JVM学习总结(六):深入探究JVM类加载机制及JIT简介

前言

上一篇文章主要学习了下JVM的类文件结构以及字节码指令,这篇文章将主要探究JVM的类加载机制,先上一张总图:

正文

一、JVM 即时编译器 JIT

1、解释执行与 JIT

Java 程序在运行的时候,主要就是执行字节码指令,一般这些指令会按照顺序解释执行,这种就是解释执行,解释执行的方式是非常低效的,它需要把字节码先翻译成机器码,才能往下执行。另外,字节码是 Java 编译器做的一次初级优化,许多代码可以满足语法分析,其实还有很大的优化空间。
所以,为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化。完成这个任务的编译器,就称为即时编译器(Just In Time Compiler),简称 JIT 编译器。

2、热点代码

热点代码,就是那些被频繁调用的代码,比如调用次数很高或者在 for 循环里的那些代码。这些再次编译后的机器码会被缓存起来,以备下次使用,但对于那些执行次数很少的代码来说,JIT这种编译动作就纯属浪费。
JVM 提供了一个参数“-XX:ReservedCodeCacheSize”,用来限制 CodeCache 的大小。也就是说,JIT 编译后的代码都会放在 CodeCache 里。
如果这个空间不足,JIT 就无法继续编译,编译执行会变成解释执行,性能会降低一个数量级。同时,JIT 编译器会一直尝试去优化代码,从而造成了 CPU占用上升。

3、热点探测

在 HotSpot 虚拟机中的热点探测是 JIT 优化的条件,热点探测是基于计数器的热点探测,采用这种方法的虚拟机会为每个方法建立计数器统计方法的执行次数,如果执行次数超过一定的阈值就认为它是“热点方法” 。虚拟机为每个方法准备了两类计数器:方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter)。在确定虚拟机运行参数的前提下,这两个计数器都有一个确定的阈值,当计数器超过阈值溢出了,就会触发 JIT 编译。

  • 方法调用计数器
    用于统计方法被调用的次数,方法调用计数器的默认阈值在 C1 模式下是 1500 次,在 C2 模式下是 10000 次,可通过 -XX: CompileThreshold 来设定;
    而在分层编译的情况下,-XX: CompileThreshold 指定的阈值将失效,此时将会根据当前待编译的方法数以及编译线程数来动态调整。当方法计数器和回边计数器之和超过方法计数器阈值时,就会触发 JIT 编译器。
  • 回边计数器
    用于统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为“回边”(Back Edge),该值用于计算是否触发 C1 编译的阈值,在不开启分层编译的情况下,C1 默认为 13995,C2 默认为 10700,可通过 -XX: OnStackReplacePercentage=N 来设置;而在分层编译的情况下,-XX:OnStackReplacePercentage 指定的阈值同样会失效,此时将根据当前待编译的方法数以及编译线程数来动态调整。
    建立回边计数器的主要目的是为了触发 OSR(On StackReplacement)编译,即栈上编译。在一些循环周期比较长的代码段中,当循环达到回边计数器阈值时,JVM 会认为这段是热点代码,JIT 编译器就会将这段代码编译成机器语言并缓存,在该循环时间段内,会直接将执行代码替换,执行缓存的机器语言。

二、深入探究JVM类加载机制

虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制
与那些在编译时需要进行连接工作的语言不同,在Java语言里面,类型的加载、连接和初始化过程都是在程序运行期间完成的,这种策略虽然会令类加载时稍微增加一些性能开销,但是会为Java应用程序提供高度的灵活性,Java里天生可以动态扩展的语言特性就是依赖运行期动态加载和动态连接这个特点实现的。

1、类的生命周期

1.1 类生命周期的七个阶段

类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)7 个阶段。其中验证、准备、解析 3 个部分统称为连接(Linking)
JVM学习总结(六):深入探究JVM类加载机制及JIT简介_第1张图片

1.2 阶段顺序

加载、验证、准备、初始化和卸载这5个阶段的顺序是确定的,类的加载过程必须按照这种顺序按部就班地开始,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定(也称为动态绑定或晚期绑定)。(注意,这里说的是按部就班地“开始”,而不是按部就班地“进行”或“完成”,强调这点是因为这些阶段通常都是互相交叉地混合式进行的,通常会在一个阶段执行的过程中调用、激活另外一个阶段)

1.3 加载的时机

