ARTICLE DETAIL

资讯详情

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

JVM高频知识点实战:内存模型、G1收集器与OOM排查

JVM高频知识点实战:内存模型、G1收集器与OOM排查 JVM 这块八股文是 Java 面试绕不过去的坎也是很多人背了又忘、忘了又背的东西。平时写代码可能感觉不到它的存在但一到线上 OOM、GC 停顿、容器被 Kill又得回头啃这些理论。这篇文章不打算面面俱到我想结合自己踩过的坑把 Java 虚拟机相关的高频知识点串一遍从 JDK/JRE/JVM 区别、内存模型、G1 收集器到调优参数和常见报错排查让你既能应付面试也能真正拿它来定位问题。如果你是准备跳槽的 Java 开发或者正在被 JVM 调优折磨的朋友这篇可以当一份“先查目录再细看”的笔记。1. 先把 JDK、JRE、JVM 的关系搞清楚1.1 这三个名词到底谁包含谁很多面试八股文第一题就是“JDK、JRE、JVM 的区别”但真到用的时候很多人还是分不清楚。JVMJava Virtual Machine是 Java 虚拟机负责把字节码解释或编译成机器码执行这是 Java 跨平台的基础。JREJava Runtime Environment是 Java 运行时环境里面包含 JVM 和 Java 核心类库比如rt.jar、java.base模块里的那些类有了 JRE 就能跑 Java 程序。JDKJava Development Kit是 Java 开发工具包它包含 JRE还额外提供了javac、jdb、jconsole、jstat这些开发、调试和监控工具。打个比方JDK 像一个完整的厨房里面有锅碗瓢盆和菜谱开发工具JRE 是只保留了炉灶和基本调料运行环境JVM 则是那个真正把菜炒熟的过程它负责把 Java 字节码变成当前操作系统能理解的机器指令。所以结论很简单JDK 包含 JREJRE 包含 JVM。实际开发机上通常会装 JDK生产服务器如果只跑程序理论上装 JRE 就够了但很多公司为了排查问题方便也会直接装 JDK。1.2 “编译一次到处运行”的真实含义Java 源文件先通过javac编译成.class字节码文件这个文件不是给 CPU 直接执行的而是给 JVM 看的。JVM 在 Windows、Linux、macOS 上分别有对应的实现所以同一个.class文件能跑在不同平台上。很多面试题问“Java 是编译型还是解释型语言”本质上它更像是“编译 解释”的混合体字节码一开始是由解释器逐行解释执行当某段代码成为热点代码后JVM 会通过 JITJust-In-Time编译器把它编译成本地机器码下次执行直接调用速度就快很多。这里要提醒一句JVM 本身是一个规范像 HotSpot、OpenJ9、GraalVM 都是它的实现。八股文里说“JVM 是 HotSpot”严格来说不准确HotSpot 只是最流行的一种实现日常默认装 OpenJDK 或 Oracle JDK 用的就是它。面试时如果能把“规范 vs 实现”说清楚会明显加分。2. 内存模型面试至少考五块区域别只背三大区域2.1 运行时数据区全景图JVM 内存模型按线程私有和线程共享来划分标准答案是五个部分程序计数器、虚拟机栈、本地方法栈、Java 堆、方法区JDK 8 以后叫元空间。程序计数器是每个线程私有的记录当前线程正在执行的字节码行号这个区域是唯一不会抛OutOfMemoryError的地方。虚拟机栈也是线程私有的存储栈帧里面包含局部变量表、操作数栈、动态链接、方法出口等信息方法调用深了就会抛StackOverflowError。本地方法栈服务于 native 方法这个了解即可。Java 堆是线程共享的存放对象实例是 GC 的主要区域也是面试里最常问的一块。方法区在 JDK 8 之前叫永久代JDK 8 之后改成了元空间Metaspace它存的是类的元信息、常量池、静态变量等元空间默认使用本地内存不受-Xmx限制这也是很多老项目升级到 JDK 8 后遇到 Metaspace OOM 的根源。2.2 堆内存为什么还要分新生代和老年代Java 堆不是一块大平地而是按照对象存活时间分成了新生代Young和老年代Old。新生代内部又分为 Eden 区和两个 Survivor 区S0、S1默认比例是 8:1:1。新创建的对象大部分会先放到 Eden 区Eden 满了触发 Minor GC存活对象被复制到 Survivor 区每经过一次 Minor GC年龄加 1默认年龄到 15 就会晋升到老年代。这样分代的核心理由是绝大多数对象都是朝生夕死的与其全堆扫描不如在新生代里用复制算法快速清理代价小、效率高。老年代里的对象存活时间更长GC 频率更低但一旦触发 Major GC / Full GC通常耗时较长。所以面试里如果问“为什么新生代用复制算法老年代用标记清除或标记整理”回答要点就是复制算法在存活率低时效率高但浪费一部分空间老年代对象存活率高复制成本太大需要用标记清除或标记整理来减少内存碎片。2.3 “JVM 的三大区域”到底怎么回答很多旧版的八股文会简化成“堆、栈、方法区三大区域”有些面试官也习惯这么问。但如果你只答“堆、栈、方法区”可能翻车。更稳妥的答法是先给标准五区域然后补一句早期教材常说的“三大区域”指的是堆、虚拟机栈、方法区本质上是按主要内存结构做的简略分类。这样既显得你有知识储备又不会和面试官较劲。我个人的理解是面试官真正想考的是“线程私有 vs 线程共享”这个边界。栈和程序计数器是线程私有的堆和方法区是线程共享的。多线程环境下每个线程有自己的栈但对象都在堆里共享所以才需要加锁、可见性这些机制。能把这个延伸到并发编程回答就会很立体。3. 垃圾收集器选型G1 为什么能成为默认3.1 常见收集器对比从 JDK 8 默认的 Parallel Scavenge Parallel Old到 JDK 9 之后默认的 G1很多人只记住了名字不知道选型逻辑。我按实际使用场景给一张表收集器工作区域算法特点适用场景Serial新生代复制单线程停顿时间长客户端应用、内存极小Parallel Scavenge新生代复制多线程吞吐量优先后台计算任务Parallel Old老年代标记整理多线程吞吐量优先与 Parallel 配套CMS老年代标记清除并发低停顿对响应时间敏感的 Web 应用G1整堆分区 局部复制可预测停顿兼顾吞吐多核大内存JDK 9 起默认ZGC整堆染色指针停顿极低毫秒级超大堆低延迟场景CMS 在 JDK 9 里被废弃JDK 14 移除了。如果你还在维护老项目遇到“Unrecognized VM option UseCMSCompactAtFullCollection”这种报错多半是 JDK 版本升级后没有删掉 CMS 相关参数。3.2 G1 的内存模型和关键参数G1 把整个堆分成一个一个的 Region每个 Region 大小默认从 1MB 到 32MB由 JVM 根据堆大小自动决定也可以通过-XX:G1HeapRegionSize手动指定。Region 之间逻辑上仍然是分代的Eden、Survivor、Old 区域都由若干个 Region 组成还有一类叫 Humongous 的区域专门放超过 Region 一半大小的大对象。G1 最大的特点是“整堆统一管理”不再像以前的收集器那样强制严格分代这样既能做新生代回收也能做老年代回收并且可以设置期望的 GC 停顿时间-XX:MaxGCPauseMillis200。实际调优时不要把停顿时间设得太小比如设成 10ms那样 G1 会频繁做垃圾回收反而导致吞吐量暴跌。我见过有人把-XX:MaxGCPauseMillis设成 50ms结果 Full GC 反而变多了。G1 的调优核心是平衡停顿时间和吞吐量先用默认值跑一段时间再用jstat -gcutil观察 Old 区的使用趋势再考虑要不要调整堆大小和 Region 大小。另一个高频参数是-XX:G1ReservePercent默认值是 10意思是预留 10% 的堆空间用于晋升避免老年代放不下触发 Full GC这个值在内存紧张时可以适当调大但不能过大否则可用堆变小。3.3 -XX:CompileThreshold 到底是什么热词里有jvm参数 -xx:compilethreshold这个参数经常被误解为 GC 参数其实它和 JIT 编译有关。JVM 发现某个方法被反复执行就会用 JIT 编译器把它编译成本地代码这个“反复执行多少次”就是编译阈值。-XX:CompileThreshold10000意思是方法调用次数达到 10000 次后触发 C2 编译。C1Client 编译器和 C2Server 编译器的默认阈值不一样C1 通常是 1500C2 通常是 10000而且开启分层编译后情况更复杂。面试时如果被问到“JIT 编译原理”可以从计数器说起JVM 为每个方法维护调用计数器和回边计数器循环体每执行一次回边计数器加一到阈值后进入编译队列。编译完成后再替换为本地代码。实际工作中一般不需要动这个参数了解它能帮你理解为什么“JVM 要跑一段时间才变快”因为热点代码被编译了。4. 调优参数和容器场景别让 JVM 吃满容器内存4.1 堆参数与 GC 日志参数怎么配最常见的参数组合是这个java -Xms512m -Xmx1024m -XX:MaxMetaspaceSize256m \ -Xloggc:/data/logs/gc.log -XX:PrintGCDetails \ -XX:PrintGCDateStamps -XX:HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath/data/logs/heap.hprof -jar app.jar-Xms和-Xmx分别指定堆初始大小和最大大小通常建议设成一样避免运行期扩容导致停顿。但堆大小不是越大越好也要给堆外内存、线程栈、元空间留余地。一个粗略的经验法则是如果机器总内存 8G给 JVM 堆设置 4G 左右剩余留给操作系统和堆外。容器场景下则要结合容器的内存 limit 来设比如容器限 2G堆最多给 1.2G-1.5G留 500M 左右给元空间、线程栈和 IO 缓冲。JDK 8u191 之后默认开启UseContainerSupportJVM 能自动感知容器内存限制。如果你的 JDK 版本比较老又在 Docker 里跑请务必升级或者显式设置-XX:MaxRAMPercentage75.0意思是用容器可用内存的 75% 作为堆上限。不要再用-Xmx写死一个数字否则容器扩缩容后内存参数就过时了。4.2 Docker 容器中 JVM 启动不了、异常重启怎么办热词里有一条特别典型“docker 容器部署的 java 程序异常重启jvm日志在哪儿”。这里有个常见误区容器退出不一定是 JVM 抛错很可能是被操作系统杀了。当容器物理内存超限cgroup 的 OOM Killer 会直接杀掉进程JVM 连抛 OOM 的机会都没有。这时候应用日志里看不到异常docker ps显示容器已退出docker inspect能看到OOMKilled: true。排查步骤我一般这么走先看容器状态docker inspect container | grep -i oom再看宿主机日志dmesg | grep -i java确认是否有Out of memory: Kill process记录检查RestartCount如果被重启策略拉起再进容器看/proc/1/cgroup确认内存限制最后配合jstat、jmap在容器内做在线诊断如果能保留现场的话JVM 自身的崩溃日志叫hs_err_pidpid.log比如hs_err_pid12345.log它默认生成在进程工作目录。如果容器的工作目录没挂载出来容器一删日志就没了。所以部署时建议在启动参数里显式指定工作目录并用-XX:ErrorFile/data/logs/hs_err_%p.log把崩溃日志固定下来。GC 日志同理容器里千万别只输出到 stdout一旦容器重启就找不到了。4.3 JVM 或 Spring Boot 会设置 SQL 执行 10 秒自动关闭吗热词里这个问题问得很有意思。答案是JVM 本身绝对不会去管 SQL 执行时间它不认识 SQL。Spring Boot 也不会在 JVM 层面做这种拦截真正可能“10 秒断掉”的是连接池、数据库驱动或中间件配置。比如 HikariCP 的connectionTimeout默认 30 秒是获取连接的超时时间MySQL JDBC 驱动的socketTimeout默认 0表示不超时但很多云厂商或中间件会默认设置 10 秒的 socket 超时来保护资源。MyBatis 也没有全局 statement timeout除非你给单个 SQL 设置了timeout属性。所以排查“SQL 执行 10 秒就失败”时不要一上来就怀疑 JVM先看数据源配置。spring.datasource.hikari.connection-timeout、spring.datasource.hikari.validation-timeout以及 MySQL URL 里的connectTimeout、socketTimeout参数这几项才是重点。常见配置大概是spring: datasource: hikari: connection-timeout: 30000 validation-timeout: 5000 max-lifetime: 1800000如果 SQL 本身执行超过 10 秒最好检查数据库慢查询日志和锁等待而不是 Java 应用设置。5. 高频报错和 OOM 排查我来盘一份速查表5.1 常见的 OutOfMemoryError 到底怎么定位java.lang.OutOfMemoryError: Java heap space是最常见的说明堆里对象太多或者有对象泄漏。先用jps找到进程再用jmap -heap pid看堆的使用情况用jstat -gcutil pid 1000观察 GC 频率。如果 Eden 一直在波动Old 区持续上涨基本可以判断有对象无法被回收。比较靠谱的办法是在启动参数里打开-XX:HeapDumpOnOutOfMemoryErrorOOM 时自动导出堆快照然后用 MAT 或 VisualVM 分析。看谁占了内存、有没有重复的线程栈引用这是定位泄漏的核心。还有一种启动时直接报java.lang.OutOfMemoryError: insufficient memory这其实不是堆满了而是 JVM 在分配堆外内存比如元空间、线程栈时系统内存不足。常见于容器内存设得太小或者机器上开了太多其他进程。解决思路是调小 JVM 内存、检查宿主机可用内存、调整线程数上限。另外unable to create new native thread也别忽略通常是线程数超过操作系统限制ulimit -u或容器 pid 限制导致的不是堆的问题。5.2 快速定位 GC 或 JVM 参数不一致的问题启动时加-XX:PrintCommandLineFlags可以打印最终生效的 JVM 参数。这个参数在排查“为什么我配了-Xmx2g但实际进程占了 4G”非常有用能看到 JVM 实际用了哪些参数。还有一个常见坑同一台机器装了多个 JDK 版本java -version和JAVA_HOME指向不一致启动脚本里$JAVA_HOME/bin/java是好的但java命令本身却是另一个版本导致参数不兼容。如果发现 Java 进程的 GC 日志没生效先检查参数顺序。Java 的-X参数必须写在-jar之前写在后面会被当成程序参数这是很多人踩过的坑。5.3 常见启动/编译报错速查表我在群聊和博客里经常看到类似的报错整理成一张表方便直接对号入座报错内容原因处理办法No JVM could be found on your system系统找不到 Java 运行时JAVA_HOME未设置或PATH不对安装 JDK配置JAVA_HOME和PATH执行java -version验证[error] could not get JVM parameters and dynamic configurations properly工具脚本读取jvm.config失败常见于路径含空格、JDK 版本不匹配检查JAVA_HOME路径查看启动脚本中的jvm.config文件内容错误: 源发行版 17 需要目标发行版 17Maven 编译 source/target 版本与当前 JDK 版本不一致在pom.xml里配置maven.compiler.source、maven.compiler.target保持和 JDK 一致java: You arent using a compiler supported by lombok...当前 JDK 版本太新Lombok 版本太老升级 Lombok 到 1.18.30或回退 JDK 版本java.lang.NullPointerException in mapping processorMapStruct 或其它注解处理器在编译期 NPE检查 DTO 映射方法、升级依赖版本开启编译日志获取堆栈drozer: 找不到 Java命令行工具需要JAVA_HOME环境变量但没有配置设置JAVA_HOME并把%JAVA_HOME%\bin加入 PATHCould not reserve enough space for object heap32 位系统或系统内存不足堆参数超过可用内存减少-Xmx检查总内存和进程数实际排查时先看错误出现的位置是在启动前还是运行中。启动前的多半是环境变量、JDK 版本问题运行中的多半是内存、GC、资源限制问题。我每一次调试 JVM一定会先确认三件事用的哪个 JDK、启动参数是什么、GC 日志在哪里。这三件事没搞清楚后面只会浪费更多时间。5.4 面试八股文高频题快速自查这里再把 JVM 面试里出现频率最高的问题列一下你可以用来查漏补缺JVM 的内存区域有哪些哪些线程共享哪些线程私有对象在堆中的分配过程什么情况下会进入老年代如何判断对象可回收什么时候用可达性分析什么对象可以作为 GC RootsG1 和 CMS 有什么区别为什么 G1 成为默认什么是类加载机制双亲委派模型是什么为什么需要破坏它什么是 JIT解释执行与编译执行的区别如何查看 JVM 进程参数jstat、jmap、jstack各自有什么用这些问题看起来都是背诵题但最好结合上文里的场景去理解。比如“对象什么时候进入老年代”不只是回答“年龄到 15”还要说出大对象直接进老年代、动态年龄判断、存活区放不下直接进老年代这些细节面试官就会觉得你是真的理解而不是背的。6. 最后分享几个小习惯我在实际项目里被 JVM 问题折磨过很多次现在养成了几个习惯。第一所有 Java 服务启动脚本里强制加上-XX:PrintCommandLineFlags和-XX:HeapDumpOnOutOfMemoryError为事后排查留证据。第二GC 日志和崩溃日志一定要落到持久化磁盘容器场景必须挂载出来不然容器一重启就是“案发现场被清理”。第三不要随便抄网上那种-Xmx8g的命令先看机器内存和容器限制再留出堆外内存的余量JVM 调优更像是“做减法”把不必要的参数删干净比堆一堆参数更重要。JVM 这块内容很多一篇不可能覆盖到全部。但只要你把内存模型、垃圾收集器、常用参数、线上排查工具这几条主线打通再去啃源码也好、看实战文章也好都不会觉得那么乱了。我把这篇文章定位成“八股文系列”的起步后续有时间再聊类加载、JIT 和工具链希望这些经验能帮你少走一点弯路。
返回列表