面试追问地图
| 主问题 | 必讲关键点 | 下一层追问 |
|---|---|---|
| JMM | 主内存/工作内存、可见性、有序性 | happens-before、内存屏障 |
| volatile | 可见性、有序性、不保证复合原子性 | DCL 为什么需要 volatile、伪共享 |
| synchronized | monitor、可重入、锁升级 | 对象头、偏向锁版本变化、异常释放锁 |
| ReentrantLock | AQS、可中断、公平、Condition | 公平锁代价、tryLock 场景 |
| CAS | 比较交换、无锁更新 | ABA、自旋开销、AtomicStampedReference |
| AQS | state、CLH 变体队列、独占/共享 | acquire/release 源码、共享模式传播、Condition 队列转移 |
| ThreadLocal | ThreadLocalMap、弱引用 Key | 开放寻址与魔数、expungeStaleEntry 时机、TTL 传递 |
| 线程池 | 核心参数、提交流程、拒绝策略 | execute 三步提交源码、ctl 位运算、Worker 为何不可重入 |
| 线程状态 | 6 种状态、BLOCKED vs WAITING | sleep vs wait、正确停止线程 |
| 锁分类 | 乐观/悲观、共享/独占、公平/非公平 | 可重入实现、synchronized 是公平吗 |
| 死锁 | 必要条件、避免策略 | 排查工具、预防方法 |
| Semaphore | 许可计数、acquire/release | 限流 vs 线程池、虚拟线程场景 |
| ForkJoinPool | 工作窃取、分治任务 | 与 ThreadPoolExecutor 对比、IO 任务风险 |
| CompletableFuture | 异步编排和异常链 | 默认线程池风险、上下文传递 |
| 虚拟线程 | M:N 调度、IO 密集 | pinning、与线程池关系、不适用 CPU 密集 |
| StampedLock | 乐观读、stamp 验证 | 与 ReadWriteLock 对比、不可重入 |
| 锁优化 | 锁粗化、锁消除、偏向锁 | JIT 如何自动优化锁 |
| 活锁 | 互相谦让、无法推进 | 与死锁的区别、随机退避 |
| 线程中断 | interrupt、isInterrupted | 协作式停止、吞掉中断的后果 |
并发题必须说明原子性、可见性、有序性分别由谁保证,并补充一个失败场景。
一、JMM 与线程基础
JMM(Java 内存模型)
频次 ★★★★ · 难度 🟡
JMM 是解决多线程并发问题的一套规则,核心处理 可见性、原子性、有序性 三个问题:
- 可见性:线程修改共享变量后,其他线程能否立即看到。CPU 缓存导致可能读到旧值,
volatile解决此问题 - 原子性:如
i++分读-改-写三步,多线程同时执行会出错,synchronized/Lock保证原子性 - 有序性:编译器/CPU 可能重排指令,
volatile/synchronized通过内存屏障阻止重排序
Java 线程 vs 操作系统线程
难度 🟢
- 平台线程(Platform Thread):1:1 模型,一个 Java 线程 = 一个 OS 线程,栈默认 1MB,开销大
- 虚拟线程(Java 21):M:N 模型,JVM 用户态调度,几百字节起步,单 JVM 可跑百万级,适合 IO 密集型
线程的 6 种状态
难度 🟢
| 状态 | 含义 |
|---|---|
| NEW | 已创建但未调用 start |
| RUNNABLE | 就绪 + 运行中 |
| BLOCKED | 等待监视器锁(synchronized 竞争失败) |
| WAITING | 无限等待(wait/join/LockSupport.park) |
| TIMED_WAITING | 限时等待(sleep(timeout)/wait(timeout)) |
| TERMINATED | 线程结束 |
BLOCKED 和 WAITING 的区别
难度 🟡
- BLOCKED:锁竞争失败后 被动触发,唤醒是 自动的(锁释放后自动重试)
- WAITING:调用 wait/join 等方法 主动触发,必须通过
notify/notifyAll或中断 显式唤醒
sleep 和 wait 的区别
难度 🟢
| 特性 | sleep() | wait() |
|---|---|---|
| 所属类 | Thread(静态方法) | Object(实例方法) |
| 释放锁 | ❌ | ✅ |
| 使用前提 | 任意位置 | 必须在同步块内 |
| 唤醒 | 超时自动 | notify/notifyAll 或超时 |
| 用途 | 暂停执行 | 线程间协作 |
如何正确停止线程
难度 🟡
- 共享标志位:用
volatile boolean控制循环退出 - 中断机制:
Thread.interrupt()设置中断标志,阻塞操作会抛InterruptedException - Future.cancel():线程池提交任务后通过 Future 取消
- 禁止用:
Thread.stop()(已废弃,可能导致状态不一致)
两个线程交替打印奇偶数
难度 🟢
用 wait/notify 控制:
- 两个线程共享一个锁对象和计数器
- 奇数线程:
count % 2 != 0时打印并 notify,否则 wait - 偶数线程:
count % 2 == 0时打印并 notify,否则 wait
二、锁与 synchronized
synchronized 和 ReentrantLock 的区别?
频次 ★★★★★ · 难度 🔴
快答
- synchronized:隐式锁,自动释放,语言级支持
- ReentrantLock:显式锁,灵活,需要手动 unlock
- ReentrantLock 支持:超时、中断、公平锁、多条件变量
深答
| 特性 | synchronized | ReentrantLock |
|---|---|---|
| 获取方式 | 自动获取 | 手动 lock() |
| 释放方式 | 自动释放 | 需要 unlock() |
| 可重入 | ✅ 可重入 | ✅ 可重入 |
| 可中断 | ❌ 不可中断 | ✅ lockInterruptibly() |
| 超时 | ❌ | ✅ tryLock(timeout) |
| 公平锁 | ❌ | ✅ fair 参数 |
| 多条件 | ❌ wait/notify | ✅ Condition |
| 性能 | 现代 JVM 优化后差不多 | 优化空间大 |
synchronized 的实现原理:
// synchronized 方法
public synchronized void method() {
// 相当于:monitor enter
// ... method body ...
// 相当于:monitor exit
}
// 字节码级别
0: aload_0 // this
1: monitorenter // 获取对象锁
2: return // 正常退出
3: astore_1 // 异常
4: aload_0 // this
5: monitorexit // 释放锁
...
monitorexit // 异常时也会释放锁锁升级路径(无锁 → 偏向 → 轻量级 → 重量级)见下文”synchronized 锁升级过程”;ReentrantLock 的排队与唤醒机制见”AQS”一节,公平与非公平的取舍见”公平锁 vs 非公平锁”。
ReentrantLock 的使用:
ReentrantLock lock = new ReentrantLock(false); // false=非公平锁
// 方式 1:基本用法
lock.lock();
try {
// critical section
} finally {
lock.unlock();
}
// 方式 2:带超时
if (lock.tryLock(1, TimeUnit.SECONDS)) {
try {
// critical section
} finally {
lock.unlock();
}
} else {
// timeout 处理
}
// 方式 3:可中断
try {
lock.lockInterruptibly();
// critical section
} catch (InterruptedException e) {
// 被中断
} finally {
lock.unlock();
}
// 方式 4:Condition 条件变量
Condition condition = lock.newCondition();
// 生产者:生产完后通知
lock.lock();
try {
produce();
condition.signalAll();
} finally {
lock.unlock();
}
// 消费者:等待条件
lock.lock();
try {
while (empty()) {
condition.await(); // 释放锁,等待被唤醒
}
consume();
} finally {
lock.unlock();
}选型建议:简单同步优先 synchronized(自动释放、无法忘记 unlock,JVM 还会持续优化);需要超时/中断/公平/多条件变量时用 ReentrantLock,且 unlock 必须放 finally。
synchronized 锁升级过程
频次 ★★★★ · 难度 🟡
无锁 → 偏向锁 → 轻量级锁 → 重量级锁(只能升级不能降级)
- 偏向锁:Mark Word 记录线程 ID,同一线程反复进入无需加锁(JDK 15 起已默认禁用)
- 轻量级锁:线程栈中创建锁记录,CAS 替换 Mark Word,失败则自旋(自适应自旋)
- 重量级锁:膨胀为 Monitor,未获取锁的线程阻塞,需用户态→内核态切换
JVM 对 synchronized 的优化
难度 🟡
- 锁消除:JIT 通过逃逸分析,检测到无竞争时消除同步锁
- 锁粗化:循环中对同一对象反复加锁,JVM 将锁扩展到循环外部
- 自适应自旋:根据上次自旋结果动态调整自旋次数
公平锁 vs 非公平锁
难度 🟡
- 公平锁:按申请顺序排队获取,吞吐小但线程不会饿死。ReentrantLock 可通过
new ReentrantLock(true)设置 - 非公平锁:先尝试 CAS 抢锁,失败才排队。吞吐大但可能饥饿。synchronized 和 ReentrantLock 默认都是非公平锁
- 非公平锁更快:避免了线程休眠/恢复的用户态→内核态切换
锁的分类总结
频次 ★★★★ · 难度 🟢
| 分类维度 | 类型 | 说明 | Java 实现 |
|---|---|---|---|
| 线程态度 | 乐观锁 | 假设无冲突,先操作再检测冲突 | CAS、版本号 |
| 悲观锁 | 假设必有冲突,先加锁再操作 | synchronized、ReentrantLock | |
| 资源排他 | 共享锁 | 允许多线程同时持有 | ReentrantReadWriteLock 的读锁 |
| 独占锁 | 同一时刻只允许一个线程持有 | synchronized、ReentrantLock 写锁 | |
| 公平性 | 公平锁 | 按申请顺序排队 | ReentrantLock(true) |
| 非公平锁 | 允许插队 | synchronized、ReentrantLock 默认 | |
| 重入性 | 可重入锁 | 同一线程可重复获得 | synchronized(隐式)、ReentrantLock(显式) |
| 不可重入锁 | 再次请求死锁 | 自定义锁,很少用 | |
| 是否等待 | 阻塞锁 | 抢不到时挂起线程 | synchronized |
| 非阻塞锁 | 抢不到立即返回或自旋 | CAS、ReentrantLock.tryLock() |
常见追问:
- 乐观锁一定比悲观锁快吗?→ 否。乐观锁在冲突率高时不断重试反而更慢,适合读多写少;悲观锁在冲突率高时更有效
- synchronized 是公平锁还是非公平锁?→ 非公平锁,且不提供公平选项
- 可重入是怎么实现的?→ 每次获取锁时判断持有线程是不是当前线程,是则 state+1,释放时 state-1 直到归零
什么是死锁,如何避免?
频次 ★★★★ · 难度 🟡
OS 视角的死锁四条件与银行家算法见操作系统,本节侧重 Java 锁死锁与排查。
快答
死锁四个必要条件(都要满足才会死锁):
- 互斥:资源不能被多个线程共享
- 持有和等待:线程持有资源的同时等待其他资源
- 不可剥夺:资源不能被强制剥夺
- 循环等待:多个线程形成循环依赖
避免:破坏其中任何一个条件
深答
经典死锁例子:
public class DeadlockExample {
static Object lock1 = new Object();
static Object lock2 = new Object();
public static void main(String[] args) {
// 线程 A
new Thread(() -> {
synchronized (lock1) {
System.out.println("A: 持有 lock1,等待 lock2");
try { Thread.sleep(100); } catch (Exception e) {}
synchronized (lock2) {
System.out.println("A: 同时持有 lock1 和 lock2");
}
}
}).start();
// 线程 B
new Thread(() -> {
synchronized (lock2) {
System.out.println("B: 持有 lock2,等待 lock1");
try { Thread.sleep(100); } catch (Exception e) {}
synchronized (lock1) {
System.out.println("B: 同时持有 lock2 和 lock1");
}
}
}).start();
}
}
// 输出:
// A: 持有 lock1,等待 lock2
// B: 持有 lock2,等待 lock1
// (死锁,程序永远无法继续)避免死锁的方案:
方案 1:顺序锁定(破坏循环等待)
// 所有线程按相同顺序获取锁
public static void main(String[] args) {
new Thread(() -> {
synchronized (lock1) {
synchronized (lock2) {
// A 线程
}
}
}).start();
new Thread(() -> {
synchronized (lock1) { // 也先获取 lock1
synchronized (lock2) {
// B 线程
}
}
}).start();
}方案 2:使用超时(破坏持有和等待)
ReentrantLock lock1 = new ReentrantLock();
ReentrantLock lock2 = new ReentrantLock();
// 尝试在超时内获取锁,失败则释放已持有的锁
if (lock1.tryLock(1, TimeUnit.SECONDS)) {
try {
if (lock2.tryLock(1, TimeUnit.SECONDS)) {
try {
// critical section
} finally {
lock2.unlock();
}
} else {
// 获取 lock2 失败,释放 lock1 并重试
System.out.println("超时,释放 lock1 并重试");
}
} finally {
lock1.unlock();
}
} else {
System.out.println("无法获取 lock1");
}方案 3:使用 ReentrantReadWriteLock(减少竞争)
ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
// 读操作
lock.readLock().lock();
try {
// 多个线程可以同时持有读锁
} finally {
lock.readLock().unlock();
}
// 写操作
lock.writeLock().lock();
try {
// 独占写锁
} finally {
lock.writeLock().unlock();
}检测死锁:
使用 jstack 命令检测死锁
jstack <pid> | grep -A 10 "Found one Java-level deadlock"
三、volatile 与 CAS
volatile 关键字的作用?
频次 ★★★★★ · 难度 🟡
快答
- 保证可见性:一个线程的改动对另一个线程立即可见
- 禁止指令重排:不能把 volatile 变量的操作与其他操作重排
- 不保证原子性:多线程修改仍不安全
深答
可见性保证:
public class VolatileExample {
private static boolean flag = false; // 普通变量
public static void main(String[] args) {
// 线程 1
new Thread(() -> {
while (!flag) {
// flag 可能被编译器优化,直接判断为 false
// 导致不会读取最新的 flag 值
}
System.out.println("Thread 1 finished");
}).start();
try { Thread.sleep(1000); } catch (Exception e) {}
// 主线程
flag = true;
System.out.println("Main set flag = true");
}
}
// 可能输出:
// Main set flag = true
// (Thread 1 永远阻塞,因为编译器优化)使用 volatile 后:
private static volatile boolean flag = false;
// 保证每次读取都从主内存读取最新值
// 输出:
// Main set flag = true
// Thread 1 finished禁止指令重排:
public class VolatileReordering {
static int x = 0, y = 0;
static int a = 0, b = 0;
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 100000; i++) {
x = 0; y = 0; a = 0; b = 0;
Thread t1 = new Thread(() -> {
a = 1;
b = y;
});
Thread t2 = new Thread(() -> {
x = 1;
y = a;
});
t1.start(); t2.start();
t1.join(); t2.join();
// 如果没有 volatile,可能出现 b = 0, y = 0 的情况
// 这说明赋值指令被重排了
if (b == 0 && y == 0) {
System.out.println("指令重排发生!");
}
}
}
}内存屏障(Memory Barrier):
volatile 变量的读写使用内存屏障确保顺序:
volatile 读前:LoadLoad 屏障
volatile 读:load 指令
volatile 读后:LoadStore 屏障
volatile 写前:StoreStore 屏障
volatile 写:store 指令
volatile 写后:StoreLoad 屏障
不保证原子性:
public class VolatileNotAtomic {
private static volatile int count = 0;
public static void main(String[] args) throws InterruptedException {
// 100 个线程,每个线程 1000 次自增
Thread[] threads = new Thread[100];
for (int i = 0; i < 100; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < 1000; j++) {
count++; // 不是原子操作:读-改-写
}
});
threads[i].start();
}
for (Thread t : threads) t.join();
// 期望:100000
// 实际:可能是 99999 或更小(竞态条件)
System.out.println("count = " + count);
}
}
// 原因:count++ 分为三步
// 1. load count
// 2. increment
// 3. store count
// 这三步之间可能被其他线程插入使用 AtomicInteger 替代:
private static AtomicInteger count = new AtomicInteger(0);
// 在新线程中
for (int j = 0; j < 1000; j++) {
count.incrementAndGet(); // 原子操作
}
// 结果总是 100000DCL 单例为什么必须加 volatile 见Java基础”单例模式(双重检查锁定)“。
CAS 的原理和问题
频次 ★★★★ · 难度 🟡
原理:比较并交换(Compare And Swap),三个操作数:内存值 V、预期值 A、新值 B。若 V==A 则将 V 更新为 B,否则不操作。
三大问题:
- ABA 问题:值从 A→B→A,CAS 无法感知中间变化。解决:
AtomicStampedReference加入版本号 - 自旋开销大:长时间 CAS 失败会浪费 CPU
- 只能保证单个变量的原子操作:多变量需用
AtomicReference或锁
四、AQS 与同步器
AQS(AbstractQueuedSynchronizer)
频次 ★★★★ · 难度 🔴
是什么:JUC 锁和同步器的公共骨架。核心组成:
- state:
volatile int,同步状态含义由子类定义(ReentrantLock 是重入次数,Semaphore 是剩余许可,CountDownLatch 是倒数计数) - CLH 变体队列:双向链表,node 持有等待线程引用和等待状态;原始 CLH 是自旋队列,AQS 改造为每个节点阻塞自己的线程,靠前驱节点唤醒
- 模板方法模式:AQS 写好排队、阻塞、唤醒的骨架,子类只需重写
tryAcquire/tryRelease(独占)或tryAcquireShared/tryReleaseShared(共享)
为什么这么设计:把”要不要给我锁”这个业务判断(tryAcquire)和”抢不到怎么排队等待”这个通用机制(AQS 主体)拆开——策略与机制分离。这样 ReentrantLock、Semaphore、CountDownLatch、ReentrantReadWriteLock 复用同一套排队/阻塞/唤醒逻辑,只是 tryAcquire 的判断条件不同。
源码:非公平锁的 acquire 主干(AbstractQueuedSynchronizer,JDK 17 节选简化):
public final void acquire(int arg) {
if (!tryAcquire(arg) && // 1. 先尝试直接抢
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt(); // 排队期间被中断过,补上中断标记
}
// ReentrantLock.NonfairSync 对 tryAcquire 的实现
protected final boolean tryAcquire(int acquires) {
return nonfairTryAcquire(acquires);
}
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) { // 锁空闲,直接 CAS 抢(不排队,体现"非公平")
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
} else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires; // 重入:state 累加,不做 CAS 也安全(自己持有)
setState(nextc);
return true;
}
return false;
}
final boolean acquireQueued(final Node node, int arg) {
boolean interrupted = false;
for (;;) {
final Node p = node.predecessor();
if (p == head && tryAcquire(arg)) { // 只有前驱是 head(即轮到自己)才再抢一次
setHead(node);
p.next = null;
return interrupted;
}
if (shouldParkAfterFailedAcquire(p, node))
interrupted |= parkAndCheckInterrupt(); // LockSupport.park() 阻塞,不自旋空耗 CPU
}
}release 主干:
public final boolean release(int arg) {
if (tryRelease(arg)) { // 重入次数减到 0 才算真正释放
Node h = head;
if (h != null && h.waitStatus != 0)
unparkSuccessor(h); // 唤醒队列里下一个节点对应的线程
return true;
}
return false;
}三个源码结论:
- 非公平体现在
nonfairTryAcquire里”锁空闲就直接抢”——不看队列里有没有人排队;公平锁版本会先hasQueuedPredecessors()判断队列非空则不抢,这也是公平锁 vs 非公平锁吞吐差异的源码依据 acquireQueued里线程真正阻塞用的是LockSupport.park()(挂起,不占 CPU),被前驱唤醒后还要重新 tryAcquire 竞争一次,不是无条件拿到锁——所以即使排在队首也可能被后来直接抢锁的线程截胡(非公平锁的”插队”)state用 CAS 更新,但持锁线程自己再次进入(重入)不需要 CAS(current == getExclusiveOwnerThread()分支直接加),这是重入锁比无重入检测的自旋锁开销更低的原因之一
共享模式:Semaphore/CountDownLatch 走 acquireShared/releaseShared,与独占模式的区别是唤醒后会继续向后传播(doReleaseShared 里 PROPAGATE 状态),因为共享资源一次释放可能满足多个等待者,不能像独占模式那样只唤醒一个就停。
Condition 队列转移:await() 会把当前线程节点从 AQS 同步队列转移到 Condition 的等待队列(一个单向链表),并释放锁;signal() 再把节点从 Condition 队列转移回 AQS 同步队列尾部,等待重新竞争锁。所以一把锁可以有多个 Condition,实现比 wait/notifyAll(只有一个等待集合,唤醒无法定向)更精细的”精确唤醒”。
常见追问
- 为什么用双向链表而不是原始 CLH 的单向?→ AQS 需要支持取消(超时、中断):一个节点要出队时需要知道前驱和后继才能把双方重新连接,单向链表做不到 O(1) 删除。
unparkSuccessor唤醒的一定是 head 的直接后继吗?→ 不一定,若后继已取消,会从队尾往前找第一个未取消的节点唤醒,跳过中间的取消节点。- ReentrantReadWriteLock 怎么用一个 int state 表示读写两种计数?→ 高 16 位存读锁计数,低 16 位存写锁计数,位运算切分同一个 state。
CountDownLatch vs CyclicBarrier
频次 ★★★ · 难度 🟢
| 特性 | CountDownLatch | CyclicBarrier |
|---|---|---|
| 可重用 | ❌(一次性) | ✅(可 reset) |
| 语义 | 一个线程等待多个线程完成 | 多个线程互相等待到达屏障 |
| 计数器操作 | countDown 递减 | await 到达屏障 |
Semaphore
频次 ★★★★ · 难度 🟢
是什么:信号量,控制同时访问资源的线程数。内部维护一组”许可”,线程获取许可后才能执行,用完后释放。
Semaphore permits = new Semaphore(3); // 最多 3 个线程同时访问
// 获取(可中断、可超时)
permits.acquire(); // 阻塞直到获取
permits.tryAcquire(1, TimeUnit.SECONDS); // 等待 1s,超时返回 false
try {
// 访问有限资源(如数据库连接)
} finally {
permits.release(); // 不要忘记释放
}限流 vs 线程池:
- 线程池控制”执行线程数”,Semaphore 控制并发访问数——线程池中的线程也可以被 Semaphore 限流
- 虚拟线程场景下不需要池化线程(见虚拟线程是否还需要线程池),但数据库连接数仍然是有限的,这时用 Semaphore 限制并发连接数比用固定大小的线程池更合适
常见追问:
- Semaphore 支持公平吗?→ 支持。构造器传
new Semaphore(3, true)就是公平信号量,线程按申请顺序获取许可 - acquire 和 tryAcquire 的区别?→ acquire 阻塞直到获取;tryAcquire 立即返回 boolean,不阻塞;tryAcquire(timeout) 等待给定时间后超时返回
- permits 是负数可以吗?→ 可以。
new Semaphore(-1)创建时许可为负,必须先 release 让许可数 ≥ 0 才能 acquire
五、ThreadLocal
ThreadLocal 原理和内存泄漏
频次 ★★★★ · 难度 🟡 · 高频:字节/美团
是什么:每个 Thread 对象里挂一个 ThreadLocalMap(threadLocals 字段),以 ThreadLocal 实例为 key、线程私有副本为 value。get()/set() 都是先取当前线程、再查线程自己的 map——数据存在线程里,不是存在 ThreadLocal 里,所以天然无竞争、不需要加锁。
源码(JDK 8 ThreadLocal.ThreadLocalMap,主干):
static class ThreadLocalMap {
// Entry 继承弱引用:key(ThreadLocal)弱引用,value 强引用
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value;
Entry(ThreadLocal<?> k, Object v) { super(k); value = v; }
}
private Entry[] table; // 初始 16,2 的幂
private void set(ThreadLocal<?> key, Object value) {
int i = key.threadLocalHashCode & (len - 1);
// 开放寻址:槽被占就线性探测下一个,不像 HashMap 拉链表
for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) {
if (e.get() == key) { e.value = value; return; } // 命中覆盖
if (e.get() == null) { // 探测途中撞到 key 已被 GC 的槽
replaceStaleEntry(key, value, i); // 复用槽位并顺手清理一段
return;
}
}
tab[i] = new Entry(key, value);
if (!cleanSomeSlots(i, sz) && sz >= threshold) rehash();
}
}
// 每 new 一个 ThreadLocal,hash 递增一个魔数
private static final int HASH_INCREMENT = 0x61c88647;
private static int nextHashCode() { return nextHashCode.getAndAdd(HASH_INCREMENT); }三个设计点:
- 开放寻址而非拉链:一个线程通常只有少量 ThreadLocal,表很小;且 Entry 要做弱引用失效清理,线性探测的顺序扫描正好和清理动作复用同一趟遍历
- 魔数 0x61c88647:即
2^32 × (1 − 黄金分割率),斐波那契散列——递增序列与 2 的幂取模后落点最分散,让相邻创建的 ThreadLocal 尽量不撞槽,压低探测长度 expungeStaleEntry清理时机:只在get/set/remove探测路过 stale 槽(key==null)时顺手清理(置空 value、把后面探测链上的 entry 前移),没有后台线程——map 一直不被访问,泄漏的 value 就一直在
内存泄漏链:ThreadLocal 实例失去强引用被 GC 后,key 变 null,但 Thread → threadLocals → Entry → value 这条强引用链还在;线程池线程不死,value 永驻。注意答题方向:弱引用不是泄漏的原因,而是补救措施——若 key 用强引用,Entry 连”可被发现是垃圾”的机会都没有。所以结论不变:用完在 finally 里 remove()。
常见追问:
- 为什么 key 用弱引用、value 不用?→ key(ThreadLocal 实例)通常还有外部静态强引用兜着,弱引用只在外部引用断掉后生效;value 没有其他引用路径,设成弱引用会被过早回收
- 子线程能拿到父线程的 ThreadLocal 吗?→ 不能;
InheritableThreadLocal在创建子线程时拷贝一份,但线程池复用线程不再触发拷贝而失效——所以有阿里 TransmittableThreadLocal,在提交任务时捕获、执行时回放 - 线程池 + ThreadLocal 除了泄漏还有什么坑?→ 脏数据:复用的线程读到上一个任务残留的 value,比泄漏更常见也更难排查,同样靠 remove 解决
通用概念:ThreadLocal 是用空间隔离换同步消除——每线程一份副本,不共享就无竞争。同类模式:JVM 的 TLAB(线程私有分配缓冲,见JVM)、Netty 线程绑定 Arena(见Netty与RPC内存池一节)、LongAdder 的分段 Cell。Spring 事务上下文、MDC 日志上下文都构建在 ThreadLocal 之上(见Spring事务详解)。
通用概念:ThreadLocal 只是上下文传递的第一层——它不跨线程(线程池要捕获/回放)、更不跨进程(要靠协议头捎带)。Spring 事务传播与链路追踪都建立在这套机制上。
六、线程池
线程池核心参数(7 个)
频次 ★★★★★ · 难度 🔴
| 参数 | 含义 |
|---|---|
| corePoolSize | 核心线程数 |
| maximumPoolSize | 最大线程数 |
| keepAliveTime | 空闲线程存活时间 |
| unit | 时间单位 |
| workQueue | 任务阻塞队列 |
| threadFactory | 线程工厂 |
| handler | 拒绝策略 |
工作流程:提交任务 → 核心线程未满则创建 → 满则入队 → 队列满则创建非核心线程 → 达最大线程数则执行拒绝策略
源码:execute() 的三步提交与 ctl 位运算(ThreadPoolExecutor,JDK 17 节选简化):
// ctl 是一个 AtomicInteger,高 3 位存运行状态,低 29 位存当前线程数——一个字段两件事,一次 CAS 双重更新
private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));
private static final int COUNT_BITS = Integer.SIZE - 3; // 29
private static int runStateOf(int c) { return c & ~COUNT_BITS_MASK; } // 取高 3 位
private static int workerCountOf(int c) { return c & COUNT_BITS_MASK; } // 取低 29 位
public void execute(Runnable command) {
int c = ctl.get();
// 第一步:线程数 < corePoolSize,直接开核心线程跑
if (workerCountOf(c) < corePoolSize) {
if (addWorker(command, true))
return;
c = ctl.get();
}
// 第二步:核心线程满了,尝试入队(不占线程数,只要还在 RUNNING 状态)
if (isRunning(c) && workQueue.offer(command)) {
int recheck = ctl.get();
if (!isRunning(recheck) && remove(command)) // 双重检查:入队后线程池被 shutdown,回滚
reject(command);
else if (workerCountOf(recheck) == 0)
addWorker(null, false); // 核心线程数为 0 时,补一个线程去消费队列
}
// 第三步:队列也满了,尝试开非核心线程(到 maximumPoolSize 上限)
else if (!addWorker(command, false))
reject(command); // 三步都失败 → 拒绝策略
}为什么把状态和线程数压进同一个 int:线程池的”状态”和”线程数”必须同时变化才对外一致(如 shutdown 时不能有线程数已减但状态还是 RUNNING 的中间态被其他线程看到)。拆成两个字段就要么加锁、要么处理不一致的读取窗口;压成一个 int 后一次 CAS 就能原子更新两者,这是无锁化状态机的常见手法。
Worker 为什么继承 AQS:
private final class Worker extends AbstractQueuedSynchronizer implements Runnable {
Worker(Runnable firstTask) {
setState(-1); // 初始 state=-1,阻止线程启动前被中断(runWorker 里会 unlock 置 0)
this.thread = getThreadFactory().newThread(this);
}
protected boolean tryAcquire(int unused) { // 不可重入:acquire 一次后重复 acquire 直接失败
if (compareAndSetState(0, 1)) {
setExclusiveOwnerThread(Thread.currentThread());
return true;
}
return false;
}
}Worker 自己实现一把不可重入的独占锁(state 0/1,没有重入计数逻辑),复用 AQS 而不是自己写 CAS + 阻塞队列:
runWorker执行任务前lock(),表示”我正忙”;shutdown()只中断**未加锁(空闲)**的线程,避免打断正在跑的任务——这就是为什么它必须是锁而不是简单的 boolean 标志- 必须不可重入:如果任务内部递归调用了线程池导致同一线程重复获取锁,说明发生了错误的重入执行,不可重入锁能让这种情况直接失败而不是死等
常见追问
- 核心线程数为 0、队列非空时谁来消费?→
execute第二步末尾的workerCountOf(recheck) == 0分支专门处理这个边界:允许 corePoolSize=0 的线程池(如newCachedThreadPool)靠这一步补线程去消费队列任务。 - 线程数为什么要拆两次 CAS(
addWorker里先 CAS 线程数,创建成功后再启动线程)?→ 先占位再创建,避免”线程数校验通过 → 并发多个线程同时创建 → 实际线程数超过 maximumPoolSize”的竞态。 getTask()怎么让空闲线程数收缩到 corePoolSize?→ 非核心线程或allowCoreThreadTimeOut=true时,workQueue.poll(keepAliveTime, ...)超时返回 null,processWorkerExit据此结束该 Worker。
线程池拒绝策略
难度 🟢
- AbortPolicy(默认):抛 RejectedExecutionException
- CallerRunsPolicy:让调用者线程自己执行
- DiscardPolicy:静默丢弃
- DiscardOldestPolicy:丢弃队列最老任务
通用概念:拒绝策略是背压的四种形态,其中 CallerRunsPolicy 最优雅:提交者忙于执行任务,自然就没空提交新任务。禁用 Executors 的真正理由是它的无界队列根本没有背压,而不是「参数不清晰」。
线程池线程数设置经验
难度 🟢
- CPU 密集型:corePoolSize = CPU 核数 + 1
- IO 密集型:corePoolSize = CPU 核数 × 2(或更高)
为什么禁止使用 Executors.newFixedThreadPool / newCachedThreadPool
频次 ★★★★ · 难度 🟢
- newFixedThreadPool:使用无界
LinkedBlockingQueue(容量 Integer.MAX_VALUE),任务堆积会 OOM - newCachedThreadPool:
maximumPoolSize = Integer.MAX_VALUE,高并发时创建大量线程会 OOM - 正确做法:手动
new ThreadPoolExecutor配置有界队列和可控线程数
ForkJoinPool 工作窃取
频次 ★★★ · 难度 🔴 · 高频:阿里
ForkJoinPool 是 Java 7 引入的线程池,专门用于分治任务(将大任务拆成小任务并行执行)。核心特点是工作窃取(Work-Stealing)。
工作窃取原理:
- 每个工作线程维护一个双端队列(Deque),存放被分配的子任务
- 线程从自己的队尾取任务执行(LIFO,减少竞争)
- 空闲线程从其他线程的队头窃取任务执行(FIFO,窃取的是最老的任务)
- 窃取发生时窃取者和被窃取者操作队列两端,竞争概率低
// 经典用法:计算 1~N 的和
ForkJoinPool pool = ForkJoinPool.commonPool();
int result = pool.invoke(new SumTask(1, 100_000));
class SumTask extends RecursiveTask<Integer> {
int l, r;
SumTask(int l, int r) { this.l = l; this.r = r; }
@Override
protected Integer compute() {
if (r - l <= 1000) { // 阈值:直接计算
int sum = 0;
for (int i = l; i <= r; i++) sum += i;
return sum;
}
int mid = l + (r - l) / 2;
SumTask left = new SumTask(l, mid);
SumTask right = new SumTask(mid + 1, r);
left.fork(); // 异步执行
int rightRes = right.compute();// 当前线程直接算右边
int leftRes = left.join(); // 等待左边结果
return leftRes + rightRes;
}
}常见坑:
- IO 任务不可用:
ForkJoinPool.commonPool()被CompletableFuture默认使用,IO 任务会阻塞工作线程且不被窃取,导致 CPU 密集型任务饿死。IO 密集型应自定义线程池。 - 任务拆分粒度不宜过细(阈值太小会导致任务数膨胀)。
fork()比invokeAll()更底层——invokeAll(left, right)等价于自动做 fork + compute + join。
与其他线程池对比:
| 特性 | ThreadPoolExecutor | ForkJoinPool |
|---|---|---|
| 适用场景 | IO/CPU 混合,独立任务 | 分治、递归可拆分任务 |
| 任务队列 | 一个全局 BlockingQueue | 每个线程一个双端 Deque |
| 空闲处理 | 超时回收 | 窃取其他线程任务 |
| 最优任务 | 短且独立 | 可拆分、粒度可控 |
关联题: 算法中的 241. 为运算表达式设计优先级 可用分治 + 记忆化搜索,底层逻辑与 ForkJoin 的分治思想一致。
Semaphore 信号量怎么用?
频次 ★★ · 难度 🟡
是什么:Semaphore 是计数信号量,维护一组”许可证”。线程调用 acquire() 获取许可证(许可证数 -1,没有则阻塞),release() 归还许可证(许可证数 +1,唤醒一个等待线程)。
典型场景:限流(如数据库连接池最多允许 10 个并发访问)、资源池控制。
核心参数:new Semaphore(permits, fair)——permits 为初始许可证数,fair 为是否公平模式(true 时按 FIFO 顺序唤醒等待线程,避免饥饿但吞吐略低)。
与 CountDownLatch 的区别:Semaphore 是可复用的计数器(acquire/release 反复使用),CountDownLatch 是一次性的(countDown 到 0 后不可重置)。
StampedLock 是什么?与 ReadWriteLock 相比有什么优势?
频次 ★★ · 难度 🔴
是什么:Java 8 引入的读写锁的改进版,引入乐观读模式。常规读写锁的读操作需要获取读锁(CAS 操作有开销),StampedLock 的乐观读不加锁直接读,读完校验 stamp 是否变化——如果期间没有写操作,乐观读零开销完成。
三种模式:写锁(writeLock(),独占)、悲观读锁(readLock(),共享)、乐观读(tryOptimisticRead(),返回 stamp,读取后在 validate(stamp) 校验)。
优势:读多写极少场景下,乐观读消除了 CAS 竞争,吞吐显著高于 ReentrantReadWriteLock。
代价:不可重入;没有 Condition 支持;API 更复杂(stamp 管理)。
什么是守护线程?有什么特点?
频次 ★ · 难度 🟢
是什么:守护线程(Daemon Thread)是”服务线程”,当 JVM 中只剩下守护线程时,JVM 自动退出。通过 thread.setDaemon(true) 设置(必须在 start() 之前调用)。
特点:GC 线程、Finalizer 线程是典型的守护线程;守护线程的 finally 块不保证执行(JVM 退出时直接终止);守护线程创建的子线程默认也是守护线程。
线程中断机制:如何优雅地停止一个正在运行的线程?
频次 ★★ · 难度 🟡
是什么:Thread.stop() 已废弃(强制终止导致不一致状态)。Java 用协作式中断:一个线程请求另一个线程”请停止”,被请求的线程在合适的时机检查中断标志并自行退出。
三种方式:
thread.interrupt():设置中断标志 + 如果线程在wait/sleep/join中则抛出InterruptedException并清除标志Thread.interrupted():静态方法,检查并清除当前线程的中断标志thread.isInterrupted():实例方法,检查但不清除中断标志
最佳实践:循环条件中检查 Thread.currentThread().isInterrupted();捕获 InterruptedException 后恢复中断标志(Thread.currentThread().interrupt())或直接退出,不要吞掉中断。
什么是活锁?如何避免?
频次 ★ · 难度 🟡
是什么:活锁(Livelock)与死锁不同——线程没有被阻塞,而是在不断改变状态尝试避开冲突,但永远无法推进。典型场景:两个线程互相谦让资源,A 发现 B 在用就释放,B 发现 A 在用也释放,两人无限循环。
与死锁的区别:死锁是”都在等,谁也动不了”;活锁是”都在动,但谁也推进不了”。
避免方法:引入随机退避(random backoff),让线程在重试前等待随机时间,打破同步节奏。
锁粗化和锁消除分别是什么?
频次 ★★ · 难度 🟡
是什么:JVM 对 synchronized 的两种编译期优化:
- 锁粗化(Lock Coarsening):连续对同一个对象加锁解锁,JIT 合并为一次大锁。如
StringBuffer在循环中每次append都加锁,JIT 把整个循环包在一个锁里。 - 锁消除(Lock Elimination):JIT 通过逃逸分析确定锁对象不会被其他线程访问,直接去掉锁。如方法内
new StringBuffer局部变量,JIT 证明它不逃逸,去掉所有synchronized。
为什么要了解:面试中不要写”手动减少锁范围”的微优化——JIT 会自动做,过度优化反而可能干扰 JIT 的锁粗化。