Skip to content

并发编程是中高级 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 / 中断

关键理解

  1. RUNNABLE 包含 Ready 和 Running:Java 线程层面不区分就绪和运行,都叫 RUNNABLE,具体由 OS 调度器决定。
  2. BLOCKED vs WAITING
    • BLOCKED:等待监视器锁,是"被动"的,锁释放后自动竞争
    • WAITING:调用 wait()/join() 等进入,是"主动"的,需要被 notify() 唤醒
  3. 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() 必须在同步块中?

  1. 语义要求:wait() 的语义是"释放锁并等待",如果没有持有锁,就谈不上释放锁。
  2. 防止竞态:如果不要求在同步块中,可能出现 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)包含两部分:

  1. Mark Word:存储对象自身的运行时数据(哈希码、GC分代年龄、锁标志位、偏向线程ID等)
  2. Klass Pointer:指向方法区中对象类型元数据的指针

Mark Word 在不同锁状态下的结构(32位 JVM):

锁状态25bit4bit1bit2bit
无锁对象 hashCodeGC 分代年龄001
偏向锁线程 ID + EpochGC 分代年龄101
轻量级锁指向栈中锁记录的指针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 都是可重入的独占锁,但在实现层面、功能特性、性能表现等方面有显著区别。

核心区别对比表

对比维度synchronizedReentrantLock
实现层面JVM 关键字,底层是 monitorenter/monitorexitJDK 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 持续优化
需要公平锁ReentrantLocksynchronized 不支持
需要可中断/尝试获取ReentrantLocksynchronized 不支持
需要多条件变量ReentrantLocksynchronized 只有一个等待集
团队新人多、代码规范要求高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() 这行代码可以分解为三步:

  1. 分配内存空间:memory = allocate()
  2. 初始化对象:ctorInstance(memory)
  3. 将 instance 指向分配的内存:instance = memory

如果没有 volatile,步骤 2 和 3 可能发生重排序,变成:

  1. 分配内存空间
  2. 将 instance 指向分配的内存(此时对象还未初始化!)
  3. 初始化对象

这时另一个线程执行第一次检查时,发现 instance != null,直接返回,但拿到的是一个未初始化完全的对象,使用时可能出错。

volatile 通过插入内存屏障禁止了 2 和 3 的重排序,保证对象构造完成后才将引用赋值给 instance。

底层原理

可见性的实现:MESI 缓存一致性协议

volatile 的写操作会触发:

  1. 修改工作内存中的值后,立即刷回主内存
  2. 通过总线嗅探机制,使其他 CPU 中该变量的缓存行失效(MESI 协议的 Invalid 状态)
  3. 其他线程读取时,发现缓存失效,从主内存重新加载最新值

MESI:Modified(修改)、Exclusive(独占)、Shared(共享)、Invalid(无效),是 CPU 缓存一致性协议的一种。

有序性的实现:内存屏障

JMM 为 volatile 插入了四类内存屏障:

屏障类型指令示例说明
LoadLoadLoad1; LoadLoad; Load2保证 Load1 在 Load2 之前完成
StoreStoreStore1; StoreStore; Store2保证 Store1 刷回内存在 Store2 之前
LoadStoreLoad1; LoadStore; Store2保证 Load1 在 Store2 刷回内存之前完成
StoreLoadStore1; StoreLoad; Load2保证 Store1 刷回内存在 Load2 之前完成

volatile 的内存屏障插入策略:

  • 在每个 volatile 操作前插入 StoreStore 屏障,之后插入 StoreLoad 屏障
  • 在每个 volatile 操作后插入 LoadLoad 屏障和 LoadStore 屏障

从汇编层面看,volatile 变量写操作时会多出一个 lock 前缀指令,该指令的作用:

  1. 将当前处理器缓存行的数据写回系统内存
  2. 使其他 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 的 Nodeval 和 next 用 volatile 修饰,保证读时不需要加锁

volatile 和 synchronized 的对比

