谜题77:搞乱锁的妖怪

下面的这段程序模拟了一个小车间。程序首先启动了一个工人线程,该线程在停
止时间到来之前会一直工作(至少是假装在工作),然后程序安排了一个定时器
任务(timer task)用来模拟一个恶毒的老板,他会试图阻止停止时间的到来。
最后,主线程作为一个善良的老板会告诉工人停止时间到了,并且等待工人停止
工作。那么这个程序会打印什么呢?

import java.util.*;
public class Worker extends Thread {
private volatile boolean quittingTime = false;
public void run() {
while (!quittingTime)
pretendToWork();
System.out.println("Beer is good");
}
private void pretendToWork() {
try {
Thread.sleep(300); // Sleeping on the job?

} catch (InterruptedException ex) { }
}
// It's quitting time, wait for worker - Called by good boss
synchronized void quit() throws InterruptedException {
quittingTime = true;
join();
}
// Rescind quitting time - Called by evil boss
synchronized void keepWorking() {
quittingTime = false;
}
public static void main(String[] args)
throws InterruptedException {
final Worker worker = new Worker();
worker.start();
Timer t = new Timer(true); // Daemon thread
t.schedule(new TimerTask() {
public void run() { worker.keepWorking(); }
}, 500);
Thread.sleep(400);
worker.quit();
}
}

  

想要探究这个程序到底做了什么的最好方法就是手动地模拟一下它的执行过程。
下面是一个近似的时间轴,这些时间点的数值是相对于程序的开始时刻进行计算
的:
• 300 ms:工人线程去检查易变的 quittingTime 域,看看停止时间是否已
经到了。这个时候并没有到停止时间,所以工人线程会回去继续“工作”。
• 400ms:作为善良的老板的主线程会去调用工人线程的 quit 方法。主线程
会获得工人线程实例上的锁(因为 quit 是一个同步化的方法),将
quittingTime 的值设为 true,并且调用工人线程上的 join 方法。这个对
join 方法的调用并不会马上返回,而是会等待工人线程执行完毕。
• 500m:作为恶毒的老板定时器任务开始执行。它将试图调用工人线程的
keepWorking 方法,但是这个调用将会被阻塞,因为 keepWorking 是一个
同步化的方法,而主线程当时正在执行工人线程上的另一个同步化方法
(quit 方法)。
• 600ms:工人线程会再次检查停止时间是否已经到来。由于 quittingTime
域是易变的,那么工人线程肯定会看到新的值 true,所以它会打印 Beer
is good 并结束运行。这会让主线程对 join 方法的调用执行返回,随后
主线程也结束了运行。而定时器线程是后台的,所以它也会随之结束运行,
整个程序也就结束了。

