JUC并发编程知识地图:从AQS到线程池的系统学习目录
1. 先搞清楚JUC知识地图该怎么分块很多人学JUCjava.util.concurrent的方式是打开IDE点开这个包看到二十多个子包、上百个类然后从第一个类开始往下啃。啃到第三层就放弃了因为每个类背后都牵扯出一堆线程模型、内存可见性、指令重排的知识单点突破根本串不起来。我自己前两年也是这么过来的回头复盘真正的问题不在某个类看不懂而在于手里没有一张靠谱的目录。JUC不是一个包而是一整套并发问题的解决方案集合。它解决的核心问题可以概括成四类线程之间怎么安全地共享数据、多个任务怎么被高效地调度、线程之间怎么协调配合、异步流程怎么编排。你把这四类问题想明白再去对应包里的类目录自然就出来了。这篇内容就是把我自己整理的那份JUC学习目录摊开讲从知识分块到每个块的硬骨头再到线上排查的实操适合刚接触并发的新手也适合写了几年业务代码但一直没系统梳理过的老手。提示这份目录不是让你按顺序全部学完而是给你一张地图。实际工作中遇到阻塞队列的问题你该知道它属于数据共享层往上会影响线程池的调度往下依赖AQS的实现。1.1 为什么先做目录比先背API重要并发编程的知识是网状结构不是线性结构。你今天背了ReentrantLock的lock()和unlock()明天遇到读写场景会发现ReentrantReadWriteLock更合适再往后看到AQS源码才明白这两个类共享同一套底层排队机制。如果一开始没有分层认知你每学一个类都是一次从零开始学十个类就是十次从零开始效率极低。做目录的本质是把记忆负担转化成索引能力。真正的高手也不是把API全背下来而是遇到问题时能快速定位到这属于哪一类问题、对应哪个工具、这个工具的边界在哪。举个真实场景某次线上接口偶发超时日志里有个线程一直卡着不返回。我第一反应不是去看业务逻辑而是判断这大概率是锁竞争或线程池队列积压然后按目录去查对应的监控指标——这种定位速度靠的就是脑子里那张分类图。1.2 我的四层分块法我把JUC相关的内容切成四层从下往上依次是层级内容范围代表类/概念学习难度第一层基础土壤线程生命周期、JMM内存模型、happens-beforeThread、volatile、内存屏障中等第二层互斥与同步锁机制、AQS、CAS、原子类synchronized、ReentrantLock、AtomicInteger高第三层数据与任务并发容器、阻塞队列、线程池ConcurrentHashMap、ArrayBlockingQueue、ThreadPoolExecutor中等第四层协作与编排同步器、异步编排、分治框架CountDownLatch、CompletableFuture、ForkJoinPool中等这个分层的关键逻辑是上层组件几乎都建立在第二层的AQS和CAS之上。你去看ReentrantLock、Semaphore、CountDownLatch、CyclicBarrier的源码会发现它们的内部类都是Sync extends AbstractQueuedSynchronizer。甚至ThreadPoolExecutor的Worker类也继承了AQS。所以第二层是枢纽第一层是地基三四层是应用。AQS吃透了三四层的一大半是水到渠成的事。1.3 每一层该花多少时间我给的建议比例是第一层占20%第二层占40%第三层25%第四层15%。别急着跳到线程池那是很多人的通病——上来就调参、背七大参数结果连内存可见性都没搞明白遇到线程池里的任务丢数据也找不出原因。第二层之所以要占最大比重因为它是从会用到懂原理的分水岭也是面试和线上排查的高频区。2. 线程基础与内存模型所有上层组件的土壤这一层看起来简单实则暗坑最多。很多人觉得new Thread().start()谁不会但问他线程有几种状态、wait和sleep的区别到底在哪、为什么volatile不能保证原子性就答不利索了。基础不牢的地方会在用到锁和线程池时集中爆发。2.1 线程状态与生命周期那些容易混淆的点Java里Thread.State定义了六种状态NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。新手最容易混的是BLOCKED和WAITING前者是等锁后者是主动放弃CPU在等通知。BLOCKED只发生在synchronized竞争上而ReentrantLock的排队等待线程状态其实是WAITING——这是个特别容易答错的点因为都叫等锁但JVM层面状态不同排查线程dump时看错了会走弯路。// 一个验证线程状态的小例子 public class StateDemo { public static void main(String[] args) throws Exception { Object lock new Object(); Thread t1 new Thread(() - { synchronized (lock) { try { lock.wait(); // 进入 WAITING } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } }); t1.start(); Thread.sleep(200); System.out.println(t1.getState()); // 输出 WAITING } }wait()必须在同步块里调用因为它要释放锁sleep()不会释放锁。这俩的区别不是背出来的是理解出来的wait是线程间协作的机制本质是我交出锁等别人通知我sleep只是让当前线程歇一会儿跟锁没关系。2.2 JMM与happens-before三个最容易踩的认知坑第一个坑以为用了volatile就万事大吉。volatile保证可见性和有序性但不保证原子性。count这种复合操作即使count是volatile的多线程下依然会丢更新。正确的做法是用AtomicInteger或者加锁。第二个坑不理解JMM的主内存-工作内存抽象。每个线程有自己的工作内存可以近似理解为CPU缓存和寄存器的抽象共享变量先读到工作内存再操作写完再刷回主内存。可见性问题就出在这个刷回的时机不确定上。第三个坑happens-before规则只记结论不理解传递性。简单说它规定了前一个操作的结果对后一个操作可见。最实用的两条程序顺序规则单线程内前面的操作happens-before后面的和volatile变量规则对一个volatile变量的写happens-before后续对它的读。理解这两条你就能解释为什么用volatile修饰的标志位能让另一个线程及时看到。注意不要试图用加个volatile就能解决并发问题的思路去做设计。并发控制的最小单元是操作而不是变量。3. 锁与AQSJUC最硬的骨头这一层是整套JUC的枢纽也是搜索热词里反复出现的aqs。我的建议是把AQS的源码读三遍以上配合手写一个简易锁比看十篇博客有用。搞清楚AQSReentrantLock、Semaphore、CountDownLatch、ReentrantReadWriteLock全部一通百通。3.1 synchronized的锁升级到底怎么回事synchronized在JDK 1.6之后做了大量优化引入偏向锁、轻量级锁、重量级锁的升级过程。核心逻辑是无竞争时尽量用最低成本维持有竞争再逐级升级。偏向锁只有一个线程反复进入同步块直接在对象头的Mark Word里记下线程ID连CAS都省了。轻量级锁出现第二个线程竞争升级为轻量级锁通过CAS自旋尝试获取。重量级锁自旋失败或竞争激烈升级为重量级锁线程进入操作系统的管程等待涉及用户态到内核态的切换开销大。理解这个升级链条的实际价值在于别在明确高并发的场景下迷信synchronized很快。一旦升级到重量级锁性能会明显下降。但这不代表synchronized不行恰恰相反JDK持续在优化它简单场景下它的代码更简洁、出错概率更低。我的实践原则是能用synchronized解决且竞争不激烈就用它需要超时获取、可中断、公平锁、条件变量再上ReentrantLock。3.2 AQS三要素拆解state、CLH队列、模板方法AQSAbstractQueuedSynchronizer的设计精妙在于它把排队机制和资源获取逻辑分离了。它有三个核心组成部分volatile int state表示同步状态。在ReentrantLock里它代表重入次数在Semaphore里它代表可用许可数在CountDownLatch里它代表剩余计数。CLH变体的双向队列没抢到资源的线程被包装成Node节点入队队头是正在持有资源或已释放的线程后续节点排队等待。模板方法AQS自己实现了acquire、release的排队骨架把tryAcquire、tryRelease这些资源判断逻辑留给子类实现。// AQS的核心骨架简化版帮助理解 public final void acquire(int arg) { if (!tryAcquire(arg) // 子类实现尝试获取资源 acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) // 失败则入队等待 selfInterrupt(); }这个骨架妙在哪妙在公平锁和非公平锁只差tryAcquire里的一行判断。非公平锁直接抢不公平锁先看队列前面有没有人在等。你去看FairSync和NonfairSync的源码差异小到令人惊讶但语义完全不同。3.3 手写一个简易互斥锁来验证AQS的理解光看源码不够动手写一个基于AQS的不可重入互斥锁能帮你确认自己是不是真的懂了。import java.util.concurrent.locks.AbstractQueuedSynchronizer; public class SimpleMutex { private final Sync sync new Sync(); private static class Sync extends AbstractQueuedSynchronizer { // 尝试获取state为0表示空闲CAS改成1即获取成功 Override protected boolean tryAcquire(int arg) { if (compareAndSetState(0, 1)) { setExclusiveOwnerThread(Thread.currentThread()); return true; } return false; } // 尝试释放把自己持有的锁放掉 Override protected boolean tryRelease(int arg) { if (getState() 0) throw new IllegalMonitorStateException(); setExclusiveOwnerThread(null); setState(0); // 注意释放不需要CAS因为只有持有者才能释放 return true; } Override protected boolean isHeldExclusively() { return getState() 1; } } public void lock() { sync.acquire(1); } public void unlock() { sync.release(1); } }写完之后你就能理解为什么释放不需要CAS了——因为只有持有锁的线程才可能走到释放这一步不存在竞争。这个细节不看源码、不动手永远体会不到。3.4 公平与非公平、可重入、超时获取的实现差异非公平锁吞吐量更高因为它允许插队减少了线程唤醒的开销公平锁更公平但每次都要检查队列性能略低。默认用非公平除非你有明确的公平性需求比如防止任务饥饿。可重入的实现方式是获取时如果发现当前持有者就是自己直接把state加1释放时减1减到0才算真正释放。所以ReentrantLock能重入而我上面那个SimpleMutex不能。超时获取靠的是tryAcquireNanos它把自旋和LockSupport.parkNanos结合每次醒来检查是否超时。这套机制在分布式锁的本地实现里也经常被借鉴。4. 原子类与CAS无锁编程的代价CASCompare-And-Swap是无锁并发的基石它依赖CPU的原子指令。AtomicInteger、AtomicReference这些类全部建立在CAS之上。但CAS不是银弹它有自己的代价搞清楚这些代价你才不会滥用。4.1 CAS的ABA问题与解决方案ABA问题的场景是线程1读到值A准备CAS改成C这期间线程2把A改成B又改回A线程1的CAS依然成功但它不知道中间发生过变化。在大多数计数器场景里ABA无所谓但在链表指针、栈顶指针这类结构里ABA会导致严重错误。解决方案是加版本号AtomicStampedReference就是干这个的。AtomicStampedReferenceInteger ref new AtomicStampedReference(100, 0); int stamp ref.getStamp(); // 比较值和版本号都一致才更新 ref.compareAndSet(100, 200, stamp, stamp 1);4.2 LongAdder为什么比AtomicLong快高并发计数下AtomicLong的所有线程抢同一个变量CAS失败重试的次数会随着并发数上升而暴增性能急剧下降。LongAdder的思路是分散热点维护一个base变量和一个Cell数组不同线程更新不同的Cell最后求和时把base和所有Cell加起来。这带来一个取舍LongAdder的sum()不是强一致的它只是近似值。所以它适合做统计比如QPS计数不适合做需要精确值的场景比如扣减库存。我的经验是日调用量比较大的计数场景用LongAdder金融相关的精确计算还是老老实实用锁或AtomicLong。提示LongAdder内部还有个容易忽略的优化——Cell加了Contended注解做缓存行填充避免伪共享。伪共享指的是两个无关变量恰好在同一个CPU缓存行里一个变量被改会导致另一个变量的缓存失效。5. 并发容器与阻塞队列数据共享的正确姿势到了这一层你终于可以不用自己造轮子了。JUC提供了完整的并发容器但每个容器的适用场景都不一样选错比不用还糟。5.1 ConcurrentHashMap的分段到桶锁演进JDK 7的ConcurrentHashMap用分段锁Segment把整个表分成若干段每段一把锁默认16段理论并发度16。JDK 8之后改成CAS synchronized锁单个桶链表头节点并发粒度更细同时引入了红黑树优化长链表。实际使用中要注意size()方法在JDK 8里返回的是估计值因为它需要遍历所有桶累加baseCount和CounterCell期间可能有并发修改。如果你的业务逻辑依赖size的精确性得自己想别的招比如用一个显式的LongAdder维护。另外ConcurrentHashMap不允许null键和null值这一点和HashMap不同。原因是在并发场景下get返回null你无法区分是值不存在还是值就是null会引入歧义。5.2 CopyOnWriteArrayList适合什么场景CopyOnWriteArrayList的原理是写时复制每次修改都拷贝一份新数组改完替换引用。读操作完全无锁性能极高。但代价是每次写都要复制整个数组内存开销大写性能差。它只适合读多写极少的场景比如配置项列表、监听器注册表。如果你拿它做高频写入的队列会直接把内存打爆。我见过有人用它做消息缓冲结果GC频繁到怀疑人生这就是典型的用错工具。5.3 七种阻塞队列的选型对照表阻塞队列是生产者-消费者模式的核心也是线程池的任务载体。选型要点如下队列类型底层结构是否有界锁机制典型场景ArrayBlockingQueue数组有界单锁双条件需要背压的固定容量场景LinkedBlockingQueue链表可选双锁读写分离线程池默认队列PriorityBlockingQueue堆无界单锁按优先级出队DelayQueue堆延迟无界单锁定时任务、缓存过期SynchronousQueue无容量-CAS/栈或队列直接移交CachedThreadPool用LinkedTransferQueue链表无界CAS高吞吐的传递场景LinkedBlockingDeque链表可选单锁双条件工作窃取、双端操作LinkedBlockingQueue用了两把锁分别管入队和出队所以生产和消费能真正并行吞吐比ArrayBlockingQueue高。但也因为它的默认容量是Integer.MAX_VALUE很多人用它做线程池队列却忘了设上限任务堆积到内存溢出才发现问题。注意线程池配无界队列是生产事故的经典配方。任务提交速度持续大于处理速度时队列无限增长最终OOM。6. 线程池八股文背后的参数推算线程池是JUC里被问得最多、也最容易被用错的部分。七大参数能背不代表会用因为真正难的是参数怎么定。6.1 ThreadPoolExecutor七大参数与执行流程七个参数corePoolSize、maximumPoolSize、keepAliveTime、TimeUnit、workQueue、threadFactory、handler。任务提交后的流程是核心线程未满则创建核心线程执行核心线程满了进队列队列满了且线程数未达最大创建非核心线程再满则执行拒绝策略。很多人误以为先创建到max再入队恰恰相反先入队队列满了才扩容到max。这个顺序搞反会导致你配了很大的max却从不生效。ThreadPoolExecutor executor new ThreadPoolExecutor( 4, // corePoolSize 8, // maximumPoolSize 60L, TimeUnit.SECONDS, // 空闲回收时间 new ArrayBlockingQueue(200), // 有界队列 new ThreadFactory() { // 自定义线程工厂便于排查 private final AtomicInteger idx new AtomicInteger(1); Override public Thread newThread(Runnable r) { Thread t new Thread(r, order-pool- idx.getAndIncrement()); t.setUncaughtExceptionHandler((thread, ex) - System.err.println(task error: ex.getMessage())); return t; } }, new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略 );自定义ThreadFactory给线程起有意义的名字是线上排查的救命稻草。线程dump里看到pool-1-thread-3你根本不知道是哪个业务看到order-pool-3瞬间就清楚了。6.2 线程数到底怎么算网上流传两个公式CPU密集型N1IO密集型2N。这两个公式可以当起点但不能照搬。更靠谱的算法是基于目标利用率线程数 CPU核数 × 目标CPU利用率 × (1 等待时间 / 计算时间)举个例子假设8核机器目标利用率80%任务中IO等待占60ms计算占40ms那么线程数 8 × 0.8 × (1 60/40) 8 × 0.8 × 2.5 16这才是真正有依据的推算。但公式只是理论值最终必须靠压测校准。我的做法是先按公式算出理论值然后在压测环境从理论值的70%开始往上调观察QPS、响应时间、CPU使用率三条曲线找到拐点。6.3 拒绝策略与线上事故复盘四种内置拒绝策略AbortPolicy抛异常、CallerRunsPolicy调用者执行、DiscardPolicy静默丢弃、DiscardOldestPolicy丢最老任务。DiscardPolicy是最危险的任务被悄悄丢掉业务方毫不知情等发现数据对不上已经过去几天了。我倾向于用CallerRunsPolicy它会让提交任务的线程自己执行天然形成背压提交速度被迫降下来不会丢任务。缺点是可能阻塞主线程所以对延迟敏感的场景要慎用。有过一次事故某个导出接口用了DiscardPolicy高峰期任务被丢用户点导出没反应客服电话打爆。后来改成CallerRunsPolicy高峰期接口变慢但用户能看到进度条在走投诉反而少了。有时候慢比丢更可接受。7. 同步器工具箱CountDownLatch到CompletableFuture这一层是协作与编排解决的是多个任务怎么配合的问题。7.1 四个经典同步器的适用边界CountDownLatch一次性倒计数。主线程等N个子任务完成await()阻塞到计数归零。不能重用用完就废。CyclicBarrier可循环的栅栏。一组线程互相等待全部到达后一起继续且能重置复用。适合分阶段计算。Semaphore信号量控制同时访问资源的线程数。适合限流、连接池控制。Phaser更灵活的同步器支持动态注册/注销参与者适合分阶段且参与方会变化的场景。CountDownLatch和CyclicBarrier最大的区别是前者是一个等多个后者是多个互相等。用错了会很别扭。7.2 CompletableFuture编排实战CompletableFuture是异步编排的利器能把多个异步任务串起来还不用回调地狱。// 并行调用三个远程接口全部完成后合并结果 CompletableFutureUser userFuture CompletableFuture.supplyAsync(() - userService.get(id), executor); CompletableFutureOrder orderFuture CompletableFuture.supplyAsync(() - orderService.get(id), executor); CompletableFutureCoupon couponFuture CompletableFuture.supplyAsync(() - couponService.get(id), executor); CompletableFutureVoid all CompletableFuture .allOf(userFuture, orderFuture, couponFuture); all.thenRun(() - { User u userFuture.join(); Order o orderFuture.join(); Coupon c couponFuture.join(); // 合并返回 }).exceptionally(ex - { // 任意一个失败都走这里 log.error(aggregate failed, ex); return null; });关键点join()和get()都阻塞取结果区别是join()抛的是非受检异常在lambda里用起来更顺手。另外allOf本身不返回结果它只保证所有任务完成你需要自己拿着每个future去取。一定要给异步任务传入自定义线程池否则默认用ForkJoinPool.commonPool()多个业务混用会互相影响一个业务卡住会拖累所有业务。这是我踩过的坑血的教训。8. 常见问题与排查技巧实录理论学得再好线上不出问题才怪。这一节全是实战排查经验。8.1 死锁排查三步定位法死锁的典型场景是两个线程互相持有对方需要的锁。排查步骤jps -l找到Java进程PID。jstack pid dump.txt导出线程栈。搜索Found one Java-level deadlockJVM会自动帮你把死锁的线程和锁信息列出来。看到类似输出就能直接定位到代码行。预防死锁的三条原则固定加锁顺序、尽量用tryLock带超时、缩小锁的范围。加锁顺序尤其重要两个方法按不同顺序获取同一批锁就是等着死锁。8.2 CPU飙高排查先看线程再看栈CPU打到100%时按这个顺序来top -Hp pid # 找出占CPU高的线程TID printf %x\n tid # 把TID转成十六进制 jstack pid | grep 十六进制tid -A 30定位到具体线程和栈帧后常见原因有几类死循环、频繁GC、大量线程上下文切换。如果是GC task thread占用高说明内存有问题去查堆如果是业务线程栈顶是某个循环方法就是死循环或算法复杂度爆炸。8.3 常见问题速查表现象可能原因排查方向线程卡住不返回死锁、锁竞争、线程池队列满jstack看线程状态数据偶尔丢失更新非原子操作、可见性问题检查count类操作OOM队列无界、缓存无淘汰看堆dump检查队列容量接口超时且CPU不高线程池队列积压监控队列size调线程数并发下偶发NPE对象发布不安全检查是否用final或同步发布GC频繁LongAdder类对象过多or大对象看GC日志分析对象分配提示线上排查的黄金顺序永远是先看现象CPU/内存/线程数再看线程栈最后看代码。直接扑向代码是最没效率的做法。最后分享一个我自己的体会JUC这东西光看不练等于没学。我每次学一个新类都会逼自己写一个能复现问题的DEMO然后想办法让它出问题再用工具去排查。比如学ConcurrentHashMap就写个多线程计数丢数据的例子学线程池就故意把队列设小触发拒绝策略。主动制造问题比被动等线上出问题强太多。这份目录你可以存着每啃完一层回来打个勾两三周之后再看整个JUC的脉络在你脑子里就不再是散的而是一张能随时调用的网。