返回文章列表
JVM虚拟机
JVMJava工具Arthas实战

13自制简单java工具

在 Java 的世界中,除了 Java 编写的业务系统之外,还有一类程序也需要 Java 程序员参与编写,这类程序就是 Java 工具。常见的 Java 工具有以下几类:

  1. 诊断类工具,如 Arthas、VisualVM 等。
  2. 开发类工具,如 Idea、Eclipse。
  3. APM 应用性能监测工具,如 Skywalking、Zipkin 等。
  4. 热部署工具,如 Jrebel 等。

Java 工具

这些工具底层依赖什么技术?面试中常见的相关问题包括:Arthas 用到了什么 Java 技术?微服务架构中常用的 APM 系统是怎么实现的?想实现动态检测 Java 程序运行情况的系统,怎么做?

学习路线通常为:先掌握 Java Agent 技术,再了解 Arthas、VisualVM、APM 系统的底层实现,最后动手实战简化版工具。

学习路线

1.Java 工具的核心:Java Agent 技术

Java Agent 技术是 JDK 提供的用来编写 Java 工具的技术,使用这种技术生成一种特殊的 jar 包,这种 jar 包可以让 Java 程序运行其中的代码。

Java Agent

Java Agent 技术实现了让 Java 程序执行独立的 Java Agent 程序中的代码,执行方式有两种:

1.1 静态加载模式

静态加载模式可以在程序启动的一开始就执行需要执行的代码,适合 APM 等性能监测系统从一开始就监控程序的执行性能。静态加载模式需要在 Java Agent 的项目中编写一个 premain 的方法,并打包成 jar 包。

静态加载模式

使用以下命令启动 Java 程序,虚拟机会加载 agent 中的代码并执行。premain 方法会在主线程中执行(先于 main 方法):

java -javaagent:./agent.jar -jar test.jar

搭建静态加载模式的 Java Agent 环境:

  1. 创建 maven 项目,添加 maven-assembly-plugin 插件打包出 java agent 的 jar 包:
<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-assembly-plugin</artifactId>
    <configuration>
        <descriptorRefs>
            <descriptorRef>jar-with-dependencies</descriptorRef>
        </descriptorRefs>
        <archive>
            <manifestFile>src/main/resources/MANIFEST.MF</manifestFile>
        </archive>
    </configuration>
</plugin>
  1. 编写类和 premain 方法,方法中打印一行信息:
public class AgentDemo {
 
    /**
     * 参数添加模式:启动 java 主程序时添加 -javaagent:agent路径
     */
    public static void premain(String agentArgs, Instrumentation inst) {
        System.out.println("java agent执行了...");
    }
}
  1. 编写 MANIFEST.MF 文件描述 java agent 的配置属性,比如使用哪一个类的 premain 方法:
Manifest-Version: 1.0
Premain-Class: com.itheima.jvm.javaagent.AgentDemo
Agent-Class: com.itheima.jvm.javaagent.AgentDemo
Can-Redefine-Classes: true
Can-Retransform-Classes: true
Can-Set-Native-Method-Prefix: true
  1. 使用 maven-assembly-plugin 进行打包。
  2. 创建 spring boot 应用,并静态加载上一步打包完的 java agent。

1.2 动态加载模式

动态加载模式可以随时让 java agent 代码执行,适用于 Arthas 等诊断系统。动态加载模式需要在 Java Agent 的项目中编写一个 agentmain 的方法,并打包成 jar 包。

动态加载模式

agentmain 方法会在独立线程(attach 线程)中执行。编写 main 方法动态连接到运行中的 java 程序:

public class AttachMain {
    public static void main(String[] args) throws IOException, AttachNotSupportedException,
            AgentLoadException, AgentInitializationException {
        VirtualMachine vm = VirtualMachine.attach("24200");
        vm.loadAgent("D:\\jvm-java-agent\\target\\itheima-jvm-java-agent-jar-with-dependencies.jar");
    }
}

AgentDemo 同时具备 premain 与 agentmain:

public class AgentDemo {
 
