Lock 锁 | JavaSE
Lock 锁
一、学习目标
完成本章后,你应该能够:
- 理解
Lock与 Java 内置synchronized的关系。 - 理解
Lock是接口,掌握最常用实现类ReentrantLock。 - 独立使用
lock()与unlock()保护共享资源。 - 理解为什么
unlock()必须放在finally中。 - 理解锁对象为什么通常应该设计成
private final。 - 理解
ReentrantLock中“Reentrant”的含义。 - 了解
tryLock()、定时tryLock()、lockInterruptibly()等显式锁能力。 - 了解公平锁与非公平锁的基本概念。
- 能够比较
synchronized与ReentrantLock。 - 理解死锁的形成机制,并掌握最基本的避免原则。
二、核心知识
2.1 为什么已经有 synchronized,还需要 Lock
上一章已经可以:
synchronized (this) {
// 临界区
}
解决线程安全问题。
synchronized 最大的特点之一是:
使用简单
↓
获得锁由 JVM 管理
↓
退出同步区域时 JVM 自动释放锁
但是有些并发场景希望拥有更加灵活的控制能力:
能不能尝试一下,拿不到锁就不等?
能不能最多等待3秒?
能不能等待锁时响应线程中断?
能不能选择公平获取锁?
能不能拥有多个等待条件?
Java 在:
java.util.concurrent.locks
包中提供了显式锁框架。
其中最核心的是:
Lock
接口。
最常见实现:
ReentrantLock
2.2 Lock 是接口
导包:
import java.util.concurrent.locks.Lock;
Lock 表示:
一种显式的锁操作抽象。
常用方法包括:
| 方法 | 作用 |
| --------------------- | ---------------------- |
| lock() | 获得锁,必要时等待 |
| unlock() | 释放锁 |
| tryLock() | 尝试立即获得锁 |
| tryLock(time, unit) | 在一定时间内尝试获得锁 |
| lockInterruptibly() | 可响应中断地等待获得锁 |
| newCondition() | 创建与锁关联的条件对象 |
本课程基础阶段首先必须掌握:
lock()
unlock()
2.3 ReentrantLock
Lock 是接口,所以不能:
new Lock();
通常使用:
ReentrantLock
创建对象:
Lock lock =
new ReentrantLock();
完整导包:
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
2.4 ReentrantLock 的基本执行模型
代码:
lock.lock();
try {
// 临界区
} finally {
lock.unlock();
}
执行过程:
线程到达 lock.lock()
↓
尝试获得锁
↓
成功?
├── 是
│ ↓
│ 进入临界区
│ ↓
│ 执行业务
│ ↓
│ finally
│ ↓
│ unlock()
│
└── 否
↓
等待
这与:
synchronized (lockObject) {
}
都能够形成:
互斥访问
但是 Lock 的:
加锁
解锁
由程序员显式控制。
三、使用方法
3.1 使用 Lock 重写银行账户
原教学案例使用:
private final Lock lock =
new ReentrantLock();
账户:
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class Account {
private final String cardId;
private double money;
private final Lock lock =
new ReentrantLock();
public Account(
String cardId,
double money
) {
this.cardId = cardId;
this.money = money;
}
public void drawMoney(
double money
) {
String name =
Thread.currentThread()
.getName();
lock.lock();
try {
if (this.money >= money) {
System.out.println(
name
+ "取钱成功,取出"
+ money
+ "元"
);
this.money -= money;
System.out.println(
name
+ "取钱后余额:"
+ this.money
);
} else {
System.out.println(
name
+ "取钱失败,余额不足"
);
}
} finally {
lock.unlock();
}
}
public double getMoney() {
return money;
}
}
核心就是:
lock.lock();
try {
// 操作共享资源
} finally {
lock.unlock();
}
3.2 为什么锁对象要共享
和 synchronized 完全一样:
多个竞争线程
↓
必须竞争同一把 ReentrantLock
如果:
public void drawMoney() {
Lock lock =
new ReentrantLock();
lock.lock();
...
}
每个线程进入方法以后:
线程A → new ReentrantLock A
线程B → new ReentrantLock B
两个人根本没有竞争同一个锁。
所以不能实现:
互斥
正确做法通常是:
private final Lock lock =
new ReentrantLock();
让这个锁与需要保护的对象长期绑定。
3.3 为什么建议 final
代码:
private final Lock lock =
new ReentrantLock();
final 的意义是:
这个字段以后不能重新指向另一把锁
避免:
lock = new ReentrantLock();
这种危险操作。
假设:
线程A持有旧锁
此时程序把字段改成新锁:
线程B拿新锁
那么:
A和B又能同时进入
整个互斥设计就被破坏了。
因此:
用于保护固定共享状态的锁对象,通常应该保持稳定。
3.4 为什么 unlock() 必须放 finally
错误写法:
lock.lock();
if (money >= amount) {
money -= amount;
}
lock.unlock();
看起来正常。
但是如果临界区出现:
RuntimeException
程序可能直接跳过:
lock.unlock();
结果:
线程A已经持有lock
↓
业务异常
↓
没有unlock
↓
其他线程永远等锁
所以必须:
lock.lock();
try {
// 临界区
} finally {
lock.unlock();
}
无论:
正常执行
还是异常退出
finally 都负责释放锁。
这是使用显式 Lock 时必须形成的肌肉记忆:
lock
↓
try
↓
finally unlock
3.5 为什么不能把 lock() 也随便放进 try
最常见标准结构是:
lock.lock();
try {
// 临界区
} finally {
lock.unlock();
}
思想是:
已经成功获得锁以后,才进入负责释放它的 try/finally 区域。
尤其当使用一些可能失败或被中断的锁获取方法时,必须确保:
只有真正获得锁
↓
才去 unlock
否则可能出现:
当前线程根本没持有锁
↓
却执行 unlock()
这样的错误。
四、原理与进阶
4.1 ReentrantLock 为什么叫“可重入锁”
Reentrant:
Re-entrant
↓
可重新进入
表示:
同一个线程已经获得这把锁以后,可以再次获得同一把锁。
例如:
private final ReentrantLock lock =
new ReentrantLock();
public void methodA() {
lock.lock();
try {
methodB();
} finally {
lock.unlock();
}
}
public void methodB() {
lock.lock();
try {
System.out.println("B");
} finally {
lock.unlock();
}
}
线程:
T1
进入:
methodA
↓
获得lock
然后:
methodA
↓
调用methodB
↓
再次lock
不会因为:
“锁已经有人拿了”
而把自己阻塞。
因为:
拿锁的人就是T1自己
4.2 可重入不是“多拿几次都不用还”
虽然同一线程可以多次:
lock.lock();
但内部会维护:
持有次数
例如:
第一次 lock
持有次数 = 1
第二次 lock
持有次数 = 2
那么:
第一次 unlock
持有次数 = 1
锁仍然属于当前线程。
必须:
第二次 unlock
持有次数 = 0
才真正完全释放。
所以:
lock几次
原则上就要对应unlock几次
4.3 synchronized 其实也是可重入的
不要误认为:
只有ReentrantLock可重入
上一章已经知道:
synchronized
的 Monitor 也支持同一个线程重复获得。
所以:
ReentrantLock
这个名字强调了它的特性,但:
可重入
并不是它相对于 synchronized 的独占优势。
它的真正优势是:
显式锁控制
+
更多扩展能力
4.4 tryLock():拿不到就不等
普通:
lock.lock();
如果锁被别人持有:
当前线程等待
而:
lock.tryLock()
表示:
现在尝试一次获取锁。
返回:
boolean
例如:
if (lock.tryLock()) {
try {
System.out.println(
"获得锁,执行任务"
);
} finally {
lock.unlock();
}
} else {
System.out.println(
"锁正在被别人占用"
);
}
特点:
获得成功
→ true
当前拿不到
→ false
→ 立即返回
这比:
lock()
更加灵活。
4.5 定时 tryLock()
还可以:
lock.tryLock(
3,
TimeUnit.SECONDS
);
表示:
最多等待指定时间尝试获取锁。
例如:
boolean acquired = false;
try {
acquired =
lock.tryLock(
3,
TimeUnit.SECONDS
);
if (!acquired) {
System.out.println(
"3秒内没有获得锁"
);
return;
}
// 临界区
} catch (InterruptedException e) {
Thread.currentThread()
.interrupt();
} finally {
if (acquired) {
lock.unlock();
}
}
它比普通:
synchronized
提供了更加显式的:
超时控制
能力。
4.6 lockInterruptibly()
普通等待锁:
lock.lock();
一旦因为别人持锁而等待,程序希望能够:
响应中断
可以:
lock.lockInterruptibly();
例如:
try {
lock.lockInterruptibly();
try {
// 临界区
} finally {
lock.unlock();
}
} catch (InterruptedException e) {
Thread.currentThread()
.interrupt();
}
这样:
线程正在等待锁
↓
收到 interrupt
↓
可以抛出 InterruptedException
这对于:
任务取消
超时控制
避免无限等待
等复杂并发场景更灵活。
4.7 公平锁与非公平锁
默认:
new ReentrantLock()
相当于:
new ReentrantLock(false)
也就是默认:
非公平锁
还可以:
new ReentrantLock(true)
创建:
公平锁
4.8 什么叫公平
假设:
T1
T2
T3
T4
都在等锁。
公平锁会尽量:
优先照顾等待时间更长的线程
形成更接近:
先来先服务
的访问策略。
非公平锁则不保证:
等待最久的一定最先拿到
新来的线程有可能在恰当时机直接获得锁。
4.9 公平是不是一定更好
不是。
公平通常意味着:
调度与排队管理成本增加
可能降低整体吞吐量。
因此:
公平
不是:
高级版本
也不是:
永远应该开启
默认 ReentrantLock 使用非公平模式,就是因为很多场景更关注:
整体吞吐能力
而不是绝对的排队顺序。
同时:
锁的公平性也不等于整个操作系统线程调度一定公平。
五、Lock 与 synchronized 对比
5.1 共同点
两者都可以实现:
互斥访问共享资源
都可以保护:
临界区
都具有:
可重入
的基本能力。
正确使用时,都可以建立必要的线程间内存同步关系。
5.2 synchronized 的特点
synchronized (lock) {
}
优点:
语法简单
JVM自动管理释放
异常退出也自动解锁
不容易漏掉unlock
适合:
大多数简单、结构化互斥场景
5.3 ReentrantLock 的特点
lock.lock();
try {
} finally {
lock.unlock();
}
优点:
控制更加显式
支持tryLock
支持超时获取
支持可中断获取
支持公平策略
可以配合Condition
代价:
代码更复杂
必须自己正确释放
更容易因为使用错误产生Bug
5.4 对比表
| 对比项 | synchronized | ReentrantLock |
| ------------ | ---------------------------------- | --------------------- |
| 类型 | Java 语言内置同步机制 | Java 类库中的显式锁 |
| 加锁 | 自动进入 Monitor | lock() |
| 解锁 | JVM 自动 | unlock() |
| 异常退出 | 自动释放 | 必须 finally |
| 可重入 | 是 | 是 |
| 尝试获取 | 基础语法不直接提供 tryLock | 支持 |
| 超时获取 | 基础语法不直接提供 | 支持 |
| 可中断等待锁 | 基础 Monitor 获取不通过该 API 提供 | lockInterruptibly() |
| 公平策略 | 不提供此构造级策略 | ReentrantLock(true) |
| 使用复杂度 | 较低 | 较高 |
| 典型场景 | 常规互斥 | 需要高级锁控制 |
5.5 到底应该选哪个
如果只是:
一个共享对象
+
一个简单临界区
+
互斥访问
通常:
synchronized
已经非常合适。
如果明确需要:
尝试获取
超时
可中断等待
公平策略
Condition
再考虑:
ReentrantLock
不能形成:
ReentrantLock 比 synchronized 高级
所以所有代码都应该改成 ReentrantLock
这种错误思维。
技术选型的核心永远是:
根据需求选择最简单且正确的机制。
六、死锁
6.1 什么是死锁
死锁(Deadlock)是:
多个线程相互持有对方需要的资源,并且都等待对方释放,导致所有相关线程永久无法继续执行。
最经典模型:
线程A:
已经拥有 Lock1
↓
还需要 Lock2
线程B:
已经拥有 Lock2
↓
还需要 Lock1
于是:
A拿着Lock1
等B释放Lock2
B拿着Lock2
等A释放Lock1
形成循环:
A等待B
↑ ↓
B等待A
程序卡住。
6.2 死锁示例
public class DeadlockDemo {
private static final Object LOCK_A =
new Object();
private static final Object LOCK_B =
new Object();
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
synchronized (LOCK_A) {
System.out.println(
"线程1获得A"
);
synchronized (LOCK_B) {
System.out.println(
"线程1获得B"
);
}
}
});
Thread t2 = new Thread(() -> {
synchronized (LOCK_B) {
System.out.println(
"线程2获得B"
);
synchronized (LOCK_A) {
System.out.println(
"线程2获得A"
);
}
}
});
t1.start();
t2.start();
}
}
某种执行顺序:
t1获得A
↓
切换
t2获得B
↓
t2等待A
↓
t1继续
↓
t1等待B
于是:
t1等t2
t2等t1
都走不下去。
注意:
这段程序不保证每一次运行都一定复现死锁,因为线程调度具有不确定性。
但是它存在明确的死锁风险。
6.3 最基础的死锁避免原则:统一锁顺序
如果所有线程都按照:
先A
后B
获得多把锁:
synchronized (LOCK_A) {
synchronized (LOCK_B) {
}
}
而不是:
线程1:A → B
线程2:B → A
就可以破坏:
循环等待
条件。
因此工程中极其重要:
多个地方需要获取多把锁时,应建立并遵守统一的锁获取顺序。
6.4 不要让持锁范围无限扩张
例如:
lock.lock();
try {
// 数据库调用
// 网络请求
// 用户输入
// 复杂IO
// 调用大量未知代码
} finally {
lock.unlock();
}
可能导致:
锁长时间不释放
↓
其他线程大量等待
甚至与其他锁组合形成复杂死锁。
所以:
锁应该保护真正需要保护的共享状态,而不是把所有耗时操作全部塞进临界区。
6.5 tryLock 可以帮助设计“拿不到就退出”
ReentrantLock 的:
tryLock()
或:
tryLock(timeout, unit)
允许程序:
获得不到锁
↓
不永久等待
↓
主动失败、回退或重试
因此在一些高级死锁规避策略中:
tryLock + timeout
比无限等待更加灵活。
但必须明确:
tryLock()本身不会自动让一个设计错误的并发系统“永不死锁”。
真正首先需要的是:
清晰的锁设计
统一顺序
缩小锁范围
减少不必要的嵌套锁
七、常见问题
7.1 Lock 是类吗?
Lock 是接口。
最常用实现:
ReentrantLock
7.2 为什么不能 new Lock()?
因为:
Lock
是接口。
应该:
Lock lock =
new ReentrantLock();
7.3 为什么 Lock 字段建议 final?
为了保证:
保护同一份共享状态时
始终使用同一个锁对象
避免运行中被替换。
7.4 为什么 unlock 一定放 finally?
为了保证临界区无论:
正常结束
还是异常退出
都尽可能正确释放已经获得的锁。
7.5 ReentrantLock 比 synchronized 一定好吗?
不是。
synchronized 更简单,而且自动释放 Monitor。
只有确实需要:
tryLock
超时
中断响应
公平策略
Condition
等高级能力时,显式锁优势才更加明显。
7.6 ReentrantLock 是不是不可重入?
恰恰相反。
名字中的:
Reentrant
就是:
可重入
7.7 synchronized 也是可重入的吗?
是。
不要把“可重入”错误理解成 ReentrantLock 独有功能。
7.8 公平锁是不是绝对按照线程创建顺序执行?
不是。
公平策略关注的是:
发生锁竞争时的等待顺序倾向
并不能保证操作系统线程整体调度严格公平。
7.9 tryLock() 拿不到锁会怎样?
无参数:
tryLock()
会立即返回:
false
而不是一直等待。
7.10 lockInterruptibly() 有什么价值?
线程在等待锁时:
可以响应interrupt
从而支持更加灵活的:
任务取消
超时控制
7.11 什么是死锁?
简单口述:
A 拿着 B 要的锁,B 拿着 A 要的锁,两个人谁都不释放,最终互相永久等待。
7.12 使用一把锁就不会有死锁吗?
经典循环等待死锁通常涉及多种资源或多把锁。
但并发设计仍然需要关注:
等待关系
锁范围
外部阻塞操作
调用链
不能只通过“我用了锁”判断系统一定正确。
八、练习与验收
8.1 知识问答
Lock位于哪个包?Lock是类还是接口?- 最常用 Lock 实现类是什么?
lock()做什么?unlock()做什么?- 为什么锁对象通常应该使用
final? - 为什么
unlock()通常必须放在finally? - Reentrant 是什么意思?
- synchronized 是否同样可重入?
tryLock()与lock()有什么区别?- 定时
tryLock()解决什么需求? lockInterruptibly()解决什么需求?- 什么是公平锁?
- 默认 ReentrantLock 是公平锁还是非公平锁?
- 公平锁是不是一定性能更高?
- synchronized 与 ReentrantLock 有哪些共同点?
- ReentrantLock 相比 synchronized 提供哪些扩展能力?
- 什么是死锁?
- 锁顺序为什么可能导致死锁?
- 如何通过统一锁顺序降低死锁风险?
8.2 代码阅读
阅读:
private final Lock lock =
new ReentrantLock();
public void increment() {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
回答:
- 哪一部分是加锁?
- 哪一部分是临界区?
- 哪一部分释放锁?
- 为什么解锁在 finally 中?
- 多线程共享同一对象时是否共享同一个 lock?
阅读:
public void increment() {
Lock lock =
new ReentrantLock();
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
回答:
- 多个线程进入方法时使用的是不是同一把锁?
- 是否能够正确保护共享
count? - 应该怎样修改?
8.3 手写代码
任务一:银行取钱
将上一章:
synchronized
版本完全改写成:
private final Lock lock =
new ReentrantLock();
要求使用标准:
lock.lock();
try {
} finally {
lock.unlock();
}
结构。
任务二:银行存钱
账户余额:
1000
两个线程:
每个线程存10次
每次100元
最终余额必须符合业务预期。
要求使用:
ReentrantLock
保护共享余额。
任务三:tryLock
创建两个线程竞争一把锁。
要求:
if (lock.tryLock()) {
try {
// 执行任务
} finally {
lock.unlock();
}
} else {
System.out.println(
"没有获得锁,执行备用逻辑"
);
}
观察与 lock() 的区别。
8.4 Debug
错误代码:
lock.lock();
money -= 100;
lock.unlock();
指出:
如果
money -= 100前后存在异常风险,为什么这种写法不够健壮?
错误代码:
private Lock lock =
new ReentrantLock();
public void resetLock() {
lock = new ReentrantLock();
}
解释:
为什么保护固定共享状态的锁对象不应该随意替换?
错误代码:
lock.unlock();
lock.lock();
try {
// 临界区
} finally {
}
指出锁生命周期问题并修正。
8.5 死锁 Debug
存在:
Lock A
Lock B
线程1:
A → B
线程2:
B → A
要求:
- 画出死锁等待图。
- 说明为什么两个线程都不能继续。
- 改成统一:
A → B - 分析修改后为什么破坏了循环等待。
8.6 综合训练
设计“转账系统”。
账户:
Account A
Account B
需要完成:
A → B 转账
业务逻辑:
检查A余额
↓
扣减A余额
↓
增加B余额
回答:
-
为什么这个业务比单账户取钱更复杂?
-
操作涉及几个共享账户?
-
如果两个线程同时执行:
A → BB → A
会涉及怎样的多锁问题?
-
如何设计统一锁顺序?
-
为什么不能随意出现:
- 线程1:先锁A再锁B
- 线程2:先锁B再锁A
-
如果使用
tryLock(timeout),可以设计怎样的失败回退策略?
本题重点不是写完整银行系统,而是训练:
多资源
+
多把锁
+
锁顺序
+
死锁
的分析能力。
8.7 本章验收
闭卷手写:
private final Lock lock =
new ReentrantLock();
public void method() {
lock.lock();
try {
// 临界区
} finally {
lock.unlock();
}
}
并能够解释:
Lock是接口
ReentrantLock是常用实现
能够说清:
lock
unlock
tryLock
lockInterruptibly
各自的基本用途。
能够比较:
synchronized
vs
ReentrantLock
并且能够口述:
什么是死锁
为什么锁顺序冲突可能死锁
如何通过统一锁获取顺序降低风险
则本章达到基础掌握标准。
下一阶段进入:
线程池
ExecutorService
这意味着我们的关注点将从:
“怎么手动管理一条线程”
进一步升级为:
“怎样统一管理和复用大量线程”