JUC速记

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(内存泄漏)。
  • 对象与字段直接操作 :能精准定位对象字段在内存中的绝对偏移量(valueOffset),无视 private 修饰符强行修改字段值。


JUC 第二层:并发核心原语(发动机层)

一、 悲观派:Synchronized 与锁升级机制

synchronized 是 Java 并发最老牌的关键字,它秉持 "悲观态度" ,认为只要不加锁就一定会出问题,因此必须先加锁再操作。

1. 锁的物理存储:对象头与 MarkWord

  • Java 中每个对象的头部都有一个 对象头(Object Header) ,其核心部分称为 MarkWord
  • MarkWord 负责存储对象的运行时数据,其中就包含了极其重要的锁标志位。JVM 后续的所有的"锁升级"动作,本质上就是在修改这个 MarkWord 里的标志位和记录的指针。

2. 原始的重量级锁机制 (Monitor)

在 JDK 1.6 之前,synchronized 只有"重量级锁"这一种形态:

  • 字节码层面 :同步代码块编译后对应 monitorentermonitorexit 两条指令;同步方法 则不走这两条指令,而是在方法访问标志上设置 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 提供了宏观的排队与唤醒框架。 它是 ReentrantLockSemaphoreCountDownLatch 等高层 API 的绝对底座。

1. AQS 的两大核心结构

AQS 的内部极其精妙,主要靠两个组件配合:

  1. volatile int state (同步状态)

    • volatile 保证多线程可见性。
    • 线程通过 CAS 操作去原子性地修改 state,修改成功即代表拿到了锁/资源。
  2. FIFO 双向链表 (等待队列/CLH变体)

    • 抢锁失败的线程,会被封装成一个 Node 节点 安全地送入队尾排队。
    • 节点中有个核心属性 waitStatus,它标记的是对后继节点的义务 :当某节点的 waitStatus 变为 SIGNAL (-1) 时,表示"我的后继节点需要(且正在等待)被唤醒"------该节点释放锁/取消排队时,有义务 unpark 它后面的线程。注意方向:标记在我身上、义务指向后继,而不是"本节点线程已休眠"。

2. AQS 底层加解锁全流程(以 ReentrantLock 为例)

  • 加锁 (lock)

    1. 一上来先用 CAS 尝试将 state 从 0 改为 1(尝试抢占)。
    2. 如果失败,调用 addWaiter 将线程封装进 Node 插入队尾。
    3. 调用 acquireQueued,如果前驱节点是 Head 头节点,做最后一次 CAS 挣扎;若仍失败,将前驱标记为 SIGNAL,然后调用底层 LockSupport.park() 强行挂起当前线程。
  • 解锁 (unlock)

    1. state 减 1(若重入则一直减,直到 0 为止)。
    2. 调用 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() 只是给线程打个招呼(设个标志)。
    • 如果目标线程正在 sleepwaitjoin(可中断阻塞方法)中,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)。这是一个强引用大铁链。

2. 打破错觉:"全班考试模型"

怎么理解多线程调用 threadLocal.set(value)

  • Key 是共享的(数学试卷) :通常用 static final 修饰 ThreadLocal 对象,全班共用一套考卷。
  • Map 和 Value 是私有的(私密答题卡与答案) :大家拿着同一把共享的钥匙(Key 的 HashCode),去各自私有的口袋(ThreadLocalMap) 里算出一个座位号,打开属于自己的 Entry 盲盒,放入自己的数据。

3. 🚨 高危陷阱:ThreadLocal 内存泄漏推演

线程池场景下,由于核心线程是反复复用、不会死亡的,这引发了可怕的连环反应:

  1. 外部业务结束,失去了对 ThreadLocal 对象的强引用。
  2. 触发 GC 垃圾回收。
  3. 因为 Entry 里的 Key 是弱引用 ,所以 Key 被 GC 强行抹除,变成了 null
  4. 此时 Value强引用 。因为线程池的线程不死,导致一条坚不可摧的引用链:Thread -> ThreadLocalMap -> Entry -> Value 一直存活。
  5. 结果 :Map 中出现了一堆 Key 为 null 但 Value 占用内存的"孤儿数据"。严格来说并非"永远无法回收" :后续 set/get/removeThreadLocalMapexpungeStaleEntry 启发式清理会顺带清掉部分 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)
