前一篇我们把线程通信这件事拆开讲清楚了:
- 线程同步解决的是“别同时乱改”
- 线程通信解决的是“条件没到先等,条件到了再继续”
wait()/notify()为什么必须和synchronized一起使用- 为什么等待条件时更推荐
while而不是if - 生产者—消费者模型为什么是并发入门的经典训练场
但学到这里,很多人很快会冒出一个新问题:
既然 Java 已经有 synchronized 了,为什么后面还要学 Lock?
而且你一看代码,可能会更疑惑:
synchronized已经能保证互斥,为什么还要手动lock()/unlock()?ReentrantLock到底“高级”在哪里?- 什么叫可重入?它和
synchronized有什么共同点? - 公平锁、可中断加锁、尝试加锁到底是什么意思?
- 真实开发里,什么时候该用
synchronized,什么时候更适合ReentrantLock?
这些问题背后,本质上是在问:
Java 的并发控制,为什么会同时保留“内置锁”和“显式锁”两套方案?
这篇文章就把这件事讲透。
我想重点回答 6 个问题:
Lock接口是为了解决什么问题ReentrantLock和synchronized的共同点与差异- 什么叫“可重入”,为什么它很重要
lock()、unlock()、tryLock()、lockInterruptibly()分别适合什么场景- 公平锁和非公平锁到底怎么理解
- 初学者最容易踩哪些坑
你把这一篇吃透,后面再去学:
ConditionReadWriteLockStampedLock- 并发容器和线程池中的锁设计
会顺很多。
一、先说结论:Lock 不是为了替代 synchronized,而是为了提供更可控的加锁能力
很多人第一次学 Lock,会下意识把它理解成“更高级版 synchronized”。
这个理解不算错,但不够准确。
更好的说法是:
synchronized 是 Java 语言内置的同步机制,语法简单、够用、稳定;Lock 是并发包提供的显式锁方案,重点在于更灵活、更可控。
它们都能解决互斥问题,但设计目标并不完全一样。
1)synchronized 的特点是简单
你写上关键字,JVM 就会帮你完成:
- 进入同步区前获取锁
- 离开同步区后自动释放锁
- 即使出现异常,也会自动释放
所以它特别适合:
- 并发入门
- 逻辑简单的临界区保护
- 锁范围比较清晰的场景
2)Lock 的特点是显式控制
用 Lock 时,你需要自己写:
- 什么时候加锁
- 什么时候释放锁
- 是否尝试加锁
- 是否响应中断
- 是否使用公平策略
- 是否拆分多个等待条件
这听起来更麻烦,但也意味着你获得了更多控制能力。
所以 Lock 不是“为了把简单事情写复杂”,而是:
当并发控制需求比 synchronized 更复杂时,给你更多工程化手段。
二、先看最基础写法:ReentrantLock 到底怎么用?
先别急着比较,先把最基本的使用方式看清楚。
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();
}
}
public int getCount() {
return count;
}
}
这个写法里最关键的是两件事:
1)先 lock() 再进关键区
这和 synchronized 的目标一致:
- 同一时刻只允许一个线程进入关键区
2)一定要在 finally 里 unlock()
这是使用显式锁最重要的习惯之一。
因为和 synchronized 不一样,Lock 不会帮你自动释放锁。
如果你中途异常退出,却忘了 unlock(),那其他线程可能就一直卡住。
所以入门阶段先把这条规则记死:
lock() 后面几乎总是立刻跟一个 try/finally。
三、什么叫可重入?为什么 ReentrantLock 这个名字里就带着它?
“可重入”这个词听起来有点抽象,但意思其实不难。
它指的是:
同一个线程如果已经拿到了某把锁,那么它再次请求这把锁时,不会被自己阻塞。
先看一个例子。
public class Demo {
public synchronized void methodA() {
methodB();
}
public synchronized void methodB() {
System.out.println("methodB 执行");
}
}
这里 methodA() 调用了 methodB(),而两个方法都加了 synchronized。
如果锁不可重入,会发生什么?
- 当前线程进入
methodA(),拿到锁 - 执行到
methodB()时,再次申请同一把锁 - 如果不允许重入,线程会把自己卡死
Java 显然不能这么设计。
所以无论是 synchronized,还是 ReentrantLock,本质上都支持可重入。
为什么可重入很重要?
因为真实代码里,方法调用经常是层层嵌套的:
- 一个同步方法调另一个同步方法
- 子类方法调用父类同步逻辑
- 业务逻辑拆成多个内部辅助方法
如果锁不能重入,很多正常的程序结构都会出问题。
所以“可重入”不是锦上添花,而是并发锁设计里的基础能力。
四、ReentrantLock 和 synchronized 有哪些共同点?
别一上来只盯差异,它们其实有很多共同点。
1)都能保证互斥访问
同一时刻,只有一个线程能进入关键区。
2)都支持可重入
同一个线程拿到锁之后,可以再次进入受同一把锁保护的代码。
3)都能用于保护共享资源
比如:
- 计数器
- 库存
- 订单状态
- 缓冲区
- 账户余额
4)都不是“越多越好”
只要涉及锁,就意味着:
- 有等待
- 有竞争
- 有可能影响吞吐
所以无论用哪一种,本质目标都还是:
用可接受的并发成本,换正确性和一致性。
五、那为什么还需要 ReentrantLock?因为它提供了 synchronized 不擅长的几种能力
这才是这篇最核心的部分。
1)可以尝试加锁,而不是无限死等
synchronized 的特点是:
- 要么拿到锁进去
- 要么拿不到就一直等
但 ReentrantLock 可以这样写:
if (lock.tryLock()) {
try {
// 拿到锁再执行
} finally {
lock.unlock();
}
} else {
System.out.println("没有拿到锁,先做别的事");
}
这在一些业务里非常实用。
比如:
- 不想让线程长时间阻塞
- 想快速失败、稍后重试
- 某些任务拿不到锁就直接放弃
这类需求用 synchronized 就不够灵活。
2)可以响应中断地等待锁
有时线程在等锁时,不应该傻等到底。
如果外部已经决定取消这个任务,希望线程别继续等,就需要“可中断加锁”。
lock.lockInterruptibly();
这表示:
- 如果锁可用,就获取锁
- 如果当前在等待锁时被中断,就停止等待并抛出异常
这种能力在:
- 任务取消
- 超时控制
- 线程池中的复杂任务协调
里非常实用。
而 synchronized 在等待进入监视器这件事上,控制力就没这么细。
3)可以指定公平锁或非公平锁
ReentrantLock 可以这样创建:
ReentrantLock fairLock = new ReentrantLock(true);
这里的 true 表示公平锁。
公平锁的大致含义是:
尽量按照等待时间先后顺序分配锁。
而非公平锁则更强调吞吐,允许“插队”现象更容易发生。
一般来说:
- 公平锁更讲秩序
- 非公平锁通常吞吐更高
synchronized 对这一层控制就没有这么显式。
4)可以配合多个 Condition 做更精细的线程协调
前一篇我们讲了 wait() / notify()。
但它们有个局限:
- 等待队列是基于对象监视器来管理的
- 同一把锁上的条件管理不够精细
而 ReentrantLock 可以配合 Condition,让不同线程等不同条件。
比如:
- 生产者等“队列未满”
- 消费者等“队列非空”
这会比单纯 notifyAll() 更清晰、更容易扩展。
这也是为什么很多稍复杂一点的并发协作场景,会逐步从 synchronized 过渡到 Lock + Condition。
六、公平锁和非公平锁到底怎么理解?
这是 ReentrantLock 里一个很容易被问到、但很多教程讲得太抽象的点。
1)公平锁:先来先服务
你可以把它想成排队买票。
- 谁先排队
- 谁先得到服务
这种策略的优点是:
- 更公平
- 不容易让某些线程长时间饿着
缺点是:
- 调度更保守
- 吞吐通常没那么高
2)非公平锁:谁抢到谁先上
这更像抢电梯。
虽然有人已经等了一会儿,但一个刚来的线程如果运气好,也可能先拿到锁。
优点是:
- 性能通常更好
- 吞吐更高
缺点是:
- 某些线程理论上可能等更久
工程上怎么选?
绝大多数普通业务里:
- 默认非公平锁就够了
只有在你明确关心:
- 等待顺序
- 线程饥饿
- 某些任务长期拿不到锁不合理
时,才需要认真评估公平锁。
别把公平锁当成“更高级的默认选项”。
它只是另一种取舍。
七、tryLock() 为什么很实用?它让你能把“阻塞”改成“决策”
这是 ReentrantLock 相比 synchronized 很有代表性的一点。
看一个简单例子:
public void doTask() {
if (lock.tryLock()) {
try {
System.out.println(Thread.currentThread().getName() + " 拿到锁,开始执行");
} finally {
lock.unlock();
}
} else {
System.out.println(Thread.currentThread().getName() + " 没拿到锁,稍后再试");
}
}
这段代码表达的意思是:
- 拿到锁就执行
- 拿不到锁就别堵着,先走别的逻辑
这个思路在很多真实场景里非常有价值,比如:
- 定时任务不希望重复执行
- 某个资源正在被占用时,当前请求直接返回“稍后再试”
- 某些后台清理任务拿不到锁就跳过本轮
所以 tryLock() 的意义不只是“多了一个 API”,而是:
你可以把“等锁”从被动阻塞,变成主动决策。
八、lockInterruptibly() 适合什么场景?
这个 API 很多初学者会略过去,但它非常体现显式锁的工程价值。
先看问题背景。
有些线程在等锁时,如果系统已经不需要它继续等了,就应该尽快退出。
例如:
- 用户取消了请求
- 上层任务已经超时
- 线程池准备关闭
- 当前线程只是候选任务,不值得一直等
这时如果你还让线程无限等待锁,就不太合理。
lockInterruptibly() 的价值就在于:
线程在等待锁的过程中,可以响应中断信号。
这意味着系统可以更主动地取消和清理任务,而不是让线程卡在那儿傻等。
对初学者来说,你先记住这句就够了:
lock()更适合“必须拿到锁再干”lockInterruptibly()更适合“等待过程中允许被取消”
九、synchronized 和 ReentrantLock 到底该怎么选?
这是最实用的问题。
我的建议很直接:
1)如果只是基础互斥,优先考虑 synchronized
比如:
- 保护一个计数器
- 保护一个共享集合的简单修改
- 某个实例方法的并发互斥
- 并发教学和基础练习
原因很简单:
- 语法短
- 可读性好
- 不容易忘记释放锁
- 足够稳定
2)如果你明确需要更灵活的控制,再考虑 ReentrantLock
比如你需要:
- 尝试获取锁
- 等锁时响应中断
- 指定公平策略
- 多个条件队列
- 更复杂的并发协调逻辑
这时 ReentrantLock 的优势才真正体现出来。
3)别把“更灵活”误解成“应该默认优先用”
有些人学完 ReentrantLock 后,会觉得它更专业,于是恨不得所有同步都改成显式锁。
这通常没必要。
因为它的灵活,伴随的是:
- 样板代码更多
- 忘记释放锁的风险更高
- 心智负担更重
所以最稳妥的原则通常是:
简单互斥用 synchronized,复杂控制用 ReentrantLock。
十、一个简单对比例子:两种写法长什么样?
用 synchronized
public class Counter {
private int count = 0;
public synchronized void increment() {
count++;
}
}
特点:
- 写法简洁
- 不容易出错
- 锁释放由 JVM 兜底
用 ReentrantLock
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();
}
}
}
特点:
- 更啰嗦一些
- 但后续扩展空间更大
- 可以进一步加
tryLock()、公平锁、Condition等能力
如果当前只是“保护 count++”,那前者通常就够了。
如果后面还要做复杂等待控制,后者会更有扩展性。
十一、初学者最容易踩的 8 个坑
1)lock() 之后忘记 unlock()
这是最经典也最危险的错误。
一旦忘记释放锁,其他线程可能长期阻塞。
2)不把 unlock() 放进 finally
如果中间抛异常,锁就可能泄漏。
3)把 ReentrantLock 当成“默认更高级所以必须用”
不是。
很多简单场景 synchronized 更合适。
4)对公平锁有误解
公平锁不是“更先进”,只是“更讲顺序”。
通常还会牺牲一部分吞吐。
5)看见 tryLock() 就到处用
如果业务本来就必须拿到锁,强行 tryLock() 反而会把逻辑搞绕。
6)忘了可重入的前提是“同一个线程”
可重入不是说所有线程都能重复进,而是当前持锁线程可以再次进入。
7)混淆“锁对象一致”和“代码位置接近”
无论是 synchronized 还是 ReentrantLock,真正产生互斥的前提都是:
- 多个线程竞争的是同一把锁
8)没搞清楚自己为什么不用 synchronized
如果你答不上来“为什么这里必须用 ReentrantLock”,那往往说明这里并不一定真的需要它。
十二、你应该怎么建立对显式锁的正确理解?
如果你现在正从 synchronized 过渡到 Lock,我建议按这个顺序理解。
第一步:先承认两者都能做互斥
别把 ReentrantLock 神化成完全不同的东西。
它首先也是锁。
第二步:再看它多出来的控制能力
重点盯住这几个问题:
- 能不能尝试获取锁
- 能不能响应中断
- 能不能指定公平策略
- 能不能拆分不同条件等待队列
这才是它的价值所在。
第三步:最后再决定是否值得引入
不是每段并发代码都值得用更复杂的工具。
工程里更重要的不是“会更多 API”,而是:
用最合适、最稳的方案解决当前问题。
十三、最后总结:ReentrantLock 的价值,不是取代 synchronized,而是给复杂并发场景更多操作空间
如果只用一句话总结这篇,我会说:
synchronized 更像默认内置锁,适合简单直接的互斥;ReentrantLock 更像显式并发工具,适合需要更细控制的场景。
你这篇真正应该带走的,是下面这几个核心认识:
1)ReentrantLock 和 synchronized 都支持互斥和可重入
所以它们不是完全割裂的两套世界。
2)可重入的意思,是同一个线程拿到锁后还能再次进入同一把锁保护的代码
这保证了嵌套调用不会把自己锁死。
3)ReentrantLock 的优势,在于它提供了更强的控制力
比如:
tryLock()lockInterruptibly()- 公平锁
- 后续配合
Condition
4)显式锁最重要的纪律,是 lock() 后一定配 try/finally
否则非常容易出事故。
5)选型时别追求“谁更高级”,而要看“谁更适合当前复杂度”
简单互斥用 synchronized,复杂控制再用 ReentrantLock,通常就是很稳的思路。
从这里开始,你对 Java 并发的理解,就从“会用内置锁”进一步走到了“会用显式锁做更细粒度控制”。
下一篇,最自然的继续方向就是:
Condition 到底是什么?为什么有了 wait() / notify(),Java 还要提供条件队列这套机制?