Advanced Java-03a-GC

Minor/Major/Full GC

① Minor GC: 在年轻代 Young space(包括 Eden 区和 Survivor 区)中的垃圾回收称之为 Minor GC,或 Young GC

  • 触发条件: 各种 GC 收集器的触发 Minor GC 的条件都是 Eden 区满了
  • STW: Minor GC 会 Stop the World,如果 Eden 区大部分对象都要被 GC(这也是正常情形)Minor GC 耗时可以基本不记,但是如果 Eden 区大部分对象都不符合 GC 条件,暂停时间将会长很多。
  • GC 过程: 一般使用 Copy GC, 具体步骤如下
    • 用 new 或者 newInstance 等方式创建的对象默认都是存放在 Eden 区,Eden 满了触发 Minor GC,把存活的对象放入 S 0,并清空 Eden;
    • 第二次 Eden 满了,会把 Eden 和 S 0 的存活对象放入 S 1,并清空 Eden 和 S0区;
    • 在几次 Minor GC 后, 有些对象在 S 0/S 1 之间来回拷贝几次, 将会进入老年代(Tenured), 所以 young GC 后 OldGen 的占用量通常会有所升高;

↑ Minor GC 图例: 左边是 Minor GC 前(黄色是垃圾对象, 红色是存活对象), 右边是 Minor GC 后

② Major GC: 清理老年代(Tenured space)

  • 触发条件:
    • CMS:老年代使用率超过某个阈值,默认大约 90% 通过 -XX:CMSInitiatingOccupancyFraction 设置;
    • G1:Heap 使用比例超过某个阈值;
  • STW: 一般 Tracing GC 在标记和清理阶段都会有 STW, 具体 STW 耗时根据使用哪种 GC 收集器而定;
  • GC 过程: 使用 Mark-Sweep 或 Mark-Compact 算法;

③ Full GC: 清理整个堆,包括 YoungGen / OldGen / PermGen(1.8 之前) / Matespace(1.8+)等所有部分的全局范围的 GC。

  • 触发条件:
    • 在将要进行(Young GC)时, 如果发现要晋升至 OldGen 的对象大小比 OldGen 剩余大小更大, 则不会触发 young GC 而是转为触发 full GC; // 如果是用的 CMS 收集器可以在日志中看到 “promotion failed” 和 “concurrent mode failure”
    • 方法区满了触发(在 Java 8 之前),或者 Metaspace 使用量达到 MaxMetaspace 阈值(Java 8+);
    • 调用 System.gc(): 此方法的调用是建议 JVM 进行 Full GC,虽然只是建议而非一定,但很多情况下它会触发 Full GC,从而增加 Full GC 的频率,也即增加了间歇性停顿的次数
    • 当执行 jmap -histo:live 或者 jmap -dump:live,可能也会
  • STW: 视不同的 GC 收集器而定
  • GC 过程: 同上

需要明白的一点,我们在 jstat 或 GC 日志中看不到 Minor GC/ Major GC / Full GC 这些名词, 这些术语无论是在 JVM 规范还是在垃圾收集研究论文中都没有正式的定义.
需要注意的是,无论是 jstat 返回还是 GC 日志,其中的 “Full GC” 并不是真正的 Full GC 发生次数, CMS GC 两个阶段(初始标记和 Remark)都会 Stop the World, 这两次 STW 在 jstat 里被视作了两次 Full GC,所以 jstat 的 FGC 更接近于统计 cms “Stop the World”的次数;
如果要判断是否真的发生 Full GC,还要看 GC 发生时,是不是三个分代使用率都发生了下降;

@ref: Major GC和Full GC的区别是什么?触发条件呢? - 知乎

GC 算法

对象存活判断

判断对象存活的两种方法:

  • 引用计数法:每个对象有一个引用计数属性,新增一个引用时计数加 1,引用释放时计数减 1,计数为 0 时可以回收。此方法简单,无法解决对象相互循环引用的问题:比如对象 a 和 b 互相有一个对方的引用,虽然两个对象都没用了但是计数却不为0。

现在主流的 JVM 无一使用引用计数方式来实现 Java 对象的自动内存管理,但 Py 和 PHP 似乎是用的这种方法;

  • 可达性分析法(Reachability Analysis):它的处理方式就是,设立若干种 roots 对象,roots 对象作为起点在图中进行深度优先遍历,每访问到一个对象,则该对象称为“可达对象”,也就是还活着的对象。否则就是不可达对象,可以被回收。

JVM 里适用于老年代的 GC 收集器都使用了”可达性分析”来判断对象是否存活,即从“GC Roots”对象出发,对堆内的对象进行 DFS 遍历。

