JVM 中篇
2024-05-18 13:50:03 5 举报
AI智能生成
JVM 中篇
作者其他创作
大纲/内容
第 1 章: Class文件结构
01-概述(学习原因)
字节码文件的跨平台性
Java 的前端编译器
前端编译器 vs 后端编译器
后端编译器是 JVM 的一部分,前端编译器不是
Java 源代码的编译结果是字节码,那么肯定需要有一种编译器能够将 Java 源码编译为字节码,承担这个重要责任的就是配置在 path 环境变量中的
HotSpot VM 并没有强制要求前端编译器只能使用 javac 来编译字节码,其实只要编译结果符合 JVM 规范都可以被 JVM 所识别即可。在 Java 的前端编译器领域,除了 javac 之外,还有一种被大家经常用到的前端编译器,那就是内置在 Eclipse 中的 ECJ (EclipseCompiler for Java) 编译器。和 javac 的全量式编译不同, ECJ 是一种增量式编译器
在 Eclipse 中,当开发人员编写完代码后,使用"Ctrl+S”快捷键时, ECJ 编译器所采取的编译方案是把未编译部分的源码逐行进行编译,而非每次都全量编译。因此 ECJ 的编译效率会比 javac 更加迅速和高效,当然编译质量和 javac 相比大致还是一样的
ECJ 不仅是 Eclipse 的默认内置前端编译器,在 Tomcat 中同样也是使用 ECJ 编译器来编译 jsp 文件。由于 ECJ 编译器是采用 GPLv2 的开源协议进行源代码公开,所以,大家可以登录 eclipse 官网下载 ECJ 编译器的源码进行二次开发
默认情况下, IntelliJ IDEA 使用 javac 编译器。(还可以自己设置为 AspectJ 编译器 ajc
前端编译器并不会直接涉及编译优化等方面的技术,而是将这些具体优化细节移交给 HotSpot 的 JIT 编译器负责。
透过字节码指令看代码细节
解释
1、成员变量遵循就近原则(看编译时类型),方法遵循动态绑定原则(看运行时类型)
1. 对于成员变量,访问遵循就近原则,这是在编译时决定的,即根据引用变量的编译时类型来确定访问哪个类中的成员变量。在你的例子中,虽然`f`的运行时类型是`Son`,但在`System.out.println(f.x)`这一行,访问的是`Father`类中的`x`,因为编译时类型是`Father`
字节码层面证明
2. 对于方法,Java使用动态绑定(也称为运行时多态)原则,这是在运行时决定的。这意味着实际调用的方法取决于对象的运行时类型。在你的例子中,`this.print()`在`Father`类的构造函数中调用,但它实际上调用了`Son`类中覆盖的`print`方法,因为对象的运行时类型是`Son`
动态绑定是在解释器(或JIT编译器)层面来进行的,而不是在编译器层面。字节码是编译器的成果,所以无法通过字节码文件来看多态性。
具体:解释器(或JIT编译器)它确保根据对象的实际类型选择正确的方法版本,从而实现多态性。这使得子类可以覆盖父类的方法,并在运行时根据对象的实际类型来调用正确的方法实现。
2、属性赋值:
1.非静态属性 ① 属性的默认初始化 - ② 显式初始化/代码块初始化(二者并列关系) - ③构造器中初始化 - ④ 有了对象之后通过“对象.属性”或“对象.方法”对属性进行赋值
2.静态属性和非静态属性不同的是 ④通过“类.静态属性”或“类.静态方法”对静态属性进行赋值
1.非静态属性 ① 属性的默认初始化 - ② 显式初始化/代码块初始化(二者并列关系) - ③构造器中初始化 - ④ 有了对象之后通过“对象.属性”或“对象.方法”对属性进行赋值
2.静态属性和非静态属性不同的是 ④通过“类.静态属性”或“类.静态方法”对静态属性进行赋值
静态属性在类加载阶段就完成了显示初始化和静态代码块初始化
从字节码文件看构造器真正包含内容(按顺序):调用父类构造器 -> 非静态属性的显式初始化 -> 非静态代码块代码执行(如果有多个,按顺序)-> 代码里构造器代码
02-虚拟机的基石:Class 文件
字节码文件里是什么?
源代码经过编译器编译之后便会生成一个字节码文件,字节码是一种二进制的类文件,它的内容是 JVM 的指令,而不像C、C++ 经由编译器直接生成机器码
什么是字节码指令 (byte code)?
Java 虚拟机的指令由一个字节长度的、代表着某种特定操作含义的操作码 (opcode) 以及跟随其后的零至多个代表此操作所需参数的操作数 (operand) 所构成。虚拟机中许多指令并不包含操作数,只有一个操作码
一个字节码指令 = 操作码 + [操作数(可选)]
如何解读供虚拟机解释执行的二进制字节码?
方式一:使用二进制看
这里用到的是 Notepad++,需要安装一个 HEX-Editor 插件,或者使用 Binary Viewerd
方式二:使用 javap 指令 jdk 自带的反解析工具
方式三:使用 IDEA 插件:jclasslib 或 jclasslib bytecode viewer 客户端工具
03-Class 字节码文件结构
概述
Class 文件本质
任何一个 Class 文件都对应着唯一一个类或接口的定义信息,但反过来说, Class 文件实际上它并不一定以磁盘文件的形式存在。Class 文件是一组以 8 位字节为基础单位的二进制流(8 位字节的意思是一个字节(Byte)由 8 位二进制位组成)
Class 文件格式
采用一种类似于 C 语言结构体的方式进行数据存储,这种结构中只有两种数据类型:无符号数和表
无符号数属于基本的数据类型,以 u1、u2、u4、u8 来分别代表 1 个字节、2 个字节、4 个字节和 8 个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者按照 UTF-8 编码构成字符串值
表是由多个无符号数或者其他表作为数据项构成的复合数据类型,所有表都习惯性地以“_info”结尾。表用于描述有层次关系的复合结构的数据,整个 Class 文件本质上就是一张表。由于表没有固定长度,所以通常会在其前面加上个数说明
Class 文件结构
充分理解了每一个字节码文件的细节,自己也可以反编译出 Java 源文件来
Class 文件的结构并不是一成不变的,随着 Java 虚拟机的不断发展,总是不可避免地会对 Class 文件结构做出一些调整,但是其基本结构和框架是非常稳定的(也就是说结构基本不会变动,有变动也不大,具体看官网介绍)
官网:https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-4.html
01、魔数:Class 文件的标志
位置:每个 Class 文件开头的 4 个字节的无符号整数称为魔数(Magic Number)
作用:它的唯一作用是确定这个文件是否为一个能被虚拟机接受的有效合法的 Class 文件。即:魔数是 Class 文件的标识符
魔数值固定为 0xCAFEBABE,不会改变。如果一个 Class 文件不以 0xCAFEBABE 开头,虚拟机在进行文件校验的时候就会直接抛出以下错误
Error: A JNI error has occurred, please check your installation and try again Exception in thread "main" java.lang.ClassFormatError: Incompatible magic value 1885430635 in class file StringTest
使用魔数而不是扩展名来进行识别主要是基于安全方面的考虑,因为文件扩展名可以随意地改动
02、Class 文件版本号:兼容性问题
位置:紧接着魔数的 4 个字节存储的是 Class 文件的版本号。同样也是 4 个字节。第 5 个和第 6 个字节所代表的含义就是编译的副版本号 minor_version,而第 7 个和第 8 个字节就是编译的主版本号 major_version。它们共同构成了 class 文件的格式版本号。譬如某个 Class 文件的主版本号为 M,副版本号为 m,那么这个 Class文 件的格式版本号就确定为 M.m
版本号和 Java 编译器的对应关系如下表
Java 的版本号是从 45 开始的,JDK1.1 之后的每个 JDK 大版本发布主版本号向上加 1
兼容性问题:不同版本的 Java 编译器编译的 Class 文件对应的版本是不一样的。目前,高版本的 Java 虚拟机可以执行由低版本编译器生成的 Class 文件,但是低版本的 Java 虚拟机不能执行由高版本编译器生成的 Class 文件。否则 JVM 会抛出 java.lang.UnsupportedClassVersionError 异常。(向下兼容)
在实际应用中,由于开发环境和生产环境的不同,可能会导致该问题的发生。因此,需要我们在开发时,特别注意开发编译的 JDK 版本和生产环境中的 JDK 版本是否一致
虚拟机 JDK 版本为 1.k(k>=2)时,对应的 class 文件格式版本号的范围为 45.0 - 44+k.0(含两端)
03、常量池:存放所有常量(Class 文件资源仓库/基石)
位置:在版本号之后,紧跟着的是常量池的数量,以及若干个常量池表项
构成
1、常量池计数器
由于常量池的数量不固定,时长时短,所以需要放置两个字节来表示常量池容量计数值
常量池容量计数值(u2 类型):从 1 开始,表示常量池中有多少项常量。即 constant_pool_count=1 表示常量池中有 0 个常量项
其值为 0x0016,掐指一算,也就是 22。需要注意的是,这实际上只有 21 项常量。索引为范围是 1-21。为什么呢?
通常我们写代码时都是从 0 开始的,但是这里的常量池却是从 1 开始,因为它把第 0 项常量空出来了。这是为了满足后面某些指向常量池的索引值的数据在特定情况下需要表达“不引用任何一个常量池项目”的含义,这种情况可用索引值 0 来表示
2、常量池表
概述
constant_pool 是一种表结构,以 1 ~ constant_pool_count - 1 为索引
主要存放内容 = 字面量(Literal) + 符号引用(Symbolic References)
它包含了 class 文件结构及其子结构中引用的所有字符串常量、类或接口名、字段名和其他常量。常量池中的每一项都具备相同的特征。第 1 个字节作为类型标记,用于确定该项的格式,这个字节称为 tag byte(标记字节、标签字节)
字面量和符号引用
常量池主要存放两大类常量:字面量(Literal)和符号引用(Symbolic References)
全限定名
com/atguigu/test/Demo 这个就是类的全限定名,仅仅是把包名的“.“替换成”/”,为了使连续的多个全限定名之间不产生混淆,在使用时最后一般会加入一个“;”表示全限定名结束
简单名称
简单名称是指没有类型和参数修饰的方法或者字段名称,上面例子中的类的 add() 方法和 num 字段的简单名称分别是 add 和 num
描述符
描述符的作用是用来描述字段的数据类型、方法的参数列表(包括数量、类型以及顺序)和返回值。详见下表
用描述符来描述方法时,按照先参数列表,后返回值的顺序描述,参数列表按照参数的严格顺序放在一组小括号“()”之内。如方法 java.lang.String tostring()的 描述符为 ()Ljava/lang/String; ,方法 int abc(int[]x, int y) 的描述符为 ([II)I
补充说明
虚拟机在加载 Class 文件时才会进行动态链接,也就是说,Class 文件中不会保存各个方法和字段的最终内存布局信息。因此,这些字段和方法的符号引用不经过转换是无法直接被虚拟机使用的。当虚拟机运行时,需要从常量池中获得对应的符号引用,再在类加载过程中的解析阶段将其替换为直接引用,并翻译到具体的内存地址中
符号引用和直接引用的区别与关联
符号引用:符号引用与虚拟机实现的内存布局无关
直接引用:引用的目标必定已经存在于内存之中了
总结:符号引用和直接引用的主要区别在于是否包含了具体的内存信息,有内存信息 JVM 可以用,反之不可用。eg 常量池 ()Ljava/lang/String;(符号引用,JVM 无法使用) (动态链接加载到内存里)-> 运行时常量池 ()Ljava/lang/String;@十六进制地址(直接引用,有内存地址,JVM 可以使用)
常量类型和结构
常量池中每一项常量都是一个表,JDK1.7 之后共有 14 种不同的表结构数据
根据上图每个类型的描述我们也可以知道每个类型是用来描述常量池中哪些内容(主要是字面量、符号引用)的。比如:CONSTANT_Integer_info 是用来描述常量池中字面量信息的,而且只是整型字面量信息
标志为 15、16、18 的常量项类型是用来支持动态语言调用的(jdk1.7 时才加入的)
为什么没有 byte、short、char、boolean 常量?因为 int 类型常量范围可以包含它们,所以 int 类型常量表示它们即可
总结
这 14 种表(或者常量项结构)的共同点是:表开始的第一位是一个 u1 类型的标志位(tag),代表当前这个常量项使用的是哪种表结构,即哪种常量类型
在常量池列表中,CONSTANT_Utf8_info常量项是一种使用改进过的UTF-8编码格式来存储诸如文字字符串、类或者接口的全限定名、字段或者方法的简单名称以及描述符等常量字符串信息
这 14 种常量项结构还有一个特点是,其中 13 个常量项占用的字节固定,只有 CONSTANT_Utf8_info 占用字节不固定,其大小由 length 决定
为什么呢?因为从常量池存放的内容可知,其存放的是字面量和符号引用,最终这些内容都会是一个字符串,这些字符串的大小是在编写程序时才确定,比如你定义一个类,类名可以取长取短,所以在没编译前,大小不固定,编译后,通过 utf-8 编码,就可以知道其长度
常量池:可以理解为Class文件之中的资源仓库,它是Class文件结构中与其他项目关联最多的数据类型(后面的很多数据类型都会指向此处),也是占用Class文件空间最大的数据项目之一
常量池中为什么要包含这些内容?
Java 代码在进行 javac 编译的时候,并不像 C 和 C++ 那样有“连接”这一步骤,而是在虚拟机加载 Class 文件的时候进行动态链接。也就是说,在 Class 文件中不会保存各个方法、字段的最终内存布局信息,因此这些字段、方法的符号引用不经过运行期转换的话无法得到真正的内存入口地址,也就无法直接被虚拟机使用。当虚拟机运行时,需要从常量池获得对应的符号引用,再在类创建时或运行时解析、翻译到具体的内存地址之中。关于类的创建和动态链接的内容,在虚拟机类加载过程时再进行详细讲解
04、访问标识(访问标记、访问标志)
位置:常量池后,紧跟着访问标记。该标记使用两个字节表示
作用:用于识别一些类或者接口层次的访问信息,包括:这个 Class 是类还是接口;是否定义为 public 类型;是否定义为 abstract 类型;如果是类的话,是否被声明为 final 等
各种访问标记(翻译自官网介绍)
没有特定的标志用于表示类,因为类是最常见的一种类型,因此字节码中默认的访问标识就代表类
类的访问权限通常以 ACC_ 开头的常量
每一种类型的表示都是通过设置访问标记的 32 位中的特定位来实现的。比如,若是 public final 的类,则该标记为 ACC_PUBLIC | ACC_FINAL
使用 ACC_SUPER 可以让类更准确地定位到父类的方法 super.method(),现代编译器都会设置并且使用这个标记(即默认带有这个标志)
补充说明
1、带有 ACC_INTERFACE 标志的 class 文件表示的是接口而不是类,反之则表示的是类而不是接口
如果一个 class 文件被设置了 ACC_INTERFACE 标志,那么同时也得设置 ACC_ABSTRACT 标志。同时它不能再设置 ACC_FINAL、ACC_SUPER 或 ACC_ENUM 标志
如果没有设置 ACC_INTERFACE 标志,那么这个 class 文件可以具有上表中除 ACC_ANNOTATION 外的其他所有标志。当然,ACC_FINAL 和 ACC_ABSTRACT 这类互斥的标志除外。这两个标志不得同时设置
2、ACC_SUPER 标志用于确定类或接口里面的 invokespecial 指令使用的是哪一种执行语义。针对 Java 虚拟机指令集的编译器都应当设置这个标志。对于 Java SE 8 及后续版本来说,无论 class 文件中这个标志的实际值是什么,也不管 class文 件的版本号是多少,Java 虚拟机都认为每个 class 文件均设置了 ACC_SUPER 标志
目前的 ACC_SUPER 标志在由 JDK1.0.2 之前的编译器所生成的
access_flags 中是没有确定含义的,如果设置了该标志,那么 Oracle 的 Java 虚拟机实现会将其忽略
3、ACC_SYNTHETIC 标志意味着该类或接口是由编译器生成的,而不是由源代码生成的
4、注解类型必须设置 ACC_ANNOTATION 标志。如果设置了 ACC_ANNOTATION 标志,那么也必须设置 ACC_INTERFACE 标志(毕竟注解的表示就是@interface 所以这里可以理解)
5、ACC_ENUM 标志表明该类或其父类为枚举类型
eg 该位置的十六进制为0021 = ACC_PUBLIC标志值 + ACC_SUPER 标志值
05、类索引、父类索引、接口索引
位置:访问标记后,会指定该类的类别、父类类别以及实现的接口
格式
this_class(类索引):2 字节无符号整数,指向常量池的索引。它提供了类的全限定名,如 com/atguigu/java1/Demo。this_class 的值必须是对常量池表中某项的一个有效索引值。常量池在这个索引处的成员必须为 CONSTANT_Class_info 类型结构体,该结构体表示这个 class 文件所定义的类或接口
super_class(父类索引):2 字节无符号整数,指向常量池的索引。它提供了当前类的父类的全限定名。如果我们没有继承任何类,其默认继承的是 java/lang/object 类。同时,由于 Java 不支持多继承,所以其父类只有一个。注意 super_class 指向的父类不能是 final
interfaces:指向常量池索引集合,它提供了一个符号引用到所有已实现的接口。由于一个类可以实现多个接口,因此需要以数组形式保存多个接口的索引,表示接口的每个索引也是一个指向常量池的 CONSTANT_Class(当然这里就必须是接口,而不是类)
interfaces_count(接口计数器):interfaces_count 项的值表示当前类或接口的直接超接口数量
interfaces[](接口索引集合):interfaces[]中 每个成员的值必须是对常量池表中某项的有效索引值,它的长度为 interfaces_count。每个成员 interfaces[i] 必须为 CONSTANT_Class_info 结构,其中 0 <= i < interfaces_count。在 interfaces[] 中,各成员所表示的接口顺序和对应的源代码中给定的接口顺序(从左至右)一样,即 interfaces[0] 对应的是源代码中最左边的接口
这三项数据来确定这个类的继承关系
类索引用于确定这个类的全限定名
父类索引用于确定这个类的父类的全限定名。由于 Java 语言不允许多重继承,所以父类索引只有一个,除了 java.1ang.Object 之外,所有的 Java 类都有父类,因此除了 java.lang.Objec t外,所有 Java 类的父类索引都不为 0
接口索引集合就用来描述这个类实现了哪些接口,这些被实现的接口将按 implements 语句(如果这个类本身是一个接口,则应当是 extends 语句)后的接口顺序从左到右排列在接口索引集合中
06、字段表集合
位置:类索引、父类索引、接口索引之后
作用:用于描述接口或类中声明的变量。字段(field)包括类级变量以及实例级变量,但是不包括方法内部、代码块内部声明的局部变量
说明:字段叫什么名字、字段被定义为什么数据类型,这些都是无法固定的,只能引用常量池中的常量来描述。它指向常量池索引集合,它描述了每个字段的完整信息。比如字段的标识符、访问修饰符(public、private或protected)、是类变量还是实例变量(static 修饰符)、是否是常量(final 修饰符)等
使用注意事项
字段表集合中不会列出从父类或者实现的接口中继承而来的字段,但有可能列出原本 Java 代码之中不存在的字段。譬如在内部类中为了保持对外部类的访问性,会自动添加指向外部类实例的字段
在 Java 语言中字段是无法重载的,两个字段的数据类型、修饰符不管是否相同,都必须使用不一样的名称,但是对于字节码来讲,如果两个字段的描述符不一致,那字段重名就是合法的
格式
fields_count(字段计数器)
fields_count 的值表示当前 class 文件 fields 表的成员个数。使用两个字节来表示
fields 表中每个成员都是一个 field_info 结构,用于表示该类或接口所声明的所有类字段或者实例字段,不包括方法内部声明的变量,也不包括从父类或父接口继承的那些字段
fields[] (字段表)
字段表结构
字段表访问标识
我们知道,一个字段可以被各种关键字去修饰,比如:作用域修饰符(public、private、protected)、static修饰符、final 修饰符、volatile 修饰符等等。因此,其可像类的访问标志那样,使用一些标志来标记字段
描述符索引
描述符的作用是用来描述字段的数据类型、方法的参数列表(包括数量、类型以及顺序)和返回值。根据描述符规则,基本数据类型(byte,char,double,float,int,long,short,boolean)及代表无返回值的void类型都用一个大写字符来表示,而对象则用字符 L 加对象的全限定名来表示
属性表集合
一个字段还可能拥有一些属性,用于存储更多的额外信息。比如初始化值(针对常量才有)、一些注释信息(为字段提供了附加的类型信息(Signature)和其他自定义的元数据(注解)。这些注释信息可以在运行时被反射机制和其他工具读取和处理,以实现更灵活的代码分析和操作)等。属性个数存放在 attribute_count 中,属性具体内容存放在 attributes 数组中
eg 以常量属性为例,结构为
说明:对于常量属性而言,attribute_length 值恒为 2
07、方法表集合
位置:字段表集合之后
说明:向常量池索引集合,它完整描述了每个方法的签名
在字节码文件中,每一个 method_info 项都对应着一个类或者接口中的方法信息。比如方法的访问修饰符(public、private 或 protected),方法的返回值类型以及方法的参数信息等
如果这个方法不是抽象的或者不是 native 的,那么字节码中会体现出来
一方面,methods 表只描述当前类或接口中声明的方法,不包括从父类或父接口继承的方法。另一方面,methods 表有可能会出现由编译器自动添加的方法,最典型的便是编译器产生的方法信息(比如:类(接口)初始化方法 <clinit>()和实例初始化方法 <init>())
使用注意事项
在 Java 语言中,要重载(Overload)一个方法,除了要与原方法具有相同的简单名称之外,还要求必须拥有一个与原方法不同的特征签名,特征签名就是一个方法中各个参数在常量池中的字段符号引用的集合,也就是因为返回值不会包含在特征签名之中,因此 Java 语言里无法仅仅依靠返回值的不同来对一个已有方法进行重载。但在 Class 文件格式中,特征签名的范围更大一些,只要描述符不是完全一致的两个方法就可以共存。也就是说,如果两个方法有相同的名称和特征签名,但返回值不同,那么也是可以合法共存于同一个 class 文件中
也就是说,尽管 Java 语法规范并不允许在一个类或者接口中声明多个方法签名相同的方法,但是和 Java 语法规范相反,字节码文件中却恰恰允许存放多个方法签名相同的方法,唯一的条件就是这些方法之间的返回值不能相同
格式
methods_count(方法计数器)
methods_count 的值表示当前 class 文件 methods 表的成员个数。使用两个字节来表示
methods 表中每个成员都是一个 method_info 结构
methods[](方法表)
methods 表中的每个成员都必须是一个 method_info 结构,用于表示当前类或接口中某个方法的完整描述。如果某个 method_info 结构的 access_flags 项既没有设置 ACC_NATIVE 标志也没有设置 ACC_ABSTRACT 标志,那么该结构中也应包含实现这个方法所用的 Java 虚拟机指令
method_info 结构可以表示类和接口中定义的所有方法,包括实例方法、类方法、实例初始化方法和类或接口初始化方法。方法表的结构实际跟字段表是一样的,方法表结构如下
方法表访问标志
跟字段表一样,方法表也有访问标志,而且他们的标志有部分相同,部分则不同,方法表的具体访问标志如下
08、属性表集合
位置:方法表集合之后。此外,字段表、方法表都可以有自己的属性表。用于描述某些场景专有的信息
作用:指的是 class 文件所携带的辅助信息,比如该 class 文件的源文件的名称。以及任何带有 RetentionPolicy.CLASS 或者 RetentionPolicy.RUNTIME 的注解。这类信息通常被用于 Java 虚拟机的验证和运行,以及 Java 程序的调试,一般无须深入了解
属性表集合的限制没有那么严格,不再要求各个属性表具有严格的顺序,并且只要不与已有的属性名重复,任何人实现的编译器都可以向属性表中写入自己定义的属性信息,但 Java 虚拟机运行时会忽略掉它不认识的属性
格式
attributes_count(属性计数器)
attributes_count 的值表示当前 class 文件属性表的成员个数。属性表中每一项都是一个 attribute_info 结构
attributes[](属性表)
属性表的每个项的值必须是 attribute_info 结构。属性表的结构比较灵活,各种不同的属性只要满足以下结构即可
属性的通用格式
属性类型
属性表实际上可以有很多类型,上面看到的 Code 属性只是其中一种,Java8 里面定义了 23 种属性。下面这些是虚拟机中预定义的属性
或者(查看官网)
具体每个属性表的结构有需要的话去官网看
小结
从 Java 虚拟机的角度看,通过 Class 文件,可以让更多的计算机语言支持 Java 虚拟机平台。因此, Class 文件结构不仅仅是 Java 虚拟机的执行入口,更是 Java 生态圈的基础和核心
04-使用 javap 指令解析 Class 文件
1-解析字节码的作用
javap 是 jdk 自带的反解析工具。它的作用就是根据 class 字节码文件,反解析出当前类对应的 code 区(字节码指令)、局部变量表、异常表和代码行偏移量映射表、常量池等信息
2-javac -g 操作
直接 javac xx.java,不会在生成对应的局部变量表等信息,如果使用 javac -g xx.java 就可以生成所有相关信息了。如果使用的 eclipse 或 IDEA,则默认情况下, eclipse、IDEA 在编译时会帮你生成局部变量表、指令和代码行偏移量映射表等信息的
例如
3-javap 的用法
格式:javap <options> <classes>
<options> 说明
4-使用举例
javap -v -p 指令输出内容分析:总之 javap 指令可以显示更多的信息:如字节码文件里没有的字节码文件路径,最后修改时间等等,并且有些显示效果更好,如静态代码块和构造器的显示
jclasslib 显示内容分析
5-总结
第 2 章:字节码指令集与解析举例
复习:再谈操作数栈和局部变量表
01-概述
执行模型
字节码与数据类型
在 Java 虚拟机的指令集中,大多数的指令都包含了其操作所对应的数据类型信息。例如,iload 指令用于从局部变量表中加载 int 型的数据到操作数栈中,而 fload 指令加载的则是 float 类型的数据
与数据类型显式相关的操作码
对于大部分与数据类型相关的字节码指令,它们的操作码助记符中都有特殊的字符来表明专门为哪种数据类型服务
与数据类型隐式相关的操作码
没有明确地指明操作类型的字母
如 arraylength 指令,它没有代表数据类型的特殊字符,但操作数永远只能是一个数组类型的对象
与数据类型无关的操作码
还有另外一些指令,如无条件跳转指令 goto 则是与数据类型无关的
其它说明
大部分的指令都没有支持整数类型 byte、char 和 short,甚至没有任何指令支持 boolean 类型。编译器会在编译期或运行期将 byte 和 short 类型的数据带符号扩展(Sign-Extend)为相应的 int 类型数据,将 boolean 和 char 类型数据零位扩展(Zero-Extend)为相应的 int 类型数据。与之类似,在处理 boolean、byte、short 和 char 类型的数组时,也会转换为使用对应的 int 类型的字节码指令来处理。因此,大多数对于 boolean、byte、short 和 char 类型数据的操作,实际上都是使用相应的 int 类型作为运算类型
指令分类
02-加载与存储指令
作用:加载和存储指令用于将数据从栈帧的局部变量表和操作数栈之间来回传递
常用指令
1-局部变量压栈指令
案例
分析:这里老师的局部变量表写多了一个,应该是 7 个 slot【0-6】;操作数栈为了方便看出其流程就没进行出栈操作,后面的案例也一样就不赘述了
2-常量入栈指令
案例
案例 1
案例 2
3-出栈装入局部变量表指令
案例
案例 1
注意:对于引用类型数据如这里 str 的 ldc #15 <atguigu> 返回的是常量池 #15 位置的值即符号引用,这个符号引用指向常量池中字符串的地址,所以存储到局部变量表 str 的值是符号引用,不是字符串 “atguigu”
案例 2(槽位复用)
03-算数指令
作用:算术指令用于对两个操作数栈上的值进行某种特定运算,并把结果重新压入操作数栈
分类:大体上算术指令可以分为两种:对整型数据进行运算的指令与对浮点类型数据进行运算的指令
使用说明
byte、short、char 和 boolean 类型说明
在每一大类中,都有针对 Java 虚拟机具体数据类型的专用算术指令。但没有直接支持 byte、short、char 和 boolean 类型的算术指令,对于这些数据的运算,都使用 int 类型的指令来处理。此外,在处理 boolean、byte、short 和 char 类型的数组时,也会转换为使用对应的 int 类型的字节码指令来处理
运算时的溢出
数据运算可能会导致溢出,例如两个很大的正整数相加,结果可能是一个负数。其实 Java 虚拟机规范并无明确规定过整型数据溢出的具体结果,仅规定了在处理整型数据时,只有除法指令以及求余指令中当出现除数为 0 时会导致虚拟机抛出异常 ArithmeticException
运算模式
向最接近数舍入模式:JVM 要求在进行浮点数计算时,所有的运算结果都必须舍入到适当的精度,非精确结果必须舍入为可被表示的最接近的精确值,如果有两种可表示的形式与该值一样接近,将优先选择最低有效位为零的
向零舍入模式:将浮点数转换为整数时,采用该模式,该模式将在目标数值类型中选择一个最接近但是不大于原值的数字作为最精确的舍入结果
NaN 值使用
当一个操作产生溢出时,将会使用有符号的无穷大表示,如果某个操作结果没有明确的数学定义的话,将会使用 NaN 值来表示。而且所有使用 NaN 值作为操作数的算术操作,结果都会返回 NaN
所有算数指令
自增指令 iinc 是直接操作本地变量表的,不经过栈
没有专门的位运算指令来执行取反操作。位运算中的取反操作可以通过其他位运算操作的组合来实现,如可以使用异或 (^) 运算符将操作数与全1的位模式进行异或运算来实现
案例
最终对应常量 <java/lang/System>和 <out : Ljava/io/PrintStream;>,这里常量池的符号索引和上图不同,只是为了说明其指向内容
print 是方法所以在常量加载之后才调用,这里常量池的符号索引和上图不同,只是为了说明其指向内容
彻底搞定 ++ 运算符
不涉及运算,++i 和 i++ 效果是一样的
涉及运算
我认为的等价关系,这样更好记一些,即 i++ 是后加 ++i 是先加
比较指令的说明
问题1:为什么 lcmp 不和前面的指令一样分为两种,lcmpg 和 lcmpl 呢?
因为 long 类型数据不会出现 NaN,前面分两种是为了处理 NaN 情况
问题2:为什么只有 long、float、double 类型的比较指令,没有 int 类型的比较指令?
因为设计者将 int 类型作为后面条件跳转判断的依据(见“08-控制转移指令”->“1-条件跳转指令”)
04-类型转换指令
类型转换指令可以将两种不同的数值类型进行相互转换
这些转换操作一般用于实现用户代码中的显式类型转换操作,或者用来处理字节码指令集中数据类型相关指令无法与数据类型一一对应的问题
1-宽化类型转化
补充了没有哪些指令,以及从资源指令和局部变量表的槽位大小两个方面解释为什么 局部变量表和 Code 没有 byte、char、short 类型
案例
2-窄化类型转化
精度损失案例
案例
案例
05-对象的创建与访问指令
介绍
Java 是面向对象的程序设计语言,虚拟机平台从字节码层面就对面向对象做了深层次的支持。有一系列指令专门用于对象操作,可进一步细分为创建指令、字段访问指令、数组操作指令、类型检查指令
1-创建指令
补充说明(格式):newarray <ArrayType> ;anewarray <ArrayType>; multianewarray <ArrayType> <dim>
其中前两个一维数组都会栈顶出栈一次(出栈的元素表示数组长度),而多维数组出栈<dim>次,具体见下面案例
案例
javap 查看
一些说明
1. 我们代码里的 new 操作对应字节码的三步操作:new、dup、invokespecial(调用构造函数),为什么呢?
在字节码中,new 指令只是创建了一个新的对象实例,并将其引用推送到操作数栈顶。但是,这个新对象的初始化工作并没有完成(如属性的构造器初始化,非静态代码块初始化等等)
所以字节码里的 new 只是创建了对象实例但没有初始化完全,需要调用构造函数进行初始化
2. 为什么要复制一份对象实例引用?
因为一份用来进行构造函数初始化,另一份可能另有他用如进行赋值给变量等等
3.用 jclasslib 看数组创建格式有点怪怪的,用 javap 查看就顺眼多了
4.对于多维数组如果后面没有指定长度,字节码会忽略它。如案例的 strArray 二维数组只是指定一个长度,对应字节码是 anewarray 而不是 multianewarray
5.jclasslib 查看的字节码指令 newarray 10 的10代表的就是 int 类型
2-字段访问指令
案例
案例 1
案例 2
3-数组操作指令
和前面“02-加载与存储指令”的区别:前面的存储是针对局部变量表的存储,而这里因为局部变量表存储的是数组引用,数组的值实际在堆空间中,所以这里的存储指令是针对堆空间数据存储
取数组长度的指令:arraylength。该指令弹出栈顶的数组元素,获取数组的长度,将长度压入栈
案例
4-类型检查指令
案例
06-方法调用于返回指令
1-方法调用指令
静态类型绑定本质是在说该方法调用是唯一的,目标方法不可以被重写的
最常用 invokevirtual 判断方法调用:不是 invokeinterface、invokespecial、invokestatic 的话,就是 invokevirtual
invokedynamic 方法调用需要具体的场景,老师就没举例,需要自己去网上找案例
案例
调用接口默认方法和静态方法字节码情况,感觉这里结果也间接说明静态绑定(唯一确定方法)类型大于动态绑定
2-方法返回指令
最后,会丢弃当前方法的整个帧,恢复调用者的帧,并将控制权转交给调用者
案例
07-操作数栈管理指令
案例
该案例也很好说明操作数栈是以 Slot 未单位出栈入栈,而不是以不同数据类型出栈入栈
08-控制转移指令
程序流程离不开条件控制,为了支持条件跳转,虚拟机提供了大量字节码指令,大体上可以分为比较指令、条件跳转指令、比较条件跳转指令、多条件分支跳转指令、无条件跳转指令等
1-条件跳转指令
案例
这里可以看出来字节码和实际代码还是有点不同的:字节码标注的是不满足条件需要跳转的情况,例如这里实际代码是a == 10,不满足条件需要跳转就是 != 即 ifne
小细节:f1 < f2 在字节码里返回的不是 int 类型(对应这里偏移量 19 的 iconst_0)吗?但为什么打印语句输出的是 boolean 类型呢?
因为 println 方法重载了很多类型,这里调用 boolean 类型参数的 println 方法,如果字节码值是 0,就翻译为 false
2-比较条件跳转指令
感觉说白了就是两个 int 类型比较的情况(结合案例看更有感觉)
案例
局部变量表对对象的存储是存储其地址,所以这里两次 new,两个对象地址肯定不同
3-多条件分支跳转指令
tableswitch 和 lookupswitch 区别图示
案例
4-无条件跳转
案例
对 i2s 的理解:为什么要 i2s,局部变量表基本单位不是 slot,一个 slot 不是四字节吗,i2s 之后值是不是就是 二分之一个 slot 了?
操作数堆栈顶部的值必须为 int 类型。它从操作数堆栈中弹出,截断为 short,然后符号扩展为 int 结果。结果被压入操作数堆栈。所以还是一个 slot,至于为什么要用 i2s,因为防止类型取值溢出,如 byte 表示 -128-127,但是因为操作时作为 int 处理,此时如果我赋值超过这个范围如128,这时就需要截断为 byte类型(保留一个字节的低位数据,高位丢弃),所以窄化处理本质是进行截断操作,目的是遵循目标类型的取值范围,并防止数据溢出
从字节码方面没完全一样,从语法方面的区别就只是 i 的作用域不同,前者循环外也能访问到 i,后者只能在循环内访问到 i
09-异常处理指令
1-抛出异常指令
官网对 athrow 说明:throw 指令的操作数堆栈图可能会引起误解:如果在当前方法中匹配了此异常的处理程序,则 throw 指令丢弃操作数堆栈上的所有值,然后将被抛出的对象推入操作数堆栈。但是,如果当前方法中没有匹配的处理程序,并且异常被抛出到方法调用链的更上层,那么处理异常的方法(如果有的话)的操作数堆栈将被清除,objectref将被压入空操作数堆栈。从抛出异常的方法到(但不包括)处理异常的方法的所有中间帧都将被丢弃。即没匹配的操作数栈所有内容清空且栈帧清除并且往抛出到调用链的更上层,如果当前栈帧匹配,则操作数栈清空,再压入操作数栈里(注意不是直接就压入,是先判断匹配后才压入)。如果都没有栈帧匹配则直到最后一个非守护线程结束如main线程,程序就终止了
案例
2-异常处理与异常表
案例
面试题
10-同步控制指令
组成:Java 虚拟机支持两种同步结构,方法级的同步和方法内部一段指令序列的同步,这两种同步都是使用 monitor 来支持的
1-方法级的同步
2-方法内指定指令序列的同步
案例
为什么 obj 对象实例的引用要复制一份?因为一份用来给 monitorenter 来加锁,另一份用来给 monitorexit 解锁
方法内指定指令序列的同步该方法的访问标识不会带有 synchronized
第 3 章:类的加载过程(类的生命周期)详解
01-概述
02-过程一:Loading(加载)阶段
1-加载完成的操作
2-二进制流的获取方式
3-类模型与Class实例的位置
该结构理解案例
4-数组类的加载
即数组的元素如果是基本数据类型就由虚拟机预先定义的来(如 int 固定4字节,数组长度若为10,则在内存分配40字节空间即可);如果是引用类型,则递归加载和创建这些元素
03-过程二:Linking(链接)阶段
1-环节1:链接阶段之Verification(验证)
当类加载到系统后,就开始链接操作,验证是链接操作的第一步。它的目的是保证加载的字节码是合法、合理并符合规范的
2-环节2:链接阶段之Preparation(准备)
“注意 1” 的案例
3-环节3:链接阶段之Resolution(解析)
例如 getfield #4 的 #4就是符号引用,届时需要更改为具体的内存地址(直接引用)
04-过程三:Initialization(初始化)阶段
案例
1-static 与 final 的搭配问题
最终结论:使用 static+fina l修饰,且显式赋值中不涉及到方法或构造器调用的基本数据类到或String类型的显式赋值,是在链接阶段的准备环节进行。若是包装类的话由于会执行自动装箱操作,即会执行 valueOf 方法,所以必定是在初始化阶段赋值
2-<clinit>() 的线程安全性
这里需要注意这个锁是隐式的,我们在字节码里看不到相关内容,是 JVM 自动给我们加上的
案例(循环依赖)
3-类的初始化情况:主动使用vs被动使用
主动使用案例
1. 实例化:当创建一个类的实例时,比如使用new关键字,或者通过反射、克隆、反序列化
2. 静态方法:当调用类的静态方法时,即当使用了字节码invokestatic指令
3. 静态字段:当使用类、接口的静态字段时(final修饰特殊考虑),比如,使用 getstatic 或者 putstatic 指令。(对应访问变量、赋值变量操作)
这里的方法很巧妙啊,接口写不了静态代码块不知道有没有初始化,就用调用方法的显示赋值(初始化环节才会执行)去着手输出内容查看是否有经历初始化阶段
4. 反射:当使用java.lang.reflect包中的方法反射类的方法时。比如:Class.forName("com.atguigu.java.Test")
5. 继承:当初始化子类时,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化
5 补充说明
在初始化一个类时,并不会先初始化它所实现的接口
在初始化一个接口时,并不会先初始化它的父接口
6. default方法:如果一个接口定义了default方法,那么直接实现或者间接实现该接口的类的初始化,该接口要在其之前被初始化
7. main方法:当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类
8. MethodHandle:当初次调用MethodHandle实例时,初始化该MethodHandle指向的方法所在的类。(涉及解析REF getStatic、REF_putStatic、REF invokeStatic方法句柄对应的类)。没啥好举例的。
被动使用案例
1.当访问一个静态字段时,只有真正声明这个字段的类才会被初始化
2. 通过数组定义类引用,不会触发此类的初始化(开辟空间,不涉及对象创建不会导致初始化)
3, 引用常量不会触发此类或接口的初始化。因为常量在链接阶段就已经被显式赋值了
4, 调用ClassLoader类的loadClass()方法加载一个类,并不是对类的主动使用,不会导致类的初始化
05-过程四:类的Using(使用)
注意是首次主动使用才会初始化,后续就不会再进行初始化了,也就是初始化操作只会进行一次
06-过程五:类的Unloading(卸载)
回顾:方法区的垃圾回收
类的实例的 class 静态属性是在 JVM 运行时动态生成的,在内存中的表现类似下图
对应上面“回顾:方法区的垃圾回收”的“不再使用类型”的回收的三个条件(蓝色字体部分)
第 4 章:再谈类的加载器
01-概述
1-大厂面试题(见“JVM面试题”)
2-类加载的分类
案例
3-类加载器的必要性
4-命名空间
5-类加载机制的基本特征
02-复习:类的加载器的分类
从编写语言上分引导类加载器是C/C++编写的,自定义类加载器都是 Java 语言编写的
1-引导类加载器
“加载扩展类和应用程序类加载器,并指定为它们的父类加载器”证明截图
案例
2-扩展类加载器
可以看出在new Laucher()时就会主动创建扩展类加载器和系统类加载器,完全不需要我们手动创建。所以该截图结合上面 ““加载扩展类和应用程序类加载器,并指定为它们的父类加载器”证明截图” 可以知道Launcher最先加载,然后调用其构造器会去加载这 扩展类加载器和系统类加载器
案例
3-系统类加载器
案例
父类赋值源码追溯(技巧:变量往哪个方法传,就追哪个方法,其它一概不看)
4-用户自定义类加载器
03-测试不同的类加载器
上面案例运行测试
04-ClassLoader 源码分析
ClassLoader 的主要方法
loadClass()源码剖析
SecureClassLoader 与 URLClassLoader
ExtClassLoader 与 AppClassLoader
Class.forName() 与 ClassLoader.loadClass()
05-双亲委派模型
定义与本质
类加载器用来把类加载到 Java 虚拟机中。从 JDK1.2 版本开始,类的加载过程采用双亲委派机制,这种机制能更好地保证 Java 平台的安全
优势与劣势
双亲委派机制的两点优势是面试题需要记住,不用死记,用例子或者画图理解性记忆即可
案例
防止核心 API 被篡改案例
破坏双亲委派机制
破坏双亲委派机制1
破坏双亲委派机制2
一个形象的比喻:公司高层维护的是和政府的关系,中层维护的是同行信息了解等等,底层就是具体的业务了。假设这时高层电脑坏了不会修,就得找底层具体的如运营部门的员工去修
破坏双亲委派机制3
热部署一个形象的案例:电脑与外置设备的关系就有点像热部署,如换鼠标,拔插鼠标接口,不需要重启电脑,新插入的鼠标马上就能用
热替换的实现
MyClassLoader 类在“07-自定义类的加载器”->“实现方式”->“实现代码案例”里
06-沙箱安全机制
JDK1.0时期
JDK1.1时期
JDK1.2时期
JDK1.6时期
07-自定义类的加载器
实现方式
理论
实现代码案例:其实要做的就一件事,把 class 文件变成字节数组,其他的调用已有的 api 就好了
08-Java9新特性
习惯:在编写一个类去加载别的类时,要注意别的类加载行为会不会导致循环依赖
0 条评论
下一页