Skip to content

synchronized 日常够用了——简单、自动加锁释放,JDK 1.6 之后性能也优化得很好。但它有几个天生的限制:拿不到锁就一直傻等、等的时候不能被中断、所有线程抢锁不排队、也没有办法让线程"分拨"等待。这篇把 ReentrantLock 系统梳理一遍:它补齐了哪些能力、每个能力解决什么场景,以及什么时候其实还是 synchronized 更合适。

这是"锁"系列的一篇。各种锁概念的全景(本地锁/分布式锁/悲观乐观锁)在《锁的分类与实现原理》,这里只深挖 ReentrantLock 本身。


一、synchronized 的四个"做不到"

先看 synchronized 用起来没毛病、但真遇到具体需求时会卡住的四个点:

场景synchronized 的表现想要的效果
试探性拿锁lock() 进了同步块才知道等不等得到拿不到就算了,或者等 500ms 就放弃
排队时被叫醒拿不到锁就阻塞,中断不了(不响应 interrupt等太久可以取消,别干耗着
先来后到不排队,谁抢到是谁的(非公平)可以选公平模式,按排队顺序给
分拨等待只有一个"大门",所有线程等在一起读写分离等、按条件分组等(Condition)

ReentrantLock 就是把这四件事补齐的显式锁。注意它不是"更强的 synchronized 所以要替代它"——它提供的是"可控性",代价是用法更繁琐。

二、基本用法:lock 和 unlock 必须成对

java
import java.util.concurrent.locks.ReentrantLock;

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

    public void increment() {
        lock.lock();          // 加锁
        try {
            count++;
        } finally {
            lock.unlock();    // 必须放 finally!
        }
    }
}

synchronized 最大的区别:释放锁要自己负责synchronized 出了同步块自动释放,ReentrantLock 如果忘了 unlock(或异常时没释放),这把锁就永远被人占着——其他线程全部卡死。所以铁律是 unlock 一定放 finally 里。

三、可重入:同一线程可以重复加锁

"可重入"的意思是:同一个线程,拿到锁之后再次进入这把锁,不会被自己卡死。锁内部维护一个计数器(holdCount),加一次加 1,释放一次减 1,减到 0 才真正释放。

java
ReentrantLock lock = new ReentrantLock();

public void outer() {
    lock.lock();
    try {
        inner();              // inner 里还要再 lock,没问题
    } finally {
        lock.unlock();
    }
}

public void inner() {
    lock.lock();              // 同一线程重复加锁,直接进
    try {
        // ...
    } finally {
        lock.unlock();
    }
}

为什么需要这个特性?如果没有可重入,上面的 outer()inner() 就死锁了——自己等自己的锁,永远等不到。synchronized 天生就是可重入的,这一点两者一致。

四、tryLock:拿不到就算了,或等一会儿

这是 synchronized 完全做不到的能力。两个版本:

java
// 版本 1:立即返回,抢不到马上走人
if (lock.tryLock()) {
    try { /* 拿到了,干活 */ }
    finally { lock.unlock(); }
} else {
    // 没抢到,干别的事去(比如记条日志、返回提示)
}

// 版本 2:最多等 500 毫秒,超时放弃
if (lock.tryLock(500, TimeUnit.MILLISECONDS)) {
    try { /* ... */ }
    finally { lock.unlock(); }
} else {
    // 等 500ms 还没轮到我,放弃
}

典型场景:任务调度去重。定时任务每分钟跑一次,集群里多台机器都会触发,但同一时刻只想让一台执行——tryLock() 抢到的机器干活,没抢到的直接返回。这种情况就不该傻等,等着毫无意义。

还有一个隐藏用法:detect 死锁tryLock(1, TimeUnit.SECONDS) 都拿不到,大概率不是"忙",而是死锁了,可以放弃并记录现场。

五、lockInterruptibly:排队时可以被叫醒

普通 lock() 排队等锁时,别的线程 interrupt 它,它不理睬,继续等。lockInterruptibly() 则会在等待中被中断,抛出 InterruptedException

java
Thread worker = new Thread(() -> {
    try {
        lock.lockInterruptibly();   // 排队,但随时可以被叫醒
        try { /* 拿到锁,干活 */ }
        finally { lock.unlock(); }
    } catch (InterruptedException e) {
        // 排队时被中断,可以清理资源、退出
        Thread.currentThread().interrupt();
    }
});

这个能力在需要可取消的任务里很关键。比如线程池要 shutdownNow(),正在排队的线程如果能响应中断就能快速退出;用 lock() 排队的话,它们会一直等到拿到锁、干完活才能停。

