JVM的故事の类加载子系统 小王国内部的事情。

本文详细介绍了Java虚拟机的类加载机制,包括加载、验证、准备、解析和初始化五个阶段,以及类加载器的工作原理。重点讨论了双亲委派模型,解释了为何及如何打破这个模型,并探讨了类加载过程中的内存分配和权限验证。此外,还涵盖了类加载器的角色,特别是自定义类加载器的设计和使用。
摘要由CSDN通过智能技术生成

时隔几年年,再次更新博客。这几年岁月蹉跎啊。

周志明老师的《深入理解java虚拟机》读书笔记


能保证的不多,但是绝对不是抄袭博客或者抄书。那样既浪费时间。读书笔记,是我读书,对书本的理解。

内存概述

简图

JVM内存简图

详细图

JVM内存详细图

PC寄存器(就是程序计数器)和虚拟机栈中的线程一一对应。

本篇文章主要讲,类加载子系统


类的加载机制

类的生命周期

类的生命周期一共分7个阶段:加载、验证、准备、解析、初始化、使用、卸载。其中验证、准备、解析,统称为 链接阶段。

其中加载、验证、准备、初始化、卸载这5个阶段的顺序是确定的,必须按部就班的开始。解析则是不确定,可能在准备以后就开始解析了,也可能在初始化之后才解析(这点体现为后期绑定)。

说明:

需要说明的是,5个阶段顺序是确定的。但是不是线性执行的,即不是字节码文件全被被加载完成以后,才进行验证,验证完成以后,再进行准备。这样依次执行下去。

而是交叉的执行,加载工作尚未完成,验证工作已经开始。但是加载是一定发生在验证之前的。

类的初始化时机

虚拟机规范,对于加载,连接的时机,并没有要求,各厂商自己按需实现,但是对初始化的时机有着明确的要求:

一共五种情况,如果发生下面说的情况之一,但是类还没有初始化,则虚拟机必须初始化该类:

  1. new、getstatic、putstatic、invokestatic 这四条字节码指令。

    看指令名字也能猜出个七八九来。

    new :实例化对象

    getstatic:获取静态变量的值

    putstatic:设置静态变量的值

    invokestatic:调用静态变量方法

    其中如果静态变量被final修饰,则不会引起初始化,因为被final修饰为常量,在编译期间就会被放进使用该常量的类的常量池里面,和常量所在的定义类将无关,两个类之间,其实在编译以后,就是大路朝天,各走一边了。

  2. 通过反射,获取类的时候。

  3. 初始化子类的时候,其父类也会被初始化。

  4. 虚拟机执行的main方法所在的类,入口类

  5. 动态语言支持,方法句柄实例被解析为REF_getStaticREF_putStaticREF_invokeStatic的时候,句柄对应的类,没有初始化,则需要初始化。

    这个看不懂,无所谓,因为,平时没用过,我就没看懂,但是我通过字节码命令名字,猜出是什么意思。

    大概意思又回到第一条,要用到句柄实例对应的类的静态变量、静态方法。没有初始化,可不得先初始化。

主动引用

上述5种情况,对一个类的使用,称为 主动引用 。会引起类的初始化。除了这 5 种行为以为,对类的时候,都称为 被动引用,不会触发类的初始化。

主动引用:上述5种行为,我的理解为确实需要用到类里面的东西,并且这些东西必须得初始化以后才能使用,如:方法、变量。要是不初始化,变量都没有值,只有默认值,方法中的变量也是如此。

常量不需要初始化,可直接在常量池中获得,所以不会引起初始化。

几种被动引用的例子
  1. 子类访问父类的静态变量。SubClass.valuevalue是父类中的静态变量。只会初始化父类,不会初始化子类。

    因为访问的父类的静态变量,和子类没啥关系。虽然子类没有初始化,但是子类是否加载,是不确定的,在hotspot中,子类是加载了。

  2. 定义数组类,SubClass[] array = new SubClass[10] ; 并不会引起对应数组类型的初始化。

    数组是比较特殊的,好多情况下,都需要单独说明。

    这种情况下,虚拟机并不会初始化Subclass,而是虚拟机自己创建了一个类,代表该引用数组类,一般类名是L 开头,后面跟上SubClass 的全类名:Lorg.example.SubClass

    关于数组的一些操作和属性,都是封装在这个类中。

    数组类的加载和普通类的加载,不是一回事。

  3. 访问一个类的静态常量:VALUEA类的final static修饰的常量。在B类中通过A.VALUE 访问,是不会引起A类初始化。

    5种情况的第一种里面,有提到,编译以后,AB 两个类没有任何瓜葛了。

    B类中不会出现A类符号引用的入口,因为是常量,编译器认识,编译期间,直接写入B类的常量池中,我们看到的还是A.VALUE,其实已经是B.VALUE了。


