mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
1880 words
5 minutes
并发编程二:线程池、JUC 工具与异步编程
2025-02-19

并发编程二:线程池、JUC 工具与异步编程#

  • 为什么不要频繁 new Thread
  • 线程池核心参数怎么理解;
  • 线程池任务执行流程是什么;
  • 拒绝策略怎么选;
  • CountDownLatchCompletableFuture 适合什么场景;
  • 并发集合解决什么问题;
  • Spring Boot 里怎么做异步;
  • 生产环境怎么做线程池隔离。

锁和 CAS 本身展开会很长,所以单独写成专题:

为什么不要一直 new Thread#

最直觉的并发写法是来一个任务就创建一个线程:

for (int i = 0; i < 10000; i++) {
new Thread(() -> {
System.out.println("处理订单");
}).start();
}

这种写法在 Demo 里能跑,但在真实项目里很危险。

问题主要有三个:

  • 线程创建和销毁有成本;
  • 线程太多会造成大量上下文切换;
  • 请求量上来后可能把内存和 CPU 打满。

线程不是越多越快。CPU 核数是有限的,线程过多时,系统会花大量时间在线程切换上,真正执行任务的时间反而变少。

所以生产项目里更常见的做法是使用线程池。

线程池解决什么问题#

线程池的核心价值是复用线程和控制资源。

可以把它理解成:

提前准备一批工作线程
任务来了放进去
有空闲线程就执行
忙不过来就排队
再忙不过来就拒绝

这样做的好处是:

  • 避免频繁创建和销毁线程;
  • 控制最大并发量;
  • 让任务可以排队;
  • 在系统扛不住时触发拒绝策略,而不是无限制拖垮机器。

不推荐直接使用 Executors#

很多入门示例会这样创建线程池:

ExecutorService pool = Executors.newFixedThreadPool(10);

这很方便,但不推荐在生产环境直接使用。

原因是 Executors 一些工厂方法内部使用的是无界队列或不受控的线程数量,容易导致任务堆积甚至 OOM。

更推荐显式创建 ThreadPoolExecutor

ThreadPoolExecutor executor = new ThreadPoolExecutor(
5,
10,
60,
TimeUnit.SECONDS,
new LinkedBlockingQueue<>(100),
new ThreadPoolExecutor.CallerRunsPolicy()
);

虽然写起来长一点,但每个参数都清楚可控。

线程池核心参数#

ThreadPoolExecutor 最重要的参数有这些:

参数含义
corePoolSize核心线程数
maximumPoolSize最大线程数
keepAliveTime非核心线程空闲存活时间
unit时间单位
workQueue任务队列
threadFactory线程创建工厂
handler拒绝策略

举个订单通知线程池:

ThreadPoolExecutor notificationExecutor = new ThreadPoolExecutor(
4,
8,
60,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(500),
runnable -> new Thread(runnable, "notification-worker"),
new ThreadPoolExecutor.CallerRunsPolicy()
);

这里的意思是:

  • 平时保留 4 个核心线程;
  • 高峰期最多扩到 8 个线程;
  • 队列最多堆 500 个任务;
  • 如果实在处理不过来,就让提交任务的线程自己执行。

线程池执行流程#

线程池处理任务时,大致按这个顺序:

任务提交
核心线程数是否已满?
↓ 否
创建核心线程执行
↓ 是
队列是否已满?
↓ 否
进入队列等待
↓ 是
最大线程数是否已满?
↓ 否
创建非核心线程执行
↓ 是
执行拒绝策略

注意一个容易误解的点:

线程池不是核心线程满了就直接创建非核心线程,而是先尝试进队列。

只有队列满了,才会继续创建非核心线程。

常见拒绝策略#

线程池扛不住时,拒绝策略决定任务怎么处理。

常见策略:

策略行为
AbortPolicy直接抛异常,默认策略
CallerRunsPolicy谁提交任务,谁自己执行
DiscardPolicy直接丢弃任务,不抛异常
DiscardOldestPolicy丢弃队列里最旧的任务

业务里最常见的是 AbortPolicyCallerRunsPolicy

AbortPolicy 适合希望尽快暴露问题的场景。
CallerRunsPolicy 适合做一点反压,让提交方慢下来。

不要轻易使用静默丢弃策略,否则任务丢了可能很难发现。

线程池大小怎么估算#

线程池大小没有万能公式,但可以先按任务类型粗略估计。

CPU 密集型任务:

线程数 ≈ CPU 核数 + 1

比如加密、压缩、复杂计算。

IO 密集型任务:

线程数 ≈ CPU 核数 * 2

比如调用外部接口、读写数据库、读写文件。

但真实项目还是要结合监控调整:

  • CPU 使用率;
  • 队列长度;
  • 任务耗时;
  • 拒绝次数;
  • 活跃线程数;
  • 下游服务响应时间。

线程池不是配完就结束,应该持续观察和调优。

CountDownLatch:等待多个任务完成#

CountDownLatch 适合一个线程等待多个任务全部完成。

