diff --git a/docs/java/concurrent/aqs.md b/docs/java/concurrent/aqs.md index 3b9fdb881ff..02eb39ea080 100644 --- a/docs/java/concurrent/aqs.md +++ b/docs/java/concurrent/aqs.md @@ -201,6 +201,150 @@ AQS 定义两种资源共享方式:`Exclusive`(独占,只有一个线程 一般来说,自定义同步器的共享方式要么是独占,要么是共享,他们也只需实现`tryAcquire-tryRelease`、`tryAcquireShared-tryReleaseShared`中的一种即可。但 AQS 也支持自定义同步器同时实现独占和共享两种方式,如`ReentrantReadWriteLock`。 +#### 独占模式与共享模式的深入对比 + +虽然都是基于 AQS 实现,但独占模式和共享模式在设计理念和实现细节上有着本质的区别。 + +**独占模式(Exclusive)的特点:** + +1. **排他性**:同一时刻只能有一个线程持有资源。当一个线程获取到资源后,其他线程必须等待该线程释放资源才能竞争。 +2. **state 语义**:`state` 表示资源的占用状态和重入次数。`state = 0` 表示未被占用,`state > 0` 表示被占用(值代表重入次数)。 +3. **实现方法**:需要实现 `tryAcquire()` 和 `tryRelease()` 方法。 +4. **典型应用**:`ReentrantLock`、`ReentrantReadWriteLock` 的写锁。 + +**共享模式(Shared)的特点:** + +1. **共享性**:允许多个线程同时持有资源。一个线程获取资源成功后,可能会触发其他等待线程的连锁唤醒。 +2. **state 语义**:`state` 表示剩余可用资源数量。比如 `Semaphore` 中,`state` 表示剩余许可证数量;`CountDownLatch` 中,`state` 表示还需要完成的计数。 +3. **实现方法**:需要实现 `tryAcquireShared()` 和 `tryReleaseShared()` 方法。返回值有特殊含义:负数表示失败,0 表示成功但无剩余资源,正数表示成功且有剩余资源。 +4. **典型应用**:`Semaphore`、`CountDownLatch`、`CyclicBarrier`、`ReentrantReadWriteLock` 的读锁。 + +**核心区别举例**: + +假设有一个停车场,有 10 个车位。 + +- **独占模式**就像是**单人车位**:一个车位只能停一辆车,其他车必须等这辆车开走才能进来。这就是 `ReentrantLock` 的模型。 +- **共享模式**就像是**共享停车场**:10 个车位可以同时停 10 辆车,每辆车进来时检查是否还有空位(`state > 0`),有就停进去并把可用车位数减 1。这就是 `Semaphore` 的模型。 + +**传播机制的区别**: + +这是共享模式最特殊的地方。在共享模式下,当一个线程释放资源后,不仅会唤醒后继节点,被唤醒的节点获取资源成功后,还可能继续唤醒它的后继节点,形成"传播效应"。这就是为什么会有 `PROPAGATE` 状态的原因。 + +而在独占模式下,资源释放后只会唤醒一个后继节点,不存在连锁唤醒的情况。 + +**举个例子**:想象一个会议室预订系统。如果是独占模式,一个会议室同一时间只能被一个团队使用。但如果改成共享模式(比如一个大型阶梯教室可以容纳多个小组同时开会),当有一个小组释放了位置,可能会触发多个等待的小组同时进入。第一个小组进入后发现还有空间,就会通知第二个小组也可以进来(传播机制),第二个小组进入后发现还有空间,又会通知第三个小组,形成连锁反应。 + +### Condition 条件队列的工作机制 + +Condition 是 AQS 提供的另一个重要功能,它实现了类似 `Object.wait()` 和 `Object.notify()` 的等待/通知机制,但功能更强大也更灵活。 + +#### Condition 队列与同步队列的关系 + +AQS 内部实际上维护了**两种队列**: + +1. **同步队列(Sync Queue)**:就是前面讲的 CLH 变体队列,用于线程竞争锁。 +2. **条件队列(Condition Queue)**:每个 Condition 对象内部维护的单向链表,用于线程等待特定条件。 + +一个锁可以有多个 Condition 对象,每个 Condition 都有自己独立的条件队列。这比 `synchronized` 的 `wait/notify` 机制灵活得多,后者只有一个等待队列。 + +#### Condition 的工作流程 + +**await() 操作的流程:** + +1. 当前线程必须先持有锁(否则抛出 `IllegalMonitorStateException`) +2. 将当前线程封装成 Node 节点,加入到 Condition 的条件队列尾部 +3. 完全释放锁(即使是重入锁,也要将 state 减到 0) +4. 阻塞当前线程,等待被 `signal` 唤醒 +5. 被唤醒后,节点从条件队列移到同步队列,重新竞争锁 +6. 获取锁成功后,从 `await()` 方法返回 + +**signal() 操作的流程:** + +1. 当前线程必须先持有锁 +2. 从条件队列的头部取出一个节点 +3. 将这个节点从条件队列移到同步队列 +4. 通过 `unpark` 唤醒该节点对应的线程 +5. 被唤醒的线程会在同步队列中竞争锁 + +**举个生动的例子**: + +可以把 Condition 想象成医院的候诊系统: + +- **同步队列**就像是**挂号大厅**:所有人都在这里排队等待叫号看病(竞争锁) +- **条件队列**就像是**检查室的等候区**:医生让你去做检查,你从挂号大厅(同步队列)进入检查等候区(条件队列),等待检查结果(await) +- 当检查结果出来(signal),护士会通知你,你再次回到挂号大厅(同步队列)重新排队等待叫号 + +一个医院可以有多个检查室(多个 Condition 对象),每个检查室都有自己的等候区(独立的条件队列)。这比 `synchronized` 只有一个等候区要灵活得多。 + +#### Condition 的经典应用场景 + +**生产者-消费者模型**: + +```java +// 使用两个 Condition 分别控制生产和消费 +Condition notFull = lock.newCondition(); // 队列未满 +Condition notEmpty = lock.newCondition(); // 队列非空 + +// 生产者 +while (队列已满) { + notFull.await(); // 等待队列有空位 +} +// 生产数据 +notEmpty.signal(); // 通知消费者来消费 + +// 消费者 +while (队列为空) { + notEmpty.await(); // 等待队列有数据 +} +// 消费数据 +notFull.signal(); // 通知生产者可以生产 +``` + +这种设计比使用 `synchronized` + `wait/notify` 更清晰,避免了"惊群效应"(所有等待线程都被唤醒但只有一个能工作)。 + +### 公平锁与非公平锁的性能对比 + +在 AQS 的基础上,可以实现公平锁和非公平锁两种策略,它们在性能和公平性之间做了不同的权衡。 + +#### 实现机制的区别 + +**公平锁(Fair Lock)**: +- 严格按照线程到达的顺序分配锁 +- 新来的线程总是先检查队列中是否有等待的线程,如果有就乖乖排队 +- 实现:在 `tryAcquire()` 中会调用 `hasQueuedPredecessors()` 检查队列中是否有前驱节点 + +**非公平锁(Nonfair Lock)**: +- 新来的线程会先尝试抢占锁,抢占失败才排队 +- 如果锁刚好被释放,新线程可能直接获得锁,而不管队列中是否有等待的线程 +- 实现:直接执行 CAS 操作尝试获取锁,不检查队列 + +#### 性能对比与权衡 + +根据 Doug Lea(AQS 作者)的测试和实际生产环境的统计: + +**吞吐量对比**: +- 在高并发场景下,非公平锁的吞吐量通常是公平锁的 **10-20 倍** +- 原因:减少了线程切换的开销。公平锁每次都要排队,涉及线程的阻塞和唤醒;非公平锁允许"插队",如果锁刚好可用,当前线程可以立即获得,避免了上下文切换 + +**响应时间对比**: +- 公平锁的响应时间更稳定,不会出现某个线程长时间得不到锁的情况 +- 非公平锁的平均响应时间更短,但可能出现"线程饥饿"现象(某些线程长时间得不到锁) + +**实际应用建议**: + +1. **默认使用非公平锁**:`ReentrantLock` 的无参构造器创建的就是非公平锁,这是因为在大多数场景下,吞吐量比顺序性更重要。 +2. **需要严格顺序时使用公平锁**:比如任务调度系统、售票系统等需要保证先来先服务的场景。 +3. **阿里巴巴的实践**:在《Java 开发手册》中建议,除非业务场景明确需要公平性,否则应该使用非公平锁以获得更好的性能。 + +**举个例子**: + +想象一个咖啡店的点单台: + +- **公平锁模式**:所有顾客必须严格按照到达顺序排队,即使前一个顾客刚点完单走开,收银员在处理订单,新来的顾客也必须等前面所有人都轮到后才能点单。 +- **非公平锁模式**:如果前一个顾客刚点完单,收银员正好空闲,新来的顾客可以直接上前点单,不用管后面是否还有排队的人。虽然可能对排队的人不公平,但整体效率更高,顾客等待时间的平均值更短。 + +在大型互联网公司的实践中(如阿里、美团),非公平锁因为性能优势被广泛采用。但在金融系统、抢票系统等需要保证公平性的场景,会选择使用公平锁,即使牺牲一些性能。 + ### AQS 资源获取源码分析(独占模式) AQS 中以独占模式获取资源的入口方法是 `acquire()` ,如下: diff --git a/docs/java/concurrent/cas.md b/docs/java/concurrent/cas.md index b2b25f19f99..cdd52da261f 100644 --- a/docs/java/concurrent/cas.md +++ b/docs/java/concurrent/cas.md @@ -147,6 +147,86 @@ public boolean compareAndSet(V expectedReference, } ``` +#### ABA 问题的实际影响场景 + +虽然 ABA 问题在理论上存在,但在实际应用中是否真的会造成影响需要具体分析: + +**无影响场景**: 如果只关心变量的当前值而不关心变更历史,ABA 问题通常不会造成实际影响。例如 `AtomicInteger` 的累加操作,即使值经历了 A→B→A 的变化,最终累加结果仍然正确。 + +**有影响场景**: 在需要维护数据结构完整性的场景中,ABA 问题可能导致严重错误。典型案例是**无锁栈**的出栈操作: + +``` +线程 1 准备 CAS 弹出节点 A (A→B→C) +线程 2 弹出 A 和 B,然后重新压入 A (栈变为 A→C) +线程 1 的 CAS 成功(发现栈顶仍是 A),将 next 指向原来的 B +结果: 节点 C 丢失,B 可能已被回收导致悬挂指针 +``` + +因此,涉及指针操作和数据结构维护时,应使用 `AtomicStampedReference` 或 `AtomicMarkableReference` 避免 ABA 问题。 + +### CAS 的 CPU 层面实现 + +在 x86 架构下,CAS 操作最终会被编译为 **CMPXCHG 指令**,并配合 **LOCK 前缀**保证原子性: + +```assembly +LOCK CMPXCHG [内存地址], 新值 +``` + +**LOCK 前缀的作用:** + +1. **锁定总线或缓存行**: 在多核处理器中,LOCK 前缀会锁定相关的缓存行(而非整个总线),防止其他 CPU 同时修改该内存位置 +2. **保证内存可见性**: 强制将写缓冲区的数据刷新到主内存,并使其他 CPU 的缓存行失效 + +这就是为什么 CAS 能够在硬件层面保证原子性的原因。相比 synchronized 的重量级锁,CAS 仅锁定单个缓存行,因此开销小得多。 + +### 自旋与阻塞的性能权衡 + +CAS 失败时会进行自旋重试,这在不同竞争程度下有不同的性能表现: + +**低竞争场景**: CAS 几乎不会失败,自旋开销极小,性能远超加锁方式(避免了线程上下文切换)。 + +**高竞争场景**: CAS 频繁失败导致大量自旋,CPU 空转消耗资源。此时 synchronized 或 Lock 的阻塞机制反而更优,因为线程会被挂起释放 CPU。 + +JDK 的 **自适应自旋锁** 就是基于这种权衡:JVM 会根据历史竞争情况动态调整自旋次数,在高竞争时及时转为阻塞。 + +### LongAdder 的分段 CAS 优化 + +在高并发计数场景下,多个线程对同一个 `AtomicLong` 进行 CAS 更新会导致激烈竞争。`LongAdder` 通过**分段计数**大幅降低竞争: + +**核心思想**: 维护一个 `Cell` 数组,每个线程优先更新自己的 Cell,最终求和获取总值。 + +```java +// LongAdder 内部结构 +transient volatile Cell[] cells; +transient volatile long base; + +final void longAccumulate(long x, ...) { + // 如果 cells 为空,先尝试更新 base + if (cells == null) { + if (casBase(b = base, b + x)) + return; + } + // 否则找到线程对应的 Cell 进行更新 + Cell c = cells[getProbe() & m]; + if (c.cas(v = c.value, v + x)) + return; + // 更新失败则扩容或rehash +} + +public long sum() { + Cell[] cs = cells; + long sum = base; + if (cs != null) { + for (Cell c : cs) + if (c != null) + sum += c.value; + } + return sum; +} +``` + +这种设计与 `ConcurrentHashMap` 的 `counterCells` 机制完全一致,都是通过**分散热点、化整为零**的思想实现高并发下的性能优化。在累加场景下,`LongAdder` 的性能可比 `AtomicLong` 高出数倍。 + ### 循环时间长开销大 CAS 经常会用到自旋操作来进行重试,也就是不成功就一直循环执行直到成功。如果长时间不成功,会给 CPU 带来非常大的执行开销。 diff --git a/docs/java/concurrent/java-thread-pool-summary.md b/docs/java/concurrent/java-thread-pool-summary.md index 283871b7988..957fcd6c758 100644 --- a/docs/java/concurrent/java-thread-pool-summary.md +++ b/docs/java/concurrent/java-thread-pool-summary.md @@ -138,6 +138,66 @@ public class ScheduledThreadPoolExecutor ![线程池各个参数的关系](https://oss.javaguide.cn/github/javaguide/java/concurrent/relationship-between-thread-pool-parameters.png) +### 线程池的生命周期状态 + +理解线程池的生命周期状态对于正确使用和管理线程池至关重要。`ThreadPoolExecutor` 使用一个 `AtomicInteger` 类型的 `ctl` 变量来同时存储两个信息:线程池的运行状态(runState)和工作线程的数量(workerCount)。 + +线程池有以下五种状态: + +- **RUNNING(运行状态)**:线程池创建后就处于 RUNNING 状态,能够接收新任务,也能处理阻塞队列中的任务。 +- **SHUTDOWN(关闭状态)**:调用 `shutdown()` 方法后进入此状态。不再接受新任务,但会继续处理阻塞队列中已有的任务。 +- **STOP(停止状态)**:调用 `shutdownNow()` 方法后进入此状态。不接受新任务,也不处理阻塞队列中的任务,并且会中断正在执行的任务。 +- **TIDYING(整理状态)**:所有任务都已终止,工作线程数量为 0,线程池会转到 TIDYING 状态,并准备执行 `terminated()` 钩子方法。 +- **TERMINATED(终止状态)**:`terminated()` 方法执行完成后,线程池进入此状态,表示线程池彻底终止。 + +这些状态之间的转换关系如下: + +1. **RUNNING → SHUTDOWN**:调用 `shutdown()` 方法 +2. **RUNNING 或 SHUTDOWN → STOP**:调用 `shutdownNow()` 方法 +3. **SHUTDOWN → TIDYING**:当队列和线程池都为空时 +4. **STOP → TIDYING**:当线程池为空时 +5. **TIDYING → TERMINATED**:当 `terminated()` 钩子方法执行完成时 + +**举个例子**:想象一个餐厅的营业流程。RUNNING 状态就像餐厅正常营业,可以接待新客人(新任务)也在服务已经就座的客人(队列中的任务)。当餐厅准备打烊时调用 `shutdown()`,这时进入 SHUTDOWN 状态——不再接待新客人,但会把已经就座的客人服务完。如果遇到紧急情况需要立即关门,调用 `shutdownNow()` 进入 STOP 状态——不接待新客人,也请已就座的客人离开。最后所有客人都离开、员工下班,餐厅进入 TIDYING 然后 TERMINATED 状态,彻底关闭。 + +### Worker 工作线程的生命周期 + +`ThreadPoolExecutor` 内部使用 `Worker` 类来封装工作线程。每个 `Worker` 对象都包装了一个线程(`Thread`),并且 `Worker` 本身也实现了 `Runnable` 接口。 + +#### Worker 的创建与启动 + +当需要新建工作线程时(比如任务提交时发现当前线程数小于核心线程数),线程池会: + +1. 创建一个新的 `Worker` 对象,将第一个任务传递给它 +2. `Worker` 内部会通过 `ThreadFactory` 创建一个新线程 +3. 将这个 `Worker` 添加到工作线程集合 `workers` 中(这是一个 `HashSet`) +4. 启动 `Worker` 内部的线程,开始执行任务 + +#### Worker 的任务执行循环 + +`Worker` 启动后,会进入一个循环(在 `runWorker()` 方法中): + +1. 首先执行传递进来的第一个任务(如果有) +2. 任务执行完毕后,通过 `getTask()` 方法从阻塞队列中获取新任务 +3. 如果获取到任务,继续执行;如果获取不到(返回 null),则跳出循环 +4. 跳出循环后,工作线程进入退出流程 + +**什么时候 `getTask()` 会返回 null 呢?** 主要有以下几种情况: + +- 线程池状态为 STOP 或以上 +- 线程池状态为 SHUTDOWN 并且队列为空 +- 当前线程数超过核心线程数,并且从队列获取任务超时(等待 `keepAliveTime` 时间) + +#### Worker 的销毁 + +当 `getTask()` 返回 null 后,Worker 会执行 `processWorkerExit()` 方法退出: + +1. 将自己从工作线程集合 `workers` 中移除 +2. 尝试将线程池状态转换为 TERMINATED +3. 如果线程是异常退出,可能会创建新的工作线程来替代 + +**举个例子**:可以把 Worker 想象成餐厅的服务员。餐厅开业时(线程池运行),根据需要招聘一定数量的服务员(创建 Worker)。每个服务员会不断从订单队列中取订单来处理(从阻塞队列获取任务)。如果订单队列长时间没有新订单,并且服务员数量超过了基本配置(超过核心线程数),一些服务员会在等待一段时间(keepAliveTime)后自动离职(Worker 销毁)。但至少会保留基本数量的服务员(核心线程数),确保餐厅随时能处理突然到来的订单。 + **`ThreadPoolExecutor` 拒绝策略定义:** 如果当前同时运行的线程数量达到最大线程数量并且队列也已经被放满了任务时,`ThreadPoolExecutor` 定义一些策略: @@ -165,6 +225,46 @@ public static class CallerRunsPolicy implements RejectedExecutionHandler { } ``` +### 拒绝策略的实际应用场景 + +不同的拒绝策略适用于不同的业务场景,选择合适的策略对系统的稳定性和性能至关重要。 + +#### AbortPolicy —— 快速失败,适合关键业务 + +`AbortPolicy` 是默认策略,当任务无法提交时直接抛出 `RejectedExecutionException` 异常。这种策略适合那些**不能容忍任务丢失**的场景,通过抛出异常让调用方感知到问题并进行相应处理。 + +**典型场景**:订单支付、金融交易等核心业务。当线程池饱和时,宁可让用户感知到系统繁忙,也不能默默地丢弃支付请求。 + +**举个例子**:假设一个电商系统的支付线程池,核心线程数 10,最大线程数 20,队列容量 100。在双十一高峰期,如果队列已满且 20 个线程都在处理任务,此时新来的支付请求会抛出异常。系统可以捕获这个异常,给用户返回"系统繁忙,请稍后重试"的提示,或者将请求放入降级处理队列。 + +#### CallerRunsPolicy —— 降低提交速度,适合可容忍延迟的场景 + +`CallerRunsPolicy` 会让调用线程自己执行被拒绝的任务,这样做有两个效果:一是不会丢弃任务,二是由于调用线程被占用,提交任务的速度会自然降低,形成一种"负反馈"机制。 + +**典型场景**:日志记录、数据统计、非实时性的数据同步。这些场景可以容忍一定的延迟,不能丢失数据但也不需要立即处理。 + +**实际案例**:阿里巴巴在《Java 开发手册》中推荐在一些场景使用 `CallerRunsPolicy`。比如 Dubbo 的线程池默认使用此策略,当服务端线程池满时,让调用方(客户端的业务线程)来执行任务,这样既保证了任务不丢失,又通过占用客户端线程自然地限制了请求速率。 + +**需要注意的风险**:如果提交任务的线程很关键(比如 Netty 的 IO 线程、消息队列的消费线程),使用 `CallerRunsPolicy` 会导致这些关键线程被阻塞,反而可能引发更严重的问题。因此要谨慎评估调用线程的重要性。 + +#### DiscardPolicy 和 DiscardOldestPolicy —— 丢弃任务,适合可容忍丢失的场景 + +`DiscardPolicy` 直接丢弃新任务,`DiscardOldestPolicy` 丢弃队列中最老的任务然后尝试提交新任务。这两种策略都会导致任务丢失,只适合对数据完整性要求不高的场景。 + +**典型场景**:访问量统计、实时监控数据采集、用户行为埋点等。这些场景下,丢失少量数据不会影响整体的业务逻辑,反而能保证系统在高负载下的稳定性。 + +**举个例子**:一个网站的访问量统计系统,每分钟可能有数百万次页面访问记录需要处理。如果统计线程池饱和,使用 `DiscardPolicy` 丢弃部分访问记录是可以接受的,因为即使丢失 1% 的数据,仍然能够准确反映整体的访问趋势。这比让整个统计系统因为过载而崩溃要好得多。 + +#### 自定义拒绝策略 —— 生产环境的最佳实践 + +在生产环境中,很多公司会实现自定义的拒绝策略来满足特定需求。常见的做法包括: + +1. **记录日志并告警**:任务被拒绝时记录详细日志并触发监控告警,帮助运维人员及时发现问题 +2. **放入备用队列**:将被拒绝的任务放入 Redis、MQ 等外部队列,后续异步处理 +3. **降级处理**:执行简化版本的任务逻辑,保证核心功能可用 + +**美团的实践案例**:美团的动态线程池组件会记录任务拒绝的指标(如拒绝次数、拒绝率),并在控制台实时展示。当拒绝率超过阈值时自动告警,运维人员可以动态调整线程池参数而无需重启应用。 + ### 线程池创建的两种方式 在 Java 中,创建线程池主要有两种方式: diff --git a/docs/java/concurrent/jmm.md b/docs/java/concurrent/jmm.md index 9afe92b3ff7..e736bd4310c 100644 --- a/docs/java/concurrent/jmm.md +++ b/docs/java/concurrent/jmm.md @@ -223,7 +223,116 @@ happens-before 与 JMM 的关系用《Java 并发编程的艺术》这本书中 > **指令重排序可以保证串行语义一致,但是没有义务保证多线程间的语义也一致** ,所以在多线程下,指令重排序可能会导致一些问题。 -在 Java 中,`volatile` 关键字可以禁止指令进行重排序优化。 +在 Java 中,`volatile` 关键字可以禁止指令进行重排序优化。 + +### volatile 的内存屏障机制 + +volatile 保证可见性和有序性的底层实现依赖于**内存屏障**。JMM 会在 volatile 变量的读写操作前后插入特定的内存屏障,禁止特定类型的重排序。 + +**内存屏障的四种类型:** + +1. **LoadLoad 屏障**: 确保屏障前的读操作先于屏障后的读操作 +2. **StoreStore 屏障**: 确保屏障前的写操作先于屏障后的写操作 +3. **LoadStore 屏障**: 确保屏障前的读操作先于屏障后的写操作 +4. **StoreLoad 屏障**: 确保屏障前的写操作先于屏障后的读操作(开销最大) + +**volatile 写操作的内存屏障插入策略:** + +``` +StoreStore 屏障 +volatile 写操作 +StoreLoad 屏障 +``` + +这保证了:volatile 写之前的所有普通写不会被重排序到 volatile 写之后,且 volatile 写的结果立即对其他线程可见。 + +**volatile 读操作的内存屏障插入策略:** + +``` +volatile 读操作 +LoadLoad 屏障 +LoadStore 屏障 +``` + +这保证了:volatile 读之后的所有普通读写不会被重排序到 volatile 读之前。 + +### volatile 与 happens-before 的关系 + +volatile 变量规则是 happens-before 原则的重要体现。根据《深入理解 Java 虚拟机》(周志明著)的描述,volatile 变量的内存语义可以总结为: + +- **写操作**: 当写一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量值立即刷新到主内存 +- **读操作**: 当读一个 volatile 变量时,JMM 会把该线程对应的本地内存置为无效,直接从主内存中读取共享变量 + +通过这种机制,volatile 确保了线程间的可见性传递: + +```java +class VolatileExample { + int a = 0; + volatile boolean flag = false; + + public void writer() { + a = 1; // 1 + flag = true; // 2 volatile写 + } + + public void reader() { + if (flag) { // 3 volatile读 + int i = a; // 4 一定能看到a=1 + } + } +} +``` + +根据 happens-before 规则: +- 1 happens-before 2 (程序顺序规则) +- 2 happens-before 3 (volatile 规则) +- 3 happens-before 4 (程序顺序规则) +- 因此 1 happens-before 4 (传递性),操作 4 一定能看到操作 1 的结果 + +### volatile 与 synchronized 的性能对比 + +在《深入理解 Java 虚拟机》第三版中,周志明指出 volatile 的性能优势主要体现在: + +1. **无锁机制**: volatile 不需要加锁,不会阻塞线程,而 synchronized 会导致线程上下文切换 +2. **轻量级同步**: volatile 只保证单个变量的可见性,synchronized 保证整个代码块的原子性 +3. **性能差异**: 在高竞争场景下,volatile 读写操作比 synchronized 快约 10 倍以上 + +**适用场景对比:** + +- **使用 volatile**: 单个变量的状态标志、双重检查锁定中的对象引用 +- **使用 synchronized**: 需要原子性保证的复合操作(如 i++) + +### Double-Checked Locking(DCL) 的 volatile 应用 + +DCL 单例模式是 volatile 最经典的应用场景,必须用 volatile 修饰实例变量: + +```java +public class Singleton { + private volatile static Singleton instance; + + private Singleton() {} + + public static Singleton getInstance() { + if (instance == null) { // 第一次检查 + synchronized (Singleton.class) { + if (instance == null) { // 第二次检查 + instance = new Singleton(); + } + } + } + return instance; + } +} +``` + +**为什么必须用 volatile?** + +`instance = new Singleton()` 在字节码层面分为三步: +1. 分配内存空间 +2. 初始化对象 +3. 将 instance 指向内存地址 + +如果发生指令重排序(2 和 3 交换),可能导致其他线程获取到未初始化完成的对象。volatile 禁止这种重排序,保证对象完全初始化后才对其他线程可见。 ## 总结 diff --git a/docs/java/concurrent/threadlocal.md b/docs/java/concurrent/threadlocal.md index b560ad85258..6cc1685b3ed 100644 --- a/docs/java/concurrent/threadlocal.md +++ b/docs/java/concurrent/threadlocal.md @@ -179,6 +179,149 @@ new ThreadLocal<>().set(s); 如果我们的**强引用**不存在的话,那么 `key` 就会被回收,也就是会出现我们 `value` 没被回收,`key` 被回收,导致 `value` 永远存在,出现内存泄漏。 +### ThreadLocal 内存泄漏的完整分析 + +#### 为什么 Entry 的 key 要设计成弱引用? + +很多人会疑惑,既然弱引用会导致内存泄漏问题,为什么 `ThreadLocalMap` 还要把 key 设计成弱引用呢?直接用强引用不就没有内存泄漏的问题了吗? + +**首先要明确一点**:前面提到"在 `ThreadLocal.get()` 操作时,如果外部代码还持有 ThreadLocal 的强引用(比如 `static ThreadLocal holder`),那么 key 不会为 null"。但问题在于,如果外部的 ThreadLocal 强引用**被清除或失效**(比如对象被回收、类被卸载),就会出现内存泄漏问题。 + +这是一个**权衡之后的设计决策**,我们来分析一下两种设计的后果: + +**如果 key 使用强引用**: +- 当外部不再持有 `ThreadLocal` 对象的强引用时(比如业务对象被回收),`ThreadLocalMap` 中的 key 仍然持有对 `ThreadLocal` 对象的强引用 +- 这会导致 `ThreadLocal` 对象无法被 GC 回收,`Entry` 对象也无法被回收 +- 更严重的是,这种情况下完全没有办法发现和清理这些无用的 Entry +- 最终导致的内存泄漏会更加隐蔽和严重 + +**如果 key 使用弱引用**(当前的设计): +- 当外部不再持有 `ThreadLocal` 对象的强引用时,下次 GC 时 key 会被回收,变成 null +- 虽然此时 value 仍然存在,但是 key 为 null 的 Entry 是可以被识别和清理的 +- `ThreadLocalMap` 在 `set()`、`get()`、`remove()` 操作中都会主动清理 key 为 null 的 Entry +- 这种设计虽然不能完全避免内存泄漏,但提供了一种**自我修复**的机制 + +**Doug Lea 的设计智慧**:使用弱引用,虽然不能完全避免内存泄漏,但至少提供了一个"补救"的机会。而使用强引用则会让问题变得无解。 + +#### 内存泄漏的完整引用链路分析 + +让我们用一个完整的引用链路图来理解 ThreadLocal 的内存泄漏问题: + +``` +Thread (强引用) + └─> ThreadLocalMap (强引用) + └─> Entry[] table (强引用) + └─> Entry (强引用) + ├─> key: ThreadLocal (弱引用) ← 这里可能被 GC + └─> value: 业务对象 (强引用) ← 这里可能泄漏 +``` + +**正常情况下的引用链**: +1. 外部代码持有 ThreadLocal 对象的强引用(比如 `static ThreadLocal threadLocalUser`) +2. Thread 对象持有 ThreadLocalMap 的强引用 +3. ThreadLocalMap 持有 Entry 数组的强引用 +4. Entry 继承 WeakReference,持有 ThreadLocal 的弱引用 +5. Entry 持有 value 的强引用 + +**发生内存泄漏的场景**: + +当外部的 ThreadLocal 强引用被置为 null 后(比如 `threadLocalUser = null`): +1. ThreadLocal 对象只剩下 Entry 中的弱引用 +2. 下次 GC 时,ThreadLocal 对象被回收,Entry 的 key 变成 null +3. 但是 Entry 对象本身和 value 仍然被强引用,无法被 GC +4. 如果线程是线程池中的线程,一直不结束,那么这些 Entry 和 value 就会一直存在 + +**举个生动的例子**: + +想象一个图书馆的借书系统: +- **Thread** 是一个读者 +- **ThreadLocalMap** 是这个读者的借书卡 +- **Entry** 是借书记录 +- **ThreadLocal** 是图书的索引卡片(使用弱引用) +- **value** 是实际的图书(使用强引用) + +正常流程:读者(Thread)用索引卡片(ThreadLocal)借了一本书(value),借书卡(ThreadLocalMap)上有记录(Entry)。 + +如果索引卡片系统升级,旧的索引卡片被销毁了(ThreadLocal 引用置为 null),那么: +- 使用弱引用设计:下次图书馆清理时(GC),发现借书记录中的索引卡片没了(key 为 null),就知道要处理这条记录 +- 使用强引用设计:即使索引卡片系统已经不存在了,借书记录仍然死死抓着旧卡片,无法识别和清理 + +#### remove() 方法为什么如此重要 + +理解了内存泄漏的机制后,我们就能明白为什么一定要调用 `remove()` 方法。 + +`remove()` 方法的作用: +1. 找到当前 ThreadLocal 对应的 Entry +2. 将 Entry 的 key 和 value 都置为 null +3. 触发探测式清理,清除更多的过期 Entry +4. 彻底断开 value 的强引用链,让 GC 可以回收 + +**阿里巴巴 Java 开发手册的强制要求**: +> 必须回收自定义的 ThreadLocal 变量,尤其在线程池场景下,线程经常会被复用,如果不清理自定义的 ThreadLocal 变量,可能会影响后续业务逻辑和造成内存泄漏等问题。尽量在代理中使用 try-finally 块进行回收。 + +#### 在线程池场景下的特殊风险 + +线程池场景是 ThreadLocal 内存泄漏的**重灾区**,原因如下: + +1. **线程复用**:线程池中的线程会被反复使用,不会销毁 +2. **ThreadLocalMap 不会被清理**:由于线程一直存活,ThreadLocalMap 也一直存在 +3. **累积效应**:每次使用 ThreadLocal 如果不清理,Entry 就会越积越多 +4. **影响后续任务**:下一个使用该线程的任务可能会读取到上一个任务残留的数据,导致业务逻辑错误 + +**典型的错误案例**(来自阿里技术博客的真实案例): + +```java +// 错误示例:在线程池中使用 ThreadLocal 但不清理 +private static ThreadLocal userInfoHolder = new ThreadLocal<>(); + +public void processRequest(Request request) { + try { + UserInfo userInfo = getUserInfo(request); + userInfoHolder.set(userInfo); // 设置用户信息 + // 处理业务逻辑 + doBusinessLogic(); + } catch (Exception e) { + // 异常处理 + } + // ❌ 没有清理 ThreadLocal! +} +``` + +**正确的做法**: + +```java +// 正确示例:使用 try-finally 确保清理 +private static ThreadLocal userInfoHolder = new ThreadLocal<>(); + +public void processRequest(Request request) { + try { + UserInfo userInfo = getUserInfo(request); + userInfoHolder.set(userInfo); + doBusinessLogic(); + } catch (Exception e) { + // 异常处理 + } finally { + // ✅ 无论是否发生异常,都要清理 + userInfoHolder.remove(); + } +} +``` + +**美团技术团队的实践经验**: + +在美团的实际项目中,曾经出现过因为 ThreadLocal 未清理导致的严重生产事故: +1. 在线程池中使用 ThreadLocal 存储用户权限信息 +2. 某次请求处理完后,由于异常导致未执行 remove() +3. 该线程被分配给下一个请求时,错误地使用了上一个用户的权限信息 +4. 导致越权访问,产生了严重的安全漏洞 + +修复方案: +1. 强制要求所有 ThreadLocal 使用都必须配合 try-finally + remove() +2. 通过静态代码扫描工具检测未正确清理的 ThreadLocal 使用 +3. 在线程池提交任务时增加清理 ThreadLocal 的包装器 + +这个案例充分说明了在线程池场景下正确使用 ThreadLocal 的重要性。记住:**ThreadLocal 用完一定要 remove(),特别是在线程池环境下!** + ### `ThreadLocal.set()`方法源码详解 ![](./images/thread-local/6.png)