什么时候需要开始类第一个阶段“加载”,虚拟机规范没有强制约束,这点交给虚拟机的具体实现来自由把控。但是对于初始化阶段,虚拟机规范则是严格规定了有且只有6种情况必须立即对类进行“初始化”(而加载、验证、准备自然需要在此之前开始):

  • 遇到new、getstatic、putstatic或invokestatic这4条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化
    生成这4条指令的最常见的Java代码场景是:使用new关键字实例化对象的时候、读取或设置一个类的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候。
  • 使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。
  • 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
  • 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
  • 当使用JDK 1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。
  • 当一个接口中定义了被 default 关键字修饰的方法(JDK1.8 新加入的默认方法)时,如果这个接口的实现类发生了初始化,那该接口要在其之前被初始化。

1.4 类加载过程

接下来我们详细讲解一下Java虚拟机中类加载的全过程,也就是加载、验证、准备、解析和初始化这5个阶段所执行的具体动作。

1.4.1 加载

“加载 loading”阶段是整个类加载(class loading)过程的一个阶段。
加载阶段虚拟机需要完成以下 3 件事情:
1)通过一个类的全限定名来获取定义此类的二进制字节流。
2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
3)在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口。

注意:这里说的“二进制字节流”没有指定一定得从某个 class 文件中获取,可以从 zip 压缩包获取、网络中获取、运行时计算生成、数据库中读取、或者从加密文件中获取等等。
我们可以通过 JHSDB 看到,JVM 启动后,相关的类已经加载进入了方法区,成为了方法区的运行时结构。

下面通过JHSDB来查看类的加载:

  • 启动JVMObject
    JVM学习总结(六):深入探究JVM类加载机制及JIT简介_第2张图片

  • Attarch 上 JVM 启动的进程

  • 打开 Class Browser
    JVM学习总结(六):深入探究JVM类加载机制及JIT简介_第3张图片

  • 可以看到很多 class 已经被加载进来了

  • 找到 JVMObject,注意!这里已经是内存了,所以说相关的类已经加载进入了方法区,成为了方法区的运行时结构。
    JVM学习总结(六):深入探究JVM类加载机制及JIT简介_第4张图片

1.4.2 验证

是连接阶段的第一步,这一阶段的目的是为了确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。但从整体上看,验证阶段大致上会完成下面 4 个阶段的检验动作:文件格式验证、元数据验证、字节码验证、符号引用验证。

  • 文件格式验证
    第一阶段要验证字节流是否符合 Class 文件格式的规范,并且能被当前版本的虚拟机处理。这阶段的验证是基于二进制字节流进行的,只有通过了这个阶段的验证之后,这段字节流才被允许进人 Java 虚拟机内存的方法区中进行存储,所以后面的三个验证阶段全部是基于方法区的存储结构(内存)上进行的,不会再直接读取、操作字节流了。
  • 元数据验证
    第二阶段是对字节码描述的信息进行语义分析,以保证其描述的信息符合《Java 语言规范》的要求,这个阶段可能包括的验证点如下:
    1)这个类是否有父类(除了 java.lang.Object 之外,所有的类都应当有父类)
    2)这个类的父类是否继承了不允许被继承的类(被 final 修饰的类)。
    3)如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法
    4)类中的字段、方法是否与父类产生矛盾(例如覆盖了父类的 final 字段,或者出现不符合规则的方法重载,例如方法参数都-致,但返回值类型却不同等)
    5)…
  • 字节码验证
    字节码验证第三阶段是整个验证过程中最复杂的一个阶段, 主要目的是通过数据流分析和控制流分析,确定程序语义是合法的、符合逻辑的。在第二阶段对元数据信息中的数据类型校验完毕以后,这阶段就要对类的方法体(Class 文件中的 Code 属性)进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的行为,例如:
    1)保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,例如不会出现类似于“在操作栈放置了一个 int 类型的数据,使用时却按 long 类型来加载入本地变量表中”这样的情况。
    2)保证任何跳转指令都不会跳转到方法体以外的字节码指令上
    3)保证方法体中的类型转换总是有效的,例如可以把-个子类对象赋值给父类数据类型,这是安全的,但是把父类对象赋值给子类数据类型,甚至把对象赋值给与它毫无继承关系、完全不相干的一个数据类型,则是危险和不合法的
    4)…
  • 符号引用验证
    最后一个阶段的校验行为发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段——解析阶段中发生。符号引用验证可以看作是对类自身以外(常量池中的各种符号引用)的各类信息进行匹配性校验,通俗来说就是,该类是否缺少或者被禁止访问它依赖的某些外部类、方法、字段等资源。本阶段通常需要校验下列内容:
    1)符号引用中通过字符串描述的全限定名是否能找到对应的类。
    2)在指定类中是否存在符合方法的字段描述符及简单名称所描述的方法和字段。
    3)符号引用中的类、字段、方法的可访问性( private、 protected. public、 )
    4)是否可被当前类访问
    5)…
