ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Spring Boot实战:ThreadLocal实现用户上下文与线程隔离

Spring Boot实战:ThreadLocal实现用户上下文与线程隔离 前几天组里一个新同事问我每个接口都要从请求头里解析 token 拿用户信息写一遍就算了几十个接口都这么干能不能像全局变量一样在代码的任何位置直接拿到当前登录用户我回了一句能但这个全局变量有一个关键前提——它必须是线程私有的。这正是 ThreadLocal 在 Spring Boot 项目里最经典的应用场景在请求处理链路中存放当前登录用户信息。最近评审一个老项目时我又看到了那段熟悉的代码Controller 第一行解析 headerService 里靠参数传 userId工具类里还要再取一次。整条链路里用户信息被传来传去稍不留神某个方法漏传参数就只能重新解析一遍 token。这种问题用 ThreadLocal 可以一次性解决。这篇文章我会从底层原理讲到 Spring Boot 里的落地代码再聊线程池、异步任务这些容易翻车的地方最后补充一下面试和选型时怎么答得漂亮。适合正在写登录拦截器、处理用户上下文或者准备把 ThreadLocal 讲透的同学。1. ThreadLocal 到底是怎么做到线程隔离的底层三件事必须看清1.1 每个线程都有一份私有的储物柜清单ThreadLocal 这个名字很容易被误解成线程内的局部变量。其实它更像一把储物柜钥匙ThreadLocal 对象本身不存业务数据真正的数据存放在各个线程自己的 ThreadLocalMap 里。当你调用threadLocal.set(value)的时候底层做的是两件事先拿到当前线程Thread.currentThread()再往这个线程的 ThreadLocalMap 里写入一条记录key 是当前这个 ThreadLocal 实例value 是你传入的业务数据。当你调用get()的时候就是用同一个 ThreadLocal 实例去当前线程的 ThreadLocalMap 里查对应的 value。这个设计带来一个很自然的结论同一个 ThreadLocal 对象在不同线程里调用get()拿到的值互不干扰。想象一下酒店入住时每个人手里都有一把自己的储物柜钥匙虽然钥匙外形都一样同一个 ThreadLocal 实例但每个人只能打开自己的柜子。这就是线程隔离的含义——它不是锁不保证互斥而是通过把数据绑定到线程上从源头避免了并发读取同一个变量的可能性。很多人刚接触时会拿它和synchronized对比这是个误区。synchronized 是让多个线程竞争同一个资源强调的是互斥访问ThreadLocal 是让每个线程各自持有一份资源副本强调的是互不干扰。一个用户信息变量如果用全局静态变量存所有线程都会读到同一个引用需要加锁才安全如果用 ThreadLocal 存每个线程都有自己的 userId天然不存在竞争。1.2 key 弱引用这是内存设计的真正考点面试聊 ThreadLocal十有八九会被问到内存泄漏。这就要说到 ThreadLocalMap 里的 Entry 结构。每个 Entry 的 key 是对 ThreadLocal 实例的 WeakReference弱引用而 value 是强引用。为什么 key 要设计成弱引用假如 key 是强引用那么只要当前线程还活着ThreadLocalMap 里就永远持有 ThreadLocal 的引用。即使外层代码已经把threadLocal变量置为 null这个对象也释放不掉等于被线程绑架了。用弱引用之后只要外部没有强引用指向 ThreadLocalGC 时 key 就会被回收Entry 变成 key 为 null 的脏数据。但注意value 还是强引用如果一直不清理value 依然无法回收。所以后面一定要配合remove()使用这是解决内存泄漏的真正核心。顺着这条线还可以再往深处想一步既然 key 是弱引用那 GC 之后 key 变 nullvalue 还在下次访问这个 Entry 时才发现异常。ThreadLocalMap 在 get、set 时会顺手清理一部分 key 为 null 的脏 Entry但这种清理是惰性的不是实时的。完全依赖它不靠谱必须靠手动 remove。1.3 哈希冲突ThreadLocalMap 用的是占坑法还有一个底层细节值得知道ThreadLocalMap 处理哈希冲突的方式和 HashMap 不一样。HashMap 用的是链表 红黑树ThreadLocalMap 用的是开放定址法里的线性探测。简单说如果计算出的桶位已经被占用就继续往后找下一个空位而不是在同一个桶里挂链表。初始容量是 16负载因子是 2/3所以当元素超过 10 个左右就会扩容。理解这个机制对日常开发没有直接的 API 影响但在面试里能说出开放定址法 线性探测这八个字说服力会强很多。这也解释了为什么 ThreadLocal 不适合存放大量数据——理论上一个线程可以创建无数个 ThreadLocal但 ThreadLocalMap 的线性探测在数据多的时候性能会下降而且每个 Entry 都占用数组空间。如果确实需要在一个线程里存很多值更好的做法是封装成一个对象塞进同一个 ThreadLocal而不是搞十几个 ThreadLocal 变量。2. Spring Boot 落地UserContext 拦截器让业务代码一行拿到用户2.1 用户上下文类存 userId 而不是整个 User 对象我见过不少团队直接在 ThreadLocal 里放一个完整的 User 实体类这种做法有两个问题。第一User 实体通常包含密码、手机号、邮箱等敏感字段放到 ThreadLocal 里等于在整个请求链路中都能被访问打印日志、序列化的时候容易把敏感信息泄漏出去。第二User 对象在请求过程中如果被某个业务代码改了字段后面再取出来就是脏数据。所以我更建议只放 userId业务需要其它信息时再查库。在实际项目里这个类的设计越简单越好。我一般会定义一个UserContext作为静态工具类把 ThreadLocal 实例封装在内部对外只暴露 get、set、clear 三个方法public class UserContext { private static final ThreadLocalLong USER_ID_HOLDER new ThreadLocal(); public static void setUserId(Long userId) { USER_ID_HOLDER.set(userId); } public static Long getUserId() { return USER_ID_HOLDER.get(); } public static void clear() { USER_ID_HOLDER.remove(); } }业务代码只跟 UserContext 打交道不直接操作 ThreadLocal。后续就算要改成别的存储结构也只改这一个类不会牵连几十个 Controller。如果你还需要存放当前请求的 traceId、门店 ID、租户 ID 这类上下文信息可以定义多个 ThreadLocal但我更推荐用一个上下文对象统一封装避免 ThreadLocal 数量膨胀。2.2 拦截器里完成解析—写入—清理全流程这一步是核心。通过HandlerInterceptor的preHandle解析 token 写入 ThreadLocal再通过afterCompletion清理。代码模板如下Component public class LoginInterceptor implements HandlerInterceptor { private final TokenService tokenService; public LoginInterceptor(TokenService tokenService) { this.tokenService tokenService; } Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(Authorization); if (StringUtils.hasText(token)) { Long userId tokenService.parseToken(token); if (userId ! null) { UserContext.setUserId(userId); } } // 这里可以加未登录的校验逻辑 return true; } Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { UserContext.clear(); } }几点取舍说明一下。第一token 解析只做一次解析结果放进 ThreadLocal后续所有业务代码直接取避免每个方法重复解析。第二清理放在afterCompletion而不是preHandle返回 false 的那条分支里因为只要preHandle执行了 set即使后续 Controller 抛出异常afterCompletion也一定会被调用这是拦截器生命周期里最稳妥的清理时机。这里有一个容易被忽略的执行细节如果preHandle返回 false当前拦截器的afterCompletion并不会执行。换句话说如果你在preHandle里先 set 了用户信息然后因为未登录等原因返回 falseThreadLocal 就失去了清理时机。所以更稳妥的处理顺序是先解析和校验全部通过后再 set 用户信息最后返回 true。有同学问那返回 false 之前要不要手动 remove要的建议在校验失败分支里先UserContext.clear()再返回 false避免脏数据残留。2.3 注册拦截器别把静态资源和开放接口也拦了拦截器类写好之后还得注册到WebMvcConfigurer里。这一步最容易翻车的是排除路径没写好导致登录接口自己也被拦截然后死循环或者 401 报错。我常用的注册配置如下Configuration public class WebConfig implements WebMvcConfigurer { private final LoginInterceptor loginInterceptor; public WebConfig(LoginInterceptor loginInterceptor) { this.loginInterceptor loginInterceptor; } Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(loginInterceptor) .addPathPatterns(/**) .excludePathPatterns(/login, /register, /captcha, /doc.html, /webjars/**); } }这里的经验是把认证相关接口、Swagger/接口文档静态资源、健康检查接口都列进排除清单。如果项目里还有 Spring Security 或者 Sa-Token 这类安全框架要和拦截器的执行顺序配合好不要在框架已经做了认证之后又在拦截器里重复解析一遍 token。另外提一句如果项目里同时有 Filter 和 Interceptor不要在 Filter 里也塞一份同样的 ThreadLocal 逻辑。两套链路同时执行清理时机一旦错乱线程池复用时很容易串号。2.4 业务代码改造从参数传遍天下到直接取改造前Controller 里的代码通常长这样GetMapping(/profile) public ResultUserProfileVO profile(RequestHeader(Authorization) String token) { Long userId tokenService.parseToken(token); User user userService.getById(userId); return Result.ok(UserProfileVO.from(user)); }改造后Controller 方法签名干净了很多GetMapping(/profile) public ResultUserProfileVO profile() { User user userService.getById(UserContext.getUserId()); return Result.ok(UserProfileVO.from(user)); }Service 层、工具类、AOP 切面里都能拿不再需要一路传参。如果项目对登录态有强校验需求还可以再加一个自定义注解比如RequireLogin配合拦截器或者 AOP 统一判断UserContext.getUserId()是否为空未登录直接抛 401。这样整个登录链路的代码就从重复解析收敛为一次解析 随处可取 统一校验。3. 线程池复用、异步丢失、内存泄漏三个能把人坑哭的 ThreadLocal 陷阱3.1 线程池复用导致的串号事故Tomcat 处理请求的线程是复用的你会在日志里看到http-nio-8080-exec-1、http-nio-8080-exec-2这样的线程名反复处理不同请求。如果在afterCompletion里没有调用UserContext.clear()那么处理请求 A 时留在 ThreadLocalMap 里的 userId会在请求 B 进来时被同一线程读到。轻则打印日志时用户错乱重则越权访问数据。之前排查过一起线上事故用户 A 反馈自己看到了用户 B 的收货地址。第一反应觉得是查询逻辑出了问题各种查 SQL、查缓存都没找到原因。后来在日志里同时打印线程名和UserContext.getUserId()发现同一个http-nio-8080-exec-7线程在 14:32:15 处理完 B 的请求后紧接着在 14:32:17 处理了 A 的请求而 ThreadLocal 里残留的还是 B 的 userId。问题一下锁定了就是漏了清理。修复方式就是补上afterCompletion里的 clear并且对项目中所有手动 set 的地方做统一排查。这个事故也说明了一个道理ThreadLocal 的串号问题在测试环境很难复现因为需要刚好同一个线程先后处理两个不同用户的请求。但在线上并发一高就特别容易出现而且一旦出现定位周期往往很长。彻底的解决办法就两条第一拦截器afterCompletion里必须 remove第二任何手动往 ThreadLocal 里 set 了数据的地方都要保证在 finally 块里 remove。把它当成团队代码规范比事后排查舒服得多。3.2 Async 和 CompletableFuture 里拿不到用户信息还有一个高频问题在 Service 方法上标了Async或者用了CompletableFuture.supplyAsync异步线程里调用UserContext.getUserId()拿到的是 null。原因很简单ThreadLocal 的数据绑定在发起请求的 Tomcat 线程上异步线程虽然逻辑上是同一个请求但物理上不是同一个线程自然读不到。Async public void sendWelcomeMessage() { Long userId UserContext.getUserId(); // 这里的 userId 是 null因为当前线程不是请求线程 }有的同学会想到InheritableThreadLocal。它确实能在new Thread()创建子线程时把父线程的值拷贝一份过去。但问题是线程池里的线程不是每次任务都新建的线程是预先创建好、反复复用的InheritableThreadLocal只在创建线程时拷贝一次线程池复用时就失效了。真正要解决线程池场景的上下文传递得用阿里的TransmittableThreadLocalTTL它能在任务提交时把父线程的上下文快照传递到任务线程并在任务结束后恢复。如果项目里异步链路多这是个可以考虑的选项但要控制好使用范围别把线程池所有任务都套上上下文容易引入脏数据。3.3 内存泄漏value 那条强引用链才是主角前面讲过 Entry 的 key 是弱引用但 value 是强引用。想象一个极端情况Tomcat 线程池里某个线程处理完请求后没有 removeThreadLocal 实例在外面已经没有任何引用了。GC 时 key 被回收但 Entry 里的 value比如一个大的 User 对象、数据库连接对象还被线程的 ThreadLocalMap 强引用着。Tomcat 线程又不会销毁这条引用链就会一直存在GC 永远回收不掉。日积月累就是一个典型的线程泄漏。在这个问题上关键结论是弱引用只是让 key 更容易被回收并不能阻止 value 泄漏。手动 remove 才是根治。这也是阿里 Java 开发手册里明确要求的自定义 ThreadLocal 变量必须使用完后 remove。如果团队对清理时机没信心还有一种兜底做法在 ThreadLocal 工具类里结合请求过滤器强制在一次请求的 finally 阶段清理。但说到底规范比兜底更重要。一句话记住弱引用只是让 key 更容易被回收并不能阻止 value 泄漏手动 remove 才是根治。4. 面试与选型什么时候用 ThreadLocal什么时候用别的方式4.1 和方法传参Request 属性放在一起怎么选很多人会问用户信息为什么不直接在方法参数里传为什么不用request.setAttribute它们其实不是同一个维度的方案理解清楚区别面试也会更好答。我把三个方案放在一张表里方案优点缺点适用场景方法传参显式、安全、可测试侵入性强深层链路传参麻烦业务数据在链路中只出现一两次request.setAttribute / getAttribute请求级生命周期自然每个方法都要持有 request 对象只在 Web 层内部使用ThreadLocal线程内随处可取侵入低不显式需要规范清理横切上下文如用户信息、traceId实际项目里更合适的组合是用户身份、请求链路 traceId 这类横切关注点放 ThreadLocal而业务方法之间的数据传递还是走参数。显式参数在可读性和可测试性上有天然优势核心业务逻辑不该依赖某个看不见的上下文才能跑通。重点是要让团队知道ThreadLocal 放什么、什么时候清理形成统一约定而不是随手就塞。4.2 Spring 框架自己也在用 ThreadLocal事务和日期格式化Spring 的事务管理是一个很好的佐证。当你在方法上标了TransactionalSpring 通过 AOP 拿到数据库连接后会把这个连接绑定到当前线程的TransactionSynchronizationManager而后者内部就是用 ThreadLocal 存储的连接资源。这样才能保证同一个事务里多个 DAO 操作用的是同一个 Connection事务提交或回滚后再清理。这也是为什么 Spring 的事务默认是绑定在线程上的跨线程调用不会共享同一个事务。再比如SimpleDateFormat不是线程安全的。如果一个静态实例被多个线程同时用格式化结果会错乱。常见解法就是用 ThreadLocal 包一份让每个线程持有自己的 DateFormat 实例private static final ThreadLocalSimpleDateFormat DATE_FORMAT_HOLDER ThreadLocal.withInitial(() - new SimpleDateFormat(yyyy-MM-dd HH:mm:ss)); public static String format(Date date) { return DATE_FORMAT_HOLDER.get().format(date); }这类线程内共享、线程间隔离的场景思路和存用户信息完全一致。面试时能举出 Spring 框架自身的使用例子会显得你对框架原理是有真实理解的而不是背了几个概念。4.3 如果让我重新选型我会考虑这几个点回到最初的问题Spring Boot 项目里到底要不要用 ThreadLocal 存用户信息我的判断标准很简单。单体应用、用户信息已经在网关层或入口拦截器解析好的场景ThreadLocal 是最轻、最快的方案。微服务架构下用户信息通常放在 JWT 里或者通过 Redis 做分布式会话那我更倾向于把用户上下文继续透传在请求头里ThreadLocal 承担的更多是当前线程上下文暂存的职责而不是跨服务传递的通道。还有一个容易被忽略的点是调试体验。IDEA 断点调试时ThreadLocal 的值不会像局部变量那样直观出现在调试面板里。排查问题时需要手动调用UserContext.getUserId()才能在评估表达式里看到当前值。所以封装类的方法命名一定要直白变量名要清楚否则别人接手代码时很容易一头雾水。我在团队内部定的规范是UserContext 必须放在 common 模块方法只保留 set/get/clear 三个不允许在业务代码里直接 new ThreadLocal。按我们项目目前的实践最稳的一套组合是登录成功后在拦截器里把 userId 塞进 UserContext业务代码统一从这里取跨线程传递用 TransmittableThreadLocal 承接所有写入 ThreadLocal 的地方强制在 finally 里 remove。这套组合运行了两个版本没有再出现一次串号问题。如果你正准备在项目里引入这个方案我的建议是先把清理规范写进代码约定里再动手改代码顺序不要反。
返回列表