Java 中的锁

更新于 2025-12-28

Jakob Jenkov 2014-06-23

锁是一种线程同步机制,类似于 synchronized 块,但锁可以比 Java 的 synchronized 块更复杂。锁(以及其他更高级的同步机制)是使用 synchronized 块实现的,因此我们并不能完全摆脱 synchronized 关键字。

从 Java 5 开始,java.util.concurrent.locks 包中包含了多个锁的实现,因此你可能不需要自己实现锁。但你仍然需要知道如何使用它们,并且了解其背后的实现原理仍然是有益的。


一个简单的锁(A Simple Lock)

我们先来看一段使用 synchronized 块的 Java 代码:

public class Counter {
    private int count = 0;

    public int inc() {
        synchronized(this) {
            return ++count;
        }
    }
}

注意 inc() 方法中的 synchronized(this) 块。该块确保同一时间只有一个线程能执行 return ++count。虽然同步块中的代码可以更复杂,但这里简单的 ++count 已足以说明问题。

也可以用 Lock 替代 synchronized 块来重写 Counter 类:

public class Counter {
    private Lock lock = new Lock();
    private int count = 0;

    public int inc() {
        lock.lock();
        int newCount = ++count;
        lock.unlock();
        return newCount;
    }
}

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) 循环,也称为“自旋锁”(spin lock)。自旋锁以及 wait() 和 notify() 方法在 线程通信(Thread Signaling) 一文中会有更详细的介绍。

当 isLocked 为 true 时,调用 lock() 的线程会在 wait() 调用中被挂起等待。如果线程在未收到 notify() 调用的情况下意外地从 wait() 返回(即发生 虚假唤醒(Spurious Wakeup)),线程会重新检查 isLocked 条件,以判断是否安全继续执行,而不是假设被唤醒就意味着可以继续。如果 isLocked 为 false,线程将退出 while(isLocked) 循环,并将 isLocked 重新设为 true,从而为其他调用 lock() 的线程锁定该 Lock 实例。

当线程完成临界区(critical section,即 lock() 和 unlock() 之间的代码)的操作后,会调用 unlock()。执行 unlock() 会将 isLocked 重置为 false,并通知(唤醒)在 lock() 方法中 wait() 调用里等待的某个线程(如果有的话)。


锁的可重入性(Lock Reentrance)

Java 中的 synchronized 块是可重入的(reentrant)。这意味着,如果一个 Java 线程进入了一个同步代码块,从而获得了该同步块所关联监视器对象的锁,那么该线程可以进入其他同样基于该监视器对象同步的代码块。

示例如下:

public class Reentrant {
    public synchronized void outer() {
        inner();
    }

    public synchronized void inner() {
        // do something
    }
}

注意 outer() 和 inner() 都声明为 synchronized,这在 Java 中等价于 synchronized(this) 块。如果一个线程调用了 outer(),那么从 outer() 内部调用 inner() 不会有任何问题,因为两个方法(或块)都是在同一个监视器对象(this)上同步的。如果一个线程已经持有了某个监视器对象的锁,它就有权访问所有在该监视器对象上同步的代码块。这被称为可重入性(reentrance)——线程可以重新进入它已持有锁的任何代码块。

然而,即使 synchronized 块是可重入的,前面展示的 Lock 类却不是可重入的。如果我们像下面这样重写 Reentrant 类,那么调用 outer() 的线程将在 inner() 方法中的 lock.lock() 处被阻塞:

public class Reentrant2 {
    Lock lock = new Lock();

    public void outer() {
        lock.lock();
        inner();
        lock.unlock();
    }

    public void inner() {
        lock.lock();
        // do something
        lock.unlock();
    }
}

调用 outer() 的线程首先会锁定 Lock 实例,然后调用 inner()。在 inner() 方法内部,该线程会再次尝试锁定同一个 Lock 实例。这将失败(即线程会被阻塞),因为在 outer() 方法中该 Lock 实例已经被锁定了。

之所以第二次调用 lock() 会被阻塞(在没有先调用 unlock() 的情况下),从 lock() 的实现中可以看出原因:

public class Lock {
    boolean isLocked = false;

    public synchronized void lock() throws InterruptedException {
        while (isLocked) {
            wait();
        }
        isLocked = true;
    }
    ...
}

决定线程是否允许退出 lock() 方法的条件就是 while 循环中的判断。当前的条件是:只有当 isLocked 为 false 时才允许退出,而不管是谁锁定了它。

要使 Lock 类支持可重入,我们需要做一点小改动:

public class Lock {
    boolean isLocked = false;
    Thread lockedBy = null;
    int lockedCount = 0;

    public synchronized void lock() throws InterruptedException {
        Thread callingThread = Thread.currentThread();
        while (isLocked && lockedBy != callingThread) {
            wait();
        }
        isLocked = true;
        lockedCount++;
        lockedBy = callingThread;
    }

    public synchronized void unlock() {
        if (Thread.currentThread() == this.lockedBy) {
            lockedCount--;
            if (lockedCount == 0) {
                isLocked = false;
                notify();
            }
        }
    }
    ...
}

注意现在 while 循环(自旋锁)也考虑了锁定 Lock 实例的线程。如果锁未被占用(isLocked == false)或者当前调用线程就是持有该锁的线程,那么 while 循环就不会执行,调用 lock() 的线程将被允许退出该方法。

此外,我们还需要记录同一个线程对锁的加锁次数。否则,一次 unlock() 调用就会释放锁,即使该锁已被同一线程多次加锁。我们不希望锁被释放,除非持有它的线程执行了与 lock() 调用次数相等的 unlock() 调用。

经过上述修改,Lock 类现在就支持可重入了。


锁的公平性(Lock Fairness)

Java 的 synchronized 块不保证试图进入它的线程获得访问权限的顺序。因此,如果有多个线程持续竞争同一个 synchronized 块的访问权,就存在某些线程永远无法获得访问权的风险——访问权总是被授予其他线程。这种情况称为饥饿(starvation)。

为了避免饥饿,锁应当具备公平性(fairness)。由于本文展示的 Lock 实现在内部使用了 synchronized 块,因此它们不保证公平性。关于饥饿和公平性的更多讨论,请参见 饥饿与公平性(Starvation and Fairness)。


在 finally 子句中调用 unlock()(Calling unlock() From a finally-clause)

当使用 Lock 保护临界区,且临界区代码可能抛出异常时,必须在 finally 子句中调用 unlock() 方法。这样做可以确保 Lock 被正确释放,以便其他线程可以获取它。

示例如下:

lock.lock();
try {
    // 执行临界区代码,可能会抛出异常
} finally {
    lock.unlock();
}

这个小结构确保了即使临界区代码抛出异常,Lock 也会被解锁。如果 unlock() 没有放在 finally 子句中,而临界区又抛出了异常,那么 Lock 将永远保持锁定状态,导致所有在该 Lock 实例上调用 lock() 的线程无限期地阻塞。