C++补充
内存分区
C++内存主要分为以下几个部分:
栈(Stack):用于存储局部变量、函数参数、返回地址等。栈是自动管理的,数据进入作用域分配空间,离开作用域自动释放。
堆(Heap):用于动态分配内存,如使用new或malloc分配的内存。堆内存需要手动释放,否则会导致内存泄露。
全局/静态存储区:存储全局变量和静态变量。全局/静态存储区的内存在程序编译时就已经分配好,且在程序整个运行期间都存在。
常量存储区:存储常量字符串等。该区域的内存在程序编译时就已经分配好,并且内容不可更改。
代码区:存储程序的可执行代码。
void function(){
std::vector<int> a(2);
}
a
对象本身(包括管理指针和其他元数据)存储在栈区。std::vector
在内部使用指针和动态分配的内存来管理其元素,当a(2)
被构造时,会在堆上分配足够的空间来存储这两个int
元素。
多态的实现原理(实现方式)是什么?以及多态的优点(特点)?
实现方式:多态分为动态多态和静态多态(又称编译期多态,即在系统编译期间就可以确定程序将要执行哪个函数)
其中动态多态是通过虚函数实现的,虚函数是类的成员函数,存在存储虚函数指针的表叫做虚函数表,虚函数表是一个存储类成员虚函数的指针,每个指针都指向调用它的地方,当子类调用虚函数时,就会去虚表里面找自己对应的函数指针,从而实现“谁调用、实现谁”从而实现多态。
静态多态则是通过函数重载(函数名相同,参数不同,两个函数在同一作用域),运算符重载,和重定义(又叫隐藏,指的是在继承关系中,子类实现了一个和父类名字一样的函数,(只关注函数名,和参数与返回值无关)这样的话子类的函数就把父类的同名函数隐藏了。隐藏只与函数名有关,与参数没有关系)来实现的。
优点:加强代码的可扩展性,可替换性,增强程序的灵活性,提高使用效率,简化对应用代码的编写和修改过程。
final标识符的作用
放在类的后面表示该类无法被继承,也就是阻止了从类的继承,放在虚函数后面该虚函数无法被重写,表示阻止虚函数的重载
explicit关键字
只能用于修饰只有一个参数的类构造函数(有一个例外就是,当除了第一个参数以外的其他参数都有默认值的时候此关键字依然有效),它的作用是表明该构造函数是显示的,而非隐式的,跟它对应的另一个关键字是implicit,意思是隐藏的,类构造函数默认情况下声明为implicit。作用是防止类构造函数的隐式自动转换。
在std::vector
中,push_back()
和emplace_back()
都用于在末尾添加元素,但它们的实现和适用场景有所不同。
push_back()
- 功能:将一个元素拷贝或移动到容器的末尾。
- 实现方式:
push_back()
会创建一个临时对象(如果传入的是构造参数),然后将该对象拷贝或移动到vector
的末尾。对于已存在的对象传递,是直接拷贝或移动的。 - 适用场景:在需要添加一个已经创建好的对象(例如一个临时变量或函数返回的对象)时使用
push_back()
更直观。
std::vector<std::string> vec;
std::string str = "hello";
vec.push_back(str); // 拷贝 str
vec.push_back("world"); // 创建临时对象并拷贝
vec.push_back(std::move(str)); // 移动 str
emplace_back()
- 功能:直接在
vector
的末尾构造对象,避免不必要的拷贝或移动操作。 - 实现方式:
emplace_back()
会将传入的参数直接用于调用对象的构造函数,因此不会额外创建临时对象。这在效率上更高,特别是对于复杂对象来说。 - 适用场景:在需要添加新对象且不希望额外拷贝时,使用
emplace_back()
更合适,尤其在构造的过程中可以直接传入参数。
std::vector<std::string> vec;
vec.emplace_back("hello"); // 直接构造字符串,无需拷贝
vec.emplace_back(10, 'a'); // 调用 std::string(10, 'a') 构造字符串
push_back()
适用于已有对象或简单对象的插入,能更直观表达意图。emplace_back()
适合在需要构造复杂对象时使用,因为它可以避免临时对象的创建,提高效率。
类中static函数是否能声明为虚函数?
不能,因为类中的static函数是所有类实例化对象所共有的,没有this指针,而虚函数依靠vptr和vtable来处理,vptr是一个指针,在类中的构造函数中生成,并且只能通过this指针访问,对于静态成员函数来说,他没有this指针,无法访问vptr,因此static函数无法声明为虚函数
为什么存在this指针?
类和对象中的成员函数存储在公共的代码段,不同的对象调用成员函数时编译器为了知道具体操作的是哪一个对象给每个“非静态的成员函数”增加了一个隐藏的指针参数,让该指针指向当前对象,在函数体中所有成员变量的操作,都是通过这个指针来完成的由编译器自动完成。
C++ 中有四种主要的强制类型转换方式:static_cast
、dynamic_cast
、const_cast
和 reinterpret_cast
,它们各自有不同的功能和适用场景。以下是每种类型转换的特点和实现原理:
四个类型强制转换
1. static_cast
- 特点:用于在编译时执行显式的类型转换。它可以在已知不会引发错误的情况下,用于安全类型转换。
- 支持基本数据类型之间的转换,如
int
到float
。 - 支持指针或引用在类层次结构中的转换,但只能用于已知类型的上下层关系(例如父类到子类的转换)。
- 支持 void* 到其他指针类型的转换。
- 支持基本数据类型之间的转换,如
- 原理:在编译时执行,转换过程中不会进行运行时检查,因此效率较高,但不适用于多态类型的安全转换。
-
适用场景:当确定转换是合法且安全时,如基本类型之间转换、类层次结构中父类指针转换为子类指针(前提是确知转换有效)。
float f = 3.14; int i = static_cast<int>(f); // 浮点转为整数
2. dynamic_cast
- 特点:用于在运行时执行安全的类型转换,主要用于指针或引用之间的转换。只能用于带有虚函数的多态类(即 RTTI,运行时类型识别)。
- 如果转换失败,指针类型会返回
nullptr
,引用类型会抛出bad_cast
异常。 - 只能用于类层次结构中基类和派生类之间的转换。
- 如果转换失败,指针类型会返回
- 原理:依赖于 RTTI 机制,在运行时检查类型安全性,适用于基类指针向派生类指针的转换(即向下转型)。
-
适用场景:当需要在类层次结构中安全地进行向下转换,并且类是多态类(带有虚函数表)时。
class Base { virtual void func() {} }; // 必须是多态类 class Derived : public Base {}; Base* b = new Derived; Derived* d = dynamic_cast<Derived*>(b); // 转换成功
3. const_cast
- 特点:用于去掉或添加
const
或volatile
修饰符,通常用于处理需要修改const
对象的场景。- 只能更改对象的
const
或volatile
属性,不能用于其他类型的转换。
- 只能更改对象的
- 原理:编译时直接更改类型的
const
或volatile
修饰,不影响数据在内存中的布局。 -
适用场景:当需要在调用接口时移除
const
属性,如需要对const
对象进行某些不可更改操作,或需要传递const
对象给非const
函数。const int a = 10; int* p = const_cast<int*>(&a); // 去掉 const 修饰
4. reinterpret_cast
- 特点:用于进行极为底层的、类型上不安全的强制转换。它可以将任何指针类型转换为其他指针类型,或将整数转换为指针类型。
- 不同类型的指针之间、整数和指针之间可以相互转换。
- 该转换并不更改数据的底层二进制表示。
- 原理:直接重新解释内存中的二进制内容,将指针或数据的类型重新解释为目标类型。转换后数据的行为可能无法预测,因此需慎重使用。
-
适用场景:当需要进行底层数据操作(如位操作或字节处理)时,或者需要将指针类型和整数类型之间转换时。
int i = 65; char* p = reinterpret_cast<char*>(&i); // 将 int* 转为 char*
转换类型 | 适用范围 | 运行时检查 | 主要功能 |
---|---|---|---|
static_cast |
编译时可确定的安全转换 | 否 | 基本类型转换、类层次结构的向上转换 |
dynamic_cast |
类层次结构中的向下转换,适用于多态类型 | 是 | 多态类的运行时类型检查,确保转换安全 |
const_cast |
去掉或添加const 或volatile 修饰 |
否 | 改变const 或volatile 修饰,主要用于修改const 对象 |
reinterpret_cast |
低层次、不安全的类型转换 | 否 | 直接重新解释数据的二进制表示,适用于指针和整数之间的转换 |
使用建议
- 优先选择
static_cast
和dynamic_cast
,因为它们更安全、用途更清晰。 const_cast
仅在必要时使用(如函数接口限制)。reinterpret_cast
应慎用,避免带来不易预期的错误和不稳定因素。
string实现
#include <iostream>
#include <cstring> // for strlen, strcpy
class MyString {
private:
char* data;
size_t len;
size_t capacity;
public:
// 默认构造函数
MyString() : data(nullptr), len(0), capacity(0) {}
// 带参数构造函数
MyString(const char* str) {
len = strlen(str);
capacity = len;
data = new char[capacity + 1];
strcpy(data, str);
}
// 拷贝构造函数(深拷贝)
MyString(const MyString& other) {
len = other.len;
capacity = other.capacity;
data = new char[capacity + 1];
strcpy(data, other.data);
}
// 移动构造函数
MyString(MyString&& other) noexcept : data(other.data), len(other.len), capacity(other.capacity) {
other.data = nullptr;
other.len = 0;
other.capacity = 0;
}
// 析构函数
~MyString() {
delete[] data;
}
// 赋值运算符重载
MyString& operator=(const MyString& other) {
if (this != &other) {
delete[] data; // 释放旧的内存
len = other.len;
capacity = other.capacity;
data = new char[capacity + 1];
strcpy(data, other.data);
}
return *this;
}
// 移动赋值运算符
MyString& operator=(MyString&& other) noexcept {
if (this != &other) {
delete[] data;
data = other.data;
len = other.len;
capacity = other.capacity;
other.data = nullptr;
other.len = 0;
other.capacity = 0;
}
return *this;
}
// 获取字符串长度
size_t size() const {
return len;
}
// 获取容量
size_t getCapacity() const {
return capacity;
}
// 检索字符串中的字符
char& operator[](size_t index) {
if (index >= len) {
throw std::out_of_range("Index out of range");
}
return data[index];
}
const char& operator[](size_t index) const {
if (index >= len) {
throw std::out_of_range("Index out of range");
}
return data[index];
}
// 添加字符到末尾
void push_back(char c) {
if (len + 1 >= capacity) {
resize(capacity == 0 ? 1 : capacity * 2);
}
data[len++] = c;
data[len] = '\0';
}
// 追加字符串
void append(const MyString& other) {
if (len + other.len >= capacity) {
resize(len + other.len);
}
strcat(data, other.data);
len += other.len;
}
// 打印字符串
void print() const {
if (data) {
std::cout << data << std::endl;
} else {
std::cout << "(empty)" << std::endl;
}
}
private:
// 重新分配内存
void resize(size_t new_capacity) {
char* new_data = new char[new_capacity + 1];
if (data) {
strcpy(new_data, data);
}
delete[] data;
data = new_data;
capacity = new_capacity;
}
};
什么是模板的全特化?
模板的全特化是在模板的原始定义之外针对某个特定类型,提供模板的特定实现。当我们为一个模板指定了所有模板参数的具体类型时,就是全特化。全特化完全替代了通用模板,对于特定的类型,编译器将使用全特化的版本。
为什么要使用全特化?
- 定制化特定类型的行为:对于某些特殊类型,需要不同于通用模板的实现。
- 优化性能:针对特定类型,提供更高效的实现。
- 解决特定类型的特殊需求:处理某些类型无法在通用模板中处理的特殊情况。
什么是模板的偏特化?
模板的偏特化是对模板参数进行部分特化,即不需要指定所有模板参数,而是针对某些参数或某些参数特性(如指针、引用、特定的类型模式)进行特化。
为什么要使用偏特化?
- 更灵活的特化方式:可以针对一类类型进行特化,而不局限于某个具体类型。
- 处理类型的某些特征:如指针类型、引用类型、数组类型等。
- 提高代码的复用性和可维护性:通过偏特化,可以减少重复代码。
偏特化只能用于类模板,不能直接对函数模板进行偏特化(但可以通过重载实现类似效果)。在偏特化时,需要在模板参数列表和类名中同时体现特化的部分。 编译器会优先选择最匹配的特化版本。如果有多个特化版本可以匹配,可能会导致编译错误,需要避免这种情况。
const关键字
- 修饰变量:
const
变量的值在初始化后不能被更改。 - 修饰指针,可以细分为三种情况:
- 指向常量的指针:指针指向的内容不可更改,但指针本身可以指向其他地址。
- 常量指针:指针本身不可更改,但指向的内容可以更改。
- 修饰函数参数,可以避免在函数内部修改该参数的值,这在传递引用或指针参数时尤为重要。
void printValue(const int &value) { // value = 10; // 错误,value 被 const 修饰 std::cout << value << std::endl; }
- 修饰类成员函数,表示该函数不会修改类的成员变量。只能在不修改类状态的成员函数上使用
const
,适用于如 get 函数等。 - 修饰返回值,在函数返回类型前加
const
可防止返回值在调用处被修改,例如返回对象的引用时防止被调用方修改。const int& getConstReference() { static int x = 10; return x; }
volatile 关键字
volatile
告知编译器不要对变量进行优化,使程序在每次访问变量时重新从内存中读取数据。这种关键字通常用于特殊情况下,如硬件寄存器访问、多线程共享标志或中断处理程序中,以确保读取到的值是最新的。
例如:在多线程编程中,某些变量可能会被多个线程共享,可能会在一个线程中修改并在另一个线程中读取。如果没有 volatile 关键字,编译器可能会将该变量缓存到寄存器中,导致其他线程无法读取到最新的值。
const volatile什么意思
const volatile
修饰的变量是只读的(不能被程序代码修改)并且易变的(编译器不能优化读取操作,必须每次都从内存中读取最新值)。常见于需要频繁读取最新值但不修改的场景,例如硬件寄存器或跨线程共享的只读标志。
例如:在多线程环境中,如果一个共享标志变量仅用于指示某种状态(如停止信号),一个线程可能会设置该标志,而其他线程只能读取该标志的值且不应修改它。将这个变量声明为 const volatile
可以避免优化,并确保读取到最新的值。
C++内存模型,堆里面的内部碎片和外部碎片
在 C++ 中,内存模型描述了程序如何管理和使用内存的布局。通常来说,C++ 内存模型分为以下几个区域:
-
栈区(Stack):用于存储局部变量、函数调用时的参数和返回地址等。栈的内存是自动管理的,遵循“后进先出”的原则,即函数结束后栈内的局部变量自动销毁。栈内存分配效率高,但空间有限。
-
堆区(Heap):用于动态分配的内存(例如通过
new
、malloc
等)。堆内存由程序员手动管理,必须显式分配和释放。堆的大小仅受系统内存限制,适用于大数据结构和需要跨函数保留的数据。 -
全局区(静态存储区):用于存储全局变量、静态变量和常量字符串,程序在启动时分配,直到程序结束才释放。
-
常量存储区:存放常量字符串,程序结束后由编译器释放。
-
代码区:存放程序的机器指令,由操作系统分配,用于存储程序的可执行代码,通常只读。
堆内存中的内部碎片和外部碎片
在堆内存中,碎片化是指内存管理系统由于分配和释放不同大小的内存块而导致的空闲内存片段。碎片化分为 内部碎片 和 外部碎片。
1. 内部碎片
内部碎片是指分配的内存块中,因分配粒度或对齐的原因,实际使用的内存少于分配的内存,造成内存浪费。例如,当分配一个较小的对象(如 5 字节),但由于内存对齐的要求,分配器可能会为它分配 8 字节,这其中多余的 3 字节就是内部碎片。
内部碎片主要发生在以下情况下:
- 内存对齐:为确保内存访问的效率,很多系统要求内存按照特定的字节对齐(如 4 字节、8 字节)。如果请求的内存块不满足对齐要求,分配器会分配比请求更多的内存。
- 固定大小的分配单元:某些分配器使用固定大小的内存块进行分配,如果请求的内存小于这个块的大小,则剩余部分形成内部碎片。
2. 外部碎片
外部碎片是指堆中存在很多小的、相互分散的空闲内存块,但这些内存块彼此不连续,因此无法满足较大的内存分配请求。例如,如果程序频繁分配和释放不同大小的内存块,可能会留下分散的小空闲块。这些空闲块的总和足够大,但因为不连续,导致无法分配较大的内存块。
外部碎片会影响程序性能,降低内存分配效率,可能导致内存不足的假象。堆内存的外部碎片主要通过以下方式产生:
- 不规则的内存分配与释放:程序频繁分配和释放不同大小的内存块,导致空闲块分散。
- 缺乏内存整理:一些内存分配器不会自动合并相邻的空闲内存块,从而导致外部碎片。
内存碎片的处理方法
- 内存池:预分配一定数量的内存块以满足相似大小的内存请求,从而减少内存碎片。
- 紧凑分配:一些内存管理器会自动合并相邻的空闲块,减少外部碎片。
- 自定义分配器:对特定数据结构自定义内存分配器,以便更有效地管理内存分配。
一般C++的类的memory layout有哪些成分,C++的对象在内存上长什么样?如果涉及到继承呢?
- 基本类的内存布局
一个没有继承关系、虚函数或虚继承的简单 C++ 类的内存布局由以下成分组成:
- 成员变量: 按照声明顺序存储在对象内存布局中,但编译器可能会根据内存对齐要求调整实际顺序。
- 对齐填充: 为了满足内存对齐要求,编译器会在成员变量之间或对象的结尾填充一些字节,以确保对象在内存中对齐。
-
带有虚函数的类的内存布局 当一个类包含虚函数时,编译器会在该类的对象布局中添加一个 虚函数表指针(vptr),指向该类的虚函数表(
vtable
)。vtable
是一个指针数组,指向类的虚函数实现。这使得运行时可以通过多态性动态调用不同的函数。在这种情况下,Base 类对象的内存布局会包含以下成分:- vptr:指向虚函数表的指针,存储在对象的头部或其他位置。
- 成员变量:x。 虚函数表(vtable)则存储在程序的只读区域(常量存储区)中。
- 带有单继承的类的内存布局
在单继承情况下,派生类继承基类的成员变量和 vptr(如果基类有虚函数)。派生类对象的布局如下:
- vptr:如果基类或派生类定义了虚函数,那么 vptr 会在对象中存在。如果基类已有虚函数表,派生类会复用或重写这个 vptr。
- 基类成员:继承基类的成员变量,保持基类的布局。
- 派生类自己的成员:存储在基类成员之后。
class Derived : public Base { public: double y; virtual void func3(); };
在这种情况下,Derived 的内存布局包括:
- vptr:指向 Derived 类的 vtable,因为 Derived 覆盖了或增加了虚函数。
- 基类成员:继承的 x。
- 派生类成员:y。
- 多重继承的内存布局
class Base1 { public: int a; virtual void func1(); }; class Base2 { public: int b; virtual void func2(); }; class Derived : public Base1, public Base2 { public: double c; };
在这种情况下,Derived 的布局如下:
- Base1 的 vptr:指向 Base1 的 vtable。
- Base1 的成员:a。
- Base2 的 vptr:指向 Base2 的 vtable。
- Base2 的成员:b。
- Derived 的成员:c。
- 虚继承的内存布局
在虚继承中,派生类会共享基类的一个实例,从而避免多重继承中的菱形继承问题。编译器会为虚继承类添加一个 虚基类指针(vptr) 或 虚基类表指针(vbptr),用于定位虚基类在对象内存中的位置。
class Base { public: int x; }; class Derived1 : virtual public Base { /* ... */ }; class Derived2 : virtual public Base { /* ... */ }; class MostDerived : public Derived1, public Derived2 { // ... };
在这种情况下,MostDerived 的布局可能包含:
- Derived1的 vfptr
- Derived1 的 vbptr:用于指向 Base 类的虚基类位置。
- Derived1的类成员
- Derived2的 vfptr
- Derived2 的 vbptr:用于指向 Base 类的虚基类位置。
- Derived2的类成员
- Base vfptr
- 虚基类 Base:Base 的实例在内存中只有一份,且通过 vbptr 进行定位。
虚继承
虚继承(Virtual Inheritance)是一种特殊的继承方式,主要用于解决多重继承中的菱形继承(或称钻石继承)问题。菱形继承问题通常会导致重复继承基类成员,造成 冗余和二义性,而虚继承可以确保派生类共享基类的唯一实例。
class Base {
public:
int value;
};
class Derived1 : public virtual Base { /* ... */ };
class Derived2 : public virtual Base { /* ... */ };
class MostDerived : public Derived1, public Derived2 { /* ... */ };
在启用虚继承后,C++ 编译器会在 Derived1 和 Derived2 中添加一个虚基类表指针(vbptr),指向 Base 的唯一实例位置。在实例化 MostDerived 时,编译器会根据虚基类表找到并共享 Base 的实例。
因此,MostDerived 的内存布局可能如下:
- Derived1 的 vbptr:用于指向 Base 的唯一实例。
- Derived2 的 vbptr:用于指向 Base 的唯一实例。
- 唯一的 Base 实例。
- MostDerived 自己的成员(如果有)
如果一个类继承了有虚函数的类,父类会存在在子类中吗
会的,派生类对象中包含基类的数据成员,因此在派生类对象中基类的部分会存在。
父类转子类安全吗?子类转父类呢?
基类指针或引用转换为派生类指针或引用通常是不安全的,因为基类对象不一定包含派生类的所有成员。如果直接将一个基类对象的指针或引用转换为派生类类型,并尝试访问派生类的成员,可能会导致未定义行为。
如果基类指针或引用实际上指向的是一个派生类对象,则可以通过 dynamic_cast
进行安全的转换。dynamic_cast
会在运行时检查类型,如果转换失败会返回 nullptr
(对于指针)或抛出 bad_cast
异常(对于引用)。
将派生类指针或引用转换为基类指针或引用通常是安全的。因为派生类包含基类的所有成员,因此将派生类对象视为基类对象不会导致内存或成员访问问题。这种转换可以通过 隐式转换 或 static_cast 安全地完成。
假设是64位的机器,一个空的类占多大内存,如果这个类包含一个虚函数呢?如果有一个类继承了空类,大小是多少(分类讨论)?
- 空类:通常占
1
字节。 - 含虚函数的空类:占
8
字节(在 64 位系统上,为虚函数表指针大小)。 - 继承自空类的类:
- 如果没有额外成员,通常应用空基类优化,占
1
字节。 - 如果包含其他成员,则仅占成员的大小(空基类不增加额外空间)。
- 如果没有额外成员,通常应用空基类优化,占
- 继承自含虚函数的空类的类:大小至少为
8
字节(包含vptr
),即使派生类本身没有增加成员。 -
虚表里除了可能有虚函数,还可能有什么?
- 虚表通常包含一个指向类型信息的指针,用于支持 typeid 和 dynamic_cast 等操作。这个指针指向的结构包含了当前类的类型信息,以便在运行时能够识别对象的实际类型。
- 在涉及 虚继承 的多重继承中,虚表还可能包含 虚基类偏移指针,用于支持虚基类的定位。当派生类通过虚继承的方式继承基类时,虚基类在对象布局中的位置并不是固定的,因为不同的派生路径会共享同一个虚基类实例。
如果一个函数是成员模板函数,可以被声明为虚函数吗?
在 C++ 中,成员模板函数不能被声明为虚函数。这是因为模板函数的机制和虚函数的机制在设计和实现上存在冲突。
-
模板函数的编译时多态性:
模板函数是通过 编译时多态 实现的。编译器会在编译期间根据模板的具体类型生成函数的代码,即模板函数的实例化发生在编译时。这意味着,模板函数在编译时会根据使用的类型参数生成特定版本的代码。 -
虚函数的运行时多态性:
虚函数依赖于 运行时多态,通过虚函数表(vtable)在运行时动态绑定函数。这要求在程序执行时通过虚表指针(vptr
)来调用合适的虚函数实现。
由于模板函数在编译时实例化,其生成的具体函数可能是无限多种类型的组合,无法在虚表中预先创建一个模板函数的实例。因此,C++ 标准不允许将模板成员函数声明为虚函数。
虚函数可以内联吗?
在 C++ 中,虚函数可以被声明为内联函数,但是在实际调用中是否会被内联展开,取决于编译器是否能够在编译时确定具体的调用目标。虚函数的运行时多态性机制使得它们通常不符合内联的条件,但在特定情况下,虚函数可以被内联。
-
虚函数的运行时多态性:
虚函数是通过虚函数表(vtable)实现运行时多态性的,在调用时根据对象的实际类型选择具体的函数实现。这种机制通常需要在运行时确定调用的具体实现,因此通常通过基类指针或引用调用虚函数时,编译器在编译期无法确定具体的实现。 -
内联的编译时展开:
内联函数的目的是在编译时直接用函数体替换函数调用,从而避免函数调用的开销(如栈帧管理和参数传递)。这要求编译器在编译期就知道函数的具体实现,以便将其展开。
虚函数的运行时多态特性和内联函数的编译时展开机制存在矛盾,因此虚函数通常难以被内联。
- 虚函数可以声明为内联,但实际内联展开取决于编译器的判断。
- 如果通过具体对象(非指针或引用)调用虚函数,编译器可能会内联。
- 非多态调用(例如派生类中直接调用基类的虚函数)也可能被内联。
- 典型的多态调用(通过基类指针或引用调用虚函数)通常无法内联,因为编译器在编译期无法确定具体实现。
-
optional
取size是多大? 大小通常为 2 个字节,因为需要额外空间来存储“是否包含值”的状态。
std::optional
是 C++17 引入的一个标准库模板类,定义在 <optional>
头文件中。它用于表示一个可能包含值、也可能不包含值的对象,通常用于替代返回空指针或特殊值来表示“值缺失”的情况。
std::optional
的主要特点
- 可选值:
std::optional<T>
是一个包装类型,它可以存储类型T
的一个值,或不存储任何值。 - 语义明确:
std::optional
可以更清晰地表达“值可能不存在”这种语义,而不必使用nullptr
或特定的标记值(如-1
或0
)来表示。 - 安全:在函数返回类型中使用
std::optional
可以避免返回裸指针或特定值带来的潜在风险,增强代码的安全性和可读性。
std::optional
的典型用法
-
替代返回值:
std::optional
常用于表示函数的返回值可能为空。例如,一个查找函数可以返回std::optional
表示找到的值或未找到的情况。 -
替代指针:
在某些情况下,std::optional
可以用来代替指针,从而避免使用空指针(nullptr
)来表示值的缺失。 -
可选参数:
可以用std::optional
来表示函数的可选参数,而不是重载函数或设置默认值。
主要成员和操作
- 默认构造函数:构造一个不包含值的
std::optional
。 std::nullopt
:一个表示空状态的特殊常量,可以赋给std::optional
来表示没有值。operator bool
:可以将std::optional
转换为布尔值,以判断是否包含值。value()
:获取optional
中的值。如果没有值,则会抛出std::bad_optional_access
异常。-
value_or(default_value)
:如果有值,返回值;否则返回default_value
。 - 提供了更安全、语义更明确的方式来表示“可选值”。
- 避免了返回特殊值或空指针的风险。
- 增强代码的可读性和维护性。
描述一下C++编译的整个过程
整个 C++ 编译过程的各个阶段如下:
- 预处理:处理宏、头文件和条件编译指令,生成纯净的.i代码文件。
- 编译:将预处理后的代码编译为.s汇编代码,进行语法检查和优化。
- 汇编:将汇编代码转换为机器码,生成.o目标文件。
- 链接:将多个目标文件和库文件整合,解析符号,生成可执行文件。
如果头文件定义了函数,源文件不实现,会在哪个环节报错?如果构建的是静态库,会报错吗?
普通程序(非库)中在 链接阶段 会出现报错。这是因为编译器在编译每个源文件时并不会检查函数的实现是否存在,它只检查函数的声明是否存在。当所有源文件编译成目标文件后,链接器会尝试将所有的符号(包括函数和变量)解析到具体的实现位置。
当构建静态库时:
- 如果头文件中声明的函数没有实现,那么构建库时不会报错。
- 这是因为静态库的构建过程仅涉及将各个目标文件打包到一个归档文件中(如 .a 文件),并不会执行链接过程。
- 因此,即使静态库中存在未实现的函数,只要没有实际使用该函数,就不会触发错误。
- 即便是库内部调用了该函数也不会报错。(因为静态库只进行编译、汇编生成目标文件,并不会进行链接,这与动态库不同。)
但是,当静态库被其他程序链接并使用到未实现的函数时,链接器会在 链接阶段 报错,因为它无法在静态库或程序中找到该函数的定义。
对于动态库:
- 构建动态库时,如果未实现的函数没有在库内部被调用,不会报错;但如果库内部代码引用了未实现的函数,则在链接阶段会报错。
- 在客户端使用动态库时,编译和链接阶段一般不会报错,因为动态库的符号解析是在运行时进行的,但运行时若调用了未实现的函数,动态链接器会尝试解析该符号,找不到实现时会导致运行时错误,报出类似“未定义符号”的错误。。
- 延迟加载机制 可能使得未实现的函数仅在被调用时引发运行时错误,而不会在程序加载时立即报错。
对静态库和动态库的理解
1. 静态库(Static Library)
静态库是在编译时被直接链接到程序中的库。它通常是一个归档文件(例如 .lib
在 Windows 上或 .a
在 Unix/Linux 上),包含了多个目标文件(编译生成的 .o
文件),每个目标文件中包含了一组函数或类的实现。
- 编译时链接:在程序编译过程中,静态库会被直接嵌入到生成的可执行文件中。链接完成后,库的代码被复制到每个使用它的可执行文件中。
- 独立性:由于静态库的代码在编译时已嵌入可执行文件中,运行时不需要额外的库文件。这使得静态链接的程序可以独立分发,无需依赖外部库。
- 占用更多磁盘空间:因为每个使用静态库的程序都会包含一份库的代码,所以多个程序使用同一个静态库时会有重复的代码拷贝,从而占用更多磁盘空间。
- 性能:在性能上,由于库代码已经嵌入可执行文件,不需要在运行时加载,因此启动速度可能比动态库稍快。
- 更新困难:如果静态库有更新,需要重新编译链接所有使用它的程序,生成新的可执行文件。
适用场景
- 嵌入式系统或独立分发:当程序需要独立运行,不依赖外部库(如某些嵌入式系统)时,可以使用静态库。
- 性能要求高:在对启动速度要求较高的场景中,由于静态库无需加载,可以减少启动时的开销。
2. 动态库(Dynamic Library)
动态库是在运行时被加载到内存中并链接到程序的库。动态库的文件扩展名通常是 .dll
(Windows)或 .so
(Unix/Linux)。在程序运行时,操作系统会将动态库加载到内存中,并将程序中的函数调用链接到动态库中的函数实现。
- 运行时链接:程序在启动时或在需要时加载动态库,这种链接方式称为延迟链接或运行时链接。它允许多个程序共享同一个动态库,节省内存空间。
- 节省内存:多个运行中的程序可以共享同一个动态库的内存实例,而不需要每个程序都包含一份库的代码,这样可以显著节省内存资源。
- 更新灵活:因为动态库在程序运行时加载,如果动态库有更新,不需要重新编译程序。只需用新版本的动态库替换旧版本即可实现程序的更新。
- 启动时加载或延迟加载:动态库可以在程序启动时加载,也可以在程序运行期间按需加载,从而提高启动速度并减少不必要的资源消耗。
适用场景
- 共享库:当多个程序需要共享库代码,并且希望尽可能节省内存资源时,动态库是一个好的选择。
- 便于更新和维护:动态库更适合需要频繁更新的系统,尤其是在大型系统中,可以随时替换动态库而不影响程序的其他部分。
特性 | 静态库 | 动态库 |
---|---|---|
链接时间 | 编译时链接 | 运行时链接 |
文件大小 | 更大,代码复制到每个可执行文件中 | 更小,多个程序共享同一个库 |
内存占用 | 每个进程独立占用内存 | 多个进程共享内存 |
启动速度 | 较快(不需要加载库) | 稍慢(需要加载库) |
更新方式 | 需要重新编译、链接 | 替换库文件即可 |
应用场景 | 独立分发、嵌入式系统 | 共享库、频繁更新的系统 |
总结
- 静态库:适用于不希望程序依赖外部文件的场景,启动速度快,但占用磁盘空间较大,不易更新。
- 动态库:适用于需要多个程序共享库、需要频繁更新库的场景,占用内存少,便于维护和更新,但启动时稍有开销。
在实际开发中,选择静态库还是动态库通常取决于应用程序的需求、性能要求、维护方式以及系统架构的设计。
一个shared_ptr大小是多大,unique_ptr呢?不同智能指针性能上有什么区别?如果只是用指针解引用,性能上有区别吗?
-
std::unique_ptr
的大小std::unique_ptr
的大小通常是 一个指针的大小,即 8 字节(在 64 位系统上)。这是因为std::unique_ptr
只需要一个指针来管理动态分配的对象。std::unique_ptr
是一个独占所有权的智能指针,意味着同一时刻只能有一个std::unique_ptr
指向特定的对象。- 在对象被销毁或
unique_ptr
被重置时,它会自动释放所管理的资源。 - 因为没有额外的引用计数等信息,所以
unique_ptr
的内存开销较小。
-
std::shared_ptr
的大小std::shared_ptr
的大小通常是 两个指针的大小,即 16 字节(在 64 位系统上)。这两个指针分别指向:- 被管理的对象。
- 一个 控制块(control block),包含引用计数等信息。
不同智能指针的性能对比
-
std::unique_ptr
的性能- 独占所有权:
unique_ptr
的独占所有权机制使它不需要引用计数,因此操作非常轻量。 - 无额外开销:由于没有控制块和引用计数,
unique_ptr
在创建、销毁、复制和移动时的开销都非常小。 - 适合单线程场景:
unique_ptr
的独占所有权机制不需要多线程同步,因此在单线程场景中性能非常好。
- 独占所有权:
-
std::shared_ptr
的性能- 引用计数:
shared_ptr
使用控制块来跟踪引用计数,因此在每次创建、复制和销毁时都需要对引用计数进行增减操作。这增加了额外的开销。 - 线程安全:
shared_ptr
的引用计数更新是线程安全的,这意味着在多线程环境中,可以安全地共享shared_ptr
对象。然而,为了保证线程安全,shared_ptr
引用计数的增减通常会使用原子操作,这在多线程环境中会带来额外的开销。 - 适合多线程共享资源:当需要多个对象或多个线程共享同一个动态分配的对象时,
shared_ptr
是合适的选择,但在频繁的创建、销毁、复制操作中,其性能劣于unique_ptr
。
- 引用计数:
在智能指针解引用方面,std::shared_ptr
和 std::unique_ptr
的性能几乎是相同的,因为解引用操作只是获取被管理对象的地址,并不涉及控制块或引用计数的更改。
在实际应用中,应该根据需要选择合适的智能指针类型:若对象需要唯一所有权且性能要求高,使用 unique_ptr
;若对象需要共享所有权,特别是在多线程场景下,使用 shared_ptr
。
一个 shared_ptr
指向另一个地址时,操作的复杂性和开销主要来源于以下几个因素:
1. 引用计数的递减和递增
当你将 shared_ptr
重新指向另一个地址时,操作系统会执行以下步骤:
- 递减旧对象的引用计数:将
shared_ptr
从旧对象指向新对象时,会先减少旧对象的引用计数,如果引用计数降为 0,旧对象会被销毁。 - 递增新对象的引用计数:接着,将
shared_ptr
指向的新对象的引用计数加 1。
这些操作是原子操作,在多线程环境中具有一定的开销,尤其当多个线程频繁执行这样的操作时。每次引用计数的增减都会触发缓存同步和内存屏障,增加系统开销。
2. 旧对象的销毁(若引用计数归零)
如果原来指向的对象在重置后引用计数降为 0,shared_ptr
会销毁该对象:
- 析构函数的调用:当引用计数降为 0 时,
shared_ptr
会调用对象的析构函数。对象的析构可能需要释放资源,如文件句柄、内存等。这会带来一定的额外开销。 - 释放控制块:
shared_ptr
的控制块也会被销毁和释放,这涉及一次堆内存的释放操作。如果频繁重置shared_ptr
,多次内存分配和释放会增加开销。
3. 在多线程环境中,增减引用计数的竞争
当多个线程频繁对同一个 shared_ptr
进行重置操作时,增减引用计数的竞争会进一步增加性能开销:
- 竞争条件:在多线程环境中,如果多个线程频繁地对同一个
shared_ptr
重置为不同对象,则引用计数的增减操作可能发生竞争,导致性能下降。每次引用计数的更改都会触发原子操作的锁定,影响 CPU 的缓存一致性。 - 伪共享:当多个线程频繁更新同一
shared_ptr
的引用计数,这些操作可能发生在同一缓存行上,导致伪共享,进一步增加缓存一致性协议的压力。
4. 内存分配和释放的频率
如果 shared_ptr
重新指向新地址的操作非常频繁,且每次都是指向不同的对象,会导致以下开销:
- 频繁的控制块分配和释放:每次指向一个新对象时,
shared_ptr
会为新对象分配一个控制块。频繁的内存分配和释放会影响程序的内存管理性能,尤其是在堆上进行小块内存分配时,开销较大。 - 潜在的内存碎片化:频繁分配和释放小块内存可能会导致内存碎片化,进一步影响内存分配效率。
5. 析构和构造的开销
每次 shared_ptr
被重置到不同的对象时,如果新的对象和旧对象的生命周期需要被管理,就可能会多次调用构造和析构函数。特别是当指向的对象包含复杂的成员或资源时,这些构造和析构函数的开销也会加大。
6. 避免开销的方法
为了减少上述操作带来的开销,可以考虑以下方法:
- 使用
weak_ptr
:如果只是短暂指向新对象,可以考虑使用weak_ptr
,因为它不会更改引用计数,减少了原子操作的开销。 - 使用
std::move
:如果旧对象不再需要,可以使用std::move
将shared_ptr
直接转移给另一个shared_ptr
,避免引用计数的增减。 - 使用
reset()
重置为 nullptr:如果shared_ptr
已经不再需要,可以先使用reset()
将其重置为nullptr
,这样旧对象会立即释放,再赋值新对象,避免频繁的引用计数竞争。
手动实现shared_ptr和unique_ptr
手动实现 std::shared_ptr
和 std::unique_ptr
的核心在于理解它们的基本特性:std::unique_ptr
实现独占所有权,而 std::shared_ptr
则实现共享所有权。为了实现这两个智能指针类,我们将从管理动态内存的角度来实现这两种指针的基本功能。
-
手动实现
unique_ptr
unique_ptr
具有独占所有权,因此它只需持有一个指向对象的指针,并在析构时删除它。复制unique_ptr
是不允许的,但它可以通过移动语义转移所有权。#include <iostream> template <typename T> class MyUniquePtr { private: T* ptr; public: // 构造函数 explicit MyUniquePtr(T* p = nullptr) : ptr(p) {} // 禁止拷贝构造和赋值 MyUniquePtr(const MyUniquePtr&) = delete; MyUniquePtr& operator=(const MyUniquePtr&) = delete; // 移动构造函数 MyUniquePtr(MyUniquePtr&& other) noexcept : ptr(other.ptr) { other.ptr = nullptr; } // 移动赋值操作符 MyUniquePtr& operator=(MyUniquePtr&& other) noexcept { if (this != &other) { delete ptr; // 释放当前对象 ptr = other.ptr; // 转移所有权 other.ptr = nullptr; // 清空源对象 } return *this; } // 解引用操作符 T& operator*() const { return *ptr; } T* operator->() const { return ptr; } // 获取原始指针 T* get() const { return ptr; } // 释放所有权 T* release() { T* temp = ptr; ptr = nullptr; return temp; } // 重置指针 void reset(T* p = nullptr) { delete ptr; ptr = p; } // 析构函数 ~MyUniquePtr() { delete ptr; } }; int main() { MyUniquePtr<int> p1(new int(42)); std::cout << *p1 << std::endl; MyUniquePtr<int> p2 = std::move(p1); // 移动所有权 if (!p1.get()) { std::cout << "p1 is now empty." << std::endl; } std::cout << *p2 << std::endl; return 0; }
- 禁止拷贝构造和赋值:通过删除拷贝构造函数和赋值操作符,确保
unique_ptr
的独占所有权特性。 - 移动构造和移动赋值:实现了移动构造函数和移动赋值操作符,允许所有权的转移。
- 析构:在析构函数中释放所管理的资源。
- 禁止拷贝构造和赋值:通过删除拷贝构造函数和赋值操作符,确保
-
手动实现
shared_ptr
shared_ptr
允许多个对象共享所有权,因此需要一个引用计数来跟踪有多少个shared_ptr
指向同一个对象。当引用计数减为零时才释放资源。#include <iostream> template <typename T> class MySharedPtr { private: T* ptr; int* ref_count; void release() { if (ref_count && --(*ref_count) == 0) { delete ptr; delete ref_count; } } public: // 构造函数 explicit MySharedPtr(T* p = nullptr) : ptr(p), ref_count(new int(1)) {} // 拷贝构造函数 MySharedPtr(const MySharedPtr& other) : ptr(other.ptr), ref_count(other.ref_count) { if (ref_count) { ++(*ref_count); } } // 赋值操作符 MySharedPtr& operator=(const MySharedPtr& other) { if (this != &other) { release(); // 释放当前对象 ptr = other.ptr; ref_count = other.ref_count; if (ref_count) { ++(*ref_count); // 增加引用计数 } } return *this; } // 移动构造函数 MySharedPtr(MySharedPtr&& other) noexcept : ptr(other.ptr), ref_count(other.ref_count) { other.ptr = nullptr; other.ref_count = nullptr; } // 移动赋值操作符 MySharedPtr& operator=(MySharedPtr&& other) noexcept { if (this != &other) { release(); // 释放当前对象 ptr = other.ptr; ref_count = other.ref_count; other.ptr = nullptr; other.ref_count = nullptr; } return *this; } // 解引用操作符 T& operator*() const { return *ptr; } T* operator->() const { return ptr; } // 获取引用计数 int use_count() const { return ref_count ? *ref_count : 0; } // 析构函数 ~MySharedPtr() { release(); } }; int main() { MySharedPtr<int> p1(new int(42)); std::cout << "p1 use count: " << p1.use_count() << std::endl; { MySharedPtr<int> p2 = p1; // 共享所有权 std::cout << "p1 use count after p2: " << p1.use_count() << std::endl; std::cout << "p2 use count: " << p2.use_count() << std::endl; } // p2 离开作用域,引用计数减 1 std::cout << "p1 use count after p2 goes out of scope: " << p1.use_count() << std::endl; return 0; }
- 引用计数管理:使用
int* ref_count
来记录当前对象的引用次数。 - 拷贝控制:拷贝构造和赋值操作符会增加引用计数,确保多个
MySharedPtr
对象安全地共享同一资源。 - 移动控制:移动构造和移动赋值将指针和引用计数转移到新的
MySharedPtr
,确保资源的唯一所有权。 - 析构:在析构函数中减少引用计数,当引用计数为零时释放资源。
- 引用计数管理:使用
C++多线程中常用的mutex是怎么实现的,和自旋锁有什么区别?
在 C++ 中,std::mutex
是一种常用的同步原语,用于在多线程环境下保护共享资源,防止多个线程同时访问并导致数据不一致。std::mutex
的实现通常依赖于底层操作系统提供的互斥锁机制,最常见的是 互斥锁(Mutex) 和 自旋锁(Spinlock)。这两种锁的区别主要在于其锁定和等待的机制不同,适用于不同的场景。下面分别介绍它们的实现原理和区别。
-
std::mutex
的实现原理std::mutex
的具体实现取决于编译器和操作系统。例如,在 POSIX 系统(如 Linux)上,std::mutex
通常封装了 POSIX 的pthread_mutex
,在 Windows 上则使用CRITICAL_SECTION
等 Win32 API。一般情况下,std::mutex
的实现包含以下机制:-
加锁(lock):当一个线程调用
mutex.lock()
时,mutex
会检查锁是否被占用。如果锁未被占用,则线程获取锁并进入临界区。如果锁已经被占用,线程将进入阻塞状态,等待锁的释放。 -
解锁(unlock):当一个线程执行完临界区代码后,调用
mutex.unlock()
释放锁。如果有其他线程在等待该锁,其中一个线程将被唤醒并尝试获取锁。 -
操作系统调度:对于
std::mutex
的阻塞线程,操作系统会将这些线程挂起,直到锁被释放。这减少了 CPU 的使用,因为等待线程进入了“睡眠”状态,释放了处理器资源,使其他线程或进程可以继续执行。
-
-
std::mutex
和自旋锁的区别自旋锁(Spinlock) 是另一种锁机制,不同于
std::mutex
的阻塞等待方式,自旋锁在锁被占用时不会使线程进入休眠,而是让线程反复检查锁的状态(即“自旋”),直到锁被释放。以下是
std::mutex
和自旋锁的主要区别:- 等待机制
std::mutex
:当一个线程试图获取被占用的std::mutex
时,线程会被挂起,进入阻塞状态,由操作系统负责管理调度。当锁释放时,操作系统唤醒该线程。- 自旋锁:当一个线程试图获取被占用的自旋锁时,它不会进入阻塞状态,而是不断循环检查锁的状态(即“自旋”),直到锁被释放。自旋锁不会导致线程挂起或切换上下文。
-
性能对比
std::mutex
:适合长时间持有锁的情况。由于阻塞的线程进入休眠状态,不会占用 CPU 资源,因此适合锁定时间较长的场景。- 自旋锁:适合短时间持有锁的情况。自旋锁不会进入阻塞状态,因此不需要上下文切换的开销,但会占用 CPU 资源。如果锁定时间较短,自旋锁避免了线程切换的开销,可以获得更好的性能。但如果锁持有时间较长,自旋锁的 CPU 开销会迅速增大。
-
上下文切换开销
std::mutex
:阻塞等待涉及线程上下文切换,开销较高。操作系统在管理阻塞线程时需要进行调度,而上下文切换会带来一定的性能开销。- 自旋锁:没有上下文切换,避免了因阻塞带来的调度开销,但在锁占用较长的情况下,自旋会浪费 CPU 资源,导致性能下降。
-
多核系统的适用性
std::mutex
:适用于单核和多核系统,因为阻塞等待并不依赖 CPU 核心数。- 自旋锁:在多核系统上更为高效,因为在多核系统中,当一个线程自旋等待时,另一个核心上的线程可以释放锁。单核系统上,自旋锁的线程会一直占用 CPU,导致锁持有线程无法执行,从而产生死锁的风险。
- 等待机制
适用场景总结
std::mutex
:适用于锁定时间不确定或较长的场景,尤其是在多线程环境下锁需要长时间持有时。操作系统的调度机制能够有效地管理线程,避免 CPU 资源的浪费。- 自旋锁:适用于锁定时间很短、线程频繁竞争的情况,尤其是在多核系统上效果更佳,避免了不必要的上下文切换开销。
小结
std::mutex
和自旋锁的主要区别在于等待机制:std::mutex
是阻塞等待,而自旋锁是忙等待。- 在锁持有时间短的情况下,自旋锁性能较好;在锁持有时间长的情况下,
std::mutex
的性能更佳。 - 在实际应用中,选择
std::mutex
还是自旋锁应根据锁的持有时间、系统的核心数量和性能要求来决定。
atomic内部实现?是有锁还是没锁的?所有的原子变量都没锁吗?对于原子变量的memory order有了解吗?
-
std::atomic
的内部实现:有锁还是无锁?std::atomic
变量的实现方式依赖于硬件的原子性支持。在大多数现代 CPU 上,硬件可以直接支持无锁的原子操作,例如使用原子读-改-写指令(如compare_and_swap
或fetch_and_add
)实现。以下是主要情况:- 无锁实现:如果硬件支持无锁的原子指令,那么
std::atomic
会直接使用这些指令。这种无锁实现的原子操作性能较高,因为它避免了线程上下文切换和锁管理的开销。 - 有锁实现:在某些不支持无锁原子操作的硬件平台上,或对于复杂的操作(如大于硬件支持的整数大小的原子变量),编译器可能会回退到使用互斥锁(如
std::mutex
)来确保线程安全。这种情况下,std::atomic
会使用锁来保证原子性。
大多数主流 CPU(如 x86、x64、ARM)都支持原子指令,因此在这些平台上,
std::atomic
通常是无锁的。但是,在一些嵌入式平台或不支持原子指令的硬件上,可能会使用锁来模拟原子操作。 - 无锁实现:如果硬件支持无锁的原子指令,那么
-
所有的原子变量都没有锁吗?
并不是所有的原子变量都在无锁环境下实现。例如:
- 小于或等于机器字大小的基本数据类型(如
int
、bool
、pointer
)在主流硬件上通常可以无锁实现。 - 复杂类型或大于机器字大小的类型(如 128 位整数或结构体)可能需要锁。标准库中的
std::atomic
提供了is_lock_free()
方法,允许程序检查特定类型的原子变量是否在无锁环境下实现。
例如:在支持无锁的环境中,例如 x86 或 ARM 上,32 位和 64 位的整数通常是无锁的,而更大的类型(如 128 位整数)可能会需要锁。
- 小于或等于机器字大小的基本数据类型(如
-
原子变量的内存顺序(Memory Order)
在 C++ 中,
std::atomic
提供了对 内存顺序(Memory Order)的控制,使开发者可以指定内存操作的顺序约束。内存顺序控制的是不同线程之间原子操作的可见性和执行顺序,这在多线程编程中尤为重要。C++ 标准定义了几种内存顺序:memory_order_relaxed
:- 仅保证操作的原子性,不保证线程间的可见性和操作顺序。编译器和处理器可能对指令进行重排,但单个线程内的操作顺序保持逻辑一致性。
- 适合对顺序无严格要求的场景,如独立计数器、统计类操作。
memory_order_consume
:- 是
memory_order_acquire
的一种弱化形式,确保数据依赖关系的操作能看到更新的结果。 - 由于在实际编译器支持上存在问题,通常会降级为
memory_order_acquire
。
- 是
memory_order_acquire
:- 保证当前线程的后续操作(读取或写入)不会重排到原子加载操作之前,确保读取的可见性。
memory_order_release
:- 保证当前线程的先前操作不会重排到原子存储操作之后,确保写入的可见性
memory_order_acq_rel
(Acquire-Release):- 同时具备
acquire
和release
的效果。 - 适用于既要同步读又要同步写的操作,例如
compare_exchange
操作。
- 同时具备
memory_order_seq_cst
(Sequentially Consistent Order):- 最严格的内存顺序保证,确保所有原子操作都具有全局的顺序。
- 提供最强的可见性和顺序性保障,但通常会有更高的性能开销。
以下是一个使用
memory_order_acquire
和memory_order_release
的示例,用于实现线程间的同步:#include <atomic> #include <thread> #include <iostream> std::atomic<bool> ready(false); std::atomic<int> data(0); void producer() { data.store(42, std::memory_order_relaxed); // 先更新数据 ready.store(true, std::memory_order_release); // 再通知消费者 } void consumer() { while (!ready.load(std::memory_order_acquire)) { // 等待生产者通知 } std::cout << "Data: " << data.load(std::memory_order_relaxed) << std::endl; // 输出数据 } int main() { std::thread t1(producer); std::thread t2(consumer); t1.join(); t2.join(); return 0; }
在这个例子中:
- 生产者线程在
memory_order_release
顺序下将ready
设置为true
,使得之前对data
的写操作(存储 42)对消费者线程可见。 - 消费者线程在
memory_order_acquire
顺序下读取ready
。如果读取到true
,可以确保此时data
的值是生产者线程已经更新过的。
总结
std::atomic
的实现:多数情况下是无锁的,依赖于硬件的原子指令;某些平台或较大的数据类型可能使用锁。- 是否使用锁:小于或等于机器字大小的类型通常是无锁的,而较大的或复杂的数据类型可能需要锁。
- 内存顺序:
std::atomic
提供了不同的内存顺序模型,允许在性能和同步保障之间进行权衡。
无锁队列
无锁队列(Lock-Free Queue)是一种并发数据结构,它允许多个线程安全地进行入队和出队操作,而无需使用传统的互斥锁。这种结构在多线程编程中极为重要,尤其在高并发场景下,可以显著提高性能并减少线程竞争导致的开销。
无锁队列的特点
无锁队列的核心特点是它使用了无锁算法,在进行并发操作时不会阻塞线程,而是依赖于原子操作(如 compare_and_swap
、fetch_add
等)来实现线程安全。这种方式通常能提供更高的性能和更低的延迟。
具体特点包括:
- 无锁操作:通过无锁算法来实现操作的原子性,避免线程阻塞。
- 线程安全:多个线程可以同时对队列进行操作,且不会破坏队列的状态。
- 高并发:由于避免了锁的竞争和上下文切换的开销,无锁队列在高并发场景下性能优于传统的锁队列。
无锁队列的实现原理
无锁队列的实现主要依赖以下几个技术:
- 原子操作:如
compare_and_swap
(CAS),它允许线程以原子方式比较并设置变量。这种操作可以确保在多线程环境下不需要加锁而安全地修改变量。 - ABA 问题:在无锁队列中,可能会出现 ABA 问题,即某个变量的值从 A 变为 B,再变回 A,使得 CAS 操作以为变量没有变化。解决方法包括使用带有版本号的指针(如
std::atomic<std::uintptr_t>
)或者引用计数。 - 两指针结构:无锁队列通常使用两个指针,一个指向队列的头(
head
),一个指向队列的尾(tail
)。这样可以允许并发的入队和出队操作,分别操作head
和tail
指针。
典型实现:Michael & Scott 队列算法
Michael 和 Scott 提出的无锁队列算法是一个典型的无锁队列实现,通常用于支持多生产者、多消费者的场景。该算法的基本思路如下:
-
数据结构:队列中的每个节点包含一个值和一个指向下一个节点的指针。队列头指针
head
指向队列的第一个元素,而尾指针tail
指向最后一个元素。 - 入队操作(Enqueue):
- 创建一个新节点,指向
nullptr
。 - 使用 CAS 操作将
tail
指针更新为新节点。 - 如果其他线程也在更新
tail
,可能会出现冲突;但是 CAS 操作确保只有一个线程可以成功。
- 创建一个新节点,指向
- 出队操作(Dequeue):
- 获取
head
指向的节点。 - 使用 CAS 操作将
head
更新为下一个节点。 - 如果
head
和tail
指向同一个节点且该节点的next
为空,则队列为空。
- 获取
下面是一个简单的无锁队列的示例代码:
#include <atomic>
#include <memory>
template <typename T>
class LockFreeQueue {
private:
struct Node {
std::shared_ptr<T> data;
Node* next;
Node(const T& value) : data(std::make_shared<T>(value)), next(nullptr) {}
};
std::atomic<Node*> head;
std::atomic<Node*> tail;
public:
LockFreeQueue() {
Node* dummy = new Node(T()); // 创建哨兵节点
head.store(dummy);
tail.store(dummy);
}
~LockFreeQueue() {
while (Node* node = head.load()) {
head.store(node->next);
delete node;
}
}
void enqueue(const T& value) {
Node* newNode = new Node(value);
Node* oldTail = tail.load();
while (true) {
Node* temp = oldTail->next;
if (temp == nullptr) {
// 尝试将 oldTail->next 设置为新节点
if (std::atomic_compare_exchange_weak(&oldTail->next, &temp, newNode)) {
// 更新 tail 指向新节点
std::atomic_compare_exchange_weak(&tail, &oldTail, newNode);
return;
}
} else {
// 如果其他线程正在入队,推进 tail 指针
std::atomic_compare_exchange_weak(&tail, &oldTail, temp);
}
}
}
std::shared_ptr<T> dequeue() {
Node* oldHead = head.load();
while (true) {
Node* temp = oldHead->next;
if (temp == nullptr) {
return std::shared_ptr<T>(); // 队列为空
}
// 尝试更新 head 指针
if (std::atomic_compare_exchange_weak(&head, &oldHead, temp)) {
std::shared_ptr<T> res = temp->data;
delete oldHead; // 删除旧的头节点
return res;
}
}
}
};
代码解释
- 哨兵节点:在初始化时,创建一个哨兵节点。
head
和tail
都指向这个哨兵节点,以确保队列最初为空,并使得入队和出队操作更加简洁。 - 入队操作:新节点通过
tail
指针入队。CAS 操作确保多个线程不会将tail->next
指向同一个节点。 - 出队操作:通过移动
head
指针从队列中移除节点。CAS 操作保证head
被更新到下一个节点。
自旋等待和性能
在无锁队列中,线程在 CAS 操作失败时会自旋等待,即反复尝试直到成功。这种忙等待在短期锁定中效率很高,因为它避免了线程上下文切换的开销,但如果操作时间过长,自旋可能会导致性能下降。因此,无锁队列适合高并发和短期锁定的场景。
STL容器的线程安全
在 C++ 标准库(STL)中,大多数容器(如 vector
、list
、map
等)本身并不保证线程安全性。这意味着在多线程环境下,使用这些容器时需要遵循一定的规则,或者通过适当的同步机制(如互斥锁 std::mutex
)来确保线程安全。
以下是 STL 容器在多线程环境下的线程安全规则及一些常见的处理方式:
-
std::vector
、std::deque
和std::list
- 这些容器在结构上是动态调整大小的(如
vector
会动态分配和释放内存),因此在多线程环境中修改其内容是非线程安全的。 - 如果一个线程修改这些容器的内容,另一个线程尝试访问容器(无论是读或写),则会导致数据竞争,需要使用互斥锁来保证线程安全。
- 这些容器在结构上是动态调整大小的(如
-
std::map
和std::unordered_map
std::map
和std::unordered_map
是键值对容器,内部实现相对复杂,尤其是unordered_map
,其元素位置可能会随插入或删除而重排。- 如果多个线程同时访问
map
或unordered_map
,则只读操作是安全的,但一旦有线程进行写操作(插入或删除),就需要同步。
-
std::set
和std::unordered_set
set
和unordered_set
是集合类型的容器,具有类似于map
和unordered_map
的特性。- 只读操作是线程安全的,多个线程同时进行读取不需要额外的同步。但是,一旦有写操作(插入或删除),就需要使用同步机制。
在需要保证线程安全的多线程环境下,可以使用以下方式确保安全访问 STL 容器:
-
互斥锁保护:使用
std::mutex
或std::shared_mutex
对容器的访问进行同步。std::mutex
:如果容器既有读操作又有写操作,可以使用std::mutex
,确保只有一个线程可以访问容器。std::shared_mutex
(C++17 引入):如果大多数操作都是读取,可以使用std::shared_mutex
,允许多个线程同时读取,而写操作仍然需要独占锁。
-
线程安全容器:C++ 标准库没有提供线程安全的容器实现,但有些第三方库(如
TBB
、Boost
、Folly
等)提供了线程安全的容器,可以根据需求使用。例如:- Intel TBB(Threading Building Blocks) 提供的线程安全容器。
- Boost 库提供的多线程工具也有线程安全的结构支持。
-
使用原子操作:如果容器只需要存储简单的计数或标志,可以使用
std::atomic
变量来存储,而不是整个容器锁定。std::atomic
允许无锁访问,提高了性能。
红黑树和哈希表的区别(时间和空间的性能上)
红黑树在最坏情况下的操作时间复杂度如下:
- 查找:
O(log n)
- 插入:
O(log n)
- 删除:
O(log n)
- 空间复杂度:红黑树在每个节点上需要额外存储一些平衡信息(如颜色),因此相较于其他二叉树类型会占用稍多的空间。但总体上,红黑树的空间复杂度是
O(n)
。
在平均情况下,哈希表的时间复杂度如下:
- 查找:
O(1)
(平均情况下),最坏情况下可能退化为O(n)
(当所有键都映射到同一个位置时)。 - 插入:
O(1)
(平均情况下),最坏情况下可能退化为O(n)
。 - 删除:
O(1)
(平均情况下),最坏情况下可能退化为O(n)
。
在大多数情况下,哈希表的查找、插入和删除操作都是 O(1)
的,但在发生哈希冲突或负载因子(元素数与哈希桶数的比值)过高时,性能可能退化为 O(n)
。
- 空间复杂度:哈希表在数组中存储元素,并且随着元素的增多,哈希表会进行动态扩容(例如
std::unordered_map
会在达到一定负载因子后自动增加桶的数量)。因此,哈希表的空间复杂度通常是O(n)
,但会随着动态扩容而多占用一些空间。 - 内存浪费:哈希表通常会预留额外的空间以降低冲突率,从而实现
O(1)
的平均时间复杂度。相比红黑树,哈希表的空间开销会更大。
红黑树和哈希表的适用场景如下:
- 红黑树的适用场景:
- 需要有序访问数据时(如按键排序、区间查询)。
- 需要频繁的插入和删除操作,同时需要有序性保障。
- 需要高效的范围查询(如找到范围内的所有键值对)。
- 哈希表的适用场景:
- 需要快速查找和插入,但不关心数据顺序时。
- 需要频繁的查找和更新操作,如缓存、字典、集合等。
- 当空间开销不是关键考虑因素时,哈希表提供了更高效的查找和插入性能。
在交易场景中,哈希表和红黑树各自应当在什么时候使用?
在交易系统中,数据结构的选择非常重要,不同的数据结构在效率、性能以及适用场景上各不相同。下面是哈希表和红黑树在交易场景中的适用场景分析,之后再讨论 deque
和 vector
的应用场景。
1. 哈希表在交易场景中的使用场景
哈希表(如 std::unordered_map
)在交易场景中的适用场景主要是快速查找和更新操作,无需关心顺序性。常见的使用场景包括:
-
按订单 ID 查找订单:在交易系统中,每个订单通常有一个唯一的订单 ID。通过哈希表可以快速根据订单 ID 查找到特定的订单,以便于查询、取消或更新订单。哈希表的
O(1)
平均查找复杂度在这种情况下具有很大优势。 -
按客户 ID 查找客户信息:在订单系统中,经常需要通过客户 ID 查找客户的订单历史、账户余额等信息。哈希表可以提供快速的查找,使得能够在海量的客户数据中高效获取目标客户的详细信息。
-
缓存数据:哈希表适合缓存常用的数据,比如缓存最新的市场行情信息、交易对信息、配置信息等。通过键值对的形式,可以实现快速查找和更新,尤其是在需要频繁读写的场景中,哈希表提供了很好的性能。
适用场景总结
- 哈希表适合在交易场景中快速查找特定对象、缓存高频读写的数据、管理和查找基于 ID 的信息(如订单 ID、客户 ID 等),并且不需要保持数据的有序性。
2. 红黑树在交易场景中的使用场景
红黑树(如 std::map
)在交易场景中的适用场景主要是需要顺序性、范围查找等操作的场合。常见的使用场景包括:
-
订单簿(Order Book):在订单簿中,通常会根据价格对买卖订单进行排序,以实现撮合交易(如寻找最优的买价和卖价)。红黑树的有序性保证了订单能够按照价格排序,便于快速查找、插入和删除。例如,撮合引擎可以快速找到价格最低的卖单和价格最高的买单。
-
价格范围查询:在交易中,可能需要查询某一价格范围内的订单。例如,找到某价格区间内所有待处理的订单。红黑树支持范围查询操作,可以通过
lower_bound
和upper_bound
方法快速查找特定区间内的订单。 -
时间排序的订单列表:在某些交易系统中,可能需要按时间戳排序的订单列表,方便按顺序处理订单。红黑树可以在插入时维持时间戳的顺序,便于顺序性处理。
适用场景总结
- 红黑树适合在交易系统中维护有序的订单簿、支持价格范围查询的订单管理、以及按时间或价格排序的需求。其
O(log n)
的复杂度在这些场景中具有较好的性能表现。
deque和vector有哪些使用场景
deque
和 vector
都是线性容器,但在某些特性上存在差异,适用的场景也有所不同。
vector
的使用场景
-
顺序数据存储:
vector
适合存储顺序性数据,并且大多数情况下可以连续访问元素。对于随机访问需求较高的数据,vector
是不错的选择,因为vector
的元素在内存中是连续存储的,因此可以使用索引快速访问。 -
只在尾部插入或删除:
vector
在尾部插入和删除的性能较好,具有O(1)
的时间复杂度。但在中间或头部插入和删除元素的性能较差,因为需要移动元素,时间复杂度为O(n)
。 -
内存占用较小:由于
vector
元素连续存储,内存分配相对紧凑。它不需要额外的指针来连接元素,因此相比deque
,在相同的容量下vector
更节省内存。
适用场景示例
- 交易记录缓存:
vector
可用于存储顺序排列的交易记录,尤其是只需要在尾部追加新记录的情况。 - 历史行情数据:存储市场的历史价格等数据,可以直接按索引快速访问,并在尾部追加新数据。
deque
的使用场景
-
双端插入和删除:
deque
允许在头部和尾部进行高效的插入和删除,时间复杂度是O(1)
。这使得它非常适合需要双端插入删除的场景。 -
动态调整长度:
deque
的内存分配方式允许在首尾插入删除元素而不会频繁进行内存重分配,适合长度动态变化频繁的场景。相比vector
,deque
的结构更加灵活。 -
队列或栈:由于支持双端操作,
deque
适合实现双端队列、双向栈等结构。deque
的设计适合 FIFO(先进先出)和 LIFO(后进先出)模型。
适用场景示例
- 实时行情窗口:
deque
适合用于存储实时行情数据的滑动窗口,比如只保留最近几秒的价格数据,在数据进来时添加到尾部,最旧的数据从头部删除。 - 订单处理队列:如果订单需要在队列的首部和尾部进行频繁操作(如撤销最早的订单),
deque
提供更高效的处理。
实现一个订单簿系统,有挂单吃单撤单的功能
#include <iostream>
#include <map>
#include <list>
#include <string>
#include <unordered_map>
enum OrderType { BUY, SELL };
// 订单结构体
struct Order {
int order_id;
OrderType type;
double price;
int quantity;
Order(int id, OrderType t, double p, int q) : order_id(id), type(t), price(p), quantity(q) {}
};
// 订单簿类
class OrderBook {
private:
// 买单按降序排列(价格从高到低)
std::map<double, std::list<Order>, std::greater<double>> buy_orders;
// 卖单按升序排列(价格从低到高)
std::map<double, std::list<Order>> sell_orders;
// 存储订单 ID 到订单的映射,方便撤单
std::unordered_map<int, std::list<Order>::iterator> order_map;
public:
// 挂单
void add_order(int order_id, OrderType type, double price, int quantity);
// 吃单
void execute_market_order(OrderType type, int quantity);
// 撤单
void cancel_order(int order_id);
};