ThreadLocal

发布于:2025-02-10 ⋅ 阅读:(51) ⋅ 点赞:(0)

一、先用一个“生活化”类比来理解

假设有一个健身房,里面有很多储物柜(Locker)。每次来一个人,就分配给他(或她)一个储物柜,里面可以放这位客人的私人物品(例如手机、衣服、钥匙等)。当另一个人来健身时,也分配另一个不同的储物柜,两个客人之间不会互相影响或混用柜子。

  • 储物柜 = ThreadLocal 中存储的数据
  • 每位客人 = 程序里的“线程”
  • 健身房 = 运行时的 JVM 或者说程序整体环境

对比:

  1. 每个客人只能访问自己的储物柜,也就是说不同客人是不会看、也看不到彼此的储物柜里的东西;
  2. 如果客人走了不把自己的柜子清空,下一次来另一个客人有可能会被分到同一个柜子,发现里面还有遗留物品,产生混乱;
  3. 所以,用完就清空非常关键;
  4. 同时,这个储物柜对于客人来说,在他们使用期间相当于随时可取随时可放的“私有空间”。

把这个类比映射到程序世界,就是:

  • “客人” -> “线程”
  • “储物柜” -> “线程本地变量 (Thread-Local variable)”
  • 每个线程操作的那份数据是独立且私有的,不与其他线程冲突。

这便是 ThreadLocal 最本质的概念:给每个线程分配一个私有的存储空间,使得该线程可以在里面读写数据,而其他线程无法干涉。


二、什么是 ThreadLocal,为什么要有它?

1. “线程局部变量”的来历

在多线程环境中,如果多条线程都要访问(读写)同一个全局变量,就会遇到并发、安全、数据一致性等问题。我们可能需要加锁、加 volatile 等,或者想办法把这个变量变成方法参数层层传递,十分繁琐。

但有些场景,数据其实不需要被线程之间共享,而是“线程私有”的。举例:

  • 当前线程处理的是“请求A”,里面存了“用户ID=1001”;
  • 另一个线程处理“请求B”,里面存了“用户ID=2002”;
  • 这两条线程对 “用户ID” 的值并没有交互或共享的必要,每个线程只关心“自己的用户ID”即可。

如果我们希望快速地在同一个线程的上下文里保存并访问这样的数据,同时不必担心和其他线程的冲突,也避免了在方法参数间反复传递,那么 ThreadLocal 就登场了。

2. ThreadLocal 的核心点

  • 同一个 ThreadLocal 实例在不同线程中,会分别存一份“线程私有的数据”
  • 线程之间互不影响,也互不可见。
  • 这在多层调用、跨模块时,非常方便,省得层层传递或维护公共状态。

三、ThreadLocal 的内部原理

如果你对底层实现不感兴趣,可以跳过,但了解一下有助于理解“为什么一定要及时清理”。

1. 每个线程有一个 ThreadLocalMap

在 Java 的实现中,每一个 Thread(准确说是 java.lang.Thread 对象)内部,都会有一个 ThreadLocalMap 的属性。它是一个散列表结构,用来存储 <ThreadLocal<?>, Object> 这样的键值对。

  • 当我们对某个 ThreadLocal 实例调用 set(value) 时,实际操作的是
    当前线程(Thread.currentThread())内部的 ThreadLocalMap ,往那张表里塞入一条记录:key = 该 ThreadLocal 对象,value = value

  • 当我们对同一个 ThreadLocal 实例调用 get() 时,它会去当前线程ThreadLocalMap 里找 key=这个 ThreadLocal 的记录,然后把 value 取出来返回给我们。

所以,每个线程都维护着一张自己的 ThreadLocalMap,里面可能会存多条记录。不同线程各自一张表,所以存储在其中的数据自然是互不可见的。

2. “key 为弱引用” 及“需要手动 remove()”

ThreadLocalMap 有一个特殊处理:它对 key(即 ThreadLocal 对象)使用“弱引用(WeakReference)”来避免内存泄露。但如果 ThreadLocal 对象被垃圾回收了,而我们忘记调用 remove() 去清理 Map 里的 value,那么这个 value 可能会变成”Key = null, Value = XXX“ 的悬挂条目(zombie entry),从而导致内存无法被回收,产生内存泄漏

因此,官方建议:在使用完 ThreadLocal 后,显式调用 remove() 方法,以保证我们在后续不会出现残留数据,也更安全。


四、怎么使用 ThreadLocal?(代码演示)

我们先写一个最简单的用法:演示如何给各个线程设置不同的值,并取出来:

public class ThreadLocalExample {

    // 声明一个静态 ThreadLocal 用来存储 String
    private static final ThreadLocal<String> threadLocal = new ThreadLocal<>();

    public static void main(String[] args) {
        // 启动两个线程
        Thread t1 = new Thread(() -> {
            // 设置当前线程的局部变量
            threadLocal.set("数据A - 来自线程T1");
            // 获取并打印
            System.out.println("T1得到: " + threadLocal.get());
            // 用完后,清理
            threadLocal.remove();
        });

        Thread t2 = new Thread(() -> {
            threadLocal.set("数据B - 来自线程T2");
            System.out.println("T2得到: " + threadLocal.get());
            threadLocal.remove();
        });

        t1.start();
        t2.start();
    }
}

