如何计算Java对象占堆内存中的⼤⼩
李强,2018年5⽉⼊职Qunar,现任⽕车票技术部Java开发⼯程师。参与构建⽕车票业务系统的底层技术⽀持体系,个⼈
对并发编程、分布式系统等技术点感兴趣。
前⾔
在实际⼯作中,我们可能会遇到需要计算某个对象占⽤堆内存空间⼤⼩的问题。这时,就需要我们了解 Java 对象在堆
内存中的实际存储⽅式和存储格式。本⽂就针对此问题展开分析,详细介绍了 Java 对象在堆内存中的结构,从⽽让⼤
家能够⽅便的计算出对象占⽤内存的⼤⼩。注意:本⽂默认环境为 64 位操作系统,JDK 为 1.8,JVM 为 HotSpot。
Oop-Klass模型
我们知道 Java 对象在内存中的访问⽅式,如下图所⽰:
通过上图,我们可以知道⼀个 Java 实例对象由 Java 堆中的实例数据和⽅法区的类型数据两部分组成。对应到 JVM 内
部,HotSpot 设计了⼀个 Oop-Klass 的⼆分模型来表⽰⼀个 Java 实例对象的两部分:
Klass 简单来说就是 Java 类在 HotSpot 中的 C++ 对等体,主要⽤于描述对象实例的具体类型。⼀般 JVM 在加
载 class ⽂件时,会在⽅法区创建 Klass ,表⽰类的元数据,其包括常量池、字段、⽅法等。
Oop指的是 Ordinary Object Pointer(普通对象指针)。其在 Java 创建对象实例的时候创建,⽤于表⽰对象的实
例信息。也就是说,在 Java 应⽤程序运⾏中每创建⼀个 Java 对象,在 JVM 内部都会创建⼀个 Oop 对象来表⽰ Java
对象。这⾥有的同学可能会产⽣这样的疑问:C++ 也是⼀种⾯向对象的编程语⾔,HotSpot 为什么不将 Java 对象直接
映射成⼀个 C++ 对象,⽽是要拆分成两个呢?这是因为在 C++ 中有⼀个概念叫虚函数表,每⼀个 C++ 对象都会有⼀个
虚函数表。HotSpot 为了提⾼效率,复⽤虚函数表,才设计了这种 Oop-Klass 的⼆分模式。这个模型参考了 Smalltalk
,详细论证见 Wiki。
Oop
JVM 中,Oop 的共同基类型 为oopDesc 。另外根据表⽰的对象类型的不同,JVM 中具有多种 oopDesc ⼦类,每个
oopDesc 的⼦类都代表⼀个在 JVM 内部使⽤的特定对象类型。JVM 中 Oop 主要由如下⼏种类型组成:
(详细代码见 JDK1.8 源码:./src/hotspot/share/oops/ )
1.//定义Oop的抽象基类
1.//定义Oop的抽象基类
2.typedef class oopDesc* oop;
3.//表⽰⼀个Java实例对象
4.typedef class instanceOopDesc* instanceOop;
5.//定义数组Oop的抽象基类
6.typedef class arrayOopDesc* arrayOop;
7.//表⽰Java对象数组
8.typedef class objArrayOopDesc* objArrayOop;
9.//表⽰基本类型的数组
f class typeArrayOopDesc* typeArrayOop;
例如,当我们使⽤ new 创建⼀个 Java 对象实例的时候,JVM 会创建⼀个 instanceOopDesc 对象来表⽰这个 Java 对
象;同理,当我们使⽤ new 创建⼀个 Java 数组实例的时候,JVM 会创建⼀个 arrayOopDes 对象来表⽰这个数组对
象。
Klass
同 Oop ⼀样,JVM 中定义了如下的 Klass 类型:
1.//所有Klass类的基类
2.class Klass;
3.//描述⼀个Java类
4.class InstanceKlass;
5.//专有的InstanceKlass,表⽰的Klass
6.class InstanceMirrorKlass;
7.//专有的InstanceKlass,⽤于类加载器
8.class InstanceClassLoaderKlass;
9.//专有的InstanceKlass,⽤于表⽰nce的⼦类的Klass
InstanceRefKlass;
11.//描述数组类型的类的基类
ArrayKlass;
13.//描述对象数组类
ObjArrayKlass;
15.//描述基本类型数组类
TypeArrayKlass;
这⾥需要额外说明⼀点:1.8 中去掉了永久代(Perm),⽽改为了元空间(MetaSpace)。因此 JDK1.8 的 Oop-Klass 模
型与 JDK1.7 存在较⼤的区别,本⽂以 JDK1.8 为准,JDK1.7 到 1.8 的详细区别可参考:1.7到1.8 Oop-Klass模型。
Java对象在堆中的布局 源码解析
上⽂我们介绍到,JVM 中通过⼀个 instanceOopDesc 对象来表⽰⼀个 Java 实例对象。想要了解 Java 对象在内存中的
布局,只需要了解 instanceOopDesc 类的结构即可。由于 instanceOopDesc 类是 oopDesc 类的⼦类,其代码⼤部分
位于 oopDesc 类中。
我们直接来看 oopDesc 类的代码:./src/hotspot/share/oops/ 中,它包含两个数据成员:
1.class oopDesc {
2.private:
3.volatile markOop _mark;
4.union _metadata {
5.Klass* _klass;
6.narrowKlass _compresd_klass;
7.} _metadata;
8.}
1._mark _mark⽤于存储对象的 HashCode、锁标记、偏向锁、⾃旋时间、分代年龄等信息。
2._metadata _metadata是⼀个指向 Klass 的指针,是⼀个共⽤体( union )。也就是说它要么是 klass 字段要么是
compresdklass 。
compresdklass 。
当 JVM 开启了-XX:+UCompresdClassPointers( 表⽰启⽤ Klass 指针压缩选项, JVM 默认开启 )选项时使⽤
commpresdklass 来存储 Klass 引⽤了,否则使⽤ _klass 存储 Klass 引⽤。
注意:在数组相关的 Oop 类中,除了上述两个数据成员外,还有⼀个 int 类型数据成员 length ,⽤于表⽰数组的长度。
详细代码见:./src/hotspot/share/oops/。 oopDesc 类本⾝的数据成员,我们称之为对象头。除了对象头
之外,oopDesc 还需要保存 Java 对象的实例字段。这些实例字段紧跟对象头存储,其起始偏移地址可通过
instanceOopDesc 类中的函数( 位于:./src/hotspot/share/oops/)计算得出:
1.// If compresd, the offt of the fields of the instance may not be aligned.
2.static int ba_offt_in_bytes() {
3.// offt computation code breaks if UCompresdClassPointers
4.// only is true
5.return (UCompresdOops && UCompresdClassPointers) ?
6.klass_gap_offt_in_bytes() :
7.sizeof(instanceOopDesc);
8.}
9.//./src/hotspot/share/oops/
int klass_gap_offt_in_bytes() {
(has_klass_gap(), "only applicable to compresd klass pointers");
klass_offt_in_bytes() + sizeof(narrowKlass);
13.}
14.
int klass_offt_in_bytes() { return offt_of(oopDesc, _metadata._klass); }
⽰例
通过对源码的分析,我们基本弄清了 Java 对象在 JVM 内部的内存分布,下⾯我们通过⼀个例⼦来更直观地说明。 假
如有如下代码:
1.public class Model {
2.
3.public static int a = 1;
4.
5.public int b;
6.
7.public Model(int b) {
8.this.b = b;
9.}
10.
11.}
12.
static void main(String[] args) {
14.
c = 10;
modela = new Model(2);
17.
modelb = new Model(3);
19.}
那么其在内存中的布局如下图所⽰:
总结
根据上⽂的学习,我们可以总结出⼀个 Java 对象在堆内存中的布局⼤致如下所⽰:
注意:这⾥的 Padding ( 对齐填充 )是作为填充字段,为满⾜ Java 对象所占内存必须为 8 字节的倍数⽽存在的。
1.对象头:对象头是 Java 对象存储结构中最复杂的部分。它由下述⼏部分组成:
(1)mark word:在 64 位系统下⽤ 8 字节表⽰;32 位系统为 4 字节。
(2)metadata:64 位系统下,若 JVM 开启 Klass 指针压缩选项( -XX:+UCompresdClassPointers,JVM 默认开启
此选项 ),则⽤ 4 字节表⽰;若不开启指针压缩( -XX:-UCompresdOops )则⽤ 8 字节表⽰;32 位系统则使⽤ 4
字节表⽰。 指针压缩的⽬的就是为了节省内存,若有同学对具体的压缩算法感兴趣可参考:CompresdOops。
(3)Length:若当前对象为数组,那么对象头中除了上述两部分内容外,还会有 4 字节的内容⽤于表⽰存储数组的长度
信息;若当前对象不为数组,则对象头中不存在此项信息。
2.实例数据:实例数据中存储的就是当前对象的实例字段。字段的存储类型有基本类型和引⽤类型两种,它们对应的存
储⼤⼩如下图:
其中 ref 表⽰引⽤类型,引⽤类型实际上是⼀个地址指针,其在 32 位系统中,占⽤ 4 字节,64 位系统中,如果开启了
指针压缩( -XX:+UCompresdOops ,JVM 默认开启此选项 )则占⽤ 4 字节,若不开启则占⽤ 8 字节。 此外,实例
数据中的字段既包括从⽗类继承的,也包括⼦类本⾝定义的。这些字段在内存中的存储顺序会受到虚拟机分配策略参数
( FieldsAllocationStyle )和字段在 Java 源码中定义顺序的影响。HotSpot 中有三种虚拟机分配策略,见如下代码注
释(源码位置:./hotspot/share/classfile/):
1.// Rearrange fields for a given allocation style
2.if( allocation_style == 0 ) {
3.// Fields order: oops, longs/doubles, ints, shorts/chars, bytes, padded fields
4.......
5.} el if( allocation_style == 1 ) {
6.// Fields order: longs/doubles, ints, shorts/chars, bytes, oops, padded fields
7.......
8.} el if( allocation_style == 2 ) {
9.// Fields allocation: oops fields in super and sub class are together.
10.......
11.}
从中可以看出这三种策略的字段排列顺序不同:
1.策略0:oops ( Ordinary Object Pointers ,普通对象指针,也就是引⽤类型 )在基本数据类型前⾯, 其后依次是
longs/doubles, ints, shorts/chars, bytes , 最后是填充字段, 以满⾜对齐要求。
2.策略1:oops 在基本数据类型之后。
3.策略2:⽗类中的引⽤类型与⼦类中的引⽤类型放在⼀起。JVM 默认分配策略为 1 ,可通过参数 -
XX:FieldsAllocationStyle=2 ,将分配策略变更为 2 。策略 0 和策略 1 区别不⼤,都是将基本数据类型按照从⼤到⼩的
⽅式排序,这样可以降低空间开销。⽽策略 3 将⽗类和⼦类的引⽤放在⼀起可以增加 GC 效率,试想在 GC 扫描引⽤
时,由于⽗类和⼦类的引⽤连续,可能只需要扫描⼀个 cache line 即可,若⽗类和⼦类的引⽤不连续,则需要扫描多个
cache line ;另外连续的内存引⽤还可减少 OopMap 的个数,从⽽达到提⾼ GC 效率的⽬的。关于虚拟机内存分配策略
的详细代码解读可参考:jvm源码分析之oop-klass对象模型,这⾥不再展开细说。在满⾜上述的虚拟机分配策略前提条
件下,⼀般⽗类的字段会在⼦类的字段之前,但是 JVM 也提供了参数 -XX:+/-CompactFields ( 默认开启 ),来允许将⼦
类实例字段中的⼩对象插⼊到⽗类变量的缝隙中。 总体来看,实例数据的⼤⼩就是对象的各个实例字段的⼤⼩之和。
对齐填充:JVM 要求对象的⼤⼩必须是 8 字节的整数倍,因此当“对象头+实例数据”的⼤⼩不满⾜ 8 字节的整数倍时,
就需要增加填充数据,以满⾜此条件。按照 8 字节对齐,是底层 CPU 数据总线读取内存数据的要求。通常 CPU 按照
字长来读取数据,⼀个数据若不对齐则可能需要 CPU 读取两次,若进⾏了对齐,则⼀次性即可取出⽬标数据,这将会
⼤⼤节省 CPU 资源,因此对象⼤⼩需要对齐。
通过上⾯的介绍,我们基本就可以计算出⼀个 Java 对象占⽤堆内存的⼤⼩了。假如有如下代码:
1.public class People {
2.int age = 20;
3.
4.String name = "XiaoMing";
5.}
6.public class Person extends People {
7.boolean married = fal;
8.
9.long birthday = 3283520940L;
10.
tag = 'a';
12.
sallary = 4700.00d;
14.}
那么⼀个 Person 对象的堆内存⼤⼩可以计算得出:
对象头:8( mark )+4( Klass 指针 )=12
对象头:8( mark )+4( Klass 指针 )=12
实例数据:4( age )+4( name )+1( married )+8( birthday )+2( tag )+8( sallay )=27那么此时 Person 对象的⼤⼩为:
12+27+1( padding )=40; 此时计算的 name 是⼀个指向 String 对象的指针,我们还需要加上 name 对象的⼤⼩:8(
mark )+4( Klass 指针 )+4( hash )+4( value[] )+4( padding )=24; 其中 value[] 是⼀个 char 数组的指针,因此需要再加上
此数组的长度:8( mark )+4( Klass 指针 )+4( length )+8*2( 8 个char )+0( padding )=32。综上,⼀个 Person 对象占⽤堆
内存的⼤⼩为 40+24+32=96 字节。
HSDB
通过上⼀章节的学习,我们已经 get 到如何计算⼀个 Java 对象占⽤的堆内存⼤⼩,但是如何来验证我们计算的是否正
确呢?其实 HotSpot 已经为我们提供了⼀个⼯具来查询运⾏时对象的 Oops 结构,那就是 HSDB 。
1.我们在命令⾏中运⾏如下程序:
1.public static void main(String[] args) throws InterruptedException {
2.
3.Person person = new Person();
4.
5.(60*1000);
6.
7.n("end");
8.}
2.使⽤ JPS 获取运⾏的 Java 进程号,运⾏如下指令启动 HSDB sudo java -cp $JAVA_HOME/lib/
,然后选择 File->Attach to HotSpot process并输⼊进程 ID :
3.此时会显⽰对应进程的线程信息。点击Tools->Object Histogram即可打开堆的对象列表。在列表中输⼊想要查看的类
名称进⾏搜索:
双击对应的对象,然后点击 Inspect 按钮即可看到该对象的 Oop 结构信息:
4.也可以点击 Tools->Class Browr打开类信息列表,并搜索想查看类信息,也可对象中各个字段的偏移量。
代码计算内存占⽤的⽅法
前⽂我们介绍了 Java 对象在堆中的内存布局之后,就可⼿动计算出⼀个 Java 对象占⽤的堆内存⼤⼩,但是假如我们需
要在代码中计算⼀个对象的内存占⽤⼜该如何进⾏呢?这⾥总结了三种⽅式供⼤家参考:
Instrumentation
使⽤ectSize()⽅法可以⽅便地计算出⼀个运⾏时对象的⼤⼩。关于如何获取
Instrumentation 这⾥不再赘述,我们关注⼀下 getObjectSize()⽅法的注释:
1./**
2.* Returns an implementation-specific approximation of the amount of storage consumed by
3.* the specified object. The result may include some or all of the object's overhead,
4.* and thus is uful for comparison within an implementation but not between implementations.
5.*
6.* The estimate may change during a single invocation of the JVM.
7.*
8.* @param objectToSize the object to size
9.* @return an implementation-specific approximation of the amount of storage consumed by the specified object
10.* @throws interException if the supplied Object is null
.
11.*/
getObjectSize(Object objectToSize);
通过注释我们可知,此⽅法求出的值是⼀个近似值,并不准确。因此这种⽅法只适⽤于同⼀个对象多次求⼤⼩并进⾏⽐
较,不适⽤⽤两个对象之间⽐较。
使⽤Unsafe
java 中的 类,有⼀个 objectFieldOfft(Field f) ⽅法,表⽰获取指定字段在所在实例中的起始地址偏移
量。因此我们可以获取指定的对象中每个字段的偏移量,并求出其中的最⼤值。偏移量最⼤的字段肯定位于实例数据中
的最后,再使⽤该字段的偏移量加上该字段的实际⼤⼩,就能知道该对象整体的⼤⼩。 例如有如下类:
1.public class Example {
2.int size = 20;
3.String name = "XiaoMing";
4.}
使⽤如下代码计算其字段的偏移地址:
使⽤如下代码计算其字段的偏移地址:
1.public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException {
2.Field field = laredField("theUnsafe");
3.essible(true);
4.Unsafe unsafe = (Unsafe) (null);
5.
6.Example example = new Example();
7.
8.Field sizeField = ss().getDeclaredField("size");
9.long sizeAddr = FieldOfft(sizeField);
n("size offt is:" + sizeAddr);
11.
nameField = ss().getDeclaredField("name");
nameAddr = FieldOfft(nameField);
n("name offt is:" + nameAddr);
15.}
结果:
1.size offt is:12
2.name offt is:16
由上可知 name 的 offt 最⼤为 16 ,在加上其本⾝的长度 4 ( String 对象的指针 ),则 Example 对象的实际⼤⼩为:
16+4+4( Padding )=24。
但是上述这种⽅式计算的只能是对象本⾝的⼤⼩,并没有计算对象中的引⽤类型所引⽤的对象的⼤⼩。若想要获取⼀个
对象的完整⼤⼩,则还需要写代码进⾏递归计算。
使⽤第三⽅⼯具
这⾥提供⼀个第三⽅专门计算堆内存占⽤⼤⼩的⼯具类:
1.
2.
3.
4.
5.
其中 RamUsageEstimator类提供获取对象堆内存占⽤⼤⼩的⽅法,如下所⽰验证我们前⼀章节计算的对象⼤⼩:
1.public static void main(String[] args) {
2.People people = new People();
3.
4.Person person = new Person();
5.
6.n("people:"+(people));
7.n("person:"+(person));
8.
9.}
10.结果:
:80
:96
RamUsageEstimator 类主要就是根据 JVM 规范计算对象的⼤⼩,并不是根据实际的内存地址计算。因此,它存在⼀个
缺点,那就是可能存在与实际⼤⼩不符合的情况。
参考资料
1.《深⼊理解 Java 虚拟机》第2版,周志明,机械⼯业出版社;
2. Hotspot :oops,klass 与 handle;
3. 如何计算 Java 对象所占内存的⼤⼩;
4. Hotspot GC研究- 开篇&对象内存布局;
5. Java 对象内存结构。
总结
本⽂⾸先介绍了 JVM 的 Oop-Klass 模型,这是理解 Java 内存布局的基础,接下来讲解了⼀个 Java 对象在堆中的结
构,然后⼜简单介绍了如何通过 HSDB 查看对象的 Oop 结构,最后介绍了⼏种通过代码计算 Java 对象内存布局的⽅
式。 掌握对象的内存布局是解决⼯作中遇到的⼀些诸如:评估本地内存的占⽤量、定位内存泄漏 Bug 等问题的基础。
希望⼤家通过阅读本⽂能够有所收获。 本⽂参考了多⽅⾯资料,并做了⼀些总结,可能会有些不正确的地⽅敬请指正。
本文发布于:2023-05-26 04:34:00,感谢您对本站的认可!
本文链接:https://www.wtabcd.cn/zhishi/a/1685046841179303.html
版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系,我们将在24小时内删除。
本文word下载地址:如何计算Java对象占堆内存中的大小.doc
本文 PDF 下载地址:如何计算Java对象占堆内存中的大小.pdf
留言与评论(共有 0 条评论) |