【问题标题】:Why does parallel stream with lambda in static initializer cause a deadlock?为什么静态初始化程序中带有 lambda 的并行流会导致死锁?
【发布时间】:2016-04-21 13:52:24
【问题描述】:

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

【问题讨论】:

  • 范围为 (0, 1) 程序正常终止。有 (0, 2) 或更高的挂起。
  • 其实是同一个问题/问题,只是API不同而已。
  • 您正在尝试在后台线程中使用一个类,但您尚未完成该类的初始化,因此它无法在后台线程中使用。
  • @Solomonoff'sSecret as i -> i 不是方法引用,它是在 Deadlock 类中实现的 static method。如果将i -> i 替换为Function.identity(),则此代码应该没问题。

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


【解决方案1】:

我发现了一个非常相似的案例 (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 has an open issue for adding a warning 适用于这种情况。

【讨论】:

  • “我们在设计功能时考虑到了这一点”“我们知道是什么导致了这个错误,但不知道如何修复它”not 表示“这不是错误”。这绝对是一个错误。
  • @bayou.io 主要问题是在静态初始化程序中使用线程,而不是 lambdas。
  • BTW Tunaki 感谢您挖掘我的错误报告。 :-)
  • @bayou.io:在类级别上和在构造函数中是一样的,让this 在对象构造期间逃逸。基本规则是,不要在初始化程序中使用多线程操作。我不认为这很难理解。您将 lambda 实现的函数注册到注册表中的示例是另一回事,它不会产生死锁,除非您要等待这些阻塞的后台线程。尽管如此,我强烈反对在类初始化器中执行此类操作。这不是他们的本意。
  • 我想编程风格的教训是:保持静态初始化器简单。
【解决方案2】:

对于那些想知道引用 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) {}
}

【讨论】:

  • @Solomonoff'sSecret 这是一个实现选择。 lambda 中的代码必须去某个地方。 Javac 将其编译为包含类中的静态方法(类似于 lambda1 在此示例中)。将每个 lambda 放入其自己的类中会更加昂贵。
  • @StuartMarks 既然 lambda 创建了一个实现函数式接口的类,那么将 lambda 的实现放在函数式接口的 lambda 的实现中不是和第二个示例一样有效吗这个帖子的?这当然是显而易见的做事方式,但我相信他们这样做是有原因的。
  • @Solomonoff'sSecret lambda 可能会在运行时创建一个类(通过java.lang.invoke.LambdaMetafactory),但 lambda 主体必须在编译时放置在某个地方。因此,lambda 类可以利用一些 VM 魔法,比从 .class 文件加载的普通类更便宜。
  • @Solomonoff'sSecret 是的,Jeffrey Bosboom 的回复是正确的。如果在未来的 JVM 中可以向现有类添加方法,元工厂可能会这样做,而不是旋转一个新类。 (纯属猜测。)
  • @Solomonoff 的秘密:不要通过查看像 i -> i 这样微不足道的 lambda 表达式来判断;他们不会成为常态。 Lambda 表达式可以使用其周围类的所有成员,包括private 的成员,这使得定义类本身成为它们的自然位置。让所有这些用例都受到针对类初始化器的特殊情况优化的实现的影响,这些初始化器具有多线程使用平凡的 lambda 表达式,而不使用其定义类的成员,这不是一个可行的选择。
【解决方案3】:

Andrei Pangin 于 2015 年 4 月 7 日对这个问题进行了很好的解释。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() 或将 lambda 替换为 Integer::sum 调用 - 会有什么变化?

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

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

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 2019-04-03
    • 1970-01-01
    • 2015-03-09
    • 2019-12-27
    • 2011-09-15
    相关资源
    最近更新 更多