Java面试Day14:并发编程核心冲刺与实战排查指南 如果你正在准备Java面试看到“Day14”这个编号就该明白这不是零基础入门课而是一整条八股复习路线中的关键节点。很多人的计划是Java基础、集合、JVM、并发一路推进真正拉开差距的地方往往就在并发编程。前面语法和集合题大家都会背到了并发面试官随便追问两三轮就能看出你是真懂还是背答案。Day14我给自己安排的是并发专题JMM与volatile、synchronized锁升级、CAS与AQS、线程池、死锁排查这一套几乎是面试官最爱从浅到深层层追问的路径。无论你面的岗位偏业务还是偏中间件并发都属于必考区域而且高频追问点相当集中。这篇文章把这一天我踩过的坑、总结出的答题思路、以及实测过的排查命令都整理出来适合正在冲刺校招或准备跳槽、需要系统过一遍并发核心知识的朋友直接参考。1. Day14为什么要安排成并发专题1.1 复习路线中的位置逻辑前13天如果按常规节奏走通常已经覆盖了Java基础语法、集合类源码、JVM内存与垃圾回收、Spring、MySQL这些模块。到达Day14时其实已经具备了理解并发知识的两块关键地基一是JVM的对象布局知识因为synchronized锁升级和对象头强相关二是操作系统层面的线程模型知识因为锁竞争、上下文切换这些概念都建立在上面。把并发安排在这个位置是有道理的。没有对象头知识谈锁升级就是空中楼阁没有操作系统概念谈重量级锁为什么慢就缺乏依据。但反过来并发又是后续看各种框架源码的钥匙像是Netty的EventLoop、Tomcat的线程池配置理解起来全都依赖并发基础。所以放在中间偏后的位置既顺理成章又能承上启下。1.2 并发在面试中的权重分布从面试考察频率来看并发编程基本稳居前二和JVM并列。具体到高频考点无非五类可见性与volatile、synchronized与锁升级、CAS与AQS、线程池、并发容器。而Day14如果只选一个主线我认为应该把“锁与线程池”打透因为这两个方向最容易考到场景设计题比如“你这个服务里线程池怎么配的”“这段并发代码有没有问题”。面试官常用的一种套路是抛出一个看似简单的问题比如“volatile能保证原子性吗”然后顺着可见性一路问到指令重排、内存屏障、单例模式再切到synchronized的锁升级。也就是说这五类考点不是孤立的它们共同构成一条逻辑链。Day14要做的事情就是把这条链子串起来而不是零散地背几个知识点。1.3 今天的时间分配建议我建议把复习时间切成三块上午主攻JMM和volatile连带把synchronized锁升级过一遍因为这两块内在逻辑是连续的下午集中精力研究CAS、AQS和线程池重点理解执行流程和参数意义晚上拿一台Linux服务器实际跑一遍死锁排查命令再整理一份自己的易错点清单。这样安排的好处是原理理解和动手验证能当堂消化而不是背完就忘。2. JMM与volatile从内存模型底层理解可见性2.1 JMM到底在讲什么Java内存模型JMM是一个抽象出来的规则体系它规定了共享变量在并发场景下如何在线程间传递。JMM把内存分为主内存和工作内存主内存是所有线程共享的存放变量真实值工作内存是线程私有的保存了变量的副本。线程对变量的所有读写都先发生在工作内存再同步回主内存。这里可以用一个生活化类比主内存像图书馆的正式馆藏工作内存像每个人手里的读书笔记你改的是自己的笔记别人看不见直到你把笔记内容提交回图书馆才同步。这种设计带来的直接问题就是可见性。当线程A修改了一个共享变量还没来得及写回主内存时线程B正在读自己工作内存里那份旧副本B读到的就是脏数据。CPU缓存、指令重排还会放大这个问题但JMM层面的核心矛盾就是工作内存与主内存的不一致。2.2 happens-before规则是解题钥匙面试官问可见性时经常搬出happens-before规则来考察。JMM规定了八条规则面试最常见的是前几条程序顺序规则也就是单线程内代码的先后顺序锁规则解锁操作先行发生于加锁操作volatile规则对一个volatile变量的写先行发生于后续对该变量的读传递性A先行于B、B先行于C则A先行于C。这里有个容易忽略的细节happens-before只保证时间可见性不保证实时性。它说的是“如果操作A先行发生于操作B那么A的结果对B可见”而不是说A一定在物理时间上先执行完。理解这一点比死记八条规则更重要因为很多面试追问都围绕“为什么这个操作不需要加锁也能看到结果”展开。2.3 volatile的两个核心语义volatile有两个核心语义可见性和禁止指令重排。先说可见性volatile变量的读写直接穿透工作内存读操作从主内存拿最新值写操作立即刷回主内存。再说禁重排编译器、CPU和JIT都可能对指令顺序进行优化volatile通过内存屏障限制了重排边界。但volatile不能保证原子性。典型例子就是count它其实是一条复合指令读count、加1、写回count。三个步骤之间完全可以被线程切换打断volatile只保证了一头一尾的可见性中间的复合操作依然可能丢更新。我在模拟面试时问过很多人能答出“volatile不能保证原子性”的不少但能举出count这种例子并且说清三个步骤的明显就靠谱多了。2.4 双重检查锁单例中的volatile双重检查锁单例是最经典的volatile应用场景。很多人会背“单例要加volatile”但被问到“为什么”时容易卡壳。关键点在于new对象不是一个原子操作它可以分解为三步分配内存、初始化对象、把引用指向内存地址。如果不加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; } }这个场景里volatile起的是禁止指令重排的作用而不是保证可见性因为synchronized已经保证了可见性。能分清这一层面试官基本就会觉得你是真懂而不是背了标准答案。3. synchronized锁升级与Monitor把锁的成长路径讲透3.1 synchronized的字节码视角synchronized在字节码层面的表现是一对monitorenter和monitorexit指令。进入时获取对象监视器锁退出时释放。编译器会在正常退出和异常路径上都生成monitorexit保证锁必然释放。但真正让面试有深度的是锁升级这整条链路。早期JDK的synchronized直接依赖操作系统的互斥量实现线程一旦竞争锁失败就进入内核态阻塞线程切换成本极高。所以JDK后续版本不断优化才演进出无锁、偏向锁、轻量级锁、重量级锁这条升级路径。面试里常见的问法就是“synchronized从无锁到重量级锁分别对应什么场景”。3.2 四态演进与对象头锁的状态存储在对象头里的Mark Word中。无锁状态时对象头记录的是对象哈希码等内容一旦开始有竞争Mark Word里的字段会根据状态切换。整个过程我在复习时画过一遍文字描述大概是这样的第一态是无锁不管竞争普通对象进入时就是这个状态。第二态是偏向锁JDK认为大部分锁不存在多线程竞争可能同一个线程反复进出同步块所以让第一个获取锁的线程把线程ID记录到Mark Word里下次再进入时直接校验线程ID相同就不再做同步操作这是“偏向”的含义。第三态是轻量级锁当第二个线程来竞争时偏向锁会撤销而新线程会尝试用CAS把对象头中的锁记录替换成自己线程栈中的锁记录如果成功就拿到锁多个线程交替执行、没有真正同时竞争时轻量级锁够用。第四态是重量级锁一旦多个线程真正同时竞争、CAS自旋失败锁就膨胀为重量级锁后续竞争的线程会被挂起进入内核态等待唤醒。这里容易忽略一个细节偏向锁也不是永远偏向。如果对象计算过identity hashcode就无法进入偏向状态如果多个线程交替竞争但始终没有同时抢轻量级锁可能反复获取释放系统会有一个阈值决定是否升级。另外从JDK 15开始偏向锁默认被废弃JDK 17也默认关闭。复习时如果只按JDK 8的标准答案回答遇到面试官追问“最新版本有什么变化”就容易露馅。3.3 锁消除与锁粗化除了锁升级JVM还做了两个编译期优化。锁消除指的是JIT分析发现某个锁对象只被当前线程访问根本不存在竞争就直接把锁去掉。锁粗化是指把相邻的多次加锁解锁合并成一次减少锁操作开销。典型反面例子是把StringBuffer的append调用放在一个很紧的循环里每次都新建局部StringBufferJVM如果分析出锁不会跨线程共享会做锁消除但如果不消除锁粗化也会减少重复进入同步块的次数。// 错误示范循环内频繁创建局部锁对象 for (int i 0; i list.size(); i) { StringBuffer sb new StringBuffer(); sb.append(list.get(i)); } // 正确思路把临界区操作合理合并避免频繁同步面试里遇到锁优化的问题能答出锁消除和锁粗化说明你对“锁的代价”有概念而不是只会背四态升级。3.4 synchronized回答要点总结面试官问synchronized时我会建议按三条线组织答案第一说清它的作用保证原子性、可见性和有序性第二讲清楚锁升级的完整路径以及每态适用场景第三补一句锁优化手段。这样一套组合拳下来覆盖了从原理到JVM优化的多个层次基本能应对后续追问。别一上来只说“synchronized是重量级锁”那已经是过时认知而且会让面试官觉得你停留在背题阶段。4. CAS、AQS与ReentrantLock并发工具的地基4.1 CAS原理与ABA问题CASCompare And Swap是一条原子指令语义是如果内存中的当前值等于预期值就把它更新为新值否则什么都不做。整个过程不可被打断比单纯用synchronized做复合操作更轻量。像AtomicInteger的incrementAndGet底层就是循环CAS不断读取当前值计算加一后的值再尝试CAS失败就重试。CAS有一个广为人知的隐患叫ABA问题线程A读到值是1线程B把1改成2又改回1线程A再执行CAS时发现值还是1就认为没有动过实际中间经历了变化。解决思路是引入版本号AtomicStampedReference就是这么做的比较时不仅比较值还比较戳记。还有一点值得注意CAS自旋在竞争激烈时会导致CPU空转。所以Java里又出现LongAdder它把热点value拆成多个cell每个线程散列到不同cell上操作求和时把所有cell相加。这个思路在面试里经常作为“CAS自旋开销怎么缓解”的延伸答案。4.2 AQS的设计思想AQSAbstractQueuedSynchronizer是Java并发包的灵魂。它的核心是两个东西一个volatile int state表示同步状态一个双向等待队列存放竞争失败的线程。ReentrantLock、Semaphore、CountDownLatch都建立在它之上。面试里如果被问到“AQS怎么实现”基本就说这三件事state的语义由子类定义加锁就是通过CAS把state从0改成1获取失败就生成节点入队然后调用LockSupport.park挂起线程释放锁时唤醒队首节点。为什么state设计成int而不是boolean因为要支持可重入。ReentrantLock同一个线程可以多次加锁每加一次state就自增释放时自减减到0才算真正释放。这个设计非常巧妙用一个int同时完成了“是否占用”和“重入次数”两个功能。我复习时用了一个类比state像餐厅领的等位号0代表没人占座大于0代表坐了人数值大小代表同一拨人占了几张相邻的凳子。4.3 公平锁与非公平锁的差别ReentrantLock有两种模式差别在AQS的lock入口处。非公平锁在新线程来竞争时会先尝试直接CAS修改state成功就直接插队拿锁公平锁则不允许插队先检查队列里有没有前驱节点有就老老实实入队。非公平锁的性能通常更好因为减少了线程唤醒的上下文切换但它可能导致队首线程饥饿等待。我在实际学习时写过一个对比代码来加深记忆同样五千次循环两个线程各拿一把锁做累加公平锁的耗时明显更高。原因就是公平锁频繁唤醒队首线程而非公平锁允许后来的线程在锁刚好释放时立即抢到。面试官问“为什么非公平锁性能更好”能说出“减少上下文切换和线程唤醒”这一点就算答到点子上了。4.4 ReentrantLock使用中的典型坑ReentrantLock要求手动加锁和释放最容易踩的坑就是忘记在finally里unlock。一旦某个分支抛异常提前返回锁就永远不释放最终导致其他线程全部阻塞。另外lockInterruptibly可以在等待锁时响应中断而lock()不响应中断这个区别在面试里有时会被问面试者容易搞混。我的经验是凡是手动锁场景第一件事就是把unlock放进finally形成肌肉记忆然后再考虑业务逻辑。ReentrantLock lock new ReentrantLock(); lock.lock(); try { // 业务逻辑 } finally { lock.unlock(); }5. 线程池面试必考也是线上事故高发地5.1 线程池解决的本质问题线程池解决的三个核心问题减少线程创建和销毁的频繁开销、控制并发数量避免资源耗尽、提供统一的任务调度和生命周期管理。池化思想在很多地方都有体现连接池、对象池都是同一个道理——复用昂贵资源。面试从“为什么用线程池”切入基本都会延伸到三大块参数含义、执行流程、拒绝策略。这三块必须能对着源码说出来至少要能画出一条完整的execute流程。5.2 七大参数与执行流程图ThreadPoolExecutor构造器里有七个参数corePoolSize核心线程数、maximumPoolSize最大线程数、keepAliveTime非核心线程存活时间、unit时间单位、workQueue任务队列、threadFactory线程工厂、handler拒绝策略。执行流程是八股中的八股提交任务时先判断当前线程数是否小于核心线程数小于就新建核心线程执行否则把任务放入工作队列等待核心线程处理如果队列满了再判断线程数是否小于最大线程数小于就新建非核心线程执行如果最大线程数也满了就走拒绝策略。这个顺序面试里经常会拆开追问尤其爱问“任务什么时候进入队列、什么时候才创建非核心线程”。这里有个容易答错的一等细节核心线程在默认情况下不会被回收除非设置了allowCoreThreadTimeOut(true)非核心线程在执行完任务后超过keepAliveTime才会被回收。5.3 四种拒绝策略怎么选JDK内置了四种拒绝策略对比如下策略行为适用场景AbortPolicy直接抛出RejectedExecutionException默认策略适合明确要发现问题的场景CallerRunsPolicy由提交任务的线程自己执行任务慢速降级适合不想丢任务的场景DiscardPolicy直接丢弃新任务适合允许丢任务的场景DiscardOldestPolicy丢弃队列中最旧的任务再提交新任务适合消息类场景任务过期无意义我最推荐面试里答CallerRunsPolicy因为它的思想是“让提交者自食其力”既不会粗暴丢任务又通过反压自然降低了提交速度听起来比直接丢任务更有工程味道。5.4 线程数怎么估算线程数配置是典型的场景题。CPU密集型任务一般设置为核心线程数CPU核数1因为CPU密集基本不会等待多一个线程是为了弥补偶尔等待导致的中断停顿。IO密集型任务因为有大量阻塞等待可以设置更多线程常见公式是CPU核数乘以2或者更精细一点用“CPU核数 / (1 - 阻塞系数)”阻塞系数在0.8到0.9之间。int core Runtime.getRuntime().availableProcessors(); int max core * 2; ThreadPoolExecutor pool new ThreadPoolExecutor( core, max, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue(1000), new ThreadFactoryBuilder().setNameFormat(biz-pool-%d).build(), new CallerRunsPolicy() );注意队列容量不能无脑设很大。无界队列是很多线上OOM的元凶因为任务不断堆积内存不断增长而线程数永远到不了最大线程数监控还看不出线程压力。这也是为什么面试里问到Executors正确答案往往是“不建议使用”因为newFixedThreadPool和newSingleThreadExecutor用的是无界队列而newCachedThreadPool最大线程数是Integer.MAX_VALUE都可能出问题。要回答得漂亮最好是说“我一般用ThreadPoolExecutor手动构造根据业务场景定制队列和拒绝策略”。5.5 线程池关闭和异常处理优雅关闭线程池用shutdown它只拒绝新任务等待已提交任务跑完shutdownNow会中断正在执行的任务并且返回还没执行的任务列表。线上经验是尽量用shutdown给任务留一个软着陆的过程。另外如果execute提交的任务内部抛了RuntimeException该线程会被销毁然后创建一个新线程顶替。这个机制容易被忽视但它解释了为什么线程池里跑的任务要自己做异常捕获否则线程会被频繁替换池子也就失去了稳定性意义。6. 实战排查用jstack揪出死锁和CPU飙高6.1 一个典型死锁演示我在实验室模拟过一个典型死锁线程A持有锁1等待锁2线程B持有锁2等待锁1两个线程互相等待谁都不让。写这段代码很简单关键是观察线程状态和堆栈。Object lockA new Object(); Object lockB new Object(); new Thread(() - { synchronized (lockA) { try { Thread.sleep(100); } catch (InterruptedException e) {} synchronized (lockB) { System.out.println(thread1 got lockB); } } }).start(); new Thread(() - { synchronized (lockB) { try { Thread.sleep(100); } catch (InterruptedException e) {} synchronized (lockA) { System.out.println(thread2 got lockA); } } }).start();启动后用jstack查看线程情况抓重点看最后一段。如果出现类似“Found one Java-level deadlock”的字样并且把两个线程持有的锁一五一十列出来那基本就实锤了。我当初第一次看到这个输出时觉得特别有成就感因为这比单纯背“死锁四个必要条件”来得扎实。6.2 死锁修复思路死锁四个必要条件面试常问但工程上的修复思路要能落地要么保证所有线程按固定的全局顺序拿锁要么用tryLock带超时替代无限等锁。固定顺序是消灭循环等待tryLock是打破不可剥夺。我在模拟场景里用tryLock(2, TimeUnit.SECONDS)修复后线程等待超时会主动放弃然后走降级逻辑链路就通了。6.3 CPU飙高定位流程线上CPU飙高排查流程基本是固定的三步先用top找到CPU占用最高的进程PID再用top -Hp PID找到进程内最耗CPU的线程ID把线程ID转成十六进制用jstack PID查这个十六进制的栈定位到具体业务代码。top -Hp 12345 printf %x 12347 jstack 12345 | grep -A 20 0x304b这个流程我实测过多次唯一容易卡住的地方是grep过滤的栈信息拼接不完整。正确的做法是多抓几次jstack对比线程状态是不是一直卡在同一个方法如果每次都指向同一个私有方法多半是这里自旋或者有死循环如果是GC线程占满CPU就要接着排查堆内存和GC配置。我在某次模拟演练里碰到过正则表达式回溯导致CPU飙高jstack一抓全是那个正则匹配方法顺着栈找到代码后改成非贪婪模式就解决了。6.4 线上并发排查的个人经验排查并发问题最忌讳的是凭空猜。我总结的靠谱顺序是先用命令把现场信息保存完整再做代码分析。像是jstack、jstat、jmap这些命令最好提前在测试环境练熟别等线上出了问题才翻命令手册。另外线上并发问题往往不是某个类用错了而是锁粒度太大或线程池配置不合理。我认识一个做电商服务的团队某次接口变慢最后定位到是新同事给一个只读缓存操作加了synchronized直接让所有请求串行化。这种问题工具只能定位现象根因还得靠代码审查经验。7. Day14复盘易错点速查与冲刺建议7.1 高频易错点对照表我把今天复习中容易踩的坑整理成了一张表方便睡前快速过一遍考察点常见错误答案正确理解volatile能否保证原子性能不能read-modify-write仍是复合操作synchronized是不是公平锁是非公平不保证等待最久的线程优先锁升级到重量级后还能降级吗不能状态只能升不能降核心线程会被回收吗会默认不会除非设置allowCoreThreadTimeOut队列满了先创建新线程还是先进队列先进队列队列在创建非核心线程之前ReentrantLock和synchronized选谁直接说哪个好看场景ReentrantLock更灵活synchronized更简单这六条是我在模拟面试里反复听到的错误答案也是面试官最爱挖的坑。尤其第一条很多人在volatile上栽跟头因为背了“可见性、有序性”就默认以为原子性也顺带了。7.2 自测清单复习完当天内容我习惯用几个问题做自测如果答不上来就说明还要再看一遍能否不查资料画出锁升级路径并说明每态触发条件能否解释AQS的state为什么是int而不是boolean能否说出ThreadPoolExecutor的完整任务流转能否用jstack定位一个死锁能否解释volatile在双重检查单例中的作用。这五个问题覆盖了今天的全部核心内容能流畅答出基本可以放心进入下一阶段。7.3 下一步学习衔接Day14之后并发部分其实还没到终点至少还有并发容器、ThreadLocal、锁工具类这些尾巴。但最核心的底层机制今天已经打通。接下来可以根据你的薄弱方向分配如果项目里大量用到缓存建议优先深挖ThreadLocal和内存泄漏场景如果面试公司偏基础架构建议把AQS再往深看一层尝试看ReentrantLock源码把acquire流程里每个条件分支都读明白。我个人不推荐把并发战线拉得太长两到三天内把今天这张网巩固住再接数据库和Redis性价比更高。我在复习并发这块时最强烈的感受是光背结论没有用一定要把场景挂上去。比如复习锁升级顺手把对象头的Mark Word画一遍复习线程池就想着自己的项目里队列和拒绝策略是怎么配的。八股是敲门砖但面试官最后认可的永远是那个能把原理落到场景里的你。