谈谈TransmittableThreadLocal实现原理和在日志收集记录系统上下文实战应用

1.InheritableThreadLocal的不足

之前我们就总结过父子线程之间上下文如何传递:父子线程之间值传递解决方案:InheritableThreadLocal和TransmittableThreadLocal

重点介绍了InheritableThreadLocal(后文简写ITL)概念、实现原理、使用示例和不足,同时浅浅地引出了TransmittableThreadLocal(后文简称TTL)的使用,但并没有展开详细分析,今天我们就来重点总结下TTL

JDKITL类可以完成父线程到子线程的值传递。但对于使用线程池等会池化复用线程的执行组件的情况,线程由线程池创建好,并且线程是池化起来反复使用的。而ITL是在父线程调用new Thread()方法来创建子线程时,执行Thread#init方法,把父线程ITL数据拷贝到子线程中。所以线程池的线程只会创建一次触发ITL的拷贝逻辑,以后都是池化起来复用,这时候执行上下文就不能在父子线程中正常传递了,示例如下:

csharp 复制代码
    // 创建线程池
    private static ExecutorService fixedThreadPool = Executors.newFixedThreadPool(1);
    public static void main(String[] args) {
        InheritableThreadLocal<String> context = new InheritableThreadLocal<>();
        context.set("value1-set-in-parent");
        fixedThreadPool.submit(()->{
            // 第一个子线程打印value1-set-in-parent,毋庸置疑
            System.out.println(context.get());
        });
        // 父线程修改InheritableThreadLocal变量值
        context.set("value2-set-in-parent");
        fixedThreadPool.submit(()->System.out.println(context.get()));
    }

执行结果:两次都是value1-set-in-parent

arduino 复制代码
value1-set-in-parent
value1-set-in-parent

代码解读:

  • Executors.newFixedThreadPool(1): 创建只有一个线程的线程池
  • 两次异步执行,在执行前设置了ITL的值,但线程池只有一个线程,第二次异步执行其实是复用线程,并没有新创建线程,这时候ITL的变量值就是前面那个线程的值,所以这里和第一个子线程一样打印value1-set-in-parent,如果上面线程池核心数和最大线程数都为2,这时候会新建一个子线程,此时会获取父线程的最新值,打印value2-set-in-parent

2.TransmittableThreadLocal使用

引入依赖:

xml 复制代码
  <dependency>
      <groupId>com.alibaba</groupId>
      <artifactId>transmittable-thread-local</artifactId>
      <version>2.12.6</version>
  </dependency>

2.1 修饰Runnable

使用TTL, 并用TtlRunnable.get()修饰Runnable,完美解决上面问题

csharp 复制代码
 private static ExecutorService fixedThreadPool = Executors.newFixedThreadPool(1);
​
​
    public static void main(String[] args) {
        // ttl
        TransmittableThreadLocal<String> context = new TransmittableThreadLocal<String>();
        context.set("value1-set-in-parent");
        fixedThreadPool.submit(()->{
            // 第一个子线程打印value1-set-in-parent,毋庸置疑
            System.out.println(context.get());
        });
        // 父线程修改InheritableThreadLocal变量值
        context.set("value2-set-in-parent");
        Runnable runnable = ()->System.out.println(context.get());
        TtlRunnable ttlRunnable = TtlRunnable.get(runnable);
        fixedThreadPool.submit(ttlRunnable);
    }

即使是同一个Runnable任务多次提交到线程池时,每次提交时都需要通过修饰操作(即TtlRunnable.get(task))以抓取这次提交时的TransmittableThreadLocal上下文的值;即如果同一个任务下一次提交时不执行修饰而仍然使用上一次的TtlRunnable,则提交的任务运行时会是之前修饰操作所抓取的上下文。示例代码如下:

csharp 复制代码
// 第一次提交
Runnable task = new RunnableTask();
executorService.submit(TtlRunnable.get(task));
​
// ...业务逻辑代码,
// 并且修改了 TransmittableThreadLocal上下文 ...
context.set("value-modified-in-parent");
​
// 再次提交
// 重新执行修饰,以传递修改了的 TransmittableThreadLocal上下文
executorService.submit(TtlRunnable.get(task));

为了省去每次RunnableCallable传入线程池时的修饰,这个逻辑可以在线程池中完成

2.2 修饰线程池

如上2.1效果,使用工具类TtlExecutors修饰线程池,这样解决上面ITL的线程池线程复用值传递问题

csharp 复制代码
    // 创建线程池,并于ttl修饰
    private static ExecutorService fixedThreadPool = TtlExecutors.getTtlExecutorService(Executors.newFixedThreadPool(1));
​
​
    public static void main(String[] args) {
        TransmittableThreadLocal<String> context = new TransmittableThreadLocal<String>();
​
        context.set("value1-set-in-parent");
        fixedThreadPool.submit(()->{
            // 第一个子线程打印value1-set-in-parent,毋庸置疑
            System.out.println(context.get());
        });
        // 父线程修改InheritableThreadLocal变量值
        context.set("value2-set-in-parent");
        fixedThreadPool.submit(()->System.out.println(context.get()));
    }

3.TransmittableThreadLocal实现原理

根据上面示例debug代码你会发现,修饰线程池TtlExecutors.getTtlExecutorService()底层也是通过修饰Runnable实现的:

ini 复制代码
    private static ExecutorService fixedThreadPool = TtlExecutors.getTtlExecutorService(Executors.newFixedThreadPool(1));
​

TtlExecutors.getTtlExecutorService()

less 复制代码
    public static ExecutorService getTtlExecutorService(@Nullable ExecutorService executorService) {
        if (TtlAgent.isTtlAgentLoaded() || executorService == null || executorService instanceof TtlEnhanced) {
            return executorService;
        }
        // 线程池封装增强
        return new ExecutorServiceTtlWrapper(executorService, true);
    }
​

来到ExecutorServiceTtlWrapper类,你会发现它实现了ExecutorService接口,实现了该接口相关的方法,同时这个类还有一个变量private final ExecutorService executorService;: 这是典型的装饰器模式

scala 复制代码
class ExecutorServiceTtlWrapper extends ExecutorTtlWrapper implements ExecutorService, TtlEnhanced {
    private final ExecutorService executorService;
    
    
    @NonNull
    @Override
    public Future<?> submit(@NonNull Runnable task) {
        return executorService.submit(TtlRunnable.get(task, false, idempotent));
    }
    
}
    
  

我这里给出它实现ExecutorService接口的一个方法submit(),可以看到最终还是通过TtlRunnable修饰Runable实现,所以核心逻辑就在TtlRunnable

