JVM内存模型深度拆解:JMM与运行时数据区,一篇文章彻底厘清 前几天帮一个团队做线上JVM排查午休时一个小伙子问我JVM内存模型到底是指堆和栈的划分还是指多线程那个可见性模型他说面试题背了不少可一旦被问到 volatile 和堆扯上关系就彻底分裂了。我当时就意识到这可能是很多Java开发都绕不过去的一个坎。这篇文章想把这个事彻底理顺。不管你是正在准备面试、遇到线上OOM不知道怎么下手还是单纯想弄明白JVM那块内存到底是怎么运作的都可以继续往下看。我尽量用平时排查问题、调参压测时的真实视角来讲而不是背教材章节。先说结论JVM的内存模型其实是两个层次的东西叠在一起。一个是语言规范层面的Java Memory ModelJMM管多线程之间的数据可见性和有序性另一个是真实运行时划分的内存区域管对象放哪里、回收从哪里开始。两者经常被网文混在一起讲但它们的职责完全不同。1. 先把两个“内存模型”分开JMM与运行时数据区1.1 被网文搞混的Java Memory ModelJMM全称是Java Memory Model很多人一听“内存模型”四个字下意识就以为是堆内存、栈内存那张图。实际上JMM是Java语言规范里定义的一套抽象规则目的是解决多线程环境下共享数据的读写一致性问题。它的核心设定很简单每个线程有自己的工作内存里面保存的是主内存中变量的副本。线程不能直接操作主内存中的变量只能先读到工作内存修改后再写回主内存。这个“读-改-写”的过程如果没有任何约束就可能出现经典的不可见问题——线程A改了值线程B读到的还是旧值。JMM为此定义了一组内存间交互操作比如lock、unlock、read、load、use、assign、store、write。这些操作两两配对规定了变量在主内存和工作内存之间怎么流转。更重要的是happens-before规则它保证了只要两个操作之间存在happens-before关系前一个操作的结果对后一个操作就是可见的。volatile关键字、synchronized、final的语义本质上都是在围绕这套规则做文章。你可以把JMM当成一份“多线程交通法规”它不关心路上跑的是自行车还是卡车只关心车辆之间怎么避让、谁先走谁后走。这块学不清楚线程安全类问题基本只能靠猜。1.2 运行时数据区是JVM自己的“城市布局”运行时数据区就完全是另一码事了。它是JVM在启动时向操作系统申请内存后按照规范划分出来的几个物理区域。Java堆、虚拟机栈、方法区、程序计数器、本地方法栈都在这张图上。如果说JMM是交通法规那运行时数据区就是城市的物理布局这段路是快速路那片是居民区还有一块是仓储物流中心。对象new出来之后到底住在哪个片区由运行时数据区决定多线程之间看到的变量新不新鲜由JMM决定。两者还有一个容易混淆的点JMM里的“主内存”和运行时数据区的“Java堆”并不是严格对应的。JMM的“主内存”是抽象概念泛指所有线程共享的内存数据区域而Java堆是真实存在的、存放对象实例的内存空间。网文里经常直接画一个框就写“主内存堆”这不太严谨容易把初学者的思路带歪。1.3 为什么这个区分会影响你排查问题区分不清楚这两个概念最直接的影响就是看不明白报错信息。比如你遇到一个并发bug两个线程反复读写同一个共享变量不管怎么加锁都觉得不对。这时候脑子里如果没有JMM就想不到去检查可见性和有序性相反你遇到OOM脑子里如果没有运行时数据区的分区概念就分不清是堆溢出还是元空间膨胀也不知道该去调哪个参数。我见过太多人查OOM第一反应就是拉大堆内存结果问题越来越严重。真正应该先做的是确认到底是哪一个区域溢出。比如java.lang.OutOfMemoryError: Metaspace跟堆大小没关系加-Xmx纯粹是添乱。所以下面这一章先把运行时数据区这块“地图”铺开一个一个区域讲清楚。这是排查一切内存问题的地基。2. 运行时数据区逐个拆解这些区域分别放什么、会出什么错2.1 线程私有的三块地PC寄存器、虚拟机栈、本地方法栈程序计数器PC Register是每一条线程自己有一份的小空间记录当前正在执行的字节码指令地址。它在JVM规范里是唯一不会出现OOM的区域因为容量需求天然很小。这个区域平时很少被提到但在看线程dump、分析CPU飙高时很有用——它告诉你线程当前执行到哪一行字节码。虚拟机栈Java Virtual Machine Stack是面试的重灾区。它的生命周期与线程相同每调用一个方法JVM就会压入一个栈帧方法返回栈帧弹出。栈帧里装着局部变量表、操作数栈、动态链接、方法出口等信息。如果线程请求的栈深度超过了虚拟机允许的最大深度会抛StackOverflowError。如果栈在动态扩展时无法申请到足够内存则可能抛OutOfMemoryError。生产环境里StackOverflowError常见于递归没有终止条件而栈OOM常见于线程数开得过多把进程的可用内存耗尽。本地方法栈Native Method Stack是为native方法服务的。HotSpot虚拟机直接把它和Java虚拟机栈合并在一起所以一般你不太能感知到它的存在但概念上仍然要记得它属于线程私有区域。2.2 线程共享的核心战场堆与方法区Java堆是JVM内存管理的最大区域被所有线程共享。几乎所有对象实例都分配在这里注意是“几乎”因为JIT的逃逸分析理论上可以让部分对象在栈上分配后文细说。堆内部还会细分出新生代、老年代新生代又分Eden区和两个Survivor区。这些分区是垃圾收集器实现分代回收的基础也是绝大多数内存调优的着手点。方法区Method Area在JDK 8之后有了本质变化。JDK 8以前它被实现在“永久代”结果因为永久代本身也有上限经常出现java.lang.OutOfMemoryError: PermGen space。JDK 8以后永久代被移除改成本机内存中的元空间Metaspace存放类元信息、运行时常量池、静态变量等数据。这里有个容易被忽视的坑字符串常量池在JDK 7时就被移到了Java堆中所以字符串大量拼接导致的OOM其实表现在堆上而不是元空间。动态生成类太多比如热部署、频繁使用反射生成代理类才会撑爆Metaspace。2.3 一个平台级别的认知进程内存不等于堆内存很多初学者以为设置了-Xmx就是限制了Java进程总体内存这是错的。Java进程的常驻内存由堆、元空间、线程栈、JIT编译代码缓存、GC回收辅助结构、DirectByteBuffer使用的堆外内存等共同组成。我去年处理过一个诡异案例某服务堆内存一直很健康老年代使用率只有三成但容器频繁被kill。后来用jcmd查了NIO Direct Buffer的使用量发现接收消息时不断申请DirectByteBuffer因为堆外内存不受-Xmx约束直接把操作系统内存打满了。所以排查内存问题先问一个问题我是看堆还是看进程两者差距可能很大。遇到直接内存相关的错误提示比如“Direct buffer memory”第一反应应该是看MaxDirectMemorySize参数而不是盲目调堆。2.4 面试常追问的区域异常对照表不同区域溢出报错信息和排查方向完全不同整理成一张表会更清晰。区域线程私有/共享主要内容典型异常排查方向程序计数器私有字节码执行地址无规范不定义OOM看线程dumpJava虚拟机栈私有栈帧、局部变量表、操作数栈StackOverflowError / OOM递归深度、线程数量本地方法栈私有native方法调用StackOverflowError / OOMnative层资源Java堆共享对象实例、数组、字符串常量池Java heap space对象泄漏、弱引用误用、堆参数方法区/元空间共享类元信息、运行时常量池、静态变量Metaspace动态生成类、热部署这张表背下来不算本事关键是遇到OOM时能第一时间把报错信息映射到对应区域。能走到这一步内存排查思路基本就通了一半。3. 一个对象从new到回收的一生3.1 对象分配时JVM在后台做的那几件事很多人以为new对象就是在堆里随便划一块内存出来实际流程远比这复杂。JVM拿到一条new指令后先做类加载检查如果当前类还没被加载、解析、初始化先触发类加载。在堆中分配原始内存空间。分配方式有两种——指针碰撞和空闲列表。如果堆内存规整用指针碰撞如果不规整用空闲列表。把分配到的内存空间初始化为零值这样实例字段不赋值也有默认值int是0boolean是false引用是null。设置对象头包含Mark Word存储哈希码、GC分代年龄、锁状态等、类型指针、数组长度如果是数组等。执行构造方法即 方法这时候对象才真正按开发者意图初始化。这一套流程里最容易看出问题的阶段是第2步。并发环境下多个线程同时分配内存如果都踩同一个指针位置就乱套了。HotSpot的解决方案之一是CAS加失败重试来保证指针碰撞操作的原子性。3.2 并不是所有对象都乖乖待在堆里教科书说“对象主要分配在堆上”注意是“主要”不是“全部”。HotSpot的JIT编译器有一个很关键的能力叫逃逸分析它会分析对象的作用域判断它会不会“逃逸”出当前方法。如果对象不会逃逸出线程或方法JVM可能做栈上分配让对象随栈帧弹出自动回收更激进一点还会做标量替换把对象的字段拆散到局部变量里干脆不创建真正对象。这背后是JIT编译器的优化策略对现代Java应用性能有实打实的影响。不过我得泼盆冷水别太迷信逃逸分析。实际生产环境中很多对象还是要通过方法返回值传出逃逸分析能优化的场景有限。但在写热点代码时如果能把大对象的生命周期限制在方法内部确实有利于JVM做优化。3.3 对象什么时候算“死了”GC Roots与可达性分析判断一个对象能不能回收主流JVM并不采用引用计数法——虽然实现简单但解决不了循环引用的问题。HotSpot用的是可达性分析从一组GC Roots出发沿着引用链向下搜索能到达的对象就算“活着”到不了的对象就是待回收对象。GC Roots主要包括虚拟机栈中栈帧里的局部变量表引用的对象。方法区中类的静态属性引用的对象。方法区中常量的引用对象。JNINative方法引用的对象。被synchronized持有的对象。这个模型解释了很多实战现象。比如一个ArrayList被static字段持有哪怕业务逻辑上已经没人用了只要类还在这个ArrayList以及它下面挂着的所有对象都永远不会被回收。这就是内存泄露最常见的一种形态。引用类型也值得多说一句强引用、软引用、弱引用、虚引用对GC的敏感度完全不同。缓存场景如果用强引用存大对象集合很容易让老年代越堆越大改造成软引用或弱引用之后GC压力会小很多。这个改造项在很多OOM案例里都是真正的解药。3.4 分代回收为什么内存模型自带“新陈代谢”绝大多数Java对象的存活时间非常短创建一个用完就扔。基于这个“弱代假设”JVM在堆内划分了新生代和老年代让不同年龄的对象待在不同区域再用不同算法回收。新生代最常用的回收算法是复制算法。Eden区和Survivor区的比例一般是8比1-XX:SurvivorRatio控制每次GC把存活对象从Eden和一块Survivor复制到另一块Survivor然后整片清空原区域。复制算法效率高但会浪费一部分空间这也是Survivor区为什么存在的原因。老年代中的对象普遍活得比较久再用复制算法就不划算。标记-清除会造成内存碎片标记-整理会移动对象。CMS用的偏标记清除G1更多用分区复制在不同场景下有它自己的取舍。玩过《我的世界》Java版的读者应该体会过地图加载久了偶尔会出现明显卡顿。每个区块对象都可能引用大量生物、方块状态数据堆使用率上去后GC停顿时间会明显增加。这种“卡顿感”就是JVM内存模型和垃圾回收在现实应用中的直接反馈。4. 内存不够了怎么定位内存溢出与内存泄露排查实战4.1 先分清OutOfMemoryError和Memory LeakOutOfMemoryError是症状Memory Leak是常见病因但两者不能画等号。有些OOM是峰值流量导致的内存瞬间不够属于容量规划问题有些OOM才是持续泄露对象只进不出。判断方法很简单用监控连续观察。如果堆使用率在服务重启之后持续上升、永不回落大概率是泄露如果是固定时间点脉冲式冲高更像是流量峰值的瞬时压力。4.2 一条标准的排查链路从jps到MAT我平时排查内存问题基本走这么一条链路jps -l 找到目标Java进程拿到PID。这一步看似基础但常有人忘记加-l看不到完整主类名。jstat -gcutil 1000 观察GC概况看Eden、Survivor、老年代的使用百分比以及YGC、FGC次数。如果Full GC频繁且回收后内存迟迟不降嫌疑就很大。jmap -dump:live,formatb,fileheap.hprof 导出堆快照。注意加live参数可以只导存活对象文件小一些但这也意味着失去了分析“死亡对象”的机会一般问题定位够用。用MATMemory Analyzer打开hprof文件先看Leak Suspects报告再看Dominator Tree。直方图按类统计对象数量和占用大小能快速发现异常大对象支配树则告诉你这个对象被谁引用了。Arthas也是一个很实用的线上工具dashboard命令能实时看到堆、GC、线程状态heapdump命令可以直接在运行中导出堆快照省去很多命令行操作。4.3 一个真实的线上案例线程池缓存List把老年代顶满之前遇到一个消息推送服务跑了一段时间后每两三分钟一次Full GC推送延迟越来越高。一开始运维习惯性加-Xmx从4G加到8G结果只是延后了崩溃时间FGC频率反而更难看。排查过程是这样的jstat看GC概览发现YGC正常但老年代使用率呈阶梯状持续增长。导出堆快照MAT直方图里某个包名下的MessageRecord实例有几十万个占用超过堆的一半。沿着GC Root路径找发现线程池的工作线程里挂着一个static List业务代码把“待重试的消息对象”全部塞进这个列表消费者逻辑又因为异常反复回滚导致对象只增不减。问题其实不难修限制列表容量、修复消费失败后的确认逻辑、增加重试上限。但整个排查过程最耗时间的是“找到谁在引用这些对象”而这一步靠的就是GC Roots与支配树分析。这里有个技巧单次堆dump看到的是某个时间点的快照别急着下结论。至少连续两次dump做对比看异常对象的数量是否在增长。只有确认“增长”才敢断定是泄露而不是临时堆积。4.4 启动就报“no suitable jvm”是环境问题别往内存上想热搜词里有一条no suitable jvm was found to start the application很多新手第一次遇到会一脸懵以为JVM内存配置有毛病。其实这个报错的含义很朴素操作系统在启动Java程序时根本没找到一个可用的JVM。常见触发场景是Windows环境变量没配好。一般按下面几步排查就能解决命令行跑 java -version如果提示“不是内部或外部命令”说明PATH里没有JVM入口先检查JAVA_HOME是否配置。查看JAVA_HOME指向的目录确认里面bin/java.exe真实存在版本对不对。确认PATH中包含了%JAVA_HOME%\bin并且顺序靠前避免被其他JDK版本劫持。顺带补充一下JDK、JRE和JVM的关系JVM是最底层负责运行字节码的引擎JRE在JVM之上加了运行所需的核心类库JDK则在JRE之上再加编译器和工具。报错里说找不到JVM大多数时候其实是找不到完整的JRE/JDK环境。这个基础概念在面试里也经常被点出来算是最小必答题。5. 内存模型是调优的地图参数得对着区域给5.1 调优之前先定延迟还是吞吐很多参数不是拍脑袋定的而是先问业务到底要什么。对在线交易、实时推送这类低延迟场景GC停顿是最大的敌人需要尽量降低频繁Full GC和长时间STW对离线计算、批量导出这类吞吐型任务单次GC时间长一点可以忍受但整体处理能力要拉满。这个选择直接决定垃圾收集器的选型。比如JDK 8时期响应优先一般选G1或者老一点的CMS吞吐优先选Parallel ScavengeParallel Old。到JDK 11之后ZGC和Shenandoah又给了更低的暂停时间选项。但我要强调一句收集器选型是调优的一部分不是全部。不做监控就换收集器往往是瞎折腾。5.2 -Xms、-Xmx、-Xmn到底在调哪块地从内存模型的角度看参数逻辑会清晰很多。-Xms和-Xmx控制Java堆的初始大小和最大大小。一般生产环境直接设成相同值-Xms4g -Xmx4g好处是运行期堆大小稳定不会因为扩容触发额外的内存复制和停顿。不过如果你追求更省内存可以故意留出伸缩空间这个按业务取舍。-Xmn控制新生代大小。新生代越大Minor GC频率越低但留给老年代的空间就越小大对象更容易进入老年代。另一条等价路径是-XX:NewRatio2表示老年代是新生代的2倍。这两个参数不要同时用基准拉满选一个作为主控制手段即可。下面是一个中等流量服务的参考配置思路-Xms4g -Xmx4g -Xmn2g -XX:SurvivorRatio8 -XX:UseG1GC -XX:MaxGCPauseMillis100这个配置的意思是堆上限4G新生代2GEden区占比是Survivor区的8倍。G1的目标停顿时间设在100ms保证单次GC停顿可控。注意配置漂不漂亮不是关键关键是你有没有办法验证它对业务的影响。5.3 从GC日志反推内存行为从内存模型的角度看调优最重要的一件事就是学会读GC日志。一次典型的Minor GC日志会显示GC前后新生代占用从多少降到多少总堆占用是多少耗时多少。Full GC日志则更多伴随老年代回收。很多人只看“有没有FGC”却忽略了晋升速率——也就是对象从新生代进入老年代的速度。我个人的习惯调优前先跑一轮压测把GC日志打开用GCeasy或者在线分析工具看曲线。重点关注三个指标Minor GC的频率和单次耗时。老年代增长速率。Full GC的间隔与停顿时间。如果老年代增长速度高说明晋升速率偏高可能的原因包括Survivor空间太小、MaxTenuringThreshold设置不当、或者Eden区本身太大导致对象长期存活。这些分析都建立在理解内存模型分区的基础上没有地图就只能瞎猜。5.4 监控指标建议最后给一份监控层面尽量覆盖的清单别等线上出问题才开始收集数据。监控项常用工具关注点堆使用率与分代占比jstat / Grafana老年代是否持续攀升GC次数与停顿时间GC日志 / GCeasyFull GC间隔是否恶化线程数量与栈内存jstack / top -H是否接近线程数上限元空间使用率jstat / Arthas dashboard动态类加载是否异常堆外内存jcmd / NMTDirectByteBuffer是否超限NMTNative Memory Tracking是JDK自带的堆外内存追踪工具启动时加上-XX:NativeMemoryTrackingsummary运行中可以用jcmd VM.native_memory查看各部分占用。排查Direct buffer memory问题时这个工具几乎是必需品。老实说看完这些可能还是会觉得JVM内存模型面很大。但只要你把“JMM管可见性运行时数据区管摆放位置垃圾回收管生命周期”这三根线立起来后面所有的面试题、排查案例、调优参数都能挂到这棵树上。剩下的就是多上手压几次测、多看几份heap dump的问题了。