对比维度volatilesynchronized
作用可见性 + 有序性原子性 + 可见性 + 有序性
使用位置修饰变量修饰方法或代码块
阻塞不会阻塞可能阻塞
性能高(无锁)相对低(有锁开销)
原子性不保证保证
适用场景状态标记、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 缓存等原因,多线程程序中可能出现:

  1. 可见性问题:一个线程的修改对另一个线程不可见
  2. 有序性问题:指令重排导致多线程下逻辑出错
  3. 原子性问题:多个操作中间被其他线程插入

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 节点的核心属性:

属性类型含义
waitStatusint等待状态(CANCELLED=1、SIGNAL=-1、CONDITION=-2、PROPAGATE=-3、0=初始)
prevNode前驱节点
nextNode后继节点
threadThread该节点对应的线程
nextWaiterNode条件队列中的下一个节点(用于 Condition)

入队过程

  1. 使用 CAS 尝试将新节点设置为 tail
  2. 如果 CAS 失败,说明有竞争,自旋重试
  3. 成功后建立前驱节点的 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 流程:

  1. 调用 tryRelease(arg) 释放同步状态(子类实现)
  2. 释放成功后,唤醒后继节点(unparkSuccessor
  3. 后继节点被唤醒后继续尝试获取

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 这么重要

  1. 统一了同步器的实现:将排队、阻塞、唤醒等通用逻辑抽到父类,子类只需关注状态的获取和释放
  2. 性能优异:用 CAS + 自旋 + 队列的方式减少了线程上下文切换
  3. 灵活扩展:支持独占和共享两种模式,支持公平/非公平,支持可中断/超时等

追问延伸

  • 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 应用中保存用户会话信息
SimpleDateFormatSimpleDateFormat 非线程安全,每个线程持有一个
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):

  1. 获取当前线程的 ThreadLocalMap
  2. 以当前 ThreadLocal 对象为 key,存入 value
  3. 如果 map 不存在则创建

get():

  1. 获取当前线程的 ThreadLocalMap
  2. 以当前 ThreadLocal 对象为 key,查找对应的 value
  3. 找到则返回,找不到则调用 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 创建线程池但不懂原理的人。

参考答案

为什么要用线程池

如果不使用线程池,每次执行任务都创建新线程,会有以下问题:

  1. 线程创建销毁开销大:每个线程的创建和销毁都需要时间和资源
  2. 缺乏统一管理:无限制创建线程可能导致 OOM
  3. 缺乏高级功能:定时执行、任务队列等

线程池的好处:

  • 降低资源消耗:复用已创建的线程,减少创建销毁开销
  • 提高响应速度:任务来了直接用空闲线程执行
  • 提高可管理性:统一分配、调优和监控
  • 提供高级功能:定时执行、并发数控制等

七大核心参数

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.SECONDSTimeUnit.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 ? ──是──▶ 创建非核心线程执行任务



 执行拒绝策略

详细说明:

  1. 阶段一:核心线程

    • 当提交任务时,如果当前线程数 < corePoolSize,创建新的核心线程执行任务
    • 即使有空闲核心线程,也会创建新的,直到达到 corePoolSize
    • 可以通过 prestartCoreThread() 预启动核心线程
  2. 阶段二:任务队列

    • 当线程数 >= corePoolSize 时,新任务加入 workQueue 等待
    • 空闲的核心线程从队列中取任务执行
  3. 阶段三:最大线程

    • 当队列满了,且线程数 < maximumPoolSize 时,创建非核心线程执行任务
    • 这些线程执行完任务后,空闲超过 keepAliveTime 会被回收
  4. 阶段四:拒绝策略

    • 当队列满了且线程数达到 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 * NcpuNcpu / (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 ──▶ TERMINATED

submit() 和 execute() 的区别

对比维度execute()submit()
方法来源Executor 接口ExecutorService 接口
参数只能传 Runnable可以传 Runnable 或 Callable
返回值voidFuture<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。

常见面试陷阱

  1. submit() 的异常陷阱:用 submit 提交任务,如果不调用 get(),异常会被悄悄吞掉,调用方完全不知道任务失败了。

  2. shutdown() 后还能提交任务吗?:不能,会触发拒绝策略,默认抛 RejectedExecutionException。

  3. shutdownNow() 一定能停止线程吗?:不一定,它只是调用 interrupt(),如果任务不响应中断(如没有阻塞方法、或捕获了 InterruptedException 但没处理),线程会继续运行。

  4. 线程池中的线程死亡了会怎样?:如果是核心线程,会重新创建一个补充;如果是非核心线程,不会补充。但异常导致的线程死亡会影响线程池的稳定性。

追问延伸

  • 为什么 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=12

4. 代码层面检测

  • 使用 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

对比项CountDownLatchCyclicBarrier
等待关系一个线程等多个线程多个线程互相等
重用性一次性可循环使用
计数方向减到 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

LongAdderCell 数组也是类似的思路,虽然没有用 @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():设置线程的中断标志位为 true
  • isInterrupted():检查中断标志(不清除)
  • 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),线程状态变为 WAITING
  • notify():从等待队列中随机唤醒一个线程,移入 EntryList 竞争锁
  • notifyAll():唤醒等待队列中所有线程,全部移入 EntryList 竞争锁
