垃圾回收的“方法论” -- 垃圾回收算法

93 阅读14分钟

1. 引入

在上一篇《引入篇》了解了JVM中,究竟什么样的对象被认为垃圾。本文主要是解答学习垃圾回收机制的第二个核心问题:JVM如何进行垃圾回收

1.1 分代收集(Generational Collection

分代收集理论名为理论,实质是一套符合大多数程序运行实际情况的经验法则,建立在以下三个分代假说之上:

  • 弱分代假说Weak Generational Hypothesis):绝大数对象都是朝生夕灭
  • 强分代假说Strong Generational Hypothesis):熬过多次垃圾回收过程的对象就越难以消亡;

以上两条理论奠定了多款垃圾回收器的设计原则:回收器应该将Java划分出不同区域,然后将回收对象根据其年龄(年龄:对象熬过垃圾回收过程的次数)分配到不同的区域当中存储。

  • 跨代引用假说Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说仅占极少数。存在互相引用关系的两个对象,是应该倾向于同时生存或者同时消亡的。

    • 跨代引用:新生代中存在对老年代对象的引用,或者老年代中存在对新生代的引用。

    • 跨代引用存在问题:以新生代GC为例,GC时,为了找到新生代中的存活对象,不得不遍历整个老年代;反之亦然。这种方案存在极大的性能浪费

    • 解决方案:引入记忆集(用来记录跨代引入的表)。以新生代GC为例,GC时,根据GC ROOT和记忆集,可以判断新生代区对象是否存活,不必遍历老年代。

1.2 分区收集

运行时整个堆空间划分成连续的不同小区间,每一个小区间都能独立使用,独立回收。

这种回收策略带来的好处可以控制一次回收多少个区间,可以较好的控制GC时间。

2. 堆区域的划分

2.1 为什么要对Java堆进行区域划分?

对堆进行区域划分是为了提高垃圾回收的效率

这种划分只是根据垃圾回收机制来进行划分,并非Java虚拟机规范本身制定。

根据上面提到的分代理论,不同对象存活时间有长有短,如果混合在一起,必然会造成频繁的垃圾回收,这样效率很慢。

因此,在对Java划分出不同的区域之后,垃圾回收器能够针对不同的区域采用不同的方法进行回收

2.2 Java堆区域是如何划分的?

Java堆分为新生代Young Generation)和老年代Old Generation)两个区域。

image.png

  • 新生代:存放存活时间短的对象,此区域发生频繁GC,每次GC后存活的少量对象对象,将会逐步晋升到老年代中存放。
    • Eden 空间
    • From Survivor空间
    • To Survivor空间

Q: 新生代划分的三个区域 Eden:from:to 的比例为何是 8:1:1 ?

总结来说,是为了减少内存空间的浪费,提高内存空间利用率

新生代区域划分为一块较大的Eden和两块较小的Survivor, 新生代中的对象98%是朝生夕死的,每次分配内存只使用Eden和其中一块Survivor,发生GC时,将EdenSurvivor中仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理Eden和已经使用过的那块Survivor空间。

按照8:1:1的比例分区,内存的空间利用率达到了90%,只有10%的空间是浪费掉了。

  • 老年代:存放应用程序中生命周期长的对象。

3. 如何给对象分配内存?

3.1 内存分配原则

  • 对象优先在Eden区分配:大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC

  • 大对象直接进入老年代,为了避免为大对象分配内存时的复制操作而降低效率。

大对象:需要大量连续内存空间Java对象,如:很长的字符串,元素数量很庞大的数组。

  • 长期存活对象进入老年代:每一个对象都有一个对象年龄,对象在新生代中每经过一次垃圾回收,对象年龄增长1,当对象年龄超过某个阈值(默认为15)时,该对象会进入老年代。

image.png

Q: 如何理解年龄

JVM给每个对象定义了一个对象年龄(Age)计数器,存储在对象头中。对象通常在Eden区中诞生,如果经过第一次Minor GC后仍然存活且能被Survivor容纳,这个对象就会被移动到Survivor空间中,并且将其对象年龄设为1岁。对象在Survivor区中每熬过一次Minor GC,年龄就增加一岁,当其年龄增加到一定程度,就会晋升到老年代中。

  • 动态对象年龄判断:为了能更好地适应不同程序的内存状况,并不是永远要求对象的年龄必须达到某个值才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半年龄大于或等于该年龄的对象就可以直接进入老年代。

  • 空间分配担保:假如在Minor GC之后,新生代仍然有大量对象存活,就需要老年代进行分配担保,把Survivor无法容纳的对象直接送入老年代。

