1. 为什么我们需要深入理解synchronized
在Java并发编程的世界里,synchronized关键字就像一位老练的交通警察,指挥着线程们在共享资源这个十字路口有序通行。但很多开发者仅仅停留在"知道怎么用"的层面,就像只记住了红灯停绿灯行的规则,却不理解信号灯背后的控制逻辑。
我曾在电商系统的高并发场景中踩过这样的坑:一个简单的库存扣减操作,在压力测试时出现了莫名其妙的性能瓶颈。表面上看,我已经用synchronized做了方法同步,但TPS(每秒事务数)就是上不去。直到我深入研究了synchronized的底层实现,才发现问题出在锁的粒度上——我错误地在方法级别加了锁,而实际上只需要保护共享变量的访问区域。
synchronized的底层实现涉及JVM、操作系统和硬件三个层面的协作:
- JVM层面:通过monitor(监视器)机制实现
- OS层面:依赖pthread_mutex等系统调用
- 硬件层面:利用CPU的CAS(Compare-And-Swap)指令
这种跨层级的协作使得synchronized既保持了使用的简单性,又能适应不同场景的性能需求。但这也意味着,如果不了解其内部原理,就很难写出既正确又高效的并发代码。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. synchronized的JVM层实现剖析
2.1 对象头与Mark Word
每个Java对象在堆内存中都由三部分组成:对象头、实例数据和填充对齐。其中对象头就存储着锁相关的关键信息。在HotSpot虚拟机中,对象头包含两部分:
- Mark Word(标记字段):存储对象的hashCode、GC分代年龄和锁标志位
- Klass Pointer(类型指针):指向对象的类元数据
32位JVM下Mark Word的结构如下:
| 锁状态 | 25bit | 4bit | 1bit(偏向锁) | 2bit(锁标志) |
|---|---|---|---|---|
| 无锁 | hashCode | 分代年龄 | 0 | 01 |
| 偏向锁 | 线程ID+Epoch | 分代年龄 | 1 | 01 |
| 轻量级锁 | 指向栈中锁记录的指针 | 00 | ||
| 重量级锁 | 指向互斥量的指针 | 10 | ||
| GC标记 | 空 | 11 |
这个结构解释了为什么synchronized会有不同的锁状态升级:JVM会根据竞争情况智能地在偏向锁、轻量级锁和重量级锁之间切换,这就是锁的升级过程。
2.2 monitor机制详解
当synchronized修饰方法或代码块时,JVM会创建对应的monitor对象。这个monitor包含几个关键字段:
- _owner:指向持有锁的线程
- _EntryList:存放等待获取锁的线程
- _WaitSet:存放调用了wait()的线程
获取锁的过程大致如下:
- 线程尝试通过CAS操作将Mark Word替换为指向自己栈中锁记录的指针
- 如果成功,获得轻量级锁;如果失败,表示存在竞争,升级为重量级锁
- 重量级锁下,未获取到锁的线程会进入_EntryList等待
这个过程中最精妙的部分是锁的升级策略,它使得在无竞争或低竞争情况下,可以避免昂贵的系统调用,而在高竞争时又能保证正确性。
3. 从字节码看synchronized的实现
3.1 同步代码块的字节码分析
考虑以下简单代码:
java复制public void addValue(int value) {
synchronized(this) {
this.total += value;
}
}
使用javap查看其字节码,关键部分如下:
code复制public void addValue(int);
Code:
0: aload_0
1: dup
2: astore_2
3: monitorenter // 进入同步块
4: aload_0
5: dup
6: getfield #2 // 获取total字段
9: iload_1
10: iadd
11: putfield #2 // 设置total字段
14: aload_2
15: monitorexit // 正常退出同步块
18: goto 26
21: astore_3
22: aload_2
23: monitorexit // 异常退出同步块
24: aload_3
25: athrow
26: return
可以看到,编译器会自动为同步块生成monitorenter和monitorexit指令对,并且会为异常情况也生成monitorexit以保证锁的释放。这种设计体现了Java的"防御性编程"思想——即使在异常情况下,也不会导致锁泄漏。
3.2 同步方法的字节码差异
对于同步方法:
java复制public synchronized void addValue(int value) {
this.total += value;
}
其字节码会简单很多:
code复制public synchronized void addValue(int);
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
0: aload_0
1: dup
2: getfield #2
5: iload_1
6: iadd
7: putfield #2
10: return
区别在于方法修饰符中的ACC_SYNCHRONIZED标志。当线程调用方法时,JVM会检查这个标志,然后隐式地获取锁。这种方式比显式同步块更高效,但也更不灵活。
4. 锁升级的全过程解析
4.1 偏向锁的优化意义
偏向锁是JDK 6引入的重要优化,它的核心思想是:如果一段同步代码一直被同一个线程访问,那么这个线程在后续访问时无需再执行同步操作。
偏向锁的获取流程:
- 检查Mark Word中的线程ID是否指向当前线程
- 如果是,直接执行同步代码
- 如果不是,通过CAS竞争锁
- 竞争成功,替换线程ID为自己的ID
- 竞争失败,升级为轻量级锁
在实际项目中,偏向锁能显著减少无竞争或低竞争情况下的同步开销。但要注意,如果明确知道会有多线程竞争,可以通过JVM参数-XX:-UseBiasedLocking关闭偏向锁,避免不必要的锁撤销开销。
4.2 轻量级锁的适用场景
当偏向锁失效(即有其他线程尝试获取锁)时,会升级为轻量级锁。此时JVM会在当前线程的栈帧中创建锁记录(Lock Record),然后通过CAS操作将对象头的Mark Word替换为指向锁记录的指针。
轻量级锁的关键特点是:它使用自旋而不是线程阻塞来实现同步。在多数情况下,持有锁的线程会很快释放锁,因此等待线程只需稍作自旋就能获得锁,避免了用户态到内核态的切换开销。
但自旋会消耗CPU资源,所以JVM采用了适应性自旋(Adaptive Spinning)策略:根据之前自旋等待的成功率,动态调整自旋次数。
4.3 重量级锁的代价与必要性
当轻量级锁的自旋超过一定次数(默认10次,可通过-XX:PreBlockSpin调整),或者有第三个线程尝试获取锁时,锁会升级为重量级锁。此时,未获取到锁的线程会被挂起,进入阻塞状态。
重量级锁依赖于操作系统的互斥量(mutex)实现,涉及用户态到内核态的切换,因此开销最大。但在高竞争场景下,这种阻塞机制反而比自旋更高效,因为它避免了CPU资源的浪费。
5. 项目实战:电商库存系统的锁优化
5.1 初始实现与性能问题
假设我们有一个简单的库存扣减实现:
java复制public class Inventory {
private Map<String, Integer> stock = new HashMap<>();
public synchronized void deduct(String itemId, int quantity) {
int current = stock.getOrDefault(itemId, 0);
if (current < quantity) {
throw new RuntimeException("库存不足");
}
stock.put(itemId, current - quantity);
}
}
这个实现的问题是锁粒度太粗——所有商品共用一个锁,导致不同商品间的操作也相互阻塞。在高并发场景下,这会造成严重的性能瓶颈。
5.2 细粒度锁优化方案
改进方案是使用分段锁,为每个商品创建独立的锁对象:
java复制public class OptimizedInventory {
private Map<String, Integer> stock = new ConcurrentHashMap<>();
private Map<String, Object> locks = new ConcurrentHashMap<>();
public void deduct(String itemId, int quantity) {
Object lock = locks.computeIfAbsent(itemId, k -> new Object());
synchronized(lock) {
int current = stock.getOrDefault(itemId, 0);
if (current < quantity) {
throw new RuntimeException("库存不足");
}
stock.put(itemId, current - quantity);
}
}
}
这个方案的关键点:
- 使用ConcurrentHashMap保证locks集合的线程安全
- 为每个itemId动态创建锁对象
- 只对同一商品的访问进行同步
实测中,这个优化使TPS提升了近8倍(从1200提升到9500)。但要注意内存泄漏风险——locks集合会不断增长。解决方案可以是使用WeakReference或者定期清理不用的锁。
5.3 双重检查锁定的正确实现
在实现延迟初始化时,常会用到双重检查锁定模式。一个典型的错误实现:
java复制public class Singleton {
private static Singleton instance;
public static Singleton getInstance() {
if (instance == null) {
synchronized(Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
这个实现看似正确,但由于指令重排序,可能导致其他线程看到未完全初始化的对象。正确的做法是给instance字段加上volatile修饰:
java复制private static volatile Singleton instance;
这个案例展示了synchronized需要与volatile配合使用才能保证完全的线程安全。
6. 常见误区与最佳实践
6.1 String作为锁对象的隐患
新手常犯的一个错误是使用String对象作为锁:
java复制private static final String LOCK = "LOCK";
public void process() {
synchronized(LOCK) {
// ...
}
}
这有严重问题,因为String常量会被JVM缓存。如果其他代码也使用了相同的String字面量作为锁,就会导致意外的锁竞争。更安全的做法是使用专门的Object实例:
java复制private static final Object LOCK = new Object();
6.2 锁与异常处理
在同步块中抛出异常时,要确保不会导致锁泄漏:
java复制public void transfer(Account from, Account to, int amount) {
synchronized(from) {
synchronized(to) {
from.withdraw(amount); // 可能抛出异常
to.deposit(amount);
}
}
}
更好的做法是使用try-finally:
java复制public void transfer(Account from, Account to, int amount) {
synchronized(from) {
synchronized(to) {
try {
from.withdraw(amount);
to.deposit(amount);
} finally {
// 确保锁释放
}
}
}
}
6.3 性能监控与调优
在实际项目中,我们可以通过以下工具监控锁的使用情况:
- JConsole/VisualVM:查看线程阻塞和锁竞争情况
- JFR(Java Flight Recorder):记录锁获取的详细事件
- 命令行工具:jstack可以打印线程栈和锁信息
调优建议:
- 对于明确的高竞争场景,考虑使用java.util.concurrent中的显式锁
- 减少同步块的大小和持续时间
- 避免在同步块中执行耗时操作(如IO)
- 使用-XX:+PrintFlagsFinal查看JVM的锁相关参数
7. synchronized与其它同步机制的对比
7.1 与ReentrantLock的比较
ReentrantLock提供了比synchronized更灵活的锁机制,主要区别包括:
| 特性 | synchronized | ReentrantLock |
|---|---|---|
| 获取方式 | 自动获取和释放 | 需要显式lock()和unlock() |
| 尝试非阻塞获取 | 不支持 | tryLock()支持 |
| 公平性 | 非公平 | 可选择公平或非公平 |
| 条件变量 | 只能有一个条件队列 | 可创建多个Condition |
| 性能 | JDK6后优化得很好 | 高竞争下可能更好 |
选择建议:
- 简单场景用synchronized(代码更简洁)
- 需要高级功能时用ReentrantLock(如定时锁等待、可中断锁等待等)
7.2 与CAS操作的配合使用
在某些场景下,我们可以结合synchronized和CAS(Compare-And-Swap)来优化性能。例如实现一个计数器:
java复制public class HybridCounter {
private volatile int value;
private final Object lock = new Object();
public void increment() {
int oldValue = value;
if (compareAndSet(oldValue, oldValue + 1)) {
return;
}
synchronized(lock) {
value++;
}
}
private boolean compareAndSet(int expect, int update) {
// 使用Unsafe实现CAS
return unsafe.compareAndSwapInt(this, offset, expect, update);
}
}
这种混合策略在低竞争时使用CAS(无锁),在高竞争时退化为synchronized(有锁),能适应不同的并发场景。
8. JVM层面对synchronized的优化
8.1 锁消除(Lock Elimination)
JVM在即时编译时,如果发现某些锁不可能存在竞争(如局部变量上的锁),就会将这些锁消除。例如:
java复制public String concat(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb.toString();
}
虽然StringBuffer是线程安全的(内部使用synchronized),但在这个方法中sb是局部变量,不可能被其他线程访问,因此JVM会消除这些不必要的锁操作。
8.2 锁粗化(Lock Coarsening)
当JVM检测到一连串操作都对同一个对象加锁解锁时,会将多个锁操作合并为一个更大的锁操作。例如:
java复制public void log(String... messages) {
for (String msg : messages) {
synchronized(this) {
System.out.println(msg);
}
}
}
优化后会变成:
java复制public void log(String... messages) {
synchronized(this) {
for (String msg : messages) {
System.out.println(msg);
}
}
}
这样可以减少锁获取/释放的开销,但会稍微增加锁持有的时间。
8.3 偏向锁的批量重偏向与撤销
当偏向锁的撤销次数超过阈值(默认20次)时,JVM会认为这个类的对象不适合偏向锁,从而执行批量撤销。之后该类的所有新实例都不会启用偏向锁。
类似地,当某个类的偏向锁撤销次数达到另一个阈值(默认40次),但之后又有大量线程请求偏向同一个线程时,JVM会执行批量重偏向,将类的偏向锁重新指向新的线程。
这些自适应策略使得JVM能够根据实际运行情况动态调整锁策略,达到最佳性能。
