前言
在上一篇文章中,讲了各种垃圾收集器以及他们的优缺点,由于CMS低停顿的重要特性所以把重心放在了CMS,同时在文章的后半部分讲了支撑垃圾收集器的利器-三色标记算法,以及实现的逻辑
由于篇幅的问题,还剩下G1和ZGC这两种垃圾收集器没有讲,那么这篇文章就来讲讲G1和ZGC
G1收集器 -XX:+UseG1GC
G1 (Garbage First) 是一款面向服务器的垃圾收集器,主要针对配置多核处理器以及大容量内存的机器,以极高概率满足GC停顿时间要求的同时,还具备高吞吐量性能特征,JDK 9开始默认使用G1 垃圾收集器
聊聊G1的内存划分
我们都知道,垃圾收集算法方法论是基于分代收集的思想,垃圾收集器又是垃圾收集算法方法论的具体实现,而其他的垃圾收集器也都是这么实现的,把堆划分成年轻代、老年代,但是G1在物理方面却已经脱离了分代的概念,虽然底层逻辑还是借用了分代的思想,既然脱离了分代的概念,我们就来看下它底层内存的划分情况到底是什么样的:
和上图所画的那样:G1将一整块堆划分成多个大小相等的独立区域Region,JVM最多可以有2048个Region默认也是2048个。
这些独立的Region既有Eden区、又有Survivor区、又有Old区、又有Humongous区(巨型对象区),也就是说G1保留了年轻代和老年代的概念,但不再是物理隔阂了,它们都是可以不连续的Region的集合。
一般Region大小等于堆大小除以2048,比如堆大小是4096M,则Region的大小是2M,当然可以通过JVM命令 -XX:G1HeapRegionSize 调整Region大小,但是推荐默认的大小调整
年轻代和老年代的内存划分
年轻代对堆内存的占比是5%,如果堆大小是4096M,那么年轻代占据约200MB左右的内存,对应的Region区域个数就是100个
可以通过 -XX:G1NewSizePercent设置年轻代初始占比,在系统的运行过程中,JVM会不断的给年轻代增加更多的Region,但是不能超过60%,可以通过 -XX:G1MaxNewSizePercent进行调整。
年轻代中的Eden区和Survivor区对应的region也跟之前一样,默认8:1:1,假设年轻代现在有100个Region,那么Eden区就是80个,survivor0就是10个,survivor1就是10个
高能预警
一个Region可能之前是年轻代,如果Region进行了垃圾回收,之后可能又会变成老年代,也就是说Region的区域功能可能会动态变化,所以说在G1的世界里,它只认识Region,但是逻辑上还是有新生代和老年代
Humongous区
G1垃圾收集器对于对象什么时候会移动到老年代和之前讲的套路一样,唯一不同的就是对于大对象的处理,G1有专门分配大对象的Humongous区,而不是让大对象进入老年代的Region,在G1中,如果一个对象超过了Region区的50%大小,那么就被判定成大对象,比如上面说的,如果Region区是2M的大小,如果一个对象超过了1M,那么就放入Region区,而且一个大对象如果太大,那么会横跨多个Region来存放
Humongous区专门用来存放大对象,不用直接进入老年代,可以节约老年代的空间,Full GC的时候除了收集年轻代和老年代之外,也会将Humongous区一并回收
聊聊G1垃圾收集的过程
G1垃圾收集器一次GC的运作过程大致上可以分为以下几个步骤:
-
初始标记: 暂停其他的所有线程STW,并记录下GC Roots直接引用的对象,这步和CMS一样,速度很快
-
并发标记: 同CMS的并发标记
-
最终标记: 同CMS的重新标记,也会STW
-
筛选回收: 筛选回收阶段首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿STW时间来制定回收计划,可以用 -XX:MaxGCPauseMillis来指定时间,比如说老年代有1000个Region都满了,但是因为预期停顿时间只有200ms默认是200ms,那么通过对各个Region的回收价值和成本计算可知,可能回收其中的800个需要200ms,那么就只会回收其中的800个Collection Set, 要回收的集合,尽量把GC的停顿时间控制在我们指定的范围内。这个阶段其实也可以做到和用户线程一起并发执行,但是因为只回收一部分Region,时间是用户可控制的,而且停顿用户线程时间将大幅提高收集效率 (因为GC线程占用的CPU时间片多了),不管是年轻代还是老年代,回收算法主要用的还是复制算法,将一个Region中的存活对象复制到另一个Region中,这种不会像CMS那样回收完因为有很多内存碎片还需要整理一次,G1采用复制算法回收几乎不会有太多内存碎片
CMS回收阶段是跟用户线程一起并发执行的,但是G1因为内部实现太过复杂,所以暂时没有实现并发回收,到了ZGC,Shenandoah就实现了并发收集Shenandoah可以看作是G1的升级版
总体上来说,G1用的算法有点类似于标记-整理,因为产生很少的内存碎片,和标记-整理达到的效果是一样的,但是它底层还是用的标记-复制算法。
G1的特点
G1的主要特点可以分为以下四点:
-
并行与并发: G1能充分利用CPU,多核环境下的硬件优势,使用多个CPU来缩短STW时间,部分其他收集器原本需要停顿Java线程来执行GC动作,G1收集器仍然可以通过并发的方式让Java线程继续执行
-
分代收集: 虽然G1可以不需要其他收集器配合就能独立管理整个堆,但还是保留了分代的概念
-
空间整合: 和CMS的标记-清除算法不同,G1从整体上看是标记-整理算法,但是从局部上来看是基于标记-复制的算法来实现的
-
可预测的停顿: 这是G1相对于CMS的另一个大优势,降低停顿时间是G1和CMS的共同关注点,但是G1在追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为毫秒的时间片段 通过-XX:MaxGCPauseMillisk来指定内完成垃圾收集
毫无疑问,可预测的停顿是G1收集器最强大的一个特点,设置不同的期望停顿时间,可使得G1在不同应用场景中取得关注吞吐量和关注延迟之间的最佳平衡。
G1会根据回收价值和成本进行排序,同时把这个排序结果维护成一个优先级列表,每次根据允许的收集时间,优先选择回收价值最大的Region
,比如一个Region花200ms能回收10M的垃圾,另外一个Region花50ms能回收20M的垃圾,那么在回收时间有限的情况下,优先选择后面那个Region。
这种使用Region划分内存空间以及有优先级的区域回收方式,保证了G1收集器在有限的时间内可以尽可能的提高收集效率
计算回收价值和成本的大概思路: 因为底层使用的是标记-复制算法,所以存活的对象越多,复制需要花费的时间就越长,存活的对象越短,复制需要花费的时间就越短,越有回收价值
从G1开始,最先进的垃圾收集器的设计导向都不约而同地变为追求能够应付应用的内存分配速率,而不追求一次把整个Java堆全部清理干净
。这样应用在分配内存的同时在收集,只要收集的速度能跟得上对象分配的速度,那一切就能运作得很完美。
这种新的设计思路从工程实现上看是从G1开始兴起的,所以说G1是收集器技术发展的一个里程碑。
高能预警
虽然G1能设置用户期望的停顿时间,但是这个时间是合理的,不能为了停顿时间,过度的压缩,毕竟G1是要冻结用户线程,也就是STW来复制对象的,这个停顿时间再怎么低,也要有个限度,G1默认的停顿时间是200MS,一般来说,回收阶段在几百毫秒内是很正常的,但是一旦把GC的停顿时间设置的很短,假设是20ms,很有可能出现的结果是:
由于停顿时间太短,导致每次选出来的回收集只占堆内存很小的一部分,收集器收集的速度还赶不上分配器分配的速度,导致垃圾慢慢变多,很有可能一开始收集器还能从空闲的Region区获得一些喘息的时间,但应用运行时间异常就不行了,最终占满堆引发Full GC反而降低性能,所以通常把期望值设置成100ms-300ms是比较合理的区间
G1里的GC方式
YoungGC
YoungGC 并不是说现有的Eden区放满了就会马上触发,G1会计算下现在Eden区回收预计需要多少,如果回收时间远远小于预期停顿时间 -XX:MaxGCPauseMillis指定,那么不会立刻触发Young GC,而是会增加年轻代的Region,继续给新对象存放,一直到下一次Eden区放满,G1计算回收Eden区花费的时间和指定的时间相近,那么会执行YoungGC
举个例子:默认情况下年轻代占整个堆内存的5%,设定的预期停顿时间默认是200ms,当Eden区满了,G1会计算Eden区如果回收完需要花费多少时间,假设是50ms,远远小于200ms,那么G1不会立刻触发YoungGC,而是会增加年轻代的Region,比如增加个100个给新对象继续存放,然后等到这100个被塞满,再次执行YoungGC会再次判断预计花费时间,假设这时候已经接近200ms,那么G1会执行YoungGC
MixedGC
不是Full GC,老年代的堆占有率超过可用参数-XX:InitiatingHeapOccupancyPercent设定的值触发MixedGC,回收所有的Young和部分Old根据期望的暂停时间确定old区的优先级顺序,以及大对象区,正常情况下,G1的垃圾收集是先做MixedGC,主要使用复制算法,需要把各个Region中存活的对象,拷贝到别的Region里去,拷贝过程中如果发现没有足够的空region去承载拷贝对象,那么就会发生一次Full GC,过程就如下图所示:
Full GC
停止系统程序,然后采用单线程进行标记、清理和压缩整理,有点类似于Serial Old,空闲出来的一批Region来供下一次Mixed GC来使用,这个过程是非常耗时的Shenandoah优化成多线程收集了
G1的参数设置
-XX:+UseG1GC
:使用G1收集器-XX:ParallelGCThreads
:指定GC工作的线程数量-XX:G1HeapRegionSize
:指定分区大小(1MB-32MB,必须是2的次数幂),默认将整堆划分成2048个分区-XX:MaxGCPauseMillis
:目标暂停时间(默认200ms)-XX:G1NewSizePercent
:新生代内存初始空间(默认整堆的5%,值配置整数,默认就是百分比)-XX:G1MaxNewSizePercent
:新生代内存最大空间-XX:TargetSurvivorRatio
:Survivor区的填充内容(默认50%),Survivor区域里的一批对象(年龄1+年龄2+年龄3......)总和超过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%,此时就会停止MixedGC,意味着这次混合回收结束了
G1的调优建议
所以对G1调优来说,就是尽量避免产生Full GC,对停顿的时间做合理的设置
假设参数 -XX:MaxGCPauseMills设置的值很大,导致系统运行了很久,年轻代可能都占用了堆内存的60%了,此时才出发年轻代的GC,那么此时经过年轻代的GC后存活下来的对象就会有很多,导致survivor区放不下那么多的对象,就会进入到老年代中
所以核心还是在调节 -XX:MaxGCPauseMills这个参数上,在保证它的年轻代的GC不要太频繁的同时,还得考虑每次GC过后存活的对象有多少,避免存活对象过多,过早进入老年代,频繁触发MixedGC
G1的使用场景
- 50%以上的堆被存活对象占用
- 对象分配和晋升的速度变化非常大
- 垃圾回收时间特别长,超过1s
- 8GB以上的堆内存 (建议值)
- 停顿时间是500ms以内
高并发系统怎么使用G1
Kafka类似的支撑高并发系统大家肯定或多或少都了解过,对于Kafka来说,每秒处理几万甚至是几十万的消息都是很正常的,所以一般来说部署Kafka需要用大内存机器比如64G,也就是说可以给年轻代分配30G-40G用来支撑高并发
这就涉及到了一个问题,我们通常说Young GC的执行时间很短,但是如果在这么大内存的Young GC也是需要不少时间的,假设30G-40G的新生代需要回收几秒中,一条消息是1KB,那么放满整个新生代只需要1-2分钟,意味着每运行1-2分钟,系统就会因为YoungGC而停顿几秒,没法处理消息,这显然是不行,那么我们怎么用G1来优化呢?
我们可以使用 -XX:MaxGCPauseMills为50ms,假设50ms能回收3-4个G的内存,然后50ms的卡顿完全能接受,用户几乎没有感知,那么系统在用户没有感知的情况下,一边回收垃圾,一边执行业务线程
G1对于漏标场景的考虑
和CMS相同,由于在并发标记阶段,没有执行STW,所以也同样会产生漏标的场景,但是G1对于漏标的场景和CMS不同,G1是采用SATB的方式解决漏标场景,SATB相对增量更新效率会高,因为不需要在重新标记阶段再次深度扫描被删除的引用对象,而CMS对增量引用的根对象会做深度扫描,G1因为很多对象都位于不同的Region,CMS就一块老年代区域,重新深度扫描的成本G1会比CMS大很多很多跨代引用的存在,不同的Region之间都算跨代,所以G1选择SATB而不做深度扫描,只是简单标记,等到下一轮GC再深度扫描
当然SATB可能产生更多的浮动垃圾,但是G1就是为了提高用户体验而允许浮动垃圾的存在
ZGC垃圾收集器-Z Garbage Collector
ZGC是jdk 11中新加入的一种垃圾收集器,但是目前市面上大多用的还是CMS和G1,从官方文档上说,ZGC也还正处于试验阶段,所以目前的大厂可能会采取试点的方式使用,但是绝大部分还是使用CMS和G1,所以我们把重点还是放在这两个垃圾收集器
但是在官网说的ZGC的目标中,有未来基石的这么一个目标,就是说以后如果有新的垃圾收集器,是在ZGC的设计理念基础上进行设计,所以接下来我们来介绍下ZGC
JVM命令
-XX:+UseZGC
参考文章
ZGC的目标
ZGC的目标总体上可以分为四大块:
- 支持TB量级的堆:盲猜这波应该可以满足未来十年的JAVA应用的需求
- 最大GC停顿时间不超过10ms:目前一般线上环境运行良好的JAVA应用的Minor GC一般在10ms以内,Full GC/Major GC/Mixed GC一般都需要100ms以上,G1可调节,但是如果过于少的话,反而会适得其反,之所以能做到这一点,是因为它的停顿时间主要和GC Roots扫描有关,而Root数量和堆大小是没有任何关系的
- 奠定未来GC垃圾收集器特性的基石
- 最糟糕的情况下吞吐量会降低15%:这种情况可以通过扩容解决
另外,ZGC还有一个最大的优点:它的停顿时间不会随着堆的增大而增大!!!,也就是说几十G堆的停顿时间是10ms以下,几百G甚至上T的堆的停顿时间也是在10ms以下
ZGC的不分代
单代,即ZGC没有分代,以前的垃圾回收之所以分代收集是源于大部分对象朝生夕死,生命周期不一致的假设,事实上,从大部分系统的运行结果来看,也符合这个假设,那为什么ZGC不分代呢?
因为分代实现麻烦呀,就先迭代出一个不分代的版本,先用起来,后续会优化
ZGC的内存布局
ZGC收集器是一款基于Region的内存布局,暂时不分代,使用了读屏障、颜色指针等技术来实现可并发的标记-整理算法。
ZGC的Region可以分为大、中、小三类容量,如下图所示:
- 小型Region: 容量固定为2MB,用于放置小于256KB的小对象
- 中型Region: 容量固定为32MB,用于放置大于等于256KB但小于4MB的对象
- 大型Region: 容量不固定可以动态变化,但必须为2MB的整数倍,用于放置大于等于4MB的对象
每个大型Region中值只会存放一个大对象,所以它的实际容量可能会小于中型Region,最小可至4MB。大型Region在ZGC的实现中是不会被重分配的-----重分配是ZGC的一种处理动作,用于复制对象阶段,因为复制一个大对象的代价会非常昂贵
ZGC的内存架构--NUMA
NUMA对应着的就是UMA,UMAUniform Access Architecture,NUMA就是Non Uniform Access Architecture
UMA表示内存只有一块,所有CPU都去访问这一块内存,那么就会存在竞争问题争夺内存总线访问权,有竞争就会有锁,有锁效率就会低,CPU内核越多,竞争越激烈,效率就越低
所以对应的NUMA就提出另外一种内存架构,NUMA表示每个CPU对应有一块内存,且这块内存在主板上离这个CPU是最近的,每个CPU都优先访问各自最近的内存块,效率自然就高了
服务器的NUMA架构在中大型系统上一直非常盛行。也是高性能的解决方案,尤其在系统的延迟方面表现都非常优秀。ZGC是能自动感知NUMA架构并充分利用NUMA架构特性。
ZGC运行过程
ZGC的运作过程大致可分为四个阶段初始标记-Pause Mark Start、最终标记-Pause Mark End、初始转移,这三个阶段会STW,由于比较简单,这里就先不讲了:
-
并发标记Concurrent Mark:与G1一样,并发标记是遍历对象图做可达性分析阶段,但是他把初始标记和最终标记合并在一起了,它的初始标记Pause Mark Start和最终标记Pause Mark End也会出现短暂的停顿,但是和G1不同的是,ZGC的标记是在指针上,而不是在对象上,标记阶段会更新颜色指针中的Mark 0、Mark 1标志位
-
并发预备重分配Concurrent Prepare for Relocate:这个阶段需要根据特定的查询条件统计得出本次收集过程需要清理哪些Region,将这些Region组成重分配集Relocation Set。ZGC每次回收都会扫描所有的Region,用范围更大的扫描成本换取省去G1中记忆集的维护成本
-
并发重分配Concurrent Relocate:重分配是ZGC执行过程中的核心阶段,这个过程要把重分配集中的存活对象复制到新的Region上,并为重分配集中的每个Region维护一个转发表 Forward Table,记录从旧对象到新对象的转向关系。ZGC的收集器能仅从一个引用上就明确得知一个对象是否处在重分配集中,如果用户线程此时并发访问了位于重分配集中的对象,这次访问将会被预置的内存屏障读屏障所截获,然后立即根据Region上的转发表记录将访问转发到新复制的对象上,并同时修正更新该引用的值,使其指向新对象,ZGC将这种行为称之为指针的自愈能力
-
并发重映射Concurrent Remap:重映射所做的就是修正整个堆中指向重分配集中旧对象的所有引用,但是ZGC中对象引用存在自愈功能,所以这个重映射操作并不是很迫切。ZGC很巧妙的把并发重映射阶段要做的工作,合并到了下一次垃圾收集循环的并发标记阶段里去完成,反正它们都需要遍历所有对象,这样合并就节省了一次遍历对象图的开销。一旦所有的对象指针都被修正之后,原来记录新旧对象关系的转发表就可以被释放掉了
指针因为自愈机制存在,只有第一次访问旧对象会慢,一旦重分配集中
某个Region的存活对象都复制完毕后,这个Region就可以立即释放,用于新对象的分配,但是转发表还得留着,不能释放掉,因为可能还有访问在使用这个转发表
其实要理解ZGC运行过程的话,要和G1的筛选回收和CMS的并发收集对比的去理解,ZGC是怎么做到不STW的,为了做到不STW,为什么要引入重分配集和转发表
颜色指针
我们知道在并发标记阶段,CMS中的三色标记是标记在对象上的,来标志对象的引用状态,准确点来说是标记在对象头中,如下图所示:
但是ZGC把对象的标识标在了指针上,也就是说,如果是一个64位的指针,ZGC从里面抽出几位来做GC标记,这就是所谓的颜色指针,下面我们来好好盘盘颜色指针:
Colored Pointers:即颜色指针,如下图所示,ZGC的核心设计之一。以前的垃圾回收器的GC信息都保存在了对象头上,而ZGC的GC信息保存在了指针上中,如下图所示
我们市面上的机器大多都是64位的,所以以一个64位的指针为例jdk11的版本:
- 18位:预留给以后使用
- 1位:Finalizable标识,与并发引用处理有关,表示这个对象只能通过finalizer才能访问
- 1位:Remapped标识,设置此位的值后,对象未指向relocation set 重分配集,说明所在的Region无需回收,relocation set表示需要GC的Region集合
- 1位:Marked1标识,用于辅助GC
- 1位:Marked0标识,用于辅助GC
- 42位:对象的地址所以它可以支持2的42次=4T的内存
Marked0和Marked1组合至少可以标记四种类型,专门用来标记对象,假设 01代表黑色、10代表灰色,00代表白色
结合颜色指针来看看ZGC流程
-
一开始对象没有经历过GC,它的指针是被设置为
Remapped
,也就是说,它所在的Region不需要被回收 -
当发生GC,进入标记阶段时,需要把对象转变为Marked0或者Marked1,但是并发标记没有STW,假设这时候用户线线程创建的新对象被标记成Marked0,如下图所示
- 在标记阶段结束之后,对象的地址视图要么是M0,要么是Remapped。M0表示不是垃圾对象,Remapped表示垃圾对象,可以被回收,ZGC会把所有活跃对象的地址存到对象活跃信息表,活跃对象被标记成M0,如下图所示
- 等到了并发重分配阶段,把标记成M0的对象转移,然后把对象设置成Remapped,这时候创建的对象是被标记成Remapped,因为应用程序和转移线程也是并发执行,那么对象的访问可能来自转移线程和应用程序线程,如下图所示
- 至此,ZGC的一个垃圾回收周期中,并发标记和并发转移就结束了。
从流程来看,似乎只用到了一个标记,JVM是为了区别前一次标记和当前标记
,所以设计成两个。ZGC是按照Region进行部分内存垃圾回收的,也就是说当对象所在的Region需要回收时,Region里面的对象需要被转移,如果Region不需要转移,Region里面的对象也就不需要转移,但是对象还是被标记成M0,如下图所示
这个对象在第二次GC周期开始的时候,地址视图还是M0。如果第二次GC的标记阶段还标记成M0的话,就不能区分出对象是活跃的,还是上一次垃圾回收标记过的。
这个时候,第二次GC周期的标记阶段切到M1视图的话就可以区分了:
- M1:本次垃圾回收中识别的活跃对象。
- M0:前一次垃圾回收的标记阶段被标记过的活跃对象,对象在转移阶段未被转移,但是在本次垃圾回收中被识别为不活跃对象。
- Remapped:前一次垃圾回收的转移阶段发生转移的对象或者是被应用程序线程访问的对象,但是在本次垃圾回收中被识别为不活跃对象。
这就是为什么要用Marked0和Marked1两个标记的原因
高能预警
通过对ZGC的对象指针分析来看,对象指针必须是64位的,也就是说ZGC不支持32位的操作系统,同样的也不支持指针压缩压缩指针是32位
颜色指针的优点
-
一旦某个Region的存活对象被移走之后,这个Region的空间立即被释放和重用。而不必等待整个堆中所有指向该Region的引用都被修正过以后才能清理,这使得理论上只要还有一个空闲的Region,ZGC就能完成收集
-
颜色指针可以大幅减少在垃圾收集过程中内存屏障的使用数量,ZGC只使用了读屏障
-
颜色指针具备了强大的扩展性,它可以作为一种可扩展的存储结构来记录更多与对象标记、重定位过程相关的数据,以便日后进一步提高性能
读屏障
之前的GC都是采用Write Barrier写屏障来解决漏标场景,这次ZGC采用了完全不同的读屏障,这是ZGC的一大特色,在标记和移动阶段,每次读取堆里对象的指针的时候,都需要加上一个Load Barrier读屏障
我们来看官网上的一个例子:
-
第一行代码:读取堆中的一个对象引用obj.fieldA赋值给引用o (fieldA如果是一个对象,同时在并发重分配阶段,才会加上读屏障),判断如果这时候对象在GC时被移动了,JVM就会加上一个读屏障,这个屏障就是用来从转发表中读出新的地址赋值给o。所以,就算GC把对象移动了,读屏障也会修正指针,不需要STW,但是也会产生自旋现象,读取的值发现已经失效了,需要不断重新读取,直到读取成功
-
下面三行代码都不需要加上读屏障,因为不需要从堆中读取对象的指针
怎么判断对象在GC时被移动过
根据上面提到过的颜色指针,如果是Bad Color,那么还不能往下执行,需要slow path,修正指针,如果是Good Color,那么直接继续执行下去,有点类似于AOP
ZGC的劣势
读屏障带来的吞吐量降低
读屏障一定会降低ZGC的吞吐量,官方的测试数据是说需要多出4%的开销
浮动垃圾的上升
ZGC最大的问题还是在于浮动垃圾,ZGC的停顿时间是在10ms以内的,但是ZGC的执行时间还是远远大于这个时间。加入ZGC的全过程需要执行10分钟,那么在这个期间,由于对象的分配速率很高,将创建大量的对象,这些对象很难进入当次GC,所以只能在下次GC的时候才会回收,那么这些对象中有很大一部分是浮动垃圾
ZGC没有分代概念,每次都需要进行全栈扫描,导致一些生命周期很短的对象没能及时回收
解决方案
目前唯一的解决方案就是增大堆的容量,使得程序得到更多的喘息时间,但是这个也是一个治标不治本的方案,如果需要从根本上解决,还是需要引入分代收集,让新生代的对象都在一个专门的区域内创建,然后专门针对这个区域进行更加频繁、更加快的收集
ZGC的参数设置
启用ZGC比较简单,设置JVM参数即可:-XX:+UniockExperimentalVMOptions -XX:+UseZGC,调优也不难,因为ZGC调优参数并不多,远不像CMS那么复杂。它和G1一样,可以调优的参数非常少,大部分工作JVM都能很好的自动完成等以后JVM越来越智能,调优的参数会越来越少
ZGC的触发时机
ZGC目前有四种触发时机:
- 定时触发:默认不使用,可通过ZCollectionInterval参数配置
- 预热触发:最多三次,在堆内存达到10%、20%、30%时触发,主要统计GC时间,为其他GC机制使用
- 分配速率:基于正态分布统计,计算内存99.9%可能的最大分配速率,以及此速率下内存将要耗尽的时间点,在耗尽之前触发GC耗尽时间-一次GC最大持续时间-一次GC检测周期时间
- 主动触发:默认开启:可通过ZProactive参数配置,距上次GC堆内存增长10%,或超过5分钟时,对比距上次GC的间隔时间跟49 * 一次GC的最大持续时间,超过则触发
如何选择垃圾收集器
- 优先调整堆的大小让服务器自己来选择
- 如果内存小于100M,那么使用串行收集器
- 如果是单核,并且没有停顿时间要求,那么建议使用串行或让JVM自己来选择
- 如果允许停顿时间超过1s,选择并行或者JVM自己选择
- 如果响应时间最重要,并且不能超过1s,使用并发收集器
- 4G以下可以用ParNew,4G-8G建议用ParNew+CMS,8G以上可以用G1,几百G以上用ZGC
课外知识点-跨代引用的解决方案
跨代引用的场景
-
JVM在新生代执行Minor GC的时候,在做GC Roots可达性扫描的时候很有可能会发生跨代引用的场景,就是新生代的对象中依赖了老年代的对象,这种时候再去老年代中扫描,效率就会显的太低了
-
涉及部分区域收集partial gc行为的GC,例如:G1、ZGC和Shenandoah收集器,Region和Region之间,都会面临相同的问题
那么我们来看下JVM是怎么来解决跨代引用 的:
记忆集
JVM在新生代中引入记忆集 Remember Set的数据结构,用来记录从非收集区到收集区的指针集合,避免把整个老年代加入GC Roots的扫描范围
JVM只需要通过记忆集判断出某一块非收集区域是否存在指向收集区的指针即可,没有必要了解跨代引用指针的全部细节
记忆集的实现方式-卡表
HotSpot使用一种卡表 CarTable的方式实现记忆集,也是目前最常用的一种方式。卡表与记忆集就是相当于List 和 ArrayList的关系,记忆集用来定义规范,卡表用来具体实现
卡表是使用一个字节数组 CARD_TABLE[] 实现,每个元素对应着其标识的内存区域一块特定大小的内存块,称之为卡页,卡页的状态和起始内存地址都维护在卡表里,卡表是由字节数组来实现,一个元素就是一个字节,一个字节是8位,有足够的空间来存储起始内存地址和状态
HotSpot使用的卡页大小是2^9=512个字节,老年代按照512K的大小划分成一块块小的内存区域,每块内存区域对应着卡表中的一个元素
当给老年代对象的成员变量赋值时
,会判断如果这个新生代的成员变量是对象并且是新生代的,那么这个老年代对象所在的卡页就会被标记成1 意味着dirty
一个卡页中可包含多个对象,只要有1个对象的字段存在跨代指针,其对应的卡表的元素就会变成1,表示该元素是脏的,否则为0,在GC的时候,只要筛选本收集区的卡表中变脏的元素加入到GC Roots里
卡表的维护
卡表变脏上面已经说了,卡表变脏的时机,在HotSpot中是使用写屏障维护卡表的状态
卡表的不同
对于G1之前的垃圾收集器,是一个年轻代专门有一个卡表,但是对于G1,由于把堆划分成了一个个的Region区,所以每个Region区都有一张卡表
本文总结
好啦,以上就是这篇文章的全部内容,详细的讲了G1和ZGC,内容确实比较多,毕竟写了一礼拜多,所以这里给大家写明需要着重看的几个点:
- 第一阶段:讲了G1垃圾收集器Region的概念和G1的执行流程
- 第二阶段:讲了ZGCZGC的执行流程和颜色指针和读屏障需要重点理解
- 第三阶段:指出了跨代引用的解决方案
- 第四阶段:垃圾收集器的选择
到这其实垃圾收集器就写完了,从Serial到Parallel到ParNew到CMS到G1到ZGC,总共讲了六种垃圾收集器,其实在我们的身边接触到的基本是Parallel、ParNew、CMS和G1这四种,其中CMS是需要大家着重去看的,因为CMS实际上才是真正开始达到用户线程和GC线程并行执行,它包含的三色标记、优缺点、GC流程都是需要好好理解的
从JDK 9开始,默认使用G1,它最大的特点就是弱化了分代的概念底层的实现还是分代收集,最大的优势就是为了可以设置GC停顿时间
,以牺牲GC时间的方式大大的提高了用户体验,它是怎么达到这一目标的也需要理解
从JDK 11开始,引入的ZGC,但是ZGC从近期的版本JDK 15还是16吧,才脱离实验属性,但是目前市面上还是没有普遍使用,所以大家只需要理解它的特性和特点就可以了,没必要过分的去理解它的调优策略,但是在ZGC的官网PPT中,写明了它是未来GC的基石,所以我们还是需要多它保持更多的关注
絮叨
最后,如果感到文章有哪里困惑的,请第一时间留下评论,如果各位看官觉得小沙弥我有点东西的话 求点赞👍 求关注❤️ 求分享👥 对我来说真的 非常有用!!!如果想获取深入理解Java虚拟机第三版这本书,可以关注微信公众号Java百科全书,输入JVM,即可获得,最后的最后,感谢各位看官的支持!!!
近期评论