接口的初始化时机

接口和类的初始化其实差不多,都是虚拟机调用<cinit>() 方法,在其里面进行赋值操作,为接口的常量赋值。

<cinit>() 方法 ,其实就是类构造器方法,是编译器自己生成的,和我们说的构造器不是一个东西,构造器是 <init>()

接口也是有<cinit>() 方法 的,在里面进行各种初始化需要做的功能,如显式赋值。

后面说到初始化,会详细说下<cinit>() 方法 ,此处暂且不表。

唯一和类初始化不同的是:前面类的5条的第三条,接口不会主动的去初始化父类接口,除非确实需要用到父类接口的东西,才会去初始化父类,这点和类不一样。

一个接口初始化的时候,可能只有自己被初始化了,自己的父类接口可能并没有得到初始化。


类的加载过程

首先说明下,前面说过类的生命周期有7个过程,第一个就是加载,但是这个加载只是类加载的一个部分。前面5个阶段,合起来才是类加载。

加载 != 类加载

加载

字面意思,就是将字节码文件,加载进虚拟机。

在整个类加载过程中,只有加载阶段,是我们可控的,因为我们可以自定义类加载器,通过重写loadClass()方法,去控制字节流的获取方式,控制从哪里读。读完以后是否需要加密解密。

这个过程,虚拟机需要做3件事:

  1. 通过类的全限定名获取类的字节码文件的二进制流。

  2. 通过流读取节码文件,将静态的字节码文件转换为 方法区 中的 运行时数据结构

  3. 在内存中生成一个 Class对象,作为字节码文件映射到方法区中的运行时数据结构的入口。

    程序想访问这些方法区中的数据结构,就需要通过这个class对像入口。

    这个Class对像,虚拟机规范没有明确规定放哪里,hotspot 是放在方法区中。

    方法区是虚拟机规范定义的一片空间,具体在哪看各厂商实现。

    hotspot1.7 之前用永久代作为方法区的具体实现,在1.7以后用元空间作为方法区的具体实现。

    hotspot中方法区在物理内存上,还是属于堆的,只是逻辑上独立,物理上还是在堆中。

这里面针对第一条,并没有说明字节码文件在哪里,因此字节码文件可以是本地的文件,也可以是网络上下载的,也可以是动态生成的,反正来路很多。

因此,许多Java技术都是来源于此:

  1. ZIP包中读取,成为以后JarWar 包的基础。
  2. 从网络中读,成为已经没落的Applet 的基础。
  3. 运行时生成,就是后来的动态代理技术。
  4. 还要从加密文件中读取,安卓的加壳。
数组类的创建过程

前面提到过,数组类的创建和普通类不一样,数组类是由虚拟机自己创建的,所以数组类本身是不需要类加载器去加载的。

但是数组的元素类型(去掉所有维度),归根到底还是类,还是需要类加载器去加载的。

比如:SubClass[] array = new SubClass[10] ;虽然SubClass[] 数组是虚拟机自己创建的,但是数组的元素类型是SubClassSubClass 还是需要类加载器加载的。

数组类的创建过程:

issue:暂时我也不理解,先空着。

  1. 如果数组类的组件类型(去掉一个维度)是引用类型,未完待续

    说明下,这是我自己理解的,可能有误,书上一笔带过,没写全…

    什么是去掉一个维度,数组有一维数组,二维数组、三维数组乃至多维数组。

    去掉一个维度,就是去掉这个维度,三维变二维,二维变一维。

    SubClass[]一维数组,去掉一个维度类型是SubClass引用类型。

    SubClass[][]二维数组,去掉一个维度以后,类型是SubClass[]类型,还是数组类型,则递归的去掉一个维度,继续去掉一个维度,变成SubClass引用类型。


验证

主要是验证字节码文件是否安全。直接关乎虚拟机本身是否安全,是否会被攻击。

虽然有编译器存在,编译器会阻拦一些危险行为:数组越界访问,故意访问其他内存数据;引用强行转换成未继承实现过的类。不给予编译通过。

