这里写目录标题
加载过程
(一)类加载过程
1.概述
ClassLoader 主要职责是负责将class文件加载到JVM中,ClassLoader是一个抽象的class,给定一个class的二进制文件,ClassLoader会尝试加载并且在JVM中生成构成这个类的各个数据结构,然后使其分布在JVM对应的内存区域中。
2.类加载过程简介
类加载分为三个阶段:
- 加载阶段
主要复杂查找并且加载类的二进制文件,其实就是class文件 - 连接阶段
- 验证
确保类文件的正确性 - 准备
为类的静态变量分配内存,并且初始化默认值 - 剖析
把类中的符号引用转换成直接引用
- 验证
- 初始化阶段
为类的静态变量赋予正确的初始值(代码编写阶段给定的值)
当jvm在我们通过java命令启动之后,其中包含的类非常多,不是每一个类都会进行加载,jvm对类的初始化是一个延迟的机制,当一个类在首次使用的时候才会被初始化,在同一个运行时包下,一个class只会被初始化一次。
3.类的主动使用和被动使用
jvm规范规定,每个类或者接口被java程序首次主动使用时,才会对其进行初始化
主动使用类的场景:
- 通过new关键字会导致类的初始化,这种初始化是比较常用的,它一定会导致类的加载并且最终初始化。
- 访问类的静态变量,包括读取和更新会导致类的初始化。
- 访问类的静态方法,也会导致类的初始化。
- 对某个类进行反射操作,会导致类的初始化。
- 初始化子类会导致父类的初始化。(如果只是通过子类使用父类的静态变量,则只会导致父类的初始化,子类不会被初始化。)
- 启动类:执行main函数所在的类会导致该类的初始化。
除了上诉的6中情况之外,其余的都被成为被动使用,不会导致类的加载和初始化。
容易混淆的例子:
- 构建某个类的数组时不会导致该类的初始化;
- 引用类的静态常量不会导致类的初始化;
4.类加载详解
4.1 类加载阶段
类加载阶段就是将class文件中的二进制数据读取到内存中,然后将该字节流所代表的的静态存储结构转换为方法区中运行时数据结构,并且在堆内存中生成一个该类的java.lang.Class对象,作为该访问方法区数据结构的入口。
类加载的最终产物就是堆内存中的class对象,对同一个ClassLoader来讲,不管某个类被加载了多少次,对应到堆内存中的class对象始终是同一个。
虚拟机规范中指出了类的加载是通过一个全限定名(包名+类名)来获取二进制数据流,但是并没有限定通过某种方式获得。
常见的就是二进制文件的形式,除此之外还有几种方式:
- 运行时动态生成
- 通过网络获取
- 通过读取zip文件获得类的二进制文件
- 将类的二进制数据存储到数据库中的BLOB字段类型中。
- 运行时生成class文件,并且动态加载。
4.2 类连接阶段
1 验证
验证阶段主要是目的是确保class文件中的字节流所包含的内容符合当前jvm的规范要求,并且不会出现危害jvm自身安全的diamante,当字节流的信息不符合要求时,则会抛出VerifyError的异常信息。
验证了什么信息:
- 验证文件格式
- 很多二进制文件中,文件头部都包含魔术因子,该因子决定了这个文件到底是什么类型,class文件的魔术因子是0xCAFEBABE。
- 主次版本号
jdk编译的版本是否在所能够处理的范围。 - 构成class文件的字节流是否存在残缺或者其他附属信息
主要看class的MD5指纹(每一个类在编译阶段经过MD5摘要算法之后,都会将结果一并附加给class字节流作为字节流的一部分) - 常量池中的常量是否存在不被支持的变量类型,比如int64
- 指向常量中的引用是否指到了不存在的常量或者该常量的烈性不被支持
- 其他信息
- 元数据的验证
元数据验证就是对class文件中的语义分析的过程,确保class字节流符合jvm规范的要求- 检查类是否存在父类,是否继承了某个接口,父类和接口是否合法,是否真是存在
- 检查该类是否继承了final修饰的类
- 检查该类是否位抽象类,如果不是抽象类,是否实现了父类的抽象方法或者接口中的所有方法
- 检查重载的合法性
- 其他语义验证
- 字节码验证
主要验证程序的控制流程,比如循环,分支等。 - 符号引用验证
类加载过程中,有些阶段是交叉进行的,比如在加载阶段尚未结束之前,连接阶段可能已经开始工作了,这样的好处是能够提高加载的整体效率,同样符号引用的验证,主要作用就是验证符号引用转换为直接引用时的合法性。
2 准备
当class的字节流通过了所有的验证过程之后,就开始为该对象的类变量,也就是静态变量,分配内存并且设置初始值,类变量的内存会被分配到方法区中,不同于实例变量会被分配到堆内存之中。
private static int a = 10;
private static final int b = 10;
a是静态变量,在准备阶段不是10,应该是先初始化为0
b 是由final修饰,应该是静态常量,常量的使用不会引起类的加载,当然也就不存在准备阶段,所以b一直是10;
当然严谨的来说,在b在编译阶段会直接赋予生成一个属性,赋值为10;
3 解析
在连接阶段中经历了验证、准备之后,就可以顺利进入到解析过程了,当然在解析中同样也会存在一些交叉验证的过程。
解析过程包括:
- 类接口解析
- 字段的解析
- 类方法的解析
- 接口方法的解析
4.1 类初始化阶段
类在初始化阶段最主要的一件事情就是执行()方法的过程 (clint是class initialize的缩写),在()方法中所有的类变量都会被赋予正确的值,也就是在程序编写的时候指定的值。
()方法在编译阶段生成的,也就是说它已经包含了在class文件中了,()中包含了所有类变量的赋值动作和静态语句块的执行代码,编译器收集的顺序是由执行语句在源文件中的出现顺序所决定的(()能够保证顺序性);
注意点:
- 静态语句块只能对静态变量进行赋值,但是不能对其进行访问。
- ()方法与类的构造函数有所不同,他不需要显示的调用父类的构造器,虚拟机会保证父类的()方法最先执行,因此父类的静态变量总是能够得到优先赋值。
虽然java编译器会帮助class生成()方法,但是该方法并不意味着总是会生成,比如某个类中既没有静态代码块,也没有静态变量,那么它就不会生成()方法的必要了,接口中同样如此。
()方法虽然真实存在,但是它只能被虚拟机执行,在主动使用触发了类的初始化之后就会调用这个方法,如果有多个线程同时访问这个方法,运行结果如何呢?
举个例子:
package com.spring.zcl.study.springbootstudy.classLoaders;
import java.util.stream.IntStream;
/**
* @Author: zcl
* @Date: 2022-01-29 16:40
*/
public class StaticClassInit {
static {
System.out.println("StaticClassInit static code init!");
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
IntStream.range(0, 5).forEach(i -> new Thread(StaticClassInit::new));
System.out.println("main process run over!");
}
}
多线程运行创建,看看运行效果
StaticClassInit static code init!
main process run ove
我们看一看到StaticClassInit static code init!只执行了一遍,说明在多线程中,类中的静态代码块只会初始化一次,也就是代表JVM保证了()方法在多线程的执行环境下的同步语义。
(二)JVM类加载器
1.JVM内置三大类加载器
不同的类加载器负责将不同的类加载到JVM内存之中,并且他们之间严格遵守着父委托的机制。
1.1 根类加载器
根加载器又称为Bootstrap类加载器,该类加载器是最为顶层的加载器,其没有任何父加载器,它是C++编写的,主要负责虚拟机核心类库的加载,比如整个java.lang包都是由根加载器所加载的,可以通过-Xbootclasspath来指定根加载器的路径,也可以通过系统属性来得知当前JVM的跟家在其都加载了那些资源。
package com.spring.zcl.study.springbootstudy.classLoaders;
/**
* @Author: zcl
* @Date: 2022-01-29 16:53
*/
public class JvmClassLoader {
public static void main(String[] args) {
System.out.println("Bootstrap:" + String.class.getClassLoader());
System.out.println(System.class.getClassLoader());
System.out.println(System.getProperty("sun.boot.class.path"));
}
}
通过String 类获取bootstrap根类加载器信息,发现是null,可以通过“sun.bboot.class.path”获取具体的根类加载器信息;
1.2 扩展类加载器
扩展类加载器的父加载器是根加载器;
作用:
用于加载JAVA_HOME下的jre\lb\ext子目录里边的类库。
纯java语言实现,它是java.lang.URLClassLoader的子类,完整类名sun.misc.Launcher$ExtClassLoader。扩展类加载器所加载的类库可以通过系统属性java.ext.dirs获得。
System.out.println(System.getProperty("java.ext.dirs"));
1.3 系统类加载器
系统类加载器是一种常见的类加载器
作用:
负责加载classpath下的类库资源。
系统类加载器的父加载器是扩展类加载器,同时他也是自定义类加载器的默认的父加载器,系统加载器的加载路径一般是通过-classpath或者-cp指定,同样也可以通过系统属性java.class.path进行获取。
2.自定义类加载器
所有的的自定义类加载器都是ClassLoader的直接子类或者间接子类,java.lang.ClassLoader是一个抽象类,但是里面并没有抽象方法,但是有findClass方法,一定要实现该方法,否则会抛出ClassNotFind 类找不到的异常。
protected Class<?> findClass(String name) throws ClassNotFoundException {
throw new ClassNotFoundException(name);
}
在ClassLoader类注释中你还可以发现这样一句话:
支持并发加载类的 ass加载器被称为具有并行能力的类加载器,并且需要在类初始化时通过调用 ClassLoader.registerAsParallelCapable 方法来注册自己。请注意,ClassLoader 类默认注册为具有并行功能。但是,如果它们具有并行能力,它的子类仍然需要注册自己。在委托模型不是严格分层的环境中,类加载器需要具有并行能力,否则类加载会导致死锁,因为加载器锁在类加载过程中被持有
2.1 双亲委托机制
类加载器最重要的机制,双亲委托机制,也称之为父委托机制。
当一个类加载器被调用loadClass之后,它并不会直接将其加载,而是先交给当前类加载器的父加载器尝试加载直到最顶层的父加载器,然后再依次向下进行加载。
protected 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 {
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
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
synchronized (getClassLoadingLock(name)) {
}
getClassLoadingLock(name)方法,
protected Object getClassLoadingLock(String className) {
Object lock = this;
if (parallelLockMap != null) {
Object newLock = new Object();
lock = parallelLockMap.putIfAbsent(className, newLock);
if (lock == null) {
lock = newLock;
}
}
return lock;
}
通过类的名字,在paralleLockMap中获取类是否存在,也就是缓存中是否存在,putIfAbsent方法存在就直接返map中值,如果不存在,则将新值放入并返回。
Class<?> c = findLoadedClass(name);
接下来就是类是否有加载
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}
可以看到如果没有加载,则找到它的父类加载器,继续loadClass,当然没有如果父类加载器是空的,看否存在根类加载器,如果没有或者找不到,则直接返回空即可;
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
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
如果最后还是没有找到该类,则调用findClass,也就是你自定义的类加载器,进行查找该类;
loadClass的大体流程就是如此,总结一下:
- 从当前类加载器中已经加载的缓存中根据类全路径名查询是否存在该类,如果存在则直接放回。
- 如果当前类存在父类加载器,则调用父类加载器load(name,false)方法对其进行加载;
- 如果当前类不存在父类加载器,则直接调用根类加载器对类进行加载
- 如果当前类的所有父类加载器都没有加载成功,则尝试调用当前类加载器的findClass方法对其进行加载,该方法就是我们自定义加载器重写的方法;
- 最后如果类被成功加载,则做一些性能数据的统计;
- 由于loadClass指定了resolve为false,所以不进行连接阶段的继续执行,这也就是为什么通过类加载器加载类并不会导致类的初始化;
由于系统类加载器的特殊性,有时候需要跳过类加载器:
- 绕过系统类加载器,直接用扩展类加载器
- 构造自定义加载器的时候,直接将父类加载器设置为null
2.2 破坏双亲委托机制
JDK提供的双亲委托机制并非一个强制性模型,可以对其灵活的破坏这种委托机制。
破坏的方式就是重写loadClass代码,替换原生的逻辑即可;
2.3 类加载器命名空间、运行时包、类的卸载等
1.类加载器命名空间
每一个类加载器实例都有一个自己的命名空间,命名空间是由该加载器及其所有父加载器所构成的,因此每个类加载器中同一个class都是独一无二的。
当然使用不同的类加载器,或者同一个类加载器的不同实例,去加载同一个class,则会在堆内存和方法区产生多个class的对象。
也就是说,同一个class在同一个类加载器命名空间之下是唯一的。
2.运行时包
编写代码通常会给一个类指定一个包名,包的作用是为了组织类,防止不同包名下同样名称的class引起冲突,还能起到封装的作用,包名和类名构成了类的全限定名称。
在JVM运行时class会有一个运行时包,运行时的包是由类加载器的命名空间和类的全限定名称共同组成的。
这样的好处出于安全和封装的考虑。
3.初始类加载器
两个类独立加载如何互相访问?
每一个类在经过ClassLoader的加载之后,在虚拟机中都会有对应的Class实例,如果某个类Class被类加载器ClassLoader加载,那么ClassLoader就称为Class的初始类加载器。
JVM为每一个类加载器维护了一个列表,该列表中记录了将该类加载器作为初始类加载器的所有class,在加载一个类时,jvm使用这些列表判断该类是否已经被加载过了,是否需要首次加载。
根据JVM规范的规定,在类的加载过程中,所有参与的类加载器,即使没有亲自加载过该类,也都会被标识为该类的初始类加载器。
比如说你定义的类经历了自定义的类加载器,系统类加载器,扩展类加载器,根类加载器,所以这些类加载器都是你定义类的初始类加载器,所以对于两个独立的类,到底能互相访问的情况,即使在自定义类加载器中,两个类是互相独立的,但是上层类加载器中会存在两个类的加载信息,所以是能够互相访问的。
4.类的卸载
JVM启动过程中,加载了很多的类,可以通过指定参数 -verbose:class参数观察到。
我们知道某个对象在堆内存中如果没有其他地方引用则会在垃圾回收线程进行GC的时候回收掉,那么该对象在堆内存中的Class对象以及Class在方法区中的数据结构何时被回收呢?
JVM规定了一个Class只有满足三种条件的时候才会被GC回收,也就是类的卸载:
- 该类的实例已经被GC
- 加载该类的ClassLoader实例被回收
- 该类的class实例没有在其他地方被引用
3.总结
- 双亲委托机制是一种包含关系,而不是继承。
- 定义与JDK源码相同的类,自定义类加载器去读取,虽然能够读取到,但是JVM是不允许这样做,因为ClassLoader会做一个安全性检查。
(三)线程上下文类加载器
线程上下文类加载器存在的原因是什么呢?
这于JVM类加载器双亲委托机制自身的缺陷是分不开的,JDK的核心库提供了很多SPI(service Provider Interface),常见的SPI包括JDBC、JCE、JNDI、JAXP和JBI等,JDK只规定了这些接口之间的逻辑关系,但不提具体的实现,具体的实现需要由第三方厂商来提供。
以JDBC为例,编写JDBC的程序都会与java.sql包下的类打交道。
Java使用JDBC这个SPI完全透明了应用程序和第三方厂商数据库驱动的具体实现,不管数据库类型如何切换,应用程序只需要替换JDBC的驱动jar包以及数据库的驱动名称即可,而不用进行任何的更新。
这样做的好处是JDBC提供了高度抽象,应用程序则只需要面向接口编程即可,不用关心各大数据库厂商提供的具体实现,但问题在于java.lang.sql中的所有接口都是由JDk提供,加载这些接口的类加载器是根加载器,第三方厂商提供的类库驱动则是由系统类加载器加载的,由于JVM类加载器的双亲委托机制,比如Connection、Statement、RowSet等皆是由根加载器加载,第三方的JDBC驱动包中的实现不会被加载。
理解:jdk原生的类一般加载都是由根类加载器进行加载的,第三方的类库,需要系统类加载器加载,这也就当根加载器加载了JDK原生的类库之后,不会扫描到第三方类库。
加载不到如何解决呢?
线程上下文类加载器,有了线程上下文加载器,启动类加载器(根类加载器)需要去委托子类加载器去加载厂商提供额SPI具体实现。将父委托变成了子委托。打破了双亲委托机制的模型。
总结脑图
学习自:
《Java高并发编程详解——多线程与架构设计》 汪文君