写点什么

JVM GC 机制

用户头像
Geek_571bdf
关注
发布于: 1 小时前

1. 垃圾与垃圾回收、引用计数法。

Java 语言的一大特性是,不需要由开发人员手动回收内存,而是交由 JVM 的垃圾回收器来自动回收。实现了自动内存管理。

 

所谓垃圾回收,就是将已分配出去但却不再使用的内存回收回来,重新使用。所谓垃圾指的是,死亡的对象所占据的堆空间。

 

如何判断一个对象是否死亡?

当一个对象不再被任何引用所引用时。引用计数法:为每个对象添加一个引用计数器。如果一个引用被赋值为某一对象,那该对象的引用计数器+1,如果该引用指向其它对象,则该对象的引用计数器-1。一旦某个对象的引用计数器为 0,则说明该对象已经死亡。

 

引用计数法的问题是:无法处理循环引用对象。如下所示,①和②对象本该被回收,但由于它们的引用计数器不为 0(③、④造成),因此造成内存泄露(期望被回收的内存对象没有被回收,注意区别于内存溢出 OutOfMemory)。

MyObject o1 = new MyObject();// ①MyObject o2 = new MyObject();// ②o1.ref = o2;// ③o2.ref = o1;// ④o1 = null;o2 = null;
复制代码

2. 可达性分析算法。

Java 虚拟机的主流垃圾回收器采取的是可达性分析算法。

