0%

C++内存管理

C++内存分配方式

内存分配方式

五大区:栈、堆、自由存储区(内存池)、全局/静态存储区和常量存储区

以下地址空间由高到低

  • :函数局部变量、参数返回值等。使得局部变量生存周期就是申请到释放该段空间。由高到低增长,此外,采用先进后出的数据结构,由系统自行分配释放。
  • :由new控制的内存块,由delete释放,一般用于动态内存分配。由低到高增长,一般由程序员分配释放,不释放则由系统释放。
  • 自由存储区(内存池):由malloc分配的内存块,由free释放。
  • 全局/静态存储区(数据段):顾名思义存储全局变量与静态变量的地方,在C语言中,这块地方分为bss段与data段,分别代表未初始化的全局变量与已初始化的全局变量和静态变量。
  • 常量存储区(text段):也就是代码段,在内存中是属于“只读”,储存常量的地方。

 

区分堆与栈

先上六大总结:

  • 管理方式不同
  • 空间大小不同
  • 能否产生碎片不同
  • 生长方向不同
  • 分配方式不同
  • 分配效率不同

管理方式:栈由编译器控制管理堆手动控制容易内存泄漏

空间大小:堆内存非常大(几乎无限制);而栈很小(有限制

碎片问题:堆的频繁new/delete操作使得它容易产生大量碎片;而栈由于先入后出的特性而不容易产生碎片

生长方向:堆向上(向高)增长;栈向下(向低)增长

分配方式:堆是动态分配;栈既有动态(alloca)也有静态分配,且栈的动态分配由编译器释放

所有的静态分配都有编译器释放

分配效率:栈效率更高。由于栈有专有的数据结构,有专门的指令进行操控,有专门的寄存器地址;而堆是函数库中的,在调用其空间时甚至需要用到调用算法等。

//main.cpp
int a = 0; 全局初始化区
char *p1; 全局未初始化区
main()
{
    int b; 栈
    char s[] = "abc"; 栈
    char *p2; 栈
    char *p3 = "123456"; 123456\0在常量区,p3在栈上。
    static int c =0; 全局(静态)初始化区
    p1 = (char *)malloc(10);
    p2 = (char *)malloc(20);
    分配得来得10和20字节的区域就在堆区。
    strcpy(p1, "123456"); 123456\0放在常量区,编译器可能会将它与p3所指向的"123456"优化成一个地方。
}

指针与数组

实际上,指针与数组是两种截然不同的概念,但指针与数组在使用过程中相似的地方却经常会被弄混淆。

数组是一块内存的表现形式,而数组名则对应着一块内存(指向数组首地址),内容是可改变的。

指针是用于指向任意类型内存块的,经常用指针来操作动态内存,和数组“变内容”的特性不同,指针是“变指向”的。


e.g1:

char a[] = “hello”;
a[0] = 'X’;
cout << a << endl;
char *p = “world”; // 注意p指向常量字符串
p[0] = 'X’; // 编译器不能发现该错误
cout << p << endl;

字符数组a的内容是hello,而a[0]的内容理所应当就是‘h’,由于数组可变内容,所以可以直接用a[0]='X'来变化内容

指针p指向的是常量字符串world,字符串的特征是变指向,此时p[0]也理所应当表示‘w’,p[0]='X'对于编译器来说是没问题的,但是常量字符串不能改变内容(world就是world),所以这个操作其实是错误的


e.g2:

char a[] = "hello world";
char *p = a;
cout<< sizeof(a) << endl; // 12字节
cout<< sizeof(p) << endl; // 4字节

可以直接使用sizeof来计算出数组的容量,而C++没办法得知指针所指的内存容量,所以最终得出的是指针变量的字节数

此外,使用函数传递数组后,数组的实质就变成同类型指针了

void Func(char a[100])
{
 cout<< sizeof(a) << endl; // 4字节而不是100字节
}

 

指针,函数参数与内存

在学C语言指针的时候认识到

# include <stdio.h>
void Swap(int *p, int *q);  //函数声明
int main(void)
{
    int i = 3, j = 5;
    Swap(&i, &j);
    printf("i = %d, j = %d\n", i, j);
    return 0;
}
void Swap(int *p, int *q)
{
    int buf;
    buf = *p;
    *p = *q;
    *q = buf;
    return;
}

函数内指针是直接对内存单元进行操控的,传递了数据的地址,再顺藤摸瓜对本体进行操作。

但是当这种效应利用在申请动态内存中时,比如说需要用函数建立链表结点之类,需要在函数内申请动态内存。

void GetMemory(char *p, int num)
{
 p = (char *)malloc(sizeof(char) * num);
}

void Test(void)
{
 char *str = NULL;
 GetMemory(str, 100); // str 仍然为 NULL
 strcpy(str, "hello"); // 运行错误
}

在前面的例子中,传入函数的指针其实传递的是地址,在函数内用过取缔值*直接操作本体,而这个例子中在函数内是改变了参数本身的地址的,也就是说”p“传入函数中,即将”NULL“地址传入函数,但动态申请内存导致指针p所指向的地址发生改变了,所以实质就不是在操作str了,而是在操作p。

除非用指向指针的指针来进行操作,这样一来相当于传入”指针的地址“(注意不是“指针所指的地址”),在函数体中对”指针的地址“取蒂值然后操作,就变成了直接对指针进行操作了。

void GetMemory2(char **p, int num)
{
 *p = (char *)malloc(sizeof(char) * num);
}

void Test2(void)
{
 char *str = NULL;
 GetMemory2(&str, 100); // 注意参数是 &str,而不是str
 strcpy(str, "hello");
 cout<< str << endl;
 free(str);
}

有一个更好的方法,那就是设置函数返回值类型为指针,然后返回函数指针。

char *GetMemory3(int num)
{
 char *p = (char *)malloc(sizeof(char) * num);
 return p;
}

void Test3(void)
{
 char *str = NULL;
 str = GetMemory3(100);
 strcpy(str, "hello");
 cout<< str << endl;
 free(str);
}

这样一来p所指地址发生改变,但是这个改变的地址又传回了str,所以str指向的地址一致。

上文提到栈内存在函数消亡时自动消亡,所以最好不要这样返回:

char *GetString(void)
{
 char p[] = "hello world";
 return p; // 编译器将提出警告
}

void Test4(void)
{
 char *str = NULL;
 str = GetString(); // str 的内容是垃圾
 cout<< str << endl;
}

p是局部变量,是储存在栈区内的,如果返回p的正常值,则正常返回;如果是局部变量的地址或者引用,则返回不出东西,因为返回出地址后变量值已经失效。

上面的动态分配是储存在堆区内的,不手动free或者delete是释放不了内存的,所以函数结束时能保存内容。


看了其他博主的解释,发现了个有点意思的代码(注意注释)

#include<iostream>
using namespace std;
 

int * func()
{
int a = 10;
return &a;
}

int main()
{
int *p = func();
cout << *p << endl; //第一次正常返回,因为编译器操碎了心,给数据做了一个保留
cout << *p << endl; //第二次结果错误,因为a的内存已经释放
cout << *p << endl; //结果仍然错误
return 0;
}

为什么上面字符串值不保留呢,很神必


 

小心野指针

  • 最”常见“的野指针
char *p;
char *str;
char *p = NULL;
char *str = (char *) malloc(100);

为初始化的指针会随便乱指,小心它撒野!

  • 最“暗淡“的野指针

指针p被free或者delete之后,没有置为NULL,让人误以为p是个合法的指针。

  • 最”忽视”的野指针
class A
{
 public:
  void Func(void){ cout << “Func of class A” << endl; }
};

void Test(void)
{
 A *p;
 {
  A a;
  p = &a; // 注意 a 的生命期
 }
 p->Func(); // p是“野指针”
}

 

以上,就是C++内存管理的肤浅、初步、入门探索,健壮指针资源管理is coming。。。。。。

 

内存泄漏

最接地气的解释就是:你用了malloc,用了new,结果没有free也没有delete掉,那么这段存在堆中的内存就被”孤立“了,就相当于拨款被”赃“了,导致了系统内存浪费使得程序运行效率降低。

如何避免内存泄漏呢

教科书式方法必然就是什么”良好编码习惯“”及时释放内存“云云。

有几种情况其实简介避免了内存泄漏

  • 标准容器

举这么一个例子:

#include<vector>
#include<string>
#include<iostream>
#include<algorithm>
using namespace std;
int main() // small program messing around with strings
{
 cout << "enter some whitespace-separated words:"n";
 vector<string> v;
 string s;
 while (cin>>s) v.push_back(s);
 sort(v.begin(),v.end());
 string cat;
 typedef vector<string>::const_iterator Iter;
 for (Iter p = v.begin(); p!=v.end(); ++p) cat += *p+"+";
 cout << cat << ’"n’;
}

假如没有vectorstring这么个好东西,我们手动去malloc去分配去free,想象一下是一件多么恐怖的事情。

这些容器不需要程序员去对装载内存进行操作,内存管理操作被它们隐藏在了库函数代码背后,这就避免了很多令人头疼的场面。

在自己的程序当中,把所有相关内存管理都打包在相关资源控制库当中,也就是专人专事,避免在正常开发模式中去考虑这些歪瓜裂枣。

  • 资源句柄//太深入了了解就好//
  • 智能指针
  1. std::unique_ptr<T> :独占资源所有权的指针。
  2. std::shared_ptr<T> :共享资源所有权的指针。
  3. std::weak_ptr<T> :共享资源的观察者,需要和 std::shared_ptr 一起使用,不影响资源的生命周期。

简单用例:

{
    int* p = new int(100);
    // ...
    delete p;  // 要记得释放内存
}
{
    std::unique_ptr<int> uptr = std::make_unique<int>(200);
    //...
    // 离开 uptr 的作用域的时候自动释放内存
}
  • 内存泄漏检测器等

据说Smart Pointer技术比较成熟,但其实C++中应用一直不广泛,这点要和java学学

 

内存对齐

直接上例子:

//32位系统
#include<stdio.h>
struct{
    int x;
    char y;
}s;

int main()
{
printf("%d\n",sizeof(s); // 输出8
return 0;
}

int类型大小为4byte,char类型大小为1byte,但作为结构体加一块变成了8byte。

处理器一般会以某个数的倍数(2、4、8...)来取内存,内存对齐的意义就是方便计算机自个儿。

字节对齐规则

老规矩,编译器的活可以手动改的

#pragma pack(n) n取啥,啥就是编译器的对齐系数(VSC)

但是最终的有效对齐值是选择对齐系数与结构体中最大那个成员大小中的最小值胜任

__attribute__(GCC)

具体规则:

  • 结构体第一个成员的偏移量(offset)为0,以后每个成员相对于结构体首地址的 offset 都是该成员大小与有效对齐值中较小那个的整数倍,如有需要编译器会在成员之间加上填充字节。
  • 结构体的总大小为 有效对齐值 的整数倍,如有需要编译器会在最末一个成员之后加上填充字节。

这两个规则分别阐述了内存对齐的操作位置,分别为结构体成员间,与结构体最后一成员后

下面用一系列struct例子保证给你解释清楚

struct{
    int i;
    char c1;
    char c2;
}x1;//sizeof(x1)=8

struct{
char c1;
int i;
char c2;
}x2;//sizeof(x2)=12

struct{
char c1;
char c2;
int i;
}x3;//sizeof(x3)=8

pPA5Tz9.md.jpg

结构体x1

首先看规则一

限制不了第一个成员,因为第一个成员offset就是0,从第二个成员开始根据char(1)或者有效对齐值(4)中最小为1,而偏移地址4可以除断1,所以可以直接存入,后面一个成员也是同样的道理,不需要空间对齐。

再看规则二

算出来struct大小为6,不是4的倍数,所以编译器在最后填充了两个空间凑成8(虚线部分)

 

pPAItW4.md.jpg

结构体x2

首先看规则一

老规矩第一个成员不管,直接从第二个开始,很容易得知int(4)最小只能选4,那么int成员从内存地址1开始往后推只能到4满足规则一,所以编译器从中补充3字节大小。

再看规则二

后续计算出9字节,不符合,于是填充到12字节

 

以上的例子只是初步了解了内存对齐的大致机制,而后的例子深挖细节与易错点

首先是数组,如果结构体中带数组,则按照数组类型来进行内存对齐,即char a[3]等同于char a写三个。

struct str2 {
    short c1[3];
    char c2[5];
    char c3[6];
}str2;//sizeof()结果为18

 

数组细则

struct str3 {
    int c;
    double t;
    char c1[3];
}str3;//sizeof()结果为24

struct str4 {
double t;
char c1[3];
int c;
}str4;//sizeof()结果为16

以上两个结构体只是将顺序改变了一下,但是最终结果却天差地别。

罪魁祸首在不同位置的偏移数与最终空间补齐上。

str3首成员大小为4,在为第二个成员腾位置的时候要从第八个位置放(规则一),于是编译器补充了4字节,最终计算出的结果为19字节,但由于按照对齐有效值来看必须是8的倍数,所以结果为24。

str4除了char后面要补充一个字节,全程几乎畅通无阻,最后算出来也是16。

不过,如果在程序前加上#pragma pack(4)则两者的结果都会变回16。

 

空数组

struct str{
    char p;
    int a;
    int b[0];
}//sizeof = 8

struct str{
int b[0];
}//sizeof = 4

struct str{

}//sizeof = 1

空数组在程序定义是非法的,只能在类或者结构体中定义,本身是不占空间的,只是一个指针指向一个位置。

至于为什么中间那个结构体大小为4,我认为是编译器检测到空数组的类型为int,所以在结尾给他补充了四个字节大小,而不是空数组本身带给他的空间。

最后一个存在一个大小为1字节,因为编译器至少需要一个空间来将不同结构体“认出来”,这个记住就好,而且这个值和编译器本身有关。

如果在空结构体中编入虚函数,则由于会生成虚函数表,所以会比1大一点

 

结构体对齐

如果有结构体嵌套结构体的情况,那么内部被嵌套的结构体计算对齐大小时按照它本身最大那个成员大小来计算

struct pa
{
   char a;
   node b;
}
sizeof(pa)=12
//b的起始位置要是4的整倍数,因此要在a后补位,
//b占8字节

 

结构体(struct)和共同体(union)

结构体struct:把不同类型的数据组合成一个整体。struct里每个成员都有自己独立的地址。sizeof(struct)是内存对齐后所有成员长度的加和。(引申出内存对齐的问题)

共同体union:各成员共享一段内存空间, 一个union变量的长度等于各成员中最长的长度,以达到节省空间的目的。所谓的共享不是指把多个成员同时装入一个union变量内, 而是指该union变量可被赋予任一成员值,但每次只能赋一种值, 赋入新值则冲去旧值。sizeof(union)是最长的数据成员的长度。

总结: struct和union都是由多个不同的数据类型成员组成, 但在任何同一时刻, union中只存放了一个被选中的成员, 而struct的所有成员都存在。在struct中,各成员都占有自己的内存空间,它们是同时存在的。一个struct变量的总长度等于所有成员长度之和。在Union中,所有成员不能同时占用它的内存空间,它们不能同时存在。Union变量的长度等于最长的成员的长度。对于union的不同成员赋值, 将会对其它成员重写, 原来成员的值就不存在了, 而对于struct的不同成员赋值是互不影响的。

 

 

malloc与new

malloc实现原理

malloc函数是将可用内存块都链接成一个空闲链表,然后在调用的时候寻找一个满足用户条件或者接近满足用户条件的块,将这个块一分为二(一部分给用户,一部分剩下的链接回链表)。如果没找着那就动用sbrk()函数在堆里头划分新内存出来。

可见所谓”可用内存块“其实就是上文所提到的”自由储存区“,这个区靠近堆且性质和堆很像,动用sbrk()的时候就动用了栈区了

另外,在空闲链表中寻找内存块的算法和操作系统中分区分配算法如出一辙

首次分配,下一次分配,最佳适配等。

具体性质见//操作系统分区分配//

操作要点

原型函数:

void * malloc(size_t size);

用malloc申请一块长度为length的整数类型的内存:

int *p = (int *) malloc(sizeof(int) * length);

这里可以发现malloc的返回值其实是void*,所以要显式转换到我们需要的类型。

后面的具体大小其实只看字节数的,不过为了防止出错,我们一般都用sizeof加类型这样的形式。

 

new实现原理

new,包括delete,其实最终都是调用malloc和free的。

数据类型分为简单类型与复杂类型

如果是简单类型(基本数据类型和不需要构造函数的类型),则调用operator new函数,核心如下:

 while ((p = malloc(size)) == 0)
            if (_callnewh(size) == 0)
            {       // report no memory
                    _THROW_NCEE(_XSTD bad_alloc, );
            }

malloc()调用失败则调用_callnewh(),再失败则返回bad_alloc异常。

这里是new和malloc区别之一,如果malloc异常则直接返回NULL

如果是复杂类型,先调用 operator new()函数,然后在分配的内存上调用构造函数。

操作要点

int *p1 = (int *)malloc(sizeof(int) * length);
int *p2 = new int[length];

new内置了sizeof、类型转换和类型安全检查功能。对于非内部数据类型的对象而言,new在创建动态对象的同时完成了初始化工作。

创建对象数组

Obj *objects = new Obj[100]; // 创建100个动态对象
Obj *objects = new Obj[100](1);// **这样是错误的**创建100个动态对象的同时赋初值1

delete

delete []objects; // 正确的用法
delete objects; // 错误的用法