你真的会用ThreadLocal吗——使用篇
2025-05-03 07:36 阅读(52)

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

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

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

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

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

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

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

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

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

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

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

https://www.zuocode.com

正文

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),大量使用线程池来处理请求和任务。

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

看一下这个流程:


请求A来了,线程T1处理它,在 ThreadLocal 里设置了用户A的信息。

请求A处理完了,但是!没有调用 remove() 。

线程T1被还回线程池。此时,T1内部的 ThreadLocalMap(这是ThreadLocal存数据的地方,原理篇细讲)还持有用户A信息的引用。

过了一会儿,请求B来了,线程池又把线程T1分配给了请求B。

请求B的处理逻辑中,尝试从 ThreadLocal 获取用户信息 get()。糟糕!它取到了上次请求A留下的用户A的信息!  数据就串了!

更严重的是,如果 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)在其中扮演了什么角色?