释放方式 自动释放 必须 手动在 finallyunlock() 必须手动释放 必须手动释放 (凭邮戳)
公平性 仅非公平锁 支持公平与非公平 支持公平与非公平 仅非公平锁
功能扩展 简单 可中断、超时、多条件变量 读多写少场景优化 极致读性能优化

2. 逐一击破:四大锁的核心定位与优劣

  • synchronized:傻瓜式自动挡(基础兜底)

    • 定位:JVM 层面的内置锁。默认首选,代码最清爽。
    • 优势:不需要手动释放锁,绝对不会因为忘记释放而死锁。自带锁升级机制(偏向锁 -> 轻量级锁 -> 重量级锁),在轻微竞争下性能并不差。
    • 劣势:极其死板。不支持超时(拿不到就死等),不支持中断响应,不能实现公平排队。
  • ReentrantLock:专业级手动挡(高阶控制)

    • 定位 :基于 AQS 框架打造的重武器,是对 synchronized 功能的全面扩展。
    • 优势 :灵活性极高,支持 tryLock() 尝试获取、超时等待、响应中断。构造函数传 true 即可变成公平锁以规避"线程饥饿"。支持多个 Condition 实现精准唤醒。
    • 劣势必须finally 块中调用 unlock(),一旦漏掉会导致排队线程永久死锁。
  • 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 含义 锁被占用情况及重入次数 剩余可用资源数 还需要等待的倒计次数
唤醒机制 释放时唤醒排在最前面的一个 归还时可能唤醒多个 归零时唤醒所有等待的主线程
本质区别 抢锁 抢资源 等条件

五、 🛡️ 架构实战:四大锁机制的"降级选择法则"

在实际业务开发中,不要盲目追求炫酷的高级锁,请遵循以下法则:

  1. 绝大多数常规场景 :直接用 synchronized!代码最清爽,有 JVM 锁升级兜底,不会因忘记 unlock 而死锁(异常时自动释放监视器锁),性能绝对够用。(注意:嵌套多个 synchronized 块时同样可能经典死锁,只是少了一种"忘解锁"的死锁成因)
  2. 需要打破死板限制时 :如果业务需要超时控制、公平排队、中断响应或精准唤醒,换成 ReentrantLock
  3. 读多写少场景 :如果读操作远大于写操作(如本地缓存读取),使用 ReentrantReadWriteLock 提升吞吐量。
  4. 极限压榨读性能 :在极限高并发的框架底层,读极多、写极少,且无法忍受写饥饿时,才去考虑使用 StampedLock 压榨那最后一丝性能。

JUC 第五层:工程化与架构落地(工业级调度层)

一、 静态管控:ThreadPoolExecutor 核心运行机制

线程池的核心思想是池化技术 (复用固定数量的线程,减少频繁创建/销毁的系统开销------注意这不是享元模式:享元是共享细粒度内部状态以省内存,池化是复用重量级资源以省时间)和生产者-消费者模式

1. 核心七参数(抽奖平台的"后台处理部门"模型)

要彻底掌握线程池,可以将它想象成一个部门的人员编制:

  • corePoolSize(核心线程数)正式员工。来了活立刻干,平时没活也不辞退。
  • maximumPoolSize(最大线程数)总编制(正式 + 临时工) 。正式员工忙不过来且排队区满了时,紧急招募临时工。
  • workQueue(任务阻塞队列)排队区。正式员工全忙时,新任务放进这里排队。
  • keepAliveTime & unit(存活时间)临时工的摸鱼容忍期。高峰期过了,临时工闲置超过这个时间就会被销毁,释放资源。
  • threadFactory(线程工厂)负责招人的 HR 。核心作用是给线程起个有意义的名字(如 order-pool-1),线上出 Bug 查日志时一目了然。
  • handler(拒绝策略)客服预案。正式员工和临时工全忙,且排队区也爆满时,再来新任务该怎么办。

2. 🚨 核心流转逻辑(千万别记错顺序!)

