-4

为测试构建了以下代码。在这个测试中,读者被要求解释为什么代码在启动代码后不到一秒就会进入死锁。

谁能准确描述导致此代码死锁的原因?

public class Test {

  static class FailerThread implements Runnable {

    final Object[] objects;
    final Random random;
    final int number;

    public FailerThread(final Object[] objects, final int number) {
      this.objects = objects;
      this.random = new Random();
      this.number = number;
    }

    @Override
    public void run() {
      final boolean isWriter = number % 2 == 0;
      int index = random.nextInt(objects.length);
      try {
        while (Thread.interrupted() == false) {
          synchronized (objects) {
            if (isWriter) {
              while (objects[index] == null) {
                System.out.println(number + ": Index " + index + " is null, waiting...");
                objects.wait();
              }
              for (int copyIndex = 0; copyIndex < objects.length; ++copyIndex) {
                if (objects[copyIndex] == null) {
                  objects[copyIndex] = this.objects[index];
                }
              }
              objects.notifyAll();
            } else {
              objects[index] = null;
            }
          }

          ++index;
          if (index >= objects.length) {
            index = 0;
          }
        }
      } catch (InterruptedException e) {
      }
    }
  }

  public static void main(String[] args) throws InterruptedException {
    final Object[] objects = new Object[10];
    for (int i = 0; i < objects.length; ++i) {
      objects[i] = new Object();
    }

    final int NUM_THREADS = 32;
    final ExecutorService executor = Executors.newFixedThreadPool(NUM_THREADS);
    for (int i = 0; i < NUM_THREADS; ++i) {
      executor.execute(new FailerThread(objects, i));
    }
  }
}

编辑:这个测试的官方答案(类似于都铎写的,只是更详细)

上述构造死锁,因为在某些时候所有的“作家”都在等待一个空值,但由于这些作家是唯一可以释放它们的人,他们将无限期地挂起。然而,更重要的问题是:为什么?

乍一看,这些代码看起来像是那些编写者占主导地位。每个循环都会选择一个线程(写入器或 nuller)来处理数组,但是虽然 nuller 只写入单个 null,但写入器会消除数组中的所有 null。所以人们会期望死锁 - 虽然可能 - 是非常不可能的(但令人惊讶的是代码在一秒钟内死锁)。然而,仔细观察,这个假设被证明是错误的,因为我们正在处理线程。

给定足够的执行时间,在多线程应用程序中重要的是:代码的哪一部分实际上能够阻塞?让我们看一下 writer/nullers 可能出现的最坏情况:

  • 在最坏的情况下,nuller 可以在没有任何影响的情况下执行。也就是说:它将 null 写入数组中已经为 null 的位置。

  • 作家可以 - 在最坏的情况下 - 无限期地阻止。

此外,在同步块的开始,选择一个(或多或少)随机候选者进入。一开始,对于写入者和无效者来说,这都是 50%,但是对于每个被阻止的写入者,机会偏向无效者的方向。即使成功的写入消除了所有空值,但 nullers 的机会始终是 50% 或更多,因为写入程序的机会(由于阻塞)不断减少。因此,从线程的角度来看,nullers 实际上是主要部分,因为整个系统旨在支持它们作为同步块的候选者。

此外——这是重要的部分——线程的执行顺序是未定义的。一个天真的印象是允许哪个线程执行alternate,但事实并非如此。同步块没有偏好,哪个线程获得访问权限是未定义的(可以说:完全随机,尽管不涉及随机)。因此,在所有 16 个线程都在同步等待的情况下,20 个执行线程内完美交替的机会,正好等于连续调用 20 个写入器或 20 个 nuller 的机会。但是由于 nuller 占主导地位(20 个 writer 什么都不做),连续调用 20 个 nuller 几乎可以保证将整个数组设置为 null,这会导致任何后续 writer 无限期地阻塞。

如果您在代码中添加更多日志输出以查看实际选择了哪个线程,您很快就会看到连续调用 10 个或更多 nuller,通常在前 200 个循环内。之后系统挂起。

为什么问这个问题

我目前正在开发一个用于评估专家 Java 程序员的测试集,并且编写了所有代码,最终需要对其进行测试。好消息:它成功了。;)

现在,在您抱怨 StackOverflow 使用不当之前:请将此视为问答。对于多线程架构的实际实现,可以从这个示例中学到很多东西。由于这是一个专家级别的问题 - 正如预期的那样 - 没有多少人能够回答它甚至理解它。然而,专家级问题的好处是,您也可以从专家级答案中学到很多东西。这就是为什么我包含了完整详细的答案。

候选人的评分方式

预见到有些人会认为这个问题对于评估测试来说太难了,并且为了给你测试者的观点,这就是候选人的评分方式:

