C++ 面试常见问题(内存管理)

C++ 面试常见问题(内存管理)

C/C++ 内存是什么?

面试高频指数:★★★★☆

一、内存本质

编程的本质其实就是操控数据,而数据存放在内存中。

内存就是计算机的存储空间,用于存储程序的指令、数据和状态。

在 C 语言中,内存被组织成一系列的字节,每个字节都有一个唯一的地址。程序中的变量和数据结构存储在这些字节中。

根据变量的类型和作用域,内存分为几个区域,如栈(stack)、堆(heap)和全局/静态存储区。

1.1 内存编址

计算机的内存是一块用于存储数据的空间,由一系列连续的存储单元组成,就像下面这样,

img

每一个单元格都表示 1 个 Bit,一个 bit 在 EE 专业的同学看来就是高低电位,而在 CS 同学看来就是 0、1 两种状态。

由于 1 个 bit 只能表示两个状态,所以大佬们规定 8个 bit 为一组,命名为 byte。

并且将 byte 作为内存寻址的最小单元,也就是给每个 byte 一个编号,这个编号就叫内存的地址。

img

1.2 内存地址空间

上面我们说给内存中每个 byte 唯一的编号,那么这个编号的范围就决定了计算机可寻址内存的范围。

所有编号连起来就叫做内存的地址空间,这和大家平时常说的电脑是 32 位还是 64 位有关。

早期 Intel 8086、8088 的 CPU 就是只支持 16 位地址空间,寄存器和地址总线都是 16 位,这意味着最多对 2^16 = 64 Kb 的内存编号寻址。

这点内存空间显然不够用,后来,80286 在 8086 的基础上将地址总线和地址寄存器扩展到了20 位,也被叫做 A20 地址总线。

当时在写 mini os 的时候,还需要通过 BIOS 中断去启动 A20 地址总线的开关。

但是,现在的计算机一般都是 32 位起步了,32 位意味着可寻址的内存范围是 2^32 byte = 4GB。

所以,如果你的电脑是 32 位的,那么你装超过 4G 的内存条也是无法充分利用起来的。

好了,这就是内存和内存地址空间。

1.3 变量的本质

有了内存,接下来我们需要考虑,int、double 这些变量是如何存储在 0、1 单元格的。 在 C 语言中我们会这样定义变量:

int a = 999;
char c = 'c';

当你写下一个变量定义的时候,实际上是向内存申请了一块空间来存放你的变量。 我们都知道 int 类型占 4 个字节,并且在计算机中数字都是用补码(不了解补码的记得去百度)表示的。

999 换算成补码就是:0000 0011 1110 0111

这里有 4 个byte,所以需要四个单元格来存储:

img

有没有注意到,我们把高位的字节放在了低地址的地方。 那能不能反过来呢? 当然,这就引出了大端和小端。 像上面这种将高位字节放在内存低地址的方式叫做大端 反之,将低位字节放在内存低地址的方式就叫做小端。

img

上面只说明了 int 型的变量如何存储在内存,而 float、char 等类型实际上也是一样的,都需要先转换为补码。

对于多字节的变量类型,还需要按照大端或者小端的格式,依次将字节写入到内存单元。

记住上面这两张图,这就是编程语言中所有变量的在内存中的样子,不管是 int、char、指针、数组、结构体、对象… 都是这样放在内存的。

深入理解 C/C++ 指针

面试高频指数:★★★★★

一、内存

二、指针是什么东西?

2.1 变量放在哪?

上面我说,定义一个变量实际就是向计算机申请了一块内存来存放。

那如果我们要想知道变量到底放在哪了呢?

可以通过运算符&来取得变量实际的地址,这个值就是变量所占内存块的起始地址。

