深入理解 ThreadLocal —— 从变量引用、强弱引用到 Spring Boot 实战

在 Spring Boot 项目开发中,很多同学第一次接触 ThreadLocal 时,都会有几个疑惑:

  • ThreadLocal 到底把数据存在哪里?
  • 为什么一个全局 ThreadLocal 对象,可以让每个线程都有自己的值?
  • ThreadLocalMap 里面的 key 和 value 到底是谁引用谁?
  • 为什么 ThreadLocalMap 的 key 要设计成弱引用?
  • 为什么线程池场景下必须调用 remove()

这篇文章就从最基础的 Java 变量引用关系 开始,逐步讲清楚 ThreadLocal 的底层原理和 Spring Boot 中的实际用法。


1. 先理解 Java 变量和对象的引用关系

在理解 ThreadLocal 之前,必须先搞懂一件事:

java 复制代码
void test() {
    User a = new User();
}

这段代码里面,到底是谁引用了谁?

1.1 new User() 在哪里?

new User() 会在 堆内存 中创建一个 User 对象。

java 复制代码
User a = new User();

可以粗略理解成两步:

java 复制代码
User对象 = new User(); // 在堆中创建对象
User a = User对象的引用; // 在栈中创建局部变量 a,保存对象引用

执行过程中,内存关系大概是这样:

text 复制代码
栈内存                          堆内存
┌──────────────┐              ┌──────────────┐
│ test 栈帧     │              │   User对象    │
│              │              │              │
│ a ───────────┼─────────────>│ new User()   │
└──────────────┘              └──────────────┘

也就是说:

是局部变量 a 引用了堆中的 User 对象。

不是 User 对象引用了变量 a


1.2 a 是不是指针?

在 Java 里面,一般不说"指针",而是说 引用

但是为了初学时理解,你可以把它近似理解成:

a 是一个"安全版指针",里面保存着找到堆对象的信息。

它和 C/C++ 指针不同,Java 不允许你直接操作内存地址,比如不能做:

java 复制代码
a + 1;

所以准确说法是:

text 复制代码
a 是引用变量,不是传统意义上的指针。

1.3 方法结束后,对象会立刻销毁吗?

不会。

比如:

java 复制代码
void test() {
    User a = new User();
    return;
}

方法执行时:

text 复制代码
栈内存                          堆内存
┌──────────────┐              ┌──────────────┐
│ test 栈帧     │              │   User对象    │
│ a ───────────┼─────────────>│              │
└──────────────┘              └──────────────┘

方法结束后,test 方法对应的栈帧销毁,局部变量 a 也销毁:

text 复制代码
栈内存                          堆内存
┌──────────────┐              ┌──────────────┐
│ test 栈帧消失  │              │   User对象    │
│ a 消失        │              │   暂时还在     │
└──────────────┘              └──────────────┘

此时堆中的 User 对象如果没有其他引用指向它,就会变成 垃圾对象

注意:

方法结束后,局部变量 a 会立刻消失;

但是堆中的 User 对象不是立刻销毁,而是等待 GC 回收。

可以总结为:

text 复制代码
方法执行中:
a ─────────> User对象

方法结束后:
a 消失
User对象没有引用,等待 GC 回收

1.4 什么叫强引用?

平时最常见的引用就是强引用:

java 复制代码
User a = new User();

关系是:

text 复制代码
a ─── 强引用 ───> User对象

只要 a 还存在,并且程序还能通过 a 找到这个对象,那么 GC 就不会回收这个 User 对象。

也就是说:

text 复制代码
强引用的意思是:只要我还指着你,GC 就不能回收你。

1.5 什么叫弱引用?

弱引用需要用 WeakReference 包一层:

java 复制代码
WeakReference<User> weakRef = new WeakReference<>(new User());

关系是:

text 复制代码
weakRef ─── 弱引用 ───> User对象

弱引用的特点是:

虽然弱引用也能找到对象,但是它不能阻止 GC 回收对象。

如果一个对象只剩弱引用,没有任何强引用,那么 GC 看到它时,就可以直接回收。

例如:

java 复制代码
User a = new User();
WeakReference<User> weakRef = new WeakReference<>(a);

此时关系是:

text 复制代码
a       ─── 强引用 ───> User对象
weakRef ─── 弱引用 ───> User对象

这时 User对象 不会被回收,因为它还有强引用 a

如果执行:

java 复制代码
a = null;

关系就变成:

text 复制代码
a       不再指向 User对象
weakRef ─── 弱引用 ───> User对象

此时 User对象 只剩弱引用,下一次 GC 时就可以被回收。

回收之后:

java 复制代码
weakRef.get(); // null

2. ThreadLocal 是什么?

ThreadLocal 是一种 线程局部变量机制

它的作用是:

让每个线程都拥有一份独立的数据,线程之间互不影响。

但是一定要注意:

ThreadLocal 对象本身通常不直接保存业务值。

真正的 value 是存在当前线程 Thread 对象里的 ThreadLocalMap 中。


3. ThreadLocal 的真实存储结构

很多人会误以为 ThreadLocal 是这样存的:

text 复制代码
ThreadLocal对象
  ├── Thread-1 -> frank
  ├── Thread-2 -> tom
  └── Thread-3 -> jack

但实际上不是。

真实结构更像这样:

text 复制代码
Thread-1
  └── ThreadLocalMap
        └── userHolder -> frank

Thread-2
  └── ThreadLocalMap
        └── userHolder -> tom

Thread-3
  └── ThreadLocalMap
        └── userHolder -> jack

也就是说:

每个线程对象 Thread 里面都有自己的 ThreadLocalMap

ThreadLocal 对象只是这个 Map 里的 key。

你存进去的值,是 Map 里的 value。


4. 全局 ThreadLocal 和每个线程自己的值,为什么不矛盾?

比如我们定义一个全局 ThreadLocal

java 复制代码
public class UserContext {
    private static final ThreadLocal<String> userHolder = new ThreadLocal<>();
}

这里的 userHolder 的确是一个全局静态对象,整个 JVM 里面通常只有这一份。

但是每个线程都有自己的 ThreadLocalMap

所以结构是:

text 复制代码
全局静态变量:
UserContext.userHolder
        │
        │ 强引用
        v
ThreadLocal对象

不同线程里面:

text 复制代码
Thread-A
  └── ThreadLocalMap
        └── key: userHolder -> value: "frank"

Thread-B
  └── ThreadLocalMap
        └── key: userHolder -> value: "tom"

所以不矛盾。

可以这样理解:

ThreadLocal 是同一把钥匙;

每个线程都有自己的柜子;

同一把钥匙去不同柜子里开出来的格子,里面可以放不同的值。


5. ThreadLocal 的 set 和 get 底层大概做了什么?

假设你写:

java 复制代码
userHolder.set("frank");

底层近似逻辑是:

java 复制代码
Thread currentThread = Thread.currentThread();

ThreadLocalMap map = currentThread.threadLocals;

if (map == null) {
    map = new ThreadLocalMap();
    currentThread.threadLocals = map;
}

map.set(userHolder, "frank");

重点是:

text 复制代码
不是 userHolder 自己保存了 frank;
而是当前线程的 ThreadLocalMap 保存了:
userHolder -> frank

再看 get()

java 复制代码
userHolder.get();

底层近似逻辑:

java 复制代码
Thread currentThread = Thread.currentThread();

ThreadLocalMap map = currentThread.threadLocals;

return map.get(userHolder);

所以核心模型是:

text 复制代码
当前线程.threadLocalMap.get(当前 ThreadLocal 对象)

6. Spring Boot 中为什么常用 ThreadLocal?

