Skip to content

ThreadLocal 全过程原理

ThreadLocal 用来保存“当前线程自己的变量副本”。它常用于登录用户、租户、traceId、事务上下文、日期格式化对象等场景。但它也是线上事故高发点:内存泄漏、用户串号、租户串号、异步日志链路断裂,很多都和 ThreadLocal 使用不当有关。

学习目标

目标要能说清楚
基本模型ThreadLocal 数据到底存在哪里
set/get/remove三个方法完整执行流程
ThreadLocalMapEntry、弱引用 key、强引用 value、开放寻址
内存泄漏为什么 key 弱引用仍然会泄漏 value
线程池串数据为什么必须 finally remove()
异步传递子线程、线程池、CompletableFuture 中上下文为什么会丢
商业场景traceId、用户上下文、租户上下文如何落地
线上排查堆内存上涨、用户串号、日志 traceId 丢失怎么查

ThreadLocal 解决什么

很多业务都需要“当前请求上下文”:

上下文用途
当前登录用户权限判断、审计字段、操作日志
当前租户 ID多租户数据隔离
traceId日志链路追踪
当前事务资源Spring 事务中保存连接、同步器等
请求开始时间统计接口耗时

如果每个方法都显式传参,代码会很臃肿:

java
orderService.create(order, userId, tenantId, traceId);

ThreadLocal 提供了一种线程内上下文存储方式:

java
UserContext.set(userId);
try {
    orderService.create(order);
} finally {
    UserContext.clear();
}

业务方法内部可以从当前线程拿到上下文,而不是层层传递。

数据到底存在哪里

最大误区:数据不是存在 ThreadLocal 对象里,而是存在当前 Thread 对象里的 ThreadLocalMap 中。

mermaid
flowchart TD
    A["Thread-1"] --> B["ThreadLocalMap-1"]
    C["Thread-2"] --> D["ThreadLocalMap-2"]
    E["同一个 ThreadLocal 对象"] --> B
    E --> D
    B --> F["Entry: ThreadLocal -> valueA"]
    D --> G["Entry: ThreadLocal -> valueB"]

可以这样理解:

对象角色
Thread真正持有当前线程的 ThreadLocalMap
ThreadLocal作为 key,用来定位当前线程里的值
ThreadLocalMap每个线程自己的小 Map
Entry.value业务保存的上下文对象

同一个 ThreadLocal 在不同线程里拿到不同值,是因为它作为 key,分别存在不同线程自己的 Map 里。

set 流程

java
THREAD_LOCAL.set(value);

流程:

mermaid
flowchart TD
    A["ThreadLocal.set(value)"] --> B["获取当前线程 Thread.currentThread"]
    B --> C["读取当前线程 threadLocals"]
    C --> D{"ThreadLocalMap 是否存在"}
    D -->|不存在| E["创建 ThreadLocalMap"]
    D -->|存在| F["计算 ThreadLocal key 的位置"]
    E --> F
    F --> G{"位置是否已有 Entry"}
    G -->|空| H["新建 Entry 保存 key 和 value"]
    G -->|同 key| I["覆盖旧 value"]
    G -->|冲突或 key 失效| J["线性探测并清理失效 Entry"]

源码核心可以简化成:

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

注意:this 就是当前 ThreadLocal 对象,它是 key。

get 流程

java
String traceId = TRACE_ID.get();

流程:

mermaid
flowchart TD
    A["ThreadLocal.get"] --> B["获取当前线程"]
    B --> C["读取当前线程 ThreadLocalMap"]
    C --> D{"Map 是否存在"}
    D -->|不存在| E["调用 initialValue 并创建 Map"]
    D -->|存在| F["用当前 ThreadLocal 定位 Entry"]
    F --> G{"是否找到当前 key"}
    G -->|找到| H["返回 Entry.value"]
    G -->|没找到| E

如果使用 withInitial

java
private static final ThreadLocal<StringBuilder> BUFFER =
        ThreadLocal.withInitial(StringBuilder::new);

第一次 get() 找不到值时,会调用初始化逻辑。

remove 流程