什么是 GC Roots:对于一个正在回收的空间,所有不在这个空间里又指向本空间中的对象的引用的集合就是“GC roots”。一般而言,GC Roots 包括(但不限于)如下几种:

  1. 运行状态的线程的栈帧中,本地变量表中的对象引用
  2. 方法区中类静态属性实体引用的对象
  3. 方法区中常量引用的对象
  4. Native 方法栈中的对象引用
  5. 由系统类加载器(system classloader)加载的对象,这些类是不能够被回收的

Copy GC 算法

Copy GC 算法把空间分成两部分,一个叫分配空间(Allocation Space),一个是幸存者空间(Survivor Space)。创建新的对象的时候都是在分配空间里创建。
在 GC 的时候,把分配空间里的活动对象复制到 Survivor Space,把原来的 Allocation Space 全部清空。然后把这两个空间交换角色。

../_images/Advanced-Java.03a.GC-Copying-GC.png

  • Copy GC 是从 GC roots 开始遍历对象,访问到一个对象则在 Survivor Space 分配一个与该对象大小相同的一块内存,然后把这个对象的所有数据都拷贝过去;
  • Copying GC 是典型的采用空间换时间的方式来提升性能,因为 Survivor 空间无法直接分配对象。下面可以看到 SpotVM 的 YoungGen GC 回收器使用了1个 Allocation + 2个 Survivor,大小比例为8:1:1,这也是利用了 YoungGen 对象“朝生夕灭”的特点,大部分对象都会被回收,所以使用较小的 Survivor ;
  • 如上所说,Copy GC 适合新生代;

在 HotsptVM 中,还会把 Allocation 称为 From space,Survivor 称为 To Space

具体的 Copy 实现可以参考 Copy GC(1) : 基本原理 - 知乎Copy GC(3) : JVM中的实现 - 知乎

  • 对象头的 Mark Word 中 forwarding 指针,在 GC 过程中的作用:
    • 假设 A 和 B 都使用了 C 对象;
    • 第一次遍历,通过 A 访问到 C,并 copy C 到 Survivor,假设对象新地址是 C1,还需要把旧对象 C 的 forwarding 指向 C1地址
    • 通过 B 开始遍历,访问到 C 的时候,发现 C 的 forwarding != null,则 B 需要修改 C 的地址为 C1
  • 每进行一次 copy gc,复制到 Survivor 的对象年龄都会+1

Tracing GC 算法

Tracing GC 算法, 根据是否压缩内存又分两种 Mark-SweepMark-Compact
在介绍 Tracing GC 之前, 需要先熟悉几个名词, 可达性分析和 GC Roots:

Mark-Sweep

标记-清除算法 (Mark-Sweep)如同它的名字一样,算法分为“标记”和“清除”两个阶段:

  • 遍历+标记,从 roots 开始进行遍历,每个访问到的对象进行 mark
  • 清理,扫描整个 Heap 区域,对于有 mark 的对象清除标记(为了下次 GC),没有 mark 的对象直接进行清理

../_images/Advanced-Java.03a.GC-Mark-Sweep.png

标记-清除算法最基础的收集算法,是因为后续的收集算法都是基于这种思路并对其不足进行改进而得到的。它的主要不足有两个:

  • 效率问题,标记和清除两个过程的效率都不高, 回收时间与 heap 大小成正比;
  • 空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

关于 mark-sweep 的一些细节,参考海纳的 Tracing GC(1) - 知乎Tracing GC(2) - 知乎

  • 如何对“可达的”对象进行 mark:“在 tracing gc 时,如果一个对象存活,就将其 mark word 的最低两位都置为1”;//低2位都为11
  • 清理阶段,直接清理掉不可达的对象,这块内存变为空闲内存,空闲内存块使用一个 free-chunk 链表管理,当下次再创建一个新对象时,从链表里选择一个合适的 chunk(使用了 first-fit,而不是 best-fit);
  • 对于这个空闲内存块的列表,如何避免并发加锁带来的性能损失,HotSpot 使用了 #TLAB 机制;

Mark-Compact

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

因为多了 Compact 步骤:即可达对象复制到 Heap 的一端,这里也需要 forwarding 指针

  • mark: 从 roots 开始进行遍历,访问过一个可达的对象进行mark,计算它的新位置(从 Heap start 开始分配新位置),然后对象的 forwarding 改为新地址,完成这一步后,所有可达的对象的 forwarding 指针都指向新地址,但是新地址仍是空的;
  • 更新引用关系:遍历 mark 过的对象,如果该对象引用了另一个对象,更新引用地址改为被引用对象的 forwarding 地址。执行完这一步以后,对象之间的引用其实是不对的。因为我们只是把对象间的相互引用更新为对象的新地址,但是新地址处还没有内容;
  • 复制对象: 这一步需要遍历整个堆?有标注的对象,复制到 forwarding 指向的新地址,没有标注的对象回收掉。

