ThreadLocal 全过程原理
ThreadLocal 用来保存“当前线程自己的变量副本”。它常用于登录用户、租户、traceId、事务上下文、日期格式化对象等场景。但它也是线上事故高发点:内存泄漏、用户串号、租户串号、异步日志链路断裂,很多都和 ThreadLocal 使用不当有关。
学习目标
| 目标 | 要能说清楚 |
|---|---|
| 基本模型 | ThreadLocal 数据到底存在哪里 |
| set/get/remove | 三个方法完整执行流程 |
| ThreadLocalMap | Entry、弱引用 key、强引用 value、开放寻址 |
| 内存泄漏 | 为什么 key 弱引用仍然会泄漏 value |
| 线程池串数据 | 为什么必须 finally remove() |
| 异步传递 | 子线程、线程池、CompletableFuture 中上下文为什么会丢 |
| 商业场景 | traceId、用户上下文、租户上下文如何落地 |
| 线上排查 | 堆内存上涨、用户串号、日志 traceId 丢失怎么查 |
ThreadLocal 解决什么
很多业务都需要“当前请求上下文”:
| 上下文 | 用途 |
|---|---|
| 当前登录用户 | 权限判断、审计字段、操作日志 |
| 当前租户 ID | 多租户数据隔离 |
| traceId | 日志链路追踪 |
| 当前事务资源 | Spring 事务中保存连接、同步器等 |
| 请求开始时间 | 统计接口耗时 |
如果每个方法都显式传参,代码会很臃肿:
orderService.create(order, userId, tenantId, traceId);ThreadLocal 提供了一种线程内上下文存储方式:
UserContext.set(userId);
try {
orderService.create(order);
} finally {
UserContext.clear();
}业务方法内部可以从当前线程拿到上下文,而不是层层传递。
数据到底存在哪里
最大误区:数据不是存在 ThreadLocal 对象里,而是存在当前 Thread 对象里的 ThreadLocalMap 中。
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 流程
THREAD_LOCAL.set(value);流程:
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"]源码核心可以简化成:
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 流程
String traceId = TRACE_ID.get();流程:
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:
private static final ThreadLocal<StringBuilder> BUFFER =
ThreadLocal.withInitial(StringBuilder::new);第一次 get() 找不到值时,会调用初始化逻辑。
remove 流程
THREAD_LOCAL.remove();流程:
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 |
冲突处理示意:
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 可以简化理解为:
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value;
}如果 key 是强引用,会出现:
flowchart TD
A["业务代码不再引用 ThreadLocal"] --> B["ThreadLocalMap 仍强引用 key"]
B --> C["ThreadLocal 对象无法被 GC"]
C --> D["key 和 value 都可能长期存在"]把 key 设计成弱引用后,如果业务代码已经不再引用 ThreadLocal,GC 可以回收 ThreadLocal 对象,Entry 的 key 会变成 null。
但是这不等于 value 会自动释放。
为什么还会内存泄漏
泄漏链路:
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 也会跟着销毁,问题不明显。线程池线程长期活着,不清理就危险。
为什么会串数据
串数据比内存泄漏更可怕,因为它可能造成用户、租户、权限错乱。
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 的铁律:
try {
CONTEXT.set(value);
// business
} finally {
CONTEXT.remove();
}商业 Demo:请求 traceId
上下文工具类:
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();
}
}在过滤器中使用:
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:租户上下文
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 允许子线程创建时继承父线程变量。
private static final InheritableThreadLocal<String> TRACE_ID =
new InheritableThreadLocal<>();它只在“创建新线程”的时候复制父线程值。线程池的问题是:线程通常早就创建好了,后续任务只是复用旧线程。
flowchart TD
A["线程池启动"] --> B["提前创建工作线程"]
C["请求设置父线程上下文"] --> D["提交任务到线程池"]
D --> E["复用已有工作线程"]
E --> F["不会重新继承当前请求上下文"]所以在线程池里,InheritableThreadLocal 可能继承不到新值,也可能保留旧值。
异步任务怎么传递上下文
错误写法:
TraceContext.set("trace-001");
executor.submit(() -> {
System.out.println(TraceContext.get()); // 可能是 null 或旧值
});正确思路是提交任务时捕获上下文,在工作线程里设置,执行完清理。
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 能不能解决线程安全
它解决的是“每个线程一份变量”,不是“共享变量安全”。
安全示例:
private static final ThreadLocal<StringBuilder> BUFFER =
ThreadLocal.withInitial(StringBuilder::new);每个线程拿到自己的 StringBuilder,互不共享。
危险示例:
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 |
排查流程:
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["继续查集合缓存、队列、静态变量"]重点看引用链里是否出现:
Thread -> threadLocals -> ThreadLocalMap -> Entry -> value线上排查二:用户串号或租户串号
排查流程:
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 和锁解决的问题不同 |
