欢迎光临
我们一直在努力

Java 项目实战: 交友项目 - ThreadLocal 统一 token 处理

概述

拦截器解析完 token 之后,用户信息放哪里才能让 Controller、Service 随手可取?答案是 ThreadLocal。本文聚焦这个"载体"本身:它为什么能工作、内部长什么样、为什么请求结束必须 remove、异步代码里为什么取不到值。

拦截器如何解析 token 并注册到 Spring MVC,见同章《代码优化:使用拦截器进行统一身份鉴权》,这里不重复。

纲要

  • 需求回顾:token 解析代码散落在各个 Controller 里的问题
  • ThreadLocal 工作原理
    • 同步模型下一个请求一个线程,这是整套方案成立的前提
    • Thread → ThreadLocalMap → Entry(key 弱引用、value 强引用)
    • 天然线程隔离的原因
  • UserHolder 工具类(与工程实读一致的完整代码)
    • 存 User 对象 vs 只存 Long id 的取舍
    • 快照版缺 null 判断的问题与改进写法
  • 为什么必须 remove
    • Tomcat 线程池复用 → 串号事故
    • 内存泄漏的形成路径
    • 清理时机:afterCompletion 与 try-finally
  • 请求全流程时序图
  • 常见坑清单:异步取不到值、withInitial 默认值、单测污染等
  • 常见误解澄清
  • 三个可复现的验证实验

需求回顾

统一身份鉴权落地后还剩一个问题:token 解析出来的用户信息存在哪。改造前的写法是每个 Controller 方法都带 @RequestHeader("Authorization") String token 参数,方法体里再走一遍 JwtUtils.getClaims(token) 取 id 和 mobile。解析逻辑本身已经由拦截器统一了,剩下的就是把"当前登录用户"这个值传递下去。

传递方式有三种:方法参数层层透传(侵入性太强)、静态变量(多线程互相覆盖)、ThreadLocal(线程内共享、线程间隔离)。交友项目选了第三种,这也是业内最经典的组合:拦截器 + ThreadLocal。

机翻稿里把这套方案念成了"sway 口快速的""所有 lolocal"之类的一串乱码,实际说的就是 ThreadLocal(后文所有机翻转写错误按上下文还原,不再逐条标注)。

ThreadLocal 为什么能工作

前提:一个请求一个线程

Spring MVC 的同步处理模型下,Tomcat 接到一个 HTTP 请求,就从线程池里取一个工作线程,这个请求的完整生命周期——拦截器 preHandle、Controller、Service、DAO、afterCompletion——全部在这一个线程里跑完,然后线程归还线程池。

这就解释了方案的可行性:拦截器在线程 T 上执行 threadLocal.set(user),随后 Controller 也在线程 T 上执行,自然能 get() 到同一个对象。整个传递过程没有参数、没有上下文对象,靠的是"同一线程"这个隐含约定。

反过来说,只要这个前提被打破——请求处理中途切换了线程——ThreadLocal 里的值就跟不过去了。这是本文后面"异步坑"一节的根源。

内部结构:每个线程一张私有表

ThreadLocal 的数据并不存在 ThreadLocal 对象里。真实的结构是:

Thread 对象
└── ThreadLocalMap threadLocals (每个 Thread 自带,外部访问不到)
└── Entry[]
└── Entry
├── key: ThreadLocal 实例(弱引用)
└── value: 存入的对象(强引用,这里是 User)

