为什么在静态初始化程序中使用带有lambda的并行流会导致死锁?

92

我遇到了一个奇怪的情况,使用静态初始化程序中带有lambda表达式的并行流需要花费很长时间,但没有CPU利用率。以下是代码:

class Deadlock {
    static {
        IntStream.range(0, 10000).parallel().map(i -> i).count();
        System.out.println("done");
    }
    public static void main(final String[] args) {}
}

这似乎是该行为的最小复现测试用例。如果我:

  • 将块放入主方法中而不是静态初始化程序中
  • 删除并行化,或者
  • 删除lambda表达式

代码将立即完成。有人能解释这种行为吗? 这是一个错误还是有意为之?

我正在使用OpenJDK版本1.8.0_66-internal。


4
使用范围 (0, 1) 时程序正常终止;使用 (0, 2) 或更高值时会挂起。 - Laszlo Hirdi
5
相似问题:https://dev59.com/7pHea4cB1Zd3GeqPwPNu在静态初始化器中使用lambda表达式的invokeAndWait方法会一直挂起,有什么解决办法?请注意不要改变原意。 - Alex - GlassEditor.com
2
实际上,这是完全相同的问题/问题,只是使用不同的API。 - Didier L
3
当您尚未完成类初始化时,正在尝试在后台线程中使用该类,因此无法在后台线程中使用它。 - Peter Lawrey
5
@Solomonoff'sSecret中的i -> i不是一个方法引用,而是实现在Deadlock类中的一个静态方法。如果将i -> i替换为Function.identity(),那么这段代码就应该可以正常工作了。 - Peter Lawrey
显示剩余6条评论
3个回答

74

我找到了一个非常类似的bug报告(JDK-8143380),被Stuart Marks视为“无问题”:

这是一个类初始化死锁。测试程序的主线程执行类静态初始化器,该初始化器设置类的初始化标志;直到静态初始化器完成,这个标志才会保持设置状态。静态初始化器执行并行流,导致lambda表达式在其他线程中评估。那些线程阻塞等待类完成初始化。然而,主线程被阻塞等待并行任务完成,从而导致死锁。

测试程序应更改,将并行流逻辑移出类静态初始化器。作为"无问题"关闭。


我能够找到另一个相关的 bug 报告 (JDK-8136753),也被 Stuart Marks 标记为 "不是问题" 并关闭:

这是一个死锁问题,因为Fruit枚举的静态初始化器与类初始化交互不良。请参见Java语言规范12.4.2节,了解有关类初始化的详细信息。简而言之,发生的情况如下: 1.主线程引用Fruit类并启动初始化过程。这将设置初始化正在进行的标志,并在主线程上运行静态初始化器。 2.静态初始化器在另一个线程中运行一些代码并等待其完成。此示例使用并行流,但这与流本身无关。通过任何方式在另一个线程中执行代码并等待该代码完成都会产生相同的效果。 3.其他线程中的代码引用Fruit类,该类检查初始化正在进行的标志。这会导致其他线程阻塞,直到清除该标志。 (请参见JLS 12.4.2的第2步。) 4.主线程被阻塞等待其他线程终止,因此静态初始化器永远无法完成。由于初始化正在进行的标志直到静态初始化器完成后才被清除,因此线程被死锁。 为避免此问题,请确保类的静态初始化快速完成,而不会导致其他线程执行需要完成初始化的类的代码。关闭为“未解决问题”。
请注意,FindBugs有一个未解决的问题,需要添加警告来处理这种情况。

21
“在我们设计这个功能时已经考虑到了这一点”和“我们知道什么导致了这个错误,但不知道如何修复它”并不意味着“这不是一个错误”。这绝对是一个错误。 - BlueRaja - Danny Pflughoeft
14
主要问题是在静态初始化器中使用线程,而不是 lambda 表达式。 - Stuart Marks
6
顺便说一下,Tunaki,感谢您挖出我的错误报告。 :-) - Stuart Marks
15
在类级别上,与构造函数中一样,允许在对象构建期间将 this 逃逸。基本规则是不要在初始化程序中使用多线程操作。我认为这不难理解。你提供的将 lambda 实现函数注册到注册表的示例是另一回事,除非你要等待其中一个被阻塞的后台线程,否则不会导致死锁。尽管如此,我强烈反对在类初始化器中执行此类操作。这不是它们的目的。 - Holger
11
我猜编程风格的课程是:保持静态初始化器简单。 - Raedwald
显示剩余4条评论

