03、JVM实战:垃圾回收算法

文章目录

    1. 为什么需要垃圾回收
    • 1.1 可达性分析算法
  • 1.2 Java中的引用:
    • 1.2.1 强引用(Strong Reference):
    • 1.2.2 软引用(Soft Reference):
    • 1.2.3 弱引用(Weak Reference):
    • 1.2.4 虚引用(Phantom Reference):
  • 1.3 对象的真正死亡:
  • 1.4 回收方法区:
    1. 垃圾回收算法
    • 2.1 标记-清除算法
  • 2.2 复制算法
    • 2.2.1 复制算法的优化:Eden区和Survivor区
    • 2.2.2 优化的复制算法下Minor GC的过程
  • 2.3 标记-整理算法
  • 2.4 分代收集算法

1. 为什么需要垃圾回收

在系统运行时,新生成的对象是优先分配在堆内存中的新生代,随着新生成的对象越来越多,新生代就会装不下了,这个时候就需要垃圾回收来把“垃圾”对象给回收掉,释放内存空间。

那在需要垃圾回收的时候,哪些对象才是“垃圾”对象呢?
在主流虚拟机中采用的是“可达性分析算法”来判断哪些对象能够回收,哪些对象不能回收的。

1.1 可达性分析算法

通过一系列称为“GC Roots”的对象作为起始点,从这些节点向下搜索,搜索所走过的路径称为引用链(Refrence Chain),当一个对象到GC Roots没有任何引用链相连(图论的话来说,这个对象到GC Roots不可达)时,证明此对象是不可用的,即可被回收的。

另外一种表述:将一系列的GC Roots作为初始的存活对象集合(live set),然后从该集合出发,探索所有能够被该集合引用到的对象,并将其加入到该集合,这个过程称为标记(mark);最终未被探索到的对象便是死亡的,即可被回收的。

图中object5,6,7到GC Roots不可达,所以会被判断成可回收对象。
![*][nbsp]

