Administrator
发布于 2018-07-11 / 1684 阅读
42

ThreadLocal 内存泄漏:线程池里的脏数据从哪来

客服工单里的串号:A 用户看到了 B 用户的手机号

7 月初的一个下午,客服转过来一张截图:用户 A 在个人中心看到的手机号,是另一个用户的。我第一反应是不信,把那串号码脱敏后在库里一查,确实属于用户 B,两个人八竿子打不着。

我们那个接口长这样,用户信息是从一个 ThreadLocal 里取的,网关在过滤器里塞进去:

public class UserContext {
    private static final ThreadLocal<UserInfo> HOLDER = new ThreadLocal<>();

    public static void set(UserInfo user) {
        HOLDER.set(user);
    }

    public static UserInfo get() {
        return HOLDER.get();
    }
}

// 过滤器
public class AuthFilter implements Filter {
    public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) {
        UserInfo user = parseToken(((HttpServletRequest) req).getHeader("Authorization"));
        UserContext.set(user);
        chain.doFilter(req, resp);
    }
}

单看这段代码,怎么想都不该串。线程 A 塞进去的用户,线程 B 凭什么读到?

先怀疑自己:ThreadLocalMap 到底存在哪

我翻了 JDK 8 的源码,ThreadLocal.set() 其实没往 ThreadLocal 对象里存东西,而是拿到了当前线程,往线程自己的 map 里塞:

public void set(T value) {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);          // 就是 t.threadLocals
    if (map != null)
        map.set(this, value);
    else
        createMap(t, value);
}

也就是说数据挂在 Thread 实例上,key 是 ThreadLocal 对象自己。那串号只有一种可能:同一个线程,上一次请求的用户没被清掉

问题就在这——Tomcat 的 http-nio-8080-exec-* 线程是线程池复用的。过滤器里只 setremove,请求处理完线程回到池子里,threadLocals 里的 UserInfo 还挂着。下一个请求如果走的分支没重新 set(我们有个内部健康检查的 URI 被过滤器放过了),UserContext.get() 拿到的就是上一个用户。

我加了一行日志打印线程名和 userId,压了 200 个请求,日志里明明白白出现了同一个 exec-3 线程连续服务两个不同用户的情况。串号复现了。

顺带搞清楚那个"弱引用泄漏"

查资料的时候,几乎所有文章都在讲 ThreadLocal 内存泄漏,说 key 是弱引用会被 GC 掉,value 却还在,形成 null -> value 的强引用链。我盯着源码看了半天才理顺:

static class ThreadLocalMap {
    static class Entry extends WeakReference<ThreadLocal<?>> {
        Object value;
        Entry(ThreadLocal<?> k, Object v) {
            super(k);              // key 是弱引用
            value = v;             // value 是强引用
        }
    }
}

画成引用关系就是这样:

Thread (强)  ->  ThreadLocalMap (强)  ->  Entry (强)
                                            |
                                     key: WeakReference -> ThreadLocal 对象
                                     value: 强引用 -> UserInfo 对象

如果 ThreadLocal 这个变量本身是静态的(像我们的 HOLDER),key 永远不会被回收,泄漏的前提都不成立。真正会泄漏的是这种写法:ThreadLocal 是个实例变量,对象被回收后 key 变 null,但线程还活着(线程池核心线程基本不死),value 就一直挂在 Entry 上,等到下次 get/set 时清理不掉就积少成多。

我们这次的锅严格说不是"内存泄漏",而是线程池复用导致的脏数据。但根因是同一个:用了 ThreadLocal 却没有配对清理。

修复:remove 放在 finally 里

师傅看完我的排查笔记,说了句"你这个过滤器少了个 finally"。改完是这样:

public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain)
        throws IOException, ServletException {
    try {
        UserInfo user = parseToken(((HttpServletRequest) req).getHeader("Authorization"));
        UserContext.set(user);
        chain.doFilter(req, resp);
    } finally {
        UserContext.clear();      // 关键
    }
}

public static void clear() {
    HOLDER.remove();
}

三个细节:

  • remove() 必须在 finally 里。放 try 末尾的话,业务代码抛异常就跳过了,而异常恰恰是最容易复现串号的场景。
  • remove() 而不是 set(null)set(null) 只是把 value 置空,Entry 还在,key 还在;remove() 会把整个 Entry 从表里删掉。
  • 所有放过过滤器的 URI 也要清理。我把健康检查路径直接挪到过滤器白名单之外单独配置了,避免半清理状态。

上线前我在测试环境用 JMeter 跑了 5000 次请求,100 并发,日志里加了校验:每次请求结束打印 Thread.currentThread().getName() + " -> " + userId,然后写了个脚本比对同一个线程名相邻两次请求的 userId 是否重复。改之前有 37 处不一致,改之后为 0。

还有一个坑:线程池里的异步任务

排查过程中发现另一个地方也在用 ThreadLocal,是给下游 Dubbo 调用传 traceId 的。这里有个更隐蔽的问题——如果主线程往线程池提交任务,子线程是读不到父线程的 ThreadLocal 的:

UserContext.set(user);
executor.submit(() -> {
    System.out.println(UserContext.get());   // null
});

要传就得用 InheritableThreadLocal,但它在线程池场景下更危险:线程池里的线程是提前创建好的,InheritableThreadLocal 只在线程创建时拷贝一次,之后父线程再改值,子线程看到的还是旧的那份。所以线程池里用 InheritableThreadLocal 等于给自己埋雷,正确做法是任务提交时把值显式传进去。

小结

这次的教训是,ThreadLocal 不是"设置完就不用管"的容器,它更像借来的东西,用完必须还。我给自己定了两条规矩:一是只要写了 set,立刻在同一屏内把 remove 的 finally 补上;二是 ThreadLocal 变量一律声明成 private static final,避免 key 被回收引发真正的内存泄漏。

另外那个串号问题,从客服反馈到定位出来花了大概 6 个小时,其中 5 个小时花在"这不可能啊"上。后来师傅说,遇到觉得不可能的 bug,先假设你用的那个东西你其实没搞懂——这话在 ThreadLocal 上应验了。

参考