在VS和g++下的string结构的区别

文章目录

      • 1. 在VS下的结构
      • 2.在gcc下的结构
      • 3.写时拷贝/共享内存

在之前的时间里,我们学习了string类的使用和模拟实现,但是在VS和g++下使用string,发现了一点问题,下面我们通过一段代码来重现一下这个问题

#include 
#include 
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++下的运行结果如下:

在VS和g++下的string结构的区别_第1张图片

在VS和g++下的string结构的区别_第2张图片

注:g++的版本为在VS和g++下的string结构的区别_第3张图片

可以看到,同样的代码,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个字节

在VS和g++下的string结构的区别_第4张图片

2.在gcc下的结构

在gcc下,string是通过写时拷贝实现的,string对象共占四个字节,其内部只包含了一个指针,该指针指向了一块对空间,内部包含了如下的字段:

struct _Rep_base
{
       size_type          _M_length;//字符串有效长度
       size_type          _M_capacity;//空间总大小
       _Atomic_word       _M_refcount;//引用计数
};

在VS和g++下的string结构的区别_第5张图片

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;
}

在VS和g++下的string结构的区别_第6张图片

按照我们的理解来说,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存放在共享的内存中。

在VS和g++下的string结构的区别_第7张图片

于是,有了这样一个机制,每当我们为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]--;//引用计数减一
    }
}

写在最后

  1. 上述对写时拷贝和共享内存的讲解仅仅是原理上的讲解,和stl库中实现的可能会有所差别与简化,请忽略这些,搞懂原理即可。
  2. 这种写法终归是有炫技的成分在其中,使用时可能在某些地方出现bug,甚至使程序crash掉
  3. 在C++的使用和设计中,需要注意的细节点有很多,可能你觉得发现了一个非常巧妙的设计,但是很有可能在某些地方就会出现难以修改的bug,所以在使用C++时,一定要对原理有充分的了解。

参考博客:这里推荐陈皓大佬的写时拷贝

你可能感兴趣的:(一些奇技淫巧,linux,c++)