在 Java 并发世界中,当并发冲突的概率变高、涉及多个变量的复合操作时,我们就需要从无锁方案跨入有锁的硬核控制区。本篇将深入底层源码与架构设计,带你透彻拆解从操作系统级的悲观锁 synchronized,到 JUC 框架的绝对基石 AQS,再到应对各种复杂工程场景的高阶锁工具库。
一、 悲观锁巅峰:Synchronized 锁升级与底层优化
synchronized 是 Java 老牌的关键字,秉持 "悲观态度" (先加锁,再操作)。JVM 为了对它进行救赎,在其底层设计与运行期进行了一系列极其精妙的改动。
1. 锁的物理存储:对象头与 MarkWord
Java 中每个对象在堆内存中都有一个 对象头(Object Header) ,其核心区域称为 MarkWord。
- 锁的本质 :MarkWord 负责存储对象的运行时数据(哈希码、GC 分代年龄等),其中包含了极其重要的锁标志位。
- 机制:JVM 所有的"锁升级"动作,本质上就是在修改这个 MarkWord 里的锁标志位和记录的线程/锁指针。
2. 重量级锁的内核机制 (Monitor)
在 JDK 1.6 之前,synchronized 只有"重量级锁"一种形态:
-
字节码层面 :编译后对应
monitorenter(加锁进入临界区)和monitorexit(解锁离开临界区)两条指令。 -
JVM 层面(核心) :每个锁对象都关联着一个 Monitor(监视器) ,内部包含三个核心逻辑区:
Owner:当前成功抢到锁、持有锁的线程。EntryList:抢锁失败,处于被动等待、陷入阻塞状态的线程队列。WaitSet:调用了obj.wait()后,主动让出锁并进入无限等待状态的线程队列,需等待obj.notify()唤醒。
-
系统层面(性能瓶颈) :Monitor 底层强依赖操作系统的互斥量(
Mutex Lock)。动用它意味着线程必须经历从用户态到内核态的上下文切换,开销极大,因而性能极差。
3. 不可逆的锁升级过程
为了避免动辄呼叫操作系统的极高开销,JDK 1.6 引入了锁升级机制(状态只能升级,通常不可逆):
| 锁状态 | 适用竞争场景 | 底层动作与原理 | 设计思想 |
|---|---|---|---|
| 无锁 | 对象刚创建 | MarkWord 处于初始状态。 | 尚无竞争,无需保护。 |
| 偏向锁 | 无竞争 单线程反复重入 | 首次进入时,JVM 在 MarkWord 贴上当前线程ID。后续该线程再来,比对 ID 一致直接放行。 | 假设永远没有竞争,彻底省去加锁/解锁的 CAS 开销。 |
| 轻量级锁 | 轻微竞争 少量线程交替执行 | 出现竞争,偏向锁撤销。通过 CAS 尝试将 MarkWord 复制到线程栈并指向自己。失败的线程原地自旋(空跑 CPU)尝试抢锁。 | 假设很快就能拿到锁,宁愿耗费 CPU 自旋,也不去 OS 排队阻塞(避免用户态/内核态切换)。 |
| 重量级锁 | 激烈竞争 多线程高并发抢锁 | CAS 自旋次数过多,发生锁膨胀 。直接动用 Monitor 和操作系统的 Mutex,抢锁失败的线程直接陷入阻塞状态,让出 CPU。 | 既然实在抢不到,自旋只会白白浪费 CPU,不如直接让线程休眠阻塞。 |
4. JVM 对 Synchronized 的锁层面优化
- 锁消除 :JIT 编译器在编译时,通过逃逸分析 如果发现某个锁对象是局部变量,绝不可能被其他线程访问到,就会在编译时强行把这个锁消除掉(如局部变量里的
StringBuffer.append)。 - 锁粗化:如果 JVM 探测到一连串零碎的操作都在对同一个对象反复加锁解锁(如在循环体内加锁),就会把锁的范围扩大到整个操作序列的外部,合并成一把大锁,避免频繁申请锁的开销。
5. 锁静态方法与普通方法的本质区别
- 锁普通方法 :锁住的是当前实例对象
this(对象级别锁),互不相同的实例之间不冲突。 - 锁静态方法 :锁住的是当前类的
Class对象(类级别锁),该类的所有实例对象共用一把锁,全部互斥。
二、 宏观管控基石:AQS (AbstractQueuedSynchronizer) 深度解密
CAS 只是微观的抢占状态动作。当大量线程抢锁失败、需要宏观的排队与精准唤醒时,JUC 的核心总调度官 AQS 闪亮登场。它是 ReentrantLock、Semaphore、CountDownLatch 的共同底层底座。
1. AQS 核心设计:双剑合璧
AQS 内部通过两个极其精妙的组件配合运转:
-
volatile int state(同步状态)使用
volatile保证多线程内存可见性。线程通过 CAS 操作去原子性地修改state,修改成功即代表拿到了锁/资源。- 独占模式(如 ReentrantLock) :
0为空闲,1为被占用,>1表示同一个线程的重入次数。 - 共享模式(如 CountDownLatch) :表示剩余的可用资源计数。
- 独占模式(如 ReentrantLock) :
-
CLH FIFO 双向队列 (等待队列)
抢锁失败的线程会被封装成一个 Node 节点,通过 CAS + 自旋安全地插入到双向链表的队尾。
- Node 核心属性
waitStatus:当其变为SIGNAL (-1)时,表示当前节点对应的线程已经挂起(通过LockSupport.park()实现),且它的前驱节点在释放锁时,有义务将其唤醒。
- Node 核心属性
2. AQS 的灵魂:模板方法模式 (Template Method)
AQS 框架把复杂的线程排队、阻塞挂起、出队、高并发下节点安全插入等一整套宏观流程写死在了父类的方法中(如 acquire()、release())。它把如何定义资源、如何尝试抢占资源的微观逻辑(tryAcquire()、tryRelease())以保护方法的形式留给子类去重写实现。
3. ReentrantLock 源码级加解锁生命周期(非公平锁视角)
① 加锁阶段 (lock())
- 初始暴力抢占 :线程一进来,不管三七二十一,直接调用
compareAndSetState(0, 1)发起 CAS 抢锁。抢到了就把ExclusiveOwnerThread设为自己。 - 逻辑尝试 (
tryAcquire) :如果第一步没抢到,检查state。若是当前锁的持有者就是自己 ,则触发可重入机制 ,执行state + 1并直接放行;若不是自己,抢锁宣告失败。 - 安全入队 (
addWaiter) :抢锁失败的线程被包装成 Node 节点,通过 CAS 自旋尾插法安全地送入 FIFO 双向链表的末尾排队。 - 阻塞前的挣扎 (
acquireQueued) :节点入队后,会检查自己的前驱是不是 Head 头节点 。如果是,则做最后一次tryAcquire挣扎抢锁;如果仍抢不到,则将前驱的状态强行改为SIGNAL (-1),随后调用LockSupport.park(this)强行挂起休眠,让出 CPU。
② 解锁阶段 (unlock())
- 状态递减 (
tryRelease) :当前持有锁的线程调用unlock(),内部将state减 1。 - 彻底释放判断 :由于支持重入,只有当
state减到0的时候,才会彻底清空锁的占有者ExclusiveOwnerThread = null,宣告锁彻底空闲。 - 唤醒后继 (
unparkSuccessor) :锁释放后,头节点 Head 负责牵头,找到队列中第一个有效等待的 Node 节点,调用LockSupport.unpark(node.thread)精准将其唤醒,重新起来抢锁。
4. 公平锁 vs 非公平锁的分水岭:hasQueuedPredecessors()
- 非公平锁 (
NonfairSync) :极其霸道。线程一上来直接 CAS 暴力抢锁,抢不到再去排队。优点是能充分利用线程唤醒的时间差让新来的线程直接把活干了,吞吐量极大。 - 公平锁 (
FairSync) :严格讲究先来后到。它的tryAcquire源码里多了一行标志性的核心判断:hasQueuedPredecessors()。该方法会检查: "当前排队的队列里,我前面是不是还有人在排队?" 如果有人在排队,公平锁会强行放弃抢占,乖乖去队尾排队。其缺点是会引发高频的线程上下文切换,性能大幅下滑。
三、 JUC 锁体系全景图:从基础控制到极限压榨
Java 并发包在不同场景演进下,衍生出了四种经典的锁控制方案:
1. 四大锁机制全景对比表
| 对比维度 | synchronized | ReentrantLock | ReentrantReadWriteLock | StampedLock |
|---|---|---|---|---|
| 锁的本质 | 独占锁 / 悲观锁 | 独占锁 / 悲观锁 | 读写分离(读共享,写独占) | 读写分离 + 乐观读机制 |
| 实现层面 | JVM 关键字(基于 Monitor) | JDK API 层(基于 AQS) | JDK API 层(基于 AQS) | JDK API 层(非 AQS 架构) |
| 释放方式 | 隐式自动释放 | 必须 在 finally 中手动 unlock() |
必须手动显式释放 | 必须手动释放(凭邮戳 Stamp 释放) |
| 公平性 | 仅支持非公平锁 | 支持公平与非公平 | 支持公平与非公平 | 仅支持非公平锁 |
| 功能扩展 | 简单,具备锁升级优化 | 支持可中断、可超时、支持多条件变量 Condition | 针对"读多写少"高频读取场景优化 | 引入乐观读,极限压榨读取性能 |
2. 逐一击破:核心定位与优劣
-
ReentrantReadWriteLock的致命痛点:写饥饿虽然读写锁实现了"读读共享、读写互斥、写写互斥",极大提升了读取吞吐量。但是,如果线上有源源不断、铺天盖地的读请求疯狂涌入,读锁就一直被占用且无法释放,导致后台的写请求线程只能被迫无限期阻塞罚站,最终被 "饿死" 。
-
StampedLock的极限压榨:乐观读 (Optimistic Read)为了彻底干掉"写饥饿",
StampedLock横空出世。它在读数据的时候,根本不加任何真正的锁!而是直接返回一个版本号邮戳(Stamp)。线程全程无阻碍地盲读数据,读完之后,通过调用
validate(stamp)校验一下在刚才盲读的期间,有没有写线程动过数据。如果没人动过,全程无锁执行,性能无敌;如果发现数据被写动了,它才会认命,降级为传统的悲观读锁重新读取,完美消除了写饥饿。
3. 🛡️ 工业级工程实战:锁机制的"降级选择法则"
- 常规首选 :90% 的普通业务场景,直接无脑用
synchronized!代码最清爽,JVM 自带锁升级,绝无漏掉释放锁而引发死锁的风险。 - 高级控制 :当需要实现超时控制 (
tryLock)、响应中断、或者需要利用多条件变量Condition实现类似"奇偶数精准交替唤醒"的高阶逻辑时,换成ReentrantLock。 - 读多写少 :类似商品详情页、配置中心本地缓存读取,换用
ReentrantReadWriteLock。 - 极限压榨 :在框架底层底层、核心中间件中,读请求占比高达 99% 且无法容忍写饥饿的极端压榨场景,才去考虑引入
StampedLock。
四、 并发三剑客:多线程高级协同指挥棒
除了互斥抢锁外,JUC 基于 AQS 的 共享模式 封装了三个应对复杂业务流的顶级协作 API。
1. CountDownLatch (倒计时器 / 一等多)
-
核心作用:让主线程(或某个等待线程)陷入阻塞,死等 N 个子线程并发执行完毕后才能继续往下走。常用于并行加载多源数据(如拼装电商详情页:并发查商品、查库存、查营销,最后合并返回)。
-
🚨 工业级标准防漏模板
使用
CountDownLatch时,务必将countDown()扣减计数的操作放在子线程的finally代码块中 !防止因为业务逻辑突发异常抛出导致countDown()错失执行,让等待的主线程陷入永久死锁的灾难。
Java
arduino
// 初始化计数器为 3
CountDownLatch latch = new CountDownLatch(3);
executor.submit(() -> {
try {
// 执行耗时远程 RPC 调用或营销风控计算...
} finally {
latch.countDown(); // 【铁律】确保无论成败,计数器必然扣减
}
});
latch.await(); // 主线程在此阻塞,直到计数器归零
(注:CountDownLatch 是一次性的,计数器扣完归零后无法重置复用。)
2. Semaphore (信号量 / 限制并发流量)
- 核心作用 :控制同时访问某种特定公共稀缺资源的并发线程总数量,是天然的应用级限流器。
- 机制 :类似于停车场的"剩余车位"。线程必须通过
acquire()成功抢到许可证(state > 0)才能进去执行,干完活调用release()归还车位。如果把许可证总数初始化设为 1,它就能直接退化成一把互斥锁来使用。
3. CyclicBarrier (循环栅栏 / 多等多)
- 核心作用:让一组线程(多方)全部到达一个屏障点之后,大家才能同时跨过栅栏往下执行。
- 最大优势 :与 CountDownLatch 是一次性用品不同,
CyclicBarrier内部的计数器在所有人跨过栅栏后会自动重置,支持在循环业务中反复复用。
4. 终极一绝:三剑客底层的 AQS 共享/独占脑内流程图
scss
线程试图进入
↓
尝试修改 state
↓
┌────────────────────┼────────────────────┐
▼ ▼ ▼
[ReentrantLock] [Semaphore] [CountDownLatch]
state == 0? state > 0? state == 0?
(独占模式:锁空闲) (共享模式:有资源) (共享模式:倒计时完)
│ │ │
├────────────────────┴────────────────────┘
├─────────────────── 成功 ───────────────────► 继续执行
▼
失败:AQS 接管宏观框架
↓
封装为 Node 节点,通过 CAS 安全尾插法送入 FIFO 双向链表
↓
标记前驱状态为 SIGNAL (-1)
↓
调用 Unsafe.park() 强行挂起当前线程,进入阻塞休眠
↓
【后续锁释放 / 资源归还 / 计数器归零】
↓
触发调用 LockSupport.unpark() 唤醒排在前面的有效 Node 节点
↓
线程睁眼,重新发起 CAS 抢占修改 state