JVM运行时数据区各区域作用与溢出原理

JVM运行时数据区各区域作用与溢出原理

1. 这篇文章要解决什么问题

很多开发者在日常编写业务代码时,对 JVM(Java虚拟机)的感知往往只停留在"它能自动帮我回收垃圾"。但在生产环境中,一旦遇到 OutOfMemoryError(OOM内存溢出)或者 StackOverflowError(栈溢出),往往会一头雾水:

  • 到底哪里溢出了?
  • 为什么这段代码会导致溢出?
  • 溢出了该去哪里排查问题?

这篇文章要解决的核心问题,就是把 JVM 在运行时是如何划分和使用内存的,以及每个区域为什么会发生崩溃(溢出)的底层逻辑讲透。学完之后,再看到 OOM 报错,你的第一反应将不再是"赶紧重启",而是能精准定位到是堆、栈、还是元空间出了问题,并知道如何去抓取证据和修复。

2. 核心原理

JVM 在执行 Java 程序时,会把它管理的内存划分为若干个不同的数据区域。根据是否被所有线程共享 ,我们可以将其分为两大类:线程私有区线程共享区

2.1 线程私有区(随着线程生与死,不存在并发问题)

① 程序计数器(Program Counter Register)

  • 它是干什么的: 简单理解,它就是当前线程所执行的字节码的"行号指示器"。CPU 在多个线程之间来回切换执行,当线程被挂起后重新唤醒时,它怎么知道上次执行到了哪里?就是靠看一眼程序计数器来恢复现场。
  • 是否会溢出: 绝不。 它是 JVM 规范中唯一一个没有规定任何 OutOfMemoryError 情况的区域。因为它只存一个内存地址,空间需求极小且固定。

② Java 虚拟机栈(Java Virtual Machine Stack)

  • 它是干什么的: 这是支撑 Java 方法执行的内存模型。你每次调用一个方法,JVM 就会在当前线程的栈里压入一个"栈帧(Stack Frame)"。方法执行完毕,栈帧出栈。
  • 栈帧里面有什么: 这是核心!栈帧里装着局部变量表 (存基本数据类型、对象引用地址)、操作数栈 (做加减乘除计算的临时工作台)、动态连接 (把符号引用转成物理内存地址)和方法出口(执行完回哪去)。
  • 为什么会出问题:
    • 栈深度超限(StackOverflowError): 如果你写了一个死递归函数,方法一直进栈不出栈,就会把这根柱子撑爆。
    • 内存溢出(OOM): 如果 JVM 允许动态扩展栈容量,当开辟的新线程太多,系统内存耗尽无法为新线程分配栈空间时,就会 OOM。

③ 本地方法栈(Native Method Stack)

  • 它是干什么的: 和虚拟机栈一模一样,只不过虚拟机栈是为 Java 代码(字节码)服务的,而本地方法栈是为 Java 中调用的 C/C++ 等底层的 native 方法服务的(比如 Unsafe 类里的方法)。同样会抛出 StackOverflowError 和 OOM。

2.2 线程共享区(所有线程共用,重点监控区域)

④ Java 堆(Java Heap)

  • 它是干什么的: JVM 内存里最大的一块地盘。它的唯一目的就是存放对象实例 。几乎你在代码里 new 出来的所有对象,都在这里安家。
  • 内部怎么划分: 为了方便垃圾回收(GC),堆通常被分为年轻代 (Eden区、Survivor 0区、Survivor 1区)和老年代。新对象出生在年轻代,熬过多次垃圾回收后会被转移到老年代。
  • 为什么会溢出(OOM): 当你不断地创建对象,并且由于代码逻辑问题(比如塞进了一个全局的 List 里一直不清理),导致垃圾回收器认为这些对象都是"存活"的,无法回收。当堆空间被填满,连老年代也装不下时,就会彻底引爆 OutOfMemoryError: Java heap space

⑤ 方法区(Method Area / Metaspace 元空间)

  • 它是干什么的: 它用来存储被 JVM 加载的类信息(Class的结构)、常量、静态变量、即时编译器(JIT)编译后的代码缓存等数据。
  • 需要澄清的历史: 在 JDK 7 及以前,方法区被称为"永久代(PermGen)",这块内存是在堆里划出去的。这就导致类的数目一多,永久代就容易 OOM。所以在 JDK 8 之后,JVM 彻底废除了永久代,改为利用操作系统的本地内存(Native Memory)来实现,叫作元空间(Metaspace)
  • 为什么会溢出(OOM): 只要不限制元空间大小,通常很难溢出。但如果使用了大量的动态代理(如 Spring AOP底层的 CGLib)在运行期间疯狂生成动态类,或者 JSP 热部署,都会把元空间撑爆,报错 OutOfMemoryError: Metaspace

