【Java 复习日记 04】深入JVM:内存、类初始化与类加载的艺术
目录
前言
学习Java的初期,JVM对于我来说就是一个神秘的“黑盒子”——我写代码,它执行,中间发生了什么我一无所知。直到我遇到了第一个内存溢出错误,看到了那一串陌生的异常信息,才意识到:不理解JVM,就无法真正驾驭Java。
今天,因为复习的时间不长我们就简单复习一下这个曾经让我一脸懵逼的领域:JVM内存模型、类初始化类加载机制以及内存报错。
1. JVM内存模型:不只是“堆和栈”
1.1 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 异常。

通过一个简单的程序,我们来理解内存分配
public class MemoryDemo {
private static final String CONSTANT = "JVM"; // 方法区(元空间)
private static List<String> cache = new ArrayList<>(); // 方法区引用,对象在堆
public static void main(String[] args) { // 栈帧开始
int localVar = 42; // 栈:局部变量
MemoryDemo demo = new MemoryDemo(); // demo在栈,对象在堆
demo.process(localVar);
}
public void process(int param) { // 新的栈帧
String threadLocal = Thread.currentThread().getName(); // 栈
byte[] buffer = new byte[1024 * 1024]; // 对象在堆
// ...
} // 栈帧销毁
}
1.2 堆与栈的比较区分
堆(Heap)和栈(Stack)是JVM运行时内存被分为的五个部分:虚拟机栈、堆、元空间、程序计数器、本地方法栈其中的重要成分。也是我初学时没有区分清楚的混淆区域,下面我想用这个表格清晰地展现出他们的区别,帮助大家理解。
| 堆(Heap) | 栈(Stack) | |
|---|---|---|
| 用途 | 主要用于存储局部变量、方法调用的参数、方法返回地址以及一些临时数据。每当一个方法被调用,一个栈帧(stack frame)就会在栈中创建,用于存储该方法的信息,当方法执行完毕,栈帧也会被移除 | 用于存储对象的实例(包括类的实例和数组)。当你使用 new 关键字创建一个对象时,对象的实例就会在堆上分配空间 |
| 生命周期 | 数据具有确定的生命周期,当一个方法调用结束时,其对应的栈帧就会被销毁,栈中存储的局部变量也会随之消失 | 对象生命周期不确定,对象会在垃圾回收机制(Garbage Collection, GC)检测到对象不再被引用时才被回收 |
| 存取速度 | 存取速度通常比堆快,因为栈遵循先进后出(LIFO, Last In First Out)的原则,操作简单快速 | 堆的存取速度相对较慢,因为对象在堆上的分配和回收需要更多的时间,而且垃圾回收机制的运行也会影响性能 |
| 存储空间 |
空间相对较小,且固定,由操作系统管理。 当栈溢出时,通常是因为递归过深或局部变量过大 |
空间较大,动态扩展,由JVM管理。 堆溢出通常是由于创建了太多的大对象或未能及时回收不再使用的对象 |
| 可见性 | 数据对线程是私有的,每个线程有自己的栈空间 | 数据对线程是共享的,所有线程都可以访问堆上的对象 |
关键认知:栈中存储的永远只是引用而不是对象,真正的对象永远在堆中。
1.3 堆的内部结构:新生代与老年代的故事
为什么堆要分代?因为对象的“寿命”不同。JVM的设计者发现了一个规律:绝大多数对象都是“朝生暮死”的。
通过下面这个例子我们一起来简单理解一下对象的生命周期:
public class ObjectLifecycle {
public static void main(String[] args) {
// 场景1:短暂存在的对象(大概率进入新生代)
for (int i = 0; i < 10000; i++) {
String temp = "temp_" + i; // 大部分很快被回收
// 使用temp...
}
// 场景2:长期存在的对象(最终进入老年代)
List<String> permanentList = new ArrayList<>();
for (int i = 0; i < 1000; i++) {
permanentList.add("item_" + i);
}
// permanentList会被业务长期使用
}
}
堆的分区逻辑:
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空间,以继续它们的生命周期。这两个区域轮流充当对象的中转站,帮助区分短暂存活的对象和长期存活的对象。
- 老年代(Old Generation/Tenured Generation):存放过一次或多次Minor GC仍存活的对象会被移动到老年代。老年代中的对象生命周期较长,因此Major GC(也称为Full GC,涉及老年代的垃圾回收)发生的频率相对较低,但其执行时间通常比Minor GC长。老年代的空间通常比新生代大,以存储更多的长期存活对象。
- 元空间(Metaspace):从Java 8开始,永久代(Permanent Generation)被元空间取代,用于存储类的元数据信息,如类的结构信息(如字段、方法信息等)。元空间并不在Java堆中,而是使用本地内存,这解决了永久代容易出现的内存溢出问题。
- 大对象区(Large Object Space / Humongous Objects):在某些JVM实现中(如G1垃圾收集器),为大对象分配了专门的区域,称为大对象区或Humongous Objects区域。大对象是指需要大量连续内存空间的对象,如大数组。这类对象直接分配在老年代,以避免因频繁的年轻代晋升而导致的内存碎片化问题。
2. 类初始化和类加载
2.1 创建对象的过程
在Java中创建对象的过程包括以下几个步骤:
- 类加载检查:虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
- 分配内存:在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。
- 初始化零值:内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
- 进行必要设置,比如对象头:初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。这些信息存放在对象头中。另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
- 执行 init 方法:在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始——构造函数,即class文件中的方法还没有执行,所有的字段都还为零,对象需要的其他资源和状态信息还没有按照预定的意图构造好。所以一般来说,执行 new 指令之后会接着执行方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全被构造出来。
2.2 类加载器有哪些?