是的,这道题太难了,没有人期望在考试中找到正确的答案,重要的是他们如何解决问题。程序员每天都会遇到他/她以前从未解决过的任务并且不知道如何立即解决,因此具有良好的解决问题的能力在这个行业中很重要。没有人可以无所不知,但每个人都可以学习。

一般有4种可能的结果:

  1. 候选人不知道答案并这么说。这是一个很好的初学者水平,因为考生有能力在紧张的考试情况下承认这一点。一个好学生是一个善于倾听的人,因此可以被教导。

  2. 候选人现在确实知道答案,但要么责备“糟糕”的问题(又名投反对票),要么给出了错误的答案,然后他/她对此进行了激烈的辩护。这基本上是最坏情况的候选人:他处于初级/中级水平,但认为自己是专家,因此拒绝学习,将被困在这个水平。在一个团队中,这个候选人要么会阻碍团队的进步(如果他们认为他是“专家”),要么很快就会成为讨厌的人。

  3. 候选人提出一个(或多或少正确的)答案,并使用有条不紊的方法来找到它。这是一个很好的中级/专家级候选人。他/她已经开发出一种有条不紊的方法来处理具有挑战性的任务,并且根据答案可以预期会进一步发展。

  4. 候选人使用有条不紊的方法并提出正确的答案。这是最好的结果,但遇到的可能只有百万分之一。

4

3 回答 3

0

不知道这是否是您期望的答案,但我可以看到满足这两个条件时发生的死锁:

  1. 至少有 10 个“读者”(非写者)能够连续进入同步块,而不允许任何写者继续进行。
  2. 从 0 到 9 的每个数组索引至少由一个通过锁的读取器随机获取一次。

由于您有 16 个读取器和 16 个写入器,并且上述情况适用,因此 10 个读取器在随机数生成器上选择 0 到 9 可以使整个数组为空,从而导致所有写入器被阻塞,因为它们的相应索引在他们可以时为空进入while循环。

编辑:事实上,它甚至比这更简单:甚至不需要 10 个读者连续进入锁。如果有 K 个读者进入锁,并且数组中的 i 个位置为空,with 0 < i <= K(因为索引可以重叠),那么如果在他们之后进来的写者在前面的读者使用的集合中都有索引,它们将被阻塞。由于读取器最终会将整个数组清空,因此如果所描述的情况重复出现,可能会导致所有写入器在有限的迭代次数中阻塞。

于 2013-11-22T18:51:44.800 回答
-1

这不是死锁,因为所有线程只有一个同步资源。

简单地说,死锁是当两个线程需要两个资源来执行某些操作时,一个线程抢占第一个资源,第二个线程抢占第二个资源,两者都无法继续。

在您的情况下,只是所有线程都陷入无限睡眠或无限循环。

您代码中的所有线程都分为两组,我将它们称为“编写器”(具有 isWrite==true 的那些)和“空值符”(从技术上讲,它们也可以编写,但它们总是写空值)。

在某些时候,“nullififer”线程遍历数组并将所有元素设置为空。它可以通过单个“nullifier”线程来完成,因为在将一个设置为 null 之后,没有什么可以阻止它继续到数组的下一个元素,以及在几次迭代过程中的几个。

没有“作家”可以继续,因为他们在当前元素为空时执行 objects.wait()。所以他们陷入无限的睡眠。

所有“nullifier”线程都会一遍又一遍地用空值无限覆盖数组。

即使一开始“writer”线程获得更多的处理器时间,最终它们也会被“nullifiers”溢出,因为“writers”有停止条件,而“nullifiers”没有。

更新:顺便说一句,您不必在“if”语句中执行布尔比较。你可以写

 while (!Thread.interrupted())

哪个更具可读性和简洁性,并且是一种常见的做法。

更新 2:您可以尝试通过在“nullifier”的 else 语句中添加 objects.wait() 来修复,类似于“writer's” if 子句中的那个,如下所示:

} else {        
    objects[index] = null;
    while (objects[index]==null) {
        System.out.println(number + ": Index " + index + " is still null, waiting...");
        objects.wait();
    }
}

我不知道这段代码应该完成什么(它看起来就像一些随机练习),所以我不确定解决方案在语义上是否正确,但它应该解决“肆虐的无效符”问题。

更新 3:如果您在循环的开头添加线程类型的日志记录,您可以很快看到只有运行的线程是那些 isWriter==false
编辑:最好在同步后进行日志记录