java
synchronized (lock) {
    while (!condition) {  // 用 while 不用 if(防止虚假唤醒)
        lock.wait();     // 释放锁,等待
    }
    // 条件满足,执行操作
}

// 另一个线程
synchronized (lock) {
    condition = true;
    lock.notify();  // 或 notifyAll()
}

notify vs notifyAll:

维度notifynotifyAll
唤醒数量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互斥锁临界区保护
ConditionLock 的 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 的三个缺点:

  1. 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);
  1. 自旋开销

    • CAS 失败后循环重试(自旋),高竞争下 CPU 浪费
    • 解决:限制自旋次数、使用 LongAdder 替代 AtomicLong(分段 CAS)
  2. 只能保证一个变量的原子性

    • 多个变量同时更新不能用单个 CAS 保证
    • 解决:把多个变量封装成对象,用 AtomicReference 整体 CAS

ABA 问题的实际影响:

  • 栈操作(pop 后 push 再 pop):可能释放已回收的内存
  • 链表操作:节点被删除后重新插入,CAS 仍可能成功
  • 转账场景:余额从 100 → 90 → 100,另一线程 CAS(100→80) 成功,但中间的钱已经转走

追问延伸

  • LongAdder 是怎么解决 CAS 自旋开销的?(分段:base + Cell 数组,不同线程 CAS 不同 Cell)
  • ABA 问题在什么场景下有实际危害?(栈/链表的并发操作,内存回收场景)

Q22: 指令重排序是什么?volatile 如何防止重排序? 「🔴 高级」

考察点:内存屏障和重排序的底层原理。

参考答案

指令重排序:编译器和 CPU 为了提高性能,在不影响单线程语义的前提下重新排列指令顺序。

重排序类型:

  1. 编译器重排序:编译器优化(循环展开、表达式简化)
  2. 指令级并行重排序:CPU 流水线、乱序执行
  3. 内存系统重排序: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() 的三步:

  1. 分配内存空间
  2. 初始化对象
  3. 将引用指向内存地址

重排序可能把 2 和 3 交换 → 其他线程看到非 null 引用但对象未初始化。

volatile 防止重排序:

  • 在 volatile 写操作前插入 StoreStore 屏障(禁止前面的写与 volatile 写重排序)
  • 在 volatile 写操作后插入 StoreLoad 屏障(禁止 volatile 写与后面的读重排序)
  • 在 volatile 读操作后插入 LoadLoadLoadStore 屏障(禁止后面的读写与 volatile 读重排序)
StoreStore 屏障
volatile 写
StoreLoad 屏障

volatile 读
LoadLoad 屏障
LoadStore 屏障

happens-before 原则保证:

  • volatile 写 happens-before 后续的 volatile 读
  • 所以 volatile 变量的写对其他线程可见

追问延伸

  • final 字段能防止重排序吗?(能,JMM 保证构造函数内的 final 写不会重排序到构造函数外)
  • 为什么 synchronized 不需要考虑重排序?(synchronized 的内存语义相当于 volatile 的全屏障)

Q23: 线程池的拒绝策略有哪些?如何选择? 「🟡 中级」

考察点:线程池的溢出处理。

参考答案

当线程池的工作队列满且线程数达到 maximumPoolSize 时,触发拒绝策略。

