1. 并发基础面试题的底层逻辑与知识框架1.1 为什么面试官总爱揪着并发基础不放干了这么多年技术面试我发现一个规律但凡涉及到后端岗位不管你是初级还是高级并发基础这块几乎是必问的。很多人觉得这些东西平时写业务代码根本用不上背了也是白背。但面试官不这么想他们通过这些问题来判断你对计算机底层运行机制的理解深度。我举个很简单的例子。面试官问你“volatile关键字的作用是什么”你回答“保证可见性不保证原子性”这只能算及格。但如果面试官接着问“为什么它能保证可见性”你要是答不上来那前面那个答案就显得像是背的。再往下追问“既然它不保证原子性那什么场景下用它才安全”这时候能答清楚的人就少了一大半。这就是并发基础面试题的特点入门门槛低但深挖无底洞。面试官可以通过层层递进的问题快速判断出你是真的理解还是临时抱佛脚。而且并发问题在实际生产环境中造成的bug往往是最难排查的一个偶发的死锁或者数据不一致可能让整个团队折腾好几天。所以面试官重视这块是有充分理由的。1.2 并发知识体系的四个核心模块根据我这些年面试别人和被面试的经验并发基础面试题基本围绕四个模块展开。把这四个模块吃透百分之八十的面试题都能从容应对。第一个模块是线程基础。包括线程的创建方式、生命周期、状态转换、线程间的通信方式等。这部分是地基面试官通常会用它来热身但偶尔也会在里面埋一些陷阱比如问“调用run()和start()有什么区别”或者“线程的六种状态是怎么转换的”。第二个模块是锁机制。从synchronized到ReentrantLock从乐观锁到悲观锁从公平锁到非公平锁从偏向锁到轻量级锁再到重量级锁。这块内容最多也最容易出深度问题。面试官往往会从“synchronized和Lock有什么区别”开始一路问到锁升级的过程、AQS的实现原理。第三个模块是并发工具类。包括CountDownLatch、CyclicBarrier、Semaphore、Exchanger这些同步器以及ConcurrentHashMap、CopyOnWriteArrayList、BlockingQueue这些并发容器。这部分考察的是你对现成工具的理解和使用经验。第四个模块是内存模型与底层原理。包括JMM、happens-before规则、内存屏障、指令重排序、CAS操作等。这部分最抽象但也是区分普通开发者和高级开发者的分水岭。1.3 面试官出题的常见套路了解面试官的出题套路能让你在准备时事半功倍。我总结了几种常见的出题方式。第一种是概念对比型。比如“synchronized和volatile有什么区别”、“sleep和wait有什么区别”、“Lock和synchronized有什么区别”。这种题考察的是你对基础概念的清晰认知回答时最好用表格的方式在脑子里过一遍确保每个维度都覆盖到。第二种是场景分析型。比如“两个线程交替打印1到100怎么实现”、“生产者消费者模型怎么用阻塞队列实现”、“多线程统计一个大文件的行数怎么做”。这种题考察的是你解决实际问题的能力需要你给出完整的思路和关键代码。第三种是原理深挖型。比如“synchronized锁升级的过程是怎样的”、“AQS的独占模式和共享模式有什么区别”、“CAS的ABA问题怎么解决”。这种题考察的是你对底层实现的理解需要你能画出内存结构或者说出关键源码的逻辑。第四种是陷阱型。比如“i是原子操作吗”、“StringBuilder和StringBuffer哪个更快”、“多线程一定比单线程快吗”。这种题往往有一个看似显然但实际错误的答案考察的是你是否真正理解并发编程中的坑。2. 线程基础高频面试题深度拆解2.1 线程创建方式的真实选择逻辑面试题里经常问“创建线程有哪几种方式”标准答案通常是三种继承Thread类、实现Runnable接口、实现Callable接口配合FutureTask。但如果你只答这三种面试官可能会追问“实际开发中你用哪种为什么”。我的经验是实际项目中几乎不会直接继承Thread类。原因很简单Java是单继承的你继承了Thread就不能继承别的类了扩展性太差。实现Runnable接口更灵活而且可以把任务和线程的创建解耦。至于Callable它和Runnable的区别在于可以有返回值并且能抛出异常适合需要获取异步执行结果的场景。但真正在生产环境里我们基本都是用线程池来管理线程的。所以如果面试官问到“你们项目里怎么创建线程”你可以直接说用ThreadPoolExecutor然后顺势把话题引到线程池的参数配置上这样就能掌握面试的主动权。这里有一个容易忽略的点线程的创建和启动是两回事。new Thread()只是在堆里创建了一个对象调用start()才是真正向操作系统申请创建一个内核线程。start()方法内部会调用native的start0()方法由JVM向操作系统发起创建线程的请求。而直接调用run()方法就只是在当前线程里执行了一个普通方法而已。2.2 线程状态转换的完整路径线程状态这块很多人背了六种状态但说不清楚转换条件。我用一个实际场景来串一下。假设你写了一个线程池任务提交任务后线程池会创建一个工作线程。这个线程刚创建时处于NEW状态。当你调用start()方法后线程进入RUNNABLE状态等待操作系统调度分配CPU时间片。注意Java里的RUNNABLE包含了操作系统层面的就绪和运行两种状态因为JVM不区分这两种。当线程执行到synchronized代码块但没抢到锁时进入BLOCKED状态。当线程调用了wait()、join()或者park()方法后进入WAITING状态需要其他线程显式唤醒。当线程调用了sleep(1000)、wait(1000)或者join(1000)后进入TIMED_WAITING状态时间到了自动唤醒。当run()方法执行完毕或者抛出异常后线程进入TERMINATED状态。这里有一个面试常问的细节BLOCKED和WAITING有什么区别。简单说BLOCKED是在等锁WAITING是在等人叫醒。BLOCKED状态的线程只要拿到锁就会自动进入RUNNABLE而WAITING状态的线程必须被notify()或者notifyAll()唤醒否则会一直等下去。还有一个容易混淆的点sleep和wait的区别。sleep是Thread类的静态方法可以在任何地方调用它不释放锁wait是Object类的方法必须在synchronized块里调用它会释放锁。这个区别在面试中出现的频率极高一定要记牢。2.3 线程通信的三种实战方式线程之间怎么通信这也是面试高频题。我总结下来主要有三种方式。第一种是共享内存。多个线程访问同一个volatile变量或者用synchronized保护的变量。这种方式最简单但需要处理好可见性和原子性问题。比如一个生产者线程往队列里放数据一个消费者线程从队列里取数据中间用一个volatile的boolean变量来控制停止标志。第二种是wait/notify机制。这是Java最经典的线程通信方式。一个线程调用wait()进入等待状态并释放锁另一个线程调用notify()或notifyAll()唤醒它。使用这个机制有几个坑wait()必须放在循环里判断条件防止虚假唤醒notify()可能唤醒错误的线程通常用notifyAll()更安全调用wait()前必须持有对象的锁。第三种是阻塞队列。这是实际项目中最推荐的方式。BlockingQueue接口提供了put()和take()方法当队列满时put()会阻塞当队列空时take()会阻塞。这样生产者和消费者就不需要自己处理等待和唤醒了代码简洁很多。常用的实现有ArrayBlockingQueue、LinkedBlockingQueue、SynchronousQueue等。我个人的经验是能用阻塞队列就不要自己写wait/notify。自己写很容易出bug比如忘记在循环里判断条件或者notify()唤醒了不该唤醒的线程。阻塞队列是经过充分测试的稳定性和性能都有保障。3. 锁机制面试题的核心考点与回答策略3.1 synchronized的锁升级过程详解synchronized的锁升级是面试中的重灾区也是区分度最高的题目之一。很多人只知道有偏向锁、轻量级锁、重量级锁但说不清楚升级的条件和过程。先说一下对象头。每个Java对象在内存中都有一个对象头其中Mark Word字段记录了锁的状态。在64位JVM上Mark Word占8个字节不同锁状态下它的结构是不一样的。偏向锁是锁升级的起点。当一个线程第一次访问同步块时JVM会在Mark Word里记录这个线程的ID并把锁标志位设为01。下次同一个线程再进入时只需要检查Mark Word里的线程ID是不是自己是的话就直接进入不需要任何CAS操作。这就是偏向锁的意义在只有一个线程访问的情况下把同步操作的代价降到最低。轻量级锁在出现第二个线程竞争时触发。偏向锁会被撤销Mark Word里的线程ID被清除锁标志位变成00。每个线程在自己的栈帧里创建一个Lock Record然后用CAS操作尝试把Mark Word指向自己的Lock Record。成功的线程获得锁失败的线程会自旋等待。重量级锁在自旋超过一定次数或者有第三个线程加入竞争时触发。锁标志位变成10Mark Word指向一个Monitor对象。没抢到锁的线程会被挂起进入操作系统的等待队列。这时候就涉及到用户态到内核态的切换了开销比较大。这里有一个重要的面试点锁升级是不可逆的。一旦升级到重量级锁就不会再降回轻量级锁或偏向锁。所以如果你的同步块里执行的操作很耗时或者竞争很激烈偏向锁和轻量级锁反而会成为额外的开销。这也是为什么JDK 15之后默认禁用了偏向锁因为在实际应用中它的收益越来越小。3.2 ReentrantLock与AQS的底层关联ReentrantLock是面试中另一个高频考点。面试官通常会从“ReentrantLock和synchronized有什么区别”开始然后逐步深入到AQS的实现原理。先回答区别。ReentrantLock比synchronized多了几个能力可以尝试非阻塞获取锁tryLock、可以设置超时时间、可以实现公平锁、可以中断等待锁的线程。但ReentrantLock需要手动释放锁必须放在finally块里否则一旦异常就会导致锁泄漏。再深入AQS。AQS全称AbstractQueuedSynchronizer是ReentrantLock、CountDownLatch、Semaphore等同步器的底层框架。它的核心是一个volatile的int变量state和一个FIFO的双向队列。以ReentrantLock为例state为0表示锁空闲state大于0表示锁被占用数值代表重入次数。线程获取锁时会用CAS操作把state从0改成1。如果失败就把自己包装成一个Node节点加入等待队列然后调用LockSupport.park()挂起自己。当持有锁的线程释放锁时会把state减到0然后唤醒队列头部的节点。AQS有两种模式独占模式和共享模式。ReentrantLock用的是独占模式同一时刻只有一个线程能持有锁。CountDownLatch和Semaphore用的是共享模式多个线程可以同时获取同步状态。面试中经常问的一个问题是AQS的等待队列是怎么实现的。简单说就是一个双向链表每个节点包含前驱指针、后继指针、等待状态和线程引用。新来的节点通过CAS操作加入队尾然后检查前驱节点的状态如果前驱是头节点就尝试获取锁否则就挂起自己。3.3 volatile关键字的正确理解方式volatile是面试中最容易被低估的知识点。很多人觉得它简单但真正理解它的人并不多。volatile有两个作用保证可见性和禁止指令重排序。可见性是指一个线程修改了volatile变量后其他线程能立即看到这个修改。这是通过内存屏障实现的写操作会在前面插入StoreStore屏障在后面插入StoreLoad屏障读操作会在前面插入LoadLoad屏障在后面插入LoadStore屏障。禁止重排序是指编译器和CPU不会对volatile变量的读写操作进行重排。这个特性在单例模式的双重检查锁里非常关键。如果没有volatileinstance new Singleton()这行代码可能会被重排成先分配内存、再赋值引用、最后初始化对象。这样另一个线程可能拿到一个还没初始化完成的对象。但volatile不保证原子性。i这种操作包含读、改、写三个步骤volatile只能保证读到的值是最新的但多个线程同时读、改、写还是会出现覆盖。要保证原子性需要用synchronized或者AtomicInteger。面试中经常问的一个场景是用volatile实现一个开关。比如一个线程在跑循环另一个线程想让它停下来。这时候用一个volatile的boolean变量就足够了因为只需要保证可见性不需要原子性。但如果你要用volatile做计数器那就错了必须用AtomicInteger或者加锁。4. 并发工具类与容器的实战应用4.1 四大同步器的使用场景对比CountDownLatch、CyclicBarrier、Semaphore、Exchanger这四个同步器面试官经常让做对比。我用实际场景来说明它们各自的用途。CountDownLatch适合“一个线程等待多个线程完成”的场景。比如主线程要等所有子任务执行完再汇总结果就可以创建一个CountDownLatch初始值为子任务数量。每个子任务完成后调用countDown()主线程调用await()等待计数归零。注意CountDownLatch是一次性的计数归零后就不能再用了。CyclicBarrier适合“多个线程互相等待”的场景。比如多个线程分别计算一个矩阵的不同部分算完后需要等所有线程都算完才能进行下一步。CyclicBarrier可以循环使用计数归零后会自动重置。它还支持一个Runnable参数在所有线程到达屏障后执行。Semaphore适合“控制同时访问资源的线程数量”的场景。比如数据库连接池限制最多10个连接就可以用Semaphore(10)。每个线程获取连接前调用acquire()释放连接后调用release()。Semaphore还支持公平和非公平模式。Exchanger适合“两个线程交换数据”的场景。比如一个线程负责生产数据一个线程负责消费数据两者可以在某个点交换缓冲区。这个类在实际项目中用得比较少但面试中偶尔会问到。我个人的经验是CountDownLatch和Semaphore用得最多CyclicBarrier偶尔用Exchanger基本没用过。面试时如果被问到可以重点讲前两个后两个知道基本用法就行。4.2 ConcurrentHashMap的线程安全实现ConcurrentHashMap是面试中必问的并发容器。JDK 1.7和1.8的实现差别很大面试官通常会问“JDK 1.8做了什么优化”。JDK 1.7用的是分段锁把整个Map分成16个Segment每个Segment独立加锁。这样多个线程访问不同Segment时不会互相阻塞。但分段锁的缺点是内存开销大而且并发度固定为16不能动态调整。JDK 1.8改成了CAS synchronized。put操作时如果数组对应位置为空就用CAS直接插入如果不为空就用synchronized锁住头节点然后遍历链表或红黑树插入。这样锁的粒度更细只锁一个桶而不是一个段。同时引入了红黑树当链表长度超过8且数组长度超过64时链表会转成红黑树查询效率从O(n)提升到O(logn)。面试中经常问的一个问题是ConcurrentHashMap的size()方法是怎么实现的。JDK 1.8用了CounterCell数组来分散计数类似LongAdder的思路。每个线程更新自己对应的CounterCell最后把所有CounterCell的值加起来。这样避免了多线程竞争同一个计数器。还有一个常见问题是ConcurrentHashMap为什么不允许null键和null值。这是因为在并发环境下如果get()返回null你无法判断是键不存在还是值就是null。而HashMap允许null是因为单线程环境下可以用containsKey()来判断。4.3 阻塞队列的选型与避坑阻塞队列在实际项目中用得非常多但选型不当也会踩坑。我整理了一个对比表格方便大家参考。队列类型底层结构有界性锁机制适用场景ArrayBlockingQueue数组有界单锁固定容量、对内存敏感LinkedBlockingQueue链表可选有界双锁高吞吐、生产者消费者分离SynchronousQueue无无容量CAS直接传递、线程池PriorityBlockingQueue堆无界单锁按优先级处理任务DelayQueue堆无界单锁延迟任务、定时任务这里重点说一下LinkedBlockingQueue的坑。它默认是无界的最大容量是Integer.MAX_VALUE。如果你用Executors.newFixedThreadPool()创建线程池它用的就是无界的LinkedBlockingQueue。当任务提交速度超过处理速度时队列会无限增长最终导致OOM。所以阿里开发手册里明确禁止使用Executors创建线程池必须手动指定队列容量。SynchronousQueue也很特殊它不存储元素每个put操作必须等待一个take操作。Executors.newCachedThreadPool()用的就是它。这个队列适合任务处理速度很快、不需要排队的场景。DelayQueue适合实现定时任务。元素必须实现Delayed接口getDelay()方法返回剩余延迟时间。只有延迟时间小于等于0的元素才能被take出来。我之前用DelayQueue实现过一个订单超时取消的功能比用定时任务框架轻量很多。5. 内存模型与底层原理的面试应对5.1 JMM与happens-before规则JMM是Java内存模型的缩写它定义了一套规则来规范多线程环境下变量的访问方式。JMM的核心目标是解决可见性、原子性和有序性问题。JMM把内存分为主内存和工作内存。主内存是所有线程共享的工作内存是每个线程私有的。线程对变量的操作必须在工作内存中进行不能直接操作主内存。这就导致了可见性问题一个线程修改了变量另一个线程可能看不到。happens-before是JMM的核心规则它定义了哪些操作之间必须保证可见性。我列几条最常用的程序顺序规则一个线程内前面的操作happens-before后面的操作。监视器锁规则解锁操作happens-before后续的加锁操作。volatile变量规则对volatile变量的写操作happens-before后续的读操作。线程启动规则Thread.start() happens-before线程内的所有操作。线程终止规则线程内的所有操作happens-before其他线程检测到该线程终止。面试中经常问的一个问题是happens-before和as-if-serial有什么区别。as-if-serial是说不管怎么重排序单线程的执行结果不能改变。happens-before是多线程环境下的可见性规则。两者一个针对单线程一个针对多线程。5.2 CAS操作的原理与ABA问题CAS全称Compare And Swap是一种无锁的原子操作。它包含三个值内存地址V、预期值A、新值B。只有当V的当前值等于A时才把V更新为B否则什么都不做。整个操作是一个原子操作由CPU的cmpxchg指令保证。CAS在Java中通过Unsafe类的compareAndSwapInt()等方法实现。AtomicInteger、AtomicLong、AtomicReference等原子类都是基于CAS的。但CAS有一个经典问题ABA问题。假设线程1把变量从A改成B又改回A。线程2用CAS检查时发现值还是A就以为没变过实际上已经变过了。在大多数场景下这不是问题但在一些对状态敏感的场景下会导致错误。解决ABA问题的方法是加一个版本号。每次修改时版本号加1CAS时同时检查值和版本号。Java提供了AtomicStampedReference来实现这个思路。不过说实话实际项目中遇到ABA问题的概率极低除非你在做无锁队列或者无锁栈这种复杂数据结构。CAS还有一个问题是自旋开销。如果CAS一直失败线程会一直自旋浪费CPU。所以CAS适合竞争不激烈的场景。竞争激烈时还是用锁更合适。5.3 指令重排序与内存屏障指令重排序是编译器和CPU为了优化性能而做的一种优化。在不改变单线程执行结果的前提下编译器和CPU可以调整指令的执行顺序。重排序有三种类型编译器重排序、CPU指令重排序、内存系统重排序。编译器重排序是在编译期完成的CPU重排序是在运行期完成的内存重排序是由于缓存和写缓冲区导致的。内存屏障是禁止重排序的手段。JMM定义了四种屏障LoadLoad屏障禁止读操作和读操作重排序。StoreStore屏障禁止写操作和写操作重排序。LoadStore屏障禁止读操作和写操作重排序。StoreLoad屏障禁止写操作和读操作重排序开销最大。volatile的写操作会在前面插入StoreStore屏障在后面插入StoreLoad屏障。volatile的读操作会在前面插入LoadLoad屏障在后面插入LoadStore屏障。这样就能保证volatile变量的读写不会被重排序到屏障的另一边。面试中经常问的一个问题是双重检查锁为什么要加volatile。就是因为instance new Singleton()这行代码可能被重排序导致另一个线程拿到一个半初始化的对象。加volatile后写操作后面的StoreLoad屏障会禁止这个重排序。6. 常见问题排查与面试避坑指南6.1 并发面试中的高频陷阱题面试中有一些题目看似简单但陷阱很多。我整理了几个最典型的。第一个陷阱i是原子操作吗。答案是不是。i包含读、改、写三个步骤多线程环境下会出现覆盖。要保证原子性需要用AtomicInteger或者synchronized。第二个陷阱StringBuilder和StringBuffer哪个更快。单线程下StringBuilder更快因为StringBuffer的方法都加了synchronized。但多线程下StringBuffer更安全。如果面试官问“哪个更快”你要先问清楚是单线程还是多线程场景。第三个陷阱多线程一定比单线程快吗。不一定。线程的创建和切换有开销如果任务本身很小多线程反而更慢。另外如果任务需要频繁加锁多线程的竞争开销也会抵消并行带来的收益。第四个陷阱volatile能保证原子性吗。不能。volatile只能保证可见性和有序性不能保证原子性。i这种复合操作必须用锁或者原子类。第五个陷阱synchronized锁的是什么。修饰实例方法锁的是当前对象修饰静态方法锁的是Class对象修饰代码块锁的是括号里的对象。面试官经常用这个来考察你是否真的理解synchronized。6.2 死锁的排查与预防死锁是并发编程中最常见的问题之一。面试官经常问“死锁的四个必要条件是什么”以及“怎么排查死锁”。死锁的四个必要条件是互斥、持有并等待、不可剥夺、循环等待。只要破坏其中任意一个死锁就不会发生。排查死锁最常用的工具是jstack。先用jps找到Java进程的PID然后执行jstack PID如果存在死锁输出末尾会有一段“Found one Java-level deadlock”的信息里面会详细列出哪个线程持有了哪个锁、在等待哪个锁。预防死锁的方法有几种按顺序加锁所有线程都按同一个顺序获取锁设置超时用tryLock(timeout)代替lock()减少锁的粒度尽量用更细粒度的锁避免嵌套锁尽量不要在一个锁里面去获取另一个锁。我个人的经验是实际项目中死锁往往出现在多个锁的嵌套获取上。比如一个方法先锁A再锁B另一个方法先锁B再锁A两个线程同时执行就可能死锁。解决方法是统一加锁顺序或者用一个大锁代替多个小锁。6.3 面试回答的节奏与技巧最后说一下面试回答的技巧。并发基础面试题往往是一环扣一环的面试官会根据你的回答决定下一个问题问什么。所以回答时要注意节奏。第一先给结论再解释。面试官问“volatile有什么作用”你先说“保证可见性和有序性不保证原子性”然后再展开解释。这样面试官能快速抓住你的核心观点。第二主动引导话题。如果你对某个知识点特别熟可以在回答时稍微提一下相关的知识点引导面试官往你擅长的方向问。比如回答完volatile后你可以补一句“它和synchronized的区别主要在于...”这样面试官可能就会顺着问synchronized。第三不会就说不会但要说思路。如果面试官问到一个你完全没听过的知识点不要瞎编。可以说“这个我了解得不多但根据我的理解可能是...”。面试官更看重你的思考过程而不是标准答案。第四用实际项目举例。如果面试官问“你在项目中怎么用并发工具的”你可以讲一个具体的场景比如用CountDownLatch做多线程数据汇总用Semaphore做接口限流。这样比干巴巴地背概念有说服力得多。第五注意控制时间。每个问题回答两三分钟就够了不要滔滔不绝。如果面试官对某个点感兴趣他会继续追问。如果你一直说反而可能暴露更多问题。我在实际面试中遇到过不少候选人基础概念背得很熟但一问到实际场景就卡壳。所以准备面试时不要只背八股文要多想想这些知识点在实际项目中怎么用。这样回答起来才有底气也更容易获得面试官的认可。