JVM的类加载

首先我们来看下Java虚拟机的类加载过程:

如上图。

JVM需要用到某个类的时候,虚拟机会加载它的 .class 文件。加载了相关的字节码信息之后,会常见对应的 Class 对象,这个过程就被称为类加载。

需要注意的是:类加载机制只负责class文件的加载,至于是否可以执行,则是由执行引擎决定的。

从图上可以看出,类的加载过程被分为五个阶段:加载、验证、准备、解析、初始化。验证、准备、解析三个阶段为连接步骤。其中加载、验证、准备、初始化这几个阶段的顺序是确定的,但是解析阶段不一定,在某些情况下可以在初始化阶段之后再开始(动态绑定)。

加载

这个阶段指的是,通过全限定类名查找Class字节码文件并将其加载到内存的过程。流程分为三步:

  • 通过全限定类型查找 .class 文件,获取二进制字节流数据
  • 把字节流所代表的静态存储结构转换为运行时数据结构
  • 在堆中为其创建一个Class队形,作为程序访问这些数据的入口。

验证

这个阶段,主要是为了保证被加载的Class对象的正确性,检测Class字节流中的数据是否符合虚拟机要求,确保不会危害到虚拟机的自身安全。

验证阶段主要包括四种验证:文件格式验证、元数据验证、字节码验证、符号引用验证。

  • 文件格式验证

    验证字节流是否符合Class文件格式的规范。

    • CA/FE/BA/BE魔数验证
    • 主次版本号验证
    • 常量池中常量类型是否存在不被支持的类型验证
    • 指向常量池中的索引是否有指定不存在或不符合类型的常量
  • 元数据验证

    对字节码描述的信息进行语义分析,保证其描述的信息符合Java语言规范要求。

    • 类是否有父类,除了Object之外,所有的类都应该有父类
    • 类的父类是否继承了不允许被继承的类(被final修饰的类)
    • 如果这个类不是抽象类,是否实现了其父类或接口中要求实现的所有方法
    • 类的字段/方法是否与父类的存在冲突。例如方法参数都一样,返回值却不同
  • 字节码验证

    通过数据流和控制流分析,确定程序语义合法且符合逻辑。

    • 对类的方法体进行校验分析,保证在运行时不会做出危害虚拟机的行为
    • 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,不会出现类似于在操作数栈放了一个int类型的数据,读取时却按照long类型加载到本地变量表中的情况
    • 保障任何跳转指令都不会跳转到方法体之外的字节码指令上
  • 符号引用验证

    确保后续的解析动作能正常运行

    • 通过字符串描述的全限定名是否能找到对应的类
    • 符号引用中的类、字段、方法的可访问性是否可被当前类访问

准备

这个阶段,主要是为了给在类中声明的静态变量分配内存空间,并将其初始化为默认值(零)。

注意,这个默认值并不是指在Java代码中显示赋予的值,而是指数据类型的默认值。比如 static num = 4; 在这里只会将num初始化为0。

在这里进行的内存分配,仅仅包括类成员(static成员),实例成员则是在创建具体的java对象时再被一起分配在堆空间中。同时也不包含final修饰的static成员,因为final在编译的时候就会分配了,准备阶段会显示初始化。

解析

这个阶段,主要是把类中对常量池的符号引用转换为直接引用的过程。

值得一提的是,解析操作往往会伴随着JVM在执行完初始化之后再执行。

解释一下什么符号引用什么是直接引用:

  • 符号引用:用一组符号来描述引用的目标,符号引用的字面量形式明确定义在《Java虚拟机规范》的Class文件格式中。
  • 直接引用:直接指向目标的指针、相对偏移量或者一个间接定位到目标的句柄。

而符号引用转直接引用的过程,主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符等7类符号引用进行。

初始化

这个阶段,主要是对类的静态变量赋予正确的初始值。也就是说,在声明静态变量时指定的初始化值以及静态代码块中的赋值。

本质上是执行了类构造器的<cinit>()的过程。

以下几种情况可以触发初始化:

  • 使用new关键字创建一个实例对象时
  • 访问类的静态字段或静态方法时
  • 对类型进行反射调用的时候,例如 newInstance()
  • 当初始化一个类时发现父类没有初始化,那会先触发父类的初始化
  • 虚拟机启动的时候,需要指定一个要执行的主类,虚拟机会初始化这个主类(比如说SpringBoot的启动类)
  • JDK8中,当一个接口定义了默认方法的时候,如果这个接口的实现类发生了初始化,那么先要将这个接口进行初始化。

以上这几种情况称之为主动引用。

