qinghuan
← JUC + JVM / 全部笔记

单元 93 / JUC + JVM

P0 · 必会主线L2 · 进阶

ThreadLocal 与线程复用:请求结束后清理身份上下文

用线程连续处理不同用户的例子,看清 ThreadLocal 为什么要清理,以及异步和嵌套调用怎样传递、恢复上下文。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能演示同一工作线程上的上下文残留,并为同步和异步路径规定清理责任。

学习理由、准备要求与知识点

为什么学它

服务器线程被反复复用,遗留的 ThreadLocal 可能把上一请求的身份带给下一请求。

先读这些内容

知识点清单

  • 线程绑定值
  • 线程复用
  • try finally remove
  • 上下文最小化
  • 异步任务传递
  • InheritableThreadLocal 局限
  • MDC 清理
  • 安全边界

运行前先确认JDK17 ThreadLocal源码固定17.0.12+7,跨线程传播以显式不可变快照演示。示例作用域要求同线程、后进先出关闭,不模拟框架全部上下文机制。

01先弄懂它是什么

ThreadLocal 让每个线程各自保存一份值,常用来放请求编号、事务资源或临时状态。它按线程分开,不会自动按用户分开:线程池中的同一个线程可能先处理用户 A,再处理用户 B。若 A 结束后没清理,B 仍可能读到 A 留下的值,导致日志串号,甚至影响错误设计的身份判断。

线程和请求活多久并不一样:线程可能工作数小时,一次请求只用几十毫秒,异步步骤还可能换到另一个线程。把请求数据放在线程上时,进入任务要设置,退出要恢复或清理;跨线程要明确捕获哪些数据、何时捕获、怎样传过去。ThreadLocal 不会自己跟着请求移动。

嵌套调用使清理更微妙:外层已经安装A,内层临时切换B,内层结束后应恢复A,整个外层结束才恢复原始状态。每次都remove会破坏外层,每次都不清理会污染后续任务。作用域栈和try-with-resources能够把安装与恢复绑定到结构化代码范围,让正常和异常路径遵守同一协议。

文中这几个词,先认一认

ThreadLocalMap
保存在每个线程内部、记录该线程各个 ThreadLocal 值的映射,不是一张全局共享表。
弱键强值
这里讨论的实现弱引用 ThreadLocal 键,却仍强引用它对应的值,因此键消失不代表值立刻释放。
上下文快照
在指定时刻复制并固定需要传递的任务关联信息,避免之后被别的任务改变。
作用域 Scope
临时设置上下文的一段代码范围,退出时恢复进入前的值,兼顾嵌套和异常情况。

还有陌生词?到术语小抄里看一个具体例子 ↗

02看看它怎么工作

ThreadLocal.get先取得当前Thread,再从该线程的映射中按当前ThreadLocal对象查找值,缺失时调用初始化逻辑。两个线程使用同一个ThreadLocal对象会进入各自映射;如果它们都set同一个可变对象引用,那个对象本身仍被共享,ThreadLocal不会自动复制对象或保护对象内部状态。

本篇固定的 JDK 实现用弱引用保存 ThreadLocal 这个键:如果别处也不再保留它,键就可能被垃圾回收。但条目里的 value 仍是普通引用,活着的线程可以经由自己的映射和条目继续留住它。

键消失后形成的“陈旧条目”,要等后续映射操作触发清理,不会立刻连值一起释放。若静态字段一直保留着 ThreadLocal,键甚至不会消失;在线程长期存活时,忘记 remove 会让旧值持续留下,直到被替换或清除。

InheritableThreadLocal通常在线程创建时复制父线程相关值,而线程池工作者往往早已创建,不会在每次任务提交时重新继承。传播任务上下文应捕获提交时快照,在执行线程安装并在finally恢复,或者使用框架支持的上下文机制。响应式链路可能跨线程流转,更应按订阅上下文而不是固定线程思考。

正在加载流程图…

弱键不能代替任务结束清理;上下文的安装与恢复应有成对边界。
查看流程图源码(Mermaid)
flowchart TD
 T["长寿命工作线程"] --> M["ThreadLocalMap"]
 M --> E["Entry"]
 E -.->|弱引用| K["ThreadLocal键"]
 E -->|强引用| V["请求值与其引用对象"]
 A["任务进入捕获快照"] --> P["压入作用域并安装"]
 P --> B["执行任务,可能异常"]
 B --> F["finally弹出并恢复外层"]
 F --> R["最外层结束时remove"]

