Java 线程池详解:7 个参数怎么配、4 种拒绝策略怎么选(附手写实现)
Java 线程池详解:7 个参数怎么配、4 种拒绝策略怎么选(附手写实现)
📌 先说结论
线程池就解决一个问题:
别每次都
new Thread,复用一批线程来执行任务。
用它的三个理由,用数据说话(我实测了 1000 个任务):
| 方式 | 总耗时 | 单个任务开销 |
|---|---|---|
new Thread创建并启动 | 189 ms | 约 189 微秒 |
| 线程池复用执行 | 8 ms | 约 8 微秒 |
差了 20 多倍。因为创建线程要分配栈内存(默认 512KB~1MB)、向操作系统注册,成本很高。
本文按"会用 → 会配 → 知道原理"的顺序讲。只想知道怎么用,看第一、二章就够了。手写实现放第五章,标注"可选"。
一、怎么用:三种创建方式
1.1 方式一:Executors工厂(简单但不推荐生产用)
// 固定大小线程池ExecutorServicepool=Executors.newFixedThreadPool(8);// 单个线程(保证任务顺序执行)ExecutorServicesingle=Executors.newSingleThreadExecutor();// 带缓存的线程池(线程数按需增长)ExecutorServicecached=Executors.newCachedThreadPool();// 定时任务线程池(这个可以放心用)ScheduledExecutorServicescheduled=Executors.newScheduledThreadPool(4);提交任务:
// 方式 1:execute(不关心返回值)pool.execute(()->System.out.println("任务跑完了"));// 方式 2:submit(拿 Future,可以拿返回值)Future<Integer>future=pool.submit(()->{return1+1;});Integerresult=future.get();// 阻塞等结果// 方式 3:invokeAll(批量提交,等全部完成)List<Callable<String>>tasks=List.of(()->"task1",()->"task2");List<Future<String>>futures=pool.invokeAll(tasks);用完要关闭:
pool.shutdown();// 不接受新任务,执行完已有的就停pool.awaitTermination(10,TimeUnit.SECONDS);// 等它真的停⚠️
shutdown()和shutdownNow()的区别:
shutdown():温和关闭,不再接受新任务,但已提交的会执行完;shutdownNow():暴力关闭,试图中断正在执行的任务,返回还没执行的任务列表。
1.2 方式二:直接new ThreadPoolExecutor(生产推荐)
ThreadPoolExecutorpool=newThreadPoolExecutor(8,// corePoolSize:核心线程数16,// maximumPoolSize:最大线程数60L,TimeUnit.SECONDS,// keepAliveTime:非核心线程空闲存活时间newLinkedBlockingQueue<>(1000),// workQueue:任务队列newThreadFactory(){// threadFactory:怎么创建线程privatefinalAtomicIntegerseq=newAtomicInteger(1);@OverridepublicThreadnewThread(Runnabler){Threadt=newThread(r,"my-pool-"+seq.getAndIncrement());t.setDaemon(false);returnt;}},newThreadPoolExecutor.AbortPolicy()// handler:拒绝策略);1.3 为什么阿里规约禁用Executors
《阿里巴巴 Java 开发手册》明确要求不要用Executors创建线程池,两个原因都是"可能把内存撑爆":
| 工厂方法 | 问题 |
|---|---|
newFixedThreadPool(n) | 队列是LinkedBlockingQueue,默认容量 Integer.MAX_VALUE(约 21 亿),任务堆积可能 OOM |
newCachedThreadPool() | 最大线程数是Integer.MAX_VALUE,线程数可能无限增长,直接 OOM |
newSingleThreadExecutor() | 同newFixedThreadPool,队列无界 |
newScheduledThreadPool(n) | ✅这个相对安全,队列有界 |
💡结论:自己
new ThreadPoolExecutor,7 个参数全部显式指定。这样每个参数你都知道它是什么,出了问题也能定位。
二、7 个参数逐个讲(重点)
这是全文最该记住的一章。
2.1corePoolSize— 核心线程数
线程池会一直保持的线程数量。即使这些线程空闲,也不会被销毁(除非你设置allowCoreThreadTimeOut(true))。
怎么定?见 2.5。
2.2maximumPoolSize— 最大线程数
线程池允许创建的线程上限。当核心线程都忙、队列也满了,才会继续创建新线程,直到达到这个上限。
2.3keepAliveTime+unit— 空闲存活时间
非核心线程空闲超过这个时间,就会被销毁。核心线程默认不受影响。
60L,TimeUnit.SECONDS// 非核心线程空闲 60 秒后回收2.4workQueue— 任务队列
核心线程都忙时,新任务放这儿排队。三种常用队列:
| 队列 | 特点 | 适用场景 |
|---|---|---|
ArrayBlockingQueue(n) | 有界,数组实现 | ✅推荐,能防 OOM |
LinkedBlockingQueue(n) | 有界(不传参就是无界 ⚠️) | 推荐传容量 |
SynchronousQueue | 容量 0,任务直接交接给线程 | 适合"线程数要快速扩容"的场景 |
PriorityBlockingQueue | 优先级队列,无界 ⚠️ | 任务有优先级时用 |
⚠️最容易踩的坑:
new LinkedBlockingQueue()不传容量 = 容量Integer.MAX_VALUE=无界队列。
后果:maximumPoolSize和拒绝策略永远不会被触发(任务全堆在队列里),堆积到 OOM。
2.5 核心线程数怎么算(面试必问)
经验公式:
CPU 密集型任务(计算为主):线程数 = CPU 核数 + 1 IO 密集型任务(等网络/磁盘):线程数 = CPU 核数 * 2 (或者更大)为什么 CPU 密集型是"核数 + 1"?因为线程数超过核数后,多出来的线程只会增加上下文切换开销,没有收益。+1 是为了在某线程偶发阻塞(缺页等)时还有线程能用。
为什么 IO 密集型可以更大?因为线程大部分时间在等 IO(不占 CPU),多开几个线程能让 CPU 更忙。
// 获取 CPU 核数intcpuCores=Runtime.getRuntime().availableProcessors();💡真实项目里别死记公式,先用公式给个初值,再根据监控数据(队列长度、活跃线程数、拒绝次数)调整。
2.6threadFactory— 线程工厂
决定线程怎么创建(起什么名字、是否是守护线程、优先级多少)。
强烈建议自己指定,因为默认线程名是pool-1-thread-1这种,出问题时根本不知道是哪个业务:
newThreadFactory(){privatefinalAtomicIntegerseq=newAtomicInteger(1);@OverridepublicThreadnewThread(Runnabler){Threadt=newThread(r,"order-pool-"+seq.getAndIncrement());t.setDaemon(false);// 还可以设置异常处理器t.setUncaughtExceptionHandler((thread,ex)->{log.error("线程 "+thread.getName()+" 异常",ex);});returnt;}}💡 更好的做法:线程名带上业务标识(
order-pool、pay-pool),这样jstack一眼就能看出哪个池子出问题。
2.7handler— 拒绝策略
线程满了、队列也满了时怎么办。详见第三章。
三、任务提交后发生了什么(任务流转)
这是理解线程池最关键的一张图。面试常问"提交一个任务,线程池内部怎么处理"。
提交任务 │ ▼ ① 核心线程数还没满? ├─ 是 → 创建新线程执行任务 ✅ └─ 否 ↓ ② 队列还有空位? ├─ 是 → 把任务放进队列排队 ✅ └─ 否 ↓ ③ 线程数还没到 maximumPoolSize? ├─ 是 → 创建新线程执行任务 ✅ └─ 否 ↓ ④ 执行拒绝策略关键点:队列满之前,不会扩容到 maximumPoolSize。也就是说如果队列是无界的,maximumPoolSize永远用不上。
3.1 实测验证
我设了core=2, max=4, 队列容量=2,连续提交 6 个任务,打印每次提交后的状态:
参数: core=2, max=4, queue=2 提交任务1 → 线程数/活跃/队列 = 0/0/0 → 1/1/0 ← 创建核心线程 提交任务2 → 线程数/活跃/队列 = 1/1/0 → 2/2/0 ← 创建核心线程 提交任务3 → 线程数/活跃/队列 = 2/2/0 → 2/2/1 ← 核心满了,进队列 提交任务4 → 线程数/活跃/队列 = 2/2/1 → 2/2/2 ← 进队列 提交任务5 → 线程数/活跃/队列 = 2/2/2 → 3/3/2 ← 队列满了,创建非核心线程 提交任务6 → 线程数/活跃/队列 = 3/3/2 → 4/4/2 ← 创建非核心线程完全符合上面那张图的顺序:先填核心线程 → 再填队列 → 队列满了才扩容到 max。
四、4 种拒绝策略怎么选
| 策略 | 行为 | 适合场景 |
|---|---|---|
AbortPolicy(默认) | 抛RejectedExecutionException | 不能丢任务,让调用方知道失败了 |
CallerRunsPolicy | 让提交任务的线程自己执行 | 不能丢任务、又不想报错;能接受拖慢调用方 |
DiscardPolicy | 静默丢弃,不报错 | 允许丢任务(如日志上报) |
DiscardOldestPolicy | 丢掉队列里最老的,把新的加进去 | 只关心最新任务(如实时数据) |
4.1 实测对比
我设了core=1, max=1, 队列容量=2,提交 6 个任务:
AbortPolicy(抛异常) 执行=3, 抛出异常=3 CallerRunsPolicy(调用者自己跑) 执行=6, 抛出异常=0 DiscardPolicy(静默丢弃) 执行=3, 抛出异常=0 DiscardOldestPolicy(丢最老的) 执行=3, 抛出异常=0怎么读这个数据:
AbortPolicy:执行 3 个(1 个在跑 + 2 个在队列),剩下3 个抛异常——调用方明确知道被拒了;CallerRunsPolicy:6 个全部执行——被拒的任务由提交它的线程(main)自己跑,所以耗时越长但一个不丢;DiscardPolicy:只执行 3 个,另外 3 个静默消失——危险在"没人知道丢了";DiscardOldestPolicy:也只执行 3 个,但丢的是队列里最老的。
4.2 选型建议
默认用AbortPolicy(至少你知道出问题了)。如果必须保任务不丢、又能接受降级,用CallerRunsPolicy。
⚠️生产环境建议自己实现一个拒绝策略:记录日志 + 告警 + 兜底落盘,别让任务"悄悄消失"。
newRejectedExecutionHandler(){@OverridepublicvoidrejectedExecution(Runnabler,ThreadPoolExecutorexecutor){log.error("线程池已满,任务被拒!队列大小={}",executor.getQueue().size());// 兜底:写入数据库 / 落地到磁盘 / 降级处理thrownewRejectedExecutionException("任务被拒绝");}}五、手写一个迷你线程池(可选,理解原理用)
标准库的ThreadPoolExecutor有 2000 多行,但核心逻辑其实很简单:
一个阻塞队列 + 若干个常驻的工作线程,工作线程循环从队列取任务执行。
5.1 完整实现(约 60 行)
packagedemo;importjava.util.ArrayList;importjava.util.List;importjava.util.concurrent.BlockingQueue;importjava.util.concurrent.LinkedBlockingQueue;importjava.util.concurrent.atomic.AtomicInteger;/** 迷你线程池:一个阻塞队列 + 若干常驻工作线程 */publicclassMiniThreadPool{privatefinalBlockingQueue<Runnable>queue;privatefinalList<Thread>workers=newArrayList<>();privatefinalAtomicIntegerworkerId=newAtomicInteger(1);privatevolatilebooleanrunning=true;publicMiniThreadPool(intcoreSize,intqueueSize){this.queue=newLinkedBlockingQueue<>(queueSize);for(inti=0;i<coreSize;i++){Threadt=newThread(this::work,"mini-worker-"+workerId.getAndIncrement());t.setDaemon(true);workers.add(t);t.start();}}/** 工作线程死循环:不断从队列取任务执行 */privatevoidwork(){while(running){try{// take() 会阻塞直到有任务 —— 这是"线程不空转"的关键Runnabletask=queue.take();task.run();}catch(InterruptedExceptione){Thread.currentThread().interrupt();break;}}}/** 提交任务 */publicvoidexecute(Runnabletask){if(!running)thrownewIllegalStateException("线程池已关闭");try{queue.put(task);}catch(InterruptedExceptione){Thread.currentThread().interrupt();}}/** 关闭:唤醒所有阻塞在 take() 上的线程 */publicvoidshutdown(){running=false;workers.forEach(Thread::interrupt);}}5.2 跑一下
MiniThreadPoolpool=newMiniThreadPool(3,10);// 3 个线程,队列容量 10for(inti=1;i<=9;i++){finalintid=i;pool.execute(()->System.out.println("任务"+id+" 由 "+Thread.currentThread().getName()+" 执行"));}Thread.sleep(500);pool.shutdown();实测输出(9 个任务被 3 个 worker 均匀消化):
任务1 由 mini-worker-3 执行 任务2 由 mini-worker-1 执行 任务3 由 mini-worker-2 执行 任务4 由 mini-worker-2 执行 任务5 由 mini-worker-1 执行 任务6 由 mini-worker-3 执行 任务7 由 mini-worker-2 执行 任务8 由 mini-worker-2 执行 任务9 由 mini-worker-2 执行 线程池已关闭5.3 和真实ThreadPoolExecutor的差距
手写版只是"能跑",真实线程池还要处理很多事:
| 能力 | 手写版 | ThreadPoolExecutor |
|---|---|---|
| 核心/最大线程数分层 | ❌ 固定 | ✅ |
| 队列满后扩容 | ❌ | ✅ |
| 拒绝策略 | ❌ 直接阻塞 | ✅ 4 种可配 |
| 空闲线程回收 | ❌ 常驻 | ✅ keepAliveTime |
| 异常处理 | ❌ 会杀掉 worker | ✅ 有兜底 |
| 状态监控 | ❌ | ✅ getActiveCount 等 |
💡所以:理解原理可以手写,生产必须用标准库。手写的价值在于"看懂
take()阻塞循环"这个核心,其余都是工程细节。
六、三个生产环境容易踩的坑
6.1 坑一:无界队列导致 OOM
// ❌ 危险:不传容量 = 无界队列 = 可能 OOMnewThreadPoolExecutor(8,16,60L,TimeUnit.SECONDS,newLinkedBlockingQueue());// ✅ 正确:给容量newThreadPoolExecutor(8,16,60L,TimeUnit.SECONDS,newLinkedBlockingQueue<>(1000));无界队列还有更隐蔽的问题:maximumPoolSize和拒绝策略永远不会生效(因为队列永远不满),你以为设置了 max=16,实际上永远只有 8 个线程在跑,任务全堆在队列里。
6.2 坑二:submit会"吞掉"异常
这是最坑的一个。
pool.submit(()->{thrownewRuntimeException("出错了!");// 异常被静默捕获了!});submit()会把异常封装到Future里,如果你不get(),异常就永远不会被打印——任务失败了你还以为成功了。
// ✅ 正确姿势 1:用 execute(异常会由 UncaughtExceptionHandler 处理)pool.execute(()->{thrownewRuntimeException("出错了");});// ✅ 正确姿势 2:submit 后必须 get()Future<?>f=pool.submit(()->{thrownewRuntimeException("出错了");});try{f.get();// 这里会抛出 ExecutionException}catch(ExecutionExceptione){Throwablereal=e.getCause();// 真正的异常在这里log.error("任务执行失败",real);}6.3 坑三:线程池没有监控,出问题全靠猜
一个生产线程池至少要监控这几个指标:
ThreadPoolExecutorpool=...;// 定期打印/上报log.info("活跃线程={}, 池大小={}, 队列长度={}, 已完成任务={}",pool.getActiveCount(),// 正在干活pool.getPoolSize(),// 当前线程数pool.getQueue().size(),// 堆积任务数 ← 最重要pool.getCompletedTaskCount()// 累计完成);关键指标:队列长度。如果它持续增长,说明任务来得比消费快,要么加线程要么限流。
七、面试常问的 7 个问题
1. 为什么要用线程池?
答:①复用线程,避免频繁创建销毁的开销(实测 1000 个任务:new Thread 189ms vs 线程池 8ms,差 20 多倍);②控制并发数量,防止创建过多线程拖垮系统;③统一管理,提供排队、拒绝、监控能力。
2.ThreadPoolExecutor的 7 个参数是什么?
答:corePoolSize(核心线程数)、maximumPoolSize(最大线程数)、keepAliveTime(空闲存活时间)、unit(时间单位)、workQueue(任务队列)、threadFactory(线程工厂)、handler(拒绝策略)。
3. 提交一个任务后,线程池内部怎么处理?
答:① 核心线程没满 → 创建核心线程执行;② 核心满了 → 任务进队列排队;③ 队列也满了 → 创建非核心线程直到maximumPoolSize;④ 线程也满了 → 执行拒绝策略。
关键点:队列满之前不会扩容到最大线程数。
4. 为什么阿里规约禁止用Executors创建线程池?
答:主要是怕 OOM。newFixedThreadPool/newSingleThreadExecutor用的是无界队列(容量Integer.MAX_VALUE),任务堆积会 OOM;newCachedThreadPool的最大线程数是Integer.MAX_VALUE,线程无限增长也会 OOM。应该自己new ThreadPoolExecutor显式指定每个参数。
5. 核心线程数怎么设置?
答:经验公式——CPU 密集型:核数 + 1;IO 密集型:核数 × 2(或更大)。CPU 密集型超过核数只会增加上下文切换;IO 密集型因为线程多数时间在等 IO,可以多开。实际应先用公式估算,再根据监控(队列长度、拒绝次数)调整。
6. 四种拒绝策略分别是什么?
答:①AbortPolicy(默认)抛RejectedExecutionException;②CallerRunsPolicy让提交任务的线程自己执行;③DiscardPolicy静默丢弃;④DiscardOldestPolicy丢弃队列里最老的任务。生产建议自定义策略(记录日志 + 告警)。
7.submit和execute有什么区别?
答:execute只能提交Runnable,异常会抛出(由UncaughtExceptionHandler处理);submit可以提交Callable并返回Future,但异常被封装在Future里,不调用get()就不会知道——这是最容易埋雷的地方。
八、总结
记住这五条就够了:
- 用哪个—— 生产环境一律
new ThreadPoolExecutor,7 个参数显式指定;别用Executors(有无界队列/无限线程的 OOM 风险)。 - 任务怎么流转—— 核心线程 → 队列 → 扩容到 max → 拒绝策略。队列不满就不会扩容。
- 参数怎么配—— 队列必须有界;核心线程数按 CPU 密集
核数+1、IO 密集核数×2起步,再靠监控调。 - 拒绝策略—— 默认
AbortPolicy,要保任务用CallerRunsPolicy,生产建议自定义(记日志+告警)。 - 两个隐蔽坑——
submit会吞异常(必须get());一定要自建线程工厂给线程起带业务标识的名字,不然出问题查不出来。
一句口诀:
有界队列 + 自建工厂 + 显式参数 + 监控队列长度——这四件事做到,线程池基本不会出事。
如果本文对你有帮助,欢迎点赞 + 收藏 + 关注!如果疑问或补充,欢迎在评论区交流~
关注我,后续分享更多 Java 核心知识点和实战技巧!