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("线程池方式"));

本质分析:

方式本质说明
继承 Threadnew Thread()直接创建线程对象
实现 Runnablenew Thread(runnable)定义任务,仍需 Thread 启动
实现 Callablenew 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() 方法结束

⚠️ 高频易错点

  1. Java 中没有 RUNNING 状态!RUNNABLE 包含了操作系统层面的 Ready 和 Running。
  2. BLOCKED 状态只针对 synchronized 关键字。使用 ReentrantLock 加锁时,线程状态是 WAITING(调用了 LockSupport.park()),不是 BLOCKED。
  3. 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 方法,它会:

  1. 创建一个操作系统原生线程(pthread_create / CreateThread)
  2. 在新线程的入口函数中回调 Java 的 Thread.run() 方法

3) 加分项

面试中经常出现这道题的变种:“如果对同一个线程对象调用两次 start() 会怎样?”

答案是会抛出 IllegalThreadStateException,因为 start() 方法开头就检查了 threadStatus,非 0 就抛异常。线程一旦终止就无法重新启动,只能创建新的线程对象。这也是线程池不直接 start() 已结束线程,而是让 Worker 循环获取任务来复用线程的原因。


Q4: sleep() 和 wait() 的区别?

【题型:对比类】

1) 面试直答版

sleep() 是 Thread 的静态方法,让当前线程休眠指定时间,不释放锁wait() 是 Object 的方法,让线程进入等待状态,会释放锁,且必须在 synchronized 块中调用。

2) 深度解析版

全维度对比:

维度sleep()wait()
所属类ThreadObject
释放锁不释放释放监视器锁
使用条件任意位置可调用必须在 synchronized 块/方法中
唤醒方式时间到自动恢复 / interrupt()notify() / notifyAll() / interrupt()
线程状态TIMED_WAITINGWAITING(无参)/ 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();

⚠️ 高频易错点

  1. wait() 不在 synchronized 块中调用会抛 IllegalMonitorStateException
  2. wait() 被唤醒后不是立即执行,而是要重新竞争锁,拿到锁后才能继续。
  3. 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() 会强制终止线程并释放所有持有的锁,这会导致:

  1. 数据不一致:正在执行到一半的操作被中断,对象可能处于不一致状态;
  2. 锁被释放:其他线程可能看到损坏的数据;
  3. 无法预测:你无法控制线程在哪一行代码被停止。

正确做法: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 的中断状态,不清除标志位

⚠️ 高频易错点

  1. interrupt() 不会立即停止线程!它只是设置一个标志位。如果线程正在执行计算密集型代码且不检查中断标志,线程不会停止。
  2. 当线程处于 sleep() / wait() / join() 时被中断,会抛出 InterruptedException同时清除中断标志位。这是一个常见的坑——捕获异常后需要重新 interrupt() 或直接退出。
  3. 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(远程方法调用)相关线程

⚠️ 高频易错点

  1. 不要在守护线程中执行 IO 操作或持有需要释放的资源,因为 JVM 退出时守护线程会被强制终止,finally不保证执行
  2. 在已启动的线程上调用 setDaemon() 会抛出 IllegalThreadStateException
  3. 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 个维度对比:

维度synchronizedReentrantLock
实现层面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() 唤醒所有线程,无法精准唤醒

⚠️ 高频易错点

  1. ReentrantLock 必须在 finally 中释放锁,否则一旦异常,锁永远不释放,造成死锁。
  2. synchronized 在竞争锁时不可中断,可能导致线程一直阻塞。
  3. 不要用 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 / 01001
偏向锁threadId / epoch / age / 1 / 01101
轻量级锁指向栈中锁记录的指针 / 0000
重量级锁指向 ObjectMonitor 的指针 / 1010
GC 标记空 / 1111

各阶段详解:

1. 偏向锁

  • 场景:只有一个线程反复进入同步块(研究表明大多数锁在整个生命周期中只被同一个线程访问)
  • 原理:第一次 CAS 将线程 ID 写入 Mark Word,后续检查 Mark Word 中是否为自己的 ID,如果是则直接进入,无需任何同步操作
  • 撤销:出现第二个线程竞争时,在全局安全点(STW)撤销偏向锁

