
JUC并发编程这块很多同学学起来容易卡在两个地方一是觉得synchronized已经够用不知道JUC这一堆工具类到底解决什么问题二是类太多、API太杂背了源码转头就忘。这篇是JUC速成下篇我直接按底层原理、常用组件到实战配置的顺序讲把volatile、CAS、AQS、锁、并发工具类、线程池、并发容器这些核心点一次理透给已经写过一点并发代码、想快速建立JUC知识框架的开发者看。学完这篇你再去翻源码或者看别的博客会发现很多细节其实都围绕着同一套底层逻辑打转不会再有那种“每个字都认识连起来不知道在说啥”的感觉。1. 必须先搞懂的底层三件套volatile、CAS、AQS1.1 volatile不是锁但它管住了“可见性”和“有序性”很多JUC工具类的底层都依赖volatile比如AQS里的state就是volatile的ReentrantLock里很多状态标志也是volatile。volatile到底干了啥两个作用可见性和有序性。可见性指的是一个线程改了共享变量其他线程能立刻看到最新值不会因为CPU缓存、指令重排导致读到旧值。有序性指的是它在编译器和CPU层面禁止指令重排序避免一些看似无害的代码在并发下出现诡异行为。但必须说清楚一个点volatile不解决原子性。经典的count问题就算用volatile修饰count两个线程同时执行count最后还是会漏掉加一。为啥因为count不是一个原子操作它分三步读count、加一、写回count。线程A读到1线程B也读到1A写回2B也写回2最终count变成了2但两次累加期望值是3。volatile只能保证读的时候能拿到最新值保证不了“读-改-写”这个组合过程不被其他线程穿插。我在实际项目里见过一个很典型的误用有人用volatile做计数器上线后发现统计的请求量和真实差了很多。排查了很久才明白是原子性问题。要做并发计数器老老实实用AtomicInteger或者LongAdder别在这个场景用volatile。volatile适合的场景是“一个线程写多个线程读”的状态标志比如开关标记、初始化完成的标志位、缓存刷新通知这一类。1.2 CAS自旋乐观锁思路的底层实现CAS全称Compare-And-Swap也有叫Compare-And-Set的它做的事情很简单比较当前内存值是否等于期望值如果相等就改成新值整个过程由CPU底层指令比如x86的cmpxchg保证原子性。用CAS实现并发控制本质上是一种乐观锁思路不直接加锁阻塞线程而是假设冲突概率低先尝试更新失败再重试。CAS有两个需要特别注意的问题。第一个是ABA问题一个值从A变成B再变回A另一个线程拿到的期望值还是ACAS成功了但中间其实被别的线程动过。解决方式是带版本号比如AtomicStampedReference每次修改带一个版本号比较的时候比较“值版本号”两个维度。第二个问题是自旋空转高并发下如果CAS一直失败线程就会一直在循环里尝试白白消耗CPU。我写过一个用AtomicInteger做序列号生成的组件低并发下表观正常峰值时段CPU飙得很高后来换成LongAdder才把压力降下来。理解CAS的“比较-交换”模型对看JUC源码特别有帮助。比如AtomicInteger.incrementAndGet的底层就是用CAS自旋读旧值CAS改成新值失败就重读再试。如果你能看懂这段逻辑后面看ConcurrentHashMap的put流程会轻松很多。1.3 AQS整个JUC同步工具的灵魂JUC里一半的类都直接或间接建立在AQSAbstractQueuedSynchronizer上。ReentrantLock、CountDownLatch、Semaphore、ReentrantReadWriteLock、ThreadPoolExecutor里的Worker底层全是AQS。AQS做的事情不复杂可以拆成两块第一块是持有一个volatile int state表示同步状态。这个state的含义由子类自己定义。ReentrantLock里state表示锁被重入的次数Semaphore里state表示剩余许可证数量CountDownLatch里state表示还要倒数的计数。第二块是一个FIFO的等待队列CLH变体。当一个线程获取同步状态失败时会被包装成Node节点加入队列尾部然后通过LockSupport.park把自己挂起前面节点释放同步状态时会唤醒后继节点继续尝试获取。理解AQS要抓住三个可重写方法tryAcquire、tryRelease、isHeldExclusively。这三个方法在AQS基类里默认是抛UnsupportedOperationException的子类按需重写。比如ReentrantLock重写tryAcquire尝试把state从0改成1非重入场景CountDownLatch重写tryAcquire判断state是否等于0等于0说明倒计时结束可以放行Semaphore重写tryAcquire尝试扣减state的值。AQS还有一个容易被问到细节队列是双向链表节点状态waitStatus有CANCELLED、SIGNAL、CONDITION、PROPAGATE几种。CANCELLED标记节点取消等待SIGNAL表示后继节点需要被唤醒。源码里很多if判断都在处理取消和唤醒的状态流转这些细节面试常考但如果你只是日常使用JUC工具类知道“AQS用state表示资源状态、用FIFO队列管理等待线程”就够了。2. 锁体系从ReentrantLock到读写锁再到StampedLock2.1 ReentrantLock相比synchronized多了什么synchronized是JVM层面的锁关键字语法简单JDK6之后做了偏向锁、轻量级锁、重量级锁的升级优化大部分场景性能并不差。那ReentrantLock为什么还要存在因为它在使用灵活性上提供了synchronized没有的几个能力可中断、可超时、可公平、多Condition队列。可中断指的是lockInterruptibly()方法线程在等待锁的过程中如果收到中断信号可以响应中断并退出等待synchronized的等待不可中断。可超时指的是tryLock(long time, TimeUnit unit)拿不到锁可以在指定时间后放弃避免无限期阻塞。公平锁指的是构造时传入true让锁按照等待顺序分配避免线程饥饿。多Condition队列则是解决“生产者消费者”里一个锁需要多个等待队列的问题比如一个队列满时生产者等“不满”条件消费者等“不空”条件。我在实践里有个经验能用synchronized就优先synchronized因为代码短、不容易出错JVM层面还会自动释放锁。只有当业务明确需要可中断、可超时、公平锁、多条件队列时才上ReentrantLock。我踩过一个坑把tryLock写成了if判断而不是while循环在竞争激烈时偶发拿不到锁直接走了else分支业务没执行完还不报错排查了很久才发现是获取锁失败后逻辑继续往下跑了。现在我的习惯是tryLock一定要配合循环重试或优雅降级拿到锁放finally里unlock避免锁泄漏。2.2 ReentrantReadWriteLock读读不互斥读写互斥ReentrantReadWriteLock把锁拆成读锁和写锁。读锁是共享锁多个线程可以同时持有写锁是排他锁只能一个线程持有。规则是读读不互斥、读写互斥、写写互斥。这个特性特别适合缓存的读写场景大量线程并发读缓存偶尔一个线程更新缓存。如果用普通ReentrantLock所有读操作都要排队吞吐量被打得很惨用读写锁读并发可以完全放开。这里要特别提醒一个坑读锁不能升级成写锁。假设两个线程同时持有读锁它们都尝试升级成写锁就会互相等待对方释放读锁形成死锁。ReentrantReadWriteLock在API层面上没有提供读锁升级为写锁的路径你只能先释放读锁再拿写锁但这样中间会有窗口期。如果需要读后写、并且对数据一致性要求很高可以考虑StampedLock或者直接用更上层的ConcurrentHashMap原子方法来替换。2.3 StampedLock乐观读让读操作几乎零成本StampedLock是JDK8加入的最大的特点是乐观读。什么叫乐观读读线程先不真正加锁而是记录一个stamp一个long值然后直接去读数据读完再检查stamp有没有变化。如果stamp没变说明读的整个过程没有写线程介入这次读是安全有效的如果stamp变了说明读期间发生过写入再升级成悲观读重试。这个机制对“读多写少、读操作耗时短”的场景特别合适。我在生产环境用它优化过一个本地配置缓存之前用ReentrantReadWriteLock虽然读并发已经放开但每次读还是要走一次锁操作换StampedLock后读路径几乎就是一次内存读加一次stamp校验吞吐量提升很明显。不过它的API比较反人类每次调用都要带着stamp参数代码里稍不注意就会漏掉unlock导致锁泄漏甚至死锁用之前一定要反复检查流程。3. 并发工具类CountDownLatch、CyclicBarrier、Semaphore3.1 CountDownLatch一次性倒计时门闩CountDownLatch的模型是“一个线程等待多个线程完成”。构造时传一个计数器值比如new CountDownLatch(3)表示要等3个线程。每个子线程做完任务后调用countDown()把计数器减一等待的线程调用await()阻塞直到计数器归零才放行。经典使用场景是启动时预热主线程要等几个子线程把缓存、连接池、配置中心全部加载完成后再对外提供服务。用CountDownLatch实现非常自然主线程await子线程各自加载完成后countDown。我写代码时有个习惯countDown要放在finally块里防止子线程执行中抛异常导致计数不减少主线程永远阻塞。还有一个点必须记住CountDownLatch的计数器归零后不能复用它是一次性的。如果你想复用要么重新new一个要么考虑CyclicBarrier。3.2 CyclicBarrier可循环使用的屏障CyclicBarrier是“N个线程互相等待全部到齐后再同时继续”。和CountDownLatch最直观的区别有两个第一CyclicBarrier是线程之间彼此等待而不是一个线程等其它线程第二它可以重置复用。构造方法还可以传入一个Runnable barrierAction在所有线程到达屏障时由最后一个到达的线程执行这个动作。一个典型的例子多线程分块计算一个大数组的和每个线程算一块全部算完后最后到达的线程负责把各块结果合并再进入下一轮计算。这个合并动作就可以放在barrierAction里代码比手动写同步逻辑清爽很多。CyclicBarrier有个容易被忽略的细节如果某个线程在等待过程中被中断或者超时屏障会被打破BrokenBarrierException其它线程等待也会异常退出。所以使用时要考虑异常处理不能只盯着正常流程。3.3 Semaphore信号量限流Semaphore本质上是共享锁的数量控制构造传入许可证数量acquire()拿许可release()归还许可。适合限流场景比如数据库连接池最多10个连接、一个接口最多允许N个并发请求。它也支持公平/非公平模式公平模式按等待顺序发放许可。用Semaphore有个容易出问题的地方release()方法即使当前线程没有拿到许可也可以调用它只是把许可证数量加一。如果你在代码里让每个线程无条件release又没注意调用次数许可证数量会一直上涨限流就形同虚设了。正确做法是acquire和release严格成对出现release放finally里数量必须等于拿到的许可数量。3.4 工具类选型对比这三个工具类经常被拿来比较核心还是看场景CountDownLatch是“一个线程等N个线程做完”CyclicBarrier是“N个线程互相等待并可以复用”Semaphore是“控制同时访问资源的线程数”。选错的情况我在评审代码里见过不少比如有人用CountDownLatch模拟多个线程并发起跑其实这种事CyclicBarrier更合适。多花两分钟想清楚场景后面省很多调试时间。4. 并发容器ConcurrentHashMap、CopyOnWriteArrayList、BlockingQueue4.1 ConcurrentHashMapJDK8以后锁粒度细到了“桶”JDK7的ConcurrentHashMap是Segment分段锁加HashEntry数组分段锁把整个map分成多段每段一把锁并发度受Segment数量限制。JDK8彻底换了一套思路弃用分段锁改用Node数组加CAS加synchronized。锁粒度从“段”缩小到“单个桶”只有在哈希冲突需要操作链表或红黑树时才锁住桶的头节点。扩容也改成了多线程协同搬运不再需要独占整个数组。实际开发中我比较推荐多用ConcurrentHashMap提供的原子方法比如computeIfAbsent而不是先get判断再put。普通HashMap时代的“先查再插”思路放在多线程下容易重复初始化同一个key的对象造成资源浪费或数据覆盖。还有一点ConcurrentHashMap的size()和mappingCount()在并发下是近似值如果对精确值有硬要求应该用LongAdder等外部计数来维护。4.2 CopyOnWriteArrayList写时复制思路的利与弊CopyOnWriteArrayList的处理方式非常极端读时不加锁直接读数组写时复制一份新数组改完再把引用替换。好处是读性能极高特别适合读多写极少的场景比如监听器列表、配置项列表。坏处也很明显每次add都会复制整个底层数组频繁写入时CPU和内存开销很大长时间大列表写入还容易引发频繁GC。还有一个细节容易被忽略CopyOnWriteArrayList的写操作本身是要加锁的否则并发写时会复制出多份数组互相覆盖。所以它适合的绝不是“写很频繁”的场景。如果有大量数据持续写入又需要多线程读优先考虑ConcurrentHashMap或者其他分段结构。4.3 BlockingQueue生产者消费者之间的一座桥阻塞队列是JUC里非常实用的容器常用类型有ArrayBlockingQueue、LinkedBlockingQueue、SynchronousQueue。ArrayBlockingQueue基于数组有界必须指定容量内存可控LinkedBlockingQueue默认无界容量随便涨简单但风险也大SynchronousQueue没有缓冲区生产者和消费者直接对接一个put必须等一个take适合缓存要求极低的场景。在线程池源码里阻塞队列的作用就是存放待执行任务。为什么生产环境推荐有界队列因为无界队列在任务积压时内存会失控进程可能被拖到OOM。阻塞队列本身也有阻塞能力queue.take()在队列为空时会挂起线程这正是“生产者消费者”模型的完美基建。我用LinkedBlockingQueue做业务削峰的时候会通过监控队列积压数量来触发告警比让任务无限堆在内存里安全得多。5. 线程池吃透ThreadPoolExecutor的七个参数5.1 核心参数别用默认值拍脑袋ThreadPoolExecutor有七个参数corePoolSize、maximumPoolSize、keepAliveTime、unit、workQueue、threadFactory、handler。corePoolSize是常驻线程数maximumPoolSize是线程数上限keepAliveTime是超出核心线程数的线程空闲存活时间workQueue是任务队列threadFactory负责创建线程自定义命名很重要handler是任务满时执行拒绝策略。这里必须提一个高频误用直接newFixedThreadPool。它的workQueue是LinkedBlockingQueue默认无界任务堆积时内存会一直上涨。文档里其实都提示了不建议直接使用Executors的便利工厂方法但很多人还是图省事。处理并发任务时我建议所有线程池都显式构造ThreadPoolExecutor参数逐个想清楚哪怕麻烦一点也比线上出故障强。5.2 线程数到底怎么定线程数设置没有万能公式但有经验法则可以依循。CPU密集型任务线程数设为CPU核心数1就够因为再多线程也抢不到CPU执行权反而增加上下文切换。IO密集型任务线程数可以明显调高因为线程大部分时间在等待IO让更多线程在等待期间排队能提高CPU利用率。粗略经验是IO密集型的线程数是CPU核心数的2倍起更精确的算法是“核心数/(1-阻塞系数)”阻塞系数表示IO时间占总耗时的比例。我自己实际工作中的节奏是先按经验值设一个数然后用压测去验证观察CPU利用率、队列积压、线程活跃数再调整。与其花时间死磕公式不如把监控指标做好数据会告诉你应该设多少。5.3 拒绝策略别让任务无声无息丢掉ThreadPoolExecutor默认拒绝策略是AbortPolicy直接抛RejectedExecutionException如果你没处理任务就会失败。另外还有CallerRunsPolicy交回给提交任务的线程自己执行DiscardPolicy直接丢弃DiscardOldestPolicy丢弃队列中最老的任务再重试提交。生产上我常用CallerRunsPolicy。它有一个隐藏好处调用方线程自己执行被拒绝的任务时提交线程会被拖慢提交速度自然降下来相当于一个反馈机制不会让任务暴丢。唯一要注意的是被拖慢的线程如果也在业务关键链路要评估延迟影响。5.4 线程池监控怎么落地线程池本身没有内置监控面板但可以通过getPoolSize、getActiveCount、getQueue().size()这些方法去获取状态。我写过一个小程序定时打印这批指标配合日志系统就能观察到线程池的运行节奏。重点是看两个东西队列积压是否持续上涨线程活跃数是否长期接近最大值。只要这两个指标稳得住线程池基本就没问题如果指标持续恶化就要考虑调整核心线程数或加容量了。6. 原子类与LongAdder无锁并发计数的最优解6.1 原子类的基本用法与局限AtomicInteger、AtomicLong、AtomicReference这些原子类底层全部依赖CAS。用它们做简单计数器、ID生成器、状态标记非常方便。AtomicReference支持CAS更新整个对象引用搭配AtomicStampedReference能解决ABA问题。使用原子类也要注意场景。incrementAndGet在高并发下自旋次数会增加因为竞争同一变量的线程越多CAS失败的次数也越多CPU空转就越明显。如果只是简单计数器并发量不高直接用没问题并发量上到几十万甚至百万级别就要考虑LongAdder了。6.2 LongAdder把热点拆散用空间换性能LongAdder的思路是把单个value拆成一段段Cell数组线程通过hash散列到不同的Cell上累加需要取值时再把所有Cell的和汇总。这样多个线程不会抢同一个热点变量CAS竞争被摊薄到了各个Cell上。我在一个日志统计系统里用过LongAdder之前是用AtomicLong做曝光数累加峰值每秒几十万次请求时CPU空转很严重。换成LongAdder后代码只改了一行CPU占用明显下降。要注意它的sum()不是强一致的但统计计数场景一般都能接受。如果你要的数据必须精确到每一个原子次数用LongAdder会踩坑那就乖乖用AtomicLong。7. CompletableFuture让异步编排变得像流水线7.1 从Future到CompletableFuture传统的Future.get()是阻塞获取结果多个任务编排起来要么嵌套get要么用CompletionService代码可读性很差。CompletableFuture直接把回调编排做成了APIthenApply、thenAccept、thenCombine、allOf、anyOf这些方法让异步链路可以像流水线一样写。一个典型场景一个页面需要同时拉用户信息、订单信息、推荐列表三个接口之间没有依赖关系。用CompletableFuture.allOf同时发起三个异步请求谁先回来谁先处理全部完成后统一组装。如果有依赖关系比如先拿用户ID再查订单详情用thenCompose或thenCombine也很直观。7.2 异常处理和默认线程池的坑CompletableFuture的异常处理是个经典坑点如果异步回调里抛出异常且没有调用exceptionally或handle去兜底异常会被吞掉进程甚至不会感知到。我的习惯是每条异步链路末尾都挂exceptionally要么返回默认值要么打日志。还有一个容易忽略的点CompletableFuture默认使用ForkJoinPool.commonPool所有异步任务共享这个池子。如果某个任务阻塞或耗时过长整个进程的异步任务都会被拖慢。耗时IO任务一定要自定义线程池传入API里都有带Executor的重载版本别嫌麻烦。8. 常见并发问题的排查思路8.1 死锁怎么定位JUC场景下的死锁常见于锁顺序不一致、读锁升级、多把ReentrantLock交叉获取。排查死锁最快的手段是jps拿到进程pid再用jstack看线程栈。jstack输出里如果出现Found one Java-level deadlock后面会直接列出线程A持有哪个锁、等待哪个锁线程B持有哪个、等待哪个一眼就能看到环。拿到这个信息后再回代码里调整锁顺序基本就能解决。8.2 线程数飙高怎么排查线程数一路飙高先看是不是有大量地方还在用new Thread而没有走线程池。如果有优先全部替换为线程池。再看线程池参数本身maximumPoolSize是不是设得太大队列是不是无界导致任务不断积压。执行jstack后看到一堆线程名称都是pool-xxx-thread-1这种就能快速定位是哪个池子。日志里线程名一定要用threadFactory自定义不然排查真的太痛苦。8.3 并发容器选型错了怎么办我看到过不少用错容器的案例写多的场景用CopyOnWriteArrayList线程不安全的HashMap在多线程下put做顺序消费却用ConcurrentHashMap自己拼队列。简单原则可以这样记读远多于写用CopyOnWriteArrayList写多读少用ConcurrentHashMap顺序消费用BlockingQueue。代码评审时看到这类问题要尽早改因为并发容器选型错了后续优化成本非常高。我个人在带团队做并发优化时最深的感受是JUC的类再多只要把volatile、CAS、AQS这三块地基打牢看到新工具类就能举一反三。另一个体会是别遇到并发问题就上锁能用无锁数据结构就用无锁能用阻塞队列削峰就削峰能拆分成多个state就用更轻的方案锁永远只是最后的手段而不是万能的灵药。这篇总结写到的每一块都是我实际开发里真刀真枪趟过的路希望能帮你省点时间、少踩几个和我一样的坑。