比如商品详情页需要并行查询:

  • 商品信息;
  • 库存;
  • 评论;
  • 推荐商品。

示例:

CountDownLatch latch = new CountDownLatch(3);
executor.execute(() -> {
try {
System.out.println("查询商品");
} finally {
latch.countDown();
}
});
executor.execute(() -> {
try {
System.out.println("查询库存");
} finally {
latch.countDown();
}
});
executor.execute(() -> {
try {
System.out.println("查询评论");
} finally {
latch.countDown();
}
});
latch.await();
System.out.println("全部加载完成");

countDown() 最好放在 finally 里,否则任务异常后没有减计数,主线程可能一直等下去。

CompletableFuture:更现代的异步编程#

CompletableFuture 更适合编排异步任务。

商品详情页可以这样写:

CompletableFuture<String> productFuture =
CompletableFuture.supplyAsync(() -> "商品信息", executor);
CompletableFuture<String> stockFuture =
CompletableFuture.supplyAsync(() -> "库存信息", executor);
CompletableFuture<String> commentFuture =
CompletableFuture.supplyAsync(() -> "评论信息", executor);
CompletableFuture.allOf(
productFuture,
stockFuture,
commentFuture
).join();
String product = productFuture.join();
String stock = stockFuture.join();
String comment = commentFuture.join();

注意这里显式传入了 executor

不建议在业务项目里随便使用默认线程池,因为不同业务混在同一个公共线程池里,出现慢任务时很难隔离。

CompletableFuture 的异常处理#

异步任务一定要考虑异常。

CompletableFuture<String> future = CompletableFuture
.supplyAsync(() -> queryStock(), executor)
.exceptionally(ex -> {
log.error("query stock failed", ex);
return "库存未知";
});

如果多个异步任务里某一个失败,要想清楚:

  • 是整体失败;
  • 还是返回兜底数据;
  • 是否需要重试;
  • 是否需要记录补偿任务。

异步不是把任务丢出去就结束,失败处理才是重点。

并发集合#

普通集合在并发读写时可能不安全。

比如多个线程同时写 HashMap,可能出现数据错乱。并发场景下可以使用 ConcurrentHashMap

ConcurrentHashMap<Long, String> productCache = new ConcurrentHashMap<>();
productCache.put(1001L, "键盘");
String name = productCache.get(1001L);

常见并发集合:

集合适合场景
ConcurrentHashMap高并发 Map 读写
CopyOnWriteArrayList读多写少的列表
BlockingQueue生产者消费者模型
ConcurrentLinkedQueue非阻塞队列

CopyOnWriteArrayList 适合读多写少,比如商品分类缓存、配置列表。写入时会复制数组,所以不适合高频写。

ConcurrentHashMap 内部会用到 CAS、锁分段等思想,CAS 细节可以看:Java CAS 专题:从 AtomicInteger 到乐观并发

Spring Boot 异步#

Spring Boot 里可以用 @Async 做异步任务。

这里只放最基础的用法,@Async 的线程池配置、异常处理、事务边界和常见失效场景,详细可以看:Spring @Async 实战:异步方法、线程池配置与常见失效场景

先开启异步:

@EnableAsync
@Configuration
public class AsyncConfig {
}

再写异步方法:

@Async("notificationExecutor")
public void sendMessage(Long userId) {
System.out.println("发送短信:" + userId);
}

生产环境不建议直接用默认线程池,最好显式配置:

@Bean("notificationExecutor")
public Executor notificationExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(8);
executor.setQueueCapacity(500);
executor.setThreadNamePrefix("notification-");
executor.initialize();
return executor;
}

还要注意:同一个类内部直接调用 @Async 方法,通常不会走代理,也就不会异步执行。

线程池隔离#

不要把所有异步任务都丢进同一个线程池。

错误做法:

发短信、写日志、导出报表、同步库存,全都使用同一个线程池

如果导出报表占满线程池,短信和库存同步也会被拖慢。

更推荐按业务拆分:

notificationExecutor:短信、邮件、站内信
exportExecutor:报表导出
inventoryExecutor:库存同步
logExecutor:日志写入

线程池隔离的目的不是“看起来更规范”,而是防止一个慢业务拖垮所有异步任务。

总结#

  • 不要频繁 new Thread,生产环境优先使用线程池。
  • 不推荐直接使用 Executors,更推荐显式配置 ThreadPoolExecutor
  • 线程池要关注核心线程数、最大线程数、队列长度和拒绝策略。
  • CountDownLatch 适合等待多个任务完成。
  • CompletableFuture 适合异步任务编排,但要显式指定线程池。
  • 并发集合不是万能的,要结合读写特点选择。
  • Spring Boot 异步要配置独立线程池,并注意代理调用问题。
  • 线程池要按业务隔离,避免互相拖垮。
Share

If this article helped you, please share it with others!

并发编程二:线程池、JUC 工具与异步编程
https://mizuki.mysqil.com/posts/java-concurrency-tools/
Author
梦幻晨风
Published at
2025-02-19
License
CC BY-NC-SA 4.0

Some information may be outdated

Table of Contents