跳到主要内容

JEP 270:为关键部分预留堆栈区域

QWen Max 中英对照

概述

为关键部分在线程堆栈上保留额外的空间,以便在发生堆栈溢出时它们仍能完成操作。

目标

  • 提供一种机制,以减轻因在关键部分抛出 StackOverflowError 而导致关键数据(如 java.util.concurrent 锁,例如 ReentrantLock)损坏而引发死锁的风险。

  • 该解决方案必须主要基于 JVM,以便不需要对 java.util.concurrent 算法、已发布的接口或现有的库和应用程序代码进行修改。

  • 该解决方案不得仅限于 ReentrantLock 的情况,并且应适用于特权代码中的任何关键部分。

非目标

  • 该解决方案并不旨在为非特权代码提供针对堆栈溢出的健壮性。

  • 该解决方案并不旨在避免 StackOverflowError,而是为了降低在关键部分内部抛出此类错误从而损坏某些数据结构的风险。

  • 所提出的解决方案是一种权衡,它在解决一些众所周知的数据损坏问题的同时,保留了性能,且资源成本合理、复杂性相对较低。

动机

StackOverflowError 是一种异步异常,当线程中的计算需要的栈空间超过允许的大小时,Java 虚拟机(JVM)可以随时抛出该异常(JVM 规范 §2.5.2 和 §2.5.6)。Java 语言规范允许通过方法调用同步抛出 StackOverflowError(JLS §11.1.3)。HotSpot 虚拟机利用这一特性,在方法入口处实现了一种称为“栈碰撞”的机制。

栈碰撞机制是一种在保留 JVM 完整性的同时报告栈溢出的简洁方法,但它并未提供让应用程序从这种情况中安全恢复的方式。栈溢出可能发生在一系列修改操作的中途,如果这些修改未完成,可能会导致数据结构处于不一致的状态。

例如,当在 java.util.concurrent.locks.ReentrantLock 类的关键部分抛出 StackOverflowError 时,锁的状态可能会被留在不一致的状态中,从而导致潜在的死锁。ReentrantLock 类使用 AbstractSynchronizerQueue 的一个实例来实现其关键部分。其 lock() 方法的实现为:

final void lock() {
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
}

该方法尝试使用原子操作来更改状态字。如果修改成功,则通过调用 setter 方法设置所有者,否则将调用慢路径。问题在于,如果在状态字已更改但所有者尚未有效设置之前抛出了 StackOverflowError,那么锁将变得不可用:其状态字表明它已被锁定,但尚未设置所有者,因此没有任何线程可以解锁它。由于栈大小检查是在方法调用时进行的(至少在 HotSpot 中是这样),当调用 Thread.currentThread() 或调用 setExclusiveOwnerThread() 时,都可能抛出 StackOverflowError。无论哪种情况,都会导致 ReentrantLock 实例损坏,并且所有试图获取此锁的线程都将被永久阻塞。

这个问题在 JDK 7 中引发了一些严重的故障,因为并行类加载是使用 ConcurrentHashMap 实现的,并且当时的 ConcurrentHashMap 代码使用了 ReentrantLock 实例。如果由于 StackOverflowError 导致某个 ReentrantLock 实例被破坏,那么类加载机制本身可能会发生死锁。(这种情况在压力测试中出现过 (JDK-7011862),但在实际应用中也有可能发生。)

ConcurrentHashMap 类的实现于 2013 年 6 月完全改变了。新的实现使用了 synchronized 语句,而不是 ReentrantLock 实例,因此 JDK 8 及更高版本不会因为损坏的 ReentrantLock 而导致类加载死锁。然而,任何使用 ReentrantLock 的代码仍然可能受到影响并导致死锁。此类问题已经在 concurrency-interest@cs.oswego.edu 邮件列表中被报告。

问题不仅限于 ReentrantLock 类。

Java 应用程序或库通常依赖于数据结构的一致性来正常运行。对这些数据结构的任何修改都是一个关键部分:在执行关键部分之前,数据结构是一致的,在执行之后,数据结构也是一致的。然而,在其执行过程中,数据结构可能会经历短暂的不一致状态。