JDK 内置 4 种拒绝策略:

策略说明适用场景
AbortPolicyRejectedExecutionException默认,重要任务不能丢失
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;
// 或根据压测结果调整

线程池工作流程:

  1. 任务来了 → 核心线程未满 → 创建核心线程执行
  2. 核心线程满 → 进入工作队列
  3. 队列满 → 创建非核心线程(直到 maximumPoolSize)
  4. 都满了 → 执行拒绝策略

核心线程数设为 0 可以吗:

  • 可以,但任务先排队,队列满才创建线程
  • 好处:内存节省;坏处:任务延迟增大
  • 不推荐核心线程数设为 0(除非任务是低优先级可延迟的)

追问延伸

  • 怎么动态调整线程池参数?(setCorePoolSize / setMaximumPoolSize 运行时调整)
  • 为什么核心线程可以设置为 0?(JDK 默认核心线程不会超时,但可以通过 allowCoreThreadTimeOut(true) 让核心线程超时回收)

Q25: 线程池有哪些种类?为什么不推荐 Executors? 「🟡 中级」

考察点:线程池的创建方式和陷阱。

参考答案

Executors 提供的 4 种线程池:

线程池核心/最大线程队列问题
newFixedThreadPooln / n无界 LinkedBlockingQueue队列可能 OOM
newSingleThreadExecutor1 / 1无界 LinkedBlockingQueue队列可能 OOM
newCachedThreadPool0 / Integer.MAX_VALUESynchronousQueue线程数可能 OOM
newScheduledThreadPooln / Integer.MAX_VALUEDelayedWorkQueue线程数可能 OOM

为什么阿里规范禁止用 Executors:

  1. FixedThreadPoolSingleThreadExecutor:用无界队列 LinkedBlockingQueue,任务堆积导致 OOM
  2. CachedThreadPool:最大线程数 Integer.MAX_VALUE,创建大量线程导致 OOM
  3. ScheduledThreadPool:同样最大线程数无上限

推荐做法:手动创建 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() 不是原子操作,分三步:
    1. 分配内存
    2. 初始化对象
    3. 引用指向内存
  • 步骤 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:

维度CountDownLatchCyclicBarrier
计数减到 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();  // 阻塞等待,无法回调
// 无法在完成后自动执行下一步
// 无法组合多个 Future

CompletableFuture 优势:

  • 支持回调(完成后自动执行下一步)
  • 支持组合(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处理结果和异常

追问延伸

  • thenApplythenCompose 的区别?(前者同步转换,后者返回另一个 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+):

  1. 偏向锁(Biased Locking):
    • 第一个线程访问锁 → 在对象头记录线程 ID
    • 同一线程再次进入 → 只比对 ID,不加锁(几乎零开销)
    • 适合:同一线程反复进入同步块(常见场景)
    • JDK 15 废弃偏向锁(收益不再明显)
  2. 轻量级锁(Thin Lock):
    • 多个线程交替进入(无竞争)→ 用 CAS 操作代替互斥量
    • 在用户态自旋,不进内核态
  3. 重量级锁(Fat Lock):
    • 竞争激烈 → 自旋失败 → 升级为互斥量 → 进内核态
  4. 锁消除(Lock Elision):
    • JIT 逃逸分析 → 如果对象不可能被其他线程访问 → 删除同步
    • StringBuffer 的同步在单线程中被消除
  5. 锁粗化(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); }
}

关键点:

  1. tryAcquire:先 CAS 获取,获取不到判断重入,都不行返回 false(AQS 会入队阻塞)
  2. 公平性:hasQueuedPredecessors() 检查队列中是否有排在前面的线程
  3. 可重入:通过判断当前线程是否是锁持有者来实现

追问延伸

  • AQS 的 CLH 队列是什么?(自旋锁队列变种,AQS 中改为阻塞等待)
  • ReentrantLock 和我们自定义的锁有什么区别?(ReentrantLock 更完善:Condition、tryLock 超时、可中断)

Q33: 为什么不能所有的锁都用 CAS? 「🟡 中级」

考察点:CAS 的适用场景和局限。

参考答案

