文章目录
- 1. 在VS下的结构
- 2.在gcc下的结构
- 3.写时拷贝/共享内存
在之前的时间里,我们学习了string类的使用和模拟实现,但是在VS和g++下使用string,发现了一点问题,下面我们通过一段代码来重现一下这个问题
#include <iostream>
#include <string>
using namespace std;
int main()
{
string s1("11111");
string s2("22222222222222222222222222222222222");
cout << "s1: " << sizeof(s1) << endl;
cout << "s2: " << sizeof(s2) << endl;
return 0;
}
这段代码在VS2022下和g++下的运行结果如下:
注:g++的版本为
可以看到,同样的代码,string类对象在VS下的x86环境中大小为28个字节,但是在g++下的大小仅仅为8字节,这是为什么呢?
1. 在VS下的结构
这里我们只考虑x86环境下的情况
VS下的string类对象总共占28个字节,其中的结构稍微复杂一点,先是有一个联合体,联合体用来定义string中字符串的存储空间
- 当字符串长度小于16时,使用内部固定的字符数组来存放(即存放在栈上)
- 当字符串长度大于等于16时,再从堆上开辟空间存放
union _Bxty
{
// storage for small buffer or pointer to larger one
value_type _Buf[_BUF_SIZE];
pointer _Ptr;
char _Alias[_BUF_SIZE]; // to permit aliasing
}_Bx;
为什么要这样设计呢?
因为在大多数情况下,创建的字符串长度都小于16,如果此时一直使用从堆上开辟的空间,然后对象生命周期结束之后再释放,会导致堆上的空间碎片化,而且频繁调用内存管理函数,导致效率很低。如果采用这种设计方式的话,将会减少很多内存管理函数的调用次数,效率高,并且不易使堆上空间碎片化
其次:还有一个size_t字段保存字符串长度,一个size_t字段保存从堆上开辟空间总的容量
最后:还有一个指针做一些其他事情
所以string类对象共占16(buff[]的大小) + 4(char*类型的指针在x86环境下的大小) + 4 + 4 = 28
个字节
2.在gcc下的结构
在gcc下,string是通过写时拷贝实现的,string对象共占四个字节,其内部只包含了一个指针,该指针指向了一块对空间,内部包含了如下的字段:
struct _Rep_base { size_type _M_length;//字符串有效长度 size_type _M_capacity;//空间总大小 _Atomic_word _M_refcount;//引用计数 };
3.写时拷贝/共享内存
在上文上,我们提到了**写时拷贝(Copy-On-Write)**技术。是编程界的“懒惰行为”——拖延战术的产物。
下面我们看一段代码:
int main()
{
string s1("hello wordl");
string s2(s1);
printf("写时拷贝前,共享内存\n");
printf("s1:%p\n", s1.c_str());
printf("s2:%p\n", s2.c_str());
s2 += '!';
printf("写时拷贝后,内存不共享\n");
printf("s1:%p\n", s1.c_str());
printf("s2:%p\n", s2.c_str());
return 0;
}
按照我们的理解来说,s1和s2是两个不同的对象,所以两个对象的地址应该是不同的,但是我们发现在向s2中写入其他值之前,两个对象指向了同一块堆空间,这就是g++使用写时拷贝的证明。在往s2中写入新的内容之后,两个对象存放的值不同了,所以就没有办法共享内存了。
接下来有这么几个问题:
1. 写时拷贝的原理是什么?
写时拷贝使用了一个东西叫引用计数,所谓引用计数就是如果需要共享内存,那么用一个变量RefCnt来存放共享这块内存的对象个数,当RefCnt==0时,这块地址就没有对象使用,即可释放,否则就不能释放。当销毁一个对象的时候,首先判断他的RefCnt是否为0,如果不为0,那么就让RefCnt–,而不是直接销毁对象,增加一个共享内存的对象时也是同理。
2. string类什么时候才共享内存?
让我们想一下,共享内存最必要的条件是什么?是两个对象指向的内存空间中,存放的值完全相同,那么我们能想到的应该只有拷贝构造和赋值重载这两种情况。
3. string类什么时候才触发写时拷贝?
显而易见,当两个对象中存放的内容相同时就共享内存,不同时就不共享内存,也就是当其中的任意一个对象指向的值发生修改时,就触发写时拷贝。例如:+=,append,insert,erase等。
4. 在写时拷贝发生时,具体发生了什么?
在问题1中,我们提到了这个方面,就是访问到RefCnt这个变量,来判断具体需要做什么,我们看下面这段代码:
if(RefCnt > 0)//有对象共享这块内存时 { char* tmp = new char[strlen(_str + 1)]; strcpy(tmp, _str); _str = tmp; }
上面的代码是一个假想的拷贝方法,如果有别的类在引用(检查引用计数来获知)这块内存,那么就需要把更改类进行“拷贝”这个动作。我们可以把这个拷的运行封装成一个函数,供那些改变内容的成员函数使用。
5. 写时拷贝具体时怎么实现的
在上文中,我们提到了需要有一个变量RefCnt,但是最大的问题是这个RefCnt存放在什么位置。我们要满足的情况是对于所有共享内存的对象,共享一个RefCnt,相信这句话肯定能给大家启发,我们可以把这个RefCnt存放在共享的内存中。
于是,有了这样一个机制,每当我们为string分配内存时,我们总是要多分配一个空间用来存放这个引用计数的值,只要发生拷贝构造或赋值时,这个内存的值就会加一。而在内容修改时,string类为查看这个引用计数是否为0,如果不为零,表示有人在共享这块内存,那么自己需要先做一份拷贝,然后把引用计数减去一,再把数据拷贝过来。下面的几个程序片段说明了这两个动作
//构造函数(分存内存) string::string(const char* tmp) { _size = strlen(tmp); _str = new char[_size + 1 + 1]; strcpy( _str + 1, tmp );//在数据区之前一个char用来存放RefCnt _str[0] = 0;//设置引用计数 } //拷贝构造(共享内存) string::string(const string& str) { if (*this != str) { _str = str.c_str(); //共享内存 _size = str.size(); _str[0]++; //引用计数加一 } } //写时才拷贝Copy-On-Write void string::COW() { _str[_size + 1]--; //引用计数减一 char* tmp = new char[_size + 1 + 1]; strncpy(tmp, _str, _size + 1); _str = tmp; _str[0] = 0; // 设置新的共享内存的引用计数 } string& string::push_back(char ch) { COW(); if(_size == _capacity) { size_t newCapacity = _capacity == 0 ? 4 : 2 * _capacity; reserve(newCapacity); } _str[_size] = ch; ++_size; str[_size] = '\0'; } char& string::operator[](size_t pos) { assert(pos <= _size || _str == nullptr); COW(); return _str[pos]; } //析构函数的一些处理 ~string() { if(_str[0] == 0)//引用计数为0时,释放内存 { delete[] _str; } else//引用计数不为0时 { _str[0]--;//引用计数减一 } }
写在最后:
- 上述对写时拷贝和共享内存的讲解仅仅是原理上的讲解,和stl库中实现的可能会有所差别与简化,请忽略这些,搞懂原理即可。
- 这种写法终归是有炫技的成分在其中,使用时可能在某些地方出现bug,甚至使程序crash掉
- 在C++的使用和设计中,需要注意的细节点有很多,可能你觉得发现了一个非常巧妙的设计,但是很有可能在某些地方就会出现难以修改的bug,所以在使用C++时,一定要对原理有充分的了解。
参考博客:这里推荐陈皓大佬的写时拷贝