可达性分析算法的实质是,将一系列称为“GC Roots”的根对象作为初始的存活对象集合。然后从该集合出发,探索所有能够被该集合引用到的对象,我们对这些对象进行标记(mark),并将其加入到集合中(//递归过程)。最终没有被标记的对象就是可回收对象。

 

GC Roots 可以简单把它理解为:由堆外指向堆内的引用。一般而言,GC Roots 包括(但不限于)如下几种:

1.        Java 方法栈桢中的局部变量;

2.        方法区的静态变量;

3.        在本地方法栈中 JNI(即通常所说的 Native 方法)引用的对象。

已启动且未停止的 Java 线程。


可以看到,可达性分析算法可以解决循环引用问题。因为可达性分析无法到达 o1、o2,因此可达性分析不会将它们加入存活对象合集之中。

 

1. stop-the-world。

多线程环境下执行可达性分析(GC)时,有 2 个问题:

l  误报:如果已经被标记为存活的对象,但紧接着其它线程将其引用设置为 null,导致本该回收的对象没有得到回收。

l  漏报:其它线程将引用更新为 GC 判断为“死亡”的对象,也就是说,该对象仍被使用,但垃圾回收器可能将其回收。

 

误报和漏报本质是一个多线程问题,虚拟机采用“stop-the-world”的方式解决上述问题,即,停止除垃圾回收线程的工作。可以看到,GC 会产生 GC Pause。

虚拟机通过安全点(safepoint)机制实现 Stop-the-world。具体说,当发生 GC 时,GC 线程会等待所有其它线程都到达安全点,再由 GC 线程进行独占的工作。

 

2. 理解安全点。

安全点的初始目的:让线程找到一个稳定的执行状态。在这个执行状态下,Java 虚拟机的堆栈不会发生变化。这么一来,垃圾回收器便能够“安全”地执行可达性分析。

 

Java 线程有四种执行状态,分别是:

①Java 程序通过 JNI 执行本地代码;

②解释执行字节码;

③执行即时编译器生成的机器码;

④线程阻塞。

 

其中,阻塞的线程处于 Java 虚拟机线程调度器的掌控之下,因此属于安全点。

 

1. Java 程序通过 JNI 执行本地代码

当 Java 程序通过 JNI 执行本地代码时,如果这段代码不访问 java 对象、不调用 Java 方法、不返回至原 Java 方法,那么 Java 虚拟机的堆栈不会发生改变,那么我们就可以将这段本地代码看做是同一个安全点。只要不离开这个安全点,Java 虚拟机便能够在垃圾回收的同时,继续运行这段本地代码。

 

由于本地代码需要通过 JNI 的 API 来完成上述三个操作(访问 Java 对象、调用 Java 方法、返回至原 Java 方法),因此虚拟机仅需在 API 的入口处进行安全点检测,便可以在必要时挂起当前线程。

 

2. 解释执行字节码、执行即时编译器生成的机器码。

对于解释执行字节码、执行即时编译器生成的机器码,虚拟机需要保证在可预见的时间内进入安全点。否则会变相地提高 GC pause。

 

对于解释执行来说,字节码与字节码之间皆可作为安全点。Java 虚拟机采取的做法是,当有安全点请求时,执行一条字节码便进行一次安全点检测。

 

执行即时编译器生成的机器码。由于这些代码直接运行在底层硬件之上,不受 Java 虚拟机掌控,因此在生成机器码时,即时编译器需要插入安全点检测,避免机器码长时间没有安全点检测的情况。HotSpot 虚拟机的做法是在生成代码的方法出口以及非计数循环的循环回边(back-edge)处插入安全点检测。

 

3. 安全点做了哪些事?

安全点至少要做:①挂起线程;②记录 GC 时所需要的信息。

 

1. 挂起线程:机器码中的安全点检测简化为一个内存访问操作。在有安全点请求的情况下,Java 虚拟机会将安全点检测访问的内存所在的页设置为不可读,并且定义一个 segfault 处理器,来截获因访问该不可读内存而触发 segfault 的线程,并将它们挂起。

 

2. 记录 GC 时所需要的信息。

l  生成对应的 OopMap:在 GC 时,当用户线程停顿下来后,并不需要一个不漏地检查完所有执行上下文和全局的引用位置。虚拟机通过一组称为 OopMap 的数据结构来直接得到哪些地方存放着对象引用。// 安全点会记录这些 GC 信息,因此 GC 时需要等待其它线程进入安全点时才能开始。

l  在即时编译过程中,也会在特定的位置记录下栈里和寄存器里哪些位置是引用。

这样收集器在扫描时就可以直接得知这些信息了,并不需要真正一个不漏地从方法区等 GC Roots 开始查找。

 

4. 综上,安全点位置的选取原则是:

在可接受的性能开销以及内存开销之内,避免机器码长时间不进入安全点的情况,间接地减少垃圾回收的暂停时间。

其中,“长时间执行”的最明显特征就是指令序列的复用,例如方法调用、循环跳转、异常跳转等都属于指令序列复用,所以,只有具有这些功能的指令才会产生安全点。

而内存开销即记录 GC 信息的开销。

 

3. 分代思想。

1. JVM 的分代回收思想是基于这样的假设:大部分的 Java 对象只存活一小段时间,而存活下来的那小部分 Java 对象则会存活很长一段时间。

 

基于分代回收思想,堆空间分为新生代和老年代,分代的好处是,JVM 可以对不同带使用不同的回收算法。

1.        对于新生代,我们猜测大部分的 Java 对象只存活一小段时间,那么便可以频繁地采用耗时较短的垃圾回收算法,让大部分的垃圾都能够在新生代被回收掉。

2.        对于老年代,我们猜测大部分的垃圾已经在新生代中被回收了,而在老年代中的对象有大概率会继续存活。当真正触发针对老年代的回收时,则代表这个假设出错了,或者堆的空间已经耗尽了。这时候,Java 虚拟机往往需要做一次全堆扫描,耗时也将不计成本。(当然,现代的垃圾回收器都在并发收集的道路上发展,来避免这种全堆扫描的情况。)


4. 新生代的划分。

eden n. 伊甸园(《圣经》中亚当和夏娃最初居住的地方)

survivor /səˈvaɪvə(r)/ n. 幸存者

新生代分为 Eden 区和 2 个大小相同的 Survivor 区。new 指令生成的对象被分配在 Eden 区。当 Eden 区耗尽时,会触发 Minor GC,来回收新生代的垃圾。此时,Eden 区存活下来的对象以及 from 指针指向的 Survivor 区中的对象,会被一起复制到 to 指针指向的 Survivor 区,然后交换两个指针。确保 to 指针指向的 Survivor 区一直是空的。

 

默认情况下,JVM 会 根据生成对象的速率,以及 Survivor 区的使用情况动态调整 Eden 区和 Survivor 区的比例。(-XX:+UsePSAdaptiveSurvivorSizePolicy)

 

也可以使用参数 -XX:SurvivorRatio 来固定这个比例。不过需要注意的是,由于其中一个 Survivor 区会是一直为空。因此,比例越低堆内存空间的浪费得就越多(Eden:Survivor)ratio /ˈreɪʃiəʊ/ n. 比率,比例

 

5. 新生代的对象什么时候晋升至老年代?

JVM 会记录 Survivor 区中的对象一共被来回复制了几次。如果一个对象被复制的次数为 15(对应虚拟机参数 -XX:+MaxTenuringThreshold),那么该对象将被晋升至老年代。

此外,如果单个 Survivor 区已经被占用了 50%(对应虚拟机参数 -XX:TargetSurvivorRatio),那么较高复制次数的对象也会被晋升至老年代。

 

6. TLAB。

由于堆是线程共享的,因此当我们使用 new 指令在堆中划分空间时,需要进行同步操作。

虚拟机的解决方案是 TLAB (Thread Local Allocation Buffer)。即,为每个线程预先申请一块内存块,并且只允许线程在自己的内存空间里开辟对象。

具体说,每个线程可以向 Java 虚拟机申请一段连续的内存,比如 2048 byte,作为线程私有的 TLAB。这个操作是加锁的,线程会维护两个指针,一个指向 TLAB 中空余内存的起始位置,一个指向 TLAB 末尾。

当发生 new 指令时,便可以通过指针加法来分配空间。即把指向空余内存位置的指针加上所请求的字节数,如果加法后空余内存指针的值仍小于或等于指向末尾的指针,则代表分配成功。否则,线程会申请更多的内存空间。

 

7. 可以看到 Minor GC 应用的是标记-复制算法// 可达性分析算法-垃圾回收的方式。即,先将 Survivor 区中的老存活对象晋升到老年代,然后将剩下的存活对象和 Eden 区的存活对象复制到另一个 Survivor 区中。理想情况下,Eden 区中的对象基本都死了,需要复制的数据将非常少。因此,采用标记-复制算法的效果很好。

 

8. 常见垃圾回收的方式:

当标记完所有的存活对象时,我们便可以进行死亡对象的回收工作了,常见垃圾回收的方式:复制、压缩、清除

l  清除(sweep),清除的做法是,将死亡对象所占据的内存标记为空闲内存,并记录在一个空闲列表 free list 中。当需要新建对象时,内存管理模块会从该空闲列表中寻找适合的空闲内存,划分给新建对象。此方法虽然原理简单,但有两个缺点。一是,它会造成内存碎片。我们知道,JVM 的堆中的对象必须是连续分布的,因此有可能出现总空闲内存足够,但无法分配的情况。二是,分派效率低。如果是一块连续的内存空间,那我们可以通过指针加法来做分配。而对于空闲列表,JVM 则需要逐个访问列表中的项,以此查找合适的内存区域。

l  压缩(compact),压缩的做法是,将存活对象聚集到内存的起始地址,从而留下一段连续的内存空间。此方法能解决内存碎片的问题,但代价是压缩算法的性能开销。

l  复制(copy),复制的做法是,将内存区域两等分,分别用指针 from 和指针 to 来维护。并且,只使用 from 指针指向的内存区域来分配内存。当发生垃圾回收时,便把存活的对象复制到 to 指针指向的内存区域中,同时交换 from 指针与 to 指针。此方法也能解决内存碎片的问题,但缺点是:堆空间的使用效率低。

 

在 Java 虚拟机中的垃圾回收算法的具体实现,综合了上述几种回收方式。吸收了它们各自的优点,同时规避的了它们的缺点。

 

9. 卡表。

1. 在 Minor GC 时,老年代对象也可能引用新生代对象。那岂不是在 Minor GC 时,要进行一次全堆扫描。HotSpot 使用卡表解决这个问题。卡表是为了处理 minor gc 时老年代对新生代的引用,避免全堆扫描。

 

2. 卡表的思路是,将整个堆划分为一个个大小为 512 字节的卡,并且维护一个卡表,用来存储每张卡的一个标识位。这个标识位代表对应的卡是否可能存有指向新生代对象的引用。如果可能存在,我们就认为这张卡是脏的。

 

可以看到,有了卡表之后,在进行 Minor GC 时,我们只需要在卡表中寻找脏卡,并将脏卡中的对象加入到 GC Roots 里,不需要扫描整个老年代。当完成所有脏卡的扫描之后,Java 虚拟机便会将所有脏卡的标识位清零。

 

3. 由于 Minor GC 伴随着存活对象的复制,而复制需要更新指向该对象的引用(堆内存地址变了)。因此,在更新引用的同时,我们又会设置引用所在的卡的标识位。这个时候,我们可以确保脏卡中必定包含指向新生代对象的引用。但在 Minor GC 之前,我们并不能确保脏卡中包含指向新生代对象的引用。

 

其原因和如何设置卡的标识位有关。

首先,如果想要保证每个可能有指向新生代对象引用的卡都被标记为脏卡,那么 Java 虚拟机需要截获每个引用型实例变量的写操作,并作出对应的写标识位操作。这个操作在解释执行器中比较容易实现。但是在即时编译器生成的机器码中,则需要插入额外的逻辑。这也就是所谓的写屏障 write barrier。写屏障需要尽可能地保持简洁。这是因为我们并不希望在每条引用型实例变量的写指令后跟着一大串注入的指令。

 

因此,写屏障并不会判断更新后的引用是否指向新生代中的对象,而是宁可错杀,不可放过,一律当成可能指向新生代对象的引用。这么一来,写屏障便可精简为下面的伪代码。

CARD_TABLE [this address >> 9] = DIRTY;

这里右移 9 位相当于除以 512,Java 虚拟机便是通过这种方式来从地址映射到卡表中的索引的。最终,这段代码会被编译成一条移位指令和一条存储指令。

 

6. 虽然写屏障不可避免地带来一些开销,但是它能够加大 Minor GC 的吞吐率:

应用运行时间 /(应用运行时间 + 垃圾回收时间)。——它能降低垃圾回收的时间,从而提高吞吐率。

 

7. 除了写屏障的开销外,卡表在高并发场景下还面临着 “伪共享” (False Sharing)问题。

我们知道,现代 CPU 中的缓存是以缓存行(Cache Line)为单位存储的,当多线程修改互相独立的变量时,如果这些变量恰好共享同一个缓存行,就会彼此影响(写回、无效化或者同步)而导致性能降低,这就是伪共享问题。

假设处理器的缓存行大小为 64 字节,而一个卡表元素占 1 字节,因此 64 个卡表元素将共享一个缓存行。这 64 个卡表元素对应的卡页总的内存为 32KB(64×512 字节),也就是说,如果不同线程更新的对象正好处于这 32KB 的内存区域内,就会导致更新卡表时正好写入同一个缓存行而影响性能。

 

为了避免伪共享问题,一种解决方案是不采用无条件的写屏障,而是先检查卡表标记,只有当该卡表元素未被标记过时才将其标记为变脏。更新卡表的逻辑如下所示:

if (CARD_TABLE [this address >> 9] != DIRTY)   

CARD_TABLE [this address >> 9] = DIRTY;

 

在 JDK 7 之后,HotSpot 虚拟机增加了一个新的参数-XX:+UseCondCardMark,用来决定是否开启卡表更新的条件判断。开启会增加一次额外判断的开销,但能够避免伪共享问题,两者各有性能损耗,是否打开要根据应用实际运行情况来进行测试权衡。


用户头像

Geek_571bdf

关注

还未添加个人签名 2019.06.13 加入

还未添加个人简介

评论

发布
暂无评论
JVM GC机制