很多人误以为是"核心满了直接开新线程",实际上线程池的原则是优先排队

完整流转这 4 步:

  1. 优先核心 :当前线程数 < corePoolSize,创建核心线程执行。
  2. 进入队列 :核心线程全忙,新请求优先进入 workQueue 排队
  3. 创建临时 :瞬间流量太大,阻塞队列也满了 ,且当前线程数 < maximumPoolSize,才会创建临时线程。
  4. 触发拒绝 :队列满了,临时线程也达到最大限制,执行 handler 拒绝策略。

3. 四大拒绝策略怎么选?

策略名称 行为表现 适用业务场景
AbortPolicy (默认) 直接抛出 RejectedExecutionException 最安全,兜底首选。适合不允许丢任务且需明确反馈的核心链路(如支付)。
CallerRunsPolicy 退给提交任务的线程(如主线程)自己去执行。 天然的限流保护 ,系统整体变慢但不丢任务,推荐使用
DiscardPolicy 默默丢弃新任务,不报错。 允许部分丢失的边缘业务(如非核心日志收集)。
DiscardOldestPolicy 丢弃队列里最老的任务,让新任务插队。 时效性极强的场景(如股票实时报价),旧数据无保留价值。

二、 避坑与实战:企业级工程规范

1. 🚨 为什么阿里规范严禁使用 Executors?

生产环境中绝对不允许使用 Executors 的快捷方法(如 newFixedThreadPoolnewCachedThreadPool)。核心致命风险是 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()
  • 难点突破 :原生的 LinkedBlockingQueuecapacityfinal 修饰的无法修改。业界通用破局方案是拷贝其源码,移除 final 修饰符 ,封装成 ResizableCapacityLinkedBlockingQueue,并暴露 setCapacity() 方法打通最后一块拼图。

3. 工业级动态架构的三层闭环

一个完整的动态线程池方案包含以下架构:

  1. 统一配置中心 (Nacos / Apollo) :将核心参数从代码剥离,交由分布式配置中心统一管理。
  2. 动态监听与热更新 :应用端监听配置中心的变更事件(如 @RefreshScope),一旦推送,立即调用底层的 setter 方法刷新运行中的线程池实例。
  3. 实时监控与告警 :通过 Micrometer 采集四大核心指标(活跃线程数、队列积压、平均耗时、拒绝任务数),推送到 Prometheus 并用 Grafana 展示。当队列使用率触及危险水位时,触发飞书/钉钉告警,开发人员直接在 Nacos 修改参数,实现一键在线化解宕机危机

如果没有精力自研,可以直接引入国内成熟的开源框架,如 Hippo4jDynamic-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 自身把复杂的排队、阻塞、唤醒流程(如 acquirerelease)写死了,但把尝试抢锁的逻辑(tryAcquiretryRelease)留给子类去实现。这是面试官考察你代码设计思维的绝佳切入点。


🔍 二、 容易被抠字眼的细节纠偏(精细化打磨)

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() 时才会把异常抛出来。这也是极易踩坑的实战点。
相关推荐
Profile排查笔记1 小时前
指纹浏览器推荐:用一套验收清单筛选 Profile、代理与自动化能力
前端·人工智能·后端·自动化
站大爷IP1 小时前
Python的pip依赖把我折腾惨了,原来requirements.txt和poetry能打出火星撞地球
后端
狗哥哥2 小时前
用“十步学习法”带你学会事件驱动架构
后端
挽安6212 小时前
Spring Boot自动配置原理:从@EnableAutoConfiguration源码一步步看懂
后端
深入云栈2 小时前
Netty 4.2.x 源码深度解析 (十二):NIO传输——NioSocketChannel与NioServerSocketChannel的IO读写实现
后端
王的宝库2 小时前
GO常用标准库包
开发语言·后端·golang
php@king2 小时前
hyperf初步认识和安装
后端
LEE2 小时前
别再堆 AGENTS.md 了:前端团队如何把 AI Coding 做成一套可执行的工程系统
前端·后端
geovindu2 小时前
python: Face Recognition
开发语言·后端·python·人脸识别