跳转至

并发编程

面试追问地图

主问题 必讲关键点 下一层追问
JMM 主内存/工作内存、可见性、有序性 happens-before、内存屏障
volatile 可见性、有序性、不保证复合原子性 DCL 为什么需要 volatile、伪共享
synchronized monitor、可重入、锁升级 对象头、偏向锁版本变化、异常释放锁
ReentrantLock AQS、可中断、公平、Condition 公平锁代价、tryLock 场景
CAS 比较交换、无锁更新 ABA、自旋开销、LongAdder、AtomicStampedReference
AQS state、CLH 变体队列、独占/共享 acquire/release 源码、共享模式传播、Condition 队列转移
LockSupport 单许可、先 unpark 不丢信号、虚假唤醒 中断语义、blocker、为什么必须循环检查条件
读写锁 高低 16 位、读共享写独占 锁降级、为什么不能直接升级、写饥饿
Phaser 动态参与者、多阶段屏障 与 CountDownLatch/CyclicBarrier 对比、终止条件
ThreadLocal ThreadLocalMap、弱引用 Key 开放寻址与魔数、expungeStaleEntry 时机、TTL 传递
线程池 核心参数、提交流程、拒绝策略 execute 三步提交源码、ctl 位运算、Worker 为何不可重入
定时线程池 DelayQueue、fixed-rate/fixed-delay 周期任务异常、取消残留、为什么 maximumPoolSize 无效
线程状态 6 种状态、BLOCKED vs WAITING sleep vs wait、正确停止线程
正确停止线程 协作式中断、三种方式、守护线程 interrupt 三方法、吞掉中断的后果
锁分类 乐观/悲观、共享/独占、公平/非公平 可重入实现、synchronized 是公平吗
StampedLock 乐观读、stamp 验证 与 ReadWriteLock 对比、不可重入
死锁 必要条件、避免策略 排查工具、预防方法
活锁 互相谦让、无法推进 与死锁的区别、随机退避
锁优化 锁粗化、锁消除、自适应自旋 JIT 如何自动优化锁
Semaphore 许可计数、acquire/release 限流 vs 线程池、虚拟线程场景
ForkJoinPool 工作窃取、分治任务 与 ThreadPoolExecutor 对比、IO 任务风险
CompletableFuture 异步编排和异常链 默认线程池风险、上下文传递
虚拟线程 M:N 调度、IO 密集 pinning、与线程池关系、不适用 CPU 密集

并发题必须说明原子性、可见性、有序性分别由谁保证,并补充一个失败场景。


一、JMM 与线程基础

JMM(Java 内存模型)

频次 ★★★★ · 难度 🟡

JMM 是解决多线程并发问题的一套规则,核心处理 可见性、原子性、有序性 三个问题: - 可见性:线程修改共享变量后,其他线程能否立即看到。CPU 缓存导致可能读到旧值,volatile 解决此问题 - 原子性:如 i++ 分读-改-写三步,多线程同时执行会出错,synchronized/Lock 保证原子性 - 有序性:编译器/CPU 可能重排指令,volatile/synchronized 通过内存屏障阻止重排序

happens-before 规则

频次 ★★★★ · 难度 🟡

happens-before 是 JMM 定义的偏序关系,用来判断两个操作之间是否存在内存可见性保证:如果 A happens-before B,则 A 的结果对 B 可见,且 A 的执行顺序在 B 之前。

八条规则: 1. 程序次序规则:同一个线程内,前面的操作 happens-before 后面的操作 2. volatile 变量规则:volatile 写 happens-before 后续的 volatile 读 3. 锁规则:unlock happens-before 后续的 lock 4. 传递性:A hb B, B hb C → A hb C 5. 线程启动规则:Thread.start() hb 线程内的任何操作 6. 线程终止规则:线程内的所有操作 hb Thread.join() 返回 7. 线程中断规则:interrupt() hb 被中断线程检测到中断 8. 对象终结规则:构造函数结束 hb finalize() 开始