    public static void premain(String agentArgs, Instrumentation inst) {
        System.out.println("java agent执行了...");
    }
 
    /**
     * attach 挂载模式:java 主程序运行之后,随时可以将 agent 挂载上去
     */
    public static void agentmain(String agentArgs, Instrumentation inst) {
        System.out.println(Thread.currentThread().getName());
        System.out.println("attach模式执行了...");
    }
}

2.实战案例 1:简化版的 Arthas

编写一个简化版的 Arthas 程序,具备以下功能:

  1. 查看内存使用情况
  2. 查看直接内存使用情况,生成堆内存快照
  3. 打印栈信息
  4. 打印类加载器
  5. 打印类的源码
  6. 打印方法执行的参数和耗时

该程序是一个独立的 Jar 包,可以应用于任何 Java 编写的系统中,具备代码无侵入性、操作简单、性能高的特点。

简化版 Arthas

2.1 查看内存使用情况(JMX 技术)

JDK 从 1.5 开始提供了 Java Management Extensions (JMX) 技术,通过 Mbean 对象的写入和获取,实现:

  • 运行时配置的获取和更改
  • 应用程序运行信息的获取(线程栈、内存、类信息等)

JMX 架构

ManagementFactory 提供了一系列方法获取各种各样的信息:类加载信息、所有内存信息(堆+非堆)、线程信息、垃圾回收器数量+回收时间等。

ManagementFactory

获取内存信息的核心代码:

public class AgentDemo {
 
    public static void agentmain(String agentArgs, Instrumentation inst) {
        memory();
    }
 
    private static void memory() {
        List<MemoryPoolMXBean> memoryPoolMXBeans = ManagementFactory.getMemoryPoolMXBeans();
 
        System.out.println("堆内存:");
        getMemoryInfo(memoryPoolMXBeans, MemoryType.HEAP);
 
        System.out.println("非堆内存:");
        getMemoryInfo(memoryPoolMXBeans, MemoryType.NON_HEAP);
 
        // nio 使用的直接内存
        try {
            Class bufferPoolMXBeanClass = Class.forName("java.lang.management.BufferPoolMXBean");
            List<BufferPoolMXBean> bufferPoolMXBeans = ManagementFactory.getPlatformMXBeans(bufferPoolMXBeanClass);
            for (BufferPoolMXBean mbean : bufferPoolMXBeans) {
                StringBuilder sb = new StringBuilder();
                sb.append("name:").append(mbean.getName())
                        .append(" used:").append(mbean.getMemoryUsed() / 1024 / 1024).append("m")
                        .append(" max:").append(mbean.getTotalCapacity() / 1024 / 1024).append("m");
                System.out.println(sb);
            }
        } catch (Exception e) {
            System.out.println(e);
        }
    }
 
    private static void getMemoryInfo(List<MemoryPoolMXBean> memoryPoolMXBeans, MemoryType heap) {
        memoryPoolMXBeans.stream().filter(x -> x.getType().equals(heap))
                .forEach(x -> {
                    StringBuilder sb = new StringBuilder();
                    sb.append("name:").append(x.getName())
                            .append(" used:").append(x.getUsage().getUsed() / 1024 / 1024).append("m")
                            .append(" max:").append(x.getUsage().getMax() / 1024 / 1024).append("m")
                            .append(" committed:").append(x.getUsage().getCommitted() / 1024 / 1024).append("m");
                    System.out.println(sb);
                });
    }
}

2.2 生成堆内存快照

更多的信息可以通过 ManagementFactory.getPlatformMXBeans 获取,例如获取 Java 虚拟机中分配的直接内存和内存映射缓冲区的大小,以及虚拟机诊断用的 HotSpotDiagnosticMXBean,通过这个 Bean 对象可以生成内存快照。

获取直接内存

生成堆内存快照的核心代码:

public static void heapDump() {
    SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd-HH-mm");
    String filename = simpleDateFormat.format(new Date()) + ".hprof";
    System.out.println("生成内存dump文件,文件名为:" + filename);
 
    HotSpotDiagnosticMXBean hotSpotDiagnosticMXBean =
            ManagementFactory.getPlatformMXBean(HotSpotDiagnosticMXBean.class);
 
    try {
        hotSpotDiagnosticMXBean.dumpHeap(filename, true);
    } catch (IOException e) {
        e.printStackTrace();
    }
}

2.3 打印栈信息

通过 ThreadMXBean 可以打印所有线程的栈信息:

public class ThreadCommand {
 
    public static void printStackInfo() {
        ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean();
        ThreadInfo[] infos = threadMXBean.dumpAllThreads(
                threadMXBean.isObjectMonitorUsageSupported(),
                threadMXBean.isSynchronizerUsageSupported());
        for (ThreadInfo info : infos) {
            StringBuilder sb = new StringBuilder();
            sb.append("name:").append(info.getThreadName())
                    .append(" threadId:").append(info.getThreadId())
                    .append(" state:").append(info.getThreadState());
            System.out.println(sb);
 
            for (StackTraceElement stackTraceElement : info.getStackTrace()) {
                System.out.println(stackTraceElement.toString());
            }
            System.out.println();
        }
    }
}

2.4 打印类加载器

Java Agent 中可以获得 Java 虚拟机提供的 Instrumentation 对象,该对象有以下几个作用:

  1. redefine:重新设置类的字节码信息。
  2. retransform:根据现有类的字节码信息进行增强。
  3. 获取所有已加载的类信息。

参考 Oracle 官方手册:https://docs.oracle.com/javase/17/docs/api/java/lang/instrument/Instrumentation.html

Instrumentation

打印所有类加载器的核心代码:

public class ClassCommand {
 
    private static Set<ClassLoader> getAllClassLoader(Instrumentation inst) {
        HashSet<ClassLoader> classLoaders = new HashSet<>();
        Class[] allLoadedClasses = inst.getAllLoadedClasses();
        for (Class clazz : allLoadedClasses) {
            ClassLoader classLoader = clazz.getClassLoader();
            classLoaders.add(classLoader);
        }
        return classLoaders;
    }
 
    public static void printAllClassLoader(Instrumentation inst) {
        Set<ClassLoader> allClassLoader = getAllClassLoader(inst);
        String result = allClassLoader.stream().map(x -> {
            if (x == null) {
                return "BootStrapClassLoader";
            } else {
                return x.toString();
            }
        }).distinct().sorted(String::compareTo).collect(Collectors.joining(","));
        System.out.println(result);
    }
}

2.5 打印类的源码

打印类的源码分为两步:

  1. 获得内存中类的字节码信息:利用 Instrumentation 提供的 ClassFileTransformer 转换器来获取字节码信息,定义转换器实现 ClassFileTransformer 接口,给 Instrumentation 添加转换器,手动触发转换器,最后删除转换器。
  2. 通过反编译工具将字节码信息还原成源代码信息:这里使用 jd-core 依赖库完成,github 地址:https://github.com/java-decompiler/jd-core。

打印类源码

Pom 添加依赖:

<dependency>
    <groupId>org.jd</groupId>
    <artifactId>jd-core</artifactId>
    <version>1.1.3</version>
</dependency>

打印类源码的核心代码(仅保留关键骨架,Printer 实现请参考 jd-core 文档):