小结

验证阶段对于虚拟机的类加载机制来说,是一个非常重要的、 但却不是必须要执行的阶段,因为验证阶段只有通过或者不通过的差别,只要通过了验证,其后就对程序运行期没有任何影响了。如果程序运行的全部代码(包括自己编写的、第三方包中的、从外部加载的、动态生成的等所有代码)都已经被反复使用和验证过,在生产环境的实施阶段就可以考虑使用-Xverify:none 参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。
JVM学习总结(六):深入探究JVM类加载机制及JIT简介_第5张图片

1.4.3 准备

准备阶段是正式为类中定义的变量(被 static 修饰的变量)分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。
这个阶段中有两个容易产生混淆的概念需要强调一下:
首先,这时候进行内存分配的仅包括类变量(被 static 修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在 Java 堆中。
其次,这里所说的初始值“通常情况”下是数据类型的零值,假设一个类变量的定义为:
public static int value=123;那变量 value 在准备阶段过后的初始值为 0 而不是 123,因为这时候尚未开始执行任何 Java 方法,而把 value 赋值为 123 是后续的初始化环节。
基本数据类型的零值表
JVM学习总结(六):深入探究JVM类加载机制及JIT简介_第6张图片

1.4.4 解析

解析阶段是 JVM 将常量池内的符号引用替换为直接引用的过程。
符号引用是一种定义,可以是任何字面上的含义,而直接引用就是直接指向目标的指针、相对偏移量。
直接引用的对象都存在于内存中,你可以把通讯录里的女友手机号码,类比为符号引用,把面对面和你吃饭的女朋友,类比为直接引用。

解析大体可以分为:(不重要)

  • 类或接口的解析
  • 字段解析
  • 类方法解析
  • 接口方法解析

我们了解几个经常发生的异常,就与这个阶段有关。
在这里插入图片描述

1.4.5 初始化

初始化主要是对一个 class 中的 static{}语句进行操作(对应字节码就是 clinit 方法)。
<clinit>()方法对于类或接口来说并不是必需的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成<clinit>()方法。
初始化阶段,虚拟机规范则是严格规定了有且只有 6 种情况必须立即对类进行“初始化”(而加载、验证、准备自然需要在此之前开始):
1)遇到 new、getstatic、putstatic 或 invokestatic 这 4 条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。生成这 4 条指令的最常见的Java 代码场景是:

  • 使用 new 关键字实例化对象的时候。
  • 读取或设置一个类的静态字段(被 final 修饰、已在编译期把结果放入常量池的静态字段除外)的时候
  • 调用一个类的静态方法的时候。

2)使用 java.lang.reflect 包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。
3)当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
4)当虚拟机启动时,用户需要指定一个要执行的主类(包含 main()方法的那个类),虚拟机会先初始化这个主类。
5)当使用 JDK 1.7 的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果 REF_getStatic、REF_putStatic、REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。
6)当一个接口中定义了被 default 关键字修饰的方法(JDK1.8 新加入的默认方法)时,如果这个接口的实现类发生了初始化,那该接口要在其之前被初始化。

案例分析

案例代码

/**
 * 初始化的各种场景
 * 通过VM参数可以观察操作是否会导致子类的加载 -XX:+TraceClassLoading
 **/
public class Initialization {
	public static void main(String[]args){
		Initialization initialization = new Initialization();
		initialization.M1();//打印子类的静态字段
		initialization.M2();//使用数组的方式创建
		initialization.M3();//打印一个常量
		initialization.M4();//如果使用常量去引用另外一个常量
	}
	public void M1(){
		//如果通过子类引用父类中的静态字段,只会触发父类的初始化,而不会触发子类的初始化(但是子类会被加载)
		System.out.println(SubClaszz.value);
	}
	public void M2(){
		//使用数组的方式, 不会触发初始化(触发父类加载,不会触发子类加载)
		SuperClazz[]sca = new SuperClazz[10];
	}
	public void M3(){
		//打印一个常量,不会触发初始化(同样不会触类加载、编译的时候这个常量已经进入了自己class的常量池)
		System.out.println(SuperClazz.HELLOWORLD);
	}
	public void M4(){
		//如果使用常量去引用另外一个常量(会不会初始化SuperClazz  1  不会走2)
		System.out.println(SuperClazz.WHAT);
	}
}
/**
 * 父类
 */
