摘要:本文主要学习了如何将类加载到虚拟机。
环境
Windows 10 企业版 LTSC 21H2
Java 1.8
1 类生命周期
类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括加载、连接、初始化、使用和卸载五个阶段。
如图:
其中,连接包括验证、准备、解析三个阶段。解析阶段在某些情况下可以在初始化后再开始,这是为了支持运行时绑定。
这几个阶段按顺序开始,相互交叉混合进行,通常在一个阶段执行的过程中调用或激活另一个阶段。
1.1 加载阶段
1.1.1 做什么
类的加载就是将class文件中的二进制数据读取到运行时内存中,将class文件中类的信息放在方法区中,然后在堆中创建一个Class对象,用于封装方法区中的数据结构。
在进行加载的时候,虚拟机需要完成三件事:
- 通过类的全类名获取该类的二进制字节流。
- 将二进制字节流所代表的静态结构转化为方法区的运行时数据结构。
- 在内存中创建一个代表该类的Class对象,作为方法区这个类的各种数据的访问入口。
1.1.2 何时做
虚拟机规范允许某个类在预料被使用的时候预先执行类的加载,不需要等到某个类首次被使用的时候才进行类的加载。
如果在进行类的加载时遇到class文件缺失,只有当使用到了该类的时候,类加载器才会报告错误,如果该类并没有被使用,那么类加载器是不会报告错误的。
1.1.3 文件的来源
加载的class文件有种来源:
- 从本地硬盘直接加载,最为普通的场景。
- 通过网络下载class文件加载,常用于Applet应用程序。
- 从压缩文件中提取class文件加载,常用于Jar包和War包。
- 运行时计算生成,常用于动态代理。
- 由其他文件生成,常用于JSP应用。
- 从数据库中提取加载,比较少见。
- 将源文件编译成class文件加载,常用于防止反编译的保护措施。
1.1.4 补充说明
相对于类加载的其他阶段而言,加载阶段(准确地说,是加载阶段获取类的二进制字节流的动作)是可控性最强的阶段,因为开发人员既可以使用系统提供的类加载器来完成加载,也可以自定义的类加载器来完成加载。
1.2 链接阶段
1.2.1 验证阶段
确保class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。
验证阶段大致会完成四个阶段的检验动作:
- 文件格式验证:验证字节流是否符合class文件格式的规范。比如是否以0xCAFEBABE开头、主次版本号是否在当前虚拟机的处理范围之内、常量池中的常量是否有不被支持的类型。
- 元数据验证:对字节码描述的信息进行分析,以保证其描述的信息符合Java语言规范的要求。比如这个类是否有除了Object之外的超类。
- 字节码验证:通过数据流和控制流分析,确定程序语义是安全法的、符合逻辑的,不会导致虚拟机崩溃。
- 符号引用验证:确保解析动作能正确执行。
验证阶段是非常重要的,但不是必须的,它对程序运行期没有影响,可以通过参数关闭验证以缩短虚拟机类加载的时间。
1.2.2 准备阶段
为类变量(也就是静态成员变量,不包括实例变量)分配内存并设置初始值的阶段,这些变量所使用的内存都在方法区中进行分配。
这个阶段只是为静态成员变量设置初始值,并不是赋值,赋值是在初始化阶段的cinit()
方法中完成的。
如果静态成员变量同时是常量,常量在编译时分配初始值,如果赋值不涉及方法调用(包括构造方法调用),会在这个阶段进行赋值。
赋值:
- 非static类型的变量,在初始化阶段的
init()
方法中赋值。 - static类型的变量,在准备阶段设置初始值,在初始化阶段的
cinit()
方法中赋值。 - static类型的变量,并且是final类型的变量,在编译阶段设置初始值。如果赋值不涉及方法调用,在准备阶段赋值,否则在初始化阶段的
cinit()
方法中赋值。
示例:
1 | // 在准备阶段设置初始值为0,在初始化阶段的clinit方法中赋值 |
1.2.3 解析阶段
虚拟机将常量池内的符号引用替换为直接引用,会把该类所引用的其他类全部加载进来,类中的引用方式包括继承、实现接口、域变量、方法定义、方法中定义的本地变量等等。
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符这七类符号引用进行。
引用:
- 符号引用:在编译文件时,并不知道所引用的类的实际地址,因此只能使用符号引用来代替。
- 直接引用:直接指向目标的指针(指向方法区,Class对象)、指向相对偏移量(指向堆区,Class实例对象)或指向能间接定位到目标的句柄。
1.3 初始化阶段
1.3.1 做什么
为类的静态变量赋予正确的初始值,虚拟机负责对类进行初始化,主要对类变量进行初始化。
对类变量进行初始值设定有两种方式:
- 声明类变量时指定初始值。
- 使用静态代码块为类变量指定初始值。
换句话说,初始化阶段是执行cinit()
方法的过程。
在执行子类的cinit()
方法前,虚拟机会先执行父类的cinit()
方法。
1.3.2 线程安全
在执行cinit()
方法时,虚拟机会保证在多线程环境中正确加锁和同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的cinit()
方法。
如果一个类的cinit()
方法中有耗时的操作,可能会造成多线程阻塞,从而导致产生死锁,并且这种死锁是很难发现的,因为看起来它们并没有可用的锁信息。
赋值方法:
init()
方法是对象构造器方法,在new一个对象并调用该类的构造方法时才会执行,用于对非静态变量进行赋值。clinit()
方法是类构造器方法,在初始化阶段执行,用于对静态变量进行赋值。
示例:
1 | class X { |
1.3.3 何时做
只有当主动使用时才会进行初始化,类的主动使用包括以下六种:
- 创建类的实例,包括使用new的方式,也包括使用反射、克隆、序列化的方式,会触发初始化。
- 调用
Class.forName()
方法,将Class文件加载到内存并返回Class对象,会触发初始化。 - 访问某个类或接口的静态变量,或者对该静态变量赋值,会触发初始化。
- 调用类的静态方法,会触发初始化。
- 作为父类,初始化子类时,会触发初始化。
- 类中包含
main()
方法作为主类,虚拟机在启动时,会触发初始化 - JDK1.7开始提供的动态语言支持,涉及解析相关方法句柄对应的类,虚拟机在启动时,会触发初始化。
除此之外,其它所有引用类的方式都不会触发初始化,称为被动引用:
- 子类引用父类静态属性,不会触发初始化。
- 调用
loadClass()
方法,将Class文件加载到内存,不会触发初始化。 - 通过数组引用类,不会触发初始化。
- 引用类的常量,常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,不会触发初始化。
1.3.4 接口说明
接口中不能使用静态代码块,但是允许有静态变量初始化的赋值操作,因此接口和类一样也会生成静态构造方法。
接口在执行静态构造方法时,不需要加载父接口,也不需要在准备阶段执行父接口的静态构造方法,只有在使用了父接口的静态变量才会加载父接口,并执行父接口的静态构造方法。
1.4 使用阶段
调用成员变量或者成员方法执行业务逻辑。
1.5 卸载阶段
虚拟机在进行垃圾收集的时候卸载类。
2 类加载器
2.1 简介
类加载器是通过类的全类名来加载类的二进制字节流的代码模块,其主要作用是将class文件二进制数据放入方法区内,然后在堆内创建一个Class类型的对象。
使用Class对象封装类在方法区内的数据结构,向开发者提供了访问方法区内数据结构的接口。
2.2 唯一
对于任意一个类,都需要由类的类加载器和类本身共同确立其在虚拟机中的唯一性。
即使两个类来源于同一个Class文件,并且被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类也不相同。
这里的相同包括equals()
方法和isInstance()
方法的返回结果,也包括使用instanceof
关键字做对象所属关系判定等情况。
2.3 分类
从虚拟机的角度来看,只存在两种不同的类加载器:
- 启动类加载器:这个类加载器使用C++语言实现,是虚拟机自身的一部分。
- 其他类加载器:这些类加载器由Java语言实现,独立存在于虚拟机外部,继承自ClassLoader类。
站在开发人员的角度来看,可以将其他类加载器划分得更细致一些。
2.2.1 启动类加载器
BootstrapClassLoader类负责加载存放在JDK安装目录中的lib目录中的类库,或被-Xbootclasspath参数指定路径中的类库(比如rt.jar和java
开头的类)。
启动类加载器是无法被程序直接引用的,也就是说是无法直接获取的。
示例:
1 | public static void main(String[] args) { |
结果:
1 | null |
2.2.2 扩展类加载器
ExtensionClassLoader类由sun.misc.Launcher$ExtClassLoader实现,负责加载JDK安装目录中的ext目录中的类库,或者被java.ext.dirs系统变量指定路径中的类库(比如javax
开头的类)。
开发者可以直接使用扩展类加载器。
示例:
1 | public static void main(String[] args) { |
结果:
1 | sun.misc.Launcher$ExtClassLoader@4b67cf4d |
2.2.3 应用类加载器
ApplicationClassLoader类由sun.misc.Launcher$AppClassLoader实现,负责加载用户类路径(ClassPath)所指定的类。
开发者可以直接使用应用类加载器,如果应用程序中没有自定义过自己的类加载器,默认使用这个类加载器。
示例:
1 | public static void main(String[] args) { |
结果:
1 | sun.misc.Launcher$AppClassLoader@18b4aac2 |
2.2.4 自定义类加载器
如果上述虚拟机自带的类加载器不能满足需求,就需要自定义类加载器。
比如应用是通过网络来传输Java类的字节码,为保证安全性,这些字节码经过了加密处理,这时系统类加载器就无法对其进行加载,这样就需要自定义类加载器来实现。
自定义类加载器需要继承ClassLoader抽象类,并重写父类的findClass()
方法,在所有父类加载器无法加载的时候调用findClass()
方法加载类。
被加载的类:
1 | public class DemoBusiness { |
编译并生成class字节码文件:
1 | D:\>javac -encoding utf8 DemoBusiness.java |
使用自定义类加载器:
1 | public class Demo { |
结果:
1 | ClassLoader >>> DemoClassLoader@7ea987ac |
2.4 关系
类加载器的关系如图:
这里类加载器之间的父子关系一般不会以继承(Inheritance)关系来实现,而是都使用组合(Composition)关系来复用父加载器的代码。
2.5 机制
2.5.1 全盘负责机制
当一个类加载器负责加载某个Class时,该Class所依赖的和引用的其他Class也将由该类加载器负责载入,除非显示使用另外一个类加载器来载入。
重要性:
- 保证一致性:确保一个类及其所有依赖项都由同一个类加载器加载。
- 命名空间隔离:这是沙箱机制和模块化的基础。不同类加载器加载的类属于不同的命名空间,即使类名相同也被视为不同的类。
2.5.2 双亲委派机制
先让父类加载器加载该类,如果父类加载器之上还有加载器,则进一步向上委托,只有在父类加载器无法加载该类时才尝试自己加载该类。
重要性:
- 避免重复加载:确保一个类在JVM中只被加载一次,防止同一个类被不同加载器加载多次导致类型混乱和资源浪费。
- 保证核心库安全:保证程序安全稳定运行,防止核心类被随意篡改。
在某些特定场景下,类加载机制不遵循双亲委派的原则,而是由子加载器主动加载或选择特定的加载路径,破坏了双亲委派模型。
但是这种破坏并非设计缺陷,而是为满足特定需求而采取的必要手段:
在JDK1.2之前,尚未引入双亲委派模型,用户自定义类加载器需要重写loadClass()
方法,可能未遵循委派模型。
在JDK1.2之后,引入了双亲委派模型,用户自定义类加载器需要重写findClass()
方法,符合双亲委派模型。
总结:
- 如果想破坏双亲委派模型,就重写
loadClass()
方法。 - 如果想保持双亲委派模型,就重写
findClass()
方法。
在某些特殊场景中,接口由启动类加载器加载,实现类由应用类加载器加载,此时应当由应用类加载器负责接口的加载。
比如JNDI服务,接口由启动类加载器去加载,由独立厂商实现的接口提供者(SPI,Service Provider Interface)的代码属于实现类,启动类加载器无法加载用户自定义的实现类。
解决办法是使用线程上下文类加载器(Thread Context ClassLoader),支持设置和获取:
- 使用
Thread.setContextClassLoaser()
方法进行设置。 - 使用
Thread.currentThread().getContextClassLoader()
方法获取。
当启动类加载器加载核心类后,需要加载实现类时,启动类加载器尝试获取线程上下文类加载器加载实现类。如果创建线程时没有设置,将使用父线程的线程上下文类加载器,如果在全局范围内都没有设置,默认使用应用程序类加载器。
所有涉及SPI的加载动作基本上都采用这种逆向委派方式加载,例如JNDI和JDBC等。
Tomcat需要部署多个Web应用,每个应用可能依赖不同版本的类库。若遵循双亲委派,这些类会被父加载器加载并共享,导致版本冲突。
Tomcat使用WebAppClassLoader加载,失败后再委托SharedClassLoader加载,而非默认双亲委派。
这么做可以让每个Web应用的类由自己的WebAppClassLoader加载,实现类隔离,避免版本冲突。
为了追求程序的动态性,使用热替换(Hot Swap)和热部署(Hot Deployment)等技术,在不重启JVM的情况下更新类。
OSGi框架为每个模块创建独立的BundleClassLoader类加载器,当模块动态更新时,OSGi会销毁旧的类加载器,创建新的类加载器重新加载类,此时新类与旧类虽然全限定名相同,但属于不同类加载器加载的不同类,实现热部署。
这种模型完全打破了双亲委派的层级关系,类加载器之间可以平级委托甚至双向委托,灵活性极高。
2.5.3 缓存机制
加载过的Class会被缓存,当程序中需要使用某个Class时,类加载器先从缓存区寻找该Class,只有缓存区不存在,系统才会读取该类对应的二进制数据,并将其转换成Class对象,存入缓存区。
这就是为什么修改了Class后,必须重启虚拟机才会生效,这也是热部署需要解决的问题。
2.5.4 沙箱机制
沙箱机制是将代码限定在虚拟机特定的运行范围中,并且严格限制代码对本地系统资源访问,通过这样的措施来保证对代码的有效隔离,防止对本地系统造成破坏。
沙箱机制是对核心源代码的保护,在一定程度上可以保护程序安全,保护原生的JDK代码。
3 类加载顺序
当加载类时:
- 加载同时被static和final修饰的基本类型(包括String类型)的常量并赋值。在准备阶段完成。
- 加载父类的静态代码,包括静态代码块和静态变量,优先加载写在前面的代码。在初始化阶段完成。
- 加载类的静态代码,包括静态代码块和静态变量,优先加载写在前面的代码。在初始化阶段完成。
- 加载父类的成员属性,包括构造代码块和成员变量,优先加载写在前面的代码。在初始化阶段完成。
- 加载父类的构造器方法。在初始化阶段完成。
- 加载类的成员属性,包括构造代码块和成员变量,优先加载写在前面的代码。在初始化阶段完成。
- 加载类的构造器方法。在初始化阶段完成。
示例:
1 | public class Demo { |
结果:
1 | HumanOtherStatic 静态代码块 ... |
条