最近一直在系统刷Java面试题,今天正好到Day31。先交代一下背景:这个系列不是为了应付面试背题,而是把高频考点按专题一个个吃透,每天记录核心知识点、实践案例和踩坑过程。前面三十天已经覆盖了集合、并发、IO、MySQL、Redis这些模块,今天轮到大家公认最硬核也最容易被问倒的一块——JVM。
为什么把JVM放到第31天才开始?因为这东西光靠背诵真的记不住,需要前面有并发、集合、类加载相关的基础做铺垫,理解起来才顺。而到了Day31这个阶段,我已经不再满足于“知道概念”,而是想把每个知识点都问到“为什么”这一层。这篇文章就是今天一天的学习实录和总结,从内存布局、垃圾回收、类加载到调优排查,一条线完整拉通,希望能给同样在准备Java面试或者日常开发中想深入理解JVM的同学一点参考。
1. 这一天是怎么安排的
1.1 为什么Day31选择了JVM专题
比起并发编程那类问题,JVM的面试题有一个特点:题目描述往往很短,但展开深度可以非常深。比如面试官问“一个对象在Java中是怎么创建的”,表面上看是个语法题,实际上可以一路追问到内存分配方式、TLAB、锁竞争、GC压力,甚至堆外内存。前端三十天积累的知识在这里全部派得上用场,尤其是并发和集合部分,和JVM的内存语义有大量交叉。
很多人把八股理解成“背下来就行”,但JVM这一块恰恰是背了也答不好的典型。原因在于JVM涉及的是运行时行为,试卷上不会写“垃圾回收有几种算法”,而是给你一个线上OOM的场景,让你分析哪里出了问题。这种题目靠的是对内存模型、GC机制、故障排查工具链的整体理解,不是孤立的知识点能解决的。所以Day31的学习目标我定为三件事:第一,把运行时数据区、对象创建、类加载、垃圾回收这几个核心链条串成一条线;第二,学会看GC日志和堆转储文件;第三,亲自实测几个调优参数,把理论变成可操作的方法论。
1.2 当天的学习计划表
我习惯在每天开始前把计划写清楚,不然JVM这种东西一钻进去容易迷路。当天上午的时间给理论梳理,下午用来做实验和写demo,晚上集中自测面试题。实际的安排是这样的:
| 时间段 | 内容安排 | 目的 |
|---|---|---|
| 09:00 - 10:30 | 运行时数据区与对象创建流程 | 建立内存模型的基本框架 |
| 10:30 - 12:00 | 垃圾回收算法与收集器选型 | 理解GC背后的设计取舍 |
| 14:00 - 15:30 | 类加载机制与双亲委派 | 搞清.class到对象的过程 |
| 15:30 - 17:30 | JVM参数实测和OOM排查演练 | 纸上概念落到真实操作 |
| 晚上 | 高频面试题自测与错题整理 | 以面试官视角检验学习效果 |
这个节奏并不算快,但有一个原则我一直坚持:每天的内容必须能形成一个“概念—原理—实操”的闭环,而不是看几篇文章就觉得自己会了。JVM尤其如此,你不亲手启动一个带GC日志的进程,永远感受不到内存分配的压力曲线到底长什么样。
2. JVM核心概念逐个击破
2.1 运行时数据区,别只会背五个名字
运行时数据区是JVM面试几乎必考的开场题。网上随便一搜就能看到程序计数器、虚拟机栈、本地方法栈、堆、方法区这一串名词,但如果你只背名字,面试官追一句“哪些线程共享哪些不共享”就卡壳了。要理解这个问题,核心是抓住线程与内存的关系:每个线程执行一个方法,就需要一块独立的栈空间来存放局部变量和调用状态,所以虚拟机栈和程序计数器天然是线程私有的;堆和方法区则是所有线程都能访问的全局区域,对象实例和类型信息放在这里。
这里有一个容易忽略的细节:程序计数器是唯一不会出现OutOfMemoryError的区域,因为它只是记录字节码指令的行号,空间占用固定。这个点在面试中经常作为“为什么”出现,本质上考的是你是否理解线程切换时的上下文恢复机制。与之相比,虚拟机栈可以动态扩展,深度超过限制时抛StackOverflowError,扩展到无法申请更多内存才是OOM。记住这个区分,比硬背两行字有用得多。
JDK8之后方法区被移除,变成了元空间,这是一个高频考点。元空间使用本地内存,而不是JVM堆内存,所以默认情况下它的上限取决于操作系统可用内存,而不是-Xmx参数。实际开发中经常出现一种情况:堆明明没满,却报了MetaSpace OOM,查下来都是因为动态生成代理类或者加载了海量类导致元空间膨胀。这类问题在面试和线上排查中都很典型,后面调优部分我会专门讲。
2.2 对象的一生:从创建到回收
搞清楚内存分区之后,下一步就是理解一个对象从诞生到消亡的完整路径。很多人对“new一个对象”的理解停在语法层面,其实JVM内部要干的事情不少:先执行类加载检查,确认这个类的元数据已经在方法区存在;然后在堆上分配内存,分配方式有两种,指针对撞和空闲列表,具体用哪种取决于堆是否规整,而堆是否规整又取决于垃圾收集器是否带压缩整理功能;接着初始化内存为零值,设置对象头,最后调用构造方法。
分配内存这一步有个并行安全的问题值得展开。堆是线程共享的,多个线程同时给对象分配内存时,必须通过CAS加失败重试来保证原子性,或者使用TLAB——每个线程在堆中预先分配一块本地缓冲区,线程内分配对象时优先在TLAB中进行,只有缓冲区满了才去共享堆区申请。这也是为什么说TLAB的存在能大幅降低锁竞争,理解了这一层,你再回答“对象分配在哪里”的问题,就不是简单一句“堆上”了。
对象创建之后,它的动向也很有意思。绝大多数对象在新生代的Eden区出生,存活过一定GC轮次后会被晋升到老年代。但这个晋升条件不是拍脑袋定的,而是由动态年龄判定机制控制:如果新生代中某个年龄段的对象总大小超过幸存区的一半,这些对象会直接进入老年代,不严格按照年龄阈值来。这种设计的意图很清晰——防止大量长期存活的对象在新生代反复复制浪费资源。这个机制是面试中区分“背过”和“真懂”的好问题,我建议顺着这个思路多问自己几个为什么。
2.3 用一张对照表理清线程私有与共享区域
复习的时候我把各个区域的关键属性整理成了一张表,对比记忆效率比纯读文章高很多。这里直接分享出来:
| 区域 | 线程归属 | 存储内容 | 异常类型 | 关键点 |
|---|---|---|---|---|
| 程序计数器 | 线程私有 | 字节码行号指示器 | 无异常 | 唯一无OOM区域 |
| 虚拟机栈 | 线程私有 | 局部变量表、操作数栈、动态链接、方法出口 | StackOverflowError / OOM | 每线程一个栈 |
| 本地方法栈 | 线程私有 | native方法调用信息 | StackOverflowError / OOM | 与虚拟机栈类似 |
| 堆 | 线程共享 | 对象实例、数组 | OOM | GC的主要工作区域 |
| 方法区/元空间 | 线程共享 | 类型信息、常量、静态变量、JIT代码缓存 | OOM | 元空间用本地内存 |
| 运行时常量池 | 线程共享 | 编译期生成的字面量与符号引用 | OOM | 属于方法区的一部分 |
有个记忆技巧:凡是“线程私有”的区域,都是为了支撑线程执行状态而存在的,生命周期和线程保持一致;凡是“线程共享”的区域,都是存放全局数据或者跨线程可见的对象。把这条逻辑印在脑子里,哪怕面试时紧张忘了定义,也能现场推出来。
3. 垃圾回收机制与收集器选型
3.1 怎么判断一个对象该不该回收
垃圾回收的前提是判断哪些对象还活着,哪些已经死了。JVM的判定依据不是引用计数(那套方案解决不了循环引用问题),而是可达性分析:从一组称为GC Roots的根节点出发,通过引用链向下遍历,任何无法从GC Roots到达的对象都会被判定为可回收。GC Roots包括虚拟机栈中引用的对象、静态属性引用的对象、常量引用的对象、本地方法栈JNI引用的对象等等。
这里有一个非常容易忽略的概念:不可达不等于立即回收。对象进入“不可达”状态后会先被标记,然后进入第一次筛选,只有覆盖了finalize方法且没有执行过,才有可能被放到低调度优先的队列中等待回收线程触发。也就是说,一个对象在真正被GC前,还保留了一次“自救”的机会。不过在实战中我强烈建议不要依赖finalize做资源清理,因为你根本控制不了它什么时候执行,说不定进程退出都不一定轮得到它。Java 9之后官方也已经把finalize标记为废弃功能了。
判断完生存状态,就要看垃圾收集器的具体策略了。堆被划分为新生代和老年代,新生代又分成一个Eden区和两个Survivor区,默认比例是8:1:1。大部分对象在Eden区分配,发生Minor GC时,存活对象会被复制到Survivor区并年龄加一,如果年龄超过阈值(默认15)或者Survivor区装不下,就会晋升到老年代。这个分代设计的理论基础是“弱分代假说”:绝大多数对象朝生夕灭,只有少量对象能活过多次GC。
3.2 垃圾收集器的适用场景对比
收集器是JVM面试题中信息量最大的一部分,尤其喜欢考CMS和G1的对比。我把主流收集器整理了一张表,方便对照记忆:
| 收集器 | 线程模型 | 收集范围 | 算法 | 目标 | 适用场景 |
|---|---|---|---|---|---|
| Serial | 单线程 | 新生代 | 复制 | 停顿时间最短 | 客户端、单核小堆 |
| Serial Old | 单线程 | 老年代 | 标记整理 | 稳定 | CMS的后备方案 |
| ParNew | 多线程 | 新生代 | 复制 | 并行回收 | 配合CMS使用 |
| Parallel Scavenge | 多线程 | 新生代 | 复制 | 吞吐量优先 | 后台计算任务 |
| Parallel Old | 多线程 | 老年代 | 标记整理 | 吞吐量优先 | 与Parallel Scavenge搭配 |
| CMS | 多线程 | 老年代 | 标记清除 | 最短停顿时间 | 互联网业务低延迟 |
| G1 | 多线程 | 整堆 | 标记整理+复制 | 可预测停顿 | 大堆、替代CMS |
CMS之所以不能和Parallel参数并列,是因为它的工作流程很特殊:初始标记、并发标记、重新标记、并发清除四个阶段,其中两个阶段要停顿用户线程,两个阶段与用户线程并发执行。它的优点是把最耗时的并发标记和并发清除阶段与应用线程并行,缩短停顿时间;缺点是使用标记清除算法会产生内存碎片,而且并发阶段会占用CPU资源导致吞吐量下降。更麻烦的是,CMS默认在老年代使用空间占比达到92%时会触发Full GC,高并发场景下很容易出现“并发模式失败”,退化为Serial Old单线程收集,停顿瞬间拉满。
3.3 实战调参示例:堆内存与GC日志
收集器的选型最终要落到参数配置上。我当天搭建了一个模拟服务来做实验,把堆大小故意调小,模拟对象频繁分配的场景,然后用GC日志观察回收行为。启动命令大致长这样:
java -Xms128m -Xmx128m \ -XX:+UseG1GC \ -XX:+PrintGCDetails \ -XX:+PrintGCDateStamps \ -XX:+HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath=/tmp/heapdump \ -Xloggc:/tmp/gc.log \ -jar demo-service.jar这里面-Xms和-Xmx建议直接设为相同值。很多刚接触调优的同学不理解为什么,原因其实很简单:如果最小值小于最大值,JVM会动态扩展和收缩堆大小,这个扩容过程本身是同步且耗时的,还可能引发额外的Full GC。生产环境为了避免这种无谓的抖动,直接把堆固定在预期大小,让JVM把精力放到GC本身而不是堆管理上。
跑了一轮之后,从GC日志里能清楚看到Young GC的节奏,每次回收大概几十毫秒,但吞吐量还行。把堆降到64MB再跑,Young GC频率明显上升,GC总耗时也在涨。这就是堆大小和GC频率的经典权衡:堆太小回收频率高,堆太大单次Full GC时间不可控,所以调优的核心目标是在两者之间找一个平衡点,而不是一味调大内存。GC日志中还能看到G1的Region划分情况和Mixed GC的行为,理解了这些,就不容易在线上环境对“为什么经常Full GC”一头雾水了。
4. 类加载机制与双亲委派
4.1 从.class字节码到类对象的完整链路
类加载机制也是JVM面试的重头戏。类的完整生命周期包括加载、验证、准备、解析、初始化、使用、卸载七个阶段,其中加载、验证、准备、初始化的顺序是确定的,但解析阶段允许在初始化之后再开始,这是为了支持Java的运行时绑定特性。
加载阶段要做的事情值得细说:通过类的全限定名获取二进制字节流,把它转换成方法区中的运行时数据结构,然后在堆中生成一个Class对象作为访问入口。用的都是.class文件的字节流。验证阶段检查字节码安全性和格式正确性,准备阶段为静态变量分配内存并设置零值,比如static int value在准备阶段是0,而不是你写入的初始值,真正的赋值发生在初始化阶段。还有一点经常被问到:解析阶段把符号引用替换为直接引用,典型例子就是方法调用和字段访问在常量池里的常量从符号变成内存地址。
初始化阶段做的是执行类构造器。注意这里说的是类级别的构造器,也就是静态变量赋值和静态代码块的合并体。触发时机有严格规定:遇到new、getstatic、putstatic、invokestatic字节码指令,使用反射调用时,初始化子类前先初始化父类,还有JVM启动时初始化主类。但通过数组定义引用类、通过子类引用父类静态字段等场景都不会触发初始化。我以前备考时一直忽略这类细节,后来发现面试官特别爱在边界条件上挖坑,比如“通过子类访问父类的静态字段,子类会不会初始化”——答案是不会。
4.2 双亲委派模型为什么这么重要
双亲委派模型的经典描述是:当一个类加载器收到类加载请求时,它不会自己先尝试加载,而是把请求委派给父加载器,每一层都这样往上抛,直到启动类加载器;只有父加载器反馈无法完成加载时,子加载器才会自己尝试。搭配的定义是:启动类加载器加载JDK核心类,扩展类加载器加载扩展目录下的类,应用类加载器加载classpath下的类。
这个机制的核心价值在于保证类加载的唯一性,避免核心类被篡改。最直观的例子是java.lang.String:如果没有双亲委派,开发者在classpath里放一个自定义的String类,各加载器各加载各的,程序里就会同时存在两个String类,类的身份认证、类型判断全部乱套,甚至可能威胁到JDK基础库的安全。面试中问到“双亲委派的好处”,从这个角度答“防止核心API被自定义类覆盖”,比答“避免重复加载”更能让面试官眼前一亮。
顺带说一句,双亲委派的代码实现并不复杂,核心逻辑在ClassLoader.loadClass方法里。先检查类是否已经加载,没有就调父加载器的loadClass,父加载器为空则使用启动类加载器,最后父加载器失败才自己findClass。但这个逻辑本身是用synchronized修饰的,在高并发类加载场景下会存在竞争,所以现代JDK加了并行的类加载机制,这就是另一个话题了,但理解基础模型后,新机制也容易上手。
4.3 打破双亲委派的场景与实现要点
双亲委派不是银弹,有些场景必须打破它。最常见的两个例子是Tomcat的Web应用类加载器和JDBC驱动加载。Tomcat需要为每个Web应用维护独立的类库,如果完全遵守双亲委派,不同应用对同一个第三方库的版本冲突就无法隔离,所以Tomcat的WebAppClassLoader优先加载自己Web应用下的类,而不是直接向上抛。JDBC的情况则是:JDK的DriverManager在启动类加载器加载,但MySQL驱动这类第三方实现却在应用类加载器范围,如果不打破双亲委派,DriverManager根本找不到驱动实现,这就是线程上下文类加载器的由来。
理解打破的原理并不复杂,无非是重写loadClass逻辑,把“向上委派”改成“向下查找”或者“先自己加载”。但在实际面试和工作中,更常考的是“结合场景分析要不要打破”以及“打破之后有什么风险”。风险其实很明显:一旦不遵循双亲委派,类的唯一性保障就被削弱了,容易出现同一个类被不同类加载器加载后类型不兼容的诡异问题。所以我的建议是:除非有明确的版本隔离需求,否则不要轻易动加载逻辑,能够在双亲委派框架内解决的问题,尽量留在框架内。
5. 调优与故障排查实录
5.1 一次OOM排查的完整思路
理论知识梳理完后,我当天专门做了一次线上OOM问题的模拟排查。场景是一位同事写了一段加载数据的代码,批量处理时直接把整个文件读进内存,运行一段时间后进程报OutOfMemoryError后挂掉。很多人拿到这种问题第一反应是“把堆调大”,但真正规范的排查路径应该是这样的:
第一步,保留现场。启动参数中加上-XX:+HeapDumpOnOutOfMemoryError和-XX:HeapDumpPath,让JVM在抛出OOM时自动生成堆转储快照。如果进程还没挂,用jmap命令手动导一份堆快照出来。第二步,用jstat观察GC情况,看看是哪个区域在持续增长,是新生代频繁GC还是老年代不断增长直到触发Full GC。第三步,分析转储文件,找到一个明显的重复对象聚集区,比如某个HashMap或ArrayList里面装了大量同类型对象。第四步,回到业务代码确认这些对象是否真的需要全部常驻内存,能不能改成分批处理或流式读取。
在我模拟的案例里,最终定位到的问题就是一次性加载了全部记录到List中,单批数据量在高峰期超过了堆容量。修复方案也简单,把读取逻辑改成逐条流式处理,内存占用立刻下降了百分之八十以上。这个过程说明一个道理:绝大多数OOM不是内存不够,是代码不合理的持有对象,调参只是临时止痛,改代码才是根治。
5.2 几条值得背下来的JVM参数
JVM参数很多,但面试和日常开发经常用的就那么几类。我整理了一份高频参数清单,都是实际调优中反复会用到的:
| 参数 | 作用 | 使用场景 |
|---|---|---|
| -Xms / -Xmx | 设置堆初始大小和最大值 | 建议设为相同值避免堆动态伸缩 |
| -Xmn | 设置新生代大小 | 堆大小固定时调整分代比例 |
| -XX:SurvivorRatio | Eden区与Survivor区比例 | 默认8,可调优减少对象晋升 |
| -XX:MaxMetaspaceSize | 元空间上限 | 防止无限制使用本地内存 |
| -XX:+UseG1GC | 使用G1收集器 | 多核大内存JDK8+首选 |
| -XX:MaxGCPauseMillis | G1目标停顿时间 | 期望GC停顿不超过多少毫秒 |
| -XX:HeapDumpOnOutOfMemoryError | OOM时自动导出堆快照 | 故障排查必备 |
| -XX:+PrintGCDetails | 输出详细GC日志 | 分析GC频率和耗时 |
| -XX:ThreadStackSize | 设置线程栈大小 | 大量线程场景下降低内存占用 |
需要特别提醒的是,MaxGCPauseMillis并不是设得越小越好。数字设得过低可能导致G1频繁调整回收策略,反而增加GC开销,甚至引发Full GC。这个参数在G1中的角色更像一个软性的目标参考值,而不是硬性指标。如果一味的调小数字发现超过目标就报错或者吞吐量明显下降,那就说明当前配置偏离实际需求了,应该回退到一个合理区间。
5.3 排查工具清单
工具这块,我觉得命令行工具箱是JVM调优必备的基础能力。jps定位Java进程PID,jstat观察内存和GC统计,jmap导出堆快照,jstack打印线程栈,jcmd是JDK8开始集成的多功能工具,HSDB则用来做底层调试。图形化工具里VisualVM和JConsole适合本地开发环境,阿里巴巴开源的Arthas则适合生产环境线上排查,不用重启进程就能观测类加载、方法调用、内存使用等数据,我平时用得最多。
我当天实测了一个场景:启动一个服务后,用jstat -gcutil观察老年代占比一直在95%以上,GC日志里Full GC频繁触发。看一眼堆快照发现大量缓存在java.util.HashMap中,而这个缓存又没有设置过期清理机制。定位到原因后,把缓存方案改成带容量上限的LRU结构,老年代占用立刻降下来了。整套工具链配合起来,排查效率可以提升很多。有一点经验供参考:jmap在较新版本JDK里执行建议配合-F参数,避免在进程内存较大时hang住,不过还是要以自己环境的实际表现来定。
6. 当天的真题自测与踩坑记录
6.1 面试官视角的六个高频问题
晚上自测时我从面试官角度给自己出了几个问题,都是JVM面试中最常抽到的类型,这里分享出来供大家自检。你可以先遮住答案,看看自己能不能在不看任何资料的情况下,用两分钟说清楚:
- 一个Java对象在内存中是怎么创建的?尽可能往底层说。
- 有哪些GC Roots?为什么虚拟机栈里的局部变量可以作为GC Roots?
- 什么样的对象会进入老年代?动态年龄判定是什么?
- CMS和G1的停顿模型有什么本质区别?
- 双亲委派是什么样的机制?为什么需要它?哪些场景要打破?
- 线上应用突然频繁Full GC,你用哪些手段排查?
这几个问题看上去基础,但每道都可以追问三层以上。比如第三题追问“Survivor区放不下会怎么办”,其实答案是对象会被直接晋升到老年代,这是空间分配担保的一种体现。如果你只背了“晋升阈值15”,在这里就会卡住。我自己的经验是,把每个基础问题当成一棵树的根节点,逐步往下想分支,复习的深度就出来了。
6.2 记录下来的三个高频坑
当天整理错题时,我专门把三个最容易混淆的考点记进笔记,这里直接列出来防止你再踩:
第一个坑是“老年代是否只有Full GC才回收”。其实不对,CMS收集器专门负责老年代的并发回收,不是等Full GC才动手。G1虽然有Mixed GC,但也会对老年代区域做混合回收。老年代对象确实比新生代长寿,但不代表它们集合到一定程度前不会被处理。
第二个坑是“System.gc()一定会引起Full GC”。调用System.gc()只是向JVM发出垃圾回收请求,但它是否真正执行取决于-XX:+DisableExplicitGC参数和JVM自身的调度决策。生产环境一般建议配置禁用显式GC,防止代码里莫名其妙触发全局停顿。
第三个坑是“新生代回收只有复制算法”。这是把概念窄化了。复制算法是新生代默认收集器的执行策略,但Parallel Scavenge也可以用其他形式的回收思路,不能简单划等号。理解时分清“算法层”和“收集器层”两个维度,就不容易混合了。
6.3 复习记忆的一点心得
到了Day31,我越来越觉得背八股本身没有问题,但必须用“给别人讲一遍”的方式去背,而不是默读。我的方法是每学完一个专题,晚上对着电脑的录音功能,用口语把当天所有概念从头到尾讲一遍,讲到卡壳的地方就是知识漏洞,第二天重点补。这个方法对JVM这种逻辑链条长的知识特别有效,因为JVM的知识点不是一个个孤岛,而是连接成网的结构,你讲的时候会不自觉地在内存、GC、类加载之间反复跳跃,一旦哪条链路断了,马上就能发现。
另外还有一个值得尝试的思路:不看任何笔记,用一张白纸画出JVM知识地图。内存分区画左边,对象创建流程画中间,GC流程画右边,类加载画下边,然后把它们连接起来。这张图如果能独立画出来,说明知识已经成体系了;如果哪个角落是空的,那正好是接下来要补的地方。我画完之后明显感觉到,原来分散在三十天里学的内容,在JVM这条主线上全都汇合了,这也是我为什么建议大家备考时专门抽出一个完整的时间段来啃JVM这块硬骨头的原因。
JVM的内容真的很多,一天肯定没法全部覆盖,但Day31这一天的价值在于把基础框架彻底立住了。后面无论遇到什么深入的问题,比如C2编译器的优化策略、完整的GC日志事件代码含义、ZGC的具体读屏障实现,都可以在这个框架上继续往上加砖。如果你也正处在Java学习的过程中,希望这一天的学习记录能给你一些启发,哪怕只是让你发现自己有哪些“以为会了其实没有”的知识点,这篇记录就没白写。