Java GC

Riicarus大约 20 分钟JavaJVMJavaJVMGC

Java GC

GC 要解决的任务:

  • 哪些内存需要回收?
  • 什么时候回收?
  • 怎么回收?

垃圾判定算法

垃圾判定算法主要包括两种, 引用计数法可达性分析算法.

引用计数法

创建的每一个对象都包含一个引用计数器, 用于记录自身的引用情况. 当一个指针指向当前对象时, 引用计数器 +1.

Object obj = new Object();

当一个新的 Object 被创建时, 它的引用计数器被初始化为 1, 因为局部变量 obj 引用了该实例. 但当方法执行结束, 栈帧中局部变量表中引用该实例的 obj 指针被销毁时, 该实例的引用计数器值会 -1. 当引用计数器的值为 0 时, 表示该实例已经不再被使用, 可以被 GC 了.

引用计数法的优势在于: 实现简单, 易于辨识垃圾, 判断效率高, 回收没有延迟性.

劣势在于:

  1. 需要额外空间存储计数器;
  2. 每次引用指向或者消失都需要同步寄存器状态(同步问题);
  3. 无法处理相互引用这种循环引用的情况.

下面是一个循环引用示例:

public class A {
    public B b;
}

public class B {
    public A a;
}

public static void main(String []args) {
    A a = new A();
    B b = new B();
    a.b = b;
    b.a = a;
}

可达性分析算法

也被称作根可达或者根搜索算法. 该算法中, 存在一个 GCRoots 的概念, 在 GC 发生时, 会以这些 GCRoots 作为根节点, 以从上到下的方式进行搜索分析, 搜索走过的路线被称为 Reference Chain(引用链). 当一个对象没有和任何引用链相连时, 则判定当前对象是不可达的, 等待被 GC.

可以作为 GCRoots 的对象:

GCRoots 是可达性分析的根本, 在 JVM 中, 可以作为 GCRoots 的对象有以下四类:

  1. 虚拟机栈中引用的对象
  2. 元空间中类静态属性引用的对象
  3. 元空间运行时常量池中常量引用的对象
  4. 本地方法栈中 JNI(native 方法) 引用的对象

除开上述中的四大类对象可以被作为根节点外,也包括被 synchronized 持有的对象/JVM 内部的一些引用对象(如: 类加载器、异常类对象等)都可以作为根节点对象.

由于 Root 采用栈方式存放变量和指针, 所以如果一个指针保存了堆内存里面的对象, 但是自己又不存放在堆内存里面, 那么它就可以被看作为一个根节点.

采用可达性分析算法的时候, 必须保证在一个一致性的内存快照中进行, 否则可能导致最终结果不准确.

因此, STW 是可达性分析算法不能避免的, 主要发生在枚举根节点时.

对象的 finalization 机制

一切 GC 机制的前提是: 要能够识别出内存中需要被回收的垃圾对象. 因此, 需要对所有对象给出一个可触及性的定义, 在 Java 中对象的可触及性分为三类:

  1. 可触及的: 存在于引用链上的对象是可触及对象, 即: 通过根节点能够找到的对象;
  2. 可复活的: 一旦一个对象的所有引用被释放, 它就会处于可复活状态, 因为在 finalize() 中可能复活该对象;
  3. 不可触及的: 在 finalize() 执行之后, 对象就会进入不可触及状态, 从此该对象没有机会再次复活, 只能等待被 GC 机制回收.

在 Java 中, 当一个对象没有引用指向时, 在被 GC 前, 会先调用该对象的 finalize() 方法, 如果该对象所属的类没有重写 finalize() 方法或者已经执行过一次该方法, 那么不再执行 finalize().

如果一个对象没有被引用, 但是重写了 finalize() 方法并且未被执行过, 那么该对象会被插入到 F-Queue 队列中, 该队列是 JVM 自动创建的队列, 由低优先级的 Finalizer 线程执行其 finalize() 方法.

finalize() 是一个对象实例最后的复活机会, 因为 GC 机制会二次对 F-Queue 中的对象进行标记. 如果一个对象执行 finalize() 过程中, 与引用链上的任何一个对象建立了联系, 那么该对象会被移出队列, 然后被标记为存活对象.