面试怎么用:happens-before 不是"实际执行顺序",而是内存可见性的保证。比如规则 3 保证了 synchronized 块内的修改在释放锁后对下一个获取锁的线程可见,这就是为什么 synchronized 能保证可见性——不是因为"互斥",而是因为 happens-before 建立了可见性链条。

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 或超时
用途 暂停执行 线程间协作

如何正确停止线程

频次 ★★ · 难度 🟡

Java 用协作式中断:一个线程请求另一个线程"请停止",被请求的线程在合适的时机检查中断标志并自行退出。Thread.stop() 已废弃(强制终止导致不一致状态)。

三种方式: - 共享标志位:用 volatile boolean 控制循环退出 - 中断机制:Thread.interrupt() 设置中断标志,阻塞操作(wait/sleep/join)会抛 InterruptedException 并清除标志 - Future.cancel():线程池提交任务后通过 Future 取消

中断相关方法: - thread.interrupt():设置中断标志 + 唤醒阻塞线程 - Thread.interrupted():静态方法,检查并清除当前线程的中断标志 - thread.isInterrupted():实例方法,检查但不清除中断标志

最佳实践:循环条件中检查 Thread.currentThread().isInterrupted();捕获 InterruptedException 后恢复中断标志(Thread.currentThread().interrupt())或直接退出,不要吞掉中断。

什么是守护线程?

频次 ★ · 难度 🟢

守护线程(Daemon Thread)是"服务线程",当 JVM 中只剩下守护线程时,JVM 自动退出。通过 thread.setDaemon(true) 设置(必须在 start() 之前调用)。

特点:GC 线程是典型的守护线程;守护线程的 finally 块不保证执行(JVM 退出时直接终止);守护线程创建的子线程默认也是守护线程。

两个线程交替打印奇偶数

难度 🟢

用 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 的优化

频次 ★★ · 难度 🟡

锁消除(Lock Elimination):JIT 通过逃逸分析确定锁对象不会被其他线程访问,直接去掉锁。如方法内 new StringBuffer 局部变量,JIT 证明它不逃逸,去掉所有 synchronized。

锁粗化(Lock Coarsening):连续对同一个对象加锁解锁,JIT 合并为一次大锁。如 StringBuffer 在循环中每次 append 都加锁,JIT 把整个循环包在一个锁里。

自适应自旋:根据上次自旋结果动态调整自旋次数,上次自旋成功则延长,失败则缩短或直接阻塞。

为什么要了解:面试中不要写"手动减少锁范围"的微优化——JIT 会自动做,过度优化反而可能干扰 JIT 的锁粗化。

公平锁 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 直到归零

StampedLock 是什么?与 ReadWriteLock 相比有什么优势?

频次 ★★ · 难度 🔴

是什么:Java 8 引入的读写锁的改进版,引入乐观读模式。常规读写锁的读操作需要获取读锁(CAS 操作有开销),StampedLock 的乐观读不加锁直接读,读完校验 stamp 是否变化——如果期间没有写操作,乐观读零开销完成。

三种模式:写锁(writeLock(),独占)、悲观读锁(readLock(),共享)、乐观读(tryOptimisticRead(),返回 stamp,读取后在 validate(stamp) 校验)。

优势:读多写极少场景下,乐观读消除了 CAS 竞争,吞吐显著高于 ReentrantReadWriteLock。

代价:不可重入;没有 Condition 支持;API 更复杂(stamp 管理)。

什么是死锁,如何避免?

频次 ★★★★ · 难度 🟡

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"

什么是活锁?如何避免?

频次 ★ · 难度 🟡

是什么:活锁(Livelock)与死锁不同——线程没有被阻塞,而是在不断改变状态尝试避开冲突,但永远无法推进。典型场景:两个线程互相谦让资源,A 发现 B 在用就释放,B 发现 A 在用也释放,两人无限循环。

与死锁的区别:死锁是"都在等,谁也动不了";活锁是"都在动,但谁也推进不了"。

避免方法:引入随机退避(random backoff),让线程在重试前等待随机时间,打破同步节奏。


三、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基础"单例模式(双重检查锁定)"。

伪共享(False Sharing)

