第四章 JVM执行子程序学习笔记

一、class文件结构

计算机只认识0和1,这个称之为本地机器NativeCode

Jvm的无关性

与平台无关性是建立在操作系统上,虚拟机厂商提供了许多可以运行在各种不同平台的虚拟机,它们都可以载入和执行字节码,从而实现程序的“一次编写,到处运行” 。

各种不同平台的虚拟机与所有平台都统一使用的程序存储格式——字节码(ByteCode)是构成平台无关性的基石,也是语言无关性的基础。Java虚拟机不和包括Java在内的任何语言绑定,它只与“Class文件”这种特定的二进制文件格式所关联,Class文件中包含了Java虚拟机指令集和符号表以及若干其他辅助信息。

ClassFile {

    u4             magic;        //识别Class文件格式,具体值为0xCAFEBABE; 即魔术
    u2             minor_version;//次版本号
    u2             major_version;//主版本号
    u2             constant_pool_count;//常量池容量计数
    cp_info        constant_pool[constant_pool_count-1];

    //它代表各种各样的字符串常量、类和接口名、字段名以及在ClassFile结构及其子结构中引用的其他常量。

    //每个常量池表条目的格式由它的第一个“标记”字节表示。
    u2             access_flags;     //访问标志
    u2             this_class;       //类索引
    u2             super_class;     //父类索引
    u2             interfaces_count;//接口索引数
    u2             interfaces[interfaces_count]; //接口索引集合

    //接口数组中的每个值必须是常量池中有效索引。
    u2             fields_count;     //字段数    
    field_info     fields[fields_count];
    u2             methods_count;   //方法数
    method_info    methods[methods_count];
    u2             attributes_count;  //属性数

    attribute_info attributes[attributes_count];
}

Class类文件(了解即可)

任何一个Class文件都对应着唯一一个类或接口的定义信息,但反过来说,Class文件实际上它并不一定以磁盘文件的形式存在。Class文件是一组以8位字节为基础单位的二进制流。

Class文件格式

各个数据项目严格按照顺序紧凑地排列在Class文件之中,中间没有添加任何分隔符,这使得整个Class文件中存储的内容几乎全部是程序运行的必要数据,没有空隙存在。Class文件格式采用一种类似于C语言结构体的伪结构来存储数据,这种伪结构中只有两种数据类型:无符号数和表。

  • 无符号数属于基本的数据类型,以u1、u2、u4、u8来分别代表1个字节、2个字节、4个字节和8个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者按照UTF-8编码构成字符串值。
  • 表是由多个无符号数或者其他表作为数据项构成的复合数据类型,所有表都习惯性地以“_info”结尾。表用于描述有层次关系的复合结构的数据,整个Class文件本质上就是一张表。

Class文件格式详解

Class的结构不像XML等描述语言,由于它没有任何分隔符号,所以在其中的数据项,无论是顺序还是数量,都是被严格限定的,哪个字节代表什么含义,长度是多少,先后顺序如何,都不允许改变。按顺序包括:

1、魔数

每个Class文件的头4个字节称为魔数(Magic Number),它的唯一作用是确定这个文件是否为一个能被虚拟机接受的Class文件。使用魔数而不是扩展名来进行识别主要是基于安全方面的考虑,因为文件扩展名可以随意地改动。文件格式的制定者可以自由地选择魔数值,只要这个魔数值还没有被广泛采用过同时又不会引起混淆即可。

2、版本号

紧接着魔数的4个字节存储的是Class文件的版本号:第5和第6个字节是次版本号Minor Version,第7和第8个字节是主版本号Major Version;譬如某个 Class 文件的主版本号为 M,副版本号为 m,那么这个Class文件的格式版本号就确定为M.m,Class 文件格式版本号大小的顺序为:1.5 < 2.0 < 2.1。

假设一个 Class 文件的格式版本号为V,仅当Mi.0 ≤ v ≤ Mj.m成立时,这个Class文件才可以被此 Java虚拟机支持。不同版本的Java虚拟机实现支持的版本号也不同,高版本号的Java虚拟机实现可以支持低版本号的Class文件,反之则不成立 。

注意:Oracle 的 JDK 在 1.0.2 版本时,支持的 Class 格式版本号范围是 45.0 至 45.3;JDK 版本在 1.1.x时,支持的 Class 格式版本号范围扩展至 45.0 至 45.65535;JDK 版本为 1. k 时(k ≥2)时,对应的 Class文件格式版本号的范围是 45.0 至 44+k.0。下表列举了Class文件版本号:

备注:十六进制34对应十进制52就是JDK1.8版本。

3、常量池

