1. 为什么我劝你别再背JVM八股文了做了这么多年Java开发面试过不少人也带过不少新人我发现一个挺普遍的现象大家聊起JVM都能侃侃而谈——什么运行时数据区、堆栈方法区、GC Roots、可达性分析、类加载双亲委派背得滚瓜烂熟。可一旦真到了线上环境JVM出了问题大部分人就懵了。日志在哪儿看GC日志怎么打开堆内存一直在涨是内存泄漏还是正常的Docker容器里跑的Java程序突然重启了怎么排查这些问题八股文里可没有标准答案。我写这篇内容就是想把这些年在实际项目里摸爬滚打攒下来的JVM实战经验掰开揉碎讲清楚。不讲虚的直接讲怎么用、怎么排查、怎么调优以及那些你在官方文档和面试题里看不到的坑。适合谁看给那些已经能熟练写业务代码、但遇到JVM问题就头疼的朋友也适合准备面试但不想只靠背题混过关的候选人。看完你能收获什么至少下次线上JVM出问题你知道从哪下手。2. JVM核心知识点用大白话讲一遍2.1 运行时数据区到底怎么回事JVM的内存布局是一切的基石。面试必问实战中也必须清楚否则你连调优参数都看不懂。JVM把内存划分为几个区域各干各的活儿程序计数器记录当前线程执行的字节码行号切换线程后能恢复执行位置。这是唯一不会OOM的区域因为占用空间极小。虚拟机栈每个线程一个里面装的是栈帧。一次方法调用就是一个栈帧包含局部变量表、操作数栈、动态链接、方法出口。你看到的StackOverflowError通常就是这里炸了——递归太深栈帧太多。本地方法栈为Native方法服务和虚拟机栈类似。堆对象实例的分配地也是GC的主战场。几乎所有对象都在这里创建。方法区在JDK 8之后叫元空间存放类信息、常量、静态变量等。很多人问JDK和JVM、JRE的区别顺带说一句JVM是虚拟机本身JRE是Java运行环境JVM核心类库JDK是开发工具包JRE编译器等开发工具。这里要特别注意一个关键点堆和栈的数据交互并不是完全割裂的。栈帧里的局部变量引用类型存储的是对象的引用真正对象在堆里。哪怕你背了栈管运行、堆管存储也得理解引用的传递和对象内存的分配是两回事。2.2 Java对象的内存分配过程新手最容易忽略的就是对象从出生到回收的完整过程。我建议你用生产线来理解类加载检查new一个对象时JVM先检查这个类能不能在常量池里找到符号引用找不到就触发类加载。分配内存类加载完成后给对象划分堆空间。分配方式有两种——指针碰撞和空闲列表取决于GC是否带压缩整理功能。内存空间初始化兜底保证实例字段在没有赋值时也有默认值基本类型是0或false引用类型是null。设置对象头对象头里存了哈希码、GC分代年龄、锁状态标志等这也是later调优和排查的重要依据。执行init方法按照程序员的意图初始化字段值。这个过程中踩过坑的朋友应该深有体会大对象直接在老年代分配如果老年代空间不够就会触发Full GC。所以你会发现某些频繁创建大对象又不及时释放的业务一天要打几十次GC日志性能想好都难。2.3 堆内存划分和对象晋升规则堆被分成了新生代和老年代。新生代里又细分出一个Eden区和两个Survivor区From和To比例默认是8:1:1。新对象出生在Eden区Eden满了触发Minor GC存活对象被复制到Survivor区。每熬过一次GC年龄加1。默认年龄到15就晋升到老年代。这个年龄阈值可以通过-XX:MaxTenuringThreshold调整。但晋升老年代不只是看年龄还有这些情况大对象直接进老年代通过-XX:PretenureSizeThreshold参数控制。Survivor区放不下存活对象的总大小超过Survivor空间的一半直接进老年代。动态年龄判定如果同龄对象的总和超过Survivor区的一半大于等于这个年龄的对象直接进老年代。这些规则看起来琐碎但你在调优时必须想明白一个逻辑对象的存活周期和晋升比例直接决定了老年代的GC频率。如果老年代一直涨不停你要么是对象一直不回收内存泄漏要么是Survivor区设置太小导致存活对象过早晋升参数不合理。2.4 怎么判断对象是否该死主流的可达性分析算法简单说就是从一系列称为GC Roots的根对象出发沿着引用链往下找能被找到的就活着找不到的就可以回收了。哪些对象能当GC Roots比如虚拟机栈中引用的对象正在执行的方法的局部变量方法区中静态属性引用的对象方法区中常量引用的对象本地方法栈中JNI引用的对象Java线程内部的引用这里有个很容易被忽略的细节强引用、软引用、弱引用、虚引用的回收策略完全不同。软引用在内存不足时才回收适合做缓存弱引用每次GC都会被回收。你在编码时如果用了缓存框架如Caffeine、Guava Cache它们其实底层就是靠这些引用类型控制对象存活的。3. 垃圾收集器选型从Serial到G1到底怎么选3.1 各收集器到底有什么区别我先用表格把主流的收集器捋一遍大家对着看就清楚了收集器线程数工作方式适用场景暂停时间Serial单线程复制算法新生代客户端小应用较长ParNew多线程复制算法新生代服务端配合CMS较短Parallel Scavenge多线程复制算法新生代关注吞吐量的后台应用可控制Serial Old单线程标记-整理老年代老年代兜底较长Parallel Old多线程标记-整理老年代关注吞吐量可控制CMS多线程标记-清除老年代对响应时间敏感的应用较短但浮动垃圾多G1多线程分区回收整体大内存、多核服务器可预测ZGC多线程染色指针读屏障超大堆、极低延迟极短1ms3.2 为什么现在默认是G1从JDK 9开始G1成了默认收集器。原因是CMS有几个硬伤内存碎片化严重、停顿时间不可控、浮动垃圾太多、并发收集时CPU占用高。G1就把整个堆划分成一个个大小相等的Region不再严格分代而是让每个Region动态扮演Eden、Survivor或者Old的角色。我实际用下来的体会是G1最核心的价值是两个能力可预测的暂停时间模型你可以通过-XX:MaxGCPauseMillis指定目标暂停时间默认200ms。G1会尽量在这个目标范围内完成回收虽然不敢保证完全达标但比CMS靠谱太多。并发标记局部回收G1不需要回收整个堆它会优先回收垃圾最多的Region这在大部分对象都是临时对象的场景下优势非常明显。但注意G1不是万能的。如果你生产环境的堆只有几百MB或者业务对象生命周期极长、几乎不产生垃圾用G1纯粹是浪费Serial或许更合适。选收集器要看你实际场景没有绝对的最好。3.3 G1参数调优的几个关键点G1参数里最容易被问到的就是热词里的-XX:CompileThreshold。其实这个参数是JIT编译相关的不是G1的。它的含义是方法被调用多少次之后触发C1/C2编译默认是10000。你可以通过-XX:PrintCompilation观察编译情况。我见过有人把这个参数调成1000结果JIT过早介入编译优化反而出现了奇怪的性能回退最后又调回去了。调G1我更建议你关注这些参数-XX:G1HeapRegionSizeRegion大小默认是堆的1/2048可设置为1MB~32MB。Region越小分配大对象越容易跨多个Region影响效率。-XX:MaxGCPauseMillis目标最大暂停时间。我一般设150ms~200ms再小会让G1频繁发起回收。-XX:InitiatingHeapOccupancyPercent默认45%表示堆使用率达到这个比例时开始并发标记触发Mixed GC。调太高可能导致Full GC增多。-XX:G1NewSizePercent和-XX:G1MaxNewSizePercent控制年轻代占总堆的比例默认分别是5%和60%。你观察GC日志后如果发现年轻代回收太平凡可以适当拉大这个范围。这里要特别提醒一开始调参别贪心先只改一个参数观察效果一次改一堆后果就是根本不知道是哪个参数导致性能变差。我踩过这个坑教训很惨痛。4. JVM参数不搞玄学逐条解读常用的配置项4.1 内存相关的核心参数通常在生产环境你至少需要把下面这些参数明确写出来不要依赖默认值-Xms4g -Xmx4g -Xmn2g -XX:MetaspaceSize512m -XX:MaxMetaspaceSize512m -XX:SurvivorRatio8 -XX:UseG1GC我解释一下-Xms和-XmxJVM堆的初始大小和最大大小。我给的建议是这两个值必须相等。原因很简单如果初始值小、最大值大JVM会在运行中不断扩容和缩容堆这个动态调整过程本身就消耗性能还会引发不必要的GC。不如一次性分配好。-Xmn新生代大小。设多少合适经验值是堆的1/3到1/4。设太大老年代就小Major GC会更频繁设太小Minor GC就更频繁。-XX:MetaspaceSize元空间初始大小。JDK 8之后方法区挪到了本地内存如果不设这个值可能导致频繁的Full GC去扩展元空间。启动时直接给足避免运行中反复扩容。-XX:SurvivorRatioEden和Survivor的比例。默认8就是Eden占80%、两个Survivor各占10%。4.2 GC日志怎么配才有用这是很多人忽略的大问题。我见过不少项目线上JVM出了异常结果没有开GC日志最后只能靠猜。日志是排查所有JVM问题的第一手资料必须提前开好。-Xlog:gc*info:file/data/logs/gc-%t.log:time,uptime,level,tags:filecount5,filesize50m这是JDK 11的写法注意JDK 8的写法完全不同JDK 8用-XX:PrintGCDetails之类。这段配置的意思是打印所有GC级别的日志写入/data/logs/目录下按时间戳分文件最多5个文件、每个最大50MB超过就滚动覆盖。开了日志之后你就能看到GC原因、GC类型、堆内存前后变化、停顿时间这是后续调优最客观的依据。4.3 让JVM崩溃时留下现场还有一个经常被忽视的配置是崩溃日志和堆转储。进程异常退出时如果你没有配置很多线索就丢了。-XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/data/logs/heapdump.hprof -XX:ExitOnOutOfMemoryError第一个参数让JVM在OOM时自动导出堆快照第二个是快照的保存路径第三个是OOM后直接退出进程配合外部守护进程重启避免处于半死不活的状态。堆转储文件可以用MAT或者JProfiler分析能直观看到到底是哪个对象撑爆了堆。这里要提一个很现实的坑堆转储文件一般和你配置的堆大小差不多。4GB堆导出的hprof可能就是4GB甚至更大生产环境磁盘要提前留足空间否则OOM时快照写不进去等于白配。4.4 JIT编译相关的参数说人话版JIT编译这块对大多数业务开发来说不是核心但面试会被问。JVM为了提升执行速度会把热点代码编译成本地机器码这里的热点识别和编译策略由-XX:CompileThreshold控制。默认方法调用次数达到10000次才会触发C1编译15000次触发C2编译。这两个阈值不是越高越好调太小启动阶段就大量编译影响启动速度调太大热点代码迟迟不优化峰值性能上不去。最常见的诉求是加速启动想让代码尽快进入编译优化阶段可以适当降低阈值想让启动更平滑、编译压力更小就调高。除非你明确知道自己的性能瓶颈在JIT编译很少见否则我建议保持默认值。5. 线上故障排查实战从现象到根因的完整链路5.1 先搞清楚进程死没死再谈其他排查JVM问题我有一套固定的顺序分享给大家第一步看进程状态。用top -Hp pid看线程的CPU占用和内存占用确认进程是活着还是僵死是CPU高还是内存高。第二步看GC日志。这一步是你提前配好日志的优势体现。打开日志看GC频率、停顿时间、每次回收之后堆用了多少能很快判断是不是GC压力太大。第三步看堆内存。用jmap -heap pid看当前堆的利用率、各区分布。用jmap -dump:formatb,filexxx.hprof pid抓堆快照注意这个操作会触发一次安全点线上执行要谨慎建议在低峰期做。第四步看线程栈。用jstack pid抓线程快照找BLOCKED、WAITING状态的线程配合top -Hp看到的CPU高线程ID转成十六进制后去jstack里搜就能定位卡在哪个方法。5.2 堆内存一直涨怎么判断是泄漏还是正常这个场景我遇到太多次了。很多人的第一反应是内存泄漏了其实不一定。我给你一个判断方法连看几轮GC日志如果Full GC之后堆占用能降到一个比较低的基线说明大部分对象能被回收更可能的问题是对象生命周期太长比如缓存没有做过期策略、静态集合不断变大。如果Full GC之后内存占用依然很高而且下一次GC间隔越来越短那大概率是内存泄漏。用jmap抓快照之后用MAT分析重点看Dominator Tree按保留堆从大到小排序看看最大的对象是什么。Leak SuspectsMAT自动帮你找可疑的泄漏点。线程栈和引用链看这个对象是被谁强引用着。我记得有一次排查线上内存泄漏最后发现是某个引擎在并发请求时不断往一个ConcurrentHashMap里放数据而这个Map的清理任务因为异常被吞掉了。这种问题如果不用堆转储分析光靠看代码真的很难发现。5.3 Docker容器里Java程序异常重启日志在哪儿这个问题也是热词里的高频场景。容器化之后JVM故障排查姿势和物理机不太一样。如果是OOM导致进程被杀Docker容器有内存限制JVM的堆设置如果超过了容器限制进程就会被OOM Killer杀掉。你需要确认两件事一是容器的内存限制是多少二是JVM堆加元空间加线程栈等所有内存的总和是否超了。很多人在容器里只设置了-Xmx忽略了元空间、线程栈、直接内存的开销总内存一旦超过容器上限进程就无缘无故被杀了。如果是JVM内部错误导致退出比如SIGSEGV崩溃JVM会生成一个hs_err_pid.log文件一般在你启动Java进程的当前目录下。这个日志里记录了崩溃原因、线程信息、出错的本地方法栈。如果容器挂了而这个log在容器文件系统里容器一删就没了——建议把工作目录挂载到宿主机持久化。如果是应用代码自己退出比如System.exit这就要看应用业务日志了。排查思路是先看容器事件docker inspect看退出码和重启策略再对照业务日志、GC日志、崩溃日志逐步排除。5.4 快速定位CPU飙升到100%的线程CPU飙高这种问题在Java服务里非常常见。我总结了最快的一招# 1. 找出CPU占用最高的进程 top # 2. 找到该进程内CPU最高的线程 top -Hp pid # 3. 把线程ID转成十六进制 printf %x\n thread_id # 4. 用jstack导出线程快照搜十六进制线程ID jstack pid | grep -A 20 hex_thread_id看到栈信息后如果是业务代码直接定位到方法如果是GC线程说明是GC导致CPU高如果是VM Thread则在执行VM操作。确定好源头再对症下药。有朋友问需要用到调试工具吗我建议就算你日常开发不用也要熟练掌握jstack、jmap、jstat这几个基础命令关键时刻能救命。jvisualvm和JProfiler适合本地分析生产环境受限于权限命令行工具反而更实用。6. 那些面试题里不会告诉你的细节和避坑指南6.1 JVM调优绝对不是每个项目都必须做的这是我最想说的一句话。很多团队拿着G1默认参数跑得好好的非要学别人调优一顿操作反而把性能调坏了。我见过最离谱的一个案例别人把-Xmx调成8GB他也要跟着调但业务是轻量级API网关平时堆占用也就几百MB。堆给大了之后GC扫描范围变大停顿时间反而变长吞吐量下降最后调回2GB才好。调优的前提是有一个明确的性能指标不满足要求且你已经通过GC日志、线程栈、堆转储定位到了JVM相关的瓶颈。没有症状不要用药。JVM默认参数本身是经过大量场景验证的平衡方案。6.2 元空间设置多大合适这是一个高频问题。JDK 8的元空间存的是类元数据加载类越多占用的元空间就越大。不同规模的项目跨度很大一个简单的Spring Boot应用加载的类大概在1万~2万元空间可能需要100MB~200MB如果集成了大量第三方SDK、动态代理、CGLIB生成的大量代理类元空间可能会涨到好几GB。我给的建议是先不设上限跑一段时间看实际占用多少然后根据观察值设置初始值和最大值。注意CGLIB每生成一个代理类元空间就会有一份对应的元数据大量动态代理场景下元空间会明显膨胀这是泄漏检查时容易忽略的地方。6.3 JVM和Spring Boot的SQL超时有什么关系热词里有一条很有意思jvm或者spring boot会设置一个sql执行10秒自动关闭吗。这里我明确说一下JVM本身不会管SQL执行超时。SQL超时是数据库驱动、连接池或者Spring事务管理器层面的东西。如果用的是MySQL官方驱动有个socketTimeout参数默认是0意思是不超时。如果用的是Druid连接池可以设置timeBetweenEvictionRunsMillis来检测并回收空闲连接。Spring Boot里可以配置spring.datasource.hikari.connection-timeout来限制获取连接的超时时间。实际遇到过的情况是数据库突然变慢SQL执行了30秒甚至更久但应用侧没设任何超时线程就挂在那里越积越多最终导致线程池耗尽。所以合理设置SQL超时是为了保护应用不被慢SQL拖垮但这个活不是JVM干的别把锅甩给JVM。6.4 为什么我建议少背八股文多积累场景经验背八股成本低、见效快面试一问JVM内存模型你能流利地背出线程私有、线程共享、堆、栈、方法区……这确实能帮你过一些初面。但真正区分水平的是你遇到服务重启后JVM日志在哪儿或者容器内存持续增长怎么排查这种问题时能不能马上给出有效方案。面试官随便追问一句你线上遇到过Full GC频繁是怎么处理的背书的人立刻露馅。我见过一个候选人面试时能把G1的Region结构、Remember Set、SATB算法讲得头头是道但问他你的服务内存多大、GC停顿一般多少毫秒、你们用的什么收集器——他一脸茫然。这确实有些本末倒置。6.5 建议每个人都建一份自己的JVM排查手册我自己的做法是把每次线上JVM问题的排查过程整理成一个小文档包含现象是什么、当时用了哪些命令、日志里发现了什么、根因是什么、怎么解决的、后续怎么预防。比如我就记录过某服务高峰期CPU飙高jstack发现大量线程卡在java.util.zip.Inflater的native方法最终确定是某个报表接口频繁解压大报文后来加了缓存和压缩等级控制问题解决。这份手册没有任何高级内容但翻出来看的时候每一页都是实战经验。面试时被问JVM问题我从这些真实案例里随便挑一个就能把面试官带入到真实的排查场景里讲比背一百道八股都管用。7. 一条完整的JVM排查路径照着做就能少踩坑最后我把整套排查流程浓缩成一个实战速查清单大家可以直接收藏服务变慢/卡顿top看进程CPU和内存top -Hp pid看线程维度。jstat -gcutil pid 1000 10看GC动态——重点关注FGC次数、FGCT耗时。jstack pid看线程栈找BLOCKED/WAITING状态。如果GC频繁抓堆快照用MAT分析。内存持续增长jstat -gc pid看各区使用率变化趋势。抓两个不同时间点的堆快照做对比看哪个区域、哪类对象在增长。查代码里是否有静态集合、缓存未设置过期时间、ThreadLocal使用后未清理。排除JVM自身问题Metaspace扩容、直接内存后再怀疑应用代码。进程崩溃/重启确认是否被OOM Killer杀dmesg | grep -i oom或者查容器事件。找崩溃日志JVM崩溃会有hs_err_pid.logOOM会有HeapDump。确认工作目录是否持久化别让日志跟着容器一起消失。如果重启频繁考虑调整容器内存上限或者JVM堆配置。Docker容器特别需要注意JVM总内存 堆 元空间 线程栈 直接内存 JVM自身开销别只算-Xmx。容器限制后配合-XX:MaxRAMPercentage或者-XX:MaxMetaspaceSize合理分配。生产环境一定开GC日志、OOM堆转储、崩溃日志日志目录要挂载持久化。根据我个人经验JVM这块没什么高深秘诀核心就是三件事理解内存模型和GC原理配置好日志和监控具备按图索骥的排查能力。这三件事做扎实了比背多少道八股文都有用。