为什么添加此防御性副本可以避免死锁?



这是原始代码。此程序最终可能会死锁,因为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) { }
}
}

教科书的作者建议在迭代之前更改updateProgress以创建ArrayList<ProgressListener> listeners的防御性副本。

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. 这是线程安全的,因为现在您有一个"本地"副本,至少是该特定线程的本地副本,并且另一个线程对其本地副本所做的操作不会影响您。

  4. 您可以通过onProgress方法更新侦听器。但是,此更改仅是listeners副本的本地更改。

  5. updateProgress返回,但"本地"更改如何传播到"原始"listeners?由于它是一个克隆,它们是单独的对象,但它们如何将更新传达回彼此?

这就是我坚持的部分。

一个真正病态的案例是:

  • 其中一个侦听器启动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.

您的问题标题与末尾的问题不匹配。这个问题的标题是关于避免死锁,安迪·特纳已经回答了这个问题。他描述了一个场景,死锁是如何发生的,结论是你自己在问题中给出的:你通过握住锁时不调用"外星方法"来避免死锁。

由于这似乎已经理解了,那么您的问题实际上是一个完全不同的问题。你会问,"'本地'变化如何传播给'原始'听众?

答案是,没有这样的局部变化。您列表的第 3 个项目符号是错误的。本地副本不仅是当前线程的本地副本,而且副本是updateProgress方法的本地副本,任何其他代码都看不到。

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

请注意,在单线程方案中,此副本甚至是必需的。许多List实现,最明显的是ArrayList,不支持在有人迭代它时被修改,即使修改是由执行迭代的同一线程进行的(除了通过用于迭代的相同Iterator修改列表,这在这里不适用)。

替代方案是CopyOnWriteArrayList,它在迭代时不需要复制,但在列表被修改时,或者多播模式,它可能只需要(部分)复制,当侦听器数量变得很大时(但对于小数字非常有效)。有关此模式的示例实现,请参阅AWTEventMulticaster

最新更新