前言

疫情期间看完了《深入理解Java虚拟机》一书,看完之后觉得有些囫囵吞枣,没有留下深刻印象,因此写点小小总结,把握一下重点知识,尽量形成认知框架中的一部分。

原书的内容十分详细,也肯定写得比我好。我这里只做简单的概括,详细的还是去书里看比较好。

Java编译器

解释器与编译器两者各有优势:

  • 当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即运行
  • 当程序启动后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码,这样可以减少解释器的中间损耗,获得更高的执行效率
  • 当程序运行环境中内存资源限制较大,可以使用解释执行节约内存,反之可以使用编译执行来提升效率

解释器与编译器的交互

编译器分类

  • 前端编译器:把*.java文件转变成*.class文件的过程
    • JDK的Javac、Eclipse JDT中的增量式编译器(ECJ)
  • 即时编译器:常称JIT编译器(Just In Time Compiler),运行期把字节码转变成本地机器码的过程
    • 提高热点代码的执行效率
    • HotSpot虚拟机的C1、C2编译器,Graal编译器
  • 提前编译器:常称AOT编译器(Ahead Of Time Compiler),直接把程序编译成与目标机器指令集相关的二进制代码的过程
    • JDK的Jaotc、GNU Compiler for the Java(GCJ)、Excelsior JET

Java类文件结构

需要注意:Java中的类不必都以磁盘文件形式存在,可以动态生成,或者通过I/O输入等等

任意有效的类或接口所满足的格式即为Class文件格式

Class文件是一组以8个字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑地排列在文 件之中,中间没有添加任何分隔符。当遇到需要占用8个字节以上空间的数据项时,则会按照高位在前(Big Endian)的方式分割成若干个8个字节进行存储。

Class文件格式采用一种类似于C语言结构体的伪结构来存储数 据,这种伪结构中只有两种数据类型:无符号数

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

Class文件格式如下表所示:

Class文件格式

魔数

每个Class文件的头4个字节被称为魔数(Magic Number),它的唯一作用是确定这个文件是否为一个能被虚拟机接受的Class文件。Class文件的魔数为0xCAFEBABE

紧接着,第5和第6个字节是次版本号(Minor Version),第7和第8个字节是主版本号(Major Version)

高版本的JDK能向下兼容以前版本的Class文件,但不能运行以后版本的Class文件。Class文件与JDK版本的兼容关系如下图所示:

Class文件版本号

注:从JDK 9开始,Javac编译器不再支持使用-source参数编译版本号小于1.5的源码

常量池

版本号之后紧接着是常量池,它是Class文件结构中与其他项目关联最多的数据,通常也是占用Class文件空间最大的数据项目之一

常量池中常量的数量并不固定,因此需要先放置一项u2类型的数据,表示常量池容量计数值(constant_pool_count)

Class文件结构中只有常量池的容量计数是从1开始,对于其他集合类型,包括接口索引集合、字段表集合、方法表集合等的容量计数都与一般习惯相同,是从0开始

常量池中主要存放两大类常量:字面量(Literal)和符号引用(Symbolic References)

  • 字面量:文本字符串、被声明为final的常量值等
  • 符号引用
    • 被模块导出或者开放的包
    • 类和接口的全限定名
    • 字段的名称和描述符
    • 方法的名称和描述符
    • 方法句柄和方法类型
    • 动态调用点和动态常量

之后对常量池的解析是一种嵌套解析:因为每一个常量都是17种常量中的一种,有的格式简单;有的较为复杂,内部又由各种简单常量组成。总之,每个常量的解析都是先读取一个字节判断其属于17种常量中的哪一种,再根据这种常量的组成结构读取组成他的实际数据。Class文件组成紧密,并没有多余的停顿符号。

更细致的解析过程详见原书6.3节,这里不再赘述

17种常量类型如下:

常量池的项目类型

访问标志

在常量池结束之后,紧接着的2个字节代表访问标志(access_flags),这个标志用于识别一些类或者接口层次的访问信息,包括:

  • 这个Class是类还是接口;
  • 是否定义为public类型;
  • 是否定义为abstract类型;
  • 如果是类的话,是否被声明为final;等等

当然,不同含义的标志位之间可以用 | 表示,具体的标志位以及标志的含义见下表:

访问标志

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

类索引用于确定这个类的全限定名;

父类索引用于确定这个类的父类的全限定名;

接口索引集合就用来描述这个类实现了哪些接口,这些被实现的接口将按implements关键字(如果这个Class文件表示的是一个接口,则应当是extends关键字)后的接口顺序从左到右排列在接口索引集合中。

Class文件中由这三项数据来确定该类型的继承关系

