调用链跨线程传递 ThreadLocal 对象对比

简介: 说起本地线程专属变量,大家首先会想到的是 JDK 默认提供的 ThreadLocal,用来存储在整个链路中都需要访问的数据,并且是线程安全的。由于在落地全链路压测的过程中,一个基本并核心的功能需求是流量标记需要在整个链路中进行传递,那么线程上下文环境成为解决这个问题最合适的技术。

一、前言

说起本地线程专属变量,大家首先会想到的是 JDK 默认提供的 ThreadLocal,用来存储在整个链路中都需要访问的数据,并且是线程安全的。由于在落地全链路压测的过程中,一个基本并核心的功能需求是流量标记需要在整个链路中进行传递,那么线程上下文环境成为解决这个问题最合适的技术

二、ThreadLocal

1、ThreadLocal 关键 API

ThreadLocal 对外提供的关键 API 如下:

//从线程上下文中获取值
public T get() ;

//将值设入线程上下文中,供同一线程后续使用
public void set(T value) ;

//清除线程上下文
public void remove() ;

2、ThreadLocal 存储结构

上述 API 使用简单,关键是要理解 ThreadLocal 的内部存储结构:

image.png

ThreadLocal 的存储结构是这样的:

  • 每个 Thread 维护一个 ThreadLocalMap 映射表,这个映射表的 key 是 ThreadLocal 实例本身,value 是真正需要存储的 Object。
  • 也就是说 ThreadLocal 本身并不存储值,它只是作为一个 key 来让线程从 ThreadLocalMap 获取 value
  • ThreadLocal 能在每个线程间进行隔离,其主要是靠在每个 Thread 对象中维护一个 ThreadLocalMap 来实现的。

3、ThreadLocal 局限性

ThreadLocal 无法在父子线程之间传递,示例代码如下:

public class ThreadLocalDemo {
   
    private static final ThreadLocal<Integer> requestIdThreadLocal = new ThreadLocal<>();
    public static void main(String[] args) {
   
        Integer reqId = new Integer(5);
        ThreadLocalDemo threadLocalExample = new ThreadLocalDemo();
        threadLocalExample.setRequestId(reqId);
    }

    public void setRequestId(Integer requestId) {
   
        requestIdThreadLocal.set(requestId);
        doBussiness();
    }

    public void doBussiness() {
   
        System.out.println("首先打印requestId:" + requestIdThreadLocal.get());
        (new Thread(new Runnable() {
   
            @Override
            public void run() {
   
                System.out.println("子线程启动");
                System.out.println("在子线程中访问requestId:" + requestIdThreadLocal.get());
            }
        })).start();
    }
}

在 doBusiness 方法又启动了一个子线程来执行业务(模拟异步处理)

运行结果如下:

首先打印requestId:5
子线程启动
在子线程中访问requestId:null

从结果上来看,在子线程中无法访问在父线程中设置的本地线程变量,即子线程中无法获取到 ThreadLocal 中的 value,从上面的存储原理分析中,已经很明白了,子线程拥有自己的 ThreadLocalMap,自然无法获取父线程ThreadLocalMap 中的值。

但往往很多操作是需要异步操作的,因此父子线程直接共享 ThreadLocal 中的值是有必要的,那我们该如何来解决该问题呢?

为了解决该问题,JDK 引入了另外一个线程本地变量实现类 InheritableThreadLocal,下面介绍以下InheritableThreadLocal,看下它是如何实现父子线程之间共享线程上下文的?

三、InheritableThreadLocal

由于 ThreadLocal 在父子线程交互中子线程无法访问到存储在父线程中的值,无法满足某些场景的需求,例如链路跟踪,例如如下场景:

image.png

为了解决上述问题,JDK 引入了 InheritableThreadLocal,即子线程可以访问父线程中的线程本地变量,更严谨的说法是子线程可以访问在创建子线程时父线程当时的本地线程变量,因为其实现原理就是在创建子线程将父线程当前存在的本地线程变量拷贝到子线程的本地线程变量中
ThreadLocal 的拷贝发生在:当前线程生成子线程实例的时候。如果当前线程的 inheritableThreadLocals 属性不为空,就会把该属性拷贝到子线程的 inheritableThreadLocals 属性中。