public static void printClass(Instrumentation inst) {
    Scanner scanner = new Scanner(System.in);
    System.out.println("请输入类名:");
    String next = scanner.next();
    Class[] allLoadedClasses = inst.getAllLoadedClasses();
    for (Class clazz : allLoadedClasses) {
        if (clazz.getName().equals(next)) {
            ClassFileTransformer transformer = new ClassFileTransformer() {
                @Override
                public byte[] transform(Module module, ClassLoader loader, String className,
                                        Class<?> classBeingRedefined,
                                        ProtectionDomain protectionDomain,
                                        byte[] classfileBuffer) throws IllegalClassFormatException {
                    ClassFileToJavaSourceDecompiler decompiler = new ClassFileToJavaSourceDecompiler();
                    Printer printer = new Printer() { /* 省略实现,参考 jd-core 文档 */ };
                    try {
                        decompiler.decompile(new Loader() {
                            @Override
                            public boolean canLoad(String s) { return false; }
                            @Override
                            public byte[] load(String s) throws LoaderException {
                                return classfileBuffer;
                            }
                        }, printer, className);
                    } catch (Exception e) {
                        e.printStackTrace();
                    }
                    return ClassFileTransformer.super.transform(
                            module, loader, className, classBeingRedefined,
                            protectionDomain, classfileBuffer);
                }
            };
            inst.addTransformer(transformer, true);
            try {
                inst.retransformClasses(clazz);
            } catch (UnmodifiableClassException e) {
                e.printStackTrace();
            } finally {
                inst.removeTransformer(transformer);
            }
        }
    }
}

2.6 打印方法执行的参数和耗时

Spring AOP 是不是也可以实现类似的功能呢? Spring AOP 确实可以统计方法执行时间、打印方法参数,但存在几个问题:

  1. 代码有侵入性,AOP 代码必须在当前项目中被引入才能完成相应功能。
  2. 无法做到灵活地开启和关闭功能。
  3. 与 Spring 框架强耦合,如果项目没有使用 Spring 框架就不可以使用。

所以使用 Java Agent 技术 + 字节码增强技术,就可以解决上述三个问题。字节码增强框架在当前类的字节码信息中插入一部分字节码指令,从而起到增强的作用。

字节码增强

ASM

ASM 是一个通用的 Java 字节码操作和分析框架,它可用于直接以二进制形式修改现有类或动态生成类。ASM 重点关注性能,让操作尽可能小且尽可能快,所以它非常适合在动态系统中使用。ASM 的缺点是代码复杂。官方网址:https://asm.ow2.io/。

ASM

引入依赖:

<dependency>
    <groupId>org.ow2.asm</groupId>
    <artifactId>asm</artifactId>
    <version>9.6</version>
</dependency>

基础框架与 MethodVisitor 子类示例:

public class ASMDemo {
 
    public static byte[] classASM(byte[] bytes) {
        ClassWriter cw = new ClassWriter(0);
        ClassVisitor cv = new ClassVisitor(ASM7, cw) {
            @Override
            public MethodVisitor visitMethod(int access, String name, String descriptor,
                                             String signature, String[] exceptions) {
                MethodVisitor mv = cv.visitMethod(access, name, descriptor, signature, exceptions);
                return new MyMethodVisitor(this.api, mv);
            }
        };
        ClassReader cr = new ClassReader(bytes);
        cr.accept(cv, 0);
        return cw.toByteArray();
    }
}
 
class MyMethodVisitor extends MethodVisitor {
 
    public MyMethodVisitor(int api, MethodVisitor methodVisitor) {
        super(api, methodVisitor);
    }
 
    @Override
    public void visitCode() {
        mv.visitFieldInsn(Opcodes.GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
        mv.visitLdcInsn("开始执行");
        mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println",
                "(Ljava/lang/String;)V", false);
        super.visitCode();
    }
 
    @Override
    public void visitInsn(int opcode) {
        if (opcode == ARETURN || opcode == RETURN) {
            mv.visitFieldInsn(Opcodes.GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
            mv.visitLdcInsn("结束执行");
            mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println",
                    "(Ljava/lang/String;)V", false);
        }
        super.visitInsn(opcode);
    }
 
    @Override
    public void visitEnd() {
        mv.visitMaxs(20, 50);
        super.visitEnd();
    }
}

Byte Buddy

Byte Buddy 是一个代码生成和操作库,用于在 Java 应用程序运行时创建和修改 Java 类,无需编译器的帮助。Byte Buddy 底层基于 ASM,提供了非常方便的 API。官网:https://bytebuddy.net/。

Byte Buddy

引入依赖:

<dependency>
    <groupId>net.bytebuddy</groupId>
    <artifactId>byte-buddy</artifactId>
    <version>1.14.10</version>
