JVM2:类加载机制、class文件加载方式;类加载的过程:装载、链接、初始化、使用、卸载;类加载器、为什么类加载器要分层?JVM类加载机制的三种方式:全盘负责、父类委托、缓存机制;自定义类加载器

JVM2:类加载机制、class文件加载方式;类加载的过程:装载、链接、初始化、使用、卸载;类加载器、为什么类加载器要分层?JVM类加载机制的三种方式:全盘负责、父类委托、缓存机制;自定义类加载器

  • 类加载机制
    • class文件加载方式
  • 类加载过程
    • 官网了解类加载的过程:装载、链接、初始化、使用、卸载
      • 1.装载(Load)
      • 2.链接(Link)
        • 验证阶段(Verify)
        • 准备阶段(Prepare)
        • 解析阶段(Resolve)
          • 符号引用和直接引用
          • 对解析结果进行缓存,lambda表达式除外
      • 3.类的初始化(Initialize)
      • 4.使用
      • 5.卸载
  • 类加载机制应该做的事图示
  • 类加载器(ClassLoader)
    • 什么是类加载器
    • 为什么类加载器要分层?
    • JVM类加载机制的三种方式
      • 1.全盘负责
      • 2.父类委托
      • 3. 缓存机制
        • ClassLoader源码分析:为什么对于类加载器来说,相同的全限定名,永远只加载一次?
      • 打破双亲委派
    • 自定义类加载器
    • Tomcat的自定义类加载器
  • 汇总

类加载机制

类加载机制:指我们将类的字节码文件所包含的数据读入内存,同时我们会生成数据的访问入口的一种特殊机制。那么我们可以得知,类加载的最终产品是数据访问入口。
JVM2:类加载机制、class文件加载方式;类加载的过程:装载、链接、初始化、使用、卸载;类加载器、为什么类加载器要分层?JVM类加载机制的三种方式:全盘负责、父类委托、缓存机制;自定义类加载器_第1张图片
那么字节码加载的方式有哪些?或者说字节码文件可以用什么方式进行加载呢?

class文件加载方式

加载.class文件的方式:

  1. 从本地系统中加载
  2. 通过网络下载.class文件。(小程序的包)
  3. 从归档文件中加载.class文件。(归档文件,包括jar、war、zip包去提取,都是可以的)
  4. 从专有数据库中提取.class文件。(jsp中会有这样的案例,但是很少)
  5. 将Java源文件动态编译为.class文件,也就是运行时计算,即动态代理
  6. 从加密文件中获取,也就是防止.class文件被反编译,从而直接获取到了流转信息,因此会对文件进行加密

类加载的方式已经了解了,接下来就可以了解类加载的过程了。

类加载过程

类加载过程:就是将Javac编译后的class文件交给JVM去run的过程,也就是类加载器找对应的.class文件加载到JVM的过程。

官网了解类加载的过程:装载、链接、初始化、使用、卸载

在这里插入图片描述
.class文件交给JVM run的过程,即类加载过程(类的生命周期),这里面就涉及到面试经常问的类加载机制。顾名思义,就是把类加载到JVM当中去。官方文档由这块内容的说明是:装载、链接、初始化,位置在第5章节:https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-5.html。

类加载过程(类的生命周期)包括以下几个阶段,装载、链接、初始化、使用、卸载。请添加图片描述
装载、验证、准备,初始化,这四个阶段的发生顺序是给固定的,而解析阶段不一定,因为有些情况可以在初始化完成之后才会进行解析操作,因为Java中有一个特殊的场景,叫运行时绑定,也叫晚期绑定。

装载、验证、准备,初始化,这几个阶段是按照顺序开始,但不一定按照顺序结束。因为这些阶段通常交叉混合进行,包括验证。

其实在整个类加载的过程中间,除了加载阶段,我们的用户、应用程序可以自定义类加载器以及我们用Java agent增强字节码以外,其它所有动作都由JVM主导,以及控制。

所以说,到了初始化才开始执行类中定义的Java程序代码(或者称之为字节码),也就是说执行代码在初始化这里仅仅只是开端,仅限于class init()方法。

类加载的过程,主要是将字节码文件,加载到虚拟机当中。

1.装载(Load)

