用 Java Agent 排查线上性能问题,核心是在不重启、不改业务代码的前提下,通过 -javaagent 静态挂载或 agentmain 动态 attach,把字节码增强/采样式 profiler 注入到目标 JVM,拿到方法级耗时、火焰图、线程栈和慢调用链路。它解决的是「日志不够细、jstack 只能看瞬间、重启代价太大」这三件事。
Java Agent 到底是什么,和看日志、jstack 有什么不同?
结论:Java Agent 是一段运行在目标 JVM 里的特殊程序,它通过 java.lang.instrument.Instrumentation 接口获得改写类字节码的能力,能在方法进入/退出时插入计时、计数、上下文传递逻辑。
普通日志是业务代码自己打的,粒度取决于开发埋点;jstack 只能抓某一个瞬间的线程快照,抓不到「哪个方法慢」;而 Agent 是全局的、可编程的。它有两种加载方式:premain 在 JVM 启动时运行(静态),agentmain 在 JVM 已运行后运行(动态)。
区分一个概念:字节码增强是在方法体前后插入代码,属于「全量埋点」,覆盖完整;采样 profiler 是周期性抓线程栈,属于「统计推断」,开销低但看不到未被采样到的短调用。
怎么用 -javaagent 把 Agent 挂到 Java 进程上?
结论:关键是 jar 包 MANIFEST.MF 里的四个属性,以及一行 -javaagent 参数。
步骤:
- 写入口类,实现
public static void premain(String agentArgs, Instrumentation inst)。
- 在
MANIFEST.MF 中声明:
Premain-Class: com.example.MyAgent
Agent-Class: com.example.MyAgent
Can-Redefine-Classes: true
Can-Retransform-Classes: true
Can-Retransform-Classes: true 是动态增强的前提,缺了它 retransformClasses 会直接抛异常。
- 启动时挂载:
java -javaagent:/opt/agent/my-agent.jar=key=value -jar app.jar,等号后面的内容会作为 agentArgs 字符串传进 premain。
- 用 ByteBuddy 做增强时,核心逻辑是
new AgentBuilder.Default().type(ElementMatchers.nameStartsWith("com.your.app")).transform(...).installOn(inst)。
已经上线的进程不能重启,怎么动态挂载?
结论:用 Attach API 向目标 PID 发送 load agent 指令,JVM 会调用 jar 包里的 agentmain 方法,整个过程不需要重启。
JDK 8 需要依赖 tools.jar,代码大致是:
VirtualMachine vm = VirtualMachine.attach("12345");
vm.loadAgent("/opt/agent/my-agent.jar", "opt=value");
vm.detach();
JDK 9 之后 Attach 能力被拆进 jdk.attach 模块,默认可用;如果要在一个 JVM 里 attach 自己,需要加 -Djdk.attach.allowAttachSelf=true。
生产中更省事的做法是用现成工具:Arthas 通过 java -jar arthas-boot.jar 选择目标进程后,直接执行 trace com.xxx.Service methodName 看方法耗时链路、watch 看入参出参、profiler start 采 CPU 火焰图、tt 做调用录像。这些命令底层都走动态 attach + 字节码增强。
排查 CPU 飙高和接口变慢,Agent 该采集什么?
结论:CPU 问题优先用采样,接口慢问题优先用增强埋点,两者结合定位最快。
- CPU 飙高:
./profiler.sh -d 30 -e cpu -f /tmp/flame.html <pid>(async-profiler)采 30 秒生成火焰图;或 jcmd <pid> JFR.start duration=60s filename=rec.jfr 拿 JDK 自带 Flight Recorder 记录。采样型工具对性能影响通常在 2% 以内,适合生产。
- 接口变慢:用 ByteBuddy 对 Controller/Service 层方法插桩,记录耗时 P99、异常率和调用链,能直接看到是哪个下游方法吃掉了时间。
- 线程卡住:
jstack <pid> 连续抓 5 次,间隔 5 秒,对比哪些线程栈一直停在同一个位置,那就是阻塞点。
- 内存/GC:
jstat -gcutil <pid> 1000 看 GC 频率,配合 jmap -histo:live <pid> 看对象占用。
线上用 Agent 有哪些必须注意的坑?
结论:增强范围要收窄、开销要压测、ClassLoader 要防泄漏,否则排查工具本身会变成故障源。
第一,控制增强范围。只对目标包名前缀(如 com.your.app.service)做 transform,用 ElementMatchers.nameStartsWith 过滤,不要对所有类增强——对 java.* 和框架核心类插桩极易引发类加载死锁。
第二,注意 redefine 的能力边界。retransformClasses 只能改方法体,不能新增字段、新增方法、改方法签名,这些需求必须在类加载前完成。
第三,恢复现场。排查结束后要能卸载增强,避免长期挂着 Agent 造成内存和性能损耗;用反射持有的 Instrumentation 引用、ThreadLocal 缓存要注意及时清理。
第四,先灰度。任何增强逻辑上线前,在预发环境用同等 QPS 压测一轮,确认 TP99 没有明显抬升再上生产。
总结一下:Java Agent 的价值在于「不重启、不改码、方法级可见」。静态挂载用在启动参数,动态 attach 用在线上救火,CPU 类问题走采样 profiler,接口类问题走字节码埋点,最后一定要把增强范围和开销控制在可回滚的范围内。