Java 并发编程面试八股文30条(深度详解版)

📖 学习指南

🎯 学习目标:通过本文,你将系统掌握 Java 并发 的核心知识,能够自信地应对任何相关面试问题。

适合人群

  • 🔰 初学者:想系统学习 Java 并发 的开发者
  • 🚀 有经验者:想深入理解 Java 并发 原理的高级开发者
  • 💼 面试准备者:想刷 Java 并发 面试题的求职者

学习建议

  1. 先理解概念,再深入原理:先搞懂”是什么”,再搞懂”为什么”
  2. 结合实战场景:不要死记硬背,要理解实际应用场景
  3. 动手实践:在自己电脑上安装环境,执行本文的示例代码
  4. 反复复习:面试前一周,每天复习 10 个问题

学习时间估算

  • ⏱️ 快速复习(只看一句话总结):2 小时
  • 📚 系统学习(看深度解析):1-2 天
  • 💪 深入理解(研究源码):1 周+

🗺️ 知识图谱

mindmap
  root((Java 并发))
    基础概念
      核心概念1
      核心概念2
      核心概念3
    高级特性
      特性1
      特性2
    实战应用
      应用场景1
      应用场景2
    性能优化
      优化技巧1
      优化技巧2

⚠️ 常见陷阱与误区

陷阱 1:概念理解错误

错误示例

1
# 错误的理解或用法

正确做法

  • 正确理解概念
  • 避免常见误区

陷阱 2:忽略边界条件

错误做法

  • 不考虑特殊情况
  • 忽略异常处理

正确做法

  • 总是考虑边界条件
  • 添加异常处理

💡 面试技巧

技巧 1:结构化回答

不要只回答”是什么”,要按照以下结构回答:

  1. 一句话总结(概念)
  2. 深度解析(原理、实现、优缺点)
  3. 面试加分回答(实际项目经验、源码理解、行业最佳实践)

技巧 2:结合实战场景

不要只背概念,要结合实际项目经验回答。

技巧 3:引导到你会的方向

如果遇到不会的问题,不要慌,可以引导到你会的方向。


🎯 实战演练(真实面试场景)

场景 1:请你设计一个系统?

回答思路

  1. 需求分析:明确系统需求
  2. 技术选型:选择合适的技术栈
  3. 架构设计:设计系统架构
  4. 性能优化:考虑性能瓶颈和优化方案

🚀 学习路径总结

第一阶段:基础概念(1-2 天)

  • 理解核心概念
  • 掌握基本操作
  • 完成入门教程

第二阶段:高级特性(2-3 天)

  • 掌握高级特性
  • 理解实现原理
  • 完成进阶教程

第三阶段:实战应用(1 周+)

  • 搭建实际项目
  • 解决实战问题
  • 阅读源码(可选)

第四阶段:面试准备(1 周)

  • 刷完本文的所有问题
  • 复习相关知识点
  • 准备项目经验
  • 模拟面试

📚 扩展学习资源

官方资源

书籍推荐

  • 《Java 并发 实战》
  • 《Java 并发 权威指南》

博客推荐


Java 并发编程面试八股文30条(深度详解版)

并发编程是 Java 面试的必考题,也是实际工作中最容易出 Bug 的地方。
本文力求把每个知识点讲透,从「是什么」到「为什么」再到「怎么用」,帮你真正理解并发编程。

建议结合代码实践,光看不练等于没看。


一、线程基础(1-5)


第 1 题:Java 线程有哪些状态?状态之间如何转换?

一句话结论

Java 线程有 6 种状态(Thread.State 枚举):NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。状态转换由 JVM 和操作系统共同管理。


深度解析

6 种状态详解:

1
2
3
4
5
6
7
8
public enum State {
NEW, // 新建:线程被创建但尚未启动
RUNNABLE, // 就绪/运行:调用了 start(),正在运行或等待 CPU 时间片
BLOCKED, // 阻塞:等待获取 synchronized 的内置锁
WAITING, // 无限等待:等待其他线程通知(wait、join、park)
TIMED_WAITING, // 限时等待:带超时的等待(sleep、wait(timeout))
TERMINATED // 终止:线程执行完毕或异常退出
}

状态转换图(非常重要!):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
  NEW

│ 调用 start()

RUNNABLE ←──────────────────────────────┐
│ │
│ 等待 synchronized 锁 │ 获取到锁
↓ │
BLOCKED
│ │
│ 获取到锁 │
↓ │
RUNNABLE ←──────────────────────────────┐│
│ ││
│ 调用 wait()/join()/LockSupport.park() ││
↓ ││
WAITING / TIMED_WAITING ││
│ ││
│ 被 notify()/notifyAll()/unpark() ││
↓ ││
RUNNABLE ←──────────────────────────────┘│

│ 执行完毕或异常

TERMINATED

每种状态的具体触发条件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 1. NEW(新建)
Thread t = new Thread(() -> {});
// t.getState() == NEW

// 2. RUNNABLE(就绪/运行)
t.start();
// t.getState() == RUNNABLE
// 注意:RUNNABLE 包括操作系统层面的「就绪」和「运行」两种状态
// JVM 不区分这两种状态(由操作系统调度)

// 3. BLOCKED(阻塞,等待 synchronized 锁)
// 线程 A 持有 synchronized 锁,线程 B 尝试进入 → B 进入 BLOCKED

// 4. WAITING(无限等待)
// - Object.wait()
// - Thread.join()
// - LockSupport.park()

// 5. TIMED_WAITING(限时等待)
// - Thread.sleep(long)
// - Object.wait(long)
// - Thread.join(long)
// - LockSupport.parkNanos(long)
// - LockSupport.parkUntil(long)

// 6. TERMINATED(终止)
// 线程执行完毕,或抛出了未捕获的异常

BLOCKED vs WAITING 的区别(高频追问!):

1
2
3
4
5
6
7
8
9
BLOCKED
→ 等待进入 synchronized 同步块(等待获取内置锁)
→ 被动等待(由 JVM 调度,锁可用时自动唤醒)
→ 不响应中断(interrupt() 不会让线程退出 BLOCKED 状态)