在 Spring Boot Web 项目中,一个 HTTP 请求通常由 Tomcat 线程池中的某个线程处理。

常见线程名类似:

text 复制代码
http-nio-8080-exec-1
http-nio-8080-exec-2
http-nio-8080-exec-3

Tomcat 线程池的特点是:

请求结束后,线程不会销毁,而是回到线程池,等待处理下一次请求。

因此,ThreadLocal 常用于保存请求级别上下文,比如:

  • 当前登录用户
  • 当前请求 TraceId
  • 租户 ID
  • 请求链路上下文
  • 日志 MDC 信息

7. Spring Boot 中使用 ThreadLocal 示例

7.1 定义上下文对象

如果只保存一个值,可以直接用一个 ThreadLocal

但实际项目中,通常推荐封装一个上下文对象:

java 复制代码
public class RequestContext {

    private String username;

    private String traceId;

    private Long userId;

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public String getTraceId() {
        return traceId;
    }

    public void setTraceId(String traceId) {
        this.traceId = traceId;
    }

    public Long getUserId() {
        return userId;
    }

    public void setUserId(Long userId) {
        this.userId = userId;
    }
}

然后定义 RequestContextHolder

java 复制代码
public class RequestContextHolder {

    private static final ThreadLocal<RequestContext> HOLDER = new ThreadLocal<>();

    public static void set(RequestContext context) {
        HOLDER.set(context);
    }

    public static RequestContext get() {
        return HOLDER.get();
    }

    public static String getUsername() {
        RequestContext context = HOLDER.get();
        return context == null ? null : context.getUsername();
    }

    public static String getTraceId() {
        RequestContext context = HOLDER.get();
        return context == null ? null : context.getTraceId();
    }

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

7.2 在拦截器中设置上下文

java 复制代码
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.util.UUID;

@Component
public class UserInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request,
                             HttpServletResponse response,
                             Object handler) {

        String username = request.getHeader("X-USER");
        String traceId = request.getHeader("X-TRACE-ID");

        if (traceId == null || traceId.isBlank()) {
            traceId = UUID.randomUUID().toString().replace("-", "");
        }

        RequestContext context = new RequestContext();
        context.setUsername(username);
        context.setTraceId(traceId);

        RequestContextHolder.set(context);

        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request,
                                HttpServletResponse response,
                                Object handler,
                                Exception ex) {
        RequestContextHolder.clear();
    }
}

Controller 里就可以直接获取:

java 复制代码
@GetMapping("/hello")
public String hello() {
    return "Hello " + RequestContextHolder.getUsername()
            + ", traceId = " + RequestContextHolder.getTraceId();
}

8. ThreadLocal 里面的引用关系到底是什么样的?

这是理解内存泄漏的重点。

ThreadLocalMap 里面的 Entry 大概类似这样:

java 复制代码
static class Entry extends WeakReference<ThreadLocal<?>> {
    Object value;
}

也就是说:

text 复制代码
Entry.key   是弱引用,指向 ThreadLocal 对象
Entry.value 是强引用,指向你存进去的业务对象

比如:

java 复制代码
private static final ThreadLocal<User> userHolder = new ThreadLocal<>();

userHolder.set(new User("frank"));

内存引用关系大概是:

text 复制代码
UserContext 类
  └── static userHolder
          │
          │ 强引用
          v
      ThreadLocal对象

当前线程 Thread
  └── ThreadLocalMap
        └── Entry
              ├── key   ──弱引用──> ThreadLocal对象
              └── value ─强引用──> User("frank")

画成完整图:

text 复制代码
┌────────────────────┐
│ UserContext 类      │
│ static userHolder   │
└─────────┬──────────┘
          │ 强引用
          v
┌────────────────────┐
│ ThreadLocal 对象    │
└─────────▲──────────┘
          │ 弱引用
┌─────────┴──────────┐
│ Entry.key           │
│ Entry.value ────────┼──── 强引用 ────> User("frank")
└────────────────────┘