如果一个关键部分由一个不包含其他方法调用的单一 Java 方法组成,当前的栈溢出机制能够很好地工作:要么可用栈足够,方法执行无误;要么栈空间不足,于是在方法的第一个字节码执行之前就会抛出 StackOverflowError

问题发生在关键部分由多个方法组成时,例如,一个方法 A 调用了一个方法 B。可用的堆栈可能足以让方法 A 开始执行。方法 A 开始修改数据结构,然后调用方法 B,但剩余的堆栈不足以执行 B,导致抛出 StackOverflowError。因为方法 B 和方法 A 的其余部分没有被执行,数据结构的一致性可能已经被破坏。

描述

所提出解决方案的主要思想是在执行栈上为关键部分预留一些空间,以允许它们在常规代码因栈溢出而被中断的地方完成执行。前提是关键部分相对较小,不需要巨大的执行栈空间即可成功完成。目的并不是要挽救一个触及其栈限制的错误线程,而是保护共享数据结构,防止在关键部分抛出 StackOverflowError 时可能导致的数据损坏。

主要机制将在 JVM 中实现。Java 源代码中唯一需要的修改是必须用来标识关键部分的注解。这个注解当前被命名为 jdk.internal.vm.annotation.ReservedStackAccess,是一个运行时方法注解,可以被任何特权代码类使用(关于此注解的可访问性,请参见下文段落)。

为了防止共享数据结构的损坏,JVM 会尝试延迟抛出 StackOverflowError,直到相关线程退出其所有的关键部分。每个 Java 线程在其执行栈中都有一个新区域,称为保留区(reserved zone)。该区域只有在 Java 线程的当前调用栈中包含使用 jdk.internal.vm.annotation.ReservedStackAccess 注解的方法时才能被使用。当 JVM 检测到栈溢出条件,并且线程的调用栈中存在注解方法时,JVM 会临时授予对保留区的访问权限,直到调用栈中不再有注解方法为止。当保留区的访问权限被撤销后,延迟的 StackOverflowError 将被抛出。如果在线程检测到栈溢出条件时,其调用栈中没有注解方法,则会立即抛出 StackOverflowError(这是当前 JVM 的行为)。

请注意,保留的栈空间可以被注解方法使用,也可以被直接或间接从这些方法调用的其他方法使用。注解方法的嵌套是自然支持的,但每个线程只有一个共享的保留区域;也就是说,调用一个注解方法并不会添加新的保留区域。保留区域的大小必须根据所有注解关键部分的最坏情况进行设定。

默认情况下,jdk.internal.vm.annotation.ReservedStackAccess 注解仅适用于特权代码(由引导类加载器或扩展类加载器加载的代码)。特权代码和非特权代码都可以使用此注解进行标注,但默认情况下,JVM 会忽略非特权代码中的该注解。此默认策略背后的原因是,关键部分的保留栈空间是所有关键部分之间的共享资源。如果任何任意代码都能够使用此空间,那么它就不再是保留空间了,这将使整个解决方案失效。即使在生产版本中,也提供了一个 JVM 标志来放宽此策略,允许任何代码从该功能中受益。

实现

在 HotSpot 虚拟机中,每个 Java 线程在其执行栈的末尾定义了两个区域:黄色区域和红色区域。这两个内存区域都受到保护,防止所有访问。

如果在执行期间,线程尝试使用黄色区域中的内存,则会触发保护错误,暂时移除黄色区域的保护,并创建和抛出 StackOverflowError。在展开线程执行栈以传播 StackOverflowError 之前,黄色区域的保护会被恢复。

如果线程尝试使用其红色区域中的内存,JVM 会立即跳转到 JVM 错误报告代码,从而生成错误报告和 JVM 进程的崩溃转储。

新方案定义的区域刚好位于黄色区域之前。如果线程的调用栈中包含一个带有 ReservedStackAccess 注解的方法,该保留区域的行为将类似于常规栈空间;否则,其行为将类似于黄色区域。

