学习JVM--垃圾回收

Posted 撸起袖子加油写BUG

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了学习JVM--垃圾回收相关的知识,希望对你有一定的参考价值。

1. 前言

  Java和C++之间显著的一个区别就是对内存的管理。和C++把内存管理的权利赋予给开发人员的方式不同,Java拥有一套自动的内存回收系统(Garbage Collection,GC)简称GC,可以无需开发人员干预而对不再使用的内存进行回收管理。

  垃圾回收技术(以下简称GC)是一套自动的内存管理机制。当计算机系统中的内存不再使用的时候,把这些空闲的内存空间释放出来重新投入使用,这种内存资源管理的机制就称为垃圾回收。

  其实GC并不是Java的专利,GC的的发展历史远比Java来得久远的多。早在Lisp语言中,就有GC的功能,包括其他很多语言,如:Python(其实Python的历史也比Java早)也具有垃圾回收功能。

  使用GC的好处,可以把这种容易犯错的行为让给计算机系统自己去管理,可以防止人为的错误。同时也把开发人员从内存管理的泥沼中解放出来。

  虽然使用GC虽然有很多方便之处,但是如果不了解GC机制是如何运作的,那么当遇到问题的时候,我们将会很被动。所以有必要学习下Java虚拟机中的GC机制,这样我们才可以更好的利用这项技术。当遇到问题,比如内存泄露或内存溢出的时候,或者垃圾回收操作影响系统性能的时候,我们可以快速的定位问题,解决问题。

  接下来,我们来看下JVM中的GC机制是怎么样的。

2. 哪些内存可以回收

  首先,我们如果要进行垃圾回收,那么我们必须先要识别出哪些是垃圾(被占用的无用内存资源)。

  Java虚拟机将内存划分为多个区域,分别做不同的用途。简单的将,JVM对内存划分为这几个内存区域:程序计数器、虚拟机栈、本地方法栈、Java堆和方法区。其中程序计数器、虚拟机栈和本地方法栈是随着线程的生命周期出生和死亡的,所以这三块区域的内存在程序执行过程中是会有序的自动产生和回收的,我们可以不用关心它们的回收问题。剩下的Java堆和方法区,它们是JVM中所有线程共享的区域。由于程序执行路径的不确定性,这部分的内存分配和回收是动态进行的,GC主要关注这部分的内存的回收。 

  对像实例是否是存活的,有两种算法可以用于确定哪些实例是死亡的(它们占用的内存就是垃圾),那么些实例是存活的。第一种是引用计数算法:

2.1 引用计数算法

  引用计数算法会对每个对象添加一个引用计数器,每当一个对象在别的地方被引用的时候,它的引用计数器就会加1;当引用失效的时候,它的引用计数器就会减1。如果一个对象的引用计数变成了0,那么表示这个对象没有被任何其他对象引用,那么就可以认为这个对象是一个死亡的对象(它占用的内存就是垃圾),这个对象就可以被GC安全地回收而不会导致系统出现问题。

  我们可以发现,这种计数算法挺简单的。在C++中的智能指针,也是使用这种方式来跟踪对象引用的,来达到内存自动管理的。引用计数算法实现简单,而且判断高效,在大部分情况下是一个很好的垃圾标记算法。在Python中,就是采用这种方式来进行内存管理的。但是,这个算法存在一个明显的缺陷:如果两个对象之间有循环引用,那么这两个对象的引用计数将永远不会变成0,即使这两个对象没有被任何其他对象引用。

public class ReferenceCountTest {

    public Object ref = null;

    public static void main(String ...args) {
        ReferenceCountTest objA = new ReferenceCountTest();
        ReferenceCountTest objB = new ReferenceCountTest();

        // 循环引用 objA <--> objB
        objA.ref = objB;
        objB.ref = objA;
        
        // 去除外部对这两个对象引用
        objA = null;
        objB = null;

        System.gc();
    }
}

上面的代码就演示了两个对象之间出现循环引用的情况。这个时候objA和objB的引用计数都是1,由于两个对象之间是循环引用的,所以它们的引用计数将一直是1,而即使这两个对象已经不再被系统所使用到。

由于引用计数这种算法存在这种缺陷,所以就有了一种称为“可达性分析算法”的算法来标记垃圾对象。

2.2 可达性分析算法

  通过可达性分析算法来判断对象存活,可以克服上面提到的循环引用的问题。在很多编程语言中都采用这种算法来判断对象是否存活。

  这种算法的基本思路是,确定出一系列的称为“GC Roots”的对象,以这些对象作为起始点,向下搜索所有可达的对象。搜索过程中所走过的路径称为“引用链”。当一个对象没有被任何到“GC Roots”对象的“引用链”连接的时候,那么这个对象就是不可达的,这个对象就被认为是垃圾对象。

  从上面的图中可以看出,object1~4这4个对象,对于GC Roots这个对象来说都是可达的。而object5~7这三个对象,由于没有连接GC Roots的引用链,所以这三个对象时不可达的,被判定为垃圾对象,可以被GC回收。

  在Java中,可以作为GC Roots的对象有以下几种:

  • 虚拟机栈中的本地变量表中引用的对象,也就是正在执行函数体中的局部变量引用的对象。
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中(Native方法中)引用的对象  

2.3 怎么判一个对象"死刑"

  当通过可达性分析算法判定为不可达的对象,我们也不能断定这个对象就是需要被回收的。当我们需要真正回收一个对象的时候,这个对象必须经历至少两次标记过程:

  当通过可达性分析算法处理以后,这个对象没有和GC Roots相连的引用链,那么这个对象就会被第一次标记,并判断对象的finalize()方法(在Java的Object对象中,有一个finalize()方法,我们创建的对象可以选择是否重写这个方法的实现)是否需要执行,如果对象的类没有覆盖这个finalize()方法或者finalize()已经被执行过了,那么就不需要再执行一次该方法了。

  如果这个对象的finalize()方法需要被执行,那么这个对象会被放到一个称为F-Queue的队列中,这个队列会被由Java虚拟机自动创建的一个低优先级Finalizer线程去消费,去执行(虚拟机只是触发这个方法,但是不会等待方法调用返回。这么做是为了保证:如果方法执行过程中出现阻塞,性能问题或者发生了死循环,Finalizer线程仍旧可以不受影响地消费队列,不影响垃圾回收的过程)队列中的对象的finalize()方法。

  稍后,GC会对F-Queue队列中的对象进行第二次标记,如果在这次标记发生的时候,队列中的对象确实没有存活(没有和GC Roots之间有引用链),那么这个对象就确定会被系统回收了。当然,如果在队列中的对象,在进行第二次标记的时候,突然和GC Roots之间创建了引用链,那么这个对象就"救活"了自己,那么在第二次标记的时候,这个存活的对象就被移除出待回收的集合了。所以,通过这种两次标记的机制,我们可以通过在finalize()方法中想办法让对象重新和GC Roots对象建立链接,那么这个对象就可以被救活了。

  下面的代码,通过在finalize()方法中将this指针赋值给类的静态属性来"拯救"自己:

public class FinalizerTest {
    private static Object HOOK_REF;

    public static void main(String ...args) throws Exception {
        HOOK_REF = new FinalizerTest();

        // 将null赋值给HOOK_REF,使得原先创建的对象变成可回收的对象
        HOOK_REF = null;
        System.gc();
        Thread.sleep(1000);

        if (HOOK_REF != null) {
            System.out.println("first gc, object is alive");
        } else {
            System.out.println("first gc, object is dead");
        }

        // 如果对象存活了,再次执行一次上面的代码
        HOOK_REF = null;
        System.gc();
        if (HOOK_REF != null) {
            System.out.println("second gc, object is alive");
        } else {
            System.out.println("second gc, object is dead");
        }
    }

    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        // 在这里将this赋值给静态变量,使对象可以重新和GC Roots对象创建引用链
        HOOK_REF = this;
        System.out.println("execute in finalize()");
    }
}

#output:

  execute in finalize()
  first gc, object is alive
  second gc, object is dead

  可以看到,第一次执行System.gc()的时候,通过在方法finalize()中将this指针指向HOOK_REF来重建引用链接,使得本应该被回收的对象重新复活了。而对比同样的第二段代码,没有成功拯救的原因是:finalize()方法只会被执行一次,所以当第二次将HOOK_REF赋值为null,释放对对象的引用的时候,由于finalize()方法已经被执行过一次了,所以没法再通过finalize()方法中的代码来拯救对象了,导致对象被回收。

3. 怎么回收内存

  上面我们已经知道了怎么识别出可以回收的垃圾对象。现在,我们需要考虑如何对这些垃圾进行有效的回收。垃圾收集的算法大致可以分为三类:

  1. 标记-清除算法
  2. 标记-复制算法
  3. 标记-整理算法

  这三种算法,适用于不同的回收需求和场景。下面,我们来一一介绍下每个回收算法的思想。

3.1 标记-清除算法

  "标记-清除"算法是最基础的垃圾收集算法。标记-清除算法在执行的时候,分为两个阶段:分别是"标记"阶段和"清除"阶段。

  在标记阶段,它会根据上面提到的可达性分析算法标记出哪些对象是可以被回收的,然后在清除阶段将这些垃圾对象清理掉。

  算法思路很简单,但是这个算法存在一些缺陷:首先标记和清除这两个过程的效率不高,其次是,直接将标记的对象清除以后,会导致产生很多不连续的内存碎片,而太多不连续的碎片会导致后续分配大块内存的时候,没有连续的空间可以分配,这会导致不得不再次触发垃圾回收操作,影响性能。

  

3.2 复制算法

  复制算法,顾名思义,和复制操作有关。该算法将内存区域划分为大小相等的两块内存区域,每次只是用其中的一块区域,另一块区域闲置备用。当进行垃圾回收的时候,会将当前是用的那块内存上的存活的对象直接复制到另外一块闲置的空闲内存上,然后将之前使用的那块内存上的对象全部清理干净。

  这种处理方式的好处是,可以有效的处理在标记-清除算法中碰到的内存碎片的问题,实现简单,效率高。但是也有一个问题,由于每次只使用其中的一半内存,所以在运行时会浪费掉一半的内存空间用于复制,内存空间的使用率不高。 

3.3 标记-整理算法

  标记-整理算法,思路就是先进行垃圾内存的标记,这个和标记-清除算法中的标记阶段一样。当将标记出来的垃圾对象清除以后,为了避免出现标记-清除算法中碰到的内存碎片问题,标记-整理算法会对内存区域进行整理。将当前的所有存活的对象移动到内存的一端,将一端的空闲内存整理出来,这样就可以得到一块连续的空闲内存空间了。

  这样做,可以很方便地申请新的内存,只要移动内存指针就可以划出需要的内存区域以存放新的对象,可以在不浪费内存的情况下高效的分配内存,避免了在复制算法中浪费一部分内存的问题。

4. 分代收集

  在现代虚拟机实现中,会将整块内存划分为多个区域。用"年龄"的概念来描述内存中的对象的存活时间,并将不同年龄段的对象分类存放在不同的内存区域。这样,就有了我们平时听说的"年轻代"、"老年代"等术语。

  顾名思义,"年轻代"中的对象一般都是刚出生的对象,而"老年代"中的对象,一般都是在程序运行阶段长时间存活的对象。将内存中的对象分代管理的好处是,可以按照不同年龄代的对象的特点,使用合适的垃圾收集算法。

  对于"年轻代"中的对象,由于其中的大部分对象的存活时间较短,很多对象都撑不过下一次垃圾收集,所以在年轻代中,一般都使用"复制算法"来实现垃圾收集器。

  在上图中,我们可以看到"Young Generation"标记的这块区域就是"年轻代"。在年轻代中,还细分了三块区域,分别是:"eden"、"S0"和"S1",其中"eden"是新对象出生的地方,而"S0"和"S1"就是我们在复制算法中说到了那两块相等的内存区域,称为存活区(Survivor Space)。

  这里用于复制的区域只是占用了整个年轻代的一部分,由于在新生代中的对象大部分的存活时间都很短,所以如果按照复制算法中的以1:1的方式来平分年轻代的话,会浪费很多内存空间。所以将年轻代划分为上图中所示的,一块较大的eden区和两块同等大小的survivor区,每次只使用eden区和其中的一块survivor区,当进行内存回收的时候,会将当前存活的对象一次性复制到另一块空闲的survivor区上,然后将之前使用的eden区和survivor区清理干净,现在,年轻代可以使用的内存就变成eden区和之前存放存活对象的那个survivor区了,S0和S1这两块区域是轮替使用的。

  HotSpot虚拟机默认Eden区和其中一块Survivor区的占比是8:1,通过JVM参数"-XX:SurvivorRatio"控制这个比值。SurvivorRatio的值是一个整数,表示Eden区域是一块Survivor区域的大小的几倍,所以,如果SurvivorRatio的值是8,那么Eden区和其中Survivor区的占比就是8:1,那么总的年轻代的大小就是(Eden + S0 + S1) = (8 + 1 + 1) = 10,所以年轻代每次可以使用的内存空间就是(Eden + S0) = (8 + 1)  = 9,占了整个年轻代的 9 / 10 = 90%,而每次只浪费了10%的内存空间用于复制。

  并不是留出越少的空间用于复制操作越好,如果在进行垃圾收集的时候,出现大部分对象都存活的情况,那么空闲的那块很小的Survivor区域将不能存放这些存活的对象。当Survivor空间不够用的时候,如果满足条件,可以通过分配担保机制,向老年代申请内存以存放这些存活的对象。

  对于老年代的对象,由于在这块区域中的对象和年轻代的对象相比较而言存活时间都很长,在这块区域中,一般通过"标记-清理算法"和"标记-整理算法"来实现垃圾收集机制。上图中的Tenured区域就是老年代所在的区域。而最后那块Permanent区域,称之为永久代,在这块区域中,主要是存放类对象的信息、常量等信息,这个区域也称为方法区。在Java 8中,移除了永久区,使用元空间(metaspace)代替了。

5. 总结

   在这篇文章中,我们首先介绍了采用最简单的引用计数法来跟踪垃圾对象和通过可达性分析算法来跟踪垃圾对象。然后,介绍了垃圾回收中用到的三种回收算法:标记-清除、复制、标记-整理,以及它们各自的优缺点。最后,我们结合上面介绍的三种回收算法,介绍了现代JVM中采用的分代回收机制,以及不同分代采用的回收算法。

以上是关于学习JVM--垃圾回收的主要内容,如果未能解决你的问题,请参考以下文章

JVM学习:垃圾回收

JVM系统学习之路常见垃圾回收器

JVM学习--垃圾回收器

03 JVM 从入门到实战 | 简述垃圾回收算法

jvm学习笔记一(垃圾回收算法)

JVM内存管理和JVM垃圾回收机制