前言市面上关于 JVM 内存区域的文章很多但相当一部分要么停留在“程序计数器、虚拟机栈、堆、方法区”的名词罗列要么直接给出一张方框图让你背下来。都写得很好可惜对基础较弱的读者不太友好。本文将从一个实例出发探索JVM内存区域希望读完本文后你能对 JVM 内存区域有个清晰的认知同时对其背后的设计哲学也有了基本的感受。阅读本文你需要基本的 Java 语法知识。运行 Java 程序时到底发生了什么假设你写了这样一个类public class HelloWorld { public static void main(String[] args) { Person p new Person(); p.name Alice; System.out.println(p.name); } } ​ class Person { String name; }在命令行执行javac HelloWorld.java之后目录下会多出HelloWorld.class和Person.class两个字节码文件。这些字节码就是 JVM 的“指令集”。接着执行java HelloWorld整个流程可以粗略拆成三个阶段类加载JVM 启动后会通过类加载器ClassLoader将HelloWorld.class和Person.class读入内存解析其中的类元信息——类名、父类、字段、方法签名、常量池等。这一阶段数据被安置在“方法区”HotSpot 的实现中JDK 8 起为元空间 Metaspace。运行时数据区初始化与执行主线程启动JVM 为它创建好程序计数器、Java 虚拟机栈、本地方法栈。然后开始执行main方法的字节码指令。在方法执行的过程中所有通过new创建的对象都分配到“堆”中方法内部的临时变量和中间计算结果则记录在线程私有的栈帧里。内存回收与程序退出方法执行完毕或发生未捕获异常后主线程结束对应的栈空间自然释放。堆中的对象失去引用后由垃圾回收器在合适的时机回收。从这个三个阶段的视角出发JVM 内存区域的划分就有了明确的现实理由p这个引用只是方法执行时的临时记录而new Person()出的那个对象可能被多个方法、甚至多个线程访问生命周期远大于单次方法调用。混在一起管理显然不合适所以必须分而治之。线程私有 vs. 线程共享JVM 的内存区域首先按线程归属分为两大类线程私有程序计数器、Java 虚拟机栈、本地方法栈。每个线程独立一份同生共死分配和回收由线程生命周期严格决定不需要垃圾回收器。线程共享Java 堆、方法区。整个进程仅此一份所有线程都能访问因此是垃圾回收的主战场也面临并发访问的挑战。我们先看三个线程私有区域。一、程序计数器它存的是什么存的是当前线程正在执行的字节码指令地址如果是 Native 方法则为空。本质上就是一个行号指示器。为什么每个线程需要一个独立的JVM 多线程的本质是不断切换线程来轮流使用 CPU 核心。线程 A 执行到第 25 条指令时被挂起CPU 去执行线程 B等线程 A 再次被调度时必须从这里继续往下执行。所以每个线程都要有自己的一份“书签”记录自己执行到哪了。举例假设main方法中的System.out.println(p.name)被编译为几条字节码指令先加载p的引用再获取其name字段然后调用打印方法。线程执行到第一条指令时程序计数器指向当前的偏移量如果此时发生线程切换回来时仍然能从那一条指令继续。没有 OutOfMemoryError程序计数器只占很小且固定长度的内存不会动态增长因此不可能内存溢出。这也是 JVM 规范中唯一保证不会 OOM 的区域。二、Java 虚拟机栈水线每个 Java 方法被调用时JVM 会为它创建一个栈帧Stack Frame压入当前线程的虚拟机栈中方法执行结束栈帧弹出销毁。这种先进后出的结构天然契合方法的嵌套调用关系。栈帧的结构栈帧内部由四部分组成局部变量表存放方法参数和方法内定义的局部变量。基本类型直接存值引用类型则存指向堆中对象的指针。操作数栈方法执行过程中的“工作台”字节码指令不断地从这里取数、运算、放回结果。动态链接每个栈帧维护一个指向运行时常量池中该栈帧所属方法的符号引用用于方法调用时的解析。方法返回地址方法执行完毕或异常退出后调用方的继续执行位置。这里用一个具体的代码片段来演示栈帧是怎样工作的。假设有一个很简单的类public class StackDemo { public static void main(String[] args) { int a 10; int b 20; int sum add(a, b); System.out.println(sum); } ​ static int add(int x, int y) { return x y; } }启动主线程JVM 为main方法创建一个栈帧压入虚拟机栈。局部变量表里存入args引用、a10、b20。此时操作数栈为空。执行到add(a, b)时main线程暂停JVM 为add方法创建一个新栈帧并压入。这个新栈帧的局部变量表中包含参数x10, y20。add方法内部执行字节码将x、y加载到操作数栈执行加法结果30留在栈顶。add方法返回栈顶的结果30被复制到main方法栈帧的操作数栈中然后add的栈帧弹出销毁。main方法将30存入局部变量表中的sum。main继续执行打印完成后自己的栈帧弹出主线程结束。从这个例子可以提炼出一个重要设计思想栈帧随方法而生、随方法而灭内存的分配和回收完全是确定性的。正因为这种确定性栈区几乎不存在长期不释放的无效内存也因此不需要垃圾回收器。这也解释了为什么StackOverflowError会发生如果调用的嵌套层次过深比如无限递归栈帧不断被压入而无法弹出最终会超过栈的最大容量。设置栈大小的-Xss参数就是这个深度的上限。三、本地方法栈它与 Java 虚拟机栈的作用完全对称只不过专门服务 Native 方法通常由 C/C 实现通过 JNI 调用。在 HotSpot 中这两个栈是合二为一的所以平常我们讲“栈”时关注虚拟机栈就够了。本地方法栈和程序计数器一起构成了线程私有内存的三根支柱。但真正让 Java 对象有处可存、让线程间能够共享数据的区域还在后面。四、Java 堆堆的基本定位Java 堆在虚拟机启动时创建所有线程共享唯一的目的就是存放对象实例和数组。前面例子里的new Person()就会在堆上分配一块内存用来存放这个Person对象的数据。几乎所有的new对象都在堆上分配。随着 JIT 编译器和逃逸分析技术的成熟某些不会逃逸出方法作用域的局部对象可能会直接在栈上分配或进行标量替换但这属于运行时的极端优化不影响我们对“绝大部分对象在堆上”的理解。一个对象在堆中的一生我们通过一个更复杂的例子来感受对象在堆上的变化。请出上一节的Personpublic class HeapDemo { public static void main(String[] args) { Person p1 new Person(); p1.name Alice; ​ // 假设这里有一大堆临时对象被创建比如 for (int i 0; i 1000000; i) { Person temp new Person(); } ​ Person p2 new Person(); p2.name Bob; } }JVM 默认会将堆划分为新生代和老年代。新生代又细分为一个 Eden 区和两个 Survivor 区S0、S1。我们跟着这段代码走一遭程序刚启动时新生代的 Eden 区是空荡的。执行Person p1 new Person()对象p1分配在 Eden 区。接着循环创建 100 万个Person临时对象。这些对象源源不断地填入 Eden 区直到空间不足触发一次Minor GC新生代垃圾回收。Minor GC 时垃圾回收器会标记 Eden 区中仍然存活的对象。此时p1还在被main栈帧的局部变量表引用所以它是存活的。其他 100 万个临时对象都没人引用属于垃圾。存活的对象p1被复制到其中一个 Survivor 区假设是 S0同时其“年龄”记为 1。Eden 区被整个清空。循环结束Eden 区又有大量临时对象变成垃圾。再次 Minor GC这次 Eden 区已经没多少存活对象了而 S0 中的p1依然存活会被复制到另一个 Survivor 区 S1年龄加 1。此后若p1经历多次 Minor GC 仍然存活达到晋升年龄阈值默认 15就会被晋升到老年代。方法末尾创建的p2作为一个新生对象仍然在 Eden 区分配。如果程序结束得早它可能还没经历过一次 GC。分代收集的设计依据是一个经验规律绝大多数对象比如临时变量、循环中的局部实例都是“朝生夕死”的只有少数对象如缓存、连接池、静态集合中的元素会长期存活。因此新生代采用复制算法快速清理大量短命对象老年代使用标记-清除或标记-整理算法降低单次回收的延迟影响。存储分配参数上-Xms设初始堆大小-Xmx设最大堆大小。生产环境中习惯将两者设为相同值避免动态扩缩容带来的额外性能抖动。五、方法区与元空间方法区的使命前面提到类加载阶段会将.class文件的元信息读入内存。这些数据存哪答案就是方法区。方法区存储每个类的类型信息类名、修饰符、父类、接口列表等字段信息字段名、类型、修饰符等方法信息方法名、返回类型、参数列表、字节码等运行时常量池编译期生成的字面量和符号引用JIT 编译后的代码缓存。方法区同样是线程共享的。它的设计动机很清晰一个类只需要被解析一次所有线程都应该能共享这些“类的定义”没必要让每个线程都存一份副本。永久代JDK 7 及之前HotSpot 使用永久代来实现方法区它是堆的一部分有固定的大小上限-XX:PermSize和-XX:MaxPermSize。问题来了你很难预估一个应用究竟会加载多少个类、产生多少常量。Spring、Hibernate 等框架动态生成大量字节码很容易撑爆永久代抛OutOfMemoryError: PermGen space。永久代的垃圾回收只在 Full GC 时触发而 Full GC 极耗时间通常伴随着整个应用停顿。类的卸载条件苛刻即使一个类不再被使用也可能长期滞留在永久代浪费内存。元空间JDK 8 起HotSpot 将方法区的实现从永久代切换为元空间Metaspace。核心变化是位置变了元空间直接使用操作系统的本地内存Native Memory不再属于 JVM 堆。字符串常量池和静态变量从方法区迁到了堆中减轻了方法区的负担。元空间默认不设上限只受物理内存限制也可以使用-XX:MaxMetaspaceSize限制最大值用-XX:MetaspaceSize指定触发第一次 Full GC 的阈值。这一改变带来了两大好处元空间大小弹性伸缩不再因“永久代参数没调好”而频繁 OOM。类元数据与堆数据管理解耦使得 HotSpot 和没有永久代概念的 JRockit 两大虚拟机更容易整合。运行时常量池和字符串.class文件有一个静态常量池记录了诸如Alice这样的字面量和符号引用。当类加载进 JVM 后这些数据进入方法区的运行时常量池。对你写代码的影响是什么看这段代码String a hello; // 字面量存入字符串常量池 String b hello; // 从常量池取同一个引用 String c new String(hello); // 强制在堆上新建对象 System.out.println(a b); // true同一个引用 System.out.println(a c); // false不是同一个对象在 JDK 7 及以上版本中字符串常量池被移到了 Java 堆。当第一次使用hello这个字面量时如果池中没有JVM 就在堆中创建一个String对象并放入池中后续再使用hello直接返回池中的同一引用。这也是String.intern()的工作基础。六、直接内存最后简单说一下直接内存。它不是运行时数据区的组成部分但经常在 JVM 内存优化中被提及。通过 NIO 的ByteBuffer.allocateDirect()分配的直接内存直接在操作系统本地内存中开辟缓冲区。好处是在进行大量 I/O 操作时数据免去在 JVM 堆和 OS 缓冲区之间多复制一次的损耗。要特别注意的是直接内存的大小不受-Xmx约束可用-XX:MaxDirectMemorySize限制超出则抛OutOfMemoryError。如果系统 I/O 密集这个参数需要根据物理内存合理设置。七、一次完整的方法涉及了哪些区域结合开头的HelloWorld例子我们可以把所有区域串成一条完整的线类加载.class文件被读入元数据存入元空间方法区常量池中的Alice字面量存入堆中的字符串常量池。主线程就绪程序计数器归零虚拟机栈为空。调用main 创建第一个栈帧压入栈。执行new Person() JVM 在堆的新生代 Eden 区分配一块内存用于存放Person对象的数据。栈帧的局部变量表中存入p引用指向这个对象。执行p.name AliceAlice已被类加载阶段放入字符串常量池堆Person对象中的name字段被赋值指向该引用。调用println 又压入一个新栈帧执行完毕后弹出销毁。main结束 栈帧弹出p引用消亡。堆上的Person对象失去引用成为垃圾将在下一次 Minor GC 时被回收。主线程结束虚拟机退出所有内存归还操作系统。整个过程没有自由干预内存分配与回收却始终秩序井然——这就是分区域、自动管理的魅力。八、总结综上所述我们把 JVM 内存区域设计的核心思想提炼为三条按生命周期分治线程私有的小区域程序计数器、栈与线程同生灭不需要复杂回收线程共享的区域堆、方法区才交给垃圾回收器统一管理互不干扰。基于分代假说优化回收利用“绝大多数对象朝生夕死”的观察在堆内划分新生代和老年代各用最合适的回收算法几乎实现了吞吐量和延迟的最优平衡。将类元数据从堆中剥离用元空间替换永久代将类定义信息的管理与对象管理完全解耦根本上解决了 PermGen OOM 的顽疾也让虚拟机之间更容易融合。参考资料JVM 内存区域图文详解 - 华为云社区JVM 内存模型深度剖析从 JMM 到运行时数据区 - 腾讯云社区JDK 为什么废弃永久代而引入元空间 - 腾讯云社区看懂这 6 张图理解 JVM 内存布局就没问题了 - 腾讯云社区