✍️ C++ 11 新特性大全:https://zhuanlan.zhihu.com/p/139515439
🔥 C++ 八股文 PDF:
1. gcc 编译流程
一段高级语言代码经过四个阶段的处理形成可执行的目标二进制代码。
预处理器→编译器→汇编器→链接器:最难理解的是编译与汇编的区别。
这里采用《深入理解计算机系统》的说法。
预处理阶段:预处理阶段主要处理 #include
指令、宏替换、条件编译等,生成 .i
文件。
- 展开头文件:将
#include
指定的文件插入到源代码中 - 宏替换:替换所有
#define
定义的宏 - 条件编译:根据预处理指令(如
#ifdef
)选择性地编译代码 - 去除注释:删除源代码中的注释内容
写好的高级语言的程序文本比如
hello.c
,预处理器根据#
开头的命令,修改原始的程序,如#include<stdio.h>
将把系统中的头文件插入到程序文本中,通常是以.i
结尾的文件。
1 | gcc -E source.c -o source.i |
编译阶段:编译阶段对源代码进行语法语义检查,生成汇编代码,产生 .s
文件。
编译器将
hello.i
文件翻译成汇编语言程序hello.s
,不同的高级语言翻译的汇编语言相同。
1 | gcc -S source.i -o source.s |
汇编阶段:汇编阶段将汇编代码翻译成机器码(机器可识别的目标代码),生成 .o
目标文件。
汇编器将汇编代码
hello.s
翻译成机器语言指令。把这些指令打包成可重定位目标程序,即.o
文件。hello.o
是一个二进制文件,它的字节码是机器语言指令,不再是字符,前面两个阶段都还有字符。
1 | gcc -c source.s -o source.o |
链接阶段: 链接阶段将多个目标文件和库文件链接在一起,生成最终的可执行文件,链接过程还可能会调用外部的动态或静态库。
比如
hello
程序调用printf
程序,它是每个 C 编译器都会提供的标准库 C 的函数。这个函数存在于一个名叫printf.o
的单独编译好的目标文件中,这个文件将以某种方式合并到hello.o
中。链接器就负责这种合并,得到的是可执行目标文件。
1 | gcc source.o -o executable |
关于编译优化:
GCC 和 G++ 提供了多种优化选项,开发者可以根据项目需求选择合适的优化级别
优化级别 | 描述 |
---|---|
-O0 |
无优化(默认) |
-O1 |
基本优化 |
-O2 |
在不显著增加编译时间的前提下进行进一步优化 |
-O3 |
启用所有优化选项,可能导致代码体积增加 |
-Os |
优化代码体积,适用于存储受限的设备 |
2. C 和 C++ 区别(函数/类/struct/class)
首先,C 和 C++ 在基本语句上没有过大的区别。
C++ 有新增的语法和关键字:
- 语法的区别有头文件的不同和命名空间的不同,C++ 允许我们自己定义自己的空间,C 中不可以。
- 关键字方面比如 C++ 与 C 动态管理内存的方式不同,C++ 中在
malloc
和free
的基础上增加了new
和delete
,而且 C++ 中在指针的基础上增加了引用的概念,关键字例如 C++中还增加了auto
,explicit
体现显示转换和隐式转换上的概念要求,还有dynamic_cast
增加类型安全方面的内容。
函数方面 C++ 中有重载和虚函数的概念:
- C++ 支持函数重载而 C 不支持,是因为 C++ 函数的名字修饰与 C 不同,C++ 函数名字的修饰会将参数加在后面,例如,
int func(int, double)
经过名字修饰之后会变成_func_int_double
,而 C 中则会变成_func
,所以 C++ 中会支持不同参数调用不同函数。 - C++ 还有虚函数概念,用以实现多态。
类方面,C 的 struct
和 C++ 的类
也有很大不同:
- C++ 中的 struct 不仅可以有成员变量还可以成员函数,而且对于 struct 增加了权限访问的概念,struct 的默认成员访问权限和默认继承权限都是
public
,C++ 中除了struct
还有class
表示类,struct 和 class 还有一点不同在于 class 的默认成员访问权限和默认继承权限都是private
。
C++ 中增加了模板来重用代码,提供了更加强大的 STL 标准库。
最后补充一点就是 C 是一种结构化的语言,重点在于算法和数据结构。C 程序的设计首先考虑的是如何通过一个代码,一个过程对输入进行运算处理输出。而 C++ 首先考虑的是如何构造一个对象模型,让这个模型能够契合与之对应的问题领域,这样就能通过获取对象的状态信息得到输出。
C 的 struct 更适合看成是一个数据结构的实现体,而 C++ 的 class 更适合看成是一个对象的实现体。
3. C++ 和 Java 区别(语言特性、垃圾回收、应用场景等)
C++ 和 Java 都有染指
指针:Java 让程序员没法找到指针来直接访问内存,没有指针的概念,并且有自动内存管理功能,从而有效地防止了 C++ 语言中的指针操作失误的影响。但并非 Java 中没有指针,Java 虚拟机内部中还是使用了指针,保证了 Java 程序的安全性。
多重继承:C++ 支持多重继承但 Java 不支持,但支持一个类继承多个接口,实现 C++ 中多重继承的功能,又避免了 C++ 的多重继承带来的不便。
数据类型和类:Java 是完全面向对象的语言,所有的函数和变量必须是类的一部分。除了基本数据类型之外,其余的都作为类对象,对象将数据和方法结合起来,把他们封装在类中,这样每个对象都可以实现自己的特点和行为。Java 中取消了 C++ 中的 struct
和 union
。
自动内存管理:Java 程序中所有对象都是用 new
操作符建立在内存堆栈上,Java 自动进行无用内存回收操作,不需要程序员进行手动删除。而 C++ 中必须由程序员释放内存资源,增加了程序设计者的负担。Java 中当一个对象不再被使用时,无用内存回收器将给他们加上标签,Java 里无用内存回收程序是以线程方式在后台运行,利用空闲时间工作来删除。
Java 不支持操作符重载,操作符重载是 C++ 的突出特性。
Java 不支持预处理功能,C++ 在编译过程中都有一个预编译阶段,Java 没有预处理器,但它提供了 import
,与 C++ 预处理器具有类似功能。
类型转换:C++ 中有数据类型隐含转换的机制,Java 中需要显式强制类型转换。
字符串:C++ 中字符串是以 NULL 终止符代表字符串的结束,而 Java 的字符串是用类对象(string
和 stringBuffer
)来实现的。
Java 中不提供 goto 语句,虽然指定 goto 为关键字,但不支持使用它。
Java 的异常机制用于捕获例外事件,增强系统容错能力。
4. C++ 中 const 和 static 关键字的作用
在 C++ 中,const
关键字用于定义常量,表示一个值在初始化后不可被修改,它可以修饰变量、函数参数、成员变量以及成员函数(表示该函数不会修改类的成员状态)。
而 static
关键字用于控制变量或函数的生命周期与作用域,修饰局部变量时使其在程序整个生命周期内存在且只初始化一次,修饰全局变量或函数时将其作用域限制在当前文件内,修饰类的成员时则实现共享(成员变量被所有对象共享,成员函数可不依赖对象直接调用)。
两者分别从不可变性与持久性/共享性两个维度提供不同的语义约束。
static
static
作用:控制变量的存储方式和可见性。
1️⃣ 局部静态变量
一般情况下,对于局部变量在程序中是存放在栈区的,并且局部的生命周期在包含语句块执行结束时便也结束了。但是如果用 static 关键字修饰,该变量会存放在静态数据区,作用域仍为局部作用域,但是当局部静态变量离开作用域后,并没有销毁,而是仍然驻留在内存当中,只不过我们不能再对它进行访问,直到该函数再次被调用,并且值不变。
2️⃣ 全局静态变量
即 static 限制了全局变量的作用域(本文件)
对于一个全局变量,它既可以在本文件中被访问到,也可以在同一个工程其它源文件被访问(添加 extern
进行声明即可);而使用 static
对全局变量进行修饰改变了其作用域范围,由原来整个工程可见变成了本文件可见,同时也是存放在静态数据区,在整个程序运行期间一直存在。
3️⃣ 静态函数
函数的定义和声明在默认情况下都是 extern
的,但静态函数只是在声明它的文件当中可见(与全局静态变量类似)
4️⃣ 类的静态成员/函数
在类中,静态成员可以实现多个对象之间的数据共享,并且使用静态数据成员还不会破坏隐藏的原则,即保证了安全性。因此静态成员/函数是类中所有对象共享的成员/函数,而不是某个对象的成员/函数。
- 在模块内的 static 全局变量可以被模块内所有函数访问,但不能被模块外其它函数访问;
- 在类中的 static 成员变量属于整个类所拥有,对类的所有对象只有一份拷⻉;
- 在类中的 static 成员函数属于整个类所拥有,这个函数不接收 this 指针,因而只能访问类的 static 成员变量;
- static 类对象必须要在类外进行初始化,static 修饰的变量先于对象存在,所以 static 修饰的变量要在类外初始化;
- 由于 static 修饰的类成员属于类,不属于对象,因此 static 类成员函数是没有
this
指针,this 指针是指向本对象的指针,正因为没有 this 指针,所以 static 类成员函数不能访问非 static 的类成员,只能访问 static 修饰的类成员; - static 成员函数不能被
virtual
修饰,static 成员不属于任何对象或实例,所以加上 virtual 没有任何实际意义;静态成员函数没有 this 指针,虚函数的实现是为每一个对象分配一个 vptr 指针,而 vptr 是通过 this 指针调用的,所以不能为 virtual;虚函数的调用关系,this->vptr->ctable->virtual function。
const
1️⃣ const 修饰基本数据类型
修饰符 const 可以用在类型说明符前,也可以在类型说明符后,结果都是一样的,使用这些常量时,只要不改变这些常量的值即可。
2️⃣ const 修饰指针变量和引用变量
引用同理
如果 const 位于 const T*
左侧,则 const 就是用来修饰指针所指向的变量,即指针指向为常量。
如果 const 位于 T* const
右侧,则 const 就是修饰指针本身,即指针本身是常量。
3️⃣ const 应用到函数中
- 作为参数的 const 修饰符:调用函数时,用相应的变量初始化 const 常量,则在函数体中,按照 const 所修饰的部分进行常量化,保护了原对象的属性。
- 作为函数返回值的 const 修饰符:声明了返回值后,它意味着这个返回值是一个常量,不能被修改。
注意:参数 const 通常用于参数为指针或引用的情况。
4️⃣ const 在类中的用法
- const 成员变量:只在某个对象生命周期内是常量,而对于整个类而言是可以改变的(因为类可以创建多个对象,不同对象其 const 数据成员值可以不同,所以不能在类的声明中初始化 const 数据成员,因为类对象在没有创建的时候,编译器不知道 const 数据成员的值是什么,const 数据成员的初始化只能在类的构造函数初始化列表中进行)
- const 成员函数:防止成员函数修改对象的内容,要注意,const 和 static 对于成员函数来说是不能同时使用的,因为 static 关键字修饰静态成员函数不含有 this 指针,即不能实例化,const 成员函数又必须具体到某一个函数。
补充:
- const 成员函数如果实在想修改某个变量,可以使用
mutable
进行修饰; - 成员变量中如果想建立在整个类中都恒定的常量,应该用类中的
枚举常量
来实现或者static const
。
5️⃣ const 修饰类对象、定义常量函数
const 常量对象只能调用 const 常量函数,非 const 成员函数都不能调用。
原因:对象调用成员函数时,在形参列表的最前面加一个形参 this,但这是隐式的。this 指针是默认指向调用函数的当前对象的,所以很自然,this 是一个常量指针 test * const
,因为不可以修改 this 指针代表的地址。但当成员函数的参数列表后加了 const 关键字(void print() const;
),此成员函数为常量成员函数,此时它的隐式 this 形参为 const test * const
,表示指向常量对象的常量指针,即不可以通过 this 指针来改变指向对象的值。
非常量对象可以调用类中的 const 成员函数,也可以调用非 const 成员函数。
1 |
|
⚠️ 注意区别 int print() const;
和 const int print();
:
- 前者为常量成员函数,
const
位于函数声明的末尾,只能由 const 常量对象来调用该 const 常量函数。 - 后者为普通成员函数,但是返回值为
const int
(注意不能用 const 修饰 void,即const void print()
会编译错误,所以这里用了int
)
5. 说一说 C++ 中四种 cast 转换
C++ 中四种类型转换是:static_cast
、dynamic_cast
、const_cast
、reinterpret_cast
const_cast
用于将 const 变量转为非 const 变量:常量指针转换为⾮常量指针,并且仍然指向原来的对象;常量引⽤被转换为⾮常量引⽤,并且仍然指向原来的对象。const_cast
去掉类型的 const
或 volatile
属性。
static_cast
用于各种隐式转换,但是没有运行时类型检查来保证转换的安全性。
比如非 const 转 const,void* 转指针等,static_cast 还可以用于多态向上转换(如 Derived 转 Base,即子类转基类)
- 进行向上转换(把派生类指针或引用转换为基类)是安全的
- 进行向下转换(把基类指针或引用转换为派生类),由于没有运行时类型检查,所以是不安全的
1 | // Base 是 Derived 的基类/父类 |
dynamic_cast
在进行向下转换时,dynamic_cast
具有类型检查(信息在虚函数中)的功能,比 static_cast
更安全。
只能用于含有虚函数的类,用于类层次间的向上和向下转换(基类转子类),只能转指针或引用,向下转换时:
- 对于指针,转换失败则返回
nullptr
- 对于引用,转换失败则抛异常
1 | int main() { |
reinterpret_cast
几乎什么都可以转,比如将 int 转指针,可能会出问题,尽量少用。
WARNING:reinterpret_cast 本质上依赖于机器,要想安全地使用 reinterpret_cast 必须对涉及的类型和编译器实现转换的过程都非常了解。
为什么不使用 C 的强制转换?
C 的强制转换表面上看起来功能强大什么都能转,但是转化不够明确,不能进行错误检查,容易出错。
static_cast 与 dynamic_cast 之间的区别?
dynamic_cast
和 static_cast
的主要区别在于类型检查的时间点和安全性:
- 类型检查时间点:
static_cast
在编译时进行类型检查,而dynamic_cast
在运行时进行类型检查。 - 安全性:
static_cast
不执行运行时类型检查,因此如果在类层次结构中进行不安全的向下转换,可能导致未定义行为。相反,dynamic_cast
会在运行时检查转换的安全性,如果转换不安全,则返回nullptr
或抛出异常,提供更高的安全性。
6. C/C++ 的四大内存分区和常量的存储位置
四大内存分区:栈、堆、静态存储区(全局变量 + 静态变量 + 常量)和代码区。
1️⃣ 栈区
由系统进行内存的管理。主要存放函数的参数以及局部变量。在函数完成执行,系统自动释放栈区内存,不需要用户管理,整个程序的栈区大小可以在编译器中由用户自行设定,VS 中默认的栈区大小为 1M,可以通过 VS 手动更改栈的大小。64 bits 的 Linux 默认栈大小为 10MB,可通过 ulimit -s
临时修改,可通过 ulimit -a
查看。
2️⃣ 堆区
由程序员手动申请,手动释放,若不手动释放,程序结束后由系统回收,生命周期是整个程序运行期间。使用 malloc
或者 new
进行堆的申请,堆的总大小为机器的虚拟内存的大小。
说明:new
操作符本质上是使用了 malloc
进行内存的申请,new
和 malloc
的区别如下:
malloc
是 C 语言中的函数,而new
是 C++ 中的操作符。malloc
申请之后返回的类型是void*
,而new
返回的指针带有类型。malloc
只负责内存的分配而不会调用类的构造函数,而new
不仅会分配内存,而且会自动调用类的构造函数。
堆和栈的区别
申请方式不同:
- 栈是系统自动分配
- 堆是自己申请和释放的
申请大小限制不同:
- 栈空间默认 10 MB;栈顶和栈底是之前预设好的,栈是向栈底扩展,大小固定,可以通过
ulimit -a
查看,由ulimit -s
修改 - 堆区一般是 1G~4G;堆向高地址扩展,是不连续的内存区域,大小可以灵活调整
申请效率不同:
- 栈由系统分配,速度快,不会有碎片
- 堆由程序员分配,速度慢,且会有碎片
栈快还是堆快?
毫无疑问是栈快一点。
因为操作系统会在底层对栈提供支持,会分配专门的寄存器存放栈的地址,栈的入栈出栈操作也十分简单,并且有专门的指令执行,所以栈的效率比较高也比较快。
而堆的操作是由 C/C++ 函数库提供的,在分配堆内存的时候需要一定的算法寻找合适大小的内存。并且获取堆的内容需要两次访问,第一次访问指针,第二次根据指针保存的地址访问内存,因此堆比较慢。
3️⃣ 静态存储区
静态存储区 = 全局数据区 + 常量区
全局数据区:全局变量 + 静态变量,该区域会被自动初始化
常量区:存放常量,不允许修改
静态存储区内的变量在程序编译阶段已经分配好内存空间并初始化。这块内存在程序的整个运行期间都存在,它主要存放 static 静态变量、全局变量和 const 常量。
区分:
static
修饰「局部变量」在静态存储区中;const
修饰「局部变量」则是在栈区中。
注意:
- 这里不区分初始化和未初始化的数据区,是因为静态存储区内的变量若不显示初始化,则编译器会自动以默认的方式进行初始化,即静态存储区内不存在未初始化的变量。
- 静态存储区内的常量分为常变量和字符串常量,一经初始化,不可修改。静态存储内的常变量是全局变量,与局部常变量不同,区别在于局部常变量存放于栈,实际可间接通过指针或者引用进行修改,而全局常变量存放于静态常量区则不可以间接修改。
- 字符串常量存储在静态存储区的常量区,字符串常量的名称即为它本身,属于常变量。
- 数据区的具体划分,有利于我们对于变量类型的理解。不同类型的变量存放的区域不同。后面将以实例代码说明这四种数据区中具体对应的变量。
4️⃣ 代码区
存放程序体的二进制代码,比如我们写的函数都是在代码区。
1 | int a = 0;//静态全局变量区 |
7a. C++ 中 class 的大小由哪些因素决定?
在 C++ 中,类的大小由多个因素决定,主要包括:
- 普通成员变量:类中定义的非静态成员变量会直接影响类的大小。每个成员变量都会占用相应的内存空间。
- 虚函数:如果类包含虚函数,编译器会为该类添加一个虚函数表(vtable),并在每个对象中添加一个指向该表的指针(vptr),这会增加每个对象的大小。
- 继承:类的继承关系也会影响其大小。
- 单一继承:派生类会继承基类的成员变量和成员函数,但不会直接增加对象的大小。
- 多重继承:派生类继承多个基类时,可能会导致对象中包含多个基类的子对象,从而增加对象的大小。
- 虚拟继承:为了解决菱形继承问题,编译器可能会在派生类中引入虚拟基类指针,增加对象的大小。
- 内存对齐:编译器通常会对类的成员变量进行内存对齐,以提高访问效率。这可能导致类的实际大小大于成员变量总和。
- 分配内存的顺序是按照声明的顺序。
- 每个变量相对于起始位置的偏移量必须是该变量类型大小的整数倍,不是整数倍空出内存,直到偏移量是整数倍为止。
- 最后整个结构体的大小必须是里面变量类型最大值的整数倍。
⚠️ 需要注意的是,类的构造函数、析构函数、静态成员变量、静态成员函数和普通成员函数不会直接影响类的大小。
- 构造函数和析构函数:
- 构造函数和析构函数是特殊的成员函数,用于对象的初始化和销毁。
- 它们的存在不会增加类的实例大小,因为它们在对象创建和销毁时被调用,但并不占用对象的内存空间。
- 静态成员变量:
- 静态成员变量属于类本身,而不是类的实例。
- 它们在类的所有实例之间共享,只有一份存储空间(静态存储区)。
- 静态成员函数:
- 静态成员函数也属于类本身,而不是类的实例。
- 它们在类的所有实例之间共享,只有一份存储空间。
- 普通成员函数:
- 普通成员函数是类的成员,但普通成员函数的代码通常存储在程序的代码段中,而不是对象的内存中。
- 因此,普通成员函数不会影响类的实例大小。
7b. [7a 类似问题] C++ 的对象存储空间是怎么安排的?
C++ 中对象的存储取决于:
- 对象的类型(普通对象、继承对象、虚函数表等)
- 存储方式(栈、堆、静态存储区)
- 对齐方式
具体来说:
1️⃣ 普通对象
(1)非静态成员变量
- 普通对象的非静态成员变量按照 声明顺序 在内存中存储。
- 编译器会根据 CPU 架构和优化需求进行 内存对齐(alignment),可能会插入填充字节(padding)。
- 类的大小通常是 最大成员类型的对齐倍数。
示例:
1 |
|
内存布局(假设 4 字节对齐):
1 | | c (1B) | padding (3B) | i (4B) | |
(2)静态成员变量:不属于对象本身,放在静态存储区,在程序启动时分配。
2️⃣ 继承
(1)非虚继承:没有 virtual
派生类对象包括基类的成员变量,存储顺序是:
- 基类子成员
- 派生类新增成员
- 对齐填充
1 | struct Base { |
(2)虚继承:基类含有 virtual
方法
- **虚基类**存储方式不同,编译器会创建虚基类指针
vptr
以及虚基类表vtable
来管理它 - 可能会多一个指向虚基类表的指针,因此对象的大小会变大
1 | struct Base { |
3️⃣ 多重继承
- 非虚多重继承:派生类按继承顺序依次存储多个基类的成员变量。
- 虚多重继承:对象中会存储多个虚表指针,可能引入 虚基类偏移表。
1 | struct A { |
4️⃣ 对象存储方式
- 栈上对象:普通局部对象,生命周期受到作用域控制
- 堆上对象:使用
new
关键词分配的对象存储在堆区,需要手动delete
- 静态存储区:
static
变量存储在静态存储区
5️⃣ 虚函数和 vtable 虚表
- 如果类中有 虚函数,编译器会为该类生成 虚表(vtable),并在对象中存储 虚指针(vptr),指向该虚表。
- 虚表存储在静态区,而 vptr 存储在对象头部(通常是对象的第一个成员)。
- vptr 使得多态调用能够动态绑定。
⚠️ 虚指针存储在对象头部;虚表存储在静态存储区。
8. new/delete 和 malloc/free 有什么区别和联系?
更多内容(讲得很好):C++ 种内存管理之 new/delete
联系:都可以用来在堆上分配和回收空间,new
/delete
是操作符,malloc
/free
是库函数。
执行 new 实际上执行两个过程:
- 调用
malloc
分配未初始化的内存空间 - 使用对象的构造函数对空间进行初始化,并返回空间的首地址
执行 delete 实际上也有两个过程:
- 使用析构函数对对象进行析构
- 调用
free
释放指针所指向空间的内存
二者区别:new
得到的是经过初始化的空间,而 malloc
得到的是未初始化的空间,所以 new 是 new 一个类型,而 malloc 则是 malloc 一个字节长度的空间。delete
和 free
同理,delete 不仅释放空间还析构对象,delete 一个类型,free 一个字节长度的空间。
对象的自动删除
通过之前的分析我们知道,new
关键字创建对象并非一步完成,而是通过先分配未初始化内存和调用构造函数初始化两步实现的。那么在这个过程中如果是第一步出错,那么内存分配失败不会调用构造函数,这是没有问题的。但是如果第一步已经完成在堆中已经成功分配了内存之后,在第二步调用构造函数时异常导致创建对象失败(抛出 std::bad_alloc
),那么就应该将第一步中申请的内存释放。C++中规定,如果一个对象无法完全构造,那么这个对象就是一个无效对象,也不会调用析构函数。因此为了保证对象的完整性,当通过 new 分配的堆内存对象在构造函数执行过程中出现异常时,就会停止构造函数的执行并且自动调用对应的 delete
运算符来对已经分配好的对内存执行销毁处理,即对象的自动删除技术。
🔥 为什么有了 malloc/free 还需要 new/delete
因为对于非内部数据类型而言,光用 malloc/free 无法满足动态对象的要求。对象在创建的同时需要自动执行构造函数,对象在消亡以前要自动执行析构函数。由于 malloc/free 是库函数而不是操作符,不在编译器控制权限之内,不能够把执行的构造函数和析构函数的任务强加于 malloc/free,所以在 C++ 中需要一个能完成动态内存分配和初始化工作的运算符 new,以及一个能完成清理和释放内存工作的运算符 delete。而且在对非基本数据类型的对象使用的时候,对象创建的时候还需要执行构造函数,销毁的时候要执行析构函数。而 malloc/free 是库函数,是已经编译的代码,所以不能把构造函数和析构函数的功能强加给 malloc/free,所以 new/delete 是必不可少的。
既然 new/delete 的功能完全覆盖了 malloc/free,为什么 C++ 不把 malloc/free 淘汰出局呢?这是因为 C++ 程序经常要调用 C 函数,而 C 程序只能用 malloc/free 管理动态内存。
🔥 malloc 与 free 的实现原理(brk()
、mmap()
)
1、在标准 C 库中,提供了 malloc/free
函数分配释放内存,这两个函数底层是由 brk
、mmap
、munmap
这些系统调用实现的;
brk
是将「堆顶」指针向高地址移动,获得新的内存空间;mmap
是在进程的虚拟地址空间中(堆和栈中间,称为文件映射区域的地方)找一块空闲的虚拟内存。
这两种方式分配的都是虚拟内存,没有分配物理内存。在第一次访问已分配的虚拟地址空间的时候,发生缺页中断,操作系统负责分配物理内存,然后建立虚拟内存和物理内存之间的映射关系。
2、malloc 分配阈值
- malloc 小于 128k 的内存,使用
brk
分配内存,将「堆顶」指针往高地址推; - malloc 大于 128k 的内存,使用
mmap
分配内存,在堆和栈之间找一块空闲内存分配;
brk 分配的内存需要等到高地址内存释放以后才能释放,而 mmap 分配的内存可以单独释放。当最高地址空间的空闲内存超过 128K(可由 M_TRIM_THRESHOLD
选项调节)时,执行内存紧缩操作(trim
)。在上一个步骤 free 的时候,发现最高地址空闲内存超过 128K,于是内存紧缩。
3、空闲地址链表:malloc 是从堆里面申请内存,也就是说函数返回的指针是指向堆里面的一块内存。操作系统中有一个记录空闲内存地址的链表。当操作系统收到程序的申请时,就会遍历该链表,然后就寻找第一个空间大于所申请空间的堆结点,然后就将该结点从空闲结点链表中删除,并将该结点的空间分配给程序。
🔥 被 free 回收的内存是立即返回给操作系统吗?
更详细的内容:
不一定。被 free
的内存不一定会立刻返回给操作系统,具体行为取决于操作系统的内存管理机制以及 C 语言运行时库(如 glibc)的实现方式。
对于 「malloc 申请的内存,free 释放内存会归还给操作系统吗?」这个问题,我们可以做个总结:
- malloc 通过
brk()
方式申请的内存,free 释放内存的时候,并不会把内存归还给操作系统,而是缓存在 malloc 的内存池中,待下次使用; - malloc 通过
mmap()
方式申请的内存,free 释放内存的时候,会把内存归还给操作系统,内存得到真正的释放。
什么场景下 malloc() 会通过 brk() 分配内存?又是什么场景下通过 mmap() 分配内存?
malloc() 源码里默认定义了一个阈值:
- 如果用户分配的内存小于 128 KB,则通过 brk() 申请内存;
- 如果用户分配的内存大于 128 KB,则通过 mmap() 申请内存;
注意,不同的 glibc 版本定义的阈值也是不同的。
1. 内存释放流程
当你在 C/C++ 中使用 free(ptr)
释放一块内存时:
- 内存被标记为“空闲”,表示这块内存可以被后续的
malloc
或calloc
重用。 - 但它通常不会立即归还给操作系统,而是由内存分配器(如 glibc 的
ptmalloc
)保留在用户进程中,用于后续分配。
2. 什么时候会真正返回给操作系统?
- 如果释放的是堆顶的内存块(即堆的末端),且满足一定条件,glibc 可能会调用
brk
或mmap
对应的释放机制(如munmap
)来将这部分内存返回给操作系统。 - 使用
mmap
分配的大块内存(通常大于一定阈值,比如 128KB),在被free
时通常会直接使用munmap
归还给操作系统。
3. glibc 的行为(以 Linux 为例)
glibc 的 malloc
有一套复杂的内存池机制,常见策略:
- 小块内存来自内部的 arena,
free
后不会归还操作系统,而是缓存起来以便重用。 - 大块内存通过
mmap
分配,free
后可能会立即调用munmap
释放给系统。
4. 查看内存是否释放
可以使用工具如:
top
或htop
查看内存使用趋势valgrind
检查内存泄漏pmap
查看进程的内存映射情况mallinfo()
(旧)或malloc_info()
(新)来观察 glibc 的内存使用状况
🔥 malloc、realloc、calloc 的区别?
1️⃣ malloc
函数
1 | void* malloc(unsigned int num_size); |
2️⃣ calloc
函数:省去了人为空间计算;malloc 申请的空间的值是随机初始化的,calloc 申请的空间的值是初始化为 0 的;
1 | void* calloc(size_t n,size_t size); |
3️⃣ realloc
函数:给动态分配的空间分配额外的空间,用于扩充容量。
1 | void realloc(void *p, size_t new_size); |
9. 异常/错误处理有几种方法,为什么有些场合要禁用?
C++ 提供了多种错误处理机制,主要包括:
- 返回码:函数通过返回值指示成功或失败,调用者需要检查返回值以确定操作结果。
- 错误码:使用全局或静态变量存储错误码,调用者需要在每个步骤后检查错误码。
- 异常处理:使用
try
、catch
和throw
关键字捕获和处理异常,提供结构化的错误处理方式。
在某些场合,可能需要禁用异常处理,原因包括:
- 性能要求高的场合:异常处理可能引入性能开销,影响程序的执行效率。
- 嵌入式系统:资源有限,可能不支持异常处理。
- 编译器不支持:某些编译器可能不支持异常处理。
禁用异常处理可以通过编译器选项实现,例如在 Sun Studio 中使用 -features=no%except
来禁用异常处理。
10. C 相关的问题,什么是野指针,有哪些野指针?
野指针是指向「未初始化」或「已释放内存」的指针,使用野指针会导致未定义行为,常见野指针:
- 未初始化的指针:指针声明后未被初始化,默认值不确定,可能指向任意内存地址
- 悬垂指针:指向已释放内存的指针,释放内存后未将指针置为 NULL,导致指针仍指向已回收的内存地址
- 空指针:指针被初始化为 NULL,但在后续使用前未被赋予有效地址,导致解引用时发生错误
为避免野指针,应该在声明指针时进行初始化,并在释放内存后将指针置为 NULL。
在更多结构化的解决方案中,一种流行的避免悬垂指针的技术是使用智能指针,一个智能指针通常使用引用技术来收回对象。还有些技术包括 tombstones 方法和 locks-and-keys 方法。另一个方法是使用 Boehm 垃圾收集器,一种保守的垃圾收集器,取代 C 和 C++ 中的标准内存分配函数。此法通过禁止内存释放函数来完全消除悬垂指针引发的错误,通过收集垃圾来回收对象。
11. 你平常怎么调试代码,你能想到多少方法?
调试代码是开发过程中非常重要的一部分,尤其是当出现问题时。调试的方式有很多种,下面是我能想到的常见调试方法:
使用调试器 (Debugger)
调试器是一种强大的工具,可以让你在程序运行时暂停执行,检查变量的值、调用堆栈等信息,逐行执行代码来找出错误。常见的调试器包括:
- GDB (GNU Debugger):适用于 C/C++ 等语言,通过命令行进行调试。
- Visual Studio Debugger:适用于 Windows 上的 C++ 和 .NET 程序。
- LLDB:用于 macOS 或 Linux 的调试器。
- Xcode Debugger:适用于 macOS 和 iOS 应用的调试器。
使用调试器,你可以:
- 设置断点:暂停程序执行,以检查变量状态和函数调用。
- 逐步执行代码:逐行执行,查看每一行代码的效果。
- 检查栈信息和变量的值:实时查看变量的值、函数调用栈、内存内容等。
例如,使用 GDB 调试 C++ 代码时,可以使用以下命令:
gdb ./your_program
启动调试器。break main
在main()
函数处设置断点。run
启动程序执行。step
或next
逐步执行代码。
插入日志输出 (Logging)
在代码中添加日志输出是调试程序的常见方法。你可以在代码中插入 printf
、std::cout
或日志库(如 log4cpp
, spdlog
, glog
等)来输出变量值、函数执行状态和程序流程。
常见做法包括:
- 输出函数进入与退出的日志。
- 打印变量值、数据结构的内容。
- 打印程序的状态和执行的分支。
例如:
1 | std::cout << "Value of x: " << x << std::endl; |
优点:
- 非常直接和简单。
- 可以在生产环境中使用(例如在开发版和发布版中配置不同的日志级别)。
缺点:
- 可能会遗漏某些地方,导致调试信息不够全面。
- 需要在最终代码中删除或关闭冗余的日志输出。
单元测试 (Unit Testing)
单元测试是一种自动化的方式,可以帮助你验证代码的正确性。使用框架如 Google Test(C++)、JUnit(Java)、pytest(Python)等,可以编写测试用例,自动运行测试,并在代码发生变化时及时捕捉错误。
单元测试的优点:
- 确保代码的每个模块都按预期工作。
- 能够提前发现潜在问题,特别是在修改代码时。
缺点:
- 测试用例需要编写和维护,可能需要额外的时间。
- 需要有较好的测试覆盖率,才能检测到更多的错误。
静态分析工具 (Static Analysis)
静态分析工具可以在代码运行之前,扫描代码并检查潜在的错误、内存泄漏、资源管理问题等。例如:
- Clang Static Analyzer
- CppCheck
- SonarQube
- Coverity
静态分析工具能够检测到:
- 未初始化的变量。
- 内存泄漏。
- 潜在的并发问题。
- 错误的代码模式等。
代码审查 (Code Review)
代码审查是与团队成员或同事一起查看和讨论代码的过程。其他开发者可以帮助你发现代码中的潜在问题或逻辑错误。
代码审查的优点:
- 多人的视角能够发现更多问题。
- 通过讨论,能够提升代码质量和团队合作。
集成测试 (Integration Testing)
集成测试是测试多个组件(或模块)一起工作时的行为。在多个模块组合工作时,问题可能不是单独模块内部,而是它们之间的交互。集成测试帮助你检查模块之间的接口和数据流。
集成测试通常用来发现:
- 模块之间的兼容性问题。
- 数据格式错误。
- 不正确的模块交互等。
内存泄漏检测工具
如果你的程序存在内存泄漏问题,可以使用专门的工具来检测内存的分配和释放:
- 🔥 Valgrind:广泛用于检测内存泄漏、内存错误等问题,适用于 C/C++ 程序。
- AddressSanitizer:现代编译器(如 Clang、GCC)提供的工具,可以检测内存相关的错误,包括越界访问、内存泄漏等。
这些工具帮助你找出内存泄漏和错误的内存访问问题,并给出详细的报告。
运行时分析工具
运行时分析工具通过收集程序运行时的信息来进行调试和优化。例如:
- gprof:用于性能分析,查看程序中哪些函数占用了最多的时间。
- perf:Linux 下的性能分析工具,帮助查看程序在系统层面的性能瓶颈。
- VisualVM:Java 应用程序的性能分析工具,能够分析内存、CPU 和线程使用情况。
条件断点和日志断点
在调试过程中,有时你希望仅在满足特定条件时暂停程序。这时可以使用条件断点或日志断点:
- 条件断点:只有当某个条件成立时,调试器才会停止程序执行。
- 日志断点:调试器在不停止程序执行的情况下,记录断点信息。
回滚与分支 (Git Bisect)
如果你无法确定错误是在哪次提交中引入的,使用 Git 提供的 git bisect
命令来回滚到历史提交并逐步测试,可以帮助定位问题的来源。
通过二分查找算法,git bisect
可以帮助你快速定位到错误引入的那一行代码。
故障注入 (Fault Injection)
故障注入是故意在程序中引入故障,以测试程序在面对错误时的反应。例如,可以通过随机生成异常、模拟网络延迟或中断等方式,检查系统的健壮性和错误处理能力。
动态分析与跟踪 (Dynamic Analysis)
使用跟踪工具(如 strace
, ltrace
, dtrace
等)来实时观察程序执行过程中的系统调用和函数调用。这种方式帮助你了解程序在运行时的行为,找出性能瓶颈或其他问题。
12. 什么是 C++ 多态?
C++ 多态即使用基类指针或引用来调用子类的重写方法,从而使得同一接口表现不同的行为。
多态优势:
- 代码复用:通过基类指针或引用,可以操作不同类型的派生类对象,实现代码复用
- 扩展性:新增派生类时,不需要修改依赖于基类的代码,只需要确保新类正确重写了虚函数
- 解耦:多态允许程序更加模块化,降低类之间的耦合度
🔥 面试一定要回答「静态多态」+「动态多态」
多态一般就是指继承 + 虚函数实现的多态,对于重载来说,实际原理是编译器为函数生成符号表时的不同规则,重载只是一种语言特性,与多态无关,与面向对象无关,所以如果非要说重载算是多态的一种,那 C++ 中多态可以分为「静态多态」和「动态多态」两种:
- 静态多态:在编译时期就决定了调用哪个函数,根据参数列表来决定,主要通过函数重载和模板实现
- 动态多态:通过子类重写父类的虚函数来实现,是运行期间决定调用的函数
动态多态的实现与虚函数表(V-Table),虚函数指针(V-Ptr)相关:
- 虚函数表(V-Table):C++ 运行时使用虚函数表来实现多态,每个包含虚函数的类都有一个虚函数表,表中存储了指向类中所有虚函数的指针。
- 虚函数指针(V-Ptr):对象中包含一个指向该类虚函数表的指针。
扩展:子类是否要重写父类的虚函数?子类继承父类时,父类的纯虚函数必须重写,否则子类也是一个虚类不可实例化。定义纯虚函数是为了实现一个接口,起到一个规范的作用,规范继承这个类的程序员必须实现这个函数。
13. 什么是虚函数与虚函数指针,C++ 虚函数的实现原理?
首先说一下 C++ 中多态的表象:在基类的函数前加上 virtual
关键字,在派生类中重写该函数,运行时将会根据对象的实际类型来调用相应的函数:
- 如果对象类型是派生类,就调用派生类的函数
- 如果是基类,就调用基类的函数
虚函数 vtable
与虚函数指针 vptr
实际上,当一个类中包含虚函数 virtual
时,编译器就会为该类生成一个虚函数表 vtable
,保存该类中虚函数的地址。同样,派生类继承基类,派生类中自然一定有虚函数,所以编译器也会为派生类生成自己的虚函数表 vtable
。当我们定义一个派生类对象时,编译器检测到该类型有虚函数,就会为这个派生类对象生成一个虚函数指针 vptr
,指向该类型的虚函数表 vtable
,虚函数指针 vptr
的初始化是在构造函数中完成的。后续如果有一个基类类型的指针指向派生类,那么当调用虚函数时,就会根据所指真正对象的虚函数表指针 vptr
去寻找虚函数的地址,也就可以调用派生类的虚函数表中虚函数以此实现多态。
补充:如果基类中没有定义成 virtual
(只有继承),那么在这种情况调用的则是 Base
中的 func()
。因为如果基类和派生类中都没有虚函数 virtual
的定义,那么编译器就会认为不用留给动态多态的机会,就事先进行函数地址的绑定(早绑定 —— 静态绑定),具体过程:
- 定义了派生类对象,首先构造基类的空间,然后构造派生类的自身内容,形成一个派生类对象
- 进行类型转换时,直接截取基类的部分内存,编译器认为类型就是基类,那么函数符号表(不同于虚函数表)绑定的函数地址也就是基类中的函数地址,执行的就是基类函数
1 | // 🌟只有 virtual 存在,编译器才会认为存在「多态」 |
1 | Derived_func() |
C++ 虚函数的内存分布 & 实现原理
以上简要介绍了「虚函数」相关内容(简要介绍了原理),接下来详细阐述实现原理
更多信息:C++ 虚函数的实现基本原理
1 | class A { |
如以上代码所示,在 C++ 中定义一个对象 A,那么在内存中的分布大概是如下图这个样子。
- 首先在主函数的栈帧上有一个 A 类型的指针指向堆里面分配好的对象 A 实例。
- 对象 A 实例的头部是一个
vtable
指针,紧接着是 A 对象按照声明顺序排列的成员变量(当我们创建一个对象时,便可以通过实例对象的地址,得到该实例的虚函数表,从而获取其函数指针) vptr
指针指向的是代码段中的 A 类型的虚函数表中的第一个虚函数起始地址。- 虚函数表
vtable
的结构其实是有一个头部的,叫做vtable_prefix
,紧接着是按照声明顺序排列的虚函数。 - 注意到这里有两个虚析构函数,因为对象有两种构造方式,栈构造和堆构造,所以对应的,对象会有两种析构方式,其中堆上对象的析构和栈上对象的析构不同之处在于,栈内存的析构不需要执行
delete
函数,会自动被回收。 typeinfo
存储着 A 的类基础信息,包括父类与类名称,C++关键字typeid
返回的就是这个对象。typeinfo
也是一个类,对于没有父类的 A 来说,当前 tinfo 是class_type_info
类型的,从虚函数指针指向的 vtable 起始位置可以看出。
1️⃣ Example-1
|如果是多继承情况下,编译器如下处理虚函数表|虚函数的实现原理
- 拷贝基类的虚函数表,多继承则拷贝每个虚函数基类的虚函数表
- 多继承会存在一个基类虚函数表和派生类自身虚函数表合并共用,该基类称为派生类的主基类
- 派生类重写基类虚函数,则替换重写后的虚函数地址
- 如果有自身虚函数,则追加自身虚函数到自身的虚函数表
其中 D 对象
vptr1
指向的虚函数表合并了「某个基类虚函数表」和「派生类自身虚函数表」,vptr2
则指向另一个基类的虚函数表
2️⃣ Example-2
1 | class A{ |
class D 的虚函数表
14. 析构函数可以是虚函数吗?什么情况下析构函数必须是虚函数?
🪞镜像问题:
- 为什么需要虚析构?虚析构实现原理?
- 析构函数一般写成虚函数的原因?
析构函数可以是虚函数。将析构函数声明为 virtual
虚函数,确保在删除基类指针指向的派生类对象时,能够正确调用派生类的析构函数,避免内存泄漏。
举例来说,一个基类的指针指向一个派生类的对象,在使用完毕准备销毁时,如果基类的析构函数没有定义成 virtual
虚函数,那么编译器根据指针类型就会认为当前对象类型是基类,仅调用基类的析构函数(该对象的析构函数的函数地址早就被绑定为基类的析构函数——静态绑定 / 早绑定),派生类的自身内容将无法被析构,造成内存泄漏。如果基类的析构函数定义为虚函数,那么编译器就可以根据实际对象,执行派生类的析构函数,再执行基类的析构函数,成功释放内存。
注释助于理解
1 | // 🌟只有 virtual 存在,编译器才会认为存在「多态」 |
1 | Derived_func() // func() 没定义 virtual 则输出 Base_func() |
⚠️ C++ 默认的析构函数不是虚函数,是因为虚函数需要额外的虚函数表和虚表指针,占用额外的内存。
当类被设计为「基类」,并且可能被继承时,析构函数应当声明为虚函数。如果类不会被继承,则析构函数可以不声明为虚函数。然而,为了代码的健壮性和可维护性,通常建议将基类的析构函数声明为虚函数,即使该类当前不会被继承。
15. 构造函数为什么一般不定义为虚函数
1️⃣ 虚函数调用只需要知道“部分信息”,即只需要知道函数接口,而不需要知道对象的具体类型。但是创建对象时,是需要知道对象的完整信息,特别是需要知道创建对象的确切类型,因此构造函数不应该被定义为虚函数。
2️⃣ 从编译器实现虚函数进行多态的方式来看,虚函数调用时通过实例化之后对象的虚函数表指针 vptr
来找到虚函数地址进行调用的,如果说构造函数是虚的,那么虚函数表指针则不存在(因为虚函数指针 vptr
的初始化是在构造函数中完成的),无法找到对应的虚函数表 vtable
来调用虚函数,那么这个调用实际上也是违反了先实例化后调用的准则。
16. 构造函数的执行顺序?析构函数的执行顺序?
1️⃣ 构造函数顺序
- 基类构造函数:如果有多个基类,则构造函数调用顺序是某类在「类派生列表」中出现的顺序,而不是它们在成员初始化表中的顺序
- 成员类对象构造函数:如果有多个成员类对象,则构造函数的调用顺序是对象在类中被声明的顺序,而不是它们出现在成员初始化表中的顺序
- 派生类构造函数
类派生列表
1 | class 派生类名:类派生列表 { |
2️⃣ 析构函数顺序
- 调用派生类的析构函数
- 调用成员类对象的析构函数
- 调用基类的析构函数
17. 静态绑定和动态绑定
我们首先要知道静态类型和动态类型:
- 静态类型:在程序中被声明时所采用的类型,在编译期间确定
- 动态类型:目前所指对象的实际类型,在运行期间确定
关于静态绑定和动态绑定:
- 静态绑定,又称早绑定,绑定的是静态类型,所对应的函数或属性依赖于对象的静态类型,发生在编译期间。
- 动态绑定,又称晚绑定,绑定的是动态类型,所对应的函数或属性依赖于动态类型,发生在运行期间。
比如说,virtual
函数是动态绑定的,非虚函数是静态绑定的,缺省参数值也是静态绑定的。
⚠️ 注意,我们不应该重新定义继承而来的缺省参数,因为即使我们重定义了,也不会起到效果。因为一个基类的指针指向一个派生类对象,在派生类的对象中针对虚函数的参数缺省值进行了重定义, 但是缺省参数值是静态绑定的,静态绑定绑定的是静态类型相关的内容。
18. 纯虚函数
纯虚函数是在基类中「声明但不实现」的虚函数,其声明方式是在函数声明的结尾处添加 = 0
,类中如果至少包含一个纯虚函数,则该类称为抽象类,抽象类是不能实例化对象的。
纯虚函数的主要作用是定义接口规范,强制要求派生类必须实现这些函数,从而实现借口的统一和标准化。派生类中必须实现继承于基类的纯虚函数,否则含有纯虚函数的类又会是抽象类,无法实例化。
1 | class Shape { |
19. 深拷贝和浅拷贝的区别(举例说明深拷贝的安全性)
1️⃣ 浅拷贝:
- 当出现类的等号
=
赋值时,会调用拷贝构造函数,在未显式定义拷贝构造函数的情况下,系统会调用默认的拷贝函数 —— 即浅拷贝,它能够完成成员的复制,当数据成员中没有指针时,浅拷贝是可行的; - 但当数据成员中有指针时,如果采用简单的浅拷贝,则两个类中的两个指针指向同一个地址,当对象快要结束时,会调用两次析构函数,从而导致野指针的问题。
1 | class ShallowCopy { |
2️⃣ 深拷贝:在数据成员含有指针时,必须采用深拷贝(自定义拷贝构造函数),在拷贝构造函数中创建一个全新对象,与原对象完全独立。深拷⻉与浅拷⻉之间的区别就在于,深拷⻉会在堆内存中另外申请空间来存储数据,从而解决来野指针的问题。简而言之,当数据成员中有指针时,必需要用深拷⻉更加安全。
1 | class DeepCopy { |
20. 说一下你理解的 C++ 四种智能指针|shared_ptr 的简易实现
更多信息:C++ 智能指针、知乎 C++ 智能指针
看这两篇,取取交集
在使用 C++ 开发过程中,最容易也是最麻烦的问题便是内存泄漏。相较于 Java、Python 或者 Go 语言都拥有垃圾回收机制,在对象没有引用时就会被系统自动回收而且基本上没有指针的概念,但是 C++ 则要求程序员自己管理内存,这一方面让程序员有更大的自由度但是也会很大影响程序员的开发效率。因此 C++11 标准中新推出了 shared_ptr
、unique_ptr
和 weak_ptr
三个智能指针来帮助管理内存。
智能指针就是一个类,当超出了类的作用域时,类会自动调用析构函数,析构函数会自动释放资源,所以智能指针的作用原理就是在函数结束时自动释放内存空间,不需要手动释放。
1 | T* get(); |
常用接口:
T
是模板参数,即传入的类型get()
用来获取auto_ptr
封装在内部的指针,也就是获取原生指针operator*()
重载*
,operator->()
重载->
,operator=()
重载=
release()
将auto_ptr
封装在内部的指针置为nullptr
,但不会破坏指针所指向的内容,函数返回的是内部指针置空之前的值reset()
直接释放封装的内部指针所指向的内存,如果指定了ptr
的值,则将内部指针初始化为该值
接下来说说哪四种智能指针:
auto_ptr
为 C++98 的方案,C++11 已抛弃- C++11 引入
std::shared_ptr
std::weak_ptr
std::unique_ptr
0️⃣ auto_ptr
C++98 方案,C++11 已抛弃
1 | auto_ptr<std::string> p1(new string("string")); |
p2 剥夺了 p1 的所有权,但是当程序运行时访问 p1 将会报错,所以 auto_ptr
缺点就是存在潜在的内存崩溃问题。
1️⃣ shared_ptr 共享式智能指针
彻底理解:
shared_ptr
是有两层析构:
- shared_ptr 本身析构会使得指向的共享对象的引用数 -1,当共享对象引用数为 0 时,则调用共享对象本身的析构函数
- 这样就可以理解循环引用了:共享对象引用还是 1,未调用共享对象本身的析构函数,其中成员 shared_ptr 的析构函数也不会被调用
shared_ptr
能够自动记录共享对象的引用次数,并且在引用计数降至零时自动删除对象,从而防止内存泄漏。每个 shared_ptr
的拷贝都指向相同的内存,在最后一个 shared_ptr
析构的时候其指向的内存资源才会被释放。
shared_ptr
初始化方式:
- 构造函数
std::make_shared()
辅助函数reset()
1 | std::shared_ptr<int> p(new int(1)); |
不能将一个原始指针直接赋值给一个智能指针,如:std::shared_ptr<int> p = new int(1)
。
对于一个未初始化的智能指针,可以通过调用 reset
方法初始化,当智能指针中有值的时候,调用 reset
方法会使引用计数减 1。当需要获取原指针的时候可以通过 get
方法返回原始指针:
1 | std::shared_ptr<int> p(new int(1)); |
智能指针初始化时也可以指定删除器,当其引用计数为 0 时将自动调用删除器来释放对象,删除器可以是一个函数对象。如当使用 shared_ptr
管理动态数组时,需要指定删除器,因为 shared_ptr
默认删除器不支持数组对象:
1 | // lambda 表达式作为删除器 |
关于 shared_ptr
的注意事项:
- 不要用一个裸指针初始化多个
shared_ptr
,会出现 double_free 导致程序崩溃 - 通过
shared_from_this()
返回 this 指针,不要把 this 指针作为shared_ptr
返回出来,因为this
指针本质就是裸指针,通过 this 返回可能会导致重复析构,不能把 this 指针交给智能指针管理。
1 | class A { |
- 尽量使用
std::make_shared<T>()
,少用new
- 不要
delete
get()
返回的裸指针 - 不是
new
出来的空间要自定义删除器 - 要避免循环引用,循环引用导致内存永远不会被释放,造成内存泄漏
1 | class A; |
🌟 解释说明循环引用:
- 首先循环引用导致 shared_ptr 指向的共享对象 A 和 B 的引用计数都是 2;
- 在离开作用域后,根据栈后进先出的特点,首先
shared_ptr<B> bp
析构时只减少 B 的引用次数为 1(这里是对象 shared_ptr 析构而非对象 B 析构),由于此时对象 B 的引用次数仍为 1(减为 0 的 B 才会被释放),所以不会调用(对象 B)内部智能指针a
的析构函数来减少引用,所以也就无法减少 A 的引用次数了。 - 接着
ap
析构时减少 A 的引用次数为 1,此时 A 的引用仍为 1 不会被析构,所以无法析构其成员对象b
; - 最终导致指针永远不会析构,产生了内存泄漏(解决方案就是使用
weak_ptr
)
2️⃣ weak_ptr 弱引用智能指针
weak_ptr
是一种不控制对象生命周期的智能指针,它指向一个 shared_ptr 管理的对象,它不管理 shared_ptr
内部指针,进行该对象的内存管理的是那个强引用的 shared_ptr。
weak_ptr 只是提供了对管理对象的一个访问手段。weak_ptr 设计的目的是为配合 shared_ptr 而引入的一种智能指针来协助 shared_ptr 工作,纯粹是作为一个旁观者监视 shared_ptr
中管理的资源是否存在,它只可以从一个 shared_ptr 或另一个 weak_ptr 对象构造,它的构造和析构不会引起引用记数的增加或减少。
weak_ptr 是用来解决 shared_ptr 相互引用时的死锁问题,如果说两个 shared_ptr 相互引用,那么这两个指针的引用计数永远不可能下降为 0,也就是资源永远不会释放。它是对对象的一种弱引用,不会增加对象的引用计数,和 shared_ptr 之间可以相互转化:
- shared_ptr 可以直接赋值给它
- 它也可以通过调用 lock 函数来获得 shared_ptr
循环引用是当两个智能指针都是 shared_ptr 类型的时候,析构时两个资源引用计数会减 1,但是两者引用计数还是为 1,导致跳出函数时资源没有被释放(析构函数没有被调用),解决办法就是把其中一个改为 weak_ptr
就可以。
总之 weak_ptr 可以用来返回 this 指针和解决循环引用问题。
- 作用 1:返回 this 指针,上面介绍的
shared_from_this()
其实就是通过weak_ptr
返回的 this 指针
Q:
shared_from_this()
是如何实现的?A:使用
shared_from_this()
的类需要继承enable_shared_from_this
类,enable_shared_from_this
类中持有一个类型为weak_ptr
的成员_M_weak_this
,调用shared_from_this()
就是将内部持有的weak_ptr
转成了shared_ptr
。
1 | class enable_shared_from_this |
- 作用 2:解决循环引用问题
1 | class A { |
🔥 代码解释:尽管局部变量的析构顺序是按照后进先出的原则,但关键在于“对象的销毁时机”是由引用计数决定的,而不是直接由局部变量析构的顺序决定的:
- 局部变量析构顺序:在 main 函数中,aaptr 先创建、bbptr 后创建,因此在退出作用域时,bbptr 会先析构,随后 aaptr 析构。
- 引用计数的影响
- 创建时,aaptr 持有 A 对象,bbptr 持有 B 对象。
- A 对象内部的成员变量 bptr 又持有 B 对象的 shared_ptr,因此 B 对象的引用计数为 2。
- B 对象内部的 weak_ptr 不会影响 A 对象的引用计数。
- 析构过程
- 当 bbptr 析构时,仅仅减少了 B 对象的引用计数,从 2 变为 1,但 B 对象并没有被销毁,因为
aaptr->bptr
仍然持有它。 - 随后 aaptr 析构,导致 A 对象的引用计数从 1 变为 0,从而触发 A 的析构函数,输出 “A delete”。
- 在 A 的析构过程中,其成员变量 bptr 被析构,从而使 B 对象的引用计数从 1 减为 0。此时,B 对象的析构函数被调用,输出 “B delete”。
- 当 bbptr 析构时,仅仅减少了 B 对象的引用计数,从 2 变为 1,但 B 对象并没有被销毁,因为
3️⃣ unique_ptr 独占式智能指针(替换 auto_ptr
)
unique_ptr
是一个独占型的智能指针,它不允许其他的智能指针共享其内部的指针:
- 不允许通过赋值将一个
unique_ptr
拷贝/赋值给另外一个unique_ptr
- 但是允许通过函数返回给其他的
unique_ptr
或者通过std::move
来转移到其他的unique_ptr
,这样的话它本身就不再拥有原指针的所有权了
与 shared_ptr
相比,unique_ptr
除了独占性的特点外,还能够指向一个数组:std::unique_ptr<int []> p(new int[10]);
。
shared_ptr
与 unique_ptr
的使用需要根据场景决定,如果希望只有一个智能指针管理资源或者管理数组就使用 unique_ptr
,如果希望使用多个智能指针管理同一个资源就使用 shared_ptr
。
🔥 实现简易的 shared_ptr
1 |
|
21. shared_ptr 的实现,shared_ptr 一定不会导致内存泄漏吗?
std::shared_ptr
的实现基于引用计数,每个 shared_ptr
实例持有一个指向控制块的指针,控制块中包含引用计数和所管理对象的指针。 当 shared_ptr
的引用计数降为零时,控制块会删除所管理的对象。 然而,shared_ptr
并非在所有情况下都能防止内存泄漏。 当存在循环引用时,shared_ptr
的引用计数永远不会降为零,导致内存无法被释放,从而引发内存泄漏。
1 |
|
为了解决循环引用问题,可以使用 std::weak_ptr
,它是一种不增加引用计数的智能指针。 std::weak_ptr
用于打破循环引用,避免内存泄漏。
1 |
|
或者如果在类之间的引用是单向的(即不会形成循环引用),可以考虑使用 std::unique_ptr
。std::unique_ptr
不会引起引用计数问题,因为它是独占的,每个对象只有一个拥有者。
1 |
|
22. STL 中 vector、list、map 的底层原理实现和适用场景?
关于 STL 库中所有的结构的底层实现原理:https://zhuanlan.zhihu.com/p/542115773
顺带了解了 set、map、unordered_map、unordered_set 之间区别:
set
、map
:底层使用红黑树实现,有序,插入、查找、删除的时间复杂度为 $O(logn)$
- 优点:有序性,内部实现红黑树使得很多操作都在 $O(logn)$ 时间复杂度下完成
- 缺点:空间占用率高,需要额外保存父节点、孩子节点和红/黑性质
unordered_set
、unordered_map
:底层使用哈希表实现,无序,查找的时间复杂度为 $O(1)$
- 优点:因为内部实现了哈希表,因此其查找速度非常的快
- 缺点:哈希表的建立比较费时
1️⃣ vector 动态数组
vector
底层是动态数组,元素连续存储在堆上- 自动扩容机制:
- vector 采用几何增长策略(通常是 2 倍扩容)
- 当
size() == capacity()
时,会申请更大的内存空间,然后拷贝旧数据到新空间 - 由于 realloc 可能导致数据搬移,
push_back()
的均摊时间复杂度为 $O(1)$,但最坏情况 $O(n)$(扩容时)
- ❓所以有可能 vector 的插入操作可能导致迭代器失效:因为 vector 动态增加大小时,并不是在原空间后增加新空间,而是以原大小两倍在开辟另外一片较大空间,然后将内容拷贝过来,并释放原有空间,所以迭代器失效。
适用场景:
✅ 高效的随机访问(O(1)
)。
✅ 批量尾部插入/删除(push_back()
)。
❌ 不适合频繁插入/删除中间元素(O(n)
)。
❌ 扩容会导致数据搬移(不适合超大数据集)。
2️⃣ list 双向链表
list
底层是双向链表,每个节点存储数据和两个指针- 插入和删除操作非常高效,不影响其他元素
- 不支持随机访问,必须顺序遍历才能找到某个元素 $O(n)$
- 不会发生扩容问题,适合频繁插入/删除的场景
适用场景:
✅ 高效插入/删除(O(1)
,特别是中间位置)。
✅ 不关心随机访问,仅需遍历。
❌ 不适合频繁随机访问(O(n)
)。
❌ 额外的指针开销(内存占用比 vector
高)。
3️⃣ map 红黑树
map
底层实现是红黑树(Red-Black Tree),一种自平衡二叉搜索树- key 是有序的
- 插入、删除、查找 $O(logn)$,因为树的高度是 $O(logn)$
- 迭代遍历按照 key 顺序进行
操作 | 时间复杂度 | 说明 |
---|---|---|
插入 insert() |
$O(log n)$ | 需要维护红黑树平衡 |
删除 erase() |
$O(log n)$ | 删除节点后可能需要旋转 |
查找 find() |
$O(log n)$ | 通过 BST 进行搜索 |
适用场景:
✅ 需要有序存储的数据结构(默认按照 key 递增)。
✅ 需要高效查找、插入、删除(O(log n)
)。
❌ 不适合频繁变更 key(因为 key 作为 BST 节点的一部分)。
❌ 遍历效率比 unordered_map
低(有序存储开销大)。
23. 菱形继承会出现二义性问题,C++ 中如何解决这个问题?
❓镜像问题:一个派生类继承两个父类,这两个父类同时有一个共同基类,如果你去调用两个父类的基类对象函数,会有问题吗?怎么解决?
注:在 Java 中,由于 Java 不支持多重继承,所以菱形继承问题也不存在。 Java 使用接口来替代多重继承,接口只定义了一些抽象的方法,而没有具体的实现。
这是 C++ 多重继承造成的菱形继承问题,如果一个派生类继承了两个拥有相同基类的父类,那么基类的成员会被继承两次,这会导致 “二义性问题” 和 “冗余存储”。
❌ 编译错误!
1 |
|
1️⃣ 解决方案一:使用作用域解析符
缺点:Derived
仍然包含 两个 Base
实例,数据冗余,而且每次调用 show()
需要手动指定作用域,不优雅。
1 | int main() { |
2️⃣ 使用虚继承|最佳方案 ✅
虚继承是为了让某个类做出声明,承诺愿意共享它的基类,这个被共享的基类就是虚基类
多继承除了造成命名冲突,还有数据冗余等问题,为了解决这些问题,C++ 引进了「虚继承」
这样能够保证 Derived
只含有一个唯一的 Base 实例。
1 |
|
不使用 virtual
时
Derived
会有两个Base
对象,导致二义性问题。- 内存浪费(两个
Base
子对象的冗余)。
使用 virtual
继承
Parent1
和Parent2
不会各自包含Base
的副本,而是共享同一个Base
实例。Derived
只会有一个Base
实例,所以调用show()
时不会有二义性。
🔥虚继承是为了让某个类做出声明,承诺愿意共享它的基类,这个被共享的基类就是虚基类!
使用虚继承解决菱形继承中的命名冲突问题
🔥 虚继承在 C++ 标准库中的实际应用
再看个虚继承的例子,彻底明白虚继承:
1 |
|
⁉️将 Base0 类作为它的直接派生类 Base1 和 Base2 的虚基类,即 Base1 虚继承 Base0,Base2 虚继承 Base0。之后 Derived 再继承 Base1 和 Base2,在 Derived 对象里面就不会存在 Base0 类的双份的成员。
Derived 对象包含着从 Base1 继承的成员和从 Base2 继承的成员,但是从 Base1 继承的 Base0 成员实际上这个地方放了一个指针,这个指针指向真正的 Base0 成员,Base2 的也是。所以实质上从最远的基类继承过来的成员,在最远派生类中只有一份。
24. 动态编译 vs 静态编译,动态链接 vs 静态链接?
在编译和链接过程中,我们可以分为以下几个阶段:
- 编译(Compilation):将源代码
.cpp
转换为目标文件.o
。 - 链接(Linking):将多个目标文件和库组合成一个可执行文件。
(1) 静态编译 vs 动态编译
- 静态编译(Static Compilation):所有代码都在 编译时 确定,并编译成完整的 可执行文件。
- 动态编译(Dynamic Compilation):代码可以在 运行时动态生成或加载,例如 JIT(Just-In-Time)编译。
(2) 静态链接 vs 动态链接
- 静态链接(Static Linking):
- 编译时 将所有 库的代码 直接复制到可执行文件中。
- 生成的可执行文件 较大,但不依赖外部动态库。
- 动态链接(Dynamic Linking):
- 运行时按需加载动态库(
.so
/.dll
)。 - 可执行文件 更小,可以更新动态库而无需重新编译整个程序。
- 运行时按需加载动态库(
25. 拷贝构造函数与 operator=()
的区别?
在 C++ 中,拷贝构造函数 和 赋值运算符 (operator=
) 主要区别在于 调用时机和行为。
(1) 拷贝构造函数
- 作用:用于创建新对象时,用已有对象进行初始化。
- 调用时机:
- 用已有对象初始化新对象
- 函数按值传递参数
- 函数返回对象(优化前的 NRVO)
示例:
1 | class MyClass { |
输出:
1 | CopyEdit |
(2) 赋值运算符 operator=
- 作用:用于 已有对象之间赋值,即一个对象的内容 被另一个对象替换。
- 调用时机:
- 两个已存在对象进行赋值时
- 🔥
a = b;
而不是MyClass a = b;
示例:
1 | class MyClass { |
输出:
1 | Assignment Operator |
(3) 主要区别
对比项 | 拷贝构造函数 | 赋值运算符 (operator= ) |
---|---|---|
作用 | 初始化新对象 | 赋值给已有对象 |
调用时机 | MyClass a = b; |
a = b; |
是否创建新对象 | ✅ 是 | ❌ 否 |
默认行为 | 成员逐一拷贝 | 成员逐一赋值 |
(4) 特殊情况
避免自赋值
1 | if (this == &other) return *this; |
支持链式赋值
1 | MyClass& operator=(const MyClass& other) { |
26. 右值引用的主要用途?
等价于问题:什么情况下会用到右值引用。
右值引用是 C++11 引入的新特性,用于实现移动语义和完美转发:
1️⃣ 实现移动语义
在传统 C++ 中,对象的赋值和传递通常涉及深拷贝,这会带来性能开销,通过右值引用,可以触发移动构造函数将资源所有权从一个对象转移到另一个对象(将资源从临时对象移动到新对象),无需深拷贝,避免了不必要的复制和销毁操作。
当一个临时对象或不再使用的资源,需要被高效地“移动”而不是拷贝时,就用到右值引用
1 | std::vector<int> v1 = {1,2,3}; |
2️⃣ 完美转发
用于函数模板的完美转发,将参数以原始的形式传递给下一个函数,避免了不必要的复制和类型转换。
模板中利用万能引用(forwarding reference)配合
std::forward
实现任意类型参数的原始性质传递
1 | template<typename T> |
针对「完美转发」,请看如下例子
假设我们有两个重载的函数 process
,一个接收左值引用,另一个接收右值引用:
1 | void process(int& i) { |
现在,我们希望编写一个模板函数 forwarding
,它能够将传入的参数完美地转发给 process
,即保持参数的左值或右值属性不变。
- 不使用完美转发的情况:如果我们直接在模板函数中调用
process(param)
,无论传入的是左值还是右值,param
在函数内部都是一个左值,这会导致总是调用接收左值引用的process
函数:
1 | template <typename T> |
- 使用完美转发的情况:为了实现完美转发,我们需要:
- 使用万能引用:在模板参数中使用
T&&
,使得函数能够同时接收左值和右值。 - 为了解决这个问题,引入了
std::forward
, 将模板函数改成如下形式就可以了,forward
被称为完美转发,根据参数的类型(左值或右值)进行条件转发,保持其原有的值类别。语义上:数据是左值就转发成左值,右值就转发成右值,哪怕在万能引用中也是如此
。
- 使用万能引用:在模板参数中使用
实现如下:
1 |
|
测试代码:
1 | int main() { |
输出结果:
1 | 左值引用处理: 10 |
补充:左值引用(&)与右值引用(&&)
在 C++11 中提出了右值引用,作用是为了和左值引用区分开来,其作用是: 右值引用限制了其只能接收右值,可以利用这个特性从而提供重载
,这是右值引用有且唯一的特性,限制了接收参数必为右值, 这点常用在 move construct 中,告诉别人这是一个即将消失的对象的引用,可以瓜分我的对象东西,除此之外,右值引用就没有别的特性了。
1 | class Base{ |
然后,一个右值引用变量在使用上就变成了左值,已经不再携带其是右引用这样的信息,只是一个左值,这就是引用在c++中特殊而且复杂的一点,引用在 c++ 中是一个特别的类型,因为它的值类型和变量类型不一样, 左值/右值引用变量的值类型都是左值, 而不是左值引用或者右值引用
。
1 | int val = 0; |
🔥 补充:万能引用(T&&)
模板中的 T&&
不同于普通的右值引用,而是万能引用,其既能接收左值又能接收右值。
1 | template<typename T> |
这种特性常用在容器元素的增加上,利用传参是左值还是右值进而在生成元素的时候调用 copy construct 还是 move construct,比如说 vector
的 emplace_back
。
所以为什么需要
std::forwad
?
模板的万能引用只是提供了能够接收同时接收左值引用和右值引用的能力,但是引用类型的唯一作用就是限制了接收的类型,后续使用中都退化成了左值,我们希望能够在传递过程中保持它的左值或者右值的属性, 如果不使用 forward
,直接按照下面的方式写就会导致问题。
1 | template <typename T> |
所以为了解决这个问题引入了 std::forward
,将模板函数改成如下形式,即可实现完美转发:
1 | template <typename T> |
27. C++ 中有哪些锁?
- 从种类上分:普通锁、读写锁、递归锁
- 从实现上分:互斥锁、自旋锁、信号量、条件变量
互斥锁(Mutex)
🌟互斥锁是在抢锁失败的情况下主动放弃 CPU 进入睡眠状态直到锁的状态改变时再唤醒,而操作系统负责线程调度,为了实现锁的状态发生改变时唤醒阻塞的线程或者进程,需要把锁交给操作系统管理,所以互斥锁在加锁操作时涉及上下文的切换。互斥锁实际的效率还是可以让人接受的,加锁的时间大概 100ns 左右,而实际上互斥锁的一种可能的实现是先自旋一段时间,当自旋的时间超过阀值之后再将线程投入睡眠中,因此在并发运算中使用互斥锁(每次占用锁的时间很短)的效果可能不亚于使用自旋锁。
- 互斥锁(Mutex):用于保护共享资源,确保任一时刻只有一个线程访问资源。
- 信号量(Semaphore):一种特殊的计数器,可以同时允许多个线程访问有限的共享资源。
互斥锁相当于信号量初值为 1 的特殊情况;信号量允许多个线程并发访问资源(初值 > 1)。
1 | std::mutex mtx; |
应用场景:
- 保护关键资源(如共享变量)
- 控制资源的访问量
条件锁/条件变量(Condition Variable)
🌟互斥锁一个明显的缺点是他只有两种状态:锁定和非锁定。而条件变量通过允许线程阻塞和等待另一个线程发送信号的方法弥补了互斥锁的不足,他常和互斥锁一起使用,以免出现竞态条件。当条件不满足时,线程往往解开相应的互斥锁并阻塞线程然后等待条件发生变化。一旦其他的某个线程改变了条件变量,他将通知相应的条件变量唤醒一个或多个正被此条件变量阻塞的线程。总的来说互斥锁是线程间互斥的机制,条件变量则是同步机制。
条件变量用于线程间通信,当某个条件满足后再唤醒等待线程。
1 |
|
应用场景:
- 生产者-消费者模型
- 线程等待某条件满足才能执行
自旋锁(Spin Lock)
🌟如果线程无法取得锁,线程不会立刻放弃 CPU 时间片,而是一直循环尝试获取锁,直到获取为止。如果别的线程长时期占有锁那么自旋就是在浪费 CPU 做无用功,但是自旋锁一般应用于加锁时间很短的场景,这个时候效率比较高。
线程在等待资源时不会挂起或睡眠,而是不断循环检测锁状态(忙等待)
1 |
|
应用场景:
- 临界区非常短小
- 多核 CPU、短暂等待资源的情况
读写锁(Read-Write Lock)
允许多个线程同时进行读操作,但写操作必须独占访问。
特点:
- 读锁共享:多个读线程并发执行
- 写锁独占:写线程执行时不能有其他读、写线程存在
1 | // C++17 的 shared_mutex |
应用场景:大量读、少量写的场景(如配置文件读取,缓存数据等)
递归锁(Recursive Mutex)
同一线程可以多次获取同一个锁,但必须释放相同次数后才完全解锁。
特点:
- 避免了同一线程递归调用中因反复加锁而引起的死锁问题
- 相比普通锁,多了一些额外开销
1 |
|
应用场景:函数递归调用或函数间的相互调用都可能再次尝试获取同一锁
28. 如何用 C++ 实现一个读写锁
1 |
|
使用实例:
1 |
|
以上实现倾向于写优先(有写操作等待时,不允许新的读操作)。
可以通过修改逻辑实现读优先或公平性策略,例如:
- 去除
writers_waiting_ == 0
的约束实现读优先。 - 更复杂的公平策略则需要额外的数据结构管理等待顺序。
- 去除
实际应用中,推荐使用现有的成熟实现,例如:
- C++17 起的标准库提供的
std::shared_mutex
(标准的读写锁实现):
- C++17 起的标准库提供的
1 |
|
29. 引用和指针的区别,是否能加 const
,作用是什么?
指针:存储变量的内存地址,可以为空(nullptr
),需要通过解引用操作符*
访问指针指向的值。指针可以在运行时重新指向不同的对象。指针可以有多级。
引用:是变量的别名,必须在初始化时绑定到一个有效的对象,且绑定后无法更改。引用不能为空,始终指向初始化时绑定的对象。引用只有一级。
const
修饰:
- 指针:
const
可以修饰指针本身或指针指向的对象。- 指向常量的指针:
const int* ptr
/int const* ptr
表示指针指向的值是常量,不能通过该指针修改值,但可以改变指针本身的指向。 - 常量指针:
int* const ptr
表示指针本身是常量,不能改变指针的指向,但可以通过指针修改指向的值。 - 指向常量的常量指针:
const int* const ptr
表示指针本身和指针指向的值都是常量,既不能修改指针的指向,也不能修改指向的值。
- 指向常量的指针:
- 引用:引用本身不能是常量,但可以引用一个常量对象。
- 指向常量的引用:
const int& ref
表示引用绑定到一个常量值,不能通过该引用修改值。常量引用常用于函数参数,允许函数接受常量或非常量实参而不进行拷贝。
- 指向常量的引用:
30. 哈希桶满了怎么办?
哈希表(如 unordered_map
)在插入元素后,如果负载因子(load_factor
,即元素个数/桶数量)超过阈值(通常是1.0左右),将触发扩容(rehash):
- 重新分配更多的 bucket(一般是原来容量的2倍或更多)。
- 重新计算元素位置(rehash),将原有元素重新插入新的 bucket 中。
- 扩容时性能开销较大 $O(n)$。
因此,为了减少扩容次数,可以提前使用 reserve
或 rehash
提高效率。
1 | unordered_map<int, int> umap; |
31. AVL vs. 红黑树
AVL 树(严格平衡二叉树):
- 左右子树高度差绝对不能超过1。
- 插入删除频繁时,旋转调整成本较高(严格的平衡限制)。
- 查询效率略优于红黑树(更平衡),但插入删除的开销稍高。
- 适用于对查询操作要求极高,但修改频率较低的场景。
红黑树(弱平衡二叉树):
- 平衡规则相对宽松,允许一定的高度差异。
- 插入删除操作旋转调整较少,综合效率更高。
- 广泛应用于 C++ 中的 STL
map
、set
等数据结构中。 - 更适用于插入删除较频繁的场景。
如果场景读多写少,要求非常严格的平衡,AVL 树适合。
如果场景写操作频繁,对读写整体性能要求更均衡,红黑树更合适。
32. move()
底层原理
std::move()
的底层原理实际上非常简单,它本身并不真正执行移动,而是一个类型转换工具,用来将左值(lvalue)强制转换为右值引用(rvalue reference),从而允许移动语义发生。
一、源码分析(典型实现)
在C++标准库中,std::move()
一般可实现为如下模板函数:
1 | template <typename T> |
上述代码可以解析为:
T&& arg
:这是一个万能引用(forwarding reference),能够绑定到左值或右值。remove_reference_t<T>
:移除模板参数T
可能带有的引用限定符,保证返回的确实是一个右值引用类型。static_cast<remove_reference_t<T>&&>
:进行强制类型转换,将传入参数从左值转换为右值引用。
二、原理分析
std::move()
本身没有发生移动动作,它只是一个类型转换工具:
- 转换前:变量(对象)本身是左值,只能调用拷贝构造函数或拷贝赋值。
- 转换后:变量变为右值引用,具备调用移动构造函数或移动赋值的资格。
本质是告诉编译器:“这里的对象我不再需要了,可以放心进行资源的移动操作。”
例如:
1 | std::string str1 = "Hello"; |
三、实际的“移动”如何发生?
实际的移动(资源转移)是通过被调用对象的移动构造函数或移动赋值运算符实现的,而不是通过std::move()
实现:
例如,std::string
的移动构造函数的伪代码:
1 | // 移动构造函数示意 |
std::move()
提供右值引用,而真正资源转移的逻辑,由类的移动构造或移动赋值完成。
四、注意事项
std::move()
不会清空对象:- 调用
std::move()
后的对象处于有效但未指定状态(valid but unspecified state),通常对象变为空或默认状态。 - 你可以继续赋值或析构,但不应该继续访问对象原先的资源。
- 调用
- 移动语义要求类本身支持移动构造或移动赋值:
- 若类本身未定义移动构造或移动赋值,调用
std::move()
仍然可能降级成拷贝。
- 若类本身未定义移动构造或移动赋值,调用
问题 | 结论 |
---|---|
std::move() 本质是什么? |
类型转换函数,从左值转为右值引用 |
真正的移动操作在哪里发生? | 类的移动构造函数或移动赋值运算符 |
调用后原对象的状态? | 有效但未指定 |
std::move()
本身几乎没有开销,它只是一个编译期的类型转换工具,真正的开销和行为由类型本身的移动构造和赋值函数决定。
33. 可执行文件加载到内存里,其内存布局是怎样的?
当可执行文件(如Linux ELF格式)加载到内存中运行时,其典型内存布局为:
从低地址到高地址顺序:
内存段 | 功能说明 |
---|---|
代码段(text segment) | 存放程序的机器指令(只读、可执行) |
数据段(data segment) | 已初始化的全局变量和静态变量 |
BSS段(bss segment) | 未初始化或初值为零的全局变量和静态变量 |
堆(Heap) | 动态分配的内存(由低地址向高地址增长) |
↕️ | (动态增长空间) |
栈(Stack) | 函数调用栈帧(由高地址向低地址增长) |
- 代码段:函数指令
- 数据段:全局或静态变量(初值不为0)
- BSS段:全局或静态变量(初值为0或未初始化)
- 堆段:动态内存(malloc/new)
- 栈段:函数调用的局部变量、调用返回地址、临时变量等
- 文件映射段:包括动态库、共享内存等,从低地址开始向上增长(跟硬件和内核版本有关)
34. 宏定义与函数的区别?
- 宏在预处理阶段完成替换,之后被替换的文本参与编译,相当于直接插入了代码,运行时不存在函数调用,执行起来更快;函数调用在运行时需要跳转到具体调用函数。
- 宏定义属于在结构中插入代码,没有返回值;函数调用具有返回值。
- 宏定义参数没有类型,不进行类型检查;函数参数具有类型,需要检查类型。
35. 宏定义 define
与 typedef
的区别?
- 宏主要用于定义常量及书写复杂的内容;typedef 主要用于定义类型别名。
- 宏替换发生在编译阶段之前(预处理阶段),属于文本插入替换;typedef 是编译的一部分。
- 宏不检查类型;typedef 会检查数据类型。
- 注意对指针的操作,
typedef char * p_char
和#define p_char char *
区别巨大。
36. 变量声明与定义的区别?
- 声明仅仅是把变量的声明的位置及类型提供给编译器,并不分配内存空间
- 定义要在定义的地方为其分配存储空间
相同变量可以在多处声明(外部变量 extern
),但只能在一处定义。
37. strlen 和 sizeof 的区别?
- sizeof 参数可以是任何数据的类型或者数据(sizeof 参数不退化)
- strlen 参数只能是字符指针且结尾是’\0’的字符串
1 | int main() { |
38. final 和 override
override
:指定了子类的这个虚函数是重写的父类的,如果你名字不小心打错了的话,编译器是不会编译通过的final
:当某个类不希望被继承,或者某个虚函数不希望被重写,那么可以在类名和虚函数后添加final
关键字,添加 final 关键字后被继承或重写,编译器会报错
1 | class Base { |
39. C 与 C++ 的类型安全
类型安全很大程度上可以等价于内存安全,类型安全的代码不会试图访问自己没被授权的内存区域。“类型安全”常被用来形容编程语言,其根据在于该门编程语言是否提供保障类型安全的机制;有的时候也用“类型安全”形容某个程序,判别的标准在于该程序是否隐含类型错误。
类型安全的编程语言与类型安全的程序之间,没有必然联系。好的程序员可以使用类型不那么安全的语言写出类型相当安全的程序,相反的,差一点儿的程序员可能使用类型相当安全的语言写出类型不太安全的程序。绝对类型安全的编程语言暂时还没有。
C 的类型安全
C 只在局部上下文中表现出类型安全,比如试图从一种结构体的指针转换成另一种结构体的指针时,编译器将会报告错误,除非使用显式类型转换。然而,C 中相当多的操作是不安全的。以下是两个十分常见的例子:
1️⃣ printf 格式输出:下述代码中,使用 %d
控制整型数字的输出,没有问题,但是改成 %f
时,明显输出错误,再改成 %s
时,运行直接报 segmentation fault 错误
1 |
|
2️⃣ malloc 函数返回值:malloc
是 C 中进行内存分配的函数,它的返回类型是 void*
即空类型指针,常常有这样的用法 char* pStr = (char*)malloc(100 * sizeof(char))
,这里明显做了显式的类型转换。类型匹配尚且没有问题,但是一旦出现 int* pInt = (int*)malloc(100 * sizeof(char))
就很可能带来一些问题,而这样的转换 C 并不会提示错误。
C++ 类型安全
如果 C++ 使用得当,它将远比 C 更有类型安全性。相比于 C 语言,C++ 提供了一些新的机制保障类型安全:
- 操作符 new 返回的指针类型严格与对象匹配,而不是
void*
- C 中很多以
void*
为参数的函数可以改写为 C++ 模板函数,而模板是支持类型检查的 - 引入
const
关键字代替#define constants
,它是有类型、有作用域的,而#define constants
只是简单的文本替换 - 一些
#define
宏可被改写为inline
函数,结合函数的重载,可在类型安全的前提下支持多种类型,当然改写为模板也能保证类型安全 - C++ 提供了
dynamic_cast
关键字,使得转换过程更加安全,因为dynamic_cast
比static_cast
涉及更多具体的类型检查
40. 内联函数 inline
和宏定义 define
的区别?
- 在使用时,宏只做简单字符串替换(预处理,即编译前);而内联函数可以进行参数类型检查(编译时),且具有返回值
- 内联函数在编译时直接将函数代码嵌入到目标代码中,省去函数调用的开销来提高执行效率,并且进行参数类型检查,具有返回值,可以实现重载
- 宏定义时要注意书写(参数要括起来)否则容易出现歧义,内联函数不会产生歧义
- 内联函数有类型检测、语法判断等功能,而宏没有
内联函数适用场景:
- 使用宏定义的地方都可以使用 inline 函数
- 作为类成员接口函数来读写类的私有成员或者保护成员,会提高效率
41. 什么是大小端存储,以及如何用代码判断大小端?
大端存储:字数据的高字节存储在低地址中
小端存储:字数据的低字节存储在低地址中
例如:32bit 的数字 0x12345678
所以在 Socket 编程中,往往需要将操作系统所用的小端存储的 IP 地址转换为大端存储,这样才能进行网络传输
小端模式中的存储方式为
大端模式中的存储方式为
了解了大小端存储的方式,如何在代码中进行判断呢?
1 |
|
42. C++ 中有几种类型的 new
?
(1) plain new
言下之意就是普通的new,就是我们常用的new,在C++中定义如下:
1 | void* operator new(std::size_t) throw(std::bad_alloc); |
因此 plain new 在空间分配失败的情况下,抛出异常 std::bad_alloc 而不是返回 NULL,因此通过判断返回值是否为 NULL 是徒劳的。
(2) nothrow new
nothrow new 在空间分配失败的情况下是不抛出异常,而是返回 NULL,定义如下:
1 | void * operator new(std::size_t, const std::nothrow_t&) throw(); |
(3) placement new
字节校招问题:placement new 是什么?
- 一般来说,使用 new 申请空间时,是从系统的“堆”中分配空间。申请所得的空间的位置是根据当时的内存的实际使用情况决定的。但是,在某些特殊情况下,可能需要在已分配的特定内存创建对象,这就是所谓的 “定位放置 new” (
placement new
)操作。 定位放置 new 操作的语法形式不同于普通的 new 操作。例如,一般都用如下语句A* p = new A;
申请空间,而 placement new 操作则使用如下语句A* p = new (ptr)A;
申请空间,其中ptr
就是程序员指定的内存首地址。- 用定位放置 new 操作,既可以在栈(stack)上生成对象,也可以在堆(heap)上生成对象,如本例就是在栈上生成一个对象。
- 优势:复用已有内存空间;
- 场景题:如果有这样一个场景,我们需要大量的申请一块类似的内存空间,然后又释放掉,比如在一个 Server 中对于客户端的请求,每个客户端的每一次上行数据我们都需要为此申请一块内存,当我们处理完请求给客户端下行回复时释放掉该内存,表面上看者符合 C++ 的内存管理要求,没有什么错误,但是仔细想想很不合理,为什么我们每个请求都要重新申请一块内存呢,要知道每一次内存的申请,系统都要在内存中找到一块合适大小的连续的内存空间,这个过程是很慢的(相对而言),极端情况下,如果当前系统中有大量的内存碎片,并且我们申请的空间很大,甚至有可能失败。为什么我们不能共用一块我们事先准备好的内存呢?可以的,我们可以使用
placement new
来构造对象,那么就会在我们指定的内存空间中构造对象。
这种 new 允许在一块已经分配成功的内存上重新构造对象或对象数组。placement new 不用担心内存分配失败,因为它根本不分配内存,它做的唯一一件事情就是调用对象的构造函数。定义如下:
1 | void* operator new(size_t, void*); |
使用 placement new
需要注意两点:
- palcement new 的主要用途就是反复使用一块较大的动态分配的内存来构造不同类型的对象或者他们的数组
- placement new 构造起来的对象数组,要显式的调用他们的析构函数来销毁(析构函数并不释放对象的内存),千万不要使用
delete
,这是因为 placement new 构造起来的对象或数组大小并不一定等于原来分配的内存大小,使用 delete 会造成内存泄漏或者之后释放内存时出现运行时错误
1 |
|
43. C++ 11 新特性有哪些?
- 自动类型推导(
auto
关键字):编译器可根据变量初始化表达式自动推导其类型,简化代码编写。 decltype
关键字:用于获取表达式的类型,常与auto
结合使用,以推导复杂类型。- 右值引用和移动语义、
move
函数:通过右值引用(&&
)支持移动构造和移动赋值,提高资源管理和程序性能。 - 初始化列表:引入统一的列表初始化语法,允许使用花括号对变量进行初始化,增强初始化的灵活性和可读性。
nullptr
关键字:引入新的空指针常量,替代原有的NULL
,提高类型安全性。- 强类型枚举(
enum class
):提供作用域限定的枚举类型,避免与其他标识符冲突,并增强类型安全性。 constexpr
关键字:允许在编译期计算常量表达式,提高程序效率。- Lambda 表达式:支持匿名函数,方便定义内联的回调或操作,简化代码结构。
- 范围
for
循环:引入基于范围的for
循环,简化对容器或数组的遍历操作。 - 智能指针:新增
std::unique_ptr
和改进的std::shared_ptr
,提供安全的资源管理机制,减少内存泄漏风险。 - 线程支持库:标准库中加入多线程支持,包括线程管理、互斥量、条件变量等,方便进行并发编程。
std::tuple
:提供固定大小的多元组,允许存储多个不同类型的值,增强数据结构的表达能力。- 正则表达式库:标准库新增正则表达式支持,方便进行字符串匹配和处理。
std::array
:提供固定大小的数组封装,结合了数组的性能和容器的功能性。std::unordered_map
和std::unordered_set
:新增无序关联容器,基于哈希表实现,提供平均常数时间的查找和插入性能。std::chrono
时间库:引入时间处理库,提供时钟、时间点、时间间隔等功能,方便进行时间相关的操作。static_assert
:在编译期进行断言检查,确保代码满足特定条件,提高代码的可靠性。std::function
和std::bind
:提供通用的函数包装器和绑定器,支持函数对象、成员函数和自由函数的统一调用。- 用户定义字面量:允许为标准类型和自定义类型定义字面量后缀,增强代码的可读性和表达能力。
alignas
和alignof
关键字:提供对齐控制和查询功能,确保数据在内存中的对齐方式符合特定要求。explicit
关键字:体现显示转换和隐式转换上的概念要求std::atomic<T>
是 C++11 引入的原子类型,用于在多线程中安全地读写变量- 还有虚函数
override
、容器非成员函数swap
、新的bitset
位运算…
44. C++ class 与 C struct 的区别?
C 语言不支持继承和多态
1️⃣ 默认访问权限不同
- C
struct
默认权限为 public - C++
class
默认权限为 private - C++
struct
默认权限 public
2️⃣ 成员函数
- C++ 中的
struct
和class
都可包含成员函数 - C 中的
struct
只能包含数据,不能包含成员函数
C++ 的 class
与 struct
都支持模板、虚函数、多态、构造函数、析构函数、重载操作符等高级特性,这些都是 C 中 struct
不具备的功能。
以上谈论的是 C struct 和 C++ class 的区别,接下来聊一聊 C++ struct 和 C++ class 的区别。
在 C++ 中,class
和 struct
的功能几乎是等价的(除了默认访问权限不同),继承时:
- struct 的继承默认是 public 继承
- class 的继承默认是 private 继承
通常情况下,如果类主要用于表示数据结构,不需要封装和访问控制,且所有成员均为 public
,则常用 struct
;如果强调封装、访问控制,需要私有或受保护成员时,则倾向于用 class
。
45. 怎么优化系统性能
- 合理使用缓存机制,如内存缓存、Redis 等
- 利用多线程或多进程技术,让更多的处理器核心参与计算,提升吞吐量
- 选择高效的算法和数据结构可以显著提升系统性能
- 编写高质量的代码,避免冗余计算,减少函数调用和内存分配,合理使用同步和异步操作
- 采用集群等高可用架构,避免单点故障,确保系统在高负载下仍能稳定运行
- 负载均衡,通过将请求分配到多台服务器上,避免单一服务器的性能瓶颈
- 使用消息队列实现高并发下的异步处理,削峰填谷,缓解系统压力
perf
工具查看系统性能瓶颈- 开启编译优化
-O2
、-O3
以下展开介绍几个主要的优化点。
内存管理优化
减少内存分配与释放次数: 频繁的堆内存分配和释放会严重拖慢程序,甚至导致内存碎片。应尽量重用对象、使用内存池等技术来降低分配开销。例如,在 C++ 中可以实现对象池,预先分配一定数量的对象,在需要时复用它们而不是每次 new
和 delete
。对于生命周期较短且数量巨大的对象,尽可能分配在栈上而非堆上,因为栈上的分配/回收开销远小于堆(注意栈有大小限制,过大的对象还是要放在堆上)。在 Java/Python 等有垃圾回收的语言中,无法手动管理内存,但可以通过减少不必要的临时对象创建来减轻 GC 压力。
避免不必要的数据拷贝:数据拷贝不仅耗费 CPU 时间,还增加内存占用。在C/C++中,尽量通过指针、引用传递大对象,或使用移动语义(std::move
)来避免昂贵的深拷贝。例如,将函数参数改为 const std::vector<T>&
引用而不是传值,可以省去一遍拷贝的成本。
提高内存访问局部性: 尽量使用连续内存的数据结构,有利于 CPU 缓存命中率。例如,相比链表,数组或动态数组(如 std::vector
)在遍历时连续访问内存,对缓存更友好。访问内存时,如果数据分散,CPU缓存无法有效预取,性能会下降。因此,应尽量使常用的数据在内存中连续存放。对于需要处理大批量数据的场景,可以考虑将“数组的结构”转变为“结构的数组”以提高向量化和缓存性能。这种优化在需要对大量对象的某个字段进行批量操作时特别有效,因为连续的内存布局可以充分利用 SIMD 指令和缓存行。
控制内存使用与回收: 注意避免内存泄漏和不必要的内存占用。未释放的内存不仅浪费资源,还可能导致系统频繁进行垃圾回收或交换,从而严重影响性能。应使用恰当的数据结构来节省内存,例如在需要存储大量布尔值时使用位图/位集而不是布尔数组。
I/O 优化
尽量减少 I/O 调用次数: 外部I/O(磁盘读写、网络通信)往往比内存操作慢几个数量级。优化I/O的一个基本原则是减少系统调用频次。例如,与其逐字节写入文件,不如积累一定数据后一次写入(批处理);读文件时尽量使用批量读取或流式读取来降低调用开销。将零散的小I/O操作合并为较少的几次大操作,可以大幅降低每次调用的固定成本,提高总体吞吐量。
使用缓冲和缓存:
- 缓冲是在内存中暂存数据,凑够一定量再进行 I/O
- 缓存则是将经常访问的数据暂存内存,以避免重复从慢速存储获取
异步和并行 I/O: 传统同步I/O会阻塞执行线程,等待操作完成。通过异步 I/O,程序可以在等待I/O的同时去处理其他任务,从而提高整体效率和响应性。另外,对于磁盘 I/O 密集型任务,合理利用操作系统的内存映射文件(mmap)也能提升效率,因为操作系统会自动预读和缓存文件内容,且内存映射减少了用户态/内核态的数据拷贝。
性能分析与瓶颈定位
在展开具体优化工作之前,识别性能瓶颈是关键的一步。盲目优化往往事倍功半,甚至优化了非瓶颈部分而徒增代码复杂度。因此建议利用各种分析工具(Profiler)来定位程序中的“热区”和问题点。
- CPU Profiling:常用 GNU gprof 工具对应用程序进行采样分析,生成函数级别的耗时报告。在Linux上可以使用
perf
工具对程序采集更底层的性能数据(如CPU周期、缓存未命中等)。跨平台的工具如 Intel VTune, AMD uProf 提供更高级的性能分析(包括线程并发、微架构瓶颈)。另外,Valgrind 的 Callgrind 模块也能分析代码热点和调用关系,并可借助KCachegrind等可视化工具查看分析结果。 - 内存和资源分析:使用 Valgrind 的 Memcheck 工具可以检测内存泄漏和非法内存访问,这有助于消除由于内存问题导致的异常行为和性能下降。Massif 是 Valgrind 的堆分析器,可以跟踪程序堆内存使用随时间的变化,找出高峰时占用大的代码路径。对于更复杂的内存分析,可以借助 Google Perf Tools(gperftools)中的 heap profiler 或 Dr. Memory 等工具。在需要分析缓存行为时,Valgrind 的 Cachegrind 模块可以模拟CPU缓存,报告缓存命中率,帮助调整数据结构以提高缓存友好度。
46. 说说移动构造函数与拷贝构造函数
- 我们用对象 a 初始化对象 b,之后对象 a 我们就不再使用了,但是对象 a 的空间还在(在析构之前),既然拷贝构造函数实际上就是把 a 对象的内容复制一份到 b 中,那么为什么我们不能直接使用 a 的空间呢?这样就避免了新的空间的分配,大大降低了构造的成本。这就是移动构造函数设计的初衷。
- ‼️拷贝构造函数中,对于指针,我们一定要采用深拷贝;而移动构造函数中,对于指针,我们采用浅拷贝。
- 移动构造函数的参数
&&
和拷贝构造函数&
不同:拷贝构造函数的参数是一个左值引用,但是移动构造函数的初值是一个右值引用。意味着,移动构造函数的参数是一个右值或者将亡值的引用。也就是说,只用一个右值或者将亡值初始化另一个对象的时候,才会调用移动构造函数。而那个move()
语句,就是将一个左值变成一个将亡值。
1 |
|
47. C++ 中指针参数传递和引用参数传递有什么区别?底层原理是什么?
恍然大悟
(1) 指针参数传递本质上是值传递,它所传递的是一个地址值
值传递过程中,被调函数的形式参数作为被调函数的局部变量处理,会在栈中开辟内存空间以存放由主调函数传递进来的实参值,从而形成了实参的一个副本(替身)。
值传递的特点是,被调函数对形式参数的任何操作都是作为局部变量进行的,不会影响主调函数的实参变量的值(即使是形参指针地址变了,实参指针地址都不会变)。
1 |
|
(2) 引用参数传递过程中,被调函数的形式参数也作为局部变量在栈中开辟了内存空间,但是这时存放的是由主调函数放进来的实参变量的地址
被调函数对形参(本体)的任何操作都被处理成间接寻址,即通过栈中存放的地址访问主调函数中的实参变量(根据别名找到主调函数中的本体)。
因此,被调函数对形参的任何操作都会影响主调函数中的实参变量。
二者区别
1) 引用传递和指针传递是不同的,虽然他们都是在被调函数栈空间上的一个局部变量:
- 但是任何对于引用参数的处理都会通过一个间接寻址的方式操作到主调函数中的相关变量。
- 而对于指针传递的参数,如果改变被调函数中的指针地址,它将应用不到主调函数的相关变量。🔥 如果想通过指针参数传递来改变主调函数中的相关变量(地址),那就得使用指向指针的指针或者指针引用。
2) 从编译的角度来讲,程序在编译时分别将指针和引用添加到符号表上,符号表中记录的是变量名及变量所对应地址。
- 指针变量在符号表上对应的地址值为指针变量的地址值,而引用在符号表上对应的地址值为引用对象的地址值(与实参名字不同,地址相同)。
- 符号表生成之后就不会再改,因此指针可以改变其指向的对象(指针变量中的值可以改),而引用对象则不能修改。
48. C++ 中类成员的访问权限和继承权限问题
访问权限
① public
: 用该关键字修饰的成员表示公有成员,该成员不仅可以在类内可以被访问,在类外也是可以被访问的,是类对外提供的可访问接口;
② private
: 用该关键字修饰的成员表示私有成员,该成员仅在类内可以被访问,在类体外是隐藏状态;
③ protected
: 用该关键字修饰的成员表示保护成员,保护成员在类体外同样是隐藏状态,但是对于该类的派生类来说,相当于公有成员,在派生类中可以被访问。
继承方式
① 若继承方式是 public
,基类成员在派生类中的访问权限保持不变,也就是说,基类中的成员访问权限,在派生类中仍然保持原来的访问权限;
② 若继承方式是 private
,基类所有成员在派生类中的访问权限都会变为私有 (private) 权限;
③ 若继承方式是 protected
,基类的共有成员 public
和保护成员 protected
在派生类中的访问权限都会变为保护 (protected) 权限,私有成员在派生类中的访问权限仍然是私有 (private) 权限。
49. 定义与声明的区别
如果是指「变量」的声明和定义:
- 从编译原理上来说,声明是仅仅告诉编译器,有个某类型的变量会被使用,但是编译器并不会为它分配任何内存。
- 而定义就是分配了内存。
如果是指「函数」的声明和定义:
- 声明:一般在头文件里,对编译器说我有一个函数叫
function()
让编译器知道这个函数的存在。 - 定义:一般在源文件里,具体就是函数的实现过程写明函数体。
50. 你知道 strcpy 与 memcpy 的区别吗
1、复制的内容不同:
strcpy
只能复制字符串- 而
memcpy
可以复制任意内容,例如字符数组、整型、结构体、类等
2、复制的方法不同:
strcpy
不需要指定长度,它遇到被复制字符的串结束符"\0"
才结束,所以容易溢出memcpy
则是根据其第 3 个参数决定复制的长度。
3、用途不同:
- 通常在复制字符串时用
strcpy
- 而需要复制其他类型数据时则一般用
memcpy
51. volatile 关键字的作用
面试回答
volatile
的意思是“脆弱的”,表明它修饰的变量的值十分容易被改变,所以编译器就不会对这个变量进行优化(CPU 的优化是让该变量存放到 CPU 寄存器而不是内存),进而提供稳定的访问。每次读取 volatile
的变量时,系统总是会从内存中读取这个变量,并且将它的值立刻保存。
解释
C/C++ 中的 volatile 关键字和 const 对应,用来修饰变量,通常用于建立语言级别的 memory barrier。
volatile
关键字是一种类型修饰符,用它声明的类型变量表示可以被某些编译器未知的因素更改,比如:操作系统、硬件或者其它线程等。遇到这个关键字声明的变量,编译器对访问该变量的代码就不再进行优化,从而可以提供对特殊地址的稳定访问。声明时语法:int volatile vInt
; 当要求使用 volatile 声明的变量的值的时候,系统总是重新从它所在的内存读取数据,即使它前面的指令刚刚从该处读取过数据。而且读取的数据立刻被保存。
1 |
|
1 | i = 10 |
✅ volatile
用在如下的几个地方:
- 中断服务程序中修改的供其它程序检测的变量需要加 volatile。
- 多任务环境下各任务间共享的标志应该加 volatile:当两个线程都要用到某一个变量且该变量的值会被改变时,应该用 volatile 声明,该关键字的作用是防止优化编译器把变量从内存装入 CPU 寄存器中。如果变量被装入寄存器,那么两个线程有可能一个使用内存中的变量,一个使用寄存器中的变量,这会造成程序的错误执行。volatile 的意思是让编译器每次操作该变量时一定要从内存中真正取出,而不是使用已经存在寄存器中的值。
- 存储器映射的硬件寄存器通常也要加 volatile 说明,因为每次对它的读写都可能有不同意义。
52. 如果有一个空类,它会默认存在哪些函数?
1 | Empty(); // 缺省构造函数 // |
53. const char* 与 string 之间的区别
string 是 C++ 标准库里面其中一个,封装了对字符串的操作,实际操作过程我们可以用 const char*
给 string
类初始化。
三者之间的转化关系如下:
1 | // 1. string 转 const char* |
54. static_cast 比 C 语言中的转换好在哪里?
- 更加安全;
- 更直接明显,能够一眼看出是什么类型转换为什么类型,容易找出程序中的错误;可清楚地辨别代码中每个显式的强制转;可读性更好,能体现程序员的意图。
55. delete 和 delete[] 区别?
delete
只会调用一次析构函数。delete[]
会调用数组中每个元素的析构函数。
56. 为什么不把所有函数写成内联函数?
内联函数以代码复杂为代价,它以省去函数调用的开销来提高执行效率。所以一方面如果内联函数体内代码执行时间相比函数调用开销较大,则没有太大的意义;另一方面每一处内联函数的调用都要复制代码,消耗更多的内存空间,因此以下情况不宜使用内联函数:
- 函数体内的代码比较长,将导致内存消耗代价
- 函数体内有循环,函数执行时间要比函数调用开销大
57. 哪些函数不能是虚函数?
- 构造函数:构造函数初始化对象,派生类必须知道基类函数干了什么,才能进行构造;当有虚函数时,每一个类有一个虚表,每一个对象有一个虚表指针,虚表指针在构造函数中初始化。
- 内联函数:内联函数表示在编译阶段进行函数体的替换操作,而虚函数意味着在运行期间进行类型确定,所以内联函数不能是虚函数。
- 静态函数:静态函数不属于对象属于类,静态成员函数没有 this 指针,因此静态函数设置为虚函数没有任何意义。
- 友元函数:友元函数不属于类的成员函数,不能被继承。对于没有继承特性的函数没有虚函数的说法。
- 普通函数:普通函数不属于类的成员函数,不具有继承特性,因此普通函数没有虚函数。
58. 什么原因造成内存泄露,你怎么避免/解决内存泄露?
1️⃣ 什么是内存泄露?
在程序运行过程中不再使用的对象没有被正确释放,从而导致程序使用的内存不断增加,最终导致程序异常退出或内存分配失败。
2️⃣ 什么原因造成内存泄露?
- 忘记释放内存:分配了内存但没有释放
- 异常 / 逻辑处理不当:写了内存释放代码,但最后未执行到
- 循环引用:使用智能指针 shared_ptr 造成内存泄露
3️⃣ 如何避免/解决内存泄露 ‼️
内存泄露一般是因为分配了内存但没有释放,要解决这个问题,我通常从以下几个层面入手:
- 我会用 RAII 机制管理资源(构造时分配,析构时释放)
- 能用智能指针(
unique_ptr
,shared_ptr
)的地方绝不手动new
/delete
,同时要注意避免循环引用(使用弱引用) - 对于资源管理比较复杂的类,我会写好析构函数,并考虑拷贝/移动语义,防止资源重复释放或泄露
- 正确捕获处理异常 / 回滚式编程:编写异常安全的代码非常困难
解决内存泄露本质上就是:该释放的要释放,生命周期清楚,用好工具,写好代码。我平时更倾向于用智能指针来管理资源,基本上能从根上避免大部分内存泄露问题。
4️⃣ 如何定位内存泄露
🔗参考链接:
- 静态检测工具:检查代码中是否出现内存泄露,
- cppcheck
- clang-tidy
valgrind
- 需要调试信息
-g
valgrind --leak-check=full
可执行程序- 可视 valgrind 为虚拟机,将可执行程序当作文件来处理,读取二进制文件的内容,进行指令解析并执行
- 需要调试信息
hook
+backtrace
:侵入式(可能会引起程序异常)- hook 住内存分配和释放接口
- 每次申请内存都记录一下,每次释放时也记录一下,然后再把这两种记录进行一个对比,把相同的去掉,剩下就是
eBPF
+uprobes
:非侵入式(内核中进行统计,不会影响程序)- 不需要调试信息
- 原理与上一种相同,但是不是侵入式,运行在内核
59. C++ 写了析构函数,系统会帮我们生成默认移动构造函数这些吗(介绍 C++ 六个特殊成员函数)
写了析构函数,系统可能不再自动生成“移动构造”和“移动赋值”函数了,但拷贝构造和拷贝赋值通常还是会生成的。
在 C++ 里,有六个所谓的“特殊成员函数”:
- 默认构造函数
MyClass()
- 析构函数
~MyClass()
- 拷贝构造函数
MyClass(const MyClass& other)
- 拷贝赋值函数
MyClass& operator=(const MyClass& other)
- 移动构造函数(C++11 起)
MyClass(MyClass&& other) noexcept
- 移动赋值函数(C++11 起)
MyClass& operator=(MyClass&& other) noexcept
如果你自己写了一个析构函数,那编译器就认为你要自己管理资源了。所以出于安全考虑,它不会再自动生成移动构造函数和移动赋值函数了,你得自己写,或者用 = default
显式声明。
1 | MyClass(MyClass&&) = default; |
60. C++ 右值引用和移动拷贝(赋值)函数的作用
右值引用和移动语义是在 C++11 之后引入的,目的是优化性能,避免不必要的资源拷贝。
以前在 C++98 里,如果你把一个对象传给另一个对象,哪怕那个对象马上就要销毁了,编译器也只能做拷贝,哪怕里面的数据非常大,比如堆上几百 MB 的数组,也得老老实实拷贝一份,非常浪费性能。
而右值引用的出现,让编译器能识别出“这是一个临时对象”,你可以放心地把它的资源“抢过来”用,而不是复制一份。
移动构造函数 MyClass(MyClass&& a) noexcept
和移动赋值操作符 MyClass& operator=(MyClass&& a) noexcept
的作用就是:
- 移动构造:当一个临时对象要变成另一个对象时,直接“接管”它的内部资源,比如把指针地址拿过来,然后把原对象的指针清空,这样就不需要重新分配和复制内存。
- 移动赋值:和移动构造类似,不过是用于对象已经存在的情况下,把另一个临时对象的资源拿过来,原来的资源先释放,然后再接管。
61. C++ 线程 thread_local 的作用是什么?
它是线程单独拥有的资源,没办法作为共享资源
thread_local
是 C++11 引入的存储类型说明符,用于为每个线程创建独立的变量副本。
使用场景:
- 每个线程都需要使用一个自己的变量(如缓存、计数器等),避免同步。
- 类似于线程的“全局变量”,但互不干扰。
示例:
1 | thread_local int counter = 0; |
✅ 示例场景:日志系统中用 thread_local
缓存上下文
在多线程程序中,很多系统会给每个线程维护一份独立的日志信息,比如线程 ID、调用栈、临时日志缓存等。如果所有线程都用一个共享变量,会导致锁竞争、效率低下。
这时候就可以用 thread_local
给每个线程一份独立副本!
1 |
|
62. 如何定义一个只能在堆上(栈上)生成对象的类?
只能在堆上
方法:将析构函数设置为私有
原因:C++ 是静态绑定语言,编译器管理栈上对象的生命周期,编译器在为类对象分配栈空间时,会先检查类的析构函数的访问性。若析构函数不可访问,则不能在栈上创建对象。
只能在栈上
方法:将 new 和 delete 重载为私有
原因:在堆上生成对象,使用 new 关键词操作,其过程分为两阶段:第一阶段,使用 new 在堆上寻找可用内存,分配给对象;第二阶段,调用构造函数生成对象。将 new 操作设置为私有,那么第一阶段就无法完成,就不能够在堆上生成对象。
63. 递归过深会造成什么问题,OOM 吗?
递归过深确实可能引发一些严重问题,但不一定是 OOM(Out of Memory)。更常见的问题是栈溢出(Stack Overflow)。
✅ 栈溢出(Stack Overflow)
- 每次函数调用都会在调用栈上分配一块内存来保存函数的执行上下文(如局部变量、返回地址等)。
- 如果递归层级太深,调用栈不断增长,最终会超过系统分配给程序的栈空间上限(跟默认「线程栈」大小相关)。
- 此时程序会抛出 StackOverflowError(Java) 或 Segmentation Fault(C/C++),或者 RecursionError(Python)。
❌ 而不是 OOM(Out of Memory)
- OOM 通常是指 堆内存 耗尽,例如大量创建对象、数组或内存泄漏。
- 除非每层递归都分配了大量堆内存(比如在每层递归里 new 很大的对象),否则递归本身并不会直接造成 OOM。
递归过深导致的栈溢出,和线程的栈大小直接相关:每个线程在启动时,操作系统会为它分配一块固定大小的栈内存(线程栈),专门用于保存函数调用帧,如果递归太深,每层调用都占用一点栈空间,栈就会被用完,最终导致栈溢出。线程栈大小是有限的,不同语言/平台有不同的默认值如下:
- Java 一般是 1MB(可通过
-Xss
参数设置) - Linux 上的原生线程(如
pthread
)默认栈大小通常是 8MB - Python 受限于解释器的默认递归深度(
sys.getrecursionlimit()
)
64. 如何获取前 K 个最大元素?
这个问题是数据结构与算法中的经典题目之一,常用于考察排序、堆、优先队列的应用。以下是几种常见的解法及其时间复杂度分析:
解法一:排序法(适合数据量不大)
思路:
- 直接对数组进行排序
- 取前 K 个元素
1 |
|
时间复杂度:
- 排序时间复杂度:
O(n log n)
- 空间复杂度:
O(1)
解法二:最小堆(推荐,适合大数据)
思路:
- 使用大小为 K 的最小堆来保存当前最大的 K 个元素
- 遍历整个数组,若当前元素比堆顶大,则替换堆顶
1 |
|
时间复杂度:
- 时间复杂度:
O(n log k)
- 构建堆:
O(k)
- 遍历其余 $n - k$ 个元素,每次
O(log k)
:总共O((n-k) log k)
- 构建堆:
- 空间复杂度:
O(k)
🔥 解法三:快排的思想(Top-K 问题,适合不要求完整排序)
思路:
- 类似快速排序中的分区(partition),选定一个“枢轴”,将大于 pivot 的放左边,小于的放右边
- 不断递归,直到找到第 K 个最大的数为止
1 |
|
时间复杂度:
- 平均:
O(n)
- 最坏(退化成链表):
O(n^2)
- 最坏(退化成链表):
- 空间复杂度:
O(1)
(递归栈不计)
✅ 总结对比
方法 | 时间复杂度 | 空间复杂度 | 适用场景 |
---|---|---|---|
排序法 | O(n log n) | O(1) | 数据量小,代码简单 |
最小堆 | O(n log k) | O(k) | 数据量大,k 远小于 n |
快速选择 | 平均 O(n) | O(1) | 不关心顺序,只要前 K 大 |
65. 堆和栈在操作系统底层的实现?
堆与栈、进程虚拟内存空间分布
【速度、安全】栈是用于管理函数调用、局部变量等的高效内存区域,由操作系统自动管理
【动态、灵活】堆是用于动态分配的内存区域,由程序员手动管理(或者通过垃圾回收机制管理 — java)
⚙️ 在 32 位机器上,进程虚拟内存空间分布如下:
栈为什么适合函数调用:严格的顺序性,嵌套调用不会乱。
递归调用过深,栈会发生什么:栈溢出、触发 SIGSEGV 信号,程序崩溃。
🔥 栈在操作系统底层的实现
- 线程创建时,操作系统为其创建栈空间(默认 8 MB)—— 分成进程栈与线程栈
- 进程栈(主线程栈)在进程启动时创建
- 线程栈通过
pthread_create
、clone
系统调用创建
- 栈的使用:只需要移动栈指针
- 硬件支持:寄存器 x86 下
- 栈顶指针 rsp
- 栈底指针 rbp
- 操作系统管理:
- 函数调用时,先将参数压栈
- 执行 call 指令,将返回地址(返回上一级函数的下一条指令)压栈
- 创建栈帧,保存旧的 rbp,设置新的 rbp
- 可能为局部变量分配空间(清理局部变量等的栈空间)
- 恢复之前的 rbp 和 rsp,ret 指令弹出返回地址
🔥 堆在操作系统底层的实现
本质为 malloc
机制:
- 当分配内存的大小 $<128KB$ 时,先从内存池获取,否则通过
brk
系统调用从堆区分配内存,回收时则回收到内存池 - 当分配内存的大小 $\ge 128KB$ 时,通过
mmap
系统调用从文件映射区分配内存,回收时通过munmap
释放内存
为什么栈的分配速度比堆快?
栈只需要移动指针,堆需要查找空闲块、处理碎片和系统调用
多线程程序中,堆和栈如何隔离?
栈是线程私有的,无需隔离;
堆是进程共享的,需同步机制如锁🔒
66. C++ mutable
关键字
腾讯 wxg 一面
1️⃣ C++ mutable
用于修饰非静态成员函数,使得该成员变量可以在 const
成员函数中允许被修改
2️⃣ 同时也可以用于修饰 lambda
表达式,可以去掉函数调用操作符后 const
关键字,从而可以在 lambda 函数体内可以修改按值捕获的外部变量。
67. 详解内存对齐(如何 padding)及其原因
腾讯 csig 一面
求 sizeof(S)
的大小并解析,以及为什么进行内存对齐。
1 | struct B { |
在 C/C++ 中,结构体的大小与成员排列不仅取决于每个成员的大小,还受到**内存对齐(Alignment)**的影响,具体规则如下:
- 每个成员变量的地址必须是其类型对齐大小(对齐边界)的整数倍。
- 结构体本身的总大小必须是其最大对齐单位的整数倍。
- 编译器会在必要的位置插入 **padding(填充字节)**来满足上述要求,以提高内存访问效率。
对结构体 S 的内存布局分析
- 结构体 B 的分析:
1 | struct B { |
- 成员
int b
对齐为 4 字节,偏移量为 0。 - 成员
char c
占 1 字节,偏移量为 4。 - 为满足结构体
B
总大小为最大对齐成员(4 字节)的倍数,需要在末尾添加 3 字节填充。
所以 sizeof(B) == 8
- 结构体 S 的分析:
1 | typedef struct { |
int a
: 占 4 字节,从 offset 0 开始。char b
: 占 1 字节,offset = 4。- padding 1 字节,使得
short c
对齐到 2 的倍数(offset = 6)。
- padding 1 字节,使得
short c
: 占 2 字节,offset = 6。char d
: 占 1 字节,offset = 8。- padding 3 字节,使得结构体
B e
对齐到 4 字节边界(offset = 12)。
- padding 3 字节,使得结构体
B e
: 占 8 字节(因为sizeof(B) == 8
),offset = 12。- 最后 offset = 12 + 8 = 20
因此: sizeof(S) == 20
(假设最后总大小为 21 字节,需要 padding 到 24 字节)
68. *(int *)0 = 0
的含义?
*(int *)0 = 0;
这行代码的意思是:将整数值 0
写入内存地址 0
所指向的地方,也就是将值 0
存储到内存的 地址 0。
(int *) 0
:把整数 0 强转为int*
类型的指针*(int *) 0
:对地址0
进行解引用,访问该地址存储的内容*(int *) 0 = 0
:对地址0
存储的内容赋值为0
,试图写入值0
到地址0
在几乎所有现代操作系统中,地址 0
是无效的地址,属于操作系统保留的内存区域。
尝试访问(特别是写入)地址 0
会导致段错误(Segmentation Fault),程序异常终止。
在某些低层次编程或嵌入式开发中,地址 0
可能用于特殊用途,但在普通用户态程序中,这样写没有任何合法理由,通常是:
- 故意制造崩溃(例如测试信号处理)。
- 调试用,或者模拟空指针解引用的错误。
- 考察面试者对指针、内存访问的理解(比如本题)。
69. 宏定义展开
1 |
|
输出为 a = 11
而非 a = 25
,宏展开后为 a = (b + 2 * b + 2)
,所以结果为 a = 11
70. 假设有一个位图数据结构定义为 uint32_t bitmap[BSIZE];
,请写出用于判断位图中第 bit 位是否为 1 的如下宏的实现
#define is_bit_set(bit) ((bitmap[(bit)/32] & (1U << ((bit)%32))) != 0)
解释:一个 uint32_t
只能表示 32 位,题目 bitmap 用的是 uint32_t
数组,所以采用 /32
找到在第几个 uint32_t
中,%32
找到在第几位。
71. 读写锁与读优先/写优先
读写锁和传统互斥锁的区别在于,能支持多个读线程并发执行
在多线程编程中,读锁和写锁通常是配合**读写锁(读写互斥锁)**使用的,目的是在多个线程并发访问共享资源时提供更高的效率。简单来说,**读锁(共享锁)允许多个线程同时读取资源,但不允许写操作;而写锁(独占锁)**则是排他的,同一时间只能有一个线程持有写锁,且写锁期间不允许任何其他线程读或写这个资源。
这个机制的好处在于:在读多写少的场景下,我们不必像传统互斥锁那样每次都加排它锁,而是可以让多个读线程并发执行,提升性能。
具体的使用场景比如:
- 读锁适合的场景:多个线程同时查询一个共享缓存、配置文件、数据库快照等,数据是只读的,不会被修改。
- 写锁适合的场景:当某个线程需要更新缓存、修改配置或写入日志文件等操作时,为了避免其他线程读到不一致的数据,就需要加写锁。
在 C++ 中,像 std::shared_mutex
就是一个典型的读写锁实现,可以配合 std::shared_lock
和 std::unique_lock
分别实现读锁和写锁。
读/写优先是基于读写锁中的策略选择而已,不要搞混了
在使用读写锁时,读优先和写优先指的是当读线程和写线程同时竞争锁资源时,系统会优先允许哪一类线程先获取锁。
读优先的策略意味着:如果当前有读线程在读,或者有新的读线程请求读锁,就会优先满足它们,哪怕有写线程已经在等待。这种策略的好处是读性能非常高,适合“读远远多于写”的场景。但问题是如果读操作持续不断,写线程可能会长时间得不到执行,造成“写饥饿”。
写优先则反过来:一旦有写线程在等待,新的读线程就要等写线程先执行完。这种方式能保证写操作不会被饿死,但也意味着读线程可能会频繁被阻塞,尤其在写比较多时,整体并发度会下降。
还有一种是公平策略,就是无论读还是写,谁先请求谁先执行。这种方式平衡了读写,防止任何一方饿死,但也可能带来一点性能损耗。
实际使用中,选择哪种策略要看业务特点:
- 如果系统是典型的读多写少,比如缓存、配置系统,用读优先可以提升整体吞吐;
- 如果写操作比较关键,比如数据库更新或者日志记录,写优先更合适;
- 如果两者都重要,或者对延迟比较敏感,可以用公平策略。
C++ 标准库里的 std::shared_mutex
是不保证写优先的,如果确实要实现写优先或者公平策略,可能需要第三方库(比如 Boost)或者平台相关的原语。
72. 多线程之间是如何通信的?线程之间怎么交互的?
<mutex>
和<condition_variable>
答案就是条件变量
多线程之间的通信和交互主要是通过共享内存来实现的,也就是说多个线程可以访问同一个进程的内存空间,从而读写同一份数据。但因为线程可能同时访问同一块数据,会产生竞态条件,所以通常需要同步机制来保证数据的一致性和正确性。
常用的同步方式包括互斥锁(mutex)、读写锁(rwlock)、信号量(semaphore)、条件变量(condition variable)等,这些机制帮助线程协调访问顺序,防止数据冲突。
此外,线程间还可以通过消息队列、事件通知等方式传递信息,尤其在异步或生产者-消费者模式中常见。现代操作系统和语言运行时通常提供了丰富的线程同步和通信工具,确保线程间可以高效且安全地交互和协作。
73. 关于使用 ==
比较 double
类型
在 C++ 中处理double
类型时,我们需要理解:
- 为什么不能直接用
==
比较浮点数(浮点数为什么是近似存储的) - 如何正确比较浮点数
1. 为什么不能直接用 ==
比较
浮点数在计算机中的存储是近似值而非精确值,double
通常使用 64 位存储(1 符号位 + 11 指数位 + 52 尾数位),像 0.1 这样的十进制小数无法精确表示为二进制分数,存储时必须进行舍入(截断无限循环部分),导致微小误差。
1 |
|
输出可能类似于:
1 | 0.1 in memory: 0.10000000000000000555 |
所以使用 ==
比较是容易出错的。
1 |
|
2. 正确的浮点数比较方法
绝对误差比较
1 |
|
3. 什么时候可以使用 ==
直接比较
在C++中,虽然大多数情况下不推荐直接用==
比较浮点数,但在以下特定情况下可以安全使用(当你能 100% 确定数值来源和没有经过任何浮点运算时,才用==
):
- 比较精确赋值的相同字面值
1 | double a = 5.0; |
- 比较整数范围内的值
1 | double x = 42.0; // 没有小数部分 |
- 比较特殊浮点值
1 | // 比较正负无穷大 |
- 比较编译期常量表达式:
1 | constexpr double PI = 3.141592653589793; |
- 比较未经过算术运算的相同变量:
1 | double val = getValue(); // 假设返回固定值 |
⚠️ 特别注意,以下情况绝不应该使用==
:
1 | // 经过算术运算的结果 |
74. 堆与栈的优缺点比较
栈 - Stack
优点:
- 快速分配/释放:栈内存的分配和释放只是移动栈指针,速度快
- 自动管理:函数返回时自动释放,无需手动管理
- 缓存友好:栈数据通常位于缓存热点区域,访问速度快
- 不会产生碎片:严格的 LIFO 顺序避免了内存碎片问题
- 线程安全:每个线程有自己的栈,无需同步
缺点
- 大小有限:栈空间通常较小(几 MB),不适合大对象
- 生命周期固定:只能在函数调用期间存在,不能跨函数使用
- 灵活性差:无法动态调整大小,必须是编译时已知的大小
- 容易溢出:递归过深或大对象可能导致栈溢出(stack overflow)
堆 - Heap
优点:
- 大容量:可用内存通常远大于栈空间
- 动态分配:可以在运行时决定分配大小和生命周期
- 全局可访问:分配的内存可以被程序任何部分访问
- 灵活性高:可以动态调整大小(如realloc)
- 适合大数据:能够处理大型数据结构
缺点:
- 分配速度慢:需要寻找合适的内存块,可能涉及系统调用
- 手动管理特性:堆内存需要显式分配 (
new/malloc
) 和释放 (delete/free
),存在内存泄漏风险 - 内存碎片:频繁分配释放可能导致碎片
- 同步开销:多线程环境下需要同步机制
- 缓存不友好:堆分配的数据可能分散在内存各处
使用建议
- 优先使用栈:适合小型、短生命周期的数据
- 必要时用堆:大型数据或需要长生命周期时使用
75. 关于 shared_ptr
的注意事项(20. 智能指针)
关于 shared_ptr
的注意事项:
不要用一个裸指针初始化多个
shared_ptr
,会出现 double_free 导致程序崩溃通过
shared_from_this()
返回 this 指针,不要把 this 指针作为shared_ptr
返回出来,因为this
指针本质就是裸指针,通过 this 返回可能会导致重复析构,不能把 this 指针交给智能指针管理。尽量使用
std::make_shared<T>()
,少用new
不要
delete
get()
返回的裸指针不是
new
出来的空间要自定义删除器要避免循环引用,循环引用导致内存永远不会被释放,造成内存泄漏(不在赘述)
1. 不要用一个裸指针初始化多个 shared_ptr
(会导致 double free)
问题场景:
1 | int* raw_ptr = new int(42); |
- 两个独立的
shared_ptr
会各自维护一个引用计数控制块(相互独立) - 当
sp1
和sp2
销毁时都会尝试释放raw_ptr
,导致 双重释放(double free) - 结果通常是程序崩溃或未定义行为
正确做法:
1 | // 方法1:直接使用 make_shared |
2. 正确使用 shared_from_this()
而不是直接返回 this
指针
问题场景:
1 | class BadExample { |
- 这会创建两个独立的
shared_ptr
控制块 - 当两个
shared_ptr
销毁时都会尝试析构同一个对象
正确做法:
1 | class GoodExample : public std::enable_shared_from_this<GoodExample> { |
3. 优先使用 std::make_shared<T>()
而不是 new
问题场景:
1 | // 不推荐 |
优势:
- 性能更好:单次内存分配(对象 + 控制块)
- 异常安全:不会在
new
和shared_ptr
构造之间发生泄漏 - 代码更简洁:不需要重复类型名称
- 缓存友好:对象和控制块内存相邻
例外情况:
- 需要自定义删除器时
- 需要指定特殊的内存分配方式时
4. 不要 delete
get()
返回的裸指针
问题场景:
1 | auto sp = std::make_shared<int>(42); |
shared_ptr
仍然拥有内存所有权- 手动
delete
会导致:- double free
- 控制块状态不一致
- 未定义行为(通常崩溃)
正确做法:
1 | auto sp = std::make_shared<int>(42); |
5. 非 new
分配的内存需要自定义删除器
问题场景:
1 | // 从 malloc 分配的内存 |
正确做法:
1 | // 使用自定义删除器(lambda 表达式作为删除器) |
常见删除器场景:
- C 风格内存分配(
malloc/calloc/realloc
)→ 使用free
- 文件操作(
fopen
)→ 使用fclose
- 系统资源(套接字、句柄等)→ 使用对应的释放函数
- 数组 → 使用
delete[]
6. 避免循环引用导致的内存泄露
问题场景 1
1 | class A; |
问题场景 2
1 | class Node { |
- 当
node1
和node2
离开作用域时:node1
的引用计数从 1→0?不,因为node2->prev
还持有引用(实际从 2→1)node2
的引用计数同样从 2→1
- 结果:两者引用计数永远不为 0,内存永远不会释放
1 | node1 [refcount=2] --> Node1对象 |
解决方案:weak_ptr
1 | class SafeNode { |
何时会出现循环引用?
- 双向链表、树结构等复杂数据结构
- 对象相互持有对方的
shared_ptr
- 父子对象互相强引用
- 观察者模式中主体和观察者互相持有
76. std::shared_ptr
可以通过 std::make_shared
和直接使用 new
表达式构造,二者有什么区别?
在 C++ 中,std::shared_ptr
可以通过 std::make_shared
和直接使用 new
表达式构造,但二者在内存管理、性能和异常安全性方面有显著区别:
1 | // 推荐方式:高效且安全 |
1. 内存分配方式
make_shared
:一次性分配内存,同时存储对象本身和控制块(引用计数等)。这是更高效的内存布局,减少了内存碎片和分配次数1
auto sp = std::make_shared<Widget>(args...); // 单次分配
new
+shared_ptr
构造函数:需要两次内存分配,一次为对象(new
),另一次为控制块(由shared_ptr
构造函数触发)1
std::shared_ptr<Widget> sp(new Widget(args...)); // 两次分配
2. 性能差异
make_shared
更快:单次内存分配减少了开销,尤其当频繁创建/销毁shared_ptr
时更明显。new
额外开销:两次分配可能引入性能损耗(尤其是对小型对象)。
解释
每次 new
/malloc
都是一次系统调用:操作系统需要查找合适的内存块、更新内存管理数据结构(如空闲链表),可能触发缺页中断或内核态切换,这些操作本身就有固定开销。两次分配就会有双倍开销。
从内存局部性角度来看:
make_shared
的内存是连续的,对象和控制块在单块内存中紧密排列,缓存命中率更高。new
的内存可能是分散的,对象和控制块位于不同内存区域,访问时可能触发多次缓存加载,同时产生更多内存碎片。
3. 异常安全性
make_shared
是异常安全的:若构造函数抛出异常,不会发生内存泄漏(因为内存已由智能指针系统托管)。1
process(std::make_shared<Widget>(a, b), may_throw()); // 安全
new
可能泄漏:如果new
成功但shared_ptr
构造前发生异常,对象不会被释放:1
process(std::shared_ptr<Widget>(new Widget(a, b)), may_throw()); // 可能泄漏
4. 对象生命周期的影响
make_shared
的延迟释放:对象内存和控制块是连续的,即使引用计数归零,对象占用的内存可能直到控制块也被释放时才归还(例如弱引用weak_ptr
仍存在时)。new
的独立释放:对象内存和控制块分离,对象内存会在引用计数归零时立即释放,控制块则等待所有weak_ptr
释放后才回收。
5. 使用场景限制
- 必须用
new
的情况:需要自定义删除器或需从已有裸指针构造时。1
std::shared_ptr<Widget> sp(new Widget, custom_deleter);
- 优先用
make_shared
:默认情况下推荐使用,除非有特殊需求。
总结
特性 | make_shared |
new + shared_ptr |
---|---|---|
内存分配次数 | 1 次 | 2 次 |
异常安全 | 是 | 否(可能泄漏) |
内存释放时机 | 对象和控制块一起释放 | 对象先释放,控制块后释放 |
自定义删除器 | 不支持 | 支持 |
最佳实践:默认使用 make_shared
,除非需要自定义删除器或特殊内存管理。
77. shared_ptr
的引用计数是在栈还是堆?
在 std::shared_ptr
的实现中,引用计数(控制块)存储在堆上,为了共享这个这个 reference count 值。
shared_ptr
和weak_ptr
可能被拷贝、传递到不同的作用域(如函数调用、线程间共享)。如果引用计数在栈上,它会在栈帧销毁时被释放,导致其他指针无法正确跟踪计数。make_shared
会将对象数据和引用计数分配在同一块堆内存中new
+shared_ptr
则是将对象数据和引用计数分配在两块独立的堆内存
- 虽然引用计数本身在堆上,但
shared_ptr
的实例(即指针对象本身)可以存储在栈上
总结
存储位置 | 内容 | 原因 |
---|---|---|
堆内存 | 引用计数(控制块)、对象数据 | 需要动态生命周期管理,支持多指针共享,跨作用域存活。 |
栈内存 | shared_ptr 实例本身 |
栈对象自动析构,通过析构函数减少堆上的引用计数,必要时释放对象内存。 |
78. 基类的虚函数可以声明为 private
吗
基类的虚函数可以声明为 private
,但这会影响其可访问性和多态行为的使用方式。
语法允许,但访问受限
从语法层面,C++ 允许虚函数为 private
,编译器不会报错。
派生类不能通过基类指针/引用直接访问 private
虚函数(违反访问控制规则)
多态行为仍然有效
如果基类提供公有方法调用私有虚函数,派生类重写该私有虚函数后,多态仍能正常工作。
- 派生类重写
foo()
时也必须为private
(访问权限可以更宽松,但不能更严格)。
1 | class Base { |
对比其他权限
虚函数权限 | 派生类能否直接调用 | 派生类能否重写 | 外部代码能否调用 |
---|---|---|---|
public |
✅ | ✅ | ✅ |
protected |
✅ | ✅ | ❌ |
private |
❌ | ✅(需同权限或更宽松) | ❌ |
79. 如果有 100 个对象,vector
、list
、map
哪个占用内存高
这个问题本质上考察 STL 容器的内存占用特性。假设我们存放 100 个对象(比如大小相同的自定义类对象),不同容器的内存开销差别主要来自于元素存储方式和额外的结构体开销。
1. vector
- 存储方式:底层是 动态数组,所有元素连续存放。
- 额外开销:
- 仅仅是
vector
自身维护的三个指针(begin/end/capacity_end),常见实现 24 字节左右。 - 可能有 容量冗余(capacity ≥ size),但不是很大。
- 仅仅是
- 内存占用:
- 接近 100 × sizeof(对象),几乎没有额外 per-element 的开销。
- 在三者中最节省内存。
2. list
- 存储方式:双向链表,每个节点独立分配。
- 额外开销:
- 每个节点除了存放对象,还要存放两个指针(prev/next),常见实现 16 字节或更多。
- 由于频繁分配,可能带来额外的 堆分配和内存碎片开销。
- 内存占用:
- 大约 100 × (sizeof(对象) + 2 × sizeof(指针)),比
vector
高不少。 - 在这三者里通常是最浪费内存的。
- 大约 100 × (sizeof(对象) + 2 × sizeof(指针)),比
3. map
(通常是红黑树)
- 存储方式:红黑树节点,每个节点存放
(key, value)
,还有指针和颜色信息。 - 额外开销:
- 一个节点要维护 父、左、右指针(3 个指针 = 24 字节)+ 颜色位(通常填充到 4 字节)。
- 还要存放键和值对象。
- 内存占用:
- 大约 100 × (sizeof(key) + sizeof(value) + 3 × sizeof(指针) + padding)。
- 如果 key/value 很小(比如 int/int),开销主要是结构体本身,比 list 更大。
结论(内存占用比较)
在存放 100 个对象时,三者大致的内存占用从低到高为:vector
< list
< map
vector
:最节省空间,几乎没有额外 per-element 开销。list
:由于链表节点指针和堆分配,开销中等。map
:红黑树节点开销更大(指针 + 平衡因子等),通常是最高的。
80. 虚函数表和虚函数指针存储在哪里?
虚函数表(vtable)
- 位置:
- 编译器生成的全局静态表,在程序的 只读数据区(.rodata) 或类似的静态存储区域。
- 每个包含虚函数的类(以及其派生类)通常对应一张或多张虚函数表。
- 表的内容是 函数指针数组,指向该类对应的虚函数实现。
- 特点:
- 程序运行时不会修改表的位置,只会改变对象中的指针指向哪一张表。
- 继承 + 覆盖虚函数时,子类的 vtable 会覆盖父类条目。
虚函数指针(vptr)
- 位置:
- 在 对象实例的内存中,通常存放在对象的头部。
- 每个对象都有一个 vptr,指向对应类的 vtable。
- 如果类有多个虚继承/多继承,可能会有多个 vptr。
- 初始化:
- 编译器在构造函数中插入代码,把对象的 vptr 指向正确的 vtable。
- 析构时,也会相应调整 vptr 以保证多态析构正确。
举例与总结
1 | class Base { |
- 程序里会生成两张 vtable:
Base
的 vtable,里面有&Base::foo
Derived
的 vtable,里面有&Derived::foo
Base
或Derived
对象实例的内存中,都会有一个 vptr 指针,指向相应的 vtable。
总结
- 虚函数表(vtable):编译器生成的全局静态表,存放在只读数据段。
- 虚函数指针(vptr):存放在对象实例内存中(通常在开头),指向该对象对应的 vtable。
81. 多线程编程一般用哪些库函数?
C 语言层面
- POSIX Threads (pthread):
pthread_create
、pthread_join
、pthread_mutex_lock
、pthread_cond_wait
等,Linux/Unix 常用。 - C11 标准库:
thrd_create
、mtx_lock
(但在实际项目里用得少)。
C++ 层面
- C++11 标准库:
std::thread
(线程创建/管理)std::mutex
、std::recursive_mutex
、std::timed_mutex
(互斥量)std::lock_guard
、std::unique_lock
(RAII 封装锁)std::condition_variable
(条件变量)std::future
/std::promise
/std::async
(任务并发模型)
82. 为什么需要锁?什么时候需要多线程?举个例子
为什么需要锁
- 多线程共享内存 → 存在 竞态条件(race condition)。
- 如果两个线程同时读写同一变量,可能导致数据不一致。
- 锁(mutex) 的作用就是保证某段代码在同一时刻只有一个线程能进入,维持数据一致性。
什么时候需要多线程
- 计算密集型任务:利用多核 CPU,提高吞吐量(例如矩阵运算、图像处理)。
- I/O 密集型任务:一个线程阻塞 I/O 时,其他线程可以继续执行(例如服务器同时处理多个客户端请求)。
举例
写一个多线程日志系统:
- 多个工作线程处理业务逻辑,把日志消息写入一个共享队列。
- 一个单独线程从队列取日志写文件。
- 共享队列必须加锁,否则多线程写入会导致数据错乱。
83. 多线程场景下出现内存泄漏怎么调试解决?
原因
- 某些线程申请的内存没有释放,线程退出后仍然占用。
- 使用 TLS(线程局部存储)或全局变量,线程结束时忘记清理。
- 线程之间交互复杂,某些分支忘记释放内存。
常见调试手段
- 工具:
valgrind --tool=memcheck
(Linux)asan
(AddressSanitizer)gdb
- 策略:
- 用 RAII(C++)避免手动
new/delete
。 - 尽量使用智能指针(
std::unique_ptr
、std::shared_ptr
)。 - 保证线程退出时执行清理逻辑(析构函数/
pthread_cleanup_push
)。
- 用 RAII(C++)避免手动
- 分而治之:把怀疑的线程单独跑,缩小问题范围。
下文详细介绍下「gdb 多线程调试」与「打日志 + 线上定位 + 复现副本」这两种方式。
当然能用 gdb 调多线程,而且线上定位常常要配合“打日志 + 复现副本”的办法一起用。下面给你一套“面试可讲、实际可用”的流程和要点。
(1) 用 gdb 调多线程的实用套路
基本操作(本地或 attach 线上进程)
1 | # 运行时调试 |
让单步/断点更可控(避免线程乱切)
1 | (gdb) set scheduler-locking on # 单步时不让其它线程抢占(on/step可选) |
条件断点 / 竞争点观察
1 | # 只在指定线程/条件触发 |
线程与锁问题的专招
死锁/卡住:先
gdb -p PID
,再thread apply all bt full
看各线程卡在什么锁/条件变量处;很多时候能直接看出两个线程的互相等待链。条件变量:在
pthread_cond_wait
、pthread_mutex_lock
处下断点或打条件断点,确认唤醒与加锁顺序。跟踪线程创建:记录是谁创建了问题线程。
1
2
3
4
5(gdb) break pthread_create
(gdb) commands
> bt
> c
> end
核心转储(coredump)事后排查
1 | ulimit -c unlimited |
也可以线上“留存一份 core”,再在离线环境慢慢剖析。
录制/重放
- rr(Linux):
rr record ./prog
,复现后用rr replay
进 gdb,可时间回溯到变量被改写的一刻,定位数据竞争/时序问题非常好用。 - gdb 的
record full
也能用,但rr
更稳定。
(2) 打日志→线上定位→复现副本→看日志
这是大厂很常见、也很靠谱的生产级排障方法:
- 打结构化日志(并带上标识)
- 必带:时间戳、线程ID、请求ID/traceID、关键状态(队列长度、锁等待时长、对象地址/指针等)。
- 在锁关键点打点:
lock acquire start/ok/timeout
、cond wait/signal
、enqueue/dequeue
、state transition
。 - 遇到潜在死锁,记录锁名/顺序和持有时长,方便识别锁顺序反转。
- 定位线上问题副本
- 用日志把异常请求/异常时间窗圈出来(比如特定用户、某个 shard、某台机器)。
- 从线上环境抽一个最小有问题的副本(相同配置/数据切片/版本/流量)到灰度或隔离环境。
- 这样能在不影响大盘的情况下复现,同时你可以随意加日志、开更高日志级别、甚至 attach gdb。
- 看日志 + 还原时序
- 通过 traceID 串起多个线程/模块的跨调用链,按照时间戳排序,重放“谁先拿了哪个锁、谁等在 cond 上”,从而定位竞态/死锁/饥饿。
- 日志中记录的内存地址/对象ID可以让你把若干条看似无关的日志对应到同一对象实例。
- 必要时动用在线栈
- 现场卡死时,用
gdb -p PID
或运维脚本(比如发送信号触发backtrace()
dump 全线程栈)把全线程栈打印出来,和日志时间窗对比,锁点一目了然。
- 现场卡死时,用
- 修复后再灰度验证
- 在副本/灰度环境验证“日志告警消失、锁等待时长下降、吞吐恢复”,再全量。
这套方法的核心是:用日志重建并发时序,用副本规避线上风险,用 gdb/栈/录制补齐细节。
84. 程序的内存布局是怎样的?
一个典型 C/C++ 程序在内存里的分布大致如下:
代码段:包括二进制可执行代码,通常只读,还可共享(多个进程运行同一程序时只存一份);
静态存储区:数据段 + BSS 段
数据段:包括已初始化的静态常量和全局变量;
BSS 段:包括未初始化的静态变量和全局变量;
堆(比如
malloc/new
):包括动态分配的内存,从低地址开始向上增长,程序员负责管理,容易泄露;文件映射区(比如
mmap
):包括共享库、文件映射、匿名映射、共享内存等;栈:包括局部变量和函数调用的上下文(参数、返回地址)等,函数调用或返回会自动分配与释放后。栈的大小是固定的,一般是 8 MB。当然系统也提供了参数,以便我们自定义大小;
内核空间(对用户程序不可见)
上图中的内存布局可以看到,代码段下面还有一段内存空间的(灰色部分),这一块区域是「保留区」,之所以要有保留区这是因为在大多数的系统里,我们认为比较小数值的地址不是一个合法地址,例如,我们通常在 C 的代码里会将无效的指针赋值为 NULL。因此,这里会出现一段不可访问的内存保留区,防止程序因为出现 bug,导致读或写了一些小内存地址的数据,而使得程序跑飞。
在这 7 个内存段中,堆和文件映射段的内存是动态分配的。比如说,使用 C 标准库的 malloc()
或者 mmap()
,就可以分别在堆和文件映射段动态分配内存。
85. delete 释放内存的时候并不知道内存大小,如何释放?
涉及
new
和delete
源码及流程链接:pdd 二面
delete
运算符在释放内存时确实不需要显式指定大小,因为它依赖底层的内存管理器来跟踪分配块的信息。当使用 new
分配内存时,内存管理器(如 glibc 的分配器)会在返回给用户的内存块前端或尾部存储额外的元数据(如分配大小、cookie 等),这些信息对用户透明。调用 delete
时,运算符会根据传入的指针向前偏移一定位置来读取这些元数据,从而确定需要释放的内存块实际大小和边界,确保正确释放。因此,用户无需手动传递大小,所有细节由内存管理器和编译器生成的代码处理。
86. 一个空类大小是多少?如果有构造函数和析构函数呢?如果有虚函数?
腾讯 TEG 一面
Q1:一个空类大小是多少?
在 C++ 中,一个空类(没有任何非静态成员变量和虚函数)的大小通常为 1 字节。这是因为编译器需要为每个对象分配一个唯一的地址标识,以确保不同对象在内存中拥有 distinct 的地址。如果大小为 0,可能导致多个对象地址相同,违反语言标准。需要注意的是,如果空类作为基类被继承,可能会发生空基类优化(EBO),此时基子对象不占用额外空间,从而节省内存。
Q2:如果空类只有构造函数和析构函数,该类大小是多少?
大小仍然是 1 个字节。 构造函数和析构函数是普通的成员函数,它们的代码并不存储在每一个对象实例中。这些函数在编译后位于代码段,所有该类的对象共享同一份函数代码。调用它们时,编译器会隐式地传入一个指向当前对象的 this
指针。因此,添加普通的成员函数(包括构造和析构)不会影响对象实例的大小。
Q3:如果空类只有虚函数,该类大小是多少?
在 64 位系统上,大小通常是 8 字节(一个指针的大小);在 32 位系统上,通常是 4 字节。
一旦一个类拥有虚函数,它就会拥有一个虚函数表(vtable),并且编译器会自动为该类的每一个对象实例添加一个隐藏的成员变量 —— 虚表指针(vptr)。这个 vptr 指向类的 vtable,用于在运行时实现多态(动态绑定)。因此,对象的大小会增加一个指针的开销。
87. 指针的大小是多少?
指针的大小不取决于它指向的数据类型,而完全取决于目标平台的寻址能力。
- 在 32 位平台上,指针的大小是 4 字节,因为它需要能表示 $2^{32}$ 个不同的内存地址。
- 在 64 位平台上,指针的大小是 8 字节,用于表示 $2^{64}$ 个地址空间。
无论是指向 int
、char
还是一个拥有虚函数的复杂类对象,所有数据指针的大小都遵循这个规则(函数指针可能在某些平台上有所不同)。
88. 内存对齐了解过吗,我如果不想对齐,怎么办?
腾讯 TEG 一面
内存对齐是编译器和硬件为了提升访问效率而实施的策略。
如果不想对齐,可以通过编译器指令强制取消填充(如 GCC/Clang 的 __attribute__((packed))
或 MSVC 的 #pragma pack(1)
),使结构体成员紧凑排列。但这样做有风险:未对齐访问在 x86 架构上会导致性能下降(多次内存访问),在其他架构(如 ARM)上可能直接触发硬件异常造成崩溃。除非有特殊需求(如协议解析或节省内存),否则不建议禁用对齐。
89. 讲讲 unordered_map 和 map 的底层实现和区别
腾讯 TEG 一面
区别
特性 | std::map |
std::unordered_map |
---|---|---|
底层数据结构 | 红黑树 (一种自平衡的二叉搜索树) | 哈希表 (数组 + 链表/红黑树) |
元素顺序 | 元素按 key 排序 (默认 std::less ,即升序) |
元素无序 (顺序取决于哈希函数) |
搜索时间复杂度 | O(log n) | 平均 O(1),最坏情况 O(n) |
插入时间复杂度 | O(log n) | 平均 O(1),最坏情况 O(n) |
删除时间复杂度 | O(log n) | 平均 O(1),最坏情况 O(n) |
迭代器稳定性 | 稳定(除非删除元素,否则迭代器始终有效) | 插入/删除可能使所有迭代器失效 |
需要为 key 定义 | operator< 或 自定义比较器 |
std::hash 哈希函数 和 operator== 相等比较 |
内存占用 | 通常较低(树节点开销) | 通常较高(需要维护数组桶和链表) |
常用场景 | 需要元素有序、顺序遍历、或要求最坏情况性能稳定 | 需要快速查找、插入、删除,且不关心顺序 |
底层实现详解
map
- 基于红黑树 (Red-Black Tree)
数据结构:
- 本质上是一颗二叉搜索树 (BST),这意味着任何节点的左子树所有节点的 key 都小于该节点的 key,右子树所有节点的 key 都大于该节点的 key。这使得中序遍历树时,可以得到有序的 key 序列。
- 它更具体地是一颗红黑树。红黑树是一种自平衡的二叉搜索树。它通过为节点添加颜色属性(红或黑)和定义一系列旋转和重新着色的规则,来确保树始终保持大致平衡(没有一条路径会比其他路径长两倍以上)。
如何工作:
- 插入:首先像普通的 BST 一样找到插入位置。插入新节点后(初始为红色),可能会破坏红黑树的平衡性质(如出现两个连续的红色节点)。这时需要通过旋转(左旋、右旋)和重新着色来恢复平衡。
- 查找:从根节点开始,与当前节点的 key 比较。如果小于,进入左子树;如果大于,进入右子树;如果相等,则找到。由于树是平衡的,查找路径长度最多为树的高度 O(log n)。
- 删除:同样先找到节点,执行 BST 删除操作后,可能会破坏平衡,需要再次通过旋转和重新着色来调整。
优点:元素始终有序,支持范围查询(如
lower_bound()
),提供了稳定的 O(log n) 操作时间。缺点:平均速度比哈希表慢,因为常数因子较大(需要多次比较和可能的内存跳跃)。
unordered_map
- 基于哈希表 (Hash Table)
数据结构:
- 一个数组(通常称为“桶”bucket 数组),数组的每个元素是一个链表的头指针(或一棵小红黑树的根节点)。
- 在 C++11 中,标准要求使用“开链法”解决哈希冲突。在极端情况下(一个桶里元素太多),标准允许该桶用树 instead of 链表来实现,以避免最坏性能。
如何工作:
- 插入:
- 对 key 进行哈希函数计算,得到一个整型的哈希值。
- 用这个哈希值
% 桶的数量
来确定元素应该放在哪个桶里(即数组的哪个索引)。 - 将键值对添加到这个桶对应的链表(或树)的末尾。
- 查找:
- 同样先计算 key 的哈希值,找到对应的桶。
- 然后遍历这个桶里的链表(或树),使用
operator==
进行精确匹配。
- 重新哈希 (Rehashing):
- 当元素数量超过
负载因子(load factor) * 桶的数量
时,容器会自动进行重新哈希。 - 这会创建一个新的、更大的桶数组,然后将所有现有元素重新计算哈希并插入到新的数组中。
- 这个过程会使所有迭代器失效!
- 当元素数量超过
- 插入:
优点:平均情况下的查找、插入、删除速度极快,接近常数时间 O(1)。
缺点:
- 元素无序。
- 最坏情况下的性能是 O(n)(例如所有 key 都哈希到同一个桶里)。
- 迭代器不稳定(重新哈希会导致失效)。
- 需要为 key 类型提供良好的哈希函数。
如何选择?
使用
std::map
当:- 你需要元素按 key 排序。
- 你需要按顺序遍历元素。
- 你无法定义一个好的哈希函数 for your key type。
- 你非常关心最坏情况的性能(保证 O(log n)),而不是平均情况。
使用
std::unordered_map
当:- 查找速度是首要任务。
- 你不需要维护元素的任何顺序。
- 你愿意并且能够为你的 key 类型定义一个高效的哈希函数(例如,基本类型和
std::string
已有内置哈希)。
90. 介绍一下B树/B+树/红黑树及其对应的应用场景有哪些
B 树
B 树是一种多路平衡搜索树,它的每个节点可以拥有多于两个的子节点,这使得它能够保持矮胖的树形结构。
B 树的设计核心是为了减少磁盘 I/O 次数,因为它一个节点的大小通常设置为一个磁盘页的大小,一次磁盘读取就能加载一个包含多个键的巨大节点,然后在内核中进行高效的二分查找。
所以它特别适合用于文件系统和数据库(如 MySQL 的 InnoDB 存储引擎)的索引,这些场景下数据量巨大无法全部装入内存,需要频繁与磁盘交换数据。
B+ 树
B+树是 B 树的一种变体,也是目前数据库和文件系统索引的事实标准。
它与 B 树的主要区别在于:首先,它的所有数据记录都只存储在叶子节点上,内部节点只存放键作为导航用的索引;其次,叶子节点之间通过指针相连形成了一个有序链表。
这样的设计带来了几个巨大优势:一是内部节点能存放更多的键,使得树更矮,查询需要的磁盘 I/O 更少;二是范围查询性能极高,一旦找到范围的起点,只需顺着叶子节点的链表遍历即可,而不需要像 B 树那样回溯到上层节点。
所以 MySQL 的 InnoDB 引擎、MongoDB、以及几乎所有关系型数据库的索引都在用 B+树。
红黑树
红黑树则是一种二叉平衡搜索树,它通过复杂的旋转和变色规则来维持大致的平衡,确保从根到任意叶子节点的最长路径不会超过最短路径的两倍,从而保证了最坏情况下搜索、插入、删除操作的时间复杂度都是 $O(log n)$。
它的主要优势在于内存中操作的效率非常高,且维护平衡的代价相对于严格的 AVL 树更小(旋转次数更少)。
因此,它的应用场景主要集中在内存计算领域,比如 C++ STL 中的 map
和 set
就是用红黑树实现的,此外还有 Linux 系统的进程调度器 Completely Fair Scheduler (CFS) 也用红黑树来管理进程队列。
91. 智能指针申请的空间是在堆上还是在栈上?
智能指针(unique_ptr
、shared_ptr
)本身是一个栈上的对象,但它所管理的内存是在堆上申请的。
92. 介绍下 vector
动态扩容机制
std::vector
的动态扩容机制是其核心特性之一,旨在平衡内存使用与操作效率。
其本质在于管理三个核心属性:size
(当前元素数量)、capacity
(当前分配的内存可容纳的元素数量)和分配策略(通常按固定因子增长,如2倍或1.5倍)。
当执行 push_back
或 emplace_back
等插入操作时,若当前 size
已达到 capacity
,则触发扩容流程:
- 计算新容量:根据预定义的增长因子(Growth Factor,通常为2)确定新的容量值(
new_capacity = old_capacity * growth_factor
)。 - 分配新内存:在自由存储区(堆)上申请一块更大的、连续的内存空间,其大小足以容纳
new_capacity
个元素。 - 迁移数据:将原有内存中的所有元素移动(若移动构造函数为
noexcept
)或拷贝到新内存的起始位置。此步骤会调用各元素的构造函数,是扩容过程中开销最大的操作,时间复杂度为 O(N)。 - 释放旧内存:销毁原内存中的对象并释放原有内存块。
- 更新内部状态:将内部指针指向新内存块,并将
capacity
更新为new_capacity
,最后在尾部构造新插入的元素。
此机制确保了插入操作的摊还时间复杂度为 O(1)。尽管单次扩容成本较高,但由于容量呈指数级增长,扩容频率迅速下降,从而将总成本均摊到多次操作上。
需要注意的是,扩容会使所有指向原 vector
元素的迭代器、指针和引用失效,因为数据的存储地址已发生改变。因此,在已知元素数量的场景下,使用 reserve()
预先分配足够容量是避免多次冗余扩容、提升性能的最佳实践。
93. vector
的 push_back
和 emplace_back
的区别?
push_back
和 emplace_back
的核心区别在于构造对象的时机和方式。
push_back
接受一个已存在的对象,并将其拷贝(左值)或移动(右值)到容器末尾,这个过程中可能产生临时对象的开销。emplace_back
则直接接受构造参数,通过完美转发在容器末尾的内存中原地构造对象,省去了创建临时对象的步骤,避免了不必要的拷贝或移动操作,因此性能更高。
在大多数情况下,尤其是插入临时对象或构造代价较高的对象时,应优先选用 emplace_back
。
94. 关于 vector
迭代器失效
当 vector
的底层存储发生改变,尤其是内存重新分配时,指向其元素的迭代器、指针和引用都会变得不可用。
以下是导致 vector
迭代器失效的几种主要情况:
1. 插入元素 (insert
, emplace
, push_back
, emplace_back
)
插入操作是否导致迭代器失效,取决于是否触发了重新分配(Reallocation)。
- 导致重新分配:如果插入新元素后,
size()
超过了capacity()
,vector
会申请一块新的更大的内存,并将所有现有元素移动或拷贝到新内存中,然后释放旧内存。- 后果:所有迭代器、指针、引用都会失效,包括
begin()
,end()
以及所有指向元素的迭代器。
- 后果:所有迭代器、指针、引用都会失效,包括
- 未导致重新分配:如果插入后
size() <= capacity()
,则只需要将插入点之后的所有元素向后移动。- 后果:所有指向插入点之后元素的迭代器、指针、引用都会失效。插入点之前的迭代器仍然有效。
2. 删除元素 (erase
, pop_back
)
删除元素会改变序列,为了保持内存连续,需要将被删除元素之后的所有元素向前移动。
- 后果:所有指向被删除元素及其之后元素的迭代器、指针、引用都会失效。被删除元素之前的迭代器仍然有效。
- 特别注意:
erase()
函数会返回一个指向被删除元素之后第一个有效元素的新迭代器,你可以利用它来安全地继续遍历。
3. 改变容量 (reserve
, resize
, shrink_to_fit
)
任何可能改变 vector
容量(capacity
)的操作都可能引起内存重新分配。
reserve(n)
:如果n > capacity()
,则会申请新内存并进行数据迁移,导致全部失效。shrink_to_fit()
:请求减少容量以匹配大小,实现可能会进行重新分配,导致全部失效。resize(n)
:- 如果
n > capacity()
(需要扩容),则全部失效。 - 如果只是增大
size()
但未触发重分配,则end()
迭代器会失效。 - 如果是减小
size()
,则被“抹去”的那些元素的迭代器会失效。
- 如果
4. 交换 (swap
)
当两个 vector
进行交换时,它们的底层数据指针会互换。
迭代器、指针、引用不会失效,但它们会交换归属。原来指向容器 A 中元素的迭代器,在交换后指向的是容器 B 中的元素,反之亦然。
5. 清空 (clear
)
clear()
函数会移除所有元素,并将 size()
设为 0。它不保证会改变 capacity()
。
所有指向被清除元素的迭代器、指针、引用都会失效。因为元素对象已经被销毁了。
95. C++ 不同权限继承分别会有怎样的表现?
C++ 的继承有 public
、protected
、private
三种方式:
public
继承:基类的public
成员仍然是public
,protected
成员仍然是protected
;protected
继承:基类的public
和protected
成员都变成protected
;private
继承:基类的public
和protected
成员都变成private
。
96. 单例模式的概念和实现?懒汉式/饿汉式?线程安全?
概念
单例模式(Singleton)是一种创建型设计模式,保证一个类在系统中只有一个实例,并提供一个全局访问点。
- 典型实现方式是将构造函数设为
private
,在类内维护一个静态指针或引用,并通过一个static
方法获取实例。 - 这样可以确保外部无法随意构造对象,而只能通过该方法获得同一个实例。
懒汉式:在第一次调用 getInstance()
时才创建实例,节省内存,但需要考虑多线程时的同步问题。
饿汉式:在程序启动时就初始化实例,线程安全且实现简单,但可能造成资源浪费。通常通过加锁(如 mutex
)或使用双重检查锁(DCLP)来保证多线程下实例只被创建一次,同时避免每次访问都加锁带来的性能损耗。
- 前者加锁在 C++11 后一般都是利用 函数内静态变量 的线程安全初始化,而非加锁
懒汉式(线程安全)
特点:进程启动阶段就构造实例;首次使用时无需加锁;简单、安全(依赖静态对象初始化顺序规则)。
饿汉式之所以能保证线程安全,核心原因在于静态对象的初始化时机:
EagerSingleton::s_instance
是一个 静态存储期对象(全局 / namespace 作用域静态变量,或类中的静态成员)。
C++ 标准规定:
- 静态存储期对象的构造在 main 函数执行之前 完成。
- 这个初始化过程是由运行时(Runtime)在单线程环境下完成的。换句话说,在进入
main()
之前,s_instance
就已经被安全地构造好了。因此,在程序运行的并发环境里,任何线程调用
EagerSingleton::instance()
时,s_instance
已经是构造完成的对象,不存在“多个线程同时构造”的竞争条件。
1 |
|
要点:
s_instance
是静态全局对象,程序启动期构造、退出时析构。- 如果跨多个翻译单元,建议把定义放进唯一的
.cpp
中,避免 ODR 冲突。
饿汉式(线程安全)
方式 A:Meyers Singleton(推荐)
特点:利用 函数内静态变量 的线程安全初始化(C++11 起标准保证),写法最简洁。
1 |
|
优点:简洁、可靠、由标准保证一次性初始化;缺点:析构顺序不受你精细控制(通常不是问题)。
方式 B:双重检查锁(DCLP)+ std::atomic
(展示用)
在需要手动控制生命周期(如显式 destroy()
)的场景可用;写法更复杂,需小心内存序语义。多数情况下 A 足够。
1 |
|
- 首选:懒汉式的 Meyers Singleton(方式 A)或饿汉式静态对象,代码最简、标准保证线程安全。
- 仅当需要手动销毁或自定义分配策略时,再考虑 DCLP/
std::call_once
等更复杂写法。
97. 虚函数定义为析构函数能避免内存泄露的实现原理是什么?
当基类指针指向派生类对象并通过 delete
释放时,如果基类的析构函数不是虚函数,那么只会调用基类析构函数,派生类部分无法正确析构,导致资源未释放从而造成内存泄露。而将析构函数声明为虚函数后,析构时会触发虚函数表的动态绑定,先调用派生类的析构函数,再调用基类析构函数,确保对象的所有资源被正确释放。
98. 为什么哈希表不适合做索引结构?
阿里面试 · 数据库
参考链接:
这是一个非常核心的【数据库】问题。
哈希表虽然拥有 O(1) 的极致点查询性能,但它固有的特性使其在大多数情况下不适合作为数据库的主流索引结构。
数据库索引的核心需求不仅仅是“快”,更是灵活性和高效地支持多种查询模式。哈希表在这一点上存在致命缺陷。
以下是哈希表不适合做(主流的)数据库索引结构的几个关键原因:
(1) 无法支持范围查询 (Range Queries)
这是哈希索引的最大死穴。
- 哈希表的工作方式:基于哈希函数将键映射到特定的存储位置。这个映射是分散的、无序的。
id=100
和id=101
的记录可能被哈希到完全不相干的两个桶(bucket)里。 - 数据库的常见需求:查询如
WHERE id > 100 AND id < 200
、WHERE name LIKE 'A%'
、ORDER BY date
。这些查询需要数据是有序的。 - 哈希表的困境:为了完成一个范围查询,哈希表必须扫描表中的每一项,因为数据在存储上是没有顺序的。这相当于全表扫描(O(n)),完全丧失了索引的意义。而 B+树等有序索引可以高效地定位范围的起点,然后通过叶子节点的链表顺序遍历即可。
(2) 无法支持排序 (ORDER BY) 和前缀匹配 (LIKE ‘abc%’)
- 排序:
ORDER BY
子句需要数据有序。哈希表输出的数据是杂乱无章的,要排序就必须将所有结果集放入内存或进行磁盘排序,成本极高。 - 前缀匹配:
LIKE 'abc%'
本质上也是一个范围查询(从’abc’到’abd’),同样需要索引是有序的。哈希函数会把'abc'
和'abcd'
哈希成完全不同的值,无法利用索引。
(3) 哈希冲突与性能退化
- 冲突处理:再好的哈希函数也可能存在冲突(两个不同的键被映射到同一个位置)。需要额外的机制来处理(如链表法),这会在冲突发生时增加查询时间。
- 最坏情况:如果哈希函数设计不佳或数据有特定模式,大量键可能被哈希到少数几个桶中,导致某些链变得非常长。最坏情况下,查询性能会退化为 O(n),这与设计初衷背道而驰。
(4) 哈希函数的选择至关重要且困难
- 选择一个能均匀分散数据的哈希函数非常关键,但这并非易事,需要根据具体的数据分布来决定。
- 一旦数据特征发生变化,原先合适的哈希函数可能不再高效,而更换哈希函数意味着要重建整个索引,成本巨大。
(5) 不支持部分索引(最左匹配原则)
- 对于复合索引(如索引
(last_name, first_name)
),B+树可以支持只查询last_name
的查询(最左匹配)。 - 哈希索引必须使用所有键来计算哈希值。如果你只知道
last_name
而不知道first_name
,哈希函数将无法计算,索引也就完全失效。
(6) 内存使用效率
- 为了避免频繁冲突,哈希表通常需要保持较大的空闲空间(较低的负载因子,如 70%),这会导致内存/磁盘空间的浪费。
- B+树的页面填充率通常可以很高(如 70%-100%),空间利用率更好。
99. B-Tree、B+ Tree、LSM-Tree
详细探讨一下在数据库和存储系统中至关重要的三种数据结构:B 树、B+ 树和 LSM 树。
它们都是为了解决不同场景下,如何高效地管理和访问磁盘上的大量数据而设计的。
1. B 树
B 树是一种自平衡的多路搜索树,它允许每个节点有多个子节点(超过两个)。它旨在最大限度地减少磁盘 I/O 操作,因为从磁盘读取一个节点(即一个页面或块)通常需要一次 I/O,而节点内键的比较则在内存中进行,速度很快。
核心特性与结构
- 多路平衡:一个节点可以拥有多个子节点(通常是上百甚至上千个),这大大降低了树的高度。树的高度与磁盘 I/O 次数直接相关,矮胖的树比高瘦的二叉树性能好得多。
- 排序节点:每个节点中的键(Key)都是按顺序存储的。
- 节点容量:一个m 阶的 B 树(order-m)满足以下属性:
- 每个节点最多有 m 个子节点。
- 每个内部节点(非根非叶)至少有 ⌈m/2⌉ 个子节点。
- 根节点至少有两个子节点(除非它是叶子节点)。
- 所有叶子节点都位于同一层,显示出完美的平衡。
- 数据存储:在经典的 B 树定义中,所有节点(包括内部节点)都可以存储数据。
操作
- 查询:从根节点开始,在节点内部进行二分查找,找到合适的区间,然后递归地进入对应的子节点,直到找到目标键或确认键不存在。
- 插入:
- 找到应插入的叶子节点。
- 如果该节点有空间,则直接插入并排序。
- 如果节点已满,则进行分裂(Split):将中间键提升到父节点,原节点分裂成两个。这个分裂过程可能会一直向上传播到根节点,导致树增高。
- 删除:稍微复杂一些,可能涉及从兄弟节点借键(借用)或者与兄弟节点合并,以保持树的平衡和节点的最小度数要求。
优点
- 自动保持平衡,保证查询、插入、删除的时间复杂度为 O(log n)。
- 矮胖的结构减少了磁盘访问次数。
缺点(尤其是在数据库索引场景下)
- 区间查询效率相对较低:由于数据可能分布在所有节点(包括内部节点),进行范围查询(如
SELECT * FROM table WHERE key BETWEEN 10 AND 100
)时需要在树中进行多次中序遍历,效率不高。 - 节点结构相对浪费空间:每个节点既存储键,也可能存储数据,导致每个节点能存放的键数量相对减少,树的高度可能略高于 B+树。
2. B+树
B+ 树是 B 树的一种变体,是现代关系型数据库(如 MySQL 的 InnoDB、PostgreSQL)索引的事实标准。它针对 B 树在数据库应用中的缺点进行了优化。
核心特性与结构(与 B 树的主要区别)
- 数据只存储在叶子节点:内部节点只存储键(索引信息)和指向子节点的指针。这使得内部节点可以容纳更多的键,从而进一步降低树的高度。
- 叶子节点通过指针串联:所有叶子节点构成一个有序双向链表。这是实现高效区间查询的关键。
操作
- 插入和删除过程与 B 树类似,也涉及分裂和合并,但规则稍有不同,因为要维护叶子节点的链表结构。
优点(相较于 B 树)
- 更低的树高:内部节点不存储数据,可以容纳更多的键,因此在相同数据量下,B+树比 B 树更矮胖,I/O 次数更少。
- 更稳定的查询性能:任何查询都必须到达叶子节点,因此每次查找的路径长度都是相同的(O(log n))。
- 极高的区间查询效率:这是 B+树最大的优势。一旦在叶子节点上找到了范围的起始点,就可以通过叶子节点的链表指针顺序扫描,无需回溯到上层节点。
缺点
- 由于数据只存在于叶子节点,每次查询都必须到达叶子节点,单点查询性能可能略逊于 B 树(如果数据在 B 树的内部节点就找到的话),但这种差异微乎其微。
3. LSM 树
LSM 树的设计理念与 B+树完全不同。它牺牲了部分的读性能,来换取极高的写吞吐量。它广泛应用于写多读少的场景,如 Google Bigtable、HBase、Cassandra、LevelDB、RocksDB 等 NoSQL 数据库中。
设计哲学
磁盘的顺序写入速度远快于随机写入。B+ 树需要原地更新数据,可能涉及随机写入(如分裂节点)。LSM 树则通过以下方式将随机写入转换为顺序写入:
- 先写入内存和日志。
- 在后台合并和排序。
核心结构与工作流程
LSM 树通常由两个或多个主要组件构成:
MemTable:
- 一个常驻内存的数据结构(通常是跳表 SkipList 或平衡树)。
- 所有新的写入操作首先被写入 MemTable,同时也会被追加到一个预写日志(Write-Ahead Log, WAL) 中用于崩溃恢复。
- 读操作需要同时查询 MemTable 和后续的 SSTable,然后合并结果。
Immutable MemTable:
- 当 MemTable 的大小达到阈值时,它会变为只读状态(Immutable),并准备被刷写到磁盘。同时,一个新的 MemTable 会被创建来接收新的写入操作。
- 这个设计避免了读写锁冲突,保证了持续的写入性能。
SSTable (Sorted String Table):
- Immutable MemTable 被顺序、批量地写入磁盘,形成一个 SSTable 文件。
- SSTable 是不可变的(Immutable),其中的数据是按键排序的。
- 随着写入不断进行,磁盘上会积累多个 SSTable 文件。
Compaction(压缩合并):
- 这是 LSM 树的核心后台进程。它会将多个较小的、可能有键重叠的 SSTable 合并(Merge-Sort) 成一个更大的、新的 SSTable,并在这个过程中丢弃已删除或过时的数据。
- 这个过程保证了数据的有序性和减少了文件数量,从而优化读性能。
优点
- 极高的写入吞吐量:写入几乎是纯顺序 I/O(写 WAL 和刷写 SSTable),速度极快,远超 B+树的随机写入。
- 良好的压缩效率:由于 SSTable 是不可变且有序的,可以采用高效的压缩算法,节省磁盘空间。
缺点
- 读放大(Read Amplification):一次读操作可能需要检查 MemTable 和多个 SSTable 文件(使用布隆过滤器等优化技术来减少不必要的文件查找),延迟可能不如 B+树稳定且通常更高。
- 写放大(Write Amplification):Compaction 过程会反复重写数据,带来额外的磁盘写入。
- 空间放大(Space Amplification):在 Compaction 发生前,重复的数据(不同版本)或已删除的数据可能同时存在于多个 SSTable 中,暂时占用额外空间。
总结与对比
特性 | B 树 | B+树 | LSM 树 |
---|---|---|---|
设计理念 | 平衡的多路搜索树,节点存数据 | B 树的变体,数据只存于叶子,叶子有链表 | 日志结构,先内存后磁盘,顺序写入 |
最佳场景 | 通用(现在较少,多被 B+树替代) | 读多写少,频繁区间查询(OLTP,关系型数据库) | 写多读少,批量写入(时序数据,日志,NoSQL) |
写入性能 | 中等,可能涉及随机写入(节点分裂) | 中等,可能涉及随机写入(节点分裂) | 极高,基本是顺序写入 |
读取性能 | 良好(点查可能更快) | 优秀(点查和范围查都非常高效) | 相对较差,存在读放大,需要查询多个结构 |
空间开销 | 节点存储数据,树可能略高 | 内部节点只存键,更矮胖,但叶子链表有额外指针 | 写放大和空间放大(Compaction 前),但压缩率高 |
复杂度 | 中(分裂/合并) | 中(分裂/合并,维护链表) | 高(需管理 MemTable,SSTable,Compaction 策略) |
- 如果你的应用是传统的 OLTP 业务,有大量随机读和范围查询(如电商、ERP),B+ 树是首选。
- 如果你的应用是监控、日志采集、物联网传感器数据等海量写入的场景,对写入速度要求极高,并能接受稍慢的读速度,LSM 树是更优的选择。
100. placement new
的使用场景及其内存分配约束
字节面试题、华为面试题
C++ 中 placement new
的使用场景和内存分配约束。这是一个高级但非常重要的特性,常用于需要精细控制内存管理的场景。
核心概念:将“内存分配”与“对象构造”分离
通常,new
运算符做了两件事:
- 分配内存:在堆上分配足够大小的内存以容纳该类型的对象。
- 构造对象:在刚刚分配的内存上调用对象的构造函数。
delete
运算符则做了相反的两件事:
- 析构对象:调用对象的析构函数。
- 释放内存:释放对象所占用的内存。
placement new
允许你将这两个步骤分离开来。它只执行第二步(构造对象),而不分配任何内存。你负责提前提供一块内存,placement new
只是在这块你提供的内存上调用构造函数。
它的标准形式如下:
1 |
|
这里的 (preallocatedMemory)
就是“placement”参数。placement new
是 operator new
的一个重载版本。
主要使用场景
placement new
的应用几乎总是围绕着性能、自定义内存管理或特殊需求。
1. 内存池和自定义内存分配器
这是最常见和最重要的用途。
- 场景:在性能关键的系统中(如游戏引擎、高频交易),频繁地使用默认的
new
和delete
会导致堆碎片和分配开销(因为需要查找合适的内存块、维护堆数据结构等)。 - 做法:程序启动时,一次性分配一大块内存(“内存池”)。当需要创建对象时,从这块大内存中手动划分出一小块空闲内存,然后使用
placement new
在这小块内存上构造对象。对象销毁时,手动调用析构函数,然后将内存标记为空闲并归还给内存池,而不是调用delete
。 - 优点:
- 极快的分配/释放速度:只是移动指针或操作空闲链表。
- 避免内存碎片:所有对象都从连续的大块内存中分配。
- 更好的局部性:连续分配的对象在物理内存上也很可能连续,提高缓存命中率。
2. 在特定内存地址构造对象
- 场景:需要与硬件或特定系统接口交互时。例如,在嵌入式系统中,你可能需要将一个对象直接构造在某个已知的硬件寄存器地址或共享内存段上。
- 做法:直接将硬件地址或共享内存地址作为
placement new
的参数。1
volatile HardwareRegister* reg = new (0xFFFF0000) HardwareRegister;
3. 非标准内存布局(例如:数组的精确控制)
- 场景:你想手动管理一个对象数组的生命周期,或者需要绕过
new[]
和delete[]
可能带来的额外开销(例如存储数组大小的开销)。 - 做法:先分配一个足够大的
char
数组(sizeof(MyClass) * N
),然后使用循环和placement new
在正确的位置逐个构造对象。销毁时,必须手动逆序调用每个对象的析构函数,最后释放整个char
数组。
1 |
|
4. 实现类似 std::vector
和 std::make_shared
的容器和智能指针
标准库容器(如 std::vector
)在底层使用 allocator
来分配原始内存,然后使用 placement new
在已分配的内存中构造元素。这使它们能够在重新分配时(reserve
)将“分配新内存”和“移动/构造元素”分离开来。
类似地,std::make_shared
通常在一次分配中同时获得控制块(引用计数)和对象本身所需的内存,然后使用 placement new
在内存的正确偏移处构造对象。
内存分配约束与注意事项(极其重要)
使用 placement new
意味着你接管了部分编译器的职责,因此必须严格遵守以下约束:
1. 内存必须提前分配且足够大
你提供的指针 preallocatedMemory
必须指向一块已经分配好的内存,并且这块内存的大小至少为 sizeof(MyClass)
字节。如果内存不足,构造函数的行为是未定义的(通常是灾难性的崩溃或数据损坏)。
2. 内存对齐必须正确
你提供的内存地址必须满足该类型 MyClass
的内存对齐要求。C++11 之后可以使用 alignof(MyClass)
来查询对齐要求,使用 alignas(MyClass) char buf[sizeof(MyClass)];
来声明一个正确对齐的缓冲区。
错误示例:
1 | char buffer[sizeof(MyClass)]; // 一个普通的char数组,可能只按1字节对齐 |
正确做法:
1 | // C++11 之前:使用编译器扩展或union技巧 |
3. 必须手动调用析构函数
由于 placement new
不分配内存,所以标准的 delete obj;
不能使用。delete
会尝试释放内存,但你提供的内存并不是由 new
分配的,释放它会导致未定义行为。
你必须显式地调用析构函数来销毁对象:
1 | obj->~MyClass(); // 正确:只执行析构,不释放内存 |
之后,你提供的原始内存如何处理(是复用、归还给内存池还是直接释放)取决于你最初是如何分配它的。
4. 生命周期管理的责任完全在程序员
你需要确保:
- 对象在其生命周期内,其底层内存保持有效且未被覆盖。
- 不要对使用
placement new
创建的对象调用delete
。 - 在底层内存被释放或重用之前,必须调用析构函数。
总结
特性 | 标准 new /delete |
placement new |
---|---|---|
内存来源 | 堆 | 由程序员预先提供 |
执行操作 | 分配内存 + 调用构造函数 | 仅调用构造函数 |
清理操作 | 调用析构函数 + 释放内存 | 必须手动调用析构函数 |
使用场景 | 通用 | 内存池、自定义分配器、特定地址构造、容器实现 |
风险 | 低 | 高(内存对齐、手动生命周期管理) |
总而言之,placement new
是一个强大的工具,它将对象的构造与内存分配解耦,为你提供了极致的内存控制能力。然而,这种能力也带来了巨大的责任,你必须严格遵守内存对齐和生命周期管理的规则,否则极易引发难以调试的未定义行为。在大多数日常应用开发中,你不需要直接使用它,但它却是许多高性能库和系统底层实现的基石。
101. 智能指针的应用场景和线程安全问题
应用场景
智能指针用于自动化资源(尤其是动态内存)的生命周期管理,防止内存泄漏。
std::unique_ptr
(独占所有权):- 场景:适用于资源在任何时刻都只能被一个所有者拥有的情况。是资源管理的默认选择。
- 例子:作为类的成员变量、在函数内部管理动态分配的对象、作为工厂函数的返回值。它替代了需要手动
delete
的原始指针。
std::shared_ptr
(共享所有权):- 场景:适用于多个对象需要共享同一个资源,并且只有在最后一个所有者被销毁时资源才能被释放的情况。
- 例子:实现缓存机制(多个客户端可能共享同一个缓存对象)、在复杂的数据结构中(如图、节点之间可能共享所有权)、需要将指针存入多个容器中。
std::weak_ptr
(弱引用):- 场景:与
std::shared_ptr
搭配使用,解决shared_ptr
的循环引用问题。它提供对共享资源的“非拥有”引用,不会增加引用计数。 - 例子:观察者模式(主题持有观察者的
weak_ptr
,避免观察者无法析构)、缓存(持有缓存对象的weak_ptr
,当主所有者释放对象后,缓存自动失效)、打破循环引用(如双链表节点中,父节点对子节点用shared_ptr
,子节点对父节点用weak_ptr
)。
- 场景:与
线程安全问题
智能指针本身的引用计数操作是线程安全的,但其指向的对象的读写需要用户自己同步。
- 引用计数的线程安全:
std::shared_ptr
的引用计数控制块(control block)是原子操作的。因此,在多线程中复制或析构shared_ptr
本身是安全的,不会导致引用计数混乱。这是一个非常重要的保证。
- 指向数据的线程不安全:
- 多个线程同时读写同一个
shared_ptr
实例(例如,对其赋值或重置)是不安全的,需要加锁。这涉及到指针本身(get()
返回的值)的更改。 - 多个线程通过不同的
shared_ptr
实例(它们指向同一个对象)去访问和修改其指向的对象是不安全的。这属于典型的数据竞争,也是必须使用互斥锁(如std::mutex
)或其他同步机制来保护对对象本身的操作。
- 多个线程同时读写同一个
更多内容,请关注牛客面经~
…