mobile wallpaper 1
mobile wallpaper 2
mobile wallpaper 3
mobile wallpaper 4
14409 字
38 分钟
Java 多线程与并发编程核心面试题精讲
2026-08-14

线程基础知识#

1. 进程与线程的区别#

  • 我的理解:
    进程是操作系统分配资源的基本单位,不同进程相互隔离,拥有独立的虚拟内存空间;线程是 CPU 调度的基本单位,一个进程包含多个线程。同一进程内的线程共享堆空间和方法区,但各自有私有的内存(如程序计数器、虚拟机栈)。进程上下文切换开销大(涉及虚拟内存地址切换),而同进程内线程切换无需切换地址空间,开销较小。

  • 重点补充与避坑:

    1. 线程私有部分务必同时回答出程序计数器(PC)和虚拟机栈(栈保证调用链和局部变量独立,PC 保证恢复后找到执行指令)。
    2. 进程切换开销大,是因为要切换页表、刷新快表(TLB);线程切换只需保存和恢复通用寄存器和栈指针。
  • 标准整理:

    1. 定位区别:进程是操作系统分配资源和保护的基本单位;线程是 CPU 调度和执行的最小单位。
    2. 内存划分:进程间内存完全隔离;进程内的线程共享堆与元空间/方法区,但私有程序计数器、虚拟机栈、本地方法栈。
    3. 切换开销:进程切换需切换虚拟地址空间、更换页表并刷新 TLB,开销大;线程共享地址空间,仅需保存/恢复寄存器和栈状态,开销小。
    4. 健壮性:一个进程崩溃不影响其他进程;同一进程内某个线程出现严重崩溃(如 JVM 致命错误),容易导致整个进程退出。

2. 并发与并行的区别#

  • 我的理解:
    并行是真正的物理同时运行,必须依赖多核 CPU,不同核心各自运行线程。并发则是 CPU 通过极短的时间片轮转在不同任务间交替切换,无论单核还是多核,只要线程数多于 CPU 核心数,就会存在并发。微观上是交替执行,宏观上看起来是同时执行。

  • 重点补充与避坑:

    • 切忌将并发绝对等同于“单核 CPU”。多核环境下,只要活跃线程数超过物理核心数,依然是在并发调度。
    • 核心记忆口诀:并发是“微观交替,宏观同时”;并行是“物理上的真正同时”。
  • 标准整理:

    • 并行(Parallelism):多个线程在多核 CPU 的不同核心上在同一物理时刻同时执行。
    • 并发(Concurrency):多个任务抢占 CPU 资源,CPU 依据调度算法快速分配时间片交替执行。微观上分时串行,宏观上感知为并发运行。

3. 创建线程的 4 种方式#

  • 我的理解:

    1. 继承 Thread 类,重写 run();
    2. 实现 Runnable 接口,重写 run();
    3. 实现 Callable 接口,重写 call(),结合 FutureTask 获取返回值;
    4. 使用自定义线程池(ThreadPoolExecutor)。
      Java 只支持单继承,实现接口比继承 Thread 更具扩展性。Callable 相比 Runnable 不仅有返回值,还能直接向上抛出受检异常,而 Runnable 只能内部 try-catch。前三种方式用于定义任务,第四种用于池化管理线程。
  • 重点补充与避坑:

    • 本质上 Java 底层创建线程只有一种方式:通过 new Thread() 并调用本地方法 start0() 创建操作系统内核线程。接口只是任务封装,线程池是调度工具。
  • 代码示例:

// 1. 实现 Runnable 接口(无返回值、不抛受检异常)
Runnable runnableTask = () -> System.out.println(Thread.currentThread().getName() + " 执行任务");
new Thread(runnableTask).start();
// 2. 实现 Callable 配合 FutureTask(有返回值、可抛异常)
Callable<String> callableTask = () -> {
// 可以声明 throws Exception
return "计算结果: 100";
};
FutureTask<String> futureTask = new FutureTask<>(callableTask);
new Thread(futureTask).start();
String result = futureTask.get(); // 阻塞获取异步结果
// 3. 线程池规范创建(推荐)
ExecutorService pool = new ThreadPoolExecutor(
2, 4, 60, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(100),
Executors.defaultThreadFactory(),
new ThreadPoolExecutor.CallerRunsPolicy()
);
pool.execute(runnableTask);
pool.shutdown();

4. run() 与 start() 的区别#

  • 我的理解:
    start() 会通知系统创建并启动一个新线程,随后自动在该子线程中异步执行 run();直接调用 run() 则只是在当前线程中执行普通方法调用,根本不会开启新线程。start() 方法一个线程实例只能调用一次,重复调用会直接抛异常。

  • 重点补充与避坑:

    • 重复调用 start() 抛出的具体异常是 IllegalThreadStateException(非法线程状态异常),因为源码会在第一行检查线程状态是否为 NEW(threadStatus != 0)。
    • start() 底层调用了 native void start0(),由 JVM 负责创建操作系统内核线程。
  • 标准整理:

    1. 执行模式:start() 会开启新线程并在新栈帧中异步执行;run() 仅在当前主调线程中同步串行执行。
    2. 生命周期流转:start() 使线程从 NEW 转入 RUNNABLE 就绪态;调用 run() 不会引起任何线程状态变化。
    3. 调用次数:start() 严禁重复调用(否则抛 IllegalThreadStateException);run() 只是普通方法,可任意次调用。

5. Java 线程的 6 种状态及其转换#

  • 我的理解:

    • NEW(新建):创建了线程对象,未调 start();
    • RUNNABLE(可运行):具备执行资格,但在等待 CPU 调度分配执行权;
    • TERMINATED(终止):任务执行完成或异常退出。
    • 等待/阻塞三状态:
      • BLOCKED(阻塞):多个线程竞争 synchronized 监视器锁,没拿到锁的线程进入阻塞;
      • WAITING(无限期等待/死等):调用不带超时的 wait()、join(),必须等别人显式唤醒;
      • TIMED_WAITING(超时等待):调用带时间的 sleep(ms)、wait(ms),时间到了自动唤醒,也可被提前唤醒。
    • 醒来或拿到锁后,统一先回到 RUNNABLE 重新竞争 CPU。
  • 重点补充与避坑:

    • 切勿混淆 BLOCKED 与 WAITING:只有争抢 synchronized 锁失败被挡在外面才会变成 BLOCKED;调用 wait() 主动让出锁进入的是 WAITING!

6. 控制线程按顺序执行(join() 的原理与实战)#

  • 我的理解:
    使用 Thread.join() 方法。比如有 T1、T2、T3,可以在 T2 的 run() 方法中调用 t1.join(),在 T3 的 run() 方法中调用 t2.join(),这样就能严格保证 T1 →\rightarrow T2 →\rightarrow T3 的顺序。底层是利用了 wait() 和线程销毁时的 notifyAll()。

  • 重点补充与避坑:

    • join() 源码核心:while (isAlive()) { wait(0); }。当目标线程结束执行终止时,JVM 会在底层自动调用该线程对象自身的 notifyAll() 唤醒阻塞线程。
    • 现代业务开发更推荐使用 CompletableFuture.thenRun() 或 单线程池(SingleThreadExecutor)。
  • 代码示例:

// 方式 A:主线程流式 join
Thread t1 = new Thread(() -> System.out.println("T1 执行完毕"));
Thread t2 = new Thread(() -> System.out.println("T2 执行完毕"));
Thread t3 = new Thread(() -> System.out.println("T3 执行完毕"));
t1.start();
t1.join(); // 主线程阻塞,直到 t1 结束
t2.start();
t2.join(); // 主线程阻塞,直到 t2 结束
t3.start();
t3.join();
// 方式 B:CompletableFuture 现代链式异步编排(推荐)
CompletableFuture.runAsync(() -> System.out.println("任务1"))
.thenRun(() -> System.out.println("任务2"))
.thenRun(() -> System.out.println("任务3"));

7. wait() 与 sleep() 的区别(锁机制核心对比)#

  • 我的理解:
    两者都会让线程暂停执行并让出 CPU。核心区别在于锁与通信:

    • sleep() 是纯休眠,不会释放锁(抱着锁睡,谁也别想进);属于 Thread 静态方法,任何位置都可调用;
    • wait() 是线程间通信,会立刻释放当前对象的监视器锁,让其他线程能够争抢锁执行;属于 Object 实例方法,必须在 synchronized 代码块内调用,否则抛出非法监视器状态异常。
  • 重点补充与避坑:

    • wait() 只释放当前调用该方法的那个对象的锁。如果外层还嵌套持有了其他锁,其他锁依然不会释放。
    • 为什么 wait 放在 Object?因为在 Java 中任何对象都可以作为锁(对象头的 Mark Word 关联了 Monitor 和 WaitSet),等待队列是绑定在对象上的。
  • 核心对比代码:

public class WaitVsSleepDemo {
private static final Object lock = new Object();
// 1. 验证 sleep 不释放锁
public static void testSleep() throws InterruptedException {
new Thread(() -> {
synchronized (lock) {
System.out.println("线程1:获取锁,开始 sleep 2秒(不放锁)...");
try {
Thread.sleep(2000); // 抱着锁睡
} catch (InterruptedException e) {}
System.out.println("线程1:sleep 结束,出同步块释放锁。");
}
}).start();
Thread.sleep(100); // 确保线程1先拿锁
new Thread(() -> {
System.out.println("线程2:尝试获取锁...");
synchronized (lock) {
// 必须等线程1 sleep 完释放锁后,线程2才能打出这句
System.out.println("线程2:终于获取到了锁!");
}
}).start();
}
// 2. 验证 wait 释放锁
public static void testWait() throws InterruptedException {
new Thread(() -> {
synchronized (lock) {
System.out.println("线程A:拿到锁,调用 wait() 立刻释放锁并等待...");
try {
lock.wait(); // 交出锁,进入 WAITING
} catch (InterruptedException e) {}
System.out.println("线程A:重新被唤醒并拿回锁,继续执行!");
}
}).start();
Thread.sleep(100); // 确保线程A先拿到锁并 wait
new Thread(() -> {
System.out.println("线程B:尝试获取锁...");
synchronized (lock) {
// 因为线程A wait 释放了锁,线程B可以立刻拿到锁进门
System.out.println("线程B:拿到锁进门!通知唤醒线程A...");
lock.notify(); // 唤醒等待线程A
System.out.println("线程B:即将退出同步块并彻底释放锁。");
}
}).start();
}
}

8. notify() 与 notifyAll() 的区别#

  • 我的理解:
    notify() 是随机唤醒等待池里的某一个线程,而 notifyAll() 是唤醒等待该锁的所有线程。如果系统中存在多种角色(如生产者与消费者),notify() 可能会唤醒错误的角色(例如消费者唤醒了另一个消费者,而没有唤醒生产者),导致所有线程都陷入等待状态,从而引发系统“假死”(信号丢失)。notifyAll() 可以保证所有等待线程都有机会竞争,避免假死。

  • 重点补充与避坑:

    • 防止虚假唤醒(Spurious Wakeup):调用 wait() 时必须放在 while 循环中判断条件,绝对不能使用 if!因为线程被唤醒后可能条件依然不满足,必须在循环中再次检查条件。
  • 标准范式代码:

synchronized (lock) {
// 必须使用 while 循环检查等待条件,禁止使用 if
while (!conditionSatisfied) {
lock.wait();
}
// 执行业务操作
doSomething();
// 优先使用 notifyAll() 防止信号丢失/假死
lock.notifyAll();
}

9. 如何安全优雅地停止一个正在运行的线程?#

  • 我的理解:
    不能使用已被废弃的 Thread.stop(),因为它是强制暴力杀死线程,相当于直接拔电源,会瞬间强制释放线程持有的所有锁,导致共享数据处于损坏或不一致的状态。正确做法是使用协作式的 interrupt() 打断机制。

  • 重点补充与避坑:

    • 处理分为两类情况:
      1. 正常运行状态:线程通过在循环体中调用 Thread.currentThread().isInterrupted() 自行判断标志位,检测到打断后主动清理资源并跳出退出;
      2. 阻塞状态(如 sleep()、wait()):调用 interrupt() 会向线程抛出 InterruptedException,并且自动重置打断标记为 false。必须在 catch 块中通过 break 退出,或者调用 Thread.currentThread().interrupt() 重新恢复打断标记。
  • 标准代码模板:

public class SafeThreadStopDemo {
public static void main(String[] args) throws InterruptedException {
Thread worker = new Thread(() -> {
// 1. 正常循环时检查打断标志位
while (!Thread.currentThread().isInterrupted()) {
try {
System.out.println("工作线程:正在执行业务任务...");
Thread.sleep(1000); // 模拟阻塞耗时
} catch (InterruptedException e) {
System.out.println("工作线程:休眠中检测到中断信号!");
// 2. 发生异常时标志位被重置,可重新补齐标记或直接 break
Thread.currentThread().interrupt();
break; // 安全退出循环
}
}
// 3. 释放资源,体面退出
System.out.println("工作线程:资源清理完成,安全终止。");
});
worker.start();
Thread.sleep(2500);
System.out.println("主线程:发出中断信号 worker.interrupt()...");
worker.interrupt();
}
}

线程并发安全#

10. synchronized 的底层实现原理(字节码与 Monitor 管程)#

  • 我的理解:
    synchronized 底层关联了一个 JVM 的 Monitor(管程)结构,核心由三部分组成:

    1. _owner:指向当前真正持有锁的单个线程,保证同一时刻只有一个线程访问临界区;
    2. _EntryList:存放竞争锁失败、处于 BLOCKED 阻塞状态的线程;
    3. _WaitSet:存放主动调用 wait() 让出锁、处于 WAITING 等待状态的线程。

    从字节码层面看:修饰代码块会生成一个 monitorenter(上锁)和两个 monitorexit(解锁)。之所以有两个 monitorexit,是因为编译器自动构建了类似 try-finally 的异常处理表,第 2 个 monitorexit 作为异常兜底机制,保证发生未捕获异常时锁依然能被正常释放,防止系统死锁。如果修饰方法,则是在方法的常量池属性打上 ACC_SYNCHRONIZED 标志位进行隐式加锁。

  • 重点补充与避坑:

    • 重入机制(Reentrancy):synchronized 是可重入锁。在 Monitor 层面,内部维护了一个 _recursions 计数器:同一个线程每重入一次,计数器加 1;每退出一次同步块,计数器减 1;只有当计数器减到 0 时,_owner 才会被置为 null,锁才算真正释放。
    • 互斥量的开销:重量级锁的 Monitor 底层依赖操作系统的互斥锁(Mutex Lock),会导致操作系统在用户态(User Mode)与内核态(Kernel Mode)之间来回切换,伴随 CPU 上下文切换,这也是早期 synchronized 性能偏低的核心原因。
  • 字节码反编译示例(javap -c):

public class SyncDemo {
private final Object lock = new Object();
public void test() {
synchronized (lock) {
System.out.println("execute");
}
}
}

反编译后的核心字节码指令流:

5: monitorenter // 1. 获取 monitor 锁,_owner 指向当前线程,_recursions 计数 +1
6: getstatic #3 // 输出执行代码...
9: ldc #4
11: invokevirtual #5
14: aload_1
15: monitorexit // 2. 正常执行完毕退出,释放锁
16: goto 24
19: astore_2 // 3. 异常处理入口(Exception table 捕获异常)
20: aload_1
21: monitorexit // 4. 异常退出路径:强制执行第 2 个 monitorexit,防止死锁
22: aload_2
23: athrow
24: return
Exception table:
from to target type
6 16 19 any // 覆盖 6~16 行的所有异常
19 22 19 any

11. 锁升级/膨胀机制与 Mark Word 变化#

  • 我的理解:
    Java 1.6 之前 synchronized 直接关联重量级锁,开销很大。为了提升性能,JDK 引入了锁升级机制:无锁 →\rightarrow 偏向锁 →\rightarrow 轻量级锁 →\rightarrow 重量级锁,整个过程单向不可逆。锁的状态保存在对象头的 Mark Word 中:

    1. 无锁:没有多线程并发竞争,对象处于初始状态;
    2. 偏向锁:只有单线程反复访问。Mark Word 中直接存入该线程的 Thread ID(认人脸)。后续该线程再次进入同步块时,无需任何 CAS 操作,直接比对 ID 通过,零开销;
    3. 轻量级锁:出现第二个线程尝试竞争,偏向锁被打破并在全局安全点撤销,升级为轻量级锁。竞争线程在自己的虚拟机栈帧中开辟 Lock Record,通过 CAS 操作将 Mark Word 替换为指向自己栈帧 Lock Record 的指针。若同一线程重入,则在栈帧中再压入一个内容为 null 的 Lock Record 作为重入计数;
    4. 重量级锁:发生多线程激烈竞争,线程自旋尝试(自适应自旋)达到阈值依然没拿到锁时,为避免 CPU 一直空转打转,锁膨胀为重量级锁。Mark Word 被替换为指向堆外 C++ 的 ObjectMonitor 对象的指针,没抢到锁的线程直接被内核挂起,进入 BLOCKED 状态。
  • 重点补充与避坑:

    • Mark Word 存储内容归位(高频避坑口诀):
      • 偏向锁 →\rightarrow 存 Thread ID(线程 ID);
      • 轻量级锁 →\rightarrow 存 Lock Record(栈帧锁记录指针);
      • 重量级锁 →\rightarrow 存 ObjectMonitor(管程对象内存指针)。
    • 自适应自旋(Adaptive Spinning):JDK 1.6 对自旋锁进行了优化,自旋的次数不再固定,而是由上一次在同一个锁上的自旋时间及锁拥有者的状态决定(上次成功过就多转几次,上次老失败就直接升级避免浪费 CPU)。
    • 偏向锁的退场:在 JDK 15 之后,偏向锁已经被官方默认禁用(-XX:-UseBiasedLocking),并在后续版本中被逐步废弃,原因是撤销偏向锁需要全局停顿(STW),在现代高并发场景下带来的开销往往超过收益。
  • 锁升级完整流程图解(文字版):

[新建对象]
│
▼
【无锁状态】
│ (单线程首次进入同步块)
▼
【偏向锁】 ──── (Mark Word 记录 Thread ID,后续无 CAS 免检通行)
│
│ (出现第二个线程轻度交替竞争,触发偏向锁撤销)
▼
【轻量级锁】 ──── (Mark Word 指向当前线程栈帧的 Lock Record)
│ (线程通过 CAS 原地自旋尝试获取锁,不挂起)
│
│ (自旋达到自适应上限仍未拿到锁,或多线程并发激烈强争)
▼
【重量级锁】 ──── (Mark Word 指向堆外 C++ 的 ObjectMonitor 对象)
(未获得锁的线程由 OS 内核挂起,进入 BLOCKED 状态)
  • 深度辨析:轻量级锁与重量级锁的区别:
    这里的关键不是“有几个线程使用这把锁”,而是“是否有线程在另一个线程持锁期间同时来竞争”。

    • 轻量级锁适用于低竞争场景。多个线程可以依次使用同一把锁,只要前一个线程已经释放,后一个线程才来加锁,持锁区间不重叠。此时主要通过 CAS 修改对象头中的 Mark Word,使其指向当前线程栈上的 Lock Record,不需要让线程阻塞。
低竞争:
T1:|------持锁------|释放
T2: |----持锁----|
  • 重量级锁适用于存在实际并发竞争的场景。当 T1 持锁时,T2/T3 也来尝试加锁,它们无法立即获得锁,需要等待。锁可能膨胀为重量级 Monitor,等待线程进入队列并可能发生阻塞、唤醒和线程调度,因此开销更大。
高竞争:
T1:|------------持锁------------|释放
T2: 尝试加锁 → 等待
T3: 尝试加锁 → 等待

核心总结:“轻量级锁”不是只有一个线程使用,而是多个线程使用时竞争较少;“重量级锁”也不要求一定有很多线程,两个线程在持锁区间重叠并产生明显等待,就可能触发锁膨胀。注意:CAS 失败不等于任何情况下都立刻升级为重量级锁,实际是否自旋、何时膨胀与 JDK/JVM 版本以及自适应自旋状态密切相关。


12. JMM(Java 内存模型)的底层结构与三大特性#

  • 我的理解:
    JMM(Java Memory Model)是一套抽象规范,用于屏蔽不同操作系统和 CPU 硬件缓存(L1/L2/L3 高速缓存、寄存器、写缓冲区)的访问差异,保证 Java 程序在不同平台上并发表现一致。JMM 将内存划分为两部分:

    • 主内存:所有线程共享,存储所有的共享变量(对象实例、静态变量等);
    • 工作内存:每个线程私有的本地内存(对应 CPU 寄存器和高速缓存),线程之间互不可见。

    线程不能直接读写主内存,必须先将主内存变量拷贝一份副本到自己的工作内存中,修改完成后再刷新回主内存。不同线程之间的通信必须通过主内存中转。正是由于这种工作内存副本机制,导致了多线程环境下的并发安全问题。

  • 重点补充与避坑:

    • 并发三大特性(面试核心必考):
      1. 原子性(Atomicity):一个或一组操作要么全部执行完毕,要么全都不执行,执行过程不被中断(通过 synchronized 或 Lock 保证);
      2. 可见性(Visibility):一个线程修改了工作内存中的共享变量并刷新回主内存后,其他线程能够立刻感知并读取到最新值(通过 volatile 或 synchronized 保证);
      3. 有序性(Ordering):为了提升执行效率,编译器和 CPU 会进行“指令重排序”。单线程下保证结果正确,但在多线程下重排序可能导致执行时序错乱。JMM 通过内存屏障和 Happens-Before 原则保证有序性。

13. CAS(Compare-And-Swap)的底层原理与三大缺点#

  • 我的理解:
    CAS 是一种基于乐观锁思想的硬件级无锁原子操作。它包含 3 个核心操作数:内存位置 V、预期原值 A、拟写入的新值 B。当且仅当内存位置 V 的当前最新值等于预期值 A 时,才把 V 修改为 B。它本质上是一个 while 循环(自旋),抱有乐观态度,认为别人通常不会修改数据;如果被别人抢先修改了,自己就重新读取最新值再算一遍(自旋重试)。

  • 重点补充与避坑:

    • CAS 的三大缺点及解决方案:
      1. ABA 问题:变量从 A 变成 B 又变回 A,CAS 误以为它从未变过。解决方案:引入版本号 / 时间戳机制,在 Java 中使用 AtomicStampedReference,比对数值的同时比对版本号;
      2. 高并发下 CPU 开销大:如果并发竞争激烈导致线程长时间 CAS 失败,循环空转自旋会导致 CPU 占用率飙高;
      3. 只能保证单个变量的原子性:无法直接保护包含多个变量的代码块,多个变量的原子操作需借助对象封装(AtomicReference)或互斥锁。
    • 硬件级底层支撑:Java 底层通过 sun.misc.Unsafe 类调用本地 native 方法,最终映射为 CPU 汇编指令 lock cmpxchg,由硬件锁住总线或缓存行确保比较并更新的原子性。
  • 代码示例(解决 ABA 问题):

