Day18 JVM垃圾收集器二
-
多标-浮动垃圾 在并发垃圾回收过程中,由于应用程序线程与GC线程同时运行,导致某些本应该被回收的垃圾仍旧被标记到引用(对象标记期间变成垃圾或新分配的对象未被标记),从而未能回收的那部分垃圾,称之为浮动垃圾。浮动垃圾通常不会影响垃圾回收的正确性,只是需要等到下一轮才会被清除。针对并发标记/并发清理期间产生的新对象,通常都是全部标记为黑色
-
漏标-读写屏障 漏标是指在并发标记阶段,由于应用线程同时修改对象引用关系,导致本应存活的对象被错误的标记为垃圾,从而在回收时被错误的释放,这会直接导致程序崩溃,是一种严重的程序错误。两种解决方案
-
写屏障 当应用线程写入一个引用字段时,JVM自动加入一些处理操作来确保GC不会漏掉新建立的引用或丢失旧引用
增量更新(Incremental Update) 当黑色对象插入新的指向白色对象的引用关系时,会将新插入的引用记录下来,等并发扫描结束之后,再以这个加入了新引用的黑色对象为根,进行重新扫描。简单理解就是,黑色对象一旦有对白色对象的新引用,就记为灰色。 原始快照(Snapshot At The Beginning SATB) 当灰色对象要删除指向白色对象的引用关系时,会将删除的引用记录下来,等并发扫描结束之后,再以这个被删除了的对象为根,进行重新扫描,确保白色对象能被正确标记(即使有可能是浮动垃圾,也让它先活过这一轮)。缺点是浮动垃圾增多,以及会有额外开销和内存占用 为什么G1选择用SATB,而CMS用增量更新? 效率和成本的综合考虑,SATB相对于增量更新效率会更高,因为不需要在重新标记阶段再次进行深度扫描,G1的很多对象都位于不同的Regin,CMS就只负责老年代一个区域,如果G1也进行深度扫描的话代价会高很多
-
读屏障 当应用线程读取一个引用时插入屏障,在访问对象前修正指针、触发对象转移,以确保对象已标记
-
-
记忆集和卡表 新生代做GC Roots可达性扫描过程中可能会碰到跨代引用的对象,如果又到老年代再去扫描的话效率太低了,为此引入了记忆集(Remember Set)
- 卡表是记忆集的一种实现,使用一个字节数组CARD_TABLE[],数组每个元素对应着其标识的一块特定大小的内存区域,成为卡页,hotspot卡页大小是2^9=512字节。一个卡页中可能包含多个对象,只要有一个对象的字段存在跨代指针,其对应的卡表元素标识就会变成1,表示该元素变脏,否则为0,同样也是使用写屏障维护卡表状态。GC时只筛选本收集区的卡表变脏的元素加入GC Roots里
-
G1收集器(-XX:UseG1GC JDK9+默认) Garbage-First收集器是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器,以极高概率满足GC停顿时间要求(-XX:MaxGCPauseMillis设置,默认200ms)的同时,还具备高吞吐量性能特征。 G1将Java堆划分为多个大小相等的独立区域,目标是不超过2048个Region,一般Region的大小等于堆大小除以2048,可以使用-XX:G1HeapRegionSize手动指定region大小,一般使用默认方式。 G1保留了年轻代和老年代的概念,但并非是物理隔离,而是以分区的形式存在。默认年轻代再堆内存的占比是5%,可通过-XX:G1NewSizePercent设置比例,在实际运行中,并不会完全严格按照这个比例,JVM在垃圾回收过程中的优化有可能会不停的给年轻代增加更多的Region,最多占比不会超过60%(-XX:G1MaxNewSizePercent),年轻代Eden/S0/S1占比依旧为8:1:1。除了年轻代和老年代之外,G1还新增了一个Humongous区来用于专门保存大对象,而不是让大对象直接进入老年代region中,只要对象的大小超过了region的50%,就会被放入Humongous区(FullGC时也会回收),如果对象太大,会横跨多个region区来存放。 回收主要还是采用复制算法,将一个region的存活对象复制到另一个region,这样就不用像CMS一样回收完存在碎片还需要整理一次。
-
特性
- 并行与并发 充分利用多核CPU硬件优势,缩短stw停顿时间
- 分代回收 保留分代概念,做区域分代收集 YoungGC:Eden区满立即触发,会STW,预测回收所需时间,如果时间远小于MaxGCPauseMillis,就新增年轻代region,继续给新对象存放,不会马上做YoungGC,直到下一次Eden区满回收时间接近设定值才做GC MixedGC:老年代的堆占有率达到参数(-XX:InitiatingHeapOccupancyPercent)设定的值则触发,回收所有年轻代和部分老年代。需要把各个region存活的对象拷贝到别的region区,如果拷贝过程中发现没有足够的空间承载对象就会触发FullGC FullGC:停止系统,然后采用单线程进行标记、清理和压缩整理,过程非常耗时,应尽量避免
- 可预测停顿 对历史GC数据建模,预测每次回收的耗时 1)优先选择垃圾比例高+回收成本低的region 2)动态决定回收多少个region
-
G1收集器的运行过程(主要是指MixedGC)
- 初始标记 暂停所有其他线程STW,并记录下GC Roots直接能引用的对象。每个region有都有自己的remember sets记录跨区引用,由写屏障维护
- 并发标记 同CMS,基于SATB
- 最终标记 同CMS
- 筛选回收 对各个Region的回收价值和成本进行排序,根据用户期望的GC停顿时间来定制回收计划,尽可能保证总停顿≤MaxGCPauseMillis(不与用户线程并发执行) G1收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的region,保证有限时间内垃圾回收的高效率。

