
多线程基础这个系列写到第三篇我已经默认读者会用 Thread 和 Runnable 创建线程、看得懂 synchronized 控制临界区了。如果这两块还不熟建议先翻一翻前两篇把线程创建、生命周期、内置锁的基本用法过一遍再回来看这篇。本文要解决的问题很直接当 synchronized 不够用的时候怎么办线程之间存在协作关系时怎么办线程数量上来了又该怎么统一管理。对应的内容就是显式锁、AQS 排队机制、并发协作工具、线程池、原子类和 volatile这一套组合起来基本上覆盖了日常开发里 90% 的并发场景。适合正在补多线程基础的初学者也适合写过不少并发代码但一直停留在能跑就行阶段的开发者对照自查。1. 从内置锁到显式锁ReentrantLock不是synchronized的替代品1.1 synchronized的三个痛点很多人在入门并发编程时学的第一个锁就是 synchronized它用起来简单加到方法上或者包一个代码块就行。但等你真正处理复杂业务的时候会发现synchronized 有几个天然短板。第一个痛点是不可中断。一个线程拿到 synchronized 锁之后如果它在临界区里出不来比如卡在远程调用上其他线程只能干等着没有任何机制可以把等待中的线程打断。在真实的接口场景里这种卡死传染是很可怕的一个上游服务慢响应可能导致一批线程全部阻塞在锁上。第二个痛点是没有超时机制。synchronized 的获取是无限期的没有 tryLock 这种等不到就放弃的选项。对于某些允许失败的场景比如用户重复提交、缓存回源这种可以降级的逻辑直接放弃锁往往比死等更合理。第三个痛点是读写锁能力缺失。synchronized 是互斥锁线程之间互相排斥哪怕两个线程都只是读一份共享数据也得排队依次执行。在一个读多写少的场景里这种锁的并发能力会被浪费掉大部分。相比之下Java 的读写锁可以让多个读线程同时持有锁只有写线程需要独占。正是这三个痛点让 JUC 包里的 Lock 体系有了存在的必要。ReentrantLock 不是用来替代 synchronized 的它的定位是在 synchronized 搞不定的场景里提供更细粒度的控制手段。1.2 ReentrantLock的核心API骨架ReentrantLock 的用法其实比 synchronized 多不了多少核心 API 就那几个import java.util.concurrent.locks.ReentrantLock; public class OrderService { private final ReentrantLock lock new ReentrantLock(); public void createOrder(Order order) { lock.lock(); try { // 临界区处理订单库存扣减、状态变更 } finally { lock.unlock(); } } }这里有个细节值得注意lock 和 unlock 之间用 try-finally 包裹unlock 永远放在 finally 里。很多人第一次写的时候会漏掉 finally直接写lock.lock(); // 业务逻辑 lock.unlock();这样做在业务逻辑不抛异常时没有问题一旦中间抛出异常锁就永远不会释放后面的线程全部卡死。这类问题在线上很难排查因为没有明显的报错只是接口越来越慢最后整个服务像死了一样。所以我的习惯是只要用了 ReentrantLockunlock 必须出现在 finally 的第一行这是铁律。除了最基本的 lock/unlockReentrantLock 还提供 tryLock 和 lockInterruptibly 两个非常实用的方法tryLock()尝试获取锁获取不到立刻返回 false不会阻塞。tryLock(long timeout, TimeUnit unit)在指定时间内尝试获取超时返回 false。lockInterruptibly()获取锁的过程中响应中断线程被 interrupt 时会抛出 InterruptedException。这跟 synchronized 拉开差距的地方恰恰是这些可退出的获取方式。业务里的降级逻辑、告警机制、超时控制都要靠它们实现。1.3 公平策略new ReentrantLock(true) 到底影响什么ReentrantLock 构造有两个无参构造是非公平锁带 true 参数的构造是公平锁。很多人不理解这两个的区别简单说非公平锁新来的线程先尝试抢一次锁抢不到再排队。公平锁新来的线程直接排队按先来后到的顺序获取锁。非公平锁的性能通常更好因为插队可以减少线程从阻塞到唤醒的上下文切换。但公平锁能避免线程饥饿适合对响应时间要求严格的场景。实际项目中我见到的绝大多数 ReentrantLock 都是非公平的因为默认构造足够用公平模式的开销在某些高并发场景下反而会成为瓶颈。注意多线程开发里公平往往是理论上的。即使你用公平锁线程的调度依然受操作系统影响不要把公平锁当成绝对公平的保证它只是降低了被插队的概率。2. AQS排队机制为什么lock的公平性、超时、中断都可以定制2.1 state与CLH变体队列简化的底层模型ReentrantLock 的实现依赖 AbstractQueuedSynchronizer也就是常说的 AQS。AQS 之所以能支撑 JUC 里一大堆工具类核心就两个东西一个 volatile 的 int 类型 state一个等待队列。// AQS 简化模型 private volatile int state; // 锁状态0表示无锁1表示持有可重入时自增 private Node head; // 等待队列头 private Node tail; // 等待队列尾拿 ReentrantLock 来说线程拿到锁时用 CAS 把 state 从 0 改成 1重入一次加 1unlock 一次减 1减到 0 才真正释放锁。拿不到锁的线程会封装成 Node 节点挂到等待队列尾部然后通过 LockSupport.park 让自己进入等待状态。把 AQS 简化来看就是一套状态变量 等待队列 CAS 操作的模板。理解了这三个东西再看 Lock、Semaphore、CountDownLatch 这些工具时会发现自己其实在重复理解同一份骨架。这也是面试里高频问 AQS 的原因它是 JUC 的底层地基。2.2 非公平锁和公平锁的代码级差异NonfairSync非公平和 FairSync公平在 AQS 里的差别很小就体现在获取锁的前置判断上。非公平锁的 lock 方法是这样的思路// 先直接尝试 CAS 抢一次锁不排队 if (compareAndSetState(0, 1)) { setExclusiveOwnerThread(Thread.currentThread()); return true; } // 抢不到再走 acquire 流程去排队 acquire(1);公平锁的 lock 方法则多了一个判断if (hasQueuedPredecessors()) { // 队列里已经有排队的线程新线程必须排队不允许插队 } else if (compareAndSetState(0, 1)) { setExclusiveOwnerThread(Thread.currentThread()); return true; }hasQueuedPredecessors 这个方法就是用来判断队列里是否有人在等。非公平锁会先去 CAS 抢一次抢不到才走排队流程公平锁则直接放弃插队念头老老实实排队。这就是两者在源码层面真正的区别没有其他玄机。2.3 中断与超时线程不是想停就能停tryLock 的超时逻辑依赖 AQS 的 acquire 带超时版本底层是 LockSupport.parkNanos 让线程睡一段时间期间如果被 interrupt会抛出 InterruptedException 并退出竞争。lockInterruptibly 则是获取锁的整个过程中都响应中断。这里要澄清一个常见的误解interrupt 不会直接停掉线程它只是设置了一个中断标记。如果线程处于 sleep、wait、join 或者 LockSupport.park 等可中断状态这个标记会触发 InterruptedException如果线程只是在跑普通代码不检查中断状态的话interrupt 没有任何强制作用。所以在多线程编程里处理 InterruptedException 的正确姿势是要么把异常重新抛出要么重新设置中断标记绝对不能吞掉异常。我实际排查过的很多事故里都有一个共同点有人在 catch 块里写e.printStackTrace()之后什么都不做把 InterruptedException 吞了。结果就是线程既没有终止也没有按预期响应中断后续逻辑全部错乱。这个坑比死锁出现的频率高得多。3. 并发协作三件套CountDownLatch、CyclicBarrier、Semaphore3.1 CountDownLatch让主线程等所有子任务完成CountDownLatch 是个计数器初始化时指定个数countDown 一次减一await 方法会一直阻塞到计数器归零。最典型的场景是主线程发起 N 个并行任务等它们全部完成后再汇总结果。import java.util.concurrent.CountDownLatch; int taskCount 10; CountDownLatch latch new CountDownLatch(taskCount); ExecutorService executor Executors.newFixedThreadPool(4); for (int i 0; i taskCount; i) { final int taskNo i; executor.submit(() - { try { // 处理某个分片任务 } finally { latch.countDown(); } }); } latch.await(5, TimeUnit.SECONDS); // 有超时兜底避免子任务异常时主线程无限等待 System.out.println(所有任务执行完毕);CountDownLatch 是一次性的计数器归零后不能重置如果要反复使用就得考虑 CyclicBarrier 了。使用它的第一个习惯是countDown 必须放在 finally 里。曾有过一个线上接口偶尔超时排查到最后就是一个线程抛异常跳过了 countDown导致 latch 的计数器永远减不到 0主线程一直阻塞到接口超时。这个案例后来也成了我反复强调 finally 的原因。3.2 CyclicBarrier兄弟线程互相等待的集合点CyclicBarrier 和 CountDownLatch 表面上看都有等待的意思实际语义完全不同。CountDownLatch 是主线程等待 N 个子线程完成CyclicBarrier 是N 个线程互相等待到达集合点后一起放行。而且 CyclicBarrier 可以复用计数器归零后自动重置。import java.util.concurrent.CyclicBarrier; int workers 4; CyclicBarrier barrier new CyclicBarrier(workers, () - { // 所有线程到达屏障后先执行这个回调适合做阶段汇总 System.out.println(本阶段所有工人已就绪); }); for (int i 0; i workers; i) { new Thread(() - { // 第一阶段工作 barrier.await(); // 等待其他线程完成第一阶段 // 第二阶段工作 }).start(); }CyclicBarrier 最容易被忽略的坑是await 会在线程中断时抛出 InterruptedException在屏障被重置时抛出 BrokenBarrierException。这两个异常如果处理不当会导致线程直接终止而其他线程还在傻等。我一般在业务代码里遇到 BrokenBarrierException 会做降级处理把所有等待线程直接放行而不是让它们继续阻塞。3.3 Semaphore令牌池与限流Semaphore 可以看作一个信号量计数器acquire 拿令牌release 还令牌没有令牌的时候 acquire 会阻塞。它最典型的用法是控制并发访问资源的上限。import java.util.concurrent.Semaphore; int permits 3; Semaphore semaphore new Semaphore(permits); // 模拟限制数据库连接并发数 for (int i 0; i 10; i) { new Thread(() - { try { semaphore.acquire(); // do something最多同时3个线程进入 } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { semaphore.release(); } }).start(); }Semaphore 的 release 方法不需要和 acquire 配对调用只要信号量计数器有空间任何线程都可以手动 release。这个特性有时会被人误用比如在异常路径里重复 release导致信号量计数越放越多限流失效。所以一个原则是acquire 和 release 必须配对release 放在 finally 里而且在同一个逻辑单元内完成。三个工具放一起对比用途差异很清晰工具核心语义可否复用典型场景CountDownLatch计数归零后放行否主线程等待多个子任务完成CyclicBarrierN个线程互相等待后同步放行是多阶段任务协同、分阶段汇总Semaphore控制同时访问资源的线程数是限流、连接池、令牌桶4. 线程池ThreadPoolExecutor七个参数的真实联动关系4.1 核心参数不是孤立存在的线程池是并发编程里绕不开的管控手段。ThreadPoolExecutor 有七个构造参数很多人背得下来但真正理解它们怎么联动的并不多。这套逻辑用一句话概括就是任务来了先看核心线程有没有空闲没有就尝试丢进队列队列满了才尝试扩充到最大线程数最大线程也满了才走拒绝策略。拆开看是这样的流程提交任务时如果运行线程数小于 corePoolSize直接创建核心线程执行任务。如果线程数已经达到 corePoolSize任务进入等待队列不去创建新线程。如果等待队列也满了才会创建非核心线程直到线程数达到 maximumPoolSize。如果线程数已经到了 maximumPoolSize队列也满了触发拒绝策略。最反直觉的往往是第 2 条核心线程没满时不排队满了之后先排队而不是立刻扩容。很多人以为线程数到了核心值就扩容到最大值其实是容量不达上限线程池会优先用队列缓冲任务。4.2 线程数怎么定两个经验公式线程数的估算没有银弹但起步可以这么算CPU 密集型任务线程数 CPU 核数 1多出来的一个用来应对偶尔的阻塞。IO 密集型任务线程数 CPU 核数 * (1 IO等待时间 / CPU计算时间)。举个例子一个服务端接口里大量做数据库查询和远程调用IO 等待时间可能是计算时间的几十倍此时线程数往往可以放到N * (1 50)这种量级。反过来纯计算型的图像处理任务线程数接近 CPU 核数就够了开太多线程反而因为上下文切换拉低吞吐。实际项目中我还会看一个指标线程池的任务队列堆积量。如果任务在队列里平均停留时间太长说明线程池配置偏小如果线程长期以来都很空闲说明配置偏大。这个需要结合监控数据调而不是套公式了事。4.3 生产环境最容易踩的两个坑第一个坑是线程池里的异常被吞掉。使用 execute 提交任务时如果任务抛出异常线程池会把异常打印到控制台后直接回收线程业务代码里的 catch 根本接不到。你可能会在日志里看到一个异常输出但没有任何上下文关联也无法知道是哪条请求引发的。我的建议是包装一层自定义 Runnable在 run 方法里统一捕获异常并输出带任务编号的日志至少排查问题时能定位到具体任务。第二个坑是线程池线程名无意义。默认的线程池线程名是pool-1-thread-1这类一旦服务里同时建了多个线程池日志里全是这种名字排查问题根本分不清是哪个线程池在干活。用 ThreadFactory 给线程池起个有意义的名字是最便宜的回报之一import java.util.concurrent.ThreadFactory; import java.util.concurrent.atomic.AtomicInteger; public class NamedThreadFactory implements ThreadFactory { private final String prefix; private final AtomicInteger counter new AtomicInteger(1); public NamedThreadFactory(String prefix) { this.prefix prefix; } Override public Thread newThread(Runnable r) { Thread t new Thread(r, prefix - counter.getAndIncrement()); t.setUncaughtExceptionHandler((thread, throwable) - System.err.println(prefix 线程异常: throwable.getMessage())); return t; } }4.4 为什么大家都说别用Executors自带的快捷方法Executors 提供了 newFixedThreadPool、newCachedThreadPool、newSingleThreadExecutor 等方法非常简洁但问题恰好出在简洁背后隐藏的队列策略上。举个典型例子ExecutorService pool Executors.newFixedThreadPool(4);这个线程池用的是 LinkedBlockingQueue默认容量是 Integer.MAX_VALUE无限接近无界。如果业务任务长期堆积队列会越吞越多任务最后内存被打爆或者任务延迟高到完全不可接受。newCachedThreadPool 又走到另一个极端核心线程数为 0最大线程数是 Integer.MAX_VALUE线程全部是短命的任务量一大就会疯狂创建线程轻则频繁上下文切换重则直接 OOM。所以业内普遍建议直接用 ThreadPoolExecutor 构造线程池参数显式写出来。虽然代码多一点但每改一个参数你都知道自己在改什么。import java.util.concurrent.*; int coreThreads 4; int maxThreads 8; long keepAliveSeconds 60; BlockingQueueRunnable queue new ArrayBlockingQueue(1000); ThreadPoolExecutor pool new ThreadPoolExecutor( coreThreads, maxThreads, keepAliveSeconds, TimeUnit.SECONDS, queue, new NamedThreadFactory(order-query), new ThreadPoolExecutor.CallerRunsPolicy() );用 ArrayBlockingQueue 或者有界队列加上一个明确的拒绝策略线程池的运行边界就清晰了。5. CAS与原子类无锁方案的优势、边界和常见误用5.1 CAS的三步操作读取、比较、交换原子类之所以能比加锁更轻量核心依赖的是 CASCompare-And-Swap。它的本质是三步操作先读取当前值然后判断当前值是否等于预期值如果相等就用新值替换它。整个比较和替换是一个原子操作由 CPU 指令直接保证。用 AtomicInteger 实现一个计数器import java.util.concurrent.atomic.AtomicInteger; public class Counter { private final AtomicInteger count new AtomicInteger(0); public void increment() { count.incrementAndGet(); } public int getCount() { return count.get(); } }这个计数器在并发下是没有锁的线程之间通过 CAS 自旋竞争。竞争不激烈的时候比 synchronized 效率高因为省去了线程阻塞和唤醒的开销。但竞争激烈时CAS 会反复自旋重试反而浪费 CPU。5.2 ABA问题别以为值没变就是真的没变CAS 有个经典缺陷叫 ABA 问题线程 A 读到值是 A线程 B 把值改成 B 又改回 A然后线程 A 执行 CAS 发现值还是 A就认为没有变化过实际上中间已经被人动过了。举一个实际点的例子某个订单状态字段用 AtomicInteger 表示0 表示待支付1 表示已支付2 表示已取消。如果线程 A 准备把 0 改成 1检查时发现是 0正常应该成功但在 CAS 执行前线程 B 可能已经把它改成了 2然后因为某种异常回滚或者业务流程又把状态改回 0线程 A 就会把已经被取消的订单重新置为已支付。这是完全不符合业务预期的。解决 ABA 问题的标准姿势是带版本号的原子引用比如 AtomicStampedReference。每修改一次版本号加 1CAS 时同时比较值和版本号值相同但版本号不同也视为变化过import java.util.concurrent.atomic.AtomicStampedReference; AtomicStampedReferenceInteger ref new AtomicStampedReference(0, 0); // 获取当前值并执行CAS时带上版本号 int[] stampHolder new int[1]; Integer current ref.get(stampHolder); ref.compareAndSet(current, 1, stampHolder[0], stampHolder[0] 1);5.3 LongAdder高并发计数器的更优解AtomicInteger 在并发量很高时CAS 自旋会非常剧烈因为所有线程都在争同一个共享变量。LongAdder 的思路是把这个共享变量拆成一组更小的单元每个线程尽量往自己的单元里累加最后需要取值时再汇总。用真实场景说的话一个接口要统计总请求量QPS 很高如果用 AtomicLong 做计数器线程大量消耗在 CAS 自旋上换成 LongAdder 之后热点被分散整体吞吐会明显提升。Counter 类的统计、Metrics 类的计数器这些场景用 LongAdder 通常是更合适的选择import java.util.concurrent.atomic.LongAdder; public class RequestMetrics { private final LongAdder totalRequests new LongAdder(); public void record() { totalRequests.increment(); } public long getTotal() { return totalRequests.sum(); } }LongAdder 也不完美它的 sum 方法在并发累加时是弱一致的如果要求精确的实时值它不合适。但绝大多数计数场景不要求每个瞬间的值都分毫不差更看重写入性能这时候 LongAdder 的优势就出来了。6. volatile与JMM可见性的边界在哪里6.1 缓存不一致问题的本质线程之间共享变量的读写为什么会出问题根源不在 Java 代码层面而在硬件层面。CPU 为了加速运算引入了多级缓存每个核心有自己的一级二级缓存一个线程在核心 A 上修改了变量另一个线程在核心 B 上可能还读取着旧缓存。JMMJava 内存模型对这个问题做了一个抽象线程操作的是工作内存工作内存和主内存之间通过 load、store 等操作同步。只要没有同步机制线程 A 对变量的修改对线程 B 就是不可见的。volatile 关键字的作用就是告诉 JVM这个变量每次读写都必须直接操作主内存不能只停留在工作内存里。public class StopFlag { private volatile boolean running true; public void stop() { running false; } }上面这个例子是 volatile 最常见的用法一个线程执行循环另一个线程修改标志位让它停止。如果 running 不加 volatile循环线程可能永远看不到 false程序就假死了。6.2 volatile 保证什么不保证什么volatile 保证两点可见性一个线程写 volatile 变量新的值对其他线程立刻可见。有序性volatile 操作前后的指令不会被 JIT 编译器或 CPU 乱序重排。但 volatile不保证原子性。最典型的反例就是count这种操作。count 在字节码层面是读 - 加一 - 写三步即使变量本身是 volatile多线程同时执行 count 时依然会丢更新。有人以为加了 volatile 就是线程安全了这是并发编程里最常见的误解之一。想保证原子性要么用 synchronized要么用 AtomicInteger 或者 LongAdder。6.3 happens-before一组不用死记的规则JMM 里最难啃的部分是 happens-before 规则实际使用中可以把它简单归纳成几条同一个线程内按代码顺序前面的操作 happens-before 后面的操作。解锁操作 happens-before 后续对同一把锁的加锁操作。对 volatile 变量的写 happens-before 后面对该变量的读。happens-before 具有传递性A happens-before BB happens-before C则 A happens-before C。这些规则存在的意义就是只要你能找到一条从线程 A 的写操作到线程 B 的读操作的 happens-before 链路那 B 读到的数据就是 A 写过的而不是缓存的旧值。实际排查可见性问题时大部分情况归结起来就一个原因没有一条 happens-before 关系覆盖到两个线程之间或者覆盖到了但因为误解写错了同步机制。6.4 双重检查锁与volatile的组合原因讲到 volatile 的顺序性就绕不开单例模式的双重检查锁。经典写法是public class Singleton { private static volatile Singleton instance; private Singleton() {} public static Singleton getInstance() { if (instance null) { synchronized (Singleton.class) { if (instance null) { instance new Singleton(); } } } return instance; } }很多人背下来这个写法却不理解为什么 instance 要加 volatile。原因是对象的创建步骤并不是原子的先把内存空间分配好然后调用构造器初始化最后把引用赋值给变量。如果编译器或 CPU 重排了顺序可能出现先赋值引用再执行构造器的情况。此时另一个线程在第一次判空的时候发现 instance 不为 null直接返回一个还没完整构造的对象后面调用它的方法就直接出问题了。volatile 的作用就是禁止这种重排保证赋值引用时对象已经初始化完成。这套技术组合在实践中验证过很多次。我曾经在一个服务里把双重检查锁的 volatile 去掉做测试高并发下偶发到了空指针异常加上 volatile 之后问题消失现象非常典型。多线程这块没有银弹锁、CAS、队列、原子类各有各的适用场景建议读者把上面这些示例自己敲一遍改改参数多跑几次很多概念光看是看不出来的实际跑一跑才会突然明白。今天这篇从头到尾没有再讲 Thread 怎么建线程也没讲 synchronized 的底层原理它是前面基础内容的自然延伸读完如果能把显式锁、线程池参数、CAS 边界、volatile 语义这四件事串起来这趟多线程基础就算真正入门了。