JVM运行时数据区详解:变量、对象和类信息到底存在哪里
JVM运行时数据区详解:变量、对象和类信息到底存在哪里
写 Java 代码时,我们通常只关注变量的值和对象的行为,很少去思考它们在 JVM 中到底如何存放。
inta=10;程序运行之后,a并不是一直存在于.class文件中,而是会随着方法执行进入 JVM 的运行时数据区。
再比如:
Objectobj=newObject();这里既有变量引用obj,也有通过new创建的对象实例,它们的生命周期和存储位置并不相同。
理解 JVM 运行时数据区,可以帮助我们搞清楚几件事:局部变量为什么方法结束后自动消失,对象为什么需要 GC 回收,静态变量、类信息、对象实例分别存放在哪里。这也是理解 Java 内存模型和垃圾回收机制的基础。
目录
- 全景图:五个区域各司其职
- 程序计数器
- 虚拟机栈
- 本地方法栈
- 堆
- 方法区
- 实战:变量去哪了
- 五个区域的差异
- 小结
全景图:五个区域各司其职
JVM 规范把运行时内存划分为五个区域。从线程的角度看,可以分成两类:
线程私有(每个线程独占一份,线程创建时分配,线程销毁时回收):
- 程序计数器
- 虚拟机栈
- 本地方法栈
线程共享(所有线程共用,JVM 启动时创建):
- 堆
- 方法区
注意:静态变量(类字段)在 HotSpot JDK 8+ 中随 Class 对象存放在堆中,而非方法区。
程序计数器
这是五个区域里最小的一块,也是唯一不会抛 OOM 的区域。
程序计数器保存当前线程下一条需要执行的字节码地址。JVM 的执行引擎执行完一条字节码后,会从程序计数器取出下一条的行号,继续执行。
每个线程都有自己的程序计数器,互不干扰。这也是"线程私有"的含义——线程 A 执行到第 10 行,线程 B 可能执行到第 50 行,各自记录,互不影响。
可以把它理解成书签。你在一本书里读到第 30 页,夹了个书签;别人读到第 80 页,也夹了个书签。书签各自独立,标记的是各自读到哪了。
唯一需要注意的:如果当前执行的是 native 方法,程序计数器的值为空(Undefined)。因为 native 方法的执行交给操作系统了,JVM 管不着。
虚拟机栈
虚拟机栈是我们最常接触的内存区域,也是理解 Java 方法调用的关键。
每当一个方法被调用,JVM 会为这个方法创建一个栈帧(Stack Frame),压入虚拟机栈。方法执行完毕,栈帧弹出。一个线程在同一时刻只有栈顶的栈帧是活跃的——正在执行的那个方法。
栈帧里装什么
每个栈帧包含四个部分:
| 组成 | 说明 |
|---|---|
| 局部变量表 | 存方法的局部变量和方法参数。基本类型直接存值,引用类型存引用(指针) |
| 操作数栈 | 方法执行过程中的临时计算空间,比如做加法时先 push 两个数,再执行 iadd |
| 动态链接 | 指向运行时常量池中该方法的引用,支持多态 |
| 方法返回地址 | 方法执行完后回到调用者的哪一行继续执行 |
其中最重要的是局部变量表。你写的每一个局部变量,不管是int a = 10还是String s = "hello",都存在这里。基本类型存的是值本身,引用类型存的是指向堆中对象的引用。
方法调用时创建栈帧,方法结束时栈帧销毁。局部变量的生命周期和方法调用一样长——方法返回,栈帧弹出,局部变量自然就没了。
两种异常
虚拟机栈有两种出错场景:
StackOverflowError:方法调用太深,栈帧不断压入,栈空间撑爆了。最常见的原因是递归没有终止条件。
publicclassStackOverflowDemo{publicstaticvoidmain(String[]args){recurse(0);}staticvoidrecurse(intdepth){// 每调一次,压入一个栈帧,直到栈空间不够recurse(depth+1);}}// 运行结果:java.lang.StackOverflowErrorOutOfMemoryError:如果虚拟机栈支持动态扩展,扩展时内存不够了就会抛这个错。不过现在的 JVM 实现通常在创建线程时就固定了栈大小,所以更多见的是 StackOverflowError。
本地方法栈
本地方法栈和虚拟机栈几乎一样,区别在于它服务的是native 方法——用 C/C++ 写的方法,通过 JNI 调用。
比如Thread.currentThread()、Object.hashCode()这些方法底层都是 native 实现。调用这些方法时,压入的是本地方法栈,不是虚拟机栈。
在 HotSpot JVM 的实现里,本地方法栈和虚拟机栈合二为一了,不单独区分。所以面试的时候提一句"本地方法栈为 native 方法服务"就够了。
堆
堆是 JVM 中用于存放对象实例和数组的主要区域,也是 GC 主要工作的地方。
所有对象实例和数组都在堆上分配。你每次写new Object()、new int[100],对象的内存都来自堆。前面说的虚拟机栈里存的是引用,真正的对象本体在堆里。
来看一个简单的例子:
publicvoidcreate(){Objectobj=newObject();}调用create()时,发生了两件事:
- 在虚拟机栈的栈帧中,局部变量
obj存了一个引用(指针) - 在堆中,分配了一块内存,存放
Object实例
obj是引用,存在栈里;对象本体,存在堆里。方法结束后栈帧销毁,obj这个引用就没了。但对象还在堆里,直到 GC 发现它没有引用指向了,才会回收。
为什么需要 GC
C++ 里,你new了一个对象,用完之后必须自己delete,不然内存就泄漏了。Java 把这个责任交给了 GC(垃圾回收):你只管new,GC 帮你判断哪些对象没人用了,然后回收它们的内存。
GC 判断对象"没人用"的依据是可达性分析。从一组叫"GC Roots"的根节点出发(比如虚拟机栈里的局部变量、类的静态字段),沿着引用链往下找,能到达的对象就是"活的",到达不了的就是"死的",可以回收。
GC Roots │ ├── 虚拟机栈中的局部变量 │ └── obj ──→ Object实例(可达,不回收) │ └── 类的静态字段 └── list ──→ ArrayList实例(可达,不回收) └── 其中引用的对象(可达,不回收) 堆中没有任何引用指向的对象 → 不可达,GC 回收这里先关注对象为什么需要 GC,具体的垃圾回收算法和分代设计会在后面的 GC 章节展开。
方法区
方法区存储的是类的元信息:类名、字段信息、方法信息、方法的字节码、运行时常量池。
当你写了一个class User,JVM 第一次用到这个类时,会通过类加载器把.class文件读进来,在方法区中创建这个类的元数据。之后你每次new User(),堆里分配的是对象实例,但"User 这个类长什么样"的信息始终在方法区里。
类加载完成后,JVM 会保存对应的类元信息。之后无论创建多少个对象实例,这些实例共享同一份类定义。
永久代和元空间
方法区是一个逻辑概念,JVM 规范定义了它,但不同版本的 HotSpot JVM 实现方式不同:
- JDK 7 及之前:用永久代(PermGen)实现,放在 JVM 堆内存里,大小固定,容易 OOM
- JDK 8 起:改为元空间(Metaspace),使用本地内存(操作系统内存),默认不限大小
永久代的问题在于它属于 JVM 堆内存中的一块固定区域,类信息增长后容易出现空间不足。JDK 8 引入元空间后,将类元数据放到了本地内存中,减少了固定大小带来的限制。
字符串常量池也从永久代移到了堆里,这是 JDK 7 就完成的变更。
实战:变量去哪了
来看一段代码,分析每个变量在运行时存在哪个区域:
publicclassMemoryDemo{// 静态字段属于类变量// HotSpot JDK8+ 中存放在堆中的 Class 对象相关区域privatestaticintcount=0;publicstaticvoidmain(String[]args){// args 引用 → 虚拟机栈(main 方法栈帧)// args 数组对象 → 堆count++;createObject();}publicstaticObjectcreateObject(){// 局部变量 a → 虚拟机栈(createObject 方法栈帧)inta=10;// obj 引用 → 虚拟机栈// Object 实例 → 堆Objectobj=newObject();returnobj;}}逐个拆解:
| 变量 | 存储位置 | 说明 |
|---|---|---|
count | 堆(Class 对象相关区域) | 静态字段属于类变量,HotSpot JDK8+ 中随 Class 对象存放在堆中 |
args引用 | 虚拟机栈 | main 方法的局部变量 |
args数组对象 | 堆 | 通过new String[]创建 |
a | 虚拟机栈 | createObject 方法的局部变量,基本类型直接存值 |
obj引用 | 虚拟机栈 | createObject 方法的局部变量,存的是指针 |
| Object 实例 | 堆 | 通过new创建的对象 |
一个规律:基本类型的局部变量直接存在栈帧的局部变量表里,方法结束就没了。对象的引用存在栈帧里,但对象本体在堆里,方法结束后引用没了,对象等着 GC 来回收。
五个区域的差异
| 区域 | 线程归属 | 存储内容 | 是否 GC | 可能的异常 |
|---|---|---|---|---|
| 程序计数器 | 私有 | 字节码行号 | 否 | 无 |
| 虚拟机栈 | 私有 | 栈帧(局部变量、操作数栈) | 否 | StackOverflowError / OOM |
| 本地方法栈 | 私有 | native 方法栈帧 | 否 | StackOverflowError / OOM |
| 堆 | 共享 | 对象实例、数组 | 是 | OOM: Java heap space |
| 方法区 | 共享 | 类信息、常量池 | 是(类卸载) | OOM: Metaspace |
记住这张表,面试时问"堆和栈的区别"、“哪些区域会 GC”、“哪些会抛 OOM”,都能从这里找到答案。
小结
JVM 将不同生命周期的数据放入不同区域:方法调用产生的数据随着栈帧销毁,对象实例交给 GC 管理,类元信息则伴随类加载存在。
但分区只是静态的内存模型。堆里的对象创建了,什么时候算"没人用了",GC 用什么算法判断可达性,分代模型是怎么回事——这些问题,是下一篇的事了。
