摘要:
JVM是Java程序运行的核心环境,负责解释执行字节码并管理内存。其核心功能包括类加载与验证、字节码执行优化、内存管理与垃圾回收(GC)、跨平台支持及安全性保障。JVM架构包含程序计数器、虚拟机栈、本地方法栈、堆和方法区等内存区域,其中堆分为新生代(Eden、Survivor区)和老年代,采用不同GC策略(MinorGC和FullGC)。类加载遵循双亲委派机制,但Tomcat等容器会打破该机制以实现应用隔离。垃圾回收通过可达性分析算法判断对象是否可回收,主要触发条件包括内存不足、手动调用System.gc()或达到阈值。JVM的自动内存管理机制有效减少了内存泄漏风险。
一、JVM 概述
(一)JVM 定义与核心地位
定义:
JVM是一个虚拟的计算机系统 ,通过解释和执行Java字节码(.class文件)来运行Java程序。它由字节码指令集、寄存器、栈、垃圾回收堆和方法区等核心组件构成,本质是遵循Java SE规范的抽象计算环境。JVM的执行引擎基于栈结构,能够将编译后的字节码转换为具体硬件平台的机器指令。
核心地位:
跨平台能力的基石
JVM通过“一次编译,到处运行 ”的特性实现平台无关性。Java源代码编译为与平台无关的字节码后,JVM屏蔽底层硬件和操作系统的差异,确保程序在不同环境中一致运行。Java生态的运行基础
作为Java程序的唯一运行环境 ,JVM不仅管理内存、执行代码,还通过垃圾回收机制优化资源利用。所有Java技术(如框架、库)均依赖JVM提供的运行时支持。技术演进的核心驱动力
JVM的设计目标是构建可扩展、高性能的执行环境。其规范由Java社区进程(JCP)持续更新,推动着Java语言及工具链的发展。
(二)JVM 主要功能
一、类加载与验证
- 动态加载与链接
JVM通过类加载器(ClassLoader)加载编译后的.class
文件,并按需动态链接类(如验证字节码合法性、解析符号引用)。例如,验证阶段会检查字节码是否符合规范,防止恶意代码注入 - 层次化加载机制
采用双亲委派模型 ,确保核心类库(如java.lang.*
)优先由Bootstrap ClassLoader(启动类加载器)加载,避免重复加载和安全风险
二、字节码执行与优化
- 解释与编译混合执行
- 解释器
逐条执行字节码,确保跨平台兼容性。
- 即时编译器(JIT)
将热点代码(频繁执行的方法)编译为本地机器指令,提升性能。例如,HotSpot JVM的C1/C2编译器分别优化低延迟和高吞吐场景。
- 解释器
- 执行引擎控制流
通过程序计数器(PC寄存器) 记录当前线程执行位置,支持分支、循环等复杂逻辑。
三、内存管理与垃圾回收
- 运行时数据区划分
- 堆(Heap) 存储对象实例和数组,是垃圾回收的主要区域
- 方法区(Metaspace) 存放类元数据、常量池和静态变量(JDK 8后移至本地内存)
- 栈(Stack)
每个线程私有的栈帧存储局部变量、操作数栈、动态链接和方法出口等。
- 自动内存回收
通过垃圾回收器(GC) 自动管理堆内存,标记并清理不可达对象。例如,G1 GC通过分区回收减少停顿时间
四、跨平台与安全性
- “一次编译,到处运行”
字节码与平台无关,JVM屏蔽底层硬件/操作系统差异,实现跨平台兼容 - 安全机制
类加载验证、字节码校验及运行时权限检查(如SecurityManager
)共同保障代码安全性
五、本地方法接口(JNI Java Native Interface)
通过本地库接口 调用C/C++等本地代码,扩展JVM功能(如高性能计算或硬件交互)。例如,Java的System.currentTimeMillis()
依赖本地方法实现。
六、性能监控与调优
JVM提供JMX(Java Management Extensions) 等工具接口,支持实时监控内存、线程状态及GC行为,助力性能优化。
二、JVM 架构解析
1.JVM的内存模型
根据 JDK 8 规范,JVM 运行时内存共分为虚拟机栈、堆、元空间、程序计数器、本地方法栈五个部分。还有一部分内存叫直接内存,属于操作系统的本地内存,也是可以直接操作的。
JVM的内存结构主要分为以下几个部分:
程序计数器:可以看作是当前线程所执行的字节码的行号指示器,用于存储当前线程正在执行的 Java 方法的 JVM 指令地址。如果线程执行的是 Native 方法,计数器值为 null。是唯一一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域,生命周期与线程相同。
Java 虚拟机栈:每个线程都有自己独立的 Java 虚拟机栈,生命周期与线程相同。每个方法在执行时都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。可能会抛出 StackOverflowError 和 OutOfMemoryError 异常。
本地方法栈:与 Java 虚拟机栈类似,主要为虚拟机使用到的 Native 方法服务,在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。本地方法执行时也会创建栈帧,同样可能出现 StackOverflowError 和 OutOfMemoryError 两种错误。
Java 堆:是 JVM 中最大的一块内存区域,被所有线程共享,在虚拟机启动时创建,用于存放对象实例和数组。从内存回收角度,堆被划分为新生代和老年代,新生代又分为 Eden 区和两个 Survivor 区(From Survivor 和 To Survivor)。如果在堆中没有内存完成实例分配,并且堆也无法扩展时会抛出 OutOfMemoryError 异常。
方法区(元空间):在 JDK 1.8 及以后的版本中,方法区被元空间取代,使用本地内存。用于存储已被虚拟机加载的类信息、常量、静态变量等数据。虽然方法区被描述为堆的逻辑部分,但有 “非堆” 的别名。方法区可以选择不实现垃圾收集,内存不足时会抛出 OutOfMemoryError 异常。
运行时常量池:是方法区的一部分,用于存放编译期生成的各种字面量和符号引用,具有动态性,运行时也可将新的常量放入池中。当无法申请到足够内存时,会抛出 OutOfMemoryError 异常。
直接内存:不属于 JVM 运行时数据区的一部分,通过 NIO 类引入,是一种堆外内存,可以显著提高 I/O 性能。直接内存的使用受到本机总内存的限制,若分配不当,可能导致 OutOfMemoryError 异常。
这里讲讲堆:
Java堆(Heap)是Java虚拟机(JVM)中内存管理的一个重要区域,主要用于存放对象实例和数组。随着JVM的发展和不同垃圾收集器的实现,堆的具体划分可能会有所不同,但通常可以分为以下几个部分:
新生代(Young Generation):新生代分为Eden Space和Survivor Space。在Eden Space中, 大多数新创建的对象首先存放在这里。Eden区相对较小,当Eden区满时,会触发一次Minor GC(新生代垃圾回收)。在Survivor Spaces中,通常分为两个相等大小的区域,称为S0(Survivor 0)和S1(Survivor 1)。在每次Minor GC后,存活下来的对象会被移动到其中一个Survivor空间,以继续它们的生命周期。这两个区域轮流充当对象的中转站,帮助区分短暂存活的对象和长期存活的对象。
补充下Minor GC
Minor GC(次要垃圾回收)是针对新生代(Young Generation)的垃圾回收过程,主要目标是快速清理 Eden 区和 Survivor 区中的短生命周期对象。以下是其具体过程:
1. 标记存活对象
从 GC Roots(如线程栈、静态变量等)出发,标记所有可达对象。
新生代的对象通常生命周期短,大部分对象会在此阶段被判定为垃圾。
2. 复制存活对象(Copying)
Eden → Survivor
将 Eden 区和当前使用的 Survivor(From 区)中的存活对象复制到另一个 Survivor(To 区)。
年龄增长
每经历一次 Minor GC,存活对象的年龄(Age)加 1。
晋升老年代
当对象年龄超过阈值(默认 15,可通过
-XX:MaxTenuringThreshold
调整)或 Survivor 区空间不足时,对象会被晋升到老年代。
3. 清理垃圾
直接清空 Eden 和 From 区(无需复杂清理,因存活对象已被复制走)。
交换 Survivor 区的角色:原来的 To 区变为下一次 GC 的 From 区。
4. 触发条件
Eden 区空间不足时触发(通常由新对象分配请求触发)。
可通过 JVM 参数调整新生代大小(如
-Xmn
)。
特点
STW(Stop-The-World)
暂停所有应用线程,但时间极短(毫秒级)。
高效
采用复制算法,避免内存碎片。
高频
发生频率高于 Full GC。
老年代(Old Generation/Tenured Generation):存放过一次或多次Minor GC仍存活的对象会被移动到老年代。老年代中的对象生命周期较长,因此Major GC(也称为Full GC,涉及老年代的垃圾回收)发生的频率相对较低,但其执行时间通常比Minor GC长。老年代的空间通常比新生代大,以存储更多的长期存活对象。
元空间(Metaspace):从Java 8开始,永久代(Permanent Generation)被元空间取代,用于存储类的元数据信息,如类的结构信息(如字段、方法信息等)。jdk1.8后,元空间并不在Java堆中,而是使用本地内存,这解决了永久代容易出现的内存溢出问题。
大对象区(Large Object Space / Humongous Objects):在某些JVM实现中(如G1垃圾收集器),为大对象分配了专门的区域,称为大对象区或Humongous Objects区域。大对象是指需要大量连续内存空间的对象,如大数组。这类对象直接分配在老年代,以避免因频繁的年轻代晋升而导致的内存碎片化问题。
这里再补充一下Full GC(完全垃圾回收)
Full GC(完全垃圾回收)是针对 整个堆内存(包括新生代、老年代)以及 方法区(元空间) 的垃圾回收过程,其触发条件更严格且耗时更长。以下是具体过程、触发条件和优化要点:
Full GC 的具体过程
标记阶段(Marking)
从所有 GC Roots(线程栈、静态变量、JNI引用等)出发,递归标记堆中所有存活对象。
若使用 并发标记算法(如CMS、G1),会尽量减少STW时间。
清理阶段(Cleaning)
Serial/Parallel Old
标记-整理(Mark-Compact),避免内存碎片。
CMS
并发标记-清除(Mark-Sweep),会产生碎片。
G1
分区(Region)回收,优先清理垃圾比例高的区域。
新生代
采用复制算法(Minor GC),存活对象晋升到老年代或Survivor区。
老年代
根据垃圾回收器不同,算法各异:
方法区(元空间)
卸载无用的类、常量等。
压缩阶段(可选)
部分回收器(如Parallel Old)会压缩老年代,减少碎片,提升内存分配效率。
Full GC 的触发条件
老年代空间不足
Minor GC后存活对象需晋升到老年代,但老年代剩余空间不足(可能因
-XX:SurvivorRatio
或-XX:MaxTenuringThreshold
设置不合理)。Promotion Failed
新生代对象晋升失败时触发。
方法区(元空间)不足
类加载过多或元空间配置过小(
-XX:MaxMetaspaceSize
)。
显式调用
通过
System.gc()
或Runtime.getRuntime().gc()
触发(建议禁用:-XX:+DisableExplicitGC
)。
分配担保失败
Minor GC前,若老年代剩余空间小于新生代对象总大小(或历次晋升平均大小),会先尝试Full GC。
CMS/G1的并发模式失败
CMS并发清理期间老年代空间耗尽,或G1无法在预期时间内完成回收。
Full GC 的性能影响
STW(Stop-The-World)
暂停所有应用线程,耗时可能达 秒级(与堆大小、对象数量正相关)。
CPU密集型
标记和压缩阶段会占用大量CPU资源。
碎片问题
CMS等基于标记-清除的回收器可能导致老年代碎片,最终触发压缩式Full GC。
为什么大对象一般都放在老年代?
大对象通常会直接分配到老年代。
新生代主要用于存放生命周期较短的对象,并且其内存空间相对较小。如果将大对象分配到新生代,可能会很快导致新生代空间不足,从而频繁触发 Minor GC。而每次 Minor GC 都需要进行对象的复制和移动操作,这会带来一定的性能开销。将大对象直接分配到老年代,可以减少新生代的内存压力,降低 Minor GC 的频率。
大对象通常需要连续的内存空间,如果在新生代中频繁分配和回收大对象,容易产生内存碎片,导致后续分配大对象时可能因为内存不连续而失败。老年代的空间相对较大,更适合存储大对象,有助于减少内存碎片的产生。
再看看栈:
JVM 中的 栈(Stack) 是线程私有的内存区域,用于存储方法调用时的 栈帧(Stack Frame),每个方法从调用到执行完成对应一个栈帧的入栈和出栈过程。栈的大小决定了方法调用的深度(可通过 -Xss
调整,如 -Xss1M
)。以下是核心要点:
1. 栈的结构
线程私有
每个线程创建时分配一个独立的栈,生命周期与线程相同。
栈帧(Stack Frame)
每个方法调用会压入一个栈帧,包含以下内容:
局部变量表(Local Variables)
存储方法参数和局部变量,以 变量槽(Slot) 为单位(32位占1 Slot,64位如
long
/double
占2 Slot)。
示例:void foo(int a, long b)
的局部变量表索引0是a
,索引1-2是b
。操作数栈(Operand Stack)
用于执行字节码指令的临时数据存储(如加减乘除操作时的中间结果)。
示例:iadd
指令会从操作数栈弹出两个int
相加后压回栈顶。动态链接(Dynamic Linking)
指向运行时常量池中该方法的符号引用,用于支持多态(如虚方法调用)。
方法返回地址(Return Address)
方法正常退出或异常退出时,返回调用者的位置(PC寄存器值)。
2. 栈的运作示例
public class Main {
public static void main(String[] args) {
int x = 1;
int y = add(x, 2); // 调用add方法
}
static int add(int a, int b) {
return a + b;
}
}
执行流程:
main
方法栈帧入栈,局部变量表存储
args
、x
、y
。调用
add
方法时,add
栈帧入栈:操作数栈压入
a=1
和b=2
。执行
iadd
后,栈顶结果为3
。
add
栈帧出栈,返回值赋给
main
栈帧的y
。
3. 栈的异常
- StackOverflowError
当栈深度超过
-Xss
限制时抛出(如无限递归调用)。
void infinite() { infinite(); } // 递归调用导致栈溢出
- OutOfMemoryError
线程创建过多导致栈总内存耗尽(需减少线程数或增大
-Xss
)。
4. 栈 vs 堆
特性 | 栈 | 堆 |
---|---|---|
内存分配 | 自动分配/释放(方法结束弹出) | 由GC管理 |
存储内容 | 局部变量、方法调用链 | 对象实例、数组 |
线程共享 | 线程私有 | 线程共享 |
访问速度 | 快(直接操作内存) | 慢(需指针寻址) |
更具体一些:
用途:栈主要用于存储局部变量、方法调用的参数、方法返回地址以及一些临时数据。每当一个方法被调用,一个栈帧(stack frame)就会在栈中创建,用于存储该方法的信息,当方法执行完毕,栈帧也会被移除。堆用于存储对象的实例(包括类的实例和数组)。当你使用new关键字创建一个对象时,对象的实例就会在堆上分配空间。
生命周期:栈中的数据具有确定的生命周期,当一个方法调用结束时,其对应的栈帧就会被销毁,栈中存储的局部变量也会随之消失。堆中的对象生命周期不确定,对象会在垃圾回收机制(Garbage Collection, GC)检测到对象不再被引用时才被回收。
存取速度:栈的存取速度通常比堆快,因为栈遵循先进后出(LIFO, Last In First Out)的原则,操作简单快速,直接操作内存。堆的存取速度相对较慢,因为对象在堆上的分配和回收需要更多的时间,而且垃圾回收机制的运行也会影响性能。
存储空间:栈的空间相对较小,且固定,由操作系统管理。当栈溢出时,通常是因为递归过深或局部变量过大。堆的空间较大,动态扩展,由JVM管理。堆溢出通常是由于创建了太多的大对象或未能及时回收不再使用的对象。
可见性:栈中的数据对线程是私有的,每个线程有自己的栈空间。堆中的数据对线程是共享的,所有线程都可以访问堆上的对象。
常见的疑惑:栈中存的到底是指针还是对象?
在JVM内存模型中,栈(Stack)主要用于管理线程的局部变量和方法调用的上下文,而堆(Heap)则是用于存储所有类的实例和数组。
当我们在栈中讨论“存储”时,实际上指的是存储基本类型的数据(如int, double等)和对象的引用,而不是对象本身。
这里的关键点是,栈中存储的不是对象,而是对象的引用。也就是说,当你在方法中声明一个对象,比如MyObject obj = new MyObject();,这里的obj实际上是一个存储在栈上的引用,指向堆中实际的对象实例。这个引用是一个固定大小的数据(例如在64位系统上是8字节),它指向堆中分配给对象的内存区域。
三、类初始化和类加载
在 JVM 中,类加载(Class Loading) 和 类初始化(Class Initialization) 是两个不同的阶段,但经常被混淆。下面详细解析它们的区别、触发条件及执行过程。
1. 类加载(Class Loading)
类加载 是指将 .class
字节码文件加载到 JVM 内存,并生成 Class<?>
对象的过程。它由 类加载器(ClassLoader) 完成,分为 加载、连接(验证、准备、解析)、初始化 三个阶段。
类从被加载到虚拟机内存开始,到卸载出内存为止,它的整个生命周期包括以下 7 个阶段:
加载:通过类的全限定名(包名 + 类名),获取到该类的.class文件的二进制字节流,将二进制字节流所代表的静态存储结构,转化为方法区运行时的数据结构,在内存中生成一个代表该类的Java.lang.Class对象,作为方法区这个类的各种数据的访问入口。(简单来说就是在程序运行时,通过类的全限定名找到对应的.class文件,将其读入内存并将其中的静态存储结构转化为方法区中的数据结构,最终生成一个代表该类的Class对象,方便程序对该类进行各种操作。)
连接:验证、准备、解析 3 个阶段统称为连接。
验证:确保class文件中的字节流包含的信息,符合当前虚拟机的要求,保证这个被加载的class类的正确性,不会危害到虚拟机的安全。验证阶段大致会完成以下四个阶段的检验动作:文件格式校验、元数据验证、字节码验证、符号引用验证(确保
.class
文件符合 JVM 规范(如魔数检查、字节码验证)。)准备:为类中的静态字段分配内存(在方法区),并设置默认的初始值(如
int=0
,boolean=false
,引用类型null
)。(但是被final修饰的static字段不会设置,因为final在编译的时候就分配了。如static final int x = 123
在此阶段直接赋值。)解析:解析阶段是虚拟机将常量池的「符号引用」直接替换为「直接引用」的过程。符号引用是以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用的时候可以无歧义地定位到目标即可。直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄,直接引用是和虚拟机实现的内存布局相关的。如果有了直接引用, 那引用的目标必定已经存在在内存中了。(将符号引用(如
java/lang/Object
)转换为直接引用(内存地址)。)
初始化:初始化是整个类加载过程的最后一个阶段,初始化阶段简单来说就是执行类的构造器方法(
<clinit>()
方法),要注意的是这里的构造器方法(<clinit>()
方法)并不是开发者写的,而是编译器自动生成的。
使用:使用类或者创建对象。
卸载:如果有下面的情况,类就会被卸载:1. 该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例。2. 加载该类的ClassLoader已经被回收。 3. 类对应的Java.lang.Class对象没有任何地方被引用,无法在任何地方通过反射访问该类的方法。
对类初始化的一些需要清楚的点:
类初始化 是类加载的最后一步,执行 <clinit>()
方法(由编译器自动生成),完成 静态变量赋值 和 静态代码块执行。
(1) 触发条件(严格规定)
JVM 规范规定 以下 6 种情况会触发初始化:
new
、getstatic
、putstatic
、invokestatic
指令(如
new
实例、访问静态变量/方法)。- 反射调用
(如
Class.forName("com.example.Test")
)。 - 子类初始化时,父类未初始化则先初始化父类。
- 主类(包含
main()
的类)在启动时初始化。 java.lang.invoke.MethodHandle
动态调用时(涉及
invokedynamic
指令)。- 接口的默认方法(JDK 8+)被实现类初始化时。
(2) 不会触发初始化的场景
- 访问
static final
常量(已在准备阶段赋值)。
- 通过数组定义引用类
(如
Test[] arr = new Test[10]
)。 - 通过类名访问
Class
对象(如
Test.class
)。 - 调用
ClassLoader.loadClass()
(仅加载,不初始化)。
(3) <clinit>()
方法的特点
- 由编译器自动生成
合并所有静态变量赋值和静态代码块。
- 线程安全
(JVM 加锁保证只执行一次)。
- 父类
<clinit>()
先执行(父类静态代码块优先于子类)。
示例:
class Parent {
static int x = 10; // (1)
static {
int x = 20; // (2)
System.out.println("Parent static block executed, local x = " + x);
}
}
class Child extends Parent {
static int y = 20; // (3)
static {
int y = 40; // (4)
System.out.println("Child static block executed, local y = " + y);
}
}
public class Main {
public static void main(String[] args) {
System.out.println("Parent.x = " + Parent.x);
System.out.println("Child.y = " + Child.y);
}
}
执行顺序:
(1) → (2) → (3) → (4)
(父类优先)。
类加载器有哪些?
启动类加载器(Bootstrap Class Loader):这是最顶层的类加载器,负责加载Java的核心库(如位于jre/lib/rt.jar中的类),它是用C++编写的,是JVM的一部分。启动类加载器无法被Java程序直接引用。
扩展类加载器(Extension Class Loader):它是Java语言实现的,继承自ClassLoader类,负责加载Java扩展目录(jre/lib/ext或由系统变量Java.ext.dirs指定的目录)下的jar包和类库。扩展类加载器由启动类加载器加载,并且父加载器就是启动类加载器。
系统类加载器(System Class Loader)/ 应用程序类加载器(Application Class Loader):这也是Java语言实现的,负责加载用户类路径(ClassPath)上的指定类库,是我们平时编写Java程序时默认使用的类加载器。系统类加载器的父加载器是扩展类加载器。它可以通过ClassLoader.getSystemClassLoader()方法获取到。
自定义类加载器(Custom Class Loader):开发者可以根据需求定制类的加载方式,比如从网络加载class文件、数据库、甚至是加密的文件中加载类等。自定义类加载器可以用来扩展Java应用程序的灵活性和安全性,是Java动态性的一个重要体现。
这些类加载器之间的关系形成了双亲委派模型,其核心思想是当一个类加载器收到类加载的请求时,首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中。
只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。(后面会细讲)
总结一下就是:
启动类加载器(Bootstrap):
- C++写的,JVM亲儿子
只管加载最核心的库(比如
rt.jar
),Java代码里调不到它。
- C++写的,JVM亲儿子
扩展类加载器(Extension):
- Java写的,干杂活的
专加载
jre/lib/ext
目录下的扩展包,爹是启动类加载器。
- Java写的,干杂活的
系统类/应用程序类加载器(Application):
- Java写的,打工人
默认加载你写的代码(ClassPath路径),爹是扩展类加载器,能直接通过
ClassLoader.getSystemClassLoader()
叫来干活。
- Java写的,打工人
自定义类加载器(Custom):
- 你自己写的,想咋加载就咋加载
(比如从网络、数据库捞class文件),灵活度拉满,但得继承
ClassLoader
类。
- 你自己写的,想咋加载就咋加载
双亲委派机制:
- “拼爹”模式
儿子收到加载请求,先甩锅给爹,爹搞不定儿子才自己上,最终锅会甩到启动类加载器。
- 好处
防止重复加载,保证核心库的安全(比如你写个
java.lang.String
也白搭,启动类加载器早加载好了)。
一句话总结:从Bootstrap到Custom,一级级甩锅,核心库优先,自定义兜底。
双亲委派机制
先来谈谈双亲委派模型,简单说就是当类加载器(Class-Loader)试图加载某个类型的时候,除非父加载器找不到相应类型,否则尽量将这个任务代理给当前加载器的父加载器去做。使用委派模型的目的是避免重复加载 Java 类型。
双亲委派的工作流程
当需要加载一个类时,流程如下:
- 委托父加载器 当前类加载器首先将请求委托给父加载器,递归至顶层(Bootstrap ClassLoader)
。
- 父加载器尝试加载 父加载器在其负责的路径中查找并加载类。若成功,则直接返回
。
- 子加载器自行加载 若父加载器无法加载(如类不存在于父加载器的路径),子加载器才会尝试自己加载
。
例如,加载java.util.ArrayList
时,Application ClassLoader会依次委托Extension和Bootstrap ClassLoader,最终由Bootstrap加载。
🌰 示例场景:加载 java.lang.String
- 应用程序代码
中写了
String s = new String();
,触发java.lang.String
类的加载。 - AppClassLoader
(应用类加载器)收到请求,先委托给父加载器 ExtClassLoader。
- ExtClassLoader
继续向上委托给 BootstrapClassLoader(顶级加载器)。
- BootstrapClassLoader
在
JRE/lib/rt.jar
中找到java.lang.String
并加载(成功)。如果找不到,会向下抛给 ExtClassLoader,再抛给 AppClassLoader。
🔍 关键流程(以 MyClass.class
为例)
// 假设用户自定义类 MyClass
public class MyClass {
public void print() {
System.out.println("Hello");
}
}
- 用户调用
new MyClass()
触发加载。
- AppClassLoader 收到请求,委派链:
AppClassLoader → ExtClassLoader → BootstrapClassLoader
- BootstrapClassLoader
和 ExtClassLoader 均无法加载(非核心类或扩展类),最终由 AppClassLoader 从
classpath
加载。
双亲委派模型的作用
保证类的唯一性:通过委托机制,确保了所有加载请求都会传递到启动类加载器,避免了不同类加载器重复加载相同类的情况,保证了Java核心类库的统一性,也防止了用户自定义类覆盖核心类库的可能。
保证安全性:由于Java核心库被启动类加载器加载,而启动类加载器只加载信任的类路径中的类,这样可以防止不可信的类假冒核心类,增强了系统的安全性。例如,恶意代码无法自定义一个Java.lang.System类并加载到JVM中,因为这个请求会被委托给启动类加载器,而启动类加载器只会加载标准的Java库中的类。
支持隔离和层次划分:双亲委派模型支持不同层次的类加载器服务于不同的类加载需求,如应用程序类加载器加载用户代码,扩展类加载器加载扩展框架,启动类加载器加载核心库。这种层次化的划分有助于实现沙箱安全机制,保证了各个层级类加载器的职责清晰,也便于维护和扩展。
简化了加载流程:通过委派,大部分类能够被正确的类加载器加载,减少了每个加载器需要处理的类的数量,简化了类的加载过程,提高了加载效率。
沙箱安全机制
沙箱(sandbox)是一种用于隔离和保护计算机系统中的应用程序的安全机制。它通过将应用程序运行在独立的虚拟环境中来防止恶意软件对主机系统的攻击。
具体来说,沙箱通常包括以下几个组件:
沙箱环境:沙箱提供了一个隔离的应用程序运行环境,其中包含了操作系统、硬件设备和其他必要的资源。
安全策略:沙箱定义了一系列安全策略,例如限制应用程序可以访问的文件和网络资源等,以确保应用程序不会对主机系统造成损害。
监控和审计:沙箱会监控应用程序的行为,并记录其所有的操作,以便后续分析和审计。
应用程序管理:沙箱提供了应用程序管理和部署的功能,使管理员能够轻松地安装、升级和卸载应用程序。
总之,沙箱安全机制可以帮助组织保护其敏感数据和系统免受恶意软件的攻击。
如何打破双亲委派机制?
先再看看另外一个更加清晰的委派机制流程图
如何去打破双亲委派机制,这个问题很经典,面试如果问到JVM,这个问题大概率会被问到。
我们既然知道了类的加载方式默认是双亲委派,那么如果我们有一个类想要通过自定义的类加载器来加载这个类,而不是通过系统默认的类加载器,也就是不走双亲委派那一套,而是走自定义的类加载器。
首先我们得清楚,双亲委派的机制是ClassLoader类中的loadClass方法实现的,打破双亲委派,其实就是重写这个方法,来用我们自己的方式来实现即可。
当然这里要注意一下,Object.class这是对象的顶级类,改变类的类加载器的时候要注意,如果全部改了,Object.class就找不到了,加载不了了
所以呢,这里重写的时候,要注意分类解决,把你想要通过自定义类加载器加载的和想通过默认类加载器加载的分隔开。
如果不想打破双亲委派模型,就重写ClassLoader类中的findClass()方法即可,无法被父类加载器加载的类最终会通过这个方法被加载。
而如果想打破双亲委派模型则需要重写ClassLoader类的loadClass()方法(当然其中的坑也不会少)。典型的打破双亲委派模型的框架和中间件有tomcat与osgi
Tomcat是如何打破"双亲委派"机制的?
首先我们得想想,Tomcat为什么要打破"双亲委派"?
其目的其实很简单,我们知道,web容器可能是需要部署多个应用程序的,上图就是其中一个例子。但是假设不同的应用程序可能会同时依赖第三方类库的不同版本。
而类加载机制是要确保唯一性的(上文原话:通过委托机制,确保了所有加载请求都会传递到启动类加载器,避免了不同类加载器重复加载相同类的情况,保证了Java核心类库的统一性,也防止了用户自定义类覆盖核心类库的可能。),但是总不能要求同一个类库在web容器中只有一份吧?所以Tomcat就需要保证每个应用程序的类库都是相互隔离并独立的,这也是它为什么打破双亲委派机制的主要目的。(Tomcat打破双亲委派机制的核心目的是解决多应用隔离问题。)
也就是说:
因为在一些情况下,应用程序需要加载一些不受控制的类。如果使用双亲委派机制,这些不受控制的类可能会被系统类库中的同名类所覆盖,导致程序出错。
通过打破双亲委派机制,Tomcat可以自己负责加载应用程序所需的类,并且不会受到系统类库的影响。这样可以保证应用程序的稳定性和安全性。
用通俗例子解释:
假设你有一个Tomcat服务器(相当于一个小区),里面部署了2个Web应用:
应用A 需要用老版本的
commons-lib.jar
(1.0版)应用B 需要用新版本的
commons-lib.jar
(2.0版)
如果遵循Java默认的双亲委派机制(小区共用同一个库房):
类加载器会优先加载父容器的类
最终两个应用会强制共用同一个版本的库(比如先加载的1.0版)
导致应用B崩溃(版本不兼容)
Tomcat的解决方案(打破规则):
给每个应用配独立的类加载器(每家有自己的小库房)
应用A用自己的1.0版,应用B用自己的2.0版
互不干扰,实现隔离
本质就是:牺牲类加载的统一性,换取多版本库的共存能力。
到这里,关于“Tomcat为什么要打破"双亲委派"?”的问题,相信你应该懂了。
Tomcat类加载概述
Tomcat的ClassLoader层级如下所示
CommonClassLoader(通用类加载器):是Tomcat中的一个类加载器,主要用于加载catalina.base/lib定义的目录和jar以及{catalina.home}/lib定义的目录和jar。它可以被Tomcat和所有的Web应用程序共同使用,实现了Web应用程序之间的类加载器相互隔离独立的目的。
与之相对应的是WebAppClassLoader(Web应用的类加载器):它是Tomcat加载应用的核心类加载器,每个Web应用程序都有一个WebAppClassLoader,类库仅仅可以被此Web应用程序使用,对Tomcat和其他Web程序都不可见。WebAppClassLoader打破了“双亲委派”机制,当收到类加载的请求时,它会先尝试自己去加载,如果找不到则交给父加载器去加载,这样做的目的是为了优先加载Web应用程序自己定义的类来实现Web应用程序相互隔离独立的目标。
Tomcat类加载器初始化过程
我们可以在org.apache.catalina.startup.Bootstrap看到如下代码:
private void initClassLoaders() {
try {
commonLoader = createClassLoader("common", null);
if( commonLoader == null ) {
commonLoader=this.getClass().getClassLoader();
}
//初始化其它两个类加载器
catalinaLoader = createClassLoader("server", commonLoader);
sharedLoader = createClassLoader("shared", commonLoader);
} catch (Throwable t) {
log.error("Class loader creation threw exception", t);
System.exit(1);
}
}
private void initClassLoaders() {
try {
// 创建CommonClassLoader
commonLoader = createClassLoader("common", null);
if( commonLoader == null ) {
// no config file, default to this loader - we might be in a 'single' env.
commonLoader=this.getClass().getClassLoader();
}
// 根据配置创建SharedClassLoader、CatalinaClassLoader
catalinaLoader = createClassLoader("server", commonLoader);
sharedLoader = createClassLoader("shared", commonLoader);
} catch (Throwable t) {
handleThrowable(t);
log.error("Class loader creation threw exception", t);
System.exit(1);
}
}
private ClassLoader createClassLoader(String name, ClassLoader parent)
throws Exception {
// 读取catalina.properties文件中的配置
String value = CatalinaProperties.getProperty(name + ".loader");
// 没有对应的配置,不会创建此类加载器,而是返回传入的父类加载器,也就是CommonClassLoader
if ((value == null) || (value.equals("")))
return parent;
value = replace(value);
List<Repository> repositories = new ArrayList<>();
String[] repositoryPaths = getPaths(value);
for (String repository : repositoryPaths) {
// Check for a JAR URL repository
try {
@SuppressWarnings("unused")
URL url = new URL(repository);
repositories.add(
new Repository(repository, RepositoryType.URL));
continue;
} catch (MalformedURLException e) {
// Ignore
}
// Local repository
if (repository.endsWith("*.jar")) {
repository = repository.substring
(0, repository.length() - "*.jar".length());
repositories.add(
new Repository(repository, RepositoryType.GLOB));
} else if (repository.endsWith(".jar")) {
repositories.add(
new Repository(repository, RepositoryType.JAR));
} else {
repositories.add(
new Repository(repository, RepositoryType.DIR));
}
}
return ClassLoaderFactory.createClassLoader(repositories, parent);
}
Tomcat是如何打破双亲委派机制的呢?
从上文中,我们不难看出,真正实现web应用程序之间的类加载器相互隔离独立的是WebAppClassLoader类加载器。它为什么可以隔离每个web应用程序呢?原因就是它打破了"双亲委派"的机制,如果收到类加载的请求,它会先尝试自己去加载,如果找不到再交给父加载器去加载,这么做的目的就是为了优先加载Web应用程序自己定义的类来实现web应用程序相互隔离独立的。
WebappClassLoader底层原理
我们知道ClassLoader默认的loadClass方法是以双亲委派的模型进行加载类的,那么想要加载自定义资源打破"双亲委派"的机制,那么Tomcat就要必定要重写findClass与loadClass方法,如下所示:
@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 {
// 1、先在应用本地目录下查找类
clazz = findClassInternal(name);
}
} catch(AccessControlException ace) {
log.warn("WebappClassLoader.findClassInternal(" + name
+ ") security exception: " + 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 {
// 2、如果在本地目录没有找到,委派父加载器去查找
clazz = super.findClass(name);
} catch(AccessControlException ace) {
log.warn("WebappClassLoader.findClassInternal(" + name
+ ") security exception: " + ace.getMessage(), ace);
throw new ClassNotFoundException(name, ace);
} catch (RuntimeException e) {
if (log.isTraceEnabled())
log.trace(" -->RuntimeException Rethrown", e);
throw e;
}
}
// 3、如果父加载器也没找到,抛出异常
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);
}
@Override
public Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
Class<?> clazz = null;
// 1、从本地缓存中查找是否加载过此类
clazz = findLoadedClass0(name);
if (clazz != null) {
if (log.isDebugEnabled())
log.debug(" Returning class from cache");
if (resolve)
resolveClass(clazz);
return clazz;
}
// 2、从AppClassLoader中查找是否加载过此类
clazz = findLoadedClass(name);
if (clazz != null) {
if (log.isDebugEnabled())
log.debug(" Returning class from cache");
if (resolve)
resolveClass(clazz);
return clazz;
}
String resourceName = binaryNameToPath(name, false);
// 3、尝试用ExtClassLoader 类加载器加载类,防止应用覆盖JRE的核心类
ClassLoader javaseLoader = getJavaseClassLoader();
boolean tryLoadingFromJavaseLoader;
try {
URL url;
if (securityManager != null) {
PrivilegedAction<URL> dp = new PrivilegedJavaseGetResource(resourceName);
url = AccessController.doPrivileged(dp);
} else {
url = javaseLoader.getResource(resourceName);
}
tryLoadingFromJavaseLoader = (url != null);
} catch (Throwable t) {
tryLoadingFromJavaseLoader = true;
}
boolean delegateLoad = delegate || filter(name, true);
// 4、判断是否设置了delegate属性,如果设置为true那么就按照双亲委派机制加载类
if (delegateLoad) {
if (log.isDebugEnabled())
log.debug(" Delegating to parent classloader1 " + parent);
try {
clazz = Class.forName(name, false, parent);
if (clazz != null) {
if (log.isDebugEnabled())
log.debug(" Loading class from parent");
if (resolve)
resolveClass(clazz);
return clazz;
}
} catch (ClassNotFoundException e) {
// Ignore
}
}
// 5、默认是设置delegate是false的,那么就会先用WebAppClassLoader进行加载
if (log.isDebugEnabled())
log.debug(" Searching local repositories");
try {
clazz = findClass(name);
if (clazz != null) {
if (log.isDebugEnabled())
log.debug(" Loading class from local repository");
if (resolve)
resolveClass(clazz);
return clazz;
}
} catch (ClassNotFoundException e) {
// Ignore
}
// 6、如果在WebAppClassLoader没找到类,那么就委托给AppClassLoader去加载
if (!delegateLoad) {
if (log.isDebugEnabled())
log.debug(" Delegating to parent classloader at end: " + parent);
try {
clazz = Class.forName(name, false, parent);
if (clazz != null) {
if (log.isDebugEnabled())
log.debug(" Loading class from parent");
if (resolve)
resolveClass(clazz);
return clazz;
}
} catch (ClassNotFoundException e) {
// Ignore
}
}
}
throw new ClassNotFoundException(name);
}
先在本地缓存中查找该类是否已经加载过,如果加载过就返回缓存中的。
如果没有加载过,委托给AppClassLoader是否加载过,如果加载过就返回。
如果AppClassLoader也没加载过,委托给ExtClassLoader去加载,这么做的目的就是:
防止应用自己的类库覆盖了核心类库,因为WebAppClassLoader需要打破双亲委托机制,假如应用里自定义了一个叫java.lang.String的类,如果先加载这个类,就会覆盖核心类库的java.lang.String,所以说它会优先尝试用ExtClassLoader去加载,因为ExtClassLoader加载不到同样也会委托给BootstrapClassLoader去加载,也就避免了覆盖了核心类库的问题。
如果ExtClassLoader也没有查找到,说明核心类库中没有这个类,那么就在本地应用目录下查找此类并加载。
如果本地应用目录下还有没有这个类,那么肯定不是应用自己定义的类,那么就由AppClassLoader去加载。
这里是通过Class.forName()调用AppClassLoader类加载器的,因为Class.forName()的默认加载器就是AppClassLoader。
如果上述都没有找到,那么只能抛出ClassNotFoundException了。
疑惑点解释:
正常情况下(如 AppClassLoader
):
加载类时先问父加载器(向上委托),父加载器找不到才自己加载。
Tomcat 的 WebAppClassLoader
不完全遵守这个规则:
- 优先自己加载
(应用目录下的类),而不是先问父加载器。
- 只有核心类(如
java.*
)才会向上委托防止应用覆盖 JDK 的类。
目的主要是保护核心类,遇到 java.*
等核心类名时,仍向上委托,防止篡改 JDK。
四、垃圾回收
什么是Java里的垃圾回收?如何触发垃圾回收?
垃圾回收(Garbage Collection, GC)是自动管理内存的一种机制,它负责自动释放不再被程序引用的对象所占用的内存,这种机制减少了内存泄漏和内存管理错误的可能性。垃圾回收可以通过多种方式触发,具体如下:
内存不足时:当JVM检测到堆内存不足,无法为新的对象分配内存时,会自动触发垃圾回收。
手动请求:虽然垃圾回收是自动的,开发者可以通过调用
System.gc()
或
Runtime.getRuntime().gc()
建议 JVM 进行垃圾回收。不过这只是一个建议,并不能保证立即执行。
JVM参数:启动 Java 应用时可以通过 JVM 参数来调整垃圾回收的行为,比如:
-Xmx
(最大堆大小)、-Xms
(初始堆大小)等。对象数量或内存使用达到阈值:垃圾收集器内部实现了一些策略,以监控对象的创建和内存使用,达到某个阈值时触发垃圾回收。
怎么判断垃圾?
在Java中,判断对象是否为垃圾(即不再被使用,可以被垃圾回收器回收)主要依据两种主流的垃圾回收算法来实现:引用计数法和可达性分析算法。
引用计数法(Reference Counting)
原理:为每个对象分配一个引用计数器,每当有一个地方引用它时,计数器加1;当引用失效时,计数器减1。当计数器为0时,表示对象不再被任何变量引用,可以被回收。
缺点:不能解决循环引用的问题,即两个对象相互引用,但不再被其他任何对象引用,这时引用计数器不会为0,导致对象无法被回收。
可达性分析算法(Reachability Analysis)
Java虚拟机主要采用此算法来判断对象是否为垃圾。
原理:从一组称为GC Roots(垃圾收集根)的对象出发,向下追溯它们引用的对象,以及这些对象引用的其他对象,以此类推。如果一个对象到GC Roots没有任何引用链相连(即从GC Roots到这个对象不可达),那么这个对象就被认为是不可达的,可以被回收。GC Roots对象包括:虚拟机栈(栈帧中的本地变量表)中引用的对象、方法区中类静态属性引用的对象、本地方法栈中JNI(Java Native Interface)引用的对象、活跃线程的引用等。