#mermaid-svg-QFbGt0NzyPo9m7zC{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-QFbGt0NzyPo9m7zC .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-QFbGt0NzyPo9m7zC .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-QFbGt0NzyPo9m7zC .error-icon{fill:#552222;}#mermaid-svg-QFbGt0NzyPo9m7zC .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-QFbGt0NzyPo9m7zC .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-QFbGt0NzyPo9m7zC .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-QFbGt0NzyPo9m7zC .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-QFbGt0NzyPo9m7zC .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-QFbGt0NzyPo9m7zC .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-QFbGt0NzyPo9m7zC .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-QFbGt0NzyPo9m7zC .marker{fill:#333333;stroke:#333333;}#mermaid-svg-QFbGt0NzyPo9m7zC .marker.cross{stroke:#333333;}#mermaid-svg-QFbGt0NzyPo9m7zC svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-QFbGt0NzyPo9m7zC p{margin:0;}#mermaid-svg-QFbGt0NzyPo9m7zC .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-QFbGt0NzyPo9m7zC .cluster-label text{fill:#333;}#mermaid-svg-QFbGt0NzyPo9m7zC .cluster-label span{color:#333;}#mermaid-svg-QFbGt0NzyPo9m7zC .cluster-label span p{background-color:transparent;}#mermaid-svg-QFbGt0NzyPo9m7zC .label text,#mermaid-svg-QFbGt0NzyPo9m7zC span{fill:#333;color:#333;}#mermaid-svg-QFbGt0NzyPo9m7zC .node rect,#mermaid-svg-QFbGt0NzyPo9m7zC .node circle,#mermaid-svg-QFbGt0NzyPo9m7zC .node ellipse,#mermaid-svg-QFbGt0NzyPo9m7zC .node polygon,#mermaid-svg-QFbGt0NzyPo9m7zC .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-QFbGt0NzyPo9m7zC .rough-node .label text,#mermaid-svg-QFbGt0NzyPo9m7zC .node .label text,#mermaid-svg-QFbGt0NzyPo9m7zC .image-shape .label,#mermaid-svg-QFbGt0NzyPo9m7zC .icon-shape .label{text-anchor:middle;}#mermaid-svg-QFbGt0NzyPo9m7zC .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-QFbGt0NzyPo9m7zC .rough-node .label,#mermaid-svg-QFbGt0NzyPo9m7zC .node .label,#mermaid-svg-QFbGt0NzyPo9m7zC .image-shape .label,#mermaid-svg-QFbGt0NzyPo9m7zC .icon-shape .label{text-align:center;}#mermaid-svg-QFbGt0NzyPo9m7zC .node.clickable{cursor:pointer;}#mermaid-svg-QFbGt0NzyPo9m7zC .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-QFbGt0NzyPo9m7zC .arrowheadPath{fill:#333333;}#mermaid-svg-QFbGt0NzyPo9m7zC .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-QFbGt0NzyPo9m7zC .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-QFbGt0NzyPo9m7zC .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-QFbGt0NzyPo9m7zC .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-QFbGt0NzyPo9m7zC .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-QFbGt0NzyPo9m7zC .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-QFbGt0NzyPo9m7zC .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-QFbGt0NzyPo9m7zC .cluster text{fill:#333;}#mermaid-svg-QFbGt0NzyPo9m7zC .cluster span{color:#333;}#mermaid-svg-QFbGt0NzyPo9m7zC div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-QFbGt0NzyPo9m7zC .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-QFbGt0NzyPo9m7zC rect.text{fill:none;stroke-width:0;}#mermaid-svg-QFbGt0NzyPo9m7zC .icon-shape,#mermaid-svg-QFbGt0NzyPo9m7zC .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-QFbGt0NzyPo9m7zC .icon-shape p,#mermaid-svg-QFbGt0NzyPo9m7zC .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-QFbGt0NzyPo9m7zC .icon-shape .label rect,#mermaid-svg-QFbGt0NzyPo9m7zC .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-QFbGt0NzyPo9m7zC .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-QFbGt0NzyPo9m7zC .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-QFbGt0NzyPo9m7zC :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

Thread-1(Tomcat 工作线程)

弱引用 WeakReference

强引用

Thread-2(另一个工作线程)

ThreadLocalMap

Entry(B 用户的 User)

UserHolder 里的static ThreadLocal<User>

Entry.key

User 对象(id=106, mobile=…)

Entry.value

ThreadLocalMap

Entry

调用 tl.set(user) 时实际发生的是:拿到当前线程的 threadLocals 表,以 tl 这个 ThreadLocal 实例为 key、user 为 value 存入。tl.get() 则是拿当前线程的表,用同一个实例作 key 查出来。

线程隔离是这张表的归属关系决定的:每个线程查的是自己的表,Thread-2 的表里根本没有 Thread-1 存的 User,所以不存在"读到别人数据"的可能,也不需要加锁。

弱引用 key 与强引用 value

Entry 继承自 WeakReference<ThreadLocal<?>>,所以 key 是弱引用;但 value 是普通的强引用字段。这个不对称设计是内存泄漏讨论的核心:

  • key 弱引用是为了防止泄漏 ThreadLocal 本身:如果外部代码不再持有某个 ThreadLocal 的引用(比如 UserHolder 类被卸载的场景),GC 可以回收它,Entry 的 key 变成 null;
  • 但 key 被 GC 后,value 还被 Entry 强引用着,而 Entry 又被 Thread 的 threadLocals 表强引用着。只要线程活着,这条引用链 Thread → ThreadLocalMap → Entry → value 就断不了。

