资讯动态

JVM速记:内存模型、类加载与垃圾回收全解析

发布时间:2026/9/15 11:35:58 来源:尧图企业网站定制
这几年帮团队筛Java候选人十个里能背出“堆、栈、方法区”的可能有七八个但你再追问一句“一个new出来的对象到底怎么从新生代搬到老年代”很多人就开始绕了。这不能全怪大家JVM的知识点太散面试又能从内存模型问到垃圾回收器从类加载问到调优参数每个方向都能往深处钻。这篇JVM速记就是专门对付这种“背得出来、讲不清楚”的局面的。内容覆盖了JVM内存模型、类加载机制、垃圾回收与回收器选型、调优命令、面试高频问题以及三个开发中让你头疼的JVM报错排查。适合准备面试的Java开发也适合每次看到GC日志、OutOfMemoryError就发怵的同学。1. 一张图吃透JVM五大内存区域1.1 堆内存对象的主战场堆是JVM管理最大的一块内存也是所有线程共享的区域。几乎所有对象实例和数组都在这里分配。堆内部不是一锅粥而是被划分成新生代和老年代新生代又继续分成Eden区和两个Survivor区也就是常说的S0和S1。默认分配比例是Eden:S0:S1 8:1:1可以通过-XX:SurvivorRatio调整。为什么Eden要占这么大因为绝大多数对象都是“朝生夕死”活不过第一次Minor GCEden大一点Minor GC发生的频率就能低一点。而Survivor区为什么要留两份这是为复制算法准备的——每次回收后存活对象从Eden和一个Survivor区复制到另一个空的Survivor区两个区来回倒腾才能保证老年代里干净的数据。你可能还听过“大对象直接进入老年代”这是通过参数-XX:PretenureSizeThreshold控制的。设置了这个阈值后大于阈值的对象直接在老年代分配避免大对象在Eden和两个Survivor之间反复复制那太浪费性能了。不过要注意这个参数只对Serial和ParNew这两个回收器有效对CMS和G1并不生效。1.2 虚拟机栈与本地方法栈每个线程的私有空间虚拟机栈描述的是Java方法执行时的内存模型。每个方法从调用到执行结束对应一个栈帧的入栈和出栈。栈帧里装着局部变量表、操作数栈、动态链接、方法出口这些信息。局部变量表主要存放基本数据类型、引用类型和returnAddress而操作数栈就是JVM执行引擎的工作台运算时把数据压进去、算完再弹出来。听到这里你可能会疑惑对象在堆里引用在栈里那方法里的临时变量到底算谁答案是对象实例本身在堆你持有的引用在栈的局部变量表里。如果线程请求的栈深度超过了JVM允许的深度就会抛StackOverflowError如果栈容量可以动态扩展但内存不够了就会抛OutOfMemoryError。栈大小可以通过-Xss参数调整比如-Xss256k。默认值在不同平台不一样Linux x64下通常是1MB。平时写递归函数多了就能感受到这个参数的重要性。1.3 方法区、运行时常量池与直接内存方法区是概念上的东西所有线程共享存放已被加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存。JDK8之前方法区的实现叫永久代JDK8之后被元空间取代。关键区别在于永久代用的是JVM堆内存而元空间直接使用本地内存。这意味着-XX:MaxMetaspaceSize不设的话元空间理论上可以一直吃机器内存直到物理内存撑不住所以生产环境一定要设这个上限防止加载过多类导致机器卡死。运行时常量池是方法区的一部分用来存放编译期生成的各种字面量和符号引用。JDK7之后字符串常量池被移到了堆里这也是为什么很多面试题喜欢问String.intern()——它和一个String对象到底是不是同一个引用答案取决于常量池在堆里的各种状态一句话解释不清楚但理解了位置关系就很容易推断。另外还有一块容易被忽略的直接内存。NIO中的DirectByteBuffer就在直接内存里分配不受堆大小限制但受机器物理内存限制。如果你用了Netty这类框架又没有合理配置堆外内存很容易出现OutOfMemoryError: Direct buffer memory。排查这类问题只看堆内存是不够的。1.4 对象的一生从创建到晋升一个对象完整的一生大概是这样的。new一个对象JVM先尝试在栈上分配不其实默认是优先在Eden区分配。Eden区满了触发Minor GCGC时用可达性分析找到存活对象把它们复制到S0区并把对象年龄设为1。之后每次Minor GC存活对象年龄加1从S0复制到S1或反过来。当对象年龄达到-XX:MaxTenuringThreshold默认值15时晋升到老年代。还有一种情况如果Survivor区装不下了多余对象会提前晋升老年代不是非得等到15岁。这里有个特别容易被忽视的细节动态年龄判定。JVM不是真的等到所有对象都到15岁才晋升而是在Survivor区中相同年龄所有对象大小总和大于Survivor空间的一半时年龄大于等于该值的对象直接进入老年代。很多人在调优时拍脑袋把MaxTenuringThreshold调成5结果发现晋升变快了老年代涨得也快了就是因为没搞清楚动态年龄判定这回事。2. 类加载机制JVM怎么把.class变成能跑起来的代码2.1 类加载的五个阶段类从被加载到被卸载完整生命周期要经过加载、验证、准备、解析、初始化五个阶段。加载阶段做的事很直白通过类的全限定名获取二进制字节流把静态存储结构转为方法区的运行时数据结构然后在堆里生成一个Class对象作为访问入口。验证阶段是为了保证字节流符合JVM规范包含文件格式验证、元数据验证、字节码验证和符号引用验证。准备阶段给类的静态变量分配内存并设置零值注意这时候是零值不是代码里写的初始值。比如static int a 10准备阶段a先等于0真正的10要等到初始化阶段调用clinit方法时才赋值。解析阶段把符号引用替换为直接引用。初始化阶段是真正执行类构造器clinit的时机静态代码块、静态变量赋值都在这里执行。很多人搞不清“类什么时候被加载”和“类什么时候被初始化”的区别。触发初始化的时机有六个new/getstatic/putstatic/invokestatic指令、反射调用、初始化子类时先初始化父类、JVM启动时指定的主类、JDK7后的动态语言支持、默认方法相关的接口初始化。类被加载了但不初始化是可能发生的比如访问一个类的静态final常量这个常量在编译期就被写入了常量池根本不会触碰到目标类。2.2 双亲委派为什么核心类不能被替代类加载器有三层从下到上分别是启动类加载器、平台类加载器和应用类加载器。JDK8前中间那层叫扩展类加载器JDK9模块化之后变成了平台类加载器名字变了好记一点。启动类加载器负责加载JDK自带的核心类比如java.lang包下的东西它由C实现在Java代码里引用不到平台类加载器负责一些扩展模块应用类加载器负责加载classpath下的类。双亲委派机制说的是当一个类加载器收到加载请求时先把请求委派给父加载器父加载器再往上委派一直到顶层的启动类加载器。只有父加载器反馈自己无法完成加载时子加载器才会尝试自己加载。为什么要这么设计核心目的就是防止核心类库被篡改。试想你自己写了一个java.lang.String如果类加载器不按双亲委派走直接加载了你写的StringJDK里的String就被替换了整个Java运行体系会直接崩掉。有了委派机制java.lang.String的加载请求最终都会被顶到启动类加载器你写的那个String根本没机会在主类路径被加载。2.3 打破双亲委派的两类真实场景双亲委派不是牢不可破的。最经典的打破场景是Tomcat。每个Web应用都要能加载自己部署的class如果完全遵守双亲委派所有Web应用都共享classpath不同版本的库就会冲突。Tomcat用WebAppClassLoader优先加载自己Web应用目录下的类加载不到才委派给父加载器这就是先把“自己”加载了一遍再往上找。另一个场景是SPI机制。JDBC的驱动注册就是经典案例DriverManager是启动类加载器加载的rt.jar里的类但具体驱动类比如MySQL的Driver放在应用classpath里按照双亲委派启动类加载器根本加载不到应用类。JDK的解决方案是引入线程上下文类加载器由DriverManager通过Thread.currentThread().getContextClassLoader()来加载具体驱动实现。面试时如果被问“双亲委派能不能被破坏”不要只说“能”一定要补一句“哪些场景破坏了为什么要破坏”。光背概念不说场景面试官下一句话就会把你问住。3. 垃圾回收原理与回收器选型3.1 判定对象已死的两种算法垃圾回收的第一步是判断哪些对象可以回收。引用计数法最简单每个对象维护一个计数器被引用就加1引用失效就减1计数器为0的对象就可以回收。听起来直观但它解决不了循环引用问题A引用了BB引用了A外部没引用它们两个对象计数器都不为0就永远回收不掉。所以主流JVM都不采用这种算法。JVM用的是可达性分析。从一组被称为GC Roots的根对象出发沿着引用链一直往下搜索不可达的对象就是可回收对象。哪些东西能当GC Roots虚拟机栈中栈帧里的局部变量表引用的对象、静态属性引用的对象、常量引用的对象、本地方法栈中JNI引用的对象、被同步锁持有的对象等。理解GC Roots很重要它是你分析内存泄漏时的起点。这里还要提一下四种引用。强引用就是普通new出来的对象只要还有强引用GC永远不会回收软引用描述还有用但非必需的对象内存不足时回收弱引用比软引用更短命下一次GC就没了虚引用最弱不能通过它获取对象实例只在对象被回收时收到一个系统通知。面试问到这记得把每种引用配合一个使用场景说软引用适合做缓存弱引用适合做ThreadLocal的Key虚引用适合做堆外内存的回收追踪。3.2 三种基础回收算法标记清除、标记复制、标记整理标记清除是CMS回收器的基础思路。先标记出所有可回收对象统一回收。问题在于产生大量内存碎片以后分配大对象时明明空间够却可能因为不连续导致触发Full GC。标记复制解决了碎片问题把存活对象复制到另一块空间然后整块清理原空间代价是浪费一部分内存。新生代用复制算法配合Survivor区就是这个思路。标记整理是复制算法在老年代的变种。老年代对象存活率高不适合频繁复制于是让所有存活对象向一端移动清理掉边界以外的内存整个过程不浪费空间也不产生碎片。代价是移动对象要更新所有引用会带来额外的停顿。所以你看实际回收器的设计本质上是这三种算法在不同区域的组合没有哪种算法是完美的。3.3 常见回收器对比Serial、Parallel、CMS、G1回收器工作区域算法特点适用场景Serial新生代复制单线程简单高效客户端模式、小型应用ParNew新生代复制Serial的多线程版与CMS搭配使用Parallel Scavenge新生代复制关注吞吐量后台计算型任务Serial Old老年代标记整理单线程老年代回收Serial的后备方案Parallel Old老年代标记整理多线程老年代回收Parallel Scavenge的老年代搭档CMS老年代标记清除低停顿但碎片化互联网场景、响应优先G1整个堆标记整理复制可预测停顿分区管理JDK9后的Server默认选择G1是JDK9之后的默认回收器。它把堆划分成一个个大小相同的Region每个Region可以扮演Eden、Survivor或者Old的角色还有专门存放超大对象的Humongous区。G1的回收思路不是全堆一起扫而是维护一个优先级列表优先回收垃圾最多的Region这就是名字里“Garbage First”的由来。3.4 面试最爱问的CMS vs G1差异CMS的设计目标是获取最短回收停顿时间适合旧时代追求低响应延迟的互联网应用。整个过程分四步初始标记、并发标记、重新标记、并发清除。初始标记和重新标记需要停顿用户线程并发标记和并发清除可以和用户线程并发执行。CMS的毛病不少基于标记清除会产生碎片、无法处理并发清除阶段产生的浮动垃圾、如果老年代增长过快还可能出现Concurrent Mode Failure退化成Serial Old单线程Full GC。G1相比CMS的优势在于可预测的停顿时间。你可以通过-XX:MaxGCPauseMillis指定期望停顿目标G1会根据历史回收数据动态调整各Region的回收数量来尽量达成。另外G1使用Region复制能有效避免碎片问题。而且G1通过Remembered Set来维护Region之间的引用关系垃圾回收时不用全堆扫描这是它在处理超大堆时表现好的核心原因。面试被问“为什么G1能实现可预测停顿”你不能只说“它分Region了”。更完整的答法是分Region让回收范围可控制G1每次只回收一部分收益最高的Region然后通过跟踪每个Region回收所花费的时间和回收得到空间的比值动态调整Region回收数量来逼近停顿目标。如果能把这个逻辑说清楚面试官基本就知道你确实是理解了的。4. JVM调优实战先定位问题再动参数4.1 调优前必须知道的三个指标很多人一听到调优就觉得是改参数。我见过不少开发上来就把-Xmx从2G调成8G结果Full GC越来越频繁停顿越来越长。调优的核心目标有三个吞吐量、停顿时间和内存占用。吞吐量指运行用户代码的时间占总运行时间的比例比如GC占1%就是吞吐量99%适合后台任务场景。停顿时间指GC导致应用暂停的时间适合在线交互场景。内存占用就是堆大小、元空间大小这些。这三个指标是互相制约的你想要低停顿就得用更小的堆、更频繁地回收吞吐量就降了你想要大吞吐堆开大一点、少回收停顿时间就不可控。所以调优之前先想清楚你要什么。配置一个线上服务第一件事不是调参数而是先明确这台机器的规格、接口的响应时间要求、接口的QPS峰值。指标定义清楚了后面所有参数选择才有依据。4.2 常用命令与日志解读调优第一步是拿到现状不是靠猜。最常用的是jstat和jmap。# 查看进程ID jps -l # 每1秒打印一次GC统计信息 jstat -gcutil 12345 1000 # 查看堆内存各区域使用量 jstat -gccapacity 12345jstat -gcutil输出里E、S0、S1、O、M分别代表Eden、Survivor0、Survivor1、老年代、元空间的使用百分比YGC和YGCT是Minor GC次数和耗时FGC和FGCT是Full GC次数和耗时。你观察几分钟就能看出规律如果老年代O涨到80%以上才触发Full GCFGC又频繁那大概率是对象晋升速度过快或者老年代空间不够。GC日志也是必须会看的。JDK8之前常用-XX:PrintGCDetails -XX:PrintGCDateStampsJDK9之后统一改成-Xlog:gc*。看GC日志时主要关注三点GC类型是Minor还是Full、停顿时间多长、每次GC后各区域的内存变化。jmap命令可以用来生成堆转储快照不过要注意一个坑提示jmap -histo:live会强制触发一次Full GC生产环境用的时候一定要谨慎最好在低峰期操作或者直接用jmap -dump:formatb,fileheap.hprof抓完整堆快照再用MAT分析。4.3 一次典型的Full GC优化过程我处理过的一个真实案例一个后台服务老年代4GFull GC每10分钟一次每次停顿3秒以上。先用jstat -gcutil观察发现Minor GC并不频繁但每次Minor GC后老年代增长速度很快。然后抓了堆快照用MAT分析发现一个大而全的本地缓存Map占了老年代70%的空间而且这个缓存一直在被更新每次更新都在往老年代塞新对象。问题定位后就不是调参的事了。改代码缓存改为Guava Cache并设置最大容量和过期时间把缓存的大小上限从20万降到5万对缓存里的对象改为软引用包装。改完以后老年代从4G降到1.5GFull GC几乎不再出现。这个案例想说的核心是大多数JVM问题不是靠调参解决的而是靠代码优化解决的。参数调优是辅助手段用来掩盖代码层面的问题时间久了要么内存还是涨要么吞吐量掉得厉害。正确的调优流程永远是先观察再定位再改代码最后才改参数验证。5. 高频JVM面试问题速查5.1 面试官常问的6类问题我整理了面试中最高频的JVM问题和答题方向建议面试前对着这张表过一遍。面试问题答题要点JVM内存模型是怎样的线程共享堆、方法区线程私有虚拟机栈、本地方法栈、程序计数器判断对象可回收的算法可达性分析GC Roots有哪些强软弱虚四种引用生存时间从强到弱各自使用场景CMS和G1有什么区别算法、停顿模型、适用场景G1默认回收器什么是双亲委派加载流程、为什么不能破坏、什么场景要打破类加载过程分几步加载、验证、准备、解析、初始化哪些OOM类型你遇到过Java heap space、Metaspace、GC overhead limit exceeded、Unable to create native thread线上JVM调优怎么做先定位问题再看参数jstat、jmap、GC日志结合分析这里面最容易被追问的是“GC overhead limit exceeded”。这个报错说的是GC占用了超过98%的时间回收却不到2%的堆空间。本质是堆太小且全是即将要死的对象。这时候不要想着加大堆而要想为什么有这么多存活对象该查内存泄漏了。5.2 JRE、JDK、JVM的关系一句话说明这个基础概念经常被问到其实就是一个包含关系JDK包含JREJRE包含JVM。准确点说JDK是Java开发工具包包含JRE和编译器、调试器等开发工具JRE是Java运行环境包含JVM和Java核心类库JVM是Java虚拟机是整个Java跨平台能力的核心。跑Java程序只要装JRE就够了做Java开发必须装JDK。但有个细节值得专门说你在生产环境部署应用可能只装了JRE但很多APM工具、性能采集脚本会用到jstat、jmap这些命令这些工具在JDK的bin目录里JRE里是没有的。所以很多公司生产环境直接装JDK不是为了开发是为了留一手排查工具。5.3 快速答题模板怎么讲清楚一个JVM问题面试被问到一个JVM问题怎么回答才显得不像背题记住三句话的组合结论先行、原理跟上、给一个场景。问“G1和CMS选哪个”你先说结论在JDK9以后的服务端场景默认用G1追求极低停顿的老旧JDK场景可能还保留CMS。再说原理G1按Region管理堆停顿可控CMS标记清除碎片问题明显。最后补场景如果堆已经超过8GG1优势更明显如果业务响应要求极高且堆在4G内CMS在低版本JDK上也很能打。这个结构的好处是每个问题你都能展示出“我不只知道定义我还知道怎么选型”。面试官最怕的就是候选人只会背“G1适合大堆”问一句“多大算大”就卡住了。你主动给场景就把主动权拿回了自己手里。6. 开发中三个高频JVM报错排查记录6.1 cannot read vmoptions路径读不出来怎么办cannot collect jvm options caused by: 0: cannot read: D:\v作业实训 vjetbrain_这类报错通常在启动JetBrains系IDE时出现比如IDEA或PyCharm。原因是IDE启动时要读取JVM选项文件但配置里写了一个无法读取的路径常见情况有三类路径指向的文件根本不存在路径中包含中文或空格导致解析器没有正确拼出完整路径文件内容里挂了一个不存在的-javaagent参数比如网上下载的破解或者优化脚本残留。排查思路很简单。先用命令确认到底读了哪些vmoptions文件C:\Users\你的用户名\AppData\Roaming\JetBrains\IntelliJIdea2024.x\idea64.exe.vmoptions C:\Program Files\JetBrains\IntelliJIdea2024.x\bin\idea64.exe.vmoptions打开这两个文件看看有没有指向不存在路径的参数。特别注意-javaagent和-XX:VMOptionsFile这两类。找到之后把无效行删掉或者注释掉重启IDE。如果文件本身没问题就把项目路径从那种带中文、空格、特殊符号的目录里挪出来比如示例里的D:\v作业实训这种路径挪到D盘根目录下的英文目录基本就恢复了。动手前记得备份原文件这个习惯在任何配置文件操作里都值一条命。6.2 expiring daemon because jvm heap space is exhausted这个报错如果出现在Gradle构建过程里说的是Gradle守护进程的JVM堆空间用完了。注意这里的关键词是“daemon”它不是你的业务应用进程是构建工具常驻后台的进程。很多同学遇到这个报错第一反应去改自己项目的JAVA_OPTS改了半天没用就是因为搞混了构建进程和业务进程。正确的解法是到项目根目录的gradle.properties里增加或调整守护进程的内存配置org.gradle.jvmargs-Xmx2048m -XX:MaxMetaspaceSize512m -XX:HeapDumpOnOutOfMemoryError改完以后重启Gradle daemon让新参数生效./gradlew --stop ./gradlew build如果加大内存后仍然经常报错就要怀疑构建脚本本身有问题了。可能是某个插件在循环里不停创建大对象也可能是每次构建都会加载一些超大依赖。另外org.gradle.daemonfalse可以临时关闭守护进程来确认是不是daemon本身的问题但日常不推荐因为每次构建都要重新启动JVM速度会慢很多。6.3 jvm reference can not find the corresponding jvm service这类报错我经常在同事的IDEA里看到一般出现在你重装了JDK、移动了JDK目录、或者IDEA升级后自带运行时路径变了之后。报错直译是“JVM引用找不到对应的JVM服务”IDE内部缓存里还记着旧的JDK路径但那个路径已经不存在了所以找不到JVM实现。排查步骤从轻到重来。先检查Project StructureFile - Project Structure - SDKs看看列出的JDK路径是否有效无效就点加号重新添加JDK目录选择你安装的JDK根目录IDE会自己识别版本。然后到File - Settings - Build Tools - Gradle里确认Gradle JVM选的是当前有效JDK而不是一个失效的引用。如果这些都正常最后再考虑处理缓存File - Invalidate Caches勾选Clear file system cache and Local History重启IDE。绝大多数情况下重新指定JDK路径这一步就能解决走到清缓存那步已经算重病重药了。写到这里想起这两年处理过的各种JVM问题有一个体会特别深JVM的每个知识点都不是孤立存在的。你在面试时讲内存模型其实是在为后面讲GC铺垫你理解了类加载的双亲委派才能真正明白为什么有的线上问题重启就好、有的重启也没用你调过的每一个GC参数背后都是对堆分区结构和回收器工作流程的理解。学这块内容最怕的就是只背结论我在实际排查中发现能把原理讲清楚的人遇到再奇怪的报错也有思路而只背参数的人换一个环境就不会玩了。希望你这份速记拿到的不是几个孤立答案而是一张能自己延伸的JVM知识网。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价