Java 提供了两套锁体系:synchronized 关键字(JVM 内置)以及 java.util.concurrent.locks 包中的显式锁接口。本文从「为什么需要锁」出发,再依次展开 Lock 接口、ReentrantLock、ReadWriteLock、StampedLock。

一、为什么使用锁

在多线程项目中,当多个线程访问公共资源时,会出现以下三类问题:

  • 原子性问题:某些操作本应作为一个整体不可分割地执行(如读-改-写),但被多个线程交错拆分。
  • 有序性问题:编译器和 CPU 可能对指令进行重排序。
  • 可见性问题:一个线程的修改,另一个线程不一定能看到。

锁的核心作用

  • 互斥:在同一时刻只允许一个线程进行操作。
  • 同步:保证线程之间的执行顺序和内存可见性。

Java 中锁大致分为两类

  • synchronized:JVM 内置关键字,自动加锁 / 释放锁。
  • Lock 接口及其实现:java.util.concurrent(JUC)包提供的显式锁,更灵活。

二、Lock 接口

java.util.concurrent.locks.Lock 是 JUC 中锁的顶层接口。

核心方法

void lock();                    // 获取锁,阻塞直到成功
void lockInterruptibly() throws InterruptedException;  // 可中断获取锁
boolean tryLock();              // 尝试非阻塞获取锁
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;  // 超时获取
void unlock();                  // 释放锁
Condition newCondition();       // 创建条件变量

相比 synchronized 的优势

优势说明
非阻塞尝试tryLock() 可立即返回结果,不阻塞
可中断lockInterruptibly() 在等待时可响应中断
超时tryLock(timeout, unit) 限时等待,避免永久阻塞
公平锁支持公平 / 非公平策略可选
多条件变量同一把锁可创建多个 Condition,实现精准的等待 / 唤醒
显式控制加锁 / 解锁由代码控制,灵活处理异常路径

使用模板

Lock lock = new ReentrantLock();
lock.lock();
try {
    // 临界区
} finally {
    lock.unlock();
}

注意:unlock() 必须放在 finally 中,否则异常会导致死锁。

三、ReentrantLock

  • 是什么:可重入互斥锁,基于 AQS(AbstractQueuedSynchronizer)实现。
  • 可重入:一个线程可以多次加锁,每获取一次 state +1,释放一次 state -1,直到 0 才真正释放。

公平锁 vs 非公平锁

new ReentrantLock(true);   // 公平锁
new ReentrantLock(false);  // 非公平锁(默认)
  • 公平锁:按等待队列顺序获取,避免饥饿,但吞吐量低。
  • 非公平锁:允许插队,吞吐量高,但可能饥饿。

大多数场景用非公平锁。

Condition

ReentrantLock 可以创建多个 Condition 来实现更精细的唤醒。

  • await():当前线程释放锁并进入等待,直到被唤醒或中断。
  • signal():唤醒一个等待中的线程。
  • signalAll():唤醒所有等待线程。

使用方法(生产者-消费者)

ReentrantLock lock = new ReentrantLock();
Condition notFull = lock.newCondition();
Condition notEmpty = lock.newCondition();

// 生产者
lock.lock();
try {
    while (queue.isFull()) notFull.await();
    queue.add(x);
    notEmpty.signal();
} finally {
    lock.unlock();
}

// 消费者
lock.lock();
try {
    while (queue.isEmpty()) notEmpty.await();
    queue.poll();
    notFull.signal();
} finally {
    lock.unlock();
}

这是实现阻塞队列的经典方式。

四、ReadWriteLock

1. 是什么

ReadWriteLock 是一个接口,核心实现是 ReentrantReadWriteLock。它维护两把锁:

  • 读锁(共享锁):多个线程可同时读。
  • 写锁(排他锁):写时不允许其他读或写。

规则:

  • 读-读:不互斥。
  • 读-写:互斥。
  • 写-写:互斥。
ReadWriteLock rwLock = new ReentrantReadWriteLock();
Lock readLock = rwLock.readLock();
Lock writeLock = rwLock.writeLock();

// 读
readLock.lock();
try {
    // 读共享数据
} finally {
    readLock.unlock();
}

// 写
writeLock.lock();
try {
    // 修改共享数据
} finally {
    writeLock.unlock();
}

2. 特点

  • 读多写少场景性能好。
  • 支持锁降级:写锁 → 读锁(先获取读锁再释放写锁)。
  • 不支持锁升级:读锁 → 写锁(会死锁)。
  • 可重入。
  • 可选择公平 / 非公平。

3. 适用场景

  • 缓存:读多写少。
  • 配置中心:偶尔更新,频繁读取。
  • 数据结构:读操作远多于写操作。

4. 缺点

  • 读多写少才划算;写多时性能不如互斥锁。
  • 可能造成写饥饿(读锁一直占用)。
  • 实现较复杂,容易用错。

五、StampedLock

1. 是什么

StampedLock 是 Java 8 引入的锁,位于 java.util.concurrent.locks。它有三种模式:

  • 写锁(writeLock):排他。
  • 悲观读锁(readLock):共享,类似 ReadWriteLock。
  • 乐观读(tryOptimisticRead):不加锁,读取后校验版本号。

核心思想:乐观读 + 版本戳(stamp)校验,在读多写少且冲突少的场景下性能极高。

2. 使用方式

StampedLock lock = new StampedLock();

// 乐观读
long stamp = lock.tryOptimisticRead();
int x = this.x;
int y = this.y;
if (!lock.validate(stamp)) {
    // 版本变了,升级为悲观读
    stamp = lock.readLock();
    try {
        x = this.x;
        y = this.y;
    } finally {
        lock.unlockRead(stamp);
    }
}

// 写
long ws = lock.writeLock();
try {
    this.x = newX;
    this.y = newY;
} finally {
    lock.unlockWrite(ws);
}

3. 特点

  • 不是可重入锁:重复获取会死锁。
  • 不支持 Condition。
  • 乐观读不阻塞写,吞吐量高。
  • stamp 必须正确使用,否则容易出错。
  • 适合读多写少、读操作短小的场景。

4. 适用场景

  • 读远多于写。
  • 读操作很快,冲突少。
  • 对吞吐量要求高。
  • 能接受复杂 API 和不可重入限制。

5. 不适用场景

  • 需要可重入。
  • 需要 Condition。
  • 写操作频繁。
  • 读操作耗时长。