常量池中常量的数量是不固定的,所以在常量池的入口需要放置一项u2类型的数据,代表常量池容量计数值(constant_pool_count)。与Java中语言习惯不一样的是,这个容量计数是从1而不是0开始的,常量池中主要存放两大类常量:字面量(Literal)和符号引用(Symbolic References)。

  • 字面量比较接近于Java语言层面的常量概念,如文本字符串、声明为final的常量值等。
  • 而符号引用则属于编译原理方面的概念,包括了下面三类常量:类和接口的全限定名(Fully Qualified Name)、字段的名称和描述符(Descriptor)、方法的名称和描述符。

常量池中的每一项都具备相同的格式特征:第一个字节作为类型标记用于识别该项是哪种类型的常量,称为“tagbyte”。常量池的索引范围是 1 至 constant_pool_count−1。
每一种类型的格式特征:这里用CONSTANT_Class_info举个例子:

4、访问标致

访问标志,access_flags 是一种掩码标志,用于表示某个类或者接口的访问权限及基础属性,包括:这个Class是类还是接口;是否定义为public类型;是否定义为abstract类型;如果是类的话,是否被声明为final等

  • ACC_SYNTHETIC标志的类,意味着它是由编译器自己产生的而不是由程序员编写的源代码生成的。
  • ACC_ENUM标志的类,意味着它或它的父类被声明为枚举类型。
  • ACC_INTERFACE标志的类,意味着它是接口而不是类,反之是类而不是接口。如果一个Class文件被设置了ACC_INTERFACE标志,那么同时也得设置ACC_ABSTRACT标志(JLS §9.1.1.1)。同时它不能再设置 ACC_FINAL、ACC_SUPER 和 ACC_ENUM 标志。
  • 注解类型必定带有ACC_ANNOTATION标记,如果设置了ANNOTATION标记,ACC_INTERFACE也必须被同时设置。如果没有同时设置ACC_INTERFACE标记,那么这个Class文件可以具有表4.1中的除ACC_ANNOTATION外的所有其它标记。当然ACC_FINAL和ACC_ABSTRACT这类互斥的标记除外(JLS §8.1.1.2)。
  • ACC_SUPER标志用于确定该Class文件里面的invokespecial指令使用的是哪一种执行语义。目前Java 虚拟机的编译器都应当设置这个标志。ACC_SUPER标记是为了向后兼容旧编译器编译的Class文件而存在的,在JDK1.0.2版本以前的编译器产生的Class文件中,access_flag里面没有 ACC_SUPER 标志。同时,JDK1.0.2前的Java虚拟机遇到ACC_SUPER标记会自动忽略它。

在表 4.1 中没有使用的access_flags标志位是为未来扩充而预留的,这些预留的标志为在编译器中会被设置为0,Java虚拟机实现也会自动忽略它们。

4、类索引、父类索引、接口索引集合

 

类索引(this_class)和父类索引(super_class)都是一个u2类型的数据,而接口索引集合(interfaces)是一组u2类型的数据的集合,Class文件中由这三项数据来确定这个类的继承关系。

类索引用(this_class)于确定这个类的全限定名,父类索引(super_class)用于确定这个类的父类的全限定名。由于Java语言不允许多重继承,所以父类索引只有一个; 但是接口可以实现好几个,所以是个集合,

除了java.lang.Object之外, 所有的Java类都有父类,因此除了java.lang.Object外,所有Java类的父类索引都不为0。

接口索引集合就用来描述这个类实现了哪些接口,这些被实现的接口将按implements语句(如果这个类本身是一个接口,则应当是extends语句)后的接口顺序从左到右排列在接口索引集合中。

5、字段表集合

 

 

字段表,fields[]数组中的每个成员都必须是一个 fields_info 结构(§4.5)的数据项,用于表示当前类或接口中某个字段的完整描述。fields[]数组描述当前类或接口声明的所有字段,但不包括从父类或父接口继承的部分。字段(field)包括类级变量以及实例级变量。

而字段叫什么名字、字段被定义为什么数据类型,这些都是无法固定的,只能引用常量池中的常量来描述。字段表集合中不会列出从超类或者父接口中继承而来的字段,但有可能列出原本Java代码之中不存在的字段,譬如在内部类中为了保持对外部类的访问性,会自动添加指向外部类实例的字段。

每个字段的结构都如下:

field_info {
    u2             access_flags;
    u2             name_index;
    u2             descriptor_index;
    u2             attributes_count;
    attribute_info attributes[attributes_count];
}

5.1、访问标识符( access_flags )如下:

对访问标识符的解释

ACC_PUBLIC、ACC_PRIVATE、ACC_PROTECTED三个标志最多只能选择其一。

接口中的字段必须有ACC_PUBLIC、ACC_STATIC、ACC_FINAL标志。

ACC_ENUM标志指示该字段用于保存枚举类型的元素。

ACC_SYNTHETIC标志表明该字段是由编译器生成的,并没有出现在源代码中。

5.2、name_index:

对常量池的引用, 其值为常量池中的有效索引,代表着字段的简单名称。

5.3、descriptor_index:

描述符的作用是用来描述字段的数据类型、方法的参数列表(包括数量、类型以及顺序)和返回值。