public class SuperClazz {
	static 	{
		System.out.println("SuperClass init!");
	}
	public static int value=123;
	public static final String HELLOWORLD="hello king";
	public static final int WHAT = value;
}
/**
 * 子类
 */
public class SubClaszz extends SuperClazz {
	static{
		System.out.println("SubClass init!");
	}

}

案例1
——代码及运行结果
JVM学习总结(六):深入探究JVM类加载机制及JIT简介_第7张图片
——结果解析
如果通过子类引用父类中的静态字段,只会触发父类的初始化,而不会触发子类的初始化(但是子类会被加载)
JVM学习总结(六):深入探究JVM类加载机制及JIT简介_第8张图片
JVM学习总结(六):深入探究JVM类加载机制及JIT简介_第9张图片
JVM学习总结(六):深入探究JVM类加载机制及JIT简介_第10张图片
案例2
——代码及运行结果
JVM学习总结(六):深入探究JVM类加载机制及JIT简介_第11张图片
——结果解析
使用数组的方式, 不会触发初始化(触发父类加载,不会触发子类加载)
JVM学习总结(六):深入探究JVM类加载机制及JIT简介_第12张图片
案例 3
——代码及运行结果
JVM学习总结(六):深入探究JVM类加载机制及JIT简介_第13张图片
——结果解析
打印一个常量,不会触发初始化,同样不会触类加载,因为编译的时候这个常量已经进入了自己class的常量池。
JVM学习总结(六):深入探究JVM类加载机制及JIT简介_第14张图片
JVM学习总结(六):深入探究JVM类加载机制及JIT简介_第15张图片
案例 4
——代码及运行结果

public class SuperClazz {
	static 	{
		System.out.println("SuperClass init!");
	}
	public static int value=123;
	public static final String HELLOWORLD="hello king";
	public static final int WHAT = value;
}

JVM学习总结(六):深入探究JVM类加载机制及JIT简介_第16张图片
——结果解析
如果使用常量去引用另外一个常量(这个值编译时无法确定,所以必须要触发初始化)
JVM学习总结(六):深入探究JVM类加载机制及JIT简介_第17张图片

2、类加载器

虚拟机设计团队把类加载阶段中的“通过一个类的全限定名来获取描述此类的二进制字节流”这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需要的类。实现这个动作的代码模块称为“类加载器”。

2.1 JVM中类加载器的分类

2.1.1 JDK 提供的三层类加载器
  • 启动类加载器(Bootstrap ClassLoader)
    这是加载器中的扛把子,任何类的加载行为,都要经它过问。它的作用是加载核心类库,也就是 rt.jar、resources.jar、charsets.jar 等。当然这些 jar 包的路径是可以指定的,-Xbootclasspath 参数可以完成指定操作。
    这个加载器是 C++ 编写的,是虚拟机自身的一部分,随着 JVM 启动。
  • 扩展类加载器(Extension ClassLoader)
    扩展类加载器,主要用于加载 lib/ext 目录下的 jar 包和 .class 文件。同样的,通过系统变量 java.ext.dirs 可以指定这个目录。
    这个加载器是个 Java 类,继承自 URLClassLoader。
  • 应用程序类加载器(Application ClassLoader)
    这是我们写的 Java 类的默认加载器,有时候也叫作 System ClassLoader。一般用来加载 classpath 下的其他所有 jar 包和 .class 文件,我们写的代码,会首先尝试使用这个类加载器进行加载。
2.1.2 自定义类加载器(Custom ClassLoader)

自定义加载器,支持一些个性化的扩展功能。

2.2 类加载器的“问题”?

如果你在项目代码里,写一个 java.lang 的包,然后改写 String 类的一些行为,编译后,发现并不能生效。JRE 的类当然不能轻易被覆盖,否则会被别有用心的人利用,这就太危险了。
对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在 Java 虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。这句话可以表达得更通俗一些:比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个 Class 文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。这里所指的“相等”,包括代表类的 Class 对象的 equals()方法、isAssignableFrom()方法、isInstance()方法的返回结果,也包括使用 instanceof 关键字做对象所属关系判定等情况。

2.3 双亲委派模型

