深入理解 JVM 内存划分:程序计数器、元数据区、栈区、堆区全解析
写 Java 代码时,我们只管
new对象、调方法,从不关心这些数据到底"住"在哪里。其实 JVM 在背后把内存划分成了几个风格迥异的区域,各自存储不同的数据、各司其职。理解内存划分,是理解对象创建过程、垃圾回收、StackOverflowError / OutOfMemoryError 的前提。本文将逐一讲解 JVM 的四大内存区域:程序计数器、元数据区、栈区、堆区。
一、JVM 内存划分总览
JVM 启动时会向操作系统申请一大块内存,并把它划分成若干区域(从线程的角度可以分成两类):
JVM 内存 ├── 线程私有区域(每个线程各有一份) │ ├── 程序计数器 │ └── 栈区(Java 虚拟机栈,核心是栈帧) └── 线程共享区域(所有线程共用一份) ├── 堆区(对象实例的家,GC 主战场) └── 元数据区(类信息、运行时常量池等)| 区域 | 线程私有/共享 | 存什么 | 会不会 OOM |
|---|---|---|---|
| 程序计数器 | 私有 | 下一条要执行的字节码地址 | 不会 |
| 栈区 | 私有 | 栈帧(局部变量、操作数栈等) | StackOverflowError / OOM |
| 堆区 | 共享 | 对象实例、数组 | OOM |
| 元数据区 | 共享 | 类元信息、运行时常量池 | OOM(元空间溢出) |
记忆技巧:线程私有的"跟着线程走"(线程创建就有、线程销毁就没),线程共享的"跟着对象和类走"(对象放堆,类信息放元数据区)。
二、程序计数器(Program Counter Register)
2.1 作用
程序计数器是一块很小的内存,记录的是当前线程下一条要执行的字节码指令的地址。
2.2 为什么需要它?—— 因为"抢占式调度"
CPU 采用抢占式调度,线程随时可能被中断、让出 CPU,过一会儿再被调度回来。恢复执行时必须知道"我上次执行到哪一条指令了",这个断点就记在程序计数器里。
- 线程执行 Java 方法时:程序计数器记录下一条字节码指令的地址;
- 线程执行本地方法(native 方法)时:程序计数器的值为
undefined。
2.3 关键特点
- 每个线程独有一份(线程私有):如果只有一个计数器,线程 A 被切换回来时就会发现计数器已经被线程 B 改乱了,所以必须每线程一份、互不干扰;
- 唯一一块不会发生 OOM 的内存区域:它只存一个地址,大小固定,也没有 GC;
- 是程序运行的"控制流指示器":分支、循环、跳转、异常处理、线程恢复都依赖它。
三、栈区(Java 虚拟机栈)
3.1 作用
栈区描述的是Java 方法执行的内存模型:每调用一个方法,JVM 就会在当前线程的栈里压入一个栈帧(Stack Frame);方法执行完毕,栈帧出栈。
方法调用链就是栈的入栈/出栈过程:
publicstaticvoidmain(String[]args){intresult=add(1,2);// main 的栈帧压栈 → add 的栈帧压栈}publicstaticintadd(inta,intb){returna+b;// add 执行完,add 栈帧出栈,回到 main}执行 add 时: │ add 栈帧 │ ← 栈顶(正在执行的方法) │ main 栈帧 │ └───────────┘ add 结束后: │ main 栈帧 │ ← 回到 main 继续执行 └───────────┘3.2 栈帧里存什么
每个栈帧主要包含以下内容:
- 方法的参数:调用方法时传进来的实参,就存在当前方法对应的栈帧里;
- 局部变量:方法内部定义的局部变量(基本类型直接存值,对象类型存的是引用/地址,对象本体在堆里);
- 方法的返回值:方法执行结束之后的返回值,保存在栈帧中,供调用方(上一个方法)取走使用;
- 返回地址:方法执行结束之后要跳转回去的地址,即回到上一个方法的调用位置继续执行。
3.3 关键特点
- 线程私有:每个线程创建时都会分配自己的栈,方法调用天然隔离,局部变量不涉及线程安全问题;
- 两个经典异常:
- StackOverflowError:调用链太深、栈帧太多(典型:递归没写终止条件);
- OutOfMemoryError:线程开太多,栈内存总和超出限制;
- 可用
-Xss设置每个线程的栈大小(如-Xss1m)。
常见误区:对象在栈里吗?不在。局部变量表里只存对象的引用(地址),对象本体永远在堆里。所以常说"局部变量在栈上、对象在堆上"。
四、堆区(Heap)
4.1 作用
堆是 JVM 中最大的一块内存,是所有对象实例和数组的家——每一次new出来的东西都分配在堆上。
Personp=newPerson();// p(引用)在栈上,new Person()(对象本体)在堆上int[]arr=newint[10];// 数组也在堆上4.2 堆的结构:分代设计
堆内部按照对象的"年龄"划分为不同区域,这是为了配合垃圾回收:
堆区 ├── 新生代(Young):新 new 出来的对象 │ ├── Eden(伊甸园区):对象出生地 │ └── Survivor × 2(幸存者区 S0 / S1):熬过一次 Minor GC 的对象 └── 老年代(Old):长期存活的对象、大对象- 对象优先在Eden区分配;
- Eden 满了触发Minor GC,存活对象被复制到 Survivor 区,反复"打扫"几次还活着的对象晋升到老年代;
- 老年代满了触发Full GC(代价更大)。
为什么要分代?基于"弱分代假说"——绝大多数对象都是"朝生夕死"的,把新对象集中在一个小区域频繁回收,比整堆扫描高效得多。
4.3 关键特点
- 线程共享:所有线程都能访问堆中的对象——堆正是线程安全问题的"事发地"(多个线程的栈上都持有指向同一个堆对象的引用);
- GC 的主战场:垃圾回收主要发生在堆上,Eden/Survivor/老年代的划分都是为了更高效地 GC;
- 会发生 OOM:对象太多、内存泄漏(该回收的对象还被引用着)都会导致
java.lang.OutOfMemoryError: Java heap space; - 可用
-Xms(初始大小)、-Xmx(最大大小)设置堆大小。
补充(了解):JDK 8 之后,如果开启了逃逸分析,个别不会"逃出"方法的对象可能通过栈上分配/标量替换直接在栈上创建,但这是 JVM 的优化手段,语义上对象仍然"属于"堆。
五、元数据区(Metaspace,即方法区)
5.1 作用
元数据区(JDK 8 之前叫方法区/永久代)存储的是类的元信息——即"类本身的数据",具体包括:
- 类对象信息:类的全限定名、父类、实现的接口、访问修饰符等类的描述信息;
- 方法信息:每个方法的字节码指令、方法签名;
- 运行时常量池:字符串常量、final 常量、符号引用等(如
"hello"这类字面量); - 静态变量:
static修饰的类变量,属于类而不属于对象,所有实例共享一份。
打个比方:堆里存的是"人"(对象实例),元数据区存的是"户口模板/图纸"(类模板)。一个类只有一份"图纸",但可以按图纸 new 出无数个对象。
classPerson{// Person 类的元信息 → 元数据区staticintcount=0;// 静态变量(JDK 8+ 实际在堆中的 Class 对象里)Stringname;// 每个对象各自的 name → 堆中的对象实例里}Personp1=newPerson();// p1 指向的实例 → 堆Personp2=newPerson();// p2 指向的实例 → 堆5.2 关键特点
- 线程共享:类信息所有线程共用;
- 会发生 OOM:大量动态生成类(如 CGLIB 动态代理、大量 JSP)可能导致
Metaspace溢出; - GC 行为:元数据区也会回收——当类加载器被回收,且该类的实例全部销毁后,对应的类信息才可能被卸载,条件非常苛刻。
六、四大区域对比与记忆总结
6.1 对比表
| 维度 | 程序计数器 | 栈区 | 堆区 | 元数据区 |
|---|---|---|---|---|
| 线程私有/共享 | 私有 | 私有 | 共享 | 共享 |
| 存储内容 | 下一条字节码地址 | 栈帧(局部变量表、操作数栈等) | 对象实例、数组 | 类元信息、运行时常量池 |
| 生命周期 | 随线程 | 随方法调用(栈帧入栈出栈) | 随对象(GC 决定) | 随类(类卸载才回收) |
| 是否 GC | 不涉及 | 不涉及(栈帧自动弹出) | 是(主战场) | 条件苛刻的类卸载 |
| 异常 | 无 | StackOverflowError / OOM | OOM | OOM |
6.2 用一段代码串起整个内存
publicclassMemoryDemo{staticintsharedCount=0;// 静态变量 → 堆中的 Class 对象publicstaticvoidmain(String[]args){intlocal=10;// 局部变量 → main 栈帧的局部变量表(栈区)Personp=newPerson("张三");// p 引用 → 栈;Person 实例和 "张三" → 堆MemoryDemo.class;// 类元信息 → 元数据区process(local);// 调 process:process 栈帧压栈}publicstaticvoidprocess(intx){// 方法字节码 → 元数据区x=x+1;// 执行过程由程序计数器逐条指引}// 方法结束:栈帧出栈}6.3 一句话记忆
- 程序计数器:每个线程的"进度条",记录下一条执行到哪(唯一不 OOM 的区域);
- 栈区:每个线程的"方法调用记录本",一个方法一个栈帧,局部变量住这里,栈上存引用;
- 堆区:所有线程共用的"对象仓库",
new出来的都住这里,分代设计为 GC 服务; - 元数据区:所有线程共用的"类图纸仓库",存类信息、常量池,JDK 8 起搬到堆外本地内存。
理解了内存划分,很多问题会豁然开朗:为什么局部变量不用考虑线程安全(栈私有)?为什么共享对象才有并发问题(堆共享)?为什么
static变量所有线程看到的是同一个(跟着类走)?为什么递归深了会 StackOverflowError(栈帧堆满了)?——答案都在这四个区域里。希望这篇文章对你有帮助,欢迎点赞收藏!