本文是一篇英文翻译转载文章,主要介绍了 Safepoints。
原英文链接:https://psy-lob-saw.blogspot.com/2015/12/safepoints.html
在过去的一年里,我做了几次关于性能分析和 JVM 运行时/执行的演讲,在这两次演讲中,我都发现自己遇到了安全点(Safepoint)这个话题。大多数人对安全点的存在一无所知,而我通常会在一屋子人中找到一两个对这个术语有所了解的开发人员。这并不令人惊讶,因为安全点并不是 Java 语言规范的一部分。但是,由于安全点是每一个 JVM 实现(据我所知)的一部分,并且扮演着重要角色,所以一下是我对这个概述的拙劣尝试。
什么是 Safepoint?
最近有人问我:“安全点是像安全词一样的东西吗?” 简短的回答是 “完全不是”,但因为我喜欢这个比喻,所以我会继续使用它:
想象一下,一个 JVM 里挤满了忙碌的变异线程(Mutator Threads),它们挥汗如雨地在堆上进行变异操作。有些线程甚至(天哪!)共享了可变状态。它们肆无忌惮地并发修改彼此的状态,简直像野兽一样。有些线程则躲在角落里独自修改自己的状态(迟早会把自己搞瞎)。突然,一块霓虹灯牌闪亮,上面写着 “PINEAPPLES”(Stop-the-World)。变异线程一个接一个停下它们在堆上的疯狂折腾,静静等待,汗水滴落。当最后一个变异线程停下时,一群精灵(gc)走了进来,清空烟灰缸,装满所有饮料,清理地上的水洼,然后以最快的速度消失,回到北极。霓虹灯牌熄灭,线程们又重新开始它们的狂欢 ……
在网上可以找到许多关于安全点的引用,接下来是我尝试使用更细致入微的内容,从这一点开始,不再使用 “汗流浃背的变异线程”。
- 安全点(Safepoint)是指线程执行过程中,其状态能被清晰描述的一段范围。变异线程(Mutator Threads)是那些操作 JVM 堆的线程(所有 Java 线程都是变异线程,非 Java 线程在调用与堆交互的 JVM API 时也可能被视为变异线程)
- 在安全点上,变异线程与堆的交换处于一个已知且定义明确的状态。这意味着栈上的所有引用都被映射(位于已知位置),并且 JVM 能够完全追踪这些引用。只要线程保持在安全点,我们就可以安全地操作堆和栈,确保线程在离开安全点时,其对世界的识图保持一致
这一机制在 JVM 需要检查或更改堆时尤为重要,例如进行垃圾回收(GC)或出于其他多种原因。如果栈上的引用未被追踪,而 JVM 执行垃圾回收,可能会错误地认为某些对象已不再存活(尽管它们仍被栈上的引用所指向)而将其回收,或者可能在移动某个对象时未更新栈上的引用,导致内存损坏。
因此,JVM 需要一种方法将线程带入安全点(并保持在安全点),以便执行各种运行时“魔法”操作。以下是 JVM 在所有变异线程到达安全点并被锁定(即全局安全点)时才能执行的部分活动列表,这些活动有时被称为安全点操作:
- Some GC phases (the Stop The World kind)
- JVMTI stack sampling methods (not always a global safepoint operation for Zing)
- Class redefinition
- Heap dumping
- Monitor deflation (not a global safepoint operation for Zing)
- Lock unbiasing
- Method deoptimization (not always)
- And many more!
Azul 公司的 John Cuthbertson 在 2014 年 JavaOne 大会上发表了一场精彩的演讲,详细介绍了安全点的背景以及除垃圾回收外的其他安全点操作细节(我们在 Azul 认为垃圾回收问题已解决,因此该演讲聚焦于其他需要暂停线程的原因)。
需要注意的是,请求全局安全点和线程安全点之间的区别仅在某些 JVM 实现中存在(例如Azul Systems的Zing JVM,提醒:我为Azul工作)。在 OpenJDK/Oracle JVM 中没有这种区别。这意味着 Zing 可以单独将某个线程带入安全点。
总结如下:
- 安全点是 JVM 实现中的一个常见细节。
- 它们用于暂停变异线程,以便 JVM 进行“修复”操作。
- 在 OpenJDK/Oracle JVM 中,每次安全点操作都需要全局安全点。
- 所有当前 JVM 都有对全局安全点的某些需求。
我的线程什么时候处于安全点?
因此,将线程置于安全点允许 JVM 继续进行其托管运行时的“魔法表演”,太棒了!这个酷炫的状态何时发生呢?
- 如果一个 Java 线程被锁或同步块阻塞、等待监听器、驻留或在阻塞 IO 上阻塞,那么它就处于安全点。本质上,这些都是 Java 线程的有序调度事件,并且是将线程带到安全点之前进行整理的一部分
- Java 线程在执行 JNI 代码时处于安全点。在跨越本机调用边界之前,堆栈保持一致状态,然后移交给本机代码。这意味着线程仍然可以在安全点上运行
- 执行字节码的 Java 线程不在安全点(或者至少 JVM 不能假定它在安全点)
- 被操作系统中断但不在安全点的 Java 线程在取消调度之前不会被带到安全点
JVM 和正在运行的 Java 线程围绕安全点有以下关系:
- JVM 不能强制任何线程进入安全点状态
- JVM 可以阻止线程离开安全点状态
那么,JVM 如何将所有线程带入安全点状态呢?问题是需要将线程暂停在已知状态,而不仅仅是中断它。为了实现这一目标,JVM 让 Java 线程在方便的地方挂起自己,如果它们观察到一个 “安全点标志” 的话。
将 Java 线程引入安全点
Java 线程会以“合理”的间隔轮询“安全点标志”(可以是全局标志或线程级标志),并在观察到“进入安全点”标志时转换到安全点状态(线程在安全点被阻塞)。这听起来很简单,但为了避免频繁检查是否需要暂停,C1/C2编译器(即-client/-server JIT编译器)会尽量减少安全点轮询的次数。除了检查标志本身的开销外,维持“已知状态”还会显著增加某些优化实现的复杂性。因此,将安全点轮询间隔拉长可以为优化提供更大的空间。综合这些考虑,安全点轮询通常出现在以下位置:
- 在解释器中运行时,在任意两个字节码之间(有效)
- 在 C1/C2 编译代码中的“非计数”循环后沿 ( On ‘non-counted’ loop back edge in C1/C2 compiled code )
- A common type of program loop is one that is controlled by an integer that counts up from a initial value to an upper limit. Such a loop is called a counting loop. The integer is called a loop control variable. Loops are implemented with the conditional branch, jump, and conditional set instructions.
- 在 C1/C2 编译代码中的方法入口/出口(Zing JVM在方法入口,OpenJDK在方法出口)。注意,当方法被内联时,编译器会移除这些安全点轮询。
如果你是那种以汇编为乐趣 (或利润,或两者兼而有之) 的人,你会在 -XX:+PrintAssembly
输出中找到安全点轮询,方法是:
- ‘{poll}’ or ‘{poll return}’ on OpenJDK, this will be in the instructions comments
- ‘tls.pls_self_suspend’ on Zing, this will be the flag examined at the poll operation
这一机制在不同的虚拟机上实现方式不同(以下以x86 + Linux为例,我未研究其他架构):
- 在 Oracle/OpenJDK 中,通过对一个特殊内存页的地址执行盲 TEST 指令来实现。所谓“盲”,是因为该指令后没有分支指令,因此非常不显眼(通常 TEST 指令会紧跟一个分支指令)。当 JVM 希望将线程带入安全点时,它会保护该内存页,导致发生 SEGV(段错误),JVM 会捕获并适当处理此错误。每个 JVM 只有一个这样的特殊内存页,因此要将某个线程带入安全点,必须将所有线程带入安全点。
- 在 Zing JVM 中,安全点标志是线程局部的(因此有 tls 前缀)。线程可以独立地被带入安全点。
更多关于轮询的细节,可参见相关文章:Dude, Where’s My Safepoint?
一旦线程检测到安全点标志,它将执行轮询触发的安全点动作。这通常意味着线程会在某个 JVM 级别的锁上阻塞,直到安全点操作完成时被释放。可以将此视为一种锁机制,其中:
- 线程可以自行锁定(例如,通过调用 JNI 或在安全点阻塞)。
- 线程可以尝试重新进入(例如,从 JNI 返回时),但如果锁被 JVM 持有,它们将被阻塞。
- 安全点操作会请求该锁,并阻塞直到获得锁(即所有变异线程都已自行锁定)。
补充 JIT
JIT 是 just in time 的缩写,也就是即时编译器。使用即时编译器技术,能够加速 Java 程序的执行速度。下面,就对该编译器技术做个简单的讲解。
当 JVM 执行代码时,它并不立即开始编译代码。这主要有两个原因:
首先,如果这段代码本身在将来只会被执行一次,那么从本质上看,编译就是在浪费精力。因为将代码翻译成 java 字节码相对于编译(编译成二进制)这段代码并执行代码来说,要快很多。
当然,如果一段代码频繁的调用方法,或是一个循环,也就是这段代码被多次执行,那么编译就非常值得了。因此,编译器具有的这种权衡能力会首先执行解释后的代码,然后再去分辨哪些方法会被频繁调用来保证其本身的编译。其实说简单点,就是 JIT 在起作用,我们知道,对于 Java 代码,刚开始都是被编译器编译成字节码文件,然后字节码文件会被交由 JVM 解释执行,所以可以说 Java 本身是一种半编译半解释执行的语言。Hot Spot VM 采用了 JIT compile 技术,将运行频率很高的字节码直接编译为机器指令执行以提高性能,所以当字节码被 JIT 编译为机器码的时候,要说它是编译执行的也可以。也就是说,运行时,部分代码可能由 JIT 翻译为目标机器指令(以 method 为翻译单位,还会保存起来,第二次执行就不用翻译了)直接执行。
JIT 编译器在运行程序时有两种编译模式可以选择,并且其会在运行时决定使用哪一种以达到最优性能。这两种编译模式的命名源自于命令行参数(eg: -client 或者 -server)。JVM Server 模式与 client 模式启动,最主要的差别在于:-server 模式启动时,速度较慢,但是一旦运行起来后,性能将会有很大的提升。原因是:当虚拟机运行在 -client 模式的时候,使用的是一个代号为 C1 的轻量级编译器,而 -server 模式启动的虚拟机采用相对重量级代号为 C2 的编译器。C2 比 C1 编译器编译的相对彻底,服务起来之后,性能更高。