除了以上几种情况之外,其他使用类的方式被看做嘶对类的被动引用,不会导致类的初始化。比如在子类中的调用父类的静态字段、定义该类的数组方式引用、调用该类的常量等情况都不会触发类进行初始化。

另外,初始化的大致步骤大概是:

  • 如果类还未加载、连接,则先进行加载、连接步骤。
  • 如果当前类存在直接父类未被进行初始化,则先初始化该父类。
  • 构造器方法中指令 按照语句在源码中出现的顺序执行。

使用、卸载

当一个类完整的经过了类加载过程之后,在内存中已经生成了Class对象,同时在Java程序中已经通过它开始创建实例对象使用时,这个阶段称之为使用阶段。

而当一个Class对象不在被任何一个位置引用,也就是说,不可触及的时候,Class就会结束生命周期,该类的加载数据也会被卸载。

注意:

Java虚拟机自带的类加载器加载的类,在JVM的生命周期中始终不会被卸载。因为JVM始终会保持与这些类加载器的引用,这这些类加载器也会始终保持着自己加载的Class对象的引用。

对于虚拟机而言,这些Class对象始终是可以被初级的。不过由用户自定义的类加载器加载的类是可以被卸载的。

类加载器类型与分析

我们来看下类加载器的类型:

Bootstrap启动类加载器

也被称为引导类加载器。指的就是 BootstrapClassLoader。启动类加载器使用C++语言实现的,是JVM自身的一部分,主要负责将<JAVA_HOME>/lib路径下的核心类库或者 -Xbootclasspath参数指定的路径下的jar包加载到内存中。

注意:

因为JVM是通过全限定类明来记载类库的,所以如果文件名不能被虚拟机识别,就算把jar包丢进lib目录表,启动类加载器也不会去加载它。处于安全考虑BootstrapClassLoader只加载报名为java、javax、sun等开头的类文件。

启动类加载器只为JVM提供加载服务,开发者不能直接使用它来加载自己的类。

Extension扩展类加载器

这个类加载器是由sun公司实现的,位于HotSpot源码目录中的sun.misc.Launcher$ExtClassLoader位置。它主要负责加载<JAVA_HOME>\lib\ext目录下或者由系统变量-Djava.ext.dir指定位路径中的类库。它可以直接被开发者使用。

Application应用类加载器

也别成为系统类加载器,也是由sun公司实现的,位于HotSpot源码目录中的sun.misc.Launcher$AppClassLoader位置。它负责加载系统类路径java -classpath-D java.class.path指定路径下的类库,也就是经常用到的classpath路径。应用程序类加载器也可以直接被开发者使用。

一般情况下,该类加载器是程序的默认类加载器。我们可以通过ClassLoader.getSystemClassLoader()方法来直接获取到它。

自定义类加载器

Java程序中,运行时一般都是通过如上三种类加载器相互配合执行的,当然,如果有特殊的加载需求也可以自定义类加载器,通过继承ClassLoader类实现。

但继承ClassLoader需要自己重写findClass()方法并编写加载逻辑。所以如果一般没有太过复杂的需求,可以直接继承URLClassLoader类,可以省略自己编写findClass方法以及文件加载转换成字节码流的步骤,使自定义类加载器编写更加简洁。

那什么情况下时,我们需要自定义类加载器呢?有以下几种情况:

  • class文件不在classpath路径下时,需要自定义类加载器加载特定路径下的class
  • 当一个class文件时通过网络传输过来的bin经过了加密处理,需要授信对class文件做了对应的解密处理之后再加载到内存中时,需要自定义类加载器。
  • 线上环境不能停机时,要动态更改某块代码,这种情况下需要自定义类加载器。(比如实现热部署功能。即一个class文件通过不同的类加载器产生不同的class对象从而实现热部署功能。)

案例

运维子平台有个需求,需要一个编写Java代码的终端,那对于这种情况就需要将运维平台中编写的class文件经过加密后,通过网络传输过来,然后对其类的字节码数据进行解密后再加载。

源码如下:

// 运维终端类加载器
public class OpsClassLoader extends ClassLoader { // 接收到的class文件本地的存储位置
private String rootDirPath; // 构造器
public OpsClassLoader(String rootDirPath) {
this.rootDirPath = rootDirPath;
} // 读取Class字节流并解密的方法
private byte[] getClassDePass(String className) throws IOException {
String classpath = rootDirPath + className; // 模拟文件读取过程.....
FileInputStream fis = new FileInputStream(classpath);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
int bufferSize = 1024;
int n = 0;
byte[] buffer = new byte[bufferSize];
while ((n = fis.read(buffer)) != -1)
// 模拟数据解密过程.....
baos.write(buffer, 0, n);
byte[] data = baos.toByteArray(); // 模拟保存解密后的数据....
return data;
} // 重写了父类的findClass方法
@SneakyThrows
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
// 读取指定的class文件
byte[] classData = getClassDePass(name);
// 如果没读取到数据,抛出类不存在的异常
if (classData == null)
throw new ClassNotFoundException();
// 通过调用defineClass方法生成Class对象
return defineClass(name,classData,0,classData.length);
}
}