按顺序看,每一步做了什么

  1. 列出需要传播的数据,排除可随意共享的大型可变对象。
  2. 在提交任务时捕获不可变快照。
  3. 执行线程进入作用域,保存原状态并安装快照。
  4. 用try-with-resources保证异常也按后进先出恢复。
  5. 最外层退出移除线程值,再测试线程复用与嵌套。

03对照实现代码

ThreadLocalMap为什么仍需主动remove (新标签页)↗

OpenJDK17.0.12+7 · ThreadLocal#get / remove;ThreadLocalMap.Entry / expungeStaleEntry

原创Java生命周期模型,展示业务边界主动remove。真实映射采用线性探测并处理弱键陈旧条目,模型不复刻其散列与清理算法。

核心机制代码 · java
final class RequestLocalModel {
    private static final ThreadLocal<String> REQUEST = new ThreadLocal<>();
    static void handle(String id, Runnable action) {
        String before=REQUEST.get(); // 保存调用线程原有值,支持嵌套请求范围。
        REQUEST.set(id);
        try { action.run(); } // 业务抛异常时仍然进入finally。
        finally {
            if(before==null) REQUEST.remove(); // 没有外层值时主动清理映射。
            else REQUEST.set(before); // 有外层值则恢复,而不是直接删除。
        }
    }
}

get调用Thread.currentThread,再读取该线程持有的ThreadLocalMap。键是ThreadLocal实例自身,值属于当前线程。这种设计解释了为什么ThreadLocal实例常被声明为静态字段,同时也说明它没有为一个请求自动建立跨线程共享作用域。

Entry继承弱引用并另外保存普通Object value。弱引用只改变键的可达性,不把value也变成弱引用。只要线程和映射仍活着,陈旧条目的value可能继续持有用户对象、类加载器或大缓冲区,因此不能把“用了弱引用”当成内存保留问题已经解决。

两个键算出同一个数组位置时,ThreadLocalMap 会继续向后找空位,这叫线性探测。查找也沿着这条路径进行,所以删除失效条目后,还要整理后续受影响的条目;只把一个槽位设为空,可能让后面的键再也查不到。需要整理多少内容取决于这一段的长度和条目状态,并不是每次 get 都扫描整张表。

remove定位当前键,清除并整理相关陈旧条目。业务主动调用remove比依赖未来某次碰巧触发的清理更可靠。remove后再次get可能重新执行initialValue,因此初始化函数也不应假设整个线程生命周期只运行一次。公开API契约与内部清理时机需要分别理解。

代码先调用谁,再调用谁

  1. ThreadLocal.get取得当前线程
  2. 访问该线程ThreadLocalMap并按键查找
  3. 缺失时初始化当前线程值
  4. 请求范围结束调用remove或恢复外层值
  5. 映射删除与陈旧条目整理断开不再需要的引用

04动手写一个例子

支持嵌套恢复的上下文作用域栈

每个线程维护一组不可变字符串上下文。open安装新快照,current返回最内层值,close必须同线程且后进先出,重复正确关闭可忽略。最外层退出清理ThreadLocal,跨线程传播须显式open捕获快照。

输入 1
open A;open B;close B;close A
输出 1
当前值依次A、B、A、空
输入 2
先open A再open B,尝试先close A
输出 2
抛异常且仍保持B为当前值

用Deque保存Frame,每个Frame包含不可变Map和创建线程身份。当前上下文来自栈顶,进入作用域压栈,退出弹栈。若关闭对象不是栈顶,说明调用者违反嵌套顺序,必须拒绝而不修改栈;否则错误关闭会把仍在使用的内层状态一起丢掉。

线程身份检查阻止另一个线程关闭原线程作用域,因为ThreadLocal.get访问的是调用线程自己的栈。跨线程传递的是Map快照,绝不是Scope对象。Map.copyOf复制映射结构并拒绝null键值;本例值为不可变String,若扩展成可变对象还必须定义深复制或不可变性约束。

最外层弹出后remove线程值。重复关闭已经成功退出的Scope可以直接返回,但此前错误关闭不能把closed标记设为true,否则稍后无法正确清理。整个算法利用线程隔离避免跨线程锁,正确性依赖所有Scope遵守同线程后进先出规则。

为什么这样写是对的栈顶始终是当前线程最近打开且未关闭的作用域;正确关闭只弹出自身,空栈后ThreadLocal不保留有效上下文。

