03-Java并发
3.1 线程基础(上)
进程和线程的区别是什么?什么是协程?
原始问法:
- 进程和线程的区别是什么?
- 什么是协程?和线程的区别是什么?
来源题目:
SRC-03-31-091,SRC-03-31-106
面试先答
进程是操作系统分配资源的基本单位,线程是 CPU 调度的基本单位。一个进程可以包含多个线程,它们共享进程的内存空间和资源,但每个线程有独立的程序计数器和栈。协程(Coroutine)是用户态的轻量级线程,由应用程序而非操作系统调度,切换成本极低。总结来说:进程间相互隔离、开销大;线程间共享内存、开销中等;协程在用户态、可实现高并发。Java 中主流还是使用线程,虚拟线程(Project Loom)在 JDK 21 中正式引入,本质上就是协程的一种实现。
核心结论
- 进程是资源分配最小单位,线程是 CPU 调度最小单位
- 同一进程内的线程共享堆和方法区,各自独立栈和程序计数器
- 协程是用户态调度,不需要内核介入,可支撑百万级并发
1. 是什么
进程(Process):操作系统创建的资源容器,包含虚拟地址空间、文件描述符表、进程控制块(PCB)等。
线程(Thread):CPU 调度的执行流,同一进程内的线程共享地址空间,但每个线程拥有独立的:
- 程序计数器(PC)
- 虚拟机栈
- 本地变量表
- 线程状态
协程(Coroutine):由编译器或运行时在用户态实现的轻量级并发单元,不依赖操作系统线程调度。
2. 为什么需要它
- 进程:需要隔离不同程序的运行环境,防止一个程序崩溃影响其他程序
- 线程:需要在同一进程内并发执行多个任务,避免进程间通信的高开销
- 协程:需要在高并发场景下(如百万级 I/O)避免线程切换的高开销
3. 底层原理与完整流程
进程创建流程:操作系统分配 PCB → 分配虚拟地址空间 → 加载可执行文件 → 创建初始线程
线程创建流程:JVM 在 HotSpot 中通过 os::create_thread 调用操作系统 API 创建线程 → 分配 Thread 对象 → 关联 OS 线程 → 加入线程组
协程实现原理:
- 编译器将协程编译为状态机
- 通过保存/恢复寄存器上下文实现挂起和恢复
- 无需内核态切换,用户态即可完成
4. 怎么使用
// Java 中使用线程
Thread thread = new Thread(() -> {
System.out.println("Hello from thread");
});
thread.start();
// JDK 21 虚拟线程(协程)
Thread vt = Thread.ofVirtual()
.name("vt-1")
.start(() -> {
System.out.println("Hello from virtual thread");
});
5. 适用场景
- 进程:隔离性要求高的任务,如浏览器多标签页、微服务
- 线程:CPU 密集型和中等并发的 I/O 密集型任务
- 协程:高并发 I/O 场景,如网关、连接池、爬虫
6. 不适用场景与替代方案
- 不要用进程做细粒度并发,开销太大
- 不要用协程做 CPU 密集型任务,无法利用多核优势
- 协程的替代方案:Reactor 模式(Netty)、异步回调
7. 优缺点与技术取舍
| 维度 | 进程 | 线程 | 协程 |
|---|---|---|---|
| 切换开销 | 高(内核态) | 中(内核态) | 低(用户态) |
| 数量上限 | 数百 | 数千 | 数百万 |
| 数据共享 | 需 IPC | 直接共享 | 直接共享 |
| 稳定性 | 高隔离 | 一个线程崩溃可能影响整个进程 | 取决于实现 |
8. 常见问题及解决方案
问题:线程数过多导致频繁切换
- 解决:使用线程池控制数量,或使用虚拟线程
问题:协程调试困难
- 解决:使用 IDEA 的协程调试器,或通过日志跟踪
9. 版本差异与实现边界
- Java 8-20:仅支持平台线程(Platform Thread),每个 Java 线程映射到一个 OS 线程
- JDK 21:正式引入虚拟线程(Virtual Thread),由
ForkJoinPool调度 - 协程不等于虚拟线程:虚拟线程是 JVM 层实现的协程,Kotlin 协程是语言层面实现
10. 常见追问
追问 1:虚拟线程会取代平台线程吗?
- 回答方向:不会,CPU 密集型任务仍需平台线程;虚拟线程主要解决 I/O 密集型高并发。
追问 2:同一进程内线程共享哪些资源?
- 回答方向:共享堆、方法区、常量池、全局变量、文件句柄;不共享栈、程序计数器。
11. 易错点
❌ 错误:线程是资源分配的基本单位
✅ 正确:进程是资源分配的基本单位,线程是 CPU 调度的基本单位
❌ 错误:协程一定比线程快
✅ 正确:CPU 密集型场景下协程没有优势,甚至可能因额外开销更慢
一句话总结
进程是资源容器、线程是执行流、协程是用户态轻量执行单元,三者分别解决隔离、并发和高并发问题。
进程间的通信方式有哪些?
原始问法:
- 进程间的通信方式有哪些?
来源题目:
SRC-03-31-092
面试先答
进程间通信(IPC)主要有六种方式:管道、消息队列、共享内存、信号量、信号和套接字。管道适合有亲缘关系的进程;消息队列适合低耦合异步通信;共享内存最快但需额外同步;信号量用于同步;信号用于通知;套接字可用于不同机器间的进程通信。Java 中常用的 IPC 包括:通过 Socket 进行网络通信、通过文件进行持久化通信、通过 Java 的 ProcessHandle 与子进程交互。
核心结论
- IPC 方式各有特点,选择取决于场景
- 共享内存是最快的 IPC 方式,但需要配合同步机制
- 套接字是最通用的 IPC,可跨主机
1. 是什么
IPC(Inter-Process Communication):操作系统提供的进程间数据交换机制。
主要 IPC 方式:
| 方式 | 特点 | 适用场景 |
|---|---|---|
| 管道(Pipe) | 半双工、先进先出、有亲缘关系 | 父子进程通信 |
| 命名管道(Named Pipe) | 可无亲缘关系 | 本机进程通信 |
| 消息队列 | 异步、按类型读取 | 低耦合异步通信 |
| 共享内存 | 最快、需同步 | 大量数据交换 |
| 信号量 | 纯同步机制 | 进程间同步 |
| 信号(Signal) | 异步通知 | 异常处理、通知 |
| 套接字(Socket) | 通用、可跨网络 | 网络通信 |
2. 为什么需要它
进程地址空间相互隔离,无法直接读写对方内存,需要操作系统提供的 IPC 机制实现数据交换和协同工作。
3. 底层原理与完整流程
以共享内存为例:
- 进程 A 调用
shmget获取共享内存段 - 调用
shmat将内存段映射到自身地址空间 - 写入数据后通过信号量通知进程 B
- 进程 B 映射同一段内存,读取数据
- 使用完毕调用
shmdt和shmctl释放
4. 怎么使用
// Java 中通过 Socket 实现 IPC
try (ServerSocket server = new ServerSocket(8080)) {
Socket client = server.accept();
// 读写数据
}
// 通过 Process 与子进程通信
ProcessBuilder pb = new ProcessBuilder("cmd", "/c", "echo", "hello");
Process process = pb.start();
5. 适用场景
- 管道:Shell 命令管道(
ls | grep java) - 消息队列:分布式系统中异步任务分发
- 共享内存:高性能计算、实时系统
- 套接字:微服务通信、分布式系统
6. 不适用场景与替代方案
- 不要用共享内存跨机器通信,改用套接字
- 不要用信号传递复杂数据,改用消息队列
- 单机多进程通信可考虑用 ZeroMQ 抽象层
7. 优缺点与技术取舍
共享内存速度最快但需要额外同步;套接字通用性最强但有网络开销;消息队列解耦但有延迟。选择时需在性能、复杂度和可靠性之间权衡。
8. 常见问题及解决方案
- 问题:共享内存段泄漏
- 解决:确保所有进程退出后正确调用
shmctl释放,或使用ipcrm清理
- 解决:确保所有进程退出后正确调用
9. 版本差异与实现边界
- Windows 和 Linux 的 IPC 实现不同
- Java 的 NIO Pipe 是 JVM 层实现,非 OS 级 IPC
- 跨平台 IPC 推荐使用 Socket 或 ZeroMQ
10. 常见追问
- 追问:共享内存为什么最快?
- 回答方向:因为多个进程映射同一块物理内存,无需数据拷贝。
11. 易错点
- ❌ 错误:共享内存不需要同步
- ✅ 正确:多个进程同时访问共享内存需要额外的同步机制(如信号量)
一句话总结
IPC 六大方式各有所长,共享内存最快、套接字最通用、管道最简洁,根据场景选择合适的通信机制。
线程间的通信方式有哪些?
原始问法:
- 线程间的通信方式有哪些?
来源题目:
SRC-03-31-093
面试先答
Java 线程间通信主要有五种方式:volatile 关键字、wait/notify 机制、Lock 和 Condition、CountDownLatch 等同步工具、以及管道流。volatile 保证可见性但不保证原子性;wait/notify 基于对象监视器实现线程间通知;Lock + Condition 是更灵活的替代方案;同步工具类提供了更高层的同步语义;管道流用于字节流通信。实际开发中最常用的是 wait/notify 和 Lock/Condition。
核心结论
volatile解决可见性,wait/notify解决线程调度Lock/Condition是wait/notify的升级,支持多条件队列- 同步工具类(Latch、Barrier、Semaphore)提供高层同步原语
1. 是什么
线程间通信方式:
| 方式 | 机制 | 特点 |
|---|---|---|
volatile |
内存可见性 | 轻量,仅保证可见性和有序性 |
wait/notify |
Object 监视器 | 需在同步块内使用,一次性唤醒 |
Lock/Condition |
AQS 实现 | 可中断、公平、多条件 |
CountDownLatch |
AQS 实现 | 一次性等待,不可重置 |
CyclicBarrier |
ReentrantLock | 可重置的循环屏障 |
Semaphore |
AQS 实现 | 控制并发数量 |
| 管道流 | PipedInputStream/OutputStream | 字节流方式通信 |
2. 为什么需要它
多个线程协作时需要协调执行顺序和数据交换。例如生产者-消费者模型中,生产者需要通知消费者数据已就绪。
3. 底层原理与完整流程
wait/notify 机制:
- 线程 A 获取对象锁
- 调用
wait()释放锁并进入等待队列 - 线程 B 获取同一对象锁
- 调用
notify()唤醒等待队列中的一个线程 - 线程 B 释放锁后,线程 A 重新竞争锁并从
wait()返回
4. 怎么使用
// wait/notify 示例
Object lock = new Object();
// 生产者
synchronized (lock) {
data.produce();
lock.notify(); // 唤醒消费者
}
// 消费者
synchronized (lock) {
while (data.isEmpty()) {
lock.wait(); // 等待数据
}
data.consume();
}
// CountDownLatch 示例
CountDownLatch latch = new CountDownLatch(3);
// 等待 3 个任务完成
for (int i = 0; i < 3; i++) {
new Thread(() -> {
doWork();
latch.countDown();
}).start();
}
latch.await(); // 等待所有任务完成
5. 适用场景
volatile:状态标志位(如running = false)wait/notify:简单的线程间通知CountDownLatch:一个线程等待多个任务完成CyclicBarrier:多个线程相互等待Semaphore:限流场景
6. 不适用场景与替代方案
- 不要用
volatile做复合操作(如count++) - 不要用
CountDownLatch做循环同步,改用CyclicBarrier - 不要在持有锁的情况下调用
Thread.sleep,改用Condition.await
7. 优缺点与技术取舍
wait/notify 使用简单但存在信号丢失和虚假唤醒问题;Lock/Condition 更灵活但代码更复杂;同步工具类封装度高但功能有限。
8. 常见问题及解决方案
问题:
notify()早于wait()调用导致信号丢失- 解决:在调用
wait()前检查条件是否满足,使用while循环而非if
- 解决:在调用
问题:虚假唤醒
- 解决:始终在
while循环中检查条件
- 解决:始终在
9. 版本差异与实现边界
wait/notify从 Java 1.0 就存在java.util.concurrent包从 Java 5 引入Condition接口与Lock搭配使用,提供更精细的线程控制
10. 常见追问
- 追问:为什么
wait()必须在同步块内调用?- 回答方向:防止信号丢失,保证检查条件和等待的原子性。
11. 易错点
❌ 错误:
wait()会释放锁,sleep()不会✅ 正确:这是两者的核心区别之一
❌ 错误:
notifyAll()比notify()好✅ 正确:根据场景选择,
notifyAll()可能导致性能问题
一句话总结
线程间通信有多种机制,wait/notify 基于对象监视器,Lock/Condition 更灵活,同步工具类提供高层封装,选择取决于场景需求。
线程之间哪些内存是共享的?
原始问法:
- 线程之间哪些内存是共享的?
来源题目:
SRC-03-31-094
面试先答
在 Java 中,同一进程的所有线程共享堆内存和方法区(元空间),包括对象实例、类信息、常量池、静态变量等。每个线程独立拥有程序计数器、虚拟机栈和本地方法栈。堆是线程间通信的主要内存区域,但必须通过同步机制保证线程安全。理解这个划分是掌握 Java 并发编程的基础。
核心结论
- 共享区域:堆、方法区(元空间)
- 线程私有:程序计数器、虚拟机栈、本地方法栈
- 堆是线程安全问题的主要发生区域
1. 是什么
JVM 运行时数据区域划分:
线程共享区域:
- 堆(Heap):存放对象实例和数组
- 方法区/元空间(Metaspace):存放类信息、常量、静态变量、JIT 编译后的代码
线程私有区域:
- 程序计数器(PC Register):记录当前线程执行的字节码行号
- 虚拟机栈(VM Stack):存储局部变量表、操作数栈、动态链接、方法出口
- 本地方法栈(Native Method Stack):为 Native 方法服务
2. 为什么需要它
- 共享内存使线程间数据交换成为可能
- 线程私有区域保证线程执行的独立性
- 合理的内存划分是实现高效并发的基础
3. 底层原理与完整流程
线程启动时:
- JVM 为新线程分配程序计数器(CPU 寄存器级别)
- 分配虚拟机栈空间(每个方法调用对应一个栈帧)
- 新线程与主线程共享堆和方法区
- 新线程通过引用访问堆中的共享对象
4. 怎么使用
public class SharedMemoryDemo {
private static int sharedCount = 0; // 方法区,线程共享
private int instanceCount = 0; // 堆,线程共享
public void method() {
int localCount = 0; // 栈,线程私有
// ...
}
}
5. 适用场景
- 共享内存用于线程间数据交换:生产者-消费者、计数器等
- 线程私有内存用于保存执行上下文:方法调用、局部变量
6. 不适用场景与替代方案
- 不要在没有同步的情况下读写共享内存
- 不要将临时数据存放在共享区域
- 需要线程隔离时使用
ThreadLocal
7. 优缺点与技术取舍
共享内存高效但需同步机制保障;线程私有内存安全但无法直接共享。通过 volatile、synchronized、Lock 等机制协调两者。
8. 常见问题及解决方案
问题:多线程修改共享变量导致数据竞争
- 解决:使用
synchronized、Lock或原子类
- 解决:使用
问题:线程私有内存泄漏
- 解决:使用
ThreadLocal.remove()清理
- 解决:使用
9. 版本差异与实现边界
- Java 8:方法区永久代,可能 OOM
- Java 8+:使用元空间(Metaspace),使用本地内存
- 堆内存可通过
-Xmx和-Xms参数配置
10. 常见追问
- 追问:
ThreadLocal是如何实现线程隔离的?- 回答方向:每个
Thread对象内部有一个ThreadLocalMap,以ThreadLocal为 key 存储值。
- 回答方向:每个
11. 易错点
❌ 错误:静态变量是线程安全的
✅ 正确:静态变量存储在方法区,所有线程共享,不是线程安全的
❌ 错误:局部变量是共享的
✅ 正确:局部变量存储在栈帧中,是线程私有的
一句话总结
堆和方法区线程共享,程序计数器和栈线程私有,共享内存是并发编程的基础但需要同步机制保障安全。
创建线程的方式有哪些?本质上的实现方式是什么?
原始问法:
- 创建线程的方式有哪些?本质上的实现方式是什么?
来源题目:
SRC-03-31-095
面试先答
Java 中创建线程主要有四种方式:继承 Thread 类、实现 Runnable 接口、实现 Callable 接口配合 Future、使用线程池。从本质上讲,前三种最终都需要创建 Thread 对象并调用 start() 方法,第四种是复用已有线程。Thread.start() 内部调用 JVM 的 native 方法 start0(),最终通过操作系统 API 创建内核级线程。JDK 21 还引入了虚拟线程,创建方式为 Thread.ofVirtual()。
核心结论
- 创建线程的四种方式:继承 Thread、实现 Runnable、实现 Callable+Future、使用线程池
- 本质都是调用
Thread.start()→start0()→ OS 创建线程 - 线程池是生产环境首选方式
1. 是什么
创建线程的方式:
方式一:继承 Thread
public class MyThread extends Thread {
@Override
public void run() {
System.out.println("Thread running");
}
}
new MyThread().start();
方式二:实现 Runnable
public class MyRunnable implements Runnable {
@Override
public void run() {
System.out.println("Runnable running");
}
}
new Thread(new MyRunnable()).start();
方式三:实现 Callable + Future
Callable<String> callable = () -> "Callable result";
FutureTask<String> futureTask = new FutureTask<>(callable);
new Thread(futureTask).start();
String result = futureTask.get(); // 阻塞获取结果
方式四:使用线程池
ExecutorService executor = Executors.newFixedThreadPool(5);
executor.submit(() -> {
System.out.println("Task running in pool");
});
2. 为什么需要它
- Runnable/Callable 实现解耦,线程逻辑与线程创建分离
- Callable 支持返回值和异常处理
- 线程池避免频繁创建销毁线程的开销
3. 底层原理与完整流程
Thread.start() 调用流程:
Thread.start()→start0()(native 方法)- JVM 调用
Thread::create_thread - HotSpot 中
os::create_thread调用 OS API(WindowsCreateThread,Linuxpthread_create) - OS 创建内核线程并关联 JVM
Thread对象 - 内核线程启动后调用
Thread::run执行用户逻辑
4. 怎么使用
见上面代码示例。推荐使用实现 Runnable 或 Callable 的方式,因为:
- 避免单继承限制
- 便于线程池管理
- 业务代码与线程逻辑分离
5. 适用场景
- 简单一次性任务:继承 Thread 或实现 Runnable
- 需要返回结果:使用 Callable + Future
- 生产环境:使用线程池
- JDK 21+ 高并发 I/O:使用虚拟线程
6. 不适用场景与替代方案
- 不要频繁创建销毁线程,改用线程池
- 不要在业务代码中直接
new Thread(),统一使用线程池 - 不要忽略
CallerRunsPolicy拒绝策略下的任务执行
7. 优缺点与技术取舍
| 方式 | 优点 | 缺点 |
|---|---|---|
| 继承 Thread | 简单直接 | 单继承限制,不可复用 |
| 实现 Runnable | 解耦,可复用 | 无返回值 |
| Callable | 有返回值,可抛异常 | 代码复杂 |
| 线程池 | 可复用,可控 | 需合理配置参数 |
8. 常见问题及解决方案
问题:
Thread.start()调用了run()但没有新线程- 解决:确认调用的是
start()而非直接调用run()
- 解决:确认调用的是
问题:Callable 任务无法取消
- 解决:使用
Future.cancel(true)中断正在执行的任务
- 解决:使用
9. 版本差异与实现边界
- Java 5:引入
Executor、Callable、Future - Java 7:引入
ForkJoinPool - JDK 21:引入虚拟线程
Thread.ofVirtual()
10. 常见追问
- 追问:
Thread.start()和Thread.run()的区别?- 回答方向:
start()创建新线程执行,run()在当前线程同步执行。
- 回答方向:
11. 易错点
❌ 错误:调用
run()启动线程✅ 正确:必须调用
start()才能启动新线程❌ 错误:Callable 可以直接 new Thread 启动
✅ 正确:需要包装为
FutureTask才能传给Thread
一句话总结
创建线程有四种方式,本质都是通过 Thread.start() → start0() 调用 OS 创建内核线程,生产环境首选线程池。
继承 Thread 类和实现 Runnable 接口的核心区别是什么?
原始问法:
- 继承 Thread 类和实现 Runnable 接口的核心区别是什么?
来源题目:
SRC-03-31-096
面试先答
核心区别在于:继承 Thread 是强耦合,实现 Runnable 是松耦合。继承 Thread 后无法再继承其他类,且每次只能创建新的线程实例;实现 Runnable 可以继承其他类、可以被线程池复用、多个线程可以共享同一个 Runnable 实例。两者底层实现相同——Thread 类内部也有一个 Runnable 字段,run() 方法会调用该字段的 run() 方法。推荐始终使用 Runnable 或 Callable。
核心结论
- 继承 Thread 是耦合设计,实现 Runnable 是解耦设计
- Runnable 可以被多个线程共享,Thread 不行
- Runnable 可以被线程池复用,Thread 不能
- 推荐始终使用实现 Runnable/Callable 的方式
1. 是什么
继承 Thread:
public class MyThread extends Thread {
@Override
public void run() {
// 线程逻辑
}
}
实现 Runnable:
public class MyRunnable implements Runnable {
@Override
public void run() {
// 线程逻辑
}
}
2. 为什么需要它
- 继承 Thread 后,线程逻辑和线程生命周期耦合在一起
- 实现 Runnable 可以将业务逻辑与线程管理分离
- Runnable 实例可以被多线程共享,也可以被线程池复用
3. 底层原理与完整流程
Thread 类内部:
public class Thread implements Runnable {
private Runnable target; // 目标 Runnable
@Override
public void run() {
if (target != null) {
target.run(); // 调用外部 Runnable
}
}
}
当继承 Thread 时,target 为 null,直接重写 run()。
当实现 Runnable 时,target 不为 null,Thread 的 run() 转发调用。
4. 怎么使用
推荐使用实现 Runnable 的方式:
// 可复用的业务逻辑
Runnable task = () -> System.out.println("Business logic");
// 被多个线程共享
new Thread(task).start();
new Thread(task).start();
// 被线程池复用
executor.execute(task);
5. 适用场景
- 简单临时任务:两种方式均可
- 需要复用、需要继承其他类、需要线程池:必须使用 Runnable
- 需要返回值:使用 Callable
6. 不适用场景与替代方案
- 不要在生产代码中直接
new Thread(),使用线程池 - 不要用继承 Thread 的方式写业务代码,限制扩展性
- 替代方案:Lambda 表达式、方法引用
7. 优缺点与技术取舍
| 维度 | 继承 Thread | 实现 Runnable |
|---|---|---|
| 代码复杂度 | 简单 | 稍复杂 |
| 单继承限制 | 受限 | 不受限 |
| 可复用性 | 不可复用 | 可复用 |
| 线程池支持 | 不支持 | 支持 |
| 解耦程度 | 耦合 | 解耦 |
8. 常见问题及解决方案
- 问题:匿名内部类中
this指针混淆- 继承 Thread 时
this指向线程对象 - 实现 Runnable 时
this指向业务对象 - 解决:使用
Thread.currentThread()获取当前线程
- 继承 Thread 时
9. 版本差异与实现边界
- Java 8+:可以使用 Lambda 简化 Runnable 创建
Thread类也实现了Runnable接口
10. 常见追问
- 追问:为什么推荐使用 Runnable?
- 回答方向:解耦、可复用、可被线程池管理、不受单继承限制。
11. 易错点
❌ 错误:继承 Thread 的方式性能更好
✅ 正确:两者底层完全相同,性能无差异
❌ 错误:Runnable 不能直接用 new Thread()
✅ 正确:
new Thread(new MyRunnable()).start()是标准用法
一句话总结
继承 Thread 是耦合的简单实现,实现 Runnable 是解耦的推荐方式,生产环境应始终选择 Runnable/Callable。
Runnable 和 Callable 的区别是什么?
原始问法:
- Runnable 和 Callable 的区别是什么?
来源题目:
SRC-03-31-097
面试先答
Runnable 和 Callable 的核心区别有三点:第一,Runnable 的 run() 方法没有返回值,Callable 的 call() 方法有返回值;第二,Runnable 的 run() 方法不能抛出受检查异常,Callable 的 call() 方法可以;第三,Callable 需要配合 Future 使用才能获取结果。两者都是函数式接口,在 Java 8 中都可以用 Lambda 创建。Runnable 自 Java 1.0 就存在,Callable 从 Java 5 引入。
核心结论
Runnable.run()无返回值,Callable.call()有返回值Callable可以抛出受检查异常Callable需配合Future获取结果
1. 是什么
// Runnable - Java 1.0
@FunctionalInterface
public interface Runnable {
void run(); // 无返回值,不可抛受检异常
}
// Callable - Java 5
@FunctionalInterface
public interface Callable<V> {
V call() throws Exception; // 有返回值,可抛受检异常
}
2. 为什么需要它
- Runnable 无法获取线程执行结果
- Runnable 无法抛出受检查异常(必须 try-catch)
- Callable 解决了这两个痛点,使线程可以像方法一样有返回值
3. 底层原理与完整流程
Callable 执行流程:
- 将
Callable包装为FutureTask(同时实现Runnable和Future) FutureTask传给Thread或ExecutorService- 线程执行
FutureTask.run()→ 调用Callable.call() - 结果存储在
FutureTask的outcome字段中 - 其他线程通过
Future.get()获取结果(阻塞等待)
4. 怎么使用
// Callable 配合 FutureTask
Callable<String> task = () -> {
// 可抛受检异常
return computeResult();
};
FutureTask<String> futureTask = new FutureTask<>(task);
new Thread(futureTask).start();
// 获取结果(阻塞)
String result = futureTask.get();
// 使用线程池
ExecutorService executor = Executors.newCachedThreadPool();
Future<String> future = executor.submit(task);
String result = future.get();
5. 适用场景
- 需要返回计算结果的异步任务:使用 Callable
- 简单无返回值的任务:使用 Runnable
- 需要抛出异常的任务:必须使用 Callable
6. 不适用场景与替代方案
- 不要在
Callable.call()中执行无限循环 - 不要在主线程中立即调用
future.get(),会变成同步 - 需要链式调用时使用
CompletableFuture
7. 优缺点与技术取舍
| 维度 | Runnable | Callable |
|---|---|---|
| 返回值 | 无 | 泛型返回值 |
| 异常 | 不可抛受检异常 | 可抛任意异常 |
| 使用复杂度 | 简单 | 需配合 Future |
| 结果获取 | 回调或共享变量 | Future.get() |
8. 常见问题及解决方案
问题:
future.get()阻塞导致主线程卡死- 解决:使用带超时的
get(timeout, unit),或使用CompletableFuture异步回调
- 解决:使用带超时的
问题:Callable 内部异常被 Future 吞掉
- 解决:调用
future.get()时异常会被包装为ExecutionException抛出
- 解决:调用
9. 版本差异与实现边界
- Java 1.0:引入
Runnable - Java 5:引入
Callable、Future、FutureTask - Java 8:
CompletableFuture提供更强大的异步能力
10. 常见追问
- 追问:
Future和FutureTask的区别?- 回答方向:
Future是接口,FutureTask是实现类,同时实现了Runnable。
- 回答方向:
11. 易错点
- ❌ 错误:Callable 可以直接
new Thread(callable).start() - ✅ 正确:需要包装为
FutureTask:new Thread(new FutureTask<>(callable))
一句话总结
Runnable 无返回值、不可抛受检异常;Callable 有返回值、可抛异常,需配合 Future 获取结果,是实现有返回值异步任务的标准方式。
线程的生命周期有哪些状态?状态之间如何切换?
原始问法:
- 线程的生命周期有哪些状态?状态之间如何切换?
来源题目:
SRC-03-31-098
面试先答
Java 线程有六种状态:NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。NEW 是创建未启动;RUNNABLE 是正在执行或等待 CPU 调度;BLOCKED 是等待锁;WAITING 是无限期等待;TIMED_WAITING 是有限期等待;TERMINATED 是执行结束。状态转换路径为:NEW→RUNNABLE→(BLOCKED/WAITING/TIMED_WAITING)→RUNNABLE→TERMINATED。注意 Java 的 RUNNABLE 状态包含了操作系统的就绪和运行两种状态。
核心结论
- 六种线程状态由
Thread.State枚举定义 - BLOCKED 等待锁、WAITING 无限等待、TIMED_WAITING 有限等待
- 只有 RUNNABLE 状态能直接进入 TERMINATED
- Java 的 RUNNABLE 对应 OS 的就绪态和运行态
1. 是什么
线程状态枚举(Thread.State):
public enum State {
NEW, // 新建,未启动
RUNNABLE, // 可运行(就绪+运行中)
BLOCKED, // 等待锁释放
WAITING, // 等待其他线程通知(无限期)
TIMED_WAITING,// 有时间限制的等待
TERMINATED // 已终止
}
2. 为什么需要它
理解线程状态有助于:
- 诊断死锁和线程阻塞问题
- 分析线程池的使用情况
- 优化并发程序性能
3. 底层原理与完整流程
状态转换图:
NEW ──start()──→ RUNNABLE
│
┌────────────┼────────────┐
│ │ │
BLOCKED WAITING TIMED_WAITING
(等锁) (wait) (sleep/await)
│ │ │
└─────锁释放/通知────────→ RUNNABLE
│
执行结束
│
TERMINATED
各状态进入条件:
- NEW:
new Thread()后、start()前 - RUNNABLE:
start()后,或从其他状态恢复 - BLOCKED:等待
synchronized锁或ReentrantLock锁 - WAITING:调用
Object.wait()、Thread.join()、Condition.await() - TIMED_WAITING:调用
Thread.sleep(timeout)、Object.wait(timeout)、Condition.await(timeout)、Thread.join(timeout) - TERMINATED:
run()方法执行完毕或抛出未捕获异常
4. 怎么使用
Thread thread = new Thread(() -> {
try {
Thread.sleep(1000); // RUNNABLE → TIMED_WAITING
} catch (InterruptedException e) {
// 处理中断
}
});
System.out.println(thread.getState()); // NEW
thread.start();
System.out.println(thread.getState()); // RUNNABLE
Thread.sleep(100);
System.out.println(thread.getState()); // TIMED_WAITING(可能)
5. 适用场景
- 监控线程状态:通过
Thread.getState()诊断线程问题 - 调试死锁:通过 JStack 查看线程状态
- 线程池调优:分析线程池中线程的状态分布
6. 不适用场景与替代方案
- 不要依赖线程状态做业务逻辑判断
- 不要用
stop()强制终止线程(已废弃) - 需要线程间通知用
wait/notify或Lock/Condition
7. 优缺点与技术取舍
线程状态是 JVM 层面的抽象,方便理解并发行为;但实际线程状态转换受 OS 调度影响,getState() 返回的值可能立即变化。
8. 常见问题及解决方案
问题:线程长时间处于 BLOCKED 状态
- 解决:使用 JStack 查看线程栈,分析锁竞争情况
问题:线程处于 WAITING 状态无法恢复
- 解决:检查是否有其他线程调用了
notify()或notifyAll()
- 解决:检查是否有其他线程调用了
9. 版本差异与实现边界
- 从 Java 1.0 就存在六种线程状态
- JDK 21 中虚拟线程的状态可能更复杂(由 Carrier 线程映射)
Thread.getState()返回的是 JVM 层的状态,不完全对应 OS 线程状态
10. 常见追问
- 追问:BLOCKED 和 WAITING 的区别?
- 回答方向:BLOCKED 等待锁(竞争式),WAITING 等待通知(协作式)。
11. 易错点
❌ 错误:RUNNABLE 状态就是正在运行
✅ 正确:RUNNABLE 包含就绪和运行两种状态,可能正在等待 CPU 调度
❌ 错误:线程可以从 BLOCKED 直接到 TERMINATED
✅ 正确:必须先经过 RUNNABLE 状态
一句话总结
Java 线程有六种状态,状态转换遵循固定路径,理解状态转换是诊断并发问题的基础。
线程 WAITING 状态和 TIMED_WAITING 状态的核心区别是什么?
原始问法:
- 线程 WAITING 状态和 TIMED_WAITING 状态的核心区别是什么?
来源题目:
SRC-03-31-099
面试先答
WAITING 和 TIMED_WAITING 的核心区别在于是否有超时机制。WAITING 是无限期等待,必须由其他线程显式调用 notify() 或 notifyAll() 才能唤醒;TIMED_WAITING 是有超时的等待,除了被其他线程通知外,超时时间到达后会自动唤醒。两者进入的方法不同:WAITING 通过 wait()、join()、await()(无参)进入;TIMED_WAITING 通过 sleep(time)、wait(time)、join(time)、await(time) 进入。
核心结论
- WAITING 无超时,必须被通知;TIMED_WAITING 有超时,可自动恢复
- WAITING 方法:
wait()、join()、await() - TIMED_WAITING 方法:
sleep(time)、wait(time)、join(time)、await(time) - 两者都需要被其他线程从外部改变状态
1. 是什么
WAITING:无限期等待其他线程的通知(notify)。
TIMED_WAITING:有超时时间的等待,超时后自动返回。
2. 为什么需要它
- WAITING 用于需要精确控制通知时机的场景
- TIMED_WAITING 用于需要超时保护的场景,避免永久阻塞
3. 底层原理与完整流程
WAITING 状态转换:
- 线程 A 获取对象锁
- 调用
wait()→ 释放锁,进入 WAITING 状态,加入等待队列 - 线程 B 获取同一对象锁,调用
notify()→ 线程 A 进入同步队列 - 线程 B 释放锁 → 线程 A 竞争锁,获取后从
wait()返回
TIMED_WAITING 状态转换:
- 与 WAITING 类似,但有超时定时器
- 超时时间到达后,即使没有
notify(),线程也会自动进入同步队列
4. 怎么使用
// WAITING 示例
synchronized (lock) {
while (!condition) {
lock.wait(); // 无限期等待
}
}
// TIMED_WAITING 示例
synchronized (lock) {
while (!condition) {
lock.wait(1000); // 最多等待 1 秒
// 超时后继续循环检查
}
}
// sleep 进入 TIMED_WAITING
Thread.sleep(1000); // 不释放锁,进入 TIMED_WAITING
5. 适用场景
- WAITING:条件明确需要外部通知的场景
- TIMED_WAITING:需要超时保护的场景,如等待网络响应
6. 不适用场景与替代方案
- 不要在 WAITING 中无限制等待,可能导致线程泄漏
- 不要用
sleep()替代wait(),sleep()不释放锁 - 替代方案:使用
CountDownLatch、Semaphore等同步工具
7. 优缺点与技术取舍
| 维度 | WAITING | TIMED_WAITING |
|---|---|---|
| 超时机制 | 无 | 有 |
| 唤醒方式 | 必须被通知 | 通知或超时 |
| 锁释放 | 释放锁 | wait/await 释放锁,sleep 不释放 |
| 风险 | 永久阻塞风险 | 超时自动恢复 |
8. 常见问题及解决方案
问题:WAITING 线程永久阻塞
- 解决:改用 TIMED_WAITING,或使用
try-catch处理中断
- 解决:改用 TIMED_WAITING,或使用
问题:
sleep()期间持有锁- 解决:使用
wait()或Condition.await()替代
- 解决:使用
9. 版本差异与实现边界
- 从 Java 1.0 就存在 WAITING 和 TIMED_WAITING 状态
Thread.sleep()不释放锁,Object.wait()释放锁- JDK 21 中虚拟线程的
sleep实现有所不同
10. 常见追问
- 追问:
wait()和sleep()的区别?- 回答方向:
wait()释放锁、需在同步块、可被中断;sleep()不释放锁、无需同步、可被中断。
- 回答方向:
11. 易错点
❌ 错误:WAITING 状态可以自动恢复
✅ 正确:WAITING 必须被其他线程显式通知
❌ 错误:
sleep()也会释放锁✅ 正确:
sleep()不释放锁,wait()才释放锁
一句话总结
WAITING 无限期等待通知,TIMED_WAITING 有超时保护,核心区别在于是否需要外部通知才能恢复。
什么是线程上下文切换?触发场景有哪些?有什么性能影响?
原始问法:
- 什么是线程上下文切换?触发场景有哪些?有什么性能影响?
来源题目:
SRC-03-31-100
面试先答
线程上下文切换是指 CPU 从一个线程切换到另一个线程的过程,涉及保存当前线程的运行状态(程序计数器、寄存器等)和恢复目标线程的状态。触发场景包括:线程时间片用完、线程主动阻塞(I/O、锁等待)、高优先级线程抢占、操作系统中断等。上下文切换开销在微秒级别,频繁切换会显著降低 CPU 缓存命中率,成为性能瓶颈。生产环境应通过合理设置线程数量、使用协程等方式减少上下文切换。
核心结论
- 上下文切换是保存/恢复线程执行状态的过程
- 触发场景:时间片轮转、阻塞、抢占、中断
- 切换开销:保存/恢复寄存器、更新内核调度数据、缓存失效、TLB 失效
- 优化方向:减少线程数、使用协程、批量处理
1. 是什么
上下文切换(Context Switch):CPU 从当前线程切换到另一个线程时,需要保存当前线程的上下文(寄存器、栈指针、程序计数器等),并恢复目标线程的上下文。
2. 为什么需要它
- CPU 核心数有限,需要在多个线程间共享
- 操作系统通过时间片轮转实现"同时"执行多个线程的假象
- 上下文切换是时间片轮转的必要代价
3. 底层原理与完整流程
上下文切换步骤:
- 触发切换(时间片用尽/阻塞/中断)
- 保存当前 CPU 寄存器到当前线程的
Thread结构 - 加载目标线程的
Thread结构到 CPU 寄存器 - 更新内存管理单元(MMU)的页表(进程切换时)
- 刷新 CPU 缓存(部分场景)
- CPU 从新上下文恢复执行
开销来源:
- 内核态/用户态切换(涉及系统调用)
- 缓存失效(L1/L2 Cache 被污染)
- TLB 失效(地址翻译缓存失效)
- 内存屏障
4. 怎么使用
上下文切换无法完全避免,但可以通过以下方式优化:
// 1. 使用线程池控制线程数量
int processors = Runtime.getRuntime().availableProcessors();
ExecutorService pool = Executors.newFixedThreadPool(processors * 2);
// 2. 使用 ForkJoinPool 实现工作窃取
ForkJoinPool fjp = new ForkJoinPool(processors);
// 3. JDK 21 使用虚拟线程(协程)
Thread.startVirtualThread(() -> {
// 用户态切换,开销极低
});
5. 适用场景
- 不可避免:操作系统层面的调度
- 可优化:应用层面减少不必要的线程创建
- 高并发场景:优先使用协程/虚拟线程
6. 不适用场景与替代方案
- 不要创建远超过 CPU 核数的线程
- 不要在循环中创建销毁线程
- 替代方案:使用协程、Reactor 模式、异步 I/O
7. 优缺点与技术取舍
| 维度 | 进程切换 | 线程切换 | 协程切换 |
|---|---|---|---|
| 耗时 | 数十微秒 | 数微秒 | 亚微秒 |
| 缓存影响 | 严重 | 中等 | 极小 |
| 内核参与 | 是 | 是 | 否 |
8. 常见问题及解决方案
问题:高并发场景下 CPU 使用率低但吞吐量低
- 解决:可能是上下文切换过多,使用
pidstat -w或vmstat诊断
- 解决:可能是上下文切换过多,使用
问题:GC 导致频繁 STW 和上下文切换
- 解决:调优 JVM GC 参数,减少 STW 时间
9. 版本差异与实现边界
- 上下文切换由操作系统内核负责,JVM 无法直接控制
- Linux 2.6+ 使用 CFS(Completely Fair Scheduler)调度
- JDK 21 虚拟线程使用用户态调度,避免内核态上下文切换
10. 常见追问
- 追问:如何减少上下文切换?
- 回答方向:控制线程数量、使用线程池、使用协程、批量处理。
11. 易错点
❌ 错误:上下文切换就是线程阻塞
✅ 正确:阻塞是线程状态变化,上下文切换是 CPU 调度行为,两者相关但不同
❌ 错误:上下文切换对性能没有影响
✅ 正确:频繁切换会严重影响缓存命中率和系统吞吐量
一句话总结
上下文切换是 CPU 在线程间切换时保存恢复状态的过程,有显著性能开销,应通过合理控制线程数量和使用协程来减少切换。
3.1 线程基础(下)
sleep() 和 wait() 的区别是什么?
原始问法:
- sleep()和wait()的区别是什么?
来源题目:
SRC-03-31-101
面试先答
sleep() 和 wait() 的核心区别有五点:第一,sleep() 是 Thread 类的静态方法,wait() 是 Object 类的实例方法;第二,sleep() 不释放锁,wait() 释放锁;第三,sleep() 可以在任何地方调用,wait() 必须在同步块内调用;第四,sleep() 进入 TIMED_WAITING 状态,时间到自动恢复,wait() 进入 WAITING 状态,必须被 notify()/notifyAll() 唤醒;第五,sleep() 直接操作系统层面的定时,wait() 基于对象监视器实现。
核心结论
sleep()不释放锁,wait()释放锁sleep()是Thread静态方法,wait()是Object实例方法sleep()可在任何地方调用,wait()必须在同步块内sleep()进入 TIMED_WAITING,wait()进入 WAITINGsleep()靠时间恢复,wait()靠通知恢复
1. 是什么
// Thread.sleep() - JDK 源码
public static void sleep(long millis) throws InterruptedException {
// native 方法实现
}
// Object.wait() - JDK 源码
public final void wait() throws InterruptedException {
// native 方法实现
}
2. 为什么需要它
sleep()用于时间控制,不需要释放锁wait()用于线程间协作,必须释放锁让其他线程有机会获取
3. 底层原理与完整流程
sleep() 流程:
- 调用
Thread.sleep(time) - JVM 设置定时器
- 线程进入 TIMED_WAITING 状态
- 不释放任何锁
- 时间到后自动恢复到 RUNNABLE
wait() 流程:
- 必须在
synchronized块内调用 - 调用
Object.wait() - 释放对象锁
- 线程进入 WAITING 状态,加入对象的等待队列
- 其他线程调用
notify()/notifyAll()唤醒 - 线程重新竞争锁,获取后从
wait()返回
4. 怎么使用
// sleep 使用场景:暂停执行,不需要线程协作
Thread.sleep(1000); // 暂停 1 秒,持有锁不会释放
// wait 使用场景:线程间协作
synchronized (lock) {
while (!dataReady) {
lock.wait(); // 释放锁,等待通知
}
processData();
}
// notify 唤醒
synchronized (lock) {
dataReady = true;
lock.notify(); // 唤醒等待的线程
}
5. 适用场景
sleep():轮询间隔、定时任务、测试延迟wait():生产者-消费者、线程间条件同步
6. 不适用场景与替代方案
- 不要用
sleep()替代wait(),会导致死锁 - 不要用
wait()做简单延迟,过于复杂 - 需要超时等待时用
wait(timeout)或Condition.await(timeout)
7. 优缺点与技术取舍
| 维度 | sleep() | wait() |
|---|---|---|
| 所属类 | Thread |
Object |
| 是否释放锁 | 否 | 是 |
| 调用位置 | 任意位置 | 同步块内 |
| 状态 | TIMED_WAITING | WAITING |
| 恢复条件 | 超时/中断 | 通知/超时/中断 |
| 作用 | 时间控制 | 线程协作 |
8. 常见问题及解决方案
问题:
sleep()期间持有锁导致其他线程无法获取- 解决:使用
wait()/notify()机制
- 解决:使用
问题:
wait()未在同步块内调用抛出IllegalMonitorStateException- 解决:确保在
synchronized块内调用wait()
- 解决:确保在
9. 版本差异与实现边界
- 两者从 Java 1.0 就存在
- JDK 21 中虚拟线程的
sleep()实现有优化
10. 常见追问
- 追问:为什么
wait()要放在while循环中?- 回答方向:防止虚假唤醒,确保条件满足后再继续执行。
11. 易错点
❌ 错误:
sleep()会释放锁✅ 正确:
sleep()不释放锁,这是重要区别❌ 错误:
wait()可以不在同步块中调用✅ 正确:必须在同步块中调用,否则抛
IllegalMonitorStateException
一句话总结
sleep() 是 Thread 的静态方法、不释放锁、用于时间控制;wait() 是 Object 的实例方法、释放锁、用于线程协作。
并发和并行的区别是什么?
原始问法:
- 并发和并行的区别是什么?
来源题目:
SRC-03-31-102
面试先答
并发(Concurrency)是指在同一时间段内交替执行多个任务,强调的是"同时处理多个事情的能力",不要求真正同时执行,单核 CPU 通过时间片轮转也能实现并发。并行(Parallelism)是指在同一时刻真正同时执行多个任务,需要多个 CPU 核心。简单来说:并发是宏观上同时、微观上交替;并行是宏观和微观上都同时。Java 中 线程 实现并发,多核 + 多线程 实现并行,ForkJoinPool 和 CompletableFuture 是实现并行的工具。
核心结论
- 并发:同一时间段交替执行,单核即可
- 并行:同一时刻同时执行,需要多核
- 并行一定是并发,但并发不一定是并行
- Java 线程在单核上实现并发,在多核上实现并行
1. 是什么
并发(Concurrency):多个任务在时间上重叠执行,通过 CPU 时间片轮转实现。
并行(Parallelism):多个任务在同一时刻真正同时执行,依赖多核 CPU。
2. 为什么需要它
- 并发:提高系统吞吐量,减少 I/O 等待时间
- 并行:利用多核 CPU,提高计算效率
3. 底层原理与完整流程
并发实现(单核):
时间轴:[线程1] [线程2] [线程1] [线程3] [线程2]
操作系统通过时间片(通常 5-10ms)在多个线程间快速切换。
并行实现(多核):
Core 0: [线程1] [线程3]
Core 1: [线程2] [线程4]
多个核心真正同时执行不同线程。
4. 怎么使用
// 并发:单线程内交替执行
new Thread(() -> {
// 任务 A
doTaskA();
}).start();
new Thread(() -> {
// 任务 B
doTaskB();
}).start();
// 并行:使用 ForkJoinPool
ForkJoinPool pool = new ForkJoinPool(4); // 4 核并行
pool.submit(() -> {
// 并行任务
}).join();
// CompletableFuture 实现并行
CompletableFuture<String> f1 = CompletableFuture.supplyAsync(taskA, pool);
CompletableFuture<String> f2 = CompletableFuture.supplyAsync(taskB, pool);
CompletableFuture.allOf(f1, f2).join();
5. 适用场景
- 并发:I/O 密集型任务,如数据库操作、网络请求
- 并行:CPU 密集型任务,如大数据处理、复杂计算
6. 不适用场景与替代方案
- 不要在单核 CPU 上做并行计算,会增加切换开销
- 不要在低并发场景下过度设计并行
- 替代方案:使用
Stream.parallel()自动并行化
7. 优缺点与技术取舍
| 维度 | 并发 | 并行 |
|---|---|---|
| CPU 需求 | 单核即可 | 需要多核 |
| 执行方式 | 交替执行 | 同时执行 |
| 适用场景 | I/O 密集 | CPU 密集 |
| 复杂度 | 较高(同步问题) | 高(数据竞争) |
8. 常见问题及解决方案
- 问题:并行流性能反而下降
- 解决:数据量小时并行开销更大,使用
parallel()前评估数据规模
- 解决:数据量小时并行开销更大,使用
9. 版本差异与实现边界
- Java 8 引入并行流
Stream.parallel() - ForkJoinPool 在 Java 7 引入,Java 8 优化
- 并行度默认等于 CPU 核数,可通过
System.setProperty调整
10. 常见追问
- 追问:并行一定比串行快吗?
- 回答方向:不一定,数据量小时并行开销可能超过收益。
11. 易错点
❌ 错误:并发就是并行
✅ 正确:并发是交替执行,并行是同时执行
❌ 错误:单核 CPU 不支持并发
✅ 正确:单核 CPU 通过时间片轮转支持并发
一句话总结
并发是宏观同时微观交替,并行是宏观微观都同时,并发解决 I/O 等待,并行利用多核计算。
同步和异步的区别是什么?
原始问法:
- 同步和异步的区别是什么?
来源题目:
SRC-03-31-103
面试先答
同步是指调用方发出请求后必须等待结果返回才能继续执行,异步是指调用方发出请求后无需等待,继续执行后续逻辑,结果通过回调、通知或轮询获取。同步的优点是逻辑简单、结果立即可用,缺点是阻塞;异步的优点是不阻塞、响应快,缺点是逻辑复杂。Java 中 Future/CompletableFuture、事件驱动模型、消息队列都是异步的实现方式。
核心结论
- 同步:阻塞等待结果,逻辑简单
- 异步:非阻塞,通过回调/通知获取结果
- 同步适合实时性要求高的场景
- 异步适合高并发、低延迟场景
1. 是什么
同步(Synchronous):调用方发出调用后阻塞等待结果返回。
异步(Asynchronous):调用方发出调用后不阻塞,结果通过回调或通知返回。
2. 为什么需要它
- 同步:保证数据一致性、逻辑清晰
- 异步:提高系统吞吐量、降低延迟
3. 底层原理与完整流程
同步调用流程:
调用方 → 请求 → 被调用方 → 处理 → 返回结果 → 调用方继续
↑阻塞等待↓
异步调用流程:
调用方 → 请求 → 被调用方
↓继续执行 ↓处理
回调/通知 ← 结果
4. 怎么使用
// 同步方式
String result = service.getData(); // 阻塞等待
// 异步方式 - Future
Future<String> future = executor.submit(() -> service.getData());
// 继续执行其他逻辑
String result = future.get(); // 需要时获取结果
// 异步方式 - CompletableFuture
CompletableFuture<String> future = CompletableFuture
.supplyAsync(() -> service.getData())
.thenApply(result -> processResult(result));
// 异步回调
future.thenAccept(result -> {
System.out.println("结果: " + result);
});
5. 适用场景
- 同步:数据库事务操作、需要立即返回结果的接口
- 异步:消息队列、异步日志、批量数据处理
6. 不适用场景与替代方案
- 不要在需要实时结果的场景使用异步
- 不要为了异步而异步,增加不必要的复杂度
- 替代方案:使用
CompletableFuture简化异步编程
7. 优缺点与技术取舍
| 维度 | 同步 | 异步 |
|---|---|---|
| 复杂度 | 简单 | 复杂 |
| 阻塞 | 是 | 否 |
| 实时性 | 高 | 低 |
| 吞吐量 | 低 | 高 |
8. 常见问题及解决方案
- 问题:异步回调嵌套过深(回调地狱)
- 解决:使用
CompletableFuture的链式调用
- 解决:使用
9. 版本差异与实现边界
- Java 5:
Future接口 - Java 8:
CompletableFuture大幅简化异步编程 - Reactor/Spring WebFlux:响应式编程
10. 常见追问
- 追问:
Future.get()是同步还是异步?- 回答方向:
get()本身是同步阻塞的,但submit()是异步提交。
- 回答方向:
11. 易错点
- ❌ 错误:异步一定比同步快
- ✅ 正确:异步吞吐量更高,但单次调用延迟可能更高
一句话总结
同步阻塞等待结果、逻辑简单,异步非阻塞、吞吐量高,根据场景选择合适的通信方式。
单核 CPU 支持 Java 多线程吗?
原始问法:
- 单核CPU支持Java多线程吗?
来源题目:
SRC-03-31-104
面试先答
单核 CPU 完全支持 Java 多线程。多线程是操作系统级别的特性,通过时间片轮转实现并发执行。单核 CPU 上的多线程是"并发"而非"并行"——多个线程在不同时间段交替执行,给人同时执行的错觉。Java 多线程在单核上的主要价值是处理 I/O 密集型任务(减少等待时间)和提高程序的响应性。对于 CPU 密集型任务,单核多线程反而可能因上下文切换而变慢。
核心结论
- 单核 CPU 支持多线程,通过时间片轮转实现并发
- 单核上的多线程是并发不是并行
- I/O 密集型任务在单核多线程上有收益
- CPU 密集型任务在单核多线程上可能更慢
1. 是什么
单核 CPU 通过操作系统的时间片轮转调度,支持多个线程并发执行。
2. 为什么需要它
- I/O 操作时 CPU 空闲,多线程可以利用等待时间执行其他任务
- 提高程序响应性,一个线程阻塞时其他线程可继续执行
3. 底层原理与完整流程
单核 CPU 调度流程:
- 操作系统为每个线程分配时间片(通常 5-10ms)
- 时间片用完或线程阻塞时,切换到下一个线程
- 切换涉及上下文保存/恢复(开销约微秒级)
4. 怎么使用
// 单核 CPU 上使用多线程 - I/O 密集型场景
new Thread(() -> {
// 数据库查询(I/O 等待时 CPU 可执行其他线程)
queryDatabase();
}).start();
new Thread(() -> {
// 同时处理其他任务
processOtherTask();
}).start();
5. 适用场景
- 单核多线程适用:I/O 密集型、事件驱动、提高响应性
- 单核多线程不适用:CPU 密集型(如下载大文件、大数据计算)
6. 不适用场景与替代方案
- 不要在单核上创建过多线程,会增加切换开销
- CPU 密集型任务建议单线程执行
- 替代方案:使用 NIO 单线程处理 I/O
7. 优缺点与技术取舍
| 维度 | 单核多线程 | 单核单线程 |
|---|---|---|
| I/O 等待 | 可执行其他任务 | 阻塞等待 |
| 切换开销 | 有 | 无 |
| CPU 利用率 | 更高(I/O 场景) | 更高(CPU 场景) |
| 复杂度 | 高 | 低 |
8. 常见问题及解决方案
- 问题:单核 CPU 上线程越多越慢
- 解决:对于 CPU 密集型任务减少线程数,对于 I/O 密集型任务适当增加
9. 版本差异与实现边界
- 所有版本的 Java 都支持单核多线程
- 操作系统调度由内核负责,与 JVM 版本无关
10. 常见追问
- 追问:单核 CPU 上线程数最佳实践?
- 回答方向:CPU 密集型=1,I/O 密集型=CPU核数 * (1 + I/O等待时间/CPU时间)。
11. 易错点
❌ 错误:单核 CPU 不能运行多线程
✅ 正确:单核 CPU 通过时间片轮转支持多线程
❌ 错误:多线程在所有场景下都更快
✅ 正确:CPU 密集型场景多线程在单核上更慢
一句话总结
单核 CPU 通过时间片轮转支持多线程并发执行,适合 I/O 密集型场景,不适合 CPU 密集型场景。
Java 的线程调度方式有哪些?
原始问法:
- Java的线程调度方式有哪些?
来源题目:
SRC-03-31-105
面试先答
Java 线程调度分为两种:抢占式调度和协作式调度。Java 默认使用抢占式调度,即每个线程由操作系统分配时间片,时间片用完后强制让出 CPU。同时 Java 也支持通过 Thread.yield() 主动让出 CPU(协作式),以及通过线程优先级 setPriority() 调整调度权重,但优先级不保证严格遵守。实际上 Java 的线程调度主要由操作系统内核负责,JVM 只是映射,Windows 采用抢占式,Linux 采用 CFS 完全公平调度。
核心结论
- Java 使用抢占式调度(默认)+ 协作式调度(可选)
- 线程优先级只是建议,不保证严格执行
- 实际调度由操作系统内核负责
Thread.yield()主动让出 CPU,但不保证成功
1. 是什么
抢占式调度:操作系统强制分配 CPU 时间片,时间到强制切换。
协作式调度:线程主动让出 CPU,需要线程配合。
2. 为什么需要它
- 抢占式:防止单个线程长时间占用 CPU,保证公平性
- 协作式:允许线程主动让出 CPU,提高响应性
3. 底层原理与完整流程
抢占式调度流程:
- 操作系统维护就绪队列
- 每个线程分配时间片(5-10ms)
- 时间片用完,强制调度下一个线程
- 高优先级线程可以抢占低优先级线程
协作式调度:
Thread.yield(); // 主动让出 CPU,但不保证成功
线程优先级:
thread.setPriority(Thread.MAX_PRIORITY); // 1-10
// 优先级只是给操作系统的建议
4. 怎么使用
// 设置线程优先级
Thread highPriority = new Thread(task);
highPriority.setPriority(Thread.MAX_PRIORITY); // 10
Thread lowPriority = new Thread(task);
lowPriority.setPriority(Thread.MIN_PRIORITY); // 1
// 主动让出 CPU
Thread.currentThread().yield();
// 线程中断(协作式取消)
thread.interrupt(); // 设置中断标志
5. 适用场景
- 抢占式:通用场景,保证公平性
- 协作式:需要精细控制的场景,如大任务分批执行
6. 不适用场景与替代方案
- 不要依赖线程优先级保证执行顺序
- 不要用
yield()做同步,不可靠 - 替代方案:使用
synchronized、Lock、同步工具类
7. 优缺点与技术取舍
| 维度 | 抢占式 | 协作式 |
|---|---|---|
| 公平性 | 高 | 依赖线程配合 |
| 响应性 | 高 | 可能低 |
| 实现复杂度 | 低 | 高 |
| CPU 利用率 | 可能低(切换多) | 高 |
8. 常见问题及解决方案
- 问题:设置高优先级线程没有先执行
- 解决:优先级只是建议,不保证严格遵守,依赖操作系统实现
9. 版本差异与实现边界
- Java 调度依赖操作系统:
- Windows:抢占式,优先级敏感
- Linux:CFS 完全公平调度,优先级影响小
- macOS:类似 Linux
Thread.yield()在不同 OS 上行为不同
10. 常见追问
- 追问:
Thread.sleep()和Thread.yield()的区别?- 回答方向:
sleep()阻塞指定时间,yield()只是让出时间片且不保证成功。
- 回答方向:
11. 易错点
❌ 错误:高优先级线程一定先执行
✅ 正确:优先级只是建议,不保证严格遵守
❌ 错误:
yield()一定让其他线程执行✅ 正确:
yield()只是建议让出 CPU,调度器可能立即重新选中当前线程
一句话总结
Java 默认抢占式调度,支持协作式让出,实际调度由操作系统决定,优先级和 yield() 都只是建议不保证执行。
3.2 线程池(上)
什么是线程池?为什么要使用线程池?如何给线程池命名?
原始问法:
- 什么是线程池?为什么要使用线程池?
- 如何给线程池命名?有什么作用?
来源题目:
SRC-03-32-107,SRC-03-32-115
面试先答
线程池是预先创建并维护一定数量线程的池子,任务提交后复用池中的线程执行,避免频繁创建销毁。使用线程池的核心原因有三:第一,降低资源消耗,复用已创建的线程;第二,提高响应速度,任务到达时无需等待线程创建;第三,统一管理线程的创建、销毁和监控。给线程池命名是为了便于问题排查,通过自定义 ThreadFactory 给每个线程加上有意义的前缀,如"订单处理-"、"邮件发送-",在 JStack 输出和日志中可以快速定位线程归属。
核心结论
- 线程池复用线程,避免频繁创建销毁
- 三大优势:降低消耗、提高响应、统一管理
- 通过
ThreadFactory给线程命名,便于监控排查
1. 是什么
线程池(ThreadPool):存放已创建线程的容器,任务提交时从池中取线程执行,用完后归还。
Java 中核心实现为 ThreadPoolExecutor。
2. 为什么需要它
- 频繁创建销毁线程开销大(涉及系统调用、栈内存分配)
- 线程数量难以控制,可能导致资源耗尽
- 缺乏统一的监控和管理手段
3. 底层原理与完整流程
线程池核心组件:
corePoolSize:核心线程数(常驻)maximumPoolSize:最大线程数workQueue:任务队列keepAliveTime:非核心线程空闲存活时间threadFactory:线程创建工厂rejectedExecutionHandler:拒绝策略
4. 怎么使用
// 自定义命名线程工厂
ThreadFactory namedFactory = new ThreadFactory() {
private final AtomicInteger counter = new AtomicInteger(0);
private final String prefix;
public NamedThreadFactory(String prefix) {
this.prefix = prefix;
}
@Override
public Thread newThread(Runnable r) {
Thread t = new Thread(r, prefix + "-" + counter.incrementAndGet());
t.setDaemon(false);
return t;
}
};
// 创建带命名的线程池
ExecutorService pool = new ThreadPoolExecutor(
4, 8, 60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(100),
new NamedThreadFactory("order-pool"),
new ThreadPoolExecutor.CallerRunsPolicy()
);
5. 适用场景
- 需要频繁创建线程的场景
- 高并发 Web 服务
- 异步任务处理
6. 不适用场景与替代方案
- 不要为每个简单任务创建独立的线程池
- 不要在关闭的线程池上提交任务
- 单任务场景:直接用
CompletableFuture
7. 优缺点与技术取舍
| 维度 | 线程池 | 手动创建线程 |
|---|---|---|
| 性能 | 高(复用) | 低(频繁创建销毁) |
| 可控性 | 高(统一管理) | 低 |
| 复杂度 | 需配置 | 简单 |
8. 常见问题及解决方案
- 问题:线程池命名后 JStack 仍看不清
- 解决:使用有意义的前缀,如业务域+功能
9. 版本差异与实现边界
- Java 5:引入
Executor框架和ThreadPoolExecutor - Java 7:
ForkJoinPool - Java 8:
CompletableFuture默认使用ForkJoinPool.commonPool()
10. 常见追问
- 追问:线程池名字在 JStack 中如何显示?
- 回答方向:
Thread.getName()返回的名字会出现在 JStack 输出中。
- 回答方向:
11. 易错点
- ❌ 错误:线程池越多越好
- ✅ 正确:线程池需要合理规划,过多反而增加开销
一句话总结
线程池复用线程、统一管理,通过命名便于监控,是生产环境并发编程的基础设施。
线程池的核心参数有哪些?拒绝策略有哪些?
原始问法:
- 线程池的核心参数有哪些?分别是什么含义?
- 线程池有哪些拒绝策略?分别是什么含义?
来源题目:
SRC-03-32-108,SRC-03-32-110
面试先答
ThreadPoolExecutor 有七个核心参数:corePoolSize(核心线程数)、maximumPoolSize(最大线程数)、keepAliveTime(非核心线程存活时间)、unit(存活时间单位)、workQueue(任务队列)、threadFactory(线程工厂)、handler(拒绝策略)。四种拒绝策略:AbortPolicy(抛异常,默认)、CallerRunsPolicy(调用线程执行)、DiscardPolicy(静默丢弃)、DiscardOldestPolicy(丢弃队列最老任务)。生产环境推荐自定义拒绝策略记录日志,避免任务丢失。
核心结论
- 七个参数:核心线程数、最大线程数、存活时间、时间单位、任务队列、线程工厂、拒绝策略
- 四种拒绝策略:抛异常、调用者执行、静默丢弃、丢弃最老
- 默认拒绝策略是
AbortPolicy(抛异常) - 生产环境推荐
CallerRunsPolicy或自定义记录日志
1. 是什么
七个核心参数:
| 参数 | 类型 | 含义 |
|---|---|---|
corePoolSize |
int | 常驻核心线程数 |
maximumPoolSize |
int | 最大线程数 |
keepAliveTime |
long | 非核心线程空闲存活时间 |
unit |
TimeUnit | 存活时间单位 |
workQueue |
BlockingQueue | 任务等待队列 |
threadFactory |
ThreadFactory | 线程创建工厂 |
handler |
RejectedExecutionHandler | 拒绝策略 |
四种拒绝策略:
| 策略 | 行为 | 适用场景 |
|---|---|---|
AbortPolicy |
抛 RejectedExecutionException |
默认,快速失败 |
CallerRunsPolicy |
调用线程执行任务 | 不允许任务丢失 |
DiscardPolicy |
静默丢弃,不抛异常 | 允许任务丢失 |
DiscardOldestPolicy |
丢弃队列最老任务 | 新任务更重要 |
2. 为什么需要它
- 不同业务场景需要不同的线程池配置
- 拒绝策略决定了系统过载时的行为
- 合理配置可避免系统崩溃和任务丢失
3. 底层原理与完整流程
public ThreadPoolExecutor(
int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler
)
4. 怎么使用
// 生产环境推荐配置
ThreadPoolExecutor executor = new ThreadPoolExecutor(
4, // corePoolSize
8, // maximumPoolSize
30L, // keepAliveTime
TimeUnit.SECONDS, // unit
new LinkedBlockingQueue<>(500), // workQueue(有界队列)
new ThreadFactoryBuilder()
.setNameFormat("biz-pool-%d")
.setDaemon(false)
.build(), // threadFactory
(r, e) -> {
log.warn("任务被拒绝,记录日志");
// 可选:存入数据库或重试队列
} // 自定义拒绝策略
);
5. 适用场景
AbortPolicy:关键业务,任务不能丢失CallerRunsPolicy:一般业务,允许降级DiscardPolicy:可丢弃的日志、通知DiscardOldestPolicy:实时性要求高
6. 不适用场景与替代方案
- 不要使用无界队列,会导致 OOM
- 不要用
Executors默认创建,参数不可控 - 替代方案:使用 Spring 的
ThreadPoolTaskExecutor
7. 优缺点与技术取舍
| 维度 | 有界队列 | 无界队列 |
|---|---|---|
| 内存安全 | 安全 | 可能 OOM |
| 线程复用 | 充分 | 不充分 |
| 任务堆积 | 有上限 | 无限堆积 |
8. 常见问题及解决方案
问题:使用无界
LinkedBlockingQueue导致 OOM- 解决:使用有界队列
问题:
AbortPolicy导致业务异常- 解决:改用
CallerRunsPolicy或自定义策略
- 解决:改用
9. 版本差异与实现边界
- 所有参数从 Java 5 就存在
Executors创建的线程池:newFixedThreadPool:无界队列newCachedThreadPool:最大线程数为 Integer.MAX_VALUEnewSingleThreadExecutor:无界队列- 生产环境应避免使用
10. 常见追问
- 追问:核心线程数可以动态调整吗?
- 回答方向:可以,通过
setCorePoolSize()和setMaximumPoolSize()。
- 回答方向:可以,通过
11. 易错点
- ❌ 错误:
maximumPoolSize一定生效 - ✅ 正确:如果使用无界队列,
maximumPoolSize不会触发
一句话总结
线程池七个参数控制行为,四种拒绝策略应对过载,生产环境必须自定义配置。
线程池的任务执行流程是怎样的?
原始问法:
- 线程池的任务执行流程是怎样的?
来源题目:
SRC-03-32-109
面试先答
线程池的任务执行流程遵循以下步骤:第一,判断核心线程数是否已满,未满则创建核心线程执行任务;第二,核心线程已满则尝试将任务加入队列;第三,队列已满则判断最大线程数是否已满,未满则创建非核心线程执行;第四,最大线程数已满则执行拒绝策略。这个流程的设计理念是优先复用核心线程,其次使用队列缓冲,最后才考虑扩容。使用无界队列时,最大线程数配置不会生效。
核心结论
- 执行流程:核心线程 → 队列 → 非核心线程 → 拒绝
- 无界队列时最大线程数不生效
- 非核心线程空闲超过
keepAliveTime后销毁
1. 是什么
任务提交后的判断流程图:
提交任务
↓
核心线程已满? ──否──→ 创建核心线程执行
↓是
队列已满? ──否──→ 加入队列等待
↓是
最大线程已满? ──否──→ 创建非核心线程执行
↓是
执行拒绝策略
2. 为什么需要它
- 优先使用核心线程,保证常驻线程的高复用
- 使用队列缓冲,应对突发流量
- 最后扩容非核心线程,兼顾吞吐量和资源
3. 底层原理与完整流程
ThreadPoolExecutor.execute() 源码流程:
public void execute(Runnable command) {
int c = ctl.get();
// 1. 核心线程未满
if (workerCountOf(c) < corePoolSize) {
if (addWorker(command, true)) return;
c = ctl.get();
}
// 2. 尝试加入队列
if (isRunning(c) && workQueue.offer(command)) {
// 重新检查状态
if (!isRunning(c) && remove(command))
reject(command);
else if (workerCountOf(c) == 0)
addWorker(null, false);
}
// 3. 尝试创建非核心线程
else if (!addWorker(command, false))
// 4. 执行拒绝策略
reject(command);
}
4. 怎么使用
// 合理配置参数
ThreadPoolExecutor pool = new ThreadPoolExecutor(
4, // corePoolSize
8, // maximumPoolSize
60, // keepAliveTime
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(100) // 有界队列
);
pool.execute(() -> {
// 任务逻辑
});
5. 适用场景
- 高并发 Web 服务
- 异步任务处理
- 批量数据处理
6. 不适用场景与替代方案
- 不要使用无界队列,会跳过最大线程限制
- 不要在任务中递归提交任务(可能死锁)
- 替代方案:
CompletableFuture、ForkJoinPool
7. 优缺点与技术取舍
流程设计保证了系统的稳定性:优先复用核心线程,通过队列削峰,最后才扩容。但队列选择不当会影响整体行为。
8. 常见问题及解决方案
- 问题:线程池参数设置后最大线程数无效
- 解决:检查是否使用了无界队列(如
LinkedBlockingQueue默认构造)
- 解决:检查是否使用了无界队列(如
9. 版本差异与实现边界
- 从 Java 5 起流程不变
Executors工具类简化了创建但隐藏了配置细节
10. 常见追问
- 追问:为什么要先加入队列再创建非核心线程?
- 回答方向:优先保证核心线程的复用,队列起到削峰填谷的作用。
11. 易错点
❌ 错误:提交任务一定会执行
✅ 正确:极端情况下会触发拒绝策略
❌ 错误:
maximumPoolSize总是生效✅ 正确:使用无界队列时不生效
一句话总结
线程池执行流程:核心线程→队列→非核心线程→拒绝,设计理念是优先复用、缓冲削峰、最后扩容。
常见的线程池有哪些?使用 Executors 创建线程池有什么坑?
原始问法:
- 常见的线程池有哪些?使用Executors创建线程池有什么坑?
来源题目:
SRC-03-32-111
面试先答
常见的线程池有四种:newFixedThreadPool(固定大小)、newCachedThreadPool(缓存型)、newSingleThreadExecutor(单线程)、newScheduledThreadPool(定时调度)。它们的底层都是 ThreadPoolExecutor。使用 Executors 创建线程池有两个大坑:第一,newFixedThreadPool 和 newSingleThreadExecutor 使用无界 LinkedBlockingQueue,高并发下可能导致 OOM;第二,newCachedThreadPool 的 maximumPoolSize 为 Integer.MAX_VALUE,可能创建大量线程导致 OOM。生产环境必须手动创建 ThreadPoolExecutor,合理设置所有参数。
核心结论
- 四种常用线程池,底层都是
ThreadPoolExecutor Executors创建的线程池有 OOM 风险- 生产环境必须手动配置
ThreadPoolExecutor - 禁止在生产代码中直接使用
Executors
1. 是什么
四种常用线程池:
| 线程池 | 特点 | 队列 | 最大线程数 |
|---|---|---|---|
newFixedThreadPool |
固定线程数 | 无界 LinkedBlockingQueue |
= corePoolSize |
newCachedThreadPool |
按需创建,空闲回收 | SynchronousQueue |
Integer.MAX_VALUE |
newSingleThreadExecutor |
单线程串行 | 无界 LinkedBlockingQueue |
1 |
newScheduledThreadPool |
定时调度 | DelayedWorkQueue |
可配置 |
2. 为什么需要它
Executors提供了快捷创建方式,简化使用- 但默认参数不适合生产环境,存在 OOM 风险
3. 底层原理与完整流程
Executors.newFixedThreadPool() 源码:
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(
nThreads, nThreads, 0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>() // 无界队列!
);
}
Executors.newCachedThreadPool() 源码:
public static ExecutorService newCachedThreadPool() {
return new ThreadPoolExecutor(
0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>()
);
}
4. 怎么使用
// 生产环境正确方式
int processors = Runtime.getRuntime().availableProcessors();
ThreadPoolExecutor executor = new ThreadPoolExecutor(
processors, // 核心线程数
processors * 2, // 最大线程数
60L, TimeUnit.SECONDS, // 存活时间
new ArrayBlockingQueue<>(200), // 有界队列
new ThreadFactoryBuilder()
.setNameFormat("app-pool-%d")
.build(),
new ThreadPoolExecutor.CallerRunsPolicy()
);
5. 适用场景
newFixedThreadPool:CPU 密集型任务newCachedThreadPool:短时异步任务newSingleThreadExecutor:串行任务newScheduledThreadPool:定时任务
6. 不适用场景与替代方案
- 不要在生产环境使用
Executors默认创建 - 替代方案:手动创建
ThreadPoolExecutor,或使用 SpringThreadPoolTaskExecutor
7. 优缺点与技术取舍
Executors 方便但不安全;手动创建复杂但可控。
8. 常见问题及解决方案
- 问题:
newFixedThreadPool导致 OOM- 解决:改用有界队列的
ThreadPoolExecutor
- 解决:改用有界队列的
9. 版本差异与实现边界
- 从 Java 5 起
Executors存在 - Alibaba Java 开发手册明确禁止生产环境使用
Executors
10. 常见追问
- 追问:
ForkJoinPool和ThreadPoolExecutor的区别?- 回答方向:
ForkJoinPool基于工作窃取算法,适合分治任务。
- 回答方向:
11. 易错点
- ❌ 错误:
Executors创建的线程池是安全的 - ✅ 正确:无界队列和超大最大线程数都可能导致 OOM
一句话总结
Executors 创建快捷但有 OOM 风险,生产环境必须手动创建 ThreadPoolExecutor 合理配置所有参数。
线程池参数可以动态调整吗?通过什么方式?
原始问法:
- 线程池参数可以动态调整吗?通过什么方式?
来源题目:
SRC-03-32-112
面试先答
ThreadPoolExecutor 的核心参数全部支持动态调整,包括:核心线程数(setCorePoolSize)、最大线程数(setMaximumPoolSize)、存活时间(setKeepAliveTime)、线程工厂(setThreadFactory)、拒绝策略(setRejectedExecutionHandler)。调整后立即对后续任务生效。队列类型无法动态调整,但可以通过 BlockingQueue 的实现间接地改变行为。线上可通过 JMX、Archaius 或自定义管理接口实现参数的动态调整。
核心结论
- 核心线程数、最大线程数可动态调整
- 存活时间、线程工厂、拒绝策略可动态调整
- 调整后立即生效
- 队列无法动态切换
1. 是什么
ThreadPoolExecutor 提供的动态调整方法:
public void setCorePoolSize(int corePoolSize)
public void setMaximumPoolSize(int maximumPoolSize)
public void setKeepAliveTime(long time, TimeUnit unit)
public void setThreadFactory(ThreadFactory threadFactory)
public void setRejectedExecutionHandler(RejectedExecutionHandler handler)
2. 为什么需要它
- 线上业务量变化,需要弹性调整
- 突发流量时扩容,低峰时缩容
- 运行时切换拒绝策略应对不同情况
3. 底层原理与完整流程
以 setCorePoolSize() 为例:
- 如果新值 > 旧值:立即创建新线程执行任务
- 如果新值 < 旧值:不影响已有线程,等空闲后自然回收
4. 怎么使用
ThreadPoolExecutor executor = new ThreadPoolExecutor(
4, 8, 60L, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(100)
);
// 动态调整核心线程数
executor.setCorePoolSize(8);
// 动态调整最大线程数
executor.setMaximumPoolSize(16);
// 动态调整拒绝策略
executor.setRejectedExecutionHandler(
new ThreadPoolExecutor.CallerRunsPolicy()
);
5. 适用场景
- 流量高峰时动态扩容
- 流量低谷时缩容节省资源
- 紧急情况下切换为降级策略
6. 不适用场景与替代方案
- 不要频繁调整参数,可能导致抖动
- 队列无法动态切换
- 替代方案:使用 Spring Cloud Gateway 的动态线程池配置
7. 优缺点与技术取舍
动态调整灵活但需要监控支撑,调整不当可能导致线程数剧烈波动。
8. 常见问题及解决方案
- 问题:动态调整后线程数立即变化
- 解决:核心线程数增加时立即创建,减少时等空闲回收
9. 版本差异与实现边界
- 从 Java 5 就支持动态调整
- Spring
ThreadPoolTaskExecutor也支持动态调整
10. 常见追问
- 追问:动态调整核心线程数时,已在执行的线程会被回收吗?
- 回答方向:不会,已在执行的线程继续执行完毕后才回收。
11. 易错点
- ❌ 错误:动态调整后所有线程立即按新参数执行
- ✅ 正确:已在执行的线程不受影响
一句话总结
ThreadPoolExecutor 支持动态调整核心参数,调整后立即生效,可用于弹性应对业务量变化。
线程池任务执行完后线程如何回收?
原始问法:
- 线程池任务执行完后线程如何回收?
来源题目:
SRC-03-32-113
面试先答
线程池中线程的回收分为两种情况:核心线程和非核心线程。核心线程默认不会回收,即使空闲也会一直存在(除非设置 allowCoreThreadTimeOut)。非核心线程在空闲超过 keepAliveTime 后自动回收。回收流程是:线程执行完任务后,进入 getTask() 方法从队列取任务,如果队列没有任务且空闲超时,线程退出。最终当 workerCount 降为 0 时,线程池的 runState 变为 TERMINATED。
核心结论
- 核心线程默认不回收,除非设置
allowCoreThreadTimeOut - 非核心线程空闲超过
keepAliveTime后回收 - 回收通过
getTask()返回null触发线程退出 - 线程池关闭时所有线程被中断回收
1. 是什么
线程回收机制:
| 线程类型 | 回收条件 | 默认行为 |
|---|---|---|
| 核心线程 | allowCoreThreadTimeOut=true 且空闲超时 |
不回收 |
| 非核心线程 | 空闲超过 keepAliveTime |
自动回收 |
2. 为什么需要它
- 避免长期空闲线程占用资源
- 核心线程常驻以提高响应速度
- 非核心线程按需回收以节省资源
3. 底层原理与完整流程
Worker 内部类的 runWorker() 方法:
final void runWorker(Worker w) {
Thread wt = Thread.currentThread();
Runnable task = w.firstTask;
w.firstTask = null;
boolean completedAbruptly = true;
try {
while (task != null || (task = getTask()) != null) {
// 执行任务
task.run();
// 清除 task 引用
task = null;
}
completedAbruptly = false;
} finally {
// 线程退出,处理回收
processWorkerExit(w, completedAbruptly);
}
}
getTask() 方法判断是否返回 null:
- 线程池正在关闭 → 返回
null - 工作线程数超过核心线程数且空闲超时 → 返回
null
4. 怎么使用
// 设置核心线程也可超时回收
ThreadPoolExecutor executor = new ThreadPoolExecutor(
4, 8, 60L, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(100)
);
executor.allowCoreThreadTimeOut(true); // 核心线程也可回收
// 优雅关闭线程池
executor.shutdown(); // 不再接受新任务,等待已有任务完成
executor.awaitTermination(60, TimeUnit.SECONDS); // 等待完成
5. 适用场景
- 长期固定负载:核心线程常驻
- 波动负载:非核心线程动态回收
- 资源紧张:开启核心线程超时回收
6. 不适用场景与替代方案
- 不要频繁创建销毁线程,失去线程池的意义
- 替代方案:使用
keepAliveTime合理配置
7. 优缺点与技术取舍
核心线程常驻保证响应速度,非核心线程回收节省资源,通过参数平衡两者。
8. 常见问题及解决方案
- 问题:线程池关闭时有任务丢失
- 解决:使用
shutdown()+awaitTermination()优雅关闭
- 解决:使用
9. 版本差异与实现边界
- 从 Java 5 起回收机制不变
allowCoreThreadTimeOut在 Java 5 就存在
10. 常见追问
- 追问:
shutdown()和shutdownNow()的区别?- 回答方向:
shutdown()等待任务完成,shutdownNow()立即中断所有线程。
- 回答方向:
11. 易错点
- ❌ 错误:核心线程执行完任务后立即回收
- ✅ 正确:核心线程默认常驻,循环等待新任务
一句话总结
核心线程常驻不回收,非核心线程空闲超时回收,通过 getTask() 判断退出,保证响应速度与资源节省的平衡。
线程池中线程异常后,会被销毁还是复用?
原始问法:
- 线程池中线程异常后,会被销毁还是复用?
来源题目:
SRC-03-32-114
面试先答
线程池中线程抛出未捕获异常后,会被销毁而非复用。ThreadPoolExecutor.Worker.runWorker() 方法中,如果任务执行时抛出 RuntimeException 且未被捕获,completedAbruptly 标志为 true,然后在 finally 块中调用 processWorkerExit() 移除该 Worker 并创建新的 Worker 替代。如果异常被 try-catch 捕获,线程会继续复用。线程池会自动补充新线程,保证 corePoolSize 数量。
核心结论
- 未捕获异常 → 线程销毁,新线程替代
- 已捕获异常 → 线程继续复用
- 核心线程异常后会补充新线程
- 可通过
afterExecute钩子处理异常
1. 是什么
异常处理流程在 runWorker() 中:
try {
while (task != null || (task = getTask()) != null) {
task.run(); // 异常在此抛出
}
completedAbruptly = false; // 正常完成
} finally {
processWorkerExit(w, completedAbruptly);
// completedAbruptly=true 时销毁线程并补充
}
2. 为什么需要它
- 防止异常线程影响后续任务
- 保证线程池的健壮性
- 自动维护线程数量
3. 底层原理与完整流程
- Worker 执行
task.run() - 如果抛出未捕获异常 →
completedAbruptly = true processWorkerExit()中判断completedAbruptly- 如果为 true →
addWorker()创建新线程替代 - 新线程与原线程同等配置
4. 怎么使用
// 方式一:任务内部捕获异常
executor.execute(() -> {
try {
doWork();
} catch (Exception e) {
log.error("任务异常", e);
}
});
// 方式二:覆盖 afterExecute 钩子
ThreadPoolExecutor executor = new ThreadPoolExecutor(
4, 8, 60L, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(100)
) {
@Override
protected void afterExecute(Runnable r, Throwable t) {
if (t != null) {
log.error("线程池任务异常", t);
}
}
};
5. 适用场景
- 所有线程池场景都需要处理异常
- 通过
afterExecute统一监控异常
6. 不适用场景与替代方案
- 不要依赖线程池自动补充线程来容错
- 替代方案:在任务内部 try-catch + 重试机制
7. 优缺点与技术取舍
自动补充保证线程数不减少,但频繁异常会导致线程频繁创建销毁。
8. 常见问题及解决方案
- 问题:线程池不断创建新线程
- 解决:检查是否有未捕获异常,修复业务逻辑
9. 版本差异与实现边界
- 从 Java 5 起行为一致
afterExecute钩子从 Java 5 就存在
10. 常见追问
- 追问:如何获取线程池中的异常?
- 回答方向:
Future.get()会抛出ExecutionException,或通过afterExecute钩子。
- 回答方向:
11. 易错点
- ❌ 错误:线程池会自动处理所有异常
- ✅ 正确:未捕获异常会导致线程销毁
一句话总结
线程池中未捕获异常导致线程销毁并自动补充,已捕获异常则继续复用,通过 afterExecute 统一处理异常。
线程池大小如何规划配置?CPU 密集型和 IO 密集型如何考量?
原始问法:
- 线程池大小如何规划配置?CPU密集型和IO密集型如何考量?
来源题目:
SRC-03-32-116
面试先答
线程池大小规划需要区分 CPU 密集型和 I/O 密集型:CPU 密集型的核心线程数一般设为 CPU 核心数 + 1(+1 防止页面缺失导致线程短暂阻塞),最大线程数与核心线程数相同即可;I/O 密集型的核心线程数可设为 CPU 核心数 ×(1 + I/O 等待时间/CPU 计算时间),通常在 CPU 核数的 2-4 倍。此外还需考虑:任务优先级、执行时长、系统内存、最大并发请求数等因素。最终应通过压测确定最佳值。
核心结论
- CPU 密集型:corePoolSize = CPU 核数 + 1
- I/O 密集型:corePoolSize = CPU 核数 × (1 + I/O等待时间/CPU计算时间)
- 需配合压测确定最优值
- 不能脱离实际场景空谈配置
1. 是什么
CPU 密集型:大量计算、序列化、加密等,CPU 使用率接近 100%。
I/O 密集型:数据库查询、网络请求、文件读写等,CPU 等待 I/O 数据。
2. 为什么需要它
- 线程数过少:CPU 利用率不足
- 线程数过多:上下文切换开销增大
- 合理配置可最大化吞吐量
3. 底层原理与完整流程
CPU 密集型公式:
corePoolSize = N + 1
N = CPU 核心数
+1 是为了防止某个线程因页面缺失或其他原因短暂阻塞时,CPU 能继续工作。
I/O 密集型公式:
corePoolSize = N × (1 + W/C)
N = CPU 核心数
W = I/O 等待时间
C = CPU 计算时间
4. 怎么使用
int cpuCores = Runtime.getRuntime().availableProcessors();
// CPU 密集型
int cpuIntensivePoolSize = cpuCores + 1;
ThreadPoolExecutor cpuPool = new ThreadPoolExecutor(
cpuIntensivePoolSize,
cpuIntensivePoolSize,
0L, TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(200)
);
// I/O 密集型(假设 I/O 等待时间是计算时间的 4 倍)
int ioIntensivePoolSize = cpuCores * (1 + 4); // CPU核数 * 5
ThreadPoolExecutor ioPool = new ThreadPoolExecutor(
ioIntensivePoolSize,
ioIntensivePoolSize,
60L, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(500)
);
5. 适用场景
- CPU 密集型:大数据计算、视频编码、加解密
- I/O 密集型:Web 服务、数据库访问、API 调用
6. 不适用场景与替代方案
- 不要盲目套用公式,必须结合压测
- 不要考虑 CPU 超线程,按物理核数计算
- 替代方案:使用动态调整 + 监控
7. 优缺点与技术取舍
| 类型 | 线程数 | 原因 |
|---|---|---|
| CPU 密集 | N+1 | 最大化 CPU 利用率 |
| I/O 密集 | N×(1+W/C) | 利用 I/O 等待时间 |
8. 常见问题及解决方案
- 问题:按公式配置后性能不如预期
- 解决:通过压测调整,考虑 GC、JIT、上下文切换等因素
9. 版本差异与实现边界
- 线程池大小规划与 JDK 版本无关
- 需考虑 CPU 架构:超线程、大小核等
10. 常见追问
- 追问:如何估算 I/O 等待时间和 CPU 计算时间?
- 回答方向:通过性能监控工具(如 JFR)测量,或通过 APM 工具采集。
11. 易错点
- ❌ 错误:线程数越多性能越好
- ✅ 正确:超过最优值后,上下文切换开销反而降低性能
一句话总结
CPU 密集型线程数为 N+1,I/O 密集型为 N×(1+W/C),最终通过压测确定最优配置。
什么是 Future 类?有什么作用?
原始问法:
- 什么是Future类?有什么作用?
来源题目:
SRC-03-32-117
面试先答
Future 是 Java 5 引入的异步计算结果接口,代表一个异步操作的未来结果。它提供了:判断任务是否完成(isDone())、获取结果(get(),阻塞等待)、取消任务(cancel())等能力。Future 本身是接口,常用实现为 FutureTask。Future 的核心价值是将同步调用转换为异步调用——提交任务后立即返回,结果通过 Future 在需要时获取。但 Future 有局限:阻塞获取、无法链式调用、无法组合多个 Future。
核心结论
Future是异步结果容器,提供获取、取消、判断完成等能力- 核心方法:
get()、cancel()、isDone()、isCancelled() FutureTask是Future的实现类,同时实现了Runnable- 局限:阻塞获取、无法链式组合
1. 是什么
public interface Future<V> {
boolean cancel(boolean mayInterruptIfRunning);
boolean isCancelled();
boolean isDone();
V get() throws InterruptedException, ExecutionException;
V get(long timeout, TimeUnit unit) throws ...;
}
2. 为什么需要它
- 异步执行任务,不阻塞主线程
- 可以控制任务的生命周期(取消、判断完成)
- 将异步结果与执行逻辑解耦
3. 底层原理与完整流程
FutureTask 状态机:
NEW → COMPLETING → NORMAL(正常完成)
→ EXCEPTIONAL(异常完成)
→ CANCELLED(取消)
get() 流程:
- 检查状态,若已完成则直接返回结果
- 若未完成,进入等待队列
- 任务完成后唤醒等待线程
- 返回结果或抛出异常
4. 怎么使用
// 使用 Callable + Future
ExecutorService executor = Executors.newCachedThreadPool();
Future<String> future = executor.submit(() -> {
return "Hello Future";
});
// 其他业务逻辑
doOtherWork();
// 获取结果(阻塞)
String result = future.get();
// 带超时获取
try {
String result = future.get(5, TimeUnit.SECONDS);
} catch (TimeoutException e) {
future.cancel(true); // 超时则取消
}
// 直接使用 FutureTask
FutureTask<String> task = new FutureTask<>(() -> "Result");
new Thread(task).start();
String result = task.get();
5. 适用场景
- 异步获取计算结果
- 并行执行多个独立任务
- 需要超时控制的异步调用
6. 不适用场景与替代方案
- 不要用
Future做链式调用和组合 - 不要在提交后立即
get(),失去异步意义 - 替代方案:
CompletableFuture
7. 优缺点与技术取舍
| 维度 | Future | CompletableFuture |
|---|---|---|
| 链式调用 | 不支持 | 支持 |
| 组合 | 不支持 | 支持 |
| 回调 | 不支持 | 支持 |
| 复杂度 | 简单 | 稍复杂 |
8. 常见问题及解决方案
- 问题:
get()阻塞导致性能问题- 解决:使用带超时的
get()或改用CompletableFuture
- 解决:使用带超时的
9. 版本差异与实现边界
- Java 5:引入
Future、FutureTask、Callable - Java 8:
CompletableFuture扩展了Future接口
10. 常见追问
- 追问:
Future和FutureTask的区别?- 回答方向:
Future是接口,FutureTask是实现类且实现了Runnable。
- 回答方向:
11. 易错点
- ❌ 错误:
Future.get()会立即返回 - ✅ 正确:
get()阻塞等待任务完成
一句话总结
Future 是异步结果容器,提供获取、取消、判断能力,简单但功能有限,复杂场景使用 CompletableFuture。
CompletableFuture 相比 Future 有什么优势?
原始问法:
- CompletableFuture相比Future有什么优势?
来源题目:
SRC-03-32-118
面试先答
CompletableFuture 是 Java 8 引入的 Future 增强版,相比传统 Future 有三大优势:第一,支持链式调用(thenApply、thenAccept),实现异步流水线;第二,支持组合多个 CompletableFuture(thenCombine、allOf、anyOf),实现复杂异步编排;第三,支持回调式编程(whenComplete、exceptionally),无需阻塞等待。此外还支持手动完成(complete)、异常处理(exceptionally)等。CompletableFuture 的引入使 Java 拥有了类似 JavaScript Promise 的异步编程能力。
核心结论
- 支持链式调用、组合编排、回调处理
- 支持手动完成和异常处理
- 类似 JavaScript Promise 的编程模型
- 是 Java 异步编程的首选
1. 是什么
public class CompletableFuture<T> implements Future<T>, CompletionStage<T> {
// 创建
static CompletableFuture<T> supplyAsync(Supplier<T> supplier);
static CompletableFuture<T> runAsync(Runnable runnable);
// 链式调用
<U> CompletableFuture<U> thenApply(Function<T, U> fn);
CompletableFuture<Void> thenAccept(Consumer<T> action);
// 组合
<U,V> CompletableFuture<V> thenCombine(CompletionStage<U> other, BiFunction<T,U,V> fn);
static CompletableFuture<Void> allOf(CompletableFuture<?>... cfs);
static CompletableFuture<Object> anyOf(CompletableFuture<?>... cfs);
// 异常处理
CompletableFuture<T> exceptionally(Function<Throwable, T> fn);
CompletableFuture<T> whenComplete(BiConsumer<T, Throwable> action);
}
2. 为什么需要它
- 传统
Future只能阻塞获取结果,无法链式处理 - 多任务组合需要手动编排
CompletableFuture让异步编程更自然、更灵活
3. 底层原理与完整流程
CompletableFuture 内部使用:
volatile状态字段(NEW、COMPLETED、CANCELLED)- Treiber 栈保存等待者(通过 CAS 操作)
- 完成时遍历栈触发回调
回调链在 complete() 方法中触发,通过 uniApply、biApply 等方法传播。
4. 怎么使用
// 链式调用
CompletableFuture.supplyAsync(() -> fetchUser())
.thenApply(user -> user.getOrders())
.thenAccept(orders -> System.out.println("订单: " + orders))
.exceptionally(ex -> {
log.error("异常", ex);
return null;
});
// 组合多个任务
CompletableFuture<String> userFuture =
CompletableFuture.supplyAsync(() -> fetchUser());
CompletableFuture<List> orderFuture =
CompletableFuture.supplyAsync(() -> fetchOrders());
// 两个都完成后处理
CompletableFuture.allOf(userFuture, orderFuture)
.thenRun(() -> {
User user = userFuture.join();
List orders = orderFuture.join();
// 处理
});
// 任意一个完成就处理
CompletableFuture.anyOf(fastApi, slowApi)
.thenAccept(result -> {
// 使用最先返回的结果
});
5. 适用场景
- 异步 API 调用
- 并行获取多个独立数据
- 流式数据处理管道
- 替代传统的回调地狱
6. 不适用场景与替代方案
- 不要在简单同步场景中过度使用
- 不要忘记异常处理
- 替代方案:RxJava、Reactor
7. 优缺点与技术取舍
| 维度 | Future | CompletableFuture |
|---|---|---|
| 链式 | 不支持 | 支持 |
| 组合 | 不支持 | 支持 |
| 回调 | 不支持 | 支持 |
| 学习曲线 | 平缓 | 稍陡 |
8. 常见问题及解决方案
问题:回调链中的异常丢失
- 解决:在每个
thenApply后添加exceptionally或handle
- 解决:在每个
问题:
join()阻塞在主线程- 解决:使用非阻塞方式,通过回调处理结果
9. 版本差异与实现边界
- Java 8:引入
CompletableFuture - Java 9:添加了
orTimeout()、completeOnTimeout()等超时方法 - Java 12:改进了
exceptionallyCompose等方法
10. 常见追问
- 追问:
CompletableFuture和 RxJava 的区别?- 回答方向:
CompletableFuture是 JDK 标准库,RxJava 是第三方库,功能更强大。
- 回答方向:
11. 易错点
- ❌ 错误:
thenApply中的异常会自动传播 - ✅ 正确:需要通过
exceptionally或handle显式处理
一句话总结
CompletableFuture 支持链式调用、组合编排和回调处理,是 Java 异步编程的首选,功能类似 JavaScript Promise。
3.2 线程池(下)—— 任务依赖设计
一个任务依赖另外两个任务才能执行,该怎么设计?
原始问法:
- 一个任务依赖另外两个任务才能执行,该怎么设计?
来源题目:
SRC-03-32-119
面试先答
任务依赖场景有多种实现方式,按复杂度递增:第一,使用 CountDownLatch,前置任务完成后 countDown(),后置任务 await() 等待;第二,使用 CyclicBarrier,所有前置任务在屏障处等待,全部到达后同时放行;第三,使用 CompletableFuture.allOf(),将多个 CompletableFuture 组合,全部完成后触发回调;第四,使用线程池 + 阻塞队列,生产者-消费者模型。推荐使用 CompletableFuture 或 CountDownLatch,代码简洁且功能完善。
核心结论
- 四种实现方式:
CountDownLatch、CyclicBarrier、CompletableFuture.allOf()、生产者-消费者 - 推荐
CompletableFuture和CountDownLatch - 简单一次性等待用
CountDownLatch - 需要循环重复用
CyclicBarrier - 需要链式组合用
CompletableFuture
1. 是什么
任务依赖是指某个任务(Task C)必须在其他任务(Task A、Task B)都完成后才能开始执行。
2. 为什么需要它
- 并行任务的后续处理
- 数据聚合场景(多个数据源全部返回后统一处理)
- 分阶段并行计算
3. 底层原理与完整流程
方案一:CountDownLatch
CountDownLatch latch = new CountDownLatch(2); // 计数=依赖的任务数
// 前置任务 A
new Thread(() -> {
doTaskA();
latch.countDown(); // 完成后 -1
}).start();
// 前置任务 B
new Thread(() -> {
doTaskB();
latch.countDown(); // 完成后 -1
}).start();
// 后置任务 C
new Thread(() -> {
latch.await(); // 等待计数归零
doTaskC();
}).start();
方案二:CompletableFuture.allOf()
CompletableFuture<Void> futureA = CompletableFuture.runAsync(() -> doTaskA());
CompletableFuture<Void> futureB = CompletableFuture.runAsync(() -> doTaskB());
CompletableFuture.allOf(futureA, futureB)
.thenRun(() -> doTaskC()); // A 和 B 都完成后执行 C
方案三:CyclicBarrier
CyclicBarrier barrier = new CyclicBarrier(3, () -> {
doTaskC(); // 屏障触发任务
});
new Thread(() -> {
doTaskA();
barrier.await(); // 等待其他参与者
}).start();
new Thread(() -> {
doTaskB();
barrier.await();
}).start();
// 参与者 3
barrier.await(); // 主线程也参与
4. 怎么使用
生产环境推荐 CompletableFuture:
public class TaskDependencyDemo {
private final ExecutorService executor =
Executors.newFixedThreadPool(4);
public void executeWithDependency() {
// 并行执行两个前置任务
CompletableFuture<String> futureA =
CompletableFuture.supplyAsync(this::fetchDataA, executor);
CompletableFuture<String> futureB =
CompletableFuture.supplyAsync(this::fetchDataB, executor);
// 全部完成后执行后置任务
CompletableFuture.allOf(futureA, futureB)
.thenRun(() -> {
String resultA = futureA.join();
String resultB = futureB.join();
processResult(resultA, resultB);
})
.exceptionally(ex -> {
log.error("任务执行异常", ex);
return null;
});
}
private String fetchDataA() { /* ... */ }
private String fetchDataB() { /* ... */ }
private void processResult(String a, String b) { /* ... */ }
}
5. 适用场景
- 数据聚合:多个 API 调用完成后汇总
- 并行计算:分块计算后合并结果
- 初始化流程:多个组件初始化完成后启动服务
6. 不适用场景与替代方案
- 不要用
Thread.join()手动实现,太繁琐 - 不要用
wait/notify实现,容易出错 - 替代方案:使用消息队列实现异步解耦
7. 优缺点与技术取舍
| 方案 | 优点 | 缺点 |
|---|---|---|
CountDownLatch |
简单直接 | 一次性不可重置 |
CyclicBarrier |
可循环使用 | 所有参与者必须到位 |
CompletableFuture |
链式组合、异常处理 | 学习曲线稍高 |
| 生产者-消费者 | 解耦 | 需额外实现 |
8. 常见问题及解决方案
问题:
CountDownLatch的countDown()在异常中未调用- 解决:在
finally块中调用countDown()
- 解决:在
问题:某个前置任务永久阻塞
- 解决:使用带超时的
await(timeout)或completeOnTimeout()
- 解决:使用带超时的
9. 版本差异与实现边界
CountDownLatch:Java 5CyclicBarrier:Java 5CompletableFuture:Java 8CompletableFuture.orTimeout():Java 9+
10. 常见追问
- 追问:
CountDownLatch和CyclicBarrier的区别?- 回答方向:
CountDownLatch一次性、一个线程等待多个;CyclicBarrier可循环、多个线程相互等待。
- 回答方向:
11. 易错点
- ❌ 错误:
CountDownLatch可以重复使用 - ✅ 正确:
CountDownLatch是一次性的,需要重用请用CyclicBarrier
一句话总结
任务依赖有多种实现方案,推荐使用 CompletableFuture.allOf() 或 CountDownLatch,根据是否需要重用和复杂度选择。
3.3 锁机制(上)
什么是死锁?死锁的四个必要条件是什么?
原始问法:
- 什么是死锁?死锁的四个必要条件是什么?
来源题目:
SRC-03-33-120
面试先答
死锁是指两个或多个线程互相持有对方需要的资源,并且都不释放,导致永远阻塞的状态。死锁的四个必要条件(缺一不可):第一,互斥条件——资源一次只能被一个线程使用;第二,占有且等待条件——持有资源同时等待其他资源;第三,不可剥夺条件——已获得的资源不能被强制夺走;第四,循环等待条件——形成等待环路。四个条件同时满足就会发生死锁,破坏任意一个条件即可避免。
核心结论
- 死锁是多线程互相持有对方所需资源导致的永久阻塞
- 四个必要条件:互斥、占有且等待、不可剥夺、循环等待
- 破坏任一条件即可避免死锁
- 死锁一旦发生无法自动恢复
1. 是什么
死锁(Deadlock):多个线程在执行过程中,因争夺资源而造成的一种互相等待的现象。
线程 A:持有资源 1,等待资源 2
线程 B:持有资源 2,等待资源 1
→ 死锁!
2. 为什么需要它
理解死锁的成因是预防和解决死锁的前提。死锁是并发编程中最严重的问题之一,会导致系统卡死。
3. 底层原理与完整流程
四个必要条件:
- 互斥条件(Mutual Exclusion):一个资源每次只能被一个线程使用
- 占有且等待条件(Hold and Wait):一个线程因请求资源而阻塞时,对已获得的资源保持不放
- 不可剥夺条件(No Preemption):线程已获得的资源,在未使用完之前不能强行剥夺
- 循环等待条件(Circular Wait):若干线程形成一种头尾相接的循环等待资源关系
4. 怎么使用
死锁示例:
Object lock1 = new Object();
Object lock2 = new Object();
// 线程 A
new Thread(() -> {
synchronized (lock1) {
// 持有 lock1,等待 lock2
synchronized (lock2) {
// 死锁!
}
}
}).start();
// 线程 B
new Thread(() -> {
synchronized (lock2) {
// 持有 lock2,等待 lock1
synchronized (lock1) {
// 死锁!
}
}
}).start();
5. 适用场景
死锁是所有并发系统都可能遇到的问题,必须在设计阶段预防。
6. 不适用场景与替代方案
- 不要在持有锁的情况下获取其他锁
- 替代方案:统一加锁顺序、使用
tryLock超时获取
7. 优缺点与技术取舍
死锁没有任何优点,必须通过设计避免。常见的预防策略:
- 破坏占有且等待:一次性申请所有资源
- 破坏不可剥夺:使用
tryLock超时 - 破坏循环等待:按固定顺序获取锁
8. 常见问题及解决方案
- 问题:线上出现死锁
- 解决:通过
jstack查看线程栈,找到互相等待的锁
- 解决:通过
9. 版本差异与实现边界
- 死锁是 OS 层面的概念,所有版本的 Java 都可能发生
- JVM 提供了
jstack、jcmd Thread.print等诊断工具
10. 常见追问
- 追问:如何通过 jstack 检测死锁?
- 回答方向:
jstack <pid>输出中会明确标注 "Found one Java-level deadlock"。
- 回答方向:
11. 易错点
- ❌ 错误:死锁可以通过 JVM 自动检测并恢复
- ✅ 正确:死锁一旦发生,必须人工介入处理
一句话总结
死锁是四条件同时满足导致的永久阻塞,破坏任一条件可预防,统一加锁顺序是最实用的方案。
如何检测死锁?如何避免死锁?
原始问法:
- 如何检测死锁?如何避免死锁?
来源题目:
SRC-03-33-121
面试先答
死锁检测和避免是两个层面的问题:检测可以通过 jstack 查看线程栈、ThreadMXBean 编程检测、或使用 jcmd Thread.print;避免则从设计层面解决,常用方法有四种:第一,统一加锁顺序,所有线程按固定顺序获取锁;第二,一次性申请所有资源;第三,使用 tryLock 超时获取锁,超时释放已持有的锁并重试;第四,使用 Lock 的可中断获取。生产实践中最有效的是统一加锁顺序,从根源消除循环等待。
核心结论
- 检测方法:
jstack、ThreadMXBean、jcmd - 避免方法:统一加锁顺序、一次性申请、超时获取、可中断获取
- 最实用的是统一加锁顺序
- 定期审查代码中的嵌套加锁
1. 是什么
死锁检测:运行时发现已经发生的死锁。
死锁避免:设计时预防死锁的发生。
2. 为什么需要它
- 死锁一旦发生无法自动恢复
- 检测帮助快速定位问题
- 避免是根本解决方案
3. 底层原理与完整流程
检测方法:
// 编程式检测
ThreadMXBean mxBean = ManagementFactory.getThreadMXBean();
long[] threadIds = mxBean.findDeadlockedThreads();
if (threadIds != null) {
// 发现死锁
ThreadInfo[] infos = mxBean.getThreadInfo(threadIds, true, true);
for (ThreadInfo info : infos) {
System.out.println(info.getThreadName() +
" waiting for " + info.getLockName());
}
}
避免方法一:统一加锁顺序
// 所有线程都按 lock1 → lock2 顺序获取
synchronized (lock1) {
synchronized (lock2) {
// 业务逻辑
}
}
避免方法二:tryLock 超时
ReentrantLock lock1 = new ReentrantLock();
ReentrantLock lock2 = new ReentrantLock();
while (true) {
if (lock1.tryLock()) {
try {
if (lock2.tryLock(1, TimeUnit.SECONDS)) {
try {
// 业务逻辑
break;
} finally {
lock2.unlock();
}
}
} finally {
lock1.unlock();
}
}
// 短暂重试
Thread.sleep(100);
}
4. 怎么使用
生产环境最佳实践:
// 1. 统一加锁顺序(最重要)
// 所有涉及这两把锁的代码,都按 lockA → lockB 顺序获取
// 2. 使用 tryLock 而非 synchronized
try {
if (lock.tryLock(5, TimeUnit.SECONDS)) {
// 业务逻辑
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
// 3. 定期审计:避免嵌套锁获取
// 代码审查时重点关注 synchronized 嵌套
5. 适用场景
- 所有涉及多锁的并发场景
- 需要可靠防止死锁的系统
6. 不适用场景与替代方案
- 不要在持有锁时调用外部方法(可能导致嵌套获取)
- 替代方案:使用
ConcurrentHashMap分段锁、无锁数据结构
7. 优缺点与技术取舍
| 方法 | 优点 | 缺点 |
|---|---|---|
| 统一加锁顺序 | 简单可靠 | 需全团队遵守 |
| 一次性申请 | 彻底避免 | 可能长时间阻塞 |
| tryLock 超时 | 灵活 | 需重试逻辑 |
| 可中断获取 | 可响应中断 | 代码复杂 |
8. 常见问题及解决方案
- 问题:第三方库内部加锁顺序不可控
- 解决:使用
tryLock超时策略
- 解决:使用
9. 版本差异与实现边界
ThreadMXBean.findDeadlockedThreads()从 Java 5 就存在- JDK 21 中虚拟线程的死锁检测有所不同
10. 常见追问
- 追问:
tryLock超时后如何处理?- 回答方向:释放已持有锁、短暂等待后重试,或向上抛出让调用方处理。
11. 易错点
- ❌ 错误:
synchronized不会死锁 - ✅ 正确:
synchronized同样会发生死锁
一句话总结
通过 jstack 检测死锁,通过统一加锁顺序、超时获取等方法避免,统一加锁顺序是最实用的方案。
什么是悲观锁?什么是乐观锁?分别的适用场景?
原始问法:
- 什么是悲观锁?什么是乐观锁?分别的适用场景?
来源题目:
SRC-03-33-122
面试先答
悲观锁认为并发冲突一定会发生,所以每次操作前先加锁(如 synchronized、ReentrantLock),适合写多或冲突频繁的场景。乐观锁认为并发冲突不会发生,操作时不加锁,更新时检查数据是否被修改(如 CAS、版本号机制),适合读多写少或冲突少的场景。Java 中 synchronized 和 ReentrantLock 是悲观锁,AtomicInteger、StampedLock、数据库版本号是乐观锁。注意:synchronized 在 JDK 6 后引入了偏向锁和轻量级锁,会在竞争少的时候表现出乐观特性。
核心结论
- 悲观锁:先加锁再操作,适合写多冲突多的场景
- 乐观锁:先操作再检查,适合读多冲突少的场景
synchronized/ReentrantLock是悲观锁- CAS/版本号/时间戳是乐观锁实现
1. 是什么
悲观锁(Pessimistic Locking):假设冲突必然发生,每次操作前先获取锁。
乐观锁(Optimistic Locking):假设冲突不会发生,操作时不加锁,更新时检查是否被修改。
2. 为什么需要它
- 悲观锁保证数据一致性,但开销大
- 乐观锁无阻塞,吞吐量高,但冲突时需重试
3. 底层原理与完整流程
悲观锁流程:
获取锁 → 操作数据 → 释放锁
乐观锁流程:
读取数据(带版本号)→ 操作数据 → 检查版本号是否变化 → 更新或重试
4. 怎么使用
// 悲观锁:synchronized
public synchronized void update() {
count++; // 加锁后操作
}
// 乐观锁:AtomicInteger
AtomicInteger count = new AtomicInteger(0);
count.incrementAndGet(); // CAS 实现,乐观锁
// 乐观锁:版本号(数据库)
// UPDATE table SET value=new, version=version+1
// WHERE id=? AND version=old_version
5. 适用场景
- 悲观锁适用:写操作多、冲突频繁、一致性要求高
- 如:金融交易、库存扣减
- 乐观锁适用:读多写少、冲突少、容忍重试
- 如:统计计数、配置更新
6. 不适用场景与替代方案
- 不要在高冲突场景使用乐观锁(大量重试反而更慢)
- 不要在低冲突场景全用悲观锁(不必要的开销)
- 替代方案:
StampedLock可在悲观和乐观之间切换
7. 优缺点与技术取舍
| 维度 | 悲观锁 | 乐观锁 |
|---|---|---|
| 吞吐量 | 低 | 高 |
| 一致性 | 强 | 需重试 |
| 冲突处理 | 阻塞等待 | 失败重试 |
| 实现复杂度 | 简单 | 稍复杂 |
8. 常见问题及解决方案
- 问题:乐观锁大量冲突导致高 CPU
- 解决:改用悲观锁,或评估冲突根源
9. 版本差异与实现边界
synchronized在 JDK 6 引入锁升级(偏向锁→轻量锁→重量锁),低竞争时表现乐观特性StampedLock(JDK 8)支持乐观读模式
10. 常见追问
- 追问:
synchronized是乐观锁还是悲观锁?- 回答方向:本质是悲观锁,但锁升级过程中会有偏向锁和轻量级锁的优化,低竞争时接近乐观锁性能。
11. 易错点
- ❌ 错误:乐观锁一定比悲观锁快
- ✅ 正确:冲突多时乐观锁重试开销更大
一句话总结
悲观锁先加锁操作、适合高冲突;乐观锁先操作检查、适合低冲突,根据场景选择锁策略。
如何实现乐观锁?
原始问法:
- 如何实现乐观锁?
来源题目:
SRC-03-33-123
面试先答
乐观锁的实现方式主要有三种:第一,CAS(Compare-And-Swap),CPU 指令级别的原子操作,Java 原子类基于此实现;第二,版本号机制,读取数据时获取版本号,更新时比较版本号是否变化,广泛用于数据库;第三,时间戳机制,类似版本号但用时间戳比较。核心思想都是"先操作后检查"——不加锁执行操作,提交时判断数据是否被其他线程修改过,若被修改则放弃并重试。Java 中 AtomicInteger、LongAdder、StampedLock 的乐观读模式都是乐观锁的实现。
核心结论
- 三种实现:CAS、版本号、时间戳
- 核心思想:先操作后检查
- CAS 是最常用的乐观锁实现
- 冲突时需重试
1. 是什么
CAS(Compare-And-Swap):CPU 级别的原子指令,比较内存值与期望值,相同则修改。
版本号:数据中增加 version 字段,更新时比较。
2. 为什么需要它
- 避免悲观锁的阻塞开销
- 提高系统吞吐量
- 适合读多写少场景
3. 底层原理与完整流程
CAS 流程:
1. 读取内存值 V
2. 比较 V 与预期值 A
3. 如果 V == A,则写入新值 B → 成功
4. 如果 V != A,则重试 → 失败
版本号流程:
1. 读取数据和版本号 version=1
2. 修改数据
3. UPDATE ... SET value=?, version=2 WHERE id=? AND version=1
4. 如果影响行数=1 → 成功
5. 如果影响行数=0 → 重试(版本号已变化)
4. 怎么使用
// CAS 实现:Java 原子类
AtomicInteger count = new AtomicInteger(0);
int oldValue = count.get();
int newValue = oldValue + 1;
boolean success = count.compareAndSet(oldValue, newValue);
// 版本号实现(伪代码)
@Entity
public class Product {
private Long id;
private int stock;
@Version
private Long version; // JPA 乐观锁版本号
}
// 数据库版本号
UPDATE product SET stock = stock - 1, version = version + 1
WHERE id = 1 AND version = 100;
5. 适用场景
- CAS:计数器、状态标志、无锁数据结构
- 版本号:数据库并发更新、订单状态变更
6. 不适用场景与替代方案
- 不要在高冲突场景使用乐观锁
- 替代方案:使用分布式锁(Redis)、悲观锁
7. 优缺点与技术取舍
| 实现方式 | 优点 | 缺点 |
|---|---|---|
| CAS | CPU 级原子,高性能 | ABA 问题、自旋开销 |
| 版本号 | 简单直观 | 需数据库支持 |
| 时间戳 | 解决 ABA | 时间精度问题 |
8. 常见问题及解决方案
- 问题:CAS 自旋导致 CPU 高
- 解决:使用
LongAdder分拆累加、添加退避策略
- 解决:使用
9. 版本差异与实现边界
- CAS 在所有现代 CPU 上支持(x86: CAS 指令,ARM: LDREX/STREX)
- Java 原子类从 Java 5 开始基于 CAS 实现
- JDK 8 的
LongAdder优化了高并发下的 CAS 自旋问题
10. 常见追问
- 追问:CAS 如何解决 ABA 问题?
- 回答方向:使用
AtomicStampedReference添加时间戳或版本号。
- 回答方向:使用
11. 易错点
- ❌ 错误:CAS 不会失败
- ✅ 正确:CAS 在并发冲突时会失败,需要重试
一句话总结
乐观锁通过 CAS 或版本号实现"先操作后检查",适合读多写少场景,冲突时重试保证最终一致性。
CAS 的原理、定义、解决方案是什么?
原始问法:
- 什么是 CAS?CAS 的原理是什么?
- CAS 的 ABA 问题是什么?如何解决?
来源题目:
SRC-03-33-124,SRC-03-33-125
面试先答
CAS(Compare-And-Swap,比较并交换)是 CPU 级别的原子操作指令。它包含三个操作数:内存位置 V、预期旧值 A、新值 B。只有当 V == A 时,才将 V 更新为 B,整个操作是原子的。CAS 是 Java 原子类、synchronized(轻量级锁)、ReentrantLock 的底层实现。CAS 的 ABA 问题是指值从 A→B→A,CAS 误认为没有变化,解决方案是添加版本号(AtomicStampedReference)。CAS 的其他局限包括:自旋开销、只能保证单个变量原子性。
核心结论
- CAS 是 CPU 原子指令,比较并交换
- 三个操作数:内存值 V、预期值 A、新值 B
- ABA 问题:值 A→B→A,CAS 无法检测
- ABA 解决方案:
AtomicStampedReference(添加版本号) - 是原子类和锁的底层实现
1. 是什么
CAS(Compare-And-Swap):一条 CPU 原子指令,实现比较-交换的原子操作。
CAS(V, A, B):
if V == A:
V = B
return true
else:
return false
2. 为什么需要它
- 实现无锁(Lock-Free)并发数据结构
- 比互斥锁性能更高
- 是 Java 原子类的底层实现
3. 底层原理与完整流程
CPU 层面:
- x86:
CMPXCHG指令(带LOCK前缀保证原子性) - ARM:
LDREX(独占读取)+STREX(独占写入)
Java 层面:
// AtomicInteger.compareAndSet() 源码
public final boolean compareAndSet(int expect, int update) {
return U.compareAndSwapInt(this, VALUE, expect, update);
}
// U = Unsafe,直接调用 native 方法
ABA 问题:
线程 1: 读取 value = A
线程 2: 将 A → B
线程 3: 将 B → A
线程 1: CAS(A, B) → 成功!(但值已经被修改过了)
4. 怎么使用
// 基本 CAS
AtomicInteger counter = new AtomicInteger(0);
counter.compareAndSet(0, 1); // CAS
// 解决 ABA 问题
AtomicStampedReference<String> stampedRef =
new AtomicStampedReference<>("A", 0);
String oldRef = stampedRef.getReference();
int oldStamp = stampedRef.getStamp();
// 更新(带版本号)
stampedRef.compareAndSet(oldRef, "B", oldStamp, oldStamp + 1);
// LongAdder 优化高并发
LongAdder adder = new LongAdder();
adder.increment(); // 分拆累加,减少自旋
5. 适用场景
- 原子计数器(
AtomicInteger、LongAdder) - 状态标志位(
AtomicBoolean) - 无锁队列(
ConcurrentLinkedQueue)
6. 不适用场景与替代方案
- 不要用 CAS 做多变量原子操作,改用锁
- 高并发下自旋开销大,改用
LongAdder - ABA 敏感场景使用
AtomicStampedReference
7. 优缺点与技术取舍
| 维度 | CAS | 互斥锁 |
|---|---|---|
| 性能 | 高(无阻塞) | 中(阻塞唤醒) |
| 原子性 | 单变量 | 多变量 |
| ABA 问题 | 有 | 无 |
| 复杂度 | 需处理失败 | 自动处理 |
8. 常见问题及解决方案
问题:CAS 自旋导致 CPU 使用率高
- 解决:使用
LongAdder、添加退避(Thread.yield())、限制自旋次数
- 解决:使用
问题:ABA 问题
- 解决:使用
AtomicStampedReference添加版本号
- 解决:使用
9. 版本差异与实现边界
- CAS 是 CPU 指令,与 JDK 版本无关
- Java 5:原子类基于 CAS 实现
- JDK 8:
LongAdder优化高并发 CAS 自旋 AtomicStampedReference从 Java 5 存在
10. 常见追问
- 追问:
LongAdder为什么比AtomicInteger快?- 回答方向:
LongAdder将单一 value 分散到多个 cell,减少 CAS 竞争。
- 回答方向:
11. 易错点
❌ 错误:CAS 能保证复合操作原子性
✅ 正确:CAS 只能保证单个变量的原子性
❌ 错误:CAS 一定比锁快
✅ 正确:低冲突时 CAS 快,高冲突时锁可能更快
一句话总结
CAS 是 CPU 级原子比较交换指令,是原子类的底层实现,存在 ABA 问题和自旋开销,通过 AtomicStampedReference 和 LongAdder 优化。
CAS 有哪些局限性?
原始问法:
- CAS有哪些局限性?
来源题目:
SRC-03-33-126
面试先答
CAS 有四大局限性:第一,ABA 问题——值从 A→B→A,CAS 误认为没有变化,解决方案是 AtomicStampedReference;第二,自旋时间过长——高并发下 CAS 反复失败导致 CPU 空转,解决方案是 LongAdder 分拆或添加退避策略;第三,只能保证单个变量的原子性——无法同时原子操作多个变量,解决方案是使用锁或 AtomicReference 包装多个变量;第四,CPU 指令顺序——CAS 依赖 CPU 缓存一致性协议,在某些架构上可能需要显式内存屏障。
核心结论
- 四大局限性:ABA 问题、自旋开销、单变量限制、指令顺序
- ABA 用
AtomicStampedReference解决 - 自旋开销用
LongAdder或退避策略解决 - 多变量用锁或包装解决
1. 是什么
CAS 局限性的具体表现:
| 局限 | 原因 | 影响 |
|---|---|---|
| ABA 问题 | 值变化后回到原值 | 无法检测修改 |
| 自旋开销 | 高并发下反复失败 | CPU 高占用 |
| 单变量限制 | 一条指令只能操作一个值 | 无法复合操作 |
| 指令顺序 | CPU 可能重排指令 | 可见性问题 |
2. 为什么需要它
理解 CAS 的局限性有助于在实际开发中正确选择并发方案。
3. 底层原理与完整流程
ABA 问题流程:
初始: value = A
T1 读 value = A
T2: CAS(A, B) → 成功
T3: CAS(B, A) → 成功
T1: CAS(A, X) → 成功(但值已经被修改过!)
自旋开销:
// 高并发下的自旋
while (!counter.compareAndSet(oldValue, oldValue + 1)) {
// 反复失败,CPU 空转
// 可能需要添加退避
Thread.yield(); // 或 LockSupport.parkNanos(1)
}
4. 怎么使用
// 解决 ABA 问题
AtomicStampedReference<Integer> atomic =
new AtomicStampedReference<>(0, 0);
int stamp = atomic.getStamp();
// CAS 时同时比较值和版本号
atomic.compareAndSet(0, 1, stamp, stamp + 1);
// 解决自旋开销
LongAdder adder = new LongAdder();
adder.increment(); // 内部使用分段 CAS
// 解决多变量问题
AtomicReference<Point> pointRef = new AtomicReference<>();
pointRef.set(new Point(0, 0));
// 一次性替换整个对象
pointRef.compareAndSet(oldPoint, newPoint);
5. 适用场景
- 低冲突场景下 CAS 性能最优
- 高冲突场景考虑使用
LongAdder或锁
6. 不适用场景与替代方案
- 不要在高冲突场景下使用单一 CAS
- 不要用 CAS 实现多变量同步
- 替代方案:
LongAdder、ReentrantLock、synchronized
7. 优缺点与技术取舍
CAS 在低冲突下性能优异,但需要处理其局限性。在 Java 8+ 中,LongAdder 和 StampedLock 提供了更好的选择。
8. 常见问题及解决方案
- 问题:CAS 导致 CPU 100%
- 解决:使用
LongAdder、添加Thread.yield()退避、限制重试次数
- 解决:使用
9. 版本差异与实现边界
- 所有 JDK 版本都存在这些局限性
- JDK 8 的
LongAdder和StampedLock部分解决了自旋开销问题
10. 常见追问
- 追问:
AtomicStampedReference的 stamp 是什么?- 回答方向:一个 int 类型的版本号,每次更新递增,用于检测 ABA 问题。
11. 易错点
- ❌ 错误:CAS 可以完全替代锁
- ✅ 正确:CAS 有 ABA、自旋、单变量等局限
一句话总结
CAS 有 ABA、自旋、单变量、指令顺序四大局限,通过 AtomicStampedReference、LongAdder 等方案弥补。
synchronized 的定义、使用方法、原理是什么?
原始问法:
- synchronized关键字的作用是什么?怎么使用?
- synchronized的底层原理是什么?
- synchronized的锁升级过程是什么?
来源题目:
SRC-03-33-127,SRC-03-33-129,SRC-03-33-130
面试先答
synchronized 是 Java 内置的关键字,用于实现线程同步,保证原子性、可见性和有序性。使用方式有三种:修饰实例方法(锁对象是 this)、修饰静态方法(锁对象是 Class 对象)、修饰代码块(锁对象是指定的任意对象)。底层原理:每个对象关联一个 monitor(监视器),通过 monitorenter/monitorexit 字节码指令实现加锁解锁。JDK 6 引入了锁优化:偏向锁→轻量级锁→重量级锁的升级过程,在低竞争下无需 OS 介入即可实现同步。
核心结论
synchronized保证原子性、可见性、有序性- 三种使用方式:实例方法、静态方法、代码块
- 底层基于对象监视器(monitor)+ 字节码指令
- 锁升级:偏向锁 → 轻量级锁 → 重量级锁
- JDK 6 后有锁升级优化,性能显著提升
1. 是什么
synchronized:Java 内置的互斥锁关键字,确保同一时刻只有一个线程执行被保护的代码。
2. 为什么需要它
- 保证多线程环境下数据的一致性
- 提供 JVM 级别的同步原语
- 相比
Lock无需手动管理,更安全
3. 底层原理与完整流程
三种使用方式:
// 1. 实例方法(锁 this)
public synchronized void method() {
// 临界区
}
// 2. 静态方法(锁 Class 对象)
public static synchronized void staticMethod() {
// 临界区
}
// 3. 代码块(锁任意对象)
Object lock = new Object();
synchronized (lock) {
// 临界区
}
锁升级过程:
无锁 → 偏向锁 → 轻量级锁 → 重量级锁
↑ ↑ ↑ ↑
| 线程再入 竞争检测 竞争升级
|
└──────────────────────────────
不可降级(G1 后可批量偏向)
- 偏向锁:同一线程多次获取锁,只需检测线程 ID,无需 CAS
- 轻量级锁:不同线程竞争,通过 CAS 交换锁指针
- 重量级锁:CAS 失败,升级为 OS 互斥量,阻塞线程
对象头存储:
Mark Word: [锁状态(2bit) | 线程ID/指向锁记录的指针 | 哈希码 | GC年龄]
4. 怎么使用
public class Counter {
private int count = 0;
// 方式一:修饰实例方法
public synchronized void increment() {
count++;
}
// 方式二:修饰代码块(更灵活)
public void incrementBlock() {
synchronized (this) {
count++;
}
}
// 方式三:修饰静态方法
public static synchronized void staticMethod() {
// 锁 Counter.class
}
}
5. 适用场景
- 简单同步需求:优先使用
synchronized - 方法级同步:修饰方法最简洁
- 需要 JVM 自动管理锁生命周期
6. 不适用场景与替代方案
- 不要在持有锁时调用外部方法
- 不要用
synchronized做长时间操作 - 替代方案:
ReentrantLock(更灵活)、ReadWriteLock(读写分离)
7. 优缺点与技术取舍
| 维度 | synchronized | ReentrantLock |
|---|---|---|
| 自动解锁 | 是 | 否(需 finally) |
| 可中断 | 否 | 是 |
| 公平锁 | 不支持 | 支持 |
| 多条件 | 不支持 | 支持 |
| 锁升级 | 支持 | 不支持 |
8. 常见问题及解决方案
问题:
synchronized锁不住的情况- 解决:检查是否锁了正确的对象(实例方法锁 this,静态方法锁 Class)
问题:锁升级导致性能抖动
- 解决:通过
-XX:-UseBiasedLocking关闭偏向锁或调整阈值
- 解决:通过
9. 版本差异与实现边界
- JDK 6 之前:只有重量级锁,性能差
- JDK 6:引入偏向锁和轻量级锁(锁升级)
- JDK 8:默认开启偏向锁
- JDK 15:废弃偏向锁(批量偏向保留)
- JVM 实现细节:HotSpot 中偏向锁的撤销和升级策略
10. 常见追问
- 追问:
synchronized和ReentrantLock怎么选?- 回答方向:简单同步用
synchronized,需要灵活控制(可中断、公平锁、多条件)用ReentrantLock。
- 回答方向:简单同步用
11. 易错点
❌ 错误:
synchronized方法锁的是方法✅ 正确:锁的是对象(this 或 Class)
❌ 错误:
synchronized不会死锁✅ 正确:
synchronized同样会死锁
一句话总结
synchronized 基于对象监视器实现,通过锁升级优化性能,保证原子性、可见性和有序性,是最常用的同步关键字。
构造方法能加 synchronized 吗?
原始问法:
- 构造方法能加synchronized吗?
来源题目:
SRC-03-33-128
面试先答
构造方法可以加 synchronized,但通常没有必要。因为构造方法是在对象创建时调用,synchronized 修饰构造方法锁的是正在创建的对象(this),而对象尚未完全构造完毕,其他线程无法访问该对象(除非通过引用逃逸)。实际上 synchronized 修饰构造方法极少使用,更多是用在普通方法上。从语法上看,synchronized 关键字可以用于任何方法包括构造方法,但 JVM 规范中对构造方法有特殊处理。
核心结论
- 语法上可以加,但没有实际意义
- 构造方法锁的是正在创建的对象
- 通常没有必要,因为对象尚未对外暴露
- 线程安全更多通过
final或内部同步保证
1. 是什么
public class Demo {
public synchronized Demo() {
// 可以编译通过,但极少使用
}
}
2. 为什么需要它
实际开发中几乎不会在构造方法上使用 synchronized,因为:
- 对象创建过程中只有一个线程能持有引用
- 构造方法执行完毕后才会将对象暴露给外部
3. 底层原理与完整流程
synchronized 修饰构造方法时:
- JVM 在方法上添加
ACC_SYNCHRONIZED标志 - 调用时获取对象监视器(monitor)
- 构造方法执行完毕后释放 monitor
- 与普通
synchronized方法机制相同
4. 怎么使用
通常不需要在构造方法上加 synchronized,如果需要在构造过程中保护共享资源,应使用其他锁:
public class SafeInit {
private static final Object INIT_LOCK = new Object();
public SafeInit() {
synchronized (INIT_LOCK) {
// 保护初始化过程
}
}
}
5. 适用场景
- 理论上可行,实际极少使用
6. 不适用场景与替代方案
- 不要依赖构造方法的
synchronized保证线程安全 - 替代方案:构造时使用静态锁或在方法内部同步
7. 优缺点与技术取舍
没有实际优点,反而可能误导。
8. 常见问题及解决方案
- 问题:构造方法加
synchronized导致死锁- 解决:避免在构造方法中加锁,不要在持有锁时调用外部方法
9. 版本差异与实现边界
- 所有版本的 Java 都支持在构造方法上加
synchronized - 实际使用中极为罕见
10. 常见追问
- 追问:为什么构造方法不需要
synchronized?- 回答方向:因为对象在构造完成前不会被其他线程访问(除非引用逃逸)。
11. 易错点
- ❌ 错误:构造方法加
synchronized没有意义 - ✅ 正确:语法上可以加,但通常不需要
一句话总结
构造方法可以加 synchronized,但因对象未对外暴露通常没有必要,实际开发中极少使用。
volatile 关键字的作用是什么?
原始问法:
- volatile关键字的作用是什么?
来源题目:
SRC-03-33-131
面试先答
volatile 关键字有两大作用:第一,保证可见性——一个线程修改了 volatile 变量的值,其他线程能立即看到最新值,通过强制主内存读写实现;第二,禁止指令重排序——编译器和 CPU 不得对 volatile 变量相关的指令进行重排序优化。注意:volatile 不能保证原子性,如 volatile int count; count++ 不是原子操作(读取-修改-写入三步)。volatile 是最轻量的同步机制,适合状态标志位、双重检查锁单例等场景。
核心结论
- 两大作用:保证可见性、禁止指令重排序
- 不保证原子性
- 最轻量的同步机制
- 适合状态标志位和 DCL 单例
1. 是什么
volatile:Java 内存模型(JMM)提供的轻量级同步关键字。
volatile boolean running = true;
volatile int count = 0;
2. 为什么需要它
- 多线程间需要实时感知状态变化
- 防止指令重排序导致的程序错误
- 比锁更轻量,无需上下文切换
3. 底层原理与完整流程
可见性实现:
- 写操作:强制刷新到主内存
- 读操作:强制从主内存读取(不用缓存)
禁止重排序:
- 在
volatile写之前插入 StoreStore 屏障 - 在
volatile写之后插入 StoreLoad 屏障 - 在
volatile读之前插入 LoadLoad 屏障 - 在
volatile读之后插入 LoadStore 屏障
4. 怎么使用
// 状态标志位
public class Worker implements Runnable {
private volatile boolean running = true;
@Override
public void run() {
while (running) {
doWork();
}
}
public void stop() {
running = false; // 其他线程立即可见
}
}
// 双重检查锁单例
public 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;
}
}
5. 适用场景
- 状态标志位(如
running、initialized) - 双重检查锁单例
- 配置数据的安全发布
6. 不适用场景与替代方案
- 不要用于复合操作(
count++),改用AtomicInteger - 不要作为锁的替代品
- 替代方案:
AtomicInteger、synchronized、ReentrantLock
7. 优缺点与技术取舍
| 维度 | volatile | synchronized |
|---|---|---|
| 可见性 | 保证 | 保证 |
| 原子性 | 不保证 | 保证 |
| 有序性 | 保证 | 保证 |
| 性能 | 高 | 中 |
| 阻塞 | 无 | 有 |
8. 常见问题及解决方案
- 问题:
volatile变量count++不是线程安全的- 解决:改用
AtomicInteger.incrementAndGet()
- 解决:改用
9. 版本差异与实现边界
- 从 Java 1.2 引入(准确说是 1.1?实际规范从 Java 1.0 就有)
- JDK 5 之后
volatile的语义被加强(完全建立在 happens-before 原则上) - JDK 9+ 中
VarHandle提供了更灵活的 volatile 访问
10. 常见追问
- 追问:为什么 DCL 单例中
instance必须用volatile?- 回答方向:防止指令重排序(对象创建分为分配内存、初始化、引用赋值三步,重排序可能导致其他线程看到未初始化的对象)。
11. 易错点
- ❌ 错误:
volatile保证原子性 - ✅ 正确:
volatile只保证可见性和有序性,不保证原子性
一句话总结
volatile 保证可见性和禁止重排序,不保证原子性,是最轻量的同步机制,适合状态标志位和 DCL 单例。
volatile 如何保证可见性和禁止指令重排序?
原始问法:
- volatile如何保证可见性和禁止指令重排序?
来源题目:
SRC-03-33-132
面试先答
volatile 通过 JMM 的内存屏障(Memory Barrier) 实现可见性和有序性。可见性:volatile 写时通过 Store 屏障强制将缓存数据刷新到主内存,volatile 读时通过 Load 屏障强制从主内存读取,绕过 CPU 缓存,保证所有线程看到的值一致。禁止重排序:编译器在 volatile 写之前插入 StoreStore 屏障(禁止写-写重排),写之后插入 StoreLoad 屏障(禁止写-读重排);在 volatile 读之前插入 LoadLoad 屏障(禁止读-读重排),读之后插入 LoadStore 屏障(禁止读-写重排)。内存屏障同时具有刷新缓存的效果。
核心结论
- 可见性:强制主内存读写(Store/Load 屏障)
- 有序性:插入内存屏障禁止重排序
- 内存屏障同时提供可见性和有序性保证
- 底层通过 CPU 指令实现(x86 的 MFENCE、ARM 的 DMB)
1. 是什么
内存屏障(Memory Barrier):一种 CPU 指令,保证屏障前后的指令执行顺序和内存可见性。
四种内存屏障:
- LoadLoad:保证屏障前的读在屏障后的读之前完成
- StoreStore:保证屏障前的写在屏障后的写之前完成
- LoadStore:保证屏障前的读在屏障后的写之前完成
- StoreLoad:保证屏障前的写在屏障后的读之前完成
2. 为什么需要它
- CPU 执行指令时可能重排序以提高性能
- CPU 缓存导致不同线程看到的值不一致
- 内存屏障强制 CPU 按顺序执行并刷新缓存
3. 底层原理与完整流程
volatile 写的屏障插入:
... 写操作 1 → StoreStore → volatile 写 → StoreLoad → ... 读操作 2
- StoreStore:保证前面的写不会被重排到后面
- StoreLoad:保证后面的读不会被重排到前面(同时刷新缓存)
volatile 读的屏障插入:
... 写操作 1 → LoadLoad → volatile 读 → LoadStore → ... 写操作 2
- LoadLoad:保证前面的读不会被重排到后面
- LoadStore:保证后面的写不会被重排到前面
CPU 实现:
- x86:
MFENCE(全屏障)、SFENCE(Store 屏障)、LFENCE(Load 屏障) - ARM:
DMB(Data Memory Barrier)、DSB(Data Synchronization Barrier)
4. 怎么使用
// volatile 写 - 隐含 StoreStore + StoreLoad
volatileFlag = true;
// volatile 读 - 隐含 LoadLoad + LoadStore
if (volatileFlag) {
// ...
}
// 查看字节码中的屏障
// javap -c Demo.class
// 会看到 volatile 变量的读写带有特定的屏障指令
// VarHandle(JDK 9+)
VarHandle handle = MethodHandles.lookup()
.in(MyClass.class)
.findVarHandle(MyClass.class, "counter", int.class)
.getVarHandle();
handle.setVolatile(this, 10); // volatile 写
int val = handle.getVolatile(this); // volatile 读
5. 适用场景
- 所有使用
volatile的场景都依赖这些机制 - 理解 JMM 的基础
6. 不适用场景与替代方案
- 不要手动添加内存屏障,JMM 已自动处理
- 替代方案:
synchronized(通过 monitor 实现可见性和有序性)
7. 优缺点与技术取舍
内存屏障提供了强可见性和有序性保证,但会带来一定的性能开销(阻止 CPU 优化、强制缓存刷新)。
8. 常见问题及解决方案
- 问题:
volatile变量性能不如普通变量- 解决:理解这是为了可见性和有序性的必要开销
9. 版本差异与实现边界
- JDK 5+ 中
volatile的语义完全基于 happens-before - x86 架构的 CPU 缓存一致性协议(MESI)在一定程度上自动处理可见性
- ARM 架构更严格,需要显式屏障
10. 常见追问
- 追问:x86 架构下
volatile的性能为什么比 ARM 好?- 回答方向:x86 采用强内存模型(TSO),StoreLoad 屏障默认存在;ARM 采用弱内存模型,需要更多屏障。
11. 易错点
- ❌ 错误:
volatile可以完全替代synchronized - ✅ 正确:
volatile不保证原子性,无法替代锁
一句话总结
volatile 通过内存屏障实现可见性(强制主内存读写)和有序性(禁止指令重排序),是 JMM 轻量级同步的核心机制。
3.3 锁机制(下)
为什么 volatile 不能保证原子性?
原始问法:
- 为什么volatile不能保证原子性?
来源题目:
SRC-03-33-133
面试先答
volatile 不能保证原子性的核心原因是:volatile 的写操作虽然对单个变量的读写具有原子性(CPU 层面),但复合操作(如 count++)不是原子的。count++ 实际包含三个步骤:读取 count 的值(读)、加 1(计算)、写回 count(写)。在多线程环境下,一个线程在读和写之间,另一个线程可能已经修改了 count,导致丢失更新。volatile 只保证可见性和有序性,不保证读-改-写操作的原子性。要保证原子性需要使用 synchronized、AtomicInteger 等。
核心结论
volatile不保证复合操作(读-改-写)的原子性count++是三个步骤,中间可能被其他线程打断- 单个变量的读写在 CPU 层面通常是原子的(对齐情况下)
- 需要原子性用
AtomicInteger或synchronized
1. 是什么
原子性:一个操作或者多个操作,要么全部执行且不被中断,要么就不执行。
count++ 的三个步骤:
步骤1: 读取 count 的值 → 如 0
步骤2: 将值加 1 → 得到 1
步骤3: 写回 count → 设置为 1
2. 为什么需要它
理解 volatile 的局限性,正确选择同步机制。
3. 底层原理与完整流程
非原子操作示例:
线程 A: 读 count(0) → 计算加1(1) → 写 count(1)
线程 B: 读 count(0) → 计算加1(1) → 写 count(1)
结果: count = 1(丢失了一次更新!预期应该是 2)
CPU 层面:
- 单个
volatile变量的读写在对齐情况下是原子的(x86 保证) - 但读-改-写需要多次 CPU 操作,不是原子的
volatile不提供 CAS 语义
4. 怎么使用
// 错误:volatile 不保证原子性
public class WrongCounter {
private volatile int count = 0;
public void increment() {
count++; // 非原子操作!
}
}
// 正确:使用 AtomicInteger
public class RightCounter {
private final AtomicInteger count = new AtomicInteger(0);
public void increment() {
count.incrementAndGet(); // CAS 原子操作
}
}
// 正确:使用 synchronized
public class SyncCounter {
private int count = 0;
public synchronized void increment() {
count++; // 整个方法是原子的
}
}
5. 适用场景
volatile:状态标志位(只读或单一写)AtomicInteger:计数器、累加器synchronized:复合操作、多变量同步
6. 不适用场景与替代方案
- 不要用
volatile做计数器 - 不要用
volatile做任何读-改-写操作 - 替代方案:原子类、
synchronized、Lock
7. 优缺点与技术取舍
| 方案 | 原子性 | 性能 | 复杂度 |
|---|---|---|---|
volatile |
仅可见性 | 最高 | 低 |
AtomicInteger |
复合操作 | 高 | 低 |
synchronized |
方法级 | 中 | 低 |
8. 常见问题及解决方案
- 问题:
volatile变量的++操作在单线程下没问题,多线程下出错- 解决:使用
AtomicInteger.incrementAndGet()
- 解决:使用
9. 版本差异与实现边界
volatile的原子性局限与 JDK 版本无关VarHandle(JDK 9+)提供了与AtomicInteger类似的功能
10. 常见追问
- 追问:为什么
volatile单个变量的读写是原子的?- 回答方向:x86 CPU 对齐的内存访问是原子的,
volatile保证了对齐。
- 回答方向:x86 CPU 对齐的内存访问是原子的,
11. 易错点
- ❌ 错误:
volatile保证count++的原子性 - ✅ 正确:
count++是读-改-写复合操作,volatile不保证原子性
一句话总结
volatile 不保证原子性因为复合操作(读-改-写)可能被其他线程打断,需要用原子类或锁保证原子性。
volatile 和 synchronized 的区别是什么?ReentrantLock 是什么?和 synchronized 的区别是什么?
原始问法:
- volatile和synchronized的区别是什么?
- ReentrantLock是什么?和synchronized的区别是什么?
来源题目:
SRC-03-33-134,SRC-03-33-135
面试先答
volatile 和 synchronized 的核心区别:volatile 是关键字、轻量级、保证可见性和有序性但不保证原子性;synchronized 是关键字、重量级(有锁升级优化)、保证可见性、有序性和原子性。ReentrantLock 是 JUC 包的类、基于 AQS 实现、功能比 synchronized 更灵活——支持可中断获取、超时获取、公平锁、多条件队列,但需要手动管理锁(finally unlock)。选择建议:简单同步优先 synchronized,需要灵活控制选 ReentrantLock。
核心结论
volatile:可见性+有序性,不保证原子性synchronized:可见性+有序性+原子性,自动管理ReentrantLock:更灵活,需手动管理- 选择原则:简单用
synchronized,复杂用ReentrantLock
1. 是什么
三者对比:
| 维度 | volatile | synchronized | ReentrantLock |
|---|---|---|---|
| 原子性 | 不保证 | 保证 | 保证 |
| 可见性 | 保证 | 保证 | 保证 |
| 有序性 | 保证 | 保证 | 保证 |
| 锁释放 | 无需释放 | 自动释放 | 手动释放 |
| 可中断 | — | 不支持 | 支持 |
| 超时获取 | — | 不支持 | 支持 |
| 公平锁 | — | 不支持 | 支持 |
| 多条件 | — | 不支持 | 支持 |
| 类型 | 关键字 | 关键字 | 类 |
2. 为什么需要它
volatile:最轻量同步,适合状态标志synchronized:自动管理锁,简单安全ReentrantLock:灵活控制,适合复杂同步
3. 底层原理与完整流程
synchronized:
- 基于对象监视器(monitor)
- 锁升级:偏向锁 → 轻量级锁 → 重量级锁
- JVM 自动管理锁获取和释放
ReentrantLock:
- 基于 AQS(AbstractQueuedSynchronizer)
- 使用 CAS 修改状态变量
- 可中断:
lockInterruptibly() - 超时:
tryLock(timeout) - 公平锁:按等待顺序获取
4. 怎么使用
// volatile 使用
private volatile boolean flag = false;
// synchronized 使用
public synchronized void method() {
// 自动获取和释放锁
}
// ReentrantLock 使用
private final ReentrantLock lock = new ReentrantLock();
private final Condition condition = lock.newCondition();
public void method() {
lock.lock();
try {
// 业务逻辑
condition.await(); // 等待条件
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
lock.unlock(); // 必须在 finally 中释放
}
}
// 可中断获取
try {
lock.lockInterruptibly();
} catch (InterruptedException e) {
// 处理中断
}
5. 适用场景
volatile:状态标志、DCL 单例synchronized:简单方法同步、代码块同步ReentrantLock:需要灵活控制的复杂同步
6. 不适用场景与替代方案
- 不要用
volatile做原子操作 - 不要忘记
ReentrantLock.unlock()(在 finally 中) - 不要过度使用
ReentrantLock,简单场景用synchronized
7. 优缺点与技术取舍
synchronized 自动管理、简单安全;ReentrantLock 灵活但需手动管理。JDK 6 后 synchronized 性能已接近 ReentrantLock。
8. 常见问题及解决方案
问题:
ReentrantLock忘记unlock()- 解决:必须在
finally块中unlock()
- 解决:必须在
问题:
synchronized无法实现公平锁- 解决:使用
new ReentrantLock(true)实现公平锁
- 解决:使用
9. 版本差异与实现边界
volatile:Java 1.0+synchronized:Java 1.0+ReentrantLock:Java 5+(JUC 包)- JDK 6 后
synchronized性能大幅提升(锁升级) - JDK 8 中两者性能基本持平
10. 常见追问
- 追问:
synchronized为什么比ReentrantLock使用更广?- 回答方向:自动管理、不会忘记释放、代码更简洁、JVM 优化持续改进。
11. 易错点
❌ 错误:
volatile可以替代synchronized✅ 正确:
volatile不保证原子性❌ 错误:
ReentrantLock一定比synchronized快✅ 正确:JDK 6+ 后
synchronized性能已接近甚至在低竞争下更好
一句话总结
volatile 最轻量、synchronized 最安全、ReentrantLock 最灵活,根据场景选择合适的同步机制。
公平锁和非公平锁的区别是什么?底层如何实现?
原始问法:
- 公平锁和非公平锁的区别是什么?底层如何实现?
来源题目:
SRC-03-33-136
面试先答
公平锁按线程请求顺序分配锁,遵循"先来先服务";非公平锁允许插队,新线程可以抢占老线程。区别在于:公平锁不会饥饿但吞吐量低,非公平锁吞吐量高但可能线程饥饿。底层实现基于 AQS(AbstractQueuedSynchronizer):公平锁获取锁时先检查等待队列,非公平锁直接 CAS 尝试。ReentrantLock 默认非公平锁,可通过构造参数 new ReentrantLock(true) 创建公平锁。synchronized 只有非公平锁。
核心结论
- 公平锁:按等待顺序获取,无饥饿,吞吐量低
- 非公平锁:允许插队,吞吐量高,可能饥饿
- 底层基于 AQS 的 CLH 队列
ReentrantLock支持公平/非公平,synchronized只有非公平
1. 是什么
公平锁:线程按请求锁的顺序获取锁。
非公平锁:新线程可以插队,不按顺序获取锁。
2. 为什么需要它
- 公平锁保证所有线程都能获取锁(无饥饿)
- 非公平锁吞吐量更高(减少 CPU 唤醒开销)
3. 底层原理与完整流程
AQS CLH 队列:
- 双向链表队列,每个节点代表一个等待线程
- 线程入队后自旋等待前驱节点释放锁
非公平锁获取流程:
// ReentrantLock.NonfairSync.lock()
final void lock() {
if (compareAndSetState(0, 1)) // 直接 CAS 尝试
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1); // 入队等待
}
公平锁获取流程:
// ReentrantLock.FairSync.lock()
final void lock() {
acquire(1); // 直接入队,不插队
}
// AQS.acquire()
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
// FairSync.tryAcquire()
protected final boolean tryAcquire(int acquires) {
Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (!hasQueuedPredecessors() && // 检查是否有前驱等待
compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
// 重入逻辑...
}
4. 怎么使用
// 非公平锁(默认)
ReentrantLock unfairLock = new ReentrantLock();
// 公平锁
ReentrantLock fairLock = new ReentrantLock(true);
// 使用
fairLock.lock();
try {
// 业务逻辑
} finally {
fairLock.unlock();
}
5. 适用场景
- 公平锁:需要保证所有线程都能获取锁的场景,如任务队列
- 非公平锁:高吞吐量要求的场景,如 Web 服务
6. 不适用场景与替代方案
- 不要在对延迟敏感的场景使用公平锁(增加 CPU 唤醒)
- 替代方案:
synchronized(非公平锁)、StampedLock(乐观读)
7. 优缺点与技术取舍
| 维度 | 公平锁 | 非公平锁 |
|---|---|---|
| 吞吐量 | 低 | 高 |
| 线程饥饿 | 无 | 可能 |
| 实现复杂度 | 稍高 | 低 |
| 唤醒开销 | 全部唤醒 | 有选择唤醒 |
8. 常见问题及解决方案
- 问题:公平锁性能比非公平锁差很多
- 解决:只有在确实需要公平性时才使用公平锁
9. 版本差异与实现边界
ReentrantLock的公平/非公平从 Java 5 支持synchronized只有非公平锁(不可选)- JDK 15 中偏向锁被移除后
synchronized性能略有调整
10. 常见追问
- 追问:为什么
synchronized只有非公平锁?- 回答方向:JVM 设计时为了吞吐量选择非公平策略,公平锁实现更复杂且吞吐更低。
11. 易错点
- ❌ 错误:公平锁比非公平锁快
- ✅ 正确:非公平锁吞吐量更高
一句话总结
公平锁按顺序获取、无饥饿但吞吐低;非公平锁允许插队、吞吐高但可能饥饿,底层基于 AQS 的 CLH 队列实现。
ReentrantReadWriteLock 是什么?适用场景?
原始问法:
- ReentrantReadWriteLock是什么?适用场景?
来源题目:
SRC-03-33-137
面试先答
ReentrantReadWriteLock 是 Java 提供的读写锁实现,基于 AQS。它将锁分为读锁和写锁:读锁可以被多个线程同时持有(读-读不互斥),写锁独占(写-读、写-写互斥)。核心优势是在读多写少的场景下提高并发性能。支持公平/非公平模式、读锁重入、写锁降级为读锁(不支持读锁升级为写锁)。适用场景:缓存实现、配置读取、统计查询等读多写少的场景。
核心结论
- 读写分离:读锁共享、写锁独占
- 适合读多写少场景
- 支持重入和降级(写→读),不支持升级(读→写)
- 基于 AQS 实现,可公平/非公平
1. 是什么
public interface ReadWriteLock {
Lock readLock(); // 读锁
Lock writeLock(); // 写锁
}
public class ReentrantReadWriteLock implements ReadWriteLock {
// 实现
}
锁的互斥关系:
| 读锁 | 写锁 | |
|---|---|---|
| 读锁 | ✅ 共享 | ❌ 互斥 |
| 写锁 | ❌ 互斥 | ❌ 互斥 |
2. 为什么需要它
- 读多写少场景下,允许多个线程同时读取
- 比互斥锁(
synchronized、ReentrantLock)并发度更高
3. 底层原理与完整流程
基于 AQS 的实现:
- 使用 state 变量的高 16 位表示读锁计数
- 低 16 位表示写锁计数
- 读锁通过
ThreadLocal记录每个线程的读锁重入次数
state: [读锁计数(16bit) | 写锁计数(16bit)]
降级(写→读):
// 写锁降级为读锁
rwLock.writeLock().lock();
try {
// 写操作
rwLock.readLock().lock(); // 降级为读锁
try {
// 读操作(持有写锁,不会被阻塞)
} finally {
rwLock.readLock().unlock();
}
} finally {
rwLock.writeLock().unlock();
}
4. 怎么使用
public class ReadWriteCache {
private final ReentrantReadWriteLock rwLock =
new ReentrantReadWriteLock();
private final Map<String, String> cache = new HashMap<>();
public String get(String key) {
rwLock.readLock().lock();
try {
return cache.get(key);
} finally {
rwLock.readLock().unlock();
}
}
public void put(String key, String value) {
rwLock.writeLock().lock();
try {
cache.put(key, value);
} finally {
rwLock.writeLock().unlock();
}
}
}
5. 适用场景
- 读多写少:缓存、配置读取
- 统计查询:计数器、仪表盘
- 文档编辑:读取多、写入少
6. 不适用场景与替代方案
- 不要在写多读多场景使用(反而增加开销)
- 不要在持有读锁时尝试获取写锁(升级不支持,会死锁)
- 替代方案:
StampedLock(更灵活)、ConcurrentHashMap(分段锁)
7. 优缺点与技术取舍
| 维度 | ReentrantReadWriteLock | ReentrantLock |
|---|---|---|
| 读并发 | 高(多线程读) | 低(单线程) |
| 写并发 | 低(独占) | 低(独占) |
| 实现复杂度 | 稍高 | 低 |
| 适用场景 | 读多写少 | 通用同步 |
8. 常见问题及解决方案
问题:读线程长时间阻塞写线程
- 解决:使用公平模式或调整读写超时
问题:读锁升级为写锁导致死锁
- 解决:读写锁只支持写→读降级,不支持读→写升级
9. 版本差异与实现边界
- Java 5 引入
ReentrantReadWriteLock - JDK 8 引入
StampedLock(更灵活的读写锁,支持乐观读) StampedLock不支持重入
10. 常见追问
- 追问:
StampedLock和ReentrantReadWriteLock的区别?- 回答方向:
StampedLock支持乐观读、不支持重入、读锁可升级为写锁。
- 回答方向:
11. 易错点
- ❌ 错误:读写锁支持读锁升级为写锁
- ✅ 正确:只支持写锁降级为读锁,不支持读锁升级为写锁
一句话总结
ReentrantReadWriteLock 实现读写分离,读锁共享、写锁独占,适合读多写少场景,基于 AQS 实现。
什么是 AQS?AQS 的原理是什么?
原始问法:
- 什么是AQS?AQS的原理是什么?
来源题目:
SRC-03-33-138
面试先答
AQS(AbstractQueuedSynchronizer,抽象队列同步器)是 JUC 包的基础同步框架,是 ReentrantLock、CountDownLatch、Semaphore、ReentrantReadWriteLock 等同步工具的底层实现。AQS 的核心设计是:使用一个 volatile int state 表示同步状态,基于 CLH 双向队列管理等待线程,通过 CAS 修改 state 实现原子状态切换,模板方法模式让子类实现具体的获取和释放逻辑。AQS 是理解 Java 并发包的关键。
核心结论
- AQS 是 JUC 包的核心基础框架
- 核心组件:
state变量 + CLH 队列 + CAS - 模板方法模式:子类实现
tryAcquire/tryRelease - 是
ReentrantLock、CountDownLatch、Semaphore等的底层
1. 是什么
AQS(AbstractQueuedSynchronizer):抽象队列同步器,JUC 包的核心基础类。
public abstract class AbstractQueuedSynchronizer
extends AbstractOwnableSynchronizer
implements java.io.Serializable {
private volatile int state; // 同步状态
private transient Node head; // 队列头
private transient Node tail; // 队列尾
// 模板方法,子类实现
protected boolean tryAcquire(int arg) { throw ... }
protected boolean tryRelease(int arg) { throw ... }
// 框架方法
public final void acquire(int arg) { ... }
public final boolean release(int arg) { ... }
}
2. 为什么需要它
- 统一 JUC 包中各种同步工具的实现
- 提供可复用的同步基础框架
- 子类只需实现少量方法即可获得完整的同步能力
3. 底层原理与完整流程
核心设计:
AQS 结构:
┌─────────────────────────────────┐
│ state (volatile int) │ ← 同步状态
│ - ReentrantLock: 锁计数 │
│ - CountDownLatch: 剩余计数 │
│ - Semaphore: 剩余许可数 │
├─────────────────────────────────┤
│ CLH 双向队列 │ ← 等待线程队列
│ head → [Node1] ← [Node2] ← tail│
├─────────────────────────────────┤
│ CAS 操作 │ ← 原子修改 state
└─────────────────────────────────┘
acquire 流程:
- 调用
tryAcquire(arg)尝试获取(子类实现) - 失败则封装为 Node 加入 CLH 队列尾部
- 在队列中自旋或阻塞等待前驱节点释放
- 前驱释放后唤醒当前线程
- 循环直到获取成功
release 流程:
- 调用
tryRelease(arg)释放(子类实现) - 释放成功后唤醒队列中的下一个线程
CLH 队列结构:
static final class Node {
volatile int waitStatus; // 状态
volatile Node prev; // 前驱
volatile Node next; // 后继
volatile Thread thread; // 线程
// 状态常量
static final int CANCELLED = 1;
static final int SIGNAL = -1;
static final int CONDITION = -2;
}
4. 怎么使用
AQS 是抽象类,通过继承实现自定义同步器:
// 自定义独占锁
public class MyLock extends AbstractQueuedSynchronizer {
@Override
protected boolean tryAcquire(int arg) {
return compareAndSetState(0, 1);
}
@Override
protected boolean tryRelease(int arg) {
setState(0);
return true;
}
public void lock() {
acquire(1);
}
public void unlock() {
release(1);
}
}
5. 适用场景
- JUC 包中所有同步工具的基础
- 自定义同步器
- 理解并发包的关键
6. 不适用场景与替代方案
- 不要直接使用 AQS,应该使用 JUC 包的具体实现
- 替代方案:使用现成的
ReentrantLock、CountDownLatch等
7. 优缺点与技术取舍
| 维度 | AQS | synchronized |
|---|---|---|
| 灵活性 | 高 | 低 |
| 功能 | 可扩展 | 固定 |
| 复杂度 | 高 | 低 |
| 性能 | 高 | 中(JDK6+ 提升) |
8. 常见问题及解决方案
- 问题:AQS 队列过长导致性能下降
- 解决:考虑使用公平/非公平锁、调整超时时间
9. 版本差异与实现边界
- Java 5 引入 AQS(JUC 包)
- AQS 是 JUC 包的核心,几乎所有并发工具都基于它
- JDK 21 中虚拟线程的实现与 AQS 有关联
10. 常见追问
- 追问:AQS 和 synchronized 的关系?
- 回答方向:
synchronized基于对象监视器实现,AQS 是 JUC 包的基础,两者是不同的同步实现。
- 回答方向:
11. 易错点
❌ 错误:AQS 是一种锁
✅ 正确:AQS 是同步框架,是锁的底层实现
❌ 错误:AQS 中的 state 含义固定
✅ 正确:state 的含义由子类决定(锁计数、剩余许可等)
一句话总结
AQS 是 JUC 包的核心同步框架,通过 state 变量、CLH 队列和 CAS 实现线程同步,是理解 Java 并发工具的关键。
3.4 并发工具类
CountDownLatch、CyclicBarrier、Semaphore 的区别和使用场景?
原始问法:
- CountDownLatch、CyclicBarrier、Semaphore的区别和使用场景?
来源题目:
SRC-03-34-139
面试先答
三者都是 JUC 包的并发工具类,基于 AQS 实现:CountDownLatch 是一次性倒计时门闩,一个或多个线程等待其他线程完成;CyclicBarrier 是可循环使用的屏障,多个线程相互等待全部到达后同时执行;Semaphore 是信号量,控制同时访问的线程数量。核心区别:Latch 是一次性的、一个线程等多个;Barrier 是循环的、多个线程互相等;Semaphore 控制并发数量。分别适用于:并行计算等待所有任务完成、多人同时开始某项任务、限流场景。
核心结论
CountDownLatch:一次性倒计时,一个等多个CyclicBarrier:循环屏障,多个互相等Semaphore:信号量,控制并发数- 底层都基于 AQS 实现
1. 是什么
| 工具类 | 核心功能 | 复用性 | 典型场景 |
|---|---|---|---|
CountDownLatch |
倒计时门闩 | 一次性 | 并行计算汇总 |
CyclicBarrier |
循环屏障 | 可循环 | 多阶段并行计算 |
Semaphore |
信号量 | 可复用 | 限流、资源池 |
2. 为什么需要它
- 提供高层同步原语,无需手动实现 wait/notify
- 代码更清晰、更安全
- 覆盖不同的并发协作场景
3. 底层原理与完整流程
CountDownLatch:
CountDownLatch latch = new CountDownLatch(3); // 计数=3
// 每个任务完成后 countDown()
latch.countDown(); // -1
// 等待计数归零
latch.await(); // 阻塞直到 0
CyclicBarrier:
CyclicBarrier barrier = new CyclicBarrier(4, () -> {
// 所有参与者到达后执行
});
// 每个线程到达屏障
barrier.await(); // 等待所有参与者
Semaphore:
Semaphore sem = new Semaphore(3); // 3 个许可
sem.acquire(); // 获取许可(阻塞)
// 临界区
sem.release(); // 释放许可
4. 怎么使用
// CountDownLatch:并行计算汇总
CountDownLatch latch = new CountDownLatch(4);
List<Result> results = Collections.synchronizedList(new ArrayList<>());
for (int i = 0; i < 4; i++) {
final int taskId = i;
new Thread(() -> {
results.add(compute(taskId));
latch.countDown();
}).start();
}
latch.await(); // 等待所有任务完成
// 汇总结果
processResults(results);
// CyclicBarrier:多阶段并行
CyclicBarrier barrier = new CyclicBarrier(3, () -> {
System.out.println("所有阶段完成,进入下一阶段");
});
for (int i = 0; i < 3; i++) {
new Thread(() -> {
for (int stage = 0; stage < 5; stage++) {
computeStage(stage);
barrier.await(); // 等待所有线程完成当前阶段
}
}).start();
}
// Semaphore:限流
Semaphore semaphore = new Semaphore(10); // 最多 10 个并发
ExecutorService executor = Executors.newCachedThreadPool();
for (int i = 0; i < 100; i++) {
executor.execute(() -> {
try {
semaphore.acquire();
apiCall(); // 限流的 API 调用
} finally {
semaphore.release();
}
});
}
5. 适用场景
- CountDownLatch:并行初始化、多任务汇总、服务启动等待
- CyclicBarrier:多阶段并行计算、多人游戏开始、数据流水线
- Semaphore:接口限流、数据库连接池、资源访问控制
6. 不适用场景与替代方案
- 不要用
CountDownLatch做循环同步,改用CyclicBarrier - 不要用
Semaphore做互斥锁(功能虽有但语义不对) - 替代方案:
CompletableFuture(更强的编排能力)
7. 优缺点与技术取舍
| 工具类 | 优点 | 缺点 |
|---|---|---|
CountDownLatch |
简单直接 | 一次性 |
CyclicBarrier |
可循环 | 所有参与者必须到位 |
Semaphore |
控制并发数 | 不支持公平性以外的策略 |
8. 常见问题及解决方案
- 问题:
CountDownLatch的countDown()未调用- 解决:在
finally块中调用,或使用超时await(timeout)
- 解决:在
9. 版本差异与实现边界
- 三者均基于 AQS 实现
Semaphore支持公平/非公平模式CyclicBarrier支持reset()重置
10. 常见追问
- 追问:
CountDownLatch和CompletableFuture.allOf()怎么选?- 回答方向:简单等待用
CountDownLatch,需要链式组合用CompletableFuture。
- 回答方向:简单等待用
11. 易错点
- ❌ 错误:
CountDownLatch可以重用 - ✅ 正确:是一次性的,重用需要重新创建
一句话总结
CountDownLatch 一次性倒计时、CyclicBarrier 循环屏障、Semaphore 信号量限流,三者从不同维度解决线程协作问题。
ThreadLocal 是干嘛的?原理是什么?
原始问法:
- ThreadLocal是干嘛的?原理是什么?
来源题目:
SRC-03-34-140
面试先答
ThreadLocal 是线程本地存储,为每个线程提供独立的变量副本,实现线程间的数据隔离。核心原理是:每个 Thread 对象内部维护一个 ThreadLocalMap(类似 HashMap),以 ThreadLocal 对象为 key,存储线程独有的值。调用 threadLocal.set(value) 时,获取当前线程的 ThreadLocalMap,将 ThreadLocal 本身作为 key 存入值;threadLocal.get() 时,从当前线程的 Map 中取出对应的值。常用于:传递上下文信息(如用户 ID、事务 ID)、线程安全的 SimpleDateFormat、避免参数传递。
核心结论
ThreadLocal实现线程间数据隔离- 原理:每个
Thread持有一个ThreadLocalMap - key 是
ThreadLocal对象本身 - 用于上下文传递、线程安全、避免参数传递
1. 是什么
public class ThreadLocal<T> {
public void set(T value) { ... }
public T get() { ... }
public void remove() { ... }
}
2. 为什么需要它
- 线程间数据隔离,无需同步
- 传递上下文信息(如 Trace ID)
- 避免方法间层层传参
3. 底层原理与完整流程
ThreadLocalMap 结构:
Thread {
ThreadLocal.ThreadLocalMap threadLocals;
}
ThreadLocalMap {
Entry[] table; // 数组
// Entry 继承 WeakReference<ThreadLocal>
static class Entry extends WeakReference<ThreadLocal> {
Object value;
}
}
set 流程:
- 获取当前线程
Thread.currentThread() - 获取线程的
ThreadLocalMap - 如果 Map 为空,创建一个
- 将
ThreadLocal对象作为 key,value 存入 Entry
get 流程:
- 获取当前线程的
ThreadLocalMap - 遍历数组找到 key 为当前
ThreadLocal的 Entry - 返回 Entry 的 value
4. 怎么使用
public class UserContext {
private static final ThreadLocal<Long> USER_ID =
new ThreadLocal<>();
public static void setUserId(Long userId) {
USER_ID.set(userId);
}
public static Long getUserId() {
return USER_ID.get();
}
public static void clear() {
USER_ID.remove(); // 必须清理!
}
}
// 在拦截器中设置
public class AuthInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(...) {
Long userId = parseUserId(request);
UserContext.setUserId(userId);
return true;
}
@Override
public void afterCompletion(...) {
UserContext.clear(); // 请求完成后清理
}
}
5. 适用场景
- 上下文传递(用户 ID、Trace ID、租户 ID)
- 线程安全的共享对象(如
SimpleDateFormat) - 避免方法参数传递
6. 不适用场景与替代方案
- 不要在使用后忘记
remove(),会导致内存泄漏 - 不要在线程池场景下直接使用(线程复用导致数据污染)
- 替代方案:Spring
RequestContextHolder、MDC(SLF4J)
7. 优缺点与技术取舍
| 维度 | ThreadLocal | 方法传参 |
|---|---|---|
| 侵入性 | 低 | 高 |
| 类型安全 | 弱 | 强 |
| 可追踪性 | 差 | 好 |
| 内存风险 | 有泄漏风险 | 无 |
8. 常见问题及解决方案
- 问题:内存泄漏(详见 WRITE-03-0020 第 5 题)
- 问题:线程池中数据串号
- 解决:使用
TransmittableThreadLocal或在任务开始时设置
- 解决:使用
9. 版本差异与实现边界
ThreadLocal从 Java 2 就存在ThreadLocalMap使用线性探测法解决哈希冲突- key 使用弱引用,value 使用强引用(导致泄漏问题)
10. 常见追问
- 追问:
ThreadLocal的 key 为什么用弱引用?- 回答方向:当
ThreadLocal变量被回收后,Entry 的 key 自动变为 null,便于清理。
- 回答方向:当
11. 易错点
- ❌ 错误:
ThreadLocal会自动清理 - ✅ 正确:必须手动调用
remove()清理
一句话总结
ThreadLocal 通过每个线程独立的 ThreadLocalMap 实现数据隔离,key 是 ThreadLocal 本身,用于上下文传递和线程安全。
ThreadLocal 如何实现线程隔离?
原始问法:
- ThreadLocal如何实现线程隔离?
来源题目:
SRC-03-34-141
面试先答
ThreadLocal 实现线程隔离的核心机制是:每个 Thread 对象内部有一个 ThreadLocalMap 数据结构(类似哈希表),存储以 ThreadLocal 对象为 key、线程私有值为 value 的键值对。当调用 threadLocal.set(value) 时,JVM 获取当前线程的 ThreadLocalMap,以 ThreadLocal 自身为 key 存入 value;调用 threadLocal.get() 时,从当前线程的 Map 中取值。由于每个线程的 Map 是独立的,所以同一 ThreadLocal 在不同线程中存储的值互不影响。
核心结论
- 每个
Thread独立持有ThreadLocalMap - 以
ThreadLocal对象为 key 存储值 - 天然隔离,无需同步
- 线程销毁时 Map 被回收
1. 是什么
隔离的关键是数据绑定在线程对象上,而非 ThreadLocal 对象上。
线程 A: ThreadLocalMap → { ThreadLocal1 → valueA, ThreadLocal2 → valueA2 }
线程 B: ThreadLocalMap → { ThreadLocal1 → valueB, ThreadLocal2 → valueB2 }
2. 为什么需要它
- 实现线程间数据隔离,避免竞争
- 无需加锁即可实现线程安全
3. 底层原理与完整流程
set 实现(JDK 源码简化):
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t); // 获取线程的 Map
if (map != null)
map.set(this, value); // 以 ThreadLocal 为 key
else
createMap(t, value); // 创建 Map
}
get 实现:
public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null)
return (T)e.value; // 返回线程绑定的值
}
return setInitialValue(); // 返回初始值
}
隔离关键点:
- 数据存储在
Thread对象的threadLocals字段中 - 不同线程的
threadLocals是不同的对象 - 即使使用同一个
ThreadLocal实例,各线程的值也互不影响
4. 怎么使用
public class ThreadLocalIsolationDemo {
private static final ThreadLocal<String> USER_NAME =
ThreadLocal.withInitial(() -> "unknown");
public static void main(String[] args) {
// 主线程设置值
USER_NAME.set("MainThread");
System.out.println(USER_NAME.get()); // MainThread
// 子线程读取(独立的值)
new Thread(() -> {
System.out.println(USER_NAME.get()); // unknown
USER_NAME.set("ChildThread");
System.out.println(USER_NAME.get()); // ChildThread
}).start();
// 主线程值不变
System.out.println(USER_NAME.get()); // MainThread
}
}
5. 适用场景
- 所有需要线程隔离的场景
6. 不适用场景与替代方案
- 不要在忘记清理的情况下使用
- 替代方案:
ScopedValue(JDK 21,更安全)
7. 优缺点与技术取舍
天然隔离、无需同步,但有内存泄漏风险。
8. 常见问题及解决方案
- 问题:
ThreadLocal在子线程中获取不到值- 解决:使用
InheritableThreadLocal(但在线程池中有限制)
- 解决:使用
9. 版本差异与实现边界
- JDK 21 引入
ScopedValue(ThreadLocal的替代品,自动清理)
10. 常见追问
- 追问:
ThreadLocal和InheritableThreadLocal的区别?- 回答方向:
InheritableThreadLocal会在子线程创建时继承父线程的值。
- 回答方向:
11. 易错点
- ❌ 错误:
ThreadLocal的值存在ThreadLocal对象中 - ✅ 正确:值存在
Thread的ThreadLocalMap中
一句话总结
ThreadLocal 通过将值绑定到每个线程独立的 ThreadLocalMap 上实现隔离,key 是 ThreadLocal 本身。
ThreadLocal 内存泄漏是什么场景触发的?怎么解决?
原始问法:
- ThreadLocal内存泄漏是什么场景触发的?怎么解决?
来源题目:
SRC-03-34-142
面试先答
ThreadLocal 内存泄漏的原因是:ThreadLocalMap 的 key 是弱引用(指向 ThreadLocal 对象),value 是强引用。当 ThreadLocal 变量被回收后,key 变为 null,但 value 仍然被 Entry 的强引用持有,无法被 GC 回收。泄漏场景:线程池中的线程长期存活、ThreadLocal 变量被回收但未调用 remove()。解决方案:使用完 ThreadLocal 后必须调用 remove() 方法清理。JDK 21 的 ScopedValue 自动管理生命周期。
核心结论
- 泄漏原因:key 弱引用被回收后 value 仍被强引用持有
- 泄漏场景:线程池、长生命周期线程
- 解决:使用后必须
remove() - JDK 21 的
ScopedValue自动清理
1. 是什么
内存泄漏场景:
1. ThreadLocal 变量 tl 被置为 null
2. GC 回收 tl 对象
3. ThreadLocalMap 中 Entry 的 key 变为 null(弱引用)
4. 但 value 仍被 Entry 强引用 → 无法回收 → 泄漏
5. 线程池中的线程长期存在 → value 累积
2. 为什么需要它
理解泄漏原因才能正确使用 ThreadLocal。
3. 底层原理与完整流程
ThreadLocalMap.Entry 结构:
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value; // 强引用
Entry(ThreadLocal<?> k, Object v) {
super(k); // 弱引用 key
value = v; // 强引用 value
}
}
泄漏过程:
Thread → ThreadLocalMap → Entry[] → Entry → value(强引用,无法回收)
↓
key(弱引用,已回收为 null)
4. 怎么使用
// 正确使用:try-finally
public void processRequest() {
UserContext.setUserId(userId);
try {
// 业务逻辑
} finally {
UserContext.clear(); // 必须清理!
}
}
// 使用 JDK 21 ScopedValue(自动清理)
public class ScopedValueDemo {
private static final ScopedValue<String> USER =
ScopedValue.newInstance();
public void processRequest() {
ScopedValue.where(USER, "alice").run(() -> {
// 业务逻辑,作用域结束自动清理
});
}
}
5. 适用场景
- 所有使用
ThreadLocal的场景都需要注意清理
6. 不适用场景与替代方案
- 不要在线程池中使用
ThreadLocal而不清理 - 替代方案:JDK 21
ScopedValue、Slf4J MDC
7. 优缺点与技术取舍
| 方案 | 泄漏风险 | 清理方式 |
|---|---|---|
ThreadLocal |
有 | 手动 remove() |
ScopedValue |
无 | 自动清理 |
InheritableThreadLocal |
有 | 手动 remove() |
8. 常见问题及解决方案
- 问题:Tomcat 中
ThreadLocal导致内存泄漏- 解决:在 Filter 的
finally中清理
- 解决:在 Filter 的
9. 版本差异与实现边界
- JDK 21:
ScopedValue自动管理,无泄漏风险 - Spring
RequestContextHolder在请求结束后自动清理
10. 常见追问
- 追问:为什么 JDK 不自动清理 value?
- 回答方向:因为 key 弱引用回收时,JDK 无法确定 value 是否还在使用。
11. 易错点
- ❌ 错误:
ThreadLocal使用后不会泄漏 - ✅ 正确:如果不调用
remove()会泄漏
一句话总结
ThreadLocal 内存泄漏是因为 key 弱引用回收后 value 仍被强引用持有,解决方法是使用后必须 remove(),JDK 21 用 ScopedValue 替代。
线程池场景下使用 ThreadLocal 会导致什么问题?如何解决?
原始问法:
- 线程池场景下使用ThreadLocal会导致什么问题?如何解决?
来源题目:
SRC-03-34-143
面试先答
线程池场景下使用 ThreadLocal 会导致两个问题:第一,数据串号——线程池中的线程被复用,如果上一个任务设置了 ThreadLocal 值但未清理,下一个任务可能读到脏数据;第二,内存泄漏——线程池中的线程长期存活,ThreadLocalMap 中的过期 Entry 无法回收。解决方案有三:第一,在任务执行的 finally 块中调用 remove() 清理;第二,使用阿里的 TransmittableThreadLocal 自动传递和清理;第三,使用 TaskDecorator 在任务提交时自动清理。
核心结论
- 线程池复用线程导致数据串号和内存泄漏
- 解决:
finally remove()、TransmittableThreadLocal、TaskDecorator - 必须在任务边界清理
ThreadLocal
1. 是什么
数据串号场景:
线程 A 执行任务 1:setUserId(100) → 未清理
线程 A 执行任务 2:getUserId() → 返回 100(脏数据!)
2. 为什么需要它
线程池复用线程,打破了"一个线程对应一个请求"的假设。
3. 底层原理与完整流程
问题根源:
- 线程池中的线程长期存活
ThreadLocalMap随线程存在- 如果上一个任务未清理,下一个任务会看到旧值
4. 怎么使用
// 方案一:finally 清理
public void executeTask(Runnable task) {
try {
before(); // 设置 ThreadLocal
task.run();
} finally {
ThreadLocalUtils.clearAll(); // 清理
}
}
// 方案二:TaskDecorator(Spring)
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setTaskDecorator(new TaskDecorator() {
@Override
public Runnable decorate(Runnable runnable) {
// 捕获当前线程的 ThreadLocal 值
Map<ThreadLocal<?>, Object> context = captureContext();
return () -> {
// 恢复到新线程
restoreContext(context);
try {
runnable.run();
} finally {
clearContext(); // 清理
}
};
}
});
// 方案三:TransmittableThreadLocal(阿里开源)
TransmittableThreadLocal<String> context = new TransmittableThreadLocal<>();
// 在线程池中自动传递和清理
5. 适用场景
- 所有使用线程池的
ThreadLocal场景 - Spring 应用中的上下文传递
6. 不适用场景与替代方案
- 不要在线程池中直接使用
ThreadLocal而不清理 - 替代方案:
MDC、SpringRequestContextHolder
7. 优缺点与技术取舍
| 方案 | 侵入性 | 自动清理 | 自动传递 |
|---|---|---|---|
| finally remove | 高 | 是 | 否 |
| TaskDecorator | 中 | 是 | 是 |
| TransmittableThreadLocal | 低 | 是 | 是 |
8. 常见问题及解决方案
- 问题:
TransmittableThreadLocal仍有泄漏- 解决:使用
TtlExecutors.getTtlExecutorService()包装线程池
- 解决:使用
9. 版本差异与实现边界
- JDK 本身不解决线程池中
ThreadLocal的传递问题 - Spring
TaskDecorator仅在 Spring 线程池中生效 TransmittableThreadLocal是阿里开源库,独立于 JDK
10. 常见追问
- 追问:
InheritableThreadLocal为什么在线程池中效果不好?- 回答方向:线程池复用线程时不会重新创建,不会触发继承。
11. 易错点
- ❌ 错误:线程池中
ThreadLocal是线程安全的 - ✅ 正确:复用导致数据串号,必须清理
一句话总结
线程池中 ThreadLocal 会导致数据串号和泄漏,通过 finally remove()、TransmittableThreadLocal 或 TaskDecorator 解决。
不同 ThreadLocal 之间如何进行信息传递?
原始问法:
- 不同ThreadLocal之间如何进行信息传递?
来源题目:
SRC-03-34-144
面试先答
不同 ThreadLocal 之间本身不能直接传递信息,因为它们是独立的变量。常见的做法有三种:第一,使用一个 ThreadLocal 存储上下文对象,将所有需要传递的数据放在这个对象中;第二,使用 ThreadLocal 的 get() 获取同一个线程中其他 ThreadLocal 的值;第三,使用 InheritableThreadLocal 或 TransmittableThreadLocal 在线程间传递。最推荐的是使用一个包装类统一管理上下文,如 Spring 的 RequestContextHolder、SLF4J 的 MDC。
核心结论
- 不同
ThreadLocal无法直接传递 - 通过上下文包装类统一管理
- 使用
InheritableThreadLocal在线程间传递 - 推荐使用
MDC或自定义上下文管理器
1. 是什么
// 方式一:上下文对象
public class ThreadContext {
private static final ThreadLocal<Map<String, Object>> CONTEXT =
ThreadLocal.withInitial(HashMap::new);
public static void put(String key, Object value) {
CONTEXT.get().put(key, value);
}
public static Object get(String key) {
return CONTEXT.get().get(key);
}
public static void clear() {
CONTEXT.remove();
}
}
2. 为什么需要它
- 多个
ThreadLocal之间共享数据 - 避免重复存储相同信息
- 统一管理上下文生命周期
3. 底层原理与完整流程
通过一个统一的 ThreadLocal<Map> 存储所有上下文数据,实现间接传递。
4. 怎么使用
// 设置上下文
ThreadContext.put("userId", 1001);
ThreadContext.put("tenantId", "tenant_01");
ThreadContext.put("traceId", UUID.randomUUID().toString());
// 在任何地方获取
Long userId = (Long) ThreadContext.get("userId");
String traceId = (String) ThreadContext.get("traceId");
5. 适用场景
- 多租户系统、链路追踪、权限校验
6. 不适用场景与替代方案
- 不要滥用上下文管理器存储大量数据
- 替代方案:使用
MDC(SLF4J)、RequestContextHolder(Spring)
7. 优缺点与技术取舍
通过统一的上下文管理器简化了 ThreadLocal 之间的协作,但需要注意数据生命周期管理。
8. 常见问题及解决方案
- 问题:上下文数据不一致
- 解决:统一入口设置、出口清理
9. 版本差异与实现边界
- 无版本差异
ScopedValue(JDK 21)提供了更安全的上下文传递
10. 常见追问
- 追问:
MDC是如何实现的?- 回答方向:
MDC内部使用ThreadLocal<Map>存储日志上下文信息。
- 回答方向:
11. 易错点
- ❌ 错误:不同
ThreadLocal可以直接通信 - ✅ 正确:通过共享的上下文对象间接传递
一句话总结
不同 ThreadLocal 通过统一的上下文包装类(ThreadLocal<Map>)间接传递信息,推荐使用 MDC 或自定义管理器。
TransmittableThreadLocal 相比 InheritableThreadLocal 核心解决了什么问题?
原始问法:
- TransmittableThreadLocal相比InheritableThreadLocal核心解决了什么问题?
来源题目:
SRC-03-34-145
面试先答
TransmittableThreadLocal(TTL,阿里开源)相比 InheritableThreadLocal 核心解决了线程池复用线程时数据传递失效的问题。InheritableThreadLocal 仅在子线程创建时从父线程复制数据,线程池中的线程被复用后不会重新创建,因此无法获取新任务的上下文。TransmittableThreadLocal 在任务提交时捕获当前线程的上下文,在任务执行时恢复到子线程,无论线程是否被复用都能正确传递。
核心结论
InheritableThreadLocal:子线程创建时复制一次,线程池中失效TransmittableThreadLocal:任务提交时捕获、执行时恢复,线程池中有效- 解决了线程池场景下的上下文传递问题
- 阿里开源,广泛应用于生产环境
1. 是什么
问题场景:
// InheritableThreadLocal 在线程池中失效
InheritableThreadLocal<String> context = new InheritableThreadLocal<>();
ExecutorService pool = Executors.newFixedThreadPool(1);
context.set("value-A");
pool.execute(() -> {
System.out.println(context.get()); // value-A(第一次,线程创建时复制)
});
context.set("value-B");
pool.execute(() -> {
System.out.println(context.get()); // value-A(不是 value-B!线程被复用)
});
2. 为什么需要它
InheritableThreadLocal在线程池中无法正确传递上下文- 这是生产环境中非常常见的痛点
- TTL 解决了这个核心问题
3. 底层原理与完整流程
TTL 工作机制:
- 任务提交时,
TtlRunnable捕获当前线程的 TTL 上下文快照 - 子线程执行任务前,将快照恢复到子线程
- 任务执行后,清理子线程的 TTL 上下文
提交线程: capture → {key1=val1, key2=val2}
↓
TtlRunnable: 保存快照
↓
子线程: load(snapshot) → 执行 → remove()
4. 怎么使用
// 1. 创建 TTL
TransmittableThreadLocal<String> context = new TransmittableThreadLocal<>();
// 2. 包装线程池
ExecutorService ttlExecutor = TtlExecutors.getTtlExecutorService(
Executors.newFixedThreadPool(4)
);
// 3. 使用
context.set("trace-id-123");
ttlExecutor.execute(() -> {
System.out.println(context.get()); // trace-id-123(正确传递)
});
// 4. 或使用 TtlRunnable / TtlCallable
Runnable ttlTask = TtlRunnable.get(() -> {
System.out.println(context.get());
});
executor.execute(ttlTask);
5. 适用场景
- 线程池中的上下文传递(用户 ID、Trace ID、租户 ID)
- 异步任务中的
ThreadLocal传递 - 全链路追踪
6. 不适用场景与替代方案
- 不要在非线程池场景中使用(没必要)
- 替代方案:
TaskDecorator(Spring)、手动传递
7. 优缺点与技术取舍
| 维度 | InheritableThreadLocal | TransmittableThreadLocal |
|---|---|---|
| 线程池支持 | 不支持 | 支持 |
| 传递时机 | 线程创建时 | 任务提交时 |
| 自动清理 | 不支持 | 支持 |
| 实现复杂度 | 低 | 高 |
8. 常见问题及解决方案
- 问题:TTL 与 Spring
TaskDecorator冲突- 解决:选择其中一种方案即可
9. 版本差异与实现边界
TransmittableThreadLocal是阿里开源项目- 最新版本 2.x,支持 JDK 1.6+
- 广泛应用于阿里巴巴、美团等公司
10. 常见追问
- 追问:TTL 如何与
CompletableFuture配合?- 回答方向:使用
TtlExecutors.getTtlExecutorService()包装CompletableFuture的线程池。
- 回答方向:使用
11. 易错点
- ❌ 错误:
InheritableThreadLocal在线程池中也能工作 - ✅ 正确:线程复用导致传递失效,必须用
TransmittableThreadLocal
一句话总结
TransmittableThreadLocal 解决了 InheritableThreadLocal 在线程池中复用线程时数据传递失效的核心问题,通过任务快照机制实现可靠的上下文传递。
Atomic 原子类的原理是什么?
原始问法:
- Atomic原子类的原理是什么?
来源题目:
SRC-03-34-146
面试先答
Java 的 Atomic 原子类基于 CAS(Compare-And-Swap) 实现。每个原子类内部维护一个 volatile 变量作为存储,通过 Unsafe 类的 native 方法直接调用 CPU 级别的 CAS 指令实现原子更新。以 AtomicInteger 为例:内部有一个 volatile int value,incrementAndGet() 方法在循环中读取旧值、计算新值、通过 CAS 比较并交换,成功则返回,失败则重试。Java 8 的 LongAdder 进一步优化,将单一 value 分散到多个 cell,减少 CAS 竞争。
核心结论
- 原子类基于 CAS +
volatile实现 - 通过
Unsafe的 native 方法调用 CPU CAS 指令 - 基本类型原子类:
AtomicInteger、AtomicLong、AtomicBoolean - 引用类型:
AtomicReference、AtomicStampedReference - JDK 8 优化:
LongAdder、DoubleAdder
1. 是什么
原子类家族:
| 分类 | 类 | 功能 |
|---|---|---|
| 基本类型 | AtomicInteger、AtomicLong、AtomicBoolean |
原子操作基本类型 |
| 引用类型 | AtomicReference<T>、AtomicStampedReference<T> |
原子操作引用类型 |
| 数组类型 | AtomicIntegerArray、AtomicLongArray、AtomicReferenceArray |
原子操作数组 |
| 属性类型 | AtomicIntegerFieldUpdater、AtomicLongFieldUpdater |
原子操作对象的 volatile 字段 |
| 累加器 | LongAdder、DoubleAdder、LongAccumulator |
高并发累加优化 |
2. 为什么需要它
- 提供无锁的线程安全操作
- 比
synchronized更轻量(无阻塞、无上下文切换) - 适合计数器、状态标志等简单原子操作
3. 底层原理与完整流程
AtomicInteger 源码:
public class AtomicInteger extends Number {
private static final Unsafe U = Unsafe.getUnsafe();
private static final long VALUE;
static {
VALUE = U.objectFieldOffset(AtomicInteger.class.getDeclaredField("value"));
}
private volatile int value; // volatile 保证可见性
public final int incrementAndGet() {
int prev, next;
do {
prev = value; // 读
next = prev + 1; // 计算
} while (!U.compareAndSetInt(this, VALUE, prev, next)); // CAS
return next;
}
}
LongAdder 优化:
LongAdder:
┌───────────────────────────────┐
│ base (long) │ ← 低并发使用
├───────────────────────────────┤
│ cells[] (Cell 数组) │ ← 高并发分散
│ ┌───┬───┬───┬───┐ │
│ │ 0 │ 0 │ 1 │ 0 │ │
│ └───┴───┴───┴───┘ │
└───────────────────────────────┘
4. 怎么使用
// AtomicInteger
AtomicInteger counter = new AtomicInteger(0);
counter.incrementAndGet(); // 原子 +1
counter.getAndAdd(10); // 原子 +10
// LongAdder(高并发推荐)
LongAdder adder = new LongAdder();
adder.increment(); // 分拆累加,减少 CAS 竞争
long result = adder.sum(); // 汇总结果
// AtomicReference
AtomicReference<String> ref = new AtomicReference<>("init");
ref.compareAndSet("init", "updated");
// AtomicStampedReference(解决 ABA)
AtomicStampedReference<String> stampedRef =
new AtomicStampedReference<>("A", 0);
stampedRef.compareAndSet("A", "B", 0, 1);
5. 适用场景
- 计数器、累加器(推荐
LongAdder) - 状态标志位
- 无锁数据结构(
ConcurrentHashMap、ConcurrentLinkedQueue)
6. 不适用场景与替代方案
- 不要用原子类做多变量复合操作
- 不要在高并发下使用单一
AtomicInteger(用LongAdder) - 替代方案:
synchronized、ReentrantLock
7. 优缺点与技术取舍
| 原子类 | 优点 | 缺点 |
|---|---|---|
AtomicInteger |
简单、轻量 | 高并发下 CAS 自旋 |
LongAdder |
高并发高性能 | 非精确操作(sum 非原子) |
8. 常见问题及解决方案
- 问题:
AtomicInteger高并发下 CPU 高- 解决:改用
LongAdder
- 解决:改用
9. 版本差异与实现边界
- Java 5:引入原子类(
AtomicInteger等) - Java 8:引入
LongAdder、DoubleAdder - Java 9:
VarHandle提供更灵活的原子操作
10. 常见追问
- 追问:
LongAdder.sum()是原子的吗?- 回答方向:不是,
sum()只是简单求和,不保证原子性,适合统计场景。
- 回答方向:不是,
11. 易错点
- ❌ 错误:原子类可以替代所有锁
- ✅ 正确:原子类只适合单变量操作
一句话总结
原子类基于 CAS + volatile 实现无锁原子操作,LongAdder 在高并发下进一步优化,是实现线程安全计数器的首选。
3.5 Java 内存模型
什么是 JMM 模型?三大特性是什么?如何保证?
原始问法:
- 什么是JMM模型?三大特性是什么?如何保证?
来源题目:
SRC-03-35-147
面试先答
JMM(Java Memory Model,Java 内存模型)是 Java 虚拟机规范中定义的内存访问模型,规定了多线程读写共享变量时的内存可见性和有序性规则。JMM 的三大特性是:原子性——操作要么全部执行要么不执行;可见性——一个线程修改的值对其他线程立即可见;有序性——指令按代码顺序执行,禁止重排序。保证方式:原子性通过 synchronized、Lock、原子类(CAS)保证;可见性通过 volatile、synchronized、final 保证;有序性通过 volatile(内存屏障)、synchronized、final 保证。理解 JMM 是掌握 Java 并发编程的核心。
核心结论
- JMM 定义了多线程内存访问的规则
- 三大特性:原子性、可见性、有序性
- 原子性:
synchronized、Lock、CAS - 可见性:
volatile、synchronized、final - 有序性:
volatile(内存屏障)、synchronized、final - JMM 基于 happens-before 原则建立
1. 是什么
JMM(Java Memory Model):Java 内存模型,定义了线程与主内存之间的抽象关系。
JMM 内存结构:
线程 A 主内存(Main Memory)
┌──────────────┐ ┌──────────────────┐
│ 工作内存 A │ │ │
│ (CPU Cache) │ 读写 │ 共享变量 │
│ │ ←─────→ │ │
│ 变量副本 │ │ │
└──────────────┘ └──────────────────┘
线程 B 主内存(Main Memory)
┌──────────────┐ ┌──────────────────┐
│ 工作内存 B │ │ │
│ (CPU Cache) │ 读写 │ 共享变量 │
│ │ ←─────→ │ │
└──────────────┘ └──────────────────┘
三大特性:
| 特性 | 含义 | 保证机制 |
|---|---|---|
| 原子性 | 操作不可分割 | synchronized、Lock、CAS |
| 可见性 | 修改立即可见 | volatile、synchronized、final |
| 有序性 | 禁止重排序 | volatile、synchronized、final |
2. 为什么需要它
- 不同 CPU 架构内存模型不同(x86 强、ARM 弱)
- 编译器会优化指令顺序
- JMM 提供统一的内存可见性和有序性保证
3. 底层原理与完整流程
happens-before 原则: JMM 通过 happens-before 规则保证有序性,如果两个操作之间存在 happens-before 关系,那么前一个操作的结果对后一个操作可见。
八大 happens-before 规则:
- 程序顺序规则:同一线程中,前面的操作 happens-before 后面的
- 监视器锁规则:
unlockhappens-before 后续对同一锁的lock volatile变量规则:volatile写 happens-before 后续对该变量的读- 线程启动规则:
Thread.start()happens-before 线程内的所有操作 - 线程终止规则:线程内的所有操作 happens-before
Thread.join()返回 - 传递性:如果 A happens-before B,B happens-before C,则 A happens-before C
final字段规则:构造函数中对final的写 happens-before 其他线程读到该final字段- 中断规则:对线程
interrupt()的调用 happens-before 被中断线程检测到中断事件
可见性保证:
volatile写:强制刷新主内存volatile读:强制从主内存读取synchronized:锁释放前刷新主内存,锁获取后从主内存读取
有序性保证:
volatile:禁止重排序(内存屏障)synchronized:锁内代码保持顺序final:初始化安全性
4. 怎么使用
// volatile 保证可见性和有序性
public class JMMDemo {
private volatile boolean ready = false;
private int data = 0;
public void writer() {
data = 1; // 写
ready = true; // volatile 写,保证 data 的修改对 reader 可见
}
public void reader() {
if (ready) { // volatile 读,保证能看到 data = 1
System.out.println(data); // 可能输出 0(重排序时)
}
}
}
// DCL 单例 - volatile 保证有序性
public class Singleton {
private static volatile Singleton instance;
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton(); // volatile 防止重排序
}
}
}
return instance;
}
}
5. 适用场景
- 所有 Java 并发编程都依赖 JMM 的规则
- 理解 JMM 是正确使用并发工具的基础
6. 不适用场景与替代方案
- 不要依赖 JMM 的默认行为(无同步时无保证)
- 替代方案:使用 JUC 包的高级工具
7. 优缺点与技术取舍
JMM 提供了统一的内存可见性保证,但需要开发者正确使用 volatile、synchronized 等同步机制。
8. 常见问题及解决方案
- 问题:没有使用同步时数据不一致
- 解决:使用
volatile、synchronized等建立 happens-before 关系
- 解决:使用
9. 版本差异与实现边界
- JMM 规范从 Java 1.0 就存在,但在 Java 5 中被重新定义
- Java 5 后的 JMM 建立在 happens-before 原则上
volatile语义在 Java 5 后被加强- JVM 实现差异:HotSpot 的内存屏障实现
10. 常见追问
- 追问:happens-before 和内存屏障的关系?
- 回答方向:happens-before 是 JMM 的抽象规则,内存屏障是 CPU 层面的实现手段。
11. 易错点
- ❌ 错误:JMM 保证所有操作的原子性
- ✅ 正确:只有
synchronized、Lock、CAS 保证原子性
一句话总结
JMM 是 Java 内存模型,通过 happens-before 规则保证原子性、可见性和有序性,是 Java 并发编程的基础规范。
什么是指令重排序?如何禁止指令重排序?
原始问法:
- 什么是指令重排序?如何禁止指令重排序?
来源题目:
SRC-03-35-148
面试先答
指令重排序是指编译器或 CPU 在执行指令时,为了提高性能而改变指令的执行顺序,但这种改变不改变程序的正确结果(在单线程中)。重排序分为两类:编译器重排序和 CPU 重排序。在多线程环境下,重排序可能导致程序错误。禁止重排序的方法:第一,使用 volatile 关键字——通过内存屏障禁止编译器和 CPU 的重排序;第二,使用 synchronized——同步块内的指令不会被重排到块外;第三,使用 final——保证初始化安全性。典型的例子是 DCL 单例中必须使用 volatile 防止重排序。
核心结论
- 指令重排序是编译器/CPU 为优化性能而调整指令顺序
- 单线程重排序不影响结果,多线程可能出错
- 禁止方法:
volatile(内存屏障)、synchronized、final - 典型场景:DCL 单例、双重检查锁
1. 是什么
指令重排序:编译器或 CPU 改变程序中指令的执行顺序。
重排序类型:
- 编译器重排序:编译器优化时调整指令顺序
- CPU 重排序:CPU 执行时动态调整指令顺序(乱序执行)
重排序示例:
// 原始代码
data = 1; // 指令 1
flag = true; // 指令 2
// 可能被重排为
flag = true; // 指令 2(先执行)
data = 1; // 指令 1(后执行)
单线程中无影响,多线程中另一个线程可能看到 flag = true 但 data = 0。
2. 为什么需要它
- 编译器和 CPU 为了提高性能进行优化
- 但在多线程中可能导致程序错误
3. 底层原理与完整流程
DCL 单例重排序问题:
instance = new Singleton(); // 分为三步:
// 1. 分配内存
// 2. 初始化对象
// 3. 将引用指向内存地址
// 可能被重排为:
// 1. 分配内存
// 3. 将引用指向内存地址 ← 重排!
// 2. 初始化对象
// 后果:
// 线程 A: 执行到步骤 3,instance 不为 null
// 线程 B: 检查 instance != null,直接使用 → 未初始化!
volatile 禁止重排序的实现:
- 写之前:StoreStore 屏障(禁止写-写重排)
- 写之后:StoreLoad 屏障(禁止写-读重排)
- 读之前:LoadLoad 屏障(禁止读-读重排)
- 读之后:LoadStore 屏障(禁止读-写重排)
4. 怎么使用
// 使用 volatile 禁止重排序
public class ProperSingleton {
private static volatile ProperSingleton instance; // 必须 volatile!
private ProperSingleton() {}
public static ProperSingleton getInstance() {
if (instance == null) { // 第一次检查
synchronized (ProperSingleton.class) {
if (instance == null) { // 第二次检查
instance = new ProperSingleton(); // volatile 写
}
}
}
return instance;
}
}
// 使用 final 保证初始化安全性
public class FinalSingleton {
private final int value;
public FinalSingleton() {
this.value = 42; // final 写,保证其他线程能看到
}
}
5. 适用场景
- DCL 单例(必须用
volatile) - 发布不可变对象(
final保证初始化安全) - 双重检查锁场景
6. 不适用场景与替代方案
- 不要在没有同步的情况下依赖程序顺序
- 替代方案:使用
synchronized、Lock、枚举单例(天然防止重排序)
7. 优缺点与技术取舍
重排序提高了单线程性能,但增加了多线程编程的复杂度。通过 volatile 等机制可以禁止重排序,但会带来一定的性能开销。
8. 常见问题及解决方案
- 问题:DCL 单例不加
volatile偶尔出错- 解决:使用
volatile或改用枚举单例
- 解决:使用
9. 版本差异与实现边界
- Java 5+ 中
volatile的语义被加强,完全建立在 happens-before 上 - Java 5 之前的
volatile不保证原子性(现在也不保证,但可见性和有序性已保证) - x86 架构的 TSO(Total Store Order)模型比 ARM 弱模型更少重排序
10. 常见追问
- 追问:为什么枚举单例不需要
volatile?- 回答方向:枚举的创建由 JVM 保证线程安全,类加载机制天然防止重排序。
11. 易错点
❌ 错误:重排序一定会发生
✅ 正确:重排序是可能发生,不是必然发生,所以是偶发 bug
❌ 错误:
synchronized不能防止重排序✅ 正确:
synchronized锁内代码不会被重排到锁外
一句话总结
指令重排序是编译器/CPU 的性能优化,多线程中可能导致错误,通过 volatile 的内存屏障、synchronized、final 禁止。
什么是内存屏障?有什么作用?
原始问法:
- 什么是内存屏障?有什么作用?
来源题目:
SRC-03-35-149
面试先答
内存屏障(Memory Barrier)是 CPU 级别的同步指令,用于保证内存操作的顺序性和可见性。它像一道屏障,阻止屏障前后的指令被重排序,并强制将缓存数据刷新到主内存或从主内存读取。内存屏障有四种类型:LoadLoad、StoreStore、LoadStore、StoreLoad。在 Java 中,volatile、synchronized、final 等关键字的可见性和有序性保证都是通过内存屏障实现的。内存屏障是理解 JMM 和底层同步机制的关键。
核心结论
- 内存屏障是 CPU 同步指令,保证有序性和可见性
- 四种类型:LoadLoad、StoreStore、LoadStore、StoreLoad
- 作用:禁止重排序 + 强制缓存刷新
- Java 中
volatile/synchronized通过内存屏障实现同步
1. 是什么
内存屏障(Memory Barrier):CPU 级别的指令,保证屏障前后的内存操作顺序不被重排序,并强制缓存与主内存同步。
四种内存屏障:
| 屏障类型 | 作用 | 说明 |
|---|---|---|
| LoadLoad | 禁止 读-读 重排 | 保证前一个读在后一个读之前完成 |
| StoreStore | 禁止 写-写 重排 | 保证前一个写在后一个写之前完成 |
| LoadStore | 禁止 读-写 重排 | 保证前一个读在后一个写之前完成 |
| StoreLoad | 禁止 写-读 重排 | 保证前一个写在后一个读之前完成(最重量级) |
2. 为什么需要它
- CPU 执行指令时可能乱序执行以提高性能
- CPU 缓存导致不同核心看到的数据不一致
- 内存屏障为并发编程提供有序性和可见性保证
3. 底层原理与完整流程
CPU 级实现:
- x86:
MFENCE(全屏障)、SFENCE(Store 屏障)、LFENCE(Load 屏障) - ARM:
DMB(Data Memory Barrier)、DSB(Data Synchronization Barrier)、ISB(Instruction Synchronization Barrier)
Java 中的使用:
volatile 写:
... 写 1 → StoreStore → volatile 写 → StoreLoad → ... 读 2
(屏障保证写 1 不被重排到 volatile 写之后)
(屏障保证 volatile 写不被重排到读 2 之后)
(同时强制刷新缓存到主内存)
volatile 读:
... 写 1 → LoadLoad → volatile 读 → LoadStore → ... 写 2
(屏障保证写 1 不被重排到 volatile 读之后)
(屏障保证 volatile 读不被重排到写 2 之后)
(同时强制从主内存读取)
synchronized 中的内存屏障:
- 进入同步块:在
lock后插入 Load 屏障(强制读) - 退出同步块:在
unlock前插入 Store 屏障(强制写)
4. 怎么使用
// 开发者无需手动使用内存屏障
// JMM 会在以下情况自动插入:
// 1. volatile 读写
volatileFlag = true; // 隐含 StoreStore + StoreLoad
if (volatileFlag) { } // 隐含 LoadLoad + LoadStore
// 2. synchronized
synchronized (lock) {
// 进入:Load 屏障
// 退出:Store 屏障
}
// 3. final 字段的初始化
public class SafePublication {
final int value;
public SafePublication(int v) {
this.value = v; // final 写,保证初始化安全
}
}
// 4. VarHandle(JDK 9+)
VarHandle handle = MethodHandles.lookup()
.in(MyClass.class)
.findVarHandle(MyClass.class, "counter", int.class)
.getVarHandle();
handle.setVolatile(this, 10); // volatile 写(含屏障)
int val = handle.getVolatile(this); // volatile 读(含屏障)
5. 适用场景
- 所有使用
volatile、synchronized、final的场景 - 理解 JMM 和并发编程的底层机制
6. 不适用场景与替代方案
- 不要在 Java 代码中手动使用内存屏障(JMM 自动处理)
- 替代方案:使用
volatile、synchronized等高层机制
7. 优缺点与技术取舍
内存屏障提供了强同步保证,但会带来性能开销(阻止 CPU 优化、强制缓存刷新)。在 x86 架构上,由于 TSO 内存模型,StoreLoad 屏障默认存在,性能影响较小;在 ARM 架构上影响更大。
8. 常见问题及解决方案
- 问题:
volatile变量性能比普通变量差- 解决:这是内存屏障的必要开销,换取可见性和有序性保证
9. 版本差异与实现边界
- 内存屏障是 CPU 指令,与 JDK 版本无关
- x86(TSO 模型):部分屏障默认存在,性能好
- ARM(弱内存模型):需要更多屏障,性能差
- JDK 9 的
VarHandle提供了更灵活的屏障控制
10. 常见追问
- 追问:
synchronized和volatile都用了内存屏障,有什么区别?- 回答方向:
synchronized通过 monitor 实现,除了屏障还有锁竞争和阻塞;volatile只有屏障,无锁开销。
- 回答方向:
11. 易错点
❌ 错误:内存屏障只在
volatile中使用✅ 正确:
synchronized、final也使用内存屏障❌ 错误:内存屏障只保证可见性
✅ 正确:内存屏障同时保证可见性和有序性
一句话总结
内存屏障是 CPU 级同步指令,禁止重排序并强制缓存刷新,是 volatile、synchronized、final 实现可见性和有序性的底层机制,是 JMM 的核心基础。