类的生命周期
类从被加载到虚拟机内存中开始到卸载出内存为止,它的整个生命周期可以简单概括为 7 个阶段:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)。其中,验证、准备和解析这三个阶段可以统称为连接(Linking)。
类加载过程
虚拟机加载 Class 文件的过程:加载→连接→初始化。连接过程:验证→准备→解析。
加载
类加载过程的第一步,主要完成下面 3 件事情:
- 通过全类名获取定义此类的二进制字节流
- 将字节流所代表的静态存储结构转换为方法区的运行时数据结构
- 在内存中生成一个代表该类的
Class对象,作为方法区这些数据的访问入口
验证
加载阶段和连接阶段的部分内容是较差进行的,加载阶段尚未结束,连接阶段就可能已经开始了。
四件事:
- 文件格式验证。验证字节流是否符合 Class 文件格式的规范,如常量池的常量是否有不支持的类型,是否以 0xCAFEBABE 开头,主次版本号是否在当前虚拟机的处理范围内。
- 元数据验证。对字节码描述的信息进行语义分析,以保证描述的信息符合 Java 语言规范的要求,如该类继承了不允许继承的类(final)。
- 字节码验证。最复杂的一个阶段,通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的,如保证任意时刻操作数栈和指令代码序列都能配合工作。
- 符号引用验证。确保解析动作正确执行。
准备
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段。需要注意:
- 仅对类变量进行内存分配,即静态变量,不包含实例变量。
- JDK7 之后,字符串常量池、静态变量等移动到堆中,即类变量在 Java 堆中分配内存。
- 类变量设置初始值时,设置为基本类型的初始值,除非加上 final 关键字,则直接被赋予值。如
static int value=111,准备阶段初始值为 0,初始化阶段再赋值 111;static final int value = 111,在准备阶段就直接赋值 111.
解析
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,就是得到类或字段、方法在内存中的指针或偏移量。解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用限定符 7 类符号引用。
符号引用就是一组符号来描述目标,可以是任何字面量。直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。在程序实际运行时,只有符号引用是不够的,执行方法时,系统需要明确知道这个方法所在的位置。Java 虚拟机为每个类都准备了一张方法表来存放类中所有的方法。当需要调用方法时,只需要知道这个方法在放发表的偏移量就可以调用该方法了。通过解析符号引用就可以直接转变为目标方法在类中方法表的位置,从而使得方法可以被调用。
初始化
初始化阶段是执行初始化方法 <clinit>() 方法(编译后自动生成的)的过程,这一步 JVM 才开始真正执行类中定义的 Java 程序代码(字节码)。
<clinit>() 方法时带锁线程安全的,以此来保证 JVM 在多线程环境中的安全性,但也会使得多线程环境下进行类初始化的话可能引起多个进程阻塞,并且难以发现。
类/接口初始化的 6 种情况:
- 遇到
new(创建类实例) 、getstatic(访问类静态变量) 、putstatic(给静态变量赋值) 或invokestatic(调用类静态方法) 这 4 条直接码指令时,初始化类。 - 使用
java.lang.reflect包的方法对类进行反射调用时如Class.forname("..."),newInstance()等等。 - 初始化子类时,父类未初始化,则先初始化父类。
- 当虚拟机启动时,用户需要定义一个要执行的主类(包含 main 方法的类),虚拟机初始化该类。
- 使用
MethodHandle和VarHandle轻量级的反射调用机制,需要使用findStaticVarHandle来初始化要调用的类。 - 接口中定义了 JDK8 新引入的默认方法,实现类初始化前,要先初始化接口。
卸载
卸载,即类的 Class 对象被 GC。卸载类需满足 3 个条件:
- 该类的所有实例对象都被 GC,堆中不存在该类的实例对象。
- 该类没有在其他地方被引用。
- 该类的类加载器的实例被 GC。
因此,JVM 生命周期内,JVM 自带的类加载器加载的类是不会被卸载的,自定义的类加载器可能被卸载。
类加载器
一个非数组类的加载阶段是可控性最强的阶段,可以自定义类加载器去控制字节流的获取方式。数组类型不通过类加载器创建,由 Java 虚拟机直接创建。所有的类都有类加载器加载,加载的作用就是将.class 文件加载到内存中。
JVM 内置的类加载器
JVM 内置了三个 ClassLoader,其中 BootstrapClassLoader 类加载器由 C++实现,其余由 Java 实现并继承自 java.lang.ClassLoader :
BootstrapClassLoader:启动类加载器,最顶层的加载类,C++实现,负责加载%JAVA_HOME%/lib目录下的 jar 包和类或者被-Xbootclasspath参数指定的路径中的所有类。ExtensionClassLoader:扩展类加载器,主要负责加载%JRE_HOME%/lib/ext目录下的 jar 包和类,或被java.ext.dirs系统变量所指定的路径下的 jar 包。AppClassLoader:应用程序类加载器,面向用户的加载器,负责加载当前应用 classpath 下的 jar 包和类。
双亲委派模型简介
每个类都有一个对应的类加载器,系统中的 ClassLoader 在协同时默认使用双亲委派模型。
在类加载时,判断当前类是否被加载过,已被加载的类直接返回,否则才会尝试加载。
加载时,首先会先把请求委派给父类加载器的 loadClass() 处理,因此所有的请求都会被传到顶层的启动类加载器处理。当父类加载器无法处理时,才会由自己处理。当父类加载器为 null 时,会使用启动类加载器作为父类加载器。
用户自定义类加载 - 应用程序类加载器 - 扩展类加载器 - 启动类加载器。
自底向上检查类是否被加载,自顶向下尝试加载类。
双亲委派模型的好处
双亲委派模型保证了 Java 程序的稳定运行,避免类的重复加载。同时,这保证了 Java 的核心 API 不被篡改。
JVM 区分不同类的方式不仅仅根据类名,相同的类文件被不同的类加载器加载产生的是两个不同的类。
自定义类加载器与不使用双亲委派模型
自定义类加载器
继承 ClassLoader 类,并重写 findClass()方法。无法被父类加载器加载的类会通过这个方法被加载。
不使用双亲委派模型
重写 loadClass()方法。
打破双亲委派机制的例子,为什么打破
- JNDI 通过引入线程上下文类加载器,可以在 Thread. setContextClassLoader 方法设置,默认是应用程序类加载器,来加载 SPI 的代码。有了线程上下文类加载器,就可以完成父类加载器请求子类加载器完成类加载的行为。打破的原因,是为了 JNDI 服务的类加载器是启动器类加载,为了完成高级类加载器请求子类加载器(即上文中的线程上下文加载器)加载类。
- Tomcat 中可以部署多个 web 项目,为了保证每个 web 项目互相独立,所以不能都由 AppClassLoader 加载,所以自定义了类加载器 WebappClassLoader,WebappClassLoader 继承自 URLClassLoader,重写了 findClass 和 loadClass,并且 WebappClassLoader 的父类加载器设置为 AppClassLoader。
- WebappClassLoader. loadClass 中会先在缓存中查看类是否加载过,没有加载,就交给 ExtClassLoader,ExtClassLoader 再交给 BootstrapClassLoader 加载;都加载不了,才自己加载;自己也加载不了,就遵循原始的双亲委派,交由 AppClassLoader 递归加载。
- Tomcat,应用的类加载器优先自行加载应用目录下的 class,并不是先委派给父加载器,加载不了才委派给父加载器。tomcat 之所以造了一堆自己的 classloader,大致是出于下面三类目的:
- 对于各个
webapp中的class和lib,需要相互隔离,不能出现一个应用中加载的类库会影响另一个应用的情况,而对于许多应用,需要有共享的 lib 以便不浪费资源。 - 与
jvm一样的安全性问题。使用单独的classloader去装载tomcat自身的类库,以免其他恶意或无意的破坏; - 热部署。
- 对于各个
- OSGi,实现模块化热部署,为每个模块都自定义了类加载器,需要更换模块时,模块与类加载器一起更换。其类加载的过程中,有平级的类加载器加载行为。打破的原因是为了实现模块热替换。
- JDK 9,Extension ClassLoader 被 Platform ClassLoader 取代,当平台及应用程序类加载器收到类加载请求,在委派给父加载器加载前,要先判断该类是否能够归属到某一个系统模块中,如果可以找到这样的归属关系,就要优先委派给负责那个模块的加载器完成加载。打破的原因,是为了添加模块化的特性。