JVM 内存结构:一次方法调用里,数据都放在哪

从一次方法调用出发,拆解 JVM 运行时数据区的五个区域——程序计数器、虚拟机栈、本地方法栈、堆、方法区,讲清线程私有与线程共享的分界、栈帧结构与堆分代、对象内存布局,以及永久代到元空间的演变和逃逸分析带来的例外。

一个 Java 程序跑起来,方法一个接一个地调用,对象一个接一个地 new。这些“方法调用”和“对象”在内存里到底放在哪?为什么局部变量不用加锁、堆上的对象却要加锁?为什么同样是内存不够,有时报 StackOverflowError、有时报 OutOfMemoryError、有时又报 Metaspace 不够?

答案都在 JVM 的内存结构里。JVM 把运行时内存划成几块职责不同的区域,一条方法调用会同时穿过其中好几块。理解这套划分,是理解线程安全、垃圾回收、内存溢出排查这些 Java 高频主题的地基。

本文回答一个问题:JVM 运行时,代码、对象、变量、方法调用这些“数据”分别放在哪个区域,这套划分决定了什么?本文讲 HotSpot 虚拟机(最主流的 JVM 实现)的内存结构,不展开垃圾回收算法——那是这个系列后面几篇的事。主要依据是 JVM 规范和《深入理解 Java 虚拟机》,版本差异会在关键处标注。

一、先分清「规范」和「实现」

JVM 规范把运行时内存定义为五个区域:程序计数器、Java 虚拟机栈、本地方法栈、堆、方法区。但规范只规定“有这么几块、各管什么”,不规定每块怎么落地——那是具体虚拟机实现的自由。

HotSpot 的实现里,最有名的一次“落地方式变更”发生在方法区上:JDK 7 及以前,方法区用永久代(PermGen)实现,受 -XX:MaxPermSize 限制、属于堆的一部分;JDK 7 先把字符串常量池从永久代挪到了堆;JDK 8 干脆删掉永久代,改用元空间(Metaspace)——放到本地内存里,默认只受物理内存限制(可用 -XX:MaxMetaspaceSize 封顶)。

这个演变的意义在于:方法区存的是类的元数据,它的容量需求和“程序 new 了多少对象”关系不大,而和“加载了多少类、用了多少反射和动态代理”关系大。把它从堆里挪出去,就避免了两类不同的内存需求互相挤占。所以后面提到方法区,默认指 JDK 8 之后的元空间实现。

规范之外还有两块常被提到、却不属于运行时数据区的内存:直接内存(Direct Memory)和代码缓存(Code Cache)。直接内存是 NIO 用 ByteBuffer.allocateDirect 分配的堆外内存,不受堆大小限制、也不受 GC 直接管理;代码缓存是 JIT 编译产物存放的地方,方法被编译得多了也可能溢出。理解“运行时数据区”的边界,顺便要知道“JVM 占的内存”比这五块更大——堆、栈、方法区之外,还有直接内存、代码缓存、线程栈、JVM 自身。排查“进程内存持续上涨”时不能只盯堆,否则会漏掉直接内存和代码缓存这两个“隐形大户”。

二、一条最关键的分界线:线程私有 vs 线程共享

五个区域先按“谁能用”分成两堆,这条线比任何一张细节图都重要。

JVM 运行时数据区的五个区域

线程私有的三块——程序计数器、虚拟机栈、本地方法栈——随线程生灭,每个线程各有一份,互不干扰。线程共享的两块——堆、方法区——所有线程都能访问。

这条分界线直接回答了开头的问题:局部变量放在虚拟机栈里,栈是线程私有的,所以局部变量天然线程安全、不用加锁;对象实例放在堆里,堆是线程共享的,所以堆上的对象需要同步来保证可见性和互斥。这也是为什么“为什么 HashMap 要加锁、而方法里的局部 ArrayList 不用”这个问题的答案,不在 HashMap 和 ArrayList 身上,在堆和栈身上。

// 局部变量:在栈上、线程私有,天然安全
void run() {
    int local = 0;   // 每个线程自己的栈帧里各有一份
    local++;         // 不用加锁
}

