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字节,所以:
UseCompressedOops
,引用为4BUseCompressedOops
,引用为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 |
+------------------+------------------+-------------------+---------------+
用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄(Generational GCAge)等。
在32位机中占4B,在64位机中占8B。
它是非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间。
用于存储指向方法区对象类型数据的指针。
在32位JVM上为4B,在未开启UseCompressedOops
的64位JVM上为8B,在开启UseCompressedOops
的64位机上为4B。
如果该对象是数组对象的话,还会有一个额外的部分用于存储数组长度
8字节对齐的填充(这个部分可被称为gap,开启压缩的时候,这个gap会尽量用一个int或者float填充)
一个对象在内存中存储的布局可以分为三块区域:对象头(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
说明:
UseCompressedOops
, 64位HotSpot VM
的mark word
都是8字节。UseCompressedOops
的话,_compressed_klass
占4字节;反之则_klass
占8字节。_compressed_klass
要转换回到正常oop需要做一定运算,具体是什么运算取决于当前的压缩模式数组对象的对象头中,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
方法返回的大小不包括对象的成员变量所引用的对象。
我们可以遍历对象的成员变量进行递归计算,在计算时需要考虑到:
int age
, 计算Person的实例大小的时候已经算上int的大小了,
变量age不能再计算了,也没办法再计算,因为age不是引用。Integer#valueOf()
值在-128~127
之间的都是享元,Enum
对象都是享元。intern string
不需要计算,intern string
在java8之前存在于字符串常量池,位于永生代(非堆)也就是说计算对象大小的时候可以分为三类: 直接跳过计算的对象(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) { ... }
}
为了让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包时,记得给JVM传递代理类参数。
另外,不同机器可能junit的测试会有问题,最好把源码下载下来,自己打包,必要时删掉测试代码。