java对象的内存大小

C语言需要开发者进行内存管理,所以开发者对内存的分配比较清楚,可以通过sizeof()函数获取一个变量的大小;
java通过JVM进行内存管理,它的内存占用情况是怎样的?



原始类型的大小

在java中,不管32位还是64位,原始类型的内存占用大小是确定的,如下:

|| 原始类型 || 大小 ||
|  boolean |   1   |
|  byte    |   1   |
|  short   |   2   |
|  char    |   2   |
|  int     |   4   |
|  float   |   4   |
|  long    |   8   |
|  double  |   8   |

引用的大小

java中没有指针,只有引用,引用是安全的,这个说法不错,但实际上引用也就是指针,
所以32位的地址对应的指针大小为4B(4*8bits),64位的地址对应的指针大小为8B(8*8bits)。
但是,从JDK6以后JVM都默认开启了指针压缩(JVM运行参数为-XX:+UseCompressedOops)。
开启指针压缩后引用就成了4字节,所以:

  • 32位JVM,引用为4B
  • 64位JVM开启UseCompressedOops,引用为4B
  • 64位JVM未开启UseCompressedOops,引用为8B

可通过jinfo -flag UseCompressedOops <pid>查看是否开启了指针压缩


关于对齐

CPU从内存中读取数据是以word为基本单位, 32位的系统中word宽度为32bits, 64位的系统中word宽度为64bits, 将整个Java对象占用内存补长为word的整倍数大大提高了CPU存取数据的性能。
但是在Hotspot虚拟机中,不管是32位系统还是64位系统,都是8字节对齐。


对象头部的大小

对象头的结构如下(来源):

+------------------+------------------+------------------ +---------------+
|    mark word     |   klass pointer  |  array size (opt) |    padding    |
+------------------+------------------+-------------------+---------------+

mark word

用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄(Generational GCAge)等。
在32位机中占4B,在64位机中占8B。
它是非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间。

klass pointer

用于存储指向方法区对象类型数据的指针。
在32位JVM上为4B,在未开启UseCompressedOops的64位JVM上为8B,在开启UseCompressedOops的64位机上为4B。

array size

如果该对象是数组对象的话,还会有一个额外的部分用于存储数组长度

padding

8字节对齐的填充(这个部分可被称为gap,开启压缩的时候,这个gap会尽量用一个int或者float填充)


java对象的内存分配

一个对象在内存中存储的布局可以分为三块区域:对象头(Header)、实例数据(Instance Data)、对齐填充(Padding)。
按照64位JVM默认的开启UseCompressedOops的情况说明普通对象、数组对象、字符串对象的内存分配

普通对象

普通对象的对象头中,无array size,有或者无padding,紧跟着就是对象的成员变量
网上找了个例子来说明:

public class Demo {  
    private final long L = 0x7EFFFFFFFFL;  
    private final int I = 0x7FFFFF;  
    private final int J = 0x7EFFFF;  
}
// Demo的地址分配
0x00000007d569f1c8: 0x0000000000000001
0x00000007d569f1d0: 0x007fffffef650cc4
0x00000007d569f1d8: 0x0000007effffffff
0x00000007d569f1e0: 0x00000000007effff
// 对于Demo地址分配的解释(x86是小端模式:数据的低位保存在内存的低地址中)
_mark:             0x00000007d569f1c8: 0x0000000000000001
_compressed_klass: 0x00000007d569f1d0: 0xef650cc4
I:                 0x00000007d569f1d4: 0x007fffff        // gap填充
L:                 0x00000007d569f1d8: 0x0000007effffffff
J:                 0x00000007d569f1e0: 0x007effff
(padding):         0x00000007d569f1e4: 0x00000000

说明:

  1. JDK7默认开了压缩指针
  2. 无论开不开UseCompressedOops, 64位HotSpot VMmark word都是8字节。
  3. 如果开启UseCompressedOops的话,_compressed_klass占4字节;反之则_klass占8字节。
  4. _compressed_klass要转换回到正常oop需要做一定运算,具体是什么运算取决于当前的压缩模式
  5. 这个对象头只需要8+4=12B,而long/double必须在8B对齐的地址上分配,所以中间有4字节的空隙(gap)。
  6. 这个gap可以尽可能的填充1个int/float,或者2个short/char,或者4个byte/boolean。

数组对象

数组对象的对象头中,array size将占用4B,紧跟着就是数组对象的数组元素

