Java GC

都知道 Java 是通过垃圾回收来实现自动管理内存。

那么主流的到底有多少款垃圾回收器呢?不同的垃圾回收器使用到了什么垃圾回收算法,以及是如何定位“垃圾”对象的?如何通过 GC 日志来进行 JVM 调优?…

带着这些疑问来深入理解 Java GC 。


常见垃圾回收算法

如何确定一个对象是否可回收

其实就是一个如何定位垃圾对象的问题。

  • 引用计数

每当有一个强引用指向该对象,该对象的引用计数加一;每当有一个指向被取消,则引用计数减一。当引用计数达到 0 的时候,代表没有任何引用指向它,该对象应该被回收。其实就是 OC 中的 ARC 原理。

使用引用计数手段定位垃圾足够简单,但是无法解决“循环引用”对象无法被清除的问题:

假如有 A、B 和 C 三个对象。彼此之间相互指向,但是除此以外没有其他的引用指向他们,这时候三个对象的引用计数均为 1 ,不会被回收,但本身这三个对象不会再被使用。


  • 根对象可达性

除了被根对象(GC roots)直接或者间接引用的对象以外,都是垃圾。

哪些是根对象?

  1. JVM stack:线程栈变量
  2. native method stack:JVM 使用到的本地方法栈变量
  3. static references:静态变量
  4. Constants pool:常量池
  5. Class:由 JVM 加载进内存的 Class 对象

所以根对象可达性的 GC 流程为:

  1. 遍历所有的根对象,对每个根对象进行标记,并将根对象加入到 workList 中。
  2. 每次将 root 对象添加到 workList 中之后,调用 mark 方法。
  3. mark 方法会 pop 出 workList 里的对象,判断该对象是否“可达”,如果“可达”则标记,并添加到 workList 中。
  4. 当 mark 方法执行完毕,代表一个根对象及其所有直接和间接的“可达”对象标记完成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
markFromRoots():
workList <- empty
for each fld in Roots // 遍历所有的根对象
ref <- *fld
if ref != null && isNotMarked(ref) // 如果对象可达并没有被标记的,直接标记该对象并将其加到 workList 中
setMarked(ref)
add(workList,ref)
mark() // 调用 mark 方法,将当前根对象的所有可达对象进行标记

mark():
//(采用的是一边从 workList 头部取出,一边从 workList 的尾部添加的深度遍历方式)
while not isEmpty(workList)
ref <- remove(workList) // pop 出一个 workList 中的对象,赋值给 ref
for each fld in Pointers(ref) // 遍历 ref 对象的所有指针域
child <- *fld
if child != null && isNotMarked(child) // 如果间接对象也可达,将间接对象进行标记,并加入 workList 中
setMarked(child)
add(workList,child)


常见垃圾回收算法

首先,根据内存中对象的状态不同,内存分为“存活对象”、“未使用”和“可回收”三种状态。

  • Mark Sweep

Mark Sweep,标记清除算法。当一个对象被判定为垃圾的时候,不会对内存里的内容进行格式化,而是将这块内存标记为“未使用”状态。当这块内容被再次分配使用,并对其进行初始化的时候才会真正去修改内存里面的内容。

执行 Mark Sweep 算法后,可用位置不联系,会产生内存碎片,而且标记和清除阶段都要进行扫描(两遍扫描),效率较低。

标记-回收算法

  • Copying

Copying,拷贝算法。采用此算法首先要将区域划分为多个区(通常为两个区)。在触发 GC 时,将 A 区的所有存活对象拷贝到 B 区(存活对象在 B 区里面是顺序排列),然后清空原来的区域。新对象在 B 区进行分配,直到 B 区的空间满了,触发 GC ,再将 B 区的存活对象拷贝到 A 区,然后清空 B 区 …

由于拷贝算法是在内存中对存活对象进行拷贝,速度很快;同时将存活对象拷贝到新区时使用的是顺序排列,不会产生内存碎片,但是通常需要双倍空间。

拷贝算法

  • Mark Compact

Mark Compact,标记压缩算法。该算法会先执行一遍“标记”操作。确定内存区域中哪些对象是存活对象,哪些是可回收,之后会将后面的存活对象往前“挪动”,使用存活对象去填充那些“可回收”或者“未分配”的位置。

最终效果是,在同一块区域内,存活对象被移到了区域的同一边,未分配的空间则在另外一边。该算法的优点是不会像 Mark Sweep 那样产生内存碎片,也不会像 Copying 那样导致空间浪费。但该算法由于先执行一遍“标记”,再进行存活对象的“挪动”(执行两遍扫描),效率较低。

标记-压缩算法

其他概念

  • Safe Point

由于 Java GC 是采用“根可达算法”,也就是从 GC Roots 出发确定哪些是存活对并对其进行标记。所以首先需要确定哪些是对象是 GC Roots,而 GC Roots 是会随时变化的,要查找 GC Roots 的方法有两种:

  1. 保守式 GC:遍历方法区和栈区进行查找
  2. 准确式 GC:使用数据结构来记录 GC Roots 的引用位置

HotSpot 使用的是准确式 GC 的方式,采用的数据结构为 OopMap。

在确定使用 OopMap 来保存 GC Roots 信息之后,又会面临一个问题:什么时候生成 OopMap?如果执行每句指令后都去生成新的 OopMap 的话,将会严重影响执行性能。

所以 JVM 限定了当代码执行到了某些特殊的地方(点)才生成 OopMap 。这些点包括:

  1. 有界循环的结束处
  2. 无界循环的跳回处
  3. 方法返回处
  4. 异常抛出处

所以 Java GC 要等待所有正在运行的用户线程到达到最近的 Safe Point 才开始垃圾回收,目的是为了确保保存 GC Roots 信息的数据结构 OopMap 达到一个“已更新”的状态。

确保用户线程都达到 Safe Point 有两种方式:

  1. 抢断式中断:在 GC 发生时,中断所有线程,并对所有线程进行检查,如果发现线程未执行到 Safe Point,就恢复线程让其运行到 Safe Point 上。
  2. 主动式中断:在 GC 发生时,不直接操作线程中断,而是简单地设置一个标志,各个用户线程到达 Safe Point 时主动轮询这个标志,发现中断标志为真时就中断挂起。

HotSpot 使用的是主动式中断的方式。


  • Safe Region

Safe Point 是针对正在运行的用户线程而言的,如果一个用户线程本身就处于 Sleep 或 Interrupted 状态 ,是无法运行到最近的 Safe Point 的。所以除了 Safe Point 以外,JVM 还引入了 Safe Region 的概念。

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

线程在进入 Safe Region 的时候需要先标记已进入了 Safe Region,等到被唤醒时准备离开 Safe Region 时,先检查能否离开,如果 GC 完成了,那么线程可以离开,否则它必须等待直到收到安全离开的信号为止。


  • Card Table

事实上,虽然我们采用了“根可达算法”,但是要找出存活对象有哪些还是很困难的。想象一下,如果我们要进行一次 YGC ,那么我们需要从 GC roots 出发,找出所有 Young 区中被 GC roots 直接或者间接引用的对象。而现实情况中,我们仍然要通过访问 Old 区来实现(因为存在 GC roots 直接引用了 Old 区对象,而 Old 区对象又引用了 Young 区对象的情况,所有要找出所有 Young 区的存活对象,我们很可能要通过访问 Old 对象来实现)。

为了解决 YGC 时,需要频繁访问 Old 区的对象,JVM 将内存分为多个 Card ,多个对象会被放到一个 Card 中。如果 Old 区的某个 Card 中的对象引用到了 Young 区中的 Card 里的对象,会将该 Old 区的 Card 标记为 Dirty ,同时所有的 Dirty Card 存放在一个 BitMap 中。


  • CSet

CSet 是指 Collection Set,G1 回收器用到。作用是记录所有可以被回收的 Card 对象。当 GC 运行时,CSet 里的所有数据会被清除。CSet 带来的负面影响不超过 1%。


  • RSet

RSet 是指 Remember Set,G1 回收器用到。是指每个 Region 里面,都有一个 HashMap 记录其他 Region 中的对象到本 Region 的引用。RSet 的作用是使垃圾回收器不需要扫描整个堆来找到哪些对象引用了本 Region 中的对象,只需要检查 RSet 即可。

由于 RSet 的存在,会对赋值操作有一定的效率影响(需要额外记录一些引用记录),对 JVM 的内存也会有所影响,但负面影响不超 5% 。


  • 三色标记

主要是 CMS 和 G1 的并发标记阶段会用到三色标记算法。三色是指对于对象的标记状态的描述:

白色:自身未被标记