ThreadLocalMap 在 set/get 时会顺手清理 key 为 null 的陈旧 Entry(expungeStaleEntry),这是内置的自救机制,但它只在触发相关操作时生效,不能依赖它兜底。所以"线程不死 + 忘记 remove"就是标准的内存泄漏配方——而 Web 应用里 Tomcat 的工作线程几乎从不主动死亡,这正是下一节的主题。

UserHolder 工具类

先看工程位置:

mf
└── mf-app-server
└── src/main/java/com/mf/server
├── interceptor
│ ├── UserHolder.java # ThreadLocal 工具类(本文主角)
│ ├── TokenInterceptor.java # 校验 token 并写入 ThreadLocal
│ └── WebConfig.java # 注册拦截器:/** + 登录白名单
└── controller
├── UserController.java # 方法体改用 UserHolder.getUserId()
└── UsersControler.java # 同上(类名本就拼错,历史遗留)

工程实读的完整代码如下,与 mf-app-server/src/main/java/com/mf/server/interceptor/UserHolder.java 逐行一致:

package com.mf.server.interceptor;

import com.mf.model.domain.User;

/**
* 工具类:实现向threadlocal存储数据的方法
*/

public class UserHolder {

private static ThreadLocal<User> tl = new ThreadLocal<>();

//将用户对象,存入Threadlocal
public static void set(User user) {
tl.set(user);
}

//从当前线程,获取用户对象
public static User get() {
return tl.get();
}

//从当前线程,获取用户对象的id
public static Long getUserId() {
return tl.get().getId();
}

//从当前线程,获取用户对象的手机号码
public static String getMobile() {
return tl.get().getMobile();
}

//清空
public static void remove() {
tl.remove();
}
}

改造后 Controller 里的用法就一行:

Long userId = UserHolder.getUserId();

工程实读还有两处与讲义不一致的地方,顺带说明:

差异点讲义写法工程实际说明
JwtUtils 包名 com.mf.utils.JwtUtils com.mf.commons.utils.JwtUtils 讲义是早期版本,工具类后来挪进了 mf-commons 模块
拦截器基类 extends HandlerInterceptorAdapter implements HandlerInterceptor Spring 5 之后 HandlerInterceptor 接口已提供 default 方法,适配器类已不必要
token 里 id 的类型 — Integer id = (Integer) claims.get("id"); 再 Long.valueOf(id) jjwt 回读数字时小值统一反序列化成 Integer,直接强转 Long 会 ClassCastException

Controller 里还残留着四个 @RequestHeader("Authorization") String token 形参(UserController 两个、UsersControler 两个),方法体已经不再引用它们——这是改造没清干净的痕迹,参数可以删掉,留在这里只会误导读者以为 token 还在被手动解析。

改进版:取不到用户就明确失败

上面这段快照代码有个隐患:getUserId() 和 getMobile() 直接 tl.get().getId(),一旦线程上没有绑定用户(白名单路径、remove 之后、异步线程里误用),抛出的是 NullPointerException——调用方只能看到空指针,不知道真正原因是"未登录"。

讲义版本里其实写了 null 判断,但返回的是 null,把"未登录"这个信息淹没在了 NPE 的另一种形态里。更合理的做法是取不到时直接抛一个语义明确的异常:

//从当前线程,获取用户对象的id;取不到视为未登录,快速失败
public static Long getUserId() {
User user = get();
if (user == null) {
throw new RuntimeException("当前线程未绑定登录用户,请确认请求经过 TokenInterceptor");
}
return user.getId();
}

拦截器已经保证非白名单路径必有用户,理论上取不到就是代码写错了(比如在异步线程里调了 UserHolder),这种错误越早暴露越好,返回 null 只会把问题推迟到更深层的 NPE。

存 User 对象还是只存 Long id

维度存 User 对象只存 Long id
取用便利 id、mobile 直接取,后续可扩展昵称、头像 只能拿到 id,其他信息要另查
内存占用 一个 User 对象(项目里字段少,差别不大) 一个 Long
泄漏时的影响面 整个用户对象滞留在泄漏的 Entry 里 只滞留一个数字
适用场景 业务层频繁需要多个用户字段 网关/鉴权层只需要身份标识