装载就是查找和导入class文件到内从中去的过程。
JVM2:类加载机制、class文件加载方式;类加载的过程:装载、链接、初始化、使用、卸载;类加载器、为什么类加载器要分层?JVM类加载机制的三种方式:全盘负责、父类委托、缓存机制;自定义类加载器_第2张图片
装载可以分成3个步骤:

  1. 通过一个类的全限定名获取定义此类的二进制字节流
    字节码文件需要读取到内从中去,首先需要做的是先找到这个字节码文件,要想找到字节码文件,则这个字节码文件一定是一个流文件,所以第一步一定是:字节码文件==>字节流文件。根据名字去找对应的.class文件,可能不同包下有重名的,因此是根据Java源文件的全限定名获取这个类的二进制字节流,而能够获取到二进制字节流的前提是,二进制文件已经变成了二进制字节流,这个时候我们是不是需要一个工具,寻找器,来寻找获取我们的二进制字节流。而我们的java中恰好有这么一段代码模块。可以实现通过类全名来获取此类的二进制字节流这个动作,并且将这个动作放到放到java虚拟机外部去实现,以便让应用程序决定如何获取所需要的类,实现这个动作的代码模块成为“类加载器”
  2. 方法区的操作
    在内存中划分出一个区域,叫方法区,将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构(类信息,静态变量,常量),因为这个时候需要run起来了
  3. 堆的操作
    在Java堆中生成一个代表这个类的java.lang.Class对象,作为对方法区中这些数据的访问入口。有了方法区,还需要生成一个数据访问入口,而这个数据访问入口同时也可以在内存当中生成。在内存中间生成一个代表这些数据可以被访问到的这些数据的访问入口,这个时候用一个数据结构来装,叫做堆,即生成一个class对象作为代表这个类的数据访问入口,这个时候装载就完成了

总结: 装载阶段完成之后,这个时候在我们的内存当中,我们的运行时数据区的方法区以及堆就已经有数据了。
方法区:类信息,静态变量,常量
堆:堆内存中代表被加载类的java.lang.Class对象
即时编译之后的热点代码并不在这个阶段进入方法区

装载(获取类的二进制字节流的阶段)是我们JAVA程序员最关注的阶段,也是操控性最强的一个阶段。因为这个阶段我们可以对于我们的类加载器进行操作,比如我们想自定义类加载器进行操作用以完成加载,又或者我们想通过JAVA Agent(Java Agent常用来做架构,写开源组件等,用于在装载阶段做字节码增强)来完成我们的字节码增强操作。

2.链接(Link)

链接可以细分为三个阶段,验证、准备、解析。

验证阶段(Verify)

验证就是为了确保所谓的字节码文件中的字节流的信息包含的信息完全符合当前JVM的规范要求,即①会验证字节码文件不能出错,②并且信息不能危害JVM自身的安全,这是两个动作。

文件格式验证
文件格式验证:发生在进入方法区之前,只有经过这个阶段的验证之后,字节流才会进入内存的方法区区进行储存。而后面的验证都不是基于方法区的储存来进行验证,而是基于方法区的储存结构区进行验证。
比如:

  1. 是否以16进制CAFEBABE开头
  2. 版本号是否正确

元数据验证
元数据验证:对类的元数据信息进行语义校验,这个元数据校验是校验的Java语法,这个校验能保证的是,不符合Java语法规范的元数据信息,无法进入到方法区。
比如:

  1. 是否有父类
  2. 是否继承了final类(final类不能被继承,继承会有问题)
  3. 一个非抽象类是否实现了所有的抽象方法

字节码验证
字节码验证:进行数据流和控制流的分析,主要校验数据对JVM的危害,并不一定语法错误
比如:

  1. 运行检查
  2. 栈数据类型和操作码操作参数是否吻合

符号引用验证
符号引用验证:这是最后一个阶段的验证,发生在符号引用转化为直接引用的时候,即解析阶段。对类自身以外的信息进行验证,比如常量池的各项引用,这个不算是类信息,而是包含的各种符号引用后进行匹配性的校验,是为了确保解析动作能够正常执行,因此来进行的验证。
比如:

  1. 常量池中描述类是否存在
  2. 访问的方法或者字段是否存在且具有足够的权限

准备阶段(Prepare)

方法区中为类变量(类变量就是静态变量,static修饰的变量)分配内存并设置Java默认的初始值,即分配零值 。
这个时候,一般情况下,类变量的默认初始值,也就是当前类型的零值会有区别,如下:

数据类型 零值
int 0
long 0L
short (short)0
char ‘\u0000’
byte (byte)0
boolean false
float 0.0f
double 0.0d
reference(引用类型) null
  • 这里不包含用final修饰的static,因为final在编译的时候就会分配了,准备阶段会显式初始化;
  • 这里不会为所谓的实例变量(指没有static修饰的)去分配空间,类变量(加static修饰的)会直接分配空间在方法区,而实例变量会随着所谓的对象一起分配到Java堆中。
private static int a = 1;

上面代码,在实际的类加载阶段,我们需要为它在内存上真正开辟一个空间,在准备阶段其实a = 0的,这里是不会为实例变量赋值我们代码定义的默认值1,而是赋零值,Java的int类型的默认值是0。

对于一些特殊情况,如果类字段属性表中存在ConstantValue属性,那在准备阶段这个变量就会被初始化为ContstantValue属性所指的值。对于这句话,我们又怎么理解呢?

ConstantValue属性到底是干什么的呢?
ConstantValue属性的作用是通知虚拟机自动为静态变量赋值,只有被static修饰的变量才可以使用这项属性。非static类型的变量的赋值是在实例构造器方法中进行的;static类型变量赋值分两种,在类构造其中赋值,或使用ConstantValue属性赋值。ConstantValue这个属性的作用,就是通知JVM自动为静态变量赋值。

