重读 JVM – javac & javap

最近看到周志明大神的《深入理解 Java 虚拟机》出了第三版,想想之前看完了第二版,当时处于一知半解的状态,所以趁着这个机会,重新学习,看完了第三版,于是做个记录。

class

引入

class 文件出现的目的是为了平台兼容性,Java 的口号是「一次编写,到处运行」 “Write once,run anywhere”,所以用 Java 这门高级语言的编写 .java 文件后,通过编译器编译输出 .class 这种平台无关的字节码文件,不需要关注是哪个厂商生产的 jvm。

在上图中,实现平台无关性的核心在于虚拟机和字节码存储格式的 .class 文件,了解到,通过其它语言编写的程序也能在 jvm 上运行,例如 ruby、groovy 语言等,是通过 jruby、groovyc 编译器,输出字节码格式的 .class 文件,最终能够在 jvm 上运行。

.java -> .class, javac

从编写的 .java 文件到 .class 文件,可以通过 javac 命令进行编译

例如编写一个 TestClass.java

package cn.sevenyuan;

public class TestClass{
    private int number;

    public int inc(){
        return number + 1;
    }
}

编译语句:(加了 -verbose 是可以在输出设备上显示虚拟机运行信息)

$ javac -verbose TestClass.java

其中,package 包名随意,文件名记得要与类名一致,不然编译时将会报错,例如文件名为 TestClass.java,但是类名是 class Test,编译错误如下:

$ javac -verbose TestClass.java
TestClass.java:3: 错误: 类Test是公共的, 应在名为 Test.java 的文件中声明
public class Test {
       ^
1 个错误

class 文件格式

类加载器读取的是 .class 文件,在日常代码编写的时候,的确不需要关注它,但为了深入学习和了解它的结构,可能之后会使用到,所以这里做个记录。

class 文件是一组以 8 个字节为基础单位的二进制流,每个数据项严格按照顺序紧凑地排列在文件中,中间没有间隔符。

下图使用的是 UltraEdit 这个软件,打开 .class 字节码文件的内容(这里来复习一下计算机的字节码格式,一个字节有 8 位,每一位是 0 或 1,是机器能够识别的二进制语言)

打开文件能看到里面是 16 进制的文本信息

  • magic number

前四个字节「cafebabe」:是一个魔数,它的唯一作用就是表示该文件能否被 jvm 识别,关于它的小故事可以另外搜索一下~

  • minor version & major version

魔数后面的四个字节:第五和第六的「00 00」表示次版本号(minor version),第七和第八字节「00 34」表示的是主版本号(Major version),第一代 jvm 1.1 的版本号是 45,十六进制的 0x34 转换成十进制为 3 $16^1$ + 4 $16^0$ = 52,所以与第一代相隔 7 个版本, 表示我使用的是 jdk8,第八代 jvm。

设置版本号的原因是,jvm 不能执行比自己版本高的 class 文件,也就是说,如果使用 jdk9 编译的代码,是不能再 jvm8 上运行的,但可以向下兼容,使用 jdk7 编译的代码,能在 jvm8 上运行。

如果用低版本 jdk 运行高版本的 class 字节码,将会报以下错误:

