11

重读 JVM - javac & javap

 4 years ago
source link: https://www.sevenyuan.cn/2020/02/16/java/2020-02-16-reread-jvm1/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

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

class

引入

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

V3QVf2A.png!web

在上图中,实现平台无关性的核心在于虚拟机和字节码存储格式的 .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 进制的文本信息

zeqeia6.png!web

  • 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 字节码,将会报以下错误:

jayAVfm.png!web

  • 常量池 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 的用法

UFrYzei.png!web

使用方式: 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."<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 指令可以这样搜索查看:

uE3qqmm.png!web

参考文档后,可以大致理解我们 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 也有帮助~)


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK