Java 从入门到精通(十七):Lock 与 ReentrantLock,为什么有了 synchronized,Java 还要再提供一套显式锁?
Java 从入门到精通(十七):Lock 与 ReentrantLock,为什么有了 synchronized,Java 还要再提供一套显式锁?
前一篇我们把线程通信这件事讲清楚了:
wait()/notify()/notifyAll()到底在做什么- 为什么线程有时不是在“抢锁”,而是在“等条件”
- 为什么线程通信必须和
synchronized搭配使用 - 生产者—消费者模型里,等待与唤醒是怎么配合的
但当你继续往下学 Java 并发,很快就会遇到另一个问题:
既然 synchronized 已经能加锁,为什么 Java 还要再提供 Lock 这套显式锁机制?
很多初学者第一次看到 ReentrantLock 时,都会有点疑惑:
- 这不也是加锁吗?
- 和
synchronized有什么本质区别? - 平时开发到底该优先用谁?
lock()、unlock()、tryLock()、lockInterruptibly()分别适合什么场景?Condition和前面学过的wait()/notify()又是什么关系?
这篇文章,我就把这条线完整接上。
你可以把它理解成:
从“会用内置锁”进阶到“会选择锁工具”的第一步。
这篇重点讲 6 件事:
synchronized和Lock的关系是什么ReentrantLock为什么叫“可重入锁”- 显式锁比内置锁多给了哪些能力
tryLock()、可中断加锁、公平锁分别解决什么问题Condition为什么比wait()/notify()更灵活- 实际开发里什么时候该用
synchronized,什么时候考虑ReentrantLock
你把这一篇吃透,后面再去学:
ReadWriteLockStampedLockCondition- 线程池里的并发控制
- 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,本质上都支持可重入。
这也是为什么很多递归调用、嵌套同步调用能正常工作的原因。
三、synchronized 和 ReentrantLock 的共同点是什么?
先别急着看差异,先把共同点踩稳。
它们都能完成这些事:
- 保证同一时刻只有一个线程进入临界区
- 保护共享数据,避免并发写乱
- 建立线程间的同步关系
- 支持可重入
也就是说,在“最基本的互斥”层面,它们都能解决问题。
如果你的需求只是:
- 一个方法同一时刻只允许一个线程执行
- 几行共享变量修改需要保护
- 没有超时、可中断、公平性这类要求
那很多时候 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 并发里最基础的一组工具串起来了:
第一层:互斥
synchronizedLock
解决的是:同一时刻谁能进入临界区。
第二层:条件协调
wait()/notify()Condition.await()/signal()
解决的是:什么时候该等,什么时候该继续。
第三层:更复杂的并发组织
后面你会继续学到:
ReadWriteLockSemaphoreCountDownLatchCyclicBarrierBlockingQueue- 线程池
这些工具本质上都在做一件事:
把多线程之间的协作规则表达得更清楚。
也就是说,并发编程不是只会“加锁”,而是要会设计:
- 资源怎么互斥
- 条件怎么等待
- 线程怎么协调
- 风险怎么兜底
最后总结一下
今天这篇,你可以把核心记成下面几句。
1)synchronized 和 ReentrantLock 都能完成互斥
在最基础的加锁能力上,它们都能保护共享资源。
2)ReentrantLock 的价值在于“更强控制力”
它不是单纯为了“再造一把锁”,而是提供:
- 尝试加锁
- 超时等待
- 可中断获取
- 公平锁
- 多条件队列
3)Condition 是比 wait() / notify() 更细粒度的条件协调工具
尤其在多条件场景下,会明显更清晰。
4)简单场景别过度设计,复杂场景别硬撑着只用 synchronized
并发工具没有“唯一正确答案”,只有“是否适配当前需求”。
如果你刚学到这里,我建议你下一步继续顺着这条线往下看:
ReentrantLock和synchronized的底层差异ReadWriteLock为什么适合“读多写少”BlockingQueue如何把生产者—消费者模式封装起来- 线程池为什么比手动创建线程更适合真实项目
更多推荐



所有评论(0)