时间 open复制m个字段O(m),current与close通常O(1)额外空间 嵌套深度d、每层m字段时O(d·m)

栈操作常数级,主要成本来自快照复制;字符串值不可变而共享,不按字符逐个深复制。

保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。

展开收起代码 · Main.java · 完整可运行程序Java · 55 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

Main.java · 完整可运行程序 · Java
import java.util.*;
import java.util.concurrent.*;

public class Main {
    static final ThreadLocal<Deque<Scope>> STACK=new ThreadLocal<>();
    static final class Scope implements AutoCloseable {
        final Thread owner=Thread.currentThread();
        final Map<String,String> values;
        boolean closed;
        Scope(Map<String,String> values) { this.values=Map.copyOf(values); }
        public void close() {
            if(Thread.currentThread()!=owner) throw new IllegalStateException("wrong thread");
            if(closed) return;
            Deque<Scope> stack=STACK.get();
            if(stack==null || stack.peek()!=this) throw new IllegalStateException("wrong close order");
            stack.pop(); closed=true; // 验证通过后才修改状态,错误关闭保持原栈。
            if(stack.isEmpty()) STACK.remove(); // 最外层结束主动断开线程上的引用。
        }
    }
    static Scope open(Map<String,String> values) {
        Scope scope=new Scope(values); // 先验证和复制,失败时不修改线程上下文。
        Deque<Scope> stack=STACK.get();
        if(stack==null) { stack=new ArrayDeque<>(); STACK.set(stack); }
        stack.push(scope); return scope;
    }
    static Map<String,String> current() {
        Deque<Scope> stack=STACK.get();
        if(stack==null) { STACK.remove(); return Map.of(); }
        return stack.peek().values;
    }
    static void check(boolean ok) { if(!ok) throw new AssertionError(); }
    public static void main(String[] args) throws Exception {
        check(current().isEmpty());
        Scope a=open(Map.of("trace","A")), b=open(Map.of("trace","B"));
        check(current().get("trace").equals("B"));
        try { a.close(); throw new AssertionError(); } catch(IllegalStateException expected) { }
        check(current().get("trace").equals("B"));
        b.close(); b.close(); check(current().get("trace").equals("A"));
        Map<String,String> captured=current();
        ExecutorService executor=Executors.newSingleThreadExecutor();
        try {
            check(executor.submit(() -> {
                check(current().isEmpty());
                try(Scope ignored=open(captured)) { return current().get("trace").equals("A"); }
            }).get(5,TimeUnit.SECONDS));
            check(executor.submit(() -> current().isEmpty()).get(5,TimeUnit.SECONDS));
            check(executor.submit(() -> {
                try { a.close(); return false; } catch(IllegalStateException expected) { return true; }
            }).get(5,TimeUnit.SECONDS));
        } finally { executor.shutdownNow(); a.close(); }
        try(Scope ignored=open(Map.of("trace","C"))) { throw new IllegalArgumentException(); }
        catch(IllegalArgumentException expected) { check(current().isEmpty()); }
        System.out.println("PASS 8");
    }
}

这些特殊情况也要试一试

  • 没有作用域时返回不可变空Map。
  • 错误关闭顺序不改变当前值。
  • 跨线程关闭拒绝,显式快照可在目标线程安装。
  • 异常退出恢复外层,最外层清除线程值。
进阶选读 · 上下文传播中的生命周期与内存保留

场景异步任务队列长时间积压,每个任务捕获完整请求对象,导致堆中保留大量用户数据。

做法只捕获必要标识和不可变小字段,为任务设置队列容量和寿命;在执行边界安装范围,完成或取消后释放闭包引用。通过堆引用链检查线程、队列任务和上下文快照各自保留的对象。

复杂度n个积压任务、每份快照m字段时保留量O(n·m),限制活跃窗口k可把任务快照部分压到O(k·m)。

好处与代价精简快照减少保留与隐私风险,但可能需要显式传递更多参数;自动复制整个请求虽然方便,却隐藏跨线程依赖。

05项目里怎么用

传递线程上下文前,先确认里面放的是什么。Trace ID 只是把同一次请求的日志串起来,不能证明用户有权限;事务里的数据库连接通常也不能复制到另一个线程继续用。身份、事务资源和追踪信息各有用途,应该分别按框架支持的方式传递,不能因为它们都叫“上下文”就一起照搬。

