黑暗的内存管理
很多人对 C 语言深恶痛绝,仅仅是因为 C 语言迫使他们在编程中必须手动分配与释放内存,然后通过指针去访问,稍有不慎可能就会导致程序运行运行时出现内存泄漏或内存越界访问。
C 程序的内存泄漏只会发生在程序所用的堆空间内,因为程序只能在堆空间内动态分配内存。NULL 指针、未初始化的指针以及引用的内存空间被释放了的指针,如果这些指针访问内存,很容易就让程序挂掉。
除了堆空间,程序还有个一般而言比较小的栈空间。这个空间是所有的函数共享的,每个函数在运行时会独占这个空间。栈空间的大小是固定的,它是留给函数的参数与局部变量用的。栈空间有点像宾馆,你下榻后,即使将房间搞的一团糟,也不需要你去收拾它,除非你把房间很严重的损坏了——用 C 的黑话来说,即缓冲区溢出。
虽然导致这些问题出现的原因很简单,但是却成为缺乏编程素养的人难以克服的障碍,被 C 语言吓哭很多次之后,他们叛逃到了 Java、C# 以及各种动态类型语言的阵营,因为这些语言将指针隐藏了起来,并提供内存垃圾回收(GC)功能。他们赢了,他们懒洋洋的躺在沙发上,拿着遥控器指挥内存,信号偶尔中断,内存偶尔紊乱。
C 内存的动态分配与回收
C 语言标准库(stdlib)中为堆空间中的内存分配与回收提供了 malloc 与 free 函数。例如,在下面的代码中,我们从堆空间中分配了 7 个字节大小的空间,然后又释放了:
#include <stdio.h>
int main(void)
{
void *p = malloc(7);
free(p);
return 0;
}
复制代码
一点儿都不难!这跟你去学校图书馆借了 7 本书,然后又还回去没什么两样。有借有还,再借不难,过期不还,就要罚款。有谁因为去图书馆借几本书就被吓哭了的?
我们也可以向堆空间借点地方存储某种类型的数据:
int *n = malloc(4);
*n = 7;
free(n);
复制代码
如果你不知道 int 类型的数据需要多大的空间才能装下,那就用 sizeof,让 C 编译器去帮助你计算,即:
int *n = malloc(sizeof(int));
*n = 7;
free(n);
复制代码
喏,就酱紫...
策略与机制分离
在 C 语言中有关内存管理的机制已经简单到了几乎无法再简单的程度了,那么为何那么多人都在嘲笑讥讽挖苦痛骂诅咒 C 的内存管理呢?
如果你略微懂得一些来自 Unix 的哲学,可能听说过这么一句话:策略与机制分离。如果没听说过这句话,建议阅读 Eric Raymond 写的《Unix 编程艺术》第一章中的 Unix 哲学部分。
malloc 与 free 是 C 提供的内存管理机制,至于你怎么去使用这个机制,那与 C 没有直接关系。例如,你可以手动使用 malloc 与free 来管理内存——最简单的策略,你也可以实现一种略微复杂一点的基于引用计数的内存管理策略,还可以基于 Lisp 之父 John McCarthy 独创的 Mark&Sweep 算法实现一种保守的内存自动回收策略,还可以将引用计数与 Mark&Sweep 这两种策略结合起来实现内存自动回收。总之,这些策略都可以在 C 的内存管理机制上实现。
借助 Boehm GC 库,就可以在 C 程序中实现垃圾内存的自动回收:
#include <assert.h>
#include <stdio.h>
#include <gc.h>
int main(void)
{
GC_INIT();
for (int i = 0; i < 10000000; ++i)
{
int **p = GC_MALLOC(sizeof(int *));
int *q = GC_MALLOC_ATOMIC(sizeof(int));
assert(*p == 0);
*p = GC_REALLOC(q, 2 * sizeof(int));
if (i % 100000 == 0)
printf("Heap size = %zu\n", GC_get_heap_size());
}
return 0;
}
复制代码
在 C 程序中使用 Boehm GC 库,只需用 GC_MALLOC 或 C_MALLOC_ATOMIC 替换 malloc,然后去掉所有的 free 语句。C_MALLOC_ATOMIC 用于分配不会用于存储指针数据的堆空间。
如果你的系统(Linux)中安装了 boehm-gc 库(很微型,刚 100 多 Kb),可以用 gcc 编译这个程序然后运行一次体验一下,编译命令如下:
$ gcc -lgc test.c
复制代码
GNU 的 Scheme 解释器 Guile 2.0 就是用的 boehm-gc 来实现内存回收的。有很多项目在用 boehm-gc,只不过很少有人听说过它们,见:
http://www.hboehm.info/gc/#users
如果 C 语言直接提供了某种内存管理策略,无论是提供引用计数还是 Mark&Sweep 抑或这二者的结合体,那么都是在剥夺其他策略生存的机会。例如,在 Java、C# 以及动态类型语言中,你很难再实现一种新的内存管理策略了——例如手动分配与释放这种策略。
Eric Raymond 说,将策略与机制揉在一起会导致有两个问题,(1) 策略会变得死板,难以适应用户需求的改变;(2) 任何策略的改变都极有可能动摇机制。相反,如果将二者剥离,就可以在探索新策略的时候不会破坏机制,并且还检验了机制的稳定性与有效性。
Unix 的哲学与 C 有何相干?不仅是有何相干,而且是息息相关!因为 C 与 Unix 是鸡生蛋 & 蛋生鸡的关系——Unix 是用 C 语言开发的,而 C 语言在 Unix 的开发过程中逐渐成熟。C 语言只提供机制,不提供策略,也正因为如此才招致了那些贪心的人的鄙薄。
这么多年来,像 C 语言提供的这种 malloc + free 的内存管理机制一直都没有什么变化,而计算机科学家们提出的内存管理策略在数量上可能会非常惊人。像 C++ 11 的智能指针与 Java 的 GC 技术,如果从研究的角度来看,可能它们已经属于陈旧的内存回收策略了。因为它们的缺点早就暴露了出来,相应的改进方案肯定不止一种被提了出来,而且其中肯定会有一些策略是基于概率算法的……那些孜孜不倦到处寻找问题的计算机科学家们,怎能错过这种可以打怪升级赚经费的好机会?
总之,C 已经提供了健全的内存管理机制,它并没有限制你使用它实现一种新的内存管理策略。
手动管理内存的常见陷阱
在编写 C 程序时,手动管理内存只有一个基本原则是:谁需要,谁分配;谁最后使用,谁负责释放。这里的『谁』,指的是函数。也就是说,我们有义务全程跟踪某块被分配的堆空间的生命周期,稍有疏忽可能就会导致内存泄漏或内存被重复释放等问题。
那些在函数内部作为局部变量使用的堆空间比较容易管理,只要在函数结尾部分稍微留心将其释放即可。一个函数写完后,首先检查一下所分配的堆空间是否被正确释放,这个习惯很好养成。这种简单的事其实根本不用劳烦那些复杂的内存回收策略。
C 程序内存管理的复杂之处在于在某个函数中分配的堆空间可能会一路辗转穿过七八个函数,最后又忘记将其释放,或者本来是希望在第 7 个函数中访问这块堆空间的,结果却在第 3 个函数中将其释放了。尽管这样的场景一般不会出现(根据快递公司丢包的概率,这种堆空间传递失误的概率大概有 0.001),但是一旦出现,就够你抓狂一回的了。没什么好方法,惟有提高自身修养,例如对于在函数中走的太远的堆空间,一定要警惕,并且思考是不是设计思路有问题,寻找缩短堆空间传播路径的有效方法。
堆空间数据在多个函数中传递,这种情况往往出现于面向对象编程范式。例如在 C++ 程序中,对象会作为一种穿着隐行衣的数据——this 指针的方式穿过对象的所有方法(类的成员函数),像穿糖葫芦一样。不过,由于 C++ 类专门为对象生命终结专门设立了析构函数,只要这个析构函数没被触发,那么这个对象在穿过它的方法时,一般不会出问题。因为 this 指针是隐藏的,也没人会神经错乱在对象的某个方法中去 delete this。真正的陷阱往往出现在类的继承上。任何一个训练有素的 C++ 编程者都懂得什么时候动用虚析构函数,否则就会陷入用 delete 去释放引用了派生类对象的基类指针所导致的内存泄漏陷阱之中。
在面向对象编程范式中,还会出现对象之间彼此引用的现象。例如,如果对象 A 引用了对象 B,而对象 B 又引用了对象 A。如果这两个对象的析构函数都试图将各自所引用对象销毁,那么程序就会直接崩溃了。如果只是两个相邻的对象的相互引用,这也不难解决,但是如果 A 引用了 B,B 引用了 C, C 引用了 D, D 引用了 B 和 E,E 引用了 A……然后你可能就凌乱了。如果是基于引用计数来实现内存自动回收,遇到这种对象之间相互引用的情况,虽然那程序不会崩溃,但是会出现内存泄漏,除非借助弱引用来打破这种这种引用循环,本质上这只是变相的谁最后使用,谁负责释放。
函数式编程范式中,内存泄漏问题依然很容易出现,特别是在递归函数中,通常需要借助一种很别扭的思维将递归函数弄成尾递归形式才能解决这种问题。另外,惰性计算也可能会导致内存泄漏。
似乎并没有任何一种编程语言能够真正完美的解决内存泄漏问题——有人说 Rust 能解决,我不是很相信,但是显而易见,程序在设计上越低劣,就越容易导致内存错误。似乎只有通过大量实践,亡羊补牢,塞翁失马,卧薪尝胆,破釜沉舟,久而久之,等你三观正常了,不焦不躁了,明心见性了,内存错误这种癌症就会自动从你的 C 代码中消失了——好的设计品味,自然就是内存友好的。当我们达到这种境界时,可能就不会再介意在 C 中手动管理内存。
很多人对 C 语言深恶痛绝,仅仅是因为 C 语言迫使他们在编程中必须手动分配与释放内存,然后通过指针去访问,稍有不慎可能就会导致程序运行运行时出现内存泄漏或内存越界访问。
C 程序的内存泄漏只会发生在程序所用的堆空间内,因为程序只能在堆空间内动态分配内存。NULL 指针、未初始化的指针以及引用的内存空间被释放了的指针,如果这些指针访问内存,很容易就让程序挂掉。
除了堆空间,程序还有个一般而言比较小的栈空间。这个空间是所有的函数共享的,每个函数在运行时会独占这个空间。栈空间的大小是固定的,它是留给函数的参数与局部变量用的。栈空间有点像宾馆,你下榻后,即使将房间搞的一团糟,也不需要你去收拾它,除非你把房间很严重的损坏了——用 C 的黑话来说,即缓冲区溢出。
虽然导致这些问题出现的原因很简单,但是却成为缺乏编程素养的人难以克服的障碍,被 C 语言吓哭很多次之后,他们叛逃到了 Java、C# 以及各种动态类型语言的阵营,因为这些语言将指针隐藏了起来,并提供内存垃圾回收(GC)功能。他们赢了,他们懒洋洋的躺在沙发上,拿着遥控器指挥内存,信号偶尔中断,内存偶尔紊乱。
C 内存的动态分配与回收
C 语言标准库(stdlib)中为堆空间中的内存分配与回收提供了 malloc 与 free 函数。例如,在下面的代码中,我们从堆空间中分配了 7 个字节大小的空间,然后又释放了:
#include <stdio.h>
int main(void)
{
void *p = malloc(7);
free(p);
return 0;
}
复制代码
一点儿都不难!这跟你去学校图书馆借了 7 本书,然后又还回去没什么两样。有借有还,再借不难,过期不还,就要罚款。有谁因为去图书馆借几本书就被吓哭了的?
我们也可以向堆空间借点地方存储某种类型的数据:
int *n = malloc(4);
*n = 7;
free(n);
复制代码
如果你不知道 int 类型的数据需要多大的空间才能装下,那就用 sizeof,让 C 编译器去帮助你计算,即:
int *n = malloc(sizeof(int));
*n = 7;
free(n);
复制代码
喏,就酱紫...
策略与机制分离
在 C 语言中有关内存管理的机制已经简单到了几乎无法再简单的程度了,那么为何那么多人都在嘲笑讥讽挖苦痛骂诅咒 C 的内存管理呢?
如果你略微懂得一些来自 Unix 的哲学,可能听说过这么一句话:策略与机制分离。如果没听说过这句话,建议阅读 Eric Raymond 写的《Unix 编程艺术》第一章中的 Unix 哲学部分。
malloc 与 free 是 C 提供的内存管理机制,至于你怎么去使用这个机制,那与 C 没有直接关系。例如,你可以手动使用 malloc 与free 来管理内存——最简单的策略,你也可以实现一种略微复杂一点的基于引用计数的内存管理策略,还可以基于 Lisp 之父 John McCarthy 独创的 Mark&Sweep 算法实现一种保守的内存自动回收策略,还可以将引用计数与 Mark&Sweep 这两种策略结合起来实现内存自动回收。总之,这些策略都可以在 C 的内存管理机制上实现。
借助 Boehm GC 库,就可以在 C 程序中实现垃圾内存的自动回收:
#include <assert.h>
#include <stdio.h>
#include <gc.h>
int main(void)
{
GC_INIT();
for (int i = 0; i < 10000000; ++i)
{
int **p = GC_MALLOC(sizeof(int *));
int *q = GC_MALLOC_ATOMIC(sizeof(int));
assert(*p == 0);
*p = GC_REALLOC(q, 2 * sizeof(int));
if (i % 100000 == 0)
printf("Heap size = %zu\n", GC_get_heap_size());
}
return 0;
}
复制代码
在 C 程序中使用 Boehm GC 库,只需用 GC_MALLOC 或 C_MALLOC_ATOMIC 替换 malloc,然后去掉所有的 free 语句。C_MALLOC_ATOMIC 用于分配不会用于存储指针数据的堆空间。
如果你的系统(Linux)中安装了 boehm-gc 库(很微型,刚 100 多 Kb),可以用 gcc 编译这个程序然后运行一次体验一下,编译命令如下:
$ gcc -lgc test.c
复制代码
GNU 的 Scheme 解释器 Guile 2.0 就是用的 boehm-gc 来实现内存回收的。有很多项目在用 boehm-gc,只不过很少有人听说过它们,见:
http://www.hboehm.info/gc/#users
如果 C 语言直接提供了某种内存管理策略,无论是提供引用计数还是 Mark&Sweep 抑或这二者的结合体,那么都是在剥夺其他策略生存的机会。例如,在 Java、C# 以及动态类型语言中,你很难再实现一种新的内存管理策略了——例如手动分配与释放这种策略。
Eric Raymond 说,将策略与机制揉在一起会导致有两个问题,(1) 策略会变得死板,难以适应用户需求的改变;(2) 任何策略的改变都极有可能动摇机制。相反,如果将二者剥离,就可以在探索新策略的时候不会破坏机制,并且还检验了机制的稳定性与有效性。
Unix 的哲学与 C 有何相干?不仅是有何相干,而且是息息相关!因为 C 与 Unix 是鸡生蛋 & 蛋生鸡的关系——Unix 是用 C 语言开发的,而 C 语言在 Unix 的开发过程中逐渐成熟。C 语言只提供机制,不提供策略,也正因为如此才招致了那些贪心的人的鄙薄。
这么多年来,像 C 语言提供的这种 malloc + free 的内存管理机制一直都没有什么变化,而计算机科学家们提出的内存管理策略在数量上可能会非常惊人。像 C++ 11 的智能指针与 Java 的 GC 技术,如果从研究的角度来看,可能它们已经属于陈旧的内存回收策略了。因为它们的缺点早就暴露了出来,相应的改进方案肯定不止一种被提了出来,而且其中肯定会有一些策略是基于概率算法的……那些孜孜不倦到处寻找问题的计算机科学家们,怎能错过这种可以打怪升级赚经费的好机会?
总之,C 已经提供了健全的内存管理机制,它并没有限制你使用它实现一种新的内存管理策略。
手动管理内存的常见陷阱
在编写 C 程序时,手动管理内存只有一个基本原则是:谁需要,谁分配;谁最后使用,谁负责释放。这里的『谁』,指的是函数。也就是说,我们有义务全程跟踪某块被分配的堆空间的生命周期,稍有疏忽可能就会导致内存泄漏或内存被重复释放等问题。
那些在函数内部作为局部变量使用的堆空间比较容易管理,只要在函数结尾部分稍微留心将其释放即可。一个函数写完后,首先检查一下所分配的堆空间是否被正确释放,这个习惯很好养成。这种简单的事其实根本不用劳烦那些复杂的内存回收策略。
C 程序内存管理的复杂之处在于在某个函数中分配的堆空间可能会一路辗转穿过七八个函数,最后又忘记将其释放,或者本来是希望在第 7 个函数中访问这块堆空间的,结果却在第 3 个函数中将其释放了。尽管这样的场景一般不会出现(根据快递公司丢包的概率,这种堆空间传递失误的概率大概有 0.001),但是一旦出现,就够你抓狂一回的了。没什么好方法,惟有提高自身修养,例如对于在函数中走的太远的堆空间,一定要警惕,并且思考是不是设计思路有问题,寻找缩短堆空间传播路径的有效方法。
堆空间数据在多个函数中传递,这种情况往往出现于面向对象编程范式。例如在 C++ 程序中,对象会作为一种穿着隐行衣的数据——this 指针的方式穿过对象的所有方法(类的成员函数),像穿糖葫芦一样。不过,由于 C++ 类专门为对象生命终结专门设立了析构函数,只要这个析构函数没被触发,那么这个对象在穿过它的方法时,一般不会出问题。因为 this 指针是隐藏的,也没人会神经错乱在对象的某个方法中去 delete this。真正的陷阱往往出现在类的继承上。任何一个训练有素的 C++ 编程者都懂得什么时候动用虚析构函数,否则就会陷入用 delete 去释放引用了派生类对象的基类指针所导致的内存泄漏陷阱之中。
在面向对象编程范式中,还会出现对象之间彼此引用的现象。例如,如果对象 A 引用了对象 B,而对象 B 又引用了对象 A。如果这两个对象的析构函数都试图将各自所引用对象销毁,那么程序就会直接崩溃了。如果只是两个相邻的对象的相互引用,这也不难解决,但是如果 A 引用了 B,B 引用了 C, C 引用了 D, D 引用了 B 和 E,E 引用了 A……然后你可能就凌乱了。如果是基于引用计数来实现内存自动回收,遇到这种对象之间相互引用的情况,虽然那程序不会崩溃,但是会出现内存泄漏,除非借助弱引用来打破这种这种引用循环,本质上这只是变相的谁最后使用,谁负责释放。
函数式编程范式中,内存泄漏问题依然很容易出现,特别是在递归函数中,通常需要借助一种很别扭的思维将递归函数弄成尾递归形式才能解决这种问题。另外,惰性计算也可能会导致内存泄漏。
似乎并没有任何一种编程语言能够真正完美的解决内存泄漏问题——有人说 Rust 能解决,我不是很相信,但是显而易见,程序在设计上越低劣,就越容易导致内存错误。似乎只有通过大量实践,亡羊补牢,塞翁失马,卧薪尝胆,破釜沉舟,久而久之,等你三观正常了,不焦不躁了,明心见性了,内存错误这种癌症就会自动从你的 C 代码中消失了——好的设计品味,自然就是内存友好的。当我们达到这种境界时,可能就不会再介意在 C 中手动管理内存。