Loading... 在 Java 里有个称之为`线程本地变量`的类型叫做 `ThreadLocal`,它与 `ThreadLocal` 之于 C# 中是一样的作用,可以在线程范围内设置变量,这个变量只会在当前线程可被访问,但是它们有一点不同的是,在 Java 中,当你设置好变量后,在线程使用完毕回到线程池之前,需要手动调用 `ThreadLocal.remove()` 方法去清除线程本地变量,否则变量随着线程回到线程池,并且在下次使用此线程时此变量继续存在,而在 C# 中,线程回到线程池时会自动清除本地变量,因此无需手动去清除。 我们的业务有这样一个场景:某个业务 UserService 类中,具有多个方法会频繁(甚至循环)调用一个获取用户标签的接口,具体原因是因为某些方法会进行递归,数据结构有个树状结构,因此,为了优化接口响应时间以及看起来不那么蠢,我使用 `ThreadLocal` 将用户标签接口的返回数据存储到当前线程,因为在单个请求中,多次调用此接口获取数据是不必要的,它看起来像这样: ```Java /** * 此静态变量ThreadLocal会为每个线程创建本地副本, 因此USER_TAGS_THREAD_LOCAL的更改只会影响当前线程 注意, * 即使线程被回收到线程池, 此变量副本也不会被清除, 因此, 需要在使用完毕后手动清除, USER_TAGS_THREAD_LOCAL.remove() * 否则可能会导致从线程池拿到的线程带有之前的变量 无论如何, 这不会产生内存泄漏问题 * 此变量在controller返回或controller异常时进行清除操作 */ public final static ThreadLocal<List<Tag>> USER_TAGS_THREAD_LOCAL = ThreadLocal.withInitial(() -> new ArrayList<>()); /** * 获取用户标签 */ private List<Tag> getUserTags(String userId) { if (StringUtil.isNullOrEmpty(userId)) { return new ArrayList<>(); } if (!CollectionUtils.isEmpty(USER_TAGS_THREAD_LOCAL.get())) { // 返回线程本地存储的用户标签 return USER_TAGS_THREAD_LOCAL.get(); } // 调用接口获取用户标签 List<Tag> tags = getUserTags(userId); USER_TAGS_THREAD_LOCAL.get().addAll(tags); return USER_TAGS_THREAD_LOCAL.get(); } // 在多个方法中会用到获取用户标签,这里假设会递归调用 public User queryUserInfo(String userId) { User user = getUser(userId); List<Tag> userTags = getUserTags(userId); user.Parent = queryUserInfo(user.ParentId); ... } ``` 这里有个问题,那就是清除 `ThreadLocal` 的时机,假设我们在 `queryUserInfo` 方法中,增加一个 `try/catch/finally` 块,在每次调用完毕后进行清除,这肯定不合适,这样的递归方法,每次都清除,会失去 `ThreadLocal` 的意义,那么在 `queryUserInfo` 外层再增加一个方法,例如: ```Java public User queryUserInfoWapper(String userId) { try { return queryUserInfo(userId); } catch { ... } finally { USER_TAGS_THREAD_LOCAL.remove(); } } ``` 这看起来确实解决了问题。但是我们在这个 `UserService` 类中有多个类似 `queryUserInfo` 的方法,为每个类似 `queryUserInfo` 的方法都增加 `try/catch/finally` 块,你得找到所有的点去清除 `ThreadLocal`,很可能会出现某些点漏掉,或者一次请求线程中多次清除了 `ThreadLocal`。 于是我开始寻找,某种能在线程池回收线程时的钩子,在线程回收时清除线程上的变量,当然最后由于实现难度或开发进度等种种原因,我选择了在控制器入口进行清除,我将 `USER_TAGS_THREAD_LOCAL` 属性暴露给外层,在控制器入口调用 `USER_TAGS_THREAD_LOCAL.remove()`: ```Java @PostMapping public User queryUserInfo(String userId) { try { User user = userService.queryUserInfo(userId); return user; } catch (Exception exception) { throw exception; } finally { UserService.USER_TAGS_THREAD_LOCAL.remove(); } } @HystrixCommand(fallbackMethod = "fallbackMethod", commandKey = "commandKey", threadPoolKey = "threadPoolKey") @PostMapping public User anotherQueryUserInfo(String userId) { try { User user = userService.queryUserInfo(userId); return user; } catch (Exception exception) { throw exception; } finally { UserService.USER_TAGS_THREAD_LOCAL.remove(); } } ``` 实际上这种做法很不好,它隐式的需要你在使用了某些调用过 `getUserTags` 的方法后手动使用 `USER_TAGS_THREAD_LOCAL.remove()`,否则线程回到线程池依然带有上次保存的数据。然而这种隐式的操作,只有了解这个 `UserService` 人才知道,别人可能直接使用业务方法而不知道需要手动调用 `USER_TAGS_THREAD_LOCAL.remove()`,我们的 BUG 也正因此产生。 在某个功能上线后,我们原先运行正常的 `queryUserInfo` 接口突然数据不正确,查看日志排查问题后发现,是因为 `getUserTags` 方法的 `USER_TAGS_THREAD_LOCAL` 数据不正常,在多个不同用户之间数据错乱,A 的标签数据被 B 返回,B 的标签数据被 C 返回。找了一个多小时的原因,这段代码怎么看也没问题,因为无论如何在控制器的 `finally` 块会清除 `USER_TAGS_THREAD_LOCAL`, 最终没辙,准备先解决问题(移除 USER_TAGS_THREAD_LOCAL 的使用)再详细查看问题的原因。正改着代码,突然一旁的同事表示,他知道原因了,他在某个其它接口的内部方法调用了 `UserService`,但是他并不知道需要清除 `USER_TAGS_THREAD_LOCAL`。那么问题找到了,接口数据不正常是因为其它接口的用户请求后会将 `USER_TAGS_THREAD_LOCAL` 数据设置到请求线程,线程回到线程池没有被清除 `USER_TAGS_THREAD_LOCAL`,而后 `queryUserInfo` 接口再次拿到相同的线程就会有上个用户留下的数据。 等等,`queryUserInfo` 接口虽然数据错误,但是 `anotherQueryUserInfo` 接口却数据正常,这两调用的是完全相同的业务方法啊?仔细一看,哦,原来 `anotherQueryUserInfo` 使用了 `Hystrix`,它使用自己的线程池去分配线程执行,因此,即使 默认线程池的线程带有错误数据,也不影响它。 最后修改:2022 年 08 月 02 日 © 允许规范转载 赞 2 如果觉得我的文章对你有用,请随意赞赏
1 条评论
最终是怎么解决的呢?