
每次有人在群里发“来背一下JVM的内存模型”我心里都会先咯噔一下。JVM八股文刷了八百遍的人真到线上遇到Docker容器里的Java进程突然消失反而连日志文件在哪都找不到。这不是我夸张是我这几年帮团队排查线上问题总结出来的经验面试题只是“知识的目录”实战是“用知识解决具体问题”。所以这篇既不打算给你整理一份新的背诵清单也不打算再把垃圾收集器参数表抄一遍。我想做的是把堆、栈、GC、类加载、JIT这些高频概念放到真实的日志、参数和故障现场里让大家知道它们到底在解决什么问题。不管你是准备jvm面试题还是正在被“SQL执行时间超过10秒”这种到底算哪一层的锅所困扰都可以从这套逻辑里找到自己的答案。1. 一条OOM日志暴露出的真相八股文和实战之间隔着一张日志先看一个我经常拿来考团队新人的东西。这不是什么冷门知识点就是一行很常见的报错java.lang.OutOfMemoryError: Java heap space Dumping heap to java_pid12345.hprof ... Heap dump file created [12345678 bytes in 0.123 secs]如果是背八股文到这里往往就停了——“哦堆内存不够了调大 -Xmx 就行”。但真到了线上这句话会引发一连串问题是哪个区域的内存不够对象的分配速率怎么样dump 文件里到底是什么对象占了内存GC 日志里有没有 Full GC 频繁触发的迹象这次 OOM 发生前Old 区是不是已经膨胀了很久如果你把这些都回答清楚了调大 -Xmx 才是一个有理有据的决策否则就是赌运气。1.1 读懂OOM日志比背诵Error类型更重要很多人对 JVM 的异常类型背得滚瓜烂熟堆溢出、栈溢出、元空间溢出、无法创建本地线程……但一旦把这些异常放到真实场景里他们往往连“这个 OOM 是 JVM 抛的还是容器 Kill 的”都分不清。区分两者有个很简单的土办法看日志里有没有“Dumping heap”和与之对应的 hprof 文件。java.lang.OutOfMemoryError: Java heap space这种是 JVM 在堆空间无法分配新对象时主动抛出的异常它属于应用层能感知到的错误而 Docker 容器超出内存 limit 被 cgroup OOM Killer 杀掉时进程是直接消失的JVM 甚至来不及打印任何异常栈。同一个词“OOM”前者是 JVM 内部资源枯竭后者是容器级外部强杀。你不看日志就很容易把调大 -Xmx 用在错误的场景里结果容器还是继续被杀。所以我在排查问题时第一步永远是先拿到现场日志。这个习惯也解释了为什么第 7 章要专门讲容器里的日志配置——没有日志一切分析都是空中楼阁。1.2 从GC日志反推系统状态堆空间 OOM 之前一般不是没有征兆的。一张 GC 日志里藏着大量信息[GC (Allocation Failure) [PSYoungGen: 6144K-512K(6144K)] 6144K-528K(19968K), 0.0012345 secs] [Times: user0.01 sys0.00, real0.01 secs]别看这一行长得吓人拆开就几件事新生代发生了什么 GC回收前用了多少、回收后剩多少、整个堆用了多少GC 停顿多久。读懂了 GC 日志你能在 OOM 发生之前就看出系统处于什么状态Eden 区每次分配都很快满说明对象分配速率高Old 区持续增长且回收不掉大概率存在对象泄漏Full GC 频繁发生则要考虑堆大小不合理或者晋升阈值设置问题。有人会问把这些看明白了有什么用用处太大了。我在压测环境里见过一个典型案例同样的流量默认参数下 GC 停顿在 200 毫秒通过 jstat 观察几天后调整新生代比例和晋升阈值停顿降到了 50 毫秒以下吞吐量提升反而更明显。这种优化不是靠背八股文能做到的而是靠“看懂日志—定位瓶颈—调整参数—验证效果”这个循环。2. JDK、JRE、JVM三件套的真实分工从字节码到机器码的完整链路问“JDK 和 JRE 有什么区别”这种问题是很多 JVM 面试题的开胃菜。但如果你只记得“JDK 包含 JREJRE 包含 JVM”那你只理解到了第一个层次。2.1 三者的边界不是“包含关系”那么肤浅把三者的关系用图片表示是一个三层同心圆JVM 在最里层JRE 包住 JVM 并附带核心类库JDK 再包住 JRE 并附带 javac、jstack、jmap、jhat 这些开发调试工具。这样记没问题但我更希望你看清楚它们各自存在的理由。JVM 是 Java 跨平台的根基。它定义了一套抽象的“Java 虚拟机”屏蔽了底层操作系统和 CPU 架构的差异同一份.class字节码在 Linux、Windows、Mac 上由各自的 JVM 实现解释执行。JRE 则是在 JVM 之外把运行 Java 程序所需的类库java.lang、java.util这些和启动命令打包在一起。JDK 是给开发者的完整工具链里面除了 JRE还包括把.java编译成.class的javac、查看线程状态和堆内存的工具。JVM 本身也是分层的。HotSpot 是最主流的实现别的厂商可以基于 JVM 规范做自己的实现。面试时如果能把“JVM 是规范HotSpot 是具体实现”这句话说出来说明你对这个东西的理解已经超过“背关系图”的阶段了。2.2 一个.class文件从加载到执行经历了什么用一个最简单的例子串起来public class Hello { public static void main(String[] args) { System.out.println(Hello JVM); } }javac Hello.java命令不会直接生成机器码而是生成一个魔数以0xCAFEBABE开头的 Class 文件。字节码对 JVM 来说就是一套指令集例如getstatic读取静态字段、invokestatic调用静态方法。JVM 运行时首先由类加载子系统把.class文件加载进来经过验证、准备、解析、初始化等步骤之后把字节码交给执行引擎。执行引擎里包含了解释器和 JIT 编译器解释器负责逐条把字节码翻译成当前平台的机器指令执行JIT 则把热点代码直接编译成本地代码缓存下来。所以“Java 是编译型还是解释型”这个问题本身就是个陷阱。早期 Java 确实以解释执行为主但现代 HotSpot 普遍采用分层编译解释器负责快速启动JIT 负责把热点代码优化成本地机器码。回答“两者都有”不算错能说出“看代码执行频率”才是加分项。2.3 解释执行与JIT编译的分工JIT 的启动需要时间所以 JVM 冷启动阶段会先用解释器跑。大量热点方法被反复调用之后JIT 介入把方法编译成机器码放到 Code Cache 中。Code Cache 也不是无限大的JVM 默认有容量限制一旦写满JIT 会停止编译系统退回解释执行性能会明显下降。这里有个常见误区很多初学者以为 JIT 只在服务器模式下存在其实 Client/Server 模式是历史产物。现在默认分层编译Tiered CompilationC1 编译器负责快速编译出较差的代码C2 编译器负责深度优化代价是编译时间更长。这些机制在后面的第 6 章会进一步展开因为理解 JIT 之后你才能真正看懂-XX:CompileThreshold这类参数在管什么。3. 运行时数据区为什么堆是共享的栈是私有的JVM 内存模型是八股文里的重灾区。网上图一堆文字一堆但大概率没人告诉你这些区域为什么要这样划分为什么虚拟机栈要线程私有为什么堆要全局共享把这些“为什么”想清楚记忆负担会小很多而且你还能反过来推断出哪些区域会抛哪种异常。3.1 五块区域的职责与生命周期按 JVM 规范运行时数据区可以分为程序计数器、虚拟机栈、本地方法栈、堆、方法区JDK 8 之后叫元空间。程序计数器是线程私有的而且很小它记录的是当前线程正在执行的字节码行号。它是唯一一个不会抛出 OutOfMemoryError 的区域因为它根本没有分配额外的堆空间。虚拟机栈也是线程私有的描述 Java 方法执行的线程内存模型。每次方法调用都会创建一个栈帧栈帧里有局部变量表、操作数栈、动态链接、方法返回地址。线程创建时栈大小由-Xss决定如果方法递归太深或者栈分配过大就会出现StackOverflowError。本地方法栈服务于 native 方法比如 JNI 调用 C/C 写的库HotSpot 把本地方法栈和虚拟机栈合并了所以 JVM 层面经常只看到“栈”这一个概念。堆和方法区是线程共享的。堆是对象分配的主战场也是垃圾回收的主战场元空间存放类的元数据和常量池等结构从 JDK 8 开始移出堆外使用本地内存。这样设计有一个核心原因让所有线程共享这些区域可以避免每个线程都复制一份完整数据同时让垃圾回收器统一管理对象生命周期。3.2 一次方法调用中的栈帧变化假设代码是这样的public int getTotal() { int base 10; return base compute(5); } public int compute(int n) { return n * 2; }当getTotal()被调用时JVM 在当前线程的虚拟机栈中压入一个栈帧。栈帧的局部变量表存着base和this操作数栈用来放中间计算结果。执行到base compute(5)时会为compute(5)压入新的栈帧参数n存放在新栈帧的局部变量表里。compute返回后新栈帧出栈结果值被压入getTotal原本的栈帧操作数栈中。每次方法调用都对应一次压栈和出栈。栈空间小了大量方法嵌套调用就会爆栈每次调用都会产生新栈帧所以方法调用是有成本的这也是为什么 JIT 要做方法内联优化——把短方法直接展开到调用方里减少栈帧的创建和销毁。3.3 常见的OutOfMemoryError到底发生在哪把异常类型和区域对应起来能让排查少走很多弯路异常信息发生区域典型原因Java heap space堆堆内存不足或对象泄漏Metaspace元空间类元数据过多或元空间上限设置不合理unable to create new native thread本地内存/操作系统线程数超限或线程栈内存不足Direct buffer memory堆外内存NIO 直接缓冲区耗尽很多同学遇到“unable to create new native thread”会误以为调大堆内存就能解决实际上这个错误通常和线程数、操作系统限制或-Xss设置有关和堆内存关系不大。反过来说Java heap space报错时你第一反应也不该是盲目调大 -Xmx而是先打开堆 dump 看谁占了内存。4. 类加载与双亲委派框架能跑起来靠的恰恰是“打破”它类加载机制是 JVM 面试题里的常客。我见过的最浅薄回答是把那几个加载器名称背一遍Bootstrap、Platform、Application。但面试官真正想听的往往是后面那句“为什么要这样设计打破它之后会发生什么”。4.1 类加载的前四步做了什么类加载的完整生命周期包括加载、验证、准备、解析、初始化、使用、卸载。加载阶段通过类的全限定名获取二进制字节流并在堆中生成java.lang.Class对象。验证阶段会检查字节流是否符合 JVM 规范防止恶意字节码破坏运行时。准备阶段为静态变量分配内存并设置零值比如static int count 100这个阶段count的初始值是 0而不是 100。解析阶段把符号引用替换为直接引用。初始化阶段执行静态代码块和静态字段赋值也就是真正执行clinit方法。很多坑都藏在“准备”和“初始化”的区别上。如果你在同一个类里既有静态字段又有静态方法调用实际触发的顺序是准备阶段先把所有静态字段归零初始化阶段才按代码顺序赋值。这里如果理解不透彻排查静态工具类初始化问题时会很困惑。4.2 双亲委派机制存在的理由双亲委派的核心逻辑是当一个类加载器收到加载请求先把这个请求委派给父加载器父加载器再往上传直到最顶层的启动类加载器。只有父加载器加载失败时子加载器才会尝试自己加载。这么设计最大的好处是安全。比如你写了一个java.lang.String想让它在 JVM 里替换 JDK 自带的 String双亲委派机制会让启动类加载器先加载官方版本你的自定义 String 永远不会被加载核心类库内部一致性不会被破坏。另一个好处是避免重复加载同一个类被多个加载器重复加载会导致内存中同时存在多个 Class 对象这在使用instanceof时会产生诡异问题。4.3 打破双亲委派的三个真实场景Java 自己的生态早就把“双亲委派”打破了很多次而且你说它被打破时语气应该是“这很正常”而不是“这是一个惊天大秘密”。第一个场景是 JDBC。DriverManager位于java.sql包由启动类加载器加载但是 MySQL 驱动等实现类躺在应用 classpath 里启动类加载器根本看不见。于是DriverManager在初始化时通过 SPI 机制使用线程上下文类加载器去加载应用 classpath 里的数据库驱动实现。这算是“父加载器请求子加载器”的经典逆袭。第二个场景是 Tomcat。为了隔离不同的 Web 应用Tomcat 为每个应用准备了独立的 WebAppClassLoader。同一个第三方库应用 A 可以用 1.0 版本应用 B 可以用 2.0 版本互不干扰。JSP 文件修改后重新编译也需要动态加载新版本类。第三个场景是 Spring Boot 的可执行 Fat JAR。大量依赖被嵌套在BOOT-INF/lib里常规的URLClassLoader无法直接加载嵌套 JAR所以 Spring Boot 自定义了LaunchedURLClassLoader来处理。面试里遇到“双亲委派能不能被打破”这种题别只回答“能”你得举出上面的例子并解释为什么需要打破。这样才是对方真正想听到的东西。5. 对象从分配到回收G1能成为默认选型不是没有道理垃圾回收是 JVM 八股文里最“卷”的部分从引用计数、可达性分析、分代算法一路背到 CMS、G1、ZGC。我的建议是与其背算法步骤不如理解整个体系在解决什么问题——对象会死需要回收回收会停顿停顿越短越好内存越大回收成本越高。5.1 对象创建的第一步没你想得那么简单很多人写 Java 十几年以为new对象只是分配内存而已。其实对象创建的第一步是触发类加载检查和类初始化。JVM 遇到new指令时会先判断这个类是否已被加载如果没有则先触发类加载过程。之后才是内存分配。分配空间可以选择指针碰撞或空闲列表这取决于堆内存是否规整。HotSpot 为了提升分配效率在线程内部设置了 TLABThread Local Allocation Buffer每个线程优先在自己私有的缓冲区里分配对象避免多线程互相争抢同一块内存。更进一步的优化是逃逸分析。如果 JVM 判断一个对象不会被外部线程访问也永远不会逃逸出方法范围它就可能把对象拆散成标量字段直接分配在栈上而不进入堆。这类对象不需要垃圾回收方法结束后随着栈帧一起销毁极大地降低了 GC 压力。5.2 可达性分析与GC Roots判断对象是否存活的算法教科书里讲的是引用计数法但 JVM 主流实现用的都是可达性分析。两者的区别在于引用计数很难处理循环引用A 引用 BB 引用 A两者都不再被外部引用引用计数却永远不为零。可达性分析从一组 GC Roots 开始像爬图一样把所有能到达的对象标记为存活剩下的全部视为可回收。GC Roots 包括虚拟机栈中局部变量表引用的对象、方法区中静态属性引用的对象、方法区中常量引用的对象、本地方法栈中 JNI 引用的对象以及活跃线程对象等。理解 GC Roots 能帮你解释一个现象为什么代码里明明已经不需要某个对象了但只要它还被某个长生命周期的静态集合引用着就始终无法被回收。5.3 G1的区域化设计与停顿可控分代收集的处理思路大家应该都知道新生代对象短命用复制算法老年代对象长寿用标记-清理或标记-整理算法。但随着堆越来越大传统的整堆回收停顿会越来越长。G1 的思路是干脆把堆划分成一个个大小相等的 Region逻辑上让 Eden、Survivor、Old 区都变成 Region 的集合。G1 的优势在于它不再要求一次回收整个年轻代或整个老年代而是维持一个可预测的停顿时间模型每次选择一批回收收益最高的 Region 来收集。它的参数-XX:MaxGCPauseMillis就是停顿目标比如你设置 200 毫秒G1 会尽量让每次 GC 停顿不超过这个值。G1 有一张多出来的“记忆集”RSet数据结构用于记录 Region 之间的跨区引用并发标记阶段通过 SATBSnapshot-At-The-Beginning算法保证并发期间对象引用变化不会导致漏标。这些细节不需要逐行背源码但你得知道G1 是一个牺牲部分吞吐量来换取停顿可控的收集器。5.4 G1常见参数与调优思路G1 刚开始普及时很多人一上来就把 Region 调成超大结果发现停顿反而更差。我的建议是先保持默认参数跑一轮压测再根据实际 GC 日志调整。常用参数就这几类-XX:UseG1GC启用 G1。在 JDK 9 之后这个参数基本可以不写因为它是 Server 模式默认收集器。-XX:MaxGCPauseMillis软目标停顿时间。需要结合真实业务设置不要盲目设成 10 毫秒否则 G1 为了追停顿目标会频繁回收反而影响吞吐量。-XX:G1HeapRegionSizeRegion 大小默认根据堆大小自动计算通常不需要显式指定。-XX:InitiatingHeapOccupancyPercent简称 IHOP默认 45。当整个堆使用率达到 45% 时G1 会启动并发标记周期准备后续的 Mixed GC。-XX:G1NewSizePercent/-XX:G1MaxNewSizePercent年轻代动态占堆的比例范围。调优的核心逻辑是先看 GC 日志里的“停顿时间”“年轻代平均大小”“Mixed GC 频率”再反向调整。如果 Full GC 频繁优先怀疑应用在分配巨量对象时导致 Humongous 区域过多或者对象晋升速率超出了老年代回收能力。网上很多“G1 调优秘籍”都是从这类日志逆推出来的。6. JVM参数与JIT别再背参数表先理解-XX:CompileThreshold在触发什么关于 JVM 参数我最怕听到的一句话是“你把常用参数整理个表给我”。参数表不是不能看但你背下来的参数如果不知道它在影响哪个环节遇到线上问题根本不知道从何下手。这一章我挑一个最容易被误解的参数-XX:CompileThreshold把它讲透。6.1 JIT是如何发现热点的JVM 不会为一辈子只跑一次的方法浪费时间做编译。它通过计数器来判断哪些代码是热点方法调用计数器统计方法被调用的次数回边计数器统计方法内部循环的跳转次数。当一个方法的调用次数超过-XX:CompileThreshold的阈值后台编译线程就会把该方法从解释执行升级为 JIT 编译执行。注意-XX:CompileThreshold在不同模式下默认值不同而且现代 HotSpot 有了分层编译之后这个参数的含义变得复杂了它更多地影响方法从解释执行进入 C1 编译的触发点而不是你以为的“总编译一次就算数”。所以如果你在压测时观察到某些方法的编译信息想通过调整 CompileThreshold 来优化性能最好先确认你是用默认的分层编译还是关掉了分层编译。java -XX:PrintCompilation -XX:TieredCompilation -version-XX:PrintCompilation会把 JIT 编译的方法列表打印出来帮助你观察哪些方法被编译了。这个输出是硬核日志但它能直观地告诉你热点方法是否已经被优化有没有因为 Code Cache 满了而停止编译。6.2 常用JVM参数按用途归类我不打算给你一张一百行的参数表按用途归成几类记住核心的就好了。内存区域相关的常见参数参数含义-Xms / -Xmx堆初始大小 / 堆最大大小生产环境建议设为一致避免伸缩开销-Xmn新生代大小-Xss每个线程的栈大小-XX:MetaspaceSize / -XX:MaxMetaspaceSize元空间初始大小 / 最大大小-XX:SurvivorRatioEden 与 Survivor 的比例默认 8:1:1GC 相关的常见参数参数含义-XX:UseG1GC使用 G1 收集器-XX:MaxGCPauseMillis停顿时间目标-XX:ParallelGCThreads并行 GC 线程数-XX:ConcGCThreads并发标记线程数排错与日志相关的参数参数含义-XX:HeapDumpOnOutOfMemoryErrorOOM 时自动生成堆 dump-XX:HeapDumpPathdump 文件输出路径-XX:PrintGCDetails打印 GC 细节JDK 9 后建议用 -Xlog:gc* 替代-XX:ErrorFileJVM 崩溃日志路径如 hs_err_pid 文件参数写多了记不住很正常但你要能分辨调堆的大小属于内存配置调 GC 暂停属于收集器配置调日志输出属于排错配置。这套分类思考方式比把一百个参数背下来管用得多。6.3 一个稍微合格的调优流程网上有很多“JVM 调优手册”我用的流程基本固定也分享给大家参考明确目标是要降低延迟还是要提高吞吐量还是避免 OOM。不同目标对应的参数策略完全不同。观察现状用jstat -gcutil pid 1000每秒输出一次 GC 情况观察年轻代、老年代的使用率和 GC 次数。获取堆快照用jmap -dump:formatb,fileheap.hprof pid抓堆用 MAT 分析大对象和引用链。看线程用jstack pid抓线程栈看是否有线程阻塞、死锁、或者自旋导致 CPU 飙高。改一个参数验证一次。不要一次改五个参数否则你永远搞不清楚是哪一个起了作用。这套流程看起来平平无奇但真比背参数表有用。我见过太多同学开局就把 -Xmx 调到机器内存的 90%结果不仅是堆内线程栈、元空间、堆外缓冲这些内存加起来早已超过物理内存系统直接 OOM Kill。6.4 参数调优的边界与坑这里泼一盆冷水JVM 参数调优的上限远没有架构设计的上限高。如果一个应用每秒钟制造几个 GB 的垃圾你再怎么调 GC 参数也很难挽回。真正的优化顺序应该是先看代码有没有创建大量不必要的对象再看数据结构是否合理最后才是调 JVM 参数。另外-Xms和-Xmx设成不同值时JVM 在运行过程中可能动态扩容和缩容。扩容时会发生 Full GC生产环境为了避免这种意外停顿通常让两者相等让堆一开始就固定大小。这是很多老 Java 工程师的默认习惯也是一个非常典型的“为什么”。还有一个常见的误解是“参数越大越好”。-Xss设太大一个线程就吃掉几个 MB启动 1000 个线程就多了几个 GB 内存-XX:MaxMetaspaceSize设得太小动态代理、反射频繁触发会直接撑爆元空间。参数设置的核心是匹配业务而不是只看最大值。7. Docker容器里Java异常重启JVM日志到底去哪了这一章是最实战的内容也是我接到过最多的求助。很多人用 Docker 部署 Java 服务进程异常退出了docker logs里干干净净什么都没有。他们问我JVM 为什么不打日志到底去哪看答案往往让人哭笑不得JVM 打了日志但打到了容器内部的工作目录容器一重启文件就没了或者说进程根本不是 JVM 主动退出而是被 cgroup OOM Killer 杀了JVM 连打印错误的机会都没有。7.1 为什么容器里“看不到”有效的JVM日志Docker 容器的标准输出会进入docker logs但 JVM 默认不会把所有日志写到标准输出。System.out.println确实会出现在docker logs里但 GC 日志、OOM dump、崩溃日志这些默认是写到文件里的。容器一旦重启临时文件系统的内容全部清空自然什么都看不到。更隐蔽的问题是很多镜像里 Java 进程的工作目录是/或者某个临时目录这些目录没有 mount 持久化卷写入的 hs_err_pid 文件、hprof 文件在容器退出后消失得干干净净。所以第一步就是不要把 JVM 日志写到容器临时目录必须写到挂载出来的 volume 里。还有一个坑如果有监控平台连接 JVM 获取参数比如用 JMX 或 JDK Attach 机制容器内会报类似[error] could not get jvm parameters and dynamic configurations properly的错误。这通常是因为工具版本和 JDK 版本不匹配或者 Attach 机制需要访问/proc与临时目录而容器权限受限。排查这类问题的时候别只盯着 JVM先检查容器是否允许进程附加到目标 JVM。7.2 一套能直接抄走的日志配置以下是我在容器里常用的一套启动参数亲测有效也适用于大多数 Spring Boot 项目java -Xms1g -Xmx1g \ -XX:MetaspaceSize256m \ -XX:MaxMetaspaceSize256m \ -XX:HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath/logs/jvm/heapdump.hprof \ -XX:ErrorFile/logs/jvm/hs_err_pid%p.log \ -Xlog:gc*:file/logs/jvm/gc.log:time,uptime,level,tags:filecount5,filesize50m \ -jar app.jar参数解释一下-Xms和-Xmx设置为相同值避免堆伸缩开启 OOM 自动 dump并在容器崩溃后依然能通过挂载卷找到文件-XX:ErrorFile用来记录 JVM 自身崩溃的 hs_err 日志-Xlog:gc*是 JDK 9 之后推荐的 GC 日志写法带轮转避免单个文件无限增长。对应 Docker Compose 或 Kubernetes 里你还需要把/logs目录挂载到宿主机或者持久化存储。这一步不做前面参数全白搭。7.3 排查链路从进程消失到定位根因假设现在的现象是容器运行一段时间后进程突然消失docker logs什么异常都没有。我的排查链路是这样的第一步确认进程是被谁杀死的。用dmesg | grep -i oom查看内核日志如果能看到类似Out of memory: Kill process 1234 (java)的记录说明是 cgroup 或宿主机的 OOM Killer 干的。这时候去看容器的内存 limit 是不是设置得太小或者 JVM 的参数是不是超过了 limit。第二步排查 JVM 内部压力。如果dmesg没有记录那就去/logs/jvm下找 hs_err 或 GC 日志。如果 GC 日志显示旧生代持续增长且回收不掉很可能是内存泄漏。抓一份堆 dump用 MAT 分析内存中的大对象。第三步检查容器内存参数。很多同事以为设置了-Xmx1g容器里就只会用 1g其实 JVM 本身除了堆还要用元空间、线程栈、JIT 编译后的 Code Cache、NIO 直接缓冲区。这些加起来最终使用量可能远超-Xmx。所以容器的内存 limit 要留出 20%~30% 的余量。第四步回到源码。如果上面三步都找不到明显问题那就用线程栈看是否存在线程无限创建、缓冲区未释放之类的代码问题。到这一步多数问题已经水落石出。7.4 Docker部署还必须注意内存limit最后聊一个非常典型的问题为什么我-Xmx设置了 2g容器里还是被 OOM Kill很可能因为容器的 limit 就是 1g。-Xmx只是约束堆的最大值不是约束整个进程的物理内存用量。JVM 的 Resident Set SizeRSS会因为元空间、线程堆栈、直接缓冲区等情况而膨胀。现代 JDK 对容器内存感知做了不少改进。JDK 8u191 之后默认启用-XX:UseContainerSupportJVM 能感知到 cgroup 的 CPU 和内存限制更早的 JDK 8 版本可能需要手动指定-XX:UseCGroupMemoryLimitForHeap或者用-XX:MaxRAMPercentage75.0这类比例参数让 JVM 把堆上限定在容器可用内存的一定比例内。如果你的镜像用的是较老的基础镜像升级 JDK 或显式设置这些参数是解决容器内堆设置异常的关键。这里也要提一个经常被混淆的问题“JVM 或者 Spring Boot 会设置一个 SQL 执行 10 秒自动关闭吗”先理清边界SQL 超时通常由数据库连接池、JDBC 驱动、MyBatis 等框架的 queryTimeout 控制和 JVM 本身没有直接关系。Spring Boot 默认也不会主动在 10 秒时把 SQL 干掉。遇到这种问题应该先看连接池配置和数据库驱动而不是去翻 JVM 参数否则方向就错了。我在实际项目里有个习惯每到一个新的 Java 容器环境先跑一遍启动脚本确认日志文件真的已经写到持久化目录然后再开始压测。这个习惯看起来笨但救过我好几次——没有日志的线上排查基本等于盲人摸象。如果你所在的团队也经常被容器里 JVM 异常重启问题困扰我建议先别急着折腾各种优化参数把日志配置、内存余量、dump 能力这三件事做好至少能帮你在下一次事故中快速找到方向。