类索引、父类索引和接口索引集合都按顺序排列在访问标志之后,类索引和父类索引用两个u2类型的索引值表示,它们各自指向一个类型为CONSTANT_Class_info的类描述符常量,通过CONSTANT_Class_info类型的常量中的索引值可以找到定义在CONSTANT_Utf8_info类型的常量中的全限定名字符串。

类索引查找全限定名的过程

对于接口索引集合,入口的第一项u2类型的数据为接口计数器(interfaces_count),表示索引表的容量。如果该类没有实现任何接口,则该计数器值为0,后面接口的索引表不再占用任何字节。

字段表集合

字段表(field_info)用于描述接口或者类中声明的变量,包括类级变量以及实例级变量,但不包括在方法内部声明的局部变量。

字段表结构

字段可以包括的修饰符有字段的作用域(public、private、protected修饰符)、是实例变量还是类变量(static修饰符)、可变性(final)、并发可见性(volatile修饰符,是否强制从主内存读写)、可否被序列化(transient修饰符)、字段数据类型(基本类型、对象、数组)、字段名称

字段修饰符放在access_flags项目中,它与类中的access_flags项目是非常类似的,都是一个u2的数据类型,其中可以设置的标志位和含义如下表所示:

字段访问标志

方法表集合

方法表中的数据项目的含义与字段表中的非常类似,仅在访问标志和属性表集合的可选项中有所区别

方法里的Java代码,经过Javac编译器编译成字节码指令之后,存放在方法属性表集合中一个名为Code的属性里面

方法表结构

因为volatile关键字和transient关键字不能修饰方法,所以方法表的访问标志中没有了ACC_VOLATILE标志和ACC_TRANSIENT标志。与之相对,synchronized、native、strictfp和abstract关键字可以修饰方法,方法表的访问标志中也相应地增加了ACC_SYNCHRONIZED、ACC_NATIVE、ACC_STRICTFP和ACC_ABSTRACT标志。其中可以设置的标志位和含义如下表所示:

方法访问标志

属性表集合

属性表集合的限制稍微宽松很多,不再要求各个属性表具有严格顺序

《Java虚拟机规范》允许只要不与已有属性名重复,任何人实现的编译器都可以向属性表中写入自己定义的属性信息

Code属性

Java程序方法体里面的代码经过Javac编译器处理之后,最终变为字节码指令存储在Code属性内

Code属性用于描述代码,所有的其他数据项目都用于描述元数据

Code属性的结构将如下表所示:

Code属性表的结构

各属性简要解析:

  • attribute_name_index是一项指向CONSTANT_Utf8_info型常量的索引,此常量值固定为“Code”,它代表了该属性的属性名称
  • attribute_length指示了属性值的长度
    • 属性名称索引与属性长度一共为6个字节,所以属性值的长度固定为整个属性表长度减去6个字节
  • max_stack代表了操作数栈(Operand Stack)深度的最大值
  • max_locals代表了局部变量表所需的存储空间
    • max_locals的单位是变量槽(Slot),变量 槽是虚拟机为局部变量分配内存所使用的最小单位
    • 根据同时生存的最大局部变量数量和类型计算出max_locals的大小
  • code_lengthcode用来存储Java源程序编译后生成的字节码指令
    • code_length代表字节码长度
    • code是用于存储字节码指令的一系列字节流
异常表

异常表属于Java代码的一部分,是代码里显式能处理的异常

如果存在异常表,那它的格式应如下表所示,包含四个字段,这些字段的含义为:如果当字节码从第start_pc行到第end_pc行之间(不含第end_pc行)出现了类型为catch_type或者其子类的异常(catch_type为指向一个CONSTANT_Class_info型常量的索引),则转到第handler_pc行继续处理。当catch_type的值为0时,代表任意异常情况都需要转到handler_pc处进行处理。

异常表属性结构

Exceptions属性

Exceptions属性的作用是列举出方法中可能抛出的受查异常(Checked Excepitons),也就是方法描述时在throws关键字后面列举的异常

Exceptions属性结构

此属性中的number_of_exceptions项表示方法可能抛出number_of_exceptions种受查异常,每一种受查异常使用一个exception_index_table项表示;exception_index_table是一个指向常量池中 CONSTANT_Class_info型常量的索引,代表了该受查异常的类型。

类加载

类加载的过程

一个类型从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)七个阶段,其中验证、准备、解析三个部分统称为连接(Linking)。这七个阶段的发生顺序如下图所示。


类的生命周期

加载阶段

