JAVA虚拟机-Ⅰ

JVM 基本概念、运行时数据区与类加载机制

Posted by Ekko on August 1, 2020

学习 JVM 有助于从运行时视角理解 Java 语言本身,包括源代码如何转化为字节码、字节码如何被执行,以及内存结构、类加载、方法调用等机制如何影响程序行为与性能表现。

本篇内容分为四部分:虚拟机的基本概念、源代码到机器码的执行路径、JVM 运行时数据区、类加载机制。其中会涉及永久代与元空间、JIT 编译、双亲委派等 JVM 基础主题。

本文参考微笑陈树义及其他资料,并结合 HotSpot 的常见实现细节进行整理。

[TOC]


什么是虚拟机

虚拟机是一种抽象化的计算机,通过软件模拟出一套可执行指令、管理内存和调度程序的运行环境。它不一定真的去“复刻一整台物理机器”,但会对上层程序提供一组稳定的执行模型。

Java 虚拟机是 Java 生态最核心的运行时环境之一。它屏蔽了不同操作系统和硬件平台的差异,使得 Java 程序先编译为统一的字节码,再由具体平台上的 JVM 去解释执行或即时编译为机器码。

例如,Windows 可执行文件和 macOS 应用程序都高度依赖底层操作系统 ABI 与文件格式,因此不能天然跨平台运行。

而 Java 代码编译后的 .class 字节码不直接绑定单一平台,只要目标机器上有兼容的 JVM,就能运行同一份字节码。

Java 语言并不直接把源代码编译成某个单一平台的机器码,而是先编译成符合 JVM 规范的字节码。随后 JVM 再根据运行时情况,选择解释执行或者把热点代码编译成本地机器码。

jvm.png

JVM 实际执行的是符合规范的字节码,而不是“只能执行 Java 语言”。只要其他语言可以编译生成合法字节码,例如 Kotlin、Scala、Groovy,也都能运行在 JVM 上。


从源代码到机器码

编译器大致可以分为三类:

  1. 前端编译器:如 javac、Eclipse JDT 中的 ECJ,负责把源代码编译成字节码
  2. JIT(Just-In-Time)即时编译器:如 HotSpot VM 中的 C1、C2,负责把热点字节码编译为本地机器码
  3. AOT(Ahead-Of-Time)编译器:在程序运行前生成本地代码。历史上出现过 GCJ、Excelsior JET,现代语境下也常提到 GraalVM Native Image,但它已经不属于传统的 JVM 运行形态

1. 前端编译器:源代码到字节码

JDK 安装目录中的 javac 工具负责把 Java 源代码编译成字节码文件。因为它位于“源代码 -> 字节码”这一前置阶段,所以通常被称为前端编译器。

运行 javac 的过程,本质上就是把 Java 语法和语义转换为 Class 文件格式的过程。

javac 的工作大致可以理解为几个阶段:

  1. 词法分析、语法分析:把源代码转换为抽象语法树(AST)
  2. 符号表填充与语义分析:检查类型、继承关系、变量可见性、方法调用是否合法
  3. 注解处理:如果项目中使用了注解处理器,会在这一阶段生成额外源码或资源
  4. 字节码生成:把语义分析结果转换为 .class 字节码文件

注意:这里是 javac 编译器的工作阶段,不是 JVM 本身在“运行 Java 程序”时的阶段。

2. JIT即时编译器:字节码到机器码

当源代码转化为字节码之后,其实要运行程序,有两种选择:

  • 使用 Java 解释器解释执行字节码
  • 使用 JIT即时编译器将字节码转化为本地机器代码

前种启动速度快但运行速度慢,后者启动速度慢但运行速度快

因为解释器不需要像 JIT 编译器那样先做热点探测和机器码优化,所以启动更快。

而 JIT 编译器会把热点方法编译为机器码并放入当前 JVM 进程的 Code Cache 中,因此同一个进程后续再次执行这些热点路径时会更快。这里的“复用”一般发生在同一 JVM 进程内部,并不是说程序下次重启还能直接沿用上一次的 JIT 产物。

实际中通常采用两种结合的方式进行java代码的编译执行