频次 ★★ · 难度 🔴

是什么:CPU 缓存以缓存行(Cache Line,通常 64 字节)为单位加载。两个线程分别修改同一缓存行上的不同变量时,虽然逻辑上不共享数据,但 CPU 缓存一致性协议(MESI)会强制该缓存行在两个 CPU 核之间来回失效和同步,导致性能严重下降。

示例:volatile long a, b; 紧挨着放在内存中,线程 1 写 a、线程 2 写 b,两个变量在同一缓存行,每次写入都触发对方 CPU 缓存行失效。

解决方案: - 填充(Padding):在变量前后加无意义的 long 字段,把目标变量推到一个缓存行独占的位置。如 Disruptor 的 RingBuffer 在 cursor 前后各填充 7 个 long(7×8=56 + 8=64 字节) - @Contended 注解(Java 8+):JVM 自动为注解的字段添加填充,需加 JVM 参数 -XX:-RestrictContended 解锁。ConcurrentHashMap 的 CounterCell 和 Thread 的 threadLocalRandomSeed 就用了这个注解

常见追问:伪共享和共享有什么区别?→ 共享是有意访问同一变量,伪共享是无意被缓存行牵连——两个线程改的是不同变量,但因缓存行粒度被迫"共享"。


CAS 的原理和问题

频次 ★★★★ · 难度 🟡

原理:比较并交换(Compare And Swap),三个操作数:内存值 V、预期值 A、新值 B。若 V==A 则将 V 更新为 B,否则不操作。

三大问题: - ABA 问题:值从 A→B→A,CAS 无法感知中间变化。解决:AtomicStampedReference 加入版本号 - 自旋开销大:长时间 CAS 失败会浪费 CPU - 只能保证单个变量的原子操作:多变量需用 AtomicReference 或锁

自旋开销的解决:LongAdder

频次 ★★★ · 难度 🟡

AtomicLong 在高并发下所有线程 CAS 同一个 volatile long,冲突率高、自旋开销大。LongAdder(Java 8+)将单一值拆成多个 Cell(每个 Cell 里一个 long),线程按 Thread.threadLocalRandomProbe 哈希到不同 Cell,CAS 分散到不同内存位置,竞争大幅降低。

原理:空间换时间——和 ConcurrentHashMap 分段锁、ThreadLocal 线程私有副本思路一致。sum() 遍历所有 Cell 求和(非原子快照,并发写时 sum 可能不精确),所以 LongAdder 适合统计计数场景,不适合需要精确瞬时值的场景(如余额)。

常见追问:什么时候用 AtomicLong、什么时候用 LongAdder?→ 低竞争用 AtomicLong(简单、精确),高竞争统计用 LongAdder(吞吐高但 sum 不精确),需要精确瞬时值还是要用 AtomicLong 或锁。


四、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;
}

三个源码结论: 1. 非公平体现在 nonfairTryAcquire 里"锁空闲就直接抢"——不看队列里有没有人排队;公平锁版本会先 hasQueuedPredecessors() 判断队列非空则不抢,这也是公平锁 vs 非公平锁吞吐差异的源码依据 2. acquireQueued 里线程真正阻塞用的是 LockSupport.park()(挂起,不占 CPU),被前驱唤醒后还要重新 tryAcquire 竞争一次,不是无条件拿到锁——所以即使排在队首也可能被后来直接抢锁的线程截胡(非公平锁的"插队") 3. 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。

LockSupport 的 park/unpark 和 wait/notify 有什么区别?

频次 ★★★★ · 难度 🟡 · 高频:阿里/字节

是什么:LockSupport 是 AQS 等同步器的底层阻塞原语。每个线程关联一个最多为 1 的“许可”:unpark(thread) 把许可设为 1,park() 有许可就消费并立即返回,没有才可能阻塞。许可不累加,连续两次 unpark 仍然只有一个许可。

