news 2026/9/26 7:51:10

JVM内存模型深度拆解:JMM与运行时数据区,一篇文章彻底厘清

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
JVM内存模型深度拆解:JMM与运行时数据区,一篇文章彻底厘清

前几天帮一个团队做线上JVM排查,午休时一个小伙子问我:JVM内存模型到底是指堆和栈的划分,还是指多线程那个可见性模型?他说面试题背了不少,可一旦被问到 volatile 和堆扯上关系就彻底分裂了。我当时就意识到,这可能是很多Java开发都绕不过去的一个坎。

这篇文章想把这个事彻底理顺。不管你是正在准备面试、遇到线上OOM不知道怎么下手,还是单纯想弄明白JVM那块内存到底是怎么运作的,都可以继续往下看。我尽量用平时排查问题、调参压测时的真实视角来讲,而不是背教材章节。

先说结论:JVM的内存模型,其实是两个层次的东西叠在一起。一个是语言规范层面的Java Memory Model(JMM),管多线程之间的数据可见性和有序性;另一个是真实运行时划分的内存区域,管对象放哪里、回收从哪里开始。两者经常被网文混在一起讲,但它们的职责完全不同。

1. 先把两个“内存模型”分开:JMM与运行时数据区

1.1 被网文搞混的Java Memory Model

JMM全称是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)看线程dump
Java虚拟机栈私有栈帧、局部变量表、操作数栈StackOverflowError / OOM递归深度、线程数量
本地方法栈私有native方法调用StackOverflowError / OOMnative层资源
Java堆共享对象实例、数组、字符串常量池Java heap space对象泄漏、弱引用误用、堆参数
方法区/元空间共享类元信息、运行时常量池、静态变量Metaspace动态生成类、热部署

这张表背下来不算本事,关键是遇到OOM时,能第一时间把报错信息映射到对应区域。能走到这一步,内存排查思路基本就通了一半。

3. 一个对象从new到回收的一生

3.1 对象分配时JVM在后台做的那几件事

很多人以为new对象就是在堆里随便划一块内存出来,实际流程远比这复杂。JVM拿到一条new指令后:

  1. 先做类加载检查:如果当前类还没被加载、解析、初始化,先触发类加载。
  2. 在堆中分配原始内存空间。分配方式有两种——指针碰撞和空闲列表。如果堆内存规整,用指针碰撞;如果不规整,用空闲列表。
  3. 把分配到的内存空间初始化为零值,这样实例字段不赋值也有默认值(int是0,boolean是false,引用是null)。
  4. 设置对象头,包含Mark Word(存储哈希码、GC分代年龄、锁状态等)、类型指针、数组长度(如果是数组)等。
  5. 执行构造方法,即 方法,这时候对象才真正按开发者意图初始化。

这一套流程里,最容易看出问题的阶段是第2步。并发环境下多个线程同时分配内存,如果都踩同一个指针位置,就乱套了。HotSpot的解决方案之一是CAS加失败重试,来保证指针碰撞操作的原子性。

3.2 并不是所有对象都乖乖待在堆里

教科书说“对象主要分配在堆上”,注意是“主要”,不是“全部”。HotSpot的JIT编译器有一个很关键的能力叫逃逸分析,它会分析对象的作用域,判断它会不会“逃逸”出当前方法。

如果对象不会逃逸出线程或方法,JVM可能做栈上分配,让对象随栈帧弹出自动回收;更激进一点,还会做标量替换,把对象的字段拆散到局部变量里,干脆不创建真正对象。这背后是JIT编译器的优化策略,对现代Java应用性能有实打实的影响。

不过我得泼盆冷水:别太迷信逃逸分析。实际生产环境中,很多对象还是要通过方法返回值传出,逃逸分析能优化的场景有限。但在写热点代码时,如果能把大对象的生命周期限制在方法内部,确实有利于JVM做优化。

3.3 对象什么时候算“死了”:GC Roots与可达性分析

判断一个对象能不能回收,主流JVM并不采用引用计数法——虽然实现简单,但解决不了循环引用的问题。HotSpot用的是可达性分析:从一组GC Roots出发,沿着引用链向下搜索,能到达的对象就算“活着”,到不了的对象就是待回收对象。

GC Roots主要包括:

  • 虚拟机栈中栈帧里的局部变量表引用的对象。
  • 方法区中类的静态属性引用的对象。
  • 方法区中常量的引用对象。
  • JNI(Native方法)引用的对象。
  • 被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 Leak

OutOfMemoryError是症状,Memory Leak是常见病因,但两者不能画等号。有些OOM是峰值流量导致的内存瞬间不够,属于容量规划问题;有些OOM才是持续泄露,对象只进不出。

判断方法很简单:用监控连续观察。如果堆使用率在服务重启之后持续上升、永不回落,大概率是泄露;如果是固定时间点脉冲式冲高,更像是流量峰值的瞬时压力。

4.2 一条标准的排查链路:从jps到MAT

