Java JVM “垃圾回收(GC)”面试清单(含超通俗生活案例与深度理解)
一、如何判断对象仍然存活?
判断对象存活有两种核心算法:引用计数算法和可达性分析算法,主流JVM(如HotSpot)因引用计数算法存在缺陷,最终采用可达性分析算法。
• 引用计数算法:给每个对象贴一张“借阅记录卡”,只要有代码用到这个对象(比如调用对象方法),就在卡片上记一次借阅(计数+1);当代码不再使用(引用失效,比如变量赋值为null),就划掉一次借阅(计数-1)。一旦卡片上的借阅次数清零,就说明这个对象没人用了,可以回收。比如图书馆的闲置书籍,每有读者借阅就登记一次,读者归还就注销一次,登记次数为零的书籍,会被整理到下架区。但这种算法有个致命问题——“循环引用”:比如两本冷门书,系统误登记为“互相借阅”,虽然没有真实读者需要它们,但借阅次数永远不为零,无法下架,所以主流JVM都不用它。
• 可达性分析算法:把“GC Roots”看作“家庭核心成员”,从核心成员出发,能直接或间接找到的“家庭成员”(对象)就是“活跃的”,找不到的就是“失联的”,可以回收。比如家族聚餐时,以长辈(GC Roots)为起点,能叫出名字、有直接亲缘关系的家人(直接引用对象),以及长辈认识的家人再认识的朋友(间接引用对象),都是需要邀请的;而长辈完全不认识、也没人介绍的人(无引用对象),就不会出现在邀请名单里(可回收)。这种算法能完美解决循环引用问题,是目前的主流方案。
二、Java中可作为GC Roots的对象有哪几种?
GC Roots是“永远不会被回收的核心对象”,就像家庭里“必须保留的关键成员”,主要有四类,每类都对应日常场景中的核心角色:
1. 虚拟机栈(栈帧本地变量表)引用的对象:比如你正在电脑上编辑的Word文档(当前线程),文档里的文字、图片(本地变量表引用的对象),只要你没关闭文档(线程没结束),这些内容就不会被系统自动删除——因为它是当前正在使用的核心数据。
2. 方法区中类静态属性引用的对象:比如手机里的“系统时间”(静态属性System.currentTimeMillis()关联的对象),只要手机系统没关机(类没被卸载),这个时间对象就一直存在,不会被回收——静态属性属于类,类存活它就存活。
3. 方法区中常量引用的对象:比如你手机通讯录里“紧急联系人”的号码(字符串常量),只要你没手动删除这个联系人(常量没被移除),这个号码对象就一直保存在手机里,不会被清理——常量一旦定义,除非主动移除,否则一直有效。
4. 本地方法栈中JNI引用的对象:比如用手机连接蓝牙音箱播放音乐(Java调用C++写的蓝牙本地方法),音箱正在播放的歌曲文件(JNI引用的Java对象),在播放结束前不会被手机清理——因为本地方法还在使用它,必须作为GC Roots保留。
三、说一下对象有哪几种引用?
Java中的引用不是“非有即无”,而是分了四种强度,从强到弱依次为强引用、软引用、弱引用、虚引用,就像“物品的重要程度分级”,不同级别对应不同的保留策略:
• 强引用:最普通的引用,比如你手机里的微信APP——只要你没卸载它(强引用存在),就算手机内存再紧张,系统也绝不会主动删掉微信,因为它是“必须保留的核心应用”。日常代码中Object obj = new Object()就是强引用,只要obj没被赋值为null,对象就绝对不会被GC回收。
• 软引用:描述“有用但可舍弃”的对象,比如手机里的“相册缩略图缓存”——平时打开相册时,缩略图能让你快速预览照片(有用),但当手机内存快满时,系统会先删掉这些缩略图缓存(回收软引用对象),给新安装的APP腾空间。这种引用适合做“内存敏感的缓存”,比如图片缓存、临时数据缓存,JDK通过SoftReference类实现。
• 弱引用:比软引用更“弱”,只要GC开始工作,不管内存够不够,都会回收它引用的对象,就像手机通知栏里的“外卖取餐提醒”——你没手动划掉它时,它会一直显示(弱引用存在),但只要你清理手机后台(触发GC),这些临时提醒就会被清掉,不会占用通知栏空间。JDK通过WeakReference类实现,适合存“临时且不重要的数据”。
• 虚引用:完全不影响对象的存活,也没法通过虚引用拿到对象本身,唯一作用是“对象被回收时收到通知”,就像APP卸载后的“残留文件清理提示”——你没法通过这个提示恢复已卸载的APP(不能获取对象),但提示能告诉你“APP已经删完,残留文件可以清理了”(收到回收通知)。JDK通过PhantomReference类实现,主要用于监测对象回收状态,实际开发中极少用到。
四、finalize()方法了解吗?有什么作用?
finalize()是Object类的一个protected方法,相当于对象被GC回收前的“最后一次申诉机会”,但实际开发中几乎没人用,因为它的执行机制非常不可靠。
它的作用可以类比“扔旧钱包前检查夹层”:当GC发现一个对象没人引用(可达性分析后无引用链),会先判断这个对象是否重写过finalize()且没执行过——如果是,就会把对象放进“待执行队列”,稍后由JVM的低优先级线程执行finalize()方法。在这个方法里,对象可以“自救”:比如把自己的引用赋值给一个静态变量(static Object save = this),就像在旧钱包夹层里找到身份证(重要数据),于是你决定不扔钱包了(对象存活);但如果finalize()里没做自救操作,或者已经执行过一次(finalize()只会执行一次),对象就会被GC正式回收——就像夹层里没找到重要东西,钱包直接被扔进垃圾桶。
需要特别注意:finalize()的执行时机完全不确定(低优先级线程可能一直没机会执行),而且可能导致对象“复活”后再次被回收时无法自救,所以绝对不能用它做资源释放(比如关闭文件、断开数据库连接),这些操作应该用try-with-resources或finally块来完成。
五、Java堆的内存分区了解吗?
Java堆是JVM中最大的内存区域,专门存对象实例,为了提高GC效率,它按“对象存活时间”分成两大区域:新生代(Young Generation) 和老年代(Old Generation),就像家里的“玄关鞋柜”和“阳台旧鞋柜”,不同区域放不同使用频率的鞋子,整理起来更高效。
• 新生代:存放“存活时间短”的对象,比如方法里的临时变量、刚创建的对象,占堆内存的1/3左右。它又细分为三个小区域:Eden区(占80%)、From Survivor区(占10%)、To Survivor区(占10%),类比家里的“玄关鞋柜”——Eden区是鞋柜的大格子,刚买的新鞋(新对象)优先放这里;From/To是鞋柜的两个小格子,放常穿、没被闲置的鞋(GC后存活的对象)。
• 老年代:存放“存活时间长”的对象,比如单例对象、缓存对象、多次GC后还存活的对象,占堆内存的2/3左右,类比家里的“阳台旧鞋柜”——玄关鞋柜里的鞋穿了很久(多次GC存活),或者鞋太大放不进玄关鞋柜(大对象),就会移到这里。老年代的GC频率远低于新生代,因为里面的对象“不容易过时”。
这种分代设计的核心逻辑是“分而治之”:新生代对象“朝生夕死”(大部分创建后很快被回收),用高效的“标记-复制算法”清理;老年代对象“长寿稳定”(大部分会长期存活),用“标记-整理算法”清理,既能保证GC效率,又能减少内存碎片。
六、垃圾收集算法了解吗?
垃圾收集算法是GC的“核心清理逻辑”,不同算法对应不同的内存区域需求,核心有三种,每种都有明确的适用场景和优缺点:
(1)标记-清除算法(Mark-Sweep)
流程分两步:先“标记”所有要回收的对象,再“清除”这些对象。类比整理书桌:第一步,在所有没用的废纸、空笔芯、旧便利贴上贴“丢弃”标签(标记);第二步,把贴了标签的东西全扔进垃圾桶(清除)。
• 优点:实现简单,不用移动对象,适合对象存活率高的场景(比如老年代早期)。
• 缺点:① 效率不稳定——如果书桌上有很多东西要丢(大量可回收对象),贴标签和扔垃圾都要花很久;② 产生内存碎片——清除后书桌会留下很多零散的空位(比如废纸占的小空间、笔芯空出的缝隙),下次想放一本大词典(大对象)时,找不到连续的空位,只能再整理一次(提前触发GC)。
(2)标记-复制算法(Mark-Copy)
为解决标记-清除的效率问题而生,流程是:把内存分成大小相等的两块(A和B),每次只用其中一块(比如A);当A满了,就“标记”A里的存活对象,复制到B块,然后把A块的内容全清空。类比家里的两个抽屉:平时只用左边抽屉(A)放文具,左边满了,就把要留的钢笔、笔记本(存活对象)移到右边抽屉(B),再把左边抽屉里的空笔芯、废纸全清空,下次就用右边抽屉,循环往复。
• 优点:效率高——只复制存活对象,如果存活对象少(比如新生代),复制速度很快;而且清除后内存没有碎片,因为新对象都存在连续的空间里。
• 缺点:浪费内存——始终有一块内存是空的(比如右边抽屉),相当于只用了一半内存。不过新生代的存活对象通常只有5%左右,浪费的内存成本远低于效率收益,所以新生代默认用这种算法。
(3)标记-整理算法(Mark-Compact)
为解决标记-清除的内存碎片问题和标记-复制的内存浪费问题而生,流程是:先“标记”存活对象,再把所有存活对象“整理”到内存的一端,最后清空另一端的内存。类比整理衣柜:第一步,在要留的衣服、裤子、围巾上贴“保留”标签(标记);第二步,把所有贴了标签的衣物移到衣柜左边,按“上衣-裤子-配饰”的顺序排好(整理);第三步,把衣柜右边空出来的空间全清空(清除)。
• 优点:无内存碎片,内存利用率100%——整理后所有存活对象都在连续空间里,大对象也能顺利分配;适合对象存活率高的场景(比如老年代)。
• 缺点:移动对象耗时——老年代的存活对象多,移动时不仅要复制数据,还要更新所有引用这些对象的指针(比如衣服移到左边后,要告诉家人“衣服现在在左边第几格”),而且移动过程中必须暂停用户线程(STW),会导致短暂卡顿。不过老年代GC频率低,偶尔的卡顿在可接受范围内。
七、说一下新生代的区域划分?
新生代是Java堆中“对象更新最快”的区域,完全基于“标记-复制算法”设计,为了减少内存浪费,它没有分成大小相等的两块,而是分成1个Eden区和2个Survivor区(From Survivor、To Survivor),默认比例是8:1:1,就像孩子的“积木收纳箱”:Eden区是收纳箱的大格子(占80%),刚买的新积木(新对象)优先放这里;From和To是两个小盒子(各占10%),放孩子常玩、没被丢弃的积木(GC后存活的对象)。
新生代的具体工作流程非常清晰,分三步:
1. 对象分配:新创建的对象几乎都放在Eden区,比如new Toy()会先存在Eden区;两个Survivor区初始时只有一个在用(比如From),另一个(To)是空的。
2. Minor GC触发:当Eden区满了,就触发Minor GC(新生代GC)。GC时,JVM会标记Eden区和From区里的存活对象(比如孩子没丢的积木),然后把这些存活对象复制到To区,同时给每个对象的“GC年龄”加1(记录被GC过几次)。
3. Survivor角色互换:复制完成后,Eden区和From区会被清空,然后From和To的角色互换——原来的To变成新的From(下次GC时用),原来的From变成新的To(下次GC时空着)。
这个流程会循环进行,直到某个对象的GC年龄达到默认阈值(15,可通过-XX:MaxTenuringThreshold配置),就会被移到老年代——就像孩子的积木玩了15次还没丢,说明是“长期喜欢的玩具”,要从收纳箱移到衣柜里(老年代)长期存放。
这种设计的核心优势是“用少量Survivor空间换高效GC”:虽然始终有一个Survivor区是空的,但只浪费10%的新生代内存,却能让标记-复制算法的效率最大化,避免了标记-清除的碎片问题和标记-复制的50%内存浪费问题。
八、Minor GC/Young GC、Major GC/Old GC、Mixed GC、Full GC 都是什么意思?
这些是GC按“收集范围”的分类,核心区别是“是否回收整个Java堆”,就像家里“整理储物的范围”不同,耗时和影响也不同:
• Minor GC/Young GC:只回收新生代的GC,就像“只整理孩子的积木收纳箱”——范围小、存活对象少,触发频率高(可能几秒一次)、耗时短(通常几十毫秒),用户几乎感觉不到卡顿。比如孩子的积木收纳箱满了,只整理收纳箱,不影响衣柜和阳台的储物。
• Major GC/Old GC:只回收老年代的GC,就像“只整理阳台旧鞋柜”——范围比新生代大,存活对象多,触发频率低(可能几分钟一次)、耗时长(通常几百毫秒),会导致轻微卡顿。目前只有CMS收集器支持单独回收老年代,其他收集器要回收老年代,必须连带新生代一起回收。
• Mixed GC:回收新生代+部分老年代的GC,就像“整理积木收纳箱的一半格子+阳台旧鞋柜的一半格子”——范围介于Minor和Full之间,是G1收集器的特色功能。G1会优先回收“垃圾多的区域”(比如收纳箱里快满的格子、旧鞋柜里没用的旧鞋),既能控制停顿时间(只整理部分区域),又能保证回收效果,适合对卡顿敏感的应用。
• Full GC:回收整个Java堆(新生代+老年代)+方法区的GC,就像“整理家里所有储物空间:积木收纳箱、衣柜、阳台、抽屉”——范围最大、存活对象最多,触发频率极低(可能几小时一次)、耗时最长(通常几秒甚至十几秒),会导致明显卡顿,严重影响用户体验。比如家里所有储物都满了,必须全整理一遍,期间家人没法用任何储物空间。
实际开发中,我们要尽量避免Full GC,通过监控和调优,让GC以Minor GC和Mixed GC为主。
九、Minor GC/Young GC 什么时候触发?
Minor GC的触发条件非常明确:新生代的Eden区没有足够空间分配新对象,就像“孩子的积木收纳箱大格子(Eden)放满了新积木,再买新积木没地方放,必须整理收纳箱”。
举个具体例子:当你在购物APP里点击“加载更多商品”,APP会创建大量Goods对象来存商品信息,这些对象会优先放在Eden区。如果Eden区已经满了,无法再创建新的Goods对象,JVM就会触发Minor GC——标记Eden区和当前使用的Survivor区(From)里的存活对象(比如还在显示的商品对象),复制到另一个Survivor区(To),然后清空Eden和From区,腾出空间给新的Goods对象。
需要补充一个重要机制:空间分配担保。Minor GC前,JVM会先检查老年代的可用空间是否大于新生代历次Minor GC后升入老年代的平均对象大小——如果大于,说明这次Minor GC后即使有对象要进老年代,老年代也能装下,就正常触发Minor GC;如果小于,JVM会先触发一次Full GC,清理老年代空间,再执行Minor GC,避免Minor GC后老年代满了导致更严重的卡顿。比如整理积木收纳箱前,发现阳台旧鞋柜的空位不够放之前每次移过去的积木平均量,就先整理阳台(Full GC),再整理收纳箱(Minor GC)。
十、什么时候会触发 Full GC?
Full GC是“最重量级”的GC,触发条件比Minor GC复杂,核心是“关键区域内存不足”或“主动触发”,常见场景有六种,每种都对应日常储物的典型问题:
1. Minor GC前老年代空间不足:Minor GC前,JVM计算发现“老年代可用空间 < 新生代历次Minor GC后升入老年代的平均对象大小”,担心这次Minor GC后有太多对象要进老年代,老年代装不下,就会先触发Full GC。比如整理积木收纳箱前,算过之前每次整理后都会移5块积木到阳台旧鞋柜,现在阳台旧鞋柜只剩3个空位,不够装5块,就先把阳台、收纳箱、衣柜全整理一遍(Full GC),再整理收纳箱。
2. Minor GC后老年代空间不足:Minor GC后,有大量存活对象要进老年代(比如对象年龄到阈值、Survivor区放不下),但老年代没空间,必须立即触发Full GC。比如整理积木收纳箱后,有6块积木要移到阳台旧鞋柜,而阳台旧鞋柜只剩3个空位,只能马上全整理(Full GC),腾出空间放这些积木。
3. 老年代内存使用率过高:老年代的内存占比达到配置的阈值(默认通常是90%,可通过-XX:OldRatio调整),JVM会触发Full GC来释放空间。比如阳台旧鞋柜有10格,已经用了9格,快满了,必须全整理一遍,不然下次再放积木就没地方了。
4. 空间分配担保失败:Minor GC时,Survivor区放不下存活对象,或者对象年龄到阈值要进老年代,但老年代也没空间,就会触发Full GC。比如整理积木收纳箱时,要留的积木太多,两个小盒子(Survivor)都放不下,阳台旧鞋柜也没空位,只能全整理(Full GC)。
5. 方法区(永久代/元空间)空间不足:JDK7及以前,方法区是永久代,存类信息、常量、静态变量,如果永久代满了,会触发Full GC;JDK8及以后,永久代被元空间取代,元空间默认用本地内存,但如果配置了元空间大小上限,上限满了也会触发Full GC。比如手机里装的APP太多,系统存储的APP图标、名称等元数据满了,就会清理所有APP缓存甚至卸载旧APP(类似Full GC)。
6. 主动触发:调用System.gc()方法(JVM不一定立即执行,但大概率会触发),或者用jmap -dump(导出堆快照)、jconsole(监控工具)等命令,会强制触发Full GC。比如你手动点击手机的“清理所有缓存”按钮,就是主动触发Full GC,清理所有储物空间的无用数据。
十一、对象什么时候会进入老年代?
对象从新生代进入老年代,不是“随机的”,而是满足特定条件,核心是“存活时间长”或“体积大”,常见场景有四种,每种都像“物品从当季储物区移到过季储物区”的规则:
1. 长期存活(GC年龄达标):每个对象的对象头里都存着“GC年龄”,每次Minor GC后,对象如果存活并被复制到Survivor区,年龄就加1。当年龄达到默认阈值(15,可通过-XX:MaxTenuringThreshold配置),对象就会被移到老年代。比如孩子的积木,每次整理收纳箱(Minor GC)后都没丢,玩了15次(年龄15),说明是“长期喜欢的玩具”,就从收纳箱移到衣柜(老年代)。
2. 大对象直接进入老年代:如果对象的大小超过配置的阈值(-XX:PretenureSizeThreshold,默认0,即不启用,需手动配置),就会跳过新生代,直接进入老年代。比如家里买的大行李箱,体积太大,放不进收纳箱的小格子(新生代),只能直接放进阳台的大柜子(老年代)。这种规则的目的是避免大对象在新生代反复复制(浪费时间),直接放在老年代更高效。
3. 动态年龄判定:JVM为了适应不同程序的内存状况,不会严格要求对象年龄必须到15才进老年代。如果Survivor区中,相同年龄的对象总大小超过Survivor区容量的一半,那么年龄≥该年龄的所有对象,都会直接进入老年代。比如收纳箱的小盒子(Survivor)容量是10块积木,现在年龄为3的积木已经有6块(超过一半),那么年龄3、4、5…的积木,都会直接移到阳台旧鞋柜(老年代),不用等年龄到15。
4. 空间分配担保:Minor GC时,Survivor区放不下存活对象,JVM会触发“空间分配担保”——检查老年代的可用空间是否大于新生代所有对象的总大小。如果大于,就把Survivor区放不下的对象直接移到老年代;如果小于,就先触发Full GC,再移对象。比如整理收纳箱时,要留的积木太多,两个小盒子(Survivor)都放不下,就把多余的积木直接移到阳台旧鞋柜(老年代),如果阳台也没空间,就先全整理(Full GC)。
十二、知道有哪些垃圾收集器吗?
HotSpot虚拟机(Oracle JDK、OpenJDK默认)提供了多种垃圾收集器,每种都有自己的适用场景,按“分代”和“核心特点”可分为七类,面试中重点关注CMS和G1:
• Serial收集器:最基础、最古老的收集器,单线程工作,GC时会暂停所有用户线程(STW),新生代用标记-复制算法,老年代用标记-整理算法。它就像“老人用的老年机”——只有一个核心线程,清理后台时不能用其他功能(STW),但优点是轻量、占用资源少,适合小内存(<100MB)、单线程环境(如嵌入式设备、简单工具类程序)。
• ParNew收集器:Serial的多线程版本,新生代用标记-复制算法,老年代配合Serial Old(标记-整理),GC时仍会STW,但用多个线程并行收集,效率比Serial高。它就像“年轻人的智能手机”——有多个核心线程,清理后台时能同时处理多个APP(多线程),适合多CPU环境,是CMS收集器的“专属新生代搭档”(CMS只能处理老年代,需要ParNew处理新生代)。
• Parallel Scavenge收集器:新生代多线程收集器,用标记-复制算法,核心追求“高吞吐量”(CPU用于运行用户代码的时间/总时间,比值越大越好),老年代配合Parallel Old(标记-整理)。它就像“跑数据报表的电脑”——不在乎偶尔卡顿(STW时间稍长),但要能快速处理大量数据(高吞吐量),适合后台计算、大数据处理、报表生成等场景,JDK8默认的收集器组合就是“Parallel Scavenge+Parallel Old”。
• Serial Old收集器:Serial的老年代版本,单线程标记-整理算法,主要作为“应急收集器”——当CMS收集器出现“Concurrent Mode Failure”(并发收集失败)时,会降级用Serial Old回收老年代,避免程序崩溃。它就像“备用的老年机”——平时不用,主力机(CMS)出问题时才用,缺点是效率低,尽量避免触发。
• Parallel Old收集器:Parallel Scavenge的老年代版本,多线程标记-整理算法,和Parallel Scavenge配合,实现“全代高吞吐量”。它就像“跑大数据的服务器”——新生代和老年代都用多线程收集,吞吐量最大化,适合对吞吐量要求极高、对停顿不敏感的场景(如离线数据处理)。
• CMS收集器(Concurrent Mark Sweep):老年代收集器,核心追求“低停顿”,用标记-清除算法,大部分阶段和用户线程并发执行(不用STW),新生代配合ParNew。它就像“刷短视频的手机”——刷视频时不能卡顿(低停顿),所以清理后台(GC)时要和刷视频(用户线程)同时进行,适合用户交互类应用(如APP、网站、电商平台),但缺点是内存碎片多、依赖CPU资源、有浮动垃圾。
• G1收集器(Garbage First):跨代收集器(能处理新生代和老年代),用标记-整理+复制算法,将堆分成多个大小相等的Region(1MB~32MB),优先回收“垃圾多的Region”(回收收益高),支持Mixed GC(回收新生代+部分老年代)。它就像“开多个办公软件的电脑”——要同时运行Word、Excel、浏览器(多线程),清理内存时不能影响办公(低停顿),所以只清理垃圾多的区域(部分回收),适合大内存(>8G)、对停顿敏感的场景(如微服务、大流量APP、金融交易系统),JDK9及以后默认的收集器。
十三、什么是 Stop The World?什么是 OopMap?什么是安全点?
这三个概念是GC的“基础保障机制”,核心是“确保GC时对象引用正确,不出现数据混乱”,就像“家庭大扫除的三个关键规则”:
• Stop The World(STW):GC执行过程中,暂停所有用户线程的过程,避免用户线程修改对象引用,导致GC标记错误。它就像“家庭大扫除时,让家人暂时别进打扫的房间”——如果家人一边打扫一边进房间放东西(用户线程修改引用),刚整理好的地方会变乱(GC标记错误),所以必须暂停家人的活动(STW)。STW是GC的“必要代价”,但优秀的收集器(如CMS、G1)会尽量缩短STW时间(CMS的STW只有初始标记和重新标记,共几十到几百毫秒;G1的STW更短,通常<100毫秒)。
• OopMap:HotSpot虚拟机中的“引用映射表”,记录“对象内哪个偏移量是引用”“栈帧/寄存器里哪个位置是引用”,GC时通过OopMap快速找到所有引用,不用遍历整个内存。它就像“大扫除的物品清单”——清单上写着“衣柜第二层左数第三个格子是要留的衣服(对象引用)”“抽屉里的笔记本记着购物清单(栈帧引用)”,打扫时按清单找,不用翻遍所有角落(遍历内存),大大提高GC效率。OopMap在类加载完成后生成,即时编译(JIT)时也会在特定位置更新。
• 安全点(Safepoint):用户线程执行时,只有到特定位置才能暂停(触发GC),这些位置就是安全点。它就像“开车时只有在加油站或红绿灯路口(安全点)才能停车加油(GC)”——不能在高速上突然停车(非安全点),因为高速上停车会导致事故(引用状态不稳定,GC标记错误)。常见的安全点有:循环末尾(非计数循环)、方法返回前、调用方法的call指令后、抛异常的位置——这些位置的对象引用状态稳定,GC时不会出错。用户线程执行时,会定期检查是否需要GC,如果需要,就会跑到最近的安全点暂停。
十四、能详细说一下 CMS 收集器的垃圾收集过程吗?
CMS是“低停顿”的代表,核心是“尽量和用户线程并发执行”,它只处理老年代,新生代由ParNew处理,整个过程分四步,其中两步STW(停顿短),两步并发(不影响用户线程),就像“整理阳台旧鞋柜,同时不影响家人用其他房间”:
1. 初始标记(CMS Initial Mark):单线程STW,标记GC Roots直接引用的老年代对象,比如“阳台旧鞋柜门口最显眼的旧鞋(GC Roots直接引用的对象)”。这个过程很快,因为只标记直接引用,不遍历引用链,通常几十毫秒就能完成,用户几乎感觉不到卡顿。
2. 并发标记(CMS Concurrent Mark):和用户线程并发执行,从初始标记的对象出发,遍历整个老年代的对象引用链,标记所有存活对象,比如“一边让家人继续用阳台(用户线程),一边慢慢找旧鞋柜里要留的旧鞋(标记存活对象)”。这个过程耗时最长,但因为和用户线程并发,不会导致卡顿,用户可以正常使用APP。
3. 重新标记(CMS Remark):多线程STW,修正并发标记期间用户线程修改的引用(比如新增的对象、删除的引用),比如“家人在并发标记时,往旧鞋柜里放了双新的旧鞋(用户线程新增对象),需要快速补标记这双鞋,避免漏标”。这个过程比初始标记稍长(通常几百毫秒),但仍在可接受范围内,是CMS的主要停顿点。
4. 并发清除(CMS Concurrent Sweep):和用户线程并发执行,清理未被标记的对象(可回收对象),比如“一边让家人用阳台,一边把没贴‘保留’标签的旧鞋扔掉(清除)”。这个过程不STW,不影响用户使用,清理完成后老年代就有了新的空间。
CMS的核心优势是“低停顿”,但缺点也很明显:用标记-清除算法会产生内存碎片,并发阶段占用CPU资源,还会产生“浮动垃圾”(并发清除时用户线程新增的垃圾,只能下次GC处理),这些问题最终导致CMS逐渐被G1取代。
十五、G1 垃圾收集器了解吗?
G1是GC技术的“里程碑式产物”,颠覆了传统的“新生代+老年代”固定分代布局,核心是“Region分区”和“优先回收高价值Region”,兼顾低停顿和高吞吐量,就像“智能整理家里的储物空间”——把储物区分成多个小房间(Region),优先整理垃圾多的房间(高价值),不用全整理,停顿更短。
(1)核心设计:Region分区
G1把Java堆分成多个大小相等的Region(默认1MB~32MB,可通过-XX:G1HeapRegionSize配置),每个Region都能动态扮演“Eden区”“Survivor区”“老年代区”或“大对象区(Humongous)”——就像把家里的储物区分成多个小房间,每个房间可按需当“玩具间”“衣服间”或“行李箱间”,灵活应对不同大小的对象。其中,大对象区专门存超过Region一半大小的对象,避免大对象在多个Region间复制。
(2)核心逻辑:优先回收高价值Region
G1会跟踪每个Region的“垃圾占比”(可回收对象的比例)和“回收耗时”,计算出每个Region的“回收价值”(垃圾多、回收快的Region价值高),然后维护一个优先级列表,每次GC都优先回收价值最高的Region——就像整理储物间时,先整理垃圾最多、整理最快的房间(比如玩具间,垃圾多且都是小物件,整理快),再整理价值低的房间,这样既能保证回收效果,又能控制停顿时间(只整理部分房间)。
(3)垃圾收集过程(四步)
1. 初始标记(Initial Mark):STW,标记GC Roots直接引用的对象,同时记录每个Region的垃圾占比,就像“快速标记每个小房间里显眼的要留物品,顺便看每个房间有多少垃圾”,耗时很短(几十毫秒)。
2. 并发标记(Concurrent Marking):和用户线程并发,从初始标记的对象出发,遍历整个堆的对象引用链,标记存活对象,更新每个Region的垃圾占比和回收价值——就像“一边让家人用储物间,一边慢慢找每个房间要留的物品,算清楚每个房间能回收多少空间”,耗时较长,但不影响用户使用。
3. 最终标记(Remark):STW,修正并发标记期间用户线程修改的引用(比如新增、删除的对象),同时处理“卡表”(记录跨Region引用的结构),确保标记准确——就像“家人刚往储物间放了新物品,快速补标记,再检查每个房间的物品是否都标记正确”,停顿比初始标记稍长,但通常<100毫秒。
4. 筛选回收(Evacuation):STW,根据优先级列表,选择一批高价值Region构成“回收集”,把回收集里的存活对象复制到空Region,然后清空原Region——就像“选几个垃圾最多的房间,把要留的物品移到空房间,再把这些垃圾房间清空”,只处理部分Region,停顿时间可控,还能避免内存碎片(复制后存活对象在连续空间)。
十六、有了 CMS,为什么还要引入 G1?
CMS虽然是“低停顿”的先驱,但存在三个致命缺点,这些缺点在大内存(>8G)环境下会被无限放大,而G1正是为解决这些问题而生,就像“智能手机取代功能机”,弥补了前者的不足:
1. 内存碎片严重:CMS用标记-清除算法,长期使用后老年代会产生大量内存碎片——就像阳台旧鞋柜长期整理,留下很多小空位,放不了大行李箱(大对象),只能触发Full GC(全整理),导致长时间卡顿。而G1用“标记-整理+复制”算法,筛选回收时会把存活对象复制到空Region,整理后所有存活对象都在连续空间,完全没有内存碎片,大对象也能顺利分配。
2. 依赖CPU资源,影响用户线程:CMS的并发标记和并发清除阶段,会占用CPU核心线程(默认占用1/4 CPU),抢占用户线程的资源——就像整理阳台时用了家里一半的人(CPU),家人没法正常用阳台(用户线程),导致APP响应变慢。而G1的并发阶段占用CPU更少,还能通过-XX:G1ConcRefinementThreads配置并发线程数,灵活控制对用户线程的影响。
3. 浮动垃圾无法处理,易触发Full GC:CMS的并发清除阶段,用户线程会产生新的垃圾(浮动垃圾),这些垃圾只能等下次GC处理——就像整理阳台时,家人又扔了新的旧鞋(浮动垃圾),这次没法清,只能下次整理。如果浮动垃圾太多,会导致老年代满,触发Full GC。而G1的筛选回收阶段只回收部分Region,能更好地控制浮动垃圾的产生,而且优先回收垃圾多的Region,即使有浮动垃圾,也能通过后续GC快速清理,很少触发Full GC。
此外,G1还支持“可预测的停顿时间”(通过-XX:MaxGCPauseMillis配置最大停顿时间,G1会尽量满足),而CMS的停顿时间不可控;G1是跨代收集器,能同时处理新生代和老年代,不用依赖其他收集器,而CMS只能处理老年代,需要ParNew配合,这些优势让G1成为大内存环境的首选。
十七、你们线上用的什么垃圾收集器?为什么要用它?
线上选择垃圾收集器,不能“盲目跟风”,必须“匹配业务场景”,核心看业务的“优先级”(吞吐量、低停顿、内存大小)和JDK版本,结合实际项目经验,常见的回答思路有三种,每种都对应具体场景:
1. 场景一:后台数据处理(如每月销售报表生成、用户行为数据分析)
我们线上用JDK8,默认的“Parallel Scavenge+Parallel Old”组合。因为这类场景的核心需求是“高吞吐量”——每天要处理几百万条数据,只要能按时生成报表,中间偶尔卡顿1秒也能接受。Parallel Scavenge的核心优势就是吞吐量优先,不用额外配置,JDK默认的参数就能满足需求,而且维护成本低,适合我们团队“轻运维”的需求。
2. 场景二:传统Web应用(如企业管理系统、内部OA系统)
我们用的是“ParNew+CMS”组合。这类应用的用户主要是公司员工,对响应时间有一定要求(点击按钮后不能卡顿超过500毫秒),但堆内存不大(4G~8G),CMS的低停顿能满足需求。不过我们做了两点优化:一是配置-XX:CMSInitiatingOccupancyFraction=75(老年代占比75%时触发CMS),避免太晚触发导致并发失败;二是定期用jmap查看内存碎片率,每3个月重启一次应用,清理碎片,避免Full GC。
3. 场景三:高并发微服务(如电商商品详情页、支付系统)
我们用JDK11,默认的“G1收集器”。这类场景的核心需求是“低停顿+大内存”——商品详情页每秒有几千次访问,卡顿超过100毫秒就会影响用户体验,而且堆内存是16G(要缓存大量商品数据)。G1的优势正好匹配:一是支持Mixed GC,只回收部分Region,停顿时间能控制在50毫秒以内;二是没有内存碎片,不用重启应用;三是能通过-XX:MaxGCPauseMillis=50配置最大停顿时间,符合我们的服务等级协议要求。
十八、垃圾收集器应该如何选择?
选择垃圾收集器的核心是“先明确业务优先级,再匹配收集器特性”,不能“唯技术论”,还要考虑JDK版本、团队维护能力、服务器资源等因素。结合前面讲的收集器特性,推荐的选择路径有五条,每条都对应明确的场景和优先级:
1. 小内存、单线程环境(如嵌入式设备、工具类小程序)
选“Serial”,JDK所有版本都支持,优点是轻量、占用资源少,不用配置任何参数,直接用默认即可。比如我们团队开发的“设备监控工具”,运行在嵌入式设备上,内存只有64MB,用Serial完全够用,而且不会占用设备的CPU资源。
2. 吞吐量优先、对停顿不敏感(如后台数据同步、离线数据分析)
选“Parallel Scavenge+Parallel Old”,JDK8默认,适合堆内存4G~8G的场景。如果用JDK9及以后,也可以配置-XX:+UseParallelGC强制启用。比如我们的“用户行为数据分析服务”,每天凌晨处理前一天的日志,堆内存8G,用这个组合能在2小时内处理完数据,吞吐量比G1高15%左右。
3. 低停顿优先、中小堆(4G~8G)(如传统Web应用、内部系统)
选“ParNew+CMS”,适合JDK8及以前。但要注意两点:一是堆内存不要超过8G,否则CMS的并发标记时间会变长,停顿不可控;二是要配置合理的CMS触发阈值(如75%~80%),避免并发失败。比如我们的“内部OA系统”,堆内存6G,用这个组合后,用户点击按钮的响应时间从原来的800毫秒降到了300毫秒。
4. 低停顿优先、大堆(>8G)(如微服务、高并发APP)
选“G1”,JDK9及以后默认,适合堆内存8G~64G的场景。如果用JDK8,需要配置-XX:+UseG1GC启用,同时设置-XX:MaxGCPauseMillis(如50毫秒)和-XX:G1HeapRegionSize(如4MB)。比如我们的“电商支付系统”,堆内存16G,用G1后,GC停顿时间控制在50毫秒以内,完全满足支付场景的低延迟要求。
5. 超大堆(>32G)、超低停顿(<10ms)(如金融交易、实时风控)
选“ZGC”(JDK11+)或“Shenandoah”(OpenJDK),这两种收集器采用“并发整理”算法,STW时间几乎可以忽略(<10毫秒),适合超大内存场景。比如我们的“股票交易系统”,堆内存64G,用ZGC后,GC停顿时间稳定在5毫秒以内,完全不影响实时交易。
十九、对象一定分配在堆中吗?有没有了解逃逸分析技术?
很多人以为“对象一定在堆中分配”,其实这是一个误区——在JIT(即时编译)编译器的“逃逸分析技术”成熟后,对象可能会在栈上分配,甚至“不分配”(标量替换),核心是“优化对象分配位置,减少堆GC压力”,就像“家里的物品不一定都要放储物间(堆),常用的可以放桌面(栈),零散的可以直接分类放抽屉(标量替换)”。
(1)什么是逃逸分析?
逃逸分析是JIT编译器的一种优化技术,它会分析对象的“引用范围”,判断对象是否“逃逸”出方法或线程:
• 不逃逸:对象只在方法内使用,没有被方法外部引用,也没有被其他线程引用。比如在方法里创建一个“临时购物清单”,只在方法里核对清单内容、打印清单,打印完后就没用了,这个清单就不逃逸。
• 方法逃逸:对象被方法外部引用,比如把购物清单作为返回值返回给调用方,或者传给其他方法作为参数,这个清单就逃逸出了当前方法。
• 线程逃逸:对象被其他线程引用,比如把购物清单存入一个静态变量,其他线程能通过静态变量访问这个清单,这个清单就逃逸出了当前线程。
逃逸分析的核心逻辑是:如果对象不逃逸,就没必要在堆中分配(堆分配需要GC回收,有性能开销),可以在栈上分配,甚至优化成“不分配对象”;如果对象逃逸,就必须在堆中分配,确保其他方法或线程能访问到。
(2)逃逸分析的三个核心优化效果?
逃逸分析不是“理论技术”,而是JDK6及以后就默认启用的实际优化,主要有三个效果,每个都能减少堆压力:
1. 栈上分配:不逃逸的对象在栈上分配,方法结束后,对象随栈帧出栈而销毁,不用GC回收。比如方法里创建的临时购物清单,在栈上分配,方法执行完后,栈帧弹出,清单就自动消失了,不用堆GC处理。这能大大减少堆中对象的数量,降低GC频率。
2. 同步消除:如果对象不逃逸,说明它不会被其他线程访问,那么对象上的同步锁(比如synchronized)就会被自动消除,提高执行效率。比如方法里创建一个用于拼接字符串的对象,这个对象的方法原本有synchronized锁,但逃逸分析发现它只在当前方法使用(不逃逸),就会删掉这个锁,避免锁开销。
3. 标量替换:如果对象不逃逸,且对象可以被“拆成”基本类型(标量,如int、String),JIT会直接在栈上分配这些基本类型,而不创建完整的对象。比如购物清单里有“牛奶、面包、鸡蛋”三个属性,逃逸分析后,不会创建“清单对象”,而是直接在栈上分配“牛奶”“面包”“鸡蛋”三个字符串,减少对象头、对齐填充等内存开销(一个对象至少占16字节,而三个字符串的内存可能更少)。
举个具体例子:在一个生成订单信息的方法里,创建一个用于拼接订单内容的对象,这个对象只在方法里拼接“订单号、金额、收货地址”,然后返回拼接后的字符串。逃逸分析会判断这个拼接对象不逃逸,进而做三个优化:一是不在堆中分配,而是在栈上处理;二是消除对象方法的同步锁;三是不创建完整对象,直接在栈上处理三个字符串的拼接。最终,这个方法执行完后,栈帧弹出,相关数据自动销毁,完全不用堆GC参与。
需要注意:逃逸分析的效果依赖JIT编译,只有热点代码(被执行多次的代码)才会被JIT编译优化,冷代码(只执行一次)还是会在堆中分配对象。