相信每一个java开发者都使用过IDEA 的 Debug,它能查看断点的上下文环境,并且提供了非常强大的可视化工具,更神奇的是我可以在断点处使用它的 Evaluate 功能直接执行某些命令,进行一些计算或改变当前变量。

目录

字节码技术-ASM

Native Agent

Java Agent

VM.attach(Vitural Machine)

SA.attach()

PerfData

再来思考 Debug

示例代码


java冷知识:程序Debug带来的启示_Java

很多人已经习以为然,不曾思考过:Java 是静态语言(运行之前是要先进行编译的),难道我写的这些代码是被实时编译又”注入”到我正在 Debug 的服务里了吗?

对,它确实是一个高阶的问题,当然如果你足够了解Btrace 的使用和实现,这的确算不上什么高深的东东,下面将一步步来寻找答案。

字节码技术-ASM

实现 Evaluate 要解决的第一个问题就是怎么改变原有代码的行为?

它的实现在 Java 里被称为动态字节码技术。我们编写的 Java 代码都是要被编译成字节码后才能放到 JVM 里执行的,而字节码一旦被加载到虚拟机中,就可以被解释执行。字节码文件(.class)就是普通的二进制文件,它是通过 Java 编译器生成的。而只要是文件就可以被改变,如果我们用特定的规则解析了原有的字节码文件,对它进行修改或者干脆重新定义,这不就可以改变代码行为了么。简单吧!!

Java 生态里有很多可以动态生成字节码的技术,像 BCEL、Javassist、ASM、CGLib 等,它们各有自己的优势。ASM 是它们中最强大的一个,使用它可以动态修改类、方法,甚至可以重新定义类,连 CGLib 底层都是用 ASM 实现的。当然,它的使用门槛也很高,使用它需要对 Java 的字节码文件有所了解,熟悉 JVM 的编译指令。但有大神开发了可以在 IDEA 里查看字节码的插件:ASM Bytecode Outline,在类文件里右键选择 Show bytecode Outline即可以右侧的工具栏查看我们要生成的字节码。

java冷知识:程序Debug带来的启示_JVM_02

在 ASM 的代码实现里,最明显的就是访问者模式,ASM 将对代码的读取和操作都包装成一个访问者,在解析 JVM 加载到的字节码时调用。ClassReader 是 ASM 代码的入口,通过它解析二进制字节码,实例化时它时,我们需要传入一个 ClassVisitor,在这个 Visitor 里,我们可以实现visitMethod()/visitAnnotation() 等方法,用以定义对类结构(如方法、字段、注解)的访问方法。而 ClassWriter 接口继承了 ClassVisitor 接口,我们在实例化类访问器时,将 ClassWriter “注入” 到里面,以实现对类写入的声明。

JVM TI

JVM TI(JVM Tool Interface)JVM 工具接口是 JVM 提供的一个非常强大的对 JVM 操作的工具接口,通过这个接口,我们可以实现对 JVM 多种组件的操作,从JVMTM Tool Interface 这里我们认识到 JVM TI 的强大,它包括了对虚拟机堆内存、类、线程等各个方面的管理接口。JVM TI 通过事件机制,通过接口注册各种事件勾子,在 JVM 事件(比如方法出入、线程始末等等)触发时同时触发预定义的勾子,以实现对各个 JVM 事件的感知和反应

Native Agent

以 C/C++代码编写的Agent,用强大的JVMTI(JVM Tool Interface)接口与JVM进行通讯,订阅感兴趣的JVM事件,当这些事件发生时,会回调Agent的代码。我们在编译 C 项目里链接静态库,将静态库的功能注入到项目里,从而才可以在项目里引用库里的函数。我们可以将 agent 类比为 C 里的静态库,我们也可以用 C 或 C++ 来实现,将其编译为 dll 或 so 文件,在启动 JVM 时启动。

启动方式通过在启动命令里加入 -agentlib: 或 -agentpath: /path/to/agent.so,也可以用后面讲的VituralMachine.attach()动态加载。

采用这种方式有各种Profile工具,如Yourkit、JProfiler、aysnc-profiler还有动态Reload Class而不重启应用的JRebel。

Java Agent

Java Agent的底层也是JVMTI ,主要在加载class文件之前做拦截并对字节码做修改,通过instrument 实现的。

1.6以前,instrument 只能在 JVM 刚启动开始加载类时生效,之后,instrument 更是支持了在运行时对类定义的修改。要使用 instrument 的类修改功能,我们需要实现它的 ClassFileTransformer接口定义一个类文件转换器。它唯一的一个 transform() 方法会在类文件被加载时调用,在 transform 方法里,我们可以对传入的二进制字节码进行改写或替换,生成新的字节码数组后返回,JVM 会使用 transform 方法返回的字节码数据进行类的加载。

采用这种方式有AspectJ、Jacoco(单元测试覆盖率)、Spring-Loaded(动态重载Class)。

启动方式有两种:

1. 通过启动时命令行加入 -javaagent:/path/to/agent.jar根据agent.jar中的MANIFEST.MF文件中的Premain-Class定义,JVM找到相应的MyAgent类,调用其premain函数。

另外,我们还需要注意 agent 的打包,它需要指定一个 Agent-Class 参数指定我们的包括 agentmain 方法的类,可以算是指定入口类吧。此外,还需要配置 MANIFEST.MF

<!-- 打包时需要使用 mvn assembly:assembl 命令生成 jar-with-dependencies 作为 agent -->
<build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-assembly-plugin</artifactId>
                <configuration>
                    <archive>
                        <manifestEntries>
                            <Agent-Class>asm.TestAgent</Agent-Class>
                            <Can-Redefine-Classes>true</Can-Redefine-Classes>
                            <Can-Retransform-Classes>true</Can-Retransform-Classes>
                            <Manifest-Version>1.0</Manifest-Version>
                            <Permissions>all-permissions</Permissions>
                        </manifestEntries>
                    </archive>
                    <descriptorRefs>
                        <descriptorRef>jar-with-dependencies</descriptorRef>
                    </descriptorRefs>
                </configuration>
            </plugin>
        </plugins>
    </build>

2. 通过VM.attach()技术,在任意时刻由外部程序来灵活加载

显然第二种启动方式更佳,可以随时让 “一段代码” 与 “主应用” 在同一JVM中运行。采用这种方式实现的非常多:

  • JMXAgent,启动一条TCP侦听线程响应JMX 请求
  • btrace,也是启动一条TCP线程与btrace client通信,接收client发过来的脚本字节码,进行加载并输出结果

VM.attach(Vitural Machine)

Sun 公司的 tools.jar 包里包含的 VirtualMachine 类提供了 attach 一个本地 JVM 的功能,它需要我们传入一个本地 JVM 的 pid, tools.jar 可以在 jre 目录下找到。

Attach本质上都是在跟踪程序与目标JVM之间建立一个沟通的管道,然后在跟踪程序使用特定的API去操作目标JVM。

跟踪程序通过Unix Domain Socket 与目标JVM的Attach Listener线程进行交互。 Socket 文件为/tmp/.java_pid$PID。API 接口是com.sun.tools.attach.VirtualMachine 及其子类sun.tools.attach.HotSpotVirtualMachine,在tools.jar中,运行时需要依赖,可以做很多事情:

  • dumpHeap: jmap -dump 效果
  • heapHisto: jmap -histo效果
  • threadDump: jstack效果
  • dataDump: kill-3 效果(jstack + jmap -heap)
  • loadAgent: 动态加载C/Java Agent
  • agentProperties: 获得已加载Agent的属性
  • sytemProperties: 获得System Properties
  • setFlag: 动态设置可写的JVM参数(但没几个是可写的)
  • printFlag: 打印JVM 参数的值
  • jcmd: 执行jcmd命令,具体能干啥见jcmd $PID help

总之, jmap, jmap,jcmd 们默认就是基于这个机制来做事情的。

SA.attach()

著名的SA(Serviceability Agent),用于分析JVM运行时进程的Snapshot数据。Snapshot的意思,就是当SA 开始分析时,整个目标JVM是停顿下来不工作的,让SA可以从容读取进程的内存,直到断开后才会恢复。所以在生产上使用这类工具时,必须先摘除流量。

这个神奇的操作,主要是通过系统调用ptrace实现。ptrace会使内核暂停目标进程并将控制权交给跟踪程序,使跟踪程序得以察看目标进程的内存,详见ptrace的man,所以在容器环境下,需要打开ptrace的安全权限。

API的接口一个是 sun.jvm.hotspot.HotSpotAgent 负责attach, sun.jvm.hotspot.runtime.VM负责操作,在sa-jdi.jar中。

VM类从内存二进制信息中提取出JVM内部数据结构,包括:

  • 内存的getObjectHeap()/getUniverse()
  • 线程的getThreads()
  • 永久代内容的getSymbolTable(),getStringTable(), getSystemDictionary()
  • 还有很厉害的读内部Native对象值的getTypeDataBase()

jstack,jmap 们默认用前面的VM.attach()模式,与目标JVM的Attach Listener线程通信, 但如果目标JVM已经半死不活,Attach Listener线程无力响应时,就可以增加-F 参数,转而使用SA.attach 模式,用ptrace去暴力接管进程, 详细代码见sun.tools.jmap.JMap。所以jmap -heap 打印heap的统计信息时,也是以SA模式进去。

SA模式比VM模式做相同事情时要慢一截,非必需时不要用它。还有,如果跟踪程序被kill-9 非正常退出,没有执行中断SA,目标JVM就会一直暂停在那里,Linux下可以执行kill -18 $PID 发送SIGCONT信号重新激活进程。

PerfData

思考:jps、jstat是如何实现的?难道是通过JMX吗?

其实不然,很多人不知道的一个机制,JVM其实每秒都会将自己的大量统计数据,写入到 /tmp/hsperfdata_$username/$pid 文件中。

