Java 并发与线程池的坑:任务失败、线程串号和 CPU 飙高
并发问题最麻烦的地方在于,它不一定每次都失败。很多代码在压测时能过,生产流量一大才暴露。这里整理的是我在后端系统里反复见到的线程池和共享状态问题。
线程池配置
不要无脑用 Executors.newFixedThreadPool
现象
某个异步任务越来越多,服务开始频繁 Full GC,最后内存溢出。翻代码发现用的是:
1 | ExecutorService pool = Executors.newFixedThreadPool(200); |
根因
newFixedThreadPool 使用无界的 LinkedBlockingQueue。线程数最多 200,但任务队列没有上限。如果生产速度长期高于消费速度,任务会在队列里无限堆积,最终耗尽堆内存。
newCachedThreadPool 则相反,队列没有容量,空闲线程可回收,但线程数上限是 Integer.MAX_VALUE。请求一多就可能创建大量线程,把机器压垮。
正确写法
生产环境最好显式创建 ThreadPoolExecutor,把核心线程数、最大线程数、队列容量和拒绝策略都想清楚:
1 | ThreadPoolExecutor executor = new ThreadPoolExecutor( |
参数不是越大越好。CPU 密集任务核心线程数通常按 CPU 核数附近配置;IO 密集任务可以适当放大,但要用监控和压测确认,而不是拍一个 200。
拒绝策略默认直接抛异常
现象
流量突增时,日志里突然出现大量 RejectedExecutionException,请求直接失败。
根因
ThreadPoolExecutor 默认拒绝策略是 AbortPolicy,队列满且线程数达到最大值后,再提交任务就抛异常。
正确写法
拒绝策略应该按业务容忍度选择:
CallerRunsPolicy:让提交任务的线程自己执行,形成背压,适合希望“慢但别丢”的场景。DiscardOldestPolicy:丢弃最老任务,适合任务有强时效性、新任务更重要的场景。- 自定义拒绝策略:记录日志、上报指标,或者降级处理。
不要默认 DiscardPolicy。它会把任务静默丢掉,没有日志,线上问题很难查。
异常与任务结果
submit 会吞掉任务异常
现象
定时任务偶尔不生效,但日志里没有任何异常。代码里用 executor.submit(() -> doWork()) 提交了任务。
根因
submit 返回 Future,任务抛出的异常不会立刻打印,而是保存在 Future 里。只有调用 Future.get() 才会重新抛出。如果没人调用 get,异常就被静默吞掉。
正确写法
如果不需要返回值,可以统一封装一层,提交后立刻处理异常:
1 | Future<?> future = executor.submit(() -> doWork()); |
或者提交时手动捕获:
1 | executor.submit(() -> { |
也可以使用 execute,它会让未捕获异常进入线程的 UncaughtExceptionHandler。但无论哪种方式,关键是不能让异常消失。
共享状态与可见性
线程池里的 SimpleDateFormat 串号
现象
并发解析日期时,偶尔把 2026-09-07 解析成 2027-01-01 这种错值,或者直接抛异常。
根因
SimpleDateFormat 内部保存了 Calendar 等可变状态,不是线程安全的。多个线程共用一个实例,会互相覆盖状态。
正确写法
Java 8 之后优先用线程安全的 DateTimeFormatter:
1 | DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); |
如果旧项目必须用 SimpleDateFormat,每次创建局部实例,或使用 ThreadLocal 隔离,并记得清理。
volatile 不能解决自增竞态
现象
用 volatile int count 做计数器,压测结果总是小于预期。
1 | private volatile int count = 0; |
根因
volatile 只保证可见性和禁止重排序,不保证复合操作的原子性。count++ 实际是“读、加一、写”三步,多线程会互相覆盖。
正确写法
用 AtomicInteger:
1 | private final AtomicInteger count = new AtomicInteger(); |
如果需要同时修改多个字段,AtomicInteger 也不够,要考虑锁或把状态封装成不可变对象。
synchronized 锁对象被重新赋值
现象
两个线程仍然同时进入同步块,因为锁对象不是 final,在别处被重新赋值了。
1 | private Object lock = new Object(); |
根因
synchronized 锁的是 lock 变量当时指向的对象。如果某个线程修改了 lock 的引用,不同线程就会锁到不同对象,互斥失效。
正确写法
锁对象必须是 final:
1 | private final Object lock = new Object(); |
更推荐直接锁业务对象或使用 ReentrantLock,避免靠一个随时可能变化的引用来保证互斥。
ThreadLocal 与 CompletableFuture
线程池复用导致 ThreadLocal 串号
现象
用户 A 的请求里读到了用户 B 的登录信息。排查发现当前用户信息存在 ThreadLocal 里,而任务跑在线程池中。
根因
线程池会复用线程。请求结束后,线程回到池里,但 ThreadLocal 的值还残留着。下一次任务复用同一个线程时,如果入口没有重新设置,就会读到上一次的数据。
正确写法
ThreadLocal 用完后必须在 finally 中清理:
1 | public void handle() { |
框架拦截器里尤其要注意。只要跨线程池传播上下文,就要同时设计好设置和清理两个动作。
不要让 CompletableFuture 默认跑公共池
现象
某个服务偶发响应变慢,日志显示任务堆积在 ForkJoinPool.commonPool 上。
根因
不传线程池时,CompletableFuture.supplyAsync 使用 ForkJoinPool.commonPool()。这个公共池由整个 JVM 共享,默认并行度通常接近 CPU 核数。如果异步任务里有数据库查询、HTTP 调用等阻塞操作,很容易把公共池占满,影响其他使用公共池的代码。
正确写法
业务异步任务显式传线程池:
1 | CompletableFuture.supplyAsync(() -> queryOrder(id), businessPool); |
parallelStream() 也默认使用公共池。如果流里包含阻塞 IO 或第三方库调用,最好改回普通 stream 加业务线程池,不要让所有并行逻辑抢同一个公共池。
排查清单
线上出现并发问题时,先看这几点:
- 线程池队列是否无界,线程数和拒绝策略是否合理。
submit的任务是否真的处理了Future异常。- 共享对象是否线程安全,尤其是日期格式化和可变状态。
- 复合操作是否用了原子类或锁,而不是只加
volatile。 ThreadLocal是否在使用后清理,线程是否会被复用。- 异步任务是否误用了
commonPool,导致公共池被阻塞任务占满。
