☰
【Java JVM】栈、堆、方法区、元空间到底什么关系?一张图 + 三次实测讲清运行时内存区域
2026/10/15 5:34:05 网站建设 项目流程

文章目录

    • 一、先看全景:一块图 + 一张表
    • 二、最容易混的两对概念
      • 2.1 方法区 ≠ 元空间,但 JDK 8 之后你可以这么理解
      • 2.2 栈和堆:谁放什么,一句话分清
    • 三、三个本机实测实验
      • 3.1 实验一:栈溢出,以及 `-Xss` 到底调了什么
      • 3.2 实验二:堆溢出,`-Xmx` 是硬上限
      • 3.3 实验三:`intern()` 与字符串常量池,一个经典陷阱
    • 四、常用参数与排查命令速查
      • 4.1 参数速查表
      • 4.2 排查命令速查表
    • 五、面试常见问法,以及怎么答才不像背的
    • 六、小结

JVM 内存区域是 Java 面试的常客,但它不该只是一道背答题。你以后排查OutOfMemoryError、调-Xmx、看 jmap 输出的时候,脑子里如果没有这张图,基本是抓瞎。

这篇不打算把八股文再抄一遍。分三步:先把整张地图画出来(一张表说清谁私有谁共享),再把最容易混的两对概念掰开——方法区和元空间、堆和栈;最后是三个在本机真实跑出来的实验(栈溢出、堆溢出、intern()陷阱),每个都有可复制的代码和实际输出。

环境说明:本文实验在JDK 8 和 JDK 21 上各跑了一遍,结论一致的地方会标出来,有差异的地方单独说。


一、先看全景:一块图 + 一张表

运行时数据区一共五块,按「线程是否共享」划分是记忆的关键:

区域线程私有/共享存什么会不会溢出
程序计数器私有当前线程执行到的字节码行号不会(唯一没有 OOM 的区域)
虚拟机栈私有栈帧:局部变量表、操作数栈、方法出口StackOverflowError/ OOM
本地方法栈私有native 方法的调用状态StackOverflowError/ OOM
堆共享对象实例、数组OOM
方法区(JDK 8 起为元空间)共享类元信息、运行时常量池、静态变量OOM

一张文字版的关系图:

线程 1 线程 2 (所有线程共享) ┌─────────────┐ ┌─────────────┐ ┌──────────────────┐ │ 程序计数器 │ │ 程序计数器 │ │ 堆 │ ├─────────────┤ ├─────────────┤ │ 对象实例 / 数组 │ │ 虚拟机栈 │ │ 虚拟机栈 │ ├──────────────────┤ │ └ 栈帧(...) │ │ └ 栈帧(...) │ │ 方法区 / 元空间 │ ├─────────────┤ ├─────────────┤ │ 类信息 / 常量池 │ │ 本地方法栈 │ │ 本地方法栈 │ └──────────────────┘ └─────────────┘ └─────────────┘

记忆方法就一句:线程私有的跟着线程生、跟着线程死,不需要垃圾回收;共享的两块(堆和方法区)才是 GC 的主战场。


二、最容易混的两对概念

2.1 方法区 ≠ 元空间,但 JDK 8 之后你可以这么理解

方法区是《Java 虚拟机规范》里的逻辑概念,元空间(Metaspace)是 HotSpot 在 JDK 8 之后对它的实现。

演变过程值得过一遍,因为面试和排查老项目都会用到:

版本方法区的实现关键变化
JDK 6 及以前永久代(PermGen)类信息、字符串常量池、静态变量都在永久代
JDK 7永久代字符串常量池、静态变量移到堆;永久代仍存在
JDK 8元空间(Metaspace)永久代被彻底移除;类元信息移到本地内存(Native Memory),受-XX:MaxMetaspaceSize限制

为什么删永久代?因为它的大小固定(-XX:MaxPermSize),动态生成类的场景(Spring 代理、反射、Groovy 脚本)一多就OutOfMemoryError: PermGen space,而且 GC 效果差。元空间用本地内存,默认不设上限,跟着机器走,这类 OOM 大幅减少。

