37 JVM开发必备:三款核心字节码工具完全指南

图片[1]-37 JVM开发必备:三款核心字节码工具完全指南-速优课

本文导读

学习 JVM 的过程中,我们经常需要和字节码打交道:查看字节码、修改字节码、分析对象内存布局…… 工欲善其事,必先利其器。掌握好相关工具,能让你的 JVM 学习之路事半功倍。

本文将系统介绍三款 JVM 开发中最常用的工具:

  • javap:JDK 自带的字节码反汇编工具,快速查看 class 文件内容
  • OpenJDK Code Tools:包含 ASMTools 和 JOL 等实用小工具
  • ASM:功能强大的字节码分析与修改框架,众多开源项目的基石

通过阅读本文,你将了解:

  • javap 的输出结构各部分分别代表什么含义
  • 如何使用 ASMTools 手工修改字节码
  • 如何用 JOL 查看对象的内存布局
  • ASM 框架的核心设计思想和访问者模式
  • 如何用 ASM 生成和修改 class 文件

一、javap:JDK 自带的字节码反汇编工具

1.1 javap 是什么

javap 是 JDK 自带的反汇编工具,可以把 class 文件反汇编成人类可读的格式。学习 JVM 的过程中,我们会频繁地用它来查看编译后的字节码。

先看一段示例代码:

public class Foo {
  private int tryBlock;
  private int catchBlock;
  private int finallyBlock;
  private int methodExit;
​
  public void test() {
    try {
      tryBlock = 0;
    } catch (Exception e) {
      catchBlock = 1;
    } finally {
      finallyBlock = 2;
    }
    methodExit = 3;
  }
}

编译后用 javap 查看:

$ javac Foo.java
$ javap -p -v Foo

