zl程序教程

您现在的位置是:首页 >  后端

当前栏目

Java Cache-EHCache系列之计算实例占用的内存大小(SizeOf引擎)

JAVA实例引擎计算 系列 占用 cache EhCache
2023-09-14 08:59:40 时间
在EHCache中,可以设置maxBytesLocalHeap、maxBytesLocalOffHeap、maxBytesLocalDisk值,以控制Cache占用的内存、磁盘的大小(注:这里Off Heap是指Element中的值已被序列化,但是还没写入磁盘的状态,貌似只有企业版的EHCache支持这种配置;而这里maxBytesLocalDisk是指在最大在磁盘中的数据大小,而不是磁盘文件大小,因为磁盘文中有一些数据是空闲区),因而EHCache需要有一种机制计算一个类在内存、磁盘中占用的字节数,其中在磁盘中占用的字节大小计算比较容易,只需要知道序列化后字节数组的大小,并且加上一些统计信息,如过期时间、磁盘位置、命中次数等信息即可,而要计算一个对象实例在内存中占用的大小则要复杂一些。

计算一个实例内存占用大小思路
在Java中,除了基本类型,其他所有通过字段包含其他实例的关系都是引用关系,因而我们不能直接计算该实例占用的内存大小,而是要递归的计算其所有字段占用的内存大小的和。在Java中,我们可以将所有这些通过字段引用简单的看成一种树状结构,这样就可以遍历这棵树,计算每个节点占用的内存大小,所有这些节点占用的内存大小的总和就当前实例占用的内存大小,遍历的算法有:先序遍历、中序遍历、后序遍历、层级遍历等。但是在实际情况中很容易出现环状引用(最简单的是两个实例之间的直接引用,还有是多个实例构成的一个引用圈),而破坏这种树状结构,而让引用变成图状结构。然而图的遍历相对比较复杂(至少对我来说),因而我更愿意把它继续看成一颗树状图,采用层级遍历,通过一个IdentitySet纪录已经计算过的节点(实例),并且使用一个Queue来纪录剩余需要计算的节点。算法步骤如下:
1. 先将当前实例加入Queue尾中。
2. 循环取出Queue中的头节点,计算它占用的内存大小,加到总内存大小中,并将该节点添加到IdentitySet中。
3. 找到该节点所有非基本类型的子节点,对每个子节点,如果在IdentityMap中没有这个子节点的实例,则将该实例加入的Queue尾。
4. 回到2继续计算直到Queue为空。
剩下的问题就是如何计算一个实例本身占用的内存大小了。这个以我目前的经验,我只能想到遍历一个实例的所有实例字段,根据每个字段的类型来判断每个字段占用的内存大小,然后它们的和就是该实例占用的总内存的大小。对于字段的类型,首先是基本类型字段,byte、boolean占一个字节,short、char占2个字节,int、float占4个字节,double占8个字节等;然后是引用类型,对类型,印象中虚拟机规范中没有定义其大小,但是一般来说对32位系统占4个字节,对64位系统占8个字节;再就是对数组,基本类型的数组,byte每个元素占1个字节,short、char每个元素占2个字节,int每个元素占4个字节,double每个元素占8个字节,引用类型的数组,先计算每个引用元素占用的字节数,然后是引用本省占用的字节数。
以上是我对EHCache中计算一个实例逻辑不了解的时候的个人看法,那么接下来我们看看EHCache怎么来计算。