int o[] = {}  // 16(header) = 8(_mark) + 4(_compressed_klass) + 4(size)
byte[] o = {1, 2, 3, 4, 5};    // 24 = 16 + 1*5 + 3(padding)
object[] o = {new object()};   // 24 = 16 + 4*1 + 4(padding)

字符串对象

把字符串对象当成一个普通的对象来看,它有3个实例成员(可能不同版本jdk,String的成员不太一样,但是算法是一样的):

private final char value[];        // 一个数组的引用,4B
private int hash;                  // int 4B
private transient int hash32 = 0;  // int 4B

因此一个String对象的大小为:24 = 8(_mark) + 4(_compressed_klass) + 4 + 4 + 4(gap部分被填充)
String s = "abc" 是String的成员变量value数组指向"abc"


对象大小的计算

原始类型的大小是固定的,不需要计算;对象的大小可用java.lang.instrument.Instrumentation计算(后面再说); 但是Instrumentation#getObjectSize方法返回的大小不包括对象的成员变量所引用的对象。
我们可以遍历对象的成员变量进行递归计算,在计算时需要考虑到:

  1. 对象的static成员不需要计算(类成员在静态区)
  2. 如果两个成员变量引用的是同一个对象,则这个对象不能重复计算,即算过的不能再算
  3. 原生类型的成员变量不需要计算,如Person类有个成员变量int age, 计算Person的实例大小的时候已经算上int的大小了, 变量age不能再计算了,也没办法再计算,因为age不是引用。
  4. 享元模式的成员变量不需要计算,如Integer#valueOf()值在-128~127之间的都是享元,Enum对象都是享元。
  5. intern string不需要计算,intern string在java8之前存在于字符串常量池,位于永生代(非堆)
  6. 数组对象需要遍历每个对象进行计算
  7. 对于普通对象,如果该对象有父类,则需要计算继承自父类的成员变量

也就是说计算对象大小的时候可以分为三类: 直接跳过计算的对象(1、2、3、4、5)、数组对象(6)、普通对象(7)
我们用Stack<Object> stack来存放待计算对象(初始只有一个元素), 用IdentityHashMap记录visited的对象
完整项目代码在我的github上,这里给出代码片段:

    /**
     * 判断该对象是否需要跳过计算
     *
     * @param obj
     * @param visited
     * @return
     */
    private static boolean skipObject(Object obj, Map<Object, Object> visited) {

        return null == obj ||               // null 直接跳过
                isSharedFlyweight(obj) ||   // 享元对象 跳过
                visited.containsKey(obj);   // 计算过的对象 跳过
    }
    /**
     * 计算栈顶元素大小
     *
     * @param stack   待计算对象栈
     * @param visited 已经计算过的对象
     * @return
     */
    private static long doSizeOf(Stack<Object> stack, Map<Object, Object> visited) {
        // 获取栈顶元素
        Object obj = stack.pop();
        // 如果该对象需要跳过计算,直接返回0
        if (skipObject(obj, visited)) {
            return 0;
        }
        // 先把该对象放到已经访问过的集合中
        visited.put(obj, null);
        // 计算这个对象的大小 (object header + primitive variables + member pointers)
        long result = SizeOf.sizeOf(obj);
        // 获取对象类型
        Class clazz = obj.getClass();
        // 如果该对象是数组类型,则还需要把数组元素压栈待计算, 然后返回
        if (clazz.isArray()) {
            // 如果该数组的元素的类型是原生类型,就不用压栈了(即使压栈,也因是skipObject而被忽略)
            if (!clazz.getComponentType().isPrimitive()) {
                int length = Array.getLength(obj);
                for (int i = 0; i < length; i++) {
                    stack.add(Array.get(obj, i));  // 数组元素压栈
                }
            }
        } else { // 即不是skipObject,也不是数组,那只能是普通对象了,则对该对象的成员变量进行处理
            while (clazz != null) {
                Field[] fields = clazz.getDeclaredFields();  // 获取所有成员
                for (Field field : fields) {
                    // 只将非static成员和非primitive成员压栈
                    if (!Modifier.isStatic(field.getModifiers()) && !field.getType().isPrimitive()) {
                        field.setAccessible(true);
                        try {
                            stack.add(field.get(obj));
                        } catch (IllegalAccessException ex) {
                            throw new RuntimeException(ex);
                        }
                    }
                }
                clazz = clazz.getSuperclass();  // 继续处理父类的成员
            }
        }
        return result;
    }