这里用到了两个常用选项:

  • -p:打印私有字段和方法(默认只打印非私有的)
  • -v:输出详细信息(如果只想看方法字节码,可以用 -c 替代 -v

javap -v 的输出内容很多,我们分块来解读。

1.2 基本信息区

最开头是 class 文件的基本信息:

Classfile ../Foo.class
  Last modified ..; size 541 bytes
  MD5 checksum 3828cdfbba56fea1da6c8d94fd13b20d
  Compiled from "Foo.java"
public class Foo
  minor version: 0
  major version: 54
  flags: (0x0021) ACC_PUBLIC, ACC_SUPER
  this_class: #7                          // Foo
  super_class: #8                         // java/lang/Object
  interfaces: 0, fields: 4, methods: 2, attributes: 1

这里包含了:

  • class 文件版本号minor version: 0, major version: 54。版本号和 Java 版本对应——Java 8 是 52,Java 10 是 54,Java 11 是 55。
  • 访问标志flags: (0x0021) ACC_PUBLIC, ACC_SUPER,表示这个类是 public 的。
  • 类名与父类this_class 指向当前类,super_class 指向父类。
  • 计数信息:接口数、字段数、方法数、属性数。

注意:新版本的 javac 编译的 class 文件不能在旧版本的 JRE 上运行,否则会抛出 UnsupportedClassVersionError

1.3 常量池

接下来是常量池(Constant Pool),用来存放各种常量和符号引用:

Constant pool:
   #1 = Methodref          #8.#23         // java/lang/Object."<init>":()V
   #2 = Fieldref           #7.#24         // Foo.tryBlock:I
   ...
  #14 = Utf8               <init>
  #15 = Utf8               ()V
  ...
  #23 = NameAndType        #14:#15        // "<init>":()V
  ...
  #30 = Utf8               java/lang/Object

常量池的每一项都有一个索引(如 #1),并且可能引用其他常量池项。比如 #1 是一个方法引用,它引用了 #8(类名)和 #23(名称和类型),而 #23 又引用了 #14(方法名)和 #15(描述符)。

可以把它想象成一棵树结构,叶子节点是字符串常量:

图片[2]-37 JVM开发必备:三款核心字节码工具完全指南-速优课

1.4 字段区

字段区列举了类中的各个字段:

  private int tryBlock;
    descriptor: I
    flags: (0x0002) ACC_PRIVATE

主要信息是字段的类型(descriptor)和访问权限(flags)。对于 final 修饰的静态字段,如果是基本类型或字符串类型,还会显示常量值。

1.5 方法区

方法区是最核心的部分,每个方法都包含最重要的代码区域(Code):

  public void test();
    descriptor: ()V
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         0: aload_0
         1: iconst_0
         2: putfield      #2                  // Field tryBlock:I
         5: aload_0
         6: iconst_2
         7: putfield      #3                  // Field finallyBlock:I
        10: goto          35
        13: astore_1
        14: aload_0
        15: iconst_1
        16: putfield      #5                  // Field catchBlock:I
        19: aload_0
        20: iconst_2
        21: putfield      #3                  // Field finallyBlock:I
        24: goto          35
        27: astore_2
        28: aload_0
        29: iconst_2
        30: putfield      #3                  // Field finallyBlock:I
        33: aload_2
        34: athrow
        35: aload_0
        36: iconst_3
        37: putfield      #6                  // Field methodExit:I
        40: return
      Exception table:
         from    to  target type
             0     5    13   Class java/lang/Exception
             0     5    27   any
            13    19    27   any
      LineNumberTable:
        line 9: 0
        ...
      StackMapTable: number_of_entries = 3
        ...

代码区包含以下几部分:

1. 头部信息

  • stack=2:操作数栈的最大深度
  • locals=3:局部变量表的大小(注意这是字节码层面的局部变量,不完全等同于 Java 源码中的局部变量)
  • args_size=1:方法接收的参数个数

2. 字节码指令 每条指令前面的数字是字节码偏移量(Bytecode Index, BCI),用来定位指令。比如 10: goto 35 表示偏移量 10 处的 goto 指令,跳转到偏移量 35 处。

3. 异常表(Exception table) 记录了每个异常处理器的信息:

  • fromto:监控的代码范围
  • target:异常处理器的起始位置
  • type:捕获的异常类型,any 表示任意异常

4. 行号表(LineNumberTable) Java 源码行号到字节码偏移量的映射。如果编译时加上 -g 参数,还会有局部变量表(LocalVariableTable),展示每个局部变量的名字、类型和作用域。这些都属于调试信息,JVM 不要求 class 文件必须包含。

5. 栈映射表(StackMapTable) 描述了字节码跳转后操作数栈的分布情况,主要用于类加载时的类型校验和即时编译,一般不需要深入了解。


二、OpenJDK Code Tools:实用小工具集

2.1 Code Tools 项目简介

OpenJDK 的 Code Tools 项目包含好几个实用的小工具。这里介绍其中两个最常用的:

  • ASMTools:字节码汇编器/反汇编器
  • JOL:对象内存布局分析工具

2.2 ASMTools:手工修改字节码

ASMTools 可以把 class 文件反汇编成文本格式(.jasm),修改后再汇编回 class 文件。这在我们需要生成一些 Java 编译器无法生成的特殊类时非常有用。

反汇编命令:

$ java -cp /path/to/asmtools.jar org.openjdk.asmtools.jdis.Main Foo.class > Foo.jasm

汇编命令:

$ java -cp /path/to/asmtools.jar org.openjdk.asmtools.jasm.Main Foo.jasm

它的输出格式和 javap 不太一样,主要用来做一些简单的字节码修改。

举个实战例子:我们知道 Java 编译器不允许把 int 值 2 赋给 boolean 变量,但在字节码层面其实是可以的(boolean 在字节码中就是用 int 实现的)。

用 ASMTools 就可以实现这个”黑魔法”:

  1. 先写一个正常的 Java 类并编译
  2. 用 ASMTools 反汇编成 .jasm 文件
  3. 找到 boolean flag = true 对应的字节码:iconst_1;
    istore_1;
  4. iconst_1 改成 iconst_2
  5. 保存后再汇编回 class 文件

这样就得到了一个 flag 值为 2 的 boolean 变量,这是 Java 编译器永远不会生成的。

同样,我们也可以用 ASMTools 验证一些 JVM 规范中的结论。比如 class 文件允许存在参数类型相同但返回类型不同的方法(Java 语言层面不允许,但字节码层面允许)。验证方法就是反汇编后复制一个方法,修改它的描述符(返回类型),再汇编回去看看能不能正常加载运行。

2.3 JOL:查看对象内存布局

JOL(Java Object Layout)是另一个非常实用的工具,可以用来查看 Java 对象在内存中的具体布局。

常用的两条命令:

# 查看对象内部细节
$ java -jar /path/to/jol-cli-0.9-full.jar internals java.util.HashMap

# 估算对象大小
$ java -jar /path/to/jol-cli-0.9-full.jar estimates java.util.HashMap

在我们学习对象内存布局那篇文章时,JOL 是验证理论知识的绝佳工具。它可以清晰地展示对象头、实例数据、对齐填充各占多少字节,字段是怎么排列的,等等。


三、ASM:强大的字节码操作框架

3.1 ASM 是什么

ASM 是一个功能强大的字节码分析与修改框架,应用非常广泛:

  • 语言编译器:Groovy、Kotlin 的编译器
  • 代码覆盖率工具:Cobertura、JaCoCo
  • 各种字节码注入的监控工具
  • Java 8 的 Lambda 表达式适配器类也是用 ASM 动态生成的

ASM 既可以生成全新的 class 文件,也可以修改已有的 class 文件。

3.2 ASMifier:快速上手 ASM 的神器

ASM 提供了一个辅助类 ASMifier,它可以接收一个 class 文件,然后输出一段能生成该 class 文件原始字节数组的 Java 代码。对于想快速上手 ASM 的人来说,这是最好的学习工具——你想生成什么样的字节码,就先用 Java 写出来编译好,再用 ASMifier 转成 ASM 代码,然后对照着学。

举个例子:

$ echo '
public class Foo {
  public static void main(String[] args) {
    boolean flag = true;
    if (flag) System.out.println("Hello, Java!");
    if (flag == true) System.out.println("Hello, JVM!");
  }
}' > Foo.java
$ javac Foo.java
$ java -cp /PATH/TO/asm-all-6.0_BETA.jar org.objectweb.asm.util.ASMifier Foo.class | tee FooDump.java

生成的 FooDump.java 大致是这样的:

public class FooDump implements Opcodes {

  public static byte[] dump () throws Exception {

    ClassWriter cw = new ClassWriter(0);
    FieldVisitor fv;
    MethodVisitor mv;
    AnnotationVisitor av0;

    cw.visit(V1_8, ACC_PUBLIC + ACC_SUPER, "Foo", null, "java/lang/Object", null);

    // ... 构造器方法等 ...

    {
      mv = cw.visitMethod(ACC_PUBLIC + ACC_STATIC, "main", "([Ljava/lang/String;)V", null, null);
      mv.visitCode();
      mv.visitInsn(ICONST_1);
      mv.visitVarInsn(ISTORE, 1);
      mv.visitVarInsn(ILOAD, 1);
      // ... 更多指令 ...
      mv.visitInsn(RETURN);
      mv.visitMaxs(2, 2);
      mv.visitEnd();
    }
    // ...
    return cw.toByteArray();
  }
}

可以看到:

  • ClassWriter 用来生成类
  • 调用 visitMethod 会得到一个 MethodVisitor
  • MethodVisitor 来生成具体的指令

这就是 ASM 的访问者模式

3.3 修改字节码的原理

生成新的 class 文件比较简单,那修改已有的 class 文件呢?原理也很直观:

ASM 提供了 ClassReader,它会读取 class 文件的原始字节,然后转换成一系列访问请求(也就是调用各种 visit 方法)。

如果我们直接把 ClassReader 的访问请求转发给 ClassWriter,那就是一个简单的”读取-写出”过程,结果和原来一样:

public static void main(String[] args) throws Exception {
    ClassReader cr = new ClassReader("Foo");
    ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES);
    cr.accept(cw, ClassReader.SKIP_FRAMES);
    Files.write(Paths.get("Foo.class"), cw.toByteArray());
}

如果想修改字节码,就在 ClassReaderClassWriter 中间插入一个自定义的访问者。这个访问者把请求转发给 ClassWriter 之前,可以做各种修改:

  • 新增:在转发的请求后面追加新的请求
  • 删除:不转发某些请求
  • 修改:忽略原请求,发出新的请求

整个过程就像一条流水线:

图片[3]-37 JVM开发必备:三款核心字节码工具完全指南-速优课

来看一个具体的例子——把 main 方法的内容替换成一句 System.out.println("Hello, World!");

import java.nio.file.*;
import org.objectweb.asm.*;

public class ASMHelper implements Opcodes {

  static class MyMethodVisitor extends MethodVisitor {
    private MethodVisitor mv;
    public MyMethodVisitor(int api, MethodVisitor mv) {
      super(api, null);
      this.mv = mv;
    }

    @Override
    public void visitCode() {
      mv.visitCode();
      mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
      mv.visitLdcInsn("Hello, World!");
      mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
      mv.visitInsn(RETURN);
      mv.visitMaxs(2, 1);
      mv.visitEnd();
    }
  }

  static class MyClassVisitor extends ClassVisitor {

    public MyClassVisitor(int api, ClassVisitor cv) {
      super(api, cv);
    }

    @Override
    public MethodVisitor visitMethod(int access, String name, String descriptor, String signature,
        String[] exceptions) {
      MethodVisitor visitor = super.visitMethod(access, name, descriptor, signature, exceptions);
      if ("main".equals(name)) {
        return new MyMethodVisitor(ASM6, visitor);
      }
      return visitor;
    }
  }

  public static void main(String[] args) throws Exception {
    ClassReader cr = new ClassReader("Foo");
    ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES);
    ClassVisitor cv = new MyClassVisitor(ASM6, cw);
    cr.accept(cv, ClassReader.SKIP_FRAMES);
    Files.write(Paths.get("Foo.class"), cw.toByteArray());
  }
}