Java对象内存结构(以Sun JVM为例)
参考:http://www.importnew.com/1305.html,之所以把参考链接放在开头是因为下面基本上是对链接所在文章的整理,之所以要整理一遍,一是怕原链接文章消失,二则是为了加深自己的理解。
在Sun JVM中,除数组以外的对象都有8个字节的头部(数组还有额外的4个字节头部用于存放长度信息),前面4个字节包含这个对象的标识哈希码以及其他一些flag,如锁状态、年龄等标识信息,后4个字节包含一个指向对象的类实例(Class实例)的引用。在这头部8个字节之后的内存结构遵循一下5个规则:
规则1: 任何对象都是以8个字节为粒度进行对齐的。
比如对一个Object类,因为它没有任何实例,因而它只有8个头部直接,则它占8个字节大小。而对一个只包含一个byte字段的实例,它需要填上(padding)7个字节的大小,因而它占16个字节,典型的如一个Boolean实例要占用16个字节的内存!
class MyClass {
    byte a;
}
[HEADER:    8 bytes] 8
[a:             1 byte ] 9
[padding:    7 bytes] 16 规则2: 类属性按照如下优先级进行排列:长整型和双精度类型;整型和浮点型;字符和短整型;字节类型和布尔类型;最后是引用类型。这些属性都按照各自的单位对齐。
在Java对象内存结构中,对象以上述的8个字节的头部开始,然后对象属性紧随其后。为了节省内存,Sun VM并没有按照属性声明时顺序来进行内存布局,而是使用如下顺序排列:
1. 双精度型(double)和长整型(long),8字节。
2. 整型(int)和浮点型(float),4字节。
3. 短整型(short)和字符型(char),2字节。
4. 布尔型(boolean)和字节型(byte),2字节。
5. 引用类型。
并且对象属性总是以它们的单位对齐,对于不满4字节的数据类型,会填充未满4字节的部分。之所以要填充是出于性能考虑:因为从内存中读取4字节数据到4字节寄存器的动作,如果数据以4字节对齐的情况小,效率要高的多。
class MyClass {
    byte a;
    int c;
    boolean d;
    long e;
    Object f;
}
//如果JVM不对其重排序,它要占40个字节
[HEADER:    8 bytes] 8
[a:             1 byte ] 9
[padding:    3 bytes] 12
[c:             4 bytes] 16
[d:             1 byte ] 17
[padding:    7 bytes] 24
[e:             8 bytes] 32
[f:              4 bytes] 36
[padding:     4 bytes] 40
//经JVM重排序后,只需要占32个字节
[HEADER:       8 bytes] 8
[e:                8 bytes] 16
[c:                4 bytes] 20
[a:                1 byte ] 21
[d:                1 byte ] 22
[padding:       2 bytes] 24
[f:                4 bytes] 28
[padding:       4 bytes] 32 规则3: 不同类继承关系中的成员不能混合排列。首先按照规则2处理父类中的成员,接着才是子类的成员。
class A {
    long a;
    int b;
    int c;
}
class B extends A {
    long d;
}
[HEADER:      8 bytes] 8
[a:               8 bytes] 16
[b:               4 bytes] 20
[c:               8 bytes] 32 规则4: 当父类最后一个属性和子类第一个属性之间间隔不足4字节时,必须扩展到4个字节的基本单位。
class A {
    byte a;
}
class B extends A {
    byte b;
}
[HEADER:    8 bytes] 8
[a:             1 byte ] 9
[padding:    3 bytes] 12
[b:             1 byte ] 13
[padding:    3 bytes] 16 规则5: 如果子类第一个成员时一个双精度或长整型,并且父类没有用完8个字节,JVM会破坏规则2,按整型(int)、短整型(short)、字节型(byte)、引用类型(reference)的顺序向未填满的空间填充。
class A {
    byte a;
}
class B extends A {
    long b;
    short c;
    byte d;
}
[HEADER:    8 bytes] 8
[a:             1 byte ] 9
[padding:    3 bytes] 12
[c:             2 bytes] 14
[d:             1 byte ] 15
[padding:    8 bytes] 24 数组内存布局
数组对象除了作为对象而存在的头以外,还存在一个额外的头部成员用来存放数组的长度,它占4个字节。
//三个元素的字节数组
[HEADER:    12 bytes] 12
[[0]:             1  byte ] 13
[[1]:              1 byte ] 14
[[2]:              1 byte ] 15
[padding:      1 byte ] 16
//三个元素的长整型数组
[HEADER:     12 bytes] 12
[padding:     4 bytes ] 16
[[0]:               8 bytes] 24
[[1]:               8 bytes] 32
[[2]:               8 bytes] 40 非静态内部类
非静态内不累它又一个额外的“隐藏”成员,这个成员时一个指向外部类的引用变量。这个成员是一个普通引用,因此遵循引用内存布局的规则。因此内部类有4个字节的额外开销。

EHCache计算一个实例占用的内存大小
EHCache中计算一个实例占用内存大小的基本思路和以上类似:遍历实例数上的所有节点,对每个节点计算其占用的内存大小。不过它结构设计的更好,而且它有三种用于计算一个实例占用内存大小的实现。我们先来看这三种用于计算一个实例占用内存大小的逻辑:
ReflectionSizeOf
使用反射的方式计算计算一个实例占用的内存大小就是我上面想到的这种方法。

因为使用反射计算一个实例占用内存大小的根据不同虚拟机的特性是来判断一个实例的各个字段占用的大小以及该实例存储额外信息占用的大小,因而EHCache中采用JvmInformation枚举类型来抽象这种对不同虚拟机实现的不同:
JVM Desc PointerSize JavaPointerSize MinimumObjectSize ObjectAlignment ObjectHeaderSize FieldOffsetAdjustment AgentSizeOfAdjustment
MinimumObjectSize default equals ObjectAligment
ObjectHeaderSize default: PointerSize + JavaPointerSize
FIeldOffsetAdjustment default: 0
AgentSizeOfAdjustment default: 0
ReferenceSize equals JavaPointerSize
ArrayHeaderSize: ObjectHeaderSize + 4(INT Size)
JRockit and IBM JVM do not support ReflectionSizeOf

而对基本类型,则因为虚拟机的规范,它们都是相同的,EHCache中采用PrimitiveType枚举类型来定义不同基本类型的长度:
enum PrimitiveType {
    BOOLEAN(boolean.class, 1),
    BYTE(byte.class, 1),
    CHAR(char.class, 2),
    SHORT(short.class, 2),
    INT(int.class, 4),
    FLOAT(float.class, 4),
    DOUBLE(double.class, 8),
    LONG(long.class, 8);

    private Class ?  type;
    private int size;

