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

时间:2016-01-15 21:22:57

标签: java java-8 deadlock java-stream fork-join

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

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

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

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

我使用的是OpenJDK版本1.8.0_66-internal。

3 个答案:

答案 0 :(得分:66)

我发现了一个非常相似的案例(JDK-8143380)的错误报告,该案例被关闭为"不是问题"作者:Stuart Marks:

  

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

     

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

我能够找到另一个错误报告(JDK-8136753),也被关闭为"不是问题"作者:Stuart Marks:

  

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

     

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

     

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

     

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

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

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

         

    关闭不是问题。

请注意FindBugs has an open issue for adding a warning这种情况。

答案 1 :(得分: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) {}
}

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

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) {}
}

答案 2 :(得分:13)

2015年4月7日Andrei Pangin对此问题有一个很好的解释。它可用here,但它是用俄语写的(我建议无论如何审查代码示例 - 它们是国际)。一般问题是在类初始化期间锁定。

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

根据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 body,它在字节码中用private static类内的StreamSum方法编写。但是这个方法不能在类静态初始化器完成之前执行,它会等待流完成的结果。

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

进行验证