JVM
Java内存区域
对于 Java 程序员来说,不再需要程序员手写操作内存,不容易出现内存泄漏和内存溢出问题。
因为 Java 程序员把内存控制权利交给 Java 虚拟机,一旦出现内存泄漏和溢出方面的问题,如果不了解虚拟机是怎样使用内存的,那么排查错误将会是一个非常艰巨的任务。
运行时数据区域
Java 虚拟机在执行 Java 程序的过程中会把它管理的内存划分成若干个不同的数据区域。
线程私有的:
程序计数器
虚拟机栈
本地方法栈
线程共享的:
堆
方法区
直接内存 (非运行时数据区的一部分)
程序计数器
当前线程所执行的字节码的行号指示器,是一个寄存器
(字节码解释器工作时通过改变这个计数器的值来选取下一条需要执行的字节码指令)
为了线程上下文切换后能够恢复上下文,每个线程需要有一个私有的程序计数器
虚拟机栈
线程私有的,生命周期和线程相同(共生死)
除了一些 Native 方法调用是通过本地方法栈实现的,其他所有的 Java 方法调用都是通过栈来实现的(每一次方法调用就会创造一个栈帧压入栈中,调用结束弹出栈)
栈由一个个栈帧组成,而每个栈帧中都拥有:
1.局部变量表 存放数据类型(char,boolean...)和对象引用 数据
2.操作数栈 中间计算结果,临时变量
3.方法返回地址。
4.动态链接、动态链接就是运行时常量池中对某个方法的符号引用,我们调用会把符号引用转化为直接地址引用
简单总结一下程序运行中栈可能会出现两种错误:
StackOverFlowError
: 若栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出StackOverFlowError
错误。OutOfMemoryError
: 如果栈的内存大小可以动态扩展, 如果虚拟机在动态扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError
异常。
本地方法栈
和虚拟机栈所发挥的作用非常相似,区别是:虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务
堆
J线程共享的一块内存区域,在虚拟机启动时创建。
此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。
Java 堆是垃圾收集器管理的主要区域,因此也被称作 GC 堆(Garbage Collected Heap)。
从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,所以 Java 堆还可以细分为:新生代和老年代;再细致一点有:Eden、Survivor、Old 等空间。进一步划分的目的是更好地回收内存,或者更快地分配内存。
堆内存被通常分为下面三部分:
新生代内存(Young Generation)
老生代(Old Generation)
永久代(Permanent Generation)
JDK 8 版本之后 PermGen(永久代) 已被 Metaspace(元空间) 取代,元空间使用的是本地内存。
JDK 8 版本之后 PermGen(永久代) 已被 Metaspace(元空间) 取代,元空间使用的是本地内存。
堆这里最容易出现的就是 OutOfMemoryError
错误,并且出现这种错误之后的表现形式还会有几种,比如:
java.lang.OutOfMemoryError: GC Overhead Limit Exceeded
:当 JVM 花太多时间执行垃圾回收并且只能回收很少的堆空间时,就会发生此错误。java.lang.OutOfMemoryError: Java heap space
:假如在创建新的对象时, 堆内存中的空间不足以存放新创建的对象, 就会引发此错误
方法区
当虚拟机要使用一个类时,它需要读取并解析 Class 文件获取相关信息,再将信息存入到方法区。方法区会存储已被虚拟机加载的 类结构信息,运行时常量池(字面量,符号引用)
方法区和永久代以及元空间是什么关系呢?
方法区和永久代以及元空间的关系很像 Java 中接口和类的关系
也就是说永久代以及元空间是 HotSpot 虚拟机对虚拟机规范中方法区的两种实现方式
JDK 1.8 的时候,方法区(HotSpot 的永久代)被彻底移除了(JDK1.7 就已经开始了),取而代之是元空间,元空间使用的是本地内存。
运行时常量池
Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有用于存放编译期生成的各种字面量(Literal)和符号引用(Symbolic Reference)的 常量池表(Constant Pool Table) 。
字面量是源代码中的固定值的表示法
常量池表会在类加载后存放到方法区的运行时常量池中。
字符串常量池
字符串常量池 是 JVM 为了提升性能和减少内存消耗针对字符串(String 类)专门开辟的一块区域,主要目的是为了避免字符串的重复创建。
字符串常量池的实现是一个固定大小的HashTable
,保存的是字符串(key)和 字符串对象的引用(value)的映射关系,字符串对象的引用指向堆中的字符串对象。
JDK1.7 之前,字符串常量池存放在永久代。JDK1.7 字符串常量池和静态变量从永久代移动了 Java 堆中。
JDK 1.7 为什么要将字符串常量池移动到堆中?
主要是因为永久代(方法区实现)的 GC 回收效率太低,只有在整堆收集 (Full GC)的时候才会被执行 GC。Java 程序中通常会有大量的被创建的字符串等待回收
对象
下面我们来详细的了解一下HotSpot 虚拟机在 Java 堆中对象分配、布局和访问的全过程。
对象创建
1.类加载检查
jvm遇到一条new指令时,先检查new的这个类名是否能在 方法区的运行时常量池 中找到对应的 符号引用。然后检查改符号引用代表的 类是否经过了 类加载过程,如果没有,先执行类加载
2.分配内存
为新生对象分配内存,需要分配的内存大小是在类加载过程中知道的,在堆中找到一块内存分给该对象
分配方式有 “指针碰撞” 和 “空闲列表” 两种,选择哪种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定(算法是标记清除还是标记整理)。
指针碰撞:
适用场合:堆内存规整(即没有内存碎片)的情况下。
原理:用过的内存全部整合到一边,没有用过的内存放在另一边,中间有一个分界指针,分配内存时只需要让指针向右移动 需要的空间大小
空闲列表:
适用场合:堆内存不规整的情况下。
原理:虚拟机会维护一个列表,该列表中会记录哪些内存块是可用的,在分配的时候,找一块儿足够大的内存块儿来划分给对象实例,最后更新列表记录。
内存分配并发问题
在创建对象的时候有一个很重要的问题,就是线程安全,因为在实际开发过程中,创建对象是很频繁的事情,作为虚拟机来说,必须要保证线程是安全的,通常来讲,虚拟机采用两种方式来保证线程安全:
CAS+失败重试: 虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。
TLAB: 为每一个线程预先在 Eden 区分配一块儿特有内存,JVM 在给线程中的对象分配内存时,首先在 TLAB 分配,当对象大于 TLAB 中的剩余内存或 TLAB 的内存已用尽时,再采用上述的 CAS 进行内存分配
3.初始化零值
将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
4.设置对象头
虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。
5.执行init
<init>
方法没执行前,所有的字段都还为零
以一般来说,执行 new 指令之后会接着执行 <init>
方法,把对象按照程序员的意愿进行初始化
PS:上面的步骤执行的是new SingleDoubleCheck()的过程
如果执行instance = new SingleDoubleCheck(),new完对象后,还要把instance引用指向对象对应的内存地址
对象的内存布局
对象在内存中的布局可以分为 3 块区域:对象头、实例数据和对齐填充。
对象头包括两部分信息,第一部分用于存储对象自身的运行时数据(哈希码、GC 分代年龄、锁状态标志等等),另一部分是类型指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
实例数据部分是对象真正存储的有效信息
对齐填充部分不是必然存在的,也没有什么特别的含义,仅仅起占位作用。 因为 Hotspot 虚拟机的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,换句话说就是对象的大小必须是 8 字节的整数倍
对象的访问定位
建立对象就是为了使用对象,我们的 Java 程序通过栈上的 reference 数据来操作堆上的具体对象。对象的访问方式由虚拟机实现而定,目前主流的访问方式有:使用句柄、直接指针。
句柄
如果使用句柄的话,那么 Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与对象类型数据各自的具体地址信息。
对象的访问定位-使用句柄
# 直接指针
如果使用直接指针访问,reference 中存储的直接就是对象的地址。
这两种对象访问方式各有优势。使用句柄来访问的最大好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。
垃圾回收
Java 的自动内存管理主要是针对对象内存的回收和对象内存的分配
Java 堆是垃圾收集器管理的主要区域,因此也被称作 GC 堆(Garbage Collected Heap)。
从垃圾回收的角度来说,由于现在收集器基本都采用分代垃圾收集算法,所以 Java 堆被划分为了几个不同的区域,这样我们就可以根据各个区域的特点选择合适的垃圾收集算法。
堆内存被通常分为下面三部分:
新生代内存(Young Generation)
老生代(Old Generation)
永久代(Permanent Generation)
内存分配与内存回收
针对 HotSpot VM 的实现,它里面的 GC 其实准确分类只有两大种:
部分收集 (Partial GC):
新生代收集(Minor GC / Young GC):只对新生代进行垃圾收集;
老年代收集(Major GC / Old GC):只对老年代进行垃圾收集。需要注意的是 Major GC 在有的语境中也用于指代整堆收集;
混合收集(Mixed GC):对整个新生代和部分老年代进行垃圾收集。
整堆收集 (Full GC):收集整个 Java 堆和方法区
新生代:
新对象优先放到新生代,存储的都是生命周期短,容易被回收的对象。 采用标记复制算法
老生代
永久代:
FULL GC 会收集永久代
死亡对象判断方法
堆中几乎放着所有的对象实例,对堆垃圾回收前的第一步就是要判断哪些对象已经死亡 需要回收(即不能再被任何途径使用的对象)
可达性分析:
通过一系列的称为 “GC Roots” 的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连的话,则证明此对象是不可用的,需要被回收
四种引用
1.强引用(StrongReference)
当内存空间不足,Java 虚拟机宁愿抛出 OutOfMemoryError 错误,使程序异常终止,也不会回收具有强引用的对象
2.软引用(SoftReference)
如果内存空间不足了,就会回收这些对象的内存
3.弱引用(WeakReference)
一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。
4.虚引用(PhantomReference)
虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收。
虚引用主要用来跟踪对象被垃圾回收的活动。
在程序设计中一般很少使用弱引用与虚引用,使用软引用的情况较多,这是因为软引用可以加速 JVM 对垃圾内存的回收速度,可以维护系统的运行安全,防止内存溢出(OutOfMemory)等问题的产生。
垃圾回收算法和垃圾收集器:
Serial
缺点:进行垃圾收集时会暂停其他工作线程,造成卡顿
优点:由于没有线程交互的开销,自然可以获得很高的单线程收集效率
ParNew
Serial 收集器的多线程版本
Parallel Scavenge
与ParNew的不同点:关注点是吞吐量(高效率的利用 CPU,同时间内能处理更多任务)
CMS 等垃圾收集器的关注点更多的是用户线程的停顿时间(提高用户体验),
而Paraller不同,它关注吞吐量
ps:Serial Old,Paraller Old, 是Serial ,Parallel Scavenge的老年代版本
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。
它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。
初始标记: 暂停所有的其他线程,并记录下直接与 root 相连的对象,速度很快 ;
并发标记: 同时开启 GC 和用户线程,用一个闭包结构去记录可达对象。但在这个阶段结束,这个闭包结构并不能保证包含当前所有的可达对象。因为用户线程可能会不断的更新引用域,所以 GC 线程无法保证可达性分析的实时性。所以这个算法里会跟踪记录这些发生引用更新的地方。
重新标记: 重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短
并发清除: 开启用户线程,同时 GC 线程开始对未标记的区域做清扫。
优点:并发收集、低停顿
缺点:
对 CPU 资源敏感;
它使用的回收算法-“标记-清除”算法会导致收集结束时会有大量空间碎片产生。
G1收集器:
G1 (Garbage-First) 是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器. 以极高概率满足 GC 停顿时间要求的同时,还具备高吞吐量性能特征.
被视为 JDK1.7 中 HotSpot 虚拟机的一个重要进化特征。它具备以下特点:
并行与并发:G1 能充分利用 CPU、多核环境下的硬件优势,使用多个 CPU(CPU 或者 CPU 核心)来缩短 Stop-The-World 停顿时间。部分其他收集器原本需要停顿 Java 线程执行的 GC 动作,G1 收集器仍然可以通过并发的方式让 java 程序继续执行。
分代收集:虽然 G1 可以不需要其他收集器配合就能独立管理整个 GC 堆,但是还是保留了分代的概念。
空间整合:与 CMS 的“标记-清除”算法不同,G1 从整体来看是基于“标记-整理”算法实现的收集器;从局部上来看是基于“标记-复制”算法实现的。
可预测的停顿:这是 G1 相对于 CMS 的另一个大优势,降低停顿时间是 G1 和 CMS 共同的关注点,但 G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间不得超过 N 毫秒。
G1 收集器的运作大致分为以下几个步骤:
初始标记
并发标记
最终标记
筛选回收
G1 收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的 Region(这也就是它的名字 Garbage-First 的由来) 。这种使用 Region 划分内存空间以及有优先级的区域回收方式,保证了 G1 收集器在有限时间内可以尽可能高的收集效率(把内存化整为零)
从 JDK9 开始,G1 垃圾收集器成为了默认的垃圾收集器
类文件结构
字节码
在 Java 中,JVM 可以理解的代码就叫做字节码(即扩展名为 .class
的文件)
好处:
字节码文件与操作系统无关(jvm屏蔽了系统差异),屏蔽了机器的差异,实现了跨平台。
Java 语言通过字节码的方式,在一定程度上解决了传统解释型语言执行效率低的问题,同时又保留了解释型语言可移植的特点
Class 文件结构
Class 文件通过 ClassFile
定义,有点类似 C 语言的结构体。
ClassFile
的结构如下:
ClassFile {
u4 magic; //Class 文件的标志
u2 minor_version;//Class 的小版本号
u2 major_version;//Class 的大版本号
u2 constant_pool_count;//常量池的数量
cp_info constant_pool[constant_pool_count-1];//常量池
u2 access_flags;//Class 的访问标记
u2 this_class;//当前类
u2 super_class;//父类
u2 interfaces_count;//接口数量
u2 interfaces[interfaces_count];//一个类可以实现多个接口
u2 fields_count;//字段数量
field_info fields[fields_count];//一个类可以有多个字段
u2 methods_count;//方法数量
method_info methods[methods_count];//一个类可以有个多个方法
u2 attributes_count;//此类的属性表中的属性数
attribute_info attributes[attributes_count];//属性表集合
}
类加载
类从被加载到虚拟机内存中开始到卸载出内存为止,它的整个生命周期可以简单概括为 7 个阶段::加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)。
加载:
通过全类名获取定义此类的二进制字节流。
将字节流里的类信息 转换为方法区的运行时数据结构。
在内存中生成一个代表该类的
Class
对象,作为方法区这些数据的访问入口。
加载过程会把类元信息放到方法区,并在堆里生成一个Class对象作为入口
加载这一步主要是通过我们后面要讲到的 类加载器 完成的。类加载器有很多种,当我们想要加载一个类的时候,具体是哪个类加载器加载由 双亲委派模型 决定
每个 Java 类都有一个引用指向加载它的 ClassLoader
。不过,数组类不是通过 ClassLoader
创建的,而是 JVM 在需要的时候自动创建的
加载阶段与连接阶段的部分动作(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未结束,连接阶段可能就已经开始了
验证
目的是确保 Class 文件的字节流中包含的信息符合全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。
验证阶段主要由四个检验阶段组成:
文件格式验证(Class 文件格式检查)
元数据验证(字节码语义检查)
字节码验证(程序语义检查)
符号引用验证(类的正确性检查)
文件格式验证这一阶段是基于该类的二进制字节流进行的,主要目的是保证输入的字节流能正确地解析并存储于方法区之内,格式上符合描述一个 Java 类型信息的要求。除了这一阶段之外,其余三个验证阶段都是基于方法区的存储结构上进行的,不会再直接读取、操作字节流了。
准备
准备阶段是正式为类变量分配内存并为类变量初始化零值的阶段 这时候进行内存分配的仅包括类变量( Class Variables ,即静态变量,被 static
关键字修饰的变量,只与类相关,因此被称为类变量),而不包括实例变量。
类变量所使用的内存都应当在 方法区 中进行分配。在 JDK 7 及之后,HotSpot 已经把原本放在永久代的字符串常量池、静态变量等移动到堆中,这个时候类变量则会随着 Class 对象一起存放在 Java 堆中。
解析
解析阶段是虚拟机将常量池内的符号引用(方法区的常量池)替换为直接引用(栈里的reference)的过程
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用限定符 7 类符号引用
初始化
class类构造器对静态变量,静态代码块进行初始化
初始化阶段是执行初始化方法 <clinit> ()
方法的过程,是类加载的最后一步,这一步 JVM 才开始真正执行类中定义的 Java 程序代码(字节码)。
对象初始化和类初始化是不一样的。
init是instance实例构造器,对非静态变量解析初始化,而clinit是class类构造器对静态变量,静态代码块进行初始化。
clinit:在准备阶段,变量已经赋过一次系统要求的初始值,而在初始化阶段,则根据程序员通过程序制定的主观计划去初始化类变量和其他资源。
对于初始化阶段,虚拟机严格规范了有且只有 6 种情况下,必须对类进行初始化(只有主动去使用类才会初始化类):
当
new
、getstatic
、putstatic
或invokestatic
这 4 条字节码指令时,比如new
一个类,读取一个静态字段(未被 final 修饰)、或调用一个类的静态方法时。。当 jvm 执行
new
指令时会初始化类。即当程序创建一个类的实例对象。当 jvm 执行
getstatic
指令时会初始化类。即程序访问类的静态变量(不是静态常量,常量会被加载到运行时常量池)。当 jvm 执行
putstatic
指令时会初始化类。即程序给类的静态变量赋值。当 jvm 执行
invokestatic
指令时会初始化类。即程序调用类的静态方法。
使用
java.lang.reflect
包的方法对类进行反射调用时如Class.forname("...")
,newInstance()
等等。如果类没初始化,需要触发其初始化。初始化一个类,如果其父类还未初始化,则先触发该父类的初始化。
当虚拟机启动时,用户需要定义一个要执行的主类 (包含
main
方法的那个类),虚拟机会先初始化这个类。MethodHandle
和VarHandle
可以看作是轻量级的反射调用机制,而要想使用这 2 个调用, 就必须先使用findStaticVarHandle
来初始化要调用的类。、
卸载
卸载类即该类的 Class 对象被 GC。
卸载类需要满足 3 个要求:
该类的所有的实例对象都已被 GC,也就是说堆不存在该类的实例对象。
该类没有在其他任何地方被引用
该类的类加载器的实例已被 GC
所以,在 JVM 生命周期内,由 jvm 自带的类加载器加载的类是不会被卸载的。但是由我们自定义的类加载器加载的类是可能被卸载的。
类加载器
从上面的介绍可以看出:
类加载器是一个负责加载类的对象,用于实现类加载过程中的加载这一步。
每个 Java 类都有一个引用指向加载它的
ClassLoader
。数组类不是通过
ClassLoader
创建的(数组类没有对应的二进制字节流),是由 JVM 直接生成的
类加载器的主要作用就是加载 Java 类的字节码( .class
文件)到 JVM 中(在内存中生成一个代表该类的 Class
对象)
类加载器加载规则
JVM 启动的时候,并不会一次性加载所有的类,而是根据需要去动态加载。也就是说,大部分类在具体用到的时候才会去加载,这样对内存更加友好。
对于已经加载的类会被放在 ClassLoader
中。在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载。也就是说,对于一个类加载器来说,相同二进制名称的类只会被加载一次。
JVM 中内置了三个重要的 ClassLoader
:
BootstrapClassLoader
(启动类加载器):最顶层的加载类,由 C++实现,通常表示为 null,并且没有父级,主要用来加载 JDK 内部的核心类库(%JAVA_HOME%/lib
目录下的rt.jar
、resources.jar
、charsets.jar
等 jar 包和类)以及被-Xbootclasspath
参数指定的路径下的所有类。ExtensionClassLoader
(扩展类加载器):主要负责加载%JRE_HOME%/lib/ext
目录下的 jar 包和类以及被java.ext.dirs
系统变量所指定的路径下的所有类。AppClassLoader
(应用程序类加载器):面向我们用户的加载器,负责加载当前应用 classpath 下的所有 jar 包和类。
除了这三种类加载器之外,用户还可以加入自定义的类加载器来进行拓展,以满足自己的特殊需求。就比如说,我们可以对 Java 类的字节码( .class
文件)进行加密,加载时再利用自定义的类加载器对其解密。
自定义类加载器
我们前面也说说了,除了 BootstrapClassLoader
其他类加载器均由 Java 实现且全部继承自java.lang.ClassLoader
。如果我们要自定义自己的类加载器,很明显需要继承 ClassLoader
抽象类。
ClassLoader
类有两个关键的方法:
protected Class loadClass(String name, boolean resolve)
:加载指定二进制名称的类,实现了双亲委派机制 。name
为类的二进制名称,resolve
如果为 true,在加载时调用resolveClass(Class<?> c)
方法解析该类。protected Class findClass(String name)
:根据类的二进制名称来查找类,默认实现是空方法。
官方 API 文档中写到:
Subclasses of
ClassLoader
are encouraged to overridefindClass(String name)
, rather than this method.建议
ClassLoader
的子类重写findClass(String name)
方法而不是loadClass(String name, boolean resolve)
方法。
如果我们不想打破双亲委派模型,就重写 ClassLoader
类中的 findClass()
方法即可,无法被父类加载器加载的类最终会通过这个方法被加载。但是,如果想打破双亲委派模型则需要重写 loadClass()
方法
双亲委派模型
好处
可以避免类的重复加载(JVM 区分不同类的方式不仅仅根据类名,相同的类文件被不同的类加载器加载产生的是两个不同的类),也保证了 Java 的核心 API 不被篡改。
如果没有使用双亲委派模型,而是每个类加载器加载自己的话就会出现一些问题,比如我们编写一个称为 java.lang.Object
类的话,那么程序运行的时候,系统就会出现两个不同的 Object
类。双亲委派模型可以保证加载的是 JRE 里的那个 Object
类,而不是你写的 Object
类。这是因为 AppClassLoader
在加载你的 Object
类时,会委托给 ExtClassLoader
去加载,而 ExtClassLoader
又会委托给 BootstrapClassLoader
,BootstrapClassLoader
发现自己已经加载过了 Object
类,会直接返回,不会去加载你写的 Object
类。
打破双亲委派模型方法
自定义加载器的话,需要继承 ClassLoader
。如果我们不想打破双亲委派模型,就重写 ClassLoader
类中的 findClass()
方法即可,无法被父类加载器加载的类最终会通过这个方法被加载。但是,如果想打破双亲委派模型则需要重写 loadClass()
方法。
为什么是重写 loadClass()
方法打破双亲委派模型呢?双亲委派模型的执行流程已经解释了:
类加载器在进行类加载的时候,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成(调用父加载器
loadClass()
方法来加载类)。
重写 loadClass()
方法之后,我们就可以改变传统双亲委派模型的执行流程。例如,子类加载器可以在委派给父类加载器之前,先自己尝试加载这个类,或者在父类加载器返回之后,再尝试从其他地方加载这个类。
应用:
我们比较熟悉的 Tomcat 服务器为了能够优先加载 Web 应用目录下的类,然后再加载其他目录下的类,就自定义了类加载器 WebAppClassLoader
来打破双亲委托机制。这也是 Tomcat 下 Web 应用之间的类实现隔离的具体原理。