线程池和ThreadLocal和ThreadLocalMap

本文解析了线程池中使用ThreadLocal的潜在问题,特别是在Tomcat环境下线程复用导致的数据污染风险,并介绍了如何避免内存泄漏。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

请直接看原文:

在线程池中使用ThreadLocal,你必须要知道这一点 - 知乎 (zhihu.com)

------------------------------------------------------------------------------------------------------------------------------

 一.先了解ThreadLocal和ThreadLocalMap的关系

 1. 一些理论 

1.ThreadLocalMap是Thread类里的一个局部变量

2.ThreadLocalMap是ThreadLocal类里的一个静态内部类, 

3.ThreadLocal里的get和set方法可以为所属的线程对象Thread里的局部变量ThreadLocalMap赋值,这样,thread和ThreadLocal和ThreadLocalHashMap它们三个就关联起来了.

1每个Thread线程内部都只会有一个ThreadLocalMap。

4.Thread内部的局部变量ThreadLocalMap是由ThreadLocal维护,ThreadLocal负责向ThreadLocalMap获取和设置线程的变量值。

3.ThreadLocalMap里面存储线程本地对象ThreadLocal(key)和线程的变量副本(value)。

4.一个Thread可以有多个ThreadLocal。 

5.不同线程里的ThreadLocalMapl是相互隔离的.不同线程里的ThreadLocal也是相互隔离的.

2.图 

上图的ThreadLocal1和ThreadLocal2和ThreadLocal3在同一个线程对象里的ThreadLocalHashMap中.

3.用代码来证明上面的图

直接上结论: 证实上面的图是正确的.

 

 二.再看线程池和ThreadLocal结合使用的注意点

先总结重要的点

1.线程池的核心线程是一直被使用的,比如核心线程数是1个, 那么总是这个线程在执行任务, 

ThreadLocal是不同线程之间相互隔离的,  但是这个线程池总是用相同的一个线程去访问ThreadLocal,也就会出现在线程池的两个任务中,A任务在ThreadLocal中set(1),  B任务是可以获得ThreadLocal中的这个(1)的.

解决办法就是:利用ThreadLocal只能存一个值,每次任务都往ThreadLocal里set值就行了.

--------------------------------------------------------------------------------------------------------------------------------

之前有业务同学和我反馈,在生产上遇到一个诡异的问题,有时获取到的用户信息是别人的。查看代码后,我发现他使用了 ThreadLocal 来缓存获取到的用户信息。

我们知道,ThreadLocal 适用于变量在线程间隔离,而在方法或类间共享的场景。如果用户信息的获取比较昂贵(比如从数据库查询用户信息),那么在 ThreadLocal 中缓存数据是比较合适的做法。但,这么做为什么会出现用户信息错乱的 Bug 呢?

我们看一个具体的案例吧。

使用 Spring Boot 创建一个 Web 应用程序,使用 ThreadLocal 存放一个 Integer 的值,来暂且代表需要在线程中保存的用户信息,这个值初始是 null。在业务逻辑中,我先从 ThreadLocal 获取一次值,然后把外部传入的参数设置到 ThreadLocal 中,来模拟从当前上下文获取到用户信息的逻辑,随后再获取一次值,最后输出两次获得的值和线程名称。

private static final ThreadLocal<Integer> currentUser = ThreadLocal.withInitial(() -> null);

    @GetMapping("wrong")
    public Map wrong(@RequestParam("userId") Integer userId) {
        String before  = Thread.currentThread().getName() + ":" + currentUser.get();
        currentUser.set(userId);
        String after  = Thread.currentThread().getName() + ":" + currentUser.get();
        Map result = new HashMap();
        result.put("before", before);
        result.put("after", after);
        return result;
    }

按理说,在设置用户信息之前第一次获取的值始终应该是 null,但我们要意识到,程序运行在

Tomcat 中,执行程序的线程是 Tomcat 的工作线程,而 Tomcat 的工作线程是基于线程池的。

顾名思义,线程池会重用固定的几个线程,一旦线程重用,那么很可能首次从 ThreadLocal 获取的值是之前其他用户的请求遗留的值。这时,ThreadLocal 中的用户信息就是其他用户的信息。

为了更快地重现这个问题,我在配置文件中设置一下 Tomcat 的参数,把工作线程池最大线程数设置为 1,这样始终是同一个线程在处理请求:

server.tomcat.max-threads=1

运行程序后先让用户 1 来请求接口,可以看到第一和第二次获取到用户 ID 分别是 null 和 1,符合预期:

