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

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

在之前的时间里,我们学习了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++下的运行结果如下:

62b46d57090b684bfd47242887c30f4a.png

01997edeff2c0f95ddaf395b331beb51.png

注:g++的版本为

ce67a442612f2ca097d006be539fa2e1.png

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

0d1f792567957bf883a5dbc7fd5b1c66.png


2.在gcc下的结构

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

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

95c6e151fcc060b1193758da1f645e05.png

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


04cab7100021eebe2c144feb793dc53b.png

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

1aa453be9a614c69954221d6eb7b4f9d.png

于是,有了这样一个机制,每当我们为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++时,一定要对原理有充分的了解。

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

相关文章
|
7天前
|
存储 安全 Java
【JAVA基础】String、StringBuilder和StringBuffer的区别——巨详细
String是不可变的,StringBuilder和StringBuffer是可变的。而StringBuffer是线程安全的,而StringBuilder是非线程安全的。
|
7天前
|
安全 Java 调度
Java基础面试,String,StringBuffer,StringBuilder区别以及使用场景
* String是final修饰的,不可变,每次操作都会产生新的对象。 * StringBuffer和StringBuilder都是在原对象上进行操作 * StringBuffer是线程安全的,StringBuilder是线程不安全的。 * StringBuffer方法是被synchronized修饰的
|
7天前
|
安全 Java 编译器
Java中String、StringBuilder和StringBuffer的区别
Java中String、StringBuilder和StringBuffer的区别
11 1
|
7天前
|
Python
python中split_string和substring区别
python中split_string和substring区别
57 1
|
7天前
|
安全
String、StringBuuffer、StringBuilder三者的区别
String、StringBuuffer、StringBuilder三者的区别
|
7天前
StringBuilder和StringBuffer区别是什么?
StringBuilder和StringBuffer区别是什么?
|
7天前
|
存储 安全 Java
面试官:请聊一聊String、StringBuilder、StringBuffer三者的区别
面试官:请聊一聊String、StringBuilder、StringBuffer三者的区别
40 8
|
7天前
TextUtils.isEmpty()和String.isEmpty()的区别
TextUtils.isEmpty()和String.isEmpty()的区别
15 1
|
7天前
|
存储 缓存 安全
JAVA面试:String、StringBuffer和StringBuilder区别
`String`是不可变的,`StringBuffer`和`StringBuilder`是可变的。`String`的不可变性源于其内部的`final char[]`数组,这意味着每次修改都会创建新对象。`StringBuffer`线程安全,方法同步,适合多线程环境,但效率较低;`StringBuilder`非线程安全,无同步,单线程中效率更高。两者初始容量相同,扩容机制也一样。
29 0
|
7天前
|
存储 算法 安全
【数据结构与算法初学者指南】【冲击蓝桥篇】String与StringBuilder的区别和用法
【数据结构与算法初学者指南】【冲击蓝桥篇】String与StringBuilder的区别和用法