上一篇文章介绍了Java的内存模型,对JVM的内存组织结构和各个内存区域有一个整体的了解。本文将详细描述Java虚拟机中的类加载器,类加载器负责将.class后缀的字节码文件加载到内存中,只有加载到内存了,才能让后面的执行引擎进行执行。接下来,让我们看看Java类加载器是如何进行加载的。

刚接触计算机的时候,我们都会被告知计算机的世界里只有0和1,如果要在计算机上运行,程序编译后应该也都是0和1才对,不然计算机是不能认识的。但是为什么虚拟机加载的是.class后缀的字节码文件呢?前一章,我们讲到了Java的平台无关性,既然要做到平台无关性,那么就必须抽象出一套自己的指令处理系统,而编译处理的字节码中的命令正是虚拟机能识别的指令。有人也把虚拟机称为解释器,就是负责解释执行字节码文件中的指令。

JVM

class文件

既然类加载器加载的是class文件,那么首先介绍一下class文件。class文件也是有自己的制定规范的,只有符合规范的class文件才能被虚拟机识别。不知道你是否打开过某个class文件,可以注意一下你打开的每个class文件的开头的4个字节,都是0xCAFEBABE。这个被称为魔数,也是class文件的一个身份识别码吧。看到这个魔数的时候,我就明白了为什么老是能看到Java的图标是一杯咖啡了,Java开发小组的人是有多喜欢喝咖啡。

class文件是采用类似于C语言中结构体来存储的,只包含两种数据类型:无符号数和表。无符号数属于基本的数据类型,可以用来描述数字、索引引用、数量值或按照UTF-8编码构成的字符串值。表是由多个无符号数或者其他表作为数据项构成的复合数据类型。整个class文件本质上就是一张表。

Java代码在编译时,并不像C和C++那样有『连接』步骤,而是在虚拟机加载class文件时候进行动态连接。当虚拟机运行时,需要从变量池获取对应的符号引用,然后在类创建或运行时解析翻译到具体的内存地址中。常量池中主要存放两种常量:字面量和符号引用。字面量通常有文本字符串、被声明为final的常量值。而符号引用则包括了三类常量:类和接口的全限定名、字段的名称和描述符、方法的名称和描述符。常量池中的每一项常量都是一个表,共有11种结构各不相同的表结构数据。

常量池后有2个字节表示访问标志,识别类和接口的访问信息。class文件中由类索引和父类索引和接口索引集合来确定类的继承关系。类索引确定这个类的全限定名,父类索引确定父类的全限定名,接口索引集合用来描述实现了哪些接口。字段表用来描述接口或类中声明的变量,字段包括了类变量和实例变量,但不包括方法内部声明的变量。方法表和字段表类似,描述类中定义的方法。class文件、字段表和方法表中都可以有自己的属性表集合。Java中方法体中的代码被编译后最终变成字节码指令存储在code属性内,code属性位于方法表的属性集合中。code属性是class文件中最重要的一个属性,用于描述代码,其他数据项目用于描述元数据。

Java程序被编译后,class文件中描述了相关的信息,后面还需要加载到虚拟机中才能运行和使用。

类加载

类加载的生命周期

类加载生命周期

从上图可以看出,类加载的生命周期包含了:加载、验证、准备、解析、初始化、使用和卸载七个阶段。验证、准备、解析三部分统称为连接阶段。一般,类加载的过程必须按照上图的顺序进行,但是解析阶段不一定,我们都知道Java是具备动态绑定(运行时绑定,例如:使用子类来创建父类型的对象),在初始化阶段之后可能会触发解析阶段,所以可能在某个阶段执行时会调用到另一个阶段。下面介绍每个阶段具体做了哪些工作。

加载阶段

加载阶段包含下面三个步骤:

  • 通过一个类的全限定名获取定义此类的二进制字节流(可以本地磁盘文件、网络字节流、动态生成等)。
  • 将字节流所代表的静态存储结构转化为方法区运行时数据结构。
  • 在Java堆生成一个代表该类的java.lang.Class对象,作为方法区这些数据的访问入口。

验证阶段

验证阶段顾名思义就是看加载的class文件是否满足规范,加载时验证类的元数据是否合法等。通常包含四个检验过程:

  • 文件格式验证

    就是验证class文件格式是否满足规范,如:之前提到class文件都包含一个固定的魔数,编译的版本是否在当前虚拟机处理范围内等。格式验证正确后,二进制字节流才能存储到内存的方法区中。

  • 元数据验证

    对字节码描述的信息进行语义分析,保证其描述的信息符合Java语言规范的要求。如:是否有父类(除java.lang.Object类之外,都有父类)、如果不是抽象类,是否实现父类或接口中要求实现的方法等。

  • 字节码验证

    进行数据流和控制流分析,对类的方法体进行校验分析,保证方法运行时不会出现危害虚拟机安全的行为。如:跳转指令不会跳转到方法体以外的字节码指令上等。

  • 符号引用验证

    该阶段发生在虚拟机将符号引用转化为直接引用的时候,这个转化是在解析阶段进行的,是对类自身以外的信息进行匹配性的校验。例如:符号引用中通过字符串描述的全限定名能否找到对应的类、是否具有对符号引用中的类、方法、字段具有访问权限等。

准备阶段

准备阶段正式为类变量分配内存并设置类变量的初始值,这些内存都在方法区上分配。这里设置的初始值是默认值,比如int类型的默认值是0,真正的初始化是在初始化阶段进行赋值。当然,如果类变量是常量,即用final修饰,则会进行正常的赋值。