(PS: 实际上这个地址是虚拟地址,并不是真正物理内存上的地址

我们可以把这个地址打印出来:

printf("%x", &a);

大概会是像这样的一串数字:0x7ffcad3b8f3c

2.2 指针本质

上面说,我们可以通过&符号获取变量的内存地址,那获取之后如何来表示这是一个地址,而不是一个普通的值呢? 也就是在 C 语言中如何表示地址这个概念呢? 对,就是指针,你可以这样:

int *pa = &a; 

pa 中存储的就是变量 a 的地址,也叫做指向 a 的指针。 在这里我想谈几个看起来有点无聊的话题:

为什么我们需要指针?直接用变量名不行吗?

当然可以,但是变量名是有局限的。

变量名的本质是什么?

是变量地址的符号化,变量是为了让我们编程时更加方便,对人友好,可计算机可不认识什么变量 a,它只知道地址和指令。

所以当你去查看 C 语言编译后的汇编代码,就会发现变量名消失了,取而代之的是一串串抽象的地址。

你可以认为,编译器会自动维护一个映射,将我们程序中的变量名转换为变量所对应的地址,然后再对这个地址去进行读写。

也就是有这样一个映射表存在,将变量名自动转化为地址:

a  | 0x7ffcad3b8f3c
c  | 0x7ffcad3b8f2c
h  | 0x7ffcad3b8f4c
....
int func(...) {
  ... 
};

int main() {
	int a;
	func(...);
};

假设我有一个需求:

要求在func 函数里要能够修改 main 函数里的变量 a,这下咋整,在 main 函数里可以直接通过变量名去读写 a 所在内存。

但是在 func 函数里是看不见a 的呀。

你说可以通过&取地址符号,将 a 的地址传递进去:

int func(int address) {
  ....
};

int main() {
	int a;
	func(&a);
};

这样在func 里就能获取到 a 的地址,进行读写了。

理论上这是完全没有问题的,但是问题在于:

编译器该如何区分一个 int 里你存的到底是 int 类型的值,还是另外一个变量的地址(即指针)。

而通过int * 去定义一个指针变量,会非常明确:这就是另外一个 int 型变量的地址。

编译器也可以通过类型检查来排除一些编译错误。

这就是指针存在的必要性。

实际上任何语言都有这个需求,只不过很多语言为了安全性,给指针戴上了一层枷锁,将指针包装成了引用。

2.3 解引用

上面的问题,就是为了引出指针解引用的。

pa中存储的是a变量的内存地址,那如何通过地址去获取a的值呢?

这个操作就叫做解引用,在 C 语言中通过运算符 *就可以拿到一个指针所指地址的内容了。

比如*pa就能获得a的值。

我们说指针存储的是变量内存的首地址,那编译器怎么知道该从首地址开始取多少个字节呢?

这就是指针类型发挥作用的时候,编译器会根据指针的所指元素的类型去判断应该取多少个字节。

如果是 int 型的指针,那么编译器就会产生提取四个字节的指令,char 则只提取一个字节,以此类推。

下面是指针内存示意图:

img

pa 指针首先是一个变量,它本身也占据一块内存,这块内存里存放的就是 a 变量的首地址。

当解引用的时候,就会从这个首地址连续划出 4 个 byte,然后按照 int 类型的编码方式解释。

#2.4 活学活用

别看这个地方很简单,但却是深刻理解指针的关键。

举两个例子来详细说明:

比如:

float f = 1.0;
short c = *(short*)&f; 

你能解释清楚上面过程,对于 f 变量,在内存层面发生了什么变化吗?

或者 c 的值是多少?1 ?

实际上,从内存层面来说,f 什么都没变。

如图:

img

假设这是f 在内存中的位模式,这个过程实际上就是把 f 的前两个 byte 取出来然后按照 short 的方式解释,然后赋值给 c。 详细过程如下:

&f取得f 的首地址
(short*)&f
上面第二步什么都没做,这个表达式只是说 : “噢,我认为f这个地址放的是一个 short 类型的变量”

最后当去解引用的时候*(short*)&f时,编译器会取出前面两个字节,并且按照 short 的编码方式去解释,并将解释出的值赋给 c 变量。

这个过程 f的位模式没有发生任何改变,变的只是解释这些位的方式。

当然,这里最后的值肯定不是 1,至于是什么,大家可以去真正算一下。 那反过来,这样呢?

short c = 1;
float f = *(float*)&c;

img

具体过程和上述一样,但上面肯定不会报错,这里却不一定。

为什么?

(float*)&c会让我们从c 的首地址开始取四个字节,然后按照 float 的编码方式去解释。 但是c是 short 类型只占两个字节,那肯定会访问到相邻后面两个字节,这时候就发生了内存访问越界。

当然,如果只是读,大概率是没问题的。

但是,有时候需要向这个区域写入新的值,比如:

*(float*)&c = 1.0;

那么就可能发生 coredump,也就是访存失败。

另外,就算是不会 coredump,这种也会破坏这块内存原有的值,因为很可能这是是其它变量的内存空间,而我们去覆盖了人家的内容,肯定会导致隐藏的 bug。

如果你理解了上面这些内容,那么使用指针一定会更加的自如。

2.5 看个小问题

讲到这里,我们来看一个问题,这是一位群友问的,这是他的需求:

img

这是他写的代码:

img

他把 double 写进文件再读出来,然后发现打印的值对不上。 而关键的地方就在于这里:

char buffer[4];
...
printf("%f %x\n", *buffer, *buffer);

他可能认为 buffer 是一个指针(准确说是数组),对指针解引用就该拿到里面的值,而里面的值他认为是从文件读出来的 4 个byte,也就是之前的 float 变量。

注意,这一切都是他认为的,实际上编译器会认为:

“哦,buffer 是 char类型的指针,那我取第一个字节出来就好了”。

然后把第一个字节的值传递给了 printf 函数,printf 函数会发现,%f 要求接收的是一个 float 浮点数,那就会自动把第一个字节的值转换为一个浮点数打印出来。

这就是整个过程。

错误关键就是,这个同学误认为,任何指针解引用都是拿到里面“我们认为的那个值”,实际上编译器并不知道,编译器只会傻傻的按照指针的类型去解释。 所以这里改成:

printf("%f %x\n", *(float*)buffer, *(float*)buffer);

三、 结构体和指针

结构体内包含多个成员,这些成员之间在内存中是如何存放的呢?

比如:

struct fraction {
	int num; // 整数部分
	int denom; // 小数部分
};
struct fraction fp;
fp.num = 10;
fp.denom = 2;

这是一个定点小数结构体,它在内存占 8 个字节(这里不考虑内存对齐),两个成员域是这样存储的:

结构体成员存储

我们把 10 放在了结构体中基地址偏移为 0 的域,2 放在了偏移为 4 的域。

接下来我们做一个正常人永远不会做的操作:

((fraction*)(&fp.denom))->num = 5; 
((fraction*)(&fp.denom))->denom = 12; 
printf("%d\n", fp.denom); // 输出多少?

img

首先,&fp.denom表示取结构体 fp 中 denom 域的首地址,然后以这个地址为起始地址取 8 个字节,并且将它们看做一个 fraction 结构体。

首先,&fp.denom表示取结构体 fp 中 denom 域的首地址,然后以这个地址为起始地址取 8 个字节,并且将它们看做一个 fraction 结构体。

在这个新结构体中,最上面四个字节变成了 denom 域,而 fp 的 denom 域相当于新结构体的 num 域。

因此:

((fraction*)(&fp.denom))->num = 5

实际上改变的是 fp.denom,而

((fraction*)(&fp.denom))->denom = 12

则是将最上面四个字节赋值为 12。

当然,往那四字节内存写入值,结果是无法预测的,可能会造成程序崩溃,因为也许那里恰好存储着函数调用栈帧的关键信息,也可能那里没有写入权限。

大家初学 C 语言的很多 coredump 错误都是类似原因造成的。

所以最后输出的是 5。

为什么要讲这种看起来莫名其妙的代码?

就是为了说明结构体的本质其实就是一堆的变量打包放在一起,而访问结构体中的域,就是通过结构体的起始地址,也叫基地址,然后加上域的偏移。

其实,C++、Java 中的对象也是这样存储的,无非是他们为了实现某些面向对象的特性,会在数据成员以外,添加一些 Head 信息,比如 C++ 的虚函数表。

实际上,我们是完全可以用 C 语言去模仿的。

这就是为什么一直说 C 语言是基础,你真正懂了 C 指针和内存,对于其它语言你也会很快的理解其对象模型以及内存布局。

四、多级指针

你要是给我写个这个:int ******p 能把我搞崩溃,我估计很多同学现在就是这种情况🤣

其实,多级指针也没那么复杂,就是指针的指针的指针的指针…非常简单。

今天就带大家认识一下多级指针的本质。

首先,我要说一句话,没有多级指针这种东西,指针就是指针,多级指针只是为了我们方便表达而取的逻辑概念。

img

这种大家都用过吧,丰巢或者超市储物柜都是这样,每个格子都有一个编号,我们只需要拿到编号,然后就能找到对应的格子,取出里面的东西。

这里的格子就是内存单元,编号就是地址,格子里放的东西就对应存储在内存中的内容。

假设我把一本书,放在了 03 号格子,然后把 03 这个编号告诉你,你就可以根据 03 去取到里面的书。

那如果我把书放在 05 号格子,然后在 03 号格子只放一个小纸条,上面写着:「书放在 05 号」。

你会怎么做?

当然是打开 03 号格子,然后取出了纸条,根据上面内容去打开 05 号格子得到书。

这里的 03 号格子就叫指针,因为它里面放的是指向其它格子的小纸条(地址)而不是具体的书。

明白了吗?

那我如果把书放在 07 号格子,然后在 05 号格子 放一个纸条:「书放在 07号」,同时在03号格子放一个纸条「书放在 05号」

img

这里的 03 号格子就叫二级指针,05 号格子就叫指针,而 07 号就是我们平常用的变量。

依次,可类推出 N 级指针。

所以你明白了吗?同样的一块内存,如果存放的是别的变量的地址,那么就叫指针,存放的是实际内容,就叫变量。

int a;
int *pa = &a;
int **ppa = &pa;
int ***pppa = &ppa;

上面这段代码,pa就叫一级指针,也就是平时常说的指针,ppa 就是二级指针。

内存示意图如下:

img

不管几级指针有两个最核心的东西:

  • 指针本身也是一个变量,需要内存去存储,指针也有自己的地址
  • 指针内存存储的是它所指向变量的地址

这就是我为什么多级指针是逻辑上的概念,实际上一块内存要么放实际内容,要么放其它变量地址,就这么简单。

4.1 指针申明

怎么去解读int **a这种表达呢?

int ** a 可以把它分为两部分看,即int* 和 a,后面 a 中的表示 a 是一个指针变量,前面的 int 表示指针变量a

只能存放 int* 型变量的地址。

对于二级指针甚至多级指针,我们都可以把它拆成两部分。

首先不管是多少级的指针变量,它首先是一个指针变量,指针变量就是一个*,其余的*表示的是这个指针变量只能存放什么类型变量的地址。

比如int*a表示指针变量 a 只能存放int 型变量的地址。

五、指针与数组

#5.1 一维数组与指针

数组是 C 自带的基本数据结构,彻底理解数组及其用法是开发高效应用程序的基础。

数组和指针表示法紧密关联,在合适的上下文中可以互换。

int array[10] = {10, 9, 8, 7};
printf("%d\n", *array);  // 	输出 10
printf("%d\n", array[0]);  // 输出 10

printf("%d\n", array[1]);  // 输出 9
printf("%d\n", *(array+1)); // 输出 9

int *pa = array;
printf("%d\n", *pa);  // 	输出 10
printf("%d\n", pa[0]);  // 输出 10

printf("%d\n", pa[1]);  // 输出 9
printf("%d\n", *(pa+1)); // 输出 9

在内存中,数组是一块连续的内存空间:

img

第 0 个元素的地址称为数组的首地址,数组名实际就是指向数组首地址,当我们通过array[1]或者*(array + 1) 去访问数组元素的时候。

实际上可以看做 address[offset],address 为起始地址,offset 为偏移量,但是注意这里的偏移量offset 不是直接和 address相加,而是要乘以数组类型所占字节数,也就是: address + sizeof(int) * offset。

学过汇编的同学,一定对这种方式不陌生,这是汇编中寻址方式的一种:基址变址寻址

5.2 sizeof 差别

最典型的地方就是在 sizeof:

printf("%u", sizeof(array));
printf("%u", sizeof(pa));

第一个将会输出 40,因为 array包含有 10 个int类型的元素,而第二个在 32 位机器上将会输出 4,也就是指针的长度。

为什么会这样呢?

站在编译器的角度讲,变量名、数组名都是一种符号,它们都是有类型的,它们最终都要和数据绑定起来。

变量名用来指代一份数据,数组名用来指代一组数据(数据集合),它们都是有类型的,以便推断出所指代的数据的长度。

对,数组也有类型,我们可以将 int、float、char 等理解为基本类型,将数组理解为由基本类型派生得到的稍微复杂一些的类型,

数组的类型由元素的类型和数组的长度共同构成。而 sizeof 就是根据变量的类型来计算长度的,并且计算的过程是在编译期,而不会在程序运行时。

编译器在编译过程中会创建一张专门的表格用来保存变量名及其对应的数据类型、地址、作用域等信息。

sizeof 是一个操作符,不是函数,使用 sizeof 时可以从这张表格中查询到符号的长度。

所以,这里对数组名使用sizeof可以查询到数组实际的长度。

pa 仅仅是一个指向 int 类型的指针,编译器根本不知道它指向的是一个整数,还是一堆整数。

虽然在这里它指向的是一个数组,但数组也只是一块连续的内存,没有开始和结束标志,也没有额外的信息来记录数组到底多长。

所以对 pa 使用 sizeof 只能求得的是指针变量本身的长度。

也就是说,编译器并没有把 pa 和数组关联起来,pa 仅仅是一个指针变量,不管它指向哪里,sizeof求得的永远是它本身所占用的字节数。

5.2 二维数组

大家不要认为二维数组在内存中就是按行、列这样二维存储的,实际上,不管二维、三维数组… 都是编译器的语法糖。

存储上和一维数组没有本质区别,举个例子:

int array[3][3] = {{1, 2,3}, {4, 5,6},{7, 8, 9}};
array[1][1] = 5;

或许你以为在内存中 array 数组会像一个二维矩阵:

1		2		3
4		5		6
7		8		9

可实际上它是这样的:

1		2		3		4		5		6		7		8		9

和一维数组没有什么区别,都是一维线性排列。 当我们像 array[1][1]这样去访问的时候,编译器会怎么去计算我们真正所访问元素的地址呢? 为了更加通用化,假设数组定义是这样的:

int array[n ][m ]

访问: array[a][b]

那么被访问元素地址的计算方式就是: array + (m * a + b)

这个就是二维数组在内存中的本质,其实和一维数组是一样的,只是语法糖包装成一个二维的样子。

#六、神奇的 void 指针

想必大家一定看到过 void 的这些用法:

void func();
int func1(void);

在这些情况下,void 表达的意思就是没有返回值或者参数为空。

但是对于 void 型指针却表示通用指针,可以用来存放任何数据类型的引用。

下面的例子就 是一个 void 指针:

void *ptr;
6.1 应用场景

void 指针最大的用处就是在 C 语言中实现泛型编程,因为任何指针都可以被赋给 void 指针,void 指针也可以被转换回原来的指针类型, 并且这个过程指针实际所指向的地址并不会发生变化。 比如:

int num;
int *pi = # 
printf("address of pi: %p\n", pi);
void* pv = pi;
pi = (int*) pv; 
printf("address of pi: %p\n", pi);

这两次输出的值都会是一样:

img

平常可能很少会这样去转换,但是你用 C 写大型软件或者写一些通用库的时候,一定离不开 void 指针,这是 C 泛型的基石,比如 std 库里的 sort 函数申明是这样的:

void qsort(void *base,int nelem,int width,int (*fcmp)(const void *,const void *));

所有关于具体元素类型的地方全部用 void 代替。

void 还可以用来实现 C 语言中的多态,这是一个挺好玩的东西。

不过也有需要注意的:

6.2 不能对 void 指针解引用

比如:

int num;
void *pv = (void*)#
*pv = 4; // 错误

为什么?

因为解引用的本质就是编译器根据指针所指的类型,然后从指针所指向的内存连续取 N 个字节,然后将这 N 个字节按照指针的类型去解释。

比如 int *型指针,那么这里 N 就是 4,然后按照 int 的编码方式去解释数字。

但是 void,编译器是不知道它到底指向的是 int、double、或者是一个结构体,所以编译器没法对 void 型指针解引用。

快速搞懂 C/C++ 指针声明

面试高频指数:★★★★☆

很多小伙伴,看到一些复杂的类型声明就看不懂到底是什么类型了,比如下面这个

int (*(*foo)[5])(int);

接下来我们就带大家如何去看懂一个复杂的声明:

一、复杂类型说明

要了解指针,多多少少会出现一些比较复杂的类型。所以先介绍一下如何完全理解一个复杂类型。

要理解复杂类型其实很简单,一个类型里会出现很多运算符,他们也像普通的表达式一样,有优先级,其优先级和运算优先级一样。

所以总结了一下其原则:从变量名处起,根据运算符优先级结合,一步一步分析。

下面让我们先从简单的类型开始慢慢分析吧。

1.1 普通变量
int p;

这是一个普通的整型变量。

即 p is int.

1.2 普通指针
int *p

首先从 p 处开始,先与 * 结合,所以说明 p 是一个指针。然后再与 int 结合,说明指针所指向的内容的类型为int型,所以 p 是一个指向整型数据的指针。

即 p is pointer to int.

1.3 数组
int p[3];

首先从 p 处开始,先与[]结合,说明 p 是一个数组。然后与 int 结合,说明数组里的元素是整型的,所以 p 是一个由整型数据组成的数组。

即:p is arry(size 3) of int.

1.4 指针数组
int *p[3];

首先从 p 处开始,先与 [] 结合,因为其优先级比高( [] 在c语言中属于后缀运算符和 () 等同为最高优先级),所以 p 是一个数组。然后再与 * 结合,说明数组里的元素是指针类型。之后再与int结合,说明指针所指向的内容的类型是整型的,所以 p 是一个指向 int 的指针数组。

英文即: p is arry(size 3) of pointer to int.

1.5 数组指针
int (*p)[3];

首先从 p 处开始,先与 * 结合(因为 * 是被括号包围的),说明 p 是一个指针。然后再与[]结合(与"()"这步可以忽略,只是为了改变优先级),说明指针所指向的内容是一个数组。之后再与 int 结合,说明数组里的元素是整型的,大小是 3,所以 p 是一个指向 int 数组(大小为3)的指针。

英文即:p is pointer to arry(size 3) of int.

1.6 二级指针
int **p;

首先从 p 开始,先与 * 结合,说明 p 是一个指针。然后再与 * 结合,说明指针所指向的元素还是指针。之后再与 int 结合,说明该指针所指向的元素是整型数据。由于二级指针以及更高级的指针极少用在复杂的类型中,所以后面更复杂的类型我们就不考虑多级指针了,最多只考虑一级指针。

英文即: p is pointer to pointer to int.

1.7 函数声明
int p(int);

从 p 处起,先与 () 结合,说明 p 是一个函数。然后进入 () 里分析,说明该函数有一个整型变量的参数,之后再与外面的 int 结合,说明函数的返回值是一个整型数据。

英文即: p is function(int) returning int.

1.8 函数指针
int (*p)(int);

从 p 处开始,先与指针结合,说明 p 是一个指针。然后与()结合,说明指针指向的是一个函数。之后再与()里的int结合,说明函数有一个int型的参数,再与最外层的int结合,说明函数的返回类型是整型,所以 p 是一个指向有一个整型参数且返回类型为整型的函数的指针。

英文即: p is pointer to function(int) returning int.

1.9 复杂声明
int* (*p(int))[3];

从 p 开始,先与()结合,说明 p 是一个函数。然后进入()里面,与int结合,说明函数有一个整型变量参数。然后再与外面的 * 结合,说明函数返回的是一个指针。

之后到最外面一层,先与[]结合,说明返回的指针指向的是一个数组。接着再与结合,说明数组里的元素是指针,最后再与int结合,说明指针指向的内容是整型数据。

所以 p 是一个参数为一个整数据且返回一个指向由整型指针变量组成的数组的指针变量的函数。

p in function(int) returning pointer to arry(size 3) of pointer to int.

二、总结

简单总结下如何解释复杂一点的 C 语言声明(暂时不考虑 const 和 volatile):

2.1 指针声明阅读顺序
  1. 先抓住 标识符(即变量名或者函数名)

  2. 从距离标识符最近的地方开始,按照优先顺序解释派生类型(也就是指针、数组、函数),顺序如下:

  • 用于改变优先级的括弧
  • 用于表示数组的[],用于表示函数的()
  • 用于表示指针的
  1. 解释完成派生类型,使用“of”、“to”、“returning”将它们连接起来。

  2. 最后,追加数据类型修饰符(一般在最左边,int、double等)。

数组元素个数和函数的参数属于类型的一部分。应该将它们作为附属于类型的属性进行解 释。

2.2 举例

比如我们上面提到的一个例子

int (* p)(int);
  • 抓住 p,即 p is

  • 抓住改变优先级的 (),括号里是 * ,也就是指针,即 p is pointer to

  • 再看表示函数的 (),即 p is pointer to function(int) returning, 记得 函数的参数类型属于函数的一部分

  • 最后看左边的类型为 int,即 p is pointer to function(int) returning int

翻译为中文就是:

p 是一个指向<参数为 int 返回int> 的函数指针。

2.3 复杂声明请使用 typedef

所以对于实在需要嵌套很多层的复杂声明,请使用 typedef 来处理复杂的声明。

说白了,就是将复杂的声明,先变为一个个简单的声明,然后取一个别名。

还是拿这个例子来说:

int* (*p(int))[3];

那我们就用 typedef 把 <int 指针变量组成的数组> 取一个别名:

typedef int* KK[3];

假设就叫 KK。

那么现在那个复杂的声明就变简单了:

KK *p(int);

C/C++内存分区

面试高频指数:★★★★☆

但是,一般来说,程序运行时,代码、数据等都存放在不同的内存区域,这些内存区域从逻辑上做了划分,大概以下几个区域:代码区、全局/静态存储区、栈区、堆区和常量区。

在 CSAPP 第九章虚拟内存,就将内存分为堆、bss、data、txt、栈等区域。

img

代码区(Code Segment)

也就是 .text 段, 代码区存放程序的二进制代码,它是只读的,以防止程序在运行过程中被意外修改。

#include <iostream>
int main() {
    std::cout << "Hello, World!" << std::endl;
    return 0;
}

比如上面这段代码中的 main 函数,编译为二进制后,函数的逻辑就存放在代码区。

当然这段区域也有可能包含一些只读的常数变量,例如字符串常量等。

全局/静态存储区(Global/Static Storage)

全局变量和静态变量都存放在全局/静态存储区。

以前在 C 语言中全局变量又分为初始化的和未初始化的,分别放在上面图中的 .bss 和 .data 段,但在 C++里面没有这个区分了,他们共同占用同一块内存区,就叫做全局存储区。

这个区域的内存在程序的生命周期几乎都是全局的,举例:

#include <iostream>
int globalVar = 0; // 全局变量
void function() {
    static int staticVar = 0; // 静态变量
    staticVar++;
    std::cout << staticVar << std::endl;
}
int main() {
    function();
    function();
    return 0;
}

globalVar是一个全局变量,staticVar是一个静态变量,它们都存放在全局/静态存储区

栈区(Stack)

栈区用于存储函数调用时的局部变量、函数参数以及返回地址。

当函数调用完成后,分配给这个函数的栈空间会被释放。例如:

#include <iostream>
void function(int a, int b) {
    int localVar = a + b;
    std::cout << localVar << std::endl;
}
int main() {
    function(3, 4);
    return 0;
}

在这个例子中,a、b和localVar都是局部变量,它们存放在栈区。

当 function 函数调用结束后,对应的函数栈所占用的空间(参数 a、b,局部变量 localVar等)都会被回收

堆区(Heap)

堆区是用于动态内存分配的区域,当使用new(C++)或者malloc(C)分配内存时,分配的内存块就位于堆区。

我们需要手动释放这些内存,否则可能导致内存泄漏。例如:

#include <iostream>
int main() {
    int* dynamicArray = new int[10]; // 动态分配内存
    // 使用动态数组...
    delete[] dynamicArray; // 释放内存
    return 0;
}

常量区(Constant Storage):

常量区用于存储常量数据,例如字符串字面量和其他编译时常量。这个区域通常也是只读的。例如:

#include <iostream>
int main() {
	char* c="abc";  // abc在常量区,c在栈上。
  return 0;
}

C++ 中指针和引用的区别

面试高频指数:★★★☆☆

区别

指针和引用在 C++ 中都用于间接访问变量,但它们有一些区别:

指针是一个变量,它保存了另一个变量的内存地址;引用是另一个变量的别名,与原变量共享内存地址。

指针可以被重新赋值,指向不同的变量;引用在初始化后不能更改,始终指向同一个变量。

指针可以为 nullptr,表示不指向任何变量;引用必须绑定到一个变量,不能为 nullptr。

使用指针需要对其进行解引用以获取或修改其指向的变量的值;引用可以直接使用,无需解引用。

下面的示例展示了指针和引用的区别:

#include <iostream>

int main() {
    int a = 10;
    int b = 20;

    // 指针
    int *p = &a;
    std::cout << "Pointer value: " << *p << std::endl; // 输出:Pointer value: 10

    p = &b;
    std::cout << "Pointer value: " << *p << std::endl; // 输出:Pointer value: 20

    // 引用
    int &r = a;
    std::cout << "Reference value: " << r << std::endl; // 输出:Reference value: 10

    // r = &b; // 错误:引用不能被重新绑定
    int &r2 = b;
    r = r2; // 将 b 的值赋给 a,r 仍然引用 a
    std::cout << "Reference value: " << r << std::endl; // 输出:Reference value: 20

    return 0;
}

从汇编看引用和指针

要是问引用和指针有什么区别,相信大多数学过c++的都能回答上几点: 指针是所指内存的地址,引用是别名,引用必须初始化。。。。。

但是引用是别名这是c++语法规定的语义,那么到底引用在汇编层面和指针有什么区别呢?

没区别。

对,引用会被c++编译器当做const指针来进行操作。

简单总结

  • 引用只是C++语法糖,可以看作编译器自动完成取地址、解引用的指针常量
  • 引用区别于指针的特性都是编译器约束完成的,一旦编译成汇编就和指针一样
  • 由于引用只是指针包装了下,所以也存在风险,比如如下代码:
int *a = new int;
int &b = *a;
delete a;
b = 12;    // 对已经释放的内存解引用
  • 引用由编译器保证初始化,使用起来较为方便(如不用检查空指针等)
  • 尽量用引用代替指针
  • 引用没有顶层const即int & const,因为引用本身就不可变,所以在加顶层const也没有意义; 但是可以有底层const即 const int&,这表示引用所引用的对象本身是常量
  • 指针既有顶层const(int * const–指针本身不可变),也有底层const(int * const–指针所指向的对象不可变)
  • 有指针引用–是引用,绑定到指针, 但是没有引用指针–这很显然,因为很多时候指针存在的意义就是间接改变对象的值,但是引用本身的值我们上面说过了是所引用对象的地址,但是引用不能更改所引用的对象,也就当然不能有引用指针了。
  • 指针和引用的自增(++)和自减含义不同,指针是指针运算, 而引用是代表所指向的对象对象执行++或–

指针传递、值传递、引用传递

面试高频指数:★★★☆☆

在 C++ 中,函数参数传递有三种常见的方式:值传递、引用传递和指针传递。以下分别给出这三种方式的示例:

一、值传递(Value Passing)

值传递是将实参的值传递给形参。在这种情况下,函数内对形参的修改不会影响到实参。

#include <iostream>

void swap_value(int a, int b) {
    int temp = a;
    a = b;
    b = temp;
}

int main() {
    int x = 10;
    int y = 20;
    swap_value(x, y);
    std::cout << "x: " << x << ", y: " << y << std::endl; // 输出:x: 10, y: 20
    return 0;
}

二、引用传递(Reference Passing)

引用传递是将实参的引用传递给形参。在这种情况下,函数内对形参的修改会影响到实参。

#include <iostream>

void swap_reference(int &a, int &b) {
    int temp = a;
    a = b;
    b = temp;
}

int main() {
    int x = 10;
    int y = 20;
    swap_reference(x, y);
    std::cout << "x: " << x << ", y: " << y << std::endl; // 输出:x: 20, y: 10
    return 0;
}

三、指针传递(Pointer Passing)
指针传递是将实参的地址传递给形参。在这种情况下,函数内对形参的修改会影响到实参。

#include <iostream>

void swap_pointer(int *a, int *b) {
    int temp = *a;
    *a = *b;
    *b = temp;
}

int main() {
    int x = 10;
    int y = 20;
    swap_pointer(&x, &y);
    std::cout << "x: " << x << ", y: " << y << std::endl; // 输出:x: 20, y: 10
    return 0;
}

四、值传递与引用传递,传的是什么?

在网上看到过很多讨论 Java、C++、Python 是值传递还是引用传递这类文章。

所以这一篇呢就是想从原理讲明白关于函数参数传递的几种形式。

参数传递无外乎就是传值(pass by value),传引用(pass by reference)或者说是传指针。

传值还是传引用可能在 Java、Python 这种语言中常常会困扰一些初学者,但是如果你有 C/C++背景的话,那这个理解起来就是 so easy。

今天我就从 C 语言出发,一次性把 Java、Python 这些都给大家讲明白

不过,要想彻底搞懂这个,需要了解两个背景知识:

  • 堆、栈

  • 函数调用栈

4.1 堆、栈

要注意,这“堆”和“栈”并不是数据结构意义上的堆(Heap,一个可看成完全二叉树的数组对象)和 栈(Stack,先进后出的线性结构)。

这里说的堆栈是指内存的两种组织形式,堆是指动态分配内存的一块区域,一般由程序员手动分配,比如 Java 中的 new、C/C++ 中的 malloc 等,都是将创建的对象或者内存块放置在堆区。

而栈是则是由编译器自动分配释放(大概就是你声明一个变量就分配一块相应大小的内存),用于存放函数的参数值,局部变量等。

就拿 Java 来说吧,基本类型(int、double、long这种)是直接将存储在栈上的,而引用类型(类)则是值存储在堆上,栈上只存储一个对对象的引用。

int age = 22;
String name = new String("shuaibei");

img

如果,我们分别对agename变量赋值,会发生什么呢?

age = 18;
name = new String("xiaobei");

img

age 仅仅是将栈上的值修改为 18,而 name 由于是 String 引用类型,所以会重新创建一个 String 对象,并且修改 name,让其指向新的堆对象。(细心的话,你会发现,图中 name 执行的地址我做了修改)

然后,之前那个对象如果没有其它变量引用的话,就会被垃圾回收器回收掉。

这里也要注意一点,我创建 String 的时候,使用的是 new,如果直接采用字符串赋值,比如:

String name = "shuaibei"

那么是会放到 JVM 的常量池去,不会被回收掉,这是字符串两种创建对象的区别,不过这里我们不关注。

Java 中引用这东西,和 C/C++ 的指针就是一模一样的嘛,只不过 Java 做了语义层包装和一些限制,让你觉得这是一个引用,实际上就是指针。

4.2 函数调用栈

一个函数需要在内存上存储哪些信息呢?

参数、局部变量,理论上这两个就够了,但是当多个函数相互调用的时候,就还需要机制来保证它们顺利的返回和恢复主调函数的栈结构信息。

那这部分就包括返回地址、ebp寄存器(基址指针寄存器,指向当前堆栈底部) 以及其它需要保存的寄存器。

所以一个完整的函数调用栈大概长得像下面这个样子:

img

那,多个函数调用的时候呢?

简单来说就是叠罗汉,这是两个函数栈:

img

今天,我们不会去详细了解函数调用过程ebp、ebp如何变化,返回地址又是如何起作用的。

今天的任务就是搞明白参数传递,所以其它的都是非主线的知识,忽略即可

下面这段代码在main函数内调用了func_a函数

int func_a(int a, int *b) {
	a = 5;
	*b = 5;
};

int main(void) {
	int a = 10;
  int b = 10;
  func_a(a, &b);
  printf("a=%d, b=%d\n", a, b);
  return 0;
}

// 输出
a=10, b=5

img

就像上图所示,编译器会生成一段函数调用代码。

将 main 函数内变量 a 的值拷贝到 func_a 函数参数 a 位置。

将变量 b的地址,拷贝到 func_a 函数参数 b 的位置。

记住这张图,这是函数参数传递的本质,没有其它方式,just copy!

copy 意味着是副本,也就是在子函数的参数永远是主调函数内的副本。

决定是值传递还是所谓的引用传递,在于你 copy 的到底是一个值,还是一个引用(的值)。

C++ RAII 思想

面试高频指数:★★★★☆

#什么是 RAII

这里我们引用cppreference 的定义: RAII(opens new window)

资源获取即初始化(Resource Acquisition Is Initialization,简称 RAII)是一种 C++ 编程技术,它将在使用前获取(分配的堆内存、执行线程、打开的套接字、打开的文件、锁定的互斥量、磁盘空间、数据库连接等有限资源)的资源的生命周期与某个对象的生命周期绑定在一起。

确保在控制对象的生命周期结束时,按照资源获取的相反顺序释放所有资源。

同样,如果资源获取失败(构造函数退出并带有异常),则按照初始化的相反顺序释放所有已完全构造的成员和基类子对象所获取的资源。

这利用了核心语言特性(对象生命周期、作用域退出、初始化顺序和堆栈展开),以消除资源泄漏并确保异常安全。

#RAII 的原理

RAII的核心思想就是:

利用栈上局部变量的自动析构来保证资源一定会被释放。

因为我们平常 C++ 编程过程中,经常会忘了释放资源,比如申请的堆内存忘了手动释放,那么就会导致内存泄露。

还有一些常见是程序遇到了异常,提前终止了,我们的资源也来不及释放。

但是变量的析构函数的调用是由编译器保证的一定会被执行,所以如果资源的获取和释放与对象的构造和析构绑定在一起,就不会有各种资源泄露问题。

#RAII 类实现步骤

一般设计实现一个 RAII 类需要四个步骤:

  • 设计一个类封装资源,资源可以是内存、文件、socket、锁等等一切
  • 在构造函数中执行资源的初始化,比如申请内存、打开文件、申请锁
  • 在析构函数中执行销毁操作,比如释放内存、关闭文件、释放锁
  • 使用时声明一个该对象的类,一般在你希望的作用域声明即可,比如在函数开始,或者作为类的成员变量

#示例

下面写一个 RAII 示例,用来演示使用 RAII 思想包装文件的操作,假设我们需要在程序中使用一个文件:

#include <iostream>
#include <fstream>

int main() {
    std::ifstream myfile("example.txt"); // 换自己的文件路径
    if (myfile.is_open()) {
        std::cout << "File is opened." << std::endl;
        // do some work with the file
    }
    else {
        std::cout << "Failed to open the file." << std::endl;
    }
    myfile.close();
    return 0;
}

上面这个例子中,手动打开和关闭了文件。

如果在程序执行的过程中发生了异常或者程序提前退出,可能会导致文件没有被关闭,从而产生资源未释放等问题。

现在使用 RAII 来改进这个例子,通过定义一个包含文件句柄的类,在类的构造函数中打开文件,在析构函数中关闭文件:

#include <iostream>
#include <fstream>

class File {
public:
    File(const char* filename) : m_handle(std::ifstream(filename)) {}
    ~File() {
        if (m_handle.is_open()) {
            std::cout << "File is closed." << std::endl;
            m_handle.close();
        }
    }

    std::ifstream& getHandle() {
        return m_handle;
    }

private:
    std::ifstream m_handle;
};

int main() {
    File myfile("example.txt");
    if (myfile.getHandle().is_open()) {
        std::cout << "File is opened." << std::endl;
        // do some work with the file
    }
    else {
        std::cout << "Failed to open the file." << std::endl;
    }
    return 0;
}

这样,在程序退出时,File类的析构函数会自动被调用,从而自动关闭文件,即使程序提前退出或者发生异常,也不会产生内存泄漏等问题。

用 RAII 思想包装 mutex

在 C++ 中,可以使用 RAII 思想来包装 mutex,确保在多线程编程中始终能安全锁定和解锁互斥量,这个用得非常多,可以在很多开源项目中看到这样的包装。

#include <iostream>
#include <mutex>
#include <thread>

class LockGuard {
public:
    explicit LockGuard(std::mutex &mtx) : mutex_(mtx) {
        mutex_.lock();
    }

    ~LockGuard() {
        mutex_.unlock();
    }

    // 禁止复制
    LockGuard(const LockGuard &) = delete;
    LockGuard &operator=(const LockGuard &) = delete;

private:
    std::mutex &mutex_;
};

// 互斥量
std::mutex mtx;
// 多线程操作的变量
int shared_data = 0;

void increment() {
    for (int i = 0; i < 10000; ++i) {
        // 申请锁
        LockGuard lock(mtx);
        ++shared_data;
        // 作用域结束后会析构 然后释放锁
    }
}

int main() {
    std::thread t1(increment);
    std::thread t2(increment);

    t1.join();
    t2.join();

    std::cout << "Shared data: " << shared_data << std::endl;

    return 0;
}

上面定义了一个 LockGuard 类,该类在构造函数中接收一个互斥量(mutex)引用并对其进行锁定,在析构函数中对互斥量进行解锁。

这样,我们可以将互斥量传递给 LockGuard 对象,并在需要保护的代码块内创建该对象,确保在执行保护代码期间始终正确锁定和解锁互斥量。

在 main 函数中,用两个线程同时更新一个共享变量,通过 RAII 包装的 LockGuard 确保互斥量的正确使用。

总结

RAII(Resource Acquisition Is Initialization)是由 C++ 之父Bjarne Stroustrup提出的,中文翻译为资源获取即初始化,

他说:使用局部对象来管理资源的技术称为资源获取即初始化;

这里的资源主要是指操作系统中有限的东西如指针内存、网络套接字、文件等等,局部对象是指存储在栈的对象,它的生命周期是由操作系统来管理的,无需人工介入。

C++ 智能指针解析

面试高频指数:★★★★☆

为什么需要智能指针

众所周知,Java 和 C/C++ 中间隔着一堵由内存动态分配和垃圾回收机制所围成的墙。

C/C++ 常见的内存错误
在实际的 C/C++ 开发中,我们经常会遇到诸如 coredump、segmentfault 之类的内存问题,使用指针也会出现各种问题,比如:

  • 野指针:未初始化或已经被释放的指针被称为野指针
  • 空指针:指向空地址的指针被称为空指针
  • 内存泄漏:如果在使用完动态分配的内存后忘记释放,就会造成内存泄漏,长时间运行的程序可能会消耗大量内存。
  • 悬空指针:指向已经释放的内存的指针被称为悬空指针
  • 内存泄漏和悬空指针的混合:在一些情况下,由于内存泄漏和悬空指针共同存在,程序可能会出现异常行为。

智能指针

而智能指针是一种可以自动管理内存的指针,它可以在不需要手动释放内存的情况下,确保对象被正确地销毁。

这种指针可以显著降低程序中的内存泄漏和悬空指针的风险。

在C++中,智能指针常用的主要是两个类实现:

  • std::unique_ptr

  • std::shared_ptr

std::unique_ptr

std::unique_ptr是一个独占所有权的智能指针,它保证指向的内存只能由一个unique_ptr拥有,不能共享所有权。

当unique_ptr超出作用域时,它所指向的内存会自动释放。

#include <memory>
#include <iostream>

int main() {
    std::unique_ptr<int> ptr(new int(10));
    std::cout << *ptr << std::endl; // 输出10
    // unique_ptr在超出作用域时自动释放所拥有的内存
    return 0;
}

std::shared_ptr

std::shared_ptr是一个共享所有权的智能指针,它允许多个shared_ptr指向同一个对象,当最后一个shared_ptr超出作用域时,所指向的内存才会被自动释放。

#include <memory>
#include <iostream>

int main() {
    std::shared_ptr<int> ptr1(new int(10));
    std::shared_ptr<int> ptr2 = ptr1; // 通过拷贝构造函数创建一个新的shared_ptr,此时引用计数为2
    std::cout << *ptr1 << " " << *ptr2 << std::endl; // 输出10 10
    // ptr2超出作用域时,所指向的内存不会被释放,因为此时ptr1仍然持有对该内存的引用
    return 0;
}

总的来说,智能指针可以提高程序的安全性和可靠性,避免内存泄漏和悬空指针等问题。

但需要注意的是,智能指针不是万能的,也并不是一定要使用的,有些场景下手动管理内存可能更为合适。

深入理解 C++ shared_ptr之手写

面试高频指数:★★★★☆

正如这篇文章 智能指针 (opens new window)所说,智能指针是一种可以自动管理内存的指针,它可以在不需要手动释放内存的情况下,确保对象被正确地销毁。

可以显著降低程序中的内存泄漏和悬空指针的风险。

而用得比较多的一种智能指针就是 shared_ptr ,从名字也可以看出来,shared 强调分享,也就是指针的所有权不是独占。

shared_ptr 的使用

shared_ptr 的一个关键特性是可以共享所有权,即多个 shared_ptr 可以同时指向并拥有同一个对象。

当最后一个拥有该对象的 shared_ptr 被销毁或者释放该对象的所有权时,对象会自动被删除。

这种行为通过引用计数实现,即 shared_ptr 有一个成员变量记录有多少个 shared_ptr 共享同一个对象。

shared_ptr 的简单用法示例:

#include <iostream>
#include <memory>

class MyClass {
public:
    MyClass() { std::cout << "MyClass 构造函数\n"; }
    ~MyClass() { std::cout << "MyClass 析构函数\n"; }
    void do_something() { std::cout << "MyClass::do_something() 被调用\n"; }
};

int main() {
    {
        std::shared_ptr<MyClass> ptr1 = std::make_shared<MyClass>();
        {
            std::shared_ptr<MyClass> ptr2 = ptr1; // 这里共享 MyClass 对象的所有权
            ptr1->do_something();
            ptr2->do_something();
            std::cout << "ptr1 和 ptr2 作用域结束前的引用计数: " << ptr1.use_count() << std::endl;
        } // 这里 ptr2 被销毁,但是 MyClass 对象不会被删除,因为 ptr1 仍然拥有它的所有权
        std::cout << "ptr1 作用域结束前的引用计数: " << ptr1.use_count() << std::endl;
    } // 这里 ptr1 被销毁,同时 MyClass 对象也会被删除,因为它是最后一个拥有对象所有权的 shared_ptr

    return 0;
}

输出结果:

MyClass 构造函数
MyClass::do_something() 被调用
MyClass::do_something() 被调用
ptr1 和 ptr2 作用域结束前的引用计数: 2
ptr1 作用域结束前的引用计数: 1
MyClass 析构函数

引用计数如何实现的

说起 shared_ptr 大家都知道引用计数,但是问引用计数实现的细节,不少同学就回答不上来了,其实引用计数本身是使用指针实现的,也就是将计数变量存储在堆上,所以共享指针的shared_ptr 就存储一个指向堆内存的指针,文章后面会手动实现一个 shared_ptr。

shared_ptr 的 double free 问题

double free 问题就是一块内存空间或者资源被释放两次。

那么为什么会释放两次呢?

double free 可能是下面这些原因造成的:

  • 直接使用原始指针创建多个 shared_ptr,而没有使用 shared_ptr 的 make_shared 函数,从而导致多个独立的引用计数。

  • 循环引用,即两个或多个 shared_ptr 互相引用,导致引用计数永远无法降为零,从而无法释放内存。

如何解决 double free

解决 shared_ptr double free 问题的方法:

  • 使用 make_shared 函数创建 shared_ptr 实例,而不是直接使用原始指针。这样可以确保所有 shared_ptr 实例共享相同的引用计数。

  • 对于可能产生循环引用的情况,使用 weak_ptr。weak_ptr 是一种不控制对象生命周期的智能指针,它只观察对象,而不增加引用计数。这可以避免循环引用导致的内存泄漏问题。

#include <iostream>
#include <memory>
class B; // 前向声明
class A {
public:
    std::shared_ptr<B> b_ptr;
    ~A() {
        std::cout << "A destructor called" << std::endl;
    }
};

class B {
public:
    std::shared_ptr<A> a_ptr;
    ~B() {
        std::cout << "B destructor called" << std::endl;
    }
};

int main() {
    {
        std::shared_ptr<A> a = std::make_shared<A>();
        std::shared_ptr<B> b = std::make_shared<B>();
        a->b_ptr = b; // A 指向 B
        b->a_ptr = a; // B 指向 A
    } // a 和 b 离开作用域,但由于循环引用,它们的析构函数不会被调用

    std::cout << "End of main" << std::endl;
    return 0;
}

上面这种循环引用问题可以使用std::weak_ptr来避免循环引用。

std::weak_ptr不会增加所指向对象的引用计数,因此不会导致循环引用。

#include <iostream>
#include <memory>

class B; // 前向声明

class A {
public:
    std::shared_ptr<B> b_ptr;
    ~A() {
        std::cout << "A destructor called" << std::endl;
    }
};

class B {
public:
    std::weak_ptr<A> a_ptr; // 使用 weak_ptr 替代 shared_ptr
    ~B() {
        std::cout << "B destructor called" << std::endl;
    }
};

int main() {
    {
        std::shared_ptr<A> a = std::make_shared<A>();
        std::shared_ptr<B> b = std::make_shared<B>();
        a->b_ptr = b; // A 指向 B
        b->a_ptr = a; // B 对 A 使用 weak_ptr
    } // a 和 b 离开作用域,它们的析构函数会被正确调用

    std::cout << "End of main" << std::endl;
    return 0;
}

但是使用 weak_ptr 也有几点注意事项:

  • 如果需要访问 weak_ptr 所指向的对象,需要将std::weak_ptr 通过 weak_ptr::lock() 临时转换为std::shared_ptr.

  • 在使用lock()方法之前,应当检查使用 std::weak_ptr::expired() 检查 std::weak_ptr是否有效,即它所指向的对象是否仍然存在。

enable_shared_from_this

从名字可以看出几个关键词:enable: 允许 shared 指 shared_ptr, from_this 则是指从类自身this 构造 shared_ptr。

想象这样一个场景:

struct SomeData;
void SomeAPI(const std::shared_ptr<SomeData>& d) {}

struct SomeData {
    void NeedCallSomeAPI() {
        // 需要用this调用SomeAPI
    }
};

上面这段代码需要在NeedCallSomeAPI函数中调用SomeAPI,而SomeAPI需要的是一个std::shared_ptr的实参。这个时候应该怎么做? 这样吗?

struct SomeData {
    void NeedCallSomeAPI() {
        SomeAPI(std::shared_ptr<SomeData>{this});
    }
};

上面的做法是错误的,因为SomeAPI调用结束后std::shared_ptr对象的引用计数会降为0,导致 this 被意外释放。

这种情况下,我们需要使用std::enable_shared_from_this ,使用方法很简单,只需要让SomeData继承std::enable_shared_from_this,然后调用shared_from_this,例如:

#include <memory>

struct SomeData;
void SomeAPI(const std::shared_ptr<SomeData>& d) {}

struct SomeData:std::enable_shared_from_this<SomeData> {
    static std::shared_ptr<SomeData> Create() {
        return std::shared_ptr<SomeData>(new SomeData);
    }
    void NeedCallSomeAPI() {
        SomeAPI(shared_from_this());
    }
private:
    SomeData() {}
};


int main()
{
    auto d{ SomeData::Create() };
    d->NeedCallSomeAPI();
}

总结一下,当下面👇这些场景用到 shared_ptr 时,需要搭配上 enable_shared_from_this:

  • 当你需要将this指针传递给其他函数或方法,而这些函数或方法需要一个std::shared_ptr,而不是裸指针。

  • 当你需要在类的成员函数内部创建指向当前对象的std::shared_ptr,例如在回调函数或事件处理中。

线程安全性

其实 shared_ptr 线程不安全主要来自于引用计数有并发更新的风险,当然引用计数本身也可以使用原子atomic。

所以在多线程环境中使用智能指针时,需要采取额外的措施来确保线程安全,

如互斥锁(std::mutex)或原子操作(std::atomic)来确保线程安全。

手写 shared_ptr

这是 C++ 面试常考的一个环节,有的会让你说实现思路,有的则直接需要手写一个。

刚才上面说过了,shared_ptr 的关键就是在于 引用计数。

要实现一个简化版本的 shared_ptr,需要考虑以下几点:

  • 在智能指针类中存储裸指针(raw pointer)和引用计数。
  • 在构造函数中为裸指针和引用计数分配内存。
  • 在拷贝构造函数和赋值操作符中正确地更新引用计数。
  • 在析构函数中递减引用计数,并在引用计数为零时删除对象和引用计数。

以下是一个简化版的 shared_ptr 的实现:

#include <iostream>

template <typename T>
class SimpleSharedPtr {
public:
    // 构造函数
    explicit SimpleSharedPtr(T* ptr = nullptr) : ptr_(ptr), count_(ptr ? new size_t(1) : nullptr) {}

    // 拷贝构造函数
    SimpleSharedPtr(const SimpleSharedPtr& other) : ptr_(other.ptr_), count_(other.count_) {
        if (count_) {
            ++(*count_);
        }
    }

    // 赋值操作符
    SimpleSharedPtr& operator=(const SimpleSharedPtr& other) {
        if (this != &other) {
            release();
            ptr_ = other.ptr_;
            count_ = other.count_;
            if (count_) {
                ++(*count_);
            }
        }
        return *this;
    }

    // 析构函数
    ~SimpleSharedPtr() {
        release();
    }

    T& operator*() const { return *ptr_; }
    T* operator->() const { return ptr_; }
    T* get() const { return ptr_; }
    size_t use_count() const { return count_ ? *count_ : 0; }

private:
    void release() {
        if (count_ && --(*count_) == 0) {
            delete ptr_;
            delete count_;
        }
    }

    T* ptr_;
    size_t* count_;
};

class MyClass {
public:
    MyClass() { std::cout << "MyClass 构造函数\n"; }
    ~MyClass() { std::cout << "MyClass 析构函数\n"; }
    void do_something() { std::cout << "MyClass::do_something() 被调用\n"; }
};

int main() {
    {
        SimpleSharedPtr<MyClass> ptr1(new MyClass());
        {
            SimpleSharedPtr<MyClass> ptr2 = ptr1;
            ptr1->do_something();
            ptr2->do_something();
            std::cout << "引用计数: " << ptr1.use_count() << std::endl;
        }
        std::cout << "引用计数: " << ptr1.use_count() << std::endl;
    }

    return 0;
}

shared_ptr 常用 API
std::shared_ptr 提供了许多有用的 API,以下是一些常用的 API:

  • shared_ptr 构造函数:创建一个空的 shared_ptr,不指向任何对象。
std::shared_ptr<int> ptr;
  • make_shared(args…):创建一个 shared_ptr,并在单次内存分配中同时创建对象和控制块。这比直接使用 shared_ptr 的构造函数要高效。
std::shared_ptr<int> ptr = std::make_shared<int>(42);
  • reset():释放当前 shared_ptr 的所有权,将其设置为 nullptr。如果当前 shared_ptr 是最后一个拥有对象所有权的智能指针,则会删除对象。
ptr.reset();
  • reset(T*):释放当前 shared_ptr 的所有权,并使其指向新的对象。如果当前 shared_ptr 是最后一个拥有对象所有权的智能指针,则会删除原对象。
ptr.reset(new int(42));
  • get():返回指向的对象的裸指针。注意,这个裸指针的生命周期由 shared_ptr 管理,你不应该使用它来创建另一个智能指针。
int* raw_ptr = ptr.get();
  • operator* 和 operator->:访问指向的对象。
int value = *ptr;
std::shared_ptr<std::vector<int>> vec_ptr = std::make_shared<std::vector<int>>();
vec_ptr->push_back(42);
  • use_count():返回当前 shared_ptr 的引用计数,即有多少个 shared_ptr 共享同一个对象。注意,use_count() 通常用于调试,不应该用于程序逻辑。
size_t count = ptr.use_count();
  • unique():检查当前 shared_ptr 是否是唯一拥有对象所有权的智能指针。等价于 use_count() == 1。
bool is_unique = ptr.unique();
  • swap(shared_ptr&):交换两个 shared_ptr 的内容。

    std::shared_ptr<int> ptr1 = std::make_shared<int>(42);
    std::shared_ptr<int> ptr2 = std::make_shared<int>(24);
    ptr1.swap(ptr2);
    
  • operator bool():将 shared_ptr 隐式转换为 bool 类型,用于检查其是否为空。

if (ptr) {

​    std::cout << "ptr 不为空" << std::endl;

} else {

​    std::cout << "ptr 为空" << std::endl;

}

深入理解 C++ weak_ptr

面试高频指数:★★☆☆☆

weak_ptr 是什么?

std::weak_ptr是C++11引入的一种智能指针,主要与std::shared_ptr配合使用。

它的主要作用是解决循环引用问题、观察std::shared_ptr对象而不影响引用计数,以及在需要时提供对底层资源的访问。

  1. 解决循环引用问题:当两个或多个std::shared_ptr对象互相引用时,会导致循环引用。这种情况下,这些对象的引用计数永远不会变为0,从而导致内存泄漏。
    std::weak_ptr可以打破这种循环引用,因为它不会增加引用计数。只需要将其中一个对象的std::shared_ptr替换为std::weak_ptr,即可解决循环引用问题。

  2. 观察std::shared_ptr对象:std::weak_ptr可以用作观察者,监视std::shared_ptr对象的生命周期。它不会增加引用计数,因此不会影响资源的释放。

    深入理解weak_ptr: 资源所有权问题

以下代码在多线程程序中是存在很大风险的,因为wp.expired()和wp.lock()运行的期间对象可能被释放:

std::weak_ptr<SomeClass> wp{ sp };

if (!wp.expired()) {
    wp.lock()->DoSomething();
}

正确的做法是:

auto sp = wp.lock();
if (sp) {
    sp->DoSomething();
}

C/C++ malloc-free底层原理-动态内存管理

面试高频指数:★★★★★

关于动态内存管理这块在面试中被考察频率非常高,切入的点也很多,有从操作系统虚拟内存问起的,也有从 malloc、new 等开始问起的。

但是无外乎就是两块内容:

  • 虚拟内存机制:物理和虚拟地址空间、TLB 页表、内存映射
  • 动态内存管理:内存管理、分配方式、内存回收、GC等等

malloc 和 free

malloc 和 free 是 C 语言中用于动态内存分配和释放内存的两个函数。

它们是 C 语言标准库的一部分,用于在程序运行期间请求和释放堆内存。

进程地址空间

img

由于虚拟内存的存在,每个进程就像独占整个地址空间一样。

如上图所示在一个32位系统中,可寻址的空间大小是4G,linux系统下0-3G是用户模式,3-4G是内核模式。

而在用户模式下又分为代码段、数据段、.bss段、堆、栈

其中代码段主要存放进程的可执行二进制代码,字符串字面值和只读变量。

数据段存放已经初始化且初始值非0的全局变量和局部静态变量。

bss段则存放未初始化或初始值为0的全局变量和局部静态变量。

而堆段则是存放由用户动态分配内存存储的变量。

栈段则主要存储局部变量、函数参数、返回地址等。

内存映射 mmap

  • 内存映射段(mmap) 的作用是:内核将硬盘文件的内容直接映射到内存,任何应用程序都可通过 Linux 的 mmap() 系统调用请求这种映射。

  • 内存映射是一种方便高效的文件 I/O 方式, 因而被用于装载动态共享库。

  • 用户也可创建匿名内存映射,该映射没有对应的文件,可用于存放程序数据。

    [!NOTE]

    在 Linux 中,若通过 malloc() 请求一大块内存,C 运行库将创建一个匿名内存映射,而不使用堆内存。“大块”意味着比阈值MMAP_THRESHOLD还大,缺省为 128KB,可通过 mallopt() 调整。

  • mmap 映射区向下扩展,堆向上扩展,两者相对扩展,直到耗尽虚拟地址空间中的剩余区域。

在Linux中进程由进程控制块(PCB)描述,用一个task_struct 数据结构表示,这个数据结构记录了所有进程信息,包括进程状态、进程调度信息、标示符、进程通信相关信息、进程连接信息、时间和定时器、文件系统信息、虚拟内存信息等. 和malloc密切相关的就是虚拟内存信息,定义为struct mm_struct *mm 具体描述进程的地址空间。

mm_struct结构是对整个用户空间(进程空间)的描述

//include/linux/sched.h 

struct mm_struct {
  struct vm_area_struct * mmap;  /* 指向虚拟区间(VMA)链表 */
  rb_root_t mm_rb;         /*指向red_black树*/
  struct vm_area_struct * mmap_cache;     /* 指向最近找到的虚拟区间*/
  pgd_t * pgd;             /*指向进程的页目录*/
  atomic_t mm_users;                   /* 用户空间中的有多少用户*/                                     
  atomic_t mm_count;               /* 对"struct mm_struct"有多少引用*/                                     
  int map_count;                        /* 虚拟区间的个数*/
  struct rw_semaphore mmap_sem;
  spinlock_t page_table_lock;        /* 保护任务页表和 mm->rss */       
  struct list_head mmlist;            /*所有活动(active)mm的链表 */
  unsigned long start_code, end_code, start_data, end_data; /* 代码段、数据段 起始地址和结束地址 */
  unsigned long start_brk, brk, start_stack; /* 栈区 的起始地址,堆区 起始地址和结束地址 */
  unsigned long arg_start, arg_end, env_start, env_end; /*命令行参数 和 环境变量的 起始地址和结束地址*/
  unsigned long rss, total_vm, locked_vm;
  unsigned long def_flags;
  unsigned long cpu_vm_mask;
  unsigned long swap_address;

  unsigned dumpable:1;
  /* Architecture-specific MM context */
  mm_context_t context;
};

其中start_brk和brk分别是堆的起始和终止地址,我们使用malloc动态分配的内存就在这之间。

start_stack是进程栈的起始地址,栈的大小是在编译时期确定的,在运行时不能改变。

而堆的大小由start_brk 和brk决定,但是可以使用系统调用sbrk() 或brk()增加brk的值,达到增大堆空间的效果,但是系统调用代价太大,涉及到用户态和内核态的相互转换。

所以,实际中系统分配较大的堆空间,进程通过malloc()库函数在堆上进行空间动态分配,堆如果不够用malloc可以进行系统调用,增大brk的值。

[!NOTE]

malloc只知道start_brk 和brk之间连续可用的内存空间它可用任意分配,如果不够用了就向系统申请增大brk。后面一部分主要就malloc如何分配内存进行说明。

相关系统调用

brk()和sbrk()
由之前的进程地址空间结构分析可以知道,要增加一个进程实际的可用堆大小,就需要将break指针向高地址移动。

Linux通过brk和sbrk系统调用操作break指针。两个系统调用的原型如下:

#include <unistd.h>
int brk(void *addr);
void *sbrk(intptr_t increment);

brk函数将break指针直接设置为某个地址,而sbrk将break指针从当前位置移动increment所指定的增量。

brk在执行成功时返回0,否则返回-1并设置errno为ENOMEM;sbrk成功时返回break指针移动之前所指向的地址,否则返回(void *)-1。

[!NOTE]

ps: 如果将increment设置为0,则可以获得当前break的地址。

另外需要注意的是,由于Linux是按页进行内存映射的,所以如果break被设置为没有按页大小对齐,则系统实际上会在最后映射一个完整的页,从而实际已映射的内存空间比break指向的地方要大一些。但是使用break之后的地址是很危险的(尽管也许break之后确实有一小块可用内存地址)。

进程所面对的虚拟内存地址空间,只有按页映射到物理内存地址,才能真正使用。

受物理存储容量限制,整个堆虚拟内存空间不可能全部映射到实际的物理内存。

因此每个进程有一个rlimit表示当前进程可用的资源上限。这个限制可以通过getrlimit系统调用得到。

img

其中rlimit是一个结构体:

struct rlimit {
  rlim_t rlim_cur;  /* Soft limit */
  rlim_t rlim_max;  /* Hard limit (ceiling for rlim_cur) */
};

每种资源有软限制和硬限制,并且可以通过setrlimit对rlimit进行有条件设置。

其中硬限制作为软限制的上限,非特权进程只能设置软限制,且不能超过硬限制。

mmap函数

#include <sys/mman.h>
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
int munmap(void *addr, size_t length);

mmap函数第一种用法是映射磁盘文件到内存中;而malloc使用的mmap函数的第二种用法,即匿名映射,匿名映射不映射磁盘文件,而是向映射区申请一块内存。

munmap函数是用于释放内存,第一个参数为内存首地址,第二个参数为内存的长度。接下来看下mmap函数的参数。

当申请小内存的时,malloc使用sbrk分配内存;当申请大内存时,使用mmap函数申请内存;

但是这只是分配了虚拟内存,还没有映射到物理内存,当访问申请的内存时,才会因为缺页异常,内核分配物理内存。

img****

  • 分配内存 < DEFAULT_MMAP_THRESHOLD,走__brk,从内存池获取,失败的话走brk系统调用

  • 分配内存 > DEFAULT_MMAP_THRESHOLD,走__mmap,直接调用mmap系统调用

其中,DEFAULT_MMAP_THRESHOLD默认为128k,可通过mallopt进行设置。 重点看下小块内存(size > DEFAULT_MMAP_THRESHOLD)的分配

malloc实现方案

由于brk/sbrk/mmap属于系统调用,如果每次申请内存,都调用这三个函数中的一个,那么每次都要产生系统调用开销(即cpu从用户态切换到内核态的上下文切换,这里要保存用户态数据,等会还要切换回用户态),这是非常影响性能的;

其次,这样申请的内存容易产生碎片,因为堆是从低地址到高地址,如果低地址的内存没有被释放,高地址的内存就不能被回收。

鉴于此,malloc采用的是内存池的实现方式,malloc内存池实现方式更类似于STL分配器和memcached的内存池,先申请一大块内存,然后将内存分成不同大小的内存块,然后用户申请内存时,直接从内存池中选择一块相近的内存块即可。

img

内存池保存在bins这个长128的数组中,每个元素都是一双向个链表。

  • bins[0]目前没有使用

  • bins[1]的链表称为unsorted_list,用于维护free释放的chunk。

  • bins[2,63)的区间称为small_bins,用于维护<512字节的内存块,其中每个元素对应的链表中的chunk大小相同,均为index*8。

  • bins[64,127)称为large_bins,用于维护>512字节的内存块,每个元素对应的链表中的chunk大小不同,index越大,链表中chunk的内存大小相差越大。

例如: 下标为64的chunk大小介于[512, 512+64),下标为95的chunk大小介于[2k+1,2k+512)。同一条链表上的chunk,按照从小到大的顺序排列。

malloc将内存分成了大小不同的chunk,然后通过bins来组织起来。

malloc将相似大小的chunk(图中可以看出同一链表上的chunk大小差不多)用双向链表链接起来,这样一个链表被称为一个bin。malloc一共维护了128个bin,并使用一个数组来存储这些bin。

数组中第一个为unsorted bin,数组编号前2到前64的bin为small bins,同一个small bin中的chunk具有相同的大小,两个相邻的small bin中的chunk大小相差8bytes。

small bins后面的bin被称作large bins。large bins中的每一个bin分别包含了一个给定范围内的chunk,其中的chunk按大小序排列。

large bin的每个bin相差64字节。

一般的情况是,程序在运行时会经常需要申请和释放一些较小的内存空间。当分配器合并了相邻的几个小的 chunk 之后,也许马上就会有另一个小块内存的请求,这样分配器又需要从大的空闲内存中切分出一块,这样无疑是比较低效的,故而,malloc 中在分配过程中引入了 fast bins,不大于 max_fast(默认值为 64B)的 chunk 被释放后,首先会被放到 fast bins中,fast bins 中的 chunk 并不改变它的使用标志 P。

这样也就无法将它们合并,当需要给用户分配的 chunk 小于或等于 max_fast 时,malloc 首先会在 fast bins 中查找相应的空闲块,然后才会去查找 bins 中的空闲 chunk。在某个特定的时候,malloc 会遍历 fast bins 中的 chunk,将相邻的空闲 chunk 进行合并,并将合并后的 chunk 加入 unsorted bin 中,然后再将 unsorted bin 里的 chunk 加入 bins 中。

unsorted bin 的队列使用 bins 数组的第一个,如果被用户释放的 chunk 大于 max_fast,或者 fast bins 中的空闲 chunk 合并后,这些 chunk 首先会被放到 unsorted bin 队列中,在进行 malloc 操作的时候,如果在 fast bins 中没有找到合适的 chunk,则malloc 会先在 unsorted bin 中查找合适的空闲 chunk,然后才查找 bins。

如果 unsorted bin 不能满足分配要求。 malloc便会将 unsorted bin 中的 chunk 加入 bins 中。然后再从 bins 中继续进行查找和分配过程。从这个过程可以看出来,unsorted bin 可以看做是 bins 的一个缓冲区,增加它只是为了加快分配的速度。(其实感觉在这里还利用了局部性原理,常用的内存块大小差不多,从unsorted bin这里取就行了,这个和TLB之类的都是异曲同工之妙啊!)

除了上述四种bins之外,malloc还有三种内存区。

当fast bin和bins都不能满足内存需求时,malloc会设法在top chunk中分配一块内存给用户;top chunk为在mmap区域分配一块较大的空闲内存模拟sub-heap。(比较大的时候) >top chunk是堆顶的chunk,堆顶指针brk位于top chunk的顶部。移动brk指针,即可扩充top chunk的大小。当top chunk大小超过128k(可配置)时,会触发malloc_trim操作,调用sbrk(-size)将内存归还操作系统。

当chunk足够大,fast bin和bins都不能满足要求,甚至top chunk都不能满足时,malloc会从mmap来直接使用内存映射来将页映射到进程空间,这样的chunk释放时,直接解除映射,归还给操作系统。(极限大的时候)

Last remainder是另外一种特殊的chunk,就像top chunk和mmaped chunk一样,不会在任何bins中找到这种chunk。当需要分配一个small chunk,但在small bins中找不到合适的chunk,如果last remainder chunk的大小大于所需要的small chunk大小,last remainder chunk被分裂成两个chunk,其中一个chunk返回给用户,另一个chunk变成新的last remainder chunk。(这个应该是fast bins中也找不到合适的时候,用于极限小的)

除了上述四种bins之外,malloc还有三种内存区。

当fast bin和bins都不能满足内存需求时,malloc会设法在top chunk中分配一块内存给用户;top chunk为在mmap区域分配一块较大的空闲内存模拟sub-heap。(比较大的时候) >top chunk是堆顶的chunk,堆顶指针brk位于top chunk的顶部。移动brk指针,即可扩充top chunk的大小。当top chunk大小超过128k(可配置)时,会触发malloc_trim操作,调用sbrk(-size)将内存归还操作系统。

当chunk足够大,fast bin和bins都不能满足要求,甚至top chunk都不能满足时,malloc会从mmap来直接使用内存映射来将页映射到进程空间,这样的chunk释放时,直接解除映射,归还给操作系统。(极限大的时候)

Last remainder是另外一种特殊的chunk,就像top chunk和mmaped chunk一样,不会在任何bins中找到这种chunk。当需要分配一个small chunk,但在small bins中找不到合适的chunk,如果last remainder chunk的大小大于所需要的small chunk大小,last remainder chunk被分裂成两个chunk,其中一个chunk返回给用户,另一个chunk变成新的last remainder chunk。(这个应该是fast bins中也找不到合适的时候,用于极限小的)

img

由之前的分析可知malloc利用chunk结构来管理内存块,malloc就是由不同大小的chunk链表组成的。malloc会给用户分配的空间的前后加上一些控制信息,用这样的方法来记录分配的信息,以便完成分配和释放工作。chunk指针指向chunk开始的地方,图中的mem指针才是真正返回给用户的内存指针。

  • chunk 的第二个域的最低一位为P,它表示前一个块是否在使用中,P 为 0 则表示前一个 chunk 为空闲,这时chunk的第一个域 prev_size 才有效,prev_size 表示前一个 chunk 的 size,程序可以使用这个值来找到前一个 chunk 的开始地址。当 P 为 1 时,表示前一个 chunk 正在使用中,prev_size程序也就不可以得到前一个 chunk 的大小。不能对前一个 chunk 进行任何操作。malloc分配的第一个块总是将 P 设为 1,以防止程序引用到不存在的区域。(这里就很细!)

  • Chunk 的第二个域的倒数第二个位为M,他表示当前 chunk 是从哪个内存区域获得的虚拟内存。M 为 1 表示该 chunk 是从 mmap 映射区域分配的,否则是从 heap 区域分配的。

  • Chunk 的第二个域倒数第三个位为 A,表示该 chunk 属于主分配区或者非主分配区,如果属于非主分配区,将该位置为 1,否则置为 0。

    当chunk空闲时,其M状态是不存在的,只有AP状态,原本是用户数据区的地方存储了四个指针,指针fd指向后一个空闲的chunk,而bk指向前一个空闲的chunk,malloc通过这两个指针将大小相近的chunk连成一个双向链表。

    在large bin中的空闲chunk,还有两个指针,fd_nextsize和bk_nextsize,用于加快在large bin中查找最近匹配的空闲chunk。不同的chunk链表又是通过bins或者fastbins来组织的。(这里就很符合网上大多数人说的链表理论了)

    malloc 内存分配流程

    如果分配内存<512字节,则通过内存大小定位到smallbins对应的index上(floor(size/8))

    如果smallbins[index]为空,进入步骤3 如果smallbins[index]非空,直接返回第一个chunk 如果分配内存>512字节,则定位到largebins对应的index上

    如果largebins[index]为空,进入步骤3 如果largebins[index]非空,扫描链表,找到第一个大小最合适的chunk,如size=12.5K,则使用chunk B,剩下的0.5k放入unsorted_list中 遍历unsorted_list,查找合适size的chunk,如果找到则返回;否则,将这些chunk都归类放到smallbins和largebins里面

    index++从更大的链表中查找,直到找到合适大小的chunk为止,找到后将chunk拆分,并将剩余的加入到unsorted_list中

    如果还没有找到,那么使用top chunk

    或者,内存<128k,使用brk;内存>128k,使用mmap获取新内存

    此外,调用free函数时,它将用户释放的内存块连接到空闲链上。到最后,空闲链会被切成很多的小内存片段,如果这时用户申请一个大的内存片段,那么空闲链上可能没有可以满足用户要求的片段了。

    于是,malloc函数请求延时,并开始在空闲链上翻箱倒柜地检查各内存片段,对它们进行整理,将相邻的小空闲块合并成较大的内存块。

    [!NOTE]

    虚拟内存并不是每次malloc后都增长,是与上一节说的堆顶没发生变化有关,因为可重用堆顶内剩余的空间,这样的malloc是很轻量快速的。

如果虚拟内存发生变化,基本与分配内存量相当,因为虚拟内存是计算虚拟地址空间总大小。

物理内存的增量很少,是因为malloc分配的内存并不就马上分配实际存储空间,只有第一次使用,如第一次memset后才会分配。

由于每个物理内存页面大小是4k,不管memset其中的1k还是5k、7k,实际占用物理内存总是4k的倍数。所以物理内存的增量总是4k的倍数。

因此,不是malloc后就马上占用实际内存,而是第一次使用时发现虚存对应的物理页面未分配,产生缺页中断,才真正分配物理页面,同时更新进程页面的映射关系。这也是Linux虚拟内存管理的核心概念之一。

内存碎片

free释放内存时,有两种情况:

chunk和top chunk相邻,若两者不相邻则和top chunk合并

img

如上图示: top chunk是堆顶的chunk,堆顶指针brk位于top chunk的顶部。移动brk指针,即可扩充top chunk的大小。当top chunk大小超过128k(可配置)时,会触发malloc_trim操作,调用sbrk(-size)将内存归还操作系统。

以上图chunk分布图为例,按照glibc的内存分配策略,我们考虑下如下场景(假设brk其实地址是512k):

malloc 40k内存,即chunkA,brk = 512k + 40k = 552k malloc 50k内存,即chunkB,brk = 552k + 50k = 602k malloc 60k内存,即chunkC,brk = 602k + 60k = 662k free chunkA。

此时,由于brk = 662k,而释放的内存是位于[512k, 552k]之间,无法通过移动brk指针,将区域内内存交还操作系统,因此,在[512k, 552k]的区域内便形成了一个内存空洞即内存碎片。 按照glibc的策略,free后的chunkA区域由于不和top chunk相邻,因此,无法和top chunk 合并,应该挂在unsorted_list链表上。

C++ malloc、new,free、delete 区别

面试高频指数:★★★★☆

malloc、new、free、delete 这几个总是放在一起来对比,今天来彻底的解析一下这几个函数/操作符:

整体用法:

#include <cstdlib>

int main() {
    // 使用 malloc 分配内存
    int *ptr = (int *)malloc(sizeof(int));
    // 记得在使用完之后释放内存
    free(ptr);
}
int main() {
    // 使用 new 分配内存并调用构造函数
    int *ptr = new int;
    // 记得在使用完之后释放内存
    delete ptr;
}

简单对比

  • 语法不同:malloc/free是一个C语言的函数,而new/delete是C++的运算符。
  • 分配内存的方式不同:malloc只分配内存,而new会分配内存并且调用对象的构造函数来初始化对象。
  • 返回值不同:malloc返回一个 void 指针,需要自己强制类型转换,而new返回一个指向对象类型的指针。
  • malloc 需要传入需要分配的大小,而 new 编译器会自动计算所构造对象的大小

详细解析

  1. 申请的内存所在位置

new 操作符从自由存储区(free store)上为对象动态分配内存空间,而malloc函数从堆上动态分配内存。

自由存储区是 C++ 基于new操作符的一个抽象概念,凡是通过 new 操作符进行内存申请,该内存即为自由存储区。

而堆是操作系统中的术语,是操作系统所维护的一块特殊内存,用于程序的内存动态分配,C语言使用malloc从堆上分配内存,使用free释放已分配的对应内存。

那么自由存储区是否能够是堆(问题等价于new是否能在堆上动态分配内存),这取决于operator new 的实现细节。

自由存储区不仅可以是堆,还可以是静态存储区,这都看operator new在哪里为对象分配内存。

不过大家可以基本将自由存储区等价于堆区。

特别的,new 甚至可以不为对象分配内存!placement_new的功能可以办到这一点:

new (place_address) type

place_address为一个指针,代表一块内存的地址。当使用上面这种仅以一个地址调用new操作符时,new操作符调用特殊的operator new,也就是下面这个版本:

void * operator new (size_t,void *) //不允许重定义这个版本的operator new

这个operator new不分配任何的内存,它只是简单地返回指针实参,然后 new 表达式负责在place_address指定的地址进行对象的初始化工作。

2. 内存分配失败时返回值

new内存分配失败时,会抛出bac_alloc异常,它不会返回NULL;malloc分配内存失败时返回NULL。

在使用C语言时,我们习惯在malloc分配内存后判断分配是否成功:

int *a  = (int *)malloc ( sizeof (int ));
if(NULL == a) {
    ...
} else {
    ...
}

从 C 语言走入 C++ 阵营的新手可能会把这个习惯带入C++:

int * a = new int();
if(NULL == a) {
    ...
} else {   
    ...
}

实际上这样做一点意义也没有,因为new根本不会返回NULL,而且程序能够执行到if语句已经说明内存分配成功了,如果失败早就抛异常了。

正确的做法应该是使用异常机制:

try {
    int *a = new int();
} catch (bad_alloc) {
    ...
}
  1. 是否调用构造函数/析构函数

使用new操作符来分配对象内存时会经历三个步骤:

  • 第一步:调用operator new 函数(对于数组是operator new[])分配一块足够大的,原始的,未命名的内存空间以便存储特定类型的对象。

  • 第二步:编译器运行相应的构造函数以构造对象,并为其传入初值。

  • 第三部:对象构造完成后,返回一个指向该对象的指针。

    使用delete操作符来释放对象内存时会经历两个步骤:

  • 第一步:调用对象的析构函数。

  • 第二步:编译器调用operator delete(或operator delete[])函数释放内存空间。

  1. 对数组的处理

C++ 提供了 new[] 与 delete[] 来专门处理数组类型:

A * ptr = new A[10];//分配10个A对象

使用 new[] 分配的内存必须使用 delete[] 进行释放:

delete [] ptr;

new 对数组的支持体现在它会分别调用构造函数函数初始化每一个数组元素,释放对象时为每个对象调用析构函数。

注意 delete[] 要与new[] 配套使用,不然会找出数组对象部分释放的现象,造成内存泄漏。

至于 malloc,它并知道你在这块内存上要放的数组还是啥别的东西,反正它就给你一块原始的内存,在给你个内存的地址就完事。所以如果要动态分配一个数组的内存,还需要我们手动自定数组的大小:

int * ptr = (int *) malloc( sizeof(int)* 10 );//分配一个10个int元素的数组
  1. new 和 malloc 是否可以相互调用

operator new /operator delete的实现可以基于malloc,而malloc的实现不可以去调用new。下面是编写operator new /operator delete 的一种简单方式,其他版本也与之类似:

void * operator new (sieze_t size)
{
    if(void * mem = malloc(size)
        return mem;
    else
        throw bad_alloc();
}
void operator delete(void *mem) noexcept
{
    free(mem);
}
  1. 能够直观地重新分配内存

使用 malloc 分配的内存后,如果在使用过程中发现内存不足,可以使用 realloc 函数进行内存重新分配实现内存的扩充。

realloc 先判断当前的指针所指内存是否有足够的连续空间,如果有,原地扩大可分配的内存地址,并且返回原来的地址指针;

如果空间不够,先按照新指定的大小分配空间,将原有数据从头到尾拷贝到新分配的内存区域,而后释放原来的内存区域。

new 没有这样直观的配套设施来扩充内存。

特征new/deletemalloc/free
分配内存的位置自由存储区
内存分配成功的返回值完整类型指针void*
内存分配失败的返回值默认抛出异常返回NULL
分配内存的大小由编译器根据类型计算得出必须显式指定字节数
处理数组有处理数组的new版本new[]需要用户计算数组的大小后进行内存分配
已分配内存的扩充无法直观地处理使用realloc简单完成
是否相互调用可以,看具体的operator new/delete实现不可调用new
分配内存时内存不足客户能够指定处理函数或重新制定分配器无法通过用户代码进行处理
函数重载允许不允许
构造函数与析构函数调用不调用

C/C++ 内存泄露如何定位、检测以及避免

面试高频指数:★★★★☆

内存泄露是什么?

简单来说就是:在程序中申请了动态内存,却没有释放,如果程序长期运行下去,最终会导致没有内存可供分配。

所以不少大厂的服务有个特点,就是会定期重启服务进程,重启的目的就是让操作系统回收整个进程的资源包括内存,这样一点点的内存泄露问题即使无法定位,也不是什么大问题哈哈哈

如何检测

检测内存泄露的方法:

  1. 手动检查代码:仔细检查代码中的内存分配和释放,确保每次分配内存后都有相应的释放操作。比如 malloc和free、new和delete是否配对使用了。

  2. 使用调试器和工具:有一些工具可以帮助检测内存泄露。例如:

  • Valgrind(仅限于Linux和macOS):Valgrind是一个功能强大的内存管理分析工具,可以检测内存泄露、未初始化的内存访问、数组越界等问题。使用Valgrind分析程序时,只需在命令行中输入valgrind --leak-check=yes your_program即可。
  • Visual Studio中的CRT(C Runtime)调试功能:Visual Studio提供了一些用于检测内存泄露的C Runtime库调试功能。例如,_CrtDumpMemoryLeaks函数可以在程序结束时报告内存泄露。
  • AddressSanitizer:AddressSanitizer是一个用于检测内存错误的编译器插件,适用于GCC和Clang。要启用AddressSanitizer,只需在编译时添加-fsanitize=address选项。

如何避免内存泄露

使用智能指针(C++):在C++中,可以使用智能指针(如std::unique_ptr和std::shared_ptr)来自动管理内存。这些智能指针在作用域结束时会自动释放所指向的内存,从而降低忘记释放内存或者程序异常导致内存泄露的风险。

异常安全:在C++中,如果程序抛出异常,需要确保在异常处理过程中正确释放已分配的内存。使用try-catch块来捕获异常并在适当的位置释放内存。 或者使用RAII(Resource Acquisition Is Initialization)技术

C/C++ 野指针和空悬指针

面试高频指数:★★★☆☆

野指针(Wild Pointer)和空悬指针(Dangling Pointer)都是指向无效内存的指针,但它们的成因和表现有所不同,区别如下:

野指针(Wild Pointer)

野指针是一个未被初始化或已被释放的指针。

所以它的值是不确定的,可能指向任意内存地址。

访问野指针可能导致未定义行为,如程序崩溃、数据损坏等。

以下是一个野指针的例子:

#include <iostream>

int main() {
    int *wild_ptr; // 未初始化的指针,值不确定
    std::cout << *wild_ptr << std::endl; // 访问野指针,可能导致未定义行为
    return 0;
}

空悬指针(Dangling Pointer)

空悬指针是指向已经被释放(如删除、回收)的内存的指针。

这种指针仍然具有以前分配的内存地址,但是这块内存可能已经被其他对象或数据占用。

访问空悬指针同样会导致未定义行为。

#include <iostream>

int main() {
    int *ptr = new int(42);
    delete ptr; // 释放内存

    // 此时,ptr成为一个空悬指针,因为它指向的内存已经被释放
    std::cout << *ptr << std::endl; // 访问空悬指针,可能导致未定义行为
    return 0;
}

为了避免野指针和空悬指针引发的问题,我们应该:

  1. 在使用指针前对其进行初始化,如将其初始化为nullptr。
  2. 在释放指针指向的内存后,将指针设为nullptr,避免误访问已释放的内存。
  3. 在使用指针前检查其有效性,确保指针指向合法内存。

delete nullptr 安全性

[!NOTE]

对一个动态存储的指针赋值nullptr,然后使用delete释放内存是否安全。我上网查阅资料有些说安全有些说不安全。 还有一个问题是,使用delete后是否要将其设置为nullptr,网上说设置为nullptr可以避免悬空指针的问题,也有的说将指针删除后设置为nullptr会伪装内存分配错误,说设置为nullptr没什么意义。

C++ 标准里 delete 一个 nullptr 实际上没任何效果,可以理解为是安全的

delete 指针后是否要置空其实也有一些争论,不过总体上来说是建议 置空的,这是为了防止后续再使用这个指针去访问到了这块已经释放的内存,但是我觉得写C++的程序员应该要对 指针的生命周期掌握比较准确

常见的 C/C++ 内存错误

面试高频指数:★★★★☆

  1. 间接引用坏指针

要知道进程的虚拟地址空间中有较大的空洞,没有映射到任何有意义的数据。

如果我们试图间接引用一个指向这些洞的指针,那么操作系统就会以段异常中止程序。

而且,虚拟内存的某些区域是只读的,试图写这些区域将会以保护异常中止这个程序。

间接引用坏指针的一个常见示例是经典的 scanf 错误。

假设我们想要使用 scanf 从 stdin 读一个整数到一个变量。

正确的方法是传递给 scanf 一个格式串和变量的地址:

int val;
scanf("%d", &val)`

然而,对于不少 C/C++ 初学者而言(对有经验者也是如此!),很容易传递 val 的内容,而不是它的地址:

scanf("%d", val)

在这种情况下,scanf 将把 val 的内容解释为一个地址,并试图将一个字写到这个位置。

在最好的情况下,程序立即以异常终止。在最糟糕的情况下,val 的内容对应于虚拟内存的某个合法的读/写区域,于是我们就覆盖了这块内存。

2. 读未初始化的内存

虽然 bss 内存位置(诸如未初始化的全局 C 变量)总是被加载器初始化为零,但是对于堆内存却并不是这样的。

一个常见的错误就是假设堆内存被初始化为零:

/* Return y = Ax */
int *matvec(int **A, int *x, int n)
{
    int i, j;
    
    int *y = (int *)Malloc(n * sizeof(int));
    
    for (i = 0; i < n; i++)
        for (j = 0; j < n; j++)
            y[i] += A[i][j] * x[j];
    return y;
}

在这个示例中,程序员不正确地假设向量 y 被初始化为零。正确的实现方式是显式地将 y[i] 设置为零,或者使用 calloc。

3. 栈缓冲区溢出

如果一个程序不检查输入串的大小就写入栈中的目标缓冲区,那么这个程序就会有缓冲区溢出错误(buffer overflow bug)。

例如,下面的函数就有缓冲区溢出错误,因为 gets 函数复制一个任意长度的串到缓冲区。

为了纠正这个错误,必须使用 fgets 函数,这个函数限制了输入串的大小:

void bufoverflow()
{
    char buf[64];
    gets(buf); /* Here is the stack buffer overflow bug */
    return;
}
  1. 误解指针运算

另一种常见的错误是忘记了指针的算术操作是以它们指向的对象的大小为单位来进行的,而这种大小单位并不一定是字节。

例如,下面函数的目的是扫描一个 int 的数组,并返回一个指针,指向 val 的首次出现:

int *search(int *p, int val)
{
    while (*p && *p != val)
        p += sizeof(int); /* Should be p++ */
    return p;
}
  1. 引用不存在的变量

没有太多经验的 C 程序员不理解栈的规则,有时会引用不再合法的本地变量,如下列所示:

int *stackref ()
{
    int val;
    return &val;
}

这个函数返回一个指针(比如说是 p),指向栈里的一个局部变量,然后弹出它的栈帧。 尽管 p 仍然指向一个合法的内存地址,但是它已经不再指向一个合法的变量了。 当以后在程序中调用其他函数时,内存将重用它们的栈帧。再后来,如果程序分配某个值给 *p,那么它可能实际上正在修改另一个函数的栈帧中的一个条目,从而潜在地带来灾难性的、令人困惑的后果。

  1. 引起内存泄漏
    内存泄漏是缓慢、隐性的杀手,当程序员不小心忘记释放已分配块,而在堆里创建了垃圾时,会发生这种问题。例如,下面的函数分配了一个堆块 x,然后不释放它就返回:
void leak(int n)
{
    int *x = (int *)Malloc(n * sizeof(int));
    return;  /* x is garbage at this point */
}
  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值