在如上源码中,我们通过了getClassDePass()方法读取了网络传输过来存储到本地的class文件的字节流数据,并对读取到的数据做了对应的解密处理(模拟),然后通过重写了父类的ClassLoader.findClass()方法,利用defineClass()方法在JVM内存中生成了最终的Class对象。

当然,如果你想代码更简洁,也可以通过继承URLClassLoader类实现。

热部署机制原理分析

大家对热部署机制都不陌生。在热部署机制出现之前,往往我们稍微更改了一丢丢的Java代码,就需要对整个项目重启之后才可生效。而热部署机制出现之后,在Java程序运行过程中,动态的修改了某个类的代码保存后,程序会自动加载更新代码。这是如何实现的呢?

在之前的类加载机制中,我们分析得知:全限定类名相同的一个类被加载过后,第二次需要用到该类的时候,会直接在类加载器的命名空间(可以理解为缓存)中进行查找,而不会被二次加载。如果强制指定同一个类加载器二次加载同一个类的时候,会抛出异常。

所以一般类被加载一次之后,就算某个类的class文件发生了改变,JVM也不会再次加载它。

而所谓的热部署机制的实现其实比较简单,就是通过利用不同的类加载器,去加载更改后的class文件,从而在内存中创建出两个不同的class对象,从而达到文件更改后可以生效的目的。

四种类加载器之间的关系

从上面的分析可知类加载器的关系如下:

Bootstrap启动类加载器 → Extension拓展类加载器 → Application应用类加载器 → Custom自定义类加载器

Bootstrap类加载器是在JVM启动时初始化的,它会负责加载ExtClassLoader,并将其父加载器设置为BootstrapClassLoader

BootstrapClassLoader加载完ExtClassLoader后会接着加载AppClassLoader系统类加载器,并将其父加载器设置为ExtClassLoader拓展类加载器。

而自定义的类加载器会由系统类加载器加载,加载完成后,AppClassLoader会成为它们的父加载器。

注意:

类加载器之间并不存在相互继承或者包含关系,从上至下仅存在父类加载器的层级引用关系。

通过Java代码来简单剖析一下类加载器之间的关系:

// 自定义类加载器
public class ClassLoaderDemo extends ClassLoader {
public static void main(String[] args){
ClassLoaderDemo classLoader = new ClassLoaderDemo(); System.out.println("自定义加载器:" +
classLoader);
System.out.println("自定义加载器的父类加载器:" +
classLoader.getParent());
System.out.println("Java程序系统默认的加载器:" +
ClassLoader.getSystemClassLoader());
System.out.println("系统类加载器的父加载器:" +
ClassLoader.getSystemClassLoader().getParent());
System.out.println("拓展类加载器的父加载器:"
+ ClassLoader.getSystemClassLoader().getParent().getParent());
}
}

结果输出如下:

自定义加载器:com.sixstarServiceOrder.ClassLoaderDemo@6d5380c2
自定义加载器的父类加载器:sun.misc.Launcher$AppClassLoader@18b4aac2
Java程序系统默认的加载器:sun.misc.Launcher$AppClassLoader@18b4aac2
系统类加载器的父加载器:sun.misc.Launcher$ExtClassLoader@45ff54e6
拓展类加载器的父加载器:null

因为BootstrapClassLoader是由C++实现的,所以在获取ExtClassLoader的父类加载器时,获取到的结果为null

总结

对类加载器进行一个总结。

JVM的类加载机制是按需加载的模式运行的,也就是说,所有类并不是在程序启动时就全部加载,而是当需要用到某个类的时候发现它未加载时,才会去触发加载的过程。

Java中的类加载器会被组织成存在父子关系的层级结构。同时类加载器之间也存在这代理模式。当一个类需要被加载时,首先会依次根据层级结构检查父加载器是否对这个类进行了加载,如果父类已经装载了则可以直接使用。反之如果未被装载则依次从上至下询问,是否在可加载范围内,是否允许被当前层级的加载器加载,如果可以则进行加载操作。