TtlRunnable 核心代码:上面的TtlRunnable.get()会调用```TtlRunnable``的构造方法

java 复制代码
public final class TtlRunnable implements Runnable, TtlWrapper<Runnable>, TtlEnhanced, TtlAttachments {
    private final AtomicReference<Object> capturedRef;
    private final Runnable runnable;
    private final boolean releaseTtlValueReferenceAfterRun;
​
    private TtlRunnable(@NonNull Runnable runnable, boolean releaseTtlValueReferenceAfterRun) {
        // 捕获主线程的所有TransmittableThreadLocal和注册的ThreadLocal值。
        this.capturedRef = new AtomicReference<>(capture());
        this.runnable = runnable;
        this.releaseTtlValueReferenceAfterRun = releaseTtlValueReferenceAfterRun;
    }
​
    @Override
    public void run() {
        // 获取主线程传递下来的TransmittableThreadLocal和ThreadLocal值,这已经在上面构造方法中捕获到
        final Object captured = capturedRef.get();
        if (captured == null || releaseTtlValueReferenceAfterRun && !capturedRef.compareAndSet(captured, null)) {
            throw new IllegalStateException("TTL value reference is released after run!");
        }
        // 重放和备份
        // 1.重放之前先备份子线程的ttl值,方便后续恢复
        // 2.重放上面获取到主线程的ttl值,set到子线程中
        final Object backup = replay(captured);
        try {
            // 执行子线程任务
            runnable.run();
        } finally {
            // 恢复   恢复线程执行replay方法之前的TTL值
            restore(backup);
        }
    }
    
    //......
    
}

核心流程:

  1. capture():创建任务时捕获当前线程的所有TTL变量
  2. replay():执行任务前,将捕获的TTL变量恢复到当前线程
  3. restore():执行完成后,恢复线程原有的TTL变量

这里关于这些核心点的源码就不展开说了,有兴趣的自行了解一波。

期望:上下文生命周期的操作从业务逻辑中分离出来。业务逻辑不涉及生命周期,就不会有业务代码如疏忽清理而引发的问题了。整个上下文的传递流程或说生命周期可以规范化成:捕捉、回放和恢复 这3个操作,即CRR(capture/replay/restore)模式。更多讨论参见 Issue:能在详细讲解一下replayrestore的设计理念吗?#201

其实捕获主线程的TTL值,通过回放set到子线程中这比较好理解,但是这个备份和恢复就有点难明白了,因为在我们通常理解里面线程池的线程执行完任务之后,直接清除该线程的ttl值即可,因为线程已经结束了,备份线程原来的ttl值再恢复貌似没有什么意义~~~

其实有一种特殊情况:线程池满了且线程池使用的是CallerRunsPolicy,则提交到线程池的任务在capture捕获线程中直接执行,也就是直接在业务主线程中同步执行;如果你直接清除,则会对主线程的后续逻辑执行可能造成bug。

这是TTL使用的整体流程图:

4.TransmittableThreadLocal在日志收集记录系统上下文实战应用

spring boot项目中引入日志功能的starter:spring-boot-starter-logging,底层使⽤了 logback + slf4j 组合作为默认底层⽇志。

之前我们总结过关于:Spring Boot项目如何实现分布式日志链路追踪每个请求都使用一个唯一标识traceId来追踪全部的链路显示在日志中,并且不修改原有的打印方式(代码无入侵),然后使用使用Logback的MDC机制日志模板中加入traceId标识,取值方式为%X{traceId} 。这样在收集的日志文件中就可以看到每行日志有一个traceId值,每个请求的值都不一样,这样我们就可以根据traceId查询过滤出一次请求的所有上下文日志了,格式如下:

xml 复制代码
<!-- 日志格式 -->
<property name="CONSOLE_LOG_PATTERN" value="[%X{traceId}] [%-5p] [%d{yyyy-MM-dd HH:mm:ss.SSS}] [%t@${PID}]  %c %M : %m%n"/>

但是在多线程场景下traceId就丢失了。要想解决这个问题就得借助今天主角TTL

4.2 logback

logback作为spring boot的默认日志,其实我们只需要重写logback对slf4j提供标准接口MDCAdapter的实现即可,把logback的实现类LogbackMDCAdapter代码抄一遍:

typescript 复制代码
public class TtlLogbackMDCAdapter implements MDCAdapter {
​
    private static final TtlLogbackMDCAdapter MDC_ADAPTER;
​
    // BEWARE: Keys or values placed in a ThreadLocal should not be of a type/class
    // not included in the JDK. See also https://jira.qos.ch/browse/LOGBACK-450
    /**
     * use com.alibaba.ttl.TransmittableThreadLocal
     */
    final ThreadLocal<Map<String, String>> readWriteThreadLocalMap = new TransmittableThreadLocal<>();
    final ThreadLocal<Map<String, String>> readOnlyThreadLocalMap = new TransmittableThreadLocal<>();
    private final ThreadLocalMapOfStacks threadLocalMapOfDeques = new ThreadLocalMapOfStacks();
​
    static {
        MDC_ADAPTER = new TtlLogbackMDCAdapter();
    }
​
    public static MDCAdapter getInstance() {
        return MDC_ADAPTER;
    }
​
    /**
     * Put a context value (the <code>val</code> parameter) as identified with the
     * <code>key</code> parameter into the current thread's context map. Note that
     * contrary to log4j, the <code>val</code> parameter can be null.
     * <p/>
     * <p/>
     * If the current thread does not have a context map it is created as a side
     * effect of this call.
     * <p/>
     * <p/>
     * Each time a value is added, a new instance of the map is created. This is
     * to be certain that the serialization process will operate on the updated
     * map and not send a reference to the old map, thus not allowing the remote
     * logback component to see the latest changes.
     *
     * @throws IllegalArgumentException in case the "key" parameter is null
     */
    @Override
    public void put(String key, String val) throws IllegalArgumentException {
        if (key == null) {
            throw new IllegalArgumentException("key cannot be null");
        }
        Map<String, String> current = readWriteThreadLocalMap.get();
        if (current == null) {
            current = new HashMap<>();
            readWriteThreadLocalMap.set(current);
        }
​
        current.put(key, val);
        nullifyReadOnlyThreadLocalMap();
    }
​
    /**
     * Get the context identified by the <code>key</code> parameter.
     * <p/>
     * <p/>
     * This method has no side effects.
     */
    @Override
    public String get(String key) {
        Map<String, String> hashMap = readWriteThreadLocalMap.get();
​
        if ((hashMap != null) && (key != null)) {
            return hashMap.get(key);
        } else {
            return null;
        }
    }
​
    /**
     * <p>Remove the context identified by the <code>key</code> parameter.
     * <p/>
     */
    @Override
    public void remove(String key) {
        if (key == null) {
            return;
        }
​
        Map<String, String> current = readWriteThreadLocalMap.get();
        if (current != null) {
            current.remove(key);
            nullifyReadOnlyThreadLocalMap();
        }
    }
​
    private void nullifyReadOnlyThreadLocalMap() {
        readOnlyThreadLocalMap.set(null);
    }
​
    /**
     * Clear all entries in the MDC.
     */
    @Override
    public void clear() {
        readWriteThreadLocalMap.set(null);
        nullifyReadOnlyThreadLocalMap();
    }
​
    /**
     * <p>Get the current thread's MDC as a map. This method is intended to be used
     * internally.</p>
     *
     * The returned map is unmodifiable (since version 1.3.2/1.4.2).
     */
    public Map<String, String> getPropertyMap() {
        Map<String, String> readOnlyMap = readOnlyThreadLocalMap.get();
        if (readOnlyMap == null) {
            Map<String, String> current = readWriteThreadLocalMap.get();
            if (current != null) {
                final Map<String, String> tempMap = new HashMap<>(current);
                readOnlyMap = Collections.unmodifiableMap(tempMap);
                readOnlyThreadLocalMap.set(readOnlyMap);
            }
        }
        return readOnlyMap;
    }
​
    /**
     * Return a copy of the current thread's context map. Returned value may be
     * null.
     */
    @Override
    public Map<String, String> getCopyOfContextMap() {
        Map<String, String> readOnlyMap = getPropertyMap();
        if (readOnlyMap == null) {
            return null;
        } else {
            return new HashMap<>(readOnlyMap);
        }
    }
​
    /**
     * Returns the keys in the MDC as a {@link Set}. The returned value can be
     * null.
     */
    public Set<String> getKeys() {
        Map<String, String> readOnlyMap = getPropertyMap();
​
        if (readOnlyMap != null) {
            return readOnlyMap.keySet();
        } else {
            return null;
        }
    }
​
    @Override
    @SuppressWarnings("unchecked")
    public void setContextMap(Map contextMap) {
        if (contextMap != null) {
            readWriteThreadLocalMap.set(new HashMap<String, String>(contextMap));
        } else {
            readWriteThreadLocalMap.set(null);
        }
        nullifyReadOnlyThreadLocalMap();
    }
​
​
    @Override
    public void pushByKey(String key, String value) {
        threadLocalMapOfDeques.pushByKey(key, value);
    }
​
    @Override
    public String popByKey(String key) {
        return threadLocalMapOfDeques.popByKey(key);
    }
​
    @Override
    public Deque<String> getCopyOfDequeByKey(String key) {
        return threadLocalMapOfDeques.getCopyOfDequeByKey(key);
    }
​
    @Override
    public void clearDequeByKey(String key) {
        threadLocalMapOfDeques.clearDequeByKey(key);
    }
}
​

把原来的ThreadLocal地方替换成TransmittableThreadLocal即可。

通过spi机制加载重写的实现:

scala 复制代码
public class TtlLogbackServiceProvider extends LogbackServiceProvider {
​
    private MDCAdapter ttlMdcAdapter;
​
    @Override
    public void initialize() {
        super.initialize();
        this.ttlMdcAdapter = TtlLogbackMDCAdapter.getInstance();
        ((LoggerContext)super.getLoggerFactory()).setMDCAdapter(ttlMdcAdapter);
    }
​
    @Override
    public MDCAdapter getMDCAdapter() {
        return this.ttlMdcAdapter;
    }
}

最后在项目resource建一个目录/META-INF/services,在目录下新建文件org.slf4j.spi.SLF4JServiceProvider写入你刚刚重写实现类的全路径类名

c 复制代码
com.plasticene.boot.web.core.log.logback.TtlLogbackServiceProvider

至此就能在多线程上下文场景中正常传递traceId并被日志系统收集到了。

4.2 log4j2

log4j2是当下更主流的日志框架,其性能表现、功能特性都优与logback,一般建议新项目可以直接采用Log4j2,当然了这并不是说使用logback就不行了,其实也问题不大,要不然spring boot也不会用它做默认日志框架了。

要想用log4j2,就必须先剔除默认spring boot默认的logback,一般依赖配置如下:

xml 复制代码
<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter</artifactId>
   <exclusions>
     <exclusion>
       <groupId>org.springframework.boot</groupId>
       <artifactId>spring-boot-starter-logging</artifactId>
     </exclusion>
   </exclusions>
</dependency>
<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-log4j2</artifactId>
</dependency>

log4j2j集成TTL实现多线程上下文传递功能更简单,只需要重写log4j2对ThreadContextMap的默认实现DefaultThreadContextMap,一样的套路把默认实现代码抄一遍:

typescript 复制代码
public class TtlThreadContextMap implements ThreadContextMap {
​
    private final ThreadLocal<Map<String, String>> localMap;
​
    public TtlThreadContextMap() {
        this.localMap = new TransmittableThreadLocal<>();
    }
​
    @Override
    public void put(final String key, final String value) {
        Map<String, String> map = localMap.get();
        map = map == null ? new HashMap<>() : new HashMap<>(map);
        map.put(key, value);
        localMap.set(Collections.unmodifiableMap(map));
    }
​
    @Override
    public String get(final String key) {
        final Map<String, String> map = localMap.get();
        return map == null ? null : map.get(key);
    }
​
    @Override
    public void remove(final String key) {
        final Map<String, String> map = localMap.get();
        if (map != null) {
            final Map<String, String> copy = new HashMap<>(map);
            copy.remove(key);
            localMap.set(Collections.unmodifiableMap(copy));
        }
    }
​
    @Override
    public void clear() {
        localMap.remove();
    }
​
    @Override
    public boolean containsKey(final String key) {
        final Map<String, String> map = localMap.get();
        return map != null && map.containsKey(key);
    }
​
    @Override
    public Map<String, String> getCopy() {
        final Map<String, String> map = localMap.get();
        return map == null ? new HashMap<>() : new HashMap<>(map);
    }
​
    @Override
    public Map<String, String> getImmutableMapOrNull() {
        return localMap.get();
    }
​
    @Override
    public boolean isEmpty() {
        final Map<String, String> map = localMap.get();
        return map == null || map.isEmpty();
    }
​
    @Override
    public String toString() {
        final Map<String, String> map = localMap.get();
        return map == null ? "{}" : map.toString();
    }
​
    @Override
    public int hashCode() {
        final int prime = 31;
        int result = 1;
        final Map<String, String> map = this.localMap.get();
        result = prime * result + ((map == null) ? 0 : map.hashCode());
        return result;
    }
​
    @Override
    public boolean equals(final Object obj) {
        if (this == obj) {
            return true;
        }
        if (obj == null) {
            return false;
        }
        if (!(obj instanceof TtlThreadContextMap other)) {
            return false;
        }
        final Map<String, String> map = this.localMap.get();
        final Map<String, String> otherMap = other.getImmutableMapOrNull();
        if (map == null) {
            return otherMap == null;
        } else {
            return map.equals(otherMap);
        }
    }
}
​

把原来的ThreadLocal地方替换成TransmittableThreadLocal即可。

4.3 使用示例

代码如下:

java 复制代码
    private final ExecutorService executorService = TtlExecutors.getTtlExecutorService(Executors.newFixedThreadPool(1));
​
    @Operation(summary = "测试traceId多线程上下文传递")
    @GetMapping("/log/traceId")
    public void testLogTraceId() {
        // webTraceFilter会写入traceId
        log.info("主线程traceId: {}", MDCTraceUtils.getTraceId());
        executorService.submit(() -> {
            log.info("子线程traceId: {}", MDCTraceUtils.getTraceId());
            try {
                // 睡眠2秒,等webTraceFilter清除traceId
                TimeUnit.SECONDS.sleep(2);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            log.info("睡眠结束子线程traceId: {}", MDCTraceUtils.getTraceId());
        });
    }

这里是在webTraceFilter设置和清除一次请求的traceId的,不了解套路的可以上面我们提到的之前总结的日志追踪文章链接跳转自行查看,这里的异步睡眠2s,等着webTraceFilter清除了traceId,看对子线程是否有影响,并没有

ini 复制代码
[1932435207898664960]-[INFO ]-[2025-06-10 21:50:35.020]-[http-nio-8882-exec-4]-[com.plasticene.boot.example.web.controller.TestController]-[testLogTraceId]:主线程traceId: 1932435207898664960
[1932435207898664960]-[INFO ]-[2025-06-10 21:50:35.037]-[pool-5-thread-1]-[com.plasticene.boot.example.web.controller.TestController]-[lambda$testLogTraceId$0]:子线程traceId: 1932435207898664960
[1932435207898664960]-[INFO ]-[2025-06-10 21:50:37.038]-[pool-5-thread-1]-[com.plasticene.boot.example.web.controller.TestController]-[lambda$testLogTraceId$0]:睡眠结束子线程traceId: 1932435207898664960
​
相关推荐
晴空月明5 分钟前
Java 内存模型与 Happens-Before 关系深度解析
java
excel21 分钟前
Nginx 与 Node.js(PM2)的对比优势及 HTTPS 自动续签配置详解
后端
bobz9652 小时前
vxlan 为什么一定要封装在 udp 报文里?
后端
bobz9652 小时前
vxlan 直接使用 ip 层封装是否可以?
后端
皮皮林5514 小时前
SpringBoot 加载外部 Jar,实现功能按需扩展!
java·spring boot
郑道4 小时前
Docker 在 macOS 下的安装与 Gitea 部署经验总结
后端
3Katrina4 小时前
妈妈再也不用担心我的课设了---Vibe Coding帮你实现期末课设!
前端·后端·设计
rocksun4 小时前
认识Embabel:一个使用Java构建AI Agent的框架
java·人工智能
汪子熙4 小时前
HSQLDB 数据库锁获取失败深度解析
数据库·后端
高松燈4 小时前
若伊项目学习 后端分页源码分析
后端·架构