// 共享对象:在堆上、线程共享,需要同步
class Counter {
    private int count;   // 堆里的字段,多线程可见
    // 多线程同时 count++ 会丢更新,需要加锁或 CAS
}

local 是局部变量,每个线程的栈帧里各有一份,谁也看不见谁,所以 local++ 天然安全;count 是 Counter 对象的字段,对象在堆里、被多个线程共享,count++ 的“读-改-写”之间会被别的线程穿插,所以丢更新。同样的 ++,安全与否不取决于语法,取决于它操作的数据在栈上还是堆上。反过来,“堆和方法区为什么线程共享”也顺理成章:对象和类是所有线程的共同资源,任何线程都可能 new 对象、都可能通过反射拿到类信息,所以这两块必须是全局的、被所有线程共同访问。

三、线程私有的三块:程序计数器、虚拟机栈、本地方法栈

**程序计数器(PC Register)**最小也最简单:它记录当前线程执行到哪条字节码指令。字节码解释执行时靠它取指令,多线程切换时靠它恢复执行位置。它是五个区域里唯一不会抛出 OutOfMemoryError 的——因为规范规定它必须能容纳一个返回地址。

它看起来可有可无,其实解决的是“线程切换后怎么接着跑”:CPU 在多个线程之间来回切,每个线程必须记住自己执行到哪条指令,切回来才能继续。程序计数器因此是线程私有的、每个线程一份——这既是它的职责,也是“为什么它线程私有”的原因。

虚拟机栈(JVM Stack)是方法调用的舞台。每个方法被执行时,JVM 都会同步创建一个栈帧压入栈,方法结束(正常返回或抛异常)时栈帧出栈。栈帧里装四样东西:

  • 局部变量表:方法参数和方法内局部变量。this(非静态方法)、参数、局部变量按槽(slot)存放;
  • 操作数栈:字节码指令的运算工作区,a + b 就是把 a、b 压栈、做加法、再把结果压回去;
  • 动态链接:指向运行时常量池中该方法的引用,用于方法内调用其他方法时定位目标;
  • 方法返回地址:记录调用者下一条指令的位置,正常返回或异常返回都要用它回到调用点。

一个栈帧的四块结构

栈的容量是有限的。栈帧压得太多(无限递归)、或单个栈帧太大(局部变量表过大),会抛出 StackOverflowError。这是和堆 OOM 完全不同的失败——堆 OOM 是“对象太多装不下”,栈溢出是“调用链太深装不下”,排查方向也不同。

局部变量表和操作数栈的配合,看一段字节码最直观:

// 源码:foo 里做一次 x + 1
void foo(int x) {
    int y = x + 1;
}

// 对应的字节码
// iload_1     # 把局部变量表的 x(槽 1)压入操作数栈
// iconst_1    # 把常量 1 压入操作数栈
// iadd        # 弹出两个操作数、相加、结果压回操作数栈
// istore_2    # 把结果存回局部变量表的槽 2(即 y)

x 从局部变量表读到操作数栈、算完又写回局部变量表——“变量存哪”(局部变量表)和“运算在哪发生”(操作数栈)是两个不同的地方。这也是为什么“栈”不是一块简单的内存,而是由栈帧、槽、操作数栈这些结构组成的。

把这几条指令逐步走一遍,看两块的数值怎么变(假设调用 foo(5)):

指令 局部变量表 操作数栈(左为底、右为顶) 这一步在干什么
初始 x=5 [] 参数 x 已放在槽 1,y 还没赋值
iload_1 x=5 [5] 把槽 1 的 x 读出来压栈
iconst_1 x=5 [5, 1] 把常量 1 压栈
iadd x=5 [6] 弹出两个、相加、结果压回栈
istore_2 x=5, y=6 [] 弹出结果,写回槽 2(即 y)

对照着看,规律很清楚:所有运算都只能在操作数栈顶发生,局部变量表只是“仓库”——要用先 iload 取出来,算完再 istore 存回去。为什么这么设计?因为操作数栈的“栈顶”天然对应 CPU 的寄存器/栈顶寻址,指令不需要带操作数地址,字节码能编得更紧凑。

局部变量表与操作数栈的逐步变化

理解了它,再看 StackOverflowError 的报错栈和调试器里的调用栈,就不再是黑盒。

