并发编程最佳实践
并发编程最佳实践关注的不是“会用多少并发 API”,而是怎样写出可预测、可维护、可观测、出问题后能快速止损的并发代码。
这一节把前面线程、锁、线程池、并发容器、异步编排和虚拟线程等内容收束成生产开发原则。
# 1. 并发设计优先级
写并发代码时,优先级应该是:
减少共享状态
│
▼
明确所有权
│
▼
选择同步工具
│
▼
设置边界与超时
│
▼
监控、压测、复盘
很多并发问题不是因为少用了高级工具,而是因为共享状态太多、生命周期不清、异常和超时没有设计。
# 2. 优先减少共享状态
共享状态越少,需要同步的地方越少。
推荐方式:
| 做法 | 说明 | 示例 |
|---|---|---|
| 局部变量 | 每个线程独立栈帧 | 请求内临时计算 |
| 不可变对象 | 创建后不再修改 | 配置快照、值对象 |
| 消息传递 | 通过队列交接数据 | 生产者消费者 |
| 分片数据 | 降低热点竞争 | 分段计数、按用户分桶 |
| 线程封闭 | 数据只被一个线程访问 | 单线程事件循环 |
不可变对象示例:
public record PriceRule(String skuId, BigDecimal discount, Instant expireAt) {
}
如果对象创建后状态不变,多个线程共享时就不需要锁来保护内部字段。
可变共享对象错误示例:
class DiscountContext {
private final Map<String, BigDecimal> cache = new HashMap<>();
BigDecimal get(String skuId) {
return cache.computeIfAbsent(skuId, this::loadDiscount);
}
}
如果这个类被多个线程共享,HashMap 就会有线程安全问题。修复方式可以是使用 ConcurrentHashMap,也可以把缓存所有权限定在单线程或请求上下文中。
# 3. 线程池必须可治理
生产线程池不能只有“能跑”,还要能治理。
线程池设计清单:
| 项目 | 要求 | 原因 |
|---|---|---|
| 命名 | 线程名包含业务含义 | 方便日志和 dump 定位 |
| 有界 | 线程数和队列容量有上限 | 防止资源失控 |
| 拒绝策略 | 拒绝时可观测、可降级 | 防止静默丢任务 |
| 超时 | 外部调用和 Future 等待要超时 | 防止无限占用线程 |
| 隔离 | 不同业务或慢任务拆池 | 避免互相拖垮 |
| 监控 | 活跃线程、队列、拒绝、耗时 | 提前发现拥塞 |
| 关闭 | 应用退出时优雅关闭 | 防止任务丢失和进程挂住 |
推荐模板:
ThreadPoolExecutor executor = new ThreadPoolExecutor(
8,
16,
60,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(1000),
new NamedThreadFactory("order-check"),
new ThreadPoolExecutor.CallerRunsPolicy()
);
线程池不是越大越好。线程池大小要和 CPU、下游连接池、数据库容量、业务延迟目标一起看。
# 4. 锁使用原则
锁的目标是保护不变量,而不是把代码块圈起来。
共享数据
│
▼
不变量:余额不能为负、库存不能超卖、状态不能倒退
│
▼
用锁保护“检查 + 修改”的完整临界区
库存扣减示例:
class Inventory {
private int stock;
synchronized boolean deduct(int count) {
if (stock < count) {
return false;
}
stock -= count;
return true;
}
}
这里锁保护的是“检查库存是否足够”和“扣减库存”这两个动作的原子性。
锁实践建议:
- 锁对象必须私有,不要锁字符串常量、包装类或外部可见对象。
- 多把锁要固定加锁顺序。
- 锁内不要做慢 IO、RPC、数据库调用。
- 不要在锁内调用未知外部回调。
- 锁保护的数据和锁对象要放在同一个类的边界内。
- 能用不可变对象替代共享可变状态时,优先不可变。
错误示例:
synchronized (userId.intern()) {
updateUser(userId);
}
intern 会把字符串放入全局字符串池,锁范围可能被意外扩大。更好的做法是使用受控的分段锁或业务级幂等设计。
# 5. 超时、取消与中断
没有超时的并发程序很容易在故障时耗尽资源。
必须设置超时的场景:
- RPC、HTTP、数据库、缓存调用。
Future.get、CompletableFuture.join/get。- 队列
offer/poll。 - 锁获取,例如
tryLock(timeout)。 - 批处理任务等待子任务完成。
示例:
Future<Order> future = executor.submit(() -> queryOrder(orderId));
try {
return future.get(800, TimeUnit.MILLISECONDS);
} catch (TimeoutException e) {
future.cancel(true);
throw new OrderQueryTimeoutException(orderId, e);
}
任务取消依赖中断协作。任务代码必须尊重中断。
while (!Thread.currentThread().isInterrupted()) {
Task task = queue.poll(500, TimeUnit.MILLISECONDS);
if (task != null) {
handle(task);
}
}
不要吞掉中断。
try {
queue.take();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
恢复中断标记能让上层逻辑继续感知取消信号。
# 6. 异步编排原则
异步编排的目标是减少阻塞等待,而不是把同步代码包装成异步形式。
推荐:
CompletableFuture<User> userFuture =
CompletableFuture.supplyAsync(() -> queryUser(userId), userExecutor);
CompletableFuture<List<Order>> orderFuture =
CompletableFuture.supplyAsync(() -> queryOrders(userId), orderExecutor);
return userFuture
.thenCombine(orderFuture, UserOrderView::new)
.orTimeout(1, TimeUnit.SECONDS)
.exceptionally(this::fallbackView);
不推荐:
CompletableFuture<User> userFuture =
CompletableFuture.supplyAsync(() -> queryUser(userId), executor);
User user = userFuture.join();
如果立刻 join,异步带来的收益会大幅下降,还可能造成线程池饥饿。
异步代码必须关注:
- 使用自定义线程池。
- 每个外部调用有超时。
- 异常链路有兜底。
- 结果合并后处理部分失败。
- ThreadLocal 上下文要显式传递。
- 不在同一个小线程池里同步等待子任务。
# 7. 并发容器选型
并发容器不是普通容器加锁的唯一替代,也不是万能解法。
| 场景 | 推荐 | 说明 |
|---|---|---|
| 高并发读写 Map | ConcurrentHashMap | 分散锁竞争,适合缓存和索引 |
| 读多写少 List | CopyOnWriteArrayList | 写时复制,读无锁 |
| 生产消费 | BlockingQueue | 队列自带阻塞协作 |
| 计数器 | LongAdder、AtomicLong | 高竞争计数可优先 LongAdder |
| 有序任务 | DelayQueue、优先级队列 | 注意无界队列风险 |
| 不变快照 | List.copyOf 等 | 适合发布只读数据 |
使用并发容器时仍要注意复合操作。
if (!map.containsKey(key)) {
map.put(key, value);
}
这不是原子操作。应使用:
map.putIfAbsent(key, value);
或者:
map.computeIfAbsent(key, this::loadValue);
# 8. 监控指标
并发程序一定要可观测。
线程池至少监控:
| 指标 | 含义 | 风险信号 |
|---|---|---|
| activeCount | 正在执行任务的线程数 | 长期接近 maximumPoolSize |
| poolSize | 当前线程数 | 异常增长 |
| queueSize | 队列长度 | 持续上升 |
| completedTaskCount | 已完成任务数 | 增长变慢 |
| rejectedCount | 拒绝次数 | 出现即要关注 |
| taskLatency | 任务执行耗时 | P95/P99 上升 |
| queueWaitTime | 排队等待时间 | 线程池拥塞 |
除了线程池,还要看:
- CPU 使用率和负载。
- GC 停顿和分配速率。
- 数据库连接池等待时间。
- HTTP 客户端连接池。
- 锁等待时间。
- 接口超时率和错误率。
并发优化必须用指标验证,不能只凭感觉。
# 9. 测试并发代码
并发代码测试要覆盖正确性和压力。
| 测试类型 | 目标 | 示例 |
|---|---|---|
| 单元测试 | 验证基本逻辑 | 状态转换、边界条件 |
| 多线程测试 | 暴露竞态 | 多线程同时扣减库存 |
| 压测 | 观察吞吐和延迟 | JMeter、wrk、业务压测 |
| 稳定性测试 | 观察长时间运行 | 队列是否堆积、内存是否泄漏 |
| 故障注入 | 验证超时和降级 | 下游变慢、异常、连接耗尽 |
多线程扣减示例:
int threadCount = 20;
CountDownLatch start = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
executor.execute(() -> {
await(start);
inventory.deduct(1);
done.countDown();
});
}
start.countDown();
done.await(3, TimeUnit.SECONDS);
CountDownLatch 可以让多个线程尽量同时开始,提高复现竞态条件的概率。
# 10. 代码评审清单
评审并发代码时,可以逐项检查:
- 是否存在共享可变状态。
- 共享状态是否有明确同步策略。
- 锁对象是否私有且稳定。
- 多把锁是否固定顺序。
- 锁内是否存在慢 IO 或外部调用。
- 线程池是否有界、命名、可监控。
- 等待 Future、锁、队列、外部调用是否有超时。
- 是否正确处理中断和取消。
- 异常是否会被吞掉。
- 是否存在同池任务互相等待。
- 并发容器复合操作是否原子。
- 是否有压测或并发测试覆盖。
# 11. 面试表达
如何写好生产级 Java 并发代码?
可以这样回答:
我会先尽量减少共享状态,用不可变对象、局部变量、队列或数据分片降低同步需求。如果必须共享状态,就明确锁保护的不变量,控制锁范围,避免锁内慢 IO,多把锁固定顺序。线程池必须显式配置线程数、队列、线程名、拒绝策略和监控,所有外部调用、Future 等待和锁等待都要有超时。最后通过压测、线程 dump、线程池指标和故障注入验证并发设计是否可靠。
# Tips 快问快答
Q:并发编程最重要的原则是什么? A:优先减少共享可变状态,其次才是选择锁或并发工具。
Q:线程池为什么必须命名? A:线程名能在日志和线程 dump 中直接定位业务来源。
Q:锁保护的到底是什么? A:锁保护的是共享数据的不变量,以及检查和修改之间的原子性。
Q:锁内为什么不能做 RPC 或数据库调用? A:慢调用会放大锁持有时间,导致大量线程阻塞。
Q:为什么要恢复中断标记?
A:捕获 InterruptedException 后恢复中断,能让上层代码继续感知取消信号。
Q:并发容器能解决所有线程安全问题吗? A:不能。单个方法通常线程安全,但多个方法组合起来仍可能不是原子的。
Q:异步代码为什么也要超时? A:没有超时的异步任务会长期占用线程、连接和内存。
Q:CompletableFuture 默认线程池能不能用?
A:简单非阻塞任务可以,生产阻塞任务建议使用自定义线程池。
Q:如何判断线程池快被打满? A:看活跃线程数、队列长度、排队时间和拒绝次数是否持续上升。
Q:并发测试为什么需要 CountDownLatch?
A:它可以让多个线程同时起跑,更容易暴露竞态问题。