资讯动态

Java JUC并发编程实战:从synchronized到CAS与线程池

发布时间:2026/10/15 4:28:09 来源:尧图企业网站定制
最近带团队里几个刚转 Java 的同事做并发改造发现一个很有意思的现象一说“并发编程”大部分人第一反应是高大上、很难、不敢碰可真把代码摊开来看无非就是“多个线程同时干活但别把数据搞乱”。Java 里解决这个问题的主力工具就是 JUCjava.util.concurrent它把并发从“学院派”的黑话变成了“工程派”的工具箱。这篇文章我想用大白话把 JUC 的几样核心家底拆开讲讲再带大家从零走一遍实战改造的完整过程让没怎么碰过并发的朋友也能快速上手知道什么场景该用什么什么坑千万不能踩。1. 并发问题的本质所有混乱都源于“共享”1.1 竞态条件两个线程同时改一个变量会怎样先说一个最基础的场景。假设你写了个计数器每个线程进来就count代码长这样public class Counter { private int count 0; public void increment() { count; } }这段代码放在单线程里跑一万次结果是 10000没问题。但放到多线程里跑一万次结果经常是 9997、9998 这种“看起来不科学”的数字。原因在于count在 CPU 眼里根本不是一步操作而是三步读取当前值、把值加一、把新值写回去。线程 A 读到 count5还没来得及写回去线程 B 也读到 count5两边都加一最后写回去还是 6等于这一次递增被“吞”掉了。这就是竞态条件的典型表现。生活中类比一下一群人围着一个饮水机接水桶里只剩最后一杯的量两个人几乎同时把手伸过去最后谁都没接满。程序里的“饮水机”就是共享变量两个人的“手”就是线程的读写操作。1.2 可见性与有序性你以为改了别人却看不到除了竞态还有两个更难察觉的问题。可见性Java 里每个线程都有自己的工作内存可以理解为 CPU 缓存线程 A 改了 count 的值这个新值不一定会立刻刷到主内存里线程 B 读到的可能还是旧值。这就好比两个人共用一个笔记一个人改了内容但没把本子递过去另一个人看到的还是原来的版本。有序性编译器和 CPU 为了性能会调整指令顺序在单线程里这无所谓因为调整后结果一致但多线程下指令重排可能会导致一个线程看到另一个线程操作的“中间状态”逻辑上完全说不通。这三个问题——原子性、可见性、有序性就是并发编程所有坑的根源。Java 内存模型JMM就是来约束这三者的规则不管你是用传统的synchronized还是 JUC 里的各种类本质上都是在解决这三件事。1.3 传统 synchronized 的尴尬够用但不够顺手Synchronized是 Java 原生的同步关键字它确实能同时解决原子性、可见性和有序性。但用久了你会发现它有几个“不顺手”的地方锁的获取和释放是隐式的编程模型偏“黑盒”如果锁范围内代码出了问题排查起来比较痛苦。一个锁只有一种阻塞策略拿不到锁就等着。在读多写少的场景下性能优势不明显甚至因为锁竞争激烈而拖慢整体吞吐。正因如此Doug Lea 大神搞出了 JUC 这一整套并发工具它不是要完全替代synchronized而是在更精细的粒度上给你更多选择。下面我们就一一拆解这些核心工具的定位和用法。2. JUC 家族核心成员逐一拆解2.1 Atomic 原子类CAS 的简单粗暴之美JUC 里最“轻量”的一批工具是java.util.concurrent.atomic包下的 Atomic 类比如AtomicInteger、AtomicLong、AtomicBoolean等。它们解决的就是“原子性”问题核心机制叫 CASCompare And Swap比较并交换。CAS 的思路很简单更新一个变量之前先检查它当前的值是否还等于我读到时的“预期值”如果等于说明没人改过就更新成新值如果不等于说明别人动过了就重新读取再试一次。整个过程是 CPU 指令级别的原子操作不会被打断。import java.util.concurrent.atomic.AtomicInteger; public class AtomicCounter { private AtomicInteger count new AtomicInteger(0); public void increment() { count.incrementAndGet(); } public int getCount() { return count.get(); } }用AtomicInteger替换int之后incrementAndGet()本身就是原子的不再需要加锁。这种方案特别适合计数器、序列号生成器、简单的状态开关等高频小操作场景。但 CAS 不是万能的。它最经典的痛点是 ABA 问题一个线程读到值 A另一个线程把 A 改成 B 又改回 A第一个线程再用 CAS 时发现值还是 A认为没人动过其实中间已经变过两次了。大多数业务场景下 ABA 无伤大雅如果实在担心可以用AtomicStampedReference加版本号来解决。2.2 Lock 体系从“纯靠自觉”到“规则明确的红绿灯”JUC 里最核心的锁是ReentrantLock。跟synchronized相比它最大的优势是“可控”可以手动lock()和unlock()灵活控制锁的范围。支持尝试获取锁拿不到就立刻放弃或等一会儿用tryLock(timeout, unit)就能实现带超时的获取。支持中断响应线程在等待锁的过程中可以被打断避免“死等”。可以创建多个条件队列Condition精确唤醒某一类线程而不是像synchronized那样只能在对象监视器上等待和唤醒。来看一个简单的用法import java.util.concurrent.locks.ReentrantLock; public class LockCounter { private final ReentrantLock lock new ReentrantLock(); private int count 0; public void increment() { lock.lock(); try { count; } finally { lock.unlock(); } } }注意unlock()一定要放在finally里否则一旦锁内代码抛异常锁就永远解不开其他线程全部卡死。这种“手动上锁、手动解锁”的模型跟开车过红绿灯一样——规则明确但前提是你自己得记得松油门、踩刹车。2.3 并发工具三件套CountDownLatch、CyclicBarrier、Semaphore这三个类初看名字都带着希腊字母味儿吓唬人实际用生活场景一对应就很好理解了。CountDownLatch倒计时门闩就像跑步比赛的发令枪。所有选手都在起跑线等着发令枪响计数减到 0所有人才一起跑。典型用法是主线程等待多个子线程完成工作后再汇总。import java.util.concurrent.CountDownLatch; public class LatchDemo { public static void main(String[] args) throws InterruptedException { int workerCount 5; CountDownLatch latch new CountDownLatch(workerCount); for (int i 0; i workerCount; i) { new Thread(() - { try { System.out.println(Thread.currentThread().getName() 开始干活); Thread.sleep(1000); System.out.println(Thread.currentThread().getName() 干完了); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { latch.countDown(); } }).start(); } latch.await(); System.out.println(所有线程都干完了主线程继续执行); } }CyclicBarrier循环栅栏跟 CountDownLatch 有点像但区别在于它可以“循环使用”而且语义是“凑齐一批人之后再一起行动”。打个比方公司团建坐大巴导游规定人齐了才发车一批人到了之后下一批继续等人齐再发车。常用于分阶段并行计算比如多线程分块处理数据每跑完一轮就栅栏集合再进入下一轮。Semaphore信号量用来控制同时访问资源的线程数量。比如一个停车场只有 3 个车位进去一辆就占一个名额出来一辆就释放一个名额超过 3 辆车进来就只能排队等。import java.util.concurrent.Semaphore; public class SemaphoreDemo { public static void main(String[] args) throws InterruptedException { Semaphore semaphore new Semaphore(3); for (int i 0; i 10; i) { final int taskId i; new Thread(() - { try { semaphore.acquire(); System.out.println(线程 taskId 拿到资源开始工作); Thread.sleep(500); System.out.println(线程 taskId 释放资源); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { semaphore.release(); } }).start(); } } }这三个工具的选择逻辑很简单等别人干完再干用 CountDownLatch多线程分阶段协同用 CyclicBarrier限流、限量、限制并发数用 Semaphore。2.4 并发容器与线程池并发世界的“基础设施”JUC 还提供了一批线程安全的容器。ConcurrentHashMap是使用频率最高的一个它把锁粒度细化到了桶Node级别读操作基本无锁写操作只锁对应的桶所以并发性能远超给整个 Map 加锁的Hashtable或用Collections.synchronizedMap()包装出来的 Map。CopyOnWriteArrayList是另一种思路每次修改都在底层数组上复制一份再改读操作永远读到完整的旧数组所以读多写少的场景极其合适典型应用是事件监听器列表。代价就是写操作昂贵写频繁的场景不要用它。线程池方面核心是ThreadPoolExecutor。很多人直接用Executors.newFixedThreadPool()或者newCachedThreadPool()这两个快捷方法虽然省事但有隐患FixedThreadPool 的等待队列是无界的如果任务积压太多会占用大量内存CachedThreadPool 最大线程数是Integer.MAX_VALUE极端情况下可能创建出海量线程。真正要精细控制还是直接构造 ThreadPoolExecutor 更靠谱import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; public class ThreadPoolConfig { public static ThreadPoolExecutor buildPool() { return new ThreadPoolExecutor( 2, // 核心线程数 4, // 最大线程数 60L, // 空闲线程存活时间 TimeUnit.SECONDS, new ArrayBlockingQueue(100), // 有界队列 new ThreadPoolExecutor.AbortPolicy() ); } }核心线程数是日常处理能力队列是缓冲区最大线程数是扛压上限拒绝策略是最后一道防线。这四个参数的关系有点像餐厅的运营模型固定员工核心线程服务顾客任务顾客多了先排队队列队排太长了再叫临时工扩容到最大线程数连临时工都忙不过来就直接拒客拒绝策略。具体怎么设得看业务对响应时间和吞吐量的要求没有一套万能参数。3. 实战改造从 synchronized 到 JUC 的完整进化3.1 场景定义模拟一个高并发下单扣库存理论说多了容易飘我们直接落到一个实战场景模拟多用户同时下单系统要做库存扣减库存只有 10 件来了 100 个请求只能让 10 个成功剩下 90 个失败。先看第一版用最简单的synchronized实现import java.util.concurrent.CountDownLatch; import java.util.concurrent.atomic.AtomicInteger; public class StockServiceSynchronized { private int stock 10; public synchronized boolean decrease() { if (stock 0) { try { Thread.sleep(10); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } stock--; return true; } return false; } public static void main(String[] args) throws InterruptedException { StockServiceSynchronized service new StockServiceSynchronized(); int requestCount 100; CountDownLatch latch new CountDownLatch(requestCount); AtomicInteger successCount new AtomicInteger(0); for (int i 0; i requestCount; i) { new Thread(() - { try { if (service.decrease()) { successCount.incrementAndGet(); } } finally { latch.countDown(); } }).start(); } latch.await(); System.out.println(成功下单数 successCount.get()); } }这个方法的核心逻辑是检查库存大于 0 后再扣减整个过程用synchronized锁住确保原子性。Thread.sleep(10)用来模拟数据库写入等耗时操作。100 个请求跑完后成功数一定是 10因为锁保证了同时只有一个线程能执行检查并扣减的完整流程。synchronized在这个场景能解决问题但代价是每次下单都持续占用锁哪怕库存只剩 0 了所有请求依然要排队进来做一次无用的检查。这在高并发场景下就像一条单行道出入口全被堵死。3.2 JUC 版本重构细粒度控制与高吞吐接下来用 JUC 的武器库做第二版改造。方案是库存用AtomicInteger扣减并发请求用线程池提交统计结果用CountDownLatch等待。import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicInteger; public class StockServiceJuc { private AtomicInteger stock new AtomicInteger(10); public boolean decrease() { while (true) { int currentStock stock.get(); if (currentStock 0) { return false; } if (stock.compareAndSet(currentStock, currentStock - 1)) { try { Thread.sleep(10); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return true; } } } public static void main(String[] args) throws InterruptedException { StockServiceJuc service new StockServiceJuc(); int requestCount 100; CountDownLatch latch new CountDownLatch(requestCount); AtomicInteger successCount new AtomicInteger(0); ExecutorService executor Executors.newFixedThreadPool(16); for (int i 0; i requestCount; i) { executor.submit(() - { try { if (service.decrease()) { successCount.incrementAndGet(); } } finally { latch.countDown(); } }); } latch.await(); executor.shutdown(); System.out.println(成功下单数 successCount.get()); } }核心变化就在decrease()方法里先读取当前库存如果已经为 0 就直接失败返回压根不参与竞争然后通过compareAndSet尝试把库存从当前值扣到当前值减一若失败就说明别的线程抢先了自旋重试。这个版本有三个细节值得说早退优化库存为 0 时立刻返回 false不用排队进入锁相比synchronized版本无效请求的响应时间大幅缩短。自旋重试CAS 失败后不会阻塞线程而是循环重试适合这种临界区极短的场景。如果临界区操作很重自旋反而会浪费 CPU。线程池提交100 个任务提交到 16 个线程的池子里执行避免频繁创建和销毁 100 个线程的开销。3.3 效果对比与选择逻辑什么时候用哪个把两版代码对比一下可以发现维度synchronized 版本JUC 版本锁粒度整个方法单个变量CAS库存为 0 时行为所有线程仍排队执行快速失败返回等待策略阻塞自旋线程管理手动创建 Thread线程池复用代码复杂度更低略高但更高可控但我要说一句公道话并不代表 JUC 就一定比 synchronized 好。如果你的业务就是低并发、代码追求极简synchronized依然是很棒的选择。JUC 的优势在于你可以在吞吐率、响应时间、资源占用之间做更精细的取舍就像手动挡和自动挡的区别——自动挡省心手动挡更多驾驶乐趣和操控空间。而且synchronized在 Java 6 之后引入了偏向锁、轻量级锁等优化很多场景下性能差距并没有“听起来”那么大。真正的选型逻辑应该按场景来分一个简单的共享计数器首选AtomicInteger。临界区逻辑复杂包含多个共享变量的协同修改用ReentrantLock或synchronized更合适。读多写少、数据量大考虑ConcurrentHashMap等并发容器。需要控制并发线程数加Semaphore。4. 常见问题与排查实录4.1 死锁锁嵌套里的“你等我、我等你”实战里最容易翻车的不是锁不够用而是锁用过头导致死锁。最常见的情况是线程 A 持有锁 1想获取锁 2线程 B 持有锁 2想获取锁 1两边互相等程序就“冻住”了。排查死锁我习惯先jps找到进程号再jstack pid导线程栈搜Found one Java-level deadlock关键字就能看到具体是哪两个线程、哪两把锁纠缠上了。要避免死锁有几条实操经验尽量缩小同步块的粒度别一个方法从头锁到尾。如果确实要加多把锁保证所有线程拿锁的顺序一致。用tryLock代替lock拿不到锁就释放已有锁并重试从源头杜绝互相等待。4.2 并发修改异常边遍历边修改的致命伤用普通容器比如ArrayList在多线程里一个线程遍历、另一个线程增删很容易抛出ConcurrentModificationException。很多人第一反应是“给遍历加锁”但如果写多读少更合理的方案是用CopyOnWriteArrayList这类并发容器。我自己踩过的一个坑是有一回用ConcurrentHashMap做缓存但在遍历时对 value 做更新操作本以为并发容器一定安全结果因为 key 集合在遍历过程中被其他线程新增了逻辑上出现了数据不一致。后来改用computeIfAbsent和compute这类原子方法才彻底解决。记住并发容器保证的是容器结构本身的操作安全它并不保证你基于容器内容做出的业务判断是安全的。4.3 线程池使用陷阱队列、拒绝策略与上下文传播线程池的常见问题可以整理成一张速查表问题现象可能原因解决方案任务积压内存飙升无界等待队列改用ArrayBlockingQueue并设合理容量线程数爆增Executors.newCachedThreadPool()无上限手动创建ThreadPoolExecutor限制最大线程数任务莫名丢失默认拒绝策略是AbortPolicy异常被吞自定义拒绝策略 全局异常处理核心线程不释放调用了shutdownNow()但有任务还在跑结合awaitTermination()优雅停机父线程请求信息丢了线程池复用时 ThreadLocal 未传递使用TransmittableThreadLocal或手动传递线程池的坑之所以隐蔽是因为大部分问题不在第一波高并发时暴露而是系统跑了很久、某一刻流量尖峰冲过来才炸。所以我在团队里定了一条规矩线上线程池必须设置线程名前缀核心线程数和队列大小必须经过压测确认拒绝策略必须有告警日志。4.4 锁的自旋与性能误判别一看 WAITING 就以为死锁还有一个容易误判的情况用jstack看到大量线程处于WAITING (parking)状态新手往往以为死锁了。实际上这很可能只是线程在队列里正常等待锁比如ReentrantLock的线程等待。区分方法很简单死锁会有明确的环形依赖关系在线程栈里能看到两个线程互相持有对方需要的锁而普通锁等待是多个线程指向同一把锁的 owner。真等久了你再结合业务超时判断而不是一看到很多WAITING就慌了。5. 新手最容易忽略的细节与我的实操心得5.1 异常处理finally 里释放锁不只是一个好习惯前面反复强调过lock()之后必须在finally里unlock()。我见过不少线上故障就是因为漏了这个finally某次业务抛异常后锁没释放整条链路像多米诺骨牌一样全部卡死。同样地CountDownLatch的countDown()也建议放在finally里特别是用线程池时如果任务抛异常后没执行到countDown()主线程会一直await()等到天荒地老。这是新手最容易踩的隐形坑。5.2 并发不是银弹JUC 也不是所有问题的答案聊了这么多 JUC 的好处最后我还是想泼一点冷水。并发问题的最优解有时候不是通过更高级的并发工具而是通过设计层面避开共享。举个例子一个统计接口要聚合多个数据源的结果与其用并发容器加锁做共享累加不如每个线程维护一个独立计数器最后用ConcurrentHashMap这种支持合并策略的方式汇总。这样共享冲突的几率大大减小代码也更清爽。还有一点能用不可变对象就别用可变对象能让对象“线程封闭”在线程内就别让它“泄露”出去。这些原则听上去没有用Lock和Atomic那么酷但实战中对系统稳定性的贡献远超某一次锁优化。我个人的体会是JUC 是一把好用的工具箱但真正让我们把并发问题处理好的永远是先把数据边界和线程边界画清楚再决定用哪把工具。这样想清楚了并发那张高冷的面具自然就摘下来了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