ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

JVM内存结构详解:从运行时数据区到OOM排查实战

JVM内存结构详解:从运行时数据区到OOM排查实战 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通常是STWStop The World暂停所有业务线程的停顿时间一长接口RT立马飙升。堆的几个关键参数-Xms堆初始大小比如-Xms512m-Xmx堆最大大小比如-Xmx4g-Xmn年轻代大小-XX:SurvivorRatioEden和单个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:MinMetaspaceFreeRatioGC后元空间空闲空间的最小比例用于控制是否要缩容我见过一个典型的线上事故一个用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) { Listbyte[] 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:MetaspaceSize16m -XX:MaxMetaspaceSize64m跑一会儿就会看到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的妙用内存结构不只是看堆线程栈也很重要。你想看当前每个线程在干什么、栈里有哪些调用用jstackjstack pid有一次我们线上服务出现某个线程CPU跑满我拿到线程号printf %x\n 线程id转成十六进制然后在jstack输出里搜这个十六进制瞬间定位到是某个图片压缩库的死循环。整个过程不到两分钟比在代码里盲猜快太多了。这里顺便说一个思维习惯排错时先想“内存分区”再想“工具”。堆的问题用jmap、jcmd栈的问题用jstackGC频率问题用jstat综合性能排查用VisualVM或Arthas。每种工具对应一类分区选对工具问题就解决了一半。4.5 JVM参数设置的最佳实践参考我把自己常用的JVM参数模板整理出来了供你参考核心是思路具体数值看场景调整-Xms4g -Xmx4g -Xmn2g -XX:SurvivorRatio8 -XX:MaxTenuringThreshold15 -XX:UseG1GC -XX:MaxGCPauseMillis200 -XX:MetaspaceSize256m -XX:MaxMetaspaceSize512m -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:HeapDumpOnOutOfMemoryErrorOOM后分析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内存坑。
返回列表