从ThreadLocal到TransmittableThreadLocal,彻底学透ThreadLocal的设计

程序员黑哥 2024-01-24 17:09 发表于湖南

1、从一个案例说起

观察下面的代码请你判断代码的输出:

public class TestCase1 {
    private static ThreadLocal<String> threadLocal = new ThreadLocal<>();
    public static void main(String[] args) {        case1();    }
    public static void case1() {
        ExecutorService executorService = Executors.newFixedThreadPool(1);        threadLocal.set("Hello");
        Runnable task1 = new Runnable() {            @Override            public void run() {                System.out.println("ThreadLocal value in task1: " + threadLocal.get());                threadLocal.set("Task1");            }        };
        Runnable task2 = new Runnable() {            @Override            public void run() {                System.out.println("ThreadLocal value in task2: " + threadLocal.get());                threadLocal.set("Task2");            }        };
        executorService.submit(task1);        sleep(100);        executorService.submit(task2);        sleep(100);
        System.out.println("ThreadLocal value in mainThread: " + threadLocal.get());
        executorService.shutdown();    }
    public static void sleep(int val){        try {            Thread.sleep(val);        } catch (InterruptedException ignored) {        }    }
}

分析这段代码的输出并不难,实际输出如下:

ThreadLocal value in task1: null
ThreadLocal value in task2: Task1
ThreadLocal value in mainThread: Hello

   因为我们线程池中只有一个线程,当第一个任务执行完成之后,这个线程池的线程的ThreadLocal便设置上了Task1,之后第二个任务执行时获取到的ThreadLocal中的值便是Task1,但是主线程和子线程是不同的线程,所以无论子线程如何修改ThreadLocal的内容对主线程都是无影响的。

   但是接下来本文想表达的不是这个最基本的ThreadLocal的用途(线程局部变量),而是想分析下述几个问题:

(1)ThreadLocal如何实现从主线程传递到子线程中的需求,从上文的代码来看如果是传递的TraceId到了线程池中将直接丢失这个信息,如何实现丢任务到线程池的线程和执行任务的线程实现某种意义上的数据传递?
(2)由于线程复用,Task2受到了第一个任务设置的ThreadLocal的值“污染”,线程池的环境下使用ThreadLocal如何避免这个问题?
(3)ThreadLocal面试常考的内存泄露的真实原因到底是什么?

   下面本文将会仔细分析上面三个问题,以求对ThreadLocal这个工具做了详细了解

2、ThreadLocal、Thread、ThreadLocalMap三者之间的关系究竟如何?

本文基于Java8的源码做技术分析。(1)Thread类中包含两个ThreadLocal.ThreadLocalMap成员变量,一个名为threadLocals一个名为inheritableThreadLocals
threadLocals:作用是承载当前线程的ThreadLocal的值
inheritableThreadLocals:Java 8 中,Thread 类中的 inheritableThreadLocals 字段用于表示线程本地变量的继承链。当创建新线程时,它们会从创建它们的线程那里继承线程本地变量(但是这个成员变量已经在java9中废弃)

(2)ThreadLocalMap则可以理解为就是一种映射结构:key是ThreadLocal, value即是我们存放在线程上下文中的值,但是需要注意的是这里的key明确标注为弱引用

static class Entry extends WeakReference<ThreadLocal<?>> {    /** The value associated with this ThreadLocal. */    Object value;
    Entry(ThreadLocal<?> k, Object v) {        super(k);        value = v;    }}

我们一般使用ThreadLocal的时候,会直接调用set和get方法,则实际上就是获取下当前线程(Thread)下的成员变量ThreadLocalMap,然后把当前的ThreadLocal对象作为key取到对应的值,或者设置对应的值。