灰色:自身被标记,但成员变量未被标记

黑色:自己和成员变量都被标记

由于标记是并发的,即在将对象的标记颜色进行更改的同时(标记线程工作),对象之间的引用关系也会发生变化(用户线程工作)。所以会存在漏标的情况:假如一个对象已经被标记黑色之后,新引用了一个白色的对象,同时原本指向这个白色对象的引用被取消,因为原对象已经被标记为黑色,不会再被扫描,所以这个白色的对象被漏标了,将会被当做“垃圾”进行清除:

三色标记-漏标

解决漏标的方案有:

Incremental Update (CMS 采用) :对新添加引用的对象(如上例的 B)进行再次标记。效率较低,因为除了新引用的对象以外,之前被标记过的成员变量(B 的其他成员变量)也要被重新标记一遍。

snapshot at the beginning (G1 采用) :保留被删除引用记录(如上例中的 C -> D 中的 ->)。效率较高,当 D 的引用被删除,将这个被删除的引用(注意是引用,而不是 B 、C 或者 D)加入 GC 的堆栈,确保 D 还能够被扫描。


JVM 内存分代模型

JVM 的内存分代模型主要分为 Young 区(年轻代)和 Old 区(老年代),比例为 1 : 2。

  • Young 区(年轻代):对象刚创建的时候所在的区域
  • Old 区(老年代):对象存活一段时间后仍未被回收所在的区域;或新对象太大, Young 区(年轻代)没有足够空间分配,也会直接进入 Old 区(老年代)
  • Permanent 区(永久代):存放 Class 的相关信息、字符串常量和静态内容。一般很少被 JVM 进行回收。在 JDK 1.8 之后,不再使用 Permanent 区(永久代),原来放在 Permanent 区(永久代)的字符串创建被放到堆中,其他内容被放到一个叫 Meta Space (元数据区)的地方,。

永久代和元数据区的主要区别:永久代必须制定大小,而元数据区可以不设置大小;永久代是在 JVM 空间里,而元数据区不由 JVM 堆内存管理,由操作系统管理。

其中年轻代中分为一个 Eden 区 ,两个 Survivor 区(分别简称为 S0,S1),比例为 8 : 1 : 1。


无论是 Young 区(年轻代)还是 Old 区(老年代)都是对象直接分配内存的区域,都是堆区空间,所谓的分代只是堆内存的逻辑分区。

  1. 当一个新的对象要进行内存分配,优先在 Eden 区进行分配,除非是大对象才会直接进入 Old 区(可以在 JVM 启动参数 -XX:PretenureSizeThreshold 中设定大对象的阈值,大小超过该阈值的对象为大对象)。
  1. 当 Eden 区首次出现空间不足,会触发一次 GC ,这时候触发的 GC 是 MinnorGC,也称为 YoungGC(以下简称为 YGC )。这时候首先会对 Eden 区的对象进行一次可达性判断,然后对被标记的对象执行 Copying 拷贝算法,复制到 S0,之后清空 Eden 区。
  1. 然后 JVM 进行运行,在 Eden 区进行对象内存分配。当 Eden 区再次空间不足,会对 Eden 区和 S0 区的对象进行可达性判断,然后将标记的对象执行 Copying 拷贝算法,复制到 S1 ,清空 Eden 区和 S0 区。
  1. 再一次 Eden 区再次空间不足,会对 Eden 区和 S1 区的对象进行可达性判断,然后将标记的对象执行 Copying 拷贝算法,复制到 S0 ,清空 Eden 区和 S1 区。

可见存在两个 Survivor 区的意义是:一个 Survivor 区复制存放上一次 YGC 存活下来的对象,一个 Survivor 处于清空状态,为下次 YGC 存活对象提供空间。同时 8:1:1 的比例设计是考虑到在单次 Eden 区从零到空间不足的周期内,大约只有 10%-20% 的对象能够存活(绝大多数对象会被回收)。

每当一个对象在一次 YGC 中存活下来,年龄就会增加一岁(分代年龄会被被记录在对象的 Mark Word 中),当年龄达到最大值 15 (因为在 Mark Word 中使用了 4 bit 记录分代年龄,4 bit 最大值就是 1111 ,转化为十进制数是 15 ),会进入 Old 区(老年代)。

而在 Old 区(老年代)出现内存不足时,会触发 MajorGC ,也称为 FullGC(以下简称为 FGC)。与 YGC 不同,FGC 由于 GC 的空间更大(Young 区 : Old 区 = 1 : 3),并且采用的时候标记压缩算法(YGC 使用的是拷贝算法),所以 FGC 导致的 STW (stop-the-world)的时间更长。

FGC 是我们想要尽量避免的, 很多时候我们进行 JVM 调优,目的是为了降低 FGC 的频率。

Young 区发生的 GC 使用的是 Copying 拷贝算法,而 Old 区发生的 GC 使用的是 Mark Compact 标记压缩算法。

最后还有一个小细节要说明,由于对象优先是按顺序分配在 Eden 区,当出现高并发的情况(多个线程想要同时在 Eden 区的某个位置申请空间),会出现资源抢夺问题,如果采用上锁的策略,无论是总线锁还是缓存锁都会降低效率。所以 JVM 针对这个问题做了一个隔离优化:线程本地分配(Thread Local Allocation Buffer),简称 TLAB。具体做法是:默认为每个线程分配 1% 的 Eden 区空间,每个线程在自己独立的 TLAB 中分配内存,从而避免了线程争用问题(当线程数量超过 100 个,或者为某个线程分配的空间不足,会触发比例重新分配或者多个线程搭配锁策略共同使用一个 TLAB 区域)。

可以使用 -XX:DoEscapeAnalysis & XX:UseTLAB 来控制是否栈上分配和 TLAB 的使用:

参数 小对象优先分配区域 速度
-XX:+DoEscapeAnalysis(开逃逸分析)
-XX:+UseTLAB / -XX:-UseTLAB(开启/关闭 TLAB )
栈上分配
-XX:-DoEscapeAnalysis(关闭逃逸分析)
-XX:+UseTLAB(开启 TLAB )
Eden 区中的 TLAB 分配 一般
-XX:-DoEscapeAnalysis(关闭逃逸分析)
-XX:-UseTLAB(关闭 TLAB )
Eden 区抢夺式分配

在分代模型下,对象何时进入老年代

  • 分代年龄达到阈值

    在不同的垃圾回收器中,阈值有所不同:

    1. Parallel 系列垃圾回收器:当对象的分代年龄达到 15 就进入 Old 区。
    2. CMS 垃圾回收器:当对象的分代年龄达到 6 就进入 Old 区。
    3. G1垃圾回收器:当对象的分代年龄达到 15 就进入 Old 区。
  • 动态年龄

    是指发生某种情况下,不一定是达到年龄阈值,对象也会进入老年代。

    Eden & Survivor 执行拷贝算法,存活对象数量超过了 Survivor 区的一半时,将存活对象中年龄最大的对象移至 Old 区。例如,在 Eden & S0 执行拷贝算法时,被移动到 S1 的存活对象总大小超过 S1 空间大小的一半,将存活对象中分代年龄最大的(通常为多个,其实就是最久一次执行拷贝算法存活至今的那一批对象)直接移到 Old 区。

  • 分配担保

    是指在 YGC 期间,需要分配内存但是 Survivor 空间不足,将会把对象直接分配到 Old 区。


常见的垃圾回收器

在 Java 发展过程中,出现了多款垃圾回收器。根据是否采用分代模型分为“分代垃圾回收器”和“不分代垃圾回收器”两大类,而且不同的垃圾回收器适用于不同的场景(通常根据内存大小来决定使用什么垃圾回收器)。

通用 GC 参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
-Xms2g                                              ## 初始化堆内存大小
-Xmx2g ## 堆内存最大值,为了避免堆弹性变化,影响性能,一般将“堆起始内存”和“堆最大内存”设置为一样
-Xmn256m ## 年轻代内存大小,整个 JVM 内存 = 年轻代 + 年老代 + 持久代
-Xss256k ## 设置每个线程的堆栈大小
-XX:PermSize=256m ## 持久代内存大小
-XX:MaxPermSize=256m ## 最大持久代内存大小

-verbose:class ## 跟踪类的加载和卸载
-XX:+TraceClassLoading ## 跟踪类的加载
-XX:+TraceClassUnloading ## 跟踪类的卸载

-XX:+PrintFlagsInitial ## 显示所有可设置参数及默认值
-XX:+PrintFlagsFinal ## 显示所有可设置参数及最终值
-XX:+PrintCommandLineFlags ## 打印传递给虚拟机的显示和隐式参数

-Xloggc:../log/gc.log ## 指定 GC 日志文件的输出路径
-XX:+PrintGC ## 输出 GC 日志
-XX:+PrintGCDetails ## 输出 GC 详细日志
-XX:+PrintHeapAtGC ## 在进行 GC 的前后打印出堆的信息
-XX:+PrintTenuringDistribution ## 参数观察各个 Age 的对象总大小
-XX:+PrintGCTimeStamps ## 输出 GC 的时间戳(以基准时间的形式,该时间为虚拟机启动后的时间偏移量)
-XX:+PrintGCDateStamps ## 输出 GC 的时间戳(以日期的形式,如 2013-05-04T21:53:59.234+0800)
-XX:+PrintGCApplicationConcurrentTime ## 输出 GC 之间运行了多少时间
-XX:+PrintGCApplicationStoppedTime ## GC 造成应用暂停的时间

-XX:+PrintReferenceGC ## 跟踪系统内的软引用,弱引用,虚引用和 Finalize 队列
-XX:+PrintClassHistogram ## 在运行时查看系统中类的分布情况,在 Java 控制台按 Ctrl + Break 组合键,控制台就会显示当前的类信息柱状图。

-XX:+AlwaysPreTouch ## 强制操作系统把内存真正分配给 JVM
-XX:+DisableExplictGC ## 禁止显示调用 GC: System.gc()

-XX:+UseTLAB ## 使用 TLAB
-XX:+PrintTLAB ## 打印TLAB分配相关信息
-XX:TLABSize ## 设置TLAB大小
-XX:+ResizeTLAB ## 自动调整TLAB大小

1. 分代垃圾回收器:

Serial

Serial 系列垃圾回收器:逻辑 + 物理分代。分为 Serial( YGC )和 Serial Old( FGC );是一款单线程垃圾回收器,串行回收。更适合小内存的 Client 模式下使用。

Serial(蓝色代表用户线程、黄色代表GC线程)

Serial 垃圾回收器负责 GC 标记阶段(使用的是拷贝算法),而 Serial Old 垃圾回收器负责 GC 清除阶段(使用的是标记压缩算法)。

Serial 相关参数:

1
2
3
4
5
6
-XX:+UseSerialGC                                 ## 使用 Serial & Serial Old

-XX:NewRatio ## 新生代与年老代的比例,默认为 2。比如为 3,则新生代 : 老年代 = 1 : 3
-XX:SurvivorRatio ## 新生代中调整 Eden 区与 Survivor 区的比例,默认为 8。即 Eden 区为 80% 的大小,两个 Survivor 分别为 10% 的大小
-XX:PreTenureSizeThreshold ## 晋升年老代的对象大小。默认为0,比如设为 10M ,则超过 10M 的对象将不在 Eden 区分配,而直接进入年老代
-XX:MaxTenuringThreshold ## 晋升老年代的最大年龄。默认为 15,比如设为 10,则对象在 10 次普通 GC 后将会被放入年老代


Parallel

Parallel 系列垃圾回收器:逻辑 + 物理分代。分为 Parallel Scavenge(YGC)和 Parallel Old(FGC);是多线程的垃圾回收器,并行回收。

Parallel(蓝色代表用户线程、黄色代表GC线程)

Parallel Scavenge 垃圾回收器负责 GC 标记阶段(使用的是拷贝算法),而 Parallel Old 垃圾回收器负责 GC 清除阶段(使用的是标记压缩算法)。

Parallel Scavenge + Parallel Old 是 Java 8 默认的垃圾回收器组合。使用 -XX:MaxGCPauseMillis-XX:GCTimeRatio 可以设定“最大垃圾收集时间”和“吞吐量大小”(默认吞吐量是 99% )。

另外 Parallel Scavenge 还提供了 -XX:UseAdaptiveSizePolicy 参数,一旦开启该设置后将不再需要设置新生代大小以及 Eden 区和 Survivor 区的比例,只需要设置好堆大小( -Xmx 参数)即可。JVM 将会动态调节这些参数来尽可能满足我们设定的“最大垃圾收集时间”和“吞吐量大小”指标。

Parallel 相关参数:

1
2
3
4
5
6
7
8
9
10
11
-XX:+UseParallelGC                               ## 使用 Parallel Scavenge & Parallel Old

-XX:NewRatio ## 新生代与年老代的比例,默认为 2。比如为 3,则新生代 : 老年代 = 1 : 3
-XX:SurvivorRatio ## 新生代中调整 Eden 区与 Survivor 区的比例,默认为 8。即 Eden 区为 80% 的大小,两个 Survivor 分别为 10% 的大小
-XX:PreTenureSizeThreshold ## 晋升年老代的对象大小。默认为0,比如设为 10M ,则超过 10M 的对象将不在 Eden 区分配,而直接进入年老代
-XX:MaxTenuringThreshold ## 晋升老年代的最大年龄。默认为 15,比如设为 10,则对象在 10 次普通 GC 后将会被放入年老代

-XX:+ParallelGCThreads ## 设置 STW 期间 GC 工作线程数
-XX:MaxGCPauseMillis ## 最大垃圾收集时间
-XX:GCTimeRatio ## 吞吐量大小
-XX:+UseAdaptiveSizePolicy ## 自动选择各区大小比例。自适应 GC 策略,在这种模式下,新生代的大小,Eden 和 Survivor 的比例,晋升老年代的对象年龄参数会自动调整,以达到在堆大小、吞吐量和停顿时间的平衡点


CMS

CMS (Concurrent Mark Sweep)垃圾回收器 :逻辑 + 物理分代。分为 ParNew(YGC)和 CMS(FGC)。也是并行回收器。效率要比 Parallel 垃圾回收器要高。

CMS 垃圾回收器能够在 GC 线程回收的时候,不停止用户线程。采用的是三色标记 + Incremental Update。

使用 -XX:+UseConcMarkSweepGC 启动参数,可以指定垃圾回收器组合为:ParNew + CMS + Serial Old(备用)。

CMS(蓝色代表用户线程、黄色代表GC线程)

ParNew 垃圾回收器负责 GC 标记阶段(使用的是拷贝算法),而 Parallel Old 垃圾回收器负责 GC 清除阶段(使用的是标记清除算法)。ParNew 和 Parallel Scavenge 的最大区别是调优方向的不同:Parallel Scavenge 的调优目的是为了提高吞吐量,而 ParNew 的调优目的是为了缩短响应时间

CMS 分为几个阶段:

  1. 初始标记:对应 GC 日志中的 CMS initial mark 。将 GC Roots 直接引用的对象进行标记。进行该操作时,所有的用户线程会被停止,会导致 STW ,但通常时间很短。使用的是单线程进行初始标记。
  1. 并发标记:对应 GC 日志中的 CMS concurrent mark 。将那些被 GC Roots 间接引用的对象进行标记(包括在“初始标记”阶段标记的对象所直接/间接引用的对象),相当于会遍历整个老年代并标记存活对象。这是耗时最长的阶段,因为要遍历所有的堆对象,所以在该阶段 CMS 实现为 GC 线程(多个)和用户线程并发执行。如果将 JVM 启动参数由 -XX:+PrintGC 改为 -XX:+PrintGCDetails ,会发现其实并发标记阶段可以细分为以下几步:

    1. CMS concurrent mark 阶段,由于用户线程和 GC 线程是并行执行,所以存在已经遍历过的对象又被用户线程改变了的状态,针对这种对象,JVM 会将这些对象所在的区域标记为 Dirty Card 。
    2. 在对老年代遍历完成之后,会进入 CMS-concurrent-preclean 阶段。该阶段会对上一阶段标记为 Dirty Card 的区域进行重新的遍历标记。
    3. 进入 CMS-concurrent-abortable-preclean 并发预清理阶段,会尝试着去承担下一阶段(会导致 STW 的 CMS Final Remark )足够多的工作。这个阶段是重复的做相同的事情直到发生 aboart 的条件(比如:重复的次数、多少量的工作、持续的时间等等)才会停止。这个阶段很大程度的影响着下一阶段 CMS Final Remark 的 STW 的长短。
  1. 最终标记:对应 GC 日志中的 CMS Final Remark 。由于在“并发标记”阶段,用户线程和 GC 线程同时运行,所以会导致有部分新产生的“垃圾”对象没有被标记,所以在“并发标记”阶段结束后,CMS 会停掉所有的用户线程(防止和在“并发标记”阶段一样,标记期间产生新的“垃圾”对象),再一次重新标记。该阶段会导致 STW ,但和初始标记一样,通常时间很短。同样的,通过 GC 日志可以发现,CMS Final Remark 阶段也细分为以下几步:

    1. weak refs processin 阶段:处理弱引用。
    2. class unloading 阶段:卸载无用的类。
    3. scrub symbol table 阶段:清理分别包含类级元数据和内部化字符串的符号和字符串表。

    注意:在最终标记阶段,只会标记那些在并发标记阶段变化过的对象,而不是全部重新标记一遍(如果是那样的话,之前的并发标记就没有意义)。JVM 是通过指针对象(在不开启指针压缩的 64 位机器上,指针是占用 8 个字节,64 位)中的 3 位来判断是否变化过,当一个指针从指向一个对象变为指向另外一个对象,这 3 位记录值将会发生变化。

  1. 并发清理:对应 GC 日志中的 CMS concurrent sweep 。这个阶段是真正清理对象。耗时相对长,CMS 实现了 GC 线程(单个)和用户线程同时执行,也正因为同时执行,所以在该阶段产生的新的“垃圾”对象(浮动垃圾)只能等待下次 GC 回收。CMS concurrent sweep 阶段包含以下几步:

    1. CMS-concurrent-sweep 阶段:清除没有被标记的“垃圾”对象并回收内存。
    2. CMS-concurrent-reset 阶段:收拾 CMS GC 后的现场,重置 CMS GC 算法内部的数据结构,供下次 CMS GC 使用。

