Skip to content

JEP 270: Reserved Stack Areas for Critical Sections | 为关键段保留堆栈区域

摘要

为线程栈保留额外的空间,以供关键部分使用,即使发生堆栈溢出也能完成。

目标

  • 提供一种机制来减轻由于关键数据(如 java.util.concurrent 锁(如 ReentrantLock))的损坏而导致死锁的风险,这是由于在关键部分抛出 StackOverflowError 异常引起的。
  • 解决方案大部分应基于 JVM 实现,以不需要修改 java.util.concurrent 算法或已发布的接口、现有库和应用程序代码。
  • 解决方案不应仅限于 ReentrantLock 情况,应适用于特权代码中的任何关键部分。

非目标

  • 该解决方案不旨在为非特权代码提供对堆栈溢出的健壮性。
  • 该解决方案不旨在避免 StackOverflowError,而是减轻在关键部分中抛出此类错误的风险,从而破坏某些数据结构。
  • 所提出的解决方案是在保持性能的同时解决一些已知的损坏情况的权衡,具有合理的资源成本和相对较低的复杂性。

动机

StackOverflowError 是 Java 虚拟机在线程计算所需的堆栈大于允许的堆栈大小时可能抛出的异步异常(JVM 规范 §2.5.2 和 §2.5.6)。Java 语言规范允许通过方法调用同步地抛出 StackOverflowError(JLS §11.1.3)。HotSpot VM 利用此属性在方法进入时实现了“堆栈砰”的机制。

堆栈砰机制是一种报告堆栈溢出已发生并保持 JVM 完整性的清洁方式,但它没有提供应用程序从这种情况中恢复的安全方式。堆栈溢出可能发生在一系列修改的中间,如果不完成,可能会使数据结构处于不一致的状态。

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

java
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

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

描述

所提出的解决方案的主要思想是为关键部分保留一些执行堆栈上的空间,以允许它们在常规代码被堆栈溢出中断时完成其执行。假设关键部分相对较小且不需要巨大的执行堆栈空间才能成功完成。目标不是拯救命中堆栈限制的错误线程,而是保护可能因 StackOverflowError 在关键部分中抛出而被破坏的共享数据结构。

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

为了防止共享数据结构的损坏,JVM 将尝试延迟抛出 StackOverflowError,直到有关线程退出其所有关键部分。每个 Java 线程都在其执行堆栈中定义了一个新区域,称为保留区域。仅当 Java 线程的当前调用堆栈中具有带注释的方法时,才可以使用此区域。当 JVM 检测到堆栈溢出条件并且线程的调用堆栈中存在带注释的方法时,JVM 授予对保留区域的临时访问权限,直到调用堆栈中不再存在带注释的方法为止。当撤销对保留区域的访问权限时,将抛出延迟的 StackOverflowError。如果线程在检测到堆栈溢出条件时其调用堆栈中没有带注释的方法,则立即抛出 StackOverflow(这是当前 JVM 行为)。

请注意,保留的堆栈空间可由带注释的方法以及直接或间接从它们调用的方法使用。自然支持带注释方法的嵌套,但每个线程只有一个共享的保留区域;也就是说,调用带注释方法不会添加新的保留区域。保留区域的大小必须根据所有带注释关键部分的最坏情况进行调整。

默认情况下,jdk.internal.vm.annotation.ReservedStackAccess 注释仅适用于特权代码(由引导程序或扩展类加载器加载的代码)。特权代码和非特权代码都可以使用此注释,但默认情况下,JVM 将忽略非特权代码的注释。这个默认策略背后的理念是关键部分的保留堆栈空间是所有关键部分的共享资源。如果任何任意代码都能够使用此空间,则它不再是一个保留空间,这将破坏整个解决方案。即使在产品构建中,也可以使用 JVM 标志来放松此策略并允许任何代码受益于此功能。

实现

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

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

如果线程尝试使用其红色区中的内存,则 JVM 立即分支到 JVM 错误报告代码,导致生成错误报告和 JVM 进程的崩溃转储。

提议解决方案定义的新区域位于黄色区之前。如果线程在其调用堆栈中具有 ReservedStackAccess 注释方法,则保留区将像常规堆栈空间一样运行;否则将像黄色区一样运行。

在设置 Java 线程的执行堆栈期间,保留区与黄色区和红色区以相同的方式受到保护。如果在线程执行期间,线程命中其保留区,则会生成 SIGSEGV 信号,信号处理程序将应用以下算法:

  • 如果故障地址在红色区,则生成 JVM 错误报告和崩溃转储。
  • 如果故障地址在黄色区,则创建并抛出 StackOverflowError
  • 如果故障地址在保留区,则执行堆栈步进,以检查调用堆栈上是否有使用 jdk.internal.vm.annotation.ReservedStackAccess 注释的方法。如果没有找到注释的方法,则创建并抛出 StackOverflowError。如果找到了注释的方法,则删除关键区域的保护,并将与注释方法相关联的最外层激活(帧)的堆栈指针存储在 C++ Thread 对象中。

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

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

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

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

此解决方案的内存成本:对于每个线程,成本是其保留区的虚拟内存,作为其堆栈空间的一部分。已经考虑过在不同的内存区域中实现保留区,作为备用堆栈。但是,这将显着增加任何堆栈步进代码的复杂性,因此已拒绝此选项。

性能成本:在 x86 平台上,使用 JSR-166 测试中的 ReentrantLock 进行的测量没有显示出对性能的任何显着影响。

性能

这种解决方案可能对性能产生的影响如下。

在此解决方案中,最耗费资源的操作是在调用栈中查找带注释方法时执行的堆栈跟踪。此操作仅在 JVM 检测到潜在的堆栈溢出时执行。如果没有这个修复程序,JVM 将抛出 StackOverflowError。因此,即使该操作相对耗费资源,它也比当前的行为更好,因为它可以防止数据损坏。此解决方案中执行频率最高的部分是在带注释方法退出时进行的检查,以确定是否需要重新启用保留区的保护。此检查的性能关键版本位于编译器中。当前的实现将以下代码序列添加到带注释方法的编译代码中:

c
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 子句不会触发堆栈溢出。

  • 新的构造,例如:

    java
    new CriticalSection(
           () -> {
               // 执行关键代码区域
            }).enter();

    这种构造可能需要在 javac 和 JVM 中进行大量工作,并且即使在不运行在堆栈溢出条件下也可能对性能产生很大影响,与保留堆栈区域相比。

代码转换解决方案:

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

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

基于堆栈的解决方案:

  • 扩展堆栈碰撞:在进入关键区域之前进一步堆栈碰撞:即使不处于堆栈溢出状态,这种解决方案也会带来性能成本,并且在嵌套关键区域中很难维护。

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

测试

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

依赖

保留堆栈区域依赖于“黄页”机制。该机制在 Windows 平台上目前存在部分问题 JDK-8067946,因此在该平台上不完全支持保留堆栈区域。