ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

JVM内存结构全解析:从栈、堆到OOM排查与GC调优

JVM内存结构全解析:从栈、堆到OOM排查与GC调优 1. 全局视角JVM内存结构到底在管什么事先聊个面试高频题JVM内存结构到底是什么如果你背过八股大概率能报出“堆、栈、方法区、程序计数器、本地方法栈”这几个名字但真到了排查线上OOM或者调优GC的时候很多人还是懵的。原因很简单——只记了名词没搞懂这几块内存之间的协作关系。我用一个生活化的类比帮你把框架立起来。把JVM想象成一家公司栈是每个员工的“工作台”上面堆着正在干的活儿局部变量、中间计算值干完一件扔一件效率极高但台子空间有限。堆是公司的“公共仓库”所有部门共享放的是长期物资对象实例。仓库 ManagerGC负责定期清理过期物资。方法区是公司的“规章制度墙”贴着所有流程规范、组织架构类信息、常量、静态变量基本不轻易动。程序计数器是每个员工手里的“工牌记录仪”记着自己干到哪一步了方便随时切换任务。这套结构是Java实现“跨平台”“自动内存管理”的地基。你写的每一个new、每一次方法调用、每加载一个类最终都会落到这几块区域里。这篇文章我会把这几个区域逐一拆开讲清楚它们各自的作用、参数调优、异常场景以及我在实际开发中踩过的坑。不管是准备面试、排查OOM还是做JVM调优这篇都值得你收藏后反复看。说明一下本文基于HotSpot虚拟机Oracle JDK/OpenJDK默认实现来讲解这也是目前绝大多数生产环境使用的JVM。其他JVM比如J9、GraalVM在内存划分上虽然大同小异但细节会有差异。2. 线程私有区域栈、本地方法栈、程序计数器2.1 虚拟机栈方法调用的“舞台”虚拟机栈VM Stack描述的是Java方法执行的内存模型。每个方法从调用到执行完毕对应一个栈帧Stack Frame的入栈和出栈。栈帧里装了什么四个核心部分局部变量表存放基本数据类型int、long、byte等、对象引用reference类型。注意对象本身不在这里这里只存“指向堆中对象的地址”或者“指向方法区中常量池的地址”。操作数栈可以理解成JVM的“临时计算台”。比如执行a b就把a压栈再把b压栈然后执行iadd指令弹栈相加结果再压回去。所有字节码指令的运算都离不开它。动态链接指向运行时常量池中该方法的引用。因为Class文件里的方法调用起初都是符号引用真正调用时要解析为直接引用。方法返回地址方法正常返回或异常退出时JVM要知道回到调用者的哪个位置继续执行。你平时听到的栈溢出StackOverflowError就是栈帧太多把栈空间撑爆了。最常见的场景递归没有终止条件或者递归深度过大。方法内声明了超大数组或大量局部变量每个栈帧的局部变量表膨胀。在线程池中提交任务时任务嵌套调用了其他线程池的任务形成隐式递归。我遇到过一个印象很深的案例一个Excel导入功能在处理多层级的树形数据时用递归构建数据层级一旦超过500层线上就开始抛StackOverflowError。当时还奇怪“为什么不是OOM”后来想明白了——栈溢出是Error级别不触发GC也不走堆内存的OOM判断它是独立的错误路径。默认情况下HotSpot的栈大小是1MB64位Linux/macOS。这个值可以通过-Xss参数调整比如-Xss256k表示把每个线程的栈大小设为256KB。注意这是“每个线程”的栈大小线程数越多总栈内存消耗越大。# 调整单个线程栈大小 java -Xss256k -jar your-application.jar调整栈大小是个双刃剑。栈设小了递归深度受限容易出现StackOverflowError栈设大了线程占用的内存多一旦线程数上去比如服务器高并发、线程池线程数开到几千总内存开销非常可观。所以生产环境不要盲目调大-Xss先算清楚线程规模和栈深度的实际需求。2.2 本地方法栈留给native方法的“专属通道”本地方法栈Native Method Stack和虚拟机栈的角色几乎一模一样区别在于虚拟机栈服务于Java方法本地方法栈服务于native方法。什么叫native方法就是方法声明上有native关键字、但没有方法体的方法底层由C/C实现。最典型的例子Object.getClass()、Object.hashCode()System.currentTimeMillis()Thread.start()底层调用的那个启动线程的方法Java 8以后不少并发类用的Unsafe相关操作很多同学不理解为什么需要单独一块区域存native方法。我的理解是Java代码跑在字节码指令集上native代码跑在操作系统原生指令集上两者执行引擎完全不一样内存布局、调用约定也不同必须“分房间住”。本地方法栈就是给JVM和底层操作系统库打交道时用的“翻译间”。HotSpot虚拟机做了一个很实用的合并操作——直接把本地方法栈和虚拟机栈合成一块。所以你在HotSpot上通过-Xss设置栈大小对两者同时生效。但理论上JVMSJava虚拟机规范允许它们各自独立其他JVM不一定这样实现。本地方法栈也会溢出同样抛StackOverflowError只是触发场景更隐蔽。我见过一个案例应用里用JNI调用了某个C语言图像处理库图像分辨率一上去本地方法栈直接溢出异常堆栈里看不到任何Java代码的调用痕迹排查起来非常痛苦。那个案例最终的解决方法是分批处理图像降低单次JNI调用的资源消耗。2.3 程序计数器最不起眼却“唯一安全”的区域程序计数器Program Counter Register是所有内存区域里最小的一个也是唯一一个在《Java虚拟机规范》中没有规定任何OutOfMemoryError情况的区域。它的作用只有一条记录当前线程正在执行的字节码指令地址。JVM的多线程是通过线程轮流切换、分配处理器执行时间片实现的一个线程被切走再切回来必须知道“刚才执行到哪了”这个信息就存在程序计数器里。这里有个容易混淆的知识点如果当前执行的是native方法程序计数器的值是undefined不可定义因为native方法的执行不依赖字节码谈不上“字节码指令地址”。很多面试官喜欢拿这个点挖坑答出来就是加分项。程序计数器是线程私有的天然不存在并发共享问题也不需要GC所以也是最“省心”的一块区域。实际操作中你甚至不需要为它配置任何参数它就像一个自启动的打卡机默默记录每个线程的工作进度。3. 线程共享区域堆与方法区含运行时常量池3.1 堆对象的大本营也是GC的主战场堆Heap是JVM管理的最大一块内存区域所有线程共享几乎所有对象实例和数组都在这里分配。你写的每一行带new的代码都是在堆里申请空间。堆的大小可以通过两个参数控制# -Xms堆初始大小 # -Xmx堆最大大小 java -Xms512m -Xmx2g -jar your-application.jar生产环境强烈建议把-Xms和-Xmx设为相同值好处有两个避免运行期堆大小动态扩容/收缩带来的性能抖动。启动时就占满内存提前发现机器内存不足的问题。堆内部又分了几个区域这直接关系到GC的行为。以HotSpot的经典分代设计为例区域职责主要GC活动新生代Young Generation刚创建的对象都在这里Minor GC/Young GC 频繁发生Eden区绝大多数对象首次分配的区域同上两个Survivor区S0、S1存放Minor GC后存活的对象交替使用同上老年代Old Generation熬过多次GC仍存活的对象晋升到这里Major GC/Old GC 相对低频元空间JDK 8类元数据独立于堆见后文Full GC时会触发卸载新生代和老年代的比例默认是1:2即-XX:NewRatio2。新生代内部Eden和Survivor的默认比例是8:1:1通过-XX:SurvivorRatio8设置。很多书上的数值是理论值真实JVM运行时会因为自适应调整-XX:UseAdaptiveSizePolicyJDK 8默认开启而动态变化如果你用jstat观察过实际分布会看到比例和启动参数并不完全一致——这是正常现象。堆空间不足会抛OutOfMemoryError: Java heap space。我帮你梳理一下最常见的几个触发场景内存泄漏对象被无意识持有GC无法回收堆被慢慢撑满。典型例子是静态集合不停add数据、未关闭的流对象、监听器注册后没注销。大对象一次性加载超大数组或超大集合直接超越堆的剩余空间。比如从数据库查了100万行数据放进List。并发量过高每个请求创建一堆对象堆扩容速度跟不上对象分配速度。排查堆OOM我的标准动作是三步加启动参数-XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/data/dump/让JVM在OOM时自动dump堆快照。用jmap -dump:formatb,fileheap.hprof pid手动导出堆快照。拿MATMemory Analyzer Tool或VisualVM分析快照看对象支配树和GC Roots引用链定位泄漏源。这里分享一个我早期踩过的坑。当时线上一个定时任务每次跑完都会触发一次Full GC我打开dump一看罪魁祸首是一个全局静态的Map存放了每一天的处理记录但从来没有清理逻辑运行三个月后Map里几百万条数据把老年代直接撑爆。这类问题就是典型的“小设计缺陷长时间运行大事故”。3.2 方法区与运行时常量池Java 8前后的重要变化方法区Method Area是JVM规范层面定义的逻辑区域它存储的是类型信息、字段信息、方法信息、静态变量、JIT编译产物等类元数据。很多地方用“永久代”PermGen来指代方法区这里需要严格区分一下在JDK 7及以前HotSpot使用“永久代”Permanent Generation来物理实现方法区。在JDK 8及以后永久代被彻底移除方法区改为“元空间”Metaspace实现并且不再占用堆内存而是使用本地内存Native Memory。这是一个非常关键的架构调整。为什么Oracle要废弃永久代我看到的最主要的几个原因永久代的大小难以精准预测。Class数量、常量池大小与应用复杂度强相关-XX:MaxPermSize设置小了频繁抛出OutOfMemoryError: PermGen space设置大了又白白浪费内存。永久代会参与GC调优复杂度高。Full GC时扫描永久代消耗的时间不短而类元数据的生命周期通常很长放在GC管辖区里并不合理。元空间使用本地内存默认只受机器物理内存限制类加载数量不再那么容易触顶开发体验好很多。对应的参数变化也很明显# JDK 7及以前的永久代参数 -XX:PermSize64m -XX:MaxPermSize256m # JDK 8及以后的元空间参数 -XX:MetaspaceSize256m -XX:MaxMetaspaceSize512m你可能会问元空间不是只用本地内存吗为什么还要去设MaxMetaspaceSize答案很简单如果不设上限一个动态生成大量类的应用典型的是热部署框架、反射频繁的框架、Groovy等动态语言脚本可能把机器物理内存消耗殆尽直接引发操作系统层面的OOM Killer那是比JVM OOM更惨烈的结局。运行时常量池Runtime Constant Pool是方法区的一部分Class文件中除了类的版本、字段、方法、接口等描述信息外还有一项常量池表Constant Pool Table用于存放编译期生成的各种字面量和符号引用。类加载后这个池子就进入方法区变成运行时常量池。JDK 7有一个重要变化字符串常量池从运行时常量池中分离移动到了堆中。所以判断下面这段代码时结果可能会让背八股的同学意外String s1 hello; String s2 hello; String s3 new String(hello); System.out.println(s1 s2); // true两个都指向字符串常量池中的同一个对象 System.out.println(s1 s3); // falses3指向堆中的String对象s1 s2为true是因为编译器把字面量“hello”放入了常量池第二次引用时直接复用。而new String(hello)强制在堆中创建一个新对象。如果你想要s1和s3指向同一个堆对象需要用intern()方法——JDK 7之后intern()会把堆中的字符串内容尝试放入字符串常量池如果池中已有相同内容的字符串则返回池中引用。我在实际工作中见过一个因字符串常量池引发的“隐蔽OOM”一个业务在循环里对用户输入做String.intern()去重缓存结果字符串基数过大常量池膨胀堆被占满。这提醒我们intern()不是免费的长生命周期的字符串进了常量池后很难被回收使用前一定要评估量的规模。3.3 直接内存与堆外内存被忽略的“第四内存区”除了JVM规范里的运行时数据区还有一块值得重视的区域——直接内存Direct Memory。它不由JVM堆管理而是通过ByteBuffer.allocateDirect()分配底层走的是操作系统的本地内存。为什么要用直接内存核心原因是减少数据在堆内和堆外的拷贝。比如Netty网络框架接收网络数据时直接在操作系统层写内存Java堆内的代码通过“零拷贝”方式读写避免了把数据从内核态复制到用户态的多次拷贝过程。高吞吐场景下这个优化收益非常大所以Netty默认的分配器就是直接内存。但直接内存有副作用它的分配和回收由JVM监控GC时却不能直接回收它只能通过Cleaner机制本质是虚引用引用队列在对象不可达后触发回收。如果直接内存被占满会抛出OutOfMemoryError: Direct buffer memory。我的经验是使用Netty或其他NIO框架时一定要按业务估算直接内存上限并给JVM敲定必要的内存隔离。比如你给堆设置了-Xmx2g机器内存是4G理论上至少还要预留直接内存和元空间的量否则堆还没占满直接内存先把机器撑爆了。比较稳妥的做法是做一次压测查看Netty的PlatformDependent.maxDirectMemory()实际使用量留足30%以上的余量。4. 内存分配的完整链路一个对象从出生到回收的一生理解了各个区域的定义我们来走一遍对象创建到回收的全过程这是串联所有知识点最有效的方式。4.1 对象的创建过程当你要执行new User()时JVM内部经历以下步骤类加载检查虚拟机会先检查User类是否已被加载、解析、初始化。如果没加载则触发类加载流程加载→验证→准备→解析→初始化。分配内存类加载通过后JVM在堆中为对象划分一块内存。这里涉及两种分配方式指针碰撞堆内存规整时用一个移动指针即可和空闲列表堆内存碎片化时用遍历空闲列表找足够大的块。GC收集器的算法决定了堆是否规整标记-压缩算法是规整的标记-清除不是。内存空间初始化将分配到的内存空间不包括对象头全部初始化为零值。这保证了实例字段不赋初值就能直接使用默认值比如int默认0boolean默认false。设置对象头在对象头中存储这个对象是哪个类的实例、对象的哈希码、GC分代年龄、锁状态标志等信息。执行构造方法调用init方法按照程序员写的逻辑初始化对象。这里有个重要的并发安全问题如果多个线程同时在堆中分配对象指针碰撞的“移动指针”操作不是线程安全的。HotSpot的解决方案是TLABThread Local Allocation Buffer即每线程在堆的Eden区预先分配一块线程私有的缓存区域小对象优先在自己的TLAB里分配TLAB用完或大对象才走同步分配路径。你可以通过-XX:UseTLAB开启JDK 8默认开启-XX:TLABSize调整大小。4.2 对象的晋升与GC回收对象从出生到回收的路径很清晰对象创建 → 优先进入Eden区大对象直接进老年代通过-XX:PretenureSizeThreshold控制默认是0表示不做限制。Eden区满了 → 触发Minor GC → Eden和S0中存活的对象复制到S1 → 存活年龄1。每熬过一次Minor GC年龄1 → 年龄达到-XX:MaxTenuringThreshold默认15→ 晋升老年代。老年代空间不足 → 触发Major GC/Full GC。动态年龄判定也值得注意如果S0中相同年龄对象大小总和大于Survivor空间的一半年龄大于等于该值的对象会直接进入老年代不需要等到15岁。这就是动态年龄规则在实际调优中经常导致对象“提前”晋升。GC算法的选择对内存影响也非常大。举几个常用组合场景GC组合建议理由低延迟微服务响应式快-XX:UseG1GCJDK 9默认可预测的停顿时间模型Region化内存管理高吞吐批处理对停顿不敏感-XX:UseParallelGC并行清理吞吐量优先超大堆几十GB以上ZGC/ShenandoahJDK 11/15停顿时间控制在毫秒级甚至更低关于G1的Region模型我多说一句。G1把堆划分为很多大小相等的小块默认2048个逻辑上不再有严格的Eden、S0、S1、Old物理分区而是每个Region动态决定自己是哪种角色。这意味着GC的“舞台”从“整堆”缩小到“若干Region”停顿时间更可控。但它也有个著名的坑Humongous Object巨型对象超过Region大小一半的对象会直接分配到连续的专用Region如果分配频繁G1的巨型对象回收效率并不理想甚至容易触发提前Full GC。4.3 静态变量、线程安全与内存可见性很多新手搞不清楚“类的静态变量到底存哪”。在JDK 8之前静态变量static字段被放在方法区永久代JDK 8之后静态变量被移动到堆中具体说是存储在类的Class对象中而Class对象本身在堆里。方法和字段的元数据仍在元空间但静态变量属于实例数据跟着Class对象住进了堆。这个变化对调优的意义在于大量的静态集合、静态缓存占的是堆内存而不是元空间。排查OOM时dump出来的堆快照里能看到静态变量的内容这是非常重要的线索。线程安全方面栈内存天然是线程安全的每个线程私有但你如果从栈中取出一个对象引用通过它去修改堆上的对象就会产生竞态问题。这也是为什么局部变量本身绝不共享但局部变量指向的对象可以共享——这是理解Java并发模型的基础。再看一个高并发场景下的典型问题——“栈内存溢出”。很多人把这个误解和堆OOM混为一谈。其实线程栈满抛的是StackOverflowError不是OutOfMemoryError而且这个错误不会触发GC。如果你们的线程数极大比如每请求一线程的老模型操作系统为线程栈分配的虚拟内存可能耗尽这时抛的可能是OutOfMemoryError: unable to create new native thread——注意这本质是“线程创建失败”不是堆内存问题最常见的解法是缩减线程池大小或改用虚拟线程Project Loom。5. 高发问题排查实录与调优实战建议5.1 快速定位堆内存OOM的标准流程附实操指令前面已经简单提过排查OOM的步骤这里展开成完整流程方便你将来直接照做。第一步启动时加上内存参数和自动Dump参数java -Xms1g -Xmx1g \ -XX:HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath/data/logs/heap.hprof \ -XX:MetaspaceSize256m \ -XX:MaxMetaspaceSize512m \ -jar your-application.jar第二步进程跑起来后用jstat观测内存使用趋势# 每隔1秒打印一次GC情况共打印10次 jstat -gc pid 1000 10重点看这几列EEden区使用率、O老年代使用率、FGCFull GC次数、FGCTFull GC累计耗时。如果FGC持续快速增长说明老年代在频繁被“挤爆”大概率有内存泄漏或对象过早晋升。第三步OOM发生或进程还在但内存很高时手动抓取堆快照jmap -dump:live,formatb,file/data/logs/heap-live.hprof pidlive参数表示先触发一次Full GC再dump这样能去除可回收对象保留真正的存活对象。这个分析要重点关注什么我建议按这个顺序支配树看哪个对象“支配”的内存最大也就是内存大头在哪。GC Roots引用链选中占用最大的对象看它的引用链上是谁持有它定位是哪段业务代码。线程栈与堆对照如果对象是线程内临时对象看对应线程在哪段代码里分配了这么多对象。5.2 一个线上Full GC频繁的真实案例讲一个我经手过的案例过程比较有代表性。某支付服务在活动大促期间出现响应变慢监控面板显示Full GC每两分钟一次老年代每次回收后占用依然超过80%。初步怀疑是内存泄漏但dump分析后并没有发现明显的“无主增长”反而都是正常业务对象。换了个思路去查jstat的新生代数据发现Eden区对象每次Minor GC后并没有大量回收存活对象比例异常高。进一步查代码发现团队为了“提升性能”把一批订单明细在查询后统一放进了ThreadLocal缓存本意是防止同一线程多次查询数据库但忽略了ThreadLocal是挂在工作线程上的线程池的线程是复用的一次请求结束ThreadLocal没有清理下一次请求拿到的还是上一次的数据引用——对象老化和线程泄漏叠加最终把老年代撑破了。这个案例的教训有两个层面ThreadLocal用完必须remove()尤其在finally块里做兜底。排查GC问题时不要只盯堆dumpjstat的新生代数据往往能更早暴露问题。5.3 不同区域OOM的辨别与应对我整理了一张速查表把不同OOM信息的对应区域和核心应对策略列出来排查时直接对号入座错误信息出问题区域首选排查方向Java heap space堆dump堆快照查泄漏、大对象、并发分配StackOverflowError虚拟机栈/本地方法栈查递归深度调-XssUnable to create new native thread操作系统线程数上限降低线程数调低栈大小换虚拟线程Metaspace元空间查动态类加载设-XX:MaxMetaspaceSize上限Direct buffer memory直接内存/NIO调大-XX:MaxDirectMemorySize查堆外缓存有几个细节值得补充GC overhead limit exceeded是JVM的保护机制表示“GC一直在跑但回收效果极差”。看到它别慌本质还是堆太小或泄漏太严重优先查堆dump而不是去调大堆。Metaspace OOM在Spring Boot应用里多半和CGLIB代理、热部署、动态脚本编译有关。如果你不确定是哪个类加载器干的可以加-XX:TraceClassLoading观察启动日志中类加载的来源。直接内存OOM时堆dump可能完全看不出来问题——堆占用可能只有几百MB但机器可用内存已经告急。遇到类似情况就加-XX:MaxDirectMemorySize默认等于-Xmx来控制上限再用NMTNative Memory Tracking工具查看本机内存的详细分配情况-XX:NativeMemoryTrackingsummary jcmd pid VM.native_memory summary5.4 参数调优的“最小介入”原则最后聊一个务实的调优心态。我刚工作时总觉得调优就是堆参数调大调小拼命试各种组合结果线上反而更差。后来带我的师傅点了我一句JVM参数调优的目标是让系统在现有资源下稳定运行而不是把每个参数调到理论最优。我现在的基本策略是-Xms和-Xmx设相等一次性确定堆边界避免动态扩缩容抖动。MetaspaceSize和MaxMetaspaceSize必须设防止类加载失控。GC选型优先用JVM自带默认JDK 8是Parallel Scavenge Parallel OldJDK 9是G1除非停顿时间明显超标再做改动。每次调参只动一个变量观察至少一个完整业务周期包含峰值流量再决定是否继续调整。所有参数变更进版本控制通过配置中心下发随时可以回滚。不要盲目相信网上流传的“神级参数组合”不同业务的内存模型差异极大一个高并发网关和一个批处理平台的调优策略完全可能是相反的。你要做的是理解每块内存的角色然后用监控数据说话。我自己体会最深的一点JVM内存结构的知识如果在平时开发中没有“用起来”永远只是面试八股。真正让你成长最快的路径是拿一个运行了半年以上的老服务打开监控面板对着老年代曲线一步步反推它这段时间经历了什么——这个过程比背十篇JVM文章都管用。
返回列表