1. synchronized关键字的本质与使用场景
在Java多线程编程中,synchronized是最基础的线程同步机制。我第一次真正理解它的重要性是在一个电商秒杀项目中——当多个用户同时抢购同一件商品时,库存数量的更新出现了诡异的负数。这个看似简单的关键字,实际上解决了并发编程中最核心的共享资源访问问题。
synchronized实现了互斥锁(Mutex)的语义,它能够保证同一时刻只有一个线程可以执行被保护的代码块或方法。这种特性我们称为"原子性"。想象一下十字路口的红绿灯:synchronized就是那个交通信号灯,确保不同方向的车辆(线程)不会同时进入路口(临界区)。
从语法层面看,synchronized有三种使用方式:
java复制// 1. 实例方法同步
public synchronized void method() {
// 临界区代码
}
// 2. 静态方法同步
public static synchronized void staticMethod() {
// 临界区代码
}
// 3. 同步代码块
public void blockMethod() {
synchronized(this) { // 也可以是其他对象
// 临界区代码
}
}
这三种形式看似简单,但锁的粒度却大不相同。实例方法锁的是当前对象实例,静态方法锁的是类的Class对象,而同步代码块则可以灵活指定锁对象。在实际项目中,我通常会选择同步代码块的方式,因为它可以精确控制锁的范围,减少不必要的性能开销。
关键经验:锁的粒度越小越好。能用代码块就不要用整个方法,能用特定锁对象就不要用this。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. synchronized的底层实现机制
2.1 Java对象头与Monitor
每个Java对象在内存中都由三部分组成:对象头、实例数据和填充对齐。其中对象头就包含了synchronized实现的关键——Mark Word。在32位JVM中,Mark Word结构如下:
| 锁状态 | 25bit | 4bit | 1bit(偏向锁) | 2bit(锁标志) |
|---|---|---|---|---|
| 无锁 | hashCode | 分代年龄 | 0 | 01 |
| 偏向锁 | 线程ID+Epoch | 分代年龄 | 1 | 01 |
| 轻量级锁 | 指向栈中锁记录的指针 | 00 | ||
| 重量级锁 | 指向Monitor的指针 | 10 | ||
| GC标记 | 11 |
当线程进入synchronized代码块时,JVM会根据竞争情况升级锁状态。这个优化过程称为"锁膨胀",包含以下阶段:
- 无锁 → 偏向锁:当第一个线程访问时,JVM会将对象头中的线程ID设置为当前线程ID
- 偏向锁 → 轻量级锁:当第二个线程尝试获取锁时,偏向锁会升级为轻量级锁(自旋锁)
- 轻量级锁 → 重量级锁:当自旋超过一定次数(JDK6默认10次),会升级为重量级锁
2.2 Monitor的工作机制
重量级锁的实现依赖于Monitor对象(也称为管程)。每个Java对象都关联一个Monitor,但这个Monitor不会被主动创建,只有当对象被当作锁使用时才会被初始化。
Monitor的核心组件包括:
- _owner:指向持有锁的线程
- _EntryList:存放等待锁的线程
- _WaitSet:存放调用了wait()的线程
当线程尝试获取锁时:
- 如果_owner为null,CAS操作将_owner设置为当前线程
- 如果_owner已是当前线程,重入计数器+1(这就是synchronized可重入的原理)
- 否则,线程进入_EntryList阻塞等待
3. synchronized的优化历程
3.1 JDK6之前的性能问题
早期的synchronized确实存在严重的性能问题,主要因为:
- 没有锁升级机制,直接使用重量级锁
- 线程阻塞/唤醒需要操作系统介入,涉及用户态/内核态切换
- 无法解决"惊群效应"——多个线程同时竞争导致的系统颠簸
3.2 现代JVM的锁优化
JDK6引入了多项优化,使得synchronized在无竞争或低竞争场景下性能接近CAS操作:
-
偏向锁:适用于单线程重复访问的场景。只需要在Mark Word中记录线程ID,不涉及真正的同步操作。
-
轻量级锁:当出现轻度竞争时,线程通过CAS和自旋尝试获取锁,避免线程阻塞。
-
适应性自旋:JVM会根据历史自旋成功率动态调整自旋次数,而不是固定值。
-
锁消除:通过逃逸分析,如果发现锁对象不会逃出当前方法,JIT会直接消除锁操作。
-
锁粗化:对于连续的同步块,JVM会合并为一个更大的同步块,减少锁获取/释放的开销。
在实际性能测试中,我观察到在低竞争场景下,优化后的synchronized性能与ReentrantLock相差无几。但在高竞争场景下,ReentrantLock的可配置性(如公平锁、条件变量)仍然具有优势。
4. synchronized的实战应用与陷阱
4.1 典型应用场景
- 单例模式的双重检查锁定:
java复制public class Singleton {
private volatile static Singleton instance;
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
注意这里的volatile关键字是必须的,防止指令重排序导致的初始化问题。
- 线程安全的计数器:
java复制public class Counter {
private int count;
public synchronized void increment() {
count++;
}
public synchronized int getCount() {
return count;
}
}
4.2 常见陷阱与解决方案
- 锁对象选择不当:
错误示例:
java复制private String lock = "lock";
public void method() {
synchronized(lock) {
// ...
}
}
问题在于字符串常量池会导致不同地方的"lock"实际上是同一个对象,可能引发意外的锁竞争。
- 死锁问题:
java复制// 线程1
synchronized(objA) {
synchronized(objB) {
// ...
}
}
// 线程2
synchronized(objB) {
synchronized(objA) {
// ...
}
}
解决方案:统一获取锁的顺序,或使用tryLock等机制。
- 性能瓶颈:
过度使用synchronized会导致串行化过度。我曾遇到一个案例:将整个Service方法同步,导致系统吞吐量只有50TPS。通过拆分为细粒度锁后提升到2000+TPS。
排查技巧:使用JConsole或VisualVM的线程监控功能,查看锁竞争情况。红色阻塞线程通常指示锁竞争热点。
5. synchronized与其它同步机制的对比
5.1 synchronized vs ReentrantLock
| 特性 | synchronized | ReentrantLock |
|---|---|---|
| 实现机制 | JVM内置 | JDK实现(AQS) |
| 锁获取方式 | 自动释放 | 必须手动unlock() |
| 可中断性 | 不支持 | 支持lockInterruptibly() |
| 公平锁 | 非公平 | 可配置公平/非公平 |
| 条件变量 | 一个锁对应一个等待队列 | 一个锁可创建多个Condition |
| 性能 | JDK6后优化良好 | 高竞争下表现更好 |
选择建议:
- 简单场景用synchronized(代码简洁,自动管理)
- 需要高级功能时用ReentrantLock(如超时、公平锁等)
5.2 synchronized vs volatile
volatile只保证可见性和禁止指令重排序,但不保证原子性。例如count++操作,即使count是volatile的,仍然需要同步。
5.3 synchronized vs CAS
CAS(Compare-And-Swap)是更轻量级的乐观锁,适合读多写少的场景。但CAS可能引发ABA问题(可通过版本号解决),且长时间自旋会消耗CPU。
在实际项目中,我通常会这样选择:
- 简单的状态标志 → volatile
- 简单的计数器 → AtomicInteger
- 复杂的同步逻辑 → synchronized或ReentrantLock
6. JVM层级的锁优化技巧
6.1 逃逸分析与锁消除
JVM会分析对象的作用域,如果确定对象不会逃逸出当前线程(即不会被其他线程访问),就会自动消除锁操作。例如:
java复制public String concat(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb.toString();
}
虽然StringBuffer的方法是同步的,但由于sb不会逃逸,JVM会移除这些同步操作。
6.2 偏向锁的批量重偏向与撤销
当一类对象的偏向锁被频繁撤销时(如多线程交替执行),JVM会禁用这类对象的偏向锁特性。我们可以通过JVM参数调整阈值:
- -XX:BiasedLockingBulkRevokeThreshold=40(默认值)
- -XX:BiasedLockingBulkRebiasThreshold=20(默认值)
6.3 锁膨胀的观察
使用-XX:+PrintFlagsFinal可以查看JVM的锁相关参数,而-XX:+PrintSynchronizationStatistics可以输出同步统计信息(需要Debug版JVM)。
在性能敏感的应用中,我通常会监控这些指标:
- 偏向锁成功率
- 轻量级锁自旋次数
- 重量级锁的等待线程数
7. 从字节码看synchronized的实现
通过javap反编译可以看到,synchronized在字节码层面是通过monitorenter和monitorexit指令实现的:
java复制public void syncMethod();
Code:
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
可以看到,编译器会自动为同步块生成异常处理逻辑,确保锁一定会被释放。这也是为什么synchronized比手动lock/unlock更安全的原因。
8. 多线程调试技巧
调试多线程问题时,传统的断点可能会改变线程时序,掩盖问题。我常用的方法是:
- 条件断点:在IDEA中设置线程特定的断点条件
java复制Thread.currentThread().getName().equals("Thread-1")
- 日志追踪:给每个线程打上唯一ID
java复制private static final ThreadLocal<String> traceId = ThreadLocal.withInitial(
() -> UUID.randomUUID().toString().substring(0,8));
public void method() {
System.out.printf("[%s] %s%n", traceId.get(), "enter method");
// ...
}
- JStack分析:当出现死锁时,使用jstack获取线程转储
bash复制jstack <pid> > thread_dump.txt
- JFR记录:使用Java Flight Recorder记录锁竞争事件
bash复制jcmd <pid> JFR.start duration=60s filename=recording.jfr
