19 | 垃圾回收:如何避免内存泄露?
海纳
讲述:海纳大小:15.72M时长:17:12
你好,我是海纳。
从这节课开始,我们进入一个新的主题,那就是垃圾回收。对于 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 绘制一条有向边。再把堆中的对象看成是结点,那么,堆里的对象和它的引用关系就可以使用图来表达了。这里有一个例子,可以帮助你理解,如下所示:
上面代码的 main 函数中,创建了三个对象。我们按照上面所描述的办法,使用有向边把具有引用关系的对象表示出来,那么上面的程序就可以用下面的示意图来表示:
这样,我们就得到了一个有向图。接下来我们就可以借助图论的各种知识来处理自动内存管理问题了。如果我们在上述 main 方法的最后,再添加一行代码:
那么,此时各个对象之间的引用关系就变成下图所示的样子:
可以看到,原来 a 对象引用着 b 对象,现在这个引用就指向了新的对象,我们记为 b’对象。b 对象指向 a 和 c 的引用并没有发生变化。
从这个图中,我们可以清楚地看到此时已经没有任何对象引用 b 对象了。在执行完这一句之后,我们在代码里也已经没有任何办法可以再次访问到 b 对象了。那么此时,b 对象就变成了垃圾对象。而 a 对象还能使用栈上的一个变量继续访问它,所以它是一个活跃对象。
将对象之间的引用关系抽象成图这种数据结构以后,我们就可以使用图算法来研究垃圾回收问题了。比如说,我们想找出所有活跃的对象,只需要从确定的活跃对象出发,对整个有向图进行遍历,遍历到的就是活跃对象,遍历不到的就是垃圾对象。
这里我们要先明确什么是“确定的活跃对象”。所有不在堆中,而指向堆里的引用都是根引用,根引用所指向的对象就是“确定的活跃对象”,这些对象是根对象。根引用的集合就是根集合。根集合是很多基于可达性分析的 GC 算法遍历的起点。例如:
在执行到 buildA 的时候,内存里的真实情况如下图所示:
buildA 的栈帧里有三个变量 objA、objB、objC,它们都有引用指向堆里,所以这三个引用都是根引用。当 buildA 的栈帧还存在的时候,这些引用就存在,那么堆里的这三个对象就是活跃对象。一旦 buildA 方法执行完了,它所对应的栈帧就会被销毁,上图中的三个引用就会消失,同时堆里的三个对象也就变成了垃圾对象。
我们定义了 GC 算法相关概念和定义后,我们再来看看 GC 算法的评价标准。
GC 算法的评价标准
各种常用的 GC 算法,它们的特点各不相同,分别适用于不同的场景。要想搞清楚在什么情况下采用什么算法,我们就要先分析清楚 GC 算法的特点。具体来讲,常用的评价 GC 算法的标准有以下几条:
分配的效率:主要考察在创建对象时,申请空闲内存的效率;
回收的效率:它是指回收垃圾时的效率;
是否产生内存碎片:在讲解 malloc 的时候,我们讲过内存碎片。碎片是指活跃对象之间存在空闲内存,但这一部分内存又不能被有效利用。比如内存里有两块不连续的 16 字节空闲空间,此时分配器要申请一块 32 字节的空间,虽然总的空闲空间也是 32 字节,但由于它们不连续,不能满足分配器的这次申请。这就是碎片空间;
空间利用率:这里主要是衡量堆空间是否能被有效利用。比如基于复制的算法无论何时都会保持一部分内存是空闲的,那么它的空间利用率就无法达到 100%,这是由算法本身决定的;
是否停顿:Collector 在整理内存的时候会存在搬移对象的情况,因为修改指针是一种非常敏感的操作,有时候它会要求 Mutator 停止工作。是否需要 Mutator 停顿,以及停顿时长是多少,是否会影响业务的正常响应等。停顿时长在某些情况下是一个关键性指标;
实现的复杂度:有些算法虽然看上去很美妙,但因为其实现起来太复杂,代码难以维护,所以无法真正地商用落地。这也会影响到 GC 算法的选择。
接下来的课程中,我们就具体地研究各种 GC 算法,并用上面的 6 条评价准则对它们进行详细地分析。
GC 算法大致上可以分为引用计数和基于可达性分析的算法两大类。其中,可达性分析算法是当前 GC 算法研究的热点,也是我们自动内存管理篇的重点。在下一节课中,我们会对它进行详细地讲解。这节课,我们先简要介绍引用计数算法。
引用计数法是非常简单高效的,它依然被 Python 和 Swift 等语言所使用。而且,引用计数法也不仅仅用在垃圾回收这一个场景。在 COM 组件编程、Linux 内核管理物理页面、C++ 智能指针等场景都能看到它的身影。虽然现在引用计数法已经不是工业界和学术界研究的重点,但是掌握它仍然具有重要的意义。
引用计数算法
GC 算法一个重要的功能是要识别出内存中的哪些对象是垃圾,也就是不会再被使用到的对象。这里就涉及到了垃圾的定义,就是不再被其他对象所引用的对象。从这个定义出发,我们可以想办法统计一个对象是否被引用。如果它被引用的次数大于 0,那它就是一个活跃对象;如果它被引用的次数为 0,那它就是一个垃圾对象。
为了记录一个对象有没有被其他对象引用,我们可以在每个对象的头上添加一个叫“计数器”的东西,用来记录有多少其他对象引用了它。Mutator 在执行的时候会改变这个计数值,比如以下代码:
执行到第三行的时候,它的引用关系如下图所示:
在这个代码中,第 1 行创建了一个对象,不妨记为对象 a,objA 这个变量指向对象 a,所以这个对象的引用计数就是 1;第 2 行创建了一个 B 对象,记为对象 b,objB 变量指向对象 b,此时,它的引用计数为 1;第 3 行 objA 的 ref 属性也指向对象 b,所以对象 b 的引用计数最终会为 2。
Mutator 在运行中还会不断地修改对象之间的引用关系,我们知道,这种引用关系的变化都是发生在赋值的时候。例如,接着上面的例子,我们再执行这样一行代码:
那么从 objA 到 objB 的引用就消失了,也就是上图中,那个从 A 的 ref 指向 B 的箭头就消失了。
对于例子中的赋值语句,如果是 Java 语言,最终会被翻译成 putfield 指令,那么 JVM 在处理 putfield 指令的时候,就可以加上对引用计数的处理。描述这个过程的伪代码如下所示:
大多数语言虚拟机(例如 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 算法有什么样的优缺点,这样,我们就能知道它适合用在什么场景中了。
引用计数算法的优缺点
从算法描述中容易推知,引用计数具备以下优点:
可以立即回收垃圾。因为每个对象在被引用次数为 0 的时候,是立即就可以知道的,所以一旦一个对象成为垃圾,它将立即被释放;
没有暂停时间。对象的回收根本不需要另外的 GC 线程专门去做,业务线程自己就搞定了,所以引用计数算法不需要停顿时间。
同时,引用计数也存在以下缺点:
在每次赋值操作的时候都要做额外的计算。在多线程的情况下,为了正确地维护引用计数,需要同步和互斥操作,这往往需要通过锁来实现,这会对多线程程序性能带来比较大的损失;
会有链式回收的情况。比如多个对象对链表形式串在一起,它们的引用计数都为 1,当链表头被回收时,整个链表都会回收,这可能会导致一次回收所使用的时间过长;
循环引用。如果 objA 引用了 objB,objB 也引用了 objA,但是除此之外,再没有其他的地方引用这两个对象了,这两个对象的引用计数就都是 1。这种情况下,这两个对象是不能被回收的。如果说上面两条缺陷还可以克服的话,那么循环引用就是比较致命的。
在使用引用计数算法进行内存管理的语言中,比如 Python 和 Swift,都会存在循环引用的问题。Python 在引用计数之外,另外引入了三色标记算法,保证了在出现循环引用的情况下,垃圾对象也能被正常回收。关于三色标记算法,我们会在第 21 节课进行重点讲解。
而 Swift 则要求程序员自己解开循环引用,也就是将 objA 对 objB 的引用设为 NULL,这样 objA 对 objB 的引用就消失了,objB 的引用计数变为 1,接下来就可以正常地将两个对象都回收掉了。
综上所述,引用计数实现方便,又可以做到对无用的资源进行立即回收,但他无法应对高并发、高吞吐的场景。
总结
好啦,这节课到这里就结束啦,我们一起来回顾下这节课的重点内容吧。在这节课里,我们介绍了 GC 算法的基本概念和定义,然后又讨论了 GC 算法的评价标准,以便于后面对各种算法进行对比和选择,最后,我们又讲解了引用计数法这种最直接最简单的 GC 算法。
GC 算法中有两种重要的角色,分别是 Mutator 和 Collector。Mutator 也可以理解为业务线程,collector 可以理解为 GC 线程。
Java 对象都是在 Java 堆中创建的,它们之间的相互引用关系可以使用图结构来表示。找出堆中活跃对象的过程就是在堆中对 Java 对象进行遍历的过程。遍历算法的起点是根集合。
所有不在堆中,而指向堆里的引用都是根引用,根引用的集合就是根集合。根集合是很多基于可达性分析的 GC 算法遍历的起点。
然后,我们讨论了 GC 算法的评价标准,总结了以下六点,分别是:分配的效率、回收的效率、是否产生内存碎片、空间利用率、是否停顿,以及实现的难度。这六个维度是一般化的评价标准,并不是所有的算法都适用全部的评价标准。比如引用计数算法就不必关心分配的效率和内存碎片问题。
在这节课的最后,我们一起探讨了引用计数算法。引用计数的特点是在对象的引用在修改的过程中,可以统计对象被引用的次数,当一个对象的被引用次数为 0,则对象可以被释放。在修改引用的过程中加一些动作,这种做法被称为 write barrier,这是非常重要的一个机制。我们后面会反复遇到各种类型的 barrier。
思考题
除了从栈上出发的引用是根引用,你还知道哪些根引用呢?欢迎在留言区分享你的想法,我在留言区等你。
好啦,这节课到这就结束啦。欢迎你把这节课分享给更多对计算机内存感兴趣的朋友。我是海纳,我们下节课再见!
公开
同步至部落
取消
完成
0/2000
荧光笔
直线
曲线
笔记
复制
AI
- 深入了解
- 翻译
- 解释
- 总结
本文深入介绍了垃圾回收(GC)算法的基本概念和评价标准。首先强调了理解Java堆的结构和对象之间的引用关系对于设计GC算法的重要性。详细介绍了GC算法的评价标准,包括分配效率、回收效率、内存碎片、空间利用率、停顿情况以及实现复杂度。简要介绍了引用计数算法的原理和优缺点,强调了其立即回收垃圾和无暂停时间的优点,同时指出了在多线程环境下的性能损失、链式回收和循环引用等缺点。最后提到了引用计数算法在Python和Swift等语言中的应用,并指出了循环引用问题的解决方法。总的来说,本文内容深入浅出,适合读者快速了解垃圾回收技术的基本概念和相关算法。文章重点在于介绍GC算法的基本概念、评价标准和引用计数算法的特点,为读者提供了全面的了解和评估垃圾回收技术的基础知识。
2021-12-10给文章提建议
仅可试看部分内容,如需阅读全部内容,请付费购买文章所属专栏
《编程高手必学的内存知识》,新⼈⾸单¥59
《编程高手必学的内存知识》,新⼈⾸单¥59
立即购买
© 版权归极客邦科技所有,未经许可不得传播售卖。 页面已增加防盗追踪,如有侵权极客邦将依法追究其法律责任。
登录 后留言
全部留言(7)
- 最新
- 精选
- 李二木可以作为根引用的有: 1) 虚拟机栈中引用的对象 2)方法区中类静态属引用的对象 3) 方法区中常量引用的对象 4)本地方法栈中JNI引用的对象
作者回复: 写得很好。还可以使用MAT这样的内存分析工具去查看是否还有其他类型。
2021-12-1025 - 送过快递的码农gc的不太清楚,但是我知道一个框架,netty,里面的bytebuf就是用的引用计数法。因为也是避免频繁的创建和销毁对象,再加上还牵扯到堆外内存。所以netty用了buffer池,用的就是引用计数法,一旦计数为0,池就会回收,把它分配给其他的channel
作者回复: Very good~
2021-12-105 - Darrenwrite barrier有点类似Java中的切面啊,在正常的业务中,插入通用的业务
作者回复: 可以这么类比,只是这是编译器自动加的。
2022-02-253 - 费城的二鹏思考题 类的静态变量引用,threadlocal引用
作者回复: 你说的是。还有其他的,可以使用MAT这样的内存分析工具查看一下。
2021-12-101 - 大鑫仔Yeah这里想给引用计数管理正个名。 如swift 引用计数的读写一般用cas 性能比较高,和可达性分析的算法相比较,对引用计数的性能损耗时间均摊到每次赋值时吞吐率是大于可达性分析的。 另外swift这类语言都提供了原生weak 引用 99.9%的时间是不需要手动清空的。2021-12-1312
- H X海纳老师,总结上的倒数第二段objB的引用计数是变为0吧?我听音频里说的是0,但是看文稿上面写的是1诶2023-03-11归属地:上海
- 炮灰为什么引用计数法不需要关心内存分配的效率和内存碎片问题2022-04-151
收起评论