JVM 内存到底分了哪几块——我的学习笔记

说在前面: 我是一个刚接触 JVM 的新手。这篇文章是我在啃资料、看视频、反复问自己"这个玩意儿到底有什么用"之后,整理出来的笔记。我不会说这个很简单------因为对我真不简单。如果你也是刚开始学,希望能帮到你。


我是怎么开始学这个的

事情是这样的。之前写 Java 代码,遇到过一个报错叫 OutOfMemoryError: Java heap space,我当时看了两眼一黑,只知道"哦,内存不够了"。但是内存不够了为什么会是这个报错,为什么有的报错叫 StackOverflowError 有的叫 Metaspace,我完全不知道。

后来跟朋友聊,他说你先不要管报错,你先搞清楚 JVM 把内存分成了哪几块。

好,我就从这个开始学。


JVM 把内存分成了五块(JDK 8)

我先说结论,然后再一块一块说。

JDK 8 里面,JVM 运行时内存分成了五个区域:

  1. 程序计数器(Program Counter Register)
  2. Java 虚拟机栈(JVM Stack)
  3. 本地方法栈(Native Method Stack)
  4. Java 堆(Heap)
  5. 元空间(Metaspace)

还有一个叫 直接内存(Direct Memory),这个不属于 JVM 管,但也算内存的一部分,后面会提。

乍一看五个名字挺吓人的,但别怕,我一个一个拆开讲。


程序计数器------最小的一块,也是唯一不会溢出的地方

这个东西的名字听着很厉害,其实它的功能挺简单:记录当前线程执行到哪一行字节码了。

你可以把它理解成你看书的时候手里夹的那张书签。你读的是哪一页、哪一行,书签帮你记着。线程也一样,CPU 把它的时间片切走了,它要去干别的线程,等切回来的时候,程序计数器帮它找到刚才读到哪儿了。

有两个点我觉得有意思:

  • 如果执行的是 Native 方法 (也就是本地方法,比如 C 写的代码),程序计数器的值是 undefined。因为 native 方法走的是本地指令,不是 JVM 的字节码,没法用这个计数器来记。
  • 它是 JVM 五大区域里 唯一不会发生 OutOfMemoryError 的地方。规范里就没给它留这个异常。

为什么它是线程私有的?因为每个线程各读各的代码,各走各的执行路径。如果共用一个计数器,切换回来就不知道切到谁那儿了。所以每个线程自己管自己的。


Java 虚拟机栈------和方法调用关系最密切的一块

栈在数据结构里是什么意思大家应该知道------先进后出。Java 虚拟机栈也是一样。每个方法被调用的时候,会往栈里压入一个"栈帧"(Stack Frame),方法执行完,栈帧弹出。

一个栈帧里面装的东西包括:

  • 局部变量表:方法里定义的局部变量就放这儿
  • 操作数栈:做运算的时候临时放数据的地方
  • 动态链接:涉及到多态、方法调用时用到的符号引用转直接引用
  • 方法出口:方法执行完了之后返回到哪儿去

我当时学到这里脑子里冒出的第一个问题是:那这个栈到底多大?

答案是不确定。取决于 JVM 的参数设置(-Xss)。但不管设置多大,如果方法调用的层数太深,比如无限递归,栈深度就超过上限了,会抛 StackOverflowError

还有一种是栈扩展的时候没成功,抛 OutOfMemoryError。这个场景比较少见,一般是线程数量太多导致的。

这也是线程私有的------每个线程有自己的虚拟机栈。


本地方法栈------和上面那个差不多,但不是一回事

Java 虚拟机栈是为 Java 方法服务的。那如果调了一个 native 方法呢?走的不是 Java 的字节码,不能走 Java 虚拟机栈------所以单独搞了一个 本地方法栈

不过 HotSpot 虚拟机(就是我们最常用的那个 Oracle JDK 和 OpenJDK 带的虚拟机)里面,本地方法栈和 Java 虚拟机栈合并到一起了。所以实际用的时候感受不到是两个。

同样可能抛 StackOverflowErrorOutOfMemoryError


Java 堆------最大的一块,也是 GC 最忙的地方