根据描述符规则,基本数据类型(byte、char、double、float、int、long、short、boolean)以及代表无返回值的void类型都用一个大写V字符来表示,而对象类型则用字符L加对象的全限定名来表示

对于数组类型,每一维度将使用一个前置的[字符来描述,如一个定义为java.lang.String[][]类型的二维数组,将被记录为:[[Ljava/lang/String,一个整型数组int[]将被记录为[I

用描述符来描述方法时,按照先参数列表,后返回值的顺序描述,参数列表按照参数的严格顺序放在一组小括号()之内。

如方法 void inc()的描述符为()V,方法 java.lang.String toString() 的描述符为()Ljava/lang/String,

方法 :

int indexOf(char[]source, int sourceOffset, int sourceCount, char[]target,

int targetOffset, int targetCount, int fromIndex)

描述符为([CII[CIII)I。

字段表集合中不会列出从超类或者父接口中继承而来的字段,但有可能列出原本Java代码之中不存在的字段,譬如在内部类中为了保持对外部类的访问性,会自动添加指向外部类实例的字段。

另外,在Java语言中字段是无法重载的,两个字段的数据类型、修饰符不管是否相同,都必须使用不一样的名称,但是对于字节码来讲,如果两个字段的描述符不一致,那字段重名就是合法的

6、方法表集合

描述了方法的定义,但是方法里的Java代码,经过编译器编译成字节码指令后,存放在属性表集合中的方法属性表集合中一个名为“Code”的属性里面。

Class文件存储格式中对方法的描述与对字段的描述几乎采用了完全一致的方式:

method_info {
    u2             access_flags;
    u2             name_index;
    u2             descriptor_index;
    u2             attributes_count;
    attribute_info attributes[attributes_count];
}

6.1access_flags: 

与属性进行对比:

因为volatile关键字和transient关键字不能修饰方法,所以方法表的访问标志中没有了ACC_VOLATILE标志和ACC_TRANSIENT标志。

 

与之相对的,synchronized、native、strictfp和abstract关键字可以修饰方法,所以方法表的访问标志中增加了ACC_SYNCHRONIZED、ACC_NATIVE、CC_STRICTFP和ACC_ABSTRACT标志。

6.2、name_index:

6.3描述符索引:

 

通过分析得出了public void init()

方法里的Java代码,经过编译器编译成字节码指令后,存放在方法属性表集合中一个名为Code的属性里面,属性表作为Class文件格式中最具扩展性的一种数据项目。

与字段表集合相对应的,如果父类方法在子类中没有被重写(Override),方法表集合中就不会出现来自父类的方法信息。但同样的,有可能会出现由编译器自动添加的方法,最典型的便是类构造器<clinit>方法和实例构造器<init>。

7、属性表集合

存储Class文件、字段表、方法表都自己的属性表集合,以用于描述某些场景专有的信息。如方法的代码就存储在Code属性表中。

对于每个属性,它的名称需要从常量池中引用一个CONSTANT_Utf8_info类型的常量来表示,而属性值的结构则是完全自定义的,只需要通过一个u4的长度属性去说明属性值所占用的位数即可。下面每个属性都具备的结构特征。

attribute_info {
u2 attribute_name_index;
    u4 attribute_length;
    u1 info[attribute_length];
}

在分析方法表的时候已经讲述了一个Code属性

7.1、Code属性描述

Java程序方法体中的代码经过Javac编译器处理后,最终变为字节码指令存储在Code属性内。Code属性出现在方法表的属性集合之中,但并非所有的方法表都必须存在这个属性,譬如接口或者抽象类中的方法就不存在Code属性。下面的图中就是Code:

 

(2)Code的两个属性值: 

 

7.2、Code属性表中的code(字节码)

(1)例如:2a b7 00 0a b1

  • 读入2a,查表得0x2A对应的指令为aload_0,这个指令的含义是将第0个Slot中为reference类型的本地变量推送到操作数栈顶。
  • 读入b7,查表得0xB7对应的指令为invokespecial,这条指令的作用是以栈顶的reference类型的数据所指向的对象作为方法接收者,调用此对象的实例构造器方法、private方法或者它的父类的方法。这个方法有一个u2类型的参数说明具体调用哪一个方法,它指向常量池中的一个CONSTANT_Methodref_info类型常量,即此方法的方法符号引用。
  • 读入00 01,这是invokespecial的参数,查常量池得0x0001对应的常量为实例构造器<init>方法的符号引用。
  • 读入b1,查表得0xB1对应的指令为return,含义是返回此方法,并且返回值为void。这条指令执行后,当前方法结束。

ava虚拟机执行字节码是基于栈的体系结构。但是与一般基于堆栈的零字节指令又不太一样,某些指令(如invokespecial)后面还会带有参数。

(2)分析Code中最后两个属性:LineNumberTable和LocalVariableTable:

 

  • LineNumberTable属性用于描述Java源码行号与字节码行号(字节码的偏移量)之间的对应关系。它并不是运行时必需的属性,但默认会生成到Class文件之中,可以在Javac中分别使用-g:none或-g:lines选项来取消或要求生成这项信息。

如果选择不生成LineNumberTable属性,对程序运行产生的最主要的影响就是当抛出异常时,堆栈中将不会显示出错的行号,并且在调试程序的时候,也无法按照源码行来设置断点。

line_number_table是一个数量为line_number_table_length、类型为line_number_info的集合,line_number_info表包括了start_pc和line_number两个u2类型的数据项,前者是字节码行号,后者是Java源码行号

 

 

归纳为下面的结构:

LineNumberTable_attribute {
    u2 attribute_name_index;
    u4 attribute_length;
    u2 line_number_table_length;
    {   
        u2 start_pc;
        u2 line_number;
    } line_number_table[line_number_table_length];
}
  • LocalVariableTable属性结构如下
LocalVariableTable_attribute {
    u2 attribute_name_index;
    u4 attribute_length;
    u2 local_variable_table_length;

    {   u2 start_pc;
        u2 length;
        u2 name_index;
        u2 descriptor_index;
        u2 index;
    } local_variable_table[local_variable_table_length];
}
  • LocalVariableTable属性用于描述栈帧中局部变量表中的变量与Java源码中定义的变量之间的关系,
  • 它也不是运行时必需的属性,但默认会生成到Class文件之中,
  • 可以在Javac中分别使用-g:none或-g:vars选项来取消或要求生成这项信息。

local_variable_info项:

args_size 为什么等于 1?<init>()和inc(),都没有参数的,为什么args_size会为1?

而且无论是在参数列表里还是方法体内,都没有定义任何局部变量,那Locals又为什么会等于1?

在任何实例方法里面,都可以通过this关键字访问到此方法所属的对象。这个访问机制对Java程序的编写很重要,而它的实现却非常简单,仅仅是通过Javac编译器编译的时候把对this关键字的访问转变为对一个普通方法参数的访问,然后在虚拟机调用实例方法时自动传入此参数而已。因此在实例方法的局部变量表中至少会存在一个指向当前对象实例的局部变量,局部变量表中也会预留出第一个Slot位来存放对象实例的引用,方法参数值从1开始计算。这个处理只对实例方法有效,如果inc()声明为static,那Args_size就不会等于1而是等于0了。

7.3、ClassFile 最后一个属性:

还有很多属性没有讲解;当分析到对应的表时查询官方文档即可

https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-4.html#jvms-4.7

 

二、字节码指令

Java虚拟机的指令由一个字节长度的、代表着某种特定操作含义的数字(称为操作码,Opcode)以及跟随其后的零至多个代表此操作所需参数(称为操作数,Operands)而构成。由于限制了Java虚拟机操作码的长度为一个字节(即0~255),这意味着指令集的操作码总数不可能超过256条。大多数的指令都包含了其操作所对应的数据类型信息。

例如:iload指令用于从局部变量表中加载int型的数据到操作数栈中,而fload指令加载的则是float类型的数据。大部分的指令都没有支持整数类型byte、char和short,甚至没有任何指令支持boolean类型。大多数对于boolean、byte、short和char类型数据的操作,实际上都是使用相应的int类型作为运算类型

阅读字节码作为了解Java虚拟机的基础技能,有需要的话可以去掌握常见指令。

1、加载和存储指令

用于将数据在栈帧中的局部变量表和操作数栈之间来回传输,这类指令包括如下内容。

  • 将一个局部变量加载到操作栈:

iload、iload_<n>、lload、lload_<n>、fload、fload_<n>、dload、dload_<n>、aload、aload_<n>

  • 将一个数值从操作数栈存储到局部变量表:

istore、istore_<n>、lstore、lstore_<n>、fstore、fstore_<n>、dstore、dstore_<n>、astore、astore_<n>

  • 将一个常量加载到操作数栈:

bipush、sipush、ldc、ldc_w、ldc2_w、aconst_null、iconst_m1、iconst_<i>、lconst_<l>、fconst_<f>、dconst_<d>

  • 扩充局部变量表的访问索引的指令:wide。

2、运算或算术指令

用于对两个操作数栈上的值进行某种特定运算,并把结果重新存入到操作栈顶。

加法指令:iadd、ladd、fadd、dadd。

减法指令:isub、lsub、fsub、dsub。

乘法指令:imul、lmul、fmul、dmul等等

3、类型转换指令

可以将两种不同的数值类型进行相互转换,Java虚拟机直接支持以下数值类型的宽化类型转换(即小范围类型向大范围类型的安全转换):

  • int类型到long、float或者double类型。
  • long类型到float、double类型。
  • float类型到double类型。
  • 处理窄化类型转换(Narrowing Numeric Conversions)时,必须显式地使用转换指令来完成,这些转换指令包括:i2b、i2c、i2s、l2i、f2i、f2l、d2i、d2l和d2f。

4、创建类实例的指令new

5、创建数组的指令: (newarray、anewarray、multianewarray)

6、访问字段指令(getfield、putfield、getstatic、putstatic)

7、数组存取相关指令

把一个数组元素加载到操作数栈的指令:

baload、caload、saload、iaload、laload、faload、daload、aaload

将一个操作数栈的值存储到数组元素中的指令:

bastore、castore、sastore、iastore、fastore、dastore、aastore

取数组长度的指令:arraylength。

8、检查类实例类型的指令instanceofcheckcast

9、操作数栈管理指令

如同操作一个普通数据结构中的堆栈那样,Java虚拟机提供了一些用于直接操作操作数栈的指令,包括:将操作数栈的栈顶一个或两个元素出栈:pop、pop2。

复制栈顶一个或两个数值并将复制值或双份的复制值重新压入栈顶:

dup、dup2、dup_x1、dup2_x1、dup_x2、dup2_x2。

将栈最顶端的两个数值互换:swap。

10、控制转移指令

控制转移指令可以让Java虚拟机有条件或无条件地从指定的位置指令而不是控制转移指令的下一条指令继续执行程序,从概念模型上理解,可以认为控制转移指令就是在有条件或无条件地修改PC寄存器的值。控制转移指令如下。

  • 条件分支:

ifeq、iflt、ifle、ifne、ifgt、ifge、ifnull、ifnonnull、if_icmpeq、if_icmpne、if_icmplt、if_icmpgt、if_icmple、if_icmpge、if_acmpeq和if_acmpne。

  • 复合条件分支:tableswitch、lookupswitch。
  • 无条件分支:goto、goto_w、jsr、jsr_w、ret。

11、方法调用指令

invokevirtual指令用于调用对象的实例方法,根据对象的实际类型进行分派(虚方法分派),这也是Java语言中最常见的方法分派方式。

invokeinterface指令用于调用接口方法,它会在运行时搜索一个实现了这个接口方法的对象,找出适合的方法进行调用。

invokespecial用于调用一些需要特殊处理的实例方法,包括实例初始化方法、私有方法和父类方法。

invokestatic指令用于调用类方法(static方法)。

invokedynamic指令用于在运行时动态解析出调用点限定符所引用的方法,并执行该方法,前面4条调用指令的分派逻辑都固化在Java虚拟机内部,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。方法调用指令与数据类型无关。

12、方法返回指令

是根据返回值的类型区分的,包括ireturn(当返回值是boolean、byte、char、short和int类型时使用)、lreturn、freturn、dreturn和areturn,另外还有一条return指令供声明为void的方法、实例初始化方法以及类和接口的类初始化方法使用。

13、异常处理指令

在Java程序中显式抛出异常的操作(throw语句)都由athrow指令来实现

14、同步指令

有monitorenter和monitorexit两条指令来支持synchronized关键字的语义

三、虚拟机栈再认识

整体介绍见 运行时数据区域,虚拟机栈简单介绍见 虚拟机栈(JVM后续的执行子程序有详细的见解)

栈帧中的数据在编译后就已经确定了,写在了字节码文件的code属性中(属性表集合)

1、栈桢详解

当前栈帧有效:一个线程的方法调用链可能会很长,这意味着虚拟机栈会被压入很多栈帧,但在线程执行的某个时间点只有位于栈顶的栈帧才是有效的,该栈帧称为“当前栈帧”,与这个栈帧相关联的方法称为“当前方法”。

1.1、局部变量表

局部变量表的容量以变量槽(Variable Slot,下称 Slot)为最小单位,虚拟机规范中导向性地说到每个 Slot 都应该能存放一个 boolean、byte、char、short、int、float、double、long  8 种数据类型和reference  ,可以使用 32 位或更小的物理内存来存放。

对于 64 位的数据类型,虚拟机会以高位对齐的方式为其分配两个连续的 Slot 空间。Java 语言中明确的(reference 类型则可能是 32 位也可能是 64 位)64 位的数据类型只有 long 和 double 两种。

1.2、操作数栈

操作数栈(Operand Stack)也常称为操作栈,它是一个先进后出(First In Last Out,FILO)栈。 同局部变量表一样, 操作数栈的每一个元素可以是任意的Java数据类型,包括long和double。 32位数据类型所占的栈容量为1,64位数据类型所占的栈容量为2。

当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈/入栈操作。 例如,在做算术运算的时候是通过操作数栈来进行的,又或者在"调用其他方法的时候是通过操作数栈来进行参数传递的"。

java虚拟机的解释执行引擎称为“基于栈的执行引擎”,其中所指的“栈”就是操作数栈。

1.3、数据重叠优化

虚拟机概念模型中每二个栈帧都是相互独立的,但在实际应用是我们知道一个方法调用另一个方法时,往往存在参数传递,这种做法在虚拟机实现过程中会做一些优化,具体做法如下:令两个栈帧出现一部分重叠。让下面栈帧的一部分操作数栈与上面栈帧的部分局部变量表重叠在一起,进行方法调用

时就可以共用一部分数据,无须进行额外的参数复制传递。

 

1.4、动态连接

既然是执行方法,那么我们需要知道当前栈帧执行的是哪个方法,栈帧中会持有一个引用(符号引用),该引用指向某个具体方法。符号引用是一个地址位置的代号,在编译的时候我们是不知道某个方法在运行的时候是放到哪里的,这时我用代号com/chj/pojo/User.Say:()V指代某个类的方法,将来可以把符号引用转换成直接引用进行真实的调用。

用符号引用转化成直接引用的解析时机,把解析分为两大类:

  • 静态解析:符号引用在类加载阶段或者第一次使用的时候就直接转换成直接引用。
  • 动态连接:符号引用在每次运行期间转换为直接引用,即每次运行都重新转换。

1.5、方法返回地址

方法退出方式有:正常退出与异常退出。理论上,执行完当前栈帧的方法,需要返回到当前方法被调用的位置,所以栈帧需要记录一些信息,用来恢复上层方法的执行状态。正常退出,上层方法的PC计数器可以做为当前方法的返回地址,被保存在当前栈帧中。"异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中一般不会保存这部分信息"

方法退出时会做的操作:恢复上次方法的局部变量表、操作数栈,把当前方法的返回值,压入调用者栈帧的操作数栈中,使用当前栈帧保存的返回地址调整PC计数器的值,当前栈帧出栈,随后执行PC计数器指向的指令。

1.6、附加信息

虚拟机规范允许实现虚拟机时增加一些额外信息,例如与调试相关的信息。一般把动态连接、方法返回地址、其他额外信息归成一类,称为栈帧信息。

2、基于栈的字节码解释执行引擎

Java编译器输出的指令流,基本上是一种基于栈的指令集架构,指令流中的指令大部分都是零地址指令,它们依赖操作数栈进行工作。与基于寄存器的指令集,最典型的就是x86的二地址指令集,说得通俗一些,就是现在我们主流PC机中直接支持的指令集架构,这些指令依赖寄存器进行工作。

2.1、基于栈的指令集

举个最简单的例子,分别使用这两种指令集计算“1+1”的结果,基于栈的指令集会是这样子的:

iconst_1
iconst_1
iadd
istore_0

两条iconst_1指令连续把两个常量1压入栈后,iadd指令把栈顶的两个值出栈、相加,然后把结果放回栈顶,最后istore_0把栈顶的值放到局部变量表的第0个Slot中。

2.2、基于寄存器的指令集

如果基于寄存器,那程序可能会是这个样子:

mov eax,1

add eax,1

mov指令把EAX寄存器的值设为1,然后add指令再把这个值加1,结果就保存在EAX寄存器里面。

基于栈的指令集主要的优点就是可移植,寄存器由硬件直接提供,程序直接依赖这些硬件寄存器则不可避免地要受到硬件的约束。栈架构指令集的主要缺点是执行速度相对来说会稍慢一些。所有主流物理机的指令集都是寄存器架构也从侧面印证了这一点。

四、方法调用

1、解析

调用目标在程序代码写好、编译器进行编译时就必须确定下来,这类方法的调用称为解析。

在Java语言中符合“编译期可知,运行期不可变”这个要求的方法,主要包括静态方法和私有方法两大类,前者与类型直接关联,后者在外部不可被访问,这两种方法各自的特点决定了它们都不可能通过继承或别的方式重写其他版本,因此它们都适合在类加载阶段进行解析。见实例代码(dispatch包)

2、静态分派

多见于方法的重载:

“Human”称为变量的静态类型(Static Type),或者叫做的外观类型(Apparent Type),后面的“Man”则称为变量的实际类型(Actual Type),静态类型和实际类型在程序中都可以发生一些变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译期可知的;而实际类型变化的结果在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。

代码中定义了两个静态类型相同但实际类型不同的变量,但虚拟机(准确地说是编译器)在重载时是通过参数的静态类型而不是实际类型作为判定依据的。并且静态类型是编译期可知的,因此,在编译阶段,Javac编译器会根据参数的静态类型决定使用哪个重载版本,所以选择了sayHello(Human)作为调用目标。所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。静态分派的典型应用是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。

3、动态分派

静态类型同样都是Human的两个变量man和woman在调用sayHello()方法时执行了不同的行为,并且变量man在两次调用中执行了不同的方法。导致这个现象的原因很明显,是这两个变量的实际类型不同。

在实现上,最常用的手段就是为类在方法区中建立一个虚方法表。虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类中重写了这个方法,子类方法表中的地址将会替换为指向子类实现版本的入口地址。PPT图中,Son重写了来自Father的全部方法,因此Son的方法表没有指向Father类型数据的箭头。但是Son和Father都没有重写来自Object的方法,所以它们的方法表中所有从Object继承来的方法都指向了Object的数据类型。

五、类生命周期

类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载(Loading)、验证Verification、准备Preparation、解析Resolution、初始化Initialization、使用Using和卸载Unloading 7个阶段,其中验证、准备、解析3个部分统称为连接(Linking)

1、初始化

初始化阶段,虚拟机规范则是严格规定了有且只有5种情况必须立即对类进行“初始化”(而加载、验证、准备自然需要在此之前开始):

1)遇到newgetstaticputstaticinvokestatic4条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。生成这4条指令的最常见的Java代码场景是:使用new关键字实例化对象的时候、读取或设置一个类的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候。

2)使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。

3)当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。

4)当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。

5)当使用JDK 1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。

举例(clazzload包中例子):

① 对于静态字段,只有直接定义这个字段的类才会被初始化,因此通过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化,如下图:

 

② 数组形式的new(而不是构造方法)不会触发类初始化:

 

③ 直接打印类的常量会不会触发类的初始化:(坑:项目中有可能常量改了,关联使用的类不重新编译就会还是原来的值),常量HELLOWORLD,但其实在编译阶段通过常量传播优化,已经将此常量的值“hello world”存储到了NotInitialization类的常量池中,以后NotInitialization对常量ConstClass.HELLOWORLD的引用实际都被转化为NotInitialization类对自身常量池的引用了。

也就是说,实际上NotInitialization的Class文件之中并没有ConstClass类的符号引用入口,这两个类在编译成Class之后就不存在任何联系了。

4.如果使用常量去引用另外一个常量,这个时候编译阶段无法进行优化,所以才会触发类的初始化。

public static int value=123;

public static final String HELLOWORLD="hello world  king";

public static final int WHAT = value;

执行下面打印方法:

//如果使用常量去引用另外一个常量(会不会进行初始化, 1,不会2)
System.out.println(SuperClazz.WHAT);

执行结果如下:SuperClass init?  123

2、加载阶段

虚拟机需要完成以下3件事情:

1)通过一个类的全限定名来获取定义此类的二进制字节流。