需要注意:

ThreadLocalMap.Entry.key 是弱引用。

ThreadLocalMap.Entry.value 是强引用。


9. 为什么 ThreadLocalMap 的 key 要设计成弱引用?

假设 key 是强引用。

代码:

java 复制代码
public void test() {
    ThreadLocal<User> local = new ThreadLocal<>();
    local.set(new User("frank"));
}

方法执行时:

text 复制代码
栈内存
┌──────────────┐
│ local         │
└──────┬───────┘
       │ 强引用
       v
ThreadLocal对象

当前线程 Thread
  └── ThreadLocalMap
        └── Entry
              ├── key   ─强引用─> ThreadLocal对象
              └── value ─强引用─> User("frank")

方法结束后,局部变量 local 消失。

如果 Entry 的 key 是强引用,那么引用关系仍然是:

text 复制代码
当前线程 Thread
  └── ThreadLocalMap
        └── Entry
              ├── key   ─强引用─> ThreadLocal对象
              └── value ─强引用─> User("frank")

只要线程不死,ThreadLocalMap 就不死,Entry 就不死。

结果就是:

text 复制代码
ThreadLocal对象 回收不了
User("frank") 也回收不了

这就很危险。

所以 JDK 把 key 设计成弱引用。

真实情况是:

text 复制代码
当前线程 Thread
  └── ThreadLocalMap
        └── Entry
              ├── key   ─弱引用─> ThreadLocal对象
              └── value ─强引用─> User("frank")

方法结束后,局部变量 local 消失:

text 复制代码
local 消失

Entry.key ─弱引用─> ThreadLocal对象
Entry.value ─强引用─> User("frank")

此时 ThreadLocal对象 只剩弱引用。

GC 看到这个 ThreadLocal对象 没有强引用了,就可以回收它。

GC 之后:

text 复制代码
当前线程 Thread
  └── ThreadLocalMap
        └── Entry
              ├── key = null
              └── value ─强引用─> User("frank")

所以,key 设计成弱引用的目的主要是:

避免 ThreadLocalMap 强行持有已经不用的 ThreadLocal 对象,导致 ThreadLocal 对象无法被回收。


10. key 是弱引用,为什么还会有内存泄漏?

因为 value 仍然是强引用。

GC 之后可能变成这样:

text 复制代码
当前线程 Thread
  └── ThreadLocalMap
        └── Entry
              ├── key = null
              └── value ─强引用─> User("frank")

虽然 ThreadLocal对象 被回收了,但是 User("frank") 还被 Entry 的 value 强引用着。

只要当前线程一直活着,这个 value 就可能一直活着。

在线程池场景下,线程通常不会频繁销毁。

所以就可能出现:

text 复制代码
Tomcat 线程池线程长期存活
  -> Thread 持有 ThreadLocalMap
  -> ThreadLocalMap 持有 Entry
  -> Entry.value 强引用业务对象
  -> 业务对象无法释放

这就是 ThreadLocal 的内存泄漏风险。


11. static ThreadLocal 就不会泄漏吗?

这个说法不严谨。

比如:

java 复制代码
private static final ThreadLocal<User> userHolder = new ThreadLocal<>();

它确实有一个静态变量强引用 ThreadLocal对象

text 复制代码
UserContext.static userHolder ─强引用─> ThreadLocal对象

所以它不容易出现:

text 复制代码
Entry.key = null

这种情况。

但是如果你不调用 remove(),value 仍然会残留在线程里面:

text 复制代码
Tomcat工作线程 Thread
  └── ThreadLocalMap
        └── Entry
              ├── key   ─弱引用─> static ThreadLocal对象
              └── value ─强引用─> 上一次请求的 User对象

请求结束后,如果没有清理:

text 复制代码
ThreadLocalMap 里面还保存着上一次请求的用户信息

下一次这个线程被复用处理别的请求时,就可能读到上一次请求的数据。

这就是脏数据问题。

所以准确说法是:

static ThreadLocal 不太容易出现 key 被 GC 后变成 null 的问题,

但它仍然可能因为线程池线程复用,导致 value 残留和脏数据。

所以用完仍然必须 remove。


12. remove() 到底做了什么?

当你调用:

java 复制代码
userHolder.remove();

本质上就是从当前线程的 ThreadLocalMap 里面删除这个 Entry。

删除前:

text 复制代码
当前线程 Thread
  └── ThreadLocalMap
        └── Entry
              ├── key   ─弱引用─> userHolder
              └── value ─强引用─> User("frank")

删除后:

text 复制代码
当前线程 Thread
  └── ThreadLocalMap
        └── Entry 被清理

这样 User("frank") 就不再被当前线程持有。

如果没有其他引用指向它,它就可以等待 GC 回收。

所以在 Spring Boot 请求中,推荐写法是:

java 复制代码
try {
    RequestContextHolder.set(context);
    // 执行业务逻辑
} finally {
    RequestContextHolder.clear();
}

在拦截器中,则通常放到 afterCompletion() 里面清理:

java 复制代码
@Override
public void afterCompletion(HttpServletRequest request,
                            HttpServletResponse response,
                            Object handler,
                            Exception ex) {
    RequestContextHolder.clear();
}

13. ThreadLocalMap 为什么会有哈希冲突?

很多人会疑惑:

ThreadLocalMap 不是一个 Map 吗?

每个 key、value 不应该都有固定位置吗?

为什么还会哈希冲突?

其实 Map 底层通常是数组。

ThreadLocalMap 底层也是一个数组:

java 复制代码
Entry[] table;

假设数组长度是 16:

text 复制代码
下标: 0   1   2   3   4   5   6   7  ...  15
数组:[ ] [ ] [ ] [ ] [ ] [ ] [ ] [ ] ... [ ]

一个 ThreadLocal 对象要放到哪个位置,需要先算 hash:

java 复制代码
index = threadLocalHashCode & (table.length - 1);

比如:

text 复制代码
userHolder 算出来 index = 4
traceHolder 算出来 index = 4

两个不同的 ThreadLocal 对象,都想放到下标 4,这就是哈希冲突。


14. ThreadLocalMap 如何解决哈希冲突?

普通 HashMap 会用链表或者红黑树处理冲突。

但是 ThreadLocalMap 使用的是 开放寻址法 ,更具体一点是 线性探测法

什么意思?

如果目标位置被占了,就往后找下一个位置。

比如:

text 复制代码
原本想放 index = 4

下标: 0   1   2   3   4       5       6
数组:[ ] [ ] [ ] [ ] [key1]  [key2]  [ ]

现在 key3 也算出来要放下标 4。

但是 4 被占了,就往后找:

text 复制代码
4 被占了
5 被占了
6 空着
放到 6

结果:

text 复制代码
下标: 0   1   2   3   4       5       6
数组:[ ] [ ] [ ] [ ] [key1]  [key2]  [key3]

查找的时候也一样。

如果要找 key3,它本来算出来的位置是 4。

底层会这样找:

text 复制代码
table[4] 不是 key3
table[5] 不是 key3
table[6] 是 key3,找到了

所以,线性探测在冲突比较多时,确实会变慢。

不过在正常业务中,一个线程里通常不会放特别多 ThreadLocal,所以性能一般不是主要问题。

真正需要注意的是:

text 复制代码
线程池场景下,用完必须 remove。

15. ThreadLocal 在 Spring Boot 中的完整生命周期图

一次请求开始:

text 复制代码
HTTP请求进入
    │
    v
Tomcat线程池分配线程
    │
    v
http-nio-8080-exec-1
    │
    v
拦截器 preHandle()
    │
    v
RequestContextHolder.set(context)

此时:

text 复制代码
http-nio-8080-exec-1
  └── ThreadLocalMap
        └── Entry
              ├── key   ─弱引用─> RequestContextHolder.HOLDER
              └── value ─强引用─> RequestContext(username, traceId)

业务代码中:

java 复制代码
RequestContextHolder.getUsername();

底层是:

text 复制代码
当前线程.threadLocalMap.get(HOLDER)

请求结束:

text 复制代码
Controller 执行完成
    │
    v
拦截器 afterCompletion()
    │
    v
RequestContextHolder.clear()
    │
    v
ThreadLocalMap 中对应 Entry 被清理

清理后:

text 复制代码
http-nio-8080-exec-1
  └── ThreadLocalMap
        └── 不再保存本次请求的 RequestContext

线程回到线程池,等待处理下一次请求。


16. 总结

16.1 Java 引用关系

java 复制代码
User a = new User();

含义是:

text 复制代码
局部变量 a 强引用堆中的 User对象

方法结束后:

text 复制代码
a 消失
如果 User对象没有其他引用,就等待 GC 回收

16.2 ThreadLocal 的真实模型

不要理解成:

text 复制代码
ThreadLocal 保存了所有线程的值

而要理解成:

text 复制代码
每个 Thread 里面有自己的 ThreadLocalMap
ThreadLocal 对象只是这个 Map 的 key
value 存在当前线程自己的 Map 里

16.3 ThreadLocalMap 的引用关系

text 复制代码
Thread
  └── ThreadLocalMap
        └── Entry
              ├── key   ─弱引用─> ThreadLocal对象
              └── value ─强引用─> 业务对象

16.4 key 为什么是弱引用?

为了避免:

text 复制代码
Thread -> ThreadLocalMap -> Entry.key -> ThreadLocal对象

这条强引用链导致已经不用的 ThreadLocal对象 无法被回收。


16.5 为什么还要 remove?

因为 value 是强引用。

如果不清理:

text 复制代码
Thread -> ThreadLocalMap -> Entry.value -> 业务对象

这条引用链仍然可能让业务对象长期存活。

尤其在 Spring Boot / Tomcat 线程池场景下,线程会被复用,不清理还可能造成脏数据。


17. 最后记住一句话

ThreadLocal 是 key,不是仓库;

Thread 才是仓库;

ThreadLocalMap 是仓库里的货架;

Entry.key 弱引用 ThreadLocal

Entry.value 强引用业务数据;

Spring Boot 线程池会复用线程,所以请求结束一定要 remove()

相关推荐
故渊at1 小时前
第五板块:Android 系统服务与电源管理 | 第十八篇:Battery Service 与 电量统计(Fuel Gauge)算法
android·算法·battery·电源·电池·电源管理·电量统计
Dxy12393102161 小时前
Python 请求:为什么 Session 比直接请求快 10 倍?
开发语言·python
The_Ticker1 小时前
港股量化实测:实时行情接口性能与数据质量深度解析
python·websocket·算法·金融
weisian1511 小时前
基础篇--概念原理-25-大模型的剪枝是什么?怎么理解?——从原理到实战,一篇讲透
算法·机器学习·大模型·剪枝
Jabes.yang1 小时前
互联网大厂Java求职面试实战解析(含技术场景与详解)
spring boot·微服务·面试·orm·技术栈·java se·jakarta ee
fie88891 小时前
基于有限体积法(FVM)的MATLAB流体力学求解程序
算法·matlab
装不满的克莱因瓶4 小时前
链式法则如何传递参数误差 —— 深入理解神经网络中的梯度传播
人工智能·python·深度学习·神经网络·数学·机器学习·ai
Anastasiozzzz4 小时前
从有限状态机到智能体图:传统 FSM 与 Agent Graph的演进
java·人工智能·python·ai
xujinwei_gingko10 小时前
SpringBoot整合WebSocket
spring boot·后端·websocket