1. 类字节码结构
本文参考Java全栈知识体系所作笔记,图片版权归作者所有。
1.1 多语言编译为字节码在JVM运行
计算机是不能直接运行java代码的,必须要先运行java虚拟机,再由java虚拟机运行编译后的java代码。
为什么jvm不能直接运行java代码呢,这是因为在cpu层面看来计算机中所有的操作都是一个个指令的运行汇集而成的,java是高级语言,只有人类才能理解其逻辑,计算机是无法识别的,所以java代码必须要先编译成字节码文件,jvm才能正确识别代码转换后的指令并将其运行。
- Java代码间接翻译成字节码,储存字节码的文件再交由运行于不同平台上的JVM虚拟机去读取执行,从而实现一次编写,到处运行的目的。
- JVM也不再只支持Java,由此衍生出了许多基于JVM的编程语言,如Groovy, Scala, Koltin等等。
1.2 Java字节码文件
class文件(即字节码文件)本质上是一个以8位字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑的排列在class文件中。jvm根据其特定的规则解析该二进制数据,从而得到相关信息。
Class文件采用一种伪结构来存储数据,它有两种类型:无符号数和表。
1.3 Class文件的结构属性
字节码文件包含以下类型的数据:
魔数:
- 在代码中出现但没有解释的数字常量或字符串,又称魔法值,在代码开发中应该尽量避免。
- 大多数情况下,我们都是通过扩展名来识别一个文件的类型的,但是扩展名是可以修改的,当一个文件的扩展名被修改过,识别一个文件的类型就必须使用“魔数”。很多类型的文件,其起始的几个字节的内容是固定的(或是有意填充,或是本就如此)这几个字节的内容也被称为魔数,因为根据这几个字节的内容就可以确定文件类型。
2. 字节码实现
2.1 编译文件
以下面的例子为例:
1 | //Main.java |
1 | javac Main.java //通过Javac命令进行编译 |
以文本形式打开生成的class文件:
1 | cafe babe 0000 0034 0013 0a00 0400 0f09 |
文件开头的4个字节(“cafe babe”)称之为
魔数
,唯有以”cafe babe”开头的class文件方可被虚拟机所接受,这4个字节就是字节码文件的身份识别。0000是编译器jdk版本的次版本号0,0034转化为十进制是52,是主版本号,java的版本号从45开始,除1.0和1.1都是使用45.x外,以后每升一个大版本,版本号加一。也就是说,编译生成该class文件的jdk版本为1.8.0。
2.2 反编译字节码文件
使用到java内置的一个反编译工具javap可以反编译字节码文件, 用法:
javap <options> <classes>
其中,<options>
选项包括:
1 | -help --help -? 输出此用法消息 |
输入命令javap -verbose -p Main.class
查看输出内容:
1 | Classfile /E:/JavaCode/TestProj/out/production/TestProj/com/rhythm7/Main.class //Class文件当前所在位置, |
开头的7行信息包括:Class文件当前所在位置,最后修改时间,文件大小,MD5值,编译自哪个文件,类的全限定名,jdk次版本号,主版本号。
然后紧接着的是该类的访问标志:ACC_PUBLIC, ACC_SUPER,访问标志的含义如下:
标志名称 | 标志值 | 含义 |
---|---|---|
ACC_PUBLIC | 0x0001 | 是否为Public类型 |
ACC_FINAL | 0x0010 | 是否被声明为final,只有类可以设置 |
ACC_SUPER | 0x0020 | 是否允许使用invokespecial字节码指令的新语义 |
ACC_INTERFACE | 0x0200 | 标志这是一个接口 |
ACC_ABSTRACT | 0x0400 | 是否为abstract类型,对于接口或者抽象类来说,次标志值为真,其他类型为假 |
ACC_SYNTHETIC | 0x1000 | 标志这个类并非由用户代码产生 |
ACC_ANNOTATION | 0x2000 | 标志这是一个注解 |
ACC_ENUM | 0x4000 | 标志这是一个枚举 |
2.3 常量池(Constant pool)
常量池主要存放的是两大类常量:
- 字面量(Literal)
- 符号引用(Symbolic References)
- 类和接口的全限定名(Fully Qualified Name)
- 字段的名称和描述符号(Descriptor)
- 方法的名称和描述符
1 | #1 = Methodref #4.#18 // java/lang/Object."<init>":()V |
第一个常量是一个方法定义,指向了第4和第18个常量。以此类推查看第4和第18个常量。最后可以拼接成第一个常量右侧的注释内容:
1 | java/lang/Object."<init>":()V |
这段可以理解为该类的实例构造器的声明,由于Main类没有重写构造方法,所以调用的是父类的构造方法。此处也说明了Main类的直接父类是Object。 该方法默认返回值是V, 也就是void,无返回值。
1 | #2 = Fieldref #3.#19 // com/rhythm7/Main.m:I |
此处声明了一个字段m,类型为I, I即是int类型。关于字节码的类型对应如下:
标识字符 | 含义 |
---|---|
B | 基本类型byte |
C | 基本类型char |
D | 基本类型double |
F | 基本类型float |
I | 基本类型int |
J | 基本类型long |
S | 基本类型short |
Z | 基本类型boolean |
V | 特殊类型void |
L | 对象类型,以分号结尾,如Ljava/lang/Object; |
对于数组类型,每一位使用一个前置的[
字符来描述,如定义一个java.lang.String[][]
类型的维数组,将被记录为[[Ljava/lang/String;
2.4 方法表集合
在常量池之后的是对类内部的方法描述,在字节码中以表的集合形式表现,暂且不管字节码文件的16进制文件内容如何,我们直接看反编译后的内容。
1 | private int m; |
此处声明了一个私有变量m,类型为int,返回值为int
1 | public com.rhythm7.Main(); |
这里是构造方法:Main(),返回值为void, 公开方法。
code
内的主要属性为:
stack: 最大操作数栈,JVM运行时会根据这个值来分配栈帧(Frame)中的操作栈深度,此处为1
locals: 局部变量所需的存储空间,单位为Slot, Slot是虚拟机为局部变量分配内存时所使用的最小单位,为4个字节大小。方法参数(包括实例方法中的隐藏参数this),显示异常处理器的参数(try catch中的catch块所定义的异常),方法体中定义的局部变量都需要使用局部变量表来存放。值得一提的是,locals的大小并不一定等于所有局部变量所占的Slot之和,因为局部变量中的Slot是可以重用的。
args_size: 方法参数的个数,这里是1,因为每个实例方法都会有一个隐藏参数this。
attribute_info: 方法体内容,0,1,4为字节码”行号”,该段代码的意思是将第一个引用类型本地变量推送至栈顶,然后执行该类型的实例方法,也就是常量池存放的第一个变量,也就是注释里的”java/lang/Object.”
“:()V, 然后执行返回语句,结束方法。 LineNumberTable: 该属性的作用是描述源码行号与字节码行号(字节码偏移量)之间的对应关系。可以使用 -g:none 或-g:lines选项来取消或要求生成这项信息,如果选择不生成LineNumberTable,当程序运行异常时将无法获取到发生异常的源码行号,也无法按照源码的行数来调试程序。
LocalVariableTable: 该属性的作用是描述帧栈中局部变量与源码中定义的变量之间的关系。可以使用 -g:none 或 -g:vars来取消或生成这项信息,如果没有生成这项信息,那么当别人引用这个方法时,将无法获取到参数名称,取而代之的是arg0, arg1这样的占位符。 start 表示该局部变量在哪一行开始可见,length表示可见行数,Slot代表所在帧栈位置,Name是变量名称,然后是类型签名。