每个类加载器都拥有一个自己的命名空间(缓存),命名空间的作用是用于存储被自身加载过的所有类的全限定名。子类加载器查找父类加载器是否加载过一个类时,就是通过类的权限定名在父类的命名空间中进行匹配。而Java虚拟机判断两个类是否相同的基准就是通过ClassLoaderId + PackageName + ClassName进行判断,也就代表着,Java程序运行过程中,是允许存在两个包名和类名完全一致的class的,只需要使用不同的类加载器加载即可,这也就是Java类加载器存在的隔离性问题,而Java为了解决这个问题,JVM引入了双亲委派机制。

子类加载器可以检查父类加载器中加载的类,但这个是不可逆的,也就代表着父类加载器是不可以查找子类加载器加载的类,存在可见性限制。

Bootstrap、Ext、APP三个类加载器加载的类是不可以被卸载的,但可以删除当前的类装载器,然后创建一个新的类装载器装载。

另:

显示加载:指的是开发者手动通过调用ClassLoader加载一个类,比如Class.forName(name)obj.getClass().getClassLoader().loadClass()方式加载class对象。

隐式加载:指不会在程序中明确的指定加载某个类,属于被动式加载,比如在加载某个类时,该类中引用了另外一个类的对象时,JVM就会去自动加载另外一个类,而这种被动加载方式就被称为“隐式加载”。

双亲委派机制

前面提到过,为了解决类加载器的隔离性问题,JVM引入了双亲委派机制,而双亲委派的核心思想在于两点:

  • 自下而上检查类是否已经被加载
  • 从上至下尝试加载类

加载过程

  • App尝试加载一个类时,它不会直接尝试加载这个类,首先会在自己的命名空间中查询是否已经加载过这个类,如果没有会先将这个类加载请求委派给父类加载器Ext完成
  • Ext尝试加载一个类时,它也不会直接尝试加载这个类,也会在自己的命名空间中查询是否已经加载过这个类,没有的话也会先将这个类加载请求委派给父类加载器Bootstrap完成
  • 如果Bootstrap加载失败,也就是代表着:这个需要被加载的类不在Bootstrap的加载范围内,那么Bootstrap会重新将这个类加载请求交由子类加载器Ext完成
  • 如果Ext加载失败,代表着这个类也不在Ext的加载范围内,最后会重新将这个类加载请求交给子类加载器App完成
  • 如果App加载器也加载失败,就代表这个类根据全限定名无法查找到,则会抛出ClassNotFoundException异常

优势

从上述的过程中可以很直观的感受到:当JVM尝试加载一个类时,通常最底层的类加载器接收到了类加载请求之后,会先交由自己的上层类加载器完成。

那么采用这种模式的优势是什么?

  • 可以避免一个类在不同层级的类加载器中重复加载,如果父类加载器已经加载过该类了,那么就不需要子类加载器再加载一次。

  • 可以保障Java核心类的安全性问题,有效防止Java的核心API类在运行时被篡改,从而保证所有子类共享同一基础类,减少性能开销和安全隐患问题。

    比如通过网络传输过来一个java.lang.String类,需要被加载时,通过这种双亲委派的方式,最终找到Bootstrap加载器后,发现该类已经被加载,从而就不会再加载传输过来的java.lang.String类,而是直接返回Bootstrap加载的String.class

实现原理

从代码层面了解一下Java中双亲委派模式的实现以及Java中定义的一些类加载器。

Java中,所有的类加载器都间接的继承自ClassLoader类,包括Ext、App类加载器(Bootstrap除外,因为它是C++实现的),如下:

// sun.misc.Launcher类
public class Launcher {
// sun.misc.Launcher类 → 构造器
public Launcher(){
Launcher.ExtClassLoader var1;
try {
// 会先初始化Ext类加载器并创建ExtClassLoader
var1 = Launcher.ExtClassLoader.getExtClassLoader();
} catch (IOException var10) {
throw new InternalError(
"Could not create extension class loader", var10);
}
try {
// 再创建AppClassLoader并把Ext作为父加载器传递给App
loader = AppClassLoader.getAppClassLoader(extcl);
} catch (IOException e) {
throw new InternalError(
"Could not create application class loader");
} // 将APP类加载器设置为线程上下文类加载器(稍后分析)
Thread.currentThread().setContextClassLoader(loader);
// 省略......
} // sun.misc.Launcher类 → ExtClassLoader内部类
static class ExtClassLoader extends URLClassLoader {
// ExtClassLoader内部类 → 构造器
public ExtClassLoader(File[] var1) throws IOException {
// 在Ext初始化时,父类构造器会被设置为null
super(getExtURLs(var1), (ClassLoader)null, Launcher.factory);
SharedSecrets.getJavaNetAccess().getURLClassPath(this)
.initLookupCache(this);
}
} // sun.misc.Launcher类 → AppClassLoader内部类
static class AppClassLoader extends URLClassLoader {}
} // java.net.URLClassLoader类
public class URLClassLoader extends SecureClassLoader
implements Closeable {} // java.security.SecureClassLoader类
public class SecureClassLoader extends ClassLoader {}

