JVM垃圾收集器
# 垃圾收集器
# 如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。
虽然我们对各个收集器进行比较,但并非为了挑选出一个最好的收集器。因为直到现在为止还没有最好的垃圾收集器出 现,更加没有万能的垃圾收集器,我们能做的就是根据具体应用场景选择适合自己的垃圾收集器。试想一下:如果有一 种四海之内、任何场景下都适用的完美收集器存在,那么我们的Java虚拟机就不会实现那么多不同的垃圾收集器了。
# Serial收集器
Serial(串行)收集器是最基本、历史最悠久的垃圾收集器了。大家看名字就知道这个收集器是一个单线程收集器了。它 的 “单线程” 的意义不仅仅意味着它只会使用一条垃圾收集线程去完成垃圾收集工作,更重要的是它在进行垃圾收集工 作的时候必须暂停其他所有的工作线程( "Stop The World" ),直到它收集结束。
# 开启
- -XX:+UseSerialGC -XX:+UseSerialOldGC
# 算法
- 新生代采用复制算法,老年代采用标记-整理算法。
# 优点
- 简单而高效(与其他收集器的单线程相比)。Serial 收集器由于没有线程交互的开销,自然可以获得很高的单线程收集效率
# 缺点
- 它在进行垃圾收集工作的时候必须暂停其他所有的工作线程( "Stop The World" ),直到它收集结束
# 老年代版本
Serial Old收集器(单线程)
- 1、在JDK1.5 以及以前的版本中与Parallel Scavenge收集器搭配使用
- 2、作为CMS收集器的后备方案
# Parallel Scavenge收集器
Parallel收集器其实就是Serial收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为(控制参数、收集算 法、回收策略等等)和Serial收集器类似。默认的收集线程数跟cpu核数相同,当然也可以用参数(- XX:ParallelGCThreads)指定收集线程数,但是一般不推荐修改。
# 开启
- -XX:+UseParallelGC(年轻代),-XX:+UseParallelOldGC(老年代)
# 算法
- 新生代采用复制算法,老年代采用标记-整理算法。
# 老年代版本
Parallel Old收集器(多线程和“标记-整理”算法)
- 1、注重吞吐量以及 CPU资源的场合,都可以优先考虑 Parallel Scavenge收集器和Parallel Old收集器
- 2、JDK8默认的新生代和老年代收集器
# ParNew收集器
ParNew收集器其实跟Parallel收集器很类似,区别主要在于它可以和CMS收集器配合使用。
# 开启
- -XX:+UseParNewGC
# 算法
- 新生代采用复制算法,老年代采用标记-整理算法
# 备注
- 它是许多运行在Server模式下的虚拟机的首要选择,除了Serial收集器外,只有它能与CMS收集器配合工作
# CMS收集器(4-8G)
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它非常符合在注重用户体 验的应用上使用,它是HotSpot虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程 (基本上)同时工作。
# 初始标记(CMS Initial Mark(STW))
暂停所有的其他线程(STW),并记录下GCRoot直接能引用的对象,速度很快
仅标记从 GC Roots 直接可达的老年代对象,停顿很短。
三色标记心智:只标记从 GC Roots 直接可达的对象,把它们放入“待扫描队列”。在三色模型里,这些对象可以理解为从白→灰(已被发现,但还没把它们的引用对象都扫描一遍)。随后进入并发阶段再把它们“涂黑”。
# 并发标记(CMS-concurrent-mark)
并发标记阶段就是从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程很长,但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行。因为用户线程继续运行,可能会有已经标记过的对象状态发生改变。
与应用线程并发,从初始标记的根出发做可达性遍历。
三色标记心智:应用线程与标记线程并发执行,不断把灰对象出队并扫描其引用,把新发现的白对象标成灰,最终把被扫描完的对象变黑。
# 并发预清理(CMS-concurrent-preclean)(有时可能被跳过或非常短)
在并发标记后,对因应用继续运行产生的“新脏卡”做补充扫描;也可见
CMS-concurrent-abortable-preclean(可中断预清理,可多次小步让路给应用)。
preclean/abortable-preclean有时可能被跳过或非常短(视 JVM 版本、负载而定),但概念上都归为“预清理”。在 并发标记 之后,并发地处理因为应用继续运行而新增的 脏卡(dirty cards)、Survivor 区对象(Minor GC 期间新晋升/复制导致的交叉代引用) 等增量变化,把可并发完成的扫描与标记修补先做掉;还可能进入 Abortable Preclean,反复小步让路,尽量将“待处理工作量”压低到目标阈值。
把大量“可并发”的补扫工作前置到 Preclean,Remark(重新标记) 的停顿时间显著缩短,而且 Remark 的工作更聚焦于“闭包与一致性校验”。
# 重新标记(CMS-remark(STW))
重新标记阶段就是为了修正并发标记期间因为用户线程继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短,主要用到三色标记里的增量更新算法做重新标记
最后一次停顿,修正并发阶段遗漏的标记(处理剩余脏卡、SATB 队列等),保证标记闭包正确。
三色标记心智:CMS采用增量更新(Incremental Update)写屏障记录并发标记期间发生的 “黑→白” 新引用,Remark 阶段把这些增量补上,避免漏标。
# 并发清理(CMS-concurrent-sweep)
开启用户线程,同时GC线程开始对未标记的区域做清扫。这个时候如果有新增对象会被标记为黑色不做任何处理,回收未标记的老年代对象,释放空间;不压缩,易形成碎片。
**三色标记心智:**清掉未标记(仍为白色)的垃圾对象。
# 并发重置(CMS-concurrent-reset)
重置本次GC过程中的标记数据,重置内部数据结构与位图,准备下一轮 CMS。
# 开启
- -XX:+UseConcMarkSweepGC(old)
# 算法
- “标记-清除”算法
# 优点
- 并发收集、低停顿、用户体验好
# 缺点
对CPU资源敏感(会和服务抢占资源)
无法处理浮动垃圾(在并发标记和并发清理阶段又产生垃圾),这种垃圾只能等到下一次GC再清理了
它使用的回收算法 -“标记-清除”算法会导致收集结束时会有大量空间碎片产生,当然通过参数- XX:+UseCMSCompactAtFullCollection可以让jvm在执行完标记清除后再做整理
执行过程中的不确定性,会存在上一次垃圾回收还没执行完,然后垃圾回收又被触发的情况,特别是在并发标记和并发清理阶段会出现,一边回收,系统一边运行,也许没回收完就再次触发full GC,也就是"concurrent mode failure"(并发失败),此时会进入stop the world,用serial old垃圾收集器来回收
# 要点
ParNew发生 *promotion failed*(晋升失败) ⇒ 立刻触发 *STW Full GC* ⇒ 走的是 *Serial Old(Mark-Sweep-Compact)*,而不是再去跑一轮并发 CMS,更不会切到 Parallel Old(CMS 配置的回退实现就是 Serial Old;Parallel Old 属于并行吞吐收集器阵营,与 CMS 的实现与组合路径不同,JVM 不会在 CMF 时临时切换到 Parallel Old)。这是 CMS 的固定回退路径,目的是立即**回收出可用(且连续)空间。分配发生在 YGC 期间需要晋升 → 老年代放不下 → Promotion Failed → Full GC(Serial Old 压缩)。
分配发生在平时(例如直入老年代) → 老年代紧张 → 先触发 CMS(并发回收) → 若Concurrent Mode Failure或仍放不下 → Full GC(Serial Old 压缩)
CMS=老年代并发回收器(不压缩);Full GC=退回 Serial Old(可压缩),压缩频率由那两个参数控制,但前提是确实发生了 Full GC。
# “Full GC”“CMS 回收”“老年代回收”这几个词怎么区分?
名词 谁在跑 是否 STW 是否压缩 典型日志关键字 Minor GC / Young GC ParNew/PSYoungGen 部分 STW 否 ParNew/[PSYoungGen]CMS 周期(Major GC of Old) CMS(并发) 初始标记/重新标记短暂 STW,其余并发 否(标记-清除) CMS-initial-mark、CMS-remark、CMS-concurrent-sweepFull GC(在 CMS 配置下) Serial Old(MSC) 是(STW) 可压缩(受 UseCMSCompactAtFullCollection与CMSFullGCsBeforeCompaction控制)行首直接写 Full GC ...,并出现Tenured/Perm/Serial Old等
# 核心参数
- -XX:+UseConcMarkSweepGC:启用cms
- -XX:ConcGCThreads:并发的GC线程数
- -XX:+UseCMSCompactAtFullCollection:FullGC之后做压缩整理(减少碎片)
- -XX:CMSFullGCsBeforeCompaction:多少次FullGC之后压缩一次,默认是0,代表每次FullGC后都会压缩一次
- -XX:CMSInitiatingOccupancyFraction: 当老年代使用达到该比例时会触发FullGC(默认是92,这是百分比)
- -XX:+UseCMSInitiatingOccupancyOnly:只使用设定的回收阈值(-XX:CMSInitiatingOccupancyFraction设定的值),如果不指定,JVM仅在第一次使用设定值,后续则会自动调整
- -XX:+CMSScavengeBeforeRemark:在CMS GC前启动一次minor gc,降低CMS GC标记阶段(也会对年轻代一起做标记,如果在minor gc就干掉了很多对垃圾对象,标记阶段就会减少一些标记时间)时的开销,一般CMS的GC耗时 80%都在标记阶段
- -XX:+CMSParallellnitialMarkEnabled:表示在初始标记的时候多线程执行,缩短STW
- -XX:+CMSParallelRemarkEnabled:在重新标记的时候多线程执行,缩短STW
# 压缩参数
-XX:+UseCMSCompactAtFullCollection和-XX:CMSFullGCsBeforeCompaction只对“Full GC”生效;- 在 CMS 体系下,“Full GC”= 回退到 Serial Old 的 Mark-Sweep-Compact(STW、可压缩);
- 正常的 CMS 周期(并发标记-清除)本身不做压缩,也不会因为上面两个参数而追加压缩。
# 1) 设置 CMSFullGCsBeforeCompaction=1,CMS 正常回收完,会不会压缩?
不会。
这个参数的语义是:发生 Full GC 时,隔多少次 Full GC 才做一次压缩(配合 UseCMSCompactAtFullCollection 为 true 才起作用)。
=0(默认):每次 Full GC 都压缩;=1:第一轮 Full GC 不压缩,第二轮 Full GC 才压缩;- 以此类推,若这一段时间里 没有发生 Full GC(一直是 CMS 正常并发回收),则完全不会触发压缩。
# 2) “开启了压缩参数,难道只有回退到 Serial Old 才会压缩?”
是的。 压缩是在 Full GC 阶段完成的,而 CMS 的常规回收不是 Full GC,它只是老年代的并发标记-清除,不压缩。只有当出现以下“回退”情形,JVM 才会进入 Serial Old 做 Full GC +(可选)压缩:
- Promotion Failed(YGC 提升失败);
- Concurrent Mode Failure(CMS 来不及回收);
- 显式
System.gc()(若未使用-XX:+ExplicitGCInvokesConcurrent/-XX:+DisableExplicitGC); - 其他分配担保失败等情况。
# G1 垃圾收集器
# G1 (Garbage-First)是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器,以极高概率满足GC停顿时间要求的同时,还具备高吞吐量性能特征
G1将Java堆划分为多个大小相等的独立区域(Region),JVM目标是不超过2048个Region(JVM源码里TARGET_REGION_NUMBER 定义),实际可以超过该值,但是不推荐。 一般Region大小等于堆大小除以2048,比如堆大小为4096M,则Region大小为2M,当然也可以用参数"-XX:G1HeapRegionSize"手动指定Region大小,但是推荐默认的计算方式。 G1保留了年轻代和老年代的概念,但不再是物理隔阂了,它们都是(可以不连续)Region的集合。 默认年轻代对堆内存的占比是5%,如果堆大小为4096M,那么年轻代占据200MB左右的内存,对应大概是100个Region,可以通过“-XX:G1NewSizePercent”设置新生代初始占比,在系统运行中,JVM会不停的给年轻代增加更多的Region,但是最多新生代的占比不会超过60%,可以通过“-XX:G1MaxNewSizePercent”调整。年轻代中的Eden和Survivor对应的region也跟之前一样,默认8:1:1,假设年轻代现在有1000个region,eden区对应800个,s0对应100个,s1对应100个。 一个Region可能之前是年轻代,如果Region进行了垃圾回收,之后可能又会变成老年代,也就是说Region的区域功能可能会动态变化。
Mixed GC
初始标记(initial mark,STW)
- 暂停所有的其他线程,并记录下gc roots直接能引用的对象,速度很快
并发标记(Concurrent Marking)
- 同CMS的并发标记
最终标记(Remark,STW)
- 同CMS的重新标记
筛选回收(Cleanup,STW)
- 筛选回收阶段首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿STW时间(可以用JVM参数 -XX:MaxGCPauseMillis指定)来制定回收计划,比如说老年代此时有1000个Region都满了,但是因为根据预期停顿时间,本次垃圾回收可能只能停顿200毫秒,那么通过之前回收成本计算得知,可能回收其中800个Region刚好需要200ms,那么就只会回收800个Region(Collection Set,要回收的集合),尽量把GC导致的停顿时间控制在我们指定的范围内。这个阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分Region,时间是用户可控制的,而且停顿用户线程将大幅提高收集效率。不管是年轻代或是老年代,回收算法主要用的是复制算法,将一个region中的存活对象复制到另一个region中,这种不会像CMS那样回收完因为有很多内存碎片还需要整理一次,G1采用复制算法回收几乎不会有太多内存碎片。(注意:CMS回收阶段是跟用户线程一起并发执行的,G1因为内部实现太复杂暂时没实现并发回收,不过到了ZGC,Shenandoah就实现了并发收集,Shenandoah可以看成是G1的升级版本)
提示
G1收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的Region(这也就是它的名字Garbage-First的由来),比如一个Region花200ms能回收10M垃圾,另外一个Region花50ms能回收20M垃圾,在回收时间有限情况下,G1当然会优先选择后面这个Region回收。这种使用Region划分内存空间以及有优先级的区域回收方式,保证了G1收集器在有限时间内可以尽可能高的收集效率
# Humongous区
G1垃圾收集器对于对象什么时候会转移到老年代跟之前讲过的原则一样,唯一不同的是对大对象的处理,G1有专门分配大对象的Region叫Humongous区,而不是让大对象直接进入老年代的Region中。在G1中,大对象的判定规则就是一个大对象超过了一个Region大小的50%,比如按照上面算的,每个Region是2M,只要一个大对象超过了1M,就会被放入Humongous中,而且一个大对象如果太大,可能会横跨多个Region来存放。 Humongous区专门存放短期巨型对象,不用直接进老年代,可以节约老年代的空间,避免因为老年代空间不够的GC开销。 Full GC的时候除了收集年轻代和老年代之外,也会将Humongous区一并回收。
# 特征
并行与并发
- G1能充分利用CPU、多核环境下的硬件优势,使用多个CPU(CPU或者CPU核心)来缩短Stop-The-World停顿时间。部分其他收集器原本需要停顿Java线程来执行GC动作,G1收集器仍然可以通过并发的方式让java程序继续执行。
分代收集
- 虽然G1可以不需要其他收集器配合就能独立管理整个GC堆,但是还是保留了分代的概念。
空间整合
- 与CMS的“标记--清理”算法不同,G1从整体来看是基于“标记整理”算法实现的收集器;从局部上来看是基于“复制”算法实现的。
可预测的停顿
- 这是G1相对于CMS的另一个大优势,降低停顿时间是G1 和 CMS 共同的关注点,但G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段(通过参数"-XX:MaxGCPauseMillis"指定)内完成垃圾收集。
# G1垃圾收集分类
YoungGC
- YoungGC并不是说现有的Eden区放满了就会马上触发,G1会计算下现在Eden区回收大概要多久时间,如果回收时间远远小于参数 -XX:MaxGCPauseMills 设定的值,那么增加年轻代的region,继续给新对象存放,不会马上做Young GC,直到下一次Eden区放满,G1计算回收时间接近参数 -XX:MaxGCPauseMills 设定的值,那么就会触发Young GC,Young GC会触发STW,但是会根据MaxGCPauseMills设定的值停顿
MixedGC
- 不是FullGC,老年代的堆占有率达到参数(-XX:InitiatingHeapOccupancyPercent)设定的值则触发,回收所有的Young和部分Old(根据期望的GC停顿时间确定old区垃圾收集的优先顺序)以及大对象区,正常情况G1的垃圾收集是先做MixedGC,主要使用复制算法,需要把各个region中存活的对象拷贝到别的region里去,拷贝过程中如果发现没有足够的空region能够承载拷贝对象就会触发一次Full GC
Full GC
停止系统程序,然后采用单线程进行标记、清理和压缩整理,好空闲出来一批Region来供下一次MixedGC使用,这个过程是非常耗时的。(Shenandoah优化成多线程收集了)
JDK 9 及更早:G1 的 Full GC 回退为单线程的 Serial Full GC(Serial Old 标记-压缩)。你会在日志看到
Full GC (Ergonomics) ... Using Serial等字样。JDK 10+(含 11/17/21/最新 LTS):引入 JEP 307,G1 拥有并行的 Full GC 实现,不再回退到 Serial Old。日志显示
Full GC (Allocation Failure) ... G1且多线程。常见触发:
- To-space exhausted:做年轻代复制回收时(from→to),承接幸存对象的目标空间不够了
- Evacuation failure:G1 回收 CSet 时,要把存活对象“撤离”到 Survivor/Old 的可用 Region;当 没有足够/合适的 Region 承接时,就出现撤离失败(日志常伴随 to-space exhausted)
- 大对象(Humongous) 失败、并发周期赶不上分配等
观察方式:开启 GC 日志,关注
Full GC行里的 collector 名称 与 线程数。
Young/Mixed GC ⇒ 都是 STW,只是 并行 执行以尽量满足
MaxGCPauseMillis目标
# G1收集器参数设置
- -XX:+UseG1GC:使用G1收集器
- -XX:ParallelGCThreads:指定GC工作的线程数量
- -XX:G1HeapRegionSize:指定分区大小(1MB~32MB,且必须是2的N次幂),默认将整堆划分为2048个分区
- -XX:MaxGCPauseMillis:目标暂停时间(默认200ms)
- -XX:G1NewSizePercent:新生代内存初始空间(默认整堆5%,值配置整数,默认就是百分比)
- -XX:G1MaxNewSizePercent:新生代内存最大空间
- -XX:TargetSurvivorRatio:Survivor区的填充容量(默认50%),Survivor区域里的一批对象(年龄1+年龄2+年龄n的多个年龄对象)总和超过了Survivor区域的50%,此时就会把年龄n(含)以上的对象都放入老年代
- -XX:MaxTenuringThreshold:最大年龄阈值(默认15)
- -XX:InitiatingHeapOccupancyPercent:老年代占用空间达到整堆内存阈值(默认45%),则执行新生代和老年代的混合收集(MixedGC),比如我们之前说的堆默认有2048个region,如果有接近1000个region都是老年代的region,则可能就要触发MixedGC了
- -XX:G1MixedGCLiveThresholdPercent(默认85%) region中的存活对象低于这个值时才会回收该region,如果超过这个值,存活对象过多,回收的的意义不大。
- -XX:G1MixedGCCountTarget:在一次回收过程中指定做几次筛选回收(默认8次),在最后一个筛选回收阶段可以回收一会,然后暂停回收,恢复系统运行,一会再开始回收,这样可以让系统不至于单次停顿时间过长。
- -XX:G1HeapWastePercent(默认5%): gc过程中空出来的region是否充足阈值,在混合回收的时候,对Region回收都是基于复制算法进行的,都是把要回收的Region里的存活对象放入其他Region,然后这个Region中的垃圾对象全部清理掉,这样的话在回收过程就会不断空出来新的Region,一旦空闲出来的Region数量达到了堆内存的5%,此时就会立即停止混合回收,意味着本次混合回收就结束了。
# G1垃圾收集器优化建议
- 假设参数 -XX:MaxGCPauseMills 设置的值很大,导致系统运行很久,年轻代可能都占用了堆内存的60%了,此时才触发年轻代gc。 那么存活下来的对象可能就会很多,此时就会导致Survivor区域放不下那么多的对象,就会进入老年代中。 或者是你年轻代gc过后,存活下来的对象过多,导致进入Survivor区域后触发了动态年龄判定规则,达到了Survivor区域的50%,也会快速导致一些对象进入老年代中。 所以这里核心还是在于调节 -XX:MaxGCPauseMills 这个参数的值,在保证他的年轻代gc别太频繁的同时,还得考虑每次gc过后的存活对象有多少,避免存活对象太多快速进入老年代,频繁触发mixed gc.
# G1收集器适用场景
- 50%以上的堆被存活对象占用
- 对象分配和晋升的速度变化非常大
- 垃圾回收时间特别长,超过1秒
- 8GB以上的堆内存(建议值)
- 停顿时间是500ms以内
# 每秒几十万并发的系统如何优化JVM
- Kafka类似的支撑高并发消息系统大家肯定不陌生,对于kafka来说,每秒处理几万甚至几十万消息是很正常的,一般来说部署kafka需要用大内存机器(比如64G),也就是说可以给年轻代分配个三四十G的内存用来支撑高并发处理,这里就涉及到一个问题了,我们以前常说的对于eden区的young gc是很快的,这种情况下它的执行还会很快吗?很显然,不可能,因为内存太大,处理还是要花不少时间的,假设三四十G内存回收可能最快也要几秒钟,按kafka这个并发量放满三四十G的eden区可能也就一两分钟吧,那么意味着整个系统每运行一两分钟就会因为young gc卡顿几秒钟没法处理新消息,显然是不行的。那么对于这种情况如何优化了,我们可以使用G1收集器,设置 -XX:MaxGCPauseMills 为50ms,假设50ms能够回收三到四个G内存,然后50ms的卡顿其实完全能够接受,用户几乎无感知,那么整个系统就可以在卡顿几乎无感知的情况下一边处理业务一边收集垃圾。
- G1天生就适合这种大内存机器的JVM运行,可以比较完美的解决大内存垃圾回收时间过长的问题。
# 要点
# G1 在哪些情况下会“回退”(进入 Full GC)
疏散失败(Evacuation Failure / to-space exhausted) 年轻代/混合回收时,对象要从 CSet 疏散到 to-space(或提升到老年代)。若 to-space 或老年代可用/连续空间不足,出现 to-space exhausted / evacuation failure,则触发 Full GC。
疏散失败(Evacuation Failure)是更大的集合,包含了晋升失败,指从 CSet 复制到 任一 to-space(Survivor 或 Old)失败
晋升失败(Promotion Failure) 存活对象需要提升到老年代但放不下(碎片或空间不足),同样会回退 Full GC。
巨型对象(Humongous)分配失败 G1 需要连续的若干 Region 才能放下 Humongous 对象(≥ 0.5×Region 大小)。找不到足够的连续空 Region 时,回退 Full GC。
并发标记进度落后(来不及回收)导致的分配失败(并发失败) 并发循环尚未回收出可用空间而应用持续分配,触发分配失败 → 回退 Full GC。
显式 GC
System.gc()且未启用并发式显式 GC(如未加-XX:+ExplicitGCInvokesConcurrent或禁用了它),可直接触发 Full GC。Metaspace 紧张/类卸载压力 元空间吃紧时,JVM 可能通过一次 Full GC 试图卸载类以释放空间。
- JDK 8 的 G1 回退=单线程整堆 Mark-Sweep-Compact,效果上就跟 Serial Old 的 Full GC 一样(STW、可压缩、极慢)。
- JDK 10 起(JEP 307):G1 内置了并行 Full GC。它不是把“Parallel Old”搬过来,而是 G1 自己的并行化整堆压缩路径(仍然 STW,但多线程、基于 Region 的移动/压缩)。
再精细一点对比下理念(相似≠相同):
| 维度 | JDK8 G1 回退 | JDK10+ G1 回退 | Parallel Old |
|---|---|---|---|
| 并行性 | 单线程 | 并行 | 并行 |
| 堆组织 | Region(但走单线程 MSC) | Region(G1 自有并行 Full GC) | 代际、连续空间 |
| 压缩方式 | 压缩(滑动/移动),但单线程 | 压缩(并行移动/整理) | 压缩(并行滑动/整理) |
| 本质关系 | 等价于 Serial Old 风格 | G1 自家并行 Full GC,不是 Parallel Old | 独立的老年代并行压缩收集器 |
- JDK8:G1 回退≈Serial Old(单线程整堆压缩);
- JDK10+:G1 回退是 G1 的并行 Full GC。它和 Parallel Old 在“并行 + 压缩 + STW”这三点上相似,但实现与堆布局完全不同(Region 化 vs 连续老年代)。
# 日志锚点(看到这些基本就是回退前后)
- 疏散失败:
to-space exhausted、evacuation failure、promotion failure - 巨型对象:
humongous allocation/Humongous regions - 回退:行首
Full GC ...,并出现G1的 Full GC 统计段(JDK8 单线程、JDK10+ 并行)
# 减少“回退”的几条快调建议
- 提前并更积极地回收:
- 固定 IHOP:
-XX:-G1UseAdaptiveIHOP -XX:InitiatingHeapOccupancyPercent=<更小> - 或保持自适应但适当降低目标暂停:
-XX:MaxGCPauseMillis=<合适值>(间接影响触发点)
- 固定 IHOP:
- 给疏散留余量:
-XX:G1ReservePercent=20(默认 10,适当调大可显著降低 to-space exhausted) - 减少 Humongous 触发:
- 调整 Region 大小:
-XX:G1HeapRegionSize(更大的 Region 提高 Humongous 阈值,减少“巨型”判定) - 避免超大短命缓冲区/数组(业务侧切块)
- 调整 Region 大小:
- 提升并行度:
-XX:ConcGCThreads、-XX:ParallelGCThreads合理配置 - JDK 升级:从 JDK8 升到 JDK11+,可获得 并行 Full GC 与更成熟的 G1 调度
一句话记忆: G1 一旦“疏散/晋升/巨型分配失败,或并发来不及”,就回退做一次整堆压缩的 Full GC;JDK8 单线程、JDK10+ 并行。
# ZGC垃圾收集器
- ZGC是一款JDK 11中新加入的具有实验性质的低延迟垃圾收集器,ZGC可以说源自于是Azul System公司开发的C4(Concurrent Continuously Compacting Collector) 收集器
- ZGC收集器是一款基于Region内存布局的, 暂时不设分代的, 使用了读屏障、 颜色指针等技术来实现可并发的标记-整理算法的, 以低延迟为首要目标的一款垃圾收集器
并发标记(Concurrent Mark)
- 与G1一样,并发标记是遍历对象图做可达性分析的阶段,它的初始标记(Mark Start)和最终标记(Mark End)也会出现短暂的停顿,与G1不同的是, ZGC的标记是在指针上而不是在对象上进行的, 标记阶段会更新颜色指针(见下面详解)中的Marked 0、 Marked 1标志位。
并发预备重分配(Concurrent Prepare for Relocate)
- 这个阶段需要根据特定的查询条件统计得出本次收集过程要清理哪些Region,将这些Region组成重分配集(Relocation Set)。ZGC每次回收都会扫描所有的Region,用范围更大的扫描成本换取省去G1中记忆集的维护成本。
并发重分配(Concurrent Relocate)
- 重分配是ZGC执行过程中的核心阶段,这个过程要把重分配集中的存活对象复制到新的Region上,并为重分配集中的每个Region维护一个转发表(Forward Table),记录从旧对象到新对象的转向关系。ZGC收集器能仅从引用上就明确得知一个对象是否处于重分配集之中,如果用户线程此时并发访问了位于重分配集中的对象,这次访问将会被预置的内存屏障(读屏障(见下面详解))所截获,然后立即根据Region上的转发表记录将访问转发到新复制的对象上,并同时修正更新该引用的值,使其直接指向新对象,ZGC将这种行为称为指针的“自愈”(Self-Healing)能力。
- ZGC的颜色指针因为“自愈”(Self-Healing)能力,所以只有第一次访问旧对象会变慢, 一旦重分配集中某个Region的存活对象都复制完毕后, 这个Region就可以立即释放用于新对象的分配,但是转发表还得留着不能释放掉, 因为可能还有访问在使用这个转发表。
并发重映射(Concurrent Remap)
- 重映射所做的就是修正整个堆中指向重分配集中旧对象的所有引用,但是ZGC中对象引用存在“自愈”功能,所以这个重映射操作并不是很迫切。ZGC很巧妙地把并发重映射阶段要做的工作,合并到了下一次垃圾收集循环中的并发标记阶段里去完成,反正它们都是要遍历所有对象的,这样合并就节省了一次遍历对象图的开销。一旦所有指针都被修正之后, 原来记录新旧对象关系的转发表就可以释放掉了。
# NUMA-aware技术
- 1、NUMA对应的有UMA,UMA即Uniform Memory Access Architecture,NUMA就是Non Uniform Memory Access Architecture。UMA表示内存只有一块,所有CPU都去访问这一块内存,那么就会存在竞争问题(争夺内存总线访问权),有竞争就会有锁,有锁效率就会受到影响,而且CPU核心数越多,竞争就越激烈。NUMA的话每个CPU对应有一块内存,且这块内存在主板上离这个CPU是最近的,每个CPU优先访问这块内存,那效率自然就提高了 2、服务器的NUMA架构在中大型系统上一直非常盛行,也是高性能的解决方案,尤其在系统延迟方面表现都很优秀。ZGC是能自动感知NUMA架构并充分利用NUMA架构特性的
# ZGC解决的问题
支持TB量级的堆
最大GC停顿时间不超10ms
- 目前一般线上环境运行良好的JAVA应用Minor GC停顿时间在10ms左右,Major GC一般都需要100ms以上(G1可以调节停顿时间,但是如果调的过低的话,反而会适得其反),之所以能做到这一点是因为它的停顿时间主要跟Root扫描有关,而Root数量和堆大小是没有任何关系的
奠定未来GC特性的基础
最糟糕的情况下吞吐量会降低15%
- 它的停顿时间不会随着堆的增大而增长!也就是说,几十G堆的停顿时间是10ms以下,几百G甚至上T堆的停顿时间也是10ms以下,至于吞吐量,通过扩容解决
# ZGC内存布局
- 小型Region(Small Region) : 容量固定为2MB, 用于放置小于256KB的小对象。
- 中型Region(Medium Region) : 容量固定为32MB, 用于放置大于等于256KB但小于4MB的对象。
- 大型Region(Large Region) : 容量不固定, 可以动态变化, 但必须为2MB的整数倍, 用于放置4MB或以上的大对象。 每个大型Region中只会存放一个大对象, 这也预示着虽然名字叫作“大型Region”, 但它的实际容量完全有可能小于中型Region, 最小容量可低至4MB。 大型Region在ZGC的实现中是不会被重分配(重分配是ZGC的一种处理动作, 用于复制对象的收集器阶段, 稍后会介绍到)的, 因为复制一个大对象的代价非常高昂。
# 颜色指针
Colored Pointers,即颜色指针,如下图所示,ZGC的核心设计之一。以前的垃圾回收器的GC信息都保存在对象头中,而ZGC的GC信息保存在指针中
优势
- 一旦某个Region的存活对象被移走之后,这个Region立即就能够被释放和重用掉,而不必等待整个堆中所有指向该Region的引用都被修正后才能清理,这使得理论上只要还有一个空闲Region,ZGC就能完成收集。
- 颜色指针可以大幅减少在垃圾收集过程中内存屏障的使用数量,ZGC只使用了读屏障。
- 颜色指针具备强大的扩展性,它可以作为一种可扩展的存储结构用来记录更多与对象标记、重定位过程相关的数据,以便日后进一步提高性能。
# 读屏障
- 之前的GC都是采用Write Barrier,这次ZGC采用了完全不同的方案读屏障,这个是ZGC一个非常重要的特性。 在标记和移动对象的阶段,每次「从堆里对象的引用类型中读取一个指针」的时候,都需要加上一个Load Barriers。 那么我们该如何理解它呢?看下面的代码,第一行代码我们尝试读取堆中的一个对象引用obj.fieldA并赋给引用o(fieldA也是一个对象时才会加上读屏障)。如果这时候对象在GC时被移动了,接下来JVM就会加上一个读屏障,这个屏障会把读出的指针更新到对象的新地址上,并且把堆里的这个指针“修正”到原本的字段里。这样就算GC把对象移动了,读屏障也会发现并修正指针,于是应用代码就永远都会持有更新后的有效指针,而且不需要STW。
# 存在问题
ZGC最大的问题是浮动垃圾。ZGC的停顿时间是在10ms以下,但是ZGC的执行时间还是远远大于这个时间的。假如ZGC全过程需要执行10分钟,在这个期间由于对象分配速率很高,将创建大量的新对象,这些对象很难进入当次GC,所以只能在下次GC的时候进行回收,这些只能等到下次GC才能回收的对象就是浮动垃圾。 ZGC没有分代概念,每次都需要进行全堆扫描,导致一些“朝生夕死”的对象没能及时的被回收。
解决方案
- 目前唯一的办法是增大堆的容量,使得程序得到更多的喘息时间,但是这个也是一个治标不治本的方案。如果需要从根本上解决这个问题,还是需要引入分代收集,让新生对象都在一个专门的区域中创建,然后专门针对这个区域进行更频繁、更快的收集。
# 启用
- -XX:+UnlockExperimentalVMOptions 「-XX:+UseZGC」
# ZGC触发时机
- 定时触发,默认为不使用,可通过ZCollectionInterval参数配置。
- 预热触发,最多三次,在堆内存达到10%、20%、30%时触发,主要时统计GC时间,为其他GC机制使用。
- 分配速率,基于正态分布统计,计算内存99.9%可能的最大分配速率,以及此速率下内存将要耗尽的时间点,在耗尽之前触发GC(耗尽时间 - 一次GC最大持续时间 - 一次GC检测周期时间)。
- 主动触发,(默认开启,可通过ZProactive参数配置) 距上次GC堆内存增长10%,或超过5分钟时,对比距上次GC的间隔时间跟(49 * 一次GC的最大持续时间),超过则触发。
# 如何选择垃圾收集器?
- 优先调整堆的大小让服务器自己来选择
- 如果内存小于100M,使用串行收集器
- 如果是单核,并且没有停顿时间的要求,串行或JVM自己选择
- 如果允许停顿时间超过1秒,选择并行或者JVM自己选
- 如果响应时间最重要,并且不能超过1秒,使用并发收集器
- 4G以下可以用parallel,4-8G可以用ParNew+CMS,8G以上可以用G1,几百G以上用ZGC
# GC安全点与安全区域
# 安全点
指代码中一些特定的位置,当线程运行到这些位置时它的状态是确定的,这样JVM就可以安全的进行一些操作,比如GC等,所以GC不是想什么时候做就立即触发的(比如某些原子性操作,不能暂停,只能执行完毕才能继续下一步),是需要等待所有线程运行到安全点后才能触发
# 触发点
- 方法返回之前
- 调用某个方法之后
- 抛出异常的位置
- 循环的末尾
大体实现思想是当垃圾收集需要中断线程的时候, 不直接对线程操作, 仅仅简单地设置一个标志位, 各个线程执行过程时会不停地主动去轮询这个标志, 一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起。 轮询标志的地方和安全点是重合的。
# 安全区域
Safe Point 是对正在执行的线程设定的。 如果一个线程处于 Sleep 或中断状态,它就不能响应 JVM 的中断请求,再运行到 Safe Point 上。 因此 JVM 引入了 Safe Region。 Safe Region 是指在一段代码片段中,引用关系不会发生变化。在这个区域内的任意地方开始 GC 都是安全的。