1. 为什么synchronized是面试必考题?
在Java技术面试中,synchronized关键字几乎成了并发编程领域的"必考科目"。这背后有几个深层次原因:
首先,synchronized是Java语言层面提供的原生同步机制,自JDK1.0就存在,是Java并发编程的基石。它代表了最基础的线程安全实现方式,理解它等于掌握了Java并发模型的核心思想。
其次,synchronized的使用看似简单,但底层实现却经历了多次重大优化(如偏向锁、轻量级锁等)。从JDK1.6开始,HotSpot团队对synchronized进行了锁升级优化,使其性能大幅提升。面试官通过这个问题可以考察候选人对JVM底层机制的了解程度。
再者,synchronized涉及的关键概念众多:对象头、Monitor、锁状态转换、内存可见性等。一个简单的关键字背后串联起了Java内存模型(JMM)、线程调度、JVM优化等多个重要知识点。
提示:我曾面试过一位候选人,他能熟练使用synchronized但说不清对象头的作用,这就像知道怎么开车但不懂发动机原理——在复杂场景下很难真正解决问题。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. synchronized的底层实现机制
2.1 对象头与Mark Word
每个Java对象在内存中都由三部分组成:对象头、实例数据和对齐填充。其中对象头又包含Mark Word和类型指针。synchronized的锁信息就存储在Mark Word中。
在32位JVM中,Mark Word的结构如下:
| 锁状态 | 25bit | 4bit | 1bit(偏向锁) | 2bit(锁标志) |
|---|---|---|---|---|
| 无锁 | 对象的hashCode | 对象分代年龄 | 0 | 01 |
| 偏向锁 | 线程ID | Epoch | 1 | 01 |
| 轻量级锁 | 指向栈中锁记录的指针 | 00 | ||
| 重量级锁 | 指向互斥量的指针 | 10 | ||
| GC标记 | 空 | 11 |
64位JVM的Mark Word结构类似,只是位数扩展为64bit。这种设计使得锁状态可以在不同场景下灵活转换。
2.2 Monitor机制
当synchronized修饰代码块时,编译后会生成monitorenter和monitorexit字节码指令。这两个指令需要与每个对象的Monitor关联。
Monitor(管程)是操作系统层面的同步原语,在Java中表现为ObjectMonitor对象。关键数据结构如下:
cpp复制class ObjectMonitor {
void* _header; // 存储Mark Word
void* _owner; // 持有锁的线程
ObjectWaiter* _WaitSet; // 处于wait状态的线程队列
ObjectWaiter* _EntryList; // 等待锁的线程队列
volatile int _count; // 重入次数
// ...其他字段
}
当线程执行monitorenter时:
- 先通过CAS尝试获取锁
- 失败后进入_EntryList排队
- 获取锁后_owner指向当前线程,_count++
- 执行monitorexit时_count--,当_count为0时释放锁
3. 锁升级的全过程解析
3.1 无锁到偏向锁
偏向锁的引入是为了解决"大多数情况下锁不存在竞争"的场景。当线程第一次获取锁时:
- 检查锁标志位是否为01(可偏向)
- 通过CAS将Mark Word中的线程ID替换为当前线程ID
- 如果成功,进入偏向模式
- 之后该线程再进入同步块时只需检查线程ID是否匹配
注意:偏向锁默认延迟4秒启用(-XX:BiasedLockingStartupDelay=4000),因为JVM启动时会有大量竞争。
3.2 偏向锁撤销
当其他线程尝试获取偏向锁时,会发生撤销:
- 暂停持有偏向锁的线程(STW)
- 检查原持有线程是否存活
- 如果已退出同步块,则恢复到无锁状态
- 如果仍在同步块中,升级为轻量级锁
3.3 轻量级锁到重量级锁
轻量级锁通过CAS自旋尝试获取锁,适用于线程交替执行的场景。当自旋超过阈值(默认10次)或等待线程数超过CPU核数的一半时,会升级为重量级锁:
- 在堆中创建ObjectMonitor对象
- 将Mark Word指向ObjectMonitor
- 未获取锁的线程进入阻塞状态
4. synchronized的内存语义
4.1 可见性保证
synchronized遵循happens-before原则:
- 解锁操作先于后续的加锁操作
- 同步块内的修改对后续进入该同步块的线程可见
这通过以下机制实现:
- 进入同步块时,清空工作内存
- 从主内存重新加载变量
- 退出同步块时,将工作内存刷新到主内存
4.2 有序性保证
编译器不会对同步块内的指令进行重排序(as-if-serial语义)。但要注意"锁粗化"优化可能会合并相邻的同步块。
5. 常见误区与最佳实践
5.1 错误用法示例
java复制// 错误1:锁对象为基本类型(自动装箱每次都是新对象)
private int lock = 0;
public void wrongMethod1() {
synchronized(lock) { /*...*/ }
}
// 错误2:锁对象为字符串常量(可能被其他类库使用)
private String lock = "LOCK";
public void wrongMethod2() {
synchronized(lock) { /*...*/ }
}
// 错误3:锁对象为可变对象
private List<Object> lock = new ArrayList<>();
public void wrongMethod3() {
synchronized(lock) {
lock.add(new Object()); // 改变了锁对象引用
}
}
5.2 最佳实践建议
- 使用final修饰的Object作为锁对象
java复制private final Object lock = new Object();
- 同步块尽量缩小范围
java复制// 不推荐
public synchronized void method() { /* 全部代码 */ }
// 推荐
public void method() {
// 非同步代码
synchronized(this) {
// 需要同步的代码
}
// 非同步代码
}
-
避免在同步块中调用外部方法(可能导致死锁)
-
对于读多写少场景,考虑使用ReadWriteLock代替
6. 性能优化技巧
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会合并这些同步块:
java复制// 优化前
public void method() {
synchronized(lock) { /* 操作1 */ }
synchronized(lock) { /* 操作2 */ }
synchronized(lock) { /* 操作3 */ }
}
// 优化后
public void method() {
synchronized(lock) {
/* 操作1 */
/* 操作2 */
/* 操作3 */
}
}
6.3 偏向锁优化参数
- -XX:+UseBiasedLocking:启用偏向锁(JDK15后默认禁用)
- -XX:BiasedLockingStartupDelay=0:立即启用偏向锁
- -XX:+PrintBiasedLockingStatistics:打印偏向锁统计信息
7. 与ReentrantLock的对比
虽然ReentrantLock功能更丰富,但synchronized仍有其优势:
| 特性 | synchronized | ReentrantLock |
|---|---|---|
| 实现方式 | JVM原生支持 | Java代码实现 |
| 锁获取方式 | 阻塞式 | 可尝试获取 |
| 公平锁 | 非公平 | 可配置 |
| 条件变量 | 单个 | 多个 |
| 锁中断 | 不支持 | 支持 |
| 性能(JDK8+) | 相当 | 相当 |
| 代码简洁性 | 高 | 低 |
选择建议:
- 简单场景优先使用synchronized
- 需要高级功能时使用ReentrantLock
- 读写分离场景使用ReadWriteLock
8. 实战中的疑难问题
8.1 死锁排查案例
我曾遇到一个典型死锁场景:
java复制// 线程1
synchronized(lockA) {
synchronized(lockB) { /*...*/ }
}
// 线程2
synchronized(lockB) {
synchronized(lockA) { /*...*/ }
}
排查步骤:
- jstack获取线程dump
- 查找"deadlock"关键词
- 分析互相等待的锁资源
- 使用jconsole可视化查看
解决方案:
- 统一锁获取顺序
- 使用tryLock设置超时
8.2 锁竞争热点优化
在高并发场景下,我发现一个账户对象的synchronized方法成为瓶颈。优化方案:
- 将大锁拆分为小锁(如按账户ID哈希分组)
- 使用ConcurrentHashMap代替同步的HashMap
- 对于统计信息等非关键数据,采用最终一致性
优化后TPS从200提升到1500+。
9. 从字节码看synchronized
通过javap查看同步方法的字节码:
java复制public class SyncDemo {
public synchronized void method1() {}
public void method2() {
synchronized(this) {}
}
}
编译后:
code复制public synchronized void method1();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=0, locals=1, args_size=1
0: return
public void method2();
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
可以看到:
- 同步方法通过ACC_SYNCHRONIZED标志实现
- 同步代码块通过monitorenter/monitorexit指令实现
- 编译器会自动添加异常处理确保锁释放
10. 虚拟线程与synchronized
JDK19引入的虚拟线程(协程)与synchronized有特殊交互:
- 在虚拟线程中执行synchronized不会阻塞载体线程
- 但当虚拟线程在synchronized块内执行阻塞操作(如I/O)时,会连带阻塞载体线程
- 建议虚拟线程代码使用ReentrantLock而非synchronized
测试表明,在10,000个虚拟线程竞争synchronized的场景下,吞吐量比平台线程高5-8倍。
