Java 从入门到精通(十七):Lock 与 ReentrantLock,为什么有了 synchronized,Java 还要再提供一套显式锁?

前一篇我们把线程通信这件事讲清楚了:

  • wait() / notify() / notifyAll() 到底在做什么
  • 为什么线程有时不是在“抢锁”,而是在“等条件”
  • 为什么线程通信必须和 synchronized 搭配使用
  • 生产者—消费者模型里,等待与唤醒是怎么配合的

但当你继续往下学 Java 并发,很快就会遇到另一个问题:

既然 synchronized 已经能加锁,为什么 Java 还要再提供 Lock 这套显式锁机制?

很多初学者第一次看到 ReentrantLock 时,都会有点疑惑:

  • 这不也是加锁吗?
  • synchronized 有什么本质区别?
  • 平时开发到底该优先用谁?
  • lock()unlock()tryLock()lockInterruptibly() 分别适合什么场景?
  • Condition 和前面学过的 wait() / notify() 又是什么关系?

这篇文章,我就把这条线完整接上。

你可以把它理解成:

从“会用内置锁”进阶到“会选择锁工具”的第一步。

这篇重点讲 6 件事:

  1. synchronizedLock 的关系是什么
  2. ReentrantLock 为什么叫“可重入锁”
  3. 显式锁比内置锁多给了哪些能力
  4. tryLock()、可中断加锁、公平锁分别解决什么问题
  5. Condition 为什么比 wait() / notify() 更灵活
  6. 实际开发里什么时候该用 synchronized,什么时候考虑 ReentrantLock

你把这一篇吃透,后面再去学:

  • ReadWriteLock
  • StampedLock
  • Condition
  • 线程池里的并发控制
  • AQS 体系下的高级并发工具

会顺很多。


一、先给结论:Lock 不是替代一切,而是给你更强的控制力

很多人刚学并发时,容易把问题理解成:

  • synchronized 是老的
  • Lock 是新的
  • 所以 Lock 一定更高级、更应该优先用

这其实不对。

更准确的理解应该是:

1)synchronized 是 Java 内置的同步机制

它简单、直接、语义稳定。

你只要写:

synchronized (lock) {
    // 临界区代码
}

就能完成互斥控制。

2)Lock 是并发包提供的显式锁接口

它让你手动控制“什么时候加锁、什么时候释放、是否尝试获取、是否响应中断、是否公平排队”等细节。

所以你可以先这样记:

  • synchronized:够用、简单、适合大多数基础同步场景
  • Lock:更灵活,适合你需要精细控制的时候

也就是说,Lock 的价值不在于“能加锁”,而在于“能更细地控制加锁行为”。


二、什么叫可重入锁?

ReentrantLock 这个类名里最关键的词,就是 Reentrant(可重入)

那什么叫可重入?

意思是:

同一个线程如果已经拿到了这把锁,那么它可以再次获取这把锁,而不会把自己卡死。

比如:

public class Demo {
    private final ReentrantLock lock = new ReentrantLock();

    public void methodA() {
        lock.lock();
        try {
            System.out.println("进入 methodA");
            methodB();
        } finally {
            lock.unlock();
        }
    }

    public void methodB() {
        lock.lock();
        try {
            System.out.println("进入 methodB");
        } finally {
            lock.unlock();
        }
    }
}

当同一个线程先进入 methodA(),再调用 methodB() 时,它会再次成功拿到同一把锁。

如果锁不可重入,会发生什么?

那线程自己会把自己堵住:

  • 第一次进来时已经持有锁
  • 第二次再请求同一把锁
  • 锁不允许重复进入
  • 于是线程永远等自己释放

这显然不合理。

所以无论是 synchronized,还是 ReentrantLock,本质上都支持可重入。

这也是为什么很多递归调用、嵌套同步调用能正常工作的原因。


三、synchronizedReentrantLock 的共同点是什么?

先别急着看差异,先把共同点踩稳。

它们都能完成这些事:

  • 保证同一时刻只有一个线程进入临界区
  • 保护共享数据,避免并发写乱
  • 建立线程间的同步关系
  • 支持可重入

也就是说,在“最基本的互斥”层面,它们都能解决问题。

如果你的需求只是:

  • 一个方法同一时刻只允许一个线程执行
  • 几行共享变量修改需要保护
  • 没有超时、可中断、公平性这类要求

那很多时候 synchronized 已经完全够用了。

所以不要把 ReentrantLock 神化成“必须替代 synchronized 的标准答案”。


四、那 ReentrantLock 到底多了什么?

这才是本文真正的重点。

ReentrantLock 相比 synchronized,最大的优势不是“能锁住”,而是多给了你几种控制方式

主要看下面这几类。