在 HotSpot 虚拟机中,经典的两个即时编译器分别是 Client Compiler(C1)和 Server Compiler(C2):

  • C1( Client Compiler) 编译模式
  • C2(Server Compiler) 编译模式

C1 编译器: 将字节码编译为本地代码,做相对轻量、保守但速度快的优化,并配合运行时收集性能画像

C2 编译器: 进行更激进、更耗时但通常也更高质量的优化,适合已经被证明是“热点”的代码

C1 更快,C2 更强。现代 HotSpot 还会通过分层编译(Tiered Compilation)把解释执行、C1、C2 组合起来使用:先快速启动,再逐步把热点代码优化到更高层级。

对于 HotSpot 来说,常见的运行方式有:

  • 混合模式(默认):解释执行 + JIT 编译共同参与
  • 解释模式:使用 -Xint 强制解释执行
  • 编译优先模式:使用 -Xcomp 尽量让代码尽快进入编译路径

-client / -server 更适合放在较老版本 JDK 的历史语境中理解;在现代主流 JDK 中,更值得关注的是分层编译和热点探测机制,而不是把它们简单理解成“二选一开关”。

3. AOT 编译器:源代码到机器码

AOT 编译器会尽量在程序运行前生成本地代码,以减少运行期解释执行和 JIT 编译带来的开销。

AOT 的难点在于:Java 具备动态类加载、反射、动态代理等运行时特性,而这些特性会让“提前静态决定一切优化”变得更困难。

因此:

  • AOT 的优势是降低启动期和运行时编译开销
  • JIT 的优势是可以基于真实运行时画像做更有针对性的优化

在传统 HotSpot 路径中,JIT 仍然是更主流的高性能方案;AOT 更适合特定场景,不宜把它理解成对 JIT 的简单替代。

4. 总结

在 JVM 中有三个非常重要的编译器,它们分别是:前端编译器、JIT 编译器、AOT 编译器

前端编译器,最常见的就是我们的 javac 编译器,其将 Java 源代码编译为 Java 字节码文件。JIT 即时编译器,最常见的是 HotSpot 虚拟机中的 Client Compiler 和 Server Compiler,其将 Java 字节码编译为本地机器代码。而 AOT 编译器则能将源代码直接编译为本地机器码。这三种编译器的编译速度和编译质量如下

  • 启动速度上,解释执行通常最快,AOT 次之,重度依赖 JIT 优化的路径启动最慢
  • 峰值性能上,JIT 通常最好,AOT 次之,纯解释执行最弱

JVM 的优势并不在于“只靠某一种执行方式”,而在于让解释器、JIT、类加载机制、运行时优化共同配合,在启动速度、峰值性能和动态能力之间取得平衡。


JVM内存结构

《Java虚拟机规范》中用的是运行时数据区这个术语

运行时数据区:Java虚拟机定义了若干种程序运行期间会使用到的运行时数据区,其中有一些会随着虚拟机启动而创建,随着虚拟机退出而销毁。另外一些则是与线程一一对应的,这些与线程对应的数据区域会随着线程开始和结束而创建和销毁

jvm内存结构.png

  • -Xms:设置堆的初始大小
  • -Xmx:设置堆的最大大小
  • -XX:NewSize:设置新生代初始大小
  • -XX:MaxNewSize:设置新生代最大大小
  • -XX:PermSize:设置永久代初始大小,仅适用于旧版 HotSpot(JDK 7 及更早)
  • -XX:MaxPermSize:设置永久代最大大小,仅适用于旧版 HotSpot(JDK 7 及更早)
  • -Xss:设置每个线程的栈大小

JVM的内存结构分为公有和私有两部分:

公有: 所有线程都共享的部分,指 Java 堆、方法区、运行时常量池

私有: 每个线程的私有数据,包括:PC寄存器、Java 虚拟机栈、本地方法栈

1. 公有部分:Java堆、方法区、运行时常量池