典型输出会是:

T1得到: 数据A - 来自线程T1
T2得到: 数据B - 来自线程T2

你会看到,“T1”和“T2”各自 set 的值不会相互影响。

一些可选用法

  • get():如果从未 set() 过,默认返回 null
  • set(T value):把当前线程的值设为 value,类型是你定义的泛型。
  • remove():清理当前线程的这份值,非常重要

可以把 ThreadLocal 当作线程范围的存储容器。在多层调用或框架中,你不想在每个方法都增加一个形参来传递某个信息(比如 UserId),那就把它放进 ThreadLocal,在需要的地方 get() 出来就行。


五、在 Spring Boot / Web 应用中:为什么这么常见?

1. 场景:一次 HTTP 请求对应一个工作线程

在很多后端 Web 框架(包括 Spring Boot, Tomcat 容器)中,请求进来后通常会被分配到线程池中的某个工作线程去处理。请求处理完再归还线程到池里。

在整个处理过程中(Controller -> Service -> DAO -> ...),都处于同一个线程上下文。这时,ThreadLocal 就特别有用。比如:

(1)存储“当前登录用户ID”

  • 在请求开始时,通过过滤器/拦截器解析出 token,得到 userId;
  • UserContextHolder.setUserId(userId)(内部就是用 ThreadLocal 存储)
  • 后面的 Service、DAO 想获取当前用户ID时,随时 UserContextHolder.getUserId() 即可。
  • 最后在请求结束时 UserContextHolder.remove() 及时清理。

(2)存储“TraceId / RequestId” 以便日志关联

  • 日志系统常用 ThreadLocal 来注入一个“traceId”,这样在一条完整请求的日志里就能输出统一的追踪 ID。
  • 此外,像 Log4j / Logback 的 MDC 也是借助 ThreadLocal 原理来存储信息。

(3)多租户 (Multi-Tenant) 场景

  • 不同租户 ID 需要额外做数据隔离或 SQL 过滤。可以在请求进入时先 ThreadLocal 存一个 tenantId,然后在 DAO 里获取该 tenantId 并做过滤。

2. 好处:避免层层传参 & 线程安全

  • 如果不用 ThreadLocal,我们可能需要在所有的方法调用链里都加一个 Long userId,这样非常麻烦且可读性差;
  • ThreadLocal,我们就“声明一次” -> “随处可取”;又不必担心多线程并发修改的问题,因为它是“当前线程私有”。

六、最佳实践:一定要做到“用完即清除”

1. 为什么必须清除?

  • 线程池环境下,线程是被重复使用的。如果这个线程在上一次请求中存了一个 userId=1001,却没有 remove(), 那下一次有可能处理另一个用户的时候,再 get() 还会拿到残留的 1001,引发严重的安全漏洞或业务错误。
  • 内存泄漏角度,JDK 的实现里,如果 ThreadLocal 对象本身被回收了,而你不清理它在 ThreadLocalMap 中的存储条目,就会有“key=null,但 value 还存活”的情况,造成泄漏。

2. 具体怎么做?

  • 通常在 Web 应用里,可以在过滤器或拦截器finally / afterCompletion() 阶段执行 ThreadLocal.remove()
  • 或者在需要使用的地方,用完就移除,不再保留。

示例(Spring MVC 拦截器):

public class UserContextInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        // 解析 userId
        Long userId = ... // 解析 token
        UserContextHolder.setUserId(userId);
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
        // 清理
        UserContextHolder.removeUserId();
    }
}

UserContextHolder 是一个自己封装的类:

public class UserContextHolder {

    private static final ThreadLocal<Long> userIdHolder = new ThreadLocal<>();

    public static void setUserId(Long userId) {
        userIdHolder.set(userId);
    }

    public static Long getUserId() {
        return userIdHolder.get();
    }

    public static void removeUserId() {
        userIdHolder.remove();
    }
}
  •  UserContextHolder内部通过创建对应类型的ThreadLocal对象来存储对应类型的线程局部变量。
  • 一定要在在 Web 请求拦截器的 afterCompletion() 里调用ContextHolder中定义的用于清除对应线程局部变量的ThreadLocal的remove方法。
  • UserContextHolder 类本身对所有线程来说是一份(因为它是静态的)。
  • ThreadLocal 帮我们完成了数据副本的隔离,保证每个线程获取到的值都是自己那份,不会互相干扰。
  • 因而,“在不同线程中,UserContextHolder 看似共享一个 ThreadLocal 变量,但实际存的值各不相同”,因为它利用线程内部的 ThreadLocalMap 做了隔离。

这样就保证了线程不会带着“上一次的私有数据”到下一次请求里,线程安全问题也就迎刃而解。

当有多个线程局部变量 

public class UserContextHolder {

