并发问题最麻烦的地方在于,它不一定每次都失败。很多代码在压测时能过,生产流量一大才暴露。这里整理的是我在后端系统里反复见到的线程池和共享状态问题。

线程池配置

不要无脑用 Executors.newFixedThreadPool

现象

某个异步任务越来越多,服务开始频繁 Full GC,最后内存溢出。翻代码发现用的是:

1
ExecutorService pool = Executors.newFixedThreadPool(200);

根因

newFixedThreadPool 使用无界的 LinkedBlockingQueue。线程数最多 200,但任务队列没有上限。如果生产速度长期高于消费速度,任务会在队列里无限堆积,最终耗尽堆内存。

newCachedThreadPool 则相反,队列没有容量,空闲线程可回收,但线程数上限是 Integer.MAX_VALUE。请求一多就可能创建大量线程,把机器压垮。

正确写法

生产环境最好显式创建 ThreadPoolExecutor,把核心线程数、最大线程数、队列容量和拒绝策略都想清楚:

1
2
3
4
5
6
7
8
9
ThreadPoolExecutor executor = new ThreadPoolExecutor(
8,
16,
60L,
TimeUnit.SECONDS,
new LinkedBlockingQueue<>(1000),
new ThreadFactoryBuilder().setNameFormat("order-pool-%d").build(),
new ThreadPoolExecutor.CallerRunsPolicy()
);

参数不是越大越好。CPU 密集任务核心线程数通常按 CPU 核数附近配置;IO 密集任务可以适当放大,但要用监控和压测确认,而不是拍一个 200。

拒绝策略默认直接抛异常

现象

流量突增时,日志里突然出现大量 RejectedExecutionException,请求直接失败。

根因

ThreadPoolExecutor 默认拒绝策略是 AbortPolicy,队列满且线程数达到最大值后,再提交任务就抛异常。

正确写法

拒绝策略应该按业务容忍度选择:

  • CallerRunsPolicy:让提交任务的线程自己执行,形成背压,适合希望“慢但别丢”的场景。
  • DiscardOldestPolicy:丢弃最老任务,适合任务有强时效性、新任务更重要的场景。
  • 自定义拒绝策略:记录日志、上报指标,或者降级处理。

不要默认 DiscardPolicy。它会把任务静默丢掉,没有日志,线上问题很难查。

异常与任务结果

submit 会吞掉任务异常

现象

定时任务偶尔不生效,但日志里没有任何异常。代码里用 executor.submit(() -> doWork()) 提交了任务。

根因

submit 返回 Future,任务抛出的异常不会立刻打印,而是保存在 Future 里。只有调用 Future.get() 才会重新抛出。如果没人调用 get,异常就被静默吞掉。

正确写法

如果不需要返回值,可以统一封装一层,提交后立刻处理异常:

1
2
3
4
5
6
Future<?> future = executor.submit(() -> doWork());
try {
future.get();
} catch (ExecutionException e) {
log.error("task failed", e.getCause());
}

或者提交时手动捕获:

1
2
3
4
5
6
7
executor.submit(() -> {
try {
doWork();
} catch (Exception e) {
log.error("task failed", e);
}
});

也可以使用 execute,它会让未捕获异常进入线程的 UncaughtExceptionHandler。但无论哪种方式,关键是不能让异常消失。

共享状态与可见性

线程池里的 SimpleDateFormat 串号

现象

并发解析日期时,偶尔把 2026-09-07 解析成 2027-01-01 这种错值,或者直接抛异常。

根因

SimpleDateFormat 内部保存了 Calendar 等可变状态,不是线程安全的。多个线程共用一个实例,会互相覆盖状态。

正确写法

Java 8 之后优先用线程安全的 DateTimeFormatter:

1
2
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
LocalDateTime time = LocalDateTime.parse("2026-09-07 10:00:00", formatter);

如果旧项目必须用 SimpleDateFormat,每次创建局部实例,或使用 ThreadLocal 隔离,并记得清理。

volatile 不能解决自增竞态

现象

用 volatile int count 做计数器,压测结果总是小于预期。

1
2
3
4
5
private volatile int count = 0;

public void increment() {
count++;
}

根因

volatile 只保证可见性和禁止重排序,不保证复合操作的原子性。count++ 实际是“读、加一、写”三步,多线程会互相覆盖。

正确写法

用 AtomicInteger:

1
2
3
4
5
private final AtomicInteger count = new AtomicInteger();

public void increment() {
count.incrementAndGet();
}

如果需要同时修改多个字段,AtomicInteger 也不够,要考虑锁或把状态封装成不可变对象。

synchronized 锁对象被重新赋值

现象

两个线程仍然同时进入同步块,因为锁对象不是 final,在别处被重新赋值了。

1
2
3
4
5
6
7
private Object lock = new Object();

public void doSomething() {
synchronized (lock) {
// critical section
}
}

根因

synchronized 锁的是 lock 变量当时指向的对象。如果某个线程修改了 lock 的引用,不同线程就会锁到不同对象,互斥失效。

正确写法

锁对象必须是 final:

1
private final Object lock = new Object();

更推荐直接锁业务对象或使用 ReentrantLock,避免靠一个随时可能变化的引用来保证互斥。

ThreadLocal 与 CompletableFuture

线程池复用导致 ThreadLocal 串号

现象

用户 A 的请求里读到了用户 B 的登录信息。排查发现当前用户信息存在 ThreadLocal 里,而任务跑在线程池中。

根因

线程池会复用线程。请求结束后,线程回到池里,但 ThreadLocal 的值还残留着。下一次任务复用同一个线程时,如果入口没有重新设置,就会读到上一次的数据。

正确写法

ThreadLocal 用完后必须在 finally 中清理:

1
2
3
4
5
6
7
8
public void handle() {
try {
userContext.set(currentUser);
doBusiness();
} finally {
userContext.remove();
}
}

框架拦截器里尤其要注意。只要跨线程池传播上下文,就要同时设计好设置和清理两个动作。

不要让 CompletableFuture 默认跑公共池

现象

某个服务偶发响应变慢,日志显示任务堆积在 ForkJoinPool.commonPool 上。

根因

不传线程池时,CompletableFuture.supplyAsync 使用 ForkJoinPool.commonPool()。这个公共池由整个 JVM 共享,默认并行度通常接近 CPU 核数。如果异步任务里有数据库查询、HTTP 调用等阻塞操作,很容易把公共池占满,影响其他使用公共池的代码。

正确写法

业务异步任务显式传线程池:

1
CompletableFuture.supplyAsync(() -> queryOrder(id), businessPool);

parallelStream() 也默认使用公共池。如果流里包含阻塞 IO 或第三方库调用,最好改回普通 stream 加业务线程池,不要让所有并行逻辑抢同一个公共池。

排查清单

线上出现并发问题时,先看这几点:

  1. 线程池队列是否无界,线程数和拒绝策略是否合理。
  2. submit 的任务是否真的处理了 Future 异常。
  3. 共享对象是否线程安全,尤其是日期格式化和可变状态。
  4. 复合操作是否用了原子类或锁,而不是只加 volatile。
  5. ThreadLocal 是否在使用后清理,线程是否会被复用。
  6. 异步任务是否误用了 commonPool,导致公共池被阻塞任务占满。