Skip to content
平兄聊技术
Go back

一个简单的c++ 内存分析

最近没有写 c++,那天帮同事看了一个 c++ 程序的内存段错误问题。问题当时倒是很快就解决了,但细想起来,还是有一些疑点的。在这里分析一下。

当时的代码大致是这样:

struct Base {
 int a[3];
};

struct Derived : public Base {
 int b[3];
};

class FunClass {
public:
 int alloc(int type) {
  if (type == 1) {
  Base* p = new Base[5];
  data.reset(p);
  } else {
   Derived* p = new Derived[5];
   data.reset(p);
  }
 }

private:
 std::shared_ptr<void> data;
};

alloc 根据外部传来的某些参数,创建对象数组,并用 shared_ptr智能指针管理这片内存。程序总是稳定的在第二遍运行的时候,core 在17行。

为了 data 既能管理 Base 类型数组的内存,又能管理 Derived 类型数组的,作者抹去了其类型,使用的模板类型参数 void。这是一种 C 的思维,又想使用 c++ STL 库便利的写法。当然,熟知 OOP 的我们,可以把模板参数变为 Base,便能轻松解决问题。

但当时,我也是一下子没转过思路来,只是隐约觉得这个 shared_ptr 哪里不对劲。想了想,new 的时候是数组,但 shared_ptr::reset 可不会自动识别成数组。故在 14,17 行的位置,分别改成了

data = std::shared_ptr<void>(p, &std::default_delete<Base[]>());
data = std::shared_ptr<void>(p, &std::default_delete<Derived[]>());

好了,故事到这里基本上就结束了,问题得到了解决。就好比

void fun() {
 B* b = new Base[3];
 ...
 delete b;
}

new/deletenew[]/delete[] 没有配对使用,混用导致了内存错误。


但是且慢,我且问你,为什么一定要配对使用?

我们知道,c++ 的new≈ c 的 malloc + constructor,delete≈ destructor + free 。而在 malloc 时,malloc 返回的地址,并不是 libc 直接向系统申请得到的内存首地址。而是向后挪了几个字节,系统申请得到的前几个字节,用来记录这段内存的长度了。也就是为什么,我们在 malloc 的时候,需要指明内存的长度,而在 free 的时候,却不需要申明长度,会恰到好处的把我们所申请的全部返回给系统。

好了,那么在 new [] 时呢?我们当然可以不假思索地脱口而出了, new[] = malloc + constructor(for each)。那么问题来了,既然同样是 malloc 得到的内存,为什么不能用 free 来释放这段内存?

可以一起来推理一下了。既然是混用会出现错误,那么显然是在 delete/free 的时候出现了问题。既然 free 会依赖所给地址向前的一段空间来得到所需释放的长度,那么显然是在这段内存空间的排布上,有所区别。

不难想象, new[] 版本中,这段空间不仅仅是记录了整个堆空间的大小,应该还记录了每个对象的大小(不然为什么要区分两种不同的堆分配操作?)。当 delete 错误地解析了这段内存空间,释放内存出现 core 也就不奇怪了。

疑问到此并没有消失。那为什么人家 c 用 malloc/free 就能统一操作,你 c++ 就一定要区分分配单个对象和分配对象数组的操作呢?回想一下,在 c 里,如果我们想在堆上分配一个数组,我们会怎么做?

B* b = malloc(sizeof(B) * num);

是了,我们计算出整个所需空间的长度,一次分配出来,然后手动地通过偏移量,计算每个对象的地址。也就是说在 libc 的层面上,压根没有对象数组这一概念,这一切都是用户(libc 的用户)自己对这块内存的逻辑划分。那么 c++ 作为 c 在类型安全上的提升,自然是把对象数组划入了其内存管理的范围,毕竟 c++ 的运行时自己就需要每个对象的地址,以为之调用构造函数。

那么 c++ 为什么不在运行时模拟 c 在 libc 用户层面的逻辑,通过偏移量去计算对象的地址?因为 c++ 存在,父类指针实际指向的是子类对象,就像我前面提到的一样。在这种情况下,sizeof 计算出来的每个对象的大小,显然是不准确的。所以当使用 delete[] 时,前面预留的空间,很有可能不仅仅记录了整块堆空间的大小,也记录了计算每个对象地址时所需的偏移量。

到此,变得清晰多了。new[] 时,预留空间的内存排布与 new 时并不相同,故在 delete/delete[] 时也需要做出区分,否则读错了堆空间大小,自然会导致 segment fault 错误。


好了,总结一下。解决问题后,我们刨根究底的方式,是通过当前的现象与设计,反复反问自己,为什么需要这么做?如果不这么做,会有什么影响?这么做,是为了解决什么问题?通过不断地迭代反问,得到了当前的答案。此外,我们其实一直是在猜想,“可以想象”等句式也是持续在文中出现。正如我一位导师教我的,一开始来自于技术上的直觉,可以是毫无根据的,但需要我们推理来证实这个直觉。也一如爱因斯坦谓之思想实验,总是先在脑海中将事情的细节都推敲一遍。

是的,为了给自己的不去从代码考察事实找补,上了挺大的价值观了,哈哈!


最后,其实草稿箱里堆积了两篇未完工的旧文了。一看时间,最近的一篇竟也是今年1月份了。而这篇文章,不过是我今天洗澡时思考的过程,洗完出来便立马着手一挥而就了。可见澡堂才是人类灵感的源泉,以及三鼓而竭古之人诚不我欺。想做什么事,应该毫不犹豫去做就是了,先不要想着做得多完美(比如各种格式/图例),带有缺憾的完成,以后可以逐步去完善它,而看似完美的半成品,因永远无法面世而毫无意义。


Share this post on:


Previous Post
2024 读书总结
Next Post
2023 读书总结