双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。这里类加载器之间的父子关系一般不会以继承(Inheritance)的关系来实现,而是都使用组合(Composition)关系来复用父加载器的代码。

双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。

使用双亲委派模型来组织类加载器之间的关系,有一个显而易见的好处就是 Java 类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类java.lang.Object,它存放在 rt.jar 之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此 Object 类在程序的各种类加载器环境中都是同一个类。相反,如果没有使用双亲委派模型,由各个类加载器自行去加载的话,如果用户自己编写了一个称为java.lang.Object 的类,并放在程序的 ClassPath 中,那系统中将会出现多个不同的 Object 类,Java 类型体系中最基础的行为也就无法保证,应用程序也将会变得一片混乱。
JVM学习总结(六):深入探究JVM类加载机制及JIT简介_第18张图片
我们可以查看 JDK 源码的 ClassLoader#loadClass 方法,来看一下具体的加载过程。和我们描述的一样,它首先使用 parent 尝试进行类加载,parent 失败后才轮到自己。同时,我们也注意到,这个方法是可以被覆盖的,也就是双亲委派机制并不一定生效。

protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
        synchronized (getClassLoadingLock(name)) {
            // 首先,查找该类是否已经被加载过了
            Class c = findLoadedClass(name);
            if (c == null) {  //未被加载过
                long t0 = System.nanoTime();
                try {
                    if (parent != null) {  // 父类加载器不为null,则调用父类加载器尝试加载
                        c = parent.loadClass(name, false);
                    } else {   // 父类加载器为null,则调用本地方法,交由启动类加载器加载,所以说ExtClassLoader的父类加载器为Bootstrap ClassLoader
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                }
                if (c == null) { //仍然加载不到,只能由本加载器通过findClass去加载
                    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;
        }
    }

3、自定义类加载器——可以打破双亲委派模型

3.1 Tomcat 类加载机制

tomcat 通过 war 包进行应用的发布,它其实是违反了双亲委派机制原则的。简单看一下 tomcat 类加载器的层次结构。
JVM学习总结(六):深入探究JVM类加载机制及JIT简介_第19张图片
那么 tomcat 是怎么打破双亲委派机制的呢?可以看图中的 WebAppClassLoader,它加载自己目录下的 .class 文件,并不会传递给父类的加载器(如果传递给父类的加载器,即使用双亲委派模型,那么会导致两个不同版本中的相同的类只会被加载一次,使得彼此互相影响,这样就起不到隔离应用的作用)。
JVM学习总结(六):深入探究JVM类加载机制及JIT简介_第20张图片
JVM学习总结(六):深入探究JVM类加载机制及JIT简介_第21张图片
在这里插入图片描述
但是,WebAppClassLoader是可以使用 SharedClassLoader 所加载的类,实现了共享和分离的功能。
如果是你自己写一个 ArrayList,放在应用目录里,tomcat 依然不会加载。它只是自定义的加载器顺序不同,但对于顶层来说,还是一样的。
【参考文档】
图解Tomcat类加载机制(阿里面试题)
深入源码——探究Tomcat的类加载机制

小结

tomcat的类加载机制是违反了双亲委托原则的,对于一些未加载的非基础类(Object,String等),各个web应用自己的类加载器(WebAppClassLoader)会优先加载,加载不到时再交给commonClassLoader走双亲委派。具体的加载逻辑位于WebAppClassLoaderBase.loadClass()方法中,这里以文字描述加载一个类的过程:

  1. 先在本地缓存中查找是否已经加载过该类(对于一些已经加载了的类,会被缓存在resourceEntries这个数据结构中),如果已经加载即返回,否则继续下一步。
  2. 让系统类加载器(AppClassLoader)尝试加载该类,主要是为了防止一些基础类会被web中的类覆盖,如果加载到即返回,否则继续。
  3. 前两步均没加载到目标类,那么web应用的类加载器将自行加载,如果加载到则返回,否则继续下一步。
  4. 最后还是加载不到的话,则委托父类加载器(Common ClassLoader)去加载。

第3第4两个步骤的顺序已经违反了双亲委托机制,除了tomcat之外,JDBC,JNDI,Thread.currentThread().setContextClassLoader();等很多地方都一样是违反了双亲委托。

3.2 SPI

Java 中有一个 SPI 机制,全称是 Service Provider Interface,是 Java 提供的一套用来被第三方实现或者扩展的 API,它可以用来启用框架扩展和替换组件。
这个说法可能比较晦涩,但是拿我们常用的数据库驱动加载来说,就比较好理解了。在使用 JDBC 写程序之前,通常会调用下面这行代码,用于加载所需要的驱动类。
Class.forName(“com.mysql.jdbc.Driver”)
这只是一种初始化模式,通过 static 代码块显式地声明了驱动对象,然后把这些信息,保存到底层的一个 List 中。这种方式我们不做过多的介绍,因为这明显就是一个接口编程的思路(这里不进行细讲)。
但是你会发现,即使删除了 Class.forName 这一行代码,也能加载到正确的驱动类,什么都不需要做,非常的神奇,它是怎么做到的呢?
MySQL 的驱动代码,就是在这里实现的。
路径:mysql-connector-java-8.0.11.jar!/META-INF/services/java.sql.Driver
里面的内容是:com.mysql.cj.jdbc.Driver
JVM学习总结(六):深入探究JVM类加载机制及JIT简介_第22张图片
通过在 META-INF/services 目录下,创建一个以接口全限定名为命名的文件(内容为实现类的全限定名),即可自动加载这一种实现,这就是 SPI。
SPI 实际上是“基于接口的编程+策略模式+配置文件”组合实现的动态加载机制,主要使用 java.util.ServiceLoader 类进行动态装载。
JVM学习总结(六):深入探究JVM类加载机制及JIT简介_第23张图片
这种方式,同样打破了双亲委派的机制。
DriverManager 类和 ServiceLoader 类都是属于 rt.jar 的。它们的类加载器是 Bootstrap ClassLoader,也就是最上层的那个。
而具体的数据库驱动,却属于业务代码,这个启动类加载器是无法加载的。这就比较尴尬了,虽然凡事都要祖先过问,但祖先没有能力去做这件事情,怎么办?
跟踪代码,来看一下。
JVM学习总结(六):深入探究JVM类加载机制及JIT简介_第24张图片
JVM学习总结(六):深入探究JVM类加载机制及JIT简介_第25张图片
JVM学习总结(六):深入探究JVM类加载机制及JIT简介_第26张图片
通过代码可以发现它把当前的类加载器,设置成了线程的上下文类加载器。那么,对于一个刚刚启动的应用程序来说,它当前的加载器是谁呢?也就是说,启动 main 方法的那个加载器,到底是哪一个?
继续跟踪代码。找到 Launcher 类,就是 jre 中用于启动入口函数 main 的类。我们在 Launcher 中找到以下代码。
JVM学习总结(六):深入探究JVM类加载机制及JIT简介_第27张图片
到此为止,事情就比较明朗了,当前线程上下文的类加载器,是应用程序类加载器。使用它来加载第三方驱动。

3.3 OSGi(了解)

OSGi 曾经非常流行,Eclipse 就使用 OSGi 作为插件系统的基础。OSGi 是服务平台的规范,旨在用于需要长运行时间、动态更新和对运行环境破坏最小的系统。
OSGi 规范定义了很多关于包生命周期,以及基础架构和绑定包的交互方式。这些规则,通过使用特殊 Java 类加载器来强制执行,比较霸道。
比如,在一般 Java 应用程序中,classpath 中的所有类都对所有其他类可见,这是毋庸置疑的。但是,OSGi 类加载器基于 OSGi 规范和每个绑定包的manifest.mf 文件中指定的选项,来限制这些类的交互,这就让编程风格变得非常的怪异。但我们不难想象,这种与直觉相违背的加载方式,这些都是由专用的类加载器来实现的。
随着 JPMS 的发展(JDK9 引入的,旨在为 Java SE 平台设计、实现一个标准的模块系统),现在深入研究 OSGi意义已经不是很大了。OSGi 是一个庞大的话题(技术上),你只需要了解到,有这么一个复杂的东西,实现了模块化,每个模块可以独立安装、启动、停止、卸载,就可以了。
一点小建议(纯属个人见解):
OSGI 一般的公司玩不转,都是阿里这些大公司在用。从大家研究技术的角度上来,就算你去这些公司,再去学习也没问题(阿里不可能要求一个小厂出来的程序员对 OSGI 精通)。主要精力还是把放在类加载、双亲委派,以及如何打破这些问题解决即可。

后语

本文是笔者对于JVM学习的归纳总结,一方面是为了便于以后的学习复盘,另一方面就是为了分享交流,共同学习,共同进步,由于笔者能力有限,如有错误之处,还望各位大佬多多指正~~

你可能感兴趣的:(#,JVM,jvm,java)