jvm中的safepoint

2,794 阅读6分钟

前言

多线程编程是一件很难的事,或者说编写在多线程条件下运行良好的代码很难。java提供了synchronized和volatile关键字,还有Lock类和Atomic相关的类来帮助我们正确的实现并发逻辑,但我在实际工作中仍倾向于尽量避免并发,还有一个偷懒的做法就是需要并发访问的变量总是加上volatile修饰。

最近遇到了两个并发相关的例子,一个是某个同事编写的利用AtomicInteger类实现的lock-free逻辑出现了bug,由于边界条件没处理好,导致出现死循环的情况;另一个是某个服务由于平时负载比较高,出现了偏向锁撤销耗时过长的问题。

偏向锁撤销

偏向锁实际上是jvm对锁的一种优化,它假定对于一个锁,实际上只有一个线程在尝试访问。偏向锁的实现很简单,就是在一个线程访问锁时,将这个锁的持有者直接标记为这个线程,当这个线程再尝试获取锁时,只需要检查这个持有者标记即可。偏向锁的优化在实际并没有多线程竞争的场景下能够有效提高程序的性能,但是当“没有多线程竞争”这个假设不成立,偏向锁就需要额外的逻辑进行撤销,而这个撤销就有可能会带来较长时间的停顿,影响程序的性能。

为什么说偏向锁撤销可能会导致长时间停顿呢,是因为偏向锁的撤销实际上需要在安全点时进行。偏向锁撤销的需要暂停拥有锁的线程并操作它的栈,所以需要在安全点进行。而程序进入安全点所需的时间是不确定的,具体的原因就跟安全点的具体实现有关。

safepoint

安全点(以下称safepoint)是jvm中的一个重要的概念,jvm中很多场景都会遇到它,最常见的应该是GC(虽然我前面提到的是偏向锁撤销)。safepoint的含义表示的是程序中的某些固定位置,在这些位置上程序的状态是“确定”的,这时jvm就可以根据程序的状态进行一些特殊的操作,比如:

  1. gc:gc时需要将不再存活的对象清理掉,所以需要“确定”地知道哪些对象不再存活。gc时还需要扫描每个线程的栈,所以需要“确定”的直到栈中的每个对象的类型(引用还是值)。
  2. 偏向锁撤销:这个前面提到了,因为需要暂停线程,同时操作线程的栈。
  3. 更新OopMap:oop(ordinary object pointer)是hotspot虚拟机里用于记录对象的元数据的数据结构,它记录了对象内各个偏移量对应的数据的类型。OopMap主要用于实现准确式GC,关于准确式GC的介绍可以参考这里
  4. Code deoptimization/Flushing code cache/Class redefinition/Various debug operation:来自这里

safepoint的位置

在jvm的运行时管理中,利用safepoint来将整个程序挂起(Stop the World),然后进行一些特殊的操作。而safepoint的思路也很简单:当我们需要执行一些特殊操作(比如gc)时,我们就在程序的某些位置设置一些暂停点,当线程来到这些暂停点时,就将自己挂起。等所有线程都挂起后,我们再进行原定的特殊操作。而safepoint的具体位置通常有以下几个:

  1. 每个字节码命令之后(解释模式)
  2. 所有的方法返回之前(JIT模式)
  3. 所有的非计数循环的末尾 (JIT模式)

只在非计数循环的末尾设置safepoint带来了一个问题:假如程序里有一个非常大的计数循环(比如循环100w次),就可能导致safepoint挂起整个程序的时间变长,因为其他已经挂起的线程都需要等待这个大循环执行结束。

前面提到safepoint的设置会使所有线程挂起,那么具体的gc或者偏向锁撤销又是由谁来执行的呢,答案就是VM线程。在jstack的输出结果中就可以看到有一个叫做"VMThread"的线程,这个线程就是专门负责在STW的时候处理各种特殊操作。

safe region

safepoint可以让运行中的线程主动挂起,而其他状态下的线程(比如正在sleep或者正阻塞在一个锁上)则无法主动运行到safepoint。对于这种情况jvm中设置了安全区(safe region)的概念,当线程处于某些状态时,jvm认为这种情况下这个线程不会对jvm heap做出任何修改,因此不会破坏jvm的“确定”的状态,所以这些线程可以认为是安全的(处于安全区)。当处于安全区的线程要从安全区出来的时候,同样需要检查是否应该主动挂起。jvm中设定以下几种状态的线程就是处于safe region:

  1. 处于阻塞或者等待中
  2. 正在执行JNI方法

所以当线程处于以上几个状态时,我们就认为它们就和达到safepoint一样,可以执行特殊的操作了。为了防止线程从safe region返回后对jvm heap进行更改,当STW时线程在从safe region返回时都会主动挂起。

线程挂起的实现

通过在特定的位置设置safepoint,我们可以让程序在需要的时候挂起。当需要STW的时候,safepoint会被激活,每个线程在运行到safepoint时,都会主动检查safepoint的状态,如果safepoint被激活,线程就会进入挂起状态。简单来说,线程在检查safepoint时会主动访问一个特定的内存页,而当STW时这个内存页设置为不可读,所以每个尝试读这个内存页的线程也就会挂起。而这个检查过程是通过JIT编译器主动插入到指令中的。

而具体来说,线程处于不同状态时,挂起线程的方式也有不同:

  1. 当线程处于解释执行时,解释器会强制将下一个指令指向检查safepoint的指令
  2. 当线程正在执行JNI方法时,VMThread不会等待其返回,而是认为其处于safe region。当它返回时会阻塞直到STW结束
  3. 当线程正在执行已经编译好的方法时,编译好的代码里会携带检查safepoint的逻辑,我们将特定的内存页设置为不可读即可
  4. 当线程正在阻塞时,VMThread不会等待其返回,而是认为其处于safe region。当它返回时会阻塞直到STW结束

当线程尝试访问标记为不可读的内存页时,会触发SIGSEGV信号,从而触发jvm内的signal handler,signal handler在收到SIGSEGV信号时会确认是否是由于safepoint检查触发了这个信号,如果是就会将自身挂起。

其他

很多内容参考了iter_zc的博客,在此表示感谢。