线程基础与生命周期- 并发编程

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)

stateDiagram-v2 [*] --> NEW: new Thread() NEW --> RUNNABLE: start() RUNNABLE --> BLOCKED: 等待 monitor 锁 RUNNABLE --> WAITING: wait()/join()/park() RUNNABLE --> TIMED_WAITING: sleep(n)/wait(n)/parkNanos BLOCKED --> RUNNABLE: 获取到锁 WAITING --> RUNNABLE: notify/unpark/join返回 TIMED_WAITING --> RUNNABLE: 超时/被唤醒 RUNNABLE --> TERMINATED: run()结束 TERMINATED --> [*]

2.2 面试必背:BLOCKED vs WAITING

状态 触发 区别
BLOCKED 等待进入 synchronized 被动等锁
WAITING 主动调用 wait()/park()/join() 主动放弃 CPU

关键区分:BLOCKED 只属于 synchronized

ReentrantLock.lock() 竞争时线程状态是 WAITING (底层 LockSupport.park()),不是 BLOCKED。 所有 j.u.c 的锁(ReentrantLockSemaphoreCountDownLatch 等)竞争时都是 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.readnative → 可判断在做 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 避免在方法签名中层层传参
线程不安全对象复用 SimpleDateFormatDecimalFormatRandom 每线程一份,避免加锁又避免反复创建
数据库连接/事务绑定 Spring @TransactionalTransactionSynchronizationManager 同一线程内多个 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 内存泄漏:完整引用链分析

flowchart LR subgraph "强引用(不会 GC)" POOL["线程池"] -->|"持有"| THREAD["Thread"] THREAD -->|"threadLocals"| MAP["ThreadLocalMap"] MAP -->|"Entry.value"| VALUE["Value 对象 ❌"] end subgraph "弱引用(可 GC)" MAP -.->|"Entry.key
WeakRef"| TL["ThreadLocal 实例"] end style VALUE fill:#ff6b6b style TL fill:#ffd93d

泄漏时序

  1. 业务代码中 static final ThreadLocal<Obj> TL = ... → 一般不会被 GC(强引用在类上)→ 不泄漏
  2. 真正危险 :方法内局部 ThreadLocal<byte[]> tl = new ThreadLocal<>(),方法返回后 tl 引用消失 → key 被 GC 变 nullvalue 仍被 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.ThreadPoolExecutorForkJoinPool 等,在 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 核心概念

flowchart TD subgraph JVM VT1["虚拟线程 1"] --> CT1["Carrier Thread
(平台线程 / 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)

决策流程图

flowchart TD A["新项目/重构"] --> B{"全链路 non-blocking
可行?"} 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() 调用 native start0() → JVM 层 JVM_StartThread → OS pthread_create 创建真正的内核线程 ,新线程在自己的栈上执行 run()
  • 直接调用 run() 只是当前线程执行一个普通 Java 方法,没有新线程、没有并发。

加分点

  • start() 只能调一次,第二次抛 IllegalThreadStateException(内部通过 threadStatus 判断)。
  • start() 建立了 happens-before:调用线程中 start() 之前的操作 对新线程中 run() 内的操作可见(JMM 规定)。

Q2:「线程池里的线程怎么复用?」(⭐⭐)

核心在 ThreadPoolExecutor.WorkerrunWorker() 方法------一个 while 循环

java 复制代码
// 简化版核心逻辑
while (task != null || (task = getTask()) != null) {
    task.run();          // 直接调 run(),不 start() 新线程
    task = null;         // 清除引用,准备接下一个
}
  1. Worker 线程启动后进入循环,调 getTask()BlockingQueue 阻塞获取任务。
  2. 拿到任务后在当前 Worker 线程栈 上执行 task.run()(多态调用用户的 Runnable)。
  3. 执行完不退出,回到循环头继续 getTask() → 阻塞等待下一个任务。
  4. 核心线程 永远阻塞在 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-IdX-Span-Id),如 Sleuth/Micrometer Tracing 自动注入。
  • gRPC:放 Metadata
  • MQ:放 Message Properties。
  • 统一抽象:OpenTelemetry Context.propagation + TextMapPropagator

Q4 🟦 字节:「创建 10000 个线程会怎样?怎么限制?」(⭐⭐)

会发生什么(逐步恶化):

  1. 每个平台线程默认栈 1MB(-Xss),10000 个 = ~10GB 虚拟内存
  2. 实际 RSS 按需分配(触碰到的页才分配物理内存),但活跃线程多 → 物理内存也会飙。
  3. 内核层面:每个线程一个 task_struct(~10KB)+ 内核栈(8KB/16KB)→ OS 层开销 ~280MB。
  4. 最终触发OOM: unable to create native threadmmap 失败)或 ulimit -u(进程数限制)。
  5. 即使创建成功,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);  // 未执行 → 钱凭空消失
}
  • 数据结构处于半更新状态 (不变式被破坏),且锁已释放 → 其他线程立即看到不一致数据
  • ThreadDeathError,大部分代码不会 catch → finally 块执行但 catch 块不兜底
  • 即使 catch 了 ThreadDeath,你无法知道线程执行到哪一步 → 无法安全恢复

正确做法 :中断标志 + 协作退出(interrupt() + isInterrupted() 检查),让线程自己决定何时、如何安全退出。


9. 快问快答(⭐)

  1. Thread.sleep(0) 有什么用?------触发一次调度让出 CPU(类似 yield 但更可靠)。
  2. 守护线程用于什么?------GC、JIT 等后台任务。
  3. setDaemonstart 后调用?------IllegalThreadStateException
  4. Thread.currentThread() 返回什么?------当前执行线程引用。
  5. ThreadGroup 还有用吗?------基本废弃;用 Executor 管理。

➡️ 下一篇:02-JMM与内存可见性 --- 线程之间怎么"看到"彼此的写?

相关推荐
人道领域1 小时前
【LeetCode刷题日记】222.极速计算完全二叉树节点数:O(log²n)算法揭秘
java·数据结构·算法·leetcode·深度优先
传说之后1 小时前
Go语言并发安全入门指南
后端
MacroZheng1 小时前
IDEA + Claude Code = 王炸!
人工智能·后端·intellij idea
Solis1 小时前
高性能二级缓存设计:Caffeine + 滑动窗口热点降级方案
后端
小碗羊肉1 小时前
【JavaWeb | 第十篇】Spring中的事务控制
java·后端·spring
SimonKing1 小时前
美团不做外卖做浏览器了,而且是AI浏览器:Tabbit
java·后端·程序员
AI人工智能+电脑小能手1 小时前
【大白话说Java面试题 第48题】【JVM篇】第8题:JVM 里的有几种 ClassLoader?为什么会有多种?
java·开发语言·jvm·面试
才疏学浅7431 小时前
批量下载鹏程实验室数据的方法
java·开发语言·word
Gopher_HBo1 小时前
Go语言常见并发模式
后端