锁像synchronized同步块一样,是一种线程同步机制,但比Java中的synchronized同步块更复杂。因为锁(以及其它更高级的线程同步机制)是由synchronized同步块的方式实现的,所以我们还不能完全摆脱synchronized关键字(译者注:这说的是Java 5之前的情况)。
自Java 5开始,java.util.concurrent.locks包中包含了一些锁的实现,因此你不用去实现自己的锁了。但是你仍然需要去了解怎样使用这些锁,且了解这些实现背后的理论也是很有用处的。可以参考我对java.util.concurrent.locks.Lock的介绍,以了解更多关于锁的信息。
以下是本文所涵盖的主题:
- 一个简单的锁
- 锁的可重入性
- 锁的公平性
- 在finally语句中调用unlock()
一个简单的锁
让我们从java中的一个同步块开始:
1 public class Counter{ 2 3 private int count = 0; 4 5 public int inc(){ 6 synchronized(this){ 7 return ++count; 8 } 9 } 10 }
可以看到在inc()方法中有一个synchronized(this)代码块。该代码块可以保证在同一时间只有一个线程可以执行return ++count。虽然在synchronized的同步块中的代码可以更加复杂,但是++count这种简单的操作已经足以表达出线程同步的意思。
以下的Counter类用Lock代替synchronized达到了同样的目的:
1 public class Counter{ 2 3 private Lock lock = new Lock(); 4 private int count = 0; 5 6 public int inc(){ 7 lock.lock(); 8 int newCount = ++count; 9 lock.unlock(); 10 return newCount; 11 } 12 }
lock()方法会对Lock实例对象进行加锁,因此所有对该对象调用lock()方法的线程都会被阻塞,直到该Lock对象的unlock()方法被调用。
这里有一个Lock类的简单实现:
1 public class Lock{ 2 3 private boolean isLocked = false; 4 5 public synchronized void lock() 6 throws InterruptedException{ 7 while(isLocked){ 8 wait(); 9 } 10 isLocked = true; 11 } 12 13 public synchronized void unlock(){ 14 isLocked = false; 15 notify(); 16 } 17 }
注意其中的while(isLocked)循环,它又被叫做“自旋锁”。自旋锁以及wait()和notify()方法在线程通信这篇文章中有更加详细的介绍。当isLocked为true时,调用lock()的线程在wait()调用上阻塞等待。为防止该线程没有收到notify()调用也从wait()中返回(也称作虚假唤醒),这个线程会重新去检查isLocked条件以决定当前是否可以安全地继续执行还是需要重新保持等待,而不是认为线程被唤醒了就可以安全地继续执行了。如果isLocked为false,当前线程会退出while(isLocked)循环,并将isLocked设回true,让其它正在调用lock()方法的线程能够在Lock实例上加锁。
当线程完成了临界区(位于lock()和unlock()之间)中的代码,就会调用unlock()。执行unlock()会重新将isLocked设置为false,并且通知(唤醒)其中一个(若有的话)在lock()方法中调用了wait()函数而处于等待状态的线程。
锁的可重入性
Java中的synchronized同步块是可重入的。这意味着如果一个java线程进入了代码中的synchronized同步块,并因此获得了该同步块使用的同步对象对应的管程上的锁,那么这个线程可以进入由同一个管程对象所同步的另一个java代码块。下面是一个例子:
1 public class Reentrant{ 2 3 public synchronized outer(){ 4 inner(); 5 } 6 7 public synchronized inner(){ 8 //do something 9 } 10 }
注意outer()和inner()都被声明为synchronized,这在Java中和synchronized(this)块等效。如果一个线程调用了outer(),在outer()里调用inner()就没有什么问题,因为这两个方法(代码块)都由同一个管程对象(”this”)所同步。如果一个线程已经拥有了一个管程对象上的锁,那么它就有权访问被这个管程对象同步的所有代码块。这就是可重入。线程可以进入任何一个它已经拥有的锁所同步着的代码块。
前面给出的锁实现不是可重入的。如果我们像下面这样重写Reentrant类,当线程调用outer()时,会在inner()方法的lock.lock()处阻塞住。
1 public class Reentrant2{ 2 3 Lock lock = new Lock(); 4 5 public outer(){ 6 lock.lock(); 7 inner(); 8 lock.unlock(); 9 } 10 11 public synchronized inner(){ 12 lock.lock(); 13 //do something 14 lock.unlock(); 15 } 16 }
调用outer()的线程首先会锁住Lock实例,然后继续调用inner()。inner()方法中该线程将再一次尝试锁住Lock实例,结果该动作会失败(也就是说该线程会被阻塞),因为这个Lock实例已经在outer()方法中被锁住了。
两次lock()之间没有调用unlock(),第二次调用lock就会阻塞,看过lock()实现后,会发现原因很明显:
1 public class Lock{ 2 3 boolean isLocked = false; 4 5 public synchronized void lock() 6 throws InterruptedException{ 7 while(isLocked){ 8 wait(); 9 } 10 isLocked = true; 11 } 12 13 ... 14 }
一个线程是否被允许退出lock()方法是由while循环(自旋锁)中的条件决定的。当前的判断条件是只有当isLocked为false时lock操作才被允许,而没有考虑是哪个线程锁住了它。
为了让这个Lock类具有可重入性,我们需要对它做一点小的改动:
1 public class Lock{ 2 3 boolean isLocked = false; 4 Thread lockedBy = null; 5 int lockedCount = 0; 6 7 public synchronized void lock() 8 throws InterruptedException{ 9 Thread callingThread = Thread.currentThread(); 10 while(isLocked && lockedBy != callingThread){ 11 wait(); 12 } 13 isLocked = true; 14 lockedCount++; 15 lockedBy = callingThread; 16 } 17 18 19 public synchronized void unlock(){ 20 if(Thread.curentThread() == this.lockedBy){ 21 lockedCount--; 22 23 if(lockedCount == 0){ 24 isLocked = false; 25 notify(); 26 } 27 } 28 } 29 30 ... 31 }
注意到现在的while循环(自旋锁)也考虑到了已锁住该Lock实例的线程。如果当前的锁对象没有被加锁(isLocked = false),或者当前调用线程已经对该Lock实例加了锁,那么while循环就不会被执行,调用lock()的线程就可以退出该方法(译者注:“被允许退出该方法”在当前语义下就是指不会调用wait()而导致阻塞)。
除此之外,我们需要记录同一个线程重复对一个锁对象加锁的次数。否则,一次unblock()调用就会解除整个锁,即使当前锁已经被加锁过多次。在unlock()调用没有达到对应lock()调用的次数之前,我们不希望锁被解除。
现在这个Lock类就是可重入的了。
锁的公平性
Java的synchronized块并不保证尝试进入它们的线程的顺序。因此,如果多个线程不断竞争访问相同的synchronized同步块,就存在一种风险,其中一个或多个线程永远也得不到访问权 —— 也就是说访问权总是分配给了其它线程。这种情况被称作线程饥饿。为了避免这种问题,锁需要实现公平性。本文所展现的锁在内部是用synchronized同步块实现的,因此它们也不保证公平性。饥饿和公平中有更多关于该内容的讨论。
在finally语句中调用unlock()
如果用Lock来保护临界区,并且临界区有可能会抛出异常,那么在finally语句中调用unlock()就显得非常重要了。这样可以保证这个锁对象可以被解锁以便其它线程能继续对其加锁。以下是一个示例:
1 lock.lock(); 2 try{ 3 //do critical section code, which may throw exception 4 } finally { 5 lock.unlock(); 6 }
这个简单的结构可以保证当临界区抛出异常时Lock对象可以被解锁。如果不是在finally语句中调用的unlock(),当临界区抛出异常时,Lock对象将永远停留在被锁住的状态,这会导致其它所有在该Lock对象上调用lock()的线程一直阻塞。
总结:
锁的可重入性:一个线程进入synchronized同步块后,它还可以在里面再进入由同一个管程对象所同步的另一个synchronized同步块,这就是synchronized同步块的可重入性。一个线程调用lock()后进入临界区,如果在它调用unlock()之前再次调用lock(),第二次调用lock就会阻塞,这样的锁就是不可重入的。解决方法是在锁的实现中用一个计数器记录一个线程重复加锁的次数。在锁状态检查时增加一个判断,如果已经加锁并且是当前线程加的,就不再调用wait(),以避免重入lock()时阻塞当前线程。线程每重复调用一次lock()计数器就增一,每调用一次unlock()计数器就减一,减为0时就解锁,以保证lock()与unlock()的调用次数相同。
锁的公平性:多个线程竞争访问同一个同步块(或同一个锁),可能会出现一些线程永远也进入不了同步块或临界区的情况。同样当调用notify()时wait()也不会保证某个线程一定能被唤醒(它只是随机地唤醒一个线程),这就造成同步的不公平性。synchronized同步块并不保证同步的公平性,锁可以实现公平性。解决方法是对使用锁的线程排队,每个线程调用lock()时都会进入一个队列,加锁时只有队头的线程能获得锁(然后该线程出队),所有其它的线程都将处于等待状态,直到他们处于队列头部。解锁时只要唤醒队头的线程。
(总结)可见,设计一个健壮的锁需要考虑很多问题:
(1)要保证争用锁的每个线程都有机会获得锁,而不会饥饿:锁的公平性问题。
(2)要保证只有一个线程获得锁,其他线程等待:滑漏条件问题。
(3)要保证等待的线程最终能被唤醒,而不会永久等待:死锁问题、嵌套管程锁死问题、丢失信号问题、锁的可重入性问题(重入锁死)、临界区抛异常的问题。
(4)要保证等待的线程被正确的notify信号唤醒,而不是被无端的Bug或错误信息唤醒:虚假唤醒问题。
Java中的读/写锁
相比Java中的锁(Locks in Java)里Lock实现,读写锁更复杂一些。假设你的程序中涉及到对一些共享资源的读和写操作,且写操作没有读操作那么频繁。在没有写操作的时候,两个线程同时读一个资源没有任何问题,所以应该允许多个线程能在同时读取共享资源。但是如果有一个线程想去写这些共享资源,就不应该再有其它线程对该资源进行读或写(译者注:也就是说:读-读能共存,读-写不能共存,写-写不能共存)。这就需要一个读/写锁来解决这个问题。
Java5在java.util.concurrent包中已经包含了读写锁。尽管如此,我们还是应该了解其实现背后的原理。
以下是本文的主题
- 读/写锁的Java实现(Read / Write Lock Java Implementation)
- 读/写锁的重入(Read / Write Lock Reentrance)
- 读锁重入(Read Reentrance)
- 写锁重入(Write Reentrance)
- 读锁升级到写锁(Read to Write Reentrance)
- 写锁降级到读锁(Write to Read Reentrance)
- 可重入的ReadWriteLock的完整实现(Fully Reentrant ReadWriteLock)
- 在finally中调用unlock() (Calling unlock() from a finally-clause)
读/写锁的Java实现
先让我们对读写访问资源的条件做个概述:
读取 没有线程正在做写操作,且没有线程在请求写操作。
写入 没有线程正在做读写操作。
如果某个线程想要读取资源,只要没有线程正在对该资源进行写操作且没有线程请求对该资源的写操作即可。我们假设对写操作的请求比对读操作的请求更重要,就要提升写请求的优先级。此外,如果读操作发生的比较频繁,我们又没有提升写操作的优先级,那么就会产生“饥饿”现象。请求写操作的线程会一直阻塞,直到所有的读线程都从ReadWriteLock上解锁了。如果一直保证新线程的读操作权限,那么等待写操作的线程就会一直阻塞下去,结果就是发生“饥饿”。因此,只有当没有线程正在锁住ReadWriteLock进行写操作,且没有线程请求该锁准备执行写操作时,才能保证读操作继续。
当其它线程没有对共享资源进行读操作或者写操作时,某个线程就有可能获得该共享资源的写锁,进而对共享资源进行写操作。有多少线程请求了写锁以及以何种顺序请求写锁并不重要,除非你想保证写锁请求的公平性。
按照上面的叙述,简单的实现出一个读/写锁,代码如下:
1 public class ReadWriteLock{ 2 3 private int readers = 0; 4 private int writers = 0; 5 private int writeRequests = 0; 6 7 public synchronized void lockRead() throws InterruptedException{ 8 while(writers > 0 || writeRequests > 0){ //已经有线程持有写锁或在请求写锁,则等待 9 wait(); 10 } 11 readers++; 12 } 13 14 public synchronized void unlockRead(){ 15 readers--; 16 notifyAll(); 17 } 18 19 public synchronized void lockWrite() throws InterruptedException{ 20 writeRequests++; 21 22 while(readers > 0 || writers > 0){ //已经有线程持有读锁或写锁,则等待 23 wait(); 24 } 25 writeRequests--; 26 writers++; 27 } 28 29 public synchronized void unlockWrite() throws InterruptedException{ 30 writers--; 31 notifyAll(); 32 } 33 }
ReadWriteLock类中,读锁和写锁各有一个获取锁和释放锁的方法。
读锁的实现在lockRead()中,只要没有线程拥有写锁(writers==0),且没有线程在请求写锁(writeRequests ==0),所有想获得读锁的线程都能成功获取。
写锁的实现在lockWrite()中,当一个线程想获得写锁的时候,首先会把写锁请求数加1(writeRequests++),然后再去判断是否能够真能获得写锁,当没有线程持有读锁(readers==0 ),且没有线程持有写锁(writers==0)时就能获得写锁。有多少线程在请求写锁并无关系。
需要注意的是,在两个释放锁的方法(unlockRead,unlockWrite)中,都调用了notifyAll方法,而不是notify。要解释这个原因,我们可以想象下面一种情形:
如果有线程在等待获取读锁,同时又有线程在等待获取写锁。如果这时其中一个等待读锁的线程被notify方法唤醒,但因为此时仍有请求写锁的线程存在(writeRequests>0,注意这里是写操作的优先级高于读操作),所以被唤醒的线程会再次进入阻塞状态。然而,等待写锁的线程一个也没被唤醒,就像什么也没发生过一样(译者注:信号丢失现象)。如果用的是notifyAll方法,所有的线程都会被唤醒,然后判断能否获得其请求的锁。
用notifyAll还有一个好处。如果有多个读线程在等待读锁且没有线程在等待写锁时,调用unlockWrite()后,所有等待读锁的线程都能立马成功获取读锁 —— 而不是一次只允许一个。
读/写锁的重入
上面实现的读/写锁(ReadWriteLock) 是不可重入的,当一个已经持有写锁的线程再次请求写锁时,就会被阻塞。原因是已经有一个写线程了——就是它自己。此外,考虑下面的例子:
- Thread 1 获得了读锁
- Thread 2 请求写锁,但因为Thread 1 持有了读锁,所以写锁请求被阻塞。
- Thread 1 再想请求一次读锁,但因为Thread 2处于请求写锁的状态,所以想再次获取读锁也会被阻塞。
上面这种情形使用前面的ReadWriteLock就会被锁定——一种类似于死锁的情形。不会再有线程能够成功获取读锁或写锁了。
为了让ReadWriteLock可重入,需要对它做一些改进。下面会分别处理读锁的重入和写锁的重入。
读锁重入
为了让ReadWriteLock的读锁可重入,我们要先为读锁重入建立规则:
- 一个线程的读锁可重入,如果它能够获取读锁(即没有写或写请求),或者已经持有读锁(不管是否有写请求)。
要确定一个线程是否已经持有读锁,可以用一个map来存储已经持有读锁的线程以及对应线程获取读锁的次数,当需要判断某个线程能否获得读锁时,就利用map中存储的数据进行判断。下面是方法lockRead和unlockRead修改后的的代码:
1 public class ReadWriteLock{ 2 3 private Map<Thread, Integer> readingThreads = 4 new HashMap<Thread, Integer>(); 5 6 private int writers = 0; 7 private int writeRequests = 0; 8 9 public synchronized void lockRead() throws InterruptedException{ 10 Thread callingThread = Thread.currentThread(); 11 while(! canGrantReadAccess(callingThread)){ //若不能授予读锁,则等待 12 wait(); 13 } 14 15 readingThreads.put(callingThread, 16 (getAccessCount(callingThread) + 1)); //否则持有读锁的次数加1 17 } 18 19 20 public synchronized void unlockRead(){ 21 Thread callingThread = Thread.currentThread(); 22 int accessCount = getAccessCount(callingThread); 23 if(accessCount == 1){ readingThreads.remove(callingThread); } 24 else { readingThreads.put(callingThread, (accessCount -1)); } //持有读锁的次数减1 25 notifyAll(); 26 } 27 28 29 private boolean canGrantReadAccess(Thread callingThread){ 30 if(writers > 0) return false; //已有线程持有写锁,不能再授予读锁 31 if(isReader(callingThread) return true; //线程已经持有读锁,可以重入 32 if(writeRequests > 0) return false; //已有线程在请求写锁,不能再授予读锁 33 return true; 34 } 35 36 private int getReadAccessCount(Thread callingThread){ //返回指定线程持有读锁的次数 37 Integer accessCount = readingThreads.get(callingThread); 38 if(accessCount == null) return 0; 39 return accessCount.intValue(); 40 } 41 42 private boolean isReader(Thread callingThread){ //判断指定线程是否已经持有读锁 43 return readingThreads.get(callingThread) != null; 44 } 45 46 }
代码中我们可以看到,只有在没有线程拥有写锁的情况下才允许读锁的重入。此外,重入的读锁比写锁优先级高。
写锁重入
仅当一个线程已经持有写锁,才允许写锁重入(再次获得写锁)。下面是方法lockWrite和unlockWrite修改后的的代码。
1 public class ReadWriteLock{ 2 3 private Map<Thread, Integer> readingThreads = 4 new HashMap<Thread, Integer>(); 5 6 private int writeAccesses = 0; 7 private int writeRequests = 0; 8 private Thread writingThread = null; 9 10 public synchronized void lockWrite() throws InterruptedException{ 11 writeRequests++; 12 Thread callingThread = Thread.currentThread(); 13 while(! canGrantWriteAccess(callingThread)){ //若不能授予写锁,则等待 14 wait(); 15 } 16 writeRequests--; 17 writeAccesses++; 18 writingThread = callingThread; 19 } 20 21 public synchronized void unlockWrite() throws InterruptedException{ 22 writeAccesses--; 23 if(writeAccesses == 0){ 24 writingThread = null; 25 } 26 notifyAll(); 27 } 28 29 private boolean canGrantWriteAccess(Thread callingThread){ 30 if(hasReaders()) return false; //已有线程持有读锁,不能再授予写锁 31 if(writingThread == null) return true; //没有线程持有写锁,可以授予写锁 32 if(!isWriter(callingThread)) return false; //当前线程不是那个唯一持有写锁的线程,则不能再授予写锁 33 return true; 34 } 35 36 private boolean hasReaders(){ 37 return readingThreads.size() > 0; 38 } 39 40 private boolean isWriter(Thread callingThread){ 41 return writingThread == callingThread; 42 } 43 }
注意在确定当前线程是否能够获取写锁的时候,是如何处理的。
读锁升级到写锁
有时,我们希望一个拥有读锁的线程,也能获得写锁。想要允许这样的操作,要求这个线程是唯一一个拥有读锁的线程。writeLock()需要做点改动来达到这个目的:
1 public class ReadWriteLock{ 2 3 private Map<Thread, Integer> readingThreads = 4 new HashMap<Thread, Integer>(); 5 6 private int writeAccesses = 0; 7 private int writeRequests = 0; 8 private Thread writingThread = null; 9 10 public synchronized void lockWrite() throws InterruptedException{ 11 writeRequests++; 12 Thread callingThread = Thread.currentThread(); 13 while(! canGrantWriteAccess(callingThread)){ 14 wait(); 15 } 16 writeRequests--; 17 writeAccesses++; 18 writingThread = callingThread; 19 } 20 21 public synchronized void unlockWrite() throws InterruptedException{ 22 writeAccesses--; 23 if(writeAccesses == 0){ 24 writingThread = null; 25 } 26 notifyAll(); 27 } 28 29 private boolean canGrantWriteAccess(Thread callingThread){ 30 if(isOnlyReader(callingThread)) return true; //若是唯一个持有读锁的线程,则可升级到写锁 31 if(hasReaders()) return false; 32 if(writingThread == null) return true; 33 if(!isWriter(callingThread)) return false; 34 return true; 35 } 36 37 private boolean hasReaders(){ 38 return readingThreads.size() > 0; 39 } 40 41 private boolean isWriter(Thread callingThread){ 42 return writingThread == callingThread; 43 } 44 45 private boolean isOnlyReader(Thread thread){ 46 return readers == 1 && readingThreads.get(callingThread) != null; 47 } 48 49 }
现在ReadWriteLock类就可以从读锁升级到写锁了。
写锁降级到读锁
有时拥有写锁的线程也希望得到读锁。如果一个线程拥有了写锁,那么自然其它线程是不可能拥有读锁或写锁了。所以对于一个拥有写锁的线程,再获得读锁,是不会有什么危险的。我们仅仅需要对上面canGrantReadAccess方法进行简单地修改:
public class ReadWriteLock{ private boolean canGrantReadAccess(Thread callingThread){ if(isWriter(callingThread)) return true; //当前线程拥有写锁,则可以授予读锁 if(writingThread != null) return false; if(isReader(callingThread) return true; if(writeRequests > 0) return false; return true; } }
可重入的ReadWriteLock的完整实现
下面是完整的ReadWriteLock实现。为了便于代码的阅读与理解,简单对上面的代码做了重构。重构后的代码如下。
1 public class ReadWriteLock{ 2 3 private Map<Thread, Integer> readingThreads = 4 new HashMap<Thread, Integer>(); 5 6 private int writeAccesses = 0; 7 private int writeRequests = 0; 8 private Thread writingThread = null; 9 10 11 public synchronized void lockRead() throws InterruptedException{ 12 Thread callingThread = Thread.currentThread(); 13 while(! canGrantReadAccess(callingThread)){ 14 wait(); 15 } 16 17 readingThreads.put(callingThread, 18 (getReadAccessCount(callingThread) + 1)); 19 } 20 21 private boolean canGrantReadAccess(Thread callingThread){ 22 if( isWriter(callingThread) ) return true; 23 if( hasWriter() ) return false; 24 if( isReader(callingThread) ) return true; 25 if( hasWriteRequests() ) return false; 26 return true; 27 } 28 29 30 public synchronized void unlockRead(){ 31 Thread callingThread = Thread.currentThread(); 32 if(!isReader(callingThread)){ 33 throw new IllegalMonitorStateException("Calling Thread does not" + 34 " hold a read lock on this ReadWriteLock"); 35 } 36 int accessCount = getReadAccessCount(callingThread); 37 if(accessCount == 1){ readingThreads.remove(callingThread); } 38 else { readingThreads.put(callingThread, (accessCount -1)); } 39 notifyAll(); 40 } 41 42 public synchronized void lockWrite() throws InterruptedException{ 43 writeRequests++; 44 Thread callingThread = Thread.currentThread(); 45 while(! canGrantWriteAccess(callingThread)){ 46 wait(); 47 } 48 writeRequests--; 49 writeAccesses++; 50 writingThread = callingThread; 51 } 52 53 public synchronized void unlockWrite() throws InterruptedException{ 54 if(!isWriter(Thread.currentThread()){ 55 throw new IllegalMonitorStateException("Calling Thread does not" + 56 " hold the write lock on this ReadWriteLock"); 57 } 58 writeAccesses--; 59 if(writeAccesses == 0){ 60 writingThread = null; 61 } 62 notifyAll(); 63 } 64 65 private boolean canGrantWriteAccess(Thread callingThread){ 66 if(isOnlyReader(callingThread)) return true; 67 if(hasReaders()) return false; 68 if(writingThread == null) return true; 69 if(!isWriter(callingThread)) return false; 70 return true; 71 } 72 73 74 private int getReadAccessCount(Thread callingThread){ 75 Integer accessCount = readingThreads.get(callingThread); 76 if(accessCount == null) return 0; 77 return accessCount.intValue(); 78 } 79 80 81 private boolean hasReaders(){ 82 return readingThreads.size() > 0; 83 } 84 85 private boolean isReader(Thread callingThread){ 86 return readingThreads.get(callingThread) != null; 87 } 88 89 private boolean isOnlyReader(Thread callingThread){ 90 return readingThreads.size() == 1 && 91 readingThreads.get(callingThread) != null; 92 } 93 94 private boolean hasWriter(){ 95 return writingThread != null; 96 } 97 98 private boolean isWriter(Thread callingThread){ 99 return writingThread == callingThread; 100 } 101 102 private boolean hasWriteRequests(){ 103 return this.writeRequests > 0; 104 } 105 106 }
在finally中调用unlock()
在利用ReadWriteLock来保护临界区时,如果临界区可能抛出异常,在finally块中调用readUnlock()和writeUnlock()就显得很重要了。这样做是为了保证ReadWriteLock能被成功解锁,然后其它线程可以请求到该锁。这里有个例子:
1 lock.lockWrite(); 2 try{ 3 //do critical section code, which may throw exception 4 } finally { 5 lock.unlockWrite(); 6 }
上面这样的代码结构能够保证临界区中抛出异常时ReadWriteLock也会被释放。如果unlockWrite方法不是在finally块中调用的,当临界区抛出了异常时,ReadWriteLock 会一直保持在写锁定状态,就会导致所有调用lockRead()或lockWrite()的线程一直阻塞。唯一能够重新解锁ReadWriteLock的因素可能就是ReadWriteLock是可重入的,当抛出异常时,这个线程后续还可以成功获取这把锁,然后执行临界区以及再次调用unlockWrite(),这就会再次释放ReadWriteLock。但是如果该线程后续不再获取这把锁了呢?所以,在finally中调用unlockWrite对写出健壮代码是很重要的。
重入锁死
重入锁死与死锁和嵌套管程锁死非常相似。锁和读写锁两篇文章中都有涉及到重入锁死的问题。
当一个线程重新获取锁,读写锁或其他不可重入的同步器时,就可能发生重入锁死。可重入的意思是线程可以重复获得它已经持有的锁。Java的synchronized块是可重入的。因此下面的代码是没问题的:
(译者注:这里提到的锁都是指的不可重入的锁实现,并不是Java类库中的Lock与ReadWriteLock类)
public class Reentrant{ public synchronized outer(){ inner(); } public synchronized inner(){ //do something } }
注意outer()和inner()都声明为synchronized,这在Java中这相当于synchronized(this)块(译者注:这里两个方法是实例方法,synchronized的实例方法相当于在this上加锁,如果是static方法,则不然,更多阅读:哪个对象才是锁?)。如果某个线程调用了outer(),outer()中的inner()调用是没问题的,因为两个方法都是在同一个管程对象(即this)上同步的。如果一个线程持有某个管程对象上的锁,那么它就有权访问所有在该管程对象上同步的块。这就叫可重入。若线程已经持有锁,那么它就可以重复访问所有使用该锁的代码块。
下面这个锁的实现是不可重入的:
1 public class Lock{ 2 3 private boolean isLocked = false; 4 5 public synchronized void lock() 6 throws InterruptedException{ 7 while(isLocked){ 8 wait(); 9 } 10 isLocked = true; 11 } 12 13 public synchronized void unlock(){ 14 isLocked = false; 15 notify(); 16 } 17 }
如果一个线程在两次调用lock()间没有调用unlock()方法,那么第二次调用lock()就会被阻塞,这就出现了重入锁死。
避免重入锁死有两个选择:
- 编写代码时避免再次获取已经持有的锁
- 使用可重入锁
至于哪个选择最适合你的项目,得视具体情况而定。可重入锁通常没有不可重入锁那么好的性能表现,而且实现起来复杂,但这些情况在你的项目中也许算不上什么问题。无论你的项目用锁来实现方便还是不用锁方便,可重入特性都需要根据具体问题具体分析。
信号量
Semaphore(信号量) 是一个线程同步结构,用于在线程间传递信号,以避免出现信号丢失(译者注:下文会具体介绍),或者像锁一样用于保护一个关键区域。自从5.0开始,jdk在java.util.concurrent包里提供了Semaphore 的官方实现,因此大家不需要自己去实现Semaphore。但是还是很有必要去熟悉如何使用Semaphore及其背后的原理
本文的涉及的主题如下:
- 简单的Semaphore实现
- 使用Semaphore来发出信号
- 可计数的Semaphore
- 有上限的Semaphore
- 把Semaphore当锁来使用
一、简单的Semaphore实现
下面是一个信号量的简单实现:
1 public class Semaphore { 2 private boolean signal = false; 3 4 public synchronized void take() { 5 this.signal = true; 6 this.notify(); 7 } 8 9 public synchronized void release() throws InterruptedException{ 10 while(!this.signal) wait(); 11 this.signal = false; 12 } 13 14 }
Take方法发出一个被存放在Semaphore内部的信号,而Release方法则等待一个信号,当其接收到信号后,标记位signal被清空,然后该方法终止。
使用这个semaphore可以避免丢失某些信号通知。用take方法来代替notify,release方法来代替wait。如果某线程在调用release等待之前调用take方法,那么调用release方法的线程仍然知道take方法已经被某个线程调用过了,因为该Semaphore内部保存了take方法发出的信号。而wait和notify方法就没有这样的功能。
当用semaphore来产生信号时,take和release这两个方法名看起来有点奇怪。这两个名字来源于后面把semaphore当做锁的例子,后面会详细介绍这个例子,在该例子中,take和release这两个名字会变得很合理。
二、使用Semaphore来产生信号
下面的例子中,两个线程通过Semaphore发出的信号来通知对方:
1 Semaphore semaphore = new Semaphore(); 2 3 SendingThread sender = new SendingThread(semaphore); 4 5 ReceivingThread receiver = new ReceivingThread(semaphore); 6 7 receiver.start(); 8 sender.start();
1 public class SendingThread { 2 Semaphore semaphore = null; 3 4 public SendingThread(Semaphore semaphore){ 5 this.semaphore = semaphore; 6 } 7 8 public void run(){ 9 while(true){ 10 //do something, then signal 11 this.semaphore.take(); 12 13 } 14 } 15 }
public class RecevingThread { Semaphore semaphore = null; public ReceivingThread(Semaphore semaphore){ this.semaphore = semaphore; } public void run(){ while(true){ this.semaphore.release(); //receive signal, then do something... } } }
三、可计数的Semaphore
上面提到的Semaphore的简单实现并没有计算通过调用take方法所产生信号的数量。可以把它改造成具有计数功能的Semaphore。下面是一个可计数的Semaphore的简单实现。
1 public class CountingSemaphore { 2 private int signals = 0; 3 4 public synchronized void take() { 5 this.signals++; 6 this.notify(); 7 } 8 9 public synchronized void release() throws InterruptedException{ 10 while(this.signals == 0) wait(); 11 this.signals--; 12 } 13 14 }
四、有上限的Semaphore
上面的CountingSemaphore并没有限制信号的数量。下面的代码将CountingSemaphore改造成一个信号数量有上限的BoundedSemaphore。
1 public class BoundedSemaphore { 2 private int signals = 0; 3 private int bound = 0; 4 5 public BoundedSemaphore(int upperBound){ 6 this.bound = upperBound; 7 } 8 9 public synchronized void take() throws InterruptedException{ 10 while(this.signals == bound) wait(); 11 this.signals++; 12 this.notify(); 13 } 14 15 public synchronized void release() throws InterruptedException{ 16 while(this.signals == 0) wait(); 17 this.signals--; 18 this.notify(); 19 } 20 }
在BoundedSemaphore中,当已经产生的信号数量达到了上限,take方法将阻塞新的信号产生请求,直到某个线程调用release方法后,被阻塞于take方法的线程才能传递自己的信号。
五、把Semaphore当锁来使用
当信号量的数量上限是1时,Semaphore可以被当做锁来使用。通过take和release方法来保护关键区域。请看下面的例子:
1 BoundedSemaphore semaphore = new BoundedSemaphore(1); 2 3 ... 4 5 semaphore.take(); 6 7 try{ 8 //critical section 9 } finally { 10 semaphore.release(); 11 }
在前面的例子中,Semaphore被用来在多个线程之间传递信号,这种情况下,take和release分别被不同的线程调用。但是在锁这个例子中,take和release方法将被同一线程调用,因为只允许一个线程来获取信号(允许进入关键区域的信号),其它调用take方法获取信号的线程将被阻塞,知道第一个调用take方法的线程调用release方法来释放信号。对release方法的调用永远不会被阻塞,这是因为任何一个线程都是先调用take方法,然后再调用release。
通过有上限的Semaphore可以限制进入某代码块的线程数量。设想一下,在上面的例子中,如果BoundedSemaphore 上限设为5将会发生什么?意味着允许5个线程同时访问关键区域,但是你必须保证,这个5个线程不会互相冲突。否则你的应用程序将不能正常运行。
必须注意,release方法应当在finally块中被执行。这样可以保在关键区域的代码抛出异常的情况下,信号也一定会被释放。
阻塞队列
阻塞队列与普通队列的区别在于,当队列是空的时,从队列中获取元素的操作将会被阻塞,或者当队列是满时,往队列里添加元素的操作会被阻塞。试图从空的阻塞队列中获取元素的线程将会被阻塞,直到其他的线程往空的队列插入新的元素。同样,试图往已满的阻塞队列中添加新元素的线程同样也会被阻塞,直到其他的线程使队列重新变得空闲起来,如从队列中移除一个或者多个元素,或者完全清空队列,下图展示了如何通过阻塞队列来合作:
从5.0开始,JDK在java.util.concurrent包里提供了阻塞队列的官方实现。尽管JDK中已经包含了阻塞队列的官方实现,但是熟悉其背后的原理还是很有帮助的。
阻塞队列的实现
阻塞队列的实现类似于带上限的Semaphore的实现。下面是阻塞队列的一个简单实现:
1 public class BlockingQueue { 2 3 private List queue = new LinkedList(); 4 private int limit = 10; 5 6 public BlockingQueue(int limit){ 7 this.limit = limit; 8 } 9 10 11 public synchronized void enqueue(Object item) 12 throws InterruptedException { 13 while(this.queue.size() == this.limit) { 14 wait(); 15 } 16 if(this.queue.size() == 0) { 17 notifyAll(); 18 } 19 this.queue.add(item); 20 } 21 22 23 public synchronized Object dequeue() 24 throws InterruptedException{ 25 while(this.queue.size() == 0){ 26 wait(); 27 } 28 if(this.queue.size() == this.limit){ 29 notifyAll(); 30 } 31 32 return this.queue.remove(0); 33 } 34 35 }
必须注意到,在enqueue和dequeue方法内部,只有队列的大小等于上限(limit)或者下限(0)时,才调用notifyAll方法。如果队列的大小既不等于上限,也不等于下限,任何线程调用enqueue或者dequeue方法时,都不会阻塞,都能够正常的往队列中添加或者移除元素。
线程池
线程池(Thread Pool)对于限制应用程序中同一时刻运行的线程数很有用。因为每启动一个新线程都会有相应的性能开销,每个线程都需要给栈分配一些内存等等。
我们可以把并发执行的任务传递给一个线程池,来替代为每个并发执行的任务都启动一个新的线程。只要池里有空闲的线程,任务就会分配给一个线程执行。在线程池的内部,任务被插入一个阻塞队列(Blocking Queue ),线程池里的线程会去取这个队列里的任务。当一个新任务插入队列时,一个空闲线程就会成功的从队列中取出任务并且执行它。
线程池经常应用在多线程服务器上。每个通过网络到达服务器的连接都被包装成一个任务并且传递给线程池。线程池的线程会并发的处理连接上的请求。以后会再深入有关 Java 实现多线程服务器的细节。
Java 5 在 java.util.concurrent
包中自带了内置的线程池,所以你不用非得实现自己的线程池。你可以阅读我写的 java.util.concurrent.ExecutorService 的文章以了解更多有关内置线程池的知识。不过无论如何,知道一点关于线程池实现的知识总是有用的。
这里有一个简单的线程池实现:
1 public class ThreadPool { //线程池 2 3 private BlockingQueue taskQueue = null; 4 private List<PoolThread> threads = new ArrayList<>(); 5 private boolean isStopped = false; 6 7 public ThreadPool(int noOfThreads, int maxNoOfTasks) { 8 taskQueue = new BlockingQueue(maxNoOfTasks); //初始化阻塞队列 9 for (int i = 0; i < noOfThreads; i++) { //初始化线程池,往池中放入一批空闲线程 10 threads.add(new PoolThread(taskQueue)); 11 } 12 for (PoolThread thread : threads) { //启动所有空闲线程 13 thread.start(); 14 } 15 } 16 17 public synchronized void execute(Runnable task) throws InterruptedException { //执行一个任务 18 if (this.isStopped) { 19 throw new IllegalStateException("ThreadPool is stopped"); 20 } 21 this.taskQueue.enqueue(task); //把任务线程放入阻塞队列 22 } 23 24 public synchronized void stop() { //停止线程池 25 this.isStopped = true; 26 for (PoolThread thread : threads) { //停止池中的每个任务线程 27 thread.toStop(); 28 } 29 } 30 }
1 public class PoolThread extends Thread { //执行任务的子线程 2 3 private BlockingQueue taskQueue = null; 4 private boolean isStopped = false; 5 6 public PoolThread(BlockingQueue queue) { 7 taskQueue = queue; 8 } 9 10 @Override 11 public void run() { //执行任务 12 while (!isStopped()) { 13 try { //不断从阻塞队列中取出任务线程来运行 14 Runnable runnable = (Runnable) taskQueue.dequeue(); 15 runnable.run(); 16 } catch (InterruptedException e) { 17 //写日志或报告异常 18 //但保持线程池运行 19 } 20 } 21 } 22 23 public synchronized void toStop() { 24 isStopped = true; 25 this.interrupt(); //打断池中任务线程的dequeue()调用,异常会在run()中捕获 26 } 27 28 public synchronized boolean isStopped() { 29 return isStopped; 30 } 31 }
其中BlockingQueue使用的是前面的实现。
线程池的实现由两部分组成。类 ThreadPool
是线程池的公开接口,而类 PoolThread
用来实现执行任务的子线程。
为了执行一个任务,方法ThreadPool.execute(Runnable task)
用 Runnable
的实现作为调用参数。在内部,Runnable
对象被放入阻塞队列 (Blocking Queue),等待着被子线程取出队列。
一个空闲的 PoolThread
线程会把 Runnable
对象从队列中取出并执行。你可以在 PoolThread.run()
方法里看到这些代码。执行完毕后,PoolThread
进入循环并且尝试从队列中再取出一个任务,直到线程终止。
调用 ThreadPool.stop()
方法可以停止 ThreadPool
。在内部,调用 stop 先会标记 isStopped
成员变量(为 true)。然后,线程池的每一个子线程都调用PoolThread.toStop()
方法停止运行。注意,如果线程池的 execute()
在 stop()
之后调用,execute()
方法会抛出 IllegalStateException
异常。
子线程会在完成当前执行的任务后停止。注意 PoolThread.toStop()
方法中调用了 this.interrupt()
。它确保阻塞在taskQueue.dequeue()
里的 wait()
调用的线程能够跳出 wait() 调用(校对注:因为执行了中断interrupt,它能够打断这个调用)
,并且抛出一个 InterruptedException
异常离开 dequeue()
方法。这个异常在PoolThread.run()
方法中被截获、报告,然后再检查 isStopped
变量。由于 isStopped
的值是 true, 因此 PoolThread.run()
方法退出,子线程终止。
剖析同步器
虽然许多同步器(如锁,信号量,阻塞队列等)功能上各不相同,但它们的内部设计上却差别不大。换句话说,它们内部的的基础部分是相同(或相似)的。了解这些基础部件能在设计同步器的时候给我们大大的帮助。这就是本文要细说的内容。
注:本文的内容是哥本哈根信息技术大学一个由Jakob Jenkov,Toke Johansen和Lars Bjørn参与的M.Sc.学生项目的部分成果。在此项目期间我们咨询Doug Lea是否知道类似的研究。有趣的是在开发Java 5并发工具包期间他已经提出了类似的结论。Doug Lea的研究,我相信,在《Java Concurrency in Practice》一书中有描述。这本书有一章“剖析同步器”就类似于本文,但不尽相同。
大部分同步器都是用来保护某个区域(临界区)的代码,这些代码可能会被多线程并发访问。要实现这个目标,同步器一般要支持下列功能:
- 状态
- 访问条件
- 状态变化
- 通知策略
- Test-and-Set方法
- Set方法
并不是所有同步器都包含上述部分,也有些并不完全遵照上面的内容。但通常你能从中发现这些部分的一或多个。
状态
同步器中的状态是用来确定某个线程是否有访问权限。在Lock中,状态是boolean类型的,表示当前Lock对象是否处于锁定状态。在BoundedSemaphore中,内部状态包含一个计数器(int类型)和一个上限(int类型),分别表示当前已经获取的许可数和最大可获取的许可数。BlockingQueue的状态是该队列中元素列表以及队列的最大容量。
下面是Lock和BoundedSemaphore中的两个代码片段。
1 public class Lock{ 2 3 //state is kept here 4 private boolean isLocked = false; 5 6 public synchronized void lock() 7 throws InterruptedException{ 8 while(isLocked){ 9 wait(); 10 } 11 isLocked = true; 12 } 13 14 ... 15 }
1 public class BoundedSemaphore { 2 3 //state is kept here 4 private int signals = 0; 5 private int bound = 0; 6 7 public BoundedSemaphore(int upperBound){ 8 this.bound = upperBound; 9 } 10 11 public synchronized void take() throws InterruptedException{ 12 while(this.signals == bound) wait(); 13 this.signal++; 14 this.notify(); 15 } 16 ... 17 }
访问条件
访问条件决定调用test-and-set-state方法的线程是否可以对状态进行设置。访问条件一般是基于同步器状态的。通常是放在一个while循环里,以避免虚假唤醒问题。访问条件的计算结果要么是true要么是false。
Lock中的访问条件只是简单地检查isLocked的值。根据执行的动作是“获取”还是“释放”,BoundedSemaphore中实际上有两个访问条件。如果某个线程想“获取”许可,将检查signals变量是否达到上限;如果某个线程想“释放”许可,将检查signals变量是否为0。
这里有两个来自Lock和BoundedSemaphore的代码片段,它们都有访问条件。注意观察条件是怎样在while循环中检查的。
1 public class Lock{ 2 3 private boolean isLocked = false; 4 5 public synchronized void lock() 6 throws InterruptedException{ 7 //access condition 8 while(isLocked){ 9 wait(); 10 } 11 isLocked = true; 12 } 13 14 ... 15 }
1 public class BoundedSemaphore { 2 private int signals = 0; 3 private int bound = 0; 4 5 public BoundedSemaphore(int upperBound){ 6 this.bound = upperBound; 7 } 8 9 public synchronized void take() throws InterruptedException{ 10 //access condition 11 while(this.signals == bound) wait(); 12 this.signals++; 13 this.notify(); 14 } 15 16 public synchronized void release() throws InterruptedException{ 17 //access condition 18 while(this.signals == 0) wait(); 19 this.signals--; 20 this.notify(); 21 } 22 }
状态变化
一旦一个线程获得了临界区的访问权限,它得改变同步器的状态,让其它线程阻塞,防止它们进入临界区。换而言之,这个状态表示正有一个线程在执行临界区的代码。这会影响到其它想获取访问许可的线程的访问条件。
在Lock中,通过代码设置isLocked = true来改变状态,在信号量中,改变状态的是signals–或signals++;
这里有两个状态变化的代码片段:
1 public class Lock{ 2 3 private boolean isLocked = false; 4 5 public synchronized void lock() 6 throws InterruptedException{ 7 while(isLocked){ 8 wait(); 9 } 10 //state change 11 isLocked = true; 12 } 13 14 public synchronized void unlock(){ 15 //state change 16 isLocked = false; 17 notify(); 18 } 19 }
1 public class BoundedSemaphore { 2 private int signals = 0; 3 private int bound = 0; 4 5 public BoundedSemaphore(int upperBound){ 6 this.bound = upperBound; 7 } 8 9 public synchronized void take() throws InterruptedException{ 10 while(this.signals == bound) wait(); 11 //state change 12 this.signals++; 13 this.notify(); 14 } 15 16 public synchronized void release() throws InterruptedException{ 17 while(this.signals == 0) wait(); 18 //state change 19 this.signals--; 20 this.notify(); 21 } 22 }
通知策略
一旦某个线程改变了同步器的状态,可能需要通知其它等待的线程状态已经变了。因为也许这个状态的变化会让其它线程的访问条件变为true。
通知策略通常分为三种:
- 通知所有等待的线程
- 通知N个等待线程中的任意一个
- 通知N个等待线程中的某个指定的线程
通知所有等待的线程非常简单。所有等待的线程都调用的同一个对象上的wait()方法,某个线程想要通知它们只需在这个对象上调用notifyAll()方法。
通知等待线程中的任意一个也很简单,只需将notifyAll()调用换成notify()即可。调用notify方法没办法确定唤醒的是哪一个线程,也就是“等待线程中的任意一个”。
有时候可能需要通知指定的线程而非任意一个等待的线程。例如,如果你想保证线程被通知的顺序与它们进入同步块的顺序一致,或按某种优先级的顺序来通知。想要实现这种需求,每个等待的线程必须在其自有的对象上调用wait()。当通知线程想要通知某个特定的等待线程时,调用该线程自有对象的notify()方法即可。饥饿和公平中有这样的例子。
下面是通知策略的一个例子(通知任意一个等待线程):
1 public class Lock{ 2 3 private boolean isLocked = false; 4 5 public synchronized void lock() 6 throws InterruptedException{ 7 while(isLocked){ 8 //wait strategy - related to notification strategy 9 wait(); 10 } 11 isLocked = true; 12 } 13 14 public synchronized void unlock(){ 15 isLocked = false; 16 notify(); //notification strategy 17 } 18 }
Test-and-Set方法
同步器中最常见的有两种类型的方法,test-and-set是第一种(set是另一种)。Test-and-set的意思是,调用这个方法的线程检查访问条件,如若满足,该线程设置同步器的内部状态来表示它已经获得了访问权限。
状态的改变通常使其它试图获取访问权限的线程计算条件状态时得到false的结果,但并不一定总是如此。例如,在读写锁中,获取读锁的线程会更新读写锁的状态来表示它获取到了读锁,但是,只要没有线程请求写锁,其它请求读锁的线程也能成功。
test-and-set很有必要是原子的,也就是说在某个线程检查和设置状态期间,不允许有其它线程在test-and-set方法中执行。
test-and-set方法的程序流通常遵照下面的顺序:
- 如有必要,在检查前先设置状态
- 检查访问条件
- 如果访问条件不满足,则等待
- 如果访问条件满足,设置状态,如有必要还要通知等待线程
下面的ReadWriteLock类的lockWrite()方法展示了test-and-set方法。调用lockWrite()的线程在检查之前先设置状态(writeRequests++)。然后检查canGrantWriteAccess()中的访问条件,如果检查通过,在退出方法之前再次设置内部状态。这个方法中没有去通知等待线程。
1 public class ReadWriteLock{ 2 private Map<Thread, Integer> readingThreads = 3 new HashMap<Thread, Integer>(); 4 5 private int writeAccesses = 0; 6 private int writeRequests = 0; 7 private Thread writingThread = null; 8 9 ... 10 11 12 public synchronized void lockWrite() throws InterruptedException{ 13 writeRequests++; 14 Thread callingThread = Thread.currentThread(); 15 while(! canGrantWriteAccess(callingThread)){ 16 wait(); 17 } 18 writeRequests--; 19 writeAccesses++; 20 writingThread = callingThread; 21 } 22 23 24 ... 25 }
下面的BoundedSemaphore类有两个test-and-set方法:take()和release()。两个方法都有检查和设置内部状态。
1 public class BoundedSemaphore { 2 private int signals = 0; 3 private int bound = 0; 4 5 public BoundedSemaphore(int upperBound){ 6 this.bound = upperBound; 7 } 8 9 10 public synchronized void take() throws InterruptedException{ 11 while(this.signals == bound) wait(); 12 this.signals++; 13 this.notify(); 14 } 15 16 public synchronized void release() throws InterruptedException{ 17 while(this.signals == 0) wait(); 18 this.signals--; 19 this.notify(); 20 } 21 22 }
set方法
set方法是同步器中常见的第二种方法。set方法仅是设置同步器的内部状态,而不先做检查。set方法的一个典型例子是Lock类中的unlock()方法。持有锁的某个线程总是能够成功解锁,而不需要检查该锁是否处于解锁状态。
set方法的程序流通常如下:
- 设置内部状态
- 通知等待的线程
这里是unlock()方法的一个例子:
1 public class Lock{ 2 3 private boolean isLocked = false; 4 5 public synchronized void unlock(){ 6 isLocked = false; 7 notify(); 8 } 9 10 }
英文原文:http://tutorials.jenkov.com/java-concurrency/index.html
中文参考:http://ifeve.com/java-concurrency-thread-directory/
***********************
心得之谈:欢迎指正,一起学习。
***********************