java
THREAD_LOCAL.remove();

流程:

mermaid
flowchart TD
    A["ThreadLocal.remove"] --> B["获取当前线程 Map"]
    B --> C{"Map 是否存在"}
    C -->|不存在| D["直接结束"]
    C -->|存在| E["定位当前 ThreadLocal 对应 Entry"]
    E --> F["清除 Entry key"]
    F --> G["清理 value 和连续失效 Entry"]

remove() 的意义不只是把当前 key 的值删除,还会触发 ThreadLocalMap 对失效 Entry 的清理。在线程池中,这是必须动作。

ThreadLocalMap 为什么不是普通 HashMap

ThreadLocalMap 是 ThreadLocal 内部定制的小型 Map,它有几个特点:

特点说明
Entry 数组底层是数组
key 是弱引用Entry extends WeakReference<ThreadLocal<?>>
value 是强引用业务对象被强引用保存
开放寻址冲突时不是链表,而是向后找下一个位置
懒清理set/get/remove 时顺带清理 key 为 null 的 Entry

冲突处理示意:

mermaid
flowchart TD
    A["计算 key 下标 i"] --> B{"table[i] 是否可用"}
    B -->|空| C["放入 Entry"]
    B -->|同一个 key| D["覆盖 value"]
    B -->|其他 key| E["i + 1 继续找"]
    E --> B

这叫开放寻址。它适合 ThreadLocalMap 这种每个线程内部使用、容量通常不大的场景。

为什么 key 是弱引用

Entry 可以简化理解为:

java
static class Entry extends WeakReference<ThreadLocal<?>> {
    Object value;
}

如果 key 是强引用,会出现:

mermaid
flowchart TD
    A["业务代码不再引用 ThreadLocal"] --> B["ThreadLocalMap 仍强引用 key"]
    B --> C["ThreadLocal 对象无法被 GC"]
    C --> D["key 和 value 都可能长期存在"]

把 key 设计成弱引用后,如果业务代码已经不再引用 ThreadLocal,GC 可以回收 ThreadLocal 对象,Entry 的 key 会变成 null。

但是这不等于 value 会自动释放。

为什么还会内存泄漏

泄漏链路:

mermaid
flowchart TD
    A["线程池线程长期存活"] --> B["Thread 对象长期存活"]
    B --> C["Thread.threadLocals 长期存活"]
    C --> D["Entry key 被 GC 后变 null"]
    D --> E["Entry.value 仍是强引用"]
    E --> F["value 无法释放"]

关键点:

对象引用情况
ThreadLocal key弱引用,可能被 GC
Entry value强引用,不会因为 key 消失自动释放
ThreadLocalMap被 Thread 持有
线程池 Thread长期复用,不会很快结束

普通临时线程执行完就销毁,ThreadLocalMap 也会跟着销毁,问题不明显。线程池线程长期活着,不清理就危险。

为什么会串数据

串数据比内存泄漏更可怕,因为它可能造成用户、租户、权限错乱。

mermaid
flowchart TD
    A["请求1 使用线程 T"] --> B["USER.set(userA)"]
    B --> C["业务结束但没有 remove"]
    C --> D["线程 T 回到线程池"]
    D --> E["请求2 复用线程 T"]
    E --> F["USER.get 读到 userA"]
    F --> G["用户或租户串号"]

所以 ThreadLocal 的铁律:

java
try {
    CONTEXT.set(value);
    // business
} finally {
    CONTEXT.remove();
}

商业 Demo:请求 traceId

上下文工具类:

java
public final class TraceContext {
    private static final ThreadLocal<String> TRACE_ID = new ThreadLocal<>();

    private TraceContext() {
    }

    public static void set(String traceId) {
        TRACE_ID.set(traceId);
    }