2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。

3)在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。

3、验证

是连接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。但从整体上看,验证阶段大致上会完成下面4个阶段的检验动作:文件格式验证、元数据验证、字节码验证、符号引用验证。

4、准备阶段

是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。这个阶段中有两个容易产生混淆的概念需要强调一下,首先,这时候进行内存分配的仅包括类变量(被static修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中。其次,这里所说的初始值“通常情况”下是数据类型的零值,假设一个类变量的定义为:public static int value=123

那变量value在准备阶段过后的初始值为0而不是123,因为这时候尚未开始执行任何Java方法,而把value赋值为123的putstatic指令是程序被编译后,存放于类构造器<clinit>()方法之中,所以把value赋值为123的动作将在初始化阶段才会执行。假设上面类变量value的定义变为:public static final int value=123

编译时Javac将会为value生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置将value赋值为123。

5、解析阶段

是虚拟机将常量池内的符号引用替换为直接引用的过程。部分详细内容见解析

6、类初始化阶段

是类加载过程的最后一步,前面的类加载过程中,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。到了初始化阶段,才真正开始执行类中定义的Java程序代码在准备阶段,变量已经赋过一次系统要求的初始值,而在初始化阶段,则根据程序员通过程序制定的主观计划去初始化类变量和其他资源,或者可以从另外一个角度来表达:初始化阶段是执行类构造器<clinit>()方法的过程。<clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的。

<clinit>()方法对于类或接口来说并不是必需的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成<clinit>()方法。

初始化的单例模式(线程安全)

虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确地加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行<clinit>()方法完毕。如果在一个类的<clinit>()方法中有耗时很长的操作,就可能造成多个进程阻塞。所以类的初始化是线程安全的,项目中可以利用这点。

六、类加载器

对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。这句话可以表达得更通俗一些:比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个Class文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。

这里所指的“相等”,包括代表类的Class对象的equals()方法、isAssignableFrom()方法、isInstance()方法的返回结果,也包括使用instanceof关键字做对象所属关系判定等情况。

1、加解密案例(deencrpt代码)

通过位的二进制异或运算进行加解密(一次就是加密,再运算一次就是解密)

1)DemoUser.class  重命名为DemoUserSrc.class同时删掉DemoUser.class,再通过XorEncrpt加密生成DemoUser.class,使用编辑工具查看下加密前和加密后。