对照记忆:synchronized 等锁阶段完全不响应中断——这也是它"不可控"的一部分。

六、公平锁:先来后到

构造时传 true 就开了公平模式:

java
ReentrantLock fairLock = new ReentrantLock(true);   // 公平锁
ReentrantLock lock = new ReentrantLock();           // 默认非公平
  • 非公平锁(默认):锁一释放,谁抢到是谁的。刚来的线程可能"插队"——它正好空闲,CAS 一下就拿到了,而队列里排了半天的线程还在睡。好处是吞吐量高(省去了唤醒排队线程的开销),坏处是可能有线程长时间抢不到。
  • 公平锁:严格按排队顺序给锁,先来先得,不会饿死。代价是每次释放都要唤醒队头线程,多一次线程切换,吞吐量明显低。

实践中默认用非公平就好。真遇到"某些线程总抢不到"的饥饿问题,先怀疑是不是锁粒度太粗,再考虑公平锁。

七、Condition:让线程"分拨"等待

synchronizedwait()/notify() 只有一个等待集合——所有等待的线程挤在一起,notify() 随机叫醒一个,叫醒的可能不是想要的那个。Condition 把"等待"拆成多个条件队列,每拨线程等各自的门

经典的生产者-消费者:

java
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;

public class BoundedBuffer {
    private final Object[] items = new Object[10];
    private int putIdx, takeIdx, count;

    private final ReentrantLock lock = new ReentrantLock();
    private final Condition notFull  = lock.newCondition();  // "不满"这拨
    private final Condition notEmpty = lock.newCondition();  // "不空"这拨

    public void put(Object x) throws InterruptedException {
        lock.lock();
        try {
            while (count == items.length) {
                notFull.await();       // 满了,去"不满"队列等
            }
            items[putIdx] = x;
            if (++putIdx == items.length) putIdx = 0;
            count++;
            notEmpty.signal();         // 放了数据,叫醒"不空"队列里的消费者
        } finally {
            lock.unlock();
        }
    }

    public Object take() throws InterruptedException {
        lock.lock();
        try {
            while (count == 0) {
                notEmpty.await();      // 空了,去"不空"队列等
            }
            Object x = items[takeIdx];
            if (++takeIdx == items.length) takeIdx = 0;
            count--;
            notFull.signal();          // 腾出位置,叫醒"不满"队列里的生产者
            return x;
        } finally {
            lock.unlock();
        }
    }
}

看清楚这个配合:生产者只叫醒消费者(notEmpty.signal()),消费者只叫醒生产者(notFull.signal())。如果用 wait()/notifyAll(),每次都得全员叫醒、全员重新检查条件,白白多一轮空转。等的人和被等的人分开,就是 Condition 的价值。

两个细节:

  • await() 必须写在 while 循环里而不是 if——被唤醒后条件可能又被别的线程改掉了,醒来要再检查一遍。
  • signal() 只叫醒一个,signalAll() 叫醒该队列全部。拿不准就用 signalAll(),代价只是多做几次条件检查。

这张图把两个队列的等待/唤醒关系画出来:

ReentrantLock Condition 双条件队列机制

八、什么时候还是用 synchronized

ReentrantLock 不是"更高级所以更好",它有明确的使用代价:

synchronizedReentrantLock
加锁/释放自动,出了同步块就放手动,忘 unlock 就出事
可重入
tryLock / 可中断 / 公平都不支持都支持
Condition无(只有单一 wait/notify)多条件队列
代码直观性差一截

判断很简单:需要 tryLock、可中断、公平、Condition 这四个能力中的任何一个,用 ReentrantLock;四个都不需要,用 synchronized 大多数业务代码是后者。

小结

  • ReentrantLock 补的是 synchronized 的四个"做不到":tryLock(试探/限时)、lockInterruptibly(排队可中断)、公平锁(先来后到)、Condition(分拨等待)
  • 代价是手动释放:unlock 必须放 finally,忘放就是死锁
  • 可重入靠内部计数器,同线程重复加锁不死锁(和 synchronized 一致)
  • 公平锁防饿死但吞吐量低,默认非公平
  • Condition 的价值是"等的人和被等的人分开",await 要写在 while
  • 四个能力都不需要时,synchronized 仍是首选

ReentrantLock 底层是怎么做到这一切的?CAS 改 state、排队、挂起唤醒——这套机制叫 AQS,单独写成了一篇:《AQS 原理详解》。读多写少的场景还有一把更合适的锁:《读写锁 ReentrantReadWriteLock 与 StampedLock》。并发的整体地图看《Java 并发编程》。