可以通过指令:jcmd $PID PerfCounter.print

内容包括jvm的基本信息,内存,GC,线程数等等,还有一些JMX中没有暴露的数据,比如包含JVM中所有的停顿的SafePoint信息。

//线程的情况
java.threads.daemon=6
java.threads.live=7
...
// younggen的情况
sun.gc.generation.0.capacity=44695552
sun.gc.generation.0.maxCapacity=715784192
sun.gc.generation.0.minCapacity=44695552

jps,其实就是读取/tmp/hsperfdata_$username/ 目录下所有的文件。

jstat,同样是读取这个神秘的文件。一个很大的好处,就是它只默默读取文件,而不会像JMX那样要与应用程序交互,打扰应用程序的工作。

PerfData文件是mmap到内存中的,读写都很快,但每次写完还要更新磁盘上的文件元数据比如last modified time,如果遇上磁盘高IO,还是有概率造成JVM被锁定一段时间。所以我们通过-XX:+PerfDisableSharedMem禁止了perfdata的写入,不过现在又有点摇摆。

注意,perfdata 和 vm.attach 都需要在/tmp 目录读写文件,如果目标JVM的启动参数重新指定了临时文件目录,而跟踪程序依然去读取/tmp ,也会导致这些机制失效。

再来思考 Debug

debug是通过Native Agent方式实现的。如果你足够细心的话,你会发现:我们在启动被 Debug 的 JVM 时必须添加参数

-agentlib:jdwp=transport=dt_socket,suspend=y,address=localhost:3333,而 -agentlib 选项就指定了我们要加载的 Java Agent,jdwp 是 agent 的名字,在 linux 系统中,我们可以在 jre 目录下找到 jdwp.so 库文件。Java 的调试体系 jdpa 组成,从高到低分别为 jdi->jdwp->jvmti,我们通过 JDI 接口发送调试指令,而 jdwp 就相当于一个通道,帮我们翻译 JDI 指令到 JVM TI,最底层的 JVM TI 最终实现对 JVM 的操作。

java冷知识:程序Debug带来的启示_Java_03

java冷知识:程序Debug带来的启示_ASM_04

当然,这里又有一个非常复杂的JDWP(调试器和应用之间通信的协议)。

示例代码

//被修改的目标类
public class TransformTarget {
    public static void main(String[] args) {
        while (true) {
            try {
                Thread.sleep(3000L);
            } catch (Exception e) {
                break;
            }
            printSomething();
        }
    }

    public static void printSomething() {
        System.out.println("hello");
    }

}
//入口类,也是代理的 Agent-Class
public class TestAgent {
    public static void agentmain(String args, Instrumentation inst) {
        inst.addTransformer(new TestTransformer(), true);
        try {
            inst.retransformClasses(TransformTarget.class);
            System.out.println("Agent Load Done.");
        } catch (Exception e) {
            System.out.println("agent load failed!");
        }
    }
}
//执行字节码修改和转换的类
public class TestTransformer implements ClassFileTransformer {

    public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
        System.out.println("Transforming " + className);
        ClassReader reader = new ClassReader(classfileBuffer);
        ClassWriter classWriter = new ClassWriter(ClassWriter.COMPUTE_FRAMES);
        ClassVisitor classVisitor = new TestClassVisitor(Opcodes.ASM5, classWriter);
        reader.accept(classVisitor, ClassReader.SKIP_DEBUG);
        return classWriter.toByteArray();
    }

    class TestClassVisitor extends ClassVisitor implements Opcodes {
        TestClassVisitor(int api, ClassVisitor classVisitor) {
            super(api, classVisitor);
        }

        @Override
        public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {
            MethodVisitor mv = super.visitMethod(access, name, desc, signature, exceptions);
            if (name.equals("printSomething")) {
                mv.visitCode();
                Label l0 = new Label();
                mv.visitLabel(l0);
                mv.visitLineNumber(19, l0);
                mv.visitFieldInsn(Opcodes.GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
                mv.visitLdcInsn("bytecode replaced!");
                mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
                Label l1 = new Label();
                mv.visitLabel(l1);
                mv.visitLineNumber(20, l1);
                mv.visitInsn(Opcodes.RETURN);
                mv.visitMaxs(2, 0);
                mv.visitEnd();
                TransformTarget.printSomething();
            }
            return mv;
        }
    }
}
//将agent动态加载到目标JVM中
public class Attacher {
    public static void main(String[] args) throws AttachNotSupportedException, IOException, AgentLoadException, AgentInitializationException {

        VirtualMachine vm = VirtualMachine.attach("34242"); // 目标 JVM pid
        vm.loadAgent("/path/to/agent.jar");
    }
}

掌握了字节码的动态修改技术后,再回头看 Btrace 的原理就更清晰了,稍微摸索一下我们也可以实现一个简版的。另外很多大牛实现的各种 Java 性能分析工具的技术栈也不外如此。