译注:本文展示了实现一个多线程同步工具类的过程当中,会遇到和解决那些与并发有关的问题。关于锁的更详细的文章,以及现实当中应该用哪种公平锁,推荐这篇文章。
饥饿与公平
原文地址: http://tutorials.jenkov.com/j...
如果一个线程没有被分配到 CPU 执行时间,该线程就处于“饥饿”状态。如果总是分配不到 CPU 执行时间(因为总是被分配到其他线程去了),那么该线程可能会被“饿死”。有一种策略用于避免出现该问题,称作“公平策略”,即保证所有的线程都能公平地得到被执行的机会。
产生饥饿的原因
在 Java 中,有三种最普遍的情形会导致饥饿的发生:
- 高优先级的线程总是吞占 CPU 执行时间,导致低优先级的线程没有机会;
- 某些线程总是能被允许进入
synchronized
块,以致某些线程总是得不到机会; - 某些线程在等待指定的对象(即调用了该对象的
wait()
方法)时,完全得不到唤醒的机会,因为被唤醒的总是别的线程。
高优先级的线程总是吞占 CPU 执行时间
每个线程都可以单独设置优先级。优先级越高,该线程就能获得更多的 CPU 执行时间。优先级的值最低为 1 最高为 10。至于如何根据优先级来分配 CPU 执行时间,则依赖于操作系统的具体实现。在大多数应用中,我们最好不要去擅自修改它。
线程无限等待进入 synchronized 块的机会
Java 当中的 synchronized 代码块也是导致饥饿的一个因素。它不保证线程进入的顺序,所以理论上某个线程可能永远无法进入 synchronized 块,这种情况下可以说这个线程就被“饿死”了。
线程无限等待被锁对象唤醒的机会
当多个线程同时调用的某个对象的 wait() 方法并等待时,notify() 方法不保证一定能唤醒哪个指定的线程。所以如果它总是不去唤醒某个线程的话,这个线程就处于永久性地等待当中了。
如何在 Java 中实现公平策略
当然我们没办法实现 100% 的绝对公平,但还是可以通过一些结构上的设计来增加线程之间的公平性。
首先我们来看一个简单的 synchronized 代码块:
public class Synchronizer{
public synchronized void doSynchronized(){
//do a lot of work which takes a long time
}
}
当多个线程调用 doSynchronized()
方法时,只有一个线程能够进入该方法并执行,而且该线程退出该方法后,正在等待的线程中无法保证哪一个才是接下来可以进入的。
用锁对象来代替 synchronized 块
为了增强公平性,第一步我们先把 synchronized 块改为锁对象:
public class Synchronizer{
Lock lock = new Lock();
public void doSynchronized() throws InterruptedException{
this.lock.lock();
// critical section, do a lot of work which takes a long time
// 需要同步执行的代码
this.lock.unlock();
}
}
请注意 doSynchronized()
方法本身现在不再是同步的了,需要同步执行的代码现在由lock.lock()
和 lock.unlock()
保护起来。
那么 Lock 类简单的实现是下面这个样子:
public class Lock{
private boolean isLocked = false;
private Thread lockingThread = null;
public synchronized void lock() throws InterruptedException{
while(isLocked){
wait();
}
isLocked = true;
lockingThread = Thread.currentThread();
}
public synchronized void unlock(){
if(this.lockingThread != Thread.currentThread()){
throw new IllegalMonitorStateException(
"Calling thread has not locked this lock");
}
isLocked = false;
lockingThread = null;
notify();
}
}
结合上面 Synchronizer 类和这里的 Lock 实现,你会看到:首先,当多个线程调用 lock() 方法时,它们会被阻塞;其次,当 Lock 对象处于锁住状态时,进入 lock() 方法的线程会在 wait() 语句处阻塞。这里要注意:当线程成功调用 wait() 方法时,会自动释放 Lock 对象的锁,于是其他的线程能够得以进入 lock() 方法,最终会有多个线程都阻塞在 wait() 语句处。
我们回头看 doSynchronized() 方法中 lock() 和 unlock() 之间的部分,假设这部分代码需要很长时间来执行,甚至比线程在 wait() 语句处等待所花的时间都长的多。那么线程获得锁所需的时间主要也是耗在 wait() 语句处,而不是进入 lock() 方法的时候。
在目前这个版本的代码中,不论线程是在 synchronized 块阻塞,还是在 wait() 处阻塞,都不能保证哪个线程能一定被唤醒,所以目前的代码尚未提供公平策略。
(译注:之所以改成这样,目的是令线程在进入 lock() 方法时的阻塞时间尽可能短,也就是所有的线程都在 wait() 处阻塞,以便实施接下来的改动。)
目前版本的 Lock 对象是在调用自身的 wait() 方法。我们改掉这点,让每个线程调用不同对象的 wait() 方法的话,那么就可以自行挑选调用哪个对象的 notify() 方法,以此实现自行挑选唤醒哪个线程。
公平锁
下面的代码展示了将 Lock 类转化为 FairLock 类的结果。请注意同步方式和 wait()
/notify()
的调用方式有了哪些的变化。
整个改动的实现是阶段性的,这个过程中需要依次解决内部锁对象死锁、同步条件丢失以及解锁信号丢失等问题。由于篇幅长度所限这里就不详述了(请参考上面的链接)。这里最重要的改动点,就是对 lock()
方法的调用现在是放在队列中,所有的线程以队列中的顺序来依次获得 FairLock 对象的锁。
public class FairLock {
private boolean isLocked = false;
private Thread lockingThread = null;
private List<QueueObject> waitingThreads =
new ArrayList<QueueObject>();
public void lock() throws InterruptedException{
QueueObject queueObject = new QueueObject();
boolean isLockedForThisThread = true;
synchronized(this){
waitingThreads.add(queueObject);
}
while(isLockedForThisThread){
synchronized(this){
isLockedForThisThread =
isLocked || waitingThreads.get(0) != queueObject;
if(!isLockedForThisThread){
isLocked = true;
waitingThreads.remove(queueObject);
lockingThread = Thread.currentThread();
return;
}
}
try{
queueObject.doWait();
}catch(InterruptedException e){
synchronized(this) { waitingThreads.remove(queueObject); }
throw e;
}
}
}
public synchronized void unlock(){
if(this.lockingThread != Thread.currentThread()){
throw new IllegalMonitorStateException(
"Calling thread has not locked this lock");
}
isLocked = false;
lockingThread = null;
if(waitingThreads.size() > 0){
waitingThreads.get(0).doNotify();
}
}
}
public class QueueObject {
private boolean isNotified = false;
public synchronized void doWait() throws InterruptedException {
while(!isNotified){
this.wait();
}
this.isNotified = false;
}
public synchronized void doNotify() {
this.isNotified = true;
this.notify();
}
public boolean equals(Object o) {
return this == o;
}
}
首先你可能注意到 lock()
方法不再是 synchronized
。因为只有这个方法里面的部分代码才需要同步。
FairLock 会为每个线程创建一个新的 QueueObject
对象并将其加入队列。调用 unlock()
方法的线程会从队列中取第一个元素对象并调用它的 doNotify()
方法,这样唤醒的就只有一个线程,而不是一堆线程。这个就是 FairLock 的公平机制所在。
注意接下来就是在同步块中重新检查条件并更新锁状态,这是为了避免同步条件丢失。
此外 QueueObject
实际上是一个信号量,doWait()
和 doNotify()
方法的目的是存取锁的状态信号,以避免解锁信号丢失,即在一个线程调用 queueObject.doWait()
之前,另一个线程已经在 unlock()
方法中调用了该对象的 queueObject.doNotify()
方法。至于将 queueObject.doWait()
方法的调用放在同步块外面,是为了避免内部对象死锁的情况发生,这样另一个线程就可以持有 FairLock 对象的锁,并安全的调用 unlock()
方法了。
最后就是对 queueObject.doWait()
这条语句进行异常捕获。如果这条语句执行时发生了 InterruptedException 异常,那么就需要在离开这个方法前将 queueObject 对象从队列中去掉。
关于执行效率的说明
我们把 Lock
和 FairLock
对比一下就会看到后者的 lock()
和 unlock()
增加了很多代码,它们会导致其执行效率比前者略有下降。这个影响的程度如何,取决于 lock()
和 unlock()
之间的同步代码的执行时间,该时间越长,则影响就越小。当然同时也取决于锁本身的使用频繁程度。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。