JUC 第一层:硬件与 OS 底层基石(物理层)
并发编程的一切痛点与解决方案,其根源都在于底层硬件(CPU、内存)与操作系统(OS)的机制。要彻底吃透 JUC,必须先打通这一层。
一、 JMM (Java 内存模型) 与底层硬件交互
1. 为什么需要 JMM?(核心痛点)
- 硬件层面的冲突 :现代 CPU 为多核架构,每个核心都有私有的 L1/L2 缓存,L3 为多核共享。当多个 CPU 核心同时修改共享变量时,会导致缓存数据不一致。
- MESI 缓存一致性协议:硬件层面通过 MESI 协议解决冲突。当一个 CPU 修改了数据,会广播信号使其他 CPU 缓存中的该数据副本失效(Invalid),强制其他核心重新去主内存拉取最新值。
- JMM 的角色 :JMM(Java Memory Model)是一套"并发安全规范法典",用于屏蔽不同硬件和操作系统的底层差异。它在逻辑上抽象出主内存(共享,对应堆)和工作内存(私有,对应线程栈和寄存器) ,规定线程只能在工作内存操作变量副本,不能直接读写主内存。
2. JMM 攻克的三大并发挑战
| 并发挑战 | 核心问题 | JMM 解决方案与底层原理 |
|---|---|---|
| 可见性 | 线程 A 修改了工作内存的数据未刷回,线程 B 读到旧数据。 | volatile 关键字。底层触发 Lock 前缀指令与 MESI 协议,强制修改后立即写回主内存,并让其他线程的缓存瞬间失效。 |
| 原子性 | i++ 等复合操作执行中途被 CPU 切走,导致数据相互覆盖。 |
加锁 (synchronized/Lock) 或 CAS。将多步操作强制变为不可分割的"原子",保证同一时刻仅有一个线程执行。 |
| 有序性 | 编译器和 CPU 为了压榨性能,对代码指令进行重排序。 | 内存屏障 (Memory Barrier) 。JMM 自动在必要操作(如 volatile 读写)前后插入屏障(如 StoreLoad),强制禁止指令越过屏障重排。 |
💡 核心法则:Happens-Before(先行发生原则)
这是 JMM 提炼给开发者的"防踩坑推导公式"。只要代码符合 Happens-Before 的 8 条规则,JMM 就会通过底层指令保障多线程执行结果的绝对安全、有序且可见。
二、 上下文切换 (Context Switch):并发的隐形杀手
1. 什么是上下文切换?
CPU 只有一个(或核心有限),通过时间片轮转执行多线程。当 CPU 挂起线程 A 去执行线程 B 时,必须记录 A 的当前进度,并在下次切回时恢复。这主要包含:
- 程序计数器 (PC 寄存器) :记录当前线程执行到了哪一行代码指令。
- CPU 寄存器状态:记录计算过程中的各种中间变量和运行状态。
2. 为什么上下文切换代价极其昂贵?
- 显式代价(OS 层) :保存和恢复寄存器数据,通常伴随着从用户态到内核态的切换(线程调度是操作系统的特权),跨越系统边界开销极大。
- 隐形代价(硬件层/最致命) :切换导致原线程在 CPU 高速缓存中的热点数据全部失效(Cache Miss)。新线程遭遇"缓存冷启动",原线程重获 CPU 时又要重新去主内存拉取数据,引发严重性能抖动。
3. 如何减少上下文切换?(实战优化)
- 拥抱无锁并发 (CAS) :使用原子类代替重量级锁(
synchronized),利用用户态自旋重试代替内核态阻塞挂起,完美避开 OS 级线程切换。 - 合理控制线程数量 :使用线程池,避免创建远超 CPU 核心数的庞大线程群(通常 CPU 密集型配
N+1,I/O 密集型配2N)。 - 虚拟线程 (协程) :Java 21 引入,虚拟线程的挂起和恢复完全由 JVM 在用户空间管理,不需要操作系统内核参与,极大地降低了切换成本。
三、 Unsafe 类:JVM 的"越权后门"
1. 核心定位
Java 受限于 JVM 沙箱,无法直接访问底层 OS 和硬件。位于 sun.misc 包下的 Unsafe 类全是 native 本地方法,允许 Java 直接调用 C/C++ 提供硬件级原子操作。它是整个 JUC 包与众多高性能框架的底层心脏。
2. 四大核心特权(双刃剑)
-
硬件级 CAS 操作 :提供
compareAndSwapInt等方法,最终向 CPU 发送cmpxchg硬件级别的原子指令。这是所有无锁并发的心脏。 -
线程精确调度 :提供
park()和unpark()方法,精准地将某个特定线程挂起或唤醒。这是 AQS(ReentrantLock等)底层排队阻塞机制的基础。 -
绕过限制的内存操作 :直接在操作系统分配、修改和释放堆外内存(Off-Heap) ,实现零拷贝并提升 I/O 吞吐量。
- 🚨 致命风险 :堆外内存完全脱离 JVM 的 GC 管辖,如果忘记手动调用
freeMemory()释放,会导致系统级 OOM(内存泄漏)。
- 🚨 致命风险 :堆外内存完全脱离 JVM 的 GC 管辖,如果忘记手动调用
-
对象与字段直接操作 :能精准定位对象字段在内存中的绝对偏移量(
valueOffset),无视private修饰符强行修改字段值。
JUC 第二层:并发核心原语(发动机层)
一、 悲观派:Synchronized 与锁升级机制
synchronized 是 Java 并发最老牌的关键字,它秉持 "悲观态度" ,认为只要不加锁就一定会出问题,因此必须先加锁再操作。
1. 锁的物理存储:对象头与 MarkWord
- Java 中每个对象的头部都有一个 对象头(Object Header) ,其核心部分称为 MarkWord。
- MarkWord 负责存储对象的运行时数据,其中就包含了极其重要的锁标志位。JVM 后续的所有的"锁升级"动作,本质上就是在修改这个 MarkWord 里的标志位和记录的指针。
2. 原始的重量级锁机制 (Monitor)
在 JDK 1.6 之前,synchronized 只有"重量级锁"这一种形态:
-
字节码层面 :同步代码块编译后对应
monitorenter和monitorexit两条指令;同步方法 则不走这两条指令,而是在方法访问标志上设置ACC_SYNCHRONIZED,由调用指令前的隐式检查实现。 -
JVM 层面 :依靠 Monitor(监视器) 来管理,内部包含三个核心逻辑区:
Owner:当前持有锁的线程。EntryList:抢锁失败,在此阻塞排队的线程队列。WaitSet:调用wait()后主动让出锁并等待notify()唤醒的线程队列。
-
性能痛点 :Monitor 底层强依赖操作系统的互斥量(
Mutex Lock)。动用它意味着线程必须经历从"用户态"到"内核态"的昂贵切换,导致性能极差。
3. JVM 的救赎:不可逆的锁升级过程
为了避免动辄呼叫操作系统的极高开销,JDK 1.6 引入了锁升级机制(通常不可逆)。⚠️ 版本注脚(必说,显专业): 偏向锁因维护成本高于收益,JDK 15 已废弃(JEP 374)并默认禁用------现代 JDK 的锁升级实际从轻量级锁起步。下面按经典 JDK 8 语境讲解:
| 锁状态 | 适用竞争场景 | 底层原理与动作 | 设计思想 |
|---|---|---|---|
| 无锁 | 对象刚创建 | MarkWord 处于初始状态。 | 尚无竞争,无需保护。 |
| 偏向锁 | 无竞争,单线程重入 | JVM 将 MarkWord 标记为偏向锁,并记录该线程ID。该线程再来时,比对 ID 一致直接放行。 | 假设永远没竞争,彻底省去加/解锁的 CAS 开销。 |
| 轻量级锁 | 轻微竞争,交替执行 | 发生竞争,偏向锁撤销。通过 CAS 尝试将 MarkWord 复制到线程栈并指向自己。失败的线程会原地自旋(空跑 CPU)尝试抢锁。 | 假设很快就能拿到锁,宁愿耗费 CPU 自旋,也不去 OS 排队阻塞(避免用户态/内核态切换)。 |
| 重量级锁 | 激烈竞争,高并发 | CAS 自旋次数过多,发生锁膨胀 。直接动用 Monitor 和操作系统的 Mutex,竞争失败的线程陷入阻塞状态。 | 既然抢不到,自旋只会白费 CPU,不如直接让线程阻塞休息,让出 CPU。 |
二、 乐观派:CAS (Compare And Swap) 无锁并发
CAS 秉持 "乐观态度" ,认为并发冲突概率低,因此平时不加锁,只在最后提交更新时比对数据。
1. 核心运行机制 (V, E, N)
CAS 操作主要依赖三个关键值:
- V (Value) :主内存中的实际值。
- E (Expected) :线程工作内存里的预期旧值。
- N (New) :线程计算后想要写入的新值。
执行流程 :线程带着 E 和 N 回到主内存,如果 V == E(说明期间没人修改过),则将 V 更新为 N;如果 V != E,说明数据被篡改,更新失败,线程会重新读取最新值并自旋重试。
2. 为什么 CAS 是绝对安全的?
- CAS 的"比较"和"交换"并非 Java 自身逻辑,而是通过
Unsafe类向操作系统和 CPU 发送了硬件级别的原子指令(如cmpxchg) 。 - 这个指令必须带上
lock前缀 (lock cmpxchg)才具备跨核原子性------lock 前缀让 CPU 独占该缓存行(或锁内存总线),多核环境下执行期间才绝对不会被其他线程打断 (裸cmpxchg在多核上并不天然原子)。
3. CAS 的三大致命缺陷与解法
-
缺陷 1:ABA 问题 。如果主存值经历
A -> B -> A的变化,CAS 会误判为无人修改而放行,这会导致过程数据丢失(如节点错乱)。- 解法 :引入版本号或时间戳,如
AtomicStampedReference,要求值和版本号同时匹配才允许修改。
- 解法 :引入版本号或时间戳,如
-
缺陷 2:极端并发下耗费 CPU。在竞争激烈的"写多读少"场景下,大量线程比对失败并陷入死循环自旋,会把 CPU 打满引发雪崩。
- 解法 :升级为悲观锁,或使用分段思想(如
LongAdder)。
- 解法 :升级为悲观锁,或使用分段思想(如
-
缺陷 3:只能保证单个变量的原子性。
- 解法 :利用
AtomicReference将多个变量封装成一个对象进行操作。
- 解法 :利用
三、 宏观管控:AQS (AbstractQueuedSynchronizer) 基石
CAS 只是微观动作,当多线程竞争极其激烈、大量线程抢锁失败时,AQS 提供了宏观的排队与唤醒框架。 它是 ReentrantLock、Semaphore、CountDownLatch 等高层 API 的绝对底座。
1. AQS 的两大核心结构
AQS 的内部极其精妙,主要靠两个组件配合:
-
volatile int state(同步状态) :- 用
volatile保证多线程可见性。 - 线程通过 CAS 操作去原子性地修改
state,修改成功即代表拿到了锁/资源。
- 用
-
FIFO 双向链表 (等待队列/CLH变体) :
- 抢锁失败的线程,会被封装成一个 Node 节点 安全地送入队尾排队。
- 节点中有个核心属性
waitStatus,它标记的是对后继节点的义务 :当某节点的waitStatus变为SIGNAL (-1)时,表示"我的后继节点需要(且正在等待)被唤醒"------该节点释放锁/取消排队时,有义务 unpark 它后面的线程。注意方向:标记在我身上、义务指向后继,而不是"本节点线程已休眠"。
2. AQS 底层加解锁全流程(以 ReentrantLock 为例)
-
加锁 (
lock) :- 一上来先用 CAS 尝试将
state从 0 改为 1(尝试抢占)。 - 如果失败,调用
addWaiter将线程封装进 Node 插入队尾。 - 调用
acquireQueued,如果前驱节点是 Head 头节点,做最后一次 CAS 挣扎;若仍失败,将前驱标记为SIGNAL,然后调用底层LockSupport.park()强行挂起当前线程。
- 一上来先用 CAS 尝试将
-
解锁 (
unlock) :- 将
state减 1(若重入则一直减,直到 0 为止)。 - 调用
unparkSuccessor,找到队列中第一个有效等待节点,调用LockSupport.unpark()唤醒该线程。
- 将
3. AQS 衍生的公平与非公平机制
- 公平锁 :讲究先来后到。抢锁前必须调用
hasQueuedPredecessors()检查队列里有没有人排队,有就乖乖入队。缺点是线程上下文切换频繁,吞吐量低。 - 非公平锁(默认) :极其霸道。无视排队队列,上来直接用 CAS 暴力抢锁,抢不到再去排队。优点是利用了线程唤醒的时间差,吞吐量和性能极高,缺点是可能导致老线程饥饿。
JUC 第三层:线程与数据隔离(单体层)
一、 线程生命周期与协作(生老病死与沟通)
1. 六大状态流转 (Thread.State)
Java 在 Thread 类中明确规定了线程的 6 种状态,它们构成了线程的一生:
-
NEW (新建) :
new Thread()创建了对象,但还没调用start()。- 避坑 :调用
run()只是普通方法执行(串行),只有调用start()才会向 OS 申请资源真启动(并发)。
- 避坑 :调用
-
RUNNABLE (可运行/就绪) :调用了
start()。在 Java 眼里只要具备执行条件就是 Runnable,至于 CPU 切没切给它,由 OS 决定。 -
WAITING (等待) :主动等待。比如调用了无参的
wait()或join(),主动去休息室,必须等其他线程显式唤醒 (如notify())。 -
TIMED_WAITING (计时等待) :带超时的等待(如
sleep(long))。时间一到自动醒来。 -
BLOCKED (阻塞) :被动等待。比如抢
synchronized锁失败了,被迫在门外罚站排队。 -
TERMINATED (终止) :
run()方法执行完毕或异常退出。
🔥 面试高频必杀:
wait唤醒后的真实流转 当一个处于WAITING状态的线程被notify唤醒时,它绝不是立刻变成RUNNABLE继续执行 ! 真实流转是:WAITING -> BLOCKED -> RUNNABLE。因为它醒来后必须重新去竞争那把锁 ,抢不到锁就得乖乖变成BLOCKED状态排队。
2. 线程的"协作"而非"强杀" (Interrupt 机制)
-
严禁强杀 :Java 废弃了
stop()方法,因为直接强制杀死线程会导致数据损坏和锁无法释放。 -
温柔中断 (
interrupt) :中断本质上是一个协作标志位。- 调用
interrupt()只是给线程打个招呼(设个标志)。 - 如果目标线程正在
sleep、wait、join(可中断阻塞方法)中,JVM 会强行将其唤醒并抛出InterruptedException异常,同时自动清除中断标志位 。这把处理后事的权利完全交给了开发者在catch块中决定。 - ⚠️ 高频陷阱:
LockSupport.park()是例外 ------被中断时只是立即返回,不抛异常、也不清除中断标志 (标志位保持 set)。所以 AQS 的等待线程醒来后要自己调Thread.interrupted()检查并清除标志。
- 调用
二、 数据隔离无锁方案:ThreadLocal 深度剖析
面对并发冲突,悲观锁是"大家排队抢",乐观锁是"大家试着抢",而 ThreadLocal 换了一个降维打击的思路:不抢了,我给你们每人发一份专属拷贝。核心思想是"空间换时间"。
1. 底层架构:"两个格子的盲盒"
很多人误以为是 ThreadLocal 内部维护了一个 Map 存所有线程的数据,恰恰相反:
-
Thread(线程对象) :每个单独的线程对象内部,都悄悄揣着一个专属的口袋:ThreadLocalMap。 -
Entry盲盒结构 :这个口袋是个数组,里面放着Entry对象。- 左侧格子 (Key) :存储
ThreadLocal对象本身。为了不影响ThreadLocal被垃圾回收,这里被设计成了弱引用 (WeakReference) 。只要发生 GC,不管内存够不够,这个 Key 就会被无情回收(变成 null)。 - 右侧格子 (Value) :存储真正的业务数据(如 UserID)。这是一个强引用大铁链。
- 左侧格子 (Key) :存储
2. 打破错觉:"全班考试模型"
怎么理解多线程调用 threadLocal.set(value)?
- Key 是共享的(数学试卷) :通常用
static final修饰ThreadLocal对象,全班共用一套考卷。 - Map 和 Value 是私有的(私密答题卡与答案) :大家拿着同一把共享的钥匙(Key 的 HashCode),去各自私有的口袋(ThreadLocalMap) 里算出一个座位号,打开属于自己的
Entry盲盒,放入自己的数据。
3. 🚨 高危陷阱:ThreadLocal 内存泄漏推演
在线程池场景下,由于核心线程是反复复用、不会死亡的,这引发了可怕的连环反应:
- 外部业务结束,失去了对
ThreadLocal对象的强引用。 - 触发 GC 垃圾回收。
- 因为
Entry里的 Key 是弱引用 ,所以 Key 被 GC 强行抹除,变成了null。 - 此时
Value是强引用 。因为线程池的线程不死,导致一条坚不可摧的引用链:Thread -> ThreadLocalMap -> Entry -> Value一直存活。 - 结果 :Map 中出现了一堆 Key 为
null但 Value 占用内存的"孤儿数据"。严格来说并非"永远无法回收" :后续set/get/remove时ThreadLocalMap的expungeStaleEntry启发式清理会顺带清掉部分 stale entry;但若线程长期存活(尤其是线程池线程)且再无任何 ThreadLocal 操作,这些 Value 就会一直滞留,积累到极致最终 OOM。根治手段:用完必调remove()。
🛡️ 终极防漏铁律: 在 Web 开发(如拦截器存用户上下文)或线程池中使用
ThreadLocal,必须在finally代码块中显式调用remove()方法,将 Key 和 Value 从当前线程的专属口袋里彻底清除。
🛠️ JUC 第四层:高级并发工具库(API 应用层)
将底层的核心原语(OS 指令、CAS、AQS)进行封装后,就得到了我们在日常业务中直接调用的 API 组件。这里是 JUC 应对复杂业务场景的"军火库"。
一、 JUC 锁体系全景图:从基础兜底到极限压榨
Java 的并发控制在不同时代和场景下不断演进,形成了四大经典的锁机制。
1. 四大锁机制全景对比表
| 对比维度 | synchronized | ReentrantLock | ReentrantReadWriteLock | StampedLock |
|---|---|---|---|---|
| 锁的本质 | 独占锁 / 悲观锁 | 独占锁 / 悲观锁 | 读写分离(读共享,写独占) | 读写分离 + 乐观锁 |
| 实现层面 | JVM 关键字 (基于 Monitor) | JDK API 层 (基于 AQS) | JDK API 层 (基于 AQS) | JDK API 层 (非 AQS) |
| 释放方式 | 自动释放 | 必须 手动在 finally 中 unlock() |
必须手动释放 | 必须手动释放 (凭邮戳) |
| 公平性 | 仅非公平锁 | 支持公平与非公平 | 支持公平与非公平 | 仅非公平锁 |
| 功能扩展 | 简单 | 可中断、超时、多条件变量 | 读多写少场景优化 | 极致读性能优化 |
2. 逐一击破:四大锁的核心定位与优劣
-
synchronized:傻瓜式自动挡(基础兜底)- 定位:JVM 层面的内置锁。默认首选,代码最清爽。
- 优势:不需要手动释放锁,绝对不会因为忘记释放而死锁。自带锁升级机制(偏向锁 -> 轻量级锁 -> 重量级锁),在轻微竞争下性能并不差。
- 劣势:极其死板。不支持超时(拿不到就死等),不支持中断响应,不能实现公平排队。
-
ReentrantLock:专业级手动挡(高阶控制)- 定位 :基于 AQS 框架打造的重武器,是对
synchronized功能的全面扩展。 - 优势 :灵活性极高,支持
tryLock()尝试获取、超时等待、响应中断。构造函数传true即可变成公平锁以规避"线程饥饿"。支持多个Condition实现精准唤醒。 - 劣势 :必须 在
finally块中调用unlock(),一旦漏掉会导致排队线程永久死锁。
- 定位 :基于 AQS 框架打造的重武器,是对
-
ReentrantReadWriteLock:读写分离斩(读多写少利器)- 定位:针对"读多写少"场景的专项优化。
- 优势:读锁之间不互斥(共享),写锁与其他任何锁都互斥(独占)。允许多个线程同时读数据,性能飙升。
- 劣势(写饥饿) :如果有源源不断的读请求,读锁一直被占用,写请求会被无限期搁置,导致写线程被饿死。
-
StampedLock:性能榨汁机(乐观读的终极形态)- 定位:针对读写锁"写饥饿"痛点推出的终极乐观锁方案。
- 优势:提供"乐观读"。读数据时根本不加真正的锁,而是拿一个版本号(Stamp/邮戳)去读。读完后校验期间是否有人写过数据。如果没人动过,全程无锁,性能逆天;如果有人动过,立刻降级为传统的"悲观读锁"重读。
- 劣势:不支持重入(自己拿写锁后再次拿写锁会死锁),不支持条件变量,代码逻辑极其繁琐。
二、 原子类 (Atomic):无锁并发的"手术刀"
当业务只需要对简单变量进行安全的更新时,用锁太重了,首选 java.util.concurrent.atomic 包下的原子类。
1. 核心原理
底层依赖 Unsafe 硬件级操作 + CAS (Compare-And-Swap) + volatile 支撑,实现了彻底的无锁编程 (Lock-Free) 。修改失败不阻塞,而是自旋重试,极大地避免了昂贵的线程上下文切换。
2. 四大分类矩阵
- 基本类型 :
AtomicInteger,AtomicLong,AtomicBoolean。 - 数组类型 :
AtomicIntegerArray,AtomicLongArray。 - 引用类型 :
AtomicReference(将多个变量封装进一个对象实现原子操作)。AtomicStampedReference(带版本号的原子引用,完美解决 CAS 的 ABA 致命漏洞)。 - 属性类型 :
AtomicIntegerFieldUpdater(反射更新某个类中指定的volatile字段)。
三、 并发三剑客:多线程协作的"指挥棒"
当多个线程需要互相配合、统一步伐时,AQS 提供了一套基于共享模式的顶级同步器工具。
1. CountDownLatch (倒计时器 / 一等多)
- 作用:让主线程阻塞,等待 N 个子线程全部执行完毕。常用于并行加载数据后统一返回。
- 机制 :初始化一个状态值
state(如 3),子线程执行完调用countDown()让state - 1,主线程调用await()阻塞直到state归 0。 - 🚨 避坑指南 :务必将
countDown()放在子线程的finally代码块中! 防止业务异常导致没有扣减,让主线程陷入永久死锁。它是一次性的,无法重置复用。
2. Semaphore (信号量 / 资源限流)
- 作用:控制同时访问某个特定资源的并发线程数量,是天然的限流器。
- 机制 :类似于停车场的"剩余车位"。只有拿到许可(
state > 0)才能进入执行,执行完归还许可。如果把许可设为 1,它就能当做互斥锁来用。
3. CyclicBarrier (循环栅栏 / 多等多)
- 作用:等待所有的参与线程全部到达某个屏障(Barrier)点,然后大家一起同时往下执行。相比 CountDownLatch,它可以复用。
四、 核心融会贯通:同步器底层的 AQS 脑内流程图
这三大组件底层都是同一套 AQS 逻辑,只是对 state 的定义和放行条件不同:
Plaintext
perl
线程进来
↓
尝试修改 state
↓
┌───────────────┬───────────────┬───────────────┐
│ ReentrantLock │ Semaphore │ CountDownLatch│
├───────────────┼───────────────┼───────────────┤
│ state==0? │ state>0? │ state==0? │
└───────────────┴───────────────┴───────────────┘
↓
成功 → 继续执行
失败 → 入队排队 → park() 挂起
↓
被唤醒
↓
再试一次
| 维度 | ReentrantLock | Semaphore | CountDownLatch |
|---|---|---|---|
| AQS 模式 | 独占模式 | 共享模式 | 共享模式 |
state 含义 |
锁被占用情况及重入次数 | 剩余可用资源数 | 还需要等待的倒计次数 |
| 唤醒机制 | 释放时唤醒排在最前面的一个 | 归还时可能唤醒多个 | 归零时唤醒所有等待的主线程 |
| 本质区别 | 抢锁 | 抢资源 | 等条件 |
五、 🛡️ 架构实战:四大锁机制的"降级选择法则"
在实际业务开发中,不要盲目追求炫酷的高级锁,请遵循以下法则:
- 绝大多数常规场景 :直接用
synchronized!代码最清爽,有 JVM 锁升级兜底,不会因忘记 unlock 而死锁(异常时自动释放监视器锁),性能绝对够用。(注意:嵌套多个synchronized块时同样可能经典死锁,只是少了一种"忘解锁"的死锁成因) - 需要打破死板限制时 :如果业务需要超时控制、公平排队、中断响应或精准唤醒,换成
ReentrantLock。 - 读多写少场景 :如果读操作远大于写操作(如本地缓存读取),使用
ReentrantReadWriteLock提升吞吐量。 - 极限压榨读性能 :在极限高并发的框架底层,读极多、写极少,且无法忍受写饥饿时,才去考虑使用
StampedLock压榨那最后一丝性能。
JUC 第五层:工程化与架构落地(工业级调度层)
一、 静态管控:ThreadPoolExecutor 核心运行机制
线程池的核心思想是池化技术 (复用固定数量的线程,减少频繁创建/销毁的系统开销------注意这不是享元模式:享元是共享细粒度内部状态以省内存,池化是复用重量级资源以省时间)和生产者-消费者模式。
1. 核心七参数(抽奖平台的"后台处理部门"模型)
要彻底掌握线程池,可以将它想象成一个部门的人员编制:
corePoolSize(核心线程数) :正式员工。来了活立刻干,平时没活也不辞退。maximumPoolSize(最大线程数) :总编制(正式 + 临时工) 。正式员工忙不过来且排队区满了时,紧急招募临时工。workQueue(任务阻塞队列) :排队区。正式员工全忙时,新任务放进这里排队。keepAliveTime&unit(存活时间) :临时工的摸鱼容忍期。高峰期过了,临时工闲置超过这个时间就会被销毁,释放资源。threadFactory(线程工厂) :负责招人的 HR 。核心作用是给线程起个有意义的名字(如order-pool-1),线上出 Bug 查日志时一目了然。handler(拒绝策略) :客服预案。正式员工和临时工全忙,且排队区也爆满时,再来新任务该怎么办。
2. 🚨 核心流转逻辑(千万别记错顺序!)
很多人误以为是"核心满了直接开新线程",实际上线程池的原则是优先排队。
完整流转这 4 步:
- 优先核心 :当前线程数 <
corePoolSize,创建核心线程执行。 - 进入队列 :核心线程全忙,新请求优先进入
workQueue排队。 - 创建临时 :瞬间流量太大,阻塞队列也满了 ,且当前线程数 <
maximumPoolSize,才会创建临时线程。 - 触发拒绝 :队列满了,临时线程也达到最大限制,执行
handler拒绝策略。
3. 四大拒绝策略怎么选?
| 策略名称 | 行为表现 | 适用业务场景 |
|---|---|---|
AbortPolicy (默认) |
直接抛出 RejectedExecutionException。 |
最安全,兜底首选。适合不允许丢任务且需明确反馈的核心链路(如支付)。 |
CallerRunsPolicy |
退给提交任务的线程(如主线程)自己去执行。 | 天然的限流保护 ,系统整体变慢但不丢任务,推荐使用。 |
DiscardPolicy |
默默丢弃新任务,不报错。 | 允许部分丢失的边缘业务(如非核心日志收集)。 |
DiscardOldestPolicy |
丢弃队列里最老的任务,让新任务插队。 | 时效性极强的场景(如股票实时报价),旧数据无保留价值。 |
二、 避坑与实战:企业级工程规范
1. 🚨 为什么阿里规范严禁使用 Executors?
生产环境中绝对不允许使用 Executors 的快捷方法(如 newFixedThreadPool、newCachedThreadPool)。核心致命风险是 OOM(内存溢出) 。
FixedThreadPool等底层使用的是LinkedBlockingQueue,其默认是无界队列(容量约 21 亿)。流量激增时任务会无限堆积在内存中导致 OOM。CachedThreadPool的最大线程数是Integer.MAX_VALUE,高并发时会疯狂创建新线程,直接耗尽机器资源宕机。- ✅ 正确姿势 :必须手动
new ThreadPoolExecutor,显式指定有界队列和明确的拒绝策略。
2. 线程数到底配多少?(拒绝死套公式)
理论上的基准点:
- CPU 密集型 (如加密、复杂计算):
CPU 核数 + 1。 - I/O 密集型 (99% 的 Web 业务,如 CRUD、RPC 调用):
2 * CPU 核数。 - 🔥 实战调优 :理论公式只是起点,最终参数必须靠压力测试(如 JMeter)得出。观察 TPS 和 CPU 使用率,动态微调寻找"甜点区"。
3. 工程最佳实践:关闭与隔离
-
业务隔离:不同的业务(订单、日志、支付)必须使用不同的线程池,防止日志的拥堵拖垮订单核心链路。
-
优雅关闭:
shutdown():温柔关闭,不接新任务,等队列里的任务干完。shutdownNow():暴力关闭,尝试中断正在执行的线程,清空队列并返回未执行的任务。
三、 动态线程池:现代微服务的最终答案
在微服务时代,依靠代码写死配置文件的静态线程池已经无法适应现实情况了。
1. 为什么必须引入动态线程池?
- 潮汐效应:例如高并发的秒杀抽奖平台,平时流量极低,大促时瞬间爆发。写死参数要么日常浪费资源,要么活动时直接宕机。
- 耗时不可控:AI Agent 编排或微服务调用时,外部 API 响应时间极其多变,要求底层线程池必须能在运行时灵活调配。
2. JDK 预留的"后门"(底层原动力)
Doug Lea 在 ThreadPoolExecutor 源码中预留了可以在运行时热修改且立即生效的方法:
setCorePoolSize()/setMaximumPoolSize()/setKeepAliveTime()。- 难点突破 :原生的
LinkedBlockingQueue的capacity是final修饰的无法修改。业界通用破局方案是拷贝其源码,移除final修饰符 ,封装成ResizableCapacityLinkedBlockingQueue,并暴露setCapacity()方法打通最后一块拼图。
3. 工业级动态架构的三层闭环
一个完整的动态线程池方案包含以下架构:
- 统一配置中心 (Nacos / Apollo) :将核心参数从代码剥离,交由分布式配置中心统一管理。
- 动态监听与热更新 :应用端监听配置中心的变更事件(如
@RefreshScope),一旦推送,立即调用底层的setter方法刷新运行中的线程池实例。 - 实时监控与告警 :通过 Micrometer 采集四大核心指标(活跃线程数、队列积压、平均耗时、拒绝任务数),推送到 Prometheus 并用 Grafana 展示。当队列使用率触及危险水位时,触发飞书/钉钉告警,开发人员直接在 Nacos 修改参数,实现一键在线化解宕机危机。
如果没有精力自研,可以直接引入国内成熟的开源框架,如 Hippo4j 或 Dynamic-tp,它们已经做好了上述闭环并自带了监控大屏。
你的笔记偏向于"执行与控制"(锁、线程池、同步器),但漏掉了 JUC 体系中同样占极大比重的 "并发容器" 部分。
1. 并发集合的王者:ConcurrentHashMap (必须补齐!) 这是后端面试绝对绕不开的超高频考点。你需要补充它在 JDK 1.7(分段锁 Segment)到 JDK 1.8(CAS + synchronized + 数组 + 链表/红黑树)的底层演进。特别是在高并发场景下,它如何比 HashTable 高效,以及它内部的扩容机制(transfer)是如何允许多线程协助扩容的。
2. 阻塞队列体系:BlockingQueue 在第五层线程池中,你提到了 workQueue,但需要向下深挖一层。面试常问:ArrayBlockingQueue(有界、一把锁)和 LinkedBlockingQueue(默认无界、两把锁分离存取)的底层区别是什么?在你的高并发抽奖平台项目中,如果瞬间涌入大量请求,选择哪种队列能拥有更高的吞吐量?
3. AQS 的灵魂设计:模板方法模式 (Template Method) 在第二层 AQS 中,你讲了底层流程,但漏了设计模式 。AQS 的精髓在于它使用了"模板方法模式"。AQS 自身把复杂的排队、阻塞、唤醒流程(如 acquire、release)写死了,但把尝试抢锁的逻辑(tryAcquire、tryRelease)留给子类去实现。这是面试官考察你代码设计思维的绝佳切入点。
🔍 二、 容易被抠字眼的细节纠偏(精细化打磨)
1. 第一层:volatile 的原子性陷阱
- 补充强调 :笔记中提到了
volatile解决可见性和有序性,但面试官极爱挖坑:"volatile能保证i++的线程安全吗?" - 修正贴士 :必须在笔记中用加粗红字强调:
volatile绝对不能保证复合操作的原子性! 读-改-写操作依然会被切走覆盖。
2. 第三层:ThreadLocal 的哈希冲突解决
- 补充机制 :你提到了它底层是
Entry数组,但没有说如果下标冲突了怎么办? - 修正贴士 :和
HashMap的链表法不同,ThreadLocalMap使用的是线性探测法 (Linear Probing) 。如果算出来的坑位被占了,它就往下挨个找空位。在发生内存泄漏并调用get()时,它还有一套探测并清理废弃null节点的启发式清理机制。
3. 父子线程的数据传递
- 补充场景 :
ThreadLocal只能隔离当前线程,如果主线程开启了子线程,子线程是拿不到主线程ThreadLocal里的数据的。 - 修正贴士 :需要补充
InheritableThreadLocal,它能在创建子线程时,将父线程的数据拷贝过去(底层是在Thread类初始化时进行的 Map 复制)。
4. 第五层:线程池的"生命特征"与 submit vs execute
- 状态机缺失 :线程池不仅有参数,还有自己的生命周期(RUNNING, SHUTDOWN, STOP, TIDYING, TERMINATED) 。底层通过一个神级的
AtomicInteger ctl变量,高 3 位存状态,低 29 位存线程数,将状态和数量打包在一起进行原子更新。 - 异常处理缺失 :向线程池提交任务,
execute()是无返回值的,抛异常会直接打印栈轨迹;而submit()返回Future,如果任务抛异常会被内部吞掉,只有当你调用future.get()时才会把异常抛出来。这也是极易踩坑的实战点。