
在做性能排查、面试准备或者只是想把 Java 这门语言真正吃透的时候JVM 内存结构永远是绕不开的第一站。很多同学看了一堆 JVM 调优参数和垃圾回收器的文章觉得云里雾里根源往往在于对内存区域本身缺乏一张清晰的地图。这次的学习笔记我就围绕 JVM 内存结构把每个区域是什么、干什么、哪些参数管它、哪些异常和它相关一次性梳理清楚。如果你正准备 JVM 面试题或者正被线上 OOM 搞得焦头烂额这篇内容应该能帮你把最核心的知识骨架搭起来。我自己最初啃《深入理解 Java 虚拟机》第 3 版的时候前两章也是反复翻了好多遍才把内存结构这部分真正刻进脑子里。后来排查线上问题多了愈发觉得这部分基础打不牢后面学什么 GC 调优、看 Arthas 输出、分析 jstat 日志全都是在空中楼阁上跳舞。所以这篇笔记不求讲完所有细节只求把内存结构的主干讲透让你合上文章之后能准确说出每个区域的作用、参数和常见的报错。1. JVM 内存结构整体脉络为什么必须先搞懂内存1.1 一句话版本的内存全景图JVM 在运行 Java 程序时会把自己管理的内存划分为若干个不同的数据区域。按照《Java 虚拟机规范》的规定这些区域主要分为两大类线程私有区域和线程共享区域。线程私有区域包括程序计数器、虚拟机栈和本地方法栈每个线程各有一份随线程生而生、随线程灭而灭。线程共享区域包括堆和方法区所有线程都能访问是整个 JVM 内存管理中重头戏中的重头戏。用大白话打个比方线程私有区域就像每个员工自己的工位桌上放着当前正在干的活儿程序计数器、需要临时摊开的文件栈帧线程共享区域就像公司的公共资源仓库堆里放着所有部门要用的原材料和成品档案室方法区里存着公司规章制度和员工花名册。公司再大仓库和档案室的管理永远是行政工作的核心比管理个人工位复杂得多。如果只记一条请记住这句话堆管对象栈管调用元空间管类信息。后面所有细节都是这句话的展开。1.2 分区的底层逻辑为什么要把内存拆成这么多块很多人第一次接触 JVM 内存结构时都会有个疑问为什么不干脆搞一大块内存随便用非要划分得这么细直接读写不就行了这个问题问得非常好。答案在于 JVM 需要同时解决两件事并发安全和高效回收。并发安全方面。线程私有的区域天然不需要考虑并发问题因为每个线程只在属于自己的空间里折腾不会踩到别的线程的数据。而线程共享的堆和方法区就必须要处理加锁、同步、CAS 等一系列并发问题。把内存分区域本质上是把需要并发控制的区域和不需要并发控制的区域做了一次物理隔离让 JVM 在频繁分配对象时不至于每次都陷入锁竞争。高效回收方面。Java 程序中的对象存活时间是高度分化的。有些对象朝生夕死比如循环里临时创建的对象有些对象则几乎和程序同生共死比如 Spring 容器里的单例 Bean。如果不分区垃圾回收器每次都得扫描全部内存效率会低到无法接受。分代收集理论正是建立在不同区域放不同存活时长的对象这一假设上的而这一假设的物理基础就是内存结构的分区。所以内存结构不只是规范更是 GC 实现的地基。1.3 堆和方法区两个共享大区从整个 JVM 内存占比来看堆几乎占据了绝大部分通常默认是物理内存的 1/4 左右所有的对象实例和数组都在这里分配。方法区在 JDK 8 之后以元空间的形式存在存储类元数据、静态变量、常量池等信息。在 JDK 7 及以前方法区还有个别名叫永久代但在 JDK 8 之后永久代被移除改用直接内存实现目的就是避免永久代大小难以控制导致的内存溢出。这两个区域是所有线程共享的所以也是 GC 的主战场。堆要管新生代、老年代方法区要管类卸载和常量池回收。在面试中聊到 JVM 内存结构时这两个区域的细节往往就是面试官追问最多的地方。2. 线程私有区每一个线程的私人储物柜2.1 程序计数器压根没有内存溢出的地方程序计数器Program Counter Register每个线程一份保存的是当前线程正在执行的字节码指令地址。如果正在执行的是 native 方法这个计数器为空。它有一个非常有意思的特点规范中唯一没有规定任何 OutOfMemoryError 的区域。原因很简单它占用的空间太小了每个线程只有一个地址大小的存储需求在 JVM 规范层面直接禁止了这个区域出现溢出。那这个计数器到底有什么用呢最重要的作用是配合线程切换。当我们执行多线程程序时CPU 会在各个线程之间快速切换每个线程被切走之前必须保存好自己执行到哪一条字节码指令了这样才能在重新获得 CPU 时间片后从断点处继续执行而不是从头再来。除了这个场景它还在分支、循环、跳转、异常处理等指令流转中扮演关键角色。在面试时讲清楚程序计数器是唯一不会出现 OOM 的区域并且能说明它服务于线程切换和字节码指令执行就已经到位了。如果不清楚这一块很容易把程序计数器和 PC 寄存器搞混后者是 CPU 层面的概念虽然名字像但完全是两码事。2.2 虚拟机栈StackOverflowError 的高发区虚拟机栈描述的是 Java 方法执行的线程内存模型。每次方法调用JVM 都会创建一个栈帧栈帧里装着局部变量表、操作数栈、动态链接、方法出口等信息。方法调用开始就入栈调用结束就出栈所以虚拟机栈是后进先出的结构。局部变量表存放编译期可知的基本数据类型boolean、byte、char、short、int、float、long、double、对象引用和 returnAddress 类型。操作数栈是执行字节码指令时的工作区所有的算术运算、参数传递都要经过它。动态链接的作用是把符号引用转换成直接引用而方法出口则记录着调用该方法之前的状态方便方法返回时恢复现场。这个区域有两个典型的异常StackOverflowError线程请求的栈深度大于虚拟机所允许的深度时抛出。绝大多数递归调用没有正确退出就会触发这个。OutOfMemoryError如果虚拟机栈允许动态扩展但扩展时无法申请到足够的内存就会抛出。关于栈的大小JVM 参数-Xss可以设定。默认值跟平台有关通常是 512KB 到 1MB 之间。我经常看到团队里有人为了追求更多线程数把 -Xss 调得很小比如 128KB这在某些场景下可以跑但如果方法调用层级很深就会出现栈溢出。反之调成 2MB 又会明显减少可创建的线程总数所以这个值需要根据实际业务情况权衡。2.3 本地方法栈藏在底层的那一块本地方法栈和虚拟机栈的作用非常相似区别就在于虚拟机栈服务的是 Java 方法而本地方法栈服务的是 native 方法。native 方法就是用 JNI 调的底层 C/C 实现比如 Java 里很多文件操作、网络操作底层都会走 native 方法。在 HotSpot 虚拟机中虚拟机栈和本地方法栈其实合二为一了。也就是说你在 HotSpot 里用 -Xss 设置栈大小时同时影响了两者。这也是为什么很多 JVM 实现细节的讨论都基于 HotSpot因为它是目前使用最广泛的虚拟机实现。本地方法栈同样会抛出 StackOverflowError 和 OutOfMemoryError不过因为 native 方法的使用场景相对少所以平时遇到这类报错时大家优先排查的往往还是虚拟机栈。3. 线程共享区堆与方法区的运转细节3.1 堆内存的结构与对象的一生堆是 JVM 管理的最大一块内存所有对象实例和数组都在这里分配。堆的逻辑上被划分为新生代和老年代默认比例大致是 1:2。新生代里又进一步划分为 Eden 区和两个 Survivor 区from 和 to默认比例是 8:1:1。对象的一生通常是这样的对象首先在 Eden 区分配。发生 Minor GC 时Eden 区存活的对象会被复制到 Survivor 区from 或 to。每经历一次 Minor GC 仍然存活对象年龄加 1。当对象年龄达到阈值默认 15可以通过 -XX:MaxTenuringThreshold 调整就会被晋升到老年代。有些大对象会直接进入老年代通过 -XX:PretenureSizeThreshold 来控制阈值。为什么这么设计还是因为在绝大多数程序中大部分对象是朝生夕死的。把新对象集中在新生代用复制算法做 GC效率非常高因为复制算法只需要处理存活对象而存活对象占比极低。老年代则不一样对象存活率高适合用标记-整理或标记-清除类算法避免频繁复制大对象带来的开销。堆的参数配置是 JVM 调优的重点。-Xms设定堆初始大小-Xmx设定堆最大大小。在实际生产中强烈建议把 -Xms 和 -Xmx 设成一样这样可以避免 JVM 在运行过程中动态扩容和缩容带来的性能波动。3.2 TLAB 与指针压缩两个提升性能的关键细节堆内存虽然是线程共享的但每一个线程在 Eden 区里都可以划出一小块只属于自己的空间这块空间叫TLABThread Local Allocation Buffer中文叫线程本地分配缓冲区。它存在的意义是对象分配是非常高频的操作如果每次分配都要对 Eden 区加锁并发性能会大打折扣。有了 TLAB每个线程在大多数情况下都可以在自己独占的缓冲区里分配对象不需要同步。只有 TLAB 用完了才需要重新申请这时才会涉及锁竞争。TLAB 默认是开启的可以通过 -XX:UseTLAB 和 -XX:TLABSize 等参数调整。虽然 TLAB 在面试中未必每次都会被问到但在分析对象分配速度和 GC 频率时它是非常关键的底层机制。指针压缩则是另一个容易被忽视但影响很大的参数。在 64 位 JVM 中对象引用默认占 8 个字节如果堆内存小于 32GB可以通过-XX:UseCompressedOops开启指针压缩把引用压缩到 4 个字节。开启后内存占用能减少将近一半缓存命中率也会提升。JDK 8 在默认情况下只要堆小于 32GB 就会自动开启指针压缩。很多人在 32GB 边界上遇到过诡异问题堆从 30GB 调到 34GB结果内存占用反而飙升就是因为 32GB 之后指针压缩自动失效了。这一点在 JVM 调优时非常值得留意。3.3 方法区元空间最容易让人忽视的共享区方法区在 JDK 8 之后由元空间Metaspace实现。为什么改因为永久代的大小很难确定而且它用的还是堆内存一旦加载的类增多很容易抛出永久代 OOM。元空间改为使用本地内存默认情况下大小只受物理内存限制大大降低了 OOM 的概率。不过注意这里说的是降低了概率不是说不会 OOM。如果不对元空间做限制它可能会无限膨胀耗尽机器物理内存。所以生产环境通常会设置-XX:MetaspaceSize和-XX:MaxMetaspaceSize前者是初始大小后者是最大大小。MetaspaceSize 设得太小会导致频繁 Full GC设得太大又可能让类加载过度膨胀需要结合类加载情况和物理内存总量来定。元空间里关键的东西包括类的元数据、运行时常量池、字段和方法信息、静态变量JDK 8 中静态变量实际在堆中类信息在元空间等。很多初学者存在一个误区以为永久代被移除之后就没有方法区了其实方法区是 JVM 规范规定的逻辑区域永久代和元空间只是它的不同实现概念上方法区一直存在。在面试时把这个逻辑讲清楚是很加分的。4. 内存参数与调优给 JVM“拍胸片”的实操方法4.1 常用 JVM 内存参数速查JVM 内存参数很多但实际工作中最常用的就那么几个整理成一张表方便查阅。参数名作用典型值-Xms堆初始大小建议与 -Xmx 相同-Xmx堆最大大小根据物理内存和应用需求定一般不超过物理内存的一半-Xss线程栈大小默认 512KB-1MB需要大量线程时可调小-XX:MetaspaceSize元空间初始大小视类加载量而定-XX:MaxMetaspaceSize元空间最大大小避免无限膨胀生产必须设置-XX:UseCompressedOops开启指针压缩堆小于 32GB 时默认开启-XX:MaxTenuringThreshold对象晋升老年代年龄阈值默认 15-XX:PretenureSizeThreshold大对象直接进老年代的阈值大于该值的对象直接在老年代分配-XX:SurvivorRatioEden 与 Survivor 的比例默认 8:1-XX:NewRatio新生代与老年代的比例默认 1:2以一台 16GB 物理内存的服务器为例假设上面只跑一个 Java 应用我通常会这样起步-Xms8g -Xmx8g -Xss512k -XX:MetaspaceSize256m -XX:MaxMetaspaceSize512m。为什么要留一半给操作系统和其他进程因为 JVM 除了堆和元空间之外还有 JIT 编译器、GC 线程、各种 native 内存、直接缓冲区等开销全塞满物理内存很容易触发系统级别的 OOM Killer。另外还有一个关键参数 -XX:HeapDumpOnOutOfMemoryError。开启之后一旦发生 OOMJVM 会自动生成一个堆转储文件.hprof这个文件是你事后排查 OOM 最重要的线索。生产环境必须加上否则 OOM 一发生现场就被破坏了再想查原因就只能靠猜。4.2 内存溢出OOM的类型与实战排查OOM 分很多种它们的报错信息各不相同定位方向也完全不同。最常见的几种java.lang.OutOfMemoryError: Java heap space堆内存耗尽。最常见的原因要么是堆太小要么是存在内存泄漏。拿到 hprof 文件后用 MAT 等工具分析大对象和引用链基本能定位。java.lang.OutOfMemoryError: GC overhead limit exceededGC 一直在运行但回收效果极差通常意味着堆极小且对象几乎全部在引用中属于濒临崩溃的边缘状态。在 hprof 之前很多场景下是因为代码在疯狂创建对象GC 跟不上分配速度。java.lang.OutOfMemoryError: Metaspace元空间耗尽。一般是加载的类过多常见于大量动态生成代理类的框架如 CGLIB、ASM或者热部署场景没有正确卸载类。java.lang.OutOfMemoryError: Direct buffer memory堆外直接内存耗尽。NIO 操作中 ByteBuffer.allocateDirect() 用得太多或者没有释放。java.lang.OutOfMemoryError: unable to create new native thread线程数量超过系统限制创建线程失败。这时候除了排查代码里是否无节制地创建线程之外还要检查操作系统层面的 /etc/security/limits.conf 里的进程级线程数限制。排查 OOM 有个基本流程先开 -XX:HeapDumpOnOutOfMemoryError 和 -XX:HeapDumpPath 拿到堆转储再用 MAT 或者 JProfiler 分析支配树找那些占用内存超大且没有被释放的对象。如果内存泄漏不明显就要用 jstat 观察 GC 频率看是不是每一次 GC 之后堆的占用都在缓慢爬升这种楼梯曲线是内存泄漏的典型特征。5. 常见问题与排查技巧实录5.1 常见问题速查表在学习和实战过程中我整理过一份内存结构相关的问题速查表。很多问题新手反复踩老手看一眼报错就能定位差距就体现在这里。现象可能的区域排查思路递归调用报 StackOverflowError虚拟机栈查递归退出条件或调大 -Xss线程创建失败报 unable to create native thread操作系统线程查系统线程数限制和代码中的线程池配置堆 OOMGC 频繁但无效果堆用 MAT 分析堆转储找大对象和引用链大量类加载或热部署后 OOM元空间查动态代理类生成、类加载器泄漏堆 30GB 升到 34GB 后内存反而紧张堆检查是否触发了指针压缩失效考虑对象引用膨胀StackOverflowError 这一类问题在平时开发和面试中都很常见。有一次我给团队做代码评审看到一段递归实现树的深度遍历代码树的深度理论上不超过几百但有人把 -Xss 调成了 128KB 来腾出更多线程数结果线上偶发栈溢出。排查了很久才发现不是代码问题而是栈空间被压得太小。这就是 -Xss 这种参数的双刃剑效应既要又要是不行的必须做取舍。5.2 排查工具链jps、jstat、jmap、jstack、arthas光知道概念不一定会排查。实际工作中我的工具链通常是这样使用的jps查看当前机器上有哪些 Java 进程拿到进程号。jstat观察 GC 情况和类加载情况比如jstat -gcutil pid 1000每秒打印一次 GC 统计能快速看出新生代和老年代的使用趋势。jmap导出堆转储文件或者查看堆内存摘要比如jmap -dump:live,formatb,fileheap.hprof pid可以导出存活对象的堆快照。jstack导出线程栈排查死锁、线程卡顿等问题。jstack pid thread_dump.txt后可以用分析工具看线程状态。jconsole 和 VisualVM图形化监控 JVM 内存、线程、CPU适合开发环境和测试环境快速发现问题。Arthas阿里开源的诊断工具可以不重启应用就在生产环境做内存、线程、调用链路分析。它的 dashboard 命令能直接显示堆内存、GC、线程等实时数据非常强大。在生产环境基本靠它和 jstat 解决大多数问题。这里我想多说一句关于 jmap 的注意事项生成堆转储文件会触发一次 Full GC并且卡顿时间可能很长所以生产环境执行 jmap dump 一定要挑业务低峰期并且用-dump:live只导出存活对象缩小文件体积减少影响。5.3 印象最深的两个案例分享两个我在实际工作中遇到的案例都和内存结构直接相关。第一个案例是线上一个服务出现了GC overhead limit exceeded。当时堆设置的是 4GB一开始以为是堆不够大直接调到 8GB结果不到两天又出现同样问题。后来导出堆转储用 MAT 分析发现大量的HashMap$Node对象被一个静态集合持有而这个静态集合只增不减像一张无限膨胀的注册表。代码是一次启动时加载配置的缓存但加载逻辑里有个 bug每次请求都会新增重复的配置项导致集合越来越大。这个问题靠调参根本解决不了唯一的办法是修代码。这也是我想强调的遇到 OOM第一反应不要是加内存而是先搞清楚为什么内存会不够。第二个案例和元空间有关。一个应用频繁做热部署每次重新加载应用都会创建新的类加载器但旧的类加载器因为被某个全局单例引用始终无法被回收导致元空间的类元数据只增不减最后直接 Metaspace OOM。排查时我们用 Arthas 的 classloader 命令查看类加载器数量和引用情况很快就定位到问题是框架里的一个静态缓存持有类加载器引用。这个案例让我对元空间为什么需要限制有了直观的理解。不设 MaxMetaspaceSize它虽然不容易爆但一旦泄漏会把整个机器的物理内存都拖垮。6. 内存结构学习的方法论怎么把这块知识真正内化很多初学者问我JVM 内存结构这部分知识到底怎么学。我的答案是不要靠背要靠画图和踩坑。画图的意思是自己在纸上画出每个内存区域然后模拟一次方法调用的完整过程。比如你写一个最简单的main方法里面创建一个对象调用另一个方法这个方法又创建了一个局部变量。试着追踪这个过程中程序计数器怎么变化、虚拟机栈里怎么入栈出栈、栈帧里的局部变量表和操作数栈怎么工作、对象到底分配在堆的哪个区域、方法区的类信息什么时候被加载。这个过程一旦你能含糊地画出来内存结构就懂了七成。踩坑的意思是多动手。在自己的电脑上装一个 JDK写一段会产生栈溢出的递归代码写一段往 List 里无限放对象的代码亲手触发一次 OOM导出堆转储用 MAT 打开看看然后在 JVM 参数里调一调数值观察报错信息的变化。这些操作远比看十篇文章管用。我在学习的时候每种 OOM 都亲手触发过一遍所以后来面试官问起时我能讲出很多纸上谈兵的人讲不出的细节。还有一个心得是学内存结构时要有意识地和后续知识挂钩。比如学完堆的分代结构去看垃圾回收器时就会更容易理解为什么 G1 要设计成 Region为什么 CMS 是分代收集器而 ZGC 以更细粒度的染色指针技术做并发标记。如果你能顺着内存结构这条线一路往外延伸JVM 学习的网络就会越织越密而不是学一块忘一块。