需要注意的是:

  1. 执行过一次 finalize() 的对象无法再次进入 F-Queue, 当执行过一次之后 finalize() 不会被再次调用, 对象直接进入不可触及状态;
  2. finalize() 的执行具有不确定性, JVM 只会保证 finalize() 会被调用, 但不能保证 finalize() 能完全执行完毕. 如: 当 Finalizer 线程正在执行 finalize() 时, 堆内存严重不足, 那么 GC 会强制回收掉队列中的对象.

垃圾回收算法

判断出对象是否存活后, 就需要垃圾回收算法来决定垃圾如何回收.

GC 一般在堆可用内存不足时触发, 通常来说, GC 会先停止应用程序(STW), 暂停所有的用户线程. 目前, JVM 中主要使用三种 GC 算法:

  1. 标记-清除法
  2. 复制算法
  3. 标记-整理算法

标记-清除法

标记-清除法是现代垃圾收集算法的基础, 该算法将工作分为标记和清除两个阶段.

标记阶段会根据可达性分析算法, 通过 GCRoots 标记堆中所有的可达对象, 而这些对象被称为堆中存活对象; 反之, 未被标记的对象被称为垃圾对象.

在清除阶段, 会对所有未标记对象进行清除.

ObjectStatusBeforeGC
ObjectStatusBeforeGC

上图为程序运行期间所有对象的状态, 初始 GC 标志位都为 0, 即未标记状态. GC 开始时, 在标记阶段会 STW, 然后 GC 线程开始遍历所有 GCRoots 节点, 根据可达性算法找出堆中所有的存活对象, 并将其标志位置 1.

ObjectStatusAfterGC
ObjectStatusAfterGC

标记阶段结束后, 所有的存活对象都被标记, 接下来就会进入清除阶段, 清除所有未被标记的对象.

所有未标记对象被清除回收掉, 剩下存活对象, 为方便下次 GC, 会将存活对象的 GC 标志位置 0.

GC 标志位保存在对象头的 MarkWord 中.

清除阶段不只是置空内存, 而是把需要清除的对象地址保存在空闲地址列表中.

标记-清除算法的缺陷:

  1. 遍历 GCRoots 要 STW, 系统停止时间很长;
  2. 堆空间垃圾对象散乱分布, 清楚后容易造成大量内存碎片;
  3. 不连续的内存空间需要一个额外的空闲列表来保存, 占用空间.

复制算法

复制算法利用冗余内存来保证内存的整齐度. 它将 JVM 的堆内存划分为两块, 在同一时刻只会使用其中的一块内存进行分配. 发生 GC 时, 会将正在使用内存区域的存活对象复制到未使用的内存中, 然后将旧内存区域全面回收, 使用新的内存区域作为内存分配的空间.

使用复制算法可以使用更为简单的指针碰撞方式进行内存管理, 不会出现内存碎片.

不过也有一些缺陷:

  1. 只能使用一半的内存, 使用效率低;
  2. 复制过程耗费时间;

复制算法适合收集存活率低的对象.

在 HotSpot 虚拟机中, 采用复制算法来清理新生代空间. 新生代被分为三块空间: Eden1+Survivor2Eden *1 + Survivor* 2; 三块空间默认比例为 8:1:18:1:1.

当发生垃圾收集时, 会将 EdenSurvivor 的存活对象复制到空闲的 Survivor 中, 然后全面回收 Eden 和旧的 Survivor 空间.

如果空闲的 Survivor 无法容纳存活对象, 那就需要老年代提供的空间担保机制来提供额外的空间; 对 Survivor 存活对象进行动态晋升判定, 把一些符合条件的对象直接放入老年代中存储, 确保新生代有足够的空间存放新对象.

标记-整理算法

标记-整理算法也被称为标记-压缩算法, 适用于对象存活率较高的场景. 标记-整理算法是标记-清除算法的优化, 也分为标记/整理两个阶段.

  1. 标记阶段: 同标记-清除算法;
  2. 整理阶段: 把所有存活对象移动(压缩)到内存的一端, 然后对存活对象边界之外的内存区域统一进行内存回收.
MarkCollatingAlgorithm
MarkCollatingAlgorithm

垃圾对象回收完后, 存活对象都集中在内存的一端:

MarkCollatingAlgorithmFinished
MarkCollatingAlgorithmFinished

类似复制算法, 标记-整理算法可以使用指针碰撞方式来管理内存. 并且, 整理完成后, 不会存在内存碎片, 可以更好地容纳占用晋升地老年代对象(一般占用空间都比较大).

不过标记-整理算法也是有弊端的, 它的收集效率不高, 并且还有移动对象带来的性能损失.

由上, 标记-整理算法适用于对老年代进行垃圾回收.

分代清除策略

现代虚拟机中都会对内存进行分代收集. HotSpotVM 将堆内存空间分为新生代和老年代, 使用不同的手机策略进行收集.

  1. 新生代: 一般使用复制算法;
  2. 老年代: 一般使用标记-整理算法, 标记-清楚算法使用较少.

分区收集策略

JDK 1.8 及之前的 VM 中, 堆空间一般会按照对象存活的生命周期划分为新生代和老年代, 用于存储不同周期的对象. 在新版本的 GC 器中, 摒弃了之前在物理内存上分代的思想, 运行时不会直接将堆内存切分为两块区域, 而是将堆划分为连续且不同的小区间, 每个小区间都独立使用, 独立回收, 这中回收策略的好处是: 可以控制一次回收多少个小区间.

STW

发生 GC 时, 会停止所有的用户线程, 导致 Java 程序全局停顿, 这种情况称为 STW. 发生 STW 时, 所有 Java 代码停止运行, 但是 native 方法会继续运行, 只是不能和虚拟机进行交互.

其他情况也可能会产生 STW, 如: 线程 Dump, 死锁检查, 堆日志 Dump 等.

为什么 GC 要 STW

避免产生浮动垃圾

如果在 GC 时不停下用户线程, 那么会导致一种情况出现: 刚标记完一块区域中的对象, 用户线程又产生的新的垃圾对象. 这样会给 GC 线程判定垃圾对象造成很大的难度.

确保内存一致性

GC 发生时, 可达性分析算法要求在一个能保证内存一致性的快照中进行, 如果不 STW, 那么无法保证可达性分析算法结果的准确性.

STW 带来的问题

  1. 客户端长时间无响应;
  2. HA系统中的主从切换脑裂问题;
  3. 上游系统宕机问题.

GC 类型划分

JVM 需要 GC 的区域主要有三个: 新生代/老年代/元空间, 不过绝大多数时候都是在收集新生代.

GC 一般分为四种类型:

  1. 新生代收集(YoungGC/MinorGC): 只针对新生代进行收集, 当 Eden 区满时触发, Survivor 满时不会触发; STW 时间短, 影响小;
  2. 老年代收集(OldGC/MajorGC): 针对老年代的收集, 目前只有 CMS 会单独进行老年代收集;
  3. 混合收集(MixedGC): 针对整个新生代空间和部分老年代空间的收集, 目前只有 G1 存在该行为;
  4. 全面收集(FullGC): 覆盖新生代/老年代和元空间的垃圾收集.

触发 FullGC 的时机:

  1. 调用 System.gc() 时, JVM 会在内存占用较多的时候, 尝试发生 FullGC;
  2. 老年代不足时, 触发;
  3. 元数据空间不足时, 触发;
  4. 对象晋升到老年代时, 老年代空间不足, 触发;
  5. 新生代空间分配担保机制触发时, 触发.

安全区域和安全点

GC 触发需要 STW, STW 时, 系统需要处于一个安全状态. 安全点和安全区域指: 当用户线程执行到安全点或安全区域的代码处, 此时发生停止是安全的, 后续再次唤醒线程工作时,执行结果也不会因为线程暂停而受到任何影响.

安全点

安全点: 当线程运行到这类位置时, 堆对象状态是确定一致的, 线程停止后, JVM可以安全地进行操作, 如GC、偏向锁撒销等.

JVM 对安全点的定义:

  1. 循环结束的末尾段;
  2. 方法调用之后;
  3. 抛出异常之后;
  4. 方法返回之前;

如何让所有线程到达安全点阻塞或停止?

  1. 主动式中断(JVM 使用): 不中断线程, 而是设置一个标志, 每个线程执行时主动轮询这个标志, 当一个线程到达安全点后, 发现中断标志就主动将自己中断挂起;
  2. 抢断式中断: 先中断所有线程, 发现没有到达安全点的线程就让其恢复并执行到安全点.