1.1 Java堆(Heap): 对于大多数应用来说,堆是 JVM 管理内存中最大的一块,也是垃圾收集器最主要关注的区域。它是线程共享的,绝大多数对象实例和数组都在这里分配。某些对象在经过逃逸分析后,可能会被优化为标量替换或栈上分配,但这属于 HotSpot 的优化行为,不是 Java 语言层面的硬性保证。

Java 堆是垃圾收集器管理的主要区域,因此也常被称为 “GC 堆”。从经典 HotSpot 分代视角来看,堆通常会被进一步划分为新生代和老年代;新生代里又有 Eden、From Survivor、To Survivor 等区域。

根据Java虚拟机规范的规定,Java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,就像我们的磁盘空间一样。在实现时,既可以实现成固定大小的,也可以是可扩展的,不过当前主流的虚拟机都是按照可扩展来实现的(通过-Xmx和-Xms控制)

如果对象无法在堆中完成分配,并且堆也无法再扩展,就会抛出 OutOfMemoryError

Java 堆根据对象的存活特征,通常会被划分为年轻代和老年代,年轻代再进一步细分为 Eden、From Survivor、To Survivor。

jvm堆结构.png

对象分配时,很多情况下会先进入年轻代的 Eden 区;当 Eden 空间不足时,会触发 Minor GC。存活下来的对象可能被复制到 Survivor 区,达到一定年龄后再晋升到老年代。-XX:MaxTenuringThreshold 可以影响对象晋升年龄。

不过需要注意:

  • “对象永远先进入 Eden” 只是便于入门的简化说法
  • 在某些收集器和参数配置下,大对象可能直接进入老年代
  • System.gc() 只是建议 JVM 进行一次 Full GC,并不保证立即执行

分代设计的依据是弱分代假说:大部分对象朝生夕灭,少数对象会存活较久。如果把所有对象完全混在一起管理,垃圾回收器就更难针对对象寿命特征做高效优化,因此会按代分区。

在经典分代配置中,常见的年轻代比例是 Eden : From : To = 8 : 1 : 1,这也是很多资料里出现 SurvivorRatio=8 的来源。

补充:上面的 Young/Old 分代布局更适合用来理解经典 HotSpot 内存模型。现代收集器如 G1、ZGC、Shenandoah 在堆组织方式上已经更灵活,不应把“固定分区图”机械套到所有 GC 上。

1.2 方法区(Method Area) 方法区与 Java 堆一样,也是各线程共享的逻辑区域。它主要存储类元数据、运行时常量池、静态变量、即时编译后的代码缓存相关信息等。

需要特别区分“规范概念”和“HotSpot 实现”:

  • 方法区 是 JVM 规范中的逻辑概念
  • 永久代(PermGen) 是 JDK 7 及以前 HotSpot 对方法区的一种实现
  • 元空间(Metaspace) 是 JDK 8+ HotSpot 对类元数据存储方式的调整,使用的是本地内存而不是堆内存

当方法区相关区域无法满足内存分配需求时,也会抛出 OutOfMemoryError

补充:运行时常量池属于方法区的一部分;而 String.intern() 相关字符串在 HotSpot JDK 7 之后主要存放在堆中,这一点很容易和“常量池”这个概念混淆。

2. 私有部分:PC寄存器、Java 虚拟机栈、本地方法栈

2.1 PC寄存器: Program Counter Register 也叫程序计数器。是一块较小的内存空间,它的作用可以看做是当前线程所执行的字节码的行号指示器

字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成

如果这个方法不是 native 方法,那么 PC 寄存器就保存 Java 虚拟机正在执行的字节码指令地址。如果是 native 方法,那么 PC 寄存器保存的值是 undefined。任意时刻,一条 Java 虚拟机线程只会执行一个方法的代码,而这个被线程执行的方法称为该线程的当前方法,其地址被存在 PC 寄存器中

多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间的计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存

此内存区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域

2.2 Java 虚拟机栈 JVM Stacks,它的生命周期与线程相同,用来存储栈帧。虚拟机栈描述的是Java方法执行的内存模型:每个方法被执行的时候都会同时创建一个栈帧(Stack Frame)用于存储局部变量表、操作栈、动态链接、方法出口等信息。每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。即存储局部变量与一些过程结果的地方

