1. 从锁竞争到性能优化:synchronized的前世今生
第一次在Java代码里写下synchronized关键字时,我以为它就是个简单的"门锁"。直到线上系统出现线程安全问题时,才意识到这个看似简单的关键字背后藏着整个并发编程的宇宙。记得有次排查一个库存超卖问题,明明加了synchronized却依然出现数据错乱,最终发现是锁对象选错了——这段经历让我明白,真正掌握synchronized需要理解三个维度:语法层(怎么用)、虚拟机层(怎么实现)、应用层(怎么优化)。
在JDK的演进历程中,synchronized的优化史就是一部Java并发性能的提升史。从JDK1.6之前的重量级锁,到现在的偏向锁、轻量级锁、适应性自旋等优化,每一次升级都在解决特定场景下的性能痛点。比如电商秒杀场景中,90%的锁在JDK1.6+的优化下根本不会进入重量级锁状态,这就是为什么现在很少人再提"用ReentrantLock替代synchronized"的建议。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. synchronized的三种应用范式
2.1 实例方法锁:对象级别的保护
当synchronized修饰实例方法时,锁对象就是当前实例(this)。这种写法最适合需要保护对象内部状态的场景。比如银行账户的转账操作:
java复制public class BankAccount {
private double balance;
public synchronized void transfer(double amount) {
balance += amount; // 这个操作需要原子性
}
}
关键细节:同一个对象的多个synchronized方法会互斥,但不同实例的方法不会互相阻塞。这就是为什么Spring的默认Bean作用域(singleton)下,用synchronized修饰service方法可能达不到预期效果——当有多个服务实例时。
2.2 静态方法锁:类级别的守护
用synchronized修饰静态方法时,锁对象变成类的Class对象。这种锁适用于需要跨实例共享的资源保护:
java复制public class IdGenerator {
private static int counter = 0;
public static synchronized int nextId() {
return ++counter; // 全局唯一的ID生成
}
}
实际项目中,我曾见过有人误将静态synchronized用于非静态资源保护,导致锁完全失效。记住一个简单的判断方法:如果资源是实例变量就用实例锁,如果是类变量就用类锁。
2.3 同步代码块:精细化控制
最灵活的用法是同步代码块,可以精确指定锁对象和临界区范围:
java复制public class Cache {
private final Object lock = new Object();
private Map<String, Object> data = new HashMap<>();
public void put(String key, Object value) {
// 非同步预处理
if(key == null) return;
synchronized(lock) { // 细粒度锁
data.put(key, value);
}
}
}
这里特别使用了专门的lock对象而非this,是为了避免外部代码意外持有我们的锁。在复杂系统中,这种"私有锁"模式能有效减少死锁风险。
3. 深入JVM:synchronized的实现原理
3.1 对象头中的锁密码
每个Java对象在内存中的布局都包含对象头,其中Mark Word是锁状态的关键载体。32位JVM下Mark Word的结构如下:
| 锁状态 | 25bit | 4bit | 1bit(偏向锁) | 2bit(锁标志) |
|---|---|---|---|---|
| 无锁 | 对象的hashCode | 分代年龄 | 0 | 01 |
| 偏向锁 | 线程ID + Epoch | 分代年龄 | 1 | 01 |
| 轻量级锁 | 指向栈中锁记录的指针 | - | - | 00 |
| 重量级锁 | 指向互斥量的指针 | - | - | 10 |
| GC标记 | - | - | - | 11 |
这个结构解释了为什么说"锁是对象的一个属性"。当锁状态变化时,JVM通过CAS操作修改这些标志位,这也是锁升级的基础。
3.2 锁升级的全过程
现代JVM的synchronized采用渐进式锁策略,包含四个阶段:
- 无锁状态:新创建对象的初始状态
- 偏向锁:当第一个线程访问时,通过CAS将线程ID写入Mark Word
- 适用场景:实际没有竞争或总是同一线程访问
- 优势:只有第一次需要CAS,后续直接检查线程ID即可
- 轻量级锁:当第二个线程尝试获取锁时升级
- 过程:原持有线程在栈帧中创建锁记录(Lock Record),将Mark Word复制到其中,然后用CAS尝试将Mark Word指向锁记录
- 特点:通过自旋避免直接阻塞,适合短时间锁持有
- 重量级锁:当自旋超过阈值(默认10次)或等待线程数超过CPU核数一半
- 实现:通过操作系统的互斥量(mutex)实现真正的线程阻塞
- 代价:涉及用户态到内核态的切换,成本最高
这个升级过程是不可逆的,也就是所谓的"锁膨胀"。理解这点对性能调优很重要——长时间运行的应用应避免频繁的锁竞争导致锁持续停留在重量级状态。
4. JDK的锁优化策略解析
4.1 偏向锁的延迟与撤销
虽然偏向锁能提升单线程性能,但在多线程环境下,频繁的偏向锁撤销反而会降低性能。因此JDK15后默认禁用偏向锁(可通过-XX:+UseBiasedLocking重新启用)。更精细的控制参数包括:
- -XX:BiasedLockingStartupDelay=4000(默认4秒后启用)
- -XX:BiasedLockingBulkRevokeThreshold=20(批量撤销阈值)
在Web容器等明确多线程环境的应用中,建议通过JVM参数关闭偏向锁。
4.2 自适应自旋的智能调节
轻量级锁失败后,线程不会立即阻塞,而是会自旋尝试获取锁。JDK1.6引入的自适应机制会根据历史成功率动态调整自旋次数:
- 如果上次自旋成功获取了锁,则下次允许更长的自旋
- 如果很少成功,则可能直接跳过自旋过程
这个优化特别适合锁持有时间不稳定的场景。通过-XX:PreBlockSpin可以设置初始自旋次数(默认10次)。
4.3 锁粗化与消除
JVM还会在编译期进行两种高级优化:
锁粗化(Lock Coarsening):
当检测到连续多个同步块使用同一个锁时,会合并为一个更大的同步块,减少锁获取/释放的开销。例如:
java复制// 优化前
synchronized(lock) { doA(); }
synchronized(lock) { doB(); }
// 优化后
synchronized(lock) {
doA();
doB();
}
锁消除(Lock Elimination):
通过逃逸分析确认某些锁对象不会逃逸当前线程时,直接移除同步操作。典型场景如StringBuffer的局部变量使用:
java复制public String concat(String s1, String s2) {
StringBuffer sb = new StringBuffer(); // 不会逃逸出方法
sb.append(s1);
sb.append(s2);
return sb.toString();
}
5. 实战中的锁性能调优
5.1 锁粒度的权衡
错误的锁粒度是实际项目中最常见的性能问题。我曾优化过一个日志服务,原始实现用类锁保护所有写操作:
java复制public class Logger {
public static synchronized void log(String message) {
writeToFile(message);
}
}
改为实例锁后吞吐量提升3倍:
java复制public class Logger {
private final Object lock = new Object();
public void log(String message) {
synchronized(lock) {
writeToFile(message);
}
}
}
更进一步,可以为不同日志文件使用不同的锁对象,实现完全并发的写入。
5.2 避免死锁的编码规范
死锁的四个必要条件(互斥、占有且等待、不可抢占、循环等待)虽然理论清晰,但实际项目中往往隐藏得很深。推荐几个实用技巧:
-
锁排序法:全局约定锁的获取顺序
java复制// 定义锁的全局排序 private static final Object LOCK_1 = new Object(); private static final Object LOCK_2 = new Object(); void methodA() { synchronized(LOCK_1) { synchronized(LOCK_2) { /* ... */ } } } void methodB() { synchronized(LOCK_1) { // 即使只需要LOCK_2也先获取LOCK_1 synchronized(LOCK_2) { /* ... */ } } } -
使用tryLock:在需要获取多个锁时,用ReentrantLock的tryLock实现超时机制
java复制if (lock1.tryLock(100, TimeUnit.MILLISECONDS)) { try { if (lock2.tryLock(100, TimeUnit.MILLISECONDS)) { try { /* 临界区 */ } finally { lock2.unlock(); } } } finally { lock1.unlock(); } }
5.3 监控锁争用的工具链
生产环境诊断锁问题需要全套工具:
-
JStack:查看线程栈和锁持有情况
bash复制
jstack -l <pid> > thread_dump.txt -
JVisualVM:图形化监控锁竞争
- 安装"Threads Inspector"插件
- 查看"Monitor"标签下的阻塞线程
-
Arthas:动态诊断工具
bash复制thread -b # 检测死锁 monitor -c 5 java.lang.StringBuffer append # 监控方法调用 -
JMH基准测试:量化锁性能
java复制@Benchmark @BenchmarkMode(Mode.Throughput) public void testSynchronized() { synchronized(this) { counter++; } }
6. 常见误区与深度问题
6.1 String作为锁对象的隐患
由于字符串常量池的特性,直接使用String字面量作为锁极其危险:
java复制// 错误示例 - 可能导致意外锁竞争
synchronized("GLOBAL_LOCK") {
// ...
}
不同地方的相同字符串字面量实际是同一个对象,这会导致本应无关的代码路径相互阻塞。安全的做法是:
java复制private static final Object LOCK = new Object();
synchronized(LOCK) {
// ...
}
6.2 锁与可见性的关系
虽然synchronized能保证原子性,但很多人忽略它也是建立happens-before关系的关键。考虑这个典型例子:
java复制public class VisibilityDemo {
private boolean ready = false;
private int value = 0;
public void writer() {
value = 42; // (1)
ready = true; // (2)
}
public void reader() {
if (ready) { // (3)
System.out.println(value); // (4)
}
}
}
即使按1→2→3→4的顺序执行,(4)处仍可能输出0。加入synchronized后:
java复制public synchronized void writer() {
value = 42;
ready = true;
}
public synchronized void reader() {
if (ready) {
System.out.println(value); // 保证看到42
}
}
现在能确保可见性,因为synchronized的解锁操作与后续加锁操作建立了happens-before关系。
6.3 锁与异常处理
临界区内的异常可能导致锁无法释放的严重问题:
java复制synchronized(lock) {
doSomething(); // 如果抛出异常?
updateState(); // 不会执行
} // 锁会自动释放吗?
实际上,synchronized块无论正常退出还是异常退出都会释放锁。但为了资源清理,仍建议:
java复制synchronized(lock) {
try {
doSomething();
updateState();
} finally {
// 即使有异常也会执行
cleanUp();
}
}
7. 从synchronized到更高级的并发工具
虽然synchronized能满足基础需求,但在复杂场景下,其他并发工具可能更合适:
| 场景 | synchronized | ReentrantLock | StampedLock |
|---|---|---|---|
| 简单同步 | ✓ | ✓ | ✗ |
| 可中断等待 | ✗ | ✓ | ✗ |
| 尝试获取锁 | ✗ | ✓ | ✓ |
| 读多写少 | ✗ | ✗ | ✓ |
| 公平性 | ✗ | ✓ | ✗ |
特别推荐StampedLock的乐观读模式,在读远多于写的场景下性能优势明显:
java复制public class Point {
private final StampedLock sl = new StampedLock();
private double x, y;
public double distanceFromOrigin() {
long stamp = sl.tryOptimisticRead(); // (1)
double currentX = x, currentY = y; // (2)
if (!sl.validate(stamp)) { // (3)
stamp = sl.readLock(); // (4)
try {
currentX = x;
currentY = y;
} finally {
sl.unlockRead(stamp);
}
}
return Math.sqrt(currentX*currentX + currentY*currentY);
}
}
这个模式的核心思想是:先乐观地读取(不需要锁),然后验证在读过程中是否有写操作发生。如果没有,就省去了锁开销;如果有,再退回到保守的读锁。
