JVM 基础
运行时数据区
JVM 在程序执行时定义了多个运行时数据区,有些数据区是由 JVM 在启动时创建并且在 JVM 退出后摧毁的,有些数据区是由每个线程所有的。每个线程私有的数据区在由线程创建时创建,随着线程的退出而销毁。
主要存在以下几个运行时数据区:由线程共享的运行时数据区:堆区、方法区;线程私有的运行时数据区:虚拟机栈、本地方法栈、程序计数器
程序计数器
JVM 可以支持多条线程同时执行,每一个 Java 虚拟机线程都有自己的一个 程序计数器。在任意时刻,每个 Java 虚拟机线程只会执行一个方法的代码,这个正在被执行的方法称为该线程的当前方法。如果当前执行的方法不是 native 的,那么这个程序计数器就会保存当前被 Java 虚拟机执行的字节码的指令的地址。如果该方法是 native 的,那么程序计数器保存的值将是 undefined。程序计数器的容量应当能够至少保存一个 returnAddress 类型的数据或者一个与平台相关的本地指针的值。
虚拟机栈
每个 Java 虚拟机线程都有自己的私有 Java 虚拟机栈,这个栈与线程同时创建,用于存储栈帧。Java 虚拟机栈的作用与传统语言(例如 C)中的栈十分类似,用于存储局部变量和一些尚未计算好的结果。另外,它在方法调用和返回中也扮演了很重要的角色。因为除了栈帧的出栈和入栈之外,Java 虚拟机栈不会再受其他的因素影响,所以栈帧可以在堆中分配。Java 虚拟机栈所使用的内存不需要保证是连续的。
Java 虚拟机规范既允许 Java 虚拟机栈被实现成固定大小,也允许根据计算动态地扩展和收缩。如果采用固定大小的 Java 虚拟机栈,那么每一个线程的 Java 虚拟机栈容量可以在线程创建的时候独立选定。
Java 虚拟机实现应当提供给程序员或者最终用户调节虚拟机栈初始容量的手段,对于可以动态扩展和收缩的 Java 虚拟机栈来说,则应当提供调节其最大、最小容量的手段。
Java 虚拟机栈可能会发生如下异常:
- 如果线程请求分配的栈容量超过 Java 虚拟机栈允许的最大容量,Java 虚拟机会抛出一个
StackOverflowError
的异常- 如果一个 Java 虚拟机栈可以动态扩展,并且尝试扩展的时候无法申请到足够的内存,或者在创建新的线程的时候没有足够的内存去创建对应的 Java 虚拟机栈,那么 Java 虚拟机将会抛出一个
OutOfMemoryError
异常
栈中存储的内容:
- 方法本身是操作码的一部分,保存在虚拟机栈中
- 方法内部变量(局部变量)作为指令的操作数部分,跟在指令的操作码之后,保存在
Stack
中。实际上,对于基本数据类型(byte、char、int、long等)保存在Stack
中;对于对象类型来讲,将会在Stack
中保存对象的引用,而实际对象则保存在堆区中 - 虚拟机栈随着线程的创建而创建,它的生命周期是随着线程的生命周期,线程结束栈内存也就随之释放,因此对于虚拟机栈来讲不存在垃圾回收
- 局部变量表存放了编译期可知的各种基本数据类型(boolean、byte、char、int、long)、对象的引用和 returnAddress(指向下一条字节码指令的地址)。局部变量表所需的内存空间在编译器完成分配,在方法运行前,该局部变量表所需要的内存空间是固定的,运行期间也不会发生改变
堆区
在 Java 虚拟机中,堆是可供各个线程共享的运行时数据区,也是供所有类实例和数组对象分配内存的区域
Java 堆在虚拟机启动的时候就被创建,它存储了被自动内存管理系统(automatic storage management system),也就是常说的 garbage collector(垃圾收集器)所管理的各种对象,这些受管理的对象无需也无法无法显式地销毁。Java 虚拟机并未假设采用何种具体的技术去实现自动内存管理系统,虚拟机的实现者可以根据系统的实际需要来选择自动内存管理工具。Java 堆的容量可以是固定的,也可以随着程序执行的需求动态地扩展,并在不需要过多空间时自动收缩。Java 堆所使用的内存不需要保证是连续的
Java 虚拟机的实现应当提供给程序员或者用户最终调节 Java 堆初始容量的手段,对于可以动态扩展和收缩的 Java 堆来说,则应当提供调节其最大、最小容量的手段
Java 堆可能会发生以下异常:
- 如果实际所需的堆超过了自动内存管理系统能够提供的最大容量,那么 Java 虚拟机将会抛出一个
OutOfMemoryError
异常
方法区
在 Java 虚拟机中,方法区是提供可供各个线程共享的运行时内存区域。方法区与传统语言中的编译代码存储区或者操作系统进程的正文段的作用非常相似,它存储了每一个类的结构信息,例如,运行时常量池、字段和方法数据、构造函数和普通方法的一些内容,还包括一些在类、实例、接口初始化用到的特殊方法
方法区在虚拟机启动的时候创建,虽然方法区是堆的逻辑组成的一部分,但是简单的虚拟机实现可以选择在这个区域不实现垃圾收集与压缩。方法区的容量可以是固定的,也可以随着程序执行的需求动态扩展,并在不需要过多空间时自动收缩。方法区在实际内存空间中可以是不连续的
Java 虚拟机实现应当提供给程序员或者最终用户调节方法区初始容量的手段,对于可以动态扩展和收缩的方法区来说,则应当调节其最大、最小容量的手段。
方法区可能发生的异常情况:
- 如果方法区的内存空间不能满足内存分配请求,那么 Java 虚拟机将会抛出一个
OutOfMemoryError
异常
运行时常量池
运行时常量池是 .class 文件中每一个类或接口的常量池表的运行时表示形式,它包括了若干种不同的常量,从编译期已知的数值字面量到必须在运行期解析后才能获取的方法或者字段引用。运行时常量池类似于传统语言中的符号表,不过它存储的数据的范围比通常意义上的符号表要更为广泛。
每一个运行时常量池都在 Java 虚拟机的方法区中分配,在加载类和接口到虚拟机之后,就会创建对应的运行时常量池
在创建类和接口的运行时常量池时,可能会发生如下异常情况:
- 当创建类或者接口时,如果构造的运行时常量池所需要的内存空间超过了方法区能够提供的最大值,那么 Java 虚拟机将会抛出一个
OutOfMemoryError
异常
运行时常量池位于方法区内部,主要存储编译期产生的字面量和符号引用,运行时产生的新常量可以放入常量池中,如 String
的 intern()
方法产生的常量
常量池是这个类会用到的常量的一个有序集合,包括直接常量(基本类型,String
)和其它对类、方法、字段的引用。
本地方法栈
Java 虚拟机的实现可能会使用到传统的栈(通常称为 C Stack)来支持 native 方法(指使用 Java 以外的其它语言编写的方法)执行,这个栈就被称为本地方法栈。当 Java 虚拟机使用其他语言来实现指令集解释器时,也可以使用本地方法栈。如果 Java 虚拟机不支持 native 方法,或者本身不依赖于传统栈,那么可以不提供本地方法栈,如果支持本地方法栈,那么这个栈一般会在线程创建的时候按照线程分配
Java 虚拟机实现应当提供给程序员或者最终用户调节本地方法栈初始容量的手段,对于长度可动态变化的本地方法栈来说,则应当提供调节其最大、最小容量的手段
本地方法栈可能发生如下异常情况:
- 如果线程请求分配的栈容量超过本地方法栈允许的最大容量,Java 虚拟机将会抛出一个
StackOverflowError
异常- 如果本地方法栈可以动态扩展,并且尝试在扩展的时候无法申请到足够的内存,或者在创建新的线程的时候没有足够的内存去创建对应的本地方法栈,那么虚拟机将会抛出一个
OutOfMemoryError
异常
对象分配
分配一个对象的流程
-
首先前提条件是使用
new
关键字准备创建一个新的对象 -
类加载检查
- 这一步骤主要完成两个工作:一是检查该指令的参数能否在常量池中定位到一个类的符号引用;二是检查这个符号引用代表的类是否已经被加载、解析和初始化过(如果没有,则需要执行相应的类加载过程)
-
为对象分配内存
-
内存分配
对象在类加载完成之后便可以确定对象的大小,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来,有以下两种划分方法
-
指针碰撞
假设 Java 堆中的内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间使用一个指针来作为分界点的指示器。那么在这种情况下分配内存就仅仅只是将这个分界点指针向空闲的内存区域移动一个单位大小的对象内存空间。这种内存分配方式称为 “指针碰撞”
-
空闲列表
如果 Java 堆中的内存并不是连续的,而是已使用的内存和未使用的内存相互交错在一起,这时就无法使用 “指针碰撞” 的方式来分配内存了。此时,JVM 会维护一个列表,用于记录那些内存块是可用的。在分配内存的时候从列表中找到一个足够大的空间划分给对象实例,并且更新列表上的记录,这种内存分配方式称为 “空闲列表”
-
-
解决线程安全问题
由于对象的内存分配是一个非常频繁的操作,在并发的情况下分配对象内存空间不是线程安全的。为了解决这个问题,存在以下两种解决方案:
- 对分配内存空间的动作进行同步处理—实际上虚拟机采用
CAS
和失败重试的方式保证更新操作的原子性 - 把内存分配的动作按照线程划分在不同的空间中进行,即每个线程在 Java 堆中预先分配一小块内存,称之为本地线程分配缓冲(Thread Local Allocation Buffer,
TLAB
)。哪个线程要分配内存,就在哪个线程的TLAB
上分配,只有在TLAB
用完并且分配新的TLAB
时,才需要同步锁定。虚拟机是否启用TLAB
,可以通过设置-XX: +/-UseTLAB
来开启或者关闭
- 对分配内存空间的动作进行同步处理—实际上虚拟机采用
-
-
初始化
- 初始化零值:内存分配完之后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),如果使用
TLAB
,那么这一工作也可以提前到TLAB
分配时进行。这一步操作保证了对象的实例字段在 Java 代码中可以不初始化值就可以直接使用,程序能够访问到这些字段的数据类型对应的零值(比如,int 类型的对应 0,引用类型对应 null)。 - 对对象进行设置:设置完零值之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例,如何能够找到类的元数据信息、对象的哈希码、对象的分代
GC
信息等。这些信息都放在对象的对象头中,根据虚拟机的不同,对象头会有不同的设置方式
完成上述操作之后,对于 JVM 来讲,一个新的对象已经产生了,但是从 Java 程序的视角来看,对象的创建才刚刚开始
- 初始化零值:内存分配完之后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),如果使用
创建一个对象的流程
逃逸分析
如果当前分配的对象的作用域在一个方法体内,那么 JVM 就可以对它进行以下优化
-
锁消除
由于在一个方法体内的所有字段都是当前的处理线程私有的,因此无论如何这个方法的执行都是线程安全的。所以这种情况下,如果执行的代码块中的锁是可以去除的,因为这种情况下加锁操作是多余的。这里的锁是指执行时存在的锁,比如,如果此时在方法体内部使用
StringBuffer
的相关方法,那么就会消除StringBuffer
对象中存在的锁。 -
标量替换
标量是指不能够再被分解的变量,比如:int、long等。聚合量是指可以进一步分解的量,一般指对象。
当当前处理的对象没有发生逃逸,并且可以进一步分解为标量,那么就会将这个对象分解为对应的标量放在栈中,不再去分配这个对象的空间,进一步提高了性能
-
栈上分配对象
由于栈上的数据在方法调用结束之后就会被释放,无需垃圾收集器的参与,因此减小了
GC
的压力,进一步提高了性能
具体流程
- 首先对要分配的对象进行逃逸分析,判断能否在栈上分配
- 如果能够在栈上分配,则直接在栈上分配,在栈上分配的对象就不需要垃圾收集器进行回收了
- 如果不能够在栈上分配,则转入下一步
- 判断是否是大对象
- 如果当前的分配对象是大对象,那么由于新生代的内存区域太小无法容纳这个大对象,使得这个对象直接进入老年代进行分配
- 如果不是大对象,则进入下一步
- 判断是否可以在
TLAB
中分配- 如果可以,那么在
TLAB
中分配 - 否则,只能在
TLAB
外的堆上分配
- 如果可以,那么在
具体流程如下图所示:
对象的结构和锁升级
对象结构
一个 Java 对象在内存中的存储区域可以分为三个区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。如下图所示:
-
对象头
-
MarkWord
:用于存储对象自身运行时的数据,如hashCode
、GC
分代年龄、偏向锁状态等信息。在 32 位和 64位的 JVM 上这一部分的长度分别为 4字节和 8字节。由于这一部分需要存储的信息太多,已经超出了 32位、64位 bit 能够存储的最大容量,考虑到虚拟机的空间效率,MarkWord
被设计成了一个非固定的数据结构以便能够在极小的空间内存储尽可能多的信息,它会根据对象的状态复用自己的存储空间。以 64位的 JVM 为例,在对象的不同状态下MarkWord
存储的信息是不一样的,如下图所示: -
类型指针:指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。并不是所有的 JVM 实现都需要在对象数据上保留类型指针,换句话说,查找对象的元数据信息不一定需要经过对象本身。
-
数组长度:Java 中的数组也是一个对象,这是因为 JVM 只能确定一个对象的大小而无法准确地知道一个数组的大小,因此,对于数组类型的对象,必须需要一个数组长度的字段来表示这个数组的长度。
-
-
实例数据
实例数据部分是对象真正存储的有效信息,也是程序代码中所定义的各种类型的字段内容。无论这个字段是从父类继承下来的还是在子类中定义的,都需要记录起来。这一部分的存储顺序会受到 JVM 分配策略参数和字段在 Java 源代码定义顺序的影响
-
对齐填充
对齐填充这一步并不是一定存在的,由于有的 JVM 的自动内存管理系统要求对象的起始地址必须是 8 字节的整数倍,因此为了满足这一需求添加了 对齐填充 字段来满足这个需求
在 64 位的 JVM 上,以上布局如下所示:
锁升级
-
无锁
无锁是指没有对资源进行锁定,所有的线程都能够访问并修改资源,但同时只有一个线程能够修改成功。
无锁的特点是修改操作会在循环内部进行,线程会不断地尝试修改资源。如果没有冲突就成功修改并退出,否则就会继续循环尝试。如果有多个线程同时修改一个值,最终必定会有一个成功,而其他修改的线程会不断重试直到修改成功。
-
偏向锁
- 初次执行到
sychronized
的代码块时,锁对象首先变为偏向锁(通过CAS
修改对象头里的锁标志位)。在执行完同步代码块之后,线程并不会主动释放偏向锁。当第二次到达同步代码块的时候,线程判断此时持有锁的线程是否就是自己(注意上文提到的MarkWord
里的偏向锁标志位),如果是则正常执行。由于之前没有释放锁,因此在这种情况下也就不需要重新加锁。如果自始至终使用锁的线程都只有一个,那么很明显,偏向锁几乎没有额外的开销 - 偏向锁只有在遇到其它线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程是不会主动释放偏向锁的
- 关于偏向锁的撤销,需要等待全局安全点,即某个时间点上没有字节码正在执行时,它会暂停拥有偏向锁的线程,然后判断锁对象是否处于被锁定状态。如果线程处于不活跃状态,则将对象头设置成无锁状态,并撤销偏向锁,恢复到无锁或轻量级锁状态
- 初次执行到
-
轻量级锁(自旋锁)
- 当对象处于偏向锁状态时,如果这时被其它的线程访问,此时偏向锁就会升级为轻量级锁,其它线程会通过自旋的形式尝试获取锁,线程不会阻塞,因此提高了性能
- 轻量级锁的获取主要存在以下两种情况
- 当关闭偏向锁功能时
- 当多个线程竞争偏向锁导致偏向锁升级为轻量级锁
- 在轻量级锁状态下继续进行锁竞争,没有抢到锁的线程将自旋,即不停地循环判断能否被成功获取。获取锁的操作,其实就是通过
CAS
修改对象头里的锁标志位。先比较当前锁标志位是否为 “释放”,如果是则将其设置为 “锁定”。
-
重量级锁
- 在轻量级锁的情况下,如果锁竞争严重,达到了允许自旋的最大自旋次数,那么将会将轻量级锁升级为重量级锁(依旧是通过
CAS
修改锁标志位)。当后续线程尝试获取锁时,发现被占用的锁是重量级锁,因此直接将自己挂起,等待别的线程来唤醒 - 在一个线程获取了重量级锁的情况下,其余所有的等待线程都会处于阻塞状态
- 在轻量级锁的情况下,如果锁竞争严重,达到了允许自旋的最大自旋次数,那么将会将轻量级锁升级为重量级锁(依旧是通过
类加载机制
类的生命周期
JVM 将 .class 文件加载到内存中,并对数据进行校验、转换解析和初始化,最终得到可用的类对象,这个过程被成为类加载。
JVM 的类加载流程可以分为五个步骤:加载——> 链接——> 初始化——> 使用——>卸载
-
加载
将 .class 文件加载到内存中,在这个过程中,主要完成三件事:
- 通过一个类的全限定名来获取定义此类的二进制字节流
- 将这个二进制字节流所代表的静态存储结构转变为方法区内的运行时数据结构
- 在内存中生成一个代表这个类的
java.lang.Class
对象(这个对象是一个特殊的对象,在 JDK 7 之前的版本中会将类对象放入方法区而不是堆区,在 JDK 7及其之后的版本中 Class 对象又被放入回堆区),这个对象将会作为这个类的各种数据的访问接口
-
链接
在链接阶段又可以划分为三个子阶段,分别为:验证、准备、解析
-
验证
这一阶段的主要目的是检查加载的类文件字节流是否符合 JVM 要求的规范,这是为了保护 JVM 的安全。在这个阶段主要完成以下四件事:
- 文件格式验证:验证字节流是否符合 Class 文件格式的规范,比如:是否以
0xCAFEBABE
开头等 - 元数据验证:对字节码描述的信息进行语义分析,以确保描述的信息符合 Java 语言规范的要求
- 字节码验证:通过对数据流和控制流分析,确定程序语义是合法的、符合逻辑的
- 符号引用验证:确保解析动作能够正确地执行
这个阶段并不是必须的
- 文件格式验证:验证字节流是否符合 Class 文件格式的规范,比如:是否以
-
准备
为静态变量分配内存,并将其初始化为默认值。准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。
- 在这个阶段进行内存分配的仅仅只包括类变量(被
static
修饰的变量),而对于实例变量,将会在对象实例化的时候随着对象一起分配在 Java 堆中 - 这个阶段设置的值是数据默认的零值(如 0,null 等),而不是 Java 代码中显式赋予的值
- 对于同时使用
static
和final
修饰的变量,在这个阶段就会显式地设置为 Java 代码指定的值
- 在这个阶段进行内存分配的仅仅只包括类变量(被
-
解析
把类中的符号引用转换为直接引用
-
-
初始化
对类的静态变量,静态代码执行初始化操作。这个过程会将静态变量赋予正确的初始值,JVM 负责对类进行初始化,主要是针对类变量进行初始化。在 Java 中对类变量进行初始值设定有两种方式:
-
声明类变量是指定初始值
public class Cat { static int a = 10; // 经过初始化阶段之后,a的值就为 10了 }
-
使用静态代码块为类变量指定初始值
public class Dog { static int a; // 使用静态代码块来初始化类变量 static { a = 10; } }
类初始化的步骤
-
加入这个类还没有被加载和链接,那么首先加载并链接该类
-
如果该类的父类还没有被初始化,则先初始化其直接父类
-
如果该类中有初始化语句,则系统依次执行这些初始化语句
初始化类的时机,主要存在以下几种情况会触发类的初始化
-
使用
new
关键字实例化对象的时候 -
读取或者设置一个类型的静态字段(被
final
修饰的除外,前提是final
修饰的是基本数据类型,如果final
修饰的是一个引用类型,那么就会把这些引用类型的初始化操作放入一个static
块中,在访问某个引用类型的句柄时完成所有的引用对象的初始化) -
调用一个类的静态方法的时候
-
使用反射对类进行调用时,如果类没有初始化,则首先初始化该类
-
初始化该类时,如果父类没有被初始化,则首先初始化父类
-
当调用某个类的
main
方法时,会触发这个主类的初始化
值得注意的是,以下几种看起来会初始化类的方式实际上不会初始化类
- 通过子类引用父类的静态字段,只会触发父类的初始化,而不会触发子类的初始化
- 定义对象数组,不会触发该类的初始化
- 常量在编译期间会存入调用类的常量池中,本质上并没有直接引用定义常量的类,不会触发定义常量所在的类
- 通过类名获取 Class 对象,不会触发类的初始化
- 通过
Class.forName()
加载指定类时,如果制定参数initialize
为false
时,也不会触发类的初始化 - 通过
ClassLoader
默认的loadClass()
方法,也不会触发初始化动作
-
-
使用
正常使用 Java 对象,无需做过多的介绍
-
卸载
一般加载类之后就不会再移除了,因此只有在 JVM 退出的时候才会卸载
类加载器
JVM 设计者将类加载阶段的 “通过一个类的全限定名来获取描述此类的二进制字节流”的操作放到了 JVM 外部去实现,以便让应用程序自己决定如何去获取所需的类。实现这个动作的代码模块被称之为“类加载器”
类加载器的层次
-
启动类加载器(
BootStrap ClassLoader
)负责将存放在
$JAVA_HOME/lib
目录下的类加载到 JVM 中,这些类一般是启动类,按照文件的名字来加载,一般这个都不需要自己去管理 -
扩展类加载器(
Extension ClassLoader
)这个加载器由
sun.misc.Launcher$ExtClassLoader
来实现,负责加载$JAVA_HOME/lib/ext
目录下或者由java.ext.dir
系统变量指定的所有类。尽管可以在这个加载器的目录下定义一些需要的类,但是由于自JDK 9
开始这一部分内容已经被废除了,因此也不建议自己手动去管理这一部分。比如某些软件如RocketMQ
使用高版本的JDK
无法正常启动,就是由于在这个类加载器路径下定义了某些类,而高版本的JDK
由于不再支持这一特性,因此就无法正常运行。 -
应用程序类加载器(
Application ClassLoader
)这个类加载器
sun.misc.Launcher$AppClassLoader
,主要负责加载用户类路径上的所有类库(通过-classpath
指定的类库)。一般情况下,这个类加载器就是默认的类加载器,但是也可以自己定义类加载器。
加载类的几种方式:
-
JVM 启动时由 JVM 自己去加载
-
通过
Class.forName()
方法去加载 -
通过
ClassLoader.loadClass()
方法加载相比较于使用
Class.forName()
的方式进行加载,ClassLoader.loadClass()
的方式有以下不同Class.forName()
不仅会将 .class 加载到 JVM 中,还会对类进行解释,执行类中的static
代码块ClassLoader.loadClass()
只会将 .class 文件加载到 JVM 中,不会执行static
代码块中的内容Class.forName()
有一个重载方法可以控制是否初始化对应的 Class 对象
双亲委派模型
JVM 在加载类的时候会首先将要加载的类交给当前的类加载器的父类加载器进行加载,如果找不到再交给子类加载器进行加载,如果最后在当前的类加载器中依旧无法加载到该类,那么就会认为是无法找到的。这样确保了 Java 中系统库的类不会被加载错误。
双亲委派模型如下所示:
注意,上面提到的几个类加载器,BootStrap ClassLoader
是没有父类加载器的,而 Extension ClassLoader
的父类加载器为 null,只是在形式上将父类加载器指向了 BootStrap ClassLoader
,实际上并不存在这个关系。
ClassLoader.loadClass()
使用的就是双亲委派的方式来加载类。
ClassLoader.loadClass()
的实现,基于JDK 1.8
public Class<?> loadClass(String name) throws ClassNotFoundException {
return loadClass(name, false);
}
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) {
c = parent.loadClass(name, false);
} else {
// 如果不存在父类加载器,那么使用 native 方法查找 BootStrap 类加载器
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() 从而实现自定义的类加载器
// 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); // 如果 resolve为true,那么表示要初始化该类,默认不初始化
}
return c;
}
}
自定义类加载器
根据上文提到的 ClassLoader.loadClass()
的实现,只需要继承 ClassLoader
并重写 findClass()
方法即可实现自定义的类加载器的实现。
以下面的例子为例(伪代码):
protected Class<?> findClass(String name) throws ClassNotFoundException {
// 通过 getClassData(name) 将指定的类文件读取到内存中
byte[] classData = getClassData(name);
if (classData == null) {
throw new ClassNotFoundException();}
else {
// 通过读取到的二进制字节流文件创建一个 Class 对象
return defineClass(name, classData, 0, classData.length);
}
}
自定义加载器的使用场景:
-
加密,避免相关的类被反编译
-
从非标准的来源加载类
实现细节
- 如果希望打破双亲委派,那么需要重写整个
loadClass()
和findClass()
方法,因为ClassLoader
在这个方法内部定义了双亲委派的实现 - 如果不希望打破双亲委派,那么只需要重写
findClass()
方法即可
线程上下文加载器
由于 Java 中存在 SPI
,但是这些类一般都是在高级别的父类加载器中定义的,因此这一类 SPI
在调用实现类的时候是无法实现的(因为它们本身处于的类加载器级别较高,双亲委派机制是不会到当前类加载器的子类加载器中去加载类的)。为了解决这一问题,需要破坏双亲委派机制才能实现。
通过线程上下文类加载器,可以获得当前线程的类加载器,这个类加载器默认是 AppClassLoader
可以加载到第三方的实现类,然后将 SPI
的类加载器设置为当前得到的上下文类加载器,这样 SPI
也就可以加载到对应的实现类了。
获取线程当前的线程上下文类加载器(这里表示的是 SPI
的线程类加载器):
ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
通过某种方式获取到 AppClassLoader
,然后将当前线程的上下文类加载器设置为这个类加载器:
// 这里将当前 SPI 的类加载器设置为某个线程上下文类加载器,使得能够加载到相关的实现类
Thread.currentThread().setContextClassLoader(targetClassLoader);
加载完成之后,再将当前的类加载器还原的原来的类加载器:
Thread.currentThread().setContextClassLoader(classLoader);
总的流程就是: 获取-> 设置 -> 使用 -> 还原
整体的伪代码如下所示:
// 获取原类加载器
ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
// 通过一些手段获取的目标类加载器
ClassLoader targetClassLoader = xxxx;
try {
// 将新的类加载器设置为当前上下文类加载器
Thread.currentThread().setContextClassLoader(targetClassLoader);
// 使用加载器加载一些自己需要的类
loadClass();
} finally {
// 还原
Thread.currentThread().setContextClassLoader(classLoader);
}