WAITING
→ 主动调用 wait()/join()/park() 进入等待
→ 需要其他线程显式唤醒(notify()/notifyAll()/unpark())
→ 响应中断(interrupt() 会让线程抛出 InterruptedException

面试加分回答

「线程状态是理解并发编程的基础。面试时经常追问:WAITING 和 BLOCKED 的区别? 核心区别是:BLOCKED 是等待获取 synchronized 内置锁,由 JVM 自动唤醒;WAITING 是主动调用 wait()/park() 进入等待,需要其他线程显式唤醒(notify()/unpark())。另外,TIMED_WAITING 超时后会自动唤醒,而 WAITING 必须被显式唤醒(或中断)。」


第 2 题:创建线程有哪几种方式?

一句话结论

从 Java 语法层面:继承 Thread、实现 Runnable、实现 Callable、线程池。从底层实现:只有一种——new Thread().start(),其他都是对它的封装。


深度解析

方式 ①:继承 Thread(不推荐)

1
2
3
4
5
6
7
8
9
10
11
12
13
// 方式 1:继承 Thread
class MyThread extends Thread {
@Override
public void run() {
System.out.println("线程执行");
}
}

new MyThread().start();

// 缺点:
// 1. Java 单继承,继承了 Thread 就不能继承其他类
// 2. 任务(run 方法)和线程耦合在一起,不利于复用

方式 ②:实现 Runnable(推荐)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 方式 2:实现 Runnable(推荐)
class MyRunnable implements Runnable {
@Override
public void run() {
System.out.println("线程执行");
}
}

new Thread(new MyRunnable()).start();

// 优点:
// 1. 解耦了「任务」和「线程」
// 2. 可以继承其他类
// 3. Runnable 是函数式接口,可以用 Lambda 简写:
new Thread(() -> System.out.println("线程执行")).start();

方式 ③:实现 Callable(有返回值)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 方式 3:实现 Callable(有返回值,能抛异常)
class MyCallable implements Callable<String> {
@Override
public String call() throws Exception {
Thread.sleep(1000);
return "执行结果";
}
}

FutureTask<String> future = new FutureTask<>(new MyCallable());
new Thread(future).start();

// 获取返回值(会阻塞)
String result = future.get();
System.out.println(result);

// 对比 Runnable:
// Runnable.run() → void,不能抛受检异常
// Callable.call() → 有返回值,可以抛异常

方式 ④:线程池(生产环境唯一推荐)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 方式 4:线程池(生产环境必须用这个!)
ExecutorService executor = Executors.newFixedThreadPool(5);

// 提交 Runnable 任务
executor.submit(() -> System.out.println("任务执行"));

// 提交 Callable 任务
Future<String> future = executor.submit(() -> {
Thread.sleep(1000);
return "结果";
});

// 关闭线程池
executor.shutdown();

为什么生产环境不能用 new Thread()?

  1. 每次 new Thread() 都要创建对象、分配栈内存(开销大)
  2. 线程数量不受控制,可能创建几千个线程 → OOM
  3. 无法统一管理(无法监控、无法限制最大并发数)

面试加分回答

「创建线程的『四种方式』是初级面试题。进阶追问:Runnable 和 Callable 的区别? 答案是:1. Runnable.run() 无返回值,Callable.call() 有返回值;2. Runnable.run() 不能抛受检异常,Callable.call() 可以抛异常;3. Callable 需要和 FutureTask 配合使用来获取返回值。另外,生产环境中永远不要手动创建线程,必须用线程池(ThreadPoolExecutor),这是阿里 Java 开发手册的强制规定。」


第 3 题:start() 和 run() 的区别?

一句话结论

start() 才是真正启动线程(创建新的调用栈,由 JVM 调度执行 run());直接调用 run() 只是在当前线程中普通的方法调用,没有创建新线程。


深度解析

正确用法:start()

1
2
3
4
5
6
Thread t = new Thread(() -> {
System.out.println("当前线程:" + Thread.currentThread().getName());
});

t.start(); // 输出:当前线程:Thread-0
// 新线程被创建,run() 在新线程中执行

错误用法:直接调用 run()

1
2
3
4
5
6
Thread t = new Thread(() -> {
System.out.println("当前线程:" + Thread.currentThread().getName());
});

t.run(); // 输出:当前线程:main
// 没有创建新线程!只是在 main 线程中调用了 run() 方法

原理解析:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Thread.start() 的底层实现(简化):
public synchronized void start() {
if (threadStatus != 0) // 确保线程只启动一次
throw new IllegalThreadStateException();

// 调用 native 方法,由 JVM 创建新线程
start0(); // native 方法,由 C/C++ 实现
}

// Thread.run() 的实现:
@Override
public void run() {
if (target != null) {
target.run(); // 直接调用 Runnable.run(),没有创建新线程!
}
}

为什么 start() 只能调用一次?
因为线程启动后,threadStatus 会改变,再次调用 start() 会抛 IllegalThreadStateException
线程结束后,不能再重新启动(线程不是可重用的,需要创建新线程)。


面试加分回答

「start() 和 run() 的区别是一个经典陷阱题。正确回答要讲清楚:start() 会调用 native 方法 start0(),由 JVM 创建新线程,然后新线程会自动调用 run() 方法。直接调用 run() 只是在当前线程中执行了一个普通方法,没有多线程的效果。另外,线程结束后不能重新 start(),如果需要重复执行任务,应该用线程池。」


第 4 题:synchronized 关键字如何使用?底层原理是什么?

一句话结论

synchronized 是 JVM 内置的隐式锁,可修饰方法或代码块。底层原理:JVM 使用 monitor(监视器锁)实现,编译后在同步块前后插入 monitorenter 和 monitorexit 指令。


深度解析

基本用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// ① 修饰实例方法(锁是当前实例对象 this)
public synchronized void method() {
// 同一时刻,只有一个线程能执行这个方法
// 锁对象:this(当前实例)
}

// ② 修饰静态方法(锁是 Class 对象)
public static synchronized void staticMethod() {
// 锁对象:MyClass.class
}

// ③ 修饰代码块(锁是指定对象)
synchronized (lockObject) {
// 锁对象:lockObject
}

底层原理(字节码层面):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Java 代码:
public void method() {
synchronized (this) {
System.out.println("hello");
}
}

// 编译后的字节码(javap -c 查看):
public void method();
Code:
0: aload_0
1: dup
2: monitorenter // ← 获取锁(进入同步块)
3: getstatic
// ... 方法体 ...
15: monitorexit // ← 释放锁(正常退出)
16: goto 24
19: astore_1
20: aload_0
21: monitorexit // ← 释放锁(异常退出,保证锁一定释放)
22: aload_1
23: athrow
24: return

为什么有两个 monitorexit?
第一个是正常退出时释放锁;第二个是异常退出时释放锁(保证异常时锁也能释放,不会死锁)。


synchronized 的锁升级(Java 6+ 重要优化):

1
2
3
4
5
6
7
8
9
10
11
12
13
无锁
↓(第一次有线程访问)
偏向锁(Biased Lock
→ 假设只有一个线程会访问同步块
→ 在对象头中记录线程 ID,下次进入不需要 CAS
↓(有竞争时)
轻量级锁(Lightweight Lock
→ 线程在栈帧中创建锁记录(Lock Record
→ 用 CAS 尝试获取锁(自旋)
↓(自旋超过一定次数,或竞争激烈时)
重量级锁(Heavyweight Lock
→ 使用操作系统 mutex(互斥锁)
→ 未获取到锁的线程进入 BLOCKED 状态(挂起)

面试加分回答

「synchronized 的底层原理是面试高频题。进阶追问:synchronized 和 ReentrantLock 的区别? 核心区别:1. synchronized 是 JVM 内置的(自动加锁/释放锁),ReentrantLock 是 Java 代码实现的(手动 lock()/unlock());2. synchronized 不支持公平锁、不支持条件变量(Condition),ReentrantLock 支持;3. synchronized 在 Java 6 之后性能已经和 ReentrantLock 接近(因为引入了锁升级),优先使用 synchronized(更简洁)。另外,synchronized 可以保证可见性和原子性,但不能禁止指令重排(需要 volatile)。」


第 5 题:volatile 关键字的作用?底层原理?

一句话结论

volatile 保证可见性(一个线程修改,其他线程立即看到)和禁止指令重排,但不保证原子性。底层原理:在写操作前插入 StoreStore 屏障,写操作后插入 StoreLoad 屏障;读操作前插入 LoadLoad 屏障,读操作后插入 LoadStore 屏障。


深度解析

问题场景(没有 volatile):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 经典的错误示例:
class MyTask implements Runnable {
boolean running = true; // ← 没有 volatile!

@Override
public void run() {
while (running) {
// 执行任务
}
System.out.println("线程停止");
}

void stop() {
running = false; // 主线程修改 running
}
}

// 问题:
// 子线程可能永远看不到 running 的变化(一直在 while 循环中)
// 因为 running 被缓存在 CPU 缓存中,没有刷回主内存

用 volatile 解决:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class MyTask implements Runnable {
volatile boolean running = true; // ← 加 volatile!

@Override
public void run() {
while (running) {
// 执行任务
}
System.out.println("线程停止"); // 能正确输出
}

void stop() {
running = false; // 主线程修改,子线程立即看到
}
}

volatile 的两大作用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 作用 1:保证可见性
// → 写 volatile 变量时,JMM 会把本地内存中的值刷回主内存
// → 读 volatile 变量时,JMM 会失效本地内存,从主内存重新读

// 作用 2:禁止指令重排
// → 在 volatile 写操作前插入 StoreStore 屏障
// → 在 volatile 写操作后插入 StoreLoad 屏障
// → 在 volatile 读操作前插入 LoadLoad 屏障
// → 在 volatile 读操作后插入 LoadStore 屏障

// 经典应用:双重检查锁的单例模式
class Singleton {
private static volatile Singleton instance; // ← 必须加 volatile!

public static Singleton getInstance() {
if (instance == null) { // 第一次检查
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查
instance = new Singleton(); // ← 这里会发生指令重排!
}
}
}
return instance;
}
}

为什么双重检查锁必须加 volatile?
instance = new Singleton() 不是原子操作,分为三步:

  1. 分配内存空间
  2. 初始化对象
  3. 将 instance 指向分配的内存地址

步骤 2 和 3 可能被重排(指令重排),导致其他线程拿到一个未初始化的对象(空指针异常)。
volatile 禁止了这种重排,保证安全地发布对象。


volatile 不保证原子性(重要!):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// volatile 不保证原子性!
class Counter {
volatile int count = 0;

void increment() {
count++; // ← 这不是原子操作!
// count++ 分为三步:读取 count → +1 → 写回 count
// 两个线程同时执行,可能丢失更新
}
}

// 解决:用 synchronized 或 AtomicInteger
AtomicInteger count = new AtomicInteger(0);
count.incrementAndGet(); // 原子操作

面试加分回答

「volatile 是 Java 并发编程中的重要关键字。面试时经常追问:volatile 和 synchronized 的区别? 答案是:1. volatile 保证可见性和禁止指令重排,不保证原子性;synchronized 保证原子性、可见性、有序性(禁止指令重排);2. volatile 不会引起线程阻塞(不涉及锁),synchronized 会让未获取到锁的线程阻塞;3. volatile 是轻量级同步,性能比 synchronized 好。另外,volatile 的经典应用场景:1. 状态标志(如 running 标志);2. 双重检查锁的单例模式;3. 独立观察(如定时读取一个 volatile 变量)。」


二、线程安全(6-10)


第 6 题:什么是线程安全?如何保证线程安全?

一句话结论

线程安全 = 多个线程同时访问共享资源时,不需要额外的同步措施,也能得到正确的结果。保证线程安全的方法:无状态设计、不可变对象、线程本地变量(ThreadLocal)、锁机制(synchronized/Lock)、CAS(无锁算法)。


深度解析

线程安全的核心问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 线程不安全的代码:
class UnsafeCounter {
private int count = 0;

void increment() {
count++; // 读-改-写,不是原子操作
}

int getCount() {
return count;
}
}

// 两个线程同时执行 increment():
// 线程 A:读取 count=0,+1,准备写回
// 线程 B:同时读取 count=0,+1,准备写回
// 结果:count=1(应该是 2)→ 丢失更新!

保证线程安全的 5 种方法:

① 无状态设计(最安全)

1
2
3
4
5
6
7
8
// 无状态 = 不包含任何字段,所有数据都在方法参数和返回值中
// 无状态的对象一定是线程安全的!
class StatelessService {
String process(String input) {
// 只使用局部变量(在栈上,每个线程有自己的栈)
return input.toUpperCase(); // 线程安全
}
}

② 不可变对象(Immutable Object)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 不可变对象 = 对象创建后,状态不能被修改
// String、Integer、LocalDate 都是不可变的
final class ImmutablePoint {
private final int x;
private final int y;

public ImmutablePoint(int x, int y) {
this.x = x;
this.y = y;
}

public int getX() { return x; }
public int getY() { return y; }
// 没有 setter → 对象创建后不能被修改 → 线程安全
}

③ 线程本地变量(ThreadLocal)

1
2
3
4
5
6
7
8
9
10
11
// ThreadLocal = 每个线程有自己的副本,互不干扰
ThreadLocal<Integer> threadLocal = ThreadLocal.withInitial(() -> 0);

void increment() {
threadLocal.set(threadLocal.get() + 1); // 每个线程操作自己的副本
}

// 应用场景:
// 1. 数据库连接(每个线程有自己的连接)
// 2. 用户会话信息(每个线程代表一个用户请求)
// 3. 日期格式化(SimpleDateFormat 不是线程安全的)

④ 锁机制(synchronized / Lock)

1
2
3
4
5
6
7
8
9
10
11
12
// 用锁保护共享资源
class SafeCounter {
private int count = 0;

synchronized void increment() { // 同一时刻只有一个线程能执行
count++;
}

synchronized int getCount() {
return count;
}
}

⑤ CAS 无锁算法(乐观锁)

1
2
3
4
5
6
7
8
9
// CAS(Compare-And-Swap)= 比较并交换
// 如果当前值 == 期望值,就更新为新值;否则不更新
AtomicInteger count = new AtomicInteger(0);

void increment() {
count.incrementAndGet(); // 底层用 CAS,无锁,性能更好
}

// CAS 的底层:CPU 的 cmpxchg 指令(硬件级别的原子操作)

面试加分回答

「线程安全是并发编程的核心概念。面试时经常追问:无状态和有状态的区别? 无状态对象不包含任何实例字段(或只包含 final 字段),所有计算都基于参数和返回值,因此一定是线程安全的。有状态对象包含可变字段,多个线程同时访问时需要同步。另外,不可变对象(Immutable) 是另一种保证线程安全的方式:String、Integer 都是不可变的,因此可以安全地在多线程间共享。设计不可变对象的规则:1. 类用 final 修饰(不能被继承);2. 所有字段用 private final 修饰;3. 不提供 setter 方法;4. 如果字段是引用类型,要确保引用指向的对象也是不可变的(或不被外部修改)。」


第 7 题:volatile 能保证原子性吗?

一句话结论

volatile 不能保证原子性!volatile 只保证可见性(一个线程修改,其他线程立即看到)和禁止指令重排,但不保证「读-改-写」操作的原子性。需要原子性请用 synchronized 或 Atomic 类。


深度解析

volatile 不保证原子性的证明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class VolatileAtomicityTest {
volatile int count = 0; // volatile,但不保证原子性!

void increment() {
count++; // 这不是原子操作
}

public static void main(String[] args) throws InterruptedException {
VolatileAtomicityTest test = new VolatileAtomicityTest();

for (int i = 0; i < 10; i++) {
new Thread(() -> {
for (int j = 0; j < 1000; j++) {
test.increment();
}
}).start();
}

Thread.sleep(2000);
System.out.println(test.count); // 输出可能 < 10000(应该是 10000)
}
}

为什么 volatile 不保证原子性?

1
2
3
4
5
6
7
8
9
10
11
12
count++ 的实际步骤(编译后):
1. 读取 count 的值(从主内存到工作内存)
2. 将值 +1
3. 写回 count(从工作内存到主内存)

两个线程同时执行 count++:
线程 A:读取 count=0
线程 B:读取 count=0 ← 读取的都是 0!
线程 A:计算 0+1=1,写回 count=1
线程 B:计算 0+1=1,写回 count=1 ← 丢失了 A 的更新!

结果:count=1(应该是 2)

volatile 只能保证:步骤 1 读到的是最新值,步骤 3 写回后其他线程立即看到。
但不能保证:步骤 1~3 作为一个整体是原子的(不可分割的)。


如何保证原子性?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 方法 1:synchronized
class SafeCounter1 {
private int count = 0;

synchronized void increment() {
count++; // 同一时刻只有一个线程能执行
}
}

// 方法 2:ReentrantLock
class SafeCounter2 {
private int count = 0;
private final Lock lock = new ReentrantLock();

void increment() {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
}

// 方法 3:AtomicInteger(推荐,性能最好)
class SafeCounter3 {
private AtomicInteger count = new AtomicInteger(0);

void increment() {
count.incrementAndGet(); // 底层用 CAS,无锁
}
}

面试加分回答

「volatile 不保证原子性是一个经典陷阱。面试时经常追问:既然 volatile 不保证原子性,那它有什么用? 答案是:1. 保证可见性(一个线程修改 volatile 变量,其他线程立即看到);2. 禁止指令重排(保证有序性);3. 可以作为状态标志(如 volatile boolean running);4. 用于双重检查锁的单例模式。另外,volatile 的适用场景是:写操作不依赖当前值(如 flag = true,而不是 count++)。如果写操作依赖当前值,必须用锁或 Atomic 类。」


第 8 题:happens-before 原则是什么?

一句话结论

happens-before 是 JMM(Java 内存模型)的核心概念,定义了操作之间的「可见性」规则。如果操作 A happens-before 操作 B,那么 A 的效果对 B 可见。JMM 定义了 8 条天然的 happens-before 规则。


深度解析

为什么需要 happens-before?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 问题:下面的代码,线程 B 一定能看到 x=1 吗?
class Example {
int x = 0;
boolean flag = false;

void writer() {
x = 1; // ①
flag = true; // ②
}

void reader() {
if (flag) { // ③
System.out.println(x); // ④ 一定输出 1 吗?
}
}
}

// 答案:不一定!
// 因为 ① 和 ② 可能被重排(指令重排)
// 也可能 ② 的修改对线程 B 不可见(内存可见性问题)

如果没有 happens-before 规则,多线程程序的行为是不可预测的。
happens-before 规则告诉我们:在哪些情况下,一个线程的写操作对另一个线程的读操作一定是可见的。


8 条天然的 happens-before 规则:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// 规则 1:程序顺序规则(Program Order Rule)
// 在同一个线程中,前面的操作 happens-before 后面的操作
// (注意:不是禁止重排,而是「as-if-serial」——重排后的结果和顺序执行一致)

// 规则 2:volatile 变量规则(Volatile Variable Rule)
// 对 volatile 变量的写操作,happens-before 后续对这个变量的读操作
volatile int x = 0;
void writer() {
x = 1; // 写 volatile 变量
}
void reader() {
int r = x; // 读 volatile 变量(一定能看到上面的写)
}

// 规则 3:传递性规则(Transitivity)
// 如果 A happens-before B,B happens-before C,那么 A happens-before C

// 规则 4:synchronized 规则(Monitor Lock Rule)
// 解锁(unlock)happens-before 后续对同一把锁的加锁(lock)
synchronized (lock) {
// 前一个线程释放锁的操作,对后续获取锁的线程可见
}

// 规则 5:线程启动规则(Thread Start Rule)
// Thread.start() happens-before 新线程的 run() 方法中的操作
Thread t = new Thread(() -> {
// 这里的操作一定能看到主线程在 start() 之前的修改
});
x = 1;
t.start(); // start() 之前的修改对 t 线程可见

// 规则 6:线程终止规则(Thread Join Rule)
// 线程中的所有操作 happens-before 其他线程成功返回 from join()
Thread t = new Thread(() -> { x = 1; });
t.start();
t.join(); // join() 返回后,主线程一定能看到 x=1

// 规则 7:中断规则(Thread Interruption Rule)
// 对线程 interrupt() 的调用,happens-before 被中断线程检测到中断事件
// (即:interrupt() happens-before 检测到中断)

// 规则 8:finalizer 规则(Finalizer Rule)
// 对象的构造函数执行结束 happens-before 它的 finalize() 方法开始

用 happens-before 分析之前的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Example {
int x = 0;
volatile boolean flag = false; // ← 加 volatile!

void writer() {
x = 1; // ①
flag = true; // ②(volatile 写)
}

void reader() {
if (flag) { // ③(volatile 读,② happens-before ③)
// 根据传递性:① happens-before ② happens-before ③
// 所以 ④ 一定能看到 x=1
System.out.println(x); // ④ 一定输出 1
}
}
}

面试加分回答

「happens-before 是理解 Java 内存模型的关键。面试时可能会问:JMM 和 JVM 内存区域(堆、栈、方法区)的区别? 答案是:JVM 内存区域是物理划分(内存如何分配),JMM 是逻辑划分(多线程环境下,读写操作如何保证可见性、有序性)。JMM 不关心内存物理上如何分配,只关心「一个线程的写操作,什么时候对其他线程可见」。另外,happens-before 规则是 JMM 对程序员的承诺:只要按照这些规则编写代码,就不需要手动加内存屏障,JMM 会自动保证可见性和有序性。」


第 9 题:as-if-serial 语义是什么?

一句话结论

as-if-serial = 不管怎么重排序,单线程程序的执行结果不能被改变。编译器、CPU 可以重排指令,但必须保证重排后的结果与顺序执行一致。只保证单线程,不保证多线程。


深度解析

什么是指令重排?

1
2
3
4
5
6
7
8
9
10
11
// 下面的代码:
int a = 1; // ①
int b = 2; // ②
int c = a + b; // ③

// 可能被重排为:
int b = 2; // ②(重排)
int a = 1; // ①(重排)
int c = a + b; // ③(不能重排,依赖 a 和 b)

// 重排后的结果与顺序执行一致 → 允许!

as-if-serial 的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 例子 1:数据依赖,不能重排
int x = 1; // ①
int y = x + 1; // ②(依赖 ① 的结果,不能重排到 ① 前面)

// 例子 2:控制依赖,可以重排(分支预测)
if (flag) { // ①
System.out.println("hello"); // ②
}
// CPU 可能先执行 ②(预测 flag 为 true),如果预测错误再回滚

// 例子 3:经典的双检锁问题
instance = new Singleton();
// 分为三步:
// 1. 分配内存
// 2. 初始化对象
// 3. 将 instance 指向内存地址
// ② 和 ③ 可能被重排!
// 导致其他线程拿到未初始化的对象 → 需要 volatile 禁止重排

as-if-serial vs happens-before:

1
2
3
4
5
6
7
8
9
as-if-serial
→ 只保证单线程的执行结果不变
→ 不保证多线程的可见性
→ 是编译器和 CPU 的优化规则

happens-before
→ 保证多线程的可见性和有序性
→ 是 JMM 对程序员的承诺
→ 定义了「什么情况下,一个线程的修改对其他线程可见」

面试加分回答

「as-if-serial 是理解指令重排的基础。面试时可能会追问:CPU 为什么要多指令重排? 答案是:现代 CPU 是流水线执行的(取指、译码、执行、写回多个阶段并行),如果某条指令需要等待(如从内存读取数据),CPU 会先执行后面的指令(乱序执行),提高吞吐量。但这种优化在单线程下是安全的(as-if-serial 保证),在多线程下可能导致问题(需要 happens-before 规则或 volatile/synchronized 来保证)。另外,Java 中的 « 先行发生 » 就是 happens-before 的官方翻译。」


第 10 题:ThreadLocal 的原理?内存泄漏问题?

一句话结论

ThreadLocal = 线程本地变量,每个线程有自己的副本,互不干扰。原理:每个 Thread 有一个 ThreadLocalMap,key 是 ThreadLocal 实例(弱引用),value 是线程本地变量。内存泄漏原因:ThreadLocalMap 的 key 是弱引用,value 是强引用;如果 ThreadLocal 没有外部强引用了,key 会被 GC 回收(变成 null),但 value 仍然是强引用 → 内存泄漏。解决:用完 ThreadLocal 后,调用 remove() 方法。


深度解析

基本用法:

1
2
3
4
5
6
7
8
9
// 每个线程有自己的 count 副本,互不干扰
ThreadLocal<Integer> threadLocal = ThreadLocal.withInitial(() -> 0);

void increment() {
threadLocal.set(threadLocal.get() + 1); // 操作自己的副本
}

// 使用完后,必须调用 remove()!(防止内存泄漏)
threadLocal.remove();

底层原理:ThreadLocalMap

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Thread 类中有:
public class Thread {
ThreadLocal.ThreadLocalMap threadLocals = null; // 每个线程有自己的 Map
}

// ThreadLocalMap 的 entry:
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value; // value 是强引用!
Entry(ThreadLocal<?> k, Object v) {
super(k); // key(ThreadLocal)是弱引用
value = v; // value 是强引用
}
}

// set() 方法的原理(简化):
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = t.threadLocals;
if (map != null) {
map.set(this, value); // this = 当前 ThreadLocal 实例,作为 key
} else {
createMap(t, value);
}
}

内存泄漏的原因(非常重要!):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
ThreadLocal 的内存布局:

Thread

└── ThreadLocalMap

├── Entry1: key=ThreadLocal1(弱引用) → value1(强引用)
├── Entry2: key=ThreadLocal2(弱引用) → value2(强引用)
└── Entry3: key=ThreadLocal3(弱引用) → value3(强引用)

问题场景:
1. ThreadLocal1 没有外部强引用了(如方法执行完毕,局部变量销毁)
2. 下次 GC 时,key(弱引用)被回收 → key = null
3. 但 value(强引用)仍然存在(因为 ThreadLocalMap 中还有引用)
4. 如果线程不结束(如线程池中的线程),value 永远无法被回收 → 内存泄漏!

为什么 key 要用弱引用?

1
2
3
4
5
6
7
8
9
如果 key 用强引用:
→ ThreadLocal 没有被回收,key(强引用)也不会被回收
→ 即使 ThreadLocal 已经没用了,value 也永远无法被回收 → 更严重的内存泄漏

用弱引用:
→ ThreadLocal 没有外部强引用时,key 会被 GC 回收(变成 null
→ ThreadLocalMap 在 set()/get()/remove() 时会清理 key=null 的 entry
→ 但如果不调用这些方法,仍然会内存泄漏!
→ 所以必须手动调用 remove()!

正确的使用方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 错误用法:不调用 remove()
public class WrongExample {
private static ThreadLocal<SimpleDateFormat> sdf =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));

void formatDate(Date date) {
sdf.get().format(date);
// 不调用 remove() → 内存泄漏!
}
}

// 正确用法:用完后调用 remove()
public class CorrectExample {
private static ThreadLocal<SimpleDateFormat> sdf =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));

void formatDate(Date date) {
try {
sdf.get().format(date);
} finally {
sdf.remove(); // ← 必须调用!
}
}
}

面试加分回答

「ThreadLocal 的内存泄漏问题是面试高频题。进阶追问:如何在线程池中正确使用 ThreadLocal? 答案是:1. 任务执行完后,必须在 finally 块中调用 remove();2. 如果是 Web 应用,可以在过滤器(Filter)中统一处理(请求开始时 set(),请求结束后 remove())。另外,ThreadLocal 的应用场景:1. 数据库连接管理(每个线程有自己的连接);2. 用户会话信息(每个线程代表一个用户请求);3. 日期格式化(SimpleDateFormat 不是线程安全的);4. 事务上下文传递。最后,InheritableThreadLocal 可以让子线程继承父线程的 ThreadLocal 值,但线程池场景下会失效(线程是复用的,不是新建的),需要用 TransmittableThreadLocal(阿里开源)。」


三、锁机制(11-16)


第 11 题:synchronized 和 ReentrantLock 的区别?

一句话结论

synchronized 是 JVM 内置的隐式锁(自动加锁/释放锁),ReentrantLock 是 Java 代码实现的显式锁(手动 lock()/unlock())。ReentrantLock 功能更强(可中断、可超时、公平锁、多条件变量),但 synchronized 更简洁(Java 6 之后性能差距不大,优先用 synchronized)。


深度解析

功能对比:

对比维度 synchronized ReentrantLock
加锁/释放锁 自动(JVM 管理) 手动(lock()/unlock())
中断响应 不支持 支持(lockInterruptibly())
超时获取锁 不支持 支持(tryLock(timeout))
公平锁 不支持(都是非公平) 支持(构造时传入 true)
条件变量(Condition) 不支持(只有 wait/notify) 支持(多个 Condition)
尝试获取锁 不支持 支持(tryLock())
性能 Java 6+ 已优化(锁升级) 差不多
可读性 更简洁 更灵活,但代码更冗长

ReentrantLock 的高级功能:

① 可中断(lockInterruptibly())

1
2
3
4
5
6
7
8
9
10
11
12
13
14
ReentrantLock lock = new ReentrantLock();

void method() throws InterruptedException {
lock.lockInterruptibly(); // ← 可以响应中断!
try {
// 如果其他线程调用了当前线程的 interrupt()
// 会抛出 InterruptedException,并释放锁
} finally {
lock.unlock();
}
}

// synchronized 不支持中断:
// 线程在等待 synchronized 锁时,无法被中断(只能一直等)

② 超时获取锁(tryLock(timeout))

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
ReentrantLock lock = new ReentrantLock();

