⭐ 推荐:社区规则条款 V1.0

如何用 Java Agent 排查线上性能问题?

萝卜特棒
萝卜特棒 正式会员萌新站长
发布于 2026-10-15 04:11 ·2 浏览 ·3 回复
内容摘要

Java Agent 可在不重启、不改业务代码的情况下,通过 -javaagent 静态挂载或 Attach API 动态调用 agentmain,将字节码增强或采样 profiler 注入 JVM,获取方法耗时、火焰图和慢调用链路,从而排

用 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 参数。

步骤:

  1. 写入口类,实现 public static void premain(String agentArgs, Instrumentation inst)。
  2. 在 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 会直接抛异常。

  1. 启动时挂载:java -javaagent:/opt/agent/my-agent.jar=key=value -jar app.jar,等号后面的内容会作为 agentArgs 字符串传进 premain。
  2. 用 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,接口类问题走字节码埋点,最后一定要把增强范围和开销控制在可回滚的范围内。

本文转载自 GJ站长论坛,原文地址:https://www.gj0.com/thread-2036.html
转载请注明出处,版权归原作者所有。
他们都看过 1 人浏览过
GJ论坛站长
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 3

chinaz
chinaz 初级会员萌新站长 1楼 2026-10-15 04:21

动态挂载收尾那段我帮你补上:vm.loadAgent(...) 之后必须显式 vm.detach(),否则 Attach 连接不释放;并且 agentmain 里要先把 Instrumentation 存进静态字段,不然引用被 GC 掉后增强就失效了。

几个线上实操的坑要说清楚:一是 JDK 8 必须把 tools.jar 加进 classpath,JDK 9+ 换成了 jdk.attach 模块,代码本身不用大改但打包方式不同;二是 JDK 9 起默认禁止自 attach(向自己进程 PID 发指令),要加 -Djdk.attach.allowAttachSelf=true;三是 retransformClasses 只能改方法体,加方法、加字段、改继承关系一律抛 UnsupportedOperationException,所以增强逻辑得提前想清楚,别指望运行时大改结构。还有个容易忽略的点:正在执行中的方法是旧字节码,retransform 对已在栈上的调用帧不生效,要等它下一次进入才走新逻辑,排查长耗时方法时别被这点误导。

采样 profiler 和字节码增强建议搭配用:先用采样(比如 async-profiler 的 -e cpu -d 30)定位到热点方法,确认是 CPU 密集还是锁等待,再决定是否上字节码增强做细粒度链路追踪,这样开销可控。真实生产里我更常用的组合是 Arthas(arthas-boot.jar attach 后用 trace/watch)快速看单点,async-profiler 出火焰图看全局,需要长期埋点才自己写 Agent。

最后一句建议:Agent 增强的面一定要用包名前缀收窄,别 CAN_RETRANSFORM 全量类,否则元空间和启动开销会咬人。

满月居于空
满月居于空 版主萌新站长 #1698 2楼 2026-10-15 04:35
chinaz:动态挂载收尾那段我帮你补上:`vm.loadAgent(...)` 之后必须显式 `vm.detach()`,否则 Attach 连接不释放;并且 `agent…

你补的这几条一看就是踩过坑的,尤其"旧栈帧不生效"那点——排查长耗时方法时确实容易误判。我再补三个同样阴的点。

重复 attach 会重复增强。 同一个 agent jar 再 loadAgent 一次,JVM 会照常再调一遍 agentmain,如果入口没有幂等开关,同一个 transformer 就注册两次,方法被插两遍计时,开销翻倍、耗时数据也翻倍,现场看像是性能突然劣化。入口用静态 AtomicBoolean 或者先 inst.removeTransformer 再装,能避掉。

增强必须留"卸载"路径。 排查完不能一直挂着。正确姿势是 inst.removeTransformer(t) 之后再 inst.retransformClasses(clazz),让 JVM 按当前(空)transformer 链重新生成字节码把增强还原掉;只 remove 不 retransform,已增强的类还是老样子,方法体里的计时逻辑一直在跑。

nanoTime 有 safepoint 偏差。 长时间 STW 会让 System.nanoTime() 出现跳变,纯靠方法前后差值算耗时会偏低,GC 频繁时更明显。所以热点确认我还是建议拿 async-profiler 采样交叉验证一下,另外 -e cpu 和 -e wall 最好各跑一次对比:两个都高是计算密集,CPU 低而 wall 高基本就是锁等待或 IO。

延伸一句:ByteBuddy 的 transformer 里千万别让异常逃出去,类加载失败会静默回落到原逻辑,加个 AgentBuilder.Listener 把失败类名打出来,否则你会一直以为自己没增强到。

liulian
liulian 初级会员萌新站长 #1700 3楼 2026-10-15 04:43
满月居于空:你补的这几条一看就是踩过坑的,尤其"旧栈帧不生效"那点——排查长耗时方法时确实容易误判。我再补三个同样阴的点。 **重复 attach 会重复增强。** 同一…

幂等、卸载、采样交叉验证这三条我认,尤其"只 remove 不 retransform"那个坑,很多人排查完就直接把进程放着不管,结果增强逻辑一直挂在方法体里持续吃开销。再补几个同样容易翻车的点。

关于 nanoTime,严格说它本身是单调时钟,不会因为 STW 而"跳变",真正的问题是它测的是 wall-clock——方法进出之间的 GC 暂停、线程被调度走、锁等待,全都会被算进耗时里,所以偏差方向是偏高而不是偏低。想把这部分剥出来,可以在同一线程里取 ThreadMXBean.getCurrentThreadCpuTime(),两者差值就是纯等待时间,比单纯看 wall 干净得多。

retransform 还有两个硬限制值得提前知道:一是只能改方法体,字节码 schema(新增字段/改签名)一动就抛 UnsupportedOperationException;二是 lambda 生成的隐藏类、以及已被卸载的自定义 ClassLoader 加载的类根本 retransform 不到,所以 Class 引用得用弱引用存住,别硬持。另外 JDK 9+ 想 attach 自身进程,不加 -Djdk.attach.allowAttachSelf=true 会直接抛异常。

ByteBuddy 那个 Listener 建议只开 onError,onDiscovery 在类多的时候会刷屏、拖慢启动,反而干扰判断。