在实际的程序中,我们什么时候才会用到ContstantValue属性呢?
在实际的程序中,只有同时被final和static修饰的字段才有ConstantValue属性,且限于基本类型和String。编译时Javac将会为该常量生成ConstantValue属性,在类加载的准备阶段虚拟机便会根据ConstantValue为常量设置相应的值,如果该变量没有被final修饰,或者该变量的类型并非基本类型及字符串,则选择在类构造器中进行初始化。

为什么ConstantValue的属性值只限于基本类型和string?
因为从常量池中只能引用到基本类型和String类型的字面量,这个也就是ConstantValue只能引到基本数据类型,以及String的原因。

假设上面的类变量a增加final修饰,定义为:

private final static int a = 1;

编译时Javac将会为a生成ConstantValue属性,在准备阶段虚拟机就会根据 ConstantValue 的设置将value赋值为1。我们可以理解为static final常量在编译期就将其结果放入了调用它的类的常量池中

public class TestJvm {

    private static final int A=1;// 编译时Javac实际上就会为A生成ConstantValue属性,在准备阶段,JVM就会根据ConstantValue它的值,将A赋值为1,可以理解为在编译期就已经将结果放入了调用它的常量池当中

    public static void main(String[] args) {
        System.out.println(A);
    }
}

在这里插入图片描述

完整的反编译之后的完整内容如下:

Classfile /D:/HAOKAI/haokai-framework/haokai-common/src/main/java/com/haokai/common/test/TestJvm.class
  Last modified 2023-5-29; size 452 bytes
  MD5 checksum 9ebfc3d20bdc8b726ca5249d1bad64fe
  Compiled from "TestJvm.java"
public class com.haokai.common.test.TestJvm
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #5.#18         // java/lang/Object."":()V
   #2 = Fieldref           #19.#20        // java/lang/System.out:Ljava/io/PrintStream;
   #3 = Class              #21            // com/haokai/common/test/TestJvm
   #4 = Methodref          #22.#23        // java/io/PrintStream.println:(I)V
   #5 = Class              #24            // java/lang/Object
   #6 = Utf8               A
   #7 = Utf8               I
   #8 = Utf8               ConstantValue
   #9 = Integer            1
  #10 = Utf8               <init>
  #11 = Utf8               ()V
  #12 = Utf8               Code
  #13 = Utf8               LineNumberTable
  #14 = Utf8               main
  #15 = Utf8               ([Ljava/lang/String;)V
  #16 = Utf8               SourceFile
  #17 = Utf8               TestJvm.java
  #18 = NameAndType        #10:#11        // "":()V
  #19 = Class              #25            // java/lang/System
  #20 = NameAndType        #26:#27        // out:Ljava/io/PrintStream;
  #21 = Utf8               com/haokai/common/test/TestJvm
  #22 = Class              #28            // java/io/PrintStream
  #23 = NameAndType        #29:#30        // println:(I)V
  #24 = Utf8               java/lang/Object
  #25 = Utf8               java/lang/System
  #26 = Utf8               out
  #27 = Utf8               Ljava/io/PrintStream;
  #28 = Utf8               java/io/PrintStream
  #29 = Utf8               println
  #30 = Utf8               (I)V
{
  private static final int A;
    descriptor: I
    flags: ACC_PRIVATE, ACC_STATIC, ACC_FINAL
    ConstantValue: int 1

  public com.haokai.common.test.TestJvm();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."":()V
         4: return
      LineNumberTable:
        line 3: 0

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: iconst_1
         4: invokevirtual #4                  // Method java/io/PrintStream.println:(I)V
         7: return
      LineNumberTable:
        line 8: 0
        line 9: 7
}
SourceFile: "TestJvm.java"

解析阶段(Resolve)

在这里插入图片描述
解析就是将符号引用转变为直接引用。解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用限定符7类符号引用进行。

解析阶段就干了一件事情,把类中的符号引用,转换为直接引用。直接引用是和JVM内存布局相关的,同一个符号引用在不同的JVM上,实际上翻译出来的直接引用,一般不会相同;而符号引用在反编译的字节码文件中,就是用来描述引用的目标的,可以是任何的字面量。下图是截取了部分在反编译之后的文件的字面量展示:
在这里插入图片描述

符号引用和直接引用

符号引用:是在文件中的,并没有被加载到内存中,就是一组符号来描述目标,可以是任何字面量。引用的目标并不一定已经加载到了内存中。
直接引用:就是这些符号引用它要变成所谓的运行时数据结构,真正的指向目标的地址,它要储存一个指针。也就是说,直接引用就是直接指向目标的指针,对于在文件上中符号引用内容,为其真正的开辟内存,并指向它。也就是把文件上的东西真正在内存上做一个落地。简而言之,就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。

直接引用是与虚拟机内存布局实现相关,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同,如果有了直接引用,那引用的目标必定存在内存中,而符号引用则不一定。因为符号引用指的就是文件上的那些标识符,所以它叫符号。

对解析结果进行缓存,lambda表达式除外

