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


86

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

代码立即完成。谁能解释这种行为?它是错误还是故意的?

我正在使用1.8.0_66-内部的OpenJDK版本。


4
在范围(0,1)下,程序正常终止。挂起(0,2)或更高。
Laszlo Hirdi '16


2
实际上,这是完全相同的问题/问题,只是使用了不同的API。
Didier L

3
当您尚未完成对类的初始化时,您尝试在后台线程中使用该类,以便无法在后台线程中使用该类。
彼得·劳瑞

4
@ Solomonoff'sSecreti -> i不是方法引用,它是static method在Deadlock类中实现的。如果i -> iFunction.identity()此代码替换应该没问题。
彼得·劳瑞

Answers:


71

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

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

应该更改测试程序,以将并行流逻辑移到类静态初始值设定项之外。闭幕不是问题。


我能够找到有关该错误的另一个错误报告(JDK-8136753),也被Stuart Marks封闭为“不是问题”:

这是一个死锁,因为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
“这被认为是我们在设计功能”“我们知道是什么原因导致这个错误但不知道如何解决它”并不意味着“这是不是一个错误”。这绝对是一个错误。
BlueRaja-Danny Pflughoeft

13
@ bayou.io主要问题是在静态初始化程序中使用线程,而不是lambdas。
斯图尔特·马克

5
BTW Tunaki感谢您挖掘我的错误报告。:-)
斯图尔特(Stuart Marks)

13
@ bayou.io:在类级别上与在构造函数中是相同的,因此this在对象构造期间可以转义。基本规则是,不要在初始化程序中使用多线程操作。我认为这很难理解。将lambda实现的函数注册到注册表中的示例是另一回事,除非您要等待这些阻止的后台线程之一,否则它不会创建死锁。但是,我强烈不鼓励在类初始化程序中执行此类操作。这不是他们的意思。
Holger

9
我想编程风格的一课是:使静态初始化器保持简单。
Raedwald

16

对于那些想知道其他线程在哪里引用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) {}
}

5
@ Solomonoff'sSecret这是一个实现选择。Lambda中的代码必须放在某个地方。Javac将其编译为包含类中的静态方法(类似于lambda1本示例)。将每个lambda放到自己的类中要贵得多。
斯图尔特·马克

1
@StuartMarks假设lambda创建了一个实现功能接口的类,那么将lambda的实现放到功能接口的lambda的实现中是否像本文第二个示例一样高效?那当然是做事的明显方式,但是我敢肯定,为什么他们按照自己的方式做事是有原因的。
恢复莫妮卡

6
@ Solomonoff'sSecret lambda可能会在运行时创建一个类(通过java.lang.invoke.LambdaMetafactory),但是lambda主体必须在编译时放置在某个位置。因此,lambda类可以利用某些VM魔术,比从.class文件加载的普通类便宜。
Jeffrey Bosboom

1
@ Solomonoff'sSecret是的,Jeffrey Bosboom的回复是正确的。如果在将来的JVM中可以将方法添加到现有类中,则元工厂可能会这样做,而不是旋转新类。(纯粹的猜测。)
Stuart Marks

3
@Solomonoff的秘密:不要通过看像您这样琐碎的lambda表达式来判断i -> i;他们不会成为常态。Lambda表达式可以使用其周围类的所有成员(包括成员)private,这使定义类本身成为其自然位置。让所有这些用例都遭受针对类初始化程序的特殊情况而优化的实现,该实现具有琐碎的lambda表达式的多线程使用,而不使用其定义类的成员,这是不可行的选择。
Holger

14

日期为2015年4月7日的Andrei Pangin对这个问题有很好的解释。可以在此处找到,但是它是用俄语编写的(无论如何,我建议您检查代码示例-它们是国际性的)。通常的问题是在类初始化期间加锁。

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


根据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()lambda删除或替换为call-Integer::sum将会发生什么变化?

在这里,我们再次看到了死锁(本文前面的类初始化程序中有死锁的一些示例)。由于parallel()流操作在单独的线程池中运行。这些线程尝试执行lambda主体,该主体以字节码形式编写为类中的一种private static方法StreamSum。但是,该方法不能在类静态初始化程序完成之前执行,后者将等待流完成的结果。

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

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.