void method() {
try {
if (lock.tryLock(1, TimeUnit.SECONDS)) { // ← 最多等 1 秒
try {
// 获取到锁
} finally {
lock.unlock();
}
} else {
// 获取锁超时
System.out.println("获取锁超时");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}

③ 公平锁

1
2
3
4
5
6
7
// 非公平锁(默认,性能更好):
ReentrantLock lock = new ReentrantLock(); // 非公平
// 线程获取锁的顺序不确定(谁抢到算谁的)

// 公平锁(构造时传入 true,性能较差):
ReentrantLock fairLock = new ReentrantLock(true); // 公平
// 线程按照请求锁的顺序获取锁(先来先得)

为什么非公平锁性能更好?
公平锁需要维护一个队列(保证先来先得),线程切换更频繁。
非公平锁允许「插队」(刚释放锁的线程有机会再次获取锁,大概率成功),减少了线程切换。


④ 多条件变量(Condition)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
ReentrantLock lock = new ReentrantLock();
Condition notEmpty = lock.newCondition(); // 条件 1:队列不为空
Condition notFull = lock.newCondition(); // 条件 2:队列不为满

// 生产者
void put(Object item) throws InterruptedException {
lock.lock();
try {
while (isFull()) {
notFull.await(); // 等待「队列不为满」条件
}
// 入队
notEmpty.signal(); // 唤醒等待「队列不为空」的消费者
} finally {
lock.unlock();
}
}

// 消费者
Object take() throws InterruptedException {
lock.lock();
try {
while (isEmpty()) {
notEmpty.await(); // 等待「队列不为空」条件
}
// 出队
notFull.signal(); // 唤醒等待「队列不为满」的生产者
return item;
} finally {
lock.unlock();
}
}

面试加分回答

「synchronized 和 ReentrantLock 的区别是经典面试题。进阶追问:什么情况下必须用 ReentrantLock? 答案是:1. 需要可中断锁(lockInterruptibly());2. 需要超时获取锁(tryLock(timeout));3. 需要公平锁;4. 需要多个条件变量(Condition)。另外,synchronized 是 JVM 内置的,JVM 可以在未来继续优化它(如 Java 6 的锁升级);ReentrantLock 是 Java 代码实现的,优化需要修改代码。因此,优先使用 synchronized,只有在 synchronized 无法满足需求时,才用 ReentrantLock。」



三、锁机制(12-16)(续)


第 12 题:ReentrantReadWriteLock 的原理?

一句话结论

读写锁 = 读锁共享、写锁独占。读读不互斥、读写互斥、写写互斥。适合读多写少的场景。


深度解析

基本用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
Lock readLock = rwLock.readLock();
Lock writeLock = rwLock.writeLock();

// 读操作:多个线程可以同时获取读锁
void read() {
readLock.lock();
try {
// 读取共享资源
} finally {
readLock.unlock();
}
}

// 写操作:只有一个线程能获取写锁
void write() {
writeLock.lock();
try {
// 修改共享资源
} finally {
writeLock.unlock();
}
}

锁的互斥规则:

1
2
3
        读锁请求    写锁请求
读锁持有 ✅ 成功 ❌ 阻塞
写锁持有 ❌ 阻塞 ❌ 阻塞

读读不互斥:多个线程可以同时获取读锁
读写互斥:有线程持有读锁时,写锁请求阻塞;有线程持有写锁时,读锁请求阻塞
写写互斥:同一时刻只有一个线程能持有写锁


锁降级(Lock Downgrading):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// ReentrantReadWriteLock 支持锁降级(写锁 → 读锁),不支持锁升级
void method() {
writeLock.lock();
try {
// 修改数据
data = "new value";

// 锁降级:先获取读锁,再释放写锁
readLock.lock(); // ← 获取读锁(此时还持有写锁)
} finally {
writeLock.unlock(); // ← 释放写锁(但仍然持有读锁)
}

try {
// 现在持有读锁,可以读数据
System.out.println(data);
} finally {
readLock.unlock(); // ← 释放读锁
}
}

为什么需要锁降级? 修改完数据后,如果直接释放写锁,在释放和重新获取读锁之间,其他线程可能修改数据。锁降级保证了数据的一致性。


面试加分回答

「ReentrantReadWriteLock 的锁升级(读锁 → 写锁)是不支持的,会导致死锁。另外,ReentrantReadWriteLock 有一个缺陷:写锁饥饿问题——如果读操作很多,写锁可能一直获取不到(因为一直有线程持有读锁)。解决这个问题需要用 StampedLock(Java 8 引入),它提供了「乐观读」,在读多写少的场景下性能更好。」


第 13 题:StampedLock 的原理?

一句话结论

StampedLock(Java 8+)= 读写锁的升级版,支持乐观读(Optimistic Reading)。读多写少场景下,性能比 ReentrantReadWriteLock 好很多。


深度解析

ReentrantReadWriteLock 的问题:

1
2
3
4
5
6
7
8
9
10
// ReentrantReadWriteLock:
// 读操作也要获取读锁(互斥)
rwLock.readLock().lock();
try {
int x = this.x; // 只是读,不需要互斥!
int y = this.y;
} finally {
rwLock.readLock().unlock();
}
// 问题:多个线程的读操作也要排队获取读锁(虽然不互斥,但有开销)

StampedLock 的乐观读:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
StampedLock stampedLock = new StampedLock();

// 乐观读(不需要获取读锁!)
void optimisticRead() {
long stamp = stampedLock.tryOptimisticRead(); // ← 获取一个标记(不获取锁)
int x = this.x;
int y = this.y;
if (!stampedLock.validate(stamp)) { // ← 检查期间是否有写操作
// 有写操作,乐观读失败,需要获取悲观读锁
stamp = stampedLock.readLock();
try {
x = this.x;
y = this.y;
} finally {
stampedLock.unlockRead(stamp);
}
}
// 使用 x, y
}

乐观读的原理tryOptimisticRead() 只是读取一个版本号,不获取锁。如果有其他线程获取了写锁,版本号会变,validate() 返回 false。


StampedLock 的三种模式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 1. 写锁(和 ReentrantReadWriteLock 的写锁一样)
long writeStamp = stampedLock.writeLock();
try {
// 修改数据
} finally {
stampedLock.unlockWrite(writeStamp);
}

// 2. 悲观读锁(和 ReentrantReadWriteLock 的读锁一样)
long readStamp = stampedLock.readLock();
try {
// 读取数据
} finally {
stampedLock.unlockRead(readStamp);
}

// 3. 乐观读(StampedLock 的特色!)
long stamp = stampedLock.tryOptimisticRead();
// 读数据
if (!stampedLock.validate(stamp)) {
// 乐观读失败,获取悲观读锁
}

StampedLock 的注意事项:

1
2
3
4
5
6
7
8
9
// ❌ StampedLock 不支持重入!
StampedLock lock = new StampedLock();
long stamp1 = lock.writeLock();
long stamp2 = lock.writeLock(); // ← 死锁!不支持重入

// ✅ 解决方案:如果需要重入,用 ReentrantReadWriteLock

// ❌ StampedLock 的读锁和写锁都不支持 Condition
// 需要用 ReentrantReadWriteLock 的 Condition

面试加分回答

「StampedLock 是 Java 8 引入的重要锁,核心优势是乐观读(在读多写少的场景下,性能提升显著)。但要注意:1. StampedLock 不支持重入(ReentrantReadWriteLock 支持);2. StampedLock 不支持 Condition(需要条件变量时用 ReentrantReadWriteLock);3. StampedLock 的写锁是不可重入的,如果当前线程已经持有写锁,再次获取写锁会死锁。面试时可能会问:StampedLock 和 ReentrantReadWriteLock 怎么选? 答案是:读多写少用 StampedLock(性能更好),需要重入或 Condition 用 ReentrantReadWriteLock。」


第 14 题:LockSupport 的作用?和 wait/notify 的区别?

一句话结论

LockSupport = 线程阻塞/唤醒的工具类(park/unpark)。相比 wait/notify:不需要在 synchronized 块中、可以先 unpark 再 park(不会丢失唤醒信号)、更灵活。是 AQS 的底层实现。


深度解析

wait/notify 的问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 问题 1:必须在 synchronized 块中
synchronized (lock) {
lock.wait(); // ← 必须在 synchronized 块中
}

// 问题 2:必须先 wait,再 notify(否则信号丢失)
// 线程 A:
synchronized (lock) {
lock.wait(); // 先 wait
}
// 线程 B:
synchronized (lock) {
lock.notify(); // 后 notify
}

// 如果线程 B 先执行 notify(),线程 A 再执行 wait() → 信号丢失,线程 A 永远阻塞!

LockSupport 的用法:

1
2
3
4
5
6
7
8
9
10
11
12
// LockSupport.park():阻塞当前线程
// LockSupport.unpark(thread):唤醒指定线程

Thread t = new Thread(() -> {
System.out.println("线程开始");
LockSupport.park(); // ← 阻塞自己
System.out.println("线程继续");
});
t.start();

Thread.sleep(1000);
LockSupport.unpark(t); // ← 唤醒线程 t(可以在 park 之前调用!)

LockSupport 的优势:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 优势 1:不需要在 synchronized 块中
// 优势 2:可以先 unpark,再 park(不会丢失唤醒信号)
Thread t = new Thread(() -> {
try { Thread.sleep(1000); } catch (Exception e) {}
System.out.println("准备 park");
LockSupport.park(); // ← 1 秒后才 park
System.out.println("被唤醒");
});
t.start();

LockSupport.unpark(t); // ← 先 unpark(在 park 之前!)
// park() 会立即返回,不会阻塞!(因为已经有一个 unpark 信号了)

// 优势 3:可以唤醒指定线程(notify 是随机唤醒一个)

LockSupport 的底层原理:

1
2
3
4
5
6
7
8
LockSupport 的底层:
→ 使用 Unsafe 类的 park()/unpark() 方法
→ 每个线程有一个「许可」(permit),初始为 0
unpark(thread):将许可设为 1(如果已经是 1,不变)
park():如果许可为 1,立即返回并将许可设为 0;否则阻塞

→ 许可最多为 1(多次 unpark 也不会累加)
→ 先 unparkparkpark 会立即返回(因为许可已经是 1

面试加分回答

「LockSupport 是 Java 并发包的底层工具,AQS(AbstractQueuedSynchronizer)就是基于 LockSupport 实现的。面试时可能会问:park() 和 unpark() 的执行顺序有要求吗? 答案是:没有!可以先 unpark() 再 park()(许可机制保证了不会丢失唤醒信号)。这和 wait()/notify() 完全不同(必须先 wait() 再 notify(),否则信号丢失)。另外,park() 响应中断,但不会抛出 InterruptedException(中断状态会被设置,需要通过 Thread.interrupted() 检查)。」


第 15 题:AQS(AbstractQueuedSynchronizer)的原理?

一句话结论

AQS = Java 并发包的基石,用一个 int 状态(state)和一个 CLH 队列实现锁和同步器。ReentrantLock、CountDownLatch、Semaphore 都是基于 AQS 实现的。


深度解析

AQS 的核心结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// AQS 的核心字段:
public abstract class AbstractQueuedSynchronizer {
private volatile int state; // ← 同步状态(锁的状态)
private transient volatile Node head; // ← CLH 队列的头节点
private transient volatile Node tail; // ← CLH 队列的尾节点

// CLH 队列(等待获取锁的线程队列):
// head → Node1 → Node2 → ... → tail
// (虚拟头节点)
}

// state 的含义(由子类定义):
// ReentrantLock:state = 0(无锁),state > 0(有锁,可重入)
// CountDownLatch:state = 计数器初始值(countDown() 减 1,state = 0 时唤醒等待线程)
// Semaphore:state = 许可证数量(acquire() 减 1,release() 加 1)

AQS 的模板方法(子类需要实现):

1
2
3
4
5
6
7
8
9
10
// 独占模式(如 ReentrantLock):
protected boolean tryAcquire(int arg); // 尝试获取锁
protected boolean tryRelease(int arg); // 尝试释放锁

// 共享模式(如 CountDownLatch、Semaphore):
protected int tryAcquireShared(int arg); // 尝试获取共享锁
protected boolean tryReleaseShared(int arg); // 尝试释放共享锁

// 判断是否为独占模式:
protected boolean isHeldExclusively();

ReentrantLock 基于 AQS 的实现(简化):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// ReentrantLock 的内部类 Sync 继承 AQS:
abstract class Sync extends AbstractQueuedSynchronizer {
// 尝试获取锁(独占模式)
@Override
protected boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) { // 无锁状态
if (compareAndSetState(0, acquires)) { // CAS 获取锁
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) { // 可重入
setState(c + acquires);
return true;
}
return false; // 获取锁失败,进入 CLH 队列
}

// 尝试释放锁
@Override
protected boolean tryRelease(int releases) {
int c = getState() - releases;
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
boolean free = false;
if (c == 0) { // 完全释放(重入次数减为 0)
free = true;
setExclusiveOwnerThread(null);
}
setState(c);
return free;
}
}

AQS 的 CLH 队列(等待队列):

1
2
3
4
5
6
7
8
9
获取锁失败的线程,进入 CLH 队列等待:

head(虚拟头节点)

Node1(Thread A,等待获取锁)

Node2(Thread B,等待获取锁)

tail

CLH 队列是双向链表,每个 Node 有 prevnextthreadwaitStatus 字段。
线程获取锁失败时,封装成 Node 加入队列尾部,然后 LockSupport.park() 阻塞自己。
持有锁的线程释放锁时,唤醒队列中的下一个线程(LockSupport.unpark())。


面试加分回答

「AQS 是 Java 并发包的基石,面试时经常问:ReentrantLock 是基于 AQS 实现的,那 synchronized 呢? 答案是:synchronized 是 JVM 内置的(C++ 实现),不使用 AQS;而 ReentrantLock 是 Java 代码实现的,底层依赖 AQS。另外,AQS 支持两种模式:独占模式(Exclusive,如 ReentrantLock)和共享模式(Shared,如 CountDownLatch、Semaphore)。面试时还可能追问:AQS 中的 state 有什么用? 答案是:state 是「同步状态」,具体含义由子类定义(ReentrantLock 中表示「重入次数」,CountDownLatch 中表示「计数器」,Semaphore 中表示「许可证数量」)。」


第 16 题:synchronized 的锁升级过程(深度)?

一句话结论

synchronized 的锁升级 = 无锁 → 偏向锁 → 轻量级锁 → 重量级锁。锁升级是为了在不同竞争情况下选择最优的加锁方式,减少性能开销。锁升级后不能降级(但偏向锁可以撤销)。


深度解析

对象头(Mark Word)的结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Java 对象头(32 位 JVM,64 位类似):
// 对象头 = Mark Word(32 bit)+ Klass Pointer(32 bit)

// Mark Word 在不同锁状态下的结构:
// 无锁状态:
// | 25 bit(hashCode)| 4 bit(分代年龄)| 1 bit(偏向锁位)| 2 bit(锁标志位)|
// |------------------|----------------|---------------|--------------|
// | hashCode | age | 0 | 01 |

// 偏向锁状态:
// | 23 bit(线程 ID)| 2 bit(epoch)| 4 bit(分代年龄)| 1 bit(偏向锁位)| 2 bit(锁标志位)|
// |------------------|----------------|-------------------|---------------|--------------|
// | threadId | epoch | age | 1 | 01 |

// 轻量级锁状态:
// | 30 bit(指向栈中锁记录的指针)| 2 bit(锁标志位)|
// |------------------------------|----------------|
// | pointer to Lock Record | 00 |

// 重量级锁状态:
// | 30 bit(指向监视器 Monitor 的指针)| 2 bit(锁标志位)|
// |----------------------------------|----------------|
// | pointer to Monitor | 10 |

锁升级的完整过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
① 无锁(No Lock
→ 对象创建后,初始为无锁状态

② 偏向锁(Biased Lock
→ 第一个线程获取锁时,将线程 ID 记录在 Mark Word 中
→ 以后该线程再次获取锁时,只需要检查线程 ID 是否匹配(不需要 CAS)
→ 性能接近无锁!
→ 适用场景:只有一个线程访问同步块

③ 轻量级锁(Lightweight Lock
→ 当有第二个线程尝试获取锁时,偏向锁升级为轻量级锁
→ 线程在栈帧中创建「锁记录」(Lock Record),用 CAS 将 Mark Word 替换为指向锁记录的指针
→ 成功:获取轻量级锁
→ 失败:自旋(spin)一定次数后,升级为重量级锁
→ 适用场景:多线程交替执行同步块(竞争不激烈)

④ 重量级锁(Heavyweight Lock
→ 轻量级锁自旋失败(或竞争激烈)时,升级为重量级锁
→ 依赖操作系统的 Mutex Lock(互斥锁)实现
→ 未获取到锁的线程进入 BLOCKED 状态(挂起,需要上下文切换)
→ 性能最差,但能保证竞争激烈时的正确性
→ 适用场景:多线程同时竞争锁(竞争激烈)

锁升级的代码演示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 锁升级的触发:
public class LockUpgradeDemo {
Object lock = new Object();

void method() {
// ① 无锁状态:lock 对象刚创建

synchronized (lock) {
// ② 第一次进入:获取偏向锁(将当前线程 ID 写入 Mark Word)
}

// 另一个线程尝试获取锁:
new Thread(() -> {
synchronized (lock) {
// ③ 偏向锁升级为轻量级锁(CAS 竞争)
}
}).start();

// 如果竞争激烈(多个线程同时竞争锁):
// ④ 轻量级锁升级为重量级锁(依赖操作系统 Mutex Lock)
}
}

偏向锁的撤销(重要!):

1
2
3
4
5
6
7
8
9
10
11
12
// 偏向锁的撤销(Revoke):
// 当有另一个线程尝试获取偏向锁时,需要撤销偏向锁(STW)

// 撤销过程(需要 STW):
// 1. 暂停持有偏向锁的线程
// 2. 检查该线程是否还持有锁
// → 如果还持有:升级为轻量级锁(在栈帧中创建锁记录)
// → 如果不持有:将对象头恢复为无锁状态(可以重新偏向)
// 3. 唤醒被暂停的线程

// Java 15 开始,偏向锁默认禁用(因为撤销的 STW 开销在某些场景下不值得)
// 可以通过 -XX:+UseBiasedLocking 开启(不推荐)

面试加分回答

「synchronized 的锁升级是 Java 6 引入的重要优化。面试时经常追问:为什么有了 synchronized,还需要 ReentrantLock? 答案是:1. ReentrantLock 可以尝试获取锁(tryLock()),synchronized 会一直阻塞;2. ReentrantLock 可以中断获取锁(lockInterruptibly()),synchronized 不支持中断;3. ReentrantLock 可以设置公平锁,synchronized 只能是非公平锁;4. ReentrantLock 有多个条件变量(Condition),synchronized 只有一个等待队列。但 Java 6 之后,synchronized 的性能已经和 ReentrantLock 接近(因为锁升级),优先使用 synchronized(更简洁,JVM 可以继续优化它)。另外,Java 15 开始偏向锁默认禁用(JEP 374),因为现代应用中,偏向锁的撤销开销(STW)可能大于其带来的性能提升。」


四、线程通信(17-20)


第 17 题:wait()、notify()、notifyAll() 的使用注意事项?

一句话结论

wait()/notify()/notifyAll() 必须在 synchronized 块中调用,且要放在 while 循环中(防止虚假唤醒)。notify() 随机唤醒一个等待线程,notifyAll() 唤醒所有等待线程。


深度解析

为什么必须在 synchronized 块中?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// ❌ 错误用法(会抛 IllegalMonitorStateException):
class Wrong {
void method() {
Object lock = new Object();
lock.wait(); // ← 必须在 synchronized 块中!
}
}

// ✅ 正确用法:
class Correct {
void method() {
Object lock = new Object();
synchronized (lock) { // ← 必须先获取锁
try {
lock.wait(); // ← 在 synchronized 块中调用
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
}

为什么有这个要求? wait() 会释放锁,如果不先获取锁,释放什么?JVM 需要在对象头上维护等待队列,这要求当前线程必须持有该对象的监视器锁。


为什么要放在 while 循环中?(虚假唤醒)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// ❌ 错误用法(可能虚假唤醒):
synchronized (lock) {
if (!condition) { // ← 用 if,有问题!
lock.wait(); // 虚假唤醒后,不会重新检查条件
}
// 执行到这里,condition 可能为 false!
}

// ✅ 正确用法(防止虚假唤醒):
synchronized (lock) {
while (!condition) { // ← 用 while(每次被唤醒后都重新检查条件)
lock.wait();
}
// 执行到这里,condition 一定为 true
}

什么是虚假唤醒? 在某些操作系统上,wait() 可能在没有被 notify()/notifyAll() 唤醒的情况下返回(操作系统层面的缺陷)。Java 官方推荐:永远在 while 循环中使用 wait()


notify() vs notifyAll():

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// notify():随机唤醒一个等待线程
// notifyAll():唤醒所有等待线程

// 示例:多个线程等待同一个条件
Object lock = new Object();

// 线程 A、B、C 都等待 condition:
synchronized (lock) {
while (!condition) {
lock.wait(); // A、B、C 都进入等待队列
}
}

// 另一个线程修改 condition 并唤醒:
synchronized (lock) {
condition = true;
lock.notify(); // ← 只唤醒 A、B、C 中的一个(随机)
// lock.notifyAll(); // ← 唤醒 A、B、C 所有线程
}

// 如果用 notify(),被唤醒的线程重新检查 while (condition),如果 condition 为 true,继续执行
// 但其他线程仍然在等待(没有被唤醒)→ 可能导致「信号丢失」
// 所以用 notifyAll() 更安全(唤醒所有等待线程,让它们重新竞争锁)

面试加分回答

「wait()/notify() 的使用是 Java 并发的基础。面试时经常追问:为什么 wait() 要放在 while 循环中? 答案是:1. 防止虚假唤醒(spurious wakeup);2. 被唤醒后,条件可能已经被其他线程改变(需要重新检查)。另外,wait() 和 sleep() 的区别也是一个经典问题:1. wait() 会释放锁,sleep() 不会释放锁;2. wait() 需要在 synchronized 块中,sleep() 不需要;3. wait() 用于线程通信,sleep() 用于暂停当前线程。最后,Java 1.8+ 推荐使用 Condition(await()/signal())代替 wait()/notify(),因为 Condition 支持多个条件变量。」


第 18 题:CountDownLatch 和 CyclicBarrier 的区别?

一句话结论

CountDownLatch = 一个线程等待 N 个线程完成后再继续(一次性,不能重置);CyclicBarrier = N 个线程互相等待,都到达屏障点后一起继续(可重复使用)。


深度解析

CountDownLatch 的用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 场景:主线程等待 3 个子线程完成任务
CountDownLatch latch = new CountDownLatch(3); // ← 计数器初始值为 3

// 子线程:
void worker() {
try {
// 执行任务
} finally {
latch.countDown(); // ← 计数器减 1
}
}

// 主线程:
void main() throws InterruptedException {
// 启动 3 个子线程
for (int i = 0; i < 3; i++) {
new Thread(this::worker).start();
}
latch.await(); // ← 阻塞,直到计数器变为 0(3 个子线程都完成任务)
System.out.println("所有任务完成");
}

CyclicBarrier 的用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 场景:3 个线程互相等待,都到达后一起继续
CyclicBarrier barrier = new CyclicBarrier(3); // ← 需要 3 个线程都到达

// 线程:
void worker() {
try {
System.out.println(Thread.currentThread().getName() + " 到达屏障");
barrier.await(); // ← 等待其他线程也到达
System.out.println(Thread.currentThread().getName() + " 继续运行");
} catch (Exception e) {
e.printStackTrace();
}
}

// 启动 3 个线程:
for (int i = 0; i < 3; i++) {
new Thread(this::worker).start();
}
// 输出:
// Thread-0 到达屏障
// Thread-1 到达屏障
// Thread-2 到达屏障
// (3 个线程都到达后,才继续)
// Thread-0 继续运行
// Thread-1 继续运行
// Thread-2 继续运行

CountDownLatch vs CyclicBarrier:

对比维度 CountDownLatch CyclicBarrier
等待方式 一个(多个)线程等待 N 个线程完成 N 个线程互相等待
是否可重用 ❌ 一次性(计数器到 0 后不能重置) ✅ 可重用(自动重置计数器)
底层实现 AQS(共享模式) ReentrantLock + Condition
适用场景 主线程等待子线程完成 多个线程互相等待(如分阶段计算)
是否有回调 ❌ 无 ✅ 可以设置屏障动作(最后一个到达的线程执行)

CyclicBarrier 的屏障动作(回调):

1
2
3
4
5
6
// CyclicBarrier 可以在所有线程到达屏障后,执行一个回调任务
CyclicBarrier barrier = new CyclicBarrier(3, () -> {
System.out.println("所有线程都到达屏障,执行回调任务");
});

// 最后一个到达屏障的线程,会执行这个回调任务

面试加分回答

「CountDownLatch 和 CyclicBarrier 的区别是经典面试题。进阶追问:CountDownLatch 能不能复用? 答案是:不能,计数器到 0 后,再调用 await() 会立即返回(不会阻塞)。如果需要复用,用 CyclicBarrier(可以自动重置计数器)。另外,CyclicBarrier 和 Phaser(Java 7+)的关系:Phaser 是更灵活的屏障,支持动态调整参与者数量(CyclicBarrier 固定参与者数量),适合更复杂的阶段同步场景。」


第 19 题:Semaphore 的原理和使用场景?

一句话结论

Semaphore(信号量)= 控制同时访问某个资源的线程数量。acquire() 获取许可证,release() 释放许可证。常用于限流(如数据库连接池)。


深度解析

基本用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 场景:限制同时访问资源的线程数量为 5
Semaphore semaphore = new Semaphore(5); // ← 5 个许可证

void worker() throws InterruptedException {
semaphore.acquire(); // ← 获取许可证(如果没有许可证,阻塞)
try {
// 访问受限资源(同时最多 5 个线程能执行这里)
Thread.sleep(1000);
} finally {
semaphore.release(); // ← 释放许可证
}
}

// 启动 10 个线程:
for (int i = 0; i < 10; i++) {
new Thread(this::worker).start();
}
// 结果:同时只有 5 个线程能执行「访问受限资源」,其他线程阻塞等待

Semaphore 的公平模式:

1
2
3
4
5
6
7
// 非公平模式(默认,性能更好):
Semaphore unfair = new Semaphore(5); // 非公平

// 公平模式(按照请求顺序获取许可证,避免饥饿):
Semaphore fair = new Semaphore(5, true); // 公平

// 公平模式的性能较差(需要维护等待队列),但能保证不饥饿

Semaphore 的应用场景:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 场景 1:数据库连接池(限制连接数量)
class ConnectionPool {
private final Semaphore semaphore;
private final List<Connection> connections;

public ConnectionPool(int poolSize) {
semaphore = new Semaphore(poolSize);
connections = new ArrayList<>(poolSize);
// 初始化连接池...
}

public Connection getConnection() throws InterruptedException {
semaphore.acquire(); // 获取许可证
return connections.remove(0); // 从池中获取连接
}

public void releaseConnection(Connection conn) {
connections.add(conn); // 归还连接
semaphore.release(); // 释放许可证
}
}

// 场景 2:限流(限制 QPS)
// 如:限制某个接口每秒最多处理 100 个请求
Semaphore rateLimiter = new Semaphore(100);
// 每个请求到来时:acquire()
// 后台定时任务每秒 release(100)(重置许可证数量)

Semaphore 的底层原理(基于 AQS):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// Semaphore 基于 AQS 实现(共享模式):
// - state = 许可证数量
// - acquire():state - 1(如果 state = 0,阻塞)
// - release():state + 1(唤醒等待线程)

// Semaphore 的内部类 Sync 继承 AQS:
abstract class Sync extends AbstractQueuedSynchronizer {
Sync(int permits) {
setState(permits); // state = 许可证数量
}

// 尝试获取共享锁(acquire)
@Override
protected int tryAcquireShared(int acquires) {
for (;;) {
int available = getState();
int remaining = available - acquires;
if (remaining < 0 || compareAndSetState(available, remaining)) {
return remaining; // 返回值 >= 0 表示获取成功
}
}
}

// 尝试释放共享锁(release)
@Override
protected boolean tryReleaseShared(int releases) {
for (;;) {
int current = getState();
int next = current + releases;
if (compareAndSetState(current, next)) {
return true;
}
}
}
}

面试加分回答

「Semaphore 是 AQS 共享模式的典型应用。面试时可能会问:Semaphore 和 CountDownLatch 的区别? 答案是:1. Semaphore 控制同时访问的线程数量(许可证),CountDownLatch 等待 N 个线程完成;2. Semaphore 的许可证可以被释放(release()),CountDownLatch 的计数器只能减不能加;3. Semaphore 可以动态调整许可证数量(reducePermits()),CountDownLatch 不能。另外,Semaphore 常用于实现限流器(如 Guava 的 RateLimiter 底层就用了 Semaphore)。」


第 20 题:Exchanger 的作用?

一句话结论

Exchanger = 两个线程在同步点交换数据。一个线程调用 exchange(data) 后阻塞,直到另一个线程也调用 exchange(data),然后互相交换数据。


深度解析

基本用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 场景:两个线程交换数据
Exchanger<String> exchanger = new Exchanger<>();

// 线程 A:
new Thread(() -> {
try {
String dataA = "来自线程 A 的数据";
String received = exchanger.exchange(dataA); // ← 阻塞,等待线程 B 也调用 exchange()
System.out.println("线程 A 收到:" + received);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}).start();

// 线程 B:
new Thread(() -> {
try {
String dataB = "来自线程 B 的数据";
Thread.sleep(1000); // 模拟延迟
String received = exchanger.exchange(dataB); // ← 两个线程都调用了 exchange(),交换数据
System.out.println("线程 B 收到:" + received);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}).start();

// 输出:
// 线程 A 收到:来自线程 B 的数据
// 线程 B 收到:来自线程 A 的数据

应用场景:

1
2
3
4
5
6
// 场景 1:数据校验(两个线程分别生成数据,然后交换校验)
// 场景 2:遗传算法(两个线程分别进化种群,然后交换基因)
// 场景 3:流水线处理(两个线程分别处理数据的不同部分,然后交换结果)

// 注意:Exchanger 只能用于两个线程之间的数据交换
// 如果有多个线程,需要用其他同步工具(如 CyclicBarrier + 共享队列)

面试加分回答

「Exchanger 是 Java 并发包中比较冷门的工具类,实际使用中较少遇到。面试时如果被问到,知道它的基本作用即可:两个线程在同步点交换数据。更常见的线程通信工具是:CountDownLatch、CyclicBarrier、Semaphore、Exchanger。另外,Exchanger 在 Java 中是基于「槽位」(slot)实现的:两个线程在同一个槽位上交换数据,如果只有一个线程到达,它会等待(阻塞)。」


五、并发集合(21-24)


第 21 题:ConcurrentHashMap 的原理?(Java 7 vs Java 8+)

一句话结论

Java 7:Segment 分段锁(16 个 Segment,默认 16 个线程并发写);Java 8+:CAS + synchronized(锁粒度更细,性能更好)。Java 8+ 的 ConcurrentHashMap 更接近 HashMap(数组 + 链表 + 红黑树),只是把锁细化到桶级别。


深度解析

Java 7 的 ConcurrentHashMap(Segment 分段锁):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Java 7 的 ConcurrentHashMap 结构:
// - 整个 ConcurrentHashMap 由多个 Segment 组成(默认 16 个)
// - 每个 Segment 是一个可重入锁(继承 ReentrantLock)
// - 每个 Segment 包含一个 HashEntry 数组(类似 HashMap)
//
// 写操作:只需要锁住对应的 Segment(其他 Segment 可以并发访问)
// 并发度:默认 16(16 个线程可以同时写)

struct ConcurrentHashMap_Java7 {
Segment[] segments; // 默认 16 个 Segment
}

struct Segment {
HashEntry[] table; // 类似 HashMap 的 table
int count;
// Segment 继承 ReentrantLock,可以作为锁使用
}

Java 7 的问题:Segment 数量固定(默认 16),如果哈希冲突严重,某个 Segment 的冲突很多,其他 Segment 空闲,并发度受限。


Java 8+ 的 ConcurrentHashMap(CAS + synchronized):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// Java 8+ 的 ConcurrentHashMap 结构(和 HashMap 类似):
// - 数组(Node[] table)+ 链表 + 红黑树
// - 写操作:先尝试 CAS(无锁),失败则用 synchronized 锁住当前桶的头节点

// 写操作的核心方法 putVal()(简化):
final V putVal(K key, V value, boolean onlyIfAbsent) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 1. 如果 table 为空,初始化(CAS)
if (tab == null || (n = tab.length) == 0) {
tab = initTable(); // CAS 初始化
}
// 2. 计算桶位置
else if ((p = tabAt(tab, i = (n - 1) & hash)) == null) {
// 桶为空:CAS 插入(无锁)
if (casTabAt(tab, i, null, newNode(hash, key, value, null)))
break; // CAS 成功,插入完成
}
// 3. 桶不为空:synchronized 锁住头节点
else {
synchronized (p) { // ← 只锁住当前桶的头节点(锁粒度很细!)
// 插入或更新节点
}
}
// 4. 判断是否需要树化(链表长度 >= 8)
// ...
}

Java 7 vs Java 8+ 的 ConcurrentHashMap 对比:

对比维度 Java 7 Java 8+
锁结构 Segment 分段锁(继承 ReentrantLock) CAS + synchronized(锁桶的头节点)
锁粒度 Segment 级别(默认 16 个 Segment) 桶级别(每个桶独立加锁)
并发度 固定(默认 16) 更高(理论上 = 桶的数量)
数据结构 Segment + HashEntry 数组 + 链表 Node 数组 + 链表 + 红黑树(和 HashMap 一致)
查询性能 需要两次哈希(先定位 Segment,再定位桶) 一次哈希(直接定位桶)

ConcurrentHashMap 的 size() 实现(Java 8+):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Java 8+ 的 size() 实现(分段计数,避免竞争):
// - 用 CounterCell 数组(类似 LongAdder 的分段计数)
// - 多个线程修改时,分散到不同的 CounterCell 中(减少 CAS 竞争)

// size() 方法:
public long mappingCount() { // ← 推荐用 mappingCount() 代替 size()(返回 long)
long n = sumCount();
return (n < 0L) ? 0L : n; // 可能是负数(并发修改),但不会影响正确性
}

// sumCount():
final long sumCount() {
CounterCell[] as = counterCells; CounterCell a;
long sum = baseCount;
if (as != null) {
for (int i = 0; i < as.length; ++i) {
if ((a = as[i]) != null)
sum += a.value; // 累加所有 CounterCell 的值
}
}
return sum;
}

面试加分回答

「ConcurrentHashMap 是 Java 并发包中使用最频繁的并发集合。面试时经常追问:ConcurrentHashMap 和 Hashtable 的区别? 答案是:1. Hashtable 是全表锁(所有操作都 synchronized),ConcurrentHashMap 是分段锁/CAS + synchronized(锁粒度更细,并发性能更好);2. Hashtable 不允许 null 键值,ConcurrentHashMap 也不允许(但 HashMap 允许);3. Hashtable 是 Java 1.0 遗留类,不推荐使用,ConcurrentHashMap 是 Java 5+ 的推荐替代。另外,Java 8+ 的 ConcurrentHashMap 为什么放弃 Segment 分段锁? 答案是:Segment 的并发度固定(默认 16),且内存开销更大(每个 Segment 是一个独立的 HashEntry 数组)。Java 8+ 使用 CAS + synchronized,锁粒度细化到桶级别,性能更好,且实现更简洁。」


第 22 题:CopyOnWriteArrayList 的原理和适用场景?

一句话结论

CopyOnWriteArrayList = 写时复制(COW)。读操作不加锁(性能很好),写操作先复制底层数组,修改后再替换(适合读多写少的场景)。缺陷:内存占用大、写操作慢。


深度解析

基本用法:

1
2
3
4
5
6
7
8
// CopyOnWriteArrayList:线程安全的 List
List<String> list = new CopyOnWriteArrayList<>();

// 读操作(不加锁,性能很好):
String value = list.get(0); // ← 直接读,不需要加锁

// 写操作(先复制数组,再修改):
list.add("new value"); // ← 复制底层数组,修改后替换

CopyOnWriteArrayList 的写操作原理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// add() 方法的实现(简化):
public boolean add(E e) {
final ReentrantLock lock = this.lock;
lock.lock(); // ← 写操作需要加锁(避免多个写线程同时复制数组)
try {
Object[] elements = getArray(); // 获取当前数组
int len = elements.length;
Object[] newElements = Arrays.copyOf(elements, len + 1); // ← 复制数组!
newElements[len] = e; // 在新数组中修改
setArray(newElements); // ← 替换底层数组(volatile 写)
return true;
} finally {
lock.unlock();
}
}

// get() 方法(不需要加锁!):
public E get(int index) {
return elementAt(getArray(), index); // ← 直接读,不加锁
}

CopyOnWriteArrayList 的优缺点:

1
2
3
4
5
6
7
8
9
优点:
1. 读操作不加锁(性能很好,适合读多写少的场景)
2. 写操作和读操作互不干扰(写操作复制数组,读操作读旧数组)
3. 迭代器是「快照迭代器」(迭代时不会抛 ConcurrentModificationException)

缺点:
1. 写操作需要复制数组(内存开销大,写操作慢)
2. 内存占用大(写操作时会同时存在两个数组:旧数组 + 新数组)
3. 只能保证最终一致性(读操作可能读到旧数据)

适用场景:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// ✅ 适合:读多写少(如:监听器列表、配置列表)
// 监听器列表(监听器注册/注销很少,但事件触发时遍历很多次)
CopyOnWriteArrayList<Listener> listeners = new CopyOnWriteArrayList<>();

void addListener(Listener l) {
listeners.add(l); // 写操作(很少)
}

void fireEvent() {
for (Listener l : listeners) { // 读操作(很多)
l.onEvent();
}
}

// ❌ 不适合:写多读少(写操作需要复制数组,性能很差)
// ❌ 不适合:数据量很大(复制数组的内存开销太大)

面试加分回答

「CopyOnWriteArrayList 是「写时复制」思想的典型应用。面试时可能会问:CopyOnWriteArrayList 的迭代器为什么不会抛 ConcurrentModificationException? 答案是:CopyOnWriteArrayList 的迭代器是基于「快照」的(迭代器创建时,复制当前数组的引用),迭代过程中,即使原数组被修改(替换成新数组),迭代器仍然遍历旧数组(不会抛 ConcurrentModificationException)。但这样会导致迭代器可能读到旧数据(最终一致性)。另外,CopyOnWriteArraySet 是基于 CopyOnWriteArrayList 实现的(适合读多写少的 Set 场景)。」


第 23 题:BlockingQueue 的实现类和适用场景?

一句话结论

BlockingQueue = 支持阻塞的队列(取元素时队列为空则阻塞,放元素时队列已满则阻塞)。常用实现类:ArrayBlockingQueue(数组)、LinkedBlockingQueue(链表)、PriorityBlockingQueue(优先级)、SynchronousQueue(同步队列,直接传递)、DelayQueue(延迟队列)。


深度解析

BlockingQueue 的核心方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
interface BlockingQueue<E> extends Queue<E> {
// 插入元素
void put(E e) throws InterruptedException; // ← 队列已满则阻塞
boolean offer(E e, long timeout, TimeUnit unit); // ← 队列已满则等待指定时间

// 获取元素
E take() throws InterruptedException; // ← 队列为空则阻塞
E poll(long timeout, TimeUnit unit); // ← 队列为空则等待指定时间

// 非阻塞方法(继承自 Queue)
boolean add(E e); // 队列已满则抛异常
E remove(); // 队列为空则抛异常
E poll(); // 队列为空则返回 null
}

常用实现类:

① ArrayBlockingQueue(数组实现,有界队列)

1
2
3
4
5
6
7
8
9
10
// ArrayBlockingQueue:基于数组的有界阻塞队列
BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(10); // ← 容量 10

// 特点:
// 1. 有界(容量固定,创建时指定)
// 2. 基于数组(内存连续,缓存友好)
// 3. 内部使用 ReentrantLock 保证线程安全
// 4. 支持公平锁(构造时传入 true)

// 适用场景:有界缓冲区(如:线程池的任务队列)

② LinkedBlockingQueue(链表实现,可选有界/无界)

1
2
3
4
5
6
7
8
9
10
// LinkedBlockingQueue:基于链表的可选有界阻塞队列
BlockingQueue<Integer> queue1 = new LinkedBlockingQueue<>(); // ← 无界(Integer.MAX_VALUE)
BlockingQueue<Integer> queue2 = new LinkedBlockingQueue<>(100); // ← 有界(容量 100)

// 特点:
// 1. 可选有界/无界(默认无界,可能导致 OOM)
// 2. 基于链表(内存不连续)
// 3. 使用两把锁(putLock 和 takeLock),并发性能更好

// 适用场景:生产者-消费者模式(默认无界,但要注意 OOM)

③ PriorityBlockingQueue(优先级队列,无界)

1
2
3
4
5
6
7
8
9
10
// PriorityBlockingQueue:支持优先级的无界阻塞队列
// 元素必须实现 Comparable,或在构造时传入 Comparator
BlockingQueue<String> queue = new PriorityBlockingQueue<>(10, Comparator.naturalOrder());

// 特点:
// 1. 无界(会动态扩容,可能 OOM)
// 2. 元素按优先级排序(不是 FIFO)
// 3. 只保证取出的元素是按优先级的,不保证遍历顺序

// 适用场景:需要按优先级处理的任务队列

④ SynchronousQueue(同步队列,容量为 0)

1
2
3
4
5
6
7
8
9
10
11
// SynchronousQueue:同步队列(每个 put() 必须等待一个 take(),反之亦然)
BlockingQueue<Integer> queue = new SynchronousQueue<>();

// 特点:
// 1. 容量为 0(不存储元素)
// 2. 每个 put() 必须等待一个 take()(同步传递)
// 3. 适用于「直接传递」场景(生产者线程和消费者线程同步)

// 适用场景:线程池的任务队列(如:Executors.newCachedThreadPool() 使用 SynchronousQueue)
// → 如果有空闲线程,任务直接交给空闲线程处理
// → 如果没有空闲线程,创建新线程

⑤ DelayQueue(延迟队列)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// DelayQueue:延迟队列(元素必须实现 Delayed 接口)
class DelayedTask implements Delayed {
long delayTime;
long startTime;

DelayedTask(long delay) {
this.delayTime = delay;
this.startTime = System.currentTimeMillis() + delay;
}

@Override
public long getDelay(TimeUnit unit) {
long diff = startTime - System.currentTimeMillis();
return unit.convert(diff, TimeUnit.MILLISECONDS);
}

@Override
public int compareTo(Delayed o) {
return Long.compare(this.startTime, ((DelayedTask) o).startTime);
}
}

// 使用:
DelayQueue<DelayedTask> queue = new DelayQueue<>();
queue.put(new DelayedTask(5000)); // 5 秒后可取出

// take():只有当元素的 getDelay() <= 0 时,才能取出
DelayedTask task = queue.take(); // ← 阻塞,直到延迟时间到期

面试加分回答

「BlockingQueue 是生产者-消费者模式的经典实现。面试时经常问:Java 线程池中的任务队列用的是什么 BlockingQueue? 答案是:1. FixedThreadPool 和 SingleThreadExecutor 使用 LinkedBlockingQueue(无界);2. CachedThreadPool 使用 SynchronousQueue(同步队列);3. ScheduledThreadPool 使用 DelayedWorkQueue(类似 DelayQueue)。另外,BlockingQueue 和 ConcurrentLinkedQueue 的区别:BlockingQueue 支持阻塞操作(put()/take()),ConcurrentLinkedQueue 是无锁队列(CAS 实现,性能更好,但不支持阻塞)。」


第 24 题:ConcurrentLinkedQueue 的原理?

一句话结论

ConcurrentLinkedQueue = 基于 CAS 的无锁并发队列(性能很好,但不支持阻塞)。适合「生产者-消费者」场景中不需要阻塞功能的场景。


深度解析

ConcurrentLinkedQueue vs BlockingQueue:

1
2
3
4
5
6
7
8
9
10
// ConcurrentLinkedQueue:无锁并发队列(CAS 实现)
ConcurrentLinkedQueue<Integer> queue = new ConcurrentLinkedQueue<>();

// 方法:
queue.offer(1); // 入队(不阻塞)
Integer value = queue.poll(); // 出队(不阻塞,队列为空则返回 null)

// 对比 BlockingQueue:
// - ConcurrentLinkedQueue:无锁,性能更好,不阻塞
// - BlockingQueue:有锁(或 CAS + 条件变量),支持阻塞

ConcurrentLinkedQueue 的底层原理(CAS + 尾指针延迟更新):

1
2
3
4
5
6
7
8
9
10
11
12
13
// ConcurrentLinkedQueue 的核心字段:
// - head:头指针(volatile)
// - tail:尾指针(volatile)
//
// 入队(offer())的简化逻辑:
// 1. 从 tail 开始,找到真正的尾节点(tail 可能滞后)
// 2. CAS 将新节点设置为尾节点的 next
// 3. 如果 CAS 失败(有其他线程并发入队),重试

// 出队(poll())的简化逻辑:
// 1. 从 head 开始,找到真正的头节点(head 可能滞后)
// 2. CAS 将头节点的 item 设为 null(标记已删除)
// 3. 如果 CAS 失败(有其他线程并发出队),重试

ConcurrentLinkedQueue 的「尾指针延迟更新」优化:入队时,不一定每次都更新 tail(减少 CAS 竞争)。当 tail 距离真正的尾节点超过 2 步时,才更新 tail。


面试加分回答

「ConcurrentLinkedQueue 是 Java 并发包中性能最好的并发队列(无锁,基于 CAS)。面试时可能会问:ConcurrentLinkedQueue 和 LinkedBlockingQueue 怎么选? 答案是:1. 如果需要阻塞功能(队列为空时阻塞消费者,队列已满时阻塞生产者),用 LinkedBlockingQueue;2. 如果不需要阻塞功能,且对性能要求高,用 ConcurrentLinkedQueue(无锁,性能更好)。另外,ConcurrentLinkedQueue 的 size() 方法性能很差(需要遍历整个队列),如果需要获取队列大小,建议用 AtomicInteger 单独维护计数器。」


六、线程池(25-28)


第 25 题:ThreadPoolExecutor 的参数含义?

一句话结论

ThreadPoolExecutor 有 7 个核心参数:corePoolSize(核心线程数)、maximumPoolSize(最大线程数)、keepAliveTime(空闲线程存活时间)、unit(时间单位)、workQueue(任务队列)、threadFactory(线程工厂)、handler(拒绝策略)。


深度解析

7 个核心参数:

1
2
3
4
5
6
7
8
ThreadPoolExecutor executor = new ThreadPoolExecutor(
5, // ① corePoolSize:核心线程数
20, // ② maximumPoolSize:最大线程数
60L, TimeUnit.SECONDS, // ③ keepAliveTime + unit:空闲线程存活时间
new LinkedBlockingQueue<>(100), // ④ workQueue:任务队列
Executors.defaultThreadFactory(), // ⑤ threadFactory:线程工厂
new ThreadPoolExecutor.AbortPolicy() // ⑥ handler:拒绝策略
);

线程池的工作流程(非常重要!):

1
2
3
4
5
6
7
8
9
10
11
12
13
提交任务到线程池:

1. 当前线程数 < corePoolSize?
→ 是:创建新线程(核心线程)执行任务
→ 否:进入步骤 2

2. 任务队列是否已满?
→ 否:将任务放入队列
→ 是:进入步骤 3

3. 当前线程数 < maximumPoolSize?
→ 是:创建新线程(非核心线程)执行任务
→ 否:进入步骤 4(执行拒绝策略)

关键:先填满核心线程数,再填满任务队列,最后才创建非核心线程(直到 maximumPoolSize)。


任务队列(workQueue)的选择:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 1. ArrayBlockingQueue(有界队列)
new ArrayBlockingQueue<>(100); // ← 队列容量固定为 100
// → 当核心线程数满了,任务进入队列
// → 队列满了,才创建非核心线程
// → 适用场景:需要限制任务队列大小(防止 OOM)

// 2. LinkedBlockingQueue(无界队列,默认 Integer.MAX_VALUE)
new LinkedBlockingQueue<>() // ← 无界(可能 OOM!)
// → 核心线程数满了,任务进入队列(队列永远不会满)
// → 永远不会创建非核心线程(maximumPoolSize 无效)
// → 适用场景:任务量不大,且内存充足

// 3. SynchronousQueue(同步队列,容量为 0)
new SynchronousQueue<>() // ← 不存储任务,直接交给线程处理
// → 如果没有空闲线程,创建新线程(直到 maximumPoolSize)
// → 如果线程数达到 maximumPoolSize,执行拒绝策略
// → 适用场景:任务处理速度快,且需要快速响应

// 4. PriorityBlockingQueue(优先级队列)
new PriorityBlockingQueue<>() // ← 按优先级处理任务

拒绝策略(handler)的选择:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 1. AbortPolicy(默认):抛 RejectedExecutionException
new ThreadPoolExecutor.AbortPolicy()

// 2. CallerRunsPolicy:由调用线程执行任务(如 main 线程)
new ThreadPoolExecutor.CallerRunsPolicy()
// → 不会丢弃任务,但会降低提交速度(调用线程被占用)

// 3. DiscardPolicy:直接丢弃任务(不抛异常)
new ThreadPoolExecutor.DiscardPolicy()

// 4. DiscardOldestPolicy:丢弃队列中最老的任务,然后重试当前任务
new ThreadPoolExecutor.DiscardOldestPolicy()

// 自定义拒绝策略:
new RejectedExecutionHandler() {
@Override
public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
// 记录日志、持久化到数据库、发送给消息队列...
}
}

面试加分回答

「ThreadPoolExecutor 的参数是 Java 并发面试的核心。面试时经常追问:核心线程数和最大线程数怎么设置? 答案是:1. CPU 密集型任务(如:计算):线程数 = CPU 核心数 + 1(减少线程切换开销);2. IO 密集型任务(如:数据库查询、网络请求):线程数 = CPU 核心数 * 2(或更高,因为 IO 操作时线程阻塞,可以让其他线程运行)。另外,阿里 Java 开发手册规定:禁止使用 Executors 创建线程池(因为 FixedThreadPool 使用无界队列 LinkedBlockingQueue,可能 OOM;CachedThreadPool 最大线程数为 Integer.MAX_VALUE,可能创建大量线程导致 OOM),必须使用 ThreadPoolExecutor 手动创建线程池。」


第 26 题:Executors 工具类的缺陷?

一句话结论

Executors 创建的线程池有缺陷:FixedThreadPool 和 SingleThreadExecutor 使用无界队列(可能 OOM);CachedThreadPool 允许创建无限个线程(可能耗尽系统资源)。阿里 Java 开发手册规定:禁止使用 Executors 创建线程池,必须使用 ThreadPoolExecutor 手动创建。


深度解析

Executors 创建的线程池的问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 1. FixedThreadPool(固定大小线程池)
ExecutorService fixed = Executors.newFixedThreadPool(2);
// 底层:
// new ThreadPoolExecutor(2, 2, 0L, TimeUnit.MILLISECONDS,
// new LinkedBlockingQueue<Runnable>()) // ← 无界队列!
// 问题:任务队列是无界的(Integer.MAX_VALUE),可能 OOM

// 2. SingleThreadExecutor(单线程线程池)
ExecutorService single = Executors.newSingleThreadExecutor();
// 底层:
// new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS,
// new LinkedBlockingQueue<Runnable>()) // ← 无界队列!
// 问题:同上(可能 OOM)

// 3. CachedThreadPool(缓存线程池)
ExecutorService cached = Executors.newCachedThreadPool();
// 底层:
// new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS,
// new SynchronousQueue<Runnable>()) // ← 同步队列
// 问题:最大线程数是 Integer.MAX_VALUE(约 21 亿),可能创建大量线程 → OOM

// 4. ScheduledThreadPool(定时任务线程池)
ExecutorService scheduled = Executors.newScheduledThreadPool(2);
// 底层:
// new ScheduledThreadPoolExecutor(2, threadFactory)
// 问题:任务队列是 DelayedWorkQueue(无界),可能 OOM

正确的创建方式(手动创建 ThreadPoolExecutor):

1
2
3
4
5
6
7
8
9
10
11
12
13
// ✅ 正确用法:手动创建 ThreadPoolExecutor,明确指定所有参数
ThreadPoolExecutor executor = new ThreadPoolExecutor(
5, // corePoolSize
20, // maximumPoolSize
60L, TimeUnit.SECONDS, // keepAliveTime
new ArrayBlockingQueue<>(100), // ← 有界队列(防止 OOM)
new ThreadFactoryBuilder().setNameFormat("my-pool-%d").build(), // ← 自定义线程名(便于调试)
new ThreadPoolExecutor.CallerRunsPolicy() // ← 自定义拒绝策略
);

// 关闭线程池:
executor.shutdown(); // 平缓关闭(等待所有任务执行完)
// executor.shutdownNow(); // 立即关闭(尝试中断正在执行的任务)

面试加分回答

「Executors 工具类的问题是阿里 Java 开发手册的强制规定。面试时可能会问:如果不用 Executors,那怎么创建线程池? 答案是:手动 new ThreadPoolExecutor(),明确指定所有参数(核心线程数、最大线程数、任务队列、拒绝策略等)。另外,线程池的关闭:shutdown() 平缓关闭(等待任务执行完),shutdownNow() 立即关闭(尝试中断正在执行的任务,返回等待中的任务列表)。如果需要「优雅关闭」(先拒绝新任务,再等待一段时间,最后强制关闭),可以用 shutdown() + awaitTermination()。」


第 27 题:Future 和 FutureTask 的原理?

一句话结论

Future = 异步计算的结果(接口);FutureTask = Future + Runnable(实现类)。可以通过 Future.get() 获取异步计算的结果(会阻塞)。


深度解析

基本用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 1. 使用 ThreadPoolExecutor + Future
ExecutorService executor = Executors.newFixedThreadPool(1);
Future<String> future = executor.submit(() -> {
Thread.sleep(1000);
return "计算结果";
});

// 2. 获取结果(会阻塞)
try {
String result = future.get(); // ← 阻塞,直到计算完成
System.out.println(result);
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}

// 3. 超时获取结果
try {
String result = future.get(2, TimeUnit.SECONDS); // ← 最多等 2 秒
System.out.println(result);
} catch (TimeoutException e) {
System.out.println("超时");
}

FutureTask 的用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// FutureTask = Future + Runnable(可以直接传给 Thread)
FutureTask<String> futureTask = new FutureTask<>(() -> {
Thread.sleep(1000);
return "计算结果";
});

// 方式 1:传给 Thread
new Thread(futureTask).start();

// 方式 2:传给 ExecutorService
ExecutorService executor = Executors.newFixedThreadPool(1);
executor.submit(futureTask);

// 获取结果:
try {
String result = futureTask.get(); // ← 阻塞,直到计算完成
System.out.println(result);
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}

Future 的缺陷:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 缺陷 1:Future.get() 会阻塞(无法异步回调)
Future<String> future = executor.submit(() -> "结果");
String result = future.get(); // ← 阻塞!

// 缺陷 2:无法链式调用(多个异步任务依赖)
// 如:任务 A 完成后,用结果执行任务 B,再执行任务 C
// 用 Future 需要手动阻塞等待:
Future<String> futureA = executor.submit(() -> "结果 A");
String resultA = futureA.get(); // 阻塞
Future<String> futureB = executor.submit(() -> resultA + " → 结果 B");
String resultB = futureB.get(); // 阻塞
// ...

// 解决:用 CompletableFuture(Java 8+)
// CompletableFuture 支持链式调用、异步回调(类似 JavaScript 的 Promise)

面试加分回答

「Future 是 Java 异步计算的基石,但功能有限(只能阻塞获取结果)。Java 8 引入了 CompletableFuture,支持链式调用、异步回调、组合多个异步任务等高级功能(类似 JavaScript 的 Promise)。面试时可能会问:Future 和 CompletableFuture 的区别? 答案是:1. Future 只能阻塞获取结果,CompletableFuture 支持异步回调(thenAccept()、thenApply() 等);2. Future 无法组合多个异步任务,CompletableFuture 可以(thenCompose()、thenCombine() 等);3. Future 无法优雅地处理异常,CompletableFuture 可以(exceptionally()、handle() 等)。生产环境推荐使用 CompletableFuture。」


第 28 题:ForkJoinPool 的原理和适用场景?

一句话结论

ForkJoinPool = 支持「工作窃取」(Work-Stealing)的线程池。适合「大任务拆分成小任务」的场景(分治算法)。Java 8 的 Parallel Stream 底层就是 ForkJoinPool。


深度解析

基本用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// ForkJoinPool 的使用:继承 RecursiveTask(有返回值)或 RecursiveAction(无返回值)
class FibonacciTask extends RecursiveTask<Long> {
int n;

FibonacciTask(int n) {
this.n = n;
}

@Override
protected Long compute() {
if (n <= 1) return (long) n;

// 拆分任务:
FibonacciTask task1 = new FibonacciTask(n - 1);
task1.fork(); // ← 异步执行 task1

FibonacciTask task2 = new FibonacciTask(n - 2);
long result2 = task2.compute(); // ← 当前线程执行 task2(减少线程切换)

long result1 = task1.join(); // ← 等待 task1 完成

return result1 + result2;
}
}

// 使用 ForkJoinPool:
ForkJoinPool pool = new ForkJoinPool();
long result = pool.invoke(new FibonacciTask(10));
System.out.println(result);

工作窃取(Work-Stealing)算法:

1
2
3
4
5
6
7
8
9
10
11
12
13
ForkJoinPool 的每个线程都有自己的任务队列(双端队列):

线程 A 的任务队列:
head → [task1, task2, task3, task4] ← tail

线程 A 从 tail 取任务(LIFO,类似栈)

线程 B 的任务队列:
head → [task5, task6] ← tail

当线程 B 的任务队列为空时:
→ 线程 B 从线程 A 的任务队列的 head 端「窃取」任务(FIFO)
→ 减少竞争(线程 A 从 tail 取,线程 B 从 head 取,不冲突)

工作窃取算法的优势:充分利用多核 CPU(空闲线程会主动「窃取」其他线程的任务),适合「大任务拆分成小任务」的场景。


ForkJoinPool vs ThreadPoolExecutor:

对比维度 ForkJoinPool ThreadPoolExecutor
任务队列 每个线程有自己的双端队列 所有线程共享一个队列
任务调度 工作窃取(Work-Stealing) 共享队列(可能产生竞争)
适用场景 大任务拆分成小任务(分治) 普通的任务执行
是否支持异步任务 ✅ 支持(fork()/join()) ✅ 支持(submit()/get())
Java 8+ 的默认使用 Parallel Stream(并行流) CompletableFuture(默认使用 ForkJoinPool)

面试加分回答

「ForkJoinPool 是 Java 7 引入的线程池,核心优势是工作窃取算法(空闲线程主动窃取其他线程的任务,提高 CPU 利用率)。面试时可能会问:ForkJoinPool 和 ThreadPoolExecutor 的区别? 答案是:1. ForkJoinPool 适合「大任务拆分成小任务」的场景(分治算法),ThreadPoolExecutor 适合普通的任务执行;2. ForkJoinPool 使用工作窃取算法(每个线程有自己的任务队列),ThreadPoolExecutor 所有线程共享一个任务队列(可能产生竞争);3. Java 8+ 的 Parallel Stream(并行流)底层使用 ForkJoinPool.commonPool()。另外,ForkJoinPool.commonPool() 是全局共享的 ForkJoinPool(默认线程数 = CPU 核心数 - 1),不适合执行阻塞任务(会阻塞其他任务)。」


七、高级主题(29-30)


第 29 题:ThreadLocal 的内存泄漏问题(深度)?

一句话结论

ThreadLocal 的内存泄漏原因:ThreadLocalMap 的 key 是弱引用(WeakReference),value 是强引用。如果 ThreadLocal 没有外部强引用了,key 会被 GC 回收(变成 null),但 value 仍然是强引用 → 内存泄漏。解决:用完 ThreadLocal 后,必须调用 remove()。


深度解析

ThreadLocal 的内存布局:

1
2
3
4
5
6
7
Thread

├── ThreadLocalMap(每个线程有自己的 ThreadLocalMap)

├── Entry[0]: key=ThreadLocal1(弱引用)→ value1(强引用)
├── Entry[1]: key=ThreadLocal2(弱引用)→ value2(强引用)
└── Entry[2]: key=null(已被 GC 回收)→ value3(强引用)← 内存泄漏!

为什么 key 要用弱引用? 如果 key 用强引用,即使 ThreadLocal 没有外部强引用了,ThreadLocalMap 仍然持有 ThreadLocal 的强引用 → ThreadLocal 无法被 GC 回收 → 更严重的内存泄漏。用弱引用后,ThreadLocal 没有外部强引用时,key 会被 GC 回收(变成 null),只是 value 还需要手动 remove()。


内存泄漏的产生过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 1. 创建 ThreadLocal(有外部强引用)
ThreadLocal<String> threadLocal = new ThreadLocal<>();
threadLocal.set("value"); // ← 当前线程的 ThreadLocalMap 中存入:key=threadLocal, value="value"

// 2. 将 threadLocal 设为 null(去掉外部强引用)
threadLocal = null;
// → 此时,ThreadLocalMap 中的 key(弱引用)没有外部强引用了
// → 下次 GC 时,key 会被回收(变成 null)

// 3. GC 后:
// → key = null
// → value = "value"(仍然是强引用,无法被 GC 回收)
// → 如果当前线程不结束(如线程池中的线程),value 永远无法被回收 → 内存泄漏!

// 4. 解决:用完 ThreadLocal 后,调用 remove()
threadLocal.remove(); // ← 删除当前线程的 ThreadLocalMap 中的 entry(key 和 value 都被删除)

为什么线程池场景下更容易内存泄漏?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 线程池中的线程是复用的(不会结束):
ExecutorService executor = Executors.newFixedThreadPool(1);

executor.submit(() -> {
ThreadLocal<String> threadLocal = new ThreadLocal<>();
threadLocal.set("value");
// ❌ 没有调用 threadLocal.remove()!
// → 任务执行完后,线程池中的线程仍然持有 value 的强引用 → 内存泄漏
// → 下一次提交任务时,这个线程仍然持有旧的 value(可能逻辑错误)
});

// ✅ 正确用法:在 finally 块中调用 remove()
executor.submit(() -> {
ThreadLocal<String> threadLocal = new ThreadLocal<>();
try {
threadLocal.set("value");
// 使用 threadLocal
} finally {
threadLocal.remove(); // ← 必须调用!
}
});

ThreadLocalMap 的清理机制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// ThreadLocalMap 在 set()、get()、remove() 时,会清理 key=null 的 entry
// 但如果不调用这些方法,就不会触发清理 → 内存泄漏

// ThreadLocalMap.set() 的清理逻辑(简化):
private void set(ThreadLocal<?> key, Object value) {
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len - 1);

for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) {
if (e.get() == key) {
e.value = value;
return;
}
if (e.get() == null) { // ← 发现 key=null 的 entry
replaceStaleEntry(key, value, i); // ← 清理 key=null 的 entry
return;
}
}
// ...
}

面试加分回答

「ThreadLocal 的内存泄漏问题是 Java 并发面试的高频题。进阶追问:为什么 ThreadLocalMap 要用弱引用? 答案是:如果 key 用强引用,即使 ThreadLocal 没有外部强引用了,ThreadLocalMap 仍然持有 ThreadLocal 的强引用 → ThreadLocal 无法被 GC 回收 → 更严重的内存泄漏(key 和 value 都无法被回收)。用弱引用后,至少 key 可以被 GC 回收(虽然 value 仍然需要手动 remove())。另外,InheritableThreadLocal 可以让子线程继承父线程的 ThreadLocal 值,但线程池场景下会失效(线程是复用的,不是新建的),需要用 TransmittableThreadLocal(阿里开源)解决。」


第 30 题:Java 并发包中的「原子类」(Atomic 类)的原理?

一句话结论

原子类(如 AtomicInteger、AtomicLong)基于 CAS(Compare-And-Swap)实现,保证了「读-改-写」操作的原子性。性能比锁好(无锁),但存在「ABA 问题」。


深度解析

AtomicInteger 的用法:

1
2
3
4
5
6
7
8
9
10
11
AtomicInteger count = new AtomicInteger(0);

// 原子自增:
count.incrementAndGet(); // ++count
count.getAndIncrement(); // count++

// 原子加法:
count.addAndGet(5); // count += 5

// CAS 操作:
boolean success = count.compareAndSet(10, 20); // ← 如果 count == 10,设置为 20(原子操作)

CAS 的原理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
CAS(Compare-And-Swap)= 比较并交换(CPU 指令,硬件级别的原子操作)

CAS 操作包含三个操作数:
1. V:内存地址(要修改的变量)
2. A:期望值(期望内存中的值是多少)
3. B:新值(如果期望值匹配,设置为新值)

CAS 的逻辑:
if (V == A) {
V = B;
return true; // 成功
} else {
return false; // 失败
}
→ 以上操作是原子的(CPU 指令级保证)

Java 中的 CAS
→ Unsafe 类的 compareAndSwapInt()/compareAndSwapLong() 方法
→ 底层是 CPU 的 cmpxchg 指令

AtomicInteger 的底层实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// AtomicInteger 的核心字段:
public class AtomicInteger {
private volatile int value; // ← 用 volatile 保证可见性

public final boolean compareAndSet(int expect, int update) {
return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
// ↑ Unsafe 类的 CAS 方法(底层是 CPU 的 cmpxchg 指令)
}

public final int incrementAndGet() {
int current;
int next;
do {
current = value; // 读取当前值
next = current + 1; // 计算新值
} while (!compareAndSet(current, next)); // ← CAS 失败则重试(自旋)
return next;
}
}

ABA 问题:

1
2
3
4
5
6
7
8
9
10
11
12
ABA 问题:
1. 线程 A 读取变量 value = 10
2. 线程 B 将 value 改为 20,再改回 10
3. 线程 A 执行 CAS:发现 value 仍然是 10 → 认为没有被修改过(实际上被修改过)

ABA 问题的后果:
→ 某些场景下,ABA 没有问题(如:计数器)
→ 某些场景下,ABA 有问题(如:链表操作,中间被修改过可能导致逻辑错误)

解决 ABA 问题:
→ 用 AtomicStampedReference(带版本号的原子引用)
→ 每次修改,版本号 +1;CAS 时不仅比较引用,还比较版本号

AtomicStampedReference 解决 ABA 问题:

1
2
3
4
5
6
7
8
9
10
// AtomicStampedReference:带版本号的原子引用(解决 ABA 问题)
AtomicStampedReference<String> ref = new AtomicStampedReference<>("A", 0);

// CAS 操作(不仅比较引用,还比较版本号):
boolean success = ref.compareAndSet(
"A", // 期望引用
"B", // 新引用
0, // 期望版本号
1 // 新版本号
);

面试加分回答

「原子类(Atomic 类)是 Java 并发包中的重要组件,基于 CAS 实现。面试时经常追问:CAS 的 ABA 问题是什么?如何解决? 答案是:ABA 问题 = 一个变量被线程 A 读取后,被线程 B 多次修改(最终值没变),线程 A 的 CAS 仍然成功(但中间被修改过,可能逻辑错误)。解决方式:用 AtomicStampedReference(带版本号的原子引用),每次修改版本号 +1,CAS 时不仅比较值,还比较版本号。另外,Java 8 引入了 LongAdder(高并发场景下,AtomicLong 的 CAS 竞争很激烈),LongAdder 使用「分段计数」(类似 ConcurrentHashMap 的 CounterCell),减少了 CAS 竞争,性能更好。」


总结:Java 并发编程面试复习 Checklist

模块 题号 核心考点
线程基础 1-5 线程状态、创建线程的方式、start() vs run()、synchronized 基础、volatile
线程安全 6-10 线程安全的定义、volatile 不保证原子性、happens-before、as-if-serial、ThreadLocal
锁机制 11-16 synchronized vs ReentrantLock、ReadWriteLock、StampedLock、LockSupport、AQS、锁升级
线程通信 17-20 wait()/notify()、CountDownLatch、CyclicBarrier、Semaphore
并发集合 21-24 ConcurrentHashMap、CopyOnWriteArrayList、BlockingQueue、ConcurrentLinkedQueue
线程池 25-28 ThreadPoolExecutor 参数、Executors 的缺陷、Future/CompletableFuture、ForkJoinPool
高级主题 29-30 ThreadLocal 内存泄漏、原子类(CAS)

最后的建议:
Java 并发编程的知识点很多,但面试中最高频的是:synchronized 和 ReentrantLock 的区别、volatile 的作用、ThreadLocal 内存泄漏、线程池参数、ConcurrentHashMap 原理、AQS 原理。
建议把这几块深入理解,并结合实际场景(如:你们的线上服务用的什么线程池?为什么?)来准备,这样面试时更有说服力。
祝你面试顺利!🚀


第 31 题:用户态和内核态的区别?

一句话总结:用户态是应用程序运行的状态(权限受限),内核态是操作系统内核运行的状态(权限最高);系统调用是用户态进入内核态的唯一方式。

深度解析

对比项 用户态(User Mode) 内核态(Kernel Mode)
权限 受限(不能直接访问硬件) 最高(可以执行 CPU 特权指令)
内存空间 用户空间(User Space) 内核空间(Kernel Space)
异常后果 进程崩溃,不影响系统 系统崩溃(蓝屏/内核 panic)

为什么要分两种状态?

1
2
3
4
5
6
7
8
如果不分状态:
→ 应用程序可以直接访问硬件(磁盘、网卡)
→ 程序有 Bug → 直接把磁盘数据清空 → 系统崩溃!

分了状态:
→ 应用程序不能直接访问硬件
→ 需要硬件操作时,通过"系统调用"让操作系统帮忙做
→ 操作系统会检查权限,防止恶意操作

系统调用过程(以 read(file) 为例):

1
2
3
4
5
6
1. 应用程序调用 `read()`(实际上是 C 库函数)
2. C 库函数触发 **软中断**(int 0x80 或 syscall 指令)
3. CPU 切换到内核态,跳转到系统调用处理函数
4. 操作系统内核执行真正的 `sys_read()`(读磁盘)
5. 读完数据,CPU 切回用户态
6. 应用程序继续执行

面试加分回答

“我们项目里有一处优化:原来每次读文件都用 read()(每次都要切到内核态,有开销),后来改成 mmap()(内存映射文件),只在第一次访问时缺页中断切到内核态,后续访问直接读内存,性能提升明显。另外,线程上下文切换(Context Switch)也需要从用户态切到内核态再切回来,所以频繁创建/销毁线程(或频繁线程切换)开销很大,这也是为什么要用线程池。”


第 32 题:僵尸进程和孤儿进程的区别?

一句话总结僵尸进程是子进程结束但父进程没回收(PID 等资源没释放);孤儿进程是父进程先挂了,子进程被 init 进程(PID=1)接管。

深度解析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
【僵尸进程(Zombie)】
父进程 子进程
│ │
│ fork() │
│───────────────>│
│ │
│ │ exit()(子进程结束)
│ │ 发送 SIGCHLD 给父进程
│ <────────────── │
│ │
│ 父进程没调用 wait()/waitpid()
│ → 子进程的 PCB(进程控制块)没被回收
│ → 子进程变成"僵尸"(Z 状态)
│ → 占用 PID(系统 PID 有限,用完了会无法创建新进程)

解决:父进程调用 `wait()` 或 `waitpid()` 回收子进程


【孤儿进程(Orphan)】
父进程 子进程
│ │
│ fork() │
│───────────────>│
│ │
exit()(父进程先挂了)
│ │
│ │ 子进程变成"孤儿"
│ │
│ → init 进程(PID=1)自动收养孤儿进程
│ → 孤儿进程结束时,init 进程负责回收
│ → 孤儿进程不会变成僵尸(因为 init 会及时回收)

代码验证

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 制造僵尸进程
#include <stdio.h>
#include <unistd.h>
int main() {
pid_t pid = fork();
if (pid == 0) {
// 子进程
printf("Child exit\n");
exit(0); // 子进程退出,但父进程不回收
} else {
sleep(60); // 父进程睡眠 60 秒,不回收子进程
}
return 0;
}
// 运行后,ps aux | grep 会看到子进程状态为 Z(Zombie)

面试加分回答

“我们线上遇到过僵尸进程问题——父进程是长期运行的守护进程,创建子进程后没调用 waitpid() 回收,导致 PID 被耗尽,新进程创建失败。解决方案:① 父进程注册 SIGCHLD 信号处理函数,在信号处理函数里调用 waitpid() ② 或者用 signal(SIGCHLD, SIG_IGN) 让系统自动回收子进程。Java 的 Process.waitFor() 内部就是调用 waitpid() 来回收子进程的。”


第 33 题:IO 密集型任务和 CPU 密集型任务,线程池参数怎么设?

一句话总结CPU 密集型:线程数 ≈ CPU 核数(避免过多上下文切换);IO 密集型:线程数 >> CPU 核数(IO 等待时 CPU 空闲,可以多搞几个线程)。

深度解析

1
2
3
4
5
6
7
8
9
10
CPU 密集型】(如:视频编码、数学计算)
→ 任务一直占用 CPU,没有等待
→ 线程数 = CPU 核数(或 +1,防止偶尔的页缺失停顿)
→ 设多了反而慢(上下文切换开销)

【IO 密集型】(如:数据库查询、HTTP 请求、文件读写)
→ 任务大部分时间在等 IO(数据库返回、磁盘读取)
→ 等 IO 时 CPU 是空闲的!
→ 可以搞更多线程,让 CPU 一直干活
→ 线程数 ≈ CPU 核数 * 2(或更高,看 IO 等待时间)

公式(经验值)

1
2
3
4
5
CPU 密集型:核心线程数 = CPU 核数
最大线程数 = CPU 核数(或 +1

IO 密集型: 核心线程数 = CPU 核数 * 2
最大线程数 = CPU 核数 * (1 + 等待时间/计算时间)

实际代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// CPU 密集型
int cpuCores = Runtime.getRuntime().availableProcessors();
ThreadPoolExecutor executor = new ThreadPoolExecutor(
cpuCores, // corePoolSize
cpuCores, // maximumPoolSize
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<>(1000)
);

// IO 密集型(假设 IO 等待时间是计算时间的 2 倍)
int cpuCores = Runtime.getRuntime().availableProcessors();
ThreadPoolExecutor executor = new ThreadPoolExecutor(
cpuCores * 2, // corePoolSize
cpuCores * 4, // maximumPoolSize
60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(1000)
);

面试加分回答

“我们微服务里有两种线程池:① API 调用线程池(IO 密集型):核心线程数 = CPU 核数 * 2,因为大部分时间在等下游 HTTP 响应 ② 本地计算线程池(CPU 密集型):核心线程数 = CPU 核数,避免上下文切换开销。另外,用 Runtime.getRuntime().availableProcessors() 获取 CPU 核数是动态的(容器化环境下可能不准),建议在启动参数里显式传入 CPU 核数:java -Dcpu.cores=8 ...


第 34 题:synchronized 和 ReentrantLock 在 CPU 层面的优化(自旋锁、自适应自旋)?

一句话总结:传统锁挂起线程需要用户态→内核态切换(开销大);自旋锁让线程先忙等一会儿(不挂起),如果锁很快释放就避免了上下文切换;自适应自旋是根据历史经验动态调整自旋次数。

深度解析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
【传统锁的问题】
线程 A 持有锁
线程 B 来抢锁
→ 操作系统把线程 B 挂起(等待)
→ 锁释放后,操作系统唤醒线程 B
→ 挂起 + 唤醒:用户态 → 内核态 → 用户态(两次上下文切换,约 1~2 微秒)

【自旋锁(Spin Lock)】
线程 A 持有锁
线程 B 来抢锁
→ 线程 B 不挂起,而是"自旋"(循环忙等,不放弃 CPU)
→ 如果锁在很短时间内释放了,线程 B 直接拿到锁(不用挂起/唤醒)
→ 如果锁很久才释放,自旋会浪费 CPU(傻等)

【自适应自旋(Adaptive Spinning)】
→ JVM 记录每个锁的自旋成功率
→ 如果上次自旋成功,这次多等一会儿
→ 如果上次自旋失败,这次直接挂起(不自旋了)

synchronized 的自旋优化

1
2
3
4
5
6
7
JDK 6 之前:synchronized 直接挂起线程(重量级锁)
JDK 6 开始:synchronized 有了"锁升级"
无锁 → 偏向锁 → 轻量级锁(自旋) → 重量级锁(挂起)

轻量级锁阶段:
→ 其他线程通过 CAS 自旋抢锁(不挂起)
→ 自旋次数超过阈值(或竞争激烈),升级为重量级锁

面试加分回答

“synchronized 在 JDK 6 之后的性能已经和 ReentrantLock 差不多了,主要就是因为有锁升级 + 自旋优化。但要注意:自旋锁在多核 CPU上才有意义(单核 CPU 自旋没用,因为持有锁的线程也需要 CPU 才能释放锁)。另外,我们曾经有一处代码用 while (lock.tryLock()) 自旋抢锁,在 32 核机器上导致 32 个线程同时自旋(忙等),CPU 100%,后来改成直接 lock()(挂起等待),CPU 占用反而降下来了——自旋不是银弹,要看场景。”


第 35 题:ThreadLocal 的正确用法和最佳实践?

一句话总结:ThreadLocal 适用于每个线程需要独立副本的场景(如用户会话、数据库连接);必须try-finallyremove() 清理,否则线程池场景下会内存泄漏。

深度解析

典型使用场景

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 场景 1:用户会话(Web 请求,每个请求一个线程)
public class UserContext {
private static final ThreadLocal<User> USER = new ThreadLocal<>();

public static void set(User user) { USER.set(user); }
public static User get() { return USER.get(); }
public static void clear() { USER.remove(); }
}

// 使用:
User user = getUserFromSession(request);
UserContext.set(user);
try {
// anywhere in the call stack, you can get the user
User currentUser = UserContext.get();
// ...
} finally {
UserContext.clear(); // MUST clear!
}

为什么线程池场景必须 remove()

1
2
3
4
线程池:线程是复用的(不会销毁)

请求 1 → 线程 A → ThreadLocal.set("user1") → 处理完 → 没调用 remove()
请求 2 → 线程 A(被复用了!)→ ThreadLocal.get() → 拿到 "user1" ❌(应该为 null 或当前用户)

正确用法模板

1
2
3
4
5
6
7
8
ThreadLocal<String> tl = new ThreadLocal<>();
try {
tl.set("value");
// ... 业务逻辑
String value = tl.get();
} finally {
tl.remove(); // 必须清理!
}

面试加分回答

“我们项目里用 ThreadLocal 存用户会话信息,但曾经有一个 Bug:在 Controller 层 set(),但在 Service 层抛了异常,finally 没执行到(实际上 finally 是会执行的,但我们代码里忘了写 finally),导致下一个请求复用了同一个线程,拿到了上一个用户的会话信息(越权访问!)。后来我们统一用 Filter/HandlerInterceptor 在请求入口 set(),在请求结束时(包括异常场景)remove()。另外,ThreadLocal 的性能比传参差(要查 ThreadLocalMap),如果调用链很短(<3 层),直接传参更好。”


如果觉得这篇文章对你有帮助,欢迎分享给更多的小伙伴!


第 36 题:Java 内存模型(JMM)的八大原子操作是什么?

一句话结论

JMM 定义了对内存的 8 种原子操作:lock、unlock、read、load、use、assign、store、write。这 8 种操作是理解 volatile、synchronized 内存语义的基础。


深度解析

八大原子操作

操作 作用对象 说明
lock(锁定) 主内存变量 把变量标识为线程独占状态
unlock(解锁) 主内存变量 释放锁定状态的变量
read(读取) 主内存变量 从主内存读取变量的值,传到线程的工作内存
load(载入) 工作内存变量 把 read 读到的值放入工作内存的变量副本
use(使用) 工作内存变量 把工作内存的变量值传给执行引擎(如计算中)
assign(赋值) 工作内存变量 把执行引擎计算的结果赋值给工作内存的变量
store(存储) 工作内存变量 把工作内存的变量值传到主内存
write(写入) 主内存变量 把 store 传过来的值写入主内存的变量

一个赋值操作的完整流程

1
2
3
4
5
6
7
8
9
10
// 代码
int x = 10;

// JVM 原子操作流程:
// 1. read(x) → 从主内存读取 x 的值(假设是 0)
// 2. load(x) → 将 x=0 载入工作内存
// 3. use(x) → 将 x=0 传给执行引擎
// 4. assign(x) → 执行引擎计算 10,赋值给工作内存的 x
// 5. store(x) → 将工作内存的 x=10 传到主内存
// 6. write(x) → 将 x=10 写入主内存

volatile 的底层实现(基于原子操作)

1
2
3
4
5
6
7
8
9
10
11
// volatile 变量 i
volatile int i = 0;

// 写操作(i = 10):
// 1. assign(i) → 工作内存 i = 10
// 2. store(i) + write(i) → 立即写入主内存
// 3. 触发 store barrier(强制刷新主内存)

// 读操作(int x = i):
// 1. 触发 load barrier(强制从主内存重新读取)
// 2. read(i) + load(i) → 从主内存读取最新值

面试加分回答

JMM 的八大原子操作是理解Java 内存模型的基石。面试中,volatile 的内存语义(可见性、禁止指令重排)就是通过对这些原子操作的限制来实现的。另外,synchronized 的可见性也是通过「unlock 之前必须 store + write」这条规则保证的——解锁之前,工作内存的修改必须刷回主内存。


第 37 题:volatile 的内存屏障(Load Barrier / Store Barrier)?

一句话结论

volatile 通过内存屏障(Memory Barrier)实现可见性和禁止指令重排:写操作插入 Store Barrier(强制刷主内存),读操作插入 Load Barrier(强制从主内存读)。


深度解析

volatile 的 happens-before 规则

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// volatile 的 happens-before 规则:
// 1. 写 happens-before 读(写操作的结果对后续读操作可见)
// 2. 线程 A 写 volatile 变量 happens-before 线程 B 读同一个 volatile 变量

volatile int x = 0;
int y = 0;

// 线程 A:
y = 10; // 普通变量写入
x = 20; // volatile 写入(Store Barrier)

// 线程 B:
int a = x; // volatile 读取(Load Barrier)→ a = 20
int b = y; // 普通变量读取 → b = 10(因为 happens-before 规则,y=10 对线程 B 可见)

内存屏障的底层实现

屏障类型 作用
Load Barrier(读屏障) 强制从主内存重新读取,禁止重排到屏障之后
Store Barrier(写屏障) 强制将工作内存的修改刷回主内存,禁止重排到屏障之前
Full Barrier(全屏障) Load + Store 的组合

volatile 的底层实现(JIT 生成的汇编)

1
2
3
4
5
; Java 代码:volatile int x = 10;
; 编译后的汇编(x86):
mov dword ptr [x], 10 ; 写入 x
lock add dword ptr [rsp], 0 ; 插入 lock 前缀指令(Store Barrier)
; lock 前缀会触发 CPU 的 MESI 协议,强制刷主内存

面试加分回答

volatile 的底层实现依赖CPU 内存屏障指令(如 x86 的 lock 前缀、mfencelfencesfence)。在 Java 中,HotSpot JVM 通过 C++ 的 OrderAccess::storeload() 等函数插入这些屏障。另外,volatile 的性能比 synchronized 好,但不能保证原子性(如 i++ 仍然有并发问题),如果需要原子性,还是要用 AtomicInteger


第 38 题:synchronized 的偏向锁、轻量级锁、重量级锁(锁升级)?

一句话结论

synchronized 的锁升级:无锁 → 偏向锁 → 轻量级锁(自旋) → 重量级锁(挂起线程)。这是 JDK 6 对 synchronized 的重大优化,避免了无条件挂起线程。


深度解析

锁升级流程

1
2
3
4
5
6
7
8
无锁(No Lock
↓ 第一次进入 synchronized 块
偏向锁(Biased Lock
↓ 另一个线程来抢锁(偏向锁撤销)
轻量级锁(Lightweight Lock
↓ 自旋超过阈值(或竞争激烈)
重量级锁(Heavyweight Lock
↓ 线程挂起,进入阻塞状态

三种锁的对比

锁状态 适用场景 实现原理 性能
偏向锁 只有一个线程访问同步块 在对象头记录线程 ID(不 CAS) 最快(几乎无开销)
轻量级锁 多个线程交替访问(不竞争) CAS 自旋抢锁(不挂起线程) 较快(自旋开销)
重量级锁 多个线程同时竞争 操作系统互斥量(线程挂起) 慢(上下文切换)

对象头(Mark Word)的变化

1
2
3
4
5
6
7
8
9
10
11
12
对象头(64 位 JVM):
┌──────────────────────────────────────────────────────┐
│ Mark Word(64 bits) │
├─────────────┬─────────────┬──────────┬───────────┤
│ 锁状态 │ 25 bits │ 31 bits │ 4 bits │
├─────────────┼─────────────┼──────────┼───────────┤
│ 无锁 │ hashCode │ 分代年龄 │ 01
│ 偏向锁 │ 线程 ID │ Epoch │ 01
│ 轻量级锁 │ 指向栈中锁记录 │ │ 00
│ 重量级锁 │ 指向监视器对象 │ │ 10
│ GC 标记 │ │ │ 11
└─────────────┴─────────────┴──────────┴───────────┘

面试加分回答

synchronized 的锁升级是 JDK 6 的重大性能优化。在 JDK 5 及之前,synchronized 直接是重量级锁(性能差),所以 JDK 5 推出了 ReentrantLock(基于 AQS,性能更好)。但 JDK 6 之后,synchronized 做了锁升级优化,性能已经和 ReentrantLock 相差无几,所以现在优先用 synchronized(语法更简洁,JVM 会自动优化)。


第 39 题:AQS(AbstractQueuedSynchronizer)的原理?

一句话结论

AQS 是 Java 并发包的基石:它用一个 volatile int state 表示同步状态,用一个 CLH 队列管理等待线程。ReentrantLock、CountDownLatch、Semaphore 都基于 AQS 实现。


深度解析

AQS 的核心字段

1
2
3
4
5
6
7
8
9
10
11
12
13
public abstract class AbstractQueuedSynchronizer {
private volatile int state; // 同步状态(volatile,保证可见性)
private transient volatile Node head; // CLH 队列头节点
private transient volatile Node tail; // CLH 队列尾节点

// 内部类:CLH 队列的节点
static final class Node {
volatile Node prev;
volatile Node next;
volatile Thread thread; // 等待的线程
int waitStatus; // 等待状态(CANCELLED、SIGNAL、CONDITION、PROPAGATE)
}
}

AQS 的 CLH 队列(双向链表)

1
2
3
head → [Node1] ←→ [Node2] ←→ [Node3] ← tail
(thread1) (thread2) (thread3)
waitStatus waitStatus waitStatus

ReentrantLock 基于 AQS 的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// ReentrantLock 的内部类 Sync 继承 AQS
public class ReentrantLock implements Lock {
abstract static class Sync extends AbstractQueuedSynchronizer {
// 获取锁(tryAcquire)
@Override
protected boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState(); // 获取 AQS 的 state
if (c == 0) {
// state == 0:锁空闲,CAS 设置 state = 1
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
} else if (current == getExclusiveOwnerThread()) {
// state > 0:锁被占用,检查是否是当前线程(可重入)
int nextc = c + acquires;
setState(nextc); // state += 1(可重入)
return true;
}
return false; // 获取锁失败,进入 CLH 队列等待
}
}
}

面试加分回答

AQS 是 Java 并发包的基石,它的设计非常精妙:用 volatile int state 表示锁状态,用 CAS 操作保证原子性,用 CLH 队列管理等待线程。面试中,如果能说出 AQS 的 stateCLH 队列tryAcquire()/tryRelease() 这几个核心概念,就已经超过 80% 的候选人了。另外,CountDownLatchSemaphore 也基于 AQS,只是它们把 state 用作「计数器」而不是「锁状态」。


第 40 题:CountDownLatch vs CyclicBarrier vs Semaphore?

一句话结论

CountDownLatch:倒计时门栓(一次性,计数器减到 0 就放开);CyclicBarrier:循环栅栏(可重复使用,等所有线程到齐再一起走);Semaphore:信号量(控制并发线程数)。


深度解析

CountDownLatch(倒计时门栓)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 场景:主线程等待 3 个工作线程完成
CountDownLatch latch = new CountDownLatch(3); // 计数器初始值 3

// 工作线程
for (int i = 0; i < 3; i++) {
new Thread(() -> {
System.out.println("工作线程完成");
latch.countDown(); // 计数器 -1
}).start();
}

// 主线程
latch.await(); // 阻塞,直到计数器减到 0
System.out.println("所有工作线程完成,主线程继续");

CyclicBarrier(循环栅栏)

1
2
3
4
5
6
7
8
9
10
11
12
// 场景:3 个线程都到齐了,再一起继续
CyclicBarrier barrier = new CyclicBarrier(3, () -> {
System.out.println("所有线程到齐,开始下一轮");
});

for (int i = 0; i < 3; i++) {
new Thread(() -> {
System.out.println("线程" + Thread.currentThread().getId() + "到达");
barrier.await(); // 等待其他线程
System.out.println("线程" + Thread.currentThread().getId() + "继续");
}).start();
}

Semaphore(信号量)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 场景:限制同时访问某个资源的线程数(如数据库连接池)
Semaphore semaphore = new Semaphore(5); // 5 个许可证

for (int i = 0; i < 10; i++) {
new Thread(() -> {
try {
semaphore.acquire(); // 获取许可证(如果没有许可证,阻塞)
System.out.println("线程" + Thread.currentThread().getId() + "获取许可证");
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
semaphore.release(); // 释放许可证
}
}).start();
}

对比

工具 作用 是否可重复 典型场景
CountDownLatch 等待其他线程完成 ❌ 一次性 主线程等待子线程完成
CyclicBarrier 等待所有线程到齐 ✅ 可重复 多线程分阶段计算(MapReduce)
Semaphore 控制并发线程数 ✅ 可重复 限流、连接池

面试加分回答

CountDownLatch 和 CyclicBarrier 的区别是面试高频题:CountDownLatch 是倒计时(计数器减到 0 就放开,不可重置),CyclicBarrier 是等所有人都到齐(计数器减到 0 会自动重置,可重复使用)。实际项目中,如果用过 Hadoop 的 MapReduceFlink 的 Barrier 对齐,理解 CyclicBarrier 会非常容易——它们本质上是同一个思想:等所有节点完成当前阶段,再一起进入下一阶段。


第 41 题:CompletableFuture 的用法和原理?

一句话结论

CompletableFuture 是 Java 8 引入的异步编程神器,支持链式调用(thenApply、thenAccept、thenRun)、组合多个异步任务(allOf、anyOf)、异常处理(exceptionally、handle),比 FutureTask 好用 100 倍。


深度解析

FutureTask 的问题

1
2
3
4
5
6
7
8
9
10
11
12
// FutureTask(Java 5 引入):只能获取结果,不支持链式调用
ExecutorService executor = Executors.newFixedThreadPool(10);
Future<String> future = executor.submit(() -> {
Thread.sleep(1000);
return "Hello";
});

// 问题 1:阻塞获取结果(get() 会阻塞)
String result = future.get(); // 阻塞!

// 问题 2:不支持链式调用(无法 thenApply、thenCompose)
// 问题 3:不支持异常处理(只能用 try-catch)

CompletableFuture(Java 8 引入)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 创建异步任务
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
return "Hello";
}, executor);

// 链式调用(thenApply:转换结果)
CompletableFuture<Integer> lengthFuture = future.thenApply(s -> s.length());

// 链式调用(thenAccept:消费结果,不返回)
future.thenAccept(s -> System.out.println(s));

// 链式调用(thenRun:上一个任务完成后,执行下一个任务)
future.thenRun(() -> System.out.println("完成"));

// 组合多个异步任务(allOf:等待所有任务完成)
CompletableFuture<Void> allOf = CompletableFuture.allOf(future1, future2, future3);
allOf.get(); // 等待所有任务完成

// 组合多个异步任务(anyOf:等待任意一个任务完成)
CompletableFuture<Object> anyOf = CompletableFuture.anyOf(future1, future2, future3);
Object result = anyOf.get(); // 获取最先完成的任务的结果

// 异常处理(exceptionally:发生异常时,返回默认值)
CompletableFuture<String> safeFuture = future.exceptionally(ex -> "默认值");

面试加分回答

CompletableFuture 是 Java 异步编程的里程碑,它让 Java 也有了类似 JavaScript Promise 的能力。实际项目中,CompletableFuture 常用于并行调用多个微服务(如商品详情页需要同时调用商品服务、库存服务、评价服务),用 allOf() 等待所有调用完成,比串行调用快 3 倍。另外,CompletableFuture 的底层基于 ForkJoinPool(默认用 ForkJoinPool.commonPool()),所以创建大量 CompletableFuture 也不会耗尽线程池。


第 42 题:StampedLock 的原理和适用场景?

一句话结论

StampedLock 是 Java 8 引入的读写锁,比 ReentrantReadWriteLock 更快,因为它支持乐观读(读之前不加读锁,只在读完后校验是否有写锁竞争)。


深度解析

ReentrantReadWriteLock 的问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// ReentrantReadWriteLock:读锁是悲观的(读之前必须加读锁)
ReadWriteLock rwLock = new ReentrantReadWriteLock();
Lock readLock = rwLock.readLock();
Lock writeLock = rwLock.writeLock();

// 读操作:必须加读锁(即使没有写操作,也会阻塞其他读线程)
public int read() {
readLock.lock(); // 悲观读(阻塞其他读线程)
try {
return value;
} finally {
readLock.unlock();
}
}

StampedLock 的乐观读

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// StampedLock:支持乐观读(读之前不加锁,读完后校验)
StampedLock stampedLock = new StampedLock();

// 乐观读(读之前不加锁)
public int read() {
long stamp = stampedLock.tryOptimisticRead(); // 获取乐观读标记
int currentValue = value; // 读取值
if (!stampedLock.validate(stamp)) { // 校验:是否有写锁竞争
stamp = stampedLock.readLock(); // 有写锁竞争,降级为悲观读
try {
currentValue = value;
} finally {
stampedLock.unlockRead(stamp);
}
}
return currentValue;
}

// 写操作
public void write(int newVal) {
long stamp = stampedLock.writeLock(); // 获取写锁
try {
value = newVal;
} finally {
stampedLock.unlockWrite(stamp);
}
}

StampedLock vs ReentrantReadWriteLock

对比项 ReentrantReadWriteLock StampedLock
读锁 悲观读(必须加锁) 乐观读(可选)
性能 较低(读锁互斥) 较高(乐观读不互斥)
可重入 ✅ 支持 ❌ 不支持
适用场景 读多写少 读非常多,写非常少

面试加分回答

StampedLock 的乐观读是它的最大亮点:读操作不需要加读锁(不阻塞其他读线程),只在读完后校验是否有写锁竞争(如果有,降级为悲观读)。这种优化在读非常多、写非常少的场景下,性能远超 ReentrantReadWriteLock。但要注意:StampedLock 不支持重入(同一个线程不能重复获取读锁或写锁),所以使用时要小心,避免死锁。


第 43 题:并发编程的常见问题(死锁、活锁、饥饿、竞态条件)?

一句话结论

死锁:线程互相等待对方释放锁(永久阻塞);活锁:线程不断重试,但始终无法取得进展(看起来在运行,实际没进展);饥饿:线程始终得不到 CPU 时间片(优先级太低);竞态条件:程序结果依赖于线程执行顺序(Bug)。


深度解析

死锁(Deadlock)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 死锁示例:
Object lockA = new Object();
Object lockB = new Object();

// 线程 1:先获取 A,再获取 B
new Thread(() -> {
synchronized (lockA) {
System.out.println("线程1获取 A");
Thread.sleep(100);
synchronized (lockB) { // 等待线程2释放 B(死锁!)
System.out.println("线程1获取 B");
}
}
}).start();

// 线程 2:先获取 B,再获取 A
new Thread(() -> {
synchronized (lockB) {
System.out.println("线程2获取 B");
Thread.sleep(100);
synchronized (lockA) { // 等待线程1释放 A(死锁!)
System.out.println("线程2获取 A");
}
}
}).start();

避免死锁的方法

  1. 按顺序获取锁(所有线程都按相同顺序获取锁)
  2. 超时机制tryLock(timeout)
  3. 银行家算法(预先判断是否会死锁)

活锁(Livelock)

1
2
3
4
5
6
// 活锁示例:两个线程不断重试,但始终无法取得进展
// 场景:两个人在走廊相遇,都往左让,然后都往右让,然后都往左让……
while (needToRetry) {
// 两个线程都在运行,但始终无法取得进展
tryAgain();
}

饥饿(Starvation)

1
2
3
4
5
// 饥饿示例:低优先级线程始终得不到 CPU 时间片
Thread lowPriorityThread = new Thread(() -> {
// 这个线程优先级太低,始终得不到执行(饥饿)
});
lowPriorityThread.setPriority(Thread.MIN_PRIORITY);

竞态条件(Race Condition)

1
2
3
4
5
6
7
8
9
10
// 竞态条件示例:程序结果依赖于线程执行顺序
class Counter {
private int count = 0;

public void increment() {
count++; // 非原子操作(read-modify-write)
}
}

// 两个线程同时调用 increment(),结果可能不正确(竞态条件)

面试加分回答

死锁是并发编程的头号杀手。面试中,除了能说出死锁的四个必要条件(互斥、占有且等待、不可抢占、循环等待),最好还能说出如何避免死锁(按顺序获取锁、超时机制、银行家算法)。另外,活锁比死锁更难调试(因为线程看起来在运行,实际没进展),实际项目中如果遇到「CPU 占用 100%,但系统没响应」的情况,可以排查一下是否是活锁。


第 44 题:Java 中不可变对象(Immutable Object)的好处?

一句话结论

不可变对象(如 String、Integer)是线程安全的(因为没有写操作,不会被多个线程同时修改),可以作为安全的共享对象,不需要加锁。


深度解析

不可变对象的特征

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 不可变对象的特征:
// 1. 类用 final 修饰(不能被继承)
// 2. 所有字段用 final 修饰(不能被修改)
// 3. 不提供 setter 方法
// 4. 如果字段是引用类型,需要防御性拷贝

public final class ImmutableObject {
private final int value;
private final String name;

public ImmutableObject(int value, String name) {
this.value = value;
this.name = name; // String 是不可变的,所以可以直接赋值
}

public int getValue() {
return value;
}

public String getName() {
return name;
}
// 没有 setter 方法(值不能被修改)
}

不可变对象的好处

好处 说明
线程安全 不可变对象不能被修改,所以不需要加锁
可以被缓存 因为不可变,所以可以安全缓存(如 Integer 的缓存池)
可以作为 HashMap 的 key 因为不可变,所以 hashCode() 不会改变
减少防御性拷贝 因为不可变,所以不需要 defensive copy

String 的不可变性

1
2
3
4
5
6
7
8
9
10
// String 是不可变的(每次修改都创建新对象)
String s = "Hello";
s = s + " World"; // 创建了新的 String 对象(原来的 "Hello" 还在字符串常量池)

// 这就是为什么 String 可以作为 HashMap 的 key:
// 因为 String 不可变,所以 hashCode() 不会改变,可以安全用作 key
Map<String, Integer> map = new HashMap<>();
map.put("key", 1);
// "key" 是不可变的,所以 hashCode() 不会改变,可以安全从 HashMap 中获取
int value = map.get("key"); // 一定能获取到

面试加分回答

不可变对象是并发编程的最佳实践之一。因为不可变对象是线程安全的(不需要加锁),所以可以大幅减少并发编程的复杂度。实际项目中,如果你的对象不需要修改,就把它设计为不可变对象(用 final 修饰类、用 final 修饰字段、不提供 setter 方法)。另外,Java 的 String、Integer、Long 都是不可变对象,所以它们可以安全地在多个线程之间共享。


第 45 题:ForkJoinPool vs ThreadPoolExecutor 的区别?

一句话结论

ThreadPoolExecutor:固定线程数的线程池(适合 CPU 密集型或 IO 密集型任务);ForkJoinPool:工作窃取(Work-Stealing)线程池(适合大任务拆小任务,如归并排序、MapReduce)。


深度解析

ThreadPoolExecutor 的局限

1
2
3
4
5
6
7
8
9
10
// ThreadPoolExecutor:如果一个任务执行时间很长,线程会一直被占用
ExecutorService executor = Executors.newFixedThreadPool(10);

// 场景:一个大任务(执行 10 秒)
executor.submit(() -> {
// 这个任务执行 10 秒,占用一个线程
Thread.sleep(10000);
});

// 问题:如果来了 10 个大任务,线程池的 10 个线程都被占用了,后续任务只能排队

ForkJoinPool 的工作窃取(Work-Stealing)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// ForkJoinPool:大任务拆小任务,线程执行完自己的任务后,会窃取其他线程的任务
ForkJoinPool forkJoinPool = new ForkJoinPool(10); // 10 个工作线程

// 场景:计算 1+2+...+1000(大任务拆小任务)
class SumTask extends RecursiveTask<Long> {
private long start;
private long end;

public SumTask(long start, long end) {
this.start = start;
this.end = end;
}

@Override
protected Long compute() {
if (end - start <= 10) { // 任务足够小,直接计算
long sum = 0;
for (long i = start; i <= end; i++) {
sum += i;
}
return sum;
} else { // 任务太大,拆成两个小任务
long mid = (start + end) / 2;
SumTask leftTask = new SumTask(start, mid);
SumTask rightTask = new SumTask(mid + 1, end);
leftTask.fork(); // 异步执行左任务
rightTask.fork(); // 异步执行右任务
return leftTask.join() + rightTask.join(); // 等待两个任务完成,合并结果
}
}
}

// 提交任务
ForkJoinTask<Long> task = new SumTask(1, 1000);
long result = forkJoinPool.invoke(task);
System.out.println(result); // 500500

工作窃取(Work-Stealing)的原理

1
2
3
4
5
6
7
ForkJoinPool 的线程队列(双端队列):
线程 1 的队列:[Task1, Task2, Task3, Task4] ← 线程 1 从队头取任务
线程 2 的队列:[Task5, Task6] ← 线程 2 从队头取任务

线程 2 执行完了,线程 1 还有很多任务:
→ 线程 2 从线程 1 的队列的队尾窃取任务(Work-Stealing)
→ 线程 2 窃取 Task4(从队尾取,减少竞争)

面试加分回答

ForkJoinPool 的工作窃取(Work-Stealing)算法是它的最大亮点:线程执行完自己的任务后,会主动窃取其他线程的任务(从队尾窃取,减少竞争)。这种机制能大幅提高 CPU 利用率(避免某些线程很忙,其他线程很闲)。实际项目中,如果你需要做大批量数据处理(如处理 1000 万个订单),可以把大任务拆成小任务,用 ForkJoinPool 并行处理,性能比 ThreadPoolExecutor 好很多。


第 46 题:CompletionService 的用法和原理?

一句话结论

CompletionService 是 ExecutorService + BlockingQueue 的组合:提交多个任务后,可以按完成顺序获取结果(哪个任务先完成,就先获取哪个的结果),而不用按提交顺序等待。


深度解析

ExecutorService 的问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// ExecutorService:按提交顺序获取结果(如果第一个任务执行很慢,后面的任务都要等)
ExecutorService executor = Executors.newFixedThreadPool(10);
List<Future<String>> futures = new ArrayList<>();

// 提交 3 个任务
futures.add(executor.submit(() -> {
Thread.sleep(3000); // 任务 1 执行 3 秒
return "Task1";
}));
futures.add(executor.submit(() -> {
Thread.sleep(1000); // 任务 2 执行 1 秒
return "Task2";
}));
futures.add(executor.submit(() -> {
Thread.sleep(2000); // 任务 3 执行 2 秒
return "Task3";
}));

// 按提交顺序获取结果(问题:任务 2 和任务 3 早就完成了,但要等任务 1 完成才能获取)
for (Future<String> future : futures) {
System.out.println(future.get()); // 输出:Task1, Task2, Task3(要等 3 秒才能拿到 Task2 的结果)
}

CompletionService 的解决方案

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// CompletionService:按完成顺序获取结果(哪个任务先完成,就先获取哪个)
ExecutorService executor = Executors.newFixedThreadPool(10);
CompletionService<String> completionService = new ExecutorCompletionService<>(executor);

// 提交 3 个任务(和上面一样)
completionService.submit(() -> {
Thread.sleep(3000);
return "Task1";
});
completionService.submit(() -> {
Thread.sleep(1000);
return "Task2";
});
completionService.submit(() -> {
Thread.sleep(2000);
return "Task3";
});

// 按完成顺序获取结果(Task2 最先完成,所以先输出 Task2)
for (int i = 0; i < 3; i++) {
Future<String> future = completionService.take(); // 阻塞,直到有任务完成
System.out.println(future.get()); // 输出:Task2, Task3, Task1(按完成顺序)
}

CompletionService 的原理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// ExecutorCompletionService 的内部实现:
public class ExecutorCompletionService<V> implements CompletionService<V> {
private final Executor executor;
private final BlockingQueue<Future<V>> completionQueue; // 完成队列

// 提交任务时,用 QueueingFuture 包装(任务完成后,自动放入 completionQueue)
@Override
public Future<V> submit(Callable<V> task) {
Callable<V> t = task;
RunnableFuture<V> f = newTaskFor(t);
executor.execute(new QueueingFuture(f)); // 用 QueueingFuture 包装
return f;
}

// QueueingFuture:任务完成后,自动放入 completionQueue
private class QueueingFuture extends FutureTask<Void> {
@Override
protected void done() {
completionQueue.add(task); // 任务完成,放入队列
}
}
}

面试加分回答

CompletionService 的核心价值是按完成顺序获取结果。实际项目中,如果你提交了多个任务,但不关心任务的提交顺序,只关心哪个任务先完成(如并行调用多个微服务,哪个先返回就用哪个的结果),用 CompletionService 比用 ExecutorService + Future 列表好得多。另外,CompletionService 的底层就是装饰器模式(Executor + BlockingQueue),这也是为什么它的 API 和 ExecutorService 非常像。


第 47 题:TransferQueue 的原理和适用场景?

一句话结论

TransferQueue 是 BlockingQueue 的增强版:生产者可以等待消费者接收元素(transfer() 方法),适合消息传递场景(生产者必须确保消息被消费)。


深度解析

BlockingQueue 的问题

1
2
3
4
5
6
7
8
// BlockingQueue:生产者把元素放入队列就返回(不关心消费者是否消费)
BlockingQueue<String> queue = new LinkedBlockingQueue<>();

// 生产者
queue.put("message"); // 放入队列就返回(不等待消费者消费)
System.out.println("消息已发送"); // 立即输出

// 问题:如果消费者还没启动,消息就在队列里(生产者不知道消息是否被消费)

TransferQueue 的解决方案

1
2
3
4
5
6
7
8
9
10
// TransferQueue:生产者可以等待消费者接收元素
TransferQueue<String> transferQueue = new LinkedTransferQueue<>();

// 生产者
transferQueue.transfer("message"); // 阻塞,直到消费者接收消息
System.out.println("消息已被消费"); // 只有消费者接收了消息,才会输出

// 消费者
String message = transferQueue.take(); // 接收消息
System.out.println("收到消息:" + message);

TransferQueue 的方法

方法 说明
transfer(E e) 阻塞,直到消费者接收元素
tryTransfer(E e) 非阻塞,如果消费者空闲就立即传递,否则返回 false
tryTransfer(E e, long timeout, TimeUnit unit) 限时等待消费者接收

适用场景

1
2
3
4
5
6
7
8
// 场景:消息必须被消费(如日志、审计日志)
// 如果用 BlockingQueue,生产者把消息放入队列就返回(不关心是否被消费)
// 如果用 TransferQueue,生产者会等待消费者接收消息(确保消息被消费)

TransferQueue<LogMessage> logQueue = new LinkedTransferQueue<>();

// 生产者(日志收集器)
logQueue.transfer(logMessage); // 阻塞,直到日志写入线程接收消息(确保日志不丢失)

面试加分回答

TransferQueue 是 Java 7 引入的消息传递增强队列。它的核心方法是 transfer():生产者把元素传给消费者,如果消费者空闲就立即传递,如果消费者不空闲就阻塞等待。这种机制非常适合消息必须被消费的场景(如日志、审计日志)。另外,TransferQueue 的实现类是 LinkedTransferQueue,它同时支持公平模式非公平模式(构造时传入 fair 参数)。


第 48 题:Exchanger 的原理和适用场景?

一句话结论

Exchanger 是两个线程之间交换数据的同步点:两个线程都到达交换点时,才交换数据(类似于「碰头」)。


深度解析

Exchanger 的用法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 场景:两个线程交换数据(如数据校对)
Exchanger<String> exchanger = new Exchanger<>();

// 线程 1:生成数据
new Thread(() -> {
try {
String data1 = "数据A";
System.out.println("线程1准备交换:" + data1);
String receivedData = exchanger.exchange(data1); // 阻塞,等待线程2交换
System.out.println("线程1收到:" + receivedData);
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();

// 线程 2:生成数据
new Thread(() -> {
try {
String data2 = "数据B";
System.out.println("线程2准备交换:" + data2);
String receivedData = exchanger.exchange(data2); // 阻塞,等待线程1交换
System.out.println("线程2收到:" + receivedData);
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();

// 输出:
// 线程1准备交换:数据A
// 线程2准备交换:数据B
// 线程1收到:数据B
// 线程2收到:数据A

适用场景

1
2
3
// 场景 1:数据校对(两个线程分别生成数据,然后交换校对)
// 场景 2:遗传算法(两个线程分别进化种群,然后交换基因)
// 场景 3:模拟(两个线程分别模拟,然后交换状态)

面试加分回答

Exchanger 是 Java 并发包中最冷门的工具类(实际项目中很少用到)。它的核心思想是两个线程在交换点碰头,然后交换数据。如果你做过遗传算法数据校对相关的项目,可能会用到 Exchanger。但大部分情况下,如果需要在两个线程之间传递数据,用 BlockingQueueTransferQueue 更直观。


第 49 题:Phaser 的原理和适用场景?

一句话结论

Phaser 是 Java 7 引入的同步工具,比 CyclicBarrier 更灵活:支持动态调整注册的线程数(CyclicBarrier 的线程数是固定的),适合多阶段任务(如 MapReduce 的多轮迭代)。


深度解析

CyclicBarrier 的局限

1
2
3
4
// CyclicBarrier:线程数固定(创建时指定,不能动态修改)
CyclicBarrier barrier = new CyclicBarrier(3); // 必须 3 个线程到齐,否则一直等待

// 问题:如果某个线程中途退出,CyclicBarrier 会永远等待(BrokenBarrierException)

Phaser 的解决方案

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Phaser:线程数可以动态注册/注销
Phaser phaser = new Phaser(1); // 主线程注册(party = 1)

// 阶段 1:等待 3 个线程到齐
for (int i = 0; i < 3; i++) {
phaser.register(); // 动态注册线程
new Thread(() -> {
System.out.println("线程" + Thread.currentThread().getId() + "到达阶段1");
phaser.arriveAndAwaitAdvance(); // 到达并等待其他线程
// 所有线程到齐后,进入阶段 2
}).start();
}

// 主线程等待所有线程到达阶段 1
phaser.arriveAndAwaitAdvance();

// 阶段 2:可以再注册新的线程(CyclicBarrier 做不到)
phaser.register();
new Thread(() -> {
System.out.println("新线程到达阶段2");
phaser.arriveAndAwaitAdvance();
}).start();

Phaser 的阶段(Phase)

1
2
3
4
5
Phaser 的阶段(Phase):
Phase 0:所有线程到达 → 进入 Phase 1
Phase 1:所有线程到达 → 进入 Phase 2
Phase 2:所有线程到达 → 进入 Phase 3
...

面试加分回答

Phaser 是 Java 并发包中最灵活的同步工具。它比 CyclicBarrier 更强大:支持动态注册/注销线程(CyclicBarrier 的线程数固定),支持多阶段任务(每个阶段可以有不同的线程数)。实际项目中,如果你需要做多轮迭代计算(如机器学习的交替最小二乘法 ALS),Phaser 会非常好用。但要注意:Phaser 的 API 比较复杂(有 arrive()arriveAndAwaitAdvance()arriveAndDeregister() 等),用之前要仔细看文档。


第 50 题:并发编程的最佳实践(如何避免常见坑)?

一句话结论

并发编程的最佳实践:① 优先用并发工具(ConcurrentHashMap、AtomicInteger);② 缩小锁范围(只在必要的代码块加锁);③ 避免死锁(按顺序获取锁、超时机制);④ 用不可变对象(线程安全,不需要加锁);⑤ 正确关闭线程池(shutdown() + awaitTermination())。


深度解析

最佳实践 1:优先用并发工具(不要自己造轮子)

1
2
3
4
5
6
7
// ❌ 不推荐:自己用 synchronized + wait()/notify() 实现生产者-消费者
// 容易出错(wait() 要在 while 循环中检查条件,否则会虚假唤醒)

// ✅ 推荐:用 BlockingQueue(JDK 已经实现好了)
BlockingQueue<String> queue = new LinkedBlockingQueue<>();
// 生产者:queue.put()
// 消费者:queue.take()

最佳实践 2:缩小锁范围

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// ❌ 不推荐:锁范围太大(整个方法都加锁)
public synchronized void process(List<String> data) {
// 1. 数据校验(不需要加锁)
if (data == null || data.isEmpty()) {
return;
}
// 2. 数据处理(不需要加锁)
List<String> processed = data.stream().map(s -> s.toUpperCase()).collect(toList());
// 3. 写入共享变量(需要加锁)
this.result = processed;
}

// ✅ 推荐:缩小锁范围(只在必要的代码块加锁)
public void process(List<String> data) {
if (data == null || data.isEmpty()) {
return;
}
List<String> processed = data.stream().map(s -> s.toUpperCase()).collect(toList());
synchronized (this) { // 只在写入共享变量时加锁
this.result = processed;
}
}

最佳实践 3:避免死锁

1
2
3
4
5
6
7
8
9
10
// ✅ 推荐:按顺序获取锁
// 所有线程都按 lockA → lockB 的顺序获取锁(不会死锁)
public void transfer(Account from, Account to, int amount) {
synchronized (from) { // 先获取 from 的锁
synchronized (to) { // 再获取 to 的锁
from.debit(amount);
to.credit(amount);
}
}
}

最佳实践 4:正确关闭线程池

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// ✅ 推荐:shutdown() + awaitTermination()
ExecutorService executor = Executors.newFixedThreadPool(10);

// 1. shutdown():不再接受新任务,但已提交的任务会继续执行
executor.shutdown();

// 2. awaitTermination():等待所有任务完成(最多等 60 秒)
try {
if (!executor.awaitTermination(60, TimeUnit.SECONDS)) {
// 3. 如果超时还没结束,强制关闭(shutdownNow())
executor.shutdownNow();
}
} catch (InterruptedException e) {
executor.shutdownNow();
}

面试加分回答

并发编程是 Java 中最容易出 Bug的领域。面试中,除了能说出各种并发工具的原理,还要能说出最佳实践。实际项目中,我见过最多的并发 Bug 是:① 锁范围太大(导致性能差);② 忘记释放锁(用 lock() 但忘了 unlock());③ 用错了并发工具(如用 HashMap 而不是 ConcurrentHashMap)。避免这些坑的最好方法是:多写、多调试、多复盘



Java 并发编程面试八股文30条(深度详解版)
https://whyalwaysme.lol/2026/06/07/2026-06-07-java-concurrency-interview-deep/
作者
Cassiur
发布于
2026年6月7日
许可协议