随后用户 2 来请求接口,这次就出现了 Bug,第一和第二次获取到用户 ID 分别是 1 和 2,显然第一次获取到了用户 1 的信息,原因就是 Tomcat 的线程池重用了线程。从图中可以看到,两次请求的线程都是同一个线程:http-nio-8080-exec-1。

这个例子告诉我们,在写业务代码时,首先要理解代码会跑在什么线程上:

我们可能会抱怨学多线程没用,因为代码里没有开启使用多线程。但其实,可能只是我们没有意识到,在 Tomcat 这种 Web 服务器下跑的业务代码,本来就运行在一个多线程环境(否则接口也不可能支持这么高的并发),并不能认为没有显式开启多线程就不会有线程安全问题

因为线程的创建比较昂贵,所以 Web 服务器往往会使用线程池来处理请求,这就意味着线程会被重用。这时,使用类似 ThreadLocal 工具来存放一些数据时,需要特别注意在代码运行完后,显式地去清空设置的数据。如果在代码中使用了自定义的线程池,也同样会遇到这个问题。

理解了这个知识点后,我们修正这段代码的方案是,在代码的 finally 代码块中,显式清除 ThreadLocal 中的数据。这样一来,新的请求过来即使使用了之前的线程也不会获取到错误的用户信息了。修正后的代码如下:

@GetMapping("right")

public Map right(@RequestParam("userId") Integer userId) {

    String before  = Thread.currentThread().getName() + ":" + currentUser.get();

    currentUser.set(userId);

    try {

        String after = Thread.currentThread().getName() + ":" + currentUser.get();

        Map result = new HashMap();

        result.put("before", before);

        result.put("after", after);

        return result;

    } finally {

        //在finally代码块中删除ThreadLocal中的数据,确保数据不串

        currentUser.remove();

    }

}

重新运行程序可以验证,再也不会出现第一次查询用户信息查询到之前用户请求的 Bug

ThreadLocal 是利用独占资源的方式,来解决线程安全问题,那如果我们确实需要有资源在线程之间共享,应该怎么办呢?这时,我们可能就需要用到线程安全的容器了。

上个例子说明,ThreadLocal用不好也会产生副作用,线程复用 会产生脏数据。由于线程 池会重用Thread对象,那么与Thread绑定的类的静态属性ThreadLocal变量也会被重用。如果在实现的线程run()方法体中不显式地调用于线程相关的ThreadLocal信息,那么倘若下一个线程不调用set()设置初始值,就可能get到重用的线程信息,包括ThreadLocal所关联的线程对象的value值。

下面我们补充介绍一下ThreadLocal 为什么会发生内存泄漏?

ThreadLocal为什么会内存泄漏

  • 每个Thread线程内部都有一个Map。
  • Map里面存储线程本地对象(key)和线程的变量副本(value)
  • 但是,Thread内部的Map是由ThreadLocal维护的,由ThreadLocal负责向map获取和设置线程的变量值。

所以对于不同的线程,每次获取副本值时,别的线程并不能获取到当前线程的副本值,形成了副本的隔离,互不干扰。

Thread线程内部的Map在类中描述如下:

public class Thread implements Runnable {
    /* ThreadLocal values pertaining to this thread. This map is maintained
     * by the ThreadLocal class. */
    ThreadLocal.ThreadLocalMap threadLocals = null;
}

ThreadLocal 在 ThreadLocalMap 中是以一个弱引用身份被Entry中的Key引用的,因此如果ThreadLocal没有外部强引用来引用它,那么ThreadLocal会在下次JVM垃圾收集时被回收。这个时候就会出现Entry中Key已经被回收,出现一个null Key的情况,外部读取ThreadLocalMap中的元素是无法通过null Key来找到Value的。因此如果当前线程的生命周期很长,一直存在,那么其内部的ThreadLocalMap对象也一直生存下来,这些null key就存在一条强引用链的关系一直存在:Thread --> ThreadLocalMap-->Entry-->Value,这条强引用链会导致Entry不会回收,Value也不会回收,但Entry中的Key却已经被回收的情况,造成内存泄漏。

但是JVM团队已经考虑到这样的情况,并做了一些措施来保证ThreadLocal尽量不会内存泄漏:在ThreadLocal的get()、set()、remove()方法调用的时候会清除掉线程ThreadLocalMap中所有Entry中Key为null的Value,并将整个Entry设置为null,利于下次内存