CAS 不是万能的,以下场景不适合:

  1. 高竞争场景
    • CAS 失败后自旋重试 → 大量线程空转 → CPU 浪费严重
    • 100 个线程同时 CAS → 99 个失败自旋 → 吞吐量不如互斥锁
    • 互斥锁:线程睡眠让出 CPU → 不空转
  2. 无法表达复杂状态
    • CAS 只能做"比较并设置" → 无法表达"等待条件满足"
    • 如:生产者-消费者需要"队列不满才生产" → CAS 无法直接表达等待
    • 需要 Condition / wait-notify 机制
  3. 多变量原子性
    • CAS 一次只能操作一个变量
    • 多变量原子操作需要 AtomicReference 包装或加锁
    • 或用 VarHandle(Java 9+)
  4. ABA 问题
    • CAS 检测不到"A→B→A"的变化
    • 需要版本号(AtomicStampedReference
  5. 长时间持有"锁"
    • CAS 自旋期间无法被中断
    • 互斥锁可以被 interrupt() 中断

CAS vs 互斥锁选择:

场景推荐原因
低竞争(如计数器)CAS无上下文切换开销
高竞争(如热点数据)互斥锁避免自旋浪费
短临界区CAS自旋比切换快
长临界区互斥锁自旋太久浪费 CPU
复杂条件等待互斥锁 + ConditionCAS 无法表达
多变量原子加锁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)、生产者-消费者

线程池用了哪些设计模式:

  1. 工厂模式ThreadFactory 创建线程
  2. 享元模式:线程复用(核心线程不销毁)
  3. 生产者-消费者:任务队列 + Worker 线程消费
  4. 模板方法execute() 流程固定,子类可扩展