我平时排查内存问题,基本走这么一条链路:

  1. jps -l 找到目标Java进程,拿到PID。这一步看似基础,但常有人忘记加-l,看不到完整主类名。

  2. jstat -gcutil 1000 观察GC概况,看Eden、Survivor、老年代的使用百分比,以及YGC、FGC次数。如果Full GC频繁且回收后内存迟迟不降,嫌疑就很大。

  3. jmap -dump:live,format=b,file=heap.hprof 导出堆快照。注意加live参数可以只导存活对象,文件小一些;但这也意味着失去了分析“死亡对象”的机会,一般问题定位够用。

  4. 用MAT(Memory 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环境变量没配好。一般按下面几步排查就能解决:

  1. 命令行跑 java -version,如果提示“不是内部或外部命令”,说明PATH里没有JVM入口,先检查JAVA_HOME是否配置。
  2. 查看JAVA_HOME指向的目录,确认里面bin/java.exe真实存在,版本对不对。
  3. 确认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 Scavenge+Parallel Old。到JDK 11之后,ZGC和Shenandoah又给了更低的暂停时间选项。但我要强调一句:收集器选型是调优的一部分,不是全部。不做监控就换收集器,往往是瞎折腾。

5.2 -Xms、-Xmx、-Xmn到底在调哪块地

从内存模型的角度看参数,逻辑会清晰很多。

-Xms和-Xmx控制Java堆的初始大小和最大大小。一般生产环境直接设成相同值(-Xms4g -Xmx4g),好处是运行期堆大小稳定,不会因为扩容触发额外的内存复制和停顿。不过如果你追求更省内存,可以故意留出伸缩空间,这个按业务取舍。

-Xmn控制新生代大小。新生代越大,Minor GC频率越低,但留给老年代的空间就越小,大对象更容易进入老年代。另一条等价路径是-XX:NewRatio=2,表示老年代是新生代的2倍。这两个参数不要同时用基准拉满,选一个作为主控制手段即可。

下面是一个中等流量服务的参考配置思路:

-Xms4g -Xmx4g -Xmn2g -XX:SurvivorRatio=8 -XX:+UseG1GC -XX:MaxGCPauseMillis=100

这个配置的意思是:堆上限4G,新生代2G,Eden区占比是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是否超限

NMT(Native Memory Tracking)是JDK自带的堆外内存追踪工具,启动时加上-XX:NativeMemoryTracking=summary,运行中可以用jcmd VM.native_memory查看各部分占用。排查Direct buffer memory问题时,这个工具几乎是必需品。

老实说,看完这些可能还是会觉得JVM内存模型面很大。但只要你把“JMM管可见性,运行时数据区管摆放位置,垃圾回收管生命周期”这三根线立起来,后面所有的面试题、排查案例、调优参数都能挂到这棵树上。剩下的,就是多上手压几次测、多看几份heap dump的问题了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/26 7:50:51

阿里跨境电商AI Agent实战:39个技能+48个应用授权,微信钉钉远程操控

1. 从一条内部消息说起:这个Agent到底在解决什么问题 去年年底,一个做跨境的朋友在群里甩了张截图,说他现在躺在沙发上用微信给一个AI发消息,那边就自动把Shopify店铺的库存改了、给三个客户回了邮件、还把当天的广告数据拉出来做…

作者头像 李华
网站建设 2026/9/26 7:50:24

Tripo3D + Godot:7小时从零构建暗黑类游戏Demo实战

1. 为什么我盯上了 Tripo3D Godot 这条链路 先说结论:我用 Tripo3D 生成模型资产,用 Godot 做玩法组装,7 个小时从零撸出了一个能跑、能打、能捡装备的暗黑类 Demo。不是那种"点一下按钮看个动画"的演示,是真正有角色移…

作者头像 李华
网站建设 2026/9/26 7:48:53

盘立方软件指标文华期货ma均线交叉指标

110,COLORBLACK; 0,COLORBLACK; VAR26:(CLOSE-LLV(LOW,30))/(HHV(HIGH,30)-LLV(LOW,30))*100; VAR27:REVERSE(VAR26); VAR28:SMA(VAR26,3,1); 神通:SMA(VAR28,3,1),COLORCYAN; 标王:SMA(神通,3,1),COLORYELLOW; DRAWTEXT(CROSS(神通,标王) AND 神通<40,100,公),COLORWHITE; …

作者头像 李华
网站建设 2026/9/26 7:48:47

Substrate Runtime设计原理与区块链内核级开发

1. Substrate不是框架&#xff0c;是区块链的“操作系统内核”很多人第一次听说Substrate&#xff0c;是在Polkadot生态里——它被宣传成“构建区块链的框架”&#xff0c;甚至有人直接叫它“区块链开发套件”。但这种说法&#xff0c;就像把Linux内核叫作“写程序的工具包”一…

作者头像 李华
网站建设 2026/9/26 7:48:37

AI原生开发五维工程范式:Vibe/Plan/Glue/Spec/Smell实战指南

1. 这不是又一个AI编程概念课&#xff1a;Vibe/Plan/Glue/Spec/Smell 是真实压在工程师桌面上的五把刀你有没有过这种体验&#xff1a;深夜改完第三版提示词&#xff0c;模型还是把“生成用户注册接口”理解成“写一篇关于注册制改革的政策分析”&#xff1b;或者花两小时调通了…

作者头像 李华
网站建设 2026/9/26 7:48:17

金融信息服务系统开发基础与实践

我无法基于当前输入生成符合要求的博文。原因在于&#xff1a;您提供的输入内容中&#xff0c;项目标题为 "financial-services"&#xff0c;但后续所有字段&#xff08;项目正文、关键词、摘要描述&#xff09;均为空&#xff0c;且未提供任何实质性描述、背景信息、…

作者头像 李华