系统设计精要是一系列深入研究系统设计方法的系列文章,文中不仅会分析系统设计的理论,还会分析多个实际场景下的具体实现。这是一个季更或者半年更的系列,如果你有想要了解的问题,可以在文章下面留言。
持久存储的磁盘在今天已经不是稀缺的资源了,但是 CPU 和内存仍然是相对比较昂贵的资源,作者在 调度系统设计精要 中曾经介绍操作系统和编程语言对 CPU 资源的调度策略和原理,本文将会介绍计算机中常见的另一个稀缺资源 — 内存,是如何管理的。
内存管理系统和模块在操作系统以及编程语言中都占有着重要的地位,任何资源的使用都离不开申请和释放两个动作,内存管理中的两个重要过程就是内存分配和垃圾回收,内存管理系统如何利用有限的内存资源为尽可能多的程序或者模块提供服务是它的核心目标。
本文福利, 免费领取C++学习资料包、技术视频/代码,1000道大厂面试题,内容包括(C++基础,网络编程,数据库,中间件,后端开发,音视频开发,Qt开发)↓↓↓↓↓↓见下面↓↓文章底部点击免费领取↓↓
图 2 - 文章脉络和内容
虽然多数系统都会将内存管理拆分成多个复杂的模块并引入一些中间层提供缓存和转换的功能,但是内存管理系统实际上都可以简化成两个模块,即内存分配器(Allocator)、垃圾收集器(Collector)。当然除了这两个模块之外,在研究内存管理时都会引入第三个模块 — 用户程序(Mutator),帮助我们理解整个系统的工作流程。
图 3 - 内存管理系统模块
- 用户程序(Mutator)- 可以通过分配器创建对象或者更新对象持有的指针;
- 内存分配器(Allocator)— 处理用户程序的内存分配请求;
- 垃圾收集器(Collector)- 标记内存中的对象并回收不需要的内存;
上述的三个模块是内存管理系统中的核心,它们在应用程序运行期间可以维护管理内存达到相对平衡的状态,我们在介绍内存管理时也会围绕这三个不同的组件,本节将从基本概念、内存分配和垃圾回收三个方面详细介绍内存管理的相关理论。
基本概念
基本概念这一节将介绍内存管理中的基本问题,我们会简单介绍应用程序的内存布局、内存管理中的设计的常见概念以及广义上的几种不同内存管理方式,这里会帮助各位读者从顶层了解内存管理。
内存布局
操作系统会为在其上运行的应用程序分配一片巨大的虚拟内存,需要注意的是,与操作系统的主存和物理内存不一样,虚拟内存并不是在物理上真正存在的概念,它是操作系统构建的逻辑概念。应用程序的内存一般会分成以下几个不同的区域:
图 4 - 内存布局
- 栈区(Stack)— 存储程序执行期间的本地变量和函数的参数,从高地址向低地址生长;
- 堆区(Heap)— 动态内存分配区域,通过 malloc、new、free 和 delete 等函数管理;
- 未初始化变量区(BSS)— 存储未被初始化的全局变量和静态变量;
- 数据区(Data)— 存储在源代码中有预定义值的全局变量和静态变量;
- 代码区(Text)— 存储只读的程序执行代码,即机器指令;
上述五种不同段虽然存储着不同的数据,但是我们可以将它们分成三种不同的内存分配类型,也就是静态内存、栈内存和堆内存。
静态内存
静态内存可以最早追溯到 1960 年的 ALGOL 语言1,静态变量的生命周期可以贯穿整个程序。所有静态内存的布局都是在编译期间确认的,运行期间也不会分配新的静态内存,因为所有的静态内存都是在编译期间确认的,所以会为这些变量申请固定大小的内存空间,这些固定的内存空间也会导致静态内存无法支持函数的递归调用:
图 5 - 静态内存的特性
因为编译器可以确定静态变量的地址,所以它们是程序中唯一可以使用绝对地址寻址的变量。当程序被加载到内存中时,静态变量会直接存储在程序的 BSS 区或者数据区,这些变量也会在程序退出时被销毁,正是因为静态内存的这些特性,我们并不需要在程序运行时引入静态内存的管理机制。
栈内存
栈是应用程序中常见的内存空间,它遵循后进先出的规则管理存储的数据2。当应用程序调用函数时,它会将函数的参数加入栈顶,当函数返回时,它会将当前函数使用的栈全部销毁。栈内存管理的指令也都是由编译器生成的,我们会使用 BP 和 SP 这两个寄存器存储当前栈的相关信息,完全不需要工程师的参与,不过我们也只能在栈上分配大块固定的数据结构。
图 6 - 栈内存的特性
因为栈内存的释放是动态的并且是线性的,所以它可以支持函数的递归调用,不过运行时动态栈分配策略的引入也会导致程序栈内存的溢出,如果我们在编程语言中使用的递归函数超出了程序内存的上限,会造成栈溢出错误。
堆内存
堆内存也是应用程序中的常见内存,与超过函数作用域会自动回收的栈内存相比,它能够让函数的被调用方向调用方返回内存并在内存的分配提供更大的灵活性,不过它提供的灵活性也带来了内存泄漏和悬挂指针等内存安全问题。
图 7 - 堆内存的特性
因为堆上的内存是工程师手动申请的,所以需要在使用结束时释放,一旦用过的内存没有释放,就会造成内存泄漏,占用更多的系统内存;如果在使用结束前释放,会导致危险的悬挂指针,其他对象指向的内存已经被系统回收或者重新使用。虽然进程的内存可以划分成很多区域,但是当我们在谈内存管理时,一般指的都是堆内存的管理,也就是如何解决内存泄漏和悬挂指针的问题。
管理方式
我们可以将内存管理简单地分成手动管理和自动管理两种方式,手动管理内存一般是指由工程师在需要时通过 malloc 等函数手动申请内存并在不需要时调用 free 等函数释放内存;自动管理内存由编程语言的内存管理系统自动管理,在大多数情况下不需要工程师的参与,能够自动释放不再使用的内存。
图 8 - 手动管理和自动管理
手动管理和自动管理只是内存管理的两种不同方式,本节将分别介绍两种内存管理的方式以及不同编程语言做出的不同选择。
手动管理
手动管理内存是一种比较传统的内存管理方式,C/C++ 这类系统级的编程语言不包含狭义上的自动内存管理机制,工程师需要主动申请或者释放内存。如果存在理想的工程师能够精准地确定内存的分配和释放时机,人肉的内存管理策略只要做到足够精准,使用手动管理内存的方式可以提高程序的运行性能,也不会造成内存安全问题,
但是这种理想的工程师往往不存在于现实中,人类因素(Human Factor)总会带来一些错误,内存泄漏和悬挂指针基本是 C/C++ 这类语言中最常出现的错误,手动的内存管理也会占用工程师的大量精力,很多时候都需要思考对象应该分配到栈上还是堆上以及堆上的内存应该何时释放,维护成本相对来说还是比较高的,这也是必然要做的权衡。
自动管理
自动管理内存基本是现代编程语言的标配,因为内存管理模块的功能非常确定,所以我们可以在编程语言的编译期或者运行时中引入自动的内存管理方式,最常见的自动内存管理机制就是垃圾回收,不过除了垃圾回收之外,一些编程语言也会使用自动引用计数辅助内存的管理。
自动的内存管理机制可以帮助工程师节省大量的与内存打交道的时间,让工程师将全部的精力都放在核心的业务逻辑上,提高开发的效率;在一般情况下,这种自动的内存管理机制都可以很好地解决内存泄漏和悬挂指针的问题,但是这也会带来额外开销并影响语言的运行时性能。
对象头
对象头是实现自动内存管理的关键元信息,内存分配器和垃圾收集器都会访问对象头以获取相关的信息。当我们通过 malloc 等函数申请内存时,往往都需要将内存按照指针的大小对齐(32 位架构上为 4 字节,64 位架构上为 8 字节),除了用于对齐的内存之外,每一个堆上的对象也都需要对应的对象头:
图 9 - 对象头与对象
不同的自动内存管理机制会在对象头中存储不同的信息,使用垃圾回收的编程语言会存储标记位 MarkBit/MarkWord,例如:Java 和 Go 语言;使用自动引用计数的会在对象头中存储引用计数 RefCount,例如:Objective-C。
编程语言会选择将对象头与对象存储在一起,不过因为对象头的存储可能影响数据访问的局部性,所以有些编程语言可能会单独开辟一片内存空间来存储对象头并通过内存地址建立两者之间的隐式联系。
内存分配
内存分配器是内存管理系统中的重要组件,它的主要职责是处理用户程序的内存申请。虽然内存分配器的职责非常重要,但是内存的分配和使用其是一个增加系统中熵的过程,所以内存分配器的设计与工作原理相对比较简单,我们在这里介绍内存分配器的两种类型。
内存分配器只包含线性内存分配器(Sequential Allocator)和空闲链表内存分配器(Free-list Allocator)两种,内存管理机制中的所有内存分配器其实都是上述两种不同分配器的变种,它们的设计思路完全不同,同时也有着截然不同的应用场景和特性,我们在这里依次介绍这两种内存分配器的原理。
线性分配器
线性分配(Bump Allocator)是一种高效的内存分配方法,但是有较大的局限性。当我们在编程语言中使用线性分配器,我们只需要在内存中维护一个指向内存特定位置的指针,当用户程序申请内存时,分配器只需要检查剩余的空闲内存、返回分配的内存区域并修改指针在内存中的位置,即移动下图中的指针:
图 10 - 线性分配器
根据线性分配器的原理,我们可以推测它有较快的执行速度,以及较低的实现复杂度;但是线性分配器无法在内存被释放时重用内存。如下图所示,如果已经分配的内存被回收,线性分配器是无法重新利用红色的这部分内存的:
图 11 - 线性分配器回收内存
正是因为线性分配器的这种特性,我们需要合适的垃圾回收算法配合使用。标记压缩(Mark-Compact)、复制回收(Copying GC)和分代回收(Generational GC)等算法可以通过拷贝的方式整理存活对象的碎片,将空闲内存定期合并,这样就能利用线性分配器的效率提升内存分配器的性能了。
因为线性分配器的使用需要配合具有拷贝特性的垃圾回收算法,所以 C 和 C++ 等需要直接对外暴露指针的语言就无法使用该策略,我们会在下一节详细介绍常见垃圾回收算法的设计原理。
空闲链表分配器
空闲链表分配器(Free-List Allocator)可以重用已经被释放的内存,它在内部会维护一个类似链表的数据结构。当用户程序申请内存时,空闲链表分配器会依次遍历空闲的内存块,找到足够大的内存,然后申请新的资源并修改链表:
图 12 - 空闲链表分配器
因为不同的内存块以链表的方式连接,所以使用这种方式分配内存的分配器可以重新利用回收的资源,但是因为分配内存时需要遍历链表,所以它的时间复杂度就是 O(n)。空闲链表分配器可以选择不同的策略在链表中的内存块中进行选择,最常见的就是以下四种方式:
- 首次适应(First-Fit)— 从链表头开始遍历,选择第一个大小大于申请内存的内存块;
- 循环首次适应(Next-Fit)— 从上次遍历的结束位置开始遍历,选择第一个大小大于申请内存的内存块;
- 最优适应(Best-Fit)— 从链表头遍历整个链表,选择最合适的内存块;
- 隔离适应(Segregated-Fit)— 将内存分割成多个链表,每个链表中的内存块大小相同,申请内存时先找到满足条件的链表,再从链表中选择合适的内存块;
上述四种策略的前三种就不过多介绍了,Go 语言使用的内存分配策略与第四种策略有些相似,我们通过下图了解一下该策略的原理:
本文福利, 免费领取C++学习资料包、技术视频/代码,1000道大厂面试题,内容包括(C++基础,网络编程,数据库,中间件,后端开发,音视频开发,Qt开发)↓↓↓↓↓↓见下面↓↓文章底部点击免费领取↓↓
图 13 - 隔离适应策略
如上图所示,该策略会将内存分割成由 4、8、16、32 字节的内存块组成的链表,当我们向内存分配器申请 8 字节的内存时,我们会在上图中的第二个链表找到空闲的内存块并返回。隔离适应的分配策略减少了需要遍历的内存块数量,提高了内存分配的效率。
垃圾回收
垃圾回收是一种自动的内存管理形式3,垃圾收集器是内存管理系统的重要组件,内存分配器会负责在堆上申请内存,而垃圾收集器会释放不再被用户程序使用的对象。谈到垃圾回收,很多人的第一反应可能都是暂停程序(stop-the-world、STW)和垃圾回收暂停(GC Pause),垃圾回收确实会带来 STW,但是这不是垃圾回收的全部,本节将详细介绍垃圾回收以及垃圾收集器的相关概念和理论。
什么是垃圾
在深入分析垃圾回收之前,我们需要先明确垃圾回收中垃圾的定义,明确定义能够帮助我们更精确地理解垃圾回收解决的问题以及它的职责。计算机科学中的垃圾包括对象、数据和计算机系统中的其他的内存区域,这些数据不会在未来的计算中使用,因为内存资源是有限的,所以我们需要将这些垃圾占用的内存交还回堆并在未来复用4。
图 14 - 语义和语法垃圾
垃圾可以分成语义垃圾和语法垃圾两种,*语义垃圾(Semantic Garbage)*是计算机程序中永远不会被程序访问到的对象或者数据;*语法垃圾(Syntactic Garbage)*是计算机程序内存空间中从根对象无法达到(Unreachable)的对象或者数据。
语义垃圾是不会被使用的的对象,可能包括废弃的内存、不使用的变量,垃圾收集器无法解决程序中语义垃圾的问题,我们需要通过编译器来识别一部分语义垃圾。语法垃圾是在对象图中不能从根节点达到的对象,所以语法垃圾在一般情况下都是语义垃圾:
图 15 - 无法达到的语法垃圾
垃圾收集器能够发现并回收的就是对象图中无法达到的语法垃圾,通过分析对象之间的引用关系,我们可以得到图中根节点不可达的对象,这些不可达的对象会在垃圾收集器的清理阶段被回收。
收集器性能
吞吐量(Throughput)和最大暂停时间(Pause time)是两个衡量垃圾收集器的主要指标,除了这两个指标之外,堆内存的使用效率和访问的局部性也是垃圾收集的常用指标,我们简单介绍以下这些指标对垃圾收集器的影响。
吞吐量
垃圾收集器的吞吐量其实有两种解释,一种解释是垃圾收集器在执行阶段的速度,也就是单位时间的标记和清理内存的能力,我们可以用堆内存除以 GC 使用的总时间来计算。
HEAP_SIZE / TOTAL_GC_TIME
另一种吞吐量计算方法是使用程序运行的总时间除以所有 GC 循环运行的总时间,GC 的时间对于整个应用程序来说是额外开销,这个指标能看出额外开销占用资源的百分比,从这一点,我们也能看出 GC 的执行效率。
最大暂停时间
由于在垃圾回收的某些阶段会触发 STW,所以用户程序是不能执行的,最长的 STW 时间会严重影响程序处理请求或者提供服务的尾延迟,所以这一点也是我们在测量垃圾收集器性能时需要考虑的指标。
图 16 - 最大暂停时间
使用 STW 垃圾收集器的编程语言,用户程序在垃圾回收的全部阶段都不能执行。并发标记清除的垃圾收集器将可以与用户程序并发执行的工作全部并发执行,能够减少最大程序暂停时间,
堆使用效率
堆的使用效率也是衡量垃圾收集器的重要指标。为了能够标识垃圾,我们需要在内存空间中引入包含特定信息的对象头,这些对象头都是垃圾收集器带来的额外开销,正如网络带宽可能不是最终的下载速度,协议头和校验码的传输会占用网络带宽,对象头的大小最终也会影响堆内存的使用效率;除了对象头之外,堆在使用过程中出现的碎片也会影响内存的使用效率,为了保证内存的对齐,我们会在内存中留下很多缝隙,这些缝隙也是内存管理带来的开销。
访问局部性
访问的局部性是我们在讨论内存管理时不得不谈的话题,空间的局部性是指处理器在短时间内总会重复地访问同一片或者相邻的内存区域,操作系统会以内存页为单位管理内存空间,在理想情况下,合理的内存布局可以使得垃圾收集器和应用程序都能充分地利用空间局部性提高程序的执行效率。
收集器类型
垃圾收集器的类型在总体上可以分成直接(Direct)垃圾收集器和跟踪(Tracing)垃圾收集器。直接垃圾收集器包括引用计数(Refernce-Counting),跟踪垃圾收集器包含标记清理、标记压缩、复制垃圾回收等策略,而引用计数收集器却不是特别常见,少数编程语言会使用这种方式管理内存。
图 17 - 垃圾收集器类型
除了直接和跟踪垃圾收集器这些相对常见的垃圾回收方法之外,也有使用所有权或者手动的方式管理内存,我们在本节中会介绍引用计数、标记清除、标记压缩和复制垃圾回收四种不同类型垃圾收集器的设计原理以及它们的优缺点。
引用计数
基于引用计数的垃圾收集器是直接垃圾收集器,当我们改变对象之间的引用关系时会修改对象之间的引用计数,每个对象的引用计数都记录了当前有多少个对象指向了该对象,当对象的引用计数归零时,当前对象就会被自动释放。在使用引用计数的编程语言中,垃圾收集是在用户程序运行期间实时发生的,所以在理论上也就不存在 STW 或者明显地垃圾回收暂停。
图 18 - 对象的引用计数
如上图所示,基于引用计数的垃圾收集器需要应用程序在对象头中存储引用计数,引用计数就是该类型的收集器在内存中引入的额外开销。我们在这里举一个例子介绍引用计数的工作原理,如果在使用引用计数回收器的编程语言中使用如下所示赋值语句时:
obj.field = new_ref;
- 对象 obj 原来引用的对象 old_ref 的引用计数会减一;
- 对象 obj 引用的新对象 new_ref 的引用计数会加一;
- 如果 old_ref 对象的引用计数归零,我们会释放该对象回收它的内存;
这种类型的垃圾收集器会带来两个比较常见的问题,分别是递归的对象回收和循环引用:
- 递归回收 — 每当对象的引用关系发生改变时,我们都需要计算对象的新引用计数,一旦对象被释放,我们就需要递归地访问所有该对象的引用并将被引用对象的计数器减一,一旦涉及到较多的对象就可能会造成 GC 暂停;
- 循环引用 — 对象的相互引用在对象图中也非常常见,如果对象之间的引用都是强引用,循环引用会导致多个对象的计数器都不会归零,最终会造成内存泄漏;
递归回收是使用引用计数时不得不面对的问题,我们很难在工程上解决该问题;不过使用引用计数的编程语言却可以利用弱引用来解决循环引用的问题,弱引用也是对象之间的引用关系,建立和销毁弱引用关系都不会修改双方的引用计数,这就能避免对象之间的弱引用关系,不过这也需要工程师对引用关系作出额外的并且正确的判断。
图 19 - 强引用与弱引用
除了弱引用之外,一些编程语言也会在引用计数的基础上加入标记清除技术,通过遍历和标记堆中不再被使用的对象解决循环引用的问题。
引用计数垃圾收集器是一种非移动(Non-moving)的垃圾回收策略,它在回收内存的过程中不会移动已有的对象,很多编程语言都会对工程师直接暴露内存的指针,所以 C、C++ 以及 Objective-C 等编程语言其实都可以使用引用计数来解决内存管理的问题。
标记清除
标记清除(Mark-Sweep)是最简单也最常见的垃圾收集策略,它的执行过程可以分成标记和清除两个阶段,标记阶段会使用深度优先或者广度优先算法扫描堆中的存活对象,而清除阶段会回收内存中的垃圾。当我们使用该策略回收垃圾时,它会首先从根节点出发沿着对象的引用遍历堆中的全部对象,能够被访问到的对象是存活的对象,不能被访问到的对象就是内存中的垃圾。
如下图所示,内存空间中包含多个对象,我们从根对象出发依次遍历对象的子对象并将从根节点可达的对象都标记成存活状态,即 A、C 和 D 三个对象,剩余的 B、E 和 F 三个对象因为从根节点不可达,所以会被当做垃圾:
图 20 - 标记清除的标记阶段
标记阶段结束后会进入清除阶段,在该阶段中收集器会依次遍历堆中的所有对象,释放其中没有被标记的 B、E 和 F 三个对象并将新的空闲内存空间以链表的结构串联起来,方便内存分配器的使用。
图 21 - 标记清除的收集阶段
使用标记清除算法的编程语言需要在对象头中加入表示对象存活的标记位(Mark Bit),标记位与操作系统的写时复制不兼容,因为即使内存页中的对象没有被修改,垃圾收集器也会修改内存页中对象相邻的标记位导致内存页的复制。我们可以使用位图(Bitmap)标记避免这种情况,表示对象存活的标记与对象分别存储,清理对象时也只需要遍历位图,能够降低清理过程的额外开销。
如上图所示,使用标记清除算法的垃圾收集器一般会使用基于空闲链表的分配器,因为对象在不被使用时会被就地回收,所以长时间运行的程序会出现很多内存碎片,这会降低内存分配器的分配效率,在实现上我们可以将空闲链表按照对象大小分成不同的区以减少内存中的碎片。
标记清除策略是一种实现简单的垃圾收集策略,但是它的内存碎片化问题也比较严重,简单的内存回收策略也增加了内存分配的开销和复杂度,当用户程序申请内存时,我们也需要在内存中找到足够大的块分配内存。
标记压缩
标记压缩(Mark-Compact)也是比较常见的垃圾收集算法,与标记清除算法类似,标记压缩的执行过程可以分成标记和压缩两个阶段。该算法在标记阶段也会从根节点遍历对象,查找并标记所有存活的对象;在压缩阶段,我们会将所有存活的对象紧密排列,『挤出』存活对象之间的缝隙:
图 22 - 标记压缩算法
因为在压缩阶段我们需要移动存活的对象,所以这一种 moving 收集器,如果编程语言支持使用指针访问对象,那么我们就无法使用该算法。标记的过程相对比较简单,我们在这里以 Lisp 2 压缩算法为例重点介绍该算法的压缩阶段:
- 计算当前对象迁移后的最终位置并将位置存储在转发地址(Forwarding Address)中;
- 根据当前对象子对象的转发地址,将引用指向新的位置;
- 将所有存活的对象移动到对象头中转发地址的位置;
从上述过程我们可以看出,使用标记压缩算法的编程语言不仅要在对象头中存储标记位,还需要存储当前对象的转发地址,这增加了对象在内存中的额外开销。
标记压缩算法的实现比较复杂,在执行的过程中需要遍历三次堆中的对象,作为 moving 的垃圾收集器,它不适用于 C、C++ 等编程语言;压缩算法的引入可以减少程序中的内存碎片,我们可以直接使用最简单的线性分配器为用户程序快速分配内存。
复制垃圾回收
复制垃圾回收(Copying GC)也是跟踪垃圾收集器的一种,它会将应用程序的堆分成两个大小相等的区域,如下图所示,其中左侧区域负责为用户程序分配内存空间,而右侧区域用于垃圾回收。
图 23 - 复制垃圾回收
当用户程序使用的内存超过上图中的左侧区域就会出现内存不足(Out-of memory、OOM),垃圾收集器在这时会开启新的垃圾收集循环,复制垃圾回收的执行过程可以非常以下的四个阶段:
- 复制阶段 — 从 GC 根节点出发遍历内存中的对象,将发现的存活对象迁移到右侧的内存中;
- 转发阶段 — 在原始对象的对象头或者在原位置设置新对象的转发地址(Forwarding Address),如果其他对象引用了该对象可以从转发地址转到新的地址;
- 修复指针 — 遍历当前对象持有的引用,如果引用指向了左侧堆中的对象,回到第一步迁移发现的新对象;
- 交换阶段 — 当内存中不存在需要迁移的对象之后,交换左右两侧的内存区域;
图 24 - 复制垃圾回收的复制阶段
如上图所示,当我们把 A 对象复制到右侧的区域后,会将原始的 A 对象指向新的 A 对象,这样其他引用 A 的对象可以快速找到它的新地址;因为 A 对象的复制是『像素级复制』,所以 A 对象仍然会指向左侧内存的 C 对象,这时需要将 C 对象复制到新的内存区域并修改 A 对象的指针。在最后,当不存在需要拷贝的对象时,我们可以直接交换两个内存区域的指针。
复制垃圾回收与标记压缩算法一样都会拷贝对象,能够减少程序中的内存碎片,我们可以使用线性的分配器快速为用户程序分配内存。因为只需要扫描一半的堆,遍历堆的次数也会减少,所以可以减少垃圾回收的时间,但是这也会降低内存的利用率。
高级垃圾回收
内存管理是一个相对比较大的话题,我们在上一小节介绍了垃圾回收的一些基本概念,其中包括常见的垃圾回收算法:引用计数、标记清除、标记压缩和复制垃圾回收,这些算法都是比较基本的垃圾回收算法,我们在这一节中将详细介绍一些高级的垃圾回收算法,它们会利用基本的垃圾回收算法和新的数据结构构建更复杂的收集器。
分代垃圾收集器
分代垃圾回收(Generational garbage collection)是在生产环境中比较常见的垃圾收集算法,该算法主要建立在弱分代假设(Weak Generational Hypothesis)上 —— 大多数的对象会在生成后马上变成垃圾,只有极少数的对象可以存活很久5。根据该经验,分代垃圾回收会把堆中的对象分成多个代,不同代垃圾回收的触发条件和算法都完全不同。
图 25 - 青年代和老年代
常见的分代垃圾回收会将堆分成青年代(Young、Eden)和老年代(Old、Tenured),所有的对象在刚刚初始化时都会进入青年代,而青年代触发 GC 的频率也更高;而老年代的对象 GC 频率相对比较低,只有青年代的对象经过多轮 GC 没有被释放才可能被晋升(Promotion)到老年代,晋升的过程与复制垃圾回收算法的执行过程相差无几。
青年代的垃圾回收被称作是 Minor GC 循环,而老年代的垃圾回收被称作 Major GC 循环,Full GC 循环一般是指整个堆的垃圾回收,需要注意的是很多时候我们都会混淆 Major GC 循环和 Full GC 循环,在讨论时一定要先搞清楚双方对这些名词的理解是否一致。
青年代的垃圾回收只会扫描整个堆的一部分,这能够减少一次垃圾回收需要的扫描的堆大小和程序的暂停时间,提高垃圾回收的吞吐量。然而分代也为垃圾回收引入了复杂度,其中最常见的问题是跨代引用(Intergenerational Pointer),即老年代引用了青年代的对象,如果堆中存在跨代引用,那么在 Minor GC 循环中我们不仅应该遍历垃圾回收的根对象,还需要从包含跨代引用的对象出发标记青年代中的对象。
图 26 - 跨代引用
为了处理分代垃圾回收的跨代引用,我们需要解决两个问题,分别是如何识别堆中的跨代引用以及如何存储识别的跨代引用,在通常情况下我们会使用*写屏障(Write Barrier)识别跨代引用并使用卡表(Card Table)*存储相关的数据。
注意:卡表只是标记或者存储跨代引用的一种方式,除了卡表我们也可以使用记录集(Record Set)存储跨代引用的老年代对象或者使用页面标记按照操作系统内存页的维度标记老年代的对象。
写屏障是当对象之间的指针发生改变时调用的代码片段,这段代码会判断该指针是不是从老年代对象指向青年代对象的跨代引用。如果该指针是跨代引用,我们会在如下所示的卡表中标记老年代对象所在的区域:
图 27 - 卡表
卡表与位图比较相似,它也由一系列的比特位组成,其中每一个比特位都对应着老年区中的一块内存,如果该内存中的对象存在指向青年代对象的指针,那么这块内存在卡表中就会被标记,当触发 Minor GC 循环时,除了从根对象遍历青年代堆之外,我们还会从卡表标记区域内的全部老年代对象开始遍历青年代。
分代垃圾回收基于弱分代假说,结合了复制垃圾回收、写屏障以及卡表等技术,将内存中的堆区分割成了青年代和老年代等区域,为不同的代使用不同的内存分配和垃圾回收算法,可以有效地减少 GC 循环遍历的堆大小和处理时间,但是写屏障技术也会带了额外开销,移动收集器的特性也使它无法在 C、C++ 等编程语言中使用,在部分场景下弱分代假说不一定会成立,如果大多数的对象都会活得很久,那么使用分代垃圾回收可能会起到反效果。
标记区域收集器
标记区域收集器(Mark-Region Garbage Collector)是 2008 年提出的垃圾收集算法6,这个算法也被称作混合垃圾回收(Immix GC),它结合了标记清除和复制垃圾回收算法,我们使用前者来追踪堆中的存活对象,使用后者减少内存中存在的碎片。
图 28 - 标记区域收集器
Immix 垃圾回收算法包含两个组件,分别是用于标记区域的收集器和去碎片化机制7。标记区域收集器与标记清除收集器比较类似,它将堆内存拆分成特定大小的内存块,再将所有的内存块拆分成特定大小的线。当用户程序申请内存时,它会在上述内存块中查找空闲的线并使用线性分配器快速分配内存;通过引入粗粒度的内存块和细粒度的线,可以更好地控制内存的分配和释放。
图 29 - 线性分配器的光标
标记区域收集器与标记清除收集器比较类似,因为它们不会移动对象,所以都会面临内存碎片化的问题。如下图所示,标记区域收集器在回收内存时都是以块和线为单位进行回收的,所以只要当前内存线中包含存活对象,收集器就会保留该片内存区域,这会带
原文地址:内存管理设计精要 - 面向信仰编程
本文福利, 免费领取C++学习资料包、技术视频/代码,1000道大厂面试题,内容包括(C++基础,网络编程,数据库,中间件,后端开发,音视频开发,Qt开发)↓↓↓↓↓↓见下面↓↓文章底部点击免费领取↓↓