
“八股文”这个词在Java圈子里多少带点贬义但你要是认真参加过几场技术面试或者真的上手排查过一次线上JVM问题就会明白一件事JVM这套东西背不背得下来是一回事能不能把知识点串起来用是另一回事。很多候选人能把《Java虚拟机规范》背得滚瓜烂熟问他“容器里Java进程被OOMKilled了日志在哪找”却一脸茫然。这恰恰说明以Java虚拟机JVM为核心的运行原理、内存模型、参数调优、排查工具不是用来应付面试的而是实打实的生产技能。这篇文章不打算给你整理一份“面试背诵手册”我按自己这些年排查线上问题的经验把JVM里最常被问到、也最常出事的几个点拆开讲一遍。包括JDK、JRE、JVM这三者的边界内存模型里堆和栈到底怎么协同-XX:CompileThreshold这类高频参数背后的JIT编译机制G1收集器为什么成了默认选择以及一个非常现实的问题——Docker容器里部署的Java程序异常重启后JVM日志到底去哪儿找。每个主题都会配实际场景和排查思路适合正在准备面试的开发者也适合那些JVM参数只会照抄、出了问题不知道从哪下手的运维或后端同学。1. 从JDK、JRE、JVM的边界感说起1.1 三者关系不是“包含”一句话那么简单搜索热词里“jre和jvm之间的关系”“jdk和jvm、jre的区别”出现频率非常高说明这确实是很多人的知识盲区。答案本身不复杂JVMJava Virtual Machine是Java程序运行的虚拟机负责执行字节码JREJava Runtime Environment在JVM基础上补充了Java标准类库和运行所需的支撑文件是跑Java程序的完整环境JDKJava Development Kit又在JRE基础上增加了javac编译器、jdb调试器、jar打包工具、jvisualvm等开发调试工具。用集合关系写就是JDK ⊇ JRE ⊇ JVM。但面试官把这个题目拿出来真正想听的往往不是这句包含关系。他会追问JVM是软件还是规范HotSpot、OpenJ9、GraalVM之间是什么关系为什么同一个.class文件在不同JDK版本下运行结果可能不同这一连串问题背后考察的是你是否理解JVM本质上是一份“规范”规范里定义了类文件结构、字节码指令集、运行时数据区、垃圾回收的接口行为而HotSpot只是这个规范最流行的一种实现。能把“规范”和“实现”分开讲比背一百遍包含关系都管用。1.2 为什么这道基础题能筛掉一批人我在面试里见过不少简历写着“精通JVM”的候选人问起JDK和JRE的区别时能说“JDK有编译器”但接着问“线上服务用JRE镜像部署影响是什么”就答不上来了。这个问题的实用场景太多了Docker镜像选型时基础镜像装的是完整JDK还是精简JRE直接决定镜像大小、攻击面、以及能不能在容器里用Arthas、jmap这些排查工具。另一个容易忽略的点是模块化。JDK9引入模块系统之后可以用jlink定制最小运行时镜像只包含业务真正用到的模块。很多团队把服务镜像从JDK8升级到JDK17时镜像体积直接砍掉一半靠的就是这个。如果只背“JDK包含JRE包含JVM”完全意识不到JDK本身也可以按需裁剪那在镜像治理、启动速度优化这些实战话题上就无话可说。1.3 工作目录与运行时依赖的坑继续沿着边界感往深走一步。JRE和JDK的差异还体现在工具链上JDK自带java、javac、jcmd、jstat、jmap、jstackJRE通常只有java。很多线上排查手段依赖这些JDK工具生产环境却为了减小体积只装了JRE结果出了内存问题连jmap都用不了。我的建议是排查类的容器镜像至少保留完整JDK或者挂载一个包含诊断工具的运维镜像别在出了问题的时候才发现工具链缺失。2. 内存模型把堆、栈、方法区串成一条线2.1 运行时数据区不是“五个名词”那么简单JVM内存模型是老八股了程序计数器、虚拟机栈、本地方法栈、堆、方法区任何一个背过面试题的人都能报出来。但能把这五个区域放进一个完整运行场景里的人不多。拿一次普通的方法调用举例线程执行到userService.getUser(id)JVM为这个线程维护的虚拟机栈里压入一个栈帧栈帧的局部变量表存放userService引用和id值操作数栈承载字节码指令的中间计算动态链接指向常量池里的方法符号引用方法出口记录调用者位置。getUser里new User()创建的对象实例则分配在共享的堆内存中。程序计数器是线程私有区域里最容易忽略的但它负责记录当前线程正在执行的字节码行号线程切换后能恢复到正确的执行位置。本地方法栈服务于Native方法典型的如System.currentTimeMillis()底层调用、某些加密库的JNI实现。2023年后很多框架转向Project Loom的虚拟线程虚拟线程的栈不在固定大小的虚拟机栈里分配而是可以在堆上动态扩容这会让“栈是线程私有”这个结论在虚拟线程场景下表现得不太一样面试能讲到这一层基本就超出八股范围了。2.2 对象在堆里的移动路线直接决定GC调优思路对象分配和晋升路线是堆内存的骨干绝大多数对象先在Eden区分配Minor GC后存活对象进入Survivor的From区下一次Minor GC时From和To交换每熬过一次GC年龄加一达到阈值默认15可用-XX:MaxTenuringThreshold调整晋升到老年代。大对象不走这条路超过-XX:PretenureSizeThreshold的对象直接进老年代目的是避免大对象在Eden和Survivor之间来回复制复制大对象的成本远高于直接放进老年代。很多线上Young GC频繁的场景本质是对象分配速率过高Eden区几秒就塞满了。这时候盲目调大堆内存不一定有效因为更大的Eden区只是延后了GC时间如果对象创建速率不变GC频率确实降了但单次GC的停顿时间会变长。正确思路是先用jstat观察Young GC频率和耗时再用jmap或MAT分析对象分布找到到底是业务代码创建了大量短生命周期对象还是某个集合类没设置初始容量导致扩容拷贝把这层原因解决了GC自然就平稳了。2.3 直接内存与容器内存踩过才会懂的坑HotSpot在JDK8之后把方法区挪到了本地内存官方名字叫元空间Metaspace默认没有上限受操作系统物理内存约束。这带来一个常见事故类加载过多反射、动态代理、热部署导致Metaspace不断膨胀容器内存被吃满进程被内核杀掉。所以产线环境一定要设置-XX:MaxMetaspaceSize压测时观察元空间占用曲线给一个合理上限。另一个容易爆的是直接内存。NIO、Netty会用DirectByteBuffer在堆外分配内存这部分不受-Xmx限制而是受-XX:MaxDirectMemorySize控制。JVM参数手册上写着默认等于-Xmx但在容器环境里如果既设置了-Xmx4g又忘了设MaxDirectMemorySize堆外和堆内的总占用可能远超容器内存限制导致OOMKilled。这类问题的典型特征是堆内存监控曲线正常但容器整体内存占用持续上涨最后进程被杀。3. 高频JVM参数解读CompileThreshold不是单纯改个数3.1-XX:CompileThreshold到底触发的是什么热词里出现了“jvm参数 -xx:compilethreshold”很多人搜这个参数是因为在网上看到“调小CompileThreshold可以提升性能”这种说法。这得从头讲清楚。JVM执行Java方法有两种方式解释执行和编译执行。解释执行启动快但每次都要逐条翻译字节码JITJust-In-Time编译则把热点方法编译成本地机器码后续调用直接执行机器码速度快得多。-XX:CompileThreshold就是判断方法是否为“热点”的调用次数阈值方法调用计数达到这个值JVM会把它提交给JIT编译器。这个参数的实际意义取决于JVM运行模式。Client模式下默认1500Server模式下默认10000。HotSpot默认使用解释器与C1/C2编译器协作的层次编译C1是客户端编译器编译速度快但优化程度低C2是服务端编译器编译耗时长但优化更激进。分层编译下方法先被C1编译再根据后续 profiling 数据决定是否升级到C2所以阈值不是一次到位的判断而是一个动态过程。把阈值从10000调到1000确实会让方法更早被编译但代价是C1/C2忙着编译线上方法CPU占用升高编译出来的代码可能还没来得及发挥性能优势进程就自己先卡了。3.2 从CompileThreshold带出一组必知必会参数围绕JIT编译和内存管理有几个参数值得每个Java开发掌握-Xms和-Xmx初始堆和最大堆。生产环境强烈建议把两者设成一致避免堆容量动态扩容触发STWStop-The-World停顿。-XX:PrintCompilation输出方法编译日志排查“为什么某个方法没被JIT优化”时很有用。-XX:ReservedCodeCacheSizeJIT编译后的机器码缓存在CodeCache里默认约240MB各版本有差异动态生成类过多的框架可能把CodeCache挤爆表现为CodeCache is full警告。-XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/path/to/dumpOOM时自动导出堆快照这是排查内存问题的保命配置。-XX:MaxDirectMemorySize设置直接内存上限容器部署必配。3.3 参数不是越多越好先验证再上生产跟调优参数打过几年交道我最大的感受是JVM参数是“按需设置”不是“堆得越多越专业”。见过有团队把网上所谓“性能优化清单”里二十几个参数原封不动贴到启动脚本结果GC日志显示停顿时间比默认配置还长。原因很简单那些参数可能基于JDK8的G1而服务跑在JDK17上默认行为早就变了。调参数前先做一件事查看当前JDK的默认值。用java -XX:PrintFlagsFinal -version可以输出所有JVM参数的最终生效值PrintFlagsInitial输出初始值两者对照才能知道哪些参数是系统默认、哪些被显式覆盖。改完参数后用jinfo -flags pid确认进程实际加载的参数防止启动脚本里写了但没生效的情况。我踩过一次参数拼写错误-XX:MaxRAMPersentage少了个eJVM直接忽略了这个参数导致容器内存限制没生效服务上线当晚就被OOMKilled。从那以后但凡改参数我都会加一步jinfo验证。4. G1收集器从面试必问到线上默认4.1 G1为什么取代了CMS老牌CMS收集器在JDK9之后被标记为废弃JDK14正式移除G1成了默认收集器。这背后的原因值得花点篇幅讲。CMS的并发标记清除算法解决了并发收集问题但它所有的内存回收都基于“标记-清除”老年代会留下大量碎片碎片多到一定程度时G1只能退化为Full GC做一次性整理停顿时间不可控。同时CMS的并发阶段和业务线程抢CPUGC线程数配置不当会导致吞吐量明显下降。G1把堆划分成一个个大小相等的Region每个Region在生命周期内可能扮演Eden、Survivor、Old或Humongous区。回收时不再需要全堆扫描而是通过Remembered Set记录跨Region引用定位存活对象更精确。G1的混合回收Mixed GC会同时回收年轻代和部分老年代Region并用复制算法整理内存从机制上避免碎片化。还有一个核心卖点是停顿预测模型G1根据历史GC数据预测回收哪些Region能在-XX:MaxGCPauseMillis目标停顿时间内完成回收让GC停顿时间变得可管理。4.2 G1的核心参数与配置逻辑G1参数里最常用的是-XX:MaxGCPauseMillis200期望的GC最大停顿时间。注意这只是目标不是硬性保证G1会尽力满足极端的全堆回收无法完全避免。-XX:G1HeapRegionSize4mRegion大小默认根据堆大小自动计算堆越大Region越大。Region太大会增加Humongous对象判定阈值太小会增加RSet维护开销。-XX:InitiatingHeapOccupancyPercent45老年代占用率达到这个比例时触发并发标记周期为混合回收做准备。调小会让GC更早介入调大招致Full GC风险增加。-XX:G1NewSizePercent和-XX:G1MaxNewSizePercent年轻代大小范围默认情况下G1会根据停顿目标动态调整年轻代大小手动设置时需谨慎。-XX:ConcGCThread并发标记线程数对CPU核数少的容器环境影响较大。配置G1参数的核心思路不是“把所有参数都自定义”而是先明确业务对延迟和吞吐的诉求。对低延迟业务重点调整MaxGCPauseMillis和年轻代比例同时配合-XX:ParallelRefProcEnabled优化Reference处理对吞吐敏感型业务反而可以放宽停顿目标让G1减少GC频率。4.3 一个真实的G1日志排查案例之前接手过一个服务现象是每天下午业务高峰期Young GC耗时从几十毫秒飙升到几百毫秒接口P99明显劣化。打开了GC日志后发现GC耗时增长的阶段集中在“Ref Proc”上。这个阶段是处理软引用、弱引用、虚引用和Finalizer引用的正常情况下耗时很低但如果系统里大量使用finalize()或者存在大量待处理的WeakReference这个阶段就会变长。用jmap抓了对象直方图发现java.lang.ref.Finalizer对象数量异常顺着引用链定位到一个老旧的连接池组件原来它内部用了finalize()做资源兜底回收导致每个连接对象都进了Finalizer队列GC时需逐个处理。替换掉那套组件后Ref Proc时间恢复正常Young GC耗时回落。这个案例想说明的是背会G1原理只是第一步线上真正需要的是通过GC日志中的各阶段耗时反推可能出问题的组件再结合堆分析工具定位根因。5. 面试高频“送命题”背后的真实考点5.1 “JVM或者Spring Boot会设置SQL执行10秒自动关闭吗”搜索热词里有一条很扎眼“jvm或者spring boot会设置一个sql执行10秒自动关闭吗”。先把结论放这儿JVM本身完全不管SQL执行它没有机制去“自动关闭”一条SQLSpring Boot默认也不做这种“10秒自动关SQL”的事。那这个现象从哪来答案是连接池和数据库驱动层。比如HikariCPSpring Boot 2.x默认连接池里这几个参数直接相关connectionTimeout默认30000毫秒指从连接池获取连接的超时时间超过就抛异常validationTimeout默认5000毫秒指连接校验的最大超时时间maxLifetime默认1800000毫秒是连接在池中的最大生命周期。如果你在配置里看到connectionTimeout10000那“10秒自动关”最可能指的就是获取连接超时而不是SQL执行超时。Druid连接池里还有queryTimeout、validationQuery等参数MyBatis层面也可以给Statement设置queryTimeout但那也不是JVM管的。这道题暴露的是很多人对“SQL超时”“连接超时”“连接销毁”概念混淆。真正确保长SQL不被拖垮该从三层入手数据库驱动层用socketTimeout限制IO等待连接池层用maxLifetime避免数据库主动断开后池中连接还“以为”自己是好的业务层用Transactional(timeout 5)这样的声明式事务超时兜底。把这些配置理清楚面试时能讲出层次。5.2 “Docker容器部署的Java程序异常重启JVM日志在哪儿”这个搜索词出现的频率很高说明容器化时代让“找日志”变成了一件比想象中复杂的事。异常重启分几类日志位置也完全不同。第一类是应用自身抛出异常比如OutOfMemoryError、未捕获的RuntimeException。这类日志如果应用配了logback/log4j会输出到配置的文件路径或标准输出。容器里如果应用日志直接打到stdout用docker logs container就能看到。但很多团队的logback配置的是相对路径容器重启后日志文件留在容器可写层里容器删掉日志跟着没了。所以容器化部署的第一原则日志目录必须挂载到宿主机或对象存储别让它睡在容器可写层。第二类是JVM自身的致命错误日志格式是hs_err_pidpid.log。当JVM发生原生崩溃比如JNI调用栈溢出、内存损坏时会生成这个文件包含崩溃线程栈、当前线程状态、系统信息、内存映射。文件默认生成在进程工作目录容器里目录可能没写权限或重启后被清理最好在启动参数里显式指定-XX:ErrorFile/logs/hs_err_%p.log。第三类是容器被内核OOMKilled。这不会在Java进程里留下任何堆栈因为进程是直接被内核杀掉的。排查要看两处docker inspect container里的State.OOMKilled字段是否为true以及宿主机dmesg -T里有没有Out of memory: Kill process之类记录。退出码1371289是SIGKILL的典型信号遇到这个退出码第一反应就查OOM。第四类是JVM自己触发的退出。-XX:ExitOnOutOfMemoryError会在OOM时主动退出进程配合重启策略会让容器不断重启-XX:HeapDumpOnOutOfMemoryError则是在OOM时先导出堆快照再退出这个堆转储文件的位置最容易被忽略很多人找半天“JVM日志”其实找的就是这个.hprof文件。5.3 容器环境里JVM怎么正确识别内存限制关于容器和JVM的关系还有一个高频考点。JDK8u191之前JVM默认不感知容器内存限制-Xmx设置多大多大超出容器内存限制就被内核杀JDK8u191开始引入了-XX:UseContainerSupport默认开启JVM能读取cgroup限制。但这里有一个容易被误解的点UseContainerSupport生效了JVM也只是把“最大内存”默认值调整为容器内存的1/4而不是自动占用全部内存。如果你不在启动参数里显式设置-Xmx或-XX:MaxRAMPercentage堆上限依然可能不够用或者与其他内存区域叠加后超限。更稳的做法是在容器环境里用-XX:MaxRAMPercentage75.0这类相对值让JVM根据容器内存动态计算堆上限同时保留-XX:MaxMetaspaceSize和-XX:MaxDirectMemorySize的上限最后把-XX:CrashOnOutOfMemoryError或-XX:ExitOnOutOfMemoryError和HealthCheck结合起来让异常进程尽早退出并由编排系统拉起而不是半死不活地卡在那儿。6. 排查工具与定位思路把八股变成排障能力6.1 诊断工具全家桶场景对了才有效JVM排查工具说多不多说少不少关键是用对场景。我按自己排查时的使用频率排个序jps -l列出Java进程拿PID的第一选择。jstat -gcutil pid 1000每秒输出一次GC各区域使用率和GC耗时看Young GC频率和老年代趋势非常直观几乎零侵入。jmap -heap pid打印堆摘要包括Eden、Survivor、老年代容量和使用率jmap -dump:formatb,fileheap.hprof pid导出堆快照生产环境慎用导出期间可能造成较长的停顿。jstack pid打印线程栈排查死锁、线程阻塞、CPU飙高时看线程在干嘛。jcmd pid helpJDK自带的综合工具替代了部分jmap功能支持GC.heap_dump、Thread.print等命令。jinfo -flags pid查看进程实际生效的JVM参数前面提到过改参数后必须验证。Arthas线上诊断神器支持反编译、方法调用追踪、火焰图不用重启进程就能做动态诊断。6.2 从“找不到日志”到完整定位链路用上面说的“容器重启”场景完整走一遍定位过程。假设告警系统提示某Java服务在过去10分钟重启了3次。第一步确认容器状态docker inspect container看RestartCount和State.OOMKilled。如果OOMKilled是true直接进入内存排查如果是false继续看下一步。第二步拉应用日志docker logs --tail500 container。如果应用日志有OutOfMemoryError说明是JVM堆或元空间不够有Unable to create native thread说明线程数超限或进程内存不足有明显业务异常则可能是启动后快速故障。第三步找JVM崩溃日志检查挂载卷或容器内工作目录是否有hs_err_pid*.log。有就优先看文件头几行里面写明了导致崩溃的线程类型和信号。第四步查堆转储文件如果配置了-XX:HeapDumpOnOutOfMemoryError在指定路径找.hprof文件用MAT或jvisualvm分析Leak Suspects看有没有明显的对象泄漏。第五步回看监控曲线把堆内存、GC次数、CPU、容器内存四张曲线叠在一起找异常拐点。这一步通常能直接指出是流量突增、内存泄漏还是配置错误。这套链路每一步都有明确目的不是拿到问题就盲目dump。顺序也很重要先看OOMKilled是不是内核杀的再看JVM崩溃日志和堆转储最后才回到监控曲线还原现场。6.3 给新接手服务时检查清单最后分享一个我自己一直在用的习惯。不管新接手的服务是谁写的上线前我都会花十几分钟确认这几件事启动脚本里有没有-Xmx有没有和容器内存限制匹配有没有开启-XX:HeapDumpOnOutOfMemoryError堆转储路径有没有挂载持久化目录GC日志有没有开路径在哪儿是否随容器日志一起采集-XX:ErrorFile有没有指定Dockerfile里工作目录有没有写权限线程栈日志jstack输出有没有留档方式有些团队做线程诊断时发现进程已经重启了拿不到现场。这些配置不花什么成本但能让你在事故发生时手里不至于空无一物。JVM八股背再多最后都是为“出了问题能定位、能解释、能修复”服务的。把基础概念和工具链串起来才是学JVM真正值得投入的地方。