这类主题最值得先看的不是概念列表,而是面试官到底在问什么。高并发和多线程的面试,核心不是让你背出所有名词,而是考察你能否把零散的知识点,串联成一个能解决实际问题的、有逻辑的技术判断体系。很多人准备了大量八股文,但一被问到“如果让你设计一个抢票系统,线程池参数怎么设?”,或者“线上服务线程数暴涨,从哪开始查?”,就卡住了。
这篇文章会从一个面试官和一线开发者的双重角度,帮你把“高并发多线程”这个庞大的话题,拆解成几个可以层层递进、现场组织答案的模块。我会假设你已经有了一些基础概念,比如知道线程是什么、锁是什么,但面对综合性的场景题和深度追问时,感觉知识点是散的。我们的目标是把这些散点,变成你面试时可以自如调用的“工具箱”。
1. 面试官到底在考什么?从“知识点背诵”到“问题解决能力”
很多人一看到“高并发多线程面试题”,就去背“进程和线程的区别”、“synchronized和ReentrantLock的区别”、“volatile关键字的作用”。这些是必要的砖块,但面试官想看的,是你用这些砖块盖房子的能力。
1.1 三个核心考察维度
面试官的问题通常围绕三个层面展开,你需要有意识地把答案往这三个方向上靠:
- 基础原理与机制:这是地基。比如Java内存模型(JMM)、happens-before原则、线程的生命周期、锁的实现原理(AQS)、线程池的核心参数和工作机制。这部分问题通常比较直接,但回答要有深度,不能只停留在表面。
- 并发工具的正确使用:这是工具。面试官会考察你是否真的会用
java.util.concurrent包下的工具,比如CountDownLatch/CyclicBarrier/Semaphore的区别和使用场景,ConcurrentHashMap的实现原理,ThreadLocal的内存泄露问题,以及各种阻塞队列的特性。 - 综合性场景设计与问题排查:这是盖房子。这是区分普通候选人和优秀候选人的关键。例如:“如何设计一个秒杀系统?”、“如何实现一个生产者-消费者模型?”、“线上服务出现死锁,如何定位和解决?”、“线程池任务堆积,可能的原因有哪些,如何优化?”
我建议你在准备时,就按照这三个维度去整理自己的知识树。看到一个基础知识点,立刻去想:它在工具里是怎么用的?在复杂场景下会引出什么问题?
1.2 回答问题的“STAR-R”框架
对于场景题,不要一上来就陷入技术细节。可以借鉴“STAR”原则,但调整为更适合技术面试的“STAR-R”:
- S(Situation):简要描述问题场景。例如:“这是一个高并发写入的场景,比如优惠券库存扣减。”
- T(Task):明确你的任务目标。例如:“需要保证在数万QPS下,库存扣减的绝对准确,不超卖。”
- A(Action):阐述你采取的技术行动。这是核心,要分层:
- 第一层:架构选型。是直接用数据库乐观锁,还是引入Redis分布式锁,或是用Redis+Lua脚本,还是用消息队列削峰填谷?
- 第二层:具体实现。如果用Redis分布式锁,选择
setnx还是Redisson?锁的粒度怎么控制(商品ID维度)?锁的超时时间设多少?如何避免锁过期但业务未执行完的问题(看门狗机制)? - 第三层:细节考量。异常处理(锁获取失败怎么办?)、降级策略(直接返回失败还是排队?)、监控指标(锁竞争次数、持有时间)。
- R(Result):说明行动带来的结果。例如:“最终实现了在xxx压力下,TPS达到xxx,且未出现超卖。”
- R(Reflection):加分项。反思与优化。例如:“后续复盘,发现锁粒度还可以细化到库存批次,进一步减少竞争。另外,我们增加了热点库存的本地缓存预热,减少对中心存储的访问压力。”
用这个框架组织语言,你的回答会显得非常有结构性和专业性。
2. 必须吃透的底层基础:JMM、锁与线程池
这一部分是面试的必答题,也是理解所有高级问题的基础。不能有模糊地带。
2.1 Java内存模型(JMM)与并发三大问题
面试官问“volatile有什么用?”,他期待的绝不仅仅是“保证可见性,禁止指令重排序”。他是在考察你对JMM的理解。
- 可见性、原子性、有序性:必须能用自己的话解释清楚,并举出反例。
- 可见性:线程A修改了共享变量,线程B不一定立刻能看到。
volatile通过读写内存屏障解决。 - 原子性:一个或多个操作,要么全部执行成功,要么全部不执行。
i++不是原子操作。需要通过synchronized或CAS(如AtomicInteger)解决。 - 有序性:程序执行的顺序不一定和代码顺序一致,编译器/处理器会做优化。
volatile和happens-before规则可以保证有序性。
- 可见性:线程A修改了共享变量,线程B不一定立刻能看到。
- Happens-Before原则:这是理解Java并发规则的总纲。要能说出几条关键的,并举例:
- 程序次序规则、管程锁定规则(
unlock先于lock)、volatile变量规则、线程启动/终止/中断规则、对象终结规则、传递性。 - 举例:为什么
Thread.start()调用前的修改,对run()方法可见?(线程启动规则)
- 程序次序规则、管程锁定规则(
- volatile的深度剖析:
- 底层实现:内存屏障(LoadLoad, StoreStore, LoadStore, StoreLoad)。
StoreLoad屏障是最重的。 - 典型场景:状态标志位(
while (!stop))、DCL(双重检查锁)单例模式(JDK5以后,配合volatile才能正确工作)。 - 常见误区:
volatile不能保证复合操作的原子性(如count++)。
- 底层实现:内存屏障(LoadLoad, StoreStore, LoadStore, StoreLoad)。
2.2 锁机制:从synchronized到AQS
锁是协调多线程访问共享资源的基石。要能对比,知道怎么选。
- synchronized:
- 用法:修饰实例方法、静态方法、代码块。
- 升级过程:这是高频考点。无锁 -> 偏向锁(Mark Word记录线程ID) -> 轻量级锁(自旋,CAS) -> 重量级锁(向操作系统申请互斥量)。要能说清楚升级的条件和目的(在无竞争或低竞争时减少开销)。
- 锁优化:自适应自旋、锁消除、锁粗化。
- ReentrantLock:
- 特点:相比
synchronized,功能更丰富:可中断、可超时、可尝试非阻塞获取、支持公平/非公平锁、可以绑定多个条件变量(Condition)。 - 底层:基于AQS(AbstractQueuedSynchronizer)。这是必须啃下的硬骨头。
- 特点:相比
- AQS核心原理:
- 它维护了一个volatile int state(表示资源状态)和一个FIFO线程等待队列(CLH变体)。
- 模板方法模式:子类需要实现
tryAcquire/tryRelease(独占模式)或tryAcquireShared/tryReleaseShared(共享模式)。 - 以
ReentrantLock.NonfairSync为例:lock()时,先直接CAS尝试将state从0改为1(非公平的体现)。- 如果失败,调用
acquire(1)。 acquire会先调用子类的tryAcquire再次尝试。- 如果还失败,则将当前线程包装成
Node加入等待队列,并调用LockSupport.park()挂起。 - 前驱节点释放锁时,会唤醒后继节点。
- 能画出AQS队列的大致结构,并说明入队、出队、挂起、唤醒的过程,面试官会眼前一亮。
2.3 线程池:不只是“七大参数”
线程池是工程中使用并发的最重要工具。绝不能只背参数。
- 核心参数与工作流程:
corePoolSize,maximumPoolSize,workQueue,keepAliveTime,threadFactory,handler。- 流程口诀:核心线程未满 -> 创建新线程执行;核心线程已满,任务队列未满 -> 入队;队列已满,最大线程未满 -> 创建临时线程;全都满了 -> 执行拒绝策略。
- 必须手写一遍这个流程,并能在白板上画出来。
- 阻塞队列选型:
LinkedBlockingQueue:无界队列(默认Integer.MAX_VALUE),任务可能无限堆积,导致OOM。适用于任务量可预估且稳定的场景。ArrayBlockingQueue:有界队列,可以防止资源耗尽。SynchronousQueue:不存储元素,直接传递。适用于快速处理、不希望任务堆积的场景。CachedThreadPool用它。PriorityBlockingQueue:优先级队列。
- 拒绝策略:
AbortPolicy(默认):抛RejectedExecutionException。CallerRunsPolicy:让提交任务的线程自己去执行。这是一种有效的回退和削峰策略,能减缓任务提交速度。DiscardOldestPolicy:丢弃队列里最老的任务。DiscardPolicy:直接丢弃新任务。- 实践建议:生产环境不要用默认的AbortPolicy,至少要用
CallerRunsPolicy,并根据业务场景考虑自定义策略(如记录日志、持久化到数据库稍后重试)。
- 参数设置经验:
- CPU密集型:
corePoolSize=maximumPoolSize= CPU核数 + 1。减少线程切换开销。 - IO密集型:
corePoolSize= CPU核数 * 2,maximumPoolSize可以设大一些(如CPU核数 * 4 / (1 - 阻塞系数))。因为线程大部分时间在等待IO。 - 队列容量:需要根据任务处理速度和内存来权衡。一定要设置监控,观察队列堆积情况。
- CPU密集型:
- 常见坑点:
- 线程池的复用:不要每次执行都
new一个线程池,要用全局共享的。 - Spring中的
@Async:默认使用SimpleAsyncTaskExecutor,它为每个任务创建新线程!生产环境必须配置自定义线程池。 ThreadLocal内存泄露:线程池中的线程是复用的,ThreadLocal变量如果不及时remove,可能会一直持有对大对象的引用,导致内存泄露。
- 线程池的复用:不要每次执行都
3. 高阶工具与模式:JUC包的精髓
掌握了基础,就要学习如何使用更高级的工具来优雅地解决复杂问题。java.util.concurrent(JUC)包是你的武器库。
3.1 同步工具类:控制线程的执行节奏
- CountDownLatch vs CyclicBarrier vs Semaphore:
- CountDownLatch(倒计时闩锁):一个线程(或多个)等待其他一组线程完成工作。初始化一个计数,线程调用
countDown()减1,调用await()的线程会阻塞直到计数为0。一次性使用。- 场景:主线程等待所有子线程初始化完毕后再执行;模拟并发测试(同时发起请求)。
- CyclicBarrier(循环屏障):一组线程互相等待,到达一个公共屏障点后再同时继续执行。可重复使用(
reset())。- 场景:多阶段任务,比如数据分片计算,每个阶段所有线程都完成后,才能进入下一阶段。
- Semaphore(信号量):控制同时访问特定资源的线程数量。用于做流量控制。
- 场景:数据库连接池限流;限制某个接口的并发调用数。
- 简单记忆:
CountDownLatch是“等人齐了开会”,CyclicBarrier是“等人齐了才能一起出发去下一个景点”,Semaphore是“停车场只有N个车位”。
- CountDownLatch(倒计时闩锁):一个线程(或多个)等待其他一组线程完成工作。初始化一个计数,线程调用
3.2 并发容器:线程安全的集合
- ConcurrentHashMap:高频考点。必须了解其演进。
- JDK7:分段锁(Segment)。每个
Segment继承ReentrantLock。 - JDK8及以后:
Node数组 + 链表/红黑树。锁的粒度更细,锁住的是数组的每个桶(链表头节点),使用synchronized和CAS。 - 关键方法:
putVal,get,size()(是一个估计值,非强一致)。 - 为什么
get不用加锁?因为Node的val和next都用volatile修饰,保证了可见性。
- JDK7:分段锁(Segment)。每个
- CopyOnWriteArrayList:写时复制。适合读多写极少的场景。每次修改(add, set)都会复制底层数组,开销大。迭代器使用快照,不会抛出
ConcurrentModificationException。 - BlockingQueue:线程池的核心,也是生产者-消费者模式的经典实现。前面已介绍,要熟悉
put/take(阻塞)和offer/poll(非阻塞/超时)的区别。
3.3 原子类与CAS
- AtomicInteger, AtomicLong, AtomicReference等:基于
Unsafe类提供的compareAndSwap(CAS)操作实现无锁更新。 - CAS原理:比较并交换。
V-内存值,A-预期值,B-新值。只有当V == A时,才将V更新为B。这是一个原子性的CPU指令。 - ABA问题:一个值从A变成B,又变回A,CAS会认为它没变过。解决方案:使用带版本号的原子引用,如
AtomicStampedReference。 - CAS的缺点:循环时间长开销大(自旋);只能保证一个共享变量的原子操作;ABA问题。
4. 实战:场景设计、问题排查与系统优化
这是面试中最能体现价值的环节。你需要把前面的知识点,像拼图一样组合起来。
4.1 经典场景设计题剖析
场景一:如何设计一个秒杀系统?这不是问你怎么写代码,是问架构思路。
- 分层削峰:
- 前端:按钮置灰、验证码、请求频率限制。
- 网关/负载均衡:限流(令牌桶、漏桶)、恶意IP过滤。
- 服务层:
- 读多写少:商品详情等静态数据,用CDN+浏览器缓存。
- 库存热点:库存数据预热到Redis。扣减库存是核心。
- 库存扣减方案对比:
- 方案A:数据库乐观锁。
update stock set stock=stock-1 where id=? and stock>0。简单,但数据库压力大,成功率随并发增高而急剧下降。 - 方案B:Redis递减。
DECR命令是原子的。性能极高。但存在超卖风险,因为Redis扣减成功到数据库真正扣减之间可能失败。 - 方案C:Redis + Lua脚本。将“判断库存”和“扣减库存”写在一个原子性的Lua脚本中执行,解决超卖。然后异步通知数据库更新最终库存。这是主流方案。
- 方案D:消息队列。所有请求先入队,服务端按自己的能力从队列里消费,实现绝对的顺序和流量控制。用户体验为“排队中”。
- 方案A:数据库乐观锁。
- 最终一致性:采用方案C或D时,要保证Redis和数据库的最终一致,可以通过监听binlog或异步任务来同步。
- 降级与熔断:如果Redis或数据库扛不住,要有预案,比如直接返回“活动太火爆,请稍后再试”。
场景二:实现一个生产者-消费者模型这考察你对线程协作和阻塞队列的理解。
- 经典写法(使用
BlockingQueue):BlockingQueue<Task> queue = new LinkedBlockingQueue<>(100); // 生产者 public void produce(Task task) throws InterruptedException { queue.put(task); // 队列满则阻塞 } // 消费者 public Task consume() throws InterruptedException { return queue.take(); // 队列空则阻塞 } - 进阶追问:
- 如果不用
BlockingQueue,用wait()/notify()怎么实现?(需要维护一个普通队列,并用synchronized保护,在队列空/满时让线程等待)。 - 如何支持多个生产者和多个消费者?
- 如何优雅地停止消费者线程?(设置一个“毒丸”对象放入队列,消费者读到它就退出)。
- 如果不用
4.2 线上问题排查链路
当面试官问“线上服务CPU飙高/线程死锁/内存泄露,怎么排查?”,他希望你有一套方法论。
- 定位问题进程和线程:
top -Hp [pid]:查看哪个线程CPU占用高。记下线程ID(十进制)。printf “%x\n” [线程id]:将线程ID转为十六进制。
- 查看线程堆栈:
jstack [pid] > jstack.log:导出Java线程堆栈。- 在
jstack.log中搜索上一步得到的十六进制nid,找到对应的线程堆栈,看它在执行什么代码。 - CPU高:通常是线程在疯狂执行(比如死循环)或频繁GC。
- 死锁:
jstack输出最后会明确提示“Found one Java-level deadlock”,并列出死锁线程和锁资源。
- 分析内存:
jmap -heap [pid]:看堆内存概况。jmap -histo:live [pid]:看存活对象 histogram。jmap -dump:live,format=b,file=heap.hprof [pid]:导出堆转储文件。- 使用MAT或JVisualVM分析
heap.hprof,查找Retained Heap最大的对象,看是谁在引用它(GC Root路径),定位内存泄露点。
- 其他工具:
jstat -gcutil [pid] 1000:每秒打印一次GC情况,观察GC频率和耗时。- Arthas:阿里开源的Java诊断神器。
thread -b直接找死锁,watch/trace命令动态监控方法调用耗时和参数。
4.3 性能优化与最佳实践
- 锁优化:
- 减小锁粒度:从方法锁缩小到代码块锁;使用
ConcurrentHashMap的分段思想。 - 减少锁持有时间:把与共享变量无关的计算移出同步块。
- 锁分离:读写锁(
ReentrantReadWriteLock),读读不互斥。 - 无锁化:尝试使用原子类、
ThreadLocal、不可变对象、CAS操作。
- 减小锁粒度:从方法锁缩小到代码块锁;使用
- 上下文切换:线程数不是越多越好。过多的线程会导致大量CPU时间花在线程切换上。用
vmstat或pidstat查看cs(上下文切换)次数。 - 伪共享(False Sharing):由于CPU缓存行(通常64字节)的机制,两个无关的变量如果位于同一个缓存行,一个线程修改其中一个,会导致另一个线程的缓存行失效,即使它没修改那个变量。解决方案:使用
@sun.misc.Contended注解(JDK8)进行字节填充。
面试到最后,面试官可能会问“你还有什么问题?”。不要问薪资福利(这留给HR),可以问一些有深度的问题,比如:“团队目前遇到的最有挑战性的并发问题是什么?”、“咱们的系统在并发方面的技术选型是怎样的?”。这能体现出你的思考深度和主动性。
把高并发和多线程的知识,从散点的记忆,变成连成线的理解,再织成面的解决方案,这是从“知道”到“掌握”的关键。面试前,找几个完整的场景(比如秒杀、对账、数据导出),自己从头到尾推演一遍,把可能被问到的点都覆盖到。真正理解的东西,是能经得住连环追问的。