
线程池的价值不是“让所有任务同时运行”而是为任务执行设置边界最多有多少线程、等待区能放多少任务、过载时由谁承担后果。如果只记住corePoolSize和maximumPoolSize两个数字却不知道队列在中间如何工作配置很容易与预期完全不同。本文以 JDK 17/21 的ThreadPoolExecutor为主线解释完整构造函数的七个参数、任务提交的真实顺序、四种内置拒绝策略以及上线前应该验证的容量和关闭行为。示例代码使用标准 Java并适用于普通 JVM 服务与 Android 应用内的 Java 线程池。线程池只管理当前进程中的任务。Android 中需要跨进程死亡、满足系统调度条件的持久后台工作应使用 WorkManager 等机制线程池本身不会把未完成任务保存到磁盘。一、先看完整构造函数七个参数各管什么ThreadPoolExecutorpoolnewThreadPoolExecutor(corePoolSize,maximumPoolSize,keepAliveTime,unit,workQueue,threadFactory,handler);参数类型含义最容易误解的地方corePoolSizeint核心工作线程数的目标下限核心线程默认按需创建并非构造时一次建好maximumPoolSizeint同时存在的工作线程上限通常只有队列无法接收新任务时才发挥作用keepAliveTimelong超过核心数量的空闲线程等待新任务的最长时间计时针对空闲等待不是单个任务的超时unitTimeUnitkeepAliveTime的时间单位30配SECONDS与配MILLISECONDS完全不同workQueueBlockingQueueRunnable暂时存放待执行任务队列容量直接决定是否扩容线程、何时拒绝threadFactoryThreadFactory创建实际执行任务的线程线程名、daemon、优先级和异常诊断都与它有关handlerRejectedExecutionHandler无法接收任务时的处理策略拒绝不只发生在“满了”关闭后再提交也会拒绝七个参数共同定义了线程池的容量与背压行为不能孤立地看某一个。构造时需满足corePoolSize 0、maximumPoolSize 0、maximumPoolSize corePoolSize、keepAliveTime 0并提供非空的单位、队列、工厂和拒绝处理器。从一个配置开始ThreadPoolExecutorpoolnewThreadPoolExecutor(2,// 常驻目标2 个核心线程4,// 总线程上限4 个30L,// 非核心线程空闲 30 秒可回收TimeUnit.SECONDS,newArrayBlockingQueue(100),// 最多 100 个等待任务Executors.defaultThreadFactory(),newThreadPoolExecutor.AbortPolicy());假设所有任务都会一直阻塞提交和执行不会同时释放容量那么前两个任务触发核心线程创建后续最多 100 个任务排队之后再创建两个非核心线程再来的任务被拒绝。现实中工作线程会同时消费队列因此“第 103 个任务一定怎样”不是通用承诺这个例子只是帮助建立容量模型。二、execute()的决策顺序先核心线程再队列再最大线程ThreadPoolExecutor.execute()的主路径可以简化为是否是否是否execute(task)工作线程数 corePoolSize?尝试新建核心工作线程执行任务线程池仍运行且队列可接收?任务进入 workQueue工作线程数 maximumPoolSize?尝试新建非核心工作线程执行任务调用拒绝策略 handler入队后重新检查线程池状态与工作线程数量源码中的并发检查比这张图多入队后会重新检查运行状态如果线程池在入队期间关闭可能把任务从队列移除并拒绝如果队列已有任务但没有工作线程会补建工作线程防止任务一直留在队列。主线仍然是核心线程有名额 → 创建线程执行 否则队列有空间 → 任务排队 否则最大线程还有名额 → 创建更多线程执行 否则 → 拒绝这解释了最常见的困惑把maximumPoolSize设得很大但线程数始终只到corePoolSize。如果队列几乎总能接收任务执行器优先入队不会为了减少排队而自动扩线程。corePoolSize 0会不会没人消费队列不会简单地把任务永久丢在队列里。任务入队后ThreadPoolExecutor会检查当前是否有工作线程必要时会启动一个线程处理队列。这个并发兜底逻辑也说明不能把上面的简化流程图当作逐行源码。三、逐个拆解七个参数3.1corePoolSize维持的并发基线当工作线程数小于corePoolSize时新任务通常触发创建线程即使队列里还有空间。核心线程默认不会因空闲而被回收但“默认不会回收”不等于构造时已经存在它们一般是任务到来时才逐步创建。若启动阶段就要准备好线程可以调用prestartCoreThread()或prestartAllCoreThreads()。如果启用allowCoreThreadTimeOut(true)核心线程也能在空闲超过keepAliveTime后退出此时keepAliveTime必须大于零。核心线程数不必等于 CPU 核数。任务如果经常等待网络、磁盘或锁合适的并发数可能更高如果是纯计算任务盲目开很多线程只会增加上下文切换。测量吞吐量和延迟比套公式更可靠。3.2maximumPoolSize队列满后的扩容上限maximumPoolSize限制的是线程数量而不是任务总数。对有界队列而言过载时线程池最多同时拥有maximumPoolSize个工作线程另外还可能有workQueue中的等待任务。使用默认近乎无界的LinkedBlockingQueue时入队通常总是成功线程池很难走到“队列满 → 新建非核心线程”的路径。因此这类配置中maximumPoolSize虽然写了实际效果通常接近corePoolSize。可以运行时调用setCorePoolSize()、setMaximumPoolSize()调整但必须保持maximum core而且修改数字不会自动解决队列积压或下游处理能力不足的问题。3.3keepAliveTime与unit空闲线程的保留时间这两个参数需要一起读30L, TimeUnit.SECONDS表示非核心工作线程空闲等待新任务最多约 30 秒不是任务运行 30 秒后被强制终止。通常只有工作线程数量超过corePoolSize时空闲线程才按这个时间回收。启用核心线程超时后核心线程也按这个规则处理。线程回收与重新创建都有成本持续突发的负载若设置得过短可能出现频繁建线程设置得过长则会多占用线程资源。任务自身的超时必须在业务层处理例如网络客户端超时、Future.get(timeout, unit)的等待超时以及取消逻辑。Future.get()超时只结束调用方的等待是否取消任务还需显式调用cancel(true)并确保任务响应中断。3.4workQueue等待区决定线程池的形态队列容量与行为适合什么注意点ArrayBlockingQueue(n)固定容量、数组实现想明确控制最大等待量容量不足会更早扩线程或拒绝LinkedBlockingQueue(n)显式有界容量需要较灵活的有界等待区不传容量时上限近似无界积压可耗尽内存SynchronousQueue()不存储元素只直接移交给空闲消费者希望优先扩线程、几乎不排队若最大线程数放得过大线程数会随突发流量激增PriorityBlockingQueue()按优先级取任务默认无界有明确优先级语义高优先级任务可能长期压住低优先级任务submit()包装任务后还要考虑可比较性ScheduledThreadPoolExecutor使用自己的延时队列处理定时任务不能套用普通有界ArrayBlockingQueue的容量推断。选队列时应先回答两个问题最多允许任务等多久满了以后谁承担压力一个显式有界队列让这两个问题可见无界队列只是把过载从“立即拒绝”转成“不断排队并增加延迟与内存占用”。3.5threadFactory把线程变成可诊断的资源默认工厂能创建线程但生产环境最好给线程命名AtomicIntegersequencenewAtomicInteger();ThreadFactorydefaultFactoryExecutors.defaultThreadFactory();ThreadFactorynamedFactorytask-{ThreadthreaddefaultFactory.newThread(task);thread.setName(file-io-sequence.incrementAndGet());returnthread;};线程名会出现在日志、线程转储和性能分析器中能快速区分数据库、网络、图片解码等工作。也可以在工厂里设置 daemon、优先级或UncaughtExceptionHandler但要理解这些设置的生命周期后果daemon 线程不会阻止 JVM 退出未捕获异常处理器也不能代替对Future失败结果的检查。3.6handler过载与关闭后的最后一道边界提交时线程池已经关闭或工作线程达到上限且队列无法接收都会触发拒绝处理器。四种内置策略如下策略行为主要风险或用途AbortPolicy抛出RejectedExecutionException默认策略让过载显式暴露调用方必须处理CallerRunsPolicy在线程池仍运行时由提交任务的线程直接执行形成背压但可能阻塞主线程、请求线程或持锁线程关闭后提交则丢弃DiscardPolicy静默丢弃任务调用方难以知道任务未执行通常只适合明确允许丢失的场景DiscardOldestPolicy丢弃队列头部任务再尝试提交新任务可能丢掉重要任务在优先级队列中“头部”也不等于最早提交可以自定义RejectedExecutionHandler记录指标、返回业务错误或将任务交给有界的外部系统但不要在里面无限等待、递归重试或悄悄创建另一个无界队列。拒绝代表系统当前无法承诺按原路径完成任务应由业务明确决定降级、重试或失败。四、三种配置放在一起差异会很直观假设core2、max8任务平均执行 200 毫秒新的任务持续到来配置新任务的主要去向可能看到的现象LinkedBlockingQueue()核心线程忙后不断排队活跃线程常在 2 附近队列持续增长等待时间变长ArrayBlockingQueue(100)先排队满后把线程扩到 8再拒绝等待量和线程数都有显式上限SynchronousQueue()不能排队直接移交或扩线程很快升到 8再无空闲线程就拒绝以上是机制推断不是固定性能排名。有界队列大小、线程数、任务耗时分布和下游容量要一起测。线程池的“最大并发”能保护本进程却不自动保护数据库连接数、远端服务限流或磁盘吞吐。一个可计算的粗估起点若平均每秒到达50个任务每个任务从提交到完成平均耗时0.2秒按照 Little 定律系统中的平均在途任务数约为50 × 0.2 10。这里的 10 包含正在执行和排队等待的任务不等于“线程数必须设 10”突发峰值、耗时长尾和下游限流会改变实际需求。只有把0.2秒明确为任务的纯执行时间时乘积才可用来粗估平均同时执行量。容量评估还应观察 P95/P99 等待时长、拒绝数、队列长度、CPU/内存使用率以及下游服务是否因并发增加而变慢。五、execute()与submit()异常去哪里了pool.execute(()-doWork());FutureResultfuturepool.submit(()-computeResult());Resultresultfuture.get();execute()接收Runnable没有结果对象。任务中未捕获的异常通常从工作线程冒出线程池会处理工作线程退出并按需要补线程。submit()返回Future任务异常会存进Future调用get()时以ExecutionException的原因抛出。因此“没在日志看到异常”不代表任务成功。try{Resultresultfuture.get(2,TimeUnit.SECONDS);use(result);}catch(TimeoutExceptione){future.cancel(true);// 发出中断请求任务需配合才能及时结束}catch(ExecutionExceptione){logFailure(e.getCause());}示例省略了InterruptedException的处理真实调用方应恢复中断标记或向上抛出不要吞掉。也不要在同一个只有一个工作线程的线程池里让父任务提交子任务后立刻get()父任务占着唯一线程等子任务而子任务在队列里等线程会形成线程池饥饿死锁。重写afterExecute()做监控时也有一个细节通过submit()提交的任务会被FutureTask包装异常通常保存在Future里afterExecute(Runnable, Throwable)收到的Throwable可能是null。若要统一记录失败需要检查任务是否为Future并注意不要在监控钩子里长时间阻塞工作线程。六、如何写出一份有边界的配置下面是一份适合“可短暂排队的文件处理任务”的示例配置数字只是演示importjava.util.concurrent.ArrayBlockingQueue;importjava.util.concurrent.Executors;importjava.util.concurrent.ThreadFactory;importjava.util.concurrent.ThreadPoolExecutor;importjava.util.concurrent.TimeUnit;importjava.util.concurrent.atomic.AtomicInteger;publicfinalclassFileTaskPool{privateFileTaskPool(){}publicstaticThreadPoolExecutorcreate(){AtomicIntegersequencenewAtomicInteger();ThreadFactoryfallbackExecutors.defaultThreadFactory();ThreadFactorynamedtask-{Threadthreadfallback.newThread(task);thread.setName(file-task-sequence.incrementAndGet());returnthread;};returnnewThreadPoolExecutor(2,4,30,TimeUnit.SECONDS,newArrayBlockingQueue(100),named,newThreadPoolExecutor.AbortPolicy());}}这份配置明确了三个边界最多 4 个运行线程最多 100 个排队任务继续提交时显式报错。业务代码必须捕获RejectedExecutionException决定向用户提示、稍后重试或丢弃允许丢失的工作。完整关闭流程pool.shutdown();// 不再接收新任务已提交任务继续执行try{if(!pool.awaitTermination(30,TimeUnit.SECONDS)){pool.shutdownNow();// 中断运行任务返回尚未开始的队列任务}}catch(InterruptedExceptione){pool.shutdownNow();Thread.currentThread().interrupt();}shutdownNow()是尽力而为它向工作线程发送中断并把未开始的任务从队列取出返回不响应中断的任务未必会立刻停止。应用退出、服务关闭或作用域结束时应由明确的拥有者触发关闭避免线程与队列长期保留对象。七、为什么Executors的快捷工厂要先看清默认值Executors的工厂方法可以快速得到执行器但隐藏了重要容量工厂方法关键默认行为需要留意newFixedThreadPool(n)固定线程数使用近乎无界的LinkedBlockingQueue任务长期到达速度高于处理速度时队列和等待时间增长newSingleThreadExecutor()单工作线程内部队列近乎无界串行顺序明确但慢任务会堵住后续任务newCachedThreadPool()SynchronousQueue可按需创建大量线程大突发或长期阻塞任务可带来过多线程newScheduledThreadPool(n)用延时队列调度任务周期任务的延迟、异常和积压需要单独监控这些方法并非不能用。若业务天然有明确的小规模上限它们可能足够若要在过载时维持可预测行为直接构造ThreadPoolExecutor并写出队列容量与拒绝策略更容易审查。CompletableFuture.supplyAsync(...)没有传入执行器时通常使用ForkJoinPool.commonPool()对于会阻塞的网络/数据库工作应明确决定是否使用专门执行器避免与其他任务共用默认池。Java 21 的Executors.newVirtualThreadPerTaskExecutor()是另一种模型每个任务可使用虚拟线程它本身不是“带有七个参数的有界线程池”仍需对数据库连接、远端接口等有限资源设置并发边界。八、上线前检查这六件事任务分类CPU 计算、磁盘/网络阻塞、短任务和长任务是否需要隔离一个慢队列不应拖住所有工作。队列容量积压 100 个任务意味着最长可能等待多久内存是否承受得住每个任务引用的对象拒绝路径调用方是否知道任务被拒绝CallerRunsPolicy若跑在 UI 或请求线程会有什么后果超时与取消任务自身是否有网络、锁和等待超时它是否响应中断可观测性是否记录活跃线程数、队列长度、拒绝数、等待时间与执行时间getActiveCount()等状态只是瞬时近似值。生命周期由谁关闭线程池任务里的ThreadLocal是否在finally中remove()避免复用线程把旧请求数据带到新任务最后一点在 Android 中还意味着不要把 Activity、Fragment 或 View 长时间捕获进排队任务。线程池与队列可能比页面活得更久匿名Runnable对页面的强引用就可能造成泄漏。九、把线程池理解成一套“容量合同”corePoolSize / maximumPoolSize → 同时执行的线程边界 workQueue → 可容忍的等待量 keepAliveTime unit → 突发扩容后的回收节奏 threadFactory → 线程的身份与诊断方式 handler → 容量耗尽后的业务结果线程池参数没有一套适合所有业务的“最佳数字”。先用上面的任务流转顺序解释当前配置再用真实任务的到达速度、耗时分布和下游容量做压测最后让过载行为可观察、可处理。这样线程池才真正把并发风险控制在系统能承受的范围内。参考资料Java 17 APIThreadPoolExecutorJava 17 APIBlockingQueueJava 17 APIExecutorsJava 21 APIExecutors 与虚拟线程执行器