2. 轻量级锁

  • 场景:多个线程交替执行同步块(竞争不激烈)
  • 原理:线程在栈帧中创建 Lock Record,CAS 将 Mark Word 替换为指向 Lock Record 的指针
  • 自旋:获取失败后不立即阻塞,而是自旋等待(忙等待)

3. 重量级锁

  • 场景:多线程同时竞争锁
  • 原理:膨胀为 ObjectMonitor(C++ 实现),通过操作系统 Mutex 实现互斥,竞争失败的线程被挂起
  • 代价:涉及用户态到内核态的切换,成本高

⚠️ 高频易错点

  1. 锁只能升级,不能降级(GC 除外)。
  2. JDK 15 之后默认禁用了偏向锁-XX:-UseBiasedLocking),因为偏向锁的撤销需要 STW,在多线程应用中反而影响性能。
  3. 调用了对象的 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 / ReentrantLockCAS(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位:写锁计数 -->|

⚠️ 高频易错点

  1. 读写锁支持锁降级(持有写锁时获取读锁,然后释放写锁),但不支持锁升级(持有读锁时获取写锁会死锁)。
  2. 写锁饥饿问题:在非公平模式下,如果读请求源源不断,写线程可能一直拿不到锁。

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 之后 synchronizedReentrantLock 的性能差距已经非常小。在无竞争或低竞争场景下,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 种内存交互操作:

  1. lock:把主内存变量标记为线程独占
  2. unlock:释放主内存变量的独占
  3. read:从主内存读取变量值到工作内存
  4. load:将 read 的值放入工作内存的变量副本
  5. use:将工作内存的值传给执行引擎
  6. assign:将执行引擎的值赋给工作内存的变量
  7. store:将工作内存的值传送到主内存
  8. write:将 store 的值写入主内存变量

指令重排序的三个层面:

源代码 -> 编译器优化重排 -> 指令级并行重排 -> 内存系统重排 -> 最终执行

JMM 通过**内存屏障(Memory Barrier)**来禁止特定类型的重排序,保证程序的正确性。

⚠️ 高频易错点

  1. JMM 是一个抽象模型,并不真实存在。它是 Java 对硬件内存架构(多级缓存、写缓冲区等)的抽象。
  2. “工作内存”不等于”线程栈”,它是 CPU 缓存、寄存器、写缓冲区等的抽象。
  3. 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() 在字节码层面分为三步:

  1. 分配内存空间
  2. 调用构造函数初始化
  3. 将引用赋值给 instance

如果没有 volatile,步骤 2 和 3 可能被重排序为 1-3-2。线程B 在步骤 3 执行后看到 instance != null,直接返回了一个未完成初始化的对象。

3. 内存屏障原理

volatile 读写操作会在字节码中插入内存屏障指令:

操作屏障类型效果
volatile 写之前StoreStore前面的写操作先完成
volatile 写之后StoreLoad写操作先于后续读操作
volatile 读之后LoadLoad读操作先于后续读操作
volatile 读之后LoadStore读操作先于后续写操作

在 x86 架构上,volatile 写操作对应 lock 前缀指令,它的作用:

  1. 将当前 CPU 缓存行写回主内存
  2. 使其他 CPU 中缓存了该地址的缓存行失效(MESI 协议)

⚠️ 高频易错点

  1. volatile 的可见性保证是立即的(写完立刻可见),而 synchronized 的可见性是在释放锁时刷新。
  2. volatile 不能替代 synchronized,因为不保证原子性。
  3. 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. 操作1 HB 操作2(程序顺序规则)
  2. 操作2 HB 操作3(volatile 规则)
  3. 操作3 HB 操作4(程序顺序规则)
  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 关系)

⚠️ 高频易错点

  1. happens-before 描述的是可见性保证,不是时间顺序。即使 A 在时间上先于 B 执行,如果没有 HB 关系,B 不一定能看到 A 的结果。
  2. 程序顺序规则只在同一个线程内有效,跨线程需要通过其他规则建立 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));

