Java 的 synchronized 关键字 —— 从用法到底层原理全面解析
一、引言
synchronized 是 Java 中最基础、最常用的线程同步关键字。它从 JDK 1.0 时代就已存在,经过 JDK 6 的大规模优化后,性能已经大幅提升,在很多场景下甚至优于 java.util.concurrent.locks.Lock。本文将全面讲解 synchronized 的用法、底层实现、锁升级机制以及相关优化。
二、基本用法
synchronized 有三种使用形式,分别对应不同的锁对象。
1. 修饰实例方法
当 synchronized 修饰实例方法时,锁的是当前实例对象(this)。
public class Counter {
private int count = 0;
public synchronized void increment() {
count++;
}
public synchronized int getCount() {
return count;
}
}
同一时刻,同一个 Counter 实例的 increment() 和 getCount() 只能有一个线程执行。不同实例之间互不影响。
2. 修饰静态方法
当 synchronized 修饰静态方法时,锁的是当前类的 Class 对象。
public class StaticCounter {
private static int count = 0;
public static synchronized void increment() {
count++;
}
public static synchronized int getCount() {
return count;
}
}
静态方法的锁与实例方法的锁不是同一把锁,它们互不干扰。静态方法锁的是 StaticCounter.class,实例方法锁的是 this。
3. 修饰代码块
synchronized 代码块可以指定任意对象作为锁,粒度更灵活。
public class BlockCounter {
private int count = 0;
private final Object lock = new Object();
public void increment() {
// 只同步需要保护的代码段
synchronized (lock) {
count++;
}
}
public void doubleIncrement() {
synchronized (lock) {
count++;
count++;
}
}
}
代码块形式的优势:
- 缩小锁范围:只保护临界区,提升并发性能
- 灵活指定锁对象:可以针对不同数据使用不同锁
💡 最佳实践:
synchronized代码块优于修饰方法,因为锁的粒度更细。同时,锁对象建议声明为private final,避免外部引用修改。
三、可重入性(Reentrancy)
synchronized 是可重入锁(Reentrant Lock)。同一个线程在持有锁的情况下,可以再次获取同一把锁。
public class ReentrantExample {
public synchronized void outer() {
System.out.println("outer");
inner(); // 同一个线程直接进入
}
public synchronized void inner() {
System.out.println("inner");
}
public static void main(String[] args) {
new ReentrantExample().outer();
}
}
输出:
outer
inner
可重入的实现原理:每个锁关联一个持有线程和一个计数器。当同一个线程再次请求锁时,计数器 +1;退出同步块时计数器 -1;当计数器归零时,锁才真正释放。
可重入避免了死锁的经典场景——同一线程在已持锁的情况下调用自身或其他同步方法。
四、底层实现原理
1. JVM 层面的字节码指令
通过 javap -v 反编译 synchronized 代码块,可以看到 JVM 使用 monitorenter 和 monitorexit 两条指令。
// 源代码
public void demo() {
synchronized (this) {
System.out.println("hello");
}
}
反编译结果:
public void demo();
flags: ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
0: aload_0
1: dup
2: astore_1
3: monitorenter // 获取锁
4: getstatic #2 // System.out
7: ldc #3 // "hello"
9: invokevirtual #4 // println
12: aload_1
13: monitorexit // 释放锁
14: goto 22
17: astore_2
18: aload_1
19: monitorexit // 异常路径下也保证释放锁
20: aload_2
21: athrow
22: return
Exception table:
from to target type
4 14 17 any
关键点:
monitorenter:尝试获取对象头的 Monitor(监视器)所有权monitorexit:释放 Monitor- 异常处理:编译器会自动生成异常表,确保无论是否抛出异常,锁都会被释放
对于 synchronized 方法,字节码层面没有 monitorenter/monitorexit,而是在方法的 ACC_SYNCHRONIZED 标志位上标记。JVM 根据该标志隐式完成加锁/解锁。
2. 对象头(Object Header)与 Mark Word
Java 对象在堆内存中的布局包含三部分:
- 对象头(Header)
- 实例数据(Instance Data)
- 对齐填充(Padding)
对象头中的 Mark Word 是锁实现的核心。在 64 位 JVM 中,Mark Word 占 8 字节(64位),其结构随锁状态变化:
| 锁状态 | 56 bit | 1 bit | 4 bit | 1 bit (biased) | 2 bit (lock) |
|---|---|---|---|---|---|
| 无锁 | 哈希码 + 分代年龄 | 0 | 0 | 0 | 01 |
| 偏向锁 | 线程ID + 偏向时间戳 + 分代年龄 | 0 | 0 | 1 | 01 |
| 轻量级锁 | 指向栈中锁记录的指针 | 0 | 00 | ||
| 重量级锁 | 指向互斥量(Monitor)的指针 | 0 | 10 | ||
| GC 标记 | 11 |
3. Monitor 机制
每个 Java 对象都有一个关联的 Monitor(监视器)。在 HotSpot 虚拟机中,Monitor 由 ObjectMonitor 类实现(C++),核心数据结构:
ObjectMonitor {
_header // 对象头
_count // 锁计数器
_owner // 持有锁的线程
_WaitSet // 执行 wait() 的线程队列
_EntryList // 等待获取锁的线程队列(阻塞状态)
_Recursions // 重入次数
}
工作流程:
- 线程执行
monitorenter时,尝试将_owner设为当前线程 - 若
_owner == null,获取成功,_count = 1 - 若
_owner == 当前线程,_count++(可重入) - 若
_owner == 其他线程,线程进入_EntryList阻塞等待 - 执行
monitorexit时,_count--,减到 0 时释放锁,唤醒_EntryList中的线程
五、锁升级(锁膨胀)
JDK 6 之前,synchronized 是重量级锁(直接依赖操作系统的 Mutex Lock),性能较差。JDK 6 引入了锁升级机制,让锁可以根据竞争激烈程度动态演化。
偏向锁 → 轻量级锁 → 重量级锁(不可逆,只能升级不能降级)
注意:JDK 15 之后,偏向锁被默认禁用并在 JDK 21 中被正式移除。但理解其设计思路对理解锁优化仍有帮助。
1. 偏向锁(Biased Locking)
设计目标:消除线程无竞争时的同步开销。
思想:当一个线程多次获取同一把锁时,该锁"偏向"于该线程,后续无需 CAS 操作即可加锁/解锁。
工作流程:
- 线程首次获取锁时,CAS 将 Mark Word 中的线程 ID 设为自己
- 之后该线程再次进入同步块时,检查线程 ID 是否匹配,匹配则直接进入(无 CAS 开销)
- 其他线程尝试获取该锁时,偏向锁被撤销(Revoke),升级为轻量级锁
偏向锁撤销:
- 等待全局安全点(Safe Point)
- 暂停拥有偏向锁的线程
- 判断线程是否存活:若已死亡,将对象头恢复为无锁状态;若存活,升级为轻量级锁
- 恢复线程
2. 轻量级锁(Lightweight Locking)
设计目标:线程交替执行同步块,不存在真正竞争时,用 CAS 代替互斥量。
工作流程:
- 线程在执行同步块前,JVM 在当前线程的栈帧中创建一块空间(Lock Record),用于存储对象当前的 Mark Word 副本(Displaced Mark Word)
- 线程通过 CAS 尝试将对象头的 Mark Word 替换为指向 Lock Record 的指针
- CAS 成功 → 获得锁
- CAS 失败 → 检查 Mark Word 是否指向当前线程的栈帧
- 是 → 重入
- 否 → 存在竞争,锁膨胀为重量级锁
解锁:通过 CAS 将 Displaced Mark Word 替换回对象头。若失败,说明锁已膨胀,需要释放重量级锁。
3. 重量级锁(Heavyweight Locking)
当竞争加剧时,轻量级锁会膨胀为重量级锁。重量级锁依赖操作系统的 Mutex Lock 实现,线程获取锁失败时会进入阻塞状态,涉及用户态 → 内核态切换,开销最大。
4. 锁升级流程图
线程尝试获取锁
│
▼
┌─── 偏向锁是否可用? ───┐
│ │
▼ ▼
是 否
│ │
▼ ▼
检查线程 ID 是否一致 轻量级锁尝试 CAS
│ │
┌────┴────┐ ┌────┴────┐
│ 匹配 │ 不匹配 │ 成功 │ 失败
└────┬────┘ └────┬────┘
│ │
▼ ▼
获取成功 撤销偏向锁 CAS 重试(自旋)
│ ┌───────┘
│ │
│ ▼
│ 尝试轻量级锁 CAS
│ │
│ ┌────┴────┐
│ │ 成功 │ 失败
│ └────┬────┘
│ │
│ ▼
│ 获取成功 膨胀为重量级锁
│ │
│ ▼
│ 线程阻塞(内核态)
│ │
└──────────────────────┘
六、JDK 6+ 优化详解
JDK 6 对 synchronized 进行了里程碑式的优化,引入了多项技术。
1. 自旋锁(Spin Lock)
背景:线程阻塞唤醒涉及内核态切换,如果锁持有时间很短,让线程"原地等待"反而更高效。
原理:线程获取锁失败时,不立即阻塞,而是执行一段忙循环(自旋),尝试获取锁。
自适应自旋(Adaptive Spinning):JDK 6 优化为自适应自旋。JVM 根据前一次自旋等待时间、锁持有者状态动态调整自旋次数。如果上次自旋成功获取了锁,这次自旋次数会适度增加;如果很少成功,则减少甚至不自旋。
2. 锁消除(Lock Elimination)
JVM 通过**逃逸分析(Escape Analysis)**判断对象是否只在当前线程内使用。如果确定不会逃逸,JVM 会将 synchronized 代码块完全消除。
// 示例
public String concat(String a, String b) {
// StringBuffer 是线程安全的,但这里只在方法内使用
// JVM 可能会消除 synchronized 块
StringBuffer sb = new StringBuffer();
sb.append(a);
sb.append(b);
return sb.toString();
}
3. 锁粗化(Lock Coarsening)
如果 JVM 检测到一系列连续的加锁/解锁操作都针对同一对象,会将多个锁合并为一个更大范围的锁。
// 优化前
for (int i = 0; i < 100; i++) {
synchronized (lock) {
// 操作
}
}
// JVM 粗化为
synchronized (lock) {
for (int i = 0; i < 100; i++) {
// 操作
}
}
七、synchronized vs Lock
Java 1.5 引入了 java.util.concurrent.locks.Lock 接口(典型实现为 ReentrantLock)。两者对比如下:
| 对比维度 | synchronized |
Lock |
|---|---|---|
| 关键字/接口 | Java 关键字,JVM 内置 | 接口,基于 AQS 实现 |
| 锁获取/释放 | 自动,由 JVM 保证释放 | 手动,lock()/unlock(),必须在 finally 中释放 |
| 可中断 | ❌ 不可中断 | ✅ lockInterruptibly() 可响应中断 |
| 超时等待 | ❌ 不支持 | ✅ tryLock(timeout, unit) |
| 公平性 | 非公平 | 支持公平/非公平两模式 |
| 读写分离 | ❌ | ✅ ReentrantReadWriteLock / StampedLock |
| 条件等待 | Object.wait()/notify() |
Condition.await()/signal(),支持多个条件队列 |
| 性能(低竞争) | 极佳(偏向锁) | 一般 |
| 性能(高竞争) | 一般(重量级锁) | 良好(自定义队列) |
| 调试/诊断 | 困难,jstack 可见 |
较好,可配合 tryLock() 排查 |
选择建议
-
优先使用
synchronized除非你需要以下特性:- 可中断锁
- 超时等待
- 公平锁
- 读写锁
- 多个条件队列
-
使用
Lock的场景:- 高并发且需要精细控制
- 需要非阻塞尝试获取锁
- 读写分离场景
代码对比
// synchronized 版本
public class SynchronizedStack {
private final List<String> list = new ArrayList<>();
public synchronized void push(String s) {
list.add(s);
}
public synchronized String pop() {
if (list.isEmpty()) return null;
return list.remove(list.size() - 1);
}
}
// Lock 版本
public class LockStack {
private final List<String> list = new ArrayList<>();
private final Lock lock = new ReentrantLock();
public void push(String s) {
lock.lock();
try {
list.add(s);
} finally {
lock.unlock();
}
}
public String pop() {
lock.lock();
try {
return list.isEmpty() ? null : list.remove(list.size() - 1);
} finally {
lock.unlock();
}
}
}
八、常见问题与注意事项
1. 锁对象不能为 null
// ❌ 错误
private final Object lock = null;
synchronized (lock) { ... } // NullPointerException
2. 不要用 String 常量作为锁
// ❌ 危险:字符串常量会被 intern
private final String LOCK = "LOCK";
// 其他类也可能用这个字符串常量,导致意外锁竞争
3. 锁对象的可见性
锁对象应声明为 final,避免引用被修改后不同线程看到不同对象。
// ✅ 正确
private final Object lock = new Object();
// ❌ 错误
private Object lock = new Object();
public void resetLock() {
lock = new Object(); // 其他线程可能还在用旧锁
}
4. 不要锁住包装类型
// ❌ 危险:Integer 缓存池
private Integer count = 0;
public void increment() {
synchronized (count) { // -128~127 之间使用缓存对象
count++;
}
}
九、总结
| 特性 | 说明 |
|---|---|
| 本质 | JVM 层面的互斥同步原语 |
| 核心实现 | 基于对象头的 Mark Word + Monitor |
| 锁升级 | 偏向锁 → 轻量级锁 → 重量级锁(仅单向) |
| 可重入 | 同一线程可重复获取同一把锁 |
| 悲观锁 | 假设一定会发生冲突,阻塞其他线程 |
| JDK 6 优化 | 自旋锁、自适应自旋、锁消除、锁粗化、锁升级 |
| 适用场景 | 低竞争 -> 极佳;高竞争 -> 可用但需考虑 Lock |
synchronized 是 Java 并发编程的基石,理解它的原理对于写出正确的并发代码至关重要。从简单的用法到底层的锁升级机制,从 JVM 优化到与 Lock 的对比,掌握这些知识能帮助我们在实际开发中做出更优的选择。
参考资源
- 《深入理解 Java 虚拟机》—— 周志明
- 《Java 并发编程的艺术》—— 方腾飞
- OpenJDK HotSpot 源码
- Java Language Specification (JLS §17.1)