Java 并发与多线程面试题精选
Java 后端真实面试专题 · 并发与多线程篇
并发是 10–25k 后端岗的必考区,问得深、追问狠。每题三段: ① 标准答(讲透:是什么+为什么+怎么做)→ ② 拓展(成体系带出关联点和面试官会追问的,答一题等于答一片)→ ③ 怎么接到你自己的项目(背八股的人答不出这段)。
年限标签:
🟢 3年内🔴 3年+这一篇的答法本身就是教学:别人问一个点,你成体系地答一片——这才是面试官眼里的"懂"。
1. 🟢 线程有哪几种状态?是怎么流转的?
标准答:Java 用 Thread.State 暴露 6 种 JVM 层状态,它们是线程在某一时刻的观察结果,不等同于操作系统的全部状态:
NEW:对象已创建但还没有调用start();同一个线程对象只能成功start()一次。RUNNABLE:已经启动、正在运行或等待操作系统调度。Java 把“就绪”和“运行中”合并在这里,不能据此断定线程真的占着 CPU。BLOCKED:等待进入synchronized的 monitor,通常是别的线程持有锁。WAITING:主动无限期等待,如Object.wait()、Thread.join()(无超时)、LockSupport.park(),需要通知或其他线程结束才能继续。TIMED_WAITING:带截止时间的等待,如sleep、wait(timeout)、join(timeout)、parkNanos。TERMINATED:run正常返回或因未捕获异常结束,不能再次启动。
典型流转如下;BLOCKED 与 WAITING 的原因不同,排查时要结合栈顶方法看:
jstack 中看到 BLOCKED,优先找竞争的锁;看到 WAITING,则检查谁应该发出通知、队列是否有数据或 Future 是否完成。状态本身只说明“在等什么”,还要结合业务链路判断是否是正常背压。
拓展:面试官常顺着追问:
- "BLOCKED 和 WAITING 区别?"——BLOCKED 是等锁(被动,抢 synchronized 没抢到),WAITING 是主动等通知(调了 wait/park)。
- "操作系统层面线程有几种状态?"——新建、就绪、运行、阻塞、终止,Java 的 RUNNABLE 对应了就绪+运行两个。
- "线程状态在哪看?"——
jstack导出线程栈,每个线程都标了状态,线上排查全靠它。 - 状态不能跳——比如 NEW 不能直接到 RUNNING,必须经 start。
往项目引 ⭐:"理解状态对排查线上很关键。我项目有次接口大面积超时,jstack 一看几十个线程全 BLOCKED 在同一把锁上,立刻判断是锁竞争,定位到一段范围过大的 synchronized,缩小锁粒度后解决——状态不是背的,是排查问题的工具。"
2. 🟢 sleep 和 wait 的区别?
标准答:最本质的区别是:sleep 只是让线程计时休眠,不释放已经持有的 monitor;wait 是条件等待,会释放调用对象的 monitor。
| 对比项 | Thread.sleep | Object.wait |
|---|---|---|
| 定义位置 | Thread 静态方法 | Object 实例方法 |
| 是否释放锁 | 不释放任何已持有的锁 | 释放被调用对象的 monitor |
| 唤醒方式 | 时间到自动唤醒,也可被中断 | notify/notifyAll、超时或中断 |
| 使用前提 | 不要求持有特定锁 | 必须先进入同一对象的 synchronized |
| 典型语义 | 限速、重试退避、定时让出 CPU | 等待某个条件成立 |
final Object monitor = new Object();
synchronized (monitor) {
while (!ready) {
monitor.wait(); // 释放 monitor,醒来后重新竞争并复查条件
}
}
synchronized (monitor) {
ready = true;
monitor.notifyAll(); // 修改条件后再通知
}
wait 必须放在 while 而不是 if 中:线程可能发生虚假唤醒,或被唤醒后条件又被其他线程消费。notify 只唤醒一个等待者,若多个条件共用一个 monitor,容易唤醒“用不上”的线程,生产代码通常优先 notifyAll 或直接使用 BlockingQueue、Condition 等高层抽象。两者被中断都会抛 InterruptedException,捕获后应恢复中断标志或把异常继续向上抛出。
拓展:这题能引出一大片,面试官最爱追:
- "wait/notify 为什么定义在 Object 而不是 Thread?"——锁是绑在任意对象上的,等待/唤醒针对的是"对象监视器(monitor)",所以必须是 Object 的方法。
- "为什么 wait 必须在 synchronized 里调用?"——调用前必须先持有该对象的 monitor,否则抛
IllegalMonitorStateException。 - "notify 和 notifyAll 区别?"——notify 随机唤醒一个等待线程(可能信号丢失),notifyAll 唤醒全部再重新竞争锁,生产上一般用 notifyAll 更安全。
- "wait 为什么要用 while 而不是 if 判断条件?"——防止"虚假唤醒",唤醒后要重新检查条件。
- 进阶对比
LockSupport.park/unpark:不需要持锁、更灵活,AQS 底层用的就是它。
往项目引 ⭐:"我项目里很少直接写 wait/notify,而是用更上层的 BlockingQueue(它内部就是等待-通知机制)。比如订单异步处理做缓冲队列,消费线程 take() 时队列空了自动阻塞、生产者 put 进来自动唤醒,比手写 wait/notify 安全得多。"
3. 🟢 创建线程有几种方式?为什么实际只用线程池?
标准答:从 API 角度常见四种方式:
- 继承
Thread并重写run(),耦合任务和执行线程,扩展性最差。 - 实现
Runnable,把任务从线程对象中分离,但没有返回值。 - 实现
Callable<T>,交给FutureTask/执行器执行,可以返回结果并抛出受检异常;Future.get()可能阻塞。 - 提交到
ExecutorService线程池,由池统一管理线程生命周期、队列和拒绝策略。
ExecutorService pool = Executors.newFixedThreadPool(8);
Future<Result> future = pool.submit(() -> calculate(input));
Result result = future.get();
前三种只是创建任务的方式,真正生产代码应让线程池负责执行:线程可复用,能限制并发、统一命名和监控,应用关闭时还能集中 shutdown。直接 new Thread 在高峰期会频繁创建/销毁、无法背压,线程数失控还会造成上下文切换和 OOM;线程池也不是“越大越好”,应按任务类型、下游承载能力和压测结果配置。
如果需要多个异步步骤的依赖、超时和异常编排,CompletableFuture 比手动阻塞 Future.get() 更合适;无论哪种方式,都要明确任务取消、超时以及线程池关闭策略。
拓展:
- "为什么不手动 new Thread?"——频繁创建销毁开销大、线程不可复用、数量不可控(并发一高线程暴涨直接 OOM)。阿里开发规约强制要求用线程池。
- Runnable 和 Callable 区别——Callable 有返回值
call()、能抛受检异常,配合 Future 拿结果。 - Future 的问题——
get()会阻塞,所以 JDK8 出了 CompletableFuture 做异步编排。 - 本质上四种方式的"任务"和"执行"是分离的,线程池就是把"执行"复用起来。
往项目引 ⭐:"我项目所有异步任务都走自定义线程池。比如商品批量导入,用 Callable 把每一批丢进线程池并行处理、再用 Future 收集结果,十万条从几分钟降到几十秒——既复用线程又能拿到每批的处理结果。"
4. 🟢 并发编程的三大特性是什么?
标准答:并发问题通常从三个维度分析:
- 原子性:一个操作或一组操作不可被观察到“做到一半”。
i++其实是读、加一、写回三步,多个线程会丢更新;用synchronized/Lock 保护临界区,或用AtomicInteger.incrementAndGet。 - 可见性:一个线程写入共享变量后,其他线程能看到最新值。线程可能把变量缓存到寄存器/工作内存,
volatile、解锁/加锁、线程启动和结束等 happens-before 规则建立可见性。 - 有序性:编译器、JIT 和 CPU 可能在不改变单线程结果的前提下重排指令;并发下若没有约束,另一个线程可能观察到不符合代码书写顺序的状态。
volatile的内存屏障、锁和安全发布规则可以限制这种重排。
三者不是同义词:volatile 能解决一个状态标志的可见性和一定的有序性,但不能让 count++ 原子;锁通常同时提供互斥、可见性和有序性;Atomic* 主要通过 CAS 保证特定变量的原子更新。
// 只需要“停止信号”的场景
private volatile boolean running = true;
// 需要读-改-写整体原子的场景
private final AtomicLong count = new AtomicLong();
long current = count.incrementAndGet();
判断该用什么工具时先问“缺的是哪种保证”,不要把所有共享变量都用一把大锁包住。最终还要考虑复合不变量:多个字段必须一起变化时,单独给每个字段加 volatile 仍然不够。
拓展:
- "
i++为什么线程不安全?"——它是"读-改-写"三步,不满足原子性,多线程会丢更新。 - "volatile 能保证原子性吗?"——不能,只保证可见性和有序性,所以计数要用
AtomicLong。 - "什么是指令重排?为什么允许?"——为了优化性能,单线程下重排不影响结果(as-if-serial),但多线程下会出问题。
- happens-before 是判断"是否存在数据竞争"的核心规则,比如解锁 happens-before 后续加锁。
- 三大特性是并发所有问题的根,几乎所有并发工具都是在解决这三个中的某一个。
往项目引 ⭐:"我项目里按'缺哪个特性补哪个'来选工具:优雅停机的状态开关只要可见性,用 volatile;并发计数要原子性,用 AtomicLong;复合操作要原子性+互斥,才上 synchronized/Lock。而不是无脑一把锁锁到底,那样性能差。"
5. 🟢 线程池的核心参数有哪些?一个任务提交进来的完整流程?
标准答:ThreadPoolExecutor 的七个参数分别控制容量、排队、命名和过载行为:corePoolSize、maximumPoolSize、keepAliveTime、TimeUnit、BlockingQueue<Runnable>、ThreadFactory、RejectedExecutionHandler。
提交任务时不是“先把线程开到最大”,而是按下面顺序决策:
- 当前工作线程数小于核心数:立即创建核心线程执行任务(即使队列里还有空位)。
- 核心线程已满:尝试放入阻塞队列。
- 队列已满且工作线程数小于最大数:创建非核心线程执行任务。
- 队列已满且已经达到最大线程数:调用拒绝策略。
核心线程默认不会因空闲超时而销毁,除非调用 allowCoreThreadTimeOut(true);非核心线程空闲超过 keepAliveTime 会回收。队列应优先使用有界实现,容量代表可接受的排队上限;线程工厂要设置有意义的名字并处理未捕获异常,方便监控和 jstack 定位。配置还要和下游数据库连接池、HTTP 连接池容量匹配,否则只是把压力从线程池转移到下游。
拓展:
- 高频追问"先创建线程还是先入队?"——先入队、再扩线程,很多人答反。原因是入队比创建线程代价小。
- "keepAliveTime 对核心线程生效吗?"——默认不,除非设
allowCoreThreadTimeOut(true)。 - 阻塞队列怎么选——有界队列(ArrayBlockingQueue/有界 LinkedBlockingQueue)防止任务无限堆积 OOM;SynchronousQueue 不存任务直接交付。
- 这套"先核心→再队列→再扩容→再拒绝"的设计哲学:优先复用、其次缓冲、最后才扩张和拒绝。
往项目引 ⭐:"我项目按业务隔离了多个线程池——订单、消息推送各用各的,避免一个业务把线程占满拖垮另一个(线程池隔离)。核心数是压测后定的,队列用有界队列,宁可触发拒绝策略也不让任务无限堆积把内存打爆。"
6. 🟢 线程池的拒绝策略有哪些?生产上你用哪种?
标准答:当工作线程已达到最大数且队列也满时,ThreadPoolExecutor 调用 RejectedExecutionHandler。JDK 提供四种策略:
AbortPolicy:抛RejectedExecutionException,让调用方明确感知过载;默认策略。CallerRunsPolicy:由提交任务的线程同步执行,形成反压,但提交线程若是请求线程,可能把接口延迟直接放大。DiscardPolicy:静默丢弃新任务,只适用于允许丢失且有独立统计的非关键任务。DiscardOldestPolicy:丢弃队列最旧任务,再尝试提交新任务;会破坏先进先出,不适合有顺序或不能丢数据的业务。
生产上不是固定选某一种,而是先按任务重要性定义语义:日志采样可以丢,订单、扣款、消息发送不能丢。关键任务通常自定义策略:记录任务类型和 traceId、打指标和告警,再把任务落库或投递到 MQ 做补偿;也可以对请求线程使用 CallerRunsPolicy 做短时背压,但必须设置超时,避免把 Web 容器线程全部拖住。
RejectedExecutionHandler handler = (task, executor) -> {
rejectedCounter.increment();
persistForRetry(task); // 不能在这里无限阻塞
};
ThreadPoolExecutor pool = new ThreadPoolExecutor(
8, 32, 60, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(1000), threadFactory, handler);
拒绝本身是容量告警:要同时观察队列长度、活跃线程、任务耗时、下游响应和拒绝次数,确认是突发流量还是下游变慢。单纯把最大线程数调大,可能把数据库连接池压垮。
拓展:
- 生产一般不用默认的——抛异常会丢任务且影响主流程。
- CallerRunsPolicy 适合"不能丢任务、宁可慢"的场景,用调用线程执行天然限流。
- 大多数情况会自定义拒绝策略:记日志、报警、把任务落库或丢 MQ 后续补偿。
- 拒绝策略触发说明池子已经扛不住了,要顺带排查是不是参数设小了或下游慢了。
往项目引 ⭐:"我项目自定义了拒绝策略:任务被拒时先记日志报警,再把任务持久化到 DB / 丢进 MQ,等池子空闲了补偿执行,保证重要任务(如订单后续处理)不丢——而不是默认抛异常把任务弄丢了。"
7. 🔴 为什么不建议用 Executors 直接创建线程池?
标准答:问题不在 Executors 这个类本身,而在它提供的几个快捷工厂把关键容量藏起来:
newFixedThreadPool/newSingleThreadExecutor使用近似无界的LinkedBlockingQueue,生产速度大于消费速度时任务会一直堆积,最终耗尽堆内存。newCachedThreadPool允许把最大线程数扩到Integer.MAX_VALUE,短时间突发就可能创建大量线程,带来上下文切换、栈内存和 OOM 风险。newScheduledThreadPool的队列也没有给业务设置清晰的堆积上限,延迟任务异常时很难做容量治理。
因此生产代码直接构造 ThreadPoolExecutor(或使用经过审核的动态线程池组件),显式指定核心/最大线程数、有界队列、线程命名、拒绝策略和关闭方式。容量要根据任务耗时、流量峰值、下游连接数压测出来;“有界”还要配合监控和补偿,否则只是把无界内存问题变成无提示丢任务。
拓展:
- 这题本质考"你是不是真的踩过/懂线程池参数",背过的人才知道。
- 延伸到"队列怎么选"——核心是有界,给一个能接受的堆积上限。
- 再延伸"线程数怎么定"(见下一题)。
往项目引 ⭐:"我项目所有线程池都是 new ThreadPoolExecutor 手动建、统一封装成工具类,用有界队列 + 自定义拒绝策略 + 有意义的线程名(方便 jstack 排查)。就是因为知道 Executors 的无界队列在流量高峰会把内存打爆。"
8. 🔴 线程池的线程数怎么设置?
标准答:先区分任务主要在计算还是等待外部资源,再把公式当作起点而不是答案:
- CPU 密集型可从
CPU 核数到核数 + 1开始,线程过多只会增加上下文切换。 - IO 密集型线程在等待数据库/网络时可以让出 CPU,可从
核数 * 2或经验公式核数 × (1 + IO 等待时间 / CPU 计算时间)估算,但上限受数据库连接池、远端 QPS 和内存限制。 - 混合任务最好拆成独立线程池,避免 IO 等待挤占计算线程;阻塞调用不要直接塞进公共 ForkJoinPool。
调参流程是:记录任务 CPU/IO 时间和队列等待时间 → 在固定流量下压测不同配置 → 观察吞吐、P99 延迟、CPU、GC、连接池和拒绝数 → 选择满足 SLO 的最小容量。线程数不是越大越快,队列过长会把延迟隐藏起来,甚至造成请求超时后任务仍在后台运行。
拓展:
- "为什么 CPU 密集不能开太多线程?"——核数就那么多,线程多了只是轮流切换,切换本身耗 CPU。
- "怎么判断是 CPU 还是 IO 密集?"——看任务在算还是在等;可用监控看 CPU 利用率。
- 实际业务大多是 IO 密集(查库、调接口)。
- 还可以做动态线程池(如美团 DynamicTp),运行时调参数不重启。
往项目引 ⭐:"我项目导入任务是典型 IO 密集(大量读写库),线程数设得比核数高很多,再根据压测的吞吐曲线微调;而做图片处理那种 CPU 密集的池子就设核数附近,避免无谓切换。"
9. 🟢 synchronized 和 ReentrantLock 的区别?
标准答:两者都是可重入的互斥机制,区别主要在控制能力和出错风险:
| synchronized | ReentrantLock | |
|---|---|---|
| 本质 | JVM 关键字,自动加解锁 | JUC 的 API,手动 lock/unlock |
| 释放 | 自动(出块/异常) | 必须 finally 里 unlock,否则死锁 |
| 中断 | 不可中断 | 可中断(lockInterruptibly) |
| 公平 | 只能非公平 | 可选公平/非公平 |
| 条件 | 一个等待队列 | 可绑定多个 Condition,精准唤醒 |
| 尝试 | 不支持 | tryLock 可带超时 |
使用 ReentrantLock 时必须把 unlock() 放在 finally,否则异常路径会永久占锁:
lock.lock();
try {
update();
} finally {
lock.unlock();
}
简单的临界区优先用 synchronized,编译器保证异常时自动释放,代码更短;需要可中断获取、超时抢锁、公平策略或多个 Condition 队列时才选 ReentrantLock。JDK 6 以后 synchronized 已做大量优化,不能用“Lock 一定更快”作为理由。无论哪种锁,都要缩小临界区、避免在锁内调用不受控的远程服务,并统一锁顺序降低死锁风险。
拓展:
- synchronized 的锁升级(无锁→偏向锁→轻量级锁→重量级锁,见下题)让它 JDK6 后已经不慢,简单同步优先用它,代码也更简洁不易出错。
- ReentrantLock 的优势场景:需要 tryLock 超时、需要可中断、需要多个 Condition(如阻塞队列的"非空"和"非满"两个条件)。
- "可重入"是什么——同一线程能重复获取自己已持有的锁,避免自己把自己锁死。
- 读多写少还可以用 ReentrantReadWriteLock 或 StampedLock 提升并发。
往项目引 ⭐:"我项目里大多数同步用 synchronized 就够、简洁可靠;只有一个抢占式任务调度的场景用了 ReentrantLock 的 tryLock(超时)——抢不到锁的线程不能一直死等,超时就放弃去干别的,这是 synchronized 给不了的。"
10. 🔴 synchronized 的锁升级过程?锁信息存在哪?
标准答:经典 HotSpot 资料常把 synchronized 描述为“无锁 → 偏向锁 → 轻量级锁 → 重量级锁”,本质是根据竞争程度选择成本更合适的实现:
- 无竞争时,线程可快速进入;早期 JDK 的偏向锁会在对象头
Mark Word记录线程标识,重复进入几乎无 CAS。 - 出现少量竞争时,轻量级锁让线程在用户态短暂自旋,用 CAS 争抢,避免立即挂起/唤醒。
- 竞争激烈或持锁时间长时,锁膨胀为重量级 monitor,失败线程阻塞,释放时再唤醒。
锁记录、对象 hash、GC 年龄等信息主要编码在对象头的 Mark Word 中,具体布局随 JVM、压缩指针和版本变化。需要注意版本边界:JDK 15 起偏向锁默认被移除/禁用,不能把旧版“升级链”当成所有现代 JDK 的运行时事实;面试时说明“这是 HotSpot 历史实现,现代版本以 monitor/轻量级路径为主”更严谨。
自旋会消耗 CPU,只有在锁很快释放时才划算;真正的性能优化仍是缩小锁范围、减少共享状态,而不是追着锁状态“调级别”。
拓展:
- "为什么要锁升级?"——大多数情况锁竞争不激烈,重量级锁的阻塞/唤醒要切到内核态、很贵,所以先用轻量级方案。
- 偏向锁在高并发反复竞争下有撤销开销,JDK15 后默认禁用了偏向锁。
- 自旋是"忙等",消耗 CPU 但避免线程切换,适合锁很快释放的场景。
- 引申到对象内存布局:对象头(Mark Word + 类型指针)+ 实例数据 + 对齐填充。
往项目引 ⭐:"理解锁升级让我对 synchronized 有底——它在低竞争下其实很轻量。所以项目里简单的同步我放心用 synchronized,不会一上来就换成 Lock '显得高级',反而把代码搞复杂。"
11. 🟢 volatile 的作用和底层原理?能保证原子性吗?
标准答:volatile 是对一个共享变量的轻量级并发语义,主要保证:
- 可见性:一个线程写入后,后续读取该变量的线程能看到新值;JMM 会在读写处建立主内存同步关系。
- 有序性:读写周围的内存屏障限制编译器、JIT 和 CPU 的重排,使发布标志前的普通写入对读到标志的线程可见。
它不保证复合操作的原子性。volatile int count; count++ 仍是读-改-写三步,两个线程可能同时读到 10,最后都写回 11。
class Worker implements Runnable {
private volatile boolean running = true;
public void stop() { running = false; }
public void run() {
while (running) { doOne(); } // 能及时看到 stop
}
}
底层实现依赖 Java 内存模型与 CPU 缓存一致性/内存屏障,具体指令随平台而变,不能简单概括为“每次都加 lock 前缀”。适合状态开关、一次写多次读的安全发布;需要计数、检查并更新或多个字段保持不变量时,使用 Atomic*、锁或不可变快照。双重检查锁单例中的实例引用也必须 volatile,否则可能看到尚未完成构造的对象。
拓展:
- "经典应用?"——双重检查锁(DCL)单例的 instance 必须加 volatile,否则可能因为"new 对象不是原子的(分配内存→初始化→赋引用,可能重排)"而拿到半初始化对象。
- "和 synchronized 比?"——volatile 只保证可见性/有序、更轻量、不阻塞;synchronized 还保证原子性、会互斥。
- 适用场景:一写多读的状态标志位。
- 要原子复合操作就上 Atomic 或锁。
往项目引 ⭐:"我项目优雅停机用 volatile boolean running 做标志位——主线程改成 false,各工作线程下一轮循环立刻可见并退出,不用加锁。但需要并发累加的计数我一律用 AtomicLong,因为 volatile 保证不了 ++ 的原子性。"
12. 🔴 CAS 是什么?有哪些问题?怎么解决?
标准答:CAS(Compare-And-Swap)把“比较当前值并更新”作为一个不可分割的硬件原子操作:只有内存值仍等于预期值时才写入新值,否则失败并由代码重试。AtomicInteger、AtomicReference 和 AQS 的很多路径都建立在这个思想上。
AtomicInteger stock = new AtomicInteger(10);
boolean success = stock.compareAndSet(10, 9);
主要代价有三类:
- ABA:值经历 A→B→A,单看值的 CAS 认为没变化,但中间操作可能已经影响业务。给值附加版本号,使用
AtomicStampedReference或业务序列号。 - 自旋开销:竞争激烈时 CAS 反复失败,线程持续占 CPU;应限制重试、退避,或改用阻塞锁。
- 多变量一致性:一次 CAS 通常只覆盖一个引用/字长,多个字段需要一起更新时可封装成不可变对象后用
AtomicReference整体替换,或使用锁/事务。
CAS 不等于“无成本、无锁就一定快”:它仍受缓存行争用、内存序和失败重试影响。高并发计数可以用 LongAdder 分散热点,但读取是近似快照;要求严格线性一致时要用 AtomicLong 或锁,并结合业务幂等。
拓展:
- "CAS 和加锁比好在哪?"——无锁、不阻塞线程、没有线程切换开销,适合竞争不激烈的场景。
- Atomic 类底层就是"CAS + 自旋"(
getAndIncrement循环 CAS 直到成功)。 - 竞争激烈时 CAS 自旋失败率高,JDK8 的 LongAdder 用分段(Cell)思想分散竞争,比 AtomicLong 更适合高并发计数。
往项目引 ⭐:"我项目的库存扣减本质就是 CAS 思想——update stock set stock=stock-1 where id=? and stock>0,这是数据库层的乐观锁:不加悲观锁、靠条件更新,影响行数为 0 就说明卖光了。既防了超卖又避免了悲观锁的性能损耗。"
13. 🔴 AQS 的原理是什么?
标准答:AQS(AbstractQueuedSynchronizer)是一个提供排队、阻塞和唤醒模板的同步器框架,ReentrantLock、Semaphore、CountDownLatch、读写锁等都复用它,但各自对 state 的含义不同。
- 一个
volatile int state表示同步资源:ReentrantLock 用它表示持有/重入次数,Semaphore 表示剩余许可,CountDownLatch 表示倒计数。 - 获取资源时先调用子类实现的
tryAcquire/tryAcquireShared;成功直接返回,失败则把当前线程封装成 Node 放入双向等待队列。 - 队列中的线程由
LockSupport.park挂起,前驱释放资源后唤醒后继,线程被唤醒再循环检查条件,避免只靠一次通知。
AQS 支持独占和共享两种模式;公平锁会检查队列先来者,非公平锁允许新线程先 CAS 抢占,吞吐通常更高。子类只需实现资源获取/释放规则,排队细节由 AQS 统一处理,这就是模板方法思想。理解 state + 队列 + park/unpark 三件事,比死记某个锁的源码更容易迁移到其他 JUC 工具。
拓展:
- 两种模式:独占(ReentrantLock,一次一个线程拿 state)、共享(CountDownLatch/Semaphore,多个线程可同时拿)。
- 公平锁 vs 非公平锁:公平锁严格按队列顺序、非公平锁允许新来的线程直接抢(吞吐更高,ReentrantLock 默认非公平)。
- AQS 用了模板方法模式,子类只需实现 tryAcquire/tryRelease。
- 底层挂起/唤醒用 LockSupport.park/unpark(不需要持锁,比 wait/notify 灵活)。
往项目引 ⭐:"这题偏底层,我面试会这么答:'我用过基于 AQS 的工具——ReentrantLock 做互斥、CountDownLatch 等多个并行任务完成、Semaphore 做并发数限流,并理解它们底层都是 state + 等待队列',把理论稳稳接到我真实用过的类上,而不是空背源码。"
14. 🟢 ConcurrentHashMap 是怎么保证线程安全的?和 HashMap、Hashtable 的区别?
标准答:JDK 8 ConcurrentHashMap 仍是数组 + 链表/红黑树,但把互斥范围缩小到桶:
- 桶为空时用 CAS 放入首节点,避免为无竞争写入加锁。
- 桶非空时只对该桶头节点做
synchronized,不同桶可以并行更新;链表过长也会树化。 - 扩容时多个线程可通过
ForwardingNode协助迁移,读操作在迁移期间仍能找到数据。
和其他 Map 的边界要说清:HashMap 不提供并发安全;Hashtable 对整个表加内置锁,吞吐低;Collections.synchronizedMap 也只保证单次方法调用,遍历需手动同步。ConcurrentHashMap 的 putIfAbsent、compute 等原子组合方法适合“检查并更新”场景,但“先 get 再 put”仍不是原子操作。它禁止 null key/value,以免并发读取时无法区分缺失与空值。
高并发计数可使用 ConcurrentHashMap<K, LongAdder>;若需要全局一致快照,仍要额外协调,因为 size() 和迭代结果可能反映不同时间点。
拓展:
- "JDK7 和 8 的实现差异?"——JDK7 用分段锁(Segment,默认 16 段),JDK8 改成 CAS + synchronized 锁单桶,粒度更细。
- "size() 怎么算的?"——用 baseCount + CounterCell 数组分散统计,避免单点竞争。
- "key/value 能为 null 吗?"——不能,因为并发下 null 无法区分"不存在"还是"值为 null"。
- 扩容时支持多线程协助迁移(transfer),提升扩容速度。
往项目引 ⭐:"我项目里本地缓存、并发计数这些都用 ConcurrentHashMap,而不是 Collections.synchronizedMap(那是锁整个 map、性能差)。比如统计各接口调用量,用 ConcurrentHashMap<String, LongAdder>,高并发下也准也快。"
15. 🟢 乐观锁和悲观锁的区别?分别用在什么场景?
标准答:
- 悲观锁先假设会冲突,获取资源后再读写,其他线程必须等待。例如 Java 的
synchronized、数据库select ... for update。它适合冲突频繁、临界区短且必须一次成功的场景,但锁范围过大或事务过长会降低并发、诱发死锁。 - 乐观锁先读并计算,提交更新时校验版本/条件是否仍成立,失败就重试或返回冲突。例如 CAS、
where version = oldVersion、where stock > 0的条件更新。它不阻塞读取,适合冲突概率低或失败可快速反馈的场景。
数据库版本号例子:
UPDATE product
SET stock = stock - 1, version = version + 1
WHERE id = ? AND version = ? AND stock > 0;
检查影响行数:1 表示本次更新成功,0 表示版本冲突或库存不足。选择方案时还要考虑重试成本、用户体验、事务边界和下游副作用;乐观锁失败后不能盲目重试发券/扣款,必须配合幂等键。高冲突热点可先在 Redis 或队列削峰,再由数据库做最终条件校验。
拓展:
- 乐观锁实现版本号:表加 version 字段,更新时
where version=旧值,成功才说明没被改、并把 version+1。 - CAS 也是乐观锁思想。
- 悲观锁要注意锁范围和死锁。
- 数据库乐观锁失败后业务上要决定是重试还是报错给用户。
往项目引 ⭐:"我项目库存扣减用乐观锁(stock>0 条件更新),并发高、冲突可接受、失败就提示'已抢完';而账户转账这种要绝对一致的,用 select for update 悲观锁锁住两个账户,避免中间被改。"
16. 🟢 CountDownLatch、CyclicBarrier、Semaphore 的区别?
标准答:三者都能让线程等待,但等待关系不同:
| 工具 | 等待语义 | 是否可复用 | 典型用途 |
|---|---|---|---|
CountDownLatch | 一个或多个线程等一组任务计数归零 | 否 | 主线程等并行查询全部完成 |
CyclicBarrier | 一组线程互相等,全部到达栅栏再继续 | 是 | 分阶段并行计算、每轮集合 |
Semaphore | 竞争有限数量的许可 | 是 | 限制并发访问连接/资源 |
CountDownLatch latch = new CountDownLatch(3);
for (Runnable task : tasks) {
pool.execute(() -> {
try { task.run(); }
finally { latch.countDown(); } // 必须放 finally,异常也要释放计数
});
}
if (!latch.await(500, TimeUnit.MILLISECONDS)) {
throw new TimeoutException("tasks not completed");
}
CyclicBarrier 的参与线程数固定,某线程异常或超时会使 barrier 进入 broken 状态,下一轮要显式处理;CountDownLatch 计数到 0 后不能复位。Semaphore 获取许可后必须在 finally 中 release(),否则许可泄漏会让后续请求永久阻塞。三者底层都可借助 AQS 的共享模式,但业务语义和故障处理不同,不能互换。
拓展:
- CountDownLatch 是"一个等多个"或"多个等一个开始",CyclicBarrier 是"多个互相等齐"。
- CountDownLatch 计数到 0 不能复位,要重复用得换 CyclicBarrier。
- 三者底层都是 AQS(前两个共享模式,Semaphore 控制 state 为许可数)。
往项目引 ⭐:"我项目首页要并行查商品、库存、营销三个服务,全部回来再聚合返回,就用 CountDownLatch(3),三个查询各 countDown 一次、主线程 await 等齐;接口并发保护用 Semaphore 限制同时处理的请求数。"
17. 🔴 CompletableFuture 怎么用?解决了什么问题?
标准答:CompletableFuture 把异步任务表示成可组合的阶段,既能注册回调,也能表达依赖、合并、超时和异常处理,避免到处 Future.get() 阻塞。
CompletableFuture<Detail> detail = CompletableFuture.supplyAsync(
() -> detailClient.query(id), ioPool);
CompletableFuture<Stock> stock = CompletableFuture.supplyAsync(
() -> stockClient.query(id), ioPool);
CompletableFuture<View> view = detail.thenCombine(stock,
(d, s) -> assemble(d, s))
.orTimeout(800, TimeUnit.MILLISECONDS)
.exceptionally(ex -> fallbackView(id, ex));
常用操作可按依赖关系记:
supplyAsync/runAsync启动有返回值/无返回值任务;thenApply转换结果,thenAccept消费结果,thenRun只关心完成;thenCompose串联“后一步依赖前一步结果”的 Future;thenCombine/allOf合并独立任务,anyOf取先完成者。
默认异步方法会使用公共 ForkJoinPool.commonPool();阻塞 IO、第三方 SDK 或大批量任务应传入业务隔离的自定义线程池,否则一个慢任务可能拖累同 JVM 的其他异步逻辑。异常必须在链上处理(exceptionally、handle 或 whenComplete),并区分“返回降级值”和“继续向上失败”。allOf 本身不返回各任务结果,通常要在完成后逐个 join,且要设置整体超时和取消策略。
拓展:
- "要不要指定线程池?"——必须传自定义线程池,否则默认用 ForkJoinPool 公共池,被一个慢任务占满会影响全局。
- 异常处理用
exceptionally/handle,别让异步异常被吞掉。 - 和 Future 比:Future 只能阻塞 get 或轮询,CompletableFuture 能声明式编排。
往项目引 ⭐:"我项目首页聚合接口原来串行调三个服务要 2 秒,改成 CompletableFuture 用自定义线程池并行发起、thenCombine 汇总,整体耗时变成最慢的那个服务(几百毫秒)。这是'多接口并行聚合'的标准做法,面试官很爱听。"
18. 🔴 怎么保证多个线程的执行结果是有序的?
标准答:先区分“提交顺序、执行顺序、结果顺序”三个概念。线程池只保证任务进入队列的顺序(且有界队列/多个工作线程下也可能改变),不保证开始执行或完成顺序。
- 只要求返回结果有序:给任务携带原始索引,并行执行后写入按索引排列的数组,或收集完成结果再排序。
- 要求严格执行有序:使用单线程执行器、同一 key 的串行队列,或用
CompletableFuture.thenCompose串联依赖;吞吐会下降。 - 要求分阶段同步:一阶段并行,全部完成后用
CountDownLatch/CyclicBarrier放行下一阶段。
List<CompletableFuture<Indexed<Result>>> futures = IntStream.range(0, inputs.size())
.mapToObj(i -> CompletableFuture.supplyAsync(
() -> new Indexed<>(i, handle(inputs.get(i))), pool))
.toList();
List<Result> ordered = futures.stream()
.map(CompletableFuture::join)
.sorted(Comparator.comparingInt(Indexed::index))
.map(Indexed::value)
.toList();
并行任务还要定义失败策略:某一项失败是整个批次失败、填充默认值,还是重试后继续。消息顺序消费则通常按业务 key 分区/入同一队列,并由单线程或同 key 串行消费者处理;仅靠时间戳排序无法修复已经发生的状态覆盖。
拓展:
- "需要顺序消费消息怎么办?"——把同一类消息发到同一队列/分区,单线程消费(引到 MQ 顺序消费)。
- 并行 + 有序往往是"并行计算、串行汇总"。
往项目引 ⭐:"我项目批量处理要并行提速、但结果必须按原顺序返回给前端,我给每个任务带上索引并行跑,最后用索引把结果重新排好,既拿到了并行的速度又保住了顺序。"
19. 🟢 ThreadLocal 是什么?底层原理?有什么坑?
标准答:ThreadLocal<T> 把同一个逻辑变量隔离成“每个线程一份”,调用 get/set 时实际访问的是当前 Thread 内部的 ThreadLocalMap,不是一个全局 Map。它适合线程封闭数据,如请求 traceId、当前租户、格式化上下文,不适合在线程之间共享结果。
private static final ThreadLocal<String> TENANT = new ThreadLocal<>();
try {
TENANT.set(request.tenantId());
service.handle(request); // 同一线程调用链可读取
} finally {
TENANT.remove(); // 线程池复用时必须清理
}
ThreadLocalMap 的 key 是对 ThreadLocal 的弱引用,value 仍是强引用。若 ThreadLocal 对象被回收而线程长期存活(线程池),就会留下“key 已空、value 还在”的 stale entry,可能造成内存占用和数据串线;弱引用只是缓解,不替代 finally + remove。线程池还会复用线程,不能假设一次请求结束线程就销毁。
异步切线程后普通 ThreadLocal 不会自动传递:InheritableThreadLocal 只适合创建子线程的场景,在线程池中可能读到旧值;需要跨线程传递时使用明确的上下文参数,或经过审计的 TTL/框架上下文,并在任务结束清理。不要把大对象、连接或用户敏感数据长时间放在 ThreadLocal 中。
拓展:
- "为什么会内存泄漏?"——key 是弱引用会被 GC 回收,但 value 是强引用还挂在 map 上,线程池线程长期复用就会堆积。所以用完一定
remove()(最好放 finally)。 - "key 为什么用弱引用?"——为了 ThreadLocal 对象本身能被回收,是一种缓解措施,但不能替代 remove。
- "父子线程怎么传值?"——
InheritableThreadLocal,但线程池场景下要用阿里的TransmittableThreadLocal(TTL)。
往项目引 ⭐:"我项目用 ThreadLocal 存当前登录用户和租户 id——请求进来在拦截器里 set、整条调用链都能取到、请求结束在 finally 里 remove 防泄漏。多租户 SaaS 这套几乎是标配,也是它最典型的真实用法。"
20. 🔴 死锁是怎么产生的?怎么定位和避免?
标准答:死锁是线程永久等待彼此持有的资源。经典必要条件有四个:互斥、持有并等待、不可剥夺、循环等待;工程上最常破坏循环等待,给多把锁建立全局顺序。
定位时先看现象(线程堆积、接口超时),再用 jstack <pid> 或 Arthas thread:JVM 通常会打印 Found one Java-level deadlock,列出每个线程持有和等待的 monitor。对于 ReentrantLock、数据库锁或跨服务锁,还要结合应用日志、锁指标和数据库死锁报告,不能只看 Java 堆栈。
避免手段包括:按资源 id 统一加锁顺序;缩小锁范围、缩短持有时间;用 tryLock(timeout),超时释放已拿到的锁并重试/失败;避免在锁内调用远程服务;必要时改成无锁数据结构或单线程化处理。修复后仍要保留死锁监控,因为新代码可能引入另一条循环路径。死锁和活锁/饥饿也要区分:活锁是线程持续让步却没有进展,饥饿是某线程长期抢不到资源。
拓展:
- "怎么定位死锁?"——
jstack会直接打印Found one Java-level deadlock和涉及的线程与锁;也能用jconsole/Arthas 检测。 - 其他避免手段:用
tryLock(超时)拿不到就放弃(破坏"不可剥夺/持有并等待");减少锁持有时间和范围;尽量用无锁结构。 - 死锁、活锁、饥饿的区别——活锁是不断重试但都让步谁也不前进,饥饿是某线程一直抢不到资源。
往项目引 ⭐:"我项目转账场景两个账户互转曾经死锁——A 转 B 锁了 A 等 B,B 转 A 锁了 B 等 A。后来统一规则'按账户 id 从小到大依次加锁',破坏了循环等待,问题根治。这是死锁避免最经典也最实用的手段。"
你能答到第几层?
- 三段都能答、还能往项目引:你是面试里的少数派,冲 18k+ 没问题。
- 标准答 + 拓展能成体系答:知识扎实,差把它绑到你真做过的项目上。
- 标准答都磕巴:别急,并发是有主线的(三大特性 → 锁 → JUC 工具 → 线程池),按主线学一遍就通。
这是面试专题的「并发篇」,网站上还有 MySQL、Redis、Spring、微服务、项目场景等系统整理。 🌐 更多真实面试专题与资料:smallredtech.com 💬 想系统学 / 简历与辅导咨询,加微信:Ahongbb666(备注「面试题」)