1. 是什么:Java 线程模型(⭐)
1.1 线程 vs 进程
- 进程:资源分配最小单位(独立地址空间)。
- 线程:CPU 调度最小单位(共享进程地址空间)。
- Java 线程 = 内核线程(1:1 模型) ,
Thread.start()最终调用pthread_create。
JDK 21+ 的虚拟线程(Virtual Thread / Project Loom)是 M:N 模型,面试中需区分。
1.2 创建线程的方式
| 方式 | 特点 |
|---|---|
extends Thread |
最原始,无法继承其他类 |
implements Runnable |
推荐:任务与线程分离 |
implements Callable<V> + FutureTask |
有返回值 / 可抛异常 |
ExecutorService.submit() |
生产环境标准做法(→ 07) |
2. 线程状态机(⭐⭐)
2.1 六种状态 (Thread.State)
2.2 面试必背:BLOCKED vs WAITING
| 状态 | 触发 | 区别 |
|---|---|---|
| BLOCKED | 等待进入 synchronized 块 |
被动等锁 |
| WAITING | 主动调用 wait()/park()/join() |
主动放弃 CPU |
关键区分:BLOCKED 只属于
synchronized
ReentrantLock.lock()竞争时线程状态是 WAITING (底层LockSupport.park()),不是 BLOCKED。 所有j.u.c的锁(ReentrantLock、Semaphore、CountDownLatch等)竞争时都是 WAITING/TIMED_WAITING。
BLOCKED vs WAITING 谁更浪费性能?
挂起后都不占 CPU,区别在进出开销:
| 维度 | BLOCKED(synchronized) |
WAITING(AQS / park) |
|---|---|---|
| 挂起时 CPU | 不占 | 不占 |
| 进入前 | 锁膨胀:偏向锁 → 轻量锁(CAS 自旋,烧 CPU)→ 重量级(OS mutex) | CAS 入队 + park(),路径固定,无膨胀 |
| 唤醒方式 | notify() → 被唤醒后仍需重新竞争 monitor,可能惊群(多线程唤醒只一个成功) |
unpark() 精确唤醒一个线程,无惊群 |
| 内核态切换 | 重量级锁 = OS mutex → 每次加锁/解锁都可能用户态 ↔ 内核态切换 | park/unpark 也涉及内核态,但 AQS 队列减少了无效唤醒次数 |
| 灵活性 | 不可中断、不可超时、不可尝试 | 可中断 / 可超时 / 可公平 |
一句话 :挂起后开销一样;但 BLOCKED 的进出成本更高 (锁膨胀 + mutex + 惊群),高并发下
ReentrantLock通常优于synchronized。
| 场景 | 线程状态 | 底层机制 |
|---|---|---|
等待 synchronized |
BLOCKED | monitorenter → OS mutex |
ReentrantLock.lock() 竞争 |
WAITING | AQS → LockSupport.park() |
ReentrantLock.tryLock(timeout) |
TIMED_WAITING | AQS → LockSupport.parkNanos() |
jstack 中的表现:
scss
// synchronized 竞争
"thread-1" BLOCKED (on object monitor)
waiting to lock <0x76ab1e8> (a java.lang.Object)
// ReentrantLock 竞争
"thread-2" WAITING (parking)
at sun.misc.Unsafe.park
- parking to wait for <0x76cd320> (a j.u.c.locks.ReentrantLock$NonfairSync)
追问 :
jstack中看到大量 BLOCKED →synchronized锁竞争;大量 WAITING (parking) → 可能j.u.c锁竞争、池耗尽或依赖阻塞。
2.3 线程阻塞全景:不止 synchronized
常见误区 :只有
synchronized才会导致线程阻塞。实际上synchronized只是唯一能让线程进入 BLOCKED 状态的原因;广义上"线程无法继续执行"的机制还有很多。
| 类别 | 阻塞方式 | JVM 线程状态 | 底层机制 |
|---|---|---|---|
| 同步锁 | synchronized 竞争 |
BLOCKED | monitorenter → OS mutex |
ReentrantLock.lock() |
WAITING | AQS → LockSupport.park() |
|
ReentrantLock.tryLock(timeout) |
TIMED_WAITING | AQS → LockSupport.parkNanos() |
|
| 等待/通知 | Object.wait() |
WAITING | 释放 monitor,进入 wait set |
Condition.await() |
WAITING | 释放 AQS 锁,LockSupport.park() |
|
Thread.join() |
WAITING | 内部调 wait() |
|
LockSupport.park() |
WAITING | 底层阻塞原语(Unsafe.park) |
|
| Sleep | Thread.sleep(n) |
TIMED_WAITING | 不释放锁,让出 CPU |
| I/O | InputStream.read() / Socket.accept() |
RUNNABLE(!) | 阻塞在 OS 系统调用(见下文详解) |
| 并发工具 | CountDownLatch.await() |
WAITING | AQS 共享模式 |
CyclicBarrier.await() |
WAITING | ReentrantLock + Condition |
|
Semaphore.acquire() |
WAITING | AQS 共享模式 | |
Future.get() / CompletableFuture.join() |
WAITING | LockSupport.park() |
|
BlockingQueue.take() / put() |
WAITING | ReentrantLock + Condition |
关键结论:
- BLOCKED 状态只有
synchronized竞争才会产生。 j.u.c的所有锁/工具竞争时都是 WAITING / TIMED_WAITING (基于 AQS +LockSupport.park())。- I/O 阻塞最具迷惑性(详见下文)。
I/O 阻塞为什么显示 RUNNABLE?------与 DMA 的关系
完整流程(以 BIO socket.read() 为例):
scss
Java 线程调用 read()
→ JNI native 方法
→ read() 系统调用 (syscall)
→ 内核:数据未就绪,将线程置为 TASK_INTERRUPTIBLE(睡眠态)
→ DMA 控制器:异步搬运数据 设备 → 内核缓冲区(CPU 不参与搬运)
→ DMA 完成,触发硬件中断
→ 内核:唤醒线程(TASK_RUNNING),复制数据 内核缓冲区 → 用户空间
→ syscall 返回 → JNI 返回 → Java 代码继续执行
关键点:
| 问题 | 答案 |
|---|---|
| 线程在 I/O 等待期间占 CPU 吗? | 不占 。内核将线程从调度队列移除(TASK_INTERRUPTIBLE),不分配时间片 |
| 数据搬运谁做的? | DMA 控制器,CPU 不参与数据搬运(只负责发起命令和处理中断) |
| 为什么 JVM 报 RUNNABLE? | Thread.State 设计粒度不区分"执行中"和"native 方法内被 OS 挂起",统一归 RUNNABLE |
jstack 能看出来吗? |
能。虽然状态是 RUNNABLE,但栈帧会显示 java.net.SocketInputStream.read → native → 可判断在做 I/O |
对比 NIO:
java
// BIO:线程阻塞在 read(),OS 层睡眠,JVM 报 RUNNABLE
inputStream.read(buf); // 线程卡在这里,不占 CPU,但"占用"了一个线程
// NIO:Selector.select() 一个线程管理多连接
selector.select(); // 底层 epoll_wait(),同样 OS 层睡眠,JVM 同样报 RUNNABLE
// 但区别是一个线程处理成千上万连接
面试加分------BIO 性能崩溃的真正原因:
I/O 等待期间单个线程确实不耗 CPU,但 BIO 要求每连接一个线程 → 高并发时引发连锁反应:
markdown连接数↑ → 线程数↑ → ① 内存爆炸(1万线程 ≈ 10GB 栈) → ② 上下文切换风暴(sys% CPU 飙高,全浪费在调度上) → 吞吐量反而↓
- ① 内存:每个线程 ~1MB 栈,1万连接就 ~10GB,还没干业务就 OOM。
- ② 上下文切换 :1万线程频繁切换 → 保存/恢复寄存器、TLB 刷新、cache 失效 → CPU 时间花在调度而非业务上。这才是"多线程性能下降"的元凶,不是 I/O 等待本身。
NIO 的本质 :通过
epoll/Selector让线程数与连接数解耦(1万连接 → 1 个 Selector + 少量 Worker)→ 线程少 → 内存省 + 切换少 → CPU 全花在业务上 → 瓶颈回归到 CPU 计算能力本身。
BIO NIO 线程数 = 连接数 远 < 连接数 1万连接内存 ~10GB(线程栈) ~几十 MB 1万连接 CPU 大量浪费在上下文切换 几乎无切换开销 瓶颈 线程资源(内存 + 切换) CPU 计算能力
3. 中断机制(⭐⭐)
3.1 中断 ≠ 强停
Thread.interrupt() 只是设置标志位 + 如果线程在 wait/sleep/join 则抛 InterruptedException。
3.2 正确响应中断的模板
java
while (!Thread.currentThread().isInterrupted()) {
try {
// 可能阻塞的操作
queue.take();
} catch (InterruptedException e) {
// 策略1:清理后退出
Thread.currentThread().interrupt(); // 恢复标志
break;
}
}
3.3 两个易混淆 API
| API | 区别 |
|---|---|
Thread.interrupted() |
静态方法 ,返回并清除标志 |
thread.isInterrupted() |
实例方法 ,仅查询不清除 |
经典坑 :捕获
InterruptedException后忘记恢复标志 → 上层循环检测不到中断,线程"僵尸"运行。
4. ThreadLocal 深度剖析(⭐⭐⭐)
4.1 使用场景
| 场景 | 典型案例 | 为什么用 ThreadLocal |
|---|---|---|
| 线程隔离的上下文 | 用户身份、traceId、RequestContext | 避免在方法签名中层层传参 |
| 线程不安全对象复用 | SimpleDateFormat、DecimalFormat、Random |
每线程一份,避免加锁又避免反复创建 |
| 数据库连接/事务绑定 | Spring @Transactional → TransactionSynchronizationManager |
同一线程内多个 DAO 共享同一个 Connection |
| Session / 安全上下文 | Spring Security SecurityContextHolder |
默认 MODE_THREADLOCAL 策略 |
| 性能优化 | Netty FastThreadLocal、Recycler 对象池 |
减少竞争,缓存线程私有对象 |
4.2 底层数据结构(源码级)
yaml
Thread 对象
└─ threadLocals: ThreadLocal.ThreadLocalMap
└─ Entry[] table (初始容量 16,2 的幂次扩容)
├─ Entry[i] { key: WeakReference<ThreadLocal<?>>, value: Object }
├─ Entry[j] { key: WeakReference<ThreadLocal<?>>, value: Object }
└─ ...
关键设计:
| 设计点 | 实现 | 为什么 |
|---|---|---|
| 哈希表 | 开放地址法(线性探测),非链表法 | 期望条目少,cache 友好 |
| 哈希函数 | ThreadLocal.threadLocalHashCode,使用黄金分割数 0x61c88647 递增 |
Fibonacci hashing,分布均匀 |
| key 弱引用 | Entry extends WeakReference<ThreadLocal<?>> |
ThreadLocal 实例被回收时 key 可以被 GC |
| 扩容阈值 | threshold = len * 2 / 3(约 66%) |
开放地址法需要较低负载因子 |
java
// ThreadLocal.get() 简化流程
public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = t.threadLocals; // 从当前线程取 map
if (map != null) {
Entry e = map.getEntry(this); // this 就是 ThreadLocal 实例作为 key
if (e != null) return (T) e.value;
}
return setInitialValue(); // 首次访问,调 initialValue()
}
4.3 内存泄漏:完整引用链分析
WeakRef"| TL["ThreadLocal 实例"] end style VALUE fill:#ff6b6b style TL fill:#ffd93d
泄漏时序:
- 业务代码中
static final ThreadLocal<Obj> TL = ...→ 一般不会被 GC(强引用在类上)→ 不泄漏。 - 真正危险 :方法内局部
ThreadLocal<byte[]> tl = new ThreadLocal<>(),方法返回后tl引用消失 → key 被 GC 变null→ value 仍被 Entry 强引用 → 线程池复用该线程 → value 永远不回收。
JDK 的自救措施(并不充分):
get()/set()/remove() 执行时会顺便清理遇到的 stale entry(key == null 的条目):
expungeStaleEntry():从当前位置向后线性探测,清理 key == null 的条目cleanSomeSlots():对数级扫描(log2(n)次探测)
但这只是启发式清理 ,不保证全部清除 → 必须显式 remove()。
java
// ✅ 正确用法模板
static final ThreadLocal<User> CONTEXT = new ThreadLocal<>();
public void handle(Request req) {
CONTEXT.set(resolveUser(req));
try {
doBusinessLogic();
} finally {
CONTEXT.remove(); // 必须!尤其在线程池环境
}
}
4.4 三代替代方案的演进
① InheritableThreadLocal(JDK 1.2)
原理 :Thread 构造器中检测父线程的 inheritableThreadLocals,浅拷贝到子线程。
java
// Thread 构造器核心逻辑
if (inheritThreadLocals && parent.inheritableThreadLocals != null) {
this.inheritableThreadLocals =
ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);
// → 遍历父 map,new Entry(key, value) 逐个拷贝
}
致命缺陷 :拷贝发生在 new Thread() 时 → 线程池复用已有线程 → 不触发构造器 → 子任务拿到的是该线程上一次任务残留的值。
java
// ❌ 线程池场景下 ITL 失效演示
var itl = new InheritableThreadLocal<String>();
var pool = Executors.newFixedThreadPool(1);
itl.set("request-A");
pool.submit(() -> System.out.println(itl.get())); // "request-A" ✅
itl.set("request-B");
pool.submit(() -> System.out.println(itl.get())); // 仍然 "request-A" ❌(复用同一线程)
② 阿里 TransmittableThreadLocal(TTL)
核心思路 :不在线程创建时拷贝,而在任务提交时快照、执行时回放、结束后还原。
scss
submit(task) 时:
1. capture() → 快照当前线程所有已注册的 TTL 值 → 存入 TtlRunnable
task.run() 时(在线程池线程上):
2. replay(snapshot) → 将快照值设入当前线程的 ThreadLocalMap(备份旧值)
3. 执行业务逻辑 → 业务代码通过 TTL.get() 拿到正确的值
4. restore(backup) → 还原线程池线程原来的值(避免污染下一个任务)
三种接入方式(侵入度递减):
| 方式 | 用法 | 侵入度 |
|---|---|---|
| 手动包装 | TtlRunnable.get(runnable) 包装后再 submit |
高 |
| 包装线程池 | TtlExecutors.getTtlExecutorService(pool) |
中 |
| Java Agent | -javaagent:transmittable-thread-local-agent.jar 字节码增强 |
零侵入 |
Agent 方案的原理:通过 ASM 字节码增强 java.util.concurrent.ThreadPoolExecutor、ForkJoinPool 等,在 execute()/submit() 时自动用 TtlRunnable 包装。
TTL 的局限:
- 本质还是基于 ThreadLocalMap → 仍有内存泄漏风险(需
remove()) - 每次提交任务都有一次 capture/replay/restore → 有开销(通常微秒级)
- 需要注册 TTL 实例才能被 capture(
TransmittableThreadLocal内部维护了一个holder弱引用集合)
③ ScopedValue(JDK 21+ Preview → JDK 25 正式)
设计哲学的根本转变:从"线程拥有可变容器"变为"作用域绑定不可变值"。
| 维度 | ThreadLocal | ScopedValue |
|---|---|---|
| 可变性 | 可读可写,任意时刻 set/get | 绑定后只读,scope 内不可改 |
| 生命周期 | 手动 remove,容易泄漏 | 自动 ,出 where().run() 立即解绑 |
| 继承模型 | ITL 浅拷贝(O(n) 每线程) | 零拷贝,子任务直接看到父绑定 |
| 内存模型 | 每线程 ThreadLocalMap → 百万线程百万份 | 栈式绑定,多线程共享同一绑定对象 |
| 数据结构 | 哈希表(开放地址法) | 栈帧关联(编译器可内联优化) |
| rebind | 任意 set() 覆盖 |
where().run() 嵌套内层覆盖,出 scope 自动恢复 |
java
static final ScopedValue<RequestContext> CTX = ScopedValue.newInstance();
// 绑定
ScopedValue.where(CTX, new RequestContext(traceId, user))
.run(() -> {
// 整条调用链 + fork 的虚拟线程都能读到
service.handle();
});
// ← 出了 run(),绑定自动消失,不可能泄漏
// 读取(任意深度)
void handle() {
var ctx = CTX.get(); // ✅ O(1),无哈希查找
}
零拷贝继承原理:
javascript
ThreadLocal 继承:
父线程 Map --拷贝--> 子线程 Map (每个子线程都拷贝一份,O(n))
ScopedValue 继承:
父 scope 的绑定 == 子任务直接可见(同一个对象引用,不拷贝)
底层通过 Continuation 的栈帧链向上查找,类似动态作用域
4.5 四代方案对比总结
scss
ThreadLocal (JDK 1.2)
│ 问题:线程池跨线程失效
▼
InheritableThreadLocal (JDK 1.2)
│ 问题:只在 new Thread() 时拷贝,线程池复用不触发
▼
TransmittableThreadLocal (阿里开源)
│ 解决:submit 时 capture → run 时 replay → finally restore
│ 问题:仍基于 ThreadLocalMap,有泄漏风险,有拷贝开销
▼
ScopedValue (JDK 21+)
│ 解决:不可变 + 自动清理 + 零拷贝继承
└ 终极方案(需配合虚拟线程 + StructuredTaskScope)
| 方案 | 线程池安全 | 自动清理 | 拷贝成本 | 虚拟线程友好 | 适用 |
|---|---|---|---|---|---|
ThreadLocal |
❌ | ❌ 需 remove | - | ⚠️ 百万线程 OOM | 单线程上下文 |
InheritableThreadLocal |
❌ | ❌ | O(n) 拷贝 | ⚠️ | 仅 new Thread 场景 |
TTL |
✅ | ❌ 需 remove | O(k) 每次提交 | ⚠️ | 平台线程 + 线程池 |
ScopedValue |
✅ | ✅ | 零拷贝 | ✅ | 虚拟线程时代首选 |
5. Thread.sleep / yield / join(⭐)
| API | 释放锁? | 释放 CPU? | hb 边? |
|---|---|---|---|
sleep(n) |
❌ | ✅ | ❌ |
yield() |
❌ | 提示(不保证) | ❌ |
join() |
❌ | ✅(阻塞当前线程) | ✅ |
6. 守护线程(⭐)
setDaemon(true) 必须在 start() 前调用;当所有非守护线程结束时 JVM 退出,守护线程直接终止(不执行 finally)。
7. 底层:Thread.start() 到 OS 的路径(⭐⭐⭐)
scss
Thread.start()
→ native start0()
→ JVM_StartThread (jvm.cpp)
→ os::create_thread (os_linux.cpp)
→ pthread_create(&tid, &attr, thread_native_entry, ...)
→ 内核 clone() 系统调用
→ 分配内核线程栈、task_struct
Java 线程 = 1:1 内核线程 (默认栈 1MB),这就是为什么线程数不能无限创建(OutOfMemoryError: unable to create native thread)。
8. 虚拟线程(Virtual Thread)(⭐⭐⭐)
JDK 21 正式发布(JEP 444),面试高频。这是 Java 并发模型自 JDK 1.0 以来最大的变革。
8.1 为什么需要虚拟线程
平台线程(Platform Thread)= 1:1 内核线程 → 创建开销大(~1MB 栈 + 内核 task_struct)→ 高并发 IO 密集型场景需要大量线程等待 IO但线程数受限 → 被迫用异步回调(Reactive / CompletableFuture) → 代码复杂度爆炸。
虚拟线程的目标 :用同步阻塞代码风格 写出异步的吞吐。
8.2 核心概念
(平台线程 / ForkJoinPool)"] VT2["虚拟线程 2"] --> CT1 VT3["虚拟线程 3"] --> CT2["Carrier Thread"] VT4["虚拟线程 4
(阻塞中)"] -.->|"unmount"| HEAP["堆上存续栈帧"] end
| 概念 | 说明 |
|---|---|
| 虚拟线程 | JVM 管理的轻量线程(M:N 模型),栈帧存放在堆上 |
| Carrier Thread | 实际执行虚拟线程的平台线程,默认是 ForkJoinPool |
| Mount / Unmount | 虚拟线程在 carrier 上挂载执行 / IO 阻塞时卸载让出 carrier |
| Continuation | 虚拟线程的底层抽象------可暂停恢复的计算 |
8.3 创建方式
java
// 方式一:直接创建
Thread vt = Thread.ofVirtual().name("my-vt").start(() -> {
// 阻塞操作在这里写同步代码就行
var result = httpClient.send(request, bodyHandler); // 阻塞时自动 unmount
});
// 方式二:工厂(推荐生产用法)
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
executor.submit(() -> blockingIOTask());
}
// 方式三:Thread.Builder
Thread.ofVirtual().name("worker-", 0).factory();
8.4 与平台线程的关键差异
| 维度 | 平台线程 | 虚拟线程 |
|---|---|---|
| 与 OS 关系 | 1:1 内核线程 | M:N,JVM 调度 |
| 创建成本 | ~1MB 栈 + syscall | ~几百字节(堆上栈帧) |
| 数量上限 | 通常数千 | 可百万级 |
| 栈内存 | 固定分配 | 按需增长(堆上) |
| 调度 | OS 调度器 | JVM ForkJoinPool |
| 阻塞代价 | 浪费一个 OS 线程 | 自动 unmount,carrier 去服务其他虚拟线程 |
| 是否需要池 | ✅ 必须池化 | ❌ 每任务一个线程(thread-per-request) |
| ThreadLocal | 正常使用 | 可用但慎用(百万线程 × ThreadLocal = 内存爆炸) |
8.4.1 虚拟线程栈的内存机制(⭐⭐ 面试高频追问)
核心区别 :平台线程栈在堆外 (OS mmap,不受 -Xmx),虚拟线程栈在堆里 (StackChunk 对象,受 GC 管理)。
makefile
平台线程执行时:
┌─────────────────────────┐
│ OS 原生栈 (堆外, 固定 1MB) │ ← -Xss 控制
└─────────────────────────┘
虚拟线程执行时 (mounted):
┌─────────────────────────┐
│ Carrier 的原生栈 (堆外) │ ← 复用 carrier 的栈
└─────────────────────────┘
虚拟线程挂起时 (unmounted):
┌─────────────────────────┐
│ StackChunk (堆内对象) │ ← 栈帧拷贝到堆, GC 管理
│ → StackChunk → ... │ 按需增长, 初始几百字节
└─────────────────────────┘
容量规划影响:
- 虚拟线程场景下,「线程栈」内存压力从堆外转移到堆内 → 需要适当调大
-Xmx; - 但总内存效率大幅提升:100 万虚拟线程挂起态仅占 ~2 GB 堆,而非 1 TB 堆外;
- 注意:大量虚拟线程的
StackChunk会增加 GC 扫描负担(对象多),ZGC/G1 大堆更友好。
详见 JVM 篇
01-内存-堆栈Metaspace与运行时数据区.md§2.3.1。
8.5 Pinning(钉住)------ 核心陷阱
虚拟线程在以下场景无法 unmount ,会钉住 carrier thread:
| 钉住场景 | 原因 | 影响 |
|---|---|---|
synchronized 块内阻塞 |
JVM 实现限制(monitor 绑定 OS 线程) | carrier 被占满 → 吞吐崩 |
| native 方法/JNI 内阻塞 | 无法挂载到堆 | 同上 |
解决:
java
// ❌ synchronized 内做 IO → pinning
synchronized (lock) {
socket.read(); // 钉住 carrier!
}
// ✅ 改用 ReentrantLock
lock.lock();
try {
socket.read(); // ReentrantLock 不 pin
} finally { lock.unlock(); }
bash
# 检测 pinning
-Djdk.tracePinnedThreads=full # JVM 参数,打印 pinning 栈帧
8.6 生产实践要点
| 实践 | 说明 |
|---|---|
| 不要池化虚拟线程 | 直接 newVirtualThreadPerTaskExecutor,每请求一个线程 |
| 避免 ThreadLocal 滥用 | 百万线程 × TL = OOM → 用 ScopedValue(JDK 21 preview) |
| 替换 synchronized | 有 IO 操作的临界区改 ReentrantLock 避免 pinning |
| 监控 | jcmd Thread.dump_to_file -format=json 查看虚拟线程状态 |
| 框架适配 | Spring Boot 3.2+、Tomcat 11+、Helidon 4 等已支持 |
8.7 Structured Concurrency(结构化并发,Preview)
java
// JDK 21+ (preview) --- JEP 453 / JEP 480
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Subtask<String> user = scope.fork(() -> fetchUser());
Subtask<Integer> order = scope.fork(() -> fetchOrder());
scope.join(); // 等待全部完成
scope.throwIfFailed(); // 任一失败 → 取消其他 + 抛异常
return new Response(user.get(), order.get());
}
// 好处:任务生命周期与代码块绑定,不会泄漏
核心价值:
- 子任务的生命周期绑定到 scope → 不会有悬挂线程
- 一个失败自动取消其他 → 错误传播清晰
- 与
try-with-resources对齐 → 代码结构 = 并发结构
8.8 ScopedValue(替代 ThreadLocal,Preview)
java
// JDK 21+ (preview) --- JEP 446 / JEP 481
static final ScopedValue<User> CURRENT_USER = ScopedValue.newInstance();
ScopedValue.runWhere(CURRENT_USER, user, () -> {
// 在此 scope 内(包括子虚拟线程)可以读取
handleRequest(); // 内部 CURRENT_USER.get() 有效
});
// scope 结束自动清理 → 不会泄漏
vs ThreadLocal:不可变、自动清理、天然适合虚拟线程。
8.9 Staff 级:Reactive vs Virtual Threads 选型决策(⭐⭐⭐)
面试高频追问:「你们用 WebFlux 还是虚拟线程?怎么决策的?」
| 维度 | Reactive (WebFlux/Reactor) | Virtual Threads (Loom) |
|---|---|---|
| 代码风格 | 函数式链 Mono.flatMap().map() |
传统命令式同步代码 |
| 学习曲线 | 陡峭(操作符 100+,调试困难) | 几乎为零(就是 Thread) |
| 调试/Profiling | 栈帧丢失,全是 lambda | 完整调用栈,jstack 可读 |
| 生态兼容 | 需要全链路 non-blocking(JDBC 不行,要 R2DBC) | 兼容所有阻塞 API(JDBC、synchronous HTTP) |
| 背压 | 原生支持 request(n) |
无原生背压,需要有界队列/信号量 |
| CPU 密集型 | 差(operator 开销大) | 差(虚拟线程设计不是为了 CPU 密集) |
| 成熟度 | 生产验证多年 | JDK 21 GA,逐步成熟 |
| 适用场景 | 流式处理、事件驱动、SSE/WebSocket | IO 密集型微服务(HTTP + DB + RPC) |
决策流程图:
可行?"} B -->|"否(用了 JDBC/同步 SDK)"| C["✅ Virtual Threads"] B -->|"是"| D{"团队 Reactive 经验?"} D -->|"经验不足"| C D -->|"经验丰富"| E{"需要流式背压?"} E -->|"是(如 WebSocket/SSE)"| F["✅ Reactive"] E -->|"否"| C
一句话:新项目默认选 Virtual Threads(代码简单 + 全栈兼容);已有 Reactive 代码库不必迁,两者可共存。
8.10 Staff 级:JDK 21+ 并发迁移 Checklist(⭐⭐⭐)
| # | 检查项 | 动作 | 原因 |
|---|---|---|---|
| 1 | synchronized 块内有 IO |
→ 改 ReentrantLock |
避免 pinning carrier |
| 2 | ThreadLocal 大对象 |
→ 改 ScopedValue |
百万虚拟线程 × TL = OOM |
| 3 | InheritableThreadLocal |
→ 改 ScopedValue + StructuredConcurrency |
零拷贝继承 |
| 4 | Executors.newFixedThreadPool |
→ Executors.newVirtualThreadPerTaskExecutor |
虚拟线程不需要池 |
| 5 | ThreadPoolExecutor 做 IO 任务 |
→ newVirtualThreadPerTaskExecutor |
无需调参 |
| 6 | CompletableFuture 复杂编排 |
→ 评估 StructuredTaskScope(preview) |
更清晰的生命周期管理 |
| 7 | parallelStream |
评估是否改为虚拟线程 + 结构化并发 | commonPool 限制消除 |
| 8 | 连接池大小 | 上调上限(JDBC/HTTP) | 虚拟线程并发度远超平台线程 |
java
// ⚠️ 最容易遗漏的:连接池成为新瓶颈
// 以前 200 个平台线程 → HikariCP maxPoolSize=50 够用
// 虚拟线程可能 10000 并发 → 50 连接瞬间耗尽 → 需要调大或加信号量限流
Semaphore dbSlot = new Semaphore(200); // 限制并发 DB 访问
dbSlot.acquire();
try { jdbcTemplate.query(...); }
finally { dbSlot.release(); }
9. 面试题精选(⭐⭐ ~ ⭐⭐⭐)
Q1:「start() 和 run() 的区别?」(⭐ 暖场)
答:
start()调用 nativestart0()→ JVM 层JVM_StartThread→ OSpthread_create创建真正的内核线程 ,新线程在自己的栈上执行run()。- 直接调用
run()只是当前线程执行一个普通 Java 方法,没有新线程、没有并发。
加分点:
start()只能调一次,第二次抛IllegalThreadStateException(内部通过threadStatus判断)。start()建立了 happens-before:调用线程中start()之前的操作 对新线程中run()内的操作可见(JMM 规定)。
Q2:「线程池里的线程怎么复用?」(⭐⭐)
答:
核心在 ThreadPoolExecutor.Worker 的 runWorker() 方法------一个 while 循环:
java
// 简化版核心逻辑
while (task != null || (task = getTask()) != null) {
task.run(); // 直接调 run(),不 start() 新线程
task = null; // 清除引用,准备接下一个
}
- Worker 线程启动后进入循环,调
getTask()从BlockingQueue阻塞获取任务。 - 拿到任务后在当前 Worker 线程栈 上执行
task.run()(多态调用用户的 Runnable)。 - 执行完不退出,回到循环头继续
getTask()→ 阻塞等待下一个任务。 - 核心线程 永远阻塞在
take();非核心线程 用poll(keepAliveTime)超时后退出循环销毁。
加分点:
- Worker 本身是个 AQS(不可重入独占锁),用来配合
shutdown()时的中断控制。 afterExecute()钩子可以捕获异常------线程池捕获run()的异常后不会让 Worker 死掉(submit包装成FutureTask吞掉异常存在Future里;execute则让 Worker 死掉并新建替换)。
Q3 🟧 阿里:「ThreadLocal 在 RPC 链路追踪中怎么传递?请求线程和业务线程不一定是同一个」(⭐⭐⭐)
答(分三层讲):
① 问题根因 :InheritableThreadLocal 只在 new Thread() 构造时从父线程 inheritableThreadLocals 拷贝一份 到子线程。线程池复用已有线程 → 不触发构造 → 拿到的是上一个任务残留的旧值。
② 同进程内解法(线程池跨线程):
| 方案 | 原理 | 缺点 |
|---|---|---|
| 手动传参 | 调用时显式传 traceId | 侵入业务代码 |
| 包装 Runnable | submit 时 capture TL 快照 → run 时 replay → finally restore | 每次都要包装 |
| 阿里 TTL | 通过 Java Agent 字节码增强 TtpRunnable/TtlCallable,自动包装线程池的 execute/submit。核心三步:capture() → replay() → restore() |
需引入 agent |
| ScopedValue(JDK 21+) | 不可变绑定 + StructuredTaskScope 自动继承,零拷贝 | 仅虚拟线程生态 |
③ 跨进程:
- HTTP:放 Header(
X-Trace-Id、X-Span-Id),如 Sleuth/Micrometer Tracing 自动注入。 - gRPC:放
Metadata。 - MQ:放 Message Properties。
- 统一抽象:OpenTelemetry
Context.propagation+TextMapPropagator。
Q4 🟦 字节:「创建 10000 个线程会怎样?怎么限制?」(⭐⭐)
答:
会发生什么(逐步恶化):
- 每个平台线程默认栈 1MB(
-Xss),10000 个 = ~10GB 虚拟内存。 - 实际 RSS 按需分配(触碰到的页才分配物理内存),但活跃线程多 → 物理内存也会飙。
- 内核层面:每个线程一个
task_struct(~10KB)+ 内核栈(8KB/16KB)→ OS 层开销 ~280MB。 - 最终触发 :
OOM: unable to create native thread(mmap失败)或ulimit -u(进程数限制)。 - 即使创建成功,10000 线程的上下文切换代价巨大 → CPU sys% 飙高,实际吞吐反而下降。
怎么限制:
| 层级 | 手段 |
|---|---|
| 应用层 | 线程池 ThreadPoolExecutor(core, max, queue) 控制并发度 |
| JVM 层 | -Xss256k 减小栈大小(注意递归深度) |
| OS 层 | ulimit -u 4096 限制进程/线程数;容器 pids.max |
| JDK 21+ | 虚拟线程:初始 ~几百字节,堆上按需增长 → 10000 个只占几 MB |
加分对比:
10000 平台线程 → ~10GB 虚拟内存 + 万级上下文切换/秒
10000 虚拟线程 → ~几 MB 堆内存 + 仅活跃的几个 carrier 线程参与调度
Q5:「Thread.stop() 为什么被废弃?」(⭐)
答:
stop() 抛出 ThreadDeath(一个 Error),强制释放该线程持有的所有 monitor 锁。
具体危害:
java
synchronized (accounts) {
accounts.debit(a, 100); // 已执行
// ← stop() 在这里触发 → 锁释放
accounts.credit(b, 100); // 未执行 → 钱凭空消失
}
- 数据结构处于半更新状态 (不变式被破坏),且锁已释放 → 其他线程立即看到不一致数据。
ThreadDeath是Error,大部分代码不会 catch → finally 块执行但 catch 块不兜底。- 即使 catch 了
ThreadDeath,你无法知道线程执行到哪一步 → 无法安全恢复。
正确做法 :中断标志 + 协作退出(interrupt() + isInterrupted() 检查),让线程自己决定何时、如何安全退出。
9. 快问快答(⭐)
Thread.sleep(0)有什么用?------触发一次调度让出 CPU(类似 yield 但更可靠)。- 守护线程用于什么?------GC、JIT 等后台任务。
setDaemon在start后调用?------IllegalThreadStateException。Thread.currentThread()返回什么?------当前执行线程引用。ThreadGroup还有用吗?------基本废弃;用Executor管理。
➡️ 下一篇:02-JMM与内存可见性 --- 线程之间怎么"看到"彼此的写?