Java并发编程的JUC实战指南:锁、原子类与线程池搭配置方案

发布时间:2026/10/9 11:10:44
Java并发编程的JUC实战指南:锁、原子类与线程池搭配置方案 很长一段时间里JUCjava.util.concurrent都是Java面试的高频考点也是从初级到高级开发都绕不开的一座山。不过面试问JUC多数停留在“你用过哪些并发类”“ConcurrentHashMap为什么线程安全”这种层面真正到生产环境十几个常见类怎么选、线程池参数怎么定、并发问题从哪查才是拉开差距的地方。这篇文章不打算把java.util.concurrent包里一百多个类挨个讲一遍那是API文档干的事。我只把我自己在项目里真正高频使用的、面试常问的、排查时救过命的那些类拉出来逐个聊清楚它们的工作原理、适用场景和踩过的坑给你一套可以抄走的选型和配置方案。1. JUC到底解决了什么问题1.1 从synchronized到 JUC 的演进逻辑Java 1.5 之前并发编程几乎只有synchronized和volatile两个武器。synchronized用起来简单但短板很多它不可中断线程一旦阻塞在锁上只能一直傻等没法响应interrupt()它不支持尝试获取锁拿不到就干等没有“等一秒钟拿不到就走别的路”这种机制它也不分读和写读读操作之间本来是安全的也被强制互斥白白损失并发度。另外synchronized本质是非公平锁在多线程激烈竞争时容易出现某些线程长时间得不到调度的情况——虽然没有绝对的饿死但响应性确实不够好。JUC 的出现把这些短板一条条补齐了。它不是在synchronized上打补丁而是搞了一套全新的并发工具体系基于 AQSAbstractQueuedSynchronizer的锁和同步器提供了可中断、可超时、可公平的锁实现。基于 CASCompare-And-Swap volatile的原子类实现了无锁并发在低竞争场景下性能远好于加锁。一批并发容器让你不再需要手动加锁就能安全读写共享数据。这套体系后来成为 Java 并发编程的事实标准也是我在项目中排查并发问题时首先会考虑的方向。1.2 JUC 核心原理与三大版图JUC 里类很多但核心原理就三个volatile保证可见性CAS 保证原子性AQS 统一了锁和同步器的实现骨架。先说volatile。它有两个作用一是保证变量在多线程之间的可见性一个线程修改了其他线程能立刻看到二是禁止指令重排序。但 volatile 不保证原子性典型例子就是i这种操作所以需要配合 CAS。CAS是 CPU 指令层面的原子操作全称是 Compare-And-Swap比较并交换。它做的事情是如果内存中的值等于我预期的旧值就把它更新成新值整个操作是原子的。这比加锁轻量得多没有线程阻塞和唤醒的开销。缺点是并发竞争激烈的时候CAS 会反复自旋空转CPU 烧得厉害所以高竞争场景下反而不如锁。AQS是 JUC 里ReentrantLock、Semaphore、CountDownLatch这些类的公共地基。它内部维护了一个volatile int state和一个等待队列 CLH 队列线程抢不到锁就进入队列挂起前一个节点释放后唤醒后一个节点。理解了 AQS再看 JUC 的锁和同步器会发现套路几乎一模一样。把这些类按用途分成三大版图记忆负担会小很多版图代表类解决什么问题原子类AtomicInteger、AtomicLong、AtomicReference、LongAdder无锁线程安全的单值/引用操作锁与同步工具ReentrantLock、ReadWriteLock、StampedLock、CountDownLatch、CyclicBarrier、Semaphore互斥、共享、线程间协作并发容器与线程池ConcurrentHashMap、CopyOnWriteArrayList、BlockingQueue、ThreadPoolExecutor、CompletableFuture高并发下的数据存储和任务执行你在面试里把这三个原理讲清楚后面不管是追问哪个类思路都不会乱。下面逐个拆。2. Lock体系ReentrantLock、读写锁与StampedLock2.1 ReentrantLock可中断、可超时的可重入锁ReentrantLock是我在项目里替换synchronized最常用的锁。它最实用的几个能力synchronized都给不了ReentrantLock lock new ReentrantLock(false); public void process() { boolean acquired false; try { // 尝试获取锁等3秒拿不到就返回false而不是无限阻塞 acquired lock.tryLock(3, TimeUnit.SECONDS); if (!acquired) { // 拿不到锁走降级逻辑比如直接返回错误或走缓存 return; } // 进入受锁保护的业务代码 doSomething(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); // 根据业务决定是重试、放弃还是标记状态 } finally { // 一定要在finally里释放否则锁会一直持有 if (acquired) { lock.unlock(); } } }tryLock带超时是最实用的特性。过去用synchronized的时候最怕的就是死锁一旦出现死锁线程全堵在锁上整个系统跟着瘫痪。用了tryLock(timeout)线程最多等预设时间等不到就撤系统不会因为锁问题彻底卡死。ReentrantLock的“可重入”与synchronized一致同一个线程可以多次获取同一把锁每获取一次内部计数state加1每释放一次减1只有减到0锁才真正释放。这里容易出问题的是 lock 和 unlock 次数不匹配加锁的次数多于解锁锁就永远释放不了。所以我的习惯是能在同一个方法里用 try-finally 包裹的绝不把 lock 和 unlock 拆到两个方法中。new ReentrantLock(true)是公平锁会让等待时间最长的线程优先获得锁false是非公平锁允许插队。公平锁看起来更正义但在高并发场景下线程频繁切换开销大吞吐反而更低。生产环境我基本只用非公平锁除非业务上明确要求按顺序执行否则别跟性能过不去。2.2 读写锁与StampedLock根据读写比例选型ReentrantReadWriteLock把锁拆成读锁和写锁读读之间不互斥多个线程能同时读读写、写写之间互斥。这个特性非常适合缓存、配置中心数据等“读多写少”的场景。ReentrantReadWriteLock rwLock new ReentrantReadWriteLock(); Lock readLock rwLock.readLock(); Lock writeLock rwLock.writeLock(); // 读操作 public Object get(String key) { readLock.lock(); try { return cache.get(key); } finally { readLock.unlock(); } } // 写操作 public void put(String key, Object value) { writeLock.lock(); try { cache.put(key, value); } finally { writeLock.unlock(); } }注意ReentrantReadWriteLock的坑如果读线程非常多写线程可能长时间拿不到写锁出现“写饥饿”。因为读锁是共享的只要有读线程持有后续读线程就一直能进来而写线程得等所有读锁释放。缓解办法是用writeLock().tryLock(timeout)拿不到写锁就放弃这次更新等下一轮再刷。还有一个更激进的StampedLock它支持乐观读读的时候不加锁直接读数据读完之后再验证这期间有没有写操作发生过。StampedLock stampedLock new StampedLock(); public double read() { long stamp stampedLock.tryOptimisticRead(); double currentValue this.value; // 验证读期间有没有写操作没有则直接返回 if (!stampedLock.validate(stamp)) { // 验证失败升级为悲观读锁 stamp stampedLock.readLock(); try { currentValue this.value; } finally { stampedLock.unlockRead(stamp); } } return currentValue; }乐观读在“几乎永远在读极偶尔写一次”的场景下性能极好因为读路径完全没有锁开销。但StampedLock有两条限制不可重入、不支持条件变量而且它不是一个替代ReentrantLock的通用锁只在特定场景下值得用。我在项目里用它管理过一份每日刷新的白名单数据读流量非常大写流量每天一次效果很理想。给个选型表直接把结论摆出来锁类型特点最合适的场景ReentrantLock可重入、可超时、可中断一般互斥场景大多数业务并发控制ReentrantReadWriteLock读读并行读写互斥读多写少如缓存、配置表StampedLock乐观读读性能极高读极多、写极少的场景如每日刷新配置3. 同步工具CountDownLatch、CyclicBarrier与Semaphore3.1 CountDownLatch与CyclicBarrier的区别这两个类都是“多个线程协作”的工具名字像、功能有交叉但设计意图完全不同我在代码评审里见过不少用混的情况。CountDownLatch是倒数计数器场景是一个或多个线程等待其他N个线程完成工作后再继续。比如主线程要等5个子任务全部跑完再汇总结果或者压测时要让100个线程在同一时刻同时冲出起跑线int workerCount 5; CountDownLatch readyLatch new CountDownLatch(1); CountDownLatch doneLatch new CountDownLatch(workerCount); for (int i 0; i workerCount; i) { new Thread(() - { try { readyLatch.await(); // 所有worker先在这里等待发令 work(); // 业务逻辑 } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { doneLatch.countDown(); // 计数减一放finally里防止异常导致不减 } }).start(); } readyLatch.countDown(); // 主线程发令 boolean allDone doneLatch.await(5, TimeUnit.SECONDS); // 等待最多5秒 if (!allDone) { // 超时有任务没完成做兜底处理 }这里最实用的细节是await(5, TimeUnit.SECONDS)。我踩过最深的坑就是任务在work()方法里抛异常但是没放 finally 里调countDown()结果计数器永远到不了0主线程在await()上眼的都不眨地等了一天一宿。所以等到现在我的规矩两条countDown()必须放 finallyawait()必须带超时。CyclicBarrier的场景则相反一组线程互相等待直到所有线程都到达同一点后一起放行。它有个可复用的特性一轮结束后可以继续下一轮适合分阶段计算之类的场景。核心区别表格化记起来容易对比维度CountDownLatchCyclicBarrier参与角色等待线程 执行线程所有线程都是执行者等待逻辑计数减到0就放行计数减到0就一起放行复用性一次性不能重置可重置复用典型场景主线程等N个子任务N个线程互相等齐再并行3.2 Semaphore限流实战Semaphore是计数信号量内部维护一个许可证数量。线程acquire()拿走一个许可证用完release()还回来。许可证发完了后来的线程阻塞等待。我最常拿它来限制对下游资源的并发访问比如限制同时调用数据库连接池、同时打外部接口的请求数Semaphore semaphore new Semaphore(10); // 最多10个并发 public void callRemote() { boolean acquired false; try { acquired semaphore.tryAcquire(1, TimeUnit.SECONDS); if (!acquired) { // 拿不到许可证说明并发已经打满快速返回“系统繁忙” return; } remoteClient.call(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { if (acquired) { semaphore.release(); } } }提醒一句tryAcquire()和acquire()之间无脑选tryAcquire。因为acquire()是阻塞式的如果release()漏了许可证会越来越少最终所有调用线程全部阻塞在信号量上比直接报错可怕得多。配合之前讲过的“锁和许可证释放放 finally”的习惯这套代码在线上跑了一两年没有因为限流过一次事故。另外Semaphore的公平性问题也存在构造时可以传new Semaphore(10, true)开公平模式但默认非公平即可性能优先。4. 并发容器与Atomic原子类4.1 ConcurrentHashMap使用细节ConcurrentHashMap是 Java 并发容器里知名度最高、也是唯一能在高并发下保底的选择。1.8 版本重构之后它的实现思路是读写都是对桶数组的操作每个桶通过volatile保证可见性写入时如果对应桶为空直接用 CAS 插入桶非空时对桶头节点加synchronized把锁粒度压缩到单桶级别。相比 1.7 的分段锁并发粒度更细了。日常工作里用得最多的操作ConcurrentHashMapString, ConfigItem configs new ConcurrentHashMap(); // 原子性的“有则读取无则计算后写入”适合本地缓存 ConfigItem item configs.computeIfAbsent(order.timeout, k - loadConfigFromDB(k)); // 原子性的“存在则更新不存在则写默认值” configs.merge(order.ttl, 3600s, (oldVal, newVal) - oldVal , newVal); // 新增一个键值对如果键已存在覆盖 configs.put(key, value);这里要说两个容易踩的细节。第一size()返回的是近似值不是精确值因为高并发下逐个统计节点无法保证一致性如果需要精确统计要么额外维护一个独立的计数器要么接受“差不多”的结果。第二computeIfAbsent方法内部会加锁lambda 里的逻辑虽然很自由但千万别放耗时操作比如调远程接口、睡100毫秒否则锁的持有时间被拉长并发性能断崖式下降。lambda 里只做纯内存计算。ConcurrentHashMap还提供了compute、computeIfPresent、merge这些原子复合操作整体上用下来确实比外围加一把大锁优雅得多。4.2 Atomic与LongAdder的CAS原理AtomicInteger、AtomicLong、AtomicReference这三个类是最常用的原子类。它们没有加锁靠的是 CAS 自旋AtomicInteger counter new AtomicInteger(0); // 等价于 i 的原子版 int current counter.incrementAndGet(); // 如果当前值是5才更新为8返回true否则不做操作返回false boolean updated counter.compareAndSet(5, 8);CAS 的最大优势是无阻塞线程不会挂起在低竞争场景下性能远好于锁。但竞争激烈时CAS 会反复自旋重试CPU 空转。针对这个问题JUC 在 1.8 之后引入了LongAdderLongAdder count new LongAdder(); count.increment(); // 内部把尝试分散到多个cell上减轻单点竞争 long total count.sum(); // sum()汇总统计时可能不是完全精确LongAdder的设计思想是空间换时间把单个计数变量拆成一组 cell多个线程分别往不同的 cell 里加最后汇总。非常适合“高频写、低频读”的场景典型例子是统计接口调用量、记录 QPS 这类埋点数据。但注意它的sum()返回的是当前近似值如果业务要求绝对精确比如扣减库存还是得用AtomicLong甚至加锁。4.3 CopyOnWriteArrayList的应用边界CopyOnWriteArrayList的策略很直白读不加锁直接读底层数组写的时候复制一份新数组在新数组上修改然后原子替换数组引用。CopyOnWriteArrayListString whiteList new CopyOnWriteArrayList(); // 读操作无锁性能极高 public boolean isAllowed(String ip) { return whiteList.contains(ip); } // 写操作每次复制整个数组代价很高 public void add(String ip) { whiteList.add(ip); }它最大的优点是读线程永远不会阻塞也不会抛ConcurrentModificationException遍历时看到的是某一时刻的数组快照弱一致性。代价是每次写都要 O(n) 级别的复制成本。所以它只适合读多写极少的场景系统白名单、黑名单、配置列表、路由表这类数据。如果写频率稍高比如每秒几十次往上数组反复全量复制内存瞬间压力、GC 也跟着受罪这时候不如直接上ConcurrentHashMap。我之前接手过一版用CopyOnWriteArrayList维护在线用户列表的代码在线用户每次上下线都触发一次复制高峰期列表上万元素每秒钟复制好几遍GC 告警不断。后来换成ConcurrentHashMap维护在线标记问题直接消失。工具没有好坏只有合不合适。5. 线程池生产环境出现频率最高的JUC类5.1 ThreadPoolExecutor七参数与执行流程如果说 JUC 里哪个类在实际代码中出现频率最高那一定是ThreadPoolExecutor。几乎所有高并发服务都离不开线程池而线程池的核心就是七个参数和一套完整的任务流转流程。ThreadPoolExecutor executor new ThreadPoolExecutor( 8, // corePoolSize 核心线程数 16, // maximumPoolSize 最大线程数 60L, TimeUnit.SECONDS, // 非核心线程空闲存活时间 new ArrayBlockingQueue(1000), // 任务队列 new ThreadFactory() { // 自定义线程工厂 private final AtomicInteger idx new AtomicInteger(0); Override public Thread newThread(Runnable r) { Thread t new Thread(r, biz-worker- idx.incrementAndGet()); t.setDaemon(false); return t; } }, new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略 );任务提交后走的是这条固定流程当前线程数 corePoolSize新建核心线程执行任务当前线程数 corePoolSize尝试把任务放入队列队列已满才尝试创建非核心线程不超过 maximumPoolSize执行任务线程数达到 maximumPoolSize队列也满执行拒绝策略这句话值得多读两遍线程池是“先入队、再扩容”不是“先扩容、再入队”。这意味着遭遇流量突增时任务是先沉淀在队列里的线程数不会立刻膨胀到最大值。理解这一点后面排查线程池问题才能找到方向。拒绝策略的选择直接影响系统行为别一股脑用默认策略行为风险点AbortPolicy直接抛 RejectedExecutionException任务丢失调用方要处理异常CallerRunsPolicy由提交任务的线程自己执行降低提交速度天然限流DiscardPolicy静默丢弃不抛异常任务无感丢失不推荐DiscardOldestPolicy丢弃队头最老任务再尝试提交高吞吐但可能丢重要任务我生产环境绝大多数都用CallerRunsPolicy理由很朴素它不会静默丢任务而是让业务线程自己兜底相当于把压力反馈给调用方系统整体不会因为线程池撑爆而雪崩。加一个自定义线程工厂则是为了出问题时在jstack里一眼认出这是哪个线程池。这里必须说清楚为什么不推荐直接用Executors的快捷方法newFixedThreadPool用的是无界LinkedBlockingQueue任务无限堆积内存可能被打爆newCachedThreadPool最大线程数是Integer.MAX_VALUE请求突增时线程数会失控CPU 和内存双双告急。手动new ThreadPoolExecutor把每个参数摆在明面上出问题才有的调。5.2 execute和submit的差异与异常处理线程池提交任务有两种方式execute(Runnable)和submit(Callable)。两者对异常的处理逻辑完全不同这是生产环境最容易踩的坑之一。execute方式任务异常直接在 worker 线程里抛出线程池会把出错的线程剔除然后重新建一条线程继续干活。问题在于这个异常对调用方是完全透明的如果没人打印日志异常就被无声吞掉了。submit方式任务异常被封装进返回的Future里只有调用future.get()的时候才会抛出ExecutionException。如果你提交了任务但从不调用get()异常同样被吞掉。// execute方式的异常得自己在任务里兜住 executor.execute(() - { try { doBiz(); } catch (Exception e) { log.error(task execute error, e); // 必须自己打日志 } }); // submit方式的异常忘了get就看不到 Future? future executor.submit(() - doBiz()); try { future.get(); } catch (ExecutionException e) { Throwable realCause e.getCause(); // 真正的业务异常在这里 log.error(task submit error, realCause); }我的建议是统一用 submit Future 的组合并且在 get() 时处理异常。这样做的好处是任务执行结果和异常都能拿到调用方可以明确感知失败但对那种“只管执行、结果不关心”的异步任务execute也完全可以前提是任务内部必须包好 try-catch 并打印完整堆栈。别把异常处理的责任丢给线程池框架。5.3 线程池参数调优经验线程池参数没有一套万能公式但我可以给一套靠谱的初始值然后靠压测调整。CPU 密集型任务核心线程数设为 CPU 核数 1。因为 CPU 密集型任务几乎不等待线程多了只会增加上下文切换开销。IO 密集型任务核心线程数设为 CPU 核数 * 2 左右。因为 IO 等待期间线程是空闲的更多线程能提高吞吐。不确定任务类型从核数 * 2 起步压测后看 CPU 使用率和队列积压情况再调。队列容量一般控制在 5002000 之间。无界队列会让任务无限堆积内存迟早爆掉。keepAliveTime非核心线程空闲释放时间一般 3060 秒即可给流量波谷留出身位。拒绝策略优先CallerRunsPolicy性能敏感型接口可以容忍任务丢失时再考虑其他策略。之前帮一个团队调过一次线程池他们用了默认的AbortPolicy接口高峰期线程池一满任务直接被抛异常前端大面积报错。改成CallerRunsPolicy之后线程池打满时压力回流到调用方系统不再崩溃只是响应变慢了一些整体可用性反而上去了。这就是选型带来的实际收益。6. 常见问题与排查技巧实录6.1 线程池任务积压应该怎么排查生产环境里线程池最常见的异常表现是接口响应变慢、任务迟迟不执行。这时候先别急着加线程先拿数据说话。我的排查习惯是三步走看线程池内部状态。通过getActiveCount()看活跃线程数getQueue().size()看排队任务数getCompletedTaskCount()看已完成任务数。如果活跃线程等于 corePoolSize、队列接近满载说明线程不够用或任务本身太慢。定位任务耗时。把任务里调外部接口、查数据库、算复杂逻辑的三段耗时分别打点看是哪一段拖了后腿。很多时候问题不在线程池而在下游依赖变慢。看监控曲线。队列积压通常是线性增长还是波浪式起伏前者说明处理速度长期跟不上提交速度后者说明偶发拥堵。如果是任务变慢导致的扩容线程池的效果有限优先处理慢任务如果是流量突增可以临时调大maximumPoolSize和队列容量但要评估内存压力。6.2 死锁快速定位线程池里一旦出现死锁表现是整个池子的线程全部阻塞任务排队但没人执行。死锁的本质是线程互相持有对方需要的锁不释放。定位工具不用花里胡哨jstack就够了jstack pid dump.txt然后在 dump 文件里搜found one Java-level deadlock它会直接告诉你哪两个线程互相等待哪些锁。如果 dump 里没直接标出死锁就搜waiting to lock和locked关键行手动推一下锁的持有和等待关系一般很快能定位到业务代码。我处理过的一起典型死锁是这样两个线程池 A 和 B业务代码里线程池 A 的任务需要向线程池 B 提交任务并等待结果而线程池 B 的线程池大小只有 1且 B 里这个唯一线程又在等待线程池 A 的结果。你等一下我、我等你一下两个池子直接全体挂死。这种“嵌套线程池相互等待”的问题光加线程没用必须从设计上打破循环等待避免跨线程池互相等待或者在提交任务时加超时。6.3 并发等待超时假死排查另一个高发问题是用CountDownLatch等待并发任务时主线程一直阻塞在await()上看起来像假死。先记住结论任何await()都必须带超时。然后按照下面三步排查数计数。在await()超时后打印doneLatch.getCount()看还剩几个计数没减掉。如果剩1个必然有一个任务的countDown()没执行。查异常路径。翻代码看work()方法内部有没有先return或者抛异常却跳过了 finally 的countDown()。这是我见到最多的原因。查线程状态。jstack看那个理论上应该执行countDown()的线程现在卡在哪。如果是调外部接口一直不通等于线程在等网络超时计数自然迟迟不来。把await()超时和 finally 里countDown()这两个习惯养成之后这类假死问题基本可以杜绝。最后说一点个人体会。用了这么多年 JUC最大的感受是类本身都是工具真正拉开差距的是你是不是能理解工具背后的机制。AQS 和 CAS 这套东西搞透了看到新类也能猜个八九不离十反过来如果只会背 API遇到线上问题照样抓瞎。这篇文章里的代码片段和参数建议都是我从生产环境里摸爬滚打总结出来的你可以直接抄但抄完一定得在自己的场景里压一压、调一调。并发编程没有银弹只有不断实测和复盘才是唯一可靠的路。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询