CMS 的不足:

  1. 内存碎片:由于 CMS 在采用的是 Mark Sweep 算法,所以 CMS 会产生内存碎片。所以 CMS 不适用于大内存,一旦老年代清理出来的空间有较多的分散内存碎片,那么从年轻代过来的对象或者大对象可能就找不到一块连续的空间来存放。
  1. 触发使用 Serial Old 垃圾回收器:由于存在内存碎片问题,所以当 Old 区有足够空间但是没有连续的内存可分配的时候,会使用 Serial Old 垃圾回收器执行 Mark Compact 算法,而 Serial Old 是单线程 GC 回收器,同时执行 Mark Compact 相对耗时,所以会导致 STW 时间较长。
  1. 降低吞吐量:由于用户线程和 GC 线程并行,所以会大大降低吞吐量。CMS 默认启动的回收线程数 = ( CPU 数量 + 3 ) / 4,如果只有一个或者两个 CPU ,那吞吐量就直接下降 50%,这样对吞吐量的影响将是巨大的。
  1. 无法清除“浮动垃圾”:可能出现 Concurrent Mode Failure 而引发另一次 Full GC 。由于在并发清理阶段用户线程还在运行,所以清理的同时会产生新的“垃圾”,而这部分“垃圾”只能等待下一次 GC 清理。也是由于在并发清理阶段用户线程仍要继续运行,所以需要预留足够多的空间要确保用户线程正常执行,这就意味着 CMS 收集器不能像其他收集器一样等老年代满了再进行 Full GC。

针对 CMS 内存碎片化以及出现 Concurrent Mode Failure 后触发的 Serial Old 运行的问题,可以通过设置以下参数来缓解问题:

  • -XX:CMSInitiatingOccupancyFraction:可以通过该 JVM 参数来设置当老年代使用比例达到多少执行 CMS ( 默认是 68% )。如果该设置设定过高,则会导致并发清理阶段用户线程空间不足出现 Concurrent Mode Failure。最终的结果可能是触发备用收集器 Serial Old 执行 GC,而 Serial Old 是单线程的,会导致较长的 STW ;设置值过小,频繁触发 GC ,没有发挥大内存的作用。生产上该参数需要多次细调才能确定一个较好的值。
  • -XX:UseCMSCompactAtFullCollection:这个前面也提过,用于在每一次 CMS 收集器清理垃圾执行一次内存整理。
  • -XX:CMSFullGCsBeforeCompaction:设置在几次 CMS 垃圾收集后,触发一次内存整理。

关于 -XX:CMSInitiatingOccupancyFraction ,在查阅资料的时候发现 HotSpot 本来定的默认值是 92% ,后来将默认值调整到 68% 。其实可以大概猜出这个值设定的初衷:HotSpot 原本希望当非年轻代临近满(92%)的时候开启并发收集,收集过程中产生的“垃圾”在剩余空间(8%)中存放已经足够,但 HotSpot 没想到的是现在内存越来越大,动辄几十兆,导致整个并发清除过程时间很长,在这么长的时间内产生的“垃圾”数量很多(也有高并发的加持),8% 的空间根本不够存放。最终导致频繁触发 Serial Old 的 Full GC。这样也给了我们警醒:对于内存较大、并发清除时间较长、并发较高、产生“垃圾”速度较快的场景,对于该参数值的调高要十分谨慎。


CMS 相关参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
-XX:+UseConcMarkSweepGC                             ## 使用 ParNew & CMS

-XX:+CMSConcurrentMTEnabled ## CMS 收集时是否使用多个线程,默认打开
-XX:ParallelCMSThreads ## CMS 线程数量
-XX:+UseCMSInitiatingOccupancyOnly ## 只允许使用设定的回收阈值(-XX:CMSInitiatingOccupancyFraction)进行 CMS 收集;如果不开启,JVM 仅在第一次使用设定值,后续则自动调整
-XX:CMSInitiatingOccupancyFraction ## 设置当老年代使用比例达到多少后开始 CMS 收集(默认为 68%)。当该值调得过小,则会频繁触发 GC ,没有发挥大内存的优势;当该值调得过大,则会导致在 CMS 执行 GC 过程中,可能老年代没有足够空间装年轻代过来的对象
-XX:+UseCMSCompactAtFullCollection ## CMS 在完成垃圾收集后是否进行一次内存碎片整理
-XX:CMSFullGCsBeforeCompaction ## 每经历多少次 CMS 收集之后对老年代执行一次标记压缩算法
-XX:+CMSClassUnloadingEnabled ## 允许永久代/元数据区进行回收
-XX:CMSInitiatingPermOccupancyFraction ## 当永久代/元数据区占有率达到该比例时,启动 CMS 回收(前提是 -XX:+CMSClassUnloadingEnabled 打开的)

-XX:+CMSScavengeBeforeRemark ## CMS remark 前回收年轻代内存
-XX:+ScavengeBeforeFullGC ## Full GC 前回收年轻代内存,默认开启

-XX:GCTimeRatio ## 设置 GC 时间占用程序运行时间的百分比
-XX:MaxGCPauseMillis ## 停顿时间,是一个建议时间,GC 会尝试用各种手段达到这个时间,比如减小年轻代


G1

G1:没有物理上的 Young 区和 Old 区,只是在逻辑上进行分代。将堆划分成多个不连续的大小相同的区域(Region)。

G1 布局

Region 中除了 Eden 、Survivor 和 Old 以外,还标明了 H ,它代表 Humongous 。这表示这些 Region 存储的是巨大对象( humongous object,H-obj ),即大小大于等于 Region 一半的对象。H-obj 有如下几个特征:

  • H-obj 直接分配到了Old Gen,防止了反复拷贝移动

  • H-obj 在 global concurrent marking 阶段的 Cleanup 和 Full GC 阶段回收

  • 在分配 H-obj 之前先检查是否超过 initiating heap occupancy percentthe marking threshold,如果超过的话,就启动 global concurrent marking ,为的是提早回收,防止 evacuation failures 和 Full GC

G1 采用的是三色标记 + SATB(snapshot at the beginning),适用于不需要实现较高吞吐量,但需要实现较短响应速度的场景。G1 的优势在于:

  1. 与 CMS 收集器一样,能让用户线程和 GC 线程并发执行
  2. 整理空闲空间更快
  3. 能更好预测 GC 停顿时间
  4. 不会像 CMS 那样牺牲大量的吞吐量
  5. 不需要更大的 Heap 空间

相比于 CMS ,G1 以下方面表现的更出色:

  • G1是一个有整理内存过程的垃圾收集器,不会产生很多内存碎片
  • G1的 STW 更可控,G1在停顿时间上添加了预测机制,用户可以指定期望停顿时间

由于 G1 是逻辑分代,也就是每个 Region 有可能是年轻代有可能是老年代,但在单次 GC 回收周期内,只能是特定的某个代。G1 之所以采取物理不分代,保留逻辑分代的设计,是为了既能复用之前分代模型的逻辑,又能避免以前分代模型出现的:“某个分区垃圾很多,某个分区垃圾很少”的情况。

每个 Region 可能会装一个或多个对象,G1 会在内部维护着一个“价值表”,记录着哪些 Region 有着较多的可回收对象,并优先回收那些“垃圾”多的 Region。也就是 Grabage First,垃圾优先。

