Java并发编程: 探索synchronized的奥秘
synchronized 如何保证多线程的运行安全
synchronized
是 Java 内置的同步关键字,用于控制多线程对共享资源的访问。在并发编程中,它能确保线程安全,核心在于它保证了 原子性 和 可见性,并通过 happens-before 关系在锁的获取和释放之间提供了有限的有序性。
synchronized 的三大特性
原子性(Atomicity)
- 一个线程进入
synchronized
方法或代码块后,其他线程必须等待。 - 这样能确保被锁保护的操作要么完整执行,要么不执行,不会被打断。
可见性(Visibility)
- 当线程释放锁时,会把工作内存里的变量值刷新到主内存。
- 当另一个线程获取锁时,会从主内存中读取最新值。
- 保证了不同线程看到的共享变量状态是一致的。
有序性(Ordering)
synchronized
并不会禁止所有指令重排。- 但它保证了:锁的释放 happens-before 随后的同一锁的获取。
- 换句话说,线程 A 在释放锁前的操作,对线程 B 在获取锁后是可见的,并且不会被重排序到锁外。
因此,synchronized 的有序性是通过 内存屏障 + happens-before 规则间接保证的,而不是像 volatile
那样禁止指令重排。
synchronized 的原理
synchronized
的底层依赖于 对象头(Object Header)和 Monitor(管程) 。
-
每个 Java 对象在内存中都有对象头,其中的 Mark Word 记录了锁状态(无锁、偏向锁、轻量级锁、重量级锁)。
-
当线程进入
synchronized
代码块时,会执行加锁操作:- 通过 CAS 修改对象头,尝试将其标记为已锁
- 如果获取失败,则进入自旋或阻塞,等待锁释放
-
当线程退出同步块时,执行解锁操作,恢复对象头状态,并唤醒等待线程。
案例分析:共享计数器
public class SyncDemo {private int count = 0;public synchronized void increment() {count++;}public int getCount() {return count;}public static void main(String[] args) throws InterruptedException {SyncDemo demo = new SyncDemo();Runnable task = () -> {for (int i = 0; i < 1000; i++) {demo.increment();}};Thread t1 = new Thread(task);Thread t2 = new Thread(task);t1.start();t2.start();t1.join();t2.join();System.out.println(demo.getCount());}
}
上面代码是一个通过synchronized
关键字修饰后,具备线程安全的代码。
如果没有 synchronized
,count++
会分解成 3 步:
1. 读取 count
1. 执行加一
1. 写回主内存 多线程环境下可能交叉执行,导致结果小于 2000。
共享计数器未同步控制示例(数字演示)
这里取一次并发来阐述一下
假设初始 count = 0
,两个线程各自执行一次 count++
,具体交错情况如下:
时间点 | 线程 1 操作 | 线程 2 操作 | count 变化 | 说明 |
---|---|---|---|---|
t0 | — | — | 0 | 初始值 |
t1 | 读取 count = 0 | — | 0 | 线程 1 读到 count |
t2 | — | 读取 count = 0 | 0 | 线程 2 也读到 count |
t3 | 加一 → 1 | — | 0 | 线程 1 本地加一,未写回 |
t4 | — | 加一 → 1 | 0 | 线程 2 本地加一,未写回 |
t5 | 写回 1 | — | 1 | 线程 1 写回 count = 1 |
t6 | — | 写回 1 | 1 | 线程 2 写回 count = 1,覆盖线程 1 的修改 |
最终结果:count = 1
理论上两个操作应该把 count 加到 2,但因为 交错读写导致丢失更新,最终只有 1。
字节码层面的实现
synchronized
在字节码中有 两种形式:
修饰方法
public synchronized void increment() {count++;
}
字节码:
public synchronized void increment();descriptor: ()Vflags: ACC_PUBLIC, ACC_SYNCHRONIZEDCode:0: aload_01: dup2: getfield #2 // Field count:I5: iconst_16: iadd7: putfield #2 // Field count:I10: return
方法被标记为 ACC_SYNCHRONIZED
,JVM 会在调用时隐式执行加锁和解锁操作。
修饰代码块
public void add() {synchronized (this) {count++;}
}
字节码:
0: aload_0
1: dup
2: astore_1
3: monitorenter
4: aload_0
5: dup
6: getfield #2 // Field count:I
9: iconst_1
10: iadd
11: putfield #2 // Field count:I
14: aload_1
15: monitorexit
16: goto 24
19: astore_2
20: aload_1
21: monitorexit
22: aload_2
23: athrow
24: return
monitorenter
:获取锁monitorexit
:释放锁- 编译器生成异常处理块,保证异常退出时锁也被释放
对比总结
形式 | 字节码实现方式 | 适用范围 |
---|---|---|
方法同步 | ACC_SYNCHRONIZED 方法标记,由 JVM 隐式加锁/解锁 | 整个方法 |
代码块同步 | monitorenter / monitorexit | 方法内部的部分逻辑 |
ObjectMonitor 机制
您可以将 ObjectMonitor 想象成一个 守卫关键资源的城堡,它包含三个核心区域来管理进出的线程。
核心结构与状态变量
组件名称 | 类型 | 作用描述 |
---|---|---|
OwnerThread | 线程引用 | 当前持有锁的线程。 只要这个字段非空,就表示该 Monitor 处于被占用状态。 |
Entry Count | int | 记录锁的 重入次数 (Re-entry count)。只有当这个计数器减到 0 时,锁才会被完全释放。 |
Mutex | 操作系统互斥量 | 在锁升级到重量级锁后,Monitor 依赖它来实现线程的阻塞和唤醒。 |
线程的等待区域(三条队列)
线程根据其状态,会被 ObjectMonitor 放入不同的队列中。
EntryList / ContentionList(入口队列/竞争队列)
- 功能: 存放所有等待竞争锁的线程。
- 线程来源: 线程首次尝试进入
synchronized
块,但发现锁已被其他线程持有,就会被放入此队列等待。 - 状态: 线程处于阻塞或自旋等待中。
WaitSet / WaitQueue(等待队列)
- 功能: 存放调用了
wait()
方法 的线程。 - 线程特征: 线程进入此队列前,已经释放了锁(Monitor)。
- 状态: 线程处于等待状态,只有被
notify()
或notifyAll()
唤醒后,才会移动到 EntryList 去重新竞争锁。
OnDeck 线程(备选线程)
- 功能: 在某些 HotSpot 实现中,用于存放一个 即将被唤醒 的线程,作为下一个获取锁的候选者。
锁的生命周期流程
- 竞争锁: 线程 → 尝试获取 Monitor。
- 获取成功: 线程 → 成为 OwnerThread,Entry Count +1。
- 获取失败: 线程 → 被放入 EntryList 阻塞等待。
- 主动等待: OwnerThread 在同步块中调用
wait()
→ 释放锁,Entry Count 清零 → 线程移动到 WaitSet 阻塞。 - 被动唤醒: 其他线程调用
notify()
→ WaitSet 中的线程 → 移动到 EntryList → 等待重新竞争锁。
锁升级过程
从 Java 6 开始,synchronized
引入 锁升级机制 来优化性能,包括:
锁状态 | 描述 | 获取锁过程 |
---|---|---|
无锁状态 | 对象未被任何线程持有锁 | 无竞争,线程直接执行,无需加锁操作 |
偏向锁 | 假设绝大多数情况下锁只被单个线程持有 | 对象头记录偏向线程ID,线程访问无需同步开销 |
轻量级锁 | 偏向锁撤销或多个线程交替执行同步代码块 | 通过 CAS 修改对象头实现自旋锁,避免挂起线程 |
重量级锁 | 竞争激烈,轻量级锁自旋失败 | JVM 使用底层 OS 的互斥量(Mutex)阻塞线程 |
无锁状态:
对象刚开始时处于无锁状态,也就是没有任何线程持有该对象的锁。
偏向锁:
为了减少无竞争情况下的锁开销,JVM引入了偏向锁。当一个线程首次访问同步代码块时,它会在对象头和当前线程的栈帧中记录偏向的线程ID。这样,在后续的执行中,如果仍然是同一个线程访问该同步代码块,JVM就可以判断出来,并允许该线程无锁地执行同步代码。偏向锁实际上是一种延迟加锁的机制,它的目标是消除无竞争情况下的同步原语,进一步提高程序的运行性能。
但是,当有其他线程尝试获取这个偏向锁时,偏向锁就会撤销,并尝试升级为轻量级锁。
在 JDK 18 中,偏向锁被标记为废弃的原因主要是基于实际的使用情况和性能分析。偏向锁的设计初衷是在无竞争或低竞争的情况下提高性能,通过减少不必要的锁操作来降低开销。然而,在实际应用中,JVM 开发者发现偏向锁并不总是能够提供预期的性能提升,有时甚至会成为性能瓶颈。
以下是一些导致偏向锁被废弃的关键因素:
- 复杂性:偏向锁的实现相对复杂,需要维护额外的锁状态和线程信息。这种复杂性不仅增加了开发和维护的成本,还可能引入潜在的错误和性能问题。
- 适用性有限:偏向锁主要适用于长时间持有锁且竞争不激烈的场景。然而,在实际应用中,很多锁的使用模式并不符合这个假设。如果锁被频繁地获取和释放,或者存在高度的竞争,偏向锁的优势就会大打折扣。
- 性能开销:尽管偏向锁的设计初衷是为了提高性能,但在某些情况下,它可能会导致额外的性能开销。例如,当偏向锁被撤销并升级为轻量级锁或重量级锁时,需要进行额外的锁状态转换和线程调度操作,这些操作可能会消耗大量的CPU资源。
- 其他同步原语的改进:随着Java并发包的不断演进,出现了更多更高效的同步原语,如 ReentrantLock、StampedLock 等。这些同步原语提供了更细粒度的锁控制,能够更好地适应不同的并发场景,因此在某些情况下可能更优于偏向锁。
轻量级锁:
轻量级锁是为了减少线程阻塞而设计的。当偏向锁撤销后,或者多个线程交替执行同步代码块时,锁会升级为轻量级锁。轻量级锁的加锁过程是通过CAS操作实现的,它试图将对象头的Mark Word替换为指向线程栈帧中锁记录的指针。如果成功,则当前线程获得锁;如果失败,说明存在竞争,此时会尝试自旋等待,即让当前线程空转一段时间,然后再次尝试获取锁。
如果自旋等待达到一定的次数仍然没有获取到锁,那么轻量级锁就会升级为重量级锁。
重量级锁:
重量级锁是Java中最基础的锁机制,它的实现依赖于操作系统的互斥量(Mutex)。当轻量级锁无法满足性能需求时,会升级为重量级锁。此时,未获取到锁的线程会被阻塞,并进入等待状态,直到持有锁的线程释放锁。由于重量级锁涉及到用户态和内核态的切换,因此它的性能开销相对较大。
重量级锁的实现依赖于底层的 Monitor 机制。每个对象都有一个与之关联的 Monitor,当线程尝试获取重量级锁时,会被放入 Monitor 的入口等待队列中。如果获取锁失败,线程会被阻塞并放入等待队列,直到持有锁的线程释放锁。
偏向锁到重量级锁
假设对象初始为无锁状态,线程 T1、T2 交替执行同步方法:
- 无锁 → 偏向锁:T1 首次访问同步块,Mark Word 记录 T1 线程ID
- 偏向锁 → 轻量级锁:T2 尝试访问偏向锁,被撤销,两个线程自旋竞争
- 轻量级锁 → 重量级锁:自旋达到阈值仍未获取锁,升级为重量级锁,阻塞线程,直到锁释放
锁升级机制能在低竞争场景下消除同步开销,高竞争场景保证线程安全。
总结
synchronized
通过 对象头 + Monitor 实现锁机制- 保证了 原子性、可见性,并通过 happens-before 提供了有限的有序性
- 字节码层面表现为:方法同步 (
ACC_SYNCHRONIZED
) 和代码块同步 (monitorenter/monitorexit
) - 数字示例验证了 未加锁时多线程会丢失更新,加锁后
synchronized
可以保证线程安全 synchronized
关键字的最大好处是非常简单易用,但是也有很多局限性,比如对中断,加锁超时,多条件变量等支持不是很好,它仅仅支持非公平锁,很多时候,我们需要lock
来做更加丰富的操作。