文章目录
- 一、先看全景:一块图 + 一张表
- 二、最容易混的两对概念
- 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 消失;堆上的对象等 GCu是个引用不是对象本体,这个区分做对了,后面讲==和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三个要点:
-Xss设置的是每个线程的栈大小,不是整个 JVM 的。开 1000 个线程,就是 1000 份。- 栈越深(方法调用链越长)或者局部变量越多,同样的
-Xss能撑的深度越小。你把recurse()里加几个大数组再试,depth 会明显变小。 - 栈不需要 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:+HeapDumpOnOutOfMemoryError | OOM 时自动导堆转储 | 生产必开,配合-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)。每种给一个成因场景,比罗列名词强。
六、小结
五块区域、两条主线:
- 线程私有三块(程序计数器、虚拟机栈、本地方法栈)跟线程生死,不用 GC;共享两块(堆、方法区/元空间)是 GC 和 OOM 的主战场。
- 方法区是概念,元空间是 JDK 8 起的实现;字符串常量池 JDK 7 起在堆里。
- 排查 OOM 的路径是固定的:
-XX:+HeapDumpOnOutOfMemoryError留证据 →jmap -histo或 MAT 找大头 → 回代码找「被持有的引用」。
内存区域这张图不是拿来背的,是拿来在你下一次面对OutOfMemoryError时,知道该往哪个区域想、该拿哪把工具。