171 lines
9.9 KiB
Markdown
171 lines
9.9 KiB
Markdown
|
|
---
|
|||
|
|
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 分析对象留存情况。
|
|||
|
|
|
|||
|
|
## 关联笔记
|
|||
|
|
|
|||
|
|
- [[垃圾回收算法与收集器]]
|