ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

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

JVM内存模型详解:从运行时数据区到OOM排查实战 几个月前我帮一个朋友排查线上服务现象很典型接口响应偶尔飙到几十秒CPU不高但日志里Full GC刷屏。他第一反应是“内存不够”把堆从4G加到8G结果Full GC反而更频繁了。我用jstat拉了一下数据才发现对象在新生代里反复复制迟迟升不上去根因是某个静态变量长期持有大量Map数据。说白了这就是没把JVM内存模型搞明白调优就像盲人摸象。JVM内存模型JVM Memory Model这个词写Java的人多少都听过。但要真被问到“一个对象从创建到回收到底经历了什么”能讲清楚的人其实不多。这篇文章不想照搬《深入理解Java虚拟机》也不准备罗列一堆面试题让你背而是结合我这些年调优、排障、做面试官的实际经验把JVM内存模型拆开揉碎运行时数据区怎么划分、对象是怎么分配和晋升的、内存参数该怎么配、线上OOM该怎么查。读完你至少能做到一件事——拿到一份堆转储文件不会慌。适合谁读刚入行的Java后端、准备跳槽刷JVM面试题的同学还有那些深夜被线上FGC和OOM折磨的值班工程师。就算你写的是Kotlin、Scala、Groovy只要跑在JVM上底层规则完全一样这篇文章同样适用。1. 运行时数据区全景JVM把内存拆成了哪几个板块要理解JVM内存模型第一步是搞清楚JVM执行Java程序时到底把内存划分成了哪些区域。JVM规范把这些区域统称为“运行时数据区”其中有的线程共享有的线程私有。理解这个划分是后面所有调优和排障的地基。1.1 程序计数器线程的“阅读进度条”程序计数器是运行时数据区里最小、也最不起眼的一块但它的问题在面试中经常出现。它是线程私有的记录当前线程正在执行的字节码指令地址。多线程靠时间片轮转来切换线程被切走再切回来时必须知道上次执行到了哪一行字节码才能继续往下走。这就好比你看书被叫去干别的事回来之后得靠书签找到刚才那页。它也是规范里唯一没有定义OutOfMemoryError的区域因为每个线程的计数器只需要很小的空间基本上不参与内存容量博弈。有个细节容易忽略当线程执行的是native本地方法时程序计数器是空值因为这时候执行的不再是JVM字节码。1.2 Java虚拟机栈每个方法的“工作台”虚拟机栈也是线程私有的生命周期和线程一致。每调用一个方法JVM就会创建一个“栈帧”压入栈中方法执行完毕栈帧弹出。栈帧里装着四样东西局部变量表、操作数栈、动态链接、方法返回地址。局部变量表最小单位是slotlong和double类型的变量会占用两个slot其他类型占一个。这也是为什么面试常问“一个对象引用占多大”——在栈上它就是一个slot真正对象数据还在堆里。操作数栈可以理解成JVM的“计算草稿纸”字节码指令做加减运算、方法调用时中间结果都在这块区域里临时存放。日常开发中这个区域最常见的报错是StackOverflowError。典型场景就是递归没有终止条件栈帧不停入栈最终把栈深度撑爆。注意这通常不代表机器内存不够而是代码层面的问题。栈大小可以用-Xss参数控制Linux环境下默认值一般在1M左右调小了能支持更多线程但调太小容易导致方法调用层级稍微深一点就爆栈。1.3 本地方法栈本地方法栈和虚拟机栈结构类似区别在于它服务于native方法。HotSpot虚拟机在实现时把虚拟机栈和本地方法栈合二为一所以平时我们排查看到的线程栈里native方法的信息也在同一份栈里。这块区域在纯Java开发中几乎碰不到但面试时问你“JVM运行时数据区图怎么画”不能漏掉它。它同样会抛StackOverflowError和OutOfMemoryError。1.4 Java堆GC的主战场堆是JVM内存模型中面积最大的一块所有线程共享几乎所有的对象实例和数组都在这里分配。这一句话就足以说明它为什么是垃圾回收的主战场也是OOM最常出现的地方。从垃圾回收角度JVM把堆进一步划分成新生代和老年代。新生代里又细分为Eden区和两个Survivor区默认比例是Eden占80%两个Survivor各占10%可以通过-XX:SurvivorRatio调整。为什么要分代因为绝大多数对象都是“朝生夕死”比如接口里创建的临时对象、循环里的局部对象。把短命对象集中放在新生代用复制算法清理起来效率高把长期存活的对象移到老年代用标记整理或标记清除算法处理避免频繁复制大对象。堆的大小由两个参数控制-Xms指定初始堆大小-Xmx指定最大堆大小。堆可以处于物理上不连续、逻辑上连续的内存空间但为了避免运行期频繁扩容缩容带来停顿生产环境通常直接把两者设为一致。1.5 方法区与元空间方法区是JVM规范里的逻辑概念主要存放类元信息、常量、静态变量、即时编译器编译后的代码缓存。注意这里提到的“类元信息”和堆里的“对象实例”是两回事对象是类的实例类本身的信息则在方法区。在HotSpot的实现里方法区在JDK 8之前叫“永久代”JDK 8之后改成了“元空间”。这个改动非常关键永久代在JVM堆内大小受到堆上限约束频繁加载和卸载类时很容易触发java.lang.OutOfMemoryError: PermGen space。JDK 8之后元空间直接使用本地内存不再占用堆空间只要操作系统内存够就不会因为类元数据导致堆OOM。这里有一个非常常见的坑元空间默认不设置上限MaxMetaspaceSize如果不配它的容量只受物理内存限制。如果一个应用反复动态生成类比如CGLIB代理、反射、热部署类元数据就会持续积累最后把整个物理内存都吃满。所以在生产环境我强烈建议显式设置-XX:MaxMetaspaceSize。1.6 运行时常量池运行时常量池是方法区的一部分存放编译期生成的字面量和符号引用比如字符串字面量、final常量、类和方法的符号引用。类加载后这些常量会从Class文件的常量池表加载进来。与Java开发关系最密切的是String.intern()方法。JDK 7之前字符串常量池放在永久代JDK 7之后移动到了堆中。这个位置变迁会带来一个直接后果intern的字符串现在可以被垃圾回收了。以前永久代里的intern字符串无法回收很容易累积成内存泄漏现在放到堆里反而更容易被管理。面试经典题“new String(abc)到底创建了几个对象”答案就和运行时常量池有关如果常量池里还没有“abc”一次会创建两个对象一个是堆上的String实例一个是常量池里的字面量对象如果已有则只创建一个堆上对象。虽然这道题有点“应试”但背后考的就是运行时常量池的位置和作用。提示把运行时数据区按“线程私有/线程共享”画成一张图是JVM面试最基本的动作。程序计数器、虚拟机栈、本地方法栈是私有的堆、方法区、运行时常量池是共享的。这张图不仅要会画还要能讲清楚每块区域负责什么、什么情况下报错。2. 对象的一生创建、布局、晋升与回收判定搞清楚了内存区域划分接下来看核心问题一个对象从new开始到被回收JVM到底做了什么。很多人调优调不明白就是因为脑子里没有这条完整的对象流动路径。2.1 new一个对象时JVM做了什么new一个对象表面上只是编译器的一条字节码指令执行时却要经历五个环节类加载检查、分配内存、初始化零值、设置对象头、执行构造方法。类加载检查是要确认类已经被加载、解析和初始化过没有则先触发类加载。分配内存就是在堆上找一块空闲空间具体方式取决于垃圾收集器是否带压缩整理功能如果收集器会整理内存碎片堆内存是规整的分配用“指针碰撞”把指针向空闲方向挪动即可如果收集器不做整理比如CMS堆内存碎片化就需要维护一张“空闲列表”来记录可用块。并发环境下多个线程同时在堆上分配对象必须解决指针碰撞的竞争问题。HotSpot有两个方案CAS加失败重试以及TLAB线程本地分配缓冲。TLAB的思路是给每个线程在新生代划一小块私有空间对象先在本地空间分配不争抢全局指针本地空间不够了再走CAS逻辑。默认-XX:UseTLAB是开启的这也是为什么我们说“大多数对象在堆上分配”但实际很多小对象是在线程私有的TLAB区域里创建的。还有一个参数值得了解-XX:PretenureSizeThreshold。它用于控制大对象直接在老年代分配避免大对象在Eden和Survivor之间反复复制。但这个参数只在Serial和ParNew这类收集器下有效用G1或者CMS的时候并不一定能按预期工作网上文章很多都是照抄老版本的结论用之前先确认自己的收集器类型。2.2 对象内存布局一个Java对象在堆里的内存布局分为三部分对象头、实例数据、对齐填充。对象头是理解JVM锁和GC的关键。它至少包含两部分Mark Word和类型指针。Mark Word存储对象自身的运行时数据比如哈希码、GC分代年龄、锁状态标志。锁状态非常有意思同一个对象的Mark Word会随着并发情况改变从无锁到偏向锁、轻量级锁、重量级锁状态不同Mark Word的位布局也不同。类型指针指向类元数据JVM通过它来确认这个对象是哪个类的实例。在64位虚拟机上类型指针默认使用压缩指针即-XX:UseCompressedOops开启引用占用从8字节压缩到4字节。这意味着在堆大小不超过32G的情况下可以节省大量内存。如果堆超过32G压缩指针会失效即使你物理内存足够大也可能因为引用变大而让可用对象空间反而变小。这个知识点调优时会直接遇到。实例数据部分存放对象真正的字段值。HotSpot有字段分配顺序的规则相同宽度的字段会被分配在一起比如long/double、int、short/char、byte/boolean、引用类型依次排列这样有助于内存对齐和访问效率。最后是对齐填充HotSpot要求对象起始地址必须是8字节的整数倍对象头加实例数据不足8字节对齐时用填充补齐。2.3 分代回收与对象晋升一个对象刚创建时进入新生代的Eden区。Eden区满了触发Minor GC。存活对象会被复制到Survivor区同时GC分代年龄加1。这里有两个Survivor区每次Minor GC后From区和To区互换保证始终有一块空白区域用来容纳复制来的存活对象。复制算法的优势是不会有内存碎片代价是必须预留一块空闲区。对象每次熬过一次Minor GC年龄就加1。默认当年龄达到15可通过-XX:MaxTenuringThreshold调整就会被晋升到老年代。但实际还有一个动态年龄判定机制如果Survivor中某个年龄对象的累计大小超过Survivor空间的一半JVM会把年龄大于等于该值的对象一起晋升而不必等到阈值15。这样能避免Survivor空间被长期存活对象占满。晋升还有一个前置条件叫空间分配担保。Minor GC之前JVM要检查老年代最大可用连续空间是否大于新生代所有对象的总大小。如果成立这次Minor GC可以安全进行如果不成立要看是否允许分配担保失败。在JDK 6u24之后担保规则简化了只要老年代连续空间大于新生代对象总大小或历次晋升的平均大小就继续Minor GC否则改为Full GC。担保机制的本质是用老年代空间兜底防止Minor GC后大量对象因Survivor放不下而直接晋升时老年代装不下。2.4 对象死亡判定与四种引用垃圾收集器回收对象前要先判断对象是否已死。最直观的想法是引用计数法每个对象维护一个计数器被引用就加1失效就减1。但引用计数法有一个经典的bug——循环引用。对象A引用B对象B引用A计数器永远不为0这两个对象就永远无法被回收。JVM主流语言采用的是可达性分析。从一系列称为GC Roots的根对象出发向下搜索引用链不可达的对象就是可回收对象。GC Roots包括栈帧中局部变量引用的对象、静态变量引用的对象、JNI本地方法引用的对象、被synchronized锁持有的对象、JVM内部的系统类对象等等。在Java层面还有四种引用类型它们的回收时机是面试题里的“钉子户”强引用new出来的对象JVM宁可抛OOM也不会回收它。软引用内存足够时不回收内存不足时回收适合做缓存。弱引用下一次垃圾回收时必然被回收WeakHashMap就是例子。虚引用不影响对象生命周期主要用于对象被回收后收到一个系统通知比如堆外内存的回收。这里有个实际场景很有代表性ThreadLocal的key是弱引用value是强引用。线程池长期存活时ThreadLocal对象被回收了但value还挂在ThreadLocalMap里无法访问就会泄漏。内存模型学到最后这些细节都是串在一起的。2.5 逃逸分析与栈上分配对象就一定要在堆上分配吗并不绝对。JIT编译器做逃逸分析时如果发现一个对象只在方法内部使用、没有被方法外部引用就可能做优化。优化的方式有两种栈上分配和标量替换。栈上分配就是让对象直接在虚拟机栈里分配方法结束栈帧弹出对象随之销毁完全不经过垃圾回收。标量替换更激进如果对象没有逃逸就把对象拆成若干个标量字段直接在方法的操作里使用局部变量替代对象本身就不用创建了。JDK默认开启逃逸分析但从实际效果看HotSpot目前主要依赖标量替换栈上分配因为复杂度的原因还不算普遍。这也是为什么我们常说“JVM能自动优化一些new出来的临时对象”但不要指望所有对象都能栈上分配。3. JVM内存参数配置与调优实操内存模型的最终落点通常是在线上配置参数、调节GC表现。下面这套参数和流程是我在实际项目里反复用过的可以直接抄作业但要明白每个参数背后的理由。3.1 内存参数速查表先把最常用的一组参数列出来参数作用常见做法-Xms堆初始大小生产环境通常等于-Xmx-Xmx堆最大大小根据服务和机器内存综合评估-Xmn新生代大小约为堆的1/3到1/2-XX:NewRatio老年代与新生代比例默认2即老年代:新生代2:1-XX:SurvivorRatioEden与Survivor比例默认8Eden占80%-XX:MaxMetaspaceSize元空间上限建议显式设置防止动态生成类撑爆内存-XX:MaxDirectMemorySize直接内存上限NIO场景需要关注默认等于-Xmx-XX:HeapDumpOnOutOfMemoryErrorOOM时自动导出堆快照线上服务强烈建议开启-XX:UseG1GC选择G1垃圾收集器JDK 9之后为默认JDK 8可显式开启3.2 从默认值说起为什么你的JVM“吃”不了更多内存很多人以为机器32G内存Java程序就能用32G。实际上如果没有显式配置HotSpot默认的最大堆容量大约只有物理内存的1/4初始堆大小还要低得多。一台32G的机器默认-Xmx可能只有8G。我用32G内存的老笔记本跑本地小模型和数据处理程序时经常有人问我怎么内存不够我第一句话就是先看-Xmx设置了多少。默认值设计得保守是有原因的服务端一台机器可能同时跑多个Java服务JVM无法知道自己是唯一用户预留内存更安全。但在现代容器和专用服务器环境下保守默认值反而会让JVM性能受限。建议在启动脚本里显式设置内存参数不要依赖默认值。参数怎么定没有万能公式但有基本逻辑。给一个电商中台普通订单服务的参考配置java -Xms4g -Xmx4g -Xmn2g \ -XX:MetaspaceSize256m -XX:MaxMetaspaceSize512m \ -XX:UseG1GC \ -XX:MaxGCPauseMillis200 \ -XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/data/logs/dump \ -jar order-service.jar这里有几个决策点-Xms和-Xmx都设为4g是为了避免运行期堆扩容缩容带来停顿-Xmn设为2g给新生代足够的空间容纳短命对象减少Minor GC频率MetaspaceSize和MaxMetaspaceSize分开设置前者是初始阈值后者是硬上限防止CGLIB等动态代理把元空间打满HeapDumpOnOutOfMemoryError启用后一旦OOM会自动生成堆转储这比事后猜测要高效得多。还需要注意JVM堆只是进程内存的一部分线程栈、元空间、直接内存、JIT编译器缓存也都占物理内存。假如机器只有8G你给堆设6G再开几百个线程元空间一涨很容易触发底层的内存交换甚至OOM Killer。堆大小永远要结合整个进程的内存预算来定。3.3 观察工具与GC日志参数配完之后不能只看不观察。JDK自带的工具已经足够日常使用我平时用得最多的是这几个jstat -gcutil 1000每秒打印一次GC统计信息看S0/S1/Eden/Old/Metaspace的占用百分比以及YGC、FGC次数和GC总耗时。jmap -heap 查看堆参数配置和各代空间使用情况。jmap -dump:live,formatb,file/data/dump.hprof 导出堆快照用于离线分析。jcmd GC.heap_dump新版JDK推荐用法比jmap更不容易被安全策略拦。jstat输出的S0/S1/E/O/M这几列能够快速判断对象主要在哪个区域堆积。比如Eden反复被占满、YGC频率很高说明对象创建速率本身很高如果Old区持续增长、FGC越来越频繁通常指向有对象长期占用内存。GC日志同样重要。JDK 8里常用的启动参数是-verbose:gc -XX:PrintGCDetails -XX:PrintGCDateStamps -Xloggc:/data/logs/gc.logJDK 11开始日志参数简化了-Xlog:gc*:file/data/logs/gc.log:time,uptime,level,tags我摘一段简化的GC日志看结构[GC (Allocation Failure) [PSYoungGen: 870912K-84618K(874496K)] 870912K-90012K(1794048K), 0.082 secs]这一行的意思是新生代发生了一次Minor GC回收前870MB回收后剩余84MB新生代总容量874MB整个堆回收前870MB回收后90MB堆总容量1.79GBGC停顿82毫秒。一次典型的MinorGC大部分对象被回收停顿在毫秒级这是健康表现。如果停顿时间经常超过几百毫秒或者FGC列增长很快就说明内存状况有问题。3.4 一次典型调优流程的记录我以一个真实项目为例讲讲完整调优流程。服务是一个订单模块高峰期YGC每两秒一次FGC每五分钟一次接口RT曲线和FGC时间点高度重合。第一步拉jstat数据看各区域水位。发现Eden区每轮都接近占满Old区在FGC后能降下来但很快又涨上去说明有对象从新生代持续流入老年代。第二步用jmap导出堆转储用MAT打开分析Dominator Tree找到老年代里最大的几个对象发现某个订单详情大Map被缓存进了静态字段高峰时期高并发写入Map膨胀后不断把数据撑进老年代。第三步修复代码把静态缓存换成Caffeine本地缓存设置最大条数和写入后过期时间。第四步才调整参数堆统一到4G新生代给2G元空间设上限。结果很直接YGC间隔从两秒延长到十几秒FGC从五分钟一次降到几小时一次接口P99显著回落。这件事给我的经验是参数只是兜底调优的核心其实是减少垃圾的产生。如果代码本身不断创建大对象、长期持有引用堆配再大也只是延迟爆炸时间。4. 高频问题与排查实录JVM内存模型相关的知识最终要落到两个现实场景线上OOM排查和面试问答。这里把最高频的问题整理成速查形式。4.1 四种典型的OOM与排查方向报错信息直接原因排查方向Java heap space堆内存不足对象占用超出-Xmx导出堆转储找大对象和引用链GC overhead limit exceededJVM花费98%以上时间做GC却回收不到2%堆内存通常意味着内存泄漏或者堆太小优先看代码持有引用Metaspace元空间被动态生成的类占满检查CGLIB代理、反射、热部署类加载器Unable to create new native thread线程数达到操作系统限制或内存不足降低线程池上限排查线程泄漏Direct buffer memory直接内存使用超过MaxDirectMemorySize检查NIO堆外Buffer是否释放Metaspace的OOM在JDK 8之后反而容易被人忽略因为元空间不占堆内存堆没满但类元数据已经吃掉了大量本地内存现象表现为进程整体内存持续上涨。遇到这种情况优先看代码里是否反复生成代理类、是否创建了自定义类加载器且没有销毁。4.2 一次堆内存溢出排查实录有一回压测环境做500并发测试服务跑二十分钟左右就开始偶发超时日志里出现java.lang.OutOfMemoryError: Java heap space。我第一反应不是去改-Xmx而是先看GC曲线。用jstat连上去之后FGC次数几乎一分钟涨一次每次FGC后Old区回收效果很差这说明内存对象回收不掉而不是单纯不够用。由于OOM会杀掉进程我提前把启动参数加上-XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/data/dump复现后果然在指定目录生成了hprof文件。用MAT打开后直接看Dominator Tree最大的一个对象是HashMap里面有大量userId到订单详情的映射顺着引用链往上找发现是一个static Map在接收请求时不断put数据既没有过期也没有容量上限。修复方案分两步代码上把static Map改成Caffeine缓存设置maximumSize和expireAfterWrite同时顺手把启动参数里的堆统一到4G、元空间设置上限。之后同样压测跑了一小时FGC次数从两位数降到个位数接口超时消失。这个案例里所有排查动作都能对应到前面讲的区域对象在堆上累积、GC无法回收、老年代膨胀根因是静态引用链把对象变成了GC Roots可达对象。4.3 高频面试题速查下面这些是我面试Java候选人时反复问的问题也是网上讨论度最高的JVM内存模型相关题目运行时数据区有哪些哪些线程私有答案程序计数器、虚拟机栈、本地方法栈私有堆、方法区共享。new一个对象经历过哪些步骤答案类加载检查、分配内存、初始化零值、设置对象头、执行构造方法。内存分配方式什么时候用指针碰撞什么时候用空闲列表答案取决于GC是否带压缩整理功能。怎么判断对象可回收答案可达性分析GC Roots列表要能说出几个典型来源。哪些情况会触发Full GC答案老年代空间不足、元空间不足、System.gc()、分配担保失败。新生代为什么要有两个Survivor答案复制算法需要一块空白区域避免内存碎片。逃逸分析做了什么答案判断对象是否逃逸出方法配合标量替换减少堆内存分配。线上OOM怎么排查答案先看GC日志和jstat确定增长区域再导出堆转储分析引用链最后回代码找根因。这些问题背后全是内存模型的延伸。能把“对象进入老年代”这条链路讲清楚远比背一个GC参数有说服力。5. 写在最后几点实操体会JVM内存模型这套东西我在实际项目里摸爬滚打了好几年有些话想在最后说一说。第一个体会不要一开始就背参数先建立“对象从哪里来、到哪里去”的心智模型。遇到线上问题先画一遍数据区再对照GC日志自然就知道该看哪里。我见过太多同事一上来就改堆大小结果问题根本没解决。第二个体会调优之前先明确目标。你要优化的是GC停顿时间还是吞吐量还是避免OOM目标不同参数方向可能完全相反。永远不要为了调优而调优。第三个体会生产环境提前把观测能力打好。GC日志要开HeapDumpOnOutOfMemoryError要配关键指标的监控面板要看这些是排障的基础设施不是出了问题之后再补的。第四个体会学习JVM最好的方式是在本机跑一个小程序配上不同的堆参数用jstat、Arthas和GC日志去观察它的行为。你很快就会发现那些密密麻麻的日志其实每一行都有含义。多踩几次坑这些东西才能真正变成你自己的判断力。
返回列表