3. 流程/机制描述

为了让你脑海中有画面感,我们来看看一段最典型的代码在 JVM 里是怎么流转的,以及溢出是如何一步步逼近的。

正常流转机制:

  1. 类加载: 当代码执行到 new User() 时,JVM 首先去"方法区(元空间)"检查有没有 User 这个类的结构定义。如果没有,先加载类。
  2. 分配内存: 类加载完后,JVM 开始在 Java 堆 的 Eden 区划分一块内存给这个新生成的 User 对象。
  3. 压栈与引用: 接着,JVM 会在当前执行线程的虚拟机栈 里,往当前方法的栈帧中的局部变量表里创建一个变量 user,这个变量里面存放的,就是那块堆内存的物理地址(引用)。
  4. 垃圾回收: 方法执行完毕,退栈。局部变量表里的引用 user 消失了。此时堆里的那个 User 对象就成了"断线风筝"。GC 线程扫描到它没人要了,就会回收这块内存。

溢出崩溃机制(以堆内存溢出为例):

  1. 如果你在死循环里不停地 new User(),并且每次 new 出来都放入一个全类级别的 static List 中。
  2. 方法退栈了,局部变量表的引用确实没了,但那个 static List(生命周期极长)依然死死抓着这一堆 User 对象引用。
  3. Eden 区满了,触发 Minor GC,GC 发现这些对象都有人引用,一个都杀不掉。只能把它们赶到 Survivor 区。
  4. Survivor 区爆满,直接将大批对象晋升到老年代。
  5. 老年代也满了,触发终极整理 Full GC。发现老年代里全是 static List 引用的对象,还是杀不掉。
  6. JVM 绝望停机,抛出 OutOfMemoryError: Java heap space

4. 关键代码/示例

为了让你深刻理解,这里给出产生各类溢出的标准实验代码。你可以直接在本地运行,配合 JVM 参数体验崩溃的瞬间。

示例 1:模拟 Java 堆溢出 (Heap Space)

JVM 配置参数: -Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError (限制最大堆内存为 20M,且溢出时自动导出 Dump 文件)

可用 -XX:HeapDumpPath= 参数来指定Dump文件的生成路径

java 复制代码
import java.util.ArrayList;
import java.util.List;

public class HeapOomDemo {
    // 定义一个占位的大对象,加速内存消耗
    static class BigObject {
        private byte[] placeholder = new byte[1024 * 1024]; // 每次占用 1MB
    }

    public static void main(String[] args) {
        List<BigObject> list = new ArrayList<>();
        // 核心原理:对象一直被强引用(list持有),垃圾回收器无法介入
        while (true) {
            list.add(new BigObject());
        }
    }
}

示例 2:模拟虚拟机栈溢出 (StackOverflowError)

JVM 配置参数: -Xss128k (将每个线程的栈容量缩小到 128K)

java 复制代码
public class StackOverflowDemo {
    private int stackDepth = 1;

    // 核心原理:没有出口的递归调用,导致栈帧无限入栈
    public void recursiveCall() {
        stackDepth++;
        recursiveCall(); 
    }

    public static void main(String[] args) {
        StackOverflowDemo demo = new StackOverflowDemo();
        try {
            demo.recursiveCall();
        } catch (StackOverflowError e) {
            System.out.println("发生栈溢出,当前栈深度为:" + demo.stackDepth);
            throw e;
        }
    }
}

示例 3:模拟元空间溢出 (Metaspace)

JVM 配置参数: -XX:MaxMetaspaceSize=10m (限制元空间大小为 10M,JDK8及以上有效)

java 复制代码
import net.sf.cglib.proxy.Enhancer;
import net.sf.cglib.proxy.MethodInterceptor;

public class MetaspaceOomDemo {
    static class TargetObject {
    }

    public static void main(String[] args) {
        // 核心原理:利用 CGLib 在运行时不断生成全新的代理类,塞满元空间
        while (true) {
            Enhancer enhancer = new Enhancer();
            enhancer.setSuperclass(TargetObject.class);
            enhancer.setUseCache(false);
            enhancer.setCallback((MethodInterceptor) (obj, method, args1, proxy) -> proxy.invokeSuper(obj, args1));
            enhancer.create(); // 动态生成新的 Class
        }
    }
}

