垃圾收集器与GC日志
ZGC收集器(Z Garbage Collector,全并发、超低延迟 10ms)
ZGC是一款JDK11中新加入的具有实验性质的低延迟垃圾收集器,ZGC可以说源自于Azul System公司开发的C4(Concurrent Continuously Compacting Collector)收集器
参考文章:https://wiki.openjdk.java.net/display/zgc/Main
http://cr.openjdk.java.net/~pliden/slides/ZGC-Jfokus-2018.pdf
目标
- 1.支持TB量级的堆。一般生产环境的硬盘还没有上TB呢,这应该可以满足未来十年内,所有Java应用的需求了吧
- 2.最大GC停顿时间不超过10ms.目前一般线上环境运行良好的Java应用Minor GC停顿时间在10ms左右,Major GC一般都需要100ms以上(G1可以调节停顿时间,但是如果调的过低的话,反而会适得其反),之所以能做到这一点是因为它的停顿时间主要跟Root扫描有关,而Root数量和堆大小是没有关系的
- 3.奠定未来GC特性的基础
- 4.最糟糕的情况下吞吐量会降低15%。这都不是事,停顿时间足够优秀。至于吞吐量,通过扩容分分钟解决。另外,Oracle官方提到了它的最大优点是:它的停顿时间不会随着堆的增大而增长!也就是说,几十G堆的停顿时间是10ms一下,几百G甚至上T堆的停顿时间也是10ms一下
不分代(暂时)
单代,即ZGC[没有分代]。我们知道以前的垃圾回收器之所以分代,是因为源于"[大部分对象朝生夕死]"的假设,事实上大部分系统的对象分配行为也确实符合这个假设,那么为什么ZGC就不分代呢?因为分代实现起来麻烦,作者就先实现出一个比较简单的单代版本。后续会优化
ZGC的内存布局
ZGC收集器是一款基于Region内存布局的,暂时不设分代的。使用了读屏障、颜色指针等技术来实现可并发的标记-整理算法的,以低延迟为首要目标的一款垃圾收集器。
ZGC的region可以具有如图所示的大、中、小三类容量:
- 1.小型Region(Small Region):容量固定为2MB,用户放置小于256KB的小对象(x < 256KB, x为对象大小)
- 2.中型Region(Medium Region):容量固定为32MB,用户放置大于等于256KB但小于4MB(256KB <= x < 4MB)
- 3.大型Region(Large region):容量不固定,可以动态变化,但必须为2MB的整数倍,用于放置4MB或以上的大对象,每个大型Region中只会存放一个大对象,这也预示着虽然名字叫做"大型Region",但它的实际容量完全有可能小于中型Region,最小容量可低至4MB.大型Region在ZGC的实现中是不会被重分配的(重分配是ZGC的一种处理动作,用于复制对象的收集阶段),因为复制一个大对象的代价非常高昂。
NUMA-aware
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位被分为:
18Bit:预留给以后使用
1Bit:Finalizable标识,此位与并发引用处理有关,它表示这个对象只能通过finalizer才能访问
1Bit:Remapped标识,设置此位的值后,对象未指向relocation set中(relocation set标识需要GC的Region集合)
1Bit:Marked1标识
1B:Marked0标识,和上面的Marked1都是标记对象用于辅助GC
42位:对象的地址(所以它可以支持2^42 = 4T内存)
为什么有2个mark标记?
每一个GC周期开始时,会交换使用过的标记位,使上次GC周期中修正的已标记状态失效,所有引用都变成未标记。
GC周期1:使用mark 0,则周期结束后所有引用mark标记都会成为01
GC周期2:使用mark2,则期待的mark标记10,所有引用都能被重新标记
通过设置ZGC后对象指针分析我们可知,对象指针必须是64位,那么ZGC就无法支持32位操作系统,同样的也就无法支持压缩指针了(CompressedOops,压缩指针也是32位)。
颜色指针的三大优势:
- 1.一旦某个Region的存活对象被一走之后,这个Region立即就能够被释放和重用掉,而不必等待堆中所有指向该Region的引用都被修正后才能清理。这使得理论上只要还有一个空闲Region,ZGC就能完成收集
- 2.颜色指针可以大幅减少在垃圾收集过程中内存屏障的使用数量,ZGC只使用了读屏障
- 3.颜色指针具备强大的扩展性,它可以作为一种可扩展的存储结构用来记录更多与对象标记、重定位过程相关的数据,以便日后进一步提高性能
读屏障
之前的GC都是采用Write Barrier,这次ZGC采用了完全不同的方案读屏障,这个是ZGC一个非常重要的特性。在标记和移动对象的阶段,每次【从堆里对象的引用类型中读取一个指针】的时候,都需要加上一个Load Barrier.那么该如何理解它呢?看下面的代码,第一行代码我们尝试读取堆中的一个对象引用obj.fieldA并赋给引用o(fieldA也是一个对象时才会加上读屏障)。如果这时候对象在GC时被移动了,接下来JVM就会加上一个读屏障,这个屏障会把读出的指针更新到对象的新地址上,并且把堆里的这个指针"修正"到原本的字段里。这样就算GC把对象移动了,读屏障也会发现并修正指针,于是应用代码就永远都会持有更新后的有效指针,而且不需要STW.那么,JVM时如何判断对象被移动过呢?就是利用上面提到的颜色指针,如果指针是Bad Color,那么程序还不能往下执行,需要【slow path】,修正指针;如果指针是Good Color,那么正常往下执行即可:
// Load Barrier
Object o = obj.fieldA; // Loading an object reference from heap
<load barrier needed hear>
Object p = o; // No barrier, not a load from heap
o.doSomething(): // No barrier, not a load from heap
int i = obj.fieldB; // No barrier, not an object reference
这个动作是不是非常像JDK并发中用到的CAS自旋?读取的值发现已经失效了,需要重新获取,而ZGC这里是之前持有的指针由于GC后失效了,需要通过读屏障修正指针。后面3行代码都不需要加读屏障:Object p = o 这行代码并没有从堆中读取数据;o.doSomething()也没有从堆中读取数据;obj.fieldB不是对象引用,而是原子类型。
正是因为Load Barrier的存在,所以会导致配置ZGC的应用的吞吐量会变低。官方的测试数据是需要多出额外4%的开销
// Load Barrier
mov 0x20(%rax), %rbx // Object o = obj.fieldA;
test %rbx, (0x16)%r15 // Bad color ?
jnz slow_path // Yes -> Enter slow path and mark/relocalte/remap, ajust 0x20(%rax) and %rbx
那么判断对象是Bad Color还是Good Color的依据是什么呢?就是根据前面提到的Colored Pointers的4个颜色位。当加上读屏障时,根据对象指针中这4位的信息,就能知道当前对象是Bad/Good Color了。
PS:既然低42位指针可以支持4T内存,那么能否通过预约更多位给对象地址来达到更大内存的目的呢?答案肯定是不可以。因为目前主板地址总线最宽只有48bit,4位是颜色位,就只剩44位了,所以受限于目前的硬件,ZGC最大只能支持15T的内存,JDK13就把最大支持堆内存从4T扩大到16T
ZGC运作过程
ZGC的运作过程大致可划分为以下4个大的阶段:
- 1.并发标记(Concurrent Mark):与G1一样,并发标记是遍历对象图做可达性分析的阶段,它的初始标记(Mark Start)和最终标记(Mark End)也会出现短暂的停顿,与G1不同的是,ZGC的标记是在指针上而不是对象上进行的,标记阶段会更新染色指针中的Marked 0、Marked 1标志位
- 2.并发预备重分配(Concurrent Prepare for Relocate):这个阶段需要根据特定的查询条件统计得出本次回收过程要清理哪些Region,将这些Region组成重分配集(Relocation Set).ZGC每次回收都会扫描所有的Region,用范围更大的扫描成本换取省去G1中记忆集的维护成本。
- 3.并发重分配(Concurrent Relocate):重分配是ZGC执行过程中的核心阶段,这个过程要把重分配集中的存活对象复制到新的Region上,并为重分配集中的每个Region维护一个转发表(Forward Table),记录从旧对象到新对象的转向关系。ZGC收集器能仅从引用上就明确得知一个对象是否处于重分配集之中,如果用户线程此时并发访问到了位于重分配集中的对象,这次访问将会被预置的内存屏障(读屏障)所截获,然后立即根据Region上的转发表记录将访问到新复制的对象上,并同时修正更新该引用的值,使其直接指向新对象,ZGC将这种行为称为指针的"自愈(Self-Healing)"能力。
(ZGC的颜色指针因为"自愈(Self-Healing)"能力,所以只有第一次访问旧对象会变慢,一旦重分配集中某个Region的存活对象都复制完毕后,这个Region就可以立即释放用于新对象的分配,但是转发表还得留着不能释放掉,因为可能还有访问在使用这个转发表) - 4.并发重映射(Concurrent Remap):重映射所做的就是修正整个堆中指向重分配集中旧对象的所有引用,但是ZGC中对象引用存在"自愈"功能,所以这个重映射操作并不是很迫切。ZGC很巧妙地把并发重映射要做地工作,合并到了下一次垃圾收集循环中地并发标记阶段里去完成,反正他们都是要遍历所有对象地,这样合并旧节省了一次遍历对象图的开销,一旦所有指针都被修正之后,原来记录新旧对象关系的转发表就可以释放掉了
ZGC存在的问题
ZGC最大的问题是浮动垃圾,ZGC的停顿时间在10m以下,但是ZGC的执行时间还是远远大于这个时间的。加入ZGC全过程需要执行10分钟,在这个期间由于对象分配速率很高,将创建大量的系对象,这些对象很难进入当次GC,所以,只能在下次GC的时候进行回收,这些只能等到下次GC才能回收的对象就是浮动垃圾.(ZGC没有分代概念,每次都需要进行全堆扫描,导致一些"朝生夕死"的对象没能及时地被回收)
解决方案
目前唯一的办法就是增大堆的容量,使得程序得到更多的喘息空间,但是这个也是一个治标不治本的方案。如果需要从根本上解决这个问题,还是需要引入分代收集,让新生对象都在一个专门的区域中创建,然后专门针对这个区域进行更频繁、更快的收集
ZGC是如何解决漏标问题的?
ZGC在并发阶段通过多种技术和机制来解决漏标问题,确保垃圾收集的准确性和一致性。漏标问题的出现通常是因为在并发
标记过程中,应用线程可能会修改对象引用,导致某些对象未被标记为存货。为了避免这种情况,ZGC采用了以下关键技术:
染色指针(Colored Poninters)
ZGC通过在对象引用指针中嵌入额外的标记信息(即颜色),将对象的状态直接编码到指针中。这些染色指针允许ZGC在访问
对象时立即知道改对象的标记状态
- 1.指针中的颜色信息:ZGC在64位的对象引用指针中嵌入了几位来存储颜色信息。这些颜色信息用于表示对象的状态,
例如是否已经被标记、是否正在移动、或者是新的对象等。 - 2.读屏障(Load Barrier):当应用线程访问一个新对象时,ZGC的读屏障会检查这个对象的染色指针,确保对象被正确标记或
处理。如果一个对象还未被标记但已经被应用,ZGC会通过读屏障触发相应的操作,防止漏标
读屏障(Load Barrier)
读屏障时一种在对象引用被读取时执行的特殊检查机制。ZGC在并发标记阶段使用读屏障来解决并发修改引用导致的漏标问题
- 1.作用:当应用线程试图访问一个对象时,读屏障会检查改对象的标记状态。如果该对象的标记状态不符合预期(例如对象违背标记为
存活),读屏障会将该对象标记为存货,确保它不会被垃圾回收 - 2.如何避免漏标:通过在每次访问对象时触发读屏障,ZGC可以捕捉到应用线程对引用的修改,确保即使对象的引用在标记过程中
被修改,也不会导致漏标
并发标记和颜色修正
ZGC结合染色指针和读屏障,在并发标记阶段通过以下步骤防止漏标
- 1.初始标记(Initial Mark):这个阶段是一个短暂的Stop-The-World(STW)暂停,标记从GC Roots可达的对象,确保基本的标记起点
- 2.并发标记(Concurrent Mark):在这一阶段,标记过程与应用线程并发进行。任何在标记过程中被访问的对象,都会通过读屏障检查和更新其标记状态
- 3.重新标记(Remark)::这个阶段是另一个短暂的STW暂停,用于处理在并发标记过程中遗漏的对象。这是最后一次确保所有存活对象都被正确标记
- 4.颜色修正(Color Correction):ZGC在标记阶段通过颜色修正机制确保对象的状态始终一致。这意味着如果某个对象在标记过程中(例如从未标记到已标记)
ZGC会立即进行颜色修正,避免漏标
并发重定位(Concurrent Relocation)
在对象的移动过程中,ZGC确保在移动过程中对象引用的所有访问都通过读屏障,从而防止漏标
- 1.自愈(Self-Healing)机制:ZGC使用自愈机制,即时在对象移动过程中,如果访问未完成标记的对象,读屏障仍然会确保这些对象被正确标记或处理
总结
ZGC通过颜色指针、读屏障、颜色修正和自愈机制等多种技术组合,成功解决了并发中的漏标问题。这些技术确保在并发标记阶段,无论应用线程
如何修改对象引用,所有存活的对象都能被正确标记,从而避免了垃圾收集过程中的任何不一致性。这使得ZGC能够在提供极低延迟的同时,仍然保证垃圾收集的准确性和完整性
ZGC的Mark0和Mark1的作用是什么?
ZGC的Mark0和Mark1是ZGC并发标记阶段中的两个关键步骤,分别用于处理不同的任务,以确保垃圾回收的准确性和效率。这两个步骤的主要作用如下:
Mark0阶段
作用
- 1.Mark0是ZGC并发标记过程中的第一个阶段。它主要负责标记从GC Roots可达的对象,并初始化整个并发标记过程
- 2.在Mark0阶段,ZGC通过遍历GC Roots(静态变量、栈上的局部变量、寄存器中的引用等),将所有直接可达的对象标记为存活。这个阶段通常是短暂的Stop-The-World(STW)暂停
- 3.Mark0阶段的标记结果作为后续并发标记的基础。它确保了所有从GC Roots开始的对象都被正确标记,避免漏标
具体任务
- 1.标记从GC Roots可达的对象
- 2.初始化并发标记所需的数据结构
- 3.确保初始标记阶段不遗漏任何根对象的标记
Mark1阶段
作用
- 1.Mark1是ZGC并发标记过程中的另一个关键阶段,通常是并发标记的第二个阶段。与Mark0不同,Mark1主要负责处理在Mark0之后对象的引用变化,并确保这些对象能够正确标记
- 2.在Mark1阶段,ZGC会继续堆堆中的对象进行遍历和标记,以确保所有存活的对象(包括在Mark0之后新创建或新引用的对象)都能被正确标记为存活
具体任务
- 1.并发地遍历和标记对象图中的其余对象,这个阶段通常与应用线程并发执行
- 2.处理Mark0阶段后对象引用的变化,确保这些变化不会导致漏标
- 3.确保整个堆中的所有可达对象在标记过程中都能被正确标记
总结
在ZGC的并发标记过程中,Mark0和Mark1分别承担了不同但相互补充的任务:
- 1.Mark0主要负责从GC Roots开始的初始标记,确保垃圾回收有一个可靠的起点
- 2.Mark1则负责继续标记堆中的其余对象,确保在并发标记过程中,没有存活对象被漏标
这两个阶段共同作用,确保ZGC在进行垃圾回收时,能够准确地标记和回收内存,同时保持极低地暂停时间
ZGC是如何解决漏标问题的
CMS和G1中是采用Write Barrier来解决对象引用发生变化的,而ZGC是采用Load Barrier来解决的,通过在每次访问对象时触发读屏障,ZGC可以捕捉到应用线程引用的修改,确保即时对象的引用在标记过程中被修改,也不会导致漏标问题
ZGC参数设置
启用ZGC比较简单,设置JVM参数即可:-XX:+UnlockExperimentalVMOptions 【-XX:+UseZGC】。调优也并不难,因为ZGC调优参数并不多,远不像CMS那么复杂。它和G1一样,可以调优的参数都比较少,大部分工作JVM能很好的自动完成
ZGC触发时机
ZGC目前有4种机制触发GC:
- 1.定时触发:默认为不适用,可通过ZCollectionInterval参数配置
- 2.预热触发,最多三次,在堆内存达到10%、20%、30%时触发,主要是统计GC时间,为其他GC机制使用
- 3.分配速率,基于正态分布统计,计算内存99.9%可能的最大分配速率,以及此速率下内存将要耗尽的时间点
- 4.主动触发(默认开启,可通过ZProactive参数配置)距离上次GC堆内存增长10%,或超过5分钟时,对比距离上次GC的间隔时间(49 * 一次GC的最大持续时间),超过则触发
如何选择垃圾收集器
- 1.优先调整堆的大小让服务器自己来选择
- 2.如果内存小于100M,使用串行收集器
- 3.如果是单核,并且没有停顿时间的要求,串行或JVM自己选择
- 4.如果允许停顿时间超过1秒,选择并行或者JVM自己选
- 5.如果响应时间最重要,并且不能超过1秒,使用并发收集器
- 6.4G以下可以用parallel,4-8G可以用ParNew + CMS,8G以上可以用G1,几百G以上用ZGC
在并发垃圾收集器在第一次GC没有进行完,可以发起第二次GC吗?
CMS垃圾收集器
- 1.如果第一轮GC还没有完成,而此时由于用户线程的继续执行导致又触发了新的GC请求,那么第二轮GC不会立即执行,而是需要等待第一轮GC完成后才会开始
- 2.GC触发机制
当队中的老年代使用量达到一定阈值时,会触发CMS GC以回收内存。如果在一次CMS GC还没有完成时,用户线程继续分配对象,导致老年代再次达到触发阈值,理论上需要再次进行GC - 2.CMS GC 重入性
CMS垃圾收集器本身并不支持"重入性",即它同时进行多次垃圾收集。CMS的设计中,一次GCGC需要完成它的所有阶段后,才能开始新的GC。
因此,如果在一次CMS GC还没完成时,用户线程的内存分配再次触发了GC请求,那么新的GC请求将会背延迟,直到当前的CMS GC完成 - 3.并发模式失败(Concurrent Mode Failure)
如果在CMS执行的过程中,用户线程分配内存速度过快,导致老年代空间不足,无法等待CMS完成,此时JVM会触发"并发模式失败"(Concurrent Mode Failure)
这种情况下,JVM会切换到一个单线程的"Searial Old" GC执行一次Stop-The-World(STW)全堆回收,以保证系统能够继续运行,Searial Old GC是一种较慢但确保回收的垃圾收集方式
G1垃圾收集器
G1收集器在一次GC尚未完成时,如果又触发了新的GC请求,第二次GC不会打断第一次GC,而是会在第一次GC完成后开始。这一点与CMS类似,但G1的优势在于它的设计更灵活,能够更好地控制GC暂停时间并减少进入Full GC的概率。
ZGC垃圾收集器
- 1.如果在ZGC正在进行一次垃圾回收时(例如正在进行并发标记或并发重定位),用户线程分配新对象的速度很快,导致需要再次进行垃圾回收,ZGC不会因为新的GC请求而停止当前的GC操作。相反,他会继续完成当前的GC操作,同时计划和开始新的GC周期
- 2.由于ZGC的设计理念是"全并发",因此它能够非常灵活地处理多个垃圾回收周期的重叠情况。新的GC周期可以与旧的GC周期重叠执行,而不会导致显著的暂停时间增加
为什么需要STW? 安全点、安全区域又是什么?
暂停线程 暂停所有有可能导致引用关系变动的线程
为什么要暂停? 如果引用关系一直在变的话,GC不干净
遍历线程,发起挂起信号。
主动式:JVM的实现方式,借助安全点实现
抢先式: 给每个线程发送暂停信号
在漏标问题中,对于引用发生变化的对象,它会被保存到OopMap里面记录
线程阻塞前需要更新OopMap,那么什么时候记录呢?
枚举根节点
从可达性分析中从GC Roots节点找引用链这个操作为例,可作为GC Roots的节点主要在全局性的引用(例如常量或类静态属性)与执行上下文(例如栈帧中的本地变量表)中,现在很多应用仅仅方法区就有数百兆,如果要逐个检查这里面的引用,那么必然会消耗很多时间。另外,可达性分析对执行时间的敏感还体现在GC停顿上,因为这项工作必须在一个能确保一致性的快照中进行——这里"一致性"的意思是指在整个分析期间整个执行系统看起来就像被冻结在某个时间点上,不可以出现分析过程中对应引用关系还在不断变化的情况。该点不满足的话分析结果准确性就无法得到保证。这点是导致GC进行时必须停顿所有Java执行线程(Sun将这件事称为"Stop The World")的其中一个重要原因,即使时在号称(几乎)不会发生停顿的CMS收集器中,枚举根节点也是必须要停顿的。
由于目前的主流Java虚拟机使用的都是准确式GC,所以当执行系统停顿下来后,并不需要一个不漏地检查完所有执行上下文和全局的引用位置,虚拟机应当是有办法直接得知哪些地方存放着对象引用。在HotSpot的实现中,是使用一组称为OopMap的数据结构来达到这个目的的,在类加载完成的时候,HotSpot就把对象内什么偏移量上是什么类型的数据计算出来,在JIT编译过程中,也会在特定的位置记录下栈和寄存器中哪些位置是引用。这样,GC在扫描时就可以直接得知这些信息了。
例如
[Verified Entry Point]
0x026eb730: mov $eax,-0x8000(%esp)
....
0x026eb7a9: call 0x026e83e0; OopMap{ebx=Oop [16]=Oop off=142}
0x026eb7ae: push $0x83c5c18
0x026eb7b3: call 0x026eb7b8
0x026eb7b8: pusha
0x026eb7b9: call 0x0822bec0
0x026eb7be: hlt
上面代码时HotSpot Client VM生成的一段String.hashCode()方法的本地代码,可以看到在0x026eb7a9处的call指令有OopMap记录,它指明了EBX寄存器和栈中偏移量为16的内存区域处各有一个普通对象指针(Ordinary Object Pointer)的引用,有效范围为从call指令开始知道0x026eb730(指令流的起始位置)+142(OopMap记录的偏移量)=0x026eb7be,即hlt指令为止
准确式GC
早之前的Exact VM版本因为使用准确式内存管理(Exact Memory Management也可以叫Non-Conservative/Accurate Memory Management)而得名,即虚拟机可以知道内存中某个位置的数据具体是什么类型。比如内存中有一个32位的整数123456,它到底是一个reference类型指向123456的内存地址还是一个数值位123456的整数。虚拟机将有能力分辨出来,这样才能在GC的时候准确判断堆上的数据是否还可能被使用。由于使用了准确式内存管理,Exact VM可以摒弃以前Classic VM基于handler的对象查找方式(原因是进行GC后对象将可能会被移动位置。如果将地址为123456的对象移动到654321,在没有明确信息表明内存中哪些数据是reference的前提下,虚拟机是不敢把内存中所有为123456的值改成654321,所以要使用句柄来保持reference值的稳定),这样每次定位对象都少了一次间接查找的开销,提升执行性能
安全点(如果线程随便哪个位置都可以停下来,这个问题就会简单很多)
在OopMap的协助下,HotSpot可以快速且准确地完成GC Roots枚举,但一个很现实地问题随之而来:可能导致引用关系变化,或者说OopMap内容变化地指令非常多,如果为每一条指令都生成OopMap,那将需要大量的额外空间,这样GC的空间成本将会变得很高。
实际上,HotSpot也的确没有为每条指令都生成OopMap,前面已经提到,只是在"特定位置"记录了这些信息,这些位置称为安全点(Safepoint),即程序执行时并非在所有地方都能停顿下来开始GC,只有在到达安全点时才能暂停。Safepoint的选定既不能太少以至于让GC等待时间太长,也不能过于频繁以至于过分增大运行时的负荷。所以,安全点的选定基本上是以程序"是否具有让程序长时间执行的特征"为标准进行选定的——因为每条指令执行的时间都非常短暂,程序不太可能因为指令流长度太长这个原因而过长时间运行,"长时间执行"的最明显特征就是指令序列复用,例如方法调用、循环跳转、异常跳转等。所以具有这些功能的指令才会产生Safepoint.
对于Safepoint.另一个需要考虑的问题是如何在GC发生时让线程(这里不包括执行JNI调用的线程)都"跑"到最近的安全点上再停顿下来。这里有两种方案可供选择:抢先式中断(Preemptive Suspension)和主动式中断(Voluntary Suspension)。其中抢先式中断不需要线程的执行代码主动去配合,在GC发生时,首先把所有线程全部中断,如果发现有线程中断的地方不在安全点上,就恢复线程,让它"跑"到安全点上。现在几乎没有虚拟机实现采用抢先式中断来暂停线程从而响应GC事件。而主动式中断的思想是当GC需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志位真时就自己中断挂起,轮询标志的地方和安全点是重合的,另外再加上创建对象需要分配内存的地方。
例如
0x01b6d627:call 0x01b2b210; OopMap{[60]=OOp off=460}
0x01b6d62c:nop
0x01b6d62d:test %eax,0x160100
0x01b6d633:mov 0x50(%esp),%esi
0x01b6d637:cmp %eax,%esi
下面代码中的test指令是HotSpot生成的轮询指令,当需要暂停线程时,虚拟机把0下0x60100的内存页设置为不可读,线程执行到test指令时就会产生一个自陷异常信号,在预先注册的异常处理器中暂停线程实现等待,这样一条指令便完成安全点轮询和触发线程中断
安全点同时解决了STW(暂停的位置)和更新OopMap(更新对象引用关系的位置)的问题
从Linux层面来说,可以调用API来暂停线程,代码量会比较大,JIT还要把它转成硬编码,难度也大。安全点现在的解决方案:
通过内存中断实现,内存页memory page 可读可写可执行,JIT需要这几个属性,如果想要安全性更高,需要把可执行禁掉。Mac上JIT是不工作的,在合适安全点的地方插入一段代码 test %eax,os_poling_page.安全点到达实际就是用户执行到test指令的实际,这条指令会改变状态寄存器状态,使得内存页变得不可读不可写,触发段异常,SIGSEGV内核捕获之后暂停线程
安全区域
使用Safepoint似乎已经完美地解决了如何进入GC的问题,但实际情况却并不一定,Safepoint机制保证了程序执行时,在不太长的时间内就会遇到可进入GC的Safepoint.但是,程序"不执行"的时候呢?所谓的程序不执行就是没有分配CPU时间,典型的例子就是线程处于Sleep状态或者Blocked状态,这时候线程无法响应JVM的中断请求,"走"到安全的地方去中断挂起,JVM也显然不太可能等待线程重新被分配CPU时间。对于这种情况,就需要安全区域(Safe Region)来解决。安全区域是指在一段代码片段之中,引用关系不会发生变化。在这个区域中的任意地方开始的GC都是安全的。我们也可以把Safe Region看作是被扩展了的Safepoint。
在线程执行到Safe Region中的代码时,首先标识自己已经进入了Safe Region,那样,当在这段时间里JVM要发起GC时,就不用管标识自己为Safe Region状态的线程了。在线程要离开Safe Region时,它要检查系统是否已经完成了根节点枚举(或者是整个GC过程),如果完成了,那线程就继续执行,否则它就必须等待知道收到可以安全离开Safe Region的信号为止。