Administrator
发布于 2022-02-05 / 1420 阅读
28

JVM TI 与字节码增强:Arthas 背后的技术

「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 能做的事非常多:GetAllThreadsForceGarbageCollectionGetObjectSizeSetBreakpointIterateOverHeap。你熟悉的 jstackjmapjprofilerArthas 的线程和堆内存相关命令,底层都是它。

但 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 APIjmap / 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 被移除,VirtualMachinejdk.attach 模块里,需要在 module-info.javarequires 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 文件解析成事件流(ClassReaderClassVisitorClassWriter)。给方法前后加计时的写法:

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();
}

这段代码的坑在 visitInsnRETURN 的 opcode 值是 177,而 IRETURNRETURN 是 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。解决办法是给 ClassWriterCOMPUTE_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 背后的技术》这块我前前后后踩了不止一次。今天先写这些,后面想到新的再补。

参考