☕ Java 高并发进阶(三):Java 锁体系全景解析——从 Synchronized 到 AQS 高阶锁

在 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 闪亮登场。它是 ReentrantLockSemaphoreCountDownLatch 的共同底层底座。

1. AQS 核心设计:双剑合璧

AQS 内部通过两个极其精妙的组件配合运转:

  1. volatile int state (同步状态)

    使用 volatile 保证多线程内存可见性。线程通过 CAS 操作去原子性地修改 state,修改成功即代表拿到了锁/资源。

    • 独占模式(如 ReentrantLock)0 为空闲,1 为被占用,>1 表示同一个线程的重入次数。
    • 共享模式(如 CountDownLatch) :表示剩余的可用资源计数。
  2. CLH FIFO 双向队列 (等待队列)

    抢锁失败的线程会被封装成一个 Node 节点,通过 CAS + 自旋安全地插入到双向链表的队尾。

    • Node 核心属性 waitStatus :当其变为 SIGNAL (-1) 时,表示当前节点对应的线程已经挂起(通过 LockSupport.park() 实现),且它的前驱节点在释放锁时,有义务将其唤醒

2. AQS 的灵魂:模板方法模式 (Template Method)

AQS 框架把复杂的线程排队、阻塞挂起、出队、高并发下节点安全插入等一整套宏观流程写死在了父类的方法中(如 acquire()release())。它把如何定义资源、如何尝试抢占资源的微观逻辑(tryAcquire()tryRelease())以保护方法的形式留给子类去重写实现。

3. ReentrantLock 源码级加解锁生命周期(非公平锁视角)

① 加锁阶段 (lock())

  1. 初始暴力抢占 :线程一进来,不管三七二十一,直接调用 compareAndSetState(0, 1) 发起 CAS 抢锁。抢到了就把 ExclusiveOwnerThread 设为自己。
  2. 逻辑尝试 (tryAcquire) :如果第一步没抢到,检查 state。若是当前锁的持有者就是自己 ,则触发可重入机制 ,执行 state + 1 并直接放行;若不是自己,抢锁宣告失败。
  3. 安全入队 (addWaiter) :抢锁失败的线程被包装成 Node 节点,通过 CAS 自旋尾插法安全地送入 FIFO 双向链表的末尾排队。
  4. 阻塞前的挣扎 (acquireQueued) :节点入队后,会检查自己的前驱是不是 Head 头节点 。如果是,则做最后一次 tryAcquire 挣扎抢锁;如果仍抢不到,则将前驱的状态强行改为 SIGNAL (-1),随后调用 LockSupport.park(this) 强行挂起休眠,让出 CPU。

② 解锁阶段 (unlock())

  1. 状态递减 (tryRelease) :当前持有锁的线程调用 unlock(),内部将 state 减 1。
  2. 彻底释放判断 :由于支持重入,只有当 state 减到 0 的时候,才会彻底清空锁的占有者 ExclusiveOwnerThread = null,宣告锁彻底空闲。
  3. 唤醒后继 (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. 🛡️ 工业级工程实战:锁机制的"降级选择法则"

  1. 常规首选 :90% 的普通业务场景,直接无脑用 synchronized!代码最清爽,JVM 自带锁升级,绝无漏掉释放锁而引发死锁的风险。
  2. 高级控制 :当需要实现超时控制 (tryLock)、响应中断、或者需要利用多条件变量 Condition 实现类似"奇偶数精准交替唤醒"的高阶逻辑时,换成 ReentrantLock
  3. 读多写少 :类似商品详情页、配置中心本地缓存读取,换用 ReentrantReadWriteLock
  4. 极限压榨 :在框架底层底层、核心中间件中,读请求占比高达 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
相关推荐
阿杰AJie41 分钟前
ExcelUtils样式相关工具
java·后端
love_muming42 分钟前
从 ArrayList 到 LinkedList:Java 集合中数组与链表的深度对比
java·数据结构·链表
荣码1 小时前
Java后端用LangChain搭大模型应用,我踩了5个坑
java
JAVA9651 小时前
JAVA面试-并发篇 04-synchronized和ReentrantLock 的区别是什么
java·面试
我是一只码蚁1 小时前
《别再死记面向对象了,我家咖啡机就是最好的老师》
java·后端
ZenosDoron1 小时前
malloc规范
java·开发语言
codeejun1 小时前
每日一Go-71、理论知识:CAP 、一致性原理 、Raft 机制(简化实现一个 Raft)
java·开发语言·golang
阿杰 AJie1 小时前
ExcelUtils样式相关工具
java·后端
Aotman_1 小时前
JavaScript数组对象中指定字段转换
java·开发语言·前端·javascript·vue.js·前端框架·es6