如上源码。

Ext、App类加载器都是sun.misc.Launcher类的内部类,而Launcher在初始化时会首先创建Ext类加载器,而在初始化Ext时,它的构造器中会强行将其父类加载器设置为null

创建完成Ext类加载器之后,会紧接着再创建App类加载器,同时在创建AppClassLoader的时候会将Ext类加载器设置为App类加载器的父类加载器。

Ext、App类加载器都继承了URLClassLoader类,该类主要是用于读取各种jar包、本地class以及网络传递的class文件,通过找到它们的字节码,然后再将其读取成字节流,最后通过defineClass()方法创建类的Class对象。而URLClassLoader类继承了SecureClassLoader类,该类也作为了ClassLoader类的拓展类,新增了几个对代码源的位置及其证书的验证以及权限定义类验证(主要指对class源码的访问权限)的方法,一般我们不会直接跟这个类打交道,更多是与它的子类URLClassLoader有所关联。

总而言之,Ext、App类加载器都间接的继承了ClassLoader类,ClassLoader类作为Java类加载机制的顶层设计类,它是一个抽象类。

下面来简单的看看ClassLoader,如下:

// ClassLoader类 → loadClass()方法
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
// 加锁
synchronized (getClassLoadingLock(name)) {
// 先尝试通过全限定名从自己的命名空间中查找该Class对象
Class<?> c = findLoadedClass(name);
// 如果找到了则不需要加载了,如果==null,开始类加载
if (c == null) {
long t0 = System.nanoTime();
try {
// 先将类加载任务委托自己的父类加载器完成
if (parent != null) {
c = parent.loadClass(name, false);
} else {
// 如果父类加载器为null,代表当前已经是ext加载器了
// 那么则将任务委托给Bootstrap加载器加载
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 处理异常,抛出异常
} if (c == null) {
// 如果都没有找到,则通过自定义实现的findClass
// 去查找并加载
long t1 = System.nanoTime();
c = findClass(name); // 这是记录类加载相关数据的(比如耗时、类加载数量等)
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
// 是否需要在加载时进行解析,如果是则触发解析操作
if (resolve) {
resolveClass(c);
}
// 返回加载后生成的Class对象
return c;
}
} // ClassLoader类 → findClass()方法
protected Class<?> findClass(String name)
throws ClassNotFoundException {
// 直接抛出异常(这个方法是留给子类重写的)
throw new ClassNotFoundException(name);
} // ClassLoader类 → defineClass()方法
protected final Class<?> defineClass(String name, byte[] b,
int off, int len) throws ClassFormatError
{
// 调用了defineClass方法,
// 将字节数组b的内容转换为一个Java类
return defineClass(name, b, off, len, null);
} // ClassLoader类 → resolveClass()方法
protected final void resolveClass(Class<?> c) {
// 调用本地(navite)方法,解析一个类
resolveClass0(c);
} // ClassLoader类 → getParent()方法
@CallerSensitive
public final ClassLoader getParent() {
// 如果当前类加载器的父类加载器为空,则直接返回null
if (parent == null)
return null;
// 如果不为空则先获取安全管理器
SecurityManager sm = System.getSecurityManager();
if (sm != null) {
// 然后检查权限后返回当前classLoader的父类加载器
checkClassLoaderPermission(parent,
Reflection.getCallerClass());
}
return parent;
}

述简单的罗列了一些ClassLoader类的关键方法,具体作用如下:

  • loadClass(name,resolve):加载名称为name的类,加载后返回Class对象实例
  • findClass(name):查找名称为name的类,返回是一个Class对象实例(该方法是留给子类重写覆盖的,在loadClass中,在父类加载器加载失败的情况下会调用该方法完成类加载,这样可以保证自定义的类加载器也符合双亲委托模式)
  • defineClass(name,b,off,len):将字节流b转换为一个Class对象
  • resolveClass(c):使用该方法可以对加载完生成的Class对象同时进行解析操作
  • getParent():获取当前类加载器的父类加载器

其实双亲委派模型的实现逻辑全在于loadClass()方法,而ExtClassLoader加载器是没有重写loadClass()方法,AppClassLoader加载器虽然重写了loadClass()方法,但其内部最终还是调用父类的loadClass()方法,如下:

// sun.misc.Launcher类 → AppClassLoader内部类 → loadClass()方法
public Class loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
int i = name.lastIndexOf('.');
if (i != -1) {
SecurityManager sm = System.getSecurityManager();
if (sm != null) {
sm.checkPackageAccess(name.substring(0, i));
}
}
// 依旧调用的是父类loadClass()方法
return (super.loadClass(name, resolve));
}

所以无论是ExtClassLoader还是AppClassLoader加载器,其本身都未打破ClassLoader.loadClass()方法中定义的双亲委派逻辑,Bootstrap、Ext、App这些JVM自带的类加载器都默认会遵守双亲委派模型。

双亲委派机制的破坏者

在Java中,官方为我们提供了很多SPI接口,例如JDBC、JBI、JNDI等。这类SPI接口,官方往往只会定义规范,具体的实现则是由第三方来完成的,比如JDBC,不同的数据库厂商都需自己根据JDBC接口的定义进行实现。

而这些SPI接口直接由Java核心库来提供,一般位于rt.jar包中,而第三方实现的具体代码库则一般被放在classpath的路径下。

位于rt.jar包中的SPI接口,是由Bootstrap类加载器完成加载的,而classpath路径下的SPI实现类,则是App类加载器进行加载的。但往往在SPI接口中,会经常调用实现者的代码,所以一般会需要先去加载自己的实现类,但实现类并不在Bootstrap类加载器的加载范围内,而经过前面的双亲委派机制的分析,我们已经得知:子类加载器可以将类加载请求委托给父类加载器进行加载,但这个过程是不可逆的。也就是父类加载器是不能将类加载请求委派给自己的子类加载器进行加载的。

所以此时就出现了这个问题:如何加载SPI接口的实现类?

答案是打破双亲委派模型。

注:

SPIService Provider Interface):JavaSPI机制,其实就是可拔插机制。在一个系统中,往往会被分为不同的模块,比如日志模块、JDBC模块等,而每个模块一般都存在多种实现方案,如果在Java的核心库中,直接以硬编码的方式写死实现逻辑,那么如果要更换另一种实现方案,就需要修改核心库代码,这就违反了可拔插机制的原则。为了避免这样的问题出现,就需要一种动态的服务发现机制,可以在程序启动过程中,动态的检测实现者。而SPI中就提供了这么一种机制,专门为某个接口寻找服务实现的机制。

