垃圾收集简述
在计算机领域,垃圾收集这个词确切说是堆内存自动回收,因为广义上讲,所谓垃圾也包括内存之外的一些东西,比如不再使用的文件句柄,但这些东西一般不算在这个概念里,这个名字大概是一开始取了个形象的名字
从历史看,垃圾回收技术既古老又年轻,现代的高级语言,基本都会将垃圾回收结合在语言设计里面,可能很多人想不到的是,垃圾回收早在上世纪60年代就已经在lisp中实现了,而在之后长达三十多年的时间里,这门技术只是在函数式语言里面演主角,直到1995年,随着java的出现,垃圾收集才逐渐被工业界接受,实际上这时候,这门技术已经相当成熟了,但是,它的理论研究分散在数以千计的论文中,而且在很长一段时间内,由于硬件条件跟不上,一直没有实际用起来,在java出现后,Richard Jones和Rafael Lins写了《垃圾收集》一书,总结了前人的理论,成为经典著作,不过这本书现在看来也有一些缺点,成书较早,举的例子大都在函数式编程范畴,里面的很多经典算法还是值得一读的
既然垃圾收集是指高级语言的堆内存自动回收,那相应的,分析这个概念,首先是堆内存,其次是自动,于是对应的,就有不使用堆内存和手动回收,这两种情况都是存在的
在计算机刚出现的那几年,写代码要用机器指令,后面好一点,用汇编助记符,这时候没什么内存申请和释放的概念,之后随着高级语言出现的内存管理机制,是静态分配内存,Fortran使用这种方式,直到Fortran77
静态分配内存的规则非常简单,编译器编译代码时,所有代码中出现的变量(无论全局还是“局部”)都固定一个地址,在程序运行时一次性申请好,然后绝对地址访问,这种内存管理方式的好处是,效率非常高,且只要程序启动成功,就不会出现内存不足的情况 它的缺点也是很明显的,相当于所有变量都是全局唯一的,因此语言上不支持递归(可以自己用栈模拟递归);所有数据大小必须在代码静态指定,无法方便地动态创建数据结构(尽管可以预申请一大块内存用代码管理) P.S.回想之前讨论过的协程,实际上静态分配内存规则跟协程的理论是一致的,只不过每个函数只能有一个协程实例
静态分配内存由于局限性太大,尤其是它不支持递归,于是很快就出现了新的内存管理方式,1958年,随着Algol58诞生了一批块结构语言,使用栈分配的方式,可以想象成去掉malloc/free的C语言,这是一个很大的进步,允许了函数递归,也可以支持在函数内定义变长数组,即数组长度在运行时由变量指定(这个实现不算难,不过C语言在后面的标准才引入) 栈分配方式的局限性在于,由于栈是以后进先出的方式操作,如果一个函数需要返回一个比较大的结果,往往需要拷贝返回,对效率是一个损耗,当然也可以写在全局变量里,但无论是拷贝还是放在全局变量,其占用大小均需要静态指定
再往后发展,引入了堆内存的管理,程序可以灵活申请内存,并将其生命周期从栈的限制解放出来,于是程序设计方面的自由度就不是什么问题了,接下来的问题就是,堆内存的管理是手动还是自动,C、C++、Pascal等语言是手动的,而其他绝大多数支持堆内存的语言,是自动的
手动管理堆内存不用多说,几乎每个学C或C++的人都头疼过这个问题,Pascal的情况也类似,有数据称,一个大型项目用手工管理堆内存的方式,内存管理上付出的工作(设计,开发,测试,改bug等)几乎可以达到40%,如果有了垃圾收集,明显能省很多事,那为什么在很长一段时间里,垃圾收集都只是一种“看起来很美”的技术呢?原因是硬件和技术的限制,垃圾收集本身是会消耗一定资源的,在cpu和内存都很紧张的时代,成本太高了,比如最早的lisp实现了垃圾收集后,运行速度非常慢(只有原来的60%),这导致垃圾收集的名声也不太好
“LISP语言最持久的贡献之一是一个非语言特征,即代表了系统自动处理内存的方法的属于及其技术——垃圾收集。”by Jean E. Samment
话说回来,lisp实现垃圾收集,也不是为技术而技术,如果能像C或Pascal语言那样在代码中显式申请释放,我猜当初做lisp的人会毫不犹豫选择,问题是在函数式编程语言中,缓行的延迟执行和数据共享非常多,程序员在写语言的时候几乎无法预料执行次序和内存数据的生命周期,除了自动垃圾收集也别无选择,垃圾收集首先出现在函数式语言,并在它开头的几十年里面基本都在函数式语言中使用,并非偶然
从功能看,垃圾收集主要实现了: 1 垃圾能被检测出来并回收,避免内存泄露 2 所有非垃圾一定不能被回收,避免悬空指针 其中第二条说得比较肯定。而第一条就比较模糊了,比如只是说垃圾能被检测,但没说所有垃圾,例如引用计数这种方法不保证在任何情况下能检测到所有垃圾,但广义上讲也算是一种(有局限的)垃圾收集方式;另外一点,垃圾收集机制只要“能”检测垃圾并回收即可,至于要不要立即检测,要不要全部回收,也是比较自由的
垃圾是指在语言限定的方式范围内,已申请但不可访问的内存,一般用可达性来定义,从进程的根集开始,看是否能达到某块申请过的内存,如果无法到达,则此内存为垃圾,比如:
a = "hello"
b = a
c = "world"
c = a
假设这段代码执行中,对每个字符串申请了一次内存,则有两块内存,运行到第四句之后开始垃圾收集,根集是a,b,c,都引用了"hello",则"hello"是可达的,除去可达的,剩余的就是不可达的,可以释放掉 当然,这两块内存都需要记录在一个内部的已申请表中,表本身不算入根集 可达性也是传递的,如果有对象和属性,若a可达,则a.x均可达,其中x是a的某个属性,以此类推
根集一般包括:常量区,全局变量区,局部变量区。首先遍历根集,所有由根集可以直接访问到的内存都是可达的,然后遍历直接可达的内存,所有从这些内存的对象存储的引用可达的对象也都是可达的,简单说: 1 根集可达 2 所有由可达内存开始可达的内存都是可达的 第二条是一个递归定义。根据这个规则,使用BFS就可以算出一个可达集
上面例子中,每个对象需要通过引用(指针)来访问,比如根集(全局空间)中存放的a,b,c都是引用,如果a是一个对象,有一个属性a.x,则a中存放的是引用,x引用的对象并不是在a内部,很多高级语言,比如java,python等都是这样设计的,原因在于,要想高效实现垃圾收集算法,需要这样一种设计,比如在C++中:
class A {...};
A *a = new A[10];
++ a;
先申请一个类A的数组,如果只是这样,垃圾收集也不难,但是在++a后,a指向了数组的第二个元素,因为数组是整体,因此这时候也相当于a引用了这个数组(因为对于一次new的空间,无法分几次delete),但是,将a作为根集来计算可达性的时候就比较麻烦了,因为a的值是一块已申请内存的中间某个地址,单纯通过a无法判断它引用的这块内存的类型和大小,更别说进一步判断这块内存有哪些引用了,于是我们不得不遍历已申请内存的记录表,通过每块内存的首地址和大小来判断a引用了哪个,这是非常低效的,因此实现垃圾收集机制的语言一般不允许随意指向一块完整内存中间位置
如果能区分开可达和不可达的内存,回收垃圾就很容易了,遍历所有申请过的内存,释放其中不可达的即可,但这只是最简单的垃圾收集,实际使用中的垃圾收集要复杂得多。《垃圾收集》和龙书里面详细介绍了各种机制和算法,以及需要注意的问题,全部抄一遍显然没意义的,后面会对一些重点算法和问题进行讨论