主线程存了用户信息,子线程居然拿不到?ThreadLocal 背锅

前言

在日常的工作中,你有用过ThreadLocal或者InheritableThreadLocal吗? 相信有很多经常写的朋友,也有很多没写过这玩意的朋友,但面试偶尔还是会碰到这种情况的。 那ThreadLocal是啥?InheritableThreadLocal又是啥?

简单来说,InheritableThreadLocal 是 ThreadLocal 的升级版。它解决了 ThreadLocal 的一个痛点:父线程创建的变量无法传递给子线程。

别小看它。平时不显山不露水。一旦你在做用户上下文传递、链路追踪、日志埋点这些类似功能时,它就变得很关键。

尤其是你用了多线程、线程池、异步任务......

搞不好,上下文就丢了。

感兴趣的朋友可以往下继续了解。


一、ThreadLocal 是啥?

简单说:

ThreadLocal 是一个能让每个线程拥有自己独立变量副本的工具。

什么意思?比如你有一个全局变量:

java 复制代码
private static String user = "张三";

问题来了:

多线程下,大家都能改这个user

线程A改成"李四",线程B又改成"王五"。

直接乱套了!

怎么解决?这时候就要用到ThreadLocal了:

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

现在:

  • 线程A 设置:userHolder.set("张三") → 它拿到的就是"张三"
  • 线程B 设置:userHolder.set("李四") → 它拿到的就是"李四"

互不干扰,就像每人发了个独立的小本本,写啥都只自己看得见。


二、为什么要用 ThreadLocal?

常见用途有这几个:

1. 用户上下文保存

用户登录后,把当前用户的userIduserName等身份信息存进ThreadLocal。这样在后续的Controller、Service、DAO层任何地方,都能直接获取用户信息,不需要在每个方法参数里传来传去。

java 复制代码
// 登录时存储
User user = getUserFromToken(token);
UserContext.setCurrentUser(user);

// 业务方法中直接使用
public void doBusiness() {
    User currentUser = UserContext.getCurrentUser(); // 无需参数传递
    System.out.println("当前用户: " + currentUser.getName());
}

2. 数据库事务管理

Spring 的事务管理就是基于ThreadLocal实现的。它把数据库连接绑定到当前线程,确保同一个事务中的所有数据库操作都用同一个连接。

java 复制代码
// Spring事务管理的简化原理
public class TransactionManager {
    private static ThreadLocal<Connection> connectionHolder = new ThreadLocal<>();
    
    public static Connection getConnection() {
        Connection conn = connectionHolder.get();
        if (conn == null) {
            conn = dataSource.getConnection();
            connectionHolder.set(conn);
        }
        return conn;
    }
}

3. 代码简化

避免了在方法调用链中层层传递公共参数(traceId、userId、tenantId等),让代码更简洁清晰。

java 复制代码
// 没有ThreadLocal时:参数需要层层传递
controller.method(traceId, userId);
service.method(traceId, userId); 
dao.method(traceId, userId);

// 有ThreadLocal时:参数只需设置一次
TraceContext.setTraceId(traceId);
UserContext.setUserId(userId);

controller.method();
service.method();  // 直接从这里获取
dao.method();      // 直接从这里获取

4. 链路追踪

在微服务架构中,ThreadLocal用于传递traceId,实现请求链路的追踪。

java 复制代码
// 在过滤器或拦截器中
String traceId = generateTraceId();
TraceContext.setTraceId(traceId);

// 后续所有日志都能带上这个traceId
log.info("[{}] 开始处理请求", TraceContext.getTraceId());

三、ThreadLocal 是怎么实现的?

ThreadLocal的实现很巧妙,它在线程对象内部维护了一个类似HashMap的结构:

java 复制代码
// Thread类中有这两个重要属性
ThreadLocal.ThreadLocalMap threadLocals = null;
ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;

// ThreadLocal的set方法源码
public void set(T value) {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = t.threadLocals;
    if (map != null) {
        map.set(this, value);
    } else {
        createMap(t, value);
    }
}

当你调用:

java 复制代码
threadLocal.set("abc");