⚠️ 高频易错点

  1. CAS 底层依赖 Unsafe 类,普通开发者不应直接使用 Unsafe(Java 9+ 可用 VarHandle 替代)。
  2. ABA 问题对基本类型通常没有实际影响,但对引用类型可能产生严重问题(如链表节点被替换)。

3) 加分项

CAS 是整个 java.util.concurrent 包的基石。AQS 的 state 修改、ConcurrentHashMap 的节点插入、AtomicInteger 的自增,底层都是 CAS。

在超高并发场景下,LongAdderAtomicLong 性能好得多(10 倍以上),原理是空间换时间:将一个值分散到多个 Cell 中,每个线程 CAS 不同的 Cell,最后 sum() 求和。这也是 ConcurrentHashMapsize() 实现思路。


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 节点的关键字段:

字段说明
waitStatusSIGNAL(-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位=写锁计数混合

⚠️ 高频易错点

  1. AQS 队列的 head 节点是哨兵节点(dummy node),不代表任何线程。
  2. 线程在队列中不是一直自旋的,而是先尝试,失败后 park() 阻塞,被前驱唤醒后再尝试。
  3. 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(); // 必须手动清理!
    }
}

⚠️ 高频易错点

  1. 内存泄漏的根本原因不是弱引用,而是线程池中线程长期存活。如果线程很快终止,ThreadLocalMap 会随线程一起被回收。
  2. 即使用强引用做 key 也会有问题(ThreadLocal 对象本身无法被回收),弱引用至少保证了 ThreadLocal 对象可以被回收,ThreadLocal 后续操作还能顺带清理 stale entry。
  3. 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;

⚠️ 高频易错点

  1. @Contended 注解默认只对 JDK 内部类有效,用户类需要添加 JVM 参数 -XX:-RestrictContended
  2. 过度填充会浪费内存,只对高并发频繁写入的场景有必要使用。
  3. 现代 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非核心线程空闲超过此时间就会被回收根据任务到达频率调整
unitkeepAliveTime 的时间单位通常用 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;
    }
};

⚠️ 高频易错点

  1. corePoolSize 设为 0 时,所有线程都是”临时工”,空闲时都会被回收。
  2. allowCoreThreadTimeOut(true) 可以让核心线程也被回收。
  3. 使用无界队列时,maximumPoolSize 参数无意义,因为队列永远不会满,永远不会创建非核心线程。

3) 加分项

在生产环境中,推荐使用 Guava 的 ThreadFactoryBuilder 或自定义 ThreadFactory 给线程池命名。线上排查问题时,jstack 输出的线程名如果都是 pool-1-thread-1 这种默认名,根本无法定位是哪个业务的线程池出了问题。

另外,线程池参数应该支持动态调整。美团开源了线程池动态配置框架,通过配置中心实时修改 corePoolSizemaximumPoolSizeworkQueue 容量等参数,无需重启应用。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);
}

⚠️ 高频易错点

  1. 注意执行顺序:核心线程 -> 队列 -> 非核心线程 -> 拒绝。不是先填满所有线程再入队!
  2. 核心线程和非核心线程在创建后没有区别,线程池不会标记哪个是核心哪个是非核心,回收时只看线程总数是否超过 corePoolSize。
  3. 任务是先入队再创建非核心线程,这意味着如果队列是无界的,永远不会创建非核心线程。

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() // 合适的拒绝策略
);

⚠️ 高频易错点

  1. LinkedBlockingQueue 默认容量是 Integer.MAX_VALUE(约 21 亿),几乎等于无界。
  2. SynchronousQueue 不存储任何元素(容量为 0),每个 put 操作必须等待一个 take 操作。
  3. 即使使用了有界队列,队列容量也不宜过大,应根据业务处理能力合理设置。

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) 深度解析版

策略行为适用场景
AbortPolicyRejectedExecutionException关键业务,不允许丢弃任务(默认)
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);
    }
}