当第三方实现者提供了服务接口的一种实现之后,在jar包的META-INF/services/目录里同时创建一个以服务接口命名的文件,该文件就是实现该服务接口的实现类。而当外部程序装配这个模块的时候,就能通过该jarMETA-INF/services/里的配置文件找到具体的实现类名,并装载实例化,完成模块的注入。基于这样一个约定就能很好的找到服务接口的实现类,而不需要再代码里制定。同时,JDK官方也提供了一个查找服务实现者的工具类:java.util.ServiceLoader

线程上下文类加载器就是双亲委派模型的破坏者。

它可以在执行线程中打破双亲委派机制的加载链关系,从而使得程序可以逆向使用类加载器。

我们可以通过分析JDBC驱动的源码来看看是怎么打破双亲委派机制是的程序逆向使用类加载器。

JDBC角度分析线程上下文类加载器

JavaSPI定义的一个核心类:DriverManager,该类位于rt.jar包中,是Java中用于管理不同数据库厂商实现的驱动,同时这些各厂商实现的Driver驱动类,都继承自Java的核心类java.sql.Driver

MySQLcom.mysql.cj.jdbc.Driver的驱动类。先看看DriverManager的源码:

// rt.jar包 → DriverManager类
public class DriverManager {
// ....... // 静态代码块
static {
// 加载并初始化驱动
loadInitialDrivers();
println("JDBC DriverManager initialized");
} // DriverManager类 → loadInitialDrivers()方法
private static void loadInitialDrivers() {
// 先读取系统属性 jdbc.drivers
String drivers;
try {
drivers = AccessController.doPrivileged(new PrivilegedAction<String>() {
public String run() {
return System.getProperty("jdbc.drivers");
}
});
} catch (Exception ex) {
drivers = null;
} AccessController.doPrivileged(new PrivilegedAction<Void>() {
public Void run() {
//通过ServiceLoader类查找驱动类的文件位置并加载
ServiceLoader<Driver> loadedDrivers =
ServiceLoader.load(Driver.class);
//省略......
}
});
//省略......
}

观察如上源码,在DriverManager类的静态代码块中调用了loadInitialDrivers()方法,该方法中,会通过ServiceLoader查找服务接口的实现类。前面分析JavaSPI机制时,曾提到过:JavaSPI存在一种动态的服务发现机制,在程序启动时,会自动去jar包中的META-INF/services/目录查找以服务命名的文件,mysql-connector-java-6.0.6.jar包文件目录如下:

如上工程结构,我们明确可以看到,在MySQLjar包中存在一个META-INF/services/目录,而在该目录下,存在一个java.sql.Driver文件,该文件中指定了MySQL驱动Driver类的路径,该类源码如下:

// com.mysql.cj.jdbc.Driver类
public class Driver extends NonRegisteringDriver
implements java.sql.Driver {
public Driver() throws SQLException {
}
// 省略.....
}

可以看到,该类是实现了Java定义的SPI接口java.sql.Driver的,所以在启动时,SPI的动态服务发现机制可以发现指定的位置下的驱动类。

看看SPI机制是如何加载对应实现类的,ServiceLoader.load()源码如下:

// ServiceLoader类 → load()方法
public static <S> ServiceLoader<S> load(Class<S> service) {
// 获取线程上下文类加载器
ClassLoader cl = Thread.currentThread().getContextClassLoader();
// 使用线程上下文类加载器对驱动类进行加载
return ServiceLoader.load(service, cl);
}

通过如上源码可以看到:最终是通过Thread.currentThread().getContextClassLoader()获取的当前执行线程的线程上下文类加载器对SPI接口的实现类进行了加载。

在前面我们分析Java中的双亲委派实现时,曾提到了Ext、App类加载器都是Launcher类的内部类,Ext、App类加载器的初始化操作都是在Launcher的构造函数中完成的,同时,在该构造函数中,Ext、App初始化完成后,会执行下面这句代码:

Thread.currentThread().setContextClassLoader(loader);

通过如上这句代码,在Launcher的构造函数中,会将已经创建好的AppClassLoader系统类加载器设置为默认的线程上下文类加载器。

我们总结一下流程:

