Skip to content

Commit 50a41eb

Browse files
author
REALROOK1E
committed
补充ThreadPoolExecutor和AQS的深入原理说明
1 parent 04f0dc0 commit 50a41eb

File tree

2 files changed

+244
-0
lines changed

2 files changed

+244
-0
lines changed

docs/java/concurrent/aqs.md

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,150 @@ AQS 定义两种资源共享方式:`Exclusive`(独占,只有一个线程
201201

202202
一般来说,自定义同步器的共享方式要么是独占,要么是共享,他们也只需实现`tryAcquire-tryRelease``tryAcquireShared-tryReleaseShared`中的一种即可。但 AQS 也支持自定义同步器同时实现独占和共享两种方式,如`ReentrantReadWriteLock`
203203

204+
#### 独占模式与共享模式的深入对比
205+
206+
虽然都是基于 AQS 实现,但独占模式和共享模式在设计理念和实现细节上有着本质的区别。
207+
208+
**独占模式(Exclusive)的特点:**
209+
210+
1. **排他性**:同一时刻只能有一个线程持有资源。当一个线程获取到资源后,其他线程必须等待该线程释放资源才能竞争。
211+
2. **state 语义**`state` 表示资源的占用状态和重入次数。`state = 0` 表示未被占用,`state > 0` 表示被占用(值代表重入次数)。
212+
3. **实现方法**:需要实现 `tryAcquire()``tryRelease()` 方法。
213+
4. **典型应用**`ReentrantLock``ReentrantReadWriteLock` 的写锁。
214+
215+
**共享模式(Shared)的特点:**
216+
217+
1. **共享性**:允许多个线程同时持有资源。一个线程获取资源成功后,可能会触发其他等待线程的连锁唤醒。
218+
2. **state 语义**`state` 表示剩余可用资源数量。比如 `Semaphore` 中,`state` 表示剩余许可证数量;`CountDownLatch` 中,`state` 表示还需要完成的计数。
219+
3. **实现方法**:需要实现 `tryAcquireShared()``tryReleaseShared()` 方法。返回值有特殊含义:负数表示失败,0 表示成功但无剩余资源,正数表示成功且有剩余资源。
220+
4. **典型应用**`Semaphore``CountDownLatch``CyclicBarrier``ReentrantReadWriteLock` 的读锁。
221+
222+
**核心区别举例**
223+
224+
假设有一个停车场,有 10 个车位。
225+
226+
- **独占模式**就像是**单人车位**:一个车位只能停一辆车,其他车必须等这辆车开走才能进来。这就是 `ReentrantLock` 的模型。
227+
- **共享模式**就像是**共享停车场**:10 个车位可以同时停 10 辆车,每辆车进来时检查是否还有空位(`state > 0`),有就停进去并把可用车位数减 1。这就是 `Semaphore` 的模型。
228+
229+
**传播机制的区别**
230+
231+
这是共享模式最特殊的地方。在共享模式下,当一个线程释放资源后,不仅会唤醒后继节点,被唤醒的节点获取资源成功后,还可能继续唤醒它的后继节点,形成"传播效应"。这就是为什么会有 `PROPAGATE` 状态的原因。
232+
233+
而在独占模式下,资源释放后只会唤醒一个后继节点,不存在连锁唤醒的情况。
234+
235+
**举个例子**:想象一个会议室预订系统。如果是独占模式,一个会议室同一时间只能被一个团队使用。但如果改成共享模式(比如一个大型阶梯教室可以容纳多个小组同时开会),当有一个小组释放了位置,可能会触发多个等待的小组同时进入。第一个小组进入后发现还有空间,就会通知第二个小组也可以进来(传播机制),第二个小组进入后发现还有空间,又会通知第三个小组,形成连锁反应。
236+
237+
### Condition 条件队列的工作机制
238+
239+
Condition 是 AQS 提供的另一个重要功能,它实现了类似 `Object.wait()``Object.notify()` 的等待/通知机制,但功能更强大也更灵活。
240+
241+
#### Condition 队列与同步队列的关系
242+
243+
AQS 内部实际上维护了**两种队列**
244+
245+
1. **同步队列(Sync Queue)**:就是前面讲的 CLH 变体队列,用于线程竞争锁。
246+
2. **条件队列(Condition Queue)**:每个 Condition 对象内部维护的单向链表,用于线程等待特定条件。
247+
248+
一个锁可以有多个 Condition 对象,每个 Condition 都有自己独立的条件队列。这比 `synchronized``wait/notify` 机制灵活得多,后者只有一个等待队列。
249+
250+
#### Condition 的工作流程
251+
252+
**await() 操作的流程:**
253+
254+
1. 当前线程必须先持有锁(否则抛出 `IllegalMonitorStateException`
255+
2. 将当前线程封装成 Node 节点,加入到 Condition 的条件队列尾部
256+
3. 完全释放锁(即使是重入锁,也要将 state 减到 0)
257+
4. 阻塞当前线程,等待被 `signal` 唤醒
258+
5. 被唤醒后,节点从条件队列移到同步队列,重新竞争锁
259+
6. 获取锁成功后,从 `await()` 方法返回
260+
261+
**signal() 操作的流程:**
262+
263+
1. 当前线程必须先持有锁
264+
2. 从条件队列的头部取出一个节点
265+
3. 将这个节点从条件队列移到同步队列
266+
4. 通过 `unpark` 唤醒该节点对应的线程
267+
5. 被唤醒的线程会在同步队列中竞争锁
268+
269+
**举个生动的例子**
270+
271+
可以把 Condition 想象成医院的候诊系统:
272+
273+
- **同步队列**就像是**挂号大厅**:所有人都在这里排队等待叫号看病(竞争锁)
274+
- **条件队列**就像是**检查室的等候区**:医生让你去做检查,你从挂号大厅(同步队列)进入检查等候区(条件队列),等待检查结果(await)
275+
- 当检查结果出来(signal),护士会通知你,你再次回到挂号大厅(同步队列)重新排队等待叫号
276+
277+
一个医院可以有多个检查室(多个 Condition 对象),每个检查室都有自己的等候区(独立的条件队列)。这比 `synchronized` 只有一个等候区要灵活得多。
278+
279+
#### Condition 的经典应用场景
280+
281+
**生产者-消费者模型**
282+
283+
```java
284+
// 使用两个 Condition 分别控制生产和消费
285+
Condition notFull = lock.newCondition(); // 队列未满
286+
Condition notEmpty = lock.newCondition(); // 队列非空
287+
288+
// 生产者
289+
while (队列已满) {
290+
notFull.await(); // 等待队列有空位
291+
}
292+
// 生产数据
293+
notEmpty.signal(); // 通知消费者来消费
294+
295+
// 消费者
296+
while (队列为空) {
297+
notEmpty.await(); // 等待队列有数据
298+
}
299+
// 消费数据
300+
notFull.signal(); // 通知生产者可以生产
301+
```
302+
303+
这种设计比使用 `synchronized` + `wait/notify` 更清晰,避免了"惊群效应"(所有等待线程都被唤醒但只有一个能工作)。
304+
305+
### 公平锁与非公平锁的性能对比
306+
307+
在 AQS 的基础上,可以实现公平锁和非公平锁两种策略,它们在性能和公平性之间做了不同的权衡。
308+
309+
#### 实现机制的区别
310+
311+
**公平锁(Fair Lock)**
312+
- 严格按照线程到达的顺序分配锁
313+
- 新来的线程总是先检查队列中是否有等待的线程,如果有就乖乖排队
314+
- 实现:在 `tryAcquire()` 中会调用 `hasQueuedPredecessors()` 检查队列中是否有前驱节点
315+
316+
**非公平锁(Nonfair Lock)**
317+
- 新来的线程会先尝试抢占锁,抢占失败才排队
318+
- 如果锁刚好被释放,新线程可能直接获得锁,而不管队列中是否有等待的线程
319+
- 实现:直接执行 CAS 操作尝试获取锁,不检查队列
320+
321+
#### 性能对比与权衡
322+
323+
根据 Doug Lea(AQS 作者)的测试和实际生产环境的统计:
324+
325+
**吞吐量对比**
326+
- 在高并发场景下,非公平锁的吞吐量通常是公平锁的 **10-20 倍**
327+
- 原因:减少了线程切换的开销。公平锁每次都要排队,涉及线程的阻塞和唤醒;非公平锁允许"插队",如果锁刚好可用,当前线程可以立即获得,避免了上下文切换
328+
329+
**响应时间对比**
330+
- 公平锁的响应时间更稳定,不会出现某个线程长时间得不到锁的情况
331+
- 非公平锁的平均响应时间更短,但可能出现"线程饥饿"现象(某些线程长时间得不到锁)
332+
333+
**实际应用建议**
334+
335+
1. **默认使用非公平锁**`ReentrantLock` 的无参构造器创建的就是非公平锁,这是因为在大多数场景下,吞吐量比顺序性更重要。
336+
2. **需要严格顺序时使用公平锁**:比如任务调度系统、售票系统等需要保证先来先服务的场景。
337+
3. **阿里巴巴的实践**:在《Java 开发手册》中建议,除非业务场景明确需要公平性,否则应该使用非公平锁以获得更好的性能。
338+
339+
**举个例子**
340+
341+
想象一个咖啡店的点单台:
342+
343+
- **公平锁模式**:所有顾客必须严格按照到达顺序排队,即使前一个顾客刚点完单走开,收银员在处理订单,新来的顾客也必须等前面所有人都轮到后才能点单。
344+
- **非公平锁模式**:如果前一个顾客刚点完单,收银员正好空闲,新来的顾客可以直接上前点单,不用管后面是否还有排队的人。虽然可能对排队的人不公平,但整体效率更高,顾客等待时间的平均值更短。
345+
346+
在大型互联网公司的实践中(如阿里、美团),非公平锁因为性能优势被广泛采用。但在金融系统、抢票系统等需要保证公平性的场景,会选择使用公平锁,即使牺牲一些性能。
347+
204348
### AQS 资源获取源码分析(独占模式)
205349

206350
AQS 中以独占模式获取资源的入口方法是 `acquire()` ,如下:

docs/java/concurrent/java-thread-pool-summary.md

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,66 @@ public class ScheduledThreadPoolExecutor
138138

139139
![线程池各个参数的关系](https://oss.javaguide.cn/github/javaguide/java/concurrent/relationship-between-thread-pool-parameters.png)
140140

141+
### 线程池的生命周期状态
142+
143+
理解线程池的生命周期状态对于正确使用和管理线程池至关重要。`ThreadPoolExecutor` 使用一个 `AtomicInteger` 类型的 `ctl` 变量来同时存储两个信息:线程池的运行状态(runState)和工作线程的数量(workerCount)。
144+
145+
线程池有以下五种状态:
146+
147+
- **RUNNING(运行状态)**:线程池创建后就处于 RUNNING 状态,能够接收新任务,也能处理阻塞队列中的任务。
148+
- **SHUTDOWN(关闭状态)**:调用 `shutdown()` 方法后进入此状态。不再接受新任务,但会继续处理阻塞队列中已有的任务。
149+
- **STOP(停止状态)**:调用 `shutdownNow()` 方法后进入此状态。不接受新任务,也不处理阻塞队列中的任务,并且会中断正在执行的任务。
150+
- **TIDYING(整理状态)**:所有任务都已终止,工作线程数量为 0,线程池会转到 TIDYING 状态,并准备执行 `terminated()` 钩子方法。
151+
- **TERMINATED(终止状态)**`terminated()` 方法执行完成后,线程池进入此状态,表示线程池彻底终止。
152+
153+
这些状态之间的转换关系如下:
154+
155+
1. **RUNNING → SHUTDOWN**:调用 `shutdown()` 方法
156+
2. **RUNNING 或 SHUTDOWN → STOP**:调用 `shutdownNow()` 方法
157+
3. **SHUTDOWN → TIDYING**:当队列和线程池都为空时
158+
4. **STOP → TIDYING**:当线程池为空时
159+
5. **TIDYING → TERMINATED**:当 `terminated()` 钩子方法执行完成时
160+
161+
**举个例子**:想象一个餐厅的营业流程。RUNNING 状态就像餐厅正常营业,可以接待新客人(新任务)也在服务已经就座的客人(队列中的任务)。当餐厅准备打烊时调用 `shutdown()`,这时进入 SHUTDOWN 状态——不再接待新客人,但会把已经就座的客人服务完。如果遇到紧急情况需要立即关门,调用 `shutdownNow()` 进入 STOP 状态——不接待新客人,也请已就座的客人离开。最后所有客人都离开、员工下班,餐厅进入 TIDYING 然后 TERMINATED 状态,彻底关闭。
162+
163+
### Worker 工作线程的生命周期
164+
165+
`ThreadPoolExecutor` 内部使用 `Worker` 类来封装工作线程。每个 `Worker` 对象都包装了一个线程(`Thread`),并且 `Worker` 本身也实现了 `Runnable` 接口。
166+
167+
#### Worker 的创建与启动
168+
169+
当需要新建工作线程时(比如任务提交时发现当前线程数小于核心线程数),线程池会:
170+
171+
1. 创建一个新的 `Worker` 对象,将第一个任务传递给它
172+
2. `Worker` 内部会通过 `ThreadFactory` 创建一个新线程
173+
3. 将这个 `Worker` 添加到工作线程集合 `workers` 中(这是一个 `HashSet`
174+
4. 启动 `Worker` 内部的线程,开始执行任务
175+
176+
#### Worker 的任务执行循环
177+
178+
`Worker` 启动后,会进入一个循环(在 `runWorker()` 方法中):
179+
180+
1. 首先执行传递进来的第一个任务(如果有)
181+
2. 任务执行完毕后,通过 `getTask()` 方法从阻塞队列中获取新任务
182+
3. 如果获取到任务,继续执行;如果获取不到(返回 null),则跳出循环
183+
4. 跳出循环后,工作线程进入退出流程
184+
185+
**什么时候 `getTask()` 会返回 null 呢?** 主要有以下几种情况:
186+
187+
- 线程池状态为 STOP 或以上
188+
- 线程池状态为 SHUTDOWN 并且队列为空
189+
- 当前线程数超过核心线程数,并且从队列获取任务超时(等待 `keepAliveTime` 时间)
190+
191+
#### Worker 的销毁
192+
193+
`getTask()` 返回 null 后,Worker 会执行 `processWorkerExit()` 方法退出:
194+
195+
1. 将自己从工作线程集合 `workers` 中移除
196+
2. 尝试将线程池状态转换为 TERMINATED
197+
3. 如果线程是异常退出,可能会创建新的工作线程来替代
198+
199+
**举个例子**:可以把 Worker 想象成餐厅的服务员。餐厅开业时(线程池运行),根据需要招聘一定数量的服务员(创建 Worker)。每个服务员会不断从订单队列中取订单来处理(从阻塞队列获取任务)。如果订单队列长时间没有新订单,并且服务员数量超过了基本配置(超过核心线程数),一些服务员会在等待一段时间(keepAliveTime)后自动离职(Worker 销毁)。但至少会保留基本数量的服务员(核心线程数),确保餐厅随时能处理突然到来的订单。
200+
141201
**`ThreadPoolExecutor` 拒绝策略定义:**
142202

143203
如果当前同时运行的线程数量达到最大线程数量并且队列也已经被放满了任务时,`ThreadPoolExecutor` 定义一些策略:
@@ -165,6 +225,46 @@ public static class CallerRunsPolicy implements RejectedExecutionHandler {
165225
}
166226
```
167227

228+
### 拒绝策略的实际应用场景
229+
230+
不同的拒绝策略适用于不同的业务场景,选择合适的策略对系统的稳定性和性能至关重要。
231+
232+
#### AbortPolicy —— 快速失败,适合关键业务
233+
234+
`AbortPolicy` 是默认策略,当任务无法提交时直接抛出 `RejectedExecutionException` 异常。这种策略适合那些**不能容忍任务丢失**的场景,通过抛出异常让调用方感知到问题并进行相应处理。
235+
236+
**典型场景**:订单支付、金融交易等核心业务。当线程池饱和时,宁可让用户感知到系统繁忙,也不能默默地丢弃支付请求。
237+
238+
**举个例子**:假设一个电商系统的支付线程池,核心线程数 10,最大线程数 20,队列容量 100。在双十一高峰期,如果队列已满且 20 个线程都在处理任务,此时新来的支付请求会抛出异常。系统可以捕获这个异常,给用户返回"系统繁忙,请稍后重试"的提示,或者将请求放入降级处理队列。
239+
240+
#### CallerRunsPolicy —— 降低提交速度,适合可容忍延迟的场景
241+
242+
`CallerRunsPolicy` 会让调用线程自己执行被拒绝的任务,这样做有两个效果:一是不会丢弃任务,二是由于调用线程被占用,提交任务的速度会自然降低,形成一种"负反馈"机制。
243+
244+
**典型场景**:日志记录、数据统计、非实时性的数据同步。这些场景可以容忍一定的延迟,不能丢失数据但也不需要立即处理。
245+
246+
**实际案例**:阿里巴巴在《Java 开发手册》中推荐在一些场景使用 `CallerRunsPolicy`。比如 Dubbo 的线程池默认使用此策略,当服务端线程池满时,让调用方(客户端的业务线程)来执行任务,这样既保证了任务不丢失,又通过占用客户端线程自然地限制了请求速率。
247+
248+
**需要注意的风险**:如果提交任务的线程很关键(比如 Netty 的 IO 线程、消息队列的消费线程),使用 `CallerRunsPolicy` 会导致这些关键线程被阻塞,反而可能引发更严重的问题。因此要谨慎评估调用线程的重要性。
249+
250+
#### DiscardPolicy 和 DiscardOldestPolicy —— 丢弃任务,适合可容忍丢失的场景
251+
252+
`DiscardPolicy` 直接丢弃新任务,`DiscardOldestPolicy` 丢弃队列中最老的任务然后尝试提交新任务。这两种策略都会导致任务丢失,只适合对数据完整性要求不高的场景。
253+
254+
**典型场景**:访问量统计、实时监控数据采集、用户行为埋点等。这些场景下,丢失少量数据不会影响整体的业务逻辑,反而能保证系统在高负载下的稳定性。
255+
256+
**举个例子**:一个网站的访问量统计系统,每分钟可能有数百万次页面访问记录需要处理。如果统计线程池饱和,使用 `DiscardPolicy` 丢弃部分访问记录是可以接受的,因为即使丢失 1% 的数据,仍然能够准确反映整体的访问趋势。这比让整个统计系统因为过载而崩溃要好得多。
257+
258+
#### 自定义拒绝策略 —— 生产环境的最佳实践
259+
260+
在生产环境中,很多公司会实现自定义的拒绝策略来满足特定需求。常见的做法包括:
261+
262+
1. **记录日志并告警**:任务被拒绝时记录详细日志并触发监控告警,帮助运维人员及时发现问题
263+
2. **放入备用队列**:将被拒绝的任务放入 Redis、MQ 等外部队列,后续异步处理
264+
3. **降级处理**:执行简化版本的任务逻辑,保证核心功能可用
265+
266+
**美团的实践案例**:美团的动态线程池组件会记录任务拒绝的指标(如拒绝次数、拒绝率),并在控制台实时展示。当拒绝率超过阈值时自动告警,运维人员可以动态调整线程池参数而无需重启应用。
267+
168268
### 线程池创建的两种方式
169269

170270
在 Java 中,创建线程池主要有两种方式:

0 commit comments

Comments
 (0)