「Arthas 凭什么不用改代码就能看到方法耗时」
2 月初排查一个慢接口,我用 Arthas 的 trace 打出了每一层调用的耗时。旁边一个刚工作两年的同事看完很惊讶:我们没加任何埋点,也没改代码,它怎么知道每个方法花了多久?
这个问题一两句话讲不清楚,正好我那几天在给公司内部的压测平台做一个无侵入的耗时采集,就把相关东西整理了一遍。我们环境是 JDK 8 和 JDK 11 混跑,Arthas 3.5.6。
先分清 JVM TI 和 Java Agent
这两个经常被混为一谈,其实不是一个层面的东西。
JVM TI(JVM Tool Interface) 是 JVM 暴露给外部工具的一层 native C 接口。它在 JVM 内部有个 JvmtiEnv 结构,工具通过它拿到一堆能力(capabilities)和事件(events):
// native agent 示例,C 语言
#include <jvmti.h>
JNIEXPORT jint JNICALL Agent_OnLoad(JavaVM *vm, char *options, void *reserved) {
jvmtiEnv *jvmti;
(*vm)->GetEnv(vm, (void **)&jvmti, JVMTI_VERSION_1_2);
jvmtiCapabilities caps = {0};
caps.can_generate_method_entry_events = 1; // 方法进入事件
caps.can_generate_method_exit_events = 1; // 方法退出事件
caps.can_get_bytecodes = 1;
caps.can_generate_all_class_hook_events = 1; // ClassFileLoadHook
(*jvmti)->AddCapabilities(jvmti, &caps);
jvmtiEventCallbacks callbacks = {0};
callbacks.MethodEntry = &onMethodEntry;
callbacks.MethodExit = &onMethodExit;
callbacks.ClassFileLoadHook = &onClassLoad;
(*jvmti)->SetEventCallbacks(jvmti, &callbacks, sizeof(callbacks));
(*jvmti)->SetEventNotificationMode(jvmti, JVMTI_ENABLE,
JVMTI_EVENT_METHOD_ENTRY, NULL);
return JNI_OK;
}
JVMTI 能做的事非常多:GetAllThreads、ForceGarbageCollection、GetObjectSize、SetBreakpoint、IterateOverHeap。你熟悉的 jstack、jmap、jprofiler、Arthas 的线程和堆内存相关命令,底层都是它。
但 JVMTI 要求你写 C/C++,还要针对不同平台编译 .so / .dll,成本太高。Java Agent 是 JVM 在这个基础上提供的一层 Java 封装:JDK 自带一个 libinstrument.so(在 $JAVA_HOME/lib/ 下),它本身是个 JVMTI agent,加载后把能力以 java.lang.instrument.Instrumentation 接口的形式暴露给 Java 代码。
$ ls $JAVA_HOME/lib/ | grep -i instrument
libinstrument.dylib # macOS
# Linux 下是 libinstrument.so,Windows 下是 instrument.dll
关键在于 Instrumentation 只暴露了 JVMTI 能力里的一小部分——主要是 ClassFileLoadHook(对应类转换)和 RetransformClasses / RedefineClasses。所以 Java Agent 擅长的是"改字节码",不擅长"看内存"。Arthas 是两者结合:trace / watch 用增强,heapdump / thread 走的是 Attach API 调 jmap / jstack 那套。
premain:启动时加载
最简单的 agent 只需要两样东西:一个类,一个 MANIFEST。
package com.xxx.agent;
import java.lang.instrument.Instrumentation;
public class TimingAgent {
// 启动时调用,JVM 会在 main() 之前调用它
public static void premain(String agentArgs, Instrumentation inst) {
System.out.println("[agent] premain, args=" + agentArgs);
inst.addTransformer(new TimingTransformer(), true); // true = 允许 retransformation
}
}
package com.xxx.agent;
import java.lang.instrument.ClassFileTransformer;
import java.security.ProtectionDomain;
public class TimingTransformer implements ClassFileTransformer {
@Override
public byte[] transform(ClassLoader loader,
String className,
Class<?> classBeingRedefined,
ProtectionDomain protectionDomain,
byte[] classfileBuffer) {
if (className == null || !className.startsWith("com/xxx/order/")) {
return null; // 返回 null 表示不修改
}
return enhance(classfileBuffer, loader);
}
}
清单文件:
Manifest-Version: 1.0
Premain-Class: com.xxx.agent.TimingAgent
Can-Retransform-Classes: true
Can-Redefine-Classes: true
Maven 侧用 maven-jar-plugin 自动写 manifest:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<configuration>
<archive>
<manifestEntries>
<Premain-Class>com.xxx.agent.TimingAgent</Premain-Class>
<Can-Retransform-Classes>true</Can-Retransform-Classes>
</manifestEntries>
</archive>
</configuration>
</plugin>
使用:
$ java -javaagent:/data/agent/timing-agent.jar=level=DEBUG -jar order-service.jar
[agent] premain, args=level=DEBUG
premain 的时机是在 main 方法执行之前,此时大部分业务类还没加载,所以 transformer 能拦到它们的首次加载。缺点是必须重启应用。
agentmain:运行时挂载
Arthas 那种"随时 attach 上去"的能力来自 agentmain + Attach API。
public class TimingAgent {
// 运行时 attach 调用
public static void agentmain(String agentArgs, Instrumentation inst) {
System.out.println("[agent] agentmain, args=" + agentArgs);
inst.addTransformer(new TimingTransformer(), true);
// 关键:已经加载过的类不会重新触发 ClassFileLoadHook,必须主动 retransform
for (Class<?> c : inst.getAllLoadedClasses()) {
if (c.getName().startsWith("com.xxx.order.")) {
try {
inst.retransformClasses(c);
} catch (UnmodifiableClassException e) {
// 数组、原始类型、某些 JVM 内部类不能改
}
}
}
}
}
MANIFEST 换一个键:
Agent-Class: com.xxx.agent.TimingAgent
Can-Retransform-Classes: true
Can-Redefine-Classes: true
挂载程序(Arthas 的 as.sh 本质上就是干这个):
package com.xxx.agent;
import com.sun.tools.attach.VirtualMachine;
import com.sun.tools.attach.VirtualMachineDescriptor;
public class Attacher {
public static void main(String[] args) throws Exception {
String pid = args[0];
String agentJar = args[1];
VirtualMachine vm = VirtualMachine.attach(pid);
try {
vm.loadAgent(agentJar, "level=DEBUG");
} finally {
vm.detach();
}
}
}
$ jps
18231 order-service.jar
$ java -cp $JAVA_HOME/lib/tools.jar:attacher.jar com.xxx.agent.Attacher \
18231 /data/agent/timing-agent.jar
[agent] agentmain, args=level=DEBUG
JDK 9 之后 tools.jar 被移除,VirtualMachine 在 jdk.attach 模块里,需要在 module-info.java 里 requires jdk.attach;。
attach 的底层机制是信号 + Unix domain socket:目标 JVM 收到 SIGQUIT(其实是专门的一个)之后,会启动一个 Attach Listener 线程,监听 /tmp/.java_pid<pid> 这个 socket,然后加载 libinstrument,再调用 agentmain。所以:
$ ls -la /tmp/.java_pid18231
srwx------ 1 app app 0 Feb 5 10:23 /tmp/.java_pid18231
如果容器里没有 /tmp 的写权限,或者两个容器没共享 PID namespace,attach 会失败。我们在 K8s 上就踩过:agent 容器和业务容器不共享 PID namespace,jps 看不到业务进程,得配置 shareProcessNamespace: true。
怎么改字节码:ASM 和 ByteBuddy
ASM:直接操作指令
ASM 是最底层的选择,它把 class 文件解析成事件流(ClassReader → ClassVisitor → ClassWriter)。给方法前后加计时的写法:
public byte[] enhance(byte[] classfileBuffer) {
ClassReader cr = new ClassReader(classfileBuffer);
ClassWriter cw = new ClassWriter(cr, ClassWriter.COMPUTE_MAXS | ClassWriter.COMPUTE_FRAMES);
cr.accept(new ClassVisitor(Opcodes.ASM9, cw) {
@Override
public MethodVisitor visitMethod(int access, String name, String desc,
String signature, String[] exceptions) {
MethodVisitor mv = super.visitMethod(access, name, desc, signature, exceptions);
if ("<init>".equals(name) || "<clinit>".equals(name)) return mv;
return new MethodVisitor(Opcodes.ASM9, mv) {
@Override public void visitCode() {
mv.visitMethodInsn(INVOKESTATIC, "com/xxx/agent/Recorder",
"start", "()V", false);
mv.visitCode();
}
@Override public void visitInsn(int opcode) {
if (opcode >= IRETURN && opcode <= RETURN || opcode == ATHROW) {
mv.visitMethodInsn(INVOKESTATIC, "com/xxx/agent/Recorder",
"end", "()V", false);
}
mv.visitInsn(opcode);
}
};
}
}, 0);
return cw.toByteArray();
}
这段代码的坑在 visitInsn:RETURN 的 opcode 值是 177,而 IRETURN 到 RETURN 是 172~177,但中间 LRETURN(173)、DRETURN(175) 这些可能会占用两个栈槽。手写指令的时候必须保证栈帧一致,否则 VerifyError。
我第一次跑就炸了:
java.lang.VerifyError: Expecting a stackmap frame at branch target 27
Exception Details:
Location: com/xxx/order/OrderService.getById(J)Lcom/xxx/Order; @27: aload_0
Reason: Expected stackmap frame at this location.
原因是 JDK 7 之后 class 文件必须有 StackMapTable。解决办法是给 ClassWriter 传 COMPUTE_FRAMES,让它自动重算,代价是性能慢 30% 左右,但它只在类加载时跑一次。
ByteBuddy:写起来像写 Java
我现在基本都用 ByteBuddy(1.12.6),它把 ASM 包了一层,用流畅 API 描述"要做什么"而不是"每条指令怎么写":
new AgentBuilder.Default()
.type(ElementMatchers.nameStartsWith("com.xxx.order."))
.transform((builder, typeDescription, classLoader, module, protectionDomain) ->
builder.method(ElementMatchers.any())
.intercept(MethodDelegation.to(TimingInterceptor.class)
.andThen(SuperMethodCall.INSTANCE)))
.with(AgentBuilder.RedefinitionStrategy.RETRANSFORMATION)
.with(AgentBuilder.Listener.Adapter.streamWritingToSystemError()
.withTransformationsOnly())
.installOn(inst);
public class TimingInterceptor {
@RuntimeType
public static Object intercept(@Origin Method method,
@SuperCall Callable<?> callable) throws Exception {
long t0 = System.nanoTime();
try {
return callable.call(); // 调原方法
} finally {
Recorder.record(method, System.nanoTime() - t0);
}
}
}
@SuperCall Callable<?> 是 ByteBuddy 的语法糖,它生成一个额外的辅助类持有原方法的调用。
ByteBuddy 会为每个被增强的类生成一个 OrderService$auxiliary$xxx 之类的辅助类,默认注入到被增强类的同一个 ClassLoader 下。这在 OSGi / 自定义 ClassLoader 环境下可能出问题,可以指定注入到 bootstrap:
.with(new InjectionStrategy.UsingUnsafe.OfBootstrapLoader())
增强的硬限制
这是必须记住的一条,JVM 规范层面就不允许:
java.lang.UnsupportedOperationException: class redefinition failed:
attempted to change the schema (add/remove fields)
java.lang.UnsupportedOperationException: class redefinition failed:
attempted to change method modifiers / delete a method
retransform 时:
- 可以改方法体里的指令
- 不能增删字段、不能增删方法、不能改方法签名(参数类型、返回类型)
- 不能改类的继承关系、访问修饰符
这是 JVM TI 的 RetransformClasses 明确规定的。Arthas 的 trace 只在方法体前后插桩,所以不违反;但你想"给这个类加个字段存耗时",只能另想办法(比如用一个全局的 ThreadLocal)。
还有几个我踩过的坑:
1. transform 里不能加载正在转换的类
ClassFileTransformer.transform() 执行时,这个类正处于加载过程中。如果你在 transformer 里 Class.forName(className),会死锁或者得到 ClassCircularityError。判断类名一律用字符串匹配,别用反射。
2. bootstrap classpath 问题
agent 的 Recorder 类要被业务类调用,而业务类是 AppClassLoader 加载的,它看不到 agent jar 里的类。两种解法:
// 方法一:把 agent 包追加到 bootstrap classpath
Boot-Class-Path: timing-agent.jar
// 方法二:运行时手动追加
inst.appendToBootstrapClassLoaderSearch(new JarFile("/data/agent/timing-agent.jar"));
我倾向第二种,因为它不依赖 MANIFEST 里的相对路径(Boot-Class-Path 是相对于 agent jar 的位置解析的,容易写错)。
3. 增强 JDK 核心类
增强 java.* 下的类(比如给 HashMap.get 插桩)需要额外小心,因为 transformer 本身在加载 JDK 类时就会被调用,很容易递归。ByteBuddy 的默认配置会忽略 bootstrap loader 加载的类,要显式打开:
.ignore(none())
Arthas 是怎么做的
翻了一下 Arthas 3.5.6 的源码,它的结构大致是:
arthas-agent → 只有 agentmain / premain,负责启动 spy 和加载 core
arthas-core → 真正干活的,包含各种 command
arthas-spy → 增强时注入到业务方法里的钩子(SpyAPI)
Enhancer 类用 ByteBuddy 给目标方法前后插桩,插入的是 SpyAPI.atEnter() 和 SpyAPI.atExit() 的调用,这些方法内部通过 Advice 回调到 core 里的监听器。它在 AdviceListenerManager 里用 ThreadLocal 存每次调用的上下文,对应关系 ID。
// arthas-spy 里的钩子,被注入到业务方法中
public class SpyAPI {
public static void atEnter(Class<?> clazz, String methodInfo,
Object target, Object[] args) { ... }
public static void atExit(Class<?> clazz, String methodInfo,
Object target, Object[] args, Object returnObject) { ... }
public static void atExceptionExit(...) { ... }
}
所以 trace 的耗时就是 atEnter 记一个 System.nanoTime(),atExit 再记一个,两者相减。原理不复杂,难的是怎么保证正确的栈帧、怎么避免影响原逻辑、怎么在 reset 的时候把字节码还原回去。
先到这
《JVM TI 与字节码增强:Arthas 背后的技术》这块我前前后后踩了不止一次。今天先写这些,后面想到新的再补。