局部变量表的槽(slot)还有一个复用细节:一个槽存一个 32 位以内的数据(int、引用等),long、double 这类 64 位数据占两个槽。方法里先定义一个局部变量、后面不用了,JIT 可能复用这个槽存别的变量——这也是为什么调试时有时看到“局部变量被优化掉了、拿不到值”。栈帧的结构是固定的,但槽的复用是动态的。

**本地方法栈(Native Method Stack)**服务和虚拟机栈类似,只是给 native 方法用的——Java 代码调 JNI 方法时,native 方法要用 C/C++ 的栈帧来执行。HotSpot 把它和虚拟机栈合二为一实现,所以日常几乎不用单独关心它。这里记住一件事就够了:线程栈(虚拟机栈 + 本地方法栈)是操作系统线程的一部分,每个 Java 线程对应一个 OS 线程(HotSpot 的 1:1 线程模型),线程栈大小(-Xss)直接影响一个线程能开多少层调用、一个进程能开多少线程。栈设得太大,几百个线程就能吃掉几 GB 内存;设得太小,稍微深一点的调用链就 StackOverflowError。

四、线程共享的堆:对象的家

堆是 JVM 内存里最大的一块,也是垃圾回收的主战场。几乎所有对象实例都分配在这里。

不过分代假设(大部分对象朝生夕死)不总是成立。如果程序里大量对象被长期持有——对象池、缓存、常驻单例——新生代复制算法“复制存活对象”的成本就上去了,因为每次 GC 都要复制一大堆“其实还活着”的对象。这也是“分代”不是万能模型的原因:G1 后来用 Region、ZGC 用着色指针,部分目的就是想摆脱“新生代/老年代”这套固定划分对某些工作负载的不适应。这个伏笔,留到后面垃圾回收那几篇展开。

堆在 HotSpot 里是分代的,分成新生代和老年代。新生代又分成 Eden 和两个 Survivor(S0、S1),默认比例 Eden:S0:S1 = 8:1:1。分代的假设是“大部分对象朝生夕死”——新生代专门承接刚 new 出来的、活不长的对象,老年代放活得久的对象,GC 对不同代用不同策略。

对象分配的流程大致是:优先进 Eden;Eden 满了触发一次新生代 GC(Minor GC),存活对象复制到 Survivor,两个 Survivor 反复倒腾,每活过一次 GC 年龄加一,年龄超过阈值(默认 15)就晋升老年代;特别大的对象(超过 -XX:PretenureSizeThreshold)直接进老年代,避免在新生代里反复复制。

为什么默认是 8:1:1 而不是各三分之一?因为分代的假设是“绝大部分对象活不过第一次 GC”。Eden 放大(占 8),给新对象足够空间;两个 Survivor 放小(各占 1),因为每次 Minor GC 后活下来的对象很少,一个 Survivor 就够装,另一个空着等下次复制。Minor GC 的动作是:标记 Eden 和当前 Survivor 里的存活对象 → 复制到另一个空 Survivor → 清空 Eden 和旧的 Survivor。这个“复制”正是新生代用复制算法的原因——存活对象少,复制成本低。

对象分配还有一个高频优化:TLAB(Thread Local Allocation Buffer,线程本地分配缓冲)。堆是线程共享的,如果每个线程 new 对象都去堆上抢同一块分配指针,就要加锁、有竞争。HotSpot 给每个线程在 Eden 里划一小块私有缓冲,线程在 TLAB 里分配对象不用加锁,TLAB 用完了才去 Eden 申请新的。所以“对象分配在堆里,但分配动作大多数时候是线程私有、无锁的”——它解释了为什么 new 一个对象通常很便宜,也解释了为什么 TLAB 参数(-XX:TLABSize)只在分配密集的场景才需要调。

堆的分代结构与对象晋升路径

堆里的一个对象,本身也有自己的内存布局。HotSpot 的对象由三部分构成:

  • 对象头:Mark Word(存哈希码、GC 分代年龄、锁状态、偏向线程等)+ 类型指针(Klass Pointer,指向方法区的类元数据);
  • 实例数据:对象的字段;
  • 对齐填充:保证对象大小是 8 字节的整数倍,方便内存管理。

