【问题标题】:Deadlock caused by creating a new thread during class initialization类初始化时创建新线程导致死锁
【发布时间】:2017-07-21 20:19:52
【问题描述】:

我刚刚注意到在类的静态初始化期间创建和启动多个线程会导致死锁并且没有线程启动。如果我在类初始化后动态运行相同的代码,这个问题就会消失。这是预期的行为吗?

简短的示例程序:

package com.my.pkg;

import com.google.common.truth.Truth;
import org.junit.Test;

import java.util.Collection;
import java.util.concurrent.Callable;
import java.util.concurrent.Future;
import java.util.concurrent.FutureTask;
import java.util.stream.Collectors;
import java.util.stream.IntStream;

public class MyClass {
    private static final Collection<Integer> NUMS = getNums();

    @Test
    public void fork_doesNotWorkDuringClassInit() {
        // This works if you also delete NUMS from above: 
        // Truth.assertThat(getNums()).containsExactly(0, 1, 2, 3, 4);
        Truth.assertThat(NUMS).containsExactly(0, 1, 2, 3, 4);
    }

    private static Collection<Integer> getNums() {
        return IntStream.range(0, 5)
                        .mapToObj(i -> fork(() -> i))
                        .map(MyClass::get)
                        .collect(Collectors.toList());
    }

    public static <T> FutureTask<T> fork(Callable<T> callable) {
        FutureTask<T> futureTask = new FutureTask<>(callable);
        Thread thread = new Thread(futureTask);
        thread.start();
        return futureTask;
    }

    public static <T> T get(Future<T> future) {
        try {
            return future.get();
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}

【问题讨论】:

  • Thread thread = new Thread(futureTask); ==> 不好。 Future&lt;Integer&gt; future = executor.submit(callable); ==> 好。你不应该手动启动线程,使用executors
  • 不,那不是程序。它甚至不是一个完整的课程。如果您需要故障排除帮助,请提供minimal reproducible example
  • @alfasin,这是一个教条的答案。使用ExecutorService 是有原因的,创建裸Thread 对象也是有原因的。一些反对调用new Thread(...) 的论点可以通过调用threadFactory.newThread(...) 来处理。
  • 我对流的了解并不多,但这看起来像是无操作:.collect(Collectors.toList()).stream()。我错了,还是这只是将 T 流变成 T 流的一种迂回方式?
  • @alfasin 上面的示例程序并不是好的代码。相反,它旨在强调我试图更好地理解的令人惊讶的行为。

标签: java multithreading concurrency thread-safety


【解决方案1】:

是的,这是预期行为。

这里的基本问题是您试图在类初始化完成之前从另一个线程访问该类。它恰好是您在类初始化期间启动的另一个线程,但这没有任何区别。

在 Java 中,类在第一次引用时被延迟初始化。当一个类尚未完成初始化时,引用该类的线程会尝试获取类初始化锁。第一个获得类初始化锁的线程会初始化该线程,并且该初始化必须在其他线程继续之前完成。

在这种情况下,fork_doesNotWorkDuringClassInit() 开始初始化,获得类初始化锁。但是,初始化会产生额外的线程,这些线程会尝试调用 lambda 可调用 () -&gt; i。可调用对象是该类的成员,因此这些线程随后被类初始化锁阻塞,该锁由开始初始化的线程持有。

很遗憾,您的初始化过程需要其他线程的结果才能完成初始化。它阻塞那些结果,而这些结果又在初始化完成时被阻塞。线程最终死锁。

更多关于类初始化的信息:

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

一般来说,Java 初始化器和构造器的功能是有限的——比在 C++ 中的情况要有限得多。这可以防止某些类型的错误,但也可以限制您可以执行的操作。这是这些限制之一的示例。

【讨论】:

  • 非常有趣。一个问题。您提到:“初始化会产生额外的线程,这些线程试图调用类的 get() 方法。然后这些线程在类初始化锁上被阻塞”。我看到死锁仍然发生,即使我将 get() 方法完全移到类之外。这是为什么?为什么新线程继续被类初始化锁阻塞,即使它们没有访问任何类变量/方法?
  • “完全在课堂之外”是什么意思?显示一些代码。您还可以运行jstack 来查看死锁的具体表现(并且您可以包含输出)。另请注意,fork() 在另一个线程的上下文中被调用,并且也必须被移动。 @RvPr
  • @BeeOnRope 我已经将 get(Future) 方法剪切粘贴到 MyClass 之外的另一个类中。我是根据 Warren 的评论做到这一点的,即“初始化会产生额外的线程,这些线程试图调用类的 get() 方法”。但现在当我仔细观察时,我意识到额外的线程实际上并没有调用 get 方法。他们正在运行的唯一任务是“() -> i”可调用的。即使我用硬编码的“5”替换了“i”,它仍然会死锁。我想知道其他线程何时何地尝试获取类锁
  • @BeeOnRope 刚刚尝试了最后一个实验,将() -&gt; i 替换为OtherClass::methodReturningInt,现在僵局消失了。似乎() -&gt; i lambda 链接到 MyClass 以便当线程尝试访问它时,它们会死锁。但是当他们调用一个完全驻留在其他地方的可调用对象时,就不会再出现死锁了。
  • 我们的 cmets 在传输过程中交叉,但是是的,lambda 通常实现为声明类的隐藏内部/嵌套类,因此它还需要等待包含类的静态初始化。
【解决方案2】:

在类静态初始化期间,对类本身持有一个锁,以阻止尝试使用该类的其他线程,以便它们等待静态初始化完成。这通常被称为“类加载器锁”或“静态初始化”锁1

如果执行静态初始化调用的代码尝试访问同一线程上的类的其他静态状态,您将不会遇到死锁,因为锁是递归的并且允许拥有的线程返回: 这是 JLS 要求的。这也适用于递归初始化,其中class A 的静态初始化最终触发class B 的初始化,其静态初始化最终访问class A 中的静态状态。虽然它不会死锁,但您经常会看到尚未初始化的静态成员的默认值(例如,null0 等)。

当您触发与上述相同类型的情况跨线程时,您会遇到死锁,因为静态初始化锁不会让其他线程重新进入。


1 以前的名称不一定准确,因为类加载器本身可能在内部使用 other 锁来保护其结构超出静态初始化锁。

【讨论】:

  • 感谢您的回答。欣赏它。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 2021-03-14
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2020-08-29
  • 2021-08-02
  • 1970-01-01
相关资源
最近更新 更多