同一符号引用进行多次解析请求是很常见的,除了动态invokedynamic指令以外(lambda表达式),JVM可以对第一次的解析结果进行缓存,也就是说,除了invokedynamic指令,只要是符号引用解析后变为直接引用,就会进行缓存,来避免解析动作重复进行。无论是否真正执行了多次解析动作,虚拟机需要保证的是
在同一个实体中,如果一个引用符号之前已经被成功解析过,那么后续这个解析就直接返回它成功的结果,也就是说解析请求就应当一直成功;同样的,如果第一次解析失败,那么其他指令对这个符号的解析请求也应该收到相同的异常。

inDy(invokedynamic)是 java 7 引入的一条新的虚拟机指令,这是自 1.0 以来第一次引入新的虚拟机指令。到了 java 8 这条指令才第一次在 java 应用,用在 lambda 表达式中。 indy 与其他 invoke 指令不同的是它允许由应用级的代码来决定方法解析,也就是说需要动态的去解析,因此加入了invokedynamic指令。

这块内容知道即可,符号引用内容会被缓存,而invokedynamic指令除外,也就是说lambda表达式除外,知道这个就行。

3.类的初始化(Initialize)

初始化阶段讲的简单点,就是执行class init()方法的过程,即执行类构造器()方法的过程。
初始化可以分为三种情况:类变量初始化、类初始化、有直接父类的类初始化。

类变量初始化:之前在准备阶段,类变量已经赋值过一次系统要求的默认值了,但是这是JVM给赋的零值,而在初始化阶段,需要赋值上程序员真正想要给到它的值。

在Java中对类变量设置初始值有两种方式:

  1. 指定类变量值:声明类变量时指定初始值
  2. 静态代码块:使用静态代码块为类变量指定初始值

按照程序员的逻辑来看,必须将静态变量定义在静态代码块之前。因为这两个的执行,是根据代码编写的顺序来的,也就是说需要把静态变量写在静态代码块之前,顺序不对可能会影响业务代码。

JVM初始化步骤:

  • 假如这个类还没有被加载和连接,则程序先加载并连接该类
  • 假如该类的直接父类还没有被初始化,则先初始化其直接父类
  • 假如类中有初始化语句,则系统依次执行这些初始化语句

类初始化:在对于类的初始化的时候,JVM是按需加载,用到的时候才会加载,假如这个类想要用到的时候,还没有进行装载和链接这两个步骤,需要先进行这两步才能继续初始化。

只有用到这个类的时候才回去初始化如果说这个类有直接父类,那么这个时候需要先初始化这个父类,才会去初始化子类。如果类中有初始化语句,还会一次执行这些初始化语句。

初始化过程什么时候会被触发执行呢?或者换句话说类初始化时机是什么呢?
只有用到这个类的时候才回去初始化。

4.使用

只有用到这个类的时候才回去初始化,因为是按需来的,即主动使用到的时候,才会导致类进行一个初始化,这叫类的主动引用

类的主动引用分为下面6中情况:

  1. 创建类的实例,也就是new的方式
  2. 访问某个类或接口的静态变量,或者对该静态变量赋值
  3. 调用类的静态方法
  4. 反射(如Class.forName(“com.aaa.Test”))
  5. 初始化某个类的子类,则其父类也会被初始化
  6. Java虚拟机启动时被标名为启动类的类(JvmCaseApplication,就是你带main方法的那个类),直接使用java.exe命令来运行某个主类、

类的被动引用:还会有一种情况,在不经意之间,有可能会用到其它的类,但是并不进行类的初始化,这种情况叫做类的被动引用 。
被动引用分为三种情况:

  1. 子类引用父类的静态字段,只会引起父类初始化,而不会引起子类初始化
  2. 引用类的static final常量,不会引起类的初始化,因为常量赋值是ConstantValue(如果只有static修饰,还是会引起该类初始化的)。
  3. 定义类组不会引起类的初始化

对于类的主动引用和被动引用面试也会问,笔试题也会有

5.卸载

类卸载的情况是非常非常少的,需要同时满足下面三种情况,才会被卸载:

  1. 该类所有的实例都已被回收,也就是Java堆中不存在该类的任何实例
  2. 加载该类的ClassLoader(ClassLoader是加载这个类的类加载器)已经被回收
  3. 该类对应的java.lang.Class对象没有任何地方引用,无法在任何地方通过反射访问该类的方法

JVM本身都会始终引用这些类加载器,而这些类加载器本身始终会引用它们所加载的类的Class对象,因此这些Class对象始终是可触及的,所以一般情况下,2和3的要求是很难达到的。
但是有一种情况会达到,比如说自定义的类加载器,有被卸载的需求,一般情况下会调用它的System.gc(),但是System.gc()只是通知JVM想去做一次GC操作,会通知GC进行回收,而并不是一调用,就会马上进行回收,因此一般在System.gc()之后,会睡个500ms~1000ms。由于卸载的时间是不确定的,一般要对类加载器进行自定义的话,一般对卸载这块是没有需求进行开发的。从正常业务代码的角度来看,是接触不到2和3的,因为卸载不了,正常代码中,一般也不会写到回收机制这块。

