1 低延迟高可用Java服务的痛点
对于低延迟和高可用的Java服务,GC停顿一直是它们的痛点。
例如一个服务,被要求在100ms内要返回结果,服务可用性要求为99.99%。假设服务收到的请求速率稳定, GC的平均停顿时间为50ms,每分钟GC 5次, 服务的平均响应耗时为60ms,则0.4%(5*50ms/60000ms)的请求处理时间会因为GC停顿增加50ms,服务的可用性最低可能为99.6%,无法满足可用性要求。
ZGC(The Z Garbage Collector)是JDK 11中推出的一款低延迟垃圾回收器,可用于大内存低延迟服务的内存管理和回收。
2 GC基础概念
- 垃圾:不再使用的对象。在JVM中,指的是一个没有引用指向的对象,或者仅包含循环引用的多个对象。
- GC:Garbage Collection,垃圾回收
- STW:Stop The World,GC的停顿时间,此时所有用户线程停止活动,等待GC线程完成垃圾回收。
- 用户线程:用于执行业务逻辑的线程。
- GC线程:用于垃圾收集的线程。
- 并发:在多核条件下,用户线程和GC线程同时执行。
- 并行:在多核条件下,多个GC线程同时执行。
3 ZGC简介
3.1 特点
- 单代内存管理
- 内存分页管理:使用小、中、大三种粒度。
3.2 优点
- 停顿时间不会随着堆内存的增大而增长:ZGC几乎所有暂停都只依赖于根集合大小,停顿时间不会随着堆内存的大小而增加。
- 单次停顿时间控制在10ms之内。在实际项目中通常停顿时间小于15ms。
3.3 缺点
- 吞吐量低,不适合吞吐量优先的程序。
- CPU占用率偏高:在实际项目中,服务使用zgc后,机器CPU负载会增加10%
- 对机器CPU负载敏感:在实际项目中,发现机器的CPU负载达到到60%以上,ZGC的内存回收速度会下降,导致GC周期增长。
- 存在浮动垃圾。
- 当内存回收速度低于分配速度,可用内存不足时,会退化成阻塞回收,所有用户线程暂停,直到GC完成。
4 低延迟的原因
ZGC在标记、转移和重定位阶段几乎都是并发的,这是ZGC实现停顿时间小于10ms的最关键原因。
ZGC 只有 3 个需要 STW 的阶段,其中初始标记和初始转移只需要扫描所有根集合,STW 时间根集合 的数量成正比,不会耗费太多时间。再标记过程主要处理并发标记引用地址发生变化的对象,这些对象数量比较少,耗时非常短。
- 初始标记:从根集合出发,标记根集合所有引用的对象。需要STW。
- 并发标记:以第1步找到的标记对象为起点,进行遍历和标记。
- 再标记:完成对并发标记过程中尚未标记的对象的标记。需要STW。
- 并发转移准备:确定需要清理的页面
- 初始转移:将需要清理的页面中,根集合引用对象移动到新页面。需要STW。
- 并发转移:将需要清理的页面中,存活对象移动到新页面。
5 关键技术
5.1 内存多重映射
内存多重映射,就是把不同的虚拟内存地址映射到同一个物理内存地址上。
ZGC 使用了内存多重映射,把同一块儿物理内存映射为 Marked0、Marked1 和 Remapped 三个虚拟内存。
Marked0、Marked1 和 Remapped 这三个虚拟内存作为 ZGC 的三个视图空间,在同一个时间点内只能有一个有效。ZGC 就是通过这三个视图空间的切换,来完成并发的垃圾回收。
5.2 着色指针
着色指针是一种将信息存储在指针中的技术。
ZGC 出现之前, GC 信息保存在对象头的 Mark Word 中。
ZGC将对象存活信息存储在42~45位中。
JVM 可以从指针上直接看到对象的三色标记状态(Marked0、Marked1)、是否进入了重分配集(Remapped)、是否需要通过 finalize 方法来访问到(Finalizable)。无需进行对象访问就可以获得 GC 信息,这大大提高了 GC 效率。
5.3 内存布局
为了细粒度地控制内存的分配,ZGC将内存分为小、中、大三种页面:
- 小页面(Small Page) : 容量固定为2MB, 用于放置小于256KB的小对象。
- 中页面(Medium Page) : 容量固定为32MB, 用于放置大于等于256KB但小于4MB的对象。
- 大页面(Large Page) : 容量不固定, 可以动态变化, 但必须为2MB的整数倍, 用于放置4MB或以上的大对象。 每个大型Region中只会存放一个大对象。
5.4 读屏障
读屏障是JVM向应用代码插入一小段代码的技术。
ZGC中读屏障的代码作用:在对象标记和转移过程中,用于确定对象的引用地址是否满足条件,并作出相应动作。
当应用线程从堆中读取对象引用时,就会执行这段代码。
Object o = obj.FieldA
<Load barrier>
Object p = o //不是从堆中读取引用
o.dosomething() //不是从堆中读取引用
int i = obj.FieldB //不是引用类型
6 异常情况说明
6.1 GC异常情况
如果内存分配速度大于回收速度,在GC执行过程中,服务可能无可用内存,这时ZGC会进入阻塞回收模式。所有的用户线程会停止,直到GC执行完毕。
这种情况,在ZGC中称之为“Allocation Stall”
6.2 内存分配异常
"Page Cache Flush"问题影响分配速度:
- 如果各种大小对象分配速度不稳定,比如中等大小的对象突然变多,中页面不足,那么就需要把小页面或者大页面转换成中页面,转换比较耗时,从而影响内存分配速度。
- GC日志中的关键字为“Page Cache Flush”。
7 GC日志
7.1 GC触发时机
ZGC有多种GC触发机制,具体如下:
- 阻塞内存分配请求触发:当垃圾来不及回收,可用内存不足时触发。应当避免出现这种触发方式。日志中关键字是“Allocation Stall”。
- 基于分配速率的自适应算法:最主要的GC触发方式,其算法原理可简单描述为”ZGC根据近期的对象分配速率以及GC时间,计算出当内存占用达到什么阈值时触发下一次GC”。日志中关键字是“Allocation Rate”。
- 基于固定时间间隔:日志中关键字是“Timer”。
- 主动触发规则:类似于固定间隔规则,但时间间隔不固定,是ZGC自行算出来的时机。日志中关键字是“Proactive”。
- 预热规则:服务刚启动时出现,一般不需要关注。日志中关键字是“Warmup”。
- 外部触发:代码中显式调用System.gc()触发。 日志中关键字是“System.gc()”。
- 元数据分配触发:元数据区不足时导致,一般不需要关注。 日志中关键字是“Metadata GC Threshold”。
7.2 GC日志解读
GC日志中每一行都注明了GC过程中的信息,关键信息如下:
- Start:开始GC,并标明的GC触发的原因。
- Phase-Pause Mark Start:初始标记,会STW。
- Phase-Pause Mark End:再次标记,会STW。
- Phase-Pause Relocate Start:初始转移,会STW。
GC信息统计:定时的打印垃圾收集信息,标注了10秒内、10分钟内、10个小时内的统计信息。
如下图所示。
8 常用GC参数
以jdk11为例,如下是一个程序的GC参数:
java -Xms256m -Xmx256m -XX:+UnlockExperimentalVMOptions -XX:+UseZGC -XX:ConcGCThreads=2 -XX:ParallelGCThreads=3 -XX:ZCollectionInterval=60 -XX:ZAllocationSpikeTolerance=4 -XX:+ZProactive -Xlog:gc*:file=gc.log:time,tid,tags -cp work.jar Main
内存大小:
- -Xms:堆的最大内存
- -Xmx:堆最小内存
启用zgc:
- -XX:+UnlockExperimentalVMOptions -XX:+UseZGC:启用ZGC的配置。
GC线程数设置:
- -XX:ConcGCThreads:并发回收垃圾的线程。示例中是2,默认是总核数的12.5%。调大后GC变快,但会占用程序运行时的CPU资源,吞吐会受到影响。
- -XX:ParallelGCThreads:STW阶段使用线程数,示例中是3,默认是总核数的60%。
触发方式设置:
- -XX:ZCollectionInterval:ZGC发生的最小时间间隔,单位秒。示例中是60秒。
- -XX:ZAllocationSpikeTolerance:ZGC触发自适应算法的修正系数,默认2,数值越大,越早的触发ZGC。示例中是4。
- ZProactive:是否启用主动回收,默认开启。 -XX:-ZProactive代表关闭
9 调优案例
9.1 服务改为ZGC后,STW时间增加
背景:
- 服务将GC从G1改为ZGC后,晚高峰STW停顿时间上升。
分析:
- 观察GC日志,主要是初始标记和初始转移耗时高导致。
- 该服务内有大量永久生存的对象,导致从根节点扫描和转移耗时较久。
解决方法:
- 使用堆外内存的方式将对象从堆中移出。
9.2 流量突增,服务出现阻塞分配的情况
背景:
- 服务将GC从G1改为ZGC,在应对突发流量时,停顿时间过长,GC日志出现大量“Allocation Stall”。
分析:
- 出现突发流量,内存分配速度高于回收速度。
- 在出现”Allocation Stall“之前,两次GC的间隔时间基本为0.
- 尝试增大GC的线程数,发现无明显效果。
- 将内存增大30%,“Allocation Stall”消失,两次GC间隔时间变长。
解决方法:
- 增大内存,增大两次GC之间的间隔,保证绝大部分对象在GC开始时已处于可回收状态。
9.3 服务内存分配速率不均,导致服务性能出现毛刺
背景:
- 服务将GC从G1改为ZGC后,STW耗时下降,但是观察可用性监控,出现可用性下降的毛刺。
分析:
- 查看GC日志,出现较多“Page Cache Flush”。
- 比较GC日志和服务日志,定位到问题代码。
- 该代码定时获取并解析来自Web API的json字符串。在解析前,会申请一个2MB的空间用于存放字符串。
解决方案:
- 定位到内存分配不均的代码,将原有解析代码改为流式解析。
10 总结
ZGC垃圾回收过程几乎全部是并发,实际STW停顿时间极短,不到10ms。
对于低延迟高可用的Java服务,ZGC可用满足其极低的暂停需求。