1. Synchronized的前世今生:从"重量级"到"智能可进化"的演进之路
2006年JDK 1.6发布前,Java开发者对synchronized关键字又爱又恨。爱的是它的简单易用,恨的是它那令人诟病的性能表现。当时synchronized的实现完全依赖操作系统层面的互斥锁(Mutex Lock),每次加锁/解锁都需要从用户态切换到内核态,这种重量级操作在高并发场景下性能损耗极大。我至今记得早期电商项目中,一个简单的秒杀功能因为滥用synchronized导致TPS(每秒事务数)直接跌到两位数。
但故事在JDK 1.6发生了转折。HotSpot虚拟机团队对synchronized进行了脱胎换骨的改造,引入了如今广为人知的"锁升级"机制。这个机制的精妙之处在于,它让锁能够根据实际竞争情况动态调整状态,就像给锁装上了智能芯片。从偏向锁到轻量级锁,再到重量级锁,锁的"体重"会随着竞争激烈程度而自适应变化。这种设计让synchronized在无竞争或低竞争场景下,性能接近无锁操作;而在高竞争场景下,又能保证线程安全。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 锁的智能进化:Synchronized的四重状态解析
2.1 无锁状态:自由竞争的起点
每个Java对象在刚创建时都处于无锁状态。这时如果有线程首次访问该对象,虚拟机会根据配置决定是否立即启用偏向锁。在偏向锁未启用的默认情况下,对象头中的Mark Word会记录对象的哈希码和分代年龄等信息。我曾在性能调优时发现,对于明确不会存在竞争的对象(如线程本地对象),通过JVM参数-XX:-UseBiasedLocking禁用偏向锁可以节省约5ns的访问时间。
2.2 偏向锁:单线程访问的极致优化
当第一个线程访问同步块时,虚拟机会通过CAS操作将对象头中的线程ID替换为当前线程ID,这个过程称为偏向锁的获取。有趣的是,这个操作甚至不需要真正的原子操作,因为此时没有竞争。在笔者的压力测试中,偏向锁使得单线程重复获取锁的性能提升了近7倍。但要注意,偏向锁的撤销成本很高,所以在明确存在多线程竞争的场景(如连接池),建议使用-XX:-UseBiasedLocking禁用偏向锁。
关键细节:偏向锁默认在应用程序启动后4秒才激活(可通过-XX:BiasedLockingStartupDelay调整),这是因为JVM启动初期通常存在大量类加载操作,这些操作本身就会导致偏向锁撤销。
2.3 轻量级锁:多线程交替执行的平衡点
当第二个线程尝试获取锁时,偏向锁就会升级为轻量级锁。这个升级过程非常精妙:
- JVM会在当前线程的栈帧中创建锁记录(Lock Record)
- 将对象头的Mark Word复制到锁记录中(Displaced Mark Word)
- 通过CAS尝试将对象头指向锁记录
- 如果成功,线程获得轻量级锁;如果失败,说明存在竞争,进入自旋等待
在我的基准测试中,两个线程交替执行时,轻量级锁相比重量级锁能减少约60%的性能开销。但要注意,自旋等待会消耗CPU资源,所以JDK采用了适应性自旋策略:基于前一次自旋的成功率动态调整本次自旋次数。
2.4 重量级锁:高竞争下的终极方案
当自旋超过阈值(默认10次,可通过-XX:PreBlockSpin调整)或者等待线程数超过CPU核数的一半时,轻量级锁就会升级为重量级锁。这时JVM会向操作系统申请互斥量(mutex),并将未获取到锁的线程放入等待队列。这个过程中最耗时的部分是从用户态到内核态的切换,在我的测试中,一次完整的重量级锁操作大约需要1微秒(是轻量级锁的100倍左右)。
3. 对象头探秘:Synchronized的物理存储结构
3.1 Mark Word的七十二变
32位JVM中,对象头的Mark Word只有32位,却要存储多种信息:
code复制|-------------------------------------------------------|
| 锁状态 | 存储内容 |
|----------|--------------------------------------------|
| 无锁 | 25位哈希码 + 4位分代年龄 + 1位偏向锁标志 + 2位锁标志(01) |
| 偏向锁 | 23位线程ID + 2位epoch + 4位分代年龄 + 1位偏向锁标志 + 2位锁标志(01) |
| 轻量级锁 | 30位指向栈中锁记录的指针 + 2位锁标志(00) |
| 重量级锁 | 30位指向互斥量(mutex)的指针 + 2位锁标志(10) |
| GC标记 | 30位空 + 2位锁标志(11) |
|-------------------------------------------------------|
64位JVM的Mark Word扩展到64位,但基本结构类似。在排查内存问题时,我常用jol-core工具打印对象头信息:
java复制// 添加依赖:org.openjdk.jol:jol-core:0.16
System.out.println(ClassLayout.parseInstance(object).toPrintable());
3.2 指针压缩带来的影响
在64位JVM开启指针压缩(-XX:+UseCompressedOops,默认开启)时,对象头布局会有所变化。有次性能调优时发现,禁用指针压缩后,锁升级的CAS操作耗时增加了约15%,这是因为CPU缓存行(通常64字节)能容纳的指针数量减半,导致缓存命中率下降。
4. 锁升级全流程:从代码到硬件的完整路径
4.1 字节码层面的实现
synchronized在字节码中体现为monitorenter和monitorexit两个指令。但有趣的是,编译器会自动为同步块添加异常处理,确保锁必然释放:
java复制// 源代码
synchronized(obj) {
// do something
}
// 等效字节码
monitorenter
try {
// do something
} finally {
monitorexit
}
4.2 运行时优化技巧
JVM会进行锁消除(Lock Elimination)和锁粗化(Lock Coarsening)优化。例如在下面代码中:
java复制public String concat(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb.toString();
}
由于StringBuffer是局部变量,JVM会消除其内部同步操作。而在循环中反复加锁时:
java复制for(int i=0; i<100; i++) {
synchronized(obj) {
// do something
}
}
JVM可能会将锁粗化为:
java复制synchronized(obj) {
for(int i=0; i<100; i++) {
// do something
}
}
5. 实战中的性能陷阱与优化策略
5.1 错误案例:锁粒度过大
我曾接手过一个支付系统,性能测试时TPS始终上不去。分析发现开发者在整个支付流程上加了大锁:
java复制public synchronized void processPayment() {
// 20多个步骤...
}
优化方案是拆分为细粒度锁,只对共享资源(如账户余额)加锁,最终性能提升了8倍。
5.2 错误案例:错误使用String作为锁
java复制// 反例
String lock = "lock";
synchronized(lock) { ... }
问题在于字符串常量池会导致不同代码段意外共享同一把锁。应该使用:
java复制private final Object lock = new Object();
5.3 监控锁竞争的工具链
- JConsole:可视化查看线程阻塞情况
- VisualVM:安装JTA插件分析锁竞争
- JFR(Java Flight Recorder):
bash复制jcmd <pid> JFR.start duration=60s filename=recording.jfr
- async-profiler:
bash复制./profiler.sh -d 30 -e lock -f lock.svg <pid>
6. Synchronized与并发容器的精妙配合
6.1 Collections.synchronizedXXX的真相
这些包装类只是在所有方法上加synchronized,性能较差。比如:
java复制List<String> list = Collections.synchronizedList(new ArrayList<>());
等效于:
java复制public boolean add(E e) {
synchronized(this) {
return list.add(e);
}
}
在高并发场景下应该使用ConcurrentHashMap等真正并发的容器。
6.2 读写锁的替代方案
对于读多写少的场景,可以考虑:
java复制// 传统方式
synchronized(this) {
// 读写操作
}
// 更好的方式
ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
rwLock.readLock().lock();
try {
// 读操作
} finally {
rwLock.readLock().unlock();
}
7. 从JVM角度理解锁优化
7.1 逃逸分析对锁的影响
JVM通过逃逸分析判断对象是否可能被其他线程访问。对于未逃逸的对象,可以实施锁消除。例如:
java复制public void doSomething() {
Object lock = new Object();
synchronized(lock) {
// 操作局部变量
}
}
这里的锁会被完全消除。
7.2 偏向锁的批量重偏向与撤销
当某个类的偏向锁撤销次数超过阈值(默认20次),JVM会进行批量重偏向(Bulk Rebias)。而当超过另一个更高阈值(默认40次)时,会禁用该类的偏向锁。可以通过-XX:BiasedLockingBulkRebiasThreshold和-XX:BiasedLockingBulkRevokeThreshold调整这些阈值。
8. 未来展望:Synchronized的持续进化
随着Java虚拟机的不断发展,synchronized仍在持续优化。GraalVM已经展示了通过JIT编译将synchronized完全消除的可能性。而在Loom项目引入的虚拟线程中,synchronized与pinned thread的交互也带来了新的优化挑战。可以预见,这个诞生二十多年的关键字仍将在Java并发体系中扮演核心角色。
