JVM-垃圾收集器与内存分配策略


青春壹個敷衍的年華 提交于 2020-01-14 23:09:04

垃圾回收(Garbage Collection)需要解决的三个问题:

  1. 哪些内存需要回收
  2. 什么时候回收
  3. 怎么回收

1. 概述

  1. 当需要排查各种内存泄漏、溢出问题时,当垃圾回收集成为系统达到更高并发量的瓶颈时,就需要对“自动化”的技术实施必要的监控和调节。
  2. 程序计数器、虚拟机栈、本地方法栈都随线程而生死,即内存区域和回收都具有确定性
  3. Java 回收针对的对象:Java 堆,这一部分内存只有在程序运行期间才能知道创建哪些对象,内存的分配和回收都是动态的。

2. 判断对象已死?

不可能再被任何途径使用的对象称为:“死去”的对象

2.1 引用计数法(Reference Counting)

给对象中添加一个引用计数器,每当有一个地方引用该对象,计数器加1;当引用失效时,计数器减1;任何时候计数器值为0的对象就是不可能被再次使用的对象。

  1. 实现简单,判定效率高。
  2. 缺点:
    • 很难解决对象之间相互循环引用的问题,所以主流 JVM 中没有使用该算法

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

主流的如 Java、C#等语言的主流实现中都是通过可达性分析来判断对象是否存活。

通过一系列的称为 “GC Roots” 的对象作为起始点,从这些节点往下搜索,搜索所走过的路径称之为 “引用链(Reference Chain)” ,当一个对象到 “GC Roots” 没有任何引用链相连时,则证明此对象是不可用的。

可以作为 “GC Roots” 的对象:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中 JNI (Native 方法)引用的对象

2.3 引用

强引用、软引用、弱引用、虚引用

  • 强引用

在程序代码中普遍存在的,类似于“Object o = new Object() ”这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。

  • 软引用

描述一些还有用但并非必须的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。

  • 弱引用

描述非必须的对象,强度低于软引用。被引用的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作的时候,无论当前内存是否足够,都会回收掉被弱引用关联的对象。

  • 虚引用

幽灵引用或幻影引用,是最弱的引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过一个虚引用来取得一个对象的实例。设置虚引用关联的唯一目的是:能在这个对象被回收器回收的时候收到一个系统通知。 - PhantomReference类来实现虚引用

2.4 生存 OR 死亡

真正宣告一个对象死亡,至少要经过两个标记过程:

  1. 进行可达性分析后发现对象没有与 “GC Roots” 相连接的引用链,那他将被第一次标记并且进行第一筛选,筛选的条件是此对象是否有必要执行 finalize() 方法。当对象没有覆盖 finalize() 方法,或者 finalize() 方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”。
  2. finalize() 方法是逃脱死亡的最后一次机会,只要对象在方法中重新与引用链上的任何一个对象建立关联即可。(把自己即 this 关键字赋值给类变量或者对象的成员变量)
  • 任何对象的 finalize() 方法都只会被系统自动调用一次!
  • finalize() 方法运行代价高昂、不确定性大,无法保证每一个对象的调用顺序。
  • finalize() 能做的所有工作,使用 try-finally 或其他方法都可以做的更好、更及时

2.5 回收方法区

  • “无用的类”:
    • 该类所有实例都已经被回收,也就是 Java 堆中不存在该类的任何实例;
    • 加载该类的 ClassLoader 已经被回收;
    • 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

3. 垃圾收集算法

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

  • 定义:首先标记出所有需要回收的对象,在标记完成后统一回收没有被标记的对象。是后续收集算法的基础。
  • 缺点:
    • 效率问题: 标记和清除的效率都不高;因为内存碎片存在,操作会变得更加耗时,因为查找一块可用空闲的内存已经不是一个简单的操作。
    • 空间问题: 标记清除过后对产生大量的不连续内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

Mark - Sweep

图源:深入理解JVM(2)——GC算法与内存分配策略

3.2 复制算法

  • 为解决上述算法的效率问题;
  • 将可用内存按容量划分为大小相等的两块,每次只使用其中一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用的内存空间一次清理掉。这样使得每次都是对整个半区进行内存回收,内存分配时就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效;
  • 代价:将内存缩小为原来的一般,代价太高。

Copying

图源:深入理解JVM(2)——GC算法与内存分配策略

商业虚拟机并不是按 1:1来分配内存空间。也用复制算法。

“Minor GC”:将内存划分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor 。回收时,将 Eden 和 Survivor 中还存活的对象一次性复制到 另外一个 Survivor 空间上,最后清理掉 Eden 和用过的 Survivor 空间。

HotSpot 虚拟机默认 Eden : Survivor = 8 :1;也就是每次新生代中可用内存空间为整个新生代容量的 90 %,只有 10 % 被浪费。当 Survivor 空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保(Handle Promotion)。

