ARTICLE DETAIL

资讯详情

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

JVM内存结构详解:从运行时数据区到内存溢出排查实践

JVM内存结构详解:从运行时数据区到内存溢出排查实践 前几天线上有个服务内存告警我连上机器先用jstat -gcutil扫了一遍Eden 区在连续几次 GC 之后依然接近占满老年代占用率也一路往上顶。当时第一反应是“某个对象被人无意识持有了”但同事追问了一句你判断的依据是哪个区域对象为什么会进老年代而不是在 Eden 区就被回收我一下被问住了。这也是这份 JVM 内存结构学习笔记的起因——把运行时数据区里的每一个区域从定义、作用、异常表现到排查时的实际意义完整过一遍。JVM 内存结构在面试里出场率极高但多数人包括我在内往往只记住一张分区图。真正上手排查线上问题时如果不清楚某个区域存的什么、什么时候会溢出、用什么参数控制很多操作就是瞎猜。这篇文章适合正在准备 JVM 面试的 Java 开发者也适合遇到过 GC 频繁、内存溢出却不知道从哪里下手的开发和运维。我会先从内存区域的全景图开始再逐个拆解虚拟机栈、堆、方法区、对象布局、直接内存这些模块最后落到高频面试题和调优工具上尽量让读者看完能建立一套自己的分析思路。1. 一次内存告警引出的认知盲区从一张分区图到一套排查方法1.1 为什么内存结构是排查所有内存问题的起点如果你去问一个工作两年以上的 Java 开发JVM 内存管理是不是重点几乎所有人都会点头。可在实际处理问题的时候大部分人还是停留在“内存溢出就加 -Xmx”这种层面。内存溢出的报错信息其实已经暴露了区域信息java.lang.OutOfMemoryError: Java heap space和java.lang.OutOfMemoryError: Metaspace背后是完全不同的处理路径。前者要分析堆内对象、类加载器、GC 频率后者往往和动态生成类、反射、CGLIB 代理有关。如果连这些区域存在哪里都不知道自然不知道从哪里排查。1.2 这份笔记的阅读路径我建议你不要光看结论而是跟着“对象的一生”来读一个对象被 new 出来之后引用会出现在虚拟机栈的局部变量表里对象实例放在堆里对象的类元数据放在方法区元空间接触过 NIO 的还会有堆外直接内存。对象被回收后引用从栈帧里消失内存回到堆中。把这条生命周期串起来JVM 内存结构就不再是一堆孤立的名词了。我整理这份笔记时给自己定了一条原则每个区域都要回答四个问题——它是什么、存什么、什么时候溢出、用什么参数管。后面的章节基本都是按这个框架走的。2. 运行时数据区全景哪些区域线程私有哪些区域共享2.1 一张表格看懂八个内存区域JVM 在运行 Java 程序时会把内存划分为若干个区域按照 JVM 规范主要包含程序计数器、虚拟机栈、本地方法栈、堆、方法区方法区里又包含运行时常量池。实际使用中还经常遇到直接内存它不属于运行时数据区但也受 JVM 参数影响排查时必须纳入考虑。区域名称线程私有/共享主要存储内容常见异常表现程序计数器私有当前线程执行的字节码行号无虚拟机栈私有栈帧局部变量表、操作数栈、动态链接、返回地址StackOverflowError本地方法栈私有Native 方法调用StackOverflowErrorJava 堆共享对象实例、数组OutOfMemoryError: Java heap space方法区共享类元数据、静态变量、常量池等OutOfMemoryError: Metaspace运行时常量池共享类文件常量池加载后的字面量与符号引用同方法区直接内存共享本地区域DirectByteBuffer 等堆外内存OutOfMemoryError: Direct buffer memory这里最容易记混的是线程私有和共享的划分。私有的三个区域跟着线程走线程创建时分配线程销毁时释放。共享的区域是全局的所有线程都能访问所以这些区域才需要做并发控制。比如堆里分配对象要解决多线程竞争问题方法区里的类元数据要考虑多线程加载导致的可见性。2.2 JDK7 到 JDK8永久代退出历史舞台方法区在 JDK8 之前由 HotSpot 的“永久代”实现JDK8 之后永久代被彻底移除类元数据迁移到“元空间”。这个改动对整个内存结构影响很大JDK6 及之前字符串常量池、静态变量都放在永久代永久代空间不足会报PermGen space。JDK7字符串常量池和静态变量被移到 Java 堆中但类元数据还在永久代。JDK8永久代完全废弃类元数据放到本地内存中的元空间大小默认只受系统可用内存影响。理解这个演变对实际排查很重要。你在用 JDK8 时如果遇到方法区溢出报错不是PermGen space而是Metaspace对应的参数也从-XX:MaxPermSize变成了-XX:MaxMetaspaceSize。面试中问到“元空间为什么替代永久代”核心原因是永久代大小难以确定容易出现溢出而且永久代需要指定上限元空间直接用本地内存由系统决定上限更合理。2.3 记忆技巧先分私有的和共享的再记每个区域的“产出物”我自己记的时候会先画一条线左边是程序计数器、虚拟机栈、本地方法栈右边是堆、方法区。左边的特点是“方法调用”右边是“对象和元数据”。然后再想一句口诀栈管运行堆管存储方法区管类信息。后续所有细节往里填就行。3. 虚拟机栈方法调用的完整舞台3.1 栈帧里的五件装备虚拟机栈描述的是 Java 方法执行期间的内存模型。每个方法从调用到执行完毕对应一个栈帧从入栈到出栈的过程。一个栈帧包含五部分局部变量表、操作数栈、动态链接、方法返回地址、附加信息。局部变量表以槽Slot为单位存放方法参数和方法内部定义的局部变量。boolean、byte、char、short、int、float、reference、returnAddress都占一个槽long和double这种 64 位的数据占两个槽。这里有个比较有意思的细节局部变量表里的槽是可以复用的如果一个变量出了作用域其槽位可能被后面的变量复用。但这也带来一个副作用——如果某个对象引用还在局部变量表中GC 就不会回收它。之前排查过一个诡异的对象无法回收问题就是因为方法里某个引用在后续代码中被重新赋值前一直占着槽位导致对象迟迟无法被回收。解决办法是在不再使用大对象后主动把引用置空或者缩小变量的作用域。操作数栈是方法执行的“工作台”所有算术运算、参数传递都在这里完成。比如执行一个加法JVM 会把两个操作数压入操作数栈然后执行iadd弹出两个数、压入结果。它的最大深度在编译期就确定了所以栈帧大小在编译期已经固定。动态链接指向运行时常量池中该方法的引用支持方法调用时的符号引用到实际引用的解析。方法返回地址用于方法返回后恢复调用者的执行状态。这些内容虽然概念多但在实际排查中更多体现在jstack输出里每一行栈信息就是一个栈帧。3.2 从字节码角度看一次方法调用为了把栈帧讲明白我常用一个最简单的加法方法作例子public int add(int a, int b) { return a b; }对应的核心字节码大致是iload_1 iload_2 iadd ireturniload_1把局部变量表中索引为 1 的变量压入操作数栈iload_2同理。iadd弹出栈顶两个 int 值计算后把结果压回栈。ireturn把栈顶值返回给调用方并结束当前栈帧。可以看到方法的参数在进入方法时就已经被放到局部变量表里了整个计算过程就是局部变量表和操作数栈不断交互的过程。3.3 栈溢出踩坑递归深度和 -Xss虚拟机栈最常见的异常是StackOverflowError。栈帧随着方法调用不断入栈如果方法递归太深或者无限递归栈总容量会被耗尽。默认栈大小因平台而异通常 512KB 到 1MB可用-Xss调整。我之前跑一段递归遍历目录树的代码目录结构很深跑了一会儿就StackOverflowError。当时第一反应是加-Xss2m结果只是延迟了崩溃时间。后来把递归改成了显式栈的手工压栈问题才彻底解决。这个经历想说明的是栈空间不是越大越好线程栈大小直接和线程数量、可用内存相关调大栈意味着同样内存下能支持的线程变少。遇到栈溢出核心思路是看代码逻辑而不是盲目加参数。java -Xss1m -jar app.jar3.4 本地方法栈HotSpot 里的特殊处理本地方法栈服务于 Native 方法调用理论上和虚拟机栈是相对独立的区域。但 HotSpot 虚拟机直接把虚拟机栈和本地方法栈合二为一了所以你在排查时不需要特意区分这两个区域。如果在jstack输出里看到大量Native Method的栈帧说明线程正在执行 JNI 调用这时的内存问题往往不在 JVM 内部而是在本地代码或者操作系统层面。4. 堆内存对象的主要出生地4.1 为什么 Java 堆要分代Java 堆是内存结构中最大的一块区域也是 GC 工作的主战场。几乎所有对象实例都在这里分配所以它也是内存泄漏、内存溢出排查的重中之重。堆被划分为新生代和老年代新生代又细分为 Eden 区和两个 Survivor 区S0、S1。分代的理由是绝大多数对象“朝生夕灭”存活时间很短。把对象按存活时间分区可以采用不同的回收算法新生代存活率低适合复制算法老年代存活率高适合标记整理或标记清除。默认情况下Eden 和两个 Survivor 的比例是 811这个比例并不是拍脑袋定的而是经过统计得出的经验值。可以通过-XX:SurvivorRatio调整。一个常见的误解是“对象只分配在 Eden 区”。实际上 JVM 做了多种优化小对象优先在 TLAB 分配TLAB 不够再在 Eden 分配大对象直接进老年代如果开启了逃逸分析某些不会逃逸出方法的小对象可能直接在栈上分配。所以堆内的分配路径其实是一条完整的分流逻辑。4.2 对象从 Eden 到老年代的晋升路线一个普通对象的完整路径大致是新对象优先在 Eden 区分配。Eden 区不够时触发 Minor GC存活对象被复制到 S0 或 S1对象年龄加 1。每次 Minor GC存活对象在 S0 和 S1 之间来回复制年龄继续增加。年龄达到-XX:MaxTenuringThreshold默认 15对象晋升到老年代。大对象超过-XX:PretenureSizeThreshold设定值直接在老年代分配避免在 Eden 和 Survivor 之间发生大量复制。动态年龄判断如果在 Survivor 中相同年龄的所有对象大小总和大于 Survivor 空间的一半年龄大于等于该年龄的对象直接晋升不需要等到 15 岁。其中大对象直接进老年代这个策略容易被忽略。比如一次性加载一个几十 MB 的 byte 数组它不会进 Eden而是直接落到老年代。如果这种对象很多、又被长期持有老年代会被快速占满最终触发 Full GC 甚至Java heap spaceOOM。我之前排查过一个批量导入功能每次导入都创建一个巨大的 byte 数组用于缓存文件内容明明并发量不高老年代却经常打满。最终就是把大对象拆成流式处理才把内存压力降下来。4.3 控制堆的常用参数和 IDEA 里防 OOM 的配置堆相关的参数是调优的基础下面这几个是必须掌握的-Xms初始堆大小建议和-Xmx设置成相同的值避免堆大小动态伸缩带来的性能抖动。-Xmx最大堆大小线上服务一般根据机器内存和业务负载评估不要盲目设置过大。-Xmn新生代大小新生代越大Minor GC 频率相对越低但会导致老年代变小需要平衡。-XX:NewRatio老年代和新生代的比例默认 2即老年代占 2新生代占 1。-XX:SurvivorRatio8Eden 和 Survivor 区的比例。-XX:MaxTenuringThreshold晋升老年代的年龄阈值。-XX:HeapDumpOnOutOfMemoryErrorOOM 时自动导出堆转储文件排查必备。对于本地开发比如 IDEA 里总是编译或运行项目时报 OOM最常见的做法是修改 IDEA 的 VM 参数把-Xmx调大。但要注意这只是把堆的上限放宽了如果代码里存在明显的内存泄漏堆再大也扛不住。开发测试时我更建议保留-XX:HeapDumpOnOutOfMemoryError真抛 OOM 还能留一份堆转储分析。java -Xms2g -Xmx2g -Xmn1g -XX:SurvivorRatio8 -XX:HeapDumpOnOutOfMemoryError -jar app.jar4.4 从jmap -heap看各区域占用想验证上面这些参数是否生效可以直接用jmap -heap查看进程的堆配置和当前各代使用情况。输出中会看到Eden Space、Survivor Space、Old Space各自的容量和使用量。如果新生代长期处于高占用、Minor GC 之后回收率极低说明进入老年代的对象量异常该看代码了。我习惯先用jstat -gcutil看整体 GC 情况再用jmap -heap看各区域绝对值两者结合起来定位是区域配置问题还是对象创建逻辑问题。5. 方法区与运行时常量池类元数据和字符串常量的归宿5.1 方法区是规范元空间是实现方法区在 JVM 规范里是一个逻辑区域用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等。HotSpot 在 JDK8 之前用永久代实现方法区JDK8 之后用元空间实现。元空间和永久代最核心的区别是它使用本地内存默认情况下不会受到 JVM 堆大小限制只受操作系统可用内存限制。这解决了一个长期痛点永久代大小不好确定设小了容易PermGen spaceOOM设大了浪费内存。改用元空间后类元数据占用的内存在系统物理内存范围内理论上都可以使用但这也意味着不受控的类加载可能把系统内存耗尽。所以生产环境还是建议配置-XX:MetaspaceSize和-XX:MaxMetaspaceSize前者是初始阈值后者是上限。什么场景会导致元空间膨胀高频的反射、CGLIB 动态代理、JSP 热部署、自定义类加载器反复加载类。之前有个做规则引擎的服务每次修改规则都会生成新的类生产跑了一个月后出现OutOfMemoryError: Metaspace。排查下来就是类加载器没有正确回收旧的类元数据无法被卸载。这种问题加MaxMetaspaceSize只能缓解症状根治还是要从类加载器的生命周期入手。5.2 运行时常量池和字符串常量池是两个池子不要混很容易被混淆的是运行时常量池和字符串常量池。运行时常量池是方法区的一部分每个类或接口在 class 文件中都有一个常量池表Constant Pool类加载后这些常量池表会被加载到运行时常量池中包括各种字面量和符号引用。它服务于动态链接让 JVM 在执行字节码时能够解析符号引用为实际的内存地址。字符串常量池则是专门存放字符串字面量的池子。JDK7 之后它被移到了 Java 堆中所以很多字符串相关的内存问题都可以通过堆转储看到。两个池子的关系是字符串常量池里的字符串对象也是堆中的对象而运行时常量池里的符号引用经过解析后可以指向这些对象。面试中经常问“new String(abc) 创建了几个对象”答案不是固定的一或二要分情况。如果字符串常量池中已有“abc”那只创建一个堆对象如果没有还要在常量池中创建一个字符串对象总共两个。这类问题看着绕其实就是对字符串常量池位置和 String 对象创建流程理解不够透彻。5.3 String.intern() 的经典案例从一个永久代 OOM 说起JDK6 时代大量调用String.intern()会迅速占满永久代因为字符串常量池就放在永久代里而永久代空间又很小。JDK7 之后字符串常量池移到堆“intern 导致永久代 OOM”的问题基本消失但 intern 仍然会占用堆内存不能滥用。关于 intern 有一个非常经典的 JDK6 与 JDK7 行为差异题String s1 new String(a) new String(b); s1.intern(); String s2 ab; System.out.println(s1 s2);在 JDK6 下intern会把字符串复制到永久代s1指向堆中的对象s2指向永久代的对象结果false。在 JDK7 及以后字符串常量池在堆中intern发现常量池里没有“ab”会把s1的引用直接放入常量池s2再通过字面量获取时拿到的就是同一个引用结果true。理解这个差异的前提是你清楚字符串常量池在不同 JDK 版本中的位置变化。6. 对象的内存布局从 new 到对齐填充6.1 new 一个对象到底做了几件事很多人写 Java 写了很多年但问到自己 new 一个对象背后发生了什么往往只能回答“分配了一块内存调用了构造函数”。实际上完整的步骤要长得多类加载检查检查这个类是否已经被加载、解析和初始化。如果没有先触发类加载。分配内存在堆上为对象划分一块大小确定的内存。初始化零值把这块内存区域都初始化为零值这样对象的实例字段不需要手动赋零就可以直接使用。设置对象头记录对象的哈希码、GC 分代年龄、锁状态标志、类型指针等。执行init方法执行构造函数按照代码把实例字段初始化为期望的值。第 2 步的“分配内存”在并发环境下不是安全的因为多个线程可能同时分配对象。JVM 有两种方案解决一种是对分配操作做 CAS 加失败重试另一种是给每个线程在 Eden 区预先分配一块 TLAB 区域线程优先在 TLAB 里分配只有 TLAB 用完需要重新申请时才会加锁。TLAB 分配对性能的影响在一开始不明显但在高并发创建对象的场景下差距会很大。第 2 步还涉及两种分配方式——指针碰撞和空闲列表。如果堆内存规整已用内存和空闲内存中间有一个指针分配时只需移动指针即可如果堆内存不规整就要维护一个空闲列表从列表中找足够大的空间。究竟用哪种方式取决于垃圾收集器是否带压缩整理功能。6.2 对象头、实例数据、对齐填充对象在堆中的内存布局分成三块对象头、实例数据、对齐填充。对象头在 HotSpot 中分成两部分一部分是 Mark Word存储对象自身的运行时数据比如哈希码、GC 分代年龄、锁状态标志另一部分是类型指针指向方法区的类元数据JVM 靠它确定这个对象是哪个类的实例。此外如果对象是数组对象头里还有一块记录数组长度的区域。实例数据存放对象真正定义的字段内容包括从父类继承下来的字段。字段分配顺序有默认规则相同宽度的字段会尽量放在一起这既是为了节省空间也是为了配合内存对齐。对齐填充是第三部分但不是必然存在的。HotSpot 要求对象起始地址必须是 8 字节的整数倍也就是对象总大小要按 8 字节对齐不够的部分用占位符补齐。为什么要做对齐和 CPU 读取内存的机制有关对齐了读写性能才高。6.3 结构体内存对齐停车位的类比说到对齐很多了解 C/C 的读者会想到结构体内存对齐。Java 对象设计沿用了类似思路。有个很形象的类比是停车位停车场每个车位都有固定大小如果车没停到指定边界整体就乱了。CPU 从内存读取数据也有“边界”偏好数据起始地址如果对齐到某个倍数一次总线事务就能读完不用做额外的拼接。对象对齐带来的另一个影响是伪共享问题。虽然这是并发层面的概念但它和“对齐”紧密相关。多个线程修改同一个缓存行中的不同变量时会因为缓存行锁定而互相拖慢。Java 里解决伪共享的Contended注解本质就是人为填充对象字段让热点变量分别落到不同的缓存行。理解了对象布局和对齐再去看这类优化思路会通透很多。6.4 指针压缩为什么很多人强调堆不要超过 32G在 64 位 JVM 中对象引用默认占 8 字节比 32 位时多一倍。为了降低内存占用JDK6 update14 之后提供了-XX:UseCompressedOops开启后对象引用被压缩到 4 字节可以显著减少内存占用。它的实现原理利用的就是 8 字节对齐。在对象都按 8 字节对齐的前提下所有对象引用实际上都是 8 的倍数JVM 可以用“存偏移量再乘以 8”的方式还原真实地址。用 4 字节的偏移量可以表示 2 的 32 次方个偏移单位每个偏移单位对应 8 字节所以理论上可以寻址 32G 内存。实际中 32G 并不是一个绝对分界线但很多资料建议 Java 堆不要超过 32G原因就在这里。超过 32G 后压缩指针失效对象引用重新占 8 字节内存占用上升GC 压力和性能都会有负面影响。如果一台机器有很多内存与其把单个堆撑到 64G不如拆成多个 JVM 实例更稳妥。6.5 对象访问定位句柄还是直接指针创建对象之后栈上的引用变量怎么找到堆里的对象主流有两种方案句柄访问和直接指针访问。句柄访问会在堆中单独划分一块句柄池引用变量存的是句柄地址句柄里再记录对象实例数据的地址和类型数据的地址。好处是对象被移动GC 时发生时虚拟机只需要改变句柄里的数据引用变量本身不用改。直接指针方式则是引用变量直接存对象地址访问速度更快。HotSpot 主要使用直接指针方式因为它减少了句柄池这一层间接寻址性能开销更小。这个知识点在面试里偶尔出现但很少被真正重视。它其实影响你对 GC 移动对象的理解为什么 GC 时引用还能正确指向对象因为要么是引用被更新要么是虚拟间接层被更新。理解了这层再去看 G1、ZGC 这些收集器的对象移动机制会更容易。7. 系统级思维直接内存和程序计数器也没那么简单7.1 程序计数器唯一不会 OOM 的区域程序计数器也叫 PC 寄存器是每个线程私有一小块内存空间用于存储当前线程正在执行的字节码行号。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。分支、循环、跳转、异常处理、线程恢复等基础功能都依赖它。它有两个很特别的地方一是它是 JVM 规范中唯一没有规定任何OutOfMemoryError的区域因为这块内存非常小且生命周期跟随线程线程结束它就被回收二是如果线程执行的是 Native 方法程序计数器值是空Undefined因为 Native 方法不是通过字节码执行的。多线程切换时每个线程独立的程序计数器保证了一个线程被挂起后还能恢复到正确的执行位置。这里的理解难点在于这是一块和“代码执行位置”强绑定的区域而不是存储数据的区域。面试中如果有人问你哪些区域不会发生 OOM答案就是程序计数器。7.2 直接内存堆外世界容易漏查直接内存不属于 JVM 运行时数据区但它和 JVM 性能、OOM 的关系非常紧密。NIO 中的DirectByteBuffer通过ByteBuffer.allocateDirect()分配堆外内存底层的实际内存由操作系统分配JVM 堆中只保留一个小小的引用对象。堆外内存最大的价值是减少数据拷贝。网络读写时如果使用堆内HeapByteBuffer数据可能要从堆内复制到堆外再传给操作系统多一次复制如果直接使用堆外内存系统调用可以直接读这块内存更适合高吞吐的 IO 场景。但直接内存有个麻烦它不归堆管堆内存充足时也可能因为系统内存耗尽而出问题。排查时会看到进程总内存占用很高但jmap -heap显示堆内占用很低这时候就要怀疑直接内存了。相关参数是-XX:MaxDirectMemorySize默认值在部分版本中和-Xmx一致也就是说默认情况下堆外能用的内存上限等于堆大小上限。我之前在数据处理服务里大量使用allocateDirect上线一段时间后频繁出现 OOM报错信息还不是Java heap space而是OutOfMemoryError: Direct buffer memory。最后发现是每次处理请求都创建新的 DirectByteBuffer没有及时释放堆外内存被耗尽。解决方式是复用 Buffer、控制并发量并把MaxDirectMemorySize设置成合理值。8. 从笔记到实战高频面试题和调优工具里怎么用8.1 几个高频面试题的解答思路把内存结构学完之后很多面试题其实都能从原理层面直接推出来JVM 内存结构有哪些区域哪些线程私有哪些共享标准答案先分私有和共享程序计数器、虚拟机栈、本地方法栈是线程私有的堆、方法区是共享的。再补充运行时常量池在方法区中直接内存是堆外。堆和栈的区别栈管方法调用存栈帧和局部变量堆管对象实例。栈内存跟随线程生命周期自动释放堆内存由 GC 管理。所有线程共享堆但每个线程有自己的栈。哪些区域会抛出 StackOverflowError哪些会 OOM虚拟机栈和本地方法栈会抛StackOverflowError堆、方法区、直接内存会抛 OOM程序计数器不会 OOM。元空间替代永久代的原因永久代大小难以确定经常 OOM而且需要设置上限元空间使用本地内存默认由系统内存决定上限更适合动态生成类的现代框架。什么对象会进入老年代大对象直接进入老年代经过多次 Minor GC 存活且年龄达到阈值的对象进入老年代动态年龄判断也可能让对象提前晋升。这套答案的根基还是对内存结构每个区域的定位有清晰认知。没有这个基础只能靠背诵面试官换个角度问就露馅。8.2 用现成工具把内存结构“看”出来工具是内存结构知识的最好验证方式。下面是我常用的组合jps列出当前机器上的 Java 进程拿到进程 ID。jstat -gcutil pid 1000 10每秒输出一次 GC 百分比信息看 YGC、FGC 数量和耗时。jmap -heap pid查看堆配置、各代空间使用情况。jmap -dump:live,formatb,fileheap.bin pid导出堆转储文件后续用 VisualVM 或 Eclipse MAT 分析。jstack pid查看线程栈能直接看到每个线程当前执行到哪个类哪个方法对定位死锁、长时间停顿很有帮助。jconsole/VisualVM图形化界面适合在测试环境观察内存曲线。Arthas 的dashboard和memory命令线上排查利器不用重启服务就能看到内存概况和线程状态。举个例子服务出现内存告警时我一般按这个顺序查先用jps确认进程没跑偏再用jstat -gcutil看 GC 频率如果 Full GC 频繁且回收后占用率依然很高基本可以判断堆内有大量对象无法回收下一步用jmap -dump导出堆快照用 MAT 找大对象和引用链。这个流程每一步都对应着内存结构里的具体区域GC 频率对应堆和 GC 算法堆快照对应对象布局和引用关系线程栈对应栈帧和局部变量表。8.3 一个可以复现的 OOM 定位思路假设线上报了java.lang.OutOfMemoryError: Java heap space不要急着加内存。先把-XX:HeapDumpOnOutOfMemoryError加上让 JVM 在 OOM 时自动转储然后分四步走第一步看 GC 日志确认 OOM 前是 Minor GC 频繁还是 Full GC 频繁这决定了问题对象大概率在新生代还是老年代。第二步看堆转储中的大对象用 MAT 的 Dominator Tree 找出占用内存最大的对象和它的 GC Roots 引用链确认是谁持有了这些对象。第三步回到代码检查大对象是否在循环里反复创建、缓存是否没有上限、ThreadLocal 是否没有清理。第四步再考虑是不是真的需要扩容如果代码逻辑没问题、业务量增长正常调整-Xmx才是合理动作。这套流程里“对象被谁持有”最后一定会落在栈帧的局部变量表、静态变量或者 ThreadLocal 的引用上而这些都对应着内存结构中的具体区域。8.4 我的学习方法小结整理完这份笔记我个人最大的收获是把“对象视角”建立起来了一个引用在栈帧的局部变量表里一个实例在堆里一个 Class 对象在元空间里。每次看到一段代码我会下意识想这段代码对应的对象分配在哪里、生命周期多长、GC 时会不会被错误晋升。当你对这个模型的感知变成直觉再去看 JVM 调优、垃圾回收器选择、OOM 排查都会顺畅很多。最后分享一个小习惯我每学一个区域都会用常见排查工具去验证一遍。比如写个无限创建对象的 Demo用jstat观察 Eden 区变化写个递归方法用jstack看栈深用反射动态生成类观察 Metaspace 增长。实践过一遍各个区域的关系会比看十遍书都牢固。
返回列表