Lock跟Java中的synchronized关键字一样,都是用于线程的同步机制。不同的是Lock相比synchronized关键字提供更加丰富的功能和灵活性。
从Java5开始,java.util.concurrent.locks包中提供了几种不同的Lock实现,因此我们不需要自己去实现锁。但我们仍然需要知道怎么使用它们,以及它们的底层原理。
一个简单的Lock
我们来看一下这样一个同步块:
public class Counter {
private int count = 0;
public int increment() {
synchronized (this) {
return ++count;
}
}
}
我们可以注意到increment()方法中的同步块。这个同步块确保了每次只会有一个线程执行++count;同步块的中的代码相对比较简单,现在我们只需要关注++count();这一行代码即可。
上面的例子用Lock来替换同步块:
public class Counter {
private Lock lock = new Lock();
private int count = 0;
public int increment() {
lock.lock();
return ++count;
lock.unlock();
}
}
increment()方法中,当有线程取得Lock实例后,其他线程都会在调用lock()后被阻塞,直到取得Lock实例的线程调用了unlock()为止。
下面是一个简单的Lock实现:
public class Lock{
private boolean isLocked = false;
public synchronized void lock() throws InterruptedException {
while (isLocked) {
wait();
}
isLocked = true;
}
public synchronized void unlock() {
isLocked = false;
notify();
}
}
我们可以注意到while(isLocked),我们称这种循环为"旋转锁"。旋转锁以及wait()和notify()方法调用在线程通讯一文中有提及。当isLocked为true时,线程会进入循环内部,从而调用wait()方法进入等待状态。实例中,在其他线程没有调用notify()发送信号唤醒的情况下,线程若是意外唤醒会重新检查isLocked条件是否为false以此来确定线程是否可以安全的执行,若仍然为true则线程重新调用wait()方法进入等待状态。因此旋转锁可以保护意外唤醒带来的潜在风险。若线程检查isLocked为false则退出while循环,将isLocked置换为true,以此来标记获得当前Lock实例。让其他线程阻塞在lock()调用上。
当线程执行完临界区代码(位于lock()和unlock()调用之间的代码)后,会调用unlock()方法释放Lock()实例。调用unlock()方法会将isLocked置换为false.并唤醒调用了lock()方法中的wait()方法进入等待状态的线程,如果有的话。
可重入锁
Java中的synchronized同步块是可重入的。这意味着,如果一个Java线程进入一个同步代码块取得当前对象锁,那么它可以再进入其他所用与之相同对象锁的其他同步代码块。如下所示:
public class Reentrant{
public synchronized void outer() {
inner();
}
public synchronized void inner() {
// do something you like
}
}
我们可以注意到outer()和inner()方法签名中都有synchronized声明,这在Java中等同于synchronized(this)同步代码块。当多个方法都是以"this"即当前对象作为监控对象时,那么一个线程在调用完outer()方法后,自然可以在outer()方法内部调用inner()方法。一个线程将一个对象作为监控对象即取得该对象锁后,它可以进入其他使用相同对象作为对象锁的同步代码块。这种情况我们称之为可重入。线程可以反复进入它所取得监控对象的全部同步代码块。
上文中给出的Lock实现并不是可重入的。如果我们将上文的Reentrant.class改写成如下代码所示的Reentrant2.class,那么线程在调用完outer()方法后,将在调用inner()方法中的lock.lock()方法后阻塞。
public class Reentrant2{
Lock lock = new Lock();
public void outer() {
lock.lock();
inner();
lock.unlock();
}
public synchronized void inner() {
lock.lock();
//do something
lock.unlock();
}
}
线程在调用outer()时会先锁住Lock实例。然后再调用inner()方法。在inner()方法内部,线程会再一次尝试锁住Lock实例,但注定会失败。因为lock实例在调用outer()方法时已经被锁住。
在还没有调用unlock()方法释放Lock()实例的时候,再次调用lock()的时候会让线程阻塞在调用lock()方法上。如下lock()方法:
public class Lock{
boolean isLocked = false;
public synchronized void lock() throws InterruptedException {
while (isLocked) {
wait();
}
isLocked = true;
}
}
旋转锁中的条件,决定了线程允不允许退出lock()方法。当isLocked为false时,意味着线程允许退出lock()方法,即允许锁住Lock实例。
让Lock.class支持可重入,我们需要作出一点改动:
public class Lock {
private boolean isLocked = false;
private Thread lockingThread;
int lockedCount = 0;
public void lock() throws InterruptedException {
Thread callingThread = Thread.currentThread();
while (isLocked && lockingThread != callingThread) {
wait();
}
isLocked = true;
lockingThread = callingThread;
lockedCount++;
}
public void unlock() {
Thread callingThread = Thread.currentThread();
if (lockingThread != null && lockingThread == callingThread) {
lockedCount--;
if (lockedCount == 0) {
isLocked = false;
notify();
}
}
}
}
我们可以注意到while循环(旋转锁)中多了一个条件,即当前线程是否为持有锁实例的线程。当只有两个条件都符合时线程才能退出循环和退出lock()方法调用。
我们需要对线程锁住Lock实例的次数进行记录。否则的话,调用一次unlock()方法就会让当前线程释放掉锁尽管实际上它调用了多次lock()。因此我们需要保障lock()和unlock()方法的调用次数是一致的,即每调用一次lock()即必须调用一次unlock()。
现在的Lock.class已经是可重入的了。
公平锁
Java的synchronized同步代码块不能保证线程按照一定的顺序进入同步代码块。因此存在一定的风险,有一或者几个线程一直无法进入同步代码块,因为其他线程一直代替它们进入到同步代码块。我们称这种情况为肌饿现象。
为了解决这个问题,我们实现的锁需要保证一定的公平性,但上文给出的Lock实现并不能保证线程的公平性。饥饿和公平一文中详细讨论了这种情况。
在finally语句中调用unlock()
当使用Lock来保证临界区代码的同步时,临界区中的代码可能会抛出异常。所以很有必要在finally语句中来调用unlock()方法。无论线程执行的代码异常与否,始终能够释放它所持有的锁,以让其他线程能正常执行。如下所示:
lock.lock();
try {
//do critical section code, which may throw exception
} finally {
lock.unlock();
}
我们使用这个小结构来保证即使临界区代码抛出异常,线程也能正常释放掉所持有的锁。如果我们没有这么做,一旦临界区代码出现异常,线程将无法释放锁,以至于其他调用了该锁的lock()方法的线程将会永远等待下去。
该系列博文为笔者复习基础所著译文或理解后的产物,复习原文来自Jakob Jenkov所著Java Concurrency and Multithreading Tutorial