20

对于那些想知道其他引用Deadlock类本身的线程在哪里的人,Java Lambda表达式的行为就像你写了这个一样:

public class Deadlock {
    public static int lambda1(int i) {
        return i;
    }
    static {
        IntStream.range(0, 10000).parallel().map(new IntUnaryOperator() {
            @Override
            public int applyAsInt(int operand) {
                return lambda1(operand);
            }
        }).count();
        System.out.println("done");
    }
    public static void main(final String[] args) {}
}

使用普通的匿名类不会导致死锁:

public class Deadlock {
    static {
        IntStream.range(0, 10000).parallel().map(new IntUnaryOperator() {
            @Override
            public int applyAsInt(int operand) {
                return operand;
            }
        }).count();
        System.out.println("done");
    }
    public static void main(final String[] args) {}
}

7
这是一种实现选择。Lambda 中的代码需要放置在某个地方。Javac 编译它成为包含类中的静态方法(类似于此示例中的 lambda1)。将每个 Lambda 放入其自己的类中会更加昂贵。@Solomonoff'sSecret - Stuart Marks
2
鉴于lambda创建了实现函数接口的类,将lambda的实现放在函数接口的lambda实现中是否同样有效,就像本帖子第二个示例中那样?这当然是做事情的显而易见的方式,但我相信它们被这样做的原因。 - Reinstate Monica
6
@Solomonoff'sSecret 该 Lambda 可能会在运行时创建一个类(通过 java.lang.invoke.LambdaMetafactory),但 Lambda 主体必须在编译时放置在某个地方。因此,Lambda 类可以利用一些虚拟机技巧,比从 .class 文件加载的普通类更具性价比。 - Jeffrey Bosboom
1
@Solomonoff的秘密 是的,Jeffrey Bosboom的回复是正确的。 如果在将来的JVM中可以向现有类添加方法,则元工厂可能会这样做,而不是生成一个新类。(纯属猜测。) - Stuart Marks
4
@Solomonoff的秘密:不要仅凭类似于i -> i这样微不足道的lambda表达式来评判它们;它们并不是常规情况。Lambda表达式可以使用其周围类的所有成员,包括private成员,这使得定义类本身成为它们的自然位置。让所有这些用例都受到针对具有多线程使用微不足道lambda表达式的类初始化器的特殊情况进行优化的实现的影响,而不使用它们所定义的类的成员,这不是一个可行的选项。 - Holger
显示剩余10条评论

18

这个问题有一个出色的解释,作者是Andrei Pangin,日期为2015年4月7日。文章链接在此,但是它是用俄语写的(我建议仍然可以查看代码示例-它们是国际通用的)。这个普遍性的问题是类初始化期间的锁。

以下是文章中的一些引用:


根据JLS的规定,每个类都有一个独特的初始化锁,在初始化期间会被占用。当其他线程在初始化期间尝试访问此类时,它将被阻塞在锁上,直到初始化完成。当类同时进行初始化时,可能会发生死锁。

我编写了一个简单的程序来计算整数的和,它应该打印什么?

public class StreamSum {
    static final int SUM = IntStream.range(0, 100).parallel().reduce((n, m) -> n + m).getAsInt();

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

现在删除parallel()或者用Integer::sum代替lambda调用 - 会有什么变化?

我们再次看到死锁(本文之前有一些类初始化程序的死锁示例)。由于parallel()流操作运行在一个单独的线程池中, 这些线程试图执行lambda主体,这被写成StreamSum类内部的private static方法的字节码。但是,在等待流完成的结果之前,此方法无法在类静态初始化器完成之前执行。

更令人惊讶的是:此代码在不同的环境中表现不同。在单CPU机器上它可以正常工作,但在多CPU机器上很可能会挂起。这种差异来自Fork-Join池的实现。您可以通过更改参数-Djava.util.concurrent.ForkJoinPool.common.parallelism=N来验证它。


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