但是类加载器是从字节码文件中加载的,字节码文件的来源,是不一定非要用编译器编译,甚至可以通过十六进制的编辑器,自己编辑。你可以编辑出上面的危险行为。所以虚拟机是一定要检查的字节码文件的。

验证阶段大约会经过四个阶段的验证:

  1. 文件格式验证

    可以理解为校验字节码文件的格式,是否符合要求。

    比如字节码开头是否有魔数CAFEBABE。后面的字节是否是主次版本,是否满足虚拟机的要求。

    检查字节码文件的格式,主要目的就是保证输入的字节流能够被正确解析,并存储到方法区中。

    只有通过了文件格式验证,字节码才会出现到虚拟机内存中的方法区中。

    主要记住,是对字节码文件的格式进行验证。

  2. 元数据验证

    对元数据进行分析,分析是否符合语法。

    比如:是否继承了不能继承的类。

    主要记住,是对语法层面进行验证。

  3. 字节码验证

    通过数据流和控制流分析代码具体的执行是否会出现危险行为。

    如:方法跳转到方法体外部的地址进行执行。

    主要记住,是对代码语义进行验证。

    如果验证不通过,则字节码文件肯定有问题,但是验证通过了,也不能说明字节码一定是安全的,,毕竟是程序在校验程序,是不完全可靠的,如经典的停机问题。

    停机问题,也是一个程序校验程序的问题。写一个程序判断一段程序是否会在有限时间内执行完毕。那么被检测的程序,也可以写出一段程序来检查自己是否被停机,然后执行不停机。或者检测出不可停机,然后执行停机。

    不可解问题。

    虚拟机在这部分花费许多时间,因为数据流分析确实复杂。

    虚拟机设计团队,在1.6以后,在方法体的Code属性表中添加了一项StackMapTable 属性,记录方法体中的本地变量表和操作栈的开始状态。最后来检查这张表是否对应,来判定是否通过验证。将字节码验证从类型推导变为类型检查。

    但是这张表理论上也可能会被修改,在修改方法体的同时,修改StackMapTable属性。

  4. 符号引用验证

    虚拟机将符号引用解析直接引用。这个验证,将在后面的解析阶段发生。

    主要记住,是对引用的信息进行匹配验证。

    比如:是否通过全限定名找的类是否存在;引用的类、字段、方法,是否有权限访问啊。是否存在符合方法描述的方法。

对于已经执行过好多次的代码,即已经通过好多次验证;或者是自己写的代码。是可以关闭验证的,以缩短类加载时间。


准备

为类变量,注意是类变量,即被static修饰的变量,分配内存和设置初值(零值)。这些变量的内存都在方法区中进行分配。而实例变量会随着对象被分配到堆中。

初值或者零值,就是变量的默认值,不是程序员规定的值,程序员规定的值,在初始化阶段才会被赋值。

如果类变量被final修饰,是常量,则直接赋指定的值,不赋零值。因为常量在编译时,编译器会为其加上常量 ConstantValue属性,准备阶段,虚拟机直接根据ConstantValue的值给它赋值。


解析

把符号引用解析为直接引用。首先明白解析是在做什么。

常量池包含的内容如下:

常量池包含内容

其中字面值是不需要解析的,就是取字面的值,常值。主要解析下面的符号引用。

符号引用

用一组符号描述引用的目标,其实就是字符串,如java/lang/Integer,描述我们要用到的类、方法、字段的字符串。

用这一段字符串来描述我们要引用的目标是啥,至于目标是否存在,是否被加载进内存,是不用管的,在解析阶段才会去验证。

符号引用,和虚拟机的内存布局无关,因为虚拟机规范,规定了符号引用的字面量形式。也就是不同的虚拟机实现,在符号引用的字面值上是确定的。

直接引用

直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。如果有了直接引用,那引用的目标必定已经被加载进内存中了。

直接引用是和虚拟机的布局相关的,同一个符号引用在不同的虚拟机实例上翻译出来的直接引用一般不会相同。

何时解析

虚拟机规范并没有指定解析发生的具体阶段。所以,解析可能发生在初始化之后,也可能发生在初始化之前。

虚拟机规范,只是规定了在操作符号引号引用之前,需要解析成直接引用。操作符号引用的指令有16 个,总结下就是调用、访问字段、方法的指令,因为确确实实要用到符号引用描述的目标了,所以解析下。所以虚拟机实现的时候,可以加载的时候就按部就班的解析,也可以加载的时候不解析,直到要使用符号引用前再去解析。

特殊的invokedynamic指令

16个指令,里面只有一个比较特殊,是调用动态方法:invokedynamic 。因为对于同一个符号引用进行多次请求解析是很常见的。虚拟机会缓存符号引用的解析,在常量池中记录直接引用,并将符号引用标记为已解析状态,避免重复解析。对于同一个符号引用,多次解析的结果总是相同的,因为后续的解析都是拿到缓存。

invokedynamic并不是这样,这就是它的特殊之处。因为调用动态方法,多次请求解析,可能多次执行生成的不同的方法,所以没有缓存,每次都会解析。并且其他符号引用,在加载阶段是可以进行解析的,invokedynamic 则是必须等到使用时候才能解析,因为加载阶段还不知道对应的是谁呢。


类或者接口的解析

在类D中用到了还没有被解析的类或者接口的符号引用S,将S解析为直接引用C的过程如下:

  1. 如果C不是数组类型,虚拟机将符号引用S描述的全限定名传递给当前类D的类加载器去加载C。加载的过程,又会触发前面我说的几个步骤,验证、准备之类的,如果没有通过验证,加载失败,则代表解析失败。

  2. 如果C是数组类型,并且数组的元素类型为对象,那么S类似于[Ljava/lang/Integer的形式,虚拟机就按照第一条的规则,去加载对应的对象类型,这里就是java/lang/Integer,接着由虚拟机生成一个数组对象。

    区分数组的组件类型和元素类型。

    组件类型是去除一个维度的类型。

    元素类型是去除所有维度的类型。

  3. 上述步骤没有报错的话,此时C已经是一个有效的类或者接口了。但是在解析之前,还需要对符号引用进行最后的验证。确认符号引用S所在的类或者DC是否有访问权限,如果不具备访问权限,则抛出java.lang.IllegalAccessError 非法访问错误。


字段解析

字段的解析,首先需要先将字段所在类解析出来。也就是先对字段表中符号引用CONSTANT_Class_info进行解析,解析出该类或者接口。也就是上面说的类或者接口的解析。

如果解析成功,才会进行字段的解析:

  1. 在解析来的类中,直接寻找与目标匹配的字段,如果找到,则直接返回这个字段的直接引用,否则进行第二步。
  2. 在解析出类的接口中找,按照继承关系,从下往上找,找到就返回,找不到进行下一步。
  3. 在解析类的父类中找,按照继承关系,从下往上找,找到返回 ,找不到就报错。

找到以后,对字段进行权限验证,看是否具有访问权限。如果不具备访问权限,则抛出java.lang.IllegalAccessError 非法访问错误。

过程中还有一些其他判断,如果同名字段,同时出现在解析类的接口和父类中,也会报错的。或者同名字段,同时在自己类中和父类的多个接口中出现,也会报错。因为不能确定到底哪个字段是需要的。


类方法解析

和字段解析一样,也要先解析出,方法所在的类或者接口。

  1. 需要先判断下,是否被修改过。所以先判断下 类方法表 对应的符号引用,解析出来是类还是接口,如果是类,则正确,因为类方法表中放的当然是类的东西;如果解析出来的是接口,则报错。
  2. 通过了第一步,然后就在解析类中寻找目标方法,找到返回,找不到进行下一步
  3. 在解析类的父类中找,找到返回,找不到进行下一步。
  4. 在解析类的接口中找,找到与找不到都报错,只是报的错不一样。如果没找到,则报没有这么一个方法的错误(NoSuchMethodError);如果找到了,因为是在接口中找到的,所以这是一个抽象类,没实现该方法,所以报错抽象方法错误(AbstractMethodError)。

如果顺利找到,且没有报错,按照惯例都会进行最后一步,权限检测,符号引用解析的最后一步,都是权限检测。


接口方法解析

和类方法解析一样,也要先解析出,方法所在的类或者接口。

  1. 第一步也和类方法一样,先检查是否被篡改过,如果在接口方法表中对应的符号引用解析出来是个类,则报错。
  2. 否则就在自身中寻找目标方法,找到返回,找不到进行下一步。
  3. 在自身实现的接口中找,一直找到Object类为止,找到返回,找不到则报错(NoSuchMethodError)。

按照解析的惯例,最后一步是权限检测,但是接口中默认的都是 public,所以不需要检测,不会抛出java.lang.IllegalAccessError 非法访问错误。


初始化
<cinit> 方法

前面的几个阶段都是虚拟机在做事情,在做自己的事情,初始化阶段,才是执行我们的代码。

虚拟机执行类构造器<cinit> 方法,<cinit> 方法是编译器自动收集所有的类变量的赋值动作和静态代码块中的语句合并产生的其中收集的顺序,按照在源代码中出现的顺序收集。静态代码块中只能访问定义在前面的变量,对于定义在其后面的变量,只能赋值,不能访问。

能赋值的原因是,类变量一开始,就会被扫描一遍,全部记录在一张表中,这次扫描只是扫描有多少定义变量,赋值是在后面发生的。所以代码中可以对定义在后面的变量赋值,因为其实赋值的时候,变量已经记录在册了。但是这个变量此时还没有被完全初始化,所以不能访问使用它。

class Test {

    static  {
        i = 99 ;
        System.out.println(i); // error ,非法向前引用
    }

    static int i = 9 ;
}

虚拟机会保证在执行在执行子类的<cinit> 方法,其父类的<cinit> 方法已经执行完毕。但是并不是在<cinit> 方法中调用父类的<cinit> 方法,和构造器方法<init> 不一样。按照从逻辑,虚拟机执行的 第一个<cinit> 方法,是Object<cinit> 方法。

<cinit> 方法,不是必备的。如果没有静态变量的赋值和静态代码块,则虚拟机不会生成<cinit> 方法。

接口中的<cinit> 方法

前面说过,接口也会生成<cinit> 方法。但是也说过类的加载和接口的加载,有个不一样的地方,就是涉及到父类的时候,加载接口的的时候,如果父类的接口没有被使用到,是不会加载的。因此,一个在执行接口的<cinit> 方法时候,如果没有使用到父类接口的变量,父类接口的<cinit> 方法是不会执行的。

接口实现类在初始化的时候,接口是不会再次初始化的,初始化只会发生一次。

多线程中的<cinit> 方法

虚拟机会保证在多线程的环境下,<cinit> 方法被正确的加锁,虚拟机自己会帮我们加上锁,保证同一个时刻只有一个线程在执行<cinit> 方法,其他线程等待。并且一个线程初始化完成以后,其他类不会得到锁,再次初始化。因为初始化只会发生一次。


类加载器

类加载器的作用

类加载器,第一个作用,见名知意,就是加载类。另外一个作用,就是判断两个类是否相等。

因为每个类加载器都有一个独立的名称空间,被它加载过的类,都在这里记录。因此,即使是同一个Class文件,被不同的类加载器加载,生成的两个类对象,虚拟机认为是不相等的,即使它们是同一个Class文件创建出的类对象。

这里的相等判断,包括:equalisInstanceinstanceOf

也就是对于任何一个类,是通过类文件本身和加载它的类加载器,两个一起决定唯一性的。


类加载器的分类

虚拟机自己将类加载器法分为两类:启动类加载器和自定义类加载器。

分成这两类也很好理解,启动类加载器是C++代码写的,其他的类加载器都是Java写的,它们都继承自抽象类ClassLoader

但是还可以根据加载的对象不同,将类加载器分为下面几种:

  1. 启动类加载器:

    加载Java核心源代码库,JDK/lib目录下面的文件,如:rt.jarruntime 。不是说lib路径下面的文件,都会被加载,启动类加载器,仅加载自己认识的文件。第三方jar包,放进该目录下面,启动类加载器也不会加载。

    启动类加载器,我们是获取不到的,使用API获取以后,返回的是null。如果我们想委托启动类加载器,直接使用 null代替即可。

    <!-- 认识的文件 -->
    <
    file:/C:/Program%20Files/Java/jre1.8.0_131/lib/resources.jar
    file:/C:/Program%20Files/Java/jre1.8.0_131/lib/rt.jar
    file:/C:/Program%20Files/Java/jre1.8.0_131/lib/sunrsasign.jar
    file:/C:/Program%20Files/Java/jre1.8.0_131/lib/jsse.jar
    file:/C:/Program%20Files/Java/jre1.8.0_131/lib/jce.jar
    file:/C:/Program%20Files/Java/jre1.8.0_131/lib/charsets.jar
    file:/C:/Program%20Files/Java/jre1.8.0_131/lib/jfr.jar
    file:/C:/Program%20Files/Java/jre1.8.0_131/classes
    
  2. 扩展类加载器:

    加载\]ib\ext目录中的文件,或者被java.ext.dirs指定的目录下面的文件。这个就没有认识不认识一说了,会加载目录下面的所有文件。

    我们可以直接使用该加载器 。JDK9 以后,ExtClassLoaderPlatformClassLoader 替代了。

  3. 应用类加载器(系统类加载器)

    加载classPath路径下面的文件,就是我们开发程序的源代码所在的地方。如果我们没有自定义自己的类加载器,那么程序中的默认类加载器,就是应用类加载器。

    我们同样可以直接使用该加载器。AppClassLoader

获取加载当前类的加载器的代码:

this.getClass().getClassLoader() ;
// 或者指定名字
String.class.getClassLoader() ;

类加载器之间的关系
类加载器之间关系

层次上看,像继承关系,其实是组合关系,但是使用 getParent() 方法确实能获取到上一层的加载器,所以,也叫父加载器。但是自己需要注意,虽然是父加载器,但是不是继承关系。

从图中可以看出,启动类加载器、扩展类加载器、应用类加载器的层次关系,以及我们自动义类加载器的层次,在应用类加载器下面。也就是说,我们自定义类加载器,就是继承ClassLoader 的是个时候,其实我们只需要重写其中的findClass 方法,至于原因,下面会讲。


自定义类加载器
  1. 继承 ClassLoader 抽象类
  2. 重写 findClass 方法
  3. findClass 中调用 DefineClass 返回。
public class MyClassLoader extends ClassLoader {

    private String path ;

    // 指定加载路径
    public MyClassLoader(String path) {
        this.path = path ;
    }

    // 这里要想破坏 双亲委派机制,就设置 parent = null
    public MyClassLoader(ClassLoader parent) {
        super(parent);
    }

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

        // 这里正常需要解析这个 类的全限定名
        // 我这里class文件直接放在 J 盘根路径下,所以直接截取最后的名字。
        String tempName = name.substring(name.lastIndexOf(".")+1);
        File file = new File(path+File.separator+tempName+".class");
        byte[] classBytes = null ;
        try {
            classBytes = getClassBytes(file);
        } catch (Exception e) {
           throw new ClassNotFoundException(name+" 文件未找到");
        }

        // name 是类的权限名。要和字节码中的名字匹配
        return defineClass(name, classBytes, 0, classBytes.length);
    }


    // 太久没用过 IO ,已经忘记了。。。
    // 其实核心就是这个方法。
    // 从哪里加载,如何加载 字节码文件。解密工作也可以再这里做
    private byte[] getClassBytes(File file) throws Exception {

        FileInputStream fis = new FileInputStream(file);
        FileChannel fc = fis.getChannel();
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        WritableByteChannel wbc = Channels.newChannel(baos);
        ByteBuffer by = ByteBuffer.allocate(1024);

        while (true) {
            int i = fc.read(by);
            if (i == 0 || i == -1)
                break;
            by.flip();
            wbc.write(by);
            by.clear();
        }

        fis.close();

        return baos.toByteArray();
    }
}

测试调用

 MyClassLoader myClassLoader = new MyClassLoader("J:");
        // 三个参数。 类限定名   是否初始化, 类加载器
 Class.forName("com.example.demo.Hello",true,myClassLoader);

Hello.java

public class Hello {
    static {
        System.out.print("我被加载 ");
        System.out.println(Hello.class.getClassLoader() +" 了!!");
    }
}

output

我被加载 sun.misc.Launcher$AppClassLoader@18b4aac2 了!!

因为 Hello 被我之前编译过来, classPath 下面已经存在,所以被应用了加载器加载了。

NoClassDefFoundError: Hello (wrong name: com/example/demo/Hello)

defineClass() 的第一次参数写错了。

我被加载 com.example.demo.MyClassLoader@2df3b89c 了!!

删除了classPath 下面已经存在的字节码。

或者设置 MyClassLoader 的perent为 null ,直接委托启动类加载器加载,它肯定不会搭理我们,最后就加载我们 J 盘下的字节码了,虽然classPath 下面有我们要加载的字节码。


双亲委派模型

有了上面类加载器之间的关系,理解双亲委派模型,就容易多了,具体含义就在下面的方法中。

loadClass 方法

