ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java 线程池详解:7 个参数怎么配、4 种拒绝策略怎么选(附手写实现)

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()就不会知道——这是最容易埋雷的地方。


八、总结

记住这五条就够了:

  1. 用哪个—— 生产环境一律new ThreadPoolExecutor,7 个参数显式指定;别用Executors(有无界队列/无限线程的 OOM 风险)。
  2. 任务怎么流转—— 核心线程 → 队列 → 扩容到 max → 拒绝策略。队列不满就不会扩容。
  3. 参数怎么配—— 队列必须有界;核心线程数按 CPU 密集核数+1、IO 密集核数×2起步,再靠监控调。
  4. 拒绝策略—— 默认AbortPolicy,要保任务用CallerRunsPolicy,生产建议自定义(记日志+告警)。
  5. 两个隐蔽坑——submit会吞异常(必须get());一定要自建线程工厂给线程起带业务标识的名字,不然出问题查不出来。

一句口诀:

有界队列 + 自建工厂 + 显式参数 + 监控队列长度——这四件事做到,线程池基本不会出事。


如果本文对你有帮助,欢迎点赞 + 收藏 + 关注!如果疑问或补充,欢迎在评论区交流~

关注我,后续分享更多 Java 核心知识点和实战技巧!

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进