JVM—SafePoint

SafePoint是什么

JVM的主要任务是执行Java程序,而JVM运行时本身也是一个程序,但是为了执行Java程序JVM还有不少辅助工作,比如进行GC、JIT编译等等。一般会把运行在JVM上的用户Java程序称为mutator

在GC中,JVM中一般的GC都使用可达性分析,也就是从应用程序的一些GC Root(比如运行中的线程栈里的方法栈帧中本地变量表、操作数表中的引用、静态变量引用等)开始通过引用进行引用图遍历,如果在JVM遍历的过程中mutator也在运行,则mutator则可能会修改这个对象图的引用关系,如果JVM不对这种并发修改进行特殊处理,可能导致一些非可回收对象没有被遍历到,从而被标记成垃圾对象而被错误的回收。
如果要完全并发GC,JVM的实现成本会比较大,并且很多情况下整体的吞吐量是会降低的。
因此在很多GC收集器中都会有一些StopTheWorld阶段,这个StopTheWorld就是safepoint。在safepoint中不会有mutator操作对象,并且线程栈和heap中每个位置的数据类型也是确定的。

一个线程要么在safepoint中,要么不在safepoint中。上面提到的StopTheWorld指的是全局safepoint,也就是要求所有线程都处于safepoint状态。后面如果没有特别说明safepoint也指的是全局safepoint。

SafePoint 如何实现的

可以这么理解,SafePoint 可以插入到代码的某些位置,每个线程运行到 SafePoint 代码时,主动去检查是否需要进入 SafePoint,这个主动检查的过程,被称为 Polling。
在hotspot实现中safepoint是协作式的,当JVM需要mutator进入safepoint时,会设置一个状态标记表示要进入safepoint了,每个mutator线程都会在合适的时机检查这个状态标记,如果发现需要进入safepoint则会暂停自己。

针对 SafePoint,线程有 5 种情况;假设现在有一个操作触发了某个 VM 线程所有线程需要进入 SafePoint(例如现在需要 GC),如果其他线程现在:

  • 运行字节码:运行字节码时,解释器会看线程是否被标记为 poll armed,如果是,VM 线程调用 SafepointSynchronize::block(JavaThread *thread)进行 block。
  • 运行 native 代码:当运行 native 代码时,VM 线程略过这个线程,但是给这个线程设置 poll armed,让它在执行完 native 代码之后,它会检查是否 poll armed,如果还需要停在 SafePoint,则直接 block。
  • 运行 JIT 编译好的代码:由于运行的是编译好的机器码,直接查看本地 local polling page 是否为脏,如果为脏则需要 block。这个特性是在 Java 10 引入的 JEP 312: Thread-Local Handshakes 之后,才是只用检查本地 local polling page 是否为脏就可以了。
  • 处于 BLOCK 状态:在需要所有线程需要进入 SafePoint 的操作完成之前,不许离开 BLOCK 状态
  • 处于线程切换状态或者处于 VM 运行状态:会一直轮询线程状态直到线程处于阻塞状态(线程肯定会变成上面说的那四种状态,变成哪个都会 block 住)。

如何排查safepoint相关问题

在JVM启动参数上增加一些参数可以打印出应用暂停和safepoint相关信息。 如果版本<=jdk8

-XX:+PrintGCApplicationStoppedTime -XX:+PrintGCApplicationConcurrentTime -XX:+PrintSafepointStatistics -XX:PrintSafepointStatisticsCount=1

如果版本>jdk8
-Xlog:gc*=info::time,tags,tid -Xlog:safepoint=info::time,tags,tid

深入Safepoint

github
可以看到,jvm中的每个子系统都或多或少的与安全点相关。因此在需要学会思考,编写的代码会与安全点有何关系之类的问题。

名词解释