JVM 会:

  1. 找到当前线程(Thread.currentThread()
  2. 拿到它的 threadLocals map
  3. threadLocal 当 key,"abc" 当 value,存进去

取值时也一样:

java 复制代码
threadLocal.get();

找当前线程 → 找它的map → 拿threadLocal对应的值。

所以,每个线程的数据是隔离的。 A线程存的,B线程看不到。


四、ThreadLocal 可能出现哪些问题?

问题1:内存泄漏!

案例

java 复制代码
public class MemoryLeakExample {
    private static ThreadLocal<byte[]> localVariable = new ThreadLocal<>();
    
    public static void main(String[] args) {
        new Thread(() -> {
            localVariable.set(new byte[1024 * 1024 * 10]); // 10MB
            // 使用完后没有remove
            // 线程结束后,value仍然被ThreadLocalMap引用,无法被GC回收
        }).start();
    }
}

原因: ThreadLocalMap的key是弱引用,但value是强引用。如果ThreadLocal实例被回收,key变成null,但value仍然存在,导致内存泄漏。

所以每次用完,记得调 remove()

java 复制代码
try {
    threadLocal.set("xxx");
    // 业务逻辑
} finally {
    threadLocal.remove(); // 必须!
}

问题2:子线程拿不到父线程的值!

案例:

java 复制代码
public class ThreadLocalCase {
    private static ThreadLocal<String> tl = new ThreadLocal<>();

    public static void main(String[] args) {
        tl.set("主线程的值");

        new Thread(() -> {
            System.out.println("子线程:" + tl.get());
        }).start();

        System.out.println("主线程:" + tl.get());
    }
}

输出:

csharp 复制代码
主线程:主线程的值
子线程:null

啥?子线程拿不到?! 对!因为ThreadLocal只在当前线程有效。子线程创建时,它的threadLocals是空的,啥都没继承,这就麻烦了。

比如你主线程存了用户ID。子线程要记录日志、发消息。

结果拿不到用户ID,日志都打不出来。

那怎么办?这时候就要用InheritableThreadLocal了。

使用的时候改一下代码:

java 复制代码
private static InheritableThreadLocal<String> itl = new InheritableThreadLocal<>();

其他不变。输出变成:

复制代码
主线程:主线程的值
子线程:主线程的值

五、InheritableThreadLocal 是怎么做到的?

核心源码分析

InheritableThreadLocal不是凭空来的。它是继承的ThreadLocal

java 复制代码
public class InheritableThreadLocal<T> extends ThreadLocal<T> {
    // ...
}

但它做了三件关键的事:

  1. 重写了 childValue() 方法
  2. 重写了 getMap() 方法
  3. 重写了 createMap() 方法

我们一个一个看。

1. 关键方法一:childValue?

java 复制代码
protected T childValue(T parentValue) {
    return parentValue;
}

这是最核心的方法!它决定:子线程拿到的值,是怎么算出来的。默认就是直接返回父线程的值。也就是原样继承。

但你可以重写它!比如你想让子线程的值加个前缀:

java 复制代码
InheritableThreadLocal<String> itl = new InheritableThreadLocal<String>() {
    @Override
    protected String childValue(String parentValue) {
        return "[子线程]" + parentValue;
    }
};

这样,子线程拿到的就是 "子线程]主线程值"

2. 关键方法二和三:getMap 和 createMap

ThreadLocalInheritableThreadLocal用的不是同一个存储区。

  • ThreadLocal用的是:threadLocals
  • InheritableThreadLocal 用的是:inheritableThreadLocals

怎么做到的?靠这两个方法:

java 复制代码
// 返回 inheritableThreadLocals,不是 threadLocals
ThreadLocalMap getMap(Thread t) {
    return t.inheritableThreadLocals;
}

// 创建 inheritableThreadLocals
void createMap(Thread t, T firstValue) {
    t.inheritableThreadLocals = new ThreadLocalMap(this, firstValue);
}

InheritableThreadLocal把数据存在另一个抽屉里。这个抽屉专门用来传给子线程。

3. Thread 初始化时的拷贝

前面都是准备。真正的继承,发生在子线程创建时

我们看Threadinit()方法(简化版):

java 复制代码
private void init(...) {
    Thread parent = currentThread(); // 找到父线程

    // 如果父线程有 inheritableThreadLocals
    if (inheritThreadLocals && parent.inheritableThreadLocals != null) {
        // 就创建一个继承的 map
        this.inheritableThreadLocals = 
            ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);
    }
}