5. 常见误区

  1. 误区一:只要报 OOM,一定是用 Java 堆内存太大导致的。

    • 正解: OOM 只是大类。你必须仔细看后面的提示。如果是 /Java heap space,才是堆问题。如果是 /Metaspace,是元空间问题(类加载太多);如果是 /Unable to create new native thread,是系统级别的线程数或者栈空间达到了上限;如果是 /Direct buffer memory,则是 NIO 使用的通过 Unsafe 分配的堆外系统内存泄露了。
  2. 误区二:字符串常量池(StringTable)一直都在方法区里。

    • 正解: 在 JDK 6 时,确实在方法区(永久代)。但由于太容易引发永久代 OOM,从 JDK 7 开始,字符串常量池和静态变量被移到了 Java 堆 中。这也是为什么大量的 String.intern() 滥用在现代 Java 中会导致堆溢出,而不是元空间溢出。
  3. 误区三:Java 有超强的 GC 机制,不可能像 C++ 那样发生内存泄漏。

    • 正解: C++ 的泄露是"忘了释放物理内存",Java 的泄露是"逻辑内存泄露"。只要你的代码错误地持有了一个毫无用处对象的引用(比如静态 Map 中的缓存一直只进不出,或者 ThreadLocal 用完忘记 remove),GC 就永远不敢回收它,这同样会造成内存泄漏。

6. 实际工作中怎么用

懂了原理之后,你就要在实际的服务器部署、代码编写和故障排查中把这些知识用起来。这是一名初级开发走向资深必须要养成的习惯:

  1. 启动脚本规范:

    • 绝对不要让生产网关在没有监控和现场保留参数的情况下裸奔。
    • 启动参数必须加上:-XX:+HeapDumpOnOutOfMemoryError-XX:HeapDumpPath=/log/heapdump.hprof。一旦发生 OOM,JVM 会在临死前自动生成快照。这就是你事后破案的唯一"黑匣子"。
    • 建议将 -Xms (初始堆大小) 和 -Xmx (最大堆大小) 设为一样大。原理是什么?防止在应用高峰期,JVM 需要频繁向操作系统申请扩容堆内存而带来的严重停顿开销。
  2. 写代码的红线:

    • 如果你要在内存里做缓存(比如写个 HashMap 存高频数据),一定要考虑淘汰策略。如果不确定能控制好边界,去用 Guava Cache 或者直接上 Redis,千万别用原生的 Map 充当无界缓存。
    • 在写拦截器、使用 ThreadLocal 时,必须且绝对 要在 finally 代码块里调用 .remove()。因为 Tomcat 这种 Web 容器使用的是线程池,线程是会被复用的。如果上一个请求放进 ThreadLocal 的巨大上下文对象没清理掉,下一个请求复用该线程时,不仅数据可能错乱,这个巨大对象也会像牛皮癣一样挂在线程空间里一直无法被 GC,最终生生把堆内存挤爆。
  3. 排查动作流:

    • 收到告警服务挂了 -> 查日志发现 OutOfMemoryError
    • 找到启动参数配置的 dump 文件所在目录,把它下载到本地电脑。
    • 使用 Eclipse MAT 或者 VisualVM 导入 dump 文件。
    • 查看大对象饼图(Dominator Tree),揪出占用内存 90% 以上的具体类名。
    • 顺藤摸瓜找到类对应的业务代码,修复长生命周期引用,修复上线。
相关推荐
华仔啊4 小时前
为啥不用 MP 的 saveOrUpdateBatch?MySQL 一条 SQL 批量增改才是最优解
java·后端
xiaoye20186 小时前
Lettuce连接模型、命令执行、Pipeline 浅析
java
beata10 小时前
Java基础-18:Java开发中的常用设计模式:深入解析与实战应用
java·后端
Seven9710 小时前
剑指offer-81、⼆叉搜索树的最近公共祖先
java
雨中飘荡的记忆1 天前
保证金系统入门到实战
java·后端
Nyarlathotep01131 天前
Java内存模型
java
暮色妖娆丶1 天前
不过是吃了几年互联网红利罢了,我高估了自己
java·后端·面试
NE_STOP1 天前
MyBatis-参数处理与查询结果映射
java