while (Thread.interrupted() == false) {
    synchronized (objects) {
        System.out.println("running isWriter=" + isWriter + " thread #" + number);
于 2013-11-22T12:10:52.077 回答
-1

提问者发布的“官方答案”在很多方面都是错误的,我将为将来偶然发现这个问题的人发布另一个答案。

在有人开始争辩说我是“最坏情况”的候选人之前,他“疯狂地为错误的答案辩护”(如海报所暗示的那样):

  • 我进行了日志记录和断点调试(显然与原始海报不同,或者他只是没有为此投入足够的时间)。
  • 我根本不是候选人,我只是想按预期使用 Stackoverflow,提供我能提供的最佳答案,如果不是为了提问者的利益,那么其他将要访问的人
  • 我这样做是因为从原始发帖人的回答中,其他人会得到不正确的同步概念。
  • 不是我对问题投了反对票,尽管具有讽刺意味的是,原始海报对我的回答投了反对票

更不用说用对基础知识的错误假设来回答“专家”级别的问题不会给你正确的答案。

同步

在答案中,海报多次提到 nuller 只写入一个值:

但是,虽然 nuller 只写入一个 null,但 writer 会消除数组中的所有 null。

在最坏的情况下,nuller 可以在没有任何影响的情况下执行。也就是说:它将 null 写入数组中已经为 null 的位置。

代码中没有任何东西使它成为现实。

只留下同步子句不会使线程放弃执行另一个线程。

同步的唯一目的是保证没有两个线程会同时进入同一个临界区,基本上它只是一个互斥体。

内在锁和同步

互斥

java中有几种方法可以使线程停止控制:

  • Thread.yield() -提示jvm 它可以将执行交给另一个线程。基本上它对 jvm 说:“我在这里做了很多工作,如果你愿意,你可以给其他人一些处理器时间,但我也可以继续。”
  • Thread.sleep() - 将线程挂起固定的时间。
  • Object.wait() - 暂停线程,直到有人在同一个对象上调用 notify(只唤醒一个服务员)/notifyAll(唤醒所有服务员)。
  • 还有其他的,但它们是相似的,通常基于这些

但是它们都没有用于nullers!所以绝对不能保证 nuller 只会写入一个值。

事实上,由于一次迭代的计算强度较低,在线程接收到的执行窗口期间,它更有可能进行多次迭代。

但同样你不必相信我,只需进行日志记录和调试。如果没有实际测试,这一切都只是一种蛊惑人心的东西。(更新:注意,日志记录应该在同步块内)

带有程序输出的屏幕截图,显示 nuller 如何连续重置多个值,然后一个写入器覆盖所有值,然后 nuller 重置所有元素,从而导致所描述的问题 请注意,第一个 nuller 在没有被 writer 中断的情况下连续重置几个值(与海报所说的相反),然后 writer 覆盖数组,然后 nuller 重置所有数组(一次几个元素),而 writer 进入睡眠状态,导致描述问题。

更新:此外作者说

此外——这是重要的部分——线程的执行顺序是未定义的。一个天真的印象是允许哪个线程执行alternate,但事实并非如此。

然而,由于期望在执行 nuller 之后该线程不能成为进入同步的线程,因此他自己成为了这种“幼稚印象”的受害者。

概率驱动开发

即使一次成功的写入消除了所有的空值,空值的机会总是 50% 或更多

这只是将整个概率论抛到了窗外,更不用说基于概率编写代码确实是个坏主意,尤其是当它涉及多线程和长执行时间时(无限猴子定理)。

尽管在一件事情上作者是对的:随着时间的推移,空想家会越来越多,而作家会越来越少。

更新:之所以重要,是因为您根本不应该从概率的角度谈论多线程,因为如果某些情况是可能的,无论它在某个时候发生多么不可能。

多线程应该根据最坏的情况来讨论,就像作者在开始时一样,尽管他未能确定 nuller 的实际最坏情况,即当单个 nuller 线程迭代所有数组而不被中断并将所有值设置为 null 时。

更新:为了演示不正确的 50/50 机会分配方式,请考虑以下简化示例:

假设我们有两个线程并且我们没有为它们设置优先级,所以默认情况下它们都有 java.lang.Thread.NORM_PRIORITY。调度程序将尽可能地在它们之间或多或少地平均分配处理器时间。

然而,一个线程遍历一个大数组,这需要一分钟。
另一个只设置数组的一个元素,它需要一秒钟。
而且它们都在一个对象上同步,因此它们不能同时执行。

在乞求调度程序将控制权交给第一个线程并开始遍历数组,即使调度程序会尝试中断它并给第二个线程一些时间,第二个线程也无法继续,因为第一个线程已经获得了锁。

因此,当一分钟过去并且第一个线程释放锁定时,调度程序认为这对他来说已经足够了,它必须给第二个线程大约一分钟的时间,因为它们都具有相同的优先级,但是因为其关键部分中的第二个线程只需要一秒钟它可以输入它〜60次。

当然,示例被简化了,会有抖动,有时调度程序会给出不相等的时间块,但总体而言,它会尝试根据线程的优先级为线程分配处理器时间。

因此推断“在 50% 的情况下,将是作家进入临界区,因为作家和 nullers 的数量相同”类似于一个古老的轶事:

- What's the probability that leaving your home you will see an alive dinosaur?
- 50% either I will see it or not.

于 2013-11-24T09:17:50.290 回答