import java.util.concurrent.atomic.AtomicStampedReference;
public class ABASolutionDemo {
// 初始值 A,初始版本号 1
private static AtomicStampedReference<String> ref = new AtomicStampedReference<>("A", 1);
public static void main(String[] args) {
int stamp = ref.getStamp(); // 获取当前版本号
// 模拟其他线程产生 ABA 操作:A -> B -> A(版本号持续递增)
ref.compareAndSet("A", "B", stamp, stamp + 1);
ref.compareAndSet("B", "A", ref.getStamp(), ref.getStamp() + 1);
// 原线程尝试基于旧版本号修改,预期失败
boolean success = ref.compareAndSet("A", "C", stamp, stamp + 1);
System.out.println("CAS 修改是否成功: " + success); // 输出 false,成功拦截 ABA 问题
}
}

14. volatile 关键字的底层原理(两保一不保)#

  • 我的理解:
    volatile 的核心作用是保证可见性与禁止指令重排序(但不保证原子性):

    1. 保证可见性(底层硬件机制):
      • 写操作:强制将当前 CPU 的写缓冲区(Store Buffer)数据立即刷新回主内存,并通过总线嗅探机制通知其他 CPU 核心,将它们工作内存中对应的缓存行直接置为“失效”;
      • 读操作:其他线程在读取该变量时,发现本地缓存行已失效,被强制从主内存重新拉取最新值,彻底杜绝了读取旧缓存值的现象。
    2. 禁止指令重排序(内存屏障):
      • 在底层插入内存屏障(Memory Barrier):写屏障确保上方的写操作不能被重排到写屏障下方;读屏障确保下方的读操作不能被重排到读屏障上方。通常在写操作末尾使用写屏障,在读操作开始时使用读屏障。
  • 重点补充与避坑:

    • 不保证原子性:volatile 无法保证诸如 count++ 的原子性。因为 count++ 在字节码层面拆分为读、改、写三步操作,多线程并发时仍然会被打断并产生写覆盖。
    • 经典应用:DCL(双重检查锁)单例模式(面试必考):
      instance = new Singleton(); 在底层分为三步:①分配内存 →\rightarrow ②初始化对象 →\rightarrow ③指针指向内存。若发生 1 →\rightarrow 3 →\rightarrow 2 的指令重排,其他并发线程在第一重检查时可能拿到尚未初始化完毕的半成品对象。使用 volatile 可以坚决阻止步骤 2 和 3 的重排序。
    • 执行顺序辨析:volatile 只保证可见性与有序性,不负责指定线程的执行先后顺序。若需控制线程执行时序,必须依赖 join()、CountDownLatch 或 CompletableFuture 等显式同步工具。
  • 代码示例(DCL 单例标准写法):

public class Singleton {
// 必须加 volatile:禁止 new 对象时的指令重排序,并保证多线程下的实例可见性
private static volatile Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) { // 第一重检查:提升性能,避免每次都加锁
synchronized (Singleton.class) {
if (instance == null) { // 第二重检查:防止多线程并发突破第一重后重复创建
// 底层分三步:1.分配空间 2.初始化 3.引用赋值
// volatile 禁止 2 和 3 重排序,彻底杜绝半成品对象逸出
instance = new Singleton();
}
}
}
return instance;
}
}

15. AQS(AbstractQueuedSynchronizer)的核心原理与设计#

  • 我的理解:
    AQS 是 Java 并发包(JUC)的底层基石,用于构建各种锁和同步器。它底层维护了一个基于双向链表的 FIFO 同步队列和一个核心状态变量:

    1. 同步状态变量 state:如果是 0 则表示无锁,如果是 1 则表示有锁(资源已被获取);当同一个线程多次获取锁时,数值大于 1 代表重入的次数;
    2. 双向队列与排队机制:抢锁失败的线程会被封装成 Node 节点加入到队列尾部;持有锁的线程释放锁后,由队列头部的节点负责唤醒后续排队的线程;
    3. 原子性保障:多线程抢锁、修改 state 状态以及入队操作均通过底层的 CAS 保证原子性;
    4. 公平锁与非公平锁:公平锁严格按照 FIFO 规则加入队列末尾排队;非公平锁新进来的线程先尝试插队抢锁,抢不到再去队列末尾排队。
  • 重点补充与避坑:

    • 核心属性:private volatile int state(状态计数)与 exclusiveOwnerThread(记录当前持有锁的独占线程引用)。
    • 线程挂起与唤醒机制:排入队列中的线程通过 LockSupport.park() 挂起让出 CPU,唤醒时通过 LockSupport.unpark() 恢复就绪态。
    • 两种资源共享模式:
      • 独占模式(Exclusive):同一时刻仅允许单线程持有资源(如 ReentrantLock);
      • 共享模式(Shared):允许多个线程并发获取资源(如 Semaphore 信号量、CountDownLatch、读写锁的读锁)。
  • AQS 核心结构示意图:

┌────────────────────────────────────────────────────────┐
│ AQS: volatile int state (0:无锁, >=1:加锁及重入次数) │
│ Thread exclusiveOwnerThread (当前持有锁的线程) │
└────────────────────────────────────────────────────────┘
│
(抢锁失败进入双向 FIFO 阻塞队列)
▼
┌──────┐ next ┌──────┐ next ┌──────┐
│ Head │ ─────────> │ Node │ ─────────> │ Tail │
│ (哨兵)│ <───────── │(Thread)│ <───────── │(Thread)│
└──────┘ prev └──────┘ prev └──────┘
│
LockSupport.park() 挂起

16. ReentrantLock 的底层机制及其与 synchronized 的对比#

  • 我的理解:
    ReentrantLock 是一个基于 AQS 和 CAS 实现的可重入互斥锁,内部通过继承 AQS 的 Sync 抽象类派生出公平锁和非公平锁(默认非公平)。

    • 语法与实现:synchronized 是 JVM 关键字,加锁和释放由 JVM 自动管理;ReentrantLock 是 Java 语言层面的 API 实现,必须由开发者手动加锁,且必须在 finally 代码块中显式释放锁;
    • 功能特性:两者都支持可重入,但 ReentrantLock 提供了更丰富的特性,如支持公平锁/非公平锁切换、可中断等锁、超时尝试拿锁以及多条件变量精确唤醒;
    • 性能考量:在现代 JDK 中两者性能差异已不大。业务场景简单时优先选择 synchronized(更安全简洁);需要用到超时控制或精确唤醒等高级特性时选择 ReentrantLock。
  • 重点补充与避坑:

    • ReentrantLock 超出 synchronized 的三大实战高级特性:
      1. 可响应中断(lockInterruptibly()):在等锁过程中如果收到中断信号可主动放弃等锁,有效避免死锁;
      2. 超时尝试拿锁(tryLock(timeout, unit)):在规定时间内尝试获取锁,拿不到则返回 false 进行服务降级,防止线程无限期阻塞;
      3. 多条件队列(Condition):synchronized 只有一个共享的 WaitSet;ReentrantLock 可通过 newCondition() 绑定多个等待队列,实现定向、精确唤醒特定角色线程(如区分生产者与消费者)。
  • 代码规范示例(ReentrantLock 与 Condition 经典模式):

import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class ReentrantLockDemo {
// 默认非公平锁,构造传 true 为公平锁
private final Lock lock = new ReentrantLock();
// 绑定不同的条件队列,实现定向精确唤醒
private final Condition condition = lock.newCondition();
public void doWork() {
lock.lock(); // 1. 手动加锁
try {
System.out.println(Thread.currentThread().getName() + " 获取到锁,执行业务");
condition.await(); // 等待特定条件,释放锁并等待
} catch (InterruptedException e) {
System.out.println("等锁或等待条件过程中被中断");
} finally {
// 2. 铁律:必须在 finally 中显式释放锁,防止业务异常引发死锁
lock.unlock();
System.out.println(Thread.currentThread().getName() + " 释放锁");
}
}
}
  • 三大高级功能实战演示与原理解析:
package com.itheima.lock;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
public class ReentrantLockTest {
static ReentrantLock lock = new ReentrantLock();
static Condition c1 = lock.newCondition();
static Condition c2 = lock.newCondition();
public static void main(String[] args) throws InterruptedException {
// 1. 可打断测试: lockInterrupt();
// 2. 可超时测试: timeOutLock();
// 3. 多条件变量测试: conditionTest();
}
// 1. 可打断获取锁
public static void lockInterrupt() throws InterruptedException {
Thread t1 = new Thread(() -> {
try {
lock.lockInterruptibly(); // 尝试获取锁,但允许被中断
} catch (InterruptedException e) {
System.out.println("等待锁的过程中被打断,安全放弃");
return;
}
try {
System.out.println(Thread.currentThread().getName() + " 获得了锁");
} finally {
lock.unlock();
}
}, "t1");
lock.lock();
t1.start();
Thread.sleep(1000);
t1.interrupt(); // 主线程打断 t1,t1 响应中断退出,不陷入死锁
lock.unlock();
}
// 2. 超时尝试拿锁
public static void timeOutLock() throws InterruptedException {
Thread t1 = new Thread(() -> {
try {
if (!lock.tryLock(2, TimeUnit.SECONDS)) { // 最多等 2 秒
System.out.println("t1 获取锁超时,降级处理");
return;
}
} catch (InterruptedException e) {
return;
}
try {
System.out.println("t1 获得了锁");
} finally {
lock.unlock();
}
}, "t1");
lock.lock();
t1.start();
Thread.sleep(3000); // 占用锁 3 秒,促使 t1 超时
lock.unlock();
}
// 3. 多条件队列精准唤醒
public static void conditionTest() {
new Thread(() -> {
lock.lock();
try {
c1.await(); // 进入 c1 等待队列
System.out.println("t1 (c1) 被唤醒并获得锁");
} catch (InterruptedException e) {}
finally { lock.unlock(); }
}, "t1").start();
new Thread(() -> {
lock.lock();
try {
c2.await(); // 进入 c2 等待队列
System.out.println("t2 (c2) 被唤醒并获得锁");
} catch (InterruptedException e) {}
finally { lock.unlock(); }
}, "t2").start();
new Thread(() -> {
lock.lock();
try {
c1.signalAll(); // 仅唤醒等待 c1 的 t1,不会惊动等待 c2 的 t2
} finally { lock.unlock(); }
}, "t3").start();
}
}

