Java并发编程深入解析:从synchronized到Lock
// 目录 · contents
1. 引言
在多核处理器成为标配的今天,并发编程已经是Java开发者的必备技能。Java从1.0版本就提供了synchronized关键字来解决线程同步问题,而在JDK
1.5中引入的java.util.concurrent(JUC)包则提供了更灵活、更强大的锁机制。
本文将从synchronized的底层实现原理出发,逐步深入到ReentrantLock、ReadWriteLock和StampedLock,帮助读者全面理解Java中的锁机制,并在实际开发中做出正确的选择。
graph TB
A[Java锁机制] --> B[内置锁 synchronized]
A --> C[显式锁 Lock]
B --> B1[对象锁]
B --> B2[类锁]
C --> C1[ReentrantLock]
C --> C2[ReadWriteLock]
C --> C3[StampedLock]
C1 --> C1a[公平锁]
C1 --> C1b[非公平锁]
C2 --> C2a[ReentrantReadWriteLock]
style A fill:#f9f,stroke:#333,stroke-width:2px
2. synchronized底层原理
2.1 Monitor机制
synchronized的实现依赖于JVM的Monitor(管程/监视器)机制。每个Java对象都可以关联一个Monitor对象,当线程执行synchronized代码块时,必须先获取该对象的Monitor。
stateDiagram-v2
[*] --> EntryList: 线程请求锁
EntryList --> Owner: 获取Monitor成功
Owner --> WaitSet: 调用wait()
WaitSet --> EntryList: 被notify()/notifyAll()唤醒
Owner --> [*]: 释放Monitor
EntryList --> EntryList: 竞争失败,继续等待
在HotSpot虚拟机中,Monitor由C++的ObjectMonitor实现,其核心数据结构包括:
- **_owner**: 指向持有Monitor的线程
- **_EntryList**: 等待获取锁的线程队列(阻塞队列)
- **_WaitSet**:
调用了
wait()方法后等待被唤醒的线程集合 - **_count**: 重入计数器
2.2 monitorenter与monitorexit
当synchronized修饰代码块时,编译后会生成monitorenter和monitorexit字节码指令。我们通过一个例子来查看:
1 | |
使用javap -c SyncDemo.class反编译后,关键字节码如下:
1 | |
注意编译器生成了两条monitorexit指令:一条在正常退出路径,另一条在异常处理路径,确保任何情况下Monitor都能被正确释放。
2.3 锁升级过程
JDK
1.6之后,HotSpot对synchronized做了大量优化,引入了偏向锁、轻量级锁和重量级锁的升级机制:
graph LR
A[无锁状态] -->|同一线程访问| B[偏向锁]
B -->|其他线程竞争| C[轻量级锁/CAS]
C -->|CAS自旋失败| D[重量级锁/Monitor]
style A fill:#90EE90
style B fill:#87CEEB
style C fill:#FFD700
style D fill:#FF6347
| 锁状态 | 适用场景 | 实现方式 | 性能开销 |
|---|---|---|---|
| 偏向锁 | 只有一个线程访问 | 对象头Mark Word记录线程ID | 极低 |
| 轻量级锁 | 多线程交替执行,无竞争 | CAS操作 + 自旋 | 低 |
| 重量级锁 | 多线程同时竞争 | 操作系统Mutex Lock | 高 |
1 | |
3. ReentrantLock详解
3.1 基本用法
ReentrantLock是JUC包中最核心的锁实现,相比synchronized提供了更丰富的功能:
1 | |
3.2 公平锁与非公平锁
1 | |
sequenceDiagram
participant T1 as 线程1
participant T2 as 线程2
participant T3 as 线程3
participant L as Lock
Note over L: 非公平锁场景
T1->>L: lock() 获取成功
T2->>L: lock() 进入等待队列
T1->>L: unlock() 释放锁
T3->>L: lock() 此时刚好请求锁
Note over T3,L: T3可能插队成功<br/>比T2先获取到锁
T3->>L: 获取成功(插队)
T3->>L: unlock()
T2->>L: 获取成功
3.3 Condition条件变量
Condition是synchronized中wait/notify机制的增强版,允许创建多个等待队列:
1 | |
4. ReadWriteLock读写锁
在读多写少的场景下,使用独占锁会严重影响性能。ReadWriteLock允许多个读线程同时访问,但写操作是排他的:
1 | |
5. StampedLock乐观读
JDK
8引入的StampedLock在ReadWriteLock的基础上增加了乐观读模式,在读多写少的场景下性能更优:
1 | |
6. 性能对比与选型建议
graph TB
subgraph 选型决策树
Q1{需要锁吗?} -->|是| Q2{读写比例?}
Q1 -->|否| A1[无锁方案<br/>CAS/Atomic]
Q2 -->|读多写少| Q3{能容忍乐观读失败重试?}
Q2 -->|读写均衡| Q4{需要tryLock/中断?}
Q2 -->|写多| Q4
Q3 -->|是| A2[StampedLock]
Q3 -->|否| A3[ReadWriteLock]
Q4 -->|是| A4[ReentrantLock]
Q4 -->|否| A5[synchronized]
end
style Q1 fill:#FFE4B5
style A1 fill:#90EE90
style A2 fill:#90EE90
style A3 fill:#90EE90
style A4 fill:#90EE90
style A5 fill:#90EE90
| 特性 | synchronized | ReentrantLock | ReadWriteLock | StampedLock |
|---|---|---|---|---|
| 可重入 | 是 | 是 | 是 | 否 |
| 公平性选择 | 否 | 是 | 是 | 否 |
| 可中断 | 否 | 是 | 是 | 是 |
| 超时获取 | 否 | 是 | 是 | 是 |
| 条件变量 | 1个(wait/notify) | 多个Condition | 多个Condition | 不支持 |
| 乐观读 | 否 | 否 | 否 | 是 |
| 自动释放 | 是 | 否(需手动) | 否(需手动) | 否(需手动) |
最佳实践
- 优先考虑synchronized:JDK 6之后性能已大幅优化,语法简洁且自动释放锁,能满足大多数场景。
- 需要高级特性时用ReentrantLock:可中断、可超时、公平锁、多Condition等场景。
- 读多写少用ReadWriteLock:典型场景如缓存、配置读取。
- 极致读性能用StampedLock:注意它不可重入且不支持Condition,需谨慎使用。
- 务必在finally中释放锁:Lock不像synchronized会自动释放,遗漏
unlock()会导致死锁。 - 避免在锁内执行耗时操作:减小临界区范围,降低锁竞争。
7. 总结
Java的锁机制从synchronized的简洁到JUC
Lock体系的灵活,为开发者提供了丰富的并发控制工具。理解每种锁的底层原理和适用场景,才能在实际项目中做出正确的选型:
synchronized经过JVM不断优化,在低竞争场景下性能优异,且使用最简单ReentrantLock提供了丰富的API,是需要高级锁特性时的首选ReadWriteLock通过读写分离提升了并发读的性能StampedLock的乐观读模式在读远多于写的场景下能获得最佳性能
选择合适的锁不仅要考虑功能需求,还要结合具体的并发场景、竞争程度以及代码维护成本综合判断。
踩坑记录
参与公司第一个高并发项目——双十一大促秒杀系统时,我们用
synchronized 锁住了库存扣减方法。压测到 500 并发时 TPS
急剧下降,GC 日志里 STW 时间飙到 800ms。排查后发现是
synchronized 的偏向锁在高竞争场景下频繁撤销,JVM
升级为重量级锁的开销远超预期。最终改用 ReentrantLock +
tryLock(timeout) 后,同等压力下 TPS 提升了 2.3 倍。
另一个坑是死锁排查。有一次线上服务卡死,jstack 看到 30
多个线程都处于 BLOCKED
状态,两个锁对象互相等待。根本原因是两个业务方法锁获取顺序不一致:方法 A
先锁 userLock 再锁 orderLock,方法 B 反过来。这种问题在 code review
很难发现,上线后才暴露。后来统一规定:多个锁必须按固定顺序获取,并写进开发规范文档。
实测结果
测试环境:4 核 8G,JDK 8,500 并发,秒杀场景压测
| 方案 | TPS | P99 延迟 | GC STW 均值 |
|---|---|---|---|
synchronized |
1,200 | 420ms | 650ms |
ReentrantLock |
2,760 | 180ms | 90ms |
结论:低竞争场景两者差距不大;高竞争短临界区场景下
ReentrantLock 的 tryLock
机制能避免线程阻塞挂起,优势非常明显。
我的看法
网上很多文章说 JDK 6 之后 synchronized 性能和
Lock
差不多了,但这个结论有前提:低竞争场景。在高竞争 +
短临界区的场景下,ReentrantLock 的优势依然显著。
我现在的选型原则是:先用
synchronized,遇到性能瓶颈或需要超时/中断/多 Condition
时再换 ReentrantLock。不要一上来就用
Lock,维护成本更高,忘记 finally 里 unlock
就是定时炸弹。