
周阳老师的JUC课程在Java并发学习圈里基本算是“标配”了不管是刚入行想补并发短板的新人还是面试前想系统梳理知识体系的候选人这套课都是绕不开的参考。我当初是带着“会用synchronized但说不清原理、知道线程池但答不上参数”的状态去跟的学完之后最大的感受是JUC不是一堆零散工具类的堆砌而是一整套围绕并发控制的思维框架。这篇笔记我不打算照着目录平铺直叙地复述而是把整个课程最核心的几条主线——Lock体系、volatile/CAS/AQS、并发工具类、线程池、阻塞队列、以及面试高频考点——串起来讲清楚重点放在“为什么这样设计”和“实际开发中怎么用”上希望能帮后来的人少走弯路。1. 先从宏观上理解JUC到底在解决什么问题1.1 并发编程的三大核心问题把JUC整个体系学完再回头看其实所有工具类、锁、同步机制归根到底都在解决三件事可见性、原子性、有序性。这是理解一切并发问题的总纲。可见性问题来自CPU多级缓存架构。每个CPU核心有自己的L1/L2缓存线程在不同核心上运行时可能读到同一个变量的不同副本。一个线程改了值另一个线程不知道这就是典型的可见性失效。volatile关键字和JUC里的原子类本质上都是在解决这个问题。原子性问题来自线程调度的时间片轮转。一个复合操作比如i的读-改-写三步在指令层面被打断多个线程交替执行就会产生数据竞争。synchronized、Lock、Atomic系列工具核心目标就是保证这些复合操作不可分割。有序性问题来自编译器和CPU的指令重排。为了提高执行效率指令的实际执行顺序可能和代码顺序不一致这在单线程下没问题但多线程环境下可能引发诡异问题。volatile的另一个作用就是通过内存屏障禁止特定范围的重排序。1.2 JUC工具类的基本盘JUCjava.util.concurrent是Java 5开始引入的并发工具包经过这么多年的迭代已经形成了一套层次分明的工具矩阵。我学完周阳老师的课之后习惯把JUC的工具分成四个层面来记忆第一层是原子操作层包括AtomicBoolean、AtomicInteger、AtomicLong、AtomicReference以及LongAdder、LongAccumulator这些高性能计数器。它们基于CASCompare And Swap实现无锁并发适用于简单状态的原子更新。第二层是锁与同步层包括Lock接口、ReentrantLock、ReentrantReadWriteLock、StampedLock以及CountDownLatch、CyclicBarrier、Semaphore这些同步工具。这一层是JUC的核心也是面试问得最多的区域。第三层是并发容器层包括ConcurrentHashMap、CopyOnWriteArrayList、BlockingQueue系列、ConcurrentLinkedQueue等。这些容器在特定场景下替代传统的线程不安全集合是高并发系统的地基。第四层是线程池与执行框架层包括ThreadPoolExecutor、ForkJoinPool、CompletableFuture等。这一层解决的是线程的创建、复用、调度和任务编排问题。这四层不是孤立的它们底层都依赖volatile和CAS这两个基础机制而AQSAbstractQueuedSynchronizer则是连接锁和同步工具的关键纽带。把这条依赖链捋清楚了JUC就不再是一堆零散类名的堆砌而是一张有逻辑的网。1.3 为什么要用JUC而不是只用synchronized这是一个很实际的问题。synchronized是JDK内置的同步机制从Java 1.0就有经过JDK 6的锁升级优化偏向锁、轻量级锁、重量级锁之后性能已经不差。那为什么还需要JUC的Lock体系我的理解是synchronized是两个极端之间的选择太有限。它要么锁住整个代码块要么完全不锁没有“读锁可以共享、写锁互斥”这种细粒度控制。在高并发读多写少的场景下ReentrantReadWriteLock能把读锁并发度拉满这是synchronized做不到的。另外synchronized的获取和释放是隐式的虽然用起来简单但在需要“尝试获取锁、超时获取锁、可中断获取锁”这些场景下就无能为力了。而Lock接口提供了tryLock()、lockInterruptibly()这些方法让锁的控制更加灵活。更关键的是synchronized一旦进入等待只能通过wait/notify机制配合而Condition接口则提供了类似await/signal的精确唤醒能力可以按条件定向唤醒线程而不是像notifyAll那样无差别唤醒。当然synchronized也不是没有优势。它写法简洁、不会出现忘记释放锁的问题异常时自动释放、并且JVM层面做了大量优化。我的建议是简单场景、单机同步、synchronized完全够用复杂场景——比如需要超时控制、多条件唤醒、读写分离、公平锁——才引入Lock。不是越高级越好是要匹配场景。2. 逐个击破核心知识点深度拆解2.1 Lock接口与ReentrantLock落地实操周阳老师在课程中用了很大篇幅讲Lock接口和ReentrantLock这部分是JUC锁体系的地基。ReentrantLock从名字就能拆出两个关键词Reentrant可重入的和Lock锁它实现了Lock接口语义和synchronized最接近但使用方式完全不同。一个标准的ReentrantLock使用模板是这样的Lock lock new ReentrantLock(); public void doSomething() { lock.lock(); try { // 核心业务逻辑 } finally { lock.unlock(); } }很多人刚开始学的时候总是记不住要在finally里释放锁。我自己也踩过这个坑——业务逻辑里某个方法抛了异常锁没释放整个线程直接卡死在后续所有获取锁的调用上。这不是危言耸听生产环境遇到过一次单看日志完全看不出原因只看到所有线程都阻塞在lock.lock()那一行dump线程栈才发现是锁没有释放导致的。ReentrantLock还有个重要的特性是公平锁和非公平锁的切换。默认构造函数创建的是非公平锁也就是允许线程“插队”这样做的好处是吞吐量高但可能导致某些线程长期获取不到锁饥饿。如果使用公平锁则严格按照线程到达顺序分配锁适用于对公平性有要求的场景// 公平锁 Lock fairLock new ReentrantLock(true);公平锁的实现原理是通过AQS中的等待队列每次获取锁时优先检查队列里有没有等待线程如果有就排在后面。性能上非公平锁通常优于公平锁因为非公平锁减少了线程的上下文切换开销所以默认明确不建议将公平性设为true。再看一个ReentrantLock比synchronized更强大的能力——可中断获取锁。想象一个场景线程A持有了锁正在执行长任务线程B在等待锁这时候线程B不想等了想主动退出。用synchronized做不到用Lock可以lock.lockInterruptibly(); try { // 业务逻辑 } finally { lock.unlock(); }当线程B调用lockInterruptibly()后如果其他线程调用threadB.interrupt()线程B会抛出InterruptedException退出等待状态。这在设计一个“可取消的任务队列”时非常有用。2.2 volatile与内存可见性为什么JMM如此重要volatile在JUC里的地位很特殊。它没有锁的性质不保证原子性但它保证了可见性和有序性。如果想深挖它的底层就绕不开Java内存模型JMMJava Memory Model。JMM规定所有变量存在主内存中每个线程有自己的工作内存线程对变量的操作必须在工作内存中进行不能直接读写主内存。这就带来一个问题线程A改了变量的值线程B可能还在用自己的工作内存里的旧值产生了数据不一致。volatile的作用就是当线程写一个volatile变量时JMM会强制把该线程工作内存中的值刷新到主内存当线程读一个volatile变量时JMM会把该线程工作内存中对应的值置为无效强制从主内存重新读取。所以一个简单的volatile变量读取就保证了读到的永远是最新值。volatile的另一个关键能力是禁止指令重排。这里有一个经典的面试题——双重检测锁DCL单例模式为什么要用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 new Singleton()这一步。这一行看起来是原子的但JVM层面其实拆成了三步1分配内存空间2初始化对象3将内存地址赋值给引用。由于指令重排可能发生2和3的顺序互换导致另一个线程在第一次检查时发现instance不是null直接返回了一个尚未初始化完成的对象。用volatile修饰后通过内存屏障禁用了这个重排序就彻底解决了这个问题。我在实际项目中用volatile最多的场景是状态标志位。比如一个开关变量控制后台任务的启停这个变量被一个线程修改被多个线程读取用volatile就足够了完全不需要上锁private volatile boolean running true; // 线程A public void stop() { running false; } // 线程B、C、D... public void run() { while (running) { // 持续工作 } }这里的关键是volatile只适合“一写多读”的场景如果多个线程都要写这个变量且写入依赖当前值比如计数volatile就无能为力了因为count的读-改-写三步依然不是原子的。这种场景必须用原子类或者加锁。2.3 CAS与原子类无锁并发的秘密CASCompare And Swap比较并交换是JUC原子类的底层实现机制也是整个JUC包的精髓之一。它的核心思路非常简单先比较当前内存值是否等于预期值如果相等说明期间没有其他线程修改过就执行替换操作如果不相等就重新读取最新值再尝试。整个过程是CPU级别的一条硬件指令如cmpxchg保证了原子性。用AtomicInteger来感受一下CAS的用法AtomicInteger count new AtomicInteger(0); public void increment() { count.incrementAndGet(); // 相当于 i 但线程安全 }incrementAndGet()的内部实现就是在循环里反复执行CAS操作public final int getAndAddInt(Object obj, long offset, int delta) { int v; do { v getIntVolatile(obj, offset); // 获取当前值 } while (!weakCompareAndSetInt(obj, offset, v, v delta)); // 尝试CAS更新 return v; }这段代码的思路是先读旧值然后尝试CAS更新如果期间有别的线程改了值CAS失败循环重试直到成功为止。这就是所谓的“自旋”。CAS的经典缺陷是ABA问题。简单来说就是线程1读到的值是A在它执行CAS之前线程2把值改成了B然后又改回了A线程1的CAS比较时会发现内存值还是A认为没人动过就执行成功了。但事实上值已经被改过了。解决方法是使用带版本号的原子引用——AtomicStampedReference。比如一个账户余额操作的场景余额被扣减又恢复虽然最终值没变但中间发生了交易如果只关心终值CAS没问题如果关心过程就必须用版本号来区分。我在实际开发里喜欢用AtomicLong或者LongAdder做全局计数器。LongAdder是Java 8加入的它在高并发下的性能优于AtomicLong因为它在内部维护了多个累加单元Cell把竞争分摊到了多个变量上最后求和时汇总。从源码实现看LongAdder用了一个cells数组每个线程根据自己的hash值映射到不同的Cell上做累加这样就减少了CAS争用。适合那种“频繁增加、偶尔读取总数”的场景比如线上流量统计。还有一个要提的是AtomicReference它可以原子地更新引用类型。我做过一个简单的配置热更新方案用一个AtomicReference持有配置对象的引用后台线程定时加载新配置并原子替换引用业务线程每次读取时拿到的都是最新的完整配置对象不用加锁整体实现很优雅private final AtomicReferenceAppConfig configRef new AtomicReference(); public void refreshConfig(AppConfig newConfig) { configRef.set(newConfig); } public AppConfig getConfig() { return configRef.get(); }这里有一个非常重要的认知CAS不是万能的。它适合临界区非常短、冲突不频繁的场景因为自旋会空耗CPU。如果临界区执行时间太长或者冲突非常激烈CAS的循环重试代价会急剧上升这时候用锁反而是更好的选择。我见过一些同事把所有的原子操作都无脑改成CAS结果在超高并发下CPU使用率飙升这就是没有理解CAS的适用边界。2.4 AQS理解JUC的钥匙如果说整个JUC只能选一个核心来深挖那一定是AQSAbstractQueuedSynchronizer。周阳老师的课程重点讲了AQS原理因为ReentrantLock、CountDownLatch、Semaphore、ReentrantReadWriteLock这些工具本质上都是基于AQS做的不同定制。AQS的核心是一个volatile int state变量加上一个FIFO双向等待队列。state表示同步状态不同的工具类对state有不同的语义ReentrantLock里state表示锁被获取的次数可重入机制就是从这里来的Semaphore里state表示剩余的许可数量CountDownLatch里state表示还需要等待的计数。AQS提供了两类模板方法独占式获取/释放exclusive和共享式获取/释放shared。子类只需要实现tryAcquire、tryRelease、tryAcquireShared、tryReleaseShared这几个方法决定什么条件下可以获取成功AQS则负责处理获取失败时的入队、阻塞、唤醒等复杂逻辑。这种模板方法模式的优雅之处在于同步器的核心机制被固定在AQS里子类只需要关注自己的“个性逻辑”。以ReentrantLock的非公平锁为例它的tryAcquire大概是这样的逻辑检查当前state如果state为0尝试CAS更新state为1抢锁成功如果state不为0但当前线程已经是持有锁的线程state加1这就是可重入其他情况返回falseAQS会将当前线程封装成Node放到等待队列尾部然后通过LockSupport.park挂起线程。关于线程的挂起和唤醒底层用的是LockSupport.park()和LockSupport.unpark()。这和Object.wait()/notify()最大的区别是park/unpark和锁没有绑定关系可以灵活地针对任意线程进行精准唤醒而且unpark可以先于park调用线程之后执行park时不会阻塞。AQS正是依赖这种机制实现了精确的线程管理避免使用notifyAll时的惊群效应。理解了AQS回头再看那些并发工具类就会发现它们都只是AQS这棵树上结的不同果实。ReentrantLock是独占模式的经典实现Semaphore是共享模式的典型代表CountDownLatch则是一个简单的共享锁变种——state初始等于N每次countDown()将state减1当state归零时所有await()的线程被同时唤醒。学AQS最忌讳的是死记源码最好的方式是先理解state和队列这两个核心概念然后跟着ReentrantLock的加锁解锁流程走一遍时序图。我自己当时能真正明白AQS是因为动手分析了lock()和unlock()两个方法调用链上每个对象状态的变化——从CAS设置state到失败就入队到前驱节点释放后唤醒后继节点——整个过程走通之后其他工具类都是顺水推舟。3. 并发工具类实战从面试题到生产落地3.1 CountDownLatch一个线程等多个线程的场景利器CountDownLatch是JUC里最直观的同步工具之一它的语义是“倒计时门闩”初始化时设定一个计数值N调用await()的线程会阻塞直到调用N次countDown()之后门闩打开等待的线程才继续执行。它的经典使用场景是“主线程等待多个子任务完成之后再汇总”。举个例子——一个数据报表服务需要从3个不同的数据源拉取数据全部拉取完成后再聚合。如果用Future线程池也能实现但用CountDownLatch思路更直接CountDownLatch latch new CountDownLatch(3); ExecutorService pool Executors.newFixedThreadPool(3); pool.submit(() - { try { // 拉取数据源A } finally { latch.countDown(); } }); pool.submit(() - { try { // 拉取数据源B } finally { latch.countDown(); } }); pool.submit(() - { try { // 拉取数据源C } finally { latch.countDown(); } }); latch.await(); // 主线程等待全部完成 // 聚合三个数据源的结果这里有两个细节必须注意第一countDown()必须放在finally里否则子任务抛异常会导致计数永远减不完主线程无限等待。第二await()支持超时重载await(long timeout, TimeUnit unit)给主线程的等待设置一个上限避免因为子任务卡死而拖垮主流程。我在生产环境就是这样做的给了10秒的超时时间抓一下报警日志比无限等下去强太多了。CountDownLatch的一个关键特性是不可复用。计数归零之后这个CountDownLatch就废了。如果需要“倒计时结束后再重新倒计时”的效果应该用下面的CyclicBarrier。3.2 CyclicBarrier让一组线程互相等待到齐CyclicBarrier的语义是“循环屏障”它可以看作是一个可复用的CountDownLatch。它的作用是让一组线程互相等待当所有线程都到达屏障点调用await()之后屏障打开所有线程同时继续执行。我印象最深的是用CyclicBarrier模拟多人游戏匹配场景。5个玩家全部准备之后游戏才开始CyclicBarrier barrier new CyclicBarrier(5, () - { System.out.println(所有玩家已准备游戏开始); }); for (int i 0; i 5; i) { new Thread(() - { // 玩家加载资源 try { barrier.await(); } catch (InterruptedException | BrokenBarrierException e) { e.printStackTrace(); } // 游戏正式开始 }, Player- i).start(); }CyclicBarrier区别于CountDownLatch最大的点有两个一是循环复用一个CyclicBarrier实例可以用多轮同步只要每个线程都调用了await()并重新开始下一轮二是屏障可破裂如果在等待过程中任何一个线程被中断或者超时屏障就会进入broken状态其他所有等待线程都会抛出BrokenBarrierException。所以在使用CyclicBarrier时catch异常不只是例行公事而是必须要处理屏障破裂的情况。从底层看CyclicBarrier并不直接使用AQS而是基于ReentrantLock和Condition实现的。它的构造方法里有一个Runnable barrierAction参数当所有线程到齐时由最后一个到达的线程执行这个动作这个设计非常巧妙可以用来自动触发“到齐后的汇总工作”。3.3 Semaphore信号量限流与资源池控制Semaphore从名字上看是“信号量”它维护了一定数量的许可证线程在执行前必须获取到许可证才能通行执行完释放许可证。它最常用的场景就是限流——限制同时访问某个资源的线程数量。比如一个系统对接了第三方短信接口对方限制每秒最多100条那么用Semaphore做一个简单的并发控制就很方便Semaphore semaphore new Semaphore(100); ExecutorService pool Executors.newFixedThreadPool(200); for (int i 0; i 500; i) { pool.submit(() - { try { semaphore.acquire(); // 发送短信 } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { semaphore.release(); } }); }Semaphore有两个值得注意的选型点。第一公平性公平信号量构造参数传true保证先申请先获得避免某些线程长期饥饿但会牺牲一些吞吐量非公平信号量允许“插队”吞吐量高但极端情况下后来的线程可能先获得许可证。第二acquire和release必须配套使用release必须放在finally里这和锁的释放是一个道理。还有一个常用的技巧是用tryAcquire做非阻塞获取如果获取不到就立即返回false而不是阻塞等待。这样可以在流量过大时直接返回“系统繁忙请稍后重试”而不是让所有请求都堆积在线程池里if (semaphore.tryAcquire(1, TimeUnit.SECONDS)) { try { // 处理请求 } finally { semaphore.release(); } } else { // 返回 429 或友好提示 }这种场景在网关层做服务保护非常常见比单纯的线程池限流更灵活。3.4 线程池7大参数决定了线程池的一切线程池是JUC里应用最广、面试问得最深的一块内容。周阳老师的课程花了大量篇幅讲解ThreadPoolExecutor核心就是对7大参数的深度理解。我在这里把参数和它们的作用串一下参数作用经验值/建议corePoolSize核心线程数CPU密集型CPU核数1IO密集型CPU核数*2或更多maximumPoolSize最大线程数一般建议核心线程数队列容量能承载的余量keepAliveTime非核心线程空闲存活时间根据请求波动情况设置通常30~60秒unit存活时间单位配合keepAliveTime使用workQueue任务队列有界队列优先避免无界队列造成内存溢出threadFactory线程工厂必须自定义给线程起有意义的名称handler拒绝策略默认AbortPolicy生产环境建议自定义任务提交时线程池的处理流程是先判断核心线程是否全部在跑如果没满就创建核心线程执行任务核心线程满了之后任务进入队列排队队列满了之后创建非核心线程执行任务如果线程总数已经达到maximumPoolSize执行拒绝策略。这7个参数的组合直接决定了线程池的行为。比如固定线程池Executors.newFixedThreadPool(n)它实际上是corePoolSize n, maximumPoolSize n配合无界队列LinkedBlockingQueue。这个实现看起来很简洁但无界队列在任务积压时会无限堆积最终导致内存溢出。阿里规约里明确禁止使用Executors的快捷方法创建线程池理由就在这里。我当时也踩过这个坑用newFixedThreadPool处理一个突发的批量任务结果队列里堆了几十万个任务直接OOM。更合理的做法是自己手动创建ThreadPoolExecutor用有界队列ArrayBlockingQueue并指定一个合理的拒绝策略ThreadPoolExecutor pool new ThreadPoolExecutor( 8, // 核心线程数 16, // 最大线程数 60L, TimeUnit.SECONDS, // 空闲回收时间 new ArrayBlockingQueue(500), // 有界队列 new ThreadFactory() { private final AtomicInteger counter new AtomicInteger(1); Override public Thread newThread(Runnable r) { Thread t new Thread(r, biz-pool- counter.getAndIncrement()); t.setDaemon(false); return t; } }, new ThreadPoolExecutor.CallerRunsPolicy() // 满了之后由调用线程执行 );这里我特别想聊一下拒绝策略。JDK内置了4种AbortPolicy直接抛异常、CallerRunsPolicy调用线程自己执行、DiscardPolicy静默丢弃、DiscardOldestPolicy丢弃最旧的任务。默认的AbortPolicy直接抛RejectedExecutionException这会让上层系统突然出现不可控的异常。我更推荐两种做法一是CallerRunsPolicy通过让提交任务的线程自己执行任务来天然实现背压减缓任务提交速度二是自定义拒绝策略落库或者放入MQ延迟重试。生产环境我一般倾向于自定义保证任务不丢失。关于线程数的设置很多人直接套公式CPU密集型就是CPU核数1IO密集型就是CPU核数*2。这个公式只是一个粗糙的起点。更准确的做法是先结合实际压测和监控数据来调整观察线程池的繁忙度、队列积压、任务执行耗时然后反复调整参数。我自己在做一个IO密集型的服务时最终用的线程数是CPU核数的4倍比理论公式大不少因为每个任务里有大量的HTTP调用等待等待期间线程是空闲的多开线程能有效提高吞吐量。4. 阻塞队列与生产者消费者最经典的并发协作模型4.1 BlockingQueue的4组核心方法阻塞队列BlockingQueue是JUC中一个非常有实战价值的组件它在线程池中承担任务队列的角色。它的核心能力是当队列满时入队操作会阻塞当队列空时出队操作会阻塞。这种机制天然适合生产者-消费者模型不需要手动写wait/notify。BlockingQueue在入队和出队两个方向各提供了3组方法加上超时的一组实际上有4种不同的行为模式操作抛异常返回特殊值阻塞超时退出入队add(e)offer(e)put(e)offer(e, time, unit)出队remove()poll()take()poll(time, unit)检查队首element()peek()不支持不支持这4组方法对应了不同的使用场景。add/remove在队列满/空时抛异常适用于明确不允许满/空的场景offer/poll返回特殊值true/false或null适合非阻塞的尝试性操作put/take会一直阻塞适合需要可靠传递的任务队列带超时的方法则提供了折中方案在限时等待的系统中特别好用。我实际用得最多的有界队列是ArrayBlockingQueue和LinkedBlockingQueue。ArrayBlockingQueue底层是数组有界创建时需要指定容量内部使用一把锁实现在JDK 7之后优化为两把锁即takeLock和putLockLinkedBlockingQueue底层是链表默认无界也可以指定容量理论上吞吐量更高但无界的风险前面已经讲过。还有一个SynchronousQueue它内部没有容量每个put必须等待一个take相当于是“手递手”的直接交接模式Executors.newCachedThreadPool用的就是它——新任务来了如果没有空闲线程立即创建一个新线程处理所以这个线程池适合短时任务和任务提交速度不定的场景。4.2 用BlockingQueue实现生产者消费者代码在这里基于BlockingQueue实现生产者消费者模型代码极度简洁而且不需要手动处理锁和条件变量。下面是我在项目里真实用过的日志异步批量写入模型public class LogProducerConsumer { private final BlockingQueueLogEntry queue new ArrayBlockingQueue(10000); private final ExecutorService consumerPool Executors.newSingleThreadExecutor(); private volatile boolean running true; public void start() { consumerPool.submit(this::consumeLoop); } public void produce(LogEntry entry) { if (!queue.offer(entry, 100, TimeUnit.MILLISECONDS)) { // 队列满了丢弃日志并计数不能阻塞业务线程 dropCounter.incrementAndGet(); } } private void consumeLoop() { while (running) { try { LogEntry entry queue.poll(500, TimeUnit.MILLISECONDS); if (entry ! null) { // 批量写入日志文件 } } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } } } }这个模型的设计关键点在于生产者线程是高频调用路径绝不能因为日志队列满了而阻塞所以用offer(entry, 100, TimeUnit.MILLISECONDS)设置了一个100毫秒的超时窗口超时直接丢弃日志牺牲一点完整性换取主业务的绝对稳定。消费者是单线程用poll(500, TimeUnit.MILLISECONDS)做超时轮询既能及时处理新日志又不会在没有日志时空转浪费CPU。这里需要注意BlockingQueue实现类的选择ArrayBlockingQueue有界且lock竞争相对简单适合这种队列容量固定、消费速度稳定的场景如果生产者偶尔突刺、队列需要弹性缓冲考虑有界的LinkedBlockingQueue。生产环境不要用无界队列这点前面已经反复强调了。4.3 Lock版精确唤醒用Condition实现按序执行BlockingQueue屏蔽了wait/notify的复杂性但有些场景需要我们手动控制等待和唤醒特别是“精确唤醒某个特定线程”这种需求。周阳老师在课程里讲了一个经典例子三个线程按顺序循环打印A、B、C。这个例子看起来简单但很考察对Lock和Condition的理解。如果用synchronizedwait/notifyAll只能无差别唤醒所有等待线程然后在循环里判断当前是否轮到自己逻辑绕且低效。用Condition就可以精确控制唤醒指定的线程组public class SequencePrinter { private final Lock lock new ReentrantLock(); private final Condition conditionA lock.newCondition(); private final Condition conditionB lock.newCondition(); private final Condition conditionC lock.newCondition(); private int state 1; // 1: A执行, 2: B执行, 3: C执行 public void printA() { lock.lock(); try { while (state ! 1) { conditionA.await(); } System.out.print(A); state 2; conditionB.signal(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { lock.unlock(); } } // printB 和 printC 同理 }这段代码的精髓在于一个Lock可以创建多个Condition每个Condition维护一个独立的等待队列。线程A调用conditionA.await()只会把自己放入conditionA的等待队列不会影响其他线程线程打印完A之后调用conditionB.signal()只唤醒等待在conditionB上的线程B。这就实现了synchronized时代很麻烦的“定向唤醒”。这里有个细节必须注意await要放在while循环里而不是if里为什么要这么做因为线程被唤醒后可能又被其他线程抢占了锁导致条件变量再次失效。用while重新检查状态是防御“虚假唤醒”spurious wakeup和“锁竞争导致的条件失效”的标准姿势。这一点在生产者消费者模式里同样适用。真实业务里我用过类似的手法实现订单状态机的流转通知订单创建后通知支付模块处理、支付完成后通知发货模块处理、发货完成后通知完成模块处理。每一个模块是一条独立的Condition队列状态变化时只signal对应的Condition而不是全局唤醒这个设计在状态机流转清晰、扩展性好的同时也避免了无效唤醒带来的CPU空转。5. 高并发场景下的避坑指南与面试高频考点5.1 并发编程的经典坑位实录整理了这些年我在并发编程实践中反复踩过的坑有些是看过周阳老师的课后才真正想明白的有些是线上故障教做人的分享出来供大家参考。第一个坑是线程池里的异常吞没。使用execute()提交任务时如果任务内部抛出运行时异常这个异常会直接打印到std err如果设置了UncaughtExceptionHandler而提交线程拿不到导致任务悄无声息地失败。这在一些“看起来跑完了但结果不对”的场景中极难排查。推荐用submit()提交通过Future的get方法捕获异常或者直接在任务内部catch所有异常并妥善处理。第二个坑是ThreadLocal内存泄漏。线程池里的线程是复用的ThreadLocalEntry的key是弱引用如果线程不被销毁那ThreadLocal对应的value会一直存在最终导致内存泄漏甚至OOM。阿里规约里明确要求使用ThreadLocal后必须调用remove清理。我见过一个用了ThreadLocal存用户信息但没有remove的线上案例导致用户A的登录状态在某些高并发场景下串到了用户B的请求里属于严重生产事故。第三个坑是过度使用锁导致性能劣化。很多初学者习惯把整个方法体都加上synchronized虽然线程安全了但并发度也归零了。我见过一个例子一个查询接口只是在一个局部HashMap读写处需要同步结果整个方法加了synchronized压测时QPS直接掉了70%。合理的方式是缩小锁的粒度用ConcurrentHashMap替代全局锁或者用读写锁分离读和写。第四个坑是忘记处理InterruptedException。很多人在catch到InterruptedException后只是打印日志没有恢复中断标志。正确的做法是调用Thread.currentThread().interrupt()恢复中断状态让上层代码感知到中断请求。这不只是规范问题在基于中断机制设计的协作式取消场景里不恢复中断标志会导致取消请求被静默吞掉。第五个坑是并发工具的误用。比如把CountDownLatch当“一次性门闩”用了多次它不可复用把CyclicBarrier当CountDownLatch用导致屏障反复打开把Semaphore的permits设成1就以为和锁一样实际上Semaphore不保证可重入如果同一个线程多次acquire就会死锁。要对每个工具的本质语义有清晰认知而不是用“感觉差不多”来选型。5.2 面试高频考点把知识体系串起来JUC是Java面试的必考区域我把周阳老师课程里反复强调面经里也常考的考点整理成一个清单每个点都值得深挖volatile相关问题volatile能保证原子性吗为什么volatile和synchronized的区别是什么volatile的底层实现内存屏障是怎样的这题在简历上出现频率极高几乎必问。CAS相关问题CAS的底层原理是什么CAS会有什么问题ABA问题如何解决AtomicInteger和synchronized做自增谁更快、什么时候谁更快这题考察的是对无锁并发的理解深度。AQS相关问题AQS的原理是什么state的作用CLH队列AQS使用的双向队列变体是怎么工作的ReentrantLock的公平锁和非公平锁在AQS层面有什么区别知道AQS基本就能断定你读没读过并发源码。锁相关的问题synchronized的锁升级过程偏向锁→轻量级锁→重量级锁ReentrantLock和synchronized的区别读写锁的适用场景StampedLock的乐观读是什么锁的适用边界是最能体现工程经验的考点。线程池相关问题线程池的7大参数任务执行的完整流程拒绝策略有哪些如何合理设置线程池参数为什么禁用Executors的快捷方法线程池的异常处理机制这是实战面最常考的并发主题。并发工具类问题CountDownLatch和CyclicBarrier的区别Semaphore的适用场景BlockingQueue的常见实现类和使用场景CompletableFuture怎么编排异步任务这类题考查的是对工具矩阵的全面掌握。我在面试候选人时对比明显的是只背结论的人会在“为什么非公平锁性能更好”“为什么并发HashMap读操作不需要加锁”这种问题上露出马脚而真正跟过完整课程、自己动手跑过示例代码的人回答的深度和细节是完全不同的。建议大家学JUC时不要停留在“会用”层面要读到源码层面把每个机制的前因后果弄明白。5.3 从课堂到生产我的一些务实建议学完JUC并不等于能用好并发编程从课堂知识到生产实践之间还有一些重要的认知转变。第一个建议是在写并发代码之前先明确你的并发场景属于哪一类是“读多写少”优先考虑读写锁、CopyOnWrite容器、“写多读少”考虑ConcurrentHashMap、加锁、“一写多读”volatile就够、“多线程协作”CountDownLatch、CyclicBarrier、CompletableFuture。场景定了选型就顺理成章了。第二个建议是优先使用更高级的并发抽象而不是自己手动加锁。能用一个ConcurrentHashMap解决的问题就不要自己实现ConcurrentMap能用CompletableFuture编排异步任务就不要手动管理FutureCountDownLatch能用BlockingQueue就屏蔽wait/notify细节。手动加锁是最后手段而不是第一选择。第三个建议是并发问题必须通过压测和监控来验证而不是靠人肉推理。并发代码的运行结果具有一定的随机性某些问题只有在特定时序下才会暴露。同一份代码在低并发下可能一直正常高并发下突然出现数据错乱。所以写完并发代码后建议用压测工具模拟高并发场景并配合JFRJava Flight Recorder、JStack线程栈等技术来验证线程状态和锁竞争情况。第四保持代码的可读性和可维护性。并发代码本来就难读如果锁的粒度、变量的可见性处理得随意后面接手的同事维护起来会非常痛苦。写并发代码时尽量把锁的范围缩到最小用清晰的命名表达同步的意图多写注释说明“为什么这里要加锁”“为什么不加锁也安全”这对团队协作很重要。写在最后的一点个人体会整套课程跟下来我最深的感受是JUC不是一个“背API”的知识体系而是一个“理解并发本质”的思维框架。从volatile和CAS这两个最基础的机制出发到AQS这个中间层再到Lock、Semaphore、CountDownLatch、线程池、阻塞队列这些具体工具每一层都在回答同一个问题——如何让多个线程在共享资源上安全、高效、低延迟地协作。把这根主线理清楚了后面不管遇到多少新的并发工具都能很快理解它的定位和用法。如果只能给大家一条学习建议我会说学JUC一定要写代码验证不要只看视频和笔记。我自己在学ReentrantLock的时候把公平锁和非公平锁分别跑了十万次加锁看线程执行顺序的分布学CountDownLatch的时候手动模拟了一个子线程抛异常的场景看主线程是不是真的卡死。只有亲手制造过问题、亲眼见过异常行为对并发机制的理解才是真正扎实的。希望这篇笔记能帮你在JUC学习路上少走一些弯路学完一定要多写多测多思考。