GC是错误的内存管理模式

Posted on July 27, 2013

只要写程序, 就免不了要和各种资源打交道, 其中最频繁的莫过于内存了.

任何一个程序都需要内存管理. 它不管是简单的还是复杂的, C 语言的还是 java 语言的. 所不一样的是, 内存管理的细节掌握在谁的手里.

对于 C 语言, 毫无疑问的, 程序员掌握所有细节. 程序员获得了最大的灵活性, 作为代价, 编译器不对任何内存管理上的疏忽负责. 而人是 最容易犯错 的生物. 意味着, 程序员总会犯错, 因此很难写出在内存管理上没有瑕疵的程序.

毫无疑问的, 将内存管理的重担全部丢给程序员, 是编译器水平低下的时代无奈的选择. 随着编译器技术的发展, 将内存管理的任务从程序员手中接管是必然的.

对于如何接管内存管理, 语言作者们分成了截然不同的两派

  • 垃圾收集
  • RAII (Resource Acquisition Is Initialization) + 智能指针

在带有垃圾收集的语言里, 程序员只管分配内存, 无需操心释放. 垃圾收集器间歇性的运作, 会将不再使用的内存释放掉. 至于如何标记哪些内存是不再使用的, 几十年间发展出了各种算法. 许多语言都带有多种标记算法供选择. “没有哪一种垃圾收集策略是适合所有程序的, 所以各种语言都发展出多套垃圾收集器, 供运行时选择.”

在许多语言里, 垃圾收集并不是编译器实现的, 而是由语言附带的运行时环境实现的, 编译器为运行时提供了附加的信息. 这就导致了语言和运行时的强耦合. 让人无法分清语言的特性和运行时的特性.

垃圾收集不是完美的, 使用垃圾收集并不意味着就可以高枕无忧了. 垃圾收集并不意味着内存泄漏成为过去式, 倒是野指针确实成为了过去式, 因为只要还有指针引用一个对象, 这个对象就绝对不会被释放. (不过, 带有垃圾收集的语言或多或少都废除了指针吧, 用引用替代了指针)

有很多很多复杂的原因丢会导致垃圾收集器无法回收特定的内存, 导致这部分内存泄漏. 更严重的是, 你很难将内存泄漏和还未被清除的内存完全区别开来. 到底是延迟收集策略 还是真的发生了内存泄漏 ? 你永远都无法正确分辨.

结果是, 程序员最终不得不回到 C 语言的老路上, 小心的检查所有的内存分配, 确保没有触发垃圾收集器的bug或者特定的一些策略 . 几乎所有使用带垃圾收集的语言开发的程序, 在其开发的后期都要经历惨痛的 “内存检查” , 回顾所有可能导致内存泄漏的代码.

垃圾收集器的另一个问题是, 除了内存, 它无法对程序使用的其他资源执行垃圾收集. 垃圾收集是以内存管理为目标产生的, 只能收集不再使用的内存, 而不能收集程序使用的其他资源, 如消息列队, 文件描述符, 共享内存, 锁.等等. 程序员不得不对其他资源执行手工管理, 像 C 程序员那样小心翼翼的操作.

最终垃圾收集仍然没有解决 “人容易犯错” 的问题, 还是把其他资源的泄漏问题丢给了程序员.

C++ 从来不认为垃圾收集是有用的东西, 和 C 派不一样 , C 派不喜欢垃圾收集纯粹是因为喜欢 “自己控制一切” (天生的 M 属性). C++ 派同样认为, 要把程序员从资源管理的重担里解放出来. 同 “投机取巧” 的 GC派不同, C++ 做了很多思考, 并最终经历了 30年的时间终于找到了解决的办法. 写入了 C++11 标准.

在这30年的时间里, C++ 的资源管理是逐步发展的. C++11 最终提出的智能指针, 源于 C++30年的探索.

C++ 要想实现 RAII + 智能指针, 两大技术缺一不可 1. 自动确定并调用 构造函数和析构函数 2. 模板

C++ 的第一步试图解放资源管理重任, 是为 C 加入了构造函数和析构函数. 构造函数和析构函数由编译器调用, 生命期终止的对象会自动调用析构函数. 不管生命期终止的原因是 return 返回, 还是 抛出了异常, 编译器总是保证, 生命期终止的对象一定会被调用析构函数.

以 “编译器自动保证对象生命期” 的技术依托下, C++ 发明了 RAII 技术, 将资源的管理变成了对象的管理,而自动变量 (创建在栈上的对象, 类的成员变量) 的生命期由编译器自动保证, 只要在构造函数里申请资源, 在析构函数里正确的释放资源, RAII 技术解决了一大部分的资源管理问题.

模板的引入使得 RAII 技术得以 “一次实现到处使用”. 如实现一次 std::vector 就可以到处使用在需要数组的情况下, 而无需为每种类型的分别实现数组RAII类. STL 内置了大量的容器, 几乎满足了所有的需求. STL的容器无法满足需求的情况下, 程序员仍然能借用 STL 的理念实现自己的 RAII 容器.

但是, 如果对象分配于堆上, 程序员不得不手工调用 delete 删除对象. 忘记将不用的对象 delete 就成了头号资源泄漏原因.

如果指针也是自动对象就好了.

C++ 标准的第一次尝试是纳入 std::auto_ptr . 但是效果并不好, 不是所有指针都可以为 auto_ptr 所代替. 最要命的是, STL 容器无法使用 auto_ptr.

C++ 标准的第二次尝试就是纳入了 std::shared_ptr , shared_ptr 在进入 C++11 标准之前, 已经在 Boost 库里实践了相当长的时间.

首先得益于 C++ 的模板技术, shared_ptr 只需实现一次, 即变成可用于任何类型的指针. 其次, 得益于 C++ 的自动生命期管理, 智能指针将需要程序员管理的堆对象也变成了能利用编译器自动管理的自动变量.

也就是, 智能指针彻底的将 delete 关键字 变成了 shared_ptr 才能使用的内部技术. 编译器能自动删除 shared_ptr 对象, 也就是编译器能自动的发出 delete 调用.

模板是智能指针技术必不可少的一部分, 否则要利用 RAII 实现智能指针就只能利用 “单根继承” 这一老土办法了. 没错, 这也是 MFC 使用的. ( MFC 诞生在 模板还没有加入 C++ 的年代. )

直到 1998 年 C++ 标准纳入了模板, C++ 才最终具备了实现自动内存管理所必须的特性.

但是准备好这些特性, 到利用这些特性发明出真正能用的智能指针, 则又花了13年的时间. ( 2011年加入了 shared_ptr. )

发明出编译器实现的自动内存管理需要时间, C++ 花了 30年的时间. 没有耐心的语言走了捷径, GC 就是这条捷径.

Comments