接手维护过老项目的朋友应该都有这种体验:系统跑着跑着,一到业务高峰就出各种幺蛾子——接口响应突然变慢、日志文件莫名缺一段、排查问题时发现关键调用链断了。表面看是偶发故障,根子往往出在两个最容易被当成"工具人"的组件上:线程池和日志模块。线程池用成了"经验值玩家",参数全靠拍脑袋;日志模块写成了"一把梭",所有输出怼到一个文件里,后续想扩展想切换根本下不了手。
这篇东西要聊的就是怎么把这两块补扎实。线程池部分会讲清楚参数到底怎么算、拒绝策略怎么选、运行时怎么动态调整和监控;日志部分会用一个实战案例,演示如何用策略模式把日志输出从"死胡同"里捞出来,让它变成可插拔、可扩展的结构。两部分最后还会串起来,因为线程池的运行状态要靠日志来观测,日志策略的切换也要靠线程池来异步执行,二者本来就是搭档。适合所有写Java后端的同学参考,尤其是那些系统已经上线、正在被并发和日志问题反复折磨的团队。
1. 线程池设计:从"能用"到"抗打"
1.1 线程池为什么会出事
很多项目最初的线程池长这样:Executors.newFixedThreadPool(10),图省事一把梭。这种写法在 demo 里没问题,上了生产环境早晚要还债。原因是它的队列是无界队列,任务积压时线程池不会触发拒绝策略,只会默默把任务堆进内存,堆到 OOM 为止。我见过一个真实案例:某服务用默认的 fixed 线程池接了一个短信推送接口,大促期间流量翻了几倍,线程池队列里堆积了上百万个任务,内存直接被打满,最后整个服务被拖垮,连健康检查都过不了。
所以做线程池设计,第一步是忘掉 Executors 工具类,改成直接 new ThreadPoolExecutor,把每个参数都拿到台面上算清楚。这不是教条,是因为线程池的动态调整、任务拒绝、监控统计这些能力全部依赖参数的合理设置,参数拍脑袋,后面所有保护机制都是摆设。
1.2 参数计算不是拍脑袋的数字
线程池核心参数有这么几个:核心线程数、最大线程数、空闲存活时间、任务队列、线程工厂、拒绝策略。前三个是资源边界,队列是缓冲池,线程工厂决定线程的"身份",拒绝策略是最后一道闸。
核心线程数和最大线程数怎么定?业内有两个常用公式,但要结合业务修正。CPU 密集型任务,核心线程数设成 CPU 核数 + 1,因为这种任务几乎不阻塞,线程多了只会增加上下文切换开销。IO 密集型任务,比如调外部接口、读写数据库、消费消息,线程的大部分时间在等待,核心线程数可以放宽到 CPU 核数 * (1 + 等待时间 / 计算时间)。这个公式里的等待时间和计算时间只能估算,比如一次外部调用平均耗时 300ms,本地计算 20ms,那比例就是 15,四核机器算出来大约是 64 个线程。实际落地时不要直接拿公式结果当最终值,先压测再微调。
最大线程数怎么定?我的习惯是核心线程数的 2~3 倍,同时要看任务队列容量。这两者是一对组合拳:队列小、最大线程大,突发流量会快速消耗线程资源;队列大、最大线程小,突发流量会先堆队列,响应时间会变长。常见的搭配是:核心线程数对应正常负载,队列容量吸收小幅度波动,最大线程数应对真正的流量尖峰。
线程工厂是很多团队忽略的点。默认的线程池线程名是 pool-1-thread-1,出了问题想从线程栈反查是哪个业务模块的线程,只能靠猜。自定义 ThreadFactory 只需要做一件事:给线程起一个有意义的名字,比如 order-async-worker,同时把线程设为非守护线程。这一点在排查线上问题时能省下大量时间。
1.3 拒绝策略怎么选
当线程池和队列都塞满时,新任务就会走拒绝策略。JDK 提供了四种:
| 策略 | 行为 | 适用场景 |
|---|---|---|
| AbortPolicy | 直接抛 RejectedExecutionException | 适合明确告知"任务不能被接受"的场景 |
| CallerRunsPolicy | 由提交任务的线程自己执行 | 适合不能丢任务、且允许调用线程阻塞的场景 |
| DiscardPolicy | 静默丢弃 | 适合允许丢消息的场景,但一般不建议 |
| DiscardOldestPolicy | 丢弃队列中最老的任务 | 适合只关心新任务的场景 |
这四种策略里,CallerRunsPolicy 是最实用的一种。它虽然会让提交任务的线程(比如业务请求线程)被阻塞去执行被拒的任务,但好处是任务不丢,同时天然起到了流量整形的作用——线程池扛不住时,提交方被迫放慢速度。
拒绝策略的定义不要直接用匿名类放一堆业务逻辑,建议单独抽出策略类。后面聊日志策略模式时会发现,这种"把变化点抽出来"的思路是相通的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 线程池的动态化与监控
2.1 参数不能写死:动态调整是关键
线上系统的负载不是恒定的,如果线程池参数写死在代码里,流量模型一变就要重启应用,太被动。ThreadPoolExecutor 内置了动态调整的接口:setCorePoolSize()、setMaximumPoolSize()、setKeepAliveTime()。这些接口天然支持运行时调整,不需要额外开发。
实际项目中,我习惯把线程池参数配置到配置中心,比如某配置中心产品,监听配置变更后再调用 setter 方法。核心线程数调小时要留意:如果当前线程数大于新的核心线程数,多余的空闲线程会被回收,但正在执行任务的线程不受影响。所以缩容不会打断正在跑的任务,这一点可以放心。
值得注意的是,调大最大线程数时,要确认任务队列的类型。使用 ArrayBlockingQueue 或 LinkedBlockingQueue 时,任务优先放队列;只有队列满时才会新开线程到最大线程数。这会导致一个现象:核心线程数较小、队列又比较大时,即使最大线程数设得很高,线程也很难增长到最大。如果希望线程优先增长而不是堆队列,可以参考 ThreadPoolExecutor 的 allowCoreThreadTimeOut 之外的另一个技巧:采用 SynchronousQueue 作为队列,它的特点是不会缓存任务,来了任务直接交给线程,线程不够就扩到最大线程数。代价是流量尖峰时更容易触发拒绝策略,适合对任务时效性要求高的场景。
2.2 留个钩子:重写 hook 方法做监控
ThreadPoolExecutor 预留了三个包可见的钩子方法,分别是 beforeExecute、afterExecute 和 terminated。默认实现是空方法,子类重写后,可以在任务执行前后插入监控逻辑。
我之前的一个做法是自定义一个 MonitoredThreadPoolExecutor:
java复制public class MonitoredThreadPoolExecutor extends ThreadPoolExecutor {
private final String poolName;
private final AtomicLong taskCount = new AtomicLong();
private final AtomicLong totalCostTime = new AtomicLong();
public MonitoredThreadPoolExecutor(String poolName, int core, int max, long keepAlive,
TimeUnit unit, BlockingQueue<Runnable> queue,
ThreadFactory factory, RejectedExecutionHandler handler) {
super(core, max, keepAlive, unit, queue, factory, handler);
this.poolName = poolName;
}
@Override
protected void beforeExecute(Thread t, Runnable r) {
super.beforeExecute(t, r);
// 记录任务开始时间,可以用 ThreadLocal 保存
// 同时可以往 MDC 里写入当前线程池的名字,方便日志检索
}
@Override
protected void afterExecute(Runnable r, Throwable t) {
// 统计耗时、失败率,做指标上报
super.afterExecute(r, t);
}
}
之后在 afterExecute 里把任务耗时、线程池活跃线程数、队列积压数打点记录,通过日志或指标系统上报。这样线程池就不再是一个黑盒,每个任务的执行情况都有据可查。
2.3 线程池与业务日志的联调
线程池跑的是异步任务,最头疼的问题是:任务执行出错时,日志里只有线程堆栈,没有业务上下文,根本不知道这个任务当初是为哪个用户、哪个订单服务的。解决方法是在线程池提交任务时传递上下文。
可以拿 ThreadLocal 做文章,但要注意线程池会复用线程,任务之间会串上下文。更稳妥的做法是提交任务时把上下文显式封装进任务里。使用 SLF4J 的 MDC 机制时,需要在任务执行前后手动 put 和 remove:
java复制public class ContextRunnable implements Runnable {
private final Map<String, String> contextMap;
private final Runnable delegate;
public ContextRunnable(Runnable delegate) {
this.delegate = delegate;
this.contextMap = MDC.getCopyOfContextMap();
}
@Override
public void run() {
if (contextMap != null) {
MDC.setContextMap(contextMap);
}
try {
delegate.run();
} finally {
MDC.clear();
}
}
}
这样从线程池里打出来的日志,依然带着调用方的 traceId、userId 等上下文信息,全链路日志才能串起来。这一步做不做,直接影响线上问题排查的效率,是真金白银的体验差异。
3. 日志策略模式实战
3.1 日志模块为什么需要策略模式
换个场景说。某次项目需要把日志从原来的文件输出,逐步扩展到按级别过滤后写入消息队列,同时把业务审计日志投递到另一个存储系统。一开始直接用 if-else 判断日志类型,再分别调用对应的输出方法,代码写起来很爽,但踩了几个坑之后就很痛苦了:
- 新增一种日志输出目标时,需要改动"分发中心"的方法,违反开闭原则
- 每种输出方式都有自己的初始化、格式处理逻辑,堆在一起越来越乱
- 想给某个输出目标单独做熔断或降级,改一处就影响所有日志
策略模式正好解决这一类问题。它的核心思想很简单:定义一族算法或处理方式,把它们分别封装起来,让它们可以互相替换。在日志模块里,每种日志输出方式就是一个策略。
3.2 策略接口的设计
先定义一个统一的策略接口:
java复制public interface LogSendStrategy {
/**
* 判断当前策略是否支持处理这条日志
*/
boolean supports(LogLevel level, String scene);
/**
* 发送或写入日志
*/
void send(LogEvent event);
/**
* 策略的优先级,值越小越先匹配
*/
default int order() {
return 0;
}
}
supports 方法是策略模式里比较关键的设计。不同日志策略可以声明自己负责哪些级别、哪些业务场景。比如:
java复制public class FileLogStrategy implements LogSendStrategy {
@Override
public boolean supports(LogLevel level, String scene) {
return level == LogLevel.INFO || level == LogLevel.WARN;
}
@Override
public void send(LogEvent event) {
// 写文件逻辑:格式化、按天分目录、同步或异步写
}
}
java复制public class KafkaLogStrategy implements LogSendStrategy {
@Override
public boolean supports(LogLevel level, String scene) {
return level == LogLevel.ERROR || "audit".equals(scene);
}
@Override
public void send(LogEvent event) {
// 发送到消息队列,供下游消费
}
}
这里有一个容易踩的坑:supports 的判断条件不要写得太宽泛,否则会出现多条策略都匹配、日志被重复发送的情况。建议用"级别 + 场景"双重条件,同时用 order() 控制匹配顺序。
3.3 策略工厂与动态切换
有了策略接口和具体实现类,还需要一个地方把它们管理起来。这里再套一个工厂模式,把策略的注册和查找集中处理:
java复制@Component
public class LogStrategyFactory {
private final List<LogSendStrategy> strategyList;
public LogStrategyFactory(List<LogSendStrategy> strategyList) {
this.strategyList = strategyList;
// 按 order 排序
this.strategyList.sort(Comparator.comparingInt(LogSendStrategy::order));
}
public LogSendStrategy getStrategy(LogLevel level, String scene) {
return strategyList.stream()
.filter(s -> s.supports(level, scene))
.findFirst()
.orElseThrow(() -> new IllegalStateException("no strategy matched"));
}
}
配合 Spring 的依赖注入,只要把策略实现类声明为 Spring Bean,工厂就会自动收集所有策略。新增一种日志输出方式时,只需要新增一个实现类,不需要改动工厂和已有策略类,这正是开闭原则的体现。
动态切换方面,可以把策略的开关状态放到配置中心。例如某个策略配置了降级开关,send 方法里先检查状态,如果该策略被降级,则走默认的文件策略。这种设计避免了"改一行代码、重启一个服务"的尴尬。
4. 线程池与日志策略的组合落地
4.1 初始化与装配
前面两部分是独立的组件,真正上线时需要把它们装配起来。线程池负责执行异步任务,日志策略负责输出任务执行过程的状态,两者配合才能形成一个可观测的系统。
装配的思路是:线程池的 afterExecute 钩子里,把任务的执行结果交给日志策略发送。例如:
java复制@Override
protected void afterExecute(Runnable r, Throwable t) {
LogEvent event = new LogEvent();
event.setPoolName(poolName);
event.setCostTime(costTime);
event.setSuccess(t == null);
event.setLevel(t == null ? LogLevel.INFO : LogLevel.ERROR);
logStrategyFactory.getStrategy(event.getLevel(), "threadpool").send(event);
}
这条日志经过策略匹配后,正常状态写入文件,异常状态投递到消息队列做告警分析。整个过程不需要在业务代码里到处埋点。
4.2 全链路日志与线程池命名
线程池命名这件事虽然小,但对日志检索体验的影响非常大。我见过一个系统里同时有七八个线程池,全是默认名字,日志里搜某个业务产生的异常,出来一堆无关线程的堆栈,根本分不清来源。
自定义 ThreadFactory 时命名建议用"业务模块 + 用途"的格式,例如:
java复制public class NamedThreadFactory implements ThreadFactory {
private final String prefix;
private final AtomicInteger counter = new AtomicInteger(0);
public NamedThreadFactory(String prefix) {
this.prefix = prefix;
}
@Override
public Thread newThread(Runnable r) {
Thread thread = new Thread(r);
thread.setName(prefix + "-" + counter.incrementAndGet());
thread.setDaemon(false);
return thread;
}
}
配合前文提到的 ContextRunnable,日志里同时具备线程池名称和业务 traceId,排查问题时输入 线程池名称或traceId,就能把一条链路从头到尾拉出来。
4.3 这套组合应对的典型场景
举个实际发生过的场景:一天中午业务方反馈支付回调偶尔变慢,排查时发现回调处理线程池的队列积压从几百涨到几千。当时线程池的特殊日志已经被收集到消息队列里了,通过查询特定时间段内该线程池的监控日志,发现某个上游接口的 P99 延迟突增,导致回调任务长时间阻塞。线程池内的核心线程不够用,任务全部堆进队列,最终影响回调的及时性。
定位问题后做的调整方式:把核心线程数调大,并把队列容量适当缩小,让任务更快被消费。同时配置了动态调整策略,目标是在上游接口变慢时,能够快速感知并自动扩容线程数,而不是等任务堆积到触发告警才介入。这套能力,没有线程池监控日志和策略化输出,根本无从谈起。
5. 常见问题与排查技巧
5.1 线程池类型导致任务堆积
现象:某个异步接口在流量高峰期响应时间暴增,线程池监控日志显示队列积压持续上涨。
排查步骤:先看监控日志里线程池的活跃线程数是否已经达到最大线程数,如果还没有达到、任务却大量积压,大概率是队列设置过大,或者核心线程数偏小导致任务优先入队而不是扩容线程。调整方向有两种:调大核心线程数,或者改用 SynchronousQueue 让任务优先触发线程扩容。
5.2 策略匹配到了错误实现
现象:日志重复发送,或本应写入消息队列的告警日志落到了文件里。
原因通常是 supports 方法条件重叠,多个策略对同一条日志都返回 true。排查时把每个策略的 order 和匹配条件打出来,确认是否有交集。修复方式:收紧判断条件,比如把 ERROR 级别的匹配精确到指定业务场景,而不是所有 ERROR 都走同一个策略。
5.3 动态调整参数后线程数不变
现象:调用 setCorePoolSize() 后,活跃线程数没有变化。
原因分析:线程池扩容线程的前提是队列已满且当前线程数小于最大线程数。如果队列没有满,调整核心线程数不会立即新增线程,而是等待新任务提交时才会逐步创建。这属于正常现象,不是配置失败。另外,如果核心线程数已经被调大且允许线程超时回收,要确认 allowCoreThreadTimeOut 是否开启,它会改变空闲线程的回收行为。
5.4 任务抛出异常被吞掉
这个坑比较隐性。线程池执行任务时,如果任务抛出 RuntimeException 且没有在 afterExecute 中重新抛出,异常堆栈往往会丢失。如果还重写了 afterExecute,一定要在方法里捕获 Throwable 并记录日志:
java复制@Override
protected void afterExecute(Runnable r, Throwable t) {
if (t == null && r instanceof Future<?>) {
try {
((Future<?>) r).get();
} catch (CancellationException ce) {
t = ce;
} catch (ExecutionException ee) {
t = ee.getCause();
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
}
}
// 将 t 和任务上下文写入日志策略
}
这一条在 execute() 提交任务时尤其重要,因为 submit() 会把异常封装在 Future 里,afterExecute 的入参 Throwable 反而是 null,不显式调用 Future.get() 就看不到真实异常。
排查问题这块,整理成速查表可以节省不少时间:
| 现象 | 排查顺序 | 常用工具或日志 |
|---|---|---|
| 线程池任务积压 | 1. 活跃线程数是否达上限 2. 队列容量是否过大 3. 单任务耗时是否变长 | 线程池监控日志、耗时统计 |
| 日志重复或缺失 | 1. 策略匹配条件是否重叠 2. 策略是否被降级 3. 是否重复注册 | 策略匹配日志 |
| 线程名全是 pool-x-thread | 1. 是否自定义 ThreadFactory | 线程堆栈 |
| 异步任务上下文丢失 | 1. 是否使用 MDC 传递 2. 任务复用线程是否清空上下文 | 日志中的 traceId |
6. 最后提醒几个实操细节
线程池和日志策略组合落地时,有一个细节值得单独说。线程池参数和日志策略配置,尽量放到配置中心而不是代码里写死。我见过太多团队把参数写成常量类,改一次要发一次版,后来挪到配置中心后,调速、切策略都是分钟级操作,省了很多不必要的发布窗口。
另外,策略模式虽好,也别过度设计。如果项目里日志输出方式就一种,未来三五年也看不出扩展迹象,强行上策略模式反而是负担。判断标准很简单:是否出现了多个可以相互替换的行为分支,是否频繁新增分支。日志模块因为面向的场景天然多样——控制台、文件、消息队列、搜索引擎、审计系统——所以适合策略模式;但如果只是写文件,用静态方法封装就够了。
还有一点就是拒绝策略的日志记录。很多人自定义拒绝策略只做业务处理,忘了记录被拒任务的上下文。实际上,被拒绝的任务是最值得分析的数据——它能告诉你系统在什么条件下达到瓶颈、哪类任务最先被牺牲。建议在拒绝策略里把任务名、提交时间、任务状态一起通过日志策略输出,后续做容量规划时这些数据非常有参考价值。
写完这些,再回看开头那个维护老项目的经历。线程池和日志策略不是高大上的技术,但把这两个基础模块做扎实,系统的稳定性和可排查性都会有质的变化。实际动手时,可以先从小的点切入,比如先把 ThreadFactory 换成有意义的命名,再把线程池的 hook 监控日志打出来,最后逐步引入策略模式重构日志输出。一步步来,踩过的坑就会越来越少。