    public static int getReferenceSize() {
        return CURRENT_JVM_INFORMATION.getJavaPointerSize();
    }
    public static long getArraySize() {
        return CURRENT_JVM_INFORMATION.getObjectHeaderSize() + INT.getSize();
    }
反射计算一个实例(instance)占用内存大小(size)步骤如下:
a. 如果instance为null,size为0,直接返回。
b. 如果instance是数组类型,size为数组头部大小+每个数组元素占用大小*数组长度+填充到对象对齐最小单位,最后保证如果size要比对象最小大小大过相等。
c. 如果instance是普通实例,size初始值为对象头部大小,然后找到对象对应类的所有继承类,从最顶层类开始遍历所有类(规则3),对每个类,纪录长整型和双精度型、整型和浮点型、短整型和字符型、布尔型和字节型以及引用类型的非静态字段的个数。如果整型和双精度型字段个数不为0,且当前size没有按长整型的大小对齐(规则5),选择部分其他类型字段排在长整型和双精度型之前,直到填充到以长整型大小对齐,然后按照先规则2的顺序排列个字计算不同类型字段的大小。在每个类之间如果没有按规定大小对齐,则填充缺少的字节(规则4)。在所有类计算完成后,如果没有按照类的对齐方式,则按类对齐规则对齐(规则1)。最后保证一个对象实例的大小要一个对象最小大小要大或相等。

UnsafeSizeOf中
UnsafeSizeOf的实现比反射的实现要简单的多,它使用Sun内部库的Unsafe类来获取字段的offset值来计算一个类占用的内存大小(个人理解,这个应该只支持Sun JVM,但是怎么JRockit中有对FieldOffsetAdjustment的配置,而该方法只在这个类中被使用。。。)。对数组,它使用Unsafe.arrayBaseOffset()方法返回数组头大小,使用Unsafe.arrayIndexScale()方法返回一个数组元素占用的内存大小,其他计算和反射机制类似。这里在最后计算填充前有对FieldOffsetAdjustment的调整,貌似在JRockit JVM中使用到了,不了解为什么它需要这个调整。对实例大小的计算也比较简单,它首先遍历当前类和父类的所有非静态字段,通过Unsafe.objectFieldOffset()找到最后一个字段的offset,根据之前Java实例内存结构,要找到最后一个字段,只需从当前类到最顶层父类遍历第一个有非静态字段的类的所有非静态字段即可。在找到最后一个字段的offset以后也需要做FieldOffsetAdjustment调整,之后还需要加1(因为有对象对齐大小对齐,因而通过加1而避免考虑最后一个字段类型的问题,很巧妙的代码!)。最后根据规则以对对象以对象对齐大小对齐。

AgentSizeOf
在Java 1.5以后,提供了Instrumentation接口,可以调用该接口的getObjectSize方法获取一个对象实例占用的内存大小。对Instrumentation的机制不熟,但是从EHCache代码的实现角度上,它首先需要有一个sizeof-agent.jar的包(包含在net.sf.ehcache.pool.sizeof中),在该jar包的MANIFEST.MF文件中指定Premain-Class类,这个类实现两个静态的premain、agentmain方法。在实际运行时,EHCache会将sizeof-agent.jar拷贝到临时文件夹中,然后调用Sun工具包中的VirtualMachine的静态attach方法,获取一个VirtualMachine实例,然后调用其实例方法loadAgent方法,传入sizeof-agent.jar文件全路径,即可将一个SizeOfAgent类附着到当前实例中,而我们就可以通过SizeOfAgent类来获取它的Instrumentation实例来计算一个实例的大小。

我们可以使用一下一个简单的例子来测试一下各种不同计算方法得出的结果: 
public class EhcacheSizeOfTest {
    public static void main(String[] args) {
        MyClass ins = new MyClass();
        
        System.out.println("ReflectionSizeOf: " + calculate(new ReflectionSizeOf(), ins));
        System.out.println("UnsafeSizeOf: " + calculate(new UnsafeSizeOf(), ins));
        System.out.println("AgentSizeOf: " + calculate(new AgentSizeOf(), ins));
    }
    
    private static long calculate(SizeOf sizeOf, Object instance) {
        return sizeOf.sizeOf(instance);
    }
    
