在垃圾回收中最常用的词就是STW。什么是STW?当GC运行时,为了遍历对象的引用关系,需要应用程序暂停,防止应用程序修改对象的引用关系导致GC标记错误,暂停应用程序就是所谓的Stop The World(简称STW)。但是STW背后的实现原理是什么?应用线程如何暂停,又如何恢复?
STW中涉及的第一个概念就是安全点(safepoint)。safepoint可以理解为代码执行过程中的一些特殊位置,当线程执行到这些位置时,说明虚拟机当前的状态是安全、可控的(安全可控指的是,通过JVM控制线程能找到活跃对象;能够检查或者更新Mutator状态),当Mutator到达这个位置时放弃CPU的执行,让JVM控制线程(VMThread是JVM的控制线程)执行。让Mutator在安全点停止的原因可以总结为两个:让VMThread能够原子地运行,不受Mutator的干扰;实现简单。
其实线程暂停有主动暂停和被动暂停,JVM实现的是主动暂停,在暂停之前,需要让手头的事情做完整以便暂停后能正常恢复。安全点在JVM中非常常见,不仅在GC中使用,在Deoptimization、一些工具类(比如dump heap等)中都会涉及。
由于JVM支持多线程及JVM内部的复杂性,可能同时存在不同的线程执行不同的代码的情况,例如解释器线程解释执行字节码,Java线程执行编译后的代码,线程执行本地代码,还存在JVM内部线程,这些线程也会执行一些并发工作,也会访问Java对象。不同的线程进入安全点的方法不同,下面分别介绍。
对于Mutator线程来说,如果它正处于解释执行状态,即通过解释器对每一条字节码执行,那么此时该如何主动放弃CPU?基本思路是当虚拟机要求解释线程暂停时,解释器会执行完当前的字节码,然后暂停。参考1.4.3节JVM对解释器的实现,虚拟机提供一个正常指令派发表,还提供一个异常指令派发表,需要进入安全点的时候,JVM会用异常指令派发表替换这个正常指令派发表,那么当前字节码指令执行完毕之后再执行下一条字节码指令,就会进入异常指令派发表。
解释线程进入安全点的时间通常是可控的,进入暂停的最大等待时间是一条字节码的执行时间。
编译线程指的是正在执行编译优化代码的线程。JIT将一段字节码片段编译成机器码,可以想象正在执行的机器码不包含让线程主动暂停的指令,所以如果没有额外的处理,编译后的机器代码无法暂停。为了让编译后的代码能够主动暂停,一种有效的方法是在编译后的机器代码中插入一些额外的指令,这些指令可能让编译代码执行时能够主动地暂停。
对于这种方法,有两个问题需要考虑:
1)在什么地方插入额外的指令?如果插入过多的指令,可能会影响编译代码的执行速度,但是插入的指令太少,可能导致编译线程迟迟无法进入暂停状态。
2)插入的额外指令应该是什么样子的?插入指令不应该对编译优化后的机器码产生负面影响(即不影响程序正确运行),同时效率应该足够高。
对于第一个问题,在执行效率和暂停效率之间取得平衡,通常只在一些特殊位置之后才会插入特殊指令,这些特殊位置通常包含函数调用点、函数返回、循环回收等。GC安全点支持和1.4.4节OSR编译替换技术有一些相似之处,虚拟机仅在特定地方做相关功能的支持。表2-2总结了OSR和安全点支持可能发生的位置。
表2-2 OSR和GC安全点支持比较
在JVM中会在上述GC安全点支持的位置上插入额外的指令来判断是否需要暂停。一种实现是设置一个全局状态标记,当需要线程暂停时修改状态值,额外指令可以判断状态是否发生变化,如果发生变化,则进入安全状态并暂停线程的执行。
JVM在Linux中的实现很有代表性,首先在JVM初始化时产生一个全局的轮询页面(Polling Page),当需要编译线程进入安全点时,该轮询页面会被设置为不可读。编译线程在执行过程中如果执行到检查轮询页面的状态,并发现页面不可读,则会产生一个信号量(SIGSEGV),JVM捕获信号量保存编译线程的状态,然后暂停自身的执行,待GC执行结束后恢复状态继续执行。
需要注意的是,编译代码可能访问堆中的对象,而进入安全点以后,GC执行可能会修改对象的位置及引用关系,所以在GC执行中需要对编译代码中引用的对象更新对象引用关系。为了更准确地支持编译后代码对象引用关系的更新,通常需要额外的数据结构存储对象的位置。
在编译代码中需要针对循环进行额外处理,否则遇到一个超大循环时可能导致编译线程长时间无法进入安全点,但是也不需要在循环的回边中每次都插入额外的指令,那样做会影响效率。一种可行的方法是每经过一定循环次数后执行额外的检查指令,在JVM中使用参数UseCountedLoopSafepoints控制是否允许循环间隔检查,并且提供了参数(LoopStripMiningIter)控制循环间隔的步长(默认值为1000),如果发现编译线程长时间无法进入安全点,则可以尝试使用这两个参数进行调整。
如果线程正在执行本地代码(Native Code,如C/C++代码),本地代码访问的内存空间和Java堆空间不是一个,这意味着本地代码不能直接访问Java对象 。理论上本地线程不需要暂停。
但是可能存在这样的情况:GC开始执行,本地线程也在并发执行,突然本地线程执行完毕切换到Java线程执行Java代码。对于这种情况,GC已经发生,但是线程尚未暂停,如何设计合理的机制暂停线程?如果不暂停,线程可能改变对象的引用关系,进而引发GC的正确性问题。
对于这种情况,一个解决方案是:当线程从本地代码执行结束切换到Java代码执行时,让线程暂停执行。当然,JVM中关于Java代码和本地代码的切换设计得相当复杂,这里不做介绍,只介绍在互操作时确保GC的正确性。如果需要了解与互操作相关的更详细的信息,可以参考其他书籍 [1] 。
在虚拟机内部也有一些并发线程,这些线程可能访问Java堆中的对象,也可能并不访问Java堆中的对象。
对于不访问Java堆的线程,例如一些周期性统计线程,仅仅统计虚拟机内部的信息,在整个执行过程中都不访问Java堆,所以对GC完全没有影响,在执行GC操作时无须暂停,不会影响GC的正确性。
对于可能访问Java堆空间对象的并发线程,在GC执行前也需要进入安全点。内部线程进入安全点的方式也是在一些控制代码处主动检查是否需要进入安全点,如果需要进入安全点,则会主动挂起自己,等待GC结束后通过信号量唤醒继续执行,所以在虚拟机内部需要编写额外的代码主动检查是否需要进入安全点。另外,由于虚拟机内部线程可以访问堆空间,为保证GC执行后的正确性,需要特别处理堆空间的对象访问。一种实现是虚拟机内部不直接访问堆空间的对象,而是通过间接方式,例如通过Handle的方式,在GC执行结束后调整Handle,以便线程能正确地访问对象;另外一种实现是虚拟机在进入安全点以后,在GC执行过程中将线程需要处理的对象处理完,待GC完成后,JVM内部并发线程总是从一个全新的状态继续执行。
至此,所有的线程都应该以不同的实现进入安全点。但是正如上面提到的,每种线程进入安全点的机制也不太相同,所以进入安全点花费的时间也不太相同。线程进入安全点的整体示意图如图2-18所示。
图2-18 不同类型的线程进入安全点的示意图
它们分别代表了5种不同的情况,如表2-3所示。
表2-3 不同类型线程进入安全点的情况
[1] Advanced Design and Implementation of Virtual Machines ,中文版为《虚拟机设计与实现:以JVM为例》。