2)写一个自定义的类加载器,继承ClassLoader,同时在加载时进行解密。

3)写一个DemoRun类,使用自义定的类加载器加密,再打印类的对象,看它是哪个类加载器加载的,是否能正常显示。

加解密的项目中运用:可以使用把代码使用私钥加密,在解析阶段使用公钥解密。这样跟用户做项目时提供对应的公钥,自己提供私钥加密后的代码信息。在类加载时使用公钥解密运行。这样可以可以确保源代码的保密性。

2、双亲委派模型

对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在Java虚拟机中的唯一性。从Java虚拟机的角度来讲,只存在两种不同的类加载器:一种是启动类加载器(Bootstrap ClassLoader),这个类加载器使用C++语言实现,是虚拟机自身的一部分;另一种就是所有其他的类加载器,这些类加载器都由Java语言实现,独立于虚拟机外部,并且全都继承自抽象类java.lang.ClassLoader。

1)启动类加载器(Bootstrap ClassLoader

这个类将器负责将存放在<JAVA_HOME>\lib目录中的,或者被-Xbootclasspath参数所指定的路径中的,并且是虚拟机识别的(仅按照文件名识别,如rt.jar,名字不符合的类库即使放在lib目录中也不会被加载)类库加载到虚拟机内存中。启动类加载器无法被Java程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给引导类加载器,那直接使用null代替即可。

2)扩展类加载器(Extension ClassLoader

这个加载器由sun.misc.Launcher$ExtClassLoader实现,它负责加载<JAVA_HOME>\lib\ext目录中的,

或者被java.ext.dirs系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器。

3)应用程序类加载器(Application ClassLoader):

