
1. 为什么非要用 ThreadLocal 存用户信息1.1 从一次普通的登录鉴权说起先问个扎心的问题你写接口时是怎么拿当前登录用户的我见过不少项目每个 Controller 方法里都来一遍request.getHeader(token)然后调工具类解析、查库、转对象、判断空指针。一个接口这样写还能忍几十个接口全都这样维护起来简直想骂人。更要命的是如果哪天 token 格式变了、或者要加一层缓存校验你得把所有接口翻出来改一遍漏一个就等着线上出事。ThreadLocal 解决的就是这个问题在请求入口处把用户信息放进去业务代码直接用请求结束自动清掉。这样一来业务层完全不用关心用户信息从哪来、怎么解析只管调用就完事。而且它是线程隔离的天然符合 HTTP 请求一个请求一个线程的处理模型。我最初接触 ThreadLocal 是在做老项目的登录模块时当时还没有 Spring Security、Shiro 这种成熟的鉴权框架后端代码里到处是从 redis 里查 session 的重复逻辑。后来用 ThreadLocal 把用户上下文统一管理以后整个 Controller 层清爽了很多重构的时候只动了拦截器和工具类业务代码一行没改。这就是 ThreadLocal Spring Boot 组合最核心的价值。1.2 ThreadLocal 的核心原理与内存模型ThreadLocal 说白了就是每个线程自带一块独立的小储物柜。不同线程访问同一个 ThreadLocal 对象实际上操作的是各自线程自己的那份数据互相不干扰。具体到 JVM 层面每个Thread对象内部维护了一个ThreadLocalMap在 JDK 8 及以后版本中叫ThreadLocal.ThreadLocalMap它的 key 是 ThreadLocal 实例本身准确说是一个WeakReferenceThreadLocal?弱引用value 就是你要存的对象。用代码表示大概是这样的结构class Thread { ThreadLocal.ThreadLocalMap threadLocals; // 每个线程自己的存储空间 } class ThreadLocalT { public void set(T value) { Thread t Thread.currentThread(); ThreadLocalMap map getMap(t); map.set(this, value); // 以当前 ThreadLocal 为 key 存值 } public T get() { Thread t Thread.currentThread(); ThreadLocalMap map getMap(t); return map.getEntry(this).value; } public void remove() { ThreadLocalMap m getMap(Thread.currentThread()); m.remove(this); } }这里有个非常关键的细节ThreadLocalMap 的 key 是弱引用。也就是说如果外部没有强引用指向 ThreadLocal 实例GC 时这个 key 会被回收掉变成key null的脏条目。但 value 仍然存在因为它被 ThreadLocalMap 的 value 数组强引用着。如果线程一直存活着比如 Tomcat 的 Worker 线程、线程池里的线程value 就永远不会被回收这就埋下了内存泄漏的隐患。所以 ThreadLocal 有一个铁律存了什么用完必须 remove。这个话题我在第 4 部分会详细展开这里先记住结论。1.3 和 request、session 方案对比的取舍有人可能会说Spring MVC 的 Controller 方法直接声明HttpServletRequest request参数不也能拿到用户信息吗确实能但有几个问题侵入性强每个方法都要加参数代码冗余度很高。静态方法里拿不到很多工具类方法是静态的没有 request 对象你总不能每个方法都手动传参进去。和业务代码耦合工具类被迫依赖 Servlet API一旦脱离 Web 环境比如定时任务、MQ 消费者里也要用用户信息这套方案就废了。Session 方案的问题更多。现在的主流项目基本都是前后端分离后端接口大多是无状态的靠 JWT 或自定义 token 来做认证。Session 天然有状态水平扩展时还得考虑 Session 同步或集中存储复杂度蹭蹭往上涨。综合来看ThreadLocal 在这个场景下是最合适的请求入口统一处理 - 中间任意位置可获取 - 请求结束统一清理同时不污染方法签名不依赖 Servlet API。后面你会看到把用户信息封装成 UserContext 以后不管是 Controller、Service 还是 Mapper 层都可以随时调UserContext.getUserId()拿到当前用户 ID。2. 落地前的关键思考方案设计2.1 需求拆解这一层到底要解决哪些问题在做 ThreadLocal 存用户信息之前先别急着写代码想清楚需求边界。我一般会拆成四个问题放什么是整个 User 对象还是只放 userId如果只放 userId业务层需要更多用户资料时还得再查一次库如果放整个 User 对象又涉及信息变更后的时效性问题。我的建议是放一个精简的 UserInfo 对象包含 id、用户名、昵称、角色/权限集合等常用字段既满足绝大多数业务场景又不至于把整个用户表所有字段都塞进去。在哪放Filter 还是 Interceptor这两个的执行时机和适用场景不同我在 2.3 会专门对比。怎么放从 token 里解析还是从 session 里取现在主流是 JWT 或者自定义 token Redis。核心逻辑大同小异根据请求头里的凭证信息解析出用户身份。什么时候清必须在请求彻底结束后移除否则线程复用后下一个请求会读到上一个用户的数据。这是 ThreadLocal 使用中最容易出问题的地方。把需求想透以后代码实现其实就是一层窗户纸。2.2 UserContext 工具类的三种设计模式对比设计 ThreadLocal 封装类的时候我看过很多种写法最常见的三种写法一静态方法版最推荐public class UserContext { private static final ThreadLocalUserInfo HOLDER new ThreadLocal(); private UserContext() {} public static void set(UserInfo userInfo) { HOLDER.set(userInfo); } public static UserInfo get() { return HOLDER.get(); } public static Long getUserId() { UserInfo userInfo HOLDER.get(); return userInfo ! null ? userInfo.getId() : null; } public static String getUsername() { UserInfo userInfo HOLDER.get(); return userInfo ! null ? userInfo.getUsername() : null; } public static void clear() { HOLDER.remove(); } }写法二继承 ThreadLocal 并重写 initialValuepublic class UserContextHolder extends ThreadLocalUserInfo { Override protected UserInfo initialValue() { // 返回一个默认空对象避免 get() 返回 null 导致到处判空 return new UserInfo(); } }这种写法的好处是get()永远不为 null但不推荐在用户上下文场景用——它会把用户不存在和用户存在但信息为空混在一起反而增加判断难度。实际上你还是要判断当前用户是否已登录、ID是否有效。写法三泛型单例版public class UserContext { private static final ThreadLocalMapString, Object CONTEXT new ThreadLocal(); public static void set(String key, Object value) { ... } public static Object get(String key) { ... } }这种搞个 Map 作为值看起来灵活实际上把类型安全丢掉了取出来还得强转而且 key 字符串容易打错除非你要在一个上下文里塞很多不同类型的值否则没必要用这个方案。我最终推荐的是第一种静态方法 泛型 ThreadLocal。理由很简单——代码少、类型安全、调用顺手。UserContext.set(user)、UserContext.get()、UserContext.getUserId()读起来跟自然语言一样后面在业务代码里用起来非常舒适。2.3 拦截器还是过滤器这步必须想清楚Spring Boot 里有两个地方可以放入用户信息进去HandlerInterceptor拦截器和Filter过滤器。很多人分不清随手选了一个结果后面踩坑。两者的核心区别在于执行时机Filter是 Servlet 容器层面的在 Spring MVC 的 DispatcherServlet 之前执行。它的特点是能拦截到所有请求包括静态资源但此时 Spring 的注解式开发体系比如Controller、Autowired还没有完全生效你想在里面注入一个 Service 调用业务方法需要一些额外配置。Interceptor是 Spring MVC 层面的在请求被 HandlerMapping 定位到具体 Handler 之后、执行 Handler 方法之前执行。在这里可以访问 Handler 对象本身也能拿到请求和响应对象。推荐在这个环节做用户信息的注入因为它可以做得更精细比如通过路径匹配规则排除掉/login、/register等白名单接口。从团队协作的角度来看拦截器可以选择性放行某些 URL对白名单的控制粒度更细正好满足用户认证的需求场景。Filter 适合做XSS 过滤、CORS 跨域处理、字符编码设置这类更底层的工作。所以结论是在 Spring Boot 项目中存放用户信息这件事优先选拦截器。这是最符合 Spring MVC 设计哲学的做法也最容易维护。3. 核心代码实现一步步搭建用户上下文3.1 搭建项目基础依赖与启动类这一步假设你已经有一个 Spring Boot 项目了。如果是从零开始的注意一下 Spring Boot 版本选择。搜索热词里经常看到springboot版本太高、Spring Boot 2.x vs 3.x这类问题我的建议是如果是新项目且团队用的是 JDK 17直接上 Spring Boot 3.x性能有提升生态也跟得上。如果历史项目是 JDK 8老老实实用 Spring Boot 2.7.x别盲目升级否则会踩一堆 jakarta 包名迁移的坑。本文的代码基于Spring Boot 2.7.x JDK 8编写如果你用的是 Spring Boot 3.x需要把javax.servlet.*换成jakarta.servlet.*其他核心逻辑完全一致。在pom.xml中只需要引入最基础的 Web 依赖即可dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency启动类没什么特殊的标准的SpringBootApplication就可以。用户上下文的初始化工作完全由拦截器完成不需要额外写配置类。3.2 定义 UserInfo 实体与 UserContext 工具类用户信息实体不用太复杂保持精简即可。我会定义一个UserInfo类包含后续在业务中最常用的几个字段public class UserInfo { private Long id; private String username; private String nickname; private String avatar; private SetString roles; public UserInfo() {} public UserInfo(Long id, String username, String nickname, String avatar, SetString roles) { this.id id; this.username username; this.nickname nickname; this.avatar avatar; this.roles roles; } // getter / setter 省略 }然后就是 UserContext 工具类代码我在 2.2 已经给过完整版了。这里再补充几个在实际项目里非常有用的快捷方法public static boolean isLoggedIn() { return HOLDER.get() ! null; } public static boolean hasRole(String role) { UserInfo userInfo HOLDER.get(); return userInfo ! null userInfo.getRoles() ! null userInfo.getRoles().contains(role); }isLoggedIn()在拦截器里判断当前用户是否已认证时会用到hasRole()可以配合自定义注解实现简单的权限校验。这里有个小细节getUserId()当用户不存在时返回 null而不是抛异常。这样写的目的是让业务代码自己决定是否需要对用户不存在做处理。有些接口因为路由已经经过鉴权所以进入业务逻辑时用户一定存在而有些接口比如某些公共查询接口可能允许未登录用户访问此时你可以写成Long userId UserContext.getUserId();然后判断if (userId null)走未登录逻辑这样灵活性会高很多。3.3 实现认证拦截器解析请求并写入用户信息核心环节来了。拦截器的职责有两块识别请求携带的凭证并解析出用户信息然后存入 UserContext。我用 JWT 作为示例用自定义 token Redis 的做法本质相同只是把解析过程换成查缓存。Component public class AuthInterceptor implements HandlerInterceptor { Autowired private JwtTokenService jwtTokenService; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 如果不是 Controller 方法请求直接放行比如静态资源映射 if (!(handler instanceof HandlerMethod)) { return true; } String token request.getHeader(Authorization); if (StringUtils.hasText(token) token.startsWith(Bearer )) { token token.substring(7); } if (StringUtils.hasText(token)) { try { UserInfo userInfo jwtTokenService.parseToken(token); UserContext.set(userInfo); } catch (JwtException | IllegalArgumentException e) { // token 无效或过期按未登录处理 log.warn(parse token failed: {}, e.getMessage()); } } return true; } Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { // 请求处理完成无论成功失败都必须清理 ThreadLocal防止内存泄漏和串数据 UserContext.clear(); } }注意几个关键点preHandle里只负责有 token 就解析并放入上下文不在这里拦截未登录的请求。如果你需要在某些接口强制用户登录可以在这个类里校验UserContext.get() null时返回 401也可以放在单独的登录检查拦截器里做。我个人的习惯是解析和鉴权分开拦截器只负责解析鉴权交给 Spring Security 或 Shiro 来处理这样职责更清晰。afterCompletion里执行UserContext.clear()是铁律必须写在finally异步中框架会保证afterCompletion最终被执行否则就是给自己埋雷。如果 token 解析失败但不影响请求继续比如一些可选的用户信息的展示场景直接捕获异常记日志即可不需要在拦截器里强制报错。3.4 注册拦截器并配置白名单路径有了拦截器还需要注册到 Spring MVC 中。通过实现WebMvcConfigurer接口可以配置哪些路径放行、哪些路径拦截Configuration public class WebMvcConfig implements WebMvcConfigurer { Autowired private AuthInterceptor authInterceptor; Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(authInterceptor) .addPathPatterns(/**) // 拦截所有请求 .excludePathPatterns( /login, /register, /captcha, /doc.html, /webjars/**, /v3/api-docs/** ); } }白名单里放行了登录、注册、验证码、接口文档这些不需要登录就能访问的路径。实际项目中建议把 Knife4j / Swagger 相关的资源路径也加到白名单里不然开发调试接口文档时会很痛苦。注册完成后Spring Boot 会在启动时扫描到WebMvcConfig这个配置类自动加载拦截器配置。需要留意的是如果你用的是 Spring Boot 3.xWebMvcConfigurer的包路径变了但接口方法名完全一样直接替换 import 即可。3.5 在业务代码中优雅地获取当前用户到了这一步业务的 Controller 和 Service 就可以直接用 UserContext 获取用户信息了。Controller 层示例RestController RequestMapping(/api/order) public class OrderController { Autowired private OrderService orderService; GetMapping(/list) public ResultListOrderVO list() { Long userId UserContext.getUserId(); // 从 ThreadLocal 获取当前用户 ID return Result.success(orderService.listByUser(userId)); } }Service 层示例Service public class OrderServiceImpl implements OrderService { Override public ListOrderVO listByUser(Long userId) { Assert.notNull(userId, 用户未登录); // 业务逻辑... } }你会发现业务层完全不需要感知用户信息是怎么放进去的只管取用就行。而且整个代码没有任何额外的参数传递接口签名保持干净。再举一个实际例子假设有一个发布文章的接口需要把当前用户设为文章作者。传统写法是 Controller 接收一个userId参数前端传啥用啥这其实非常危险——用户可以伪造别人的 ID 来发文章。正确的姿势就是后端自己从 UserContext 取无论如何不信任前端传的用户标识字段。4. 想上线这些坑你必须提前知道4.1 内存泄漏remove 不是可选项而是必选项这是 ThreadLocal 最经典的坑我再强调一遍。Tomcat 处理请求时使用线程池线程执行完请求后并不会销毁而是归还给线程池继续处理下一个请求。如果你在请求开头向 ThreadLocal 写了用户信息请求结束时没有 remove那么这个线程在处理下一个请求时ThreadLocal 里还残留着上一个用户的数据。有两种可能的结果如果下一个请求也触发了 ThreadLocal 写入旧数据会被覆盖看起来没事但实际上上一个用户的对象引用仍然存活在线程中GC 无法回收长时间运行下来可能造成内存占用不断上升。更可怕的是如果下一个请求没有写入用户信息比如走了白名单路径业务代码调用UserContext.get()时就会拿到上一个用户的信息造成用户数据串线。这种 Bug 在测试环境很难发现因为本机调试时 Tomcat 线程不多并发量低线上高并发下就会偶发出现。所以UserContext.clear()必须放在afterCompletion中确保即使业务抛异常也能被 Spring MVC 捕获并执行到这一步。这是防止生产事故的关键代码。4.2 异步线程与线程池场景ThreadLocal 失效了ThreadLocal 是基于当前线程的一旦代码跑到了新的线程里就拿不到原来线程里的用户信息了。这在 Spring Boot 项目中非常常见比如用Async异步执行邮件发送、短信通知用ExecutorService线程池并行处理批量任务用 MQ 消费者接收消息并处理业务。以上场景中如果你在异步线程里调用UserContext.get()得到的结果一定是 null。解决方案有几种手动传递在提交任务之前把需要的用户 ID 取出来作为方法参数传给异步任务。代码最直观但对方法签名有侵入。使用装饰器通过TaskDecorator把主线程的 ThreadLocalMap 拷贝到子线程中。Spring 的ThreadPoolTaskExecutor支持setTaskDecorator可以这样实现Bean public ThreadPoolTaskExecutor taskExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(5); executor.setMaxPoolSize(10); executor.setQueueCapacity(100); executor.setTaskDecorator(runnable - { // 保存主线程中的用户上下文 UserInfo userInfo UserContext.get(); return () - { try { UserContext.set(userInfo); runnable.run(); } finally { UserContext.clear(); } }; }); executor.initialize(); return executor; }使用 TransmittableThreadLocal这是阿里开源的 ttl 框架能够自动将主线程的 ThreadLocal 值传递给子线程适合大规模使用线程池传递上下文的场景。如果你的项目里异步场景非常多直接用 TTL 一劳永逸如果只是偶尔一两个异步任务手动传递参数就够了。不要为了一个偶尔的需求引入额外的依赖。4.3 从 ThreadLocal 到 TraceId用户上下文的扩展思路用户信息放在 ThreadLocal 里之后一个非常自然的扩展需求是在日志中打印当前用户 ID。你可以在日志配置的 pattern 中加入一个 MDC 字段然后通过一个过滤器或 AOP 切面把用户 ID 放入 MDCComponent public class UserLogFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { try { MDC.put(userId, String.valueOf(UserContext.getUserId())); chain.doFilter(request, response); } finally { MDC.remove(userId); } } }这样在 logback 的 pattern 里配置%X{userId}就能把用户 ID 打印到每行日志中。线上排查问题时根据用户 ID 检索日志会快很多。进一步地你在微服务调用链中也可以用 ThreadLocal 存放 traceId、spanId实现调用链追踪。此时建议把 UserContext 拆成更通用的RequestContext其中包含 userId、traceId、请求来源等字段让 ThreadLocal 容器类有更大的扩展空间。4.4 常见问题速查表问题现象根本原因解决方案高并发下偶发出现用户串号ThreadLocal 未 remove线程复用读到脏数据在afterCompletion中调用UserContext.clear()内存占用持续上涨ThreadLocal 的 value 被回收key 为弱引用value 残留同上务必 remove异步任务里UserContext.get()为 nullThreadLocal 线程隔离子线程不共享主线程数据手动传参 / TaskDecorator / TransmittableThreadLocal静态方法里拿不到用户信息没有把用户信息放入当前线程的 ThreadLocal在入口拦截器中统一放入网关服务转发场景用户信息丢失服务间调用时没有传递用户信息头用 Feign 拦截器或 RestTemplate 拦截器传递 userIdSpring Boot 3.x 启动报错找不到javax.servlet.*依赖包名从 javax 迁移到 jakarta使用jakarta.servlet.*或降低版本4.5 我用在实际项目中的两个建议建议一UserContext 的静态实例要用private static final修饰。不要图方便写public static ThreadLocalUserInfo USER_INFO new ThreadLocal()直接把 ThreadLocal 对象暴露出去。原因很简单暴露出去后其他开发人员可能会直接调用set、get、remove破坏了封装性。一旦某段代码随意 remove业务层就会间歇性取不到用户排查起来非常费劲。保持 UserContext 的静态方法入口隔离实现细节是我在这个模块上最坚持的一点。建议二清理动作要放在 finally 或 afterCompletion不要只放在 try 里。很多初学者会在拦截器的 preHandle 里写try { 业务逻辑 } finally { UserContext.clear(); }但这样一旦异常被上层框架捕获顺序上可能出现问题。正确做法是依赖 Spring MVC 的拦截器生命周期把清理动作放到afterCompletion中这个方法是无论处理过程是否抛异常都会被调用的兜底钩子时序上最安全。踩过几次坑之后我再也没有在业务代码里手动清过 ThreadLocal。5. 环境差异与版本兼容性实战5.1 Spring Boot 2.x 和 3.x 的兼容性处理身边不少朋友在学习和面试时都遇到过springboot版本太高的问题。这个点放到 ThreadLocal 这个场景来说主要是Servlet API 包名迁移的问题。Spring Boot 3.x 内置的是 Servlet 6.0包名从javax.servlet改成了jakarta.servlet。如果你的拦截器或 Filter 里依赖了HttpServletRequest、HttpServletResponse升级到 Spring Boot 3.x 后需要把 import 语句全部替换。具体到本文的代码需要改动的地方其实只有一处HandlerInterceptor接口本来就在org.springframework.web.servlet包下不涉及 servlet 包变动只有拦截器实现方法参数里的HttpServletRequest和HttpServletResponse需要从javax换成jakarta。这里有个实用技巧如果团队正在做 2.x 到 3.x 的迁移可以先全局搜索javax.servlet.然后批量替换为jakarta.servlet.。注意javax.validation等相关依赖也可能存在包名迁移问题建议做完全局替换后跑一遍编译和关键流程的冒烟测试。5.2 不同启动方式下的拦截器生效验证有时候代码写对了但拦截器就是不生效。这里列两个我在实际开发中遇到的典型场景Spring Boot 应用没有继承 SpringBootServletInitializer如果你通过外置 Tomcat 以 war 包方式部署项目但主启动类没有继承SpringBootServletInitializer并重写configure方法Spring Boot 的自动配置就不会完全生效包括 WebMvcConfigurer 的配置可能被忽略。解决方案是让主类继承该类。项目内存在多个 WebMvcConfigurer如果在不同模块定义了多个WebMvcConfigurer并添加了同路径模式的拦截器它们的执行顺序不一定是声明顺序。此时可以用registry.addInterceptor时设置.order(0)、.order(1)来明确顺序防止某个拦截器因为顺序问题先返回了 false 导致后面的拦截器不执行。5.3 从 SpringMVC 老工程迁移过来要注意什么搜索热词里有springmvc工程如何改造成springboot工程这个问题这里也顺带提一下和 ThreadLocal 相关联的部分。老的 SpringMVC 项目通常使用 web.xml 配置过滤器比如你可能有一个叫UserContextFilter的类在里面每次都session.getAttribute后放到 ThreadLocal 里。改造时可以把 Filter 改成 Spring Boot 的注册方式Bean public FilterRegistrationBeanUserContextFilter userContextFilter() { FilterRegistrationBeanUserContextFilter registrationBean new FilterRegistrationBean(); registrationBean.setFilter(new UserContextFilter()); registrationBean.addUrlPatterns(/*); registrationBean.setOrder(1); return registrationBean; }或者干脆换成拦截器方案代码更贴合 Spring Boot 的生态习惯。老工程里如果已经有了一套 ThreadLocal 封装核心的 UserContext 类可以直接搬过来只是把创建和清理的时机重新接入到新框架的生命周期里即可。真正容易出问题的反而是那些隐藏的引用比如某些工具类直接调用了具体线程的 ThreadLocal 变量改造后代码路径变了容易漏清理。