Java进阶必修课:线程这一关,难的不是写代码,而是解决并发问题
摘要
很多 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++ 不是一个原子操作,它至少会经历三步:
- 读取 count
- 执行加一
- 写回结果
多个线程同时执行时,就可能互相覆盖,最终导致统计值偏小。
解决方案一:简单计数用 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 个问题:
- 这段逻辑真的需要并发吗?
- 共享变量有没有竞争风险?
- 我用的是原子类、锁,还是错误地指望 volatile?
- 线程池是不是专用的、可控的?
- 异步任务有没有超时、异常处理和限流?
- ThreadLocal 有没有在 finally 里清理?
如果这 6 个问题里有 2 个答不上来,这段并发代码大概率还不够稳。
更多推荐

所有评论(0)