这个类加载器由sun.misc.Launcher $App-ClassLoader实现。由于这个类加载器是ClassLoader中的getSystemClassLoader()方法的返回值,所以一般也称它为系统类加载器。它负责加载用户类路径(ClassPath)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

我们的应用程序都是由这3种类加载器互相配合进行加载的,如果有必要,还可以加入自己定义的类加载器。双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。这里类加载器之间的父子关系一般不会以继承(Inheritance)的关系来实现,而是都使用组合(Composition)关系来复用父加载器的代码。

使用双亲委派模型来组织类加载器之间的关系,有一个显而易见的好处就是Java类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类java.lang.Object,它存放在rt.jar之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都是同一个类。相反,如果没有使用双亲委派模型,由各个类加载器自行去加载的话,如果用户自己编写了一个称为java.lang.Object的类,并放在程序的ClassPath中,那系统中将会出现多个不同的Object类,Java类型体系中最基础的行为也就无法保证,应用程序也将会变得一片混乱。

3、应用程序类加载器

ClassLoader中的loadClass方法中的代码逻辑就是双亲委派模型:

在自定义ClassLoader的子类时候,我们常见的会有两种做法,一种是重写loadClass方法,另一种是重写findClass方法。其实这两种方法本质上差不多,毕竟loadClass也会调用findClass,但是从逻辑上讲我们最好不要直接修改loadClass的内部逻辑。我建议的做法是只在findClass里重写自定义类的加载方法。
loadClass这个方法是实现双亲委托模型逻辑的地方,擅自修改这个方法会导致模型被破坏,容易造成问题。因此我们最好是在双亲委托模型框架内进行小范围的改动,不破坏原有的稳定结构。同时,也避免了自己重写loadClass方法的过程中必须写双亲委托的重复代码,从代码的复用性来看,不直接修改这个方法始终是比较好的选择。

