Java 虚拟机 | 拿放大镜看对象

2021-01-16  本文已影响0人  彭旭锐

点赞关注,不再迷路,你的支持对我意义重大!

🔥 Hi,我是丑丑。本文 「Java 路线」导读 —— 他山之石,可以攻玉 已收录,这里有 Android 进阶成长路线笔记 & 博客,欢迎跟着彭丑丑一起成长。(联系方式在 GitHub)


目录


前置知识

这篇文章的内容会涉及以下前置 / 相关知识,贴心的我都帮你准备好了,请享用~


1. 对象的创建过程

在 Java 中创建对象的一般方式是使用 new 关键字,编译后会生成以 new 字节码指令开始的多条指令,例如:

源代码:
String str = new String();
字节码:
0 new #26 <java/lang/String>
3 dup
4 invokespecial #27 <java/lang/String.<init>>
7 astore_0

—— 图片引用自网络

提示: 这里讨论的对象是指一般的对象,即使用 new 创建的对象。

1.1 检查加载

根据常量池索引#26找到类的符号引用<java/lang/String>,并且检查类是否被类加载器加载过,如果没有需要先执行类加载过程(加载 & 解析 & 初始化)。

1.2 分配内存

1.2.1 分配方式

Java 对象需要一块连续的堆内存空间,分配方式有 指针碰撞 & 空闲列表。指针碰撞法要求 Java 堆是绝对规整的,而空闲列表法不要求 Java 堆是绝对规整的。

所有已分配内存压缩到堆的一端,剩下一端为空闲的内存,两块区域使用一个 分配指针 作为分界指示器。当需要分配对象内存时,只需要把指针向挪动与对象大小相等的距离,将该区域划分给对象。

虚拟机会维护一个列表记录哪些内存时空闲的。当需要对象内存时,需要遍历空闲列表找到一块足够大的空间划分给对象。

1.2.2 并发安全

由于 Java 堆是线程共享的,而创建对象(分配内存)的行为在虚拟机中是非常频繁的,那么就需要考虑多线程并发分配内存的问题,解决方法有:CAS 操作 & 分配缓冲

通过虚拟机参数-XX+UseTLAB来控制是否启用 TLAB 功能。

提示: TLAB 中的中的对象空间依然是所有线程共享的,只是其他线程无法在这个区域分配对象。

1.3 初始化零值

将实例数据的值初始化为零值(例如 int 为 0 ,boolean 为 false,引用类型为 null)。

1.4 设置对象头

设置对象头信息,包括 Mark Work & 类型指针 & 数组长度。

1.5 执行 <init> 构造函数

执行 <init> 构造函数,<init> 由编译器生成,包括成员变量初始值、实例代码块和对象构造函数。


2. 对象的内存布局

对象的内存布局主要包含 3 个区域:对象头 & 实例数据 & 对齐填充。其中对象头主要包含 Mark Work 标志位,如果采用「直接指针」的对象访问,那么对象头里还包含类型指针。如果是数组对象,那么对象头还包含数组的长度。实例数据区存储了「本类声明的实例字段」和「从父类继承的实例字段」(类字段存储在方法区)。

2.1 对象头(Header)

对象头包含 Mark Work & 类型指针 & 数组长度

2.1.1 Mark Work

由于对象头里的信息是与对象实例数据无关的额外存储成本,Mark Word 被设计为一个有状态的数据结构,可以根据对象的状态 复用

2.1.2 类型指针(Class Pointer)

-XX:+UseCompressedClassPointers -XX:+UseCompressedOops

2.1.3 数组长度

源码:
char [] str = new char[2];
System.out.println(ClassLayout.parseInstance(str).toPrintable());

