响应式网站导航栏,公司申请注册流程,上海优秀网站设计,正规的投资公司融资流程Java Instrumentation 包 Java Instrumentation 概述 Java Instrumentation 这个技术看起来非常神秘#xff0c;很少有书会详细介绍。但是有很多工具是基于 Instrumentation 来实现的#xff1a; APM 产品: pinpoint、skywalking、newrelic、听云的 APM 产品等都基于 Instru… Java Instrumentation 包 Java Instrumentation 概述 Java Instrumentation 这个技术看起来非常神秘很少有书会详细介绍。但是有很多工具是基于 Instrumentation 来实现的 APM 产品: pinpoint、skywalking、newrelic、听云的 APM 产品等都基于 Instrumentation 实现 热部署工具Intellij idea 的 HotSwap、Jrebel 等 Java 诊断工具Arthas、Btrace 等 由于对字节码修改功能的巨大需求JDK 从 JDK5 版本开始引入了java.lang.instrument 包。它可以通过 addTransformer 方法设置一个 ClassFileTransformer可以在这个 ClassFileTransformer 实现类的转换。 JDK 1.5 支持静态 Instrumentation基本的思路是在 JVM 启动的时候添加一个代理javaagent每个代理是一个 jar 包其 MANIFEST.MF 文件里指定了代理类这个代理类包含一个 premain 方法。JVM 在类加载时候会先执行代理类的 premain 方法再执行 Java 程序本身的 main 方法这就是 premain 名字的来源。在 premain 方法中可以对加载前的 class 文件进行修改。这种机制可以认为是虚拟机级别的 AOP无需对原有应用做任何修改就可以实现类的动态修改和增强。 从 JDK 1.6 开始支持更加强大的动态 Instrument在JVM 启动后通过 Attach API 远程加载后面会详细介绍。 本文会分为 javaagent 和动态 Attach 两个部分来介绍 Java Instrumentation 核心方法 Instrumentation 是 java.lang.instrument 包下的一个接口这个接口的方法提供了注册类文件转换器、获取所有已加载的类等功能允许我们在对已加载和未加载的类进行修改实现 AOP、性能监控等功能。 常用的方法如下 /** * 为 Instrumentation 注册一个类文件转换器可以修改读取类文件字节码 */void addTransformer(ClassFileTransformer transformer, boolean canRetransform);/** * 对JVM已经加载的类重新触发类加载 */void retransformClasses(Class?... classes) throws UnmodifiableClassException;/** * 获取当前 JVM 加载的所有类对象 */Class[] getAllLoadedClasses() 它的 addTransformer 给 Instrumentation 注册一个 transformertransformer 是 ClassFileTransformer 接口的实例这个接口就只有一个 transform 方法调用 addTransformer 设置 transformer 以后后续JVM 加载所有类之前都会被这个 transform 方法拦截这个方法接收原类文件的字节数组返回转换过的字节数组在这个方法中可以做任意的类文件改写。 下面是一个空的 ClassFileTransformer 的实现 public class MyClassTransformer implements ClassFileTransformer { Override public byte[] transform(ClassLoader loader, String className, Class? classBeingRedefined, ProtectionDomain protectionDomain, byte[] classBytes) throws IllegalClassFormatException { // 在这里读取、转换类文件 return classBytes; }} 接下来我们来介绍本文的主角之一 javaagent。 Javaagent 介绍 Javaagent 是一个特殊的 jar 包它并不能单独启动的而必须依附于一个 JVM 进程可以看作是 JVM 的一个寄生插件使用 Instrumentation 的 API 用来读取和改写当前 JVM 的类文件。 Agent 的两种使用方式 它有两种使用方式 在 JVM 启动的时候加载通过 javaagent 启动参数 java -javaagent:myagent.jar MyMain这种方式在程序 main 方法执行之前执行 agent 中的 premain 方法 在 JVM 启动后 Attach通过 Attach API 进行加载这种方式会在 agent 加载以后执行 agentmain 方法 premain 和 agentmain 方法签名如下 public static void premain(String agentArgument, Instrumentation instrumentation) throws Exceptionpublic static void agentmain(String agentArgument, Instrumentation instrumentation) throws Exception 这两个方法都有两个参数 第一个 agentArgument 是 agent 的启动参数可以在 JVM 启动命令行中设置比如java -javaagent:jarfileappId:agent-demo,agentType:singleJar test.jar的情况下 agentArgument 的值为 appId:agent-demo,agentType:singleJar。 第二个 instrumentation 是 java.lang.instrument.Instrumentation 的实例可以通过 addTransformer 方法设置一个 ClassFileTransformer。 第一种 premain 方式的加载时序如下 Agent 打包 为了能够以 javaagent 的方式运行 premain 和 agentmain 方法我们需要将其打包成 jar 包并在其中的 MANIFEST.MF 配置文件中指定 Premain-class 等信息一个典型的生成好的 MANIFEST.MF 内容如下 为了能够以 javaagent 的方式运行 premain 和 agentmain 方法我们需要将其打包成 jar 包并在其中的 MANIFEST.MF 配置文件中指定 Premain-class 等信息一个典型的生成好的 MANIFEST.MF 内容如下 下面是一个可以帮助生成上面 MANIFEST.MF 的 maven 配置 build finalNamemy-javaagent/finalName plugins plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-jar-plugin/artifactId configuration archive manifestEntries Agent-Classme.geek01.javaagent.AgentMain/Agent-Class Premain-Classme.geek01.javaagent.AgentMain/Premain-Class Can-Redefine-Classestrue/Can-Redefine-Classes Can-Retransform-Classestrue/Can-Retransform-Classes /manifestEntries /archive /configuration /plugin /plugins/build Agent 使用方式一JVM 启动参数 下面使用 javaagent 实现简单的函数调用栈跟踪以下面的代码为例 public class MyTest { public static void main(String[] args) { new MyTest().foo(); } public void foo() { bar1(); bar2(); } public void bar1() { } public void bar2() { }} 通过 javaagent 启动参数的方式在每个函数进入和结束时都打印一行日志实现调用过程的追踪的效果。 核心的方法 instrument 的逻辑如下 public static class MyMethodVisitor extends AdviceAdapter { Override protected void onMethodEnter() { // 在方法开始处插入 enter xxx mv.visitFieldInsn(GETSTATIC, java/lang/System, out, Ljava/io/PrintStream;); mv.visitLdcInsn(enter this.getName()); mv.visitMethodInsn(INVOKEVIRTUAL, java/io/PrintStream, println, (Ljava/lang/String;)V, false); super.onMethodEnter(); } Override protected void onMethodExit(int opcode) { super.onMethodExit(opcode); // 在方法结束处插入 exit xxx mv.visitFieldInsn(GETSTATIC, java/lang/System, out, Ljava/io/PrintStream;); mv.visitLdcInsn(exit this.getName()); mv.visitMethodInsn(INVOKEVIRTUAL, java/io/PrintStream, println, (Ljava/lang/String;)V, false); }} 把 agent 打包生成 my-trace-agent.jar添加 agent 启动 MyTest 类 java -javaagent:/path_to/my-trace-agent.jar MyTest 可以看到输出结果如下 enter mainenter fooenter bar1exit bar1enter bar2exit bar2exit fooexit main 通过上面的方式我们在不修改 MyTest 类源码的情况下实现了调用链跟踪的效果。更加健壮和完善的调用链跟踪实现会在后面的 APM 章节详细介绍。 Agent 使用方式二Attach API 使用 在 JDK5 中开发者只能 JVM 启动时指定一个 javaagent 在 premain 中操作字节码Instrumentation 也仅限于 main 函数执行前这样的方式存在一定的局限性。从 JDK6 开始引入了动态 Attach Agent 的方案除了在命令行中指定 javaagent现在可以通过 Attach API 远程加载。我们常用的 jstack、arthas 等工具都是通过 Attach 机制实现的。 接下来我们会结合跨进程通信中的信号和 Unix 域套接字来看 JVM Attach API 的实现原理 JVM Attach API 基本使用 下面以一个实际的例子来演示动态 Attach API 的使用代码中有一个 main 方法每隔 3s 输出 foo 方法的返回值 100接下来动态 Attach 上 MyTestMain 进程修改 foo 的字节码让 foo 方法返回 50。 public class MyTestMain { public static void main(String[] args) throws InterruptedException { while (true) { System.out.println(foo()); TimeUnit.SECONDS.sleep(3); } } public static int foo() { return 100; // 修改后 return 50; }} 步骤如下 1、编写 Attach Agent对 foo 方法做注入完整的代码见github.com/arthur-zhan… 动态 Attach 的 agent 与通过 JVM 启动 javaagent 参数指定的 agent jar 包的方式有所不同动态 Attach 的 agent 会执行 agentmain 方法而不是 premain 方法。 public class AgentMain { public static void agentmain(String agentArgs, Instrumentation inst) throws ClassNotFoundException, UnmodifiableClassException { System.out.println(agentmain called); inst.addTransformer(new MyClassFileTransformer(), true); Class classes[] inst.getAllLoadedClasses(); for (int i 0; i classes.length; i) { if (classes[i].getName().equals(MyTestMain)) { System.out.println(Reloading: classes[i].getName()); inst.retransformClasses(classes[i]); break; } } }} 2、因为是跨进程通信Attach 的发起端是一个独立的 java 程序这个 java 程序会调用 VirtualMachine.attach 方法开始和目标 JVM 进行跨进程通信。 public class MyAttachMain { public static void main(String[] args) throws Exception { VirtualMachine vm VirtualMachine.attach(args[0]); try { vm.loadAgent(/path/to/agent.jar); } finally { vm.detach(); } }} 使用 jps 查询到 MyTestMain 的进程 id java -cp /path/to/your/tools.jar:. MyAttachMain pid 可以看到 MyTestMain 的输出的 foo 方法已经返回了 50。 java -cp . MyTestMain100100100agentmain calledReloading: MyTestMain505050 JVM Attach API 的底层原理 JVM Attach API 的实现主要基于信号和 Unix 域套接字接下来详细介绍这两部分的内容。 信号是什么 信号是某事件发生时对进程的通知机制也被称为“软件中断”。信号可以看做是一种非常轻量级的进程间通信信号由一个进程发送给另外一个进程只不过是经由内核作为一个中间人发出信号最初的目的是用来指定杀死进程的不同方式。 每个信号都有一个名字以 SIG 开头最熟知的信号应该是 SIGINT我们在终端执行某个应用程序的过程中按下 CtrlC 一般会终止正在执行的进程正是因为按下 CtrlC 会发送 SIGINT 信号给目标程序。 每个信号都有一个唯一的数字标识从 1 开始下面是常见的信号量列表 在 Linux 中一个前台进程可以使用 CtrlC 进行终止对于后台进程需要使用 kill 加进程号的方式来终止kill 命令是通过发送信号给目标进程来实现终止进程的功能。默认情况下kill 命令发送的是编号为 15 的 SIGTERM 信号这个信号可以被进程捕获选择忽略或正常退出。目标进程如果没有自定义处理这个信号就会被终止。对于那些忽略 SIGTERM 信号的进程则需要编号为 9 的 SIGKILL 信号强行杀死进程SIGKILL 信号不能被忽略也不能被捕获和自定义处理。 下面写了一段 C 代码自定义处理了 SIGQUIT、SIGINT、SIGTERM 信号 signal.cstatic void signal_handler(int signal_no) { if (signal_no SIGQUIT) { printf(quit signal receive: %d\n, signal_no); } else if (signal_no SIGTERM) { printf(term signal receive: %d\n, signal_no); } else if (signal_no SIGINT) { printf(interrupt signal receive: %d\n, signal_no); }}int main() { signal(SIGQUIT, signal_handler); signal(SIGINT, signal_handler); signal(SIGTERM, signal_handler); for (int i 0;; i) { printf(%d\n, i); sleep(3); }} 编译运行上面的 signal.c 文件 gcc signal.c -o signal./signal 这种情况下在终端中CtrlCkill -3kill -15都没有办法杀掉这个进程只能用kill -9 0^Cinterrupt signal receive: 2 // CtrlC12term signal receive: 15 // kill pid345quit signal receive: 3 // kill -3 678[1] 46831 killed ./signal // kill -9 成功杀死进程 JVM 对 SIGQUIT 的默认行为是打印所有运行线程的堆栈信息在类 Unix 系统中可以通过使用命令 kill -3 pid 来发送 SIGQUIT 信号。运行上面的 MyTestMain使用 jps 找到整个 JVM 的进程 id执行 kill -3 pid在终端就可以看到打印了所有的线程的调用栈信息 Full thread dump Java HotSpot(TM) 64-Bit Server VM (25.51-b03 mixed mode):Service Thread #8 daemon prio9 os_prio31 tid0x00007fe060821000 nid0x4403 runnable [0x0000000000000000] java.lang.Thread.State: RUNNABLE...Signal Dispatcher #4 daemon prio9 os_prio31 tid0x00007fe061008800 nid0x3403 waiting on condition [0x0000000000000000] java.lang.Thread.State: RUNNABLEmain #1 prio5 os_prio31 tid0x00007fe060003800 nid0x1003 waiting on condition [0x000070000d203000] java.lang.Thread.State: TIMED_WAITING (sleeping) at java.lang.Thread.sleep(Native Method) at java.lang.Thread.sleep(Thread.java:340) at java.util.concurrent.TimeUnit.sleep(TimeUnit.java:386) at MyTestMain.main(MyTestMain.java:10) Unix 域套接字Unix Domain Socket 使用 TCP 和 UDP 进行 socket 通信是一种广为人知的 socket 使用方式除了这种方式还有一种称为 Unix 域套接字的方式可以实现同一主机上的进程间通信。虽然使用 127.0.0.1 环回地址也可以通过网络实现同一主机的进程间通信但 Unix 域套接字更可靠、效率更高。Docker 守护进程Docker daemon使用了 Unix 域套接字容器中的进程可以通过它与Docker 守护进程进行通信。MySQL 同样提供了域套接字进行访问的方式。 Unix 域套接字是什么 Unix 域套接字是一个文件通过 ls 命令可以看到 ls -lsrwxrwxr-x. 1 ya ya 0 9月 8 00:26 tmp.sock 两个进程通过读写这个文件就实现了进程间的信息传递。文件的拥有者和权限决定了谁可以读写这个套接字。 与普通套接字的区别是什么? Unix 域套接字更加高效Unix 套接字不用进行协议处理不需要计算序列号也不需要发送确认报文只需要复制数据即可 Unix 域套接字是可靠的不会丢失报文普通套接字是为不可靠通信设计的 Unix 域套接字的代码可以非常简单的修改转为普通套接字 下面是一个简单的 C 实现的域套接字的例子 .├── client.c└── server.c server.c 充当 Unix 域套接字服务器启动后会在当前目录生成一个名为 tmp.sock 的 Unix 域套接字文件它读取客户端写入的内容并输出。 server.cint main() { int fd socket(AF_UNIX, SOCK_STREAM, 0); struct sockaddr_un addr; memset(addr, 0, sizeof(addr)); addr.sun_family AF_UNIX; strcpy(addr.sun_path, tmp.sock); int ret bind(fd, (struct sockaddr *) addr, sizeof(addr)); listen(fd, 5) int accept_fd; char buf[100]; while (1) { accept_fd accept(fd, NULL, NULL)) -1); while ((ret read(accept_fd, buf, sizeof(buf))) 0) { // 输出客户端传过来的数据 printf(receive %u bytes: %s\n, ret, buf); }} 客户端的代码如下 client.cint main() { int fd socket(AF_UNIX, SOCK_STREAM, 0); struct sockaddr_un addr; memset(addr, 0, sizeof(addr)); addr.sun_family AF_UNIX; strcpy(addr.sun_path, tmp.sock); connect(fd, (struct sockaddr *) addr, sizeof(addr)) -1 int rc; char buf[100]; // 读取终端标准输入的内容写入到 Unix 域套接字文件中 while ((rc read(STDIN_FILENO, buf, sizeof(buf))) 0) { write(fd, buf, rc); }} 在命令行中进行编译和执行 gcc server.c -o servergcc client.c -o client 启动两个终端一个启动 server 端一个启动 client 端 ./server./client 可以看到当前目录生成了一个 tmp.sock 文件 ls -lsrwxrwxr-x. 1 ya ya 0 9月 8 00:08 tmp.sock 在 client 输入 hello在 server 的终端就可以看到 ./serverreceive 6 bytes: hello JVM Attach 过程分析 执行 MyAttachMain当指定一个不存在的 JVM 进程时会出现如下的错误 java -cp /path/to/your/tools.jar:. MyAttachMain 1234Exception in thread main java.io.IOException: No such process at sun.tools.attach.LinuxVirtualMachine.sendQuitTo(Native Method) at sun.tools.attach.LinuxVirtualMachine.init(LinuxVirtualMachine.java:91) at sun.tools.attach.LinuxAttachProvider.attachVirtualMachine(LinuxAttachProvider.java:63) at com.sun.tools.attach.VirtualMachine.attach(VirtualMachine.java:208) at MyAttachMain.main(MyAttachMain.java:8) 可以看到 VirtualMachine.attach 最终调用了 sendQuitTo 方法这是一个 native 的方法底层就是发送了 SIGQUIT 信号给目标 JVM 进程。 前面信号部分我们介绍过JVM 对 SIGQUIT 的默认行为是 dump 当前的线程堆栈那为什么调用 VirtualMachine.attach 没有输出调用栈堆栈呢 对于 Attach 的发起方假设目标进程为 12345这部分的详细的过程如下 1、Attach 端检查临时文件目录是否有 .java_pid12345 文件 这个文件是一个 UNIX 域套接字文件由 Attach 成功以后的目标 JVM 进程生成。如果这个文件存在说明正在 Attach 中可以用这个 socket 进行下一步的通信。如果这个文件不存在则创建一个 .attach_pid12345 文件这部分的伪代码如下 String tmpdir /tmp;File socketFile new File(tmpdir, .java_pid pid);if (socketFile.exists()) { File attachFile new File(tmpdir, .attach_pid pid); createAttachFile(attachFile.getPath());} 2、Attach 端检查如果没有 .java_pid12345 文件创建完 .attach_pid12345 文件以后发送 SIGQUIT 信号给目标 JVM。然后每隔 200ms 检查一次 socket 文件是否已经生成5s 以后还没有生成则退出如果有生成则进行 socket 通信 3、对于目标 JVM 进程而言它的 Signal Dispatcher 线程收到 SIGQUIT 信号以后会检查 .attach_pid12345 文件是否存在。 目标 JVM 如果发现 .attach_pid12345 不存在则认为这不是一个 attach 操作执行默认行为输出当前所有线程的堆栈 目标 JVM 如果发现 .attach_pid12345 存在则认为这是一个 attach 操作会启动 Attach Listener 线程负责处理 Attach 请求同时创建名为 .java_pid12345 的 socket 文件监听 socket。 源码中 /hotspot/src/share/vm/runtime/os.cpp 这一部分处理的逻辑如下 #define SIGBREAK SIGQUITstatic void signal_thread_entry(JavaThread* thread, TRAPS) { while (true) { int sig; { switch (sig) { case SIGBREAK: { // Check if the signal is a trigger to start the Attach Listener - in that // case dont print stack traces. if (!DisableAttachMechanism AttachListener::is_init_trigger()) { continue; } ... // Print stack traces }} AttachListener 的 is_init_trigger 在 .attach_pid12345 文件存在的情况下会新建 .java_pid12345 套接字文件同时监听此套接字准备 Attach 端发送数据。 那 Attach 端和目标进程用 socket 传递了什么信息呢可以通过 strace 的方式看到 Attach 端究竟往 socket 里面写了什么 sudo strace -f java -cp /usr/local/jdk/lib/tools.jar:. MyAttachMain 12345 2 strace.out...5841 [pid 3869] socket(AF_LOCAL, SOCK_STREAM, 0) 55842 [pid 3869] connect(5, {sa_familyAF_LOCAL, sun_path/tmp/.java_pid12345}, 110) 05843 [pid 3869] write(5, 1, 1) 15844 [pid 3869] write(5, \0, 1) 15845 [pid 3869] write(5, load, 4) 45846 [pid 3869] write(5, \0, 1) 15847 [pid 3869] write(5, instrument, 10) 105848 [pid 3869] write(5, \0, 1) 15849 [pid 3869] write(5, false, 5) 55850 [pid 3869] write(5, \0, 1) 15855 [pid 3869] write(5, /home/ya/agent.jar..., 18 unfinished ... 可以看到往 socket 写入的内容如下 1\0load\0instrument\0false\0/home/ya/agent.jar\0 数据之间用 \0 字符分隔第一行的 1 表示协议版本接下来是发送指令 load instrument false /home/ya/agent.jar 给目标 JVM目标 JVM 收到这些数据以后就可以加载相应的 agent jar 包进行字节码的改写。 如果从 socket 的角度来看VirtualMachine.attach 方法相当于三次握手建连VirtualMachine.loadAgent 则是握手成功之后发送数据VirtualMachine.detach 相当于四次挥手断开连接。 这个过程如下图所示 小结 本文讲解了 javaagent一起来回顾一下要点 第一javaagent 是一个使用 instrumentation 的 API 用来改写类文件的 jar 包可以看作是 JVM 的一个寄生插件。 第二javaagent 有两个重要的入口类Premain-Class 和 Agent-Class分别对应入口函数 premain 和 agentmain其中 agentmain 可以采用远程 attach API 的方式远程挂载另一个 JVM 进程。 本文由 mdnice 多平台发布