17. 如何排查与定位 Java 中的死锁(Deadlock)?#

  • 我的理解:
    排查死锁通常使用 JDK 自带的排错命令组合:

    1. 使用 jps(JVM Process Status Tool)查看系统上正在运行的 Java 进程列表,获取目标 Java 程序的 PID(进程 ID);
    2. 使用 jstack 打印并分析该进程的线程堆栈快照(jstack -l <PID>)。命令执行后会自动检测死锁,并在日志中输出死锁信息与具体的代码行号,方便开发者精确定位并修改代码。
  • 重点补充与避坑:

    • 概念明确:PID 指的是进程 ID(Process ID),不是线程 ID。
    • jstack 诊断特征:如果在运行中有死锁,jstack 会在输出的最底部直接、明确地打印出 Found 1 deadlock.,并清晰列出哪个线程持有了哪把锁在等待哪把锁,以及发生死锁的 Java 代码具体行号。
    • 可视化排查工具:
      • jconsole:连接进程后进入“线程”面板,点击底部的【检测死锁】按钮即可直观查看;
      • VisualVM:进入“线程”面板,出现死锁时会自动弹出红色告警提示;
      • Arthas:线上常用阿里巴巴开源诊断工具,直接执行 thread -b 即可自动定位阻碍其他线程的死锁线程。
    • 死锁预防与破除机制:
      1. 严格规定全局加锁顺序(破坏循环等待条件,如按照资源 ID 升序加锁);
      2. 改用 ReentrantLock.tryLock(timeout)(超时放弃拿锁,破坏请求与保持条件);
      3. 尽量缩小同步代码块的执行范围,避免嵌套持有多种锁资源。
  • jstack 死锁分析日志示例(关键诊断信息):

Found one Java-level deadlock:
=============================
"Thread-1":
waiting to lock monitor 0x00007f (object 0x111, a java.lang.Object),
which is held by "Thread-0"
"Thread-0":
waiting to lock monitor 0x00008f (object 0x222, a java.lang.Object),
which is held by "Thread-1"
Java stack information for the threads listed above:
===================================================
"Thread-1":
at DeadLockDemo.lambda$main$1(DeadLockDemo.java:23)
- waiting to lock <0x111> (a java.lang.Object)
- locked <0x222> (a java.lang.Object)
"Thread-0":
at DeadLockDemo.lambda$main$0(DeadLockDemo.java:12)
- waiting to lock <0x222> (a java.lang.Object)
- locked <0x111> (a java.lang.Object)
Found 1 deadlock.

18. ConcurrentHashMap 的底层原理与并发设计(JDK 7 vs JDK 8+)#

  • 核心演进对比:

    • JDK 7:采用 Segment 分段锁(继承自 ReentrantLock),每个 Segment 守护一部分 Hash 槽(默认并发度 16),并发度受限于 Segment 数量。
    • JDK 8+:废弃了 Segment,采用 Node 数组 + 链表 / 红黑树 结构,结合 CAS + synchronized 实现更细粒度的桶级锁。锁只锁定当前桶的头节点,并发度直接提升为桶数组长度级别。
  • JDK 8+ 中 CAS 与 synchronized 的精妙配合:

    1. CAS:抢占空桶(无锁快冲):
      线程根据 key 的哈希计算桶下标 i。如果 table[i] == null,直接通过 casTabAt(table, i, null, newNode) 抢占空桶。成功则完成插入,失败则自旋重试,完全不引入线程阻塞。
    2. synchronized:保护非空桶的复杂修改(细粒度互斥):
      如果桶内已经存在节点(哈希冲突),需要进行链表遍历、节点追加或红黑树平衡,这属于多步复合操作。此时使用 synchronized (firstNode) 锁住当前桶的头节点,保证多步操作的原子性与内存可见性。
    3. 为什么要把 CAS 和 synchronized 结合起来?
      • 空桶赋值操作单一,使用 CAS 零阻塞;
      • 冲突桶操作复杂,使用 synchronized 简单可靠;
      • 锁仅作用于冲突的单个桶头节点,不同桶之间的操作完全并发执行,相同桶才需要排队。
  • 重点补充与避坑(线程安全的边界):
    ConcurrentHashMap 仅能保证单个方法的原子性(如 put、get、remove)。对于包含多个步骤的复合操作(如“先检查再更新”):

// 错误示范:复合操作仍然存在并发竞态条件
if (!map.containsKey(key)) {
map.put(key, value);
}
// 正确示范:使用底层原子复合方法
map.putIfAbsent(key, value);
// 或使用函数式原子更新
map.computeIfAbsent(key, k -> computeValue(k));

19. 导致并发程序出现 Bug 的根本原因(并发三要素深度总结)#

  • 核心总结:并发 Bug 的三大根源:
    在多线程并发编程中,所有的线程安全问题归根结底都可以追溯到以下三点:
并发要素产生根源现实影响与表现Java 对应防护手段
可见性 (Visibility)CPU 多级高速缓存与写缓冲区一个线程修改的共享变量,另一个线程读不到最新值,引发死循环或脏读volatile、synchronized、Lock、final
原子性 (Atomicity)操作系统的线程分时调度与时间片切换类似 count++ 的多步复合指令被打断,导致更新丢失与写覆盖synchronized、Lock、JUC 原子类(CAS)
有序性 (Ordering)编译器优化与 CPU 指令重排序单线程保证结果一致,但多线程下指令时序错乱,导致拿到半成品对象(如 DCL 逸出)volatile(内存屏障)、synchronized、Happens-Before 原则
  • 总结口诀:
    缓存引发可见性,分时带来原子性,重排扰乱有序性。掌握这三大要素与底层硬件成因,即可全面贯通 Java 并发安全的底层防御体系。

线程池#

20. 线程池的 7 大核心参数、执行流程与 4 大拒绝策略#

  • 原图解参考:
  • 我的理解: 可以通过**“银行办事大厅”**形象记忆线程池的 7 大核心参数:
  1. corePoolSize(核心线程数):银行平时常驻坐班的正式柜员;即便没有业务处理也不会被解雇下班;
  2. maximumPoolSize(最大线程数):人流暴增时大厅开启的最大柜台数(正式柜员 + 临时加班柜员);
  3. keepAliveTime(非核心线程存活时间):临时加班柜员无客户办理业务后,持续空闲多久后解聘下班;
  4. unit(时间单位):存活时间的计量单位(秒、毫秒等);
  5. workQueue(阻塞队列):大厅等候区的长椅座位数,正式柜员忙不过来时,客户坐在长椅上排队等待;
  6. threadFactory(线程工厂):招聘中介公司,负责统一创建线程并赋予有意义的业务名称;
  7. handler(拒绝策略):大厅保安,当柜台全部开满且等候区长椅也全部坐满时,对新来的客户采取的拦截处置方案。

任务提交流程(三道防线机制): 新任务提交到线程池时的流转顺序:

  1. 第一道防线(核心线程):若当前活跃线程数 < corePoolSize,即便已有核心线程处于空闲,也会优先创建新的核心线程来直接执行任务;
  2. 第二道防线(阻塞队列):若当前活跃线程数 == corePoolSize,新提交的任务进入 workQueue 阻塞队列排队等候;
  3. 第三道防线(临时救急线程):若阻塞队列已满,且当前活跃线程数 < maximumPoolSize,线程池会立刻创建临时救急线程来直接执行该任务;
  4. 最终兜底(拒绝策略):若阻塞队列已满,且活跃线程数已达到 maximumPoolSize,线程池将触发配置的 RejectedExecutionHandler 拒绝策略。
  • 重点补充与避坑:

  • 4 大内置拒绝策略对比(必背考点):

    1. AbortPolicy(JDK 默认):直接抛出 RejectedExecutionException 异常,阻止系统正常运行(硬拒绝,适合金融交易等宁可报错也绝不能丢任务的核心业务);
    2. CallerRunsPolicy(调用者运行):让提交任务的主线程(如 main 线程)自己去执行该任务。这种机制不仅保证了任务绝不会被丢弃,更重要的是它会直接占用调用者线程的时间,从而自动降低上游提交任务的速率,起到天然的反压限流保护效果;
    3. DiscardPolicy(静默丢弃):直接悄无声息地丢弃最新提交的任务,不抛异常也不执行(适合日志采集、指标打点等允许丢失数据的边缘场景);
    4. DiscardOldestPolicy(丢弃最老任务):悄悄丢弃阻塞队列中最前面的任务(即等待时间最长的队头任务),然后尝试将当前新任务重新提交进队列。
  • 高频考点与实战避坑:

    • 为什么任务 5 会比队列中的任务 3、任务 4 先被执行? 当核心线程满且队列满后,触发创建临时救急线程。该救急线程创建后会直接领取代入它的那个新任务(任务 5)执行,而不会先去队列头部取任务。因此在突发流量下,新任务可能会先于在队列中排队的任务被处理!
    • 核心线程能够销毁吗? 默认情况下核心线程常驻不销毁;但若调用了 threadPool.allowCoreThreadTimeOut(true),核心线程在空闲超过 keepAliveTime 后同样会被回收。
    • 核心线程是启动时就创建好的吗? 默认是懒加载(有任务提交时才创建);若需要在系统启动阶段预热线程池以抗住突发流量,可调用 threadPool.prestartAllCoreThreads() 提前将核心线程全部初始化。
  • 实战验证代码与执行轨迹解析:

package com.itheima.threadpool;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
public class TestThreadPoolExecutor {
static class MyTask implements Runnable {
private final String name;
private final long duration;
public MyTask(String name) {
this(name, 0);
}
public MyTask(String name, long duration) {
this.name = name;
this.duration = duration;
}
@Override
public void run() {
try {
LoggerUtils.get("myThread").debug("running..." + this);
Thread.sleep(duration);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
@Override
public String toString() {
return "MyTask(" + name + ")";
}
}
public static void main(String[] args) throws InterruptedException {
AtomicInteger c = new AtomicInteger(1);
ArrayBlockingQueue<Runnable> queue = new ArrayBlockingQueue<>(2);
LinkedBlockingQueue linkedBlockingQueue = new LinkedBlockingQueue();
ThreadPoolExecutor threadPool = new ThreadPoolExecutor(
2, //核心线程数
3, //最大线程数
0, //应急线程存活时间
TimeUnit.MILLISECONDS, // 应急线程存活单位
queue, // 阻塞队列
r -> new Thread(r, "myThread" + c.getAndIncrement()), // 线程工厂
new ThreadPoolExecutor.AbortPolicy()); //拒绝策略
showState(queue, threadPool);
threadPool.submit(new MyTask("1", 3600000));
showState(queue, threadPool);
threadPool.submit(new MyTask("2", 3600000));
showState(queue, threadPool);
threadPool.submit(new MyTask("3"));
showState(queue, threadPool);
threadPool.submit(new MyTask("4"));
showState(queue, threadPool);
threadPool.submit(new MyTask("5",3600000));
showState(queue, threadPool);
threadPool.submit(new MyTask("6"));
showState(queue, threadPool);
}
private static void showState(ArrayBlockingQueue<Runnable> queue, ThreadPoolExecutor threadPool) {
try {
Thread.sleep(300);
} catch (InterruptedException e) {
e.printStackTrace();
}
List<Object> tasks = new ArrayList<>();
for (Runnable runnable : queue) {
try {
Field callable = FutureTask.class.getDeclaredField("callable");
callable.setAccessible(true);
Object adapter = callable.get(runnable);
Class<?> clazz = Class.forName("java.util.concurrent.Executors$RunnableAdapter");
Field task = clazz.getDeclaredField("task");
task.setAccessible(true);
Object o = task.get(adapter);
tasks.add(o);
} catch (Exception e) {
e.printStackTrace();
}
}
LoggerUtils.main.debug("pool size: {}, queue: {}", threadPool.getPoolSize(), tasks);
}
}
提交任务当时系统状态触发结果与执行状态
任务 1活跃线程 0 < 核心数 2创建核心线程 1 执行(模拟耗时 1 小时)
任务 2活跃线程 1 < 核心数 2创建核心线程 2 执行(模拟耗时 1 小时)
任务 3活跃线程 2 = 核心数 2核心已满,进入阻塞队列排队(队列:[3])
任务 4活跃线程 2 = 核心数 2核心已满,进入阻塞队列排队(队列:[3, 4])
任务 5队列已满 (2/2),线程数 2 < 最大 3立即创建临时救急线程 3 直接执行任务 5(耗时 1 小时)
任务 6活跃线程 3 = 最大 3,队列已满触发拒绝策略:默认抛出 RejectedExecutionException 异常

21. 常见的阻塞队列对比(ArrayBlockingQueue vs LinkedBlockingQueue)#