在设置 Java 线程的执行栈期间,保留区(reserved zone)会以与黄色区和红色区相同的方式受到保护。如果在线程执行过程中触碰到了其保留区,则会生成一个 SIGSEGV 信号,并且信号处理器将应用以下算法:

  • 如果故障地址在红色区域,则生成 JVM 错误报告和崩溃转储。

  • 如果故障地址在黄色区域,则创建并抛出 StackOverflowError

  • 如果故障地址在保留区域,则执行栈遍历,检查调用栈中是否存在带有 jdk.internal.vm.annotation.ReservedStackAccess 注解的方法。如果不存在这样的方法,则创建并抛出 StackOverflowError。如果找到带有注解的方法,则移除临界区域的保护,并在 C++ 的 Thread 对象中存储与带注解方法相关的最外层激活(帧)的栈指针。

如果为了允许临界区完成其执行而移除了保留区域的保护,则必须在该线程退出临界区后立即恢复保护,并抛出延迟的 StackOverflowError。HotSpot 解释器已被修改为检查是否正在退出已注册的最外层注解方法。在每次移除帧激活时都会执行此检查,通过将要恢复的栈指针值与存储在 C++ Thread 对象中的值进行比较来实现。如果恢复的栈指针值高于存储的值(栈向下增长),则会调用运行时以更改内存保护,并在跳转到 StackOverflowError 生成代码之前重置 Thread 对象中的栈指针值。这两个编译器也经过修改,在方法退出时执行相同的检查,但仅针对带有 ReservedStackAccess 注解的方法,或者其编译代码中包含内联注解方法的方法。

当抛出异常时,控制流不会经过常规的方法退出代码,因此如果异常传播到注解方法之上,保留区的保护可能无法正确恢复。为了防止这种情况,每次开始传播异常时,都会恢复保留区的保护,并重置存储在 C++ Thread 对象中的栈指针值。在这种情况下,延迟的 StackOverflowError 不会被抛出。其理由是,抛出的异常比延迟的 StackOverflowError 更重要,因为它指示了正常执行被中断的原因和位置。

抛出 StackOverflowError 是 Java 通知应用程序某个线程已达到其栈限制的方式。然而,异常和错误有时会被 Java 代码捕获,导致通知丢失或未被正确处理,这可能会使问题的调查变得非常困难。为了在存在保留栈区域的情况下简化栈溢出错误的故障排查,JVM 在访问保留栈区域时提供了另外两种通知方式:一种是由 JVM 打印的警告(输出到与其他所有 JVM 消息相同的流中),另一种是 JFR 事件。需要注意的是,即使由于在临界区中抛出了另一个异常而导致延迟的 StackOverflowError 未被抛出,JVM 警告和 JFR 事件仍然会生成,并可用于故障排查。

保留栈(reserved-stack)功能由两个 JVM 标志控制,一个用于配置保留区域的大小(所有线程使用相同的大小),另一个用于允许非特权代码使用该功能。将保留区域的大小设置为零会完全禁用此功能。当禁用时,解释代码和编译代码在方法退出时不会执行检查。

此解决方案的内存成本:对于每个线程,成本为其保留区域的虚拟内存,这部分属于其栈空间。曾有人考虑过在不同的内存区域实现保留区,作为备用栈。然而,这样做会显著增加任何栈遍历代码的复杂性,因此该选项已被拒绝。

性能成本:使用 JSR-166 测试ReentrantLock 进行的测量并未显示在 x86 平台上对性能有任何显著影响。

性能

以下是该解决方案可能对性能产生的影响。

此解决方案中最耗费资源的操作是在调用栈中查找带注解方法时执行的栈回溯(stack walking)。该操作仅在 JVM 检测到潜在的栈溢出时才会执行。如果没有此修复,JVM 将抛出 StackOverflowError。因此,即使该操作相对耗时,它仍然优于当前的行为,因为它可以防止数据损坏。此解决方案中执行最频繁的部分是当带注解方法退出时进行的检查,以判断是否需要重新启用保留区域的保护。这一检查的性能关键版本位于编译器中。当前实现会在带注解方法的编译代码中添加以下代码序列:

0x00007f98fcef5809: cmp    rsp,QWORD PTR [r15+0x298]
0x00007f98fcef5810: jle 0x00007f98fcef583c
0x00007f98fcef5816: mov rdi,r15
0x00007f98fcef5819: test esp,0xf
0x00007f98fcef581f: je 0x00007f98fcef5837
0x00007f98fcef5825: sub rsp,0x8
0x00007f98fcef5829: call 0x00007f9910f62670 ; {runtime_call}
0x00007f98fcef582e: add rsp,0x8
0x00007f98fcef5832: jmp 0x00007f98fcef583c
0x00007f98fcef5837: call 0x00007f9910f62670 ; {runtime_call}

此代码适用于 x86_64 平台。在快速情况下(无需重新启用保留区的保护),它增加了两条指令,包括一个小跳转。x86_32 的版本较大,因为它并没有总是在寄存器中提供 Thread 对象的地址。该功能也在 Solaris/SPARC 上实现。

开放性问题

保留区域的默认大小仍然是一个悬而未决的问题。这个大小将取决于使用 ReservedStackAccess 注解的 JDK 代码中最长的关键区域,同时也会依赖于平台架构。我们还可以考虑根据 JVM 是运行在高端服务器上还是运行在虚拟内存受限的环境中,来设置不同的默认值。

为了缓解栈大小问题,新增了一个调试/故障排查功能。该功能在调试版本中默认启用,并在生产版本中作为诊断 JVM 选项提供。当此功能被激活时,它会在 JVM 即将抛出 StackOverflowError 时运行:它会遍历调用栈,如果发现一个或多个使用 ReservedStackAccess 注解的方法,则会将其名称与警告消息一起打印到 JVM 标准输出。控制此功能的 JVM 标志名称为 PrintReservedStackAccessOnStackOverflow

保留区域的默认大小为一页(4K),实验表明这对于目前已标注的 java.util.concurrent 锁的关键部分已经足够。

在 Windows 平台上,保留的栈区域并未得到完全支持。在 Windows 上开发此功能期间,发现了一个关于控制栈特殊区域方式的错误(JDK-8067946)。该错误阻止了 JVM 授予对保留栈区域的访问权限。因此,在 Windows 上检测到栈溢出条件时,如果调用栈中存在带注解的方法,JVM 将打印警告信息、触发 JFR 事件,并立即抛出 StackOverflowError。对于应用程序而言,JVM 的行为没有变化。然而,JVM 警告和 JFR 事件有助于排查问题,表明发生了可能有害的情况。

替代方案

已经考虑了几种替代方法,其中一些已经被实施和测试。以下是这些方法的列表。

基于语言的解决方案:

  • try/catch/finally 结构:它们无法解决任何问题,因为无法保证 finally 子句不会同样触发栈溢出。

  • 新的结构例如:

    new CriticalSection(
    () -> {
    // 执行关键部分代码
    }).enter();

    这种结构可能需要在 javac 和 JVM 中进行大量工作,并且与保留的栈区域相比,即使在非栈溢出条件下,它的使用也可能会对性能产生较大影响。

代码转换解决方案:

  • 通过强制 JIT 内联所有调用的方法来避免方法调用(因为栈溢出检查是在方法调用时执行的):内联可能需要加载和初始化应用程序未使用的类,强制内联可能与编译器规则(代码大小、内联深度)冲突,并且内联不适用于所有代码模式(例如,反射)。

  • 重构代码以在源代码级别避免方法调用:重构需要修改已经很复杂的代码(java.util.concurrent),并且这种重构会破坏封装性。

基于堆栈的解决方案:

  • 扩展栈碰撞:在进入临界区之前进一步碰撞栈:此解决方案即使在没有栈溢出的情况下也会带来性能成本,并且在嵌套临界区时难以维护。

  • 可扩展栈:由多个不连续的内存块构建栈,在检测到栈溢出时添加一个新的内存块:此解决方案增加了 JVM 管理不连续栈的复杂性(包括目前基于指针比较的所有栈管理逻辑);它还可能要求我们复制/移动栈的某些部分,并由于碎片问题对内存分配后端造成更大压力。

测试

此更改附带一个可靠的单元测试,能够重现由堆栈溢出引起的 java.util.concurrent.lock.ReentrantLock 损坏。

依赖

保留的栈区依赖于“黄页”机制。该机制目前在 Windows 上部分损坏 [JDK-8067946],因此在该平台上不完全支持保留的栈区。