由于ThreadLocalMap的key是弱引用,而Value是强引用。这就导致了一个问题,ThreadLocal在没有外部对象强引用时,发生GC时弱引用Key会被回收,而Value不会回收,如果创建ThreadLocal的线程一直持续运行,那么这个Entry对象中的value就有可能一直得不到回收,发生内存泄露。

来看看ThreadLocal的get()方法底层实现

public T get() {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null) {
        ThreadLocalMap.Entry e = map.getEntry(this);
        if (e != null)
            return (T)e.value;
    }
    return setInitialValue();
}

在调用map.getEntry(this)时,内部会判断key是否为null,继续看map.getEntry(this)源码

private Entry getEntry(ThreadLocal key) {
    int i = key.threadLocalHashCode & (table.length - 1);
    Entry e = table[i];
    if (e != null && e.get() == key)
        return e;
    else
        return getEntryAfterMiss(key, i, e);
}

在getEntry方法中,如果Entry中的key发现是null,会继续调用getEntryAfterMiss(key, i, e)方法,其内部回做回收必要的设置,继续看内部源码:

private Entry getEntryAfterMiss(ThreadLocal key, int i, Entry e) {
    Entry[] tab = table;
    int len = tab.length;

    while (e != null) {
        ThreadLocal k = e.get();
        if (k == key)
            return e;
        if (k == null)
            expungeStaleEntry(i);
        else
            i = nextIndex(i, len);
        e = tab[i];
    }
    return null;
}

注意k == null这里,继续调用了expungeStaleEntry(i)方法,expunge的意思是擦除,删除的意思,见名知意,在来看expungeStaleEntry方法的内部实现:

private int expungeStaleEntry(int staleSlot) {
    Entry[] tab = table;
    int len = tab.length;


    // expunge entry at staleSlot(意思是,删除value,设置为null便于下次回收)
    tab[staleSlot].value = null;
    tab[staleSlot] = null;
    size--;


    // Rehash until we encounter null
    Entry e;
    int i;
    for (i = nextIndex(staleSlot, len);
         (e = tab[i]) != null;
         i = nextIndex(i, len)) {
        ThreadLocal k = e.get();
        if (k == null) {
            e.value = null;
            tab[i] = null;
            size--;
        } else {
            int h = k.threadLocalHashCode & (len - 1);
            if (h != i) {
                tab[i] = null;


                // Unlike Knuth 6.4 Algorithm R, we must scan until
                // null because multiple entries could have been stale.
                while (tab[h] != null)
                    h = nextIndex(h, len);
                tab[h] = e;
            }
        }
    }
    return i

注意这里,将当前Entry删除后,会继续循环往下检查是否有key为null的节点,如果有则一并删除,防止内存泄漏。

但这样也并不能保证ThreadLocal不会发生内存泄漏,例如:

  • 使用static的ThreadLocal,延长了ThreadLocal的生命周期,可能导致的内存泄漏。
  • 分配使用了ThreadLocal又不再调用get()、set()、remove()方法,那么就会导致内存泄漏。

如何避免泄漏

既然Key是弱引用,那么我们要做的事,就是在调用ThreadLocal的get()、set()方法时完成后再调用remove方法,将Entry节点和Map的引用关系移除,这样整个Entry对象在GC Roots分析后就变成不可达了,下次GC的时候就可以被回收。

如果使用ThreadLocal的set方法之后,没有显示的调用remove方法,就有可能发生内存泄露,所以养成良好的编程习惯十分重要,使用完ThreadLocal之后,记得调用remove方法。

为什么ThreadLocalMap的key是弱引用呢?

key 使用强引用:引用的ThreadLocal的对象被回收了,但是ThreadLocalMap还持有ThreadLocal的强引用,如果没有手动删除,ThreadLocal不会被回收,导致Entry内存泄漏。

key 使用弱引用:引用的ThreadLocal的对象被回收了,由于ThreadLocalMap持有ThreadLocal的弱引用,即使没有手动删除,ThreadLocal也会被回收。value在下一次ThreadLocalMap调用set,get,remove的时候会被清除。

### TTL 的定义及其实现 TransmittableThreadLocal(简称 TTL)是一种扩展了 `ThreadLocal` 功能的工具类,主要用于解决多线程环境下上下文数据无法自动传递的问题。传统的 `ThreadLocal` 提供的是线程隔离的功能,即每个线程都有独立的存储空间来保存其本地变量副本[^1]。然而,在使用线程池或多线程框架时,由于新创建的任务可能运行在不同的线程中,传统 `ThreadLocal` 无法将父线程中的上下文信息传递给子线程。 TTL 解决了这一问题,它通过装饰器模式拦截任务提交的过程,并在任务执行前将父线程的上下文复制到目标线程的 `ThreadLocalMap` 中。这种机制使得即使在线程池或异步调用场景下,也能保持上下文的一致性可传递性。 --- ### ThreadLocal 的概念及实现 `ThreadLocal` 是 Java 提供的一种机制,用于实现线程级别的局部变量存储。它的核心原理在于:每个线程都维护了一个名为 `ThreadLocalMap` 的哈希表结构,其中键是 `ThreadLocal` 实例(采用弱引用),而值则是该线程专属的数据副本[^2]。当调用 `get()` 方法时,实际上是访问当前线程的 `ThreadLocalMap` 对象并从中获取对应的数据[^3]。 以下是简单的代码示例: ```java public class ThreadLocalExample { private static final ThreadLocal<String> threadLocalValue = new ThreadLocal<>(); public static void main(String[] args) { threadLocalValue.set("Main Thread Value"); System.out.println(Thread.currentThread().getName() + ": " + threadLocalValue.get()); new Thread(() -> { // 子线程不会继承主线程的 ThreadLocal 值 System.out.println(Thread.currentThread().getName() + ": " + threadLocalValue.get()); }).start(); } } ``` 上述代码展示了不同线程之间 `ThreadLocal` 数据相互隔离的特点。 --- ### InheritableThreadLocal 的特性 `InheritableThreadLocal` 是对 `ThreadLocal` 的一种增强版本,允许子线程继承父线程的初始值。具体来说,当一个新的线程启动时,如果其父线程设置了某些 `InheritableThreadLocal` 变量,则这些变量会被初始化为父线程中的相同值[^4]。需要注意的是,这种方式仅适用于显式创建的新线程,而不支持线程池等复用线程的场景。 下面是一个例子: ```java public class InheritableThreadLocalExample { private static final InheritableThreadLocal<String> inheritableThreadLocalValue = new InheritableThreadLocal<>(); public static void main(String[] args) { inheritableThreadLocalValue.set("Parent Thread Value"); new Thread(() -> { // 子线程可以继承父线程的值 System.out.println(Thread.currentThread().getName() + ": " + inheritableThreadLocalValue.get()); }).start(); } } ``` 尽管如此,`InheritableThreadLocal` 并不适用于复杂的并发环境,比如线程池或者异步任务调度。 --- ### TTL 与 ThreadLocal/InheritableThreadLocal 的区别 | **特性** | **ThreadLocal** | **InheritableThreadLocal** | **TTL** | |------------------------|--------------------------|----------------------------------|-----------------------------------| | **线程隔离** | 支持 | 支持 | 支持 | | **子线程继承值** | 不支持 | 支持 | 支持 | | **线程池兼容** | 不支持 | 不支持 | 支持 | | **异步上下文传递** | 不支持 | 不支持 | 支持 | 从表格可以看出,虽然 `ThreadLocal` `InheritableThreadLocal` 能够满足部分需求,但在现代高并发应用中(尤其是涉及线程池异步编程的情况),它们显得力不从心。相比之下,TTL 更加灵活且功能强大。 --- ### 应用场景对比 - **ThreadLocal**: 主要应用于需要严格线程隔离的简单场景,例如数据库连接管理、事务控制等。 - **InheritableThreadLocal**: 当希望子线程能够直接继承父线程的部分状态时非常有用,但受限于线程池不可用等问题。 - **TTL**: 面向复杂分布式系统的开发,特别是在微服务架构下的日志追踪、请求链路跟踪以及跨线程上下文传播等领域表现出色。 --- #### 示例代码展示 TTL 使用方式 以下是如何利用 TTL 来确保线程池内的上下文一致性: ```java import com.alibaba.ttl.TransmittableThreadLocal; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; public class TTLDemo { private static final TransmittableThreadLocal<String> contextHolder = new TransmittableThreadLocal<>(); public static void main(String[] args) throws InterruptedException { contextHolder.set("Request Context"); ExecutorService executor = Executors.newSingleThreadExecutor(TtlRunnable::get); executor.submit(() -> { String context = contextHolder.get(); // 自动从父线程继承上下文 System.out.println("Task executed with context: " + context); }); executor.shutdown(); } } ``` 在此案例中,借助 `TransmittableThreadLocal` 阿里巴巴开源库的支持,实现了线程间的安全上下文共享。 --- ###
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值