1)可以显式加锁、显式释放

lock.lock();
try {
    // 临界区
} finally {
    lock.unlock();
}

你会发现,它不像 synchronized 那样靠代码块边界自动释放,而是由你自己控制。

这带来的好处是:

  • 锁的作用范围更灵活
  • 可以跨多个代码片段组织逻辑
  • 更适合一些复杂流程控制

但代价也很明显:

如果你忘了 unlock(),就会出大问题。

所以 ReentrantLock 几乎总要配合 try/finally 使用。

2)可以尝试获取锁,而不是死等

这就是 tryLock() 的价值。

if (lock.tryLock()) {
    try {
        System.out.println("拿到锁,执行业务");
    } finally {
        lock.unlock();
    }
} else {
    System.out.println("没拿到锁,走降级逻辑");
}

这在很多场景里特别有用,比如:

  • 不想长期阻塞线程
  • 没抢到锁就直接返回
  • 想做降级、重试、跳过当前任务
  • 避免多个线程长时间堆死在某个热点资源上

synchronized 就没有这种“试一下,不行就算了”的语义。

3)可以设置超时等待

if (lock.tryLock(2, TimeUnit.SECONDS)) {
    try {
        System.out.println("2 秒内成功拿到锁");
    } finally {
        lock.unlock();
    }
} else {
    System.out.println("等了 2 秒还没拿到");
}

这个能力也很实用。

因为很多真实系统里,最怕的不是抢不到锁,而是:

线程无限期傻等,最后把吞吐、响应时间、调用链全拖死。

有超时,你就能做更稳妥的策略控制。

4)可以响应中断

这就是 lockInterruptibly() 的意义。

lock.lockInterruptibly();

它表示:

如果线程在等待锁的过程中被中断,那就不要继续死等了,直接响应中断。

这对取消任务、停止线程、超时控制都很重要。

synchronized 在等待进入监视器时,对中断支持就没这么灵活。

5)可以选择公平锁或非公平锁

构造 ReentrantLock 时可以这样写:

ReentrantLock fairLock = new ReentrantLock(true);

true 表示公平锁。

公平锁的意思是:

大致按等待顺序来分配锁,先等的线程更有机会先拿到。

默认是非公平锁,吞吐通常更高,但有时可能让某些线程等得更久。

所以你可以这样理解:

  • 公平锁:更讲排队秩序
  • 非公平锁:更讲性能和吞吐

不过要注意,公平不是“绝对公平”,只是尽量按规则排。


五、为什么显式锁更适合复杂并发控制?

因为真实世界里的并发问题,很多并不只是“围一段代码加个锁”这么简单。

例如:

  • 某个线程只愿意等 500ms
  • 某个后台任务可以被取消
  • 某个资源抢不到时要走降级逻辑
  • 某类线程需要更稳定的排队顺序
  • 某组等待条件需要单独管理,而不是所有线程混在一个等待队列里

这时 ReentrantLock 的优势就出来了。

它更像是:

把“锁”从语法糖变成一个可以编排策略的对象。

这也是为什么在一些框架、中间件、并发容器里,显式锁特别常见。


六、Condition 是什么?为什么它比 wait() / notify() 更灵活?

如果你学过前一篇,就会知道:

  • wait():线程等待
  • notify():唤醒一个
  • notifyAll():唤醒全部

但这套机制有个明显局限:

同一个锁对象上的等待线程,默认都挤在一个等待集合里。

很多时候这并不够细。

比如生产者—消费者场景里,其实经常有两类条件:

  • 队列不满,生产者才能继续放
  • 队列不空,消费者才能继续取

如果所有线程都在一个等待队列里,你经常会遇到:

  • 唤醒了“不该醒”的线程
  • 醒了以后又发现条件不满足
  • 又继续睡回去

效率并不好。

Condition 的核心价值就是:

一个锁,可以关联多个条件队列。

示意写法:

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

这样:

  • 生产者可以等 notFull
  • 消费者可以等 notEmpty

彼此的等待与唤醒更精确。

这就是显式锁在复杂线程协调里特别有价值的地方。


七、看一个 Condition 版生产者—消费者例子

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

public class MessageQueue {
    private final Queue<String> queue = new LinkedList<>();
    private final int capacity = 5;

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

    public void put(String message) throws InterruptedException {
        lock.lock();
        try {
            while (queue.size() == capacity) {
                notFull.await();
            }

            queue.offer(message);
            System.out.println("生产消息:" + message);

            notEmpty.signal();
        } finally {
            lock.unlock();
        }
    }

    public String take() throws InterruptedException {
        lock.lock();
        try {
            while (queue.isEmpty()) {
                notEmpty.await();
            }

            String message = queue.poll();
            System.out.println("消费消息:" + message);

            notFull.signal();
            return message;
        } finally {
            lock.unlock();
        }
    }
}

