Files
autumn-recruitment/01.Java/jvm/JVM 内存模型.md
T

171 lines
9.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [java/lang, jvm-memory, heap, metaspace, oom, gc-roots]
create time: 2026-08-08 18:00
update time: 2026-08-08 18:00
---
# JVM 内存模型
## 概述
JVM 内存模型定义了程序运行时的数据布局——对象住在哪、方法代码存在哪、线程的局部变量放在哪。理解这块是调试 OOM、排查 GC 异常、调优堆大小的前提。本文将从运行时数据区的划分讲起,覆盖对象创建的生命周期和常见 OOM 类型的触发条件。
## 运行时数据区
JVM 将内存划分为多个逻辑区域,各自有明确的生命周期和用途。可以按"是否线程私有"分成两大阵营。
### 线程共享区域
**堆(Heap)**:所有线程共享,是 JVM 中最大的一块内存,存放所有对象实例和数组。堆又被进一步细分为新生代(Young Gen)和老年代(Old Gen),新生代再分为 Eden 区和两个 Survivor 区(From / To)。这是垃圾收集的主要舞台。
> [!NOTE]
> 堆大小由 `-Xms`(初始堆)和 `-Xmx`(最大堆)控制,通常建议两者设为同一值,避免运行时动态扩缩带来的性能开销。
**方法区(Method Area)**:存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码缓存。JDK 7 时期方法区还在永久代(PermGen)中实现;JDK 8 之后彻底移除了永久代,用**元空间(Metaspace)**代替,元空间使用本地内存(Direct Memory),通过 `-XX:MaxMetaspaceSize` 限制上限。
> [!NOTE]
> 为什么要把元空间搬到本地内存?永久代大小固定且容易撑爆(尤其是动态代理场景下 Class 数量爆炸时),换成元空间后理论上只受限于本机内存总量,大幅降低了 OOM 的概率。
### Java 虚拟机栈(JVM Stack)
每个线程创建时都会创建一个虚拟机栈,描述 Java 方法的调用过程。每个方法被执行时都会创建一个栈帧,存储在栈中,包含局部变量表、操作数栈、动态链接、方法出口等信息。**栈帧随着方法调用进入而压栈,返回时弹栈**。栈溢出会抛出 `StackOverflowError`。
**本地方法栈(Native Method Stack)**:与虚拟机栈功能类似,但服务的是 Native 方法(通常是用 C/C++ 编写的 JNI 方法)。HotSpot 直接将本地方法栈和虚拟机栈合二为一。
**程序计数器(Program Counter Register)**:一块很小的内存空间,记录当前线程执行的字节码行号。如果执行的是 Native 方法,计数器值为空。它是唯一不会发生 OutOfMemoryError 的区域。
## GC Roots 判定标准
GC Roots Tracing 算法通过可达性分析判断对象是否存活——从一组根节点出发,沿着引用链搜索,被搜到的标记为存活,搜不到的标记为死亡。以下是 JVM 规范中定义的 GC Roots 来源:
| 来源 | 说明 |
|------|------|
| 虚拟机栈中引用的对象 | 各线程栈帧中局部变量表里引用的对象实例 |
| 方法区类静态属性引用的对象 | `static` 字段所指向的对象 |
| 方法区常量引用的对象 | `final static` 常量关联的引用 |
| 本地方法栈 JNI 引用的对象 | Native 方法通过 JNI 传入的句柄 |
> [!NOTE]
> 一个对象的死亡通常需要两次标记:第一次确认没有 GC Roots 路径可达;第二次才是真正回收。如果这个对象重写了 `finalize()` 方法且尚未执行过,虚拟机会在第二次标记时帮它触发一次——不过 finalize() 在 Java 9+ 已被标记为废弃。
> [!TIP]
> 面试常考点:这 5 个区域中,只有堆和方法区可能 OOM,栈和计数器不会。程序计数器的设计决定了它只需要极小的空间——因为它是线程私有的,切换时只需恢复计数值即可。
```mermaid
graph TD
subgraph 线程共享["线程共享区域"]
H["堆 Heap\n对象实例/数组"]
MA["方法区/元空间\n类信息/常量/静态变量"]
end
subgraph 线程私有["线程私有区域"]
JS["Java 虚拟机栈\n栈帧/局部变量/操作数栈"]
NMS["本地方法栈\nNative 方法"]
PCR["程序计数器\n字节码行号"]
end
JS -->|访问| H
MA -->|引用| H
```
## 对象创建过程
在堆中分配一个对象并非简单的 `malloc`,而是经历了完整的一系列检查与初始化步骤。
**第一步:类加载检查**。当 JVM 遇到 `new` 指令时,首先检查参数能否在常量池中定位到这个类的符号引用,并检查该符号引用代表的类是否已被加载、解析和初始化。如果未加载,则执行对应的类加载流程。
**第二步:分配内存**。类检查通过后,就在堆中划出一块确定大小的空间。内存分配有两种主流方式:
- **指针碰撞(Bump the Pointer)**:堆内存规整时使用,空闲空间和已占用空间各占一端,分配时把指针向空闲方向移动对象大小的距离。这种方式效率高,前提是堆必须规整。
- **空闲列表(Free List)**:堆非规整时(如采用标记-清除算法的收集器),维护一个列表记录哪些内存块可用,分配时从中选择一块足够大的空间。
> [!WARNING]
> 热点问题的背后往往是一个取舍:G1 和 ZGC 等现代收集器为了做到堆规整,选择在回收阶段做整理,代价是 STW 时间或额外的屏障开销。
**第三步:初始化零值**。分配到的内存必须清零(置为 0 值),这一步确保了对象的字段在 Java 代码中不用一开始就赋值也能有默认值(int 为 0、reference 为 null 等)。
**第四步:设置对象头**。HotSpot 虚拟机会在对象头上设置一些自身运行时的数据,包括:
- HashCode(延迟计算)
- 分代年龄(达到阈值后晋升老年代)
- 锁状态标志位
- 指向锁对象监控器的指针
- 偏向锁的 ThreadID
- 指向栈中 VMEntryFrame 的指针
**第五步:执行 `init` 方法**。按照程序员的意愿对对象进行初始化,设置好各个字段的真正值。
整个流程可以用下图概括:
```mermaid
flowchart LR
A["new 指令"] --> B["类加载检查\n常量池定位+加载"]
B --> C["分配内存\n指针碰撞 / 空闲列表"]
C --> D["零值初始化\nmemset 到 0"]
D --> E["设置对象头\nHash/分代年龄/锁标志"]
E --> F["执行 init\n字段赋初值"]
F --> G["对象可被访问"]
```
## OOM 常见类型
`OutOfMemoryError` 是一个 Error 而非 Exception,表示 JVM 已经无法继续分配内存。它有几个不同的子类,各自对应不同的内存区域和问题场景。
### `java.lang.OutOfMemoryError: Java heap space`
**最常见**的 OOM。通常是对象存活数量过多、生命周期过长,或者存在内存泄漏(比如集合类持续 add 却不 remove)。也可能仅仅是因为堆设置得太小。
排查思路:用 MAT 或 JProfiler 导出堆快照(heap dump),分析 Dominator Tree 找到持有大量引用的大对象。
### `java.lang.OutOfMemoryError: Metaspace`
JDK 8 之后出现。元空间耗尽说明加载的 Class 太多,常见于:
- 大量动态生成了 Class(如 MyBatis 扫描了超大包路径、频繁使用 CGLIB 动态代理)
- 使用了过多的 OSGi 模块或者热部署框架(如 Spring Boot DevTools)
调优参数:`-XX:MaxMetaspaceSize` 设大一点,或者从根本上减少 Class 数量。
### `java.lang.StackOverflowError`
严格来说这不是一个 OutOfMemoryError,而是栈深度超限。通常由无限递归或递归过深触发——每个方法调用会压入一个栈帧,超出 `-Xss` 设定的单线程栈大小时抛出此错误。排查思路:审查递归逻辑是否有正确的退出条件,或适当增大 `-Xss`。
### `java.lang.OutOfMemoryError: unable to create new native thread`
JVM 尝试创建新的 OS 线程失败。通常不是 JVM 内存不够,而是操作系统级别的线程数限制触顶(Linux 的 `ulimit -u`、Windows 的用户会话极限)。每个 Java 线程最终映射为一个原生线程,消耗约 1MB 的栈内存(由 `-Xss` 决定)。
解决方案:减少并发线程数、增大 `-Xss`(但会增加单个线程的内存消耗)、或者提升系统的线程数上限。
### `java.lang.OutOfMemoryError: GC overhead limit exceeded`
当 GC 花费超过 98% 的时间却只回收了不到 2% 的堆内存时触发。本质上是一种自我保护机制——JVM 发现自己在做无效回收,干脆抛错而不是无限循环。
通常意味着堆仍然有少量空间,但这些空间被一群几乎不会死掉的对象占据着。加大堆容量或修复内存泄漏是根本办法。也可以通过 `-XX:-UseGCOverheadLimit` 关闭这个检查,但这只是掩耳盗铃。
### `java.lang.OutOfMemoryError: Direct buffer memory`
NIO 的 DirectByteBuffer 走的是堆外内存(通过 `Unsafe.allocateMemory` 分配),不受 `-Xmx` 控制。常见的触发场景是使用 Netty、gRPC 等大流量网络框架时直接 Buffer 分配过快。
调参:`-XX:MaxDirectMemorySize` 控制上限。
## 实践场景
### 秋招面试高频问题
| 问题 | 回答要点 |
|------|---------|
| JDK 7 和 JDK 8 在方法区上的区别? | 7 用永久代、8 用元空间;永久代在堆内、元空间在本机内存 |
| 如何判断一段内存属于哪个区域? | 对象实例在堆、类信息在元空间、局部变量在栈帧、计数器只存一行号 |
| 给一个 OOM 的排查流程 | GC Log → Dump 堆快照 → MAT 打开 → Dominator Tree → 找最大对象链 |
### 实战技巧
线上出现 OOM 时,可以通过 JVM 启动参数自动 dump:
```java
// JVM 参数(不需要写在 Java 代码里,这里是示意配置)
// -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/dumps/
// -XX:OnError="jstack %p > /data/logs/jstack.txt"
```
配合 `jmap -dump:format=b,file=heap.bin <pid>` 可以手动导出堆快照,然后用 Eclipse MAT 分析对象留存情况。
## 关联笔记
- [[垃圾回收算法与收集器]]