前言
在深入到 Arthas 的原理之前我们先看一个有趣的示例,我们依然使用前面文章中用到的代码示例,
public class DemoApplication {
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 文件如下所示:
Manifest-Version: 1.0
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 平台下实现的,结合我们之前的例子,可以把整个过程总结为如下的一张图:

在 GVM 调用attach
的时候如果发现没有java_pid
这个文件,则开始启动attach
机制,首先会创建一个attach_pid
的文件,这个文件的主要作用是用来鉴权。然后向Signal Dispacher
发送BREAK
信号,之后就一直在轮询等待java_pid
这个套接字。Signal Dispacher
中注册的信号处理器Attach Listener
中首先会校验attach_pid
这个文件的 uid 是否和当前 uid 一致,鉴权通过后才会创建attach_pid
建立通信通道。
正文
消失的堆栈
扫码关注我

公众号|程序员的理想主义
原文始发于微信公众号(苦味代码):Arthas原理系列(一):利用JVM的attach机制实现一个极简的watch命令
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/21611.html