    // 存储当前线程的用户ID
    private static final ThreadLocal<Long> userIdHolder = new ThreadLocal<>();
    // 存储当前线程的租户ID
    private static final ThreadLocal<String> tenantIdHolder = new ThreadLocal<>();
    // 存储当前线程的请求追踪ID(用于日志跟踪)
    private static final ThreadLocal<String> traceIdHolder = new ThreadLocal<>();

    // --------------- 用户ID ---------------
    public static void setUserId(Long userId) {
        userIdHolder.set(userId);
    }

    public static Long getUserId() {
        return userIdHolder.get();
    }

    public static void removeUserId() {
        userIdHolder.remove();
    }

    // --------------- 租户ID ---------------
    public static void setTenantId(String tenantId) {
        tenantIdHolder.set(tenantId);
    }

    public static String getTenantId() {
        return tenantIdHolder.get();
    }

    public static void removeTenantId() {
        tenantIdHolder.remove();
    }

    // --------------- 追踪ID ---------------
    public static void setTraceId(String traceId) {
        traceIdHolder.set(traceId);
    }

    public static String getTraceId() {
        return traceIdHolder.get();
    }

    public static void removeTraceId() {
        traceIdHolder.remove();
    }

    // --------------- 统一清理方法,防止内存泄漏 ---------------
    public static void clear() {
        userIdHolder.remove();
        tenantIdHolder.remove();
        traceIdHolder.remove();
    }
}
  • 每个变量都单独使用一个 ThreadLocal
    • 这样保证不同的变量互不干扰,每个线程都能存取自己的 userIdtenantIdtraceId
  • 提供 set() / get() / remove() 三个方法
    • setXXX() 用于存储数据
    • getXXX() 用于读取数据
    • removeXXX() 用于清理数据
  • 提供 clear() 方法
    • 一次性清理所有 ThreadLocal 变量,避免在线程池环境下的内存泄漏问题。
    • 在 Web 请求拦截器的 afterCompletion() 里调用 clear(),确保线程不会残留上次请求的数据。

七、总结:ThreadLocal 的本质与注意事项

  • 本质

    • ThreadLocal 为每个线程都“开”了一块独立空间(可以类比“储物柜”);
    • 在这个空间里存的值对其他线程不可见,因此可以保证线程私有数据的安全;
    • 用来避免多线程共享数据的并发问题或者繁琐的参数传递。
  • 使用场景

    • 存储当前请求信息(用户ID、请求ID、租户ID...),日志 MDC,事务上下文,等等。
    • 只在同一个线程内需要访问的数据,不用跨线程。
  • 核心 API

    • set(T value):把当前线程对应的 ThreadLocal 值设置成 value
    • get():获取当前线程对应的值(若没设置过默认 null)。
    • remove():移除当前线程对应的值,必须养成习惯“用完就清理”。
  • 注意事项

    • 一定要保证 remove(),尤其在线程池环境或长期存活线程中,否则会产生数据混乱内存泄漏
    • 切勿将 ThreadLocal 当作跨线程共享的工具,它只适合“线程私有”;
    • 合理封装,可以在一个管理类(例如 UserContextHolder)里完成“set/get/remove”,让代码更清晰。

一句话概括

ThreadLocal 是一个“线程范围的存储工具”,能够在多线程应用中优雅地管理“仅属于当前线程”的数据,用完一定要及时清理,就能避免各种线程安全或内存泄漏问题。


八、再补充几点常见疑问

  • ThreadLocal 与 Synchronized 的区别

    • ThreadLocal 用于“让每个线程各存一份数据”,从根本上避免了数据竞争;
    • synchronized(或锁)用于“让多个线程顺序访问共享数据,防止并发冲突”。
    • 若你根本不需要多线程共享同一个数据,那直接用 ThreadLocal 可能更简洁。
  • 为什么要在 Web 应用尤其注意?

    • 因为 Web 服务器一般都有线程池来处理请求,一个线程在处理完请求A后,可能会被复用去处理请求B。
    • 如果不清理,可能把请求A的东西带给了B,后果严重。
  • ThreadLocalMap 的 key 是弱引用,value 是强引用

    • 这意味着如果 ThreadLocal 实例本身没有被外部引用,就会被 GC 回收,而 ThreadLocalMap 里只剩下 null -> value,value 无法释放,出现内存泄漏。
    • 所以养成 remove() 的好习惯
  • MDC (Mapped Diagnostic Context) 是怎么用 ThreadLocal 的?

    • MDC 其实就是为每个线程(请求)维护一张 Map,存储一些日志上下文,如 traceIduserId 等;
    • 底层就是通过 ThreadLocal<Map<String, String>> 来实现的。

最后,一段话总结

  • ThreadLocal 可以认为是一种在“当前线程”里随时取用的数据存储,不会与其他线程的存储混在一起;
  • 它的出现,大大简化了跨层共享局部数据的开发难度,尤其在Web请求上下文、日志上下文、认证信息等方面非常实用;
  • 但它需要使用者保持极大的自觉记得清理(remove),否则会有副作用(脏数据、内存泄漏);
  • 这是它最常被人诟病的地方,但是如果使用得当,ThreadLocal 是一个非常优雅、简洁的多线程编程“管理器”。

网站公告

今日签到

点亮在社区的每一天
去签到