如果以上三个条件全部满足,jvm就会在方法区垃圾回收的时候对类进行卸载,类的卸载过程其实就是在方法区中清空类信息,java类的整个生命周期就结束了。但是一般情况下启动类加载器加载的类不会被卸载,而我们的其他两种基础类型的类加载器只有在极少数情况下才会被卸载。

类加载机制应该做的事图示

类加载器(ClassLoader)

什么是类加载器

  • 是负责读取Java字节码代码,并且将其转换成一个java.lang.Class这样的一个实例的一个代码模块。
  • 还有一个功效是用于确定类在虚拟机中的唯一性。什么意思呢?就是一个类,在加载到同一个类加载器中具有唯一性,不同的类加载器是允许同名类存在的,而相同的类加载器是不允许同名类存在的。

一个类在同一个类加载器中具有唯一性(Uniqueness),而不同类加载器中是允许同名类存在的,这里的同名是指全限定名相同。但是在整个JVM里,纵然全限定名相同,若类加载器不同,则仍然不算作是同一个类,无法通过instanceOf 、equals 等方式的校验。

比如你自己建一个java.lang.String这样一个类,也是可以正常加载的,这是因为类加载器是有分层的,类加载器是分级别的。
请添加图片描述

1)Bootstrap ClassLoader
负责加载$JAVA_HOME中 jre/lib/rt.jar 里所有的class或Xbootclassoath选项指定的jar包。由C++实现,不是ClassLoader子类。
2)Extension ClassLoader
负责加载java平台中扩展功能的一些jar包,包括 $JAVA_HOME 中jre/lib/*.jar 或 -Djava.ext.dirs 指定目录下的jar包。
3)App ClassLoader
负责加载classpath中指定的jar包及 Djava.class.path 所指定目录下的类和jar包。
4)Custom ClassLoader
通过java.lang.ClassLoader的子类自定义加载class,属于应用程序根据自身需要自定义的ClassLoader,如tomcat、jboss都会根据j2ee规范自行实现ClassLoader。

为什么类加载器要分层?

1.2版本的JVM中,只有一个类加载器,就是现在的“Bootstrap”类加载器。也就是根类加载器。但是这样会出现一个问题。
假如用户调用他编写的java.lang.String类。理论上该类可以访问和改变java.lang包下其他类的默认访问修饰符的属性和方法的能力。也就是说,我们其他的类使用String时也会调用这个类,因为只有一个类加载器,我无法判定到底加载哪个。因为Java语言本身并没有阻止这种行为,所以会出现问题。

这个时候,我们就想到,可不可以使用不同级别的类加载器来对我们的信任级别做一个区分呢?
比如用三种基础的类加载器做为我们的三种不同的信任级别。最可信的级别是java核心API类。然后是安装的拓展类,最后才是在类路径中的类(属于你本机的类)。
所以,我们三种基础的类加载器由此而生。但是这是我们开发人员的视角。

代码:

public class Demo {

    public static void main(String[] args) {
        // AppClassLoader
        System.out.println(new Demo().getClass().getClassLoader());
        // ExtClassLoader
        System.out.println(new Demo().getClass().getClassLoader().getParent());
        // Bootstrap ClassLoader
        System.out.println(new Demo().getClass().getClassLoader().getParent().getParent());
        System.out.println(new String().getClass().getClassLoader());

        /**
         * 输出结果如下:之所以还有null,是因为Bootstrap ClassLoader的本质是C层面的东西,在Java层面看不到
         * sun.misc.Launcher$AppClassLoader@18b4aac2
         * sun.misc.Launcher$ExtClassLoader@1e80bfe8
         * null
         * null
         */
    }
}

JVM类加载机制的三种方式

1.全盘负责

全盘负责机制也叫做当前类加载机制。当一个类加载器负责加载某个class的时候,它所依赖和引用的其它class都应该由当前的这个类加载器负责载入,除非是你显式要求使用另一个加载器,否则就是当前的加载器一起加载。

例如,系统类加载器AppClassLoader加载入口类(含有main方法的类)时,会把main方法所依赖的类及引用的类也载入,依此类推。“全盘负责”机制也可称为当前类加载器负责机制。显然,入口类所依赖的类及引用的类的当前类加载器就是入口类的类加载器。
以上步骤只是调用了ClassLoader.loadClass(name)方法,并没有真正定义类。真正加载class字节码文件生成Class对象由“双亲委派”机制完成。

2.父类委托

“双亲委派”是指子类加载器如果没有加载过该目标类,就先委托父类加载器加载该目标类,只有在父类加载器找不到字节码文件的情况下才从自己的类路径中查找并装载目标类。