局部变量表存放了编译期可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它不等同于对象本身,根据不同的虚拟机实现,它可能是一个指向对象起始地址的引用指针,也可能指向一个代表对象的句柄或者其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)

其中64位长度的long和double类型的数据会占用2个局部变量空间(Slot),其余的数据类型只占用1个。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小

在Java虚拟机规范中,对这个区域规定了两种异常状况:

  • 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常
  • 如果虚拟机栈可以动态扩展(当前大部分的Java虚拟机都可动态扩展,只不过Java虚拟机规范中也允许固定长度的虚拟机栈),当扩展时无法申请到足够的内存时会抛出OutOfMemoryError异常

2.3 本地方法栈:

本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的Native方法服务。虚拟机规范中对本地方法栈中的方法使用的语言、使用方式与数据结构并没有强制规定,因此具体的虚拟机可以自由实现它。甚至有的虚拟机(譬如Sun HotSpot虚拟机)直接就把本地方法栈和虚拟机栈合二为一

与虚拟机栈一样,本地方法栈区域也会抛出StackOverflowError和OutOfMemoryError异常

2.4 追踪OutOfMemoryError

对内存结构清晰的认识同样可以帮助理解不同OutOfMemoryErrors:

Exception in thread “main”: java.lang.OutOfMemoryError: Java heap space

原因:对象不能被分配到堆内存中

Exception in thread "main": java.lang.OutOfMemoryError: Metaspace

原因:类元数据无法继续分配,常见于动态生成大量类、频繁热部署、代理类大量产生且类加载器无法释放等场景

Exception in thread "main": java.lang.OutOfMemoryError: PermGen space

原因:这是旧版 HotSpot(JDK 7 及更早)中的永久代溢出表现,本质上也是方法区实现空间不足

Exception in thread “main”: java.lang.OutOfMemoryError: Requested array size exceeds VM limit

原因:申请的数组长度超过了 JVM 实现允许的上限,不一定单纯等同于“堆不够”

Exception in thread “main”: java.lang.OutOfMemoryError: request <size> bytes for <reason>. Out of swap space?

原因:本地内存分配失败。JNI、本地库、JVM 自身都可能使用这部分内存

Exception in thread “main”: java.lang.OutOfMemoryError: <reason> <stack trace>(Native method)

原因:同样属于本地内存分配失败,只是报错位置发生在本地方法路径上

Exception in thread "main": java.lang.OutOfMemoryError: Direct buffer memory

原因:直接内存不足。直接内存不属于 JVM 规范定义的运行时数据区,但在 NIO、Netty 等场景中非常常见


Java 类的加载机制

1. 什么是类的加载

类加载指的是把 .class 文件中的二进制字节流读入内存,并转换为 JVM 可以使用的运行时数据结构,随后在堆中生成对应的 java.lang.Class 对象,作为访问这些元数据的入口。

类加载器并不需要等到某个类被“首次主动使用”时再加载它,JVM规范允许类加载器在预料某个类将要被使用时就预先加载它,如果在预先加载的过程中遇到了.class文件缺失或存在错误,类加载器必须在程序首次主动使用该类时才报告错误(LinkageError错误)如果这个类一直没有被程序主动使用,那么类加载器就不会报告错误

加载.class文件的方式:

  • 从本地系统中直接加载
  • 通过网络下载.class文件
  • 从zip,jar等归档文件中加载.class文件
  • 从专有数据库中提取.class文件
  • 将Java源文件动态编译为.class文件

2. 类的生命周期

java类生命周期.png

JVM 虚拟机执行 class 字节码的过程可以分为七个阶段:加载、验证、准备、解析、初始化、使用、卸载

其中类加载的过程为:加载、验证、准备、解析、初始化五个阶段

在这五个阶段中,加载、验证、准备和初始化这四个阶段发生的顺序是确定的,而解析阶段则不一定,它在某些情况下可以在初始化阶段之后开始,这是为了支持Java语言的运行时绑定(也成为动态绑定或晚期绑定)