-
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%,此时就会立即停止混合回收,意味着本次混合回收就结束了。
-
调优建议
- 不要盲目调整
MaxGCPauseMillis如设为 10ms→ G1 会频繁 GC,吞吐下降,如设为1000ms→G1单次处理时间长,存活的对象可能比较多,有可能触发FullGC - 监控FullGC 一旦发生需要即使优化
- 尽量避免大对象
- 使用场景
- 大堆内存 8G以上(例如Kafka服务器)
- 停顿时间要求高≤500ms
- 50%以上的堆被存活对象占用
- 不要盲目调整
-
-
ZGC收集器(-XX:UseZGC 15+转正) jdk11引入的一款低延迟、可伸缩、基于分区的垃圾收集器,目标是在任意堆大小下(TB级),实现始终低于10ms的GC停顿时间,并对吞吐量的影响极小,适用于超大内存、对延迟敏感度极低的系统
-
内存布局 Region-Based
- 小型region(Small) 固定容量为2MB,用于放置小于256KB的对象。
- 中型region(Medium) 固定容量为32MB,用于放置256KB≤对象大小≤4MB的对象
- 大型region(Large) 不固定容量,动态变化,用于放置4MB以上的大对象,每个大region中只会存放一个大对象。所以它的实际容量最小有可能仅有4MB


- 在JDK11-JDK20中,ZGC是不支持分代的,自JDK21起试验性引入分代模式(需通过-XX:+ZGenerational手动启用),默认还是非分代模式,但在JDK24之后,该参数被废弃,默认是强制分代模式。
-
NUMA-aware(Non Uniform Memory Access Architecture)
早期的内存是CPU通过总线访问,随着核心数增多,都走一条总线的话争抢会越来越严重,也就相应的造成了性能瓶颈,于是就把CPU和内存集成到一个单元访问,形成了NUMA,每次CPU访问内存时,优先访问自己分配到的这块,如果本地内存空间不够了,就会从远程调配,以防出现内存使用倾斜。