../_images/Advanced-Java.03a.GC-Mark-Compact.png

标记-整理算法是在标记-清除算法的基础上,又进行了对象的移动,需要进行3次扫描,因此吞吐量差一些,但是却解决了内存碎片的问题。
如果堆中存在某些对象,它们可以经过多次 GC 而存活下来,那么这些对象就会被整理到堆的开头。所以经过几轮 Compact 以后,堆的头部位置出现空洞的可能性就会很小,这个加速就会很明显 @ref: Tracing GC(3): mark & compaction - 知乎

总结:比较三种 GC 算法

  • copy GC
    • ✔︎仅一次扫描即可完成 copy + 更新 forwarding ,吞吐量大,无碎片
    • ✘需要额外的 Survivor space,仅适合 YoungGen(因为每次 Young GC 后大部分对象都被销毁,只需要很小的 Survivor)
  • tracing GC:
    • mark and Sweep
      • ✔︎不需要额外空间,需要2次扫描,第一次标记,第二次清理
      • ✘有内存碎片
    • mark and Compact
      • ✔︎不需要额外空间,没有内存碎片
      • ✘因为需要内存Compact,需要3次扫描(1更新forwarding 2更新引用 3复制+清理),吞吐量最低

总结:如果 GC 中,对象的地址发生移动,才需要更新对象的 forwarding

GC 收集器

GC 收集器是上面提到的几种 GC 算法的具体实现, 有些 GC 收集器只能用于新生代(比如 ParNew), 有些只用于老年代(比如 CMS), 有些可以同时用于两个代(Serial);
对于每种 GC 收集器, 都要注意以下几个问题:

a. 这种 GC 收集器可以用于哪个区, 如何启用;
b. 实现是什么样的, 分几个阶段, 哪些阶段有 STW;
c. 并行(Parallel)还是并发(Concurrent)的, 注意“并发”和“并行”的不同:

  1. 并行(Parallel):使用多个线程同时执行 GC 任务,但此时用户线程仍然处于等待状态。并行 GC 的两个例子,一个是 ParNew,一个是 parallel scavenge。这两种 GC 的特点都是启动了多个 GC 线程来做垃圾回收。

    名字 Par(allel)开头的一般都是并行 GC, 多个线程同时进行 GC, 但仍会停顿;

  2. 并发(Concurrent):指用户任务与 GC 任务同时执行(但不一定是并行的,可能会交替执行),用户任务不会停顿。并发 GC 的一个典型例子,是 CMS,看它的名字就知道了 Concurrent Mark Sweep。

    名字 C(oncurrent)开头的是并发 GC, GC 线程和工作线程并发的执行;

下图是说明了不同分代可以使用的收集器,如果两个收集器之间存在连线,就说明它们可以搭配使用:

几种常见的 GC 收集器组合

  • 新生代 Parallel + 老年代 Parallel Old
  • 新生代 ParNew + 老年代 CMS
  • 新生代, 老年代都使用 G1

为什么不使用新生代 Parallel Scavenge + 老年代 CMS?因为二者

下面介绍 7 种 GC 收集器 :

Serial

串行回收器(Serial Garbage Collector), 可以用于新生代和老年代, 下面都使用 Serial New 和 Serial Old 表示两代的 GC 回收器

  • 通过 JVM 参数 -XX:+UseSerialGC 可以使用串行垃圾回收器。
  • 特性:
    • 新生代、老年代都可以使用串行回收器, 新生代复制算法, 老年代标记-压缩算法.
    • 串行垃圾回收器通过持有应用程序所有的线程进行工作。它为单线程环境设计,只使用一个单独的线程进行垃圾回收,可能会产生较长的停顿,所以可能不适合服务器环境。它依然是 HotSpot 虚拟机运行在 Client 模式下的默认的新生代收集器。

ParNew

ParNew 回收器:Serial 收集器新生代的多线程版本, 只适用于新生代.

  • -XX:+UseParNewGC(new 代表新生代,所以适用于新生代),-XX:ParallelGCThreads 线程数量
  • 特性:
    • ParNew 收集器就是 Serial 收集器的多线程版本,它也是一个新生代收集器。除了使用多线程进行垃圾收集外,其余行为包括 Serial 收集器可用的所有控制参数、收集算法(复制算法)、Stop The World、对象分配规则、回收策略等与 Serial 收集器完全相同,两者共用了相当多的代码
    • ParNew 收集器是许多运行在 Server 模式下的虚拟机中首选的新生代收集器,原因是: 除了 Serial 收集器外,目前只有它能和 CMS 收集器(Concurrent Mark Sweep)配合工作

Parallel

