你真的会用ThreadLocal吗——使用篇

首页 编程分享 PHP丨JAVA丨OTHER 正文

一只叫煤球的猫 转载 编程分享 2025-05-03 22:10:16

简介 那是一个月黑风高的夜晚… 记得那是一个上线前的深夜,大家都在紧张地进行最后的集成测试。突然,测试环境的某个核心服务开始疯狂报警,错误日志刷得飞起 🚀。 错误信息很诡异,大概意思是用户A的操作数据


前言:那是一个月黑风高的夜晚…

记得那是一个上线前的深夜,大家都在紧张地进行最后的集成测试。突然,测试环境的某个核心服务开始疯狂报警,错误日志刷得飞起 🚀。

错误信息很诡异,大概意思是用户A的操作数据莫名其妙地串到了用户B的请求里。这可是个大事故啊!所有人都被叫了起来,包括正在梦里撸猫的我 😴。

我们几个老鸟围着日志和代码,排查了半天。数据隔离问题?事务问题?缓存问题?各种猜测满天飞。

最后,目光聚焦在了一个不起眼的工具类上,里面用到了 ThreadLocal 来传递用户信息。代码看起来没啥毛病,用户信息 set 进去,后续链路也能 get 到。但问题是,我们的服务是基于Tomcat线程池的,线程是会被复用的啊!

经过一番紧张的Debug和分析,真相大白:开发这个工具类的同学,在请求处理结束时,忘了调用 ThreadLocalremove() 方法!😱 这就导致了线程被回收到池中,下一次请求复用这个线程时,竟然取到了上一个请求残留的用户信息!

所有人恍然大悟,问题解决,虚惊一场。

这次经历让我意识到,ThreadLocal 这个看似简单的工具,很多人可能只是“会用”,但并没有真正“用对”。它的坑,踩下去也是挺疼的。

所以,我决定写两篇文章,跟大家彻底聊聊 ThreadLocal

这一篇,我们先聚焦“怎么用”,由浅入深,把它的使用场景和注意事项掰扯清楚。至于它背后的原理,为什么会内存泄漏,ThreadLocalMap 是个啥?咱们留到下一篇《原理篇》再细说,先卖个关子 😉。

耐心看完,你一定有所收获。

正文

ThreadLocal是个啥

简单来说,ThreadLocal 提供了一种线程(Thread)级别的局部(Local)变量

它最大的特点是:为每个使用该变量的线程都提供一个独立的变量副本

啥意思呢?

就是说,你创建了一个 ThreadLocal 变量,比如 threadLocalUser,然后线程A通过 threadLocalUser.set("User A") 设置了值,线程B通过 threadLocalUser.set("User B") 设置了值。那么,在线程A内部,任何时候通过 threadLocalUser.get() 获取到的都是 “User A”,而在线程B内部,获取到的永远是 “User B”。

它们俩互不干扰,就像每个线程都有自己的“小金库”💰,存取都在自己的空间里。

这玩意儿主要用来解决什么问题呢?

  • 线程安全问题

    当多个线程需要共享某个非线程安全的对象时,一种常见的做法是加锁(synchronized 或 Lock)。

    但加锁会带来性能开销和死锁风险。

    ThreadLocal 提供了一种“空间换时间”的思路,给每个线程一个独立副本,避免了线程间的竞争,自然也就线程安全了,而且通常比锁更快。

  • 线程上下文传递

    在一个请求处理链路中(比如Web应用),很多时候我们需要在不同的方法、不同的类之间传递一些公共信息(比如当前登录用户、事务ID、Trace ID等)。

    如果一层层通过方法参数传递,代码会变得非常臃肿难看。

    ThreadLocal 可以把这些信息存起来,链路中的任何地方都能方便地获取,代码更优雅。✨

核心API(三板斧)

ThreadLocal 的核心API非常简单,记住这三个就差不多了:

  • void set(T value): 将当前线程的此线程局部变量的副本设置为指定值。
  • T get(): 返回当前线程的此线程局部变量的副本中的值。如果这是线程第一次调用该方法,则会通过调用 initialValue() 方法来初始化值(除非之前调用过 set)。
  • void remove()(敲黑板,划重点!🚨)  移除此线程局部变量的当前线程值。

我们来看个简单的例子:

public class ThreadLocalDemo {

    // 1. 创建一个 ThreadLocal 变量
    private static final ThreadLocal<String> userContext = new ThreadLocal<>();

