2012年软考软件设计师辅导:漫谈C++内存分配失败

来源:软件水平考试    发布时间:2012-11-04    软件水平考试视频    评论

  没错,是“漫谈”,而且“漫”得有点乱。然而,抛砖尚可引玉,想到的事情,纵然脉络不那么顺,写下来也不是坏事。开卷有益,动笔也有益。

  一切缘自一位C语言开发经验丰富的的朋友问我的一个问题。朋友问:“C++中的new在分配内存失败时会抛出异常(std::bad_alloc)而不返回0(一些老的编译器可能还在返回0,但这样的编译器实在”太老了“),这跟C程序员的做法很不一样。而且,许多C++程序在使用new创建对象时也根本不检查这种异常。这是一种什么哲学呢?”他还提到:“一般C程序员总会判断一下malloc失败的情况,就连Linux内核中都是如此。”

  当时,我首先想到的是:一般用C++实现的应用层程序,内存管理方面自不能与内核程序相提并论。OS内核需直接管理物理内存,所有应用程序的地址空间都由它映射而来,然后依靠它建立的机制进行翻译。内核如果在内存管理方面不保险,应用层还怎么过日子?另外,内核中的内存分配尚须考虑许多其它问题,比如不同区域的不同特性(像某些DMA使用的buffer要物理连续且位于特定位置)。应用层程序则不一样,它们一般拥有flat的虚拟内存空间,且数量上通常远大于物理内存。因此,一个应用程序如果能耗尽虚拟内存,那要么是对数据的规模估计不足,要么就是一个必需专门解决的严重bug.耗尽虚拟内存跟其它许多严重的bug(再如缓冲区溢出导致的堆栈破坏)一样,即使能检测到也常常无计可施,如果“有计可施”,那何不早施此计?何苦等它发生再亡羊补牢呢?反过来想,该失败的时候痛痛快快的快速失败,这不算坏事。至少,比带着问题继续运行半小时,然后在某个完全不相干的地方发生莫名其妙又难以重现的bug要好得多。

  这是我当时给朋友的回答,朋友勉强同意了,至少不再纠结C++程序员因何不在new的时候检查std::bad_alloc了。然而,顺着这个问题,我自己又联想到好多东西。

  (1)首先想到的是Java语言的做法。Java中的变量都是引用(基本类型的除外),而被引用的对象是用new在堆(heap)上创建的。在Java中new一个对象的时候,理论上也有可能引发java.lang.OutOfMemoryError.当然,这是个Error,不是从java.lang.Exception派生的“异常”,因此语言并没强制我们catch它。然而,语言是否要求并不重要,语言为什么不要求才是重要的。显然,如果问题真的很严重,即使语言不要求,Java程序员也会在每一处new的周围包上try/catch.可Java程序员没有这么做。为什么?我想关键的原因跟上面是一样的:一个应用程序耗尽虚拟内存,要么是对数据的规模估计不足(是否应通过java命令的-Xm系列参数设置更大的heap呢?),要么就是一个必须专门解决的bug.同时,相对C++来说,Java程序中采用这一决策还有更充分的理由:因为有GC机制,Java程序中不太会有因为粗心造成的内存泄露(顶多有因不良设计造成的内存伪泄露)。

  (2)C++中的“new”还不只是分配内存那么简单。对于用户自定义的类型来说,“new T;”相当于operator new再加上对T的构造函数的调用。由于类的构造函数完全可能引发异常,于是,就算内存分配一切顺利,一条new语句还是可能产生异常。看来,需要catch的不止std::bad_alloc.

  (3)暂不考虑“哲学”因素,如果有人仍然觉得应该像C程序那样严格检查内存分配,可不可以呢?当然可以,毕竟它还能抛出异常么,它能抛出我们就能捕捉。于是人们自然会想:C++或Java程序员用驼鸟策略对付内存分配的失败,异常在使用上比较麻烦会不会是原因之一呢?表面看是显然的:每分配一次内存都要包上一层try/catch,跟C中的针对返回值的if/else风格比起来凌乱多了。

  实际上,那不是使用异常的正确方法。如果异常只是if/else的简单语法替代物,那它根本就没有存在的必要。异常的好处之一(真的只是“之一”)是:一个异常只需一个地方处理就足够了。比如下面这样:view plain void f1() { try { // ……

  f2();} catch (const some_exception& e) { // ……

  }

  void f2() { // ……

  f3();}

  void f3() { // ……

  f4();}

  void f4() { // ……

  throw some_exception();} f4惹祸,f1收场,中间f2和f3只是一脸无辜地把异常“透过去”了(在Java中尚需声明一下)--原因很可能是它们不具备足够的上下文来处理这个异常。于是,我们不用像使用返回值那样,从发生问题的地方开始,到处理问题的地方“之下”,中间每一层都要判断一下,从而写下一层层诸如:view plain x = f();if(x < 0)

  return x;之类的语句。这也有利于逻辑分层。

  值得一提的是,在异常回滚的过程中,栈上已经构造好的对象都会正常析构。当然,这要求程序员在设计类的时候要考虑“异常安全”的因素。

  关于异常处理的思想和异常的使用,完全可以讲一本书。更有兴趣的朋友可以去找些相关书籍看看。

  (4)事实上,C++中并非只有抛出异常的new,也有不抛异常的new,即通常所说的“nothrow new”。可以这样使用它:view plain #include <new> // ……

  T* p = new (std::nothrow) T(/* …… */);其中,nothrow是头文件<new>中定义的一个类型为std::nothrow_t的常量,我们可以直接使用它。这时,如果内存分配失败,p的值将为空(0),且不会有异常抛出,跟C的malloc很像了。

  nothrow new实际是标准库中实现的operator new和operator new[]的重载。我们也可以根据需要自己重载operator new/operator new[],可以有全局的,也可以针对某个类重载。但实践中用的不多。

  注意,使用nothrow new创建对象时,只能保证不会因为operator new或operator new[]的失败而抛出std::bad_alloc,但难保对象的构造函数不会抛出其它异常,甚至就抛出std::bad_alloc.

  (5)说到C++的内存分配,还有必要提一下set_new_handler.它允许你设置一个可以在operator new和operator new[]分配内存失败时可以回调的函数。如果你觉得还有什么办法能释放一些内存的话,这个回调函数就是最后的救命稻草了。

  (6)话说回来,多线程程序中,尤其是所谓的worker thread中,在线程函数退出之前使用“catch(……)”捕捉一下所有异常(不止std::bad_alloc)也不是完全没用。别指望能恢复什么,只求不要因为一个线程而挂掉整个程序,同时尽量保证一下数据一致性就好。另外,也别指望catch(……)能捕获一切“问题”或“bug”,没有那么好的事情。它只能捕获C++的异常,其它的问题,比如前面提到的堆栈破坏,再比如野指针访问,哪有那么容易检测得到。

  通常一个线程crash会导致整个进程crash,有人因为这个原因而更倾向于使用多进程,尤其是在类Unix的环境中。我个人对此虽不反对也不是特别赞同,因为欠债总是要还的,这也包括“技术债务”:有bug迟早还是要解决。不过,使用多进程还有别的好处,因为进程间共享数据比同一个进程的线程之间要麻烦得多,这会“迫使”开发者做出减少共享,从而既能减少并发问题又能提高并发效率的设计。

  (7)最后,我的另一个好朋友兼同事认为:程序crash没有那么可怕。它可能是多数客户最难以忍受的bug,但那只是源于社会心理,不见得是真正最严重的bug.

视频学习

我考网版权与免责声明

① 凡本网注明稿件来源为"原创"的所有文字、图片和音视频稿件,版权均属本网所有。任何媒体、网站或个人转载、链接转贴或以其他方式复制发表时必须注明"稿件来源:我考网",违者本网将依法追究责任;

② 本网部分稿件来源于网络,任何单位或个人认为我考网发布的内容可能涉嫌侵犯其合法权益,应该及时向我考网书面反馈,并提供身份证明、权属证明及详细侵权情况证明,我考网在收到上述法律文件后,将会尽快移除被控侵权内容。

最近更新

社区交流

考试问答