基于jvm的编程语言(比如:Java)程序员不用关心内存的分配与回收,世界top级的程序员们为此付出了巨大的努力,随着JDK版本的不断出新,GC机制的效率也得到了不断的加强,作为java程序员,了解GC算法以及设计对我们排查性能问题有巨大帮助,再加上AI,定位排查问题的效率直接起飞~
可达性分析算法
如何判断对象已死? 一种叫做“引用计数法”,给对象头中添加一个引用计数器,对象被引用一次就+1;当引用被解除就-1。如果对象的引用计数器的值为0,就代表这个对象可以被GC了,这种方法无法解决循环引用的问题,比如A引用了B,B引用了A,A和B的引用计数值都为1,但是没有其他对象引用A或B了,引用计数的方式就导致永远无法回收这种对象了。
为了解决这种问题,现在的GC收集算法都采用了可达性分析算法 这个算法的基本思路就是以一系列被称为 “GC Roots” 的根对象作为起始节点集。从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为引用链(Reference Chain) 。这是一种对象着色机制。后续遍历堆时,如何根据着色结果去处理这些对象(是清除、整理还是复制),各种 GC 收集器采用的方式不同,这也成就了它们各自鲜明的特点。
- 活对象:如果一个对象到 GC Roots 之间有至少一条引用链相连(用图论的话说,就是从 GC Roots 到这个对象是可达的),那就证明这个对象系统还在用,它是活的。
- 死对象(垃圾) :如果一个对象到 GC Roots 之间没有任何引用链相连(不可达),哪怕这个对象和别的对象互相勾连(比如前面提到的 A 和 B 循环引用),由于它们和“根”断了联系,它们依然会被判定为可回收的垃圾。
GC ROOT
GC 算法总是说从程序根对象(GC Roots)开始遍历,GC Root就相当于垃圾收集器遍历堆内存的起点了,GC Root不在堆中,主要从四个地方开始
- 虚拟机栈:这是GC ROOT的主要部分了,它是被调用(执行中)的方法中的局部变量、形参、方法内部的对象引用,只要它指向了堆中的某个对象,这个对象就是 GC Root。
// a b c 三个引用指向的对象都是GC Root对象
public void sum(Integer a, Integer b) {
Integer c = a + b;
// ...... 其他业务
}
- 方法区中类静态属性引用的对象
public class CacheService {
// cacheMap 是静态变量,它指向的 Map 对象就是 GC Root
private static Map<String, Object> cacheMap = new HashMap<>();
}
- 方法区中常量引用的对象
public class Config {
// DEFAULT_LIMIT 指向的 String 对象就是 GC Root
public static final String DEFAULT_LIMIT = "100";
}
- 本地方法栈中 JNI(Native 方法)引用的对象
java底层调用一些本地方法(Native 方法),这些方法由C/C++开发,方法内部会引用java堆得对象,这些被执行的本地方法内部也属于是GC Root
GC算法
我们可以笼统的认为,JVM堆内存被划分成了年轻代和老年代,为什么要分代管理内存呢?因为这两个代的GC算法不同,适配场景不用,两个代的算法各有特点与优劣,下面逐一介绍。
年轻代
标记-复制(Mark-Copy)
年轻代(Young Generation)又被划分成Eden区和两个Survivor区(From Survivor区也叫S0、To Survivor区也叫S1),应用程序中大多数对象都被直接分配到Eden区,Eden区占用到达一定比例时,触发Minor GC(Yong GC)
- 第一次GC:第一次GC:将存活的对象从Eden区复制到S0区。 (注意:如果此时 S0 区域不够大,放不下这些存活对象,才会触发『分配担保机制』,把放不下的对象直接送入老年代)。此时 S1 和 Eden 区被清空。
- 第二次GC:将Eden + S0 区存活的对象复制到S1区,S1区和S0区角色互换,原来的 S1 变成了下一次的 From,原来的 S0 变成了下一次的 To(保持 To 区永远为空)。
- 循环往复,每次GC没有被回收的对象年龄+1,JVM 筛选对象去老年代,除了固定年龄(15)和 To 区装不下之外,还有一个非常重要的底层机制叫 “动态年龄判定(Dynamic Age Tenuring)” 。
动态年龄判定: 相同年龄的所有对象大小的总和大于 Survivor 空间的一半(50%) ,那么年龄大于或等于该年龄的对象就可以直接进入老年代,无需等到 15 岁。
优势:
- 可以得到连续可用的空闲内存(无内存碎片),也是这个算法的核心目的。
- 执行效率高,每次执行,搜集堆中一部分的内存,避免了全堆扫描,保证执行效率。但是,如果年轻代被分配特别大的内存,比如超过2GB,那这个执行效率就没有办法保证了,但是G1,ZGC收集器,依然可以保证大年轻代的GC效率。
- 清理垃圾极快,对于底层来说,就是挪动了一下指针而已。
劣势:
- 一直空一个Survivor区什么也不存,浪费了,设计这个Survivor区也是为了尽力减少内存浪费,总比把年轻代以5:5分割来实现要好的多吧。
- 对象被复制到别的内存区域了,与对象关联的引用也要做变更的,这个引用的修改也是比较耗性能的,这也能说明如果年轻代太大,GC的性能也可能会有问题,年轻代大了,GC需要修改的对象引用也就变多了。
- 需要内存担保机制,当Minor GC后,如果Surivor区放不下剩下的存活对象,需要把一部分对象存到老年代,所以老年代是没法用这个算法的,总不能放到堆外把 ^_^!
综上,标记-复制算法是一种空间换时间的思想,适合死的多,活的少的GC场景。
老年代
老年代在堆内存中就是一整块内存了,有一些大对象分配时,会被直接分配到老年代,还有一些Minor GC被内存担保机制发配下来的对象,还有一些就是多次Mionr GC都依然存活的长生命周期对象了
标记-清除(Mark-Sweep)
- 标记:从GC Roots开始通过引用遍历,被遍历到的对象在对象头中打上『存活』标记(清除动作结束后,这些标记会被重置,以便迎接下一次 GC)
- 清除:把所有没有打标记的对象都删除掉
优缺点分析: 清除动作过后,老年代的空间就会产生大量的空闲内存碎片。这种方式的优点是 GC 回收阶段速度极快,因为不需要在内存中移动对象、修改指针;但缺点是会引发“并发模式失败(Concurrent Mode Failure)”。如果新晋升的对象太大,在这些碎片空隙里根本塞不下,JVM 就会被迫退化为单线程的内存整理收集器 —— Serial Old。此时会触发一次漫长且可怕的 Full GC,导致整个业务系统长时间卡死(STW)。这就是为什么我们在 JVM 优化或写代码时,要极力避免大对象频繁进入老年代的原因。
标记-整理(Mark-Compact)
标记过程与上面相同,但后续动作不同:它会把所有存活的对象向内存的一端整齐地移动,直接覆盖掉死对象的空间。
优缺点分析: 它的优点是天然没有内存碎片,后续分配新对象非常高效;但缺点是性能(耗时)不太理想。因为移动对象不仅要搬运内存,还要修改所有指向这些对象的引用指针,这通常需要全程暂停用户线程(STW)。
有的GC收集器老年代采用标记-清除算法(CMS),有的采用了标记-整理算法,后续我们细说
Stop The World (STW)
无论是年轻代还是老年代的GC算法,在标记或移动对象时,都不可避免地要暂停用户的业务线程,这就是 STW。
跨代引用于卡表(Card Table)
问题: Minor GC只回收年轻代,如果老年代引用了年轻代的对象(跨代引用),年轻代在执行可达性分析时,如何确保这个被老年代引用的年轻代对象不被回收?难不成让老年代也来一次扫描?
JVM引入了 (Card Table)/记忆集 的概念,把老年代划分成无数个 512 字节的卡页。哪个卡页里有对象引用了年轻代,就把这个卡页标记为“脏(Dirty)”。Minor GC 时,只需要把这些“脏卡”里的老年代引用加入 GC Roots 即可。