1. 为什么我要系统啃一遍JUC做Java后端五六年面试被问得最多的就是并发。早些年我还能靠背八股混过去直到有次线上真出了个事故一个订单导出接口平时QPS几十跑得好好的大促那天突然大量超时日志里全是线程池拒绝异常。当时我盯着RejectedExecutionHandler那几个策略看了半天才发现队列用的是无界LinkedBlockingQueue任务全堆在队列里最大线程数形同虚设内存一路涨到快OOM。那次之后我下定决心把java.util.concurrent这个包从头到尾捋一遍不是背API是搞清楚每个类到底解决什么问题、底层怎么实现的、什么场景该用哪个。这个系列就是我边学边记的产物持续更新。它适合已经会写Java、能看懂基本多线程代码但一碰到AQS、CAS、ThreadPoolExecutor参数调优就发怵的同学。我不会一上来甩源码而是先讲清楚这个东西为什么存在再拆实现最后落到你项目里该怎么用、怎么避坑。JUC这块东西看着零散其实有一条主线用更细粒度的同步手段替代又重又容易死锁的synchronized同时把线程的创建、调度、协作都管起来。抓住这条线你会发现Lock、AQS、CAS、线程池、并发容器、CompletableFuture全是围绕它展开的。下面我按自己的学习路径来组织从最底层的原子操作和内存可见性讲起一路往上到线程池和并发工具类。每一块我都会给能直接跑的代码、参数计算过程还有我踩过的坑。2. 先把地基打牢JMM、CAS与原子类2.1 从一段看起来没问题的代码说起先看这段代码你觉得最后count是多少public class Counter { private int count 0; public void add() { count; } public static void main(String[] args) throws InterruptedException { Counter c new Counter(); Thread t1 new Thread(() - { for (int i 0; i 10000; i) c.add(); }); Thread t2 new Thread(() - { for (int i 0; i 10000; i) c.add(); }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(c.count); } }很多人第一反应是20000。实测下来大概率小于20000而且每次跑还不一样。原因就藏在count这三个字符里——它根本不是原子操作编译后是读count到寄存器、加1、写回内存三步。两个线程可能同时读到同一个值各自加1再写回一次自增就丢了。这就是竞态条件也是JUC要解决的最核心问题。要理解为什么会丢得先理解Java内存模型JMM。JMM规定每个线程有自己的工作内存共享变量存在主内存。线程读写变量时是先把主内存的值拷到工作内存操作完再刷回去。这套机制带来三个问题原子性操作能不能被打断、可见性一个线程改了另一个线程能不能立刻看到、有序性指令会不会被重排。count三个问题全占了。2.2 volatile到底保证了什么volatile是面试高频但很多人答不全。它保证两件事可见性和禁止指令重排但不保证原子性。也就是说你把上面的count加上volatile结果照样错因为count还是三步。那volatile什么时候有用最典型的是状态标志位public class Worker { private volatile boolean running true; public void shutdown() { running false; } public void work() { while (running) { // 干活 } System.out.println(优雅退出); } }如果running不加volatilework线程可能永远读的是自己工作内存里的旧值true导致死循环退不出来。加了之后shutdown一改work立刻能看到。注意volatile的可见性底层靠的是内存屏障Memory Barrier。写volatile变量时会在前面插StoreStore屏障、后面插StoreLoad屏障读的时候插LoadLoad和LoadStore屏障。这些屏障的作用是禁止特定重排并强制刷新/失效缓存。你不用背屏障名字但要记住结论volatile写之后的普通写对读该volatile变量的线程可见。2.3 CAS无锁并发的基石既然count不是原子的最直接的办法是加锁。但锁有开销线程阻塞唤醒要陷入内核态。于是有了CASCompare And Swap比较内存中的值是否等于预期值相等就更新不等就重试。整个过程由CPU的cmpxchg指令保证原子性不需要锁。Java里通过Unsafe类暴露CAS能力但Unsafe不让普通开发者直接用所以有了AtomicInteger这些原子类public class AtomicCounter { private final AtomicInteger count new AtomicInteger(0); public void add() { count.incrementAndGet(); } }incrementAndGet内部就是个自旋循环读当前值、算新值、CAS尝试更新失败就重来。这样两个线程同时加也不会丢。但CAS有三个经典问题必须知道ABA问题线程1读到A改成B又改回A线程2 CAS时发现还是A就更新了其实中间已经变过。解决办法是加版本号AtomicStampedReference就是干这个的。自旋开销竞争激烈时大量线程反复CAS失败重试CPU空转。所以高竞争场景下LongAdder比AtomicLong更合适它把热点分散到多个Cell上最后求和。只能保证一个变量的原子性要同时更新多个变量得用AtomicReference把多个字段包成一个对象。2.4 原子类选型速查场景推荐类理由普通计数、低竞争AtomicInteger/AtomicLong简单直接CAS自旋高并发计数、统计LongAdder/DoubleAdder分段累加吞吐更高需要版本号防ABAAtomicStampedReference带stamp标记更新对象引用AtomicReference泛型引用CAS更新对象某字段AtomicIntegerFieldUpdater省内存不用包对象实操心得LongAdder的sum()方法返回的不是强一致快照统计场景够用但如果你要拿这个值做精确判断比如限流阈值得用AtomicLong。我早期做限流器时用LongAdder计数结果并发下判断总差那么几个后来换成AtomicLong才准。3. 锁的进化从synchronized到AQS3.1 synchronized的升级之路synchronized不是一开始就这么强的。早期它就是个重量级锁直接调操作系统的互斥量线程一阻塞就进内核态开销大。后来JDK做了锁升级无锁 → 偏向锁 → 轻量级锁 → 重量级锁。偏向锁只有一个线程访问时直接在对象头Mark Word里记下线程ID下次进来比对一下就行连CAS都省了。适合始终一个线程访问的场景。轻量级锁有第二个线程来竞争偏向锁撤销升级为轻量级锁用CAS自旋抢锁。适合锁持有时间短、竞争不激烈。重量级锁自旋失败多次升级为重量级锁没抢到的线程直接挂起。这套升级机制让synchronized在大多数场景下性能不输ReentrantLock而且写法简单、不会忘记释放。所以我的建议是能用synchronized就用synchronized除非你需要它的高级功能。3.2 ReentrantLock比synchronized多了什么ReentrantLock是JUC里的显式锁它比synchronized多三个能力可中断获取锁lockInterruptibly()线程等锁时能被中断避免死等。超时获取锁tryLock(3, TimeUnit.SECONDS)等不到就放弃防止死锁。公平锁构造时传true按等待顺序发锁避免饥饿。但公平锁吞吐会下降因为要维护队列顺序。ReentrantLock lock new ReentrantLock(true); // 公平锁 lock.lock(); try { // 临界区 } finally { lock.unlock(); // 必须放finally }注意unlock()一定要放finally否则临界区抛异常锁就永远不释放了。我见过有人把unlock写在try里结果异常直接跳过整个服务卡死。这是血泪教训。3.3 AQSJUC的万能底座ReentrantLock、Semaphore、CountDownLatch、ReentrantReadWriteLock这些看起来八竿子打不着的类底层都是同一个东西——AQSAbstractQueuedSynchronizer。搞懂AQSJUC里一大半的同步器你就通了。AQS的核心就三样东西一个volatile int state表示同步状态。ReentrantLock里state0表示没锁1表示锁了一次2表示重入了两次。一个CLH双向队列没抢到锁的线程包装成Node排队。一套模板方法子类只需要实现tryAcquire和tryRelease排队、唤醒这些脏活AQS全包了。以ReentrantLock非公平锁为例加锁流程大致是先CAS尝试把state从0改成1成功就拿到锁设置持有线程为当前线程。失败的话如果当前线程就是持有者state加1重入。再失败就把当前线程包成Node加入队列尾部然后LockSupport.park()挂起。释放锁时state减1减到0就唤醒队列里下一个节点。// 简化版理解非真实源码 final void lock() { if (compareAndSetState(0, 1)) setExclusiveOwnerThread(Thread.currentThread()); else acquire(1); // 走AQS排队逻辑 }实操心得理解AQS的state语义是理解所有同步器的钥匙。CountDownLatch里state是倒计数Semaphore里state是许可数ReentrantReadWriteLock里state高16位是读锁、低16位是写锁。你把这个state想明白每个类的行为就都清楚了。3.4 读写锁读多写少场景的利器ReentrantReadWriteLock把锁分成读锁和写锁读读不互斥读写互斥写写互斥。适合读远多于写的场景比如缓存。ReentrantReadWriteLock rwLock new ReentrantReadWriteLock(); // 读 rwLock.readLock().lock(); try { return cache.get(key); } finally { rwLock.readLock().unlock(); } // 写 rwLock.writeLock().lock(); try { cache.put(key, value); } finally { rwLock.writeLock().unlock(); }但它有个坑写锁可以降级为读锁读锁不能升级为写锁。也就是说你持有读锁时想再拿写锁会死锁。正确姿势是先拿写锁再拿读锁然后释放写锁。另外读锁是共享的如果一直有读线程进来写线程可能永远等不到这叫写饥饿。JDK的ReentrantReadWriteLock是非公平的写线程有机会插队但高并发读场景下还是可能饿。要更公平可以用StampedLock它支持乐观读性能更好但API复杂且不可重入。4. 线程池别再用Executors了4.1 为什么阿里规范禁止用ExecutorsExecutors提供了几个快捷方法newFixedThreadPool、newCachedThreadPool、newSingleThreadExecutor。看着方便但全是坑newFixedThreadPool和newSingleThreadExecutor用的是无界队列LinkedBlockingQueue任务堆积时最大线程数永远用不上队列无限涨最后OOM。newCachedThreadPool用的是无界线程数最大线程数是Integer.MAX_VALUE任务一多就疯狂创建线程直接把系统资源耗尽。所以正确做法是手动new ThreadPoolExecutor把七个参数都显式指定。4.2 七个参数怎么算ThreadPoolExecutor executor new ThreadPoolExecutor( 4, // corePoolSize 8, // maximumPoolSize 60L, TimeUnit.SECONDS, // keepAliveTime new ArrayBlockingQueue(200), // workQueue new ThreadFactory() { ... }, // threadFactory new ThreadPoolExecutor.CallerRunsPolicy() // handler );参数怎么定没有万能公式但有个经验方法corePoolSize如果是CPU密集型设为CPU核数 1如果是IO密集型设为CPU核数 * 2或更高。因为IO等待时CPU闲着多开线程能提高利用率。maximumPoolSize一般设成core的2倍左右别太大否则线程切换开销大。workQueue必须用有界队列ArrayBlockingQueue或LinkedBlockingQueue带容量。容量根据你能接受的最大积压量来定。handler默认AbortPolicy直接抛异常很多人不敢用。其实抛异常是好事能让你及时发现系统过载。CallerRunsPolicy让提交任务的线程自己跑起到反压作用也是个好选择。注意线程池的执行顺序是先core再队列最后才扩到max。也就是说队列没满之前maximumPoolSize根本不起作用。很多人以为任务多了就自动扩线程其实不是。所以队列容量要设得合理别设太大。4.3 线程池监控不监控等于裸奔线程池最怕的是任务堆积了但没人知道。我习惯在项目里加个定时任务定期打印线程池状态ScheduledExecutorService monitor Executors.newSingleThreadScheduledExecutor(); monitor.scheduleAtFixedRate(() - { System.out.printf(活跃线程%d, 池大小%d, 队列大小%d, 已完成%d%n, executor.getActiveCount(), executor.getPoolSize(), executor.getQueue().size(), executor.getCompletedTaskCount()); }, 0, 10, TimeUnit.SECONDS);关键指标是队列大小和活跃线程数。队列持续增长说明处理不过来得扩容或优化任务活跃线程数长期等于max且队列满说明该加机器了。4.4 线程池常见问题速查现象可能原因排查方向任务不执行核心线程满、队列满、拒绝策略触发看拒绝日志、队列大小CPU飙高线程数过多、任务死循环jstack看线程栈内存涨队列无界、任务对象大换有界队列、看堆dump任务执行慢锁竞争、IO阻塞看线程状态是否BLOCKED/WAITING线程泄漏任务里创建线程没关检查任务代码实操心得ThreadFactory一定要自定义给线程起个有意义的名字比如order-export-pool-1。出问题时jstack一看线程名就知道是哪个池比pool-1-thread-1强太多。这个习惯我坚持了好几年排查线上问题省了无数时间。5. 并发容器与工具类实战5.1 ConcurrentHashMap别再用Collections.synchronizedMapHashMap线程不安全多线程put可能丢数据甚至死循环JDK7的头插法。Collections.synchronizedMap虽然安全但整个map一把锁并发度低。ConcurrentHashMap才是正解。JDK8的ConcurrentHashMap做了大改放弃分段锁改用CAS synchronized锁单个桶。也就是说只有哈希冲突到同一个桶的线程才互斥不同桶完全并行。并发度大幅提升。几个使用要点putIfAbsent是原子的适合做没有才放的缓存。computeIfAbsent也是原子的适合懒加载但计算函数里不要做耗时操作因为它会锁住桶阻塞其他线程。size()返回的是估算值不精确别拿它做严格判断。ConcurrentHashMapString, String cache new ConcurrentHashMap(); // 原子懒加载 String value cache.computeIfAbsent(key, k - loadFromDB(k));注意computeIfAbsent的映射函数里如果又去操作同一个map会死锁。我见过有人在里面递归调用computeIfAbsent直接卡死。记住映射函数里只做纯计算别碰map本身。5.2 CopyOnWriteArrayList读多写极少的场景CopyOnWriteArrayList的思路很暴力写的时候复制整个数组改完替换引用。读操作完全无锁因为读的是旧数组快照。适合读多写极少、且数据量不大的场景比如配置列表、监听器列表。CopyOnWriteArrayListString listeners new CopyOnWriteArrayList(); listeners.add(listener1); // 写复制数组 for (String l : listeners) { ... } // 读无锁但它有两个明显缺点写开销大每次复制整个数组、内存占用翻倍。所以数据量大或写频繁的场景千万别用会拖垮内存。5.3 CountDownLatch与CyclicBarrier别搞混这俩名字像用途完全不同CountDownLatch一个线程等多个线程完成。比如主线程等10个初始化任务都做完再继续。一次性计数到0就不能再用。CyclicBarrier多个线程互相等到齐了再一起走。比如10个线程分片计算每轮结束要同步一次。可循环到齐后自动重置。// CountDownLatch主线程等子任务 CountDownLatch latch new CountDownLatch(3); for (int i 0; i 3; i) { new Thread(() - { try { doWork(); } finally { latch.countDown(); } }).start(); } latch.await(); // 主线程阻塞直到3个都countDown System.out.println(全部完成); // CyclicBarrier线程互相等 CyclicBarrier barrier new CyclicBarrier(3, () - System.out.println(一轮结束)); for (int i 0; i 3; i) { new Thread(() - { while (true) { doWork(); barrier.await(); // 等齐3个再继续 } }).start(); }实操心得CountDownLatch的await()最好带超时await(5, TimeUnit.SECONDS)。否则某个子任务卡死主线程就永远等下去。生产环境里我吃过这个亏一个初始化任务因为网络问题卡住整个服务启动流程挂起最后只能重启。5.4 CompletableFuture异步编排的正确姿势Future的缺点是get()会阻塞而且没法组合多个异步任务。CompletableFuture解决了这些支持链式调用和组合。CompletableFutureString future CompletableFuture .supplyAsync(() - queryUserInfo(userId), executor) .thenApply(info - enrichInfo(info)) .thenCompose(info - queryOrderAsync(info, executor)) .exceptionally(ex - { log.error(查询失败, ex); return 默认值; }); String result future.get(3, TimeUnit.SECONDS);几个关键方法thenApply转换结果同步执行。thenApplyAsync转换结果异步执行用另一个线程。thenCompose串联另一个异步任务避免嵌套CompletableFuture。thenCombine合并两个独立异步任务的结果。allOf/anyOf等所有/任意一个完成。注意CompletableFuture默认用ForkJoinPool.commonPool()这个池的线程数是CPU核数-1如果任务里有阻塞操作会把公共池占满影响其他用它的代码。所以一定要传自定义线程池别用默认的。这个坑我在一个批量查询接口里踩过公共池被占满后整个应用的并行流都变慢了。6. 我踩过的坑与排查实录6.1 死锁怎么发现、怎么预防死锁的四个必要条件互斥、持有并等待、不可剥夺、循环等待。破坏任意一个就能避免。最实用的是破坏循环等待给所有锁定义全局顺序所有线程按同一顺序加锁。怎么发现死锁jstack一把梭jstack pid | grep -A 20 Found one Java-level deadlock它会直接告诉你哪两个线程、各持有什么锁、在等什么锁。我遇到过一次两个线程一个先锁A再锁B另一个先锁B再锁Ajstack一跑就定位了。预防手段用tryLock(timeout)代替lock()超时放弃。统一加锁顺序。尽量缩小锁范围别在锁里调外部服务。6.2 线程池拒绝异常别慌先看队列线上突然大量RejectedExecutionException第一反应不是加线程数而是看队列为什么满了。可能是下游变慢导致任务堆积也可能是任务本身有死循环。盲目调大maximumPoolSize只是把问题往后拖最后OOM更惨。我的排查顺序是先看队列大小趋势再看任务平均耗时最后看下游依赖是否正常。定位到根因再决定是扩容、限流还是优化任务。6.3 并发问题排查速查表问题工具/命令关键看什么死锁jstackFound one Java-level deadlockCPU飙高top -Hpjstack找出占CPU的线程转16进制查栈内存泄漏jmapMAT大对象、线程池队列线程泄漏jstack线程数持续增长任务堆积线程池监控队列大小、活跃线程数实操心得top -Hp pid找出占CPU最高的线程ID然后printf %x\n tid转成16进制再去jstack输出里搜这个16进制值就能定位到具体哪行代码。这套组合拳我用了无数次比任何可视化工具都快。7. 这个系列后面还会写什么JUC这块内容太多一篇写不完。我计划后面继续拆ForkJoinPool的分治思想、StampedLock的乐观读、BlockingQueue各实现类的取舍、ThreadLocal的内存泄漏问题、Disruptor这种高性能队列的思路。每一篇都会保持这个风格——先讲为什么再拆怎么做最后给能跑的代码和踩坑记录。如果你也在啃JUC建议别光看一定要自己写demo跑一遍。并发问题最坑的地方在于它平时不出现一出现就是线上事故。我上面写的每个坑基本都是真金白银换来的。你把这些坑提前踩一遍线上就能少熬几个夜。