🌈 引言:GC就像Java世界的清洁工
- 自动保洁系统:默默清理程序运行产生的"内存垃圾"
- 性能守护者:好的GC策略能让程序提速30%以上
- 内存管理大师:通过巧妙算法平衡内存使用与程序效率
问题:如何判断对象可以回收?
1.引用计数法 :每个对象有一个引用计数属性,新增一个引用时计数加1,引用释放时计数减1,计数为0时可以回收。
类比,就像共享单车的扫码锁,当最后一个用户归还后(计数归零)才能回收
缺点:循环引用无法处理
2.可达性分析算法,Java虚拟机中的垃圾回收器采用可达性分析来探索所有存活的对象。扫描堆中的对象,看看是否能够沿着 GC Roots 对象为起点的引用链找到该对象,找不到,表示可以回收。
类比:像蜘蛛网的连接关系,只要还有一条‘丝线’(引用链)连接着重要节点(GC Root),对象就能存活
GC Roots 对象.:不能被垃圾回收的对象,可用 mat(memoryAnalyzer) 可视化工具查看,如
- 虚拟机栈(栈帧中的本地变量表)中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中JNI(即一 般说的Native方法)引用的对象
代码示例:
public static void main(String[] args) {
Demo demo = new Demo();
demo = null;
}
public static Demo a;
public static void main(String[ args) {
Demo b = new Demo();
b.a = new Demo();
b = null;
}
public static final Demo a = new Demo();
public static void main(String[ args) {
Demo demo = new Demo();
demo = null;
}
不可达的对象并非“非死不可”
- 即使在可达性分析法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑阶段”,要真正宣告一个对象死亡,至少要经历两次标记过程;
🔎 详细流程解析
1. 第一次标记:不可达判定
- 可达性分析:从 GC Roots(如栈帧变量、静态变量等)出发,遍历对象图,标记所有不可达的对象。
- 标记结果:不可达对象被标记为“待回收”,但 不会立即清理。
2. 筛选阶段:finalize() 的最后机会
检查对象是否重写 finalize() 方法:
- 未重写:直接判定为“可回收”,进入回收流程。
- 已重写:将对象放入 F-Queue 队列,由 JVM 的 Finalizer 线程触发其 finalize() 方法。
复活机制:
public class Zombie {
static Zombie instance;
@Override
protected void finalize() throws Throwable {
instance = this; // 对象复活!
}
}
在 finalize() 中,对象可通过重新建立引用链(如将 this 赋值给全局变量)实现自救。
3. 第二次标记:最终审判
执行 finalize() 后:GC 对 F-Queue 中的对象进行第二次可达性分析。
- 仍不可达:对象被标记为“可回收”,等待内存回收。
- 变为可达:对象被移出待回收集合,继续存活。
🚨 注意事项
- 避免使用 finalize():
资源释放应在 try-finally 或 try-with-resources 中显式处理。
finalize() 执行延迟会导致内存释放不及时,影响性能。 - GC 触发时机:
不可达对象的内存回收由 JVM 的垃圾收集器(如 CMS、G1)的具体实现决定,存在延迟。
分代收集策略下,对象可能从年轻代晋升到老年代后才被回收。 - 监控工具:
使用 VisualVM、JProfiler 或 MAT 分析内存泄漏,定位未及时回收的对象。
再谈引用
- 无论是引用计数法还是可达性分析算法,判定对象的存活都与引用有关。
- JDK1.2之前,Java中引用的定义很传统:如果Reference类型的数据存储的数值代表的是另一块内存的起始地址,就称这块内存代表一个引用。
- JDK1.2之后,Java对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用、虚引用四种(引用强度逐渐减弱)
四(五)种引用
目的:为了更好地管理内存、优化垃圾回收
1.强引用
普通变量 赋值即为强引用,如
A a = new A();
- 类似于必不可少的生活用品,只有所有 GC Roots 对象都不通过【强引用】引用该对象,该对象才能被垃圾回收
- 当内存空间不足时,Java虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终止,也不会随意回收具有强引用的对象来解决内存不足的问题
- 用途:需要长期持有的对象,例如常用的业务对象
2.软引用(SoftReference)
SoftReference a = new SoftReference(new A());
- 类似于可有可无的生活用品,如果仅有软引用该对象时, 当内存不足时,垃圾回收器会回收软引用指向的对象,但在内存充足的情况下,软引用对象不会被回收。
- 软引用自身需要配合**引用队列(ReferenceQueue)**来释放,如果软引用所引用的对象被垃圾回收,JAVA虚拟机就会把这个软引用加入到与之关联的引用队列中。
- 用途:适用于缓存机制,例如缓存图片、数据等,可以在内存不足时自动释放(典型例子是反射数据)
3. 弱引用(WeakReference)
WeakReference a = new WeakReference(new A());
- 类似于可有可无的生活用品,如果仅有弱引用引用该对象时,只要发生垃圾回收,无论内存是否充足,都会释放该对象,比软引用对象具有更短的生命周期;
- 弱引用自身需要配合引用队列来释放,同软引用
- 用途:常用于实现监听器、观察者模式等,允许在不需要时自动释放对象(典型例子是ThreadLocalMap中的Entry对象)
4.虚引用( PhantomReference )
PhantomReference a = new PhantomReference(newA());
- 顾名思义,形同虚设的引用,主要用来跟踪对象被垃圾回收的活动。
- 必须配合引用队列一起使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。程序可以通过判断引用队列中是 否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。程序如果发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。当虚引用引用的对象被回收时,会将虚引用对象入队,由Reference Handler线程释放其关联的外部资源
- 用途:用于管理资源的回收,例如清理操作(典型例子 是Cleaner释放DirectByteBuffer占用的直接内存)
5. 终结器引用(FinalReference)
无需手动编码,但其内部配合 引用队列 使用,在垃圾回收时,终结器引用入队(被引用对象暂时未被回收),再由Finalizer线程通过终结器引用找到被引用对象并调用它的finalize方法,第二次GC时才能回收被引用对象
注意:在程序设计中一般很少使用弱引用和虚引用,使用软引用情况较多,软引用可以加速JVM对垃圾内存的回收速度,可以维护系统的运行安全,防止内存溢出(OutOfMemory)等问题的产生。
四大引用类型总结(快递类比)
引用类型 | 生存策略 | 典型应用场景 | 生命周期管理 |
---|---|---|---|
强引用📦 | 绝不回收(除非断连) | 普通对象创建 | 手动置null |
软引用📮 | 内存不足时才回收 | 缓存系统 | 配合ReferenceQueue |
弱引用📧 | 见GC就回收 | ThreadLocalMap | 自动清理 |
虚引用👻 | 追踪对象回收 | 直接内存管理 | 必须配队列使用 |
演示 demo
//演示软引用 -Xmx8M
public class RefDemo {
private static final int _4MB = 4 * 1024 * 1024;
public static void main(String[] args) {
List<SoftReference<byte[]>> list = new ArrayList<>();
ReferenceQueue<byte []> queue = new ReferenceQueue<>();//引用队列
for (int i = 0; i < 5; i++) {
//关联引用队列,当软引用关联的引用队列byte[]被回收时,软引用会自己加入到queue中去
SoftReference<byte[]> ref = new SoftReference<>(new byte[_4MB], queue);
list.add(ref);
System.out.println(ref.get() + "=====" + list.size());
}
//从队列中获取无用的 软引用对象,并移除
Reference<? extends byte[]> poll = queue.poll();
while (poll != null){
list.remove(poll);
poll = queue.poll();
}
for (SoftReference<byte[]> s : list){
System.out.println(s.get());
}
}
}
1. 三大垃圾回收算法(类比搬家
1.1 标记清除 Mark Sweep - 简单粗暴
沿着GCRoot引用链查找被引用的对象并标记下来,标记完成后统一清除没有标记的对象的内存空间
- 优点:快速直接
- 缺点:产生内存碎片(就像搬家后房间散落的纸箱
1.2 标记整理 Mark Compact - 空间优化
沿着GCRoot引用链查找被引用的对象并标记下来,然后移动被标记的内存的地址
- 优点:消除碎片
- 缺点:耗时较长(像整理衣柜需要重新叠放衣服)
1.3 复制Copy(复制后交换)- 双仓库策略
- 将内存分为大小相同的两块(from区和to区),每次使用其中的一块。
- 垃圾回收时,将from区被标记的对象依次移动到to内存区,然后清除掉from区的内存空间。完成之后from区和to区进行置换
- 优点:高效无碎片
- 缺点:需要双倍空间(像把物品先搬到临时仓库再清空原仓库)
1.4 分代垃圾回收(类比人生阶段
现在都采用分代垃圾收集算法,根据对象存活周期的不同将内存分为几块,一般将java堆分为新生代和老年代(默认比例1:2),新生代又分为Eden区和2个survivor区(默认比例8:1:1),我们可以根据各个年代的特点选择合适的垃圾回收算法。
-新生代:朝生暮死的年轻人(Minor GC频率高)
-老年代:长期驻留的长者(Full GC代价大)
比如在新生代中,每次收集都会有大量对象死去,所以可以选择复制算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。而老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以我们必须选择“标记-清除”或“标记-整理”算法进行垃圾收集。
延伸面试问题: HotSpot为什么要分为新生代和老年代?
内存分配及晋升规则
- 对象首先分配在伊甸园区域
- 新生代空间不足时,触发minor gc,伊甸园和from存活的对象使用copy算法复制到to中,存活的对象年龄加1并且交换from to
- minor gc会引发stop the world,暂停其它用户的线程,等垃圾回收结束,用户线程才恢复运行
- 当对象寿命超过阈值15次GC(4bit存储年龄)仍然存活时,会晋升至老年代.
- 当老年代空间不足,会先尝试触发major gc, 如果之后空间仍不足,那么触发full gc,STW的时间更长
- 大对象会晋升至老年代(新生代放不下,老年代放得下,直接放老年代,不触发垃圾回收)
tip:一个线程outofMemory并不会导致整个Java进程结束
GC规模
1.Minor GC发生在新生代的垃圾回收,STW 暂停时间短
2.MixedGC新生代+老年代部分区域的垃圾回收,G1收集器特有
3.Full GC新生代+老年代完整垃圾回收,STW 暂停时间长,应尽力避免
2. 垃圾回收器
2.1 串行
- 单线程 只会使用一条垃圾收集线程去完成垃圾收集工作,在进行垃圾回收工作时必须暂停所有其他的工作线程(Stop The World),直到它结束
- 新生代:复制算法 老年代:标记整理算法
- 堆内存较小,适合个人电脑
2.2 吞吐量优先
- 多线程 :就是串行的多线程版本
- 新生代:复制算法 老年代:标记整理算法
- 堆内存较大,多核cpu
- 让单位时间内,STW的时间最短 0.2+0.2=0.4
2.3 响应时间优先 CMS
CMS(Concurrent Mark Sweep):是一种以获取最短回收停顿时间为目标的收集器,比较注重用户体验。
- 多线程 ,第一款真正意义上的并发收集器,第一次实现了让垃圾回收线程和用户线程(基本上)同时工作。
- 标记清除算法+标记整理算法
- 堆内存较大,多核cpu
- 尽可能让单次STW的时间最短 0.1 0.1 0.1 0.1 0.1=0.5
处理流程
-初始标记: 暂停所有的其他线程,并记录下直接与root相连的对象,速度很快 ;
并发标记: 同时开启GC和用户线程,用一个闭包结构去记录可达对象。但在这个阶段结束,这个闭包结构并不能保证包含当前所有的可达对象。因为用户线程可能会不断的更新引用域,所以GC线程无法保证可达性分析的实时性。所以这个算法里会跟踪记录这些发生引用更新的地方。 - 重新标记: 重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短
- 并发清除: 开启用户线程,同时GC线程开始对为标记的区域做清扫。
主要优点:并发收集、低停顿
缺点:
- 对CPU资源敏感
- 无法处理浮动垃圾
- 使用的回收算法-‘标记-清除’算法会导致收集结束时有大量空间碎片产生
2.4 G1收集器
发展历程
- 2004论文发布
- 2009 JDK 6u14 体验
- 2012 JDK 7u4 官方支持
- 2017JDK9默认
Garbage First 是一款面向服务器的垃圾收集器,主要针对多颗处理器及大容量内存的机器,以提高概率满足GC停顿时间要求的同时,还具备高吞吐量性能特征。
- 把连续的内存划分为多个大小相等的独立区域(Region), 每个Region可以根据需要划分到Eden区、Survivor区、Old区、Humongous区(超过Region大小的50%就会被放在这里,如果超过100%,则放入多个连续的Humgous区)
- 通过 XX:G1HeapRegionSize可以设置每个Region的大小,它的取值范围1-32Mb,必须为2的N次幂。
- G1收集器在后台维护了一个优先列表,每次根据允许的收集停顿时间MaxGCPauseMillis,优先选择回收价值最大的Region(名字 Garbage-First的由来),这种使用Region划分内存空间以及优先级的区域回收方式,保证了G1收集器能够在有限时间内尽可能高的收集效率。
内存结构图:
相关JVM参数
XX:+UseG1GC JDK8之前未启用
-XX:G1HeapRegionSize=size
XX:MaxGCPauseMillis=time(默认200ms)
注意:停顿时间设置尽可能合理,一般100-200ms,如果设置太短会导致每次只收集一小部分,如果收集的速度逐渐跟不上分配内存的速度,运行时间一久就会导致占满整个堆而触发Full F+GC,反而降低性能。
适用场景
- 同时注重吞吐量(Throughput) 和低延迟(Low latency),默认的暂停目标是200 ms
- 超大堆内存,会将堆划分为多个大小相等的Region
- 整体上是标记+整理算法,两个区域之间是复制算法
主要特征:
- 并行与并发:G1能充分利用CPU、多核环境下的硬件优势,使用多个CPU(CPU或者CPU核心)来缩短Stop-The-World停顿时间。部分其他收集器原本需要停顿Java线程执行的GC动作,G1收集器仍然可以通过并发的方式让java程序继续执行。
- 分代收集:虽然G1可以不需要其他收集器配合就能独立管理整个GC堆,但是还是保留了分代的概念。
- 空间整合:与CMS的“标记–清理”算法不同,G1从整体来看是基于“标记整理”算法实现的收集器;从局部上来看是基于“复制”算法实现的。
- 可预测的停顿:这是G1相对于CMS的另一个大优势,降低停顿时间是G1 和 CMS 共同的关注点,但G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内。
垃圾回收阶段:
- 初识标记
- 并发标记
- 最终标记
- 筛选回收
2.4.1 Full GC
当老年代中的对象占用的空间已经超过了一定阈值时,JVM可能会启动Full GC来释放不再被引用的对象。会回收整个Java堆中的所有对象,包括老年代和新生代。
💡 哪些情况会触发Full GC?
- 晋升失败:Eden区满了并且触发Minor GC后,可以晋升到老年的对象,放入老年代时发现空间不足,则触发Full Gc;
- 无法放下大对象: 进行大对象分配时,无法找到足够的连续空间来分配该大对象,也会触发Full Gc
- 元空间或永久代:在JDK 8及更高版本中,类信息存放在元空间中。当系统中要加载的类、反射的类和调用的方法较多时,元空间可能会被占满,导致Full Gc;
- 分配担保:young GC时,survivor区的空间不足时直接进入老年代,会导致Full GC;
- 发生(Concurrent Mode Failure):在执行CMS GC的过程中,如果有对象需要放入老年代,而此时老年代空间不足,或者在做Minor GC的时候,新生代Survivor空间放不下,需要放入老年代,而老年代也放不下,导致并发模式失败,进而触发Full Gc;
- 显示System.gc() 通知虚拟机执行Full Gc。
SerialGC
- 新生代内存不足发生的垃圾收集- minor gc
- 老年代内存不足发生的垃圾收集- full gc
ParallelGC - 新生代内存不足发生的垃圾收集- minor gc
- 老年代内存不足发生的垃圾收集- full gc
CMS - 新生代内存不足发生的垃圾收集- minor gc
- 老年代内存不足
G1 - 新生代内存不足发生的垃圾收集- minor gc
- 老年代内存不足
3. 垃圾回收调优
相关VM参数
堆初始大小 -Xms
堆最大大小 -Xmx 或 -XX:MaxHeapSize=size
新生代大小 -Xmn或(-XX:NewSize=size + -XX:MaxNewSize=size)
幸存区比例(动态) -XX:InitialSurvivorRatio=ratio 和 -XX:+UseAdaptiveSizePolicy
幸存区(伊甸园)比例【1-10】,剩下的from和to平分 -XX:SurvivorRatio=ratio
年轻代晋升老年代阈值【0-15】,默认15 -XX:MaxTenuringThreshold=threshold
晋升详情 -XX:+PrintTenuringDistribution
GC详情 -XX:+PrintGCDetails -verbose:gc
FullGC前MinorGC -XX:+ScavengeBeforeFullGC
调优领域
- 内存
- 锁竞争
- cpu占用
- io
确定目标
- 【低延迟】 还是【高吞吐量】,选择合适的回收器
- 低延迟:CMS,G1,ZGC
- 高吞吐量:ParallelGC
最快的GC是不发生GC
查看FullGC前后的内存占用,考虑下面几个问题
- 数据是不是太多?
resultSet = statement.executeQuery(“select * from 大表 limit in”) - 数据表示是否太臃肿?
- 对象图
- 对象大小16 Integer 24 int4
- 是否存在内存泄漏?
- static Map map =
- 软
- 弱
- 尽量用第三方缓存实现
3.1 新生代调优
新生代的特点
- 所有的new操作的内存分配非常廉价 TLAB thread-local allocation buffer
- 死亡对象的回收代价是零
- 大部分对象用过即死
- Minor GC的时间远远低于Full GC
内存并不是越大越好,满足以下规则
- 新生代能容纳所有【并发量* (请求-响应)】的数据
- 幸存区大到能保留【当前活跃对象+需要晋升对象】
- 晋升阈值配置得当,让长时间存活对象尽快晋升
XX:MaxTenuringThreshold=threshold
-XX: +PrintTenuringDistribution
Desired survivor size 48286924 bytes, new threshold 10 (max 10)
-age 1: 28992024 bytes, 28992024 total
-age 2: 1366864 bytes, 30358888 total
-age 3: 1425912 bytes, 31784800 total
.....
3.2 老年代调优
以CMS为例
- CMS的老年代内存越大越好
- 先尝试不做调优,如果没有Full GC那么已经…否则先尝试调优新生代
- 观察发生Full GC时老年代内存占用,将老年代内存预设调大1/4~ 1/3
-XX:CMSInitiatingOccupancyFraction=percent(越低,fullgc触发时机越早)
3.3 案例
- 案例1 Full GC 和 Minor GC频繁
原因:内存不够 尝试增大新生代内存、幸存区内存、晋升阈值 - 案例2 请求高峰期发生Full GC,单次暂停时间特别长(CMS)
- 案例3 老年代充裕情况下,发生FullGC (CMS jdk1.7)
💡 高频面试三连
CMS和G1的核心区别是什么?
- G1和CMS都是关注停顿时间的垃圾回收器。在早期通常都会拿来进行对比,但目前在高版本jdk中CMS已经被移除了,同时默认使用G1垃圾回收器。相比CMS,G1可以指定最大停顿时间、Region内存布局、按收益动态回收region、算法上也采用标记-整理利于长期运行;但由于要维护记忆集付出的成本要比cms高。
- 对于CMS和G1在JDK11以前发生full gc都是串行收集这样整个回收时间就会变得非常长,如果频繁发生full gc,那它们的性能还不如ps+po的组合,而在JDK11开始对G1的full gc进行改进,支持了并行收集,乍一看其实就让从单线程执行标记-整理,变成多线程,每个线程分配一部分region进行标记-整理,这其中涉及到了很多细节,例如:每个线程标记-整理完整后,最后一个region是不满的,并且当前没有可用region,就会把每个线程最后一个不满的region再进行一次压缩以便可以释放出完整的region空间。
对象晋升老年代的途径有哪些?
1、动态年龄:当大于等于某个年龄的所有对象大小大于survivor的一定阈值时这些对象也会进入到年代(默认百分之50);
2、大对象:创建的对象特别大(通过-XX:PretenureSizeThreshold设置);
3、分配担保:young Gc时,survivor区的空间不足时直接进入老年代。
如何诊断线上GC问题?
解析:对 JVM 进行性能调优可以从以下几个关键步骤入手:
监控:首先要对 JVM 的运行状态进行监控,了解各项性能指标,如内存使用情况、垃圾回收频率、停顿时间等。可以使用可视化工具如 JConsole、VisualVM 等进行实时监控,通过这些工具可以直观地看到 JVM 的各项参数和指标变化情况。
分析:根据监控得到的数据进行分析,找出可能存在的性能问题。例如,如果发现垃圾回收频率过高,可能意味着内存分配不合理或者对象存活周期过长;如果停顿时间过长,可能是因为垃圾回收算法选择不当或者内存不足等原因。
调整:针对分析出的问题进行调整。比如,如果发现内存不足,可以适当增加堆内存的大小;如果垃圾回收频率过高,可以考虑优化对象的创建和使用方式,或者更换垃圾回收算法等。调整时需要注意,每次调整后要再次进行监控和分析,以验证调整的效果。
哪些 JVM 参数对性能调优比较重要?请列举几个并说明它们的作用。
解析:以下是一些对 JVM 性能调优比较重要的参数:
-Xmx:用于指定堆内存的最大允许大小。例如,-Xmx512m表示堆内存最大可达到 512MB。合理设置这个参数可以避免因堆内存不足而导致的内存溢出问题,同时也需要根据程序的实际需求来确定合适的大小,过大的堆内存可能会导致垃圾回收时间延长。
-Xms:指定堆内存的初始大小。通常情况下,为了减少内存分配和回收的开销,建议将-Xms和-Xmx设置为相同的值,这样在 JVM 启动时就可以一次性分配好所需的堆内存,避免后续因内存不足而频繁调整。
-XX:NewRatio:用于控制新生代和老年代在堆内存中的比例关系。例如,-XX:NewRatio=2表示老年代与新生代的比例为 2:1,即老年代占堆内存的三分之二,新生代占堆内存的三分之一。合理设置这个比例可以根据程序中对象的存活情况来优化垃圾回收效率。
-XX:SurvivorRatio:用于控制新生代中 Eden 区与 Survivor 区的比例关系。例如,-XX:SurvivorRatio=8表示 Eden 区与每个 Survivor 区的比例为 8:1,即 Eden 区占新生代的八分之七,每个 Survivor 区占新生代的十六分之一。这个参数也会影响垃圾回收的效率和效果。
参考:
黑马程序员视频笔记
https://juejin.cn/post/6844903666432868365?searchId=20250224133914395985D6393F6F01B234#heading-22