[C++]记一次由placement new引发内存泄露的惨痛教训

背景

由于项目中用到一个类,那个类的构造方式有点麻烦,而且它的构造函数有很多个,每一个构造函数都或多或少与其他的构造函数有点相似,只是少了一些参数而已,于是想到可以在另一个构造函数中,利用placement new在this指针的地方来构造,本来想着因为它是在对象本身的地址的地方,不会有内存泄漏出现,但是问题还是出现了。

placement new

new操作符相信我们大家都很熟悉,在此就不介绍了,而placement new相信大家用得都比较少。一般来说,我们new出来的对象一般都是放在进程内存划分中的自由存储区中。而new一个对象通常需要先malloc一块对应大小的内存空间,然后再在该内存空间上调用构造函数。然而,malloc是需要一点代价的,它需要去系统找一块可用的对应大小的内存空间给你,虽然malloc内部也维护了自己的一套内存管理,但是终究还是不如直接拿着一个地址空间去操作来得快速。所以有些情况下,我们为了提高效率,或者出于别的目的,可能会需要在已分配的特定内存构造对象。最常用的是例如在我们实现对象池的时候。

他的用法简单如下

struct MyStruct {
	MyStruct(int a, int b) {
		_a = a;
		_b = b;
	}
private:
	int _a = 0;
	int _b = 0;
};

int main() {
	void* p = malloc(sizeof(MyStruct));
	MyStruct* p2 = new(p) MyStruct(1, 2);
	return 0;
}

问题的出现

而在本次问题中,因为构造函数的数量比较多,为了减少构造函数的编写,采用了placement new的形式。定位到出问题的地方其问题模型最终简化如下:

首先先看看版本一:内存正常的版本

struct MyStruct {
	MyStruct(int b) {
		_b = b;
	}
	MyStruct(int a, int b) {
		new(this)MyStruct(b);
	}
private:
	int _a = 0;
	int _b = 0;
};

int main() {
	while (true) {
		MyStruct m(1, 1);
	}
	return 0;
}

vs2019下查看内存占用情况如图:
没有内存泄漏版本

查看内存占用情况,发现内存占用平平无奇。

其次看看版本二:存在内存泄漏的版本

#include <string>

struct MyStruct {
	MyStruct(std::string b) {
		_b = b;
	}
	MyStruct(int a, std::string b) {
		new(this)MyStruct(b);
	}
private:
	int _a = 0;
	std::string _b;
};

int main() {

	while (true) {
		MyStruct m(1, "hello");
	}
	return 0;
}

vs2019下查看内存占用情况图:
内存泄漏版本

查看程序的内存消耗情况,可以很明显的看出内存在不断泄漏中。

以上情况在Centos7和vs2019环境下都测试过,情况一样,以上以vs测试环境来阐述问题

问题分析

我们仔细分析这两种写法得知,版本一和版本二唯一的区别就是版本二用到了std标准库的模板类std::string。实际测试中发现,只要用到了模板库中的模板类,都会出现内存泄漏问题。原来问题就出在了模板类上。这到底是怎么回事。
查来查去,网上查阅了多方资料,终于得出了结论:
原来,对于类对象的基础类型成员来说,基础类型所需要的内存空间大小始终是原先的那块空间那么大,无论如何都跳不开那块空间,无论它怎么初始化,怎么构造,都还是在原来的空间上去赋值,所以没啥问题。
然而,对于std标准库中的模板类,它们自带有一套内存分配手段。我们以std::string为例来描述。std::string它会在内存空间中预先申请额外的空间去存放接下来的每一个字符,如果append进去的字符超出了他的长度,那么它会去重新申请一块空间去存放当前的所有字符,旧的那些字符会一个个复制到新申请的内存空间中,然后析构掉旧的空间。我们持有的一个std::string的变量,其实是另一个对象,它内部维护了它真实的字符串内存,而它自己则相当于其内部字符串空间的一个地址别名而已。

可以通过查看std::string的大小

int main() {
	std::string s1 = "1234";
	std::cout << "s1 " << sizeof(s1) << std::endl;
	std::string s2 = "12345678";
	std::cout << "s2 " << sizeof(s2) << std::endl;
	return 0;
}

程序输出如下

s1 40
s2 40

可以看出它占用的大小并不是它真实的内部需要分配空间的大小

那么他是怎么释放它内部额外分配的空间的呢,我们通过查看std::string的源码发现,它是在析构函数的时候回去释放掉他的空间的。

析构函数如下

    ~basic_string() noexcept {
        _Tidy_deallocate();
#if _ITERATOR_DEBUG_LEVEL != 0
        auto&& _Alproxy          = _GET_PROXY_ALLOCATOR(_Alty, _Getal());
        const auto _To_delete    = _Mypair._Myval2._Myproxy;
        _Mypair._Myval2._Myproxy = nullptr;
        _Delete_plain_internal(_Alproxy, _To_delete);
#endif // _ITERATOR_DEBUG_LEVEL != 0
    }

所以,问题就发现了,当这个string作为我们的对象成员的时候,实际上占用的大小一直都是那么大,无论你string大小多少。虽然我们用placement new的时候确实是在原先对象的那块空间上进行构造,但是每次用new(this)构造一次那块空间,string成员都会去别的地方再去申请一块新的空间去存放它的实际内容,而这块空间与this的那块地址空间是独立的,不连续的,不包括在this空间大小中的。如果我们的对象按照正常流程创建和析构,当然没有什么问题,因为当析构对象的string成员的时候会调用string的析构函数。但是,现在我们在构造函数中又用了placement new,却没有地方可以供这个string成员去调用它的析构函数,所以这个string内部申请的空间一直都没地方去释放它。当然会导致内存泄漏啦。

替代方案

既然找到了问题的原因,那么解决方案就容易了,我们可以有如下的解决方案。

1:换成委托构造函数
C++11扩展了构造函数的功能,它能够在执行一个构造函数的时候,委托其他构造函数执行它自己的初始化过程。写法如下:

struct MyStruct {
	MyStruct(std::string b) {
		_b = b;
	}
	MyStruct(int a, std::string b) :MyStruct(b) {}
private:
	int _a = 0;
	std::string _b;
};

int main() {

	while (true) {
		MyStruct m(1, "ab");
	}

	return 0;
}

可以看出内存占用正常

2:显式调用析构函数
既然是由于placement new的时候,没有地方调用this对象的析构函数导致std::string无法析构内部开辟的空间,那么我们就想到在placement new之前显式的调用this的析构函数去迫使它析构掉std::string内部占用的空间,代码如下:

struct MyStruct {
	MyStruct(std::string b) {
		_b = b;
	}
	MyStruct(int a, std::string b) {
		this->~MyStruct();
		new(this)MyStruct(b);
	}
private:
	int _a = 0;
	std::string _b;
};

int main() {
	while (true) {
		MyStruct m(1, "1");
	}
	return 0;
}

查看内存占用情况,内存占用正常

值得注意的是:我们还是推荐使用委托构造函数。因为在构造函数的时候调用析构函数不知道会发生什么,这种思路从一开始就是错的,因为没人会这么做,也没有规定能这么做。构造都还没完成就想着去析构,这本来就是不对的。所以以防万一还是使用标准规定的。

  • 2
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值