维度 park/unpark wait/notify
是否必须先持锁 不需要 必须在对应对象的 synchronized 中
唤醒目标 unpark 精确指定线程 notify 从等待集合中选择一个
调用顺序 先 unpark 后 park 也不会丢信号 先 notify 后 wait 会丢通知
中断行为 park 返回并保留中断标记,不抛异常 wait 抛 InterruptedException 并清除中断标记
虚假唤醒 可能 可能

为什么必须循环检查条件:park() 可能因许可、中断、超时或虚假唤醒返回;返回只代表“线程可以继续运行”,不代表业务条件已满足。正确模板是条件判断和状态发布在外层,park 只负责避免空转:

while (!canProceed()) {
    LockSupport.park(this); // blocker 便于 jstack 等工具显示阻塞原因
}

常见追问: - unpark 两次能让后续两个 park 都通过吗?→ 不能,许可上限为 1;第一次 park 消费后,第二次仍会阻塞。 - 为什么 AQS 不直接用 wait/notify?→ AQS 要精确唤醒队列中的后继线程,而且不能要求调用方先持有某个监视器;park/unpark 更适合构建同步器。 - 线程被中断后调用 park 会怎样?→ 会立即返回;业务代码应检查并处理中断状态,不能把返回等同于成功获取资源。

ReentrantReadWriteLock 如何用一个 state 表示读写状态?

频次 ★★★★ · 难度 🔴 · 高频:阿里/字节

是什么:读写锁把 AQS 的 32 位 state 一分为二:高 16 位记录所有线程持有读锁的总次数,低 16 位记录写锁重入次数。读锁可被多个线程共享,写锁只能被一个线程独占,因此单个计数器就能同时完成冲突判断。

static final int SHARED_SHIFT = 16;
static final int SHARED_UNIT  = 1 << SHARED_SHIFT;
static final int EXCLUSIVE_MASK = SHARED_UNIT - 1;

static int sharedCount(int c)    { return c >>> SHARED_SHIFT; }
static int exclusiveCount(int c) { return c & EXCLUSIVE_MASK; }

读锁的总重入次数直接存在高 16 位;每个线程自己的重入次数则由 firstReader 和 ThreadLocalHoldCounter 辅助记录,否则释放读锁时无法判断当前线程到底持有几次。读、写计数的理论上限都是 65535,超过会抛 Error。

锁降级可以,锁升级不可以:持有写锁的线程可以先获取读锁,再释放写锁,把独占访问安全地降级为共享读取;但持有读锁时直接等待写锁会造成死锁——自己不释放读锁,写锁永远不可能满足“读计数为 0”。需要升级时,应释放读锁、获取写锁,并在写锁内重新检查条件。

writeLock.lock();
try {
    updateCache();
    readLock.lock();       // 先拿读锁
} finally {
    writeLock.unlock();    // 再放写锁,完成降级
}
try {
    useCache();
} finally {
    readLock.unlock();
}

常见追问: - 读多写少就一定适合读写锁吗?→ 不一定;临界区很短或竞争低时,状态维护和调度成本可能高于普通互斥锁。应以压测结果决定。 - 读锁能创建 Condition 吗?→ 不能,readLock().newCondition() 会抛 UnsupportedOperationException;只有写锁支持 Condition。 - 非公平模式有什么风险?→ 吞吐更高,但持续涌入的读线程可能让写线程等待较久;公平模式缓解饥饿,但增加排队与上下文切换成本。

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 - 与 CountDownLatch 的区别?→ Semaphore 是可复用的计数器(acquire/release 反复使用),CountDownLatch 是一次性的(countDown 到 0 后不可重置)

Phaser 解决什么问题?

频次 ★★★ · 难度 🟡

是什么:Phaser 是支持动态参与者和多个阶段的可复用同步屏障。任务可以在运行期间 register() 加入,也可以用 arriveAndDeregister() 到达后退出;当本阶段所有参与者都到达时,阶段号递增,进入下一轮。

工具 参与者数量 能否复用 适用场景
CountDownLatch 创建时固定 否 主线程等待一批任务完成
CyclicBarrier 创建时固定 是 固定线程反复在同一点会合
Phaser 可动态增减 是,多阶段 分阶段计算、任务运行中加入或退出
Phaser phaser = new Phaser(1); // 主线程先注册,避免任务尚未注册时提前结束

