EZLippi-浮生志

btrace动态追踪技术解析

开发环境定位问题手段较多,可以加日志、远程调试hotswap等,但在生产环境就没这么方便了,服务上线后就不能随便重启,比如某个接口有时候返回的数据异常,日志又没打印详情,这时候又想知道方法的入参是什么、是否调用了内部某个方法,或者接口响应时间较长想排查具体在哪个方法上调用比较耗时,这些场景都需要用到动态追踪的技术,btrace就是一个能帮助你分析和监控JVM的工具,采用了动态attach到目标JVM的方法,非侵入式监控,主要使用了JVMT(JVM Tool Interface)和Instrumentation技术,国内介绍btrace的文章并不多,最近正好要在部门内分享btrace的使用心得,因此整理了这篇文档,希望能够把btrace里的技术讲清楚。

btrace工作流程

btrace主要采用了Java Compiler API、ASM字节码修改技术、JVMT(JVM Tool Interface)和jdk1.6开始提供的Instrumentation技术,Java Compiler API用于在运行时把java源码编码成class文件;通过ASM字节码修改框架来实现对类的修改,通过tools.jar里提供的attach接口将btrace-agent 动态attach到目标JVM,实现非侵入式监控,btrace-agent会在目标JVM中创建一个Socket服务端,用于实现和btrace-client JVM的通信, btrace-agent会根据你的追踪脚本来生成字节码修改工具类,注册到ClassFileTransformer上,当JVM加载类时会调用ClassFileTransformer的transfrom方法(首次建立连接时会获取所有加载的类触发一次transform),btrace-agent会在transform()方法内对类的字节码进行修改,从而达到追踪的目标。

整个btrace的流程图如下所示:

Instrumentation技术简介

instrumentation技术提供了在运行时修改类的字节码的入口,你可以在启动脚本中通过-javaagent:jarpath[=options]选项添加到虚拟机参数中,jarpath是agent jar的路径,可以提供一些参数给agent,agent需要自己解析传递进来的参数,agent jar包的manifest文件必须包含Premain-Class属性,这个值定义了agent class的入口,JVM在初始化后会调用agent-class的premain方法,premain方法的定义如下:

1
public static void premain(String agentArgs, Instrumentation inst);

如果agent class没有实现上述方法,JVM会尝试调用下面这个重载方法:

1
public static void premain(String agentArgs);

同时你也可以在agent class中添加一个agentmain方法,这个方法主要是用于在JVM启动之后动态attach到目标JVM后调用的,如果agent是通过命令行参数加载的,则agentmain方法不会被调用;如果agent class无法加载或者agent class没有合适的premain方法,又或者premain方法内部抛出了未捕捉到的异常,JVM会退出。

如果需要在JVM启动之后动态attach agent到目标JVM,需要在agent jar包manifest文件包含Agent-Class属性,值为agent-class的全限定名称,agent class必须实agentmain方法,和premain方法类似,JVM会先尝试调用下面的agentmain方法:

1
public static void agentmain(String agentArgs, Instrumentation inst);

如果找不到上面的方法则尝试调用下面的重载方法:

1
public static void agentmain(String agentArgs);

btrace源码分析

btrace-client启动过程

使用btrace时需要给btrace脚本传递目标进程的pid以及用于追踪的脚本(java源码),这部分代码的入口在com.sun.btrace.client.Main类的main方法,btrace客户端启动后会先调用Java编译api将追踪的脚本编译成class文件,编译之后attach btrace-agent到目标进程,代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
//com.sun.btrace.client.Main
Client client = new Client(port, OUTPUT_FILE, PROBE_DESC_PATH,
DEBUG, TRACK_RETRANSFORM, TRUSTED, DUMP_CLASSES, DUMP_DIR, statsdDef);
if (! new File(fileName).exists()) {
errorExit("File not found: " + fileName, 1);
}
byte[] code = client.compile(fileName, classPath, includePath);
if (code == null) {
errorExit("BTrace compilation failed", 1);
}
client.attach(pid, null, classPath);

上面的includePath是通过-cp启动参数传递给btrace客户端进程的,用于把-cp指定的路径动态添加到目标虚拟机的bootClasspath上,attach方法先找到btrace-agent.jar的路径,然后继续:

1
2
3
4
5
6
7
8
9
10
//com.sun.btrace.client.Client
public void attach(String pid, String sysCp, String bootCp) throws IOException {
String agentPath = "/btrace-agent.jar";
String tmp = Client.class.getClassLoader().getResource("com/sun/btrace").toString();
tmp = tmp.substring(0, tmp.indexOf('!'));
tmp = tmp.substring("jar:".length(), tmp.lastIndexOf('/'));
agentPath = tmp + agentPath;
agentPath = new File(new URI(agentPath)).getAbsolutePath();
attach(pid, agentPath, sysCp, bootCp);
}

