JVM 内存区域划分
JVM 在运行 Java 程序时,会将内存划分为多个不同的区域,不同区域负责存放不同类型的数据。
一. 程序计数器(Program Counter Register)
程序计数器是一块线程私有 的内存区域,用于保存当前线程下一条要执行的指令地址。
- 这里的"指令"指的是 Java 字节码指令
- 当线程切换时,程序计数器可以保证线程恢复到正确的执行位置
特点:
- 线程私有
- 内存空间很小
- 是 JVM 中唯一一个不会发生 OutOfMemoryError 的区域
二. 堆(Heap)
堆是 JVM 中最大的一块内存区域,用于存放对象实例。
- 通过
new关键字创建的对象,通常存放在堆中 - 数组对象也存放在堆中
特点:
- 线程共享
- 几乎所有对象和数组都在堆中分配
- 是垃圾回收(GC)最主要的管理区域
- 最容易发生内存溢出异常
常见异常:
- OutOfMemoryError: Java heap space
三. 栈(Stack)
栈是线程私有 的内存区域,用于存储方法调用相关的信息。
当一个方法被调用时,会创建一个栈帧(Stack Frame),方法执行结束后,对应的栈帧出栈。
栈中主要保存的内容包括:
- 局部变量(基本数据类型、对象引用)
- 方法的形参
- 方法之间的调用关系
- 操作数栈
- 方法入口,返回地址
3.1 Java 虚拟机栈
Java 虚拟机栈用于保存 Java 方法执行时的调用关系和运行数据。
- 每个 Java 方法对应一个栈帧
- 方法调用遵循 后进先出(LIFO) 的规则
常见异常:
- StackOverflowError(递归过深或死递归)
3.2 本地方法栈
本地方法栈用于支持 JVM 调用 本地方法(Native Method) ,这些方法通常由 C / C++ 实现。
- 本地方法栈保存的是本地方法的调用关系
- 在 JVM 内部实现,与操作系统和底层库交互
特点:
- 线程私有
- 结构与 Java 虚拟机栈类似
- 也可能抛出 StackOverflowError
四. 元数据区(方法区 / Metaspace)
元数据区用于存储类的元数据信息,包括:
- 类的结构信息(类名、父类、接口等)
- 方法信息
- 运行时常量池
- 静态变量(static 修饰的变量)
版本说明
- JDK 7 及之前:方法区的实现是 永久代(PermGen)
- JDK 8 及之后:方法区由 元空间(Metaspace) 实现,使用的是本地内存
常见异常:
- OutOfMemoryError: Metaspace
什么是线程私有?
由于JVM的多线程是通过线程轮流切换并分配处理器执⾏时间的⽅式来实现,因此在任何⼀个确定的
时刻,⼀个处理器(多核处理器则指的是⼀个内核)都只会执⾏⼀条线程中的指令。因此为了切换线程后
能恢复到正确的执⾏位置,每条线程都需要独⽴的程序计数器,各条线程之间计数器互不影响,独⽴
存储。我们就把类似这类区域称之为"线程私有"的内存。
总结
| 内存区域 | 是否线程私有 | 主要存储内容 |
|---|---|---|
| 程序计数器 | 是 | 当前线程执行的字节码指令地址 |
| 堆 | 否 | 对象实例,成员变量 |
| Java 虚拟机栈 | 是 | Java 方法调用与局部变量 |
| 本地方法栈 | 是 | Native 方法调用 |
| 元数据区(方法区) | 否 | 类信息、常量、静态变量 |
JVM类加载的过程
在学习 JVM 的过程中,类加载机制 是一个绕不开的核心知识点。
很多 JVM 相关的问题,比如静态变量初始化顺序、类什么时候被加载、为什么会出现某些奇怪的空指针问题,本质都和类加载过程有关。
本文将从整体流程出发,用通俗易懂的方式讲解 JVM 的类加载过程。
一、什么是 JVM 类加载
JVM 类加载,指的是 JVM 将 .class 文件加载到内存,并最终形成可以被程序使用的 Class 对象的过程。
简单来说,就是把"磁盘上的类文件",变成"内存中可运行的类"。
二、类加载的整体流程
JVM 的类加载过程一共分为五个阶段:
- 加载(Loading)
- 验证(Verification)
- 准备(Preparation)
- 解析(Resolution)
- 初始化(Initialization)
其中,验证、准备、解析 三个阶段合称为 连接(Linking)阶段。
整体流程如下:
加载 → 验证 → 准备 → 解析 → 初始化
三、加载(Loading)
1. 加载阶段做了什么?
加载阶段主要完成三件事情:
- 根据类的全限定名获取对应的字节码文件
- 将字节码文件转换为 JVM 内部的数据结构
- 在内存中生成一个代表该类的 Class 对象
2. 加载阶段的特点
- 类的来源可以多种多样(本地文件、网络、Jar 包等)
- 加载由 **类加载器(ClassLoader)**完成
- 该阶段不会执行任何 Java 代码
四、验证(Verification)
1. 验证阶段的作用
验证阶段的主要目的是 确保字节码文件是合法、安全的,防止恶意代码破坏 JVM。
验证内容包括:
- 文件格式是否正确
- 字节码是否符合 JVM 规范
- 是否存在非法指令或越界访问
2. 验证失败的结果
如果验证失败,类加载过程会直接终止,程序无法继续运行。
五、准备(Preparation)
1. 准备阶段做了什么?
准备阶段主要完成:
- 为 类变量(static 变量)分配内存
- 将类变量设置为 默认初始值
注意,这里的初始化只是 默认值初始化,并不是程序中写的赋值。
2. 常见误区
很多人误以为在准备阶段就会执行静态变量的赋值操作,其实这是错误的。
真正的赋值发生在 初始化阶段。
六、解析(Resolution)
1. 解析阶段的作用
解析阶段是将 符号引用转换为直接引用。
简单理解就是:
- 将类名、方法名、字段名等"符号"
- 替换为 JVM 可以直接定位的内存地址
2. 解析的特点
- 解析阶段不一定一次性完成
- JVM 可能会在运行过程中延迟解析
七、初始化(Initialization)
1. 初始化阶段做了什么?
初始化阶段是 类加载过程中最重要的阶段 ,也是 唯一会执行 Java 代码的阶段。
主要执行内容包括:
- 为静态变量赋程序中定义的值
- 执行静态代码块
2. 初始化顺序规则
初始化遵循以下顺序:
- 父类先初始化
- 子类再初始化
- 同一个类中,按照代码书写顺序执行
八、类什么时候会被初始化?
只有在 主动使用类 时,才会触发类的初始化。常见场景包括:
- 创建类的实例
- 访问或修改类的静态变量
- 调用类的静态方法
- 通过反射使用类
- JVM 启动时指定的主类
以下情况不会触发初始化:
- 定义类的数组
- 引用常量
九、类加载过程总结
| 阶段 | 主要作用 | 是否执行 Java 代码 |
|---|---|---|
| 加载 | 获取字节码并生成 Class 对象 | 否 |
| 验证 | 保证字节码合法安全 | 否 |
| 准备 | 分配 static 变量内存并赋默认值 | 否 |
| 解析 | 符号引用转为直接引用 | 否 |
| 初始化 | 执行静态变量赋值和静态代码块 | 是 |
JVM 双亲委派模型详解
一、什么是双亲委派模型
双亲委派模型(Parent Delegation Model)是指:
当一个类加载器接收到类加载请求时,优先将该请求委派给父类加载器去完成,只有当父类加载器无法加载时,子加载器才会尝试自己加载。
简单概括就是一句话:
先交给父加载器,父加载器不行,自己再加载。
二、为什么要使用双亲委派模型
双亲委派模型的设计主要是为了解决两个核心问题:
1. 保证 Java 核心类的安全性
Java 的核心类库(如 java.lang、java.util 等)是 Java 运行的基础。
如果没有双亲委派模型:
- 用户可以自定义与核心类同名的类
- JVM 可能会加载错误的类
- 会对 Java 的安全性造成严重威胁
通过双亲委派模型:
- 核心类始终由最顶层的类加载器加载
- 用户无法替换或篡改 Java 核心类
2. 保证类的唯一性
在 JVM 中,一个类是否相同,不仅取决于类的全限定名,还取决于 加载它的类加载器。
双亲委派模型可以避免:
- 同一个类被多个类加载器重复加载
- 造成类冲突和类型不一致的问题
从而保证类在 JVM 中的唯一性。
三、JVM 中的主要类加载器
在 JVM 中,类加载器通常分为三层结构,自上而下分别是:
1. 启动类加载器(Bootstrap ClassLoader)
- 最顶层的类加载器
- 负责加载 Java 最核心的类库
- 通常加载
java.lang、java.util等基础类 - 由 JVM 使用本地代码实现
2. 扩展类加载器(Extension ClassLoader)
- 负责加载 Java 的扩展类库
- 是启动类加载器的子加载器
- 用于扩展 Java 的标准功能
3. 应用程序类加载器(Application ClassLoader)
- 负责加载应用程序中的类
- 即开发者自己编写的类
- 是日常开发中最常用的类加载器
四、双亲委派模型的工作流程
当应用程序类加载器需要加载某个类时,整体流程如下:
- 应用程序类加载器将请求委派给扩展类加载器
- 扩展类加载器继续将请求委派给启动类加载器
- 启动类加载器尝试加载该类
- 如果启动类加载器无法加载,请求返回给扩展类加载器
- 如果扩展类加载器仍然无法加载,最终由应用程序类加载器自己加载
可以总结为:
自下而上委派,自上而下尝试加载。
五、双亲委派模型与类加载过程的关系
在 JVM 类加载的五个阶段中:
- 加载阶段由类加载器完成
- 双亲委派模型正是类加载器在加载阶段遵循的规则
两者的区别在于:
- 类加载过程描述的是"类加载分为哪些阶段"
- 双亲委派模型描述的是"加载阶段如何选择类加载器"
六、双亲委派模型是否可以被打破
理论上,双亲委派模型是可以被打破的,但一般不建议这样做。
在某些特殊场景下,例如:
- 自定义类加载器
- 模块隔离需求
- 容器或框架级别的加载控制
一些框架(如 Tomcat、SPI 机制)会在特定场景下打破双亲委派模型,以满足实际需求。
但在普通业务开发和面试中,默认都是遵循双亲委派模型。
七、双亲委派模型的优点
- 避免重复加载类:比如 A 类和 B 类都有⼀个父类 C 类,那么当 A 启动时就会将 C 类加载起来,那
么在 B 类进行加载时就不需要在重复加载 C 类了。 - 安全性:使用双亲委派模型也可以保证了 Java 的核心 API 不被篡改,如果没有使用双亲委派模
型,而是每个类加载器加载自己的话就会出现⼀些问题,比如我们编写⼀个称为 java.lang.Object
类的话,那么程序运行的时候,系统就会出现多个不同的 Object 类,而有些 Object 类又是用户自
己提供的因此安全性就不能得到保证了。
JVM 内存回收与垃圾回收器
一、垃圾回收的意义与范围
在 JVM 的运行区域中,程序计数器、虚拟机栈、本地方法栈这三个区域随线程而生,随线程而灭,内存分配具有确定性,不需要过多考虑回收。
垃圾回收的核心关注点是:Java 堆(Heap)和方法区(Method Area)。这两个区域的内存分配和回收是动态的,几乎所有的对象实例都存放在堆中。
二、判断对象是否存活的逻辑
1. 引用计数算法
- 原理:为对象添加一个引用计数器。每当有一个地方引用它,计数器加 1;引用失效时,计数器减 1。计数器为 0 的对象被视为"垃圾"。
- 局限性:它无法解决循环引用的问题。例如对象 A 引用对象 B,对象 B 也引用对象 A,但此外再无其他引用。此时两者的计数器都不为 0,导致无法被回收。因此,主流 JVM(如 HotSpot)并不采用此算法。
2. 可达性分析算法
- 原理:以一系列被称为 GC Roots 的对象作为起点,从这些节点开始向下搜索。搜索走过的路径称为引用链。如果一个对象到 GC Roots 没有任何引用链相连(即不可达),则证明此对象不可用。
- 可作为 GC Roots 的对象包括 :
- 虚拟机栈(栈帧中的本地变量表)中引用的对象。
- 方法区中类静态属性引用的对象。
- 方法区中常量引用的对象。
- 本地方法栈中 JNI(即 Native 方法)引用的对象。
三、强、软、弱、虚:引用的四种境界
为了支持更精细的内存管理,Java 将引用分成了四类:
- 强引用 :最普遍的引用,如
Object obj = new Object()。只要强引用存在,垃圾回收器永远不会回收被引用的对象。 - 软引用:描述还有用但非必需的对象。在系统将要发生内存溢出(OOM)之前,会把这些对象列入回收范围进行第二次回收。如果这次回收后内存仍不足,才会抛出溢出异常。
- 弱引用:强度比软引用更弱。被弱引用关联的对象只能生存到下一次垃圾回收发生之前。无论当前内存是否足够,只要发生 GC,弱引用对象就会被回收。
- 虚引用:最弱的一种。它不影响对象的生存时间,也无法通过虚引用获取对象实例。设置虚引用的唯一目的是在对象被回收时收到一个系统通知。
四、垃圾回收算法(方法论)
1. 标记-清除算法 (Mark-Sweep)
- 过程:分为标记和清除两个阶段。首先标记出所有需要回收的对象,标记完成后统一回收。
- 缺点:效率低;产生大量不连续的内存碎片。碎片过多会导致后续大对象无法找到连续内存而频繁触发 GC。
2. 复制算法 (Copying)
- 过程:将可用内存划分为大小相等的两块,每次只使用一块。当这块内存用完时,将还活着的对象复制到另一块,然后清理掉当前这一块。
- 优点:实现简单,运行高效,无碎片。
- 应用:现代 JVM 用此算法回收新生代。为了提高空间利用率,HotSpot 将新生代分为 Eden、Survivor From、Survivor To 三部分(比例 8:1:1)。
3. 标记-整理算法 (Mark-Compact)
- 过程:标记过程与标记-清除一致,但后续步骤是让所有存活对象都向内存的一端移动,然后直接清理掉边界以外的内存。
- 优点:解决了碎片问题,不需要浪费一半的空间。
- 应用:主要用于老年代。
4. 分代收集算法 (Generational Collection)
- 原理:根据对象存活周期的不同将内存划分为几块。一般是把 Java 堆分为新生代和老年代。
- 策略:新生代中对象死得快,采用复制算法;老年代中对象存活率高,采用标记-清除或标记-整理算法。
五、垃圾收集器(具体实现)
1. 经典收集器
- Serial 收集器:单线程。进行垃圾回收时,必须暂停其他所有工作线程(Stop The World,简称 STW)。
- ParNew 收集器:Serial 的多线程版本,常与 CMS 配合使用。
- Parallel Scavenge 收集器:关注吞吐量(CPU 运行用户代码时间与总消耗时间的比值),适合后台计算。
- CMS 收集器 (Concurrent Mark Sweep):以最短停顿时间为目标。过程包括:初始标记(STW)、并发标记、重新标记(STW)、并发清除。它有碎片多、无法处理浮动垃圾的缺点。
2. G1 收集器 (Garbage First)
- 地位:面向服务端应用,旨在替换 CMS。
- 原理:将整个堆划分为多个大小相等的独立区域(Region)。它依然保留新生代和老年代的概念,但它们不再是物理隔离的。
- 优势:可预测的停顿时间模型。它会根据每个 Region 的回收价值(回收所获得的空间大小以及回收所需时间)维护一个优先级列表,优先回收价值最大的 Region。
六、对象的一生:内存分配与晋升历程
- 新生诞生:绝大多数对象在 Eden 区出生。
- 初次历练 (Minor GC):当 Eden 区空间不足时,触发 Minor GC。存活对象被复制到 Survivor From 区,年龄设为 1。
- 来回搬迁:在后续的 Minor GC 中,存活对象在两个 Survivor 区(From 和 To)之间往返复制。每经过一次 GC,年龄加 1。
- 晋升老人:当对象年龄达到 15 岁(默认值)时,会被移动到老年代。大对象(需要大量连续内存空间的对象)也会直接进入老年代。
- 寿终正寝 (Full GC) :当老年代空间不足时,会触发 Full GC。如果 Full GC 后内存依然不足,就会抛出
OutOfMemoryError。