刚刚说到每个 Region 可能是年轻代,可能是老年代。也就是 G1 是动态调整新生代和老年代的比例的。动态调整年轻代和来年代的比例也是为了能够将 GC 暂停时间尽可能靠近我们设定的值。建议使用 G1 GC 的时候不要指定这个比例,这是 G1 用作预测 GC 时间的基准。


G1 的 Young GC:

G1 的 Young GC 会导致 STW 。选定所有年轻代里的 Region ,通过控制年轻代的 Region 个数(即年轻代内存大小)来控制 Young GC 的时间开销。


G1 的 Mixed GC:

G1 的 Mixed GC 会导致 STW 。选定所有年轻代里的 Region ,外加根据 global concurrent marking 统计得出收集收益高的若干老年代 Region。在用户指定的开销目标范围内尽可能选择收益高的老年代 Region 。

global concurrent marking 的执行过程与 CMS 类似,都是包括“起始标记”、“并发标记”、“最终标记”和“筛选回收”几步:

  • 初始标记(initial mark,STW):标记从 GC Root 开始直接可达的对象。
  • 并发标记(Concurrent Marking):这个阶段从 GC Root 开始对 Heap 中的对象标记,标记线程与应用程序线程并行执行,并且收集各个Region的存活对象信息
  • 最终标记(Remark,STW):标记那些在并发标记阶段发生变化的对象,将被回收
  • 筛选回收(Cleanup):清除空Region(没有存活对象的),加入到free list

G1 允许通过 JVM 参数 -XX:InitiatingHeapOccupancyPercent 设定一个阈值(默认为 45%),当非年轻代的堆( Old + Humongous )的使用比例达到阈值时,会触发 Mixed GC 。


G1 的 Full GC:

事实上,G1 只提供了 Young GC 和 Mixed GC,但 Mixed GC 并不会回收所有的老年代 Region。但如果 Mixed GC 实在无法跟上程序分配内存的速度,导致老年代填满无法继续进行 Mixed GC ,就会使用 Full GC 来收集整个 Heap。

注意:G1 的 Full GC 在 Java 10 之前是单线程回收,相当于使用的 Serial 回收算法;在 Java 10 之后使用的是多线程回收,相当于使用 Parallel 回收算法。

如果使用 G1 GC,仍然频繁出现 Full GC。除了进行内存容量扩展(降低回收的频率)和 CPU 扩展(提高回收的速度)之外;还可以降低 Mixed GC 触发的阈值( JVM 参数 -XX:InitiatingHeapOccupancyPercent ),让 Mixed GC 尽量提前发生。


总结:如果遇到 Eden 区空间分配不足,只会执行 YGC ,只会回收年轻代;而当非年轻代( Old + Humongous )的 Heap 使用比例达到了设定的阈值,将会触发 Mixed GC,回收所有的年轻代和根据设定时间尽可能回收存活对象少的老年代;如果 Mixed GC 的速度跟不上内存使用速度,将会触发 Full GC,在 Java 10 之前是使用 Serial Old 单线程回收,Java 10 之后是使用 Parallel Old 多线程回收。


G1 的相关参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
-XX:+UseG1GC                                   ## 使用 G1 

-XX:G1HeapRegionSize ## Region 的大小,值是 2 的幂;范围是 1MB 到 32MB 之间;随着 size 增加,垃圾的存活时间更长,GC 间隔更长,但每次 GC 的时间也会更长
-XX:G1NewSizePercent ## 新生代最小比例,默认为 5%
-XX:G1MaxNewSizePercent ## 新生代最大比例,默认为 60%
-XX:InitiatingHeapOccupancyPercent ## 当非年轻代( Old + Humongous )的堆使用比例达到阈值时,会触发 Mixed GC ,默认是 45%

-XX:MaxGCPauseMillis ## 最大垃圾收集停顿时间,默认值 200ms,不是硬性条件;G1 会调整 Young 区的 Region 数量来尽量达到
-XX:GCPauseIntervalMillis ## GC 间隔时间

-XX:ConcGCThreads ## 并行标记的数线程数量
-XX:ParallelGCThreads ## 设置 STW 期间 GC 工作线程数
-XX:GCTimeRatio ## 设置 GC 时间占用程序运行时间的百分比


2. 不分代垃圾回收器:

ZGC

采用的是 ColoredPointer + 读屏障算法。


Shenandoah

采用的是 ColoredPointer + 读屏障算法。


Epsilon

Epsilon:主要是 Debug 时候使用的垃圾回收器。其实就是没做任何回收操作的垃圾回收器。


所有的垃圾回收算法都会导致 STW ,只是时间长短问题。GC 的发生往往有个触发点(通常是内存不够了),一旦需要执行 GC ,用户线程将会被停止,但用户线程通常不能做到说停就停(比如某条原子指令执行到一半、比如马上 GC Roots 要发生变化),所以为了权衡这种情况,会有一个 safe point 的概念,代表用户线程能够被安全停止的时间点。

safe point:safe point 是 Java 代码中一个可以允许线程暂停的地方,该位置保存了线程上下文的所有信息(通过为 GC 生成 OopMap 数据结构来保存栈上和寄存器上的 GC Roots 指针)。在 HotSpot JVM 中,任何一个用户线程到达 Safe Point 的时候都会对一个标识位进行检查,来决定是否需要暂停执行。

CMS 的 STW 时长可以控制在大约 200 ms 以内;G1 的 STW 在 10 ms 以内;而 ZGC 和 Shenandoah 的 STW 在 1 ms 以内。

JDK 1.8 默认的垃圾回收器是 Parallel Scavenge + Parallel Old。

通过命令 java -XX:+PrintCommandLineFlags 可以查看当直接用 java 命令运行一个程序时,有哪些参数会被使用:

1
2
3
4
5
6
-XX:InitialHeapSize=268435456 
-XX:MaxHeapSize=4294967296
-XX:+PrintCommandLineFlags
-XX:+UseCompressedClassPointers
-XX:+UseCompressedOops
-XX:+UseParallelGC


各款垃圾回收器的简单对比:

垃圾回收器 适用内存范围 GC 算法(YGC + FGC) GC 线程
Serial < 100 M 拷贝算法 / 标记压缩算法 单线程
Parallel 100M ~ 10G 拷贝算法/ 标记压缩算法 多线程
CMS 10 G ~ 30 G 拷贝算法 / 标记清除算法 多线程 + 并发
G1 > 100G 拷贝算法 + 标记压缩算法 多线程 + 并发
ZGC TB 级别内存 不分代 多线程 + 并发

常见的垃圾回收器参数组合:

  • -XX:+UseSerialGC :Serial + Serial Old
  • -XX:+UseParNewGC :ParNew + Serial Old
  • -XX:+UseConcMarkSweepGC :ParNew + CMS + Serial Old
  • -XX:+UseParallelGC (JDK 1.8 默认) :Parallel Scavenge + Parallel Old
  • -XX:+UseParallelOldGC :Parallel Scavenge + Parallel Old
  • -XX:+UseG1GC :G1

GC 日志分析

分析目的

分析日志的目的是为了进行 JVM 调优,调优的主要目的是为了降低 FGC 的频率。

先了解两个基本概念,它们通常会作为 JVM 调优的方向:

  • 吞吐量:业务代码时间 / (业务代码时间 + GC 时间)

  • 响应时间:返回响应的时间 - 收到请求的时间


日志相关配置

1
2
3
4
5
6
7
8
-Xloggc:/opt/logs/project—name-gc-%t.log     ## 指定文件名字格式, %t 为日志文件创建时间
-XX:NumberOfGCLogFiles=5 ## 日志文件数量
-XX:GCLogFileSize=20M ## 每个日志文件的大小限制,则当前配置总日志大小为 5 * 20 = 100M
-XX:+UseGCLogFileRotation ## 循环使用,当第 6 个日志文件创建时,会删掉第一个(最早)日志文件

-XX:+PrintGCDetails ## 记录 GC Detail 信息
-XX:+PrintGCDateStamps ## 记录 GC 发生时间
-XX:+PrintGCCause ## 记录 GC 触发原因


分析案例

由于 Serial 和 Parallel 比较相似,同时 Parallel 是 JDK 1.8 的默认垃圾回收器。以 Parallel 为例分析一下 GC 日志。

案例代码,不断创建大小为 1M 的对象,直到 OOM:

1
2
3
4
5
6
7
public static void main(String[] args) {
LinkedList<Object> list = new LinkedList<>();
for (;;) {
byte[] bytes = new byte[1024 * 1024];
list.add(bytes);
}
}


Parallel Scavenge & Parallel Old

使用 -Xmn10M -Xms50M -Xmx50M -XX:+PrintCommandLineFlags -XX:+PrintGCDetails JVM 启动参数后 GC 日志:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

