1. G1收集器
1.1. 介绍
G1(Garbage-First)是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器(大内存和性能强机器,不适合小内存)。以极高概率满足GC停顿时间要求的同时,还具备高吞吐量性能特征。
开启参数
JDK9默认使用的垃圾收集器(8还不完善)。JVM 参数-XX:+UseG1GC开启。
算法
适用于年轻代和老年代,采用标记-复制算法。
独立区域(Region)
1. G1将Java堆所有划分为多个大小相等的独立区域(Region),JVM目标是不超过2048个Region(JVM源码里TARGET_REGION_NUMBER 定义),实际可以超过该值,但是不推荐。
2. 一般Region大小等于堆大小除以2048,比如堆大小为4096M,则Region大小为2M,当然也可以用参数 -XX:G1HeapRegionSize 手动指定Region大小,但是推荐默认的计算方式。
4. 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个。
5.G1不会一开始就将所有格子指定区域功能,都是一边使用一边划分的,各格子区域功能也是变化。 一个Region可能之前是年轻代,如果Region进行了垃圾回收,之后可能又会变成老年代,也就是说Region的区域功能可能会动态变化。
1.2. GC步骤
G1收集器一次GC(主要值Mixed GC)的运作过程大致分为以下几个步骤:
初始标记(initial mark,STW):暂停所有的其他线程,并记录下GCRoots直接能引用的对象,速度很快 ;
并发标记(Concurrent Marking):同CMS的并发标记
最终标记(Remark,STW):同CMS的重新标记
筛选回收(Cleanup,STW):STW开启GC线程筛选回收垃圾。
筛选回收阶段首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿STW时间(可以用JVM参数 -XX:MaxGCPauseMillis指定,这个是所有阶段STW总和时间,默认200ms,为了用户体验设置的)。来制定回收计划,回收一部分垃圾,因为没法在指定时间内一定收集完堆的垃圾对象。
比如老年代此时有1000个Region都满了,但是因为根据预期停顿时间,本次筛选回收可能只能停顿100毫秒,那么通过之前回收成本计算得知,可能回收其中800个Region刚好需要100ms,那么就只会回收800个Region。尽量把整个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收集器在有限时间内可以尽可能高的收集效率。
1.4. G1收集器特点
被视为JDK1.7以上版本Java虚拟机的一个重要进化特征。它具备以下特点:
1. 并行与并发:G1能充分利用CPU、多核环境下的硬件优势,使用多个CPU(CPU或者CPU核心)来缩短Stop-The-World停顿时间。部分其他收集器原本需要停顿Java线程来执行GC动作,G1收集器仍然可以通过并发的方式让java程序继续执行。
2. 分代收集:虽然G1可以不需要其他收集器配合就能独立管理整个GC堆,但是还是保留了分代的概念。
3. 空间整合:与CMS的标记-清理算法不同,G1从垃圾回收整体来看效果就像是标记-整理算 法,从局部上看是基于标记-复制算法。
4. 可预测的停顿:这是G1相对于CMS的另一个大优势,降低停顿时间是G1 和 CMS 共同的关注点,但G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段(通过参数-XX:MaxGCPauseMills指定)内完成垃圾收集。
毫无疑问, 可以由用户指定期望的停顿时间是G1收集器很强大的一个功能, 设置不同的期望停顿时间, 可使得G1在不同应用场景中取得关注吞吐量和关注延迟之间的最佳平衡。
这里设置的期望值必须是符合实际的, 不能异想天开, 毕竟G1是要冻结用户线程来复制对象的, 这个停顿时间再怎么低也得有个限度。 它默认的停顿目标为两百毫秒, 如果把停顿时间调得非常低, 很可能出现由于停顿时间太短, 导致每次选出来的回收集只占堆内存很小的一部分, 收集器收集的速度逐渐跟不上分配器分配的速度, 导致垃圾慢慢堆积。
一开始收集器还能从空闲的堆内存中获得一些喘息的时间, 但应用运行时间一长就不行了, 最终占满堆引发Full GC反而降低性能, 所以通常把期望停顿时间设置为一两百毫秒或者两三百毫秒会是比较合理的。
1.5. 垃圾收集分类
1.5.1. 对象移动到老年代原则
1. G1垃圾收集器对于对象什么时候会转移到老年代跟之前讲过的原则一样。
2. 唯一不同的是对大对象的处理,G1有专门分配大对象的Region叫Humongous区,而不是让大对象直接进入老年代的Region中。在G1中,大对象的判定规则就是一个大对象超过了一个Region大小的50%,比如按照上面算的,每个Region是2M,只要一个大对象超过了1M,就会被放入Humongous中,而且一个大对象如果太大,可能会横跨多个Region来存放(多个Region组H区)。
Humongous区专门存放短期巨型对象,不用直接进老年代,可以节约老年代的空间,避免因为老年代空间不够的GC开销。
Full GC的时候除了收集年轻代和老年代之外,也会将H区一并回收。
3 前面说到卡表概念,处理老年代跨代引用年轻代,任何垃圾收集器都有。对于G1就是在每一个Eden区开辟空间存储卡表信息。
1.5.2. YoungGC/Minor GC
收集年轻代,YoungGC并不是说现有的Eden区放满了就会马上触发,G1会计算下现在Eden区回收大概要多久时间,如果回收时间远远小于参数 -XX:MaxGCPauseMills 设定的值,那么增加年轻代的Region,继续给新对象存放,不会马上做Young GC(可通过-XX:G1MaxNewSizePercent调整,但占比不能超过60%,)。
触发YoungGC时机
1. Eden区满,G1计算整体回收时间接近参数 -XX:MaxGCPauseMills 设定的值,那么就会触发Young GC。
2. Eden区满,G1计算整体回收时间远远小于参数 -XX:MaxGCPauseMills 设定的值。增加的空间已达到占比空间。就触发YoungGC解决。
YoungGC采用复制算法,把存活对象复制到空闲独立区域(该区域就变成被拷贝区域的类型,比如被拷贝是Eden区拷贝到独立区域就变成Eden区),清除当前独立区域。
1.5.3. MixedGC
老年代的堆占有率达到参数(-XX:InitiatingHeapOccupancyPercent)设定的值则触发(占有率指的是老年代独立区域在堆的占用率),回收所有的Young和部分Old(根据期望的GC停顿时间确定Old区垃圾收集的优先顺序)以及大对象区(H区),正常情况G1的垃圾收集会最先触发MixedGC,主要使用标记复制算法,需要把各个region中存活的对象拷贝到别的region里去,拷贝过程中如果发现没有足够的空region能够承载拷贝对象就会触发一次Full GC(注意触发条件是根据MixedGC触发的)。
1.5.4. Full GC
前面两种收集都是多线程按照正常流程收集。Full GC情况下就停止系统程序,然后采用单线程进行标记、清理和压缩整理,一个个清理独立区域,也不用复制算法这些(不用管它怎么清理),好空闲出来一批独立区域来供下一次MixedGC使用,这个过程是非常耗时的。(Shenandoah优化成多线程收集了)。
所以对该垃圾收集器优化就是尽量不触发Full GC和停顿时间合理把控。
1.5.4. G1收集器参数设置
1. -XX:+UseG1GC:使用G1收集器。
2. -XX:ParallelGCThreads:指定GC工作的线程数量(垃圾回收时候的线程)。
3. -XX:G1HeapRegionSize:指定分区Region大小(1MB~32MB,且必须是2的N次幂),默认将整堆划分为2048个分区。
4.-XX:MaxGCPauseMillis:目标暂停时间(默认200ms)。
5.-XX:G1NewSizePercent:新生代内存初始空间(默认整堆5%,值配置整数,默认就是百分比)。设置是整数,比如30代表30%。
6.-XX:G1MaxNewSizePercent:新生代占内存最大空间百分比,比如30代表30%。
7.-XX:TargetSurvivorRatio:Survivor区的填充容量(默认50%),Survivor区域里的一批对象(年龄1+年龄2+年龄n的多个年龄对象)总和超过了Survivor区域的50%,此时就会把年龄n(含)以上的对象都放入老年代。跟动态年龄判断机制有关。
8.-XX:MaxTenuringThreshold:最大年龄阈值(默认15)。
9.-XX:InitiatingHeapOccupancyPercent:老年代占用空间达到整堆内存阈值(默认45%)超过阈值则执行新生代和老年代的混合收集(MixedGC)。比如堆默认有2048个region,如果有接近1000个region都是老年代的region,则可能就要触发MixedGC了。
10.-XX:G1MixedGCLiveThresholdPercent:默认85%,MixedGC使用的参数。 Region中的存活对象低于这个值时才会回收该Region,如果超过这个值,存活对象过多,回收的的意义不大。
11.-XX:G1MixedGCCountTarget:针对回收筛选阶段,在一次回收过程中指定做几次筛选回收(默认8次),在最后一个筛选回收阶段可以回收一会,然后暂停回收,恢复系统运行,一会再开始回收,这样可以让系统不至于单次停顿时间过长。
12.-XX:G1HeapWastePercent(默认5%): GC过程中空出来的region是否充足的阈值,在混合回收的时候,对Region回收都是基于复制算法进行的,都是把要回收的Region里的存活对象放入其他Region,然后这个Region中的垃圾对象全部清理掉,这样的话在回收过程就会不断空出来新的Region,一旦空闲出来的Region数量达到了堆内存的5%,此时就会立即停止混合回收,意味着本次混合回收就结束了。
1.6. G1垃圾收集器优化建议
1. 合理设置 -XX:MaxGCPauseMills
假设参数 -XX:MaxGCPauseMills 设置的值很大,导致系统运行很久才会做年轻代GC,年轻代可能都占用了堆内存的60%了,此时才触发年轻代GC。那么存活下来的对象可能就会很多,此时就会导致Survivor区域放不下那么多的对象,就会进入老年代中。或者是你年轻代GC过后,存活下来的对象过多,导致进入Survivor区域后触发了动态年龄判定规则,达到了Survivor区域的50%,也会快速导致一些对象进入老年代中。
所以这里核心还是在于调节 -XX:MaxGCPauseMills 这个参数的值,在保证他的年轻代GC别太频繁的同时,还得考虑每次GC过后的存活对象有多少,避免存活对象太多快速进入老年代,频繁触发Mixed GC,最终导致Full GC。
1.7. G1适合场景
1. 50%以上的堆被存活对象占用。
2. 对象分配和晋升的速度变化非常大。
3. 垃圾回收时间特别长,超过1秒。
4. 8GB以上的堆内存(建议值)。
5. 停顿时间是500ms以内。
2. ZGC收集器
2.1. ZGC介绍
ZGC(The Z Garbage Collector)是JDK 11中推出的一款追求极致低延迟的垃圾收集器,它曾经设计目标包括:
1. 停顿时间不超过10ms(JDK16已经达到不超过1ms)。
2. 停顿时间不会随着堆的大小,或者活跃对象的大小而增加。
3. 支持8MB~4TB级别的堆,JDK15后已经可以支持16TB。
如果使用ZGC来做Java项目,像对STW敏感的证券系统,游戏的系统都可以去用Java来做(以前都是C或者C++的市场),所以ZGC的出现就是为了抢占其他语言的市场。
2.2. ZGC中的内存布局
为了细粒度地控制内存的分配,和G1一样,ZGC将内存划分成小的分区,在ZGC中称为页面,跟G1分区有点区别这边是分页。
ZGC中没有分代的概念(新生代、老年代)。
ZGC支持3种页面,分别为小页面、中页面和大页面,可能一个堆中2会出现很多小页面,几个中页面,一两个大页面。
其中小页面指的是2MB的页面空间,中页面指32MB的页面空间,大页面大小受操作系统控制,当操作系统识别是GB级会设置为2MB,TB级就会用1GB。 一般常用的是2MB和1GB,总之必须为2MB的整数倍。
当对象大小小于等于256KB时,对象分配在小页面。
当对象大小在256KB和4M之间,对象分配在中页面。
当对象大于4M,对象分配在大页面。
ZGC对于不同页面回收的策略也不同。简单地说,小页面优先回收;中页面和大页面则尽量不回收。
这么设计原因:
标准大页(huge page)是Linux Kernel 2.6引入的,),目的是通过使用大页内存来取代传统的4KB内存页面,以适应越来越大的系统内存,让操作系统可以支持现代硬件架构的大页面容量功能。
标准大页有两种格式大小: 2MB 和 1GB ,2MB 是默认的页大小。 如果操作系统识别内存是GB级别的会使用2MB 页块大小, TB 级别的内存使用1GB 页块大小 。 2MB 是默认的页大小。
所以ZGC这么设置也是为了适应现代硬件架构的发展,提升性能。
2.3. ZGC支持NUMA(了解即可)
在过去,对于X86架构的计算机,内存控制器还没有整合进CPU,所有对内存的访问都需要通过北桥芯片来完成。X86系统中的所有内存都可以通过CPU进行同等访问。任何CPU访问任何内存的速度是一致的,不必考虑不同内存地址之间的差异,这称为统一内存访问(Uniform Memory Access,UMA)。UMA系统的架构示意图如图所示。
在UMA中,各处理器与内存单元通过互联总线进行连接,各个CPU之间没有主从关系。之后的X86平台经历了一场从“拼频率”到“拼核心数”的转变,越来越多的核心被尽可能地塞进了同一块芯片上,各个核心对于内存带宽的争抢访问成为瓶颈,所以人们希望能够把CPU和内存集成在一个单元上(称Socket),这就是非统一内存访问(Non-Uniform Memory Access,NUMA)。很明显,在NUMA下,CPU访问本地存储器的速度比访问非本地存储器快一些。下图所示是支持NUMA处理器架构示意图。
ZGC是支持NUMA的,在进行小页面分配时会优先从本地内存分配,当不能分配时才会从远端的内存分配。对于中页面和大页面的分配,ZGC并没有要求从本地内存分配,而是直接交给操作系统,由操作系统找到一块能满足ZGC页面的空间。ZGC这样设计的目的在于,对于小页面,存放的都是小对象,从本地内存分配速度很快,且不会造成内存使用的不平衡,而中页面和大页面因为需要的空间大,如果也优先从本地内存分配,极易造成内存使用不均衡,反而影响性能。
2.4. ZGC的核心概念,指针着色技术(Color Pointers)
ZGC不像其它垃圾回收器做标记时候标记在对象头上,它是在对象指针上做标记。在对象指针中借高四位来做GC相关事情(用来实现垃圾回收中的并发标记、转移和重定位等)。这四位中,其中只有3位做事M0,M1,Remapped,且它们是互斥的,比如其中一位标记位1,其他两位就标记为0。其中一位有标记这个指针就有特定颜色。
在早期版本,比如JDK11,ZGC中用对象指针的低42位表示对象的真实地址,寻址范围能接受4TB的堆,所以JDK11只支持4TB的堆。
因为目前主板地址总线最宽只有48bit,ZGC下对象指针中4位是用来做标记,就只剩44位寻址,受限于目前的硬件,ZGC最大只能支持16T的内存,JDK13就把最大支持堆内存从4T扩大到了16T。
因为需要至少42位存储对象地址,高四位来做GC相关事情。所以它必须要求在64位的机器上才可以工作。
ZGC下JVM没法进行指针压缩(因为算法只支持对象指针中,最多35位有效位的的压缩)。
2.4.1. C语言方式来理解指针着色技术
通过C语言代码演示下指针着色技术的实现方式,分别多次映射将原本真实内存地址映射到虚地址(分别对应remapped,m0,m1互斥标记后对象指针表示的地址)。
分别多次通过mmap将一个内存真实的小地址映射到虚拟的大地址,来说明指针着色后,原来指针从真实地址变为虚拟地址。
例子
通过一个例子演示Linux多视图映射。Linux中主要通过系统函数mmap完成视图映射。多个视图映射就是多次调用mmap函数,多次调用的返回结果就是不同的虚拟地址。
操作系统里头共享内存可以通过创建一个文件描述符表示。
设置文件描述符创建共享内存的大小。
调用mmap函数进行内存映射,将小的地址映射更大虚拟地址。
uint32_t *remapped
表示定义一个uint32_t 类型的指针,记录存储uint32_t 类型变量的内存地址。
*remapped
此指针指向的内存地址中存放的内容,就是共享内存对象的真实地址。共享内存对象分别三次虚拟映射之后,通过*remapped
修改共享内存的真实地址,然后分别打印三个虚拟映射地址的所指向的内存地址中存放的内容,说明它们操作都是同一共享内存对象。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/mman.h>
#include <sys/types.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <stdint.h>
int main()
{
//创建一个共享内存的文件描述符
int fd = shm_open("/example", O_RDWR | O_CREAT | O_EXCL, 0600);
if (fd == -1) return 0;
//防止资源泄露,需要删除。执行之后共享对象仍然存活,但是不能通过名字访问
shm_unlink("/example");
//将共享内存对象的大小设置为4字节
size_t size = sizeof(uint32_t);
ftruncate(fd, size);
//3次调用mmap,把一个共享内存对象映射到3个虚拟地址上
int prot = PROT_READ | PROT_WRITE;
uint32_t *remapped = mmap(NULL, size, prot, MAP_SHARED, fd, 0);
uint32_t *m0 = mmap(NULL, size, prot, MAP_SHARED, fd, 0);
uint32_t *m1 = mmap(NULL, size, prot, MAP_SHARED, fd, 0);
//关闭文件描述符
close(fd);
//测试,通过一个虚拟地址设置共享内存的真实地址,打印映射后3个虚拟地址和它们地址指定的内容
*remapped = 0xdeafbeef;
printf("48bit of remapped is: %p, value of 32bit is: 0x%x\n", remapped, *remapped);
printf("48bit of m0 is: %p, value of 32bit is: 0x%x\n", m0, *m0);
printf("48bit of m1 is: %p, value of 32bit is: 0x%x\n", m1, *m1);
return 0;
}
在Linux上通过gcc编译后运行文件,得到的执行文件:
gcc -lrt -o mapping mapping.c
然后执行下,我们来看下执行结果。
从结果我们可以发现,3个变量对应3个不同的虚拟地址。
实地址:(32位指针)是:0xdeafbeef <一位16进制代表4位二进制>
虚地址:(48位指针):
0x7f93aef8e000<虚地址remapped>
0x7f93aef8d000<虚地址m0>
0x7f93aef8c000<虚地址m1>
它们都是通过mmap映射同一个内存共享对象,所以它们的物理地址是一样的,并且它们的值都是0xdeafbeef。
2.5. ZGC流程
2.5.1. 根可达算法(可达性算法)
来判定对象是否存活的。这个算法的基本思路就是通过一系列的称为GC Roots的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots(set集合)没有任何引用链相连时,则证明此对象是不可用的。
作为GC Roots的对象主要包括下面4种
1. 虚拟机栈(栈帧中的本地变量表):各个线程调用方法堆栈中使用到的参数、局部变量、临时变量等。
2. 方法区中类静态变量,比如:java类的引用类型静态变量。
3. 方法区中常量,比如:字符串常量池里的引用。
4. 本地方法栈中JNI指针:(即一般说的Native方法)。
2.5.2. 垃圾回收流程
使用的垃圾算法
1. ZGC采用了标记整理和标记复制算法。根据不同场景选择合适的算法,比如一个页中只存储了一部分垃圾对象可以用标记复制算法,一个页中存满垃圾则使用标记整理算法。
流程
一次ZGC流程:标记阶段(标识垃圾)和转移阶段(对象复制或移动)。
标记阶段(标识垃圾)
标记阶段分多步是为了减少STW时间。
1. 初始阶段
在ZGC初始化之后,此时地址视图为Remapped(创建对象时候对象指针的Remapped位标志为1,因为对象地址此时没有变动,注意其它标记位标记为0,看前面指针着色技术。),程序正常运行,在内存中分配对象,满足一定条件后垃圾回收启动。
2. 初始标记(STW)
从根集合(GC Roots)出发,找出根集合直接引用的活跃对象(根对象)。
这个阶段需要暂停(STW),初始标记只需要扫描所有GC Roots的根对象,其处理时间和GC Roots的数量成正比,停顿时间不会随着堆的大小或者活跃对象的大小而增加。
3. 并发标记
根据初始标记找到的根对象,使用深度优先遍历对象的成员变量进行标记。标记信息在对象指针上,会在**指针M位上做标记,M0和M1用来区分相邻两次GC中的标记,也就是这次GC用M0标记颜色,下次GC就用M1标记颜色。
这个阶段不需要暂停(没有STW),扫描剩余的所有对象,这个处理时间比较长,所以走并发,业务线程与GC线程同时运行,但是这个阶段会产生多标和漏标问题。
4. 再标记(STW)
这个阶段需要暂停(STW),主要处理漏标对象,通过SATB算法解决(G1中也是用这个解决漏标的方案)。
转移阶段(对象复制或移动整理到新内存上,释放原来的空间,涉及标记复制和标记整理)。
5. 并发转移准备(分析最有价值GC分页,无STW )
标识哪些页面里头垃圾特别多做为优先级别,如果一个页面全是垃圾,可以在该阶段直接清理了(使用标记整理算法)。
6. 初始转移(有STW )
转移初始标记的存活对象到新空间同时做对象重定位,对其指针Remmapped位做标记为1。转发表中不记录转移Gc Roots引用对象的信息,因为Gc Roots引用的对象在代码占用量很少,直接STW修改使用这些对象的旧指针地方为新指针,时间很短。
7. 并发转移(对转移并发标记的存活对象做转移,无STW)
并发转移借助转发表(类似于HashMap)。将存活对象,复制到空闲的页面(使用标记复制算法)。
在当前垃圾对象页面创有一个转发表(HashMap),记录对象的旧指针,和对象新指针(HashMap实现,key旧指针,value新指针)。对象转移和插转发表是原子操作。
做完之后清理旧的页面。
此时没有做重定位,重定位在读屏障中或者下次GC并发标记中做统一重定位。
8. 并发标记对象的重定位(对象转移后,原来程序使用的转移对象的旧指针需要修改为新的指针)
该操作在下次GC的并发标记中触发,下次GC并发标记时候,切换M0和M1位标记存活对象指针颜色(比如上次是M0这次就是M1位标记颜色),遇到上次还没重定位的存活对象(根据颜色指针,比如指针颜色标记的是M0位),先改变指针颜色标记M1,借助转发表,修改程序中使用到该对象的旧指针为新指针,然后清理在转发表中记录。(重定位和删除转发表记录是原子操作)。这边没有在指针Remmapped位做标记操作(Remmapped位标记情况只有对象创建时候,还要初始转移重定位GcRoots根对象时候触发)。
重定位往往涉及到并发控制问题,很多垃圾器则使用STW来操作修改,ZGC基本不使用STW进行重定位(除了初始转移阶段)。一种是对象借助读屏障达到重定位效果。第二种就是在下次并发标记,对旧对象的指针进行统一修改。
结合图来说明流程
有两个页面用标记整理算法实现垃圾回收。
一开始创建了对象A,B,C,D,那么其实也是重定位状态,对象地址没变,此时标记这些对象指针的Remapped为1,指针颜色为蓝色。指针地址假设为1,2,3,4。
开始垃圾回收。
初始标记
找到做为Gc Roots引用的对象A,标记指针M0为1,此时对象A的指针是绿色。进行下步。
并发标记
将关联存活的B,C标记指针M0为1,此时针为绿色。
再标记
并发标记中假设D又与存活对象关联,这种本是垃圾对象又关联了是漏标。该阶段通过SATB会对这些漏标标记指针颜色为绿色。
并发转移准备
分析优先清理垃圾比较多的页面,并清理全是垃圾的页面(标记整理)。
初始转移
转移标记为绿色的GcRoots根对象,并对其Remapped位做标记,其余位标记0,指针变为蓝色。因为GcRoots根对象很少,直接STW进行重定位,将引用该对象程序中的旧指针改成新指针。
并发转移
并发转移借助转发表(类似于HashMap)。将存活对象,复制到空闲的页面(标记复制)。对象转移和插转发表做原子操作。
并发标记对象的重定
下次GC并发标记中执行,此时切换用M1标记存活对象为红色,遇到存活的标记绿色的对象就是上次还没重定位的存活对象,对象指针M1标记,修改指针颜色。然后根据转发表,修改程序中引用这些对象的旧指针为新指针,删除转发表里头的记录(重定位和删除转发表记录是原子操作)。
注意在第一次ZGC垃圾回收结束,下次并发标记前,创建对象是蓝色的。
2.6. ZGC通过读屏障进行重定位
加入读屏障条件:当从堆中读取引用后,判断当前指针是否是Bad Color(不是本次GC的Mark颜色)就需要加入读屏障。
涉及对象:并发转移但还没做对象重定位的对象(通过着色指针使用M0和M1可以区分)。
触发时机:在两次GC之间业务线程访问这样的对象。
触发的操作:对象重定位(修正旧指针为新指针)+删除转发表记录(两个一起做原子操作)。
读屏障是JVM向应用代码插入一小段代码的技术。当应用线程从堆中读取对象引用时,就会执行这段代码。
需要注意的是,仅从堆中读取对象引用才会触发这段代码。
读屏障例子
读屏障就是在代码中,从堆中读取引用进行别的操作前加了一段代码。
package enjoy.jvm.zgc;
public class ZObject {
ZObject instance = null;
int i=13;
public void ReadBarrier(){
//调用一次 方法计数器 xxxx.ReadBarrier.count +1
ZObject A = new ZObject();
A.instance= new ZObject();//A->B
System.gc();//todo 这里发生一次ZGC
//...加入读屏障代码
ZObject D = A.instance; //这里需不需要加读屏障?(需要)
ZObject E = D;//这里需不需要加读屏障?(不需要,这样只是读旧的指针,指针传递)
D.hashCode();//这里需不需要加读屏障?(不需要)
int j = D.i;//这里需不需要加读屏障?(不需要)
}
2.7. ZGC中GC触发机制
ZGC跟别的垃圾回收器触发GC不太一样。
预热规则:服务刚启动时出现,一般不需要关注。日志中关键字是“Warmup”。
JVM启动预热,如果从来没有发生过GC,则在堆内存使用超过10%、20%、30%时,分别触发一次GC,以收集GC数据。
基于分配速率的自适应算法
最主要的GC触发方式(默认方式),其算法原理可简单描述为ZGC根据近期的对象分配速率以及GC时间,计算出当内存占用达到什么阈值时触发下一次GC。
通过ZAllocationSpikeTolerance参数控制阈值大小,如-XX:ZAllocationSpikeTolerance=5,该参数默认2,数值越大,越早的触发GC。日志中关键字是Allocation Rate。
基于固定时间间隔
固定时间间隔触发GC。
通过ZCollectionInterval控制,如-XX:ZCollectionInterval=120,单位是秒。
适合应对突增流量场景。流量平稳变化时,自适应算法可能在堆使用率达到95%以上才触发GC。流量突增时,自适应算法触发的时机可能会过晚,导致部分线程阻塞。通过调整此参数解决流量突增场景的问题,比如定时活动、秒杀等场景。
主动触发规则
类似于固定间隔规则,但时间间隔不固定,是ZGC自行算出来的时机,通过ZProactive参数控制。
我们的服务因为已经加了基于固定时间间隔的触发机制,所以通过 -XX:-ZProactive 参数将该功能关闭,以免GC频繁,影响服务可用性。
阻塞内存分配请求触发
当垃圾来不及回收,垃圾将堆占满时,会导致部分线程阻塞就会触发。应当避免出现这种触发方式。日志中关键字是Allocation Stall。
外部触发
代码中显式调用System.gc()触发。 日志中关键字是System.gc()。
元数据分配触发
元数据区不足时导致,一般不需要关注。 日志中关键字是Metadata GC Threshold。
2.8. ZGC参数优化设置
ZGC 优势不仅在于其超低的 STW 停顿,也在于其参数的简单,绝大部分生产场景都用默认配置就行。极端情况下,还是有可能需要对 ZGC 个别参数做个调整,只有注意下面三类:
调整堆大小:参数Xmx,当分配速率过高,超过回收速率,造成堆内存不够时,会触发 Allocation Stall,这类 Stall 会减缓当前的用户线程。因此,当我们在 GC 日志中看到 Allocation Stall,通常可以认为堆空间偏小或者 concurrent gc threads 数偏小。
调整GC 触发时机:ZAllocationSpikeTolerance, ZCollectionInterval。
基于分配速率的自适应算发ZAllocationSpikeTolerance,比如-XX:ZAllocationSpikeTolerance=5。用来估算当前的堆内存分配速率,在当前剩余的堆内存下,ZAllocationSpikeTolerance 越大,估算的达到 OOM 的时间越快,ZGC 就会更早地进行触发 GC。
基于固定时间间隔ZCollectionInterval,比如-XX:ZCollectionInterval=120。用来指定 GC 发生的间隔,以秒为单位触发 GC。
调整GC 线程:ParallelGCThreads, ConcGCThreads。
ParallelGCThreads 是设置GC过程中,有STW阶段的GC 线程数目,默认为 CPU 个数的 60%。
ConcGCThreads 是设置GC过程中并发阶段 GC 线程的数目,默认为 CPU 个数的 12.5%(取整)。增加 GC 线程数目,可以加快 GC 完成任务,减少各个阶段的时间,但也会增加 CPU 的抢占开销,可根据生产情况调整。
通常设置 Xmx 即可满足业务的需求,大大减轻 Java 开发者的负担。
2.9. ZGC典型应用场景
对于性能来说,不同的配置对性能的影响是不同的,如充足的内存下即大堆场景,ZGC 在各类 Benchmark 中能够超过 G1 大约 5% 到 20%,而在小堆情况下,则要低于 G1 大约 10%;不同的配置对于应用的影响不尽相同,开发者需要根据使用场景来合理判断。
当前 ZGC 不支持压缩指针和分代 GC,其内存占用相对于 G1 来说要稍大,在小堆情况下较为明显,而在大堆情况下,这些多占用的内存则显得不那么突出。因此,以下两类应用强烈建议使用 ZGC 来提升业务体验:
1. 超大堆应用。超大堆(百 G 以上)下,CMS 或者 G1 如果发生 Full GC,停顿会在分钟级别,可能会造成业务的终端,强烈推荐使用 ZGC。
2. 当业务应用需要提供高服务级别协议(Service Level Agreement,SLA),例如 99.99% 的响应时间不能超过 100ms,此类应用无论堆大小,均推荐采用低停顿的 ZGC。
2.10. ZGC生产注意事项
2.10.1. RSS 内存异常现象
在Linux中,RSS(Resident Set Size)内存是指进程当前驻留在物理内存中的部分。它表示进程实际使用的物理内存量,包括代码、数据和共享库等。
由前面 ZGC 原理可知,ZGC 采用多映射 multi-mapping 的方法实现了三份虚拟内存指向同一份物理内存。而 Linux 统计进程 RSS 内存占用的算法是比较脆弱的,这种多映射的方式并没有考虑完整,因此根据当前 Linux 采用大页和小页时,其统计的开启 ZGC 的 Java 进程的内存表现是不同的。在内核使用小页的 Linux 版本上,这种三映射的同一块物理内存会被 linux 的 RSS 占用算法统计 3 次,因此通常可以看到使用 ZGC 的 Java 进程的 RSS 内存膨胀了三倍左右,但是实际占用只有统计数据的三分之一,会对运维或者其他业务造成一定的困扰。而在内核使用大页的 Linux 版本上,这部分三映射的物理内存则会统计到 hugetlbfs inode 上,而不是当前 Java 进程上。
2.10.2. 共享内存调整
ZGC 需要在 share memory 中建立一个内存文件来作为实际物理内存占用,因此当要使用的 Java 的堆大小大于 /dev/shm 的大小时,需要对 /dev/shm 的大小进行调整。通常来说,命令如下(下面是将 /dev/shm 调整为 64G):
vi命令打开编辑
vi /etc/fstab
tmpfs /dev/shm tmpfs defaults,size= 65536MB
首先修改 fstab 中 shm 配置的大小,size 的值根据需求进行修改,然后再进行 shm 的 mount 和 umount。
umount/dev/shm
mount /dev/shm
总之上面这些命令都要执行一遍。
2.10.3. mmap 节点上限调整
ZGC 的堆申请和传统的 GC 有所不同,需要占用的 memory mapping 数目更多,即每个 ZPage 需要 mmap 映射三次,这样系统中仅 Java Heap 所占用的 mmap 个数为 (Xmx / zpage_size) * 3
,默认情况下 zpage_size 的大小为 2M。
为了给 JNI 等 native 模块中的 mmap 映射数目留出空间,内存映射的数目应该调整为 (Xmx / zpage_size) 3*1.2
。
默认的系统 memory mapping 数目由文件 /proc/sys/vm/max_map_count 指定,通常数目为 65536,当给 JVM 配置一个很大的堆时,需要调整该文件的配置,使得其大于 (Xmx / zpage_size) 3*1.2
。
3. 如何选择垃圾收集器
1. 优先调整堆的大小让服务器自己来选择。
2. 如果内存小于100M,使用串行收集器。
3. 如果是单核,并且没有停顿时间的要求,串行或JVM自己选择。
4. 如果允许停顿时间超过1秒,选择并行或者JVM自己选。
5. 如果响应时间最重要,并且不能超过1秒,使用并发收集器。
6. 4G以下可以用parallel,4-8G可以用ParNew+CMS,8G以上可以用G1,几百G以上用ZGC。
下图有连线的可以搭配使用,红线特殊情况会使用该垃圾收集器(CMS并发失败下转为Serial Old收集)。
JDK 8默认使用 Parallel(年轻代和老年代都是)。
JDK 9默认使用 G1。
JDK 16默认使用ZGC。
4. 安全点与安全区域
4.1. 安全点
安全点就是指代码中一些特定的位置,当线程运行到这些位置时它的状态是确定的,这样JVM就可以安全的进行一些操作,比如GC等,所以GC不是想什么时候做就立即触发的,是需要等待所有线程运行到安全点后才能触发。
大体实现思想是当垃圾收集需要中断用户线程的时候, 不直接对用户线程操作, 在一个地方简单地设置一个标志位, 各个线程执行到安全点时候轮询这个标志, 一旦发现中断标志为真时就在安全点上主动中断挂起。
这些特定的安全点位置主要有以下几种:
1. 方法返回之前。
2. 调用某个方法之后。
3. 抛出异常的位置。
4. 循环的末尾
4.2. 安全区域
Safe Point 是对正在执行的线程设定的。
如果一个线程处于 Sleep 或中断状态,就不能运行到 Safe Point 上它,响应 JVM 的中断请求。因此 JVM 引入了 Safe Region。
Safe Region 是指在一段代码片段中,引用关系不会发生变化。在这个区域内的任意地方开始 GC 都是安全的。