public T get() {    Thread t = Thread.currentThread();    // 获取当前线程的 ThreadLocalMap    ThreadLocalMap map = getMap(t);    if (map != null) {        // 把this作为key,来取对应的value业务参数        ThreadLocalMap.Entry e = map.getEntry(this);        if (e != null) {            @SuppressWarnings("unchecked")            T result = (T)e.value;            return result;        }    }    return setInitialValue();}

3、ThreadLocal的内存泄露的原因到底是什么?

前文分析,ThreadLocalMap的key是ThreadLocal类型并且是弱引用的 但是value不是,那假如key被回收了 value没有,不就是出现内存泄漏了吗,即整个结构变成下面这种:

图片

这时候entry继续保留了对value的引用,此时确实是出现了内存泄漏,存在一块我们无法访问的变量但是却也无法回收的情况,但是事实真的如此吗  我们先看下源码是否存在这个问题。

// ThreadLocal的get方法调用会走到这个方法private Entry getEntry(ThreadLocal<?> key) {    int i = key.threadLocalHashCode & (table.length - 1);    Entry e = table[i];    if (e != null && e.get() == key)        return e;    else        // 关注点1:        return getEntryAfterMiss(key, i, e);}

private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {    Entry[] tab = table;    int len = tab.length;
    while (e != null) {        ThreadLocal<?> k = e.get();        if (k == key)            return e;        if (k == null)            // 关注点2:            expungeStaleEntry(i);        else            i = nextIndex(i, len);        e = tab[i];    }    return null;}

 从上面的源码看在关注点2的位置,处理了key为null的场景,这个expungeStaleEntry源码中则是清理了所有key为null的键值对,把value也改为null。所以其实ThreadLocalMap的key作为弱引用并不会导致内存泄漏 也就是说ThreadLocal本身并不会导致内存泄漏
但是为什么网上很多人说ThreadLocal仍然有内存泄漏风险呢?其实这个原因还是与线程池有关系,也就是说我们线程池里面的线程是可以复用的,但是如果线程池中的一个线程执行了一个任务这个任务在ThreadLocal里面塞了一个值,但是这个值后续永远不会被用到,也就是没用调用remove方法这时候就会出现内存泄漏,但是在笔者看来这不是源码或者线程池的问题,实际上是一种编码行为的不规范,属于人为因素。

4、线程池场景下ThreadLocal的值传递问题

   ThreadLocal的值传递需求,往往发生在需要标识一条链路的情景下,例如我们查找日志时,往往携带一个TraceId去查找当时的这次请求链路下的所有日志,但是如果我们的业务代码中使用了线程池,如果不做处理你会发现这个线程池中执行任务的线程打印的日志的TraceId和我们搜索的请求的TraceId并不相同,所以为了定位问题方便我们往往需要保证一个请求的TraceId在异步任务中继续保持一致性,这就涉及了ThreadLocal的值传递。

如果让你实现这个需求,你会怎么做?

(1)复写submit或者execute方法
一个比较朴素的想法是,在线程池执行任务的时候,把需要传递的值注入进去,因为投放任务的时候是“主线程”做的事情,执行任务是子线程执行的。所以可以这样简单实现:

public class TraceIdTransmitThreadPool extends ThreadPoolTaskExecutor {
    @Override    public void execute(Runnable task) {        String traceId = getTraceIdFromContext();        super.execute(()->{            ThreadLocalUtils.set(traceId);            try{                task.run();            }finally {                ThreadLocalUtils.clear(traceId);            }        });    }
    private String getTraceIdFromContext() {        return ThreadLocalUtils.get();    }}

(2)利用InheritableThreadLocals
在本文的第二部分提到了Thread类中除了threadLocals还有个inheritableThreadLocals 这个ThreadLocalMap的局部变量,这个东西实际作用是什么呢?实际作用是在子线程创建的时候,父线程会把threadLocal拷贝到子线程中。下面我们用一个例子来解释下这个东西的作用

ThreadLocal<String> local = new InheritableThreadLocal<>();//ThreadLocal<String> local = new ThreadLocal<>();local.set("hello");new Thread(() -> {    // 仅使用ThreadLocal 这里将取到NUll值    // 使用InheritableThreadLocal 这里将取到主线程设置的线程局部变量    System.out.println("子线程:" + local.get());}).start();
sleep(1000)

上面的代码输出为

子线程:null

可以看出来确实主线程中设置的值被带进到子线程中了。下面简单分析下原理,翻开new Thread的构造方法源码时我们会找到下面这行代码:

if (inheritThreadLocals && parent.inheritableThreadLocals != null)    this.inheritableThreadLocals =        ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);

然后在InheritableThreadLocal类的实现源码中发现其最主要的就是复写了getMap的实现

ThreadLocalMap getMap(Thread t) {   return t.inheritableThreadLocals;}

所以能进行值传递的原因很简单,就是会把父进程的inheritableThreadLocals 进行值拷贝,然后get/set方法在取值的时候不再从Thread类的threadLocals中取值,而不是从inheritableThreadLocals取, 但是我们线程池这种环境下面核心线程一般不会频繁的反复销毁重新创建,所以这种方案其实并不适合线程池的环境,此外可能是jdk官方也觉得这种方式设计的不好在jdk9之后就直接拿掉了这个inheritableThreadLocals局部变量

(3)利用TransmittableThreadLocal
TransmittableThreadLocal是一个开源项目

在本文开头的时候举了一个例子,现在我们将ThreadLocal更改为TransmittbaleThreadLocal,就可以直接体会到两者的区别,代码如下:

public static void case1() {
    //ThreadLocal<String> threadLocal = new ThreadLocal<>();    TransmittableThreadLocal<String> threadLocal = new TransmittableThreadLocal<>();    ExecutorService executorService = Executors.newFixedThreadPool(1);    // 这里是核心    executorService = TtlExecutors.getTtlExecutorService(executorService);
    Runnable task1 = () -> {        System.out.println("ThreadLocal value in task1: " + threadLocal.get());        threadLocal.set("Task1");    };
    Runnable task2 = () -> {        System.out.println("ThreadLocal value in task2: " + threadLocal.get());        threadLocal.set("Task2");    };
    threadLocal.set("Hello");
    executorService.submit(task1);    sleep(100);    executorService.submit(task2);
    System.out.println("ThreadLocal value in mainThread: " + threadLocal.get());
    executorService.shutdown();}

最终代码运行的时候如下:

ThreadLocal value in task1: Hello
ThreadLocal value in mainThread: Hello
ThreadLocal value in task2: Hello

从代码的运行结果可以看出子线程和主线程的线程局部变量的实现了统一,并且很神奇的一点是线程1中执行第一个任务之后对线程局部变量做了修改,丝毫不影响这个线程在执行第二个任务中线程局部变量的值,在执行第二个任务的时候仍然可以取到父线程中的值,这一点请读者对照开头的例子仔细体会。

那么这个究竟是怎么实现的呢?其实主要就是上面的代码中 executorService = TtlExecutors.getTtlExecutorService(executorService); 这行代码进行了装饰作用,感兴趣的读者可以注释掉这行代码,观察代码的运行结果(Task2将无法再取到父线程设置的局部变量)

这里我先引用下官方文档的一张图示:

图片

其实TransmittbaleThreadLocal(简称TTL)的源码设计就是一个装饰者设计模式的典型范例
任务修饰:使用TtlRunnableTtlCallable来修饰传入线程池的RunnableCallable
线程池修饰:使用 getTtlExecutorService来包装和修饰接口ExecutorService

我们先从TtlRunnable类开始进行分析,核心也就是看下run方法怎么实现的(这属于框架的基准内容比较重要)

public void run() {    /**     * capturedRef就是主线程传递下来的ThreadLocal的值。     */    Object captured = capturedRef.get();    if (captured == null || releaseTtlValueReferenceAfterRun && !capturedRef.compareAndSet(captured, null)) {        throw new IllegalStateException("TTL value reference is released after run!");    }    /**     * 1. backup(备份)是子线程已经存在的ThreadLocal变量;     * 这也是做到了上面说的一个线程执行两次任务,从父线程中拿到的局部变量值也不会互相影响的关键     * 2. 将captured的ThreadLocal值在子线程中set进去;     */    Object backup = replay(captured);    try {        // 修饰的目标        runnable.run();    } finally {        /**         *  在子线程任务中,ThreadLocal可能发生变化,该步骤的目的是         *  回滚{@code runnable.run()}进入前的ThreadLocal的线程         */        restore(backup);    }}

从上面的代码来看最重要的就是要知道captured这个变量的值到底是怎么get出来的,首先我们要知道从继承路线来看TransmittableThreadLocal 继承了InheritableThreadLocal所以自然有InheritableThreadLocal的全部能力

图片

captured这个变量实际上是从这个capture方法返回的,这个方法返回的快照然后会被传递到replay方法中进行应用

源码位置:com.alibaba.ttl.TransmittableThreadLocal.Transmitter#capture@NonNullpublic static Object capture() {    return new Snapshot(captureTtlValues(), captureThreadLocalValues());}

这个Snapshot看名字就知道是个快照,这个快照到底怎么实现的呢

// 抓取 TransmittableThreadLocal 的快照private static WeakHashMap<TransmittableThreadLocal<Object>, Object> captureTtlValues() {    WeakHashMap<TransmittableThreadLocal<Object>, Object> ttl2Value = new WeakHashMap<TransmittableThreadLocal<Object>, Object>();    // 从 TransmittableThreadLocal 的 holder 中,遍历所有有值的 TransmittableThreadLocal,将 TransmittableThreadLocal 取出和值复制到 Map 中。    for (TransmittableThreadLocal<Object> threadLocal : holder.get().keySet()) {        ttl2Value.put(threadLocal, threadLocal.copyValue());    }    return ttl2Value;}
//  抓取注册的 ThreadLocal。private static WeakHashMap<ThreadLocal<Object>, Object> captureThreadLocalValues() {    final WeakHashMap<ThreadLocal<Object>, Object> threadLocal2Value = new WeakHashMap<ThreadLocal<Object>, Object>();    // 从 threadLocalHolder 中,遍历注册的 ThreadLocal,将 ThreadLocal 和 TtlCopier 取出,将值复制到 Map 中。    for (Map.Entry<ThreadLocal<Object>, TtlCopier<Object>> entry : threadLocalHolder.entrySet()) {        final ThreadLocal<Object> threadLocal = entry.getKey();        final TtlCopier<Object> copier = entry.getValue();
        threadLocal2Value.put(threadLocal, copier.copy(threadLocal.get()));    }    return threadLocal2Value;}

上面源码的注释中提到了“注册”过程,这个注册行为则发生在TransmittableThreadLocal的get/set方法内部实现中。 现在我们有了快照,但是我们怎么将快照中的数据内容传递到子线程中呢  这就是TtlRunnable类中run方法中调用的replay方法所做的事情了。其实仔细看源码就会知道这个方法的核心目标就是要把快照中的数据给设置到当前线程的上下文中,这样你在子线程中调用get方法才能取到对应的值。

@NonNullpublic static Object replay(@NonNull Object captured) {    final Snapshot capturedSnapshot = (Snapshot) captured;    return new Snapshot(replayTtlValues(capturedSnapshot.ttl2Value), replayThreadLocalValues(capturedSnapshot.threadLocal2Value));}
// 重播 TransmittableThreadLocal,并保存执行线程的原值@NonNullprivate static WeakHashMap<TransmittableThreadLocal<Object>, Object> replayTtlValues(@NonNull WeakHashMap<TransmittableThreadLocal<Object>, Object> captured) {    WeakHashMap<TransmittableThreadLocal<Object>, Object> backup = new WeakHashMap<TransmittableThreadLocal<Object>, Object>();      for (final Iterator<TransmittableThreadLocal<Object>> iterator = holder.get().keySet().iterator(); iterator.hasNext(); ) {        TransmittableThreadLocal<Object> threadLocal = iterator.next();        backup.put(threadLocal, threadLocal.get());        if (!captured.containsKey(threadLocal)) {            iterator.remove();            threadLocal.superRemove();        }    }
    // 关键: 将 map 中的值,设置到 ThreadLocal 中。    setTtlValuesTo(captured);
    // TransmittableThreadLocal 的回调方法,在任务执行前执行。    doExecuteCallback(true);
    return backup;}

所以总结下就是 get/set 方法中完成了TransmittableThreadLocal的注册,然后在执行run方法的时候通过TtlRunnable进行了方法包装,在调用之前进行快照形成,并应用快照到当前线程中,最后在线程执行结束之后,run方法内部对线程局部变量做的修改则会被还原,这也是本节举例中最后三次打印都是一个结果的主要原因

所以TransmittableThreadLocal 就比较适合在多线程环境下作为线程局部变量进行类似traceId这样的参数的传参,此外TransmittableThreadLocal 还支持javaAgent方式启动,这样就不需要在代码中显式的去包装线程池了。

java -javaagent:path/to/transmittable-thread-local-2.x.y.jar \    -cp classes \    com.alibaba.demo.ttl.agent.AgentDemo

5、总结

本文主要介绍了ThreadLocal这个常用的类的相关知识点进行了介绍,并着重介绍了TransmittableThreadLocal的设计思路,希望对大家有所帮助

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值