------------------------------------------------------
JOL:
[C object internals:
 OFFSET  SIZE   TYPE DESCRIPTION        VALUE
      0     4        (object header)    01 00 00 00 (00000001 00000000 00000000 00000000) (1)
      4     4        (object header)    00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4        (object header)    41 00 00 f8 (01000001 00000000 00000000 11111000) (-134217663)
     12     4        (object header)    【数组长度:2】02 00 00 00 (00000010 00000000 00000000 00000000) (2)
     16     4   char [C.<elements>     N/A
     20     4        (loss due to the next object alignment)
Instance size: 24 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

可以看到,对象头中有一块 4 字节的区域,值为2,表示该数组长度为 2。

2.2 实例数据(Instance Data)

实例数据是对象的有效信息,可以理解为报文段中的 payload。对象的实例数据包括:

但不包括类级字段(存储在方法区)。

2.3 对齐填充(Padding)

HotSpot 虚拟机对象的大小必须按 8 字节对齐,如果对象占用空间不是 8 字节的倍数,则需要增加对齐填充数据。直观来看,“无效” 的填充数据使得对象占用空间加大,增大了虚拟机的内存消耗。那么为什么要这么做呢? Editting...

2.4 实验

JOL(Java Object Layout)是 OpenJDK 提供的用于分析对象内存布局的工具,地址:JOL。主要的局限性是只支持 HotSpot / OpenJDK 虚拟机,如果在其他虚拟机上使用会报错:

java.lang.IllegalStateException: Only HotSpot/OpenJDK VMs are supported

现在,我们使用JOL分析 new Object() 在 HotSpot 虚拟机上的内存布局:

步骤一:添加依赖
implementation 'org.openjdk.jol:jol-core:0.11'

步骤二:创建对象
Object obj = new Object();

步骤三:打印对象内存布局

1. 输出虚拟机与对象内存布局相关的信息
System.out.println(VM.current().details());
2. 输出对象内存布局信息
System.out.println(ClassLayout.parseInstance(obj).toPrintable());

输出结果如下:

# Running 64-bit HotSpot VM.
# Using compressed oop with 3-bit shift.
# Using compressed klass with 3-bit shift.
# Objects are 8 bytes aligned.
# Field sizes by type: 4, 1, 1, 2, 2, 4, 4, 8, 8 [bytes]
# Array element sizes: 4, 1, 1, 2, 2, 4, 4, 8, 8 [bytes]

java.lang.Object object internals:
 OFFSET  SIZE   TYPE DESCRIPTION        VALUE
      0     4        (object header)    01 00 00 00 (00000001 00000000 00000000 00000000) (1)
      4     4        (object header)    00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4        (object header)    e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
     12     4        (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

其中关于虚拟机的信息:

HotspotUnsafe.java

public String details() {
    // ...
    out.printf("# %-19s: %d, %d, %d, %d, %d, %d, %d, %d, %d [bytes]%n",
                "Field sizes by type",
                oopSize,
                sizes.booleanSize,
                sizes.byteSize,
                sizes.charSize,
                sizes.shortSize,
                sizes.intSize,
                sizes.floatSize,
                sizes.longSize,
                sizes.doubleSize
        );
}

3. 对象的访问定位

我们都知道 Java 的类型可以分为基础数据类型与引用类型(Reference)。对于引用类型变量,在虚拟机栈上存储的只是 Reference,而对象真正的实例数据是存储在堆上。通过 Reference 访问对象实例数据的方式分为分为 句柄访问 & 直接指针访问

3.1 句柄访问

在 Java 堆中单独划分一块区域作为句柄池,Reference 中存储是对象的句柄。句柄中存储的是对象实例数据与类型数据的地址。

句柄访问的优点是句柄中对象实例数据和类型数据的地址是稳定的,当对象在垃圾收集是被移动时,只需要修改实例数据的指针,而 Reference 本身不需要修改。

引用自《深入理解Java虚拟机(第3版本)》—— 周志明 著

3.2 直接指针访问

Reference 中存储的是指向对象的地址,对象内存中有一块是实例数据,另外有一个指针指向类型数据,这个指针就是 第 2.1.2 节 中的类型指针(Class Pointer)

直接指针访问的优点是速度更快,因为节省了一次指针的访问。由于在 Java 虚拟机中对象访问的频率非常高,所以直接指针访问的优势更明显。

引用自《深入理解Java虚拟机(第3版本)》—— 周志明 著


4. 对象的存活判断

判断对象是否为垃圾对象的方法可以分为两种:引用计数 & 可达性分析

4.1 引用计数算法(Reference Counting)

引用计数法在创建对象时额外分配一个引用计数器,用于记录指向该对象的引用个数。如果有一个新的引用指向该对象,则计数器加 1;当一个引用不再指向该对象,则计数器减 1 。当计数器的值为 0 时,则该对象为垃圾对象。

1、及时性:当对象变成垃圾后,程序可以立刻感知,马上回收;而在可达性分析算法中,直到执行 GC 才能感知;
2、最大暂停时间短:GC 可与应用交替运行。

1、计数器值更新频繁:大多数情况下,对象的引用状态会频繁更新,更新计数器值的任务会变得繁重;
2、堆利用率降低:计数器至少占用 32 位空间(取决于机器位数),导致堆的利用率降低;
3、实现复杂;
4、(致命缺陷)无法回收循环引用对象。

易错: 引用计数法是算法简单,实现较难。

4.2 可达性分析算法(Reachability Analysis)

从 GC 根节点(GC Root)为起点,根据引用关系形成引用链。当一个对象存在到 GC Root 的引用链,则为存活对象,否则为垃圾对象。在 Java 中,GC Root 主要包括:

1、Java 虚拟机栈帧中的本地变量表
2、本地方法栈中引用的对象
3、方法区类静态变量引用的对象
4、方法区常量池中引用的对象
5、同步锁(synchronized 关键字)持有的对象

1、可回收循环引用对象;
2、实现简单。

1、最大停顿时间长:在 GC 期间,整个应用停顿(stop-the-world,STW);
2、回收不及时:只有执行 GC 才能感知垃圾对象;

4.3 小结

判定方法 优点 缺点
引用计数 1、及时性
2、最大暂停时间短
1、计数器值更新频繁
2、堆利用率降低
3、实现复杂
4、无法回收循环引用对象
可达性分析 1、可回收循环引用对象
2、实现简单
1、最大停顿时间长
2、回收不及时

由于引用计数式 GC 存在「无法回收循环引用对象」 的致命缺陷,工业实现上还是追踪式 GC 占据了主流,后面我主要介绍的也是追踪式 GC。


5. 对象的引用类型

不同引用类型的作用不尽相同,这一点很多文章没有明确指出。软引用 & 弱引用提供了更加灵活地控制对象生存期的能力,而虚引用提供了感知对象垃圾回收的能力。 除了虚引用之外,Object#finalize() 也提供了感知对象被垃圾回收的能力。

引用类型 Class 作用 对象 GC 时机(不考虑 GC 策略)
强引用 / GC Root 可达就不会回收
软引用 SoftReference 灵活控制生存期 空闲内存不足以分配新对象时
弱引用 WeakReference 灵活控制生存期 每次GC
虚引用 PhantomReference 感知对象垃圾回收 每次GC

提示: 对象是否被 GC,不仅仅取决于引用类型,还取决于当次 GC 采用的策略。


6. 对象的分配策略

6.1 对象的分配区域

几乎所有对象都分配在 Java 堆,除此之外还可以分配在:

6.2 逃逸分析

逃逸分析(Escape Analysis)是分析对象的引用是否逃逸到当前栈帧或者其它线程,如果一个对象不会逃逸,则可以直接在栈上分配,而不是分配在 Java 堆。当对象在栈上分配时,当前方法结束之后对象的生命周期也结束了,不需要参与垃圾回收,可以提高虚拟机的执行效率。

通过JVM参数可指定是否开启逃逸分析:-XX:+DoEscapeAnalysis

6.3 对象的分配原则

大多数情况下,新生对象在 Eden 区分配,当 Eden 区没有足够空间时,虚拟机发起一次 Minor GC。

大对象占用内容较多,如果分配在 Eden 区的话,容易提前发生垃圾回收,同时 GC 的时候也会大量复制内存,所以大对象直接在 Tenured 区分配。

在对象头中有一个字段标记对象的年龄,如果对象经过一次 Minor GC 之后依然存活,并且 Survivor 区能够容纳的话,那么对象会被复制到 Survivor 区,并且对象的年龄加 1。当对象的年龄增加到一定程度时,就是晋升到 Tenured 区。


参考资料


创作不易,你的「三连」是丑丑最大的动力,我们下次见!

上一篇下一篇

猜你喜欢

热点阅读