Thread 的 init 相关逻辑如下:

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

赋值拷贝代码如下:

 private ThreadLocalMap(ThreadLocalMap parentMap) {
   
            Entry[] parentTable = parentMap.table;
            int len = parentTable.length;
            setThreshold(len);
            table = new Entry[len];

            for (int j = 0; j < len; j++) {
   
                Entry e = parentTable[j];
                if (e != null) {
   
                    @SuppressWarnings("unchecked")
                    ThreadLocal<Object> key = (ThreadLocal<Object>) e.get();
                    if (key != null) {
   
                        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++;
                    }
                }
            }
        }

类似于 Map 的复制,只不过其在 Hash 冲突时,不是使用链表结构,而是直接在数组中找下一个为 null 的槽位。

温馨提示:
子线程默认拷贝父线程的方式是浅拷贝,如果需要使用深拷贝,需要使用自定义 ThreadLocal,继承 InheritableThreadLocal 并重写 childValue 方法。

1、InheritableThreadLocal 的特性

示例代码如下:

public class InheritableThreadLocalDemo {
   
    private static final InheritableThreadLocal<Integer> requestIdThreadLocal = new InheritableThreadLocal<>();
    public static void main(String[] args) {
   
        Integer reqId = new Integer(5);
        InheritableThreadLocalDemo threadLocalExample = new InheritableThreadLocalDemo();
        threadLocalExample.setRequestId(reqId);
    }

    public void setRequestId(Integer requestId) {
   
        requestIdThreadLocal.set(requestId);
        doBussiness();
    }

    public void doBussiness() {
   
        System.out.println("首先打印requestId:" + requestIdThreadLocal.get());
        (new Thread(new Runnable() {
   
            @Override
            public void run() {
   
                System.out.println("子线程启动");
                System.out.println("在子线程中访问requestId:" + requestIdThreadLocal.get());
            }
        })).start();
    }
}

运行结果如下:

首先打印requestId:5
子线程启动
在子线程中访问requestId:5

符合预期,在子线程中如愿访问到了在主线程中设置的本地环境变量。

2、InheritableThreadLocal 局限性

InheritableThreadLocal 的核心思想即:让我们可以在父线程创建子线程的时候将 ThreadLocal 中的值传递给子线程。 在大部分场景下,业务应用不可能每一个异步请求都 new 一个单独的子线程来处理(内存会被撑爆),因此需要使用到线程池,线程池中即存在线程复用的情况,假设线程池中后面创建的线程中的上下文数据否都来自线程池中被复用的线程,这就出现父子线程的上下文变量复制混乱的情况

示例代码如下:

public class InheritableThreadLocalWeaknessDemo {
   

    private static final InheritableThreadLocal<Integer> INHERITABLE_THREAD_LOCAL = new InheritableThreadLocal<>();
    //模拟业务线程池
    private static final ExecutorService threadPool = Executors.newFixedThreadPool(5);

    public static void main(String[] args) throws InterruptedException {
   
        //模拟同时10个web请求,一个请求一个线程
        for (int i = 0; i < 10; i++) {
   
            new TomcatThread(i).start();
        }

        Thread.sleep(3000);
        threadPool.shutdown();
    }

    static class TomcatThread extends Thread{
   
        //线程下标
        int index;

        public TomcatThread(int index) {
   
            this.index = index;
        }

        @Override
        public void run() {
   
            String parentThreadName = Thread.currentThread().getName();
            //父线程中将index值塞入线程上下文变量
            System.out.println( parentThreadName+ ":" + index);
            INHERITABLE_THREAD_LOCAL.set(index);

            threadPool.submit(new BusinessThread(parentThreadName));
        }
    }

    static class BusinessThread implements Runnable{
   
        //父进程名称
        private String parentThreadName;

        public BusinessThread(String parentThreadName) {
   
            this.parentThreadName = parentThreadName;
        }

        @Override
        public void run() {
   
            System.out.println("parent:"+parentThreadName+":"+INHERITABLE_THREAD_LOCAL.get());
        }
    }
}

