1. 从一个面试题说起:JVM内存结构到底是什么
Java开发者但凡去面试,十有八九会被问到JVM内存结构。很多人背了答案,知道有堆、虚拟机栈、方法区这些名词,但真到排查问题的时候,这些知识就派不上用场了。我见过太多同事,用jmap一看堆内存爆了,第一反应是“加内存参数”,结果加了之后照样OOM,问题一点没解决。
原因很简单:你只是知道了名字,不知道它们各自负责什么、怎么协作、出了问题去哪里查。
JVM内存结构,通俗说就是Java程序运行的时候,内存这块地盘是怎么划分的、每块地盘上都在干什么活。搞懂它,你才能真正看懂那些内存溢出的报错日志,才能在写代码的时候有意识避开高风险写法,才能在做调优的时候知道该调哪个参数而不是瞎调。
这东西适合谁学?说实话,只要你写Java,都应该掌握。刚入门的人可以建立完整的内存认知框架,工作两三年的人可以把零散经验串起来,准备面试的人则可以把它和GC、类加载这些知识点连成体系。这篇笔记我尽量用大白话讲清楚每个区域的作用,同时附上可运行的验证代码和真实场景的排查思路,希望你看完能对JVM内存有个立体的认识。
2. 整体设计与思路拆解:运行时数据区到底分了几块
先搞清楚一个前提:JVM内存结构,规范名称是“运行时数据区”(Runtime Data Area)。它和“Java内存模型”(JMM)不是一回事,JMM说的是多线程场景下共享变量的可见性、原子性、有序性规则,而运行时数据区是JVM实际运行时内存区域的物理划分。很多人把这两者混着聊,这是面试大忌。
根据《Java虚拟机规范》,运行时数据区大致分为以下几块:
| 区域名称 | 线程共享 | 异常类型 | 主要用途 |
|---|---|---|---|
| 堆(Heap) | 是 | OutOfMemoryError | 存放对象实例和数组,GC的主要战场 |
| 方法区(Method Area) | 是 | OutOfMemoryError | 存放类元信息、常量、静态变量、即时编译后的代码 |
| 虚拟机栈(VM Stack) | 否 | StackOverflowError / OutOfMemoryError | 每个线程运行时的栈帧,存放局部变量表、操作数栈等 |
| 本地方法栈(Native Method Stack) | 否 | StackOverflowError / OutOfMemoryError | 为native方法服务 |
| 程序计数器(PC Register) | 否 | 无 | 记录当前线程执行的字节码行号 |
这里有个容易忽略的点:程序计数器是唯一不会抛出OOM的区域。因为每个线程的程序计数器只占一小块内存,且随线程创建而创建、线程销毁而释放,不会参与垃圾回收,空间极小,规范规定它不需要考虑内存不足的情况。
有一个地方需要特别说明,就是方法区的具体实现形态。
在JDK 7及之前,方法区是堆中的“永久代”(PermGen),用JVM参数-XX:MaxPermSize控制上限。到了JDK 8,方法区被移出堆,改成“元空间”(Metaspace),使用本地内存,默认情况下只受物理内存总量限制。这个调整的根本原因是:永久代在堆内的大小很难精确控制,类加载器如果频繁卸载/重载(比如应用热部署),就很容易把永久代占满,引发java.lang.OutOfMemoryError: PermGen space。改成元空间走本地内存后,出问题的概率降低不少,但代价是如果代码疯狂的生成类,元空间也可能把机器内存吃光。
刚开始学这块,我的建议是先记住一个核心边界:堆负责存对象数据,栈负责执行方法调用,方法区负责存放类的结构信息。三个角色各有各的活,搞清边界再往下看细节,思路会越理越顺。
3. 核心细节解析与实操要点
3.1 堆:JVM内存的大头,所有对象的老家
堆是JVM管理的最大一块内存,几乎所有的对象实例和数组都在这里分配。我们平时执行new关键字创建对象,多数情况下对象就会落在堆上(注意,是多数情况,因为JIT编译器在开启逃逸分析后可能有栈上分配等优化,这个后面再聊Java对象的一生)。
堆在物理上被分成两块区域:年轻代(Young Generation)和老年代(Old Generation)。年轻代又细分为Eden区和两个Survivor区(常说的S0、S1或者From、To区)。默认的Eden和Survivor大小比例通常是8:1:1,但这不代表所有对象都会严格按照这个比例待着。
我在实际排查问题时有个体会:很多开发人员没搞清楚一个东西——堆的大小和堆内部各代的布局是两回事。你设置-Xms和-Xmx控制的是堆的总容量,但年轻代大小、晋升阈值、幸存区比例这些都是可以单独调的。如果只看堆总大小,不看代际结构,很多"明明堆还剩很多,却频繁Full GC"的问题根本找不到原因。
举例来说,如果系统中存在大量朝生夕灭的对象(比如高并发下临时创建的请求对象),那么年轻代会频繁触发Minor GC;如果代码里有大量生命周期超长的对象被引用,对象会逐渐晋升到老年代,最终老年代满了就会触发Major GC/Full GC,而Full GC通常是STW(Stop The World,暂停所有业务线程)的,停顿时间一长,接口RT立马飙升。
堆的几个关键参数:
-Xms:堆初始大小,比如-Xms512m-Xmx:堆最大大小,比如-Xmx4g-Xmn:年轻代大小-XX:SurvivorRatio:Eden和单个Survivor区的比例,默认为8,即 Eden:S0:S1 = 8:1:1-XX:MaxTenuringThreshold:对象晋升老年代的年龄阈值(经过Minor GC的次数)-XX:PretenureSizeThreshold:大对象直接进老年代的字节数阈值
一个常见误区是把-Xms和-Xmx设置成不同大小。生产环境一般建议直接设成相同的值,避免运行期因堆扩容缩容带来的性能抖动。因为JVM的堆扩容不是瞬间完成的,扩容过程可能触发Full GC,这个停顿很伤服务。
3.2 虚拟机栈:方法的调用,都在这里一步步走
JVM栈是线程私有的,它的生命周期与线程相同。每调用一个方法,JVM就会在栈中压入一个栈帧(Stack Frame)。一个栈帧包含四个核心东西:
- 局部变量表(Local Variables)
- 操作数栈(Operand Stack)
- 动态链接(Dynamic Linking)或者说指向运行时常量池的引用
- 方法返回地址(Return Address)
你在方法里声明的基本类型变量、对象引用,都在局部变量表里存放;算数运算的中间过程、传参操作,则在操作数栈里进行。可以这样理解:局部变量表是方法的"草稿纸",操作数栈是计算用的"临时演算区"。
每个线程的栈容量,默认值跟平台有关,比如Linux x86_64上默认大约是1MB。可以通过-Xss参数调整。
当栈的深度超过JVM允许的深度时,就会抛StackOverflowError。最常见的触发场景就是递归调用没有正确的终止条件。我还见过一种不太容易发现的情况:死循环也会导致栈溢出?不会。死循环如果一直在方法内部转,不产生新的栈帧,栈深度不变,是不会溢出的。但如果是自己在代码里写了一个无限递归,那就必炸。
另外,如果启动线程时开出的栈空间总和超过了系统可用内存,会抛OutOfMemoryError。这个在高并发场景下很容易踩坑:你创建了太多线程,每个线程占1MB栈,线程数一大,内存就没了。
关于-Xss设置,我的建议是:别盲目调大。栈空间调大会降低能创建的线程总数,而调小虽然能容纳更多线程,但递归深度一旦增大就容易栈溢出。一般默认值够用,非必要不动它。
3.3 方法区与元空间:存放类的“档案室”
方法区存储的是已被JVM加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。信息量很大,但它不像堆那样被垃圾回收重点关注。GC在方法区(元空间)确实存在,但回收条件极其苛刻,主要是针对废弃常量和无用的类,且回收效果往往不如堆。
JDK 8之后的元空间,一个关键特征是在本地内存中分配。默认情况下元空间上限是无限制的(只受物理内存约束),如果你写的代码在运行时大量生成类(比如某些动态代理框架、字节码增强框架、JSP热部署),就可能导致元空间不断膨胀,把机器内存吃光。
几个和元空间相关的参数:
-XX:MetaspaceSize:元空间初始大小,并不是设置的上限,而是触发GC的阈值参考-XX:MaxMetaspaceSize:元空间最大大小,不设默认无限-XX:MinMetaspaceFreeRatio:GC后元空间空闲空间的最小比例,用于控制是否要缩容
我见过一个典型的线上事故:一个用CGLIB生成大量代理类的应用,没有设置MaxMetaspaceSize,运行一段时间后元空间疯狂增大,最后直接导致宿主机内存告警。所以,即使默认无限,生产环境也建议把MaxMetaspaceSize显式设置一个合理值,防止极端情况把机器拖垮。
需要说明的是,字符串常量池在JDK 7之后其实已经移到了堆中。这个变化影响很大——如果你用String.intern(),它操作的对象就在堆上,如果用的是JDK 6及以前版本,它操作的是永久代。实际开发中我建议少用intern(),除非你有极其明确的使用场景,比如大量重复的字符串值得去重,否则它可能引入额外的GC压力和内存占用。
3.4 程序计数器和本地方法栈:容易被忽略的小角色
程序计数器(PC Register)是每个线程私有的,它保存当前线程正在执行的字节码指令的地址。如果正在执行的是一个Java方法,PC记录的是正在执行的虚拟机字节码指令地址;如果是native方法,PC的值为空(undefined)。这个区域是唯一在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域,一般也不用我们去配置什么。
本地方法栈(Native Method Stack)和虚拟机栈类似,也是线程私有,不过它是为JVM执行native方法服务的。HotSpot虚拟机把本地方法栈和虚拟机栈合并在一起了,所以你可以把它理解成同一个栈上既跑Java方法也用同样区域执行native方法。平时开发中直接打交道的机会不多,但在排查某些本地I/O、加密相关库问题的时候,会看到native方法栈溢出的报错,这个时候可以优先排查是不是有C/C++层面的递归或无界调用。
3.5 一个帮助记忆的例子
我在给团队新人讲这块内容的时候,喜欢用一个餐厅的例子。堆就像后厨的食材仓库,所有需要用到的菜品原材料(对象实例)都在仓库里,仓库空间有限,需要定期清理过期食材(垃圾回收)。虚拟机栈就像厨师手里的工作台和一个只允许从上往下压的一摞盘子,每做一道菜(调用方法),就放一个空盘子上来(压栈);每完成一道菜,就端走一个盘子(弹栈)。盘子压得太多太高,整个摞子就倒了(栈溢出)。方法区则是餐厅的菜谱库,记录着每道菜的做法、食材清单和调味品比例(类的结构信息、方法字节码、常量),不管做多少次菜都能照着来。程序计数器就是每道菜做到哪一步的记录小纸条,厨师做到哪一行了,看一眼纸条就知道。
这样一联想,区域之间的职责和协作关系就清晰多了。
4. 实操过程与核心环节实现
4.1 用代码验证:对象到底放在哪个区
理论讲再多,不如跑个实验。这里我写几个小例子,你可以直接复制到本地跑一跑,感受每个区域抛异常的样子。
先来看堆溢出的场景:
public class HeapOOMDemo { public static void main(String[] args) { List<byte[]> list = new ArrayList<>(); while (true) { list.add(new byte[1 * 1024 * 1024]); // 每次分配1MB } } }运行时加上JVM参数:
-Xms64m -Xmx64m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/heap.hprof这里我解释一下为什么堆大小要设成64m,而不是直接设成默认大小。因为小堆能快速复现问题,而且-XX:+HeapDumpOnOutOfMemoryError会让JVM在堆溢出时自动导出堆转储快照,方便我们用分析工具来看。跑完你会看到类似这样的报错:
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space看到Java heap space就说明是堆内存不够了。这时候,如果导出了heap.hprof文件,你可以用VisualVM或者Eclipse MAT打开,找到底是哪块代码在持续创建大对象,定位到具体的调用栈,比盲猜强得多。
再来看虚拟机栈溢出的场景:
public class StackOverflowDemo { public static void main(String[] args) { recurse(0); } private static void recurse(int depth) { System.out.println("当前递归深度: " + depth); recurse(depth + 1); } }跑完一般会看到StackOverflowError,并且调用栈能直接看出来递归没有终止条件。这里你还可以尝试用-Xss128k把栈调小,会发现递归深度变得更浅就溢出了;用-Xss2m调大,深度就会变大。这个实验很有意思,能直观感受栈容量对调用深度的影响。
4.2 元空间验证:运行时生成类到底多恐怖
元空间溢出的复现要借助字节码生成框架。一个常见的做法是用CGLIB或者ASM在运行时不断生成新类。我用Spring的CGLIB来演示:
public class MetaspaceOOMDemo { public static void main(String[] args) { while (true) { Enhancer enhancer = new Enhancer(); enhancer.setSuperclass(TestBean.class); enhancer.setUseCache(false); enhancer.setCallback((MethodInterceptor) (obj, method, args1, proxy) -> proxy.invokeSuper(obj, args1)); enhancer.create(); } } static class TestBean {} }运行时加参数:
-XX:MetaspaceSize=16m -XX:MaxMetaspaceSize=64m跑一会儿就会看到java.lang.OutOfMemoryError: Metaspace。
这个例子在现实中的映射是什么?很多应用框架内部反射、代理用得特别多,一旦走了极端(比如频繁热部署、动态构造代理类),元空间就可能扛不住。建议你在自己的项目里查一下有没有设置元空间上限,如果没有,至少心里有个数:极端情况下它最多能涨到多少。
4.3 用JVM自带工具观察各区域内存分布
验证完异常,再来看怎么在正常运行中观察各区内存。JDK自带的jcmd、jstat、jmap是免费的诊断利器。
比如我想看JVM进程的内存概况:
jcmd <pid> GC.heap_info输出里能看到garbage-first heap、region size、total heap、used heap等关键信息。每次GC的统计数据可以用:
jstat -gc <pid> 1000这条命令每1秒打印一次GC情况,其中S0C、S1C、EC、OC、MC分表对应S0区、S1区、Eden区、老年代、元空间的容量,YGC、FGC是年轻代和Full GC的次数,YGCT、FGCT是各自耗时。在压力测试的时候开着这个,能非常直观地看到对象是怎么在各代际之间流动的。
如果你想看堆里到底存了什么类型的对象:
jmap -histo:live <pid> | head -50这个命令会触发一次Full GC(注意,线上操作要谨慎),然后按对象实例数量和占用大小排序,帮你快速定位到底是谁在堆里霸占地盘。命令行模式下live这个参数要慎用,最好在流量低峰期操作。
4.4 线程栈与死锁排查:jstack的妙用
内存结构不只是看堆,线程栈也很重要。你想看当前每个线程在干什么、栈里有哪些调用,用jstack:
jstack <pid>有一次我们线上服务出现某个线程CPU跑满,我拿到线程号printf '%x\n' <线程id>转成十六进制,然后在jstack输出里搜这个十六进制,瞬间定位到是某个图片压缩库的死循环。整个过程不到两分钟,比在代码里盲猜快太多了。
这里顺便说一个思维习惯:排错时先想“内存分区”,再想“工具”。堆的问题用jmap、jcmd;栈的问题用jstack;GC频率问题用jstat;综合性能排查用VisualVM或Arthas。每种工具对应一类分区,选对工具,问题就解决了一半。
4.5 JVM参数设置的最佳实践参考
我把自己常用的JVM参数模板整理出来了,供你参考(核心是思路,具体数值看场景调整):
-Xms4g -Xmx4g -Xmn2g -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=15 -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/logs/jvm/heap.hprof -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/data/logs/jvm/gc-%t.log几个关键点:
- 堆初始化大小和最大值保持一致,避免扩容抖动
- GC日志务必打开,这是事后排查的第一手资料,很多问题不看GC日志根本没方向
- 堆转储文件一定要开,OOM时不导dump,等于白送了个破案机会
- 元空间上限建议设置,宁愿报OOM让你知道出问题了,也不想让机器被拖死
5. 常见问题与排查技巧实录
5.1 堆溢出但代码审查找不到大对象
这种情况太常见了。你review代码,都是一些常规CRUD,居然OOM了。
我有一年排查过一个触目惊心的案例:某个查询接口,接收前端传入的ID列表,代码写的是WHERE id IN (...),结果前端能传入上万个ID,后端把上万个ID拼成一个SQL去查,返回的结果集巨大,直接把年轻代打满,对象还没走完Minor GC就被灌进老年代,最终触发Full GC连环爆。
排查手段很简单:加上-XX:+HeapDumpOnOutOfMemoryError,OOM后分析dump文件,看对象类型占比。如果发现char[]、byte[]占比极高,检查一下是不是SQL查询返回了过多数据或者字符串拼接过于粗暴。这类问题,不看堆dump,基本靠猜。
5.2 栈溢出到底是谁的锅
栈溢出排查有一个典型的坑:你以为是谁调用太深,其实是某个方法的局部变量太大。
Java规范里,如果栈帧中的局部变量表、操作数栈占用的容量太大,那么能压的栈帧数量就会减少,一个方法就可能直接让栈溢出,不需要递归。比如一个方法里声明了一个byte[] buffer = new byte[10 * 1024 * 1024](这里补充一下,栈上分配的常见情况很少直接手动声明这么大的数组),但如果你在递归方法里每层都声明了较大的局部变量,那栈溢出的时间会大幅提前。排查时除了看调用深度,也想想局部变量的体积。
另外还有一种情况:死循环内创建新线程,每个线程有自己的栈,线程数量一多,反而先栈内存不足。线上报unable to create new native thread,别只盯着业务代码,先看线程池有没有正确回收空闲线程,再看系统层ulimit -u是否限制了线程数。
5.3 元空间莫名增长,排查方向怎么选
元空间增长在没有设置MaxMetaspaceSize的情况下是最隐蔽的。因为平时GC日志里根本不会显著体现它,你只能用jstat -gc <pid>里的MC(元空间容量)和MU(元空间使用量)来观察。
如果发现元空间持续增长,优先查这几类场景:
- 热部署:每次重新部署Web应用都会重新加载类
- CGLIB/动态代理:运行期频繁创建代理类
- 反射/ASM:代码里动态生成class文件
- Groovy等动态语言:脚本每次执行都会产生新类
对应措施:生产环境设置MaxMetaspaceSize做兜底;排查类加载器是否被重复创建;确认缓存代理类的逻辑是否正确。
5.4 一个容易忽略的细节:直接内存
有时候OOM的报错信息和上面说的几个区域都对不上,什么Direct buffer memory。这是NIO直接内存(DirectByteBuffer)用多了。直接内存不属于堆,但受-XX:MaxDirectMemorySize限制,默认等于堆的最大值,在JVM规范中不归属于运行时数据区的这几个部分,但是实际使用中它确实会占用系统的物理内存。
想想看:如果你的应用大量使用Netty或者其他NIO框架,申请了堆外内存做IO缓冲,堆是没满,但Direct Memory满了,照样OOM,而且报错信息是OutOfMemoryError: Direct buffer memory。排查思路:用jcmd <pid> VM.native_memory去看native内存使用,或者检查是否有未释放的ByteBuffer。这里要特别留意,bytebuffer申请的是堆外内存,不受Xmx控制,GC时回收也未必及时。
5.5 排查问题,一个顺手的组合拳
我把这些年排内存问题最常用的组合拳总结一下,遇到问题可以照着走:
- 先看GC日志,判断究竟是堆、栈、元空间还是直接内存的问题
- 用
jstat -gc看当前各区域占用和GC频率 - 用
jmap -histo或者导出heap dump,看对象分布 - 用
jstack看线程栈,确认业务逻辑是否卡在异常路径上 - 用
jcmd <pid> VM.native_memory summary看native内存占用(注意需要开启NativeMemoryTracking)
组合拳的好处是快:不用从头到尾打一遍,直接根据报错信息跳到对应环节。
5.6 实战排查中的三条铁律
第一条,不要在生产环境随手执行jmap -histo:live或者jmap -dump:live。因为live会触发Full GC,线上高峰期来一下,服务直接卡死,事故比内存问题还严重。如果必须导dump,加-dump参数的同时选在流量低谷,或者用gcore抓核心转储后离线分析。
第二条,所有的JVM参数变更,都要压测验证后上线。哪怕只是调一个堆大小,也要在预发环境跑一遍压测看GC日志,确认停顿时间在可接受范围再上生产。别问我为什么强调这个,我曾经因为顺手把-Xmx调大,导致Full GC时间变长,接口超时一大片,那天的教训很深。
第三条,排错记录一定要写下来。你在生产上踩过的每一个坑,都是普通文档里学不到的财富。我基本每解决一次线上JVM问题,都会把排查过程、根因、解决方法、后续预防写成一篇简短笔记。时间久了,你会发现对内存结构的理解不再停留在名词上,而是真正长在了自己的骨子里。
6. 后续学习的路线建议
内存结构是JVM知识体系的基石,但只是第一步。接下来建议按这个顺序往下走:先学垃圾回收算法与收集器(理解各区域为什么这么划分,Minor GC和Full GC的触发逻辑),再学类加载机制(理解方法区里的类信息是怎么来的),然后学JMM和并发(理解内存结构与多线程的关系),最后学调优工具和案例分析。每一步都能在上一篇笔记的基础上往下钻,慢慢织成一张完整的知识网。
我现在写这篇笔记时也会想,如果当年刚学JVM的时候有人告诉我“先把运行时数据区当成一张地图来背”,学习曲线肯定会平缓很多。希望这篇笔记对你也有类似的作用。接下来我会继续更新这个系列,下一篇大概率会写垃圾回收,欢迎持续关注,也欢迎在评论区聊聊你在项目里踩过的JVM内存坑。