Arthas原理系列(一):利用JVM的attach机制实现一个极简的watch命令
历史文章推荐
- OGNL语法规范
- 消失的堆栈
- Arthas原理系列(一):利用JVM的attach机制实现一个极简的watch命令
- Arthas原理系列(二):总体架构和项目入口
- Arthas原理系列(三):服务端启动流程
- Arthas原理系列(四):字节码插装让一切变得有可能
前言
在深入到 Arthas 的原理之前我们先看一个有趣的示例,我们依然使用前面文章中用到的代码示例,
public static void main(String[] args) {
for (int i = 0; i < Integer.MAX_VALUE; i++) {
System.out.println("times:" + i + " , result:" + testExceptionTrunc());
}
}
public static boolean testExceptionTrunc() {
try {
// 人工构造异常抛出的场景
((Object)null).getClass();
} catch (Exception e) {
if (e.getStackTrace().length == 0) {
try {
// 堆栈消失的时候当前线程休眠5秒,便于观察
Thread.sleep(5000);
} catch (InterruptedException interruptedException) {
// do nothing
}
return true;
}
}
return false;
}
}
运行这段代码,然后我们使用 Arthas 的tt
命令记录testExceptionTrunc
的每次调用的情况
tt -t com.idealism.demo.DemoApplication testExceptionTrunc
再新开一个窗口,打开 Arthas,使用dump
命令把正在运行的字节码输出到本地文件后查看此时的字节码
可以看到,现在正在运行的字节码和我们从源码编译过来的相比多了两行,多的这两行正是 Arthas 插装的代码,Arthas 的一切魔法都从这里开始。
实现一个极简的 watch 命令
给运行中的代码插装新的代码片段,这个特性 JVM 从 SE6 就已经开始支持了,所有有关代码插装的 API 都在java.lang.instrument.Instrumentation
这个包中。有了 JVM 的支持和 Arthas 的启发,我们可以借助代码插装实现一个极简版的watch
命令,这样的一个小工具有以下特点:
- 可以统计被插装方法的运行时间
- 被插装的代码不感知该工具的存在,该工具动态 attach 到目标类的 JVM 中
- 为了示例简单明了,只实现计时功能,不实现
watch
的其他功能
由于插装代码是一件过于底层且需要对字节码有很高的掌握度,所以我们引入了一个二方包javassist
来做具体的插装工作,maven 坐标如下:
<dependency>
<groupId>org.javassist</groupId>
<artifactId>javassist</artifactId>
<version>3.21.0-GA</version>
</dependency>
我们会使用 javassist 最基础的功能,详细的使用教程请参考https://www.baeldung.com/javassist我们的目标类是我们前面文章中一直使用的 Demo 类,代码如下:
public class DemoApplication {
private static Logger LOGGER = LoggerFactory.getLogger(DemoApplication.class);
public static void main(String[] args) {
for (int i = 0; i < Integer.MAX_VALUE; i++) {
// System.out.println("times:" + i + " , result:" + testExceptionTrunc());
testExceptionTruncate();
}
}
public static void testExceptionTruncate() {
try {
// 人工构造异常抛出的场景
((Object)null).getClass();
} catch (Exception e) {
if (e.getStackTrace().length == 0) {
System.out.println("stack miss;");
try {
// 堆栈消失的时候当前线程休眠5秒,便于观察
Thread.sleep(5000);
} catch (InterruptedException interruptedException) {
// do nothing
}
}
}
System.out.println("stack still exist;");
}
}
为了方便插装代码打印日志,我们引入了一个静态的LOGGER
,并且将testExceptionTruncate
改为返回void
类型的返回值,这样的改动让代码插装更加简单。如何让两个运行中的 JVM 建立连接呢,JVM 通过attach api
支持了这种场景
public static void run(String[] args) {
String agentFilePath = "/Users/jnzh/Documents/Idea Project/agent/out/agent.jar";
String applicationName = "com.idealism.demo.DemoApplication";
//iterate all jvms and get the first one that matches our application name
Optional<String> jvmProcessOpt = Optional.ofNullable(VirtualMachine.list()
.stream()
.filter(jvm -> {
LOGGER.info("jvm:{}", jvm.displayName());
return jvm.displayName().contains(applicationName);
})
.findFirst().get().id());
if(!jvmProcessOpt.isPresent()) {
LOGGER.error("Target Application not found");
return;
}
File agentFile = new File(agentFilePath);
try {
String jvmPid = jvmProcessOpt.get();
LOGGER.info("Attaching to target JVM with PID: " + jvmPid);
VirtualMachine jvm = VirtualMachine.attach(jvmPid);
jvm.loadAgent(agentFile.getAbsolutePath());
jvm.detach();
LOGGER.info("Attached to target JVM and loaded Java agent successfully");
} catch (Exception e) {
throw new RuntimeException(e);
}
}
运行这个方法的 JVM 通过名称匹配目标 JVM,然后通过attach
方法与目标 JVM 取得联系,继而对目标 JVM 发出指令,让其挂载插装agent
,整个过程如下图所示:
在我们反复提的 agent 里,我们才真正做代码插装的工作,attach API
中要求,被目标代码挂载的agent
包必须实现agentmain
且在打包的 MANIFEST.MF 中指定 Agent-Class 属性,完整的 MANIFEST.MF 文件如下所示:
Main-Class: com.idealism.agent.AgentApplication
Agent-Class: com.idealism.agent.AgentApplication
Can-Redefine-Classes: true
Can-Retransform-Classes: true
有个小插曲,用 IDEA 打 JAR 包的时候,指定的 MANIFEST.MF 的路径到${ProjectName}/src
就可以,默认的需要删掉框中的路径,否则,打出来的 MANIFEST.MF 文件不会生效。
在agentmain
方法中我们实现了对目标类的插装,目标 JVM 在被attach
后会自动调用这个方法:
public static void agentmain(String agentArgs, Instrumentation inst) {
LOGGER.info("[Agent] In agentmain method");
String className = "com.idealism.demo.DemoApplication";
transformClass(className,inst);
}
transformClass
方法做了一层转发:
private static void transformClass(String className, Instrumentation instrumentation) {
Class<?> targetCls = null;
ClassLoader targetClassLoader = null;
// see if we can get the class using forName
try {
targetCls = Class.forName(className);
targetClassLoader = targetCls.getClassLoader();
transform(targetCls, targetClassLoader, instrumentation);
return;
} catch (Exception ex) {
LOGGER.error("Class [{}] not found with Class.forName");
}
// otherwise iterate all loaded classes and find what we want
for(Class<?> clazz: instrumentation.getAllLoadedClasses()) {
if(clazz.getName().equals(className)) {
targetCls = clazz;
targetClassLoader = targetCls.getClassLoader();
transform(targetCls, targetClassLoader, instrumentation);
return;
}
}
throw new RuntimeException("Failed to find class [" + className + "]");
}
最后的流程会调用方法:
private static void transform(Class<?> clazz, ClassLoader classLoader, Instrumentation instrumentation) {
TimeWatcherTransformer dt = new TimeWatcherTransformer(clazz.getName(), classLoader);
instrumentation.addTransformer(dt, true);
try {
instrumentation.retransformClasses(clazz);
} catch (Exception ex) {
throw new RuntimeException("Transform failed for class: [" + clazz.getName() + "]", ex);
}
}
在这里我们实现了一个TimeWatcherTransformer
并将代码插装的工作委托给它来做:
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
byte[] byteCode = classfileBuffer;
String finalTargetClassName = this.targetClassName.replaceAll("\\.", "/"); //replace . with /
if (!className.equals(finalTargetClassName)) {
return byteCode;
}
if (className.equals(finalTargetClassName) && loader.equals(targetClassLoader)) {
LOGGER.info("[Agent] Transforming class DemoApplication");
try {
ClassPool cp = ClassPool.getDefault();
CtClass cc = cp.get(targetClassName);
CtMethod m = cc.getDeclaredMethod(TEST_METHOD);
m.addLocalVariable("startTime", CtClass.longType);
m.insertBefore("startTime = System.currentTimeMillis();");
StringBuilder endBlock = new StringBuilder();
m.addLocalVariable("endTime", CtClass.longType);
m.addLocalVariable("opTime", CtClass.longType);
endBlock.append("endTime = System.currentTimeMillis();");
endBlock.append("opTime = (endTime-startTime)/1000;");
endBlock.append("LOGGER.info(\"[Application] testExceptionTruncate completed in:\" + opTime + \" seconds!\");");
m.insertAfter(endBlock.toString());
byteCode = cc.toBytecode();
cc.detach();
} catch (NotFoundException | CannotCompileException | IOException e) {
LOGGER.error("Exception", e);
}
}
return byteCode;
}
有了JVM的支持,我们实现一个简单的watch命令也不难,只需要在目标方法的前后插入时间语句就可以了,目标JVM在attach了我们的agent后会输出本次调用的时间,如下图所示:
JVM Attach 机制的实现
在前面的例子里我们之所以可以在一个 JVM 中发送指令让另一个 JVM 加载 Agent,是因为 JVM 通过 Attach 机制提供了一种进程间通信的方式,http://lovestblog.cn/blog/2014/06/18/jvm-attach/?spm=ata.13261165.0.0.26d52428n8NoAy 详细的讲述了 Attach 机制是如何在 Linux 平台下实现的,结合我们之前的例子,可以把整个过程总结为如下的一张图:
在 JVM 调用attach
的时候如果发现没有java_pid
这个文件,则开始启动attach
机制,首先会创建一个attach_pid
的文件,这个文件的主要作用是用来鉴权。然后向Signal Dispacher
发送BREAK
信号,之后就一直在轮询等待java_pid
这个套接字。Signal Dispacher
中注册的信号处理器Attach Listener
中首先会校验attach_pid
这个文件的 uid 是否和当前 uid 一致,鉴权通过后才会创建attach_pid
建立通信通道。
相关文章
- Solr JVM&运维
- 【JVM】字节码指令介绍
- Java虚拟机详解01----初识JVM
- JVM 基础:回收哪些内存/对象 引用计数算法 可达性分析算法 finalize()方法 HotSpot实现分析
- JVM类加载机制
- JVM监控命令
- JVM性能调优监控命令jps、jinfo、jstat、jmap+jhat、jstack使用详解
- JVM调优:jdk1.8的所有-X参数
- JVM调优:-Xms40M -Xmx60M 指定堆的最小、最大大小
- JVM 调优实战--一个案例理解常用工具(命令)
- JVM 调优实战--常用JVM命令:jps/jinfo/jstat/jmap/jstack/jhat
- 打印JVM配置参数的命令
- Atitit .jvm 虚拟机指令详细解释
- 【Android 插件化】插件化原理 ( JVM 内存数据 | 类加载流程 )
- JVM之字节码——Class文件格式
- JVM调优概述
- arthas jvm相关命令使用示例:jvm、sysprop