父类委托别名就叫双亲委派机制(之所以叫双亲委派是翻译问题)。
“双亲委派”机制加载Class的具体过程是:

  1. ClassLoader先判断该Class是否已加载,如果已加载,则返回Class对象;如果没有则委托给父类加载器。
  2. 父类加载器判断是否加载过该Class,如果已加载,则返回Class对象;如果没有则委托给祖父类加载器。
  3. 依此类推,直到始祖类加载器(引用类加载器:Bootstrap ClassLoader)。
  4. 始祖类加载器判断是否加载过该Class,如果已加载,则返回Class对象;如果没有则尝试从其对应的类路径下寻找class字节码文件并载入。如果载入成功,则返回Class对象;如果载 入失败,则委托给始祖类加载器的子类加载器。
  5. 始祖类加载器的子类加载器尝试从其对应的类路径下寻找class字节码文件并载入。如果载入成功,则返回Class对象;如果载入失败,则委托给始祖类加载器的孙类加载器。
  6. 依此类推,直到源ClassLoader。
  7. 源ClassLoader尝试从其对应的类路径下寻找class字节码文件并载入。如果载入成功,则返 回Class对象;如果载入失败,源ClassLoader不会再委托其子类加载器,而是抛出异常。

比如你自定义了一个String类,负责加载的类加载器是AppClassLoader,那么它还是会往上再去寻找更高的信任级别的类加载器,ExtClassLoader,Bootstrap ClassLoader,如果说Bootstrap ClassLoader没有,才会去加载下面层级的。只有当父类的加载器找不到字节码文件的时候,才会从自己的类路径中查找并装载目标类。父类委托加载的具体过程:首先一定是判断顶层类是否被加载,如果顶层类有被加载的话,剩下的全都不管,永远只加载一个,永远只加载级别高的。

“双亲委派”机制只是Java推荐的机制,并不是强制的机制。我们可以继承java.lang.ClassLoader类,实现自己的类加载器。如果想保持双亲委派模型,就应该重写findClass(name)方法;如果想破坏双亲委派模型,可以重写loadClass(name)方法。

3. 缓存机制

缓存机制会保证所有加载过的class都在内存中进行缓存,当程序需要用某个class的时候,类加载器肯定首先优先从内存的缓存区域中进行寻找该块class,只有当缓存区不存在的时候,系统才会去读取类对应的二进制数据,再将它转换为对应的class对象,存入缓存区;如果有就不读,这就是为什么修改了Class后,必须重启JVM,程序的修改才会生效。对于一个类加载器实例来说,相同全名的类只加载一次,即 loadClass方法不会被重复调用

ClassLoader源码分析:为什么对于类加载器来说,相同的全限定名,永远只加载一次?

jdk8用到的是直接内存,也就是元空间,所以说会用到直接内存来做缓存,这也是为什么类变量指挥初始化一次。这个可以看ClassLoader的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) {// 之前有说过,有父类即代表parent不为空,因此会一直找上层
                        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);// 如果都没有,会调用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);
                /**
                * 该方法是字节码加载到内存中进行到链接操作,也就是说在这个方法中,
                * 会对文件格式以及字节码进行一个验证,并且在这里会为static修饰的开辟初始空间,
                * 即准备阶段也是在这里完成的,包括符号引用转换为直接引用,访问控制以及方法覆盖都是在这个方法里的
                */
            }
            return c;
        }
    }

打破双亲委派

父类委托模型实际上并不是一个强制模型,它会带来一些问题,比如说,Java中有一个类叫java.sql.Driver,驱动类,jdk只能提供一个这样的接口规范,不能提供实现,而提供实现的是我们的数据库厂商,而提供商的依赖库,总不能放在Bootstrap ClassLoader下面,它应该是属于扩展类的,也就是ExtClassLoader,而如果根据父类委托这个机制,则在Bootstrap ClassLoader区进行加载,只能加载到java.sql.Driver的接口,而不能加载到它的实现,因此需要打破父类委托机制。

所以java想到了几种办法可以用来打破我们的双亲委派。
SPI : 比如Java1.6中,有一种方式叫做SPI(Service Provide interface,服务提供接口),Java 在核心类库中定义了许多接口,并且还给出了针对这些接口的调用逻辑,然而并未给出实现。开发者要做的就是定制一个实现类,在 META-INF/services 中注册实现类信息,以供核心类库使用。比如比如JDBC中的DriverManager,jdk只要提供类似java.sql.Driver的接口,供应商提供服务,编程人员编码的时候,直接面向接口编程,直接去做实现,然后jdk又可以自动找到这个实现,包括Java在核心类库中定义了很多接口,并且我们会针对这些接口做调用逻辑。

OSGI: OSGI(Open Service Gateway Initiative) 技术是 Java 动态化模块化系统的一系列规范,它可以实现模块化热部署。OSGi实现模块化热部署的关键则是它自定义的类加载器机制的实现。每一个程序模块都有一个自己的类加载器,当需要更换一个程序模块时,就把程序模块连同类加载器一起换掉,以实现代码的热替换。这种方式能行是能行,但是太恶心了,基本上没人用。这个时候,还有一种方案,就是我们自定义类加载器。

自定义类加载器