    public static class MyClass {
        byte a;
        int c;
        boolean d;
        long e;
        Object f;
    }
}
//输出结果如下(问题:这里的JVM是64-Bit HotSpot JVM with Compressed OOPs,它的实例头部占用了12个字节大小,但是它占用内存的大小还是和32位的大小一样,这是为什么?):
[31 23:21:19,598 INFO ] [main] sizeof.JvmInformation - Detected JVM data model settings of: 64-Bit HotSpot JVM with Compressed OOPs
ReflectionSizeOf: 32
UnsafeSizeOf: 32
[31 23:26:52,479 INFO ] [main] sizeof.AgentLoader - Located valid tools.jar at C:\Program Files\Java\jdk1.7.0_25\jre\..\lib\tools.jar
[31 23:26:52,729 INFO ] [main] sizeof.AgentLoader - Extracted agent jar to temporary file C:\Users\DINGLE~1\AppData\Local\Temp\ehcache-sizeof-agent6171098352070763093.jar
[31 23:26:52,729 INFO ] [main] sizeof.AgentLoader - Trying to load agent @ C:\Users\DINGLE~1\AppData\Local\Temp\ehcache-sizeof-agent6171098352070763093.jar
AgentSizeOf: 32
Deep SizeOf计算
EHCache中的SizeOf类中还提供了deepSize计算,它的步骤是:使用ObjectGraphWalker遍历一个实例的所有对象引用,在遍历中通过使用传入的SizeOfFilter过滤掉那些不需要的字段,然后调用传入的Visitor对每个需要计算的实例做计算。
ObjectGraphWalker的实现算法和我之前所描述的类似,稍微不同的是它使用了Stack,我更倾向于使用Queue,只是这个也只是影响遍历的顺序,这里有点深度优先还是广度优先的味道。另外,它抽象了SizeOfFilter接口,可以用于过滤掉一些不想用于计算内存大小的字段,如Element中的key字段。SizeOfFilter提供了对类和字段的过滤:
public interface SizeOfFilter {
    // Returns the fields to walk and measure for a type
    Collection Field  filterFields(Class ?  klazz, Collection Field  fields);
    // Checks whether the type needs to be filtered
    boolean filterClass(Class ?  klazz);
SizeOfFilter的实现类可以用于过滤过滤掉@IgnoreSizeOf注解的字段和类,以及通过net.sf.ehcache.sizeof.filter系统变量定义的文件,读取其中的每一行为包名或字段名作为过滤条件。最后,为了性能考虑,它对一些计算结果做了缓存。

ObjectGraphWalker中,它还会忽略一些系统原本就存在的一些静态变量以及类实例,所有这些信息都定义在FlyweightType类中。

SizeOfEngine类
SizeOfEngine是EHCache中对使用不同方式做SizeOf计算的抽象,如在计算内存中对象的大小需要使用SizeOf类来实现,而计算磁盘中数据占用的大小直接使用其size值即可,因而在EHCache中对SizeOfEngine有两个实现:DefaultSizeOfEngine和DiskSizeOfEngine。对DiskSizeOfEngine比较简单,其container参数必须是DiskMarker类型,并且直接返回其size字段即可;对DefaultSizeOfEngine,则需要配置SizeOfFilter和SizeOf子类实现问题,对SizeOfFilter,它会默认加入AnnotationSizeOfFilter、使用builtin-sizeof.filter文件中定义的类、字段配置的ResourceSizeOfFilter、用户通过net.sf.ehcache.sizeof.filter配置的filter文件的ResourceSizeOfFilter;对SizeOf的子类实现问题,它优先选择AgentSizeOf,如果不支持则使用UnsafeSizeOf,最后才使用ReflectionSizeOf。
public interface SizeOfEngine {
    Size sizeOf(Object key, Object value, Object container);
    SizeOfEngine copyWith(int maxDepth, boolean abortWhenMaxDepthExceeded);
还可以参考 http://www.javamex.com/tutorials/memory/


Google Aviator——轻量级 Java 表达式引擎实战 Drools(JBoss Rules )是一个开源业务规则引擎,符合业内标准,速度快、效率高。业务分析师或审核人员可以利用它轻松查看业务规则,从而检验是否已编码的规则执行了所需的业务规则。
Java 嵌入数据处理引擎:SQLite 的挑战者 esProc SPL SQLite可以方便地嵌入Java,但数据源加载繁琐,计算能力不足,无法独立完成业务逻辑,架构上弱点颇多。SPL也很容易嵌入Java,且直接支持更多数据源,计算能力更强,流程处理方便,可独立实现业务逻辑。SPL还提供了多种优化体系结构的手段,代码既可外置也可内置于Java,支持解释执行和热切换,可进行高性能内存计算。
java脚本引擎的设计原理浅析 本人在阿里巴巴长期担任和负责规则引擎、流程引擎相关的技术开发,另外还负责开发和维护开源项目:https://github.com/alibaba/QLExpress QLExpress是一个脚本引擎工具,类似Groovy,JRuby等,是为了解决当时电商规则动态编译、表达式高精度计算、复杂布尔运算、自定义函数和操作符号、语法树生成等需求而设计的。