代码模拟了同时有 10 个 web 请求(启动10个线程),每个线程内部又向业务线程池中提交一个异步任务。

执行结果如所示:

Thread-0:0
Thread-3:3
Thread-2:2
Thread-1:1
Thread-4:4
Thread-5:5
Thread-6:6
Thread-7:7
Thread-8:8
Thread-9:9
parent:Thread-6:6
parent:Thread-0:0
parent:Thread-4:4
parent:Thread-7:0
parent:Thread-3:6
parent:Thread-8:8
parent:Thread-9:4
parent:Thread-1:6
parent:Thread-2:2
parent:Thread-5:0

从这里可以看出,子线程中输出的父线程名称与下标 index 无法一一对应,在子线程中出现出现了线程本地变量混乱的现象,在链路跟踪与全链路压测出现这种情况是致命的

怎么解决这个问题呢?
TransmittableThreadLocal ”闪亮登场“。

四、TransmittableThreadLocal

TransmittableThreadLocal 是阿里开源的库,继承了 InheritableThreadLocal,优化了在使用线程池等会池化复用线程的情况下传递 ThreadLocal 的使用。

官网地址:https://github.com/alibaba/transmittable-thread-local

实践是检验整理的唯一标准,我们还是以上面的示例来进行验证,看看 TransmittableThreadLocal 是否支持上述场景:

首先引包:

<dependency>
     <groupId>com.alibaba</groupId>
     <artifactId>transmittable-thread-local</artifactId>
     <version>2.12.0</version>
 </dependency>

示例代码如下:

public class TransmittableThreadLocalDemo {
   

    private static final TransmittableThreadLocal<Integer> INHERITABLE_THREAD_LOCAL = new TransmittableThreadLocal<>();
    //模拟业务线程池
    private static final ExecutorService threadPool = Executors.newFixedThreadPool(5);

    public static void main(String[] args) throws InterruptedException {
   
        //模拟同时10个web请求,一个请求一个线程
        for (int i = 0; i < 10; i++) {
   
            new TomcatThread(i).start();
        }

        Thread.sleep(3000);
        threadPool.shutdown();
    }

    static class TomcatThread extends Thread{
   
        //线程下标
        int index;

        public TomcatThread(int index) {
   
            this.index = index;
        }

        @Override
        public void run() {
   
            String parentThreadName = Thread.currentThread().getName();
            //父线程中将index值塞入线程上下文变量
            System.out.println( parentThreadName+ ":" + index);
            INHERITABLE_THREAD_LOCAL.set(index);

            threadPool.submit(TtlRunnable.get(new BusinessThread(parentThreadName)));
        }
    }

    static class BusinessThread implements Runnable{
   
        //父进程名称
        private String parentThreadName;

        public BusinessThread(String parentThreadName) {
   
            this.parentThreadName = parentThreadName;
        }

        @Override
        public void run() {
   
            System.out.println("parent:"+parentThreadName+":"+INHERITABLE_THREAD_LOCAL.get());
        }
    }
}

运行结果如下:

Thread-0:0
Thread-3:3
Thread-2:2
Thread-1:1
Thread-4:4
Thread-5:5
Thread-6:6
Thread-7:7
Thread-8:8
Thread-9:9
parent:Thread-6:6
parent:Thread-0:0
parent:Thread-4:4
parent:Thread-7:0
parent:Thread-3:6
parent:Thread-8:8
parent:Thread-9:4
parent:Thread-1:6
parent:Thread-2:2
parent:Thread-5:0

我们可以看到,子线程中输出内容与父线程一致,没有出现线程上下文变量复制混乱的情况

1、TransmittableThreadLocal 实现原理

JDK 的I nheritableThreadLocal 类可以完成父线程到子线程的值传递。但对于使用线程池等会池化复用线程的组件的情况,线程由线程池创建好,并且线程是池化起来反复使用的;这时父子线程关系的ThreadLocal值传递已经没有意义,应用需要的实际上是把 任务提交给线程池时的 ThreadLocal 值传递到 任务执行时。