-XX:InitialHeapSize=52428800 -XX:MaxHeapSize=52428800 -XX:MaxNewSize=10485760 -XX:NewSize=10485760 -XX:+PrintCommandLineFlags -XX:+PrintGCDetails -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:+UseParallelGC

[GC (Allocation Failure) [PSYoungGen: 7622K->1001K(9216K)] 7622K->4202K(50176K), 0.0025653 secs] [Times: user=0.02 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [PSYoungGen: 8491K->832K(9216K)] 11693K->11209K(50176K), 0.0034571 secs] [Times: user=0.01 sys=0.01, real=0.00 secs]
[GC (Allocation Failure) [PSYoungGen: 8232K->848K(9216K)] 18609K->18401K(50176K), 0.0033234 secs] [Times: user=0.02 sys=0.01, real=0.00 secs]
[GC (Allocation Failure) [PSYoungGen: 8169K->873K(9216K)] 25723K->25595K(50176K), 0.0032531 secs] [Times: user=0.01 sys=0.01, real=0.01 secs]
[GC (Allocation Failure) [PSYoungGen: 8196K->809K(9216K)] 32918K->32699K(50176K), 0.0032527 secs] [Times: user=0.01 sys=0.01, real=0.00 secs]
[Full GC (Ergonomics) [PSYoungGen: 809K->0K(9216K)] [ParOldGen: 31890K->32411K(40960K)] 32699K->32411K(50176K), [Metaspace: 3250K->3250K(1056768K)], 0.0102645 secs] [Times: user=0.06 sys=0.01, real=0.01 secs]
[GC (Allocation Failure) [PSYoungGen: 7325K->224K(5120K)] 39736K->39803K(46080K), 0.0032945 secs] [Times: user=0.01 sys=0.01, real=0.01 secs]
[Full GC (Ergonomics) [PSYoungGen: 224K->0K(5120K)] [ParOldGen: 39579K->39578K(40960K)] 39803K->39578K(46080K), [Metaspace: 3250K->3250K(1056768K)], 0.0063474 secs] [Times: user=0.04 sys=0.00, real=0.00 secs]
[Full GC (Ergonomics) [PSYoungGen: 3147K->2048K(5120K)] [ParOldGen: 39578K->40602K(40960K)] 42725K->42650K(46080K), [Metaspace: 3250K->3250K(1056768K)], 0.0029575 secs] [Times: user=0.02 sys=0.00, real=0.01 secs]
[Full GC (Ergonomics) [PSYoungGen: 3072K->3072K(5120K)] [ParOldGen: 40602K->40602K(40960K)] 43674K->43674K(46080K), [Metaspace: 3250K->3250K(1056768K)], 0.0030473 secs] [Times: user=0.01 sys=0.00, real=0.00 secs]
[Full GC (Allocation Failure) [PSYoungGen: 3072K->3072K(5120K)] [ParOldGen: 40602K->40586K(40960K)] 43674K->43658K(46080K), [Metaspace: 3250K->3250K(1056768K)], 0.0076845 secs] [Times: user=0.05 sys=0.00, real=0.01 secs]

Heap
PSYoungGen total 5120K, used 3221K [0x00000007bf600000, 0x00000007c0000000, 0x00000007c0000000)
eden space 4096K, 78% used [0x00000007bf600000,0x00000007bf9257d8,0x00000007bfa00000)
from space 1024K, 0% used [0x00000007bff00000,0x00000007bff00000,0x00000007c0000000)
to space 3072K, 0% used [0x00000007bfa00000,0x00000007bfa00000,0x00000007bfd00000)
ParOldGen total 40960K, used 40586K [0x00000007bce00000, 0x00000007bf600000, 0x00000007bf600000)
object space 40960K, 99% used [0x00000007bce00000,0x00000007bf5a28e8,0x00000007bf600000)
Metaspace used 3282K, capacity 4556K, committed 4864K, reserved 1056768K
class space used 353K, capacity 392K, committed 512K, reserved 1048576K

Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
...

主要输出内容包括三部分:

  1. GC 记录

通常一条 GC 记录包括以下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
[
GC ## GC类型(GC or Full GC)
(Allocation Failure) ## 触发本次 GC 的原因
[PSYoungGen: 7622K->1001K(9216K)] 7622K->4202K(50176K), 0.0025653 secs] ## 分代名称(新生代 or 老年代): 回收前该分代所占空间 -> 回收后该分代所占的空间 (该分代的总空间), 回收前整个堆所占的空间 -> 回收后整个堆所占的空间, 回收时间
[Times: user=0.02 sys=0.00, real=0.00 secs ## GC 时间,和 Linux 时间对应: 用户态消耗时间, 内核态消耗时间, 实际消耗时间
]

[
Full GC
(Ergonomics)
[PSYoungGen: 809K->0K(9216K)]
[ParOldGen: 31890K->32411K(40960K)] 32699K->32411K(50176K), [Metaspace: 3250K->3250K(1056768K)], 0.0102645 secs] ## Full GC 在 Young GC 的基础上多了老年代和元数据区的 GC 前后空间大小打印
[Times: user=0.06 sys=0.01, real=0.01 secs
]

  1. “堆空间”信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
Heap

## Young 区的大小状况:总大小为 5120K ,已使用 3221K
PSYoungGen total 5120K, used 3221K
## Young 区的起始地址,使用空间结束地址,Young 区的结束地址
[0x00000007bf600000,0x00000007c0000000, 0x00000007c0000000)

## Eden 区的使用状况:总大小为 4096K,已使用 78%
eden space 4096K, 78% used
## Eden 区的起始地址,使用空间结束地址,Eden 区的结束地址
[0x00000007bf600000,0x00000007bf9257d8,0x00000007bfa00000)

## from 其实就是 Survivor 区中的一个:总大小 1024K ,已使用 0%
from space 1024K, 0% used
## from 区的起始地址,使用空间结束地址,from 区的结束地址
[0x00000007bff00000,0x00000007bff00000,0x00000007c0000000)

## to 其实就是 Survivor 区中的另一个:总大小 3072K ,已使用 0%
to space 3072K, 0% used
## to 区的起始地址,使用空间结束地址,to 区的结束地址
[0x00000007bfa00000,0x00000007bfa00000,0x00000007bfd00000)

## 老年代的大小状况:总大小为 40960K ,已使用 40586K
ParOldGen total 40960K, used 40586K
## 老年代的起始地址,使用空间结束地址,老年代的结束地址
[0x00000007bce00000, 0x00000007bf600000, 0x00000007bf600000)

## 对象区的使用情况:总空间 40960K,已使用 99%
object space 40960K, 99% used
[0x00000007bce00000,0x00000007bf5a28e8,0x00000007bf600000)

## 元数据区的大小:已使用/总容量(弹性容量大小)/虚拟内存占用(连续占用大小)/虚拟内存保留(总虚拟内存大小)
Metaspace used 3282K, capacity 4556K, committed 4864K, reserved 1056768K

## 元数据区中用作类存储的大小:已使用/总容量(弹性容量大小)/虚拟内存占用(连续占用大小)/虚拟内存保留(总虚拟内存大小)
class space used 353K, capacity 392K, committed 512K, reserved 1048576K

  1. 堆栈信息

和其他 OOM 输出的堆栈信息类似,不展开讨论。


ParNew & CMS

修改 JVM 启动参数 -Xmn10M -Xms50M -Xmx50M -XX:+UseConcMarkSweepGC -XX:+PrintCommandLineFlags -XX:+PrintGCDetails,将 GC 切换为 CMS,在运行相同的案例代码,得到如下 GC 日志 :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45

-XX:InitialHeapSize=52428800 -XX:MaxHeapSize=52428800 -XX:MaxNewSize=10485760 -XX:MaxTenuringThreshold=6 -XX:NewSize=10485760 -XX:OldPLABSize=16 -XX:+PrintCommandLineFlags -XX:+PrintGCDetails -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:+UseConcMarkSweepGC -XX:+UseParNewGC

## ParNew 的 Young GC
[GC (Allocation Failure) [ParNew: 7626K->1023K(9216K), 0.0028803 secs] 7626K->4108K(50176K), 0.0029157 secs] [Times: user=0.01 sys=0.00, real=0.01 secs]
[GC (Allocation Failure) [ParNew: 8514K->119K(9216K), 0.0049311 secs] 11598K->11083K(50176K), 0.0049535 secs] [Times: user=0.02 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [ParNew: 7437K->44K(9216K), 0.0037013 secs] 18401K->18176K(50176K), 0.0037208 secs] [Times: user=0.03 sys=0.00, real=0.01 secs]
[GC (Allocation Failure) [ParNew: 7366K->14K(9216K), 0.0034385 secs] 25498K->25314K(50176K), 0.0034562 secs] [Times: user=0.02 sys=0.01, real=0.00 secs]

