
一直从事Java开发的人迟早会撞上这样一幕线上突然告警接口全线超时内存曲线一路走高重启后撑几个小时又复发。这时候翻日志最刺眼的几个字就是java.lang.OutOfMemoryError。我第一次遇到时脑子里只有一个疑问JVM的内存到底是怎么用的明明设了-Xmx2g为什么一个看起来不大的接口就把堆塞爆了后来啃规范、调GC、做堆dump分析才逐渐把JVM内存模型从面试八股文变成真正能救火的知识体系。这篇文章不打算画一堆漂亮的架构图而是用我实际排查问题和调优的经验把JVM内存模型里的角色、职责、异常行为、GC取舍、OOM定位和参数调优串成一条线。适合三类人看准备JVM面试的Java开发、正在被线上OOM或Full GC频繁折磨的后端/运维同学、想真正理解堆栈方法区背后逻辑的一线工程师。看完你会知道哪个内存区域会抛什么错、为什么对象会从年轻代跑到老年代、以及线上内存问题该从哪里入手。1. 先弄清楚JVM内存模型到底在管什么事1.1 一套规范若干区域各自有命JVM内存模型Java虚拟机规范里定义的Runtime Data Areas说白了就是给Java进程划好的一张分工表程序计数器、虚拟机栈、本地方法栈、堆、方法区JDK8后叫元空间逻辑上仍是方法区。每个区域有自己独立的职责、生命周期和异常行为。可以把它理解成一家公司堆是大仓库所有真正要保存的货物对象实例都堆在这里虚拟机栈是每个人的工位方法调用来来去去工位里放的是局部变量和操作过程方法区是墙上贴的规章制度和类蓝图描述每个对象长什么样、方法怎么调。很多人最开始学这块容易把JVM内存模型和Java内存模型JMM搞混。这是两个完全不同层面的东西JMM是并发编程层面的规范管的是多线程下共享变量的可见性、有序性、原子性典型载体是volatile和synchronizedJVM内存模型讲的则是运行时数据区域的划分和各区域的内存行为。所以在面试时听到内存模型四个字建议先反问一句你说的是运行时内存布局还是并发内存语义两个方向差得远了。1.2 理解内存模型的正确姿势我的经验是不要死记每个区域能存什么而是带着两个问题去学。第一个问题这段数据是谁的线程私有区域天然不存在线程安全问题堆和方法区才是并发访问和GC的重灾区。第二个问题这个区域满了会怎样有的区域满了只能抛StackOverflowError有的会抛OutOfMemoryError还有的根本不会溢出。把这两个问题贯穿到每个区域上你就从背诵知识过渡到了排查问题的能力层。还有一点要提醒JVM内存模型描述的是规范但你在生产环境用的是HotSpot实现。规范与实现之间有差异——比如方法区在JDK7叫永久代HotSpot在JDK8把它改成了元空间并挪到本地内存比如本地方法栈在HotSpot里被合并到了虚拟机栈比如字符串常量池在JDK7之后被移到了堆。学习时以规范为主线排查问题时则必须以你实际运行的JDK版本为准。网上大量资料还在说字符串常量池在永久代那是JDK7之前的老黄历了。2. 运行时数据区域全景拆解2.1 线程私有区程序计数器、虚拟机栈、本地方法栈先讲程序计数器。它是一块很小的内存记录当前线程正在执行的字节码指令地址。为什么需要它因为Java线程切换靠的是时间片轮转一个线程被中断再恢复必须知道自己刚才执行到哪条指令。它是唯一一个被规范明确说不会OOM的区域。实际开发中你几乎不用管它但要能说清楚它是当前线程的断点记录。虚拟机栈是面试和排查的重灾区。每次方法调用会创建一个栈帧栈帧里装着局部变量表、操作数栈、动态链接、方法出口等。局部变量表存放基本类型变量、引用类型变量注意是引用本身不是对象和returnAddress等操作数栈是字节码运算时临时放数据的地方比如执行iadd时把两个int从操作数栈弹出相加再把结果压回去动态链接指向运行时常量池里的类符号引用用来支持多态调用方法出口记录调用者的返回地址。栈的深度不是无限的当递归太深或方法调用链太长就会抛StackOverflowError。HotSpot默认给每个线程的栈大小大概是1MB不同平台和JDK版本有差异常见是512KB到1MB可以通过-Xss调整。这里我踩过一个坑早期为了解递归深度问题把-Xss调得特别大结果线程一多整个进程的虚拟内存直接失控了。线程栈是每个线程复制一份的1000个线程每个栈1MB光栈就是1GB的虚拟内存。所以调栈大小之前先想清楚你的线程总数。本地方法栈是给native方法用的。HotSpot把本地方法栈和虚拟机栈合并成了一个所以在HotSpot上通常见不到单独配置了解这一点就够了。2.2 线程共享区堆、方法区、直接内存堆是JVM内存模型里的绝对主角。几乎所有对象实例和数组都在这里分配它是GC管理的主要区域也是绝大多数OOM发生的地方。物理上堆内存不要求连续逻辑上连续即可。堆内部在主流收集器下会进一步划分为年轻代和老年代年轻代又分为Eden区和两个Survivor区S0、S1。这个划分是GC分代回收策略的基础具体逻辑放到GC章节细讲。方法区在JDK8之前叫永久代JDK8之后HotSpot用元空间Metaspace替代。注意一个容易混淆的点元空间在物理上不再是JVM堆的一部分而是使用本地内存逻辑上它仍然是JVM规范中的方法区。它存储类元信息类版本、字段、方法、接口、静态变量JDK7之后实际存放位置有争议但概念上属于方法区、运行时常量池等。动态生成类反射、CGLIB、热部署、大量lambda会让元空间不断膨胀最终抛Metaspace OOM。元空间默认没有上限受操作系统物理内存约束我强烈建议生产环境显式设置-XX:MaxMetaspaceSize否则类加载器泄漏时你会看到整个进程被慢慢吃干。直接内存Direct Memory不在JVM运行时数据区的规范范围内但NIO的DirectByteBuffer会用本地内存分配缓冲区受-XX:MaxDirectMemorySize限制默认等于堆的最大值。我在排查OOM时见过一种情况堆内存明明还剩下不少进程却因为本地内存耗尽崩溃最后定位是大批DirectByteBuffer没释放。所以要真正搞清一个Java进程的内存使用不能只盯着-Xmx要看堆 元空间 线程栈 直接内存 代码缓存 GC管理结构这一整盘账。把各区域的属性收拢成一张表方便速查区域是否线程私有存储内容溢出类型GC是否参与常见参数程序计数器是字节码指令地址不会溢出不参与无虚拟机栈是局部变量表、操作数栈等StackOverflowError不参与栈中引用指向堆对象-Xss本地方法栈是native方法调用信息StackOverflowError不参与-Xss堆否对象实例、数组、字符串常量池OutOfMemoryError: Java heap space重点参与-Xms/-Xmx/-Xmn等方法区/元空间否类元信息、运行时常量池、静态变量OutOfMemoryError: Metaspace参与元数据回收-XX:MaxMetaspaceSize直接内存否DirectByteBuffer等NIO缓冲OutOfMemoryError: Direct buffer memory不直接参与-XX:MaxDirectMemorySize3. 对象的一生——从创建到回收3.1 创建对象时JVM在忙什么一个new关键字背后JVM做了一连串动作。第一步是类加载检查遇到new指令JVM先到常量池里定位类的符号引用检查这个类是否已完成加载、解析和初始化。如果还没加载就要先触发类加载过程这里可能出现NoClassDefFoundError或静态代码块的异常。第二步是分配内存。分配前得先算出对象需要多大然后在堆里找一块连续空间。这里有两种方案如果堆内存是规整的比如复制算法整理过后的内存就用指针碰撞把空闲区边界指针往对象大小方向挪一下即可如果堆内存不规整比如用标记-清除算法之后就得用空闲列表从记录空闲块的列表里找一块分配。GC后堆的规整程度直接决定了采用哪种方案。第三步是并发处理。多线程同时分配对象会有竞争JVM用了两种手段CAS加失败重试保证分配动作的原子性TLABThread Local Allocation Buffer每个线程在Eden区里划一小块私有缓冲区线程先在自己的TLAB里分配对象用完再向公共区域申请新的大部分分配动作不用加锁。HotSpot默认开启TLAB这也是为什么一般不建议乱关这个优化。第四步是把分配到的内存空间对象头除外初始化为零值这样对象的任何字段在赋初值前就能读到默认值0、null、false。第五步是设置对象头包括Mark Word存储哈希码、GC分代年龄、锁状态标志等信息和类型指针指向方法区的类元数据用于运行时确定对象类型。最后才是执行init方法按Java代码里的构造函数初始化字段。严格说构造函数执行之前对象内存和对象头已经就绪了。3.2 对象在内存里长什么样对象在堆里的布局分三部分对象头Header、实例数据Instance Data、对齐填充Padding。对象头是理解Java内存和锁的关键它含两部分Mark Word和类型指针。Mark Word以64位为例存了一堆动态信息对象哈希码、GC分代年龄、锁状态标志、是否偏向锁等。同一块区域在不同状态下语义不同——无锁状态下存哈希和年龄偏向锁状态下存线程ID和epoch。所以分析dump时如果想知道一个对象是否被GC照顾过可以看它的年龄字段。类型指针指向类的元数据。HotSpot默认在堆小于32G时开启压缩指针-XX:UseCompressedOops类型指针从8字节压到4字节。别小看这4字节堆里成千上万的对象每个省4个字节内存占用和GC效率都明显改善。实例数据部分存放对象的实际字段值包括父类继承下来的字段。对齐填充是为了保证对象大小是8字节的整数倍方便CPU高效访问。数组对象还额外有一个记录长度的字段所以数组对象的对象头会大4字节。在MAT上我曾见过一个触目惊心的dump一个HashMap缓存里业务对象改了个字段类型单对象从48字节涨到80字节缓存容纳5万条凭空多出160MB。对象布局真不是纯理论它直接关系到大对象缓存的内存开销。3.3 访问对象句柄还是直接指针拿到一个对象引用后怎么定位对象在堆中的实际位置两种主流方案句柄访问和直接指针访问。句柄访问是在堆里维护一个句柄池引用先指向句柄句柄里再保存对象实例数据和类型数据的地址好处是GC移动对象后只需更新句柄引用本身不用改。直接指针访问则是引用直接指向对象地址对象内部自带类型指针指向方法区的类元数据HotSpot就是这种方式好处是快——一次指针跳跃就能访问到对象数据省了一次间接寻址。对性能敏感的HotSpot来说这点开销值得省。理解这个也能帮你理解为什么HotSpot的GC喜欢移动对象因为直接指针访问下对象移动后引用本身不需要变。3.4 逃逸分析带来的例外所有对象都在堆上分配这个说法在现代HotSpot里已经不那么精确了。通过逃逸分析JIT编译器如果确定一个对象不会逃逸出方法或线程就可能做栈上分配或标量替换。标量替换最简单对象拆成几个基本字段直接在栈帧的局部变量里分配连对象头都省了。比如方法内部创建了一个Point(x, y)不返回、不存全局容器JIT可能直接把x和y拆成两个局部变量来处理。我实测过一个循环创建小对象的场景打开逃逸分析优化后young GC压力明显下降。这也是为什么写代码时尽量控制对象的逃逸范围代码结构更清晰JIT也更容易做极致优化。4. GC与内存模型的关系——分代收集的设计逻辑4.1 为什么堆要分代先摆结论绝大多数对象朝生夕灭。弱分代假说和强分代假说说的就是大部分对象生命周期很短而存活得越久的对象越可能继续存活。如果不分代每次GC都要扫描全堆大量短命对象混在老家伙中间被反复检查代价极高。分代之后年轻代放新对象老年代放长期存活对象。Minor GC只清理年轻代因为大部分对象很快死去用复制算法高效清理老年代对象存活率高用标记-清除或标记-整理更划算。这里有个比例问题年轻代大小和Eden:Survivor比例直接影响GC频率和晋升速率。默认-XX:NewRatio2表示老年代:年轻代2:1-XX:SurvivorRatio8表示Eden:S0:S18:1:1。留两个Survivor的原因是复制算法需要一个存活对象先从一边搬到另一边的缓冲区。每次Minor GCEden和正在使用的Survivor中存活对象复制到另一个空的Survivor超过MaxTenuringThreshold次数的对象晋升老年代。默认阈值是15但如果Survivor里同年龄对象的总大小超过Survivor空间一半时JVM会启动动态年龄判定让较大的对象提前晋升防止Survivor被挤爆。4.2 垃圾标记从GC Roots出发对象是否存活不看引用计数循环引用会让计数失效而是用可达性分析。从一组叫GC Roots的根节点出发沿引用链遍历能到达的对象标记为存活否则判定可回收。GC Roots包括虚拟机栈中局部变量表引用的对象方法区中静态属性引用的对象方法区中常量引用的对象JNINative方法引用的对象以及被同步锁synchronized持有的对象、运行中的线程等。理解GC Roots是定位为什么这个对象还没被回收的关键——一个对象被静态Map引用着就一直是活的一个对象被线程栈里的局部变量引用着只要线程还没退出方法它也活着。这里有个常见误解finalize方法能拯救对象。JVM确实会把重写了finalize且未执行过的对象放进Finalizer队列下一次GC前再看它是否在finalize里重新引用自己。但现代JVM里Finalizer问题很多拖慢GC、资源释放时间不可控千万别依赖它。我的建议很直接资源清理用try-with-resources或显式close别用finalize。4.3 三种基础算法与主流收集器的取舍标记-清除算法先标记不可达对象再清除简单但会产生内存碎片下次分配大对象时可能找不到连续空间。标记-复制算法把存活对象复制到另一块区域处理完后旧区域整体清空无碎片但浪费空间。标记-整理算法把存活对象往一端移动清理边界以外的区域消除了碎片但移动指针成本高。没有绝对优劣只有场景匹配。把算法落到收集器上ParNew CMS是老一代互联网的常见组合CMS主打低延迟与用户线程并发执行但没法在并发阶段整理老年代内存时间长了碎片严重还会出现Concurrent Mode Failure退化为Serial Old做Full GC停顿反而更狠。G1是JDK9之后的默认收集器把堆划分为若干Region每个Region可以独立扮演Eden、Survivor、Old的角色靠可预期停顿模型控制GC时间。ZGC主打超大堆和超低延迟用染色指针和读屏障停顿控制在毫秒级。我现在的选择原则是吞吐型批处理任务用Parallel配合大堆没问题延迟敏感的在线服务直接用G1堆超过64G且延迟要求极高再考虑ZGC。4.4 各种GC触发条件与Full GC的口径很多人分不清Minor GC、Major GC、Full GC。Minor GC指年轻代GC触发条件很简单Eden区空间不足。Major GC通常指老年代GC但HotSpot的GC日志里Major GC经常和Full GC混着叫导致误读。Full GC针对整个堆包括年轻代、老年代、元空间。触发条件包括老年代空间不足、元空间不足、调用System.gc()未禁用ExplicitGCInvokesConcurrent时、CMS并发失败后退化、堆内存分配失败等。G1还有一个特殊场景对象大小超过Region大小的一半会进入Humongous区这些大对象持续分配时可能导致G1频繁Full GC。线上常见问题是某个大对象频繁创建G1来不及并发回收加大Region大小、或者检查业务中是否在循环创建超大数组才是根治方向。只看GC日志里的Full GC次数不够要看停顿时间、GC前后内存差额、是否伴随空间分配失败。有几次我在日志里看到Full GC字样但停顿只有几十毫秒那是并行回收线程在工作不是单线程退化所以别一看到Full GC就慌了。5. 内存排查实战——从OOM到GC频繁一把梭到底5.1 四种典型内存问题现场Heap OOM最常见现象是java.lang.OutOfMemoryError: Java heap space。常见原因对象缓存无界增长、一次性加载超大集合、批量导入导出时数据集超过堆。处理思路不是上来就加-Xmx而是先dump堆找到占用最大的对象。StackOverflowError通常出现在递归无终止条件、调用链深度过大、或栈太小异常名是java.lang.StackOverflowError出现时先看代码调用链必要时调-Xss。Metaspace OOM的现象是java.lang.OutOfMemoryError: Metaspace常见于动态代理类生成、热部署、反射、字节码生成框架解决思路是限制-XX:MaxMetaspaceSize并排查类加载器泄漏。Direct buffer OOM的现象是java.lang.OutOfMemoryError: Direct buffer memory常见于Netty和各类NIO框架要检查直接内存分配与释放必要时调-XX:MaxDirectMemorySize并检查Buffer池。比较难区分的是进程内存不高却被操作系统杀掉的情况。这往往是线程栈数量过多、直接内存泄漏、本地内存超限造成的光靠-Xmx解决不了。我见过一个案例同事把-Xmx调到8G但每线程栈设了2MB线上500个线程光栈就1G再加元空间和代码缓存8G容器的内存直接撑爆被OOM Kill。所以调堆容量的同时要整体评估进程对系统内存的占用。5.2 排查工具链与命令现场排查JVM内存问题我一般按这个顺序走。先用jps找到目标进程ID再jstat -gcutil PID 1000 10观察GC情况和各区使用百分比。输出里的E、S0、S1、O、M列分别代表Eden、两个Survivor、老年代、元空间YGC和FGC是年轻代和Full GC次数。如果O列长期接近100且FGC增长频繁基本确认老年代在痉挛。下一步jmap -histo PID | head -40看堆中实例数最多的类如果某个业务类实例数异常增长大概率是泄漏点。要拿完整堆快照用jmap -dump:live,formatb,fileheap.bin PID但live参数会额外触发一次Full GC生产环境要谨慎最好在低峰操作或者干脆配合-XX:HeapDumpOnOutOfMemoryError让JVM在OOM时自动落盘。拿到dump之后我习惯用MAT打开重点看Leak Suspects报告和Dominator Tree。Leak Suspects直接给出可疑的大对象持有链Dominator Tree告诉你哪个对象以最大支配面积占住内存。比如一个ConcurrentHashMap里堆了几百万个Session对象在Dominator Tree里一眼就能看到Map及其Entry数量。另一个好用的工具是Arthas在线执行memory和dashboard命令能快速看堆、非堆、GC情况配合jad反编译定位代码不用重启进程。贴一段常用命令的输出效果$ jstat -gcutil 12345 1000 5 S0 S1 E O M CCS YGC YGCT FGC FGCT CGC CGCT 0.00 6.20 98.30 35.40 92.10 89.30 1284 7.580 3 1.210 2 0.510E区接近98说明年轻代GC频繁触发O区35说明老年代暂时还好M区92接近元空间上限如果继续上涨就要留意。每秒输出一次看几轮就能判断内存变化趋势。5.3 一个真实的泄漏排查案例几年前我接手过一个在线订单服务现象是运行两小时后接口响应逐渐变慢最后Full GC频繁。监控曲线显示老年代内存指数式爬升。我没有先翻代码而是直接jmap -histo结果发现订单状态机上下文OrderContext对象数量达到数百万。再dump堆MAT的Leak Suspects直接指向一个静态的HashMap。定位到代码后真相大白订单服务为了做状态流转跟踪把每个订单的上下文塞进了静态缓存却没有做移除逻辑。订单完成、取消之后上下文永驻内存。叠加一个流量高峰上下文数量瞬间爆表。修复分两层。紧急处理把静态缓存改成带过期时间的本地缓存比如Caffeine设置最大容量和过期策略根治处理把订单状态机的中间状态改为在Redis里存储临时上下文不再驻留本地堆。修完观察三天Full GC从每小时两三次降到零老年代内存趋于平缓。这个案例里最有价值的动作就是jmap -histo加一次堆dump十几分钟锁定问题。如果当时只靠加内存缓解只会把问题推迟爆雷。5.4 内存排查避坑清单排查过程中我踩过不少坑列几个最重要的。第一不要在高峰期执行jmap -dump:live它会先做Full GC再dump对在线服务影响很大用自动落盘替代-XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/data/logsOOM时自动生成dump。第二jmap -histo:live同样会触发Full GC如果只想看实例分布用不带live的jmap -histo。第三G1场景下单看老年代使用率看不出Humongous对象的问题要关注Region大小和Humongous对象统计配合源码搜索大数组创建。第四元空间OOM别使劲调大-XX:MaxMetaspaceSize它只是个天花板真正泄漏的是类加载器无法卸载要查动态生成类的来源。第五排查时先记录基线数据GC次数、GC耗时、堆用量曲线改完一个参数再看趋势不要同时改七八个参数否则根本说不清哪个改动生效了。6. JVM内存参数调优实践——参数不是乱调的6.1 关键参数与默认值速查先把常用参数给一张速查表。注意参数之间有联动不要单独判断。参数作用典型默认值说明-Xms / -Xmx初始堆 / 最大堆物理内存的1/64和1/4生产环境建议设相同值避免扩容抖动-Xmn年轻代大小由NewRatio计算直接影响Minor GC频率-XX:NewRatio老年代:年轻代比例2官方建议优先用-Xmn-XX:SurvivorRatioEden:Survivor比例8太小会造成早晋升-XX:MaxTenuringThreshold晋升阈值15动态年龄判定可能覆盖它-XX:MaxMetaspaceSize元空间上限无上限建议显式设置防无限膨胀-Xss线程栈大小平台相关调大前先算线程总数-XX:MaxDirectMemorySize直接内存上限等于-XmxNIO场景重点检查-XX:UseG1GC使用G1收集器JDK9默认延迟敏感服务首选-XX:HeapDumpOnOutOfMemoryErrorOOM自动dump关闭建议开启并设置路径6.2 一个Spring Boot服务的调优过程说一个调过的典型服务4核8G容器跑Spring Boot应用QPS峰值2000P99要求小于200ms。初始配置直接-Xms4g -Xmx4gJDK17默认G1。上线第二天就发现问题年轻代GC频率很高每次Minor GC耗时十几毫秒但频率太高GC总耗时占了CPU的15%偶尔还出现老年代上涨。我先用jstat -gcutil观察发现Eden区每5分钟就占满老年代稳定在30%左右说明堆配置比例不合理。调整分两步。第一步调整年轻代大小观察YGC频率。我把-Xmn从默认的2g左右调到1.2g让年轻代只占30%多一点同时把-XX:SurvivorRatio从8调成6给Survivor更大缓冲减少对象过早晋升。结果YGC频率上升了一些但单次GC耗时从15ms降到了8ms整体GC CPU开销下降。这里有个反直觉点年轻代太大Minor GC要复制的目标区域大单次耗时更长太小频率高。要找到平衡点不能只看频率。第二步给G1设置停顿目标-XX:MaxGCPauseMillis50并打开GC日志-XX:PrintGCDetails -XX:PrintGCDateStamps。G1会根据目标自动调整Region回收数量实测P99从240ms降到160msGC停顿对接口延迟的影响变得不明显。最后反思调优不是把参数往大调也不是照抄别人的配置而是先明确瓶颈在频率还是单次停顿。频率高考虑加大Eden或减少每次GC的扫描范围单次停顿大考虑收集器切换、Region大小、并发线程数。每次改动都要用GC日志和监控指标验证切忌一把梭。6.3 经验之谈参数调优的错误示范网上很多JVM调优宝典一上来就-XX:UseG1GC -XX:MaxGCPauseMillis10 -Xmx16g看起来很专业问题很大。最大停顿时长设太小会让G1频繁扩大收集范围造成为了低延迟反而高CPU的恶性循环。我见过一个项目把MaxGCPauseMillis设成5结果GC线程疯狂抢占CPU业务线程饿死。调优的第一性原理是先判断瓶颈在哪再去动收集器停顿目标。还有一点容器内存有限时堆设到物理内存90%以上还没等GC优化操作系统已经换页了那才叫灾难。7. 常被问到的内存模型面试题与易错点7.1 高频问题速答几个反复出现的问题我直接给标准答案。第一哪些区域会OOM堆会方法区/元空间会虚拟机栈在动态扩展时也可能OOM直接内存也可能OOM程序计数器不会。第二对象一定在堆上分配吗不一定绝大多数在堆上但JIT逃逸分析可能栈上分配或标量替换。第三字符串常量池在哪个位置JDK7之后位于堆中别再说永久代。第四什么情况下对象晋升老年代达到晋升阈值、动态年龄判定、大对象直接进入PretenureSizeThreshold或G1的Humongous规则。第五内存泄漏和内存溢出的区别泄漏是对象无法被回收导致内存占用越来越大最终可能溢出溢出是分配不出新内存泄漏是溢出的常见原因之一。第六CMS并发阶段哪里会STW初始标记和重新标记阶段会STW并发标记和并发清除不会。7.2 易错点清单老年代用标记-清除年轻代用标记-复制这句话正确但容易误导。老年代里CMS确实用标记-清除但G1老年代回收用的是Region级复制别把算法和收集器绑死。-Xmx和-Xms相同不是必须但生产环境建议相同去掉堆扩展的开销。默认参数在JDK版本之间差异很大尤其是默认GC。JDK8上调G1和JDK17上调G1很多默认值不一样答案以实际运行JDK为准。线程栈占用的是进程虚拟内存小规格容器里线程数一多线程栈的开销很可观排查内存时别忽略线程数。String.intern()在JDK7之后会把字符串放入堆中的StringTable大量intern字符串会直接增加堆内存占用还可能带来StringTable扩容引发的长时间STW。7.3 给还在啃概念的人一句实在话我也是从背概念开始再用排查经验反过来理解概念的。如果你刚开始接触JVM内存模型别试图一次性把GC、收集器、参数全学完。先把运行时数据区域这张图用熟再找一台测试机器把一个小服务跑起来用jmap、jstat、jvisualvm一遍遍观察对象分配和GC过程。亲手看一次对象从Eden区复制到Survivor、再晋升到老年代比读十篇内存模型详解都管用。等后面真遇到线上问题再带着问题回来翻细节那时候你对内存模型的理解就不再是一个个名词而是一整套能用来定位问题的坐标系。我个人还有一个习惯每个新项目上线前都会检查一遍JVM的启动参数里有没有开-XX:HeapDumpOnOutOfMemoryError、-Xms和-Xmx是否一致、元空间有没有设上限。这几项确认完至少能保证线上出了问题时有足够的现场信息可以回溯。很多时候排障难不是难在不会分析dump而是根本没有dump可分析。把基础工作做在前面比任何花哨的调优技巧都值钱。