attach方法里先把tools.jar的路径找出来,这个路径后面要添加到systemClassPath(appClassLoader的加载路径)上,tools.jar是JDK的一个工具类库,包括javac、attach以及监控jvm的工具集比如jstack、jmap、jstat的入口都在这里面,如果没有tools.jar就无法执行这些命令,然后通过VirtualMachine的attach方法获取到目标虚拟机,最后调用loadAgent方法将btrace-agent动态加载,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//com.sun.btrace.client.Client
VirtualMachine vm = null;
vm = VirtualMachine.attach(pid);
String toolsPath = getToolsJarPath(
serverVmProps.getProperty("java.class.path"),
serverVmProps.getProperty("java.home")
);
if (sysCp == null) {
sysCp = toolsPath;
} else {
sysCp = sysCp + File.pathSeparator + toolsPath;
}
agentArgs += ",systemClassPath=" + sysCp;
vm.loadAgent(agentPath, agentArgs);

btrace-agent初始化过程

前面将btrace-agent.jar attach到目标jvm后,jvm会调用btrace-agent.jar的Manifest文件中的Agent-Class的agentMain方法,manifest文件内容如下:

1
2
3
4
5
6
Manifest-Version: 1.0
Premain-Class: com.sun.btrace.agent.Main
Agent-Class: com.sun.btrace.agent.Main
Can-Redefine-Classes: true
Can-Retransform-Classes: true
Boot-Class-Path: btrace-boot.jar

上面几个参数的作用简单讲一下:

  1. Premain-Class,前面提到过,包含了premain方法的类的全限定类名,JVM启动时调用premain-class的premain方法,如果是通过-javaagent参数传递的,该参数为必须项
  2. Agent-Class,和Premain-Class类似,动态attach到JVM时是必须参数
  3. Boot-Class-Path,可选参数,表示需要添加给bootstrap ClassLoader进行加载的路径,如果有多个路径通过空格进行分割
  4. Can-Redefine-Classes,可选参数,该agent是否需要重定义类,默认为false
  5. Can-Retransform-Classes,可选参数,该agent是否需要对字节码修改,默认为false

