【JVM第八篇--垃圾回收】GC和GC算法

写在前面的话:本文是在观看尚硅谷JVM教程后,整理的学习笔记。其观看地址如下:尚硅谷2020最新版宋红康JVM教程

1、垃圾

1.1、什么是垃圾

垃圾(Garbage)在Java语言中是指在运行程序中没有任何指针指向的对象,这个对象就是需要被回收的垃圾。

如果不及时对内存中的垃圾进行清理,那么这些垃圾对象所占用的内存空间就会一直保留到应用程序结束,被保留的空间也无法被其他对象所使用,极可能导致内存溢出。

1.2、垃圾回收

垃圾回收(Garbage Collection)即常说的GC。GC的作用就是清理内存中的垃圾,释放被占用的内存空间,高效地利用内存。如果不进行垃圾回收,释放内存,则内存迟早会被消耗完毕,最终导致程序崩溃。因为程序在运行过程中是会不断产生对象来占用内存的。

除了释放成为垃圾的对象,垃圾回收有时也可以清理内存里的内存碎片,使其能在物理空间上能连成一片,以便JVM能将内存分配给新的对象。

1.3、Java的垃圾回收区域

在JVM中,只有方法区和堆区有垃圾回收的行为。其中堆又是垃圾回收的重点区域,即频繁收集新生代的垃圾,较少收集老年代,基本不动永久代/元空间。

实际上,方法区的垃圾回收性价比较低。方法区中的垃圾回收主要是常量的回收和类型的卸载。但类型的卸载条件非常苛刻,需要同时满足一下三个条件:

①该类的所有实例都已经被回收,也就是堆中不再有该类及其子类的实例。
②加载该类的类加载器已经被回收。
③该类对应的java.lang.class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

实际上,很难同时满足这三个条件。如类加载器这一条,JVM所创建的3个默认的类加载器是不会被回收的,即只有自己写的类加载器才有可能会被回收。但除非有特殊需求,大部分情况下,我们并不会为每一个用户类实现对应的类加载器。这也意味着绝大部分的类都不会在方法区被卸载并回收。

所以,实际上垃圾回收的重点就是堆区。

2、如何判断垃圾

2.1、引用计数算法

垃圾回收操作应该有如下两种行为:

①判断那些对象属于垃圾。
②将判断为垃圾的对象清除。

首先是判断对象是否存活(是否已成为垃圾)。

在堆中存放着几乎所有的Java实例,在GC执行垃圾回收时,首先需要区分出那些实例是存活的对象,那些是已经死亡的对象。只有被标记为已经死亡的对象,GC才会在执行垃圾回收时,释放掉其所占用的内存空间。

当一个对象已经不再被任何存活的对象继续引用时,就可称之为死亡对象,即垃圾。判断对象是否已死一般有两种方式:引用计数算法和可达性算法。

引用计数算法(Reference counting),其具体实现就是,对每个对象保存一个整型的引用计数器属性,被引用几次就将该属性设为这个值,用于记录对象被引用的情况。

比如,对于对象A,只要有任何一个对象引用了A,则A的引用计数器就加一。当引用失效时,引用计数器的值就减一。若对象A的引用计数器值为0,即表示对象A不被使用,可以进行回收。

引用计数器的优点:
实现简单,垃圾对象便于标识,效率高,回收也没有延迟。

引用计数器的缺点:
①每个对象都会有引用计数器字段,这样的做法增加了存储空间的开销。
②每次引用的变化都需要更新引用计数器,加法和减法的操作又增加了时间开销。
③就是引用计数器最严重的缺陷,即无法处理循环引用的对象。

比如,有对象ObjA和ObjB,这两个对象都有一个属性Object instance;若令ObjA.instance = ObjB;,ObjB.instance = ObjA; 。那么ObjA和ObjB的引用计数器的值就始终无法为0(因为始终有一个引用指向他们),这就意味着,即使已经没有其他对象引用ObjA和ObjB了。这两个对象也无法被回收,这将会导致内存泄漏。

有代码如下,

public class ReferenceCountTest {
    //成员变量,没有static,即非类独有,每个对象一份。作用就是占内存
    private byte[] bigSize = new byte[5 * 1024 * 1024]; //5MB

    Object ref = null;

    public static void main(String[] args) {

        ReferenceCountTest obj1 = new ReferenceCountTest();
        ReferenceCountTest obj2 = new ReferenceCountTest();

        /**
         * 互相引用,则两个对象中的ref属性都保存着另一个对象的引用
         */
        obj1.ref = obj2;
        obj2.ref = obj1;

        /**
         * 此时,将引用变量obj1和obj2都置为空,
         * 则在当前线程的虚拟机栈中,再无变量引用刚new出来的两个对象
         */
        obj1 = null;
        obj2 = null;

        /**
         * 此时,两个对象在栈中的引用已经为空,即除了在堆中依然保留着互相引用外,
         * 再无任何引用指向它们,故应该被判定为垃圾。
         *
         * 1、先不显式地执行GC,看堆区中的占用情况
         * 2、显式地执行垃圾回收,再看堆区中的占用情况
         * 使用虚拟机参数打印出GC细节:-XX:+PrintGCDetaile
         */
        System.gc();
    }
}

则在为引用类型obj1和obj2赋值,以及为他们所指向的对象的属性ref赋值后,他们在内存中关系图如下,
在这里插入图片描述

在将obj1和obj2置为null后,main方法中的obj1和obj2的引用断开,示例图如下,

在这里插入图片描述

此时,除了堆中的ReferenceCountTest类对象实例1和实例2互相引用外,已经再无任何引用指向他们。按理来说,此时的对象实例1和实例2都应该被回收,但由于这两个实例对象中的属性ref的值仍然保存着对方的地址,故引用计数器的值依然为1。则意味着这两个对象无法回收,这是引用计数算法的最大的缺陷。

其实,引用计数算法在极端情况下,也有很高的延迟性。比如,在对象连环引用的情况下:若有引用指向对象A,而对象A又指向对象B,B又指向C,C又指向D。。。;如此情况下,如果指向对象A的引用消失,那么将引发连环的回收反应。而只有上一个对象被回收,它指向的下一个对象才能在下一次的GC中被判断为垃圾回收,这就有了延迟性,引用计数算法就显得不那么及时。

目前主流的JVM都没有采用引用计数器算法。

2.2、可达性分析算法

当前主流的商业语言,如Java、C#等都采用了可达性分析算法来判断对象是否存活。这种类型的垃圾收集通常叫做追踪性垃圾收集(Tracing Garbage Collection)。

相对于引用计数算法,可达性分析算法不仅也有简单高效的特点,重要的是该算法可以有效解决循环引用的问题。

可达性分析算法的基本思路如下,
以根对象(GC Roots)集合为起点,按照从上到下的方式搜索被根对象集合所连接的目标对象是否可达。在可达性算法中,内存中的存活对象都会被根对象集合直接或间接的连接,而死亡的对象则不会被连接。

示意图如下,
在这里插入图片描述

可以看出object7、8、9、10都没有再被根对象集合里的对象直接或者间接引用,故都被判断为垃圾对象。但并不是被判断为垃圾对象就必然会被回收,实际上还有机会通过finalization机制,重新被判断为存活对象,这将在后面介绍。

使用可达性分析算法后,存活对象都会被GC Roots集合直接或间接连接着,搜索存活对象时走过的路径被称为引用链(Reference Chian),没有被引用链相连的对象就是垃圾对象。

GC Roots

我们知道只有被GC Roots集合引用的对象才会被判定为存活对象,那么GC Roots集合中又包含了什么样的对象呢?

在Java语言中,GC Roots集合包含以下的元素:

  • 1、虚拟机栈中引用的对象
    如各个线程被调用的,方法中的引用类型的参数、引用类型的局部变量等,这些引用都保存在虚拟机栈对应栈帧的局部变量表中。这些引用指向的对象都会被视为GC Roots中的对象。
  • 2、本地方法栈中JNI(即native方法)所引用的对象
    这些对象被传入本地方法中进行调用,且都还没有进行释放。
  • 3、类静态属性引用的对象
    类静态属性属于类,它随着类的生命周期存在,而类是很少被回收的(类的回收条件刚才已提到)。如果类静态属性是一个引用类型,并且该引用指向一个对象,那么该对象也会被加入到GC Roots中。
  • 4、常量所引用的对象
    运行时常量池中常量所引用的对象,字符串常量池中的引用所指向的对象。
  • 5、所有被同步锁(Synchronized关键字)所持有的对象。
  • 6、JVM内部的引用
    如类的class对象,又如一些异常对象(NullPointerException、OutOfMemoryError等)

除了上述这些固定的GC Roots集合外,根据用户所选择的垃圾收集器以及当前回收的内存区域的不同外,还会选择其他对象“临时加入”到GC Roots。

比如,在只针对新生代的回收中,可能在老年代中有些对象引用了新生代中的对象,为了避免这些被老年代中的对象所引用的新生代对象被回收,所以需要将与新生代中有关联的老年代中的对象也临时加入到GC Roots中。

应用可达性算法的注意点

如果要使用可达性分析来判断对象是否可回收,那么分析工作必须在一个保证一致性的快照中进行。这点不满足,分析结果就不会准确。

意思是,在进行可达性分析的期间,系统必须停止,不能出现在分析过程中,对象的引用关系还在不断变化的现象。所谓的一致性快照就是,在JVM运行的某个时间点进行记录,记录此时间点JVM的所有状态。然后才能根据这个快照进行可达性分析。这也是为什么GC时必须进行“Stop the Word”(系统暂停)的重要原因。

3、对象的finalization机制

Java语言提供了对象的终止(finalization)机制来允许开发人员提供对象被销毁之前的自定义逻辑处理。实现这个机制的finalize方法在Object类中,由于Object类是所有类的父类或祖先类,同时finalize方法允许在子类中被重写,所以实际上每个类都可以实现finalize方法。

对象的finalization机制就是:
经过可达性分析后,如果某个对象无法从所有的根对象访问,那么说明这个对象已经不再被使用了。此时就会对这些不再被使用的对象进行第一次标记。然后,会查看这些无法到达的对象是否实现了finalize方法:

  • 如果对象没有实现finalize方法,则直接可以进行回收。

  • 如果对象实现了finalize方法,那么就会将对象的finalize方法交给Finalizer线程来执行(一个由虚拟机创建的,低优先级的后台线程)。GC会对这些交给Finalizer线程执行后的对象进行第二次标记,如果在finalize方法中,对象又重新与GC Roots进行了关联,比如将自己(this关键字)赋值给某个引用链上的对象的属性(如objectA = this;),那么对象将会重新存活 ,即该对象会被移出将要进行回收的集合中。如果没有在finalize方法中复活自己,则会被第二次标记,此时对象才可以直接被回收。

Finalizer线程:
Finalizer线程是一个后台线程,用于执行finalize方法。所有实现了finalize方法的对象都会被放在一个F-Queue队列中,Finalizer线程会去运行这个队列。Finalizer线程执行完队列中的一个元素,则线程中虚拟机栈存储的这个对象的引用就会被释放,此时GC就可以根据该对象是否还与引用链相连接,来进行第二次标记,并决定是否回收这个对象。

我们不应该主动去实现finalize方法,因为:

①在Finalizer线程执行时,如果线程执行缓慢(比如某个对象的finalize方法有大量的循环),那么其他的finalize方法就会一直处于等待状态。这也意味着含有finalize方法的对象会一直被Finalizer线程所引用,那么GC就无法回收这些对象。finalize方法会影响GC的效率,尤其是大量的finalize方法或者一个糟糕的finalize方法(如前面说的大量循环)。

②我们想在finalize方法中实现某种操作,比如关闭连接,但是finalize方法的执行时间是没有保证的。Finalizer线程是一个优先级很低的线程,则意味着不会马上执行,它何时执行完全由GC线程决定,即在进行GC时,才会把finalize方法交给Finalizer线程去执行。如果没有进行GC那么Finalizer线程就不会运行。则我们的操作就由于执行时间的不确定而给程序带来隐患。

所以强烈建议不再使用finalize方法!!

4、垃圾收集算法

4.1、标记-清除算法

在经过可达性算法分析和finalization机制后,一个对象是否存活已经能够判断出来了。那么接下来的操作就是回收死亡对象的内存。目前在JVM中,常见的垃圾收集算法有三种,分别是①标记-清除算法②标记-复制算法③标记-整理算法。

标记-清除算法是一种非常基础和常见的垃圾收集算法,其执行过程如下:
当堆中的有效空间(available memory)被耗尽时,就会停止整个程序(STW),然后执行标记和清除的操作。

标记:采用可达性分析算法,从引用根节点遍历,标记所有被引用的对象,一般是在对象的对象头(Header)中记录为可达对象。

清除:对堆内存中的所有对象进行从头到尾的线性遍历,如果发现某个对象在其对象头中没有被标记为可达对象,则就将该对象所占用的内存回收。

图示如下,
在这里插入图片描述
可以看出,标记清除算法的优点就是简单易实现,但其缺点也同样很明显:
①效率不算高,因为标记和清除都要进行遍历,这也意味着标记和清除两个过程都会因为对象的增加而效率下降。
②这种方式清理出来的空闲空间是不连续的,产生了内存碎片问题。故需要维护一个空闲列表,才能知道新对象该如何分配内存。而碎片问题可能会导致,即使内存空间足够,大对象依然有可能无法存放的问题。

注意:
在垃圾回收中所谓的清除,并不是真的把对应的内存置空,而是把需要清除的对象地址保存在空闲的地址列表中,等有新对象需要分配内存空间时,会判断垃圾对象的位置空间是否足够。若足够,则分配给新对象。

4.2、标记-复制算法

为了解决标记-清除算法的缺陷,研究出了标记-复制算法。

其核心思想如下:
将内存空间分为大小相等的两块,每次只使用其中的一块。在垃圾回收时,将正在使用的内存块中标记为存活的对象复制到未被使用的内存块中,然后一次性清理正在使用的内存块中的所有对象,交换两个内存块的角色,完成垃圾回收。

图示如下,
在这里插入图片描述
复制算法的优点有:
①复制过去后保证了空间的连续性,不会出现“碎片问题”。
②实现比较简单,不需要空闲链表的存在,直接移动指针分配内存,所以效率很高。

复制算法的缺点有:
①可用内存空间缩小了一半,浪费了原来的内存
②由于需要复制对象至另一半空间,故有一定的空间开销
③因为对象地址空间被改变,所以在复制过去后,还用花费一定的时间开销来维护对象之间的引用关系。比如,如果栈中的引用指向了堆中某块内存,经过复制算法后,还要把这个引用进行修改才行。

特别地,当存活的对象很多时,复制算法的效率就会降低,因为无论是复制对象本身的开销还是维护对象间引用的开销都会提高。所以,复制算法要在垃圾对象多,而存活对象少的情况下才能发挥出优势,否则光是复制对象就耗费了许多性能。

目前标记-复制算法主要应用在新生代中。

在新生代中,对常规的应用程序进行垃圾回收时,通常一次可以回收70%-99%的内存空间,回收性价比很高。所以现在的商业虚拟机(如HotSpot)都是采用复制算法来回收新生代。

在这里插入图片描述

4.3、标记-整理算法

复制算法的高效性是建立在存活对象少,垃圾对象多的前提下的。这种情况在新生代经常发生,但在老年代,更常见的情况是大部分对象都是存活对象。如果依然使用复制算法,由于存活对象较多,复制的成本也很高。因此,基于老年代垃圾回收的特性,需要其他算法。

标记-清除算法也可以应用在老年代中,但是该算法执行完内存回收还会产生内存碎片,故需要在标记-清除算法上进行改进,由此研究出了标记-整理算法。

标记-整理算法的基本过程如下:

第一阶段:即标记阶段,与标记-清除算法一样,从根节点开始标记所有被引用的对象。

第二阶段:将所有存活对象压缩(移动)到内存的一端,按顺序排放。

最后,清理边界外所有的空间。

实际上,标记-整理算法的最终效果等同于标记-清除算法执行完成后,再进行一次内存碎片的整理。二者的本质差异在于,标记-清除算法是非移动式的回收算法,而标记-整理算法是移动式的。

在这里插入图片描述
可以看到,被标记的存活对象将被整理,按照内存地址依次排列,而未被标记的内存将被清理掉。如此一来,当我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可,这比标记-清除算法需要维护一个空闲列表显然少了许多开销。但是由于还要移动对象,所以实际上标记-整理算法的执行效率低于标记-清除算法。

标记-整理算法的优点有:
①消除了标记-清除算法中产生的碎片问题。我们需要给新对象分配内存时,只需要一个内存的起始地址即可。
②消除了复制算法中,内存减半的高额代价。

标记-整理算法的缺点有:
①从效率上看,标记-整理算法要低于复制算法和标记-清除算法。
②移动对象的同时,如果对象被其他对象引用,则还要调整引用地址
③移动过程中,需要全程暂停用户的应用程序(STW)

三种算法的对比

标记-整理算法(Mark-Compact) 标记-清除算法(Mark-Sweep) 标记-复制算法(Mark-Copying)
速度 最慢 中等 最快
空间开销 少,不堆积碎片 少,堆积碎片 多,通常需要存活对象的2倍大小,不堆积碎片
移动对象

所以没有最优的算法,主要是看应用场景。

4.4、分代收集算法