⚠️ 高频易错点

  1. DiscardPolicy 的静默丢弃很危险——任务丢了但没有任何日志,排查问题时很难发现。
  2. CallerRunsPolicy 可能阻塞调用者线程(如 Tomcat 的 IO 线程),导致接口响应变慢。
  3. 所有拒绝策略在线程池已 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 + 1N = 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));

⚠️ 高频易错点

  1. 公式只是起点,实际必须压测调优。不同机器、不同负载下最优值不同。
  2. 不要把所有业务放在一个线程池里,应该按业务类型隔离线程池(如:订单池、支付池、通知池)。
  3. 容器环境(Docker/K8s)中,availableProcessors() 在低版本 JDK 中可能返回宿主机核心数而非容器分配的核心数。

3) 加分项

在实际项目中,我推荐的做法:

  1. 先用理论公式计算一个初始值
  2. 用 JMeter / wrk 进行压测,观察吞吐量、延迟、CPU 利用率
  3. 逐步调整线程数,找到吞吐量最高且延迟可接受的值
  4. 配合动态线程池(美团开源的 DynamicTp),支持运行时调整参数
  5. 监控线程池关键指标:活跃线程数、队列长度、拒绝次数、任务平均耗时

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() 内部将任务包装成 FutureTaskFutureTask.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);
        }
    }
}

⚠️ 高频易错点

  1. submit() 提交的任务如果不调用 future.get(),异常会完全丢失,连日志都没有。
  2. execute() 导致线程终止再创建,频繁异常会导致线程频繁创建/销毁,影响性能。
  3. 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 7JDK 8
数据结构Segment + HashEntry 数组 + 链表Node 数组 + 链表 + 红黑树
锁粒度Segment(每段一把锁)桶头节点(每个桶一把锁,更细)
锁类型ReentrantLockCAS + synchronized
并发度Segment 数量(默认 16)数组长度(动态扩容)
Hash 冲突链表链表 -> 红黑树(长度>=8)

⚠️ 高频易错点

  1. ConcurrentHashMapkey 和 value 都不允许为 null(HashMap 允许),因为无法区分”key 不存在”和”key 对应 null 值”(多线程下有歧义)。
  2. JDK 8 中不再使用 Segment,但源码中保留了 Segment 类只是为了序列化兼容
  3. size() 返回的是近似值,不是精确值(多线程操作期间可能变化)。

3) 加分项

JDK 8 选择用 synchronized 而非 ReentrantLock 的原因:

  1. synchronized 在 JVM 层面持续优化(锁升级),在无竞争或低竞争场景下(ConcurrentHashMap 每个桶竞争很低),性能优于 ReentrantLock
  2. synchronized 不需要手动释放,代码更简洁
  3. 节省内存(不需要额外的 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 原理一致。

⚠️ 高频易错点

  1. size() 返回的是近似值,因为在统计过程中可能有其他线程在修改。
  2. mappingCount() 方法返回 long,在元素数量超过 Integer.MAX_VALUE 时比 size() 更准确。
  3. 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)

⚠️ 高频易错点

  1. CopyOnWriteArrayList 不适合写多的场景,每次写都要复制整个数组。
  2. 迭代器遍历的是创建迭代器时刻的快照,不会反映后续修改。
  3. CopyOnWriteArraySet 是基于 CopyOnWriteArrayList 实现的(用 addIfAbsent)。

3) 加分项

典型应用场景:

  • 黑白名单:读取频率远高于更新频率
  • 监听器/观察者列表:Swing 的事件监听器列表
  • 路由表/配置信息:读多写极少
  • Spring 中的 ApplicationListener 列表

在需要高并发读且列表不太大(几十到几百个元素)的场景下,CopyOnWriteArrayList 比 Collections.synchronizedList 和用 ConcurrentHashMap 模拟 Set 性能更好。


Q32: CountDownLatch 和 CyclicBarrier 的区别?

【题型:对比类】

1) 面试直答版

CountDownLatch 是一次性的倒计数器,一个或多个线程等待其他线程完成;CyclicBarrier 是可循环使用的屏障,一组线程互相等待到达同一个点后同时继续。CountDownLatch 不可重置,CyclicBarrier 可以。