  • 常量池 constant pool

在次主版本号后面,是常量池入口,常量池可以用来比喻为 class文件里的资源仓库。由于常量池中常量的数量不是固定的,所以在入口处需要告知常量池中有多少个常量。

而且下标起点与常规的 java 习惯不太一样,它的下标是从 1 开始的,入口位置在 class 文件的偏移地址:0x00000008

详细数据项对照表请参考书中的 6-3 配图

类型 标志 说明
CONSTANT_Utf8_info 1 UTF-8编码的字符串
CONSTANT_Integer_info 3 整型字面量
CONSTANT_Float_info 4 浮点型字面量
CONSTANT_Long_info 5 长整型字面量
CONSTANT_Double_info 6 双精度浮点型字面量
CONSTANT_Class_info 7 类或接口的符号引用
CONSTANT_String_info 8 字符串类型字面量
CONSTANT_Fieldref_info 9 字段的符号引用
CONSTANT_Methodref_info 11 类中方法的符号引用
CONSTANT_InterfaceMethodref_info 11 接口中方法的符号引用
CONSTANT_NumberAndType_info 12 字段或方法的部分符号引用
CONSTANT_MethodHandle_info 15 表示方法句柄
CONSTANT_MethodType_info 16 表示方法类型
CONSTANT_Dynamic_info 17 表示一个动态计算常量
CONSTANT_InvokeDynamic_info 18 表示一个动态方法调用点
CONSTANT_Module_info 19 表示一个模块
CONSTANT_Package_info 20 表示一个模块中开放或者导出的包

常量池中每一项常量都是一个表,每种不同类型都能从常量表中找出对应项。表中的 tag 和 value,tag 表示它的类型,value 就是它的值。

我是这样理解常量池中的数据项,tag info,类比于 String name 这种编程习惯,前面是类型修饰符,后面是它的值。

数据项之间有着完全不同的结构,如果要手工参考这么多张表找出实际含义,有点费眼,所以推荐下面这个字节码反编译工具:javap

分析工具 javap

简介

javap 全称是 Java class file disassembler ,/jdk/bin 目录下的字节码反编译工具,使用该工具,可以反编译出当前类对应的类名、版本号、常量池和代码区(code)等信息,反编译出来的信息更加清晰和直观。

通过 man javap 命令就能在终端下初步了解 javap 的用法

使用方式: javap [ options ] class

其中, 可能的选项 [ options ] 包括:

标志 解释
-help –help -? 输出此用法消息
-version 版本信息
-v -verbose 输出附加信息
-l 输出行号和本地变量表
-public 仅显示公共类和成员
-protected 显示受保护的/公共类和成员
-package 显示程序包/受保护的/公共类和成员 (默认)
-p -private 显示所有类和成员
-c 对代码进行反汇编
-s 输出内部类型签名
-sysinfo 显示正在处理的类的 系统信息 (路径, 大小, 日期, MD5 散列)
-constants 显示最终常量
-classpath 指定查找用户类文件的位置
-cp 指定查找用户类文件的位置
-bootclasspath 覆盖引导类文件的位置

最后一个参数 class ,是前面编译后的文件,输入时不需要带上 .class 后缀

查看反编译后的结果

拿开头编译出来的 TestClass.class 试验

$ javap -verbose TestClass
Classfile /Users/jingqi/Deploy/Project/VSCode/TestClass.class
  Last modified 2020-2-16; size 293 bytes
  MD5 checksum 1b9eeadb7d1396ca4fa706e0b0bc7ac8
  Compiled from "TestClass.java"
public class cn.sevenyuan.TestClass
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref          #4.#15         // java/lang/Object."":()V
#2 = Fieldref           #3.#16         // cn/sevenyuan/TestClass.number:I
#3 = Class              #17            // cn/sevenyuan/TestClass
#4 = Class              #18            // java/lang/Object
#5 = Utf8               number
#6 = Utf8               I
#7 = Utf8               
#8 = Utf8               ()V
#9 = Utf8               Code
#10 = Utf8               LineNumberTable
#11 = Utf8               inc
#12 = Utf8               ()I
#13 = Utf8               SourceFile
#14 = Utf8               TestClass.java
#15 = NameAndType        #7:#8          // "":()V
#16 = NameAndType        #5:#6          // number:I
#17 = Utf8               cn/sevenyuan/TestClass
#18 = Utf8               java/lang/Object
{
  public cn.sevenyuan.TestClass();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."":()V
         4: return
      LineNumberTable:
        line 3: 0

  public int inc();
    descriptor: ()I
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=1, args_size=1
         0: aload_0                     // 0处的局部变量中的objectref被压入操作数堆栈,这里的是 this 对象
         1: getfield      #2             // Field number:I,从操作数堆栈中弹出的引用类型为objectref,这里获取的是 number 对象引用
         4: iconst_1                    // 将int常量 1 压入操作数堆栈
         5: iadd                        // 弹出栈中的 number 值和 int 常量 1,进行加操作,并将结果压入栈
         6: ireturn                     // 从方法返回int
      LineNumberTable:
        line 7: 0
}
SourceFile: "TestClass.java"

在输出信息头部,能看到 minor versionmajor versionConstant pool 等前面提到的信息,比根据字节码去查找一一对应看得更舒适。

刚开始看代码去里的 aload_0 、iadd 和 iconst_1 等可能有些疑惑,反编译出来 JVM 指令集可以参考 oracle 官方文档: The Java Virtual Machine Instruction Set

例如 aload_0 指令可以这样搜索查看:

参考文档后,可以大致理解我们 inc() 方法在操作系统下底层的逻辑:

public int inc();
    descriptor: ()I
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=1, args_size=1
         0: aload_0                          // 0处的局部变量中的objectref被压入操作数堆栈,这里的是 this 对象
         1: getfield      #2                  // Field number:I,从操作数堆栈中弹出的引用类型为objectref,这里获取的是 number 对象引用
         4: iconst_1                        // 将int常量 1 压入操作数堆栈
         5: iadd                            // 弹出栈中的 number 值和 int 常量 1,进行加操作,并将结果压入栈
         6: ireturn                         // 从方法返回int
      LineNumberTable:
        line 7: 0

小结

常规开发中,使用的是 java 高级语言,可能没有多少关注到 jvm 底层执行逻辑,这次了解学习 class 字节码,直接查看十六位进制文件有点吃力,所以通过 javap 命令来查看反编译后的信息,学习 jvm 指令集。

通过简单对比后,了解到简单的 inc() 方法,里面一行的 return number + 1 代码,经过反汇编之后,原来经历了

  • this 对象入栈
  • number 对象引用入栈
  • 整型常量 1 入栈
  • 对象出栈,两者相加后,将结果压入栈
  • 最后弹出栈信息

机器只认识操作码,简单的数值加一经过反编译后,可以看到里面的局部变量表、常量池和操作数栈,机器后续一系列复杂操作都从中可以窥探,所以了解学习字节码格式,之后学习操作系统会有一定的帮助(或者说两者可以互补,操作系统知识对学习 jvm 也有帮助~)