在Servlet拦截器、过滤器或任务装饰器中使用finally清理,并覆盖异常、提前返回和嵌套调用。响应式应用优先使用框架提供的Context及传播组件,不把一个请求固定到某个线程的假设带进事件循环。测试要复用同一个工作线程连续运行不同请求,单次新线程测试容易漏掉污染问题。

排查内存保留时查看从活线程到ThreadLocalMap条目的引用链,以及从任务队列到捕获对象的引用链。两条路径可能同时存在,删掉ThreadLocal后仍有队列闭包持有对象也很常见。不要仅凭类名里出现ThreadLocal就断言它是全部内存问题的根因。

容易踩的坑

  • 请求结束不remove,线程复用串用户或串日志。
  • 认为弱键意味着value一定立即回收。
  • 把同一可变对象放入多个线程后认为已隔离。
  • 把InheritableThreadLocal当成每次线程池提交都会继承。
  • 内层退出无条件remove破坏外层上下文。

几种做法,怎么选

表格有四列,窄屏可左右滑动查看代价和适用条件。

方案优势代价与限制适用条件
显式参数传递依赖清楚,便于测试调用链参数更多关键业务身份与必须依赖的数据
ThreadLocal作用域适合线程绑定的基础设施上下文需严格安装恢复,跨线程要传播同步调用链与框架边界
框架上下文传播适配异步与响应式边界需要正确注册与版本配置复杂执行器与订阅链路

06检验是否读懂了

先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。

先用自己的话解释

01ThreadLocal的数据存在ThreadLocal对象里吗?

展开参考答案与推导

固定JDK实现中值存在线程的ThreadLocalMap,ThreadLocal实例作为键;每个线程访问自己的映射。

02弱键为什么仍可能保留大量value?

展开参考答案与推导

键回收后条目value仍被活线程映射强引用,清理是择机发生;主动remove或线程结束才能按相应路径断开引用。

03为什么嵌套范围应恢复原值?

展开参考答案与推导

外层范围仍在运行,内层结束直接清除会丢失外层合法上下文。

换个情况,还会处理吗

01同一线程池第二个任务读到第一个用户ID,如何修复?

展开参考答案与推导

在任务边界安装可信上下文并finally恢复或清除,写连续复用同线程的测试,并检查异常提前返回分支。

02改用InheritableThreadLocal后偶尔传播错误,为什么?

展开参考答案与推导

继承与线程创建相关,线程池复用不会按每次提交重新复制,应使用提交时快照与执行时作用域。

03remove后堆占用仍高,下一步查哪里?

展开参考答案与推导

检查队列闭包、未完成Future、缓存和其他引用链;上下文可能被多个路径持有,不能只凭单字段清理判断对象必然回收。

写代码试一试

01实现嵌套上下文栈。

输入 / 条件
打开A再B,依次关闭B和A
输出 / 预期
B、A、空

提示 Scope保存owner并检查栈顶。

展开参考答案与推导

按正文后进先出协议,错误关闭不修改状态。

02测试异常路径不会污染后续任务。

输入 / 条件
单线程执行器任务A安装上下文后抛错,任务B读取
输出 / 预期
B为空

提示 复用同一工作者,finally清理。

展开参考答案与推导

用Future观察A的异常,再提交B验证空状态,最后关闭执行器。

03设计只传播必要字段的快照。

输入 / 条件
请求含traceId、userId、token、大请求体
输出 / 预期
快照仅含被允许的关联字段

提示 建立显式字段白名单。

展开参考答案与推导

复制不可变小值,身份字段另按可信认证协议处理,不传播令牌与连接对象。

读完之后,独立完成

课后作业:自己做一遍

本篇共 12 道作业,先完成 9 道基础与综合任务,再按前置知识挑战进阶题。其中包含 2 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。

这次练什么

能演示同一工作线程上的上下文残留,并为同步和异步路径规定清理责任。

本篇重点加练