追问延伸

  • new Thread().start() 有什么问题?(不可控、无上限、创建开销大、无管理)
  • corePoolSize=0corePoolSize=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) 的原理:

  1. 修改 Future 状态为 CANCELLED
  2. 如果任务正在执行 → 调用 Thread.interrupt() 设置中断标志
  3. 如果任务在队列中 → 从队列移除
  4. 任务代码需要响应中断(检查 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 操作和原生方法可能不响应中断

追问延伸

  • shutdownshutdownNow 的区别?(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 → 失败重试

乐观锁实现要点:

  1. 版本标记:用版本号、时间戳或数据本身作为"是否被修改"的标记
  2. 检查-修改-提交:先读 → 在本地修改 → 提交时检查版本 → 成功则提交,失败则重试
  3. 重试机制:失败后决定是否重试(有重试次数限制防止活锁)

适用场景:读多写少、冲突少、对性能敏感。 不适用:写竞争激烈(重试率高,不如悲观锁)。

追问延伸

  • 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 的区别

维度BLOCKEDWAITING
触发原因等待获取 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() 执行流程:

  1. 释放 Monitor 锁(_owner = null_count = 0
  2. 当前线程加入 _WaitSet,状态变为 WAITING
  3. 线程被挂起,等待 notify

notify() 执行流程:

  1. _WaitSet 中取出一个线程
  2. 将该线程移到 _EntryList
  3. 线程状态变为 BLOCKED,需要重新竞争锁

notify 后线程不能立即执行,必须等持有锁的线程退出同步块后,EntryList 中的线程才有机会获取锁。

追问延伸

  • 为什么 notify() 不释放锁?(notify 只是唤醒,锁在同步块结束时才释放)
  • Conditionsignal()Object.notify() 有什么区别?(Condition 可以精确唤醒某个条件队列的线程)

Q41: JUC 包下常用的类有哪些? 「🟡 中级」

考察点:Java 并发工具包的整体认知。

参考答案

JUC(java.util.concurrent)核心组件分类:

1. 锁

  • ReentrantLock:可重入排他锁
  • ReentrantReadWriteLock:读写锁(读共享、写排他)
  • StampedLock:乐观读锁(JDK 8+,性能更好)

2. 原子类java.util.concurrent.atomic):

  • 基本类型:AtomicIntegerAtomicLongAtomicBoolean
  • 引用类型:AtomicReferenceAtomicStampedReference(解决 ABA)
  • 累加器:LongAdderLongAccumulator(高并发下比 AtomicLong 更高效)
  • 数组:AtomicIntegerArrayAtomicReferenceArray

3. 并发集合

  • ConcurrentHashMap:线程安全 HashMap
  • CopyOnWriteArrayList:写时复制,读多写少
  • ConcurrentLinkedQueue:无锁并发队列
  • BlockingQueue 体系:ArrayBlockingQueueLinkedBlockingQueueSynchronousQueuePriorityBlockingQueue

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 原子类:AtomicIntegerAtomicReference
  • LongAdder:高并发计数
  • 并发集合:ConcurrentHashMapCopyOnWriteArrayList

方案3:不可变对象

  • final 修饰 + 无 setter → 天然线程安全
  • StringInteger 等不可变类
  • 函数式编程:纯函数无副作用

方案4:线程封闭

  • ThreadLocal:每个线程独立副本
  • 栈封闭:局部变量天然线程安全

方案5:消息传递

  • 不共享内存,通过消息队列通信(Actor 模型、Go CSP 模型)

选型建议:

场景推荐方案
简单同步synchronized
需要超时/中断ReentrantLock
读多写少ReadWriteLock / StampedLock
计数器LongAdder
高并发 MapConcurrentHashMap
线程独立数据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?(不可重入,且容易死锁)
  • 分段锁怎么实现的?(ConcurrentHashMap JDK 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 都是可重入的

维度synchronizedReentrantLock
可重入
实现方式Monitor 的 _count 计数AQS 的 state 计数
重入计数ObjectMonitor._countstate 变量
释放条件退出同步块时 count 减到 0unlock() 调用次数 == lock() 次数

可重入的实现原理

synchronized:

  • Monitor 内部维护 _count_owner
  • 同一线程再次进入 → _count++
  • 退出同步块 → _count--,减到 0 时释放锁

ReentrantLock(基于 AQS):

  • AQS 的 state 变量记录重入次数
  • tryAcquire 判断当前线程 == 持有线程 → state++
  • tryReleasestate--,减到 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 线程安全的场景(满足以下条件之一):

  1. 写入不依赖当前值(如 boolean flag = true
  2. 单线程写、多线程读(如配置变量)
  3. 使用 AtomicReference 包装
java
// 安全:状态标志位
private volatile boolean running = true;
// 线程 A 读取 running
// 线程 B 设置 running = false → A 立即感知

// 安全:单写多读
private volatile Config config;
// 一个线程更新 config = newConfig,其他线程读取

volatile 和 synchronized 全面对比

维度volatilesynchronized
原子性❌ 不保证✅ 保证
可见性✅ 保证✅ 保证
有序性✅ 保证(禁止重排序)✅ 保证
阻塞不阻塞可能阻塞
粒度变量级对象/方法级
性能轻量较重(锁升级后更重)
编译优化禁止 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 是工厂类,提供各种线程池的静态工厂方法
  • newFixedThreadPoolnewCachedThreadPoolnewSingleThreadExecutor

4. 策略模式

  • RejectedExecutionHandler 是拒绝策略接口
  • 不同实现代表不同策略:AbortPolicyCallerRunsPolicyDiscardPolicyDiscardOldestPolicy
  • 用户也可以自定义策略

5. 享元模式

  • 线程被复用(Worker 对象不销毁),执行完一个任务后继续从队列取下一个
  • 避免频繁创建/销毁线程的开销

6. 适配器模式

  • RunnableAdapterCallable 适配为 Runnable
  • FutureTask 同时实现了 RunnableFuture

追问延伸

  • 为什么 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++ 不是原子操作,分三步:

  1. 读取 count 的值
  2. 加 1
  3. 写回 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 goroutineJava 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.synchronizedMap
  • CopyOnWriteArrayList 替代 Collections.synchronizedList(读多写少)

7. 锁释放放在 finally

java
lock.lock();
try {
    // 临界区
} finally {
    lock.unlock();  // 确保异常时也释放
}

8. 不可变优先

  • 能用 final 就不用锁
  • 能用不可变对象就不共享可变状态

追问延伸

  • 怎么排查生产环境的死锁?(jstack 分析线程 dump,找锁依赖环)
  • ConcurrentHashMap 的 size 为什么不精确?(分段统计,允许并发修改)