- 启动类加载器(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动态性的一个重要体现。
2.3 类加载的七个阶段:
- 加载:通过类的全限定名(包名 + 类名),获取到该类的.class文件的二进制字节流,将二进制字节流所代表的静态存储结构,转化为方法区运行时的数据结构,在内存中生成一个代表该类的Java.lang.Class对象,作为方法区这个类的各种数据的访问入
- 连接:验证、准备、解析 3 个阶段统称为连接。
- 验证:确保class文件中的字节流包含的信息,符合当前虚拟机的要求,保证这个被加载的class类的正确性,不会危害到虚拟机的安全。验证阶段大致会完成以下四个阶段的检验动作:文件格式校验、元数据验证、字节码验证、符号引用验证
- 准备:为类中的静态字段分配内存,并设置默认的初始值,比如int类型初始值是0。被final修饰的static字段不会设置,因为final在编译的时候就分配了
- 解析:解析阶段是虚拟机将常量池的「符号引用」直接替换为「直接引用」的过程。符号引用是以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用的时候可以无歧义地定位到目标即可。直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄,直接引用是和虚拟机实现的内存布局相关的。如果有了直接引用, 那引用的目标必定已经存在在内存中了。
- 初始化:初始化是整个类加载过程的最后一个阶段,初始化阶段简单来说就是执行类的构造器方法(() ),要注意的是这里的构造器方法()并不是开发者写的,而是编译器自动生成的。
- 使用:使用类或者创建对象
- 卸载:一个类要被JVM卸载,条件非常苛刻,需要同时满足以下三点:
- 该类所有的实例都已经被回收:这是最显而易见的前提。如果堆中还存在这个类的任何一个实例对象,那么定义这个对象的Class对象肯定不能被卸载。
- 加载该类的ClassLoader已经被回收:这是最关键也是最难满足的条件。类与其加载器是双向绑定的共生关系。一个类由哪个类加载器加载,这个信息是存储在Class对象里的。要卸载一个类,必须先卸载加载它的类加载器。
- 类对应的Java.lang.Class对象没有任何地方被引用:不能在任何地方通过反射(如静态字段、全局变量)、静态变量、JNI等途径引用到这个Class对象。一旦这个Class对象还存在强引用,GC就不会回收它,那么这个类也就不会被卸载。

3. 内存泄漏和溢出
3.1 内存泄漏
内存泄露:内存泄漏是指程序在运行过程中不再使用的对象仍然被引用,而无法被垃圾收集器回收,从而导致可用内存逐渐减少。虽然在Java中,垃圾回收机制会自动回收不再使用的对象,但如果有对象仍被不再使用的引用持有,垃圾收集器无法回收这些内存,最终可能导致程序的内存使用不断增加。
内存泄露常见原因:
- 静态集合:使用静态数据结构(如HashMap或ArrayList)存储对象,且未清理。
- 事件监听:未取消对事件源的监听,导致对象持续被引用。
- 线程:未停止的线程可能持有对象引用,无法被回收。
3.2 内存溢出
内存溢出:内存溢出是指Java虚拟机(JVM)在申请内存时,无法找到足够的内存,最终引发OutOfMemoryError。这通常发生在堆内存不足以存放新创建的对象时。
内存溢出常见原因:
- 大量对象创建:程序中不断创建大量对象,超出JVM堆的限制。
- 持久引用:大型数据结构(如缓存、集合等)长时间持有对象引用,导致内存累积。
- 递归调用:深度递归导致栈溢出。
总结
写完这篇JVM深度探索笔记,我感觉能够看到代码在底层是如何运行的。我的核心收获:
1. 内存意识:构建清晰的内存运行图景
- 理解了JVM内存的全貌:不仅是堆和栈,还包括程序计数器、本地方法栈、方法区(元空间)、运行时常量池和直接内存,明确了各自的功能与生命周期。
- 透彻区分了堆与栈:栈存储方法调用的帧、局部变量和引用,速度快、线程私有;堆存储所有对象实例,由GC管理、线程共享。关键认知:栈中存引用,堆中存对象。
- 明白了堆分代的设计逻辑:基于对象“朝生暮死”的规律,将堆划分为新生代(Eden, Survivor)和老年代,以优化垃圾回收效率。
- 认识了方法区(元空间)的角色:它是类的“档案馆”,存储类元数据、常量等。JDK 8后用元空间替代永久代,使用本地内存,避免了永久代的OOM问题。
2. 性能直觉:洞察影响性能的关键设计
- 理解了对象生命周期的差异:短暂对象在新生代周转,长期存活对象最终会进入老年代。这指导我避免创建不必要的、尤其是大对象。
- 认识了GC的成本:Minor GC(新生代)频繁但快,Full GC(含老年代)慢且影响大。合理的数据结构设计和对象生命周期管理能减少GC压力。
3. 调试能力:定位内存问题的根本原因
- 能区分内存泄漏与内存溢出:
- 内存泄漏:对象已不再使用但仍被引用(如静态集合长期持有、未注销监听器),导致GC无法回收。
- 内存溢出:实际需要的内存超过了JVM能提供的最大内存(如创建超大对象、过深递归)。
- 能将异常类型与内存区域关联:
- StackOverflowError / OutOfMemoryError → 虚拟机栈/本地方法栈。
- OutOfMemoryError: Java heap space → 堆空间不足。
- OutOfMemoryError: Metaspace → 元空间不足。
- 理解了类卸载的苛刻条件:需同时满足该类所有实例被回收、加载它的ClassLoader被回收、该类对应的Class对象未被任何地方引用。
- 设计思维:从底层原理理解语言特性
- 清晰了对象创建的完整过程:从类加载检查、分配内存、初始化零值、设置对象头,到最后执行构造函数(<init>)。
- 理解了类加载的层次与过程:
- 层次:启动类加载器 → 扩展类加载器 → 系统类加载器 → 自定义类加载器(双亲委派模型)。
- 过程:加载 → 连接(验证、准备、解析)→ 初始化 → 使用 → 卸载(条件苛刻)。
- 能从JVM视角审视代码设计:在编写代码时,能下意识地考虑变量作用域(栈帧)、对象分配位置(堆)、类加载时机以及可能产生的内存影响,从而做出更合理的架构与实现选择。
最后的学习心得:
学习JVM不是一次性任务,而是持续的过程。每当我学习一个新的框架或技术,我都会思考:它在JVM层面是如何实现的?这会带来什么样的内存和性能影响?这种思考方式让我对Java的理解越来越深刻。
如果各位有所收获喜欢的话记得点赞、收藏、关注哦
承蒙关照!

更多推荐


所有评论(0)