注意:字符串常量池在 JDK 7 就进了堆,不在元空间。很多老文章把它画在方法区里,那是 JDK 6 的事。

2.2 栈和堆:谁放什么,一句话分清

  • 栈:方法的局部变量(基本类型直接存值,引用类型存「地址」)、方法的调用现场。方法结束,栈帧弹出,里面的东西直接没了——不需要 GC。
  • 堆:所有new出来的东西。栈里的引用指向堆里的对象。

看这段:

publicvoiddemo(){inta=10;// a 本身就在栈帧的局部变量表里,值就是 10Useru=newUser();// u(引用)在栈,User 对象本体在堆}// 方法结束:栈帧弹出,u 消失;堆上的对象等 GC

u是个引用不是对象本体,这个区分做对了,后面讲==和equals、讲参数传递才不会绕进去。


三、三个本机实测实验

以下三个实验的输出都是我在这台机器上跑出来的真实结果,你可以直接复制代码得到类似(但数值不一定相同)的输出。

3.1 实验一:栈溢出,以及-Xss到底调了什么

publicclassStackDemo{staticintdepth=0;staticvoidrecurse(){depth++;recurse();// 无出口的递归}publicstaticvoidmain(String[]args){try{recurse();}catch(StackOverflowErrore){System.out.println("StackOverflowError at depth="+depth);}}}

编译运行(把栈上限压到 256k,让溢出来得快一点):

javac StackDemo.javajava-Xss256kStackDemo# 本机 JDK 8 输出:StackOverflowError at depth=3563

三个要点:

  1. -Xss设置的是每个线程的栈大小,不是整个 JVM 的。开 1000 个线程,就是 1000 份。
  2. 栈越深(方法调用链越长)或者局部变量越多,同样的-Xss能撑的深度越小。你把recurse()里加几个大数组再试,depth 会明显变小。
  3. 栈不需要 GC,但栈不是越大越好。栈越大,同样的内存能开的线程数越少——高并发场景把-Xss调成 256k 甚至 128k 反而是常规操作。

StackOverflowError在真实项目里九成来自递归没有出口或者循环依赖的 toString/equals。看到这个异常,直接看堆栈最顶上那行重复出现的类名,基本就是它。

3.2 实验二:堆溢出,-Xmx是硬上限

importjava.util.ArrayList;importjava.util.List;publicclassHeapDemo{publicstaticvoidmain(String[]args){List<byte[]>list=newArrayList<>();intcount=0;try{while(true){list.add(newbyte[1024*1024]);// 每次追加 1MB,且一直持有引用count++;}}catch(OutOfMemoryErrore){System.out.println("OutOfMemoryError: "+e.getMessage()+" | allocated MB ~ "+count);}}}
java-Xmx32mHeapDemo# 本机 JDK 8 输出:OutOfMemoryError: Java heap space | allocated MB ~ 29

-Xmx32m只给了 32MB,最终只塞进去约 29MB——剩下的被对象头、对齐和 GC 预留吃掉了。同样的代码在 JDK 21 上只塞进约 15MB(G1 的分区机制预留更多),所以别拿这个数衡量性能,它只说明一件事:-Xmx是硬上限,堆真会满。

这个实验也顺带说明了 OOM 的标准成因:对象一直被持有(list活着),GC 回收不掉,新对象又源源不断。真实项目里的堆溢出几乎全是这个模式——比如一个静态Map只往里 put 不 remove。

3.3 实验三:intern()与字符串常量池,一个经典陷阱

publicclassInternDemo{publicstaticvoidmain(String[]args){Strings1=newStringBuilder("计算机").append("技术").toString();System.out.println("case1 (池中原本没有): "+(s1.intern()==s1));Strings2=newStringBuilder("ja").append("va").toString();System.out.println("case2 (java 启动时已在池中): "+(s2.intern()==s2));Strings3="hello";Strings4=newStringBuilder("hel").append("lo").toString();System.out.println("case3 (hello 字面量已入池): "+(s4.intern()==s4));}}

本机 JDK 8 与 JDK 21 输出完全一致:

case1 (池中原本没有): true case2 (java 启动时已在池中): false case3 (hello 字面量已入池): false

逐个解释:

  • case1 是 true:运行时拼出来的"计算机技术",常量池里原本没有。JDK 7 起intern()会把堆里这个对象的引用登记进常量池,所以s1.intern()返回的就是s1自己。JDK 6 不是这样——永久代里会复制一份副本,返回的是副本的引用,所以 JDK 6 上这行是false。这是 JDK 6/7 的经典行为差异。
  • case2 是 false:"java"这个字符串在 JVM 启动初始化时就进池了(版本号等系统信息用到它),intern()返回的是池里那份,不是s2。
  • case3 是 false:源代码里出现了"hello"字面量,类加载阶段它就进池了。s4是运行时新建的另一个对象,intern()自然返回池里那份。

这个实验想让你记住的就一条:==比较的是引用(是不是同一个对象),intern()是去常量池里找登记的那份。面试爱考,业务代码里则应当无脑用equals。


四、常用参数与排查命令速查

4.1 参数速查表

参数作用典型用法
-Xms/-Xmx堆初始 / 最大大小生产环境两者设成一样,避免动态扩容抖动
-Xss每线程栈大小高并发线程多的服务调小(如 256k)
-XX:MaxMetaspaceSize元空间上限不设的话默认吃本地内存,动态类多的服务建议设
-XX:+HeapDumpOnOutOfMemoryErrorOOM 时自动导堆转储生产必开,配合-XX:HeapDumpPath=/your/path
-XX:SurvivorRatio/-XX:NewRatio新生代比例一般不动,先看 GC 日志再调

4.2 排查命令速查表

命令干什么示例
jps -l列出 Java 进程jps -l
jmap -heap <pid>查看堆配置与使用JDK 9+ 换成jcmd <pid> GC.heap_info
jmap -histo <pid>按实例数列出对象分布排 OOM 第一步:看谁占的对象最多
jmap -dump:live,format=b,file=heap.hprof <pid>手动导堆转储用 MAT / VisualVM 分析
jstat -gc <pid> 1000每秒刷一次 GC 统计看 YGC/FGC 次数和各代占用
jstack <pid>导线程栈排 CPU 飙高、死锁

Windows 用户注意:jmap -heap、jstack在某些版本上对本地进程可能报Unable to open socket file,用管理员身份运行同一 JDK 的工具、或改用jcmd即可。Linux 服务器上则记得用和应用同一个 JDK的工具,跨版本容易连不上。


五、面试常见问法,以及怎么答才不像背的

「栈和堆的区别?」别只说「栈存局部变量堆存对象」。加一句:栈帧随方法出入自动弹压、不需要 GC;堆是 GC 主战场,所有线程共享所以要考虑并发。这一句能把区别从「位置」讲到「生命周期」,比背定义高一档。

「方法区和元空间什么关系?」答「一个是规范里的逻辑概念,一个是 HotSpot JDK 8 起的实现(用本地内存,替代永久代)」,再补一句「字符串常量池 JDK 7 就进堆了」——这句是区分背书和理解的分水岭。

「OutOfMemoryError 有哪几种?」按区域报:堆(Java heap space)、元空间(Metaspace,JDK 8 前是PermGen space)、栈(StackOverflowError,严格说是栈溢出不是 OOM)、直接内存(Direct buffer memory)。每种给一个成因场景,比罗列名词强。


六、小结

五块区域、两条主线:

  1. 线程私有三块(程序计数器、虚拟机栈、本地方法栈)跟线程生死,不用 GC;共享两块(堆、方法区/元空间)是 GC 和 OOM 的主战场。
  2. 方法区是概念,元空间是 JDK 8 起的实现;字符串常量池 JDK 7 起在堆里。
  3. 排查 OOM 的路径是固定的:-XX:+HeapDumpOnOutOfMemoryError留证据 →jmap -histo或 MAT 找大头 → 回代码找「被持有的引用」。

内存区域这张图不是拿来背的,是拿来在你下一次面对OutOfMemoryError时,知道该往哪个区域想、该拿哪把工具。


需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询