闲聊GC-基础篇

5 阅读8分钟

基于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)

  1. 第一次GC:第一次GC:将存活的对象从Eden区复制到S0区。 (注意:如果此时 S0 区域不够大,放不下这些存活对象,才会触发『分配担保机制』,把放不下的对象直接送入老年代)。此时 S1 和 Eden 区被清空。

image.png

image.png

image.png

  1. 第二次GC:将Eden + S0 区存活的对象复制到S1区,S1区和S0区角色互换,原来的 S1 变成了下一次的 From,原来的 S0 变成了下一次的 To(保持 To 区永远为空)。

image.png

image.png

  1. 循环往复,每次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)

  1. 标记:从GC Roots开始通过引用遍历,被遍历到的对象在对象头中打上『存活』标记(清除动作结束后,这些标记会被重置,以便迎接下一次 GC)
  2. 清除:把所有没有打标记的对象都删除掉

image.png

image.png 优缺点分析: 清除动作过后,老年代的空间就会产生大量的空闲内存碎片。这种方式的优点是 GC 回收阶段速度极快,因为不需要在内存中移动对象、修改指针;但缺点是会引发“并发模式失败(Concurrent Mode Failure)”。如果新晋升的对象太大,在这些碎片空隙里根本塞不下,JVM 就会被迫退化为单线程的内存整理收集器 —— Serial Old。此时会触发一次漫长且可怕的 Full GC,导致整个业务系统长时间卡死(STW)。这就是为什么我们在 JVM 优化或写代码时,要极力避免大对象频繁进入老年代的原因。

标记-整理(Mark-Compact)

标记过程与上面相同,但后续动作不同:它会把所有存活的对象向内存的一端整齐地移动,直接覆盖掉死对象的空间。

image.png

image.png

优缺点分析: 它的优点是天然没有内存碎片,后续分配新对象非常高效;但缺点是性能(耗时)不太理想。因为移动对象不仅要搬运内存,还要修改所有指向这些对象的引用指针,这通常需要全程暂停用户线程(STW)。

有的GC收集器老年代采用标记-清除算法(CMS),有的采用了标记-整理算法,后续我们细说

Stop The World (STW)

无论是年轻代还是老年代的GC算法,在标记或移动对象时,都不可避免地要暂停用户的业务线程,这就是 STW

跨代引用于卡表(Card Table)

问题: Minor GC只回收年轻代,如果老年代引用了年轻代的对象(跨代引用),年轻代在执行可达性分析时,如何确保这个被老年代引用的年轻代对象不被回收?难不成让老年代也来一次扫描?

JVM引入了 (Card Table)/记忆集 的概念,把老年代划分成无数个 512 字节的卡页。哪个卡页里有对象引用了年轻代,就把这个卡页标记为“脏(Dirty)”。Minor GC 时,只需要把这些“脏卡”里的老年代引用加入 GC Roots 即可。

image.png