摘要
很多 Java 开发都会写多线程,但一到线上环境,线程安全、线程池堆积、异步任务失控、ThreadLocal 串数据这些问题就会集中暴露。真正的差距,不在于你会不会用 Thread、synchronized、CompletableFuture,而在于并发出问题时,你能不能快速定位并给出可靠解法。这篇文章不空讲概念,直接从实际问题出发,讲清楚常见线程问题该怎么改、为什么这么改,以及在线上项目里如何把并发代码写得更稳。

做 Java 开发的人,几乎都写过线程相关代码。

有人写过 new Thread(),有人会用线程池,也有人在项目里用 CompletableFuture 做异步编排。可一旦代码到了线上,问题就开始变味了:

  • 明明本地跑得好好的,线上偶尔丢数据
  • 明明开了异步,接口反而更慢了
  • 明明用了线程池,服务却越来越卡
  • 明明只是传个上下文,最后还串请求了

这时候你会发现,线程真正难的,从来不是“会不会写”,而是:

出了问题以后,你知不知道该怎么解决。

这篇文章不讲大段理论,直接讲 5 类最常见的线程问题,以及对应的工程级解决方案。


一、count++ 为什么会出错,正确解法是什么?

先看一段最典型的错误代码:

public class Counter {
    private int count = 0;

    public void incr() {
        count++;
    }

    public int getCount() {
        return count;
    }
}

很多人第一次看到会觉得没问题,但在并发场景下,这段代码非常危险。

因为 count++ 不是一个原子操作,它至少会经历三步:

  1. 读取 count
  2. 执行加一
  3. 写回结果

多个线程同时执行时,就可能互相覆盖,最终导致统计值偏小。

解决方案一:简单计数用 AtomicInteger

import java.util.concurrent.atomic.AtomicInteger;

public class Counter {
    private final AtomicInteger count = new AtomicInteger(0);

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

    public int getCount() {
        return count.get();
    }
}

这个方案适合:

  • 请求计数
  • 访问量统计
  • 简单累加状态

它的优点是轻量,代码也直观。对于单变量自增场景,通常优先考虑原子类。

解决方案二:有“判断 + 修改”逻辑时,用锁保护

public class Account {
    private int balance = 1000;

    public synchronized boolean withdraw(int amount) {
        if (balance < amount) {
            return false;
        }
        balance -= amount;
        return true;
    }

    public synchronized int getBalance() {
        return balance;
    }
}

这里不能只看“减余额”这一步,而要看整个业务过程是否需要一起保证一致性。

一句话总结:

单值更新优先原子类,完整业务一致性优先锁。


二、线程停不下来,不是逻辑错了,而是你没解决可见性

错误代码非常常见:

public class Worker implements Runnable {
    private boolean stop = false;

    public void shutdown() {
        stop = true;
    }

    @Override
    public void run() {
        while (!stop) {
            // do work
        }
    }
}

为什么这段代码可能停不下来?

因为线程 A 把 stop 改成了 true,线程 B 不一定能立刻看到这个变化。

解决方案:用 volatile 保证可见性

public class Worker implements Runnable {
    private volatile boolean stop = false;

    public void shutdown() {
        stop = true;
    }

    @Override
    public void run() {
        while (!stop) {
            // do work
        }
    }
}

volatile 适合这类场景:

  • 停止标记
  • 状态开关
  • 配置刷新通知

但要注意一件事:

volatile 解决的是“看得到”,不是“改得安全”。

所以它适合状态通知,不适合 count++ 这种复合操作。


三、线程池不是为了提速,而是为了防失控

很多项目喜欢这样写:

ExecutorService executor = Executors.newFixedThreadPool(200);

表面看很方便,实际上这类代码在线上很容易出问题:

  • 线程数拍脑袋设置
  • 队列容量不透明
  • 异常处理缺失
  • 不同业务共用一个池子
  • 高峰期无法优雅退化

更稳妥的方案:手动创建 ThreadPoolExecutor

ThreadPoolExecutor executor = new ThreadPoolExecutor(
        8,
        16,
        60L,
        TimeUnit.SECONDS,
        new LinkedBlockingQueue<>(500),
        r -> new Thread(r, "order-pool-" + System.nanoTime()),
        new ThreadPoolExecutor.CallerRunsPolicy()
);

这段配置解决了几个关键问题:

  • 核心线程数可控
  • 最大线程数可控
  • 队列长度可控
  • 线程名称可追踪
  • 拒绝策略明确

其中最容易被忽略的,是拒绝策略。

