NingG +

JVM 实践:GC 原理

1. 背景

从 14 年,第一次在团队中分享 JVM 的 GC 原理以及调优实践,17 年在美团分享一次,最近面向公司的支付技术团队又分享了一次,做了局部内容的更新。在美团那次,技术学院录了部分视频,美团的读者可以到 mit 上搜一下。18 年这次,围绕 JDK8 以及 G1 垃圾收集器,做了部分内容更新,这次集中放出来吧,准备 2 篇 blog 来描述 JVM GC 原理和调优实践。(自己有一个 keynote 版本

2. 主要内容

主要几个方面:

  1. 基本逻辑:
    1. Java 代码执行过程
    2. JVM 内存分配
    3. JVM 内存回收
  2. 实现细节:
    1. 整体策略:分代策略
    2. 垃圾收集器
  3. 实践:具体 JVM 调优的详细场景和操作

2.1. 基本逻辑

2.1.1. Java 代码执行过程

2.1.1.1. JVM 是什么

2.1.1.2. Java 代码执行过程

JVM 整体由 4 部分组成:

  1. 加载:类加载器 ClassLoader
  2. 执行:执行引擎
  3. 内存:运行时数据区,Runtime Date Area
  4. 内存回收:垃圾回收

2.1.2. 内存分配

2.1.2.1. 运行时数据区
  1. 方法区:
    1. 类:Class
    2. 静态变量
    3. 常量池(字符串常量、数字常量)
  2. Java 堆:
    1. 对象:Object
    2. 数组
  3. Java 栈:Java 方法调用过程
    1. 操作数栈
    2. 局部变量表
    3. 方法出口
  4. 本地方法栈:本地方法调用过程
  5. 程序计数器:Program Counter

2.1.2.2. 实例

JVM 内存空间:

  1. 线程共享:
    1. Java 堆
    2. 直接内存
    3. 方法区
  2. 线程独占:
    1. Java 栈
    2. 本地方案栈
    3. PC 寄存器

2.1.3. 内存回收

2.1.3.1. 为什么要回收内存
2.1.3.2. 回收哪些内存

引用计数

根搜索

2.1.3.3. 如何回收

核心问题二:如何回收?

分代回收:根据对象存活时间,分级策略

哪些条件,会触发对象进入「老年代」:

  1. 对象年龄:对象在「新生代」中survivor 区,存活的年龄,达到阈值(默认为 15),则,进入「老年代」
  2. 同龄对象过半:年龄动态计算,对象在「新生代」中survivor 区,同龄对象大小之和,超过了 survivor 区50%,则,集体进入「老年代」
  3. 大对象:大对象,在「新生代」放不下,直接进入「老年代」

2.2. 实现细节

经验取值的说明:

2.2.1. 简要

背景:

HotspotVM:串行、并行、并发

2.2.2. 具体的 GC 垃圾收集器

其中,新生代的 Serial New、ParNew、Parallel Scavenge,具体的作用和区别:

Serial(新生代-串行-收集器):单线程,独占式,

ParNew(新生代-并行-收集器):多线程,独占式

Parallel Scavenge(新生代-并行-收集器):多线程,独占式

2.2.3. CMS 垃圾收集器(老年代)

关于 CMS 垃圾收集器的重新标记,细节参考下文。

重新标记过程中,需要进行全堆扫描,本质原因:存在跨代引用

  1. 并发标记过程中,一些新生代对象,可能重新引用了新的老年代对象

  2. 重新标记过程中,由于 young gc 过程,才会触发根搜索,但 full gc 是独立的,不会进行重新的根搜索,因此,会采用全堆扫描

优化要点:开启 CMSScavengeBeforeRemark, 在重新标记阶段,会强制进行一次 young gc,降低内存中,现有对象的数量,以此,降低全堆扫描的时间。

关于 CMS 垃圾收集器的缺陷,参考下文。

JVM GC 欺骗(CMS 垃圾收集器),核心过程:

  1. CMS:并发 GC 过程中
    1. 新生代对象:不断进入老年代,老年代空间不足 CMS 失败;
    2. 老年代 CMS 退化为:Serial Old 串行 GC,Stop-The-World;
  2. Full GC 之后,因为满足条件,不会抛出 OOM
    1. 已经占用的 Heap 空间,未超过阈值(可用的 Heap 空间,满足 GCHeapFreeLimit (2%))
    2. GC 时间占比,未超过阈值:GCTimeLimit(默认 98%)

解决办法:

  1. CMS 垃圾收集器,降低 CMS 退化概率:
    1. 开启压缩,减少因为内存碎片,导致的 CMS 退化为 Serial Old 概率,具体参数:-XX:+UseCMSCompactAtFullCollection-XX:CMSFullGCsBeforeCompaction 多少次 full gc 进行一次压缩,一般只设置 CMSFullGCsBeforeCompaction 每 4 次 Full GC 进行一次内存整理,比较合适;
    2. 降低触发 full gc 的阈值:老年代已使用内存空间占比。尽早进行 GC:-XX:CMSInitiatingOccupancyFraction 老年代空间占用比例,触发的阈值。默认 92%,建议:68% (Note:内存使用率增长较快,阈值调低,降低 CMS 退化风险;内存使用率增长较慢,阈值调高,减少 CMS 收集频率)
  2. 调整 OOM 触发条件,避免在 OOM 边缘,性能过低:GCHeapFreeLimit(可用空间占比)、GCTimeLimit(GC 时间占比)

Note:

老年代的 CMS 垃圾收集,可能会退化为 Serial Old,其中:

  1. CMS:默认,标记-清除;(可以开启压缩-XX:+UseCMSCompactAtFullCollection-XX:CMSFullGCsBeforeCompaction 多少次 full gc 进行一次压缩)
  2. Serial Old:标记-清除-压缩

特别说明:这个实际案例,本质是 2 个原因

  1. 老年代 CMS 退化为 Serial Old
    1. 设置开启碎片压缩CMSFullGCsBeforeCompaction 多少次 full gc 进行一次压缩
    2. 设置 full gc 的触发条件CMSInitiatingOccupancyFraction 默认为 92%,建议设置为 70%
  2. OOM 默认阈值:GC 之后,只要不超过阈值,就认为可以继续尝试 GC,而不主动 OOM
    1. 已占用 Heap 的阈值:默认 98%,建议设置为 90%
    2. GC 占用时间的阈值:默认 98%,建议设置为 90%

补充说明:

关于 CMS 垃圾收集器的「Full GC 触发条件」 CMSInitiatingOccupancyFraction 老年代的「堆使用率」,默认值:

  1. JDK 1.5:默认值为 68%

  2. JDK 1.6:默认值为 92%

关于 CMS,更多调优细节,参考:

2.2.4. G1 垃圾收集器(新生代、老年代)

G1,Garbage First:

G1 垃圾收集器,更多细节:

几个细节:

  1. G1 垃圾收集器的内存组织粒度:Region
  2. Region 大小:-XX:G1HeapReginSize 设置大小,一般为 1M~32M之间
  3. JVM最多支持2000个区域,可推算G1能支持的最大内存为2000*32M = 62.5G

2.2.5. CMS vs. G1

2.3. 实践:JVM 调优实践

单独进行一次分享和整理:

2.4. 补充

2.4.1. Young GC & Full GC

2.4.2. gc 过程中,全堆扫描

GC 分为 young gc 和 full gc:

  1. Young GC:年轻代,进行垃圾回收时,也存在「老年代」对象,持有「年轻代」对象的引用,因此,基本思路上,也是需要扫描老年代
    1. 数量少:经统计,「老年代」持有「新生代」对象引用的情况不足1%,根据这一特性JVM引入了卡表(card table)来实现这一目的,逻辑上,将「老年代」标识为一张张,每个 512B
    2. 卡表card table中,标识了哪个「老年代」区域,存在指向「年轻代」对象的引用
    3. Young GC 过程中,虚拟机依赖「卡表」,识别出哪些老年代中,存在指向「年轻代」的引用,避免了全堆扫描。
  2. Full GC:老年代,选取 CMS 作为垃圾收集器时,也存在「跨代引用」的问题
    1. 4 个阶段:会经历初始标记并发标记重新标记并发清理 4 个阶段
    2. 重新标记阶段,会进行全堆扫描,因为只有 young gc 才会进行「根搜索」,因此,只能「全表扫描」
    3. 为了解决上述重新标记阶段,暂停时间过长,可以设置参数,强制重新标记之前,进行一次 young gc,减少新生代存活对象数量,提升「全表扫描」速度;具体参数 CMSScavengeBeforeRemark 需要谨慎使用

几个方面:

  1. Young GC 过程中,需要找到「老年代」指向「新生代」的引用:采用卡表
    1. 老年代,划分为卡页,每个 512B;
    2. 单独存储一个卡表,标识哪个卡页存在指向年轻代的引用;
    3. 存在指向年轻代对象的卡页,对应的卡表记录,被标记为 dirty;
    4. 扫描 dirty卡表记录,从而避免全堆扫描
  2. Full GC 过程,不需要存储「新生代」到「老年代」的引用:
    1. 老年代的 GC 是低频操作
    2. 新生代,绝大多数对象,存活时间非常短;
    3. 如果记录「新生代」到「老年代」的引用,会耗费比较多的存储空间;
    4. 关于 CMS 垃圾收集器:并发标记阶段,应用线程和GC线程是并发执行的,因此可能产生新的对象或对象关系发生变化,例如:
      • 新生代的对象晋升到老年代;
      • 直接在老年代分配对象;
      • 老年代对象的引用关系发生变更;
      • 等等。
    5. 对于这些对象,需要重新标记以防止被遗漏,也是依赖 Card Table(卡表)。为了提高重新标记的效率,并发标记阶段会把这些发生变化的对象所在的Card标识为Dirty,这样后续阶段就只需要扫描这些Dirty Card的对象,从而避免扫描整个老年代。

更多细节,参考:

3. 讨论问题

讨论问题

4. 参考资料

同类文章:

微信搜索: 公众号 ningg ,即可联系我

Top