对象头的 Mark Word 是理解很多并发主题的钥匙——锁状态、偏向锁、分代年龄全挤在这个机器字里,后面讲 synchronized 锁升级时会再回到它。

用 JOL(Java Object Layout)能看到一个对象的真实布局:

# 一个空 Object 的 JOL 输出(64 位 JVM,开启指针压缩)
java.lang.Object object internals:
 OFFSET  SIZE  TYPE  DESCRIPTION
      0     4        (object header)   # Mark Word
      4     4        (object header)   # Klass Pointer(压缩后 4 字节)
      8     0        (object header)   # 无实例数据
Instance size: 8 bytes                  # 8 字节对齐

空对象占 8 字节,其中 Mark Word 和类型指针各占 4 字节(类型指针在开启指针压缩时从 8 字节压到 4 字节)。对齐填充在这里是 0,因为 8 字节已经对齐;换成字段数不规整的对象,末尾才会补几个字节凑成 8 的倍数。这个“对象到底占多少内存”的账,排查堆占用时经常要算。

一个 HotSpot 对象的内存布局

五、方法区:类的元数据住哪

方法区存的是“类本身”,不是“类的对象”。具体包括:类的元数据(类名、字段、方法描述)、运行时常量池、静态变量。JIT 编译出来的机器码放在独立的代码缓存(Code Cache)里,严格说不在方法区。

一句话区分堆和方法区:new User() 出来的那个 User 实例在堆里,User.class 这个类对象和 User 类的元数据、静态字段在方法区。堆是“对象的集合”,方法区是“类的集合”。

class User {
    private String name;        // 实例字段:跟着对象进堆
    private static int count;   // 静态字段:在方法区,全类共享一份
}

name 是实例字段,每个 User 对象各有一份、存在堆里;count 是静态字段,全类只有一份、存在方法区(元空间),不随对象生灭。这也能解释一个常见的误区:静态字段不是“一直存在、不会 GC 的对象字段”,它压根不跟对象走,它的生命周期和“类是否被加载”绑定。

方法区里的运行时常量池也值得单独说。Class 文件里有个常量池,装着各种字面量(字符串、数字)和符号引用(类名、方法名、字段名的“名字”,还不是内存地址)。类加载后,这些常量进入运行时常量池,符号引用在真正用到时才被解析成直接引用(内存地址)。上一节说的“栈帧里的动态链接指向运行时常量池”,指的就是这个解析过程——方法调用时,栈帧通过动态链接、再经过运行时常量池,找到要调用的目标方法。这也能解释为什么“类加载”和“内存结构”分不开:方法区存的是类的静态信息,运行时常量池是静态信息到运行时引用的桥梁。

方法区的溢出和堆也不同:堆 OOM 是“对象太多”,方法区(元空间)溢出是“类太多”——大量动态生成类(反射、CGLIB、动态代理、JSP)会撑爆元空间。排查时看到 OutOfMemoryError: Metaspace,就该去查是不是哪里在无限生成类,而不是去调大堆。

方法区也会被回收,只是比堆苛刻得多。类的卸载要求极其严格:该类的所有实例都已被回收、加载它的类加载器已被回收、该类的 Class 对象没有任何地方被引用。所以“类太多导致元空间溢出”,往往不是靠 GC 卸载类来解决,而是靠排查“谁在无限生成类”来解决——卸载门槛太高,指望 GC 兜底不现实。这也是为什么动态代理、CGLIB 这类会动态生成类的框架,长期运行时尤其要留意元空间。

六、对象一定在堆里吗:逃逸分析的例外

前面说“几乎所有对象都在堆”,“几乎”这个限定词有讲究。HotSpot 的 JIT 编译器会做逃逸分析:如果分析出某个对象不会逃出当前方法(不会被其他线程或方法访问),就可能把它优化成栈上分配,或把它的字段标量替换成局部变量。这样对象随栈帧生灭,方法结束就消失,不用进堆、不用等 GC。

逃逸分析是 JIT 的优化手段,不是规范保证——它默认开启,但分析不出来时对象还是照常进堆。所以正确的认知是:从语义上,对象属于堆;从实现上,JIT 可能把不逃逸的对象优化到栈上。这解释了为什么“所有对象都在堆”这句流传很广的话,严格说是不准确的,只是它不影响你写正确的代码。