    public static void main(String[] args) {
        // 线程 A
        new Thread(() -> {
            String userName = "酷炫张三";
            // 2. 设置值
            userContext.set(userName);
            System.out.println("Thread A set user: " + userName);

            try {
                // 模拟业务处理
                Thread.sleep(100);
                processUserData(); // 在其他方法中获取
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            } finally {
                // 4. (关键!)移除值
                System.out.println("Thread A removing user: " + userContext.get());
                userContext.remove();
            }
        }, "Thread-A").start();

        // 线程 B
        new Thread(() -> {
            String userName = "低调李四";
            // 2. 设置值
            userContext.set(userName);
            System.out.println("Thread B set user: " + userName);

            try {
                // 模拟业务处理
                Thread.sleep(50);
                processUserData(); // 在其他方法中获取
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            } finally {
                // 4. (关键!)移除值
                System.out.println("Thread B removing user: " + userContext.get());
                userContext.remove();
            }
        }, "Thread-B").start();
    }

    private static void processUserData() {
        // 3. 获取值
        String currentUser = userContext.get();
        System.out.println(Thread.currentThread().getName() + " processing data for user: " + currentUser);
        // ... 其他业务逻辑 ...
    }
}

运行这段代码,你会看到线程A和线程B各自打印自己的用户信息,互不干扰。✅

是不是使用起来很简单?

别忘了 remove()

别忘了 remove()!别忘了 remove()!别忘了 remove()

重要的事情说三遍!为啥 remove() 这么重要?

还记得开头那个月黑风高的故事吗?问题就出在忘了 remove()

在现代Java应用中,尤其是Web服务器(如Tomcat)和各种框架(如Spring),大量使用线程池来处理请求和任务。

而线程池里的线程是会被复用的。

看一下这个流程:

  1. 请求A来了,线程T1处理它,在 ThreadLocal 里设置了用户A的信息。
  2. 请求A处理完了,但是!没有调用 remove()
  3. 线程T1被还回线程池。此时,T1内部的 ThreadLocalMap(这是ThreadLocal存数据的地方,原理篇细讲)还持有用户A信息的引用
  4. 过了一会儿,请求B来了,线程池又把线程T1分配给了请求B。
  5. 请求B的处理逻辑中,尝试从 ThreadLocal 获取用户信息 get()糟糕!它取到了上次请求A留下的用户A的信息!  🤯 数据就串了!
  6. 更严重的是,如果 ThreadLocal 存的是比较大的对象,并且不断有新的请求进来,线程不断被复用且不 remove(),这些“残留”的对象会一直存在于线程的 ThreadLocalMap 中,无法被GC回收,最终可能导致内存泄漏 ,把你的服务器内存撑爆!💥

所以,最佳实践是: 在使用 ThreadLocal 的代码块(通常是 try-finally 结构)的 finally 中,一定、必须、务必调用 remove() 方法,确保线程执行完毕后清理掉 ThreadLocal 变量。

比如下面这个示例:


ThreadLocal<UserInfo> userInfoThreadLocal = new ThreadLocal<>();

public void handleRequest(Request req) {
    UserInfo userInfo = getUserInfoFromRequest(req);
    userInfoThreadLocal.set(userInfo);
    try {
        // ... 执行业务逻辑,中间可能会调用N多方法 ...
        serviceA();
        serviceB();
        // ... 这些方法内部可以通过 userInfoThreadLocal.get() 获取用户信息 ...
    } finally {
        // 无论业务逻辑是否异常,都要清理!
        userInfoThreadLocal.remove(); // <-- 千万别忘了这一步!
        System.out.println("ThreadLocal for user " + userInfo.getId() + " removed.");
    }
}

initialValue() 和 withInitial()

有时候,我们希望 ThreadLocal 在第一次 get() 并且没有 set() 过的时候,能返回一个默认值,而不是 null。可以通过重写 initialValue() 方法或者使用 ThreadLocal.withInitial() 工厂方法来实现。

方式一:重写 initialValue()

private static final ThreadLocal<Integer> counter = new ThreadLocal<Integer>() {
    @Override
    protected Integer initialValue() {
        System.out.println(Thread.currentThread().getName() + " initializing counter to 0");
        return 0; // 初始值为 0
    }
};

public static void main(String[] args) {
    new Thread(() -> {
        System.out.println(Thread.currentThread().getName() + " initial get: " + counter.get()); // 首次get,会调用initialValue
        counter.set(counter.get() + 1);
        System.out.println(Thread.currentThread().getName() + " after increment: " + counter.get());
        counter.remove();
    }, "Thread-C").start();

     new Thread(() -> {
        System.out.println(Thread.currentThread().getName() + " initial get: " + counter.get()); // 另一个线程首次get,也会调用initialValue
        counter.set(counter.get() + 5);
        System.out.println(Thread.currentThread().getName() + " after increment: " + counter.get());
        counter.remove();
    }, "Thread-D").start();
}

方式二:使用 withInitial()

👍 这种方式更简洁,推荐使用!