并行回收器(Parallel Garbage Collector): 可以用于新生代和老年代, 新生代是 Parallel Scavenge 收集器( 复制算法), 老年代是 Parallel Old( 标记整理算法).

  • JVM 参数:
    • -XX:+UseParallelGC:新生代使用 Parallel 收集器 + 老年代使用串行收集器
    • -XX:+UseParallelOldGC:新生代+老年都使用 Parallel
  • 特性:
    • 使用多线程进行扫描堆并标记对象, 缺点是在 minor 和 full GC 的时候都会 Stop the world
    • CMS 等收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间,而 Parallel Scavenge 收集器的目标是达到一个可控制的吞吐量(Throughput)

CMS

CMS(Concurrent Mark Sweep), 仅适用于老年代的回收器, 是一种以获取最短回收停顿时间为目标的收集器,它非常符合那些集中在互联网站或者 B/S 系统的服务端上的 Java 应用,这些应用都非常重视服务的响应速度。从名字上(“Mark Sweep”)就可以看出它是基于“标记-清除”算法实现的

  • JVM 参数:

    • 通过 JVM 参数 -XX:+UseConcMarkSweepGC 打开
    • -XX:+ExplicitGCInvokesConcurrent : 使 System.gc() 触发的 Full GC 改为 CMS,防止过长的 STW 时间
    • -XX:CMSInitiatingOccupancyFraction=70:老年代使用率超过70%,触发 CMS GC
    • -XX:CMSFullGCsBeforeCompaction: 进行几次Full GC后,进行一次碎片整理:
  • 特性:

    • 只适用于老年代 GC, 新生代可以搭配 ParNew 收集器;
    • “初始标记”和”重新标记”阶段仍然 Stop the World, 但耗时最长的”并发标记”和”并发清除”过程收集器线程都可以与用户线程一起”并发的”工作;
    • 因为是 Mark-Sweep 的, GC 后有内存碎片, 所以很多情况下 Old Gen 有足够空间但是仍会由 Minor GC 触发 Major GC;
    • 当 CMS 失败出现 Concurrent Mode Failure 会转换到 Serial Old, 将导致非常长时间的 Stop The World
    • 触发 CMS GC 的条件可能有
      • OldGen 使用率大于阈值 -XX:CMSInitiatingOccupancyFraction,默认92;
      • Young GC 有大量的对象晋升,但 OldGen 空间不够的时候(注1);
      • 大对象(需要大量连续内存空间)触发:此类对象会直接进入老年代,而老年代虽然有很大的剩余空间,但是无法找到足够大的连续空间来分配给当前对象,-XX:PretenureSizeThreshold 令大于这个设置值的对象直接在老年代分配,这样做的目的是避免在 Eden 区及两个 Survivor 区之间发生大量的内存复制;
      • PermGen 和 Metaspace 超过初始的阈值扩容也会引起;
      • Heap的使用量大于 -Xms引起扩容,也会触发CMS GC,建议 -Xms 和 -Xmx 设置为相同的值;
      • Heap的剩余空间很多也会引起缩容操作,这时也会触发CMS GC;
      • 调用 System.gc()