  • Java程序启动
  • JVM初始化C++编写的Bootstrap启动类加载器
  • Bootstrap加载Java核心类(核心类中包含Launcher类)
  • Bootstrap加载Launcher类,其中触发Launcher构造函数
  • Bootstrap执行Launcher构造函数的逻辑
  • Bootstrap初始化并创建Ext、App类加载器
  • Launcher类的构造函数中将Ext设置为App的父类加载器,同时再将App设置为默认的线程上下文类加载器
  • Bootstrap继续加载其他Java核心类(如:SPI接口)
  • SPI接口中调用了第三方实现类的方法
  • Bootstrap尝试去加载第三方实现类,发现不在自己的加载范围内,无法加载
  • 依赖于SPI的动态服务发现机制,这些实现类会被交由线程上下文类加载器进行加载(在前面讲过,线程上下文加载器在Launcher构造函数被设置为了App类加载器)
  • 通过App系统类加载器加载第三方实现类,发现这些实现类在App的加载范围内,可以被加载,SPI接口的实现类加载完成
  • ...

很明显的就可以感觉出来,线程上下文类加载器介入后,轻而易举的打破了原有的双亲委派模型,同时,也正是因为线程上下文类加载器的出现,从而使得Java的类加载器机制更加灵活,方便。

总结

简单来说,Java提供了很多核心接口的定义,这些接口被称为SPI接口,同时为了方便加载第三方的实现类,SPI提供了一种动态的服务发现机制(约定),只要第三方在编写实现类时,在工程内新建一个META-INF/services/目录并在该目录下创建一个与服务接口名称同名的文件,那么在程序启动的时候,就会根据约定去找到所有符合规范的实现类,然后交给线程上下文类加载器进行加载处理。

总结

总归来说,Java类加载机制的核心点也就是开篇提到的那几个重点:类加载过程、类加载器、双亲委派模型、自定义类加载器以及线程上下文类加载器,掌握这几部分即可。

[JVM] JVM的类加载机制的更多相关文章

  1. 【JVM】JVM系列之类加载机制(四)

    一.前言 前面分析了class文件具体含义,接着需要将class文件加载到虚拟机中,这个过程是怎样的呢,下面,我们来仔细分析. 二.什么是类加载机制 把class文件加载到内存,并对数据进行校验.转换 ...

  2. JVM之虚拟机类加载机制

    有兴趣可以先参考前面的几篇JVM总结: JVM自动内存管理机制-Java内存区域(上) JVM自动内存管理机制-Java内存区域(下)     JVM垃圾收集器与内存分配策略(一) 我们知道,在编写一 ...

  3. 图解JVM和Tomcat类加载机制

