JUC 高频面试题
一、JUC 核心基础与并发基础概念
1. 什么是JUC?它包含哪些核心模块?
答案:
JUC 是 java.util.concurrent 包的简称,是 JDK 提供的 Java 并发编程核心工具包,解决了传统 synchronized + wait/notify 在复杂并发场景下功能不足、性能差、易用性低的问题。
核心模块分为 8 大类:
- 锁体系:
Lock接口、ReentrantLock、ReentrantReadWriteLock、StampedLock等 - 同步器基石:AQS(
AbstractQueuedSynchronizer),几乎所有锁和同步工具的底层实现 - 原子类:
java.util.concurrent.atomic包下的原子操作类 - 线程池:
Executor、ExecutorService、ThreadPoolExecutor、ScheduledExecutorService等 - 并发集合:
ConcurrentHashMap、CopyOnWriteArrayList、BlockingQueue阻塞队列家族等 - 同步工具类:
CountDownLatch、CyclicBarrier、Semaphore、Phaser等 - 并发基础工具:
LockSupport、ThreadLocal(并发高频考点,虽不在 JUC 包但必问) - 并行框架:Fork/Join 框架
Java 并发编程是 Java 语言的核心优势之一,其提供的并发工具类体系丰富且强大。
2. 并发与并行的核心区别?
答案:
- 并发(Concurrent):同一时间段内,多个任务交替执行,宏观上看是“同时运行”,微观上是 CPU 时间片切换的串行执行。单核 CPU 只能实现并发。
- 并行(Parallel):同一时刻,多个任务在多个 CPU 核心上真正同时执行。多核 CPU 才能实现并行。
面试加分点:并发的核心是解决多线程对共享资源的争抢问题,并行的核心是最大化利用多核 CPU 提升任务执行效率。
3. 进程与线程的核心区别?Java线程的生命周期有哪些?
答案:
进程与线程的核心区别
| 维度 | 进程 | 线程 |
|---|---|---|
| 资源归属 | 操作系统资源分配的最小单位,有独立的内存地址空间 | CPU 调度的最小单位,共享所属进程的内存资源,仅持有栈、程序计数器等极小私有数据 |
| 开销 | 进程创建、切换、销毁开销极大,涉及内核态上下文切换 | 线程开销极小,用户态即可完成大部分操作,共享进程资源 |
| 稳定性 | 进程之间相互隔离,一个进程崩溃不影响其他进程 | 线程共享进程资源,一个线程崩溃会导致整个进程崩溃 |
| 通信复杂度 | 进程间通信(IPC)复杂,如管道、socket、共享内存等 | 线程间通信简单,直接通过进程内共享变量通信,需解决线程安全问题 |
Java线程的6种生命周期(JDK定义)
- NEW(新建):线程对象被创建,还未调用
start()方法 - RUNNABLE(可运行):调用
start()后,处于 Java 虚拟机的可执行状态,包含操作系统层面的就绪态和运行态(等待 CPU 时间片或正在执行) - BLOCKED(阻塞):线程等待 synchronized 锁释放,进入锁阻塞队列,未获取到监视器锁
- WAITING(无限等待):调用
wait()、join()、LockSupport.park()等无超时方法,需被其他线程主动唤醒 - TIMED_WAITING(计时等待):调用
Thread.sleep(long)、wait(long)、join(long)、LockSupport.parkNanos()等带超时的方法,超时后自动唤醒 - TERMINATED(终止):线程
run()方法执行完毕,或异常退出,生命周期结束
面试避坑点:Java 没有 RUNNING 状态,BLOCKED 仅针对 synchronized 锁的阻塞,Lock 的锁等待是 WAITING 状态,而非 BLOCKED。
4. 线程的创建方式
在 Java 中,创建线程的核心是创建 Thread 对象,但任务的提供方式有多种。以下是常见的几种方法:
| 方法 | 有无返回值 | 异常处理 | 解耦性 | 适用场景 |
|---|---|---|---|---|
| 继承 Thread | 无 | 只能在 run 内捕获 | 差 | 简单的独立线程 |
| 实现 Runnable | 无 | 只能在 run 内捕获 | 好 | 任务与线程分离 |
| Callable + Future | 有 | 可抛出受检异常 | 好 | 需要返回结果或抛出异常 |
| 线程池 | 有/无 | 通过 Future 获取 | 最好 | 高并发、任务频繁、资源受限 |
| CompletableFuture | 有 | 链式处理 | 最好 | 复杂异步流、函数式编程 |
最推荐:在一般应用中使用线程池 + Runnable/Callable;在需要编排多个异步任务时使用 CompletableFuture。
5. 什么是线程安全?引发线程安全问题的根本原因是什么?
答案:
线程安全:多线程环境下,无论线程如何交替执行,程序都能表现出正确的行为,不会出现数据异常、结果不符合预期的问题,无需调用方做额外的同步处理。
引发线程安全问题的三大根本原因:
- 共享可变数据:多个线程同时读写同一个共享的可变变量,这是线程安全问题的前提
- CPU 时间片切换(线程抢占式执行):线程的执行是抢占式的,操作系统会随时切换线程,导致操作执行一半被中断
- 指令重排序与内存可见性问题:编译器、CPU、JVM 会对指令进行重排序优化,且多核 CPU 的缓存与主内存存在数据同步延迟,导致一个线程的修改,其他线程无法立刻看到。
面试加分点:解决线程安全问题的核心思路,就是从这三个根源入手:比如用不可变对象消除可变数据、用原子类保证操作不可分割、用锁保证同一时刻只有一个线程执行、用 volatile 保证内存可见性和禁止指令重排。
二、volatile 与 Java 内存模型(JMM)
1. 什么是JMM?它的核心作用是什么?
答案:
JMM(Java Memory Model,Java 内存模型),是 JVM 规范定义的一套线程内存访问规则,解决了不同 CPU 架构、操作系统下,多线程内存访问的一致性问题,屏蔽了底层硬件和操作系统的内存访问差异,保证 Java 程序在不同平台下的并发行为是一致的。
JMM 的核心三大目标:
- 保证原子性:保证操作不可分割,要么全部执行成功,要么不执行
- 保证可见性:一个线程对共享变量的修改,能立刻被其他线程感知到
- 保证有序性:禁止编译器和 CPU 对指令进行重排序,保证执行顺序和代码逻辑一致
JMM 的核心实现:围绕 happens-before 先行发生规则、内存屏障、主内存与工作内存的交互协议实现。
2. volatile 关键字的核心作用是什么?底层实现原理是什么?
答案:
volatile 是 JVM 提供的轻量级同步机制,核心作用有两点:
- 保证共享变量的内存可见性:对 volatile 变量的写操作,会立刻刷新到主内存;对 volatile 变量的读操作,会直接从主内存读取,而不是使用线程工作内存中的缓存副本。
- 禁止指令重排序:通过内存屏障,禁止编译器和 CPU 对 volatile 变量前后的指令进行重排序,保证执行的有序性。
重要补充:volatile 不能保证原子性,比如 count++ 这种复合操作,即使 count 是 volatile 修饰的,多线程下依然会有线程安全问题。
底层实现原理
- 可见性实现:对 volatile 变量的写操作后,JVM 会插入一条 Store 屏障,强制将当前 CPU 缓存中的数据刷新到主内存;对 volatile 变量的读操作前,JVM 会插入一条 Load 屏障,强制让当前 CPU 缓存失效,从主内存重新加载最新数据。
- 禁止重排序实现:JVM 基于内存屏障(Memory Barrier) 实现了 4 种重排序规则,核心是:
- volatile 写之前的操作,不能被重排序到 volatile 写之后
- volatile 读之后的操作,不能被重排序到 volatile 读之前
- 前一个是 volatile 写,后一个是 volatile 读,不能重排序
- 硬件层面:JVM 的内存屏障最终会映射为 CPU 的内存屏障指令(如 x86 架构的
lock前缀指令),lock指令会锁定总线/缓存行,保证缓存一致性,同时禁止指令重排。
3. 什么是 happens-before 规则?核心规则有哪些?
答案:
happens-before 是 JMM 定义的一套先行发生规则,是判断数据是否存在竞争、线程是否安全的核心依据。它的核心含义是:如果操作 A happens-before 操作 B,那么 A 的执行结果对 B 完全可见,且 A 的执行顺序一定在 B 之前,即使发生指令重排,也不能突破这个规则。
JMM 定义了 8 条核心的 happens-before 规则,无需手动加锁/volatile 即可保证:
- 程序次序规则:一个线程内,按照代码执行顺序,前面的操作 happens-before 后面的操作(单线程内逻辑一致性)
- volatile 变量规则:对一个 volatile 变量的写操作,happens-before 后续对这个变量的读操作
- 锁规则:对一个锁的解锁操作,happens-before 后续对这个锁的加锁操作(无论是 synchronized 还是 Lock)
- 线程启动规则:主线程 A 启动子线程 B,线程 A 中调用
B.start()之前的操作,happens-before 线程 B 中的所有操作 - 线程终止规则:线程 A 等待线程 B 终止,线程 B 中的所有操作,happens-before 线程 A 从
B.join()方法成功返回后的操作 - 线程中断规则:对线程
interrupt()方法的调用,happens-before 被中断线程检测到中断事件的发生 - 对象终结规则:一个对象的初始化完成(构造方法执行结束),happens-before 它的
finalize()方法的开始执行 - 传递性规则:如果 A happens-before B,B happens-before C,那么 A happens-before C
面试加分点:DCL 单例模式中,volatile 解决指令重排问题,核心就是依赖 volatile 变量规则+传递性规则,保证对象初始化完成后,才会赋值给 instance 引用。
4. volatile 和 synchronized 的核心区别?
答案:
| 维度 | volatile | synchronized |
|---|---|---|
| 核心定位 | 轻量级同步机制,仅保证可见性和有序性 | 重量级同步机制,保证原子性、可见性、有序性 |
| 原子性保证 | 不保证原子性 | 完全保证原子性,同步块内的操作具有排他性 |
| 使用场景 | 单一变量的状态标记、DCL 单例、禁止指令重排 | 复合操作的原子性、临界区代码的并发安全 |
| 线程阻塞 | 不会造成线程阻塞,无锁竞争 | 会造成线程阻塞,未获取到锁的线程会进入阻塞状态 |
| 作用范围 | 只能修饰变量 | 可以修饰方法、代码块 |
| 优化机制 | 无锁优化,基于内存屏障实现 | 有锁升级机制(偏向锁→轻量级锁→重量级锁),锁粗化、锁消除等优化 |
5. 为什么 DCL(双重检查锁)单例必须加 volatile?
答案:
DCL 单例的经典写法如下:
public class Singleton {
// 必须加 volatile
private static volatile Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
// 第一次检查,避免不必要的加锁
if (instance == null) {
synchronized (Singleton.class) {
// 第二次检查,避免多线程同时进入第一个 if 后重复创建对象
if (instance == null) {
instance = new Singleton(); // 问题核心行
}
}
}
return instance;
}
}
不加 volatile 会出现的核心问题:指令重排序导致的半初始化对象逸出。
instance = new Singleton() 这行代码,在 JVM 中会被拆解为 3 步指令:
- 分配对象的内存空间
- 初始化对象(执行构造方法,初始化成员变量)
- 将 instance 引用指向分配的内存地址(此时 instance != null)
在没有 volatile 修饰时,编译器和 CPU 可能会对这 3 步进行重排序,执行顺序变成 1→3→2。
- 当线程 A 执行完 1 和 3,instance 已经不为 null,但对象还没完成初始化
- 此时线程 B 进入第一个 if 判断,发现 instance != null,直接返回这个半初始化的对象
- 线程 B 使用这个未初始化完成的对象时,会出现空指针、成员变量值异常等严重问题。
而 volatile 的禁止指令重排序特性,会保证 1→2→3 的执行顺序,不会出现重排,同时保证对象初始化完成后,才会对其他线程可见,彻底解决半初始化对象的问题。
三、CAS 与原子类体系
1. 什么是 CAS?它的核心原理是什么?优缺点有哪些?
答案:
CAS(Compare And Swap,比较并交换),是 JUC 提供的一种无锁原子操作,底层依赖 CPU 提供的硬件级原子指令实现,在不使用锁的情况下,保证多线程环境下变量更新的原子性,是整个原子类体系的核心,也是 AQS、并发容器的底层基础。
核心原理
CAS 包含三个核心操作数:
- 内存地址 V(要更新的变量的内存地址)
- 预期值 A(上一次读取到的变量的值)
- 新值 B(要更新的目标值)
CAS 的执行逻辑:当且仅当内存地址 V 中的实际值,等于预期值 A 时,才会将 V 中的值更新为新值 B;否则更新失败,返回当前的实际值,线程可以选择重试(自旋)。整个操作是 CPU 硬件级别的原子指令,执行过程中不会被线程中断,保证了操作的原子性。
Java 中,CAS 操作是通过 Unsafe 类提供的 compareAndSwapInt、compareAndSwapLong、compareAndSwapObject 等 native 方法实现的,最终映射为 CPU 的 lock cmpxchg 等原子指令。
核心优点
- 无锁并发,避免线程阻塞:全程无锁,线程不会因为竞争锁而进入阻塞状态,减少了线程上下文切换的开销,在低并发场景下性能远高于锁
- 细粒度的原子控制:可以针对单个变量做原子操作,锁的粒度远小于 synchronized 同步块
- 非阻塞算法:一个线程的失败不会影响其他线程的执行,适合实现高可用的非阻塞数据结构。
核心缺点
- ABA 问题:变量的值从 A 变成 B,又变回 A,CAS 会误以为值没有变化,更新成功,但实际上中间已经发生过变更,在一些场景下会导致逻辑问题
- 高并发下自旋 CPU 开销大:CAS 更新失败时,通常会采用自旋重试的方式,高并发下大量线程同时竞争同一个变量,会导致大量的自旋重试,占用大量 CPU 资源,甚至超过锁的开销
- 只能保证单个变量的原子性:CAS 只能对单个共享变量做原子操作,无法保证多个变量复合操作的原子性,这种场景还是需要锁
- 只能保证操作的原子性,无法保证有序性和可见性:原子类依赖 volatile 修饰的 value 保证可见性和有序性,CAS 本身不提供这两个特性。
2. 什么是 ABA 问题?如何解决?
答案:
ABA 问题描述
CAS 操作中,线程 1 读取到变量的值为 A,此时线程 2 将变量的值从 A 改成 B,又改回 A;线程 1 执行 CAS 时,发现变量的值还是 A,就会认为值没有发生过变化,执行更新成功,但实际上这个变量已经被修改过,中间的变更过程被忽略了,在一些业务场景下会导致严重的逻辑问题。
典型场景:比如基于链表的无锁栈,栈顶元素是 A,线程 1 准备将栈顶换成 B,此时线程 2 执行了出栈 A、入栈 C、入栈 A,栈顶又变成了 A;线程 1 执行 CAS 成功,会导致栈中的 C 元素丢失。
解决方案
核心思路:给变量增加版本号/时间戳,每次变量更新时,版本号递增,CAS 不仅比较变量的值,还要比较版本号,只有值和版本号都和预期一致,才执行更新。
JUC 中提供了两个现成的类解决 ABA 问题:
- AtomicStampedReference:给变量绑定一个 int 类型的版本号(stamp),每次更新都会修改版本号,解决 ABA 问题
- AtomicMarkableReference:给变量绑定一个 boolean 类型的标记,记录变量是否被修改过,只能记录是否变更,无法记录变更次数,适合只需要知道是否被修改过的场景。
3. 原子类的底层实现原理是什么?
核心答案:
原子类基于 CAS(Compare-And-Swap)+ volatile + Unsafe 类 实现:
- volatile 变量:内部维护一个
volatile修饰的核心变量,保证多线程间的可见性和有序性。 - Unsafe 类:通过
sun.misc.Unsafe的 native 方法(如compareAndSwapInt()、compareAndSwapObject())调用 CPU 的原子 CAS 指令,保证操作的原子性。 - 自旋重试:当 CAS 操作失败时,通过循环自旋重试,直到更新成功,实现无锁的原子操作。
4. JUC 原子类分为哪几类?常用的有哪些?
答案:
JUC 的原子类都在 java.util.concurrent.atomic 包下,基于 CAS+volatile 实现,分为以下几大类:
1. 基本类型原子类
针对 Java 基本类型的原子操作,核心类:
AtomicInteger:int 类型原子类AtomicLong:long 类型原子类AtomicBoolean:boolean 类型原子类
核心常用 API:get()、set()、getAndSet()、compareAndSet()、getAndIncrement()、incrementAndGet()、getAndAdd()、addAndGet() 等。
2. 数组类型原子类
针对数组中的元素,提供原子更新能力,核心类:
AtomicIntegerArray:int 数组原子类AtomicLongArray:long 数组原子类AtomicReferenceArray:引用类型数组原子类
核心特点:可以原子更新数组中指定索引位置的元素,保证数组元素的线程安全。
3. 引用类型原子类
针对对象引用的原子操作,解决单个 CAS 无法保证多个变量原子性的问题,核心类:
AtomicReference:对象引用的原子类AtomicStampedReference:带版本号的对象引用原子类,解决 ABA 问题AtomicMarkableReference:带标记位的对象引用原子类
示例:原子更新用户对象
AtomicReference<User> userRef = new AtomicReference<>(new User("张三", 20));
userRef.compareAndSet(userRef.get(), new User("李四", 25));
4. 字段更新器原子类
针对对象的某个成员字段,提供原子更新能力,无需修改原有类的代码,核心类:
AtomicIntegerFieldUpdater:原子更新对象的 int 类型字段AtomicLongFieldUpdater:原子更新对象的 long 类型字段AtomicReferenceFieldUpdater:原子更新对象的引用类型字段
使用条件:
- 字段必须是
volatile修饰的,保证可见性。 - 字段必须是可见的(非 private,或通过反射可访问)。
- 不能是
static字段,只能是实例字段。 - 对于基本类型更新器,字段类型必须与更新器类型完全一致(如
AtomicIntegerFieldUpdater只能更新int字段,不能是Integer)。
5. 高并发累加器(JDK 1.8+)
针对高并发统计场景优化的原子累加器,核心类:
LongAdder:long 类型的高并发累加器LongAccumulator:long 类型的自定义累加器,支持自定义运算规则DoubleAdder:double 类型的高并发累加器DoubleAccumulator:double 类型的自定义累加器
5. LongAdder 的实现原理是什么?和 AtomicLong 相比有什么优势?
答案:
核心背景
AtomicLong 的核心问题:高并发场景下,大量线程同时竞争同一个 volatile 变量的 CAS 更新,会导致大量的自旋重试,CPU 开销急剧上升,性能大幅下降。
LongAdder 是 JDK 1.8 新增的类,专门针对高并发累加场景做了优化,核心设计思想是热点数据分散,将单一的 value 值,拆分为一个 base 值+一个 Cell 数组,多个线程分散竞争不同的 Cell 元素,最后汇总结果,极大降低了 CAS 的竞争粒度,提升了高并发下的性能。
LongAdder 核心实现原理
LongAdder 继承自 Striped64 类,核心数据结构:
- base 变量:低并发时,直接 CAS 更新 base 值,和 AtomicLong 逻辑一致
- Cell 数组:高并发时,数组会被初始化,每个 Cell 元素是一个封装了 volatile long value 的对象,线程通过哈希值映射到数组中的某个 Cell 元素,只对这个 Cell 做 CAS 更新,竞争粒度从全局一个 value,变成了数组中的一个 Cell 元素
- 自旋与扩容机制:如果线程对应的 Cell 元素 CAS 失败,不会一直自旋,而是会尝试哈希到其他 Cell,或者对 Cell 数组进行扩容,最大扩容到 CPU 核心数。
核心执行逻辑:
- 调用
add()方法累加时,先尝试直接 CAS 更新 base 值,成功则直接返回 - base 更新失败,说明存在竞争,会通过线程的 probe 哈希值,找到对应的 Cell 元素,CAS 更新 Cell 的值
- 如果 Cell 还未初始化,会通过 CAS 加锁初始化 Cell 数组
- 如果对应的 Cell 更新失败,会重新哈希,尝试其他 Cell,或者触发数组扩容
- 调用
sum()方法获取最终结果时,会遍历 Cell 数组,将所有 Cell 的值和 base 值相加,返回总和。
LongAdder 和 AtomicLong 的核心区别
| 维度 | AtomicLong | LongAdder |
|---|---|---|
| 核心设计 | 单一 value 值,所有线程竞争同一个变量 | 热点分散,base+Cell 数组,线程分散竞争不同的 Cell |
| 性能表现 | 低并发下性能好,高并发下竞争激烈,性能急剧下降 | 低并发下和 AtomicLong 相当,高并发下性能远超 AtomicLong,竞争越激烈优势越明显 |
| 原子性保证 | 每次 CAS 都是原子操作,get() 能获取到最新的准确值 | 累加操作是分散的,sum() 获取的是最终汇总值,并发累加过程中 sum() 返回的结果不是强一致的 |
| 适用场景 | 低并发的原子更新、需要实时获取准确值的场景 | 高并发的统计、累加、计数场景,对实时性要求不高,最终结果准确即可 |
| 内存占用 | 固定占用少量内存 | 高并发下会创建 Cell 数组,占用更多内存,以空间换时间 |
面试避坑点:LongAdder 只适合累加、计数场景,不适合需要精准比较并更新的场景,因为它没有提供 compareAndSet 方法。
6. LongAccumulator 和 LongAdder 的区别是什么?
核心答案:
- LongAdder:是
LongAccumulator的特例,仅支持累加/减操作(默认累积函数是(x, y) -> x + y)。 - LongAccumulator:更通用,支持自定义累积函数(如乘法、取最大值等),构造时需传入函数和初始值。
// 自定义取最大值的 LongAccumulator LongAccumulator maxAccumulator = new LongAccumulator(Long::max, Long.MIN_VALUE); maxAccumulator.accumulate(10); maxAccumulator.accumulate(20); System.out.println(maxAccumulator.get()); // 输出 20
7. CAS 的优缺点是什么?原子类和锁的性能对比?什么时候用原子类?
核心答案:
-
CAS 的优缺点:
- 优点:
- 无锁,非阻塞,无线程上下文切换开销,低竞争下性能远超锁。
- 保证单个变量的原子操作,适合简单计数、状态更新等场景。
- 缺点:
- 高并发下 CPU 开销大:大量线程 CAS 失败会无限自旋,占用大量 CPU 资源。
- 仅能保证单个变量的原子性:无法保证多个变量或一段代码的原子性。
- ABA 问题:无法感知变量的中间修改过程,需通过版本号解决。
- 优点:
-
原子类和锁的性能对比:
- 低竞争场景:原子类性能优于锁(无阻塞、无上下文切换)。
- 高竞争场景:原子类可能因自旋导致 CPU 飙升,此时锁(如 ReentrantLock)的性能可能更稳定。
-
原子类适用场景:
- 单一变量的原子操作(计数器、序列生成、状态标记)。
- 低并发更新、高并发读取的场景。
- 对性能要求极高,且能接受 ABA 问题(或通过版本号解决)的场景。
四、AQS 核心原理(JUC 基石,必深挖)
1. 什么是 AQS?它的核心设计思想是什么?
答案:
AQS(AbstractQueuedSynchronizer,抽象队列同步器),是 JUC 中用于构建锁、同步工具(CountDownLatch、Semaphore 等)的核心基础框架,几乎所有 JUC 中的锁和同步工具,底层都是基于 AQS 实现的。
AQS 的核心设计思想:
- 用一个 volatile 修饰的 int 类型 state 变量,表示同步状态:不同的同步工具,对 state 的定义不同,比如 ReentrantLock 中,state=0 表示锁未被占用,state>0 表示锁被持有,可重入次数为 state 的值;CountDownLatch 中,state 表示计数器的剩余值。
- 用 CLH 变体的双向 FIFO 阻塞队列,管理等待锁的线程:当线程获取同步状态失败时,会被封装成 Node 节点,加入这个等待队列,同时阻塞线程;当同步状态释放时,会唤醒队列中等待的线程,尝试获取同步状态。
- 基于模板方法设计模式:AQS 是一个抽象类,封装了同步队列管理、线程阻塞与唤醒、状态更新等通用核心逻辑,只暴露了
tryAcquire、tryRelease、tryAcquireShared、tryReleaseShared等几个 protected 方法,由子类实现自定义的同步语义(独占/共享、公平/非公平、可重入等),子类无需关心队列管理、线程阻塞等底层逻辑。 - 支持两种同步模式:独占模式(Exclusive,如 ReentrantLock,同一时刻只能有一个线程持有锁)、共享模式(Shared,如 Semaphore、CountDownLatch,同一时刻可以有多个线程同时获取同步状态)。
2. AQS 的核心数据结构是什么?
答案:
AQS 的核心数据结构分为两大部分:同步状态 state、CLH 双向队列(同步队列)+ 条件队列。
1. 核心状态变量
private volatile int state;
- volatile 修饰,保证多线程间的可见性
- 提供了
getState()、setState()、compareAndSetState()三个方法操作 state,子类通过这几个方法实现同步语义 - 是整个同步器的核心,所有的加锁、释放锁,本质都是对 state 的原子更新。
2. CLH 双向同步队列(等待队列)
AQS 通过双向链表实现的 FIFO 队列,用于管理获取同步状态失败的等待线程,队列的节点是 AQS 的内部类 Node。
Node 节点核心属性:
| 属性 | 类型 | 核心作用 |
|---|---|---|
| waitStatus | volatile int | 节点的等待状态,决定了线程的唤醒、阻塞逻辑 |
| prev | volatile Node | 前驱节点,双向链表的前向指针 |
| next | volatile Node | 后继节点,双向链表的后向指针 |
| thread | Thread | 节点封装的等待线程 |
| nextWaiter | Node | 条件队列中的下一个节点,或者标记共享/独占模式 |
waitStatus 核心状态值:
| 状态值 | 含义 |
|---|---|
| 0 | 节点初始化的默认状态 |
| SIGNAL(-1) | 表示当前节点的后继节点被阻塞了,当前节点释放锁或取消时,需要唤醒后继节点 |
| CONDITION(-2) | 节点在条件队列中,等待条件满足 |
| CANCELLED(1) | 节点对应的线程因为超时、中断被取消,是唯一大于 0 的状态,一旦进入这个状态就不会再变化 |
| PROPAGATE(-3) | 共享模式下,释放同步状态时,需要向后传播唤醒操作 |
队列结构特点:
- 队列的头节点是哨兵节点(哑节点),不关联任何等待线程,真正的等待线程从第二个节点开始
- 双向链表,每个节点都有 prev 和 next 指针,方便节点取消、唤醒时的操作
- 入队操作是在队尾添加节点,基于 CAS 保证线程安全;出队操作是修改头节点,无需 CAS,因为只有持有锁的线程才会操作头节点。
3. 条件队列(ConditionObject)
AQS 的内部类 ConditionObject 实现了 Condition 接口,用于实现等待/通知模式,每个 Condition 对象对应一个单向的条件队列,用于存放调用 await() 方法等待条件的线程。
- 条件队列的节点也是
Node对象,通过nextWaiter属性组成单向链表 - 一个 AQS 可以对应多个 Condition 对象,也就是多个条件队列
- 当线程调用
await()方法时,会释放锁,将线程封装成 Node 节点加入条件队列,阻塞线程 - 当其他线程调用
signal()/signalAll()方法时,会将条件队列中的节点转移到同步队列中,等待获取锁。
3. AQS 独占模式的获取与释放流程是怎样的?
答案:
独占模式(Exclusive):同一时刻只能有一个线程获取到同步状态,典型实现是 ReentrantLock。
1. 独占模式获取同步状态流程(acquire 方法)
核心入口方法是 acquire(int arg),这是 AQS 的模板方法,ReentrantLock 的 lock() 方法最终会调用这个方法。
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
执行流程分为 4 步:
- 调用子类实现的 tryAcquire 方法,尝试获取同步状态
- 子类根据自己的同步语义,实现 state 的更新逻辑,比如非公平锁直接 CAS 更新 state,成功则设置独占线程,返回 true
- 如果获取成功,方法直接返回,线程继续执行临界区代码
- 如果获取失败,进入下一步。
- addWaiter 方法,将当前线程封装成独占模式的 Node 节点,加入同步队列队尾
- 先通过快速 CAS,尝试将节点加入队尾,成功则返回节点
- 如果 CAS 失败(多线程竞争入队),进入
enq()方法,通过自旋+CAS,循环尝试将节点加入队尾,直到成功为止,保证多线程环境下入队一定成功。
- acquireQueued 方法,节点进入队列后,自旋尝试获取同步状态,失败则阻塞线程
- 节点进入队列后,先判断前驱节点是不是头节点,如果是,再次调用
tryAcquire尝试获取同步状态 - 获取成功,将当前节点设置为新的头节点,原头节点出队,方法返回
- 获取失败,判断是否需要阻塞当前线程:如果前驱节点的 waitStatus 是 SIGNAL,说明前驱节点释放锁时会唤醒自己,当前线程可以安全阻塞
- 调用
LockSupport.park()方法阻塞当前线程,直到被前驱节点唤醒,或者被中断 - 线程被唤醒后,再次进入循环,尝试获取锁,重复上述流程
- 整个过程中,如果线程被中断,会记录中断状态,方法结束后返回中断标记,不会提前退出。
- 节点进入队列后,先判断前驱节点是不是头节点,如果是,再次调用
- 处理中断
- 如果
acquireQueued方法返回 true,说明线程在等待过程中被中断过,调用selfInterrupt()方法,恢复线程的中断状态,交给用户代码处理。
- 如果
关键特点:acquire 方法是不可中断的,线程在等待过程中被中断,不会提前退出等待队列,只会记录中断状态,直到获取到锁之后,才会处理中断。如果需要可中断的获取,使用 acquireInterruptibly 方法。
2. 独占模式释放同步状态流程(release 方法)
核心入口方法是 release(int arg),ReentrantLock 的 unlock() 方法最终会调用这个方法。
public final boolean release(int arg) {
if (tryRelease(arg)) {
Node h = head;
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);
return true;
}
return false;
}
执行流程分为 3 步:
- 调用子类实现的 tryRelease 方法,尝试释放同步状态
- 子类实现释放逻辑,比如 ReentrantLock 中,state 减 1,直到 state=0,锁完全释放,清空独占线程,返回 true
- 如果释放失败(比如重入次数未完全释放),返回 false,方法结束
- 如果释放成功(state=0),进入下一步。
- 判断是否需要唤醒后继节点
- 检查头节点,如果头节点不为空,且 waitStatus 不是 0(说明有后继节点在等待),进入唤醒流程。
- unparkSuccessor 方法,唤醒头节点的后继节点
- 找到头节点的后继节点,如果后继节点是 CANCELLED 状态,从队尾向前遍历,找到第一个非 CANCELLED 状态的有效节点
- 调用
LockSupport.unpark()方法,唤醒这个节点对应的线程 - 被唤醒的线程,会回到
acquireQueued的循环中,再次尝试获取锁。
4. AQS 共享模式的获取与释放流程是怎样的?
答案:
共享模式(Shared):同一时刻可以有多个线程同时获取到同步状态,典型实现是 CountDownLatch、Semaphore、ReentrantReadWriteLock 的读锁。
1. 共享模式获取同步状态流程(acquireShared 方法)
核心入口方法是 acquireShared(int arg),模板方法,子类实现 tryAcquireShared 方法。
public final void acquireShared(int arg) {
if (tryAcquireShared(arg) < 0)
doAcquireShared(arg);
}
执行流程分为 2 步:
- 调用子类实现的 tryAcquireShared 方法,尝试获取同步状态
- 方法返回值是 int 类型,含义固定:
- 返回值 >= 0:获取同步状态成功,且还有剩余的同步状态,后续等待的线程也可以尝试获取
- 返回值 < 0:获取同步状态失败,需要进入等待队列
- 获取成功,方法直接返回;获取失败,进入
doAcquireShared方法。
- 方法返回值是 int 类型,含义固定:
- doAcquireShared 方法,将线程封装成共享模式节点,加入队列,自旋等待获取
- 和独占模式的
acquireQueued逻辑类似,核心区别有 3 点:- 节点是共享模式(Node.SHARED)
- 当线程获取同步状态成功后,不仅会设置自己为头节点,还会向后传播唤醒操作,如果还有剩余的同步状态,会继续唤醒后继的共享节点,让多个线程可以同时获取到同步状态
- 唤醒的条件是前驱节点是头节点,获取成功后会触发传播唤醒,而独占模式只会自己获取锁,不会唤醒其他节点。
- 和独占模式的
2. 共享模式释放同步状态流程(releaseShared 方法)
核心入口方法是 releaseShared(int arg),模板方法,子类实现 tryReleaseShared 方法。
public final boolean releaseShared(int arg) {
if (tryReleaseShared(arg)) {
doReleaseShared();
return true;
}
return false;
}
执行流程分为 2 步:
- 调用子类实现的 tryReleaseShared 方法,尝试释放同步状态
- 子类实现释放逻辑,比如 Semaphore 中,释放许可,增加 state 的值
- 因为是共享模式,释放操作可能会有多个线程同时执行,所以子类必须保证这个方法的线程安全(通常用 CAS 自旋实现)
- 释放成功返回 true,进入下一步;失败返回 false,方法结束。
- doReleaseShared 方法,唤醒等待队列中的共享节点,保证唤醒操作向后传播
- 这是共享模式的核心方法,通过自旋+CAS,唤醒头节点的后继节点
- 唤醒后继节点后,后继节点获取锁成功,会继续触发传播唤醒,实现多个线程同时获取同步状态
- 处理并发释放的场景,保证多个线程同时释放时,唤醒操作不会丢失,队列中的节点都能被正确唤醒。
5. AQS 中公平锁和非公平锁的实现区别是什么?
答案:
公平锁和非公平锁的核心区别,在于获取锁时,是否遵守队列的 FIFO 顺序:公平锁严格按照线程排队的顺序获取锁,先等待的线程先获取锁;非公平锁会先尝试插队获取锁,获取失败再进入队列排队。
基于 AQS 的 ReentrantLock,公平锁和非公平锁的实现差异,核心在 tryAcquire 方法的实现上,AQS 的队列管理逻辑完全一致。
1. 非公平锁的 tryAcquire 核心逻辑(NonfairSync)
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
// 1. 锁未被占用,直接 CAS 尝试获取锁,不管队列里有没有等待的线程
if (c == 0) {
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
// 2. 锁被当前线程持有,处理可重入
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
// 3. 获取失败,返回 false,进入队列排队
return false;
}
核心特点:只要锁是空闲的(state=0),不管同步队列里有没有等待的线程,直接 CAS 插队获取锁,成功就直接持有锁,失败才进入队列排队。
2. 公平锁的 tryAcquire 核心逻辑(FairSync)
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
// 核心区别:多了 hasQueuedPredecessors() 判断
if (!hasQueuedPredecessors() &&
compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
核心区别:当锁空闲时,会先调用 hasQueuedPredecessors() 方法,判断同步队列中是否有比当前线程等待更久的前驱节点:
- 如果有,直接返回 false,不尝试 CAS 获取锁,进入队列排队,保证公平性
- 如果没有,才会尝试 CAS 获取锁。
公平锁与非公平锁的对比
| 维度 | 公平锁 | 非公平锁 |
|---|---|---|
| 顺序保证 | 严格遵守 FIFO 顺序,先等待的线程先获取锁,不会出现线程饥饿 | 不保证顺序,插队获取可能导致后排队的线程先拿到锁,极端情况下会出现线程饥饿 |
| 性能表现 | 性能较低,因为每次获取锁都要检查队列,且线程唤醒和切换的开销大 | 性能更高,插队成功可以直接获取锁,避免了线程阻塞和唤醒的开销,减少了上下文切换 |
| 适用场景 | 对锁获取的顺序有严格要求,能接受性能损耗的场景 | 绝大多数业务场景,追求高吞吐量,能接受顺序性的牺牲 |
面试加分点:ReentrantLock 默认使用非公平锁,因为非公平锁的吞吐量远高于公平锁;即使是公平锁,tryLock() 无参方法也会直接插队尝试获取锁,不遵守公平性规则,只有 lock() 方法会遵守公平锁的逻辑。
6. AQS 中 Condition 的 await() 和 signal() 原理是什么?
答案:
Condition 是 AQS 提供的等待/通知机制,替代了传统的 wait/notify,可以实现多个等待队列,支持更精细的线程通知,比如生产者消费者模型中,可以分别实现队列满、队列空两个条件队列,精准唤醒。
Condition 的实现类是 AQS 的内部类 ConditionObject,每个 Condition 对象对应一个单向的条件队列,和 AQS 的同步队列配合完成等待/通知逻辑。
1. await() 方法的执行流程
await() 方法会让当前线程释放锁,进入条件队列等待,直到被唤醒、中断或超时。
核心执行步骤:
- 将当前线程封装成 Node 节点,加入条件队列的队尾
- 节点的 waitStatus 设置为 CONDITION,通过 nextWaiter 组成单向链表
- 因为调用 await() 的线程一定是持有锁的,所以入队操作不需要 CAS,是线程安全的。
- 完全释放持有的锁
- 调用
release()方法,释放 AQS 的同步状态(state),唤醒同步队列中等待的线程 - 如果锁是可重入的,会一次性释放所有的重入次数,保证锁完全释放
- 释放失败会抛出 IllegalMonitorStateException,和 wait() 必须在同步块中调用一样,await() 必须在持有锁的情况下调用。
- 调用
- 阻塞当前线程,等待被唤醒
- 循环检查节点是否已经被转移到同步队列中,如果没有,调用
LockSupport.park()阻塞线程 - 线程被唤醒的场景:其他线程调用 signal()/signalAll()、线程被中断、超时时间到。
- 循环检查节点是否已经被转移到同步队列中,如果没有,调用
- 被唤醒后,重新获取锁
- 线程被唤醒后,节点已经从条件队列转移到了同步队列中,会调用
acquireQueued()方法,在同步队列中自旋尝试获取锁 - 获取锁成功后,才会从 await() 方法返回,继续执行后续代码。
- 线程被唤醒后,节点已经从条件队列转移到了同步队列中,会调用
- 处理中断状态
- 如果线程在等待过程中被中断,会记录中断状态,在获取锁之后,抛出中断异常,或者恢复中断状态,取决于中断处理模式。
2. signal() 方法的执行流程
signal() 方法会唤醒条件队列中等待时间最长的第一个节点,将其转移到同步队列中,等待获取锁。
核心执行步骤:
- 检查当前线程是否持有锁
- signal() 方法必须在持有锁的情况下调用,否则会抛出 IllegalMonitorStateException。
- 取出条件队列的第一个节点(头节点)
- 如果条件队列为空,直接返回,无需唤醒。
- 将节点从条件队列转移到同步队列中(transferForSignal)
- 先将节点的 waitStatus 从 CONDITION 修改为 0,准备进入同步队列
- 调用 AQS 的
enq()方法,将节点加入同步队列的队尾 - 将前驱节点的 waitStatus 设置为 SIGNAL,保证前驱节点释放锁时会唤醒这个节点
- 如果节点已经被取消,会唤醒线程,让它处理取消逻辑。
- 唤醒节点对应的线程
- 转移成功后,调用
LockSupport.unpark()唤醒节点中的线程 - 被唤醒的线程,会回到 await() 的循环中,发现自己已经在同步队列里,开始尝试获取锁。
- 转移成功后,调用
3. signalAll() 方法
和 signal() 的区别是,会遍历整个条件队列,将所有的节点都转移到同步队列中,逐个唤醒,而不是只唤醒第一个节点。
面试核心考点:Condition 和 wait/notify 的区别
| 维度 | Condition | wait/notify |
|---|---|---|
| 队列数量 | 一个锁可以对应多个 Condition 对象,也就是多个等待队列,实现精准唤醒 | 一个锁只能对应一个等待队列,notify 只能随机唤醒一个线程,无法精准唤醒 |
| 锁依赖 | 依赖 Lock 接口实现,必须在 lock() 和 unlock() 之间调用 | 依赖 synchronized 关键字,必须在同步方法/同步块中调用 |
| 功能特性 | 支持更丰富的等待模式,比如不可中断等待、超时等待、截止时间等待等 | 只有 wait()、wait(long)、wait(long, int) 三种模式 |
| 线程状态 | 等待时进入 WAITING 状态,唤醒后进入同步队列,状态为 WAITING,获取锁时阻塞才会进入 BLOCKED | wait() 后进入 WAITING 状态,notify 后进入 BLOCKED 状态,等待获取监视器锁 |
7. AQS 灵魂三问
1. AQS 为什么要用双向队列(CLH 变体)?
一句话答案:为了高效取消、高效唤醒、高效节点删除,单向队列做不到。
详细满分版:
- 方便前驱节点失效/取消时,快速找到新前驱
- 线程在等待时可能被中断或超时,需要从队列中删除自己。
- 单向链表无法找到前驱,双向链表通过
prev能直接定位。
- 唤醒时精准找到后继节点
- AQS 唤醒规则:只唤醒下一个有效节点。
- 双向链表保证
unparkSuccessor()能快速找到需要唤醒的线程。
- 保证入队出队的原子性与稳定性
- 双向链表在并发 CAS 入队时更安全。
- 避免单向链表容易出现的断裂、丢失问题。
总结(背诵版):
双向队列主要解决三个问题:线程取消时能快速删除节点、唤醒时能快速找到后继、高并发下队列更稳定。
2. AQS 如何实现线程阻塞和唤醒?
一句话答案:LockSupport.park() 阻塞,LockSupport.unpark() 唤醒,配合节点 waitStatus 实现精准控制。
满分流程版:
- 获取锁失败
线程封装成 Node,加入队列尾部。 - 判断前驱是否为 head
- 不是 → 阻塞
- 是 → 再尝试一次获取
- 阻塞调用
LockSupport.park(this)- 线程进入 WAITING 状态,释放 CPU
- 前驱节点释放锁
tryRelease()成功- 调用
unparkSuccessor() LockSupport.unpark(thread)唤醒后继
- 被唤醒后
线程从 park 处恢复,再次尝试获取锁。
关键原理:
- park/unpark 精准操作线程,比 wait/notify 更高效、更安全。
- 无需锁,不依赖 Monitor,不涉及线程上下文切换浪费。
背诵版:
AQS 使用
LockSupport.park()阻塞线程,LockSupport.unpark()唤醒线程。线程抢锁失败后入队并阻塞;锁释放时,唤醒队列中的后继节点,被唤醒线程重新尝试抢锁。
3. 共享模式为什么会传播唤醒(PROPAGATE)?
一句话答案:因为共享锁可以同时被多个线程持有,必须把唤醒“传递下去”,让所有等待线程都能拿到许可。
详细满分版:
- 共享模式允许多个线程同时获取锁
例如 Semaphore、CountDownLatch。 - 只唤醒一个不够
若队列里有 N 个等待线程,释放一个许可后,可能多个线程都能获取。 - 传播唤醒机制
当一个线程获取共享锁成功后:- 不仅自己运行
- 还要继续唤醒下一个节点
- 下一个唤醒后继续往后传 → 这就是 PROPAGATE 传播
- 目的
保证所有等待的共享线程都能被及时唤醒,避免队列堵塞、许可浪费。
背诵版:
共享模式允许多线程同时获取同步状态,释放或获取成功后必须传播唤醒后续节点,保证所有等待线程都能及时获取许可,提高并发效率。
8. AQS 为什么使用双向链表?为什么用 CLH 变体队列?
答案:
1. AQS 为什么使用双向链表,而不是单向链表?
核心原因是为了高效处理节点的取消、超时、中断场景,以及保证唤醒操作的可靠性,具体有 3 点:
- 高效处理取消的节点:当线程因为中断、超时被取消时,节点的 waitStatus 会变成 CANCELLED,需要将这个节点从队列中移除。如果是单向链表,无法直接找到前驱节点,必须从头节点遍历,时间复杂度 O(n);而双向链表可以通过 prev 指针直接找到前驱节点,修改前驱和后继的指针,快速移除节点,时间复杂度 O(1)。
- 保证唤醒操作的可靠性:当释放锁唤醒后继节点时,如果后继节点是 CANCELLED 状态,需要从后往前遍历,找到第一个有效的非取消节点。双向链表的 prev 指针可以从队尾向前遍历,避免了从头遍历的开销,同时能保证不会漏掉有效节点。
- 安全处理节点入队的并发场景:入队操作是 CAS 更新队尾,当一个节点刚完成 CAS 设置 tail,还没来得及设置前驱节点的 next 指针时,双向链表可以通过 prev 指针保证链表的完整性,即使 next 指针还没设置,也能通过 prev 指针反向遍历,不会出现链表断裂的问题。
2. AQS 为什么用 CLH 变体队列,而不是原生 CLH 队列?
原生 CLH 队列是一种单向链表的自旋锁队列,核心是线程在前驱节点的状态上自旋,等待前驱节点释放锁,适合 NUMA 架构的 CPU。
AQS 使用的是CLH 变体队列,和原生 CLH 的核心区别,以及选择的原因:
- 从自旋等待改为阻塞等待:原生 CLH 队列是线程在前驱节点上自旋,不阻塞线程,适合短时间的锁持有场景;而 AQS 的变体队列,线程获取锁失败后会调用
LockSupport.park()阻塞,而不是自旋,适合长时间的锁持有场景,避免了长时间自旋占用 CPU 的问题,更适合 Java 的通用并发场景。 - 从单向链表改为双向链表:原生 CLH 是单向链表,只关注前驱节点的状态;而 AQS 改为双向链表,解决了节点取消、超时、中断的处理问题,适配了 Java 中可中断、可超时的锁获取需求。
- 适配了独占+共享两种模式:原生 CLH 队列只支持独占锁,而 AQS 的变体队列,同时支持独占模式和共享模式,通过节点的 nextWaiter 标记模式,实现共享模式的传播唤醒,适配了 JUC 中多种同步工具的需求。
- 引入了条件队列:原生 CLH 队列没有条件队列的设计,AQS 的变体队列配合 Condition 的条件队列,实现了等待/通知机制,功能更丰富,适配了更复杂的并发场景。
五、JUC 锁体系核心面试题
1. Lock 接口和 synchronized 关键字的核心区别?
答案:
Lock 是 JUC 提供的锁接口,核心实现是 ReentrantLock,和 synchronized 都是 Java 中用于保证线程安全的锁机制,核心区别如下:
| 维度 | synchronized | Lock |
|---|---|---|
| 底层实现 | JVM 层面实现,是 Java 的关键字,底层通过监视器锁(monitor)实现,依赖对象头和内置的等待队列 | JDK 层面实现,是 Java 接口,基于 AQS 框架实现,纯 Java 代码实现,不依赖 JVM 的内置特性 |
| 锁特性 | 非公平锁,可重入,不可中断,不支持超时获取 | 支持公平锁/非公平锁可选,可重入,支持可中断获取锁,支持超时获取锁,功能更灵活 |
| 锁释放 | 自动释放,同步块执行完毕、异常退出时,JVM 会自动释放锁,不会出现死锁 | 手动释放,必须在 finally 块中调用 unlock() 方法释放锁,否则会导致锁泄漏,引发死锁 |
| 等待/通知 | 基于 wait/notify/notifyAll,一个锁只能对应一个等待队列,无法精准唤醒 | 基于 Condition 的 await/signal/signalAll,一个锁可以对应多个等待队列,实现精准唤醒,功能更强大 |
| 锁优化 | JDK 1.6 之后有锁升级机制(偏向锁→轻量级锁→重量级锁),自适应自旋,锁粗化,锁消除等优化 | 基于 CAS+volatile 实现,没有锁升级机制,非公平锁有插队优化,自旋仅在入队前尝试,失败则阻塞 |
| 性能表现 | 低并发下,偏向锁、轻量级锁性能好;高并发锁竞争激烈时,升级为重量级锁,性能下降明显 | 高并发竞争下,性能更稳定,可控性更强,非公平锁的吞吐量高于 synchronized |
| 可扩展性 | 功能固定,无法扩展 | 接口化设计,可以自定义 Lock 的实现类,扩展锁的功能,比如读写锁、乐观读锁等 |
| 线程状态 | 竞争失败时,线程进入 BLOCKED 状态 | 竞争失败时,线程进入 WAITING/TIMED_WAITING 状态,不会进入 BLOCKED 状态 |
面试加分点:JDK 1.6 之后,synchronized 的性能已经大幅提升,和 ReentrantLock 在低并发下差距很小;ReentrantLock 的优势在于更丰富的锁特性,比如可中断、超时、公平锁、多条件队列,而不是绝对的性能优势。
2. ReentrantLock 的可重入性是如何实现的?
答案:
ReentrantLock 是可重入锁,指的是同一个线程已经持有了锁,再次调用 lock() 方法时,不会被阻塞,而是直接获取成功,同时增加重入次数,对应的 unlock() 方法会减少重入次数,直到重入次数为 0,才会真正释放锁。
ReentrantLock 的可重入性,完全基于 AQS 的 state 变量实现,核心逻辑在 tryAcquire 和 tryRelease 方法中:
1. 加锁时的可重入处理(tryAcquire)
- 首先获取 state 的值,如果 state=0,说明锁未被占用,CAS 更新 state 为 1,设置当前线程为独占线程,获取锁成功
- 如果 state>0,说明锁已经被持有,检查持有锁的线程是不是当前线程
- 如果是当前线程,说明是重入加锁,将 state 的值+1,更新 state,返回 true,获取锁成功
- 如果不是当前线程,返回 false,获取锁失败,进入队列等待。
2. 释放锁时的可重入处理(tryRelease)
- 首先将 state 的值-1,计算新的 state 值
- 检查持有锁的线程是不是当前线程,如果不是,抛出 IllegalMonitorStateException
- 如果新的 state 值等于 0,说明重入次数已经完全释放,清空独占线程,返回 true,锁完全释放
- 如果新的 state 值大于 0,说明还有重入次数未释放,更新 state,返回 false,锁不会释放,也不会唤醒等待的线程。
关键注意点:lock() 和 unlock() 必须成对出现,重入了多少次,就必须 unlock() 多少次,否则会导致锁无法释放,其他线程永远无法获取锁,引发死锁。
3. ReentrantReadWriteLock 读写锁的原理是什么?有什么优缺点?
答案:
ReentrantReadWriteLock 是 JUC 提供的读写锁,实现了 ReadWriteLock 接口,核心思想是读写分离:锁分为读锁和写锁,读锁是共享锁,同一时刻可以有多个线程同时持有读锁;写锁是独占锁,同一时刻只能有一个线程持有写锁,且写锁和读锁是互斥的(写锁持有期间,读锁无法获取;读锁持有期间,写锁无法获取)。
读写锁的核心目标,是解决传统独占锁在读多写少场景下的性能问题:传统独占锁,即使多个线程都是读操作,也会互斥,同一时刻只能有一个线程执行,并发性能极低;而读写锁允许多个线程同时读,只有写操作时才会互斥,大幅提升了读多写少场景的并发吞吐量。
核心实现原理
ReentrantReadWriteLock 底层也是基于 AQS 实现的,核心是将 AQS 的 32 位 int 类型 state 变量,拆分为高 16 位和低 16 位,分别表示读锁的持有数量和写锁的重入次数:
- 高 16 位:表示读锁的持有次数,每有一个线程获取读锁,高 16 位的值+1,支持可重入
- 低 16 位:表示写锁的持有次数,写锁是可重入的,重入一次,低 16 位的值+1
通过位运算,就可以快速从 state 中分别获取读锁和写锁的状态,无需额外的变量。
1. 写锁的获取与释放
写锁是独占锁,实现了 AQS 的独占模式:
- 获取逻辑:调用
tryAcquire方法,先检查 state 的值,如果 state!=0,说明有读锁或者写锁被持有:- 如果低 16 位(写锁状态)为 0,说明有读锁被持有,直接获取失败,因为读写互斥
- 如果低 16 位不为 0,但持有写锁的线程不是当前线程,获取失败,写锁是独占的
- 如果是当前线程持有写锁,处理可重入,低 16 位+1,更新 state,获取成功
- 如果 state=0,说明没有任何锁被持有,CAS 更新 state 的低 16 位,设置独占线程,获取成功。
- 释放逻辑:调用
tryRelease方法,低 16 位-1,如果低 16 位变为 0,说明写锁完全释放,清空独占线程,返回 true,唤醒同步队列中的等待线程。
2. 读锁的获取与释放
读锁是共享锁,实现了 AQS 的共享模式:
- 获取逻辑:调用
tryAcquireShared方法,先检查写锁是否被其他线程持有,如果是,直接获取失败,读写互斥;如果写锁是当前线程持有,允许“写锁降级为读锁”,可以获取读锁。- 然后通过 CAS 更新 state 的高 16 位,读锁计数+1,获取成功,返回>=0 的值
- 高 16 位的计数,不仅记录了读锁的总持有次数,还通过 ThreadLocal 记录了每个线程的读锁重入次数,保证可重入性。
- 释放逻辑:调用
tryReleaseShared方法,通过 CAS 更新 state 的高 16 位,读锁计数-1,如果高 16 位变为 0,说明所有读锁都释放了,返回 true,唤醒同步队列中等待的写锁线程。
3. 锁升降级规则
- 锁降级(支持):持有写锁的线程,可以在不释放写锁的情况下,获取读锁,然后释放写锁,完成写锁到读锁的降级。目的是保证数据的可见性,避免释放写锁后,其他线程修改了数据,当前线程无法感知。
- 锁升级(不支持):持有读锁的线程,不能直接获取写锁,必须先释放所有读锁,才能获取写锁。因为如果多个线程都持有读锁,同时尝试升级写锁,会互相等待,导致死锁。
核心优点
- 读多写少场景下,并发性能远超独占锁:读操作可以并行执行,不会互斥,大幅提升系统吞吐量
- 可重入性:读锁和写锁都支持可重入,使用灵活
- 支持公平/非公平模式:可以选择公平锁或非公平锁,适配不同的场景
- 支持 Condition:写锁可以创建 Condition 对象,实现等待/通知机制,读锁不支持 newCondition(),因为读锁是共享的,没有排他性,无法保证等待/通知的线程安全。
核心缺点与问题
- 写饥饿问题:高并发读多写少场景下,读锁一直被持有,写锁线程会一直等待,无法获取锁,出现写饥饿的问题,甚至永远无法执行
- 重入的读锁会阻碍写锁:即使是同一个线程,持有读锁的情况下,也无法获取写锁,必须先释放读锁
- 高并发写场景下,性能不如独占锁:因为读写互斥,且有额外的 state 拆分、计数开销,写多的场景下,性能不如 ReentrantLock
- 不支持锁升级:容易导致死锁,使用时需要特别注意。
4. StampedLock 是什么?和 ReentrantReadWriteLock 相比有什么优势?
答案:
StampedLock 是 JDK 1.8 新增的锁机制,位于 java.util.concurrent.locks 包,核心是为了解决 ReentrantReadWriteLock 的写饥饿问题,进一步提升读多写少场景下的并发性能,它不是基于 AQS 实现的,而是自己实现了一套基于状态和队列的同步逻辑。
StampedLock 的核心设计思想是引入乐观读模式,读操作全程不加锁,只有在数据修改后,才会检查数据是否发生了变化,避免了读锁和写锁的互斥,大幅减少了读操作的开销,同时缓解了写饥饿的问题。
核心特性与三种锁模式
StampedLock 的核心是戳记(stamp):所有的加锁操作,都会返回一个 long 类型的 stamp 戳记,代表锁的版本和状态;释放锁、转换锁模式时,需要传入对应的 stamp,只有 stamp 匹配,才能操作成功。
StampedLock 支持三种锁模式:
- 写锁(WriteLock):独占锁,和 ReentrantReadWriteLock 的写锁类似,同一时刻只能有一个线程持有写锁,获取成功返回一个 stamp,释放写锁时需要传入这个 stamp。支持悲观锁的可中断、超时获取。
- 悲观读锁(ReadLock):共享锁,和 ReentrantReadWriteLock 的读锁类似,同一时刻可以有多个线程持有悲观读锁,和写锁互斥。获取成功返回 stamp,释放时需要传入。
- 乐观读(Optimistic Read):这是 StampedLock 的核心特性,全程不加锁,不会阻塞写线程。
- 调用
tryOptimisticRead()方法,返回一个当前的 stamp 戳记,这个操作没有任何加锁开销,只是读取了当前的锁状态 - 线程基于这个 stamp,读取共享数据,读取完成后,调用
validate(stamp)方法,检查这个 stamp 是否有效(也就是在读取过程中,是否有写锁被持有过,数据是否被修改过) - 如果 validate 返回 true,说明读取过程中数据没有被修改,读取的数据是安全有效的,可以直接使用
- 如果 validate 返回 false,说明数据被修改过,乐观读失效,需要升级为悲观读锁,重新读取数据,保证数据的一致性。
- 调用
锁模式转换
StampedLock 支持在一定条件下,将一种锁模式转换为另一种锁模式,比如:
- 乐观读验证失败,可以转换为悲观读锁
- 悲观读锁可以尝试升级为写锁
- 写锁可以降级为悲观读锁,或者乐观读
这种灵活的模式转换,避免了频繁的释放锁和重新加锁,提升了性能。
和 ReentrantReadWriteLock 相比的核心优势
- 彻底解决写饥饿问题:ReentrantReadWriteLock 中,读锁和写锁是互斥的,大量读线程持有读锁,会导致写线程一直等待,出现写饥饿;而 StampedLock 的乐观读模式,读操作不加锁,不会阻塞写线程,写线程可以正常获取写锁,从根本上缓解了写饥饿的问题。
- 读操作的性能大幅提升:乐观读模式全程无锁,没有 CAS 操作的开销,也没有线程阻塞和唤醒的开销,比 ReentrantReadWriteLock 的悲观读锁性能高很多,尤其在高并发读多写少的场景下,优势非常明显。
- 更灵活的锁模式:支持乐观读、悲观读、写锁三种模式,支持模式转换,适配更多的业务场景,比如大部分场景用乐观读,极少数冲突场景升级为悲观读,平衡了性能和数据一致性。
- 无重入开销:StampedLock 的设计本身不支持可重入,避免了重入计数的开销,当然也带来了使用限制。
核心缺点与使用注意事项
- 不支持可重入:StampedLock 是不可重入的,同一个线程不能重复获取写锁或悲观读锁,否则会导致死锁,这是和 ReentrantLock、ReentrantReadWriteLock 最大的区别。
- 不支持 Condition 条件队列:StampedLock 的写锁和悲观读锁,都不支持 newCondition() 方法,无法实现等待/通知机制,需要这个特性的场景,不能用 StampedLock。
- 乐观读的使用有严格限制:乐观读模式下,读取的数据可能是不一致的,必须先读取 stamp,再读取数据,然后 validate 验证,且只能用于读取操作,不能用于修改操作。
- 不可中断的获取模式:StampedLock 的悲观读锁和写锁的阻塞获取方法,是不可中断的,如果线程在等待锁的过程中被中断,不会抛出中断异常,会继续等待,可能导致线程无法终止,需要可中断的场景,要使用对应的
lockInterruptibly()方法。
适用场景:读多写少的高并发场景,对读性能要求极高,能接受乐观读的少量冲突,不需要锁重入和 Condition 特性的场景,比如缓存、元数据读取等。
5. LockSupport 是什么?和 wait/notify 相比有什么优势?
答案:
LockSupport 是 JUC 提供的线程阻塞与唤醒的基础工具类,位于 java.util.concurrent.locks 包,底层是基于 Unsafe 类的 park() 和 unpark() native 方法实现的,是 AQS 中线程阻塞和唤醒的核心工具,整个 JUC 的锁和同步工具,底层的线程阻塞唤醒都是用 LockSupport 实现的。
LockSupport 的核心设计思想是基于“许可证(permit)”的机制:
- 每个线程对应一个许可证,许可证的状态只有 0 和 1 两种,默认是 0
- 调用
park()方法:如果许可证是 1,会将许可证置为 0,立即返回,不会阻塞;如果许可证是 0,会阻塞当前线程,直到许可证变为 1 - 调用
unpark(Thread thread)方法:给指定的线程设置许可证为 1,唤醒被 park 阻塞的线程。即使提前调用 unpark,后续线程调用 park 时,也会直接拿到许可证,不会阻塞,这是和 wait/notify 最大的区别之一。
LockSupport 核心常用方法
| 方法 | 核心作用 |
|---|---|
static void park() | 阻塞当前线程,直到被 unpark 唤醒,或者被中断 |
static void park(Object blocker) | 阻塞当前线程,blocker 是阻塞对象,用于监控和排查问题,推荐使用这个方法 |
static void parkNanos(long nanos) | 阻塞当前线程,最长阻塞指定的纳秒时间,超时自动返回 |
static void parkUntil(long deadline) | 阻塞当前线程,直到指定的截止时间(毫秒时间戳) |
static void unpark(Thread thread) | 唤醒指定的线程,给线程发放许可证 |
和 wait/notify 相比的核心优势
- 无需依赖锁对象,使用更灵活
- wait/notify 必须在 synchronized 同步块中(接上文)
- LockSupport 的 park/unpark 不需要获取任何锁,随时随地都可以调用,使用更灵活,代码更简洁,也避免了因为锁的顺序问题导致的死锁。
-
唤醒可以先于阻塞执行,不会出现信号丢失的问题
- wait/notify 中,如果线程 A 先调用 notify,线程 B 后调用 wait,线程 B 会一直阻塞,信号完全丢失,无法被唤醒
- LockSupport 中,即使先调用 unpark(threadB),给线程 B 发放了许可证,后续线程 B 调用 park() 时,会直接拿到许可证,不会阻塞,完全不会出现信号丢失的问题,极大提升了并发编程的可靠性。
-
可以精准唤醒指定的线程,而不是随机唤醒
- notify() 只能随机唤醒等待队列中的一个线程,无法指定唤醒某个特定的线程;notifyAll() 会唤醒所有等待的线程,造成“惊群效应”,带来不必要的线程切换开销
- LockSupport 的 unpark() 方法,可以精准唤醒指定的线程,只需要传入线程对象,不会影响其他线程,避免了惊群效应,性能更好。
-
线程中断处理更友好
- wait() 方法会抛出 InterruptedException 中断异常,必须捕获处理,且线程被中断后,会清除中断标记
- park() 方法不会抛出中断异常,线程被中断后,会从 park() 中返回,不会抛出异常,同时保留线程的中断状态,由用户代码自行处理中断,更灵活,也避免了异常处理的繁琐。
-
支持超时、截止时间的灵活阻塞模式
- wait() 只有无超时、纳秒超时两种模式,功能有限
- LockSupport 支持纳秒超时、绝对时间截止的阻塞模式,适配更多的超时场景,比如可超时的锁获取。
-
排查问题更方便
- park(Object blocker) 方法可以传入一个 blocker 对象,JVM 的线程 dump 工具会记录这个对象,排查死锁、线程阻塞问题时,可以清楚的看到线程是被哪个对象阻塞的,方便问题定位,而 wait() 方法的阻塞对象排查起来更麻烦。
面试避坑点
- park() 方法即使被唤醒,也不会返回任何原因,无论是被 unpark 唤醒、中断唤醒、还是超时返回,都需要用户代码自行检查,比如检查中断状态、检查业务条件是否满足。
- park() 方法可能会出现“虚假唤醒”,也就是没有调用 unpark,线程也可能从 park() 中返回,所以通常需要在循环中调用 park(),每次唤醒后检查业务条件是否满足,不满足则继续 park。
- unpark 只会给线程设置一个许可证,多次调用 unpark,许可证也不会累加,始终是 1,比如连续调用两次 unpark,后续线程调用两次 park,第一次 park 会拿到许可证返回,第二次 park 会阻塞,因为许可证已经变成 0 了。
6. 什么是死锁?死锁产生的四个必要条件是什么?如何排查和避免死锁?
答案:
什么是死锁
死锁是指两个或多个线程,在执行过程中,因为互相等待对方持有的锁资源,而导致的永久阻塞的现象,没有外力干预的情况下,这些线程会一直互相等待,无法继续执行,最终导致系统功能卡死、服务不可用。
一个最简单的死锁示例:线程 A 持有锁 1,等待获取锁 2;线程 B 持有锁 2,等待获取锁 1,两个线程互相等待对方释放锁,永远无法获取到需要的锁,形成死锁。
死锁产生的四个必要条件
死锁的产生,必须同时满足以下四个条件,缺一不可:
- 互斥条件:一个资源(锁)同一时刻只能被一个线程持有,其他线程想要获取,必须等待,这是锁的基本特性,无法破坏。
- 持有并等待条件:线程已经持有了至少一个资源,又去申请新的资源,而新的资源已经被其他线程持有,当前线程被阻塞,同时不释放自己已经持有的资源。
- 不可剥夺条件:线程已经持有的资源,在自己使用完之前,不能被其他线程强行剥夺,只能自己主动释放。
- 循环等待条件:多个线程之间,形成了头尾相接的循环等待资源的链条,每个线程都在等待下一个线程持有的资源,最终形成闭环,比如线程 A 等线程 B,线程 B 等线程 C,线程 C 等线程 A。
死锁的排查方法
当系统出现线程卡死、功能无响应的情况,怀疑出现死锁时,可以通过以下工具排查:
- jstack 命令(最常用)
- 首先通过
jps命令,找到 Java 进程的 PID - 执行
jstack <PID>,输出 Java 进程的线程栈信息 - jstack 会自动检测死锁,在输出的末尾,会明确打印出
Found one Java-level deadlock:,并列出死锁的线程、持有的锁、等待的锁,以及死锁的发生位置,非常直观。
- 首先通过
- JConsole / VisualVM 可视化工具
- JDK 自带的可视化监控工具,连接到 Java 进程后,在“线程”选项卡中,点击“检测死锁”按钮,工具会自动检测出死锁的线程,展示每个线程的栈信息、持有的锁和等待的锁,适合不熟悉命令行的场景。
- Arthas 在线诊断工具
- 阿里开源的 Java 在线诊断工具,线上环境非常方便,使用
thread -b命令,可以直接检测出阻塞其他线程的死锁线程,打印出详细的死锁信息,定位问题非常高效。
- 阿里开源的 Java 在线诊断工具,线上环境非常方便,使用
- 线程 dump 分析工具
- 比如 IBM Thread and Monitor Dump Analyzer、FastThread 等,将 jstack 导出的线程 dump 文件上传,工具会自动分析死锁、线程阻塞、资源竞争等问题,适合复杂的死锁场景分析。
死锁的避免方案
死锁的四个必要条件,只要破坏其中一个,就可以避免死锁的发生,核心方案如下:
-
破坏循环等待条件(最常用、最有效)
- 核心方案:统一加锁顺序,所有线程都按照固定的全局顺序来获取锁,比如按照锁的哈希值大小、资源 ID 的大小,从小到大依次加锁,避免出现 A 等 B、B 等 A 的情况,彻底打破循环等待的闭环。
- 示例:锁 1 的 ID 是 100,锁 2 的 ID 是 200,所有线程都必须先获取锁 1,再获取锁 2,就不会出现线程 A 持有锁 1 等锁 2,线程 B 持有锁 2 等锁 1 的情况。
-
破坏持有并等待条件
- 方案 1:一次性申请所有需要的资源:线程在执行前,一次性申请所有需要的锁,只有所有锁都申请成功,才开始执行;只要有一个锁申请失败,就释放所有已经持有的锁,重新尝试,避免持有部分锁,等待其他锁的情况。
- 缺点:会降低并发性能,因为提前申请了所有锁,锁的持有时间变长,且可能出现频繁的申请释放,适合锁数量少、持有时间短的场景。
-
破坏不可剥夺条件
- 核心方案:使用 Lock 接口的
tryLock(long timeout, TimeUnit unit)方法,尝试获取锁时,设置超时时间,如果超时时间内没有获取到锁,就主动放弃,释放自己已经持有的锁,避免一直阻塞等待,打破不可剥夺的限制。 - 这是线上业务中非常常用的方案,不仅可以避免死锁,还能防止线程因为锁竞争长时间阻塞,提升系统的可用性。
- 注意:必须在获取锁失败时,主动释放已经持有的锁,否则还是会出现持有并等待的问题。
- 核心方案:使用 Lock 接口的
-
尽量减少锁的使用,降低锁的粒度和持有时间
- 死锁的根源是多线程竞争锁,所以尽量使用无锁编程,比如原子类、ThreadLocal、不可变对象,避免使用锁。
- 必须使用锁时,尽量降低锁的粒度,比如用分段锁、读写锁,减少锁的持有时间,只在需要操作共享资源的临界区加锁,执行完立刻释放,降低死锁发生的概率。
-
避免锁的嵌套使用
- 死锁绝大多数都是因为锁的嵌套使用导致的,比如在持有锁 A 的代码块中,又去获取锁 B,嵌套层级越多,越容易出现死锁。
- 开发中尽量避免锁的嵌套,尽量不要在持有一个锁的情况下,去获取另一个锁,如果必须嵌套,一定要严格遵守加锁顺序,或者使用 tryLock 超时机制。
7. 活锁、饥饿锁 和 死锁的区别是什么?
答案:
这三个都是并发编程中常见的线程活跃性问题,都会导致线程无法正常执行,但核心原因和表现完全不同,具体区别如下:
| 问题 | 核心定义 | 核心特点 | 与死锁的核心区别 | 常见原因 | 解决方案 |
|---|---|---|---|---|---|
| 死锁 | 多个线程互相等待对方持有的锁资源,永久阻塞,无法继续执行 | 线程处于 BLOCKED/WAITING 状态,永远不会被唤醒,不消耗 CPU,完全停止执行 | 线程完全阻塞,不占用 CPU,互相等待资源 | 锁嵌套使用、加锁顺序不一致、不释放锁、不可中断的锁等待 | 固定加锁顺序、tryLock 超时机制、避免锁嵌套、一次性申请所有锁 |
| 活锁 | 多个线程都在运行,都在不断改变自己的状态,但因为互相响应对方的动作,导致谁都无法继续执行下去,一直在空转 | 线程始终处于 RUNNABLE 状态,一直在执行代码,消耗 CPU 资源,但始终无法完成自己的任务,没有进展 | 线程没有阻塞,一直在运行,只是做无用功,占用 CPU | 多个线程互相谦让,都释放自己的资源给对方,导致资源在多个线程之间来回传递,谁都无法同时拿到所有资源;重试机制设计不当,失败就立刻重试,一直冲突 | 给线程增加随机的等待时间,打破同步的谦让/重试节奏;调整重试机制,增加重试次数限制;避免互相释放资源的逻辑 |
| 饥饿锁 | 一个线程因为优先级低、或者始终无法获取到锁资源,导致长期得不到执行,一直无法完成任务 | 线程可能处于 WAITING 状态,或者偶尔被唤醒但抢不到锁,长期无法执行,系统可能还在运行,只是这个线程的任务永远无法完成 | 只有一个或少数线程无法执行,其他线程可以正常执行,不是互相等待,而是单个线程抢不到资源 | 非公平锁下,高并发竞争,短任务频繁抢占锁,导致长任务一直抢不到锁;线程优先级设置不当,低优先级线程一直得不到 CPU 调度;持有锁的线程执行时间过长,释放锁的频率太低 | 改用公平锁,保证 FIFO 的获取顺序;减少锁的持有时间,降低锁的粒度;避免设置线程优先级,依赖操作系统的默认调度;优化临界区代码,加快锁的释放 |
典型示例:
- 死锁:A 拿锁 1 等锁 2,B 拿锁 2 等锁 1,两人都不动了
- 活锁:A 和 B 在走廊相遇,A 往左让,B 也往左让,两人一直来回让,谁都过不去,一直在动,但就是走不过去
- 饥饿锁:高峰期收费站,小车一直插队,大货车一直抢不到收费窗口,永远无法通过
六、线程池与异步编程
1. 为什么要使用线程池?直接 new Thread 有什么问题?
答案:
线程池是一种基于池化思想管理线程的工具,核心是提前创建好若干个线程,放入池中,任务提交时,复用池中的线程执行,而不是每次都新建线程,任务执行完,线程放回池中,等待下一个任务。
直接 new Thread 执行任务的核心问题
- 线程创建与销毁的开销极大:Java 中,线程的创建和销毁,需要 JVM 和操作系统配合,涉及内核态和用户态的切换,开销很大。如果频繁创建和销毁线程,会严重消耗系统资源,降低系统性能。
- 线程数量不可控,容易导致 OOM 和系统崩溃:无限制的创建线程,每个线程都会分配栈内存(默认 1M 左右),高并发下,大量线程会占用大量内存,导致 OOM;同时,大量线程会导致 CPU 频繁切换线程上下文,开销极大,CPU 使用率飙升,系统卡顿甚至崩溃。
- 线程缺乏统一管理:无法对线程进行统一的调度、监控、限流、超时控制,任务执行完线程就销毁了,无法复用,也无法统计任务的执行情况,出了问题难以排查。
- 不支持高级功能:不支持定时执行、周期执行、任务排队、拒绝策略等高级功能,复杂的并发场景无法满足。
线程池的核心优势
- 降低资源消耗:通过复用池中的线程,避免了频繁创建和销毁线程的开销,大幅降低了系统资源的消耗。
- 提高响应速度:任务提交时,不需要等待线程创建,直接复用池中的线程执行,任务可以立刻执行,提升了系统的响应速度。
- 控制线程的并发数量:通过核心参数,控制线程池的最大线程数,避免无限制创建线程,防止资源耗尽,保证系统的稳定运行。
- 统一的线程管理与监控:线程池提供了统一的管理能力,可以对任务进行排队、调度、拒绝,同时可以监控线程池的状态、任务执行情况、线程数量等指标,方便问题排查和系统优化。
- 丰富的高级功能:支持定时执行、周期执行、任务批量处理、异常处理、Future 异步获取结果等高级功能,适配复杂的业务场景。
2. ThreadPoolExecutor 的七大核心参数是什么?每个参数的含义是什么?
答案:
ThreadPoolExecutor 是 Java 线程池的核心实现类,七大核心参数决定了线程池的运行机制,构造方法如下:
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler)
七大核心参数详解:
1. corePoolSize:核心线程数
- 线程池中长期保留的存活的线程数量,即使这些线程处于空闲状态,也不会被销毁,除非设置了
allowCoreThreadTimeOut=true。 - 可以理解为线程池的“常驻线程数”,是线程池的基本容量,核心线程会一直存在,处理提交的任务。
- 线程池初始化时,核心线程是 0,提交任务时才会创建核心线程,直到达到 corePoolSize,也可以通过
prestartCoreThread()/prestartAllCoreThreads()方法提前创建核心线程。
2. maximumPoolSize:最大线程数
- 线程池允许创建的最大线程数量,是线程池的容量上限。
- 当核心线程都在忙碌,且任务队列已经满了的时候,线程池会创建新的非核心线程,直到线程总数达到 maximumPoolSize。
- 非核心线程空闲时间超过 keepAliveTime,就会被销毁,而核心线程默认不会被销毁。
- 注意:如果使用的是无界任务队列,这个参数不会生效,因为队列永远不会满,不会创建非核心线程。
3. keepAliveTime:非核心线程的空闲存活时间
- 非核心线程(超过 corePoolSize 的线程)空闲下来之后,最多存活多长时间,超过这个时间,就会被销毁,释放资源。
- 如果设置了
allowCoreThreadTimeOut=true,核心线程也会受这个参数的影响,空闲超过 keepAliveTime 也会被销毁。 - 这个参数需要配合 unit 参数使用,指定时间单位。
4. unit:keepAliveTime 的时间单位
- 是
java.util.concurrent.TimeUnit枚举类型,用于指定 keepAliveTime 的时间单位,常用的有:TimeUnit.MILLISECONDS:毫秒TimeUnit.SECONDS:秒TimeUnit.MINUTES:分钟
5. workQueue:任务等待队列(阻塞队列)
- 用于存放等待执行的任务的阻塞队列,当核心线程都在忙碌时,新提交的任务会进入这个队列排队等待执行。
- 这个队列是
BlockingQueue<Runnable>类型,不同的队列类型,会直接影响线程池的行为,常用的队列有:ArrayBlockingQueue:有界数组阻塞队列,必须指定容量LinkedBlockingQueue:链表阻塞队列,可以指定容量,默认是无界的SynchronousQueue:同步队列,不存储元素,每个插入操作必须等待对应的删除操作,相当于任务直接交给线程执行,不排队PriorityBlockingQueue:优先级无界阻塞队列,可以按照任务的优先级排序执行。
6. threadFactory:线程工厂
- 用于创建线程池中的线程的工厂,线程池创建新线程时,都是通过这个工厂的
newThread()方法创建的。 - 可以自定义线程工厂,给线程设置有意义的名称、设置线程的优先级、是否是守护线程、线程的异常处理器等,方便后续的日志排查和问题定位。
- 如果不指定,会使用默认的
Executors.defaultThreadFactory(),创建的线程名称是pool-<n>-thread-<m>,优先级都是默认的 5,非守护线程。
7. handler:拒绝策略(饱和处理策略)
- 当线程池达到饱和状态时(核心线程满、任务队列满、最大线程数也满了),新提交的任务无法处理,线程池会调用这个拒绝策略,处理无法执行的任务,是一种限流保护机制。
- 是
RejectedExecutionHandler接口的实现类,JDK 内置了 4 种默认的拒绝策略,也可以自定义实现这个接口,实现自己的拒绝逻辑。
3. 线程池的核心执行流程是什么?
答案:
当通过 execute() 方法向线程池提交一个新任务时,线程池的执行流程分为 4 步,严格按照核心参数的规则执行,流程如下:
-
判断核心线程数是否已满
- 检查当前线程池中的运行线程数,是否小于
corePoolSize核心线程数。 - 如果小于,即使有空闲的核心线程,也会新建一个核心线程来执行这个任务,直到线程数达到 corePoolSize。
- 如果已经达到 corePoolSize,核心线程已满,进入下一步。
- 检查当前线程池中的运行线程数,是否小于
-
判断任务队列是否已满
- 尝试将任务加入
workQueue任务等待队列。 - 如果入队成功,任务会在队列中排队,等待空闲的核心线程来获取执行。
- 如果队列已满,入队失败,进入下一步。
- 尝试将任务加入
-
判断最大线程数是否已满
- 检查当前线程池中的运行线程数,是否小于
maximumPoolSize最大线程数。 - 如果小于,会立即新建一个非核心线程,来执行这个任务。
- 如果已经达到 maximumPoolSize,线程池的线程数量已经达到上限,无法再创建新线程,进入下一步。
- 检查当前线程池中的运行线程数,是否小于
-
执行拒绝策略
- 此时,核心线程满、队列满、最大线程数也满了,线程池处于饱和状态,无法处理新提交的任务。
- 调用
RejectedExecutionHandler拒绝策略的rejectedExecution()方法,处理这个任务,默认的策略是抛出RejectedExecutionException异常。
流程图简化版:
提交任务 → 核心线程未满?→ 新建核心线程执行任务
↓ 否
任务队列未满?→ 加入队列等待执行
↓ 否
最大线程数未满?→ 新建非核心线程执行任务
↓ 否
执行拒绝策略
面试避坑点:
- 线程池是先排队,再创建非核心线程,而不是先创建非核心线程再排队,这个顺序非常重要,很多人会搞反。
- 如果使用无界队列,第二步永远不会入队失败,所以永远不会走到第三步和第四步,maximumPoolSize 参数无效,不会创建非核心线程,任务会一直加入队列,高并发下可能导致队列无限膨胀,内存溢出。
4. 线程池有哪些状态?状态流转是怎样的?
答案:
ThreadPoolExecutor 定义了 5 种线程池状态,用于表示线程池的运行情况,不同的状态下,线程池对任务的处理行为不同,状态流转有严格的规则。
线程池的状态存储在 AtomicInteger 类型的 ctl 变量中,ctl 的高 3 位存储线程池状态,低 29 位存储线程池中的线程数量。
5 种线程池状态详解
| 状态 | 状态值 | 核心含义 | 行为特点 |
|---|---|---|---|
| RUNNING | 111 | 运行状态,线程池正常工作,接受新任务,处理队列中的任务 | 线程池初始化后的默认状态,正常接收和处理任务,是最常用的状态 |
| SHUTDOWN | 000 | 关闭状态,调用 shutdown() 方法进入 | 不接受新提交的任务,但是会继续处理已经提交的任务,包括队列中等待的任务,直到所有任务处理完毕 |
| STOP | 001 | 停止状态,调用 shutdownNow() 方法进入 | 不接受新提交的任务,也不处理队列中的任务,会中断正在执行任务的线程,清空任务队列,尝试立刻停止线程池 |
| TIDYING | 010 | 整理状态,所有任务都已终止,工作线程数为 0,即将进入终止状态 | 当 SHUTDOWN 状态的队列和任务都执行完,或者 STOP 状态的线程都中断后,会进入这个状态,接下来会执行 terminated() 钩子方法 |
| TERMINATED | 011 | 终止状态,线程池彻底终止 | terminated() 方法执行完毕后,进入这个状态,线程池完全停止,生命周期结束 |
线程池状态流转规则
- RUNNING → SHUTDOWN:调用
shutdown()方法,或者线程池的 finalize() 方法被调用时触发。 - RUNNING → STOP:调用
shutdownNow()方法时触发。 - SHUTDOWN → TIDYING:当 SHUTDOWN 状态下,所有任务(包括队列中的任务)都执行完毕,所有工作线程都销毁,线程数为 0 时触发。
- STOP → TIDYING:当 STOP 状态下,所有正在执行的线程都被中断,任务队列被清空,线程数为 0 时触发。
- TIDYING → TERMINATED:当
terminated()钩子方法执行完成后触发,线程池彻底终止。
面试避坑点:
- SHUTDOWN 状态下,线程池不会再接受新任务,但是会把已经提交的任务(正在执行的+队列里的)全部执行完,再关闭;而 STOP 状态会直接中断正在执行的线程,清空队列,立刻停止,会导致任务丢失。
- 线程池一旦进入 SHUTDOWN/STOP 状态,就不会再回到 RUNNING 状态,状态流转是单向的,不可逆的。
5. JDK 内置的 4 种默认线程池是什么?各有什么特点?为什么阿里规范不推荐使用 Executors 创建线程池?
答案:
JDK 的 Executors 工具类,提供了 4 种常用的默认线程池创建方法,底层都是基于 ThreadPoolExecutor 封装的,分别是:newFixedThreadPool、newSingleThreadExecutor、newCachedThreadPool、newScheduledThreadPool。
1. newFixedThreadPool:固定线程数的线程池
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>());
}
核心特点:
- 核心线程数=最大线程数,都是传入的 nThreads,线程数量固定,不会创建非核心线程
- 非核心线程的空闲时间是 0,因为没有非核心线程
- 任务队列是无界的
LinkedBlockingQueue,默认容量是 Integer.MAX_VALUE - 所有提交的任务,都会由固定数量的线程执行,线程不会被销毁,即使长时间空闲。
适用场景:任务数量已知,长期执行的任务,需要控制并发线程数的场景,适合 CPU 密集型任务。
核心问题:任务队列是无界的,高并发下,任务会不断加入队列,导致队列无限膨胀,占用大量内存,最终引发 OOM。
2. newSingleThreadExecutor:单线程的线程池
public static ExecutorService newSingleThreadExecutor() {
return new FinalizableDelegatedExecutorService
(new ThreadPoolExecutor(1, 1,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>()));
}
核心特点:
- 核心线程数和最大线程数都是 1,整个线程池只有一个工作线程
- 任务队列是无界的
LinkedBlockingQueue - 保证所有任务按照提交的顺序,串行执行,因为只有一个线程
- 线程池会保证即使这个线程因为异常退出,也会创建一个新的线程,继续执行后续的任务,保证任务不会丢失。
适用场景:需要保证任务串行执行,严格按照提交顺序执行的场景。
核心问题:和 FixedThreadPool 一样,任务队列是无界的,高并发下会导致队列无限膨胀,引发 OOM。
3. newCachedThreadPool:缓存线程池
public static ExecutorService newCachedThreadPool() {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
60L, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>());
}
核心特点:
- 核心线程数是 0,最大线程数是 Integer.MAX_VALUE,相当于可以无限创建非核心线程
- 线程的空闲存活时间是 60 秒,空闲超过 60 秒的线程会被销毁
- 任务队列是
SynchronousQueue同步队列,这个队列不存储元素,每一个插入操作必须等待对应的删除操作,相当于任务不会排队,直接交给线程执行 - 提交任务时,如果有空闲线程,就用空闲线程执行;如果没有,就新建一个线程执行任务,线程数量几乎没有上限。
适用场景:大量短生命周期的异步任务,任务执行时间短,提交频率波动大的场景,追求高响应速度。
核心问题:最大线程数是无界的,高并发下,会创建大量的线程,每个线程占用栈内存,最终导致内存溢出 OOM;同时大量线程会导致 CPU 频繁上下文切换,系统性能急剧下降。
4. newScheduledThreadPool:定时任务线程池
public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) {
return new ScheduledThreadPoolExecutor(corePoolSize);
}
ScheduledThreadPoolExecutor 继承自 ThreadPoolExecutor,核心是支持定时执行、周期执行任务,底层使用延迟队列 DelayedWorkQueue。
核心特点:
- 核心线程数由传入的 corePoolSize 指定,最大线程数是 Integer.MAX_VALUE
- 任务队列是无界的延迟队列
DelayedWorkQueue - 支持
schedule()延迟执行任务,scheduleAtFixedRate()固定频率周期执行,scheduleWithFixedDelay()固定延迟周期执行。
适用场景:需要定时执行、周期执行的任务场景,替代 Timer 定时器,因为 Timer 是单线程的,且异常会导致整个定时器停止,而 ScheduledThreadPoolExecutor 更稳定,支持多线程。
核心问题:最大线程数是 Integer.MAX_VALUE,延迟队列是无界的,高并发下可能导致 OOM;同时周期任务如果执行时间过长,会导致任务堆积,影响定时的准确性。
为什么阿里 Java 开发规范不推荐使用 Executors 创建线程池?
核心原因是:Executors 创建的默认线程池,都存在资源耗尽的风险,参数设置不合理,会导致线上故障,具体如下:
- 无界队列导致 OOM 风险:
newFixedThreadPool和newSingleThreadExecutor使用了无界的LinkedBlockingQueue,默认容量是 Integer.MAX_VALUE,高并发下任务会不断堆积到队列中,占用大量内存,最终引发 OOM,且队列满了才会触发拒绝策略,无界队列永远不会满,拒绝策略永远不会生效,无法起到限流保护的作用。 - 无界线程数导致 OOM 和 CPU 飙升:
newCachedThreadPool和newScheduledThreadPool的最大线程数是 Integer.MAX_VALUE,高并发下会创建大量的线程,每个线程默认分配 1M 的栈内存,大量线程会直接耗尽内存,引发 OOM;同时,大量线程会导致 CPU 频繁进行上下文切换,系统负载飙升,响应变慢,甚至崩溃。 - 默认参数无法适配业务场景:不同的业务场景,需要的核心线程数、队列类型、拒绝策略、线程命名都不同,Executors 的默认参数是通用的,无法针对业务场景优化,比如 IO 密集型和 CPU 密集型任务,线程池参数配置完全不同,默认参数无法满足。
- 缺乏自定义能力,不利于问题排查:默认的线程工厂创建的线程名称是通用的,没有业务标识,出现线上问题时,无法通过线程名称定位到对应的业务模块,排查问题困难;同时默认的拒绝策略是抛出异常,无法自定义降级处理逻辑。
规范推荐的做法:
直接使用 ThreadPoolExecutor 的构造方法创建线程池,自己指定核心线程数、最大线程数、使用有界的任务队列、自定义线程工厂(设置业务相关的线程名称)、自定义拒绝策略,根据业务场景合理配置参数,同时做好线程池的监控,避免资源耗尽的风险。
6. 线程池的 4 种默认拒绝策略是什么?如何自定义拒绝策略?
答案:
拒绝策略是线程池的饱和保护机制,当线程池的核心线程满、任务队列满、最大线程数也满了,无法处理新提交的任务时,会调用拒绝策略,处理无法执行的任务。
JDK 内置了 4 种 RejectedExecutionHandler 接口的实现类,作为默认的拒绝策略,也可以自定义实现这个接口,实现自己的拒绝逻辑。
1. 4 种默认拒绝策略
| 拒绝策略类 | 核心行为 | 特点 | 适用场景 |
|---|---|---|---|
| AbortPolicy(默认策略) | 直接抛出 RejectedExecutionException 运行时异常,终止任务的提交 | 线程池默认的拒绝策略,直接抛出异常,让调用方感知到线程池已经饱和,需要处理 | 绝大多数业务场景,需要明确感知到任务被拒绝,做异常处理,避免任务丢失 |
| CallerRunsPolicy | 由提交任务的调用方线程,直接执行这个被拒绝的任务 | 不会丢弃任务,也不会抛出异常,任务会由调用线程执行,这样会减慢任务提交的速度,给线程池缓冲的时间,起到限流的作用,避免任务丢失 | 对任务丢失要求低,不允许任务失败,流量平稳的场景,适合非核心的、不紧急的任务 |
| DiscardPolicy | 直接静默丢弃被拒绝的任务,不做任何处理,也不抛出异常 | 最粗暴的策略,任务直接被丢弃,调用方完全感知不到,会导致任务丢失,且无法追踪 | 完全不重要的任务,比如日志上报、非核心的统计,丢失了也不影响业务的场景 |
| DiscardOldestPolicy | 丢弃任务队列中等待最久的最老的任务(队头的任务),然后重新尝试提交当前被拒绝的任务 | 会丢弃队列中等待最久的任务,给新任务腾位置,可能会导致任务丢失,且打乱任务的执行顺序 | 任务执行优先级相同,允许丢弃旧任务,优先处理新任务的场景,极少使用 |
2. 如何自定义拒绝策略
自定义拒绝策略非常简单,只需要实现 RejectedExecutionHandler 接口,重写 rejectedExecution() 方法,在方法中实现自己的处理逻辑即可。
rejectedExecution() 方法有两个参数:
Runnable r:被拒绝的任务对象ThreadPoolExecutor executor:当前的线程池实例,可以获取线程池的状态、队列、线程数等信息,做对应的处理。
自定义拒绝策略示例:
- 自定义日志+降级策略:记录被拒绝的任务日志,然后交给调用线程执行,避免任务丢失
public class CustomRejectPolicy implements RejectedExecutionHandler {
private static final Logger logger = LoggerFactory.getLogger(CustomRejectPolicy.class);
@Override
public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
// 1. 记录详细的拒绝日志,方便排查问题
logger.error("线程池任务被拒绝!线程池状态:{},核心线程数:{},最大线程数:{},当前线程数:{},队列剩余容量:{}",
executor.isShutdown() ? "已关闭" : "运行中",
executor.getCorePoolSize(),
executor.getMaximumPoolSize(),
executor.getPoolSize(),
executor.getQueue().remainingCapacity());
// 2. 降级处理:如果线程池没有关闭,由调用线程执行任务
if (!executor.isShutdown()) {
r.run();
}
}
}
- 自定义持久化策略:将被拒绝的任务持久化到数据库/消息队列,后续重试执行,保证任务不丢失
public class PersistRejectPolicy implements RejectedExecutionHandler {
private final TaskRepository taskRepository;
public PersistRejectPolicy(TaskRepository taskRepository) {
this.taskRepository = taskRepository;
}
@Override
public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
// 将任务持久化到数据库,后续通过定时任务重试执行
if (r instanceof MyBusinessTask) {
MyBusinessTask task = (MyBusinessTask) r;
taskRepository.save(task);
}
}
}
- 自定义阻塞等待策略:阻塞调用线程,等待队列有空闲位置,再提交任务,实现背压限流
public class BlockingRejectPolicy implements RejectedExecutionHandler {
@Override
public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
if (!executor.isShutdown()) {
try {
// 阻塞等待,直到队列有空间,将任务加入队列
executor.getQueue().put(r);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RejectedExecutionException("任务入队被中断", e);
}
}
}
}
使用自定义拒绝策略:
创建线程池时,将自定义的拒绝策略传入构造方法即可:
ThreadPoolExecutor executor = new ThreadPoolExecutor(
10,
20,
60L,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(100),
new CustomThreadFactory("business-pool"),
new CustomRejectPolicy() // 使用自定义拒绝策略
);
7. 线程池的核心参数如何合理配置?(CPU 密集型、IO 密集型)
答案:
线程池参数没有万能的固定值,核心是根据任务的类型(CPU 密集/IO 密集)、系统的 CPU 核心数、业务的 QPS、任务执行时间、系统的资源限制来综合配置,核心目标是:既充分利用系统资源,提升吞吐量,又不会因为线程过多导致系统资源耗尽,保证系统稳定。
首先明确两个核心概念:
- CPU 密集型任务:任务主要消耗 CPU 资源,比如复杂的计算、数据加密、压缩、数值计算等,任务执行过程中,CPU 一直处于忙碌状态,IO 操作很少,执行时间短。
- IO 密集型任务:任务主要进行 IO 操作,比如数据库读写、网络请求、文件读写、RPC 调用等,任务执行过程中,CPU 大部分时间处于空闲状态,等待 IO 返回,执行时间相对较长。
1. 核心参数配置的核心公式与原则
(1)CPU 密集型任务
- 核心原则:线程数不宜过多,因为 CPU 一直处于忙碌状态,过多的线程会导致频繁的上下文切换,反而降低性能。
- 经典配置公式:
- 核心线程数 = CPU 核心数 + 1
- 最大线程数 = CPU 核心数 + 1
- 队列容量:根据任务数量和执行时间配置,建议使用有界队列,容量设置为预期的最大排队任务数,避免堆积。
- 为什么+1:即使是 CPU 密集型任务,线程也可能因为页缺失、或者其他原因出现短暂的空闲,多出来的 1 个线程可以充分利用 CPU 的空闲时间,保证 CPU 的利用率达到 100%。
- 示例:8 核 CPU 的服务器,CPU 密集型任务的线程池,核心线程数和最大线程数都设置为 9 即可。
(2)IO 密集型任务
- 核心原则:线程数可以设置多一些,因为任务大部分时间在等待 IO,CPU 空闲,多的线程可以利用 CPU 的空闲时间,处理更多的任务,提升吞吐量。
- 经典配置公式:
- 基础公式:核心线程数 = CPU 核心数 * 2
- 精准公式(推荐):核心线程数 = CPU 核心数 * (1 + 任务平均等待时间 / 任务平均 CPU 计算时间)
- 任务平均等待时间:任务执行中,IO 等待的时间(比如数据库查询、RPC 调用的时间)
- 任务平均 CPU 计算时间:任务执行中,CPU 计算的时间
- 举个例子:一个任务,RPC 调用等待时间是 800ms,CPU 计算时间是 200ms,那么等待时间/计算时间=4,8 核 CPU 的话,核心线程数=8*(1+4)=40。
- 最大线程数:可以和核心线程数一致,或者略高一些,但是不宜设置过大,避免线程过多导致内存和上下文切换开销过大。
- 队列容量:建议使用有界队列,根据任务的 QPS 和峰值流量设置,避免任务堆积过多,导致内存溢出,同时设置合理的拒绝策略。
(3)混合型任务
如果任务中既有 CPU 计算,又有 IO 操作,属于混合型任务,最优的方案是将任务拆分,把 CPU 密集型和 IO 密集型的任务,分别用不同的线程池来执行,各自配置对应的参数,避免互相影响。
如果无法拆分,就按照 IO 密集型的配置来,因为大部分业务场景的任务,都是 IO 密集型的,CPU 计算时间占比很低。
2. 其他核心参数的配置原则
(1)keepAliveTime 空闲存活时间
- 核心是根据任务的提交频率来设置,任务提交频繁、峰值波动大,可以设置长一些,比如 60s,避免频繁创建和销毁线程;
- 如果任务是间歇性的,峰值过后长时间没有任务,可以设置短一些,比如 10s,尽快释放空闲的非核心线程,节省内存资源;
- 一般业务场景,设置 60s 是比较通用的选择。
(2)workQueue 任务队列
- 队列类型选择:
- 优先选择
ArrayBlockingQueue有界数组队列,必须指定容量,内存占用连续,性能稳定,可以控制任务堆积的最大数量,触发拒绝策略,起到限流保护的作用,是线上业务的首选。 LinkedBlockingQueue链表队列,即使设置了容量,内存占用也比数组队列高,节点对象会频繁创建和销毁,GC 压力大,除非是任务执行时间波动大的场景,否则不推荐。SynchronousQueue同步队列,不存储任务,直接交给线程执行,适合任务执行时间短、提交频率高的场景,需要配合较大的最大线程数使用,比如 CachedThreadPool 用的就是这个队列,但是要注意控制最大线程数,避免 OOM。- 优先级队列只适合需要按优先级执行任务的特殊场景。
- 优先选择
- 队列容量设置:
- 队列容量不是越大越好,容量太大,会导致任务堆积,任务的等待时间变长,超时的任务变多,甚至内存溢出;容量太小,会频繁触发拒绝策略,创建非核心线程,影响系统稳定性。
- 通用的设置思路:根据任务的平均执行时间、线程池的线程数、可接受的最大等待时间来计算。
举个例子:核心线程数 20,每个任务平均执行时间 100ms,那么每秒最多能处理 2010=200 个任务;如果可接受的任务最大等待时间是 2s,那么队列容量可以设置为 2002=400,保证队列里的任务最多等待 2s 就能被执行。
(3)threadFactory 线程工厂
- 线上业务必须自定义线程工厂,给线程设置有业务含义的名称前缀,比如
user-service-pool-、order-query-pool-,这样在线程 dump、日志排查时,可以快速定位到对应的业务模块和线程池,极大提升问题排查效率。 - 同时可以给线程设置未捕获异常处理器
UncaughtExceptionHandler,处理线程执行任务时抛出的未捕获异常,避免异常丢失,方便问题定位。 - 自定义线程工厂示例:
public class CustomThreadFactory implements ThreadFactory {
private final String namePrefix;
private final AtomicInteger threadNumber = new AtomicInteger(1);
public CustomThreadFactory(String namePrefix) {
this.namePrefix = namePrefix;
}
@Override
public Thread newThread(Runnable r) {
Thread thread = new Thread(r, namePrefix + threadNumber.getAndIncrement());
// 设置线程优先级,默认即可,不建议修改
thread.setPriority(Thread.NORM_PRIORITY);
// 设置为非守护线程,默认即可
thread.setDaemon(false);
// 设置未捕获异常处理器
thread.setUncaughtExceptionHandler((t, e) -> {
// 记录异常日志
logger.error("线程{}执行任务时发生未捕获异常", t.getName(), e);
});
return thread;
}
}
(4)handler 拒绝策略
- 核心业务场景,优先使用
AbortPolicy默认策略,抛出异常,让业务方感知到线程池饱和,做降级、限流处理,避免任务丢失而业务无感知。 - 非核心任务,不允许任务丢失的场景,可以使用
CallerRunsPolicy,由调用线程执行,起到背压限流的作用,减慢任务提交速度。 - 完全不重要的任务,比如日志、统计,可以使用
DiscardPolicy,直接丢弃,不影响核心业务。 - 对任务可靠性要求极高的场景,自定义拒绝策略,将任务持久化,后续重试执行,保证任务不丢失。
3. 线上配置的核心注意事项与优化建议
- 避免使用无界队列和无界最大线程数:这是线上最常见的坑,无论什么场景,都要设置有界的队列和合理的最大线程数,避免 OOM。
- 参数配置不是一成不变的,需要压测验证:根据公式计算出来的参数,只是一个初始值,必须通过压测,模拟线上的峰值流量,观察 CPU 使用率、内存、GC、系统吞吐量、响应时间等指标,不断调整参数,找到最优的配置。
- 不同的业务模块,使用不同的线程池:不要所有业务都共用一个线程池,避免某个业务出现问题,耗尽线程池资源,导致其他业务全部受影响,也就是线程池的隔离。比如核心的订单支付业务,和非核心的日志统计业务,必须分开使用不同的线程池。
- 做好线程池的监控:线上运行的线程池,必须监控核心指标,比如:线程池的当前线程数、核心线程数、最大线程数、活跃线程数、任务队列的大小、队列剩余容量、已完成的任务数、拒绝的任务数等,通过监控及时发现线程池的瓶颈、异常,比如队列堆积、任务被频繁拒绝、线程数打满等问题。
- 避免在任务中创建子线程池:任务执行过程中,不要创建线程池,会导致线程数不可控,资源耗尽,线程池应该在系统启动时创建,全局复用。
- 核心线程数不宜设置过大:即使是 IO 密集型任务,核心线程数也不宜设置过大,一般不超过 CPU 核心数的 50 倍,过多的线程会占用大量的内存,同时导致 CPU 上下文切换的开销急剧增加,反而降低系统性能。
8. 线程池中的线程复用原理是什么?
答案:
线程池的核心优势之一就是线程复用,避免了频繁创建和销毁线程的开销。线程池中的线程,执行完一个任务不会立即销毁,而是循环等待并执行新任务,核心原理基于 ThreadPoolExecutor 内部的 Worker 类和 runWorker() 方法。
核心实现逻辑
-
Worker 类的设计
Worker是线程池的内部类,继承自AQS并实现了Runnable,它封装了一个Thread(实际执行的线程)和一个firstTask(初始任务)。- 当提交任务需要创建新线程时,线程池会创建一个
Worker对象,将任务赋值给firstTask,并启动Worker内部的Thread。
-
runWorker():线程复用的核心循环
Worker启动后,会调用runWorker(this)方法,进入一个无限循环:final void runWorker(Worker w) { Thread wt = Thread.currentThread(); Runnable task = w.firstTask; w.firstTask = null; w.unlock(); // 允许中断 boolean completedAbruptly = true; try { // 核心循环:只要能获取到任务,就一直执行 while (task != null || (task = getTask()) != null) { w.lock(); // ... 省略中断检查、钩子方法等逻辑 try { beforeExecute(wt, task); Throwable thrown = null; try { task.run(); // 直接调用任务的 run(),而非 start() } catch (RuntimeException | Error | Throwable x) { thrown = x; throw x; } finally { afterExecute(task, thrown); } } finally { task = null; w.completedTasks++; w.unlock(); } } completedAbruptly = false; } finally { processWorkerExit(w, completedAbruptly); } }- 线程先执行初始化的
firstTask。 - 之后不断调用
getTask()从workQueue阻塞队列中获取新任务。 - 只要
getTask()返回非 null,线程就会继续执行下一个任务,不会销毁。
- 线程先执行初始化的
-
getTask():控制线程回收与复用的边界
getTask()方法决定了线程是继续复用还是退出销毁:- 如果当前线程数 >
corePoolSize,且线程空闲时间超过keepAliveTime,则返回null,触发线程退出。 - 如果是核心线程,且未设置
allowCoreThreadTimeOut,则会一直阻塞在队列的take()上,永久等待新任务,实现核心线程的永久复用。 - 如果线程池正在关闭,则返回
null,触发线程退出。
- 如果当前线程数 >
一句话总结
线程池的线程复用,本质是Worker 线程在一个 while 循环中,不断从阻塞队列获取任务并执行,只要队列有任务或线程未超时,线程就会一直存活并复用,只有在特定条件下(超时、线程池关闭)才会退出销毁。
七、并发集合(高频)
1. 为什么不推荐在多线程下使用普通 HashMap?会有什么问题?
答案:
- 多线程同时 put 触发扩容,可能形成 环形链表,导致
get()时死循环(CPU 100%)。 - 多线程 put 会导致 数据覆盖、丢失。
- key 为 null、value 为 null 不影响线程安全,但本身不具备任何同步机制。
结论:并发场景绝对不能用 HashMap。
2. ConcurrentHashMap 底层原理(JDK 1.7 vs 1.8)
1.7 结构
- 分段锁 Segment + 数组 + 链表
- 每个 Segment 是一把独立的 ReentrantLock。
- 锁粒度:段(Segment),默认 16 段,并发度 16。
1.8 结构
- 数组 + 链表 + 红黑树
- 锁:CAS + synchronized
- 锁粒度:数组头节点(桶),粒度更细。
1.8 核心优化
- 锁粒度从段 → 桶,并发更高。
- 链表长度 ≥ 8 且数组长度 ≥ 64 转为红黑树。
- 不允许 key/value 为 null(避免歧义:是没放进去还是本来就是 null)。
- 扩容采用 多线程协助扩容,效率更高。
为什么 1.8 用 synchronized 而不是 ReentrantLock?
- synchronized 不断被 JVM 优化,性能已不弱。
- 代码更简洁、更轻量,不需要维护 AQS 队列。
- 锁升级(偏向→轻量→重量)在短时间锁定场景优势巨大。
3. ConcurrentHashMap get 方法为什么不用加锁?
答案:
- 数组用 volatile 修饰,保证可见性。
- Node 的
next、val都是 volatile。 - 查询过程:
- 计算 hash
- 定位桶
- 遍历链表/红黑树
- 整个过程只读、不修改,且 volatile 保证读到最新数据,所以不需要加锁。
4. ConcurrentHashMap 如何保证扩容安全?
答案:
- 扩容时会用一个 forwarding node(占位节点) 标记当前桶正在迁移。
- 其他线程 put/get 遇到占位节点,协助一起扩容,而不是阻塞。
- 采用 高低位拆分,把旧桶链表拆成两条,分别放到新数组。
5. 其他常见并发集合
- CopyOnWriteArrayList
- 写时复制:写操作加锁,复制新数组,替换引用。
- 读无锁,性能极高。
- 适用:读多写少,不要求实时强一致。
- CopyOnWriteArraySet
- 底层就是 CopyOnWriteArrayList。
- ConcurrentSkipListSet / ConcurrentSkipListMap
- 跳表结构,高并发有序。
- 并发性能远好于 Collections.synchronizedSortedMap。
八、同步工具类
1. CountDownLatch(倒计时门闩)
- 作用:允许一个或多个线程等待其他线程完成操作。
- 原理:初始化计数器,
await()阻塞直到计数器归零,countDown()减少计数。 - 适用场景:主线程等待所有子任务完成后再继续。
2. CyclicBarrier(循环栅栏)
- 作用:让一组线程互相等待,直到所有线程都到达某个屏障点,然后同时继续执行。
- 特点:可重用(计数器重置),可传入一个 Runnable,当所有线程到达后优先执行该任务。
- 适用场景:多线程计算,最后合并结果。
import java.util.concurrent.CyclicBarrier;
public class CyclicBarrierExample {
public static void main(String[] args) {
int parties = 3;
CyclicBarrier barrier = new CyclicBarrier(parties, () -> System.out.println("所有线程到达屏障点"));
for (int i = 0; i < parties; i++) {
new Thread(() -> {
try {
System.out.println("线程 " + Thread.currentThread().getId() + " 开始等待");
barrier.await(); // 等待所有线程到达屏障点
System.out.println("线程 " + Thread.currentThread().getId() + " 继续执行");
} catch (Exception e) {
e.printStackTrace();
}
}).start();
}
}
}
3. Semaphore(信号量)
- 作用:控制同时访问特定资源的线程数量(限流)。
- 方法:
acquire()获取许可,release()释放许可。 - 适用场景:数据库连接池、限流器。
4. 三者对比(面试必问)
- CountDownLatch:一次性、A 等 B。
- CyclicBarrier:可循环、互相等待。
- Semaphore:控制并发数。
九、ThreadLocal(高频)
1. ThreadLocal 作用
- 线程本地变量,每个线程独立副本,无竞争、无线程安全问题。
2. 底层结构
- 每个 Thread 内部有
ThreadLocalMap。 - key 是 弱引用 的 ThreadLocal 对象。
- value 是强引用。
3. 内存泄漏原因
- ThreadLocal 被回收后,key 为 null。
- value 仍被 Thread → ThreadLocalMap → Entry → value 强引用。
- 只要线程不退出,value 永远无法 GC。
4. 如何避免内存泄漏
- 使用完必须调用 remove()。
- 不要在线程池里滥用 ThreadLocal。
十、Fork/Join 框架
1. 核心思想
- 分治 + 工作窃取。
- 大任务拆小任务,并行计算,最后合并结果。
2. 核心类
ForkJoinPool线程池。ForkJoinTask任务抽象。RecursiveTask(有返回值)。RecursiveAction(无返回值)。
3. 适用场景
- 纯计算、CPU 密集、可拆分。
- 如:大数组排序、海量数据计算。
十一、线程安全总结(面试收尾万能句)
实现线程安全的 4 种方式
- 互斥阻塞:synchronized、Lock(悲观锁)。
- 非阻塞同步:CAS、AtomicXXX(乐观锁)。
- 无同步方案:
- 栈封闭(局部变量)
- 不可变类(String、Integer、final)
- 线程本地存储:ThreadLocal。
十二、高频综合题(大厂最爱问)
1. synchronized 和 ReentrantLock 区别
- synchronized:JVM 实现、自动释放、非公平、不可中断、一个等待队列。
- ReentrantLock:JDK 实现、手动 unlock、公平/非公平可选、可中断、多 Condition、可超时。
2. 死锁的 4 个条件
- 互斥
- 持有并等待
- 不可剥夺
- 循环等待
破坏任意一个即可避免死锁。
3. 如何避免死锁
- 固定加锁顺序
- 使用
tryLock(timeout) - 避免嵌套锁
- 减少锁范围
4. 什么是伪共享?如何避免?
- 多个变量装入同一个 CPU 缓存行。
- 一个修改,导致其他核缓存失效。
- 避免:缓存行填充(JDK8+ 使用
@Contended)。
十三、中高级必问深度题
1. AQS 为什么要用双向链表?
- 方便 取消节点时快速删除。
- 从后往前找有效节点。
- 支持
unparkSuccessor高效唤醒。
2. 为什么 AQS 队列是从尾部入队?
- 保证 FIFO。
- CAS 只需要在尾部单点竞争,冲突最小。
3. 读写锁为什么不支持升级(读 → 写)?
- 多个线程都持读锁,同时想升级,会互相等待 → 死锁。
- 只支持降级(写 → 读)。
4. StampedLock 核心优势
- 乐观读不加锁,读不阻塞写,解决写饥饿。
- 比 ReadWriteLock 吞吐量更高。
- 注意:不可重入、不支持 Condition。

1547

被折叠的 条评论
为什么被折叠?