-
ZGC的回收过程
- 并发标记(Concurrent Mark) 遍历对象图做可达性分析,它的初始标记(Mark Start)和最终标记(Mark End)也会出现短暂的停顿,ZGC的标记是在指针上用64位地址的高四位记录,标记阶段会更新染色指针
- 并发预备重分配(Concurrent Prepare for Relocate) 需要根据特定的查询条件统计得出本次收集过程要清理的region,并将这些region组成重分配集(Relocation Set)。用更大的扫描成本换G1记忆集的维护成本。
- 并发重分配(Concurrent Relocate) 将重分配集中的存活对象复制到新的region上,并为分配集中的每个region维护一个转发表(Forward Table),记录从旧对象到新对象的转向关系。 ZGC收集器能仅从引用上就明确得知一个对象是否处于重分配集之中,如果用户线程此时并发访问了位于重分配集中的对象,这次访问将会被预置的内存屏障(读屏障)所截获,然后立即根据Region上的转发表记录将访问转发到新复制的对象上,并同时修正更新该引用的值,使其直接指向新对象,ZGC将这种行为称为指针的“自愈”(Self-Healing)能力。只有第一次触发时会有较大消耗
- 并发重映射(Concurrent Remap) 重映射所做的就是修正整个堆中指向重分配集中旧对象的所有引用,但是ZGC中对象引用存在“自愈”功能,所以ZGC可以很巧妙地把并发重映射阶段要做的工作,合并到了下一次垃圾收集循环中的并发标记阶段里去完成,节省了一次遍历对象图的开销,一旦所有指针修正后,原来记录新旧对象关系的转发表就会被释放。
-
染色指针(Colored Pointer)
以往的垃圾回收器的GC信息都保存在对象头中,而ZGC的GC信息保存在64位地址的指针中,使得ZGC在扫描引用时,不需要访问对象本身,只看指针就能知道对象的状态(也使得ZGC高度依赖64位机器,32位系统无法使用)
- 18位:预留给以后使用【46-63】;
- 1位:Finalizable标识【45】,此位与并发引用处理有关,它表示这个对象只能通过finalizer才能访问;
- 1位:Remapped标识【44】,设置此位的值后,对象未指向relocation set中(relocation set表示需要GC的Region集合);
- 1位:Marked1标识【43】;
- 1位:Marked0标识【42】,和上面的Marked1都是标记对象用于辅助GC;
- 42位:对象的地址【0-41】(所以它可以支持2^42=4T内存):
使用双缓冲标记,在每一轮GC周期开始时交替使用,提高并发性能,第一轮GC使用Marked0,第二轮使用Marked1
-
读屏障
之前的GC都是采用Write Barrier,这次ZGC采用了完全不同的方案读屏障,这个是ZGC一个非常重要的特性。 在标记和移动对象的阶段,每次「从堆里对象的引用类型中读取一个指针」的时候,都需要加上一个Load Barriers。

-
缺点
- 浮动垃圾多,每次停顿时间都在10ms以下,并且不做分代,意味着每次GC都有极大概率清理不完朝生夕死的垃圾,增大堆内存能有更多的喘息时间,但也是治标不治本
- CPU消耗较高,因为读屏障的存在,每次读取引用都会触发检查
-
ZGC触发时机
- 定时触发 默认禁用,可通过-XX:ZCollectionInterval配置
- 预热触发 最多三次,当堆内存达到10%、20%、30%时触发,主要统计GC时间,为其他GC机制使用
- 分配速率 基于正态分布统计,计算内存99.9%可能的最大分配速率,以此速率下内存将要耗尽的时间点,在耗尽之前触发
- 主动触发 (默认,通过-XX:ZProactive配置)距上次GC堆内存增长10%,或超过5分钟时,对比距上次GC的间隔时间跟(49 * 一次GC的最大持续时间),超过则触发
-
-
如何选择垃圾收集器?
- 优先调整堆的大小让服务器自己来选择
- 如果内存小于100M,使用串行收集器
- 如果是单核,并且没有停顿时间的要求,串行或JVM自己选择
- 如果允许停顿时间超过1秒,选择并行或者JVM自己选
- 如果响应时间最重要,并且不能超过1秒,使用并发收集器
- 4G以下可以用parallel,4-8G可以用ParNew+CMS,8G以上可以用G1,几百G以上用ZGC(JDK9+默认使用G1)

-
安全点与安全域
安全点就是指代码中一些特定的位置,当线程运行到这些位置时它的状态是确定的,这样JVM就可以安全的进行一些操作,比如GC等,所以GC不是想什么时候做就立即触发的,是需要等待所有线程运行到安全点后才能触发。
这些特定的安全点位置主要有以下几种:
- 方法返回之前
- 调用某个方法之后
- 抛出异常的位置
- 循环的末尾
大体实现思想是当垃圾收集需要中断线程的时候, 不直接对线程操作, 仅仅简单地设置一个标志位, 各个线程执行过程时会不停地主动去轮询这个标志, 一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起。 轮询标志的地方和安全点是重合的。
安全区域又是什么?
Safe Point 是对正在执行的线程设定的。如果一个线程处于 Sleep 或中断状态,它就不能响应 JVM 的中断请求,再运行到 Safe Point 上。因此 JVM 引入了 Safe Region。Safe Region 是指在一段代码片段中,引用关系不会发生变化。在这个区域内的任意地方开始 GC 都是安全的。






