这是原始代码。此程序最终可能会死锁,因为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
获得的原始锁的时间。我理解为什么它减少了锁的时间,但不了解它如何避免调用带有锁的外星方法。这是我的思路。
它创建数组列表
listeners
的克隆。此克隆是一个单独的对象,其中包含原始listener
具有的确切元素。
现在这是线程安全的,因为现在您有一个"本地"副本,至少是该特定线程的本地副本,并且另一个线程对其本地副本所做的操作不会影响您。
您可以通过
onProgress
方法更新侦听器。但是,此更改仅是listeners
副本的本地更改。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
方法的本地副本,任何其他代码都看不到。
因此,当通过addListener
或removeListener
对侦听器列表进行修改时,无论由哪个线程进行修改,都会直接影响对象的listener
字段引用的列表,该列表仍然与以前相同。但此更改不会影响本地副本,updateProgress
方法正在迭代。由于updateProgress
只是遍历本地副本,因此它永远不会有任何需要传播到原始列表的修改。
请注意,在单线程方案中,此副本甚至是必需的。许多List
实现,最明显的是ArrayList
,不支持在有人迭代它时被修改,即使修改是由执行迭代的同一线程进行的(除了通过用于迭代的相同Iterator
修改列表,这在这里不适用)。
替代方案是CopyOnWriteArrayList
,它在迭代时不需要复制,但在列表被修改时,或者多播模式,它可能只需要(部分)复制,当侦听器数量变得很大时(但对于小数字非常有效)。有关此模式的示例实现,请参阅AWTEventMulticaster
。