注1:空间分配担保

  • (1)在发生 Minor GC 之前,虚拟机先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立的话,那么 Minor GC 可以确认是安全的;
    (2)如果不成立的话,虚拟机会查看 HandlePromotionFailure 设置值是否允许担保失败,如果允许那么就会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次 Minor GC;如果小于,或者 HandlePromotionFailure 设置不允许冒险,那么就要进行一次 Full GC。
  • GC步骤:
    1. 初始标记(CMS initial mark):仅仅只是标记一下 GC Roots 能直接关联到的对象(标记直接被 GC root 引用或者被年轻代存活对象所引用的所有对象),速度很快,需要“Stop The World”
    2. 并发标记(CMS concurrent mark): 由上一阶段标记过的”可达对象”出发进行标记(遍历老年代),用户任务不停顿;
    3. 重新标记(CMS remark): 暂停用户任务,对 整个堆(新生代+老年代) 开始扫描并标记。因为并发标记阶段是和用户线程并发执行的过程,所以该过程中可能有对象从“不可达”变为“可达”,这个阶段需要重新标记出此类对象,防止在下一阶段被清理掉,该阶段会“Stop The World”
      特别需要注意一点,这个阶段是 以新生代中对象为 GC Roots 来判断对象是否存活的(CMS 只做老年代的 Major GC,但很多 GC Roots 在新生代,又指向了老年代的的对象(这种情况叫跨代引用),所以需要以新生代对象为 GC Roots 进行扫描, CMS预清理可以减少这一阶段的耗时(见下面 “CMS-concurrent-preclean” )。
    4. 并发清除(CMS concurrent sweep):用户任务不停顿;

../_images/12.Java-GC-CMS.png

因为阶段3需要以新生代对象为 Roots 进行整堆扫描,但如果此时新生代对象非常多,会增加这一阶段的耗时(此阶段还有 STW),所以 CMS 提供了 CMSScavengeBeforeRemark 参数,在阶段3之前强制一次 Minor GC,减轻整堆扫描的负担,这一步 Minor GC 也叫预清理,在 CMS GC 日志中可以看到“CMS-concurrent-preclean” 就是这一阶段;

同样 Minor GC 的时候,也会遇到 “老年代 -> 新生代” 的跨代引用,所以理论上也需要扫描整个堆,但是实际情况“老年代 -> 新生代”的概率很小,经过统计信息显示,老年代持有新生代对象引用的情况不足1%,根据这一特性 JVM 引入了卡表(card table)来实现对这种情况的监控:将老年代的空间分成大小为512B 的若干张卡(card),用一个数组表示这些 card 的状态,如果发生 “老年代 -> 新生代” 的跨代引用,这个 card 对应的数组元素会被置为脏(卡表还有另外的作用,标识并发标记阶段哪些块被修改过),之后 Minor GC 时通过扫描卡表就可以很快的识别哪些卡中存在老年代指向新生代的引用。这样虚拟机通过空间换时间的方式,避免了 Minor GC 的全堆扫描。

关于卡表(CARD Table)的设计,参考 「卡表」一节

../_images/Advanced-Java.03a.GC-CMS-OldGen-Cards.png

网上搜索到的很多 GC 优化,都是针对 ParNew + CMS 的组合,正因为老年代+新生代分别使用两种不同的策略,导致二者不能非常好的配合,才不得不处理跨代引用的问题,所以针对 CMS 有非常多的优化案例,更多例子参考下面的「优化案例」一节。

这种情况在 G1 和 ZGC 出现后有了改观,程序员不再需要过多干预 GC 参数了。

@ref: 从实际案例聊聊Java应用的GC优化 - 美团技术团队

G1

G1 回收器 : 在 JDK 7u4 版本被正式推出,JDK 9 成为 default GC,用于逐步替换掉 CMS 收集器, G1仍然是分代的(年轻代 & 老年代),而 G1最大的优势就在于可预测的停顿时间模型,我们可以自己通过参数-XX:MaxGCPauseMillis 来设置允许的停顿时间(默认200ms) → G1回收器详解

ZGC

ZCG: ZGC 是 JDK 11 引入的, 在 JDK 15成为 default GC,特点是极短的 STW 时间, 无论堆的大小(上 T 级的情况)能保证 10 ms 以下的 JVM 停顿。→ ZGC回收器详解

GC 优化

GC 评价指标

GC 策略的评价指标:

  1. 吞吐量: 系统的生命周期内,应用程序所花费的时间和系统总运行时间的比值。系统总运行时间 = 应用程序耗时 + 总 GC 耗时。for example:如果系统运行了 100 分钟,全部 GC 耗时 1 分钟,则系统吞吐量=99% 注意区别系统设计中的“Thoughput”
  2. STW时间: 是否需要 STW,以及 STW 耗时多少
  3. 垃圾回收频率: 一般而言,频率越低越好,通常增大堆空间可以有效降低垃圾回收发生的频率,但是会增加回收时产生的停顿时间。
  4. 反应时间: 当一个对象成为垃圾后,多长时间内,它所占用的内存空间会被释放掉。

优化的过程,确定需要优化的指标,确定优化方案,最后验收结果

GC 优化案例

给出一些经验性的参考值:

  • Young CG 发生频率很高, 频率秒~分钟之间, 每次 YGC 的 STW 耗时很短, 可能不超过10ms;
  • Major GC 正常情况大约 1-2 次/天, 如果几小时就出现一次 Major GC 属于不正常;
  • Full GC 尽量杜绝;

➤ 一些 GC 问题的参考案例,多为一些旧时代(JDK7 + CMS)的优化:

  • Garbage Collection Optimization for High-Throughput and Low-Latency Java Applications
    • Linked 的 Feed 后台 GC 优化(Java7u51,ParNew+CMS、32GB Heap、6 GB YoungGen)
    • 因为有缓存长期存活的大对象,老年代 GC 触发阈值 CMSInitiatingOccupancyFraction=92,降低 Major GC 频率
    • 因为新生代几乎都在 eden 被回收,晋升的数量很小,所以设置了更小的晋升年龄,MaxTenuringThreshold=2,缩短新生代的停顿时间(主要是 copy 这一步耗时)
    • 开启 AlwaysPreTouch,commit 申请内存后立刻分配物理内存,而不是等发生缺页异常,也给出了这样做的原因:GC 发生 STW 时,user time 低、system time 高
  • 从实际案例聊聊Java应用的GC优化 - 美团技术团队
    • 案例 1: Minor GC 非常频繁(新生对象多,导致动态晋升年龄下降,更多的对象进入老年代)这里使用了扩容 eden 降低 Minor GC 频率
    • 案例 2: CMS 的 remark 阶段耗费非常高(该阶段有 STW,导致接口的响应时间出现非常高的情况)
    • 案例 3: PermGen 扩容导致出现 CMS GC(full gc)
  • 探索StringTable提升YGC性能 - 简书
    • StringTable 过大导致的 YGC 时间过长
  • java - 由「Metaspace容量不足触发CMS GC」从而引发的思考
    • JDK8环境使用了 CMS,Metaspace 达到扩容阈值导致 CMS GC(full gc)

GC 日志解读

GC 日志相关参数:

-XX:+PrintGC 输出 GC 日志
-XX:+PrintGCDetails 输出 GC 的详细日志
-XX:+PrintGCTimeStamps 输出 GC 的时间戳(以基准时间的形式)
-XX:+PrintGCDateStamps 输出 GC 的时间戳(以日期的形式,如 2013-05-04T21:53:59.234+0800)
-XX:+PrintHeapAtGC 在进行 GC 的前后打印出堆的信息
-Xloggc:../logs/gc.log 日志文件的输出路径

一般 GC 日志格式

图 1: Young GC 日志格式解释如下(ParallelGC):

图 2: Full GC 日志格式解释如下(ParallelGC):

GC 日志实例分析

Serial 收集器

一次 YGC 和 Full GC:

33.125: [GC [DefNew: 3324 K->152 K(3712 K), 0.0025925 secs] 3324 K->152 K(11904 K), 0.0031680 secs]
# 33.125 表示从 Java 虚拟机启动以来经过的秒数
# DefNew 表示新生代用了 Serial 收集器, ParNew 表示 ParNew 收集器, PSYoungGen 表示 Parallel Scavenge 收集器
# 3324 K->152 K(3712 K) 表示 GC 前新生代大小->GC 后新生代大小(新生代总大小)
# 3324 K->152 K(11904 K) 表示 GC 前堆大小->GC 后堆大小(堆总大小)
# 0.0025925 secs 表示此次新生代 GC 耗时 2ms


100.667: [Full GC [Tenured: 0 K->210 K(10240 K), 0.0149142 secs] 4603 K->210 K(19456 K), [Perm : 2999 K->2999 K(21248 K)], 0.0150007 secs] [Times: user=0.01 sys=0.00, real=0.02 secs]
# 100.667 表示从 Java 虚拟机启动以来经过的秒数
# Full GC 表示这是一次 Full GC
# [Tenured: 0 K->210 K(10240 K), 0.0149142 secs] 老年代 GC 前->后大小
# 4603 K->210 K(19456 K) 表示堆 GC 前->后
# [Times: user=0.01 sys=0.00, real=0.02 secs] 表示耗时 20 ms

ParNew + CMS 收集器

正常的 CMS GC 日志, 可以看到 CMS 标记的三个阶段:

# 初始标记, STW
15578.148: [GC [1 CMS-initial-mark: 6294851 K(20971520 K)] 6354687 K(24746432 K), 0.0466580 secs] [Times: user=0.04 sys=0.00, real=0.04 secs]

# 并发标记, 从 CG Root 出发开始标记
15578.195: [CMS-concurrent-mark-start]
15578.333: [CMS-concurrent-mark: 0.138/0.138 secs] [Times: user=1.01 sys=0.21, real=0.14 secs]


# 预清理
15578.334: [CMS-concurrent-preclean-start]
15578.391: [CMS-concurrent-preclean: 0.056/0.057 secs] [Times: user=0.20 sys=0.12, real=0.06 secs]

# 可中断预清理
15578.391: [CMS-concurrent-abortable-preclean-start]
15581.905: [CMS-concurrent-abortable-preclean: 3.506/3.514 secs] [Times: user=11.93 sys=6.77, real=3.51 secs]

# 重新标记, STW
15582.032: [weak refs processing, 0.0027800 secs]15582.035: [class unloading, 0.0033120 secs]15582.038: [scrub symbol table, 0.0016780 secs]15582.040: [scrub string table, 0.0004780 secs] [1 CMS-remark: 6299829 K(20971520 K)] 6348225 K(24746432 K), 0.1365130 secs] [Times: user=1.24 sys=0.00, real=0.14 secs]