</dependency>
<dependency>
    <groupId>net.bytebuddy</groupId>
    <artifactId>byte-buddy-agent</artifactId>
    <version>1.14.10</version>
</dependency>

编写一个 Advice 通知描述如何去增强类:

public class ByteBuddyDemo {
    public static void main(String[] args) throws Exception {
        Foo foo = new Foo();
        MyClassLoader myClassLoader = new MyClassLoader();
        Class<? extends Foo> newClazz = new ByteBuddy()
                .subclass(Foo.class)
                .method(ElementMatchers.any())
                .intercept(Advice.to(MyAdvice.class))
                .make()
                .load(myClassLoader)
                .getLoaded();
        Foo foo1 = newClazz.getDeclaredConstructor().newInstance();
        foo1.test();
    }
}
 
class MyAdvice {
    @Advice.OnMethodEnter
    static void onEnter() { System.out.println("方法进入"); }
    @Advice.OnMethodExit
    static void onExit() { System.out.println("方法退出"); }
}

利用 AgentBuilder 对已加载的类进行增强:

public class ClassEnhancerCommand {
 
    public static void enhanceClass(Instrumentation inst) {
        Scanner scanner = new Scanner(System.in);
        System.out.println("请输入类名:");
        String next = scanner.next();
        Class[] allLoadedClasses = inst.getAllLoadedClasses();
        for (Class clazz : allLoadedClasses) {
            if (clazz.getName().equals(next)) {
                new AgentBuilder.Default()
                        .disableClassFormatChanges()
                        .with(AgentBuilder.RedefinitionStrategy.RETRANSFORMATION)
                        .with(new AgentBuilder.Listener.WithTransformationsOnly(
                                AgentBuilder.Listener.StreamWriting.toSystemOut()))
                        .type(ElementMatchers.named(clazz.getName()))
                        .transform((builder, type, classLoader, module, protectionDomain) ->
                                builder.visit(Advice.to(MyAdvice.class).on(ElementMatchers.any())))
                        .installOn(inst);
            }
        }
    }
}

打印方法参数和耗时的 Advice:

class MyAdvice {
    @Advice.OnMethodEnter
    static long enter(@Advice.AllArguments Object[] ary) {
        if (ary != null) {
            for (int i = 0; i < ary.length; i++) {
                System.out.println("Argument: " + i + " is " + ary[i]);
            }
        }
        return System.nanoTime();
    }
 
    @Advice.OnMethodExit
    static void exit(@Advice.Enter long value) {
        System.out.println("耗时为:" + (System.nanoTime() - value) + "纳秒");
    }
}

最后将整个简化版的 arthas 进行打包,在服务器上进行测试。使用 maven-shade-plugin 插件可以将所有依赖打入同一个 jar 包中并指定入口 main 方法:

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-shade-plugin</artifactId>
    <version>1.4</version>
    <executions>
        <execution>
            <phase>package</phase>
            <goals>
                <goal>shade</goal>
            </goals>
            <configuration>
                <finalName>itheima-attach-agent</finalName>
                <transformers>
                    <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
                        <mainClass>com.itheima.jvm.javaagent.AttachMain</mainClass>
                    </transformer>
                </transformers>
            </configuration>
        </execution>
    </executions>
</plugin>

打包测试

3.实战案例 2:APM 系统的数据采集

Application performance monitor (APM) 应用程序性能监控系统是采集运行程序的实时数据并使用可视化的方式展示,使用 APM 可以确保系统可用性、优化服务性能和响应时间、持续改善用户体验。常用的 APM 系统有 Apache Skywalking、Zipkin 等。Skywalking 官方网站:https://skywalking.apache.org/。Java 程序中的第一步数据采集使用的就是 Java Agent 技术。

APM 系统

需求:编写一个简化版的 APM 数据采集程序,具备以下功能:

  1. 无侵入性获取 spring boot 应用中 controller 层方法的调用时间。
  2. 将所有调用时间写入文件中。

