并发编程(三)
一、synchronized:JVM内置锁的底层原理
1.1 同步代码块与monitor机制
synchronized可以修饰方法或代码块,其底层依赖JVM的monitor(监视器)实现。当我们对一个代码块加锁时:
synchronized (lock) {
// 临界区代码
}
编译后会在字节码中插入monitorenter和monitorexit指令——前者在进入同步块时执行,后者在正常或异常退出时执行。即使临界区抛出异常,monitorexit也会被触发,从而释放锁。这是一个重要的面试点:抛异常不会导致锁一直占用,避免死锁。
底层实现细节:
synchronized在字节码层面通过两个指令实现:
- monitorenter:进入同步块,尝试获取对象监视器(monitor)的所有权
- monitorexit:退出同步块,释放对象监视器的所有权
编译器会自动在同步代码块前后插入这两个指令。为了保证异常时也能释放锁,编译器会生成一个异常表,在异常发生时自动执行monitorexit。
对象监视器(Monitor)原理:
Monitor是操作系统提供的同步原语,在HotSpot中由ObjectMonitor实现,其核心结构如下:
ObjectMonitor {
_owner // 持有当前锁的线程指针
_count // 锁计数器(支持可重入性)
_waiters // 等待队列(调用wait()的线程)
_entryList // 入口队列(竞争锁失败的线程)
_recursions // 重入次数
}
执行流程:
- 线程执行monitorenter时,检查_count是否为0
- 若为0,将_owner设为当前线程,_count加1
- 若不为0且_owner是当前线程,_count加1(可重入)
- 若不为0且_owner不是当前线程,线程进入_entryList阻塞
- 执行monitorexit时,_count减1,减到0时释放锁并唤醒_entryList中的线程
synchronized的三大核心作用:
- 原子性:保证临界区代码的原子执行,防止多线程交错执行导致的数据不一致
- 可见性:根据JMM规范,锁释放时会将工作内存数据刷新到主内存,锁获取时会清空工作内存并从主内存重新加载
- 有序性:禁止临界区内部代码与外部代码的指令重排序
三种使用方式及锁对象:
| 使用方式 | 锁对象 | 作用范围 |
|---|---|---|
| 修饰实例方法 | this(当前实例对象) | 同一实例的所有同步方法互斥 |
| 修饰静态方法 | 类的Class对象 | 该类所有实例的静态同步方法互斥 |
| 修饰代码块 | 括号内指定的对象 | 同一锁对象的所有同步代码块互斥 |
1.2 锁存放在哪里?——对象头
Java中每个对象都有一个对象头(Header),其中Mark Word区域记录了锁的相关信息。在32位HotSpot虚拟机中,对象在内存中分为三部分:对象头、实例数据、对齐填充。其中对象头是synchronized锁机制的核心载体。
对象头结构(32位):
| 长度 | 内容 | 说明 |
|---|---|---|
| 32bit | Mark Word | 存储对象的哈希码、GC分代年龄、锁状态标志等 |
| 32bit | Klass Pointer | 指向对象所属类的元数据的指针 |
| 可选 | 数组长度 | 只有数组对象才有,占32bit |
Mark Word在不同锁状态下的位图结构(64位):
| 锁状态 | 存储内容 | 锁标志位 |
|---|---|---|
| 无锁 | unused:25|age:4|biased_lock:0 | 01 |
| 偏向锁 | threadId:54|epoch:2|age:4|biased_lock:1 | 01 |
| 轻量级锁 | ptr_to_lock_record:62 | 00 |
| 重量级锁 | ptr_to_monitor:62 | 10 |
Mark Word是一个动态的数据结构,会根据对象的锁状态复用存储空间。当Mark Word的最后两位的锁标志位是10时,表示处于重量级锁的模式,此时Mark Word记录的是monitor的地址。
小知识:两个不同对象的hashcode有可能相同,但对象头中除了hashcode,还存储了分代年龄、锁标记等。
1.3 锁的升级(膨胀)过程 —— 必考!
synchronized在JDK 1.6之后做了重大优化,引入了锁升级机制,且升级不可逆(偏向锁→轻量级锁→重量级锁)。
完整锁状态转换图:
无锁 → 偏向锁 → 轻量级锁 → 重量级锁(不可降级)
各锁状态详解:
| 锁状态 | 适用场景 | 原理简述 | 优缺点 |
|---|---|---|---|
| 偏向锁 | 只有一个线程反复获取锁 | 在Mark Word中记录线程ID,该线程再次进入时无需任何同步操作 | 优点:开销几乎为0。缺点:一旦有第二个线程竞争,立即膨胀,且需要执行偏向锁撤销(STW) |
| 轻量级锁 | 少量线程交替持有锁 | 通过CAS尝试将对象头的Mark Word复制到线程栈的Lock Record中,成功则获取锁;失败则自旋等待 | 优点:线程不用阻塞,响应快。缺点:自旋会消耗CPU,不适合大量线程竞争 |
| 重量级锁 | 多线程激烈竞争 | 竞争失败的线程进入阻塞队列,由操作系统内核完成线程调度 | 优点:不浪费CPU。缺点:线程挂起和唤醒的切换成本高(用户态↔内核态) |
重量级锁的注意点:重量级锁通过对象内部的监视器(monitor)实现,其中monitor的本质是依赖于底层操作系统的Mutex Lock实现,操作系统实现线程之间的切换需要从用户态到内核态的切换,切换成本非常高。
🔥 重要更新:偏向锁在现代JDK中的变化
JDK 15+默认禁用偏向锁,JDK 17+彻底移除偏向锁逻辑。现代JVM(JDK 17~21)重点优化的是轻量级锁的自适应自旋。
为什么?偏向锁撤销的成本远高于其带来的收益:
- 偏向锁撤销需要在全局安全点(Safepoint)执行,会导致Stop-The-World(STW)停顿
- 一次撤销需要停顿所有线程,遍历所有线程栈帧查找锁记录,时间复杂度为O(N×M)
- 在高并发场景下,频繁的锁撤销可能导致毫秒级延迟,对延迟敏感服务不可接受
如果使用JDK 8-14且遇到因偏向锁撤销导致的性能问题,可以通过启动参数手动禁用:
-XX:-UseBiasedLocking
为什么锁升级不可逆? 因为一旦出现过竞争,JVM认为后续竞争的概率依然存在,反向降级会引入复杂度且收益很小。
1.4 锁的优点与缺点
优点:
- 使用简单,无需手动释放(JVM自动完成)
- JVM层面做了大量优化(锁消除、锁粗化、自适应自旋)
- 与JVM调度完美整合,不会出现像ReentrantLock那样的忘记unlock
缺点:
- 重量级锁下性能差于显式Lock
- 锁升级过程不可控,不能像ReentrantLock那样尝试加锁(tryLock)
- 偏向锁在某些场景下撤销会引发STW(JDK 15+已解决)
二、CAS:无锁并发的基石
2.1 什么是CAS?
CAS(Compare And Swap)是一种原子操作,需要调用操作系统内核函数才能实现真正的原子性。它包含三个参数:内存地址V,期望值A,新值B。只有当V的值等于A时,才将V更新为B,整个过程不可被中断(CPU指令级别保证)。
// 伪代码示意
boolean compareAndSwap(V, A, B) {
if (V == A) {
V = B;
return true;
}
return false;
}
CAS是一种"乐观锁"的实现:区别于synchronized等悲观锁的"先加锁再操作"思路,CAS采用"先验证再更新"的无锁方式,在低并发场景下大幅降低线程调度与上下文切换的开销。
CAS操作的内存语义:CAS操作同时具备volatile的读写内存语义,保证变量的可见性与禁止指令重排序。
CPU层面的原子性实现:
CAS的原子性本质是CPU硬件层面的指令支持。以主流X86_64架构为例,CAS的核心是CMPXCHG指令(比较并交换指令),但该指令本身不具备多核原子性,必须搭配LOCK前缀才能实现多核环境下的原子操作。
LOCK前缀的核心作用:
- 锁定操作对应的内存地址的缓存行(基于MESI协议),避免多核CPU同时修改该内存地址
- 禁止该指令与前后的读写指令重排序
- 刷新写缓冲区,保证操作结果对所有CPU核心立即可见
极端情况下(操作数据跨缓存行时),LOCK前缀会降级为总线锁,保证操作的原子性,但性能开销会显著提升。
2.2 代码演示:CAS实现线程安全计数器
Java代码示例
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
public class Counter {
private AtomicInteger atomicI = new AtomicInteger(0);
private int i = 0;
public static void main(String[] args) {
final Counter cas = new Counter();
List<Thread> ts = new ArrayList<>(600);
long start = System.currentTimeMillis();
// 启动100个线程,每个对普通int和AtomicInteger各累加10000次
for (int j = 0; j < 100; j++) {
Thread t = new Thread(() -> {
for (int k = 0; k < 10000; k++) {
cas.count();
cas.safeCount();
}
});
ts.add(t);
}
for (Thread t : ts) t.start();
for (Thread t : ts) {
try { t.join(); } catch (InterruptedException e) { e.printStackTrace(); }
}
System.out.println("非线程安全结果: " + cas.i); // 大概率小于1000000
System.out.println("CAS安全结果: " + cas.atomicI.get()); // 总是1000000
System.out.println("耗时(ms): " + (System.currentTimeMillis() - start));
}
/** 使用CAS实现线程安全计数器 */
private void safeCount() {
for (;;) {
int cur = atomicI.get();
boolean suc = atomicI.compareAndSet(cur, cur + 1);
if (suc) break;
}
}
/** 非线程安全计数器 */
private void count() { i++; }
}
讲解
普通i++不是原子操作(读-改-写三步),多线程下结果丢失。而CAS通过compareAndSet在循环中不断尝试,直到成功。这种方式避免了互斥锁的阻塞唤醒,但激烈竞争时CPU空转严重——此时反而不如重量级锁。
Unsafe类中CAS方法的底层实现
Unsafe提供了三个核心的CAS方法,均为native方法,直接调用底层CPU指令:
public final native boolean compareAndSwapObject(Object o, long offset, Object expected, Object x);
public final native boolean compareAndSwapInt(Object o, long offset, int expected, int x);
public final native boolean compareAndSwapLong(Object o, long offset, long expected, long x);
参数说明
- o:要操作的对象
- offset:要操作的字段在对象中的内存偏移量
- expected:预期值
- x:新值
获取Unsafe实例
import sun.misc.Unsafe;
import java.lang.reflect.Field;
public class UnsafeUtil {
private static Unsafe unsafe;
static {
try {
Field theUnsafeField = Unsafe.class.getDeclaredField("theUnsafe");
theUnsafeField.setAccessible(true);
unsafe = (Unsafe) theUnsafeField.get(null);
} catch (Exception e) {
throw new RuntimeException("Failed to get Unsafe instance", e);
}
}
public static Unsafe getUnsafe() { return unsafe; }
}
2.3 CAS的三大原子问题及解决方案
(1)ABA问题
描述:线程1将值从A改为B再改回A;线程2看到值还是A,CAS成功,但中间状态已被改变过。
影响:某些场景(如栈顶指针、链表头)可能造成错误。例如,在线程安全的栈结构中,如果栈顶指针经历了多次压栈和弹栈操作后回到原来的值,CAS可能会错误地认为栈状态没有变化。
解决方案:使用版本号/标记位。AtomicStampedReference同时保存引用和版本戳,每次更新同时递增版本号。
完整示例
import java.util.concurrent.atomic.AtomicStampedReference;
public class ABADemo {
private static AtomicStampedReference<Integer> atomicStampedRef =
new AtomicStampedReference<>(100, 0);
public static void main(String[] args) {
Thread threadA = new Thread(() -> {
int[] stampHolder = new int[1];
Integer oldValue = atomicStampedRef.get(stampHolder);
int oldStamp = stampHolder[0];
try {
Thread.sleep(1000); // 模拟线程A在读取后等待一段时间
} catch (InterruptedException e) {
e.printStackTrace();
}
// 版本号必须匹配才能更新,由于线程B已经改变了版本号,这里会返回false
boolean updated = atomicStampedRef.compareAndSet(oldValue, 200, oldStamp, oldStamp + 1);
System.out.println("Thread A: Updated: " + updated);
});
Thread threadB = new Thread(() -> {
int[] stampHolder = new int[1];
Integer oldValue = atomicStampedRef.get(stampHolder);
int oldStamp = stampHolder[0];
// 将值从100改为150,版本号+1
atomicStampedRef.compareAndSet(oldValue, 150, oldStamp, oldStamp + 1);
// 将值从150改回100,版本号再+1(此时版本号已经是+2了)
atomicStampedRef.compareAndSet(150, oldValue, oldStamp + 1, oldStamp + 2);
});
threadA.start();
threadB.start();
}
}
在这个示例中,线程A和线程B同时操作atomicStampedRef。线程B先将值从100改为150,然后再改回100。由于使用了AtomicStampedReference,线程A在比较并设置时会检查引用和标记是否都匹配,因此能够检测到这个变化并避免ABA问题。
AtomicStampedReference.compareAndSet有四个参数:预期引用、更新后的引用、预期标志、更新后的标志。正是通过引入整数标记(stamp)来解决ABA问题。
(2)循环时间长开销大
自旋CAS如果长时间不成功,会大量消耗CPU。
解决方案:
- 设置自旋次数上限
- 退化为互斥锁(JVM自适应自旋)
- 使用JVM的自适应自旋优化——JVM会根据历史竞争情况动态调整自旋次数
(3)只能保证一个共享变量的原子操作
解决方案:
- 将多个变量封装成一个对象,使用AtomicReference对该对象进行CAS
- 使用锁(synchronized或ReentrantLock)
2.4 总线锁与缓存锁
处理器实现原子操作依赖两种锁机制:
- 总线锁:使用LOCK#信号,锁定整个总线,其他处理器无法访问内存。代价高。
- 缓存锁:如果内存地址被缓存在CPU的缓存行中,且该缓存行处于独占状态(MESI协议的M状态),CPU可以直接操作缓存行,无需锁住总线。缓存锁的开销远小于总线锁,是现代CPU的主流实现方式。
以下两种情形不会被缓存锁定:
- 操作的数据跨越多个缓存行(无法被单个缓存行覆盖)
- 处理器不支持缓存锁定(老式CPU自动降级为总线锁)
三、内存可见性与指令重排序
3.1 为什么会看到"过期的"数据?
每个CPU核心都有自己的高速缓存(L1/L2/L3缓存),主存(RAM)与CPU之间通过缓存一致性协议(如MESI)进行同步。一个线程对变量的修改可能暂时停留在缓存中,没有刷新到主内存,导致另一个线程看不到修改。这就是内存可见性问题。
现代多核处理器系统中,每个CPU核心都有自己的高速缓存,在没有内存模型约束的情况下,编译器可能会对指令进行重排序以优化性能,CPU也可能会乱序执行。这导致在多线程环境下,一个线程对变量的修改可能对其他线程不可见,或者指令执行顺序出乎意料。
代码示例:可见性问题
public class VisibilityDemo {
private static boolean flag = true;
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
while (flag) { }
System.out.println("Thread exit");
}).start();
Thread.sleep(1000);
flag = false; // 主线程修改flag
}
}
由于没有同步机制,JVM可能将while(flag)优化为if(!flag) while(true),导致子线程永远看不到flag的更新。
3.2 指令重排序
现代CPU采用流水线技术提高指令吞吐量,但会带来重排序问题。从源码到最终执行,可能经历:
源代码 → 编译器重排序 → 指令级并行重排序 → 内存系统重排序 → 最终指令序列
final关键字可以阻止部分重排序(构造函数返回前保证初始化完成),但范围有限。
3.3 happens-before规则
Java内存模型的核心是happens-before关系。如果两个操作之间存在happens-before关系,那么第一个操作的结果对第二个操作可见,且第一个操作的执行顺序排在第二个操作之前。
八大happens-before规则:
| 规则 | 说明 |
|---|---|
| 程序顺序规则 | 同一个线程中,书写在前面的操作happens-before后面的操作 |
| 监视器锁规则 | 对一个锁的解锁happens-before随后对同一个锁的加锁 |
| volatile变量规则 | 对一个volatile变量的写happens-before随后对该变量的读 |
| 传递性 | 如果A happens-before B,且B happens-before C,则A happens-before C |
| 线程启动规则 | 线程的start()方法happens-before该线程内的任何动作 |
| 线程终止规则 | 线程内的任何动作happens-before其他线程检测到该线程终止 |
| 中断规则 | 对线程的interrupt()调用happens-before被中断线程检测到中断 |
| finalizer规则 | 对象的构造函数执行结束happens-before其finalize()方法开始 |
3.4 内存屏障
JMM通过插入内存屏障(LoadLoad、StoreStore等)来禁止特定类型的重排序。volatile和synchronized底层都会插入内存屏障,从而保证可见性和有序性。
四种内存屏障类型:
| 屏障类型 | 作用 |
|---|---|
| LoadLoad | 禁止处理器把上面的volatile读与下面的普通读重排序 |
| LoadStore | 禁止处理器把上面的volatile读与下面的普通写重排序 |
| StoreStore | 禁止处理器把上面的volatile写与下面的普通写重排序 |
| StoreLoad | 禁止处理器把上面的volatile写与下面的读/写重排序,并强制刷新缓存 |
JMM在volatile写操作前后插入屏障的策略:
在每个volatile写操作的前面插入一个StoreStore屏障
在每个volatile写操作的后面插入一个StoreLoad屏障
JMM在volatile读操作后面插入屏障的策略:
在每个volatile读操作的后面插入一个LoadLoad屏障
在每个volatile读操作的后面插入一个LoadStore屏障
在x86架构中,JMM仅需在volatile写后面插入一个StoreLoad屏障即可正确实现volatile写-读的内存语义。这意味着在x86处理器中,volatile写的开销比volatile读的开销会大很多(因为执行StoreLoad屏障开销会比较大)。
3.5 volatile关键字详解
volatile关键字是Java中最轻量级的同步机制,它保证了对volatile变量的读写具有可见性——写入volatile变量的值立即对其他线程可见。
volatile的局限性:
不保证原子性:复合操作如count++在volatile变量上仍然不是线程安全的
只适用于以下场景:写入变量不依赖于当前值(如设置标志位)、该变量不与其他状态变量共同构成不变量
与C++ volatile的区别:Java的volatile不用于硬件映射变量,而是专门用于多线程通信。
volatile vs synchronized:
| 特性 | volatile | synchronized |
|---|---|---|
| 原子性 | 不保证 | 保证 |
| 可见性 | 保证 | 保证 |
| 有序性 | 保证 | 保证 |
| 互斥性 | 无 | 有 |
| 性能开销 | 低 | 相对较高 |
四、线程间通信方式
| 方式 | 原理 | Java示例 |
|---|---|---|
| 共享内存 | 线程读写同一块内存(堆中的变量) | volatile、synchronized、CAS |
| 消息传递 | 线程间显式发送消息/事件 | wait/notify、BlockingQueue、管道流 |
所有实例域、静态域、数组元素都存储在堆内存中,堆是线程共享的。JMM决定一个线程对共享变量的写入何时对其他线程可见。
五、final关键字的内存语义
final字段在Java内存模型中有特殊语义。在构造函数中正确初始化后的final字段,在其他线程中可见时一定处于已初始化状态,无需同步。这为不可变对象的安全发布提供了保证。
注意:如果final字段指向一个可变对象,该对象内部的状态仍然需要额外同步。
从内存模型的角度来看,JMM规定了final域的内存语义:在构造函数内对一个final域的写入,与随后将该对象引用赋值给另一个引用变量之间,存在happens-before关系。
六、死锁的排查与预防
6.1 什么是死锁?
最常见死锁模式:线程A先锁accountA再锁accountB,线程B反过来先锁accountB再锁accountA。只要调度稍有交错,就会卡死。
6.2 死锁排查方法
使用jstack命令:
# 查看进程ID
jps
# 导出线程堆栈(关键:必须加 -l 参数才显示锁信息)
jstack -l <pid>
# 如果输出末尾有 "Found 1 deadlock." 即确认死锁
jstack -l输出末尾如果有"Found 1 deadlock."就是实锤;它会列出锁持有者与等待者地址,匹配locked <0x...>和waiting to lock <0x...>即可定位。
关键分析方法:
重点盯住java.lang.Thread.State: BLOCKED (on object monitor)和Locked ownable synchronizers
注意看locked <0x0000000712345678>和waiting to lock <0x0000000712345678>的十六进制地址是否匹配
jstack死锁定位示例:
# 采样三次对比,找出卡住的线程
jstack <pid> > jstack_1.txt
sleep 5
jstack <pid> > jstack_2.txt
sleep 5
jstack <pid> > jstack_3.txt
# 查看锁的卡顿情况
cat jstack_1.txt | grep "waiting to lock" | sort | uniq -c
实用分析脚本:
#!/bin/bash
# jstack_analyzer.sh
PID=$1
LOOPS=${2:-3}
INTERVAL=${3:-5}
echo "分析进程 PID: $PID"
for i in $(seq 1 $LOOPS); do
echo "第 $i 次采样..."
jstack "$PID" > "jstack_${PID}_$i.txt"
sleep $INTERVAL
done
# 统计线程状态分布
for file in jstack_${PID}_*.txt; do
echo "=== $file ==="
grep "java.lang.Thread.State:" "$file" | sort | uniq -c
echo "锁等待情况:"
grep "waiting to lock" "$file" | sort | uniq -c | sort -nr | head -5
done
6.3 死锁预防策略
统一加锁顺序
long id1 = accountA.getId();
long id2 = accountB.getId();
if (id1 < id2) {
synchronized (accountA) {
synchronized (accountB) {
// 转账操作
}
}
} else {
synchronized (accountB) {
synchronized (accountA) {
// 转账操作
}
}
}
使用tryLock超时机制
ReentrantLock lockA = new ReentrantLock();
ReentrantLock lockB = new ReentrantLock();
if (lockA.tryLock(100, TimeUnit.MILLISECONDS)) {
try {
if (lockB.tryLock(100, TimeUnit.MILLISECONDS)) {
try {
// 临界区代码
} finally {
lockB.unlock();
}
}
} finally {
lockA.unlock();
}
}
避免在同步块内调用外部方法——万一那个方法内部又去抢另一把锁,你根本控制不了顺序。
降低锁粒度,减少持锁时间
七、总结
7.1 8个高频问题解答
-
synchronized抛异常会释放锁吗?
→ 会,monitorexit指令保证释放,且编译器会生成异常表在异常发生时自动执行monitorexit。 -
锁升级可以降级吗?
→ 不能,不可逆。一旦出现过竞争,JVM认为后续竞争的概率依然存在,反向降级会引入复杂度且收益很小。 -
CAS一定比锁快吗?
→ 不一定。低竞争时快,高竞争时自旋空转,不如重量级锁。 -
如何解决ABA?
→ AtomicStampedReference(使用版本号)或AtomicMarkableReference(使用布尔标记)。 -
volatile能保证原子性吗?
→ 不能,只能保证可见性和有序性。复合操作如count++在volatile变量上仍然不是线程安全的。 -
JDK 15+为什么默认禁用偏向锁?
→ 因为偏向锁撤销需全局安全点(STW),导致毫秒级延迟;现代应用多为短生命周期对象+高并发,偏向锁收益为负。
7.2 ReentrantLock和synchronized的区别?
| 特性 | synchronized | ReentrantLock |
|---|---|---|
| 实现层面 | JVM内置 | Java代码层面 |
| 锁获取方式 | 自动 | lock()/tryLock() |
| 锁释放 | JVM自动释放 | 必须手动unlock() |
| 中断响应 | 不支持 | 支持 |
| 公平锁 | 非公平 | 可选公平/非公平 |
| 条件变量 | 单一wait/notify | 多个Condition |
| 性能(低竞争) | 较好 | 较好 |
| 性能(高竞争) | 重量级锁下较差 | 较好 |
7.3 什么是伪共享(False Sharing)?如何避免?
当多个线程访问位于同一缓存行的不同变量时,每次写入都会导致整个缓存行失效,造成性能下降。解决方案:使用缓存行填充(@Contended注解)或确保相关变量对齐到不同缓存行。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)