这个例子特别适合你体会 Condition 的优势:

  • 满了时,只让生产者等 notFull
  • 空了时,只让消费者等 notEmpty
  • 放入数据后,只唤醒“等着消费”的线程
  • 取走数据后,只唤醒“等着生产”的线程

相比所有线程都混用 wait() / notifyAll(),这套逻辑更清晰,也更贴近真实业务需求。


八、使用 ReentrantLock 时,最容易踩哪些坑?

这部分你最好认真看,因为实际开发里很多问题都出在这。

1)忘记在 finally 里释放锁

错误写法:

lock.lock();
doSomething();
lock.unlock();

如果 doSomething() 中间抛异常,unlock() 就可能根本执行不到。

正确写法:

lock.lock();
try {
    doSomething();
} finally {
    lock.unlock();
}

这几乎是硬规则。

2)拿到锁几次,就要释放几次

因为它是可重入锁。

如果同一个线程连续 lock() 了两次,那也必须对应 unlock() 两次,锁才会真正释放。

3)tryLock() 成功后也别忘了释放

有些人觉得“这是尝试获取,不一定成功”,结果成功之后反而忘了 unlock()

4)await() / signal() 也必须在持锁状态下调用

wait() / notify() 一样,Condition 也不是随便乱调的。

它们必须在已经持有对应锁的前提下使用,否则会抛异常。

5)等待条件时,依然要用 while,别随手写成 if

像这样:

while (queue.isEmpty()) {
    notEmpty.await();
}

不要图省事写成:

if (queue.isEmpty()) {
    notEmpty.await();
}

为什么?

因为线程被唤醒后,不代表条件就一定仍然满足。可能:

  • 有虚假唤醒
  • 有别的线程先一步改了状态
  • 多线程竞争后,条件又失效了

所以标准写法就是:

醒来以后重新检查条件。


九、实际开发中,到底该优先用谁?

这是很多人最关心的问题。

我给你一个很实用的判断原则。

优先用 synchronized 的场景

如果你只是:

  • 保护一个简单共享变量
  • 同步一个方法或一小段临界区
  • 不需要超时、可中断、公平性
  • 不需要多个条件队列

那优先用 synchronized 很合理。

原因很简单:

  • 代码更短
  • 出错面更小
  • 不容易忘记释放锁
  • 语义更直接

考虑 ReentrantLock 的场景

如果你需要:

  • 尝试获取锁(tryLock()
  • 超时获取锁
  • 等待锁时响应中断
  • 更灵活的条件队列(Condition
  • 公平锁控制
  • 更复杂的并发编排

ReentrantLock 更合适。

所以别把选择理解成“谁更高级”,而要理解成:

谁更适合当前问题。


十、再往前一步:这篇学完后,你脑子里要形成什么图景?

到这里,你应该已经把 Java 并发里最基础的一组工具串起来了:

第一层:互斥

  • synchronized
  • Lock

解决的是:同一时刻谁能进入临界区。

第二层:条件协调

  • wait() / notify()
  • Condition.await() / signal()

解决的是:什么时候该等,什么时候该继续。

第三层:更复杂的并发组织

后面你会继续学到:

  • ReadWriteLock
  • Semaphore
  • CountDownLatch
  • CyclicBarrier
  • BlockingQueue
  • 线程池

这些工具本质上都在做一件事:

把多线程之间的协作规则表达得更清楚。

也就是说,并发编程不是只会“加锁”,而是要会设计:

  • 资源怎么互斥
  • 条件怎么等待
  • 线程怎么协调
  • 风险怎么兜底

最后总结一下

今天这篇,你可以把核心记成下面几句。

1)synchronizedReentrantLock 都能完成互斥

在最基础的加锁能力上,它们都能保护共享资源。

2)ReentrantLock 的价值在于“更强控制力”

它不是单纯为了“再造一把锁”,而是提供:

  • 尝试加锁
  • 超时等待
  • 可中断获取
  • 公平锁
  • 多条件队列

3)Condition 是比 wait() / notify() 更细粒度的条件协调工具

尤其在多条件场景下,会明显更清晰。

4)简单场景别过度设计,复杂场景别硬撑着只用 synchronized

并发工具没有“唯一正确答案”,只有“是否适配当前需求”。


如果你刚学到这里,我建议你下一步继续顺着这条线往下看:

  1. ReentrantLocksynchronized 的底层差异
  2. ReadWriteLock 为什么适合“读多写少”
  3. BlockingQueue 如何把生产者—消费者模式封装起来
  4. 线程池为什么比手动创建线程更适合真实项目
Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