1. 为什么我们需要关注synchronized优化?
在Java并发编程的世界里,synchronized关键字就像是一把万能钥匙,从Java诞生之初就存在。但很多开发者对它存在误解——认为它只是个简单的"重量级锁"。实际上,经过二十多年的演进,synchronized已经发展出一套精妙的优化体系。
我曾在生产环境遇到过这样一个案例:一个看似简单的用户积分服务,在QPS达到2000时突然性能暴跌。通过JProfiler分析发现,95%的线程都阻塞在synchronized代码块上。但奇怪的是,这个代码块平均执行时间只有2ms,理论上不应该成为瓶颈。这正是synchronized优化机制没有正确发挥作用导致的。
1.1 从对象头说起的内存布局
每个Java对象在内存中都由三部分组成:对象头、实例数据和对齐填充。其中对象头(Mark Word)是synchronized实现的关键所在。在64位JVM中,Mark Word的结构如下:
code复制|------------------------------------------------------------------|
| Mark Word (64 bits) | State |
|-------------------------------------------------------|-----------|
| unused:25 | identity_hashcode:31 | unused:1 | age:4 | biased_lock:1 | lock:2 | Normal |
|-------------------------------------------------------|-----------|
| thread:54 | epoch:2 | unused:1 | age:4 | biased_lock:1 | lock:2 | Biased |
|-------------------------------------------------------|-----------|
| ptr_to_lock_record:62 | lock:2 | Lightweight Locked |
|-------------------------------------------------------|-----------|
| ptr_to_heavyweight_monitor:62 | lock:2 | Heavyweight Locked |
|-------------------------------------------------------|-----------|
| | lock:2 | Marked for GC |
|------------------------------------------------------------------|
这个结构揭示了synchronized的优化基础——锁的状态就藏在对象头的几个bit中。JVM正是通过动态修改这些状态位来实现锁优化的。
关键点:对象头在不同锁状态下会存储不同内容。比如偏向锁时存储线程ID,轻量级锁时存储栈帧中的锁记录指针。
1.2 锁的四种状态转换路径
synchronized锁有明确的升级路径:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁。这个转换是不可逆的,就像单行道一样只能向上升级:
- 初始状态:对象刚创建时处于无锁状态
- 偏向锁:当第一个线程访问时,JVM会启用偏向模式
- 轻量级锁:当第二个线程尝试获取锁时,偏向锁升级为轻量级锁
- 重量级锁:当多个线程竞争激烈时,最终会升级为重量级锁
这个升级过程不是随机的,而是有严格的判断条件。比如从偏向锁升级到轻量级锁需要满足:
- 当前持有偏向锁的线程已经释放锁
- 有另一个线程尝试获取这个锁
- 没有发生批量撤销偏向锁的操作
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 偏向锁:单线程下的极致优化
偏向锁是JDK 6引入的重要优化,它的核心思想是"偏向第一个访问的线程"。这种优化对于绝大多数实际场景非常有效——统计显示,超过80%的锁在整个生命周期内都只会被单个线程访问。
2.1 偏向锁的工作机制
当锁对象第一次被线程获取时,JVM会在对象头中记录这个线程的ID,并将偏向模式标志位设为1。之后这个线程再进入同步块时,只需要简单检查:
- 对象头中的线程ID是否指向当前线程
- 偏向锁标志位是否为1
如果都满足,线程可以直接执行同步代码,完全不需要任何原子操作。这种优化使得单线程情况下的同步开销几乎为零。
java复制// 示例:偏向锁生效的典型场景
public class BiasLockExample {
private static final Object lock = new Object();
public static void main(String[] args) {
synchronized(lock) {
// 第一次进入:启用偏向模式
System.out.println("First enter");
}
// 第二次进入:直接检查线程ID
synchronized(lock) {
System.out.println("Second enter");
}
}
}
2.2 偏向锁的延迟启用问题
JVM启动时有个默认延迟(约4秒)才会启用偏向锁。这是因为JVM启动过程中会加载大量类,这些类的初始化过程往往涉及并发操作。如果立即启用偏向锁,可能导致大量偏向锁撤销操作,反而降低性能。
我们可以通过JVM参数调整这个行为:
code复制-XX:BiasedLockingStartupDelay=0 // 立即启用偏向锁
但生产环境不建议修改,除非你非常清楚自己在做什么。我曾经在一个高频交易系统中设置了这个参数,结果导致启动时间延长了15%,因为大量类初始化时的锁竞争触发了频繁的偏向锁撤销。
2.3 批量重偏向与批量撤销
当某个类的偏向锁被撤销超过一定阈值(默认20次),JVM会认为这个类的对象不适合使用偏向锁,会触发批量撤销。之后这个类新创建的对象都不会启用偏向锁。
类似地,如果一个类的偏向锁被大量不同线程访问(但竞争不激烈),JVM会触发批量重偏向,将偏向锁重新指向新的线程。
这两个机制使得偏向锁能够自适应程序的运行特征。我们可以通过以下参数调整阈值:
code复制-XX:BiasedLockingBulkRevokeThreshold=40 // 提高批量撤销阈值
-XX:BiasedLockingBulkRebiasThreshold=30 // 调整批量重偏向阈值
3. 轻量级锁:多线程交替执行的优化
当两个线程交替访问同步块(而不是同时竞争)时,偏向锁会升级为轻量级锁。轻量级锁的核心思想是"通过CAS操作避免OS层面的线程阻塞"。
3.1 轻量级锁的加锁过程
- 在当前线程的栈帧中创建锁记录(Lock Record)空间
- 将对象头中的Mark Word复制到锁记录中(称为Displaced Mark Word)
- 使用CAS操作尝试将对象头中的指针替换为指向锁记录的指针
- 如果成功,当前线程获得锁;如果失败,表示存在竞争,开始自旋
这个过程中最关键的步骤是CAS操作。在x86架构下,它对应的是lock cmpxchg指令,这是一个原子性的比较交换操作。
java复制// 伪代码展示轻量级锁CAS操作
bool tryLightweightLock(Object obj) {
markWord = obj.mark;
lockRecord = createLockRecordInStack();
lockRecord.displacedHeader = markWord;
if (CAS(obj.mark, markWord, pointerTo(lockRecord))) {
return true; // 获取轻量级锁成功
}
return false;
}
3.2 自旋优化的实践考量
当轻量级锁获取失败时,线程不会立即阻塞,而是会进行自旋(忙等待)尝试再次获取锁。JVM采用了适应性自旋策略:
- 自旋次数不是固定的,而是根据前一次自旋是否成功来动态调整
- 如果上次自旋成功获取了锁,这次会增加自旋次数
- 如果上次自旋失败,这次会减少自旋次数
我们可以通过JVM参数控制自旋行为:
code复制-XX:PreBlockSpin=10 // 设置最大自旋次数(JDK6之前有效)
在JDK6之后,这个参数已经失效,因为引入了更智能的适应性自旋算法。在我的性能调优经验中,除非在非常特定的硬件环境下,否则不建议调整自旋参数。
3.3 锁膨胀的临界点
轻量级锁会在以下情况下膨胀为重量级锁:
- 自旋超过一定次数仍未获取锁(适应性自旋失败)
- 等待锁的线程数超过CPU核心数的一半
- 持有锁的线程调用了wait()方法
重量级锁会触发操作系统的线程调度,导致上下文切换,这是非常昂贵的操作。因此,在编写同步代码时,我们应该尽量避免锁膨胀。
实战技巧:使用
jstack工具可以查看线程的锁状态。在堆栈跟踪中,轻量级锁会显示为"waiting to lock <0x000000076bf62200> (a java.lang.Object)",而重量级锁会显示"parking to wait for <0x000000076bf62200>"。
4. 重量级锁与锁消除优化
当锁竞争激烈时,最终都会升级为重量级锁。重量级锁依赖于操作系统提供的互斥量(mutex)实现,涉及用户态到内核态的切换,性能开销最大。
4.1 重量级锁的实现细节
在重量级锁状态下,对象头中的指针指向一个Monitor对象(也称为管程)。这个Monitor包含以下关键字段:
- _owner:指向持有锁的线程
- _EntryList:存储等待获取锁的线程
- _WaitSet:存储调用了wait()的线程
当线程尝试获取重量级锁时:
- 通过CAS操作尝试将_owner设置为当前线程
- 如果失败,进入_EntryList等待
- 获取锁的线程执行完毕后,会唤醒_EntryList中的线程
这个过程中最耗时的部分是线程的阻塞和唤醒,因为需要操作系统介入。在我的性能测试中,一次完整的锁争用(包括阻塞和唤醒)大约需要1-5微秒,而轻量级锁的CAS操作只需要几十纳秒。
4.2 锁消除(Lock Elision)优化
JIT编译器在某些情况下会直接消除同步操作,即使代码中显式使用了synchronized。这种优化称为锁消除,主要发生在两种场景:
- 对象逃逸分析证明锁对象不会共享:如果JVM确定某个对象不会逃逸出当前线程,就会消除这个对象的锁。
java复制// 锁消除的典型例子
public String concatString(String s1, String s2, String s3) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
sb.append(s3);
return sb.toString();
}
在这个例子中,StringBuffer是方法内的局部变量,不会逃逸出当前线程,因此所有的同步操作都会被消除。
- 锁对象是已知的不可变对象:比如锁对象是某个类的final字段,且这个字段指向一个不可变对象。
锁消除可以通过JVM参数控制:
code复制-XX:+DoEscapeAnalysis // 开启逃逸分析(默认开启)
-XX:+EliminateLocks // 开启锁消除(默认开启)
4.3 锁粗化(Lock Coarsening)
另一种重要的优化是锁粗化。当JVM检测到一连串连续的对同一个对象的加锁解锁操作时,会将锁的范围扩大到整个操作序列的外部,减少锁的获取释放次数。
java复制// 锁粗化的例子
public void process(List<Item> items) {
for (Item item : items) {
synchronized(this) {
doSomething(item);
}
}
// 可能被优化为:
synchronized(this) {
for (Item item : items) {
doSomething(item);
}
}
}
锁粗化虽然减少了同步开销,但可能增加锁的持有时间,需要根据具体场景权衡。我们可以通过以下参数禁用锁粗化:
code复制-XX:-EliminateLocks // 禁用锁消除和锁粗化
5. 实战中的synchronized优化策略
理解了synchronized的优化原理后,我们可以在实际开发中应用这些知识来编写更高效的并发代码。
5.1 对象大小对锁性能的影响
由于锁状态信息存储在对象头中,对象的内存布局会影响锁操作的性能。特别是对于小对象:
- 数组对象比普通对象多4字节存储数组长度
- 对象对齐填充会影响缓存行利用率
- 对象字段排列顺序会影响字段访问速度
在我的基准测试中,一个只包含单个int字段的对象,其同步操作比包含8个字段的对象快约15%。这是因为更简单的内存布局使得CAS操作更容易成功。
5.2 锁粒度的选择
锁粒度是并发编程中的关键考量。太粗的锁会导致竞争激烈,太细的锁会增加管理开销。一些实用的建议:
- 按业务维度拆分锁:比如用户订单系统可以为每个用户维护一个独立的锁对象
- 使用锁分段技术:ConcurrentHashMap就是典型例子,将数据分成多个段,每个段独立加锁
- 读写分离:读多写少的场景考虑使用ReadWriteLock
java复制// 锁分段示例
public class StripedLock {
private final Object[] locks;
private final int mask;
public StripedLock(int concurrencyLevel) {
int size = 1;
while (size < concurrencyLevel) {
size <<= 1;
}
locks = new Object[size];
for (int i = 0; i < locks.length; i++) {
locks[i] = new Object();
}
mask = size - 1;
}
public Object getLock(Object key) {
return locks[key.hashCode() & mask];
}
}
5.3 避免常见的锁误用
在实际代码审查中,我经常发现以下几种锁误用情况:
- 在循环内加锁:除非确实需要每次迭代都加锁,否则应该将锁移到循环外部
- 锁对象变化:锁对象应该是final的,避免无意中改变锁对象
- 不同锁顺序:多个锁应该总是以相同的顺序获取,避免死锁
- 锁与异常处理:确保在异常情况下也能释放锁,最好使用try-finally块
java复制// 错误的锁用法示例
public class BadLockExample {
private Object lock = new Object();
public void process() {
// 错误1:锁对象可能被改变
synchronized(lock) {
// 错误2:没有处理异常
doSomethingDangerous();
}
}
public void setLock(Object newLock) {
this.lock = newLock; // 致命错误:改变了锁对象
}
}
5.4 监控锁性能的工具
在生产环境中监控锁竞争情况非常重要。常用的工具包括:
- JConsole/VisualVM:查看线程状态和锁等待情况
- JFR (Java Flight Recorder):记录详细的锁事件
- async-profiler:低开销的锁分析工具
- JStack:获取线程转储分析锁持有情况
例如使用JFR记录锁事件:
bash复制java -XX:+UnlockCommercialFeatures -XX:+FlightRecorder \
-XX:StartFlightRecording=duration=60s,filename=lock.jfr \
-XX:FlightRecorderOptions=settings=profile MyApp
在我的性能调优实践中,发现大约60%的性能问题都与锁竞争有关。通过合理使用这些工具,可以快速定位锁瓶颈。
6. synchronized与其他并发工具的比较
虽然synchronized已经得到了极大优化,但在某些场景下,其他并发工具可能更合适。
6.1 与ReentrantLock的对比
ReentrantLock提供了比synchronized更灵活的功能:
| 特性 | synchronized | ReentrantLock |
|---|---|---|
| 公平锁 | 不支持 | 支持 |
| 尝试非阻塞获取锁 | 不支持 | tryLock()支持 |
| 可中断的锁获取 | 不支持 | lockInterruptibly()支持 |
| 超时获取锁 | 不支持 | tryLock(timeout)支持 |
| 条件变量 | 有限的wait/notify | 支持多个Condition |
但是synchronized在JDK6之后性能已经与ReentrantLock相当,甚至在某些场景下更优。选择建议:
- 需要高级功能(如公平锁、可中断锁)时使用ReentrantLock
- 简单同步场景优先使用synchronized
- 考虑代码可读性和维护成本
6.2 与CAS操作的对比
对于简单的原子操作,java.util.concurrent.atomic包提供的CAS操作通常比synchronized性能更好:
java复制// 使用synchronized
public class SynchronizedCounter {
private int value;
public synchronized int increment() {
return ++value;
}
}
// 使用AtomicInteger
public class CasCounter {
private final AtomicInteger value = new AtomicInteger();
public int increment() {
return value.incrementAndGet();
}
}
基准测试显示,在低竞争情况下,CAS版本比synchronized版本快3-5倍。但在高竞争情况下,CAS可能导致大量重试,此时synchronized的性能可能更好。
6.3 与并发容器的对比
对于集合操作,并发容器通常比手动同步更好:
java复制// 手动同步的集合
List<String> syncList = Collections.synchronizedList(new ArrayList<>());
// 并发容器
ConcurrentLinkedQueue<String> concurrentQueue = new ConcurrentLinkedQueue<>();
并发容器通常采用更细粒度的锁或完全无锁算法,性能更好。例如ConcurrentHashMap的分段锁设计,允许多个线程同时读写不同的段。
7. JVM层面对synchronized的持续优化
Java的每个新版本几乎都会对synchronized进行优化。了解这些变化有助于我们编写面向未来的代码。
7.1 JDK 15的偏向锁废弃
由于现代应用的线程池模式导致偏向锁的收益下降,JDK 15开始默认禁用偏向锁。可以通过以下参数重新启用:
code复制-XX:+UseBiasedLocking
在我的测试中,禁用偏向锁对大多数现代应用影响很小,甚至在某些微服务场景下性能还有所提升。
7.2 锁保留技术(Lock Reservation)
这是JVM内部的一种优化,当JVM检测到某个锁经常被特定线程获取时,会尝试"保留"这个锁给该线程,减少CAS操作的开销。这种优化完全透明,无需开发者干预。
7.3 自适应自旋的改进
最新JDK版本中,自旋算法变得更加智能,会考虑:
- CPU负载情况
- 锁持有时间的历史统计
- 线程调度延迟
这使得自旋在合适的时候更积极,在不合适的时候更保守,整体性能更加稳定。
8. 从字节码看synchronized实现
理解synchronized的字节码表示有助于更深入理解其工作原理。
8.1 同步方法的字节码
同步方法会在方法flags中设置ACC_SYNCHRONIZED标志:
java复制public synchronized void syncMethod() {
// 方法体
}
对应的字节码:
code复制public synchronized void syncMethod();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=0, locals=1, args_size=1
0: return
8.2 同步块的字节码
同步块使用monitorenter和monitorexit指令:
java复制public void syncBlock() {
synchronized(this) {
// 同步块
}
}
对应的字节码:
code复制public void syncBlock();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
0: aload_0
1: dup
2: astore_1
3: monitorenter // 进入同步块
4: aload_1
5: monitorexit // 正常退出同步块
6: goto 14
9: astore_2
10: aload_1
11: monitorexit // 异常退出同步块
12: aload_2
13: athrow
14: return
Exception table:
from to target type
4 6 9 any
9 12 9 any
可以看到,编译器会自动为同步块生成异常处理逻辑,确保锁一定会被释放。这也是为什么我们不应该手动调用monitorenter/monitorexit——编译器生成的代码更可靠。
8.3 锁消除的字节码证据
我们可以通过查看JIT编译后的汇编代码来验证锁消除。使用以下命令:
bash复制java -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly MyClass
在输出中查找锁相关指令(如lock cmpxchg),如果发现预期中的锁操作消失了,就说明锁消除生效了。
9. 常见误区与正确实践
在多年的Java并发编程教学中,我发现开发者对synchronized存在一些普遍误解。
9.1 误区一:"synchronized方法比同步块慢"
实际上,经过JVM优化后,两者的性能差异可以忽略不计。选择标准应该是:
- 需要同步整个方法时用synchronized方法
- 只需要同步部分代码时用同步块
- 考虑代码清晰度和维护成本
9.2 误区二:"volatile可以替代synchronized"
volatile只保证可见性,不保证原子性。例如count++这样的操作,即使count是volatile的,也需要同步:
java复制private volatile int count;
// 不安全的操作
public void unsafeIncrement() {
count++; // 实际上是读-改-写三步操作
}
// 安全的操作
public synchronized void safeIncrement() {
count++;
}
9.3 误区三:"String.intern()返回的对象适合做锁"
这是非常危险的做法,因为intern()返回的对象是全局共享的:
java复制// 绝对不要这样做!
synchronized(userName.intern()) {
// ...
}
这可能导致完全无关的代码路径之间产生意外的锁竞争。
9.4 正确实践:锁对象的最佳选择
理想的锁对象应该是:
- 私有的(private)
- 不可变的(final)
- 专用于同步目的
- 与保护的数据有明确关联
java复制public class CorrectLockExample {
private final Object lock = new Object(); // 理想的锁对象
private int sharedData;
public void updateData(int value) {
synchronized(lock) {
sharedData = value;
}
}
}
10. 性能调优实战案例
最后分享一个真实的性能调优案例,展示如何应用synchronized优化知识解决实际问题。
10.1 问题场景
一个电商平台的库存服务,在秒杀活动期间出现严重性能问题。关键代码如下:
java复制public class InventoryService {
private static final Map<Long, Integer> inventory = new HashMap<>();
public synchronized boolean deductInventory(Long itemId, int quantity) {
Integer stock = inventory.get(itemId);
if (stock == null || stock < quantity) {
return false;
}
inventory.put(itemId, stock - quantity);
return true;
}
}
分析发现,这个全局锁导致所有商品扣减操作都串行化,QPS无法超过500。
10.2 优化方案
我们实施了多级优化:
- 锁粒度细化:为每个商品ID创建独立的锁
- 锁分段:使用StripedLock模式减少锁数量
- 乐观锁尝试:先读后写,配合版本号检查
- 并发容器替换:最终采用ConcurrentHashMap
优化后的核心代码:
java复制public class OptimizedInventoryService {
private final ConcurrentHashMap<Long, AtomicInteger> inventory = new ConcurrentHashMap<>();
private final StripedLock lock = new StripedLock(16);
public boolean deductInventory(Long itemId, int quantity) {
AtomicInteger stock = inventory.get(itemId);
if (stock == null) {
return false;
}
// 快速路径尝试
while (true) {
int current = stock.get();
if (current < quantity) {
return false;
}
if (stock.compareAndSet(current, current - quantity)) {
return true;
}
// CAS失败,重试
}
}
// 用于复杂操作的分段锁
public void batchUpdate(Map<Long, Integer> updates) {
updates.forEach((itemId, qty) -> {
Object lock = this.lock.getLock(itemId);
synchronized(lock) {
deductInventory(itemId, qty);
}
});
}
}
10.3 优化效果
优化前后的性能对比:
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 最大QPS | 500 | 12,000 |
| 平均延迟(ms) | 45 | 3.2 |
| CPU利用率 | 30% | 75% |
| GC频率 | 高频 | 低频 |
这个案例展示了合理使用锁优化技术可以带来的巨大性能提升。关键在于理解synchronized的工作原理,然后根据具体场景选择最适合的优化策略。
