文章目录
Pre
性能优化 - 案例篇:池化对象_Commons Pool 2.0通用对象池框架
- 引言:多核时代与并行编程意义;
- 并行获取数据示例:CountDownLatch 封装与使用;
- 线程池设计与调优:核心/最大线程数、队列、拒绝策略;
- SpringBoot 中的异步执行:@EnableAsync、@Async 与自定义线程池;
- 线程安全与常见并发陷阱:不安全集合、SimpleDateFormat 等案例;
- ThreadLocal 与 Netty FastThreadLocal:底层实现与性能差异;
- 多线程常见问题盘点与解决思路;
- 小结:并行编程的关键要点与注意事项;
1. 引言:多核时代与并行编程意义
如今的计算设备(PC、服务器,甚至手机)几乎都配备了多颗 CPU 核心,正确地利用多核并发能力,才能最大化提升系统吞吐量与响应速度。对于业务场景而言,如果某个接口仅串行地等待 20+ 个下游接口返回,就会产生明显的性能瓶颈。Java 平台通过丰富的并发 API(java.util.concurrent
包),让我们无需直接操心底层线程创建与同步,就能轻松实现高效并行。
接下来将从一个“并行获取数据”的核心示例切入,深入讲解:
- CountDownLatch 封装并行任务,协同等待整体结果;
- 线程池 设计要素与常见参数(核心线程数、最大线程数、队列容量、拒绝策略);
- SpringBoot 异步任务:
@EnableAsync
、@Async
与自定义ThreadPoolTaskExecutor
; - 线程安全陷阱:并发下的集合、安全日期类、异常丢失等常见问题;
- ThreadLocal vs FastThreadLocal:内部数据结构与查找性能对比;
- 并行编程中需要重点关注的几大考点与实践经验。
2. 并行获取数据示例:CountDownLatch 封装
2.1 场景描述
假设有一个“用户画像”接口,需要在 50ms 内返回给前端。
- 系统需从 20+ 个下游服务接口并行汇总数据;
- 每个下游接口最优耗时约 20ms,如果串行调用,总耗时 ≈ 20ms × 20 = 400ms,远超 50ms SLA;
- 解决思路:并行调用所有下游接口,最后汇总结果,超时则放弃未返回的子任务。
2.2 CountDownLatch 工具类封装
Java 的 CountDownLatch
本质上是一个可重用的倒计时计数器。
- 构造时指定“请求总数”
N
; - 每当一个并行任务完成后,调用
countDown()
将计数器减 1; - 主线程调用
await(long timeout, TimeUnit)
阻塞,直到计数器变为 0或超时。
下面提供一个通用封装示例 ParallelFetcher
:
public class ParallelFetcher {
private final long timeout;
private final CountDownLatch latch;
private final ThreadPoolExecutor executor;
public ParallelFetcher(int jobSize, long timeoutMill) {
// 初始化倒计时锁:任务数 = jobSize
this.latch = new CountDownLatch(jobSize);
this.timeout = timeoutMill;
// 自定义线程池:核心 100,最大 200,队列容量 100,空闲线程 1 小时后回收
this.executor = new ThreadPoolExecutor(
100, 200,
1, TimeUnit.HOURS,
new ArrayBlockingQueue<>(100)
);
}
/**
* 提交一个并行任务,任务执行完毕后自动 countdown()
*/
public void submitJob(Runnable task) {
executor.execute(() -> {
try {
task.run();
} finally {
latch.countDown();
}
});
}
/**
* 阻塞等待:直到所有任务 countdown() 或者超时
*/
public void await() {
try {
latch.await(timeout, TimeUnit.MILLISECONDS);
} catch (InterruptedException e) {
throw new IllegalStateException("并行任务等待被中断", e);
}
}
/** 释放线程池资源 */
public void dispose() {
executor.shutdown();
}
}
2.3 使用示例
假设我们有一个 SlowInterfaceMock
类,模拟 20 个耗时随机(0–60ms)的下游接口:
public class ParallelFetcherDemo {
public static void main(String[] args) {
String userId = "123";
SlowInterfaceMock mock = new SlowInterfaceMock();
// 并行任务数 20,整体等待超时 50ms
ParallelFetcher fetcher = new ParallelFetcher(20, 50);
// 线程安全的集合存储并发结果
Map<String, String> resultMap = new ConcurrentHashMap<>();
// 提交 20 个并行任务
for (int i = 0; i < 20; i++) {
int idx = i;
fetcher.submitJob(() -> {
// 假设 mock.methodX(userId) 模拟调用下游服务
resultMap.put("method" + idx, mock.call(userId, idx));
});
}
// 主线程阻塞最多 50ms,或者直到 20 个任务全部完成
fetcher.await();
// 查看未超时时到底收集了多少结果
System.out.println("Latch 值(剩余任务数): " + fetcher.latch.getCount());
System.out.println("收集到的结果数量: " + resultMap.size());
System.out.println("结果内容: " + resultMap);
// 记得释放线程池
fetcher.dispose();
}
}
注意事项:
-
结果集合要线程安全:示例中使用了
ConcurrentHashMap
,若直接用HashMap
,在并发写入时会出现数据丢失或线程冲突。 -
await(timeout)
超时后仍有未完成任务:CountDownLatch.await
超时后只是唤醒主线程,但工作线程仍在继续执行,若要彻底中断,需在任务内部对超时逻辑做特殊处理(如设置连接超时、检查中断标志等)。 -
线程池参数配置:
- 核心线程数
corePoolSize = 100
,最大线程数200
,队列容量100
。 - 若业务并发量预计不止 1 个,此处可根据 “任务量 / 50ms” 的最差并发做估算。
- I/O 密集型:线程数可远超 CPU 核数;
- 计算密集型:线程数应接近 CPU 核数,过多线程反而增加上下文切换。
- 核心线程数
3. 线程池设计与调优
ThreadPoolExecutor
是 Java 并发包里最灵活的线程池实现,常见构造参数如下:
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler)
- corePoolSize:核心线程数,线程池会一直维持至少这个数量的线程常驻;
- maximumPoolSize:最大线程数,超过
corePoolSize
、且队列已满时才会创建; - keepAliveTime + unit:非核心线程空闲多久后会被回收;
- workQueue:阻塞队列,用来缓存提交但尚未执行的任务;
- threadFactory:可自定义线程名称、守护状态等;
- handler(拒绝策略):当线程池和队列都已满时,如何处理新的任务。
3.1 常用工作队列类型
-
无界队列
LinkedBlockingQueue
-
构造示例:
new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>());
-
特点:无限制容量,不会触发
maximumPoolSize
;所有任务都排队等待,可能导致内存使用过大。 -
适用场景:任务总量可控、内存充足,且希望核心线程数固定。
-
-
有界队列
ArrayBlockingQueue
或LinkedBlockingQueue(capacity)
-
构造示例:
new ThreadPoolExecutor(coreSize, maxSize, 1, TimeUnit.HOURS, new ArrayBlockingQueue<>(100));
-
特点:队列容量固定,若队列满后才会创建非核心线程;能平滑控制任务量与线程数。
-
适用场景:既要防止过度排队占内存,又要允许峰值并发时扩充线程。
-
-
SynchronousQueue
(不存储任务)-
构造示例:
new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60, TimeUnit.SECONDS, new SynchronousQueue<>());
-
特点:提交任务时,必须有空闲线程立即接收,否则创建新线程;没有队列缓存,每个任务要么立即执行,要么创建线程。
-
适用场景:短平快任务,追求极低延迟;但需谨慎控制最大线程数,以免爆发大量线程导致 OOM。
-
3.2 常见拒绝策略(RejectedExecutionHandler
)
AbortPolicy
(默认):抛出RejectedExecutionException
,由调用方捕获或让其冒泡失败;CallerRunsPolicy
:将任务回退到调用线程执行,既不会抛弃也不会爆错,但会导致调用者线程被阻塞;DiscardPolicy
:直接静默丢弃任务,不抛异常,不占用调用线程;DiscardOldestPolicy
:丢弃队列中最旧的任务,然后尝试将新任务加入队列;
选型要点:
-
业务是否能容忍丢失任务?
- 关键业务(如订单处理、支付)一般倾向于让请求快速失败,让上层有补偿机制;
- 非关键异步任务可以选用丢弃策略,降低压力。
-
是否希望“退而求其次”(
CallerRunsPolicy
)?- 若调用者线程可短暂承担执行压力,且能承受更长响应时延,可考虑;
3.3 线程池示例
// 核心池大小 = 50,最大池大小 = 100,队列容量 = 200
ThreadPoolExecutor executor = new ThreadPoolExecutor(
50, 100,
60, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(200),
new ThreadFactoryBuilder().setNameFormat("async-worker-%d").build(),
new ThreadPoolExecutor.AbortPolicy()
);
-
分析:
- 常住线程 50,当并发任务超过 50 时,先往队列(200)排队;
- 队列也满时,再扩展到最大线程数 100;
- 若 100 线程 + 200 队列都饱和,则触发抛弃策略,让后续调用方捕获异常;
- 非核心(50~100)空闲 60s 后回收。
4. SpringBoot 中的异步执行
SpringBoot 提供了极简的方式去执行异步任务:
-
在主类上启用异步支持
@SpringBootApplication @EnableAsync public class Application { … }
-
在需要异步的方法上添加
@Async
注解@Service public class MyService { @Async("myThreadPool") public CompletableFuture<String> fetchData() { // 模拟耗时调用 Thread.sleep(1000); return CompletableFuture.completedFuture("OK"); } }
-
自定义线程池
ThreadPoolTaskExecutor
@Configuration public class AsyncConfig { @Bean("myThreadPool") public ThreadPoolTaskExecutor myThreadPool() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(20); executor.setMaxPoolSize(50); executor.setQueueCapacity(100); executor.setKeepAliveSeconds(30); executor.setThreadNamePrefix("async-worker-"); executor.initialize(); return executor; } }
-
调用异步方法
@Autowired private MyService myService; public void process() { // 立即返回,不阻塞调用线程 CompletableFuture<String> future = myService.fetchData(); // 后续可以 .thenAccept()、.get(timeout) 等处理结果 }
注意:
- 默认
@Async
若不指定Executor
名称,会使用 Spring 提供的单例的 SimpleAsyncTaskExecutor,该 Executor 不做线程复用,新任务直接创建新线程,长期来看会导致线程暴增,不推荐; - 自定义
ThreadPoolTaskExecutor
时,同样要参考上文“线程池设计”部分,避免无限制队列或过高的最大线程数; - 当异步方法需要返回结果且捕获异常时,应使用
CompletableFuture
或ListenableFuture
,否则默认抛出的异常会静默丢失。
5. 线程安全与常见并发陷阱
并发编程的最大挑战在于:多线程同时访问共享状态会出现竞态、可见性、重排序等问题。下面盘点一些常见的面试高频陷阱与注意点。
5.1 非线程安全集合示例
-
HashMap
- 不是线程安全的,多线程并发
put
/get
会造成数据丢失、死循环、甚至 CPU 100% 占用(HashMap 链表结构循环指向)。 - 解决:使用
ConcurrentHashMap
。
- 不是线程安全的,多线程并发
-
ArrayList
- 并发插入时可能触发扩容与内部数组复制,若不加锁会导致
IndexOutOfBoundsException
或数据覆盖。 - 解决:使用
CopyOnWriteArrayList
或外层同步。
- 并发插入时可能触发扩容与内部数组复制,若不加锁会导致
5.2 SimpleDateFormat
线程安全问题
SimpleDateFormat
不是线程安全的,内部维持可变的 Calendar
对象。并发调用 format()/parse()
会导致输出混乱、抛出 ArrayIndexOutOfBoundsException
等。
public class DateFormatDemo {
private static final SimpleDateFormat SDF = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
public static void main(String[] args) throws InterruptedException {
ExecutorService executor = Executors.newFixedThreadPool(10);
for (int i = 0; i < 100; i++) {
executor.execute(() -> {
System.out.println(SDF.format(new Date()));
});
}
executor.shutdown();
}
}
解决方案:
-
为每个线程关联一个
SimpleDateFormat
:private static final ThreadLocal<SimpleDateFormat> SDF_TL = ThreadLocal.withInitial( () -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss") ); // 使用: SDF_TL.get().format(date);
-
使用 JDK8+ 的
java.time.format.DateTimeFormatter
,它天然线程安全;
5.3 异常丢失问题:submit()
vs execute()
-
execute(Runnable)
- 如果任务运行时抛出未捕获异常,会触发
ThreadPoolExecutor.afterExecute()
,若未重写,异常会打印到控制台,程序继续运行;
- 如果任务运行时抛出未捕获异常,会触发
-
submit(Runnable)
/submit(Callable)
- 返回
Future<?>
对象;若任务抛异常,不会自动打印,需要在后续调用future.get()
时才能看到ExecutionException
。 - 若开发者未调用
get()
,异常会静默丢失,排错困难。
- 返回
建议:
- 如果希望及时发现任务异常,可使用
execute()
; - 若必须使用
submit()
(需要Future
返回值),应在调用者处定期future.get()
或为线程池自定义ThreadFactory
+UncaughtExceptionHandler
。
5.4 ParallelFetcher
示例的常见陷阱
- 结果 Map 必须使用线程安全集合,如
ConcurrentHashMap
,否则并发put
会丢失数据; CountDownLatch.await(timeout)
超时后,当前线程被唤醒,但还在执行的子任务不会被自动取消;若任务涉及网络 I/O,应为每个子任务单独设置连接超时或检查中断标志;- 异常丢失:若子任务内部抛出 RuntimeException,若未在
runnable.run()
包裹try/catch
,会导致任务直接退出,且不会回倒计时;建议在runnable.run()
内部捕捉所有异常,并在finally
中countDown()
,保证倒计时。
6. ThreadLocal 与 Netty FastThreadLocal
在多线程项目中,常用 ThreadLocal<T>
维护线程私有数据,以便在线程间传递上下文(如用户会话、事务信息等)。但其底层实现存有性能隐患:
6.1 ThreadLocal
底层:“开放寻址”数组
-
每个
Thread
对象中都维护一个ThreadLocalMap threadLocals
; -
ThreadLocalMap
内部结构并未实现java.util.Map
,而是使用数组+开放寻址法存储键值对:- 数组长度通常比实际元素数大,通过简单哈希定位索引,若冲突则“线性探测”继续向后查找空位;
- 查找与插入成本:在最坏情况下 O(n) 次探测。
-
当线程中使用大量不同的
ThreadLocal
时,ThreadLocalMap
容易产生频繁的重哈希与探测,性能开销较高。
6.2 Netty FastThreadLocal
的优化
Netty 针对 ThreadLocal
的这一低效点,提出了 FastThreadLocal
:
-
预先分配一个固定大小的 数组(称为 ‘indexed variable table’);
-
每个
FastThreadLocal
在创建时分配一个全局唯一的 int 索引; -
对应线程维护一个
InternalThreadLocalMap fastThreadLocals
,该 map 仅是一个Object[] indexedVariables
数组;set/remove/get
时直接通过索引访问:indexedVariables[index]
→ O(1) 读写;
-
极大减少了哈希探测、重哈希开销,适合 Netty 这一类底层高并发网络库频繁读写场景。
示例对比:
// JDK ThreadLocal 写入
ThreadLocal<String> tl = ThreadLocal.withInitial(() -> "foo");
String val = tl.get(); // 底层需要 hash,然后开放寻址法查找
// Netty FastThreadLocal 写入
FastThreadLocal<String> ftl = new FastThreadLocal<>();
String val2 = ftl.get(); // 直接 indexedVariables[index]
- 在多线程并发场景下,
FastThreadLocal
读写性能优于ThreadLocal
,但会占用一点固定内存做数组; - 若应用本身并没有太多
ThreadLocal
,且并发不特别极致,ThreadLocal
足矣;只有在追求每百万次操作极限性能时,才考虑FastThreadLocal
。
7. 多线程常见问题盘点与解决思路
在工作与面试中,以下并发知识点尤其容易被考察:
-
I/O 密集型 vs 计算密集型
- I/O 密集型:大部分时间在等待数据库/网络响应,线程数可大于 CPU 核数;
- 计算密集型:大部分时间在 CPU 计算,线程数应接近 CPU 核数。
-
线程安全集合
非线程安全 线程安全 ArrayList
CopyOnWriteArrayList
HashMap
ConcurrentHashMap
HashSet
CopyOnWriteArraySet
SimpleDateFormat
DateTimeFormatter
(Java8+)StringBuilder
StringBuffer
-
异常丢失
executor.submit(...)
并不会自动打印异常,必须调用Future.get()
才能捕获;executor.execute(...)
中抛出的未捕获异常会进入afterExecute
,可借此定制日志。
-
任务取消
CountDownLatch.await(timeout)
只是唤醒等待线程,并不会中断子任务;- 若需真正取消,还必须让子任务自身在可中断状态下(如阻塞 I/O,检查
Thread.interrupted()
)及时退出。
-
SimpleDateFormat
线程安全- 不是线程安全的,需用
ThreadLocal<SimpleDateFormat>
或者DateTimeFormatter
替代;
- 不是线程安全的,需用
-
ThreadLocal
内存泄漏- 若线程池复用同一线程,又未及时调用
threadLocal.remove()
,会导致下一次任务仍然拿到旧值; - 需在线程使用完成后显式
ThreadLocal.remove()
,或在任务上下文结束时清理。
- 若线程池复用同一线程,又未及时调用
-
异常处理在循环任务中的重要性
while (!Thread.currentThread().isInterrupted()) { try { // 处理业务逻辑 } catch (Exception ex) { // 记录日志,避免循环直接中断 } }
- 如果循环内部的任意一次运行抛出未捕获 RuntimeException,就会退出循环,导致线程提前结束;
-
生产者—消费者模型
- 使用
LinkedBlockingQueue
、ArrayBlockingQueue
等超时offer/poll
,结合ReentrantLock
、Condition
或Semaphore
,实现线程安全、可控的任务队列;
- 使用
8. 小结:并行编程的关键要点与注意事项
-
并行获取数据
- 当单个接口需要汇总多个耗时调用时,串行执行常常会超过 SLA,必须并发多线程调用;
- 使用
CountDownLatch
、ExecutorService
等工具类,让代码更简洁可靠; - 尽量为每个并行任务设置单独的“调用超时时间”,避免单个子任务无限等待拖垮整体。
-
线程池设计与调优
-
明确业务类型:I/O 密集型 vs 计算密集型,分别估算合适线程数;
-
选择合理的工作队列:
- 无界队列:不会触发扩容,但易 OOM;
- 有界队列:平滑过载,但需要配置队列容量与最大线程数;
- SynchronousQueue:零队列,适合实时性要求高的任务;
-
拒绝策略应根据业务可容忍方案选取:快速失败、回退到调用线程或丢弃策略。
-
-
SpringBoot 异步任务
- 使用
@Async
只需要加三行注解就能开启异步,但务必提供自定义线程池,避免默认无限线程浪涌; - 异步方法若需要返回值,应使用
CompletableFuture
、ListenableFuture
等,及时捕获并处理异常;
- 使用
-
线程安全&常见陷阱
- 并发环境首选“线程安全集合”与“不可变对象”避免竞态;
SimpleDateFormat
必须在每个线程独立实例或用DateTimeFormatter
;submit()
提交任务异常不会自动打印,需显式调用Future.get()
或自定义UncaughtExceptionHandler
;
-
ThreadLocal vs FastThreadLocal
ThreadLocal
基于“开放寻址”数组查找,少量ThreadLocal
场景开销可忽略;- Netty
FastThreadLocal
用预分配数组 + 索引的方式,保证了 O(1) 访问,适合高并发量底层网络库。
-
并发设计习惯
- 循环任务必须捕获所有异常,避免线程意外退出;
- 并发代码中尽量使用简单易懂的并发原语(例如
CountDownLatch
、Semaphore
),避免手写wait/notify
; - 注意内存泄漏:
ThreadLocal
在线程池环境里一定要手动remove()
,防止后续任务读取到旧值; - 并发场景下绝不使用非线程安全的共享对象;
通过掌握以上要点,开发者能够在多核多线程时代更自信地编写并行代码,既满足业务响应时长要求,又能避免常见并发陷阱。
-
建议:
- 尽量用
CountDownLatch
、CyclicBarrier
、CompletableFuture
等高层原语封装并行逻辑,避免手写Thread
/wait-notify
; - 线程池设计要基于业务类型(I/O vs 计算),合理配置核心/最大线程数、队列与拒绝策略;
- 异步任务要自定义线程池,并使用
CompletableFuture
捕获异常,避免提交后“异常静默丢失”; - 多线程环境下常见集合与日期类并发陷阱要牢记:一定使用线程安全版本或可见性安全的替代品;
- 理解
ThreadLocal
底层实现局限,仅在必要时使用;若对性能要求极致,可考虑 NettyFastThreadLocal
。
- 尽量用