1. 从Java线程同步的痛点说起
第一次在多线程环境下操作共享变量时,我遇到了经典的"银行转账"问题:两个线程同时给同一个账户存款,结果余额竟然比预期少了一部分。这个经历让我意识到,在并发编程中,仅仅依靠代码逻辑的正确性是不够的,还需要考虑线程间的执行顺序问题。而synchronized关键字,就是Java为解决这类问题提供的最基础也最重要的同步机制。
2. synchronized的底层实现原理
2.1 对象头与Mark Word
每个Java对象在内存中存储时,都包含对象头信息。对象头由两部分组成:Mark Word和类型指针。Mark Word是实现synchronized锁的关键所在,它记录了对象的锁状态信息。在32位JVM中,Mark Word的结构如下:
| 锁状态 | 25bit | 4bit | 1bit(偏向锁) | 2bit(锁标志) |
|---|---|---|---|---|
| 无锁 | 对象的hashCode | 对象分代年龄 | 0 | 01 |
| 偏向锁 | 线程ID | Epoch | 1 | 01 |
| 轻量级锁 | 指向栈中锁记录的指针 | 00 | ||
| 重量级锁 | 指向互斥量(Monitor)的指针 | 10 | ||
| GC标记 | 空 | 11 |
2.2 Monitor机制
当使用synchronized修饰方法或代码块时,JVM会在编译后的字节码中插入monitorenter和monitorexit指令。这两个指令分别对应获取锁和释放锁的操作。每个对象都与一个Monitor相关联,Monitor包含以下关键字段:
- _owner:记录持有该Monitor的线程
- _EntryList:存放等待获取锁的线程
- _WaitSet:存放调用了wait()方法的线程
注意:Monitor的获取是互斥的,同一时刻只有一个线程能够持有Monitor。其他线程必须等待当前线程释放锁后才能尝试获取。
3. 锁升级的全过程解析
3.1 无锁状态
新创建的对象默认处于无锁状态。此时Mark Word中存储的是对象的hashCode、分代年龄等信息。锁标志位为01,偏向锁标志为0。
3.2 偏向锁阶段
当第一个线程访问同步块时,JVM会将对象头中的线程ID设置为当前线程ID,并将偏向锁标志置为1。此时对象进入偏向锁状态。偏向锁的核心思想是:如果只有一个线程访问同步块,就不需要进行实际的同步操作。
偏向锁的获取过程:
- 检查Mark Word中的线程ID是否指向当前线程
- 如果是,直接执行同步代码
- 如果不是,检查偏向锁标志是否为1
- 如果为1,尝试通过CAS操作将线程ID替换为当前线程ID
- 如果替换成功,执行同步代码
- 如果替换失败,说明存在竞争,开始撤销偏向锁
3.3 轻量级锁阶段
当第二个线程尝试获取锁时,偏向锁就会升级为轻量级锁。轻量级锁的实现依赖于线程栈中的锁记录(Lock Record)和CAS操作。
轻量级锁的获取过程:
- 在当前线程的栈帧中创建锁记录空间
- 将对象头中的Mark Word复制到锁记录中(Displaced Mark Word)
- 尝试通过CAS将对象头中的Mark Word替换为指向锁记录的指针
- 如果替换成功,当前线程获得锁
- 如果替换失败,检查对象头中的Mark Word是否指向当前线程的栈帧
- 如果是,直接执行同步代码
- 如果不是,说明存在竞争,锁会膨胀为重量级锁
3.4 重量级锁阶段
当多个线程竞争同一个锁时,轻量级锁就会膨胀为重量级锁。重量级锁依赖于操作系统的互斥量(Mutex)实现,线程的阻塞和唤醒需要从用户态切换到内核态,开销较大。
重量级锁的特点:
- 通过Monitor对象实现
- 未获取锁的线程会被阻塞,放入_EntryList
- 线程的阻塞和唤醒需要操作系统介入
- 适用于高并发场景
4. 锁升级的触发条件与性能影响
4.1 各阶段锁的适用场景
| 锁类型 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 偏向锁 | 只有一个线程访问同步块 | 几乎没有同步开销 | 存在锁撤销的开销 |
| 轻量级锁 | 多个线程交替执行,没有真正竞争 | 避免线程阻塞 | 自旋会消耗CPU |
| 重量级锁 | 多个线程同时竞争锁 | 避免CPU空转 | 线程阻塞唤醒开销大 |
4.2 锁升级的触发条件
偏向锁升级为轻量级锁:
- 当第二个线程尝试获取锁时
- 调用hashCode()方法(因为偏向锁的Mark Word中没有空间存储hashCode)
轻量级锁升级为重量级锁:
- 自旋超过一定次数(默认10次)
- 等待线程数超过CPU核心数的一半
4.3 锁降级
在特定情况下,JVM也会进行锁降级操作。例如在GC时,为了减少STW时间,可能会将重量级锁降级为轻量级锁。但锁降级不是JVM的常规行为,开发者不应依赖这种行为。
5. 实战中的锁优化建议
5.1 减少锁的持有时间
将不需要同步的代码移出同步块,尽量减少临界区的大小。例如:
// 不推荐的写法 public synchronized void process() { // 准备数据(不需要同步) prepareData(); // 处理数据(需要同步) handleData(); } // 推荐的写法 public void process() { // 准备数据(不需要同步) prepareData(); synchronized(this) { // 处理数据(需要同步) handleData(); } }5.2 减小锁的粒度
将一个大的锁拆分为多个小的锁,减少锁的竞争。例如ConcurrentHashMap就采用了分段锁的设计。
5.3 避免锁的滥用
不是所有共享数据都需要加锁。对于读多写少的场景,可以考虑使用读写锁(ReentrantReadWriteLock)或乐观锁(CAS)。
5.4 锁粗化
在循环内部加锁会导致频繁的锁获取和释放,此时JVM可能会进行锁粗化优化,将锁移到循环外部。但开发者不应依赖JVM的优化,最好在编码时就考虑这一点。
6. 常见问题排查与性能调优
6.1 如何判断锁竞争激烈?
- 使用JVisualVM或JConsole查看线程状态
- 检查BLOCKED状态的线程数量
- 使用JStack查看线程堆栈,分析锁的持有情况
6.2 锁竞争导致的性能问题
症状:
- CPU使用率不高但吞吐量低
- 大量线程处于BLOCKED状态
- 应用响应时间变长
解决方案:
- 减小锁粒度
- 使用读写锁替代独占锁
- 考虑使用无锁数据结构(如ConcurrentHashMap)
- 缩短临界区代码
6.3 死锁问题排查
死锁的四个必要条件:
- 互斥条件
- 请求与保持条件
- 不剥夺条件
- 循环等待条件
排查方法:
- 使用jstack -l 获取线程转储
- 查找"deadlock"关键词
- 分析线程等待的锁和持有的锁
7. JVM参数调优建议
7.1 偏向锁相关参数
- -XX:+UseBiasedLocking:启用偏向锁(JDK15后已废弃)
- -XX:BiasedLockingStartupDelay=0:JVM启动后立即启用偏向锁
7.2 自旋锁相关参数
- -XX:+UseSpinning:启用自旋锁(JDK6后默认开启)
- -XX:PreBlockSpin=10:自旋次数默认值
7.3 锁膨胀相关参数
- -XX:+DoEscapeAnalysis:启用逃逸分析(有助于锁消除)
- -XX:+EliminateLocks:启用锁消除优化
8. 从synchronized看Java并发演进
synchronized的优化历程反映了Java并发编程的发展趋势:
- JDK1.6之前:只有重量级锁,性能较差
- JDK1.6:引入偏向锁和轻量级锁,大幅提升单线程和低竞争场景性能
- JDK后续版本:不断优化锁的实现,减少内存开销和上下文切换
在实际开发中,理解这些底层原理能帮助我们:
- 更准确地诊断并发问题
- 编写更高效的并发代码
- 做出更合理的架构设计决策