另外注意这里的几个阶段是按顺序开始,而不是按顺序进行或完成,因为这些阶段通常都是互相交叉地混合进行的,通常在一个阶段执行的过程中调用或激活另一个阶段

2.1 加载

加载阶段是类加载过程的第一个阶段。在这个阶段,JVM 的主要目的是将字节码从各个位置(网络、磁盘等)转化为二进制字节流加载到内存中,接着会为这个类在 JVM 的方法区创建一个对应的 Class 对象,这个 Class 对象就是这个类各种数据的访问入口

查找并加载类的二进制数据加载是类加载过程的第一个阶段,在加载阶段,虚拟机需要完成以下三件事情:

  • 通过一个类的全限定名来获取其定义的二进制字节流
  • 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
  • 在Java堆中生成一个代表这个类的 java.lang.Class对象,作为对方法区中这些数据的访问入口

相对于类加载的其他阶段而言,加载阶段(准确地说,是加载阶段获取类的二进制字节流的动作)是可控性最强的阶段,因为开发人员既可以使用系统提供的类加载器来完成加载,也可以自定义自己的类加载器来完成加载

加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区之中,而且在Java堆中也创建一个 java.lang.Class类的对象,这样便可以通过该对象访问方法区中的这些数据

2.2 连接之验证

当 JVM 加载完 Class 字节码文件并在方法区创建对应的 Class 对象之后,JVM 便会启动对该字节码流的校验,只有符合 JVM 字节码规范的文件才能被 JVM 正确执行

验证阶段大致完成4个检验动作:

  • 文件格式验证:验证字节流是否符合Class文件格式的规范;例如:是否以 0xCAFEBABE(魔数)开头、主次版本号是否在当前虚拟机的处理范围之内、常量池中的常量是否有不被支持的类型
  • 元数据验证:对字节码描述的信息进行语义分析(注意:对比javac编译阶段的语义分析),以保证其描述的信息符合Java语言规范的要求;例如:这个类是否有父类,除了 java.lang.Object之外
  • 字节码验证:通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的
  • 符号引用验证:确保解析动作能正确执行

当代码数据被加载到内存中后,虚拟机就会对其进行校验,确认它是否满足 JVM 规范。验证阶段会带来一定开销,但它能显著提高运行时安全性。早期资料里常见 -Xverify:none 这类参数,不过实际生产环境通常不建议为了省一点启动时间而关闭验证。

2.3 连接之准备

当完成字节码文件的校验之后,JVM 便会开始为类变量分配内存并初始化。这里需要注意两个关键点,即内存分配的对象以及初始化的类型

  • 内存分配的对象。Java 中的变量可以粗分为「类变量」和「实例变量」两种类型。「类变量」指被 static 修饰的变量,而其他成员变量属于实例变量。在准备阶段,JVM 只会为类变量分配内存,而不会为实例变量分配内存。实例变量最终随对象一起分配在堆上
1
2
3
public static int age = 18; // 准备阶段先赋零值 0,真正赋值 18 发生在初始化阶段
public static final int num = 3; // 编译期常量,在准备阶段就可能直接具备确定值
public String name = "Ekko";
  • 初始化的类型。在准备阶段,JVM 会为类变量分配内存,并为其初始化。但是这里的初始化指的是为变量赋予 Java 语言中该数据类型的零值,而不是用户代码里初始化的值

还需要注意的是:

  • 对基本数据类型来说,类变量和实例变量如果不显式赋值,系统会给它们默认零值;而局部变量在使用前必须显式赋值,否则编译不通过
  • 对于同时被static和final修饰的常量,必须在声明的时候就为其显式地赋值,否则编译时不通过;而只被final修饰的常量则既可以在声明时显式地为其赋值,也可以在类初始化时显式地为其赋值,总之,在使用前必须为其显式地赋值,系统不会为其赋予默认零值
  • 对于引用数据类型reference来说,如数组引用、对象引用等,如果没有对其进行显式地赋值而直接使用,系统都会为其赋予默认的零值,即null
  • 如果在数组初始化时没有对数组中的各元素赋值,那么其中的元素将根据对应的数据类型而被赋予默认的零值

2.4 连接之解析

