对于Java 程序员来说,JVM 帮助我们做了很多事情。
JVM是虚拟机,能够识别字节码,就是class文件或者你打包的jar文件,运行在操作系统上。
JVM帮我们实现了跨平台,你只需要编译一次,就可以在不同的操作系统上运行,并且效果是一致的。
在Java中你使用对象,使用内存,不用担心回收,只管new对象就行了,不用管垃圾的回收。
因为Java当中是自动化的垃圾回收机制。JVM有专门的垃圾回收器,把垃圾回收这件事给干了。
对于Java的项目来说,JVM进行垃圾回收会有一个很大的问题,就是STW。
什么是STW,STW的全称是StopTheWorld(停止所有业务线程)。
Java项目中,如果JVM要进行垃圾回收,会暂停所有的业务线程,也就是项目中的线程,这样会导致业务系统暂停。
STW带来的问题
Google 主导的 Android 系统需要解决的一大问题就是显示卡顿问题,通过对 GC 算法的不断演进,停顿时间控制在几个ms级别。所以这也是Android与苹果IOS系统竞争的一大利器。
证券交易系统主要就是买入、卖出,现在都是使用系统完成自动下单,如果用Java系统来做,遇到了STW,假如STW的时间是3秒。刚收到市场行情是比较低的买入的,但是因为STW卡顿了3秒,3秒后的市场行情可能完全不同。所以如果使用Java来做证券系统,一定是要求STW时间越短越好!
58同城的大数据系统,单集群5000+的Hadoop集群,日万亿级实时数据分发。如果遇到STW也是不行的。
为了满足不同的业务需求,Java 的 GC 算法也在不停迭代,对于特定的应用,选择其最适合的 GC 算法,才能更高效的帮助业务实现其业务目标。对于这些延迟敏感的应用来说,GC 停顿已经成为阻碍 Java 广泛应用的一大顽疾,需要更适合的 GC 算法以满足这些业务的需求。
近些年来,服务器的性能越来越强劲,各种应用可使用的堆内存也越来越大,常见的堆大小从 10G 到百G级别,部分机型甚至可以到达TB级别,在这类大堆应用上,传统的 GC,如 CMS、G1 的停顿时间也跟随着堆大小的增长而同步增加,即堆大小指数级增长时,停顿时间也会指数级增长。特别是当触发 Full GC 时,停顿可达分钟级别(百GB级别的堆)。当业务应用需要提供高服务级别协议(Service Level Agreement,SLA),例如 99.99% 的响应时间不能超过 100ms,此时 CMS、G1 等就无法满足业务的需求。
为满足当前应用对于超低停顿、并应对大堆和超大堆带来的挑战,伴随着 2018 年发布的 JDK 11,A Scalable Low-Latency Garbage Collector - ZGC 应运而生。
单线程:Serial、SerialOld
多线程:ParallelScavenge、ParallelOld
多线程+并发:CMS、G1、Shenadndoah、ZGC(STW控制在1ms)
如何选择垃圾收集器
1.优先调整堆的大小让服务器自己来选择
2.如果内存小于100M,使用串行收集器
3.如果是单核,并且没有停顿时间的要求,串行或JVM自己选择
4.如果允许停顿时间超过1秒,选择并行或者JVM自己选
5.如果响应时间最重要,并且不能超过1秒,使用并发收集器
6.4G以下可以用parallel,4-8G可以用ParNew+CMS,8G以上可以用G1,几百G以上用ZGC下图有连线的可以搭配使用
JDK1.8默认使用Ps
JDK1.9默认使用G1
ZGC(The Z Garbage Collector)是JDK 11中推出的一款追求极致低延迟的垃圾收集器,它曾经设计目标包括:
这么去想,如果使用ZGC来做Java项目,像对STW敏感的证券系统,游戏的系统都可以去用Java来做(以前都是C或者C++的市场),所以ZGC的出现就是为了抢占其他语言的市场。
ZGC目标
如下图所示,ZGC的目标主要有4个:
Java 11 引入了 ZGC,宣称暂停时间不超过 10ms。
ZGC 所采用的算法就是 Azul Systems 很多年前提出的 Pauseless GC(简称 Azul PGC):
具体见 https://www.usenix.org/legacy/events/vee05/full_papers/p46-click.pdf(虚拟机 zing: 它属于 zual 这家公司,非常牛,是一个商业产品,很贵!它的垃圾回收速度非常快(1 毫秒之内),是业界标杆。它的一个垃圾回收的算法后来被 Hotspot 吸收才有了现在的 ZGC)
虽然 Oracle 出的各种介绍资料上都完全没有提及 ZGC,我们从外部也无法证实或否认 Oracle GC 团队在研发 ZGC 的时候是否参考了 Azul 的论文,但就结果来看 ZGC 确实就是换了壳实现的 Azul PGC(哈哈...)。
为了细粒度地控制内存的分配,和G1一样,ZGC将内存划分成小的分区,在ZGC中称为页面(page)。
1、ZGC中没有分代的概念(新生代、老年代)
ZGC支持3种页面,分别为小页面、中页面和大页面。
ZGC对于不同页面回收的策略也不同。简单地说,小页面优先回收;中页面和大页面则尽量不回收。
2、为什么这么设计?
要先了解什么是内存分段,什么是内存分页。
标准大页(huge page)是Linux内核2.6引入,目的是通过使用大页内存来取代传统的4KB内存页面,以适应越来越大的系统内存,让操作系统可以支持现代硬件架构的大页面容量功能。
标准大页(huge page)有两种格式大小: 2MB 和 1GB , 2MB 页块大小适合用于 GB 大小的内存, 1GB 页块大小适合用于 TB 级别的内存; 2MB 是默认的页大小。
所以ZGC这么设置也是为了适应现代硬件架构的发展,提升性能。
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这样设计的目的在于,对于小页面,存放的都是小对象,从本地内存分配速度很快,且不会造成内存使用的不平衡,而中页面和大页面因为需要的空间大,如果也优先从本地内存分配,极易造成内存使用不均衡,反而影响性能。
+-------------------+-+----+-----------------------------------------------+
|00000000 00000000 0|0|1111|11 11111111 11111111 11111111 11111111 11111111|
+-------------------+-+----+-----------------------------------------------+
| | | |
| | | * 41-0 Object Offset (42-bits, 4TB address space)
| | |
| | * 45-42 Metadata Bits (4-bits)
| | 0001 = Marked0
| | 0010 = Marked1
| | 0100 = Finalizable
| | 1000 = Remapped
| |
| * 46-46 Unused (1-bit, always zero)
|
* 63-47 Fixed (17-bits, always zero)
以前的垃圾回收器的GC信息都保存在对象头中,而 ZGC 的 GC 信息保存在指针中
每个对象有一个64位指针,这64位被分为:
每一个GC周期开始时,会交换使用的标记位,使上次GC周期中修正的已标记状态失效,所有引用都变成未标记。
GC周期1:使用mark0,则周期结束所有引用mark标记都会成为01。
GC周期2:使用mark1,则期待的mark标记10,所有引用都能被重新标记。
通过对配置ZGC后对象指针分析我们可知,对象指针必须是64位,那么ZGC就无法支持32位操作系统,同样的也就无法支持压缩指针了(CompressedOops,压缩指针也是32位)。
PS:既然低42位指针可以支持4T内存,那么能否通过预约更多位给对象地址来达到支持更大内存的目的呢?
答案肯定是不可以。因为目前主板地址总线最宽只有48bit,4位是颜色位,就只剩44位了,所以受限于目前的硬件,ZGC最大只能支持16T的内存。
JDK13就把最大支持堆内存从4T扩大到了16T。
颜色指针的三大优势:
1.一旦某个Region的存活对象被移走之后,这个Region立即就能够被释放和重用掉,而不必等待整个堆中所有指向该Region的引用都被修正后才能清理,这使得理论上只要还有一个空闲Region,ZGC就能完成收集。
2.颜色指针可以大幅减少在垃圾收集过程中内存屏障的使用数量,ZGC只使用了读屏障。
3.颜色指针具备强大的扩展性,它可以作为一种可扩展的存储结构用来记录更多与对象标记、重定位过程相关的数据,以便日后进一步提高性能。
我们通过一个例子演示Linux多视图映射。Linux中主要通过系统函数mmap完成视图映射。多个视图映射就是多次调用mmap函数,多次调用的返回结果就是不同的虚拟地址。示例代码如下
#include
#include
#include
#include
#include
#include
#include
#include
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
虚地址:(48位指针):
0x7f93aef8e000
0x7f93aef8d000
0x7f93aef8c000
但是因为它们都是通过mmap映射同一个内存共享对象,所以它们的物理地址是一样的,并且它们的值都是0xdeafbeef。
根可达算法来判定对象是否存活的。这个算法的基本思路就是通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。
作为GC Roots的对象主要包括下面4种
复制算法
标记整理
在ZGC初始化之后,此时地址视图为Remapped,程序正常运行,在内存中分配对象,满足一定条件后垃圾回收启动。
这个阶段需要暂停(有STW),初始标记只需扫描所有GC Roots,扫描时间和GC Roots的数量成正比,停顿时间不会随着堆的大小或者活跃对象的大小而增加。
这个阶段不需要暂停(没有STW),扫描剩余的所有对象,这个处理时间比较长,所以走并发,业务线程与GC线程同时运行。但是这个阶段会产生漏标问题。
在这个阶段结束时,我们知道哪些对象仍然存在,哪些对象是垃圾。
在进行标记后,GC统计了垃圾最多的若干内存page(即上文的region),将它们称作:relocation set。对于这些page,GC需要将它们中所有的存活对象进行重新分配,并生成一个映射转发表,存放被relocated的对象的新地址。
ZGC 只有三个 STW 阶段: 初始标记,再标记,初始转移
使用转发表(类似于HashMap),对象转移和插转发表做原子操作
指针颜色:
Remapped:
M0:绿色
M1:红色
初始标记:标记 GC Root 直接指向的对象(根对象),下图为A,标记为Remmaped(引用被标记为蓝色)
并发标记:从根对象开始扫描所有的对象B、C 标记为Remmaped(引用被标记为蓝色),此阶段有重定位发生
再标记:通过三色标记算法处理并发标记阶段漏表的对象(引用被标记为蓝色)
此时已经区分出来哪些是可用对象,哪些是垃圾
并发转移准备:分析最有价值的GC分页,如果全是垃圾,直接回收了
初始转移:转移根对象(对应初始标记阶段标记的对象),下图A,标记为M0(引用被标记为蓝色)
并发转移:转移并发标记阶段存活的对象。(引用被标记为绿色)
转发表记录对象的旧地址和新地址。如果业务线程需要用到对象话(业务线程指向的还是旧地址),业务线程通过转发表找到新地址。
为什么A不需要,因为GCRoots和A的数量太少了,没必要,直接STW
二次GC的时候对并发标记对象的重定位(一次GC的时候,并发转移的对象的旧地址和新地址都记录在了转发表,二次GC会修改指针指向,并清除转发表)
因为上一次GC的并发标记做了转发表(绿色指针),x1-->x2-->x3,这次GC并发标记发现绿色指针直接给修正为x1-->x3,把转发表清掉。修改指针和删转发表也要做原子操作
M0(mark-0)、M1(mark-1)区分相邻两次GC中的标记
下次GC中的并发标记(同时做上次并发标记对象的重定位)
技术上:指针着色中M0和M1区分
所有一个存活的对象的指针标记是:第一次GC为m0,第二次为m1,第三次为m0,第四次为m1,来回变动
在两次GC中间业务线程
如何访问没有做完重定位的对象?使用转发表
jdk8默认垃圾回收器是PS
在标记和移动对象的阶段,每次从 GC 堆里的对象的引用类型字段里读取一个指针的时候,这个指针都会经过一个“Loaded Value Barrier”(LVB)。这是一种“Read Barrier”(读屏障),会在不同阶段做不同的事情。
之前的GC都是采用WriteBarrier(写屏障),这次ZGC采用了完全不同的方案读屏障,这个是ZGC一个非常重要的特性。
在标记和转移对象的阶段,每次从堆里对象的引用类型中读取一个指针的时候,都需要加上一个LoadBarriers。那么我们该如何理解它呢?
看上面的代码,第一行代码我们尝试读取堆中的一个对象引用obj.fieldA并赋给引用o(fieldA也是一个对象时才会加上读屏障)。如果这时候对象在GC时被移动了,接下来JVM就会加上一个读屏障,这个读屏障会把读出的指针更新到对象的新地址上,并且把堆里的这个指针“修正”到原本的字段,去掉转发表记录。这样就算GC把对象移动了,读屏障也会发现并修正指针,于是应用代码就永远都会持有更新后的有效指针,而且不需要STW。
那么,JVM是如何判断对象被移动过呢?就是利用上面提到的颜色指针,如果指针是BadColor,那么程序还不能往下执行,需要「slowpath」,修正指针;如果指针是GoodColor,那么正常往下执行即可。
这个动作是不是非常像JDK并发中用到的CAS自旋?读取的值发现已经失效了,需要重新读取。而ZGC这里是之前持有的指针由于GC后失效了,需要通过读屏障修正指针。
后面3行代码都不需要加读屏障:Objectp=o这行代码并没有从堆中读取数据;o.doSomething()也没有从堆中读取数据;obj.fieldB不是对象引用,而是原子类型。
正是因为LoadBarriers的存在,所以会导致配置ZGC的应用的吞吐量会变低。官方的测试数据是需要多出额外4%的开销:
那么,判断对象是BadColor还是GoodColor的依据是什么呢?就是根据上一段提到的ColoredPointers的4个颜色位。当加上读屏障时,根据对象指针中这4位的信息,就能知道当前对象是Bad/GoodColor了。
涉及对象:并发转移但还没做对象重定位的对象(着色指针使用M0和M1可以区分,图中B、C)
触发时机:在两次GC之间业务线程访问这样的对象(图中B、C)
触发操作:对象重定位+删除转发表记录(两个一起做原子操作)
为什么要做读屏障:对象(B、C)已经被移动但指针未修正,应用程序访问到旧地址出错?
读屏障是JVM向应用代码插入一小段代码的技术。当应用线程从堆中读取对象引用时,就会执行这段代码。
需要注意的是,仅“从堆中读取对象引用”才会触发这段代码。
读屏障是JVM向应用代码插入一小段代码的技术
不是涉及的代码都要触发
吞吐量降低(官方测试多4%开销)
最主要的GC触发方式(默认方式),基于正态分布统计,计算内存99.9%可能的最大分配速率,以及此速率下内存将要耗尽的时间点,在耗尽之前触发GC(耗尽时间-一次GC最大持续时间-一次GC检测周期时间)。
其算法原理可简单描述为”ZGC根据近期的对象分配速率以及GC时间,计算出当内存占用达到什么阈值时触发下一次GC”。
通过ZAllocationSpikeTolerance参数控制阈值大小,该参数默认2,数值越大,越早的触发GC。日志中关键字是“Allocation Rate”。
默认为不使用,通过ZCollectionInterval控制,适合应对突增流量场景。流量平稳变化时,自适应算法可能在堆使用率达到95%以上才触发GC。流量突增时,自适应算法触发的时机可能会过晚,导致部分线程阻塞。我们通过调整此参数解决流量突增场景的问题,比如定时活动、秒杀等场景。
(默认开启,可通过ZProactive参数配置)距上次GC堆内存增长10%,或超过5分钟时,对比距上次GC的间隔时间跟(49*一次GC的最大持续时间),超过则触发。
类似于固定间隔规则,但时间间隔不固定,是ZGC自行算出来的时机,我们的服务因为已经加了基于固定时间间隔的触发机制,所以通过-ZProactive参数将该功能关闭,以免GC频繁,影响服务可用性。
预热规则:服务刚启动时出现,一般不需要关注。日志中关键字是“Warmup”。
JVM启动预热,如果从来没有发生过GC最多三次,在堆内存达到10%、20%、30%时触发,分别触发一次GC,以收集GC数据,主要时统计GC时间,为其他GC机制使用。
当垃圾来不及回收,垃圾将堆占满时,会导致部分线程阻塞。我们应当避免出现这种触发方式。日志中关键字是“Allocation Stall”。
ZGC 优势不仅在于其超低的 STW 停顿,也在于其参数的简单,绝大部分生产场景都可以自适应。当然,极端情况下,还是有可能需要对 ZGC 个别参数做个调整,大致可以分为三类:
由上可以看出 ZGC 需要调整的参数十分简单,通常设置 Xmx 即可满足业务的需求,大大减轻 Java 开发者的负担。
对于性能来说,不同的配置对性能的影响是不同的,如充足的内存下即大堆场景,ZGC 在各类 Benchmark 中能够超过 G1 大约 5% 到 20%,而在小堆情况下,则要低于 G1 大约 10%;不同的配置对于应用的影响不尽相同,开发者需要根据使用场景来合理判断。
当前 ZGC 不支持压缩指针和分代 GC,其内存占用相对于 G1 来说要稍大,在小堆情况下较为明显,而在大堆情况下,这些多占用的内存则显得不那么突出。
因此,以下两类应用强烈建议使用 ZGC 来提升业务体验:
由前面 ZGC 原理可知,ZGC 采用多映射 multi-mapping 的方法实现了三份虚拟内存指向同一份物理内存。而 Linux 统计进程 RSS 内存占用的算法是比较脆弱的,这种多映射的方式并没有考虑完整,因此根据当前 Linux 采用大页和小页时,其统计的开启 ZGC 的 Java 进程的内存表现是不同的。在内核使用小页的 Linux 版本上,这种三映射的同一块物理内存会被 linux 的 RSS 占用算法统计 3 次,因此通常可以看到使用 ZGC 的 Java 进程的 RSS 内存膨胀了三倍左右,但是实际占用只有统计数据的三分之一,会对运维或者其他业务造成一定的困扰。而在内核使用大页的 Linux 版本上,这部分三映射的物理内存则会统计到 hugetlbfs inode 上,而不是当前 Java 进程上。
ZGC 需要在 share memory 中建立一个内存文件来作为实际物理内存占用,因此当要使用的 Java 的堆大小大于 /dev/shm 的大小时,需要对 /dev/shm 的大小进行调整。通常来说,命令如下(下面是将 /dev/shm 调整为 64G):
vi/etc/fstabtmpfs /dev/shm tmpfs defaults,size= 65536M00
首先修改 fstab 中 shm 配置的大小,size 的值根据需求进行修改,然后再进行 shm 的 mount 和 umount。
umount/dev/shmmount /dev/shm
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。
目前ZGC历代版本中存在的一些问题(阿里、腾讯、美团、华为等大厂在支持业务切换 ZGC 的出现的),基本上都已经将遇到的相关问题和修复积极向社区报告和回馈,很多问题在JDK16和JDK17已经修复完善。另外的话,问题相对来说不是非常严重,如果遇到类似的问题可以查看下JVM团队的历代修复日志,同时King老师的建议就是尽量使用比较新的版本来上线,以免重复掉坑里面。
G1:30ms
ZGC:0.009+0.011+0.011=0.03ms