合理使用自定义类加载器,最好不要重写loadClass()方法,findClass()方法最好也不要重新给,因为会破坏双亲委派机制。

package com.haokai.common.test;

import java.io.*;

public class MyClassLoader extends ClassLoader {
    private String root;

    /**
     * 核心在于字节吗文件的获取,这里如果字节码有加密。需要在这里进行相应的解密操作
     *
     * @return
     * @throws ClassNotFoundException
     */
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        byte[] classData = loadClassData(name);
        if (classData == null) {
            throw new ClassNotFoundException();
        } else {
            // 此方法负责将二进制的字节码转换为class对象
            return defineClass(name, classData, 0, classData.length);
        }
    }

    private byte[] loadClassData(String className) {
        String fileName = root +
                File.separatorChar + className.replace('.', File.separatorChar)
                + ".class";
        try {
            InputStream ins = new FileInputStream(fileName);
            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            int bufferSize = 1024;
            byte[] buffer = new byte[bufferSize];
            int length = 0;
            while ((length = ins.read(buffer)) != -1) {
                baos.write(buffer, 0, length);
            }
            return baos.toByteArray();
        } catch (IOException e) {
            e.printStackTrace();
        }
        return null;
    }

    public String getRoot() {
        return root;
    }

    public void setRoot(String root) {
        this.root = root;
    }

    public static void main(String[] args) {
        MyClassLoader loaderDemo1 = new MyClassLoader();
        MyClassLoader loaderDemo2 = new MyClassLoader();
        // 这里传入的一定是类的全限定名,也就是你存放该class文件的路径
        loaderDemo1.setRoot("D:\\HAOKAI\\haokai-framework\\haokai-common\\src\\main\\java");
        loaderDemo2.setRoot("D:\\classPath");
        Class<?> demo1Class = null;
        Class<?> demo2Class = null;
        try {
            // 这里传入的一定是类的全限定名,包括文件需要给到相应的权限
            demo1Class = loaderDemo1.loadClass("com.haokai.common.test.TestDemo");
            System.out.println(demo1Class);
            Object demo1 = demo1Class.newInstance();
            System.out.println(demo1.getClass().getClassLoader());

            // Demo2的java文件和class文件不放在放在类路径下
            demo2Class = loaderDemo2.loadClass("TestDemo");
            System.out.println(demo2Class);
            Object demo2 = demo2Class.newInstance();
            System.out.println(demo2.getClass().getClassLoader());
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        } catch (InstantiationException e) {
            e.printStackTrace();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        }
    }
}

自定义类加载器的核心在于对字节码文件的获取,如果是加密的字节码则需要在该类中对文件进行解
密,自定义类加载器常用于加密。由于这里只是演示,我并未对class文件进行加密,因此没有解密的过程。这里有几点需要注意:

  1. 这里传递的文件名需要是类的全限定性名称,因为 defineClass 方法是按这种格式进行处理的。
    如果没有全限定名,那么我们需要做的事情就是将类的全路径加载进去,而我们的setRoot就是前缀地址 setRoot + loadClass的路径就是文件的绝对路径
  2. 最好不要重写loadClass方法,因为这样容易破坏双亲委托模式。
  3. 类路径下的com.haokai.common.test.TestDemo类本身可以被 AppClassLoader 类加载,因此我们如果想要使用自定义类加载器进行加载,就不能把 TestDemo.class 放在类路径下。否则,由于双亲委托机制的存在,当前类路径下会优先使用父类加载器进行加载,会直接导致该类由AppClassLoader 加载,而不会通过我们自定义类加载器来加载。

运行时注意,需要手动先将Java文件编译成class文件,下面是两个TestDemo的代码

在这里插入图片描述
当前类路径下会优先使用父类加载器进行加载,会直接导致该类由AppClassLoader 加载,com.haokai.common.test.TestDemo,注意这里有包名

package com.haokai.common.test;

public class Demo2 {
    public static void main(String[] args) {
        System.out.println();
    }
}

使用自定义类加载器进行加载,不放在类路径下,自己定义一个路径,
在这里插入图片描述
TestDemo,注意这里是没有包名

public class TestDemo {
    public static void main(String[] args) {
        System.out.println();
    }
}

输出如下:可以看到第二个是用的是MyClassLoader,我们自己定义的加载器

class com.haokai.common.test.TestDemo
sun.misc.Launcher$AppClassLoader@18b4aac2
class TestDemo
com.haokai.common.test.MyClassLoader@1e80bfe8

Tomcat的自定义类加载器

Tomcat也是重写了类加载器,Tomcat的自定义类加载器的所在目录8.0版本和8.5之后的版本不在一个目录,8.0版本已经过时了,基本8.0版本已经全面被8.5版本代替。

在tomcat8.5后的版本中,放在src\java\org\apache\catalina\loader中。
下载源码,我这里用8.5.89的版本,https://tomcat.apache.org/download-80.cgi

在这里插入图片描述

