Java高并发底层原理(十四)—— 线程池如何复用和调度线程 并发程序需要同时处理多个任务最直接的做法是为每个任务创建一个线程newThread(()-handleRequest()).start();任务数量较少时这种写法没有明显问题。但如果短时间内到来大量任务程序就会不断创建线程。线程创建、启动和销毁都需要成本过多线程还会争夺 CPU 和内存导致上下文切换频繁甚至耗尽系统资源。线程池解决的核心问题是把“任务”和“线程”分离。任务表示需要完成的工作线程表示执行任务的执行者。线程池提前维护一组可复用的工作线程把不断到来的任务交给这些线程执行而不是每个任务都重新创建一个线程。一、为什么不能为每个任务都创建线程一个 Java 线程不仅是一个普通对象它还需要对应的执行栈、程序计数器以及 JVM 和操作系统中的线程管理状态。创建一个线程通常意味着申请线程资源、分配线程栈、启动线程并参与操作系统调度任务结束后还要回收这些资源。如果每个任务都创建新线程会产生三类问题。第一线程创建和销毁本身有成本短任务尤其容易被这部分成本放大。第二每个线程都需要占用内存线程数量过多时仅线程栈就可能消耗大量空间。第三CPU 核心数量有限即使创建了几万个线程同一时刻真正运行的线程也只有少数大量线程会频繁暂停、恢复和切换。线程池不让任务直接驱动线程数量无限增长而是在三类资源之间做协调资源作用工作线程真正执行任务任务队列保存暂时没人执行的任务并发上限限制最多创建多少工作线程所以线程池的目标不是“创建更多线程”而是“用有限线程稳定处理更多任务”。二、任务、线程和 Worker 是什么关系在线程池中任务和线程是两个不同概念。Runnabletask()-handleRequest();任务表示“要做什么”线程表示“由谁来做”。没有线程池时一个线程通常只执行一个任务线程启动执行任务任务结束线程也结束。在线程池中一个工作线程执行完当前任务后不会立刻结束而是继续寻找下一个任务。线程池内部会使用Worker管理工作线程。可以先把Worker理解为线程池内部的工作单元它关联一个真正的 Java 线程并保存这个线程启动后要执行的第一个任务。工作线程启动后先执行firstTask。执行完之后它会继续调用getTask()从任务队列中获取后续任务。只要还能拿到任务这个线程就会继续复用。因此线程池复用的不是任务而是工作线程。同一个Worker Thread可以在生命周期内执行多个不同的Runnable。三、ThreadPoolExecutor 的核心参数Java 线程池的核心实现类是ThreadPoolExecutor。一个常见的创建方式如下ThreadPoolExecutorexecutornewThreadPoolExecutor(2,4,60,TimeUnit.SECONDS,newArrayBlockingQueue(100),newThreadPoolExecutor.AbortPolicy());这些参数分别表示参数含义corePoolSize核心线程数maximumPoolSize最大线程数keepAliveTime非核心线程的最长空闲时间unit空闲时间单位workQueue任务队列handler拒绝策略假设corePoolSize 2maximumPoolSize 4队列容量为100可以理解为正常情况下先创建最多两个核心工作线程核心线程都忙时任务进入队列队列也满时继续创建非核心线程线程总数最多增加到四个如果线程和队列都达到上限就执行拒绝策略。最大线程数不是线程池一开始就会创建的线程数量而是任务过载时允许扩展到的上限。四、新任务到来后如何被调度线程池接收到新任务时不是直接入队也不是立即把线程数扩展到最大值。execute()的核心分流顺序是先核心线程再任务队列再非核心线程最后拒绝策略。假设核心线程数是2最大线程数是4队列容量是2。连续提交七个执行时间较长的任务时会出现这样的结果任务处理方式Task 1创建 Worker 1直接执行Task 2创建 Worker 2直接执行Task 3进入任务队列Task 4进入任务队列Task 5队列已满创建 Worker 3直接执行Task 6队列已满创建 Worker 4直接执行Task 7线程数达到上限队列仍满执行拒绝策略这里有一个容易忽略的细节当队列已满后线程池创建新的非核心工作线程时新线程会直接执行刚刚提交的任务而不是先从队列中取出更早提交的任务。因此即使任务队列本身是先进先出的整个线程池也不保证任务严格按照提交顺序开始执行。在上面的例子中Task 5和Task 6可能比更早入队的Task 3、Task 4先开始执行。线程池保证的是按照既定规则分配线程和保存任务不保证所有任务严格按照提交顺序开始或结束。五、为什么不是先创建到最大线程数如果corePoolSize 2maximumPoolSize 100线程池在任务稍微增加时就立刻创建一百个线程会很快带来大量线程调度和内存开销。因此ThreadPoolExecutor采用的是一种分阶段扩容策略先创建核心线程核心线程都忙时让任务进入队列队列也满时才创建非核心线程线程数达到上限后再执行拒绝策略。这种设计是在两个目标之间取平衡线程太少任务等待时间变长线程太多线程切换和资源消耗变大。任务队列可以吸收短时间的任务高峰避免瞬时流量直接推动线程数暴涨。但队列也不能无限增大。队列过大时任务虽然没有被拒绝却可能等待很久同时占用大量内存。线程池的容量设计本质上就是在线程数、队列长度和拒绝策略之间选择系统过载时的退让方式。六、Worker 如何循环获取任务线程池中的工作线程启动后不只执行创建时分配给它的第一个任务还会在循环中不断从队列获取后续任务。这个过程可以简化为RunnabletaskfirstTask;while(task!null||(taskgetTask())!null){try{task.run();}finally{tasknull;}}firstTask是创建Worker时直接交给它的任务getTask()负责从任务队列中取得后续任务。如果队列中有任务工作线程取出并继续执行如果队列中没有任务工作线程可能阻塞等待也可能在空闲超时后退出。这里可以把 Worker 的生命周期理解成三段先执行首个任务再循环获取队列任务最后在取不到任务且满足退出条件时结束。阶段含义firstTaskWorker 创建时携带的首个任务getTask()从任务队列获取后续任务退出线程池关闭或空闲时间超过回收条件如果任务执行过程中抛出异常当前任务会提前结束工作线程也可能异常退出。线程池会在任务结束后做清理并根据当前线程数、队列状态和线程池状态决定是否需要补充新的工作线程。这里先记住一点线程池复用的是正常存活的工作线程如果工作线程异常退出线程池需要重新维护自己的工作线程集合。七、任务队列如何影响线程池行为任务队列用于保存已经提交、但暂时没有工作线程执行的任务。队列类型会直接改变线程池的扩容节奏。队列特点对线程数的影响ArrayBlockingQueue固定容量数组队列队列满后更容易创建非核心线程LinkedBlockingQueue可指定容量不指定时容量很大任务更容易堆积线程数不容易增长到最大值SynchronousQueue容量为0不保存任务任务必须直接交给线程线程数更容易增长ArrayBlockingQueue适合需要明确限制任务积压数量的场景。LinkedBlockingQueue如果不指定容量效果接近无界队列当核心线程都在忙时新任务会不断进入队列maximumPoolSize往往很难发挥作用。SynchronousQueue不保存任务每个新任务都必须直接交给工作线程如果没有空闲线程接收就尝试创建新线程直到达到最大线程数。因此配置线程池时不能只看核心线程数和最大线程数还要同时考虑队列容量。队列容量较大任务更容易排队线程数不容易增长队列容量较小更容易创建额外线程也更早触发拒绝策略队列容量为0时任务无法排队会优先推动线程数增长。八、核心线程、预启动和空闲回收创建ThreadPoolExecutor时核心线程通常不会立即全部启动。线程池对象已经创建并不代表工作线程已经创建。默认情况下任务到来后线程池才逐步创建核心线程。如果业务对首个任务延迟比较敏感可以提前启动核心线程executor.prestartCoreThread();executor.prestartAllCoreThreads();前者提前启动一个核心线程后者提前启动所有核心线程。这样可以避免第一个任务到来时再承担线程创建成本。线程池也不需要永久保留所有曾经创建过的线程。假设corePoolSize 2maximumPoolSize 10任务高峰期间线程池可能扩展到十个工作线程。高峰结束后如果这些线程全部长期保留就会浪费内存和调度资源。默认情况下超过核心线程数的工作线程在空闲时间达到keepAliveTime后可以退出线程数逐步回落到核心线程数。核心线程通常不会因为空闲而退出如果开启下面的配置核心线程也可以超时回收executor.allowCoreThreadTimeOut(true);此时线程池长期没有任务时工作线程数甚至可以降到0。九、拒绝策略决定系统过载时如何退让当工作线程数已经达到maximumPoolSize并且任务队列也已经满了线程池就无法继续接收新任务。此时必须决定怎样处理新提交的任务这就是拒绝策略。拒绝策略行为适用提醒AbortPolicy抛出RejectedExecutionException默认策略调用方能明确感知失败CallerRunsPolicy由提交任务的线程直接执行任务可以形成反向压力但会拖慢提交线程DiscardPolicy直接丢弃新任务不抛异常只能用于允许丢任务的场景DiscardOldestPolicy丢弃队列中最旧任务再尝试提交当前任务可能导致旧任务长期无法执行AbortPolicy不会悄悄丢弃任务调用方可以记录日志、返回错误、稍后重试或执行降级处理。CallerRunsPolicy不创建新线程而是让提交线程同步执行任务提交线程被占用后提交速度会下降从而把压力传回上游这种效果常称为反向压力。拒绝策略不是异常情况下才考虑的附属配置而是线程池容量设计的一部分。它决定系统过载时是明确失败、拖慢上游、丢弃新任务还是牺牲旧任务。十、线程池大小为什么要看任务类型和下游资源线程池的目的不是创建尽可能多的线程而是把并发程度控制在系统能承受的范围内。CPU 密集型任务的大部分时间都在计算例如数据压缩、图片处理、加密计算、复杂规则计算。CPU 核心数量有限线程数远大于 CPU Core 数量通常不会提高吞吐量反而会增加上下文切换和 Cache 重新加载成本。实际配置可以从接近 CPU Core 数量开始再通过压测调整。I/O 密集型任务的大部分时间都在等待外部操作例如远程接口、数据库、磁盘或网络响应。线程等待 I/O 时通常不会持续占用 CPU因此线程数可以适当高于 CPU Core 数量让部分线程等待时其他线程继续使用 CPU。但 I/O 任务也不能无限增加线程数因为下游资源也有并发上限。数据库连接池可能只有二十个连接外部接口可能有并发限制系统文件描述符、内存和网络连接数也都有上限。如果线程池配置两百个工作线程但数据库连接池只有二十个连接真正能访问数据库的仍然只有二十个线程其余线程只是在等待连接。因此最大线程数应该同时考虑 CPU 能力、任务等待比例、线程内存成本、数据库连接数量、外部服务承载能力以及业务允许的最大并发量。线程池的作用之一就是把进入下游系统的压力限制在系统能够承受的范围内。十一、如何估算任务队列容量队列容量过小会较早触发扩容和拒绝队列容量过大则可能让任务等待很久甚至在结果已经失去业务价值后才开始执行。队列容量不能只根据内存大小设置还要考虑任务最多允许排队多久以及线程池稳定处理速度。假设线程池有十个工作线程每个任务平均执行时间为200ms。一个线程每秒大约处理1 ÷ 0.2 5个任务十个线程每秒大约处理10 × 5 50个任务。如果一个任务最多允许在队列中等待五秒那么理论队列容量可以粗略估算为queue capacity ≈ stable throughput per second × max waiting seconds ≈ 50 × 5 ≈ 250也可以写成queue capacity ≈ worker count × max waiting seconds ÷ average task seconds这个结果只能作为初步估算不能直接当成最终配置。实际任务耗时会波动数据库变慢或外部接口抖动时任务执行时间可能从几百毫秒变成数秒。通常要保留安全余量并通过压测观察任务平均等待时间、最长等待时间、队列峰值长度、拒绝数量、CPU 使用率和下游响应时间。还要区分“最多排队五秒”和“整个请求必须五秒内完成”。如果整个请求最多五秒而任务本身预计执行一秒那么留给排队的时间最多只有四秒还要扣除其他调用时间。如果任务提交速度长期大于线程池处理速度任何有限队列最终都会被填满。此时不能只扩大队列而应该限流、拒绝、降级或者提高整个处理链路的实际能力。十二、线程池如何关闭线程池中的工作线程会持续等待新任务。如果程序不再需要线程池应当主动关闭否则工作线程可能一直存活占用资源并阻止 JVM 正常退出。shutdown()表示平缓关闭不再接收新任务正在执行的任务继续执行队列中的任务也继续执行所有任务完成后工作线程退出线程池终止。shutdown()发出关闭命令后会立即返回不会一直等待所有任务执行完成。executor.shutdown();shutdownNow()会尝试中断正在执行任务的工作线程同时移出并返回队列中尚未开始的任务。ListRunnablenotStartedTasksexecutor.shutdownNow();它只是发出中断请求不保证任务立即停止。任务能否尽快结束取决于任务代码是否正确响应中断。例如阻塞方法可以通过InterruptedException响应中断普通计算循环则需要主动检查中断状态。while(!Thread.currentThread().isInterrupted()){doWork();}awaitTermination()会让调用线程最多等待指定时间它只负责等待不会主动关闭线程池。因此通常先调用shutdown()再调用awaitTermination()等待线程池终止。executor.shutdown();try{if(!executor.awaitTermination(30,TimeUnit.SECONDS)){ListRunnablenotStartedTasksexecutor.shutdownNow();if(!executor.awaitTermination(30,TimeUnit.SECONDS)){System.out.println(thread pool still not terminated);}}}catch(InterruptedExceptione){executor.shutdownNow();Thread.currentThread().interrupt();}即使调用了shutdownNow()如果任务忽略中断线程池仍然可能无法及时终止。十三、线程池调度任务的完整过程把前面的内容合在一起一个任务从提交到执行结束可以按这条主线理解任务先进入execute()的分流逻辑可能被核心 Worker 直接执行可能进入队列等待可能触发非核心 Worker 创建也可能被拒绝Worker 执行完当前任务后再通过getTask()继续从队列获取后续任务当线程池关闭或 Worker 空闲超时且满足回收条件时Worker 才会退出。线程池控制的是任务如何分配给有限数量的线程而不是保证任务中的共享数据自动安全。多个线程池工作线程同时修改共享变量时仍然需要使用synchronized、ReentrantLock、CAS、线程安全容器或其他正确的同步机制。本章总结线程池的因果链条可以从“任务和线程分离”开始理解任务只是待执行的工作Worker 才是可以复用的执行单元。任务提交后线程池先用核心线程承接稳定负载再用任务队列吸收短时峰值当队列也无法承受时才创建非核心线程继续扩展如果线程数和队列都达到上限拒绝策略决定系统如何在过载时退让。Worker 执行完首个任务后会通过getTask()继续从队列中获取后续任务所以线程池节省的是反复创建和销毁线程的成本。空闲回收、预启动和关闭流程都是围绕 Worker 生命周期展开需要降低首个任务延迟时可以预启动需要释放空闲资源时可以超时回收不再使用时必须主动关闭。因此线程池不是简单地“多开几个线程”而是在核心线程数、最大线程数、队列容量、拒绝策略、任务耗时和下游承载能力之间建立边界。它能控制任务调度和线程复用但不会自动消除任务内部的数据竞争只要多个 Worker 同时访问共享状态仍然需要额外的同步机制。