ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

JUC并发编程:从原子类到显式锁,全面拆解Java并发基石

JUC并发编程:从原子类到显式锁,全面拆解Java并发基石 JUC全称 java.util.concurrent是Java并发编程的基石。作为一个写了几年业务代码、又踩过不少并发坑的开发者我最近把JUC从头到尾重新捋了一遍发现以前很多“能用但说不清”的地方都串起来了。这篇是JUC学习笔记的第一期先把最核心的地图画出来再用两个高频场景原子累加、显式锁把底层原理拆开揉碎讲清楚。适合正在学并发、准备面试、或者想把手头线程安全问题解决得更优雅的Java开发者。1. 为什么基本功越扎实越能看懂JUC1.1 线程协作不是靠“感觉”的写并发代码最怕的就是“看起来对跑起来错”。我在早期写过一个库存扣减多个线程同时去扣一个共享变量明明每条线程都做了判断结果库存还是扣成了负数。后来才知道这是典型的竞态条件判断和修改之间不是原子的线程A读到库存还有1件线程B也读到1件两边都减1最终变成0甚至-1。另一个容易忽略的问题是内存可见性。Java内存模型规定每个线程有自己的工作内存普通变量在某个线程里修改后其他线程不一定会立即看到。你以为是所有线程在操作同一个变量实际上可能是各自缓存里的一份副本。再加上编译器和CPU为了优化会做指令重排就算变量在代码顺序上没问题执行顺序也可能和你想象的完全不同。这些问题靠“加个synchronized”或者“用volatile”能解决一部分但解决得不够优雅。synchronized能保证原子性和可见性可它无法支持超时、无法响应中断、也做不到按等待时间公平分配锁。volatile只能解决可见性和有序性无法解决多个线程同时写同一个变量时的原子性。真正要系统性地解决并发问题还是得靠JUC这套专门为并发设计的工具集。1.2 JUC解决的是 synchronized 的低级问题JUC并不是要取代synchronized而是把并发控制的粒度做得更细能力做得更强。synchronized是隐式的锁的获取和释放由JVM自动处理省事但不可控。JUC里的Lock是显式的你需要自己lock也必须自己unlock但换来了tryLock超时、lockInterruptibly中断等能力。更关键的是JUC提供了很多synchronized给不了的东西。比如你不用锁也能保证线程安全的原子类基于CAS无锁算法比如读写锁ReentrantReadWriteLock读多写少场景下能把并发度拉满再比如CountDownLatch、Semaphore、CyclicBarrier这些同步器处理线程之间的协作比wait/notify清晰得多。所以说JUC是Java并发编程从“能用”走向“会用”的分水岭一点也不夸张。2. JUC的整体地图先看清有哪些积木2.1 原子类无锁并发的基础JUC最基础的一块积木是原子类。它们在java.util.concurrent.atomic包下提供了一组支持原子更新的变量类型。我平时用得最多的是AtomicInteger、AtomicLong、AtomicBoolean还有Java 8之后加入的LongAdder。它们的基本思路不是用锁互斥而是用CASCompare And Swap配合自旋来解决并发下的读改写问题。CAS的无锁特性在低竞争场景下非常快但也要注意高竞争下的自旋开销这个后面细说。2.2 锁显式控制并发协作锁是JUC的重头戏。以ReentrantLock为代表它实现了Lock接口提供了可重入、可中断、可超时、公平/非公平可选的锁。底层依赖AQSAbstractQueuedSynchronizer后面我会单独展开。和它配合的还有ReentrantReadWriteLock把读锁和写锁分开读读之间不互斥适合读操作远大于写操作的场景。StampedLock则更进一步支持乐观读在读多写少且数据不敏感时性能更猛但它不可重入用起来需要注意。2.3 并发容器、线程池、同步器各司其职再往上JUC还提供了并发容器比如ConcurrentHashMap、CopyOnWriteArrayList、BlockingQueue系列它们比Collections.synchronizedXxx包装出来的集合效率高得多因为锁粒度更小甚至无锁。线程池方面有ThreadPoolExecutor、ScheduledThreadPoolExecutor以及Executors工具类。同步器则有CountDownLatch、CyclicBarrier、Semaphore、Exchanger用来协调多线程的分工、汇合、限流。整个JUC的组件可以整理成这样一张对照表类别典型类核心解决什么问题适用场景原子类AtomicInteger、LongAdder无锁的原子读改写计数器、累加器、状态标记锁ReentrantLock、ReentrantReadWriteLock显式互斥、读写分离需要超时、中断、公平性的临界区并发容器ConcurrentHashMap、BlockingQueue高并发下的安全集合缓存、消息队列、任务池线程池ThreadPoolExecutor复用线程、管理生命周期异步任务、批量处理同步器CountDownLatch、Semaphore多线程协作与限流等待多任务完成、控制并发数学JUC的时候我建议别一上来就扎进源码而是先把这张地图记在脑子里。知道有什么工具、解决什么问题遇到具体场景才谈得上选型和优化。3. 实战一从 AtomicInteger 理解 CAS 无锁编程3.1 一段“看似正确”的多线程累加问题先看一个最常见的并发错误。我们写一个累加器开10个线程每个线程累加10000次期望结果是100000public class CounterDemo { private static int count 0; public static void main(String[] args) throws InterruptedException { int threadCount 10; Thread[] threads new Thread[threadCount]; for (int i 0; i threadCount; i) { threads[i] new Thread(() - { for (int j 0; j 10000; j) { count; // 不是原子操作 } }); threads[i].start(); } for (Thread t : threads) { t.join(); } System.out.println(最终结果: count); } }跑几次你就会发现结果有时候是100000有时候是98567有时候是99912反正很少稳定等于目标值。原因就是count这个操作在字节码层面包含三条指令读取count、加1、写回count。两条线程同时读到同一个旧值再分别写回累加就丢失了一次。我刚开始学并发的时候第一反应是给累加操作加synchronized。这当然能解决但加锁意味着获取锁、阻塞、唤醒在高频累加场景下会有明显的线程上下文切换开销。JUC里的原子类就是为这种场景准备的。3.2 用 CAS 解决AtomicInteger 怎么做到不加锁也安全我们换成AtomicIntegerimport java.util.concurrent.atomic.AtomicInteger; public class AtomicCounterDemo { private static AtomicInteger count new AtomicInteger(0); public static void main(String[] args) throws InterruptedException { int threadCount 10; Thread[] threads new Thread[threadCount]; for (int i 0; i threadCount; i) { threads[i] new Thread(() - { for (int j 0; j 10000; j) { count.incrementAndGet(); } }); threads[i].start(); } for (Thread t : threads) { t.join(); } System.out.println(最终结果: count.get()); } }这次无论跑多少次结果都是稳定的100000。AtomicInteger的核心是incrementAndGet方法它的内部逻辑可以用一段伪代码来描述“比较并交换”先读取当前内存值V然后把V作为预期值在更新前再比较一次内存值是否还是V如果是就更新为新值U如果不是说明其他线程改过了就重新读取再试直到成功为止。这个“读-改-写”的循环就是CAS自旋。在Java源码里AtomicInteger.incrementAndGet最终会调用Unsafe的compareAndSwapInt这是一个由CPU指令支持的原子操作。也就是说它不是用软件锁而是直接靠硬件指令保证比较和交换这两个动作不可分割。所以CAS又被叫做无锁编程因为它没有让线程阻塞而是让失败的线程“重试”。3.3 实操心得原子类适用的场景和边界原子类好用但不是万能药。我踩过几个坑分享给你。第一个坑是ABA问题。假设一个线程读到的值是A准备改成C时另一个线程先把A改成B再把B改成A那第一个线程做CAS时看到内存值还是A就认为没人动过继续更新。这在水位、金额这种“发生过变化但回到原值就会出问题”的场景是不能接受的。解决办法是用AtomicStampedReference它额外带一个版本号每次改变版本号都递增CAS时同时比较值和版本号。第二个坑是自旋在竞争激烈时会很耗CPU。当大量线程同时争抢同一个AtomicInteger时失败线程会不停重试空转占满CPU。我遇到过线上一个高并发计数器直接导致CPU使用率飙到90%以上。所以高并发、大基数的累加场景我更推荐用LongAdder它内部把热点分散到多个单元最后再汇总竞争压力小得多。第三个坑是原子类只适合解决单个变量的读改写。如果你的业务逻辑是“先检查库存是否充足再扣减”这个操作跨了多个变量或者有复杂的业务判断CAS就不好使了这种时候应该考虑加锁。4. 实战二ReentrantLock 的可控加锁体验4.1 从 synchronized 到 Lock 的迁移再来看另一块核心积木ReentrantLock。为什么有了synchronized还要用它我给大家一个非常典型的例子。比如要模拟一个转账操作同一个账户对象只允许一个线程改余额private final Lock lock new ReentrantLock(); public void transfer(int amount) { lock.lock(); try { balance balance amount; // 其他业务逻辑 } finally { lock.unlock(); } }这段代码和synchronized看起来差不多但注意try-finally结构手动释放锁是必须的。我早期就犯过只加锁忘记在finally里unlock的错结果一个线程获取锁后抛异常其他线程全部阻塞在那个lock.lock()上最后排查了很久才发现是死锁。所以用Lock的时候我会先在脑子里立个规矩lock之后第一行一定是tryfinally里一定是unlock中间写业务。那么ReentrantLock相对synchronized到底有什么优势最直观的是可重入同一个线程可以多次获取同一把锁不会把自己锁死。这个synchronized也有。但它独有的能力是下面几个。4.2 公平锁与非公平锁的取舍ReentrantLock取名Reentrant可重入构造时可以指定公平策略。默认是非公平锁也就是线程获取锁的顺序不一定遵守请求顺序可能“后来的先抢到”。非公平锁的优势是吞吐量高因为只要锁被释放恰好有个线程在自旋或刚来就能直接抢到省去唤醒和排队。缺点是可能造成线程“饿死”等待很久的线程仍然抢不到锁。公平锁会让线程按照请求的先后顺序排队获取代价是性能相对较低因为每个刚到的线程都要检查队列里有没有更早的等待者。我个人的选型经验是业务里没有极端要求默认用非公平锁如果担心某些线程长时间拿不到锁比如批量任务分配可以选公平锁。Lock fairLock new ReentrantLock(true); // 公平锁 Lock unfairLock new ReentrantLock(); // 非公平锁这里要提醒一点公平锁的实现比非公平锁复杂它对性能的影响在低并发下几乎感觉不到但高并发下差距会明显。所以在大多数业务场景中没必要刻意追求公平。4.3 锁超时和中断Lock 独有的“反悔”能力ReentrantLock还有一个我很看重的能力获取锁的时候可以设置超时。比如我们有一个资源被多个线程竞争不想让线程无限等下去if (lock.tryLock(2, TimeUnit.SECONDS)) { try { // 成功拿到锁执行业务 } finally { lock.unlock(); } } else { // 超时没拿到走降级逻辑 System.out.println(获取锁超时改走备用方案); }tryLock不传参数时是立即尝试不会阻塞传了时间和单位就会等待一段时间超过时间还没拿到就返回false。这种方式非常适合做“能拿到锁就做拿不到就快速失败”的降级处理避免线程长时间挂在锁上。lockInterruptibly则允许等待锁的过程中响应线程中断。如果一个线程在等待锁时被其他线程调用了interrupt()它就会抛出InterruptedException而不是傻等。这比synchronized那种“拿不到锁就一直阻塞”的方式人性化得多。4.4 AQS 在背后做了什么学ReentrantLock绕不开AQS因为它是JUC锁的核心底座。AQS维护了一个volatile的int类型变量state用来记录锁的状态同时维护一个等待队列先把拿不到锁的线程包装成Node节点放进先进先出的队列里排队。拿ReentrantLock举例state为0表示锁未被占用线程拿到锁后state变成1可重入一次state就加1。释放锁时state减1直到归0才表示真正释放。如果同时有多个线程竞争AQS会通过CAS去改state失败的线程进入等待队列通过LockSupport.park挂起。等锁释放后AQS会从队列里唤醒合适的线程继续尝试。理解AQS的“状态队列”模型后面再看Semaphore、CountDownLatch、ReentrantReadWriteLock就轻松了。它们本质上是AQS在不同语义下的应用state代表计数、许可证数量等队列负责等待与唤醒。所以我才反复强调第一期学习笔记一定要先把锁和AQS的关系吃透后面都是一通百通。5. 综合小案例用 JUC 重构一个并发扣减库存的服务5.1 需求分析与基础版本说了这么多理论我们来搞一个有实际业务味道的小案例高并发场景下的库存扣减。需求很简单一个商品有100件库存100个用户并发抢购每人最多买1件不允许超卖。我见过很多人第一版代码长这样public class StockService { private int stock 100; public void deduct() { if (stock 0) { // 模拟一些耗时操作 stock--; System.out.println(扣减成功剩余: stock); } else { System.out.println(库存不足); } } }这段代码在单线程下没问题但并发环境下必然出乱子。两个线程同时通过stock 0的判断然后一起执行stock--就会把100件库存卖出101件。解决办法有很多但我想演示的是JUC的两种经典解法分别对应原子类和锁。5.2 升级到JUC版本先看用AtomicInteger版本。把库存换成AtomicInteger并且用decrementAndGet的返回值来判断是否扣减成功import java.util.concurrent.atomic.AtomicInteger; public class AtomicStockService { private AtomicInteger stock new AtomicInteger(100); public boolean deduct() { while (true) { int current stock.get(); if (current 0) { return false; // 库存不足 } int next current - 1; if (stock.compareAndSet(current, next)) { System.out.println(扣减成功剩余: next); return true; } // CAS失败说明有其他线程改过了重试 } } }这段代码的关键是compareAndSet(current, next)。它不会像stock--那样盲目减一而是先在循环里拿到当前值current计算出next再尝试把current更新为next。只有字段值还是current时更新才会成功否则说明别的线程已经扣过就会重新循环读取最新值。这样就保证了“判断库存0”和“扣减”两个动作是一体的。如果业务逻辑更复杂一点比如需要同时判断多个条件或者扣减时要同步记录一条订单流水那原子类就有点吃力了。这时候我用ReentrantLock把整个临界区包起来import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; public class LockStockService { private int stock 100; private final Lock lock new ReentrantLock(); public boolean deduct() { lock.lock(); try { if (stock 0) { return false; } stock--; // 这里可以放心地做其他非原子操作 System.out.println(扣减成功剩余: stock); return true; } finally { lock.unlock(); } } }5.3 测试结果与性能对比我拿这两种方案做了个小测试开100个线程同时调用扣减方法每个线程尝试一次。AtomicInteger版本最终剩余0Lock版本最终剩余0都没有超卖。从耗时上看在低竞争下AtomicInteger略快因为少了线程阻塞唤醒在高竞争下两者差距会缩小但Lock的稳定性更好不会像高竞争CAS那样大量自旋导致CPU飙升。如果你的库存字段本身就在数据库里那肯定用数据库的行锁/乐观锁更合适。JUC这些方案更适合做本地内存级别的限流、熔断、计数器或者缓存库存扣减。在电商秒杀这种真实场景中往往是JUC先做本地预扣减挡住大头流量数据库再做最终校验两层配合使用。6. 常见问题与排查技巧实录6.1 使用 CAS 自旋导致 CPU 飙高怎么办我在实际项目里就遇到过一个高频刷新指标的服务用AtomicInteger去累加数据并发量一上来CPU直接打满。排查后发现失败线程都在无限循环CAS导致大量空转。解决思路有两个一是换LongAdder它内部做了热点分散多个线程改不同的Cell最后sum()再合并二是结合Thread.yield或线程休眠让出CPU但更推荐前者。总之使用原子类前先评估一下竞争强度。6.2 ReentrantLock 忘记 unlock 会怎样忘掉unlock是Lock使用最大的坑。一旦在执行业务时抛异常锁就永远不释放其他线程全部阻塞。所以无论如何都要用try-finally包裹。我还会在开发环境的代码检查规则里强制要求lock之后不允许直接写业务代码必须先写try再写finally unlock。这个习惯养成了死锁问题能减少一大半。还有一个细节ReentrantLock是可重入的同一个线程可以lock多次但unlock也必须配对相应次数。假设你写了一个递归方法每次都lock而没有一一对应unlock最后锁也释放不了。所以我写代码时会特别注意lock和unlock的层级对应关系。6.3 如何选择 synchronized 和 JUC 锁这是所有学JUC的人都会纠结的问题。我的判断标准很简单如果只是保证一段短代码的互斥synchronized写起来最简洁java.util.concurrent的Lock接口并行度更高但使用更繁琐。真正需要超时获取锁、响应中断、公平排队这些能力时ReentrantLock才是更合适的选择。另外读多写少的场景优先考虑ReentrantReadWriteLock或StampedLock。JUC官方其实也一直建议能用synchronized解决的问题优先用synchronized避免过度设计。6.4 JUC学习路线建议如果你刚开始学JUC我建议不要一上来就啃源码。先把整体地图记牢然后按这条路线推进先写几个多线程安全失败的例子亲眼看到线程不安全的结果再用原子类解决简单计数问题理解CAS和ABA接着用ReentrantLock解决互斥问题理解AQS的state和等待队列然后去研究ConcurrentHashMap和线程池最后用CountDownLatch、Semaphore做线程协作小场景。每一步都要动手跑代码不要只看博客。我个人在实际操作中还有一个习惯每学一个组件就把它和上一个组件对比比如AtomicInteger和synchronized对比、ReentrantLock和synchronized对比、ConcurrentHashMap和Hashtable对比。对比着学才能发现JUC真正的设计精妙之处。第一期笔记先到这里下一步我准备把AQS源码和线程池的核心参数拆开讲这段时间也在整理第二期的素材到时候可以直接照着跑。
返回列表