for (Runnable task : tasks) {
    phaser.register();
    executor.execute(() -> {
        try {
            task.run();
            phaser.arriveAndAwaitAdvance(); // 等本阶段其他任务
        } finally {
            phaser.arriveAndDeregister();
        }
    });
}

phaser.arriveAndDeregister(); // 主线程完成注册工作并退出

为什么比 CyclicBarrier 灵活:到达和等待可以拆开。arrive() 只上报到达而不阻塞,awaitAdvance(phase) 可以由任意线程等待阶段变化;重写 onAdvance(phase, parties) 还能在阶段切换时执行动作并决定是否终止。

常见追问: - arriveAndAwaitAdvance 被中断会怎样?→ 普通版本不会因中断抛异常;需要可中断或超时等待时使用 awaitAdvanceInterruptibly。 - 如何防止 Phaser 永远不结束?→ 所有动态注册都必须在 finally 中配对注销;还可用 forceTermination() 释放等待者作为失败兜底。 - 参与者很多时如何降低竞争?→ 可以把多个子 Phaser 挂到父 Phaser,形成分层树,减少所有线程争用同一个状态。


五、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。但它只是上下文传递的第一层——不跨线程(线程池要捕获/回放,见上方 InheritableThreadLocal/TTL)、更不跨进程(要靠协议头捎带,如链路追踪的 traceId)。Spring 事务上下文、MDC 日志上下文都构建在 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
  • newCachedThreadPool:maximumPoolSize = Integer.MAX_VALUE,高并发时创建大量线程会 OOM
  • 正确做法:手动 new ThreadPoolExecutor 配置有界队列和可控线程数

ScheduledThreadPoolExecutor 如何实现定时与周期任务?

频次 ★★★★ · 难度 🟡 · 高频:美团/阿里

是什么:ScheduledThreadPoolExecutor 继承 ThreadPoolExecutor,用按触发时间排序的 DelayedWorkQueue 保存任务。工作线程从队首获取任务;任务延迟尚未到期时等待,到期后才可执行。它使用相对延迟而不是把墙上时钟当作调度依据,降低系统时间回拨的影响。

方法 下一次触发点 任务执行过久时
scheduleAtFixedRate 以上一次计划开始时间 + period 计算 不会并发重叠,但会尽快追赶落后的计划
scheduleWithFixedDelay 以上一次实际结束时间 + delay 计算 始终在完成后再等待固定间隔

三个生产坑:

  1. 周期任务抛出未捕获异常后,后续执行会被抑制;任务体必须捕获异常并记录失败,不能期待下一轮自动恢复。
  2. 已取消但尚未到触发时间的任务默认可能继续留在队列中,长期调度服务应设置 setRemoveOnCancelPolicy(true),避免取消任务堆积。
  3. 它使用无界延迟队列并主要依靠 corePoolSize 执行任务,因此调大 maximumPoolSize 没有实际效果;corePoolSize 设为 0 或允许核心线程超时,可能导致到期任务无人处理。
ScheduledThreadPoolExecutor scheduler = new ScheduledThreadPoolExecutor(2);
scheduler.setRemoveOnCancelPolicy(true);

scheduler.scheduleWithFixedDelay(() -> {
    try {
        refreshCache();
    } catch (Exception e) {
        log.error("refresh cache failed", e);
    }
}, 0, 30, TimeUnit.SECONDS);

常见追问: - 为什么不用 Timer?→ Timer 单线程执行,一个任务阻塞会拖延全部任务;任务抛未捕获异常还可能终止计时线程。定时线程池支持多工作线程及线程池的监控、拒绝和关闭能力。 - 同一个周期任务会不会重叠执行?→ 不会;即使某次由不同线程执行,前一次执行的效果也 happens-before 后一次执行。 - 它适合分布式定时任务吗?→ 不适合。它只解决单进程调度,不提供集群唯一执行、分片、故障转移和持久化;这些需求见分布式任务调度。

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 的分治思想一致。