Java 并发编程面试题精讲 (40 题)
本文覆盖并发编程面试中最高频的 40 道题,按 底层原理类 / 对比类 / 场景设计类 三大题型分类。 每道题均提供三层递进式答案,帮助你在面试中做到 能答、答好、答出彩。
难度标识:⭐ 基础 / ⭐⭐ 中等 / ⭐⭐⭐ 高频难题 / ⭐⭐⭐⭐ 高级
一、线程基础(6 题)
Q1: 创建线程有几种方式?
【题型:底层原理类】
1) 面试直答版
常见说法有四种:继承 Thread 类、实现 Runnable 接口、实现 Callable 接口、使用线程池。但从底层来看,真正创建线程的方式只有一种——new Thread()。Runnable 和 Callable 只是定义任务的方式,线程池内部也是通过 new Thread() 来创建线程的。
2) 深度解析版
表面上的四种方式:
// 方式一:继承 Thread
class MyThread extends Thread {
@Override
public void run() { System.out.println("Thread 方式"); }
}
// 方式二:实现 Runnable
class MyRunnable implements Runnable {
@Override
public void run() { System.out.println("Runnable 方式"); }
}
new Thread(new MyRunnable()).start();
// 方式三:实现 Callable(配合 FutureTask)
class MyCallable implements Callable<String> {
@Override
public String call() { return "Callable 方式"; }
}
FutureTask<String> task = new FutureTask<>(new MyCallable());
new Thread(task).start();
// 方式四:线程池
ExecutorService pool = Executors.newFixedThreadPool(5);
pool.submit(() -> System.out.println("线程池方式"));
本质分析:
| 方式 | 本质 | 说明 |
|---|---|---|
| 继承 Thread | new Thread() | 直接创建线程对象 |
| 实现 Runnable | new Thread(runnable) | 定义任务,仍需 Thread 启动 |
| 实现 Callable | new Thread(futureTask) | FutureTask 实现了 Runnable |
| 线程池 | new Worker() (Worker extends Thread) | 池内部通过 ThreadFactory 创建线程 |
所以严格来说,创建线程只有一种方式:构造 Thread 类(或其子类)的实例,调用 start() 方法,由 JVM 调用本地方法 start0() 创建操作系统线程。
Runnable / Callable 是定义任务的方式,而不是创建线程的方式。这两个概念不应混淆。
⚠️ 高频易错点:很多候选人会直接回答”四种”,但面试官真正想听的是你能否区分”创建线程”和”定义任务”。如果被追问,需要能说清楚只有 Thread 才能真正创建线程。
3) 加分项
在实际项目中,我们几乎不会直接 new Thread(),而是通过线程池管理线程。原因有三:
- 资源可控:直接创建线程没有上限,容易 OOM;
- 复用线程:避免频繁创建/销毁线程带来的系统开销(线程创建涉及 OS 系统调用,成本远高于普通对象);
- 统一管理:便于监控、设置超时、统一异常处理。
另外,Java 19+ 引入了虚拟线程(Virtual Threads / Project Loom),本质上是用户态的轻量级线程,由 JVM 调度而非 OS 调度,创建成本极低(约 1KB 栈空间 vs 传统线程 1MB),适合大量 IO 密集型场景。
Q2: 线程的生命周期有哪些状态?
【题型:底层原理类】
1) 面试直答版
Java 线程有 6 种状态:NEW(新建)、RUNNABLE(可运行)、BLOCKED(阻塞)、WAITING(等待)、TIMED_WAITING(超时等待)、TERMINATED(终止)。这 6 种状态定义在 Thread.State 枚举中。
2) 深度解析版
状态转换图(ASCII):
┌──────────────────────────────────────────┐
| |
┌─────┐ start() | ┌──────────┐ |
│ NEW │──────────>│ RUNNABLE │ |
└─────┘ │ (Ready + │ |
│ Running) │ |
└────┬──┬───┘ |
│ │ |
synchronized │ │ wait()/join() |
等待锁 │ │ LockSupport.park() |
│ │ |
┌────▼──┘ notify()/notifyAll() ┌────▼───────────┐
│ BLOCKED │◄──────────────────────── │ WAITING │
│(等待锁) │ LockSupport.unpark()│ (无限等待) │
└────┬────┘ └────────────────┘
│ |
│ 获取到锁 |
│ |
┌────▼────┐ |
│RUNNABLE │◄──────────────────────────────┘
└────┬────┘ sleep(time)/wait(time)
│ join(time)
│ LockSupport.parkNanos()
┌────▼───────────┐
│ TIMED_WAITING │ 超时或被唤醒后
│ (超时等待) │──────────────────> RUNNABLE
└────────────────┘
run() 执行完毕
RUNNABLE ──────────────────────────> TERMINATED
各状态详解:
| 状态 | 含义 | 触发条件 |
|---|---|---|
| NEW | 线程对象已创建,尚未调用 start() | new Thread() |
| RUNNABLE | 可运行状态(包含 OS 层面的 Ready 和 Running) | 调用 start() 后 |
| BLOCKED | 等待获取 synchronized 监视器锁 | 进入 synchronized 块时锁被占用 |
| WAITING | 无限期等待其他线程执行特定操作 | wait() / join() / LockSupport.park() |
| TIMED_WAITING | 有时限的等待 | sleep(ms) / wait(ms) / join(ms) / parkNanos() |
| TERMINATED | 线程执行完毕或因异常退出 | run() 方法结束 |
⚠️ 高频易错点:
- Java 中没有 RUNNING 状态!RUNNABLE 包含了操作系统层面的 Ready 和 Running。
- BLOCKED 状态只针对 synchronized 关键字。使用
ReentrantLock加锁时,线程状态是 WAITING(调用了LockSupport.park()),不是 BLOCKED。Thread.sleep()进入的是 TIMED_WAITING,不是 BLOCKED。
3) 加分项
可以通过 jstack <pid> 命令查看线程的状态,或者通过 ThreadMXBean 在程序中获取:
ThreadMXBean bean = ManagementFactory.getThreadMXBean();
ThreadInfo[] infos = bean.dumpAllThreads(true, true);
for (ThreadInfo info : infos) {
System.out.println(info.getThreadName() + " => " + info.getThreadState());
}
在排查线上问题时,如果发现大量线程处于 BLOCKED 状态,通常是发生了锁竞争或死锁;如果大量线程处于 WAITING 状态,可能是线程池中的线程在等待任务,或者某些资源迟迟未就绪。
Q3: start() 和 run() 的区别?
【题型:对比类】
1) 面试直答版
start() 会创建一个新线程,然后在新线程中执行 run() 方法;直接调用 run() 只是在当前线程执行一个普通方法,不会创建新线程。
2) 深度解析版
对比分析:
| 维度 | start() | run() |
|---|---|---|
| 是否创建新线程 | 是,JVM 调用 start0() 本地方法创建 OS 线程 | 否,在调用者线程中同步执行 |
| 执行线程 | 新线程 | 当前线程 |
| 能否多次调用 | 不能,第二次调用抛出 IllegalThreadStateException | 可以,普通方法可重复调用 |
| 线程状态变化 | NEW -> RUNNABLE | 无状态变化 |
代码验证:
Thread t = new Thread(() -> {
System.out.println("当前线程: " + Thread.currentThread().getName());
});
// 调用 start():输出 Thread-0(新线程)
t.start();
// 直接调用 run():输出 main(当前线程)
// t.run();
源码分析:
// Thread.start() 源码关键逻辑
public synchronized void start() {
if (threadStatus != 0) // 已经启动过
throw new IllegalThreadStateException();
group.add(this);
boolean started = false;
try {
start0(); // native 方法,真正创建 OS 线程
started = true;
} finally {
if (!started) group.threadStartFailed(this);
}
}
private native void start0(); // JVM 层面创建线程并调用 run()
start0() 是一个 JNI 方法,它会:
- 创建一个操作系统原生线程(pthread_create / CreateThread)
- 在新线程的入口函数中回调 Java 的
Thread.run()方法
3) 加分项
面试中经常出现这道题的变种:“如果对同一个线程对象调用两次 start() 会怎样?”
答案是会抛出 IllegalThreadStateException,因为 start() 方法开头就检查了 threadStatus,非 0 就抛异常。线程一旦终止就无法重新启动,只能创建新的线程对象。这也是线程池不直接 start() 已结束线程,而是让 Worker 循环获取任务来复用线程的原因。
Q4: sleep() 和 wait() 的区别?
【题型:对比类】
1) 面试直答版
sleep() 是 Thread 的静态方法,让当前线程休眠指定时间,不释放锁;wait() 是 Object 的方法,让线程进入等待状态,会释放锁,且必须在 synchronized 块中调用。
2) 深度解析版
全维度对比:
| 维度 | sleep() | wait() |
|---|---|---|
| 所属类 | Thread | Object |
| 释放锁 | 不释放 | 释放监视器锁 |
| 使用条件 | 任意位置可调用 | 必须在 synchronized 块/方法中 |
| 唤醒方式 | 时间到自动恢复 / interrupt() | notify() / notifyAll() / interrupt() |
| 线程状态 | TIMED_WAITING | WAITING(无参)/ TIMED_WAITING(有参) |
| 异常 | 抛出 InterruptedException | 抛出 InterruptedException |
| 设计目的 | 暂停执行一段时间 | 线程间通信/协作 |
代码演示锁行为差异:
Object lock = new Object();
// sleep() 不释放锁
new Thread(() -> {
synchronized (lock) {
System.out.println("线程A 获取锁");
try {
Thread.sleep(5000); // 睡眠期间仍然持有锁
} catch (InterruptedException e) {}
System.out.println("线程A 释放锁");
}
}).start();
// 线程B 在线程A sleep 期间无法获取锁
new Thread(() -> {
synchronized (lock) {
System.out.println("线程B 获取锁"); // 5秒后才能打印
}
}).start();
// wait() 释放锁
new Thread(() -> {
synchronized (lock) {
System.out.println("线程A 获取锁");
try {
lock.wait(); // 释放锁,进入等待
} catch (InterruptedException e) {}
System.out.println("线程A 被唤醒,重新获取锁");
}
}).start();
// 线程B 可以立即获取锁
new Thread(() -> {
synchronized (lock) {
System.out.println("线程B 获取锁"); // 立即可执行
lock.notify(); // 唤醒线程A
}
}).start();
⚠️ 高频易错点:
wait()不在 synchronized 块中调用会抛IllegalMonitorStateException。wait()被唤醒后不是立即执行,而是要重新竞争锁,拿到锁后才能继续。wait()应始终在while循环中使用(防止虚假唤醒),而不是if。
3) 加分项
实际开发中,我们更推荐使用 Lock + Condition 替代 synchronized + wait/notify,因为 Condition 支持多个等待队列,更灵活:
ReentrantLock lock = new ReentrantLock();
Condition condition = lock.newCondition();
// condition.await() 相当于 wait()
// condition.signal() 相当于 notify()
此外,LockSupport.park() / unpark() 是更底层的线程阻塞原语,不需要在 synchronized 块中使用,也不需要处理锁的问题,被 AQS 等框架广泛使用。
Q5: 如何优雅地停止一个线程?
【题型:场景设计类】
1) 面试直答版
使用 interrupt() + 标志位 的方式通知线程停止。线程在合适的时机检查中断状态,主动退出。不推荐使用已废弃的 stop()、destroy() 方法。
2) 深度解析版
为什么不能用 stop()?
Thread.stop() 会强制终止线程并释放所有持有的锁,这会导致:
- 数据不一致:正在执行到一半的操作被中断,对象可能处于不一致状态;
- 锁被释放:其他线程可能看到损坏的数据;
- 无法预测:你无法控制线程在哪一行代码被停止。
正确做法:interrupt() + 标志位
public class GracefulStopThread extends Thread {
// volatile 保证可见性
private volatile boolean stopped = false;
public void stopGracefully() {
stopped = true;
this.interrupt(); // 如果线程在 sleep/wait 中,中断它
}
@Override
public void run() {
while (!stopped && !Thread.currentThread().isInterrupted()) {
try {
// 业务逻辑
doWork();
// 如果有阻塞操作(sleep/wait/join),interrupt 会使其抛出异常
Thread.sleep(1000);
} catch (InterruptedException e) {
// sleep/wait 被中断时会清除中断标志位
// 需要重新设置中断标志或直接退出
Thread.currentThread().interrupt(); // 重新设置中断标志
break; // 退出循环
}
}
// 清理资源
cleanup();
}
}
中断机制详解:
| 方法 | 作用 |
|---|---|
t.interrupt() | 设置线程 t 的中断标志位 |
Thread.interrupted() | 返回当前线程中断状态,并清除标志位(静态方法) |
t.isInterrupted() | 返回线程 t 的中断状态,不清除标志位 |
⚠️ 高频易错点:
interrupt()不会立即停止线程!它只是设置一个标志位。如果线程正在执行计算密集型代码且不检查中断标志,线程不会停止。- 当线程处于
sleep()/wait()/join()时被中断,会抛出InterruptedException,同时清除中断标志位。这是一个常见的坑——捕获异常后需要重新interrupt()或直接退出。Thread.interrupted()和t.isInterrupted()的区别:前者会清除标志位,后者不会。
3) 加分项
在实际项目中(如线程池的 shutdownNow()),也是通过 interrupt() 来通知工作线程停止的。Spring 的 @Async 任务取消、Netty 的 EventLoop 关闭,底层都是通过中断机制实现的。
设计一个可停止的后台服务线程时,我推荐的 pattern:
public class BackgroundService implements Runnable {
private final CountDownLatch shutdownLatch = new CountDownLatch(1);
@Override
public void run() {
try {
while (!Thread.currentThread().isInterrupted()) {
doWork();
}
} finally {
shutdownLatch.countDown(); // 通知外部:已完成关闭
}
}
public void shutdown() throws InterruptedException {
Thread.currentThread().interrupt();
shutdownLatch.await(10, TimeUnit.SECONDS); // 等待线程完成清理
}
}
Q6: 守护线程是什么?有什么用?
【题型:底层原理类】
1) 面试直答版
守护线程(Daemon Thread)是为其他线程提供服务的后台线程。当所有非守护线程结束时,JVM 就会退出,守护线程会被自动销毁。最典型的守护线程是 GC(垃圾回收)线程。
2) 深度解析版
设置方式:
Thread t = new Thread(() -> {
while (true) {
System.out.println("守护线程运行中...");
try { Thread.sleep(1000); } catch (InterruptedException e) { break; }
}
});
t.setDaemon(true); // 必须在 start() 之前设置
t.start();
核心规则:
| 规则 | 说明 |
|---|---|
| JVM 退出条件 | 所有非守护线程结束 -> JVM 退出 -> 守护线程被终止 |
| 设置时机 | 必须在 start() 前调用 setDaemon(true) |
| 继承性 | 子线程默认继承父线程的 daemon 属性 |
| finally 块 | 守护线程被杀时 不保证执行 finally 块 |
典型的守护线程:
- GC 垃圾回收线程
- Finalizer 线程(执行对象的 finalize 方法)
- 信号分发线程(Signal Dispatcher)
- RMI(远程方法调用)相关线程
⚠️ 高频易错点:
- 不要在守护线程中执行 IO 操作或持有需要释放的资源,因为 JVM 退出时守护线程会被强制终止,
finally块不保证执行。- 在已启动的线程上调用
setDaemon()会抛出IllegalThreadStateException。- main 线程不是守护线程。
3) 加分项
线程池中的线程是否为守护线程,取决于 ThreadFactory 的实现。默认的 Executors.defaultThreadFactory() 创建的是非守护线程。如果需要创建守护线程的线程池,可以用 Guava 的 ThreadFactoryBuilder:
ThreadFactory factory = new ThreadFactoryBuilder()
.setDaemon(true)
.setNameFormat("bg-worker-%d")
.build();
ExecutorService pool = new ThreadPoolExecutor(4, 8, 60, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(100), factory);
实际应用中,心跳检测、监控数据上报、缓存定时清理等后台任务通常设置为守护线程,这样应用关闭时不会因为这些后台线程而阻止 JVM 退出。
二、锁与同步(8 题)
Q7: synchronized 和 ReentrantLock 的区别?
【题型:对比类 | 高频重点】
1) 面试直答版
synchronized 是 JVM 内置的关键字锁,使用简单但功能有限;ReentrantLock 是 JDK 提供的 API 级别锁,功能更丰富,支持公平锁、可中断、可超时、多条件变量等特性。两者都是可重入的互斥锁。
2) 深度解析版
至少 8 个维度对比:
| 维度 | synchronized | ReentrantLock |
|---|---|---|
| 实现层面 | JVM 内置关键字,monitorenter/monitorexit 指令 | JDK API,基于 AQS 实现 |
| 锁释放 | 自动释放(退出代码块/异常时) | 手动释放,必须在 finally 中 unlock() |
| 可中断 | 不支持(等锁时不可中断) | 支持 lockInterruptibly() |
| 超时获取 | 不支持 | 支持 tryLock(timeout) |
| 公平性 | 非公平 | 可选公平/非公平(构造参数) |
| 条件变量 | 单条件(配合 wait/notify) | 多条件(多个 Condition 对象) |
| 可重入 | 可重入 | 可重入 |
| 锁绑定 | 锁对象是任意 Object | 锁对象是 ReentrantLock 自身 |
| 性能 | JDK 6 后优化,差距不大 | 竞争激烈时略优 |
| 锁状态查询 | 不支持 | 支持 isLocked() / getQueueLength() |
代码对比:
// synchronized 方式
public synchronized void syncMethod() {
// 自动获取/释放锁
}
// ReentrantLock 方式
private final ReentrantLock lock = new ReentrantLock();
public void lockMethod() {
lock.lock();
try {
// 业务逻辑
} finally {
lock.unlock(); // 必须手动释放
}
}
多条件变量的优势(生产者-消费者场景):
ReentrantLock lock = new ReentrantLock();
Condition notFull = lock.newCondition(); // 队列未满条件
Condition notEmpty = lock.newCondition(); // 队列非空条件
// 生产者等待 notFull,通知 notEmpty
// 消费者等待 notEmpty,通知 notFull
// 用 synchronized 只能 notifyAll() 唤醒所有线程,无法精准唤醒
⚠️ 高频易错点:
- ReentrantLock 必须在 finally 中释放锁,否则一旦异常,锁永远不释放,造成死锁。
- synchronized 在竞争锁时不可中断,可能导致线程一直阻塞。
- 不要用
lock()后忘记unlock(),这是最常见的 bug。
3) 加分项
选型建议:
- 如果不需要 ReentrantLock 的高级特性(公平、中断、超时、多条件),优先用
synchronized——代码简洁、不会忘记释放锁、JVM 做了大量优化(锁升级)。 - 如果需要
tryLock()避免死锁、lockInterruptibly()支持中断、多个Condition精准唤醒,则用ReentrantLock。
在 JDK 源码中,ConcurrentHashMap(JDK 8)的 put 操作使用了 synchronized(锁头节点),ReentrantReadWriteLock 基于 AQS 和 ReentrantLock 思路实现。Doug Lea 在并发包中大量使用 AQS 框架而非 synchronized,是因为需要更细粒度的控制。
Q8: synchronized 锁升级过程?
【题型:底层原理类 | 高频重点】
1) 面试直答版
synchronized 锁有四个状态:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁。锁只能升级不能降级。偏向锁消除单线程重复加锁的开销;轻量级锁通过 CAS 自旋避免线程阻塞;自旋失败后膨胀为重量级锁,线程进入阻塞状态。
2) 深度解析版
锁升级全流程:
┌────────────┐
│ 无锁状态 │ 对象刚创建,无线程访问
└──────┬─────┘
│ 线程A 第一次获取锁
▼
┌────────────┐
│ 偏向锁 │ Mark Word 记录线程A的ID
│ (Biased) │ 后续线程A获取锁无需CAS
└──────┬─────┘
│ 线程B 尝试获取锁(竞争出现)
│ 撤销偏向锁(STW)
▼
┌────────────┐
│ 轻量级锁 │ CAS 自旋尝试获取锁
│(Lightweight)│ 不阻塞线程
└──────┬─────┘
│ 自旋超过阈值或竞争加剧
▼
┌────────────┐
│ 重量级锁 │ 线程被操作系统挂起(阻塞)
│(Heavyweight)│ 依赖 OS Mutex
└────────────┘
Mark Word 在不同锁状态下的结构(64 位 JVM):
| 锁状态 | Mark Word 内容(部分) | 标志位 |
|---|---|---|
| 无锁 | hashcode / age / 0 / 01 | 001 |
| 偏向锁 | threadId / epoch / age / 1 / 01 | 101 |
| 轻量级锁 | 指向栈中锁记录的指针 / 00 | 00 |
| 重量级锁 | 指向 ObjectMonitor 的指针 / 10 | 10 |
| GC 标记 | 空 / 11 | 11 |
各阶段详解:
1. 偏向锁
- 场景:只有一个线程反复进入同步块(研究表明大多数锁在整个生命周期中只被同一个线程访问)
- 原理:第一次 CAS 将线程 ID 写入 Mark Word,后续检查 Mark Word 中是否为自己的 ID,如果是则直接进入,无需任何同步操作
- 撤销:出现第二个线程竞争时,在全局安全点(STW)撤销偏向锁
2. 轻量级锁
- 场景:多个线程交替执行同步块(竞争不激烈)
- 原理:线程在栈帧中创建 Lock Record,CAS 将 Mark Word 替换为指向 Lock Record 的指针
- 自旋:获取失败后不立即阻塞,而是自旋等待(忙等待)
3. 重量级锁
- 场景:多线程同时竞争锁
- 原理:膨胀为 ObjectMonitor(C++ 实现),通过操作系统 Mutex 实现互斥,竞争失败的线程被挂起
- 代价:涉及用户态到内核态的切换,成本高
⚠️ 高频易错点:
- 锁只能升级,不能降级(GC 除外)。
- JDK 15 之后默认禁用了偏向锁(
-XX:-UseBiasedLocking),因为偏向锁的撤销需要 STW,在多线程应用中反而影响性能。- 调用了对象的
hashCode()方法后,偏向锁会直接升级为轻量级锁(因为 Mark Word 空间被 hashcode 占用)。
3) 加分项
可以用 JOL(Java Object Layout)工具观察锁升级:
// 添加依赖:org.openjdk.jol:jol-core
Object obj = new Object();
System.out.println(ClassLayout.parseInstance(obj).toPrintable()); // 查看 Mark Word
synchronized (obj) {
System.out.println(ClassLayout.parseInstance(obj).toPrintable()); // 锁状态变化
}
JDK 15+ 移除偏向锁后,锁升级流程简化为:无锁 -> 轻量级锁 -> 重量级锁。在高并发场景下,这反而提升了性能,因为省去了偏向锁撤销的 STW 开销。
Q9: 什么是公平锁和非公平锁?
【题型:对比类】
1) 面试直答版
公平锁按照线程请求锁的顺序获取锁(FIFO),先到先得;非公平锁允许插队,新来的线程有机会直接获取锁而不用排队。非公平锁性能更好(减少线程切换),是 ReentrantLock 的默认模式。
2) 深度解析版
公平 vs 非公平获取锁的流程:
公平锁获取流程:
线程C 请求锁 -> 检查队列 -> 有人排队? -> 是 -> 排队等待
-> 否 -> CAS 尝试获取
非公平锁获取流程:
线程C 请求锁 -> 先 CAS 尝试获取 -> 成功 -> 获取锁(插队成功!)
-> 失败 -> 入队排队
为什么非公平锁吞吐量更高?
假设线程 A 持有锁,线程 B 在排队等待。当 A 释放锁时:
- 公平锁:唤醒 B(需要上下文切换,耗时约 10-30 微秒),在 B 被唤醒的过程中锁空闲。
- 非公平锁:此时线程 C 恰好来请求锁,C 直接 CAS 获取成功,在 B 被唤醒前已经执行完毕并释放了锁。B 醒来后再获取锁。这样锁的空闲时间更短。
代码示例:
// 非公平锁(默认)
ReentrantLock unfairLock = new ReentrantLock();
ReentrantLock unfairLock2 = new ReentrantLock(false);
// 公平锁
ReentrantLock fairLock = new ReentrantLock(true);
源码层面的区别(AQS):
// 非公平锁的 lock()
final void lock() {
if (compareAndSetState(0, 1)) // 先直接 CAS 尝试(插队)
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1); // 失败再走正常流程
}
// 公平锁的 lock()
final void lock() {
acquire(1); // 直接走正常流程(检查队列)
}
// 公平锁的 tryAcquire() 多了一个 hasQueuedPredecessors() 检查
protected final boolean tryAcquire(int acquires) {
if (hasQueuedPredecessors()) // 队列中有人在等?
return false; // 公平锁不插队
// ...
}
⚠️ 高频易错点:
synchronized是非公平锁,且无法改为公平锁。只有ReentrantLock可以选择公平或非公平。
3) 加分项
公平锁的适用场景:需要严格按顺序处理的业务(如按请求先后排队的秒杀系统)。但在绝大多数场景下,非公平锁足够且性能更好。
ReentrantReadWriteLock 也支持公平/非公平模式。在写少读多的场景下,非公平读写锁可能导致写线程饥饿(读线程不断插队导致写线程迟迟拿不到锁),此时可以考虑公平模式或使用 JDK 8 引入的 StampedLock。
Q10: 什么是可重入锁?为什么需要可重入?
【题型:底层原理类】
1) 面试直答版
可重入锁是指同一个线程可以多次获取同一把锁而不会死锁。synchronized 和 ReentrantLock 都是可重入锁。如果锁不可重入,同一线程在持有锁时再次请求该锁就会死锁。
2) 深度解析版
可重入的含义:
public class ReentrantDemo {
public synchronized void methodA() {
System.out.println("methodA");
methodB(); // 在持有锁的情况下调用另一个 synchronized 方法
}
public synchronized void methodB() {
// 如果锁不可重入,这里会死锁!
// 因为 methodA 已经持有锁,methodB 又尝试获取同一把锁
System.out.println("methodB");
}
}
如果锁不可重入,会发生什么?
线程A 调用 methodA() -> 获取 this 锁 -> 成功
线程A 在 methodA() 中调用 methodB() -> 尝试获取 this 锁
-> 锁已被占用(自己占的)-> 等待锁释放
-> 但自己在等待,无法执行 methodA() 的剩余代码来释放锁
-> 死锁!
可重入的实现原理:
锁内部维护了一个持有线程 + 重入计数器:
// 简化的可重入锁实现原理
class SimplifiedReentrantLock {
private Thread owner = null;
private int holdCount = 0;
public synchronized void lock() {
Thread current = Thread.currentThread();
if (owner == current) {
holdCount++; // 同一线程重入,计数器+1
return;
}
while (owner != null) {
wait(); // 其他线程持有锁,等待
}
owner = current;
holdCount = 1;
}
public synchronized void unlock() {
if (owner != Thread.currentThread()) throw new IllegalMonitorStateException();
holdCount--;
if (holdCount == 0) { // 计数器归零才真正释放锁
owner = null;
notifyAll();
}
}
}
在 AQS 中,state 字段就充当了重入计数器的角色:
state = 0:锁未被持有state = 1:锁被持有,第一次获取state = n:锁被同一线程重入了 n 次
⚠️ 高频易错点:
lock()和unlock()必须成对出现。重入 N 次就必须释放 N 次,否则锁不会真正释放。
3) 加分项
可重入在递归调用和调用链中特别重要。例如 Spring 的 @Transactional 事务传播行为中,如果外层方法已经持有了数据库连接的锁,内层事务方法也需要使用同一连接,可重入特性确保不会死锁。
不可重入锁在实际开发中几乎不使用,但在某些嵌入式或内核场景下存在(如 Linux 的自旋锁 spin_lock 就是不可重入的)。
Q11: 什么是死锁?如何排查和预防?
【题型:场景设计类 | 高频重点】
1) 面试直答版
死锁是两个或多个线程互相持有对方需要的锁,导致所有线程永久阻塞。死锁需要同时满足四个条件:互斥、持有并等待、不可剥夺、循环等待。预防死锁的核心是破坏这四个条件之一,最常用的方法是按固定顺序获取锁。
2) 深度解析版
死锁示例:
Object lockA = new Object();
Object lockB = new Object();
// 线程1:先拿A,再拿B
new Thread(() -> {
synchronized (lockA) {
System.out.println("线程1 持有 lockA");
try { Thread.sleep(100); } catch (Exception e) {}
synchronized (lockB) { // 等待线程2释放 lockB
System.out.println("线程1 持有 lockA + lockB");
}
}
}).start();
// 线程2:先拿B,再拿A
new Thread(() -> {
synchronized (lockB) {
System.out.println("线程2 持有 lockB");
try { Thread.sleep(100); } catch (Exception e) {}
synchronized (lockA) { // 等待线程1释放 lockA
System.out.println("线程2 持有 lockB + lockA");
}
}
}).start();
// 结果:两个线程互相等待,永远阻塞
死锁的四个必要条件:
| 条件 | 说明 | 破坏方式 |
|---|---|---|
| 互斥 | 资源同一时刻只能被一个线程持有 | 使用无锁算法(CAS) |
| 持有并等待 | 持有一个资源的同时等待另一个 | 一次性申请所有资源 |
| 不可剥夺 | 已获取的资源不能被强行夺走 | tryLock() 超时释放 |
| 循环等待 | 形成首尾相连的等待环 | 按固定顺序获取锁 |
排查死锁的方法:
# 方法1:jstack 查看线程堆栈
jstack <pid>
# 输出中会显示 "Found one Java-level deadlock"
# 方法2:jconsole / jvisualvm 图形化工具
# 连接到 JVM 后,"线程"标签页有"检测死锁"按钮
# 方法3:代码中使用 ThreadMXBean
ThreadMXBean mxBean = ManagementFactory.getThreadMXBean();
long[] deadlockedThreads = mxBean.findDeadlockedThreads();
if (deadlockedThreads != null) {
ThreadInfo[] infos = mxBean.getThreadInfo(deadlockedThreads, true, true);
for (ThreadInfo info : infos) {
System.out.println(info);
}
}
预防策略:
// 策略1:按固定顺序获取锁(最常用)
// 通过 System.identityHashCode 确定锁的顺序
private void lockInOrder(Object lock1, Object lock2) {
int hash1 = System.identityHashCode(lock1);
int hash2 = System.identityHashCode(lock2);
if (hash1 < hash2) {
synchronized (lock1) { synchronized (lock2) { /* 业务 */ } }
} else if (hash1 > hash2) {
synchronized (lock2) { synchronized (lock1) { /* 业务 */ } }
} else {
// hash 冲突时使用 tie-breaking lock
synchronized (tieLock) {
synchronized (lock1) { synchronized (lock2) { /* 业务 */ } }
}
}
}
// 策略2:使用 tryLock 超时机制
boolean gotLockA = lockA.tryLock(1, TimeUnit.SECONDS);
boolean gotLockB = lockB.tryLock(1, TimeUnit.SECONDS);
if (gotLockA && gotLockB) {
try { /* 业务 */ }
finally {
lockA.unlock();
lockB.unlock();
}
} else {
// 获取失败,释放已获取的锁,重试或放弃
if (gotLockA) lockA.unlock();
if (gotLockB) lockB.unlock();
}
⚠️ 高频易错点:面试时不要只会说”四个条件”,面试官更关注你如何在实际代码中预防和排查死锁。
3) 加分项
在分布式系统中,死锁问题更复杂。分布式锁的死锁预防通常使用:
- 锁超时:Redis 分布式锁的 TTL / ZooKeeper 的临时节点(客户端断开自动释放)
- 看门狗机制:Redisson 的 WatchDog 定时续期,客户端真正宕机后锁自动过期
- 锁顺序协议:所有服务按统一的资源 ID 排序获取锁
数据库层面的死锁,MySQL InnoDB 有死锁检测机制(wait-for graph),检测到死锁后会选择一个事务回滚。
Q12: 乐观锁和悲观锁的区别?
【题型:对比类】
1) 面试直答版
悲观锁假设并发冲突一定会发生,先加锁再操作(如 synchronized、ReentrantLock);乐观锁假设冲突很少发生,先操作再检测冲突,冲突时重试(如 CAS、数据库版本号机制)。读多写少用乐观锁,写多用悲观锁。
2) 深度解析版
对比表:
| 维度 | 悲观锁 | 乐观锁 |
|---|---|---|
| 核心思想 | ”总会有冲突" | "基本不会冲突” |
| 加锁时机 | 操作前加锁 | 不加锁,提交时检测冲突 |
| Java 实现 | synchronized / ReentrantLock | CAS(AtomicInteger 等) |
| 数据库实现 | SELECT ... FOR UPDATE | 版本号 / 时间戳 |
| 适用场景 | 写多读少,竞争激烈 | 读多写少,竞争不激烈 |
| 开销 | 线程阻塞/唤醒的开销 | 自旋重试的 CPU 开销 |
| 死锁风险 | 有 | 无 |
数据库乐观锁实现:
-- 版本号方式
UPDATE product SET stock = stock - 1, version = version + 1
WHERE id = 100 AND version = #{oldVersion};
-- 如果 affectedRows == 0,说明有冲突,需要重试
Java CAS 乐观锁:
AtomicInteger count = new AtomicInteger(0);
// CAS 操作:期望值为 0,更新为 1
boolean success = count.compareAndSet(0, 1);
// 底层是 Unsafe.compareAndSwapInt(),CPU 指令级别保证原子性
⚠️ 高频易错点:乐观锁并不是真的”锁”,它只是一种并发控制策略。在竞争非常激烈的场景下,乐观锁的大量重试反而比悲观锁更浪费 CPU。
3) 加分项
实际项目中的选型:
- 缓存更新:乐观锁(Redis WATCH + MULTI/EXEC)
- 库存扣减:乐观锁(版本号或
stock > 0条件) - 转账操作:悲观锁(需要保证强一致性)
- 计数器:乐观锁(
AtomicLong/LongAdder)
JDK 8 的 StampedLock 提供了一种乐观读锁,在无竞争时比 ReentrantReadWriteLock 性能更高。
Q13: 读写锁是什么?适用什么场景?
【题型:底层原理类】
1) 面试直答版
读写锁将锁分为读锁(共享锁)和写锁(独占锁)。多个线程可以同时持有读锁,但写锁是独占的。读读并发、读写互斥、写写互斥。适用于读多写少的场景,如缓存。
2) 深度解析版
读写锁的规则:
| 当前状态 | 读锁请求 | 写锁请求 |
|---|---|---|
| 无锁 | 允许 | 允许 |
| 持有读锁 | 允许(共享) | 阻塞(互斥) |
| 持有写锁 | 阻塞(互斥) | 阻塞(互斥) |
ReentrantReadWriteLock 使用示例:
class CachedData {
private Object data;
private volatile boolean cacheValid;
private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
private final Lock readLock = rwLock.readLock();
private final Lock writeLock = rwLock.writeLock();
public Object getData() {
readLock.lock();
try {
if (cacheValid) return data;
} finally {
readLock.unlock();
}
// 缓存失效,获取写锁更新
writeLock.lock();
try {
if (!cacheValid) { // double check
data = loadFromDB();
cacheValid = true;
}
return data;
} finally {
writeLock.unlock();
}
}
}
锁降级(写锁 -> 读锁):
writeLock.lock();
try {
data = loadFromDB();
readLock.lock(); // 在持有写锁时获取读锁(降级)
} finally {
writeLock.unlock(); // 释放写锁,此时仍持有读锁
}
try {
use(data); // 在读锁保护下使用数据
} finally {
readLock.unlock();
}
AQS 中读写锁的 state 设计:
ReentrantReadWriteLock 使用 AQS 的 state 字段的高 16 位表示读锁持有次数,低 16 位表示写锁重入次数:
state (32 位 int)
|<-- 高16位:读锁计数 -->|<-- 低16位:写锁计数 -->|
⚠️ 高频易错点:
- 读写锁支持锁降级(持有写锁时获取读锁,然后释放写锁),但不支持锁升级(持有读锁时获取写锁会死锁)。
- 写锁饥饿问题:在非公平模式下,如果读请求源源不断,写线程可能一直拿不到锁。
3) 加分项
JDK 8 引入了 StampedLock,提供了乐观读(Optimistic Read)模式:
StampedLock sl = new StampedLock();
long stamp = sl.tryOptimisticRead(); // 乐观读,不加锁
int x = this.x;
int y = this.y;
if (!sl.validate(stamp)) {
// 乐观读期间有写操作,升级为悲观读锁
stamp = sl.readLock();
try {
x = this.x;
y = this.y;
} finally {
sl.unlockRead(stamp);
}
}
StampedLock 不可重入,适合在读远多于写的场景下替代 ReentrantReadWriteLock,性能提升显著。
Q14: synchronized 做了哪些优化?
【题型:底层原理类】
1) 面试直答版
JDK 6 对 synchronized 做了大量优化:锁升级(偏向锁、轻量级锁)、锁消除(JIT 消除不必要的锁)、锁粗化(合并相邻的加锁操作)、自适应自旋(根据历史情况调整自旋次数)。
2) 深度解析版
四大优化详解:
1. 锁消除(Lock Elimination)
JIT 编译器通过逃逸分析,如果发现一个对象只在方法内部使用、不会被其他线程访问,就会消除对它的加锁操作。
// 看似有锁操作,实际被 JIT 优化消除
public String concat(String s1, String s2) {
// StringBuffer 是线程安全的(内部用 synchronized)
// 但这里 sb 是局部变量,不会逃逸到方法外
StringBuffer sb = new StringBuffer();
sb.append(s1); // synchronized 会被消除
sb.append(s2); // synchronized 会被消除
return sb.toString();
}
2. 锁粗化(Lock Coarsening)
如果一系列连续操作都对同一个对象加锁,JVM 会将多次加锁合并为一次大范围的加锁:
// 优化前:每次循环都加锁/解锁
for (int i = 0; i < 100; i++) {
synchronized (lock) {
doSomething(i);
}
}
// JVM 优化后:一次加锁覆盖整个循环
synchronized (lock) {
for (int i = 0; i < 100; i++) {
doSomething(i);
}
}
3. 自适应自旋(Adaptive Spinning)
轻量级锁获取失败后,线程不会立即阻塞,而是自旋等待。JVM 会根据上一次在同一把锁上的自旋时间和结果来调整:
- 上次自旋成功了 -> 这次允许更长的自旋时间
- 上次自旋失败了 -> 这次减少自旋甚至不自旋,直接阻塞
4. 锁升级(Lock Escalation)
即 Q8 中详述的:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁。
⚠️ 高频易错点:锁消除依赖于 JIT 的逃逸分析,解释执行时不会生效。可以通过
-XX:+DoEscapeAnalysis(默认开启)和-XX:+EliminateLocks控制。
3) 加分项
正是因为这些优化,JDK 6 之后 synchronized 和 ReentrantLock 的性能差距已经非常小。在无竞争或低竞争场景下,synchronized 甚至更快(偏向锁几乎零开销)。这也是为什么 JDK 源码中(如 ConcurrentHashMap JDK 8)开始回归使用 synchronized 而非 ReentrantLock——Doug Lea 说过,ConcurrentHashMap 使用 synchronized 是因为 JVM 团队会持续优化 synchronized,而 ReentrantLock 只能在 Java 层面优化。
三、底层原理(8 题)
Q15: 什么是 Java 内存模型(JMM)?
【题型:底层原理类 | 高频重点】
1) 面试直答版
JMM(Java Memory Model)定义了 Java 程序中多线程如何通过内存交互。核心概念是主内存和工作内存:所有变量存储在主内存中,每个线程有自己的工作内存(本地缓存)。JMM 主要解决三个问题:可见性、原子性、有序性。
2) 深度解析版
JMM 架构图:
线程A 线程B
┌──────────┐ ┌──────────┐
│ 工作内存A │ │ 工作内存B │
│ (CPU缓存) │ │ (CPU缓存) │
│ x = 1 │ │ x = 0 │ <- 可能不一致!
└─────┬────┘ └─────┬────┘
│ read/write │
└────────┬────────────┘
│
┌─────▼─────┐
│ 主内存 │
│ (堆/方法区) │
│ x = 1 │
└───────────┘
三大特性:
| 特性 | 说明 | 问题 | 解决方案 |
|---|---|---|---|
| 可见性 | 一个线程修改的值,其他线程能否立即看到 | 工作内存缓存导致读到旧值 | volatile / synchronized / final |
| 原子性 | 一个操作是否不可分割 | i++ 不是原子操作(读-改-写) | synchronized / Atomic* |
| 有序性 | 代码执行顺序是否与编写顺序一致 | 编译器/CPU 指令重排序 | volatile / synchronized / happens-before |
JMM 定义的 8 种内存交互操作:
lock:把主内存变量标记为线程独占unlock:释放主内存变量的独占read:从主内存读取变量值到工作内存load:将 read 的值放入工作内存的变量副本use:将工作内存的值传给执行引擎assign:将执行引擎的值赋给工作内存的变量store:将工作内存的值传送到主内存write:将 store 的值写入主内存变量
指令重排序的三个层面:
源代码 -> 编译器优化重排 -> 指令级并行重排 -> 内存系统重排 -> 最终执行
JMM 通过**内存屏障(Memory Barrier)**来禁止特定类型的重排序,保证程序的正确性。
⚠️ 高频易错点:
- JMM 是一个抽象模型,并不真实存在。它是 Java 对硬件内存架构(多级缓存、写缓冲区等)的抽象。
- “工作内存”不等于”线程栈”,它是 CPU 缓存、寄存器、写缓冲区等的抽象。
- JMM 不保证 64 位 long/double 操作的原子性(但现代 64 位 JVM 实际上保证了)。
3) 加分项
JMM 的设计目标是在程序员易用性和编译器/硬件优化之间找到平衡。它不禁止所有重排序,而是只禁止会改变程序语义的重排序。happens-before 规则就是这个平衡点的体现——只要遵循 happens-before 规则,程序员不需要关心底层如何重排序。
在 ARM 等弱内存模型的 CPU 架构上,JMM 需要插入更多内存屏障来保证语义;在 x86(TSO 模型)上则不需要那么多屏障。这也是为什么同样的 Java 程序在不同硬件上可能有不同的并发行为——JMM 统一了这些差异。
Q16: volatile 关键字的作用和原理?
【题型:底层原理类 | 高频重点】
1) 面试直答版
volatile 有两个作用:保证可见性(一个线程修改变量后,其他线程立即可见)和禁止指令重排序(通过内存屏障)。但 volatile 不保证原子性。
2) 深度解析版
1. 可见性保证
// 没有 volatile,线程B 可能永远看不到 flag 的变化
// 因为线程B 可能一直读取自己工作内存中的缓存值
volatile boolean flag = false;
// 线程A
flag = true; // 立即刷新到主内存
// 线程B
while (!flag) { // 每次从主内存读取最新值
// 没有 volatile,这个循环可能永远不退出
}
2. 禁止重排序
// 经典场景:DCL 单例模式
class Singleton {
// 必须加 volatile!
private static volatile Singleton instance;
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton(); // 这行有问题
}
}
}
return instance;
}
}
instance = new Singleton() 在字节码层面分为三步:
- 分配内存空间
- 调用构造函数初始化
- 将引用赋值给 instance
如果没有 volatile,步骤 2 和 3 可能被重排序为 1-3-2。线程B 在步骤 3 执行后看到 instance != null,直接返回了一个未完成初始化的对象。
3. 内存屏障原理
volatile 读写操作会在字节码中插入内存屏障指令:
| 操作 | 屏障类型 | 效果 |
|---|---|---|
| volatile 写之前 | StoreStore | 前面的写操作先完成 |
| volatile 写之后 | StoreLoad | 写操作先于后续读操作 |
| volatile 读之后 | LoadLoad | 读操作先于后续读操作 |
| volatile 读之后 | LoadStore | 读操作先于后续写操作 |
在 x86 架构上,volatile 写操作对应 lock 前缀指令,它的作用:
- 将当前 CPU 缓存行写回主内存
- 使其他 CPU 中缓存了该地址的缓存行失效(MESI 协议)
⚠️ 高频易错点:
- volatile 的可见性保证是立即的(写完立刻可见),而 synchronized 的可见性是在释放锁时刷新。
- volatile 不能替代 synchronized,因为不保证原子性。
- volatile 数组只保证引用的可见性,不保证数组元素的可见性。
3) 加分项
volatile 的典型应用场景:
- 状态标志位:如
volatile boolean running = true; - DCL 单例:防止指令重排
- 独立观察:多个线程读取传感器数据,一个线程写入
- 开销较低的读写锁策略:
volatile读 +synchronized写
在 JMM 层面,volatile 的语义可以用 happens-before 来描述:对一个 volatile 变量的写操作 happens-before 于后续对这个变量的读操作。
Q17: volatile 能保证原子性吗?为什么?
【题型:底层原理类 | 经典陷阱题】
1) 面试直答版
不能。 volatile 只保证可见性和有序性,不保证原子性。经典的反例是 volatile int i; i++;——i++ 本质上是”读-改-写”三步操作,volatile 无法保证这三步的原子性。
2) 深度解析版
i++ 的字节码分析:
volatile int count = 0;
// count++ 编译后的字节码:
// 1. getfield #count // 从主内存读取 count 的值到操作数栈(volatile 读)
// 2. iconst_1 // 将常量 1 压入操作数栈
// 3. iadd // 栈顶两个值相加
// 4. putfield #count // 将结果写回主内存(volatile 写)
并发问题演示:
时刻 线程A 线程B count(主内存)
T1 读取 count = 0 0
T2 读取 count = 0 0
T3 计算 0 + 1 = 1 0
T4 计算 0 + 1 = 1 0
T5 写入 count = 1 1
T6 写入 count = 1 1
期望结果: 2,实际结果: 1(更新丢失)
线程A 和 线程B 都读到了 count=0(volatile 保证了可见性,读到的都是最新值),但在各自计算后写回时,发生了写覆盖。问题不在于可见性,而在于”读-改-写”不是一个原子操作。
代码验证:
volatile int count = 0;
// 10 个线程各累加 10000 次
for (int i = 0; i < 10; i++) {
new Thread(() -> {
for (int j = 0; j < 10000; j++) {
count++; // 非原子操作
}
}).start();
}
Thread.sleep(3000);
System.out.println(count); // 结果小于 100000
正确的解决方案:
// 方案1:synchronized
synchronized (this) { count++; }
// 方案2:AtomicInteger(推荐)
AtomicInteger count = new AtomicInteger(0);
count.incrementAndGet(); // CAS 保证原子性
// 方案3:LongAdder(高并发推荐)
LongAdder count = new LongAdder();
count.increment();
⚠️ 高频易错点:volatile 对单个变量的读或写是原子的(如
volatile long l;的赋值操作),但对复合操作(读-改-写、check-then-act)不保证原子性。
3) 加分项
这道题的本质是理解 volatile 解决的问题域。volatile 解决的是缓存一致性问题(CPU 缓存和主内存的同步),而原子性问题的根源是竞态条件(race condition),需要用锁或 CAS 来解决。
一个有趣的点:volatile long 的赋值在 32 位 JVM 上不是原子的(long 是 64 位,需要两次 32 位写操作),但在 64 位 JVM 上是原子的。JMM 对 volatile long/double 做了特殊规定,要求其读写必须是原子的。
Q18: happens-before 是什么?
【题型:底层原理类】
1) 面试直答版
happens-before 是 JMM 定义的一组规则,用于确定一个操作的结果对另一个操作是否可见。它不是指时间上的先后,而是指可见性保证:如果操作 A happens-before 操作 B,那么 A 的结果对 B 可见。
2) 深度解析版
JMM 定义的 8 条 happens-before 规则:
| 规则 | 说明 |
|---|---|
| 程序顺序规则 | 同一线程中,前面的操作 HB 后面的操作 |
| 监视器锁规则 | unlock 操作 HB 后续的 lock 操作(同一把锁) |
| volatile 规则 | volatile 写 HB 后续的 volatile 读(同一变量) |
| 线程启动规则 | Thread.start() HB 该线程中的所有操作 |
| 线程终止规则 | 线程中的所有操作 HB Thread.join() 的返回 |
| 中断规则 | thread.interrupt() HB 被中断线程检测到中断事件 |
| 终结器规则 | 构造函数完成 HB finalize() 方法开始 |
| 传递性 | 如果 A HB B,B HB C,则 A HB C |
关键理解:happens-before 不等于时间先后
int x = 0;
volatile boolean flag = false;
// 线程A
x = 42; // 操作1
flag = true; // 操作2(volatile 写)
// 线程B
if (flag) { // 操作3(volatile 读)
int r = x; // 操作4,r 一定等于 42
}
推导过程:
- 操作1 HB 操作2(程序顺序规则)
- 操作2 HB 操作3(volatile 规则)
- 操作3 HB 操作4(程序顺序规则)
- 由传递性:操作1 HB 操作4,所以 x=42 对操作4 可见
没有 happens-before 关系的情况:
int a = 0, b = 0;
// 线程A // 线程B
a = 1; b = 1;
int r1 = b; int r2 = a;
// 可能出现 r1 = 0, r2 = 0(重排序 + 没有 HB 关系)
⚠️ 高频易错点:
- happens-before 描述的是可见性保证,不是时间顺序。即使 A 在时间上先于 B 执行,如果没有 HB 关系,B 不一定能看到 A 的结果。
- 程序顺序规则只在同一个线程内有效,跨线程需要通过其他规则建立 HB 关系。
3) 加分项
理解 happens-before 的关键是理解 JMM 的设计哲学:JMM 给程序员提供了足够强的内存可见性保证(通过 HB 规则),同时给编译器和处理器留下了足够的优化空间(在不违反 HB 规则的前提下可以任意重排序)。
这也是为什么 Java 的并发 bug 难以复现——只要没有 HB 关系,指令重排序和缓存不一致的行为就是未定义的,可能在某些硬件或 JVM 版本上表现正确,在另一些上出问题。
Q19: 什么是 CAS?有什么问题?
【题型:底层原理类 | 高频重点】
1) 面试直答版
CAS(Compare-And-Swap)是一种无锁的原子操作:比较内存值与预期值,相等则更新为新值,否则不更新。Java 中通过 Unsafe.compareAndSwap* 实现,底层对应 CPU 的 cmpxchg 指令。CAS 的三个问题:ABA 问题、自旋开销大、只能保证单个变量的原子性。
2) 深度解析版
CAS 操作流程:
CAS(V, Expected, New)
V = 内存地址
Expected = 预期的旧值
New = 要设置的新值
if (V 的当前值 == Expected) {
V = New;
return true; // 更新成功
} else {
return false; // 更新失败,需要重试
}
// 以上整个操作是原子的(CPU 指令保证)
Java 中的应用:
// AtomicInteger.incrementAndGet() 源码
public final int incrementAndGet() {
return U.getAndAddInt(this, VALUE, 1) + 1;
}
// Unsafe.getAndAddInt() 源码
public final int getAndAddInt(Object o, long offset, int delta) {
int v;
do {
v = getIntVolatile(o, offset); // 读取当前值
} while (!compareAndSwapInt(o, offset, v, v + delta)); // CAS 尝试更新
return v;
}
三大问题:
1. ABA 问题
初始值 A
线程1 读取值 A
线程2 将 A 改为 B
线程2 将 B 改回 A
线程1 CAS 比较:当前值 == A(预期值),更新成功
// 但实际上值已经被改过了!对于引用类型,对象可能已经被替换
解决方案:
// 使用 AtomicStampedReference(版本号)
AtomicStampedReference<Integer> ref = new AtomicStampedReference<>(1, 0);
int stamp = ref.getStamp(); // 获取版本号
ref.compareAndSet(1, 2, stamp, stamp + 1); // 同时比较值和版本号
// 或使用 AtomicMarkableReference(boolean 标记)
AtomicMarkableReference<Integer> ref2 = new AtomicMarkableReference<>(1, false);
2. 自旋开销
在高竞争场景下,CAS 大量失败导致一直自旋重试,浪费 CPU:
// 极端情况:100 个线程同时 CAS,每次只有 1 个成功,其余 99 个重试
while (!compareAndSwap(...)) {
// 一直循环,CPU 空转
}
解决方案:
LongAdder分段 CAS,减少竞争- 自旋一定次数后升级为锁(自适应)
3. 只能保证单个变量的原子性
// CAS 无法同时原子地更新两个变量
// 解决方案:将多个变量封装成一个对象
AtomicReference<Pair> ref = new AtomicReference<>(new Pair(x, y));
ref.compareAndSet(oldPair, new Pair(newX, newY));
⚠️ 高频易错点:
- CAS 底层依赖
Unsafe类,普通开发者不应直接使用Unsafe(Java 9+ 可用VarHandle替代)。- ABA 问题对基本类型通常没有实际影响,但对引用类型可能产生严重问题(如链表节点被替换)。
3) 加分项
CAS 是整个 java.util.concurrent 包的基石。AQS 的 state 修改、ConcurrentHashMap 的节点插入、AtomicInteger 的自增,底层都是 CAS。
在超高并发场景下,LongAdder 比 AtomicLong 性能好得多(10 倍以上),原理是空间换时间:将一个值分散到多个 Cell 中,每个线程 CAS 不同的 Cell,最后 sum() 求和。这也是 ConcurrentHashMap 的 size() 实现思路。
Q20: AQS 是什么?原理是什么?
【题型:底层原理类 | 高级】
1) 面试直答版
AQS(AbstractQueuedSynchronizer,抽象队列同步器)是 JUC 中锁和同步工具的核心框架。它维护了一个 volatile int state(表示同步状态)和一个CLH 变体双向队列(管理等待线程)。ReentrantLock、Semaphore、CountDownLatch 等都是基于 AQS 实现的。
2) 深度解析版
AQS 核心结构:
AQS
├── volatile int state // 同步状态(0=未锁定,>0=已锁定/重入次数)
├── Thread exclusiveOwner // 独占锁的持有线程
└── CLH Queue (双向链表) // 等待队列
┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐
│ head │ <-> │Node A│ <-> │Node B│ <-> │ tail │
│(哨兵)│ │SIGNAL│ │SIGNAL│ │ 0 │
└──────┘ └──────┘ └──────┘ └──────┘
Node 节点的关键字段:
| 字段 | 说明 |
|---|---|
waitStatus | SIGNAL(-1)=需要唤醒后继,CANCELLED(1)=已取消,CONDITION(-2)=在条件队列,PROPAGATE(-3)=传播共享唤醒 |
prev / next | 双向链表前驱/后继 |
thread | 等待的线程 |
nextWaiter | 条件队列的下一个节点 / 标记共享(SHARED)或独占(EXCLUSIVE) |
独占锁获取流程(以 ReentrantLock 为例):
lock()
└── acquire(1)
├── tryAcquire(1) // 子类实现:CAS 修改 state
│ ├── 成功 -> 获取锁,返回
│ └── 失败 ↓
├── addWaiter(Node.EXCLUSIVE) // 包装成 Node,入队(CAS + 自旋)
└── acquireQueued(node, 1) // 队列中自旋尝试获取锁
└── for (;;) {
if (前驱是 head && tryAcquire 成功) {
设置自己为 head;
return; // 获取锁成功
}
shouldParkAfterFailedAcquire(); // 设置前驱的 waitStatus 为 SIGNAL
parkAndCheckInterrupt(); // LockSupport.park() 阻塞
}
独占锁释放流程:
unlock()
└── release(1)
├── tryRelease(1) // 子类实现:修改 state
│ └── state == 0 -> 真正释放锁
└── unparkSuccessor(head) // 唤醒后继节点 LockSupport.unpark()
模板方法模式——子类需要实现的方法:
| 方法 | 用途 | 实现者 |
|---|---|---|
tryAcquire(int) | 独占获取 | ReentrantLock |
tryRelease(int) | 独占释放 | ReentrantLock |
tryAcquireShared(int) | 共享获取 | Semaphore / CountDownLatch |
tryReleaseShared(int) | 共享释放 | Semaphore / CountDownLatch |
isHeldExclusively() | 是否独占持有 | ReentrantLock |
基于 AQS 实现的工具对比:
| 工具 | state 含义 | 模式 |
|---|---|---|
| ReentrantLock | 重入次数(0=未锁) | 独占 |
| Semaphore | 剩余许可数 | 共享 |
| CountDownLatch | 倒计数值 | 共享 |
| ReentrantReadWriteLock | 高16位=读锁计数,低16位=写锁计数 | 混合 |
⚠️ 高频易错点:
- AQS 队列的 head 节点是哨兵节点(dummy node),不代表任何线程。
- 线程在队列中不是一直自旋的,而是先尝试,失败后
park()阻塞,被前驱唤醒后再尝试。tryAcquire返回 false 不代表永久失败,acquireQueued会在队列中反复尝试。
3) 加分项
自定义同步器示例(实现一个不可重入的互斥锁):
class SimpleLock extends AbstractQueuedSynchronizer {
@Override
protected boolean tryAcquire(int arg) {
if (compareAndSetState(0, 1)) {
setExclusiveOwnerThread(Thread.currentThread());
return true;
}
return false;
}
@Override
protected boolean tryRelease(int arg) {
setExclusiveOwnerThread(null);
setState(0); // volatile 写,保证可见性
return true;
}
public void lock() { acquire(1); }
public void unlock() { release(1); }
}
AQS 的设计体现了模板方法 + 策略模式:框架负责排队、阻塞、唤醒的复杂逻辑,子类只需要实现”如何判断是否能获取/释放同步状态”。这种设计极大简化了并发工具的开发。Doug Lea 的论文《The java.util.concurrent Synchronizer Framework》详细描述了 AQS 的设计思路。
Q21: ThreadLocal 原理?为什么会内存泄漏?
【题型:底层原理类 | 高频重点】
1) 面试直答版
ThreadLocal 为每个线程提供独立的变量副本,实现线程隔离。每个线程内部有一个 ThreadLocalMap,key 是 ThreadLocal 对象(弱引用),value 是存储的值(强引用)。内存泄漏的原因是:key 被回收后变成 null,但 value 仍然被 Entry 强引用,无法回收。尤其在线程池场景下,线程长期存活,泄漏会持续累积。
2) 深度解析版
数据结构:
Thread
└── ThreadLocal.ThreadLocalMap threadLocals
└── Entry[] table (开放地址法解决哈希冲突)
├── Entry(key=ThreadLocal-A [弱引用], value="值A")
├── Entry(key=ThreadLocal-B [弱引用], value="值B")
└── Entry(key=null [已被GC], value="泄漏的值") <- 内存泄漏!
ThreadLocalMap.Entry 的定义:
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value; // 注意:value 是强引用!
Entry(ThreadLocal<?> k, Object v) {
super(k); // key 是弱引用
value = v;
}
}
内存泄漏过程:
Step 1: ThreadLocal tl = new ThreadLocal();
tl.set(bigObject);
// 栈上的 tl 引用 -> ThreadLocal 对象 (强引用)
// Entry.key -> ThreadLocal 对象 (弱引用)
// Entry.value -> bigObject (强引用)
Step 2: tl = null; // 栈上的强引用断开
// 此时 ThreadLocal 对象只剩 Entry.key 的弱引用
// GC 后,key 被回收,Entry.key = null
Step 3: // Entry 仍然存在于 table 数组中
// Entry.value 仍然强引用着 bigObject
// bigObject 无法被 GC -> 内存泄漏!
ThreadLocal 的自清理机制:
ThreadLocal 在 get()、set()、remove() 时会顺带清理 key 为 null 的 Entry(expungeStaleEntry 方法)。但如果调用很少或线程一直存活不调用这些方法,泄漏就无法被清理。
最佳实践:
private static final ThreadLocal<UserContext> userContext = new ThreadLocal<>();
public void process() {
try {
userContext.set(new UserContext(...));
// 业务逻辑
} finally {
userContext.remove(); // 必须手动清理!
}
}
⚠️ 高频易错点:
- 内存泄漏的根本原因不是弱引用,而是线程池中线程长期存活。如果线程很快终止,ThreadLocalMap 会随线程一起被回收。
- 即使用强引用做 key 也会有问题(ThreadLocal 对象本身无法被回收),弱引用至少保证了 ThreadLocal 对象可以被回收,ThreadLocal 后续操作还能顺带清理 stale entry。
InheritableThreadLocal可以让子线程继承父线程的 ThreadLocal 值,但在线程池场景下也有问题(线程复用导致”继承”的是上一个任务的值)。
3) 加分项
在线程池中使用 ThreadLocal 时,阿里巴巴开源了 TransmittableThreadLocal(TTL),解决了线程池复用线程时 ThreadLocal 值传递的问题。其原理是在任务提交到线程池时,将当前线程的 ThreadLocal 值快照保存,执行任务前恢复,任务执行后还原。
Spring 中广泛使用 ThreadLocal:
RequestContextHolder:存储当前请求信息TransactionSynchronizationManager:存储事务上下文SecurityContextHolder:存储安全上下文
这些都需要在请求处理完毕后清理,Spring 通过 Filter/Interceptor 在 finally 中调用 remove()。
Q22: 什么是伪共享?如何解决?
【题型:底层原理类 | 高级】
1) 面试直答版
伪共享(False Sharing)是指多个不相关的变量被加载到同一个 CPU 缓存行中,一个线程修改其中一个变量会导致整个缓存行失效,其他线程即使访问的是不同变量也要重新加载。解决方法是缓存行填充(Padding),使每个变量独占一个缓存行。Java 8 的 @Contended 注解可以自动填充。
2) 深度解析版
缓存行概念:
CPU 缓存(L1/L2/L3)的最小读写单位是缓存行(Cache Line),通常为 64 字节。当读取一个变量时,会把它所在的整个缓存行都加载到缓存。
伪共享场景:
// 两个变量恰好在同一个缓存行中
class FalseSharingDemo {
volatile long x; // 线程A 频繁修改
volatile long y; // 线程B 频繁修改
}
// 内存布局(假设对象头 16 字节):
// | 对象头(16B) | x(8B) | y(8B) | ... |
// |<------------ 同一个缓存行 (64B) ------------>|
// 线程A 修改 x -> 缓存行失效 -> 线程B 缓存中的 y 也失效了!
// 线程B 重新加载缓存行 -> 修改 y -> 缓存行失效 -> 线程A 缓存中的 x 也失效!
// 两个线程互相"抖动"对方的缓存,性能急剧下降
CPU Core 0 CPU Core 1
┌──────────┐ ┌──────────┐
│ L1 Cache │ │ L1 Cache │
│ [x, y] │ ◄─失效─┐ │ [x, y] │
└──────────┘ │ └──────────┘
│ 修改 x │ │ 修改 y
└───────────────┘─────────┘
MESI 协议导致互相使缓存行失效
解决方案一:手动填充
class PaddedLong {
volatile long value;
// 填充 7 个 long(56 字节),加上 value(8 字节)= 64 字节
long p1, p2, p3, p4, p5, p6, p7;
}
解决方案二:@Contended 注解(JDK 8+)
// 需要 JVM 参数 -XX:-RestrictContended
@sun.misc.Contended
class ContendedLong {
volatile long value;
}
// JVM 自动在字段前后填充 128 字节(2 个缓存行宽度,防止预取)
JDK 源码中的实际应用:
// LongAdder 中的 Cell 类
@sun.misc.Contended
static final class Cell {
volatile long value;
// @Contended 确保每个 Cell 独占缓存行
// 避免多个线程 CAS 不同 Cell 时的伪共享
}
// Thread 类中的随机数种子
@sun.misc.Contended("tlr")
long threadLocalRandomSeed;
⚠️ 高频易错点:
@Contended注解默认只对 JDK 内部类有效,用户类需要添加 JVM 参数-XX:-RestrictContended。- 过度填充会浪费内存,只对高并发频繁写入的场景有必要使用。
- 现代 CPU 的缓存行通常是 64 字节,但有些架构(如某些 ARM)可能不同。
3) 加分项
Disruptor(LMAX 交易所开源的高性能队列)是伪共享优化的经典案例。它对 Sequence(类似原子计数器)做了缓存行填充,实现了远超 ArrayBlockingQueue 的性能。
性能差距可以非常夸张:在伪共享存在时,两个线程互相修改相邻变量的吞吐量可能只有优化后的1/10 甚至更低。这是因为每次缓存行失效都涉及 L1 -> L2 -> L3 -> 主内存的数据传输,延迟从 1ns 级别暴涨到 100ns 级别。
四、线程池(6 题)
Q23: 线程池的核心参数有哪些?
【题型:底层原理类 | 高频重点】
1) 面试直答版
线程池有 7 个核心参数:核心线程数(corePoolSize)、最大线程数(maximumPoolSize)、空闲线程存活时间(keepAliveTime)、时间单位(unit)、工作队列(workQueue)、线程工厂(threadFactory)、拒绝策略(handler)。
2) 深度解析版
餐厅类比帮助理解:
线程池 = 一家餐厅
corePoolSize = 正式员工数(3人):一直在岗,即使没客人
maximumPoolSize = 最大员工数(5人):忙不过来时临时招人
keepAliveTime = 临时工闲置时间(60秒):没活干就裁掉
workQueue = 等候区座位(10个):员工忙不过来时客人先排队
threadFactory = 招聘部门:负责创建新员工(起名字、设优先级)
handler = 满员策略:等候区也满了怎么办
构造函数参数详解:
public ThreadPoolExecutor(
int corePoolSize, // 核心线程数(常驻线程)
int maximumPoolSize, // 最大线程数
long keepAliveTime, // 非核心线程的空闲存活时间
TimeUnit unit, // 时间单位
BlockingQueue<Runnable> workQueue, // 任务队列
ThreadFactory threadFactory, // 线程工厂
RejectedExecutionHandler handler // 拒绝策略
)
| 参数 | 说明 | 建议 |
|---|---|---|
| corePoolSize | 即使空闲也不会被回收的线程数(除非设置 allowCoreThreadTimeOut) | 根据业务类型和 CPU 核心数确定 |
| maximumPoolSize | 线程池最大线程数 | ≥ corePoolSize |
| keepAliveTime | 非核心线程空闲超过此时间就会被回收 | 根据任务到达频率调整 |
| unit | keepAliveTime 的时间单位 | 通常用 TimeUnit.SECONDS |
| workQueue | 任务等待队列 | ArrayBlockingQueue(有界) / LinkedBlockingQueue(可无界,危险) |
| threadFactory | 创建线程的工厂 | 建议自定义,设置有意义的线程名 |
| handler | 队列满+线程满时的拒绝策略 | 根据业务选择 |
自定义线程工厂的最佳实践:
ThreadFactory factory = new ThreadFactory() {
private final AtomicInteger counter = new AtomicInteger(1);
@Override
public Thread newThread(Runnable r) {
Thread t = new Thread(r, "order-pool-" + counter.getAndIncrement());
t.setDaemon(false);
t.setUncaughtExceptionHandler((thread, ex) -> {
log.error("线程 {} 异常", thread.getName(), ex);
});
return t;
}
};
⚠️ 高频易错点:
corePoolSize设为 0 时,所有线程都是”临时工”,空闲时都会被回收。allowCoreThreadTimeOut(true)可以让核心线程也被回收。- 使用无界队列时,
maximumPoolSize参数无意义,因为队列永远不会满,永远不会创建非核心线程。
3) 加分项
在生产环境中,推荐使用 Guava 的 ThreadFactoryBuilder 或自定义 ThreadFactory 给线程池命名。线上排查问题时,jstack 输出的线程名如果都是 pool-1-thread-1 这种默认名,根本无法定位是哪个业务的线程池出了问题。
另外,线程池参数应该支持动态调整。美团开源了线程池动态配置框架,通过配置中心实时修改 corePoolSize、maximumPoolSize、workQueue 容量等参数,无需重启应用。ThreadPoolExecutor 提供了 setCorePoolSize()、setMaximumPoolSize() 等方法支持运行时修改。
Q24: 线程池的执行流程?
【题型:底层原理类 | 高频重点】
1) 面试直答版
提交任务后:先看核心线程是否已满 -> 未满则创建核心线程执行;已满则放入队列 -> 队列未满则入队等待;队列满则创建非核心线程 -> 非核心线程也满了则执行拒绝策略。
2) 深度解析版
执行流程图:
提交任务 execute(task)
│
▼
┌───────────────────────┐
│ 当前线程数 < corePoolSize? │
└───────────┬───────────┘
│ │
YES NO
│ │
▼ ▼
┌──────────┐ ┌──────────────────┐
│创建核心线程│ │ workQueue.offer() │
│执行任务 │ │ 队列未满? │
└──────────┘ └────────┬─────────┘
│ │
YES NO
│ │
▼ ▼
┌─────────┐ ┌────────────────────┐
│任务入队 │ │当前线程数<maximumPoolSize?│
│等待执行 │ └────────┬───────────┘
└─────────┘ │ │
YES NO
│ │
▼ ▼
┌──────────┐ ┌──────────┐
│创建非核心 │ │执行拒绝策略│
│线程执行任务│ │(Reject) │
└──────────┘ └──────────┘
源码级分析(execute 方法):
public void execute(Runnable command) {
int c = ctl.get(); // ctl 同时保存线程数和线程池状态
// Step 1: 当前线程数 < 核心线程数 -> 创建核心线程
if (workerCountOf(c) < corePoolSize) {
if (addWorker(command, true)) // true = 核心线程
return;
c = ctl.get();
}
// Step 2: 核心线程满了 -> 尝试入队
if (isRunning(c) && workQueue.offer(command)) {
int recheck = ctl.get();
// 双重检查:入队后线程池可能被关闭了
if (!isRunning(recheck) && remove(command))
reject(command);
// 如果线程数为 0(allowCoreThreadTimeOut 场景),创建一个线程
else if (workerCountOf(recheck) == 0)
addWorker(null, false);
}
// Step 3: 队列满了 -> 创建非核心线程
else if (!addWorker(command, false)) // false = 非核心线程
// Step 4: 非核心线程也满了 -> 拒绝
reject(command);
}
Worker 线程的运行逻辑:
// Worker.run() -> runWorker(this)
final void runWorker(Worker w) {
Runnable task = w.firstTask; // 第一个任务
w.firstTask = null;
while (task != null || (task = getTask()) != null) { // 循环从队列取任务
w.lock();
try {
beforeExecute(w.thread, task); // 钩子方法
task.run(); // 执行任务
afterExecute(task, null); // 钩子方法
} finally {
task = null;
w.completedTasks++;
w.unlock();
}
}
// getTask() 返回 null -> 线程退出
processWorkerExit(w, false);
}
⚠️ 高频易错点:
- 注意执行顺序:核心线程 -> 队列 -> 非核心线程 -> 拒绝。不是先填满所有线程再入队!
- 核心线程和非核心线程在创建后没有区别,线程池不会标记哪个是核心哪个是非核心,回收时只看线程总数是否超过 corePoolSize。
- 任务是先入队再创建非核心线程,这意味着如果队列是无界的,永远不会创建非核心线程。
3) 加分项
beforeExecute() 和 afterExecute() 是两个重要的钩子方法,可以用来:
- 记录任务执行时间
- 处理异常
- 设置/清理 ThreadLocal
@Override
protected void afterExecute(Runnable r, Throwable t) {
if (t != null) {
log.error("任务执行异常", t);
}
// 清理 ThreadLocal,避免内存泄漏
MDC.clear();
}
Q25: 为什么不推荐使用 Executors 创建线程池?
【题型:底层原理类 | 高频重点】
1) 面试直答版
Executors 工厂方法创建的线程池使用了无界队列或无限线程数,在高并发下可能导致 OOM(OutOfMemoryError)。阿里巴巴 Java 开发手册明确规定:线程池不允许使用 Executors 创建,而是通过 ThreadPoolExecutor 手动指定参数。
2) 深度解析版
四种工厂方法的风险:
| 工厂方法 | 创建的线程池 | 风险点 |
|---|---|---|
newFixedThreadPool(n) | 固定线程数 | 队列是 LinkedBlockingQueue(无界!),任务堆积可致 OOM |
newSingleThreadExecutor() | 单线程 | 同上,无界队列 |
newCachedThreadPool() | 可缓存线程池 | maximumPoolSize = Integer.MAX_VALUE,可能创建大量线程致 OOM |
newScheduledThreadPool(n) | 定时任务线程池 | DelayedWorkQueue 无界,同样有 OOM 风险 |
源码分析:
// FixedThreadPool -- 无界队列
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>()); // 默认容量 Integer.MAX_VALUE
}
// 风险:如果任务提交速度 > 处理速度,队列无限增长 -> OOM
// CachedThreadPool -- 无限线程
public static ExecutorService newCachedThreadPool() {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE, // 最大线程数无限!
60L, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>()); // SynchronousQueue 不存储元素
}
// 风险:每个任务可能创建一个新线程 -> 线程数爆炸 -> OOM
正确做法:
// 手动创建,参数可控
ThreadPoolExecutor pool = new ThreadPoolExecutor(
10, // 核心线程数
20, // 最大线程数
60, TimeUnit.SECONDS, // 空闲线程存活时间
new ArrayBlockingQueue<>(200), // 有界队列!
new ThreadFactory() { // 自定义线程工厂
private final AtomicInteger num = new AtomicInteger(1);
@Override
public Thread newThread(Runnable r) {
return new Thread(r, "biz-pool-" + num.getAndIncrement());
}
},
new ThreadPoolExecutor.CallerRunsPolicy() // 合适的拒绝策略
);
⚠️ 高频易错点:
LinkedBlockingQueue默认容量是Integer.MAX_VALUE(约 21 亿),几乎等于无界。SynchronousQueue不存储任何元素(容量为 0),每个 put 操作必须等待一个 take 操作。- 即使使用了有界队列,队列容量也不宜过大,应根据业务处理能力合理设置。
3) 加分项
在 Spring Boot 中,推荐使用 @Configuration + @Bean 创建线程池,并注册为 Spring Bean 统一管理生命周期:
@Bean("orderPool")
public ThreadPoolExecutor orderThreadPool() {
return new ThreadPoolExecutor(
Runtime.getRuntime().availableProcessors(),
Runtime.getRuntime().availableProcessors() * 2,
60, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(500),
new CustomThreadFactory("order"),
new ThreadPoolExecutor.CallerRunsPolicy()
);
}
线上还应该配合监控线程池指标(活跃线程数、队列大小、拒绝次数等),及时预警。可以使用 Micrometer + Prometheus + Grafana 搭建监控面板。
Q26: 线程池有哪些拒绝策略?
【题型:底层原理类】
1) 面试直答版
线程池提供 4 种内置拒绝策略:
- AbortPolicy(默认):直接抛出
RejectedExecutionException - CallerRunsPolicy:由提交任务的线程自己执行
- DiscardPolicy:静默丢弃任务
- DiscardOldestPolicy:丢弃队列中最老的任务,然后重新提交
2) 深度解析版
| 策略 | 行为 | 适用场景 |
|---|---|---|
| AbortPolicy | 抛 RejectedExecutionException | 关键业务,不允许丢弃任务(默认) |
| CallerRunsPolicy | 调用者线程执行任务 | 不希望丢弃任务,可以接受降速 |
| DiscardPolicy | 静默丢弃,无任何反馈 | 无关紧要的任务(日志、统计) |
| DiscardOldestPolicy | 丢弃队头任务,重新入队 | 只关心最新数据的场景(如实时报价) |
CallerRunsPolicy 的巧妙之处:
public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
if (!e.isShutdown()) {
r.run(); // 由提交线程自己执行
}
}
这不仅不丢弃任务,还实现了反压(Back Pressure):提交线程忙于执行任务时,不会继续提交新任务,给线程池喘息的时间。
自定义拒绝策略:
// 实际项目中常见的自定义策略
public class LogAndDiscardPolicy implements RejectedExecutionHandler {
@Override
public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
// 记录日志 + 监控告警
log.warn("线程池已满,任务被拒绝: pool={}, active={}, queue={}",
executor.getPoolSize(),
executor.getActiveCount(),
executor.getQueue().size());
// 可选:持久化到数据库/MQ,后续补偿执行
fallbackQueue.offer(r);
}
}
⚠️ 高频易错点:
DiscardPolicy的静默丢弃很危险——任务丢了但没有任何日志,排查问题时很难发现。CallerRunsPolicy可能阻塞调用者线程(如 Tomcat 的 IO 线程),导致接口响应变慢。- 所有拒绝策略在线程池已
shutdown后都会直接丢弃任务。
3) 加分项
生产环境中的拒绝策略选择:
- Web 接口:
CallerRunsPolicy(降级而非丢弃)+ 监控告警 - 消息消费:
AbortPolicy+ 手动重试或消息 Nack - 日志/统计:
DiscardPolicy(丢弃可接受) - 定时任务:自定义策略(持久化失败任务,后续补偿)
Dubbo 的线程池拒绝策略会打印线程池状态的详细日志(每个线程的堆栈),方便排查问题。Netty 的拒绝策略会创建一个新线程来执行被拒绝的任务(兜底方案)。
Q27: 如何合理配置线程池大小?
【题型:场景设计类 | 高频重点】
1) 面试直答版
CPU 密集型任务:线程数 = CPU 核心数 + 1;IO 密集型任务:线程数 = CPU 核心数 * 2 或使用公式 N * (1 + W/C),其中 W 是等待时间,C 是计算时间。但这只是理论值,实际应该通过压测确定。
2) 深度解析版
理论公式:
| 任务类型 | 公式 | 说明 |
|---|---|---|
| CPU 密集型 | N + 1 | N = CPU 核心数,+1 是为了在某线程因缺页中断等原因暂停时不浪费 CPU |
| IO 密集型 | N * (1 + W/C) | W = IO 等待时间,C = CPU 计算时间 |
| 混合型 | 拆分为 CPU 池 + IO 池 | 分别配置,避免互相影响 |
获取 CPU 核心数:
int cpuCores = Runtime.getRuntime().availableProcessors();
// 注意:容器环境中可能返回宿主机核心数,需要使用 JDK 10+ 的容器感知特性
// 或设置 -XX:ActiveProcessorCount=4
IO 密集型的公式推导:
假设 CPU 核心数 = 4
一个任务:计算 20ms + IO 等待 80ms = 总耗时 100ms
CPU 利用率 = 20/100 = 20%
为了让 CPU 不空闲,一个核心需要同时服务 100/20 = 5 个线程
总线程数 = 4 * 5 = 20
即 N * (1 + W/C) = 4 * (1 + 80/20) = 4 * 5 = 20
实际配置策略:
// CPU 密集型(图像处理、加密计算等)
int cpuPoolSize = Runtime.getRuntime().availableProcessors() + 1;
// IO 密集型(HTTP 调用、数据库查询等)
// 方法1:简单估算
int ioPoolSize = Runtime.getRuntime().availableProcessors() * 2;
// 方法2:精确计算
// 先用 APM 工具(如 SkyWalking)测量平均等待时间和计算时间
double waitTime = 200; // ms
double computeTime = 50; // ms
int ioPoolSize2 = (int) (cpuCores * (1 + waitTime / computeTime));
⚠️ 高频易错点:
- 公式只是起点,实际必须压测调优。不同机器、不同负载下最优值不同。
- 不要把所有业务放在一个线程池里,应该按业务类型隔离线程池(如:订单池、支付池、通知池)。
- 容器环境(Docker/K8s)中,
availableProcessors()在低版本 JDK 中可能返回宿主机核心数而非容器分配的核心数。
3) 加分项
在实际项目中,我推荐的做法:
- 先用理论公式计算一个初始值
- 用 JMeter / wrk 进行压测,观察吞吐量、延迟、CPU 利用率
- 逐步调整线程数,找到吞吐量最高且延迟可接受的值
- 配合动态线程池(美团开源的 DynamicTp),支持运行时调整参数
- 监控线程池关键指标:活跃线程数、队列长度、拒绝次数、任务平均耗时
Little’s Law(利特尔法则)也可以辅助估算:
L = lambda * W
L = 系统中的并发请求数(≈ 需要的线程数)
lambda = 请求到达速率(如 1000 QPS)
W = 平均处理时间(如 200ms = 0.2s)
L = 1000 * 0.2 = 200 线程
Q28: 线程池中线程抛异常会怎样?
【题型:场景设计类 | 高频重点】
1) 面试直答版
使用 execute() 提交时,异常会导致该线程终止,线程池会创建新线程补充;使用 submit() 提交时,异常被封装在 Future 中,调用 future.get() 时才会抛出 ExecutionException,线程不会终止。无论哪种方式,如果不处理异常,异常信息可能会丢失。
2) 深度解析版
execute() vs submit() 的异常行为:
ThreadPoolExecutor pool = new ThreadPoolExecutor(1, 1, 0, TimeUnit.SECONDS,
new LinkedBlockingQueue<>());
// 方式1:execute() -- 异常直接抛出
pool.execute(() -> {
System.out.println("execute 线程: " + Thread.currentThread().getName());
throw new RuntimeException("execute 异常");
});
// 控制台输出异常堆栈(通过 UncaughtExceptionHandler)
// 该线程被销毁,线程池创建新线程替代
Thread.sleep(1000);
// 方式2:submit() -- 异常被吞掉
Future<?> future = pool.submit(() -> {
System.out.println("submit 线程: " + Thread.currentThread().getName());
throw new RuntimeException("submit 异常");
});
// 不调用 future.get(),异常静默丢失!
// 调用 future.get() 才会抛出 ExecutionException
try {
future.get(); // 这里才能拿到异常
} catch (ExecutionException e) {
System.out.println("捕获: " + e.getCause().getMessage());
}
源码分析为什么 submit 会吞异常:
submit() 内部将任务包装成 FutureTask,FutureTask.run() 中:
public void run() {
try {
V result = callable.call();
set(result);
} catch (Throwable ex) {
setException(ex); // 异常被保存到 outcome 字段
// 没有抛出!外层 Worker 无法感知
}
}
异常处理的最佳实践:
// 方案1:任务内部 try-catch(最推荐)
pool.execute(() -> {
try {
riskyOperation();
} catch (Exception e) {
log.error("任务执行异常", e);
}
});
// 方案2:使用 submit() + Future.get()
Future<?> future = pool.submit(task);
try {
future.get();
} catch (ExecutionException e) {
log.error("任务执行异常", e.getCause());
}
// 方案3:自定义 UncaughtExceptionHandler
ThreadFactory factory = r -> {
Thread t = new Thread(r);
t.setUncaughtExceptionHandler((thread, ex) -> {
log.error("线程 {} 未捕获异常", thread.getName(), ex);
});
return t;
};
// 方案4:重写 afterExecute()
class MonitoredPool extends ThreadPoolExecutor {
@Override
protected void afterExecute(Runnable r, Throwable t) {
super.afterExecute(r, t);
if (t == null && r instanceof Future<?>) {
try {
((Future<?>) r).get();
} catch (ExecutionException e) {
t = e.getCause();
} catch (Exception e) {
t = e;
}
}
if (t != null) {
log.error("任务执行异常", t);
}
}
}
⚠️ 高频易错点:
submit()提交的任务如果不调用future.get(),异常会完全丢失,连日志都没有。execute()导致线程终止再创建,频繁异常会导致线程频繁创建/销毁,影响性能。UncaughtExceptionHandler只对execute()提交的任务有效,对submit()无效(因为异常被 FutureTask 捕获了)。
3) 加分项
在生产环境中,我的最佳实践是在任务内部统一 try-catch,配合 MDC(Mapped Diagnostic Context)记录请求链路信息:
pool.execute(() -> {
MDC.put("traceId", TraceContext.getTraceId());
try {
doWork();
} catch (Exception e) {
log.error("任务异常, traceId={}", MDC.get("traceId"), e);
// 告警(钉钉/企业微信)
alertService.notify("线程池任务异常", e);
} finally {
MDC.clear(); // 清理 ThreadLocal
}
});
五、并发工具与容器(6 题)
Q29: ConcurrentHashMap 的实现原理?
【题型:底层原理类 | 高频重点】
1) 面试直答版
JDK 7:使用分段锁(Segment),将数据分成多个段,每个段一把锁,并发度等于段数(默认 16)。JDK 8:放弃了分段锁,改用 CAS + synchronized(锁住链表/红黑树的头节点),数据结构与 HashMap 相同:数组 + 链表 + 红黑树。
2) 深度解析版
JDK 7 实现:
ConcurrentHashMap (Segment 数组)
├── Segment[0] (继承 ReentrantLock)
│ └── HashEntry[] table
│ ├── HashEntry -> HashEntry -> null
│ └── HashEntry -> null
├── Segment[1] (独立的锁)
│ └── HashEntry[] table
│ └── ...
├── ...
└── Segment[15]
并发度 = Segment 数量(默认 16,初始化后不可扩容)
不同 Segment 的操作完全并行
同一个 Segment 的操作串行(加锁)
JDK 8 实现:
ConcurrentHashMap (Node 数组)
├── Node[0] -> Node -> Node -> null (链表)
├── Node[1] -> null
├── Node[2] -> TreeBin -> TreeNode (红黑树,链表长度>=8 且数组长度>=64)
├── null
├── Node[5] -> Node -> null
└── ...
put 操作:
1. 计算 hash,定位桶位
2. 桶为空 -> CAS 插入(无锁)
3. 桶不为空 -> synchronized(头节点) {
链表/红黑树操作
}
4. 链表长度 >= 8 -> 转红黑树(数组长度 < 64 则优先扩容)
JDK 8 put 源码关键逻辑:
final V putVal(K key, V value, boolean onlyIfAbsent) {
// key 和 value 都不允许为 null!
if (key == null || value == null) throw new NullPointerException();
int hash = spread(key.hashCode());
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
if (tab == null || (n = tab.length) == 0)
tab = initTable(); // CAS 初始化
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
// 桶为空,CAS 插入(无锁操作)
if (casTabAt(tab, i, null, new Node<>(hash, key, value)))
break;
}
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f); // 帮助扩容
else {
synchronized (f) { // 锁住头节点(粒度比 Segment 更细)
// 链表/红黑树的插入操作
}
}
}
addCount(1L, binCount); // 更新 size(LongAdder 思路)
return null;
}
JDK 7 vs JDK 8 对比:
| 维度 | JDK 7 | JDK 8 |
|---|---|---|
| 数据结构 | Segment + HashEntry 数组 + 链表 | Node 数组 + 链表 + 红黑树 |
| 锁粒度 | Segment(每段一把锁) | 桶头节点(每个桶一把锁,更细) |
| 锁类型 | ReentrantLock | CAS + synchronized |
| 并发度 | Segment 数量(默认 16) | 数组长度(动态扩容) |
| Hash 冲突 | 链表 | 链表 -> 红黑树(长度>=8) |
⚠️ 高频易错点:
ConcurrentHashMap的 key 和 value 都不允许为 null(HashMap 允许),因为无法区分”key 不存在”和”key 对应 null 值”(多线程下有歧义)。- JDK 8 中不再使用 Segment,但源码中保留了 Segment 类只是为了序列化兼容。
size()返回的是近似值,不是精确值(多线程操作期间可能变化)。
3) 加分项
JDK 8 选择用 synchronized 而非 ReentrantLock 的原因:
- synchronized 在 JVM 层面持续优化(锁升级),在无竞争或低竞争场景下(ConcurrentHashMap 每个桶竞争很低),性能优于 ReentrantLock
- synchronized 不需要手动释放,代码更简洁
- 节省内存(不需要额外的 Lock 对象)
ConcurrentHashMap 的扩容是多线程协助扩容(helpTransfer),每个线程负责迁移一部分桶,大大加速了扩容过程。
Q30: ConcurrentHashMap 的 size() 怎么计算的?
【题型:底层原理类】
1) 面试直答版
JDK 8 的 ConcurrentHashMap 使用了类似 LongAdder 的思路:维护一个 baseCount 和一个 CounterCell 数组。更新时先 CAS 修改 baseCount,失败则分散到 CounterCell 中。size() 返回 baseCount + 所有 CounterCell 值的总和。
2) 深度解析版
计数机制详解:
// 核心字段
private transient volatile long baseCount; // 基础计数
private transient volatile CounterCell[] counterCells; // 分段计数器
@sun.misc.Contended // 避免伪共享
static final class CounterCell {
volatile long value;
}
addCount() 流程:
private final void addCount(long x, int check) {
CounterCell[] cs; long b, s;
// Step 1: 先尝试 CAS 更新 baseCount
if ((cs = counterCells) != null ||
!U.compareAndSetLong(this, BASECOUNT, b = baseCount, s = b + x)) {
// CAS 失败(说明竞争激烈)
CounterCell c; long v;
// Step 2: 通过线程的 probe 值(类似 hash)映射到某个 CounterCell
if (cs == null || (c = cs[ThreadLocalRandom.getProbe() & (cs.length - 1)]) == null ||
!(U.compareAndSetLong(c, CELLVALUE, v = c.value, v + x))) {
// Step 3: CounterCell CAS 也失败了,进入 fullAddCount()
// 类似 LongAdder 的 longAccumulate(),扩容 counterCells 或重试
fullAddCount(x, uncontended);
return;
}
}
// 还需要检查是否触发扩容 ...
}
size() 的计算:
public int size() {
long n = sumCount();
return ((n < 0L) ? 0 :
(n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE : (int)n);
}
final long sumCount() {
CounterCell[] cs = counterCells;
long sum = baseCount;
if (cs != null) {
for (CounterCell c : cs) {
if (c != null)
sum += c.value; // 累加所有 CounterCell
}
}
return sum;
}
为什么不用 AtomicLong?
在高并发下,AtomicLong 的 CAS 冲突严重(所有线程竞争同一个变量),而分段计数将竞争分散到多个 CounterCell 上,与 LongAdder 原理一致。
⚠️ 高频易错点:
size()返回的是近似值,因为在统计过程中可能有其他线程在修改。mappingCount()方法返回long,在元素数量超过Integer.MAX_VALUE时比size()更准确。CounterCell使用了@Contended注解避免伪共享,这是性能的关键。
3) 加分项
LongAdder 和 ConcurrentHashMap 的 CounterCell 思路完全相同,都是 Doug Lea 的设计。核心思想是空间换时间:在竞争不激烈时只用 baseCount/base(一个变量),竞争激烈时扩展到多个 Cell,大幅减少 CAS 冲突。
这种设计在分布式系统中也有对应——分布式计数器(如 Redis 的 HyperLogLog、Cassandra 的 Counter 类型)也采用类似的分段聚合策略。
Q31: CopyOnWriteArrayList 原理及适用场景?
【题型:底层原理类】
1) 面试直答版
CopyOnWriteArrayList 的核心思想是写时复制(Copy-On-Write):读操作不加锁直接读取底层数组;写操作(add/set/remove)加锁后复制一份新数组,修改完成后将引用指向新数组。适用于读多写少的场景,如监听器列表、配置信息等。
2) 深度解析版
add 方法源码分析:
public boolean add(E e) {
synchronized (lock) { // JDK 11+ 使用 synchronized,之前用 ReentrantLock
Object[] es = getArray();
int len = es.length;
// 复制一份新数组,长度 +1
es = Arrays.copyOf(es, len + 1);
es[len] = e; // 在新数组上添加元素
setArray(es); // volatile 写,将引用指向新数组
return true;
}
}
// 读操作:无锁,直接读
public E get(int index) {
return elementAt(getArray(), index); // 直接读底层数组,无锁
}
优缺点分析:
| 优点 | 缺点 |
|---|---|
| 读操作完全无锁,性能极高 | 写操作需要复制整个数组,内存开销大 |
| 不会抛出 ConcurrentModificationException | 数组很大时写操作性能差 |
| 实现简单,线程安全 | 只能保证最终一致性,不保证实时一致性 |
一致性问题:
CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();
// 线程A 正在遍历旧数组
// 线程B 添加了新元素(指向了新数组)
// 线程A 看不到线程B 添加的元素(遍历的仍然是旧数组)
// 这是"弱一致性"(eventual consistency)
⚠️ 高频易错点:
- CopyOnWriteArrayList 不适合写多的场景,每次写都要复制整个数组。
- 迭代器遍历的是创建迭代器时刻的快照,不会反映后续修改。
CopyOnWriteArraySet是基于CopyOnWriteArrayList实现的(用addIfAbsent)。
3) 加分项
典型应用场景:
- 黑白名单:读取频率远高于更新频率
- 监听器/观察者列表:Swing 的事件监听器列表
- 路由表/配置信息:读多写极少
- Spring 中的
ApplicationListener列表
在需要高并发读且列表不太大(几十到几百个元素)的场景下,CopyOnWriteArrayList 比 Collections.synchronizedList 和用 ConcurrentHashMap 模拟 Set 性能更好。
Q32: CountDownLatch 和 CyclicBarrier 的区别?
【题型:对比类】
1) 面试直答版
CountDownLatch 是一次性的倒计数器,一个或多个线程等待其他线程完成;CyclicBarrier 是可循环使用的屏障,一组线程互相等待到达同一个点后同时继续。CountDownLatch 不可重置,CyclicBarrier 可以。
2) 深度解析版
对比表:
| 维度 | CountDownLatch | CyclicBarrier |
|---|---|---|
| 计数方向 | 倒计数(N -> 0) | 正计数(0 -> N) |
| 是否可重用 | 不可重用(一次性) | 可循环使用(reset()) |
| 等待方 | 一方等待多方完成 | 多方互相等待 |
| 实现基础 | AQS 共享锁 | ReentrantLock + Condition |
| 回调 | 无 | 支持 barrierAction(到齐后执行) |
| 异常处理 | 线程中断不影响其他线程 | 一个线程异常则所有线程抛 BrokenBarrierException |
CountDownLatch 示例(等待多个任务完成):
CountDownLatch latch = new CountDownLatch(3);
// 三个子任务
for (int i = 0; i < 3; i++) {
final int taskId = i;
new Thread(() -> {
doTask(taskId);
latch.countDown(); // 完成一个,计数 -1
}).start();
}
latch.await(); // 主线程等待计数归零
System.out.println("所有任务完成");
CyclicBarrier 示例(多线程同步点):
CyclicBarrier barrier = new CyclicBarrier(3, () -> {
System.out.println("所有线程到达屏障,开始下一阶段");
});
for (int i = 0; i < 3; i++) {
new Thread(() -> {
phase1();
barrier.await(); // 等待所有线程完成第一阶段
phase2();
barrier.await(); // 等待所有线程完成第二阶段(可重用!)
}).start();
}
经典应用场景:
| 工具 | 场景 |
|---|---|
| CountDownLatch | 主线程等待多个子线程完成(如并行查询多个数据源后汇总) |
| CountDownLatch | 多个线程等待同一个信号(如压测时所有线程同时开始) |
| CyclicBarrier | 多人游戏等待所有玩家准备就绪 |
| CyclicBarrier | 多阶段并行计算(如大数据的 MapReduce) |
⚠️ 高频易错点:
countDown()可以在任意线程调用,不一定是执行任务的线程。CountDownLatch的计数无法重置,归零后await()不再阻塞。CyclicBarrier的某个线程超时或被中断,其他等待的线程会抛BrokenBarrierException。
3) 加分项
JDK 7 引入了 Phaser,可以看作是 CountDownLatch 和 CyclicBarrier 的升级版:
- 支持动态注册/注销参与者(parties 数量可变)
- 支持多阶段同步
- 可以自定义每阶段结束后的行为
在微服务场景中,CountDownLatch 常用于并行调用多个下游服务后聚合结果(类似 CompletableFuture.allOf,但更底层)。
Q33: Semaphore 是什么?有什么用?
【题型:底层原理类】
1) 面试直答版
Semaphore(信号量)是基于 AQS 实现的并发工具,维护一组许可(permits)。线程通过 acquire() 获取许可(许可不够则阻塞),通过 release() 释放许可。常用于限流和资源池控制(如限制数据库连接并发数)。
2) 深度解析版
基本用法:
// 限制同时只有 3 个线程可以访问资源
Semaphore semaphore = new Semaphore(3);
for (int i = 0; i < 10; i++) {
new Thread(() -> {
try {
semaphore.acquire(); // 获取一个许可(阻塞等待)
System.out.println(Thread.currentThread().getName() + " 获取许可");
Thread.sleep(2000); // 模拟耗时操作
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
semaphore.release(); // 释放许可
}
}).start();
}
AQS 中的实现:
state= 当前剩余许可数acquire()->tryAcquireShared():CAS 将 state 减 1,减后 < 0 则入队等待release()->tryReleaseShared():CAS 将 state 加 1,唤醒等待的线程
实际应用——限流器:
class RateLimiter {
private final Semaphore semaphore;
public RateLimiter(int maxConcurrent) {
this.semaphore = new Semaphore(maxConcurrent);
}
public <T> T execute(Callable<T> task) throws Exception {
semaphore.acquire();
try {
return task.call();
} finally {
semaphore.release();
}
}
}
// 使用:限制同时最多 10 个请求调用下游服务
RateLimiter limiter = new RateLimiter(10);
limiter.execute(() -> callDownstreamService());
| 方法 | 说明 |
|---|---|
acquire() | 获取 1 个许可,阻塞等待 |
acquire(n) | 获取 n 个许可 |
tryAcquire() | 非阻塞尝试获取,返回 boolean |
tryAcquire(timeout) | 超时等待获取 |
release() | 释放 1 个许可 |
availablePermits() | 当前可用许可数 |
⚠️ 高频易错点:
release()不要求是获取许可的那个线程调用,任何线程都可以释放。这意味着许可数可以超过初始值(调用 release 比 acquire 多)。acquire()和release()不需要成对出现在同一线程中(与 Lock 不同)。- Semaphore 支持公平和非公平模式,默认非公平。
3) 加分项
Semaphore 的一个有趣特性是可以实现互斥锁(new Semaphore(1) = 二元信号量),但与 ReentrantLock 的区别是:Semaphore 不可重入,且释放许可不限于持有线程。
在 Hystrix(Netflix 的熔断器框架)中,信号量隔离模式就是基于 Semaphore 实现的,用于限制某个服务的并发调用数。相比线程池隔离,信号量隔离的开销更小(不需要额外线程),但无法实现超时控制。
Q34: BlockingQueue 有哪些实现?怎么选?
【题型:对比类】
1) 面试直答版
常用的 BlockingQueue 实现有 5 种:ArrayBlockingQueue(数组有界)、LinkedBlockingQueue(链表可选有界)、SynchronousQueue(不存储元素)、PriorityBlockingQueue(优先级无界)、DelayQueue(延迟无界)。选择依据:有界/无界需求、排序需求、容量需求。
2) 深度解析版
全面对比:
| 实现 | 底层结构 | 是否有界 | 锁 | 特点 |
|---|---|---|---|---|
| ArrayBlockingQueue | 数组 | 有界(必须指定) | 单锁(读写共用) | 公平/非公平可选 |
| LinkedBlockingQueue | 链表 | 可选(默认 Integer.MAX_VALUE) | 双锁(读写分离) | 吞吐量通常高于 Array |
| SynchronousQueue | 无存储 | 0 容量 | 无锁(CAS) | 每个 put 等待 take |
| PriorityBlockingQueue | 堆 | 无界 | 单锁 | 元素按优先级出队 |
| DelayQueue | PriorityQueue | 无界 | 单锁 | 元素到期后才能出队 |
| LinkedTransferQueue | 链表 | 无界 | 无锁(CAS) | 比 SynchronousQueue 更灵活 |
ArrayBlockingQueue vs LinkedBlockingQueue:
// ArrayBlockingQueue:一把锁,put 和 take 互斥
// 适合生产消费速度接近的场景
ArrayBlockingQueue<String> abq = new ArrayBlockingQueue<>(100);
// LinkedBlockingQueue:两把锁(putLock 和 takeLock),读写可并行
// 适合生产消费速度不均衡的场景
LinkedBlockingQueue<String> lbq = new LinkedBlockingQueue<>(100);
SynchronousQueue:
// 容量为 0,适合直接传递任务(Executors.newCachedThreadPool 使用)
SynchronousQueue<String> sq = new SynchronousQueue<>();
// put() 会阻塞直到有线程 take()
// 公平模式使用队列(FIFO),非公平模式使用栈(LIFO)
DelayQueue:
class DelayedTask implements Delayed {
private final long executeTime; // 执行时间
@Override
public long getDelay(TimeUnit unit) {
return unit.convert(executeTime - System.currentTimeMillis(), TimeUnit.MILLISECONDS);
}
@Override
public int compareTo(Delayed o) {
return Long.compare(this.getDelay(TimeUnit.MILLISECONDS),
o.getDelay(TimeUnit.MILLISECONDS));
}
}
// 适用场景:定时任务、订单超时取消、缓存过期清理
选型指南:
| 场景 | 推荐 |
|---|---|
| 线程池工作队列(通用) | ArrayBlockingQueue(有界、可控) |
| 线程池工作队列(高吞吐) | LinkedBlockingQueue(双锁并行) |
| 直接传递(CachedThreadPool) | SynchronousQueue |
| 按优先级处理任务 | PriorityBlockingQueue |
| 延迟/定时任务 | DelayQueue |
⚠️ 高频易错点:
LinkedBlockingQueue默认容量是Integer.MAX_VALUE,使用时务必指定容量。PriorityBlockingQueue是无界的,可能导致 OOM。SynchronousQueue的size()永远返回 0,isEmpty()永远返回 true。
3) 加分项
LinkedBlockingQueue 的双锁设计是一个精妙的优化:putLock 控制入队,takeLock 控制出队,生产者和消费者操作不同端,可以并行执行。但这也增加了实现复杂度——需要用 AtomicInteger count 来协调两把锁对元素计数的维护。
Disruptor 使用了无锁的环形缓冲区(RingBuffer)替代 BlockingQueue,在低延迟场景(金融交易系统)下性能是 ArrayBlockingQueue 的 10-100 倍。
六、场景设计题(5 题 + 1 题)
Q35: 如何实现一个简单的限流器?
【题型:场景设计类】
1) 面试直答版
最简单的方式是用 Semaphore 控制并发数;更通用的方式是令牌桶算法(以固定速率生成令牌,请求需要获取令牌才能执行)和滑动窗口算法(统计窗口内的请求数)。
2) 深度解析版
方案一:Semaphore 限制并发数
class ConcurrencyLimiter {
private final Semaphore semaphore;
public ConcurrencyLimiter(int maxConcurrent) {
this.semaphore = new Semaphore(maxConcurrent);
}
public void execute(Runnable task) throws InterruptedException {
semaphore.acquire();
try {
task.run();
} finally {
semaphore.release();
}
}
}
方案二:简易令牌桶限流器
class TokenBucketLimiter {
private final int maxTokens; // 桶容量
private final int refillRate; // 每秒生成令牌数
private int currentTokens; // 当前令牌数
private long lastRefillTime; // 上次补充时间
public TokenBucketLimiter(int maxTokens, int refillRate) {
this.maxTokens = maxTokens;
this.refillRate = refillRate;
this.currentTokens = maxTokens;
this.lastRefillTime = System.nanoTime();
}
public synchronized boolean tryAcquire() {
refillTokens();
if (currentTokens > 0) {
currentTokens--;
return true;
}
return false;
}
private void refillTokens() {
long now = System.nanoTime();
double elapsed = (now - lastRefillTime) / 1e9; // 秒
int newTokens = (int) (elapsed * refillRate);
if (newTokens > 0) {
currentTokens = Math.min(maxTokens, currentTokens + newTokens);
lastRefillTime = now;
}
}
}
方案三:滑动窗口限流器
class SlidingWindowLimiter {
private final int maxRequests; // 窗口内最大请求数
private final long windowSizeMs; // 窗口大小(毫秒)
private final Deque<Long> timestamps = new ConcurrentLinkedDeque<>();
public SlidingWindowLimiter(int maxRequests, long windowSizeMs) {
this.maxRequests = maxRequests;
this.windowSizeMs = windowSizeMs;
}
public synchronized boolean tryAcquire() {
long now = System.currentTimeMillis();
// 移除过期的时间戳
while (!timestamps.isEmpty() && now - timestamps.peekFirst() > windowSizeMs) {
timestamps.pollFirst();
}
if (timestamps.size() < maxRequests) {
timestamps.addLast(now);
return true;
}
return false;
}
}
⚠️ 高频易错点:
- Semaphore 限制的是并发数,不是 QPS(每秒请求数),两个概念不同。
- 简单的计数器限流有临界问题:在窗口交界处可能出现 2 倍的请求量。滑动窗口解决了这个问题。
- 令牌桶允许突发流量(桶满时一次性消耗多个令牌),漏桶则是恒定速率处理。
3) 加分项
在生产环境中,推荐使用 Guava 的 RateLimiter(令牌桶算法,支持预热和平滑限流)或 Sentinel(阿里开源的流量防护组件,支持多种限流策略)。
分布式限流需要使用 Redis + Lua 脚本实现(Redis 的 INCR + EXPIRE 原子操作),或使用 Redis Cell 模块。Nginx 的 limit_req 模块也是常用的网关层限流方案。
Q36: 三个线程交替打印 ABC 怎么实现?
【题型:场景设计类】
1) 面试直答版
有多种实现方式:wait/notify 控制顺序、Lock+Condition 精准唤醒、Semaphore 传递许可。核心思想都是让三个线程轮流获得执行权。
2) 深度解析版
方案一:synchronized + wait/notify
class PrintABC {
private int state = 0; // 0=A, 1=B, 2=C
private final Object lock = new Object();
public void printA() {
for (int i = 0; i < 10; i++) {
synchronized (lock) {
while (state != 0) {
try { lock.wait(); } catch (InterruptedException e) {}
}
System.out.print("A");
state = 1;
lock.notifyAll();
}
}
}
public void printB() {
for (int i = 0; i < 10; i++) {
synchronized (lock) {
while (state != 1) {
try { lock.wait(); } catch (InterruptedException e) {}
}
System.out.print("B");
state = 2;
lock.notifyAll();
}
}
}
public void printC() {
for (int i = 0; i < 10; i++) {
synchronized (lock) {
while (state != 2) {
try { lock.wait(); } catch (InterruptedException e) {}
}
System.out.print("C");
state = 0;
lock.notifyAll();
}
}
}
}
方案二:Lock + Condition(精准唤醒,最优解)
class PrintABC_Condition {
private final ReentrantLock lock = new ReentrantLock();
private final Condition condA = lock.newCondition();
private final Condition condB = lock.newCondition();
private final Condition condC = lock.newCondition();
private int state = 0;
public void printA() {
for (int i = 0; i < 10; i++) {
lock.lock();
try {
while (state != 0) condA.await();
System.out.print("A");
state = 1;
condB.signal(); // 精准唤醒打印B的线程
} catch (InterruptedException e) {
} finally {
lock.unlock();
}
}
}
public void printB() {
for (int i = 0; i < 10; i++) {
lock.lock();
try {
while (state != 1) condB.await();
System.out.print("B");
state = 2;
condC.signal(); // 精准唤醒打印C的线程
} catch (InterruptedException e) {
} finally {
lock.unlock();
}
}
}
public void printC() {
for (int i = 0; i < 10; i++) {
lock.lock();
try {
while (state != 2) condC.await();
System.out.print("C");
state = 0;
condA.signal(); // 精准唤醒打印A的线程
} catch (InterruptedException e) {
} finally {
lock.unlock();
}
}
}
}
方案三:Semaphore
class PrintABC_Semaphore {
private final Semaphore semA = new Semaphore(1); // A 先执行
private final Semaphore semB = new Semaphore(0);
private final Semaphore semC = new Semaphore(0);
public void printA() throws InterruptedException {
for (int i = 0; i < 10; i++) {
semA.acquire();
System.out.print("A");
semB.release(); // 通知B
}
}
public void printB() throws InterruptedException {
for (int i = 0; i < 10; i++) {
semB.acquire();
System.out.print("B");
semC.release(); // 通知C
}
}
public void printC() throws InterruptedException {
for (int i = 0; i < 10; i++) {
semC.acquire();
System.out.print("C");
semA.release(); // 通知A
}
}
}
⚠️ 高频易错点:
- wait/notify 方案中
notifyAll()会唤醒所有等待线程,不够精准(浪费 CPU)。Condition 的signal()可以精准唤醒。wait()必须在while循环中(防止虚假唤醒),不能用if。
3) 加分项
Semaphore 方案最简洁优雅,且扩展性最好——如果改为 N 个线程交替打印,只需 N 个 Semaphore 链式传递即可。Lock+Condition 方案展示了对 JUC 工具的深入理解,面试时推荐先说 Semaphore 方案再补充 Condition 方案。
Q37: 如何设计一个并发安全的单例模式?
【题型:场景设计类】
1) 面试直答版
常见的线程安全单例有 5 种:饿汉式、懒汉式+synchronized、DCL(双重检查锁)+volatile、静态内部类、枚举。推荐使用枚举或静态内部类,简洁且天然线程安全。
2) 深度解析版
方式一:饿汉式(类加载时创建)
public class Singleton1 {
private static final Singleton1 INSTANCE = new Singleton1();
private Singleton1() {}
public static Singleton1 getInstance() { return INSTANCE; }
}
// 优点:简单,线程安全(类加载机制保证)
// 缺点:不支持延迟加载,类加载即创建实例
方式二:懒汉式 + synchronized(性能差)
public class Singleton2 {
private static Singleton2 instance;
private Singleton2() {}
public static synchronized Singleton2 getInstance() {
if (instance == null) {
instance = new Singleton2();
}
return instance;
}
}
// 缺点:每次调用 getInstance() 都要加锁,性能差
方式三:DCL + volatile(面试重点)
public class Singleton3 {
private static volatile Singleton3 instance; // volatile 必须加!
private Singleton3() {}
public static Singleton3 getInstance() {
if (instance == null) { // 第一次检查(无锁)
synchronized (Singleton3.class) {
if (instance == null) { // 第二次检查(有锁)
instance = new Singleton3(); // volatile 防止指令重排
}
}
}
return instance;
}
}
// 不加 volatile 的风险:见 Q16 中的分析(重排序导致返回未初始化的对象)
方式四:静态内部类(推荐)
public class Singleton4 {
private Singleton4() {}
private static class Holder {
private static final Singleton4 INSTANCE = new Singleton4();
}
public static Singleton4 getInstance() {
return Holder.INSTANCE;
}
}
// 优点:延迟加载 + 线程安全(类加载机制保证)
// 原理:Holder 类只有在第一次调用 getInstance() 时才会被加载
方式五:枚举(最推荐)
public enum Singleton5 {
INSTANCE;
public void doSomething() { }
}
// 优点:线程安全、防止反序列化和反射破坏单例
// 《Effective Java》推荐的最佳方式
五种方式对比:
| 方式 | 线程安全 | 延迟加载 | 防反射 | 防反序列化 | 推荐度 |
|---|---|---|---|---|---|
| 饿汉式 | 是 | 否 | 否 | 否 | 中 |
| 懒汉式+sync | 是 | 是 | 否 | 否 | 低 |
| DCL+volatile | 是 | 是 | 否 | 否 | 中 |
| 静态内部类 | 是 | 是 | 否 | 否 | 高 |
| 枚举 | 是 | 否 | 是 | 是 | 最高 |
⚠️ 高频易错点:
- DCL 中 volatile 不能省略!不加 volatile,由于指令重排,其他线程可能拿到未初始化的对象。
- 普通单例可以通过反射
setAccessible(true)调用私有构造函数破坏单例,枚举是唯一能防反射的方式。- 序列化/反序列化会创建新对象破坏单例,需要添加
readResolve()方法(枚举天然免疫)。
3) 加分项
Spring 中的 Bean 默认就是单例的(scope=“singleton”),由 Spring 容器管理。Spring 使用的是三级缓存来解决单例 Bean 的循环依赖问题,而非上述的任何一种方式——它通过 singletonObjects(一级缓存)、earlySingletonObjects(二级缓存)、singletonFactories(三级缓存)三个 ConcurrentHashMap 实现。
Q38: 10 个线程同时对一个变量累加,如何保证结果正确?
【题型:场景设计类】
1) 面试直答版
三种方式:synchronized 加锁、AtomicInteger(CAS 原子操作)、LongAdder(分段 CAS)。性能排序:LongAdder > AtomicInteger > synchronized。竞争不激烈用 AtomicInteger,高并发推荐 LongAdder。
2) 深度解析版
方案一:synchronized
class Counter_Sync {
private int count = 0;
public synchronized void increment() {
count++;
}
public synchronized int getCount() {
return count;
}
}
方案二:AtomicInteger(CAS)
class Counter_Atomic {
private final AtomicInteger count = new AtomicInteger(0);
public void increment() {
count.incrementAndGet(); // CAS 自旋
}
public int getCount() {
return count.get();
}
}
方案三:LongAdder(分段 CAS)
class Counter_Adder {
private final LongAdder count = new LongAdder();
public void increment() {
count.increment(); // 分散到不同 Cell
}
public long getCount() {
return count.sum(); // 汇总所有 Cell 的值
}
}
性能对比(10 个线程各累加 100 万次):
| 方式 | 原理 | 耗时(参考值) | 适用场景 |
|---|---|---|---|
| synchronized | 互斥锁 | ~800ms | 需要复合操作时 |
| AtomicInteger | 单点 CAS | ~300ms | 低中竞争 |
| LongAdder | 分段 CAS | ~50ms | 高竞争,只需最终一致 |
LongAdder 为什么更快?
AtomicInteger: 10 个线程竞争同一个变量 -> CAS 冲突频繁 -> 大量自旋
┌───┐
│ 0 │ <-- 所有线程 CAS 这一个位置
└───┘
LongAdder: 将竞争分散到多个 Cell
┌──────┐ ┌───┐ ┌───┐ ┌───┐
│ base │ │C0 │ │C1 │ │C2 │
└──────┘ └───┘ └───┘ └───┘
▲ ▲ ▲ ▲
线程1 线程2 线程3 线程4
// sum() = base + C0 + C1 + C2
⚠️ 高频易错点:
LongAdder的sum()不是原子操作,在并发修改时返回的是近似值。- 如果需要精确的当前值(如用于 CAS 比较),只能用
AtomicLong。LongAdder不支持compareAndSet()操作。
3) 加分项
JDK 8 的 LongAccumulator 是 LongAdder 的通用版本,支持自定义累积函数:
LongAccumulator max = new LongAccumulator(Math::max, Long.MIN_VALUE);
max.accumulate(10);
max.accumulate(20);
max.get(); // 20
选型原则:如果只是计数(递增/递减),用 LongAdder;如果需要获取/设置精确值或做 CAS 操作,用 AtomicLong;如果需要自定义操作(最大值、最小值、求和等),用 LongAccumulator。
Q39: 如何实现一个生产者-消费者模型?
【题型:场景设计类 | 高频重点】
1) 面试直答版
最推荐用 BlockingQueue 实现,它内置了线程安全和阻塞等待机制。也可以用 wait/notify 或 Lock/Condition 手动实现,但更复杂。
2) 深度解析版
方案一:BlockingQueue(推荐,最简洁)
class ProducerConsumer_BQ {
private final BlockingQueue<String> queue = new ArrayBlockingQueue<>(10);
// 生产者
class Producer implements Runnable {
@Override
public void run() {
try {
int i = 0;
while (!Thread.currentThread().isInterrupted()) {
String item = "商品-" + (i++);
queue.put(item); // 队列满则阻塞
System.out.println("生产: " + item);
Thread.sleep(100);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
// 消费者
class Consumer implements Runnable {
@Override
public void run() {
try {
while (!Thread.currentThread().isInterrupted()) {
String item = queue.take(); // 队列空则阻塞
System.out.println("消费: " + item);
Thread.sleep(200);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
public void start() {
new Thread(new Producer(), "producer-1").start();
new Thread(new Consumer(), "consumer-1").start();
new Thread(new Consumer(), "consumer-2").start();
}
}
方案二:wait/notify 手动实现
class ProducerConsumer_WN {
private final Queue<String> queue = new LinkedList<>();
private final int capacity = 10;
private final Object lock = new Object();
// 生产者
public void produce(String item) throws InterruptedException {
synchronized (lock) {
while (queue.size() == capacity) { // 队列满,等待
lock.wait();
}
queue.offer(item);
System.out.println("生产: " + item + ",队列大小: " + queue.size());
lock.notifyAll(); // 唤醒消费者
}
}
// 消费者
public String consume() throws InterruptedException {
synchronized (lock) {
while (queue.isEmpty()) { // 队列空,等待
lock.wait();
}
String item = queue.poll();
System.out.println("消费: " + item + ",队列大小: " + queue.size());
lock.notifyAll(); // 唤醒生产者
return item;
}
}
}
方案三:Lock + Condition(精准唤醒)
class ProducerConsumer_LC {
private final Queue<String> queue = new LinkedList<>();
private final int capacity = 10;
private final ReentrantLock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition(); // 队列未满
private final Condition notEmpty = lock.newCondition(); // 队列非空
public void produce(String item) throws InterruptedException {
lock.lock();
try {
while (queue.size() == capacity) {
notFull.await(); // 队列满,等待 notFull
}
queue.offer(item);
notEmpty.signal(); // 精准唤醒消费者
} finally {
lock.unlock();
}
}
public String consume() throws InterruptedException {
lock.lock();
try {
while (queue.isEmpty()) {
notEmpty.await(); // 队列空,等待 notEmpty
}
String item = queue.poll();
notFull.signal(); // 精准唤醒生产者
return item;
} finally {
lock.unlock();
}
}
}
⚠️ 高频易错点:
wait()必须在while循环中判断条件,不能用if(虚假唤醒问题)。notifyAll()会唤醒所有等待线程(包括其他生产者),Condition.signal()可以只唤醒消费者。BlockingQueue的put/take是阻塞方法,offer/poll是非阻塞方法,注意区分。
3) 加分项
在实际项目中,生产者-消费者模型的典型实现:
- 消息队列:Kafka/RabbitMQ 就是分布式的生产者-消费者模型
- 线程池:ThreadPoolExecutor 的 Worker 线程就是消费者,从 workQueue 中 take 任务
- Reactor 模式:Netty 的 EventLoop 从 taskQueue 中取任务执行
选型建议:
- 单机场景:
BlockingQueue足够 - 分布式场景:Kafka(高吞吐)/ RabbitMQ(功能丰富)
- 极致低延迟:Disruptor(无锁设计)
Q40: 如何实现多个异步任务的结果聚合?
【题型:场景设计类 | 高频重点】
1) 面试直答版
使用 CompletableFuture.allOf() 等待所有异步任务完成后聚合结果。它支持链式调用,是 JDK 8 引入的异步编程利器。也可以用 CountDownLatch + Future 实现,但代码更繁琐。
2) 深度解析版
场景:商品详情页需要聚合多个微服务的数据
商品详情页
├── 商品基本信息 <- 商品服务(200ms)
├── 库存信息 <- 库存服务(150ms)
├── 价格信息 <- 价格服务(180ms)
├── 评价信息 <- 评价服务(300ms)
└── 推荐商品 <- 推荐服务(250ms)
串行调用:200+150+180+300+250 = 1080ms
并行调用:max(200,150,180,300,250) = 300ms(提速 3.6 倍)
方案一:CompletableFuture(推荐)
public ProductDetail getProductDetail(Long productId) {
ExecutorService pool = Executors.newFixedThreadPool(5);
// 并行发起多个异步调用
CompletableFuture<ProductInfo> productFuture =
CompletableFuture.supplyAsync(() -> productService.getInfo(productId), pool);
CompletableFuture<StockInfo> stockFuture =
CompletableFuture.supplyAsync(() -> stockService.getStock(productId), pool);
CompletableFuture<PriceInfo> priceFuture =
CompletableFuture.supplyAsync(() -> priceService.getPrice(productId), pool);
CompletableFuture<List<Review>> reviewFuture =
CompletableFuture.supplyAsync(() -> reviewService.getReviews(productId), pool);
CompletableFuture<List<Product>> recommendFuture =
CompletableFuture.supplyAsync(() -> recommendService.recommend(productId), pool);
// 等待所有任务完成
CompletableFuture.allOf(productFuture, stockFuture, priceFuture,
reviewFuture, recommendFuture).join();
// 聚合结果
ProductDetail detail = new ProductDetail();
detail.setProduct(productFuture.join());
detail.setStock(stockFuture.join());
detail.setPrice(priceFuture.join());
detail.setReviews(reviewFuture.join());
detail.setRecommendations(recommendFuture.join());
return detail;
}
方案二:带超时和异常处理的完整版
public ProductDetail getProductDetailSafe(Long productId) {
ExecutorService pool = customThreadPool;
CompletableFuture<ProductInfo> productFuture =
CompletableFuture.supplyAsync(() -> productService.getInfo(productId), pool)
.exceptionally(ex -> {
log.error("获取商品信息失败", ex);
return ProductInfo.DEFAULT; // 降级默认值
});
CompletableFuture<StockInfo> stockFuture =
CompletableFuture.supplyAsync(() -> stockService.getStock(productId), pool)
.orTimeout(500, TimeUnit.MILLISECONDS) // JDK 9+ 超时控制
.exceptionally(ex -> {
log.warn("获取库存超时,使用缓存值");
return stockCache.get(productId);
});
CompletableFuture<PriceInfo> priceFuture =
CompletableFuture.supplyAsync(() -> priceService.getPrice(productId), pool)
.completeOnTimeout(PriceInfo.DEFAULT, 500, TimeUnit.MILLISECONDS); // JDK 9+
// 任意两个完成后就可以先返回部分数据
CompletableFuture<ProductDetail> result = productFuture
.thenCombine(priceFuture, (product, price) -> {
ProductDetail detail = new ProductDetail();
detail.setProduct(product);
detail.setPrice(price);
return detail;
})
.thenCombine(stockFuture, (detail, stock) -> {
detail.setStock(stock);
return detail;
});
try {
return result.get(1, TimeUnit.SECONDS); // 总超时 1 秒
} catch (Exception e) {
log.error("聚合结果失败", e);
return ProductDetail.FALLBACK;
}
}
方案三:CountDownLatch + Future(传统方式)
public ProductDetail getProductDetail_CDL(Long productId) throws InterruptedException {
CountDownLatch latch = new CountDownLatch(5);
ProductDetail detail = new ProductDetail();
ExecutorService pool = customThreadPool;
pool.submit(() -> {
try {
detail.setProduct(productService.getInfo(productId));
} finally {
latch.countDown();
}
});
pool.submit(() -> {
try {
detail.setStock(stockService.getStock(productId));
} finally {
latch.countDown();
}
});
// ... 其他任务类似
boolean completed = latch.await(1, TimeUnit.SECONDS);
if (!completed) {
log.warn("部分任务超时");
}
return detail;
}
CompletableFuture 常用 API 速查:
| 方法 | 说明 |
|---|---|
supplyAsync() | 异步执行,有返回值 |
runAsync() | 异步执行,无返回值 |
thenApply() | 结果转换(类似 map) |
thenCompose() | 扁平化(类似 flatMap) |
thenCombine() | 合并两个结果 |
allOf() | 等待所有完成 |
anyOf() | 任一完成即返回 |
exceptionally() | 异常降级 |
orTimeout() | 超时控制(JDK 9+) |
completeOnTimeout() | 超时返回默认值(JDK 9+) |
⚠️ 高频易错点:
CompletableFuture默认使用ForkJoinPool.commonPool(),建议指定自定义线程池,避免与其他任务共享。allOf()返回的是CompletableFuture<Void>,需要通过各个原始 Future 的join()获取结果。join()和get()的区别:join()抛出非受检异常CompletionException,get()抛出受检异常ExecutionException。- 不要忘记异常处理和超时控制,否则一个下游服务挂了可能拖垮整个接口。
3) 加分项
在 Spring 中可以结合 @Async + CompletableFuture 使用:
@Service
public class ProductService {
@Async("customPool")
public CompletableFuture<ProductInfo> getInfoAsync(Long id) {
return CompletableFuture.completedFuture(getInfo(id));
}
}
对于更复杂的异步编排(如 A 完成后并行 B 和 C,B 和 C 都完成后执行 D),CompletableFuture 的链式 API 非常优雅。在响应式编程(WebFlux / Project Reactor)中,Mono.zip() 是类似的操作,但支持背压和更丰富的操作符。
总结:面试答题思路框架
回答底层原理类问题的模板
1. 一句话概括是什么
2. 核心数据结构 / 关键字段
3. 关键流程 / 状态变化
4. 源码级别的关键代码
5. 常见误区和注意事项
回答对比类问题的模板
1. 一句话说清核心区别
2. 多维度对比表(至少 5 个维度)
3. 代码示例展示差异
4. 选型建议(什么场景用什么)
回答场景设计类问题的模板
1. 说出 2-3 种方案
2. 给出最推荐方案的完整代码
3. 分析各方案的优劣
4. 补充生产环境中的注意事项
附录:高频考点速查表
| 考点 | 关键词 | 频率 |
|---|---|---|
| synchronized 锁升级 | 偏向锁/轻量级/重量级/Mark Word | 极高 |
| volatile | 可见性/有序性/不保证原子性/内存屏障 | 极高 |
| CAS | Compare-And-Swap/ABA/自旋 | 极高 |
| AQS | state/CLH队列/模板方法 | 高 |
| 线程池参数 | 7个参数/执行流程/拒绝策略 | 极高 |
| ConcurrentHashMap | JDK7 Segment/JDK8 CAS+synchronized | 极高 |
| ThreadLocal | 弱引用/内存泄漏/线程池场景 | 高 |
| ReentrantLock vs synchronized | 至少6个维度对比 | 高 |
| 死锁 | 四个条件/jstack/预防策略 | 高 |
| JMM | 主内存/工作内存/happens-before | 中高 |
本文共 40 题,涵盖 Java 并发编程面试的核心知识点。建议结合源码阅读和实际编码练习,做到真正理解而非死记硬背。