第7章 虚拟机类加载机制
目录
7.1 概述
虚拟机如何加载Class文件?
Class文件中的信息进入到虚拟机之后会发生什么变化?
虚拟机把描述类的Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的java类型。
Java中,类型的加载、连接和初始化都是在运行期完成的。
后续讨论的两个约定:这里的类不特别之处的话包含class和interface;这里的class文件不特指文件,而是一串二进制的字节流,以任何形式存在都可以。
7.2 类加载的时机
类加载的生命周期:加载、验证、准备、解析、初始化、使用和卸载。
其中验证、准备、解析统称为连接Linking。
加载、验证、准备、初始化和卸载是有先后顺序的,但是解析在初始化前面、后面都可以。比如解析可以在初始化之后,这是为了支持动态绑定。
Java虚拟机规范只规定了初始化在哪些场景下必须立即执行(加载、验证、准备自然在此之前也要开始),有且仅有5种情况,详情略。
7.3 类加载的过程
7.3.1 加载
注意加载和类加载的区别。加载是类加载中的一个过程。
加载要完成的事情:
- 通过一个类的全限定名来获取定义此类的二进制字节流。
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据结构的访问入口。
第一步中java虚拟机规范并没有具体规定从哪里获取,如何获取此类的二进制字节流。所以一个非数组类的加载阶段,可以使用系统提供的引导类加载器完成,也可通过用户自定义的类加载器完成(自定义类加载器,并重写loadClass方法)。
对数组类而言,它本身是由java虚拟机直接创建的。但是它的元素仍然是靠类加载器去创建,因此和上面类似。
加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机要求的格式存储在方法区中。然后在内存中实例化一个java.lang.Class对象,这个对象将作为程序访问方法区中的这些数据类型的入口。对HotSpot·虚拟机,Class对象存储在方法区中。
加载和连接阶段的部分内容是交叉进行的,但是开始时间仍保持固定的先后顺序。
7.3.2 验证
验证是连接阶段的第一步,这一步是为了保证Class文件中的字节流符合当前虚拟机要求,且不会危害虚拟机自身安全。
- 文件格式验证
- 元数据验证
- 字节码验证
- 符号引用验证
7.3.3 准备
准备是连接阶段的第二部,这一步是正式为类变量分配内存并设置类变量初始值的阶段。
注意,这里是类变量,不是实例变量。实例变量将会在对象实例化时随着对象一起分配在java堆。
注意,这里说的初始值是数据类型的零值,如果一个类变量被定义为public static int value=1。
那么准备阶段过后,value的值为0,将value赋值为1的操作将在初始化阶段进行。但如果定义的是public static final int value=1,那么准备阶段过后,value的值为1。
7.3.4 解析
解析是连接阶段的第三步,这一步是将常量池中的符号引用替换为直接引用的过程。
符号引用:用一组符号来描述引用的目标,引用的目标不一定已经加载到内存中。符号引用和虚拟机实现的内存布局无关,它是明确定义在java虚拟机规范的class文件格式中。
直接引用:引用的目标一定已经加载到内存中,它和虚拟机实现的内存布局有关。
- 类和接口的解析
- 字段解析
- 类方法解析
- 接口方法解析
7.3.5 初始化
到了初始化阶段,才真正开始执行类中定义的java程序代码。
初始化阶段是执行类构造器<clinit>()方法的过程。<clinit>()方法是由编译器自动收集类中所有类变量的赋值动作和静态语句块中语句合并生成的(如果没有则不生成)。
虚拟机会保证在子类的<clinit>()方法执行前,父类的<clinit>()方法已经执行完毕。接口中没有static语句块,但是仍有变量初始化的赋值操作,所以也有<clinit>()方法。但与类不同的是,它不需要首先执行父接口的<clinit>()方法,而是只有调用父接口中定义的变量时,父接口才会初始化。
虚拟机会保证一个类的<clinit>()方法在多线程环境下被正确的加锁、同步。
7.4 类加载器
通过一个类的全限定名来获取描述此类的二进制字节流,这个过程可以让应用程序自己选择如何去获取,这个动作叫做类加载器。
7.4.1 类与类加载器
对于任何一个类,都需要由加载它的类加载器和这个类本身一起确立其在java虚拟机中的唯一性。否则,即使来自于同一个class文件并被同一个虚拟机加载,只要加载他们的类加载器不同,那么这两个类就必定不相等。
7.4.2 双亲委派机制
从虚拟机角度划分,类加载器可分为:
- 启动类加载器Bootstrap ClassLoader,是虚拟机的一部分,使用c++实现。
- 其他类加载器,独立于虚拟机外部,由java实现。继承自抽象类java.lang.ClassLoader。
从开发者角度划分,类加载器可分为:
- 启动类加载器Bootstrap ClassLoader,负责加载存放在<JAVA_HOME>\lib目录下或者-Xbootclasspath指定路径下,且被虚拟机识别的类库。
- 扩展类加载器Extension ClassLoader,负责加载存放在<JAVA_HOME>\lib\ext目录下或者java.ext.dirs指定路径下的类库。
- 应用程序类加载器/系统类加载器Application ClassLoader,负责加载用户类路径指定下的类库。一般是程序中默认的类加载器,也是方法getSystemClassLoader()的返回值。
如有必要,还可以定义自己的类加载器。这些类加载器的关系如图所示。
//缺图。
类加载器之间的父子关系不是通过继承实现,而是通过组合composition来复用父加载器的代码。
双亲委派机制:如果一个类加载器收到了类加载的请求,它首先不会自己尝试加载,而是把这个请求委派给父类加载器完成。层层往上,最终传递到顶层的启动类加载器中。只有当父加载器反馈自己无法完成这个加载请求时,子加载器才会尝试自己去加载。
双亲委派机制对于保证java程序的稳定运作很重要,实现可以查看java.lang.ClassLoader类的loadClass()方法。
7.4.3 破坏双亲委派机制
双亲委派机制并不是一个强制性的约束模型,而是java设计者推荐给开发者的类加载器实现方式。Java中大部分都遵循这个模型,但也有例外。
历史上有过3次破坏情况。
第一次破坏是由于双亲委派机制是JDK1.2引入,而java.lang.ClassLoader在JDK1.0就存在了,所以需要向前兼容,添加了方法findClass()。然后再在双亲委派机制中,将双亲委派的逻辑放在java.lang.ClassLoader的loadClass()方法中,如果父类加载器加载失败,则会调用自己findClass()来进行加载。
第二次被破坏是由于模型自身缺陷造成。如果基础类又要调用回用户的代码,那该怎么办?比如JNDI服务,它的代码由启动类加载器去加载,但是JNDI需要对资源进行集中管理与查找,因此要调用独立厂商实现并部署在应用程序ClassPath下的JNDI接口提供者SPI的代码。但是启动类加载器并不认识这些代码。
因为java设计团队引入了线程上下文加载器Thead Context ClassLoader。
可以通过Thread类的setContextClassLoader()设置。如果创建线程时还没设置,会从父线程中继承一个。如果在应用程序的全局范围内都没有设置的话,默认为应用程序类加载器。
有了线程上下文加载器,JNDI服务就使用它去加载所需要的SPI代码,也就是父类加载器请求子类加载器去完成类加载的操作。
第三次被破坏是由于对动态性的追求,比如代码热替换、模块热部署等。目前OSGi已经成为了业界事实上的java模块化标准,它的关键在于自定义的类加载器机制。每一个bundle都有一个自己的类加载器,当需要更换一个bundle时,就把bundle连同类加载器一起换掉来实现代码热替换等。在OSGi场景下,类加载器不再是双亲委派机制的树状结构,而是复杂的网状结构,比如可能存在平级的类加载器委托。