交友项目选了存 User 对象,业务层大量用到 id 和 mobile,取舍合理。要清楚代价:一旦发生泄漏,滞留的是完整对象。如果业务里 User 很大或者数量敏感,只存 id、需要时再查库是更保守的选择。

为什么必须 remove

afterCompletion 里的那行 UserHolder.remove() 看起来像可有可无的收尾代码,实际上是整个方案的安全阀。去掉它会出两种事故。

事故一:串号(安全性事故)

Tomcat 用线程池处理请求,线程是复用的,不是用完即毁。推演一遍去掉 remove 后的时间线:

用户B的请求

工作线程 http-nio-8080-exec-3

用户A的请求

用户B的请求

工作线程 http-nio-8080-exec-3

用户A的请求

#mermaid-svg-zKfLS1dIwKL1n80P{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-zKfLS1dIwKL1n80P .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-zKfLS1dIwKL1n80P .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-zKfLS1dIwKL1n80P .error-icon{fill:#552222;}#mermaid-svg-zKfLS1dIwKL1n80P .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-zKfLS1dIwKL1n80P .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-zKfLS1dIwKL1n80P .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-zKfLS1dIwKL1n80P .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-zKfLS1dIwKL1n80P .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-zKfLS1dIwKL1n80P .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-zKfLS1dIwKL1n80P .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-zKfLS1dIwKL1n80P .marker{fill:#333333;stroke:#333333;}#mermaid-svg-zKfLS1dIwKL1n80P .marker.cross{stroke:#333333;}#mermaid-svg-zKfLS1dIwKL1n80P svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-zKfLS1dIwKL1n80P p{margin:0;}#mermaid-svg-zKfLS1dIwKL1n80P .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-zKfLS1dIwKL1n80P text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-zKfLS1dIwKL1n80P .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-zKfLS1dIwKL1n80P .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-zKfLS1dIwKL1n80P .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-zKfLS1dIwKL1n80P .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-zKfLS1dIwKL1n80P #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-zKfLS1dIwKL1n80P .sequenceNumber{fill:white;}#mermaid-svg-zKfLS1dIwKL1n80P #sequencenumber{fill:#333;}#mermaid-svg-zKfLS1dIwKL1n80P #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-zKfLS1dIwKL1n80P .messageText{fill:#333;stroke:none;}#mermaid-svg-zKfLS1dIwKL1n80P .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-zKfLS1dIwKL1n80P .labelText,#mermaid-svg-zKfLS1dIwKL1n80P .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-zKfLS1dIwKL1n80P .loopText,#mermaid-svg-zKfLS1dIwKL1n80P .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-zKfLS1dIwKL1n80P .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-zKfLS1dIwKL1n80P .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-zKfLS1dIwKL1n80P .noteText,#mermaid-svg-zKfLS1dIwKL1n80P .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-zKfLS1dIwKL1n80P .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-zKfLS1dIwKL1n80P .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-zKfLS1dIwKL1n80P .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-zKfLS1dIwKL1n80P .actorPopupMenu{position:absolute;}#mermaid-svg-zKfLS1dIwKL1n80P .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-zKfLS1dIwKL1n80P .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-zKfLS1dIwKL1n80P .actor-man circle,#mermaid-svg-zKfLS1dIwKL1n80P line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-zKfLS1dIwKL1n80P :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

初始:ThreadLocalMap 为空

线程归还线程池,userA 留在线程里

请求 /users(带A的token)

UserHolder.set(userA)

返回A的数据

同一线程被复用,请求(带B的token)

假设此路径未经 set 就调 get(异常分支漏掉等)

UserHolder.getUserId() → 返回A的id

B用户读到了A的数据

严格地说,在正常流程里 B 的请求会先经过 preHandle 重新 set,旧值会被覆盖,串号发生在某次请求没有走到 set 就调用了 get 的分支上:比如某个接口临时把 token 校验改成了可跳过、异常处理代码里直接读 UserHolder、或者新写的接口忘了被拦截器覆盖。只要线程里残留着上一个用户的对象,这些分支读到的就是别人的身份。

这类事故的特点是平时测试完全正常,上线后才偶发,而且读到的数据看起来"格式完全正确"——因为确实是某个真实用户的。排查难度远高于报错型 bug,属于最不值得赌的那种。

