摘要: 本文以如何计算Java对象占用内存大小为切入点,在讨论计算Java对象占用堆内存大小的方法的基础上,详细讨论了Java对象头格式并结合JDK源码对对象头中的协议字段做了介绍,涉及内存模型、锁原理、分代GC、OOP-Klass模型等内容。
关键词:HotspotVM、Java对象头、HSDB、锁原理、分代GC、OOP-Klass
摘要
本文以如何计算Java对象占用内存大小为切入点,在讨论计算Java对象占用堆内存大小的方法的基础上,详细讨论了Java对象头格式并结合JDK源码对对象头中的协议字段做了介绍,涉及内存模型、锁原理、分代GC、OOP-Klass模型等内容。最后推荐JDK自带的Hotspot Debug工具——HSDB,来查看对象在内存中的具体存在形式,以论证文中所述内容。
背景
目前我们系统的业务代码中大量使用了LocalCache
的方式做本地缓存,而且cache的maxSize通常设的比较大,比如10000。我们的业务系统中就使用了size为10000的15个本地缓存,所以最坏情况下将可缓存15万个对象。这会消耗掉不菲的本地堆内存,而至于实际上到底应该设多大容量的缓存、运行时这大量的本地缓存会给堆内存带来多少压力,实际占用多少内存大小,会不会有较高的缓存穿透风险,目前并不方便知悉。考虑到对缓存实际占用内存的大小能有个更直观和量化的参考,需要对运行时指定对象的内存占用进行评估和计算。
要计算Java对象占用内存的大小,首先需要了解Java对象在内存中的实际存储方式和存储格式。
另一方面,大家都了解Java对象的存储总得来说会占用JVM内存的堆内存、栈内存及方法区,但由于栈内存中存放的数据可以看做是运行时的临时数据,主要表现为本地变量、操作数、对象引用地址等。这些数据会在方法执行结束后立即回收掉,不会驻留。对存储空间空间的占用也只是执行函数指令时所必须的空间。通常不会造成内存的瓶颈。而方法区中存储的则是对象所对应的类信息、函数表、构造函数、静态常量等,这些信息在类加载时(按需)只会在方法区中存储一份,不会产生额外的存储空间。因此本文所要讨论的主要目标是Java对象对堆内存的占用。
内存占用计算方法
如果读者关心对象在JVM中的存储原理,可阅读本文后边几个小节中关于对象存储原理的介绍。如果不关心对象存储原理,而只想直接计算内存占用的话,其实并不难,笔者这里总结了三种方法以供参考:
1. Instrumentation
使用java.lang.instrument.Instrumentation.getObjectSize()
方法,可以很方便的计算任何一个运行时对象的大小,返回该对象本身及其间接引用的对象在内存中的大小。不过,这个类的唯一实现类InstrumentationImpl
的构造方法是私有的,在创建时,需要依赖一个nativeAgent,和运行环境所支持的一些预定义类信息,我们在代码中无法直接实例化它,需要在JVM启动时,通过指定代理的方式,让JVM来实例化它。
具体来讲,就是需要声明一个premain方法,它和main方法的方法签名有点相似,只不过方法名叫“premain
”,同时方法参数也不一样,它接收一个String
类型和instrumentation
参数,而String参数实际上和String[]是一样的,只不过用String统一来表达的。在premain
函数中,将instrumentation
参数赋给一个静态变量,其它地方就可以使用了。如:
/**
* @author yepei
* @date 2018/04/23
* @description
*/
public class SizeTool {
private static Instrumentation instrumentation;
public static void premain(String args, Instrumentation inst) {
instrumentation = inst;
}
public static long getObjectSize(Object o) {
return instrumentation.getObjectSize(o);
}
}
从方法名可以猜到,这里的premain是要先于main执行的,而先于main执行,这个动作只能由JVM来完成了。即在JVM启动时,先启动一个agent,操作如下:
假设main方法所在的jar包为:A.jar,premain方法所在的jar包为B.jar。注意为main所在的代码打包时,和其它工具类打包一样,需要声明一个MANIFEST.MF清单文件,如下所求:
Manifest-Version: 1.0
Main-Class: yp.tools.Main
Premain-Class: yp.tools.SizeTool
然后执行java命令执行jar文件:
java -javaagent:B.jar -jar A.jar
点评:这种方法的优点是编码简单,缺点就是必须启动一个javaagent,因此要求修改Java的启动参数。
2. 使用Unsafe
java中的sun.misc.Unsafe
类,有一个objectFieldOffset(Field f)
方法,表示获取指定字段在所在实例中的起始地址偏移量,如此可以计算出指定的对象中每个字段的偏移量,值为最大的那个就是最后一个字段的首地址,加上该字段的实际大小,就能知道该对象整体的大小。如现有一Person类:
class Person{
int age;
String name;
boolean married;
}
假设该类的一个实例p,通过Unsafe.objectFieldOffset()方法计算到得age/birthday/married三个字段的偏移量分别是16,21, 17,则表明p1对象中的最后一个字段是name,它的首地址是21,由于它是一个引用,所以它的大小默认为4(开启指针压缩),则该对象本身的大小就是21+4+ 7= 32字节。其中7表示padding,即为了使结果变成8的整数倍而做的padding。
但上述计算,只是计算了对象本身的大小,并没有计算其所引用的引用类型的最终大小,这就需要手工写代码进行递归计算了。
点评:使用Unsafe可以完全不care对象内的复杂构成,可以很精确的计算出对象头的大小(即第一个字段的偏移)及每个字段的偏移。缺点是Unsafe通常禁止开发者直接使用,需要通过反射获取其实例,另外,最后一个字段的大小需要手工计算。其次需要手工写代码递归计算才能得到对象及其所引用的对象的综合大小,相对比较麻烦。
3. 使用第三方工具
这里要介绍的是lucene提供的专门用于计算堆内存占用大小的工具类:RamUsageEstimator
,maven坐标:
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-core</artifactId>
<version>4.0.0</version>
</dependency>
RamUsageEstimator
就是根据java对象在堆内存中的存储格式,通过计算Java对象头、实例数据、引用等的大小,相加而得,如果有引用,还能递归计算引用对象的大小。RamUsageEstimator
的源码并不多,几百行,清晰可读。这里不进行一一解读了。它在初始化的时候会根据当前JVM运行环境、CPU架构、运行参数、是否开启指针压缩、JDK版本等综合计算对象头的大小,而实例数据部分则按照java基础数据类型的标准大小进行计算。思路简单,同时也在一定程度上反映出了Java对象格式的奥秘!
常用方法如下:
//计算指定对象及其引用树上的所有对象的综合大小,单位字节
long RamUsageEstimator.sizeOf(Object obj)
//计算指定对象本身在堆空间的大小,单位字节
long RamUsageEstimator.shallowSizeOf(Object obj)
//计算指定对象及其引用树上的所有对象的综合大小,返回可读的结果,如:2KB
String RamUsageEstimator.humanSizeOf(Object obj)
点评:使用该第三方工具比较简单直接,主要依靠JVM本身环境、参数及CPU架构计算头信息,再依据数据类型的标准计算实例字段大小,计算速度很快,另外使用较方便。如果非要说这种方式有什么缺点的话,那就是这种方式计算所得的对象头大小是基于JVM声明规范的,并不是通过运行时内存地址计算而得,存在与实际大小不符的这种可能性。
Java对象格式
在HotSpot虚拟机中,Java对象的存储格式也是一个协议或者数据结构,底层是用C++代码定义的。Java对象结构大致如下图所示——
即,Java对象从整体上可以分为三个部分,对象头、实例数据和对齐填充
对象头:Instance Header,Java对象最复杂的一部分,采用C++定义了头的协议格式,存储了Java对象hash、GC年龄、锁标记、class指针、数组长度等信息,稍后做出详细解说。
实例数据:Instance Data,这部分数据才是真正具有业务意义的数据,实际上就是当前对象中的实例字段。在VM中,对象的字段是由基本数据类型和引用类型组成的。其所占用空间的大小如下所示:
类型 |