前面提到的三种垃圾收集算法,并没有哪一种能完全取代其他算法,它们都具有各自的优势和特点。同样的这三种算法都无法对所有类型(长生命周期、短生命周期、大对象、小对象)的对象进行回收。因此,根据不同类型的死亡对象,采用不同的垃圾收集算法,这样的算法应用被称为分代收集算法(Generational Collection),严格来说分代收集算法应该是一种垃圾收集的理论。

分代收集算法基于这样一个事实:不同对象的生命周期不同,因此不同生命周期的对象可以采用不同的收集方式,以便提高回收效率。分代收集算法根据对象的不同类型将内存划分为不同的区域,一般将堆划分为新生代和老年代。

在Java程序运行中,会产生大量的对象,其中有些对象是与业务息息相关,比如Http请求中的Session对象,线程、Socket连接,这些对象跟业务直接挂钩,因此生命周期较长。而有些对象的生命周期则较短,如String对象,由于其不可变的特性,系统会产生大量这些对象,有些对象甚至只使用一次即可回收。因此,使用分代垃圾收集算法,性价比最好。

目前,几乎所有的垃圾收集器都采用了分代收集算法执行垃圾回收。

  • 在堆区中新生代的特点是:区域相对老年代较小,对象生命周期短,存活率低,垃圾回收频繁。

在这种情况下,复制算法的回收整理速度是最快的。复制算法的效率只和当前存活对象的多少有关,因此很适合新生代的回收。而复制算法内存利用率不高的问题,通过两个survivor区的设计得到了缓解。默认情况下,新生代和老年代在堆中的比例是1:2。而新生代中Eden区和两个survivor区的比例为8:1:1,所以实际上只有新生代内存中的1/10来作为复制算法所需的空闲区域,因此浪费的内存空间并不算大。

  • 在堆中老年代的特点是:区域较大,对象生命周期长,存活率高,回收不如新生代频繁。

在这种情况下,会存在大量的存活对象,复制算法明显不合适。故一般是由标记-清除算法来实现或者是由标记-清除算法和标记-整理算法混合实现。原因如下:
①标记阶段的开销实际上是与存活对象的数量成正比(因为要遍历所有对象)
②清除阶段的开销与所管理的区域的大小成正比(因为要遍历所管理的内存区域)
③压缩阶段的开销与存活独享的数量成正比(因为要移动对象)

分代思想被现有的虚拟机广泛使用。几乎所有的垃圾回收器都会区分新生代和老年代。

4.5、增量收集算法

上述的算法在垃圾回收过程中都不可避免的处于一种Stop The World 的状态。在STW状态下,程序所有的用户线程都会挂起,暂停一切正常工作,等待垃圾回收的完成,如果垃圾回收时间过长,应用程序被挂起很久,将严重影响用户体验或者系统的稳定性。为了解决这一问题,即对实时垃圾收集算法的研究直接导致了增量收集(Incremental Collecting)算法的出现。

增量收集算法的基本思想如下:
如果一次性将所有的垃圾进行处理,需要造成系统长时间的停顿,那么可以让垃圾收集线程和应用线程交替执行。每次,垃圾收集线程只收集一小片区域的内存空间,接着切换到应用程序线程。如此反复,直到垃圾收集完成。

增量收集算法的基础仍然是传统的标记-清除和复制算法。增量收集算法通过对线程间冲突的处理,允许垃圾收集线程以分阶段的方式完成垃圾标记、清理或者复制工作。

增量收集算法的优点有:
使用这种方式,由于在垃圾回收过程中,间断性地还执行了应用程序代码,故减少了系统的停顿时间。

增量收集算法的缺点有:
因为线程切换和上下文转换的消耗,会使得垃圾回收的总体成本上升,造成系统吞吐量的下降。

4.6、分区算法

一般来说,在相同条件下,堆空间越大,一次GC时所需要的的时间就越长,有关GC产生的停顿也就越长。为了更好地控制GC产生的停顿时间,将一块大的内存区域分割为多个小块,根据目标的停顿时间,每次合理的回收若干小块,而不是整个堆空间,从而减少一次GC产生的停顿。

分代算法按照对象的生命周期长短划分为两个部分,分区算法将堆空间划分成连续的不同小区域。
每一块小区域都独立使用,独立回收。这种算法的好处是可以控制一次回收多少个小区间。

posted @ 2020-11-17 16:35  就行222  阅读(770)  评论(1编辑  收藏  举报