From cffb9d3063205e29a236c17d68916c52a54de3a6 Mon Sep 17 00:00:00 2001 From: REALROOK1E Date: Sun, 8 Mar 2026 01:39:24 +0800 Subject: [PATCH 1/3] =?UTF-8?q?docs:=20=E7=BA=BF=E7=A8=8B=E6=B1=A0?= =?UTF-8?q?=E6=96=87=E7=AB=A0=E6=96=B0=E5=A2=9E=E7=94=9F=E5=91=BD=E5=91=A8?= =?UTF-8?q?=E6=9C=9F=E7=8A=B6=E6=80=81=E3=80=81Worker=E6=9C=BA=E5=88=B6?= =?UTF-8?q?=E3=80=81=E6=8B=92=E7=BB=9D=E7=AD=96=E7=95=A5=E5=BA=94=E7=94=A8?= =?UTF-8?q?=E5=9C=BA=E6=99=AF=E4=B8=89=E8=8A=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../concurrent/java-thread-pool-summary.md | 42 ++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/docs/java/concurrent/java-thread-pool-summary.md b/docs/java/concurrent/java-thread-pool-summary.md index 100a2ff4d27..9e83f33df3a 100644 --- a/docs/java/concurrent/java-thread-pool-summary.md +++ b/docs/java/concurrent/java-thread-pool-summary.md @@ -136,6 +136,32 @@ public class ScheduledThreadPoolExecutor ![线程池各个参数的关系](https://oss.javaguide.cn/github/javaguide/java/concurrent/relationship-between-thread-pool-parameters.png) +### 线程池生命周期状态 + +`ThreadPoolExecutor` 使用 `ctl` 变量(`AtomicInteger` 类型)同时管理线程池的运行状态和工作线程数量。线程池共有 5 种状态: + +- **运行中(`RUNNING`)**:接受新任务,并处理队列中的任务。线程池创建后的初始状态。 +- **关闭(`SHUTDOWN`)**:不再接受新任务,但会继续处理队列中已有的任务。调用 `shutdown()` 后进入。 +- **停止(`STOP`)**:不接受新任务,不处理队列中的任务,并尝试中断正在执行的任务。调用 `shutdownNow()` 后进入。 +- **整理中(`TIDYING`)**:所有任务已终止,工作线程数为 0,即将执行 `terminated()` 钩子方法。 +- **已终止(`TERMINATED`)**:`terminated()` 方法执行完毕,线程池彻底终结。 + +状态只能单向流转:运行中(`RUNNING`)→ 关闭(`SHUTDOWN`)→ 整理中(`TIDYING`)→ 已终止(`TERMINATED`),或者运行中(`RUNNING`)→ 停止(`STOP`)→ 整理中(`TIDYING`)→ 已终止(`TERMINATED`)。在关闭(`SHUTDOWN`)状态下再调用 `shutdownNow()` 也会转为停止(`STOP`)。 + +`shutdown()` 是"温和关闭"——中断空闲线程,但队列中的任务仍会执行完毕。`shutdownNow()` 是"强制关闭"——尝试中断所有正在运行的线程,并将队列中未执行的任务以 `List` 返回。`terminated()` 是一个空的钩子方法,可以通过继承 `ThreadPoolExecutor` 来重写它,用于在线程池终止后做清理工作。 + +### Worker 工作线程机制 + +`ThreadPoolExecutor` 将每个工作线程封装为内部类 `Worker`。`Worker` 继承了 AQS 并实现了 `Runnable` 接口。 + +**为什么 `Worker` 要继承 AQS?** `Worker` 实现了一个**不可重入的独占锁**,用于配合 `shutdown()` 区分线程是空闲还是正在工作——正在执行任务的 Worker 持有锁,`shutdown()` 对每个 Worker 尝试 `tryLock()`,失败则说明该线程正在工作,不会被中断。 + +**Worker 的生命周期:** + +1. **创建**:`execute()` 判断需要新建线程时,调用 `addWorker()` 创建 `Worker` 实例,内部通过 `ThreadFactory` 创建线程。 +2. **运行**:线程启动后进入 `runWorker()` 的 `while` 循环,通过 `getTask()` 不断从队列取任务执行。核心线程用 `workQueue.take()`(阻塞等待),非核心线程用 `workQueue.poll(keepAliveTime, unit)`(超时等待)。 +3. **退出**:`getTask()` 返回 `null` 时 Worker 退出循环并清理。返回 `null` 的情况包括:线程池处于停止(`STOP`)状态、线程池处于关闭(`SHUTDOWN`)状态且队列为空、非核心线程等待超时、或运行时缩小了 `maximumPoolSize`。如果退出后工作线程数低于核心数,会自动补充一个新线程。 + **`ThreadPoolExecutor` 拒绝策略定义:** 如果当前同时运行的线程数量达到最大线程数量并且队列也已经被放满了任务时,`ThreadPoolExecutor` 定义一些策略: @@ -163,6 +189,20 @@ public static class CallerRunsPolicy implements RejectedExecutionHandler { } ``` +### 4 种拒绝策略的实际应用场景 + +上面介绍了 4 种内置拒绝策略的基本行为,下面结合实际生产经验,说明它们各自适合什么场景: + +**`AbortPolicy`**:适用于对任务丢失零容忍的核心业务(如支付、转账)。任务被拒绝时调用方会收到 `RejectedExecutionException`,必须在业务代码中捕获并做补偿(如重试或持久化到数据库后补偿执行)。《阿里巴巴 Java 开发手册》指出,如果不做任何配置,队列满时会直接抛异常,开发者必须显式处理。 + +**`CallerRunsPolicy`**:适用于不允许丢弃任务、且允许降低提交速度的场景。由于任务在调用者线程中执行,调用者在此期间无法提交新任务,形成了一种天然的**反压(back-pressure)**机制。美团技术团队在《Java 线程池实现原理及其在美团业务中的实践》中提到,这是他们线上业务中较常使用的拒绝策略。但需要注意:如果提交任务的线程是 Web 容器的请求处理线程(如 Tomcat 的 Worker 线程),会导致该请求响应时间显著增加,在延迟敏感的场景中需谨慎。 + +**`DiscardPolicy`**:适用于任务允许丢失的非关键路径,如日志异步写入、监控指标上报。该策略完全静默(空实现),被拒绝的任务不会留下任何痕迹,排查问题时可能难以发现任务丢失。 + +**`DiscardOldestPolicy`**:适用于只关心最新数据、旧任务可被覆盖的场景,如实时行情推送、传感器数据采集。需要注意:如果使用了 `PriorityBlockingQueue`,`poll()` 弹出的是优先级最高的任务而非最旧的任务,可能导致重要任务被误丢。 + +**生产环境中的常见做法**:以上 4 种内置策略往往不能完全满足需求。Dubbo 框架自定义了 `AbortPolicyWithReport` 策略,在抛异常之外还会将被拒绝的任务信息 dump 到本地文件,方便事后排查。美团技术团队建议对线程池的拒绝次数进行监控和告警。常见的自定义策略思路包括:将被拒绝的任务写入数据库或消息队列后续补偿消费、递增监控计数器上报 Prometheus、或者调用 `workQueue.put(r)` 阻塞等待队列有空位(Netty 中有类似实现)。 + ### 线程池创建的两种方式 在 Java 中,创建线程池主要有两种方式: @@ -740,7 +780,7 @@ Exception in thread "main" java.util.concurrent.TimeoutException #### 为什么不推荐使用`SingleThreadExecutor`? -`SingleThreadExecutor` 和 `FixedThreadPool` 一样,使用的都是容量为 `Integer.MAX_VALUE` 的 `LinkedBlockingQueue`(无界队列)作为线程池的工作队列。`SingleThreadExecutor` 使用无界队列作为线程池的工作队列会对线程池带来的影响与 `FixedThreadPool` 相同。说简单点,就是可能会导致 OOM。 +`SingleThreadExecutor` 和 `FixedThreadPool` 一样,使用的都是容量为 `Integer.MAX_VALUE` 的 `LinkedBlockingQueue`(无界队列)。`SingleThreadExecutor` 使用无界队列作为线程池的工作队列会对线程池带来的影响与 `FixedThreadPool` 相同。说简单点,就是可能会导致 OOM。 ### CachedThreadPool From 3dddee3333db6477ca8efa562fab27641874c3dd Mon Sep 17 00:00:00 2001 From: REALROOK1E Date: Sun, 8 Mar 2026 06:59:23 +0800 Subject: [PATCH 2/3] =?UTF-8?q?docs:=20=E5=AE=8C=E5=96=84Java=E5=B9=B6?= =?UTF-8?q?=E5=8F=91=E9=9D=A2=E8=AF=95=E9=A2=98=E5=92=8CAQS=E8=AF=A6?= =?UTF-8?q?=E8=A7=A3=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - java-concurrent-questions-02.md: 新增volatile内存屏障类型、读写屏障插入策略、DCL内存屏障分析、volatile与happens-before关系、volatile与synchronized性能对比 - aqs.md: 新增独占模式与共享模式深入对比、Condition条件队列工作机制及源码分析、公平锁与非公平锁性能差异分析 --- docs/java/concurrent/aqs.md | 379 +++++++++++++++++- .../java-concurrent-questions-02.md | 127 ++++++ 2 files changed, 504 insertions(+), 2 deletions(-) diff --git a/docs/java/concurrent/aqs.md b/docs/java/concurrent/aqs.md index 2ac1a44c594..8f45336ebbc 100644 --- a/docs/java/concurrent/aqs.md +++ b/docs/java/concurrent/aqs.md @@ -199,6 +199,93 @@ AQS 定义两种资源共享方式:`Exclusive`(独占,只有一个线程 一般来说,自定义同步器的共享方式要么是独占,要么是共享,他们也只需实现`tryAcquire-tryRelease`、`tryAcquireShared-tryReleaseShared`中的一种即可。但 AQS 也支持自定义同步器同时实现独占和共享两种方式,如`ReentrantReadWriteLock`。 +### 独占模式与共享模式的深入对比 + +上面简要介绍了 AQS 的两种资源共享方式,下面从多个维度对独占模式和共享模式进行系统对比,帮助更深入地理解二者的差异。 + +#### 特性对比 + +| 对比维度 | 独占模式(Exclusive) | 共享模式(Share) | +| --- | --- | --- | +| **并发度** | 同一时刻只有一个线程能获取到资源 | 同一时刻可以有多个线程同时获取到资源 | +| **获取资源入口** | `acquire(int arg)` | `acquireShared(int arg)` | +| **释放资源入口** | `release(int arg)` | `releaseShared(int arg)` | +| **需要重写的模板方法** | `tryAcquire(int)` / `tryRelease(int)` | `tryAcquireShared(int)` / `tryReleaseShared(int)` | +| **tryXxx 返回值** | `boolean`,`true` 表示获取/释放成功 | `int`(获取时),负数表示失败,0 表示成功但无剩余资源,正数表示成功且有剩余资源;`boolean`(释放时) | +| **唤醒后继节点** | 释放资源时唤醒一个后继节点 | 获取资源成功后,如果还有剩余资源,会继续唤醒后续节点(传播唤醒) | +| **Node 类型标识** | `Node.EXCLUSIVE`(`null`) | `Node.SHARED`(一个静态的 `Node` 实例) | +| **典型实现** | `ReentrantLock`、`ReentrantReadWriteLock` 的写锁 | `Semaphore`、`CountDownLatch`、`ReentrantReadWriteLock` 的读锁 | + +#### `state` 在不同同步器中的语义 + +AQS 中的 `state` 是一个通用的同步状态变量,不同的同步器赋予它不同的含义: + +| 同步器 | 模式 | `state` 的语义 | +| --- | --- | --- | +| `ReentrantLock` | 独占 | 表示锁的重入次数。`state == 0` 表示锁空闲;`state > 0` 表示锁被持有,值为重入次数 | +| `ReentrantReadWriteLock` | 独占 + 共享 | 高 16 位表示读锁的持有数量(共享),低 16 位表示写锁的重入次数(独占) | +| `Semaphore` | 共享 | 表示可用许可证的数量。每次 `acquire()` 减少,`release()` 增加 | +| `CountDownLatch` | 共享 | 表示需要等待的计数。每次 `countDown()` 减 1,到 0 时唤醒所有等待线程 | + +下面通过一个代码示例来直观感受独占模式和共享模式在使用上的区别: + +```java +import java.util.concurrent.Semaphore; +import java.util.concurrent.locks.ReentrantLock; + +public class ExclusiveVsSharedDemo { + public static void main(String[] args) { + // 独占模式:同一时刻只有 1 个线程能进入临界区 + ReentrantLock lock = new ReentrantLock(); + + // 共享模式:同一时刻最多 3 个线程能进入临界区 + Semaphore semaphore = new Semaphore(3); + + // 独占模式示例 + Runnable exclusiveTask = () -> { + lock.lock(); + try { + System.out.println(Thread.currentThread().getName() + + " 获取到独占锁,正在执行..."); + Thread.sleep(500); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + lock.unlock(); + } + }; + + // 共享模式示例 + Runnable sharedTask = () -> { + try { + semaphore.acquire(); + System.out.println(Thread.currentThread().getName() + + " 获取到许可证,正在执行..."); + Thread.sleep(500); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + semaphore.release(); + } + }; + + System.out.println("=== 独占模式(ReentrantLock)==="); + for (int i = 0; i < 5; i++) { + new Thread(exclusiveTask, "独占线程-" + i).start(); + } + + try { Thread.sleep(3000); } catch (InterruptedException e) { } + + System.out.println("\n=== 共享模式(Semaphore)==="); + for (int i = 0; i < 5; i++) { + new Thread(sharedTask, "共享线程-" + i).start(); + } + } +} +``` + +运行上面的代码可以观察到:独占模式下 5 个线程严格按顺序一个一个执行,而共享模式下最多有 3 个线程同时执行。 + ### AQS 资源获取源码分析(独占模式) AQS 中以独占模式获取资源的入口方法是 `acquire()` ,如下: @@ -929,9 +1016,296 @@ protected final boolean tryReleaseShared(int releases) { `doReleaseShared()` 方法在前文获取资源(共享模式)的部分已进行了详细的源码分析,此处不再重复。 -## 常见同步工具类 +### Condition 条件队列的工作机制 + +前面在 `waitStatus` 状态表格中提到过 `CONDITION`(值为 -2)状态,表示节点在 Condition 条件队列中等待。这里系统讲解 Condition 条件队列的工作机制。 + +#### 什么是 Condition? + +`Condition` 是 `java.util.concurrent.locks` 包中定义的接口,它提供了类似于 `Object.wait()` / `Object.notify()` 的线程等待/通知机制,但功能更加强大和灵活。`Condition` 必须与 `Lock` 配合使用,就像 `wait/notify` 必须与 `synchronized` 配合使用一样。 + +与 `Object` 的 `wait/notify` 相比,`Condition` 的主要优势在于: + +- **支持多个等待队列**:一个 `Lock` 可以创建多个 `Condition` 实例,不同的线程可以在不同的条件上等待,实现更精细的线程协作。而 `synchronized` 只有一个等待队列。 +- **支持不响应中断的等待**:`Condition` 提供了 `awaitUninterruptibly()` 方法。 +- **支持超时等待**:`Condition` 提供了 `awaitNanos(long)` 和 `await(long, TimeUnit)` 方法,可以设定等待的截止时间。 + +#### AQS 中的两种队列 + +在 AQS 内部实际上维护了 **两种队列**: + +1. **同步队列(CLH 变体队列)**:就是前面详细分析过的双向队列,用于存放获取资源失败而等待的线程节点。 +2. **条件队列(Condition Queue)**:是一个单向链表,用于存放调用了 `Condition.await()` 方法而等待的线程节点。每个 `Condition` 实例维护一个独立的条件队列。 + +条件队列中的节点使用 `Node` 的 `nextWaiter` 指针来链接下一个节点,形成单向链表。条件队列的头节点为 `firstWaiter`,尾节点为 `lastWaiter`。 + +#### Condition 的核心工作流程 + +AQS 的内部类 `ConditionObject` 实现了 `Condition` 接口,其核心方法为 `await()` 和 `signal()`。 + +**`await()` 方法的工作流程:** + +1. 将当前线程封装为 `Node` 节点(`waitStatus` 设置为 `CONDITION`),加入到条件队列的尾部。 +2. 完全释放当前线程持有的锁(即将 `state` 值置为 0),并保存释放前的 `state` 值。 +3. 阻塞当前线程,等待被 `signal()` 唤醒或被中断。 +4. 被唤醒后,重新通过 `acquireQueued()` 进入同步队列竞争锁,并恢复之前保存的 `state` 值(重入次数)。 + +**`signal()` 方法的工作流程:** + +1. 检查调用 `signal()` 的线程是否持有锁(不持有则抛出 `IllegalMonitorStateException`)。 +2. 将条件队列中第一个等待的节点从条件队列移除。 +3. 将该节点的 `waitStatus` 从 `CONDITION` 修改为 `0`,并通过 `enq()` 方法将其加入到同步队列的尾部。 +4. 如果同步队列中前驱节点的状态异常(`CANCELLED`)或者 CAS 设置前驱节点状态为 `SIGNAL` 失败,则直接唤醒该线程。 + +`signalAll()` 方法与 `signal()` 类似,区别在于它会将条件队列中的 **所有** 节点都转移到同步队列中。 + +下面的代码示例展示了 `Condition` 的典型用法——实现一个简单的有界阻塞队列: + +```java +import java.util.LinkedList; +import java.util.Queue; +import java.util.concurrent.locks.Condition; +import java.util.concurrent.locks.ReentrantLock; + +public class SimpleBlockingQueue { + private final Queue queue = new LinkedList<>(); + private final int capacity; + private final ReentrantLock lock = new ReentrantLock(); + // 两个不同的条件队列:分别用于"队列不满"和"队列不空" + private final Condition notFull = lock.newCondition(); + private final Condition notEmpty = lock.newCondition(); + + public SimpleBlockingQueue(int capacity) { + this.capacity = capacity; + } + + /** + * 向队列中添加元素,如果队列已满则等待。 + */ + public void put(T item) throws InterruptedException { + lock.lock(); + try { + // 队列满时,在 notFull 条件上等待 + while (queue.size() == capacity) { + notFull.await(); + } + queue.offer(item); + // 添加元素后,通知在 notEmpty 条件上等待的消费者线程 + notEmpty.signal(); + } finally { + lock.unlock(); + } + } + + /** + * 从队列中取出元素,如果队列为空则等待。 + */ + public T take() throws InterruptedException { + lock.lock(); + try { + // 队列空时,在 notEmpty 条件上等待 + while (queue.isEmpty()) { + notEmpty.await(); + } + T item = queue.poll(); + // 取出元素后,通知在 notFull 条件上等待的生产者线程 + notFull.signal(); + return item; + } finally { + lock.unlock(); + } + } + + public static void main(String[] args) { + SimpleBlockingQueue blockingQueue = new SimpleBlockingQueue<>(5); + + // 生产者线程 + Thread producer = new Thread(() -> { + try { + for (int i = 0; i < 10; i++) { + blockingQueue.put(i); + System.out.println("生产: " + i); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }, "Producer"); + + // 消费者线程 + Thread consumer = new Thread(() -> { + try { + for (int i = 0; i < 10; i++) { + int item = blockingQueue.take(); + System.out.println("消费: " + item); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }, "Consumer"); + + producer.start(); + consumer.start(); + } +} +``` -下面介绍几个基于 AQS 的常见同步工具类。 +在上面的例子中,`notFull` 和 `notEmpty` 是两个独立的 `Condition` 实例,分别维护各自的条件队列。生产者在队列满时在 `notFull` 上等待,消费者在队列空时在 `notEmpty` 上等待。这种分离等待条件的设计,避免了不必要的线程唤醒,比 `synchronized` + `wait/notifyAll` 更加高效。 + +#### `await()` 核心源码分析 + +```java +// AQS 内部类 ConditionObject +public final void await() throws InterruptedException { + if (Thread.interrupted()) + throw new InterruptedException(); + // 1、将当前线程封装为 Node 节点,加入条件队列 + Node node = addConditionWaiter(); + // 2、完全释放锁,并保存释放前的 state 值 + int savedState = fullyRelease(node); + int interruptMode = 0; + // 3、如果节点不在同步队列中,则阻塞当前线程 + while (!isOnSyncQueue(node)) { + LockSupport.park(this); + if ((interruptMode = checkInterruptWhileWaiting(node)) != 0) + break; + } + // 4、被唤醒后,重新进入同步队列竞争锁 + if (acquireQueued(node, savedState) && interruptMode != THROW_IE) + interruptMode = REINTERRUPT; + if (node.nextWaiter != null) + unlinkCancelledWaiters(); + if (interruptMode != 0) + reportInterruptAfterWait(interruptMode); +} +``` + +`await()` 方法中有两个关键操作: + +- `fullyRelease(node)`:完全释放锁(而不是只释放一次),这样即使线程重入了多次锁,也能在等待期间让其他线程获取到锁。被唤醒后会通过 `acquireQueued(node, savedState)` 恢复之前的重入次数。 +- `isOnSyncQueue(node)`:判断节点是否已经被转移到同步队列。当其他线程调用 `signal()` 时,节点会从条件队列转移到同步队列,此时 `isOnSyncQueue()` 返回 `true`,线程退出 `while` 循环,开始竞争锁。 + +### 公平锁与非公平锁的性能差异分析 + +前面的源码分析中,以 `ReentrantLock` 的非公平锁为例讲解了 `tryAcquire()` 的实现。实际上 `ReentrantLock` 同时支持公平锁和非公平锁两种模式。这里深入分析二者的实现差异及其对性能的影响。 + +#### 源码层面的差异 + +`ReentrantLock` 默认使用非公平锁,通过构造参数可以切换为公平锁: + +```java +// 非公平锁(默认) +ReentrantLock unfairLock = new ReentrantLock(); +// 公平锁 +ReentrantLock fairLock = new ReentrantLock(true); +``` + +二者的核心差异在于 `tryAcquire()` 方法的实现。非公平锁的 `nonfairTryAcquire()` 前面已经分析过,下面看公平锁的实现: + +```java +// ReentrantLock.FairSync +protected final boolean tryAcquire(int acquires) { + final Thread current = Thread.currentThread(); + int c = getState(); + if (c == 0) { + // 关键差异:先调用 hasQueuedPredecessors() 判断同步队列中是否有等待更久的线程 + if (!hasQueuedPredecessors() && + compareAndSetState(0, acquires)) { + setExclusiveOwnerThread(current); + return true; + } + } + else if (current == getExclusiveOwnerThread()) { + int nextc = c + acquires; + if (nextc < 0) + throw new Error("Maximum lock count exceeded"); + setState(nextc); + return true; + } + return false; +} +``` + +**唯一的区别** 就是公平锁在 CAS 修改 `state` 之前多了一个 `hasQueuedPredecessors()` 判断: + +```java +// AQS +public final boolean hasQueuedPredecessors() { + Node t = tail; + Node h = head; + Node s; + return h != t && + ((s = h.next) == null || s.thread != Thread.currentThread()); +} +``` + +这个方法用于判断当前线程之前是否有其他线程在排队。如果有,则当前线程不能直接获取锁,必须排队等待,从而保证了 **FIFO** 的公平性。 + +而非公平锁没有这个判断,当锁刚好释放时,新来的线程可以直接通过 CAS 抢到锁,即使同步队列中已经有其他线程在等待。 + +#### 性能差异对比 + +| 对比维度 | 非公平锁(默认) | 公平锁 | +| --- | --- | --- | +| **吞吐量** | 更高。新线程有机会直接获取锁,减少了线程上下文切换 | 较低。所有线程都必须排队,增加了上下文切换的开销 | +| **线程饥饿** | 可能发生。极端情况下某些线程长时间无法获取锁 | 不会发生。严格按照请求顺序分配锁 | +| **上下文切换** | 较少。持有锁的线程释放锁后,新到达的线程可能直接获取锁,不需要唤醒队列中的线程 | 较多。每次释放锁都需要唤醒队列中的下一个线程 | +| **适用场景** | 大多数场景(对响应时间和吞吐量要求较高) | 对公平性有严格要求的场景(如资源分配、任务调度) | + +**为什么非公平锁性能通常更好?** + +关键原因在于 **减少了线程上下文切换的次数**。当持有锁的线程 A 释放锁后: + +- **非公平锁**:此时如果恰好有线程 B 正在尝试获取锁(还没有进入同步队列),线程 B 可以直接通过 CAS 获取到锁并立即执行,省去了唤醒队列中线程的开销。而队列中等待的线程被唤醒后发现锁被占用,会重新阻塞,虽然看起来"浪费"了一次唤醒,但总体上减少了线程切换次数。 +- **公平锁**:线程 B 必须排到队列尾部,然后唤醒队列头部的线程。从线程被唤醒到真正开始执行之间,存在一段 **调度延迟**(线程状态从阻塞切换到运行),在这段延迟期间锁处于空闲状态,降低了锁的利用率。 + +Doug Lea 在 `ReentrantLock` 的文档中指出:使用公平锁的程序在多线程环境下的总体吞吐量通常低于使用非公平锁的程序(即更慢),因此 `ReentrantLock` 默认使用非公平模式。但在需要保证请求处理顺序或避免线程饥饿的场景中(如连接池分配),公平锁是更好的选择。 + +下面通过代码示例来演示公平锁与非公平锁在行为上的差异: + +```java +import java.util.concurrent.locks.ReentrantLock; + +public class FairVsUnfairLockDemo { + // 分别测试公平锁和非公平锁 + private static void testLock(ReentrantLock lock, String lockType) { + System.out.println("=== " + lockType + " ==="); + Runnable task = () -> { + for (int i = 0; i < 2; i++) { + lock.lock(); + try { + System.out.println(Thread.currentThread().getName() + " 获取到锁"); + } finally { + lock.unlock(); + } + } + }; + + Thread[] threads = new Thread[5]; + for (int i = 0; i < 5; i++) { + threads[i] = new Thread(task, lockType + "-线程-" + i); + } + for (Thread t : threads) { + t.start(); + } + for (Thread t : threads) { + try { t.join(); } catch (InterruptedException e) { } + } + System.out.println(); + } + + public static void main(String[] args) { + // 非公平锁:同一个线程可能连续多次获取到锁 + testLock(new ReentrantLock(false), "非公平锁"); + + // 公平锁:线程按请求顺序交替获取锁 + testLock(new ReentrantLock(true), "公平锁"); + } +} +``` + +运行上面的代码可以观察到:非公平锁模式下,同一个线程可能连续多次获取到锁(因为它释放锁后立即又去竞争,有很大概率在队列中的线程被唤醒之前就抢到了锁);而公平锁模式下,线程获取锁的顺序更加均匀,不会出现某个线程连续霸占锁的情况。 + +## 常见同步工具类 ### Semaphore(信号量) @@ -1610,3 +1984,4 @@ threadnum:7is finish - 从 ReentrantLock 的实现看 AQS 的原理及应用: +```` diff --git a/docs/java/concurrent/java-concurrent-questions-02.md b/docs/java/concurrent/java-concurrent-questions-02.md index f261cd10129..78c82fc9140 100644 --- a/docs/java/concurrent/java-concurrent-questions-02.md +++ b/docs/java/concurrent/java-concurrent-questions-02.md @@ -44,6 +44,49 @@ public native void fullFence(); 理论上来说,你通过这个三个方法也可以实现和`volatile`禁止重排序一样的效果,只是会麻烦一些。 +#### 4 种内存屏障类型 + +JMM(Java 内存模型)定义了 4 种内存屏障(Memory Barrier),用于控制特定条件下的指令重排序和内存可见性: + +| 屏障类型 | 指令示例 | 说明 | +| --- | --- | --- | +| **LoadLoad** | `Load1; LoadLoad; Load2` | 保证 `Load1` 的读取操作在 `Load2` 及其后续读取操作之前完成 | +| **StoreStore** | `Store1; StoreStore; Store2` | 保证 `Store1` 的写入操作对其他处理器可见(刷新到内存),先于 `Store2` 及其后续写入操作 | +| **LoadStore** | `Load1; LoadStore; Store2` | 保证 `Load1` 的读取操作在 `Store2` 及其后续写入操作刷新到内存之前完成 | +| **StoreLoad** | `Store1; StoreLoad; Load2` | 保证 `Store1` 的写入操作对其他处理器可见,先于 `Load2` 及其后续读取操作。`StoreLoad` 屏障的开销是四种屏障中最大的,它同时具有其他三种屏障的效果,因此也称为 **全能屏障(Full Barrier)** | + +#### volatile 读写操作的内存屏障插入策略 + +JMM 针对编译器制定了 `volatile` 读写操作的内存屏障插入策略,以确保在任意处理器平台上都能获得正确的 volatile 内存语义: + +**volatile 写操作的内存屏障插入策略:** + +在每个 volatile 写操作的 **前面** 插入一个 `StoreStore` 屏障,在 **后面** 插入一个 `StoreLoad` 屏障。 + +``` +StoreStore 屏障 +volatile 写操作 +StoreLoad 屏障 +``` + +- 前面的 `StoreStore` 屏障:保证在 volatile 写之前,其前面的所有普通写操作已经对任意处理器可见(刷新到主内存)。 +- 后面的 `StoreLoad` 屏障:保证 volatile 写之后,其写入的值对后续的 volatile 读/写操作可见。这是开销最大的屏障,但也是最关键的——它避免了 volatile 写与后面可能有的 volatile 读/写操作发生重排序。 + +**volatile 读操作的内存屏障插入策略:** + +在每个 volatile 读操作的 **后面** 插入一个 `LoadLoad` 屏障和一个 `LoadStore` 屏障。 + +``` +volatile 读操作 +LoadLoad 屏障 +LoadStore 屏障 +``` + +- `LoadLoad` 屏障:保证 volatile 读之后的普通读操作不会被重排序到 volatile 读之前。 +- `LoadStore` 屏障:保证 volatile 读之后的普通写操作不会被重排序到 volatile 读之前。 + +这样一来,volatile 写-读的组合就建立了一个类似于 **锁的释放-获取** 的语义:**volatile 写操作之前的所有操作结果,对于后续对该 volatile 变量的读操作之后的所有操作都是可见的。** + 下面我以一个常见的面试题为例讲解一下 `volatile` 关键字禁止指令重排序的效果。 面试中面试官经常会说:“单例模式了解吗?来给我手写一下!给我解释一下双重检验锁方式实现单例模式的原理呗!” @@ -81,6 +124,67 @@ public class Singleton { 但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 1->3->2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。例如,线程 T1 执行了 1 和 3,此时 T2 调用 `getUniqueInstance`() 后发现 `uniqueInstance` 不为空,因此返回 `uniqueInstance`,但此时 `uniqueInstance` 还未被初始化。 +#### 从内存屏障角度理解 DCL 必须使用 volatile + +上面从指令重排序的角度解释了 DCL 单例中 `uniqueInstance` 为什么需要 `volatile` 修饰。下面从内存屏障的角度进一步分析 `volatile` 是如何解决这个问题的。 + +`uniqueInstance = new Singleton();` 这行代码的三个步骤(分配内存、初始化对象、赋值引用)中,如果不加 `volatile`,步骤 2 和步骤 3 可能会被重排序为 1→3→2。加了 `volatile` 之后,由于 `uniqueInstance` 是 volatile 变量,对它的写操作(步骤 3:将引用赋值给 `uniqueInstance`)会按照前面介绍的 volatile 写的内存屏障插入策略来处理: + +1. 在 volatile 写 **之前** 插入 `StoreStore` 屏障:保证步骤 1(分配内存)和步骤 2(初始化对象)的写操作在步骤 3(赋值引用)之前完成,**禁止了步骤 2 和步骤 3 的重排序**。 +2. 在 volatile 写 **之后** 插入 `StoreLoad` 屏障:保证步骤 3 的写入结果对其他线程立即可见。 + +这样,当线程 T2 读取 `uniqueInstance` 时(volatile 读),如果发现 `uniqueInstance != null`,那么可以保证该对象一定已经被完全初始化了。 + +### volatile 与 happens-before 的关系 + +JMM 中的 happens-before 原则是判断数据是否存在竞争、线程是否安全的重要依据。`volatile` 变量的读写操作与 happens-before 原则有着密切的关系。 + +> 关于 happens-before 原则的详细介绍,可以参考 [JMM(Java 内存模型)详解](https://javaguide.cn/java/concurrent/jmm.html) 这篇文章。 + +happens-before 原则中与 `volatile` 直接相关的是 **volatile 变量规则**: + +> **对一个 volatile 变量的写操作 happens-before 于后续对该 volatile 变量的读操作。** + +也就是说,如果线程 A 写入了一个 volatile 变量,线程 B 随后读取了同一个 volatile 变量,那么线程 A 在写入 volatile 变量之前所做的所有修改(包括对非 volatile 变量的修改),对线程 B 都是可见的。 + +这个规则配合 happens-before 的 **传递性规则**(如果 A happens-before B,B happens-before C,那么 A happens-before C),可以实现一种轻量级的线程间通信。下面通过一个示例来说明: + +```java +public class VolatileHappensBeforeDemo { + private int a = 0; + private int b = 0; + private volatile boolean flag = false; + + // 线程 A 执行 + public void writer() { + a = 1; // 操作1:普通写 + b = 2; // 操作2:普通写 + flag = true; // 操作3:volatile 写 + } + + // 线程 B 执行 + public void reader() { + if (flag) { // 操作4:volatile 读 + int x = a; // 操作5:普通读,x 一定等于 1 + int y = b; // 操作6:普通读,y 一定等于 2 + System.out.println("x=" + x + ", y=" + y); + } + } +} +``` + +上面代码中,happens-before 关系链如下: + +1. 操作1、操作2 happens-before 操作3(**程序顺序规则**:同一线程中,前面的操作 happens-before 后面的操作) +2. 操作3 happens-before 操作4(**volatile 变量规则**:volatile 写 happens-before volatile 读) +3. 操作4 happens-before 操作5、操作6(**程序顺序规则**) + +根据 **传递性**:操作1、操作2 happens-before 操作5、操作6。 + +因此,当线程 B 在操作4 读取到 `flag == true` 时,线程 A 在操作3 之前对 `a` 和 `b` 的修改对线程 B 一定是可见的。这里的关键在于:**volatile 变量的写-读操作,不仅保证了 volatile 变量本身的可见性,还通过 happens-before 的传递性"顺带"保证了其前后普通变量的可见性。** + +这也解释了为什么在实际开发中,`volatile` 经常被用作 **状态标志位**(如上面例子中的 `flag`),它可以在不使用锁的情况下,安全地在线程间传递状态信息,同时保证相关数据的可见性。 + ### volatile 可以保证原子性么? **`volatile` 关键字能保证变量的可见性,但不能保证对变量的操作是原子性的。** @@ -616,6 +720,29 @@ Open JDK 官方声明:[JEP 374: Deprecate and Disable Biased Locking](https:// - `volatile` 关键字能保证数据的可见性,但不能保证数据的原子性。`synchronized` 关键字两者都能保证。 - `volatile`关键字主要用于解决变量在多个线程之间的可见性,而 `synchronized` 关键字解决的是多个线程之间访问资源的同步性。 +#### volatile 与 synchronized 的性能对比 + +上面提到 `volatile` 是线程同步的轻量级实现,性能比 `synchronized` 要好。下面从底层原理的角度分析为什么 `volatile` 性能更好,以及在什么情况下应该选择哪个。 + +周志明在《深入理解 Java 虚拟机》中指出: + +> volatile 变量的读操作的性能消耗与普通变量几乎没有什么差别,但是写操作则可能会慢上一些,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。不过即便如此,大多数场景下 volatile 的总开销仍然要比锁来得更低。 + +二者性能差异的根本原因在于底层实现机制不同: + +| 对比维度 | `volatile` | `synchronized` | +| --- | --- | --- | +| **实现层面** | 通过插入内存屏障指令实现,不涉及线程阻塞和上下文切换 | 依赖操作系统的互斥锁(Mutex Lock),涉及用户态与内核态的切换 | +| **读操作开销** | 与普通变量几乎相同 | 需要获取 monitor 锁,即使无竞争也有一定开销(偏向锁/轻量级锁 CAS) | +| **写操作开销** | 需要插入 `StoreStore` + `StoreLoad` 内存屏障,有一定开销但不会导致线程阻塞 | 需要获取和释放 monitor 锁,有竞争时会导致线程阻塞和上下文切换 | +| **竞争时的表现** | 不会导致线程阻塞,始终是非阻塞的 | 线程竞争激烈时,会频繁发生阻塞和唤醒,上下文切换开销大 | +| **功能范围** | 只能修饰变量,只保证可见性和有序性 | 可以修饰方法和代码块,同时保证可见性、有序性和原子性 | + +**选择建议:** + +- 如果只需要保证变量的可见性(如状态标志位、DCL 单例中的实例引用),优先使用 `volatile`,因为它的开销更小。 +- 如果需要保证复合操作的原子性(如 `i++`、先检查后执行等),则必须使用 `synchronized`、`Lock` 或原子类,`volatile` 无法胜任。 + ## ReentrantLock ### ReentrantLock 是什么? From d11d56bea9ea96ec7e3d852456ca33e6d968ba18 Mon Sep 17 00:00:00 2001 From: REALROOK1E Date: Sun, 8 Mar 2026 07:28:54 +0800 Subject: [PATCH 3/3] =?UTF-8?q?docs:=20=E8=A1=A5=E5=85=85=20ThreadLocal=20?= =?UTF-8?q?=E5=86=85=E5=AD=98=E6=B3=84=E6=BC=8F=E6=B7=B1=E5=85=A5=E5=88=86?= =?UTF-8?q?=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java-concurrent-questions-03.md | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/docs/java/concurrent/java-concurrent-questions-03.md b/docs/java/concurrent/java-concurrent-questions-03.md index a13da622d83..ef3b3269bcd 100644 --- a/docs/java/concurrent/java-concurrent-questions-03.md +++ b/docs/java/concurrent/java-concurrent-questions-03.md @@ -160,6 +160,80 @@ static class Entry extends WeakReference> { 1. 在使用完 `ThreadLocal` 后,务必调用 `remove()` 方法。 这是最安全和最推荐的做法。 `remove()` 方法会从 `ThreadLocalMap` 中显式地移除对应的 entry,彻底解决内存泄漏的风险。 即使将 `ThreadLocal` 定义为 `static final`,也强烈建议在每次使用后调用 `remove()`。 2. 在线程池等线程复用的场景下,使用 `try-finally` 块可以确保即使发生异常,`remove()` 方法也一定会被执行。 +#### 为什么 Entry 的 key 要设计为弱引用? + +这是一个经典的面试追问。很多同学知道 `ThreadLocalMap` 的 key 是弱引用,但不清楚**为什么要这样设计**,以及如果换成强引用会怎样。 + +我们先来看完整的引用链路。当一个线程使用 `ThreadLocal` 时,涉及以下引用关系: + +``` +强引用(栈/静态变量)──→ ThreadLocal 实例 + ↑ +Thread ──→ ThreadLocalMap ──→ Entry ─── key(WeakReference)──┘ + │ + └─── value(强引用)──→ 实际存储的对象 +``` + +理解了这条引用链路,我们来对比两种设计方案: + +**假设 key 使用强引用(实际没有采用):** + +当业务代码中的 `ThreadLocal` 引用被置为 `null`(例如方法执行结束、对象被回收),此时虽然业务代码已经不再需要这个 `ThreadLocal`,但由于 `ThreadLocalMap` 的 Entry 对 key 持有**强引用**,`ThreadLocal` 实例仍然无法被 GC 回收。只要线程不终止,这个 `ThreadLocal` 和它对应的 value 都会一直存在于内存中,造成 key 和 value **都无法回收**的内存泄漏。 + +**key 使用弱引用(实际采用的方案):** + +当业务代码中的 `ThreadLocal` 引用被置为 `null` 后,由于 Entry 的 key 是弱引用,`ThreadLocal` 实例在下次 GC 时会被回收,key 变为 `null`。此时虽然 value 仍然存在(强引用),但 `ThreadLocalMap` 在执行 `get()`、`set()`、`remove()` 等操作时,会主动探测并清理这些 key 为 `null` 的 "stale entry"(过期条目),从而释放 value 对象。 + +也就是说,**弱引用的设计是一种"兜底"防御机制**——即便开发者忘记调用 `remove()`,JVM 的 GC 配合 `ThreadLocalMap` 的自清理逻辑,仍然有机会回收泄漏的数据。而如果使用强引用,一旦忘记 `remove()`,就完全没有任何补救机会了。 + +> 需要注意的是,这种自清理机制是**被动触发**的(只在 `get`/`set`/`remove` 操作时顺便清理),并不能保证所有过期条目都被及时清理。因此,**弱引用只是降低了内存泄漏的风险,并没有彻底消除它**,手动调用 `remove()` 仍然是必须的。 + +#### 线程池场景下的特殊风险 + +上面提到内存泄漏的条件之一是"线程持续存活"。在使用 `new Thread()` 创建线程的场景下,线程执行完毕后会被销毁,其持有的 `ThreadLocalMap` 也会随之被 GC 回收,泄漏的影响相对有限。 + +但在**线程池**场景下,问题会被严重放大。线程池中的核心线程默认不会被销毁,它们会被反复复用来执行不同的任务。这意味着: + +1. **内存泄漏持续累积**:每个任务如果使用了 `ThreadLocal` 却没有清理,其 value 就会一直残留在该线程的 `ThreadLocalMap` 中。随着任务不断提交和执行,泄漏的数据会越积越多,最终可能导致 OOM。 +2. **数据污染(脏数据)**:上一个任务设置的 `ThreadLocal` 值,如果没有被清理,下一个被分配到同一线程的任务就能读取到这个残留值。这可能导致严重的业务逻辑错误,比如用户 A 的请求读取到了用户 B 的身份信息。 + +**美团技术团队的真实事故案例:** + +美团技术团队在[《Java 线程池实现原理及其在美团业务中的实践》](https://tech.meituan.com/2020/04/02/java-pooling-pratice-in-meituan.html)一文中就记录了一次因 `ThreadLocal` 使用不当引发的线上事故:在一个依赖 `ThreadLocal` 传递用户上下文的 Web 应用中,由于使用了线程池处理请求,且没有在请求结束后清理 `ThreadLocal`,导致**后续请求复用了同一线程时,读取到了前一个请求遗留的用户信息**,造成了用户数据串号的严重问题。 + +#### 阿里巴巴 Java 开发手册的强制规约 + +正因为线程池 + `ThreadLocal` 的组合如此容易踩坑,《阿里巴巴 Java 开发手册》在"并发处理"章节中对此做出了**强制**级别的要求: + +> **【强制】** 必须回收自定义的 `ThreadLocal` 变量记录的当前线程的值,尤其在线程池场景下,线程经常会被复用,如果不清理自定义的 `ThreadLocal` 变量,可能会影响后续业务逻辑和造成内存泄露等问题。尽量在代理中使用 `try-finally` 块进行回收。 + +正确的使用模式如下: + +```java +// 定义为 static final,避免重复创建 ThreadLocal 实例 +private static final ThreadLocal userContextHolder = new ThreadLocal<>(); + +public void processRequest(HttpServletRequest request) { + try { + // 在 try 块中设置值 + UserContext context = buildUserContext(request); + userContextHolder.set(context); + + // 执行业务逻辑 + doBusinessLogic(); + } finally { + // 在 finally 块中必须清理,确保无论是否发生异常都会执行 + userContextHolder.remove(); + } +} +``` + +这里有三个关键要点: + +1. **`ThreadLocal` 声明为 `static final`**:确保整个应用只有一个 `ThreadLocal` 实例,避免因重复创建导致旧实例失去强引用后 key 被回收,加剧内存泄漏。 +2. **`try-finally` 保证 `remove()` 一定被执行**:即使业务逻辑抛出异常,`finally` 块也能确保 `ThreadLocal` 被清理。 +3. **在使用完毕后立即清理,而不是在下次使用前设置**:在使用前 `set()` 虽然可以覆盖旧值解决脏数据问题,但无法解决上一次任务遗留 value 的内存占用问题。只有在用完后 `remove()`,才能同时避免内存泄漏和数据污染。 + ### ⭐️如何跨线程传递 ThreadLocal 的值? **为什么 ThreadLocal 在异步场景下会失效?**