Appearance
并发编程是中高级 Java 面试的分水岭。从线程基础到 JMM、AQS、线程池、并发容器,考察的是你对多线程环境下的正确性和性能的理解深度。
Q1: 线程的创建方式有哪几种? 「🟢 校招/初级」
考察点:考察候选人对 Java 线程基础的掌握程度,是否了解不同创建方式的本质区别,以及实际生产中的最佳实践。筛掉只会背概念、不了解实际应用场景的人。
参考答案:
Java 中创建线程主要有以下四种方式:
1. 继承 Thread 类
java
public class MyThread extends Thread {
@Override
public void run() {
System.out.println("继承Thread类创建线程");
}
}
// 使用:new MyThread().start();优点:编写简单,run() 方法内直接使用 this 获取当前线程。 缺点:Java 单继承限制,无法再继承其他类;任务与线程耦合,不便于复用。
2. 实现 Runnable 接口
java
public class MyRunnable implements Runnable {
@Override
public void run() {
System.out.println("实现Runnable接口创建线程");
}
}
// 使用:new Thread(new MyRunnable()).start();优点:避免单继承限制;任务与线程解耦,同一个 Runnable 可被多个线程执行;适合线程池管理。 缺点:没有返回值,不能抛出受检异常。
3. 实现 Callable 接口 + FutureTask(有返回值、可抛异常)
java
public class MyCallable implements Callable<Integer> {
@Override
public Integer call() throws Exception {
Thread.sleep(1000);
return 42;
}
}
// 使用:
FutureTask<Integer> futureTask = new FutureTask<>(new MyCallable());
new Thread(futureTask).start();
Integer result = futureTask.get(); // 阻塞等待结果优点:支持返回值,可抛出受检异常;通过 Future 可以取消任务、查询是否完成。 缺点:调用 get() 会阻塞当前线程,需要配合超时机制使用。
4. 线程池创建(实际生产推荐)
java
ExecutorService executor = Executors.newFixedThreadPool(10);
executor.execute(() -> System.out.println("线程池执行任务"));
Future<String> future = executor.submit(() -> "有返回值的任务");优点:
- 复用线程,减少线程创建和销毁的开销
- 控制并发数,防止资源耗尽
- 提供任务调度、队列管理等高级功能
- 统一管理线程生命周期
缺点:需要合理配置参数,不当配置可能导致 OOM 或性能问题。
四种方式对比
| 方式 | 返回值 | 异常处理 | 继承限制 | 复用性 | 生产推荐 |
|---|---|---|---|---|---|
| 继承 Thread | 无 | 只能 try-catch | 单继承限制 | 差 | 不推荐 |
| 实现 Runnable | 无 | 只能 try-catch | 无 | 好 | 一般 |
| Callable + FutureTask | 有 | 可抛受检异常 | 无 | 好 | 较少直接用 |
| 线程池 | 有(submit) | 完善 | 无 | 最好 | 强烈推荐 |
注意:严格来说,真正的线程只有 Thread 类一种,Runnable、Callable 只是任务的描述方式,最终都要交给 Thread 或线程池来执行。
追问延伸:
- 为什么说 Callable 是有返回值的 Runnable?它们的本质区别是什么?
- FutureTask 的实现原理是什么?它是如何同时支持 Runnable 和 Callable 的?
- 为什么阿里巴巴 Java 开发手册不推荐使用 Executors 创建线程池?
- 除了这四种,你还知道哪些创建线程的方式?(如 CompletableFuture、虚拟线程等)
Q2: 线程的生命周期和状态转换? 「🟢 校招/初级」
考察点:考察对线程状态机的理解,是否清楚各种方法对线程状态的影响。筛掉对线程运行机制一知半解的人。
参考答案:
六种线程状态
Thread 类内部定义了 State 枚举,共 6 种状态:
| 状态 | 说明 |
|---|---|
| NEW | 新建状态,线程对象已创建,但尚未调用 start() 方法 |
| RUNNABLE | 可运行状态,包括 Ready(就绪)和 Running(运行中)两个子状态。调用 start() 后进入就绪,等待 CPU 调度;获得 CPU 时间片后进入运行 |
| BLOCKED | 阻塞状态,线程在获取 synchronized 锁失败时进入阻塞,等待锁释放 |
| WAITING | 等待状态,调用 wait()、join()、LockSupport.park() 等进入无限期等待,需要被主动唤醒 |
| TIMED_WAITING | 计时等待状态,调用 sleep(time)、wait(time)、join(time)、LockSupport.parkNanos() 等进入,超时后自动唤醒 |
| TERMINATED | 终止状态,线程执行完毕或因异常退出 |
状态转换图(文字描述)
┌─────────┐ start() ┌────────────┐ 获取锁失败 ┌──────────┐
│ NEW │ ──────────▶│ RUNNABLE │ ───────────▶│ BLOCKED │
└─────────┘ └────────────┘ └──────────┘
│ ▲ │
│ │ 获取锁成功 │
│ └─────────────────────┘
│
wait()/join() │ notify()/notifyAll()
▼
┌───────────┐
│ WAITING │
└───────────┘
▲
│
sleep(time)/wait(time)/join(time)
│
┌────────────────┐
│ TIMED_WAITING │
└────────────────┘
RUNNABLE 执行完毕 / 异常退出 ──▶ TERMINATED各方法对状态的影响
| 方法 | 调用后状态变化 | 是否释放锁 | 唤醒条件 |
|---|---|---|---|
start() | NEW → RUNNABLE | - | - |
run() 正常结束 | RUNNABLE → TERMINATED | - | - |
sleep(long) | RUNNABLE → TIMED_WAITING | 不释放 | 时间到 |
wait() | RUNNABLE → WAITING | 释放 | notify / notifyAll |
wait(long) | RUNNABLE → TIMED_WAITING | 释放 | 时间到 / notify |
notify() | 一个 WAITING → BLOCKED → RUNNABLE | - | - |
notifyAll() | 全部 WAITING → BLOCKED → RUNNABLE | - | - |
join() | 当前线程 → WAITING | 不释放(内部是 wait) | 被 join 线程执行完 |
yield() | 仍在 RUNNABLE(让出 CPU 但不释放锁) | 不释放 | 重新调度 |
LockSupport.park() | RUNNABLE → WAITING | 不释放 | unpark / 中断 |
关键理解
- RUNNABLE 包含 Ready 和 Running:Java 线程层面不区分就绪和运行,都叫 RUNNABLE,具体由 OS 调度器决定。
- BLOCKED vs WAITING:
- BLOCKED:等待监视器锁,是"被动"的,锁释放后自动竞争
- WAITING:调用
wait()/join()等进入,是"主动"的,需要被notify()唤醒
- notify 后不立即运行:notify 只是让等待线程从 WAITING 进入 BLOCKED(锁池),需要重新竞争锁,拿到锁后才进入 RUNNABLE。
追问延伸:
- 为什么 wait/notify/notifyAll 定义在 Object 类而不是 Thread 类?
- yield() 和 sleep() 有什么区别?
- 一个线程两次调用 start() 会发生什么?为什么?
- 如何理解 WAITING 和 BLOCKED 都属于"阻塞",但 JVM 要区分它们?
Q3: sleep() 和 wait() 的区别? 「🟢 校招/初级」
考察点:考察对线程基础方法的理解深度,尤其是锁释放机制和使用场景的差异。筛掉只会死记硬背、不理解原理的人。
参考答案:
sleep() 和 wait() 都能让线程暂停执行,但它们在所属类、锁释放、使用位置、唤醒方式等方面有本质区别。
核心区别对比表
| 对比维度 | sleep() | wait() |
|---|---|---|
| 所属类 | Thread 类的静态方法 | Object 类的实例方法 |
| 锁释放 | 不释放锁 | 释放锁(monitor) |
| 使用位置 | 任何地方都能调用 | 必须在同步方法/同步块中调用(即持有 monitor 时) |
| 唤醒方式 | 睡眠时间到自动唤醒 | 需要 notify() / notifyAll() 唤醒,或超时自动唤醒 |
| 用途 | 暂停执行一段时间 | 线程间通信/协作 |
| 异常 | 抛出 InterruptedException | 抛出 InterruptedException |
代码示例对比
sleep() 不释放锁:
java
public class SleepDemo {
private static final Object lock = new Object();
public static void main(String[] args) {
new Thread(() -> {
synchronized (lock) {
System.out.println("线程A获取锁,开始sleep");
try {
Thread.sleep(3000); // sleep 不释放锁
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("线程A sleep结束,释放锁");
}
}).start();
new Thread(() -> {
synchronized (lock) {
System.out.println("线程B获取锁"); // 3秒后才会打印
}
}).start();
}
}wait() 释放锁:
java
public class WaitDemo {
private static final Object lock = new Object();
public static void main(String[] args) {
new Thread(() -> {
synchronized (lock) {
System.out.println("线程A获取锁,开始wait");
try {
lock.wait(3000); // wait 释放锁
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("线程A被唤醒,重新获取锁");
}
}).start();
new Thread(() -> {
synchronized (lock) {
System.out.println("线程B立即获取锁"); // 几乎立即打印
}
}).start();
}
}为什么 wait() 必须在同步块中?
- 语义要求:wait() 的语义是"释放锁并等待",如果没有持有锁,就谈不上释放锁。
- 防止竞态:如果不要求在同步块中,可能出现
wait()和notify()调用顺序错乱,导致信号丢失(signal missed)。
为什么 sleep() 定义在 Thread 而 wait() 定义在 Object?
- sleep() 是让当前线程暂停,操作对象是线程本身,所以定义在 Thread 类。
- wait() 是让锁的持有者等待,操作对象是对象监视器(monitor),Java 中每个对象都有 monitor,所以定义在 Object 类。
共同点
- 都能让线程进入等待状态(sleep 进入 TIMED_WAITING,wait 无参进入 WAITING、有参进入 TIMED_WAITING)
- 都抛出 InterruptedException,响应中断
- 都不会释放 CPU 调度资格以外的资源(区别在于是否释放锁)
追问延伸:
- 为什么 wait/notify/notifyAll 要和 synchronized 一起使用?不用会怎样?
- sleep(0) 和 yield() 有什么区别和联系?
- 如何实现一个线程等待另一个线程完成后再执行?用 wait/notify 和 join 分别怎么实现?
- 调用 wait() 后线程是立即释放锁吗?notify 后线程是立即获取锁吗?
Q4: synchronized 的实现原理?锁升级过程? 「🟡 中级」
考察点:考察对 synchronized 底层实现的理解,是否了解 JVM 对锁的优化机制。筛掉只会用 synchronized 但不理解其底层原理的人。
参考答案:
synchronized 的底层实现
synchronized 是 JVM 层面的关键字,基于对象的监视器(Monitor)实现。
字节码层面
java
public void syncMethod() {
synchronized (this) {
// 临界区
}
}编译后的字节码:
monitorenter // 进入同步块
... 临界区代码 ...
monitorexit // 退出同步块(正常退出)
monitorexit // 异常退出(编译器自动插入,保证异常时也释放锁)- monitorenter:尝试获取对象 monitor 的所有权,若计数器为 0 则获取成功并将计数器置 1;若已持有则计数器 +1(可重入);否则阻塞等待。
- monitorexit:释放 monitor,计数器 -1,减到 0 则释放锁。
- 两个 monitorexit:一个对应正常路径,一个对应异常路径,确保锁一定会被释放。
对象头与 Mark Word
在 JVM 中,对象在内存中分为三部分:对象头、实例数据、对齐填充。
对象头(Object Header)包含两部分:
- Mark Word:存储对象自身的运行时数据(哈希码、GC分代年龄、锁标志位、偏向线程ID等)
- Klass Pointer:指向方法区中对象类型元数据的指针
Mark Word 在不同锁状态下的结构(32位 JVM):
| 锁状态 | 25bit | 4bit | 1bit | 2bit |
|---|---|---|---|---|
| 无锁 | 对象 hashCode | GC 分代年龄 | 0 | 01 |
| 偏向锁 | 线程 ID + Epoch | GC 分代年龄 | 1 | 01 |
| 轻量级锁 | 指向栈中锁记录的指针 | 00 | ||
| 重量级锁 | 指向重量级锁(monitor)的指针 | 10 | ||
| GC 标记 | 空 | 11 |
锁升级过程
JDK 1.6 之后,synchronized 引入了锁升级机制,从低到高依次为:无锁 → 偏向锁 → 轻量级锁 → 重量级锁。锁只能升级不能降级(偏向锁可以被撤销)。
1. 无锁状态
对象刚创建时,还没有任何线程竞争,处于无锁状态。Mark Word 中存储对象的 hashCode 和 GC 分代年龄。
2. 偏向锁
适用场景:锁总是被同一个线程获取,没有竞争。
过程:
- 第一个线程访问同步块时,使用 CAS 将 Mark Word 中的线程 ID 设置为当前线程 ID
- 以后该线程进入同步块时,只需判断线程 ID 是否匹配,无需 CAS
- 这是一种"偏心"机制,默认偏向第一个获取它的线程
偏向锁撤销(Revoke):
- 当另一个线程尝试获取锁时,偏向模式结束
- 撤销需要等待全局安全点(Safepoint),暂停持有偏向锁的线程
- 检查原线程是否还活着,如果已死则恢复无锁并重新偏向;如果还活着则升级为轻量级锁
3. 轻量级锁
适用场景:多线程交替执行同步块,竞争不激烈。
过程:
- 线程在自己的栈帧中创建 Lock Record(锁记录)
- 将 Mark Word 复制到 Lock Record 中(Displaced Mark Word)
- 使用 CAS 尝试将对象头的 Mark Word 替换为指向 Lock Record 的指针
- 成功则获取轻量级锁,失败说明有竞争,进入自旋(自适应自旋)
- 自旋一定次数仍未获取到锁,则膨胀为重量级锁
自适应自旋:JDK 1.6 引入,自旋时间不固定,由前一次在同一个锁上的自旋时间和锁拥有者的状态决定。
4. 重量级锁
适用场景:多线程激烈竞争,自旋开销大于线程阻塞唤醒开销。
- 依赖操作系统的互斥量(Mutex)实现
- 线程阻塞和唤醒需要从用户态切换到内核态,开销大
- 此时 Mark Word 中存储指向 ObjectMonitor 的指针
- ObjectMonitor 内部有 EntryList(等待队列)、WaitSet(wait 等待队列)、owner(持有线程)
锁升级路径总结
无锁 ──▶ 偏向锁 ──▶ 轻量级锁 ──▶ 重量级锁
撤销↑ 自旋失败↑其他锁优化
锁消除(Lock Elimination)
JIT 编译器在运行时,如果检测到某些对象不可能被多线程访问(即不存在共享数据竞争),就会对这些同步锁进行消除。
java
public String concat(String s1, String s2, String s3) {
// StringBuffer 的 append 是 synchronized 的
// 但 sb 是局部变量,不可能逃逸到其他线程
// JIT 会消除这里的锁
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
sb.append(s3);
return sb.toString();
}锁粗化(Lock Coarsening)
如果有一串连续的操作都对同一个对象反复加锁解锁,JVM 会将锁的范围扩展到整个操作序列的外部。
java
for (int i = 0; i < 100; i++) {
synchronized (lock) {
// 操作
}
}
// 可能被粗化为:
synchronized (lock) {
for (int i = 0; i < 100; i++) {
// 操作
}
}追问延伸:
- 偏向锁有什么缺点?为什么 JDK 15 默认禁用了偏向锁?
- 轻量级锁 CAS 失败后为什么不直接膨胀为重量级锁,而是要先自旋?
- 自适应自旋是怎么判断自旋次数的?
- 什么是可重入锁?synchronized 是如何实现可重入的?
- 对象头的 Mark Word 在 64 位 JVM 中有什么不同?
Q5: synchronized 和 ReentrantLock 的区别? 「🟡 中级」
考察点:考察对两种锁机制的深入理解和选型能力,是否了解各自的优劣势和适用场景。筛掉只会用一种锁、不了解底层差异的人。
参考答案:
synchronized 和 ReentrantLock 都是可重入的独占锁,但在实现层面、功能特性、性能表现等方面有显著区别。
核心区别对比表
| 对比维度 | synchronized | ReentrantLock |
|---|---|---|
| 实现层面 | JVM 关键字,底层是 monitorenter/monitorexit | JDK API 层面,基于 AQS 实现 |
| 锁释放 | 自动释放,编译器保证异常时也释放 | 手动释放,必须在 finally 中 unlock() |
| 可重入 | 支持(monitor 计数器) | 支持(state 计数器) |
| 公平锁 | 非公平(默认且仅支持非公平) | 支持公平和非公平,构造方法可指定 |
| 可中断 | 不可中断(死等) | 支持可中断获取(lockInterruptibly()) |
| 尝试获取 | 不支持 | 支持 tryLock()(可带超时) |
| 多条件变量 | 不支持(只有一个 wait 队列) | 支持多个 Condition,可绑定多个等待队列 |
| 性能 | 轻量竞争下性能好,有锁升级优化 | 高竞争下功能更丰富,性能稳定 |
| 使用复杂度 | 简单,不易出错 | 复杂,需手动释放锁,容易忘记 |
| 锁类型 | 仅独占锁 | 独占锁(基于 AQS 独占模式) |
代码示例对比
synchronized:
java
public class SyncDemo {
private final Object lock = new Object();
public void method() {
synchronized (lock) {
// 临界区代码
// 自动释放锁,异常也会释放
}
}
}ReentrantLock:
java
public class ReentrantLockDemo {
private final ReentrantLock lock = new ReentrantLock(); // 默认非公平
// private final ReentrantLock fairLock = new ReentrantLock(true); // 公平锁
public void method() {
lock.lock(); // 获取锁
try {
// 临界区代码
} finally {
lock.unlock(); // 必须手动释放,放在 finally 中保证释放
}
}
// 可中断获取锁
public void interruptibleMethod() throws InterruptedException {
lock.lockInterruptibly();
try {
// 临界区代码
} finally {
lock.unlock();
}
}
// 尝试获取锁,带超时
public boolean tryMethod() throws InterruptedException {
if (lock.tryLock(1, TimeUnit.SECONDS)) {
try {
// 临界区代码
return true;
} finally {
lock.unlock();
}
}
return false;
}
}ReentrantLock 的高级特性
1. 公平锁
java
ReentrantLock fairLock = new ReentrantLock(true);公平锁按照线程请求锁的顺序分配(FIFO),优点是不会产生饥饿,缺点是吞吐量低(需要维护队列,频繁上下文切换)。
synchronized 只有非公平锁,优点是吞吐量大,缺点是可能产生饥饿。
2. Condition 多条件变量
synchronized 的 wait/notify 只有一个等待队列,而 ReentrantLock 可以创建多个 Condition,每个 Condition 对应一个等待队列,实现更精细化的线程协作。
java
// 典型应用:阻塞队列的实现
ReentrantLock lock = new ReentrantLock();
Condition notFull = lock.newCondition(); // 队列不满的条件
Condition notEmpty = lock.newCondition(); // 队列不空的条件
public void put(E e) throws InterruptedException {
lock.lock();
try {
while (count == items.length) {
notFull.await(); // 队列满了,等待"不满"条件
}
// 入队...
notEmpty.signal(); // 通知"不空"条件
} finally {
lock.unlock();
}
}
public E take() throws InterruptedException {
lock.lock();
try {
while (count == 0) {
notEmpty.await(); // 队列空了,等待"不空"条件
}
// 出队...
notFull.signal(); // 通知"不满"条件
return e;
} finally {
lock.unlock();
}
}3. 可中断锁
lockInterruptibly() 允许线程在等待锁的过程中响应中断,避免死等。这在死锁排查和优雅停止中很有用。
性能对比
- JDK 1.5 之前:ReentrantLock 性能明显优于 synchronized(synchronized 是重量级锁)
- JDK 1.6 及之后:synchronized 引入了锁升级(偏向锁、轻量级锁),性能大幅提升,两者性能大致相当
- 高竞争场景:ReentrantLock 的 tryLock + 自旋策略可能更灵活,但 synchronized 的锁升级也已经很优化
选型建议
| 场景 | 推荐选择 | 原因 |
|---|---|---|
| 大部分普通场景 | synchronized | 代码简洁,不易出错,JVM 持续优化 |
| 需要公平锁 | ReentrantLock | synchronized 不支持 |
| 需要可中断/尝试获取 | ReentrantLock | synchronized 不支持 |
| 需要多条件变量 | ReentrantLock | synchronized 只有一个等待集 |
| 团队新人多、代码规范要求高 | synchronized | 降低忘记释放锁的风险 |
基本原则:优先使用 synchronized,除非你明确需要 ReentrantLock 提供的高级特性。synchronized 是 JVM 内置的,JVM 可以做更多优化(如锁消除、锁粗化、逃逸分析),而 ReentrantLock 是纯 Java 代码实现的。
追问延伸:
- ReentrantLock 是如何实现可重入的?和 synchronized 的可重入实现有什么不同?
- 公平锁和非公平锁的实现差异是什么?为什么非公平锁性能更好?
- Condition 的 await/signal 和 Object 的 wait/notify 有什么联系和区别?
- ReentrantLock 的 tryLock() 是公平的吗?为什么?
- 你在项目中用过 ReentrantLock 吗?什么场景下用的?
Q6: volatile 关键字的作用和原理? 「🟡 中级」
考察点:考察对 JMM 内存模型和 volatile 底层原理的理解,是否清楚可见性和有序性的实现机制。筛掉只会说"可见性、禁止重排"但说不清原理的人。
参考答案:
volatile 是 Java 提供的一种轻量级同步机制,主要有两大作用:保证可见性和禁止指令重排序。它不保证原子性。
两大作用
1. 保证可见性
当一个线程修改了 volatile 变量的值,其他线程能立即看到最新值。
为什么会有可见性问题?
Java 内存模型(JMM)规定:所有变量都存储在主内存中,每个线程有自己的工作内存(如 CPU 缓存),线程对变量的操作都在工作内存中进行,完成后再刷回主内存。这就可能导致一个线程修改了变量但还没刷回主内存,另一个线程读到的还是旧值。
java
// 没有 volatile 的情况
boolean flag = false;
// 线程A
flag = true; // 修改工作内存中的 flag,可能不会立即刷回主内存
// 线程B
while (!flag) { // 可能一直读到 false,导致死循环
// 业务逻辑
}加上 volatile boolean flag; 后,线程 A 的修改会立即刷回主内存,并使其他线程的缓存失效,线程 B 每次读都会从主内存重新加载。
2. 禁止指令重排序
编译器和 CPU 为了提高性能,可能会对指令进行重排序,单线程下重排序不影响结果,但多线程下可能导致问题。
经典案例:DCL 双重检查锁定单例
java
public class Singleton {
private static volatile Singleton instance; // 必须加 volatile
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) { // 第一次检查(无锁)
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查(加锁)
instance = new Singleton(); // 问题所在
}
}
}
return instance;
}
}为什么必须加 volatile?
instance = new Singleton() 这行代码可以分解为三步:
- 分配内存空间:
memory = allocate() - 初始化对象:
ctorInstance(memory) - 将 instance 指向分配的内存:
instance = memory
如果没有 volatile,步骤 2 和 3 可能发生重排序,变成:
- 分配内存空间
- 将 instance 指向分配的内存(此时对象还未初始化!)
- 初始化对象
这时另一个线程执行第一次检查时,发现 instance != null,直接返回,但拿到的是一个未初始化完全的对象,使用时可能出错。
volatile 通过插入内存屏障禁止了 2 和 3 的重排序,保证对象构造完成后才将引用赋值给 instance。
底层原理
可见性的实现:MESI 缓存一致性协议
volatile 的写操作会触发:
- 修改工作内存中的值后,立即刷回主内存
- 通过总线嗅探机制,使其他 CPU 中该变量的缓存行失效(MESI 协议的 Invalid 状态)
- 其他线程读取时,发现缓存失效,从主内存重新加载最新值
MESI:Modified(修改)、Exclusive(独占)、Shared(共享)、Invalid(无效),是 CPU 缓存一致性协议的一种。
有序性的实现:内存屏障
JMM 为 volatile 插入了四类内存屏障:
| 屏障类型 | 指令示例 | 说明 |
|---|---|---|
| LoadLoad | Load1; LoadLoad; Load2 | 保证 Load1 在 Load2 之前完成 |
| StoreStore | Store1; StoreStore; Store2 | 保证 Store1 刷回内存在 Store2 之前 |
| LoadStore | Load1; LoadStore; Store2 | 保证 Load1 在 Store2 刷回内存之前完成 |
| StoreLoad | Store1; StoreLoad; Load2 | 保证 Store1 刷回内存在 Load2 之前完成 |
volatile 的内存屏障插入策略:
- 在每个 volatile 写操作前插入 StoreStore 屏障,之后插入 StoreLoad 屏障
- 在每个 volatile 读操作后插入 LoadLoad 屏障和 LoadStore 屏障
从汇编层面看,volatile 变量写操作时会多出一个 lock 前缀指令,该指令的作用:
- 将当前处理器缓存行的数据写回系统内存
- 使其他 CPU 缓存了该内存地址的数据无效
volatile 不保证原子性
volatile 只保证可见性和有序性,不保证原子性。例如:
java
volatile int count = 0;
// 多线程下执行 count++ 仍然不安全
// count++ 不是原子操作,包含三步:读-改-写
count++;
// 等价于:
// int temp = count; // 读(volatile保证读到最新值)
// temp = temp + 1; // 改
// count = temp; // 写(volatile保证立即刷回)虽然每次读都能读到最新值,但"改"和"写"之间可能有其他线程已经修改了值,导致丢失更新。
volatile 的经典应用场景
| 场景 | 说明 |
|---|---|
| 状态标志位 | 如 volatile boolean running = true; 用于线程停止标记 |
| DCL 单例模式 | 防止指令重排导致拿到未初始化的对象 |
| 双重检查锁定 | 各种延迟初始化的场景 |
| CopyOnWrite 容器 | 底层数组用 volatile 修饰,保证读操作的可见性 |
| ConcurrentHashMap 的 Node | val 和 next 用 volatile 修饰,保证读时不需要加锁 |
volatile 和 synchronized 的对比
| 对比维度 | volatile | synchronized |
|---|---|---|
| 作用 | 可见性 + 有序性 | 原子性 + 可见性 + 有序性 |
| 使用位置 | 修饰变量 | 修饰方法或代码块 |
| 阻塞 | 不会阻塞 | 可能阻塞 |
| 性能 | 高(无锁) | 相对低(有锁开销) |
| 原子性 | 不保证 | 保证 |
| 适用场景 | 状态标记、DCL单例 | 临界区同步、竞态条件保护 |
追问延伸:
- volatile 能保证原子性吗?为什么?那 AtomicInteger 是怎么保证原子性的?
- DCL 单例中为什么要用 volatile?如果去掉 volatile 会有什么问题?
- 内存屏障有哪几种?volatile 是怎么通过内存屏障实现有序性的?
- MESI 协议的具体状态流转是怎样的?
- volatile 写一个 long/double 变量是原子的吗?和普通 long/double 有什么区别?
- 说说你对 happens-before 原则中 volatile 变量规则的理解。
Q7: 什么是 JMM(Java 内存模型)?happens-before 原则? 「🟡 中级」
考察点:考察对 Java 内存模型的整体理解,是否清楚 JMM 要解决什么问题、以及 happens-before 规则的含义和应用。筛掉对并发底层模型理解模糊的人。
参考答案:
什么是 JMM
JMM(Java Memory Model,Java 内存模型)是 Java 虚拟机规范中定义的一种抽象的内存模型,它屏蔽了各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的内存访问效果。
JMM 要解决的问题
由于编译器优化、CPU 乱序执行、CPU 缓存等原因,多线程程序中可能出现:
- 可见性问题:一个线程的修改对另一个线程不可见
- 有序性问题:指令重排导致多线程下逻辑出错
- 原子性问题:多个操作中间被其他线程插入
JMM 就是用来定义多线程程序中共享变量的访问规则,解决上述问题。
JMM 的内存结构
JMM 把内存分为两部分:
┌─────────────────────────────────────────────────┐
│ 主内存 │
│ (所有共享变量都存储在这里,如堆内存中的对象) │
└─────────────┬───────────────────────┬───────────┘
│ │
读/写 │ 读/写 │
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ 线程A工作内存 │ │ 线程B工作内存 │
│ (CPU缓存/寄存器)│ │ (CPU缓存/寄存器)│
└──────────────────┘ └──────────────────┘- 主内存(Main Memory):所有线程共享,存储所有共享变量(实例字段、静态字段、数组对象等)
- 工作内存(Working Memory):每个线程私有,保存该线程使用到的变量的主内存副本拷贝
- 线程对变量的所有操作(读、写)都必须在工作内存中进行,不能直接读写主内存
注意:JMM 是抽象概念,不直接对应物理内存。工作内存可能对应 CPU 缓存、寄存器、编译优化等。
JMM 的三大特性
| 特性 | 含义 | 保证手段 |
|---|---|---|
| 原子性 | 一个操作不可分割,要么全部执行,要么不执行 | synchronized、Lock、原子类 |
| 可见性 | 一个线程修改了共享变量,其他线程能立即看到最新值 | volatile、synchronized、Lock |
| 有序性 | 程序执行按照代码的先后顺序执行 | volatile、synchronized、Lock |
happens-before 原则
为什么需要 happens-before
仅靠 sychronized 和 volatile 来保证有序性,程序开发会非常麻烦。JMM 提供了 happens-before(先行发生)原则,给程序员一个明确的保证:
如果操作 A happens-before 操作 B,那么 A 操作的结果对 B 操作是可见的,且 A 操作的执行顺序排在 B 操作之前。
注意:两个操作有 happens-before 关系,不代表它们在时间上一定是先发生的。happens-before 是 JMM 给程序员的可见性保证,不是时间先后顺序。
八大 happens-before 规则
| 规则名称 | 规则描述 |
|---|---|
| 程序顺序规则 | 同一个线程内,前面的操作 happens-before 后面的操作(按代码顺序) |
| 管程锁定规则 | 对一个锁的 unlock 操作 happens-before 后续对这个锁的 lock 操作 |
| volatile 变量规则 | 对一个 volatile 变量的写操作 happens-before 后续对这个变量的读操作 |
| 线程启动规则 | Thread 对象的 start() 方法 happens-before 此线程的每一个操作 |
| 线程终止规则 | 线程中的所有操作都 happens-before 对此线程的终止检测(如 join()、isAlive()) |
| 线程中断规则 | 对线程 interrupt() 方法的调用 happens-before 被中断线程检测到中断事件 |
| 对象终结规则 | 一个对象的初始化完成(构造方法执行结束)happens-before 它的 finalize() 方法的开始 |
| 传递性规则 | 如果 A happens-before B,且 B happens-before C,那么 A happens-before C |
规则应用举例
java
class Example {
int a = 0;
volatile boolean flag = false;
// 线程A
public void writer() {
a = 1; // 操作1
flag = true; // 操作2(volatile写)
}
// 线程B
public void reader() {
if (flag) { // 操作3(volatile读)
int i = a; // 操作4
}
}
}根据 happens-before 规则推导:
- 程序顺序规则:操作1 happens-before 操作2;操作3 happens-before 操作4
- volatile 变量规则:操作2 happens-before 操作3
- 传递性规则:操作1 happens-before 操作4
结论:线程 B 读到 flag 为 true 时,a = 1 一定对线程 B 可见。这就是 volatile 的"附带效果"——它不仅保证自身变量的可见性,还能保证它之前的所有写操作对后续读它的线程都可见。
再看一个 synchronized 的例子
java
int x = 0;
synchronized (lock) { // 线程A
x = 10;
} // unlock
synchronized (lock) { // 线程B
System.out.println(x); // 一定能看到 x = 10
} // lock根据管程锁定规则:线程A 的 unlock happens-before 线程B 的 lock,再结合程序顺序规则,线程B 一定能看到 x = 10。
JMM 和 as-if-serial
- as-if-serial 语义:在单线程中,不管怎么重排序,程序的执行结果不能被改变。这是对单线程程序的保证。
- happens-before:是 JMM 对多线程程序的可见性保证,确保正确同步的程序不会有重排序问题。
可以理解为:as-if-serial 保护单线程程序的正确性,happens-before 保护正确同步的多线程程序的正确性。
追问延伸:
- JMM 和 JVM 内存区域(堆、栈、方法区)有什么区别和联系?
- happens-before 和时间上的先后顺序有什么区别?能举个例子吗?
- 为什么 volatile 写之前的普通写不会被重排到 volatile 写之后?
- 程序顺序规则说"按代码顺序",那还会有指令重排吗?
- 你能结合 AQS 的源码讲讲 happens-before 是怎么体现的吗?
Q8: 什么是 AQS?原理是什么? 「🔴 高级」
考察点:考察对 Java 并发包底层基石的理解深度,是否理解 AQS 的核心设计思想和实现机制。筛掉只会用并发工具类但不懂底层原理的人。
参考答案:
AQS 是什么
AQS(AbstractQueuedSynchronizer,抽象队列同步器)是 Java 并发包(java.util.concurrent.locks)的核心基础框架,它是一个用于构建锁和同步器的抽象类。
几乎所有的 JUC 并发工具类都基于 AQS 实现,包括:
- ReentrantLock:可重入锁(独占模式)
- ReentrantReadWriteLock:读写锁(读共享、写独占)
- Semaphore:信号量(共享模式)
- CountDownLatch:倒计时门闩(共享模式)
- CyclicBarrier:循环栅栏(间接使用)
- FutureTask:异步任务(间接使用)
AQS 的核心思想
AQS 的核心设计可以用一句话概括:用一个 volatile 修饰的 state 变量表示同步状态,通过内置的 FIFO 等待队列来管理线程的排队等待,配合 CAS 操作来修改 state 实现同步。
三大核心要素
┌──────────────────────────────────────────────┐
│ AQS 核心三件套 │
│ │
│ 1. state 状态变量 (volatile int) │
│ - 表示锁的持有状态、许可数量等 │
│ │
│ 2. CLH 等待队列 (双向链表) │
│ - 存放获取锁失败的等待线程 │
│ │
│ 3. CAS 操作 (Unsafe.compareAndSwap) │
│ - 原子方式修改 state 和队列节点 │
└──────────────────────────────────────────────┘state 状态变量
java
private volatile int state;- state 用
volatile修饰,保证可见性 - 不同同步器对 state 的含义不同:
- ReentrantLock:state 表示锁的持有次数(0 表示未被持有,>0 表示被重入的次数)
- Semaphore:state 表示剩余许可数
- CountDownLatch:state 表示剩余计数
- 访问 state 的三个核心方法:
getState():获取当前状态setState():设置状态(volatile 写)compareAndSetState():CAS 方式设置状态
CLH 等待队列
AQS 内部维护了一个双向链表结构的等待队列(CLH 变体),用于存放获取同步状态失败的线程。
┌─────────┐ prev ┌─────────┐ prev ┌─────────┐
head │ Node │ ◀────── │ Node │ ◀────── │ Node │ tail
│ (空) │ ──────▶ │ Thread2 │ ──────▶ │ Thread3 │
└─────────┘ next └─────────┘ next └─────────┘
▲ ▲
│ │
头节点(哨兵) 尾节点Node 节点的核心属性:
| 属性 | 类型 | 含义 |
|---|---|---|
waitStatus | int | 等待状态(CANCELLED=1、SIGNAL=-1、CONDITION=-2、PROPAGATE=-3、0=初始) |
prev | Node | 前驱节点 |
next | Node | 后继节点 |
thread | Thread | 该节点对应的线程 |
nextWaiter | Node | 条件队列中的下一个节点(用于 Condition) |
入队过程:
- 使用 CAS 尝试将新节点设置为 tail
- 如果 CAS 失败,说明有竞争,自旋重试
- 成功后建立前驱节点的 next 指向
注意:头节点是一个"哨兵节点"(dummy node),不对应实际等待线程,它的存在是为了简化入队出队的边界处理。
两种模式
1. 独占模式(Exclusive)
同一时刻只有一个线程能持有同步状态。典型实现:ReentrantLock。
核心方法:
acquire(int arg):独占式获取同步状态release(int arg):独占式释放同步状态
acquire 流程:
调用 acquire(arg)
│
▼
tryAcquire(arg) ──── 成功 ────▶ 返回
(子类实现) │
│ 失败 │
▼ │
addWaiter(Node.EXCLUSIVE) 将当前线程包装为Node,加入队列尾部
│
▼
acquireQueued(node, arg)
│
├─ 在队列中自旋尝试获取
├─ 若前驱是 head 则 tryAcquire
├─ 失败则判断是否应该 park
└─ 被中断则设置中断标记
│
▼
返回(可能带中断标记)release 流程:
- 调用
tryRelease(arg)释放同步状态(子类实现) - 释放成功后,唤醒后继节点(
unparkSuccessor) - 后继节点被唤醒后继续尝试获取
2. 共享模式(Share)
同一时刻可以有多个线程持有同步状态。典型实现:Semaphore、CountDownLatch、ReadWriteLock 的读锁。
核心方法:
acquireShared(int arg):共享式获取同步状态releaseShared(int arg):共享式释放同步状态
与独占模式的主要区别:
- 共享模式获取成功后,需要传播唤醒后继节点(PROPAGATE 状态)
- 因为共享模式允许多个线程同时持有,一个线程释放后可能有多个等待线程都能获取成功
模板方法模式
AQS 是模板方法模式的经典应用。AQS 定义了同步的骨架流程(排队、阻塞、唤醒等),将具体的获取/释放逻辑留给子类实现。
子类需要实现的方法(钩子方法):
| 方法 | 描述 | 模式 |
|---|---|---|
tryAcquire(int arg) | 独占式获取同步状态 | 独占 |
tryRelease(int arg) | 独占式释放同步状态 | 独占 |
tryAcquireShared(int arg) | 共享式获取同步状态(>=0成功,<0失败) | 共享 |
tryReleaseShared(int arg) | 共享式释放同步状态 | 共享 |
isHeldExclusively() | 是否当前线程独占 | 独占 |
这些方法默认都抛出 UnsupportedOperationException,子类根据需要选择性实现。
ReentrantLock 中的 AQS 实现示例
以 ReentrantLock 的非公平锁为例:
java
static final class NonfairSync extends Sync {
// 非公平锁:上来就直接 CAS 抢锁
final void lock() {
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
}
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) { // 锁未被持有
if (compareAndSetState(0, acquires)) { // 直接 CAS 抢,不排队
setExclusiveOwnerThread(current);
return true;
}
} else if (current == getExclusiveOwnerThread()) { // 可重入
int nextc = c + acquires;
setState(nextc);
return true;
}
return false;
}
}公平锁的 tryAcquire 区别:多了一步 !hasQueuedPredecessors() 判断,即如果队列中有人在排队,就不能直接抢,必须去排队。
为什么 AQS 这么重要
- 统一了同步器的实现:将排队、阻塞、唤醒等通用逻辑抽到父类,子类只需关注状态的获取和释放
- 性能优异:用 CAS + 自旋 + 队列的方式减少了线程上下文切换
- 灵活扩展:支持独占和共享两种模式,支持公平/非公平,支持可中断/超时等
追问延伸:
- AQS 的等待队列为什么用双向链表而不是单向链表?
- AQS 为什么要用 CLH 队列的变体?原始 CLH 队列是什么样的?
- Node 的 waitStatus 有哪些取值?各有什么含义?
- 独占模式和共享模式的主要区别是什么?releaseShared 为什么要传播?
- 为什么 AQS 的 head 是一个空节点(哨兵节点)?
- 你能自己基于 AQS 实现一个简单的互斥锁吗?
- AQS 中的 park/unpark 是怎么实现的?和 wait/notify 有什么区别?
Q9: 原子类的原理?CAS 是什么?有什么问题? 「🟡 中级」
考察点:考察对无锁编程、CAS 机制的理解,是否清楚原子类的底层实现和适用场景。筛掉只会用原子类但不懂原理的人。
参考答案:
原子类概览
java.util.concurrent.atomic 包下提供了一系列原子操作类,它们基于 CAS + volatile 实现,是无锁(lock-free)编程的基础。
| 分类 | 代表类 |
|---|---|
| 基本类型 | AtomicInteger、AtomicLong、AtomicBoolean |
| 数组类型 | AtomicIntegerArray、AtomicLongArray、AtomicReferenceArray |
| 引用类型 | AtomicReference、AtomicStampedReference、AtomicMarkableReference |
| 字段更新器 | AtomicIntegerFieldUpdater、AtomicLongFieldUpdater、AtomicReferenceFieldUpdater |
| 累加器 | LongAdder、DoubleAdder、LongAccumulator、DoubleAccumulator |
CAS 是什么
CAS(Compare-And-Swap,比较并交换)是一种乐观锁机制,也是一种原子操作。
CAS 的三个操作数
- V:内存中的当前值(Value)
- A:预期值(Expect)
- B:要修改的新值(New value)
操作逻辑:当且仅当内存值 V 等于预期值 A 时,才将 V 更新为新值 B;否则什么也不做。整个操作是原子的。
java
// 伪代码
boolean compareAndSwap(V, A, B) {
if (V == A) { // 比较
V = B; // 交换
return true;
}
return false;
}原子性由 CPU 指令保证(如 x86 的
cmpxchg指令),不需要加锁。
CAS 在 Java 中的实现
Java 中的 CAS 操作通过 Unsafe 类提供,底层调用 CPU 的原子指令:
java
// sun.misc.Unsafe 中的方法
public final native boolean compareAndSwapInt(Object o, long offset,
int expected, int x);
public final native boolean compareAndSwapLong(Object o, long offset,
long expected, long x);
public final native boolean compareAndSwapObject(Object o, long offset,
Object expected, Object x);AtomicInteger 的 getAndIncrement 实现:
java
public final int getAndIncrement() {
return unsafe.getAndAddInt(this, valueOffset, 1);
}
// Unsafe 中的实现(自旋 + CAS)
public final int getAndAddInt(Object o, long offset, int delta) {
int v;
do {
v = getIntVolatile(o, offset); // 读取当前值
} while (!compareAndSwapInt(o, offset, v, v + delta)); // CAS 更新,失败则重试
return v;
}CAS 的三大问题
1. ABA 问题
问题描述:如果一个值原来是 A,变成了 B,然后又变回了 A,那么使用 CAS 进行检查时会发现它的值没有发生变化,但实际上它已经被修改过了。
线程1:V = A,准备 CAS 为 C(还没执行)
线程2:V = A → B → A (中间改了两次)
线程1:执行 CAS(V, A, C) → 成功,但中间的变化被忽略了大部分场景下 ABA 不影响正确性,但在某些场景(如链表、栈等数据结构)可能导致严重问题。
解决方案:使用版本号/时间戳。
java
// AtomicStampedReference:带版本号的原子引用
AtomicStampedReference<String> ref = new AtomicStampedReference<>("A", 0);
// 比较时不仅比较引用,还要比较版本号
ref.compareAndSet("A", "B", 0, 1); // 期望引用A,版本0 → 更新为B,版本1| 类 | 说明 |
|---|---|
| AtomicStampedReference | 使用 int 版本号,可检测 ABA |
| AtomicMarkableReference | 使用 boolean 标记,简化版,只能知道有没有改过 |
2. 自旋开销问题
问题描述:CAS 如果长时间不成功(竞争激烈),会一直自旋循环,占用 CPU 资源。
java
// 类似这样的自旋循环,竞争激烈时可能空转很久
while (!compareAndSwapInt(obj, offset, expect, update)) {
// 什么也不做,一直重试
}影响:
- CPU 使用率升高
- 高并发下性能可能不如重量级锁(因为重量级锁会让线程休眠,不占 CPU)
解决方案/优化:
- 自适应自旋:JVM 可以根据前一次自旋成功率调整自旋次数
- LongAdder:分段累加,分散竞争(见下文)
- 竞争激烈时考虑使用锁或其他同步方式
3. 只能保证一个共享变量的原子操作
问题描述:CAS 只能对单个共享变量执行原子操作,无法同时保证多个变量的原子性。
解决方案:
- AtomicReference:将多个变量封装成一个对象,用 AtomicReference 来保证引用对象的原子性
- synchronized / Lock:需要原子操作多个变量时,直接用锁更简单
LongAdder:高并发下的更优选择
JDK 8 引入了 LongAdder,在高并发场景下比 AtomicLong 性能更好。
核心思想:分段累加
- 内部维护一个
base值和一个Cell[]数组 - 竞争不激烈时,直接更新 base(和 AtomicLong 一样)
- 竞争激烈时,不同线程更新不同的 Cell 元素(分散竞争)
- 获取结果时,将 base 和所有 Cell 累加起来
LongAdder 内部结构
┌──────────────────────────────────────┐
│ base │ Cell[0] │ Cell[1] │ Cell[2] │ ...
│ 100 │ 50 │ 30 │ 20 │
└──────────────────────────────────────┘
sum = 100 + 50 + 30 + 20 = 200适用场景:高并发下的统计计数(如 QPS、访问量),只需要最终结果,不需要每次都拿到精确值。
不适用场景:需要精确实时值的场景(LongAdder 的 sum 在并发修改时可能不准确)。
原子类和 volatile、synchronized 的关系
| 特性 | volatile | 原子类 | synchronized |
|---|---|---|---|
| 原子性 | 不保证 | 保证(单个变量) | 保证 |
| 可见性 | 保证 | 保证(底层用 volatile) | 保证 |
| 有序性 | 保证 | 保证 | 保证 |
| 性能 | 最高 | 高(无锁) | 相对低(有锁) |
| 适用 | 状态标志位 | 单个变量的原子更新 | 临界区保护 |
原子类 = volatile + CAS 自旋,是一种"乐观锁"的实现方式。
追问延伸:
- 你在项目中用过哪些原子类?什么场景下用的?
- ABA 问题在什么场景下会真正导致错误?能举个具体例子吗?
- LongAdder 的 sum 方法是精确的吗?为什么?
- AtomicInteger 和 synchronized 哪个性能更好?怎么选择?
- 什么是乐观锁和悲观锁?CAS 属于哪一种?
- Unsafe 类是什么?为什么不建议直接使用?
- 你了解 CPU 层面的 CAS 是怎么实现的吗?(缓存锁/总线锁)
Q10: ThreadLocal 的原理?内存泄漏问题? 「🟡 中级」
考察点:考察对 ThreadLocal 底层实现和潜在问题的理解,是否清楚线程隔离机制和内存泄漏的原因及解决方案。筛掉只会用 ThreadLocal 但不懂原理的人。
参考答案:
ThreadLocal 是什么
ThreadLocal 是 Java 提供的一种线程隔离机制,它为每个线程提供独立的变量副本,每个线程只能访问自己的副本,从而避免了多线程竞争问题。
ThreadLocal 不是用来解决共享变量竞争的,而是用来实现线程封闭(Thread Confinement)的,让数据在不同线程间互不干扰。
典型应用场景
| 场景 | 说明 |
|---|---|
| 数据库连接 | 每个线程一个 Connection,避免频繁创建关闭 |
| 事务上下文 | 保存当前线程的事务状态 |
| Session 管理 | Web 应用中保存用户会话信息 |
| SimpleDateFormat | SimpleDateFormat 非线程安全,每个线程持有一个 |
| TraceId / 请求链路追踪 | 在整个调用链路中传递请求 ID |
| 用户上下文 | 保存当前登录用户信息 |
代码示例:
java
// SimpleDateFormat 线程安全方案
public class DateUtil {
private static final ThreadLocal<SimpleDateFormat> sdf =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
public static String format(Date date) {
return sdf.get().format(date);
}
}底层原理
核心结构
每个 Thread 对象内部都有一个 threadLocals 成员变量,类型是 ThreadLocal.ThreadLocalMap。
Thread 1 Thread 2
┌──────────────────────┐ ┌──────────────────────┐
│ threadLocals (Map) │ │ threadLocals (Map) │
│ ┌──────────────────┐ │ │ ┌──────────────────┐ │
│ │ key value │ │ │ │ key value │ │
│ │ tl1 ref → val1 │ │ │ │ tl1 ref → val3 │ │
│ │ tl2 ref → val2 │ │ │ │ tl2 ref → val4 │ │
│ └──────────────────┘ │ │ └──────────────────┘ │
└──────────────────────┘ └──────────────────────┘
▲ ▲ ▲ ▲
│ │ │ │
└────┴────── 弱引用 ────────────┘ │
│ │
ThreadLocal 对象 对应的 value
(堆中,可能被GC) (强引用,不会被GC)ThreadLocalMap 的结构
ThreadLocalMap 是一个自定义的 HashMap,核心特点:
- Entry 继承自 WeakReference:key(ThreadLocal)是弱引用
- value 是强引用
- 使用线性探测法解决哈希冲突(不是链表法)
java
static class ThreadLocalMap {
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value; // value 是强引用
Entry(ThreadLocal<?> k, Object v) {
super(k); // key 是弱引用
value = v;
}
}
private Entry[] table;
// ...
}set() / get() 方法的流程
set(T value):
- 获取当前线程的 ThreadLocalMap
- 以当前 ThreadLocal 对象为 key,存入 value
- 如果 map 不存在则创建
get():
- 获取当前线程的 ThreadLocalMap
- 以当前 ThreadLocal 对象为 key,查找对应的 value
- 找到则返回,找不到则调用
initialValue()初始化并返回
内存泄漏问题
为什么会内存泄漏
内存泄漏的关键在于:key 是弱引用,value 是强引用。
Thread → ThreadLocalMap → Entry(key弱引用, value强引用)
↓ ↓
GC后为null 无法回收当 ThreadLocal 对象被 GC 回收后(因为是弱引用),Entry 的 key 变成了 null,但 value 仍然被 Entry 强引用着。如果这个线程一直不结束(比如线程池中的线程),value 就永远无法被回收,导致内存泄漏。
注意:如果线程执行完就销毁了(非线程池场景),ThreadLocalMap 也会被回收,不会有内存泄漏问题。线程池场景才是重灾区,因为线程是复用的,不会销毁。
为什么 key 要用弱引用
很多人会问:既然弱引用会导致 key 被 GC 进而内存泄漏,为什么还要用弱引用?
答案是:用弱引用反而更安全。
- 如果 key 是强引用:ThreadLocal 对象即使没有外部引用了也不会被回收(因为 ThreadLocalMap 还引用着它),这会导致 ThreadLocal 对象和 value 都泄漏,问题更严重
- 如果 key 是弱引用:ThreadLocal 对象在没有外部强引用时会被 GC 回收,至少 ThreadLocal 对象本身不会泄漏。value 的泄漏问题可以通过
remove()来避免
而且 ThreadLocalMap 在执行 set()/get()/remove() 时,会顺便清理 key 为 null 的 Entry(叫 expungeStaleEntry 方法),这是一种被动的清理机制。
内存泄漏的解决方案
1. 手动调用 remove()(最推荐)
java
try {
threadLocal.set(value);
// 业务逻辑
} finally {
threadLocal.remove(); // 用完就清理
}2. try-finally 模式
在方法入口 set,出口 remove,确保一定被清理。
3. 线程池场景特别注意
线程池中的线程会复用,如果不清理,下一个任务可能拿到上一个任务遗留的值,不仅有内存泄漏,还可能产生业务逻辑错误。
常见的内存泄漏场景
- Filter / Interceptor 中 set 了但没有 remove:异常路径可能导致 remove 不执行
- 线程池 + ThreadLocal:线程复用,value 一直存在
- ThreadLocal 声明为 static 但忘了清理:以为 static 的对象不会泄漏
InheritableThreadLocal
InheritableThreadLocal 是 ThreadLocal 的子类,它允许子线程继承父线程的值。
java
InheritableThreadLocal<String> itl = new InheritableThreadLocal<>();
itl.set("父线程的值");
new Thread(() -> {
System.out.println(itl.get()); // 输出:父线程的值
}).start();原理:在创建子线程时,会将父线程的 inheritableThreadLocals 复制一份到子线程中。
注意:
- 复制的是引用,不是深拷贝,如果 value 是可变对象,父子线程修改会互相影响
- 线程池场景下子线程可能不是父线程创建的,会导致继承失效
ThreadLocal 的常见问题
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 内存泄漏 | key弱引用被GC,value强引用无法回收 | 手动 remove() |
| 线程池值串用 | 线程复用,旧值没清 | 每次使用后 remove |
| 子线程拿不到值 | ThreadLocal 不支持继承 | 使用 InheritableThreadLocal |
| 父子线程值互相影响 | InheritableThreadLocal 是浅拷贝 | 深拷贝 value |
追问延伸:
- ThreadLocalMap 为什么用线性探测法而不是链表法解决哈希冲突?
- ThreadLocal 为什么不直接用 HashMap,而是自己实现 ThreadLocalMap?
- ThreadLocal 的哈希值是怎么生成的?为什么用 0x61c88647 这个魔数?
- 除了内存泄漏,ThreadLocal 还有什么坑?
- 在线程池中使用 ThreadLocal 需要注意什么?
- 你知道 TransmittableThreadLocal 吗?它解决了什么问题?
- ThreadLocal 和 synchronized 有什么区别?各自的适用场景?
Q11: 线程池的核心参数?工作流程? 「🟡 中级」
考察点:考察对线程池核心机制的理解,是否清楚各参数的含义和工作流程,以及实际生产中的配置经验。筛掉只会用 Executors 创建线程池但不懂原理的人。
参考答案:
为什么要用线程池
如果不使用线程池,每次执行任务都创建新线程,会有以下问题:
- 线程创建销毁开销大:每个线程的创建和销毁都需要时间和资源
- 缺乏统一管理:无限制创建线程可能导致 OOM
- 缺乏高级功能:定时执行、任务队列等
线程池的好处:
- 降低资源消耗:复用已创建的线程,减少创建销毁开销
- 提高响应速度:任务来了直接用空闲线程执行
- 提高可管理性:统一分配、调优和监控
- 提供高级功能:定时执行、并发数控制等
七大核心参数
ThreadPoolExecutor 的构造方法有 7 个参数:
java
public ThreadPoolExecutor(
int corePoolSize, // 1. 核心线程数
int maximumPoolSize, // 2. 最大线程数
long keepAliveTime, // 3. 空闲线程存活时间
TimeUnit unit, // 4. 时间单位
BlockingQueue<Runnable> workQueue, // 5. 任务队列
ThreadFactory threadFactory, // 6. 线程工厂
RejectedExecutionHandler handler // 7. 拒绝策略
)1. corePoolSize(核心线程数)
线程池中保持存活的核心线程数量,即使它们是空闲的。
- 默认情况下,核心线程会一直存活,即使没有任务
- 设置
allowCoreThreadTimeOut(true)可让核心线程也超时回收
2. maximumPoolSize(最大线程数)
线程池中允许存在的最大线程数。
- 当队列满了之后,线程池会创建非核心线程来执行任务
- 核心线程 + 非核心线程总数不超过 maximumPoolSize
3. keepAliveTime(空闲线程存活时间)
非核心线程空闲后的存活时间。
- 超过这个时间,空闲的非核心线程会被回收
- 如果设置了
allowCoreThreadTimeOut(true),核心线程也会受此限制
4. unit(时间单位)
keepAliveTime 的时间单位,如 TimeUnit.SECONDS、TimeUnit.MILLISECONDS 等。
5. workQueue(任务队列)
存放等待执行任务的阻塞队列。
| 队列类型 | 特点 | 适用场景 |
|---|---|---|
ArrayBlockingQueue | 有界队列,数组实现,FIFO | 资源有限,需要控制队列大小 |
LinkedBlockingQueue | 可设置容量的无界队列(默认 Integer.MAX_VALUE),链表实现 | 大多数场景,吞吐量高 |
SynchronousQueue | 不存储任务,直接交给线程执行 | 配合无界最大线程数,如 newCachedThreadPool |
PriorityBlockingQueue | 优先级队列,按优先级执行 | 需要按优先级执行任务 |
DelayQueue | 延迟队列,任务到时间才执行 | 定时任务、延迟任务 |
6. threadFactory(线程工厂)
创建线程的工厂,可以设置线程名称、是否守护线程、优先级等。
java
// 自定义线程工厂示例
ThreadFactory namedThreadFactory = new ThreadFactoryBuilder()
.setNameFormat("my-pool-%d")
.setDaemon(false)
.build();生产环境建议自定义线程工厂,给线程起有意义的名字,方便排查问题。
7. handler(拒绝策略)
当线程池和队列都满了,新任务来的时候的处理策略。
| 策略 | 行为 | 适用场景 |
|---|---|---|
AbortPolicy(默认) | 抛出 RejectedExecutionException 异常 | 重要任务,不能丢弃 |
CallerRunsPolicy | 由提交任务的线程自己执行 | 不希望任务丢失,且提交者可以分担压力 |
DiscardPolicy | 直接丢弃任务,不抛异常 | 不重要的任务,丢了也无所谓 |
DiscardOldestPolicy | 丢弃队列中最老的任务,然后重试 | 最新的任务最重要的场景 |
生产环境建议自定义拒绝策略,如记录日志、持久化任务等,比直接丢弃或抛出异常更友好。
工作流程
线程池的执行流程可以概括为四步:
提交任务
│
▼
核心线程数 < corePoolSize ? ──是──▶ 创建核心线程执行任务
│
否
▼
队列是否已满? ──否──▶ 加入队列等待
│
是
▼
线程数 < maximumPoolSize ? ──是──▶ 创建非核心线程执行任务
│
否
▼
执行拒绝策略详细说明:
阶段一:核心线程
- 当提交任务时,如果当前线程数 < corePoolSize,创建新的核心线程执行任务
- 即使有空闲核心线程,也会创建新的,直到达到 corePoolSize
- 可以通过
prestartCoreThread()预启动核心线程
阶段二:任务队列
- 当线程数 >= corePoolSize 时,新任务加入 workQueue 等待
- 空闲的核心线程从队列中取任务执行
阶段三:最大线程
- 当队列满了,且线程数 < maximumPoolSize 时,创建非核心线程执行任务
- 这些线程执行完任务后,空闲超过 keepAliveTime 会被回收
阶段四:拒绝策略
- 当队列满了且线程数达到 maximumPoolSize 时,新任务触发拒绝策略
常见的四种线程池及问题
JDK 的 Executors 类提供了四种常用线程池的工厂方法,但阿里巴巴 Java 开发手册禁止使用 Executors 创建线程池。
| 线程池 | 核心参数 | 问题 |
|---|---|---|
newFixedThreadPool(n) | 核心=最大=n,LinkedBlockingQueue(无界) | 队列无界,任务堆积可能 OOM |
newCachedThreadPool() | 核心=0,最大=Integer.MAX_VALUE,SynchronousQueue | 线程数无界,可能创建大量线程导致 OOM |
newSingleThreadExecutor() | 核心=最大=1,LinkedBlockingQueue(无界) | 队列无界,任务堆积可能 OOM |
newScheduledThreadPool(n) | 核心=n,最大=Integer.MAX_VALUE,DelayedWorkQueue | 最大线程无界,可能 OOM |
正确做法:使用 ThreadPoolExecutor 的构造方法手动创建,明确各参数。
java
// 生产环境推荐的创建方式
ThreadPoolExecutor executor = new ThreadPoolExecutor(
10, // 核心线程数
50, // 最大线程数
60L, // 空闲存活时间
TimeUnit.SECONDS, // 时间单位
new ArrayBlockingQueue<>(1000), // 有界队列,控制内存
new ThreadFactoryBuilder() // 自定义线程工厂
.setNameFormat("biz-pool-%d")
.build(),
new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略
);线程数配置经验
| 任务类型 | 线程数配置公式 | 说明 |
|---|---|---|
| CPU 密集型 | Ncpu + 1 | 计算密集,减少上下文切换,+1 是为了容错(某个线程缺页/暂停时替补) |
| IO 密集型 | 2 * Ncpu 或 Ncpu / (1 - 阻塞系数) | IO 等待时间长,需要更多线程;阻塞系数通常 0.8~0.9 |
| 混合型 | 根据任务类型拆分线程池 | 不要一个线程池什么任务都跑 |
最佳配置需要根据实际压测结果调整,公式只是经验起点。
追问延伸:
- 为什么阿里巴巴不推荐使用 Executors 创建线程池?
- 核心线程会被回收吗?怎么让核心线程也可以被回收?
- 线程池的队列你会怎么选择?ArrayBlockingQueue 和 LinkedBlockingQueue 有什么区别?
- 什么情况下会触发拒绝策略?你会怎么设计拒绝策略?
- 如何优雅地配置线程池参数?有什么监控和调优手段?
- 线程池的核心线程是在什么时候创建的?可以预热吗?
- 如果线程池里的线程抛异常了会怎样?
- 你知道有哪些线程池监控指标?怎么获取?
Q12: 线程池的优雅关闭?submit 和 execute 的区别? 「🟡 中级」
考察点:考察对线程池生命周期管理和异常处理的理解,是否清楚提交任务的两种方式的区别以及实际生产中的注意事项。筛掉只会用但不会管的人。
参考答案:
线程池的优雅关闭
线程池关闭有三个核心方法:shutdown()、shutdownNow()、awaitTermination()。
1. shutdown() — 温和关闭
java
executor.shutdown();行为:
- 线程池状态变为 SHUTDOWN
- 不再接受新任务(提交会触发拒绝策略)
- 已提交的任务(包括队列中等待的)会继续执行完成
- 不会阻塞调用线程,立即返回
适用场景:正常停止服务,确保所有已提交任务执行完。
2. shutdownNow() — 强制关闭
java
List<Runnable> pendingTasks = executor.shutdownNow();行为:
- 线程池状态变为 STOP
- 不再接受新任务
- 尝试停止正在执行的任务(通过
interrupt()中断) - 跳过队列中等待的任务,将其作为返回值返回
- 不会阻塞调用线程,立即返回
注意:
shutdownNow()不保证一定能停止正在执行的任务,因为它只是调用interrupt(),如果任务不响应中断,线程会继续运行直到任务完成- 返回的队列中的任务列表可以用来做补偿处理
适用场景:需要紧急停止,或重启服务时需要获取未执行的任务做持久化。
3. awaitTermination() — 等待关闭完成
java
boolean terminated = executor.awaitTermination(1, TimeUnit.HOURS);行为:
- 阻塞当前线程,等待线程池关闭完成
- 如果在超时时间内线程池所有任务都执行完且线程都终止,返回 true
- 如果超时了还没终止,返回 false
- 通常和
shutdown()配合使用
标准优雅关闭流程
java
// 1. 停止接受新任务
executor.shutdown();
try {
// 2. 等待已提交任务完成,最多等 1 小时
if (!executor.awaitTermination(1, TimeUnit.HOURS)) {
// 3. 超时了还没完成,强制关闭
executor.shutdownNow();
// 4. 再等一会儿,给响应中断的任务一些时间
if (!executor.awaitTermination(10, TimeUnit.SECONDS)) {
System.err.println("线程池未能完全关闭");
}
}
} catch (InterruptedException e) {
// 5. 等待过程中被中断,重新强制关闭
executor.shutdownNow();
Thread.currentThread().interrupt(); // 恢复中断状态
}关闭过程中的状态转换
RUNNING ──shutdown()──▶ SHUTDOWN ──队列空+线程都终止──▶ TIDYING ──▶ TERMINATED
│
└──shutdownNow()──▶ STOP ──线程都终止──▶ TIDYING ──▶ TERMINATEDsubmit() 和 execute() 的区别
| 对比维度 | execute() | submit() |
|---|---|---|
| 方法来源 | Executor 接口 | ExecutorService 接口 |
| 参数 | 只能传 Runnable | 可以传 Runnable 或 Callable |
| 返回值 | void | Future<T> |
| 异常处理 | 异常直接抛出(可能导致线程死亡) | 异常被封装在 Future 中,调用 get() 时才抛出 |
| 能否取消任务 | 不能 | 可以通过 Future.cancel() 取消 |
代码示例对比
execute():
java
executor.execute(() -> {
// 业务逻辑
int i = 1 / 0; // 这里抛出异常,线程会直接终止
// 异常堆栈会打印到 System.err,但调用方感知不到
});submit():
java
Future<Integer> future = executor.submit(() -> {
// 业务逻辑
return 1 / 0; // 异常被捕获,封装在 Future 中
});
try {
Integer result = future.get(); // 调用 get() 时才会抛出异常
} catch (ExecutionException e) {
// 异常在这里被捕获
Throwable cause = e.getCause(); // 获取真实异常
System.out.println("任务执行异常:" + cause.getMessage());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}线程池的异常处理
线程池中任务的异常如果没有正确处理,可能导致线程死亡或异常被吞掉。
异常处理的三种方式
方式一:任务内部 try-catch(最推荐)
java
executor.execute(() -> {
try {
// 业务逻辑
} catch (Exception e) {
// 记录日志、告警等
log.error("任务执行异常", e);
}
});优点:简单直接,异常不会丢失。缺点:每个任务都要写 try-catch。
方式二:submit + Future.get()
java
Future<?> future = executor.submit(task);
try {
future.get(); // 获取结果时捕获异常
} catch (ExecutionException e) {
// 处理异常
}优点:可以拿到异常详情。缺点:必须调用 get(),否则异常被吞掉。
方式三:自定义 ThreadFactory + UncaughtExceptionHandler
java
ThreadFactory factory = r -> {
Thread thread = new Thread(r);
thread.setName("my-pool-" + thread.getId());
thread.setUncaughtExceptionHandler((t, e) -> {
log.error("线程 {} 发生未捕获异常", t.getName(), e);
});
return thread;
};注意:这种方式只对
execute()提交的任务有效,submit()提交的任务异常会被 Future 吞掉,不会触发 UncaughtExceptionHandler。
常见面试陷阱
submit() 的异常陷阱:用 submit 提交任务,如果不调用 get(),异常会被悄悄吞掉,调用方完全不知道任务失败了。
shutdown() 后还能提交任务吗?:不能,会触发拒绝策略,默认抛 RejectedExecutionException。
shutdownNow() 一定能停止线程吗?:不一定,它只是调用 interrupt(),如果任务不响应中断(如没有阻塞方法、或捕获了 InterruptedException 但没处理),线程会继续运行。
线程池中的线程死亡了会怎样?:如果是核心线程,会重新创建一个补充;如果是非核心线程,不会补充。但异常导致的线程死亡会影响线程池的稳定性。
追问延伸:
- 为什么 submit() 的异常不调用 get() 就看不到?底层是怎么实现的?
- shutdown() 和 shutdownNow() 你在项目中是怎么用的?
- 如何监控线程池中任务的异常?有什么好的实践?
- 线程池中的线程如果抛异常了,这个线程还在吗?线程池会怎么处理?
- awaitTermination() 和 isTerminated() 有什么区别?
- 如果线程池里的任务是死循环,shutdownNow() 能停止吗?为什么?
- 你知道线程池有哪些状态?各状态之间如何流转?
Q13: 死锁是什么?四个必要条件?怎么排查和避免? 「🟡 中级」
考察点:考察对死锁概念的理解深度,是否清楚死锁的必要条件、排查手段和避免策略。筛掉对多线程问题缺乏实战经验的人。
参考答案:
什么是死锁
死锁(Deadlock)是指两个或多个线程在执行过程中,因互相等待对方释放锁而造成的阻塞现象,如果没有外力干预,它们将永远等待下去。
死锁示例代码
java
public class DeadlockDemo {
private static final Object lockA = new Object();
private static final Object lockB = new Object();
public static void main(String[] args) {
// 线程1:先拿A,再拿B
new Thread(() -> {
synchronized (lockA) {
System.out.println("线程1:拿到了锁A");
try { Thread.sleep(100); } catch (InterruptedException e) {}
synchronized (lockB) {
System.out.println("线程1:拿到了锁B");
}
}
}, "线程1").start();
// 线程2:先拿B,再拿A
new Thread(() -> {
synchronized (lockB) {
System.out.println("线程2:拿到了锁B");
try { Thread.sleep(100); } catch (InterruptedException e) {}
synchronized (lockA) {
System.out.println("线程2:拿到了锁A");
}
}
}, "线程2").start();
}
}运行结果:两个线程都卡住了,永远不会结束。
死锁的四个必要条件
死锁必须同时满足以下四个条件,破坏其中任意一个就能避免死锁:
| 条件 | 说明 |
|---|---|
| 互斥条件 | 锁是互斥的,同一时刻只能被一个线程持有 |
| 请求与保持条件 | 线程已经持有了至少一个锁,又去请求新的锁,而新锁被其他线程持有 |
| 不剥夺条件 | 已获得的锁不能被其他线程强行剥夺,只能自己主动释放 |
| 循环等待条件 | 线程之间形成头尾相接的循环等待锁的关系 |
死锁排查方法
1. jstack 命令
最常用的死锁排查工具,步骤:
bash
# 1. 找到 Java 进程 PID
jps -l
# 2. 查看线程栈信息,jstack 会自动检测死锁
jstack <pid>如果有死锁,jstack 输出的末尾会有明确提示:
Found one Java-level deadlock:
=============================
"线程2":
waiting to lock monitor 0x000000001b23b288 (object 0x00000000d5c03a60, a java.lang.Object),
which is held by "线程1"
"线程1":
waiting to lock monitor 0x000000001b23b278 (object 0x00000000d5c03a70, a java.lang.Object),
which is held by "线程2"
Java stack information for the threads listed above:
===================================================
"线程2":
at DeadlockDemo.lambda$main$1(DeadlockDemo.java:22)
- waiting to lock <0x00000000d5c03a60> (a java.lang.Object)
- locked <0x00000000d5c03a70> (a java.lang.Object)
"线程1":
at DeadlockDemo.lambda$main$0(DeadlockDemo.java:11)
- waiting to lock <0x00000000d5c03a70> (a java.lang.Object)
- locked <0x00000000d5c03a60> (a java.lang.Object)2. jconsole / jvisualvm
图形化工具,可以直观地看到死锁检测结果和线程状态。
3. Arthas — thread -b
Arthas 是阿里巴巴开源的 Java 诊断工具,排查死锁非常方便:
bash
# 进入 Arthas
java -jar arthas-boot.jar
# 直接查看阻塞的线程(自动找死锁)
thread -b输出示例:
"线程1" Id=12 BLOCKED on java.lang.Object@123456 owned by "线程2" Id=13
"线程2" Id=13 BLOCKED on java.lang.Object@654321 owned by "线程1" Id=124. 代码层面检测
- 使用
ThreadMXBean.findDeadlockedThreads()编程式检测 - 定期巡检,发现死锁自动告警
java
ThreadMXBean bean = ManagementFactory.getThreadMXBean();
long[] deadlockedThreads = bean.findDeadlockedThreads();
if (deadlockedThreads != null) {
// 发现死锁,告警
}死锁的避免策略
死锁的四个必要条件中,互斥条件是锁的基本特性,不能破坏。我们可以从其他三个条件入手。
策略一:破坏请求与保持条件 — 一次性申请所有资源
线程在开始执行前,一次性申请所有需要的锁,要么全拿到,要么一个都不拿。
java
// 不好的做法:分步申请
synchronized (lockA) {
// ...
synchronized (lockB) {
// ...
}
}
// 好的做法:封装一个方法,原子性地获取所有锁
// (实际中可用一个全局锁来保护锁的获取,或使用 tryLock 机制)缺点:资源利用率低,可能导致某些线程长时间拿不到所有锁。
策略二:破坏不剥夺条件 — 超时放弃(tryLock)
使用 ReentrantLock.tryLock() 尝试获取锁,获取不到就释放已持有的锁,重试或放弃。
java
public boolean transfer(Account from, Account to, BigDecimal amount) {
while (true) {
if (from.lock.tryLock()) {
try {
if (to.lock.tryLock()) {
try {
// 执行转账
from.debit(amount);
to.credit(amount);
return true;
} finally {
to.lock.unlock();
}
}
} finally {
from.lock.unlock();
}
}
// 获取失败,休眠随机时间,避免活锁
Thread.sleep(random.nextInt(10));
}
}活锁问题:如果两个线程都"拿A拿不到就释放重试",可能出现都在不断尝试但都拿不到的情况,叫活锁(Live Lock)。解决方法是加入随机等待时间。
策略三:破坏循环等待条件 — 固定加锁顺序(最常用)
给所有锁排个序,所有线程都按照相同的顺序获取锁。
java
// 按 System.identityHashCode 排序,保证加锁顺序一致
public void transfer(Account from, Account to, BigDecimal amount) {
Account first = from.hashCode() < to.hashCode() ? from : to;
Account second = from.hashCode() < to.hashCode() ? to : from;
synchronized (first) { // 先拿 hash 小的
synchronized (second) { // 再拿 hash 大的
// 执行转账
}
}
}如果 hash 冲突怎么办?:可以加一个"打破平局"的锁,或用 ID 排序(如果有唯一 ID 的话)。
这是最推荐的死锁避免方式,实现简单且高效。
策略四:减少锁粒度 / 使用无锁编程
- 用 CAS + 原子类代替锁
- 用并发容器代替手动加锁
- 缩小锁的范围,减少持锁时间
死锁 vs 活锁 vs 饥饿
| 现象 | 描述 | 特点 |
|---|---|---|
| 死锁 | 线程互相等待,都不释放资源 | 永远阻塞,CPU 占用低 |
| 活锁 | 线程都在运行,但不断重试,始终无法推进 | 线程不阻塞,CPU 占用高 |
| 饥饿 | 某个线程一直拿不到资源,无法执行 | 其他线程正常,只有它"饿肚子" |
追问延伸:
- 你在实际项目中遇到过死锁吗?怎么排查和解决的?
- 什么是活锁?和死锁有什么区别?怎么避免活锁?
- 什么是线程饥饿?怎么避免?
- synchronized 能通过 tryLock 的方式避免死锁吗?为什么?
- 数据库层面的死锁你了解吗?怎么排查?
- 除了固定顺序,还有什么破坏循环等待的方法?
- 如果线上发生死锁了,你的排查步骤是什么?
Q14: 常用的并发工具类有哪些?CountDownLatch/CyclicBarrier/Semaphore/Phaser 「🟡 中级」
考察点:考察对 JUC 并发工具类的掌握程度,是否清楚各工具类的适用场景和区别。筛掉只会用基础锁、对并发工具了解有限的人。
参考答案:
java.util.concurrent 包提供了丰富的并发工具类,最常用的四个是 CountDownLatch、CyclicBarrier、Semaphore、Phaser。
1. CountDownLatch — 倒计时门闩
作用:一个线程等待多个线程完成任务后再继续执行。
核心方法:
CountDownLatch(int count):构造方法,指定计数数量countDown():计数减 1,调用一次减一次await():等待计数变为 0,阻塞当前线程await(long timeout, TimeUnit unit):带超时的等待
典型场景:
- 主线程等待多个子任务完成(如并行计算汇总结果)
- 模拟并发,多个线程同时开始(count=1 的用法)
java
// 场景1:主线程等待多个工作线程完成
public class CountDownLatchDemo {
public static void main(String[] args) throws InterruptedException {
int taskCount = 5;
CountDownLatch latch = new CountDownLatch(taskCount);
ExecutorService executor = Executors.newFixedThreadPool(taskCount);
for (int i = 0; i < taskCount; i++) {
final int taskId = i;
executor.submit(() -> {
try {
System.out.println("任务" + taskId + "开始执行");
Thread.sleep(1000); // 模拟任务
System.out.println("任务" + taskId + "执行完成");
} finally {
latch.countDown(); // 计数减1
}
});
}
latch.await(); // 主线程等待所有任务完成
System.out.println("所有任务执行完毕,汇总结果");
executor.shutdown();
}
}特点:
- 一次性使用:计数减到 0 后就不能再用了,无法重置
- 一个线程等多个线程:典型的"一等多"模式
2. CyclicBarrier — 循环栅栏
作用:多个线程互相等待,直到所有线程都到达栅栏位置,才一起继续执行。栅栏可以重复使用。
核心方法:
CyclicBarrier(int parties):指定参与的线程数CyclicBarrier(int parties, Runnable barrierAction):所有线程到达后执行的动作await():到达栅栏,等待其他线程reset():重置栅栏,使其回到初始状态
典型场景:
- 多线程计算数据,最后合并计算结果
- 模拟并发,让所有线程准备好后同时开始
java
// 场景:多个人等齐了一起出发
public class CyclicBarrierDemo {
public static void main(String[] args) {
int peopleCount = 3;
CyclicBarrier barrier = new CyclicBarrier(peopleCount,
() -> System.out.println("人都到齐了,出发!"));
for (int i = 0; i < peopleCount; i++) {
final int personId = i;
new Thread(() -> {
try {
System.out.println("人" + personId + "到达集合点");
barrier.await(); // 等待其他人
System.out.println("人" + personId + "开始爬山");
Thread.sleep(1000); // 模拟爬山
System.out.println("人" + personId + "到达山顶");
barrier.await(); // 栅栏可以重复使用!
System.out.println("人" + personId + "一起下山");
} catch (Exception e) {
e.printStackTrace();
}
}).start();
}
}
}特点:
- 可重用:所有线程通过后自动重置,可以再次使用
- 多等多:所有线程互相等待
- 有 barrierAction:最后一个到达的线程执行该回调
3. Semaphore — 信号量
作用:控制同时访问特定资源的线程数量,即限流。
核心方法:
Semaphore(int permits):指定许可数量Semaphore(int permits, boolean fair):可指定是否公平acquire():获取一个许可,没有就阻塞release():释放一个许可tryAcquire():尝试获取,不阻塞
典型场景:
- 接口限流(限制 QPS)
- 资源池控制(如数据库连接池)
java
// 场景:限流,最多同时 3 个请求
public class SemaphoreDemo {
public static void main(String[] args) {
Semaphore semaphore = new Semaphore(3); // 3个许可
ExecutorService executor = Executors.newFixedThreadPool(10);
for (int i = 0; i < 10; i++) {
final int taskId = i;
executor.submit(() -> {
try {
semaphore.acquire(); // 获取许可
System.out.println("请求" + taskId + "开始处理");
Thread.sleep(1000); // 模拟处理
System.out.println("请求" + taskId + "处理完成");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
semaphore.release(); // 释放许可
}
});
}
executor.shutdown();
}
}
// 输出:同一时刻最多 3 个请求在处理特点:
- 限流神器:控制并发数
- 可公平/非公平:默认非公平,性能更好
- 基于 AQS 共享模式:state 表示剩余许可数
4. Phaser — 阶段器
作用:更灵活的栅栏,支持动态调整参与者数量,支持多阶段同步。可以看作是 CyclicBarrier 的增强版。
核心方法:
register()/bulkRegister(int parties):注册参与者arrive():到达,不等待arriveAndAwaitAdvance():到达并等待其他人arriveAndDeregister():到达并取消注册(减少参与者)getPhase():获取当前阶段编号
典型场景:
- 任务分阶段执行,每个阶段需要等所有参与者完成
- 参与者数量动态变化的场景
java
// 场景:模拟考试,三个阶段:答题 → 交卷 → 出成绩
public class PhaserDemo {
static class ExamPhaser extends Phaser {
@Override
protected boolean onAdvance(int phase, int registeredParties) {
switch (phase) {
case 0:
System.out.println("所有人都答完题了,开始收卷");
return false; // 返回 false 表示继续
case 1:
System.out.println("所有人都交卷了,开始阅卷");
return false;
case 2:
System.out.println("成绩都出来了,考试结束");
return true; // 返回 true 表示终止
default:
return true;
}
}
}
public static void main(String[] args) {
ExamPhaser phaser = new ExamPhaser();
phaser.bulkRegister(3); // 注册 3 个考生
for (int i = 0; i < 3; i++) {
final int studentId = i;
new Thread(() -> {
System.out.println("考生" + studentId + "开始答题");
phaser.arriveAndAwaitAdvance(); // 阶段0:答题完成
System.out.println("考生" + studentId + "交卷");
phaser.arriveAndAwaitAdvance(); // 阶段1:交卷完成
System.out.println("考生" + studentId + "查看成绩");
phaser.arriveAndAwaitAdvance(); // 阶段2:出完成绩
}).start();
}
}
}特点:
- 动态参与者:可以随时注册/取消注册
- 多阶段同步:比 CyclicBarrier 更灵活的阶段控制
- 可终止:onAdvance 返回 true 时终止
- JDK 7 引入:相对较新
四个工具类对比总结
| 工具类 | 作用 | 模式 | 可重用 | 核心方法 | 适用场景 |
|---|---|---|---|---|---|
| CountDownLatch | 倒计时门闩 | 一等多 | 否 | countDown / await | 等待多个任务完成 |
| CyclicBarrier | 循环栅栏 | 多等多 | 是 | await | 多线程同步点、循环使用 |
| Semaphore | 信号量 | 限流 | 是 | acquire / release | 流量控制、资源池 |
| Phaser | 阶段器 | 多阶段同步 | 是 | arriveAndAwaitAdvance | 复杂的多阶段任务 |
常见对比问题
CountDownLatch vs CyclicBarrier
| 对比项 | CountDownLatch | CyclicBarrier |
|---|---|---|
| 等待关系 | 一个线程等多个线程 | 多个线程互相等 |
| 重用性 | 一次性 | 可循环使用 |
| 计数方向 | 减到 0 | 加到 parties |
| 异常处理 | 计数不会重置 | 有 BrokenBarrierException |
| 高级功能 | - | 有 barrierAction |
Semaphore vs 锁
Semaphore 可以当锁用(permits=1),但和锁有区别:
- 锁的释放必须由持有锁的线程执行,Semaphore 的 release 可以由任何线程调用
- 锁是可重入的,Semaphore 不是(同一个线程可以多次 acquire 消耗多个许可)
追问延伸:
- CountDownLatch 和 CyclicBarrier 有什么区别?分别在什么场景下使用?
- CyclicBarrier 的 barrierAction 是由哪个线程执行的?
- Semaphore 能当互斥锁用吗?和 ReentrantLock 有什么区别?
- Phaser 比 CyclicBarrier 强在哪里?
- 你在项目中用过这些工具类吗?什么场景下用的?
- CountDownLatch 的 count 可以重置吗?如果需要循环使用怎么办?
- Semaphore 的公平和非公平模式有什么区别?
Q15: 什么是伪共享?怎么避免? 「🔴 高级」
考察点:考察对 CPU 缓存机制和性能调优的理解深度,是否清楚伪共享的成因和解决方案。筛掉对底层性能优化不了解的人。
参考答案:
CPU 缓存行(Cache Line)
要理解伪共享,首先要理解 CPU 缓存行。
CPU 的运算速度远快于内存的访问速度,为了弥补这个差距,CPU 引入了多级缓存(L1、L2、L3)。
缓存行(Cache Line)是 CPU 缓存的最小单位,通常大小是 64 字节。CPU 从内存加载数据时,不是只加载需要的那一个变量,而是加载它所在的整个缓存行。
CPU 缓存结构
┌─────────────────────────┐
│ L1 Cache (核独有) │ ~32KB,速度最快
└─────────────────────────┘
┌─────────────────────────┐
│ L2 Cache (核独有) │ ~256KB
└─────────────────────────┘
┌─────────────────────────┐
│ L3 Cache (核共享) │ ~几MB,共享
└─────────────────────────┘
┌─────────────────────────┐
│ 主内存 (RAM) │
└─────────────────────────┘为什么用缓存行?
- 空间局部性原理:访问一个变量后,很可能很快访问它附近的变量
- 一次性加载 64 字节比多次加载效率高
什么是伪共享
伪共享(False Sharing):当多个线程同时修改同一个缓存行中的不同变量时,虽然这些变量之间没有关系(不共享),但因为它们在同一个缓存行中,一个变量的修改会导致整个缓存行失效,其他 CPU 核心需要重新从主内存加载,从而影响性能。
缓存行(64字节)
┌──────────────────────┐
│ 变量A │ 变量B │ ... │ ← 同一个缓存行
└──────────────────────┘
▲ ▲
│ │
CPU核1 CPU核2
修改A 修改B
│ │
└─── 互相影响 ───┘为什么叫"伪"共享?
- 变量 A 和 B 本质上是独立的,不存在数据共享
- 但因为它们恰好落在同一个缓存行里,导致看起来像在"共享"同一个缓存行
- 这种"假的共享"就是伪共享
伪共享的性能影响
伪共享在高并发、频繁写入的场景下对性能影响非常大,可能导致性能下降几倍甚至一个数量级。
经典示例:两个独立的计数器
java
// 没有填充的计数器(有伪共享)
public class FalseSharing {
volatile long count1 = 0;
volatile long count2 = 0; // 两个 long 在同一个缓存行
// 线程1 只操作 count1
void inc1() { count1++; }
// 线程2 只操作 count2
void inc2() { count2++; }
}虽然两个线程操作完全不同的变量,但因为它们在同一个缓存行,每次修改都会导致对方的缓存失效,性能急剧下降。
怎么避免伪共享
方法一:缓存行填充(Padding)
在变量前后填充一些无用的字节,让每个变量独占一个缓存行。
java
// 手动填充 7 个 long(每个8字节),共 56 字节 + 自身 8 字节 = 64 字节
public class PaddedCounter {
volatile long p1, p2, p3, p4, p5, p6, p7; // 前填充
volatile long count = 0; // 实际变量
volatile long p8, p9, p10, p11, p12, p13, p14; // 后填充
}为什么前后都要填充? 因为不知道变量在内存中的具体位置,如果只填一边,另一边可能还是和其他变量共享缓存行。
方法二:@Contended 注解(JDK 8+)
JDK 8 引入了 @sun.misc.Contended 注解(JDK 11 移到了 jdk.internal.vm.annotation.Contended),JVM 会自动为被注解的字段或类添加缓存行填充。
java
// 用在字段上
public class ConcurrentHashMap {
@sun.misc.Contended static final class CounterCell {
volatile long value;
// ...
}
}注意:使用
@Contended需要 JVM 参数-XX:-RestrictContended,否则只有 JDK 内部类可以使用。
方法三:数据结构调整
将数组的每个元素分开,或重新组织数据结构,避免被频繁修改的变量相邻。
伪共享的典型应用场景
1. ConcurrentHashMap 的 CounterCell
ConcurrentHashMap 的 size 计算使用了 CounterCell[] 数组来分段计数,每个 CounterCell 用 @Contended 注解避免伪共享:
java
// JDK 源码片段
@jdk.internal.vm.annotation.Contended // 防止伪共享
static final class CounterCell {
volatile long value;
CounterCell(long x) { value = x; }
}因为每个 CounterCell 都会被不同的线程频繁修改,如果不做填充,它们在数组中是连续存放的,会严重的伪共享问题。
2. Disruptor 框架
LMAX 的 Disruptor 是一个高性能的无锁并发框架,它的 RingBuffer 中大量使用了缓存行填充来避免伪共享,这是它高性能的关键之一。
java
// Disruptor 中的填充示例
class LhsPadding {
protected long p1, p2, p3, p4, p5, p6, p7;
}
class Value extends LhsPadding {
protected volatile long value;
}
class RhsPadding extends Value {
protected long p9, p10, p11, p12, p13, p14, p15;
}3. LongAdder
LongAdder 的 Cell 数组也是类似的思路,虽然没有用 @Contended,但通过分散更新 + 数组间隔的方式减少伪共享的影响。
什么时候需要考虑伪共享
伪共享虽然影响大,但也不是所有场景都需要处理:
需要考虑的场景:
- 高并发、多线程频繁写入的场景
- 性能瓶颈已经确认在内存访问上
- 热点数据结构(计数器、队列的头尾指针等)
不需要考虑的场景:
- 读多写少的场景(缓存行失效不频繁)
- 性能要求不高的普通业务代码
- 还没通过压测定位到性能瓶颈时(不要过早优化)
优化原则:先定位瓶颈,再针对性优化。不要为了优化而优化,缓存行填充会浪费内存空间。
追问延伸:
- 缓存行为什么通常是 64 字节?不是更大或更小?
- 伪共享在什么情况下最严重?读多写少的场景有影响吗?
- @Contended 注解的原理是什么?它是怎么填充的?
- 除了 CPU 缓存,你还知道哪些硬件层面影响并发性能的因素?
- 你在实际项目中遇到过伪共享问题吗?怎么发现和解决的?
- MESI 协议和伪共享有什么关系?
- 怎么验证伪共享是否存在?有什么工具可以检测?
Q16: Java 线程和操作系统线程的关系? 「🟡 中级」
考察点:线程模型的底层理解。
参考答案:
HotSpot JVM 中 Java 线程和 OS 线程是 1:1 映射:
Thread.start()→ JVM 调用pthread_create()→ OS 创建内核线程- Java 线程的调度由 OS 调度器管理
- 线程状态由 JVM 管理,但底层依赖 OS 线程状态
线程映射模型对比:
| 模型 | 说明 | 代表 |
|---|---|---|
| 1:1 | 一个用户线程对应一个内核线程 | Java(HotSpot)、C pthread |
| N:1 | 多个用户线程映射到一个内核线程 | 早期绿色线程(Java 1.1) |
| M:N | 多个用户线程映射到多个内核线程 | Go goroutine、Java 21 虚拟线程 |
Java 线程的代价:
- 每个线程约 1-2 MB 栈空间
- 创建线程需要系统调用(内核态切换)
- 线程数过多 → 上下文切换开销大
- 一般建议线程数不超过几百
Java 21 虚拟线程改变了这个模型:
- 虚拟线程是 JVM 管理的轻量级线程
- M:N 模型,多个虚拟线程映射到少量载体线程(ForkJoinPool)
- 虚拟线程 IO 阻塞时不占用 OS 线程
追问延伸:
- 为什么 Java 不用协程而用虚拟线程?(虚拟线程兼容已有 API,不需要改代码)
- Go 的 GMP 模型和 Java 虚拟线程的区别?(Go 是运行时级抢占式调度,Java 虚拟线程是 JVM 级协作式调度)
Q17: 如何停止一个线程?interrupt 的原理? 「🟡 中级」
考察点:线程控制的正确方式。
参考答案:
停止线程的正确方式:interrupt(协作式,不是强制停止)。
java
Thread t = new Thread(() -> {
while (!Thread.currentThread().isInterrupted()) {
// 正常工作
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
// sleep/wait/join 被中断时抛异常,且清除中断标志
// 需要重新设置中断状态或退出循环
Thread.currentThread().interrupt(); // 重新设置中断
break;
}
}
});
t.start();
t.interrupt(); // 请求停止interrupt 的原理:
interrupt():设置线程的中断标志位为 trueisInterrupted():检查中断标志(不清除)Thread.interrupted():检查当前线程的中断标志并清除- 如果线程在
sleep/wait/join时被中断:- 清除中断标志
- 抛出
InterruptedException - 需要调用者处理
为什么不用 Thread.stop()(已废弃):
stop()强制杀死线程,可能导致:- 锁没释放 → 死锁
- 数据不一致 → 对象处于半初始化状态
- 不安全,JDK 1.2 后废弃
为什么不用 volatile boolean flag:
- 线程在
sleep/wait时无法响应 flag 变化 interrupt能唤醒阻塞中的线程
最佳实践:
- 用
interrupt+isInterrupted检查 - 捕获
InterruptedException后恢复中断状态 - 不要吞掉异常(catch 后什么都不做)
追问延伸:
InterruptedException抛出后中断标志还在吗?(不在,被清除了,需要Thread.currentThread().interrupt()恢复)- 线程池中如何中断任务?(
Future.cancel(true)或shutdownNow())
Q18: notify 和 notifyAll 的区别?wait/notify 的原理? 「🟡 中级」
考察点:线程间通信机制。
参考答案:
wait/notify 机制:
wait():当前线程释放锁,进入对象的等待队列(WaitSet),线程状态变为 WAITINGnotify():从等待队列中随机唤醒一个线程,移入 EntryList 竞争锁notifyAll():唤醒等待队列中所有线程,全部移入 EntryList 竞争锁
java
synchronized (lock) {
while (!condition) { // 用 while 不用 if(防止虚假唤醒)
lock.wait(); // 释放锁,等待
}
// 条件满足,执行操作
}
// 另一个线程
synchronized (lock) {
condition = true;
lock.notify(); // 或 notifyAll()
}notify vs notifyAll:
| 维度 | notify | notifyAll |
|---|---|---|
| 唤醒数量 | 1个(随机) | 全部 |
| 性能 | 快(少竞争) | 慢(全部竞争锁) |
| 安全性 | 可能唤醒错误的线程 | 更安全 |
| 推荐 | 精确控制时用 | 默认推荐 |
为什么推荐 notifyAll:
notify随机唤醒一个,可能唤醒的线程条件不满足 → 又wait→ 所有线程都在等 → 死锁notifyAll唤醒所有,虽然有多余竞争,但保证正确性
虚假唤醒(Spurious Wakeup):
- 线程可能在没有
notify的情况下被唤醒 - 所以必须用
while循环检查条件,不能用if
追问延伸:
wait(0)和wait()一样吗?(一样,0 表示无限等待)- LockSupport.park/unpark 和 wait/notify 有什么区别?(park 不需要锁、unpark 可以先于 park 调用)
Q19: 线程间通信方式有哪些? 「🟢 校招/初级」
考察点:多线程通信的全面了解。
参考答案:
| 方式 | 说明 | 适用场景 |
|---|---|---|
| wait/notify | 基于 Object 的等待通知 | 生产者-消费者 |
| volatile | 共享变量的可见性 | 简单标志位 |
| synchronized | 互斥锁 | 临界区保护 |
| Condition | Lock 的 await/signal | 多条件等待 |
| BlockingQueue | 阻塞队列 | 生产者-消费者(推荐) |
| LockSupport.park/unpark | 线程阻塞/唤醒 | AQS 底层 |
| 管道/PipeStream | 管道通信 | IO 流场景 |
| Semaphore | 信号量 | 限流 |
生产者-消费者示例(BlockingQueue):
java
BlockingQueue<String> queue = new LinkedBlockingQueue<>(10);
// 生产者
new Thread(() -> {
queue.put("data"); // 满了则阻塞
}).start();
// 消费者
new Thread(() -> {
String data = queue.take(); // 空了则阻塞
}).start();Condition 示例:
java
ReentrantLock lock = new ReentrantLock();
Condition notFull = lock.newCondition();
Condition notEmpty = lock.newCondition();
// 生产者
lock.lock();
try {
while (queue.size() == capacity) notFull.await();
queue.add(data);
notEmpty.signal();
} finally {
lock.unlock();
}追问延伸:
- BlockingQueue 的实现类有哪些?(ArrayBlockingQueue、LinkedBlockingQueue、SynchronousQueue、PriorityBlockingQueue)
- 为什么推荐 BlockingQueue 而不是 wait/notify?(封装好、不易出错、线程安全)
Q20: 公平锁和非公平锁的区别?为什么非公平锁吞吐量高? 「🟡 中级」
考察点:锁的公平性。
参考答案:
| 维度 | 公平锁 | 非公平锁 |
|---|---|---|
| 获取顺序 | 先到先得(FIFO) | 允许插队 |
| 吞吐量 | 低 | 高 |
| 饥饿 | 不会 | 可能 |
| 实现 | CLH 队列严格排队 | 先 CAS 尝试,失败再排队 |
ReentrantLock 的公平/非公平:
java
ReentrantLock fairLock = new ReentrantLock(true); // 公平锁
ReentrantLock unfairLock = new ReentrantLock(false); // 非公平锁(默认)非公平锁吞吐量高的原因:
- 线程释放锁后,新来的线程可以直接 CAS 获取锁,不需要排队
- 减少线程上下文切换(刚释放锁的线程可能马上又获取锁)
- 在锁持有时间短的场景下,非公平锁效率更高
公平锁的问题:
- 每次都要检查队列中有没有等待线程 → 性能开销
- 线程切换更频繁(不能插队 → 每次换线程)
synchronized 是公平锁还是非公平锁:
- 非公平锁(无法配置公平性)
追问延伸:
- ReentrantLock 的公平锁是怎么实现的?(AQS 的 CLH 队列,tryAcquire 时检查 hasQueuedPredecessors)
- 什么场景适合用公平锁?(对响应时间敏感、不能有饥饿的场景)
Q21: CAS 有什么缺点?ABA 问题怎么解决? 「🟡 中级」
考察点:CAS 机制的问题和解决方案。
参考答案:
CAS 的三个缺点:
- ABA 问题:
- 线程1读到值 A → 准备 CAS(A → C)
- 线程2把 A 改成 B 再改成 A
- 线程1的 CAS 仍然成功(值是 A),但中间发生了变化
- 解决:
AtomicStampedReference(版本号)或AtomicMarkableReference(布尔标记)
java
AtomicStampedReference<Integer> ref = new AtomicStampedReference<>(100, 0);
// CAS 时同时比较值和版本号
int[] stamp = new int[1];
Integer value = ref.get(stamp);
ref.compareAndSet(value, 200, stamp[0], stamp[0] + 1);自旋开销:
- CAS 失败后循环重试(自旋),高竞争下 CPU 浪费
- 解决:限制自旋次数、使用
LongAdder替代AtomicLong(分段 CAS)
只能保证一个变量的原子性:
- 多个变量同时更新不能用单个 CAS 保证
- 解决:把多个变量封装成对象,用
AtomicReference整体 CAS
ABA 问题的实际影响:
- 栈操作(pop 后 push 再 pop):可能释放已回收的内存
- 链表操作:节点被删除后重新插入,CAS 仍可能成功
- 转账场景:余额从 100 → 90 → 100,另一线程 CAS(100→80) 成功,但中间的钱已经转走
追问延伸:
LongAdder是怎么解决 CAS 自旋开销的?(分段:base + Cell 数组,不同线程 CAS 不同 Cell)- ABA 问题在什么场景下有实际危害?(栈/链表的并发操作,内存回收场景)
Q22: 指令重排序是什么?volatile 如何防止重排序? 「🔴 高级」
考察点:内存屏障和重排序的底层原理。
参考答案:
指令重排序:编译器和 CPU 为了提高性能,在不影响单线程语义的前提下重新排列指令顺序。
重排序类型:
- 编译器重排序:编译器优化(循环展开、表达式简化)
- 指令级并行重排序:CPU 流水线、乱序执行
- 内存系统重排序:CPU 缓存导致可见性问题
重排序导致的问题(双重检查锁定 DCL):
java
class Singleton {
private static Singleton instance; // 不加 volatile 有问题!
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton(); // 可能被重排序!
}
}
}
return instance;
}
}new Singleton() 的三步:
- 分配内存空间
- 初始化对象
- 将引用指向内存地址
重排序可能把 2 和 3 交换 → 其他线程看到非 null 引用但对象未初始化。
volatile 防止重排序:
- 在 volatile 写操作前插入
StoreStore屏障(禁止前面的写与 volatile 写重排序) - 在 volatile 写操作后插入
StoreLoad屏障(禁止 volatile 写与后面的读重排序) - 在 volatile 读操作后插入
LoadLoad和LoadStore屏障(禁止后面的读写与 volatile 读重排序)
StoreStore 屏障
volatile 写
StoreLoad 屏障
volatile 读
LoadLoad 屏障
LoadStore 屏障happens-before 原则保证:
- volatile 写 happens-before 后续的 volatile 读
- 所以 volatile 变量的写对其他线程可见
追问延伸:
final字段能防止重排序吗?(能,JMM 保证构造函数内的 final 写不会重排序到构造函数外)- 为什么 synchronized 不需要考虑重排序?(synchronized 的内存语义相当于 volatile 的全屏障)
Q23: 线程池的拒绝策略有哪些?如何选择? 「🟡 中级」
考察点:线程池的溢出处理。
参考答案:
当线程池的工作队列满且线程数达到 maximumPoolSize 时,触发拒绝策略。
JDK 内置 4 种拒绝策略:
| 策略 | 说明 | 适用场景 |
|---|---|---|
| AbortPolicy | 抛 RejectedExecutionException | 默认,重要任务不能丢失 |
| CallerRunsPolicy | 由提交任务的线程执行 | 降速保护(让生产者减速) |
| DiscardPolicy | 静默丢弃新任务 | 允许丢失的非核心任务 |
| DiscardOldestPolicy | 丢弃队列最老的任务 | 只关心最新任务 |
java
ThreadPoolExecutor executor = new ThreadPoolExecutor(
2, 4, 30, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(10),
new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略
);自定义拒绝策略:
java
RejectedExecutionHandler handler = (r, exec) -> {
// 记录日志、持久化到 MQ、降级处理
log.warn("任务被拒绝: {}", r);
// 可以阻塞等待队列有位置
try {
exec.getQueue().put(r); // 阻塞式放入队列
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
};选择建议:
- 核心业务:AbortPolicy(抛异常,不丢任务)
- 非核心业务:DiscardPolicy 或 DiscardOldestPolicy
- 削峰填谷:CallerRunsPolicy(回压生产者)
- 不能丢的任务:自定义(持久化到 MQ/DB)
追问延伸:
- CallerRunsPolicy 有什么副作用?(提交线程被阻塞,如果是 Tomcat 的 IO 线程则影响吞吐)
- 为什么不推荐 Executors.newFixedThreadPool?(默认无界队列,可能 OOM)
Q24: 线程池参数如何设置?CPU 密集型 vs IO 密集型? 「🟡 中级」
考察点:线程池调优。
参考答案:
线程池核心参数:
java
ThreadPoolExecutor executor = new ThreadPoolExecutor(
corePoolSize, // 核心线程数
maximumPoolSize, // 最大线程数
keepAliveTime, // 空闲存活时间
unit, // 时间单位
workQueue, // 工作队列
threadFactory, // 线程工厂
handler // 拒绝策略
);参数设置原则:
CPU 密集型(计算为主,很少 IO):
- 核心线程数 = CPU 核数 + 1(+1 防止偶发的页面错误等停顿)
- 队列不宜太大(任务排队没有意义,不如赶紧执行)
- 例:
N_cpu = 4, corePoolSize = 5
IO 密集型(网络/磁盘/数据库 IO 多):
- 核心线程数 = CPU 核数 × 2 或更多
- 公式:
N = CPU 核数 × (1 + IO等待时间 / CPU计算时间) - 例:IO 等待是 CPU 计算的 4 倍 →
N = 4 × (1 + 4) = 20 - 队列可以适当大
混合型:
- 拆分成 CPU 密集和 IO 密集两个线程池
- 分别设置参数
实际经验:
java
// 获取 CPU 核数
int cpuCores = Runtime.getRuntime().availableProcessors();
// CPU 密集型
int coreSize = cpuCores + 1;
// IO 密集型
int ioCoreSize = cpuCores * 2;
// 或根据压测结果调整线程池工作流程:
- 任务来了 → 核心线程未满 → 创建核心线程执行
- 核心线程满 → 进入工作队列
- 队列满 → 创建非核心线程(直到 maximumPoolSize)
- 都满了 → 执行拒绝策略
核心线程数设为 0 可以吗:
- 可以,但任务先排队,队列满才创建线程
- 好处:内存节省;坏处:任务延迟增大
- 不推荐核心线程数设为 0(除非任务是低优先级可延迟的)
追问延伸:
- 怎么动态调整线程池参数?(
setCorePoolSize/setMaximumPoolSize运行时调整) - 为什么核心线程可以设置为 0?(JDK 默认核心线程不会超时,但可以通过
allowCoreThreadTimeOut(true)让核心线程超时回收)
Q25: 线程池有哪些种类?为什么不推荐 Executors? 「🟡 中级」
考察点:线程池的创建方式和陷阱。
参考答案:
Executors 提供的 4 种线程池:
| 线程池 | 核心/最大线程 | 队列 | 问题 |
|---|---|---|---|
| newFixedThreadPool | n / n | 无界 LinkedBlockingQueue | 队列可能 OOM |
| newSingleThreadExecutor | 1 / 1 | 无界 LinkedBlockingQueue | 队列可能 OOM |
| newCachedThreadPool | 0 / Integer.MAX_VALUE | SynchronousQueue | 线程数可能 OOM |
| newScheduledThreadPool | n / Integer.MAX_VALUE | DelayedWorkQueue | 线程数可能 OOM |
为什么阿里规范禁止用 Executors:
FixedThreadPool和SingleThreadExecutor:用无界队列LinkedBlockingQueue,任务堆积导致 OOMCachedThreadPool:最大线程数Integer.MAX_VALUE,创建大量线程导致 OOMScheduledThreadPool:同样最大线程数无上限
推荐做法:手动创建 ThreadPoolExecutor:
java
ThreadPoolExecutor executor = new ThreadPoolExecutor(
10, // 核心线程数
20, // 最大线程数
60, TimeUnit.SECONDS, // 空闲存活时间
new LinkedBlockingQueue<>(1000), // 有界队列
new ThreadFactoryBuilder()
.setNameFormat("biz-pool-%d") // 命名线程
.build(),
new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略
);线程池命名的重要性:
- 方便排查问题(jstack 中看到线程名就知道是哪个池)
- 不命名的默认是
pool-1-thread-1,无法区分
追问延伸:
SynchronousQueue是什么?(容量为 0 的队列,每个 put 必须等 take,直接传递)ScheduledThreadPoolExecutor怎么实现定时任务?(DelayedWorkQueue + Leader-Follower 模式)
Q26: ThreadLocal 的使用场景?InheritableThreadLocal?TransmittableThreadLocal? 「🟡 中级」
考察点:ThreadLocal 的进阶使用。
参考答案:
ThreadLocal 使用场景:
- 上下文传递(RequestContext、UserContext)
- 线程隔离的工具对象(SimpleDateFormat、Random)
- 数据库连接/事务管理(Spring 的 TransactionSynchronizationManager)
java
// 示例:用户上下文
ThreadLocal<User> userContext = new ThreadLocal<>();
userContext.set(currentUser);
// 同线程其他方法
User u = userContext.get();
userContext.remove(); // 必须手动清理InheritableThreadLocal:
- 父线程的值可以传递给子线程
- 原理:Thread 创建时复制父线程的 InheritableThreadLocalMap
- 局限:只在线程创建时复制,线程池场景下不生效(线程复用)
java
InheritableThreadLocal<String> itl = new InheritableThreadLocal<>();
itl.set("parent-value");
new Thread(() -> {
System.out.println(itl.get()); // "parent-value"
}).start();TransmittableThreadLocal(阿里开源):
- 解决线程池场景下的上下文传递
- 在任务提交时捕获 ThreadLocal,执行前恢复,执行后清理
- 使用方式:用
TtlRunnable.get(runnable)包装
java
TransmittableThreadLocal<String> ttl = new TransmittableThreadLocal<>();
ttl.set("parent-value");
// 线程池场景
executor.submit(TtlRunnable.get(() -> {
System.out.println(ttl.get()); // "parent-value"
}));ThreadLocal 内存泄漏原因:
- Thread → ThreadLocalMap → Entry(key 是
WeakReference<ThreadLocal>, value 是强引用) - ThreadLocal 被 GC 后 key 变 null,但 value 还在 → 泄漏
- 解决:用完后
remove()
追问延伸:
- ThreadLocalMap 的 key 为什么要用弱引用?(如果强引用,ThreadLocal 对象无法被 GC)
- 为什么 ThreadLocal 用开放地址法而不用链地址法?(线程的 ThreadLocal 数量少,开放地址法更省内存)
Q27: 单例模式为什么要用 volatile + synchronized? 「🔴 高级」
考察点:双重检查锁定的底层原理。
参考答案:
双重检查锁定(DCL)单例:
java
class Singleton {
private static volatile Singleton instance; // 必须加 volatile!
public static Singleton getInstance() {
if (instance == null) { // 第一次检查(无锁)
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查(加锁)
instance = new Singleton(); // 创建对象
}
}
}
return instance;
}
}为什么必须加 volatile:
new Singleton()不是原子操作,分三步:- 分配内存
- 初始化对象
- 引用指向内存
- 步骤 2 和 3 可能被重排序(JIT 优化)
- 另一个线程在第一次检查时看到 instance != null(步骤 3 已执行)
- 但对象还没初始化(步骤 2 未执行)→ 使用未初始化的对象 → NPE
volatile 的作用:
- 禁止步骤 2 和 3 的重排序(StoreStore 屏障)
- 保证一个线程创建对象后,其他线程能看到完整的对象
为什么第一次检查不加锁:
- 大部分情况下 instance 已经初始化,直接返回
- 避免每次获取单例都加锁的性能开销
为什么第二次检查必须:
- 两个线程同时通过第一次检查
- 一个获得锁创建对象,另一个等锁
- 第二个获得锁后如果不检查会重复创建
更优的单例实现(枚举):
java
enum Singleton {
INSTANCE;
// 天然线程安全、防反射、防序列化
}追问延伸:
static变量的初始化是线程安全的吗?(类加载时初始化,JVM 保证线程安全)- 为什么不用内部类实现?(也可以,
static内部类在类加载时初始化,但可能被反射破坏)
Q28: 3 个线程并发执行,1 个等待全部完成后再执行? 「🟡 中级」
考察点:并发工具类的实际使用。
参考答案:
方案一:CountDownLatch
java
CountDownLatch latch = new CountDownLatch(3);
for (int i = 0; i < 3; i++) {
new Thread(() -> {
try {
// 执行任务
System.out.println("线程 " + Thread.currentThread().getName() + " 完成");
} finally {
latch.countDown(); // 计数减 1
}
}).start();
}
latch.await(); // 等待 3 个线程都完成
System.out.println("所有线程完成,继续执行");方案二:CompletableFuture
java
CompletableFuture<Void> f1 = CompletableFuture.runAsync(() -> { /* task1 */ });
CompletableFuture<Void> f2 = CompletableFuture.runAsync(() -> { /* task2 */ });
CompletableFuture<Void> f3 = CompletableFuture.runAsync(() -> { /* task3 */ });
CompletableFuture.allOf(f1, f2, f3).join(); // 等待全部完成
System.out.println("所有任务完成");方案三:CyclicBarrier
java
CyclicBarrier barrier = new CyclicBarrier(4); // 4 个线程(3+1)
for (int i = 0; i < 3; i++) {
new Thread(() -> {
// 执行任务
barrier.await(); // 等待 4 个都到达
}).start();
}
barrier.await(); // 主线程也等待
System.out.println("所有线程到达屏障");CountDownLatch vs CyclicBarrier:
| 维度 | CountDownLatch | CyclicBarrier |
|---|---|---|
| 计数 | 减到 0 | 等到 N 个 |
| 复用 | 不可复用 | 可复用(reset) |
| 场景 | 主线程等待子任务 | 多线程互等 |
追问延伸:
- CountDownLatch 可以复用吗?(不可以,减到 0 后无法重置)
CompletableFuture.allOf底层用什么实现?(ForkJoinPool 的 ManagedBlocker + CountDownLatch 的变体)
Q29: 悲观锁和乐观锁的区别? 「🟢 校招/初级」
考察点:锁的基本分类。
参考答案:
| 维度 | 悲观锁 | 乐观锁 |
|---|---|---|
| 思想 | 认为一定会冲突,先加锁 | 认为不一定冲突,先操作再检查 |
| 实现 | synchronized、ReentrantLock、MySQL行锁 | CAS、版本号 |
| 性能 | 冲突多时好 | 冲突少时好 |
| 场景 | 写多读少 | 读多写少 |
悲观锁示例:
java
synchronized (lock) {
// 互斥访问
}乐观锁示例:
java
// CAS 方式
AtomicInteger count = new AtomicInteger(0);
count.incrementAndGet(); // CAS 自旋
// 版本号方式(数据库)
UPDATE user SET name='new', version=version+1
WHERE id=1 AND version=oldVersion;乐观锁的 CAS 缺陷:
- 高竞争下自旋开销大
- ABA 问题
- 只能保证单个变量
实际选择:
- 竞争不激烈 → 乐观锁(CAS / 版本号)
- 竞争激烈 → 悲观锁(synchronized / ReentrantLock)
- 数据库场景 → 乐观锁(version 字段)或悲观锁(
SELECT ... FOR UPDATE)
追问延伸:
synchronized是悲观锁还是乐观锁?(悲观锁,但 JVM 有偏向锁/轻量级锁优化)- MySQL 的
SELECT ... FOR UPDATE是什么锁?(悲观锁/排他锁,其他事务不能读也不能写)
Q30: CompletableFuture 怎么用?和 Future 有什么区别? 「🟡 中级」
考察点:异步编程能力。
参考答案:
Future 的局限:
java
Future<String> future = executor.submit(() -> "result");
String result = future.get(); // 阻塞等待,无法回调
// 无法在完成后自动执行下一步
// 无法组合多个 FutureCompletableFuture 优势:
- 支持回调(完成后自动执行下一步)
- 支持组合(thenCombine、allOf、anyOf)
- 支持异常处理(exceptionally、handle)
- 支持指定线程池
java
// 1. 异步执行
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
return "hello";
}, executor);
// 2. 链式调用
future.thenApply(s -> s + " world") // 转换
.thenAccept(System.out::println) // 消费
.exceptionally(e -> { // 异常处理
System.out.println("error: " + e);
return null;
});
// 3. 组合两个 Future
CompletableFuture<String> f1 = CompletableFuture.supplyAsync(() -> "hello");
CompletableFuture<String> f2 = CompletableFuture.supplyAsync(() -> "world");
f1.thenCombine(f2, (a, b) -> a + " " + b)
.thenAccept(System.out::println); // "hello world"
// 4. 等待全部完成
CompletableFuture<Void> all = CompletableFuture.allOf(f1, f2, f3);
all.join(); // 等待全部完成
// 5. 任一完成
CompletableFuture<Object> any = CompletableFuture.anyOf(f1, f2, f3);
Object result = any.join(); // 第一个完成的结果
// 6. 异常处理
future.handle((result, ex) -> {
if (ex != null) return "default";
return result;
});常用方法:
| 方法 | 说明 |
|---|---|
supplyAsync | 异步执行有返回值 |
runAsync | 异步执行无返回值 |
thenApply | 转换结果 |
thenAccept | 消费结果 |
thenCompose | 链接另一个 CompletableFuture |
thenCombine | 合并两个独立的结果 |
allOf | 等待全部完成 |
anyOf | 等任一完成 |
exceptionally | 异常恢复 |
handle | 处理结果和异常 |
追问延伸:
thenApply和thenCompose的区别?(前者同步转换,后者返回另一个 CompletableFuture)- CompletableFuture 默认用什么线程池?(ForkJoinPool.commonPool)
Q31: synchronized 锁静态方法和普通方法有什么区别?JVM 对 synchronized 做了哪些优化? 「🟡 中级」
考察点:synchronized 的锁粒度和 JVM 优化。
参考答案:
synchronized 锁的目标:
| 修饰方式 | 锁对象 | 示例 |
|---|---|---|
| 实例方法 | this(当前实例) | synchronized void method() |
| 静态方法 | Class 对象(全局唯一) | synchronized static void method() |
| 代码块(实例) | 括号中的对象 | synchronized(this) { } |
| 代码块(类) | Class 对象 | synchronized(MyClass.class) { } |
java
class Counter {
private int count = 0;
// 锁 this:不同实例之间不互斥
public synchronized void instanceMethod() { count++; }
// 锁 Class:所有实例之间互斥
public static synchronized void staticMethod() { ... }
// 等价于实例方法
public void blockMethod() {
synchronized(this) { count++; }
}
}注意:实例方法和静态方法的锁不同 → 它们之间不互斥。
JVM 对 synchronized 的优化(JDK 6+):
- 偏向锁(Biased Locking):
- 第一个线程访问锁 → 在对象头记录线程 ID
- 同一线程再次进入 → 只比对 ID,不加锁(几乎零开销)
- 适合:同一线程反复进入同步块(常见场景)
- JDK 15 废弃偏向锁(收益不再明显)
- 轻量级锁(Thin Lock):
- 多个线程交替进入(无竞争)→ 用 CAS 操作代替互斥量
- 在用户态自旋,不进内核态
- 重量级锁(Fat Lock):
- 竞争激烈 → 自旋失败 → 升级为互斥量 → 进内核态
- 锁消除(Lock Elision):
- JIT 逃逸分析 → 如果对象不可能被其他线程访问 → 删除同步
StringBuffer的同步在单线程中被消除
- 锁粗化(Lock Coarsening):
- 连续多次加锁同一对象 → JIT 合并为一次大锁
for (int i = 0; i < 100; i++) { synchronized(lock) { ... } }→ 合并为一次
锁升级流程:无锁 → 偏向锁 → 轻量级锁(自旋)→ 重量级锁(不可降级)
追问延伸:
- 偏向锁为什么在 JDK 15 废弃?(多核 CPU 下偏向锁的撤销开销变大,收益不显著)
- 锁消除的逃逸分析是什么?(分析对象是否逃出方法/线程,不逃则可消除同步)
Q32: CAS 和 AQS 有什么关系?如何用 AQS 实现一个可重入的公平锁? 「🔴 高级」
考察点:CAS 和 AQS 的关系及自定义锁实现。
参考答案:
CAS 和 AQS 的关系:
- CAS 是底层原子操作(
Unsafe.compareAndSwapInt) - AQS 是基于 CAS 构建的同步框架
- AQS 用 CAS 操作
state变量和等待队列
AQS 核心结构:
state(volatile int) ← CAS 修改,表示锁状态
│
↓
┌─────────────┐
│ 等待队列 (CLH) │ ← 入队/出队用 CAS
│ Node(head) │
│ Node(t1) │
│ Node(t2) │
└─────────────┘state=0→ 无锁;state=1→ 已锁;state=N→ 重入 N 次- 获取锁:CAS 把 state 从 0 改为 1(公平锁先检查队列有无前驱)
- 释放锁:CAS 把 state 减 1(减到 0 则完全释放)
自定义可重入公平锁实现:
java
import java.util.concurrent.locks.AbstractQueuedSynchronizer;
class MyReentrantLock {
private final Sync sync = new Sync(true); // fair=true
private static class Sync extends AbstractQueuedSynchronizer {
private final boolean fair;
Sync(boolean fair) { this.fair = fair; }
// 加锁
protected boolean tryAcquire(int arg) {
Thread current = Thread.currentThread();
int state = getState();
if (state == 0) {
// 公平锁:先检查队列中是否有前驱
if (!fair || !hasQueuedPredecessors()) {
if (compareAndSetState(0, arg)) { // CAS 获取锁
setExclusiveOwnerThread(current);
return true;
}
}
} else if (current == getExclusiveOwnerThread()) {
// 重入:state + 1
setState(state + arg);
return true;
}
return false; // 获取失败,AQS 会把线程加入等待队列
}
// 释放锁
protected boolean tryRelease(int arg) {
int newState = getState() - arg;
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
boolean free = (newState == 0);
if (free) setExclusiveOwnerThread(null);
setState(newState); // 不需要 CAS,只有持有锁的线程能释放
return free;
}
protected boolean isHeldExclusively() {
return getExclusiveOwnerThread() == Thread.currentThread();
}
}
public void lock() { sync.acquire(1); }
public void unlock() { sync.release(1); }
}关键点:
tryAcquire:先 CAS 获取,获取不到判断重入,都不行返回 false(AQS 会入队阻塞)- 公平性:
hasQueuedPredecessors()检查队列中是否有排在前面的线程 - 可重入:通过判断当前线程是否是锁持有者来实现
追问延伸:
- AQS 的 CLH 队列是什么?(自旋锁队列变种,AQS 中改为阻塞等待)
ReentrantLock和我们自定义的锁有什么区别?(ReentrantLock 更完善:Condition、tryLock 超时、可中断)
Q33: 为什么不能所有的锁都用 CAS? 「🟡 中级」
考察点:CAS 的适用场景和局限。
参考答案:
CAS 不是万能的,以下场景不适合:
- 高竞争场景:
- CAS 失败后自旋重试 → 大量线程空转 → CPU 浪费严重
- 100 个线程同时 CAS → 99 个失败自旋 → 吞吐量不如互斥锁
- 互斥锁:线程睡眠让出 CPU → 不空转
- 无法表达复杂状态:
- CAS 只能做"比较并设置" → 无法表达"等待条件满足"
- 如:生产者-消费者需要"队列不满才生产" → CAS 无法直接表达等待
- 需要
Condition/wait-notify机制
- 多变量原子性:
- CAS 一次只能操作一个变量
- 多变量原子操作需要
AtomicReference包装或加锁 - 或用
VarHandle(Java 9+)
- ABA 问题:
- CAS 检测不到"A→B→A"的变化
- 需要版本号(
AtomicStampedReference)
- 长时间持有"锁":
- CAS 自旋期间无法被中断
- 互斥锁可以被
interrupt()中断
CAS vs 互斥锁选择:
| 场景 | 推荐 | 原因 |
|---|---|---|
| 低竞争(如计数器) | CAS | 无上下文切换开销 |
| 高竞争(如热点数据) | 互斥锁 | 避免自旋浪费 |
| 短临界区 | CAS | 自旋比切换快 |
| 长临界区 | 互斥锁 | 自旋太久浪费 CPU |
| 复杂条件等待 | 互斥锁 + Condition | CAS 无法表达 |
| 多变量原子 | 加锁 | CAS 不支持多变量 |
Java 的折中方案:
synchronized:先 CAS 自旋(轻量级锁)→ 失败后升级为互斥量(重量级锁)AQS:先 CAS 尝试 → 失败后入队阻塞(混合策略)LongAdder:高并发计数 → 分段 CAS(Cell 数组),减少竞争
追问延伸:
LongAdder怎么解决 CAS 高竞争问题?(分段:每个线程 CAS 不同 Cell,最后汇总)synchronized的锁升级是不是也是 CAS→互斥锁的混合策略?(是的)
Q34: ReentrantLock 是怎么实现公平锁的? 「🔴 高级」
考察点:ReentrantLock 公平锁的底层实现。
参考答案:
ReentrantLock 基于 AQS 实现,公平和非公平的区别在于 tryAcquire 的实现。
非公平锁(NonfairSync):
java
final boolean nonfairTryAcquire(int acquires) {
Thread current = Thread.currentThread();
int state = getState();
if (state == 0) {
// 直接 CAS 抢锁,不管队列中有没有等待者
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
} else if (current == getExclusiveOwnerThread()) {
// 重入
setState(state + acquires);
return true;
}
return false;
}公平锁(FairSync):
java
final boolean tryAcquire(int acquires) {
Thread current = Thread.currentThread();
int state = getState();
if (state == 0) {
// 关键区别:先检查队列中有没有排在前面的线程
if (!hasQueuedPredecessors() && // ← 公平性保证
compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
} else if (current == getExclusiveOwnerThread()) {
setState(state + acquires);
return true;
}
return false;
}hasQueuedPredecessors() 的作用:
- 检查 CLH 等待队列中是否有排当前线程前面的线程
- 有 → 当前线程不能抢锁 → 返回 false → AQS 把它加入队列
- 无 → 可以 CAS 抢锁
公平锁 vs 非公平锁:
| 维度 | 公平锁 | 非公平锁 |
|---|---|---|
| 获取顺序 | FIFO(先来先得) | 允许插队 |
| 吞吐量 | 低(频繁切换) | 高(减少切换) |
| 饥饿 | 不会 | 可能(某线程一直抢不到) |
| 适用 | 严格公平 | 性能优先 |
非公平锁吞吐量高的原因:
- 刚释放锁的线程可能立刻重新获取 → 不用切换到队列中的线程
- 如果是公平锁 → 必须唤醒队列中的线程 → 上下文切换开销
追问延伸:
- 为什么非公平锁可能吞吐量高 10 倍?(减少线程切换 + 缓存友好)
tryLock()是公平还是非公平的?(非公平,不看队列直接 CAS)
Q35: 线程池核心线程数设为 0 可以吗?线程池比直接创建线程有什么优势? 「🟡 中级」
考察点:线程池参数边界和优势理解。
参考答案:
核心线程数设为 0 可以吗:
- 可以。
corePoolSize=0是合法的 - 任务来了 → 先进队列 → 队列满后才创建非核心线程
- 如果用
SynchronousQueue(无缓冲队列)→ 每个任务直接创建新线程 - 如果用
LinkedBlockingQueue(无界)→ 核心线程数为 0 时任务永远排队 → 可能不创建线程
java
// corePoolSize=0 + SynchronousQueue → 有任务就创建线程(最多 maxPoolSize)
new ThreadPoolExecutor(0, 10, 60, TimeUnit.SECONDS, new SynchronousQueue<>());
// corePoolSize=0 + 无界队列 → 任务全部排队,不创建线程
// (实际上不会创建新线程,因为队列不会满)
new ThreadPoolExecutor(0, 10, 60, TimeUnit.SECONDS, new LinkedBlockingQueue<>());实际工程建议:核心线程数通常 > 0,避免任务排队延迟。
线程池 vs 直接创建线程的优势:
| 维度 | 直接 new Thread() | 线程池 |
|---|---|---|
| 资源消耗 | 每次创建/销毁开销大 | 复用线程,避免创建/销毁开销 |
| 管理控制 | 无法控制数量 → OOM 风险 | 可设置最大线程数、队列大小 |
| 任务排队 | 无 | 有界队列缓冲 |
| 拒绝策略 | 无 | 提供策略(丢弃/CallerRuns/Abort) |
| 监控 | 无 | 可获取活跃数、队列大小、已完成任务数 |
| 生命周期 | 无管理 | 优雅关闭(shutdown/shutdownNow) |
| 设计模式 | 无 | 工厂模式(ThreadFactory)、生产者-消费者 |
线程池用了哪些设计模式:
- 工厂模式:
ThreadFactory创建线程 - 享元模式:线程复用(核心线程不销毁)
- 生产者-消费者:任务队列 + Worker 线程消费
- 模板方法:
execute()流程固定,子类可扩展
追问延伸:
new Thread().start()有什么问题?(不可控、无上限、创建开销大、无管理)corePoolSize=0和corePoolSize=1有什么区别?(0 会排队延迟,1 直接执行)
Q36: 提交给线程池的任务可以撤回吗?Future.cancel() 的原理? 「🟡 中级」
考察点:线程池任务管理和取消机制。
参考答案:
任务撤回通过 Future.cancel() 实现:
java
Future<?> future = executor.submit(() -> {
// 长时间任务
while (!Thread.currentThread().isInterrupted()) {
// do work
}
});
// 撤回任务
boolean cancelled = future.cancel(true); // true = 中断正在执行的任务Future.cancel(mayInterruptIfRunning) 行为:
| 参数 | 任务在队列中 | 任务正在执行 | 任务已完成 |
|---|---|---|---|
cancel(false) | 从队列移除 | 不中断,等执行完 | 返回 false |
cancel(true) | 从队列移除 | 发 interrupt() | 返回 false |
cancel(true) 的原理:
- 修改 Future 状态为
CANCELLED - 如果任务正在执行 → 调用
Thread.interrupt()设置中断标志 - 如果任务在队列中 → 从队列移除
- 任务代码需要响应中断(检查
Thread.currentThread().isInterrupted())
java
// 正确响应中断的任务
Runnable task = () -> {
try {
while (!Thread.currentThread().isInterrupted()) {
Thread.sleep(1000); // sleep 中被中断会抛 InterruptedException
// do work
}
} catch (InterruptedException e) {
// 被中断 → 清理资源退出
Thread.currentThread().interrupt();
}
};注意:
cancel()不能保证任务立即停止 → 只是设置中断标志- 如果任务不响应中断(如
while(true)不检查中断)→ 无法取消 shutdownNow()会中断所有正在执行的任务(类似对每个任务 cancel(true))- I/O 操作和原生方法可能不响应中断
追问延伸:
shutdown和shutdownNow的区别?(shutdown 等任务完成,shutdownNow 立即中断)- 如何实现"超时取消"任务?(
future.get(timeout)+TimeoutException后 cancel)
Q37: 多线程交替打印奇偶数怎么实现? 「🟡 中级」
考察点:线程间通信的实战能力。
参考答案:
方式1:synchronized + wait/notify:
java
class OddEvenPrinter {
private int count = 1;
private final int max;
public OddEvenPrinter(int max) { this.max = max; }
public void printOdd() {
synchronized (this) {
while (count <= max) {
if (count % 2 != 1) { // 不是奇数就等
try { wait(); } catch (InterruptedException e) { return; }
} else {
System.out.println(Thread.currentThread().getName() + ": " + count++);
notifyAll();
}
}
}
}
public void printEven() {
synchronized (this) {
while (count <= max) {
if (count % 2 != 0) { // 不是偶数就等
try { wait(); } catch (InterruptedException e) { return; }
} else {
System.out.println(Thread.currentThread().getName() + ": " + count++);
notifyAll();
}
}
}
}
}
// 使用
OddEvenPrinter printer = new OddEvenPrinter(100);
new Thread(printer::printOdd, "奇数线程").start();
new Thread(printer::printEven, "偶数线程").start();方式2:ReentrantLock + Condition(更灵活):
java
class OddEvenPrinter {
private int count = 1;
private final int max;
private final ReentrantLock lock = new ReentrantLock();
private final Condition oddCond = lock.newCondition();
private final Condition evenCond = lock.newCondition();
public void printOdd() {
lock.lock();
try {
while (count <= max) {
while (count % 2 != 1) oddCond.await();
System.out.println(Thread.currentThread().getName() + ": " + count++);
evenCond.signal();
}
} finally { lock.unlock(); }
}
public void printEven() {
lock.lock();
try {
while (count <= max) {
while (count % 2 != 0) evenCond.await();
System.out.println(Thread.currentThread().getName() + ": " + count++);
oddCond.signal();
}
} finally { lock.unlock(); }
}
}方式3:CAS 自旋(无锁):
java
class OddEvenPrinter {
private final AtomicInteger count = new AtomicInteger(1);
private final int max;
public void printOdd() {
while (count.get() <= max) {
int val = count.get();
if (val % 2 == 1 && val <= max) {
if (count.compareAndSet(val, val + 1)) {
System.out.println(Thread.currentThread().getName() + ": " + val);
}
}
}
}
public void printEven() {
while (count.get() <= max) {
int val = count.get();
if (val % 2 == 0 && val <= max) {
if (count.compareAndSet(val, val + 1)) {
System.out.println(Thread.currentThread().getName() + ": " + val);
}
}
}
}
}三种方式对比:
synchronized:简单,但notifyAll唤醒所有线程可能有抖动ReentrantLock + Condition:精确唤醒(oddCond/evenCond),更高效- CAS:无锁,但忙等浪费 CPU
扩展:N 个线程交替打印 1-N,可以用 Condition 数组或取模判断。
追问延伸:
- 3 个线程交替打印 ABC 怎么实现?(3 个 Condition + 取模判断)
- CAS 方式有什么缺点?(忙等浪费 CPU,但延迟最低)
Q38: Java 中实现乐观锁有哪些方式? 「🟡 中级」
考察点:乐观锁的多种实现方式。
参考答案:
方式1:Atomic 原子类(CAS):
java
AtomicInteger version = new AtomicInteger(0);
// 更新前 CAS 检查
int old = version.get();
// ... 修改数据 ...
if (!version.compareAndSet(old, old + 1)) {
// 版本变了,重试
}方式2:数据库 version 字段:
sql
-- 表结构
CREATE TABLE account (
id INT PRIMARY KEY,
balance DECIMAL(10, 2),
version INT DEFAULT 0
);
-- 更新时检查版本
UPDATE account SET balance = balance - 100, version = version + 1
WHERE id = 1 AND version = 0;
-- 返回影响行数 = 0 → 版本不匹配 → 重试方式3:StampedLock 乐观读(Java 8+):
java
StampedLock sl = new StampedLock();
long stamp = sl.tryOptimisticRead(); // 乐观读锁(不加锁)
int x = data; // 读取
if (!sl.validate(stamp)) { // 验证期间是否有写操作
// 乐观读失败 → 升级为悲观读
stamp = sl.readLock();
try { x = data; } finally { sl.unlockRead(stamp); }
}方式4:自定义 CAS + 版本号:
java
class OptimisticLock<T> {
private volatile T data;
private volatile long version = 0;
public boolean update(T newData, long expectedVersion) {
if (version != expectedVersion) return false; // 版本不匹配
// CAS 更新版本号
if (UNSAFE.compareAndSwapLong(this, VERSION_OFFSET, expectedVersion, expectedVersion + 1)) {
data = newData;
return true;
}
return false;
}
}方式5:数据库条件更新(无版本号):
sql
-- 利用余额本身作为条件
UPDATE account SET balance = balance - 100
WHERE id = 1 AND balance >= 100;
-- 如果余额不足 → 影响行数 0 → 失败重试乐观锁实现要点:
- 版本标记:用版本号、时间戳或数据本身作为"是否被修改"的标记
- 检查-修改-提交:先读 → 在本地修改 → 提交时检查版本 → 成功则提交,失败则重试
- 重试机制:失败后决定是否重试(有重试次数限制防止活锁)
适用场景:读多写少、冲突少、对性能敏感。 不适用:写竞争激烈(重试率高,不如悲观锁)。
追问延伸:
StampedLock的乐观读和悲观读有什么区别?(乐观读不阻塞写,但可能失败;悲观读阻塞写)- 乐观锁和 MVCC 有什么关系?(MVCC 是数据库层面的乐观并发控制,读不加锁读旧版本)
Q39: sleep 会释放 CPU 吗?blocked 和 waiting 有什么区别? 「🟡 中级」
考察点:线程状态的深层理解,高频细节题。
参考答案:
sleep 会不会释放 CPU:
Thread.sleep()不会释放锁,但会让出 CPU 的执行权。- sleep 会让线程从 RUNNABLE 进入 TIMED_WAITING 状态,CPU 调度器会切换到其他线程执行。
- 但 sleep 不会释放持有的 synchronized 锁——其他线程即使被 CPU 调度也无法进入同步块。
- 对比:
wait()既释放 CPU 又释放锁。
java
synchronized (lock) {
Thread.sleep(5000); // 释放 CPU,但不释放 lock
// 其他线程无法进入这个同步块
}
synchronized (lock) {
lock.wait(5000); // 释放 CPU 且释放 lock
// 其他线程可以进入这个同步块
}BLOCKED 和 WAITING 的区别:
| 维度 | BLOCKED | WAITING |
|---|---|---|
| 触发原因 | 等待获取 synchronized 锁 | 调用了 wait/join/LockSupport.park |
| 是否持有锁 | 未持有(等待获取) | 已释放(wait)或未持有(join/park) |
| 唤醒方式 | 锁被释放且竞争成功 | 被 notify/notifyAll/interrupt/unpark |
| 是否可超时 | 不可(只能等锁) | WAITING 不可,TIMED_WAITING 可自动唤醒 |
BLOCKED 状态的线程在Monitor 的 EntryList 中排队,WAITING 状态的线程在 Monitor 的 WaitSet 中或被 LockSupport.park 阻塞。
追问延伸:
Thread.yield()和sleep(0)有什么区别?- 为什么
wait()必须在 synchronized 块中调用?
Q40: notify 选择唤醒哪个线程?wait/notify 的底层实现原理? 「🔴 高级」
考察点:wait/notify 机制的底层实现。
参考答案:
notify 选择哪个线程:
notify()的选择策略由 JVM 实现,不保证公平性,通常是 WaitSet 中的第一个(HotSpot 大致按 FIFO,但规范不保证)。notifyAll()唤醒 WaitSet 中的所有线程,它们被移到 EntryList,然后竞争锁,只有一个能获取锁,其他继续 BLOCKED。- 不能指定唤醒某个特定线程——这是 Java wait/notify 的局限(JUC 的
Condition可以更精确控制)。
wait/notify 的底层实现(基于 ObjectMonitor):
ObjectMonitor {
_owner; // 持有锁的线程
_EntryList; // 等待获取锁的线程队列(BLOCKED)
_WaitSet; // 调用 wait() 的线程队列(WAITING)
_count; // 重入计数
}wait() 执行流程:
- 释放 Monitor 锁(
_owner = null,_count = 0) - 当前线程加入
_WaitSet,状态变为 WAITING - 线程被挂起,等待 notify
notify() 执行流程:
- 从
_WaitSet中取出一个线程 - 将该线程移到
_EntryList - 线程状态变为 BLOCKED,需要重新竞争锁
notify 后线程不能立即执行,必须等持有锁的线程退出同步块后,EntryList 中的线程才有机会获取锁。
追问延伸:
- 为什么
notify()不释放锁?(notify 只是唤醒,锁在同步块结束时才释放) Condition的signal()和Object.notify()有什么区别?(Condition 可以精确唤醒某个条件队列的线程)
Q41: JUC 包下常用的类有哪些? 「🟡 中级」
考察点:Java 并发工具包的整体认知。
参考答案:
JUC(java.util.concurrent)核心组件分类:
1. 锁:
ReentrantLock:可重入排他锁ReentrantReadWriteLock:读写锁(读共享、写排他)StampedLock:乐观读锁(JDK 8+,性能更好)
2. 原子类(java.util.concurrent.atomic):
- 基本类型:
AtomicInteger、AtomicLong、AtomicBoolean - 引用类型:
AtomicReference、AtomicStampedReference(解决 ABA) - 累加器:
LongAdder、LongAccumulator(高并发下比 AtomicLong 更高效) - 数组:
AtomicIntegerArray、AtomicReferenceArray
3. 并发集合:
ConcurrentHashMap:线程安全 HashMapCopyOnWriteArrayList:写时复制,读多写少ConcurrentLinkedQueue:无锁并发队列BlockingQueue体系:ArrayBlockingQueue、LinkedBlockingQueue、SynchronousQueue、PriorityBlockingQueue
4. 同步工具:
CountDownLatch:倒计数,等待 N 个任务完成CyclicBarrier:循环屏障,多个线程相互等待Semaphore:信号量,限流Exchanger:两个线程交换数据Phaser:增强版 CyclicBarrier
5. 线程池(java.util.concurrent):
ThreadPoolExecutor:标准线程池ForkJoinPool:分治任务线程池ScheduledThreadPoolExecutor:定时任务
6. 异步编排:
CompletableFuture:链式异步编排Future/FutureTask:异步结果
追问延伸:
LongAdder为什么比AtomicLong快?(分段累加,减少 CAS 竞争)StampedLock有什么陷阱?(不可重入,容易死锁)
Q42: 怎么保证多线程安全?有哪些方案? 「🟡 中级」
考察点:线程安全的系统性认知。
参考答案:
保证线程安全的三个维度:加锁同步、无锁设计、不变性。
方案1:加锁(悲观)
synchronized:JVM 内置锁,简单易用ReentrantLock:灵活控制(可中断、可超时、可公平)- 读写锁:读多写少场景,
ReentrantReadWriteLock
方案2:无锁(乐观)
- CAS 原子类:
AtomicInteger、AtomicReference LongAdder:高并发计数- 并发集合:
ConcurrentHashMap、CopyOnWriteArrayList
方案3:不可变对象
final修饰 + 无 setter → 天然线程安全String、Integer等不可变类- 函数式编程:纯函数无副作用
方案4:线程封闭
ThreadLocal:每个线程独立副本- 栈封闭:局部变量天然线程安全
方案5:消息传递
- 不共享内存,通过消息队列通信(Actor 模型、Go CSP 模型)
选型建议:
| 场景 | 推荐方案 |
|---|---|
| 简单同步 | synchronized |
| 需要超时/中断 | ReentrantLock |
| 读多写少 | ReadWriteLock / StampedLock |
| 计数器 | LongAdder |
| 高并发 Map | ConcurrentHashMap |
| 线程独立数据 | ThreadLocal |
| 不可变数据 | final + 构造器 |
追问延伸:
- synchronized 和 Lock 性能差多少?(JDK 6+ 优化后差距很小,不激烈竞争时 synchronized 可能更快)
- 不可变对象怎么实现?(final 字段 + 防御性拷贝)
Q43: Java 中有哪些常见的锁?在什么场景下使用? 「🟡 中级」
考察点:锁分类的全面认知。
参考答案:
Java 中常见锁的分类:
| 锁类型 | 代表 | 特点 | 适用场景 |
|---|---|---|---|
| 悲观锁 | synchronized | 先加锁再操作 | 写多读少 |
| 乐观锁 | Atomic类/CAS | 先操作再检查 | 读多写少 |
| 公平锁 | ReentrantLock(true) | FIFO 排队 | 对延迟敏感 |
| 非公平锁 | synchronized | 可插队 | 吞吐量优先 |
| 可重入锁 | synchronized/ReentrantLock | 同一线程可多次获取 | 递归调用 |
| 读写锁 | ReentrantReadWriteLock | 读共享、写排他 | 读多写少 |
| 乐观读锁 | StampedLock | 乐观读不阻塞写 | 读远多于写 |
| 自旋锁 | CAS(AQS 内部) | 不阻塞,忙等待 | 临界区很短 |
| 偏向锁 | JVM synchronized 优化 | 单线程优化 | 单线程访问 |
| 轻量级锁 | JVM synchronized 优化 | CAS 自旋 | 低竞争 |
| 重量级锁 | JVM synchronized 优化 | OS 互斥量 | 高竞争 |
| 共享锁 | ReadLock | 多线程可同时读 | 读操作 |
| 排他锁 | WriteLock/synchronized | 独占访问 | 写操作 |
实际使用中的锁选型:
java
// 1. 简单场景:synchronized
public synchronized void increment() { count++; }
// 2. 需要超时/可中断:ReentrantLock
lock.tryLock(3, TimeUnit.SECONDS);
// 3. 读多写少:ReadWriteLock
readLock.lock(); // 多线程可同时读
writeLock.lock(); // 写排他
// 4. 高并发计数:LongAdder(内部用分段锁/CAS)
longAdder.increment();
// 5. 极高并发读:StampedLock 乐观读
long stamp = stampedLock.tryOptimisticRead();
// 读操作...
if (!stampedLock.validate(stamp)) {
stamp = stampedLock.readLock(); // 升级为悲观读
// 读操作...
stampedLock.unlockRead(stamp);
}追问延伸:
StampedLock为什么不能替代ReentrantReadWriteLock?(不可重入,且容易死锁)- 分段锁怎么实现的?(
ConcurrentHashMapJDK 7 的 Segment)
Q44: 除了 synchronized 还有什么方法可以实现线程同步? 「🟡 中级」
考察点:线程同步方案的广度。
参考答案:
除了 synchronized,Java 还有多种线程同步方式:
1. Lock 接口:
java
ReentrantLock lock = new ReentrantLock();
lock.lock();
try { /* 临界区 */ } finally { lock.unlock(); }2. volatile 关键字(轻量级同步):
- 保证可见性 + 禁止重排序
- 不保证原子性(适合状态标志位)
3. CAS 原子类:
java
AtomicInteger count = new AtomicInteger(0);
count.incrementAndGet(); // CAS 无锁4. 并发集合(内部已实现同步):
java
ConcurrentHashMap<String, String> map = new ConcurrentHashMap<>();
CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();5. 同步工具类:
java
Semaphore semaphore = new Semaphore(3); // 限流
semaphore.acquire();
// 临界区
semaphore.release();6. BlockingQueue(生产者-消费者):
java
BlockingQueue<Task> queue = new LinkedBlockingQueue<>();
queue.put(task); // 队列满则阻塞
Task t = queue.take(); // 队列空则阻塞7. CountDownLatch / CyclicBarrier(线程协调):
java
CountDownLatch latch = new CountDownLatch(3);
// 多线程完成后 await 返回
latch.await();8. ThreadLocal(避免共享):
java
ThreadLocal<SimpleDateFormat> tl = ThreadLocal.withInitial(() ->
new SimpleDateFormat("yyyy-MM-dd"));
// 每个线程独立副本,无需同步追问延伸:
volatile能完全替代synchronized吗?(不能,volatile 不保证原子性)BlockingQueue内部怎么实现线程同步?(ReentrantLock+Condition)
Q45: 可重入锁怎么理解?synchronized 和 ReentrantLock 都是可重入的吗? 「🟡 中级」
考察点:可重入锁的本质理解。
参考答案:
可重入锁:同一线程可以多次获取同一把锁,不会自己阻塞自己。
java
// synchronized 可重入示例
synchronized (lock) {
System.out.println("第一次加锁");
synchronized (lock) { // 同一线程再次获取,不会阻塞
System.out.println("第二次加锁");
}
}
// ReentrantLock 可重入示例
lock.lock(); // count = 1
lock.lock(); // count = 2(同一线程,不阻塞)
lock.unlock(); // count = 1
lock.unlock(); // count = 0,释放锁synchronized 和 ReentrantLock 都是可重入的:
| 维度 | synchronized | ReentrantLock |
|---|---|---|
| 可重入 | ✅ | ✅ |
| 实现方式 | Monitor 的 _count 计数 | AQS 的 state 计数 |
| 重入计数 | ObjectMonitor._count | state 变量 |
| 释放条件 | 退出同步块时 count 减到 0 | unlock() 调用次数 == lock() 次数 |
可重入的实现原理:
synchronized:
- Monitor 内部维护
_count和_owner - 同一线程再次进入 →
_count++ - 退出同步块 →
_count--,减到 0 时释放锁
ReentrantLock(基于 AQS):
- AQS 的
state变量记录重入次数 tryAcquire判断当前线程 == 持有线程 →state++tryRelease→state--,减到 0 时释放
为什么需要可重入:
- 避免递归调用时自己死锁自己
- 允许在持锁方法中调用其他也需要同一把锁的方法
java
public synchronized void methodA() {
methodB(); // 如果不是可重入,这里会死锁
}
public synchronized void methodB() {
// ...
}追问延伸:
ReentrantReadWriteLock的读锁是可重入的吗?(是,但写锁不可重入读锁)- 不可重入锁有什么?(
StampedLock不可重入)
Q46: volatile 能保证线程安全吗?volatile 和 synchronized 的比较? 「🟡 中级」
考察点:volatile 的局限性理解。
参考答案:
volatile 不能完全保证线程安全:
- volatile 保证 可见性(修改对其他线程立即可见)和 有序性(禁止指令重排序)
- volatile 不保证原子性——这是关键限制
java
private volatile int count = 0;
// 线程不安全!volatile 不能保证 ++ 操作原子
void increment() {
count++; // 读-改-写三步,不是原子操作
}
// count = 0 → 两个线程各 ++ 1000 次
// 结果可能 < 2000(虽然 volatile 保证可见性,但存在交错执行)volatile 线程安全的场景(满足以下条件之一):
- 写入不依赖当前值(如
boolean flag = true) - 单线程写、多线程读(如配置变量)
- 使用
AtomicReference包装
java
// 安全:状态标志位
private volatile boolean running = true;
// 线程 A 读取 running
// 线程 B 设置 running = false → A 立即感知
// 安全:单写多读
private volatile Config config;
// 一个线程更新 config = newConfig,其他线程读取volatile 和 synchronized 全面对比:
| 维度 | volatile | synchronized |
|---|---|---|
| 原子性 | ❌ 不保证 | ✅ 保证 |
| 可见性 | ✅ 保证 | ✅ 保证 |
| 有序性 | ✅ 保证(禁止重排序) | ✅ 保证 |
| 阻塞 | 不阻塞 | 可能阻塞 |
| 粒度 | 变量级 | 对象/方法级 |
| 性能 | 轻量 | 较重(锁升级后更重) |
| 编译优化 | 禁止 JIT 优化该变量 | 锁范围内的变量 |
| 适用场景 | 状态标志、单写多读 | 复合操作、临界区保护 |
volatile 的底层实现:
- 写操作:插入 StoreLoad 屏障(写后刷回主内存)
- 读操作:插入 LoadLoad 屏障(从主内存读取,不用工作内存缓存值)
- 使用 内存屏障 实现,不涉及 OS 级互斥量
追问延伸:
volatile int的++操作为什么不安全?(++是读-改-写三步非原子操作)i++在 32 位机器上用long为什么也有问题?(64 位 long 的读写不是原子的,需要 volatile 保证可见性)
Q47: 线程池用了哪些设计模式? 「🔴 高级」
考察点:线程池的设计哲学和模式理解。
参考答案:
线程池中使用了多种设计模式:
1. 生产者-消费者模式:
- 任务提交者 = 生产者
- 工作队列
BlockingQueue= 缓冲区 - 工作线程 = 消费者
BlockingQueue天然实现了生产者-消费者的协调
java
// submit = 生产
executor.submit(task);
// worker.run = 消费
while ((task = getTask()) != null) {
task.run();
}2. 模板方法模式:
ThreadPoolExecutor定义骨架流程- 子类可以覆盖钩子方法来自定义行为
java
// 可覆盖的钩子方法
protected void beforeExecute(Thread t, Runnable r) { }
protected void afterExecute(Runnable r, Throwable t) { }
protected void terminated() { }3. 工厂模式:
Executors是工厂类,提供各种线程池的静态工厂方法newFixedThreadPool、newCachedThreadPool、newSingleThreadExecutor
4. 策略模式:
RejectedExecutionHandler是拒绝策略接口- 不同实现代表不同策略:
AbortPolicy、CallerRunsPolicy、DiscardPolicy、DiscardOldestPolicy - 用户也可以自定义策略
5. 享元模式:
- 线程被复用(
Worker对象不销毁),执行完一个任务后继续从队列取下一个 - 避免频繁创建/销毁线程的开销
6. 适配器模式:
RunnableAdapter将Callable适配为RunnableFutureTask同时实现了Runnable和Future
追问延伸:
- 为什么
Executors不推荐使用?(OOM 风险,推荐直接用ThreadPoolExecutor) Worker类继承 AQS 是为什么?(实现线程的中断状态控制,shutdown 时判断线程是否空闲)
Q48: 两个线程并发读写同一个整型变量,初始值为零,每个线程加50次,结果可能是什么? 「🟡 中级」
考察点:并发原子性问题的经典面试题。
参考答案:
java
private int count = 0;
// 两个线程各执行 50 次 count++
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50; i++) count++;
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50; i++) count++;
});
t1.start(); t2.start();
t1.join(); t2.join();
System.out.println(count);结果范围:2 ~ 100。
为什么不是 100:
count++ 不是原子操作,分三步:
- 读取 count 的值
- 加 1
- 写回 count
两个线程交错执行时会发生 丢失更新:
时间线:
t1 读 count=0
t2 读 count=0 ← 读到了旧值
t1 写 count=1
t2 写 count=1 ← t2 的更新覆盖了 t1,丢失了一次更新为什么最小是 2:
极端情况(所有写操作都丢失,但最后一次不丢失):
- t1 读 0 → 写 1 → 读 1 → 写 2 → ... → 读 49 → 写 50(正常50次)
- t2 一直在读旧值(每次读到 t1 还没更新的值)→ 最后一次读到 49,写 50
- 但更极端的情况:t2 每次读到 t1 上一次写的值,只在最后写一次
理论最小值 = 2(一个线程先完成50次,另一个线程只在最后写了一次覆盖)
为什么不会是 0 或 1:
- 最后一次写一定生效(总有一个线程最后执行写操作)
- 最少的情况是一个线程执行完50次(结果50),另一个线程每次读到的值都最终被自己覆盖,但至少最后一次写会生效
解决方案:
java
// 1. synchronized
synchronized (lock) { count++; }
// 2. AtomicInteger
AtomicInteger count = new AtomicInteger(0);
count.incrementAndGet();
// 3. LongAdder(更高性能)
LongAdder count = new LongAdder();
count.increment();追问延伸:
- 用
volatile修饰 count 能解决吗?(不能,volatile 不保证 ++ 原子性) - 如果 100 个线程各加 10000 次,结果会怎样?(结果远小于 1000000,丢失更新更多)
Q49: Go 的协程和 Java 的线程有什么区别? 「🔴 高级」
考察点:跨语言并发模型对比。
参考答案:
| 维度 | Go goroutine | Java Thread |
|---|---|---|
| 调度方式 | 用户态 GMP 模型 | OS 线程(1:1) |
| 栈大小 | 初始 2KB,动态增长 | 固定大小(默认 1MB) |
| 创建成本 | 极低(~几μs) | 较高(~几十μs+系统调用) |
| 上下文切换 | 用户态切换(~几十ns) | 内核态切换(~几μs) |
| 通信方式 | CSP 模型(channel) | 共享内存 + 锁 |
| 数量上限 | 轻松百万级 | 通常数千级 |
| 抢占 | 协作式 + 信号抢占(Go 1.14+) | 抢占式 |
| 调度器 | Go runtime 自带 | OS 调度器 |
Go GMP 模型:
- G(Goroutine):协程,用户态轻量线程
- M(Machine):OS 线程,真正执行 G
- P(Processor):逻辑处理器,持有 G 的本地队列
- M 必须绑定 P 才能执行 G,P 的数量 = GOMAXPROCS
go
// Go:百万协程轻松创建
for i := 0; i < 1000000; i++ {
go func() { /* ... */ }()
}
// 通过 channel 通信
ch := make(chan int)
go func() { ch <- 42 }()
val := <-ch // 接收java
// Java:线程池 + 共享内存通信
ExecutorService pool = Executors.newFixedThreadPool(200);
BlockingQueue<Integer> queue = new LinkedBlockingQueue<>();
pool.submit(() -> queue.put(42));
int val = queue.take();Java 的虚拟线程(JDK 21+) 是 Java 对协程的回应:
- 轻量级线程,类似 Go goroutine
- 适合 IO 密集型场景
- 与现有代码兼容(Thread API 不变)
java
// JDK 21 虚拟线程
Thread.startVirtualThread(() -> {
// 轻量级协程,不阻塞 OS 线程
});追问延伸:
- Java 虚拟线程和 Go goroutine 有什么区别?(虚拟线程是阻塞式 IO,Go 是非阻塞 + netpoller)
- Go 的 channel 和 Java 的 BlockingQueue 有什么本质区别?(channel 是 CSP 哲学,BlockingQueue 仍是共享内存)
Q50: Java 多线程开发中,锁的最佳实践有哪些? 「🔴 高级」
考察点:工程实践和编码规范。
参考答案:
1. 缩小锁范围:
- 只锁必要的代码,不要把整个方法都锁住
- 锁内不要做 IO、远程调用、耗时计算
java
// 差:锁整个方法
public synchronized void process() {
loadData(); // IO 操作不应该在锁内
updateData(); // 需要同步
logResult(); // IO 操作不应该在锁内
}
// 好:缩小范围
public void process() {
loadData();
synchronized (this) {
updateData();
}
logResult();
}2. 锁分离/锁分段:
- 不同的数据用不同的锁,减少竞争
ConcurrentHashMap的分段锁思想
java
// 差:一把大锁
private final Object lock = new Object();
void updateA() { synchronized(lock) { /* ... */ } }
void updateB() { synchronized(lock) { /* ... */ } }
// 好:锁分离
private final Object lockA = new Object();
private final Object lockB = new Object();
void updateA() { synchronized(lockA) { /* ... */ } }
void updateB() { synchronized(lockB) { /* ... */ } }3. 锁顺序一致:
- 多锁场景按固定顺序获取,避免死锁
java
// 差:不同顺序获取锁
void transferAtoB() { synchronized(lockA) { synchronized(lockB) { } } }
void transferBtoA() { synchronized(lockB) { synchronized(lockA) { } } }
// → 可能死锁
// 好:按 hashCode 排序获取
void transfer(Account from, Account to) {
if (from.hashCode() < to.hashCode()) {
synchronized(from) { synchronized(to) { } }
} else {
synchronized(to) { synchronized(from) { } }
}
}4. 使用 tryLock 带超时:
- 避免死锁,快速失败
java
if (lock.tryLock(3, TimeUnit.SECONDS)) {
try { /* ... */ } finally { lock.unlock(); }
} else {
// 超时处理,不无限等待
}5. 优先使用并发工具而非 wait/notify:
CountDownLatch替代手写等待逻辑BlockingQueue替代wait/notify实现生产者-消费者Semaphore替代手写限流
6. 优先使用并发集合:
ConcurrentHashMap替代Collections.synchronizedMapCopyOnWriteArrayList替代Collections.synchronizedList(读多写少)
7. 锁释放放在 finally:
java
lock.lock();
try {
// 临界区
} finally {
lock.unlock(); // 确保异常时也释放
}8. 不可变优先:
- 能用
final就不用锁 - 能用不可变对象就不共享可变状态
追问延伸:
- 怎么排查生产环境的死锁?(
jstack分析线程 dump,找锁依赖环) ConcurrentHashMap的 size 为什么不精确?(分段统计,允许并发修改)