1. synchronized 在 Java 并发编程中的核心地位
作为 Java 语言中最基础的线程同步机制,synchronized 关键字从 JDK1.0 开始就承担着守护线程安全的重任。在 Java 内存模型(JMM)中,它同时保证可见性和原子性——这是许多开发者选择它的首要原因。我见过太多项目因为不当使用 synchronized 导致性能瓶颈,也见证过合理优化后吞吐量提升 3-5 倍的案例。
现代 JVM 中的 synchronized 早已不是简单的重量级锁。从 JDK6 开始引入的锁升级机制(偏向锁→轻量级锁→重量级锁),到 JDK15 的偏向锁延迟启用优化,每一次迭代都在降低这个关键字的性能开销。理解这些底层优化原理,是写出高性能并发代码的前提。
2. synchronized 的底层实现机制
2.1 对象头与 Mark Word 结构
每个 Java 对象头都包含关键的 Mark Word 字段,在 64 位 JVM 中的结构如下:
code复制|------------------------------------------------------------------|
| 锁状态 | 25bit | 31bit | 1bit |
|--------------|----------------|-------------------------|-------|
| 无锁 | unused | hashCode | 0 |
| 偏向锁 | threadId(54bit)| epoch(2bit) | 1 |
| 轻量级锁 | 指向栈中锁记录 | | 00 |
| 重量级锁 | 指向monitor对象| | 10 |
| GC标记 | | | 11 |
|------------------------------------------------------------------|
这个灵活的结构设计使得锁状态可以随竞争情况动态变化。通过 jol-core 工具打印对象头,可以直观看到锁状态变化:
java复制// 添加依赖:org.openjdk.jol:jol-core:0.16
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
2.2 Monitor 监视器模型
当升级到重量级锁时,对象会关联到一个 Monitor 对象(由 C++ 实现的 ObjectMonitor)。其核心结构包含:
cpp复制ObjectMonitor() {
_header = NULL; // 对象头
_count = 0; // 重入次数
_waiters = 0; // 等待线程数
_recursions = 0; // 锁重入次数
_object = NULL; // 关联的Java对象
_owner = NULL; // 持有锁的线程
_WaitSet = NULL; // 处于wait状态的线程
_cxq = NULL; // 竞争队列
_EntryList = NULL; // 等待锁block的线程
}
这个模型解释了为什么 synchronized 是"重量级"的——线程竞争需要操作系统的互斥量(mutex)支持,涉及用户态到内核态的切换。
3. 锁升级全流程解析
3.1 偏向锁优化原理
偏向锁的核心思想是"锁会倾向于第一个获取它的线程"。在没有其他线程竞争时,只需要在 Mark Word 中记录线程ID:
- 首次加锁时通过 CAS 操作将线程ID写入 Mark Word
- 后续同一线程进入时只需检查线程ID是否匹配
- 出现竞争时(其他线程尝试获取)开始撤销偏向锁
重要提示:JDK15 后偏向锁默认延迟 4s 启用(-XX:BiasedLockingStartupDelay=4000),因为多数短期对象不需要偏向锁优化。
3.2 轻量级锁(自旋锁)实现
当偏向锁被撤销,JVM 会尝试轻量级锁:
- 在当前线程栈帧中创建锁记录(Lock Record)
- 通过 CAS 将 Mark Word 复制到锁记录,并替换为指向锁记录的指针
- 成功则获取锁,失败则自旋重试(默认自旋10次)
轻量级锁的优化点在于:
- 避免操作系统层面的线程阻塞
- 适合锁持有时间短的场景(<1ms)
- 自旋消耗 CPU 但避免线程切换
3.3 重量级锁的触发条件
当出现以下情况时会升级为重量级锁:
- 自旋超过阈值(-XX:PreBlockSpin=10)
- 等待线程数超过 CPU 核数的一半
- 调用 wait() 方法时强制升级
此时会:
- 向操作系统申请互斥量
- 竞争失败的线程进入 _EntryList 排队
- 调用 wait() 的线程进入 _WaitSet
4. 关键优化技术与参数调优
4.1 锁消除(Lock Elision)
JIT 编译器通过逃逸分析,发现某些锁不可能被共享时,会直接消除锁操作。例如:
java复制public String concat(String s1, String s2) {
StringBuffer sb = new StringBuffer(); // 未逃逸出方法
sb.append(s1);
sb.append(s2);
return sb.toString();
}
可以通过 JVM 参数观察锁消除:
code复制-XX:+DoEscapeAnalysis -XX:+EliminateLocks
4.2 锁粗化(Lock Coarsening)
将相邻的同步块合并,减少锁的获取/释放次数。例如:
java复制// 优化前
for(int i=0; i<100; i++) {
synchronized(obj) {
doSomething();
}
}
// 优化后
synchronized(obj) {
for(int i=0; i<100; i++) {
doSomething();
}
}
4.3 关键 JVM 参数调优
| 参数 | 默认值 | 说明 |
|---|---|---|
| -XX:+UseBiasedLocking | true | 是否启用偏向锁 |
| -XX:BiasedLockingStartupDelay | 4000 | 偏向锁启用延迟(ms) |
| -XX:+SpinYield | true | 自旋时是否让出CPU |
| -XX:PreBlockSpin | 10 | 自旋次数阈值 |
| -XX:+DoEscapeAnalysis | true | 启用逃逸分析 |
5. 实战中的避坑指南
5.1 锁粒度的选择误区
常见反模式:
- 锁住整个方法(如 synchronized 修饰方法)
- 锁住大对象(如 synchronized(this))
- 不同业务共用同一把锁
优化方案:
java复制// 细粒度锁示例
private final Object orderLock = new Object();
private final Object userLock = new Object();
void updateOrder() {
synchronized(orderLock) { /*...*/ }
}
void updateUser() {
synchronized(userLock) { /*...*/ }
}
5.2 死锁检测与预防
通过 jstack 检测死锁:
bash复制jstack -l <pid> | grep -A 10 deadlock
预防策略:
- 按固定顺序获取多把锁
- 使用 tryLock() 设置超时
- 避免在同步块中调用外部方法
5.3 性能问题定位工具
- JVisualVM:监控线程阻塞情况
- Arthas:查看锁竞争热点
bash复制thread -b # 查看阻塞线程 monitor -c 5 java.lang.Object monitor # 监控锁竞争 - Async-profiler:分析锁自旋消耗
6. 与其他同步机制的对比
6.1 synchronized vs ReentrantLock
| 特性 | synchronized | ReentrantLock |
|---|---|---|
| 实现机制 | JVM 内置 | JDK 实现 |
| 锁获取 | 自动释放 | 必须手动 unlock() |
| 条件变量 | 一个 wait/notify | 多个 Condition |
| 公平锁 | 非公平 | 可配置 |
| 性能 | JDK6 后优化接近 | 高竞争时更优 |
6.2 适用场景建议
- 简单同步:优先用 synchronized(更简洁)
- 需要特性:选择 ReentrantLock(如可中断、超时、公平锁)
- 读多写少:考虑 ReadWriteLock
- 高性能场景:尝试 StampedLock
7. 最新 JDK 中的改进
JDK15 对偏向锁的重要变更:
- 默认延迟 4 秒启用(减少不必要的偏向/撤销开销)
- 新增诊断参数:
code复制-XX:+PrintBiasedLockingStatistics -XX:BiasedLockingBulkRevokeThreshold=20 - 对于明显不适合偏向锁的类(如 ConcurrentHashMap),JVM 会主动禁用其偏向锁
在微服务架构中,我通常建议:
- 短生命周期对象:禁用偏向锁(-XX:-UseBiasedLocking)
- 高竞争场景:直接使用重量级锁+线程池控制并发
