ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Java线程池从入门到实战:核心参数、执行流程与避坑指南

Java线程池从入门到实战:核心参数、执行流程与避坑指南 1. Java线程池到底解决了什么问题先算算手动new Thread的账大家最开始写Java并发代码八成都是这个路子来一个请求就new Thread(() - doSomething()).start()。本地跑着没问题功能也正常等上了生产环境用户一多服务就开始不对劲了——CPU飙高、内存报警、接口超时严重的直接OOM。这不是段子是我早年真实踩过的坑。后来才明白手动创建线程的问题根本不是代码能不能跑而是成本被完全忽略了。每一个线程背后都是一套完整的操作系统资源线程栈默认要占1MB左右的内存空间创建和销毁需要触发系统调用频繁切换线程还会带来上下文切换的开销。假设你的接口QPS是2000每个请求都新建线程处理线程对象本身要等GC回收JVM里瞬时可能堆积数百上千个线程。我见过最夸张的一次生产环境线程数到了3000多Full GC一天十几次服务基本处于半瘫痪状态。线程池Thread Pool就是来治这个病的。它的核心思路是先把一批线程创建好放在池子里任务来了直接复用线程执行执行完线程不销毁继续等下一个任务。这样线程的创建成本被分摊到多次任务执行中同时通过限制池子里线程的数量把并发度控制在一个系统能承受的范围里。用个生活化的类比餐厅高峰期的翻台压力大如果每来一桌客人就临时招一个服务员客人走了就辞退那光招人、培训的成本就够餐厅破产。聪明的做法是固定养一批服务员高峰期排队叫号实在忙不过来了再临时加人忙完再让人休息。Java线程池的corePoolSize就是固定服务员数量workQueue就是排队叫号的候餐区maximumPoolSize是能临时扩容的上限handler则是餐厅真的坐不下了的时候怎么办的策略。对于Java开发者来说线程池不只是面试八股文里的常客更是生产环境并发编程的基石。JDK在java.util.concurrent包下提供了成熟的线程池框架核心类是ThreadPoolExecutor它把线程的创建、调度、回收、队列管理全部封装好了。这一篇我会把线程池的知识点完整梳理一遍从核心参数到执行流程从内置线程池的坑到阻塞队列的选型再到实战配置和面试问答一次性讲透。2. ThreadPoolExecutor的七大参数每个参数都在回答一个具体问题线程池的核心类ThreadPoolExecutor构造方法有多个重载版本最完整的那个有七个参数。很多开发者初学线程池这七个参数背得滚瓜烂熟但真到要自己配置的时候就傻眼了。原因很简单只知道参数名不知道每个参数背后在回答什么问题。2.1 corePoolSize稳定在编员工数corePoolSize是核心线程数也就是池子里始终保留的线程数量。默认情况下即使这些线程没有任务可做它们也不会被销毁除非设置了allowCoreThreadTimeOut(true)。这个值怎么定它是线程池性能调优的第一个关键决策点。如果设置得太小高并发时任务会在队列里积压处理不过来设置得太大空闲线程会白白占用内存和CPU调度资源。后面我专门用一节讲它的估算方法这里先记住一个概念核心线程数代表系统正常负载下期望维持的并发处理能力。2.2 maximumPoolSize忙不过来的临时扩编上限maximumPoolSize是线程池允许的最大线程数。当核心线程全部忙碌且工作队列已经满了线程池没办法再让新任务排队了此时会创建新线程来执行新任务。但新线程数量达到maximumPoolSize之后就不会再创建了再多的任务就只能走拒绝策略。注意一个非常容易误解的点最大线程数不是平时都能用的线程数而是在队列满了之后的兜底扩容上限。很多初级开发者把corePoolSize和maximumPoolSize当成最小并发数和最大并发数来理解这个理解在宏观上没错但忽略了中间的触发条件——不是任务一多就立刻扩容而是队列满了才扩容。这个触发顺序直接决定了任务的执行延迟和系统负载后面讲执行流程时会详细展开。2.3 keepAliveTime与TimeUnit临时工的闲置淘汰时间keepAliveTime配合TimeUnit使用控制的是非核心线程的空闲存活时间。当一个非核心线程空闲了这么久就会被回收销毁。有个细节值得注意JDK 1.6之后如果调用allowCoreThreadCoreTimeout(true)这个超时机制也会作用于核心线程让核心线程在空闲一定时间后被回收。这样在低峰期可以进一步释放系统资源。实际配置中keepAliveTime不宜设得太短。比如CachedThreadPool把超时时间设为60秒意味着一个临时线程空闲超过60秒就会被回收。如果设成1秒高并发场景下线程频繁创建销毁反而造成资源抖动。2.4 workQueue任务排队等候区workQueue是线程池的阻塞队列用来缓存等待执行的任务。这里的关键认知是队列不仅缓冲了任务压力还决定了线程池的扩容时机。如果用的是有界队列比如ArrayBlockingQueue队列满了线程池才会扩容到maximumPoolSize。如果用的是无界队列比如LinkedBlockingQueue默认容量是Integer.MAX_VALUE队列永远都不会满那么maximumPoolSize这个参数就形同虚设了因为永远没有扩容的触发条件。这直接引出了内置线程池的一个大坑FixedThreadPool和SingleThreadExecutor用的都是无界队列所以它们的maximumPoolSize参数实际上不起任何作用而且无界队列可能导致任务无限积压最终内存耗尽。第五部分我会专门详细讲阻塞队列的选型。2.5 threadFactory线程的出身设定threadFactory是线程工厂用来创建线程池里的线程。不传的话JDK会使用默认的Executors.defaultThreadFactory()。这个参数平时最容易被忽略但在生产环境监控排障时却极其重要。默认线程工厂创建的线程名字是pool-1-thread-1这种格式如果你有多个线程池线上出问题后jstack打印出来的线程栈根本无法区分线程属于哪个业务线程池。经验做法是自定义ThreadFactory给线程起一个有业务含义的名字并设置好优先级ThreadFactory customThreadFactory new ThreadFactory() { private final AtomicInteger count new AtomicInteger(1); Override public Thread newThread(Runnable r) { Thread t new Thread(r, order-query-thread- count.getAndIncrement()); t.setDaemon(false); t.setPriority(Thread.NORM_PRIORITY); return t; } };如果你用Spring框架ThreadPoolTaskExecutor还提供了setThreadNamePrefix方法更简单。给线程起好名字是线程池实践中成本最低、收益最高的小技巧没有之一。2.6 RejectedExecutionHandler队伍满员后的处理方式当核心线程、工作队列、最大线程全都满了新提交的任务会交给RejectedExecutionHandler来处理。JDK内置了四种拒绝策略后文会细讲。这里只强调一点拒绝策略是线程池最后的防线它不能根本解决压力问题但可以决定系统撑不住时以什么方式失败。是抛异常让调用方感知还是静默丢弃还是让提交任务的线程自己执行这个选择要结合业务场景来定。2.7 参数速览表参数作用默认行为常见误解corePoolSize常驻线程数量不传默认0有任务才创建以为线程池启动时就全建好maximumPoolSize最大线程数量等于corePoolSize以为任务多了就立刻扩容keepAliveTime非核心线程空闲超时默认0表示不回收忽略它对资源释放的作用workQueue任务缓存队列不传则用内置队列低估队列对扩容时机的影响threadFactory线程创建工厂默认工厂线程名无业务含义不重视线程命名handler拒绝策略默认抛RejectedExecutionException不结合业务场景选择这几个参数的组合行为就是线程池的全部秘密。理解了每个参数背后的问题再看执行流程和内置线程池的源码就会通透很多。3. 任务从提交到执行核心线程、队列、最大线程、拒绝策略的完整流转线程池的参数不是孤立存在的它们的配合关系决定了任务的生命周期。我先把完整的执行流程写出来这是面试手撕源码时的标准答案也是实际调优时判断性能瓶颈的地图。3.1 任务提交后的四步判断当你调用executor.execute(task)时ThreadPoolExecutor内部执行的是极其精密的四步判断逻辑第一步判断核心线程是否还有空闲如果当前工作线程数小于corePoolSize线程池会创建一个新线程来执行这个任务即使已经有核心线程处于空闲状态也不让新任务去排队。这一点很多人有误解以为核心线程有空闲新任务直接复用不就行了——但JDK的设计是在核心线程数没达到corePoolSize之前每来一个任务就新开一个线程目的是让线程池尽快达到设定的核心并发能力。第二步尝试把任务放入工作队列如果当前线程数已经大于等于corePoolSize线程池会把任务交给workQueue.offer(task)由阻塞队列暂存。队列操作成功任务就排队等待核心线程执行完成后来取。第三步队列满了尝试扩容如果队列已经满了offer失败线程池会判断当前线程数是否小于maximumPoolSize。如果是创建新线程直接执行这个刚提交的任务注意不是去执行队列里排队的旧任务这是按任务提交顺序的自然逻辑新线程会继续从队列头部取任务执行。第四步扩容也到上限了执行拒绝策略如果线程数已经到了maximumPoolSize新任务会被交给RejectedExecutionHandler处理。把四步判断抽象一下就是面试题里最常问的那句话先核心线程再工作队列再最大线程最后拒绝策略。这个顺序就是线程池行为特性的核心所在。3.2 触发扩容的条件与常见误解我经常会问身边同事一个问题核心线程数4最大线程数10队列容量100现在一下来了50个任务线程池会怎么处理不少人的答案是创建10个线程50个任务分成几批执行。但正确答案是前4个任务创建4个核心线程执行后面46个任务全部放进队列排队。线程池不会因为任务多就立刻扩到10个线程因为队列还没满offer是成功的。只有任务量超过了104个4个核心线程都忙 队列100个排满第105个任务到来时才会触发扩容创建第5个线程。如果后续任务持续到来线程数会一路涨到10第11个以上新任务才走拒绝策略。这个队列未满不扩容的机制对系统负载有重要意义短时间突发的任务会被队列吸收掉系统不会因为瞬时的流量尖峰就疯狂创建线程这实际上是一层缓冲保护。3.3 四种拒绝策略各自的适用场景JDK在ThreadPoolExecutor内部类中定义了四种拒绝策略AbortPolicy默认直接抛出RejectedExecutionException异常。这是最诚实的策略——明确告诉调用方线程池已经扛不住了你的任务我没有接收。适合那些任务丢失会造成严重后果的业务宁可报错也不能静默丢掉。CallerRunsPolicy不抛异常而是让提交任务的线程自己执行这个任务。比如主线程调用execute提交任务被拒绝后这个任务就在主线程里run()。这种策略的暗含逻辑是谁提交谁执行用提交方的线程来消化压力同时天然形成了一种背压机制——如果主线程被任务拖慢提交新任务的速度自然就降下来了。适合低吞吐、对延迟不敏感但对任务完成率有要求的场景。DiscardPolicy静默丢弃任务不抛异常。这个策略我实际项目中基本不用因为任务消失对业务是不可接受的排查问题也极难发现。DiscardOldestPolicy丢弃队列里最老的任务然后重新尝试提交当前任务。这个策略适合允许丢弃一些旧任务、优先处理新任务的场景比如实时性要求高的数据推送服务旧数据晚推送还不如不推送。3.4 自己改写拒绝策略的实操内置的四种策略常常不够用。我有一个实际项目要求线程池满载时新任务不丢弃也不抛异常而是降级走一条备用的消息队列通道让下游慢慢消化。这种情况我就自己实现RejectedExecutionHandlerpublic class MqFallbackPolicy implements RejectedExecutionHandler { Override public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) { if (r instanceof TaskWrapper) { TaskWrapper task (TaskWrapper) r; mqProducer.send(task.getPayload()); // 降级发消息 } else { throw new RejectedExecutionException(Unknown task type); } } }写自定义策略要注意两点一是拿到executor参数后不要尝试再次调用execute()否则可能造成递归二是任务类型最好做一层包装让拒绝策略能识别任务并提取必要的上下文信息。执行流程这一整块内容几乎覆盖了线程池面试八股文的60%也是线上排查问题的主线索。比如你发现任务大量进入拒绝策略按这个流程反射一遍就能定位是核心线程数配得小了还是队列配置不合理还是瞬时流量真的超出了整个系统的承载上限各有各的解法。4. 内置四种线程池的翻车现场快捷方式背后的真实代价JDK的Executors工具类提供了一堆现成的线程池静态方法用起来确实方便一行代码就能拿到一个能跑的线程池。但麻烦也藏在这里——内置线程池封装好了参数同时也封装了风险。阿里《Java开发手册》里明确禁止使用Executors创建线程池更推荐直接使用ThreadPoolExecutor原因就在它们默认的队列策略上。4.1 FixedThreadPool看起来稳实则无界队列隐患newFixedThreadPool(int nThreads)创建的线程池核心线程数等于最大线程数也就是说线程数量是固定的不会扩容。问题出在它的工作队列上源码是new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueueRunnable());用的是LinkedBlockingQueue没指定容量——默认容量是Integer.MAX_VALUE这意味着队列实际上是无界的。这个设计初看很合理线程数固定任务多就排队慢慢执行。但在高并发场景下如果任务产生速度长期大于消费速度队列里的任务会一路堆积。每个Runnable对象都要占内存堆积到百万级别时就会内存溢出OOM。最要命的是这种OOM不是突然发生的而是服务越来越慢GC时间越来越长最终在某一次高峰彻底崩溃。4.2 CachedThreadPool弹性最大风险也最大newCachedThreadPool()的参数配置new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS, new SynchronousQueueRunnable());核心线程数为0最大线程数为Integer.MAX_VALUESynchronousQueue不存储任何任务——每个任务提交后必须立即有一个线程接收处理否则就创建新线程。空闲线程超过60秒被回收。这个来多少任务就开多少线程的设计优点是响应极快适合大量短时异步任务的场景。但风险也极其明显如果提交任务的速度超过处理速度线程数会无限增长直接导致线程资源耗尽、CPU上下文切换开销失控最终系统被压垮。我见过某个定时任务框架里误用了CachedThreadPool处理消息推送高峰期瞬间创建了几千个线程把一台8核16G的机器直接打到卡死。4.3 SingleThreadExecutor单线程的串行承诺也可能是坑newSingleThreadExecutor()按名字理解就是池子里只有一个线程任务一个个串行执行保证顺序性。它的实现也是无界队列。单核线程池最容易被忽略的问题有两个。一是在某些JDK版本中它是用FinalizableDelegatedExecutorService包装的用来屏蔽掉ThreadPoolExecutor的setCorePoolSize等方法确保使用者无法改成多线程。二是有界队列的不在场仍然会造成OOM风险任务积压场景下和FixedThreadPool一样危险。4.4 ScheduledThreadPool延迟任务也要警惕队列积压newScheduledThreadPool(int corePoolSize)是定时任务线程池核心线程数可指定用于执行延迟任务和周期任务。它的工作队列是DelayedWorkQueue里面按任务的执行时间排序到点了才会被取出执行。DelayedWorkQueue的问题在于它本质上也是无界的如果不断有延迟时间很长的任务被提交同样会堆积。另外要注意调度线程池的maximumPoolSize默认是Integer.MAX_VALUE虽然DelayedWorkQueue理论上不会触发扩容但一切都要看实际使用的重载方法。4.5 内置线程池速查对比线程池核心线程最大线程队列空闲回收主要风险FixedThreadPool指定值等于核心线程LinkedBlockingQueue无界不回收队列堆积OOMCachedThreadPool0Integer.MAX_VALUESynchronousQueue60秒线程数无限增长SingleThreadExecutor11LinkedBlockingQueue无界不回收队列堆积OOMScheduledThreadPool指定值Integer.MAX_VALUEDelayedWorkQueue按配置延迟任务堆积看到这里应该明白了问题不在Executors本身而在于默认参数不一定适合你的场景。最稳妥的做法是直接new ThreadPoolExecutor(...)并显式指定有界队列每个参数根据自己的业务特性和流量模型来决定。5. 阻塞队列的选择队列的形状决定了线程池的性格工作队列是线程池最容易被低估的组件。我做了几年并发编程后才深刻体会到线程池的参数决定了它有多少人力队列则决定了任务如何排队、会排多久、会不会把内存耗尽。不同类型的阻塞队列直接塑造了线程池的行为特征。5.1 有界队列 vs 无界队列怎么选队列选型的第一原则就是明确自己能不能承受任务积压。无界队列如默认容量的LinkedBlockingQueue的优点是任务不会被拒绝提交方不用处理RejectedExecutionException。但代价是任务全部堆在内存里一旦积压速度大于处理速度系统内存就是唯一的上限。线上生产环境我几乎不会用无界队列原因很简单宁可让服务及时报错也不要让它拖着内存隐患慢慢走向OOM。有界队列如ArrayBlockingQueue则把积压量限制在一个可控范围内队列满了之后触发扩容和拒绝策略形成一道明确的背压信号。缺点是如果队列容量设置不合理可能出现频繁扩容和拒绝但这是可以通过配置调优来解决的不是结构性问题。5.2 五种常用队列逐个拆ArrayBlockingQueue基于数组实现的有界队列创建时必须指定容量。容量固定后队列底层是一个环形数组入队和出队操作共享同一把锁。它的特点是一旦创建容量不可变适合对任务积压量有严格上限的场景。我通常用它作为默认队列比如订单处理线程池队列容量设200最多积压200个任务再多就触发扩容或者拒绝策略整个流程是可控的。LinkedBlockingQueue基于链表实现的队列可以指定容量也可以不指定不指定就是无界。内置的FixedThreadPool使用的就是无界版本。有界的LinkedBlockingQueue和ArrayBlockingQueue在功能上大致相当但内部实现不同LinkedBlockingQueue的入队和出队用了两把锁理论上并发吞吐更高。不过它默认的Integer.MAX_VALUE容量是个隐形炸弹创建时要格外注意显式指定容量。SynchronousQueue这个队列很特殊它的内部容量为0不存储任何任务。每个入队操作必须等待一个对应的出队操作否则就会阻塞。线程池用SynchronousQueue时提交任务必须有一个线程立即来取否则就创建新线程。配合CachedThreadPool的无限容量形成了任务一到必须立刻有人处理的效果。它适合任务处理时间极短、且不能容忍排队等待的场景。代价就是线程数量完全跟着任务节奏走没有缓冲。PriorityBlockingQueue支持按优先级排序的无界队列任务可以是Runnable的实现类通过实现Comparable接口或者在构造时传入Comparator来定义优先级。它适合有优先级属性的任务场景比如后台任务里紧急的系统监控任务需要优先于普通的数据清理任务执行。注意两点一是无界队列全部用同一个默认容量陷阱二是优先级排序是全局的如果低优先级任务长期占据队列高优先级任务可能一直被跳过这是优先级反转的一种形式需要监控任务的海量程度。DelayedWorkQueueScheduledThreadPoolExecutor专用的延迟队列只在延迟时间到达后才把任务交给线程执行。内部实现是堆结构任务的getDelay时间越小越先被取出。5.3 队列和线程池参数的配合节奏队列选型不是独立的它要和corePoolSize、maximumPoolSize配合起来看。举个实际案例。我做过一个文件解析服务每天定时有大批解析任务涌入任务时长大约2秒左右。我最初配置的是核心线程8、最大线程16、队列容量1024结果高峰期任务排队时间长达数十分钟。后来分析了一下场景特征任务数量大但单个耗时不长突发性强但持续时间有限。这个场景的合理配置应该是让队列只承担极短时间的缓冲快速扩容到maximumPoolSize去消化高峰。于是我把队列容量改成了64核心线程数调到4最大线程数调到20结果排队时间降到秒级CPU负载反而因为及时扩容更平稳了。这就是队列和参数的动态平衡队列越长扩容越慢排队延迟越大系统越倾向于平滑队列越短扩容越快资源消耗越激进任务延迟越低。没有绝对正确的配置只有适合场景的配置。6. 线程池状态与优雅关闭从RUNNING到TERMINATED的完整路径线程池的关闭是生产环境最容易出事故的环节之一。很多线上故障的根本原因是服务重启或发布时线程池被强杀丢了一堆正在执行或排队中的任务。这一节把线程池的状态流转和关闭机制讲透。6.1 五种运行状态ThreadPoolExecutor内部用AtomicInteger的高3位来记录线程池状态低29位记录工作线程数。五种状态分别是RUNNING正常运行状态能接收新任务也能处理队列中已有任务。SHUTDOWN关闭状态不再接收新任务但会继续处理队列中已排队的任务。STOP停止状态不再接收新任务不处理队列中的旧任务且会中断正在执行的任务线程。TIDYING所有任务已结束工作线程数为0线程池正在执行terminated()钩子方法。TERMINATEDterminated()方法执行完成线程池彻底终止。状态间的迁移路径是RUNNING - SHUTDOWN - TIDYING - TERMINATED或者RUNNING - STOP - TIDYING - TERMINATED。理解这套状态机对正确使用shutdown和shutdownNow至关重要。6.2 shutdown与shutdownNow温柔关闭和暴力关闭shutdown()是温柔关闭它会将状态置为SHUTDOWN然后等待队列中的任务全部执行完毕线程池才自然过渡到TIDYING和TERMINATED。期间如果还有新任务提交会被拒绝。shutdownNow()是暴力关闭将状态置为STOP立即中断所有正在执行任务的线程并返回队列中尚未执行的任务列表让调用方可以自行处理这些遗留任务。实际调用shutdownNow()的场景非常少。我记得有一次线上有个任务卡死了无法自动结束而发布窗口只有几分钟才用了shutdownNow()硬杀然后把遗留任务记录成日志发布完成后手动补跑。大部分情况下优先用shutdown()。6.3 awaitTermination给线程池一个善后的宽限期光调用shutdown()还不够因为它是异步方法调用完立即返回线程池可能还在慢慢消化队列里的任务。如果这时候主程序直接退出JVM一终止线程池里的任务照样会丢失。正确的关闭流程是配合awaitTermination来等待executor.shutdown(); if (!executor.awaitTermination(30, TimeUnit.SECONDS)) { executor.shutdownNow(); if (!executor.awaitTermination(30, TimeUnit.SECONDS)) { // 记录日志标记线程池未能及时终止需要人工介入 } }这段代码的含义是先温柔关闭等待30秒看线程池是否结束如果没结束说明有任务卡住了升级为暴力关闭再等30秒还不行基本可以断定线程卡死在不可中断的阻塞操作里如数据库连接等待、网络IO阻塞只能记录日志走其他补偿流程。我在用Spring的DisposableBean或者PreDestroy做优雅停机时都是这么处理线程池的。没有这个习惯之前我在一次版本发布中直接丢了上千条未处理的业务消息后续对账补了整整一天。从那以后关线程池必等、必中断保护就成了我的铁律。6.4 给线程池增加钩子方法做监控ThreadPoolExecutor提供了三个可重写的钩子方法beforeExecute、afterExecute、terminated。这三个方法平时是空实现用来给子类做扩展的。我最常用的是afterExecute在里面统计任务执行时间和异常信息喂给监控系统public class MonitoredThreadPoolExecutor extends ThreadPoolExecutor { private final MetricsRegistry metrics; Override protected void afterExecute(Runnable r, Throwable t) { super.afterExecute(r, t); long cost ThreadLocal取到的任务开始时间差; metrics.histogram(task.cost).update(cost); if (t ! null) { metrics.counter(task.exception).inc(); } } }需要提醒一个细节afterExecute拿到异常有个陷阱。当你的任务是Runnable直接提交时如果任务内部抛了未捕获异常异常会被FutureTask包装不会直接通过Throwable t参数传递给你。只有当你提交的是Callable且调用Future.get()时异常才会从ExecutionException中暴露出来。所以想用钩子方法全面捕获异常任务最好包装成Callable。7. 实战配置线程池核心线程数、队列长度、拒绝策略的经验公式很多人学完线程池参数到了真正配置的时候还是慌不知道从哪里下手。我自己的经验是分三步走先根据任务类型估算线程数再确定队列容量和拒绝策略最后留出监控和动态调整的后手。7.1 线程数估算CPU密集型和IO型任务的标准打法业界流传一个经验公式CPU密集型任务核心线程数设为CPU核数1IO密集型任务核心线程数设为CPU核数×2。这个公式在大多数场景下是能用的但我们应该理解它背后的原理。CPU密集型任务如下载图片后做压缩、复杂计算线程基本在占用CPU计算线程数超过CPU核数之后多出来的线程只能排队等待被调度反而增加上下文切换开销。设成核数1是为了让某个线程因为页缺失、缓存未命中而短暂阻塞时另一个线程能立刻顶上。IO密集型任务如发送HTTP请求、读写数据库线程执行过程中有大量时间在等待网络或磁盘IO。这个等待时间CPU是空闲的完全可以多开线程来利用这段空闲时间处理其他任务。所以理论上更精确的线程数是线程数 CPU核数 * (1 等待时间 / 计算时间)比如一个任务计算耗时20msIO等待耗时80ms那么等待时间是计算时间的4倍线程数应该是核数×5。这个公式每个项目差别很大但给了我们一个估算的正确姿势先测出任务的计算占比再决定线程数。7.2 队列长度设计从业务容忍延迟倒推队列长度没有统一标准我的习惯是从业务角度倒推。核心问题是这个任务最多能等多久假设核心线程数4单任务执行时间200ms队列容量N那排在队尾的任务等待时间大约是N×200ms/4。如果业务要求任务提交后1秒内必须开始执行那么N×200ms/4要小于1000msN最大取19左右我一般保守取N16。项目里我还会做一个保守兜底队列容量不大于corePoolSize的4倍经验值不是死规则。队列太长线程池就变成任务堆积池了。7.3 拒绝策略选型的业务判断拒绝策略的选择我建议按业务的容忍度来分任务不能丢、报错后可以重试用AbortPolicy让调用方捕获RejectedExecutionException做重试或降级。任务不能丢、可以延迟执行用CallerRunsPolicy提交线程自己消化形成背压。实时性要求高、旧任务价值低考虑DiscardOldestPolicy优先处理新的任务。有独立的降级通道自定义策略如前面提到的发消息队列。最不建议的是DiscardPolicy。它让任务悄无声息消失没有日志、没有异常、没有回调排查问题如大海捞针。7.4 配置动态化prestartAllCoreThreads与动态调参一个常见的认知误区是线程池配置好之后就死板了。其实ThreadPoolExecutor提供了强大的动态调节能力prestartAllCoreThreads()启动时立即创建所有核心线程避免任务一到才创建线程的冷启动延迟。setCorePoolSize(int)动态修改核心线程数。setMaximumPoolSize(int)动态修改最大线程数。setKeepAliveTime(long, TimeUnit)动态修改空闲回收时间。利用这些方法可以做一个简单的动态线程池管理器后台定时监控队列积压量积压超过阈值就把maximumPoolSize调大空闲稳定后再回调。很多开源框架如美团动态线程池组件做的就是这套事情但原理并不复杂自己动手也能实现。8. 面试高频问题真正拉开差距的思考深度线程池是Java面试八股文的重灾区网上总结的面试题一抓一大把。但根据我自己面试候选人的经验能够把线程池讲得出彩的人靠的不是背答案而是对源码和场景的理解深度。这一部分列出几个常见问题和我认为真正有价值的回答思路。8.1 线程池的核心参数有哪些你是怎么理解的这个问题的标准答案是七大参数但拉开差距的是对参数之间关系的理解。比如要主动提到核心线程和最大线程之间还隔着一个工作队列队列没有满之前线程池不会扩容所以maximumPoolSize的生效条件是队列满。再补一句所以如果用了无界队列maximumPoolSize就等于废掉了。这几句话能直接展示你用过、思考过而不是背书。8.2 提交一个任务到线程池内部经历了哪些过程这题考的是执行流程。最好按四步判断来答先判断核心线程核心线程满了尝试入队队列满了尝试扩容扩容到上限走拒绝策略。每个步骤都可以追问细节。比如面试官问入队失败意味着什么你要能答出队列是有界的且已经满了此时系统需要更激进的方式处理压力。8.3 为什么不建议用Executors创建线程池这个问题已经快变成标准面试题了。回答要点有两层第一层是缺陷层面FixedThreadPool和SingleThreadExecutor的无界队列会导致OOMCachedThreadPool的最大线程数无上限会导致线程爆炸第二层是方法论层面直接使用ThreadPoolExecutor可以让参数显式化、可控化规避这些隐含风险。如果能补一句具体风险取决于业务场景比如我的某类业务任务量小、量可控用内置线程池问题也不大反而显得你辩证看待问题。8.4 线程池的线程数应该怎么设置这一题最能区分实战经验和理论派。建议回答按场景分CPU密集型、IO密集型分别怎么算IO密集型可以用任务的计算和等待时间比例来估算然后补充一句配置之后要用压测和监控来验证不是写完就完事了。压测里如果ThreadPoolExecutor的getTaskCount和completedTaskCount差距持续增大就说明处理不过来需要优化。8.5 如果线程池中的任务抛异常了会发生什么这题的坑点在于execute()和submit()行为不同。用execute()提交任务时如果任务中抛出运行时异常线程会退出ThreadPoolExecutor会创建新线程顶上但你拿不到这个异常除非设置UncaughtExceptionHandler。用submit()提交时异常被FutureTask捕获会封装成ExecutionException但需要调用Future.get()才会被抛出。如果不调用get()异常就静默吞掉这是很多线上诡异问题的一个重要来源。答题时把这个细节说清楚基本就是满分。8.6 线程池的关闭方法有哪些区别是什么shutdown()和shutdownNow()的区别是最常考的点。回答时我会带上状态机的迁移、awaitTermination的作用以及自己项目里怎么用先shutdown、再等待、再shutdownNow的三段式关闭。这个回答既有源码依据又有实践场景比单纯背两个方法名的区别高出很多。这些问题的回答思路其实本质就是这篇文章的内容——参数怎么配任务怎么流转内置线程池埋了什么坑队列怎么选关闭怎么处理。把这些真正理解了面试不是背出来的是用经历喂出来的。
返回列表