Synchronized详解
本文深入剖析Java synchronized关键字,重点讲解其如何保障并发下的原子性与可见性、三种使用方式、基于Monitor的底层原理,以及从偏向锁到重量级锁的升级过程。
我们来对 Java 中的 synchronized 关键字进行一次全面而深入的详解。
核心摘要
synchronized 是 Java 中用于解决多线程并发问题最基本、最常用的关键字。它的核心作用是提供一种互斥锁(Mutual Exclusion Lock)机制,确保在同一时刻,只有一个线程可以执行被 synchronized 保护的代码块或方法。这能有效解决由多线程引发的原子性、可见性和有序性问题。
1. 为什么需要 synchronized?—— 线程安全问题
在多线程环境中,如果多个线程同时访问和修改同一个共享变量,就会出现不可预知的结果,这就是线程安全问题。主要体现在三个方面:
- 原子性(Atomicity)
- 问题:一个或多个操作,要么全部执行成功,要么全部不执行,中间不能被任何因素打断。像
count++这样的操作,看起来是一行代码,但实际上包含三个步骤:① 读取count值;② 将值加 1;③ 将新值写回count。多线程环境下,这三步之间可能被其他线程插入,导致结果错误。synchronized的作用:它能保证被其修饰的代码块在执行期间不会被其他线程打断,从而保证了原子性。
可见性(Visibility)
- 问题:当一个线程修改了共享变量的值,其他线程能够立即看到这个修改。由于 Java 内存模型(JMM)的存在,每个线程有自己的工作内存(通常是 CPU 缓存),线程对变量的修改可能先存在于工作内存中,没有及时刷新到主内存,导致其他线程读取到的是旧值。
synchronized的作用:当一个线程释放锁时,JMM 会强制它将工作内存中的所有修改刷新到主内存。当另一个线程获取锁时,JMM 会强制它从主内存中重新加载共享变量的值。这就保证了可见性。
有序性(Ordering)
- 问题:为了提高性能,编译器和处理器可能会对指令进行重排序。在单线程环境下,这通常没问题,但在多线程环境下,重排序可能会导致意想不到的逻辑错误。
synchronized的作用:它隐式地禁止了临界区(被锁定的代码)内外的指令重排序,保证了代码执行的“先后顺序”符合预期。
2. synchronized 的三种使用方式
synchronized 锁定的对象被称为锁监视器(Monitor)。锁住哪个对象是理解其工作方式的关键。
a. 修饰实例方法
当 synchronized 修饰一个普通(非静态)方法时,它锁定的对象是当前类的实例(this)。
public class MyCounter {
private int count = 0;
// 锁对象是 this,即 MyCounter 的实例
public synchronized void increment() {
count++;
}
}
// 使用示例
MyCounter counter1 = new MyCounter();
MyCounter counter2 = new MyCounter();
// 线程A和B竞争 counter1 对象锁,会互斥
new Thread(() -> counter1.increment()).start();
new Thread(() -> counter1.increment()).start();
// 线程C可以和A、B同时运行,因为它锁的是 counter2 对象
new Thread(() -> counter2.increment()).start();
- 效果:多个线程同时调用同一个实例的
increment()方法时,会发生互斥,只有一个线程能执行。如果它们调用的是不同实例的increment()方法,则不会互斥。
b. 修饰静态方法
当 synchronized 修饰一个静态方法时,它锁定的对象是当前类的 Class 对象(例如 MyCounter.class)。
public class MyCounter {
private static int count = 0;
// 锁对象是 MyCounter.class
public static synchronized void increment() {
count++;
}
}
// 使用示例
MyCounter counter1 = new MyCounter();
MyCounter counter2 = new MyCounter();
// 所有线程都在竞争同一个锁:MyCounter.class
// 无论通过哪个实例调用,或直接通过类名调用,都会互斥
new Thread(() -> MyCounter.increment()).start();
new Thread(() -> MyCounter.increment()).start();
- 效果:这个锁是类级别的,对于这个类的所有实例都是全局唯一的。无论有多少个实例,只要调用这个静态同步方法,就必须竞争同一个
MyCounter.class锁。
c. 修饰代码块
这是最灵活的方式,可以显式指定用哪个对象作为锁。
public class MyCounter {
private int count = 0;
private final Object lock = new Object(); // 推荐使用一个专用的、final的、私有的对象作为锁
public void performAction() {
// ... 其他非同步操作 ...
// 只对关键代码块加锁,减小锁的粒度,提高性能
synchronized (lock) { // 显式指定 lock 对象为锁
count++;
}
// ... 其他非同步操作 ...
}
// 也可以锁定 this 或 Class 对象
public void anotherAction() {
synchronized(this) {
// 效果等同于修饰实例方法
}
}
public static void staticAction() {
synchronized(MyCounter.class) {
// 效果等同于修饰静态方法
}
}
}
- 优点:
- 减小锁的粒度:只锁定必要的代码,而不是整个方法,可以显著提高并发性能。
- 明确锁对象:可以清晰地控制锁定的对象,避免使用
this这种可能被外部代码无意中锁定的对象,减少死锁风险。
3. 底层原理 —— monitorenter 与 monitorexit
synchronized 的实现依赖于 JVM 底层的 Monitor(监视器锁) 机制。
字节码层面:
- 对于同步代码块,JVM 会在编译后的字节码中插入
monitorenter和monitorexit两条指令。monitorenter在代码块开始处,尝试获取锁;monitorexit在代码块正常结束和异常结束时都会被插入,确保锁一定会被释放。 - 对于同步方法,JVM 会在方法的
access_flags中添加一个ACC_SYNCHRONIZED标志,当方法被调用时,JVM 会自动执行获取和释放锁的逻辑。
- 对于同步代码块,JVM 会在编译后的字节码中插入
对象与 Monitor 的关系:
- Java 中的任何对象都可以作为锁。
- 每个对象内部都与一个 Monitor 关联。当线程尝试获取这个对象的锁时,实际上是在尝试获取这个 Monitor 的所有权。
- Monitor 内部维护了几个关键部分:
_owner: 指向当前持有锁的线程。_EntryList: 一个等待队列,存放所有尝试获取锁但被阻塞的线程。_WaitSet: 一个等待队列,存放调用了该对象wait()方法的线程。_recursions: 一个计数器,用于支持锁的重入。
锁升级(JDK 1.6 后的优化)
为了提高性能,synchronized 在现代 JVM 中并不总是使用重量级的操作系统锁,而是实现了一个从低到高的锁升级过程。
偏向锁 (Biased Locking)
- 场景:锁大多数情况下由同一个线程获取,几乎没有竞争。
- 原理:当一个线程首次获取锁时,JVM 会在对象头(Object Header)中记录下这个线程的 ID,并将锁状态标记为“偏向”。之后,该线程再次进入同步块时,无需任何 CAS 操作或同步,直接检查线程 ID 即可,开销极低。
- 升级:如果另一个线程尝试获取这个锁,偏向模式就会被撤销,锁会升级为轻量级锁。
轻量级锁 (Lightweight Locking)
- 场景:存在少量线程竞争,且锁的持有时间非常短。
- 原理:线程尝试获取锁时,会使用自旋(Spinning)的方式,即在一个循环中不断尝试用 CAS(Compare-And-Swap)原子操作将对象头中的锁记录指向自己的线程栈。自旋不会让线程进入阻塞状态,避免了操作系统层面的线程上下文切换,如果能很快获得锁,效率很高。
- 升级:如果自旋一定次数(或一定时间)后仍未获取到锁,说明竞争激烈,锁会膨胀为重量级锁。
重量级锁 (Heavyweight Locking)
- 场景:存在激烈竞争,或锁的持有时间长。
- 原理:这是最传统的锁实现。锁会升级为真正的 Monitor,获取不到锁的线程会被阻塞(挂起),并放入
_EntryList队列中。这需要操作系统介入,进行线程的上下文切换,开销较大,但能很好地处理高并发场景。
4. synchronized 的关键特性
可重入性 (Reentrancy)
- 一个线程在持有某个对象的锁之后,可以再次进入任何其他使用该对象锁的同步代码块,而不会造成死锁。
- 原理:Monitor 内部有一个计数器 (
_recursions)。线程每获取一次锁,计数器加 1;每释放一次锁,计数器减 1。只有当计数器为 0 时,锁才真正被释放。
javapublic synchronized void methodA() { System.out.println("进入 methodA"); methodB(); // 同一个线程可以成功调用 methodB } public synchronized void methodB() { System.out.println("进入 methodB"); }不可中断性 (Non-interruptible)
- 一个线程在等待获取
synchronized锁的过程中,是不能被Thread.interrupt()方法中断的。它会一直等待下去,直到获取到锁为止。这一点与ReentrantLock不同。
- 一个线程在等待获取
非公平性 (Non-fair)
synchronized是一种非公平锁。当锁被释放时,JVM 允许新来的线程“插队”,直接尝试获取锁,而不是严格按照等待队列(_EntryList)的先进先出顺序。这可能会提高吞吐量,但可能导致某些线程长时间“饥饿”。
5. synchronized vs. ReentrantLock
| 特性 | synchronized |
ReentrantLock |
|---|---|---|
| 性质 | Java 关键字,由 JVM 实现 | Java API 类 (java.util.concurrent.locks.Lock) |
| 锁的释放 | 自动释放:代码块执行完毕或异常退出时,JVM 自动释放锁 | 手动释放:必须在 finally 块中调用 unlock() 方法,否则可能导致死锁 |
| 功能 | 基本的同步功能 | 功能更强大,提供更多高级特性 |
| 可中断 | 不可中断 | 可中断 (lockInterruptibly()) |
| 公平性 | 非公平锁 | 可选,可创建公平锁或非公平锁(默认) |
| 超时获取 | 不支持 | 支持 (tryLock(long, TimeUnit)) |
| 绑定条件 | 只能与一个条件(wait/notify)关联 |
可绑定多个 Condition 对象,实现精确唤醒 |
| 性能 | JDK 1.6 后性能大幅提升,与 ReentrantLock 在低竞争下相差无几 |
高竞争下,通过精细控制,性能可能更优 |
总结与最佳实践
首选
synchronized:在不需要ReentrantLock的高级特性(如可中断、超时、公平锁)时,优先使用synchronized。它的语法更简洁,不易出错(自动释放锁),且 JVM 的持续优化使其性能非常出色。减小锁粒度:尽量使用同步代码块,并且只包裹必要的、修改共享资源的代码,以提高并发能力。
使用专用锁对象:推荐使用
private final Object lock = new Object();作为锁对象,而不是this或字符串常量。这可以避免外部代码无意中获取你的锁,造成意外的干扰或死锁。理解锁的对象:清晰地知道你的代码锁住的是哪个对象(是
this实例,还是.class对象,或是某个自定义对象),这是正确使用synchronized的基础。
synchronized 是 Java 并发编程的基石,虽然现代并发包(JUC)提供了更多高级工具,但深刻理解 synchronized 的工作原理和适用场景,对于编写健壮、高效的并发程序至关重要。