事故二:内存泄漏

每次请求 set 一个 User,不 remove 的话,Entry 越积越多。key 是弱引用这一点帮不上忙——UserHolder.tl 是 static 常量,永远可达,key 根本不会被回收,每个 Entry 都是"活的"。高 QPS 的服务上,这些对象叠加 GC 存活时间,最终表现就是老年代占用持续上涨,直到 java.lang.OutOfMemoryError: Java heap space。

机翻稿里讲师描述的"随着请求不断增多,ThreadLocal 里存放的用户对象越来越多,可能就会存在内存溢出问题"说的就是这个。

清理时机:afterCompletion

afterCompletion 是清理的正确位置,原因有两个:

  • 它在响应完成后必定执行——Controller 正常返回会执行,Controller 抛异常也会执行(异常对象作为 ex 参数传入),不存在"异常路径漏清理"的漏洞;
  • 它在处理该请求的同一个线程上执行——清理的就是刚刚用过的那张表,线程归池前把现场抹干净。
  • 有一个边界要准确理解:如果 preHandle 返回 false(token 无效被拦截),这个拦截器自己的 afterCompletion 不会被执行。不过此时 UserHolder.set 也没发生过,表是干净的,所以交友项目的代码在这个边界上是安全的。多拦截器串联时,afterCompletion 只会触发在"preHandle 返回 true"的拦截器上,且按注册的逆序执行。

    如果业务代码需要手动 set(比如定时任务里模拟登录用户),不要依赖外部清理,用 try-finally 把生命周期锁死:

    try {
    UserHolder.set(user);
    // 业务处理,可能抛异常
    doBusiness();
    } finally {
    UserHolder.remove();
    }

    请求全流程时序图

    把 set、取用、remove 串成一条完整链路:

    Controller / Service

    TokenInterceptor

    Tomcat 工作线程

    客户端(模拟器)

    Controller / Service

    TokenInterceptor

    Tomcat 工作线程

    客户端(模拟器)

    #mermaid-svg-9z0UYJtlavmLGeHN{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-9z0UYJtlavmLGeHN .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-9z0UYJtlavmLGeHN .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-9z0UYJtlavmLGeHN .error-icon{fill:#552222;}#mermaid-svg-9z0UYJtlavmLGeHN .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-9z0UYJtlavmLGeHN .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-9z0UYJtlavmLGeHN .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-9z0UYJtlavmLGeHN .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-9z0UYJtlavmLGeHN .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-9z0UYJtlavmLGeHN .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-9z0UYJtlavmLGeHN .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-9z0UYJtlavmLGeHN .marker{fill:#333333;stroke:#333333;}#mermaid-svg-9z0UYJtlavmLGeHN .marker.cross{stroke:#333333;}#mermaid-svg-9z0UYJtlavmLGeHN svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-9z0UYJtlavmLGeHN p{margin:0;}#mermaid-svg-9z0UYJtlavmLGeHN .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-9z0UYJtlavmLGeHN text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-9z0UYJtlavmLGeHN .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-9z0UYJtlavmLGeHN .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-9z0UYJtlavmLGeHN .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-9z0UYJtlavmLGeHN .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-9z0UYJtlavmLGeHN #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-9z0UYJtlavmLGeHN .sequenceNumber{fill:white;}#mermaid-svg-9z0UYJtlavmLGeHN #sequencenumber{fill:#333;}#mermaid-svg-9z0UYJtlavmLGeHN #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-9z0UYJtlavmLGeHN .messageText{fill:#333;stroke:none;}#mermaid-svg-9z0UYJtlavmLGeHN .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-9z0UYJtlavmLGeHN .labelText,#mermaid-svg-9z0UYJtlavmLGeHN .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-9z0UYJtlavmLGeHN .loopText,#mermaid-svg-9z0UYJtlavmLGeHN .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-9z0UYJtlavmLGeHN .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-9z0UYJtlavmLGeHN .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-9z0UYJtlavmLGeHN .noteText,#mermaid-svg-9z0UYJtlavmLGeHN .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-9z0UYJtlavmLGeHN .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-9z0UYJtlavmLGeHN .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-9z0UYJtlavmLGeHN .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-9z0UYJtlavmLGeHN .actorPopupMenu{position:absolute;}#mermaid-svg-9z0UYJtlavmLGeHN .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-9z0UYJtlavmLGeHN .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-9z0UYJtlavmLGeHN .actor-man circle,#mermaid-svg-9z0UYJtlavmLGeHN line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-9z0UYJtlavmLGeHN :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

    HTTP 请求,Header 携带 Authorization

    preHandle()

    JwtUtils.verifyToken 校验,getClaims 解析

    构造 User,UserHolder.set(user)

    放行(同一线程)

    UserHolder.getUserId() → 106

    业务响应

    afterCompletion()(无论成功异常)

    UserHolder.remove()

    线程归还线程池,表已清空

    机翻稿里记录的调试过程与此吻合:断点打在拦截器和 Controller 上,请求进来先停在 preHandle,存入 ThreadLocal;进到 Controller 时参数里的 userId 还是 null,UserHolder.getUserId() 执行后变成 106;控制器执行完毕后进入 afterCompletion,清空对应数据。

    常见坑清单

    坑现象原因正确做法
    @Async / 自建线程池里读 UserHolder 拿到 null 或 NPE 异步方法跑在别的线程上,新线程的 ThreadLocalMap 是空的 主线程先 getUserId(),把 id 作为方法参数传进异步方法;或用 TaskDecorator 在任务提交时复制上下文
    InheritableThreadLocal 当万能解 线程池场景偶尔取不到 它只在子线程对象被创建那一刻复制一次父线程的值;线程池里的线程早就创建好了,之后提交的任务继承的是池化线程当年那份陈旧快照 线程池场景不要指望它,老老实实显式传参
    ThreadLocal.withInitial(() -> new User()) 取到"空白用户",业务静默出错 取不到时会调用初始化函数给默认值,"必须取到当前登录用户"的场景下默认值掩盖了未登录的事实 保持无默认值构造,get 到 null 就抛异常(见改进版)
    单元测试不清理 用例 A set 之后,同线程跑的用例 B 读到了 A 的数据 JUnit 通常复用测试线程 每个用例 @AfterEach 里 UserHolder.remove()
    remove 后继续 get 得到 null 却当有效值用 remove 之后线程表里已无该 Entry get 结果为 null 一律按未登录处理,抛异常或返回 401
    多个拦截器都操作 ThreadLocal 清理顺序不符合直觉 afterCompletion 按注册逆序执行,先注册的后清理 后 set 的先 remove,避免误删别的拦截器的数据
    认为弱引用能防泄漏 老年代持续上涨 key 弱引用只保证 ThreadLocal 实例本身可回收;static 持有的 ThreadLocal 根本不可回收,value 又是强引用 唯一可靠的手段是请求结束时显式 remove

    常见误解澄清

    误解事实
    “数据存在 ThreadLocal 对象里” 存在每个 Thread 自带的 ThreadLocalMap 里,ThreadLocal 对象只是查表的 key
    “ThreadLocal 会自动清理,不 remove 没事” 自动清理只在 set/get 触发时清 key 为 null 的陈旧 Entry;static 的 ThreadLocal 不会被回收,value 一直滞留
    “线程隔离 = 线程安全,什么都不用做” 隔离只解决"并发互不干扰",不解决"生命周期管理",后者靠 remove
    “InheritableThreadLocal 能解决线程池传递” 只在子线程创建时继承一次,池化线程场景不可靠
    “preHandle 拦截了请求,afterCompletion 也会执行清理” preHandle 返回 false 时 afterCompletion 不执行,但此时也尚未 set,无泄漏风险
    “没复现串号就说明没问题” 串号依赖线程复用且命中未 set 的 get 分支,条件苛刻;没复现不能证明不存在

    验证实验

    三个实验都能在本章工程上直接复现,用 Postman 或模拟器发请求均可。

    实验一:正常流程取到当前用户

    在 TokenInterceptor.preHandle 的 UserHolder.set(user) 和 Controller 的 UserHolder.getUserId() 各打一个断点,Debug 启动 mf-app-server,用已登录账号请求"查看用户详情"接口。预期:先停在拦截器,放行后停在 Controller,此时 getUserId() 返回 token 里的 id(如 106)。这是方案的主链路验证。

    实验二:串号实验(注释掉 remove)

    临时注释掉 afterCompletion 里的 UserHolder.remove(),重启服务,用两个账号的 token 交替请求一个"返回当前用户名"的接口(可临时在 Controller 里 return UserHolder.get().getMobile() 观察)。

    // 临时验证用:返回当前线程绑定的手机号,验证完删除
    @GetMapping("/tmp/whoami")
    public ResponseEntity whoami() {
    return ResponseEntity.ok(UserHolder.get() == null ? "no-user" : UserHolder.get().getMobile());
    }

    要如实说明预期:串号不是必现的。它需要两个条件叠加——下一次请求恰好复用了同一个 Tomcat 工作线程,且该次请求没有经过 set(正常流程每次都会覆盖)。低并发下线程池倾向复用最近空闲的线程,连续快速交替请求有一定概率命中;在拦截器正常 set 的前提下,覆盖逻辑也会掩盖串号。所以这个实验的意义在于观察线程复用与残留现象,而不能靠"没复现"证明不 remove 也没问题——生产环境里未覆盖的 get 分支 + 残留值才是真实事故形态。串号的确定性复现可以脱离 Web 环境做:

    public class CrossUserDemo {

    private static final ThreadLocal<String> TL = new ThreadLocal<>();

    public static void main(String[] args) {
    ExecutorService pool = Executors.newFixedThreadPool(1);
    // 第一个"用户":set 后不 remove,模拟被注释掉的 afterCompletion
    pool.submit(() -> TL.set("user-A"));
    // 第二个"用户":直接读,不经过 set(模拟未覆盖的 get 分支)
    pool.submit(() -> System.out.println("读到的用户: " + TL.get()));
    pool.shutdown();
    }
    }

    单线程池保证必然复用同一个线程,输出稳定是 读到的用户: user-A——这就是串号的本质。

    实验三:@Async 里读 UserHolder 为 null

    @Service
    public class AsyncDemoService {

    @Async
    public void readUser() {
    // 大概率输出 null:异步线程的 ThreadLocalMap 里没有拦截器 set 的值
    System.out.println("async user = " + UserHolder.get());
    }
    }

    @GetMapping("/tmp/async")
    public ResponseEntity async() {
    asyncDemoService.readUser();
    return ResponseEntity.ok("done");
    }

    启动类加 @EnableAsync,请求 /tmp/async,控制台输出 async user = null。修法是显式传参:调用方先取 Long userId = UserHolder.getUserId(),把它作为参数传进异步方法。线程在请求中途切换的那一刻,ThreadLocal 上下文就断了——这是这套方案唯一要时刻记住的边界。

    官方文档

    • ThreadLocal (Java SE API) —— set/get/remove 的语义与线程局部变量的定义
    • Spring MVC 拦截器 —— preHandle / postHandle / afterCompletion 的执行时机
    • Spring 任务与异步调度 —— @Async 线程池与 TaskDecorator,跨线程传递上下文的官方入口

    总结

    • 拦截器 + ThreadLocal 的组合之所以成立,根基是 Spring MVC 同步模型下"一个请求一个线程从头处理到尾";ThreadLocalMap 挂在每个 Thread 上,key 是 ThreadLocal 实例(弱引用),value 是存入的对象(强引用),线程隔离由此天然形成。
    • remove 不是可选项。线程回池复用带来串号风险,key 弱引用救不了强引用的 value,内存泄漏随之而来;清理位置选在 afterCompletion,因为它在异常路径上也执行、也在同一线程上执行。
    • getter 建议取不到就抛异常,把"未登录/上下文丢失"暴露成明确错误,而不是用 null 或 withInitial 默认值把问题埋进业务层。
    • 线程一切换(@Async、自建线程池、CompletableFuture),ThreadLocal 上下文即失效;InheritableThreadLocal 只解决"创建时继承",线程池场景必须显式传参或 TaskDecorator。
    • 串号没复现不代表安全,脱离 Web 环境的固定线程池 demo 可以确定性地复现它。

    统一 token 处理至此闭环:拦截器管校验,UserHolder 管存取,afterCompletion 管清理。这套链路跑通后,Controller 里再也没有 token 解析代码——但业务代码一多,各种异常的处理散落各层的问题又浮出水面,下一节进入统一异常处理:概述见已交付的《代码优化:异常处理的概述》,具体落地见《代码优化:异常处理的代码实现》。

    赞(0)
    未经允许不得转载:171主机测评 » Java 项目实战: 交友项目 - ThreadLocal 统一 token 处理
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址