这个方法是加载类的逻辑所在,也就是双亲委派模型的具体实现。方法在抽象类ClassLoader中。

  protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
        synchronized (getClassLoadingLock(name)) {
            // 首先根据类的全限定名检查,类是否被加载过。
            Class<?> c = findLoadedClass(name);
            // 如果为 null 没找到,则开始加载
            if (c == null) {
                long t0 = System.nanoTime();
                try {
                    // 判断当前类加载器的父类是否是 null
                    // 前面加载器分类那讲过 null,即代表是 启动类加载器
                    if (parent != null) {
                        // 父类加载器不是启动类加载器
                        // 则委托 父类加载器加载
                        c = parent.loadClass(name, false);
                    } else {
                        // 如果父类加载器是启动类加载器
                        // 则委托 启动类加载器 加载。
                        // 使用下面独特的方法
                        // 而不是简单的  parent.loadClass
                        c = findBootstrapClassOrNull(name);
                    }
                    // 这里报错的原因,委托的父类加载器
                    // 并没有完成请求
                    // 也就是父类加载器的加载目录没有发现目标类
                } catch (ClassNotFoundException e) {
                   // 这个异常是 非启动类加载器 抛出的。
                }
				
                // 如果承接上文,父类没有成功加载的处理。
                if (c == null) {
                    long t1 = System.nanoTime();
                    // 使用 findClass 去加载这个类。
                    // findClass 的定义见下面分析
                    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;
        }
    }
findClass 方法
protected Class<?> findClass(String name) throws ClassNotFoundException {
    throw new ClassNotFoundException(name);
}

这个方法,直接 protected ,摆明让实现类自己实现。那么类的加载逻辑也就走到最后一步了。

如果委托父类没有成功,则自己处理,自己处理的逻辑,就写在 findClass()中。这也是自定义类加载器为啥要重写 findClass 的原因。

总结就是:loadClass 方法帮我们实现双亲委派模型,findClass 方法帮我们实现自己的加载方式。

还有一点,虽然请求最开始你发起的,但是如果被父类加载器完成了加载,那么类的类加载器,就是最终完成加载任务的加载器,而不是一开是发起请求的加载器。

总结

双亲委派机制,就是当遇到一个类加载请求的时候,自己不直接加载,委托父类去加载,因此,最终的请求都会委托到 启动类加载器 ,然后一层一层的反馈下去,如果此层可以加载,就加载,否则,传递下去。

同时也让类加载器各司其职,让类也有了层次关系。基础类由上层加载器加载。应用类由下层加载器加载。

优点

这样是的启动类加载,加载的优先级最高。也就是核心代码库的代码,一定是启动类加载的。

即使你写了定义一个和Object全限定名一样的Object,然后自定义类加载器去加载。这样,层层委托上去,最后启动类加载器,发现自己可以加载,自己就去加载真实的Object类。你定义的李鬼类,是得不到加载的。

想象下,如果李鬼类被加载进内存,联系之前说的定位类的唯一性。灾难性的问题,好好的程序,被一个第三方类加载一个假冒的常用类破坏了,各种判断相等的地方,出现问题。比如加载假的String类,绝杀一样。

既然李鬼加载不进来,还是想让唯一性出现问题,那么更狠一点,自己再重写 defineClass 方法,强行去加载真实的Object类,也是不行的,核心类的加载,java.lang 包下面的类的加载,是需要授权的,不是任意一个类加载器都可以加载,虚拟机会监督这个权限。

防止破坏核心类,java 的安全性体现之一。

打破

双亲委派机制是可以打破的。最早的打破,发生再JDK1.2 之前,那时候还没提出双亲委派模型,自定义类加载器,都是从写loadClass 方法,谁还委托这,委托那的,都自己上来就加载。

后来才推出了findClass 方法,才有委托这么一说。

后面的JDNI 也打破了,但是这次打破,是模型本身确实有点瑕疵。

但是 JNDI具体是怎么是怎么回事,我还不清楚,也不能硬讲,先放这。(issue)

最后,打破,不是贬义,代表一种变化,Tomcat容器的打破双亲委派模型,就是一个很不错的做法。

打破的方法

自己想打破也很简单,重写loadClass 方法,不去维托父类,按照自己的想法写。或者直接设置有参构造器的参数ClassLoader parentparent = null ,越过应用类加载器,拓展类加载器,直接委托启动类加载器。


总结

到这里 JVM的类加载子系统算告一段落了。

  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值