这段代码的逻辑是:

  1. MyClassVisitor 截获对 main 方法的访问
  2. 返回自定义的 MyMethodVisitor 代替原来的 MethodVisitor
  3. MyMethodVisitor 忽略所有传入的指令请求,只在 visitCode 时生成新的指令

这就是 ASM 修改字节码的基本套路。掌握了这个模式,你就能实现各种复杂的字节码操作了。


总结与思考

本文要点回顾

  1. javap:JDK 自带的反汇编工具,是学习字节码的入门必备
    • 输出包括:基本信息、常量池、字段、方法
    • 方法区的 Code 属性包含字节码指令、异常表、行号表等
    • 常用参数:-p(显示私有成员)、-v(详细输出)、-c(仅输出字节码)
  2. OpenJDK Code Tools
    • ASMTools:字节码汇编/反汇编,适合做简单的字节码修改,生成 Java 编译器无法生成的类
    • JOL:查看对象内存布局,验证对象头、字段排列、对齐填充等理论知识
  3. ASM 框架:功能最强大的字节码操作框架
    • 基于访问者模式,通过 ClassReader 读取、ClassVisitor 转换、ClassWriter 写出
    • ASMifier 是学习 ASM 的神器,可以把现有 class 文件转成 ASM 代码
    • 修改字节码的核心是在 reader 和 writer 之间插入自定义访问者

思考与实践

  1. 动手用 javap -v 查看你自己项目中的某个类,尝试读懂它的常量池和方法字节码。
  2. 用 JOL 查看 java.lang.Objectjava.lang.Stringjava.util.HashMap 等常用类的内存布局,和你学到的理论对照一下。
  3. 试着用 ASM 实现一个简单的字节码注入:在某个方法的入口和出口分别打印一条日志。

工具是我们探索 JVM 世界的眼睛和手。熟练掌握这些工具,你就能更高效地学习 JVM,也能更从容地解决实际工作中的各种问题。

© 版权声明
THE END
喜欢就支持一下吧
点赞11
相关推荐
评论 抢沙发

请登录后发表评论

    请登录后查看评论内容

温馨提示:
1、本内容转载于网络,版权归原作者所有!
2、本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。
3、本内容若侵犯到你的版权利益,请联系我们,会尽快给予删除处理!