编程高手必学的内存知识

19 | 垃圾回收:如何避免内存泄露?

海纳

华为编译器高级专家,原Huawei JDK团队负责人

你好,我是海纳。

从这节课开始,我们进入一个新的主题,那就是垃圾回收。对于C/C++程序员来说,内存错误是一个非常头疼的问题,常见的错误有内存泄露、悬空指针等。这使得程序员在写程序时必须很小心地申请内存,并且在适当的时候释放。但即便是很优秀的程序员,也很难保证每次申请、释放内存的操作和时机都是正确的。

为了使得程序员将注意力集中在业务逻辑而不是内存管理,于是自动内存管理技术就诞生了,它也被称为垃圾回收技术(Garbage Collection,GC)

垃圾回收技术降低了程序员的心智负担,将程序员从繁重的内存管理工作中解放出来,这才使得淘宝这样的大型应用成为可能。但是随着业务越来越复杂,GC算法中固有的停顿造成业务卡顿等问题也变得越来越严重。在一些时延敏感型业务中,业务响应时间和GC停顿的矛盾就更加突出。所以,理解GC算法的基本原理并对其加以优化,是现代Java程序员的一项必备技能。

接下来的几节课,我们就来学习各种具体的GC算法。这节课我会先介绍GC算法中的基本概念,在这个基础上,我再带你深入了解一类重要的GC算法:引用计数法。通过这节课的学习,你将掌握垃圾回收这个重要话题相关的基本概念,了解GC算法的简单分类和算法评价标准。

我们先从垃圾回收的基本概念讲起吧。

垃圾回收的基本概念和定义

在GC算法的学习过程中,一个比较大的挑战是概念和术语比较多。在这个专栏里,我会根据需要把专用概念逐个加以介绍,我们先从最简单的开始。

GC算法中最重要的两个角色就是Mutator和Collector

  • Mutator

Mutator的本意是改变者。这个词所表达的是通过程序来改变对象之间的引用关系。因为我们所写的所有Java程序,都有可能改变对象的存活和死亡状态,以及它们之间的引用关系,那么这些Java程序就是Mutator。因为Java程序运行所在的这些线程,我们也称为业务线程,所以在某些情况下,Mutator和业务线程这两个术语是可以混用的。在这节课中,我们使用Mutator这个术语。

  • Collector

Collector用于回收空间中的垃圾,所以叫做收集者。根据不同的GC算法,Collector不仅仅是收集,例如在Mark-Sweep中,它还负责标记存活对象、识别垃圾对象。执行Collector的线程,我们一般称为Collector线程或者GC线程。在某些GC算法中,业务线程也有可能帮助做垃圾回收的工作。所以,Mutator和Collector只是一种相对的说法,而不是精确的概念

然后,我们还需要理解Java堆的结构,以及如何描述堆中的对象,以便于设计GC算法。

在软件篇,我们已经详细讨论过Java进程的内存布局了。在这里,我重点强调一下“Java堆”和软件篇中讲的“进程堆”的区别。进程堆是指进程中可以使用malloc和free进行分配和释放的一块用户态内存区域。而Java堆则专指创建普通Java对象的地方,这一段内存是由虚拟机所管理的

软件篇答疑里,我们也讲过,进程的堆的概念比Java堆的概念要大。进程堆还包括了Metaspace、JIT代码段等部分。明确了Java堆的概念以后,我们再来看看堆里的对象该如何进行描述呢?

假设有两个对象A和B,A引用了B对象,我们就从A向B绘制一条有向边。再把堆中的对象看成是结点,那么,堆里的对象和它的引用关系就可以使用图来表达了。这里有一个例子,可以帮助你理解,如下所示:

public class Main {
    public static void main(String args[]){
        try {
            A a = new A();
            a.b = new B();
            a.c = new C();
            a.b.a = a;
            a.b.c = a.c;
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}
class A {
    public B b;
    public C c;
}
class B {
    public A a;
    public C c;
}
class C {
    Runnable r;
}

上面代码的main函数中,创建了三个对象。我们按照上面所描述的办法,使用有向边把具有引用关系的对象表示出来,那么上面的程序就可以用下面的示意图来表示:

这样,我们就得到了一个有向图。接下来我们就可以借助图论的各种知识来处理自动内存管理问题了。如果我们在上述main方法的最后,再添加一行代码:

   a.b = new B();

那么,此时各个对象之间的引用关系就变成下图所示的样子:

可以看到,原来a对象引用着b对象,现在这个引用就指向了新的对象,我们记为b’对象。b对象指向a和c的引用并没有发生变化。

从这个图中,我们可以清楚地看到此时已经没有任何对象引用b对象了。在执行完这一句之后,我们在代码里也已经没有任何办法可以再次访问到b对象了。那么此时,b对象就变成了垃圾对象。而a对象还能使用栈上的一个变量继续访问它,所以它是一个活跃对象

将对象之间的引用关系抽象成图这种数据结构以后,我们就可以使用图算法来研究垃圾回收问题了。比如说,我们想找出所有活跃的对象,只需要从确定的活跃对象出发,对整个有向图进行遍历,遍历到的就是活跃对象,遍历不到的就是垃圾对象。

这里我们要先明确什么是“确定的活跃对象”。所有不在堆中,而指向堆里的引用都是根引用,根引用所指向的对象就是“确定的活跃对象”,这些对象是根对象。根引用的集合就是根集合。根集合是很多基于可达性分析的GC算法遍历的起点。例如:

public class Main {
    public static void main(String args[]){
        buildA();
        A objInMain = new A();
    }
    public static void buildA() {
        A objA = new A();
        B objB = new B();
        C objC = new C();
        objA.b = objB;
        objB.a = objA;
        objA.c = objC;
    }
}

在执行到 buildA 的时候,内存里的真实情况如下图所示:

buildA的栈帧里有三个变量objA、objB、objC,它们都有引用指向堆里,所以这三个引用都是根引用。当buildA的栈帧还存在的时候,这些引用就存在,那么堆里的这三个对象就是活跃对象。一旦buildA方法执行完了,它所对应的栈帧就会被销毁,上图中的三个引用就会消失,同时堆里的三个对象也就变成了垃圾对象。

我们定义了GC算法相关概念和定义后,我们再来看看GC算法的评价标准。

GC算法的评价标准

各种常用的GC算法,它们的特点各不相同,分别适用于不同的场景。要想搞清楚在什么情况下采用什么算法,我们就要先分析清楚GC算法的特点。具体来讲,常用的评价GC算法的标准有以下几条:

  1. 分配的效率:主要考察在创建对象时,申请空闲内存的效率;
  2. 回收的效率:它是指回收垃圾时的效率;
  3. 是否产生内存碎片:在讲解malloc的时候,我们讲过内存碎片。碎片是指活跃对象之间存在空闲内存,但这一部分内存又不能被有效利用。比如内存里有两块不连续的16字节空闲空间,此时分配器要申请一块32字节的空间,虽然总的空闲空间也是32字节,但由于它们不连续,不能满足分配器的这次申请。这就是碎片空间;
  4. 空间利用率:这里主要是衡量堆空间是否能被有效利用。比如基于复制的算法无论何时都会保持一部分内存是空闲的,那么它的空间利用率就无法达到100%,这是由算法本身决定的;
  5. 是否停顿:Collector在整理内存的时候会存在搬移对象的情况,因为修改指针是一种非常敏感的操作,有时候它会要求Mutator停止工作。是否需要Mutator停顿,以及停顿时长是多少,是否会影响业务的正常响应等。停顿时长在某些情况下是一个关键性指标;
  6. 实现的复杂度:有些算法虽然看上去很美妙,但因为其实现起来太复杂,代码难以维护,所以无法真正地商用落地。这也会影响到GC算法的选择。

接下来的课程中,我们就具体地研究各种GC算法,并用上面的6条评价准则对它们进行详细地分析。

GC算法大致上可以分为引用计数基于可达性分析的算法两大类。其中,可达性分析算法是当前GC算法研究的热点,也是我们自动内存管理篇的重点。在下一节课中,我们会对它进行详细地讲解。这节课,我们先简要介绍引用计数算法。

引用计数法是非常简单高效的,它依然被Python和Swift等语言所使用。而且,引用计数法也不仅仅用在垃圾回收这一个场景。在COM组件编程、Linux内核管理物理页面、C++智能指针等场景都能看到它的身影。虽然现在引用计数法已经不是工业界和学术界研究的重点,但是掌握它仍然具有重要的意义。

引用计数算法

GC算法一个重要的功能是要识别出内存中的哪些对象是垃圾,也就是不会再被使用到的对象。这里就涉及到了垃圾的定义,就是不再被其他对象所引用的对象。从这个定义出发,我们可以想办法统计一个对象是否被引用。如果它被引用的次数大于0,那它就是一个活跃对象;如果它被引用的次数为0,那它就是一个垃圾对象。

为了记录一个对象有没有被其他对象引用,我们可以在每个对象的头上添加一个叫“计数器”的东西,用来记录有多少其他对象引用了它。Mutator在执行的时候会改变这个计数值,比如以下代码:

A objA = new A();
B objB = new B();
objA.ref = objB;

执行到第三行的时候,它的引用关系如下图所示:

在这个代码中,第1行创建了一个对象,不妨记为对象a,objA这个变量指向对象a,所以这个对象的引用计数就是1;第2行创建了一个B对象,记为对象b,objB变量指向对象b,此时,它的引用计数为1;第3行objA的ref属性也指向对象b,所以对象b的引用计数最终会为2。

Mutator在运行中还会不断地修改对象之间的引用关系,我们知道,这种引用关系的变化都是发生在赋值的时候。例如,接着上面的例子,我们再执行这样一行代码:

objA.ref = null

那么从objA到objB的引用就消失了,也就是上图中,那个从A的ref指向B的箭头就消失了。

对于例子中的赋值语句,如果是Java语言,最终会被翻译成putfield指令,那么JVM在处理putfield指令的时候,就可以加上对引用计数的处理。描述这个过程的伪代码如下所示:

void do_oop_store(Value * obj, Value value) {
    inc_ref(&value);
    dec_ref(obj);
    obj = &value;
}
void inc_ref(Value * ptr) {
    ptr->ref_cnt++;
}
void dec_ref(Value * ptr) {
    ptr->ref_cnt--;
    if (ptr->ref_cnt == 0) {
        collect(ptr);
        for (Value * ref = ptr->first_ref; ref != null; ref=ref->next)
            dec_ref(ref);
    }
}

大多数语言虚拟机(例如Java虚拟机hotspot、CPython虚拟机等)都是使用C/C++来编写的,所以,我们这里的伪代码也使用C语言来描述。

上面的代码展示了在把 value 赋值给 obj 这个指针之前,我们必须先改变两个对象的引用计数。一个是obj指针原来指向的对象,它的引用计数要减一,另一个是value对象,它的引用计数加一。如果某个对象的引用计数为0,就把这个对象回收掉(collect方法负责回收内存,根据GC算法的不同,collect的实现会有所不同,所以这里我们只要知道它的功能即可,不必在意它的实现),然后把这个对象所引用的所有对象的引用计数减1。

我们在写一个对象的域的时候做了一些工作,就好比在更新对象域的时候,对这个动作进行了拦截。所以,GC中对这种特殊的操作起了一个比较形象的名字叫write barrier。这里的barrier是屏障,拦截的意思,中文翻译就是写屏障。你要注意与第15节课我们所讲的内存屏障加以区别。

那在do_oop_store里,可不可以先做减,后做加呢?就是说第2行和第3行的先后顺序换过来有没有影响呢?答案是不行。因为当obj和value是同一个对象的时候,如果先减后加的话,这个对象就会被回收,内存有可能会被破坏。从而,这个对象就有可能发生数据错误。

到这里,引用计数算法的基本原理就讲完了。接下来,我们就可以使用GC算法的评价标准来分析一下这种GC算法有什么样的优缺点,这样,我们就能知道它适合用在什么场景中了。

引用计数算法的优缺点

从算法描述中容易推知,引用计数具备以下优点:

  1. 可以立即回收垃圾。因为每个对象在被引用次数为0的时候,是立即就可以知道的,所以一旦一个对象成为垃圾,它将立即被释放;
  2. 没有暂停时间。对象的回收根本不需要另外的GC线程专门去做,业务线程自己就搞定了,所以引用计数算法不需要停顿时间。

同时,引用计数也存在以下缺点:

  1. 在每次赋值操作的时候都要做额外的计算。在多线程的情况下,为了正确地维护引用计数,需要同步和互斥操作,这往往需要通过锁来实现,这会对多线程程序性能带来比较大的损失;
  2. 会有链式回收的情况。比如多个对象对链表形式串在一起,它们的引用计数都为1,当链表头被回收时,整个链表都会回收,这可能会导致一次回收所使用的时间过长;
  3. 循环引用。如果 objA引用了objB,objB也引用了objA,但是除此之外,再没有其他的地方引用这两个对象了,这两个对象的引用计数就都是1。这种情况下,这两个对象是不能被回收的。如果说上面两条缺陷还可以克服的话,那么循环引用就是比较致命的。

在使用引用计数算法进行内存管理的语言中,比如Python和Swift,都会存在循环引用的问题。Python在引用计数之外,另外引入了三色标记算法,保证了在出现循环引用的情况下,垃圾对象也能被正常回收。关于三色标记算法,我们会在第21节课进行重点讲解。

而Swift则要求程序员自己解开循环引用,也就是将objA对objB的引用设为NULL,这样objA对objB的引用就消失了,objB的引用计数变为1,接下来就可以正常地将两个对象都回收掉了。

综上所述,引用计数实现方便,又可以做到对无用的资源进行立即回收,但他无法应对高并发、高吞吐的场景

总结

好啦,这节课到这里就结束啦,我们一起来回顾下这节课的重点内容吧。在这节课里,我们介绍了GC算法的基本概念和定义,然后又讨论了GC算法的评价标准,以便于后面对各种算法进行对比和选择,最后,我们又讲解了引用计数法这种最直接最简单的GC算法。

GC算法中有两种重要的角色,分别是Mutator和CollectorMutator也可以理解为业务线程,collector可以理解为GC线程

Java对象都是在Java堆中创建的,它们之间的相互引用关系可以使用图结构来表示。找出堆中活跃对象的过程就是在堆中对Java对象进行遍历的过程。遍历算法的起点是根集合

所有不在堆中,而指向堆里的引用都是根引用,根引用的集合就是根集合。根集合是很多基于可达性分析的GC算法遍历的起点。

然后,我们讨论了GC算法的评价标准,总结了以下六点,分别是:分配的效率、回收的效率、是否产生内存碎片、空间利用率、是否停顿,以及实现的难度。这六个维度是一般化的评价标准,并不是所有的算法都适用全部的评价标准。比如引用计数算法就不必关心分配的效率和内存碎片问题。

在这节课的最后,我们一起探讨了引用计数算法。引用计数的特点是在对象的引用在修改的过程中,可以统计对象被引用的次数,当一个对象的被引用次数为0,则对象可以被释放。在修改引用的过程中加一些动作,这种做法被称为write barrier,这是非常重要的一个机制。我们后面会反复遇到各种类型的barrier。

思考题

除了从栈上出发的引用是根引用,你还知道哪些根引用呢?欢迎在留言区分享你的想法,我在留言区等你。

好啦,这节课到这就结束啦。欢迎你把这节课分享给更多对计算机内存感兴趣的朋友。我是海纳,我们下节课再见!

教程推荐

Ruby在线教程

PL/SQL在线教程

Java 加密在线教程

Scala在线教程

JavaScript 设计模式在线教程

Python 数学应用在线教程

随机推荐

可心柔婴童乳霜纸可以入手吗?内幕评测透露。

创维55A3D可靠性如何?深度爆料评测!

小东北星厨水饺-馄饨评测结果好吗?全方位评测分享!

春光饮料用户评价如何?图文解说评测,简明扼要!

康夫F9评价怎么样?3分钟了解评测报告!

松下ES-ERT3品牌口碑如何?全方位评测分享!