安全区域

中断或者休眠状态的线程无法相应 JVM 的中断请求, 因此需要标志自己是否处于安全状态.

安全区域是指一个线程执行到一段代码时, 该区域的代码不会改变堆中对象的引用, 在这区域内JVM可以安全地进行操作. 当线程进入到该区域时需要先标识自己进入了, 这样GC线程则不会管这些已标识的线程; 当线程要离开这个区域时需要先判断可达性分析是否完成, 如果完成了则往下执行, 如果没有则需要原地等待到GC线程发出安全离开信息为止.

GC 收集器

GC 收集器影响的堆空间

JVM 的堆空间结构会根据 GC 收集器的类型来决定.

在所有收集器中, 大体会将堆空间分为两类: 分代分区.

如: 在分代堆空间中, 有新生代/老年代和永久代的划分, 新生代又分为 eden * 1, survivor * 2.

GC 相关术语

串行/并行, 独占/并发

  1. Serial 收集: 所有用户线程停止, 单条 GC 线程回收堆;
  2. Parallel 收集: 所有用户线程停止, 多条 GC 线程回收堆, 需要多核 CPU 的支持;
  3. Monopoly 执行: GC 工作时, GC 线程会抢占所有的资源并执行, 整个应用程序停止;
  4. Concurrent 执行: GC 工作时, 用户线程和 GC 线程交替并发执行, 应用程序不会停止;

吞吐量

Throughput=UserCodeExecuteTime/(UserCodeExecuteTime+GCTime)Throughput = UserCodeExecuteTime / (UserCodeExecuteTime + GCTime)

停顿时间

GC 工作时, 用户线程暂停的时间.

  1. 吞吐量优先: 确保程序更高的吞吐量, 允许 GC 发生长时间暂停;
  2. 响应时间优先: 确保程序更高的响应时间, 发生 GC 暂停的时间越短越好;

Java GC 收集器

  1. 分代收集器: Serial, ParNew, Parallel Scavenge, CMS, Serial Old, Parallel Old;
  2. 分区收集器: G1, ZGC, Shenandoah;

分代收集器中, 又分为:

  1. 新生代收集器: Serial, ParNew, Parallel Scavenge;
  2. 老年代收集器: CMS, Serial Old, Parallel Old;
Generations_GC_Cooperation
Generations_GC_Cooperation

上图中, 有连线的收集器是可以配合使用的(Eden + Old).

分代 GC 收集器

Serial 收集器

Serial 是最原始的新生代收集器, 同时它属于单线程的 GC 收集器, 所以也被称为串行收集器. 它在执行 GC 工作时, 是以单线程运行的, 在发生 GC 时, 会产生 STW, 停止所有用户线程, 在执行 GC 时并不会出现线程间的切换. 因此, 在单颗 CPU 的机器上, 它的清理效率非常高. 一般来说, 采用Client模式运行的 JVM, 选取该款收集器作为内嵌GC是个不错的选择.

  • 启动参数: -XX: +UseSerialGC(开启参数后, 老年代会默认使用 MSC);

  • 收集动作: 串行, 单线程;

  • 采用算法: 复制算法;

  • STW: GC 过程在 STW 中执行;

  • 执行过程:

    SerialGC
    SerialGC

ParNew 收集器

ParNew 收集器是 Serial 收集器的多线程版本, 除开使用多个 GC 线程进行收集之外, 大致与 Serial 收集器相同.

  • 启动参数: -XX: +UseSerialGC(开启参数后, 老年代会默认使用 MSC);

  • 收集动作: 并行, 多线程;

  • 采用算法: 复制算法;

  • STW: GC 过程在 STW 中执行;

  • 执行过程:

    ParNewGC
    ParNewGC

Parallel Scavenge 收集器

Parallel Scavenge 同样是作用于新生代的收集器, 但是与 ParNew 的关注点不同: ParNew 通过控制 GC 线程数量来缩短 STW 的时间, 更关注响应时间, 而 Parallel Scavenge 更关注吞吐量.

Serial Old 收集器

