前言
经常在跟群友聊天, 聊到jvm调优, 群友都会说, 我们用zgc吧, 那样就不用那么麻烦去调优了
我们用 .net 吧, .net 就没有所谓的虚拟机调优问题了
那么今天的问题就是 zgc 真的就不需要调优了吗?
答: 特殊场景下需要
挖坑, .net 为什么没有 jvm 调优?
本章内容
- zgc 是什么?
- 三色标记法
- zgc 的使用场景是什么?
- 其他垃圾收集器的缺点
- zgc 怎么工作的?
- zgc 目前存在的问题
注意: 本文大多数知识点来自网络, 有错请留言, 我们一起讨论
zgc 是什么?
Z Garbage Collector是一款 低延迟 垃圾收集器
测试版本在 jdk 11, 但是目前来说, zgc 还是需要手动开启的
三色标记法
三色标记清除算法, 非常简单
类似于黑色墨水滴入白色的水中, 会产生水波
-
它的波峰是灰色
-
波经过的节点为黑色
-
波未经过的节点是白色
只要节点是灰色或者黑色, 那么就意味着 "水波" 可达, 否则 "水波" 未达或者不可达
在一次gc完毕之后, 还是白色的节点将进入 finalize 线程, 进行最后的拯救也就是调用 finalize 函数(有点像cpp的析构函数, 但又不是)
如果在该函数中该节点的引用被再次挂载到 gc roots 那么该节点将不会被清除
否则节点将在该函数执行完毕后清除
注意: finalize函数只能被调用一次, 并且效率非常低, 除非特殊情况最好别用
zgc 的使用场景是什么?
就如前面说的一样, zgc 的使用场景在于低延迟停顿, 然后又不会出现极端大量 new 对象的情况
其他垃圾回收器的缺点
cms缺点
cms使用标记清除算法, 在内存碎片特别严重以至于无法分配内存的情况下, 将会做一次内存整理
他的工作原理是
- 标记 gc roots(stop the world)
- 并发扫描堆
- 重新扫描新增和改变的节点(这里会存在一个记录项, 记录在并发扫描堆的时候, 对象引用的变化状态和新对象加入gc toots)
[1](stop the world) - 并发删除节点
[1]为什么要记录?原因很简单, 如果对象的索引改变了, 可能该对象原本是可达 ,现在不可达, 这次 gc , 无法被清除掉, 这个问题其实不大, 下次扫描就行了
最重要的原因在于新创建的对象将被认为是垃圾对象, 因为没有被标记到, 这点特别严重
为了解决这个问题, 设计了两套方案, 这里就不引入了, 说下名字:
增量更新和原始快照, 说白了就是有变化的节点标记为"灰色"
它的优点非常明显, 就是标记清除算法的优点, stw 时间对于之前的 serial old垃圾收集器来说较低
另一个优点就是他的名字: concurrent mark sweep, 并发
但是缺点非常明显. 三点:
- 内存碎片化严重, 多次cms之后的那次内存整理可以回家过个节回来
- 和java线程争夺cpu计算资源
- 无法处理浮动垃圾
什么是浮动垃圾?
就是在 cms gc过程中产生了新的垃圾垃圾, 特别是在并发删除节点阶段
不好理解, 为什么浮动垃圾就是cms的缺点了?
讲个事情: jdk5 版本在 java 堆 的 百分之68 被占用后, 就会执行 cms!!!
在 jdk6 之后该数字变成 92%
为什么?
因为如果预留的阈值为百分之99%的话, 浮动垃圾和新增加的对象可能导致 oom 错误
浮动垃圾的问题在 zgc 上也存在
G1缺点
让 G1 垃圾收集器 管理你的堆, 你将会看到你的操场被它规划为 很多区域(region[4]) , 比如篮球场, 排球场, 足球场, 森林和跑道等(新生代的survivor区, eden区和老年代等)
他会根据不同的区域使用不同的套餐
比如
-
篮球场(老年代) 那么他就会带着扫把去打扫(
混合gcor特殊方式orfull gc)[2] -
森林(新生代) 他会带着袋子和夹子去夹垃圾(
young gcor混合gc)
[2]: 特殊方式? 其实我也不知道它有没有专业的名字, 但是我知道它的方式并不是 full gc, 而是通过计算回收效率选定几个region进行gc
[4]: 用什么方式定义region的大小?g1 使用两个指针, 分别表示region的开始位置和结束位置, 换句话说, 一个region就有两个指针(Top at Mark Start)来做界定
G1 怎么清除垃圾的?
- 初始标记
- 并发标记
- 最终标记(重新标记)
- 筛选回收(根据global current marking扫描得出来的数据选择回收效率最高的几个region进行
标记-复制算法)[3] - 重定位阶段,因为转移导致对象的地址发生了变化,在重定位阶段,所有指向对象旧地址的指针都要调整到对象新的地址上
[3]: 这里是将 old region 中的对象复制到 new region 中, 最后清除掉 old region
注意看图中的
safepoint
根据这个过程, 你会发现, 除了初始标记, 最终标记和筛选回收需要stw外, 其他不走都可以并发标记
其中初始标记只标记 gc roots, gc roots少, 那么效率高
并发标记 标记整个堆, 慢, 但是并发
最终标记只标记有变化的位置, 变化少
筛选回收(stop the world)只有复制几个region的对象到新的region, 效率应该高吧?!!!
但, g1 主要的时间都是花费在复制
而且还是 STW 占用时间还不低, 几百ms 吧
跟前面的新生代大多数对象朝生夕死不同, 在g1中如果即将回收的
region被g1标记为老年代, 那么大部分对象未必朝生夕死, 所以根据标记-复制算法的缺点看, 如果存活的对象太多, 那么复制幸存对象的时间就会很长
这是 g1 的缺点之一
g1 还有一个缺点根据 标记复制算法的缺点, 占用内存空间会被 cms 大, 大10%~20%
因为它需要多余的空闲的region空间接受复制过来的对象
如果空闲的region不足甚至还会 STW , 然后调用 serial old 垃圾收集器堆整个堆的内存进行回收和整理, 那效率慢的可怕
其次是卡表问题, g1 中每一个region都需要维护一个叫卡表的存在, 占用内存
卡表是记忆集的实现, 主要目的是为了解决夸代引用问题, 比如老年代的对象 突然引用了 新生代的对象, 那么这个新生代对象就不能这么简单被清除了, 甚至和需要和老年代对象同生共死
在CMS垃圾收集器阶段, 我们的卡表只有一个, 但是在 G1 阶段 卡表是一个region一个
这些卡表中还被划分出多个 卡页 , 这些卡页才是存放这些夸代引用对象的地方
卡表的出现还带来的另一个问题, 缓存行伪共享问题
所以有了卡表和卡页还不够, 还需要写屏障, 也就是 "=" 进行特性处理, 更新你的卡表
就单单一个 CMS 就知道很麻烦, 而 G1 里有一堆
所以 G1 对 CPU 的计算资源负担肯定很高
为什么G1需要这么多卡表?
在g1中, 大量对象隔着大量的 region 进行跨代引用问题, 其复杂度可想而知啊
总结下G1的缺点
- 占用内存更高
- 对cpu的负载更高
- 如果幸存的对象较多, G1复制(STW)的时间也较长
zgc 怎么工作的?
zgc有一个独特的功能, 那就是 染色指针
染色指针的特点就是一种将信息存储在指针中的技术。
ZGC仅支持64位系统,它把64位虚拟地址空间划分为多个子空间(也就是所谓的多重映射技术)
如果你的系统是4GB内存, 那么默认每个进程的内存空间就是4GB, 其中1GB给系统内核使用, 剩下的3GB都交给进程, 而这里的 4GB 和物理内存中的 4gb 内存是不一样, 他是虚拟内存, 现在我们的系统都是十几GB, 但是我们的64位操作系统却支持TB级别的内存, 那么多余出来的地址可以被用在多重映射, 也就是说, 多个虚拟地址映射一个物理地址, 所以上面的 M0 M0 和 remapped 都和物理内存映射
这样你就可以修改 M0 M1 Remapped 这些标志位, 因为都在虚拟地址上
ZGC中也有region这个概念, 只不过它将按照对象的大小, 定义了集中不同的region
- 对象0~256k, 使用小型Region(Small Region), 大小是 2MB
- 对象256kb~2mb: 使用中型Region(Medium Region)容量固定为32MB
- 对象大于2mb: 使用大型Region(Large Region)容量不固定,可以动态变化,但必须为2MB的整数倍,用于放置4MB或以上的大对象。每个大型Region中只会存放一个大对象,这也预示着虽然名字叫作“大型Region”,但它的实际容量完全有可能小于中型Region,最小容量可低至4MB。大型Region在ZGC的实现中是不会被重分配(重分配是ZGC的一种处理动作,用于复制对象的收集器阶段,稍后会介绍到)的,因为复制一个大对象的代价非常高昂。
接下来就是 ZGC 的执行流程了
非常简单,
- 标记
- 再标记
- 移动(重分配)
- 重映射
[5]
这四个步骤都是并发执行的
[5]: 并发重映射重映射所做的就是修正整个堆中指向重分配集中旧对象的所有引用, 但是ZGC的并发重映射并不 是一个必须要“迫切”去完成的任务,因为前面说过,即使是旧引用,它也是可以自愈的,最多只是第一次使用时多一次转发和修正操作。重映射清理这些旧引用的主要目的是为了不变慢(还有清理结束后可以释放转发表这样的附带收益),所以说这并不是很“迫切”。因此,ZGC很巧妙地把并发重映射阶段要做的工作,合并到了下一次垃圾收集循环中的并发标记阶段里去完成,反正它们都是要遍历所有对象的,这样合并就节省了一次遍历对象图的开销。一旦所有指针都被修正之后,原来记录新旧对象关系的转发表就可以释放掉了。
只有在标记阶段, 需要表示gc roots 根节点和重标记变化的节点的使用会进行一次 STW, 但是 gc roots 和 重新标记这两个阶段的节点通常都比较少, 所以 STW 的时间才会说是 10ms
在jdk16好像, 开始 从 10ms 变成 0.1 ms, 也就是 stop the world的时间
注意, 这里的 10ms 是 stop the world的时间, 但是 zgc 在执行整个垃圾回收的过程的时间可能是几分钟甚至10分钟, 你可以认为是从CMS的一次性清空, 到G1的一块一块清空, 到现在的一点一点清空, 只不过是持续的GC从短到长的变化
不过中间使用一项技术, 叫指针自愈技术, 说白了, 在from region 复制幸存对象到 to region 的时候, 会有一个转发表(Forward Table)
这个转发表, 主要的目的是记录这种移动信息
在java线程找到引用, 会被读屏障拦截, 发现 remapped 标志位为重分配状态, 就会去查转发表, 然后立即根据Region上的转发表记录将访问转发到新复制的对象上,并同时修正更新该引用的值,使其直接指向新对象,ZGC将这种行为称为指针的“自愈”(Self-Healing)能力, 这样做的好处是只有第一次访问旧对象会陷入转发,也就是只慢一次
zgc 目前存在的问题
ZGC没有分代
对象分代的好处在于可以划分出不同的区域
然后按照不同的区域配置相应的垃圾回收器,也就是相应的垃圾回收算法
通过分代算法可以过滤到大部分朝生夕死的对象,并将长期存活的对象存放在老年代
其次,分类算法后,垃圾收集器不再是直接针对整个堆进行回收,而是针对新生代进行回收回收,那么它的时间会较短, 他的缺点就是频率会变高,但是这是值得付出的代价
ZGC,由于时间上的问题,并没有加上分代算法
这样会产生一些问题,比如说这zgc只要执行一次gc,就是针对整个堆的垃圾回收, 虽然这zgc使用了g1一样的region
但它和g1不同的地方在于它会对整个堆积进行垃圾回收,而g1只回收几个区域
所以单次垃圾回收的时间会较长,而且这样会产生巨多的浮动垃圾
所以在特殊环境下,比如说短时间内或者是高并发特别严重产生大量对象的情况下,这zgc可能并不是最好的选择
因为垃圾收集回收的内存效率上可能不比高并发环境下产生大量对象的速度快
同样的zgc也需要较大的堆空间, 让他能够追赶上对象产生的速度
针对zgc的调优一般也是针对这个角度进行调优的,一般是可以定义zgc西每次执行的频率,比如说是5秒一次,然后如果垃圾回收器的效率比较慢的话,可以适量提高垃圾回收器的线程数量
zgc相关参数
-Xms10G -Xmx10G
-XX:ReservedCodeCacheSize=256m -XX:InitialCodeCacheSize=256m
-XX:+UnlockExperimentalVMOptions -XX:+UseZGC
-XX:ConcGCThreads=2 -XX:ParallelGCThreads=6
-XX:ZCollectionInterval=120 -XX:ZAllocationSpikeTolerance=5
-XX:+UnlockDiagnosticVMOptions -XX:-ZProactive
-Xlog:safepoint,classhisto*=trace,age*,gc*=info:file=/opt/logs/logs/gc-%t.log:time,tid,tags:filecount=5,filesize=50m
-
-Xms-Xmx:堆的最大内存和最小内存,这里都设置为10G,程序的堆内存将保持10G不变。 -
-XX:ReservedCodeCacheSize-XX:InitialCodeCacheSize:设置CodeCache的大小,JIT编译的代码都放在CodeCache中,一般服务64m或128m就已经足够。我们的服务因为有一定特殊性,所以设置的较大,后面会详细介绍。 -
-XX:+UnlockExperimentalVMOptions-XX:+UseZGC:启用ZGC的配置。 -
-XX:ConcGCThreads:并发回收垃圾的线程。默认是总核数的12.5%,8核CPU默认是1。调大后GC变快,但会占用程序运行时的CPU资源,吞吐会受到影响。 -
-XX:ParallelGCThreads:STW阶段使用线程数,默认是总核数的60%。 -
-XX:ZCollectionInterval:ZGC发生的最小时间间隔,单位秒。 -
-XX:ZAllocationSpikeTolerance:ZGC触发自适应算法的修正系数,默认2,数值越大,越早的触发ZGC。 -
-XX:+UnlockDiagnosticVMOptions-XX:-ZProactive:是否启用主动回收,默认开启,这里的配置表示关闭。 -
-Xlog:设置GC日志中的内容、格式、位置以及每个日志的大小。
相关参考: Main - Main - OpenJDK Wiki
更多参数:
- -XX:MinHeapSize, -Xms:设置JVM的最小堆大小。这是堆的初始大小,JVM在启动时会分配这么多的堆空间。
- -XX:InitialHeapSize, -Xms:设置JVM的初始堆大小。与MinHeapSize相同,这是堆的初始大小。
- -XX:MaxHeapSize, -Xmx:设置JVM的最大堆大小。这是堆能够增长到的最大大小。
- -XX:SoftMaxHeapSize:设置JVM的软上限堆大小。当内存不足时,JVM会尝试回收垃圾以使堆的使用保持在软上限以下。
- -XX:ConcGCThreads:设置并发垃圾回收的线程数。
- -XX:ParallelGCThreads:设置并行垃圾回收的线程数。
- -XX:UseDynamicNumberOfGCThreads:启用动态确定垃圾回收线程数的机制。
- -XX:UseLargePages:启用使用大页面(Large Pages)来提高性能和减少内存开销。
- -XX:UseTransparentHugePages:启用透明巨页(Transparent Huge Pages)来提高性能和减少内存开销。
- -XX:UseNUMA:启用使用非一致性内存访问(NUMA)来提高性能。
- -XX:SoftRefLRUPolicyMSPerMB:设置软引用LRU策略的时间限制。
- -XX:AllocateHeapAt:在指定地址处分配堆内存。
- -XX:ZAllocationSpikeTolerance:ZGC对内存分配波动的容忍度。
- -XX:ZCollectionInterval:设置ZGC执行垃圾回收的时间间隔。
- -XX:ZFragmentationLimit:设置ZGC堆碎片化的限制。
- -XX:ZMarkStackSpaceLimit:设置ZGC标记阶段的栈空间大小限制。
- -XX:ZProactive:启用ZGC的主动模式,用于更早地触发垃圾回收。
- -XX:ZUncommit:启用ZGC的内存释放机制,用于释放不再使用的内存。
- -XX:ZUncommitDelay:设置ZGC内存释放的延迟时间。
- -XX:ZStatisticsInterval:设置ZGC统计信息输出的时间间隔。
- -XX:ZVerifyForwarding:启用ZGC的转发对象验证。
- -XX:ZVerifyMarking:启用ZGC的标记过程验证。
- -XX:ZVerifyObjects:启用ZGC的对象验证。
- -XX:ZVerifyRoots:启用ZGC的根节点验证。
- -XX:ZVerifyViews:启用ZGC的对象视图验证。