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

Rei*_*ica 82 java deadlock fork-join java-8 java-stream

我遇到了一个奇怪的情况,在静态初始化程序中使用带有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) {}
}
Run Code Online (Sandbox Code Playgroud)

这似乎是此行为的最小再现测试用例.如果我:

  • 将块放在main方法而不是静态初始化器中,
  • 删除并行化,或
  • 删除lambda,

代码立即完成.谁能解释这种行为?这是一个错误还是这个意图?

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

Tun*_*aki 70

我发现了一个非常相似的情况下(的bug报告JDK-8143380这是封闭的"不是一个问题"司徒雷登商标):

这是一个类初始化死锁.测试程序的主线程执行类静态初始化程序,它为类设置初始化进程中的标志; 静态初始化程序完成之前,此标志保持设置状态.静态初始化程序执行并行流,这会导致在其他线程中计算lambda表达式.这些线程阻止等待类完成初始化.但是,主线程被阻塞等待并行任务完成,从而导致死锁.

应更改测试程序以将并行流逻辑移出类静态初始化程序之外.关闭不是问题.


我能找到(另一个bug报告JDK-8136753),也关闭了为"不是一个问题"斯图尔特标志:

这是一个死锁,因为Fruit枚举的静态初始化程序与类初始化交互不良.

有关类初始化的详细信息,请参阅Java语言规范,第12.4.2节.

http://docs.oracle.com/javase/specs/jls/se8/html/jls-12.html#jls-12.4.2

简而言之,发生的事情如下.

  1. 主线程引用Fruit类并启动初始化过程.这将设置初始化进行中标志并在主线程上运行静态初始化程序.
  2. 静态初始化程序在另一个线程中运行一些代码并等待它完成.此示例使用并行流,但这与流本身无关.通过任何方式在另一个线程中执行代码,并等待该代码完成,将具有相同的效果.
  3. 另一个线程中的代码引用Fruit类,它检查初始化进程中的标志.这会导致另一个线程阻塞,直到清除该标志.(参见JLS 12.4.2的第2步.)
  4. 主线程被阻塞,等待另一个线程终止,因此静态初始化器永远不会完成.由于在静态初始化程序完成之前不会清除初始化进行中标志,因此线程会死锁.

要避免此问题,请确保类的静态初始化快速完成,而不会导致其他线程执行要求此类完成初始化的代码.

关闭不是问题.


请注意,FindBugs有一个未解决的问题,即为此情况添加警告.

  • _"当我们设计功能"_和_时就考虑了这一点我们知道是什么导致了这个错误而不是如何修复它"_不**意味着_"这不是一个错误"_.这绝对是一个错误. (20认同)
  • @ bayou.io主要问题是在静态初始化程序中使用线程,而不是lambdas. (12认同)
  • @ bayou.io:在类级别上它与构造函数中的相同,在对象构造期间让`this`逃脱.基本规则是,不要在初始化程序中使用多线程操作.我不认为这很难理解.将lambda实现的函数注册到注册表的示例是另一回事,它不会创建死锁,除非您要等待这些被阻塞的后台线程.尽管如此,我强烈反对在类初始化程序中执行此类操作.这不是他们的意思. (12认同)
  • 我想编程风格的教训是:保持静态初始化器简单. (9认同)
  • BTW Tunaki感谢您挖掘我的错误报告.:-) (4认同)

Tam*_*dus 16

对于那些想知道其他线程在哪里引用Deadlock类本身的人来说,Java lambdas的行为就像你写的那样:

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) {}
}
Run Code Online (Sandbox Code Playgroud)

使用常规匿名类没有死锁:

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) {}
}
Run Code Online (Sandbox Code Playgroud)

  • @ Solomonoff'sSecret lambda可能在运行时创建一个类(通过[java.lang.invoke.LambdaMetafactory](https://docs.oracle.com/javase/8/docs/api/java/lang/invoke/LambdaMetafactory. html)),但lambda体必须放在编译时的某个地方.因此,lambda类可以利用某些VM魔法比从.class文件加载的普通类更便宜. (6认同)
  • @ Solomonoff'sSecret这是一个实现选择.lambda中的代码必须去某个地方.Javac将它编译成包含类中的静态方法(类似于本例中的`lambda1`).将每个lambda放入自己的类中会相当昂贵. (5认同)
  • @ Solomonoff的秘密:不要通过查看像你的"我 - >我"这样琐碎的lambda表达来判断; 他们不会是常态.Lambda表达式可以使用它们周围类的所有成员,包括`private`,这使得定义类本身成为它们的自然位置.让所有这些用例都受到针对类初始化程序的特殊情况进行优化的实现,多线程使用简单的lambda表达式,而不是使用它们的定义类的成员,这不是一个可行的选择. (3认同)
  • @StuartMarks 鉴于 lambda 创建了一个实现函数式接口的类,那么将 lambda 的实现放入函数式接口的 lambda 的实现中不是和本文的第二个示例一样有效吗?这当然是显而易见的做事方式,但我确信他们这样做是有原因的。 (2认同)

Ada*_*ker 13

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);
    }
} 
Run Code Online (Sandbox Code Playgroud)

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

在这里,我们再次看到死锁[在本文前面的类初始化器中有一些死锁的例子].由于parallel()流操作在单独的线程池中运行.这些线程尝试执行lambda body,它以字节码编写,作为类中的private static方法StreamSum.但是这个方法不能在类静态初始化器完成之前执行,它等待流完成的结果.

更有意思的是:此代码在不同环境中的工作方式不同.它可以在单个CPU机器上正常工作,并且很可能挂在多CPU机器上.这种差异来自Fork-Join池实现.您可以自己更改参数进行验证-Djava.util.concurrent.ForkJoinPool.common.parallelism=N