所以,我们会认为程序将运行不到 1 秒钟,打印 Beer is good ,然后正常的结
束。但是当你尝试运行这个程序的时候,你会发现它没有打印任何东西,而是一
直处于挂起状态(没有结束)。我们的分析哪里出错了呢?
其实,并没有什么可以保证上述几个交叉的事件会按照上面的时间轴发生。无论
是 Timer 类还是 Thread.sleep 方法,都不能保证具有实时(real-time)性。这
就是说,由于这里计时的粒度太粗,所以上述几个事件很有可能会在时间轴上互
有重叠地交替发生。100 毫秒对于计算机来说是一段很长的时间。此外,这个程
序被重复地挂起;看起来好像有什么其他的东西在工作着,事实上,确实是有这
种东西。
我们的分析存在着一个基本的错误。在 500ms 时,当作为恶毒老板的定时器任务
运行时,根据时间轴的显示,它对 keepWorking 方法的调用会被阻塞,因为
keepWorking 是一个同步化的方法并且主线程正在同一个对象上执行着同步化
方法 quit(在 Thread.join 中等待着)。这些都是对的,keepWorking 确实是一个
同步化的方法,并且主线程确实正在同一个对象上执行着同步化的 quit 方法。
即使如此,定时器线程仍然可以获得这个对象上的锁,并且执行 keepWorking
方法。这是如何发生的呢?
问题的答案涉及到了 Thread.join 的实现。这部分内容在关于该方法的文档中
(JDK 文档)是找不到的,至少在迄今为止发布的文档中如此,也包括 5.0 版。
在内部,Thread.join 方法在表示正在被连接(join)的那个 Thread 实例上调
用 Object.wait 方法。这样就在等待期间释放了该对象上的锁。在我们的程序中,
这就使得作为恶毒老板的定时器线程能够堂而皇之的将 quittingTime 重新设置
成 false,尽管此时主线程正在执行同步化的 quit 方法。这样的结果是,工人
线程永远不会看到停止时间的到来,它会永远运行下去。作为善良的老板的主线
程也就永远不会从 join 方法中返回了。
使这个程序产生了预料之外的行为的根本原因就是 WorkerThread 类的作者使用
了实例上的锁来确保 quit 方法和 keepWorking 方法的互斥,但是这种用法与超
类(Thread)内部对该锁的用法发生了冲突。这里的教训是:除非有关于某个类
的详细说明作为保证,否则千万不要假设库中的这个类对它的实例或类上的锁会
做(或者不会做)某些事情。对于库的任何调用都可能会产生对 wait、notify、
notifyAll 方法或者某个同步化方法的调用。所有这些,都可能对应用级的代码
产生影响。
如果你需要获得某个锁的完全控制权,那么就要确定没有任何其他人能够访问到
它。如果你的类扩展了库中的某个类,而这个库中的类可能使用了它的锁,或者
如果某些不可信的人可能会获得对你的类的实例的访问权,那么请不要使用与这
个类或它的实例自动关联的那些锁。取而代之的,你应该在一个私有的域中创建
一个单独的锁对象。在 5.0 版本发布之前,用于这种锁对象的正确类型只有
Object 或者它的某个普通的子类。从 5.0 版本开始,
java.util.concurrent.locks 提供了 2 种可选方案:ReentrantLock 和
ReentrantReadWriteLock。相对于 Object 类,这 2 个类提供了更好的机动性,
但是它们使用起来也要更麻烦一点。它们不能被用在同步化的语句块

(synchronized block)中,而且必须辅以 try-finally 语句对其进行显式的获
取和释放。
订正这个程序最直接的方法是添加一个 Object 类型的私有域作为锁,并且在
quit 和 keepWorking 方法中对这个锁对象进行同步。通过上述修改之后,该程
序就会打印出我们所期望的 Beer is good。可以看出,该程序能够产生正确行
为并不依赖于它必须遵从我们前面分析的时间轴:

private final Object lock = new Object();
// It's quitting time, wait for worker - Called by good boss
void quit() throws InterruptedException{
synchronized (lock){
quittingTime = true;
join();
}
}
// Rescind quitting time - Called by evil boss
void keepWorking(){
synchronized(lock){
quittingTime = false;
}
}

  


另外一种可以修复这个程序的方法是让 Worker 类实现 Runnable 而不是扩展
Thread,然后在创建每个工人线程的时候都使用 Thread(Runnable)构造器。这
样可以将每个 Worker 实例上的锁与其线程上的锁进行解耦。这是一个规模稍大
一些的重构。
正如库类对锁的使用会干扰应用程序一样,应用程序中对锁的使用也会干扰库
类。例如,在迄今为止发布的所有版本的 JDK(包括 5.0 版本)中,为了创建一
个新的 Thread 实例,系统都会去获取 Thread 类上的锁。而执行下面的代码就可
以阻止任何新线程的创建:

synchronized(Thread.class){
Thread.sleep(Long.MAX_VALUE);
}

  


总之,永远不要假设库类会(或者不会)对它的锁做某些事情。为了隔离你自己
的程序与库类对锁的使用,除了那些专门设计用来被继承的库类之外,请避免继
承其它库类 [EJ Item 15]。为了确保你的锁不会遭受外部的干扰,可以将它们
设为私有以阻止其他人对它们的访问。
对于语言设计者来说,需要考虑的是为每个对象都关联一个锁是否是合适的。如
果你决定这么做了,就需要考虑限制对这些锁的访问。在 Java 中,锁实际上是
对象的公共属性,或许它们变为私有的会更有意义。同时请记住在 Java 语言中,

一个对象实际上就是一个锁:你在对象本身之上进行同步。如果每个对象都有一
个锁,而且你可以通过调用一个访问器方法来获得它,这样或许会更有意义。

posted @ 2018-10-24 02:08  尐鱼儿  阅读(138)  评论(0编辑  收藏  举报