问题:Java agent 采用静态加载模式还是动态加载模式?由于程序启动之后就需要持续地进行信息的采集,所以采用静态加载模式。

3.1 Java Agent 参数的获取

在 Java Agent 中,可以通过如下方式传递参数:

java -javaagent:./agent.jar=参数 -jar test.jar

接下来通过 premain 参数中的 agentArgs 字段获取。如果有多个参数:

java -javaagent:./agent.jar=param1=value1,param2=value2 -jar test.jar

在 Java 代码中使用字符串解析出对应的 key value。

参数获取

3.2 Byte Buddy 参数的传递

在 Java Agent 中如果需要传递参数到 Byte Buddy,可以采用如下方式:

  1. 绑定 Key Value,Key 是一个自定义注解,Value 是参数的值。
  2. 自定义注解。
  3. 通过注解注入。

Byte Buddy 参数传递

3.3 完整实现

AgentMain 的 premain 方法使用 ByteBuddy 增强带 @RestController 或 @Controller 注解的类:

public class AgentMain {
 
    public static void premain(String agentArgs, Instrumentation inst) {
        new AgentBuilder.Default()
                //禁止 byte buddy 处理时修改类名
                .disableClassFormatChanges()
                //处理时使用 retransform 增强
                .with(AgentBuilder.RedefinitionStrategy.RETRANSFORMATION)
                //打印出错误日志
                .with(new AgentBuilder.Listener.WithTransformationsOnly(
                        AgentBuilder.Listener.StreamWriting.toSystemOut()))
                //匹配哪些类
                .type(ElementMatchers.isAnnotatedWith(ElementMatchers
                        .named("org.springframework.web.bind.annotation.RestController")
                        .or(ElementMatchers.named("org.springframework.web.bind.annotation.Controller"))))
                //增强,使用 MyAdvice 通知,对所有方法都进行增强
                .transform((builder, typeDescription, classLoader, module, protectionDomain) ->
                        builder.visit(Advice
                                .withCustomMapping()
                                .bind(AgentParam.class, agentArgs)
                                .to(TimingAdvice.class).on(ElementMatchers.any())))
                .installOn(inst);
    }
}

TimingAdvice 统计耗时并写入文件:

public class TimingAdvice {
 
    //方法进入时,返回开始时间
    @Advice.OnMethodEnter
    static long enter() {
        return System.nanoTime();
    }
 
    //方法退出时候,统计方法执行耗时
    @Advice.OnMethodExit
    static void exit(@Advice.Enter long value,
                     @Advice.Origin("#t") String className,
                     @Advice.Origin("#m") String methodName,
                     @AgentParam("agent.log") String fileName) {
        String str = methodName + "@" + className + "耗时为: "
                + (System.nanoTime() - value) + "纳秒\n";
        try {
            FileUtils.writeStringToFile(new File(fileName), str, StandardCharsets.UTF_8, true);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

启动 spring boot 服务时,添加 javaagent 的路径,并添加文件名参数即可生效。

4.总结

Arthas 这款工具用到了什么 Java 技术?

Arthas 主要使用了 Java Agent 技术,这种技术可以让运行中的 Java 程序执行 Agent 中编写代码。Arthas 使用了 Agent 中的动态加载模式,可以选择让某个特定的 Java 进程加载 Agent 并执行其中的监控代码。监控方面主要使用的就是 JMX 提供的一些监控指标,同时使用字节码增强技术,对某些类和某些方法进行增强,从而监控方法的执行耗时、参数等内容。

Arthas 总结

APM 系统是如何获取到 Java 程序运行中的性能数据的?

APM 系统比如 Skywalking 主要使用了 Java Agent 技术,这种技术可以让运行中的 Java 程序执行 Agent 中编写代码。Skywalking 编写了 Java Agent,使用了 Agent 中的静态加载模式,使用字节码增强技术,对某些类和某些方法进行增强,从而监控方法的执行耗时、参数等内容。比如对 Controller 层方法增强获取接口调用的时长信息,对数据库连接增强获取数据库查询的时长、SQL 语句等信息。

APM 总结