当通过准备阶段之后,JVM 针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符 7 类符号引用(符号引用就是一组符号来描述目标,可以是任何字面量)进行解析。这个阶段的主要任务是将其在常量池中的符号引用替换成直接其在内存中的直接引用(直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄)

2.5 初始化

到了初始化阶段,用户定义的类初始化逻辑才真正开始执行。更具体地说,就是执行类构造器 <clinit>(),它由静态变量赋值和静态代码块按源码顺序合并而成。

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

  • 声明类变量是指定初始值
  • 使用静态代码块为类变量指定初始值

JVM初始化步骤

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

类初始化时机

  • 创建类的实例,也就是new的方式
  • 访问某个类或接口的静态变量,或者对该静态变量赋值
  • 调用类的静态方法
  • 反射(如 Class.forName("com.nchu.Test")
  • 初始化某个类的子类,则其父类也会被初始化
  • 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类

但也要注意一个经典例外:

  • 访问编译期常量static final 且值在编译期可确定)通常不会触发定义该常量的类初始化,因为编译器可能已经把值内联到调用方常量池里

2.6 使用

当 JVM 完成初始化阶段之后,JVM 便开始从入口方法开始执行用户的程序代码

2.7 卸载

当用户程序代码执行完毕后,JVM 便开始销毁创建的 Class 对象,最后负责运行的 JVM 也退出内存。这个阶段也只是了解一下就可以

Java虚拟机结束生命周期的几种情况:

  • 执行了 System.exit()方法
  • 程序正常执行结束
  • 程序在执行过程中遇到了异常或错误而异常终止
  • 由于操作系统出现错误而导致Java虚拟机进程终止

3. 类加载器

类加载器层次.png

在 HotSpot 中,Bootstrap ClassLoader(启动类加载器)由 JVM 本身以本地代码实现。Java 层面通常拿不到它的直接引用,因此很多地方会看到 null,这并不表示“没有父加载器”,而是表示这个加载器不以普通 ClassLoader Java 对象的形式暴露出来。

AppClassLoader和ExtClassLoader都是在sun.misc.Launcher里定义的,启动过程如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public Launcher() {
  Launcher.ExtClassLoader var1;
  try {
      var1 = Launcher.ExtClassLoader.getExtClassLoader();
  } catch (IOException var10) {
      throw new InternalError("Could not create extension class loader", var10);
  }

  try {
      this.loader = Launcher.AppClassLoader.getAppClassLoader(var1);
  } catch (IOException var9) {
      throw new InternalError("Could not create application class loader", var9);
  }

  Thread.currentThread().setContextClassLoader(this.loader);
  String var2 = System.getProperty("java.security.manager");
  if (var2 != null) {
      SecurityManager var3 = null;
      if (!"".equals(var2) && !"default".equals(var2)) {
          try {
              var3 = (SecurityManager)this.loader.loadClass(var2).newInstance();
          } catch (IllegalAccessException var5) {
          } catch (InstantiationException var6) {
          } catch (ClassNotFoundException var7) {
          } catch (ClassCastException var8) {
          }
      } else {
          var3 = new SecurityManager();
      }

      if (var3 == null) {
          throw new InternalError("Could not create SecurityManager: " + var2);
      }

      System.setSecurityManager(var3);
  }

}

注意:这里父类加载器并不是通过继承关系来实现的,而是采用组合实现的

从 JVM 规范角度看,只存在两大类:

  • 启动类加载器:由 JVM 自身实现,是虚拟机的一部分
  • 所有其它的类加载器:这些类加载器都由Java语言实现,独立于虚拟机之外,并且全部继承自抽象类 java.lang.ClassLoader,这些类加载器需要由启动类加载器加载到内存中之后才能去加载其他的类

从 Java 开发者视角看,经典 JDK 8 语境下大致分为三层:

  • 启动类加载器:负责加载核心类库,Java 程序通常不能直接拿到它
  • 扩展类加载器:ExtClassLoader,负责加载扩展目录中的类库,这是 JDK 8 及更早版本里常见的说法
  • 应用程序类加载器:AppClassLoader,负责加载用户 classpath 下的类,也是日常最常打交道的默认加载器

