大概每个写过一段时间Java多线程的人都会有这种感觉学的时候每个知识点都能听懂Thread、synchronized、锁、线程池、ConcurrentHashMap单独拎出来讲都能聊上几句可真到线上出了问题或者在设计一个并发模块时脑子里的知识点全变成了一盘散沙不知道从哪个下手。我这个JAVA进阶系列的Thread学习写到第12篇按计划本来该继续往下讲新东西但写到这我反而觉得最该做的是一次“多线程小结”。不是简单罗列学过的内容而是把那些散落的知识点重新串成体系顺便把这些年实际写并发代码踩过的坑、总结的判断逻辑一并收进来。这篇小结是写给两类人的一类是刚学完Java基础、正在啃多线程的开发者看完以后能知道哪些知识点是真正的核心主线哪些是边角料另一类是已经在写业务代码、但遇到并发问题还是靠猜的开发者这篇里会给出排查死锁、配置线程池、选择并发容器的具体判断链路。小结类文章最忌讳写成目录式的知识点堆砌所以我不会挨个复习API而是按“为什么这些知识点会以这样的方式存在”来讲——当你理解了每个设计背后的矛盾知识才算真正是你的。1. 为什么学到第12篇我才觉得“终于入门了”1.1 多线程的本质是“资源竞争的治理”不是“开线程的语法”很多人学多线程的第一个动作就是看Thread类和Runnable接口的用法跑通一个demo之后就觉得自己会了。但说句不好听的光会启动线程和调用start()距离“会写多线程”还差着十万八千里。多线程编程的真正难点从来不在“怎么创建线程”而在于你要同时让多个线程共享数据、互相协作、还要保证性能和正确性这本质上是一道资源竞争治理题。举个最常见的例子一个电商库存扣减逻辑单线程下就是库存 0然后减一没有任何问题。一旦并发两个请求同时读到库存为1都判断“够了”然后都执行减一库存变成了-1。你没改任何业务代码只是引入了多线程正确性就崩了。所以我在总结时把所有的知识分成两类一类是解决“数据安全”的synchronized、锁、volatile、原子类、ThreadLocal另一类是解决“任务调度”的线程池、Future、CompletableFuture、同步工具类。两者互相独立又互相配合这构成了整张知识地图的第一层骨架。学到这里我慢慢想明白一件事并发编程里那些眼花缭乱的技术方案本质上都是在对同一个问题进行回答——多个执行流同时访问共享资源时如何让结果仍然正确且性能还能接受。1.2 我用三条主线把知识点收拢起来如果让我把整个多线程的知识体系压缩成三句话我会这样概括第一线程是执行任务的载体它的生命周期、调度方式决定了程序运行时底层发生了什么第二数据在线程之间怎么流动才是安全的依赖的是Java内存模型JMM那套规则第三把单任务抽象成批量任务后如何管理线程的创建、复用、排队和拒绝这是线程池和异步编排工具解决的。这三条主线不是平行的它们是层层嵌套的关系。线程池是管理线程的容器而线程池里的任务在执行的瞬间内部仍然会发生数据竞争你用CountDownLatch去协调多个线程的进度但等这些线程真正开始读写共享变量时又回到了可见性和锁的问题上。想通了这三层嵌套再看任何并发代码你都能第一时间判断出作者在解决哪一层的问题。这也是我在写第12篇小结时最大的感受知识不是学得越多越好而是要让每个知识点都能安放在它该在的位置上。比如Thread.yield()这种冷门方法你单独背它的语义永远记不住但放在“线程状态切换”这条主线上你就理解了它只是把当前线程从运行状态让出来重新竞争CPU仅此而已。1.3 这篇小结适合谁看我先说清楚这篇小结的适用范围避免读者走弯路。如果你完全没接触过多线程连new Thread()都要查文档建议先去把Java基础语法过一遍再看这篇否则部分段落会缺少上下文。如果你已经会用synchronized、会写ExecutorService但遇到以下几个场景依然头疼那么这篇小结正好适合你在多个线程里共享了一个HashMap偶尔会卡死或数据错乱写了一个多线程跑批程序上线后任务经常丢失或者线程数暴涨排查线上问题时看到线程dump里一堆WAITING状态不知道从哪里下手两个服务互相调用偶发超时重启就好但隔几天又犯。总结一句话这篇小结解决的是“知识会了但不会用”的问题。下面每一章都会从具体矛盾出发把相关知识点拉通来复盘。2. 六状态与JMM基础中最容易被低估的两块地基2.1 线程六状态在调试日志里的真实模样先聊一个看起来最简单、但很多人其实记不准确的话题Java线程的生命周期。网上很多文章会告诉你线程有六种状态NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。这六个名字背下来不难难的是你在真实日志里看到这堆状态时能不能一眼判断出发生了什么。我见过不少开发者在线上排查问题时的第一反应就是“线程卡死了”但卡死有很多种。如果看到RUNNABLE状态的线程大量堆积这往往是线程在跑CPU密集计算或者正在等一个内核态的锁而BLOCKED状态说明线程想进入一个synchronized代码块但锁被其他线程占着WAITING是线程调用了wait()或join()之后挂起TIMED_WAITING则是在等待一个带超时时间的操作。这里最容易忽略的一个细节是RUNNABLE状态其实包含了两种情况一种是线程正在CPU上执行另一种是线程已经就绪但正在等待CPU时间片。所以在Linux上用top命令看到某个进程CPU不高但线程池里一堆RUNNABLE不一定是它在忙计算也可能是它在反复进行系统调用比如FileInputStream的阻塞读在Java层面依然会被标记成RUNNABLE真正的等待被操作系统吞掉了。这个经验在排查IO密集场景时特别重要不然你会误判线程在空转。2.2 volatile、可见性与happens-before规则六状态讲的是线程“在干什么”接下来这个问题讲的是线程“能看到什么”。Java内存模型里有一条最反直觉的规则两个线程同时读写同一个变量A线程改了值B线程不一定看得到。原因不复杂每个线程在工作内存里会缓存变量的副本CPU还有寄存器、多级缓存写操作什么时候刷回主内存并没有绝对的实时保证。volatile这个关键字解决的就是这个可见性问题它保证了对变量的写操作会立即刷新到主内存读操作会从主内存重新加载。但它只保证单个变量读写时的可见性不保证复合操作的原子性。比如volatile int countcount这个操作依然是先读后写两步两个线程同时执行时照样会丢更新。所以我才说volatile最典型的应用场景是状态标志位一个布尔值控制循环是否继续一个枚举控制初始化状态这些写少读多的场景它很合适。比volatile更重要的是它背后牵连的happens-before规则。Java内存模型定义了一套规则只要两个操作满足happens-before关系前一个操作的结果就对后一个操作可见。我用大白话解释一下它的意思是“如果A操作在时间逻辑上先于B操作并且两者有happens-before关系那么A对内存的修改在B执行时一定能被看到”。这里不需要你去背所有的规则条文但有一条必须记牢对一个锁的解锁操作一定happens-before于后来对这个锁的加锁操作。这就是为什么synchronized能同时保证原子性、可见性和有序性它是多线程安全兜底的那块砖。2.3 synchronized到底“锁”住了什么锁升级这条暗线synchronized可能是Java里用得最多的并发关键字但很多人用它的时候并不知道它在底层经历了什么。早期JDK里它是纯粹的重型锁每次加锁解锁都要通过操作系统级别的互斥量性能很差。后来JDK 1.6做了大改引入了偏向锁、轻量级锁、重量级锁的升级路径也就是说synchronized的性能并不是一成不变的慢它会在竞争不激烈时退化成很轻量级的操作。锁升级这条暗线到底怎么回事我简化来说一个对象第一次被某个线程加锁时会先尝试偏向锁相当于在对象头里记录“这个锁偏向于某线程”之后该线程再次进入时不需要任何CAS操作如果有别的线程来竞争偏向锁撤销升级成轻量级锁这个阶段通过CAS自旋来抢锁不阻塞如果自旋都抢不到说明竞争真正激烈了再膨胀为重量级锁进入阻塞队列。理解这条升级路径对实际开发有直接帮助。你会发现同样是synchronized在低并发下和高并发下的成本差异很大因此不要听了“同步有开销”就过度设计也不要在高竞争场景里把希望寄托在自旋上。我在做性能排查时见过一种典型问题某服务把一段执行耗时很短的代码块加了锁但这段代码会被极高频调用导致锁竞争非常厉害线程大量阻塞。这个问题的解法不是去换一种锁而是要么把锁粒度拆细要么用乐观锁思路改造后面我还会细讲。2.4 ThreadLocal的隔离和泄漏一个案例讲透多线程里“共享”带来的麻烦太多所以有一种设计干脆走反向思路不共享。ThreadLocal就是让每个线程维护自己独立的变量副本彼此看不见。它最常见的应用是传递上下文信息比如把用户ID、链路追踪ID放在ThreadLocal里在同一个线程的各个方法间共享但又不污染其它线程。真正让ThreadLocal从“好用”变成“危险”的是它在线程池场景下的内存泄漏问题。原理说起来倒也直白每个ThreadLocal实例作为key存在线程的一个Map里这个Map的key被设计成弱引用但value是强引用。线程池里的线程生命周期很长线程存活期间value会一直被这个Map引用住如果你不用了也没调用remove()你就永远“抱着”这个对象不放。如果value是几百兆的缓存对象累积几次就会内存爆炸。我之前接手过一个线上OOM问题就是有人把一个大批量的中间结果集存在ThreadLocal里任务执行完没有清理线程池又是核心线程常驻的配置结果内存一路涨到物理极限。定位方法不复杂用内存分析工具看线程对象的关联引用一抓一个准。从那以后我就给自己定了一条规矩ThreadLocal的清理必须在finally块里remove()写完就删不留侥幸。这是教科书里不会强调、但生产环境一定会遇到的经验。3. 锁的复盘选择、粒度与死锁排查链路3.1 synchronized还是ReentrantLock我的选择标准ReentrantLock和synchronized的选择是一个被问烂了的问题但这里最容易犯的错误是“为了显高级而用ReentrantLock”。我需要把选择标准讲清楚synchronized是语言层面内置的用起来简单、不容易出错JVM会自动管理锁的释放发生异常也不会死锁性能上经过锁升级优化后绝大多数场景下已经足够ReentrantLock的优势集中在高级能力上比如可中断地获取锁lockInterruptibly()、尝试非阻塞获取锁tryLock()、公平锁、条件变量等。我的标准是如果只需要基本的互斥优先synchronized如果你需要在获取锁的过程中响应中断、需要限时等待、需要多个条件队列再上ReentrantLock如果是特别追求公平性先来先服务那synchronized做不到得用ReentrantLock(true)。公平锁在Java里是真公平但付出的代价是更多的上下文切换所以我在业务代码里几乎不用公平锁——大多数场景下我们要的只是“最终不出错”不是“绝对公平”。这里补充一个容易踩的坑ReentrantLock必须在finally里解锁而且解锁操作和加锁操作要配对。我见过一个代码在tryLock()成功之后忘了解锁导致业务线程越积越多最后整个服务假死。排查到最后才发现是锁没有被释放不是业务逻辑的问题。用ReentrantLock比用synchronized多了一份“自己负责”的成本这是选型时必须想清楚的。3.2 锁粒度演进从单点锁到分段式的思路有时候代码里只有一个锁功能也对但并发性能就是上不去这时候问题往往出在锁粒度上。锁的粒度太粗所有线程都挤在一个入口相当于把多线程退化成了串行执行锁粒度太细又会因为频繁加锁解锁产生额外开销甚至引入复杂的逻辑错误。我在做数据缓存刷新时曾遇到过这个典型问题一个热点HashMap承载了全量配置数据每次配置变更就锁整个HashMap并全量覆盖。配置变更虽然不频繁但这个锁把所有读线程也挡住了因为读取时也要拿同一个锁保证不会读到写入一半的数据。后来我把方案改成读写锁读并发走ReadLock多个读者可以并行只有写的时候才拿WriteLock。就这么一改同一样本流量下接口耗时下降了四成。顺着这个思路再往下走一步就是分段锁思想。ConcurrentHashMap在JDK 1.7时代用的就是分段锁把整个哈希表切成一段一段每段一把锁不同段的读写可以并行。这种思想在现代并发代码里依然很值得借鉴当你觉得一把大锁拖垮了吞吐量先别急着上分布式锁或者无锁数据结构想想能不能把数据按照某种维度拆成独立的桶让竞争只发生在同一个桶内。这才是性价比最高的演进路径。3.3 一次死锁的定位过程jstack完整排查链路死锁是多线程里后果最严重的故障之一特点是服务不崩溃但相关线程全部卡死接口超时堆积。纯粹的代码审查很难提前发现所有死锁路径因为死锁需要满足四个条件同时成立互斥、持有并等待、不可剥夺、循环等待。而实际代码里锁的顺序往往散落在不同方法中肉眼几乎不可能穷举。有一次线上故障我们的一个任务调度模块突然大面积超时重启之后恢复但过几个小时又复发。我当时的排查链路是这样走的先打线程dump在Linux上用jstack pid输出所有线程的状态然后过滤出BLOCKED和WAITING状态的线程重点关注那些“locked”和“waiting to lock”成对出现的记录。很快我就发现线程A持有了一把A锁正在等B锁线程B持有B锁正在等A锁一个标准的循环等待。下一步是定位到代码位置。线程dump里每一帧都带着类名和方法名顺藤摸瓜找到那两处加锁的方法很快就看清了订单状态变更和退款操作都涉及两个资源但两处代码加锁的顺序不一致一个先锁订单再锁用户另一个反过来。在低并发下两个操作碰上的概率很低一旦流量上来交叉等待就发生了。修复方案也很简单统一加锁顺序先锁订单再锁用户并在代码注释里写清楚这条全局约定。这个习惯我建议每个团队都建立起来凡是涉及多把锁的操作团队内必须约定一个全局的锁顺序并写进代码规范。3.4 读写锁与StampedLock的真实定位ReentrantReadWriteLock和StampedLock都是对“锁粒度优化”这个思路的延伸。先说ReentrantReadWriteLock它把锁分成读锁和写锁读锁之间不互斥写锁和任何锁都互斥。这个设计对“读多写少”的场景非常友好比如配置中心、路由表、商品详情缓存这类数据读的频率远远高于写。但要注意读写锁有锁升级的坑不能在一个线程持有读锁的情况下再去获取写锁否则会死锁。我见过有人在这个问题上栽过跟头最后只能通过先释放读锁再抢写锁来解决。StampedLock是JDK 8引入的更进阶的方案它引入了乐观读模式读取数据时不加锁而是先拿到一个stamp印戳读完之后再验证这个stamp是否仍然有效。如果有效说明读的期间没人写过如果失效再升级为悲观读锁或写锁重新读。这种乐观读在数据真正很少被写的时候性能极高几乎是纯内存读的成本。但StampedLock不适合在锁内部调用可重入的操作也不支持条件变量所以它更像一把手术刀用来精雕细琢特定场景不是日常通用的那类锁。我自己的判断是绝大多数读多写少场景ReentrantReadWriteLock已经够用只有当压测数据明确告诉你读路径确实成了瓶颈时才值得用StampedLock去抢那一点极致性能。并发工具很多但成本、复杂度总是跟着性能提升一起增长选型要控制自己的贪心。4. 线程池生产环境真正的“并发阀门”4.1 七大参数的决策逻辑先想清楚回调再写代码线程池是一片被误解很深的知识点。很多人看到ThreadPoolExecutor的构造方法里有七个参数就头大但我想换个角度来讲把这七个参数当成一次“服务容量规划”的决策过程而不是死记参数含义。corePoolSize是常态下的线程数量相当于你的服务每天需要保底多少人干活maximumPoolSize是极限情况下最多能扩到多少人keepAliveTime是超出核心的线程空闲多久后可以裁掉workQueue是任务太多、核心线程都忙时任务先在哪里排队threadFactory控制线程如何创建命名、是否守护handler是队列都满了之后新任务怎么处理。关键的运行逻辑必须捋清楚任务来了先找核心线程核心满了就塞队列队列满了才开始扩线程直到maximumPoolSize再满了才触发拒绝策略。这个顺序和很多人的直觉相反——线程池并不是“先扩线程再排队”而是“队列比扩容优先”。理解这一步你才能解释为什么maximumPoolSize设得再大队列没满时也触发不了扩容。我在设计线程池时一共要敲定四件事线程数、队列类型、拒绝策略、线程命名。后面三个都可以对应到具体场景拍板但线程数往往需要结合一个经验公式来估算下面会专门聊。4.2 队列选型的差异以及为什么无界队列要警惕workQueue是线程池里最容易被忽略、但影响最大的参数。它的类型决定了任务在内存里如何排队也决定了你在高流量下会最先遇到什么问题。队列主要分成有界和无界两类有界队列又有数组实现和链表实现之分ArrayBlockingQueue容量固定、LinkedBlockingQueue默认可以无界也可以设置容量SynchronousQueue则是一个零容量的直传队列不存储任务直接转给线程。这里最要命的是无界队列的陷阱如果用了无界队列maximumPoolSize和拒绝策略实际上永远没有触发机会。因为队列永远不会满任务只会一直往内存里堆。这个在短期内看是“永不拒绝”似乎很安全但本质是拿内存换安全。我见过一个定时任务服务消息生产速度偶尔暴涨线程池用的是无界队列核心线程处理不过来消息积压了几个小时内存被堆到OOM。如果当时用的是有界队列最多是触发拒绝策略配合监控报警问题早就能暴露。所以在生产环境的线程池配置里我几乎总是选择有界队列容量要根据业务情况进行估算宁可偶尔触发拒绝也绝不能让内存悄无声息地爆掉。控制风险的前提是让风险显性化无界队列恰恰抹掉了这道预警信号。4.3 拒绝策略的设计比默认策略多做两件事当队列和线程都满了新提交的任务就交给拒绝策略处理。JDK默认提供了四种策略AbortPolicy直接抛异常CallerRunsPolicy让提交任务的线程自己去执行等于把压力回推给调用方DiscardPolicy静默丢弃DiscardOldestPolicy丢弃队列里最老的任务。很多人不假思索地用默认的AbortPolicy结果高流量下一堆RejectedExecutionException打到业务层接口一片报错模板。我的做法是即便选了AbortPolicy或者CallerRunsPolicy也一定会做两件额外的事第一自定义ThreadFactory把线程名统一命名成可辨识的格式比如order-async-worker-1这样线程dump里一眼能看出异常来自哪个线程池第二在拒绝策略里加监控埋点把拒绝数量、线程池活跃度、队列剩余容量打成日志或指标配合告警系统。记录一下我踩过的具体教训有一次大促流量上来一个线程池的队列容量设得太小触发了大量拒绝。因为默认的AbortPolicy只是抛异常异常虽然被业务层抓到了但日志里没有统计拒绝次数。等到故障复盘时大家才从接口超时和线程dump往回想绕了一大圈才找到真正的短板。如果当时拒绝策略里打了指标这个故障可以用十分钟定位出来。所以我会说拒绝策略不是收尾动作它是线程池自我保护的最后一道警戒线。4.4 为什么Executors快捷方法被那么多文章说“不要用”Executors工具类提供了几个看着很省事的快捷方法newFixedThreadPool、newCachedThreadPool、newScheduledThreadPool、newSingleThreadExecutor。这些方法背后的ThreadPoolExecutor配置都被预设好了问题就出在预设上。newFixedThreadPool和newSingleThreadExecutor用的都是无界LinkedBlockingQueue这正好踩了上面说的无界队列陷阱newCachedThreadPool则相反用的是SynchronousQueue线程数随任务数无限扩张理论上最大线程数等于Integer.MAX_VALUE任务洪峰时能把机器资源直接打爆。我的态度不是彻底否定这些快捷方法而是提醒一句话快捷方法的默认参数适合跑demo和压测不适合直接进生产。在生产环境我宁可多写几行代码把ThreadPoolExecutor完整参数配出来也不愿意图省事埋下隐患。这里有一个看起来微小但影响很大的细节线程池一定要设置合理的线程名。用Executors生成的线程默认叫pool-1-thread-1这种名字出了问题查线程dump根本不知道这是哪个业务模块的线程排查成本直接翻倍。自定义ThreadFactory看似多写了几行但它省下的排查时间是这几行的几十倍。5. 并发工具与容器的选型地图5.1 三个同步器的分工CountDownLatch、CyclicBarrier、SemaphoreCountDownLatch、CyclicBarrier、Semaphore这三个类用法长得有点像都是让线程之间互相等待或者限制并发但如果用错场景代码会变得特别别扭。我在实际使用中总结的口诀是这样的CountDownLatch是倒计时门闩一个或多个线程等待“倒计数”归零一次性用完就废适合主线程等待多个子任务全部完成的场景CyclicBarrier是循环栅栏一组线程互相等待等大家都到齐了再同时出发而且可以循环使用适合分阶段并行计算的场景比如多线程分片处理完一批数据后再合并进入下一批。Semaphore和前两者的逻辑不一样它本质上是一个“许可证”系统线程要执行必须先拿到一个许可用完归还。它最合适的场景是限流比如一个接口同时只能有5个线程去调外部接口多了就排队等许可。这里最容易犯的错误是把Semaphore的许可数量当成固定的资源上限却不考虑许可获取和释放的异常路径——如果没有在finally里释放许可许可会越用越少最后所有线程都被卡死。我在写代码时对这类“获取-释放”型的工具一律采用try-finally模板几乎形成条件反射。5.2 ConcurrentHashMap的版本演进告诉我们的并发思路ConcurrentHashMap是学习并发容器的必修课但比记住它的API更值得琢磨的是它在JDK版本演进中展现出的并发设计思路变化。JDK 1.7时代的实现是分段锁哈希表被分割成多个Segment每个Segment是一把独立的锁不同Segment的写操作能并行这对应了我前面讲过的锁粒度拆分思想。JDK 1.8以后实现方式有了很大调整放弃了Segment分段锁改用CASCompare And Swap配合synchronized锁住单个桶的头节点。写入时先用CAS尝试直接插入如果发生哈希冲突再对桶头节点加synchronized。这个演进的本质是在冲突不激烈时尽量走无锁路线只有真正发生竞争才退化为锁。这种“无锁优先、锁兜底”的思路放到今天做任何并发设计都不过时。实际开发中我对ConcurrentHashMap的使用还有一个经验它虽然保证了单个读写的线程安全但不保证复合操作的原子性。又是一个经典场景map.putIfAbsent(key, value)是原子的但先get再判断再put就不是原子的。如果需要类似“不存在才放”的语义一定要用putIfAbsent这类原子方法来完成不要自己拼三段式逻辑。5.3 CopyOnWriteArrayList读多写少场景的取舍CopyOnWriteArrayList这个名字透露了它的设计写操作时不直接在原数组上改而是复制一份新的数组在副本上修改然后把容器内部的引用指向新数组。这样读操作完全不加锁因为读的永远是一个不可变的快照。它最典型的应用场景是读多写少、且对读的实时性要求不高的地方比如监听器列表、白名单这类数据。但这个类的缺点也很明显每次写操作都要复制整个底层数组写成本极高。如果你在一个频繁更新的数据结构上用它性能会惨不忍睹而且读操作可能读到旧数据不是“强一致”。有一次我在一个缓存列表的场景里误用了它列表每几秒钟就更新一次而列表本身有几万个元素每次更新复制一遍数组内存和CPU都绷不住了。复盘后换成了读写锁方案才把性能拉回来。所以我把它列入选型地图时总要强调一句话CopyOnWriteArrayList适合“几乎不写”的场景如果写频率超过你的容忍度趁早换方案。5.4 CompletableFuture让异步编排从回调地狱里走出来在CompletableFuture之前Java做异步主要靠Future和回调。Future的硬伤是它只支持阻塞式获取结果get()会卡住异步任务之间要串成链路时非常痛苦得在一个线程里阻塞等待前一个任务结束再去处理下一个任务等于把好端端的异步做成了同步。而回调虽然不阻塞但层层嵌套的写法会让后续维护的人读代码读到崩溃。CompletableFuture做的事情是把“任务编排”这件事显式化了。它允许你通过thenApply串行接续处理、thenCompose把异步操作扁平化组合、whenComplete在结果就绪后统一收尾、exceptionally处理异常路径。我在做多服务聚合接口时深有体会原来需要同时调用户服务、订单服务、商品服务再人工合并结果用CompletableFuture加allOf可以一次性并发发起这三个请求全部完成后回调合并代码层次清楚耗时也从三者之和降到了最慢的那一个。这里有一个细节值得注意使用CompletableFuture时最好显式指定线程池比如CompletableFuture.supplyAsync(supplier, executor)。如果不指定它默认使用ForkJoinPool.commonPool()这个公共池被所有代码共享一旦其中一个任务的执行时间长或者抛出的异常吞噬了线程会影响全局的异步任务调度。我偏好给异步编排单独建一个线程池隔离更干净。6. 小结之后我把并发代码重构了一遍6.1 一个任务分发场景的选型推演理论知识复盘完之后最好的检验是找一个真实场景把整个选型过程重新推演一遍。我用一个经常出现的业务场景来说明系统每天有一批待处理的任务需要并发地拉取外部数据、做本地清洗、再写入结果表。任务数量不确定高峰期可能有几万个每个任务耗时也不一样有的几十毫秒有的几秒。按我小结得出的判断链路来走第一任务的并发执行统一走线程池不直接在循环里new Thread线程创建销毁有开销而且无法限制并发数第二线程池参数方面这是IO密集型场景拉数据、写表都涉及网络和磁盘核心线程数不能按CPU核数拍脑袋要按IO密集的经验公式估算第三线程池队列要选有界的任务洪峰时宁可触发拒绝和告警也不能无界堆积把内存打爆第四多个批次任务之间需要并发分片批次内再用CountDownLatch同步等待全部完成第五如果任务之间有依赖关系比如A完成后才能做B优先拆成多个阶段用CompletableFuture编排而不是硬套一把锁。这套推演的价值不在于结论本身而在于每个选择都有一条可以解释的路径。以后遇到新场景只要把场景特征往这些维度上套一遍方案就自然出来了。6.2 线程数与队列长度的估算公式以及压测验证线程数怎么估业界流传比较广的经验公式有两个。对CPU密集型任务核心线程数大约等于CPU核心数加一N1对IO密集型任务更合理的估算公式是N * (1 等待时间/计算时间)。举个例子一个任务平均计算耗时10毫秒等待磁盘或网络IO耗时90毫秒那么每个线程真正占用CPU的时间只有10%理论上可以用大约N * 10个线程来把CPU利用率拉满。不过这毕竟是估算工程上还要留余量我通常取估算值的70%到80%作为初始值然后交给压测去验证。队列长度的估算思路是反推的队列容量决定了一次洪峰下最多在内存里积压多少任务。如果单个任务平均占用1KB内存队列设10万就意味着最多积压100MB内存这个量级你要能承受。另一方面队列太长会导致任务“滞留时间”变长数据写到队列里很久才被消费业务上是否允许这种延迟也得想清楚。压测时我还习惯观察几个指标线程池活跃线程数曲线、队列深度、拒绝次数、任务平均等待时间。如果线程数一直顶到maximumPoolSize说明并发需求超出预期如果队列深度一直上涨说明消费速度跟不上生产速度如果拒绝次数上升说明容量已经到极限了。这一套指标可以让人很快看清线程池配置是否和实际流量匹配而不是等到线上出问题才去改参数。6.3 上线前并发自检清单七问最后这套自检清单是我在复盘后给自己定的硬规矩写并发代码上线前必须过一遍能挡掉很大一部分潜在问题有没有多个线程共享同一个可变对象如果有是靠锁、原子类还是不可变设计来保证正确性涉及多把锁的操作加锁顺序在全团队是否有一致的约定ThreadLocal用了之后是否保证在finally里remove()线程池的队列是否是有界的拒绝策略是否打了监控埋点线程池的线程名是否能一眼认出属于哪个业务模块异步任务是否都设置了超时机制如果外部接口一直不返回线程会不会被一直占住并发修改的数据结构选型是否真正匹配这个场景的读写比例和一致性的要求这七问看起来简单但每一条都对应了线上真实发生过的事故。我在重构成那一版任务调度代码时就发现原来有一处用了无界队列、一处ThreadLocal没有清理、一处异步任务完全没设超时。如果不是先做一次全面的小结复盘这些问题是不会在代码评审里被逐条盯住的。多线程学到中段最忌讳的是“贪多”想一口气把并发包里的类全部学完。这12篇学下来我的体会是把线程生命周期、Java内存模型、锁的本质、线程池参数背后的逻辑、并发容器的设计取舍这五块基石弄透再去碰其他进阶内容一切都会变得很顺。继续往下学之前我会真的停下来把这套自检清单用在新项目上遇到问题再回到对应的章节去翻源码。知识这种东西只有被反复使用和复盘才会真正长在自己身上。