你竟然不懂JVM中垃圾回收基本知识:暂停应用程序STW之安全点?
安全点
在垃圾回收中最常用的词就是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?基本思路是当虚拟机要求解释线程暂停时,解释器会执行完当前的字节码,然后暂停。
参考解释执行那节JVM对解释器的实现,虚拟机提供一个正常指令派发表,还提供一个异常指令派发表,需要进入安全点的时候,JVM会用异常指令派发表替换这个正常指令派发表,那么当前字节码指令执行完毕之后再执行下一条字节码指令,就会进入异常指令派发表。
解释线程进入安全点的时间通常是可控的,进入暂停的最大等待时间是一条字节码的执行时间。
编译线程进入安全点
编译线程指的是正在执行编译优化代码的线程。JIT将一段字节码片段编译成机器码,可以想象正在执行的机器码不包含让线程主动暂停的指令,所以如果没有额外的处理,编译后的机器代码无法暂停。为了让编译后的代码能够主动暂停,一种有效的方法是在编译后的机器代码中插入一些额外的指令,这些指令可能让编译代码执行时能够主动地暂停。
对于这种方法,有两个问题需要考虑:
1)在什么地方插入额外的指令?如果插入过多的指令,可能会影响编译代码的执行速度,但是插入的指令太少,可能导致编译线程迟迟无法进入暂停状态。
2)插入的额外指令应该是什么样子的?插入指令不应该对编译优化后的机器码产生负面影响(即不影响程序正确运行),同时效率应该足够高。
对于第一个问题,在执行效率和暂停效率之间取得平衡,通常只在一些特殊位置之后才会插入特殊指令,这些特殊位置通常包含函数调用点、函数返回、循环回收等。GC安全点支持和1.4.4节OSR编译替换技术有一些相似之处,虚拟机仅在特定地方做相关功能的支持。表2-2总结了OSR和安全点支持可能发生的位置。
在JVM中会在上述GC安全点支持的位置上插入额外的指令来判断是否需要暂停。一种实现是设置一个全局状态标记,当需要线程暂停时修改状态值,额外指令可以判断状态是否发生变化,如果发生变化,则进入安全状态并暂停线程的执行。
JVM在Linux中的实现很有代表性,首先在JVM初始化时产生一个全局的轮询页面(Polling Page),当需要编译线程进入安全点时,该轮询页面会被设置为不可读。编译线程在执行过程中如果执行到检查轮询页面的状态,并发现页面不可读,则会产生一个信号量(SIGSEGV),JVM捕获信号量保存编译线程的状态,然后暂停自身的执行,待GC执行结束后恢复状态继续执行。
需要注意的是,编译代码可能访问堆中的对象,而进入安全点以后,GC执行可能会修改对象的位置及引用关系,所以在GC执行中需要对编译代码中引用的对象更新对象引用关系。为了更准确地支持编译后代码对象引用关系的更新,通常需要额外的数据结构存储对象的位置。
在编译代码中需要针对循环进行额外处理,否则遇到一个超大循环时可能导致编译线程长时间无法进入安全点,但是也不需要在循环的回边中每次都插入额外的指令,那样做会影响效率。一种可行的方法是每经过一定循环次数后执行额外的检查指令,在JVM中使用参数UseCountedLoopSafepoints控制是否允许循环间隔检查,并且提供了参数(LoopStripMiningIter)控制循环间隔的步长(默认值为1000),如果发现编译线程长时间无法进入安全点,则可以尝试使用这两个参数进行调整。
本地线程进入安全点
如果线程正在执行本地代码(Native Code,如C/C++代码),本地代码访问的内存空间和Java堆空间不是一个,这意味着本地代码不能直接访问Java对象[1]。理论上本地线程不需要暂停。
但是可能存在这样的情况:GC开始执行,本地线程也在并发执行,突然本地线程执行完毕切换到Java线程执行Java代码。对于这种情况,GC已经发生,但是线程尚未暂停,如何设计合理的机制暂停线程?如果不暂停,线程可能改变对象的引用关系,进而引发GC的正确性问题。
对于这种情况,一个解决方案是:当线程从本地代码执行结束切换到Java代码执行时,让线程暂停执行。当然,JVM中关于Java代码和本地代码的切换设计得相当复杂,这里不做介绍,只介绍在互操作时确保GC的正确性。如果需要了解与互操作相关的更详细的信息,可以参考其他书籍[2]。
JVM内部并发线程进入安全点
在虚拟机内部也有一些并发线程,这些线程可能访问Java堆中的对象,也可能并不访问Java堆中的对象。
对于不访问Java堆的线程,例如一些周期性统计线程,仅仅统计虚拟机内部的信息,在整个执行过程中都不访问Java堆,所以对GC完全没有影响,在执行GC操作时无须暂停,不会影响GC的正确性。
对于可能访问Java堆空间对象的并发线程,在GC执行前也需要进入安全点。内部线程进入安全点的方式也是在一些控制代码处主动检查是否需要进入安全点,如果需要进入安全点,则会主动挂起自己,等待GC结束后通过信号量唤醒继续执行,所以在虚拟机内部需要编写额外的代码主动检查是否需要进入安全点。
另外,由于虚拟机内部线程可以访问堆空间,为保证GC执行后的正确性,需要特别处理堆空间的对象访问。一种实现是虚拟机内部不直接访问堆空间的对象,而是通过间接方式,例如通过Handle的方式,在GC执行结束后调整Handle,以便线程能正确地访问对象;
另外一种实现是虚拟机在进入安全点以后,在GC执行过程中将线程需要处理的对象处理完,待GC完成后,JVM内部并发线程总是从一个全新的状态继续执行。
安全点小结
至此,所有的线程都应该以不同的实现进入安全点。但是正如上面提到的,每种线程进入安全点的机制也不太相同,所以进入安全点花费的时间也不太相同。线程进入安全点的整体示意图如图2-18所示。
它们分别代表了5种不同的情况,如表2-3所示。
扩展阅读:垃圾回收器请求内存设计
在Linux平台上,一些GC实现(如JVM)中使用mmap函数首先申请一大块内存,然后自己管理对象的分配;一些GC实现使用glibc库函数直接调用malloc函数满足对象的分配;还有一些GC实现使用第三方库函数(如TCMalloc)管理对象的分配。不同的选择其考量是什么?
要理解GC设计的策略,需要理解malloc/free的实现。先来看一段C程序员使用malloc/free管理内存代码片段:
int* pInt = (int*) malloc(10 * sizeof(int));
//使用pInt,直到free分配的内存才释放
free(pInt);
一个问题是free是如何知道释放10个int大小的内存空间?在函数原型中free只是接收1个参数:待释放的指针,所以这个指针指向的地址一定经过特殊的处理,让free在执行时不需要内存的长度空间。
典型的实现是在使用malloc时对分配的内存做额外的变化,多申请一块空间用于存储内存的实际长度,这样使用free的时候按照同样的约定就可以找到内存的实际长度。下面给出malloc和free的功能描述:
函数malloc(size)实际完成的功能可以分解为:
1)实际向OS分配的内存长度为size+4,其中4字节用于存储内存的长度;假设OS返回的内存地址为pStart。
2)将长度写入地址开始的位置,即*((int*)pStart)=size。
3)返回真实可用的内存空间给应用,即(void*)((char*)pStart + 4)。
函数free(pPointer)实际完成的功能可以分解为:
1)获得指针指向的内存真实起始地址,即char* pRealStart =
(char*)pPointer-4。
2)获得应用实际使用的内存长度,即int size = *((int*)pRealStart)。
3)通过OS的API真正释放内存起始位置为pRealStart,长度为size+4的内存空间。
当然类库在malloc中还可以额外分配更多的内存用于其他功能,例如校验。这样的设计就会导致真实分配的内存超过用户请求的内存,意味着在使用库函数的分配/释放函数时有额外的内存消耗。
另外一种管理内存的方案是直接向OS请求一大块内存空间,即使用类似mmap(Linux系统的API)的方式,由VM提供内存分配和回收的功能,VM通常不需要记录内存使用的长度(在JVM中内存的长度信息通过类的元数据提供),这样就可以避免这种内存消耗。在一些基准测试中,发现直接使用库函数的分配/释放与VM直接管理内存的方式相比会有额外的5%~15%的内存消耗。
由于glibc使用弱符号引用的方式允许用户提供运行时的malloc/free,这样就可以使用一些成熟的类库(如TCMalloc)来提供高效的malloc/free。
TCMalloc有一个非常大的优点——高效,基于线程/CPU的缓存分配方式,能极大地提高应用运行的效率。当然TCMalloc也有不足之处,可能存在一定的内存浪费。除此之外,虽然TCMalloc是基于线程/CPU的缓存分配方式,避免了多线程分配的锁竞争问题,但是效率与后文介绍的TLAB的效率还是略有差异。关于TCMalloc的更多内容可以参考官方文档[1]。
最后做一个简单的总结,直接使用库函数malloc甚至TCMalloc可能存在的问题如下:
1)回收效率不够高,内存使用free释放后,不一定会被立即重复使用。
2)内存使用效率不够高,在malloc、new库函数中除了分配真正的对象空间外,还会附加一些额外占用内存的信息,比如分配的长度、越界信息。
3)分配效率不够高,通常在malloc中需要对堆进行加锁,用于保证多个进程同时竞争堆空间的分配。即便TCMalloc中优化了基于线程的分配,也无法达到Mutator中TLAB的分配效率。
本文给大家讲解的内容是JVM中垃圾回收相关的基本知识:安全点,解释+编译+本地+JVM内部并发线程进入安全点
- 下篇文章给大家讲解的内容是JVM垃圾回收器详解:串行回收,分代堆内存管理概述
- 感谢大家的支持!