分配担保: 如果另外一块 Survivor 空间不足以存放上一次新生代收集下来的存活对象时这些对象直接通过分配担保机制进入老年代。

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

  • 复制收集算法在对象存活率较高时就需要进行较多的复制操作,效率会变低。为应对被使用的内存中所有对象都是100%存活的极端情况,在老年代一般不直接选用复制算法。

标记 - 整理算法:标记过程与“标记-清除”一样,后续步骤不是直接对可回收对象进行清理,而是让所有存活对象都像一端移动,然后直接清理掉端边界以外的内存。

Mark - Sweep

图源:深入理解JVM(2)——GC算法与内存分配策略

3.4 分代收集算法

当代商业虚拟机的垃圾回收采用该算法。Generation Collection

  • 新生代:每次垃圾收集时都有大批对象死去,选用复制算法,只需要少量存活对象的复制成本就可以完成收集;
  • 老年代:对象存活率高、没有额外空间对他进行分配担保,就使用“标记 - 清理” 或 “标记 - 整理”

4. HotSpot 的算法实现

在执行上述垃圾回收算法的时候,必须对算法的执行效率进行严格的考量,才能保证虚拟机高效运行。

4.1 枚举根节点

  1. 方法区很大,如果要逐个检查这里面的引用,必然会消耗很多的时间。
  2. 可达性分析对执行时间的敏感还体现在 GC 停顿上,因为这项工作必须在一个能保证一致性的快照中进行——这里的“一致性”指的是在整个分析期间整个执行系统看起来就像被冻结在某个时间点上,不可以出现分析过程中对象引用关系还在不断的变化的情况,该点不满足的话分析结果准确性就无法得到保证。这点是导致 GC 进行时停止所有 Java 线程 (Stop The World) 的原因。

4.2 安全点

  1. 在 OopMap 的协助下,HotSpot 可以快速准确地完成 GC Roots 枚举,但可能会导致引用关系变化。
  2. Hotspot 没有为每条指令生成 OopMap,只在“特定的位置”记录了这些信息,这些位置称为安全点 (SafePoint)。即程序执行并非在所有的地方都停顿下来开始 GC,只有在达到安全点时才能暂停。
  3. 安全点不能太多,也不能太少。
    • 太多: 过分增大运行时负载;
    • 太少: 让 GC 等待时间太长
  4. 安全点的选择:
    1. 以程序 “是否具备让程序长时间执行的特征”
    2. 方法调用、循环跳转、异常跳转等产生 SafePoint
  5. 如何在 GC 发生时让所有线程到最近的安全点再停顿:
    1. 抢先式中断(现在几乎没有虚拟机采用这种方式来暂停线程从而响应 GC 事件)
    2. 主动式中断
      • 当 GC 需要中断线程时,不直接对线程操作,仅仅简单的设置一个标志,各线程执行时主动去轮询这个标志,发现中断标志位真时就自己中断挂起。轮询标志的地方和安全点是重合的,另外再加上创建对象需要分配内存的地方。

4.3 安全区域

指的是在一段代码区域中,引用关系不会发生变化。在这个区域的任意位置 开始 GC 都是安全的。


5. 垃圾收集器

垃圾收集器的实现由厂商自己实现,下面讨论的垃圾收集器是基于 JDK 1.7 Update 14之后的 Hotspot 虚拟机。

​ 图:HotSpot 虚拟机的垃圾收集器

存在连线说明两两可以相互搭配使用。重点分析 CMS 和 G1

目前不存在最好的垃圾收集器,只存在在各种场景下最合适的垃圾收集器。

5.0 相关概念

  • 并行(Parallel): 指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。
  • 并发(Concurrent): 指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),用户程序在继续执行。而垃圾收集程序运行在另外一个 CPU 上。
  • 吞吐量(Throughput): 吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间)

5.1 Serial 收集器

  1. 最基本、历史最悠久,新生代收集器。
  2. 单线程收集器: 不仅仅说明他只会使用一个 CPU 或一条收集线程去完成垃圾收集工作,重要的是在他进行垃圾收集时,必须暂停其他所有的线程直到收集结束。
  3. 收集运行示意图如下:

Serial

本节图源:深入理解JVM(3)

  1. 虚拟机运行在 Client 模式下的默认新生代收集器。
  2. 优点:简单高效(相比其他收集器而言),对于限定单个 CPU 的环境来说, Serial 收集器由于没有线程交互的开销,专心做垃圾收集可以获得最高的单线程收集效率。

5.2 ParNew 收集器

  1. Serial 的多线程版本,除了多条线程,其余行为包括Serial收集器可用的所有控制参数、收集算法(复制算法)、Stop The World、对象分配规则、回收策略等与Serial收集器完全相同。
  2. 收集器运行示意图:

ParNew

  1. 在 Server 模式下虚拟机的首选新生代收集器,除 Serial 外,她是唯一一个可以与 CMS 配合的收集器。
  2. 在 CPU 非常多的情况下,可以使用 -XX:ParallelGCThreads 参数来限制垃圾收集的线程数。

