面试追问地图

主问题必讲关键点下一层追问
JMM主内存/工作内存、可见性、有序性happens-before、内存屏障
volatile可见性、有序性、不保证复合原子性DCL 为什么需要 volatile、伪共享
synchronizedmonitor、可重入、锁升级对象头、偏向锁版本变化、异常释放锁
ReentrantLockAQS、可中断、公平、Condition公平锁代价、tryLock 场景
CAS比较交换、无锁更新ABA、自旋开销、AtomicStampedReference
AQSstate、CLH 变体队列、独占/共享acquire/release 源码、共享模式传播、Condition 队列转移
ThreadLocalThreadLocalMap、弱引用 Key开放寻址与魔数、expungeStaleEntry 时机、TTL 传递
线程池核心参数、提交流程、拒绝策略execute 三步提交源码、ctl 位运算、Worker 为何不可重入
线程状态6 种状态、BLOCKED vs WAITINGsleep 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 支持:超时、中断、公平锁、多条件变量

深答

特性synchronizedReentrantLock
获取方式自动获取手动 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 锁死锁与排查。

快答

死锁四个必要条件(都要满足才会死锁):

  1. 互斥:资源不能被多个线程共享
  2. 持有和等待:线程持有资源的同时等待其他资源
  3. 不可剥夺:资源不能被强制剥夺
  4. 循环等待:多个线程形成循环依赖

避免:破坏其中任何一个条件

深答

经典死锁例子:

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();  // 原子操作
}
 
// 结果总是 100000

DCL 单例为什么必须加 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 锁和同步器的公共骨架。核心组成:

  • statevolatile 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;
}

三个源码结论

  1. 非公平体现在 nonfairTryAcquire 里”锁空闲就直接抢”——不看队列里有没有人排队;公平锁版本会先 hasQueuedPredecessors() 判断队列非空则不抢,这也是公平锁 vs 非公平锁吞吐差异的源码依据
  2. acquireQueued 里线程真正阻塞用的是 LockSupport.park()(挂起,不占 CPU),被前驱唤醒后还要重新 tryAcquire 竞争一次,不是无条件拿到锁——所以即使排在队首也可能被后来直接抢锁的线程截胡(非公平锁的”插队”)
  3. state 用 CAS 更新,但持锁线程自己再次进入(重入)不需要 CAScurrent == getExclusiveOwnerThread() 分支直接加),这是重入锁比无重入检测的自旋锁开销更低的原因之一

共享模式Semaphore/CountDownLatchacquireShared/releaseShared,与独占模式的区别是唤醒后会继续向后传播doReleaseSharedPROPAGATE 状态),因为共享资源一次释放可能满足多个等待者,不能像独占模式那样只唤醒一个就停。

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

频次 ★★★ · 难度 🟢

特性CountDownLatchCyclicBarrier
可重用❌(一次性)✅(可 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 对象里挂一个 ThreadLocalMapthreadLocals 字段),以 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 + 阻塞队列:

  1. runWorker 执行任务前 lock(),表示”我正忙”;shutdown() 只中断**未加锁(空闲)**的线程,避免打断正在跑的任务——这就是为什么它必须是锁而不是简单的 boolean 标志
  2. 必须不可重入:如果任务内部递归调用了线程池导致同一线程重复获取锁,说明发生了错误的重入执行,不可重入锁能让这种情况直接失败而不是死等

常见追问

  • 核心线程数为 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
  • newCachedThreadPoolmaximumPoolSize = 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。

与其他线程池对比:

特性ThreadPoolExecutorForkJoinPool
适用场景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 的锁粗化。