编程计算对象的大小

JDK7的java.lang.instrument包中有个Instrumentation API提供了getObjectSize方法来计算对象的大小, 这个方法返回的是对象的大小,不包括其成员变量所引用的对象(sun.instrumentInstrumentationImpl实现了该接口)。 而且,这个方法不能直接使用,必须实现一个instrumentation代理类并且打包进JAR文件。

定义代理类

我们这样定义代理类:

public class SizeOf {
    /**
     * JVM将在启动时通过{@link #premain}初始化此成员变量.
     */
    private static Instrumentation instrumentation;
    /**
     * JVM会调用该函数来初始化代理类
     *
     * @param agentArgs premain 函数得到的程序参数,随同 “–javaagent:”一起传入。
     *                  eg:java -javaagent:jar 文件的位置 [= 传入 premain 的参数 ]
     * @param inst      java.lang.instrument.Instrumentation 的实例,由 JVM 自动传入
     */
    public static void premain(String agentArgs, Instrumentation inst) {
        instrumentation = inst;
    }
    /**
     * 返回对象大小,不包括其成员变量所引用的对象
     *
     * @param object 需要计算大小的对象
     * @return 对象的大小
     * @see java.lang.instrument.Instrumentation#getObjectSize(Object objectToSize)
     */
    public static long sizeOf(Object object) {
        if (instrumentation == null)
            throw new IllegalStateException("Instrumentation is null");
        return instrumentation.getObjectSize(object);  // 通过Instrumentation的实现类计算
    }
    /**
     * 返回包含引用对象在内的大小(需要我们自己计算)
     *
     * @param obj 需要计算大小的对象
     * @return 对象的全部大小
     */
    public static long fullSizeOf(Object obj) { ... }
}

将代理类打成jar包

为了让JVM知道instrumentation代理类的存在,必须将其打包进JAR文件并且设定manifest.mf文件中的属性。 在我们的例子中,需要设定如下属性:

Premain-Class: 代理类SizeOf的全类名  
Can-Redefine-Classes: true

我们可以通过maven工具进行打包,打包的时候可以指定生成manifest.mf的属性, 在pom.xml文件中可以在<build>元素中添加<plugins>元素,添加maven打包插件,就像这样:

    <build>
        <plugins>
            <!-- maven 打包插件, 可指定生成的 META-INF/manifest.mf 文件中的属性 -->
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-jar-plugin</artifactId>
                <version>2.1</version>
                <configuration>
                    <archive>
                        <!-- 生成 manifest.mf 时添加的属性 -->
                        <manifestEntries>
                            <Premain-Class>cn.loveshisong.sizeof.SizeOf</Premain-Class>
                            <Can-Redefine-Classes>true</Can-Redefine-Classes>
                        </manifestEntries>
                    </archive>
                </configuration>
            </plugin>
        </plugins>
    </build>

配置虚拟机参数,使用代理

另外,Java程序必须使用 -javaagent 参数指向该jar文件来启动。我们的例子中形如:

java -javaagent:代理类jar包的位置  
java -javaagent:sizeofag.jar (jar包在classpath中可以这么写)

我们可以在进行测试的时候,通过maven的插件来指定JVM运行参数:

<build>
        <plugins>
            <!-- 上面那个plugin不要忘了加上 -->
            <!-- 测试运行器(Test Runner), 执行到特定生命周期阶段的时候,通过该插件来执行JUnit或者TestNG的测试用例 -->
            <!-- maven-surefire-plugin 在test时默认执行 src/test/java/ 下所有名为*Test.java和*TestCase.java的测试类 -->
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-surefire-plugin</artifactId>
                <configuration>
                    <forkMode>pertest</forkMode>
                    <!-- argLine指定了VM运行参数 -->
                    <argLine>-javaagent:${basedir}/target/sizeof-${project.version}.jar</argLine>
                    <workingDirectory>${basedir}/target</workingDirectory>
                    <useSystemClassLoader>true</useSystemClassLoader>
                </configuration>
            </plugin>

        </plugins>
    </build>

这样我们在使用junit进行单元测试的时候,就已经给JVM传递了参数。


在程序中使用例子中的jar包

只有一点,在其它项目中使用这个打包好的jar包时,记得给JVM传递代理类参数。
另外,不同机器可能junit的测试会有问题,最好把源码下载下来,自己打包,必要时删掉测试代码。