    说到本篇的tomcat类加载机制,不得不说翻译学习tomcat的初衷. 之前实习的时候学习javaMelody的源码,但是它是一个Maven的项目,与我们自己的web项目整合后无法直接断点调试.后来同 ...

  4. 深入理解JVM,虚拟机类加载机制

    类加载过程概览 类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括以下7个阶段: 加载(Loading) 验证(Verification) 准备(Preparation) 解析(Re ...

  5. Java面试题:JVM中的类加载机制

    JVM 的类加载机制是指 JVM 把描述类的数据从 .class 文件加载到内存,并对数据进行校验.转换解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型,这就是 JVM 的类加载机制. 类 ...

  6. 【JVM】虚拟机类加载机制

    什么是类加载 虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验.转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制. <[JVM]类文件结构& ...

  7. jvm之java类加载机制和类加载器(ClassLoader),方法区结构,堆中实例对象结构的详解

    一.类加载或类初始化:当程序主动使用某个类时,如果该类还未被加载到内存中,则JVM会通过加载.连接.初始化3个步骤来对该类进行初始化.如果没有意外,JVM将会连续完成3个步骤. 二.类加载时机:  1 ...

  8. JVM之Java类加载机制

    什么是类加载机制 虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验.转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这既是虚拟机的类加载机制 类的生命周期 生命周期简述 ...

  9. jvm系列 (五) ---类加载机制

    类的加载机制 目录 jvm系列(一):jvm内存区域与溢出 jvm系列(二):垃圾收集器与内存分配策略 jvm系列(三):锁的优化 jvm系列 (四) ---强.软.弱.虚引用 我的博客目录 什么是类 ...

  10. 【JVM第二篇--类加载机制】类加载器与双亲委派模型

    写在前面的话:本文是在观看尚硅谷JVM教程后,整理的学习笔记.其观看地址如下:尚硅谷2020最新版宋红康JVM教程 一.什么是类加载器 在类加载过程中,加载阶段有一个动作是"通过一个类的全限 ...

随机推荐

  1. apache-jmeter-5.6.3版本报错:errorlevel=1的解决办法

    一.背景: 今天遇到了apache-jmeter-5.6.3版本,下载解决后,打开bin下的:jmeter.bat报错 二.解决方法:  尝试解决了jmeter.bat的内存占用还是没有解决 最终发现 ...

  2. java项目实战-jdbc实现-书城的增删改查-day21

    目录 1. 安装mysql 安装navicate 2. jdbc实现增删改查 1. 安装mysql 安装navicate 参考网上资料 创库 创表 并放入3条测试数据 2. jdbc实现增删改查 bo ...

  3. LaTex · overleaf | 使用技巧存档

    如何使用 bibtex:http://www.taodudu.cc/news/show-5832925.html?action=onClick bibtex 格式:https://blog.csdn. ...

  4. [转帖]资料整理——Oracle版本历史(很全面)(Releases and versions of Oracle Database)

    资料来源: https://en.wikipedia.org/wiki/Oracle_Database Oracle Database Version Initial Release Version ...

  5. Redis不同版本,内存分配,硬件的性能研究

    Redis不同版本,内存分配,硬件的性能研究 前言 Konw more ! Do more ! Gain more ! 骨折之后开始减肥. 前段时间跳绳导致膝盖不舒服,现在改骑车和走路. 在有限的没人 ...

  6. [转帖]Oracle数据库开启NUMA支持

    NUMA简介 NUMA(Non Uniform Memory Access Architecture,非统一内存访问)把一台计算机分成多个节点(node),每个节点内部拥有多个CPU,节点内部使用共有 ...

  7. Clickhouse的极简安装-之二(macos+linux)

    Clickhouse的极简安装-之二(macos+linux) StudyFrom https://clickhouse.com/docs/en/install 然后简单的获取方式: curl htt ...

  8. [转帖]可直接拿来用的kafka+prometheus+grafana监控告警配置

    kafka配置jmx_exporter 点击:https://github.com/prometheus/jmx_exporter,选择下面的jar包下载: 将下载好的这个agent jar包上传到k ...

  9. [转帖]Redhat 8 磁盘调度策略变化:NOOP改为NONE

    说明 在 redhat 4/5/6/7版本中的NOOP调度策略,从8开始修改为NONE,官方解释: none Implements a first-in first-out (FIFO) schedu ...

  10. 【转帖】Linux中如何取消ln链接?(linuxln取消)

    https://www.dbs724.com/163754.html Linux系统使用ln命令可以快速创建链接,ln链接是指把文件和目录链接起来,当改变源时可以快速地改变整个目录下的文件和目录.有时 ...