堆是 JVM 里最大的一块内存。所有的对象实例和数组都在这里分配。

它是线程共享的,JVM 启动的时候就创建好了。

堆里面还被划分成了几个子区域(为了垃圾回收效率优化):

  • 新生代(Young Generation):里面又分三个部分------一个 Eden 区、两个 Survivor 区(S0 和 S1)
  • 老年代(Old Generation / Tenured Generation)

我刚学到这里的时候很不理解:为什么堆还要分这么多区域?后来知道这是为了"分代回收"------大部分对象活不了多久就死了(比如循环里 new 的对象),新生代回收频率高但快;少部分对象命硬,会晋升到老年代,回收频率低。这是一种典型的"二八定律"思路。

堆不够用的时候抛的异常大家都眼熟:OutOfMemoryError: Java heap space


元空间(Metaspace)------取代了方法区的新东西

先说方法区(Method Area)是什么。

方法区是 JVM 规范里的一个逻辑区域,用来存放:

  • 已被虚拟机加载的类信息
  • 常量
  • 静态变量
  • 即时编译器编译后的代码缓存

这个区虽然是堆的逻辑组成部分,但有个别名叫"非堆"(Non-Heap)。所以它虽然是"堆的概念"但不算"堆的实体"。

在 JDK 8 之前,方法区的实现叫永久代(PermGen),在 JVM 堆里面。

到 JDK 8,永久代被干掉了,换成了 元空间(Metaspace) 。关键的区别是:元空间使用了本地内存,不再是 JVM 堆的一部分了。这意味着它的大小不再受 JVM 堆大小的限制,而是受本机物理内存的限制。

元空间不够用的时候抛 OutOfMemoryError: Metaspace


运行时常量池------方法区的一部分

常量池这个词可能你在反编译 class 文件的时候见过。编译器把类里的字面量 (比如字符串常量 "hello"、final 常量等)和符号引用(类名、方法名、字段名的引用)放到这个池子里。

运行时常量池就是这些信息在运行时的存放位置,是方法区的一部分。

还有一个特点:动态性 。不是只有编译期生成的东西才能放进去。你在代码里调 String.intern(),也可以在运行时把字符串扔到常量池里。

内存不够的时候也会抛 OutOfMemoryError。


直接内存------不属于 JVM,但 JVM 可以操作

直接内存是操作系统的本地内存,不归 JVM 管。

那它跟 JVM 有什么关系?Java 里有一个 NIO(New I/O)的 API,可以通过 ByteBuffer.allocateDirect() 分配一块堆外内存。这块内存的好处是:在进行 I/O 操作(比如读写文件、网络通信)时,数据不用在堆内存和本地内存之间来回拷贝,性能好很多。

很多高性能框架比如 Netty 就是基于这个原理做优化的。

但是------它是本地内存,所以也受物理内存上限的限制。用得不好也会报 OutOfMemoryError: Direct buffer memory


五种内存溢出,一张表总结

学完之后我把五种溢出情况整理了一下:

溢出区域 异常信息 常见原因
堆溢出 Java heap space new 大对象、内存泄漏、GC 回收不掉
栈溢出 StackOverflowError / OutOfMemoryError 递归太深、线程开太多
元空间溢出 Metaspace 加载类太多、动态类生成、CGLIB 等
直接内存溢出 Direct buffer memory direct buffer 没回收、一直分配

程序计数器那行没写------因为它是唯一不会溢出的。


最后想说的

这些东西我一开始觉得是背概念,背完就忘。后来我发现,真正帮我理解的不是背,而是带着问题去查

  • "为什么栈会溢出?"------因为我写过递归忘记写终止条件
  • "堆溢出是什么时候遇到的?"------因为我导了一个超大 Excel 没分页
  • "元空间溢出在什么场景出现?"------因为看到过用 CGLIB 动态代理搞出来的案例

如果你是新手,我的建议是:不要硬背分区名称。先把以下两个记住就行:

  1. 线程私有的:程序计数器、Java 虚拟机栈、本地方法栈
  2. 线程共享的:堆、元空间(方法区)

剩下的,遇到具体的报错再去翻。等报错见多了,分区图自然就长在脑子里了。