重读 JVM - javac & javap

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

class

引入

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

compiler_java

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

.java -> .class, javac

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

例如编写一个 TestClass.java

1
2
3
4
5
6
7
8
9
package cn.sevenyuan;

public class TestClass {
private int number;

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

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

1
$ javac -verbose TestClass.java

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

1
2
3
4
5
$ 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 试验

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
$ 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."<init>":()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 <init>
#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 // "<init>":()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."<init>":()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() 方法在操作系统下底层的逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
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 也有帮助~)

资料参考

  1. 深入理解Java Class文件格式(一)
  2. 通过javap命令分析java汇编指令