类连接和初始化
类连接主要验证的内容
❤:类文件结构检查:按照JVM规范规定的类文件结构进行
❤:元数据验证:对字节码描述的信息进行语义分析,保证其符合Java语言规范要求(重载、final等)
❤:字节码验证:通过对数据流和控制流进行分析,确保程序语义是合法和符合逻辑的。这里主要对方法体进行校验。
❤:符号引用验证:对类自身以外的信息,也就是常量池中的各种符号引用,进行匹配校验
其实 GC 分为两大类,分别是 Partial GC 和 Full GC。
Partial GC 即部分收集,分为 young gc、old gc、mixed gc。
Full GC 即整堆回收,指的是收取整个堆,包括年轻代、老年代,如果有永久代的话还包括永久代。
其实还有 Major GC 这个名词,在《深入理解Java虚拟机》中这个名词指代的是单单老年代的 GC,也就是和 old gc 等价的,不过也有很多资料认为其是和 full gc 等价的。
还有 Minor GC,其指的就是年轻代的 gc。
大致上可以认为在年轻代的 eden 快要被占满的时候会触发 young gc。
为什么要说大致上呢?因为有一些收集器的回收实现是在 full gc 前会让先执行以下 young gc。
比如 Parallel Scavenge,不过有参数可以调整让其不进行 young gc。
可能还有别的实现也有这种操作,不过正常情况下就当做 eden 区快满了即可。
eden 快满的触发因素有两个,一个是为对象分配内存不够,一个是为 TLAB 分配内存不够。
这个触发条件稍微有点多,我们来看下。
这个得从内存申请说起。
一般而言生成对象需要向堆中的新生代申请内存空间,而堆又是全局共享的,像新生代内存又是规整的,是通过一个指针来划分的。
内存是紧凑的,新对象创建指针就右移对象大小 size 即可,这叫指针加法(bump [up] the pointer)。
可想而知如果多个线程都在分配对象,那么这个指针就会成为热点资源,需要互斥那分配的效率就低了。
于是搞了个 TLAB(Thread Local Allocation Buffer),为一个线程分配的内存申请区域。
这个区域只允许这一个线程申请分配对象,允许所有线程访问这块内存区域。
TLAB 的思想其实很简单,就是划一块区域给一个线程,这样每个线程只需要在自己的那亩地申请对象内存,不需要争抢热点指针。当这块内存用完了之后再去申请即可。这种思想其实很常见,比如分布式发号器,每次不会一个一个号的取,会取一批号,用完之后再去申请一批。
可以看到每个线程有自己的一块内存分配区域,短一点的箭头代表 TLAB 内部的分配指针。
如果这块区域用完了再去申请即可。不过每次申请的大小不固定,会根据该线程启动到现在的历史信息来调整,比如这个线程一直在分配内存那么 TLAB 就大一些,如果这个线程基本上不会申请分配内存那 TLAB 就小一些。
还有 TLAB 会浪费空间,我们来看下这个图。
可以看到 TLAB 内部只剩一格大小,申请的对象需要两格,这时候需要再申请一块 TLAB ,之前的那一格就浪费了。在 HotSpot 中会生成一个填充对象来填满这一块,因为堆需要线性遍历,遍历的流程是通过对象头得知对象的大小,然后跳过这个大小就能找到下一个对象,所以不能有空洞。当然也可以通过空闲链表等外部记录方式来实现遍历。还有 TLAB 只能分配小对象,大的对象还是需要在共享的 eden 区分配。所以总的来说 TLAB 是为了避免对象分配时的竞争而设计的。
可以看到和 TLAB 很像,PLAB 即 Promotion Local Allocation Buffers。用在年轻代对象晋升到老年代时。 在多线程并行执行 YGC 时,可能有很多对象需要晋升到老年代,此时老年代的指针就“热”起来了,于是搞了个 PLAB。先从老年代 freelist(空闲链表) 申请一块空间,然后在这一块空间中就可以通过指针加法(bump the pointer)来分配内存,这样对 freelist 竞争也少了,分配空间也快了。
大致就是上图这么个思想,每个线程先申请一块作为 PLAB ,然后在这一块内存里面分配晋升的对象。这和 TLAB 的思想相似。
《深入理解Java虚拟机》:由于CMS收集器无法处理“浮动垃圾”(FloatingGarbage),有可能出现“Con-current Mode Failure”失败进而导致另一次完全“Stop The World”的Full GC的产生。
这段话的意思是因为抛这个错而导致一次 Full GC。而实际上是 Full GC 导致抛这个错,我们来看一下源码,版本是 openjdk-8。首先搜一下这个错:
再找找看 report_concurrent_mode_interruption
被谁调用。
查到是在 void CMSCollector::acquire_control_and_collect(...)
这个方法中被调用的。
再来看看 first_state : CollectorState first_state = _collectorState;
看枚举已经很清楚了,就是在 cms gc 还没结束的时候。
而 acquire_control_and_collect
这个方法是 cms 执行 foreground gc 的。
cms 分为 foreground gc 和 background gc。
foreground 其实就是 Full gc。
因此是 full gc 的时候 cms gc 还在进行中导致抛这个错。
究其原因是因为分配速率太快导致堆不够用,回收不过来因此产生 full gc。
也有可能是发起 cms gc 设置的堆的阈值太高。
以下的回答来自 R 大。
因为没足够开发资源,偷懒了。就这么简单。没有任何技术上的问题。 大公司都自己内部做了优化。
所以最初怎么会偷这个懒的呢?多灾多难的CMS GC经历了多次动荡。它最初是作为Sun Labs的Exact VM的低延迟GC而设计实现的。但 Exact VM在与 HotSpot VM争抢 Sun 的正牌 JVM 的内部斗争中失利,CMS GC 后来就作为 Exact VM 的技术遗产被移植到了 HotSpot VM上。就在这个移植还在进行中的时候,Sun 已经开始略显疲态;到 CMS GC 完全移植到 HotSpot VM 的时候,Sun 已经处于快要不行的阶段了。开发资源减少,开发人员流失,当时的 HotSpot VM 开发组能够做的事情并不多,只能挑重要的来做。而这个时候 Sun Labs 的另一个 GC 实现,Garbage-First GC(G1 GC)已经面世。相比可能在长时间运行后受碎片化影响的 CMS,G1 会增量式的整理/压缩堆里的数据,避免受碎片化影响,因而被认为更具潜力。于是当时本来就不多的开发资源,一部分还投给了把G1 GC产品化的项目上——结果也是进展缓慢。毕竟只有一两个人在做。所以当时就没能有足够开发资源去打磨 CMS GC 的各种配套设施的细节,配套的备份 fullGC 的并行化也就耽搁了下来。
但肯定会有同学抱有疑问:HotSpot VM不是已经有并行GC了么?而且还有好几个?
让我们来看看:
所以…就是这么一回事。
HotSpot VM 确实是已经有并行 GC 了,但两个是只负责在 young GC 时收集 young gen 的,这俩之中还只有 ParNew 能跟 CMS 搭配使用;而并行 full GC 虽然有一个 ParallelOld,但却与 CMS GC 不兼容所以无法作为它的备份 full GC使用。
这张图是 2008 年 HostSpot 一位 GC 组成员画的,那时候 G1 还没问世,在研发中,所以画了个问号在上面。
里面的回答是 :
“ParNew” is written in a style… “Parallel Old” is not written in the “ParNew” style
HotSpot VM 自身的分代收集器实现有一套框架,只有在框架内的实现才能互相搭配使用。
而有个开发他不想按照这个框架实现,自己写了个,测试的成绩还不错后来被 HotSpot VM 给吸收了,这就导致了不兼容。我之前看到一个回答解释的很形象:就像动车组车头带不了绿皮车厢一样,电气,挂钩啥的都不匹配。
在常见的分代 GC 中就是利用记忆集来实现的,记录可能存在的老年代中有新生代的引用的对象地址,来避免全堆扫描。
上图有个对象精度的,一个是卡精度的,卡精度的叫卡表。
把堆中分为很多块,每块 512 字节(卡页),用字节数组来中的一个元素来表示某一块,1表示脏块,里面存在跨代引用。
在 Hotspot 中的实现是卡表,是通过写后屏障维护的,伪代码如下。
cms 中需要记录老年代指向年轻代的引用,但是写屏障的实现并没有做任何条件的过滤。
即不判断当前对象是老年代对象且引用的是新生代对象才会标记对应的卡表为脏。
只要是引用赋值都会把对象的卡标记为脏,当然YGC扫描的时候只会扫老年代的卡表。
这样做是减少写屏障带来的消耗,毕竟引用的赋值非常的频繁。
cms 的记忆集的实现是卡表即 card table。
通常实现的记忆集是 points-out 的,我们知道记忆集是用来记录非收集区域指向收集区域的跨代引用,它的主语其实是非收集区域,所以是 points-out 的。
在 cms 中只有老年代指向年轻代的卡表,用于年轻代 gc。
而 G1 是基于 region 的,所以在 points-out 的卡表之上还加了个 points-into 的结构。
因为一个 region 需要知道有哪些别的 region 有指向自己的指针,然后还需要知道这些指针在哪些 card 中。
其实 G1 的记忆集就是个 hash table,key 就是别的 region 的起始地址,然后 value 是一个集合,里面存储这 card table 的 index。
我们来看下这个图就很清晰了。
像每次引用字段的赋值都需要维护记忆集开销很大,所以 G1 的实现利用了 logging write barrier(下文会介绍)。也是异步思想,会先将修改记录到队列中,当队列超过一定阈值由后台线程取出遍历来更新记忆集。
G1 分了 young GC 和 mixed gc。
young gc 会选取所有年轻代的 region 进行收集。
midex gc 会选取所有年轻代的 region 和一些收集收益高的老年代 region 进行收集。
所以年轻代的 region 都在收集范围内,所以不需要额外记录年轻代到老年代的跨代引用。
之前文章分析到了并发执行漏标的两个充分必要条件是:
将新对象插入已扫描完毕的对象中,即插入黑色对象到白色对象的引用。
删除了灰色对象到白色对象的引用。
cms 和 g1 分别通过增量更新和 SATB 来打破这两个充分必要条件,维持了 GC 线程与应用线程并发的正确性。
cms 用了增量更新(Incremental update),打破了第一个条件,通过写屏障将插入的白色对象标记成灰色,即加入到标记栈中,在 remark 阶段再扫描,防止漏标情况。
G1 用了 SATB(snapshot-at-the-beginning),打破了第二个条件,会通过写屏障把旧的引用关系记下来,之后再把旧引用关系再扫描过。
这个从英文名词来看就已经很清晰了。讲白了就是在 GC 开始时候如果对象是存活的就认为其存活,等于拍了个快照。而且 gc 过程中新分配的对象也都认为是活的。每个 region 会维持 TAMS (top at mark start)指针,分别是 prevTAMS 和 nextTAMS 分别标记两次并发标记开始时候 Top 指针的位置。
Top 指针就是 region 中最新分配对象的位置,所以 nextTAMS 和 Top 之间区域的对象都是新分配的对象都认为其是存活的即可。
而利用增量更新的 cms 在 remark 阶段需要重新所有线程栈和整个年轻代,因为等于之前的根有新增,所以需要重新扫描过,如果年轻代的对象很多的话会比较耗时。
要注意这阶段是 STW 的,很关键,所以 CMS 也提供了一个 CMSScavengeBeforeRemark 参数,来强制 remark 阶段之前来一次 YGC。
而 g1 通过 SATB 的话在最终标记阶段只需要扫描 SATB 记录的旧引用即可,从这方面来说会比 cms 快,但是也因为这样浮动垃圾会比 cms 多。
写屏障其实耗的是应用程序的性能,是在引用赋值的时候执行的逻辑,这个操作非常的频繁,因此就搞了个 logging write barrier。
把写屏障要执行的一些逻辑搬运到后台线程执行,来减轻对应用程序的影响。
在写屏障里只需要记录一个 log 信息到一个队列中,然后别的后台线程会从队列中取出信息来完成后续的操作,其实就是异步思想。像 SATB write barrier ,每个 Java 线程有一个独立的、定长的 SATBMarkQueue,在写屏障里只把旧引用压入该队列中。满了之后会加到全局 SATBMarkQueueSet。
后台线程会扫描,如果超过一定阈值就会处理,开始 tracing。
在维护记忆集的写屏障也用了 logging write barrier 。
G1 从大局上看分为两大阶段,分别是并发标记和对象拷贝。
并发标记是基于 STAB 的,可以分为四大阶段:
1、初始标记(initial marking),这个阶段是 STW 的,扫描根集合,标记根直接可达的对象即可。在G1中标记对象是利用外部的bitmap来记录,而不是对象头。
2、并发阶段(concurrent marking),这个阶段和应用线程并发,从上一步标记的根直接可达对象开始进行 tracing,递归扫描所有可达对象。 STAB 也会在这个阶段记录着变更的引用。
3、最终标记(final marking), 这个阶段是 STW 的,处理 STAB 中的引用。
4、清理阶段(clenaup),这个阶段是 STW 的,根据标记的 bitmap 统计每个 region 存活对象的多少,如果有完全没存活的 region 则整体回收。
对象拷贝阶段(evacuation),这个阶段是 STW 的。
根据标记结果选择合适的 reigon 组成收集集合(collection set 即 CSet),然后将 CSet 存活对象拷贝到新 region 中。 G1 的瓶颈在于对象拷贝阶段,需要花较多的瓶颈来转移对象。
其实从之前问题的 CollectorState 枚举可以得知几个流程了。
1、**初始标记(initial mark)**,这个阶段是 STW 的,扫描根集合,标记根直接可达的对象即可。
2、**并发标记(Concurrent marking)**,这个阶段和应用线程并发,从上一步标记的根直接可达对象开始进行 tracing,递归扫描所有可达对象。
3、**并发预清理(Concurrent precleaning)**,这个阶段和应用线程并发,就是想帮重新标记阶段先做点工作,扫描一下卡表脏的区域和新晋升到老年代的对象等,因为重新标记是 STW 的,所以分担一点。
4、可中断的预清理阶段(AbortablePreclean),这个和上一个阶段基本上一致,就是为了分担重新标记标记的工作。
5、**重新标记(remark)**,这个阶段是 STW 的,因为并发阶段引用关系会发生变化,所以要重新遍历一遍新生代对象、Gc Roots、卡表等,来修正标记。
6、**并发清理(Concurrent sweeping)**,这个阶段和应用线程并发,用于清理垃圾。
7、**并发重置(Concurrent reset)**,这个阶段和应用线程并发,重置 cms 内部状态。
cms 的瓶颈就在于重新标记阶段,需要较长花费时间来进行重新扫描。
卡表其实只有一份,又得用来支持 YGC 又得支持 CMS 并发时的增量更新肯定是不够的。
每次 YGC 都会扫描重置卡表,这样增量更新的记录就被清理了。
所以还搞了个 mod-union table,在并发标记时,如果发生 YGC 需要重置卡表的记录时,就会更新 mod-union table 对应的位置。
这样 cms 重新标记阶段就能结合当时的卡表和 mod-union table 来处理增量更新,防止漏标对象了。
分别是最短暂停时间和吞吐量。
最短暂停时间:因为 GC 会 STW 暂停所有应用线程,这时候对于用户而言就等于卡顿了,因此对于时延敏感的应用来说减少 STW 的时间是关键。
吞吐量:对于一些对时延不敏感的应用比如一些后台计算应用来说,吞吐量是关注的重点,它们不关注每次 GC 停顿的时间,只关注总的停顿时间少,吞吐量高。
举个例子:
方案一:每次 GC 停顿 100 ms,每秒停顿 5 次。
方案二:每次 GC 停顿 200 ms,每秒停顿 2 次。
两个方案相对而言第一个时延低,第二个吞吐高,基本上两者不可兼得。
所以调优时候需要明确应用的目标。
这个问题在面试中很容易问到,抓住核心回答。
现在都是分代 GC,调优的思路就是尽量让对象在新生代就被回收,防止过多的对象晋升到老年代,减少大对象的分配。需要平衡分代的大小、垃圾回收的次数和停顿时间。需要对 GC 进行完整的监控,监控各年代占用大小、YGC 触发频率、Full GC 触发频率,对象分配速率等等。
然后根据实际情况进行调优。
比如进行了莫名其妙的 Full GC,有可能是某个第三方库调了 System.gc。
Full GC 频繁可能是 CMS GC 触发内存阈值过低,导致对象分配不过来。
还有对象年龄晋升的阈值、survivor 过小等等,具体情况还是得具体分析,反正核心是不变的。
当Survivor区的的内存大小不足以装下下一次Minor GC所有存活对象时,就会启动担保机制,把Survivor区放不下的对象放到老年代;
大对象(大小大于-XX:PretenureSizeThreshold的对象)直接在老年代分配内存;(只对Serial和ParNew收集器有效,对于Parallel Scavenge收集器无效)
把age大于-XX:MaxTenuringThreshold的对象晋升到老年代;(对象每在Survivor区熬过一次,其age就增加一岁);
JVM源码
1 | uint ageTable::compute_tenuring_threshold(size_t survivor_capacity) { |
TargetSurvivorRatio为我们设置的JVM参数-XX:TargetSurvivorRatio其默认值为50,从代码中可以看到会记录1->n的年龄对象的总内存和,当此值大于我们期望值时,就设置MaxTenuringThreshhold为该age,即>=此age年龄的对象都会晋升到老年代。
首先我们知道根据 「Java虚拟机规范」,Java 虚拟机运行时数据区分为程序计数器、虚拟机栈、本地方法栈、堆、方法区。
而程序计数器、虚拟机栈、本地方法栈这 3 个区域是线程私有的,会随线程消亡而自动回收,所以不需要管理。
因此垃圾收集只需要关注堆和方法区。
而方法区的回收,往往性价比较低,因为判断可以回收的条件比较苛刻。
比如类的卸载需要此类的所有实例都已经被回收,包括子类。然后需要加载的类加载器也被回收,对应的类对象没有被引用这才允许被回收。
就类加载器这一条来说,除非像特意设计过的 OSGI 等可以替换类加载器的场景,不然基本上回收不了。
而垃圾收集回报率高的是堆中内存的回收,因此我们重点关注堆的垃圾收集。
既然是垃圾收集,我们得先判断哪些对象是垃圾,然后再看看何时清理,如何清理。
常见的垃圾回收策略分为两种:一种是直接回收,即引用计数;另一种是间接回收,即追踪式回收(可达性分析)。
大家也都知道引用计数有个致命的缺陷-循环引用,所以 Java 用了可达性分析。
那为什么有明显缺陷的计数引用还是有很多语言采用了呢?
比如 CPython ,由此看来引用计数还是有点用的,所以咱们就先来盘一下引用计数。
引用计数其实就是为每一个内存单元设置一个计数器,当被引用的时候计数器加一,当计数器减少为 0 的时候就意味着这个单元再也无法被引用了,所以可以立即释放内存。
如上图所示,云朵代表引用,此时对象 A 有 1 个引用,因此计数器的值为 1。
对象 B 有两个外部引用,所以计数器的值为 2,而对象 C 没有被引用,所以说明这个对象是垃圾,因此可以立即释放内存。
由此可以知晓引用计数需要占据额外的存储空间,如果本身的内存单元较小则计数器占用的空间就会变得明显。
其次引用计数的内存释放等于把这个开销平摊到应用的日常运行中,因为在计数为 0 的那一刻,就是释放的内存的时刻,这其实对于内存敏感的场景很适用。
如果是可达性分析的回收,那些成为垃圾的对象不会立马清除,需要等待下一次 GC 才会被清除。
引用计数相对而言概念比较简单,不过缺陷就是上面提到的循环引用。
首先我们知道像整型、字符串内部是不会引用其他对象的,所以不存在循环引用的问题,因此使用引用计数并没有问题。
那像 List、dictionaries、instances 这类容器对象就有可能产生循环依赖的问题,因此 Python 在引用计数的基础之上又引入了标记-清除来做备份处理。
但是具体的做法又和传统的标记-清除不一样,它采取的是找不可达的对象,而不是可达的对象。
Python 使用双向链表来链接容器对象,当一个容器对象被创建时,它被插入到这个链表中,当它被删除时则移除。
然后在容器对象上还会添加一个字段 gc_refs,现在咱们再来看看是如何处理循环引用的:
具体如下图示例,A 和 B 对象循环引用, C 对象引用了 D 对象。
为了让图片更加清晰,我把步骤分开截图了,上图是 1-2 步骤,下图是 3-4 步骤。
最终循环引用的 A 和 B 都能被清理,但是天下没有免费的午餐,最大的开销之一是每个容器对象需要额外字段。
还有维护容器链表的开销。根据 pybench,这个开销占了大约 4% 的减速。
至此我们知晓了引用计数的优点就是实现简单,并且内存清理及时,缺点就是无法处理循环引用,不过可以结合标记-清除等方案来兜底,保证垃圾回收的完整性。
所以 Python 没有解决引用计数的循环引用问题,只是结合了非传统的标记-清除方案来兜底,算是曲线救国。
其实极端情况下引用计数也不会那么及时,你想假如现在有一个对象引用了另一个对象,而另一个对象又引用了另一个,依次引用下去。
那么当第一个对象要被回收的时候,就会引发连锁回收反应,对象很多的话这个延时就凸显出来了。
可达性分析其实就是利用标记-清除(mark-sweep),就是标记可达对象,清除不可达对象。至于用什么方式清,清了之后要不要整理这都是后话。
标记-清除具体的做法是定期或者内存不足时进行垃圾回收,从根引用(GC Roots)开始遍历扫描,将所有扫描到的对象标记为可达,然后将所有不可达的对象回收了。
所谓的根引用包括全局变量、栈上引用、寄存器上的等。
看到这里大家不知道是否有点感觉,我们会在内存不足的时候进行 GC,而内存不足时也是对象最多时,对象最多因此需要扫描标记的时间也长。
所以标记-清除等于把垃圾积累起来,然后再一次性清除,这样就会在垃圾回收时消耗大量资源,影响应用的正常运行。
所以才会有分代式垃圾回收和仅先标记根节点直达的对象再并发 tracing 的手段。
但这也只能减轻无法根除。
我认为这是标记-清除和引用计数的思想上最大的差别,一个攒着处理,一个把这种消耗平摊在应用的日常运行中。
而不论标记-清楚还是引用计数,其实都只关心引用类型,像一些整型啥的就不需要管。
所以 JVM 还需要判断栈上的数据是什么类型,这里又可以分为保守式 GC、半保守式 GC、和准确式 GC。
保守式 GC 指的是 JVM 不会记录数据的类型,也就是无法区分内存上的某个位置的数据到底是引用类型还是非引用类型。
因此只能靠一些条件来猜测是否有指针指向。比如在栈上扫描的时候根据所在地址是否在 GC 堆的上下界之内,是否字节对齐等手段来判断这个是不是指向 GC 堆中的指针。
之所以称之为保守式 GC 是因为不符合猜测条件的肯定不是指向 GC 堆中的指针,因此那块内存没有被引用,而符合的却不一定是指针,所以是保守的猜测。
我再画一张图来解释一下,看了图之后应该就很清晰了。
前面我们知道可以根据指针指向地址来判断,比如是否字节对齐,是否在堆的范围之内,但是就有可能出现恰好有数值的值就是地址的值。
这就混乱了,所以就不能确定这是指针,只能保守认为就是指针。
因此肯定不会有误杀对象的情况。只会有对象已经死了,但是有疑似指针的存在指向它,误以为它还活着而放过了它的情况发生。
所以保守式 GC 会有放过一些“垃圾”,对内存不太友好。
并且因为疑似指针的情况,导致我们无法确认它是否是真的指针,所以也就无法移动对象,因为移动对象就需要改指针。
有一个方法就是加个中间层,也就是句柄层,引用会先指到句柄,然后再从句柄表找到实际对象。
所以直接引用不需要改变,如果要移动对象只需要修改句柄表即可。不过这样访问就多了一层,效率就变低了。
半保守式GC,在对象上会记录类型信息而其他地方还是没有记录,因此从根扫描的话还是一样,得靠猜测。
但是得到堆内对象了之后,就能准确知晓对象所包含的信息了,因此之后 tracing 都是准确的,所以称为半保守式 GC。
现在可以得知半保守式 GC 只有根直接扫描的对象无法移动,从直接对象再追溯出去的对象可以移动,所以半保守式 GC 可以使用移动部分对象的算法,也可以使用标记-清除这种不移动对象的算法。
而保守式 GC 只能使用标记-清除算法。
相信大家看下来已经知道准确意味 JVM 需要清晰的知晓对象的类型,包括在栈上的引用也能得知类型等。
能想到的可以在指针上打标记,来表明类型,或者在外部记录类型信息形成一张映射表。
HotSpot 用的就是映射表,这个表叫 OopMap。
在 HotSpot 中,对象的类型信息里会记录自己的 OopMap,记录了在该类型的对象内什么偏移量上是什么类型的数据,而在解释器中执行的方法可以通过解释器里的功能自动生成出 OopMap 出来给 GC 用。
被 JIT 编译过的方法,也会在特定的位置生成 OopMap,记录了执行到该方法的某条指令时栈上和寄存器里哪些位置是引用。
这些特定的位置主要在:
这些位置就叫作安全点(safepoint)。
那为什么要选择这些位置插入呢?因为如果对每条指令都记录一个 OopMap 的话空间开销就过大了,因此就选择这些个关键位置来记录即可。
所以在 HotSpot 中 GC 不是在任何位置都能进入的,只能在安全点进入。
至此我们知晓了可以在类加载时计算得到对象类型中的 OopMap,解释器生成的 OopMap 和 JIT 生成的 OopMap ,所以 GC 的时候已经有充足的条件来准确判断对象类型。
因此称为准确式 GC。
其实还有个 JNI 调用,它们既不在解释器执行,也不会经过 JIT 编译生成,所以会缺少 OopMap。
在 HotSpot 是通过句柄包装来解决准确性问题的,像 JNI 的入参和返回值引用都通过句柄包装起来,也就是通过句柄再访问真正的对象。
这样在 GC 的时候就不用扫描 JNI 的栈帧,直接扫描句柄表就知道 JNI 引用了 GC 堆中哪些对象了。
我们已经提到了安全点,安全点当然不是只给记录 OopMap 用的,因为 GC 需要一个一致性快照,所以应用线程需要暂停,而暂停点的选择就是安全点。
我们来捋一遍思路。首先给个 GC 名词,在垃圾收集场景下将应用程序称为 mutator 。
一个能被 mutator 访问的对象就是活着的,也就是说 mutator 的上下文包含了可以访问存活对象的数据。
这个上下文其实指的就是栈、寄存器等上面的数据,对于 GC 而言它只关心栈上、寄存器等哪个位置是引用,因为它只需要关注引用。
但是上下文在 mutator 运行过程中是一直在变化的,所以 GC 需要获取一个一致性上下文快照来枚举所有的根对象。
而快照的获取需要停止 mutator 所有线程,不然就得不到一致的数据,导致一些活着对象丢失,这里说的一致性其实就像事务的一致性。
而 mutator 所有线程中这些有机会成为暂停位置的点就叫 safepoint 即安全点。
openjdk 官网对安全点的定义是:
A point during program execution at which all GC roots are known and all heap object contents are consistent. From a global point of view, all threads must block at a safepoint before the GC can run.
不过 safepoint 不仅仅只有 GC 有用,比如 deoptimization、Class redefinition 都有,只是 GC safepoint 比较知名。
我们再来想一下可以在哪些位置放置这个安全点。
对于解释器来说其实每个字节码边界都可以成为一个安全点,对于 JIT 编译的代码也能在很多位置插入安全点,但是实现上只会在一些特定的位置插入安全点。
因为安全点是需要 check 的,而 check 需要开销,如果安全点过多那么开销就大了,等于每执行几步就需要检查一下是否需要进入安全点。
其次也就是我们上面提到的会记录 OopMap ,所以有额外的空间开销。
那 mutator 是如何得知此时需要在安全点暂停呢?
其实上面已经提到了是 check,再具体一些还分解释执行和编译执行时不同的 check。
在解释执行的时候的 check 就是在安全点 polling 一个标志位,如果此时要进入 GC 就会设置这个标志位。
而编译执行是 polling page 不可读,在需要进入 safepoint 时就把这个内存页设为不可访问,然后编译代码访问就会发生异常,然后捕获这个异常挂起即暂停。
这里可能会有同学问,那此时阻塞住的线程咋办?它到不了安全点啊,总不能等着它吧?
这里就要引入安全区域的概念,在这种引用关系不会发生变化的代码段中的区域称为安全区域。
在这个区域内的任意地方开始 GC 都是安全的,这些执行到安全区域的线程也会标识自己进入了安全区域,
所以会 GC 就不用等着了,并且这些线程如果要出安全区域的时候也会查看此时是否在 GC ,如果在就阻塞等着,如果 GC 结束了那就继续执行。
可能有些同学对counted 循环有点疑问,像for (int i...)
这种就是 counted 循环,这里不会埋安全点。
所以说假设你有一个 counted loop 然后里面做了一些很慢的操作,所以很有可能其他线程都进入安全点阻塞就等这个 loop 的线程完毕,这就卡顿了。
前面我们提到标记-清除方式的 GC 其实就是攒着垃圾收,这样集中式回收会给应用的正常运行带来影响,所以就采取了分代收集的思想。
因为研究发现有些对象基本上不会消亡,存在的时间很长,而有些对象出来没多久就会被咔嚓了。这其实就是弱分代假说和强分代假说。
所以将堆分为新生代和老年代,这样对不同的区域可以根据不同的回收策略来处理,提升回收效率。
比如新生代的对象有朝生夕死的特性,因此垃圾收集的回报率很高,需要追溯标记的存活对象也很少,因此收集的也快,可以将垃圾收集安排地频繁一些。
新生代每次垃圾收集存活的对象很少的话,如果用标记-清除算法每次需要清除的对象很多,因此可以采用标记-复制算法,每次将存活的对象复制到一个区域,剩下了直接全部清除即可。
但是朴素的标记-复制算法是将堆对半分,但是这样内存利用率太低了,只有 50%。
所以 HotSpot 虚拟机分了一个 Eden 区和两个Survivor,默认大小比例是8∶1:1,这样利用率有 90%。
每次回收就将存活的对象拷贝至一个 Survivor 区,然后清空其他区域即可,如果 Survivor 区放不下就放到 老年代去,这就是分配担保机制。
而老年代的对象基本上都不是垃圾,所以追溯标记的时间比较长,收集的回报率也比较低,所以收集频率安排的低一些。
这个区域由于每次清除的对象很少,因此可以用标记-清除算法,但是单单清除不移动对象的话会有很多内存碎片的产生,所以还有一种叫标记-整理的算法,等于每次清除了之后需要将内存规整规整,需要移动对象,比较耗时。
所以可以利用标记-清除和标记-整理两者结合起来收集老年代,比如平日都用标记-清除,当察觉内存碎片实在太多了就用标记-整理来配合使用。
可能还有很多同学对的标记-清除,标记-整理,标记-复制算法不太清晰,没事,咱们来盘一下。
分为两个阶段:
标记阶段:tracing 阶段,从根(栈、寄存器、全局变量等)开始遍历对象图,标记所遇到的每个对象。
清除阶段:扫描堆中的对象,将为标记的对象作为垃圾回收。
基本上就是下图所示这个过程:
清除不会移动和整理内存空间,一般都是通过空闲链表(双向链表)来标记哪一块内存空闲可用,因此会导致一个情况:空间碎片。
这会使得明明总的内存是够的,但是申请内存就是不足。
而且在申请内存的时候也有点麻烦,需要遍历链表查找合适的内存块,会比较耗时。
所以会有多个空闲链表的实现,也就是根据内存分块大小组成不同的链表,比如分为大分块链表和小分块链表,这样根据申请的内存分块大小遍历不同的链表,加快申请的效率。
当然还可以分更多个链表。
还有标记,标记的话一般我们会觉得应该是标记在对象身上,比如标记位放在对象头中,但是这对写时复制不兼容。
等于每一次 GC 都需要修改对象,假设是 fork 出来的,其实是共享一块内存,那修改必然导致复制。
所以有一种位图标记法,其实就是将堆的内存某个块用一个位来标记。就像我们的内存是一页一页的,堆中的内存可以分成一块一块,而对象就是在一块,或者多块内存上。
根据对象所在的地址和堆的起始地址就可以算出对象是在第几块上,然后用一个位图中的第几位在置为 1 ,表明这块地址上的对象被标记了。
而且用位图表格法不仅可以利用写时复制,清除也更加高效,如果标记在对象头上,那么需要遍历整个堆来扫描对象,现在有了位图,可以快速遍历清除对象。
但是不论是标记对象头还是利用位图,标记-清除的碎片问题还是处理不了。
因此就引出了标记-复制和标记-整理。
首先这个算法会把堆分为两块,一块是 From、一块是 To。
对象只会在 From 上生成,发生 GC 之后会找到所有存活对象,然后将其复制到 To 区,之后整体回收 From 区。
再将 To 区和 From 区身份对调,即 To 变成 From , From 变成 To,我再用图来解释一波。
可以看到内存的分配是紧凑的,不会有内存碎片的产生。
不需要空闲链表的存在,直接移动指针分配内存,效率很高。
对 CPU缓存亲和性高,因为从根开始遍历一个节点,是深度优先遍历,把关联的对象都找到,然后内存分配在相近的地方。
这样根据局部性原理,一个对象被加载了那它所引用的对象也同时被加载,因此访问缓存直接命中。、
当然它也是有缺点的,因为对象的分配只能在 From 区,而 From 区只有堆一半大小,因此内存的利用率是 50%。
其次如果存活的对象很多,那么复制的压力还是很大的,会比较慢。
然后由于需要移动对象,因此不适用于上文提到的保守式 GC。
当然我上面描述的是深度优先就是递归调用,有栈溢出风险,还有一种 Cheney 的 GC 复制算法,是采用迭代的广度优先遍历,具体不做分析了,有兴趣自行搜索。
标记-整理其实和标记-复制差不多,区别在于复制算法是分为两个区来回复制,而整理不分区,直接整理。
算法思路还是很清晰的,将存活的对象往边界整理,也没有内存碎片,也不需要复制算法那样腾出一半的空间,所以内存利用率也高。
缺点就是需要对堆进行多次搜索,毕竟是在一个空间内又标记,又移动的,所以整体而言花费的时间较多,而且如果堆很大的情况,那么消耗的时间将更加突出。
至此相信你对标记-清除、标记-复制和标记-整理都清晰了,让我们再回到刚才提到的分代收集。
我们已经根据对象存活的特性进行了分代,提高了垃圾收集的效率,但是像在回收新生代的时候,有可能有老年代的对象引用了新生代对象,所以老年代也需要作为根,但是如果扫描整个老年代的话效率就又降低了。
所以就搞了个叫记忆集(Remembered Set)的东西,来记录跨代之间的引用而避免扫描整体非收集区域。
因此记忆集就是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。根据记录的精度分为
最常见的是用卡精度来实现记忆集,称之为卡表。
我来解释下什么叫卡。
拿对象精度来距离,假设新生代对象 A 被老年代对象 D 引用了,那么就需要记录老年代 D 所在的地址引用了新生代对象。
那卡的意思就是将内存空间分成很多卡片。假设新生代对象 A 被老年代 D 引用了,那么就需要记录老年代 D 所在的那一块内存片有引用新生代对象。
也就是说堆被卡切割了,假设卡的大小是 2,堆是 20,那么堆一共可以划分成 10 个卡。
因为卡的范围大,如果此时 D 旁边在同一个卡内的对象也有引用新生代对象的话,那么就只需要一条记录。
一般会用字节数组来实现卡表,卡的范围也是设为 2 的 N 次幂大小。来看一下图就很清晰了。
假设地址从 0x0000 开始,那么字节数组的 0号元素代表 0x0000~0x01FF,1 号代表0x0200~0x03FF,依次类推即可。
然后到时候回收新生代的时候,只需要扫描卡表,把标识为 1 的脏表所在内存块加入到 GC Roots 中扫描,这样就不需要扫描整个老年代了。
用了卡表的话占用内存比较少,但是相对字长、对象来说精度不准,需要扫描一片。所以也是一种取舍,到底要多大的卡。
还有一种多卡表,简单的说就是有多张卡表,这里我画两张卡表示意一下。
上面的卡表表示的地址范围更大,这样可以先扫描范围大的表,发现中间一块脏了,然后再通过下标计算直接得到更具体的地址范围。
这种多卡表在堆内存比较大,且跨代引用较少的时候,扫描效率较高。
而卡表一般都是通过写屏障来维护的,写屏障其实就相当于一个 AOP,在对象引用字段赋值的时候加入更新卡表的代码。
这其实很好理解,说白了就是当引用字段赋值的时候判断下当前对象是老年代对象,所引用对象是新生代对象,于是就在老年代对象所对应的卡表位置置为 1,表示脏,待会需要加入根扫描。
不过这种将老年代作为根来扫描会有浮动垃圾的情况,因为老年代的对象可能已经成为垃圾,所以拿垃圾来作为根扫描出来的新生代对象也很有可能是垃圾。
不过这是分代收集必须做出的牺牲。
所谓的增量式 GC 其实就是在应用线程执行中,穿插着一点一点的完成 GC,来看个图就很清晰了
这样看起来 GC 的时间跨度变大了,但是 mutator 暂停的时间变短了。
对于增量式 GC ,Dijkstra 等人抽象除了三色标记算法,来表示 GC 中对象三种不同状况。
白色:表示还未搜索到的对象。灰色:表示正在搜索还未搜索完的对象。黑色:表示搜索完成的对象。
下面这图从维基百科搞得,虽说颜色没对上,但是意思是对的(black 画成了蓝色,grey画成了黄色)。
我再用文字概述一下三色的转换。
GC 开始前所有对象都是白色,GC 一开始所有根能够直达的对象被压到栈中,待搜索,此时颜色是灰色。
然后灰色对象依次从栈中取出搜索子对象,子对象也会被涂为灰色,入栈。当其所有的子对象都涂为灰色之后该对象被涂为黑色。
当 GC 结束之后灰色对象将全部没了,剩下黑色的为存活对象,白色的为垃圾。
一般增量式标记-清除会分为三个阶段:
这里解释下 GC 中两个名词的含义。
并发:应用线程和 GC 线程一起执行。并行:多个 GC 线程一起执行。
看起来好像三色标记没啥问题?来看看下图。
第一个阶段搜索到 A 的子对象 B了,因此 A 被染成了黑色,B 为灰色。此时需要搜索 B。
但是在 B 开始搜索时,A 的引用被 mutator 换给了 C,然后此时 B 到 C 的引用也被删了。
接着开始搜索 B ,此时 B 没有引用因此搜索结束,这时候 C 就被当垃圾了,因此 A 已经黑色了,所以不会再搜索到 C 了。
这就是出现漏标的情况,把还在使用的对象当成垃圾清除了,非常严重,这是 GC 不允许的,宁愿放过,不能杀错。
还有一种情况多标,比如 A 变成黑色之后,根引用被 mutator 删除了,那其实 A 就属于垃圾,但是已经被标记为黑色了,那就得等下次 GC 清除了。
这其实就是标记过程中没有暂停 mutator 而导致的,但这也是为了让 GC 减少对应用程序运行的影响。
多标其实还能接受,漏标的话就必须处理了,我们可以总结一下为什么会发生漏标:
只要打破这两个条件任意一个就不会发生漏标的情况。
这时候可以通过以下手段来打破两个条件:
利用写屏障在黑色引用白色对象时候,将白色对象置为灰色,这叫增量更新。
利用写屏障在灰色对象删除对白色对象的引用时,将白色对象置为灰,其实就是保存旧的引用关系。这叫STAB(snapshot-at-the-beginning)。
至此有关垃圾回收的关键点和思路都差不多了,具体有关 JVM 的垃圾回收器等我下篇再作分析。
现在我们再来总结一下。
关于垃圾回收首先得找出垃圾,而找出垃圾分为两个流派,一个是引用计数,一个是可达性分析。
引用计数垃圾回收的及时,对内存较友好,但是循环引用无法处理。
可达性分析基本上是现代垃圾回收的核心选择,但是由于需要统一回收比较耗时,容易影响应用的正常运行。
所以可达性分析的研究方向就是往如何减少对应用程序运行的影响即减少 STW(stop the world) 的时间。
因此根据对象分代假说研究出了分代收集,根据对象的特性划分了新生代和老年代,采取不同的收集算法,提升回收的效率。
想方设法的拆解 GC 的步骤使得可以与应用线程并发,并且采取并行收集,加快收集速度。
还有往评估的方向的延迟回收或者说回收部分垃圾来减少 STW 的时间。
如下图所示,ZGC的目标主要有4个:
另外,Oracle官方提到了它最大的优点是:它的停顿时间不会随着堆的增大而增长!也就是说,几十G堆的停顿时间是10ms以下,几百G甚至上T堆的停顿时间也是10ms以下。
接下来从几个维度概述一下ZGC。
ZGC是一个全新的垃圾回收器,它完全不同以往HotSpot的任何垃圾回收器,比如:PS、CMS、G1等。如果真要说它最像谁的话,那应该是Azul公司的商业化垃圾回收器:「C4」,ZGC所采用的算法就是Azul Systems很多年前提出的Pauseless GC,而实现上它介于早期Azul VM的Pauseless GC与后来Zing VM的C4之间。不过需要说明的是,JDK11中ZGC只能运行在Linux64操作系统之上。JDK14新增支持了MacOS和Window平台:
如下图所示,是ZGC和Parallel以及G1的压测对比结果(CMS在JDK9中已经被标记deprecated,更高版本中已经被彻底移除,所以不在对比范围内)。我们可以明显的看到,停顿时间方面,ZGC是100%不超过10ms的,简直是秒天秒地般的存在:
接下来,再看一下ZGC的垃圾回收过程,如下图所示。由图我们可知,ZGC依然没有做到整个GC过程完全并发执行,依然有3个STW阶段,其他3个阶段都是并发执行阶段:
这一步就是初始化标记,和CMS以及G1一样,主要做Root集合扫描,「GC Root是一组必须活跃的引用,而不是对象」。例如:活跃的栈帧里指向GC堆中的对象引用、Bootstrap/System类加载器加载的类、JNI Handles、引用类型的静态变量、String常量池里面的引用、线程栈/本地(native)栈里面的对象指针等,但不包括GC堆里的对象指针。所以这一步骤的STW时间非常短暂,并且和堆大小没有任何关系。不过会根据线程的多少、线程栈的大小之类的而变化。
第二步就是并发标记阶段,这个阶段在第一步的基础上,继续往下标记存活的对象。并发标记后,还会有一个短暂的暂停(Pause Mark End),确保所有对象都被标记。
即为Relocation阶段做准备,选取接下来需要标记整理的Region集合,这个阶段也是并发执行的。接下来又会有一个Pause Relocate Start步骤,它的作用是只移动Root集合对象引用,所以这个STW阶段也不会停顿太长时间。
单代,即ZGC「没有分代」。我们知道以前的垃圾回收器之所以分代,是因为源于“「大部分对象朝生夕死」”的假设,事实上大部分系统的对象分配行为也确实符合这个假设。
那么为什么ZGC就不分代呢?因为分代实现起来麻烦,作者就先实现出一个比较简单可用的单代版本。用符合我们国情的话来解释,大概就是说:工作量太大了,人力又不够,老板,先上个1.0版本吧!!!
这一点和G1一样,都是基于Region设计的垃圾回收器,ZGC中的Region也被称为「ZPages」,ZPages被动态创建,动态销毁。不过,和G1稍微有点不同的是,G1的每个Region大小是完全一样的,而ZGC的Region大小分为3类:2MB,32MB,N×2MB,如此一来,灵活性就更好了:
部分压缩,这一点也很G1类似。以前的ParallelOldGC,以及CMS GC在压缩Old区的时候,无论Old区有多大,必须整体进行压缩(CMS GC默认情况下只是标记清除,只会发生FGC时才会采用Mark-Sweep-Compact对Old区进行压缩),如此一来,Old区越大,压缩需要的时间肯定就越长,从而导致停顿时间就越长。
而G1和ZGC都是基于Region设计的,在回收的时候,它们只会选择一部分Region进行回收,这个回收过程采用的是Mark-Compact算法,即将待回收的Region中存活的对象拷贝到一个全新的Region中,这个新的Region对象分配就会非常紧凑,几乎没有碎片。垃圾回收算法这一点上,和G1是一样的。
NUMA对应的有UMA,UMA即Uniform Memory Access Architecture,NUMA就是Non Uniform Memory Access Architecture。UMA表示内存只有一块,所有CPU都去访问这一块内存,那么就会存在竞争问题(争夺内存总线访问权),有竞争就会有锁,有锁效率就会受到影响,而且CPU核心数越多,竞争就越激烈。NUMA的话每个CPU对应有一块内存,且这块内存在主板上离这个CPU是最近的,每个CPU优先访问这块内存,那效率自然就提高了:
服务器的NUMA架构在中大型系统上一直非常盛行,也是高性能的解决方案,尤其在系统延迟方面表现都很优秀。ZGC是能自动感知NUMA架构并充分利用NUMA架构特性的。
Colored Pointers,即颜色指针是什么呢?如下图所示,ZGC的核心设计之一。以前的垃圾回收器的GC信息都保存在对象头中,而ZGC的GC信息保存在指针中。每个对象有一个64位指针,这64位被分为:
通过对配置ZGC后对象指针分析我们可知,对象指针必须是64位,那么ZGC就无法支持32位操作系统,同样的也就无法支持压缩指针了(CompressedOops,压缩指针也是32位)。
这个应该翻译成读屏障(与之对应的有写屏障即Write Barrier,之前的GC都是采用Write Barrier,这次ZGC采用了完全不同的方案),这个是ZGC一个非常重要的特性。在标记和移动对象的阶段,每次「从堆里对象的引用类型中读取一个指针」的时候,都需要加上一个Load Barriers。那么我们该如何理解它呢?看下面的代码,第一行代码我们尝试读取堆中的一个对象引用obj.fieldA并赋给引用o(fieldA也是一个对象时才会加上读屏障)。如果这时候对象在GC时被移动了,接下来JVM就会加上一个读屏障,这个屏障会把读出的指针更新到对象的新地址上,并且把堆里的这个指针“修正”到原本的字段里。这样就算GC把对象移动了,读屏障也会发现并修正指针,于是应用代码就永远都会持有更新后的有效指针,而且不需要STW。那么,JVM是如何判断对象被移动过呢?就是利用上面提到的颜色指针,如果指针是Bad Color,那么程序还不能往下执行,需要「slow path」,修正指针;如果指针是Good Color,那么正常往下执行即可:
这个动作是不是非常像JDK并发中用到的CAS自旋?读取的值发现已经失效了,需要重新读取。而ZGC这里是之前持有的指针由于GC后失效了,需要通过读屏障修正指针。
后面3行代码都不需要加读屏障:Object p = o这行代码并没有从堆中读取数据;o.doSomething()也没有从堆中读取数据;obj.fieldB不是对象引用,而是原子类型。
正是因为Load Barriers的存在,所以会导致配置ZGC的应用的吞吐量会变低。官方的测试数据是需要多出额外4%的开销:
那么,判断对象是Bad Color还是Good Color的依据是什么呢?就是根据上一段提到的Colored Pointers的4个颜色位。当加上读屏障时,根据对象指针中这4位的信息,就能知道当前对象是Bad/Good Color了。
「扩展阅读」:既然低42位指针可以支持4T内存,那么能否通过预约更多位给对象地址来达到支持更大内存的目的呢?答案肯定是不可以。因为目前主板地址总线最宽只有48bit,4位是颜色位,就只剩44位了,所以受限于目前的硬件,ZGC最大只能支持16T的内存,JDK13就把最大支持堆内存从4T扩大到了16T。
启用ZGC比较简单,设置JVM参数即可:-XX:+UnlockExperimentalVMOptions 「-XX:+UseZGC」。调优也并不难,因为ZGC调优参数并不多,远不像CMS那么复杂。它和G1一样,可以调优的参数都比较少,大部分工作JVM能很好的自动完成。下图所示是ZGC可以调优的参数:
下面对部分参数进行更加详细的说明。
UseNUMA
ZGC默认是开启支持NUMA的,不过,如果JVM探测到系统绑定的是CPU子集,就会自动禁用NUMA。我们可以通过参数-XX:+UseNUMA显示启动,或者通过参数-XX:-UseNUMA显示禁用。如果运行在NUMA服务器上,并且设置-XX:+UseNUMA,那对性能提升是显而易见的。
UseLargePages
配置ZGC使用large page通常就会得到更好的性能,比如在吞吐量、延迟、启动时间等方面。而且没有明显的缺点,除了配置过程复杂一点。因为它需要root权限,这也是默认并没有开启使用large page的原因。
ConcGCThreads
ZGC是一个并发垃圾收集器,那么并发GC线程数就非常重要了。如果设置并发GC线程数越多,意味着应用线程数就会越少,这肯定是非常不利于应用系统稳定运行的。这个参数ZGC能自动设置,如果没有十足的把握。最好不要设置这个参数。
ParallelGCThreads
这是个并行线程数,与上一个参数ConcGCThreads有所不同,ConcGCThreads表示GC线程和应用线程「并发」执行时GC线程数量。而ParallelGCThreads表示GC时STW阶段的「并行」GC线程数量(例如第一阶段的Root扫描),这时候只有GC线程,没有应用线程。笔者这里解释了JVM中「并发和并行的区别」,也是JVM中比较容易理解错误的地方。
ZUncommit
掌握这个参数之前,我们先说一下JVM申请以及回收内存的行为。以前的垃圾回收器比如ParallelOldGC和CMS,只要JVM申请过的内存,即使发生了GC回收了很多内存空间,JVM也不会把这些内存归还给操作系统。这就会导致top命令中看到的RSS只会越来越高,而且一般都会超过Xmx的值(参考文章:)。
不过,默认情况下,ZGC是会把不再使用的内存归还给操作系统的。这对于那些比较注意内存占用情况的应用和服务器来说,是很有用的。这种行为可以通过JVM参数**-XX:-ZUncommit**关闭。不过,无论怎么归还,JVM至少会保留Xms参数指定的内存大小,这就是说,当Xmx和Xms一样大的时候,这个参数就不起作用了。
和这个参数一起起作用的还有另一个参数:-「XX:ZUncommitDelay=sec」,默认300秒。这个参数表示不再使用的内存最多延迟多长时间才会被归还给操作系统。因为不再使用的内存不应该立即归还给操作系统,这样会造成频繁的归还和申请行为,所以通过这个参数来控制不再使用的内存需要经过多久的时间才归还给操作系统。
接下来,我们看一下从JDK11到JDK15这5个版本,ZGC都迭代了哪些特性:
JDK 15 (under development)
JDK 14
JDK 13
JDK 12
JDK 11