为什么在静态初始化器中使用lambda进行并行stream导致死锁?
我遇到了一个奇怪的情况,在静态初始化器中使用带有lambda的并行stream看似永远没有CPU利用率。 代码如下:
class Deadlock { static { IntStream.range(0, 10000).parallel().map(i -> i).count(); System.out.println("done"); } public static void main(final String[] args) {} }
这似乎是这种行为的最小再现testing案例。 如果我:
- 把该块放在主要的方法而不是一个静态初始化器,
- 删除并行,或
- 删除lambda,
代码即刻完成。 任何人都可以解释此行为? 这是一个错误还是这是打算?
我正在使用OpenJDK版本1.8.0_66内部。
我发现了一个非常相似的案例( JDK-8143380 )的错误报告,被Stuart Marksclosures为“不是问题”
这是一个类初始化的死锁。 testing程序的主线程执行类静态初始化程序,它为类设置初始化正在进行中的标志; 这个标志保持设置,直到静态初始化完成。 静态初始化器执行并行stream,这会导致在其他线程中评估lambdaexpression式。 那些线程阻塞等待类完成初始化。 但是,主线程被阻塞,等待并行任务完成,导致死锁。
应该改变testing程序来移动类静态初始化程序之外的并行stream逻辑。 closures不是问题。
我能够find另一个bug报告( JDK-8136753 ),也被Stuart Marksclosures为“不是问题”:
这是一个死锁,因为水果枚举的静态初始化与类初始化交互不良。
有关类初始化的详细信息,请参阅Java语言规范第12.4.2节。
http://docs.oracle.com/javase/specs/jls/se8/html/jls-12.html#jls-12.4.2
简而言之,发生的事情如下。
- 主线程引用Fruit类并开始初始化过程。 这将设置初始化正在进行中的标志,并在主线程上运行静态初始化程序。
- 静态初始化器在另一个线程中运行一些代码并等待它完成。 这个例子使用并行stream,但是这与stream本身无关。 以任何方式在另一个线程中执行代码,并等待代码完成,将会产生相同的效果。
- 另一个线程中的代码引用了Fruit类,该类检查初始化进程中的标志。 这导致其他线程阻塞,直到标志被清除。 (见JLS 12.4.2的第2步。)
- 主线程被阻塞,等待另一个线程终止,所以静态初始化程序永远不会完成。 由于初始化进程中标志不会被清除,直到静态初始化完成后,线程才会死锁。
为了避免这个问题,确保一个类的静态初始化快速完成,而不会导致其他线程执行需要该类完成初始化的代码。
closures不是问题。
请注意, FindBugs对于为这种情况添加警告有一个公开的问题 。
对于那些想知道其他线程引用Deadlock
类本身的人,Java lambdaperformance得像你写的:
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) {} }
Andrei Pangin在2015年4月7日对这个问题有一个很好的解释,可以在这里find ,但是它是用俄语写的(我build议你回顾一下代码示例 – 它们是国际化的)。 一般的问题是在类初始化时locking。
以下是文章中的一些引用:
根据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
调用replacelambda – 会改变什么?
在这里我们再次看到死锁[在本文前面的类初始化器中有一些死锁的例子]。 由于parallel()
stream操作在单独的线程池中运行。 这些线程尝试执行lambda主体,这是写在字节码中作为StreamSum
类中的private static
方法。 但是这个方法在完成类静态初始化之前不能执行,等待stream完成的结果。
更令人震惊的是:这个代码在不同的环境中工作方式不同。 它可以在一台CPU机器上正常工作,并且很可能挂在多CPU机器上。 这种差异来自Fork-Join池的实现。 你可以自己validation它是否改变参数-Djava.util.concurrent.ForkJoinPool.common.parallelism=N