吃透JVM原理:从运行时数据区到垃圾回收与调优实战
Java 写多了很多同学会陷入一种状态代码能跑但不清楚它到底是怎么跑的。我也是从那个阶段过来的直到有一次线上服务半夜 OOMjstack 打了半天才定位到问题才真正意识到JVM 不是面试八股文里的一页 A4 纸而是每个 Java 开发必须吃透的地基。这篇文章不讲虚的直接从 Java 技术体系里最基础也最关键的一环——JVM 原理出发把运行时数据区、类加载机制、垃圾回收和调优实战串起来。无论你是准备 java 面试题、java 八股文还是想掌握 jvm 调优工具这套内容都是绕不开的底座。我会尽量用大白话讲同时把该有的细节、参数、排查路径都交代清楚所有命令都是实测过的你可以直接照着敲。1. JVM内存模型运行时的地基1.1 运行时数据区到底分了哪几块JVM 在执行 Java 程序时会把自己管理的内存划分成若干个区域这就是常说的“运行时数据区”。很多人背过这张图但背完就忘关键是没有把它和实际运行的程序对应起来。我换个方式给你讲。程序计数器是最小的一块可以理解成每个线程的“书签”。线程执行到哪一条字节码指令它记着。为什么需要它因为线程切换之后要恢复到正确的执行位置。注意执行 native 方法时计数器是空的因为那不在字节码层面管控范围内了。虚拟机栈是每个线程私有的一块内存存的是“栈帧”。一个方法开始执行就压入一个栈帧方法返回栈帧弹出。栈帧里装的是局部变量表、操作数栈、动态链接、方法出口这些东西。而本地方法栈是给 native 方法用的HotSpot 里直接和虚拟机栈合并了你不需要费劲区分。唯一要记住的面试点是递归太深、方法嵌套太多就会在这里抛出 StackOverflowError。堆和方法区是线程共享的。堆存放对象实例是 GC 的主战场方法区存类元信息、常量、静态变量等。JDK 8 之后方法区被改成了“元空间”从堆里挪到了本地内存里目的之一是避免永久代频繁触发 Full GC也让元信息的空间上限变得可控。你只要记住一句话JDK 8 以后没有永久代Class 元数据放在本地内存中的元空间。这三个区域加起来就是 JVM 管理的内存主体。记住哪块私有、哪块共享是最基础的分值题。1.2 堆内存的布局与对象到底咋分配堆内部不是一块整地而是分代管理的。新生代里又拆成 Eden 区和两个 Survivor 区S0、S1老年代单独一块。很多新手不理解为什么要分代实际上分代依据的是“大部分对象朝生夕死”这个经验法则。新对象绝大多数在 Eden 区分配Eden 满了触发 Minor GC。Minor GC 之后存活的对象复制到 Survivor 区并且年龄加一。每次熬过一次 Minor GC年龄加一默认到 15 岁就晋升到老年代这个阈值可以用 -XX:MaxTenuringThreshold 调整。有几个细节是踩坑高发区大对象会直接进入老年代典型就是大数组、大字符串、一张几十 MB 的图片转 byte[]。触发阈值由 -XX:PretenureSizeThreshold 控制默认是 0意思是只要大于这个值才直接进老年代0 代表不启用。动态年龄判定如果 Survivor 区中相同年龄所有对象大小的总和大于 Survivor 区的一半年龄大于等于该值的对象直接进入老年代不用等到 15 岁。分配担保机制当新生代无法容纳存活对象时会通过 HandlePromotionFailure 等策略把这些对象直接放入老年代。为什么有生命周期的概念如果你在代码里写了一个线程池里面保存了大量业务数据对象这些对象普遍活得很久就会被提前晋升到老年代老年代越来越大最终导致 Full GC 甚至 OOM。所以堆调优的核心就是让短命对象尽量在新生代就死掉让长命对象平稳留在老年代别折腾。1.3 栈帧里到底藏了什么虚拟机栈是方法执行的工作台一个方法从调用到结束对应的就是一个栈帧的生命周期。面试里常考栈帧结构你就按四个部分去记。局部变量表存放方法参数和方法内部定义的局部变量槽位是可以复用的。操作数栈是字节码指令的工作区比如 iadd 指令把两个数从操作数栈取出来相加再压回去。动态链接指向运行时常量池中该方法的引用用来支持方法调用。方法出口记录调用方的返回地址方法正常返回或异常返回都要走这里。我一般把栈比作“工作台面”操作数栈就是台面上临时搁置的半成品局部变量表是台面上贴好的便签纸工作完擦掉不带走。理解了这一层看字节码就不会觉得抽象了。顺带说一个进阶点逃逸分析。HotSpot 会分析对象是否逃逸出方法作用域如果对象只在方法内部使用没有逃逸就可能被优化到栈上分配甚至做标量替换这样连对象头都不用建。所以你以为 new 出来的对象一定在堆上不完全是JIT 优化后可能压根没建对象。这也是很多八股文里没讲明白的地方。2. 类加载与对象创建JVM工作原理的主线2.1 类加载过程五板斧一个类从 .java 文件变成 JVM 里能用的类要经历“加载、验证、准备、解析、初始化”五个阶段。你把类想象成一份饭菜加载是采购食材验证是检查食材有没有毒准备是切菜备菜解析是给食材做标注分类初始化才是下锅炒。加载阶段由类加载器完成把字节码读进内存生成 Class 对象。验证阶段做安全性检查文件格式、字节码语义、符号引用正确性防止恶意字节码打进内存。准备阶段给静态变量分配内存并设置默认值注意这里是“默认零值”不是代码里写的初始值。比如 static int a 100准备阶段 a 是 0真正赋值成 100 要到初始化阶段。解析阶段是把常量池里的符号引用替换成直接引用可以理解为把变量名精准换算成内存地址。初始化阶段才是执行类构造方法clinit的地方静态变量赋值和静态代码块在这里执行。触发初始化的条件有几种new 对象、访问静态字段或静态方法、反射调用、初始化一个类的子类等等。这是高频考点尤其是“准备和初始化的区别”几乎每个 java 面试八股文里都有。2.2 双亲委派模型为什么是“潜规则”类加载器分三层启动类加载器Bootstrap、扩展类加载器Extension/Platform、应用类加载器Application。双亲委派的规则是收到加载请求时先不自己加载而是委派给父加载器父加载器再往上委派直到最顶层的启动类加载器再由父加载器尝试加载加载不了才回到子加载器手里。这样做最大的好处是保证 Java 核心类库不被人篡改。比如你写一个 java.lang.String如果在应用类加载器里先加载自己的类那整个类体系就乱了。双亲委派把核心类的加载权限死死绑在启动类加载器上保证同一个类只有一份定义。那怎么打破双亲委派两个经典场景Tomcat 里要加载不同 webapp 的类不能互相干扰所以每个 webapp 有独立类加载器JDBC 里 DriverManager 在启动类加载器加载却要调用厂商的 Driver 实现属于 SPI 场景于是用了线程上下文类加载器来做“逆向”加载。面试会被问“双亲委派怎么打破”你把这两个例子记住再答一句“重写 loadClass 而不是重写 findClass”基本就稳了。2.3 一个对象从 new 到回收的完整轨迹new 一个对象JVM 在字节码层面先检查常量池能否定位到类引用类没加载就触发加载。类加载完成后在堆上分配内存然后设置对象头信息包括偏向锁、GC 分代年龄、hashCode 等接着调用构造方法把实例字段初始化为最终值。最后当没有任何引用指向它它会经历可达性分析、GC 标记最终被回收。对象在内存里分三段对象头、实例数据、对齐填充。对象头又包含 Mark Word 和类型指针锁升级、GC 年龄都藏在 Mark Word 里。这块知识跟 synchronized 锁优化是联动的你复习对象头顺便把无锁→偏向锁→轻量级锁→重量级锁这条链路过一遍收益更高。3. 垃圾回收机制与主流GC回收器3.1 怎么判断一个对象该回收Java 里判断对象能不能回收用的是可达性分析而不是引用计数。原因很简单引用计数解决不了循环引用A 引用 B、B 引用 A但两者外部都没人引用计数永远不为零如果只靠计数这两块内存就泄漏了。可达性分析的起点是一组 GC Roots从这些根出发做遍历能到达的对象视为存活到不了就标记为可回收。GC Roots 包括虚拟机栈中引用的对象、静态属性引用的对象、常量池引用的对象、JNI 引用的对象、活跃线程、synchronized 持有的对象等。引用类型这块也是高频考点强引用正常 new软引用内存不足才回收适合做缓存弱引用GC 一到就回收ThreadLocal 的 ThreadLocalMap 里就用了弱引用虚引用形同虚设主要配合引用队列跟踪对象回收。你把这四种引用和场景对应好面试基本不会被问住。3.2 三种GC算法搭配什么场景标记-清除算法是最基础的分两步标记可回收对象然后统一回收。缺点是内存碎片化严重大对象可能找不到连续空间。标记-复制算法把内存分成两块只用其中一块GC 时把存活对象复制到另一块然后整块清掉。代价是浪费一半空间新生代就用了这个思想Eden:S0:S1 8:1:1只浪费 10%比对半分划算得多。标记-整理算法适合老年代标记完存活对象把存活对象往内存一端移动解决碎片问题但移动对象要更新引用成本较高。新生代多采用复制因为存活对象少复制成本低。老年代要保证存储空间连续又有大量长命对象所以用标记-清除或标记-整理。这就是分代收集的核心逻辑。3.3 主流GC回收器怎么选现在生产环境最常用的就是 G1JDK 9 之后还是默认回收器。G1 跟过去那些回收器最大的区别是它把整个堆划分为一个个大小相等的 Region新生代、老年代不再是物理连续的区域而是若干 Region 的集合。G1 通过 RSet 记录 Region 之间的引用关系每次回收不需要扫描全堆只扫描待回收 Region 相关的 RSet 即可。G1 的核心优势是“可预测停顿模型”你可以用 -XX:MaxGCPauseMillis 指定 GC 停顿目标比如 200ms。它内部维护一个优先列表每次收集时选停顿时间收益最大的 Region 集合CSet这就是“Garbage First”的含义哪里的垃圾最多先收哪里。CMS 是 G1 之前的明星回收器主打低停顿但在 JDK 9 后被废弃了。原因在于它是基于标记-清除的会产生大量碎片而且并发标记过程中用户线程还在跑会产生浮动垃圾最终可能触发 Concurrent Mode Failure退化到 Serial Old 做 Full GC反而更慢。再往上就是 ZGC。ZGC 的停顿时间不随堆大小增长目标在 10ms 以内靠的是染色指针和读屏障。JDK 11 引入实验JDK 15 才算正式可用。如果你的服务要求低延迟且堆上几十 GB可以重点考虑。我把几个关键指标整理成表方便你对比回收器年代算法特点适用场景Serial新生代复制单线程简单高效客户端小应用Parallel新生代复制吞吐优先服务端后台计算CMS老年代标记-清除并发低停顿JDK8 时期的互联网应用G1不分代逻辑Region 复制可预测停顿JDK9 默认服务端标配ZGC全程染色指针亚毫秒级停顿超大堆、低延迟场景选型策略很简单JDK 8 且追求吞吐用 ParallelJDK 8 且要求低延迟用 CMS 并预留 30% 堆空间JDK 9 以上直接 G1Docker 容器里关注堆上限一定要显式设置 -Xmx避免容器限制导致异常。3.4 GC日志到底怎么看不会看 GC 日志调优就是纸上谈兵。JDK 8 用 -XX:PrintGCDetailsJDK 9 以后改成统一日志 -Xlog:gc*。我直接给你看一段真实日志拆开读[GC (Allocation Failure) [PSYoungGen: 524800K-1307K(612352K)] 525296K-1803K(2012160K), 0.0034562 secs] [Times: user0.01 sys0.00, real0.01 secs]PSYoungGen 是 Parallel 回收器的新生代区域524800K 是回收前占用1307K 是回收后占用括号里 612352K 是该区域总容量。后面 525296K 是整堆回收前占用1803K 是回收后占用2012160K 是整堆总容量。最后 0.0034562 secs 是耗时Times 里 user 是用户态耗时sys 是内核态耗时real 是实际时钟时间。如果 usersys 大于 real 好几倍说明 GC 是并发执行的如果 user 和 real 接近可能是 STW。老年代 GC 日志会有 Full GC 标记看到 Full GC 频率高基本就是老年代空间告急或者元空间不够。不要慌先用 jstat 看趋势再决定是加内存还是改代码。4. JVM调优实战参数、工具与OOM排查4.1 常用JVM参数盘点调参之前先明确一件事调优的目标不是让 GC 次数越少越好而是让应用的响应时间、吞吐量和资源占用达到平衡。堆设太大GC 单次停顿更长设太小Minor GC 频率上升。常规做法是把 -Xms 和 -Xmx 设置为相同值避免运行期动态扩容带来性能抖动。以下是我日常必配的一组参数-Xms2g -Xmx2g -Xmn1g -XX:UseG1GC -XX:MaxGCPauseMillis200 -XX:MetaspaceSize256m -XX:MaxMetaspaceSize512m -XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/data/logs/oom.hprof -XX:ExitOnOutOfMemoryError-Xmn 控制新生代大小对 GC 频率影响很大。新生代太小短命对象频繁晋升老年代老年代压力大新生代太大老年代空间被压缩可能引发 Full GC。经验值是堆的 1/3 到 1/2具体按对象存活率观察。这两个 HeapDump 参数非常关键。很多人线上 OOM 后连 dump 文件都没留下来全靠猜。加上 -XX:HeapDumpOnOutOfMemoryError 后一旦 OOM 自动导出堆快照配合 -XX:ExitOnOutOfMemoryError 让进程快速退出方便监控系统拉起新实例用最短时间保留现场。关于网上常说的“设置 IDEA 的 JVM 运行内存防止开发测试时 OOM”方向是对的但别在 idea64.exe.vmoptions 里把 -Xmx 调到 8G如果你的电脑标配 16GIDEA 占一半内存其他程序直接受影响。IDEA 这种大型 IDE 一般 -Xmx2g 到 -Xmx4g 足够主要调高的是 MetaSpace 和 CodeCache防止开发大型项目时类加载频繁报错。4.2 调优工具全家桶实战命令工具按排查场景分看进程用 jps看 GC 用 jstat看堆快照用 jmap看线程堆栈用 jstack看实时状态用 visualvm 或 arthas。你把这一条线记牢后面就有方向了。jps 是最简单的列出当前机器上的 Java 进程jps -ljstat 是 GC 状况监控主力每 1 秒打印一次连续输出 10 次jstat -gcutil 8321 1000 10S0、S1 是 Survivor 使用率E 是 EdenO 是老年代M 是元空间YGC 是 Minor GC 次数YGCT 是 Minor GC 总耗时FGC 是 Full GC 次数。如果 O 区持续高位不下FGC 频繁飙升说明堆内存要不够了或者有内存泄漏。jmap 主要用于导出堆快照jmap -dump:formatb,file/tmp/heap.hprof 8321dump 文件通常很大最好先 jmap -heap 看一下概要确认有问题再 dump。如果线上 Java 进程卡死jmap 可能会触发 Full GC要谨慎使用更推荐用 arthas 的 heapdump 命令。jstack 打线程快照排查死锁和线程阻塞jstack 8321 /tmp/thread.txt死锁时日志里直接有 Found one Java-level deadlock 字样可以 grep 一下。排查高 CPU 问题时先 top -Hp 找到线程 pid转十六进制后在 jstack 输出里搜 nid就能定位到具体代码行。arthas 是阿里开源的工具线上排查神器。dashboard 命令实时展示线程、内存、GC 信息thread -n 3 直接列出 CPU 占用最高的三个线程watch 命令观测方法入参和返回值不用改代码不用重启进程。强烈建议线上问题排查先上 arthas再考虑 jmap 和 jstack。它对运行态的影响更小。visualvm 和 jconsole 是可视化老伙计本地开发环境用它们看看内存曲线、线程状态比较方便。但线上通常没有图形界面所以命令行工具才是基本功。4.3 一次生产OOM排查实录我之前接手过一个在线报表服务用户导出大列表时频繁重启。接手后的排查顺序是这样的。先看监控内存曲线呈锯齿状每次压到顶就掉下来典型的堆内存不足伴随 GC。用 jstat -gcutil 观察发现老年代使用率长期 90% 以上FGC 次数持续上涨几乎每秒一次。然后用 jmap -heap 看堆参数发现 -Xmx2g 且新生代只有 512m。再加 -XX:HeapDumpOnOutOfMemoryError 参数等下次 OOM 自动导出堆快照。dump 文件拿下来用 MAT 打开看 Dominator Tree发现一个 ArrayList 实例占了几百 MB实例字段里全部是查询接口返回的 DTO 对象。最后在代码里定位死循环分页查询时每次把查询结果 addAll 进同一个 list没有分批处理。数据量小的时段没问题数据量一大直接堆溢出。修复方案很简单改成分批处理每批处理完释放引用关键是要把“一次 OOM 的完整排查路径”跑通——先看监控曲线再 jstat 看 GC 频率再 dump 分析对象最后回到代码层面解决。一条链路下来问题从“偶发重启”变成“可定位可预防”。4.4 线程池与“JVM剩余线程”的误区这个话题我必须单独拎出来说因为很多人被一些面试资料带偏了。网上有句错误的说法线程池最大线程数应该设置为 JVM 剩余可用线程数。这句话在工程里完全站不住脚。JVM 线程是操作系统的原生线程线程数上限受操作系统、栈内存、CPU 核数共同制约。线程池参数调优看的是任务类型CPU 密集型任务线程数设置为 CPU 核数 1IO 密集型任务线程数设置为 CPU 核数 × 2 1因为有大量时间在等待 IO线程阻塞时可以再接入新任务。这些公式只能做初值最终要结合压测结果调整。还有一个关键点线程不是越多越好。线程切换本身有开销而且每个线程默认占用 1MB 左右的栈空间创建一万个线程光栈内存就吃掉近 10GB。所以线程池参数代表的是“你能接受多少并发同时在跑”而不是“JVM 还剩多少资源给你跑”。这道题同时也是 Java 基础知识的高频面试点建议你彻底想明白别再被“剩余线程”这种伪概念带沟里。4.5 故障排查工具对比速查场景首选工具关键命令/动作查看 Java 进程列表jpsjps -l看 GC 频率和耗时jstatjstat -gcutil pid 1000看堆内存概况jmapjmap -heap pid导出堆快照jmap / arthasjmap -dump:formatb,file/tmp/h.hprof pid分析线程状态jstackjstack pid thread.txt定位高 CPU 线程top jstacktop -Hp pid转十六进制搜 nid线上动态观测arthasdashboard / thread -n 3 / watchJVM 参数核对jinfojinfo -flags pidGC 日志分析官方脚本/gceasy把日志导入在线工具自动分析5. 高频JVM面试题与故障速查5.1 JMM内存模型和JVM运行时数据区不是一回事我发现很多人把这两个概念混在一起。Java 内存模型JMM描述的是多线程共享变量的可见性问题它规定了一个线程对共享变量的写入何时对另一个线程可见。它定义了一套抽象的内存访问规则包括主内存、工作内存、volatile、synchronized、happens-before 关系。而运行时数据区是 JVM 管理内存的物理划分两者不是一个层面的东西。面试回答思路先说 JMM 是规范解决原子性、可见性、有序性然后说 volatile 可以保证可见性和有序性但不能保证原子性接着举一个经典例子——两个线程同时 intvolatile 修饰也不能保证结果一致因为 int 是三步操作读、加、写。这样答面试官会觉得你是真的理解而不是背八股文。5.2 经典高频八道题速答我整理了八个不能不会的 JVM 面试题每个都按“考点 参考答案”的方式给你。第一题JVM 内存区域哪块是线程私有的答程序计数器、虚拟机栈、本地方法栈其中本地方法栈在 HotSpot 中合并到虚拟机栈。第二题如何判断对象可以被回收答可达性分析从 GC Roots 向下遍历不可达即回收同时涉及四种引用类型。第三题类加载过程是什么答加载、验证、准备、解析、初始化重点答准备阶段分配静态变量内存但赋默认值初始化阶段才执行clinit。第四题双亲委派模型的作用答防止核心类库被篡改避免类重复加载举例说明 Tomcat 和 SPI 如何打破。第五题CMS 和 G1 的区别答CMS 基于标记-清除低停顿但有碎片G1 基于 Region 划分可预测停顿且整体是标记-复制JDK9 后默认。第六题什么情况会触发 Full GC答老年代空间不足、元空间不足、System.gc 调用、分配担保失败、大对象无法分配等。第七题OOM 的类型有哪些答Heap OOM、Metaspace OOM、StackOverflow、Direct Memory OOMNIO 大量分配堆外内存。第八题怎么优化频繁 Full GC答先看 GC 日志判断是内存泄漏还是内存不足内存不足就调大堆或优化对象生命周期内存泄漏就用 MAT 定位滞留对象。5.3 常见JVM故障速查表故障现象可能原因排查手段java.lang.OutOfMemoryError: Java heap space堆内存不足或泄漏jmap dump MAT 分析java.lang.OutOfMemoryError: Metaspace类元数据太多加大 MaxMetaspaceSize检查动态代理类生成StackOverflowError无限递归或栈帧过大jstack 定位检查递归出口CPU 飙升死循环、GC 频繁、线程忙等top -Hp jstack 定位线程数打满线程池配置过大、连接池泄漏jstack 统计线程状态接口响应慢Full GC 频繁、锁竞争jstat 看 FGCjstack 看 BLOCKED最后再分享一个我自己的使用习惯每台服务器上都装好 arthas配置好统一的 GC 日志目录所有 Java 服务启动时都加上 HeapDump 参数。这三点做好绝大多数 JVM 问题都能在十分钟内定位到代码层面。JVM 原理看起来吓人但它和线上真实问题一一对应之后反而比框架源码更直观。你把这套内容吃透再回头刷 java 面试题、看 jvm 调优工具思路都会清晰很多。