    public static String get() {
        return TRACE_ID.get();
    }

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

在过滤器中使用:

java
import java.io.IOException;
import java.util.UUID;
import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;

public class TraceFilter implements Filter {
    @Override
    public void doFilter(ServletRequest request, ServletResponse response,
                         FilterChain chain) throws IOException, ServletException {
        HttpServletRequest httpRequest = (HttpServletRequest) request;
        String traceId = httpRequest.getHeader("X-Trace-Id");
        if (traceId == null || traceId.isBlank()) {
            traceId = UUID.randomUUID().toString();
        }

        try {
            TraceContext.set(traceId);
            chain.doFilter(request, response);
        } finally {
            TraceContext.clear();
        }
    }
}

为什么放在 finally

请求处理可能成功、失败、抛异常、被拦截器中断。只要线程要回到线程池,就必须清理。

商业 Demo:租户上下文

java
public final class TenantContext {
    private static final ThreadLocal<Long> TENANT_ID = new ThreadLocal<>();

    private TenantContext() {
    }

    public static void set(Long tenantId) {
        TENANT_ID.set(tenantId);
    }

    public static Long require() {
        Long tenantId = TENANT_ID.get();
        if (tenantId == null) {
            throw new IllegalStateException("tenantId is missing");
        }
        return tenantId;
    }

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

在多租户系统中,ThreadLocal 可以减少 tenantId 层层传参。但它只能保证当前线程内可读,不能代替数据库 where 条件、权限校验和网关鉴权。

InheritableThreadLocal 为什么在线程池中危险

InheritableThreadLocal 允许子线程创建时继承父线程变量。

java
private static final InheritableThreadLocal<String> TRACE_ID =
        new InheritableThreadLocal<>();

它只在“创建新线程”的时候复制父线程值。线程池的问题是:线程通常早就创建好了,后续任务只是复用旧线程。

mermaid
flowchart TD
    A["线程池启动"] --> B["提前创建工作线程"]
    C["请求设置父线程上下文"] --> D["提交任务到线程池"]
    D --> E["复用已有工作线程"]
    E --> F["不会重新继承当前请求上下文"]

所以在线程池里,InheritableThreadLocal 可能继承不到新值,也可能保留旧值。

异步任务怎么传递上下文

错误写法:

java
TraceContext.set("trace-001");
executor.submit(() -> {
    System.out.println(TraceContext.get()); // 可能是 null 或旧值
});

正确思路是提交任务时捕获上下文,在工作线程里设置,执行完清理。

java
import java.util.concurrent.Executor;

public class ContextAwareExecutor implements Executor {
    private final Executor delegate;

    public ContextAwareExecutor(Executor delegate) {
        this.delegate = delegate;
    }

