zl程序教程

您现在的位置是:首页 >  后端

当前栏目

Arthas原理系列(一):利用JVM的attach机制实现一个极简的watch命令

JVM命令原理 实现 一个 系列 利用 机制
2023-09-14 09:15:19 时间

历史文章推荐

  1. OGNL语法规范
  2. 消失的堆栈
  3. Arthas原理系列(一):利用JVM的attach机制实现一个极简的watch命令
  4. Arthas原理系列(二):总体架构和项目入口
  5. Arthas原理系列(三):服务端启动流程
  6. 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建立通信通道。


Arthas原理系列(一):利用JVM的attach机制实现一个极简的watch命令 - 知乎历史文章推荐 OGNL语法规范消失的堆栈Arthas原理系列(一):利用JVM的attach机制实现一个极简的watch命令Arthas原理系列(二):总体架构和项目入口Arthas原理系列(三):服务端启动流程 Arthas原理系列(四):字节码插…https://zhuanlan.zhihu.com/p/328974405