TransmittableThreadLocal 类继承并加强 InheritableThreadLocal 类,解决上述的问题。

相比 InheritableThreadLocal,添加了:

  • protected 方法 copy
    用于定制 任务提交给线程池 的 ThreadLocal 值传递到任务执行时的拷贝行为,缺省传递的是引用。
  • protected 方法 beforeExecute/afterExecute
    执行任务(Runnable/Callable`)的前/后的生命周期回调,缺省是空操作。

简单来说,有个专门的 TtlRunnable 和 TtlCallable 包装类,用于读取原 Thread 的 ThreadLocal 对象及值并存于Runnable/Callable 中,在执行 run 或者 call 方法的时候再将存于 Runnable/Callable 中的 ThreadLocal 对象和值读取出来,存入调用 run 或者 call 的线程中。

以 TtlRunnable 为例,构造函数如下:

private final AtomicReference<Object> capturedRef;
private final Runnable runnable;
private final boolean releaseTtlValueReferenceAfterRun;

private TtlRunnable(Runnable runnable, boolean releaseTtlValueReferenceAfterRun) {
   
    //从父类capture复制到本类
    this.capturedRef = new AtomicReference<>(capture());
    this.runnable = runnable; //提交的runnable对象
    this.releaseTtlValueReferenceAfterRun = releaseTtlValueReferenceAfterRun;
}

capture 函数的复制过程如下:

@Nonnull
        public static Object capture() {
   
            Map<TransmittableThreadLocal<?>, Object> captured = new HashMap<TransmittableThreadLocal<?>, Object>();
            for (TransmittableThreadLocal<?> threadLocal : holder.get().keySet()) {
   
                captured.put(threadLocal, threadLocal.copyValue());
            }
            return captured;
        }

其中 holder 记录了当前 Thread 绑定了哪些 TransmittableThreadLocal 对象。captured 保存了父线程ThreadLocal 的值。

接着任务提交到线程池,线程开始运行时,取出保存在 captured 中的父线程 ThreadLocal 值并重新 set。即将父线程值传递到了任务执行时。

@Override
public void run() {
   
    Object captured = capturedRef.get();
    if (captured == null || releaseTtlValueReferenceAfterRun && !capturedRef.compareAndSet(captured, null)) {
   
        throw new IllegalStateException("TTL value reference is released after run!");
    }

    Object backup = replay(captured);
    try {
   
        runnable.run();
    } finally {
   
        restore(backup);
    }
}

这样 TransmittableThreadLocal 就解决了在线程池场景下的 ThreadLocal 对象传递。

2、整个过程的完整时序图

image.png

根据时序图的步骤来说明:

  1. createTtl()、setTtlValue() 其实就是调用 TransmittableThreadLocal 的线程上下文值,ttlValue 就是上下文中的值;
  2. createBizTaskRunnable 就是执行业务的线程,createTtlRunnableWrapper(Runnable) 就是使用TtlRunnable.get() 来封装了 Runnable,捕获操作 captureAllTtlValues 就是发生这里;
  3. 下面就进入到了时序图中 captureAllTtlValues、get()、copy(T value) 的实现;
  4. submitTtlRunnableToThreadPool、run() 对应线程池开始执行任务;
  5. 接下来就进入到了时序图中的beforeExecute、replayCapturedTtlValues() 方法;
  6. 时序图中 run、useValueInTtl 即对应到业务 Runnable 中的实现,因为此时已经完成重放操作,子线程中可以使用父线程的 ttlValue;
  7. 后面就是使用备份的子线程上下变量 backup 来恢复子线程的上下文环境,避免因为重放导致子线程的上下文环境被污染。对应到时序图中的 restoreTtlValueBeforeReplay,afterExecute。

源码如有兴趣的可以自己去看下。

五、小结

本文简单介绍了ThreadLocal、InheritableThreadLocal、TransmittableThreadLocal 的实现原理,并从 ThreadLocal、InheritableThreadLocal的 局限性,最终引出 TransmittableThreadLocal,为全链路压测中流量标记的透传打下坚实的基础

示例代码:

参考资料:

目录
相关文章
|
4月前
|
存储 监控 安全
解锁ThreadLocal的问题集:如何规避多线程中的坑
解锁ThreadLocal的问题集:如何规避多线程中的坑
128 0
|
4月前
|
存储 Java 测试技术
ThreadLocal:线程专属的变量
ThreadLocal:线程专属的变量
55 0
|
4天前
|
存储 Java 程序员
优化Java多线程应用:是创建Thread对象直接调用start()方法?还是用个变量调用?
这篇文章探讨了Java中两种创建和启动线程的方法,并分析了它们的区别。作者建议直接调用 `Thread` 对象的 `start()` 方法,而非保持强引用,以避免内存泄漏、简化线程生命周期管理,并减少不必要的线程控制。文章详细解释了这种方法在使用 `ThreadLocal` 时的优势,并提供了代码示例。作者洛小豆,文章来源于稀土掘金。
|
17天前
|
安全 Java C#
Spring创建的单例对象,存在线程安全问题吗?
Spring框架提供了多种Bean作用域,包括单例(Singleton)、原型(Prototype)、请求(Request)、会话(Session)、全局会话(GlobalSession)等。单例是默认作用域,保证每个Spring容器中只有一个Bean实例;原型作用域则每次请求都会创建一个新的Bean实例;请求和会话作用域分别与HTTP请求和会话绑定,在Web应用中有效。 单例Bean在多线程环境中可能面临线程安全问题,Spring容器虽然确保Bean的创建过程是线程安全的,但Bean的使用安全性需开发者自行保证。保持Bean无状态是最简单的线程安全策略;
|
2月前
|
存储 SQL Java
(七)全面剖析Java并发编程之线程变量副本ThreadLocal原理分析
在之前的文章:彻底理解Java并发编程之Synchronized关键字实现原理剖析中我们曾初次谈到线程安全问题引发的"三要素":多线程、共享资源/临界资源、非原子性操作,简而言之:在同一时刻,多条线程同时对临界资源进行非原子性操作则有可能产生线程安全问题。
|
2月前
|
存储 缓存 算法
同时使用线程本地变量以及对象缓存的问题
【7月更文挑战第15天】同时使用线程本地变量和对象缓存需小心处理以避免数据不一致、竞争条件及内存泄漏等问题。线程本地变量使各线程拥有独立存储,但若与对象缓存关联,可能导致多线程环境下访问旧数据。缺乏同步机制时,多线程并发修改缓存中的共享对象还会引起数据混乱。此外,若线程结束时未释放对象引用,可能导致内存泄漏。例如,在Web服务器场景下,若一更新缓存而另一线程仍获取旧数据,则可能返回错误信息;在图像处理应用中,若多线程无序修改算法对象则可能产生错误处理结果。因此,需确保数据一致性、避免竞争条件并妥善管理内存。
|
2月前
|
安全 Java
多线程线程安全问题之避免ThreadLocal的内存泄漏,如何解决
多线程线程安全问题之避免ThreadLocal的内存泄漏,如何解决
|
2月前
|
存储 安全 Java
多线程线程安全问题之ThreadLocal是什么,它通常用于什么场景
多线程线程安全问题之ThreadLocal是什么,它通常用于什么场景
|
2月前
|
设计模式 存储 缓存
Java面试题:结合建造者模式与内存优化,设计一个可扩展的高性能对象创建框架?利用多线程工具类与并发框架,实现一个高并发的分布式任务调度系统?设计一个高性能的实时事件通知系统
Java面试题:结合建造者模式与内存优化,设计一个可扩展的高性能对象创建框架?利用多线程工具类与并发框架,实现一个高并发的分布式任务调度系统?设计一个高性能的实时事件通知系统
40 0
|
2月前
|
存储 缓存 Java
Java面试题:解释Java中的内存屏障的作用,解释Java中的线程局部变量(ThreadLocal)的作用和使用场景,解释Java中的锁优化,并讨论乐观锁和悲观锁的区别
Java面试题:解释Java中的内存屏障的作用,解释Java中的线程局部变量(ThreadLocal)的作用和使用场景,解释Java中的锁优化,并讨论乐观锁和悲观锁的区别
30 0