
1. 从一次线上事故开始JVM内存结构到底在解决什么问题先说一件真实发生过的事。去年我们组维护的一个订单服务平时跑得好好的某天下午突然开始频繁告警日志里滚动出现java.lang.OutOfMemoryError: Java heap space。服务没死但接口响应从几十毫秒涨到了好几秒最后只能重启节点应急。复盘的时候发现问题根源并不复杂——某个接口在循环里不断往一个静态集合里塞数据对象无法被垃圾回收堆被慢慢撑爆了。这类问题如果你对JVM内存结构没有一个清晰的认知排查起来会非常痛苦。你可能会盯着日志发呆不知道这个报错到底是哪个区域满了也不知道该去看哪块参数更不知道什么样的代码写法会触发这种问题。而当你把内存结构吃透了看到报错信息的那一瞬间脑子里大概就有了一条排查路径heap space 对应的是堆区说明对象分配太多且回收不掉下一步就去查堆占用、看GC日志、找大对象引用链。这篇文章我就把JVM内存结构这件事从头到尾讲清楚。内容不局限于面试题的背答案更多的是从“一个Java程序跑起来之后内存到底是怎么分的、每块区域是干什么的、出问题了怎么查”这个角度去展开。适合正在准备面试的初学者也适合写过一段时间代码、想系统补一补JVM基础的同学就算是有几年经验的开发我相信里面的一些排查思路和参数细节也能让你有收获。2. 运行时数据区JVM内存的官方划分JVM内存结构官方说法叫“运行时数据区”Runtime Data Area。Java虚拟机规范把它划分为几个区域每个区域有各自的用途、生命周期和异常类型。理解这部分是后面所有调优和排查的前提。2.1 程序计数器最小的内存区域最大的作用程序计数器Program Counter Register在JVM里是唯一一个不会出现OutOfMemoryError的区域。它是线程私有的说白了就是记录当前线程执行到字节码的哪一行。为什么要线程私有因为CPU在多个线程之间切换执行时每个线程都需要知道自己执行到哪了。线程切换回来之后才能从上次暂停的位置继续往下走。这就是“上下文切换”能正确恢复的基本前提。这个区域实在太小通常只有几十字节但它在JVM里扮演的角色很像你读书时的书签——不用记住全书内容只要记住读到哪里了就行。对于Java开发者来说平时几乎不直接和它打交道但它是JVM能够实现多线程的基础设施之一。2.2 Java虚拟机栈每个方法调用的幕后记录员虚拟机栈Java Virtual Machine Stack也是线程私有的。它的生命周期和线程一致在线程创建时同时创建。可以把它理解成一个“方法调用的台账”。每次调用一个方法JVM就会创建一个栈帧Stack Frame入栈方法执行完毕栈帧出栈。递归调用过深、或者方法嵌套调用过多就会导致栈帧数量超出栈的容量抛出StackOverflowError。栈帧内部承载了四类核心内容局部变量表存放方法参数和方法内部定义的局部变量。注意存放的是基本数据类型的值和引用类型对象的引用也就是对象的地址对象本体不在这里。操作数栈方法执行过程中所有计算的临时“草稿纸”。比如执行a bJVM会把a、b压入操作数栈然后执行加法指令结果再压回栈中。动态链接当前方法引用其他方法、变量或常量池中的字面量时需要借助符号引用来查找实际内容。方法出口方法正常返回或异常退出时需要知道回到调用方的哪个位置继续执行。这里有一个新手最容易混淆的点栈里存的是对象引用不是对象本身。对象本体在堆里。很多人会画“栈里放对象”的示意图这是不严谨的。栈的大小可以用-Xss参数设置默认值在不同平台上不一样一般是512KB到1MB之间。调大-Xss可以支持更深的递归但代价是每个线程占用的内存变大可创建的线程数变少。这个度该怎么把握后面在排查章节我再细说。2.3 本地方法栈为native方法单独开辟的空间本地方法栈Native Method Stack和虚拟机栈的职责非常相似区别在于虚拟机栈服务于Java方法而本地方法栈服务于native方法——也就是用其他语言比如C/C编写、通过JNI方式调用的方法。在你调用Thread.start()、FileInputStream.read()这类底层使用了native实现的方法时执行入栈出栈的区域就是本地方法栈。这个区域在实际开发和面试中出现的频率都不高有一个重要的点值得记住HotSpot虚拟机也就是Oracle官方JDK默认使用的虚拟机把虚拟机栈和本地方法栈合在了一起。也就是说在HotSpot中-Xss参数同时控制这两个栈的大小。2.4 堆GC的主战场也是内存调优的重心堆Heap是JVM内存结构中最大的一块也是所有线程共享的区域。你用new创建出来的绝大多数对象实例都存放在这里。堆之所以是调优的重心是因为它是垃圾回收器GC的主要工作区域。GC要考虑的“对象是死是活”“要不要回收”“什么时候回收”针对的绝大部分都是堆里的对象。从JVM规范的角度看堆的逻辑划分是这样的新生代Young Generation新创建的对象先进入这里。新生代内部又分为Eden区和两个幸存者区Survivor区一般叫S0和S1。老年代Old Generation经历多次GC仍然存活的对象会被晋升到老年代。为什么要把堆拆成这种结构根本原因是“大多数对象朝生夕灭”这个统计规律。IBM的研究表明在典型应用中约98%的对象在创建后很快就不再被引用。如果所有对象放一起统一扫描回收效率会很低。分代之后新生代的对象回收频率高、范围小老年代对象的GC频率可以放得很低整体性能就能得到提升。堆的大小通过-Xms初始大小和-Xmx最大大小来控制。生产环境里通常建议把这两个值设为相同避免运行期动态扩展堆大小带来的性能抖动。堆内部新生代和老年代之间的比例可以通过-XX:NewRatio调整。这个参数的含义是老年代与新生代的比值-XX:NewRatio2表示老年代是新生代的2倍。2.5 方法区与元空间类的信息的“档案室”方法区Method Area也是线程共享的。它存放的是类信息——每个类的结构、字段、方法、常量、静态变量、编译后的代码等。你在程序里定义了一个类这个类的“元信息”就会加载到方法区里。这里要特别说清楚一个容易混淆的历史演进。JDK 8以前方法区的实现叫“永久代”PermGen它使用的是JVM堆内的内存。JDK 8开始永久代被移除方法区改为使用本地内存实现名字叫“元空间”Metaspace。这个改变最大的意义在于方法区不再受堆大小的限制默认情况下只受本机可用内存总量的约束。以前永久代容易出现java.lang.OutOfMemoryError: PermGen space在动态生成大量类的场景下特别常见但JDK 8之后这个报错基本消失了换成了Metaspace相关的OOM。还有一个点容易被忽略运行时常量池Runtime Constant Pool也是方法区的一部分。你写的每一个字符串字面量、被final修饰的常量值、类中引用的符号引用都存放在这里。字符串常量池虽然在概念上和运行时常量池是分开的但底层存储也和方法区/堆的演进有关这里不再展开。3. 堆内存的精细划分与对象的完整一生理解了五大区域的职责之后接下来的核心内容就是把堆内部的结构和对象从创建到回收的完整流程讲透。这部分直接关系到GC参数怎么设置、线上排查日志怎么看。3.1 新生代Eden区和两个Survivor区的分工新生代是对象生命周期的起点。几乎所有新创建的对象都会先分配到新生代的Eden区伊甸园区。为什么新生代要分成Eden区和Survivor区这个问题经常有人问。设想一下如果只有一个独立区域GC来了只能把存活对象全部移动到老年代老年代很快就满了GC频繁发生性能崩盘。有了两个Survivor区一般简称S0、S1GC时可以先把Eden区和其中一个Survivor区里存活的对象一起复制到另一个空的Survivor区然后一次性清空Eden区和原来那个Survivor区。这样就能在新生代内完成对象的“筛选”只有经过多轮GC仍然存活的对象才被晋升到老年代。Eden区和Survivor区的默认比例可以通过-XX:SurvivorRatio调整。默认值大约是8意思是Eden区占新生代的80%两个Survivor区各占10%。这里有一个新手容易踩的误区Survivor区并不是越大越好。如果Survivor区过大Eden区就会变小新对象频繁触发Minor GC如果Survivor区过小对象还没等到“足够老”就被迫晋升到老年代老年代压力变大。默认8:1:1是经过大量测试后的经验值没有特殊场景不建议乱动。3.2 对象晋升从Eden到老年代要闯哪些关一个对象从创建到被回收或者走到老年代大致经历这样的过程对象通过new创建后优先分配在Eden区。如果有大对象——比如一个很大的数组或字符串——JVM会尝试直接分配在老年代避免复制带来的开销。这就是“大对象直接进入老年代”的规则。Eden区空间不足时触发Minor GC。JVM从Eden区和其中一个Survivor区出发沿着引用链找到所有存活对象复制到另一个空的Survivor区。复制的同时对象年龄加1。每次Minor GC存活对象年龄加1。默认情况下年龄达到15可通过-XX:MaxTenuringThreshold调整的对象晋升到老年代。当Survivor区中相同年龄的对象总和超过Survivor区空间的一半时年龄大于等于这些对象年龄的对象会直接晋升到老年代。这个机制叫“动态年龄判定”目的是为了避免Survivor区中某些年龄组被长期占用。上面这个流程如果越来越多对象进入老年代说明新生代装不下了或者Survivor区的“筛选”能力不足。此时老年代会触发Major GC或者叫Full GC。Full GC的STW时间远比Minor GC要长是性能优化重点关注的指标。3.3 常用堆参数的配置逻辑堆参数不是越大越好关键要匹配业务特征。# 堆大小 -Xms2g # 初始堆大小 -Xmx2g # 最大堆大小 -Xmn1g # 新生代大小老年代将自动为剩余空间 -XX:NewRatio2 # 老年代与新生代的比值 -XX:SurvivorRatio8 # Eden与Survivor的比值 -XX:MaxTenuringThreshold15 # 晋升老年代的年龄阈值对于大多数Java服务-Xms和-Xmx建议设为相等。否则JVM在堆空间不足时会尝试扩展堆扩展过程有额外开销线上响应可能抖动。新生代大小直接影响Minor GC的频率和Full GC的压力。如果业务特征是“大量短生命周期对象”新生代偏大会让Minor GC频率降低如果业务里有大量常驻对象新生代太大反而导致老年代偏小Full GC频次增加。修改参数后建议通过jstat -gc观察各区域的使用率和GC次数变化而不是凭感觉调。4. 内存报错的排查实战说到JVM内存结构不能不提那些让人头皮发麻的内存错误。很多开发第一次被线上问题搞得焦头烂额就是从一条OutOfMemoryError开始的。下面结合我自己排查过的真实场景来聊。4.1 OutOfMemoryError: Java heap space这是最最常见的内存报错意思是堆空间不足新对象无法分配到堆内存。排查路径一般是这样用jstat -gcutil pid 1000看各区域使用率判断是新生代还是老年代被占满。用jmap -dump:formatb,fileheap.hprof pid导出堆快照。用MATMemory Analyzer Tool或VisualVM分析快照找出占用最大的对象和引用链。我遇到过一个典型案例一个定时任务里用了Executors.newFixedThreadPool但队列用的是无界队列任务生产速度远超消费速度队列里不断堆积任务对象最终将堆撑爆。这种问题从堆快照里看非常明显——找到那个占了几GB的对象顺着引用链就能看到积压队列。注意jmap导出堆快照时JVM会暂停服务。线上操作前要评估影响最好在低峰期执行或者用jmap -histo:live这种较轻量的方式先做粗筛。4.2 OutOfMemoryError: MetaspaceJDK 8之后动态生成类比如大量的CGLIB代理、热加载class导致元空间耗尽时会出现Metaspace溢出。排查思路分两步第一步确认是不是元空间真的满。用jstat -gc pid看看M列Metaspace使用率和MUMetaspace已用空间是否接近上限。第二步要查代码哪里在无限生成类比如循环里创建代理类、动态编译脚本等场景。预防手段就是给元空间设置一个上限避免无限制增长拖垮整个进程-XX:MetaspaceSize256m -XX:MaxMetaspaceSize512m-XX:MetaspaceSize并不是初始大小而是触发元空间GC的阈值。当元空间使用量达到这个值JVM会触发Full GC来尝试回收废弃类。所以不要惊讶为什么没到最大值就频繁Full GC——那很可能是因为触发了元空间阈值GC。4.3 StackOverflowError递归的噩梦递归调用没有正确退出条件时栈帧不断入栈最终把线程栈撑爆抛出StackOverflowError。这个问题定位很简单——堆栈信息会直接告诉你递归调用发生在哪一行。难的是如何避免。我见过太多同学在递归实现里忘记处理边界条件或者递归深度本身很大比如遍历深层JSON树然后试图通过调大-Xss来“解决”。这里必须说明调大-Xss只能缓解症状如果业务逻辑确实需要很深的递归更合理的做法是改成循环显式栈或者用尾递归优化。调大-Xss带来的副作用是减少了线程数量上限——每个线程占用更多内存-Xss从512KB调到2MB相同内存下线程数就少很多。4.4 Gradle构建报错expiring daemon because JVM heap space is exhausted构建工具也会因为内存问题报错。网上流传最广的一条Gradle报错是expiring daemon because JVM heap space is exhausted这个报错的意思是Gradle后台守护进程Daemon的堆空间耗尽它会自动过期并尝试重新启动。很多人遇到这个报错第一反应是把gradle.properties里的org.gradle.jvmargs-Xmx往上加。这种做法有点道理但要注意一个细节如果你在gradle.properties里设置的堆内存太大而你的开发机总内存有限其他进程会被挤爆反而更不稳定。我个人处理这种问题的一般顺序先把org.gradle.jvmargs-Xmx2g这类配置调到一个合理的值同时配合-XX:MaxMetaspaceSize。检查是否有多个Gradle守护进程同时存在./gradlew --stop清理一下旧进程。用jmap -dump看守护进程的堆里到底堆了什么。很多时候不是堆太小而是某个插件或任务在构建期缓存了大量数据比如某些Android工程的dex缓存。4.5 内存问题排查速查表报错信息对应区域常见原因排查工具Java heap space堆对象分配过多/内存泄漏jstat, jmap, MATMetaspace方法区元空间动态生成类过多jstat, MATStackOverflowError虚拟机栈递归过深堆栈日志GC overhead limit exceeded堆GC频繁且回收效果差jstat, GC日志Direct buffer memory直接内存NIO使用过多代码审查5. JVM调优的实践思路从内存结构出发很多同学问JVM调优到底应该怎么入门。我的看法是不要背参数而是回到内存结构本身去思考问题。堆不够就加堆元空间爆了就查类加载Full GC频繁就看老年代对象来源。每一类问题都有对应区域可以排查这就是内存结构知识的实操价值。5.1 收集信息先于一切调优动作调优的第一步不是改参数而是判断“现在的瓶颈到底在哪”。最常用的信息收集命令我列一下# 查看Java进程 jps # 查看堆各区域使用情况和GC次数 jstat -gcutil pid 1000 # 查看当前JVM参数 jinfo -flags pid # 在线查看线程状态 jstack pid # 导出堆快照 jmap -dump:formatb,fileheap.hprof pidjstat -gcutil输出里的几个关键列EEden使用率、S0/S1Survivor区、O老年代使用率、M元空间使用率、FGCFull GC次数、FGCTFull GC累计耗时。如果FGC频繁且FGCT很高核心问题就是老年代空间不足或者元空间触发GC过频繁。5.2 按场景选择GC回收器JVM内存结构是GC的基础不同GC回收器本质上就是在不同区域上用不同策略做回收。Serial GC单线程回收适合客户端应用在小内存场景下STW短。Parallel GCJDK 8默认回收器多线程并行回收关注高吞吐量。CMS老年代回收器并发标记清除STW比Parallel短JDK 9后不推荐。G1JDK 9及以后的默认回收器将堆划分为多个Region按Region收集兼顾停顿时间和吞吐量。ZGC低延迟回收器暂停时间控制在毫秒级适合超大堆场景。选型时先想清楚业务到底在意吞吐量还是响应时间。批处理任务在意吞吐量Parallel很有优势在线业务在意响应时间G1或ZGC更合适。有一个常见的调优误区是盲目追求“最强回收器”。如果你在JDK 8上用G1就需要显式加上-XX:UseG1GC。如果你的服务GC停顿本身就不痛不痒完全没必要折腾回收器优先解决对象分配逻辑才是正道。5.3 面试中关于JVM内存结构的高频问题这部分顺便回答热搜里出现的高频问题。其实面试官最爱问的翻来覆去就那几个角度“说一下JVM内存结构”——对应本文第2章五大区域的职责、线程私有/共享、异常类型。“JVM内存模型JMM和JVM内存结构有什么区别”——这是一个高频混淆点。JMM是Java内存模型的缩写讨论的是多线程下共享变量的可见性和有序性规则与内存结构是两回事。回答时先声明“这两者不是一个维度上的概念”再分开讲面试加分。“对象在堆内存中是怎么分配和晋升的”——对应本文第3章重点是Minor GC、Survivor、年龄阈值、动态年龄判定。“JRE和JVM之间是什么关系”——JRE是Java运行时环境包含JVM和Java核心类库JVM是JRE中的核心部分负责运行字节码。JREJVM类库JDKJRE开发工具。“JVM有哪些垃圾回收器怎么选”——对应本文5.2节。“遇到过哪些内存问题怎么排查”——对应本文第4章如果能结合一个线上真实案例讲面试效果远好于背概念。可能有人觉得“内存结构”是很基础的知识不值得写一篇文章。但我个人观点恰恰相反基础的东西如果理解得足够深线上排查和JVM调优时底气会完全不一样。拿我自己来说很多看似复杂的GC调优问题拆到底就是对象在哪个区域、该不该晋升、什么时候回收这几个问题的组合。最后分享一个小习惯给自己维护的每一个Java服务都配齐-Xlog:gc*或者-verbose:gc日志并且加上时间戳。出了内存问题GC日志配合堆快照一起看定位速度会快非常多。等你亲手用这套方法解决过几次线上OOM再回头去看那些面试题会发现它们不过就是平时干活的基本功而已。