子线程一创建,JVM 就检查:"父线程有没有可继承的数据?"。 如果有,就调用createInheritedMap,把数据拷贝过来。

4. createInheritedMap:怎么拷贝的?

这个方法在 ThreadLocal 类里:

java 复制代码
static ThreadLocalMap createInheritedMap(ThreadLocalMap parentMap) {
    return new ThreadLocalMap(parentMap);
}

它调用了 ThreadLocalMap 的一个特殊构造函数:

java 复制代码
private ThreadLocalMap(ThreadLocalMap parentMap) {
    Entry[] parentTable = parentMap.table;
    int len = parentTable.length;
    setThreshold(len);
    table = new Entry[len]; // 新建一个 table

    for (int j = 0; j < len; j++) {
        Entry e = parentTable[j];
        if (e != null) {
            ThreadLocal<Object> key = (ThreadLocal<Object>) e.get();
            if (key != null) {
                // 调用 childValue 计算子线程的值
                Object value = key.childValue(e.value);
                Entry c = new Entry(key, value);
                int h = key.threadLocalHashCode & (len - 1);
                while (table[h] != null)
                    h = nextIndex(h, len);
                table[h] = c;
                size++;
            }
        }
    }
}

关键点:

  • 遍历父线程的 table
  • 拿到每个 Entry
  • 调用 key.childValue(e.value) 计算子线程的值
  • 放到子线程的新 table

六. InheritableThreadLocal 在线程池中的问题

来看这个例子:

java 复制代码
ExecutorService executor = Executors.newFixedThreadPool(1);

InheritableThreadLocal<String> context = new InheritableThreadLocal<>();

// 第一次提交任务
context.set("task1");
executor.submit(() -> {
    System.out.println(context.get()); // 输出: task1
});

// 第二次提交任务
context.set("task2");
executor.submit(() -> {
    System.out.println(context.get()); // 输出啥?
});

你以为输出 task2?但也有可能还是 task1!为什么?

因为线程池里的线程是复用的 。第一次任务执行完,线程没销毁。inheritableThreadLocals里的值还在。

第二次任务来了,线程还是那个线程。InheritableThreadLocal的值没变。所以输出的还是 task1


总结

  • ThreadLocal:线程私有变量,隔离数据。
  • ThreadLocal有内存泄漏风险,记得remove()
  • ThreadLocal不支持子线程继承。
  • InheritableThreadLocal:解决继承问题,子线程创建时自动拷贝。但它在线程池中会污染上下文。
  • 线程池推荐使用 TransmittableThreadLocal(TTL)。

InheritableThreadLocal 很有用。但只适合"每次创建新线程"的场景。

如果你用线程池,这里推荐另外一个,那就是TransmittableThreadLocal(TTL)。用法几乎一样,它能解决线程复用下的传递问题。

公众号:程序员刘大华,专注分享前后端开发的实战笔记。关注我,少走弯路,一起进步!

📌往期精彩

《工作 5 年没碰过分布式锁,是我太菜还是公司太稳?网友:太真实了!》

《90%的人不知道!Spring官方早已不推荐@Autowired?这3种注入方式你用对了吗?》

《写给小公司前端的 UI 规范:别让页面丑得自己都看不下去》

《终于找到 Axios 最优雅的封装方式了,再也不用写重复代码了》

相关推荐
知了一笑2 小时前
「AI」网站模版,效果如何?
前端·后端·产品
小王子4802 小时前
性能优化实践分享
后端
RoyLin2 小时前
TypeScript设计模式:状态模式
前端·后端·typescript
RoyLin2 小时前
TypeScript设计模式:观察者模式
前端·后端·typescript
间彧2 小时前
Spring Boot项目中,Redis 如何同时执行多条命令
java·redis
RoyLin2 小时前
TypeScript设计模式:备忘录模式
前端·后端·typescript
白衣鸽子2 小时前
PageHelper:分页陷阱避免与最佳实践
后端
BingoGo2 小时前
PHP 和 Elasticsearch:给你的应用加个强力搜索引擎
后端·php
泉城老铁2 小时前
Spring Boot对接抖音获取H5直播链接详细指南
spring boot·后端·架构