Serial Old 收集器和 Serial 类似, 都是单线程的垃圾收集器, 只是收集对象变为老年代.

CMS 收集器

CMS(Concurrent-Mark-Sweep) 收集器, 使用标记-清除算法对老年代进行收集.

收集步骤为:

  1. 初始标记(STW): 标记 GCRoots 直接关联对象.
  2. 并发标记: 从 GCRoots 直接关联对象开始, 遍历整个对象图, 标记对象存活情况.
  3. 重新标记(STW): 修正并发标记过程中, 由用户程序导致的标记变动.
  4. 并发清除: 清除标记阶段的死亡对象.

CMS 使用三色标记法对对象图进行标记.

在并发标记阶段, CMS 通过写后屏障, 使用增量标记的方式记录产生变动的对象引用.

优点: 低停顿.

缺点:

  1. 对处理器资源敏感, 需要多核并发支持.
  2. 并发标记导致无法处理浮动垃圾(并发标记阶段新产生的垃圾), 导致预留的用户空间不足, 触发 FullGC.
  3. 标记-清除算法导致收集结束会产生大量空间碎片, 无法为大对象创建提供空间, 触发 FullGC.

分区收集器

G1 收集器

G1(Garbage First) 收集器, 仍然保留了分代的思想, 但是不局限于任何一个分代进行收集. G1 面向堆中的任何一个部分来组成回收集. -- Mixed GC.

G1 基于 Region 对堆内存进行划分, 每个 Region 都可以是新生代或者老年代. G1 每次收集的内存空间都是 Region 的整数倍.

对于大对象, G1 将其划分在专门的 Humongous 区域, 一般作为老年代处理.

G1 会跟踪每个 Region 中垃圾堆积的价值大小, 优先处理回收收益更大的 Region, 这也是 Garbage First 名称的来历.

对于可能跨 Region 的引用, G1 使用双向卡表的方式来维护存在相互引用的 Region, 在触发 GC 时同时进行处理. 双向卡表相对单纯的卡表需要维护更多的内容, 内存消耗更多.

在三色标记的过程中, G1 使用写屏障来处理并发导致的标记改变.

  1. 写前屏障: 通过原始快照 (SATB, Snapshot-At-The-Beginning) 的方式记录并发阶段发生改变的引用.
  2. 写后屏障: 维护双向卡表.

收集步骤:

  1. 初始标记(STW)
  2. 并发标记
  3. 最终标记(STW)
  4. 筛选回收(并行, STW)

ZGC 收集器

ZGC(Z Garbage Collector) 是基于动态 Region 设计的, 没有分代的思想:

  1. 小型 Region: 2 MB 大小, 每个对象内存小于 256 KB.
  2. 中型 Region: 32 MB 大小, 每个对象内存在 256 KB ~ 4 MB 之间.
  3. 大型 Region: 容量不固定, 但是一定是 2n2^n MB, 每一个 Region 只存放一个大型对象, 不会被重新分配空间.

ZGC 使用染色指针来对对象引用进行标记, 直接将标记信息记录在引用对象的指针上. 因此, 在对象引用发生变动时, 可以直接通过指针进行记录操作, 不再需要借助写屏障来进行维护.

收集过程:

  1. 并发标记(很短暂的 STW)
  2. 并发预备重分配: 根据特定查询条件统计得出本次收集要清理的 Region, 将其组成重分配集 (Relocation Set).
  3. 并发重分配: 将重分配集中的存活对象复制到新的 Region 上, 并为重分配集中的每个 Region 维护一个转发表, 记录旧对象到新对象的转向关系.
  4. 并发重映射: 修正整个堆中指向重分配集中旧对象的所有引用, 当所有指针都修复完, 转发表就可以被释放.

在并发整理算法的实现上, ZGC 使用了读屏障.

在并发重分配的过程中, 存活对象被复制到了新的 Region 上, 每个 Region 都有一个转发表来记录旧对象到新对象的转向关系. 如果在这个阶段用户线程并发访问了重分配过程中的对象, 并且通过指针上的标记发现对象处于重分配集中, 就会被读屏障截获, 通过转发表的内容转发该访问, 并修改引用的值.

ZGC 将这种行为称为"自愈" (Self-Healing).