  • 原图解参考:
  • 我的理解: 阻塞队列(BlockingQueue)是线程池中任务排队的核心容器。在日常开发与面试中最常被对比的两大实现是 ArrayBlockingQueue 与 LinkedBlockingQueue:
  1. ArrayBlockingQueue:底层基于定长数组实现,是有界队列,初始化时必须强制指定容量;
  2. LinkedBlockingQueue:底层基于单向链表实现,如果不指定容量,默认容量为 Integer.MAX_VALUE(约为 21 亿),本质上等同于无界队列。
  • 重点补充与避坑:
  • 核心维度深度对比矩阵:
对比维度ArrayBlockingQueueLinkedBlockingQueue
底层数据结构定长 Object 循环数组单向链表(动态包装 Node 节点)
是否有界强制有界(构造必须指定容量)默认无界(不传参为 Integer.MAX_VALUE,极易 OOM;可显式指定容量)
锁分离机制单把锁(ReentrantLock 统一管理入队和出队)两把锁(putLock 与 takeLock 读写分离)
并发吞吐性能入队与出队互斥竞争同一把锁,并发竞争相对激烈生产者(入队)与消费者(出队)并行不互斥,高并发吞吐量更高
内存与 GC 压力初始化时一次性预分配连续内存数组,后续零 GC 内存开销每次入队均需 new Node<E>(),出队后需垃圾回收,高并发下 GC 开销稍大
  • 其他常见 BlockingQueue 实现(高分加分项):
    • SynchronousQueue:容量为 0 的阻塞队列。它不存储任何元素,每一个 put 操作必须等待另一个线程执行 take 取走数据才能返回,否则持续阻塞(类似于“一手交钱一手交货”的现场交接)。Executors.newCachedThreadPool() 底层采用的正是该队列;
    • PriorityBlockingQueue:基于二叉小顶堆实现的无界优先级阻塞队列,能够按照元素的自然排序或 Comparator 顺序出队;
    • DelayQueue:基于优先级队列实现的无界延迟阻塞队列,队列中元素必须实现 Delayed 接口,只有当指定的延迟时间到达后才能从队列中被取出。Executors.newScheduledThreadPool() 底层采用的即是类似机制。

22. 如何合理配置线程池的核心线程数?#

  • 原图解参考:
  • 我的理解: 配置线程池的核心线程数不能凭感觉拍脑袋,必须结合**硬件 CPU 逻辑核心数(记为 NN)与任务的业务属性(CPU 密集型 vs IO 密集型)**进行针对性划分:

  • CPU 密集型任务(如大量复杂数学计算、密码加解密、音视频编解码、大文件压缩等):

    • 推荐配置:N+1N + 1(或直接等于 NN);
    • 底层逻辑:CPU 核心已经处于满负荷运算状态,创建过多线程不仅不能提速,反而会因为频繁的 CPU 时间片上下文切换(Context Switch)带来额外的性能内耗;
  • IO 密集型任务(如 Web 业务接口、MySQL/Redis 数据库交互、RPC 远程调用、网络爬虫、第三方 API 通信等):

    • 经典推荐配置:2N+12N + 1 或按照理论估算公式动态配置;
    • 底层逻辑:线程大部分时间都阻塞在网络通信或磁盘 IO 上,CPU 利用率偏低。配置较多的线程能让 CPU 在某个线程等待 IO 时迅速切换执行其他就绪线程,大幅提升 CPU 核心的整体吞吐能力。
  • 重点补充与避坑:

  • 高频追问 1:为什么 CPU 密集型通常要配置为 N+1N + 1,而不是刚好 NN?

    • 替补队员理论:即使是纯计算密集型任务,线程也可能偶尔因为操作系统的缺页中断(Page Fault)、垃圾回收短暂暂停或偶发的外部资源挂起而让出 CPU。额外多配置的 1 个线程充当“替补队员”,能在此刻立即顶上接替运算,确保系统 CPU 核心始终维持 100% 的饱满利用率,避免算力真空。
  • 高频追问 2:IO 密集型的精确理论计算公式是什么?

    • 业界公认的标准经验估算公式为: 最佳线程数=N×(1+IO 阻塞等待时间CPU 纯计算时间)\text{最佳线程数} = N \times (1 + \frac{\text{IO 阻塞等待时间}}{\text{CPU 纯计算时间}})
    • 计算推演:若一个业务请求总耗时 100ms,其中 CPU 计算耗时 10ms,其余 90ms 均在等待数据库查询和 RPC 网络传输返回,则等待时间与计算时间之比为 90/10=990 / 10 = 9。在 4 核机器(N=4N = 4)上: 最佳线程数=4×(1+9)=40\text{最佳线程数} = 4 \times (1 + 9) = 40 此时配置 40 个核心线程可以充分榨干 CPU 的调度能力。
  • 实战架构演进:高并发长耗时场景下,无限增大线程池是解法吗?

    • 单机瓶颈警示:绝对不能盲目调大线程池!在 JVM 中,每个 Java 线程在 64 位系统下默认占用 1MB 物理栈空间(-Xss1m),若开辟数千个线程将直接吃光系统堆外物理内存,且成千上万个线程剧烈争抢 CPU 会造成灾难级的上下文切换开销,导致 CPU Load 飙升至 100% 系统假死;
    • 工业级生产解决方案:
      1. 引入消息队列(MQ)削峰填谷:面对瞬时高并发长任务,上游服务直接将任务发送至 Kafka / RocketMQ 暂存并快速给前端响应,下游微服务按自身处理能力恒速拉取消费;
      2. 分布式容器水平扩容:单机物理资源有限,通过 K8s 的 HPA 弹性伸缩机制自动水平增加 Pod 节点分摊流量;
      3. 动态线程池(开源选型):生产环境中推荐使用 Dynamic-TP 或 Hippo4j,将线程池的核心参数托管至 Nacos / Apollo 等配置中心,实现无需重启服务的运行时参数热更新与全链路监控告警。

23. 为什么不推荐使用 Executors 创建线程池?(《阿里 Java 开发手册》两大 OOM 根因规约)#

  • 原图解参考:
  • 我的理解与阿里规约核心背景: 《阿里巴巴 Java 开发手册》中明确规定了一条强制性并发铁律:

【强制】 线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。

规约的核心根因在于:Executors 封装的 4 种常用快捷线程池底层隐蔽了关键参数配置,其默认无界队列或无上限创建线程的缺陷,极易在生产高并发流量下直接引发 JVM 内存溢出(OOM 崩溃)。

  • 重点补充:两大 OOM 致命陷阱深度剖析:

  • 陷阱 1:无界阻塞队列导致堆内存溢出(Java heap space)

    • 涉及类型:Executors.newFixedThreadPool(n) 与 Executors.newSingleThreadExecutor();
    • 底层源码成因:它们底层使用的等待队列是 LinkedBlockingQueue,且调用的是无参构造方法。此时队列的最大容量默认为 Integer.MAX_VALUE(约为 21 亿);
    • 崩溃路径:当外部请求流量暴涨而线程处理速度较慢时,新任务会在内存队列中无限堆积排队,迅速榨干 JVM 堆内存,最终直接抛出 java.lang.OutOfMemoryError: Java heap space。
  • 陷阱 2:最大线程数无上限导致系统线程耗尽(unable to create new native thread)

    • 涉及类型:Executors.newCachedThreadPool() 与 Executors.newScheduledThreadPool(n);
    • 底层源码成因:它们的 maximumPoolSize 默认参数均被设置为了 Integer.MAX_VALUE;
    • 崩溃路径:CachedThreadPool 没有任何核心线程,任务到来直接创建新线程;当大量耗时任务涌入时,线程池会疯狂创建数以万计的操作系统内核级线程,迅速耗尽系统进程的线程句柄上限与内存,最终崩溃抛出 java.lang.OutOfMemoryError: unable to create new native thread。
  • 四大快捷线程池实战验证与运行轨迹:

1. FixedThreadPool(固定大小线程池)#

  • 核心参数:核心线程数 = 最大线程数,使用无界队列 LinkedBlockingQueue。
package com.itheima.threadpool;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class FixedThreadPoolCase {
static class FixedThreadDemo implements Runnable{
@Override
public void run() {
String name = Thread.currentThread().getName();
for (int i = 0; i < 2; i++) {
System.out.println(name + ":" + i);
}
}
}
public static void main(String[] args) throws InterruptedException {
//创建一个固定大小的线程池,核心线程数和最大线程数都是3
ExecutorService executorService = Executors.newFixedThreadPool(3);
for (int i = 0; i < 5; i++) {
executorService.submit(new FixedThreadDemo());
Thread.sleep(10);
}
executorService.shutdown();
}
}
PS C:\JavaProjects\面试题目\juc-project> & 'C:\Software\Jdks\bin\java.exe' '@C:\Users\Chenxin\AppData\Local\Temp\cp_bwbiqm81wdlttphwvn4t7m08j.argfile' 'com.itheima.threadpool.FixedThreadPoolCase'
pool-1-thread-1:0
pool-1-thread-1:1
pool-1-thread-2:0
pool-1-thread-2:1
pool-1-thread-3:0
pool-1-thread-3:1
pool-1-thread-1:0
pool-1-thread-1:1
pool-1-thread-2:0
pool-1-thread-2:1

执行现象解析:可以看到,线程池中始终只有 3 个核心线程被复用执行任务。

2. SingleThreadExecutor(单线程化线程池)#

  • 核心参数:核心线程数 = 最大线程数 = 1,使用无界队列 LinkedBlockingQueue,保证所有任务按照 FIFO 顺序依次执行。
package com.itheima.threadpool;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class NewSingleThreadCase {
static int count = 0;
static class Demo implements Runnable {
@Override
public void run() {
count++;
System.out.println(Thread.currentThread().getName() + ":" + count);
}
}
public static void main(String[] args) throws InterruptedException {
//单个线程池,核心线程数和最大线程数都是1
ExecutorService exec = Executors.newSingleThreadExecutor();
for (int i = 0; i < 10; i++) {
exec.execute(new Demo());
Thread.sleep(5);
}
exec.shutdown();
}
}
PS C:\JavaProjects\面试题目\juc-project> & 'C:\Software\Jdks\bin\java.exe' '@C:\Users\Chenxin\AppData\Local\Temp\cp_bwbiqm81wdlttphwvn4t7m08j.argfile' 'com.itheima.threadpool.NewSingleThreadCase'
pool-1-thread-1:1
pool-1-thread-1:2
pool-1-thread-1:3
pool-1-thread-1:4
pool-1-thread-1:5
pool-1-thread-1:6
pool-1-thread-1:7
pool-1-thread-1:8
pool-1-thread-1:9
pool-1-thread-1:10

执行现象解析:全局只有 1 个工作线程,严格按照任务提交的顺序单线程串行执行。

3. CachedThreadPool(可缓存弹性线程池)#