# 并发清理
15582.043: [CMS-concurrent-sweep-start]
15590.327: [CMS-concurrent-sweep: 8.193/8.284 secs] [Times: user=30.34 sys=16.44, real=8.28 secs]
  • 阶段 1:Initial Mark, 这个是 CMS 两次 stop-the-wolrd 事件的其中一次,这个阶段的目标是:标记那些直接被 GC root 引用或者被年轻代存活对象所引用的所有对象,

    40.146: [GC [1 CMS-initial-mark: 26386 K(786432 K)] 26404 K(1048384 K), 0.0074495 secs]

  • 阶段 2:并发标记, 在这个阶段 Garbage Collector 会根据上个阶段找到的 GC Roots 遍历查找,标记所有存活的对象。这一阶段,Garbage Collector 与用户的应用程序并发运行。

    40.683: [CMS-concurrent-mark: 0.521/0.529 secs]

  • 阶段 3:Concurrent Preclean, 这也是一个并发阶段,与应用的线程并发运行,不会 stop-the-wolrd 。这一阶段会查找前一阶段中从新生代晋升或者有更新的对象。这一阶段可以减少 stop-the-world 的 remark 阶段的工作量

    40.701: [CMS-concurrent-preclean: 0.017/0.018 secs]

  • 阶段 4:Concurrent Abortable Preclean (可中断的预清理) 这也是一个并发阶段,同样不会不会 stop-the-wolrd 。该阶段主要工作仍然是并发标记对象是否存活,只是这个过程可被中断。此阶段在 Eden 区使用超过 2 M 时启动,当然 2 M 是默认的阈值,可以通过参数修改。如果此阶段执行时等到了 Minor GC,或者等了超过CMSMaxAbortablePrecleanTime的时间(默认 5 s)都没有发生 Minor GC,则会进入下一阶段 – Remark。// 该阶段尽量等一次 Minor GC 来减少新生代对象数量,减少 remark 阶段需要扫描新生代对象的数量,减少 remark 阶段 STW 耗时。通过CMSScavengeBeforeRemark参数,可以在这一阶段强制进行一次 Minor GC。

    15581.905: [CMS-concurrent-abortable-preclean: 3.506/3.514 secs] [Times: user=11.93 sys=6.77, real=3.51 secs]

  • 阶段 5:Remark, 这是第二个 STW 阶段,暂停所有用户线程,从 GC Root 开始重新扫描整堆,标记存活的对象。需要注意的是,虽然 CMS 只回收老年代的垃圾对象,但是这个阶段依然需要扫描新生代,因为很多 GC Root 都在新生代,而这些 GC Root 指向的对象又在老年代,这称为“跨代引用”。

    15582.032: [weak refs processing, 0.0027800 secs]15582.035: [class unloading, 0.0033120 secs]15582.038: [scrub symbol table, 0.0016780 secs]15582.040: [scrub string table, 0.0004780 secs] [1 CMS-remark: 6299829 K(20971520 K)] 6348225 K(24746432 K), 0.1365130 secs] [Times: user=1.24 sys=0.00, real=0.14 secs]

  • 阶段 6:Concurrent Sweep,并发清理,不需要 STW,需要注意的: 因为 CMS 是 Mark-Sweep 算法, 仍会存在内存碎片。

    15590.327: [CMS-concurrent-sweep: 8.193/8.284 secs] [Times: user=30.34 sys=16.44, real=8.28 secs]

