`
utensil
  • 浏览: 152585 次
  • 性别: Icon_minigender_1
  • 来自: 深圳
社区版块
存档分类
最新评论

RAII和垃圾收集

阅读更多

Utensil按: 此文转自CSDN文档中心,作者是Elminste。看了这篇文章才知道,之前我称之为“像栈一样令人放心的析构函数”并非常喜爱的C++特性,原来名为RAII(资源获取即初始化, Resource Acquisition Is Initialization),而且发现原来它和GC并不是我想象的那么水火不容。这篇文章,在我看来,进一步说明了Java因为GC而对面向对象的思想所进行的阉割,而且正是Java写的程序常常因为资源泄漏问题而崩溃的罪魁祸首(个人观点,请勿扔砖)。

 

 

先来看一小段代码,它取自 Bjarne Stroustrup 的演讲“Speaking C++ as a Native”:

 

// use an object to represent a resource ("resource acquisition is initialization")

class File_handle { // belongs in some support library
    FILE* p;
public:
    File_handle(const char* pp, const char* r)
        { p = fopen(pp,r); if (p==0) throw Cannot_open(pp); }
    File_handle(const string& s, const char* r)
        { p = fopen(s.c_str(),r); if (p==0) throw Cannot_open(pp); }
    ~File_handle() { fclose(p); } // destructor
    // copy operations and access functions
};

void f(string s)
{
    File_handle file(s, "r");
    // use file
}
 

 

熟悉 C++ 的朋友对这种简称为 RAII 的技巧一定不会陌生。简单的说,RAII 的一般做法是这样的:在对象构造时获取资源,接着控制对资源的访问使之在对象的生命周期内始终保持有效,最后在对象析构的时候释放资源。借此,我们实际上 把管理一份资源的责任托管给了一个对象。这种做法有两大好处:

 

第一、我们不需要显式地释放资源。以上述代码中的函数 f 为例,我们不必担心“忘记”关闭文件的问题。而且,即使是函数 f 的控制结构发生了改变,例如在函数中间插入 return 或者抛出异常,我们也能确定这个文件肯定会被关闭。特别是在“异常满天飞”的如今,RAII 是实现异常安全的有力武器。类似的,如果某个类 C 包含一个 File_handle 成员,我们也不必担心类 C 的对象会在销毁时“忘记”关闭文件。

 

第二、采用这种方式,对象所需的资源在其生命期内始终保持有效 —— 我们可以说,此时这个类维护了一个 invariant。这样,通过该类对象使用资源时,就不必检查资源有效性的问题,可以简化逻辑、提高效率。

 

好,介绍完了 RAII,下一个要出场的角色是大名鼎鼎的垃圾收集(Garbage Collection,下面简称 GC)。随着 Java 的流行,GC 已经被越来越多的人所接受,下面我简单介绍一下 GC 的运行机理。

 

首先引入几个术语:在 GC 的语境中,对于程序可以直接操纵的指针值(例如,保存在局部变量或是全局变量中的),我们称之为“根”;假设对象 A1 保存了一个指向对象 A2 的指针,对象 A2 保存了指向对象 A3 的指针,我们称 A1->A2->A3 构成了一条“指针链” —— 当然,指针链可以任意地长;假设从程序中的某个根出发,通过一条指针链能够到达对象 A,那么我们认为对象 A 是“存活”的,否则,就认为它已经“死亡”,随时可以释放它占用的内存。

 

所有 GC 实现,其运行方式都是检查对象是否存活,并将已经死亡的对象释放,其实现机理一般分为三大类:一、引用计数(reference counting),这类 GC 实现为每个对象保存指向它的指针数量,一旦这个数量降为 0 ,就将这个对象释放,小有名气的 boost::shared_ptr 采用的就是就是这种方式;二、标记-清扫(mark-sweep),这类 GC 实现周期性地扫描整个堆,先将其中的存活对象标记出来,然后再将剩下的死亡对象全部释放;三、节点复制(copying),这类 GC 实现将整个堆分成两半,并周期性地将存活对象从当前使用的那一半搬到另一半,留在原先位置的死亡对象就自然地被抛弃了。这三类实现中,引用计数的限制最多 (特别是无法回收环形结构),而且一般在效率上居于劣势,应用较少,后两类使用较多。这方面的一些细节,可以参考 2003 年第 1 期程序员上的垃圾收集专栏。另外,人民邮电出版社即将推出《垃圾收集》一书的中译本,这本书可以说是目前世上唯一一本关于 GC 的全面性的专著,对 GC 有兴趣的朋友可以找来看一下(嘻嘻,打个广告,^_^)。

 

毫无疑问,对于程序员来说,在分配了内存之后如果能够不必操心怎么释放它,那一定是非常惬意的。更重要的是,程序员们从此可以向悬挂引用和内存泄漏 告别了 —— 它们可是程序开发中最令人头痛的 bug 之一。最后,有了 GC 的支持,在程序的各个模块之间共享数据变得更容易、更安全,有助于简化模块之间的接口。虽然在 GC 对效率的影响方面,人们还存在着各种疑虑,但必须承认,GC 是一种有价值的技术。

 

可惜,非常不幸的,现有的 GC 机制和 RAII 之间可以说是水火不容 —— 怎么会这样呢?

 

症结在于这两位对待析构函数的态度不同。回顾我们对 RAII 的介绍,它的核心内容就是将一份资源托管给一个对象,让资源在对象的生命周期之内均处于有效状态,这样,它就要求资源由对象的析构函数来释放。而问题正是 在于,当前现有的 GC 机制下面,很难提供对析构函数的支持。可能会有人感到奇怪,让 GC 实现在释放对象的时候调用析构函数不就结了吗?可惜,事情不那么简单。

 

在 GC 的语境中,像析构函数这样在销毁对象时执行的动作,被称为“终结”(finalization),而支持终结一直是 GC 实现上的一个难题,因为终结动作很可能给收集工作带来很大的干扰。举例而言,考虑下面这样一个终结动作(这里我采用 C++ 析构函数的形式):

 

class wedget
{
    ... ...
    ~wedget()
    {
        // global_pointer 是一个全局变量
        global_pointer = this;
    }
};
 

假设现在我们有一个 wedget 对象 w,进一步假设在某个时刻,GC 机制发现从任何一个根出发,都无法到达 w ,那么按照定义它已经死亡,可以执行终结动作然后释放了。但是,当我们执行终结动作的时候,w 又把指向自己的指针赋给了一个全局变量也就是一个根,也就是重新出现了一条由根出发、可到达 w 的指针链,这样,按照定义 —— 它又复活了!如果你有心,随便动动脑子就可以想出上述问题的许多变种,其中有一些还可能显得很“冠冕堂皇”。

 

此时我们该怎么做呢?复活 w ?那样的话我们还必须复活所有 w 指向的对象,但要实现这一点很难,这要求我们不能在执行终结动作之前释放任何对象(你无法预先确知终结动作会影响哪些对象),而且可能陷入死循环(执行完 终结动作之后,你必须重新确定各个对象存活与否,然后再试着执行终结动作 ……)。那么我们不复活 w ?也不好,这样一来 global_pointer 就成了一个悬挂引用,GC 保证的安全性就被捅了一个大窟窿。或者我们禁止在析构函数中出现指针操作?困难,如果析构函数调用其他函数,难道你还能递归地禁止下去?要不我们禁止调用 其他函数?咳咳,那这个析构函数根本就无法实现任何实质性的功能,不要提释放资源了。

 

除去实现上的困难之外,用 GC 中的终结机制来释放资源还有一个更本质上的问题:执行终结机制的时间是无法确定的。且不说除引用计数之外的 GC 实现释放对象本来就有相当大的延时,就算将来的实现真的能够保证对象在死亡的瞬间被释放,同样无法满足需求:假设在某一时刻你希望析构某个对象,释放它占 有的资源,但只要某处仍然存在一个指向该对象的指针,这个对象就会“顽强”地生存下去。不妨假设一下,如果这里需要释放的资源是一个计时收费的网络链接, 那么 …… (祝你好运,兄弟!这是你的铺盖卷,^_^)

 

综上,我们已经有充分的理由说,现有 GC 环境下面根本不可能应用 RAII ,它们之间水火不容。事实上,像 Java 那样支持 GC 的语言,一般都不鼓励你使用终结机制,对象所需的资源必须显式地释放。最简单的,为这个类添加一个 close 成员函数负责释放资源。

 

这样做有什么缺点呢?对照最初我们对 RAII 优点的介绍就可以知道了:

 

首先,所有对象需要的资源必须显式地手工释放。拿最初的例子来说,函数 f 的最后必须加上一句 file.close(),而且我们得开始担心函数 f 控制结构的改变,无论是中间插入 return 还是可能抛出异常的地方,都必须加上 file.close()。针对这种情况,Java 等语言一般会支持 try ... finally 这个特征,规定无论因为何种原因离开函数,都必须调用 finally 代码块中的代码。try ... finally 确实有效地缓解了这一问题,但是仍然不及 RAII 方案理想:第一、在撰写 try ... finally 中付出的努力是无法重用的,如果你有 10 个函数里用了 file_handle,你必须把同样的代码写上 10 遍;第二、确保  try 块中申请的资源和 finally 块中释放资源互相配对现在成了程序员的责任,这是多出来的簿记负担,而且一旦出错出现资源泄漏是很令人头痛的 —— 一般来说,这要比内存泄漏隐蔽多了,而且不可能有专门的工具帮忙;第三、如果某个类拥有若干类似 file_handle 这样的成员,我们必须为这个类也添加一个 close 函数,并逐个调用成员的 close 函数(搞不好各个成员释放资源的函数名字还不一样),这也是一个多出来的簿记负担 —— 而且 try ... finally 帮不上什么太大的忙。

 

其次,由于允许显式地释放资源,对象无法再像以前那样保持“所需的资源在其生命期内始终有效”这样一个 invariant ,因此对象中所有使用资源的方法必须检测资源的有效性,用户在使用对象的时候也必须留意资源是否有效(这种错误多半以异常形式呈现)。这不仅使得逻辑变得 复杂,而且又是一个多出来的簿记负担 —— 对于每种需要资源的类,用户必须记住它们抛出的代表资源无效的异常是什么。

 

唔 ~~ 到目前为止,似乎形势稍稍有点令人沮丧。RAII 是管理资源的利器,而 GC 提供的方便和安全保证更是诱人之极,但偏偏两者不可得兼。你要么投向 GC 的怀抱,然后不得不手工管理其他资源,忍受多出来的麻烦和簿记负担;要么放弃 GC,老老实实手工管理内存,但却能够在管理其他资源的时候享受 RAII 带来的方便和安全。你当然可以说世界就是这样的,有时候我们不得不做出权衡,为了得到一些而放弃另一些,只是 …… 有没有更好的办法呢?

 

锵!锵!啪!

 

欲知后事如何,且听下回分解!

 

上回说到,RAII 与现有的 GC 环境互不相容,也提到了问题的症结在于对析构函数的调用。这并非仅仅是一个令人遗憾的巧合,仔细想想不难发现,在这个矛盾背后,实际上是两者在“如何看待一个对象”这一问题上的分歧。

 

前面说过,RAII 的核心内容是把资源托管给对象,并保证资源在对象生命周期内始终有效。这样,我们实际上把管理资源的任务转化成了管理对象的任务,你拥有对象就等于拥有资 源,对象存在则资源必定存在,反之亦然。RAII 的全部威力均源于此。这种做法的出发点,用 Bjarne Stroustrup 的话来说,是“use an object to represent a resource”(引自本文上篇开头所引用的代码中的注释)。换句话说,在 RAII 的眼中,对象代表了资源,它的行为和状态应该与资源的行为和状态完全一致,所以资源的申请和释放也就自然应该和对象的构造与析构对应起来。对于大多数资 源,程序员有权而且需要控制它何时释放,那么很自然的,对于管理这些资源的对象,程序员也就应该有权而且需要控制它何时析构。一言以蔽之,这要求程序员有 权控制对象的生命周期。

 

然而现有的 GC 环境并不满足上述条件。在本文的上篇中介绍过,对于 GC 来说,一个对象的生命周期有多长程序员是无权干涉的,任给对象 A,它是否存活必须严格地按照“是否存在一条从根出发的指针链能够到达 A”来判定。在 GC 的眼中,对象只是一段被分配的内存,而历史已经证明,决定内存何时释放这个任务决不能交给爱犯错的程序员,应该由专门的收集器完成,以确保每个内存回收动 作都是安全的。什么?一个对象代表一份资源?哈!我 GC 大人的眼中只有内存,没有什么资源!

 

啧啧,两者眼中对象的角色差异如此之大,难怪会水火不容了。不过,这个矛盾真的没有调和的可能吗?倒也不见得。RAII 看重对象的语义,要让对象代表资源,因此需要程序员能够控制对象的生命周期,而 GC 眼中只有内存,反复强调只有让收集器来回收内存才能保证安全,这活不能让程序员来干。因此,要同时满足这两者的要求,对象的生命周期和必须和内存的释放脱 钩。这不是什么新鲜想法。事实上,按照 C++ 标准的定义,一个对象的生命周期始于构造函数,终于析构函数,它本来就不是由内存分配释放来决定的,而 placement new 更是把这一点表现的极为充分,分配内存、构造对象、析构对象、释放内存四个操作清清楚楚、各自独立。至此,我们的解决方案已经呼之欲出了。一方面,我们要 把释放内存的责任移交给 GC,同时又要允许程序员控制对象生命周期,那么,正确的做法就是 ……

 

在现有 GC 机制的基础上,允许程序员显式地析构对象,同时,若程序试图访问已经析构的对象,将抛出异常。

 

这初看起来非常荒谬(我已经可以看到台下飞来的无数鸡蛋和番茄 …… 哎哎?!你们真砸啊?),但其实并非如此。首先,内存的分配和释放由 GC 机制管理,凡是指针可到达的内存总是有效的,因此我们拥有同样的安全性保证 —— 没有悬挂引用和内存泄漏,所以,我们可以放心大胆地在模块之间共享对象,不必操心悬挂引用的问题,也不必担心内存管理的细节“弄脏”模块之间的接口;其 次,由于允许显式析构对象,程序员能够控制对象的生命周期,因此我们可以继续应用 RAII “用对象代表资源”的语义并享受它带来的便利;最后,试图访问已经析构的对象将会抛出异常,堵上了无定义行为的口子。一句话,现在我们可以把对象放入支持 GC 的堆里而不必改变它的语义了。

 

瞧!两边不是合作得很好么?

 

必须承认的是,要实现这个方案,实际上是非常简单的,但是就我所知,目前并没有任何一个 GC 环境支持这个做法。这是因为大多数支持 GC 的语言并没有类似 C++ 中的析构函数这样的语言特征,换句话说,压根没有应用 RAII 的能力,而 C++ 本身又并不支持 GC。嗯嗯,面对这种情况,我决定效法先贤 —— 自己实现一个用于 C++ 的垃圾收集库,用智能指针来实现对指针访问的控制,并提供显式析构的接口。

 

最后,我来试着回答某些可能出现的质疑:

 

问:显式析构和显式调用 close 有什么区别?不一样要程序员自己动手吗?
答:有区别。请参阅本文上篇中,关于 RAII 相对于显式释放资源方案的优势。

 

问:你允许程序员析构对象,那么可能出现指向已析构对象的指针,这和悬挂指针不是一样么?
答:不一样。如果你试图访问已析构对象,只会抛出一个异常(当然,这得通过智能指针来实现),而访问悬挂指针 …… 不用我多说吧?

 

问:(续上)这样一来,通过指针访问对象可能抛出异常,这是原来没有的。
答:这种说法不准确。在现有的 GC 机制下,对象占有的资源需要调用(例如)close 成员函数显式释放,而在调用了 close 之后,再试图访问这个对象一样会抛出异常,指明使用这个对象所需的资源已经被释放。而且,对于现有的方案,每种对象释放资源的函数可能都不同,标识“资源 已释放”的异常可能都不同,但如果使用显式析构的方案,释放资源的手段是一致的(析构对象),标识“资源无效”的异常也是一致的(“对象已析构”),这大 大减轻了程序员的簿记负担。

 

问:(再续)但是你多一个检查啊?每个指针访问都需要检查对象是否已析构,效率太低。
答:这种说法也不准确。采用显式调用 close 释放资源的方案,在该对象内部,每个完成实质性工作(因此需要访问对象所需的资源)的成员函数同样必须检查资源是否有效,也是一个检查。而且,这样的检查 程序员需要为每个类分别撰写,而采用上述显式析构的方案,只有智能指针需要做这个检查。

 

问:(再续)你上述两个问题的回答多少有点偏颇吧?并不是对象的每个成员函数都需要访问资源,你上述两个辩解对于不需要访问资源的成员函数是不成立 的。而且,也并不是所有的对象都需要管理资源,如果我想在支持 GC 的堆里放一个 int 怎么办?这样,“可能会抛异常”和“多一个检查”无疑是两个很大的缺点了吧?
答:嗯嗯,这可能是最有力的质疑了,我的回答是这样的:我会另外提供 一种不支持显式析构的智能指针,它的使用就像传统 GC 环境下的指针一样,也就没有“可能会抛异常”和“多一个检查”的问题。换句话说,如果你的对象不管理资源,或者你一定要在资源释放之后还能继续访问对象, 用它你可以退回到传统的 GC 方案去。

 

问:我就是不喜欢你这套想法!有意见吗?
答: …… 没意见。如果你确实无法接受“显式析构”的想法,你也可以只使用上面提到的另一种智能指针 —— 嗯嗯,除了“显式析构”之外,我对于如何在 C++ 中具体实现垃圾收集机制也有很多其他的想法,除了“显式析构”之外,我的收集器应该还会有其他方面的优点。当然,这是其他文章的内容了。

 

好,大致就是这样,希望大家多提意见。如果哪位对实现垃圾收集器这个具体工作有兴趣,请来这边坐坐:

http://www.allaboutprogram.com/bb/viewtopic.php?t=1520

分享到:
评论

相关推荐

    对象的释放和垃圾收集机制.txt

    ### 对象的释放与垃圾收集机制 #### 一、引言 在现代编程语言中,内存管理是一项重要的任务。...此外,随着技术的发展,新的垃圾收集算法和技术也在不断涌现,开发者需要持续学习以跟上最新的发展趋势。

    SimpleRAII:一个很小的 ​​C++ 垃圾收集助手类-开源

    这不仅意味着你可以免费地在自己的项目中使用这个库,而且可以学习到关于RAII和垃圾收集的实现细节。如果你发现库中有需要改进的地方,或者想要添加新的功能,你可以提交拉取请求,参与到项目的开发中。 在实际项目...

    spd:使用 ScopedPool RAII 垃圾收集和 GC 作为后备的工具库

    速度Scoped Pools D (spd) 是一组工具,通常需要 D 垃圾收集器才能运行。 但是,它们已被修改为在 memutils 的作用域池上运行。 作用域池按如下方式运行: Json json;{ auto pool = ScopedPool(); json = ...

    C++实现垃圾回收器

    在编程领域,垃圾回收(Garbage Collection, GC)是一种自动管理内存的技术,它负责跟踪和回收不再使用的对象,以防止内存泄漏。C++作为一种静态类型、编译式的语言,其标准库并不内置垃圾回收机制,程序员需要手动...

    C++实例——垃圾清理系统

    首先,C++标准库并没有提供内置的垃圾清理系统,如Java或.NET中的垃圾收集器。在C++中,程序员需要手动管理内存,通过`new`运算符分配内存,使用完后用`delete`来释放。这样的设计让C++具备了更高的性能和灵活性,但...

    第十一章 持有对象

    理解GC的工作原理,如分代垃圾收集、可达性分析等,对优化性能和避免内存问题至关重要。 4. 对象的生命周期管理:包括对象的创建、使用和销毁过程。理解何时创建对象、如何在对象之间传递以及何时安全地销毁对象,...

    内存管理算法与C,C++实现

    5. **垃圾收集**:C和C++不像Java或Python那样内置垃圾收集机制,但可以通过智能指针(如C++的`std::unique_ptr`、`std::shared_ptr`)来模拟自动内存管理,它们在对象不再使用时自动释放内存。 6. **内存池**:为...

    Java Monitor Pattern设计模式

    然而,Java不支持析构函数,而是依靠垃圾收集器(GC)来回收对象。对于非内存资源,如数据库连接或网络套接字,Java提供`finally`块来确保在任何情况下都能正确关闭资源。例如: ```java MyResource res = null; ...

    memplusplus:具有智能GC的C ++内存分配器

    垃圾收集是memplusplus的核心特性,其背后可能采用多种垃圾收集算法,如标记-清除、复制、标记-压缩或分代收集等。这些算法旨在高效地找出并回收未使用的内存,同时尽量减少程序运行时的暂停时间。 四、数据结构与...

    mem_out.rar_out

    在Java中,确保对象不再被引用时可以被垃圾收集。 四、www.pudn.com.txt文件可能包含的资源 这个文本文件可能是从www.pudn.com网站下载的相关资料链接,它可能提供额外的内存管理和优化技术,包括其他内存检测工具...

    C++与其他语言的比较.docx

    C++与其他语言的比较 ...此外,C/C++的运行行为是确定的,且不会有额外行为(例如C#/Java必然会初始化变量),也不会有如垃圾收集(GC)而造成的不确定性延迟,而且C/C++的数据结构在内存中的布局也是确定的。

    c++转换成c#代码

    - C++需要手动释放内存,C#的垃圾收集器自动回收不再使用的对象。 - C#有`using`语句,用于资源的自动清理,类似C++的RAII(Resource Acquisition Is Initialization)。 8. **异步编程**: - C#通过`async/...

    内存泄漏检测源码

    常见的内存泄漏检测方法包括跟踪分配和释放记录、使用智能指针、垃圾收集机制以及特定的内存调试库。在这个项目中,我们可以推测源码可能采用了跟踪分配和释放记录的方法。这种方法通过记录每次内存分配和释放的操作...

    东软 校园招聘考试套题 嵌入式 Java C++

    3. Java内存管理:理解垃圾回收机制,包括分代收集、可达性分析等,以及如何避免内存泄漏。 4. 多线程:了解并发编程的基本原理,如synchronized、volatile、ThreadLocal等,并能解决并发问题。 5. 设计模式:熟悉...

    WLambda:WLambda-Rust的可嵌入脚本语言

    WLambda-Rust的可嵌入脚本语言WLambda是Rust的嵌入式动态脚本语言,可以调用每个值... 没有垃圾收集器。 内存和资源管理仅依赖于引用计数和RAII。 您可以创建自己的放置函数。 通过不使用unsafe保持Rust的安全性。 WLa

    面试、笔试题目大全

    《面试、笔试题目大全》是收集了互联网上广泛流传的面试和笔试问题的资源集合,尤其包含了世界500强公司的面试题目。这份资料对于准备求职的IT专业人士来说,是一份极具价值的学习材料。它涵盖了C、C++、Java等编程...

    my-node-addon.zip

    9. **内存管理**: 考虑到JavaScript的垃圾收集机制,C++扩展需要小心地管理内存,确保不会产生内存泄漏。通常,使用V8的智能指针如`Local`和`Persistent`可以帮助管理对象的生命周期。 10. **测试和调试**: 编写...

    C++ 智能指针实现

    本文将深入探讨智能指针的实现机制,重点介绍`SmartPtr`,并分析其如何实现自动化垃圾收集,以提升C++程序的稳定性和效率。 #### 智能指针的理解与运用 智能指针是一种封装了原始指针功能的对象,它通过资源管理...

    Real time micro benchmark suite-开源

    - JVM性能:测量Java虚拟机的垃圾收集、类加载和JIT编译的效率。 - 并发编程:测试Java的并发原语,如线程池、原子变量和锁的性能。 - 序列化与反序列化:评估数据结构在内存和磁盘之间的转换速度,这对于网络...

    C-Practice:回购包含使用功能强大的表达语言C ++的许多问题

    "回购"在这里可能指的是代码重用或对象的回收机制,这在C++中是通过引用计数、智能指针和垃圾收集(在某些库中)来实现的。本练习旨在深入理解C++的这些特性,并通过解决实际问题来提升技能。 首先,让我们讨论C++...

Global site tag (gtag.js) - Google Analytics