异步上下文包含历史身份,只有明确授权时点与传播范围才能避免把快照当作持续权限。

  • 异步任务传递
  • 安全边界

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01原理问答

    三个请求先后由两个固定工作线程处理。画出请求身份与 ThreadLocal 值的归属,说明请求对象结束后哪些引用仍可能存活。

    提交内容
    线程与引用关系图
    怎样算完成
    • 区分请求寿命和线程寿命
    • 覆盖没有身份的请求

    练到的知识点线程绑定值 · 线程复用

  2. 作业 02代码推演

    拦截器写入用户、租户、完整请求体三项上下文,业务可能在校验失败时提前返回。审查保存范围与每条退出路径。

    提交内容
    字段评审与控制流图
    怎样算完成
    • 逐字段说明必要性
    • 正常、异常和拒绝路径均可追踪

    练到的知识点try finally remove · 上下文最小化

  3. 作业 03原理问答

    任务在创建时捕获上下文,在提交时又补充追踪信息,执行前请求对象再次变化。为各字段选择语义时点并解释选择依据。

    提交内容
    字段捕获时点表
    怎样算完成
    • 区分创建、提交和执行
    • 明确可变对象是否仍共享

    练到的知识点异步任务传递

  4. 作业 04代码推演

    队列中的任务带有用户甲身份,但由用户乙重试按钮重新触发。定义执行者、发起者与被代理主体,审查旧上下文能否直接复用。

    提交内容
    委托身份及审计记录
    怎样算完成
    • 区别审计归属和授权主体
    • 包含跨租户重放

    练到的知识点安全边界

综合应用

  1. 作业 05面试场景

    线程池在用户登录前已经创建,之后才提交带上下文的后台任务。比较显式传参与继承线程上下文的实际创建时点。

    提交内容
    线程创建及提交时间线
    怎样算完成
    • 包含同一线程执行不同用户任务
    • 注明任务提交后数据是否可变

    练到的知识点异步任务传递 · InheritableThreadLocal 局限

  2. 作业 06设计评审

    日志 MDC 中的租户标识同时被业务授权读取,日志装饰器又会覆盖该字段。评审日志追踪与安全身份之间的信任边界。

    提交内容
    数据来源图与职责划分
    怎样算完成
    • 区分可信身份和日志标签
    • 覆盖缺失及伪造字段

    练到的知识点MDC 清理 · 安全边界

  3. 作业 07编程实践

    实现本地单线程执行器上的上下文包装演示,提交用户甲、匿名、用户乙三类任务并安排嵌套调用及异常;逐任务记录上下文进入与退出。

    提交内容
    源码与生命周期日志
    怎样算完成
    • 日志不含真实令牌
    • 外部提交对象修改有专门用例

    练到的知识点线程绑定值 · 线程复用 · try finally remove · 上下文最小化 · 异步任务传递 · InheritableThreadLocal 局限 · MDC 清理 · 安全边界

  4. 作业 08设计评审

    异步任务可切换执行器并由定时器触发回调,业务仅在首次提交时包装上下文。评审每次线程边界的传播和清理责任。

    提交内容
    异步边界图
    怎样算完成
    • 包括回调及失败恢复
    • 不假定所有续接运行在同一线程

    练到的知识点异步任务传递 · 安全边界

  5. 作业 09编程实践

    在本地构造用户任务经两个命名执行器与一个定时器的链,记录每段身份来源;加入委托变更和异常回调,验证自定传播契约。

    提交内容
    Java 代码与传播报告
    怎样算完成
    • 只传递虚构最小字段
    • 线程结束后有清理证据

    练到的知识点异步任务传递 · 安全边界

进阶挑战 · 掌握前置后选做

  1. 作业 10设计评审

    异步任务可能排队十分钟,提交时用户有权限,执行时权限已撤销。定义哪些上下文可作为快照、哪些授权必须重新判断。

    提交内容
    执行契约与验收矩阵
    怎样算完成
    • 区分审计身份和实时权限
    • 覆盖取消、重试和任务转交

    练到的知识点线程复用 · try finally remove · 上下文最小化 · 异步任务传递 · MDC 清理 · 安全边界

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 11思维拓展 · 跨域迁移

    把 ThreadLocal 传播迁移到持久任务队列,任务可能跨机器并在数天后执行。哪些对象可序列化为审计事实,哪些权限必须重新判定?

    提交内容
    持久上下文契约
    怎样算完成
    • 不传递实时连接或真实凭据
    • 区分身份快照和实时权限

    练到的知识点异步任务传递 · 安全边界

  2. 作业 12思维拓展 · 反例构造

    构造日志中的用户 ID 始终正确,但实际授权仍使用错误主体的案例,说明日志关联为何不足以证明安全上下文正确。

    提交内容
    反例数据流
    怎样算完成
    • 至少两个身份使用点
    • 日志本身不得伪造

    练到的知识点异步任务传递 · 安全边界

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