得物技术

用好 TTL Agent 不踩雷:避开内存泄露与CPU 100%两大核心坑|得物技术

目录 一、前言 二、TTL简介 三、演化历史 1. 手搓wrap 2. TTL开源 3. 透明代理 四、生产案例 1. 内存泄露 2. 高频切换 五、规范建议 一 前言 近几年,许多 Java 应用默认启用了 TTL Agent。它以 Java Agent 方式在运行期增强,实现线程上下文在线程池/异步执行间的透明传递,无需改造 Runnable 或线程池,真正做到了对业务代码的零侵入。
但我们也观察到:若使用不当,可能带来稳定性隐患,如 上下文污染、线程/内存泄漏、CPU 异常 等。本文将简要回顾 TTL 的工作原理,并结合近期生产案例,给出可复现的问题现象与避坑实践。 二 TTL简介 TTL [ transmittable-thread-local ] 开源地址 :https://github.com/alibaba/transmittable-thread-local 阿里线程 MTC 透传库【解决多线程传递Context的需求】
简介 :在异步/线程池场景下,将父线程的 ThreadLocal 上下文可靠地“捕获 → 传递 → 恢复”,防止上下文(例如 TraceId、RpcContext、染色标 ...)丢失或串用。
现状 :得物 Java 类应用服务默认已开启。
三 演化历史 手搓wrap Java 中每个线程,允许为每个线程创建一个独立的绑定变量副本,线程之间互不干扰。它常用于解决 线程安全 问题,尤其在 多线程环境下共享对象 时非常有用。例如:日志跟踪、事务管理、Trace跟踪......
最早Java 应用如果要把线程的上下文在线程池之间透传,需要手搓wrap。
import java.util.concurrent.*;publicclassTLWrapDemo {    static final ThreadLocal<String> ctx = new ThreadLocal<>();    // 手动“透传”主线程上下文    static Runnable wrap(Runnable task) {        String captured = ctx.get(); // 抓取提交任务时的上下文        return () -> {            try { ctx.set(captured); task.run(); } // 上下文回放,并执行任务逻辑            finally { ctx.remove(); } // 防止线程复用造成“脏上下文”        };    }    publicstaticvoidmain(String[] args) throws Exception {        ExecutorService pool = Executors.newSingleThreadExecutor();        System.out.println("=== 不使用 wrap(透传失败) ===");        ctx.set("User-A");        pool.submit(() -> System.out.println("1: " + ctx.get())).get(); // null        ctx.set("User-B");        pool.submit(() -> System.out.println("2: " + ctx.get())).get(); // 仍然 null        System.out.println("\n=== 使用 wrap(透传成功) ===");        ctx.set("User-A");        pool.submit(wrap(() -> System.out.println("3: " + ctx.get()))).get(); // User-A        ctx.set("User-B");        pool.submit(wrap(() -> System.out.println("4: " + ctx.get()))).get(); // User-B        pool.shutdown();    }}运行结果:=== 不使用 wrap(透传失败) ===  1: null 2: null=== 使用 wrap(透传成功) ===3: User-A4: User-B
这种手动wrap的方式存在很多局限:
  • 每次 submit 都要手动 wrap,一旦忘记就会“断链”,新手容易踩坑;
  • 只适用于 Runnable,像 Callable、CompletableFuture、并行流 ... 都不兼容;
  • 三方框架(如 Spring、RxJava)常用自己的线程池,无法注入手动 wrap;
  • 多个上下文变量时,手动管理麻烦且容易出错;
  • context.remove() 语义弱,业务上有的变量需要保留,有的需要清除,不好区分处理。

TTL开源 2013年,Dubbo作者之一哲良首次意识到 MTC 在线程池中透传困难,没有统一的规范。为了解决这一痛点,开源了基础库 TTL(Transmittable ThreadLocal),实现了上下文透传标准解决方案。
线程 MTC 透传,TTL 示例:
import com.alibaba.ttl.TransmittableThreadLocal;import com.alibaba.ttl.threadpool.TtlExecutors;import java.util.concurrent.*;publicclassDemo1_ExecutorWrap {    static final TransmittableThreadLocal<String> ctx = new TransmittableThreadLocal<>();    publicstaticvoidmain(String[] args) throws Exception {        ExecutorService raw = Executors.newFixedThreadPool(2);        ExecutorService pool = TtlExecutors.getTtlExecutorService(raw); // 一次装饰        System.out.println("=== 装饰后的线程池 ===");        ctx.set("User-A"); // 跟普通写法一致,使用者几乎无感        pool.submit(() -> System.out.println("A1: " + ctx.get())).get();         ctx.set("User-B"); // 跟普通写法一致,使用者几乎无感        pool.submit(() -> System.out.println("B1: " + ctx.get())).get();        pool.shutdown();    }}

透明代理 API 局限性 TTL 库本身仅以 API 方式简化了手动 wrap Runnable / Callable 的复杂度,但本质上仍然依赖开发者主动进行 wrap 操作。 然而在实际项目中,一旦遇到如 Spring @Async、RxJava、Netty、MyBatis 插件 等框架,它们内部往往会 自动创建线程池并提交异步任务 ,这些过程开发者 无法显式介入 ,就意味着无法注入TTL的上下文传播。
Agent 增强 TTL Agent 基于Java Agent 技术, 在JVM启动阶段通过字节码增强,自动拦截所有异步任务入口 ,包括 Executor #submit ()、ForkJoinPool #submit ()、CompletableFuture 等常见 API,对传入的任务对象(Runnable/Callable 等)进行无侵入 wrap,确保线程上下文(如 ThreadLocal、TraceId、用户信息等)在异步任务中正确传播,无需开发者手动干预。
ThreadPoolExecutor 字节码增强前后 JAD 反编译对比,如图所示:
未启用TTL Agent字节码增强
启用TTL Agent字节码增强 增强逻辑 Utils.doAutoWrap(command)) 则就是将 Runnable 自动 wrap 为TtlRunnable,TtlRunnable可在真正Runnable逻辑执行前后,对当前线程进行上下文管理干预。
TTL 增强逻辑
TtlRunnable 干预上下文 这样只需要在 JVM 启动的时候引入TTL Agent,线程池 被自动赋予MTC透传能力。
JVM 参数启用TTL Agent
任何事情都会有两面性,自动地背后往往是稳定性隐患。近期遇到了两个TTL典型的case,在此给大家分享。
四 生产案例 内存泄露 一次故障中,我们曾紧急大规模卸载掉了所有 JAVA 应用 Agent,在恢复还原的时候,不少同学是先恢复的promise-agent [ Trace ] ,然后再恢复 ttl-agent。这样恰好就引出了一个内存泄露的问题,后面发现跟 JVM参数 -javaagent 的放置顺序有关 。
初期采取了临时措施进行快速止血,未能深入探讨其根因。近期抽空对该问题做了一些研究,给大家分享。 泄露表现 如图所示,早期有部分同学参考了错误的文档配置 TTL,虽然JVM能够正常启动,初期运行也未表现异常,但在某些应用中,特别是DPP场景下, GC线程会突然异常活跃,导致 Pod 的 CPU使用率迅速飙升 。
此类情况下兜底策略被频繁触发,触发频率甚至上涨了数十倍,只能通过紧急重启来缓解,严重时甚至需要 24 小时轮班人工值守。当时多个 DPP 场景平均每 3 小时就要重启一次。
错误文档
内存爆炸疯狂GC 为什么 ttl - javaagent 后置,会触发内存泄露? 需要了解机制:JVM 的类加载 + Bytecode Instrumentation(字节码插桩)
Agent 插桩 JavaAgent 是Java提供的一种机制,允许你在类加载(load)到 JVM 之前,对类的字节码进行修改,常用于性能监控、日志记录、AOP、自动埋点等场景。
简单来说:JavaAgent 就像是一个“插件”,它可以“悄悄”地在你的代码运行前,把一些逻辑插进去。
JavaAgent 依赖 Instrumentation 接口。你需要实现一个 Agent类,并实现一个特殊的方法:
public static void premain(String agentArgs, Instrumentation inst)
这个方法会在 main 方法之前被调用。
如果你使用-javaagent:path/agent.jar 启动应用,JVM 会先调用 Agent 的 premain() 方法。
来个简单的 demo:
import java.lang.instrument.*;import javassist.*;public class Agent {    public static void premain(String args, Instrumentation inst) throws Exception {        // ←←← 注册 Transformer        inst.addTransformer((loader, name, cls, domain, bytes) -> {             if (!"java/lang/Thread".equals(name)) return null; // 修改 Thread.sleep()            try { // 字节码编辑                ClassPool cp = ClassPool.getDefault();                CtClass cc = cp.get("java.lang.Thread");                CtMethod m = cc.getDeclaredMethod("sleep", new CtClass[]{CtClass.longType});                m.insertBefore("long t=System.currentTimeMillis();");                m.insertAfter("System.out.println(\"[MyAgent] sleep took \" + (System.currentTimeMillis()-t) + \" ms\");");                return cc.toBytecode();            } catch (Exception e) { e.printStackTrace(); }            return null;        }, true);        // ← 主动触发强制重新转换 Thread 类 ( Thread 已经加载过 )        inst.retransformClasses(Thread.class);    }}// 流程:JVM 启动时  └─> 检查 -javaagent:agent.jar      └─> 加载 Agent 类          └─> 调用 premain()              └─> 注册 Transformer(addTransformer)                  └─> (可选) 触发 retransformClasses() // JDK核心不可以// 使用 agent 效果public class Main {    public static void main(String[] args) throws Exception {        Thread.sleep(500);    }}// 运行结果:[MyAgent] sleep took 500 ms
  • 每个 -javaagent 都会注册 java.lang.instrument.ClassFileTransformer。
  • 所有 transformers 会在 类第一次加载前 被 JVM 按照 agent 加载顺序调用。
  • 一旦一个类(比如 ThreadPoolExecutor)被 JVM 定义了,后续其它 Agent 就无法再改它的字节码。
类的生命周期各阶段是否支持插桩:
Promise分析 打开 promise-agent 源码,其中找到 ThreadPoolExecutorInstrumentationModule。
Promise 劫持插桩 这个 InstrumentationModule 会根据类名匹配 java.util.concurrent.ThreadPoolExecutor。在 Agent 启动时,若使用了 ThreadPoolExecutor,即便是对字段、构造函数或方法的检查,也会触发类的初始化(defineClass)。一旦 ThreadPoolExecutor 被加载,JVM 则不会允许后续 TTL Agent transformer 再对其进行插桩。可以简单理解为, promise-agent 如果先于 ttl-agent,它则会“劫持”了对 TTL 后续对 ThreadPoolExecutor 的插桩权限。
TTL 透传失效致内存泄漏 如果 TTL线程池字节码增强失败,TransmittableThreadLocal 在各线程池线程切换时将无法自动透传。而 promise 的TTLScope.scopeManager.ttlScope 正是TransmittableThreadLocal。
透传失效会导致关键的退出逻辑无法执行【如:if (scopeManager.ttlScope.get() != this)】,进而引发内存泄漏问题。
泄露原因:Trace 的 TTLScope 无法正常退出
TTLScope 串联消耗内存空间
官方解法 确保 ttl-agent.jar 的 -javaagent 顺序排在第一。 Eg:java -javaagent:ttl-agent.jar   -javaagent:promise-agent.jar .... -jar app.jar
  • 将TTL agent 放在前面,它的 transformer 会最先被调用,从而有机会修改与线程池相关的类(如 ThreadPoolExecutor、Executors 等)。
  • 后续的 agent 仍然可以修改类(前提是类还未被定义),但 TTL Agent 已确保优先插桩。

作者哲良其实早在 GitHub 上就已回答过大家,标准的使用方式是这样:
高频切换 背景说明 在部分算法业务中,发布/重启窗口会有概率出现 CPU 突发且持续高水位(如 ≥90% ),运行期亦偶发,问题多年悬而未决。近期在强化黑屏诊断工具,意外捕捉到一条关键线索,最终成功定位并解决了该问题。

实用工具 异常发生时,人工取证受干扰多、难度高。
现已在 ZJDK 镜像内置 JEX,一键抓取,显著提升取证效率与成功率。 JEX (Java Execution Extractor) 工具: 可一键快速抓取 Java [gc、thread、dump、jfr、env、dmesg、cgroup] 现场信息,并打包上传OSS。 使用方法:
JEX --help
JEX  并发+串行 抓取故障现场
故障现象 某日上午11点左右,t-ts-main-clk-v12-sek(交易主搜精排)突然出现一批 Pod 的 CPU 飙升。我们紧急使用 JEX 工具,捕获到一个All In One的现场包。

故障分析 分析GC日志 分析GC日志,只能看到判断是内存瞬间打满,触发了大量GC线程疯狂吃算力。
结论:内存一定有问题,究竟谁在吃内存?
Yong\Old 瞬间打满,且无法回收
GC 线程疯狂吃算力
CGroup CPU水位打满
分析内存dump 分析链路:
  • 泄露源:内存大量被 ConcurrentHashMap 占用
  • 溯源一:大对象 ConcurrentHashMap 被封装在 itemFeature
  • 溯源二:itemFeature 被 PredictorParam 持有
  • 溯源三:PredictorParam 被 PredictorContext 持有
  • 溯源四:PredictorContext 最终被ThreadLocal 持有,所属线程为:predict-bound-task
发现泄露对象
泄露对象溯源 分析JFR 飞行记录时间:30s 堆栈分析:
  • 观察 predict-bound-task 线程栈,主要是调下游做 Tensorflow 特征推理。
  • 下面有 TtlRunnable 装饰,也就是意味着 这里必有线程切换。
  • TtlRunnable.run 展开,结合Neuron源码分析得出右图链路。
  • 1个TFS调用需X组线程Y次协作完成,每次均需要做TTL切换,无形中拖慢 ThreadLocal 释放时间。

注意 :该步骤基于“调用栈+业务代码”的联合推演。
脱离业务语义的栈信息通常只能描述现象,难以收敛到根因。这里多亏算法预估同学大力支持解惑。
predict-bound-task 线程栈 有TtlRunnable 装饰
展开堆栈:1个TFS调用需N组线程协作完成
线程栈火焰图不擅长展示异步链路,因此改用更直观的因果链路图:
可以看到 TtlRunnable 看似非常轻量级的wrap,但在 CPU密集、高并发、高频切换 场景,本身对性能的杀伤力巨大,最要命的是,如果线程通过 ThreadLocal 挂载了大量对象,这可能会延缓大对象释放的时间。即便是几秒钟的延缓,也极有可能突然堵死整个堆内存,导致疯狂GC,这个时候GC线程会跟业务线程争夺CPU算力,进入恶性循环。
涡轮增压逻辑
日常态 部分Pod OOM 时间 buffer < 1s 内存压力: 查看内存分配栈,在资源极度紧张的时候,TTL 回放栈依然是雄冠全栈。
内存分配压力
仿真压测 注意挂载 ttl-agent:( 建议在CPU较烂电脑上测试,更能说明问题 ) java -javaagent:./transmittable-thread-local-2.14.5.jar com.poizon.security.TtlPerfHighConcurrencyTest
https://dw-ops.oss-cn-hangzhou.aliyuncs.com/algo-sre/tmp/TtlPerfHighConcurrencyTest.java
测试结果==== Summary ====Case         | Tasks      | Time(s)  | Throughput(t/s)  | Avg(ns/task)baseline     | 4995000    | 1.018    | 4907582          | 204         withTTL      | 4995000    | 6.044    | 826381           | 1210        -----------------

归因结论 栈与堆内证据表明:TTL 上下文捕获/回放在高并发与频繁线程切换时放大 CPU 开销。与相关方确认 Neuron 无 TTL-Agent 刚性依赖后,针对高频异常场景移除 Agent,经多轮压力灰度验证与多日观测,CPU 冲顶消失。
【price 场景】优化前
【price 场景】优化后
【v12 场景】优化前
【v12 场景】优化后 结论 : CPU密集、高并发、高频切换场景 禁用TTL-Agent,改用 API 显式透传(Maven 依赖),将 TTL 使用范围收敛至必要链路。
五 规范建议
  • Agent 顺序:
    • -javaagent:ttl-agent.jar 必须排第一;
    • 在 CI/CD 模板与运维手册中强约束,并加启动期自检日志。
  • 默认策略:
    • 对 CPU 密集 / 高并发 / 高频切换 / 大对象 ThreadLocal场景,默认禁用 TTL-Agent ;
    • 如需透传,优先 API 显式透传 ,并收敛到关键链路。

把“是否需要 Agent 级透明增强”从习惯性默认, 升级为工程化决策:能 API,就别 Agent;能收敛,就不扩散 。
往期回顾
1. 线程池ThreadPoolExecutor源码深度解析|得物技术 2. 基于浏览器扩展 API Mock 工具开发探索|得物技术 3. 破解gh-ost变更导致MySQL表膨胀之谜|得物技术 4. MySQL单表为何别超2000万行?揭秘B+树与16KB页的生死博弈|得物技术 5. 0基础带你精通Java对象序列化--以Hessian为例|得物技术
文 /药尘
关注得物技术,每周一、三更新技术干货 要是觉得文章对你有帮助的话,欢迎评论转发点赞~ 未经得物技术许可严禁转载,否则依法追究法律责任。 “ 扫码添加小助手微信 如有任何疑问,或想要了解更多技术资讯,请添加小助手微信: