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

23

注意:这不是一个重复的问题,请仔细阅读主题 https://stackoverflow.com/users/3448419/apangin 引用:

真正的问题是,为什么代码有时候会在不应该的情况下起作用。 即使没有lambda表达式,问题也能够重现。这让我觉得可能存在JVM漏洞。

https://stackoverflow.com/a/53709217/2674303的评论中,我试图找出代码的行为从一次启动到另一次启动的不同原因,并且讨论的参与者给了我一个建议,创建一个单独的主题。

我们来看一下以下源代码:

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

你能解释一下那个行为吗?


3
多线程的本质与时间密切相关。如果它总是失败或从不失败,那么“多个线程”的概念就不会那么难理解。 - GhostCat
9
这不是重复的问题。真正的问题是代码有时会在不应该的情况下起作用。即使没有lambda函数,这个问题还是会出现。这让我觉得可能存在JVM bug。稍后我会再检查一下。 - apangin
2
同意@apangin的观点。当我们将System.out.println("Finished");移动到static {}块的末尾时,我们可以清楚地显示工作线程成功执行了lambda主体,而类初始化尚未完成,即这不是流操作过早返回的问题。请注意,该示例对于较新版本的Java来说有些不幸,因为从Java 9开始,count()将跳过整个处理并返回可预测的大小。因此,.map(i -> { System.out.println("map: "+Thread.currentThread().getName()+" "+i); return 1; }).sum();可能更好。 - Holger
3
我现在确信这是一个与常量池解析相关的JVM bug。我还发现了一个非常古老的类似bug[JDK-4493560],涉及静态字段访问。看起来该bug已经修复了getstatic/putstatic字节码,但没有修复invokestatic - apangin
2
暂时没有时间找到根本原因。在深入研究后,我会发布答案,并可能提交错误报告。 - apangin
显示剩余18条评论
1个回答

20
TL;DR 这是一个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) {
    }
}

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

Called from main
Called from Thread-2
Initialization complete

为什么这是一个bug

JVMS §6.5要求在执行invokestatic字节码时,如果尚未初始化声明解析方法的类或接口,则必须对其进行初始化。

如果尚未初始化声明解析方法的类或接口,则必须对其进行初始化

Thread-2调用staticTarget时,主类StaticInit显然未初始化(因为其静态初始化器仍在运行)。这意味着Thread-2必须启动JVMS §5.5中描述的类初始化过程。根据该过程,

  1. 如果C的Class对象表明初始化正在被另一个线程进行,则释放LC并阻塞当前线程,直到收到通知,指示已完成正在进行的初始化

但是,尽管类正在由线程main进行初始化,但Thread-2没有被阻塞。

其他JVM怎么样

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

为什么会发生

当解释器第一次遇到invokestatic字节码时,它调用JVM运行时来解析方法引用。作为此过程的一部分,如果需要,JVM会初始化类。成功解析后,解析的方法将保存在常量池缓存条目中。常量池缓存是HotSpot特定的结构,用于存储已解析的常量池值。
在上述示例中,main线程首先解析调用staticTargetinvokestatic字节码。解释器运行时跳过类初始化,因为该类已由同一线程初始化。解析的方法保存在常量池缓存中。下次Thread-2执行相同的invokestatic时,解释器看到字节码已经解析,并使用常量池缓存条目而不调用运行时,从而跳过了类初始化。 getstatic/putstatic的类似错误早已被修复 - JDK-4493560,但修复并未涉及invokestatic。我已提交新的错误JDK-8215634以解决此问题。
至于原始示例,它是否挂起取决于哪个线程首先解析静态调用。如果是main线程,则程序完成而没有死锁。如果静态调用由其中一个ForkJoinPool线程解析,则程序会挂起。
更新

这个漏洞已经确认。它将在即将发布的版本JDK 8u201、JDK 11.0.2和JDK 12中得到修复。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接