补充:JDK 9 引入模块系统后,类加载器层次和搜索路径与 JDK 8 已经不完全相同,ExtClassLoader 也被 PlatformClassLoader 取代。因此很多旧资料中的目录路径只适用于较老版本 JDK。

JVM 类加载机制

  • 按需加载,类通常在首次主动使用时才真正进入加载和初始化流程
  • 父类委托,先让父类加载器试图加载该类,只有在父类加载器无法加载该类时才尝试从自己的类路径中加载该类
  • 缓存机制,已经被某个类加载器成功定义过的 Class 通常会被复用;因此在同一个 JVM 进程里,单纯替换磁盘上的 .class 文件往往不会立刻生效,除非借助新的类加载器或重启进程

另外需要记住一个实用结论:一个类在 JVM 中的唯一性,通常由“类的全限定名 + 定义它的类加载器”共同决定。同名类被不同类加载器加载后,会被视为不同类型。

4. 类的加载

类加载有几种常见触发方式:

  • 命令行启动应用时候由JVM初始化加载
  • 通过Class.forName()方法动态加载,默认会执行初始化块
  • 通过ClassLoader.loadClass()方法动态加载,不会执行初始化块

更准确地说:

  • Class.forName(String) 默认等价于“加载 + 连接 + 初始化”
  • ClassLoader.loadClass() 主要做加载,是否初始化取决于后续是否发生主动使用

5. 双亲委派模型

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

双亲委派机制:

  • AppClassLoader 收到类加载请求时,通常会先委派给父加载器 ExtClassLoader
  • ExtClassLoader 收到请求时,再继续向上委派给 Bootstrap ClassLoader
  • 如果父加载器在自己的搜索范围内找不到目标类,子加载器才会回退到自己的搜索路径继续尝试
  • 如果最终仍然无法完成加载,通常会抛出 ClassNotFoundException

双亲委派模型的核心价值在于:

  • 避免核心类被下层类加载器随意替换
  • 保证同一个类在合理边界内尽量只被加载一次
  • 维持 Java 核心类型体系的稳定性,例如避免应用自己伪造一个 java.lang.String

补充

Java 类常见成员包括:字段、方法、构造器、代码块、内部类

初始化块又被称为代码块,属于类中的一种成员形式。它不是一个独立的方法,而是一段由编译器合并进构造器或 <clinit>() 的初始化逻辑,因此可以看作构造器和静态初始化过程的一种补充

1
2
3
[修饰符]{
  方法体;
}
  • 修饰符只能是 static,使用 static 修饰的初始化块称为静态初始化块,没有 static 的称为普通初始化块
  • 方法体中可以为任意逻辑语句,包含输入、输出、变量、运算等

优点:

  • 和构造器很像,都是用于初始化信息
  • 当多个构造器中有重复的语句,可以往上提取到初始化块中,提高代码的复用性

特点:

调用时机:

  • 静态初始化块:加载类
  • 普通初始化块:创建对象

静态初始化块只会调用一次,随着类的加载而加载(因为类只加载一次)

普通初始化块可以调用多次,随着对象的创建而创建

一个类中可以有多个静态初始化块和多个普通初始化块

静态初始化块执行早于普通初始化块

同一个类型的初始化块的执行顺序取决于定义的先后顺序

执行顺序:

静态初始化块、静态属性初始化 > 普通初始化块、普通属性初始化 > 构造器

继承关系的执行顺序:

爷爷类 静态初始化块、静态属性初始化块 > 父亲类 静态初始化块、静态属性初始化块 > 儿子类 静态初始化块、静态属性初始化块 > 爷爷类 普通初始化块、普通属性初始化 > 构造器 > 父亲类 普通初始化块、普通属性初始化 > 构造器 > 儿子类 普通初始化块、普通属性初始化 > 构造器 >

静态初始化块中遵循静态成员的特点,只能直接访问静态成员

初始化位置:

  • 普通 final 属性:初始化必须在声明处、构造器或普通代码块中完成
  • 静态 final 属性:初始化必须在声明处或静态代码块中完成