并发编程这块内容我自己前前后后啃过不下五遍每次重新翻出来看都有新的理解。2020年前后那段时间面试市场上对并发编程的考察密度突然上了一个台阶几乎每一场后端岗位的面试都绕不开线程池、锁、内存模型这几块。当时我整理了一份自己的复习笔记后来陆续补充了不少实际踩坑的经验慢慢就攒成了这份东西。它不是什么官方教材就是一份从面试实战和项目实战里滚出来的总结适合正在准备后端或基础架构方向面试的朋友也适合已经工作但想系统梳理并发知识体系的同学。下面我按自己复习和实战的顺序把核心内容拆开讲。1. 并发编程面试的核心考察逻辑1.1 面试官到底想考什么很多人复习并发编程的时候容易陷入一个误区就是把API背得滚瓜烂熟比如Thread.start()和Runnable.run()的区别、synchronized修饰方法和修饰代码块的差异这些当然要会但面试官真正想判断的是你对并发问题本质的理解深度。我面过不少人问“线程池参数怎么配”能答出来的很多但追问一句“你线上那个池为什么核心线程数设成8而不是16”能说清楚的就少了一半。面试官考察的点大致可以归为四层第一层是概念准确性比如什么是可见性、什么是原子性这些术语不能含糊第二层是原理理解比如volatile为什么能保证可见性但不能保证原子性这背后涉及内存屏障和CPU缓存一致性协议第三层是工具选型能力给定一个场景你能不能说清楚为什么用ReentrantLock而不是synchronized第四层是问题排查经验线上CPU飙高、线程死锁、任务堆积你怎么定位怎么解决。这四层里前两层靠看书和背八股能解决后两层必须靠实际项目和踩坑积累。所以如果你时间有限我建议把精力优先放在第三层和第四层上因为面试官一旦发现你有实战经验前面的基础问题反而不会太为难你。1.2 知识体系的搭建顺序我自己的复习顺序是这样的先搞清楚线程的生命周期和状态转换这是所有并发问题的地基然后进入共享变量的可见性和原子性问题也就是volatile、synchronized、CAS这一套接着是线程池和并发工具类这是日常开发用得最多的最后是内存模型和底层原理包括JMM、happens-before、内存屏障这些偏理论的内容。这个顺序的好处是你每学一块都能立刻和上一块建立联系。比如学volatile的时候你会自然想到线程状态里的“就绪”和“运行”切换时共享变量是怎么被读取和写回的学线程池的时候你会用到CAS去理解ctl变量的设计。如果一上来就啃JMM很容易被各种抽象概念绕晕最后只记住几个名词面试时一追问就露馅。提示复习并发编程不要按书的章节顺序走按“问题驱动”的顺序走。先问自己“多线程为什么会出问题”然后顺着问题去找答案这样记住的东西是活的。1.3 2020年前后考察重点的变化2020年那会儿面试对并发编程的考察出现了一个明显的变化从“你会不会用”转向“你知不知道为什么这么设计”。举个例子以前问HashMap线程不安全答“并发扩容会导致死循环”就够了后来会追问“JDK 8之后为什么改成尾插法就不会死循环了但依然不安全”这就逼着你去理解红黑树和扩容时的数据迁移逻辑。另一个变化是对线程池的考察更细了。以前问“线程池有哪些参数”就能过后来会问“核心线程数满了之后任务进队列还是直接创建新线程”“队列满了之后拒绝策略怎么选”“allowCoreThreadTimeOut设成true会有什么影响”。这些问题没有标准答案面试官看的是你能不能结合业务场景给出合理的解释。还有一个趋势是结合线上问题来问。比如“线上服务突然大量超时你怀疑是线程池的问题怎么排查”这种问题考的是你的排查思路而不是某个具体知识点。我当时的准备方法是把自己项目里遇到过的并发问题都写成小故事每个故事包含现象、排查过程、根因、解决方案面试时直接讲效果比背八股好得多。2. 线程基础与状态转换的深挖2.1 六种状态与操作系统状态的对应关系Java的Thread.State枚举定义了六种状态NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。很多人背得下来但一问“RUNNABLE和操作系统里的运行态是什么关系”就卡住了。这里的关键是Java的RUNNABLE其实包含了操作系统层面的“就绪”和“运行”两种状态也就是说一个Java线程在RUNNABLE时可能正在CPU上跑也可能在等待CPU时间片。BLOCKED专指等待synchronized锁的状态注意它不包括等待Lock接口的锁。如果你用ReentrantLock线程在等待锁时进入的是WAITING或TIMED_WAITING状态具体取决于你调用的是lock()还是tryLock(timeout)。这个区别在排查死锁时很重要因为jstack输出的线程状态会不一样。WAITING和TIMED_WAITING的区别在于有没有超时时间。Object.wait()不带参数进入WAITING带参数进入TIMED_WAITINGThread.join()同理。这里有个容易混淆的点Thread.sleep()进入的是TIMED_WAITING而且它不释放锁这一点和wait()完全不同。我见过有人在synchronized块里用sleep()等条件结果把整个锁持有到睡眠结束性能直接崩掉。Java状态触发方式是否释放锁常见排查特征BLOCKED等待synchronized锁不涉及jstack显示waiting to lockWAITINGwait()、join()、park()wait释放join不释放jstack显示in Object.wait()TIMED_WAITINGsleep()、wait(timeout)sleep不释放wait释放jstack显示sleeping或waitingRUNNABLE运行或就绪不涉及可能正在跑也可能在等CPU2.2 状态转换的实操验证光看表格记不住我建议你写一段代码实际跑一下用jstack观察状态变化。比如下面这段代码启动两个线程一个持有锁后wait()另一个尝试获取同一把锁public class StateDemo { private static final Object lock new Object(); public static void main(String[] args) throws Exception { Thread t1 new Thread(() - { synchronized (lock) { try { lock.wait(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } }, thread-1); Thread t2 new Thread(() - { synchronized (lock) { System.out.println(t2 got lock); } }, thread-2); t1.start(); Thread.sleep(100); t2.start(); Thread.sleep(100); // 此时t1在WAITINGt2在BLOCKED Thread.sleep(100000); } }运行后用jstack pid查看你会看到thread-1处于WAITINGthread-2处于BLOCKED。这个实验做一遍比背十遍状态图都管用。我当时就是靠这个方法把状态转换彻底搞清楚的后来面试被问到“wait和sleep的区别”我直接画了个状态转换图面试官就没再追问了。2.3 中断机制的正确使用姿势中断是并发编程里最容易被误用的机制之一。Thread.interrupt()并不是真的“中断”线程它只是设置一个标志位具体怎么响应取决于线程当前在做什么。如果线程在sleep()、wait()、join()这些可中断方法上阻塞调用interrupt()会抛出InterruptedException并清除中断标志如果线程在正常运行interrupt()只是把标志位设为true需要你自己检查Thread.currentThread().isInterrupted()来决定是否退出。我见过很多代码在捕获InterruptedException后直接吞掉异常什么都不做这是非常危险的。正确的做法是要么继续往上抛要么重新设置中断标志Thread.currentThread().interrupt()让上层代码知道中断发生了。下面是一个标准的中断处理模板public void run() { try { while (!Thread.currentThread().isInterrupted()) { // 执行任务 doWork(); } } catch (InterruptedException e) { // 恢复中断状态让调用者知道 Thread.currentThread().interrupt(); } finally { // 清理资源 cleanup(); } }注意不要在捕获InterruptedException后既不抛出也不恢复中断标志这会导致上层代码无法感知中断线程可能永远无法优雅退出。3. 锁机制与并发控制的实战解析3.1 synchronized的锁升级过程synchronized在JDK 6之后引入了锁升级机制从无锁到偏向锁、轻量级锁、重量级锁目的是在不同竞争程度下选择最合适的锁实现。这个知识点面试问得特别多但很多人只记住了名词不知道升级的触发条件。偏向锁的核心思想是如果一个锁总是被同一个线程获取那就没必要做CAS操作直接在对象头里记录线程ID就行。当第二个线程尝试获取锁时偏向锁升级为轻量级锁线程通过CAS自旋来竞争。如果自旋超过一定次数默认是10次可以通过-XX:PreBlockSpin调整或者有第三个线程加入竞争轻量级锁就膨胀为重量级锁线程进入阻塞状态。这里有个实操细节偏向锁在JDK 15之后默认关闭了因为它的维护成本比较高而且在现代应用中锁竞争往往比较激烈偏向锁的收益不明显。如果你在面试中被问到偏向锁可以顺带提一句这个变化会显得你关注了版本演进。锁升级的过程是不可逆的一旦升级到重量级锁就不会再降级。这个设计的原因是能升级到重量级锁说明竞争已经比较激烈了降级带来的收益不大反而增加了复杂度。3.2 ReentrantLock与synchronized的选型对比面试里经常问“什么时候用ReentrantLock什么时候用synchronized”。我的回答框架是这样的先看功能需求再看性能最后看代码简洁性。功能上ReentrantLock支持公平锁、可中断锁、超时获取锁、条件变量Condition这些是synchronized不具备的。如果你需要这些高级功能比如实现一个带超时的资源获取或者需要多个等待队列那就必须用ReentrantLock。性能上在JDK 6之后两者的性能差距已经很小了synchronized在低竞争场景下甚至更快因为JVM对它做了大量优化。代码简洁性上synchronized是关键字不需要手动释放锁不容易出错ReentrantLock必须在finally块里释放写漏了就会导致锁泄漏。我自己的习惯是如果没有特殊需求优先用synchronized因为出错概率低如果需要超时、中断、公平锁这些特性再用ReentrantLock。下面是一个典型的ReentrantLock使用模板private final ReentrantLock lock new ReentrantLock(); public void doSomething() { lock.lock(); try { // 临界区代码 } finally { lock.unlock(); } }提示ReentrantLock的unlock()必须放在finally的第一行不要在unlock()之前做任何可能抛异常的操作否则锁可能永远释放不了。3.3 读写锁与StampedLock的适用场景ReentrantReadWriteLock允许多个读线程同时访问但写线程独占。它的适用场景是读多写少比如缓存系统。但要注意读写锁容易引发写线程饥饿因为读线程源源不断写线程可能一直拿不到锁。JDK 8引入的StampedLock通过乐观读解决了这个问题它的tryOptimisticRead()不阻塞写线程读完之后用validate()检查有没有写操作发生如果没有就直接用数据如果有再升级为悲观读。StampedLock的性能在特定场景下比ReentrantReadWriteLock好很多但它不支持重入而且API比较复杂用错了容易死锁。我个人的建议是除非你确实遇到了读写锁的性能瓶颈否则优先用ReentrantReadWriteLock它的语义更清晰维护成本更低。锁类型读并发写并发是否可重入适用场景synchronized否否是简单同步ReentrantLock否否是需要高级功能ReentrantReadWriteLock是否是读多写少StampedLock是否否读极多写极少追求性能4. 线程池与并发工具类的深度实践4.1 线程池参数的计算与配置线程池的七个参数里corePoolSize、maximumPoolSize、workQueue、handler是最关键的。很多人配线程池靠感觉其实有一个经验公式可以参考对于CPU密集型任务核心线程数设为CPU核数1对于IO密集型任务核心线程数设为CPU核数×2或者用CPU核数 / (1 - 阻塞系数)来估算阻塞系数一般在0.8到0.9之间。但这个公式只是起点实际配置必须结合压测结果调整。我自己的做法是先按公式设一个初始值然后观察线程池的活跃线程数、队列长度、拒绝次数如果队列经常满但CPU利用率不高说明线程数偏少如果CPU利用率很高但队列很短说明线程数可能偏多上下文切换开销大。队列的选择也很关键。ArrayBlockingQueue有界能防止任务无限堆积但队列满了会触发拒绝策略LinkedBlockingQueue默认无界其实是Integer.MAX_VALUE任务可以无限堆积但可能导致内存溢出。我一般推荐用有界队列配合CallerRunsPolicy拒绝策略这样当任务堆积时提交任务的线程会自己执行任务起到背压的作用。ThreadPoolExecutor executor new ThreadPoolExecutor( 8, // corePoolSize 16, // maximumPoolSize 60L, TimeUnit.SECONDS, // keepAliveTime new ArrayBlockingQueue(200), // 有界队列 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() // 背压策略 );4.2 线程池的监控与动态调整线程池上线之后不能不管必须加监控。ThreadPoolExecutor提供了几个关键方法getActiveCount()获取活跃线程数getQueue().size()获取队列长度getCompletedTaskCount()获取已完成任务数。我一般会把这些指标打到监控系统里设置告警阈值比如队列长度超过容量的80%就告警。动态调整方面ThreadPoolExecutor允许在运行时修改核心线程数和最大线程数通过setCorePoolSize()和setMaximumPoolSize()。这个特性在应对突发流量时很有用但要注意调大核心线程数后新线程不会立即创建而是等到有新任务提交时才创建调小核心线程数后多余的线程会在下次空闲时被回收。注意动态调整线程池参数时要确保maximumPoolSize始终大于等于corePoolSize否则会抛出IllegalArgumentException。4.3 CountDownLatch、CyclicBarrier与Semaphore的选型这三个工具类经常被放在一起比较。CountDownLatch是一次性的计数器减到零后就不能重置适合“一个线程等待多个线程完成”的场景比如主线程等待所有子任务完成后再汇总结果。CyclicBarrier是可以循环使用的适合“多个线程互相等待到齐后再一起继续”的场景比如多阶段计算每个阶段结束后所有线程同步一次。Semaphore是信号量控制同时访问资源的线程数量适合限流场景。我见过有人用CountDownLatch实现循环等待结果第二次调用await()直接返回了因为计数器已经归零。这种错误在面试里如果被问到能答出来就是加分项。选型的时候记住一句话等别人用CountDownLatch等彼此用CyclicBarrier控数量用Semaphore。工具类是否可重用典型场景注意事项CountDownLatch否主线程等子任务计数器归零后不可重置CyclicBarrier是多阶段并行计算打破屏障后需处理BrokenBarrierExceptionSemaphore是资源限流释放前需确保已获取4.4 CompletableFuture的异步编排CompletableFuture是JDK 8引入的异步编程工具它解决了Future不能回调、不能组合的问题。我日常用得最多的几个方法是supplyAsync()提交异步任务thenApply()对结果做转换thenCompose()串联两个异步任务thenCombine()合并两个异步任务的结果exceptionally()处理异常。这里有一个容易踩的坑thenApply()和thenApplyAsync()的区别。前者可能由上一个任务的线程执行后者会提交到线程池执行。如果你在thenApply()里做了耗时操作可能会阻塞上一个任务的线程影响吞吐量。我一般建议如果回调逻辑很轻用thenApply()如果回调逻辑比较重用thenApplyAsync()并传入自定义线程池。CompletableFutureString future CompletableFuture .supplyAsync(() - queryFromDb(), dbExecutor) .thenApplyAsync(data - enrichData(data), enrichExecutor) .exceptionally(ex - { log.error(async task failed, ex); return default; });5. 内存模型与底层原理的面试突破5.1 JMM与happens-before规则JMMJava内存模型是并发编程的理论基础它定义了多线程环境下共享变量的读写规则。核心概念是主内存和工作内存每个线程有自己的工作内存共享变量存储在主内存中线程读写变量时先拷贝到工作内存操作完再写回主内存。这个模型导致的问题就是可见性——一个线程改了变量另一个线程可能看不到。happens-before是JMM用来描述操作之间可见性的规则。如果A happens-before B那么A的结果对B可见。最常用的几条规则包括程序顺序规则同一线程内前面的操作happens-before后面的操作、监视器锁规则解锁happens-before后续加锁、volatile变量规则写volatile happens-before后续读volatile、传递性规则A hb BB hb C则A hb C。面试里经常给一段代码让你判断有没有并发问题。这时候你就用happens-before规则去套看两个操作之间有没有建立hb关系。如果没有就存在可见性问题。这个方法比凭感觉判断靠谱得多。5.2 volatile的内存屏障实现volatile保证可见性和有序性但不保证原子性。它的底层实现是通过内存屏障实现的写volatile变量时会在写操作前后插入StoreStore和StoreLoad屏障读volatile变量时会在读操作前后插入LoadLoad和LoadStore屏障。这些屏障的作用是禁止指令重排序并强制将工作内存的修改刷新到主内存。为什么volatile不保证原子性因为像i这样的操作实际上是“读-改-写”三步volatile只能保证读到的值是最新的但读完之后、写回之前其他线程可能已经修改了值导致写回时覆盖了别人的修改。要保证原子性必须用synchronized或AtomicInteger。提示volatile最适合的场景是“一个线程写多个线程读”的状态标志位比如volatile boolean running true这种场景下不需要原子性只需要可见性。5.3 CAS与ABA问题的解决CASCompare And Swap是乐观锁的核心实现它包含三个值内存地址、预期值、新值。如果内存地址的当前值等于预期值就更新为新值否则不做任何操作。CAS是原子操作由CPU指令保证。AtomicInteger、AtomicReference这些类都是基于CAS实现的。CAS的经典问题是ABA线程1读取值为A线程2把值改成B又改回A线程1的CAS操作发现值还是A就认为没有变化实际上已经变过了。解决方法是加版本号AtomicStampedReference就是干这个的它内部维护了一个版本号每次修改版本号加一CAS时同时比较值和版本号。AtomicStampedReferenceInteger ref new AtomicStampedReference(100, 0); int stamp ref.getStamp(); ref.compareAndSet(100, 200, stamp, stamp 1);5.4 伪共享与缓存行填充伪共享是一个比较底层的性能问题。CPU缓存以缓存行为单位通常是64字节。如果两个变量位于同一个缓存行一个线程修改了变量A会导致整个缓存行失效另一个线程读取变量B时就必须从主内存重新加载即使B没有被修改。这就是伪共享。解决方法是缓存行填充在变量前后各填充7个long类型的变量凑满64字节让每个变量独占一个缓存行。JDK 8提供了Contended注解但默认是关闭的需要加-XX:-RestrictContended参数才能生效。Disruptor框架就是通过缓存行填充来提升性能的。这个知识点在面试里属于加分项能答出来说明你对底层有研究。但要注意不要为了炫技而滥用缓存行填充它会增加内存占用只有在高并发、低延迟的场景下才值得。6. 线上问题排查与避坑经验6.1 CPU飙高的排查思路线上CPU飙高是并发问题最常见的表现之一。我的排查步骤是这样的先用top -Hp pid找到CPU占用最高的线程ID然后把线程ID转成十六进制再用jstack pid | grep hex找到对应的线程栈。如果栈显示线程在执行业务代码说明是计算密集型任务需要优化算法或增加机器如果栈显示线程在RUNNABLE但代码是空的可能是死循环如果栈显示大量线程在BLOCKED说明锁竞争严重。有一次我遇到一个案例CPU飙到90%以上jstack发现大量线程在HashMap.get()上但HashMap本身不是线程安全的并发读写会导致死循环。后来定位到是一个全局缓存没有加锁多个线程同时扩容导致链表成环。换成ConcurrentHashMap后问题解决。这个案例告诉我共享的可变状态必须用线程安全的容器不要心存侥幸。6.2 死锁的定位与预防死锁的四个必要条件是互斥、持有并等待、不可剥夺、循环等待。破坏任意一个条件就能预防死锁。实际开发中最常见的死锁原因是锁顺序不一致线程1先锁A再锁B线程2先锁B再锁A两者互相等待。定位死锁用jstack它会自动检测死锁并输出“Found one Java-level deadlock”下面会列出涉及死锁的线程和锁信息。预防死锁的方法有统一锁顺序、使用tryLock(timeout)、减少锁的粒度、用无锁数据结构。我自己的习惯是如果必须获取多个锁就按对象的hashCode排序后再获取这样所有线程的锁顺序一致不会形成循环等待。6.3 线程泄漏的识别与处理线程泄漏是指线程创建后没有正确终止导致线程数持续增长。常见原因包括线程池没有关闭、ThreadLocal没有清理、线程在wait()后没有被唤醒。识别线程泄漏的方法是监控JVM的线程数如果线程数持续上升且不下降基本可以确定是泄漏。ThreadLocal泄漏是一个高频问题。ThreadLocal的值存在Thread对象的ThreadLocalMap里key是ThreadLocal的弱引用value是强引用。如果ThreadLocal对象被回收了key变成null但value还在导致内存泄漏。解决方法是在使用完ThreadLocal后调用remove()尤其是在线程池场景下线程会被复用不清理会污染下一个任务。private static final ThreadLocalUserContext CONTEXT new ThreadLocal(); public void doSomething() { try { CONTEXT.set(new UserContext()); // 业务逻辑 } finally { CONTEXT.remove(); // 必须清理 } }6.4 常见问题速查表问题现象可能原因排查工具解决方案CPU飙高死循环、锁竞争、频繁GCtop、jstack优化算法、减少锁粒度线程阻塞死锁、资源等待jstack统一锁顺序、超时机制内存溢出线程泄漏、队列堆积jmap、jstat清理ThreadLocal、有界队列任务堆积线程池参数不合理监控指标调整核心线程数、拒绝策略响应变慢上下文切换频繁vmstat减少线程数、用协程7. 面试表达与知识串联技巧7.1 如何把知识点串成故事面试的时候零散的知识点容易忘但故事不会。我习惯把并发知识串成一条线从“多线程为什么快”讲到“多线程为什么出问题”再讲到“怎么解决这些问题”最后讲到“解决之后怎么监控”。这条线串起来就是CPU多核并行提升吞吐量但共享变量导致可见性和原子性问题用synchronized和volatile解决用线程池管理线程资源用监控指标发现异常。比如被问到“线程池”你可以这样展开先说为什么需要线程池降低线程创建销毁开销、控制并发数再说核心参数怎么配结合业务场景然后说监控怎么做活跃线程数、队列长度最后说遇到过什么问题任务堆积、拒绝策略。这样回答既有广度又有深度面试官会觉得你真正用过。7.2 面对不会的问题怎么应对面试中遇到不会的并发问题很正常关键是怎么应对。我的策略是先承认不会然后展示思考过程最后给出一个合理的猜测。比如被问到“StampedLock的乐观读是怎么实现的”如果你没看过源码可以说“我了解它的设计思路是通过版本号来验证具体实现应该是CAS加版本戳我回去会仔细看一下源码”。这样既诚实又展示了你的分析能力。千万不要不懂装懂面试官一追问就露馅了。并发编程的很多问题没有标准答案面试官更看重你的思考方式和学习能力。7.3 复习节奏与资料选择最后说一下复习节奏。我建议把并发编程的复习分成三轮第一轮用两周时间过一遍基础概念和API重点是线程状态、锁、线程池第二轮用一周时间深入原理重点是JMM、CAS、内存屏障第三轮用一周时间做实战演练自己写几个并发场景的Demo用jstack、jmap观察运行状态。资料方面除了经典的并发编程书籍我强烈建议多看JDK源码尤其是ThreadPoolExecutor、ConcurrentHashMap、AQS这几个类的实现。看源码的时候不要逐行读先看类注释和核心方法理解设计思路再深入细节。另外多逛技术社区看看别人遇到的并发问题和解决方案这些实战经验比书本知识更宝贵。我在实际面试和工作中最大的体会是并发编程不是靠背出来的是靠踩坑踩出来的。每一个你解决过的线上问题都会变成面试时的底气。所以如果你现在还在准备阶段不妨找几个开源项目看看它们是怎么处理并发的或者自己写一个小项目故意制造一些并发问题然后去排查和解决。这个过程虽然费时间但收获是实打实的。
阅读完成 · 觉得有帮助?