资讯动态

JUC并发编程实战:从内存模型到线程池的进阶指南

发布时间:2026/10/14 1:41:59 来源:尧图企业网站定制
1. 为什么JUC是Java进阶路上绕不开的那道坎刚入行那会儿我对JUC这三个字母是有点发怵的。面试造火箭工作拧螺丝很多人背了一堆AQS、CAS、ThreadLocal的八股文真到了线上出了问题连线程池的队列满了该看哪个指标都不知道。这个系列我打算持续更新把自己这些年从写业务代码到做中间件、从排查线上CPU飙高到重构并发模块的经验一点一点拆开揉碎讲清楚。JUC全称java.util.concurrent是JDK 1.5引入的并发工具包由并发领域的大师级人物主导设计。它解决的核心问题就一个让多线程编程从“手动挡”变成“自动挡”。在JUC出现之前我们写并发只能靠synchronized、wait/notify、Thread这些原始工具代码又臭又长还特别容易死锁。JUC把锁、同步器、线程池、并发容器、原子类这些常用能力全部标准化了你不需要重新造轮子只要理解它的设计意图就能写出既安全又高效的并发代码。这个系列适合谁看如果你已经能写Java业务代码但对volatile和synchronized的区别说不清楚线程池参数全靠默认值遇到ConcurrentModificationException就加synchronized一把梭那这个系列就是为你准备的。我不会只贴API文档而是把每个组件背后的“为什么”讲透再配上我实际踩过的坑和调优参数。你可以把它当成一本可以随时翻开的并发实战手册遇到问题就来找对应的章节。2. 从内存模型说起并发问题的根到底在哪2.1 可见性、原子性、有序性三座大山很多人学JUC上来就背ReentrantLock和CountDownLatch结果面试官一问“为什么需要这些工具”就卡壳了。并发编程的所有麻烦归根结底是三个问题可见性、原子性、有序性。可见性说的是一个线程改了共享变量另一个线程能不能立刻看到。在单核时代这不是问题但多核CPU为了性能每个核心都有自己的缓存线程A改了变量先写进自己的缓存线程B读的还是旧值。原子性说的是一个操作要么全做完要么全不做i看着是一行代码实际上分读取、加一、写回三步多线程下必然出问题。有序性说的是编译器和CPU为了优化性能可能会调整指令执行顺序单线程下没问题多线程下就可能读到“半成品”对象。我见过一个真实的线上案例一个开关变量用普通boolean修饰主线程改了值工作线程死活读不到重启才恢复。后来改成volatile问题消失。这就是典型的可见性问题。所以理解JUC第一步不是学工具而是理解Java内存模型JMM是怎么定义这些规则的。2.2 JMM的happens-before规则比你想的更实用JMM定义了一套happens-before规则用来描述两个操作之间的内存可见性。如果A happens-before B那么A的结果对B可见。这条规则听起来抽象但实际用起来非常具体。比如程序顺序规则一个线程内前面的操作happens-before后面的操作。监视器锁规则解锁happens-before后续对同一个锁的加锁。volatile变量规则对volatile变量的写happens-before后续对这个变量的读。传递性规则A h-b BB h-b C那么A h-b C。我排查并发问题时经常用这套规则反推如果两个线程之间没有建立happens-before关系那共享变量的读写就是不确定的。这比死记“加锁就安全”要靠谱得多。举个例子ConcurrentHashMap的get操作不加锁也能保证可见性就是因为内部用了volatile和Unsafe的读屏障建立了happens-before关系。2.3 volatile不是“轻量级锁”它解决的是另一个问题新手最容易犯的错就是把volatile当成“轻量级synchronized”。volatile只保证可见性和有序性不保证原子性。你用volatile int count然后count多线程下照样丢数据。那volatile到底用在哪两个典型场景一是状态标志位比如volatile boolean running true一个线程改另一个线程循环读二是双重检查锁定的单例模式volatile防止指令重排导致拿到未初始化完的对象。我个人的经验是能用volatile解决的就别上锁因为锁的开销和死锁风险都更大但前提是你真的理解它不保证原子性。3. 锁的进化史从synchronized到AQS3.1 synchronized的锁升级面试必问但更要会用synchronized在JDK 1.6之后做了大量优化引入了偏向锁、轻量级锁、重量级锁的升级过程。偏向锁针对的是“同一个线程反复进入同步块”的场景轻量级锁针对的是“不同线程交替进入但竞争不激烈”的场景只有真正发生激烈竞争时才会膨胀成重量级锁走操作系统互斥量。这个机制的实际意义是大部分情况下你不需要手动优化synchronized。我见过有人为了“性能”把简单的同步块改成ReentrantLock结果代码复杂度上去了性能反而没提升。除非你有明确的证据表明锁竞争是瓶颈否则优先用synchronized代码简洁JVM还会帮你优化。不过有一点要注意偏向锁在JDK 15之后默认关闭了因为维护成本高、收益有限。如果你还在用JDK 8可以关注一下-XX:-UseBiasedLocking这个参数高并发场景下关闭偏向锁有时反而更稳。3.2 ReentrantLock的三大杀手锏ReentrantLock比synchronized灵活在哪三个地方可中断、可超时、可公平。可中断意味着你可以在等待锁的时候响应中断避免线程一直卡死。可超时意味着你可以设置tryLock(3, TimeUnit.SECONDS)拿不到锁就去做别的事而不是无限等待。可公平意味着你可以选择公平锁让等待时间最长的线程优先获取锁但公平锁的吞吐量通常更低非公平锁反而更高效因为减少了线程切换。我实际用ReentrantLock最多的场景是带超时的资源获取。比如一个任务需要拿两个锁用synchronized很容易死锁用tryLock加上超时和回退逻辑就能有效避免。代码大概长这样if (lock1.tryLock(1, TimeUnit.SECONDS)) { try { if (lock2.tryLock(1, TimeUnit.SECONDS)) { try { // 业务逻辑 } finally { lock2.unlock(); } } } finally { lock1.unlock(); } }3.3 AQSJUC的绝对核心理解它一通百通AbstractQueuedSynchronizerAQS是JUC里最核心的抽象类ReentrantLock、CountDownLatch、Semaphore、ReentrantReadWriteLock全部基于它实现。AQS的核心就三样东西一个volatile的state变量、一个CLH双向队列、一套模板方法。state表示同步状态比如ReentrantLock里state为0表示没锁大于0表示锁被持有且重入次数。CLH队列用来存放等待的线程每个线程包装成Node节点通过CAS操作入队和出队。模板方法定义了获取和释放的骨架子类只需要实现tryAcquire和tryRelease。理解AQS之后你看CountDownLatch的源码就会很轻松state初始化为计数N每次countDown就是CAS减一减到0就唤醒队列里所有等待的线程。Semaphore也一样state表示许可数量acquire就是减一release就是加一。我建议每个学JUC的人都花时间把AQS的源码读一遍不用全懂但至少把acquire和release的主流程跟下来之后再看其他同步器就是降维打击。4. 线程池最容易用错也最该用对的组件4.1 七个参数每一个都有坑ThreadPoolExecutor的构造函数有七个参数核心线程数、最大线程数、空闲存活时间、时间单位、工作队列、线程工厂、拒绝策略。很多人只改前两个后面全用默认结果线上出了各种诡异问题。核心线程数和最大线程数的关系取决于队列类型。如果用的是LinkedBlockingQueue且没指定容量那队列几乎永远不会满最大线程数就形同虚设线程数永远停在核心线程数。这就是为什么Executors.newFixedThreadPool不推荐使用——它的队列是无界的任务堆积会导致OOM。工作队列的选择直接决定线程池的行为。SynchronousQueue不存储任务来了就交给线程适合高吞吐短任务。ArrayBlockingQueue有界能防止任务无限堆积。PriorityBlockingQueue带优先级但要注意任务必须实现Comparable。我的经验是生产环境永远用有界队列然后根据业务QPS和任务耗时估算容量。拒绝策略有四种抛异常、丢弃、丢弃最老的、 caller runs。默认的抛异常策略其实是最安全的因为它能让上游感知到压力。CallerRunsPolicy会让提交任务的线程自己执行相当于一种负反馈能减缓任务提交速度。我一般用自定义拒绝策略记录日志并报警而不是静默丢弃。4.2 线程数到底设多少别信“CPU核数1”“CPU密集型设N1IO密集型设2N”这个说法流传很广但实际工作中不能生搬硬套。线程数的本质是让CPU和IO设备都尽量忙碌。如果任务是纯计算线程数等于核数确实最优多了只会增加上下文切换。但如果是IO密集型比如调外部接口、读写数据库线程大部分时间在等待那线程数可以远大于核数。我通常用这个公式估算线程数 核数 * 目标CPU利用率 * (1 等待时间/计算时间)。比如8核目标利用率80%等待时间是计算时间的9倍那线程数大约是8 * 0.8 * 10 64。当然这只是起点最终要靠压测和监控来调。线上一定要监控线程池的活跃线程数、队列大小、拒绝次数这些指标比任何公式都可靠。4.3 线程池的监控和动态调参线程池一旦创建核心参数就固定了但业务流量是变化的。JDK原生ThreadPoolExecutor没有提供动态修改核心线程数和最大线程数的公开方法但可以通过反射或者继承重写来暴露setCorePoolSize和setMaximumPoolSize。很多中间件就是这么做的。监控方面我习惯在自定义ThreadFactory里给线程起有意义的名字比如“order-process-pool-1”这样出问题时jstack一眼就能定位。另外重写beforeExecute和afterExecute可以统计任务执行耗时重写terminated可以感知线程池关闭。这些扩展点用好了排查问题会轻松很多。5. 并发容器与原子类别再用错数据结构5.1 ConcurrentHashMap的演进与使用禁忌ConcurrentHashMap在JDK 7和JDK 8的实现差异很大。JDK 7用分段锁把整个Map分成多个Segment每个Segment独立加锁。JDK 8改成CAS synchronized锁的粒度细化到每个桶并发度更高。使用上有几个禁忌第一不要用size()方法做精确判断它返回的是估计值因为并发环境下统计精确大小代价太高。第二不要用containsValue()它需要遍历整个Map性能很差。第三复合操作要用putIfAbsent、computeIfAbsent、merge这些原子方法而不是先get再put否则中间会有竞态条件。我见过一个典型错误用ConcurrentHashMap做缓存先get判断是否存在不存在再put结果高并发下同一个key被重复计算和写入。改成computeIfAbsent之后问题解决代码还更简洁。5.2 CopyOnWriteArrayList的适用场景非常窄CopyOnWriteArrayList每次写操作都会复制整个数组所以写性能很差但读性能极好因为读不需要加锁。它适合读多写极少的场景比如监听器列表、配置白名单。如果你的场景是频繁写入千万别用它否则GC压力会让你怀疑人生。我实际用它的场景是事件监听器注册表启动时注册一批监听器运行时几乎不变但每次事件触发都要遍历。这种场景下CopyOnWriteArrayList非常合适遍历时不用担心ConcurrentModificationException。5.3 原子类的CAS原理和ABA问题AtomicInteger、AtomicLong、AtomicReference这些原子类底层用的是CASCompare And Swap操作。CAS有三个值内存地址、预期旧值、新值。只有当内存中的值等于预期旧值时才更新为新值否则失败重试。CAS的经典问题是ABA线程1把值从A改成B又改回A线程2用CAS检查时发现还是A就以为没变过。解决方法是加版本号AtomicStampedReference就是干这个的。不过实际业务中ABA问题很少遇到除非你在做无锁队列或者复杂的状态机。另外CAS在高竞争下会大量自旋消耗CPU。JDK 8引入了LongAdder用分段累加的方式减少竞争吞吐量比AtomicLong高很多。如果你在做计数统计优先用LongAdder。6. 同步器工具箱CountDownLatch、CyclicBarrier、Semaphore怎么选6.1 CountDownLatch一次性的等待CountDownLatch的典型用法是“主线程等待多个子任务完成”。初始化时指定计数N每个子任务完成后调用countDown主线程调用await阻塞直到计数归零。注意它是一次性的计数归零后不能重置。我常用它来做并行任务的聚合。比如一个接口需要查三个下游服务用三个线程并行查主线程await全部返回后合并结果。比串行查快很多代码也不复杂。6.2 CyclicBarrier可循环的屏障CyclicBarrier和CountDownLatch的区别在于CyclicBarrier是多个线程互相等待到齐后一起继续而且可以循环使用。它适合分阶段任务比如多线程计算每算完一轮就同步一次再进入下一轮。CyclicBarrier还有一个高级用法构造时可以传入一个Runnable当所有线程到达屏障时这个Runnable会由最后一个到达的线程执行。我见过用它来做每轮计算的汇总挺巧妙的。6.3 Semaphore控制并发访问数量Semaphore维护一组许可acquire获取许可release释放许可。它适合限流场景比如控制同时访问某个资源的线程数不超过N。和线程池的区别是Semaphore不创建线程只是控制进入临界区的线程数量。我实际用Semaphore做过接口限流给每个下游接口分配一个Semaphore调用前acquire调用后release超过并发数就阻塞或快速失败。比用线程池隔离更轻量。7. 并发编程的实战避坑心得7.1 死锁的排查和预防死锁的四个必要条件互斥、持有并等待、不可剥夺、循环等待。破坏任意一个就能避免死锁。最实用的是破坏循环等待给所有锁定义全局顺序所有线程按顺序获取。排查死锁用jstack它会直接告诉你“Found one Java-level deadlock”并打印出线程持有的锁和等待的锁。我遇到过一次死锁两个线程分别持有A锁和B锁又互相等待对方的锁。后来统一了锁的获取顺序问题解决。7.2 线程泄漏的常见原因线程泄漏比内存泄漏更隐蔽。常见原因有线程池没有正确关闭、ThreadLocal没有remove、无限创建线程。ThreadLocal尤其要注意线程池里的线程是复用的如果不remove上一个任务的数据会带到下一个任务造成数据污染和内存泄漏。我的习惯是ThreadLocal变量用try-finally包起来finally里必须remove。线程池用shutdown加awaitTermination确保优雅关闭。7.3 并发测试怎么做才有效并发bug很难复现所以测试要讲究方法。我一般用CountDownLatch做“发令枪”让所有测试线程同时开始最大化竞争。然后用CyclicBarrier做多轮测试提高触发概率。工具方面JMH适合做微基准测试jcstress专门用来测并发正确性能发现很多肉眼看不出的内存可见性问题。最后分享一个我自己的习惯每次写完并发代码都会问自己三个问题——共享变量有没有正确同步锁的粒度是不是太粗异常路径下锁能不能释放这三个问题能挡住大部分低级错误。JUC的内容很多这个系列我会持续更新下一篇打算聊聊CompletableFuture和响应式编程的配合使用感兴趣可以关注。

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

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

免费获取报价 →
↑