java线程:他是jvm中线程概念的一种特殊化,是专门针对执行java代码的线程。线程本地gc根被定义为一个oop,它是指向java线程本地的java堆的指针,这里代表的java堆上对象,是可达性分析树的根。
可变线程状态:是一种java线程状态,特指可以改变java堆的线程状态。例如,分配对象或修改设置字段或其线程本地gc根,这种状态有时也被称为不安全状态。
安全点:他是一个全局JVM状态,这里的直观概念是在这个点或状态下,java世界停止了。所以他是安全的,因为所有其他应用线程停止了,这样jvm可以独占访问检查和处理。更技术性的定义是没有java线程在内部执行或可以转换到可变线程状态。技术定义的另一部分是所有java线程的线程本地gc根都可以访问或发布到JVM。
Safepointing作为动词,也称为stop the world,是jvm到达一个安全点的过程或者机制。并且有一个更古老的直观概念,这围绕着暂停或停止所有正在执行的java线程的过程,这对于抽象来说是可以的,但这次我们将了解到这是一种更加精细和进化的机制,它严重依赖于线程之间的协作,使用一种称为协作挂起的技术。
合作暂停:是一种基于轮询的技术。这意味着java线程将在VM中的指定位置检查或轮询线程本地状态。在暂停时,JVM会阻止java线程转换为可变线程状态,并且在暂停时,jvm会触发java线程从可变线程状态转换为不可变线程状态。并且由于这种转换,线程本地gc根将被发布。由于这种转换,线程本地gc根将被发布,传统上将系统带到安全点对于提供某种形式的自动内存管理的运行时来说是必要的邪恶机制。被称为如此的原因是,这是运行时延迟和不确定性的主要来源之一。
安全点就想轮回或苦难一样,我们想摆脱他们以达到涅槃。
新的衍生技术,例如线程握手和线程栈水位线,他们使得安全点机制可以更轻量级或更精细化。
谁需要安全点,因为某些操作因素,导致必须达到安全点才能完成任务,相反,某些操作必须在非安全点才能完成一样。

线程状态

java线程有一个叫做线程状态的字段(_thread_state),_thread_state的目的是跟踪java线程正在执行的代码的哪一部分。

这是java线程的状态转换:
github

  1. 可变线程状态是一种线程可以改变java堆或其线程本地gc根的状态。
  2. 不可变线程状态是不能做这些事情的状态。
  3. 过渡状态,他们就像可变状态和不可变状态之间的桥梁。

转换状态具有安全的检查或轮询指令以及适当的防护。

例子

看看下面的情况:
我们有了一个新线程,他开始在VM状态下运行。假设这个线程现在想要执行一些java代码;为了做到这一点,他需要遍历到java状态的转换;正如我们所说的状态转换包含安全点检查;这里的一些值得注意的状态转换时,处于运行java代码的状态可以在不执行安全点检查的情况下转换到状态VM和状态native,而是在线程返回状态java时执行安全点检查。
这里的另一个重要说明是,在native状态下执行的代码被认为是安全的,这意味着在安全点期间,java线程实际上可以继续运行native代码,这也意味着与安全点涉及阻塞或停止所有java线程的直观概念相悖。这只意味着她们不会在敏感的可变状态下执行。

java线程本地gc根(Java Thread Local GC Roots)

在我们将安全点定义为全局jvm状态时,所有java线程的线程本地gc根都可以访问或发不到jvm。当前所有的垃圾收集器都是跟踪型垃圾收集器,这意味着他们从所谓的根集开始跟踪可达性树,这是一组立即可以访问的oops(对象指针),是根集的一个子集,即java线程本地可访问的根集。

让我们看看这些线程本地gc根是什么

本地jni句柄

java线程有一个成为活动句柄(_active_handles)的字段,他是一个本地jni句柄,提供给在本地状态下运行的jni代码对应的oop的间接访问,但是分配解除分配甚至取消引用jni句柄涉及首先执行vm状态,这将会执行安全点检查。本地jni句柄是自动管理的,因此,当代码从jni方法返回时,他会从进行安全点检查的状态native转换为状态java。由该方法分配的本地jni句柄被释放。

句柄区域

java线程也有一个叫做句柄区域(_handle_area)的字段,句柄区域及其他伙伴句柄,提供与本地jni句柄几乎相同的间接功能,但是这些是针对在VM状态下运行的代码。
重要的区别是这些句柄不是自动管理的,而是必须由程序员手动管理。HandleMark用于描述句柄范围,并且HandleMark析构函数将为该特定范围是否分配的句柄,并且范围也可以嵌套。

该线程还有一个内部结构体,并且有一个对应字段叫做锚(_anchor)。他由三个指针组成:

  • _last_java_sp 表示最后一个java堆栈指针
  • _last_java_pc 表示最后一个java程序计数器
  • _last_java_fp 表示最后一个java帧指针

最后一个java帧是外部遍历线程栈的入口点。我们排出JFR采集的帧。
如果线程在其堆栈上至少有一个java激活记录或帧,并且他当前不在状态java中,则设置他。所以处于状态java的线程在转换到其他状态之前,会设置这个锚(last java fram ljf)。
相反,他在线程重新进入状态java时被清除。