5.3 Parallel Scavenge 收集器

  1. 使用复制算法新生代多线程收集器。
  2. 目的:达到一个可控制的吞吐量,关注点就在吞吐量,“吞吐量优先收集器”。
  3. 高吞吐量可以高效率地利用 CPU 时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。
  4. 在手工优化存在困难的时候,使用 Parallel Scavenge 配合自适应的调节策略,把内存管理的工作交给虚拟机去完成。

5.4 Serial Old 收集器

  1. Serial 的老年代版本;
  2. 使用 “标记 - 整理” 算法;
  3. 用途:
    1. 在 JDK 1.5 之前与 Parallel Scavenge 搭配使用;
    2. 作为 CMS 的后备预案,在并发收集发生 Concurrent Mode Failure 时使用。
  4. 运行示意图同 Serial。

5.5 Parallel Old 收集器

  1. Parallel 的老年代版本;
  2. 使用 “标记 - 整理” 算法;
  3. 在注重吞吐量以及 CPU 资源敏感的场合,都可以优先考虑 Parallel Scavenge 加 Parallel Old 收集器。
  4. 运行示意图同 Parallel Scavenge。

5.6 CMS 收集器

  1. CMS(Concurrent Mark Sweep)是一种以获取最短回收停顿时间为目标的收集器。
  2. 该算法的 “标记 - 清除” 步骤:
    • 初始标记(Initial Mark): 仅仅只是标记一下 GC Roots 能关联到的对象,速度很快。
    • 并发标记(Concurrent Mark): 进行 GC Roots Tracing 的过程,在整个过程中耗时最长。
    • 重新标记(Remark): 为修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般比初试标记阶段稍长,远比并发标记时间短。
    • 并发清除(Concurrent Sweep)

其中,初始标记重新标记需要 “Stop The World”

  1. 运行示意图如下:

CMS

  1. 优点:
    • 并发收集;
    • 低停顿。
  2. 缺点:
    • CMS 收集器对 CPU 资源敏感;
    • CMS 收集器无法处理浮动垃圾(Floating Garbage),可能出现 “Concurrent Mode Failure”失败而导致另一次 Full GC 的产生。
    • CMS 基于“标记 - 清除” 算法,导致空间碎片太多,会给大对象分配带来麻烦,往往会出现老年代还有很大空间剩余,但是无法找到足够大的连续空间来分配当前对象,不得不提前触发一次 Full GC。

5.7 G1 收集器

  1. 面向服务端应用的垃圾收集器。目标是在未来替换掉 JDK 1.5 中发布的 CMS 收集器。
  2. 具有的特点:
    • 并行与并发: 充分利用多 CPU、多核环境下的硬件优势,缩短 “Stop The World” 的时间,可以通过并发的方式让 Java 程序继续执行;
    • 分代收集:
    • 空间整合: 运作期间不会产生大量的空间碎片,收集后能提供规整的内存空间,有利于程序长时间运行。
    • 可预测的停顿: 能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间不得超过 N 毫秒,这几乎已经是 Java (RTSJ) 的垃圾收集器的特征了。
  3. 使用 G1收集器时,Java 堆的内存布局不同于其他,它将整个 Java 堆划分为多个大小相等的区域,保留了新生代和老年代,但不再是物理隔离的,都是一部分区域的集合。
  4. 这种使用 Region 划分内存空间以及有优先级的区域回收方式,保证了 G1 收集器在有限的时间内可以获取尽可能高的收集效率。
  5. G1 收集器的运作:
    1. 初始标记(Initial Marking)
    2. 并发标记(Concurrent Marking): 从 GC Roots 开始对堆中对象进行可达性分析,需要停顿线程,但耗时很短。可与用户程序并发进行。
    3. 最终标记(Final Marking)
    4. 筛选回收(Live Data counting and Evacuation): 首先对各个Region中的回收价值和成本进行排序,根据用户所期望的GC 停顿时间来制定回收计划。此阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分Region,时间是用户可控制的,而且停顿用户线程将大幅度提高收集效率。
  6. 运行示意图:

G1

5.8 内存分配与回收策略

  1. 对象优先在 Eden 分配
  2. 大对象直接进入老年代
  3. 长期存活的对象将进入老年代
  4. 动态对象年龄判断
  5. 空间分配担保

5.9 总结

收集器 串行、并行、并发 新生代/老年代 算法 目标 适用场景
Serial 串行 新生代 复制算法 响应速度优先 单CPU环境下的Client模式
Serial Old 串行 老年代 标记-整理 响应速度优先 单CPU环境下的Client模式、CMS的后备预案
ParNew 并行 新生代 复制算法 响应速度优先 单CPU环境下的Server模式、与CMS配合
Parallel Scavenge 并行 新生代 复制算法 吞吐量优先 在后台运算而不需要太多交互的任何
Parallel Old 并行 老年代 标记-整理 吞吐量优先 在后台运算而不需要太多交互的任何
CMS 并发 老年代 标记-清除 响应速度优先 集中在互联网站或B/S系统服务端上的Java应用
G1 并发 标记-整理+ 复制算法 响应速度优先 将来替换CMS

6. 参考资料

工具导航Map