`CallerRunsPolicy`` 的价值不在于“继续执行任务”,而在于:

当线程池扛不住时,让调用方一起变慢,从而形成天然限流。

这才是工程里真正重要的点。


四、ThreadLocal 不是线程安全神器,用错了最容易串数据

很多人会这样用 ThreadLocal:

private static final ThreadLocal<String> TRACE_ID = new ThreadLocal<>();

public void process(String traceId) {
    TRACE_ID.set(traceId);
    doBiz();
}

在单线程测试里完全没问题,但在线程池环境下特别容易踩坑。

因为线程池里的线程会复用,如果这次请求设置了值却没有清理,下次请求可能直接读到旧值。

正确方案:一定要 try-finally

private static final ThreadLocal<String> TRACE_ID = new ThreadLocal<>();

public void process(String traceId) {
    try {
        TRACE_ID.set(traceId);
        doBiz();
    } finally {
        TRACE_ID.remove();
    }
}

这个细节非常重要,尤其适合这些场景:

  • TraceId 透传
  • 登录用户上下文
  • 租户标识
  • 动态数据源标记

线上很多“偶发串号”问题,最后都是 ThreadLocal 没清理导致的。


五、异步代码真正的坑,不是写不出来,而是收不住

先看一个常见写法:

for (Order order : orders) {
    CompletableFuture.runAsync(() -> sendMsg(order));
}

这段代码的问题很集中:

  • 没指定线程池
  • 默认走公共线程池
  • 没有超时控制
  • 没有异常处理
  • 没有限制并发量

结果就是:任务一多,线程池和下游服务一起出问题。

更合理的方案:专用线程池 + 超时 + 异常兜底

ExecutorService msgExecutor = new ThreadPoolExecutor(
        4,
        8,
        60L,
        TimeUnit.SECONDS,
        new LinkedBlockingQueue<>(200),
        r -> new Thread(r, "msg-pool-" + System.nanoTime()),
        new ThreadPoolExecutor.CallerRunsPolicy()
);

CompletableFuture<Void> future = CompletableFuture
        .runAsync(() -> sendMsg(order), msgExecutor)
        .orTimeout(3, TimeUnit.SECONDS)
        .exceptionally(ex -> {
            log.error("sendMsg failed, orderId={}", order.getId(), ex);
            return null;
        });

这段改造的价值很明确:

  • 指定专用线程池,避免污染公共资源
  • 加超时,防止任务长时间卡死
  • 加异常处理,避免错误静默丢失
  • 队列有限,防止任务无限堆积

如果任务量很大,再进一步做分批处理:

int batchSize = 100;
for (int i = 0; i < orders.size(); i += batchSize) {
    int end = Math.min(i + batchSize, orders.size());
    List<Order> batch = orders.subList(i, end);
    for (Order order : batch) {
        CompletableFuture.runAsync(() -> sendMsg(order), msgExecutor);
    }
}

核心思路不是“怎么让任务更多地并发”,而是:

怎么让并发在系统承受范围内发生。


六、线程池进阶:一个线上真实味道的案例

这里补一个更接近生产环境的例子。

假设有一个订单系统,用户下单后需要异步做三件事:

  • 发优惠券
  • 发短信
  • 写操作日志

最初代码可能是这样:

public void afterOrderCreated(Order order) {
    executor.submit(() -> couponService.sendCoupon(order));
    executor.submit(() -> smsService.sendSms(order));
    executor.submit(() -> logService.saveLog(order));
}

上线初期没问题,但订单高峰一来,问题出现了:

  • 发短信接口偶尔超时
  • 日志服务高峰期变慢
  • 所有任务共用一个线程池
  • 慢任务把快任务也拖住了

结果是:

  • 线程池队列堆积
  • 优惠券发放延迟
  • 短信大量超时
  • 接口整体响应开始抖动

正确优化思路:按业务隔离线程池

private final ExecutorService couponExecutor = new ThreadPoolExecutor(
        4, 8, 60, TimeUnit.SECONDS,
        new LinkedBlockingQueue<>(200),
        r -> new Thread(r, "coupon-pool-" + System.nanoTime()),
        new ThreadPoolExecutor.CallerRunsPolicy()
);

private final ExecutorService smsExecutor = new ThreadPoolExecutor(
        8, 16, 60, TimeUnit.SECONDS,
        new LinkedBlockingQueue<>(500),
        r -> new Thread(r, "sms-pool-" + System.nanoTime()),
        new ThreadPoolExecutor.CallerRunsPolicy()
);

private final ExecutorService logExecutor = new ThreadPoolExecutor(
        2, 4, 60, TimeUnit.SECONDS,
        new LinkedBlockingQueue<>(1000),
        r -> new Thread(r, "log-pool-" + System.nanoTime()),
        new ThreadPoolExecutor.DiscardOldestPolicy()
);

然后按任务特征分别提交:

public void afterOrderCreated(Order order) {
    CompletableFuture.runAsync(() -> couponService.sendCoupon(order), couponExecutor)
            .exceptionally(ex -> {
                log.error("sendCoupon failed, orderId={}", order.getId(), ex);
                return null;
            });

    CompletableFuture.runAsync(() -> smsService.sendSms(order), smsExecutor)
            .orTimeout(2, TimeUnit.SECONDS)
            .exceptionally(ex -> {
                log.error("sendSms failed, orderId={}", order.getId(), ex);
                return null;
            });

    CompletableFuture.runAsync(() -> logService.saveLog(order), logExecutor)
            .exceptionally(ex -> {
                log.error("saveLog failed, orderId={}", order.getId(), ex);
                return null;
            });
}

为什么这样更合理?

  • 优惠券、短信、日志是不同业务,不应该互相拖垮
  • 短信是外部依赖,更容易超时,线程池要单独隔离
  • 日志相对次要,可以接受一定程度降级
  • 不同业务可以配不同的队列和拒绝策略

这才是线程池进阶里最重要的能力:

不是参数背得多,而是知道怎么按业务做资源隔离。


七、最后给一份并发代码自检清单

每次写线程相关代码前,我都建议先问自己这 6 个问题:

  1. 这段逻辑真的需要并发吗?
  2. 共享变量有没有竞争风险?
  3. 我用的是原子类、锁,还是错误地指望 volatile?
  4. 线程池是不是专用的、可控的?
  5. 异步任务有没有超时、异常处理和限流?
  6. ThreadLocal 有没有在 finally 里清理?

如果这 6 个问题里有 2 个答不上来,这段并发代码大概率还不够稳。

更多推荐