agentMain方法首先解析btrace-client传递进来的参数,启动追踪脚本,然后会启动一个socket服务端用来和btrace-client进行通信,JVM在调用agentMain方法时会传递一个Instrumentation对象进来,Btrace就是通过Instrumentation来做文章,下面代码的最后面agent给Instrumentation添加了一个BTraceTransformer,这个BTraceTransformer继承自java.lang.instrument.ClassFileTransformer类,用于对类的字节码进行修改,agentMain的主要代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
private static synchronized void main(final String args, final Instrumentation inst) {
//把Instrumentation引用赋值给inst变量
Main.inst = inst;
try {
loadArgs(args);
//解析参数
parseArgs();
//启动脚本
int startedScripts = startScripts();
//另起线程启动socketServer监听客户端连接
Thread agentThread = new Thread(new Runnable() {
@Override
public void run() {
BTraceRuntime.enter();
try {
startServer();
} finally {
BTraceRuntime.leave();
}
}
});
} finally {
//添加transformer到Instrumentation
inst.addTransformer(transformer, true);
}

startScripts()方法内部调用了loadBTraceScript()来加载btrace脚本,然后初始化ClientContext和FileClient对象,最后调用handleNewClient()方法:

1
2
3
4
5
6
7
8
9
10
11
private static boolean loadBTraceScript(String filePath, boolean traceToStdOut) {
SharedSettings clientSettings = new SharedSettings();
clientSettings.from(settings);
clientSettings.setClientName(scriptName);
ClientContext ctx = new ClientContext(inst, transformer, clientSettings);
Client client = new FileClient(ctx, traceScript);
if (client.isInitialized()) {
handleNewClient(client).get();
return true;
}
}

handleNewClient方法内部会调用client.retransformLoaded()来将所有的类进行替换,替换时先获取JVM加载的所有类,然后过滤那些不可修改的以及不在候选范围内的类,也就是说只会对匹配到的类进行替换,比如替换你的Btrace脚本的OnMethod方法里引用的clazz,通过ASM插入一些追踪的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
void retransformLoaded() throws UnmodifiableClassException {
if (runtime != null) {
if (probe.isTransforming() && settings.isRetransformStartup()) {
ArrayList<Class> list = new ArrayList<>();
ClassCache cc = ClassCache.getInstance();
for (Class c : inst.getAllLoadedClasses()) {
if (c != null) {
cc.get(c);
if (inst.isModifiableClass(c) && isCandidate(c)) {
debugPrint("candidate " + c + " added");
list.add(c);
}
}
}
list.trimToSize();
int size = list.size();
if (size > 0) {
Class[] classes = new Class[size];
list.toArray(classes);
//调用BTraceTransformer执行修改
inst.retransformClasses(classes);
}
}
}
}

在FileClient初始化过程中会去编译btrace追踪脚本,首先调用readScript()把文件转换成字节数组,然后调用init方法,init方法内部把字节数组封装成一个InstrumentCommand对象,最后调用loadClass()方法来完成btrace脚本的加载,loadClass()方法内部创建了一个BTraceProbePersisted,一个Probe相当于是一个探针,探测具体方法的调用,最后把probe注册到BTraceTransformer上,BTraceTransformer对象里会保存所有的Probe列表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
FileClient(ClientContext ctx, File scriptFile) throws IOException {
super(ctx);
if (!init(readScript(scriptFile))) {
debug.warning("Unable to load BTrace script " + scriptFile);
}
}
private boolean init(byte[] code) throws IOException {
InstrumentCommand cmd = new InstrumentCommand(code, new String[0]);
boolean ret = loadClass(cmd, canLoadPack) != null;
if (ret) {
super.initialize();
}
return ret;
}
protected final Class loadClass(InstrumentCommand instr, boolean canLoadPack) throws IOException {
//从InstrumentCommand对象中获取字节数组
String[] args = instr.getArguments();
this.btraceCode = instr.getCode();
//创建BTraceProbePersisted
probe = load(btraceCode, canLoadPack);
this.runtime = new BTraceRuntime(probe.getClassName(), args, this, debug, inst);
//最后调用register方法把probe注册到transformer上
return probe.register(runtime, transformer);

}

最后来看下probe的register()方法的实现,主要是调用BTraceTransformer.register()方法注册一个probe,然后调用了BTraceProbeSupport的defineClass来加载追踪脚本,实际上是通过Unsafe类来加载的,也就是说追踪脚本类是由JVM的启动类加载器加载的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public Class register(BTraceRuntime rt, BTraceTransformer t) {
byte[] code = dataHolder;
Class clz = delegate.defineClass(rt, code);
//调用BTraceTransformer.register()方法注册一个probe
t.register(this);
this.transformer = t;
this.rt = rt;
return clz;
}
private Class defineClassImpl(byte[] code, boolean mustBeBootstrap) {
ClassLoader loader = null;
if (! mustBeBootstrap) {
loader = new ClassLoader(null) {};
}
Class cl = unsafe.defineClass(className, code, 0, code.length, loader, null);
unsafe.ensureClassInitialized(cl);
return cl;
}

ClassFileTransformer实现修改类

最后我们来看一下最为关键的BTraceTransformer类的实现,JDK 1.6提供的Instrument技术新增了java.lang.instrument.ClassFileTransformer接口,所有的要加载到JVM中的transformer都要实现这个接口,并重写transform()方法,JVM在加载类的时候会把该类对应的ClassLoader和字节数组传递给transform()方法,实现类可以修改字节数字并把修改后的值返回,需要特别注意的是btrace先会过滤掉classLoader为null(由引导类加载器加载的类,大部分为JVM的核心类库)和系统类加载器加载的类,主要是出于保护JVM核心功能的目的,通过ASM来实现对类的修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public synchronized byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
if (probes.isEmpty()) return null;
className = className != null ? className : "<anonymous>";

if ((loader == null || loader.equals(ClassLoader.getSystemClassLoader())) && isSensitiveClass(className)) {
return null;
}

if (filter.matchClass(className) == Filter.Result.FALSE) return null;

boolean entered = BTraceRuntime.enter();
try {
BTraceClassReader cr = InstrumentUtils.newClassReader(loader, classfileBuffer);
BTraceClassWriter cw = InstrumentUtils.newClassWriter(cr);
for(BTraceProbe p : probes) {
cw.addInstrumentor(p, loader);
}
byte[] transformed = cw.instrument();
if (transformed == null) {
// no instrumentation necessary
return classfileBuffer;
}
return transformed;
} catch (Throwable th) {
throw th;
} finally {
if (entered) {
BTraceRuntime.leave();
}
}
}

前面总结了btrace的工作流程,需要注意的是,btrace监控退出后,原先所有的class都不会被恢复,你的所有的监控代码依然一直在运行,同时为了减少对目标JVM的影响,btrace对追踪脚本做了较多限制,比如不能创建新对象和数组,不能捕捉和抛出异常等,btrace-client在编译完追踪脚本之后会进行校验,校验的详细内容在com.sun.btrace.compilerVerifier类中,感兴趣的同学可以看看, 在btrace-agent端也会通过com.sun.btrace.runtime.instr.MethodInstrumentor类及其子类进行校验,尽量保证我们监控代码的安全。

参考文档:

  1. VirtualMachine
  2. instrument
  3. JVMTI 和 Agent 实现
  4. btrace一些你不知道的事
🐶 您的支持将鼓励我继续创作 🐶