这里的anchor结构只需要设置_last_java_sp,因为其他字段要么与该上下文无关,要么可以通过堆栈便利代码推导出来。堆栈上的java帧可能包含普通、压缩的或派生的oops。所以,如果和之前讨论的句柄对比下,这些事裸oops,即他们没有处理引用定向,他们是直接指针。
ordinary指的是一般对象普通的oop;压缩oop是oop的压缩版本,他是32位大小;派生的oop是指向对象的指针,不是直接指向其对象头的。例如,我们可以考虑一个指针,他指向数组中的一个元素,派生oop始终与基址相关,对于java中的特定代码位置,对于像是代表特定的代码位置的程序计数器,堆栈槽和寄存器包含相对于该程序计数器的oops,是由编译器生成的一段元数据描述的,是叫做OopMap的一个东西。为了精确定位帧中的oop,OopMap使用栈指针的相对地址或RegisterMap的索引的相对地址来描述位置。
并非所有代码代码位置都有OopMap,他们主要与调用点和安全点轮询页面指令相关联。对于堆栈遍历,每帧的返回地址将于一个OopMap相关联。

java线程 CPU上下文

执行java代码的线程也有一个cpu上下文,并且根据调用约定和出于性能原因,理想情况下,oops由寄存器分配器直接放入寄存器中。
Hotspot广泛使用称为Stubs或StubRoutines的东西,他们是特定于平台的特殊程序集帮助程序。
Stub的一个重要特性就是在线程暂停他的工作让出CPU的时候保存CPU上下文,并且在线程回归之行的时候恢复CPU上下文,在某种程度上类似于上下文切换。

RegisterMap用于将oop映射描述的位置解析为寄存器,还有一些线程本地gc根。

例如潜在的未决异常和一些与jvmti相关的状态。

将系统带到安全点的过程

VMThread is the coordinator

void VMThread::loop() {
...
while(true) {
if (should_terminate()) break;
wait_for_operation();
if (should_terminate()) break;
assert(_next_vm_operation != NULL, "Must have one");
inner_execute(_next_vm_operation);
}
}

该过程由从VMThread请求安全点操作的客户端启动,它通过将VM_Operation类型的对象排入队列并将其属性evaluate_at_safepoint()设置为true来实现。
VMThread将等待出队并启动安全点过程以服务提交的请求。

VMThread - SafepointSynchronize::begin()

void SafepointSynchronize::begin() {
assert(Thread::current()->is_VM_thread(), "Only VM thread may execute a safepoint");

Threads_lock->lock();
assert(_state == _not_synchronized, "trying to safepoint synchronize with wrong state");
int nof_threads = Threads::number_of_threads();
...
arm_safepoint();

int iterations = synchronize_threads(safepoint_limit_time, nof_threads, &initial_running);
assert(_waiting_to_block == 0, "No thread should be running");
}

这是VMThread运行的第一部分。他由三个部分组成:

  1. 组装java线程
  2. 同步,即等待所有java线程
  3. 当所有线程都被认为是安全的,即已经到达全局jvm安全点状态时,VMThread运行提交的操作

首先看看组装部分

VMThread - arm_safepoint()

java线程有一个嵌入的结构体。
_poll_data字段,由两个指针组成_polling_word和_polling_page。arming本质上意味着VMThread更改了安全点检查中使用的所谓轮询页面的内存保护。并且他从内存保护状态PAGE_READONLY更改为PAGE_NOACCESS。
这里VMThread大致完成了封装了java线程的内存页,它有效的删除了线程转换到任何红色不安全可变状态的能力。他切换了从蓝色到红色的所有链接。这意味着,VMThread通过这个进程所做的事情现在状态机制被替换成了一个临时的状态机制。

然而,这只是合作暂停的一部分。之前说的是,JVM阻止了java线程转变到被归类为可变的线程状态。

定义的第二部分是JVM触发java线程从可变状态为不可变状态。作为这个转换线程的结果,本地gc根被发布。
如何确保那些已经在可变状态下运行的线程变为不可变状态,对于在VM状态下运行的线程,他需要等待,知道线程自己执行转换。在VM状态中只有少数地方显式滴执行安全点检查。例如当争用VM下的互斥锁或监视器时。
这种设计的前提是java线程应该在状态VM中话费尽可能少的时间。

但是在状态Java中运行的现场就不一样了。
我们不能像在状态VM中那样,等待状态Java中的线程自行过渡。例如,一个线程可以执行无限循环,这是完全合法的,在这种情况下,JVM永远不会到达安全点。因此,要解决这个问题,需要有一种适当的机制来疏散或推出当前运行java代码的线程。

private static int fibonacci(int n) {
int first = 0;
if (n == first) {
return first;
}
int second = 1;
if (scond == n) {
return second;
}
int nth = 1;
for(int i = 2; i <= n; ++i) {
nth = getSuccressor(first, second);
first = second;
second = nth;
}
return nth;
}
private static int getSuccressor(int first, int second) {
return first + second;
}

上面代码完成斐波那契数列,实现版本迭代。
但是在了解到细节时,最好谈谈x64调用约定或应用程序二进制接口,以及Hotspot Java调用约定以及他们之间的关系。
windows平台ABI按顺序在寄存器中传递前四个参数,其余的按从右到左(RTL)的顺序在堆栈上传递。还有32个字节的影子栈区域需要由调用者分配。System V ABI定义了参数在这六个寄存器中传递。而其余的也从右到左在堆栈上传递。
在栈下面有一个128字节的红色区域,Hotspot java调用约定定义了6个寄存器,用于在windows平台上参数传递,其余的从右到左在堆栈上传递,有一个自定义版本的堆栈保护,这只适用于System V平台。
对于所有这些,栈需要在16字节的边界上对齐。如果仔细观察,就会发现Hotspot java调用约定是平台ABI的一个移位或旋转版本,这种移位或旋转是因为通过跳过第一个ABI寄存器,我们可以调用非静态jni方法,而不必打乱参数。
相反,我们只将jni结束指针直接放在第一个ABI寄存器中。大多数寄存器都被认为是易失的,即调用方必须在调用过程中保留寄存器。除了RBP寄存器,是被调用的保存,如果存在压缩oops,R12就会保存java堆基址,而R15寄存器保存当前线程。所以这些寄存器可以被认为是常数。

JIT编译器在常规代码流中插入了安全点检查或轮询指令,他由两个指令组成:
第一个是从驻留在r15中的java线程加载轮询页面指针,然后执行一个test,解除轮询页面引用。除了在代码流中插入safepoint检查指令之外,JIT还确保这里的测试指令具有关联的OopMap,但对于这个特定的例子,这个特定的OopMap是空的。原因是,这段代码并没有真正使用任何java对象,因为他是一个静态方法,他没有接受对象,而且他只用了java原始类型。
github
Safepoint检查由编译器插入到循环头中,就像这里这样,以便捕获长时间运行的循环,并且在方法返回之前。

让我们看一下被调用的方法getSuccessor
github
这里可以看到安全点检查指令有点不同,这是一个cmp,而不是一个test,这是安全点检查的一个更优化版本。这与衍生技术如跨线程握手和线程水位线有关。不幸的事,这个版本不能应用于所有上下文中,后面会知道为什么在不同情况下需要不同的轮询指令。
在这段代码中,safepoint检查比较指令没有关联的OopMap,这是为什么?这是因为在方法末尾的safepoint调用指令是在栈帧出栈之后,但在返回指令之前插入的。这意味着调用者的返沪指令位于栈的顶部,通过表示一个调用点,他有一个关联的OopMap。
如果我们返回调用方,我们可以看到安全点检查,这是一个test指令,尝试解除对她从r15(java线程位于寄存器r15中)加载的轮询页面的引用。但是如果VMThread封装了轮询页面,即它将内存保护从PAGE_READONLY更改为PAGE_NO_ACCESS。
硬件将报非发访问或者segmentation fault,这又会通知操作系统,反过来通知寄存器信号或异常处理程序。Hotspot JVM将向各自的操作系统注册新号处理程序,以获取有关此事件的通知。利用硬件和操作系统提供的信息,我们可以确定发生了什么,所以在这个例子中,这是非法访问或者segmentation fault,并且还提供了有关哪个指令导致他的信息,内存地址。
所以在信号处理程序内部,我们可以确定内存地址是安全点轮询页面,所以我们知道这种非法访问或者segmentation fault并不是真正的崩溃,但它与合作暂停有关。
我们保存被困在名为saved_exception_pc的java线程字段中的指令,操作系统还为我们提供了处于trap时的状态和cpu上下文,我们可以在信号处理程序中重写cpu上下文,也就是说我们可以修改指令指针(IP),而不是指向捕获他的原始java代码,而是指向特定的StubRoutine。
当线程在展开所有异常处理程序后恢复时,操作系统重新加载这个现在修改的cpu上下文,并且线程继续在指定的StubRoutine中运行,而不是在原来的java代码中运行。

看一下Hotspot StubRoutine的代表性实例
github
这个叫做StubRoutineBlob,代表了汇编代码。

  1. 我们说在循环中间进行曲折进给,这意味着栈上没有正确的返回地址,所以我们要解决这个问题,所以我吗需要为他分配一个槽。
  2. 这里很重要,我们正在栈上建立一个新栈帧,一个与java代码本身没有直接关系的对象,而是一个元对象。并且因为jvm是java语言的元理论的实现,可以看到很多这些元结构与真正的java代码混合在一起,所以让我们称这个对象为合成帧或者Stub帧来表示这种差异。
  3. rflags寄存器被推入栈
  4. 如果还记得调用约定,说过堆栈需要在16字节边界上对齐
  5. 然后将cpu上下文的通用寄存器溢出到栈上
  6. 此处浮点寄存器也被溢出,如果查看这些Stub帧的大小,会发现她们非常大,例如此帧大小为360个字或近3000个字节,而这样做的原因当然是大部分cpu上下文都溢出了
  7. 如果记得在信号处理程序中,我们保存了错误指令的地址,我们现在将这个地址写入我们一开始分配的栈槽,我们这样做是为了让他成为返回地址,这样做的原因不仅仅是为了以后能够返回到代码中断的地方,更重要的是,test指令有一个关联的OopMap,并且在安全点期间,栈必须具有所有返回地址的OopMaps,而StubRoutines发挥的另一个重要的作用是在java ABI和特定平台ABI之间切换,这也是为什么这些Stub帧是平台特定的原因。
  8. 可以看到,因为我吗即将调用进入JVM,改掉用需要在此处添加到平台ABI的调用,对于这个例子在windows上,首先需要分配一个32字节的栈空间,然后将线程作为rcx中的第一个参数传递
  9. 在我们对安全点定义中,说过所有java线程的线程本地gc根都可以访问或发布到jvm,并且详细说明了_last_java_frame是发布到jvm的重要部分,因为它提供了遍历栈的入口点,其cpu上下文的一部分,直接在寄存器中,因此出现了一个棘手的问题,即如何使线程的cpu上下文可访问或发布到jvm以使其到达安全点,答案是使用Stub帧提供解决方案,通过将cpu上下文溢出到栈中,进入这些Stub帧之一,她看起来就像一个常规帧。cpu上下被塑造成一个栈帧。
  10. 所以确实可以在这里看到_last_java_frame或锚字段将被设置为指向Stub帧而不是真正的java代码帧
  11. 重要的方面也是这些合成Stub帧也有OopMaps,这个特定的OopMap将详细说明每个寄存器在堆栈上的位置,OopMap的值类型表示栈位置,确实属于调用者帧,这里是fibonacci方法,之前提到了一个RegisterMap,其中也会填充那些Stub帧中的位置信息,并与调用者中的OopMap一起,即fibonacci方法的OopMap,表示放在寄存器中的oops可以被解析出

SafepointSynchronize::block()

所以负责将这个特定线程的cpu上下文和堆栈导出到VM。当我们最终进入VM操作的时候,将java线程的线程状态字段设置为阻塞,连同Full Fence完全内存屏障指令,这对于线程本地gc根可见或发不到VM非常重要。线程现在将等待全局信号量,在VM线程运行VM操作之后,她发出信号量以及当线程恢复的时候,他将解除自己的轮询页并从中断的地方继续执行。
github

至此,关于Hotspot JVM中的安全点到此结束。
Safepoint 可以理解成是在代码执行过程中的一些特殊位置,当线程执行到这些位置的时候,线程可以暂停。在 SafePoint 保存了其他位置没有的一些当前线程的运行信息,供其他线程读取。这些信息包括:线程上下文的任何信息,例如对象或者非对象的内部指针等等。我们一般这么理解 SafePoint,就是线程只有运行到了 SafePoint 的位置,他的一切状态信息,才是确定的,也只有这个时候,才知道这个线程用了哪些内存,没有用哪些;并且,只有线程处于 SafePoint 位置,这时候对 JVM 的堆栈信息进行修改,例如回收某一部分不用的内存,线程才会感知到,之后继续运行,每个线程都有一份自己的内存使用快照,这时候其他线程对于内存使用的修改,线程就不知道了,只有再进行到 SafePoint 的时候,才会感知。