用好 TTL Agent 不踩雷:避开内存泄露与CPU 100%两大核心坑|得物技术
但我们也观察到:若使用不当,可能带来稳定性隐患,如 上下文污染、线程/内存泄漏、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。
这种手动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(); // nullctx.set("User-B");pool.submit(() -> System.out.println("2: " + ctx.get())).get(); // 仍然 nullSystem.out.println("\n=== 使用 wrap(透传成功) ===");ctx.set("User-A");pool.submit(wrap(() -> System.out.println("3: " + ctx.get()))).get(); // User-Actx.set("User-B");pool.submit(wrap(() -> System.out.println("4: " + ctx.get()))).get(); // User-Bpool.shutdown();}}运行结果:=== 不使用 wrap(透传失败) ===1: null2: null=== 使用 wrap(透传成功) ===3: User-A4: User-B
- 每次 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典型的case,在此给大家分享。
四 生产案例 内存泄露 一次故障中,我们曾紧急大规模卸载掉了所有 JAVA 应用 Agent,在恢复还原的时候,不少同学是先恢复的promise-agent [ Trace ] ,然后再恢复 ttl-agent。这样恰好就引出了一个内存泄露的问题,后面发现跟 JVM参数 -javaagent 的放置顺序有关 。
初期采取了临时措施进行快速止血,未能深入探讨其根因。近期抽空对该问题做了一些研究,给大家分享。 泄露表现 如图所示,早期有部分同学参考了错误的文档配置 TTL,虽然JVM能够正常启动,初期运行也未表现异常,但在某些应用中,特别是DPP场景下, GC线程会突然异常活跃,导致 Pod 的 CPU使用率迅速飙升 。
此类情况下兜底策略被频繁触发,触发频率甚至上涨了数十倍,只能通过紧急重启来缓解,严重时甚至需要 24 小时轮班人工值守。当时多个 DPP 场景平均每 3 小时就要重启一次。
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 {// ←←← 注册 Transformerinst.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 就无法再改它的字节码。
TTL 透传失效致内存泄漏 如果 TTL线程池字节码增强失败,TransmittableThreadLocal 在各线程池线程切换时将无法自动透传。而 promise 的TTLScope.scopeManager.ttlScope 正是TransmittableThreadLocal。
透传失效会导致关键的退出逻辑无法执行【如:if (scopeManager.ttlScope.get() != this)】,进而引发内存泄漏问题。
官方解法 确保 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 上就已回答过大家,标准的使用方式是这样:
实用工具 异常发生时,人工取证受干扰多、难度高。
现已在 ZJDK 镜像内置 JEX,一键抓取,显著提升取证效率与成功率。 JEX (Java Execution Extractor) 工具: 可一键快速抓取 Java [gc、thread、dump、jfr、env、dmesg、cgroup] 现场信息,并打包上传OSS。 使用方法:
故障现象 某日上午11点左右,t-ts-main-clk-v12-sek(交易主搜精排)突然出现一批 Pod 的 CPU 飙升。我们紧急使用 JEX 工具,捕获到一个All In One的现场包。
故障分析 分析GC日志 分析GC日志,只能看到判断是内存瞬间打满,触发了大量GC线程疯狂吃算力。
结论:内存一定有问题,究竟谁在吃内存?
分析内存dump 分析链路:
- 泄露源:内存大量被 ConcurrentHashMap 占用
- 溯源一:大对象 ConcurrentHashMap 被封装在 itemFeature
- 溯源二:itemFeature 被 PredictorParam 持有
- 溯源三:PredictorParam 被 PredictorContext 持有
- 溯源四:PredictorContext 最终被ThreadLocal 持有,所属线程为:predict-bound-task
- 观察 predict-bound-task 线程栈,主要是调下游做 Tensorflow 特征推理。
- 下面有 TtlRunnable 装饰,也就是意味着 这里必有线程切换。
- TtlRunnable.run 展开,结合Neuron源码分析得出右图链路。
- 1个TFS调用需X组线程Y次协作完成,每次均需要做TTL切换,无形中拖慢 ThreadLocal 释放时间。
注意 :该步骤基于“调用栈+业务代码”的联合推演。
脱离业务语义的栈信息通常只能描述现象,难以收敛到根因。这里多亏算法预估同学大力支持解惑。
线程栈火焰图不擅长展示异步链路,因此改用更直观的因果链路图:
仿真压测 注意挂载 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 | 204withTTL | 4995000 | 6.044 | 826381 | 1210-----------------
归因结论 栈与堆内证据表明:TTL 上下文捕获/回放在高并发与频繁线程切换时放大 CPU 开销。与相关方确认 Neuron 无 TTL-Agent 刚性依赖后,针对高频异常场景移除 Agent,经多轮压力灰度验证与多日观测,CPU 冲顶消失。
五 规范建议
- 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为例|得物技术
文 /药尘
关注得物技术,每周一、三更新技术干货 要是觉得文章对你有帮助的话,欢迎评论转发点赞~ 未经得物技术许可严禁转载,否则依法追究法律责任。 “ 扫码添加小助手微信 如有任何疑问,或想要了解更多技术资讯,请添加小助手微信: