Basic Of Concurrency(十四: Java中的锁)

560 阅读6分钟

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

上一篇: Slipped Conditions
下一篇: Java中的读写锁