private static final ThreadLocal<Integer> counter = ThreadLocal.withInitial(() -> {
    System.out.println(Thread.currentThread().getName() + " initializing counter to 0 via withInitial");
    return 0; // 使用 Lambda 表达式提供初始值
});

注意:

  • initialValue()withInitial() 提供的初始值,只会在当前线程第一次调用 get() 且没有调用过 set()被设置。
  • 如果调用过 set(),再调用 get() 就会返回 set 的值。
  • 如果调用了 remove() 之后再调用 get(),则会重新触发初始化逻辑。

InheritableThreadLocal 父子线程传递

还有一个 ThreadLocal 的亲戚叫 InheritableThreadLocal

它的特殊之处在于:当父线程创建一个子线程时,子线程会自动继承父线程中 InheritableThreadLocal 变量的值。

直接看代码:

// 使用 InheritableThreadLocal
private static final ThreadLocal<String> inheritableContext = new InheritableThreadLocal<>();

public static void main(String[] args) {
    inheritableContext.set("Value from Main Thread");
    System.out.println("Main thread value: " + inheritableContext.get());

    new Thread(() -> {
        // 子线程可以获取到父线程设置的值
        System.out.println("Child thread inherited value: " + inheritableContext.get());

        // 子线程修改值,不影响父线程
        inheritableContext.set("Value modified by Child Thread");
        System.out.println("Child thread modified value: " + inheritableContext.get());

        // 同样需要 remove
        inheritableContext.remove();
    }).start();

    // 等待子线程执行完毕
    try {
        Thread.sleep(100);
    } catch (InterruptedException e) {}

    // 父线程的值不受子线程修改影响
    System.out.println("Main thread value after child finished: " + inheritableContext.get());
    inheritableContext.remove();
}

注意点:

  • 继承是创建子线程时发生的,是值的浅拷贝(对于引用类型,父子线程共享同一个对象引用)。
  • 子线程创建后,父子线程各自修改 InheritableThreadLocal 的值互不影响。
  • 线程池场景下的坑InheritableThreadLocal 在线程池中使用时要特别小心!因为线程复用,父线程设置的值可能会被“意外”带到后续不相关的任务中。如果父任务创建了子任务并提交到线程池,这种继承关系可能会导致混乱和内存泄漏。阿里巴巴的 transmittable-thread-local (TTL) 库就是为了解决这个问题而生的,感兴趣可以去了解下。

所以除非你非常明确知道需要父子线程传递数据,并且清楚其潜在风险,否则优先使用普通的 ThreadLocal,并不简易直接使用 InheritableThreadLocal

一些常见的使用场景

  • Web 应用中的用户身份传递

    在 Filter 或 Interceptor 中获取用户信息,set 到 ThreadLocal,后续 Controller、Service 层都可以方便地 get 到。

    请求结束时在 Filter 或 Interceptor 的 finally 块中 remove

  • 事务管理

    Spring 框架广泛使用了 ThreadLocal 来管理事务状态(TransactionSynchronizationManager)。

    每个线程持有自己的事务信息(是否开启事务、隔离级别、是否只读等)。

  • 日志链路追踪 (Trace ID)

    在分布式系统中,为了追踪一个请求的完整调用链路,通常会生成一个全局唯一的 Trace ID。

    这个 Trace ID 可以放在 ThreadLocal 中,随着请求在服务内部的线程调用栈中传递,打印日志时带上它,方便串联日志。

结尾

好了,关于 ThreadLocal 的使用篇就聊到这里。我们从一个真实的“踩坑”故事出发,了解了 ThreadLocal 是什么,为什么用它,怎么用它。

也了解了它的核心API (set, get, remove)和重要事项(必须remove())等。

希望通过这篇“使用篇”,你能对 ThreadLocal 的正确用法有一个更清晰、更深入的认识。下次再遇到需要它解决问题的场景时,能胸有成竹,用得明明白白,避免重蹈我们的覆辙。

当然,仅仅知道怎么用还不够“酷” 😎。

想知道 ThreadLocal 底层是怎么为每个线程维护独立副本的吗?ThreadLocalMap 到底长啥样?为什么 remove() 如此关键,不 remove 就一定会内存泄漏吗?弱引用(WeakReference)在其中扮演了什么角色?

别急,这些问题的答案,我们将在下一篇 《你真的会用ThreadLocal吗——原理篇》 中为你揭晓!敬请期待! 😉

转载链接:https://juejin.cn/post/7498970712473600041


Tags:


本篇评论 —— 揽流光,涤眉霜,清露烈酒一口话苍茫。


    声明:参照站内规则,不文明言论将会删除,谢谢合作。


      最新评论




ABOUT ME

Blogger:袅袅牧童 | Arkin

Ido:PHP攻城狮

WeChat:nnmutong

Email:nnmutong@icloud.com

标签云