【C++八股-第七期】C++基础 ③ - 24年春招特
提纲:
👉 八股:
什么是原子操作?请解释原子操作的定义和特点
相对于互斥锁,为什么原子操作在实现上更加高效
介绍一下内联函数的使用场景及使用条件
请比较和对比宏定义、宏函数以及内联函数的区别
介绍一下 字节对齐 及其作用
简单介绍一下引用和指针的区别
介绍一下
左值
和右值
在赋值操作中的作用,右值引用有什么作用简单介绍一下移动语义的原理
多线程编程修改全局变量需要注意什么
👉 代码:
1. 什么是原子操作?请解释原子操作的定义和特点
原子操作是指在执行过程中不会被中断的操作。在计算机科学中,原子操作是一个不可分割的操作,它要么完全执行,要么完全不执行,没有中间状态。原子操作通常用于确保多线程或并发程序中的数据一致性和并发控制。
特点和定义:不可分割性、独占性、原子性、不依赖其他操作。
-
不可分割性: 原子操作是不可被中断的,它在执行过程中不能被其他操作插入。无论系统如何调度,原子操作的执行过程对外部是不可见的。
-
独占性: 一次只能有一个线程能够执行原子操作。其他线程必须等待当前原子操作执行完成才能进行下一步操作。
-
原子性: 原子操作的执行要么全部完成,要么完全没有执行,不会出现部分执行的情况。这确保了多线程环境下对共享数据的一致性。
-
不依赖其他操作: 原子操作的执行不依赖于其他操作的协助,它是独立的。
// 伪代码,实际实现可能依赖于具体的编程语言和平台
lock(mutex); // 加锁操作
// 一些共享数据的操作
unlock(mutex); // 解锁操作
拓展(了解即可):
示例: 在多线程编程中,一个典型的原子操作是对共享变量的加锁和解锁操作。在以上的伪代码中,lock 和 unlock 操作是原子的,它们要么一起执行,要么一起不执行。
在现代计算机体系结构中,一些特定的机器指令也被设计为原子操作,例如原子交换(compare-and-swap)等。这些指令能够保证在多处理器环境下执行时的原子性。
原子操作对于确保并发程序的正确性和性能是非常重要的,因为在多线程环境下,如果不使用原子操作来保护共享数据,可能会导致数据竞争、死锁等问题。
2. 相对于互斥锁,为什么原子操作在实现上更加高效
原子操作相对于互斥锁在实现上更加高效的主要原因是它们通常涉及到硬件级别的支持,其中包括使用总线加锁和缓存加锁的方式。
拓展(了解即可):
总线加锁(Bus Locking):
总线锁定: 在多处理器的计算机体系结构中,一种实现原子操作的方式是通过在总线上发送一个锁定信号。这个锁定信号会通知其他处理器禁止对内存的访问,直到当前处理器完成对共享资源的修改。
性能影响: 尽管总线加锁是一种可行的原子操作实现方式,但它可能引起性能问题。当一个处理器在总线上锁定时,其他处理器将无法访问内存,这可能导致性能瓶颈,特别是在高并发的情况下。
缓存加锁(Cache Locking):
缓存行锁定: 为了避免总线加锁可能导致的性能问题,现代处理器通常采用缓存加锁的方式。这种方式涉及到缓存行级别的锁定,而不是锁定整个总线。
缓存一致性: 当一个处理器在缓存中修改共享变量时,它会发出一个信号,通知其他处理器相应的缓存行无效。其他处理器在需要访问该缓存行时,会重新从内存中加载最新的值。
减小竞争范围: 缓存加锁的方式减小了锁定的范围,提高了并发度。只有在需要修改的缓存行上才会进行锁定,而其他缓存行仍然可以独立地被其他处理器访问。
原子操作实现原理:
硬件指令支持: 许多现代处理器提供特殊的原子操作指令,如比较交换(Compare-and-Swap)等。这些指令允许在一个原子操作中完成读取、修改和写入的操作,而无需额外的锁定操作。
缓存一致性协议: 多处理器系统中,缓存一致性协议确保在多个缓存之间维护共享数据的一致性。处理器之间通过缓存一致性协议来协调对共享数据的访问。
3. 介绍一下内联函数的使用场景及使用条件
什么是内联函数?
C++ 内联函数是通常与类一起使用。如果一个函数是内联的,那么在编译时,编译器会把该函数的代码副本放置在每个调用该函数的地方。
如果想把一个函数定义为内联函数,则需要在函数名前面放置关键字 inline
。
为什么使用内联函数?
函数调用是有调用开销的,执行速度要慢很多,调用函数要先保存寄存器,返回时再恢复,复制实参等等。
如果本身函数体很简单,那么函数调用的开销将远大于函数体执行的开销。为了减少这种开销,我们才使用内联函数。
内联函数使用的条件
以下情况不宜使用内联:
(1)如果函数体内的代码比较长,使用内联将导致内存消耗代价较高。
(2)如果函数体内出现循环,那么执行函数体内代码的时间要比函数调用的开销大。
内联不是什么时候都能展开的,一个好的编译器将会根据函数的定义体,自动地取消不符合要求的内联。
4. 请比较和对比宏定义、宏函数以及内联函数的区别
-
替换方式:
- 宏定义和宏函数是简单的文本替换,没有类型检查。
- 内联函数是真正的函数,进行了类型检查。
-
参数传递:
- 宏定义和宏函数使用参数进行文本替换。
- 内联函数使用参数,但进行了类型检查。
-
适用范围:
- 宏定义和宏函数可以用于任何代码片段。
- 内联函数通常用于短小的代码片段。
-
类型和语法检查:
- 宏定义和宏函数缺乏类型和语法检查。
- 内联函数进行了类型和语法检查。
总体而言,内联函数提供了更加安全和高效的代码替换机制,但需要注意,内联并不是适合所有情况的。在大多数情况下,使用内联函数更可取,但在某些情况下,特别是对于复杂的函数体,编译器可能选择不进行内联。
拓展:
宏定义(Macro Definitions):
替换规则: 宏定义使用 #define 关键字,通过简单的文本替换实现。在代码中,每次出现宏的名称时,都会被替换为预定义的文本。
没有类型检查: 宏定义是一种纯文本替换,没有参数类型检查和语法检查。这可能导致潜在的错误,并使代码更难调试。
适用范围: 宏定义可以用于任何代码,包括语句、表达式和声明。
宏函数(Function-like Macros):
参数化: 宏函数是一种使用 #define 定义的带参数的宏。它允许通过参数来传递值,并在代码中使用这些值进行替换。
替换规则: 宏函数使用参数进行文本替换。参数在宏体中用括号括起来,每次调用时,实参会替换对应的形参。
没有类型检查: 类似于宏定义,宏函数也没有参数类型检查。
适用范围: 宏函数可以用于任何代码,包括语句、表达式和声明。
内联函数(Inline Functions):
函数形式: 内联函数是由 inline 关键字定义的真正的函数。它类似于普通函数,但在编译时可以选择将函数调>用处的代码替换为函数体,而不进行实际的函数调用。
参数类型检查: 内联函数进行了参数类型检查和语法检查,与普通函数相似。这使得内联函数更安全,减少了潜在的错误。
适用范围: 内联函数通常用于短小的代码片段,例如简单的访问器函数。对于复杂的函数,编译器可能选择不内联,而是进行普通函数调用。
优势: 内联函数可以减少函数调用的开销,提高性能。同时,它还保留了普通函数的所有语法和类型检查的优势。
5. 介绍一下 字节对齐 及其作用
字节对齐(Byte Alignment) 是计算机内存中数据存储的一种规则,它要求数据的起始地址是某个值的整数倍,通常是数据类型的大小或者内存对齐的要求。字节对齐的目的是为了提高内存访问的效率。
示例:
struct MyStruct {
char c; // 1字节
int i; // 4字节对齐
double d; // 8字节对齐
};
字节对齐的原则:
-
基本原则: 结构体(或对象)的首地址能够被结构体中最大类型大小所整除。
-
结构体成员对齐: 结构体中的每个成员变量都要满足其自身的对齐要求。例如,int 变量可能需要4字节对齐,而 double 变量可能需要8字节对齐。
-
总大小: 结构体的总大小是其成员大小的总和,但要满足结构体对齐的要求。
-
特殊对齐指令: 在一些编程语言中,可以使用特殊的对齐指令来设置结构体或者变量的对齐方式。
在上述示例中,MyStruct 结构体的对齐要求是8字节,因为 double 类型需要8字节对齐。结构体成员按照字节对齐的原则排列,使得整个结构体满足8字节对齐的要求。
为什么要字节对齐?
-
硬件要求: 很多硬件架构对数据的访问有着字节对齐的要求。例如,一些处理器可能要求整数在内存中的地址是4的倍数,双精度浮点数可能要求8的倍数,以此类推。不满足硬件的字节对齐要求可能导致性能下降或者出现访问错误。比如sparc系统,如果取未对齐的数据会发生错误,而在x86上就不会出现错误,只是效率下降。
-
提高访问速度: 许多处理器和存储系统能够更有效地访问按照字节对齐规则排列的数据。当数据按照字节对齐存储时,可以通过一次内存访问获取更多的数据,提高数据传输的效率。
-
缓存性能: 字节对齐有助于提高缓存的命中率。缓存通常以缓存行(Cache Line)为单位加载数据,如果数据按照字节对齐存储,可以减少不必要的缓存行加载,提高缓存利用率。
6. 简单介绍一下引用和指针的区别
-
本质:
-
引用是别名,与变量共享内存空间。
-
指针是实体,占用内存空间。
-
-
语法和声明:
-
引用: 使用 '&' 符号进行声明,必须在定义时进行初始化,并且一旦引用被初始化,就无法再引用其他对象。
int x = 10; int& ref = x; // 声明并初始化引用
-
指针: 使用 '*' 符号进行声明,可以在定义时初始化,也可以在之后改变指向的对象。
int y = 20; int* ptr = &y; // 声明并初始化指针
-
-
内存操作:
-
引用: 在使用引用时,不需要使用解引用操作符(*),因为引用本身就是目标对象的别名。
int z = 30; int& ref = z; cout << ref; // 不需要解引用
-
指针: 指针需要通过解引用操作符来访问目标对象的值。
int w = 40; int* ptr = &w; cout << *ptr; // 解引用指针
-
-
空值:
-
引用: 引用不能是空值(nullptr),必须在初始化时指定引用的目标。
-
指针: 指针可以是空指针(nullptr),即指向空地址。
-
-
重新赋值:
-
引用: 一旦引用被初始化,就无法再引用其他对象,因此不能被重新赋值。
int a = 5, b = 10; int& ref = a; // 引用a ref = b; // 不是重新赋值,而是修改a的值
-
指针: 指针可以在之后指向其他对象,可以重新赋值。
Copy code int c = 15, d = 20; int* ptr = &c; // 指针指向c ptr = &d; // 重新赋值,指针指向d
-
-
地址:
-
引用: 引用在使用时无需取地址,因为引用本身就是目标对象的别名。
-
指针: 指针需要通过取地址操作符 & 来获取目标对象的地址。
int e = 25; int& ref = e; // 引用 int* ptr = &e; // 指针
-
-
数组:
-
引用: 引用无法声明为数组的别名,因为引用必须在初始化时绑定到一个对象。
-
指针: 指针可以用于指向数组的首地址,支持数组的操作。
int arr[5] = {1, 2, 3, 4, 5}; int* ptr = arr; // 指向数组的首地址
-
-
sizeof计算容量:
-
sizeof(指针) 计算的是指针本身的大小。
-
sizeof(引用) 计算的是引用的对象的大小。
-
-
二级指针和二级引用:
-
存在二级指针(指向指针的指针)。
-
没有二级引用的概念。
-
7. 介绍一下左值
和右值
在赋值操作中的作用,右值引用有什么作用
左值和右值:
-
左值: 左值是指表达式结束后依然存在的持久化对象。通常,左值可以取地址,例如变量或者具名对象。左值可以出现在赋值操作符的左边或右边。
int x = 42; // x 是左值 int* ptr = &x; // &x 是左值
-
右值: 右值是指表达式结束后即将销毁的临时对象。通常,右值不能取地址,是一种临时的、短暂的值。右值通常出现在赋值操作符的右边。
Copy code
int y = 10 + 20; // 10 + 20 是右值
int&& rvalueRef = y + 5; // y + 5 是右值
赋值操作中的左值和右值:
- 左值作用: 左值在赋值操作中表示被赋值的目标。例如:
int x = 42; // x 是左值,赋值操作的目标
- 右值作用: 右值通常表示临时的、计算结果等,可以被赋值给左值。C++11 引入了右值引用,使得对右值的引用更加灵活。
int&& rvalueRef = 10 + 20; // 10 + 20 是右值,可以被右值引用引用
右值引用的作用:
右值引用(Rvalue Reference)是C++11引入的特性,通过 && 表示。右值引用有以下主要作用:
-
移动语义(Move Semantics): 允许有效地转移资源的所有权,而不是复制。这对于大型数据结构、动态分配的内存等场景下能够提高性能。
std::vector<int> getVector() { std::vector<int> v = {1, 2, 3}; return v; // 返回的 v 是右值 } std::vector<int> vec = getVector(); // 使用移动语义,避免不必要的拷贝
-
完美转发(Perfect Forwarding): 在泛型编程中,右值引用使得可以更好地保留传递参数的值类别,避免额外的拷贝。
template <typename T>
void forwardValue(T&& value) {
process(std::forward<T>(value)); // 使用 std::forward 完美转发
}
右值引用允许我们更灵活地处理临时值,并提供了性能上的优势,特别是在处理资源管理等方面。
8. 简单介绍一下移动语义的原理
移动语义是C++11引入的一项特性,旨在提高对于资源管理类对象的效率,特别是在涉及资源所有权转移的情况下。移动语义的核心是通过将资源的所有权从一个对象转移到另一个对象,而不是传统的复制。
传统拷贝语义:
在传统的拷贝语义中,当一个对象将其资源复制给另一个对象时,需要在堆上分配新的内存,并将原始对象的内容复制到新内存中。这可能涉及到大量的内存分配和复制操作,尤其是对于大型数据结构或包含动态分配内存的对象。
class String {
public:
String(const char* str) {
data = new char[strlen(str) + 1];
strcpy(data, str);
}
// 拷贝构造函数
String(const String& other) {
data = new char[strlen(other.data) + 1]; //在堆上分配新的内存
strcpy(data, other.data);
}
~String() {
delete[] data;
}
private:
char* data;
};
int main() {
String str1 = "Hello";
String str2 = str1; // 调用拷贝构造函数,复制str1的内容到str2
return 0;
}
移动语义的原理:
移动语义引入了右值引用和移动构造函数/移动赋值运算符,通过这些机制,可以将资源的所有权从一个对象转移到另一个对象,而无需复制底层数据。
右值引用:
T&&
右值引用表示对临时对象(右值)的引用。它可以绑定到将要销毁的临时对象,以及具有名称的右值引用。
移动构造函数:
class String {
public:
// 移动构造函数
String(String&& other) noexcept {
data = other.data; // 转移指针,而不是复制内容
other.data = nullptr; // 置空原对象的指针,防止在析构时释放资源
}
// 其他代码...
};
移动赋值运算符:
class String {
public:
// 移动赋值运算符
String& operator=(String&& other) noexcept {
if (this != &other) {
delete[] data; // 释放当前对象的资源
data = other.data; // 转移指针
other.data = nullptr; // 置空原对象的指针
}
return *this;
}
// 其他代码...
};
使用移动语义可以提高效率,特别是在处理大型数据结构或涉及频繁的资源管理时。移动构造函数和移动赋值运算符通常通过右值引用实现,从而允许在对象的资源上进行转移而非复制。
9. 多线程编程修改全局变量需要注意什么
目的:在多线程编程中修改全局变量时,确保线程安全性和避免竞态条件(Race Condition):
-
全局变量加关键字volatile;
-
互斥锁(Mutex): 使用互斥锁来保护对全局变量的访问。互斥锁确保在任何时刻只有一个线程能够修改全局变量,防止多个线程同时对其进行写操作。
#include <iostream> #include <mutex> #include <thread> std::mutex globalVariableMutex; int globalVariable = 0; void modifyGlobalVariable() { std::lock_guard<std::mutex> lock(globalVariableMutex); // 修改全局变量 globalVariable++; } int main() { std::thread t1(modifyGlobalVariable); std::thread t2(modifyGlobalVariable); t1.join(); t2.join(); std::cout << "Global variable value: " << globalVariable << std::endl; return 0; }
-
原子操作(Atomic Operations): 对于一些基本类型,可以使用原子操作来确保对全局变量的读写是原子的。C++ 提供了 std::atomic 类来实现原子操作。
#include <iostream> #include <atomic> #include <thread> std::atomic<int> globalVariable(0); void modifyGlobalVariable() { // 修改全局变量 globalVariable++; } int main() { std::thread t1(modifyGlobalVariable); std::thread t2(modifyGlobalVariable); t1.join(); t2.join(); std::cout << "Global variable value: " << globalVariable << std::endl; return 0; }
-
线程安全的数据结构: 使用线程安全的数据结构,例如
std::shared_mutex
、std::atomic
,或其他并发容器,以减少手动管理锁的复杂性。 -
避免死锁: 当使用多个互斥锁时,确保在任何时刻只持有一个锁,以防止死锁的发生。
-
注意竞态条件: 注意在并发环境中可能发生的竞态条件,例如在检查全局变量的值后再进行修改。
-
避免数据竞争: 尽量避免数据竞争,即多个线程同时读写同一个变量而没有适当的同步机制。合理使用互斥锁、原子操作等手段来保护共享资源。
-
确保共享资源的可见性: 在多线程环境中,确保对共享资源的修改对其他线程是可见的。可以使用 std::atomic 或适当的同步机制来保证可见性。
-
考虑使用局部变量: 在可能的情况下,考虑使用局部变量而不是全局变量,以减小共享资源的范围,从而降低竞态条件的可能性。