解析阶段

解析阶段是虚拟机将常量池中的符号引用替换为直接引用的过程,这里符号引用其实就是一个字面量,用来定位具体的类,该类可能并未加载到内存中。而直接引用是指向目标的指针、相对偏移量或一个能间接定位到目标的句柄,直接引用的目标已经存在内存中。解析主要针对类或接口、字段、类方法、接口方法四类符号引用进行分别对应常量池中对应的四种常量类型。

  • 类或接口解析

当我们加载一个类时,通常都会依赖其他的类或接口,而编译时持有的是该类或接口的符号引用,这时就需要将符号引用解析为该类或接口的直接引用。解析的时候,如果该类或接口未被加载,将会触发加载阶段,同时也会对访问权限进行验证,当出现任何异常,解析过程会宣告失败。

  • 字段解析

字段解析也是解析字段所属的类或接口的符号引用,虚拟机会对该字段进行搜索,如果所属的类包含的该字段则返回这个字段的直接引用,否则递归查找该类实现的接口或该类继承的父类,如果找不到则查找失败,抛出异常,查找成功也会进行访问权限的验证。如果父类和接口都出现匹配的字段,会出现编译错误,不知道要用哪个出现歧义。

  • 类方法解析

类方法解析与字段解析类似,就是解析方法所属的类或接口的符号引用。因为类方法和接口方法的常量类型定义是分开的,所以如果发现方法所属的类是一个接口时会抛出异常。查找过程与字段解析类似,如果该类找不到匹配的方法,则递归查找父类中的方法。

  • 接口方法解析

接口方法解析类似类方法解析,但是接口方法都是public的,所以不需要进行访问权限的验证。

初始化阶段

初始化阶段是类加载的最后一步,该阶段真正开始执行类中定义的Java程序代码。其实简单讲该阶段就是执行类的类构造器方法。我们写Java都清楚构造函数,构造函数其实是实例化类时候执行的。某个类加载后是以java.lang.Class的一个对象存在,该类构造器()方法对应的在类加载时执行。该方法负责对所有类变量进行赋值,前面准备阶段进行了初始化默认值。还有类中的静态语句块也被合并到该方法中执行,由于合并的时候按源文件中语句出现的顺序,所以静态语句块中只能访问到定义在它之前的类变量,之后的访问不到。类构造器()不需要显示调用父类的构造器,虚拟机会保证在子类类构造器执行时,先执行父类的()方法,在虚拟机中第一个被执行的()方法的类一定是java.lang.Object,显然它是所有类的父类。这也就能解释执行顺序:父类静态语句块>子类静态语句块。类构造器()不是必须的,如果该类没有静态语句块,也没有对类变量的赋值,编译器可以不为该类生成()方法。

虚拟机会保证一个类的()方法在多线程的环境下被正确的加锁和同步,如果多个线程同时初始化一个类,只会有一个线程去执行该类的()方法,其他线程会阻塞。

类加载器

类加载器的作用就是在类加载阶段,通过一个类的全限定名获取对应类的二进制字节流,它让应用程序决定如何去获取所需的类。这是很正常的,因为一个类的来源可以是多种途径的,本地磁盘、网络、动态生成等途径。需要注意的是即使是同一个Class文件,由不同的类加载器进行加载,在虚拟机中这两个类是不相等的。比如,通过instanceof关键字来判断对象是否属于某个类,类型检查会返回false。

虚拟机中提供了三种类加载器:

  • 启动类加载器(Bootstrap ClassLoader):这个类加载器是C++语言实现的,是虚拟机自身的一部分,负责将存放在<JAVA_HOME>/lib目录中的类库(还需是虚拟机识别的)加载到虚拟机内存中,启动类加载器无法被Java程序直接引用。
  • 扩展类加载器(Extension ClassLoader):负责加载<JAVA_HOME>/lib/ext目录中的或java.ext.dirs系统变量所指定的路径的类库.
  • 应用程序类加载器(Application ClassLoader):也称为系统类加载器,负责加载用户用户类路径(ClassPath)上指定的类库,如果没有自定义自己的类加载器,一般情况下这个就是程序中默认的类加载器。

双亲委派模型

双亲委派模型

通常情况我们的程序都是由上面三种类加载器相互配合进行加载的,必要情况也会添加自定义的类加载器。这些类加载器之间的层次关系称为类加载器的双亲委派模型。除了顶层的启动类加载器外,其他的类加载器都有自己的父类加载器,通常不是通过继承关系来实现,而是采用组合关系来复用父加载器代码。

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

为什么要采用这种方式来加载类呢?试想一下,如果每个加载器都自己去加载类,前面提到不同类加载器加载的类并不相等,那么这会出现混乱。采用双亲委派模型能够保证Java程序的稳定运行,类加载器具备了优先级的关系。这样还能保证Java的核心库不会被恶意的污染,比如我们自己写一个java.lang.Object类,由于最终会交给启动类加载器加载,所以加载的类必然是Java提供的类库中的java.lang.Object类,而自己写的类是不会被加载的。

总结

本文从class字节码文件介绍开始,介绍了类加载的基本流程。我们写的Java源文件通过编译后将生成满足规范的字节码文件,类加载时根据字节码文件的描述在内存中进行结构化存储,加载过程判断类的各种信息是否符合规范,是否可以根据符号引用来定位到具体的类、方法、字段等,同时判断是否具有访问权限等。通过了解类的加载过程,能够更深入的理解Java程序执行的过程,包括父类与子类的执行顺序、类加载时如何进行查找等。