跳到主要内容

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

概括

在线程堆栈上保留额外的空间供关键部分使用,以便即使发生堆栈溢出,它们也可以完成。

目标

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

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

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

非目标

  • 该解决方案的目的并不是为非特权代码提供针对堆栈溢出的鲁棒性。

  • 该解决方案的目的并不是要避免StackOverflowErrors,而是为了减轻在关键部分内引发此类错误并从而损坏某些数据结构的风险。

  • 所提出的解决方案是在解决一些众所周知的腐败案例的同时保持性能、合理的资源成本和相对较低的复杂性之间的权衡。

动机

StackOverflowError是一个异步异常,只要线程中的计算需要比允许的更大的堆栈(JVM 规范第 §2.5.2 和§2.5.6),Java 虚拟机就会抛出该异常。 Java 语言规范允许StackOverflowError通过方法调用同步抛出 a (JLS §11.1.3)。 HotSpot VM 使用此属性在方法入口上实现“stack-banging”机制。

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

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

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

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

这个特殊的问题在 JDK 7 中引起了一些严重的问题,因为并行类加载是使用 a 实现的ConcurrentHashMap,并且当时的ConcurrentHashMap代码使用了ReentrantLock实例。如果ReentrantLock实例由于以下原因而损坏,StackOverflowError则类加载机制本身可能会死锁。 (这发生在压力测试中(JDK-7011862),但也可能发生在现场。)

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

问题不仅限于班级ReentrantLock

Java 应用程序或库通常依赖数据结构的一致性才能正常工作。对这些数据结构的任何修改都是临界区:临界区执行之前数据结构是一致的,执行之后数据结构也是一致的。然而,在执行过程中,数据结构可能会经历短暂的不一致状态。

如果临界区由不包含其他方法调用的单个 Java 方法组成,则当前的堆栈溢出机制运行良好:要么可用堆栈足够并且该方法执行没有问题,要么不够,因此StackOverflowError在该方法之前抛出a 。执行该方法的第一个字节码。

当临界区由多个方法组成时,例如方法 A 调用方法 B,就会出现问题。可用堆栈足以让方法 A 开始执行。方法A开始修改一个数据结构,然后调用方法B,但是剩余的堆栈不足以执行B,导致StackOverflowError抛出a。由于方法 B 和方法 A 的其余部分尚未执行,因此数据结构的一致性可能已受到损害。

描述

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

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

为了防止共享数据结构的损坏,JVM 将尝试延迟抛出 a StackOverflowError,直到相关线程退出其所有关键部分。每个 Java 线程在其执行堆栈中定义了一个新区域,称为保留区域。仅当 Java 线程jdk.internal.vm.annotation.ReservedStackAccess在其当前调用堆栈中具有注释的方法时,才能使用此区域。当 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.如果找到带注释的方法,则解除对临界区的保护,并将Thread与带注释的方法相关的最外层激活(帧)的堆栈指针存储在C++对象中。

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

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

抛出 aStackOverflowError是 Java 通知应用程序线程已达到其堆栈限制的方法。然而,Java 代码有时会捕获异常和错误,并且通知会丢失或未正确处理,这会使问题的调查变得非常困难。为了简化对存在保留堆栈区域的堆栈溢出错误的故障排除,当授予对保留堆栈区域的访问权限时,JVM 会提供另外两个通知:一个是 JVM 打印的警告(与所有其他 JVM 消息在同一流上) ,第二个是 JFR 事件。请注意,即使StackOverflowError由于关键部分中引发了另一个异常而未引发延迟,也会生成 JVM 警告和 JFR 事件并可用于故障排除。

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

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

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

表现

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

此解决方案中成本最高的操作是在调用堆栈中查找带注释的方法时执行的堆栈遍历。仅当 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立即抛出 a。应用程序的 JVM 行为没有变化。但是,JVM 警告和 JFR 事件可以帮助进行故障排除,表明发生了潜在的有害情况。

备择方案

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

基于语言的解决方案:

  • try//构造:它们没有解决任何问题catchfinally因为不能保证该finally子句也不会触发堆栈溢出。

  • 新的构造例如:

    new CriticalSection(
    () -> {
    // do critical section code
    }).enter();

    此构造可能需要在javacJVM 中进行大量工作,并且与保留的堆栈区域相比,它的使用可能会对性能产生很大影响,即使不在堆栈溢出情况下运行也是如此。

代码转换解决方案:

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

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

基于堆栈的解决方案:

  • 扩展堆栈碰撞:在进入关键部分之前进一步碰撞堆栈:即使不在堆栈溢出情况下,此解决方案也会产生性能成本,并且很难使用嵌套的关键部分进行维护。

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

测试

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

依赖关系

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