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。成功的线程获得锁,失败的线程会自旋等待。
重量级锁在自旋超过一定次数或者有第三个线程加入竞争时触发。锁标志位变成10,Mark 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变量的读写操作进行重排。这个特性在单例模式的双重检查锁里非常关键。如果没有volatile,instance = 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问题的方法是加一个版本号。每次修改时版本号加1,CAS时同时检查值和版本号。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做接口限流。这样比干巴巴地背概念有说服力得多。
第五,注意控制时间。每个问题回答两三分钟就够了,不要滔滔不绝。如果面试官对某个点感兴趣,他会继续追问。如果你一直说,反而可能暴露更多问题。
我在实际面试中遇到过不少候选人,基础概念背得很熟,但一问到实际场景就卡壳。所以准备面试时,不要只背八股文,要多想想这些知识点在实际项目中怎么用。这样回答起来才有底气,也更容易获得面试官的认可。