在加载阶段,Java虚拟机需要完成以下三件事情:

  • 通过一个类的全限定名来获取定义此类的二进制字节流
    • 从ZIP压缩包中读取,这很常见,最终成为日后JAR、EAR、WAR格式的基础。
    • 从网络中获取,这种场景最典型的应用就是Web Applet。
    • 运行时计算生成,这种场景使用得最多的就是动态代理技术,在java.lang.reflect.Proxy中,就是用了ProxyGenerator.generateProxyClass()来为特定接口生成形式为“*$Proxy”的代理类的二进制字节流。
    • 由其他文件生成,典型场景是JSP应用,由JSP文件生成对应的Class文件。
    • 从数据库中读取,这种场景相对少见些,例如有些中间件服务器(如SAP Netweaver)可以选择把程序安装到数据库中来完成程序代码在集群间的分发。
    • 可以从加密文件中获取,这是典型的防Class文件被反编译的保护措施,通过加载时解密Class文件来保障程序运行逻辑不被窥探。
  • 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
  • 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口

验证阶段

这一阶段的目的是确保Class文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。

四个检验动作:

  • 文件格式验证:验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理
    • 是否以魔数0xCAFEBABE开头
    • 主、次版本号是否在当前Java虚拟机接受范围之内
    • 常量池的常量中是否有不被支持的常量类型(检查常量tag标志)
    • 指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量
    • CONSTANT_Utf8_info型的常量中是否有不符合UTF-8编码的数据
    • Class文件中各个部分及文件本身是否有被删除的或附加的其他信息
  • 元数据验证:对类的元数据信息进行语义校验,保证不存在与《Java语言规范》定义相悖的元数据信息
    • 这个类是否有父类(除了java.lang.Object之外,所有的类都应当有父类)
    • 这个类的父类是否继承了不允许被继承的类(被final修饰的类)
    • 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法
    • 类中的字段、方法是否与父类产生矛盾(例如覆盖了父类的final字段,或者出现不符合规则的方法重载,例如方法参数都一致,但返回值类型却不同等)
  • 字节码验证:通过数据流分析和控制流分析,确定程序语义是合法的、符合逻辑的
    • 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作
    • 保证任何跳转指令都不会跳转到方法体以外的字节码指令上
    • 保证方法体中的类型转换总是有效的
  • 符号引用验证:主要目的是确保解析行为能正常执行;发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段——解析阶段中发生
    • 符号引用中通过字符串描述的全限定名是否能找到对应的类
    • 在指定类中是否存在符合方法的字段描述符及简单名称所描述的方法和字段
    • 符号引用中的类、字段、方法的可访问性是否可被当前类访问

文件格式验证阶段的验证是基于二进制字节流进行的,只有通过了这个阶段的验证之后,这段字节流才被允许进入Java虚拟机内存的方法区中进行存储,所以后面的三个验证阶段全部是基于方法区的存储结构上进行的,不会再直接读取、操作字节流

准备阶段

准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段

  • 这时候进行内存分配的仅包括类变量,而不包括实例变量
  • 初始值通常情况下是数值类型的零值(final字段会直接赋最终值)

解析阶段

解析阶段是Java虚拟机将常量池内的符号引用替换为直接引用的过程

  • 符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定是已经加载到虚拟机内存当中的内容。
  • 直接引用(Direct References):直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局直接相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在虚拟机的内存中存在

初始化阶段

初始化阶段就是执行类构造器<clinit>()方法的过程

  • <clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作静态语句块static{}块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问

由于父类的<clinit>()方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作

同一个类加载器下,一个类型只会被初始化一次

类加载器

对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在Java虚拟机中的唯一性

每一个类加载器,都拥有一个独立的类名称空间

三层类加载器

  • 启动类加载器:这个类加载器负责加载存放在<JAVA_HOME>\lib目录,或者被-Xbootclasspath参数所指定的路径中存放的,而且是Java虚拟机能够识别的(按照文件名识别,如rt.jartools.jar,名字不符合的类库即使放在lib目录中也不会被加载)类库加载到虚拟机的内存中
  • 扩展类加载器:负责加载<JAVA_HOME>\lib\ext目录中,或者被java.ext.dirs系统变量所指定的路径中所有的类库
  • 应用程序加载器:ClassLoader类中的getSystem-ClassLoader()方法的返回值,所以有些场合中也称它为“系统类加载器”。负责加载用户类路径(ClassPath)上所有的类库,开发者同样可以直接在代码中使用这个类加载器

类加载器双亲委派模型

双亲委派模型

双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器。不过这里类加载器之间的父子关系一般不是以继承(Inheritance)的关系来实现的,而是通常使用组合(Composition)关系来复用父加载器的代码。

双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。

破坏双亲委派模型:。。。