前段时间同事在群里发了一张线上报错截图,日志里红字异常特别醒目:java.lang.OutOfMemoryError: Java heap space。群里沉默了几秒,紧接着有人问“这到底是堆满了还是内存泄漏?”“JVM内存模型到底怎么画的来着?”问题一出来,发现平时CRUD写得很溜的几个人,其实对JVM内存区域划分的概念是模糊的。这也不奇怪,毕竟日常开发很少直接碰内存,可一旦线上出问题,或者面试被问到“JVM运行时数据区有哪些”,能不能讲清楚,就直接区分出你是“用过”还是“懂过”Java。
这篇内容不是教科书式的复述,而是结合我实际排查问题、调优参数、带新人时踩过的坑,把JVM核心认知里最基础也最关键的内存区域划分讲透。你会搞清楚程序计数器、虚拟机栈、堆、方法区各管什么,哪些区域会抛什么异常,以及-Xmx、-Xss、-XX:MaxMetaspaceSize这些参数到底在设置什么。适合刚学JVM的同学,也适合写了两三年Java但对内存管理始终一知半解的朋友。
1. 为什么每个Java程序员都得懂内存区域划分
1.1 一次线上OOM引起的重视
继续说开头那个场景。报错的是我们一个订单同步服务,平时跑得挺稳,结果那天业务量一涨,内存直接爆掉。重启以后好了,但大家都清楚这只是暂时的。
我当时让负责的同学先执行jstat -gcutil <pid> 1000看一眼GC情况,结果他愣住了,问我“这输出的E、S0、S1、O、M是什么意思”。那一刻我突然意识到,很多人背过“堆、栈、方法区”这几个名词,但真到用的时候,连观察内存状态都不知道看哪个区域。
那次事故最终定位到是一个大对象列表被缓存到了静态Map里,没有清理策略,导致老年代不停增长。修复并不复杂,但排查过程绕了不少弯路。如果一开始就对内存区域划分有清晰认知,知道“对象进了堆、老年代涨了说明有对象一直在逃逸”,并且能对应上jstat每列的含义,定位问题的时间至少能缩短一半。
1.2 内存区域划分到底解决了什么问题
很多人觉得Java有自动内存管理(GC),为什么还要手动关心内存区域?这个想法其实有个误区:GC只是帮你回收了堆和方法区里的部分垃圾,但它不会帮你决定“一个对象应该放在哪”“栈帧多大够用”“类信息存哪儿”。你需要设置堆大小、栈大小、元空间大小,需要理解对象什么时候能进入老年代、哪些线程私有的数据不会被其他线程访问——这些基础全建立在内存区域划分之上。
换句话说,内存区域划分就是JVM的“城市规划图”。懂得了这张图,你才知道自己写的代码运行在哪个区域、参数配置影响的是哪块空间、报错信息对应的是哪个区域的异常。面试中那一整套“JVM工作原理”“GC回收器选择”“线程池参数配置”的追问,追根溯源,都逃不开这份区域划分的底子。
2. 运行时数据区的整体架构
2.1 线程私有与线程共享的分类逻辑
JVM在执行Java程序时,会把它管理的内存划分成若干个不同的数据区域。这些区域从“是否线程共享”的角度看,天然分成两类:线程私有的和线程共享的。
为什么有些区域要线程私有?你可以把每个线程想象成一个独立的“工人”,每个工人干活时手里需要一张自己的操作台、一沓便签纸、一个进度记录本。这些工具如果大家混用,你写一笔我画一下,工作早就乱套了。所以程序计数器、虚拟机栈、本地方法栈这三个区域是每个线程独享的,生命周期跟线程一致,线程结束,区域也释放。
线程共享的区域则像是公司的公共仓库和公共档案室。所有线程都能往里存取对象、读取类信息。因为共享,所以这些区域才会成为GC的重点关注对象,也最容易出现并发访问和内存压力的问题。Java堆和方法区就承担了这个角色。
2.2 一张表理清五个核心区域
JVM规范中的运行时数据区主要包含五块:程序计数器、虚拟机栈、本地方法栈、Java堆、方法区。先把它们的关键信息列成一张速查表,后面再逐一展开讲。
| 区域 | 线程关系 | 存储内容 | 常见异常 |
|---|---|---|---|
| 程序计数器 | 线程私有 | 当前线程执行的字节码行号指示器 | 无 |
| 虚拟机栈 | 线程私有 | 栈帧,每个方法调用对应一个栈帧 | StackOverflowError、OutOfMemoryError |
| 本地方法栈 | 线程私有 | 为Native方法服务 | StackOverflowError、OutOfMemoryError |
| Java堆 | 线程共享 | 对象实例、数组 | OutOfMemoryError: Java heap space |
| 方法区 | 线程共享 | 类信息、常量、静态变量、JIT缓存 | OutOfMemoryError: Metaspace |
我建议你先把这张表装进脑子里。后面每一次看GC日志、调JVM参数,本质上都是在跟这张表里的各个区域打交道。
3. 逐区拆解:每个内存区域里到底装了什么
3.1 程序计数器:最不起眼但绝不能少
程序计数器在五个区域里存在感最低,因为它既不会OOM,也不归GC管。但它做的事情非常关键:记录当前线程正在执行的字节码指令地址。
为什么要记录这个?因为线程是会切换的。假设线程A执行到第10行指令时CPU被线程B抢走了,等线程A再次被调度回来,它得知道自己刚才执行到哪一行了。这个“继续往下执行的线索”就存在程序计数器里。如果当前执行的是Native方法,程序计数器的值是undefined,因为没有字节码指令可记录。
程序计数器的空间很小,可以理解成线程私有的一小块内存。它也是规范里唯一没有规定任何OutOfMemoryError情况的区域,所以一般面试时能说出“它存的是字节码行号、线程私有、不会OOM”这三点就过关了。
3.2 Java虚拟机栈:方法调用的“便签纸”
虚拟机栈描述的是Java方法执行的线程内存模型。每个线程的栈里装着若干个栈帧,每调用一个方法,就压入一个栈帧;方法返回,栈帧弹出。栈帧里主要包含局部变量表、操作数栈、动态链接、方法返回地址。
| 栈帧组成 | 主要作用 |
|---|---|
| 局部变量表 | 存放方法参数和方法内部定义的局部变量 |
| 操作数栈 | 存放计算过程中的中间结果,字节码指令的操作对象 |
| 动态链接 | 指向运行时常量池中该方法的引用,支持方法调用解析 |
| 方法返回地址 | 方法退出后恢复到调用位置所需的信息 |
看到这里你应该明白了,为什么递归没写终止条件最终会抛StackOverflowError——每一层递归都会压入一个栈帧,而线程能拥有的栈帧数量是有限度的。默认情况下,64位Linux和macOS上每个线程的栈大小是1MB,用-Xss可以调整。
给你一个更直观的理解:假期你往书包里塞衣服,塞到一定厚度就拉不上拉链了。栈帧就是那一层层衣服,拉链就是栈的容量上限。想多塞一点,就调大-Xss,但调得过大也会占用过多内存,挤占其他资源,甚至导致系统能创建的线程数量变少。
3.3 本地方法栈:被合并的“编外区域”
本地方法栈跟虚拟机栈的作用类似,区别在于虚拟机栈为Java方法服务,本地方法栈为Native方法服务。这里说的Native方法,是指使用native关键字修饰、由C/C++等非Java语言实现的方法。
在HotSpot虚拟机里,这块区域跟Java虚拟机栈被合并成了一个,你设置-Xss时,两个栈的大小都会受影响。实际开发中我们很少直接调用Native方法,但你依赖的底层库可能一直在用,比如一些网络库、加密库、文件IO操作在底层可能走了native实现。所以它虽然“编外”,但依然线程私有,依然可能抛StackOverflowError和OutOfMemoryError。
3.4 Java堆:所有对象的“仓库”
Java堆是JVM内存区域里最大的一块,也是GC工作的主战场。几乎所有的对象实例和数组都在这里分配。为什么加“几乎”?因为随着JIT编译器和逃逸分析技术的发展,如果确定一个对象不会逃逸出方法,JVM可能会在栈上直接分配对象空间,而不是进堆。不过这只是优化手段,从逻辑上说,堆依然是对象的主要存放地。
堆在物理上可以被分成新生代和老年代两块,比例上新生代又可以细分为Eden区和两个Survivor区。默认情况下Eden区和Survivor区的比例是8:1:1,这个比例可以通过-XX:SurvivorRatio调整。
这么说可能有点空洞,来算笔账。假设你的服务设置了-Xms2g -Xmx2g -Xmn1g,那么:
- 新对象诞生在Eden区,Eden约800MB。
- 每轮Minor GC后,存活对象进入Survivor区。
- 经历一定次数GC仍然存活的对象进入老年代。
- 老年代默认是新生代的2倍,大约2GB(这里按整个堆2g、新生代1g推算,老年代约1g)。
这组数字不用死记,但你得理解:为什么大对象直接进老年代?因为大对象在Eden区频繁复制代价太高。为什么长期存活的对象要升入老年代?因为老年代的GC频率更小,更适合“寿星”居住。理解了这个,你以后看GC日志里Eden Space、Survivor Space、Old Gen的状态变化,就不会一脸懵了。
3.5 方法区与运行时常量池:类元数据的“档案室”
方法区存储的是类信息、常量、静态变量、即时编译器编译后的代码缓存等。很多人会把方法区跟“永久代”混为一谈,准确说,永久代是HotSpot在JDK 8以前对方法区的一种实现方式,JDK 8以后,方法区被迁移到了本地内存里的元空间(Metaspace)。
方法区里还有一个重要的组成部分叫运行时常量池,它负责存放编译期生成的字面量(比如字符串字面量、final常量值)和符号引用。字符串常量池在JDK 7以前也在方法区里,JDK 7开始被移到了Java堆。这也是为什么常量池的讨论总是容易把人绕晕的原因——因为它确实“搬家”过。
方法区如果满了,会抛OutOfMemoryError: Metaspace。常见的场景是运行时动态生成大量类,比如某些框架做CGLIB代理、持续热部署加载新类,而类加载器又无法正常回收,元空间就会一路涨到物理内存不够用。
4. 容易被忽略的“编外内存”:直接内存与元空间
4.1 直接内存为什么在JVM规范之外
直接内存(Direct Memory)不在JVM规范规定的运行时数据区内,但它经常参与Java程序的内存计算,尤其是在使用NIO的DirectByteBuffer时。
它的思路很直接:在Java堆之外分配一块本地内存,由操作系统直接管理。这样一来,IO操作时数据可以少一次从Java堆复制到native内存的过程,性能会好不少。代价是,分配和回收直接内存不受Java堆GC的完全控制,如果一直分配不释放,它会吃掉操作系统的真实内存。
相关的参数是-XX:MaxDirectMemorySize,默认大约等于-Xmx的值。很多同学只盯着堆内存,忽略了直接内存,一旦程序用了大量NIO,就可能出现明明堆剩余很多,却报”Direct buffer memory“错误的情况。所以排查内存问题时,别把眼光只放在堆上。
4.2 永久代到元空间:一次重要的迁移
JDK 8把原本用永久代实现的方法区改成了元空间,为什么这么改?因为永久代的大小是固定的,默认值有限,经常出现类元数据过多导致的OutOfMemoryError: PermGen space,而且调优也不方便。
改成元空间以后,最直观的变化是:类的元数据不再占用Java堆内存,而是使用本地内存。默认情况下元空间大小没有上限,只受物理内存限制。你可以通过-XX:MetaspaceSize设定初始阈值,用-XX:MaxMetaspaceSize设置最大值。
实际效果就是,使用Spring Boot、MyBatis、CGLIB这类动态代理重灾区的项目,再也不用频繁担心PermGen爆掉了。但也不是说完全不用管,如果应用出现“动态生成类停不下来”的问题,元空间照样能涨满。我见过一个轮询生成代理类的测试代码,跑一个晚上就把元空间撑爆了。
5. 控制内存区域的JVM参数与调优工具
5.1 内存参数配置速查
了解了每个区域,接下来就是通过参数控制它们。参数不用全背,但下面这组是日常开发、排障、面试都绕不开的。
| 参数 | 作用 | 我的建议 |
|---|---|---|
-Xms | 初始堆大小 | 建议与-Xmx一致,避免堆反复伸缩带来的性能抖动 |
-Xmx | 最大堆大小 | 根据业务估算,一般不超过物理内存的50%-70% |
-Xmn | 新生代大小 | 可根据GC日志调整,一般占堆大小的1/3到1/4 |
-XX:SurvivorRatio | Eden与Survivor比例 | 默认8,通常不需要动 |
-Xss | 线程栈大小 | 一般是512KB-1MB,不是越大越好 |
-XX:MetaspaceSize | 元空间初始阈值 | 可按应用类数量设置,比如256MB |
-XX:MaxMetaspaceSize | 元空间最大大小 | 建议设置,避免无上限占用物理内存 |
-XX:MaxDirectMemorySize | 直接内存上限 | 用NIO时务必关注 |
很多人设置堆内存的时候会陷入一个误区:觉得-Xmx调得越大越好。其实堆太大,GC单次STW的时间会变长;堆太小,GC频率会变高。比较靠谱的做法是,先用默认参数跑一段时间,再用jstat观察GC频率和内存占用,最后反推合适的堆大小。
5.2 常用调优工具怎么看区域内存
很多工具都能帮助观察内存状态,我们不用都精通,但至少要会几个常用的。下面是我在实际工作中用得最多的几个,附上它们解决什么问题。
| 工具 | 核心作用 | 我常用的命令/操作 |
|---|---|---|
| jps | 查看Java进程PID | jps -l |
| jstat | 查看GC和类加载情况 | jstat -gcutil <pid> 1000 |
| jmap | 查看堆配置、导出堆dump | jmap -heap <pid>、jmap -dump:format=b,file=heap.hprof <pid> |
| jstack | 查看线程栈快照 | jstack <pid> |
| jconsole | 图形化监控内存、线程、类 | 直接启动,连接进程即可 |
| visualvm | 可视化分析堆dump和GC | 导入.hprof文件,查看对象占用Top N |
| arthas | 在线诊断神器 | dashboard看全局,memory看各区域内存池情况 |
我第一次用arthas的memory命令时,一眼就看到Eden区、老年代、元空间分别占了多少,比在日志里猜直观太多了。如果是本地开发或者测试环境,配合jconsole或visualvm观察对象分配和GC,效果也很直观。
6. 从内存区域划分到OOM排查实战
6.1 各区域常见的异常形态
不同区域OOM的报错信息不一样,排查方向也不一样。这里把常见的异常形态整理成一张速查表,看到报错先定位区域,再说下一步。
| 异常信息 | 对应区域/原因 | 排查方向 |
|---|---|---|
Java heap space | 堆内存不足,对象过多或泄漏 | 用jmap导出堆,分析对象引用链 |
GC overhead limit exceeded | GC几乎不回收,98%时间在GC且回收不到2% | 查看堆使用率是否持续高位,考虑堆溢出或泄漏 |
Metaspace | 元空间不足,类元数据过多 | 观察类加载数量是否持续增长,检查类加载器是否泄漏 |
unable to create new native thread | 无法创建新线程,线程数超过操作系统限制 | 检查线程池配置、操作系统ulimit限制 |
StackOverflowError | 虚拟机栈溢出,通常是递归过深 | 用jstack查看栈调用,定位无限递归 |
Direct buffer memory | 直接内存不足 | 检查NIO相关代码,评估-XX:MaxDirectMemorySize |
6.2 一套排查思路,三个真实场景
排查内存问题时,我建议按这个顺序来:
- 先用
jps或top确认进程还在,拿到PID。 - 用
jstat -gcutil <pid> 1000连续观察GC情况和各区占用,判断是内存泄漏还是内存不足。 - 用
jmap -heap <pid>确认堆配置是否跟预期一致。 - 必要时
jmap -dump导出堆快照,用visualvm或MAT分析大对象和引用链。 - 如果怀疑线程问题,用
jstack抓线程栈。 - 在线环境不方便重启时,用arthas直接跑
dashboard、memory、thread快速判断瓶颈。
场景一,有个报表服务跑了几天后响应变慢,日志里频繁出现GC overhead limit exceeded。用jstat观察后发现老年代占用几乎打满,导出堆快照分析,发现一个HashMap里堆积了大量历史报表的查询条件对象,根因是缓存Map没有设置过期策略。修复后老年代稳定在30%以下。
场景二,一个内部系统持续热部署后报Metaspace OOM。用jstat -class观察类加载数量,发现每发布一次,类数量就涨几百个,而且旧类没有卸载。定位到是自定义类加载器持有静态引用,依赖包重复加载导致的元空间泄漏。这个坑如果不从元空间的角度想,很难想到。
场景三,新同学写了一段把菜单表转树的递归代码,数据量一大直接StackOverflowError。用jstack抓到异常线程,调用栈里几百行都是同一个方法,一眼看出递归没有终止条件。改完之后树构建稳定,CPU占用也降下来了。
6.3 开发环境防OOM:IDEA内存设置实操
说到开发测试时的OOM,还一个很常见的场景是IDEA自身卡顿、编译时OOM,或者本地启动项目时频繁OOM。首先要区分两个东西:本地IDEA的JVM内存,和项目运行时的JVM内存,这是两套设置。
IDEA自己的内存,在菜单Help里选择Edit Custom VM Options,IDEA会为你创建一个idea.vmoptions文件,里面可以设置-Xms和-Xmx。比如我的开发机是16GB内存,会给IDEA设置为-Xms512m -Xmx2048m,并在文件里加上-XX:+HeapDumpOnOutOfMemoryError,这样IDEA万一OOM还能留个dump文件排查。
项目运行时的JVM参数,则在Run/Debug Configurations里找到对应应用的VM options。本地开发一般建议给足内存,比如-Xms512m -Xmx1024m -XX:MaxMetaspaceSize=512m。如果你的项目跑起来总在本地OOM,先别急着把-Xmx开到好几个G,用jvisualvm本地连一下进程,看看是不是哪块缓存没控制好,或者测试数据量太大导致堆被打满。盲目加堆是治标不治本。
另外补一个容易踩的坑:很多人项目里用的是CGLIB动态代理、反射比较重的框架,本地启动时如果报Metaspace相关错误,优先检查-XX:MaxMetaspaceSize是不是设得太小了,适当调大元空间,而不是去动堆的-Xmx。
我在实际排查中养成了一个习惯:不管什么问题,先看一眼各个内存区域的水位,再动手调参。内存区域划分不是背完就扔的八股文,而是每次看到GC日志、OOM报错、性能指标时,能迅速在脑子里浮现出“是哪块区域出了状况”的地图。你把这幅地图刻进脑子里,后面再接触G1、ZGC这些回收器,理解起来就是顺水推舟的事。
最后分享一个我常用的笨办法:刚开始学JVM内存的时候,我在本地开了一个Spring Boot应用,先跑一分钟,用jconsole截图看一眼各区水位,再写一段往List里塞对象的代码,观察Eden区的增长曲线。多来几次,你就能直观感受到“对象先到Eden、GC后进Survivor、长期存活进入老年代”的完整过程。这种肉眼可见的反馈,比单纯记概念牢固得多。