为什么添加这个防御性拷贝可以避免死锁?

3
这是原始代码。这个程序可能会出现死锁,因为 updateProgress 方法调用了另一个方法,该方法可能会或可能不会获取另一个锁。而我们已经获取了这两个锁,但不知道是否按正确的顺序完成。
import java.io.*;
import java.net.URL;
import java.util.ArrayList;

class Downloader extends Thread {
  private InputStream in;
  private OutputStream out;
  private ArrayList<ProgressListener> listeners;

  public Downloader(URL url, String outputFilename) throws IOException {
    in = url.openConnection().getInputStream();
    out = new FileOutputStream(outputFilename);
    listeners = new ArrayList<ProgressListener>();
  }
  public synchronized void addListener(ProgressListener listener) {
    listeners.add(listener);
  }
  public synchronized void removeListener(ProgressListener listener) {
    listeners.remove(listener);
  }
  private synchronized void updateProgress(int n) {
    for (ProgressListener listener: listeners)
      listener.onProgress(n);
  }

  public void run() {
    int n = 0, total = 0;
    byte[] buffer = new byte[1024];

    try {
      while((n = in.read(buffer)) != -1) {
        out.write(buffer, 0, n);
        total += n;
        updateProgress(total);
      }
      out.flush();
    } catch (IOException e) { }
  }
}

教科书的作者建议在迭代遍历 ArrayList<ProgressListener> listeners 之前,将 updateProgress 更改为创建 防御性拷贝

private void updateProgress(int n) {
    ArrayList<ProgressListener> listenersCopy;
    synchronized(this) {
      listenersCopy = (ArrayList<ProgressListener>)listeners.clone();
    }
    for (ProgressListener listener: listenersCopy)
      listener.onProgress(n);

这样做避免了在持有锁时调用“外来”方法,并减少原始锁(在updateProgress中获取)的持有时间。我知道它为什么会减少锁的持有时间,但不知道它如何避免在持有锁时调用外部方法。以下是我的思路:

  1. 它创建了一个listeners的数组列表副本。这个副本是一个独立的对象,包含与原始listener完全相同的元素。

  2. 现在这是线程安全的,因为你有一个“本地”副本,至少对于该特定线程来说是本地的,另一个线程对其本地副本所做的任何操作都不会影响到你。

  3. 您通过onProgress方法更新侦听器。然而,这种更改仅针对您的listeners副本是本地的。

  4. updateProgress返回,但是“本地”更改如何传播到“原始”listeners呢?由于它是一个克隆,它们是分离的对象,但它们如何相互通信以进行更新呢?

这就是我卡住的部分。


2
附注:在这里使用CopyOnWriteArrayList可能会更好。 - Andy Turner
我认为这一切所做的只是缓解其中一个监听器在其 onProgress 实现期间调用 addListenerremoveListener 的病态情况。 - Oliver Charlesworth
@OliverCharlesworth 但是这肯定与死锁无关,而是会导致“ConcurrentModificationException”。 - Andy Turner
@AndyTurner - 我认为原始代码会死锁。锁是通过进入“updateProgress”来获取的,然后调用“listener.onProgress”,然后(病态地)调用“addListener”,该方法尝试获取锁。 - Oliver Charlesworth
哦,除了synchronized方法是可重入的。所以我不明白这里预期出现了什么问题。 - Oliver Charlesworth
除了教学简单之外,教科书作者选择使用不安全的转换而不是 new ArrayList<>(listeners) 这一事实让我对该书的权威性和专业水平产生了质疑。 - VGR
2个回答

4

一个真正病态的情况可能是这样的:

  • 其中一个监听器启动了一个 Thread
  • 该线程试图调用 Downloader 上的同步方法
  • 调用监听器的线程(启动新线程的线程)在启动的线程上调用 join

类似于:

class Pathological implements ProgressListener {
  // Initialize in ctor.
  final Downloader downloader;

  @Override void onProgress(int n) {
    Thread t = new Thread(() -> downloader.removeListener(Pathological.this));
    t.start();
    t.join();
  }
}

在这种情况下会产生死锁,因为启动的线程在第一个线程持有监视器时无法取得进展。
采用防御性复制可避免这种情况,因为当调用Pathological.onProgress时,第一个线程不再持有监视器;但我仍然更喜欢使用另一种设计用于处理并发访问的列表实现,例如CopyOnWriteArrayList

比我想象的更加病态 ;) - Oliver Charlesworth
@OliverCharlesworth 我相信唯一适当的回应是“咩哈哈哈哈”。 - Andy Turner
或者使用多播器模式,当监听器数量较少时特别高效。 - Holger
谢谢您的解释。这本书没有解释那个问题——代码来自于《七周七并发模型》。 - bigstones

0

你的问题标题与最后的问题不符。问题的标题是关于避免死锁,这已经被Andy Turner回答过了。他描述了一个死锁可能发生的场景,并且你在问题中给出了结论:在持有锁时不调用“外部方法”可以避免死锁。

既然这似乎已经被理解了,那么你的问题实际上是完全不同的。你正在问,“‘本地’更改如何传播到‘原始’侦听器?”

答案是,没有这样的本地更改。你列表中的第三个项目是错误的。本地副本不仅局限于当前线程,而且该副本仅局限于updateProgress方法,其他任何代码都看不到它。

因此,无论哪个线程进行修改,通过addListenerremoveListener对监听器列表进行的修改都直接影响到您对象的listener字段引用的列表,该列表仍然与之前相同。但是,这种更改不会影响updateProgress方法正在迭代的本地副本。由于updateProgress仅迭代本地副本,因此它永远不会有任何需要传播到原始列表的修改。

请注意,在单线程场景中,甚至需要这个副本。许多List实现,特别是ArrayList,不支持在某人正在迭代它时进行修改,即使修改是由执行迭代的同一线程进行的(除了通过用于迭代的相同Iterator修改列表,这里不适用)。

替代方案有CopyOnWriteArrayList,在迭代时不需要复制,但当列表被修改时需要;或者使用多播模式,只在移除监听器时可能需要(部分)复制,但当监听器数量增加时可能变得低效(但对于少量监听器来说非常高效)。请查看AWTEventMulticaster以获取这种模式的一个实现示例


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接