官网链接,点击复制后面的地址即可触发下载:https://dlcdn.apache.org/tomcat/tomcat-8/v8.5.89/src/apache-tomcat-8.5.89-src.zip
云盘链接:源码下载-apache-tomcat-8.5.89-src

解压后,进入apache-tomcat-8.5.89-src\java\org\apache\catalina\loader目录,找到ClassLoader相关的类,

在这里插入图片描述

打开可以发现WebappClassLoader extends WebappClassLoaderBase,找到WebappClassLoaderBase的findClass()方法:

@Override
public Class<?> findClass(String name) throws ClassNotFoundException {

    if (log.isDebugEnabled()) {
        log.debug("    findClass(" + name + ")");
    }

    checkStateForClassLoading(name);

    // (1) Permission to define this class when using a SecurityManager
    if (securityManager != null) {
        int i = name.lastIndexOf('.');
        if (i >= 0) {
            try {
                if (log.isTraceEnabled()) {
                    log.trace("      securityManager.checkPackageDefinition");
                }
                securityManager.checkPackageDefinition(name.substring(0,i));
            } catch (Exception se) {
                if (log.isTraceEnabled()) {
                    log.trace("      -->Exception-->ClassNotFoundException", se);
                }
                throw new ClassNotFoundException(name, se);
            }
        }
    }

    // Ask our superclass to locate this class, if possible
    // (throws ClassNotFoundException if it is not found)
    Class<?> clazz = null;
    try {
        if (log.isTraceEnabled()) {
            log.trace("      findClassInternal(" + name + ")");
        }
        try {
            if (securityManager != null) {
                PrivilegedAction<Class<?>> dp =
                    new PrivilegedFindClassByName(name);
                clazz = AccessController.doPrivileged(dp);
            } else {
                clazz = findClassInternal(name);
            }
        } catch(AccessControlException ace) {
            log.warn(sm.getString("webappClassLoader.securityException", name,
                    ace.getMessage()), ace);
            throw new ClassNotFoundException(name, ace);
        } catch (RuntimeException e) {
            if (log.isTraceEnabled()) {
                log.trace("      -->RuntimeException Rethrown", e);
            }
            throw e;
        }
        if ((clazz == null) && hasExternalRepositories) {
            try {
                clazz = super.findClass(name);
            } catch(AccessControlException ace) {
                log.warn(sm.getString("webappClassLoader.securityException", name,
                        ace.getMessage()), ace);
                throw new ClassNotFoundException(name, ace);
            } catch (RuntimeException e) {
                if (log.isTraceEnabled()) {
                    log.trace("      -->RuntimeException Rethrown", e);
                }
                throw e;
            }
        }
        if (clazz == null) {
            if (log.isDebugEnabled()) {
                log.debug("    --> Returning ClassNotFoundException");
            }
            throw new ClassNotFoundException(name);
        }
    } catch (ClassNotFoundException e) {
        if (log.isTraceEnabled()) {
            log.trace("    --> Passing on ClassNotFoundException");
        }
        throw e;
    }

    // Return the class we have located
    if (log.isTraceEnabled()) {
        log.debug("      Returning class " + clazz);
    }

    if (log.isTraceEnabled()) {
        ClassLoader cl;
        if (Globals.IS_SECURITY_ENABLED){
            cl = AccessController.doPrivileged(
                new PrivilegedGetClassLoader(clazz));
        } else {
            cl = clazz.getClassLoader();
        }
        log.debug("      Loaded by " + cl.toString());
    }
    return clazz;

}

为什么要重写?有时候我们需要用到一些扩展类,Java会默认提供一些个该扩展类的接口规范,但是我们要想要操作的话,需要去实现它的类加载器,因为不想让它加载到他的父类上去。

类加载过程的第一步装载的过程中间,内存当中就会有方法区以及堆两块内容。 那么内存当中是否还会有其它的区域呢?
进一步了解运行时数据区的内容。

汇总

JVM1:官网了解JVM;Java源文件运行过程、javac编译Java源文件、如何阅读.class文件、class文件结构格式说明、 javap反编译字节码文件;类加载机制、class文件加载方式

JVM2:类加载机制、class文件加载方式;类加载的过程:装载、链接、初始化、使用、卸载;类加载器、为什么类加载器要分层?JVM类加载机制的三种方式:全盘负责、父类委托、缓存机制;自定义类加载器

JVM3:图解类装载与运行时数据区,方法区,堆,运行时常量池,常量池分哪些?String s1 = new String创建了几个对象?初识栈帧,栈的特点,Java虚拟机栈,本地方法发栈,对象指向问题

JVM4:Java对象内存布局:对象头、实例数据、对齐填充;JOL查看Java对象信息;小端存储和大端存储,hashcode为什么用大端存储;句柄池访问对象、直接指针访问对象、指针压缩、对齐填充及排序

JVM5:JVM内存模型与运行时数据区的关系,堆为什么分区,分代年龄,Young区划分,Survivor区为什么分为S0和S1,如何理解各种GC:Partial GC、Full GC、Young GC

你可能感兴趣的:(jvm,jvm,Java,类加载机制,类加载器,类加载)