我最近在整理之前写过的多线程系列笔记数了数光Thread相关的进阶笔记已经写到第12篇了。把这些零散知识回笼一下心里有个感觉多线程这东西刚学的时候觉得无非就是new Thread、synchronized、Lock这些东西写两个demo就以为自己掌握了但真正在项目里碰到性能瓶颈、死锁、数据错乱时才发现自己连门都没入。这篇小结不打算从头讲API那没意义Java官方文档和成堆的教学文章已经写得很全。我真正想做的是把我这一年多从理论到实战踩过的坑、悟出的道理重新串成一条真正能用的知识线。这篇文章只写给那种愿意认真把并发这件事搞透的人看不管你是刚学完语法准备进阶还是在项目里被线上问题折磨过的老手应该都能从中找到一点共鸣。1. 学习多线程的正确顺序18个知识点与四档认知层级多线程知识面很宽如果东一榔头西一棒子地学很容易出现“什么都见过什么都不牢固”的状态。我整理了一张自己的知识清单把常用内容按依赖关系排成了四档按照这个顺序去学会顺畅很多。第一档是根基必须彻底吃透Thread的生命周期与状态转换、synchronized的锁升级流程、volatile的内存语义、wait/notify机制的底层原理、ThreadLocal的作用域与内存泄漏。这五个点就像地基缺一个上层知识全是空中楼台。特别是synchronized锁升级很多人面试能背出“无锁→偏向锁→轻量级锁→重量级锁”但问到他写的代码在什么场景下锁真正膨胀了他就懵了。第二档是并发包核心学会它们才算摆脱手写底层ThreadPoolExecutor的参数与执行策略、FutureTask与Callable的配合方式、CountDownLatch/CyclicBarrier/Semaphore这三兄弟的使用场景、ConcurrentHashMap在JDK 7和JDK 8之间的实现差异、BlockingQueue在整个生产者消费者体系中的位置。这一档的特点是“有标准答案”每个类解决什么场景的问题都是明确的。第三档是进阶思维直接拉开你和其他开发者的差距AQS的模板方法设计、ReentrantLock的公平与非公平实现、Condition队列的等待通知模型、LockSupport.park/unpark与wait/notify的本质区别、CAS自旋与AtomicInteger的性能边界。到这一档你才算真正理解了JUCJava并发包为什么这样设计。第四档是工程级能力也是很多高级开发者的分水岭线程池参数在真实业务下的合理设置、异步编排CompletableFuture的复杂链路、并发容器在高并发下还存在的坑、分布式场景下JVM并发工具失效时的替代方案。我给大家的建议是先照着四档顺序自测一遍每一档里面的知识点能否不看资料用自己的话讲清楚如果能直接跳到文章下面的实战章节看踩坑经验即可如果不能建议先补这一档再往下看。这套顺序不是哪个培训机构给我编的是拿真实项目里的线上事故换来的。2. 线程安全的三座大山可见性、原子性与有序性以及它们的攻防之道谈多线程绕不开线程安全。但我发现一个现象很多人对“线程安全”的理解停留在“加锁就不出错了”这个层面。这不够。线程安全问题的本质来源就三个可见性、原子性、有序性。这三个问题不搞明白你写出来的锁永远是碰运气。2.1 可见性问题CPU缓存与主内存的迷雾先举一个最典型的例子。假设两个线程同时操作一个int count变量线程A执行count线程B在一个while循环里读取count。A线程改了值B线程可能永远看不到变化。这就是可见性。每个线程在CPU核心上执行时会先把变量从主内存读取到该核心的高速缓存或寄存器中线程A在自己的缓存里改了值如果没有强制刷回主内存线程B读到的永远是它自己缓存里的旧值。volatile关键字解决的正是这个问题。写法是private volatile int count 0;其核心语义有两条其一对该变量的写操作会立即刷新到主内存其二对该变量的读操作会直接从主内存读取。在JDK 5之后volatile还引入了happens-before规则一个volatile变量的写操作在时间上先于另一个线程对它的读操作时写之前的所有普通变量赋值对读线程是可见的。这一点很关键它意味着volatile不仅仅是“防重排序”它还能担任轻量级的同步屏障。2.2 原子性问题count在底层其实分三步很多人一开始总是想不通count不是一行代码吗怎么会不安全CPU执行count时可不是一条指令它至少拆成三步第一步把count从内存读到寄存器第二步在寄存器里执行1第三步把结果写回内存。如果两个线程同时执行这三步就可能出现两个线程都读到count5都1都写回6结果本来应该加两次变成7实际丢了一次更新。原子性的解法不用我多说一是synchronized对同步块加锁二是用AtomicInteger这种CAS自旋的原子类。这里我想多说一句的是如果竞争不激烈AtomicInteger的性能远好于synchronized但一旦高竞争下CAS自旋太多CPU开销会飙升反而可能不如synchronized在锁膨胀后的阻塞挂起。所以没有银弹一切选择都要看场景和压力测试数据。2.3 有序性问题编译器和CPU的指令重排有序性是最容易被忽视的因为它平时不出事一出声就是重构级别的疑难杂症。为了充分利用CPU流水线编译器和处理器在不改变单线程语义的前提下可能会对指令执行顺序进行调整。单线程看没有任何问题但在多线程环境下这种重排可能导致一个线程看到另一个线程“颠倒”的操作结果。最经典的案例就是单例模式的双重检查锁。早期代码如果instance没有被volatile修饰线程A正在初始化Singleton对象时先给instance指向了内存地址但内部的构造方法还没执行完线程B进来发现instance非空直接拿去用结果拿到一个半初始化的对象。这段代码在网上被讨论过无数遍但每次项目里有人把volatile漏掉仍然会在高并发下偶发崩溃极端难以复现。解决方案就是给instance加上volatile利用它的内存屏障禁止指令重排。2.4 攻防策略什么时候用锁什么时候用volatile什么时候用原子类这三座大山纠缠在一起新手最常犯的错误就是“一刀切”。我总结了一个大致的选型判断流程分享出来判断你的操作是不是简单的读或写单字段。如果是并且对该字段的可见性有要求先考虑volatile。判断你的操作是不是复合操作例如“检查再修改”“先读后写”。这类语义必须用synchronized或Lock或者原子类。判断并发竞争激烈程度。竞争高时优先考虑加锁配合缩小临界区竞争低时原子类是很好的选择。判断是否需要多个条件等待通知机制。如果需要在锁内等待某个条件满足再执行Condition比synchronized的wait/notify更灵活。这一套判断走下来大多数场景都能找到合适方案。真正的高手不在于会背多少种锁而在于每个场景下能快速判断“非此即彼”是否成立。3. synchronized锁升级与ReentrantLock的分岔口一道选择难题Java并发包里synchronized和ReentrantLock经常被拉到一起对比。有人觉得有了ReentrantLock就不需要synchronized也有人觉得synchronized简单可靠Lock完全是多余的。这两种想法都太极端。从底层看HotSpot虚拟机对synchronized做了大量优化。锁升级过程是偏向锁→轻量级锁→重量级锁。偏向锁的意思是同一个线程重复进入同步块时不需要每次做CAS操作只要检查一下对象头里的线程ID还是不是自己一旦出现第二个线程竞争偏向锁就撤销升级为轻量级锁轻量级锁通过CAS自旋等方式试图避免操作系统级别的线程阻塞如果自旋超过阈值仍然抢不到锁就膨胀为重量级锁进入操作系统内核的互斥量。这套机制的可怕之处在于JVM会根据竞争情况自动调整多数场景下你用它没有额外负担。ReentrantLock的优势在于强大的API能力可响应中断的lockInterruptibly、可超时的tryLock、公平锁与非公平锁的切换、配合多个Condition实现精细化的等待唤醒。还有一点synchronized的wait/notify只能和同一把锁绑定一个等待队列而ReentrantLock的newCondition可以创建多个等待队列这在实现有界缓冲队列、限流器这类逻辑时会清晰很多。我用这么个类比帮助同事理解synchronized像自动挡汽车绝大多数路况下你不需要自己换挡JVM帮你处理一切ReentrantLock更像手动挡懂原理的人可以在特定赛道如高并发下的公平性控制、超时获取锁里开出优势但你要为每一次换挡负责。选择核心依据是如果你的临界区逻辑简单、不涉及超时和中段代码量越小越好首选synchronized。如果需要实时响应中断、限时等待锁、多路条件通知必须上ReentrantLock。如果只是读多写少的场景甚至可以两者都不用直接上ReadWriteLock或者StampedLock减少读线程之间的互斥开销。顺便提醒一句StampedLock并不是万能神器它的乐观读模式在并发更新非常频繁时反复校验版本会导致CPU占用飙升。很多性能测评没有穿透业务量级就直接推荐它实战时要自己做压测再决定。4. 线程池从ThreadPoolExecutor参数到底层队列一次讲透你的“线程人生”线程池是Java并发编程里被使用频率最高也最容易出事的组件。很多人用Executors.newFixedThreadPool一把梭结果生产环境出现线程数失控、任务全部堆积在队列、内存被撑爆这种事故。这不是线程池的错是用法不对。4.1 七大参数与真实业务之间的对应关系ThreadPoolExecutor的七个参数已经在无数文章里被解释过了我这里拿出一个真实业务场景来套一下。假设我要做一个订单超时关单的调度系统每分钟会有大量关单任务投递进来每个任务需要查询订单状态并更新数据库平均耗时约200ms。单机正常吞吐量按每秒处理50个任务算数据库连接池最大20个连接。如果核心线程数我设置成50看起来吞吐量上去了实际上数据库连接池先成了瓶颈一堆线程阻塞在获取数据库连接上CPU和内存还白耗。我把核心线程数定位在16最大线程数32队列容量设置成1000拒绝策略选择CallerRunsPolicy让提交任务的线程在队列满时自己执行这样既保证系统不会因任务堆积而崩溃也给上游一个背压信号。关于参数设置的顺序我的经验是先摸清下游资源的峰值能力再反推线程数而不是先拍脑袋定线程数。很多团队上来就定“核心10最大20”后来压测发现完全不是这么回事。一条核心公式必须记住线程数 CPU密集型任务用CPU核数1IO密集型任务大量阻塞等待可以用CPU核数乘以某个系数但最终必须经过压测修正。4.2 为什么不推荐Executors的快捷方法Executors提供的newFixedThreadPool和newCachedThreadPool有一个共性问题就是队列或线程数不受控。newFixedThreadPool的队列用的是无界LinkedBlockingQueue意味着任务可以无限堆积如果下游宕机生产者还在不停提交任务内存最终被队列撑爆。newCachedThreadPool的思路反过来核心线程数为0最大线程数为Integer.MAX_VALUESynchronousQueue直接提交线程空闲60秒回收如果任务本身耗时较长而提交速度又很快线程会无限创建直接把系统拖垮。我不建议完全禁用Executors工具类但用之前你必须清楚它隐藏了什么参数。公司规范里通常有两条硬性规定线程池必须通过ThreadPoolExecutor显式创建队列必须有界并且设置拒绝策略。这两条到了生产环境真的能救命。4.3 线程池动态调参微服务时代的必修课很多系统上线后流量会波动固定的核心线程数往往不是最优解。Java线程池提供了setCorePoolSize和setMaximumPoolSize我们可以结合监控指标在不重启应用的前提下动态调整线程数这也是大流量批量任务场景下的常用骚操作当任务队列积压超过阈值时通过配置中心下发指令增加核心线程数流量回落后再调低。当然这个动态调整要做成闭环不能人工盯着改否则又是新的不稳定因素。5. 从CompletableFuture到ForkJoin异步编排与分治处理的实战思考线程安全解决后接下来就是如何把多线程用得更优雅。CompletableFuture可以说是Java并发工具里最被低估的一个类它把回调地狱变成了流水线式的链式调用。过去写多阶段异步任务要么用Future一个个get()串起来要么自己写回调嵌套代码又丑又难维护。CompletableFuture的thenApply、thenCompose、allOf、anyOf这些方法能建立一个清晰的任务依赖图。举个例子一个用户下单后的初始化流程创建订单、扣减库存、发送通知、赠送积分。这四个步骤里创建订单是前置条件扣库存和发通知可以并行积分赠送依赖前面两者都完成。用CompletableFuture写出来逻辑层次是相当清楚的。关键点在于需要把每一步单独拆成一个方法返回CompletableFuture让任务之间的编排关系通过组合方法表达而不是把业务逻辑堆在一个大lambda里。ForkJoin则是另一种思路适合把一个大任务拆成一堆子任务并行处理再合并结果。典型应用是数组排序、大文件解析、树形结构遍历。ForkJoinPool自带工作窃取算法某个线程队列空了可以去偷其他线程队列尾部的任务让所有核心尽量保持忙碌。但要注意ForkJoin里的任务如果内部再调用join()或get()阻塞等待很可能会导致队列中其他任务被阻塞出现假死现象这在JDK 8以前已经是臭名昭著的坑了。递归拆分任务时还必须设置一个合理的阈值避免任务拆分到过细的粒度反而增加了无谓的线程切换和任务管理开销。6. 并发容器与Atomic家族在细节里感受并发性能的极限并发包里的各种容器用对了是性能神器用错了就是误导元凶。很多人以为把所有HashMap换成ConcurrentHashMap就万事大吉其实远非如此。ConcurrentHashMap保证的是单个操作的线程安全比如put、get、computeIfAbsent这些单个方法。但如果你要做“先检查是否存在再决定是否插入”这样的复合操作即使每个方法都安全组合起来依然可能出问题。要保证复合操作安全还是要用computeIfAbsent这种原子方法或者在外面加锁。另外ConcurrentHashMap在JDK 8之后使用的是CASsynchronized对桶节点加锁锁粒度非常小。它放弃了JDK 7的Segment分段锁主要原因是分段锁在扩容时需要锁住整个Segment影响面太大。大小、扩容这些操作在JDK 8里也有非常复杂的实现比如扩容时允许多线程协助迁移节点。所以理解这类容器时不要只看使用API还要对内部结构有一定敏感度这样遇到诡异的性能瓶颈时才有个排查方向。AtomicInteger、AtomicLong这些原子类内部是CAS自旋适合做计数器、序列号生成器、限流器的统计字段。但有个经常踩坑的点AtomicLong在高并发下存在严重的缓存行伪共享问题。伪共享的意思是多个变量恰好落在同一个CPU缓存行里某个线程改了其中一个变量会导致其他核心缓存行失效即使其他变量根本没被修改。JDK 8的LongAdder就是为了解决这个问题而生的它在内部维护一组Cell每个线程分散更新自己的Cell最终sum()时汇总在极高并发下性能远超AtomicLong。如果你的场景是统计型而不是强一致型比如QPS计数、热点指标统计直接选LongAdder就好。7. JMM与happens-before你以为在执行代码其实在遵守协议Java内存模型也就是JMM曾经是我学习并发时最头疼的部分因为它看不见摸不着。但后来发现一个速成的角度把它理解为“一套JVM和CPU之间的协议手册”。这套手册约定什么情况下一个线程的写操作对另一个线程可见什么情况下指令不允许重排。happens-before规则里有几条最常用背下来绝对不亏程序顺序规则单线程内每个操作按代码顺序happens-before后续操作但这不妨碍指令重排因为重排后依然保持as-if-serial语义。锁规则对一个锁的解锁操作happens-before后续对这个锁的加锁操作。volatile规则对一个volatile变量的写操作happens-before后续对这个变量的读操作。传递性如果A happens-before BB happens-before C那么A happens-before C。之前提到双重检查锁里的volatile用的就是volatile规则加传递性。看起来只是修饰了一个变量实际上是在修饰一个跨线程的完整同步边界。理解JMM之后你再回头去看synchronized这个关键字会真正明白为什么它能解决可见性和有序性问题它不只是互斥锁它还是一个内存屏障的释放和获取语义。8. 多线程设计模式实战不可变对象、ThreadLocal与生产者消费者模式是一种组合经验。学多线程不能只停留在API层面还要掌握几个经典设计模式。不可变对象模式很好用核心思想是如果一个对象一旦创建后就没有任何字段可以被修改那么它天然就是线程安全的。字符串String就是一个范例。在设计业务值时能用final字段就用final能不提供setter就不提供状态变化时返回新的对象而不是修改旧对象。这样散落在多个线程中的引用完全不需要锁。ThreadLocal是另一种思路每个线程持有变量的独立副本从根源上避免共享。它最常见的应用场景是传递上下文信息比如请求ID、用户ID、事务状态。但ThreadLocal有个非常隐蔽的内存泄漏问题线程池里的线程是复用的如果使用完ThreadLocal不调用remove()当前线程再次执行其他任务时ThreadLocalMap里依然持有上一次任务的值而且这个Entry的key是WeakReference但value是强引用如果线程长期存活value就会一直无法回收。我的个人习惯是在try-finally中手动remove绝不依赖自动清理。生产者消费者模式是最经典的消息解耦方案。Java里的BlockingQueue已经把等待通知机制封装得极其完善ArrayBlockingQueue和LinkedBlockingQueue各有侧重。实际写业务时我们往往会在这之上再套一层流量控制逻辑比如根据队列积压情况动态调整生产速率防止下游消费者被击穿。9. 多线程调优实战从死锁、活锁到假共享一份问题排查链路排查多线程线上问题是最考验人综合能力的场景之一。死锁相对好查通常表现为某个接口持续超时线程dump里能看到互相等待锁的循环关系。活锁比死锁更隐蔽多个线程都在不断尝试获取资源又不断让出CPU被占满但业务毫无进展。假共享最阴险代码逻辑完全正确却因为CPU缓存行层面的冲突导致性能上不去。遇到这类问题我通常按照固定链路排查第一步抓线程dump。用jstack命令排查线程状态重点看BLOCKED和WAITING状态的线程找出锁等待关系。第二步抓堆内存。用jmap和MAT分析是否有大量对象堆积在线程的ThreadLocalMap里。第三步看GC日志。频繁Full GC也可能导致线程长期停顿这是和锁无关但表现类似的另一类问题。第四步配合压测复现。并发问题大概率在特定压力阈值下才暴露压测时保留线程dump和GC日志的现场证据。最怕的是那种偶发问题几天出现一次没有任何规律。这时候我只能在关键路径上加日志重点记录锁等待时间、队列积压量和重试次数然后持续观测。现场日志永远是排查并发问题的第一生产力。10. JAVA并发知识的最终检验面试深挖与真实战斗的差距这一篇是进阶小结我也想把面试这一关单独拿出来说因为“学没学会”最直接的试金石就是同事或者面试官深挖。很多候选人能口吐莲花地讲AQS源码但一问到他线上线程池怎么配的开始含糊其辞一说让他手写一个多线程的生产者消费者代码漏洞百出。这种纸面高手在真实项目里遇到突发流量时会非常狼狈。反过来讲一个只经历过业务开发、没啃过并发源码的人也许把系统稳稳定期跑着但一旦别人问他为什么会话失效、为什么某个接口在高峰时线程全部阻塞他就只能靠猜。这两类人的差距不在于谁多读了几篇源码分析而在于谁通过了“高压场景”的实战检验。我给自己设计的最终检验方法是抛开源码尝试回答这样的问题ThreadPoolExecutor在提交一个任务时如果核心线程没有满会不会先放入队列答案是会优先创建核心线程执行任务。那如果核心线程满了但队列没满会不会创建新线程答案是任务会进队列而不是立刻创建到最大线程。这个顺序和很多人自以为的“队列满了才开新线程”有偏差但它的设计恰恰是为了避免资源的无谓浪费。没有这种细节靠近感多线程就永远停留在知识表面。这篇小结写到这里我从18个知识点开始梳理一路走到线程安全的三座大山、锁选型、线程池、异步编排、并发容器、JMM、设计模式、问题排查最后再到面试检验。回头再看多线程的核心始终不变它不是一堆孤立的API而是一套围绕“如何安全、高效、优雅地共享资源”的完整思维体系。你在项目里真正能写出并发代码是一回事能说清楚为什么这样写是另一回事能根据线上表现反向定位到理论知识那才叫真的学会了。希望这篇小结能帮你把脑子里零散的知识节点再重新钉一钉我们下一篇继续。