3.2 堆内存分配流程

image.png

4.GC类型

image.png

4.1 部分收集(Partial GC

收集目标:不是完整回收整个Java堆的垃圾,其中又分为: 新生代收集,老年代收集,混合收集。

image.png

4.1.1 新生代收集(Minor GC/Young GC

  • 收集目标:新生代的垃圾;
  • 触发时机:新创建的对象优先在新生代Eden区分配,如果Eden区没有足够的空间,就会触发Minor GC来清理新生代。

4.1.2 老年代收集(Major GC/Old GC

  • 收集目标:老年代的垃圾。目前只有CMS收集器有单独收集老年代的行为。

4.1.3 混合收集(Mixed GC

  • 收集目标:收集整个新生代及部分老年代的垃圾收集,目前只有G1收集器有这种行为。

4.2 整堆收集(Full GC

  • 收集目标:收集整个Java堆和方法区的垃圾。
  • 触发时机
    • Minor GC前检查老年代:发现老年代可用的连续内存空间 < 新生代历次Young GC后升入老年代的对象总和的平均大小,说明本次Minor GC后可能升入老年代的对象大小,可能超过了老年代当前可用内存空间,那就会触发 Full GC
    • Minor GC后老年代空间不足Minor GC后一批对象会进入老年代,此时老年代无足够的内存空间就会触发Full GC
    • 老年代空间不足:老年代内存使用率过高,达到一定比例,触发Full GC
    • 方法区空间不足:如果方法区由永久代实现,永久代空间不足触发Full GC
    • 空间分配担保失败:新生代的To区放不下从 EdenFrom 拷贝过来对象,或者新生代对象 GC 年龄到达阈值需要晋升这两种情况,老年代如果放不下的话都会触发
    • System.gc()等命令触发。

5. 垃圾回收算法

5.1 标记-清除(Mark-Sweep)算法

5.1.1 基本思想

算法分为标记清除两个阶段:

  • 标记阶段:对象是否属于垃圾的判定过程,在此过程中标记出所有需要回收的对象或者存活的对象;
  • 清除阶段: 对所有被认定为是垃圾的对象进行统一回收

image.png

5.1.2 缺点

  • 效率问题:标记和清除这两个过程效率都不高;
  • 空间问题:标记清除后会产生大量不连续的内存碎片,空间碎片太多可能会导致程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得触发另一次GC动作。

5.2 复制(Copying)算法

5.2.1 基本思想

JVM中原有堆内存分成两块,每次只使用其中的一块。发送GC时,就将还存活着的对象复制到另外一块上,然后再把已使用过的内存空间一次清理掉。清理完成后,交换两块内存的角色。

image.png

5.2.2 优点

  • 回收后不需要考虑内存碎片的复杂情况;
  • 内存分配时直接使用简单高效的指针碰撞方式,移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。

5.2.3 缺点

  • 将可用内存缩小为了原来的一半,浪费内存;
  • 复制时对象移动的开销。

现代商用JVM大多采用复制算法用于回收 新生代 空间。

5.3 标记-整理(Mark-Compact)算法

5.3.1 基本思路

同样也是分为两个阶段:标记阶段与整理阶段。标记过程仍然与标记-清除算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存

标记-清除算法与标记-整理算法的本质差异在于前者是一种非移动式的回收算法, 而后者是移动式

image.png

5.3.2 优点

  • 解决了标记-清除算法的内存碎片;
  • 解决了复制算法的空间浪费。

5.3.3 缺点

收集效率不高,因为需要标记对象,移动存活对象,所以耗费的时间资源开销大。

标记-整理算法主要用于回收老年代区域。

5.4 基础GC算法总结

以上三种GC算法则是JVM的基础GC算法,综合来看:

  • 收集速度:复制算法 > 标记-清除算法 > 标记-整理算法
  • 内存整齐度:复制算法 = 标记-整理算法 > 标记-清除算法
  • 内存利用率:标记-整理算法 > 标记-清除算法 > 复制算法

6. STW/安全点/安全区域

6.1 STW

6.1.1 什么是STW?

STWStop The World,指的是GC时会停下所有的用户线程,从而导致程序出现全局停顿的无响应情况。

发生STW后,所有的代码会停止运行,不过native代码可继续执行。

一般发生STW都是由于GC引起,但在某几种少数情况,也会导致STW出现,如:线程Dump,死锁检查,堆日志Dump等。

6.1.2 GC时为什么需要STW?

总结为以下两点原因:

  • 避免浮动垃圾产生

    设想如下情形:刚刚标记完成一块区域中的对象,但转眼用户线程又在该区域中产生了新的垃圾

    这种情况下GC机制很难去准确判断哪些是垃圾,会给GC线程造成很大的负担,GC算法的实现难度也会增加。

浮动垃圾:用户线程运行过程中产生了新的垃圾,新的垃圾在此次GC中无法清除,只能等到下次清理,这些垃圾叫做浮动垃圾。

  • 确保内存一致性

    GC发生时,可达性分析标记垃圾的期间,JVM不可以出现分析过程中对象引用关系还在不断变化的情况。这样分析结果的准确性无法得到保证。

6.1.3 STW带来的问题

  • 客户端长时间无响应问题

    STW发生时,会停止所有用户线程,所以对于客户端发送过来的网络请求并没有线程处理,则客户端会一直处于无响应状态。

  • HA系统中的主从切换脑裂问题

    同时如果系统做了主备、主从、多活等HA方案,如果主机触发GC发生STW,造成主机长时间停顿,而备机会监测到主机没有工作,于是备机开始尝试将流量切换到自身来处理,从备机变为了主机。

    但旧主不工作只是暂时的,因为GC的原因导致暂停一段时间,而当GC完成后,旧主会依旧开始工作,最终造成了整个HA系统中出现了双主情况,形成了脑裂问题。

HA:High Availability Cluster,简称HA Cluster,是指以减少服务中断时间为目的的服务器集群技术。

脑裂问题:通俗来讲就是出现了两个大脑,不知道听谁的。

  • 上游系统宕机问题

    如果系统是以多机器部署的工程项目,那么如果当某个工程所在的机器发送GC出现STW时,那么上游系统过来的请求则不会处理,如果STW时间一长,最终很有可能导致上游机器扛不住流量而出现宕机

6.2 安全点(Safepoint

6.2.1 什么是安全点?

代码中的一些特定位置,当线程运行到这些位置不会导致引用关系的变化,这样JVM可以安全的进行一些操作。

这些特定的位置主要在:

  • 循环的末尾(非counted循环)
  • 方法临返回前 / 调用方法的call指令后
  • 可能抛异常的位置

用通俗的比喻,假如老王去拉车,车上东西很重,老王累的汗流浃背,但是老王不能在上坡或者下坡休息,只能在平地上停下来擦擦汗,喝口水。此处的“平地” 就是安全点

image.png

(图源三分恶)

6.2.2 为什么要有安全点?

GC时需要暂停用户线程,用户程序执行时并不是在代码指令流的任意位置都能停下来,我们要确保暂停的点不会导致引用关系的变化,也就是必须执行到安全点才能停止。

6.2.3 GC时如何让所有线程停到最近的安全点?

  • 抢先式中断Preemptive Suspension):不需要线程的执行代码主动去配合,在GC时,系统首先把所有用户线程全部中断,如果发现有用户线程中断的地方不在安全点上,就恢复这条线程执行,让它一会再重新中断,直到跑到安全点上。【几乎没有虚拟机实现采用抢先式中断来暂停线程响应GC事件】

  • 主动式中断Voluntary Suspension):当垃圾收集需要中断线程的时候,不直接对线程操作,仅仅设置一个标志位,各个线程执行过程时会不停地主动去轮询这个标志,一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起。轮询标志的地方和安全点是重合的,另外还要加上所有创建对象和其他需要在 Java 堆上分配内存的地方,这是为了检查是否即将要发生垃圾收集,避免没有足够内存分配新对象

6.3 安全区域(Safe Region

6.3.1 什么是安全区域?

一段代码片段中,引用关系不会发生变化。在这个区域内任意地方GC都是安全的。这一段代码片段就是安全区域。

6.3.2 为什么要有安全区域?

用户线程处于Sleep或者Blocked状态时,线程无法进入安全点,针对上述情况,必须引入安全区域

用户线程执行到安全区域中的代码时,首先会标识自己已经进入安全区域,这段时间如果JVM发起GC,就不用管这个线程了。

当线程要离开安全区域时,线程要检查JVM是否已经完成了GC Roots或者其他需要STW的阶段:

  • 如果已经完成,线程可以当作无事发生,继续执行
  • 否则线程需要一直等待,直到收到可以离开安全区域的信号为止。

参考资料