// 这个 Point 只在本方法内使用,不会逃逸,JIT 可能做标量替换
void sum() {
    Point p = new Point(1, 2);   // 不返回、不传给别人、不存字段
    int s = p.x + p.y;           // 等价于直接把 x、y 当局部变量算
}

// 开启 / 关闭逃逸分析(默认开启)
// -XX:+DoEscapeAnalysis -XX:+EliminateAllocations

逃逸分析能把 Point p 的两个字段替换成两个局部变量,new Point 的堆分配直接消失。但判断“会不会逃逸”是保守的——只要有一处不确定,就放弃优化、老老实实进堆。所以从写代码的角度,你仍然要按“对象在堆”来思考线程安全和生命周期,逃逸分析只是 JIT 在背后帮你省 GC 压力,不是你可以依赖的语义。

七、这套划分决定了什么

把五块区域装进脑子,很多问题会自动有答案。

线程安全:栈里的数据(局部变量)线程私有,天然安全;堆里的数据(对象字段)线程共享,需要 volatile、锁或不可变来协调。这就是“局部变量不用同步、共享对象要同步”的根因。堆共享这一条,也是 volatile、synchronized、CAS 这些并发工具存在的根本原因——它们都是为了让多个线程在共享的堆数据上协调一致。下一篇讲 volatile 与 JMM 时,正是从“堆上的字段怎么保证可见性和有序性”这个问题出发。

内存溢出的类型:栈深了是 StackOverflowError,堆满了是 OutOfMemoryError: Java heap space,类加载多了是 OutOfMemoryError: Metaspace。三种错误对应的内存区域不同,排查动作也不同——栈溢出查递归,堆溢出查对象生命周期和泄漏,元空间溢出查动态生成类。

GC 的作用范围:GC 主要在堆上工作(新生代、老年代),方法区的类卸载也有但要苛刻得多。理解了堆分代,才能理解后面要讲的“新生代用复制算法、老年代用标记-清除/整理、G1 用 Region”这些垃圾回收设计为什么这么选。

三类失败还可以串成一条排查思路:先看报错类型,StackOverflowError 查调用链(哪里递归没出口、哪里调用过深),OutOfMemoryError: Java heap space 查对象(哪个对象一直在长、是不是泄漏、dump 出来看引用链),OutOfMemoryError: Metaspace 查类(是不是动态代理、CGLIB 在无限生成类、类加载器有没有泄漏)。报错类型本身就是第一条线索,因为它指向了不同的内存区域——先判断“是哪块内存爆了”,再决定查递归、查对象还是查类,方向就不会错。

现在把一次方法调用串成完整路径,看看五块区域怎么配合。假设 main() 调用 foo(a),foo 里 new User() 并调用它的 getName():

  1. 程序计数器指向 main 里调用 foo 的那条字节码指令;
  2. JVM 为 foo 压入一个栈帧,参数 a 进局部变量表;
  3. new User() 在堆的 Eden(更准确说是 TLAB)里分配对象,对象头的类型指针指向方法区里 User 类的元数据;
  4. 调用 user.getName() 时,栈帧的动态链接经运行时常量池定位到方法区里 getName 的方法信息;
  5. foo 返回,栈帧出栈,程序计数器回到 main 的调用点。

一次调用,五块区域全部参与。反过来,任何一个内存异常,也都能在这条路径上定位到具体是哪一块出了问题。

一个方法调用,从头到尾会穿过:程序计数器指路 → 栈帧在虚拟机栈里进出 → 局部变量在栈帧的局部变量表 → new 的对象进堆 → 对象的类型信息在方法区。把这五块串成这条路径,JVM 内存结构就不再是一堆名词,而是一张能定位任何数据、任何错误的活地图。

这篇文章画的是地图,后面几篇就是在这张地图上各占一块:HashMap 讲堆里一个具体对象怎么组织,volatile 和锁讲堆上共享字段怎么协调,线程池讲线程(和线程栈)怎么复用,GC 那几篇讲堆怎么回收。地图先立住,后面的机制才有地方挂。

参考资料