2) 深度解析版

对比表:

维度CountDownLatchCyclicBarrier
计数方向倒计数(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)

⚠️ 高频易错点

  1. countDown() 可以在任意线程调用,不一定是执行任务的线程。
  2. CountDownLatch 的计数无法重置,归零后 await() 不再阻塞。
  3. 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()当前可用许可数

⚠️ 高频易错点

  1. release() 不要求是获取许可的那个线程调用,任何线程都可以释放。这意味着许可数可以超过初始值(调用 release 比 acquire 多)。
  2. acquire()release() 不需要成对出现在同一线程中(与 Lock 不同)。
  3. 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无界单锁元素按优先级出队
DelayQueuePriorityQueue无界单锁元素到期后才能出队
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

⚠️ 高频易错点

  1. LinkedBlockingQueue 默认容量是 Integer.MAX_VALUE,使用时务必指定容量
  2. PriorityBlockingQueue 是无界的,可能导致 OOM。
  3. SynchronousQueuesize() 永远返回 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;
    }
}

⚠️ 高频易错点

  1. Semaphore 限制的是并发数,不是 QPS(每秒请求数),两个概念不同。
  2. 简单的计数器限流有临界问题:在窗口交界处可能出现 2 倍的请求量。滑动窗口解决了这个问题。
  3. 令牌桶允许突发流量(桶满时一次性消耗多个令牌),漏桶则是恒定速率处理。

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
        }
    }
}

⚠️ 高频易错点

  1. wait/notify 方案中 notifyAll() 会唤醒所有等待线程,不够精准(浪费 CPU)。Condition 的 signal() 可以精准唤醒。
  2. 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
静态内部类
枚举最高

⚠️ 高频易错点

  1. DCL 中 volatile 不能省略!不加 volatile,由于指令重排,其他线程可能拿到未初始化的对象。
  2. 普通单例可以通过反射 setAccessible(true) 调用私有构造函数破坏单例,枚举是唯一能防反射的方式。
  3. 序列化/反序列化会创建新对象破坏单例,需要添加 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

⚠️ 高频易错点

  1. LongAddersum() 不是原子操作,在并发修改时返回的是近似值
  2. 如果需要精确的当前值(如用于 CAS 比较),只能用 AtomicLong
  3. LongAdder 不支持 compareAndSet() 操作。

3) 加分项

JDK 8 的 LongAccumulatorLongAdder 的通用版本,支持自定义累积函数:

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/notifyLock/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();
        }
    }
}

⚠️ 高频易错点

  1. wait() 必须在 while 循环中判断条件,不能用 if(虚假唤醒问题)。
  2. notifyAll() 会唤醒所有等待线程(包括其他生产者),Condition.signal() 可以只唤醒消费者。
  3. BlockingQueueput/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+)

⚠️ 高频易错点

  1. CompletableFuture 默认使用 ForkJoinPool.commonPool(),建议指定自定义线程池,避免与其他任务共享。
  2. allOf() 返回的是 CompletableFuture<Void>,需要通过各个原始 Future 的 join() 获取结果。
  3. join()get() 的区别:join() 抛出非受检异常 CompletionExceptionget() 抛出受检异常 ExecutionException
  4. 不要忘记异常处理和超时控制,否则一个下游服务挂了可能拖垮整个接口。

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可见性/有序性/不保证原子性/内存屏障极高
CASCompare-And-Swap/ABA/自旋极高
AQSstate/CLH队列/模板方法
线程池参数7个参数/执行流程/拒绝策略极高
ConcurrentHashMapJDK7 Segment/JDK8 CAS+synchronized极高
ThreadLocal弱引用/内存泄漏/线程池场景
ReentrantLock vs synchronized至少6个维度对比
死锁四个条件/jstack/预防策略
JMM主内存/工作内存/happens-before中高

本文共 40 题,涵盖 Java 并发编程面试的核心知识点。建议结合源码阅读和实际编码练习,做到真正理解而非死记硬背。