
做Java并发开发这些年JUCjava.util.concurrent包是我觉得最值得反复啃的一块。它不像基础语法那样背几个API就能上岗也不像框架那样配好依赖就能跑它逼着你真正想清楚一件事多线程到底在争什么。很多人学了synchronized就觉得会并发编程了一碰到真实项目里的性能瓶颈、线程安全问题、任务排队策略照样手足无措本质就是没把JUC这套组合拳吃透。这篇是JUC高并发编程系列的中篇聚焦并发包的核心机制、Atomic原子类、并发容器、AQS锁体系以及线程池适合已经掌握线程基础和synchronized用法、想进一步理解JUC底层设计和实战取舍的同学。我会把每个机制背后的“为什么”讲清楚附上我实际项目中踩过的坑和排查思路尽量让这篇能直接拿去用。1. 先搞清楚JUC到底在解决哪三件事很多初学者一上来就背ConcurrentHashMap、CopyOnWriteArrayList的API问起来都能说几句但一换场景就不知道怎么选。我建议先把并发编程的三个核心问题刻在脑子里可见性、原子性、有序性。JUC里几乎每一个类都是针对这三个问题中的某一个或某几个给出的解法。1.1 可见性线程之间能不能看到对方改的东西可见性问题源于CPU缓存和内存之间的不一致。每个线程在工作的时候会把共享变量拷贝到自己的CPU缓存或线程工作内存里修改后不一定马上刷回主内存。结果是线程A改了某个变量线程B循环里读到的还是旧值程序就卡在死循环里出不来。我最早被这个问题坑是在一个分布式调度项目里用boolean标志位控制消费者线程的暂停和恢复主线程改了标志位消费者线程完全不响应。后来加了volatile才解决。这里要记住一个关键结论volatile保证的是可见性和有序性但完全不保证原子性很多人栽就栽在这里。1.2 原子性check-then-act这种操作有多危险原子性指的是一个操作要么全部执行成功要么全部不执行中间不能被其他线程插一脚。最经典的例子就是count它看起来是一行代码实际上是“读取-加一-写回”三步操作。两个线程同时读到count10各自加一写回结果可能是11而不是12这就是丢了更新。更隐蔽的是check-then-act模式比如if (map.get(key) null) { map.put(key, value); }。两个线程同时判断key不存在然后都执行put就会产生覆盖。很多Cache失效重建的场景出现重复请求打到数据库基本都是这种问题。解决原子性问题的手段就是加锁或者使用JUC里的Atomic原子类。1.3 有序性编译器和CPU偷偷帮你重排有序性问题最反直觉因为单线程里代码是按顺序执行的但编译器为了优化指令流水线或者CPU为了乱序执行可能把没有依赖关系的指令重新排序。在单线程下没问题多线程下就可能出诡异的现象。最典型的案例就是双重检查锁DCL单例模式中的instance new Singleton()这一步在字节码层面是“分配内存-调用构造器-赋值引用”三步指令重排后可能“分配内存-赋值引用-调用构造器”另一个线程拿到非null但未完成初始化的对象一用就崩。解决手段就是给引用加volatile禁止重排。Java内存模型JMM里的happens-before规则本质上就是把这些复杂的可见性、有序性约束整理成程序员能理解的几条规则。2. volatile与CASJUC最底层的两块砖2.1 volatile的适用范围与实战禁忌volatile是JUC里最轻量的同步机制只做三件事修改后立即刷回主内存、其他线程读的时候强制从主内存读、禁止指令重排。它适合“一个线程写、多个线程读”的标志位、状态开关场景。但volatile有三个禁区必须记住不保证原子性volatile int count; count照样线程不安全。不适用于“先检查后执行”的依赖场景比如if (flag true) { doSomething(); }判断和后续动作中间状态可能已经被改了。复合操作全部别用volatile硬撑比如累加、比较并交换、集合size判断等。我见过一个线上事故业务方用volatile的HashMap做缓存读写并发高之后偶发数据错乱排查到最后发现是被volatile的“可见性”表象误导以为加了volatile就万事大吉。记住volatile替代不了锁它只是最上层的那层“轻纱”。2.2 CAS的实现原理与自旋代价CASCompare And Swap是Atomic原子类和很多并发容器实现原子性的基础。它做的事情是比较内存中的当前值是否等于期望值如果相等就更新为新值否则就重试。这个操作是CPU指令级原子操作不需要加锁。JUC里大量使用Unsafe类的compareAndSwapInt这类方法实现CAS。以AtomicInteger的incrementAndGet为例实际是do-while循环里不断尝试CAS失败就重新读取最新值再试这个不断重试的过程叫自旋。自旋的好处是没有线程上下文切换的开销在低竞争场景下性能非常高坏处是如果竞争非常激烈大量线程空转消耗CPU性能反而断崖式下跌。所以你看LongAdder的设计就很有意思它在高竞争场景下把单一value拆成多个cell每个线程往自己的cell里累加最后再求和本质上就是给CAS的“竞争热点”做了拆分。实际项目中如果统计接口调用次数、QPS计数这类高并发写的场景我一般直接用LongAdder而不是AtomicLong。2.3 ABA问题看得见的坑少有人中招CAS有一个经典缺陷叫ABA问题线程A读到值1线程B把值改成2又改回1线程A再CAS时发现还是1比较成功就更新了但中间其实发生过变化。大多数业务场景下ABA问题影响不大但涉及无锁数据结构、链表指针时就可能出问题。解决ABA问题的常规手段是加版本号AtomicStampedReference就是这么设计的它内部同时维护引用和版本号每次CAS都要同时比较引用和版本号。我在实际项目中做无锁栈的时候遇到过类似问题用AtomicStampedReference带版本号的CAS方案解决代价是代码复杂度上升不少。如果业务场景对中间状态不敏感就别为了防ABA硬上版本号过度设计也是坑。3. 并发容器别再用HashMap和ArrayList扛并发量了3.1 Atomic工具类只是开始真正的容器战场Atomic原子类解决的是“单点计数”这类问题一旦涉及复杂的数据结构比如并发读写的Map、队列、List就需要专门的并发容器。很多新人图省事直接给HashMap加个synchronized就当并发容器用小流量没问题流量一上来锁竞争就爆炸。JUC的并发容器设计核心思路是两类一是降低锁粒度比如ConcurrentHashMap的分段设计JDK7和CAS锁升级JDK8另一种是读写分离的最终一致性比如CopyOnWriteArrayList的写时复制。理解清楚这两类思路遇到具体场景就知道怎么选型。3.2 ConcurrentHashMap并发版的HashMap到底强在哪JDK8的ConcurrentHashMap放弃了JDK7的分段锁设计改为CASsynchronized的组合数组元素为空时用CAS初始化Node冲突时给这个桶的头节点加synchronized锁。锁粒度从“段”降到了“单桶”并发度大幅提升。它的size()方法也不像老版本那样简单而是用baseCount加CounterCell数组的方式统计低并发时用baseCount累加高并发时分散到CounterCell里最终求和。所以ConcurrentHashMap的size()是个近似值不适合做需要精确计数的业务判断这一点很多面试官喜欢问。实际使用中的几个要点key和value都禁止为null源码里直接NPE。这和HashMap允许null是完全不同的设计取舍因为并发场景下无法区分“值为null”和“不存在”。迭代器是弱一致性的迭代过程中其他线程修改了map迭代器不一定会马上反映也不抛ConcurrentModificationException。所以我做缓存遍历、批量操作时提前预估到这个特性。复合操作比如putIfAbsent、compute这些才是原子操作先containsKey再get这种两步走一样有竞态。3.3 CopyOnWriteArrayList与BlockingQueue的实际定位CopyOnWriteArrayList的核心思想是读操作不加锁直接遍历当前数组快照写操作加锁并复制一份完整数组修改完再把数组引用整体替换。它读多写少时性能极好适合缓存黑白名单、配置列表、监听器列表这类场景。但它有两个硬伤一是每次写都复制整个数组元素量大或写频繁时内存开销和GC压力巨大二是读到的数据可能不是最新值因为读的是旧快照。很多人拿它当普通ArrayList用写多读少的场景直接内存暴涨我就是在这个坑上吃过亏后来改成读写锁才解决。BlockingQueue则是另一类思路它在线程间传数据时天然支持阻塞等待ArrayBlockingQueue是有界的LinkedBlockingQueue默认无界但也可以指定容量。线程池的等待队列底层就是BlockingQueue选错了队列类型线程池的整个行为都会变样这个放到线程池章节细讲。4. 锁的进阶ReentrantLock、AQS与三个协作类4.1 ReentrantLock和synchronized怎么选synchronized是JVM层面的关键字JDK6之后经过锁升级优化性能已经不输ReentrantLock多少。但ReentrantLock提供了几个synchronized没有的能力可中断获取锁、超时获取锁、尝试非阻塞获取锁、多个Condition条件队列、公平锁。我在实际项目里用的是这个准则如果只是简单的代码块互斥用synchronized代码简洁、不会忘记解锁如果需要控制超时、需要多个等待条件、需要公平排队用ReentrantLock。特别是调用外部接口、数据库这类不确定耗时的场景lock.tryLock(3, TimeUnit.SECONDS)能有效避免线程无限期阻塞这是我做资源申请模块时最常用的手段。ReentrantLock的公平锁和非公平锁细节也值得注意。公平锁能保证等待时间最长的线程先拿到锁避免线程饥饿但吞吐量低。非公平锁允许新来的线程抢占锁吞吐量高但可能让老线程一直等。实际场景里我基本用默认的非公平锁只有在对响应顺序有硬性要求时才切公平锁。4.2 AQS是怎么把“等待-通知”做成通用框架的AQSAbstractQueuedSynchronizer是JUC锁和同步工具的共同底层ReentrantLock、Semaphore、CountDownLatch内部都继承了它。它的核心机制就两部分一个volatile int类型的state变量表示同步状态和一个CLH变体的双向等待队列管理被阻塞的线程。以ReentrantLock为例lock()就是尝试用CAS把state从0改成1成功就拿到锁失败就把当前线程包装成Node挂到队尾并阻塞。unlock()则是把state改回0同时唤醒队列里第一个等待线程。公平锁与非公平锁的差别就在获取锁时要不要先检查队列里有没有排队的线程。理解了AQS再看别的同步工具就一通百通。Semaphore是把state当作许可证数量acquire就尝试减1减到0就阻塞。CountDownLatch是把state当作倒计数值countDown就减1减到0就唤醒所有await的线程。所以JUC的学习路线我始终建议先啃AQS框架再回来看各种同步类一眼就能看穿它的行为。4.3 CountDownLatch、CyclicBarrier、Semaphore的应用示范这三个协作工具是面试高频考点也是实际开发里非常实用的“流程开关”。CountDownLatch是“倒计时门闩”一个线程等待多个线程的事做完再继续。典型场景是主线程等好几个子任务都完成后再汇总。它是一次性的计数减到0就回不去了。我做过一个报表生成任务拆成5个子查询并行执行最后用CountDownLatch等它们都返回再合并结果接口耗时从4秒降到1.5秒。CyclicBarrier是“循环栅栏”多个线程互相等等到齐了再一起放行而且可以循环使用。典型场景是多线程分阶段计算每阶段等大家都到齐再进入下一阶段。区别在于CountDownLatch是“一等多”CyclicBarrier是“多等多”。Semaphore是“信号量”控制同一时间最多有多少个线程访问资源。典型场景是限流比如一个接口只允许10个并发调用超出的就等待。注意它不是分布式限流只能管单机进程内多节点需要配合Redis或Sentinel这类外部组件。5. 线程池参数看似简单细节全是坑5.1 ThreadPoolExecutor七个参数逐个说透线程池是JUC里项目落地最多、同时翻车也最多的组件。ThreadPoolExecutor有七个参数核心线程数corePoolSize、最大线程数maximumPoolSize、空闲存活时间keepAliveTime、时间单位unit、任务队列workQueue、线程工厂threadFactory、拒绝策略handler。很多人背了参数但不知道实际执行逻辑。实际流程是提交任务时先判断当前线程数是否小于核心线程数小于就新建线程执行否则放入任务队列队列满了再看是否小于最大线程数小于就新建非核心线程执行再不行就执行拒绝策略。注意这里的关键点是先入队而不是先扩容到最大线程数这个顺序是理解线程池行为的分水岭。我遇到过一个线上事故核心线程数设了8最大线程数设了32任务队列选的LinkedBlockingQueue但没设容量上限。高峰期瞬时涌入几千个任务线程实际只启动了8个其他任务全部堆积在无限队列里响应时间从50ms一路涨到10秒。这就是“无界队列最大线程数形同虚设”的经典案例。用无界队列时maximumPoolSize永远不会触发拒绝策略也永远不会执行线程池只会像一个越来越堵的蓄水池。5.2 四种拒绝策略和自定义选择ThreadPoolExecutor自带四种拒绝策略AbortPolicy直接抛RejectedExecutionException默认策略。CallerRunsPolicy任务不丢弃由提交任务的线程自己执行。这就有了天然的重试和限流效果提交线程被占住了就会减少后续任务的提交速度。DiscardPolicy静默丢弃。DiscardOldestPolicy丢弃队列里等待最久的任务再尝试提交。实际项目中我推荐CallerRunsPolicy用于对任务丢失敏感的业务配合监控报警至少能保证任务最终执行而且提交线程自己执行时会天然形成反压避免系统被冲垮。AbortPolicy适合明确需要感知拒收的场景比如用户请求直接报错提示稍后重试。Discard相关的两个策略我在生产环境基本不用静默丢数据是最难排查的故障之一。还有个容易忽略的点线程工厂threadFactory最好自己设置给线程起有意义的名字比如order-worker-1。默认的线程名是pool-1-thread-1线上排查线程池问题时看着线程转储完全不知道是哪个业务模块的线程定位成本极高。起名这个习惯关键时刻能救命。5.3 核心线程数到底怎么算核心线程数的设置没有银弹但主流参考思路分两类CPU密集型任务核心参考CPU核数 1。1是为了防止某个线程因为缺页中断等偶发阻塞让CPU空闲。IO密集型任务公式较多常用的是CPU核数 * 2或者按线程等待时间与计算时间的占比估算即CPU核数 * (1 等待时间/计算时间)。我自己的做法是先按理论公式算出初值再压测调整。比如一个服务节点是8核业务里有较多数据库和RPC调用等待时间明显大于计算时间我一般先设16~24个核心线程然后拿真实流量回放压测观察CPU利用率、线程活跃数和队列积压量逐步调整。线程池参数务必配置化放到配置中心动态调整别写死在代码里。压测是调参的唯一可靠途径纸上算出来的数字永远只是起点。另外如果任务执行时间波动大我倾向于用动态线程池方案把核心线程数、最大线程数、队列容量都做成可动态修改的配合监控指标自动扩容缩容。这类工具现在有不少开源实现也可以自己基于ThreadPoolExecutor的setCorePoolSize等方法封装。6. 常见问题与排查技巧实录6.1 死锁的定位与预防多线程项目里最经典的故障就是死锁。Java的synchronized和ReentrantLock都做不到自动检测死锁但可以通过合理设计避免。我用过最有效的三板斧固定锁顺序所有线程按同一顺序获取多个锁这是最根本的解法从源头消灭循环等待。用tryLock超时获取锁时设置超时超时失败就回滚并释放已持有的锁避免无限期等待。用jstack抓线程转储线上出现死锁现象时执行jstack pid线程转储尾部通常会有Found one Java-level deadlock的明确提示直接定位到两个线程和各自的持锁信息。我遇到过一次很隐蔽的死锁是A服务调用B服务的同步接口B服务回调A服务两个服务各持有一个JVM级别的锁最终形成跨进程的循环等待。单靠jstack只能看到本进程的锁关系最后是结合两边的线程转储和调用链日志才定位清楚。所以说锁不但要设计在方法级别还要想清楚跨线程、跨进程的调用链路。6.2 线程池线程耗尽问题排查线程池线程耗尽的典型现象是接口超时、依赖线程池的后台任务不跑了、线程转储里大量WAITING状态线程堆积在ThreadPoolExecutor的Worker上。排查步骤我一般这么走先看线程池监控指标待处理队列大小、活跃线程数、完成任务数确认是否已饱和然后看核心任务有没有被慢接口阻塞比如调用数据库超时、HTTP调用没设超时时间再看是不是某个任务里误用了并行流或者嵌套线程池导致线程“套娃”耗尽。一个非常常见的低级错误是在任务内部又提交任务给同一个线程池并且future.get()等待结果如果核心线程数小、任务量大父任务占着线程等子任务子任务等空闲线程直接形成线程池内的死锁。遇到这种场景要么任务拆分得彻底一点不要嵌套要么给get设置超时要么干脆用独立的线程池处理异步子任务。6.3 面试里常见的JUC追问方向JUC是Java面试绕不开的板块面试官最爱在几个点上深挖volatile的理解延伸问JMM、happens-before、指令重排的案例。CAS的原理和ABA问题延伸问AtomicStampedReference、LongAdder。ConcurrentHashMap的实现细节JDK7和JDK8的区别、为什么JDK8放弃分段锁。AQS的原理比如ReentrantLock公平锁和非公平锁的源码级差异。CountDownLatch和CyclicBarrier的区别以及各自适合什么场景。线程池参数、拒绝策略、执行顺序、核心线程数怎么算以及无界队列导致的问题。面试准备时我建议别只背结论把关键类的源码打开看一遍AQS的acquire流程、ThreadPoolExecutor的execute方法分支逻辑看懂了才能应对变着花样的追问。这里多说一句面试回答时要主动带出“实际项目中遇到的问题和解决过程”比干巴巴背概念强得多面试官要的就是你有真实场景的工程判断力。6.4 别忽略线程安全之外的坑优雅关闭线程池除了用起来要小心关闭线程池也是一个高频出问题的地方。很多人直接调用shutdownNow()想把任务全部停掉结果在跑的任务被中断数据库事务回滚、正在写的文件只写了一半线上数据直接出问题。我的习惯是先调用shutdown()停止接收新任务再awaitTermination指定超时时间等存量任务跑完超时了再考虑shutdownNow()强制中断最后检查isTerminated确认真正关闭。如果是长期运行的业务线程池比如处理MQ消息的消费者池一般不需要主动关闭保持常驻即可。但每次发版重启时Spring容器销毁阶段这些池子是否做了优雅关闭、是否等待在跑任务结束这个极其重要否则每次发版都可能出现消息重复消费或数据不一致。另外一点线程池里的线程没有清理ThreadLocal的习惯。ThreadLocal被线程池复用后线程不销毁ThreadLocal的值就一直留在线程里一旦业务代码忘了remove轻则内存泄漏重则后面的任务读到上一个任务的脏数据。用ThreadLocal传递用户信息、traceId的场景一定要在任务执行的finally里remove这也是个老生常谈但线上反复出现的问题。6.5 压测和监控才是JUC落地的最后一步代码写得再漂亮不压测、不监控线上照样出问题。做并发改造后我一般会用压测工具打一轮流量重点看几个指标TPS和响应时间的变化、CPU利用率和负载、线程池活跃数与队列长度、GC频率和停顿时间。监控方面线程池真实的活跃线程数、队列大小、拒绝次数、任务耗时这些指标都要暴露出来接上监控看板或报警。这轮我们项目做秒杀活动的限流改造时就是先压测发现Semaphore的许可数设得太保守请求大量阻塞排队加上了动态调整和排队等待超时最终才把接口耗时控制在可接受范围。JUC的工具本身只是零件怎么组合、怎么调优、怎么观察线上表现才真正体现工程能力。在我个人经验里JUC经常被高估也经常被低估高估在以为用了并发容器就是并发安全了低估在不知道它背后重试、阻塞、快照策略对系统整体行为的影响。多写多测多排查把每一个类的设计意图和适用边界吃透比多背几道面试题管用得多。