  • 核心参数:核心线程数 = 0,最大线程数 = Integer.MAX_VALUE,救急线程存活时间为 60 秒,使用不存储元素的 SynchronousQueue。
package com.itheima.threadpool;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class CachedThreadPoolCase {
static class Demo implements Runnable {
@Override
public void run() {
String name = Thread.currentThread().getName();
try {
//修改睡眠时间,模拟线程执行需要花费的时间
Thread.sleep(100);
System.out.println(name + "执行完了");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public static void main(String[] args) throws InterruptedException {
//创建一个缓存的线程,没有核心线程数,最大线程数为Integer.MAX_VALUE
ExecutorService exec = Executors.newCachedThreadPool();
for (int i = 0; i < 10; i++) {
exec.execute(new Demo());
Thread.sleep(1);
}
exec.shutdown();
}
}
PS C:\JavaProjects\面试题目\juc-project> & 'C:\Software\Jdks\bin\java.exe' '@C:\Users\Chenxin\AppData\Local\Temp\cp_bwbiqm81wdlttphwvn4t7m08j.argfile' 'com.itheima.threadpool.CachedThreadPoolCase'
Picked up JAVA_TOOL_OPTIONS: -Dfile.encoding=UTF-8
pool-1-thread-1执行完了
pool-1-thread-2执行完了
pool-1-thread-3执行完了
pool-1-thread-4执行完了
pool-1-thread-5执行完了
pool-1-thread-6执行完了
pool-1-thread-7执行完了
pool-1-thread-8执行完了
pool-1-thread-9执行完了
pool-1-thread-10执行完了

执行现象解析:每个任务执行需要耗费 100ms,而外部每隔 1ms 就提交一个新任务。由于没有核心线程且阻塞队列容量为 0,每个任务都迫使线程池创建一个全新的临时线程,瞬间创建了 10 个线程!若任务耗时较长(如 1000ms),每秒数千请求涌入将直接在极短时间内拉起数千个线程,导致机器 CPU 与内存瞬间崩溃。

4. ScheduledThreadPool(定时及周期任务线程池)#

  • 核心参数:固定核心线程数,最大线程数 = Integer.MAX_VALUE,基于小顶堆延迟队列 DelayedWorkQueue。
package com.itheima.threadpool;
import java.util.Date;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
public class ScheduledThreadPoolCase {
static class Task implements Runnable {
@Override
public void run() {
try {
String name = Thread.currentThread().getName();
System.out.println(name + ", 开始:" + new Date());
Thread.sleep(1000);
System.out.println(name + ", 结束:" + new Date());
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public static void main(String[] args) throws InterruptedException {
//按照周期执行的线程池,核心线程数为2,最大线程数为Integer.MAX_VALUE
ScheduledExecutorService scheduledThreadPool = Executors.newScheduledThreadPool(2);
System.out.println("程序开始:" + new Date());
/**
* schedule 提交任务到线程池中
* 第一个参数:提交的任务
* 第二个参数:任务执行的延迟时间
* 第三个参数:时间单位
*/
scheduledThreadPool.schedule(new Task(), 0, TimeUnit.SECONDS);
scheduledThreadPool.schedule(new Task(), 1, TimeUnit.SECONDS);
scheduledThreadPool.schedule(new Task(), 5, TimeUnit.SECONDS);
Thread.sleep(5000);
// 关闭线程池
scheduledThreadPool.shutdown();
}
}
PS C:\JavaProjects\面试题目\juc-project> & 'C:\Software\Jdks\bin\java.exe' '@C:\Users\Chenxin\AppData\Local\Temp\cp_bwbiqm81wdlttphwvn4t7m08j.argfile' 'com.itheima.threadpool.ScheduledThreadPoolCase'
Picked up JAVA_TOOL_OPTIONS: -Dfile.encoding=UTF-8
程序开始:Sat Aug 22 14:25:47 CST 2026
pool-1-thread-1, 开始:Sat Aug 22 14:25:47 CST 2026
pool-1-thread-1, 结束:Sat Aug 22 14:25:48 CST 2026
pool-1-thread-2, 开始:Sat Aug 22 14:25:48 CST 2026
pool-1-thread-2, 结束:Sat Aug 22 14:25:49 CST 2026
pool-1-thread-1, 开始:Sat Aug 22 14:25:52 CST 2026
pool-1-thread-1, 结束:Sat Aug 22 14:25:53 CST 2026

执行现象解析:延迟任务分别设置了 0 秒、1 秒和 5 秒后触发,内部由 2 个核心线程协同调度执行。

  • 阿里规约警告图解:
  • 标准解答:生产级 ThreadPoolExecutor 规范写法: 在商业级生产项目中,必须通过原生 ThreadPoolExecutor 显式声明所有参数,明确指定有界队列与业务语义明确的线程工厂:
// 生产级线程池构建范式
int corePoolSize = Runtime.getRuntime().availableProcessors();
int maxPoolSize = corePoolSize * 2;
long keepAliveTime = 60L;
ThreadPoolExecutor executor = new ThreadPoolExecutor(
corePoolSize,
maxPoolSize,
keepAliveTime,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(1000), // 1. 强制使用有界队列,避免无限堆积 OOM
new ThreadFactory() { // 2. 自定义业务命名的线程工厂,便于 jstack 问题排查
private final AtomicInteger threadNumber = new AtomicInteger(1);
@Override
public Thread newThread(Runnable r) {
Thread t = new Thread(r, "order-service-pool-" + threadNumber.getAndIncrement());
t.setDaemon(false);
t.setPriority(Thread.NORM_PRIORITY);
return t;
}
},
new ThreadPoolExecutor.CallerRunsPolicy() // 3. 生产级拒绝策略(提供天然反压)
);

多线程实战与常用工具#

24. CountDownLatch 实战:海量数据批量导入与并发内存控制#

  • 原图解参考:
  • 我的理解: CountDownLatch(倒计时门闩/减法计数器)是 JUC 并发包中非常常用的线程协同工具:
  1. 工作机制:初始化时指定计数值(new CountDownLatch(count));各个子线程完成一项任务后调用 countDown() 使计数减 1;
  2. 协调主线程:主线程调用 await() 阻塞等待,直到计数器递减到 0 时,主线程才被唤醒继续往下执行后续逻辑;
  3. 典型应用:多线程并行处理批量任务,等待所有分片批次全部完成后执行结果汇总、统一提交或统计耗时。
  • CountDownLatch 基础工作模型验证:
package com.itheima.application;
import java.util.concurrent.CountDownLatch;
public class CountDownLatchDemo {
public static void main(String[] args) throws InterruptedException {
//初始化了一个倒计时锁 参数为 3
CountDownLatch latch = new CountDownLatch(3);
new Thread(() -> {
System.out.println(Thread.currentThread().getName()+"-begin...");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
//count--
latch.countDown();
System.out.println(Thread.currentThread().getName()+"-end..." +latch.getCount());
}).start();
new Thread(() -> {
System.out.println(Thread.currentThread().getName()+"-begin...");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
//count--
latch.countDown();
System.out.println(Thread.currentThread().getName()+"-end..." +latch.getCount());
}).start();
new Thread(() -> {
System.out.println(Thread.currentThread().getName()+"-begin...");
try {
Thread.sleep(1500);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
//count--
latch.countDown();
System.out.println(Thread.currentThread().getName()+"-end..." +latch.getCount());
}).start();
String name = Thread.currentThread().getName();
System.out.println(name + "-waiting...");
//等待其他线程完成
latch.await();
System.out.println(name + "-wait end...");
}
}
PS C:\JavaProjects\面试题目\juc-project> & 'C:\Software\Jdks\bin\java.exe' '@C:\Users\Chenxin\AppData\Local\Temp\cp_bwbiqm81wdlttphwvn4t7m08j.argfile' 'com.itheima.application.CountDownLatchDemo'
Picked up JAVA_TOOL_OPTIONS: -Dfile.encoding=UTF-8
Thread-0-begin...
Thread-1-begin...
main-waiting...
Thread-2-begin...
Thread-0-end...2
Thread-2-end...1
Thread-1-end...0
main-wait end...

执行现象解析:每个子线程完成工作后调用 countDown() 将计数减 1,主线程通过 await() 阻塞等待,直至倒计数为 0 时才恢复执行。

  • 重点补充与生产级实战避坑:

1. 初版实现与潜在隐患(伪内存分页与 OOM 风险)#

将 CountDownLatch 的初始值设为总批次页数,主线程每生成一页任务就提交给线程池处理:

package com.itheima.cdl.service.impl;
import com.alibaba.fastjson.JSON;
import com.itheima.cdl.mapper.ApArticleMapper;
import com.itheima.cdl.pojo.SearchArticleVo;
import com.itheima.cdl.service.ApArticleService;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.elasticsearch.action.bulk.BulkRequest;
import org.elasticsearch.action.index.IndexRequest;
import org.elasticsearch.client.RequestOptions;
import org.elasticsearch.client.RestHighLevelClient;
import org.elasticsearch.common.xcontent.XContentType;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
@Service
@Transactional
@Slf4j
public class ApArticleServiceImpl implements ApArticleService {
@Autowired
private ApArticleMapper apArticleMapper;
@Autowired
private RestHighLevelClient client;
@Autowired
private ExecutorService executorService;
private static final String ARTICLE_ES_INDEX = "app_info_article";
private static final int PAGE_SIZE = 2000;
/**
* 批量导入
*/
@SneakyThrows
@Override
public void importAll() {
//总条数
int count = apArticleMapper.selectCount();
//总页数
int totalPageSize = count % PAGE_SIZE == 0 ? count / PAGE_SIZE : count / PAGE_SIZE + 1;
//开始执行时间
long startTime = System.currentTimeMillis();
//一共有多少页,就创建多少个CountDownLatch的计数
CountDownLatch countDownLatch = new CountDownLatch(totalPageSize);
int fromIndex;
List<SearchArticleVo> articleList = null;
for (int i = 0; i < totalPageSize; i++) {
//起始分页条数
fromIndex = i * PAGE_SIZE;
//查询文章
articleList = apArticleMapper.loadArticleList(fromIndex, PAGE_SIZE);
//创建线程,做批量插入es数据操作
TaskThread taskThread = new TaskThread(articleList, countDownLatch);
//执行线程
executorService.execute(taskThread);
}
//调用await()方法,用来等待计数归零
countDownLatch.await();
long endTime = System.currentTimeMillis();
log.info("es索引数据批量导入共:{}条,共消耗时间:{}秒", count, (endTime - startTime) / 1000);
}
class TaskThread implements Runnable {
List<SearchArticleVo> articleList;
CountDownLatch cdl;
public TaskThread(List<SearchArticleVo> articleList, CountDownLatch cdl) {
this.articleList = articleList;
this.cdl = cdl;
}
@SneakyThrows
@Override
public void run() {
//批量导入
BulkRequest bulkRequest = new BulkRequest(ARTICLE_ES_INDEX);
for (SearchArticleVo searchArticleVo : articleList) {
bulkRequest.add(new IndexRequest().id(searchArticleVo.getId().toString())
.source(JSON.toJSONString(searchArticleVo), XContentType.JSON));
}
//发送请求,批量添加数据到es索引库中
client.bulk(bulkRequest, RequestOptions.DEFAULT);
//让计数减一
cdl.countDown();
}
}
}

致命缺陷深度剖析: 上述初版代码虽然实现了“分批写入 ES”,但并没有实现有界的内存分批!

  1. 内存堆积 OOM:主线程在循环中快速预查数据库,将携带大量 articleList 实体的任务塞入线程池的阻塞队列。当数据库查询速度快于 ES 写入消费速度时,上百页的完整数据实体全部被阻塞队列强引用持有,内存占用呈 O(总数据量)O(\text{总数据量}),极易瞬间打爆 JVM 堆内存;
  2. 主线程假死挂起:若某个分批任务在执行时发生异常,或线程池触发了 DiscardPolicy 丢弃策略,导致该任务的 countDown() 未被执行,主线程的 await() 将永久挂起,程序彻底卡死。

2. 生产级优化方案:参数化轻量任务与即用即查(内存恒定可控)#

核心设计理念: 主线程向线程池提交任务时仅传递分页元数据(fromIndex 与 pageSize),任务对象极度轻量。只有当工作线程被真正调度执行时才去查库加载数据,写入 ES 完成后局部变量随栈帧弹出立即失效,GC 可即时回收。 系统峰值内存占用由 O(总数据量)O(\text{总数据量}) 暴降为恒定的 O(活跃工作线程数×PAGE_SIZE)O(\text{活跃工作线程数} \times \text{PAGE\_SIZE})!

package com.itheima.cdl.service.impl;
import com.alibaba.fastjson.JSON;
import com.itheima.cdl.mapper.ApArticleMapper;
import com.itheima.cdl.pojo.SearchArticleVo;
import com.itheima.cdl.service.ApArticleService;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.elasticsearch.action.bulk.BulkRequest;
import org.elasticsearch.action.bulk.BulkResponse;
import org.elasticsearch.action.index.IndexRequest;
import org.elasticsearch.client.RequestOptions;
import org.elasticsearch.client.RestHighLevelClient;
import org.elasticsearch.common.xcontent.XContentType;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.TimeUnit;
@Service
@Slf4j
public class ApArticleServiceImpl implements ApArticleService {
@Autowired
private ApArticleMapper apArticleMapper;
@Autowired
private RestHighLevelClient client;
@Autowired
private ExecutorService executorService;
private static final String ARTICLE_ES_INDEX = "app_info_article";
private static final int PAGE_SIZE = 2000;
@SneakyThrows
@Override
public void importAll() {
int count = apArticleMapper.selectCount();
if (count == 0) {
log.info("无数据需要导入");
return;
}
int totalPageSize = (count + PAGE_SIZE - 1) / PAGE_SIZE;
long startTime = System.currentTimeMillis();
CountDownLatch countDownLatch = new CountDownLatch(totalPageSize);
log.info("开始导入 ES,数据总量: {},总批次数: {}", count, totalPageSize);
for (int i = 0; i < totalPageSize; i++) {
int fromIndex = i * PAGE_SIZE;
// 仅传递分页参数,任务对象极轻量,主线程几乎不占内存
executorService.execute(new ImportTask(fromIndex, PAGE_SIZE, countDownLatch));
}
// 等待所有批次执行完成(带超时时间,防止异常卡死)
boolean completed = countDownLatch.await(30, TimeUnit.MINUTES);
long endTime = System.currentTimeMillis();
if (completed) {
log.info("ES 索引数据批量导入完成,共导入: {} 条,耗时: {} 秒", count, (endTime - startTime) / 1000);
} else {
log.warn("ES 索引导入超时!可能存在部分任务未完成");
}
}
class ImportTask implements Runnable {
private final int fromIndex;
private final int pageSize;
private final CountDownLatch cdl;
public ImportTask(int fromIndex, int pageSize, CountDownLatch cdl) {
this.fromIndex = fromIndex;
this.pageSize = pageSize;
this.cdl = cdl;
}
@Override
public void run() {
try {
// 1. 延迟加载:工作线程执行时才从 DB 查询数据
List<SearchArticleVo> articleList = apArticleMapper.loadArticleList(fromIndex, pageSize);
if (articleList != null && !articleList.isEmpty()) {
// 2. 批量写入 ES
BulkRequest bulkRequest = new BulkRequest(ARTICLE_ES_INDEX);
for (SearchArticleVo vo : articleList) {
bulkRequest.add(new IndexRequest().id(vo.getId().toString())
.source(JSON.toJSONString(vo), XContentType.JSON));
}
BulkResponse bulkResponse = client.bulk(bulkRequest, RequestOptions.DEFAULT);
if (bulkResponse.hasFailures()) {
log.error("批次 offset [{}-{}] 写入部分失败: {}", fromIndex, fromIndex + pageSize, bulkResponse.buildFailureMessage());
}
}
} catch (Exception e) {
log.error("批次 offset [{}-{}] 导入异常", fromIndex, fromIndex + pageSize, e);
} finally {
// 3. 必须在 finally 保证 countDown 执行
cdl.countDown();
// 4. 方法结束局部变量 articleList 失去引用,GC 即时回收
}
}
}
}
  • 生产实战总结与避坑铁律:
    1. 内存可控:内存峰值从 O(总数据量)O(\text{总数据量}) 降为 O(工作线程数×PAGE_SIZE)O(\text{工作线程数} \times \text{PAGE\_SIZE}),避免了阻塞队列堆积强引用实体对象导致的堆内存溢出;
    2. 健壮性兜底:countDown() 必须放置在 finally 块中确保无论是否异常都必定递减;主线程 await() 必须设置合理的最大超时时间(如 30 分钟),严禁无参永久阻塞。

25. 多线程数据汇总:多接口并行调用与性能优化(串行 vs 并行)#

  • 原图解参考:
  • 我的理解: 在微服务或聚合层(BFF)接口开发中,一个主业务接口(如“订单详情页”)往往需要调用多个远程 RPC / HTTP 子服务(如订单服务、商品服务、物流服务):
  1. 传统串行调用:按顺序依次调用各远程接口,总耗时为各项调用耗时之和(T=T订单+T商品+T物流T = T_{\text{订单}} + T_{\text{商品}} + T_{\text{物流}});一旦某个接口响应较慢,整体接口响应时间就会严重拖长;
  2. 多线程并行聚合调用:将各个独立的查询逻辑包装为 Callable 任务提交给线程池并发执行,主线程通过 Future.get() 汇总合并结果。整体耗时仅取决于耗时最长的那一个接口(T=max⁡(T订单,T商品,T物流)T = \max(T_{\text{订单}}, T_{\text{商品}}, T_{\text{物流}})),从而大幅降低接口 RT,成倍提升系统并发吞吐量。
  • 实战代码对比:串行 vs 并行汇总:
package com.itheima.cdl.controller;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestTemplate;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Future;
@RestController
@RequestMapping("/order_detail")
@Slf4j
public class OrderDetailController {
@Autowired
private RestTemplate restTemplate;
@Autowired
private ExecutorService executorService;
@SneakyThrows
@GetMapping("/get/detail_new/{id}")
public Map<String, Object> getOrderDetailNew() {
long startTime = System.currentTimeMillis();
Future<Map<String, Object>> f1 = executorService.submit(() -> {
Map<String, Object> r =
restTemplate.getForObject("http://localhost:9991/order/get/{id}", Map.class, 1);
return r;
});
Future<Map<String, Object>> f2 = executorService.submit(() -> {
Map<String, Object> r =
restTemplate.getForObject("http://localhost:9991/product/get/{id}", Map.class, 1);
return r;
});
Future<Map<String, Object>> f3 = executorService.submit(() -> {
Map<String, Object> r =
restTemplate.getForObject("http://localhost:9991/logistics/get/{id}", Map.class, 1);
return r;
});
Map<String, Object> resultMap = new HashMap<>();
resultMap.put("order", f1.get());
resultMap.put("product", f2.get());
resultMap.put("logistics", f3.get());
long endTime = System.currentTimeMillis();
log.info("接口调用共耗时:{}毫秒",endTime-startTime);
return resultMap;
}
@SneakyThrows
@GetMapping("/get/detail/{id}")
public Map<String, Object> getOrderDetail() {
long startTime = System.currentTimeMillis();
Map<String, Object> order = restTemplate.getForObject("http://localhost:9991/order/get/{id}", Map.class, 1);
Map<String, Object> product = restTemplate.getForObject("http://localhost:9991/product/get/{id}", Map.class, 1);
Map<String, Object> logistics = restTemplate.getForObject("http://localhost:9991/logistics/get/{id}", Map.class, 1);
long endTime = System.currentTimeMillis();
Map<String, Object> resultMap = new HashMap<>();
resultMap.put("order", order);
resultMap.put("product", product);
resultMap.put("logistics", logistics);
log.info("接口调用共耗时:{}毫秒",endTime-startTime);
return resultMap;
}
}
  • 重点补充与避坑:
    1. Future.get() 阻塞超时防御:Future.get() 是同步阻塞操作,生产环境中严禁无参调用 f.get()!若下游某个第三方接口网络抖动或发生假死,调用线程将被无限期挂起,导致上游请求积压乃至线程池耗尽。必须强制设置超时时间:f.get(1500, TimeUnit.MILLISECONDS),并在捕获 TimeoutException 后执行降级或托底默认值处理;
    2. 现代化技术演进(CompletableFuture 响应式编排):在 Java 8+ 生产环境中,更推荐使用 CompletableFuture.allOf()。它支持完全异步无阻塞回调(thenCombine、exceptionally 等),天然支持超时控制与声明式降级策略,极大减少了阻塞等待线程资源的开销。

26. Spring 异步调用:@Async 的使用与自定义线程池配置#

  • 原图解参考:
  • 我的理解: 在 Spring Boot 应用开发中,很多非核心旁路业务(例如记录用户的搜索历史、发送短信通知、记录操作审计日志等)并不需要阻塞当前主流程:
  1. 异步解耦:通过在方法上标注 @Async 注解,Spring 能够通过 AOP 动态代理将该方法的执行包装为一个异步任务,提交至后台独立线程执行;主线程无需等待即可立刻向客户端返回结果;
  2. 启用前置条件:必须在 Spring Boot 启动类或配置类上显式添加 @EnableAsync 注解开启对异步调用的支持;
  3. 指定自定义线程池:通过 @Async("taskExecutor") 显式绑定我们自定义的专用业务线程池,严禁使用 Spring 默认未受控的线程池。
  • 实战代码示范:搜索历史异步落库:
// 1. 业务检索主流程:主查询执行后,直接异步调用落库记录
SearchRequest request = new SearchRequest(ARTICLE_ES_INDEX);
// 设置查询条件
BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
if (null == keyword || "".equals(keyword)) {
request.source().query(QueryBuilders.matchAllQuery());
} else {
request.source().query(QueryBuilders.queryStringQuery(keyword).field("title").defaultOperator(Operator.OR));
// 触发异步调用:保存用户搜索历史
apUserSearchService.insert(userId, keyword);
}
// 2. 异步方法实现:标注 @Async("taskExecutor") 交由指定线程池执行
@Async("taskExecutor")
@Override
public void insert(Integer userId, String keyword) {
// 异步执行保存用户历史记录到 MongoDB 或 MySQL
log.info("用户搜索记录保存成功, 用户id: {}, 关键字: {}", userId, keyword);
}
// 3. 自定义生产级线程池配置
package com.itheima.cdl.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
@Configuration
public class ThreadPoolConfig {
private static final int CORE_POOL_SIZE = 17;
private static final int MAX_POOL_SIZE = 50;
private static final int QUEUE_CAPACITY = 1000;
private static final int KEEP_ALIVE_SECONDS = 500;
@Bean("taskExecutor")
public ExecutorService executorService() {
AtomicInteger c = new AtomicInteger(1);
LinkedBlockingQueue<Runnable> queue = new LinkedBlockingQueue<Runnable>(QUEUE_CAPACITY);
return new ThreadPoolExecutor(
CORE_POOL_SIZE,
MAX_POOL_SIZE,
KEEP_ALIVE_SECONDS,
TimeUnit.MILLISECONDS,
queue,
r -> new Thread(r, "itheima-pool-" + c.getAndIncrement()),
new ThreadPoolExecutor.DiscardPolicy() // 边缘业务允许静默丢弃
);
}
}
// 4. 启动类开启异步调用支持
@MapperScan("com.itheima.cdl.mapper")
@SpringBootApplication
@EnableAsync // 必须显式开启异步调用
public class CDLApplication {
public static void main(String[] args) {
SpringApplication.run(CDLApplication.class, args);
}
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
@Bean
public RestTemplate restTemplate(){
return new RestTemplate();
}
}
  • 原图解参考(业务场景总结):
  • 重点补充与避坑:
    1. 禁止裸用 @Async(生产大忌):若仅标注 @Async 而不显式指定 Bean 名称,Spring 默认会回退使用 SimpleAsyncTaskExecutor。该执行器并不复用线程,而是每次接收到任务都新建一个全新的操作系统线程,高并发请求下瞬间导致机器崩溃(unable to create new native thread);
    2. 同一个类内部方法自调用失效问题:@Async 是基于 Spring AOP 动态代理生效的。如果在同一个 Service 类内部,methodA() 直接调用带 @Async 的 methodB(),调用绕过了代理对象,导致异步失效退化为单线程同步执行。解决方案:通过自注入当前 Bean 代理对象,或将异步逻辑剥离到独立 Service 中。

27. 如何控制某个方法允许并发访问的线程数量?(Semaphore 信号量限流)#

  • 原图解参考:
  • 我的理解: 要控制某个方法或某段核心资源同时允许并发访问的线程数量,Java JUC 并发包中提供了 Semaphore(信号量):
  1. 形象记忆(停车场车位模型):
    • 初始化时指定许可证数量:new Semaphore(permits)(类似于停车场对外开放固定数量的停车位);
    • 线程进入方法前必须调用 semaphore.acquire() 获取通行许可证(车辆驶入占用车位);若当前无空闲许可证,该线程将被阻塞挂起在 AQS 等待队列中;
    • 线程处理完业务逻辑后,必须在 finally 块中调用 semaphore.release() 归还许可证(车辆驶离释放车位,唤醒后续排队线程)。
  • 重点补充与避坑:

  • Semaphore 与普通互斥锁(ReentrantLock)的核心差异:

    • ReentrantLock 属于互斥独占锁(许可证数量等同于 1),同一时刻只允许单个线程进入临界区;
    • Semaphore 属于共享同步器,允许多个线程(最多 permits 个)同时进入临界区执行,常用于系统级资源访问的单机并发限流(如限制下游慢服务的并发调用数、限制数据库连接池并发借出数)。
  • 生产实战代码规范模板:

// 初始化信号量:最多允许 5 个线程并发访问该方法
private final Semaphore semaphore = new Semaphore(5);
public void doLimitedResourceAccess() {
try {
// 尝试获取许可(阻塞式获取,也可使用带超时的 tryAcquire(timeout, unit))
semaphore.acquire();
// 执行核心受保护的业务逻辑
processHeavyBusiness();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.error("获取信号量许可被中断", e);
} finally {
// 铁律:必须在 finally 中确保释放许可,防止因业务异常导致许可永久丢失!
semaphore.release();
}
}

28. ThreadLocal 的底层原理、实战场景与内存泄漏防范#

  • 原图解参考:
  • 我的理解(线程随身小背包): ThreadLocal(线程本地变量)是 Java 提供的一种让每个线程独享自己专属变量副本的机制:
  1. 核心价值:它的主要目的不是为了解决多线程并发竞争共享变量,而是为了**「线程级上下文隐式传递(解耦方法参数)」与「非线程安全对象的无锁复用」**(如 SimpleDateFormat);
  2. 底层存储模型:可以把 ThreadLocal 理解为给当前线程绑定的一个**“随身小背包”**。数据真正存储在每个 Thread 对象私有的 ThreadLocalMap 中,Key 为当前 ThreadLocal 对象的弱引用,Value 为用户存入的值。线程之间的数据天然物理隔离,互不干扰。
  • 基础机制代码验证:
package com.itheima.other;
public class ThreadLocalTest {
static ThreadLocal<String> threadLocal = new ThreadLocal<>();
public static void main(String[] args) {
new Thread(() -> {
String name = Thread.currentThread().getName();
threadLocal.set("itcast");
print(name);
System.out.println(name + "-after remove : " + threadLocal.get());
}, "t1").start();
new Thread(() -> {
String name = Thread.currentThread().getName();
threadLocal.set("itheima");
print(name);
System.out.println(name + "-after remove : " + threadLocal.get());
}, "t2").start();
}
static void print(String str) {
//打印当前线程中本地内存中本地变量的值
System.out.println(str + " :" + threadLocal.get());
//清除本地内存中的本地变量
threadLocal.remove();
}
}
Picked up JAVA_TOOL_OPTIONS: -Dfile.encoding=UTF-8
t1 :itcast
t1-after remove : null
t2 :itheima
t2-after remove : null

执行现象解析:各个线程操作的都是自己私有的变量副本,并在 remove() 后彻底清除,彼此独立无并发干扰。

  • 重点补充与生产实战场景:

1. 为什么实际开发中极度依赖 ThreadLocal?#

在标准的 Spring Boot Web 应用中,一个 HTTP 请求通常由一个独立的 Tomcat Worker 线程从头处理到尾:

Filter/拦截器 → Controller → Service A → Service B → DAO
  • 不使用 ThreadLocal 的痛点(参数污染 / 参数传染):如果底层 DAO 或深层 Service 需要当前登录用户的 userId,就必须在 Controller、Service A、Service B 的每一个方法签名中都显式加上 Long userId 参数,引发极其恶心的方法签名污染;
  • 使用 ThreadLocal 的优雅设计:在请求入口处(Spring 拦截器)鉴权解析 Token 后塞入当前线程背包,后续任何深层业务类直接随用随取,代码极其清爽解耦。

2. 实战规范代码实现#

① 定义用户上下文工具类(线程随身小背包):

package com.itheima.context;
import lombok.Data;
public class UserHolder {
// 创建线程私有的 ThreadLocal 容器
private static final ThreadLocal<UserDTO> TL = new ThreadLocal<>();
public static void set(UserDTO user) {
TL.set(user);
}
public static UserDTO get() {
return TL.get();
}
public static void remove() {
TL.remove();
}
@Data
public static class UserDTO {
private Long id;
private String username;
private String role;
}
}

② 在 Spring 拦截器中解析 Token 并存入与清理:

package com.itheima.interceptor;
import com.itheima.context.UserHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
@Component
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
// 1. 获取请求头中的 Token
String token = request.getHeader("Authorization");
if (token == null || token.isEmpty()) {
response.setStatus(401);
return false;
}
// 2. 解析 Token 获取用户信息(模拟)
UserHolder.UserDTO user = parseToken(token);
// 3. 将用户信息存入当前线程的 ThreadLocal
UserHolder.set(user);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
// 4. 关键:请求生命周期结束时必须在 afterCompletion 强制清理!
UserHolder.remove();
}
private UserHolder.UserDTO parseToken(String token) {
UserHolder.UserDTO dto = new UserHolder.UserDTO();
dto.setId(1001L);
dto.setUsername("zhangsan");
return dto;
}
}

③ 业务层(Controller / Service / DAO)随用随取:

package com.itheima.service.impl;
import com.itheima.context.UserHolder;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@Service
@Slf4j
public class OrderServiceImpl {
public void createOrder() {
// 无需通过方法参数层层传递,直接从当前线程背包中获取登录用户信息
UserHolder.UserDTO currentUser = UserHolder.get();
log.info("当前操作用户ID: {}, 用户名: {}, 正在创建订单...",
currentUser.getId(), currentUser.getUsername());
}
}

3. ThreadLocal 内存泄漏成因与两大生产避坑铁律#

  • 底层引用链与内存泄漏(OOM)根因剖析:

    • ThreadLocalMap.Entry 继承了 WeakReference<ThreadLocal<?>>,其 Key 是对 ThreadLocal 实例的弱引用,但 Value 是对用户数据对象的强引用;
    • 泄漏场景:如果外部 ThreadLocal 强引用断开,发生 GC 时,Entry 的 Key 会被垃圾收集器直接回收变为 null。但由于当前工作线程 Thread 仍然存活(特别是 Web 容器与线程池中常驻的核心线程),线程内部的 ThreadLocalMap 就会一直存在一条强引用链: Thread 引用→ThreadLocalMap→Entry→Value(数据对象)\text{Thread 引用} \rightarrow \text{ThreadLocalMap} \rightarrow \text{Entry} \rightarrow \text{Value(数据对象)}
    • 这导致 Key 虽然变成了 null,但 Value 永远无法被 GC 回收,长期累积便造成了**内存泄漏(Memory Leak)**甚至诱发 OutOfMemoryError。
  • 生产避坑两大铁律(必考总结):

    1. 防数据污染(用户身份串号):Tomcat 等 Web 容器的 Worker 线程是高度复用的。如果一个请求结束后没有调用 remove(),下一个请求复用同一个线程时,会直接读到上一个用户的残留身份数据,造成灾难级的安全与串号事故;
    2. 防内存泄漏与规避机制:
      • 虽然 ThreadLocalMap 的 get() / set() / remove() 内部包含探测式清理(expungeStaleEntry)和启发式清理机制,会自动清理 Key 为 null 的脏 Entry;
      • 但在实际生产中,必须养成铁一般的编码习惯:在拦截器的 afterCompletion 或代码的 finally 块中显式调用 remove(),彻底断开强引用,杜绝内存泄漏!
分享

如果这篇文章对你有帮助,欢迎分享给更多人!

Java 多线程与并发编程核心面试题精讲
https://xinrose.top/posts/interview/thread-concurrency/
作者
xinRose
发布于
2026-08-14
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

目录