为什么在静态初始化器中使用并行流会导致不稳定的死锁

Why using parallel streams in static initializer leads to not stable deadlock

注意:它不是重复的,请仔细阅读主题сarefully https://whosebug.com/users/3448419/apangin 引述:

The real question is why the code sometimes works when it should not. The issue reproduces even without lambdas. This makes me think there might be a JVM bug.

的评论中,我试图找出代码从一开始到另一次表现不同的原因,那次讨论的参与者向我提出了创建一个单独主题的建议。

让我们考虑以下源代码:

public class Test {
    static {
        System.out.println("static initializer: " + Thread.currentThread().getName());

        final long SUM = IntStream.range(0, 5)
                .parallel()
                .mapToObj(i -> {
                    System.out.println("map: " + Thread.currentThread().getName() + " " + i);
                    return i;
                })
                .sum();
    }

    public static void main(String[] args) {
        System.out.println("Finished");
    }
}

有时(几乎总是)它会导致死锁。

输出示例:

static initializer: main
map: main 2
map: ForkJoinPool.commonPool-worker-3 4
map: ForkJoinPool.commonPool-worker-3 3
map: ForkJoinPool.commonPool-worker-2 0

但有时它会成功完成(非常罕见):

static initializer: main
map: main 2
map: main 3
map: ForkJoinPool.commonPool-worker-2 4
map: ForkJoinPool.commonPool-worker-1 1
map: ForkJoinPool.commonPool-worker-3 0
Finished

static initializer: main
map: main 2
map: ForkJoinPool.commonPool-worker-2 0
map: ForkJoinPool.commonPool-worker-1 1
map: ForkJoinPool.commonPool-worker-3 4
map: main 3

你能解释一下这种行为吗?

TL;DR This is a HotSpot bug JDK-8215634

问题可以通过一个完全没有竞争的简单测试用例重现:

public class StaticInit {

    static void staticTarget() {
        System.out.println("Called from " + Thread.currentThread().getName());
    }

    static {
        Runnable r = new Runnable() {
            public void run() {
                staticTarget();
            }
        };

        r.run();

        Thread thread2 = new Thread(r, "Thread-2");
        thread2.start();
        try { thread2.join(); } catch (Exception ignore) {}

        System.out.println("Initialization complete");
    }

    public static void main(String[] args) {
    }
}

这看起来像是 classic 初始化死锁,但 HotSpot JVM 不会挂起。相反,它打印:

Called from main
Called from Thread-2
Initialization complete

为什么这是一个错误

JVMS §6.5 要求在执行 invokestatic 字节码时

the class or interface that declared the resolved method is initialized if that class or interface has not already been initialized

Thread-2调用staticTarget时,主classStaticInit显然是未初始化的(因为它的静态初始化器仍然是运行)。这意味着 Thread-2 必须启动 class 在 JVMS §5.5 中描述的初始化程序。按照这个程序,

  1. If the Class object for C indicates that initialization is in progress for C by some other thread, then release LC and block the current thread until informed that the in-progress initialization has completed

但是,尽管 class 正在由线程 main 进行初始化,但 Thread-2 并未被阻塞。

其他 JVM 呢

我测试了 OpenJ9 和 JET,它们都在上述测试中出现了死锁。
有趣的是,HotSpot 在 -Xcomp 模式下也会挂起,但在 -Xint 或混合模式下不会。

它是如何发生的

当解释器第一次遇到 invokestatic 字节码时,它调用 JVM 运行时来解析方法引用。作为此过程的一部分,JVM 在必要时初始化 class。解析成功后解析的方法保存在常量池缓存条目中。常量池缓存是一个特定于 HotSpot 的结构,用于存储已解析的常量池值。

在上面的测试中,调用staticTargetinvokestatic字节码首先被main线程解析。解释器运行时跳过 class 初始化,因为 class 已经被同一个线程初始化。解析后的方法保存在常量池缓存中。下次 Thread-2 执行相同的 invokestatic 时,解释器看到字节码已经解析并使用常量池缓存条目而不调用运行时,因此跳过 class 初始化。

很久以前就修复了 getstatic/putstatic 的类似错误 - JDK-4493560, but the fix did not touch invokestatic. I've submitted the new bug JDK-8215634 以解决此问题。

关于原来的例子,

挂不挂取决于哪个线程先解析静态调用。如果它是 main 线程,程序将在没有死锁的情况下完成。如果静态调用由 ForkJoinPool 个线程之一解析,程序将挂起。

更新

错误是 confirmed。它已在即将发布的版本中修复:JDK 8u201、JDK 11.0.2 和 JDK 12.