什么是GC Roots?

  • 在Java语言中,可作为GC Roots的对象:
    (1). 虚拟机栈(栈帧中的本地变量表)中引用的对象;(局部变量
    (2). 方法区中类静态属性引用的对象;(静态变量
    (3). 方法区中常量引用的对象(static final);(成员变量不行,因为成员变量是存储在堆内存的对象中的,和对象共存亡
    (4). 本地方法栈中JNI(Native方法)引用的对象;
    (5). 活着的线程;

这里有一篇文章进行了不错的证明: [【证】:那些可作为GC Roots的对象][GC Roots]

1.2 Java中的引用:

传统定义:如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称这块内存代表这一个引用。
JDK1.2后对引用进行了扩展,分为强引用、软引用、弱引用、虚引用:

1.2.1 强引用(Strong Reference):

在程序中普遍存在,类似于“Object obj = new Object()”这类的引用,只要强引用还在(即obj还指向Object对象),GC永远不会回收被引用的对象。
当手动置空 obj=null; 这个对象在下次GC运行的时候被回收。

  • 示例:
public class Kafka {
   
     
    public static ReplicaManager replicaManager = new ReplicaManager();
}

1.2.2 软引用(Soft Reference):

描述一些还有用但并非必须的对象,通常用于缓存,JDK1.2后,提供了SoftReference类来实现软引用。
正常情况下垃圾回收是不会回收软引用对象的;软引用对象的回收时机:

  • 当发生GC时,JVM虚拟机取决于软引用对象是否新创建或者最近被使用过,来判断是否回收;
  • 在JVM虚拟机发生OOM异常时,所有软引用对象都会被回收;
  • 如果软引用对象被一个强引用指向,即使发生OOM时,也不会被回收;

  • 示例:
public class Kafka {
   
     
    public static SoftReference<ReplicaManager> replicaManager = new SoftReference<ReplicaManager>(ReplicaManager());
}

  • 回收时机:clock - timestamp `<= freespace * SoftRefLRUPolicyMSPerMB

  • clock - timestamp:软引用对象多久没有被访问过了;

  • freespace(MB):JVM中的空闲内存空间;

  • SoftRefLRUPolicyMSPerMB(JVM参数):每MB的空闲内存允许SoftReference对象存活多久;

  • 示例:

  • 当前JVM里的空闲空间有1000M,SoftRefLRUPolicyMSPerMB 默认值为1000毫秒,则现在允许 SoftReference对象存活 1000*1=1000秒;

1.2.3 弱引用(Weak Reference):

描述一些非必须对象,强度比软引用更弱一点,被弱引用关联的对象只能生存到下一次垃圾回收发生之前(即跟没有引用一样,只要发生垃圾回收,都会把这个对象回收掉)。当前GC工作时,无论内存是否足够,都会回收掉弱引用关联的对象。JDK1.2后,提供了WeakReference类来实现软引用。

  • 示例:
public class WeakReferenceDemo {
   
     

    public static WeakReference<String> weakReference1;

    public static void main(String[] args) {
   
     

        test1();
        //可以输出hello值;因为此时已无强引用指向对象,但是未进行gc,所以弱引用仍持有对象
        System.out.println("未进行gc时,只有弱引用指向value内存区域:" + weakReference1.get());

        //执行GC,回收弱引用
        System.gc();

        //弱引用被释放掉了,所以引用的对象也被回收了
        System.out.println("进行gc时,只有弱引用指向value内存区域:" + weakReference1.get());

    }

    public static void test1() {
   
     
        String hello = new String("value");

        weakReference1 = new WeakReference<>(hello);

        System.gc();
        //此时gc不会回收弱引用,因为字符串"value"仍然被hello对象强引用
        System.out.println("进行gc时,强引用与弱引用同时指向value内存区域:" + weakReference1.get());

    }
}

// output:
进行gc时,强引用与弱引用同时指向value内存区域:value
未进行gc时,只有弱引用指向value内存区域:value
进行gc时,只有弱引用指向value内存区域:null

说明:

  • 当有强引用指向value的时候,即使进行GC,弱引用不会被释放,引用的对象不会被回收;
  • 当没有强引用指向value的时候,此时执行GC,弱引用会被释放,引用的对象会被回收;

WeakHashMap:

  • 介绍:WeakHashMap的键是弱键,当某个键不再正常使用时,会被从WeakHashMap中自动移除;即对于一个给定的键,其映射的存在并不阻止垃圾回收器对该键的丢弃,这就使该键成为可终止的,然后被回收,某个键被回收时,它对应的键值对也就从映射中有效地移除了。

  • 实现原理:通过WeakReference和ReferenceQueue实现,WeakHashMap的key是弱键,即是WeakReference类型的,ReferenceQueue是一个队列,它会保存被GC回收的弱键。

  • 新建WeakHashMap,将键值对添加到WeakHashMap中。WeakHashMap是通过数组table保存Entry(单向链表的键值对);

  • 当某弱键不再被其他对象引用并被GC回收时,这个弱键同时会被添加到ReferenceQueue中;

  • 当下一次需要操作WeakHashMap时,会先同步table和queue,table中保存了全部的键值对,queue中保存了已经被GC回收的键值对;同步它们,即是删除table中废弃的键值对;

1.2.4 虚引用(Phantom Reference):

最弱的一种引用关系,对象是否有虚引用的存在,完全不会对生存时间够成影响,也无法通过虚引用来取得一个对象实例。

唯一目的是能在对象被GC回收时收到一个系统通知(这就可以实现在对象被回收时做一些事情了:如DirectByteBuffer的Cleaner机制);JDK1.2后,提供了PhantomReference类来实现虚引用。

1.3 对象的真正死亡:

在可达性分析算法中不可达的对象(没有GC Roots引用的对象),是一定马上就被回收吗?
不是的,一个对象真正的死亡,还要经过两次标记的过程:

  • 如果对象在可达性分析后发现没有与GC Roots相连接的引用链,那它会被第一次标记并且进行一次筛选,筛选的条件是此对象是否还有必要执行 finalize() 方法;没有必要执行的条件是:

  • 对象没有覆盖finalize()方法;

  • finalize()方法 已经被虚拟机调用过;

  • 如果这个对象有必要执行finalize()方法(实现了finalize()方法),这个对象会被放置在一个F-Queue队列中,并由一个低优先级的Finalizer线程执行它。

  • finalize()方法是对象逃脱死亡的最后一次机会,如果对象要在finalize()中拯救自己,只需要重新与引用链上的任何一个对象建立关联即可,比如把自己(this)赋值给某个类变量或者对象成员变量。那在第二次标记时会被移出F-Queue队列中。

    • 示例:

      public class ReplicaManager {
      
      
          public static ReplicaManager instance;
          @override
          protected void finalize() throws Throwable {
      
      
              ReplicaManager.instance = this;
          }
      }
      
      
 *  如果对象还没有在此方法中逃脱,就会被回收了;  
    由于此方法运行代价高昂,不确定性大,无法保证各个对象的调用顺序,所以完全不推荐使用此方法来拯救对象。
 *  **所以也可以在finalize()方法中,实现一些在对象回收前的自定义操作**;(Java官方不推荐)

### 1.4 回收方法区: ###

方法区(如HotSpot虚拟机中的元空间或者永久代)的垃圾回收主要回收两部分内容:**废弃常量**和**无用的类**。

 *  **回收废弃常量**:与回收Java堆中的对象非常类似。以常量池中的字面量的回收 为例,例如一个字符串“abc”已经进入了常量池中,但是当前系统中没有任何String对象是“abc”,也就是说没有任何String对象引用常量池中的这个“abc”对象,当这个时候发生垃圾回收,必要的话,这个常量就会被清理出常量池。常量池中的其他类(接口)、方法、字段的符号引用都是这样。
 *  **回收无用的类**:条件比判断“废弃常量”复杂许多,需要同时满足下面三个条件:

 *  该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例;
 *  加载该类的ClassLoader都已被回收;
 *  该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

在大量使用反射,动态代理,CGLIB等ByteCode框架、动态生成JSP以及OSGI这类频繁自定义ClassLoader的场景都需要虚拟机具备类卸载功能,以保证永久代不会溢出。

## 2. 垃圾回收算法 ##

### 2.1 标记-清除算法 ###

标记-清除算法分为两个二个阶段:首先标记出所有需要回收的对象;在标记完成后统一回收所有被标记的对象。  
![&nbsp;][nbsp 1]

 *  缺点:

 *  空间问题:标记清除之后会**产生大量不连续的内存碎片,导致以后程序运行过程需要分配较大对象的时候,无法找到足够连续的内存**,而不得不提前触发垃圾回收动作;
 *  分配效率低:如果是连续的内存空间,可以通过指针加法的方式来做分配;而对于不连续的空闲列表,JVM需要逐个访问列表中的项来查找能够放入新建对象的空闲内存;

### 2.2 复制算法 ###

将可用内存按照容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了需要进行回收时,就**标记出需要存活的对象,然后将这些需要存活的对象复制到另外一块空白内存中(复制的时候可以紧凑地连续排列),然后把已使用的内存空间一次性清理掉**;现在的商业虚拟机都是采用这种复制算法来回收新生代。  
![&nbsp;][nbsp 2]

 *  优点:每次对整个半区进行内存回收,不会导致内存碎片的问题,实现简单,运行高效;
 *  缺点:将内存缩小为原来的一半,对内存的使用效率太低;

#### 2.2.1 复制算法的优化:Eden区和Survivor区 ####

根据IBM公司的研究表明:**新生代中的对象98%都是“朝生夕死”的(即存活时间很短,存活对象占比很小),所以并不需要按照1:1的比例来划分内存空间,而是将内存划分为一块较大的Eden区和两块较小的Survivor区,每次使用Eden区和一块Survivor区(用于存放上次Minor GC后还存活的对象)**。HotSpot虚拟机默认Eden区和Survivor区的大小是8:1,这样每次新生代中的可用内存空间只有10%会被浪费。

 *  示例(假设Eden区有800M内存,则相当于有900M的内存可以使用):  
![&nbsp;][nbsp 3]  
当发生回收时:将Eden区和刚才使用的Survivor区还存活着的对象(此时存活的对象非常少)一次性的复制到另外一块Survivor空间上,然后清理掉Eden区和使用过的Survivor区。  
![&nbsp;][nbsp 4]  
接着,新对象继续分配在Eden区,另外那块Survivor区继续保存Minor GC后存活的对象,并始终保持有一块Survivor区是空的,这样一直循环使用三块内存区域。
 *  为什么需要两块 Survivor区?

 *  假设只有一块 Survivor区:
    
     *  当发生第一次YGC的时候,回收Eden区,并将存活对象被放入了这块Survivor区;
     *  在发生下一次YGC时,回收Eden区和这块Survivor区,此时Eden区和这块Survivor区都可能有存活对象;
        
         *  第一,如果此处不做处理,等到后面几次YGC后,Survivor区不够了才移到老年代;多次的进入Survivor区,会导致内存区域不连续,内存利用率下降;
         *  第二,如果直接将Survivor区的存活对象移到老年代,将会导致老年代空间迅速增大,并且这些对象很可能下一次就该被回收的;(也就是说老年代放入了很多低年龄的对象)
         *  所以如果有两块Survivor区的话,可以保证内存区域连续,并且在Survivor区中对象的相互复制,可以增加年龄,实现年龄阈值判断;

当然,我们没有办法保证每次回收都只有不多于10%的对象存活,当另外的Survivor空间不够时,就需要依赖其他内存(老年代)进行分配担保。如果另外一块Survivor没有足够的空间存放上一次新生代收集下来的存活对象时,这些对象将直接通过分配担保机制进入老年代。

#### 2.2.2 优化的复制算法下Minor GC的过程 ####

 *  GC开始时,对象只会存在于Eden区和From Survivor区,To Survivor区是空的(作为保留区域)。
 *  GC进行时,Eden区中所有存活的对象都会被复制到To Survivor区,而在From Survivor区中,仍存活的对象会根据它们的年龄值决定去向,年龄值达到年龄阈值(**默认为15,JVM参数“-XX:MaxTenuringThreshold”**)的对象会被移到老年代中,没有达到阈值的对象会被复制到To Survivor区(**每复制一次,年龄加1**);接着清空Eden区和From Survivor区,新生代中存活的对象都在To Survivor区。
 *  接着,From Survivor区和To Survivor区会进行交换,把To Survivor区中的对象复制到From Survivor区中,也就是新的From Survivor区就是上次GC的To Survivor区。总之,不管怎样都会保证To Survivor区在一轮GC之后是空的。
 *  GC时当To Survivor区没有足够的空间存放新生代存活的对象时,**需要依赖老年代进行分配担保,将这些对象存放在老年代中**。

### 2.3 标记-整理算法 ###

复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会变低,另外还需要额外的空间进行担保,以应对使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接使用此算法。

“标记-整理”算法的标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收的对象进行清理,而是让所有存活的对象都向一端移动(压缩),然后清理掉端边界以外的内存。  
![&nbsp;][nbsp 5]

缺点:

 *  压缩算法的性能开销;

**HotSpot虚拟机中CMS垃圾回收器回收老年代采用的就是 标记-整理算法**。

### 2.4 分代收集算法 ###

当前商业虚拟机的垃圾收集都采用“分代收集”算法,根据对象存活周期的不同将内存划分为几块。一般是把Java堆划分为新生代和老年代,再根据各个年代的特点来采用最适合的收集算法。  
在新生代中,每次垃圾收集都发现有大量对象死去,只有少量存活,就采用复制算法,只需要付出少量存活对象的复制成本就可以完成收集;而老年代中因为对象存活率较高,没有额外空间进行分配担保,就必须使用“标记-清除”算法或者“标记-整理”算法来进行回收。


[nbsp]: https://cloud.cxykk.com/images/2024/1/27/2054/1706360057401.png
[GCRoots]: https://blog.csdn.net/u010798968/article/details/72835255
[nbsp 1]: https://cloud.cxykk.com/images/2024/1/27/2054/1706360063288.png
[nbsp 2]: https://cloud.cxykk.com/images/2024/1/27/2054/1706360069112.png
[nbsp 3]: https://cloud.cxykk.com/images/2024/1/27/2054/1706360075217.png
[nbsp 4]: https://cloud.cxykk.com/images/2024/1/27/2054/1706360081023.png
[nbsp 5]: https://cloud.cxykk.com/images/2024/1/27/2054/1706360086862.png