六、Tomcat类加载器

Tomcat本身也是一个java项目,因此其也需要被JDK的类加载机制加载,也就必然存在引导类加载器、扩展类加载器和应用(系统)类加载器。

Common ClassLoader作为Catalina ClassLoader和Shared ClassLoader的parent,而Shared ClassLoader又可能存在多个children类加载器WebApp ClassLoader,一个WebApp ClassLoader实际上就对应一个Web应用,那Web应用就有可能存在Jsp页面,这些Jsp页面最终会转成class类被加载,因此也需要一个Jsp的类加载器。

需要注意的是,在代码层面Catalina ClassLoader、Shared ClassLoader、Common ClassLoader对应的实体类实际上都是URLClassLoader或者SecureClassLoader,一般我们只是根据加载内容的不同和加载父子顺序的关系,在逻辑上划分为这三个类加载器;而WebApp ClassLoader和JasperLoader都是存在对应的类加载器类的。

当tomcat启动时,会创建几种类加载器:

1、Bootstrap 引导类加载器 加载JVM启动所需的类,以及标准扩展类(位于jre/lib/ext下)

2、System 系统类加载器 加载tomcat启动的类,比如bootstrap.jar,通常在catalina.bat或者catalina.sh中指定。位于CATALINA_HOME/bin下。

3、Common 通用类加载器 加载tomcat使用以及应用通用的一些类,位于CATALINA_HOME/lib下,比如servlet-api.jar

4、webapp 应用类加载器每个应用在部署后,都会创建一个唯一的类加载器。该类加载器会加载位于 WEB-INF/lib下的jar文件中的class 和 WEB-INF/classes下的class文件。

Tomcat类加载源码分析:WebappClassLoader中loadClass方法

猜你喜欢

转载自blog.csdn.net/m0_37661458/article/details/90777901