JVM(六)-类加载
参考文献
- 深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)
- JVM 基础 - Java 类加载机制
虚拟机类加载机制
- Java虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验,转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这个过程被称作虚拟机的类加载机制.
- 在Java语言中,类型的加载,连接和初始化过程都是在程序运行期间完成的.
类的生命周期
- 一个类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将经历**加载(Loading),验证(Verification),准备(Preparation),解析(Resolution),初始化(Initialization),使用(Using)和卸载(Unloading)**七个阶段.
- 其中验证,准备,解析三个阶段被统称为连接(Linking).
- 其中前五个部分(加载,验证,这边,解析,初始化)统称为类加载.
类加载的过程
Loading
加载
- 加载阶段是整个类加载(Class Loading)过程的一个阶段,在加载阶段,Java虚拟机需要完成以下三件事情:
- 通过类的全限定名来获取定义此类的二进制字节流.
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构.
- 在堆内存中生成一个代表这个类的
java.lang.Class
对象,用来封装类在方法区内的数据结构,并向外提供了访问方法区内数据结构的接口..getClass()
- 在类加载后,class 类文件中的常量池信息以及其它数据会被保存到 JVM 内存的方法区中.
加载类的方式
- 最常见的方式: 本地文件系统中加载,从jar等归档文件中加载.
- 动态的方式: 将java源文件动态编译为class
- 其他方式: 网络下载,从专有数据库中加载等等.
Verification
验证
- 验证是连接阶段的第一步,这一阶段的目的是确保 Class 文件的字节流中包含的信息符合<<Java 虚拟机规范>>的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全.
- 从整体上看,验证阶段大致会完成下面四个阶段的校验动作:
- 文件格式验证
- 元数据验证
- 字节码验证
- 符号引用验证
文件格式验证
- 第一阶段要验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理.这一阶段可能包括下面这些验证点:
- 是否以魔数
0xCAFEBABE
开头; - 主次版本号是否在当前Java虚拟机接受范围之内;
- 常量池的常量中是否有不被支持从常量类型(检查常量tag标志);
- 指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量
CONSTANT_Utf8_info
型的常量中是否有不符合 UTF-8 编码的数据.- Class 文件中各个部分及文件本身是否有被删除的或附加的其他信息
- 是否以魔数
- 主要目的是保证输入的字节流能正确地解析并存储于方法区之内,格式上符合描述一个Java类型信息的要求.
- 这阶段的验证是基于二进 制字节流进行的,只有通过了这个阶段的验证之后,这段字节流才被允许进入 Java 虚拟机内存的方法区中进行存储,所以后面的三个验证阶段全部是基于方法区的存储结构上进行的,不会再直接读取、操作字节流了.
元数据验证
- 第二阶段是对字节码描述的信息进行语义分析,以保证其描述的信息符合《Java虚拟机规范》的要求,这个阶段可能包括的验证点如下:
- 这个类是否有父类(除了 java.lang.Object 之外,所有的类都应当有父类).
- 这个类的父类是否继承了不允许被继承的类(被 final 修饰的类).
- 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法.
- 类中的字段、方法是否与父类产生矛盾(例如覆盖了父类的 final 字段,或者出现
不符合规则的方法重载,例如方法参数都一致,但返回值类型却不同等).
- 第二阶段的主要目的是对类的元数据信息进行语义校验,保证不存在与《Java虚拟机规范》定义相悖的元数据信息.
字节码验证
- 第三阶段是整个验证过程中最复杂的一个阶段,主要目的是通过数据流分析和控制流分析,确定程序语义是合法的、符合逻辑的.在第二阶段对元数据信息中的数据类型校验完毕以后,这阶段就要对类的方法体(Class 文件中的 Code 属性)进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的行为.
符号引用验证
- 最后一个阶段的校验行为发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段——解析阶段中发生.符号引用验证可以看作是对类自身以外(常量池中的各种符号引用)的各类信息进行匹配性校验,通俗来说就是,该类是否缺少或者被禁止访问它依赖的某些外部类、方法、字段等资源.
- 符号引用验证的主要目的是确保解析行为能正常执行,如果无法通过符号引用验证,Java 虚拟机将会抛出一个
java.lang.IncompatibleClassChangeError
的子类异常,典型的如:java.lang.IllegalAccessError
、java.lang.NoSuchFieldError
、
java.lang.NoSuchMethodError
等.
Preparation
准备
-
然后进入准备阶段,这个阶段将会为类的静态变量分配内存,并将其初始化为标准默认值(比如
null
或者0 值
等),这些内存都将在方法区中分配。. -
注意:准备阶段并未执行任何 Java 代码.
-
对于该阶段有以下几点需要注意:
-
这时候进行内存分配的仅包括类变量(
static
),而不包括实例变量,实例变量会在对象实例化时随着对象一块分配在Java堆中. -
这里所设置的初始值通常情况下是数据类型默认的零值(如
0
、0L
、null
、false
等),而不是被在Java代码中被显式地赋予的值. -
假设一个类变量的定义为:
public static int value = 3
;那么变量value在准备阶段过后的初始值为0
,而不是3
,因为这时候尚未开始执行任何Java方法,而把value赋值为3的put static
指令是在程序编译后,存放于类构造器<clinit>()
方法之中的,所以把value赋值为3的动作将在初始化阶段才会执行.
-
Resolution
解析
-
然后进入可选的解析符号引用阶段. 也就是解析常量池,主要有以下四种:
- 类或接口的解析
- 字段解析
- 类方法解析
- 接口方法解析
-
解析阶段就需要将符号引用解析并链接为直接引用(相当于指向实际对象).如果有了直接引用,那引用的目标必定在堆中存在.直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄.
- 符号引用: 以一组无歧义的符号来描述所引用的目标,与虚拟机的实现无关.
-
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符类符号引用进行.
-
加载一个
class
时, 需要加载所有的super
类和super
接口. -
解析过程保证了相互引用的完整性,把继承与组合推进到运行时.
与该阶段相关的异常
java.lang.NoSuchFieldError
根据继承关系从下往上,找不到相关字段时的报错.java.lang.IllegalAccessError
字段或者方法,访问权限不具备时的错误.java.lang.NoSuchMethodError
找不到相关方法时的错误.
Initialization
类初始化
-
JVM 规范明确规定, 必须在类的首次“主动使用”时才能执行类初始化.到达了初始化这个阶段,则说明这个类要被进行使用了.类加载并不代表这个类就要被初始化.就像把知识的索引先装进脑海中,只有等到要用的时候才准备拿出来,而不是一股脑的全部拿出来.
-
初始化的过程包括执行: static静态变量赋值语句,static静态代码块,类构造器方法.
-
类初始化阶段是类加载过程的最后阶段,在这个阶段中,JVM 首先将执行构造器
<clinit>
方法,编译器会在将 .java 文件编译成 .class 文件时,收集所有类初始化代码,包括静态变量赋值语句、静态代码块、静态方法,收集在一起成为<clinit>()
方法. -
JVM会保证
<clinit>()
方法的线程安全,保证同一时间只有一个线程执行. -
JVM在初始化执行代码时,如果实例化一个新对象,会调用
<init>
方法对实例变量进行初始化,并执行对应的构造方法内的代码.
类的初始化的触发时机
-
当虚拟机启动时,初始化用户指定的主类,就是启动执行的
main
方法所在的类; -
创建类实例: 当遇到用以新建目标类实例的
new
指令时,初始化new
指令的目标类,就是new
一个类的时候要初始化; -
当遇到调用静态方法的指令时,初始化该静态方法所在的类;
-
当遇到访问静态字段的指令时,初始化该静态字段所在的类;
-
如果类还没有加载和连接,就先加载和连接.如果类存在父类,且父类没有初始化,就先初始化父类;
- 子类初始化时会首先调用父类的
<clinit>()
方法,再执行子类的<clinit>()
方法
- 子类初始化时会首先调用父类的
-
如果一个接口定义了
default
方法,那么直接实现或者间接实现该接口的类的初始化,会触发该接口的初始化; -
反射某个类: 使用反射 API 对某个类进行反射调用时,初始化这个类,其实跟前面一样,反射调用要么是已经有实例了,要么是静态方法,都需要初始化;
-
当初次调用
MethodHandle
实例时,初始化该MethodHandle
指向的方法所在的类.
不会执行类初始化
-
通过子类引用父类的静态字段,只会触发父类的初始化,而不会触发子类的初始化.
-
定义对象数组,不会触发该类的初始化.
-
常量在编译期间会存入调用类的常量池中,本质上并没有直接引用定义常量的类,不会触发定义常量所在的类.
-
通过类名获取
Class
对象,不会触发类的初始化,Hello.class 不会让 Hello 类初始化. -
通过
Class.forName
加载指定类时,如果指定参数initialize
为false
时,也不会触发类初始化,其实这个参数是告诉虚拟机,是否要对类进行初始化.Class.forName(“jvm.Hello”)
默认会加载 Hello 类. -
通过
ClassLoader
默认的loadClass
方法,也不会触发初始化动作(加载了,但是不初始化). -
关于接口:
- 初始化一个类的时候,并不会先初始化它实现的接口;
- 初始化一个接口时,并不会初始化它的父接口;
类的卸载
- 当代表一个类的Class对象不在被引用,那么Class对象的生命周期就结束了,对应的在方法区中的数据也会被卸载.
- JVM自带的类加载器装载的类,是不会卸载的,由用户自定义的类加载器加载的类是可以卸载的.
即时编译
- 初始化完成后,类在调用执行过程中,执行引擎会把字节码转为机器码,然后在操作系统中才能执行.在字节码转换为机器码的过程中,虚拟机中还存在着一道编译,那就是即时编译.
- 最初,虚拟机中的字节码是由解释器( Interpreter )完成编译的,当虚拟机发现某个方法或代码块的运行特别频繁的时候,就会把这些代码认定为“热点代码”.
- 为了提高热点代码的执行效率,在运行时,即时编译器(JIT)会把这些代码编译成与本地平台相关的机器码,并进行各层次的优化,然后保存到内存中.
类加载机制
类加载器
- 类加载器作用是负责加载,验证,准备,解析,初始化这个五个步骤
- 任意一个类都需要其加载器和类本身来确定类在JVM的唯一性;每个类加载器都有自己的类名称空间,同一个类class由不同的加载器加载,则被JVM判断为不同的类.
类加载器分类
-
启动类加载器(Bootstrap ClassLoader)
- 它用来加载 Java 的核心类,是用原生 C++ 代码来实现的,并不继承自
java.lang.ClassLoader
(负责加载JDK中jre/lib/rt.jar
里所有的class).它可以看做是 JVM 自带的,我们再代码层面无法直接获取到启动类加载器的引用,所以不允许直接操作它, 如果打印出来就是个null
.举例来说,java.lang.String
是由启动类加载器加载的,所以String.class.getClassLoader()
就会返回 null.但是后面可以看到可以通过命令行参数影响它加载什么.
- 它用来加载 Java 的核心类,是用原生 C++ 代码来实现的,并不继承自
-
扩展类加载器(Extension ClassLoader)
- 它负责加载
jre/lib/ext
或者由java.ext.dirs
系统属性指定的目录中的 JAR 包的类,代码里直接获取它的父类加载器为null
(因为无法拿到启动类加载器).
- 它负责加载
-
应用程序类加载器(Application ClassLoader)
- 它负责在 JVM 启动时加载来自 Java 命令的
-classpath
或者-cp
选项、java.class.path
系统属性指定的 jar 包和类路径.在应用程序代码里可以通过ClassLoader
的静态方法getSystemClassLoader()
来获取应用类加载器.如果没有特别指定,则在没有使用自定义类加载器情况下,用户自定义的类都由此加载器加载.
- 它负责在 JVM 启动时加载来自 Java 命令的
-
自定义类加载器(Custom ClassLoader)
- 如果用户自定义了类加载器,则自定义类加载器都以应用类加载器作为父加载器.应用类加载器的父类加载器为扩展类加载器.这些类加载器是有层次关系的,启动加载器又叫根加载器,是扩展加载器的父加载器,但是直接从
ExClassLoader
里拿不到它的引用,同样会返回 null.
- 如果用户自定义了类加载器,则自定义类加载器都以应用类加载器作为父加载器.应用类加载器的父类加载器为扩展类加载器.这些类加载器是有层次关系的,启动加载器又叫根加载器,是扩展加载器的父加载器,但是直接从
-
类加载机制有三个特点:
-
双亲委托:当一个自定义类加载器需要加载一个类,比如
java.lang.String
,它很懒,不会一上来就直接试图加载它,而是先委托自己的父加载器去加载,父加载器如果发现自己还有父加载器,会一直往前找,这样只要上级加载器,比如启动类加载器已经加载了某个类比如java.lang.String
,所有的子加载器都不需要自己加载了.如果几个类加载器都没有加载到指定名称的类,那么会抛出ClassNotFountException
异常. -
负责依赖:如果一个加载器在加载某个类的时候,发现这个类依赖于另外几个类或接口,也会去尝试加载这些依赖项.
-
缓存加载:为了提升加载效率,消除重复加载,一旦某个类被一个类加载器加载,那么它会缓存这个加载结果,不会重复加载.
-
双亲委派模型
-
JVM
中的ClassLoader
通常采用双亲委派模型,要求除了启动类加载器外,其余的类加载器应该有自己的父级加载器.这里的父子关系是组合而不是基础,工作过程如下:-
当
AppClassLoader
加载一个class
时,它首先不会自己去尝试加载这个类,而是把类加载请求委派给父类加载器ExtClassLoader
去完成. -
当
ExtClassLoader
加载一个class
时,它首先也不会自己去尝试加载这个类,而是把类加载请求委派给BootStrapClassLoader
去完成. -
如果
BootStrapClassLoader
加载失败(例如在$JAVA_HOME/jre/lib
里未查找到该class)
,会使用ExtClassLoader
来尝试加载; -
若
ExtClassLoader
也加载失败,则会使用AppClassLoader
来加载,如果AppClassLoader
也加载失败,则会报出异常ClassNotFoundException
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// 首先判断该类型是否已经被加载
// First, check if the class has already been loaded
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
// 如果没有被加载,就委托给父类加载或者委派给启动类加载器加载
try {
if (parent != null) {
// 如果存在父类加载器,就委派给父类加载器加载
c = parent.loadClass(name, false);
} else {
// 如果不存在父类加载器,就检查是否由启动类加载器加载类,通过调用本地方法native Class<?> findBootstrapClass(String name)
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}
if (c == null) {
// If still not found, then invoke findClass in order
// to find the class.
long t1 = System.nanoTime();
// 如果父类加载器和启动类加载器都不能完成加载任务,才调用自身的加载功能
c = findClass(name);
// this is the defining class loader; record the stats
PerfCounter.getParentDelegationTime().addTime(t1 - t0);
PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
} -
-
不同的类加载器加载同一个class文件会导致出现两个类.而java给出解决方法是下层的加载器加委托上级的加载器去加载类,如果父类无法加载(在自己负责的目录找不到对应的类),而交还下层类加载器去加载.
双亲委派模型说明
-
双亲委派模型好处:
- 通过委派的方式,可以避免类的重复加载,当父加载器已经加载过某一个类时,子加载器就不会再重新加载这个类.
- 通过双亲委派的方式,还保证了安全性.因为
Bootstrap ClassLoader
在加载的时候,只会加载JAVA_HOME
中的jar包里面的类,如java.lang.Integer
,那么这个类是不会被随意替换的.
-
打破双亲委派模型:
- 自定义类加载器,实现双亲委派的代码在
java.lang.ClassLoader
的loadClass()
方法中,如果自定义类加载器,推荐覆盖实现findClass()
方法 - 使用线程上下文类加载器;
- 自定义类加载器,实现双亲委派的代码在