目录
一、背景
二、Java线程模型
三、Synchronized实现原理
3.1 锁的使用
3.2 解释执行
3.3 JIT执行
3.4 锁的状态
3.5 monitorenter
3.5.1 偏向锁
3.5.2 轻量级锁
3.5.3 重量级锁
3.6 monitorexit
3.6.1 偏向锁
3.6.2 轻量级锁
3.6.3 重量级
四、可见性的真相
4.1 缓存一致性协议
4.2 根因分析
五、CAS实现原理
5.1 CAS介绍
5.2 CAS特性
5.3 CAS实现原理
六、Volatile
6.1 字节码
6.2 屏障类型
6.3 volatile修饰的写操作
6.4 volatile修饰的读操作
6.5 如何保证可见性
七、Reentrantlock实现原理
7.1 AQS
7.2 lock
7.3 unlock
7.4 CLH队列
7.5 与 synchronized 关键字的对比
7.5 可中断锁和超时锁
7.6 Condition
八、线程阻塞或唤醒时操作系统的动作
九、原子性、有序性、可见性的保证
一、背景
讲解一下在java中涉及到并发的相关基础知识,深入理解 synchronized, volatile, CAS, ReentrantLock 与内存可见性、原子性、有序性
二、Java线程模型
现代JVM(如HotSpot)默认采用此模型:每个Java线程直接绑定一个操作系统线程(内核线程)
-
优点:
-
利用多核CPU并行执行。
-
线程阻塞(如I/O)不影响其他线程。
-
-
缺点:
-
线程创建/销毁开销大(需OS介入)。
-
线程数量受限于OS(默认Linux约数千个)。
-
三、Synchronized实现原理
3.1 锁的使用
Java 使用synchronized关键字加锁,
Object lock = new Object();
synchronized (lock) {// 代码块
}
加上锁的代码会被编译成如下的字节码:
0: new #2 // 创建 Object 对象
3: dup
4: invokespecial #1 // 调用 Object 构造方法
7: astore_1 // 存储到局部变量 lock8: aload_1 // 加载 lock 到操作数栈
9: dup
10: astore_2 // 存储锁对象副本
11: monitorenter // 尝试获取锁
12: aload_2
13: monitorexit // 正常释放锁
14: goto 20
17: aload_2
18: monitorexit // 异常时释放锁 (确保锁被释放)
19: athrow
20: return
我们后面主要研究下 monitorenter 和 monitorexit 的底层原理,了解这两个字节码,我们首先要了解,它们是如何被java执行的
3.2 解释执行
“解释执行”的核心含义:
-
执行单元是单条字节码指令: 解释器(包括模板解释器)的基本工作单元是一条字节码指令。它逐条读取、解释(分派)并执行字节码指令。
-
没有预先的“完整编译”: 在执行一个 Java 方法之前,解释器不会把这个方法包含的所有字节码指令作为一个整体编译成一段完整的、连续的、优化过的本地机器码。它只为每条指令准备了模板。
-
边“解释”(分派)边执行: 执行过程始终伴随着一个分派循环:
-
取指: 从当前方法的字节码流中读取下一条指令的操作码。
-
分派: 根据这个操作码,查找或计算对应的机器码模板的入口地址。
-
跳转执行: 跳转到该模板地址,执行对应的机器码片段。
-
循环: 执行完这条指令的模板后,必须回到分派循环的开头,重复上述步骤处理下一条指令。
-
-
执行上下文依赖分派循环: 每条指令的机器码模板执行完毕后,控制权必须交还给解释器的分派循环。这个循环负责维护执行状态(如程序计数器 PC、栈指针等),并决定下一条要执行的指令是什么。模板本身通常不包含跳转到下一条指令的逻辑(除了像
goto
这种控制流指令,它们会直接修改 PC)。
3.3 JIT执行
-
JIT 编译: 当 HotSpot 发现某个方法是“热点”时,它的 JIT 编译器(如 C1, C2)会介入。
-
执行单元: 它将整个方法(或一个热点循环)作为一个单元。
-
过程: 读取该方法的所有相关字节码,进行复杂的静态分析和优化(如寄存器分配、死代码消除、循环展开、方法内联、逃逸分析等),最终生成一段完整的、连续的、高度优化的本地机器码。
-
执行: 当再次调用这个方法时,JVM 直接跳转到这段编译好的机器码的起始地址。这段机器码自己负责执行整个方法的逻辑,包括控制流(跳转、循环、调用)。它完全绕过了解释器的分派循环。执行过程中不再有“取指-分派”的开销,并且代码是优化过的。
-
-
关键区别: JIT 编译后执行的是一个完整优化后的代码块,而模板解释器执行的是一系列独立的、通过分派循环粘合起来的机器码片段。
通过上面的解释,我们大概能理解,java执行时是边解释字节码边执行的,而不是直接翻译成机器码文件。
java会把 monitorenter 和 monitorexit解释成相关的机器码,JVM执行时会跳到monitorenter 的机器码的位置,进行执行
例如对于monitorenter :
-
当解释器执行到
monitorenter
字节码时:-
分派循环跳转到
monitorenter
的机器码模板入口。 -
CPU 执行模板中的指令:准备好参数(对象引用),然后执行
call
指令。 -
CPU 跳转到
InterpreterRuntime::monitorenter
函数的机器码(这个函数本身是 C++ 写的,在 JVM 启动时已经被编译成了机器码)。 -
InterpreterRuntime::monitorenter
的机器码执行复杂的锁逻辑(可能调用更底层的ObjectMonitor::enter
等)。 -
InterpreterRuntime::monitorenter
函数执行完毕,通过ret
指令返回到调用它的地方——也就是monitorenter
模板中call
指令的下一条指令。 -
monitorenter
模板中剩余的指令(如果有)执行。 -
控制权返回到解释器的分派循环,准备执行下一条字节码。
-
下面来看下monitorenter和monitorexit的具体动作
3.4 锁的状态
📐 对象头组成及大小(以主流64位系统为例)
组成部分 | 大小(字节) | 说明 | 是否可变 |
---|---|---|---|
Mark Word | 8 | 存储对象哈希码、锁状态、GC年龄等信息 | ❌ 固定大小 |
Klass Pointer | 4(或8) | 指向类元数据的指针(默认指针压缩) | ✅ 可配置 |
数组长度(可选) | 4 | 仅数组对象存在(存储数组长度) | ❌ 固定大小 |
在不同锁状态下,保存内容如下所示
3.5 monitorenter
java8的锁粒度有 偏向锁、轻量级锁、重量级锁
3.5.1 偏向锁
-
检查对象头中的 Mark Word:
-
若可偏向(偏向模式位
1
,锁标志位01
)且 线程ID指向当前线程:直接进入同步块(无CAS)。 -
若可偏向但 线程ID不指向当前线程:触发偏向锁撤销(需全局安全点),升级为轻量级锁。
-
若未偏向:通过 CAS 将 Mark Word 的线程ID设置为当前线程。
-
撤销偏向锁过程:
-
触发条件
当线程A尝试获取偏向线程B的锁时(对象头Mark Word中的线程ID ≠ 当前线程ID),触发撤销。 -
暂停所有Java线程(STW)
-
JVM 触发 全局安全点(Safepoint),暂停所有Java线程(包括持有偏向锁的线程B和竞争线程A)。
-
关键原因:对象头的Mark Word和持有锁线程B的栈帧状态需被原子修改,避免并发冲突。
-
-
撤销操作(由JVM在安全点执行)
-
步骤1:检查持有偏向锁的线程B的状态:
-
若线程B 已退出同步块(无活跃锁):
-
直接重置对象头为 无锁状态(锁标志位
01
,偏向模式0
)。
-
-
若线程B 仍处于同步块中(活跃锁):
-
将锁升级为 轻量级锁:
-
在线程B的栈帧中生成锁记录(Lock Record),拷贝原Mark Word。
-
用CAS将对象头的Mark Word替换为指向该锁记录的指针(锁标志位
00
)。
-
-
若线程B已销毁:强制释放偏向锁。
-
-
-
-
恢复线程并升级竞争
-
安全点结束后,所有线程恢复执行。
-
竞争线程A重新尝试获取锁:
-
此时对象头已是轻量级锁状态(标志位
00
),线程A通过 CAS自旋 竞争轻量级锁。
-
-
3.5.2 轻量级锁
-
在栈帧中创建 锁记录(Lock Record),拷贝对象头的 Mark Word(称为 Displaced Mark Word)。
-
通过 CAS 将对象头的 Mark Word 替换为指向锁记录的指针(锁标志位
00
)。-
成功:获得锁。
-
失败(其他线程已占用):自旋重试;若自旋失败,升级为重量级锁。
-
cas操作的值如下:
-
目标内存地址:对象头的 Mark Word
-
预期值(Expected Value):对象原始的 Mark Word(从栈帧的锁记录中获取),对象原始的 Mark Word一般是偏向锁或者无锁状态
-
新值(New Value):指向当前线程栈帧中锁记录的指针 + 轻量级锁标志位
00
3.5.3 重量级锁
-
触发条件:
当线程尝试获取轻量级锁时,如果 CAS 失败(表示锁已被占用),线程会进入自旋重试状态。-
若自旋超过阈值仍失败(默认约 10-50 次,JVM 自适应调整)
-
或自旋期间有第三个线程加入竞争
-
参考图:
-
升级动作:
JVM 调用inflateLock()
方法,创建ObjectMonitor
对象(重量级锁结构),修改对象头标志位为10
Monitor 对象对象结构:
-
检查对象关联的 Monitor 对象(位于对象头指向的 ObjectMonitor)。
-
调用
ObjectMonitor::enter()
:-
若 Owner 为
null
:通过 CAS 设置 Owner 为当前线程。 -
若 Owner 是当前线程:重入计数
_recursions++
。 -
否则:线程进入 阻塞队列(cxq/EntryList),等待唤醒。
-
3.6 monitorexit
3.6.1 偏向锁
-
不修改对象头(保留偏向状态)。
-
仅检查线程ID是否匹配(防止错误解锁)。
3.6.2 轻量级锁
通过 CAS 将 Displaced Mark Word 写回对象头。
-
成功:锁释放。
-
失败:表明已升级为重量级锁,需走重量级锁释放流程
3.6.3 重量级
-
调用
ObjectMonitor::exit()
:-
重入计数
_recursions--
。 -
若
_recursions == 0
:清空 Owner,唤醒阻塞队列中的线程。
-
重量级锁变化举例:
四、可见性的真相
4.1 缓存一致性协议
多线程并发修改变量时会有可见性问题
虽然我们有缓存一致性协议,具体如下图,但它只能保证最终一致性,而不能保证中间过程的一致性:
4.2 根因分析
已知:
当CPU修改共享变量时:
-
使其他CPU的缓存行失效(I状态)
-
修改自己的缓存(M状态)
-
最终写回主内存
分析:
原因1:写缓冲(Store Buffer)
现代CPU使用写缓冲优化性能:
plaintext
CPU-A 操作流程: 1. b的新值存入写缓冲 ← 立即继续执行后续指令 2. 异步将写缓冲刷新到缓存(此时才触发MESI)
在步骤1→2的间隙中:
-
CPU-C读取b时,由于失效请求未到达,可能仍读取自己的旧缓存
-
即使CPU-A认为"修改已完成",实际修改仍在缓冲中未提交
原因2:失效队列(Invalidation Queue)
plaintext
CPU-C 操作流程: 1. 收到b的失效请求 → 存入失效队列 2. 继续使用本地缓存(直到处理失效队列) ← 关键延迟点! 3. 后续读取才从主内存重新加载
在步骤1→3期间,CPU-C仍可能使用已失效的缓存值。
原因3:指令重排:
// 共享变量
int a = 0;
boolean flag = false; // 注意:非volatile!// 线程1(核心1执行)
a = 42; // 语句1
flag = true; // 语句2// 线程2(核心2执行)
while(!flag); // 语句3
System.out.println(a); // 可能输出0!
具体如何解决可见性问题,我们后面分析
五、CAS实现原理
5.1 CAS介绍
CAS(Compare-And-Swap) 是一种基于硬件的原子操作,用于实现无锁(lock-free)并发编程。它是 Java 并发包(java.util.concurrent
)中原子类(如 AtomicInteger
、AtomicReference
等)的核心实现机制。
CAS 操作包含三个参数:
-
内存地址
V
(需要更新的变量) -
期望值
A
(变量当前应具有的值) -
新值
B
(需要设置的新值)
操作逻辑:
java
if (V == A) {V = B; // 更新值return true; // 成功 } else {return false; // 失败 }
整个过程由硬件(CPU 指令)保证原子性,不会被线程调度打断。
5.2 CAS特性
-
原子性(Atomicity)
-
操作不可分割,要么完全执行成功,要么完全不执行。
-
由底层 CPU 指令(如 x86 的
CMPXCHG
)直接支持,无需锁。
-
-
无锁(Lock-Free)
-
线程通过循环重试(自旋)更新数据,避免阻塞。
-
示例代码:
java
public final int incrementAndGet(AtomicInteger atomicInt) {int prev, next;do {prev = atomicInt.get(); // 当前值next = prev + 1; // 新值} while (!atomicInt.compareAndSet(prev, next)); // CAS 失败则重试return next; }
-
-
可见性(Visibility)
-
CAS 操作隐含
volatile
语义,确保修改对其他线程立即可见。
-
-
避免死锁
-
无锁机制天然规避了死锁风险。
-
5.3 CAS实现原理
由底层 CPU 硬件指令直接保证的
-
x86/x86-64 架构
-
指令:
LOCK CMPXCHG
-
CMPXCHG
(Compare and Exchange)是基础指令 -
LOCK
前缀(汇编指令前缀)强制独占内存访问,确保原子性 -
工作流程:
assembly
; 伪汇编代码 LOCK CMPXCHG [memory], reg ; [memory]为内存地址,reg为新值 ; 比较 EAX(隐含寄存器)与 [memory] 的值 ; 相等 → 将 reg 存入 [memory],并设置 ZF=1 ; 不等 → 将 [memory] 加载到 EAX,并设置 ZF=0
-
-
-
总线锁定(Bus Locking)
-
LOCK
前缀使 CPU 在执行期间锁定内存总线 -
阻止其他核心/CPU 访问同一内存区域
-
注意:CPU 在成功将缓存行标记为“独占”(Exclusive)状态之前,必须确保它拥有该缓存行的最新数据副本。如果它发现数据不是最新的,它就无法成功获得独占状态,操作会失败或需要重试。
-
缓存一致性协议(如 MESI)
-
现代 CPU 通过缓存锁定代替总线锁定
-
当 CPU 检测到
CMPXCHG
操作时:-
将缓存行标记为"独占"状态
-
若其他核心尝试修改,会使其缓存行失效
-
确保只有一个核心能成功修改
-
-
-
内存顺序模型支持
-
指令隐含内存屏障(Memory Barrier)
-
保证操作前后的内存可见性顺序
-
六、Volatile
6.1 字节码
代码示例:
public class Test {volatile int v;
}
字节码:Field access_flags: 0x0040 (ACC_VOLATILE)
对于 v = 42
(volatile 写),字节码只有一条简单指令:
java
putfield #4 // 将值 42 写入字段 v(#4 是常量池索引)
关键点:
-
字节码 没有显式的屏障指令
-
JVM 通过字段的
ACC_VOLATILE
标志识别需要特殊处理
6.2 屏障类型
JVM 通过内存屏障实现 volatile
的语义,屏障类型如下:
屏障类型 | 作用 |
---|---|
LoadLoad | 确保当前读操作在后续读操作之前完成。 |
StoreStore | 确保当前写操作在后续写操作之前完成(刷新到主内存)。 |
LoadStore | 确保当前读操作在后续写操作之前完成。 |
StoreLoad | 全能屏障:确保当前写操作对所有处理器可见后才执行后续读/写操作。 |
volatile
读写操作的屏障插入规则:
-
volatile
写操作:-
写操作前:
StoreStore
屏障(防止与前面的普通写重排序)。 -
写操作后:
StoreLoad
屏障(防止与后面的volatile
读/写重排序)。
java
StoreStore Barrier v = 42; // volatile 写 StoreLoad Barrier
-
-
volatile
读操作:-
读操作前:
LoadLoad
+LoadStore
屏障(防止与后续操作重排序)。 -
读操作后:无额外屏障(某些架构下合并到前面)。
java
int tmp = v; // volatile 读 LoadLoad Barrier + LoadStore Barrier
-
具体对应的CPU指令不写了,过程有点复杂,大概率了解一下吧
屏障类型较多,理解起来也有点费劲,我直接说结果,即有了这些屏障后,程序的运行结果是怎样的
6.3 volatile修饰的写操作
// 第一阶段:准备操作(普通读写) 普通操作1 普通操作2 ... 普通操作N// StoreStore 屏障(隐形防线) volatile 写 = 新值; // 关键操作// StoreLoad 屏障(隐形防线) 后续操作1 后续操作2 ... 后续操作M
1. 屏障前的保证(前置防护)
-
✅ 所有 volatile 之前的操作都已计算完成
(包括普通读/写、方法调用等) -
✅ 所有普通写操作结果全局可见
(通过 StoreStore 屏障保证) -
❌ 绝不允许 volatile 之后的操作重排到前面
(StoreLoad 屏障阻止后续任何操作前移)
2. 屏障后的保证(后置防护)
-
✅ volatile 写本身全局可见
(通过 StoreLoad 屏障强制刷新) -
❌ 绝不允许 volatile 之前的操作重排到后面
(StoreStore 屏障阻止前面操作后移)
6.4 volatile修饰的读操作
// 前置操作区(可能被重排到此)
普通操作A
普通操作B
// 墙入口
int tmp = v; // volatile读
// 隐形的双屏障防线
LoadLoad Barrier
LoadStore Barrier
// 后置保护区
后续操作1
后续操作2
1. 墙的入口(读操作本身)
-
✅ 强制获取最新值:
使当前 CPU 缓存失效,从主内存加载最新值 -
⚠️ 不限制前面操作:
允许墙前的普通操作重排到墙后(与写操作关键区别!)
6.5 作用总结
一、volatile
写操作的作用
当线程执行 volatile
写操作(如 volatileVar = 42;
)时:
-
可见性保证
-
✅ 确保该写操作完成后,所有线程都能立即看到这个新值。
-
🔧 底层机制:强制刷新 CPU 写缓冲区,将新值同步到主内存,并通过缓存一致性协议(如 MESI)使其他 CPU 的缓存副本失效。
-
-
有序性保证
-
⛔ 禁止指令重排序:
-
禁止将
volatile
写之前的任何操作 重排序到写操作之后。 -
禁止将
volatile
写之后的读操作 重排序到写操作之前。
-
-
🔧 底层机制:插入
StoreStore
+StoreLoad
内存屏障(如 x86 的lock
指令)。
-
二、volatile
读操作的作用
当线程执行 volatile
读操作(如 int val = volatileVar;
)时:
-
可见性保证
-
✅ 确保读取到的是最新值(可能是其他线程刚写入的值)。
-
🔧 底层机制:强制从主内存或其他 CPU 重新加载数据(跳过本地可能过期的缓存)。
-
-
有序性保证
-
⛔ 禁止指令重排序:
-
禁止将
volatile
读之后的任何操作 重排序到读操作之前。 -
禁止将
volatile
读之前的写操作 重排序到读操作之后。
-
-
🔧 底层机制:插入
LoadLoad
+LoadStore
内存屏障(如 ARM 的dmb
指令)。
-
6.6 x86 CPU实现
以下是几种常见架构上,为了实现 volatile
语义,HotSpot JVM 通常使用的屏障和对应的 CPU 指令:
-
-
x86/x86-64:
-
volatile
写:-
屏障要求: StoreStore 屏障 + StoreLoad 屏障
-
实际指令:
lock addl $0x0, (%rsp)
(或其他类似指令)-
lock
前缀:这是 x86 上实现强内存屏障效果的关键。它锁定内存总线(或使用缓存一致性协议如 MESI),确保该指令的操作具有原子性,并隐式包含了 StoreLoad 屏障。它还会刷新写缓冲区。 -
addl $0x0, (%rsp)
:这是一个对栈顶指针%rsp
指向的内存地址加 0 的空操作。它本身不改变数据,目的是提供一个让lock
前缀作用的目标指令。 -
为什么不需要显式 StoreStore? x86 架构的 TSO (Total Store Order) 内存模型本身保证了写操作(包括非 volatile 写)不会重排序。因此,在
volatile
写之前插入的 StoreStore 屏障在 x86 上通常是 空操作(nop
)。
-
-
-
volatile
读:-
屏障要求: LoadLoad 屏障 + LoadStore 屏障
-
实际指令: 通常没有显式的屏障指令!
-
原因: x86 的 TSO 模型天然保证了:
-
LoadLoad 不会重排序(后面的读能看到前面的读的结果)。
-
LoadStore 不会重排序(后面的写不会重排序到前面的读之前)。
-
-
因此,对于
volatile
读,JVM 在 x86 上通常只需要生成普通的mov
指令来加载值,而不需要插入任何显式的屏障指令。volatile
读本身的内存语义(如缓存行失效)由 CPU 的缓存一致性协议(MESI)自动处理。
-
-
-
总结 (x86):
-
volatile
写:lock addl $0x0, (%rsp)
(或等效指令) -> 主要提供 StoreLoad 屏障。 -
volatile
读:普通的mov
指令 -> 屏障是空操作。
-
-
-
Q:那lock addl $0x0, (%rsp) 指令和 修改 volatile值的mov指令 不是原子的,岂不是会造成值修改了,但是不可见的情况?
lock addl $0x0, (%rsp)
的核心作用是:
-
排空写缓冲区
强制当前 CPU 的写缓冲区中所有数据(包括之前的mov
)立即刷到缓存/内存。 -
使其他 CPU 的缓存失效
通过缓存一致性协议(MESI)广播,使其他 CPU 中该 volatile 变量的缓存行失效。 -
StoreLoad 屏障
确保后续读操作必须重新从主存加载最新值。
假设 mov
执行后、lock
执行前发生中断:
x86asm
mov [var], eax ; 值进入写缓冲区 ; <-- 此处发生中断(写缓冲区未刷新) lock addl $0x0, (%rsp) ; 中断返回后执行
-
问题:其他 CPU 在中断期间可能读到旧值。
-
解答:x86 的中断处理机制保证:
-
中断返回前会隐式排空写缓冲区(类似
sfence
)。 -
中断结束后执行
lock
指令,再次强制刷新并失效化缓存。
-
-
结果:最终仍保证可见性(可能略有延迟,但符合 Java 语义)。
七、Reentrantlock实现原理
7.1 AQS
Reentrantlock基于AQS实现,AQS介绍
-
状态变量 (
state
):-
一个
volatile int
类型的变量,表示锁的状态。 -
对于
ReentrantLock
:-
state = 0
: 锁未被任何线程持有。 -
state > 0
: 锁已被某个线程持有。数值表示该线程重入锁的次数(同一个线程多次获取锁)。
-
-
-
CLH 队列 (FIFO 线程等待队列):
-
一个双向链表(或变体)实现的等待队列。
-
当多个线程竞争锁失败时,它们会被构造成
Node
节点,并加入到这个队列尾部排队等待。 -
队列中的线程会以 FIFO(先进先出)的顺序被唤醒(公平模式下严格 FIFO,非公平模式下可能插队)。
-
7.2 lock
-
-
尝试获取 (
tryAcquire
):-
线程首先尝试通过 CAS 操作将
state
从 0 改为 1。 -
成功 (state 原来是 0):
-
设置当前线程为锁的独占所有者 (
exclusiveOwnerThread
)。 -
返回 true,获取锁成功。
-
-
失败 (state > 0):
-
检查当前线程是否已经是锁的持有者 (
exclusiveOwnerThread == currentThread
)。 -
如果是,则将
state
加 1(重入计数增加),返回 true。 -
如果不是,返回 false。
-
-
-
加入队列等待 (
acquireQueued
):-
如果
tryAcquire
失败(返回 false),线程会将自己包装成一个Node
节点。 -
使用 CAS 操作将节点安全地加入到 CLH 队列的尾部。
-
进入自旋或阻塞状态:
-
检查自己是否是队列中第一个有效等待节点(头节点的后继)。
-
如果是,再次尝试
tryAcquire
(非公平锁总是尝试一次;公平锁严格排队)。 -
如果还不是第一个节点或尝试失败:
-
检查前驱节点的状态,判断是否需要阻塞自己。
-
调用
LockSupport.park(this)
将当前线程挂起(阻塞)。
-
-
线程被唤醒(通常是前驱节点释放锁时唤醒它)后,会再次尝试获取锁。
-
-
-
7.3 unlock
-
尝试释放 (
tryRelease
):-
检查当前线程是否是锁的持有者(
exclusiveOwnerThread == currentThread
),否则抛出IllegalMonitorStateException
。 -
将
state
减 1(表示减少一次重入计数)。 -
如果
state
减到 0:-
将
exclusiveOwnerThread
设置为null
,表示锁完全释放。 -
返回 true。
-
-
如果
state
仍然大于 0(说明还有重入),返回 false。
-
-
唤醒后继 (
unparkSuccessor
):-
如果
tryRelease
返回 true(锁完全释放),则找到 CLH 队列中第一个状态正常的等待节点(通常是头节点的后继)。 -
调用
LockSupport.unpark(s.thread)
唤醒该节点对应的线程,使其有机会去竞争锁。
-
相关代码:
7.4 CLH队列
前面加锁和解锁过程都使用了到了CLH队列,下面具体介绍一下什么是CLH队列,以及在Reentrantlock中做了哪些优化
-
核心思想: 一个隐式链接的 FIFO 队列,用于管理等待锁(或共享资源)的线程。每个等待线程被封装成一个节点(Node)。
-
关键机制: 每个节点通过一个
volatile
状态字段(通常是waitStatus
) 来轮询(spin) 其前驱节点(predecessor node) 的状态。 -
核心操作:
-
入队(获取锁失败时): 新线程尝试获取锁失败后,会创建一个新节点,通过
CAS
(Compare-And-Swap) 操作将自身设置为新的tail
(尾指针),同时记录下它入队时看到的尾节点作为其前驱节点(prev)。 -
等待(自旋轮询前驱): 线程在一个循环中不断检查其前驱节点的状态标志(
waitStatus
)。如果前驱节点的状态表明它已经释放了锁(或即将释放),那么当前线程就有资格尝试获取锁。关键点:线程只关心它前面的那个节点(前驱)的状态。 -
出队(获取锁成功): 当线程检测到前驱节点释放了锁(状态变为
SIGNAL
或类似),它成功获取锁,并成为新的队列头(head
)。原头节点通常被移除或成为虚拟头节点。 -
释放锁(通知后继): 当持有锁的线程(队列头节点)释放锁时,它会检查其后继节点(
next
)的状态。如果后继节点在等待(状态为SIGNAL
),它会修改自身的状态(例如,设置waitStatus = 0
或直接清除状态)或直接设置后继节点的状态(在 AQS 变体中通常是设置头节点状态为SIGNAL
后由后继轮询),这个状态变化会被其后继节点(正在轮询前驱状态)立即感知(volatile
保证可见性),从而唤醒后继节点去尝试获取锁。
-
-
原始 CLH 特点(纯自旋):
-
FIFO 公平性。
-
线程在 CPU 上忙等(自旋)其前驱的状态变化。
-
锁释放仅需修改一个
volatile
变量(自身状态),通知是无锁且高效的。 -
避免了“惊群效应”(只唤醒一个后继)。
-
AQS中如何使用?
-
显式双向链表:
-
AQS 节点不仅维护指向前驱节点(
prev
) 的指针(用于轮询状态和取消时移除),还维护指向后继节点(next
) 的指针。这不是原始 CLH 必需的(原始 CLH 通常只有隐式前驱链),但大大简化了节点取消(Cancellation)(中断、超时)时的链表操作。
-
-
虚拟头节点(Dummy Head):
-
AQS 队列初始化时通常会创建一个不关联任何线程的虚拟头节点。第一个真正等待的线程节点会成为虚拟头节点的后继。这使得队列操作(如判断是否有等待线程、唤醒后继)逻辑更统一,避免边界条件判断。
-
-
状态整合(
waitStatus
):-
AQS 节点的
waitStatus
字段承载了比原始 CLH 状态标志更丰富的含义:-
SIGNAL(-1)
: 最重要的状态。 表示该节点的后继节点需要被唤醒(即,后继节点在等待)。当前节点释放锁或取消时,必须唤醒其后继节点。节点在阻塞自己之前,通常会将其前驱节点的waitStatus
设置为SIGNAL
(通过CAS
),这样前驱节点就知道“我后面有人等着呢,你释放时记得叫我”。 -
CANCELLED(1)
: 节点关联的线程已取消等待(如超时或中断)。需要从队列中安全移除。 -
CONDITION(-2)
: 节点当前在条件队列(ConditionObject
)中等待,而不是在主同步队列中。 -
PROPAGATE(-3)
: 仅用于共享模式。 表示下一次acquireShared
操作应该无条件传播(表示后续节点也可能可以获取共享资源)。 -
0
: 初始状态,或表示节点不处于上述任何特殊状态。
-
-
-
阻塞代替自旋(关键优化):
-
这是 AQS CLH 变体最核心的改进! 原始 CLH 是纯自旋(忙等),消耗 CPU。
-
AQS 中,线程不会持续自旋轮询前驱状态。其流程是:
-
a) 尝试获取锁。
-
b) 失败,创建节点并入队(设置
tail
,链接prev
)。 -
c) 快速自旋检查前驱状态: 在一个循环中快速检查几次:
-
如果前驱是头节点(说明快轮到自己了),再次尝试获取锁(防止不必要的阻塞)。
-
如果前驱节点的
waitStatus == SIGNAL
,说明前驱已设置好“释放时唤醒我”的标志,安全地阻塞自己(调用LockSupport.park()
)。 -
如果前驱状态是
CANCELLED
,则跳过该前驱,继续找更前面的有效前驱。 -
如果前驱状态正常但不是
SIGNAL
,则尝试用CAS
将前驱的waitStatus
设置为SIGNAL
(告诉它“你释放时记得叫我”)。
-
-
d) 如果步骤 c 中的检查表明可以安全阻塞了(前驱是
SIGNAL
),则调用park()
挂起当前线程。
-
-
唤醒: 当持有锁的线程(头节点)释放锁时,它会检查头节点的
waitStatus
。如果是SIGNAL
(通常都会是,因为后继在阻塞前设置了它),它会找到其后继节点(next
),并调用LockSupport.unpark(successor.thread)
唤醒其后继线程。被唤醒的线程从park()
处返回,回到步骤 c 的循环中,再次尝试获取锁。
-
-
入队(
enq
)与设置前驱状态(shouldParkAfterFailedAcquire
):-
入队操作 (
enq
) 使用CAS
保证线程安全地更新tail
。 -
shouldParkAfterFailedAcquire
方法实现了步骤 c 中的逻辑:检查/清理前驱状态,确保前驱是有效的且waitStatus == SIGNAL
,然后才决定调用park()
。
-
7.5 与 synchronized
关键字的对比
-
相似点:都是可重入互斥锁。
-
ReentrantLock 优势:
-
灵活性:支持公平锁/非公平锁选择、可中断锁等待 (
lockInterruptibly
)、超时锁等待 (tryLock(timeout)
)、多条件变量 (newCondition
)。 -
API 化:显式的
lock()
和unlock()
操作,控制更精细。 -
性能:在高度竞争的场景下,现代 JVM 的
synchronized
优化(锁升级)已经非常高效,两者性能差距不大。但在某些特定场景(如大量读少量写),ReentrantLock 结合ReadWriteLock
可能更优。
-
-
synchronized 优势:
-
简洁性:语法简单,由 JVM 自动管理锁的获取和释放(在 synchronized 代码块结束时释放),不易出错(避免忘记
unlock
)。 -
JVM 优化:JVM 深度优化(偏向锁、轻量级锁、锁消除、锁粗化等)。
-
下面说一下reentrantlock实现的
7.5 可中断锁和超时锁
可中断锁:
源码:
private void doAcquireInterruptibly(int arg) throws InterruptedException {final Node node = addWaiter(Node.EXCLUSIVE); // 将当前线程包装为独占模式Node加入队列尾部boolean failed = true;try {for (;;) { // 自旋等待final Node p = node.predecessor(); // 获取前驱节点if (p == head && tryAcquire(arg)) { // 如果前驱是头节点(轮到我了)且尝试获取锁成功setHead(node); // 将自己设为新的头节点(出队)p.next = null; // help GCfailed = false;return; // 成功获取锁,退出方法}if (shouldParkAfterFailedAcquire(p, node) && // 检查是否应该阻塞(前驱状态正常)parkAndCheckInterrupt()) // 4. 真正阻塞线程,并在此处检查中断!throw new InterruptedException(); // 5. 如果阻塞中被中断,抛出异常!}} finally {if (failed)cancelAcquire(node); // 6. 如果最终失败(如因中断),取消节点获取状态}
}private final boolean parkAndCheckInterrupt() {LockSupport.park(this); // 使用LockSupport.park()挂起当前线程return Thread.interrupted(); // 线程被唤醒后,立即检查并清除中断标志,返回是否因中断被唤醒
}
当线程判断需要阻塞时(通过 shouldParkAfterFailedAcquire
),调用 LockSupport.park(this)
挂起当前线程
-
线程可能被以下三种方式唤醒:
-
持有锁的线程释放锁,并唤醒队列中的后继节点(
unparkSuccessor
)。 -
其他线程调用了该线程的
interrupt()
方法。 -
虚假唤醒(
spurious wakeup
)。
-
-
线程被唤醒后,第一件事就是调用
Thread.interrupted()
。这个方法做两件事:-
返回线程当前的中断状态(
true
表示被中断过)。 -
清除线程的中断状态(设为
false
)。
-
超时锁:
final boolean doAcquireNanos(int arg, long nanosTimeout) {if (nanosTimeout <= 0L) return false; // 时间已到,直接失败final long deadline = System.nanoTime() + nanosTimeout;final Node node = addWaiter(Node.EXCLUSIVE); // 加入队列boolean failed = true;try {for (;;) {final Node p = node.predecessor();if (p == head && tryAcquire(arg)) { // 如果是队首则尝试获取锁setHead(node);p.next = null; // 帮助GCfailed = false;return true;}nanosTimeout = deadline - System.nanoTime();if (nanosTimeout <= 0L) return false; // 超时检查if (shouldParkAfterFailedAcquire(p, node) && nanosTimeout > spinForTimeoutThreshold) { // 大于阈值才挂起LockSupport.parkNanos(this, nanosTimeout); // 关键:挂起指定时间}if (Thread.interrupted()) // 响应中断throw new InterruptedException();}} finally {if (failed) cancelAcquire(node); // 失败则取消节点}
}
其实可以看到 无论是可中断锁还是超时锁,它们都使用了LockSupport这个对象来加锁解锁,没有采用synchronized这种操作,而LockSupport支持锁中断,支持锁超时机制,所以这就是reetrantlock能实现这些多功能锁的原因了
7.6 Condition
基本使用:
Lock lock = new ReentrantLock();
Condition condition = lock.newCondition();// 等待方
lock.lock();
try {while (!conditionSatisfied) { // 循环检查条件condition.await(); // 释放锁并等待}// 执行条件满足后的操作
} finally {lock.unlock();
}// 通知方
lock.lock();
try {// 改变条件condition.signal(); // 或 signalAll()
} finally {lock.unlock();
}
实现原理简要介绍:
Condition
的核心实现基于 AQS(AbstractQueuedSynchronizer),讲解Condition时会涉及两个队列,一个是同步队列,就是AQS存储线程的队列,一个是条件队列,指的是condition对象对应的队列,以下是关键原理:
-
等待队列(条件队列)
-
每个
Condition
对象内部维护一个 FIFO 等待队列(单向链表)。 -
当线程调用
await()
时,会释放锁、修改对应lock锁的状态、并进入条件队列等待。 -
-
队列节点类型与 AQS 同步队列相同(
Node
类)。
-
-
节点转移机制
-
调用
signal()
时,将条件队列的头节点移动到 AQS 同步队列尾部。正好对应上了上面代码的isOnSyncQueue -
-
移动到同步队列的节点会尝试获取锁,成功后从
await()
返回。
-
-
唤醒与阻塞控制
-
await()
:释放锁 → 阻塞线程 → 加入条件队列。 -
signal()
:将条件队列的首节点移入同步队列,唤醒线程。 -
signalAll()
:移动条件队列所有节点到同步队列。
-
-
避免虚假唤醒
通过循环检查条件(while (condition) await()
)确保线程被唤醒时条件真正满足。
八、线程阻塞或唤醒时操作系统的动作
1. 用户态尝试获取锁
-
步骤:
-
线程在用户态通过 CAS(Compare-And-Swap)自旋尝试获取锁(例如
synchronized
的偏向锁/轻量级锁)。 -
若锁未被竞争(无冲突),直接获取成功,全程在用户态完成。
-
-
关键点:
此时无系统调用,不涉及内核态切换,性能极高。
2. 竞争失败:进入自适应自旋
-
步骤:
-
当 CAS 失败(锁被其他线程占用),JVM 启动自适应自旋(根据历史成功率动态调整自旋次数)。
-
线程在用户态循环重试,仍不切内核态。
-
-
关键点:
自旋避免了立即陷入内核,但消耗 CPU 时间。
3. 自旋失败:真正阻塞(切内核态)
-
步骤:
-
自旋仍无法获取锁时,JVM 调用底层操作系统的线程阻塞原语(如 Linux 的
futex()
)。 -
线程状态从
RUNNABLE
变为BLOCKED
。 -
触发系统调用(如
futex
)→ 从用户态陷入内核态。
-
-
关键点:
此处是用户态切内核态的核心节点!
4. 内核态操作
-
内核完成以下动作:
-
保存线程上下文(寄存器、程序计数器等)。
-
将线程移入锁的等待队列(由内核管理)。
-
触发线程调度:从就绪队列选择新线程运行。
-
切换 CPU 上下文到新线程。
-
-
关键点:
所有操作均在内核态完成,需要特权指令。
5. 唤醒阶段(再次切内核态)
-
当锁释放时:
-
持有锁的线程调用
unlock()
,触发 JVM 的唤醒机制。 -
JVM 通过
futex_wake()
系统调用陷入内核态。 -
内核从等待队列中移出一个/多个线程,标记为就绪状态。
-
线程重新参与调度,下次被 CPU 选中时恢复执行。
-
-
关键点:
唤醒操作同样需切内核态,恢复线程时需切换回用户态。
一点操作系统知识,大概了解一下:
上面使用到了futex,操作系统将所有等待同一 futex(相同 uaddr)的线程被组织在同一个等待队列中
1. uaddr 的本质
-
uaddr 是用户空间的一个内存地址(32位整数),通常指向:
-
锁状态变量(如 Java 对象头中的 MarkWord)
-
条件变量标志位
-
信号量计数器
-
-
关键特性:
相同 uaddr 值 → 代表同一同步资源(如同一把锁)
-
一对一映射:
每个唯一的 uaddr 值对应 一个且仅一个 内核等待队列 -
自动创建:
当首个线程在某个 uaddr 上调用futex_wait()
时,内核自动创建队列 -
自动销毁:
当队列为空(无等待线程)时,内核自动销毁队列
// 每个 futex 等待队列
struct futex_queue {struct list_head threads; // 线程链表(FIFO)u32 *uaddr; // 绑定的用户态地址atomic_t waiters; // 等待线程计数
};
九、原子性、有序性、可见性的保证
特性 | 保障方法 |
---|---|
原子性 | synchronized 、ReentrantLock 、原子类(AtomicInteger 等)、CAS |
可见性 | volatile 、synchronized /锁、final 、CAS |
有序性 | volatile (禁止重排序)、synchronized /锁(内存屏障)、fina 、CAS |