背景
在审查 GridItemPushSender 类时,发现同事使用 AtomicBoolean + CAS 机制来控制任务的串行执行。虽然功能正确,但存在更简洁的解决方案。
原始实现(CAS 方案)
代码示例
java
public class GridItemPushSender {
private final ExecutorService singleWorker;
private final AtomicBoolean isRunning = new AtomicBoolean(false);
public void send() {
// CAS 尝试获取执行权
if (!isRunning.compareAndSet(false, true)) {
log.info("任务正在执行中,本次请求跳过");
return;
}
singleWorker.submit(() -> {
try {
doSend();
} catch (Exception e) {
log.error("推送异常: {}", e.getMessage());
} finally {
// 释放执行权
isRunning.set(false);
}
});
}
}
问题分析
- 代码冗余 :需要手动管理
isRunning标志位 - 容易出错 :如果忘记在
finally中重置标志,会导致后续所有请求被拒绝 - 职责不清:线程池本身就有队列管理机制,重复造轮子
- 维护成本高:增加了额外的状态变量,提高了理解难度
优化方案(线程池内置机制)
核心思路
利用 ThreadPoolExecutor 的有界队列 + 拒绝策略来实现相同的功能,让线程池自己管理任务丢弃逻辑。
优化后代码
java
public class GridItemPushSender {
private final ExecutorService singleWorker;
public GridItemPushSender(GridItemPushLogGateway pushLogGateway, ProgramGateway apiClient) {
this.pushLogGateway = pushLogGateway;
this.apiClient = apiClient;
// 使用 ThreadPoolExecutor 自定义配置
this.singleWorker = new ThreadPoolExecutor(
1, // 核心线程数
1, // 最大线程数
0L, TimeUnit.MILLISECONDS, // 空闲线程存活时间
new ArrayBlockingQueue<>(1), // 容量为1的有界队列
Executors.defaultThreadFactory(),
new ThreadPoolExecutor.DiscardPolicy() // 拒绝策略:直接丢弃
);
}
public void send() {
singleWorker.submit(() -> {
try {
doSend();
} catch (Exception e) {
log.error("推送异常: {}", e.getMessage());
}
});
}
}
工作原理对比
CAS 方案的工作流程
请求1 → CAS检查(isRunning=false) → 成功 → 设置isRunning=true → 执行任务
请求2 → CAS检查(isRunning=true) → 失败 → 直接返回
请求3 → CAS检查(isRunning=true) → 失败 → 直接返回
任务完成 → finally设置isRunning=false
请求4 → CAS检查(isRunning=false) → 成功 → 设置isRunning=true → 执行任务
线程池方案的工作流程
请求1 → 线程空闲 → 立即执行
请求2 → 线程忙碌 → 进入队列(队列容量1)→ 等待
请求3 → 线程忙碌 + 队列已满 → 触发DiscardPolicy → 直接丢弃
请求4 → 线程忙碌 + 队列已满 → 触发DiscardPolicy → 直接丢弃
任务完成 → 从队列取出请求2执行
两种方案的差异
| 维度 | CAS 方案 | 线程池方案 |
|---|---|---|
| 代码行数 | ~15行 | ~8行 |
| 状态管理 | 手动管理 AtomicBoolean |
线程池自动管理 |
| 容错性 | 忘记释放会导致死锁 | 无此风险 |
| 缓冲能力 | 无缓冲,第2个请求就被拒绝 | 允许1个请求排队 |
| 可配置性 | 需修改代码 | 可通过参数调整 |
| 可读性 | 需要理解 CAS 机制 | 标准的线程池用法 |
| 维护成本 | 高 | 低 |
为什么推荐线程池方案?
1. 职责单一原则
线程池的核心职责就是管理任务和线程,包括:
- 控制并发线程数
- 管理任务队列
- 处理任务拒绝
使用 CAS 手动控制相当于绕过了线程池的任务管理机制。
2. 更少的代码 = 更少的 Bug
java
// CAS 方案:需要记住在 finally 中释放
try {
doWork();
} finally {
isRunning.set(false); // ⚠️ 容易忘记
}
// 线程池方案:无需手动管理
singleWorker.submit(() -> doWork()); // ✅ 简洁明了
3. 更好的扩展性
如果需要调整行为,只需修改线程池参数:
java
// 允许最多3个任务排队
new ArrayBlockingQueue<>(3)
// 改为记录日志而不是丢弃
new ThreadPoolExecutor.CallerRunsPolicy()
// 改为抛出异常
new ThreadPoolExecutor.AbortPolicy()
4. 符合 Java 最佳实践
《Effective Java》和《Java Concurrency in Practice》都推荐:
优先使用标准库提供的并发工具,而不是手动实现同步逻辑。
实际效果验证
测试场景
连续快速调用 5 次 send() 方法:
java
for (int i = 1; i <= 5; i++) {
sender.send();
System.out.println("提交请求 " + i);
Thread.sleep(100);
}
CAS 方案输出
提交请求 1 → ✅ 获取执行权,开始执行
提交请求 2 → ⚠️ 任务正在执行中,跳过
提交请求 3 → ⚠️ 任务正在执行中,跳过
提交请求 4 → ⚠️ 任务正在执行中,跳过
提交请求 5 → ⚠️ 任务正在执行中,跳过
结果:只有第1个请求被执行,其余全部被拒绝。
线程池方案输出
提交请求 1 → ✅ 立即执行
提交请求 2 → 📥 进入队列等待
提交请求 3 → ❌ 队列已满,丢弃
提交请求 4 → ❌ 队列已满,丢弃
提交请求 5 → ❌ 队列已满,丢弃
任务1完成 → 📤 从队列取出请求2执行
结果:第1个请求执行,第2个请求排队,其余被丢弃。
注意事项
1. 队列容量的选择
java
// 队列容量 = 0:完全不允许排队,立即丢弃
new ArrayBlockingQueue<>(0)
// 队列容量 = 1:允许1个任务排队(当前方案)
new ArrayBlockingQueue<>(1)
// 队列容量 = N:允许N个任务排队
new ArrayBlockingQueue<>(N)
2. 拒绝策略的选择
| 策略 | 行为 | 适用场景 |
|---|---|---|
DiscardPolicy |
直接丢弃新任务 | 任务可丢失,如日志推送 |
CallerRunsPolicy |
由调用线程执行 | 不能丢失任务,但要限流 |
AbortPolicy |
抛出 RejectedExecutionException | 需要感知任务被拒绝 |
DiscardOldestPolicy |
丢弃队列中最老的任务 | 保证最新任务优先执行 |
3. 资源清理
记得在应用关闭时优雅地关闭线程池:
java
@PreDestroy
public void shutdown() {
singleWorker.shutdown();
try {
if (!singleWorker.awaitTermination(5, TimeUnit.SECONDS)) {
singleWorker.shutdownNow();
}
} catch (InterruptedException e) {
singleWorker.shutdownNow();
Thread.currentThread().interrupt();
}
}
总结
核心观点
- 优先使用标准库:Java 并发包已经提供了丰富的工具,不要重复造轮子
- 简单即是美:能用一行代码解决的问题,不要用十行
- 让专业的人做专业的事:线程池负责管理任务,我们只负责提交任务
学习要点
- 理解
ThreadPoolExecutor的参数含义 - 掌握不同拒绝策略的使用场景
- 学会根据业务需求选择合适的队列容量
- 养成阅读 JDK 源码的习惯,了解标准库的实现原理
参考资料:
- 《Effective Java》第3版 - 第81条:优先使用并发工具类而不是 wait 和 notify
- 《Java Concurrency in Practice》- 第8章:线程池的使用
- Oracle 官方文档:ThreadPoolExecutor)