    @Override
    public void execute(Runnable command) {
        String traceId = TraceContext.get();
        delegate.execute(() -> {
            try {
                TraceContext.set(traceId);
                command.run();
            } finally {
                TraceContext.clear();
            }
        });
    }
}

这类包装思路也常用于 CompletableFuture、线程池、消息消费、批处理任务。

ThreadLocal 能不能解决线程安全

它解决的是“每个线程一份变量”,不是“共享变量安全”。

安全示例:

java
private static final ThreadLocal<StringBuilder> BUFFER =
        ThreadLocal.withInitial(StringBuilder::new);

每个线程拿到自己的 StringBuilder,互不共享。

危险示例:

java
private static final User SHARED_USER = new User();
private static final ThreadLocal<User> USER =
        ThreadLocal.withInitial(() -> SHARED_USER);

虽然用了 ThreadLocal,但每个线程拿到的是同一个共享对象,仍然可能线程不安全。

Spring 里哪里用了 ThreadLocal

常见例子:

场景大致用途
事务管理保存当前线程绑定的数据库连接、事务同步资源
RequestContextHolder保存当前请求对象
SecurityContextHolder保存当前认证用户上下文
MDC 日志上下文保存 traceId、userId 等日志字段

这些框架能用 ThreadLocal,是因为它们通常在请求入口设置,在请求结束或事务结束时清理。自己写业务代码也必须遵守这个生命周期。

线上排查一:内存泄漏

现象:

现象可能原因
堆内存缓慢上涨ThreadLocal value 没清理
老年代对象越来越多线程池长期引用上下文对象
dump 中大量业务对象被 Thread 引用Thread.threadLocals 持有 value

排查流程:

mermaid
flowchart TD
    A["堆内存持续上涨"] --> B["保留 heap dump"]
    B --> C["MAT 查看大对象和引用链"]
    C --> D{"是否被 Thread 引用"}
    D -->|是| E["展开 threadLocals"]
    E --> F["查看 Entry value 类型"]
    F --> G["定位哪个 ThreadLocal 未 remove"]
    D -->|否| H["继续查集合缓存、队列、静态变量"]

重点看引用链里是否出现:

text
Thread -> threadLocals -> ThreadLocalMap -> Entry -> value

线上排查二:用户串号或租户串号

排查流程:

mermaid
flowchart TD
    A["出现用户串号"] --> B["检查用户上下文来源"]
    B --> C{"是否使用 ThreadLocal"}
    C -->|是| D["检查入口是否 set"]
    D --> E["检查 finally 是否 remove"]
    E --> F["检查异常路径是否跳过清理"]
    F --> G["检查异步任务是否复制旧上下文"]
    C -->|否| H["检查缓存 key、Session、Token、网关转发"]

要特别检查:

检查点说明
过滤器 finally最常见遗漏点
异常返回异常路径也必须清理
线程池任务父线程 ThreadLocal 不会自动传递
MDC 日志traceId 丢失或串日志
租户拦截器SQL 拼租户条件前是否拿到正确 tenantId

常见坑

后果正确做法
set 后不 remove内存泄漏、串数据try/finally remove
在线程池依赖 InheritableThreadLocal继承不到新值或读到旧值显式包装任务传递上下文
ThreadLocal 保存大对象泄漏影响更严重只放小上下文 ID
ThreadLocal 保存可变共享对象仍然线程不安全每个线程创建独立对象
在异步任务中直接 get上下文丢失提交任务时捕获并设置
把 ThreadLocal 当缓存线程维度缓存不可控用明确的缓存组件并设置过期

面试标准回答

ThreadLocal 是什么

ThreadLocal 是线程本地变量工具。它让同一个 ThreadLocal 对象在不同线程里保存不同值,常用于当前用户、traceId、租户、事务上下文等。数据不是存在 ThreadLocal 自己里面,而是存在每个 Thread 对象内部的 ThreadLocalMap 中,ThreadLocal 只是 key。

ThreadLocal 为什么会内存泄漏

ThreadLocalMap 的 Entry 中 key 是弱引用,value 是强引用。如果 ThreadLocal 对象没有外部强引用,GC 后 key 会变成 null,但 value 仍然被 Entry 强引用。在线程池里线程长期存活,ThreadLocalMap 也长期存在,如果不 remove,value 就可能一直无法释放。

ThreadLocal 为什么会串数据

线程池会复用线程。上一个请求在线程 T 中设置了 ThreadLocal,如果结束时没有 remove,线程 T 回到线程池后又处理下一个请求,下一个请求就可能读到上一个请求的用户、租户或 traceId。所以 ThreadLocal 必须在 finally 中清理。

InheritableThreadLocal 能解决异步上下文传递吗

只能解决创建新子线程时继承父线程变量,不能可靠解决线程池上下文传递。线程池线程通常提前创建并复用,任务提交时不会重新继承当前父线程上下文,所以可能拿不到值或拿到旧值。生产里更推荐包装 Runnable、Callable 或 Executor,显式捕获、设置和清理上下文。

ThreadLocal 能保证线程安全吗

ThreadLocal 只能让每个线程拿到自己的变量副本,不能让共享对象自动线程安全。如果 ThreadLocal 里放的是同一个共享对象,多个线程仍然会并发修改同一个对象。它适合保存线程私有上下文,不适合替代锁。

关联知识点

知识点为什么要看
ThreadLocal基础快速回顾基础模型和常见用法
线程池生命周期与排查线程池复用为什么放大 ThreadLocal 风险
CompletableFuture全过程异步编排中上下文为什么容易丢
Java内存模型区分线程隔离、可见性和共享变量安全
synchronized全过程对比 ThreadLocal 和锁解决的问题不同