## CMS 的 Full GC
[GC (CMS Initial Mark) [1 CMS-initial-mark: 25299K(40960K)] 26338K(50176K), 0.0005599 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[CMS-concurrent-mark-start]
[CMS-concurrent-mark: 0.001/0.001 secs] [Times: user=0.01 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [ParNew: 7339K->6K(9216K), 0.0036135 secs] 32639K->32474K(50176K), 0.0036305 secs] [Times: user=0.03 sys=0.00, real=0.01 secs]
[CMS-concurrent-preclean-start]
[CMS-concurrent-preclean: 0.000/0.000 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[CMS-concurrent-abortable-preclean-start]
[GC (Allocation Failure) [ParNew: 7331K->6K(9216K), 0.0035544 secs] 39799K->39642K(50176K), 0.0035752 secs] [Times: user=0.03 sys=0.01, real=0.00 secs]
[GC (Allocation Failure) [ParNew: 7332K->7332K(9216K), 0.0000099 secs][CMS[CMS-concurrent-abortable-preclean: 0.000/0.005 secs] [Times: user=0.03 sys=0.01, real=0.00 secs]
(concurrent mode failure): 39636K->40609K(40960K), 0.0085766 secs] 46968K->46755K(50176K), [Metaspace: 3246K->3246K(1056768K)], 0.0086120 secs] [Times: user=0.01 sys=0.00, real=0.01 secs]
[Full GC (Allocation Failure) [CMS: 40609K->40609K(40960K), 0.0022058 secs] 47861K->47779K(50176K), [Metaspace: 3246K->3246K(1056768K)], 0.0022298 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[Full GC (Allocation Failure) [CMS: 40609K->40594K(40960K), 0.0058331 secs] 47779K->47763K(50176K), [Metaspace: 3246K->3246K(1056768K)], 0.0058541 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]
[GC (CMS Initial Mark) [1 CMS-initial-mark: 40594K(40960K)] 47763K(50176K), 0.0002856 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[CMS-concurrent-mark-start]
[CMS-concurrent-mark: 0.001/0.001 secs] [Times: user=0.01 sys=0.00, real=0.00 secs]
[CMS-concurrent-preclean-start]
[CMS-concurrent-preclean: 0.001/0.001 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[CMS-concurrent-abortable-preclean-start]
[CMS-concurrent-abortable-preclean: 0.000/0.000 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC (CMS Final Remark) [YG occupancy: 7492 K (9216 K)][Rescan (parallel) , 0.0001603 secs][weak refs processing, 0.0000470 secs][class unloading, 0.0001829 secs][scrub symbol table, 0.0003149 secs][scrub string table, 0.0001252 secs][1 CMS-remark: 40594K(40960K)] 48086K(50176K), 0.0008695 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[CMS-concurrent-sweep-start]
[CMS-concurrent-sweep: 0.000/0.000 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[CMS-concurrent-reset-start]
[CMS-concurrent-reset: 0.000/0.000 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]

Heap
par new generation total 9216K, used 7492K [0x00000007bce00000, 0x00000007bd800000, 0x00000007bd800000)
eden space 8192K, 91% used [0x00000007bce00000, 0x00000007bd551238, 0x00000007bd600000)
from space 1024K, 0% used [0x00000007bd600000, 0x00000007bd600000, 0x00000007bd700000)
to space 1024K, 0% used [0x00000007bd700000, 0x00000007bd700000, 0x00000007bd800000)
concurrent mark-sweep generation total 40960K, used 40590K [0x00000007bd800000, 0x00000007c0000000, 0x00000007c0000000)
Metaspace used 3278K, capacity 4556K, committed 4864K, reserved 1056768K
class space used 353K, capacity 392K, committed 512K, reserved 1048576K

Exception in thread "main" java.lang.OutOfMemoryError: Java heap space

可以看到 CMS 不同于 Parallel Old ,CMS 将 Full GC 分为了好几步:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
## 初始标记(对所有被 GC Root 直接引用的对象进行标记),单线程,会 STW 
[GC (CMS Initial Mark) [1 CMS-initial-mark: 40594K(40960K)] 47763K(50176K), 0.0002856 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]

## 并发标记(遍历所有对象/执行并发预清理),多线程,不会 STW
[CMS-concurrent-mark-start]
[CMS-concurrent-mark: 0.001/0.001 secs] [Times: user=0.01 sys=0.00, real=0.00 secs]

## 将修改过的 Card 标记为 Dirty Card
[CMS-concurrent-preclean-start]
[CMS-concurrent-preclean: 0.001/0.001 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]

[CMS-concurrent-abortable-preclean-start]
[CMS-concurrent-abortable-preclean: 0.000/0.000 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]

## 最终标记(处理弱引用/卸载无用的类/清理“无用类”对应的字符串表),多线程,会 STW
[GC (CMS Final Remark) [YG occupancy: 7492 K (9216 K)][Rescan (parallel) , 0.0001603 secs][weak refs processing, 0.0000470 secs][class unloading, 0.0001829 secs][scrub symbol table, 0.0003149 secs][scrub string table, 0.0001252 secs][1 CMS-remark: 40594K(40960K)] 48086K(50176K), 0.0008695 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]

## 并发清除(清除“垃圾”对象并充值数据结构),多线程,不会 STW
[CMS-concurrent-sweep-start]
[CMS-concurrent-sweep: 0.000/0.000 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[CMS-concurrent-reset-start]
[CMS-concurrent-reset: 0.000/0.000 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]


G1

修改 JVM 启动参数 -Xms50M -Xmx50M -XX:+UseG1GC -XX:+PrintCommandLineFlags -XX:+PrintGCDetails,将 G1(仅指定堆大小,不指定新生代的大小,由 G1 动态调整),在运行相同的案例代码,得到如下 GC 日志 :

  • Young GC:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
[GC pause (G1 Evacuation Pause) (young), 0.0040538 secs]

## 使用两个 GC Wroker 进行并发工作,共耗时 2.6ms
[Parallel Time: 2.6 ms, GC Workers: 2]

## Min: 第一个垃圾收集线程开始工作时间;Max: 最后一个垃圾收集线程开始工作时间;
## Diff: Min 和 Max 的差值。理想情况下,你希望它们是同时开始,即 Diff 趋近于0
[GC Worker Start (ms): Min: 318.9, Avg: 318.9, Max: 319.0, Diff: 0.1]

## 扫描根对象集合(线程栈、JNI、全局变量、系统表等等)花费的时间
[Ext Root Scanning (ms): Min: 0.9, Avg: 0.9, Max: 1.0, Diff: 0.1, Sum: 1.9]

## 每个分区都有自己的 RSet,用来记录其他分区指向当前分区的指针,如果 RSet 有更新,G1 中会有一个 post-write barrier 管理跨分区的引用(新的被引用的 Card 会被标记为 Dirty,并放入一个日志缓冲区,如果这个日志缓冲区满了会被加入到一个全局的缓冲区,在JVM运行的过程中还有线程在并发处理这个全局日志缓冲区的 Dirty Card)
## Update RS表示允许垃圾收集线程处理本次垃圾收集开始前没有处理好的日志缓冲区,这可以确保当前分区的RSet是最新的
[Update RS (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
## 在 Update RS 这个过程中处理多少个日志缓冲区
[Processed Buffers: Min: 0, Avg: 0.0, Max: 0, Diff: 0, Sum: 0]

## 扫描每个新生代分区的 RSet,找出有多少指向当前分区的引用来自 CSet
[Scan RS (ms): Min: 0.0, Avg: 0.0, Max: 0.1, Diff: 0.0, Sum: 0.1]

## 扫描代码中的根对象节点(局部变量)花费的时间
[Code Root Scanning (ms): Min: 0.0, Avg: 0.1, Max: 0.1, Diff: 0.1, Sum: 0.1]

## 拷贝存活对象到 Survivors 区或者 Old 区
[Object Copy (ms): Min: 0.9, Avg: 0.9, Max: 1.0, Diff: 0.1, Sum: 1.8]

## 当一个垃圾收集线程完成任务时,它就会进入一个临界区,并尝试帮助其他垃圾线程完成任务(steal outstanding tasks),Min 表示该垃圾收集线程什么时候尝试 terminatie,Max 表示该垃圾收集回收线程什么时候真正 terminated
[Termination (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
## 如果一个垃圾收集线程成功盗取了其他线程的任务,那么它会再次盗取更多的任务或再次尝试 terminate,每次重新 terminate 的时候,这个数值就会增加
[Termination Attempts: Min: 1, Avg: 1.0, Max: 1, Diff: 0, Sum: 2]

## 垃圾收集线程在完成其他任务的时间
[GC Worker Other (ms): Min: 0.1, Avg: 0.2, Max: 0.4, Diff: 0.3, Sum: 0.4]
## 展示每个垃圾收集线程的最小、最大、平均、差值和总共时间
[GC Worker Total (ms): Min: 2.1, Avg: 2.2, Max: 2.3, Diff: 0.1, Sum: 4.4]
## Min: 表示最早结束的垃圾收集线程的结束时间;Max: 表示最晚结束的垃圾收集线程结束时间。
## Diff: Min 和 Max 的差值。理想情况下,你希望它们是同时结束,即 Diff 趋近于0
[GC Worker End (ms): Min: 321.1, Avg: 321.1, Max: 321.2, Diff: 0.1]

## 释放用于管理并行垃圾收集活动的数据结构,应该接近于0,线性执行
[Code Root Fixup: 0.0 ms]
## 清理更多的数据结构,应该很快,耗时接近于0,线性执行
[Code Root Purge: 0.0 ms]

## 清除 Color Table
[Clear CT: 0.3 ms]

## 扩展功能
[Other: 1.1 ms]
## 选择要进行回收的分区放入 CSet( G1 选择的是垃圾最多的分区,也就是存活对象率最低的分区优先)
[Choose CSet: 0.1 ms]
## 处理Java中的各种引用(soft、weak、final、phantom、JNI)
[Ref Proc: 0.5 ms]
## 遍历所有的引用,将不能回收的放入 pending 列表
[Ref Enq: 0.0 ms]
## 在回收过程中被修改的 Card 将会被重置为 Dirty
[Redirty Cards: 0.2 ms]
## H-Obj 可以被回收
[Humongous Register: 0.0 ms]
## 确保 H-Obj 可以被回收、释放该 H-Obj 所占的分区,重置分区类型,并将分区还到 free 列表,并且更新空闲空间大小
[Humongous Reclaim: 0.0 ms]
## 将要释放的分区还回到 free 列表
[Free CSet: 0.0 ms]

## YGC 前 Eden 已使用(Eden 总容量) -> YGC 后 Eden 已使用(Eden 总容量),说明 Eden 区发生了调整;Survivors YGC 前后大小,说明有存活对象被移到了 Survivors 区;YGC 前 Heap 已使用(Heap 总容量) -> YGC 后 Heap 已使用(Heap 总容量)
[Eden: 7168.0K(7168.0K)->0.0B(10240.0K)
Survivors: 0.0B->1024.0K
Heap: 7168.0K(51200.0K)->616.0K(51200.0K)]
## 消耗的用户时间/消耗的内核时间/消耗实际时间
[Times: user=0.00 sys=0.00, real=0.01 secs]

  • Mixed GC:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
## 为了充分利用 STW 的机会来 trace 所有可达的对象
## 进行一次 YGC,initial-mark 阶段是作为 Young GC 中的一部分存在的。
## initial-mark 设置了两个 TAMS(top-at-mark-start)变量,用来区分存活的对象和在并发标记阶段新分配的对象。在 TAMS 之前的所有对象,在当前周期内都会被视作存活的。
[GC pause (G1 Evacuation Pause) (young) (initial-mark) (to-space exhausted), 0.0190155 secs]
[Parallel Time: 16.0 ms, GC Workers: 2]
[GC Worker Start (ms): Min: 298714.1, Avg: 298714.1, Max: 298714.2, Diff: 0.1]
[Ext Root Scanning (ms): Min: 0.6, Avg: 0.7, Max: 0.7, Diff: 0.0, Sum: 1.3]
[Update RS (ms): Min: 9.8, Avg: 9.8, Max: 9.8, Diff: 0.1, Sum: 19.6]
[Processed Buffers: Min: 32, Avg: 42.0, Max: 52, Diff: 20, Sum: 84]
[Scan RS (ms): Min: 0.6, Avg: 0.6, Max: 0.7, Diff: 0.1, Sum: 1.2]
[Code Root Scanning (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
[Object Copy (ms): Min: 4.3, Avg: 4.3, Max: 4.3, Diff: 0.0, Sum: 8.6]
[Termination (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
[Termination Attempts: Min: 1, Avg: 1.0, Max: 1, Diff: 0, Sum: 2]
[GC Worker Other (ms): Min: 0.1, Avg: 0.3, Max: 0.4, Diff: 0.3, Sum: 0.5]
[GC Worker Total (ms): Min: 15.6, Avg: 15.7, Max: 15.7, Diff: 0.1, Sum: 31.3]
[GC Worker End (ms): Min: 298729.8, Avg: 298729.8, Max: 298729.8, Diff: 0.0]
[Code Root Fixup: 0.0 ms]
[Code Root Purge: 0.0 ms]
[Clear CT: 0.2 ms]
[Other: 2.8 ms]
[Evacuation Failure: 1.8 ms]
[Choose CSet: 0.0 ms]
[Ref Proc: 0.3 ms]
[Ref Enq: 0.0 ms]
[Redirty Cards: 0.2 ms]
[Humongous Register: 0.0 ms]
[Humongous Reclaim: 0.0 ms]
[Free CSet: 0.0 ms]
[Eden: 1024.0K(1024.0K)->0.0B(1024.0K) Survivors: 1024.0K->1024.0K Heap: 48177.2K(51200.0K)->48748.2K(51200.0K)]
[Times: user=0.02 sys=0.01, real=0.02 secs]

## 根分区扫描。根分区扫描主要扫描的是新的 Survivor 分区,找到这些分区内的对象指向当前分区的引用,如果发现有引用,则做个记录
[GC concurrent-root-region-scan-start]
[GC concurrent-root-region-scan-end, 0.0022521 secs]

## 并发标记阶段
[GC concurrent-mark-start]
[GC concurrent-mark-end, 0.1779259 secs]

## 重新标记阶段,会 STW
## Finalize Marking:Finalizer 列表里的 Finalizer 对象处理
## GC ref-proc:处理Java中的各种引用(soft、weak、final、phantom、JNI)
## Unloading:类卸载
## 除了前面这几个事情,这个阶段最关键的结果是:绘制出当前并发周期中整个堆的最后面貌,剩余的SATB缓冲区会在这里被处理,所有存活的对象都会被标记;
[GC remark
[Finalize Marking, 0.0007559 secs]
[GC ref-proc, 0.0011838 secs]
[Unloading, 0.0032355 secs], 0.0065268 secs]
[Times: user=0.01 sys=0.00, real=0.01 secs]

## 清理阶段,会 STW
## 计算出最后存活的对象:标记出initial-mark阶段后分配的对象;标记出至少有一个存活对象的分区;
## 为下一个并发标记阶段做准备,previous和next位图会被清理;
## 没有存活对象的老年代分区和巨型对象分区会被释放和清理;
## 处理没有任何存活对象的分区的RSet;
## 所有的老年代分区会按照自己的存活率(存活对象占整个分区大小的比例)进行排序,为后面的CSet选择过程做准备;
[GC cleanup 48865K->48865K(51200K), 0.0013240 secs]
[Times: user=0.00 sys=0.00, real=0.00 secs]

## 并发清理阶段启动。完成之前 cleanup 所剩余的清理工作;将完全清理好的分区加入到二级 free 列表,等待最终还会到总体的 free 列表
[GC concurrent-cleanup-start]
[GC concurrent-cleanup-end, 0.0006460 secs]

  • Full GC:

1
2
3
4
5
6
7
## Full GC Heap 前后使用量(总容量) 耗时
[Full GC (Allocation Failure) 49350K->49350K(51200K), 0.1379542 secs]
[Eden: 0.0B(2048.0K)->0.0B(2048.0K)
Survivors: 0.0B->0.0B
Heap: 49350.9K(51200.0K)->49350.7K(51200.0K)],
[Metaspace: 4034K->4034K(1056768K)]
[Times: user=0.15 sys=0.00, real=0.14 secs]

当 G1 Mixed GC 的回收速度跟不上分配速度,会触发 Full GC,在 JDK 10 以前 中使用的是 Serial Old 进行 Full GC,STW 很长。使用 G1 应当尽量避免 Full GC 。


参考文献

Garbage First Garbage Collector Tuning

Java Hotspot G1 GC的一些关键技术

Collecting and reading G1 garbage collector logs - part 2

深入理解 synchronized 《天才儿童1985》- 张敬轩

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×