@ref 参考: JVM 之 ParNew 和 CMS 日志分析

有 GC 问题的日志例子:

106.641: [GC 106.641: [ParNew (promotion failed): 14784 K->14784 K(14784 K), 0.0370328 secs]106.678: [CMS 106.715: [CMS-concurrent-mark: 0.065/0.103 secs] [Times: user=0.17 sys=0.00, real=0.11 secs]
# ParNew (promotion failed) 表示新生代 GC 过程中, 对象晋升到老年代失败, 因为需要晋升至老年代的对象超过了老年代的可用大小
# 这种情况下会触发 Full GC, 见下

0.195: [GC 0.195: [ParNew: 2986 K->2986 K(8128 K), 0.0000083 secs]0.195: [CMS 0.212: [CMS-concurrent-preclean: 0.011/0.031 secs] [Times: user=0.03 sys=0.02, real=0.03 secs]
(concurrent mode failure): 56046 K->138 K(57344 K), 0.0271519 secs] 59032 K->138 K(65472 K), [CMS Perm : 2079 K->2078 K(12288 K)], 0.0273119 secs] [Times: user=0.03 sys=0.00, real=0.03 secs]
# 第二行表示在 cms-preclean 阶段发生了 concurrent mode failure,
# CMS 失败往往意味着 JVM 会退回到 Serial Old 收集器进行回收, 造成较长的 STW

上面是因为执行 ParNew GC 的时候, 因为需要晋升至老年代的对象超过了老年代的可用大小, 所以 promotion failed, 而触发了 Full GC,
还存在一种情况, 老年代大小足够的情况下仍然会触发 promotion failed, 可以通过 -XX:UseCMSCompactAtFullCollection -XX:CMSFullGCBeforeCompaction=5 参数, 在 5 次 Full GC 后进行一次 Compaction 操作避免内存碎片.

GC 引起的 Error

什么代码会导致 GC 引起的内存错误: OutOfMemoryError:Java Heap, OutOfMemoryError:PermGen, OutOfMemoryError:Matespace, OutOfMemoryError: Direct buffer memory ? (以及堆栈错误StackOverflowError)

  • Java 虚拟机栈和本地方法栈: 在 JVM 规范中,对 Java 虚拟机栈规定了两种异常:
    • 如果线程请求的栈大于所分配的栈大小,则抛出 StackOverFlowError 错误,比如进行了一个不会停止的递归调用;
    • 如果虚拟机栈是可以动态拓展的,拓展时无法申请到足够的内存,则抛出 OutOfMemoryError 错误。
  • 堆内存: OutOfMemoryError
  • 直接内存: 在 JDK 1.4 中引入的 NIO 使用 Native 函数库在堆外内存上直接分配内存,但直接内存不足时,也会导致 OOM。
  • 方法区: 随着 Metaspace 元数据区的引入,方法区的 OOM 错误信息也变成了 “java.lang.OutOfMemoryError:Metaspace”。
    对于旧版本的 Oracle JDK,由于永久代的大小有限,而 JVM 对永久代的垃圾回收并不积极,如果往永久代不断写入数据,例如 String.Intern()的调用,在永久代占用太多空间导致内存不足,也会出现 OOM 的问题,对应的错误信为 “java.lang.OutOfMemoryError:PermGen space”

GC 内存设计

卡表(Card_Table)

Minor GC 是回收新生代,但是免不了老年代对象引用新生代,对于这个情况,Hotspot VM 使用了“卡表”的方案:
该技术将整个堆划分为一个个大小为 512 字节的卡,并且维护一个 byte 型数组,用来存储每张卡的一个标识位。这个标识位代表对应的卡是否可能存有指向新生代对象的引用。如果可能存在这种引用,那么我们就认为这张卡是脏的。
然后在进行 Minor GC 时,就可以把“脏卡”中的对象加入进 CG Roots 里;

用1 byte 表示一个 512B 的内存区,何时更新这个 1byte 的标志位呢?Hotspot 的做法是截获每一个更新引用的操作,这里采用了“宁可错杀,不可放过一个”,只要有“更新引用”的操作,不管更新后是不是指向新生代,都会把对应的卡表标志置为脏;

截获引用的写操作在解释执行中比较容易实现,但在机器码执行时就不太容易做到了,HotSpot 在这里使用了 写屏障 的技术( 这里要区别 volatile 的写屏障,二者不是一回事),
具体做法是,在“引用更新”的机器码后面,插入改写卡表标志的操作:

CARD_TABLE [this address >> 9] = DIRTY;

右移9位 = 除以512,也正好是卡表的 size,上面的 address 就是“做了引用修改”的对象的地址,
这种修改卡表项的操作,只需要2个机器码(一次位移+ 一次保存),效率尚可

但是在高并发环境下,对卡表标志的写操作,还会有“伪共享”的问题(有些地方也翻译为虚共享,英文 false sharing),
在对卡表项状态进行写操作时,需要把卡表项数组加载进 CPU 缓存里,CPU 缓存的最小单元是 cache line(缓存行),大小=64byte,也就是说一个 cache line 里能存储 64个卡表项标志位,对应64x512= 32KB 的 JVM 内存;

假设出现了一个最坏情况,有2个线程 A 和 B,分别运行在2个 CPU1 和 2上,线程 A 需要修改 CARD_TABLE[0],线程 B 修改 CARD_TABLE[6],虽然看起来不冲突,但是实际上卡表项的0和6,会被加载进一个 cache line 里(一个 cache line 能容纳64个卡表项,卡表项0~64 都会在一个 cache line),
这样 A 线程改完卡表项0,CPU1要向 CPU2 发送总线消息,告诉 CPU2这个 cache line 的数据被改了,需要 CPU2 同步 cache line 的状态(这一步骤是 #MESI 协议里规定的,详情参考 [[../21.Operating-System/01.CPU_Cache]]

看似2个线程的写操作互不干扰,但实际上因为这2个写都在操作一个 cache line,导致 CPU 做了很多同步工作,这也就是“伪共享”

为此,HotSpot 引入了一个新的参数 -XX:+UseCondCardMark,来尽量减少写卡表的操作。其伪代码如下所示:

if (CARD_TABLE [this address >> 9] != DIRTY)
CARD_TABLE [this address >> 9] = DIRTY;

@ref::

记忆集(RSet)

Remembered Set,与卡表类似,但 RSet 是一种 points-into 结构,见 G1回收器详解