小米C++一面 (9月11日) - 参考答案
1、const属性:介绍const关键字的用法和作用,包括修饰变量、指针、函数参数、函数返回值和成员函数等。
const是C++中一个非常重要的关键字,用于定义常量,表示“只读”或“不可修改”。它提高了程序的健壮性和可读性,并允许编译器进行更多的优化。
1.1、修饰变量:
- 作用: 使变量成为常量,其值在初始化后不能被修改。
- 示例:cpp
const int MAX_SIZE = 100; // MAX_SIZE是一个常量,不能被修改 // MAX_SIZE = 200; // 错误:不能修改const变量
1.2、修饰指针: const修饰指针时,位置不同,含义也不同,通常遵循“const修饰离它最近的那个”原则。
const修饰指针指向的内容(常量指针):- 语法:
const Type* ptr;或Type const* ptr; - 含义: 指针
ptr指向的内容是常量,不能通过ptr修改其内容,但ptr本身可以指向其他地址。 - 示例:cpp
int a = 10, b = 20; const int* p = &a; // p指向的内容是const int // *p = 15; // 错误:不能通过p修改a的值 p = &b; // 正确:p可以指向其他地址
- 语法:
const修饰指针本身(指针常量):- 语法:
Type* const ptr; - 含义: 指针
ptr本身是常量,一旦初始化后,不能再指向其他地址,但可以通过ptr修改其指向的内容。 - 示例:cpp
int a = 10, b = 20; int* const p = &a; // p本身是const *p = 15; // 正确:可以通过p修改a的值 // p = &b; // 错误:p不能指向其他地址
- 语法:
const修饰指针指向的内容和指针本身(指向常量的常量指针):- 语法:
const Type* const ptr; - 含义: 指针
ptr指向的内容和ptr本身都是常量,都不能被修改。 - 示例:cpp
int a = 10; const int* const p = &a; // p和*p都是const // *p = 15; // 错误 // p = nullptr; // 错误
- 语法:
1.3、修饰函数参数:
- 作用: 防止函数内部修改传入的参数,特别是对于引用和指针参数,可以避免不必要的副作用。
- 示例:cpp
void printValue(const int& val) { // 引用参数,不能修改val // val = 10; // 错误 std::cout << val << std::endl; } void printArray(const int* arr, int size) { // 指针参数,不能修改arr指向的内容 // arr[0] = 1; // 错误 for (int i = 0; i < size; ++i) { std::cout << arr[i] << " "; } std::cout << std::endl; }
1.4、修饰函数返回值:
- 作用: 限制函数返回值的修改。对于返回基本类型或对象,
const通常没有太大意义(因为返回的是副本)。但对于返回指针或引用,const可以防止通过返回值修改原始数据。 - 示例:cpp
const int& getConstRef(const int& val) { // 返回const引用,不能通过返回值修改原始变量 return val; } const char* getErrorMessage() { // 返回const char*,不能通过指针修改错误信息字符串 return "Error occurred!"; }
1.5、修饰成员函数:
- 语法:
ReturnType FunctionName(...) const; - 作用: 声明一个成员函数为
const成员函数。const成员函数不能修改对象的任何非mutable成员变量,也不能调用非const成员函数。 - 目的: 保证对象的常量性,即调用该函数不会改变对象的状态。这对于实现“读操作”非常重要,允许
const对象调用这些函数。 - 示例:cpp
class MyClass { private: int value; mutable int accessCount; // mutable成员可以在const成员函数中修改 public: MyClass(int v) : value(v), accessCount(0) {} int getValue() const { // const成员函数 // value = 10; // 错误:不能修改非mutable成员 accessCount++; // 正确:可以修改mutable成员 return value; } void setValue(int v) { // 非const成员函数 value = v; } };
总结: const关键字在C++中提供了强大的类型安全和语义表达能力,有助于编写更安全、更清晰、更易于维护的代码。
2、函数重载与函数重写:解释函数重载(Overload)和函数重写(Override)的区别,包括发生条件、作用域和实现机制。
函数重载(Overload)和函数重写(Override)是C++中实现多态性的两种不同机制,它们发生在不同的场景,具有不同的目的。
| 特性 | 函数重载 | 函数重写 |
|---|---|---|
| 定义 | 在同一作用域内,函数名相同,但参数列表(参数个数、类型或顺序)不同的一组函数。 | 在派生类中重新定义基类中的虚函数,以实现不同的行为。 |
| 发生条件 | 1. 同一作用域(类内或全局)。2. 函数名相同。3. 参数列表不同(参数个数、类型或顺序)。4. 返回值类型可以相同也可以不同,但不能作为区分重载的依据。 | 1. 发生在基类与派生类之间。2. 基类中必须是virtual(虚)函数。3. 派生类中函数名、参数列表、返回值类型(协变除外)必须与基类虚函数完全相同。4. 派生类中可以显式使用override关键字(C++11)。 |
| 作用域 | 同一作用域(类作用域或命名空间作用域)。 | 跨越基类和派生类两个作用域。 |
| 实现机制 | 编译时多态(静态多态)。编译器根据函数调用时提供的参数类型和数量来决定调用哪个函数。 | 运行时多态(动态多态)。通过虚函数表(vtable)和虚表指针(vptr)在运行时决定调用哪个函数。 |
是否需要virtual | 不需要。 | 必须是基类的virtual函数。 |
| 是否改变函数签名 | 改变函数签名(参数列表不同)。 | 不改变函数签名(与基类虚函数签名相同)。 |
总结:
- 重载是“同名不同参”,解决的是同一功能在不同数据类型或数量参数下的实现问题,是编译时决定的。
- 重写是“基类虚函数在派生类中的重新实现”,解决的是通过基类指针或引用调用派生类对象时,能够执行派生类特定行为的问题,是运行时决定的。
3、多态实现原理:阐述C++多态的实现原理,包括虚函数表(vtable)和虚表指针(vptr)的作用。
C++中的多态(Polymorphism)主要通过虚函数和虚函数表(vtable)机制来实现运行时多态(动态多态)。
3.1、虚函数:
在基类中声明为virtual的成员函数。当通过基类指针或引用调用虚函数时,实际执行哪个函数取决于指向或引用的对象的实际类型,而不是指针或引用的类型。
3.2、虚函数表(vtable):
- 定义: 每个包含虚函数的类(或继承了虚函数的类)都会有一个虚函数表。虚函数表是一个静态的、只读的函数指针数组,它存储了该类中所有虚函数的地址。
- 内容: 虚函数表中的每个条目对应一个虚函数。如果派生类重写了基类的虚函数,那么该条目会指向派生类中重写的函数;如果派生类没有重写,则指向基类中的函数。
- 生成时机: 虚函数表是在编译时由编译器为每个类生成的。
3.3、虚表指针(vptr):
- 定义: 每个包含虚函数的类的对象,都会在其实例的内存布局中包含一个虚表指针(vptr)。
vptr是一个指向该对象所属类的虚函数表的指针。 - 内容:
vptr存储的是对应类的虚函数表的地址。 - 初始化时机:
vptr是在对象构造时由编译器自动初始化的。当构造一个派生类对象时,vptr会先指向基类的虚函数表,然后随着构造过程的进行,最终指向派生类的虚函数表。 - 位置:
vptr通常是对象内存布局中的第一个成员(或者在某些编译器实现中是第二个,如果第一个是基类的vptr)。
多态的实现过程:
- 编译时:
- 编译器为每个含有虚函数的类生成一个虚函数表(vtable)。
- 编译器在每个含有虚函数的类的对象中添加一个虚表指针(vptr)。
- 运行时:
- 当通过基类指针或引用调用一个虚函数时,编译器会生成代码,通过以下步骤进行调用: a. 首先,通过基类指针(或引用)找到对象的内存地址。 b. 从对象的内存地址中,找到存储在对象开头的虚表指针(vptr)。 c. 通过
vptr找到该对象所属类的虚函数表(vtable)。 d. 在虚函数表中,根据虚函数在类中的声明顺序(偏移量),找到对应虚函数的地址。 e. 调用该地址处的函数。
- 当通过基类指针或引用调用一个虚函数时,编译器会生成代码,通过以下步骤进行调用: a. 首先,通过基类指针(或引用)找到对象的内存地址。 b. 从对象的内存地址中,找到存储在对象开头的虚表指针(vptr)。 c. 通过
示例:
#include <iostream>
class Base {
public:
virtual void func1() { std::cout << "Base::func1()\n"; }
virtual void func2() { std::cout << "Base::func2()\n"; }
void nonVirtualFunc() { std::cout << "Base::nonVirtualFunc()\n"; }
};
class Derived : public Base {
public:
void func1() override { std::cout << "Derived::func1()\n"; } // 重写虚函数
virtual void func3() { std::cout << "Derived::func3()\n"; } // 派生类新增虚函数
};
int main() {
Base* b_ptr = new Derived();
b_ptr->func1(); // 调用 Derived::func1()
b_ptr->func2(); // 调用 Base::func2()
b_ptr->nonVirtualFunc(); // 调用 Base::nonVirtualFunc()
// b_ptr->func3(); // 错误:Base类没有func3()
delete b_ptr;
return 0;
}在这个例子中:
Base类有一个vtable,包含Base::func1和Base::func2的地址。Derived类也有一个vtable,其中func1的条目指向Derived::func1,func2的条目指向Base::func2(因为Derived没有重写它),func3的条目指向Derived::func3。- 当
b_ptr->func1()被调用时,b_ptr指向一个Derived对象,通过Derived对象的vptr找到Derived的vtable,然后找到func1对应的函数地址,最终调用Derived::func1()。
4、多线程知识:考察多线程编程的基础知识,如线程的创建、同步、互斥等。
多线程编程是现代并发编程的核心,允许程序同时执行多个任务,提高资源利用率和响应速度。
4.1、线程的创建:
- C++11标准库: 使用
std::thread类来创建和管理线程。cpp#include <thread> #include <iostream> void task_function() { std::cout << "Hello from thread!\n"; } int main() { std::thread my_thread(task_function); // 创建线程并执行task_function my_thread.join(); // 等待线程完成 // my_thread.detach(); // 或者分离线程,让它独立运行 return 0; } - 其他方式: 平台特定的API,如POSIX线程(
pthread)在Linux/Unix系统上,或Windows API的CreateThread。
4.2、线程的同步:
- 目的: 协调多个线程的执行顺序,确保它们在访问共享资源时不会产生冲突,或者在特定条件满足时才继续执行。
- 常见机制:
- 互斥量 : 保护共享资源,确保同一时间只有一个线程访问。 (
std::mutex) - 条件变量: 允许线程等待某个条件成立,并在条件成立时被通知唤醒。 (
std::condition_variable) - 信号量: 控制对共享资源的访问数量。允许指定数量的线程同时访问资源。
- 读写锁: 允许多个读线程同时访问,但写线程独占访问。 (
std::shared_mutex) - 原子操作: 对基本数据类型进行无锁的原子性操作,保证操作的完整性。 (
std::atomic)
- 互斥量 : 保护共享资源,确保同一时间只有一个线程访问。 (
4.3、线程的互斥 (Mutual Exclusion):
- 目的: 确保在任何时刻,只有一个线程能够进入临界区,访问共享资源,从而避免数据竞争。
- 实现方式: 主要通过互斥量 (Mutex) 来实现。
- 当一个线程需要访问共享资源时,它会尝试锁定互斥量。
- 如果互斥量未被锁定,该线程成功锁定并进入临界区。
- 如果互斥量已被其他线程锁定,该线程会被阻塞,直到互斥量被释放。
- 线程完成对共享资源的访问后,会解锁互斥量,允许其他等待的线程获取锁。
- C++中的互斥:
std::mutex:基本的互斥量。std::lock_guard:RAII风格的互斥量包装器,在构造时锁定,在析构时自动解锁,防止忘记解锁。std::unique_lock:更灵活的互斥量包装器,可以手动锁定/解锁,支持延迟锁定、尝试锁定等。
4.4、线程的生命周期:
- 创建: 线程对象被创建。
- 就绪: 线程已准备好运行,等待CPU调度。
- 运行: 线程正在CPU上执行。
- 阻塞: 线程因等待某个事件(如获取锁、I/O操作、条件变量)而暂停执行。
- 终止: 线程执行完毕或被取消。
4.5、常见问题:
- 死锁 (Deadlock): 多个线程互相等待对方释放资源而无法继续执行。需要通过避免死锁的策略来解决(如按序加锁)。
- 活锁 (Livelock): 线程不断尝试获取资源但总是失败,导致CPU空转。
- 饥饿 (Starvation): 某个线程长时间无法获取到所需的资源,导致无法执行。
- 数据竞争 (Data Race): 多个线程同时访问和修改共享数据,且至少有一个是写操作,导致结果不确定。
5、mutex与lock_guard的区别:解释std::mutex和std::lock_guard的用途和区别,以及它们在多线程编程中的应用。
std::mutex和std::lock_guard都是C++11中用于多线程同步的工具,但它们扮演的角色和使用方式有所不同。
| 特性 | std::mutex | std::lock_guard |
|---|---|---|
| 本质 | 互斥量对象,提供lock()和unlock()方法。 | RAII(Resource Acquisition Is Initialization)风格的互斥量包装器。 |
| 用途 | 提供基本的互斥功能,保护临界区。 | 简化互斥量的使用,确保互斥量在作用域结束时自动解锁,防止忘记解锁导致的死锁或资源泄漏。 |
| 生命周期 | 独立的对象,需要手动管理其锁定和解锁。 | 局部对象,其生命周期与所在的作用域绑定。 |
| 锁定/解锁 | 手动调用lock()和unlock()。 | 构造时自动调用lock(),析构时自动调用unlock()。 |
| 灵活性 | 较高,可以手动控制锁定和解锁的时机。 | 较低,一旦构造就锁定,直到作用域结束才解锁。不支持手动解锁、尝试锁定、延迟锁定等。 |
| 异常安全 | 如果在lock()和unlock()之间发生异常,可能导致unlock()未被调用,从而造成死锁。 | 具有异常安全,无论代码如何退出作用域(正常退出或抛出异常),析构函数都会被调用,从而确保互斥量被解锁。 |
| 应用场景 | 需要精细控制锁定/解锁时机,或实现更复杂的同步逻辑(如条件变量)。 | 简单地保护一个代码块的临界区,确保互斥访问,且不需要手动控制解锁时机。推荐优先使用。 |
示例:
#include <mutex>
#include <iostream>
#include <thread>
#include <vector>
std::mutex mtx; // 全局或共享的互斥量
int shared_data = 0;
void increment_manual() {
mtx.lock(); // 手动锁定
// 临界区
shared_data++;
std::cout << "Manual increment: " << shared_data << std::endl;
mtx.unlock(); // 手动解锁
}
void increment_with_lock_guard() {
std::lock_guard<std::mutex> lock(mtx); // 构造时锁定,作用域结束时自动解锁
// 临界区
shared_data++;
std::cout << "Lock_guard increment: " << shared_data << std::endl;
// 无需手动解锁,离开作用域时自动解锁
}
int main() {
std::vector<std::thread> threads;
for (int i = 0; i < 5; ++i) {
threads.emplace_back(increment_manual);
}
for (int i = 0; i < 5; ++i) {
threads.emplace_back(increment_with_lock_guard);
}
for (auto& t : threads) {
t.join();
}
std::cout << "Final shared_data: " << shared_data << std::endl;
return 0;
}总结: std::mutex是互斥量的核心,提供了lock()和unlock()等基本操作。而std::lock_guard是基于std::mutex的RAII封装,它通过在构造函数中锁定互斥量,在析构函数中解锁互斥量,从而保证了互斥量的正确释放,即使在发生异常时也能避免死锁。因此,在大多数需要简单互斥保护的场景中,强烈推荐使用std::lock_guard(或更灵活的std::unique_lock),以提高代码的健壮性和异常安全性。
6、vector与list的区别:比较std::vector和std::list两种容器的底层实现、内存布局、访问效率、插入删除效率等方面的区别。
std::vector和std::list是C++标准库中最常用的两种序列容器,它们在底层实现、性能特性和适用场景上有着显著的区别。
| 特性 | std::vector | std::list |
|---|---|---|
| 底层实现 | 动态数组(Dynamic Array) | 双向链表(Doubly Linked List) |
| 内存布局 | 元素在内存中是连续存储的。 | 元素在内存中是非连续存储的,每个节点独立分配内存,并包含指向前一个和后一个节点的指针。 |
| 随机访问 (Random Access) | 支持。通过索引[]或at()可以在O(1)时间复杂度内访问任意元素。 | 不支持。访问任意元素需要从头或尾遍历,时间复杂度为O(N)。 |
| 插入/删除效率 | 尾部: push_back()和pop_back()通常是O(1)(摊销)。头部/中间: O(N)。因为需要移动插入/删除点之后的所有元素。 | 任意位置: O(1)(一旦获得指向该位置的迭代器)。因为只需要修改少数指针。 |
| 迭代器有效性 | 插入/删除: 除了尾部操作外,插入或删除元素可能导致所有迭代器失效。扩容: 扩容时会重新分配内存,导致所有迭代器、指针和引用失效。 | 插入/删除: 不会导致其他迭代器失效,只有被删除元素的迭代器失效。 |
| 缓存友好性 | 高。由于元素连续存储,CPU缓存可以预取数据,提高缓存命中率。 | 低。元素分散存储,每次访问可能导致缓存未命中,性能相对较低。 |
| 内存开销 | 相对较小。除了元素数据本身,只需要存储容量、大小和起始地址等少量管理信息。 | 相对较大。每个元素除了数据本身,还需要额外的空间存储两个指针(前驱和后继)。 |
| 扩容机制 | 当容量不足时,会重新分配一块更大的内存(通常是当前容量的1.5倍或2倍),将所有旧元素复制/移动到新内存,然后释放旧内存。开销较大。 | 不需要扩容机制,每个节点独立分配,按需增长。 |
| 适用场景 | 需要频繁随机访问元素。主要在尾部进行插入和删除操作。对内存连续性有要求。元素数量相对稳定,或对扩容开销不敏感。 | 需要频繁在任意位置进行插入和删除操作。不需要随机访问元素。对内存开销不敏感,或元素类型较小,指针开销相对不显著。 |
总结:
- 如果你需要随机访问元素,并且主要在尾部进行增删操作,或者对缓存性能有较高要求,那么
std::vector是更好的选择。 - 如果你需要频繁在任意位置进行插入和删除操作,并且不关心随机访问性能,那么
std::list更合适。
7、内存溢出与内存泄漏:解释内存溢出(Out of Memory)和内存泄漏(Memory Leak)的概念、产生原因以及如何发现和避免。
内存溢出(Out of Memory, OOM)和内存泄漏(Memory Leak)是C++程序中常见的内存问题,它们都与内存管理不当有关,但表现形式和原因不同。
7.1、内存溢出 (Out of Memory, OOM)
- 概念: 指程序在申请内存时,没有足够的内存空间供其使用。当程序试图分配比可用内存(包括物理内存和交换空间)更多的内存时,就会发生内存溢出。
- 产生原因:
- 申请了过大的内存: 例如,
new int[INT_MAX],试图一次性分配一个非常大的数组,超出了系统或进程的内存限制。 - 内存泄漏累积: 长期运行的程序,如果存在内存泄漏,随着时间的推移,泄漏的内存会不断累积,最终耗尽所有可用内存,导致后续的内存申请失败,从而引发内存溢出。
- 递归调用过深: 函数递归调用层数过多,导致栈空间耗尽(栈溢出,Stack Overflow),这也是一种特殊的内存溢出。
- 申请了过大的内存: 例如,
- 表现: 程序崩溃,操作系统报告内存不足错误,
new操作抛出std::bad_alloc异常(或返回nullptr,取决于new的重载版本)。 - 发现: 观察程序运行时的内存使用量,如果持续增长直到耗尽系统资源,则可能存在内存溢出。
- 避免:
- 合理规划内存使用: 避免一次性申请过大的内存块。
- 及时释放不再使用的内存: 使用
delete或free释放动态分配的内存。 - 使用智能指针:
std::unique_ptr和std::shared_ptr可以自动管理内存,减少手动释放的错误。 - 优化算法和数据结构: 减少不必要的内存占用。
- 避免无限递归: 确保递归函数有正确的终止条件。
7.2、内存泄漏 (Memory Leak)
- 概念: 指程序在运行过程中,动态分配的内存(如使用
new或malloc分配的内存)在不再使用后没有被及时释放,导致这部分内存无法被系统回收,从而造成内存的浪费。 - 产生原因:
- 忘记
delete或free: 最常见的原因,程序员在new或malloc后,忘记调用delete或free来释放内存。 - 指针丢失: 指向动态分配内存的指针被覆盖或丢失,导致无法再访问到这块内存,也就无法释放。
- 异常安全问题: 在
try-catch块中,如果在new和delete之间发生异常,导致delete语句没有被执行。 - 循环引用: 在
shared_ptr等引用计数机制中,如果两个对象互相持有对方的shared_ptr,导致引用计数永远不为零,从而无法释放。
- 忘记
- 表现: 程序运行时间越长,占用的内存越多,但实际使用的内存并没有那么多。长期运行可能导致系统性能下降,最终引发内存溢出。
- 发现:
- 内存分析工具: 使用Valgrind (Linux)、AddressSanitizer (ASan)、Visual Leak Detector (Windows) 等工具检测内存泄漏。
- 观察内存使用曲线: 长期运行程序,观察其内存使用量是否持续增长。
- 避免:
- RAII (Resource Acquisition Is Initialization): 将资源(如内存)的生命周期与对象的生命周期绑定。在对象构造时获取资源,在对象析构时自动释放资源。智能指针就是RAII的典型应用。
- 智能指针: 优先使用
std::unique_ptr和std::shared_ptr来管理动态内存。 - 配对使用
new/delete和malloc/free: 确保每次分配都有对应的释放。 - 注意异常安全: 在可能抛出异常的代码块中,确保资源能够被正确释放。
- 使用
std::weak_ptr解决循环引用: 当使用shared_ptr时,如果存在循环引用,使用weak_ptr来打破循环。
总结: 内存泄漏是内存溢出的一个潜在原因,而内存溢出是内存泄漏的最终结果之一。两者都强调了在C++中正确管理内存的重要性。
26秋招小米C++面经 (9月17日) - 参考答案
1、std::vector扩容机制:扩容机制是什么?扩容时代价多大?如何避免频繁扩容?
1.1、扩容机制:
std::vector的底层是一个动态数组。当vector的当前容量不足以容纳新元素时(例如调用push_back()),它会执行扩容操作。- 具体步骤:
- 分配新内存:
vector会申请一块比当前容量更大的内存空间。通常,新的容量是旧容量的1.5倍或2倍(具体倍数取决于标准库实现,但通常是常数倍)。 - 元素迁移: 将旧内存中的所有元素移动(C++11后)或复制(C++11前)到新的内存空间中。
- 释放旧内存: 释放原来的内存空间。
- 更新内部指针: 更新
vector内部指向内存块的指针和容量信息。
- 分配新内存:
1.2、扩容代价:
- 时间代价: 扩容操作的代价是
O(N),其中N是vector中元素的当前数量。因为需要遍历所有元素进行移动/复制。如果元素类型不支持移动语义,则只能进行复制,开销更大。 - 空间代价: 扩容会暂时占用双倍的内存空间(旧内存和新内存同时存在一段时间),直到旧内存被释放。
- 迭代器/指针/引用失效: 扩容后,
vector的底层内存地址会改变,导致所有指向旧内存的迭代器、指针和引用全部失效。这是使用vector时需要特别注意的地方。
1.3、如何避免频繁扩容:
- 预留空间: 在创建
vector时,如果能预估到大致的元素数量,可以使用reserve()成员函数预先分配足够的内存空间。这样可以避免多次扩容,减少性能开销。cppstd::vector<int> vec; vec.reserve(1000); // 预留1000个元素的空间 for (int i = 0; i < 1000; ++i) { vec.push_back(i); } - 构造时指定大小: 如果知道
vector最终会包含多少个元素,可以在构造时就指定大小,并进行初始化。cppstd::vector<int> vec(1000); // 创建包含1000个元素的vector,并进行值初始化 - 避免不必要的
push_back: 如果可以,尽量一次性填充数据,而不是多次push_back。 - 使用
emplace_back: 对于复杂对象,emplace_back可以直接在vector的内存中构造对象,避免额外的拷贝或移动操作,但它并不能避免扩容本身。
总结: 扩容是vector实现动态数组的必要机制,但其O(N)的代价和迭代器失效问题需要注意。通过reserve()预留空间是避免频繁扩容最有效的方法。
2、malloc和new的区别:C++中malloc和new的区别?delete和delete[]能混用吗?
2.1、malloc和new的区别:
(同小米C++一面问题1的C++内存管理机制部分,这里进行简要总结)
| 特性 | new | malloc |
|---|---|---|
| 本质 | C++运算符 | C库函数 |
| 类型安全 | 类型安全,返回指定类型的指针,无需强制类型转换。 | 类型不安全,返回void*,需要强制类型转换。 |
| 构造/析构 | 调用对象的构造函数进行初始化,delete调用析构函数。 | 不调用构造函数和析构函数,只分配/释放原始内存。 |
| 内存大小 | 编译器根据类型自动计算所需大小。 | 需要手动指定分配的字节数。 |
| 数组分配 | new[]用于分配数组,delete[]用于释放数组。 | malloc分配数组时,需要手动计算总字节数。 |
| 重载 | 可以被重载(operator new和operator delete)。 | 不能被重载。 |
| 内存分配失败 | 默认抛出std::bad_alloc异常。 | 返回NULL。 |
2.2、delete和delete[]能混用吗?
不能混用。
delete: 用于释放通过new分配的单个对象所占用的内存。delete[]: 用于释放通过new[]分配的数组所占用的内存。
原因: 当使用new[]分配一个对象数组时,编译器通常会在分配的内存块中额外存储一些信息,例如数组的元素个数。delete[]会利用这些信息来正确地调用数组中每个元素的析构函数,并释放整个内存块。如果对数组使用delete(而不是delete[]),编译器只会调用第一个元素的析构函数,并且可能无法正确释放整个内存块,从而导致:
- 内存泄漏: 数组中除第一个元素外的其他元素的内存未被释放。
- 未定义行为: 可能会导致程序崩溃或其他不可预测的行为。
示例:
class MyClass {
public:
MyClass() { std::cout << "MyClass constructor\n"; }
~MyClass() { std::cout << "MyClass destructor\n"; }
};
int main() {
// 正确使用
MyClass* obj = new MyClass();
delete obj; // 调用一次析构函数
MyClass* arr = new MyClass[3];
delete[] arr; // 调用三次析构函数,并正确释放内存
// 错误混用示例
MyClass* bad_arr = new MyClass[3];
// delete bad_arr; // 错误!会导致未定义行为和内存泄漏
// 编译器可能只调用第一个元素的析构函数,且可能无法正确释放整个数组内存
return 0;
}总结: 严格遵循new与delete配对,new[]与delete[]配对的原则,是C++内存管理的基本要求。
3、const成员或引用成员与移动构造函数:如果类中有const成员或引用成员,能否自动生成移动构造函数?为什么?
结论: 如果类中含有const成员或引用成员,编译器将不会自动生成移动构造函数。
原因: 移动构造函数的目的是将源对象的资源“窃取”过来,从而避免深拷贝,提高效率。这意味着源对象在移动后通常会处于一个“有效但未指定”的状态,其内部资源(如指针指向的内存)会被置空或转移。
const成员:const成员在对象构造时必须被初始化,并且之后不能被修改。移动构造函数需要修改源对象的成员(例如,将源对象的资源指针置空),以确保源对象在移动后处于安全状态。但const成员不允许这种修改。- 如果编译器自动生成移动构造函数,它将无法在移动过程中修改源对象的
const成员,这与const的语义相冲突。
引用成员:
- 引用成员在对象构造时必须被初始化,并且一旦初始化,就不能再引用其他对象(引用不能被重新赋值)。
- 移动构造函数需要将源对象的引用成员重新绑定到新的资源(如果引用的是内部资源),或者将源对象的引用成员置空(这在C++中是不允许的,因为引用必须始终引用一个有效的对象)。
- 由于引用不能被重新赋值,编译器无法生成一个能够“移动”引用成员的移动构造函数。
总结: const成员和引用成员的特性(一旦初始化就不能修改/重新绑定)与移动语义(需要修改源对象的状态)是矛盾的。因此,为了避免语义冲突和潜在的错误,C++标准规定,如果类中包含const成员或引用成员,编译器将不会自动生成移动构造函数(以及移动赋值运算符)。在这种情况下,如果你需要移动语义,你必须手动实现移动构造函数,并确保const成员和引用成员的正确处理(通常意味着这些成员不能被移动,或者需要特殊设计)。
4、内存对齐:什么是内存对齐?为什么需要内存对齐?如何手动控制结构体的内存对齐?
4.1、什么是内存对齐?
内存对齐是指编译器在为程序中的数据结构(特别是结构体struct和联合体union)分配内存时,会按照一定的规则,使得每个成员变量在内存中的起始地址都是其自身大小(或某个倍数)的整数倍。同时,整个结构体的大小也会是其最大成员变量大小(或指定对齐字节数)的整数倍。
4.2、为什么需要内存对齐?
内存对齐主要是出于以下几个原因:
CPU访问效率:
- CPU在访问内存时,通常是按字(word)或双字(double word)为单位进行存取的。如果数据没有对齐,一个数据可能跨越两个内存访问周期,导致CPU需要进行两次内存访问,然后进行额外的位操作来拼接数据,这会大大降低访问效率。
- 对齐后,CPU可以在一个内存周期内直接读取整个数据,提高访问速度。
平台兼容性:
某些硬件平台(如一些RISC处理器)对内存访问有严格的对齐要求。如果数据不对齐,可能会导致硬件异常(如总线错误、段错误)或程序崩溃。
缓存效率:
CPU缓存通常以缓存行(Cache Line)为单位进行数据传输。如果数据对齐,可以确保一个数据结构尽可能地完整地存储在一个或少数几个缓存行中,减少缓存未命中,提高缓存利用率。
原子操作:
对于某些需要原子性操作的数据(如多线程中的共享变量),如果不对齐,原子操作可能无法正确执行。
4.3、如何手动控制结构体的内存对齐?
手动控制内存对齐通常是为了优化内存使用(减少填充字节)或与特定硬件/协议进行交互。
#pragma pack(n)(GCC/MSVC等编译器扩展):- 作用: 设置编译器对齐的字节数
n。n必须是2的幂次方(如1, 2, 4, 8, 16)。 - 规则: 结构体成员的对齐方式将是
n和成员自身大小中的较小值。整个结构体的对齐方式将是n和最大成员大小中的较小值。 - 使用:cpp
#pragma pack(push, 1) // 将当前对齐设置压栈,并设置新的对齐字节为1 struct MyStruct1 { // 成员将按1字节对齐,没有填充 char a; int b; char c; }; // sizeof(MyStruct1) == 6 (1+4+1) #pragma pack(pop) // 恢复之前的对齐设置 #pragma pack(push, 4) // 设置对齐字节为4 struct MyStruct2 { char a; int b; char c; }; // sizeof(MyStruct2) == 12 (1+3(padding)+4+1+3(padding)) #pragma pack(pop) - 注意:
#pragma pack是编译器特定的扩展,不具备跨平台可移植性。使用时要谨慎,并确保在不需要时恢复默认对齐。
- 作用: 设置编译器对齐的字节数
__attribute__((packed))(GCC/Clang扩展):- 作用: 告诉编译器取消结构体内的所有填充字节,使其成员紧密排列。相当于
#pragma pack(1)。 - 使用:cpp
struct MyStruct3 { char a; int b; char c; } __attribute__((packed)); // sizeof(MyStruct3) == 6
- 作用: 告诉编译器取消结构体内的所有填充字节,使其成员紧密排列。相当于
__attribute__((aligned(n)))(GCC/Clang扩展):- 作用: 指定结构体或变量的最小对齐字节数
n。n必须是2的幂次方。 - 使用:cpp
struct MyStruct4 { char a; int b; char c; } __attribute__((aligned(8))); // 整个结构体按8字节对齐 // sizeof(MyStruct4) 可能是 12 或 16,取决于内部成员的对齐和填充
- 作用: 指定结构体或变量的最小对齐字节数
alignas(C++11标准):- 作用: C++11引入的标准关键字,用于指定变量或类型的对齐要求。
- 使用:cpp
struct alignas(8) MyStruct5 { // 整个结构体按8字节对齐 char a; int b; char c; }; // sizeof(MyStruct5) 可能是 12 或 16 alignas(16) int data[4]; // 确保data数组按16字节对齐 - 优点: 标准化,跨平台。
总结: 内存对齐是性能和兼容性的重要考量。在大多数情况下,让编译器自动处理对齐是最好的选择。只有在特定场景(如与硬件交互、网络协议、极致内存优化)下,才需要手动控制对齐,并且应优先使用alignas。
5、进程和线程的区别与通信:进程和线程的区别?进程间通信有哪些方式?哪种效率最高?
5.1、进程和线程的区别:
| 特性 | 进程 (Process) | 线程 (Thread) |
|---|---|---|
| 定义 | 资源分配的基本单位 | CPU调度的基本单位 |
| 资源拥有 | 拥有独立的地址空间、文件句柄、内存等资源 | 共享所属进程的资源(地址空间、文件等),但有自己的栈、寄存器、程序计数器 |
| 关系 | 一个进程可以包含多个线程 | 线程是进程的一部分 |
| 切换开销 | 切换开销大,需要切换页表、内核栈等 | 切换开销小,只需保存和恢复少量寄存器状态 |
| 通信 | 进程间通信(IPC)复杂,需要管道、消息队列、共享内存等 | 线程间通信简单,可以直接读写共享变量(需要同步机制) |
| 健壮性 | 一个进程崩溃不会影响其他进程 | 一个线程崩溃会导致整个进程崩溃 |
| 创建/销毁 | 创建和销毁开销大 | 创建和销毁开销小 |
5.2、进程间通信 (IPC) 有哪些方式?
进程间通信(Inter-Process Communication, IPC)是操作系统提供的一种机制,允许不同进程之间交换数据。
- 管道 (Pipe):
- 特点: 半双工(数据只能单向流动),匿名管道只能用于父子进程或兄弟进程之间,有名管道(FIFO)可以在任意无关进程间通信。
- 优点: 简单易用。
- 缺点: 效率相对较低,容量有限。
- 消息队列 (Message Queue):
- 特点: 存储在内核中的消息链表,可以实现进程间的异步通信。发送方将消息放入队列,接收方从队列中取出消息。
- 优点: 克服了管道的不足,可以实现任意进程间的通信,消息具有优先级。
- 缺点: 消息体大小有限制,需要额外的拷贝开销。
- 共享内存 (Shared Memory):
- 特点: 允许不同进程直接访问同一块物理内存。一个进程写入,另一个进程可以直接读取,无需通过内核拷贝。
- 优点: 效率最高,因为避免了数据在内核和用户空间之间的多次拷贝。
- 缺点: 需要额外的同步机制(如信号量、互斥量)来保证数据的一致性,防止数据竞争。
- 信号量 (Semaphore):
- 特点: 主要用于进程间的同步和互斥,而不是数据传输。它是一个计数器,用于控制对共享资源的访问。
- 优点: 简单有效。
- 缺点: 不传输数据。
- 套接字 (Socket):
- 特点: 可以在同一台机器上或通过网络在不同机器上的进程间通信。可以基于TCP(可靠、面向连接)或UDP(不可靠、无连接)。
- 优点: 最通用,支持网络通信。
- 缺点: 相比共享内存,效率较低,需要进行数据序列化和网络传输。
- 文件 (File):
- 特点: 进程可以通过读写同一个文件来进行通信。例如,一个进程写入文件,另一个进程读取文件。
- 优点: 简单,易于实现。
- 缺点: 效率低,需要考虑文件锁和同步问题。
5.3、哪种效率最高?
在所有进程间通信方式中,共享内存的效率最高。因为它允许进程直接访问同一块物理内存,避免了数据在用户空间和内核空间之间的多次拷贝,从而减少了系统调用的开销和数据传输的延迟。
6、线程同步机制:线程同步有哪些机制?std::mutex、std::lock_guard、std::unique_lock的区别?
6.1、线程同步机制:
线程同步的目的是协调多个线程的执行,确保它们在访问共享资源时不会产生冲突,或者在特定条件满足时才继续执行。常见的机制包括:
- 互斥量 (Mutex): 提供对共享资源的独占访问,防止数据竞争。
- 条件变量 (Condition Variable): 允许线程等待某个条件成立,并在条件成立时被通知唤醒。
- 信号量 (Semaphore): 控制对共享资源的访问数量,允许多个线程(但数量有限)同时访问。
- 读写锁 (Read-Write Lock): 允许多个读线程同时访问,但写线程独占访问,提高并发读的性能。
- 原子操作 (Atomic Operations): 对基本数据类型进行无锁的原子性操作,保证操作的完整性。
- 屏障 (Barrier): 强制多个线程在某个点上等待,直到所有线程都到达该点才能继续执行。
6.2、std::mutex、std::lock_guard、std::unique_lock的区别:
std::mutex:- 本质: C++11标准库提供的最基本的互斥量类型。它是一个对象,提供了
lock()和unlock()成员函数来手动锁定和解锁互斥量。 - 特点: 必须手动管理锁定和解锁。如果忘记调用
unlock(),或者在lock()和unlock()之间发生异常,可能导致互斥量永远处于锁定状态,从而引发死锁。 - 用途: 作为底层互斥机制,通常不直接使用,而是通过RAII包装器来使用。
- 本质: C++11标准库提供的最基本的互斥量类型。它是一个对象,提供了
std::lock_guard<std::mutex>:- 本质: 一个轻量级的RAII风格的互斥量包装器。
- 特点: 在构造时自动调用传入互斥量的
lock()方法,在析构时自动调用unlock()方法。这意味着它的生命周期与它所在的作用域绑定。 - 优点: 异常安全,无论代码如何退出作用域(正常退出或抛出异常),互斥量都会被正确解锁,有效防止死锁。
- 缺点: 灵活性差,一旦构造就锁定,直到作用域结束才解锁,不能手动解锁、尝试锁定、延迟锁定等。
- 用途: 适用于简单地保护一个代码块的临界区,是推荐优先使用的互斥量管理方式。
std::unique_lock<std::mutex>:- 本质: 一个更灵活的RAII风格的互斥量包装器,提供了比
std::lock_guard更多的功能。 - 特点: 同样在构造时可以锁定互斥量,在析构时自动解锁。但它提供了更多的控制选项:
- 延迟锁定: 可以在构造时不立即锁定,之后手动调用
lock()。 - 尝试锁定:
try_lock()尝试锁定,如果成功则返回true,否则返回false,不会阻塞。 - 定时锁定:
try_lock_for()和try_lock_until()尝试在指定时间段内锁定。 - 手动解锁: 可以手动调用
unlock()提前释放锁。 - 所有权转移: 可以通过移动语义将锁的所有权从一个
unique_lock对象转移到另一个。
- 延迟锁定: 可以在构造时不立即锁定,之后手动调用
- 优点: 提供了极大的灵活性,同时保持了RAII的异常安全特性。
- 缺点: 相对于
std::lock_guard,其对象本身会占用更多的内存,且操作开销略大。 - 用途: 适用于需要更精细控制互斥量生命周期和锁定行为的场景,例如与条件变量配合使用、实现自定义锁策略等。
- 本质: 一个更灵活的RAII风格的互斥量包装器,提供了比
总结:
std::mutex是互斥量的核心,提供基本功能。std::lock_guard是最常用的RAII封装,简单、安全、高效,适用于大多数临界区保护。std::unique_lock提供了更高级的RAII封装,功能更强大、更灵活,适用于复杂同步场景。
7、虚假共享(False Sharing):什么是虚假共享?如何避免?
7.1、什么是虚假共享 (False Sharing)?
虚假共享是一种在多核处理器系统中,由于缓存一致性协议而导致的性能问题。它发生在不同的CPU核心上运行的线程,访问不同但位于同一个缓存行中的数据时。
- 缓存行 (Cache Line): CPU缓存(L1、L2、L3)不是以字节为单位存储数据的,而是以固定大小的块(通常是32字节、64字节或128字节)为单位进行存储和传输,这些块被称为缓存行。
- 缓存一致性协议: 为了保证多核处理器中所有核心的缓存数据一致,当一个核心修改了其缓存行中的数据时,其他核心中包含相同缓存行的副本会被标记为无效(或更新)。
虚假共享的发生过程:
- 假设两个线程
T1和T2分别在CPU1和CPU2上运行。 T1需要修改变量A,T2需要修改变量B。- 变量
A和B在内存中恰好相邻,被加载到了同一个缓存行CL中。 CPU1将CL加载到其L1缓存,并修改A。根据缓存一致性协议,CPU2中包含CL的缓存行副本被标记为无效。CPU2需要修改B,发现其缓存行CL无效,于是从主存(或CPU1的缓存)中重新加载CL到其L1缓存。此时,CPU1中包含CL的缓存行副本被标记为无效。CPU1再次修改A,又导致CPU2的缓存行无效。- 这个过程不断重复,
CPU1和CPU2的缓存行在A和B之间来回“弹跳”,导致大量的缓存失效和主存访问,严重降低了性能。尽管T1和T2访问的是不同的变量,但由于它们在同一个缓存行中,导致了“虚假”的共享和竞争。
7.2、如何避免虚假共享?
避免虚假共享的核心思想是确保不同线程独立修改的数据位于不同的缓存行中。
填充 (Padding):
- 在结构体或类中,在可能被不同线程独立修改的变量之间插入填充字节,使其强制位于不同的缓存行中。
- 通常填充到缓存行大小的倍数(如64字节)。
- C++11
alignas/ C++17std::hardware_destructive_interference_size:cpp#include <atomic> #include <thread> #include <vector> #include <iostream> #include <new> // For std::hardware_destructive_interference_size // 假设缓存行大小为64字节 struct alignas(64) Counter { std::atomic<long long> value = 0; }; // 或者使用C++17的推荐方式 struct AlignedCounter { alignas(std::hardware_destructive_interference_size) std::atomic<long long> value; // 其他数据... 如果有,也需要考虑对齐 };
局部化数据:
尽量让每个线程拥有自己私有的数据副本,避免共享。如果必须共享,则尽量将共享数据集中管理,并使用适当的同步机制。
使用
std::atomic:std::atomic类型本身并不能直接避免虚假共享,但它提供了原子操作,确保了对单个变量的读写是原子的。在某些情况下,编译器或硬件可能会对std::atomic变量进行特殊处理,使其尽可能地对齐或独占缓存行,但这并非强制保证。数据结构设计:
重新设计数据结构,将可能被不同线程独立访问的变量放置在不同的结构体中,或者通过指针间接访问,以增加它们在内存中不相邻的可能性。
总结: 虚假共享是一个底层性能问题,通常在多核高并发场景下才会显现。通过填充或合理的数据结构设计,可以有效地避免它,从而提高程序的并发性能。
8、手撕代码:实现一个线程安全的环形队列(支持多生产者多消费者)。
问题分析: 实现一个线程安全的环形队列,需要解决以下几个关键问题:
- 并发访问: 多个生产者和多个消费者同时访问队列,需要保证数据的一致性和完整性。
- 满/空状态: 队列满时生产者阻塞,队列空时消费者阻塞。
- 环形特性: 队列的读写指针在达到末尾时需要“回绕”到开头。
参考答案:
我们将使用互斥量(std::mutex)来保护队列的共享状态,并使用条件变量(std::condition_variable)来处理生产者在队列满时等待、消费者在队列空时等待的场景。
环形队列的实现原理:
- 使用一个固定大小的数组作为底层存储。
- 使用两个指针(或索引):
head指向队列的第一个元素,tail指向队列的最后一个元素的下一个位置。 - 队列为空的条件:
head == tail。 - 队列为满的条件:
(tail + 1) % capacity == head。为了区分空和满,通常会牺牲一个存储单元,即实际可存储元素数量比容量少1。
手撕代码 (C++):
#include <vector>
#include <mutex>
#include <condition_variable>
#include <iostream>
#include <thread>
#include <chrono>
template <typename T>
class ThreadSafeCircularQueue {
public:
explicit ThreadSafeCircularQueue(size_t capacity)
: capacity_(capacity + 1), // 牺牲一个位置来区分空/满
buffer_(capacity + 1),
head_(0),
tail_(0) {}
// 生产者:向队列中添加元素
void push(const T& item) {
std::unique_lock<std::mutex> lock(mutex_);
// 等待队列不满
not_full_cv_.wait(lock, [this] { return !isFull(); });
buffer_[tail_] = item;
tail_ = (tail_ + 1) % capacity_;
// 通知消费者队列不为空
not_empty_cv_.notify_one();
}
// 消费者:从队列中取出元素
T pop() {
std::unique_lock<std::mutex> lock(mutex_);
// 等待队列不空
not_empty_cv_.wait(lock, [this] { return !isEmpty(); });
T item = buffer_[head_];
head_ = (head_ + 1) % capacity_;
// 通知生产者队列不满
not_full_cv_.notify_one();
return item;
}
// 检查队列是否为空
bool isEmpty() const {
return head_ == tail_;
}
// 检查队列是否已满
bool isFull() const {
return (tail_ + 1) % capacity_ == head_;
}
// 获取队列当前大小
size_t size() const {
if (tail_ >= head_) {
return tail_ - head_;
} else {
return capacity_ - head_ + tail_;
}
}
private:
size_t capacity_; // 实际存储容量 + 1
std::vector<T> buffer_;
size_t head_; // 队头索引
size_t tail_; // 队尾索引(下一个元素将要插入的位置)
mutable std::mutex mutex_; // 保护队列状态的互斥量
std::condition_variable not_full_cv_; // 队列不满的条件变量
std::condition_variable not_empty_cv_; // 队列不空的条件变量
};
// 生产者函数
void producer(ThreadSafeCircularQueue<int>& queue, int id, int num_items) {
for (int i = 0; i < num_items; ++i) {
int item = id * 1000 + i;
queue.push(item);
std::cout << "Producer " << id << " pushed: " << item << std::endl;
std::this_thread::sleep_for(std::chrono::milliseconds(50));
}
}
// 消费者函数
void consumer(ThreadSafeCircularQueue<int>& queue, int id, int num_items) {
for (int i = 0; i < num_items; ++i) {
int item = queue.pop();
std::cout << "Consumer " << id << " popped: " << item << std::endl;
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
}
// int main() {
// ThreadSafeCircularQueue<int> queue(5); // 容量为5的队列
// const int num_producers = 2;
// const int num_consumers = 2;
// const int items_per_producer = 10;
// const int items_per_consumer = 10;
// std::vector<std::thread> producers;
// for (int i = 0; i < num_producers; ++i) {
// producers.emplace_back(producer, std::ref(queue), i + 1, items_per_producer);
// }
// std::vector<std::thread> consumers;
// for (int i = 0; i < num_consumers; ++i) {
// consumers.emplace_back(consumer, std::ref(queue), i + 1, items_per_consumer);
// }
// for (auto& p : producers) {
// p.join();
// }
// for (auto& c : consumers) {
// c.join();
// }
// std::cout << "All producers and consumers finished." << std::endl;
// return 0;
// }关键点:
std::unique_lock: 用于RAII风格的锁管理,确保在wait期间可以自动释放锁,并在被唤醒后重新获取锁。std::condition_variable::wait(): 接收一个unique_lock和一个lambda谓词。当谓词为false时,wait会原子性地释放锁并阻塞当前线程。当被notify_one()或notify_all()唤醒时,它会重新获取锁,并再次检查谓词。如果谓词为true,则返回;否则继续阻塞。notify_one(): 唤醒一个等待中的线程。notify_all(): 唤醒所有等待中的线程。- 容量设计:
capacity_比实际可存储元素多1,用于区分队列空和队列满的状态。当head_ == tail_时队列为空,当(tail_ + 1) % capacity_ == head_时队列为满。
9、内存泄漏和越界排查:如何使用Valgrind或ASAN排查内存泄漏和越界问题?
9.1、Valgrind (Linux/Unix)
用途: Valgrind是一个强大的程序分析工具,主要用于检测内存管理和线程错误。它是一个框架,包含多个工具,其中最常用的是Memcheck,用于检测内存错误。
能检测的问题:
- 内存泄漏 (Memory Leaks): 未释放的堆内存。
- 非法读写 (Invalid Reads/Writes): 访问已释放的内存、越界访问数组、使用未初始化的内存。
- 使用已释放内存 (Use-after-free): 访问
delete或free后的内存。 - 双重释放 (Double-free): 对同一块内存进行多次释放。
- 栈溢出 (Stack Overflows)。
使用方法:
- 编译程序: 使用
g++ -g编译你的C++程序,包含调试信息。 - 运行Valgrind:
valgrind --leak-check=full --show-leak-kinds=all ./your_program [args]--leak-check=full:执行详细的内存泄漏检查。--show-leak-kinds=all:显示所有类型的内存泄漏(definite, indirect, possible, reachable)。
- 分析报告: Valgrind会输出详细的报告,指出内存错误发生的位置、调用栈等信息。
优点: 功能强大,能够检测多种内存错误,无需修改源代码。
缺点: 运行速度慢(通常比正常运行慢5-20倍),只支持Linux/Unix平台。
9.2、AddressSanitizer (ASan)
- 用途: ASan是一个快速的内存错误检测工具,集成在GCC和Clang编译器中。它通过在编译时插入检测代码,并在运行时进行检查。
- 能检测的问题:
- 越界访问 (Out-of-bounds access): 堆、栈、全局变量的越界读写。
- 使用已释放内存 (Use-after-free)。
- 双重释放 (Double-free)。
- 使用已返回栈内存 (Use-after-return)。
- 内存泄漏 (Memory Leaks): 通过LeakSanitizer (LSan) 模块。
- 使用方法:
- 编译程序: 使用
g++ -fsanitize=address -g编译你的C++程序。 - 运行程序: 直接运行编译后的程序即可。当检测到内存错误时,程序会立即终止并打印详细的错误报告,包括调用栈。
- 编译程序: 使用
- 优点: 速度快(通常比正常运行慢2-3倍),集成在编译器中,易于使用,报告清晰。
- 缺点: 需要重新编译代码,对内存占用有一定增加。
总结: Valgrind和ASan都是C++内存调试的利器,各有侧重。Valgrind更全面但性能开销大,适合开发阶段的深度检测;ASan更快,适合集成到日常测试和CI/CD中。
10、GDB调试死锁:如何用GDB调试死锁?thread apply all bt这个命令有什么用?
10.1、如何用GDB调试死锁?
调试死锁的关键在于识别哪些线程被阻塞,以及它们正在等待哪些资源。GDB提供了一系列命令来帮助我们分析多线程程序的死锁。
步骤:
编译程序: 使用
g++ -g编译你的C++程序,包含调试信息。运行程序并触发死锁:
- 在GDB中启动程序:
gdb ./your_program,然后run。 - 或者,如果程序已经在运行并死锁,可以使用
gdb -p <pid>附加到正在运行的进程。
- 在GDB中启动程序:
暂停程序: 当程序进入死锁状态(通常表现为程序卡住,不再响应),按下
Ctrl+C来暂停程序,进入GDB命令行。查看所有线程及其状态:
使用
info threads命令查看当前进程中所有线程的列表,包括它们的ID、目标函数和状态。分析线程堆栈:
- 关键步骤: 使用
thread apply all bt命令。这个命令会遍历所有线程,并为每个线程打印其完整的调用堆栈(backtrace)。 - 分析: 仔细检查每个线程的堆栈信息。死锁通常表现为多个线程都在等待某个锁或条件变量。在堆栈中,你会看到
pthread_mutex_lock、pthread_cond_wait等系统调用,这表明线程正在等待资源。 - 识别等待关系: 通过分析不同线程的堆栈,找出它们各自持有的锁和正在等待的锁。如果线程A持有锁X并等待锁Y,而线程B持有锁Y并等待锁X,那么就形成了死锁。
- 关键步骤: 使用
查看锁的状态(如果可能):
- 如果知道锁变量的地址,可以尝试查看其状态(例如,
p &my_mutex)。但这通常需要对底层同步原语的实现有深入了解。
- 如果知道锁变量的地址,可以尝试查看其状态(例如,
设置断点: 在可能发生死锁的代码区域(如加锁操作前)设置断点,逐步执行,观察锁的获取和释放顺序。
10.2、thread apply all bt这个命令有什么用?
thread apply all bt是GDB中一个非常强大的命令,用于在多线程调试中一次性获取所有线程的调用堆栈信息。- 作用:
thread apply all:表示对当前进程中的所有线程执行后续命令。bt(backtrace):表示打印当前线程的调用堆栈。
- 结合起来:
thread apply all bt的含义就是“对所有线程,都打印它们的调用堆栈”。 - 在死锁调试中的重要性:
- 死锁是多个线程之间相互依赖、互相等待的结果。要诊断死锁,必须同时了解所有相关线程的当前执行状态和它们正在等待什么。
- 通过
thread apply all bt,你可以一次性看到所有线程的“快照”,包括它们当前正在执行的函数、调用路径以及是否被阻塞在某个同步原语上(如pthread_mutex_lock、pthread_cond_wait等)。 - 这使得调试者能够快速识别出哪些线程处于等待状态,以及它们等待的资源是什么,从而推断出死锁的形成原因和涉及的锁资源。
总结: thread apply all bt是GDB调试多线程死锁的“杀手锏”命令,它能提供全局的线程状态视图,帮助开发者快速定位死锁的根源。
11、虚函数表:什么是虚函数表?多重继承下的虚函数表结构是怎样的?
11.1、什么是虚函数表 (vtable)?
- 定义: 虚函数表(vtable)是一个静态的、只读的函数指针数组,它存储了类中所有虚函数的地址。
- 生成时机: 在编译时由编译器为每个包含虚函数的类生成。
- 内容: 虚函数表中的每个条目对应一个虚函数。如果派生类重写了基类的虚函数,那么该条目会指向派生类中重写的函数;如果派生类没有重写,则指向基类中的函数。
- 作用: 是C++实现运行时多态(动态多态)的核心机制。每个含有虚函数的对象都含有一个虚表指针(vptr),指向其所属类的vtable。
11.2、多重继承下的虚函数表结构是怎样的?
多重继承(一个类继承自多个基类)会使虚函数表的结构变得更加复杂。编译器需要为每个基类维护一个独立的虚函数表指针,以正确处理来自不同基类的虚函数调用。
假设有一个类Derived多重继承自Base1和Base2:
class Base1 {
public:
virtual void func1_base1() { std::cout << "Base1::func1_base1()\n"; }
virtual void func_common() { std::cout << "Base1::func_common()\n"; }
virtual ~Base1() { std::cout << "~Base1()\n"; }
};
class Base2 {
public:
virtual void func1_base2() { std::cout << "Base2::func1_base2()\n"; }
virtual void func_common() { std::cout << "Base2::func_common()\n"; }
virtual ~Base2() { std::cout << "~Base2()\n"; }
};
class Derived : public Base1, public Base2 {
public:
void func1_base1() override { std::cout << "Derived::func1_base1()\n"; }
void func1_base2() override { std::cout << "Derived::func1_base2()\n"; }
void func_common() override { std::cout << "Derived::func_common()\n"; }
~Derived() override { std::cout << "~Derived()\n"; }
virtual void func_derived() { std::cout << "Derived::func_derived()\n"; }
};在多重继承中,Derived对象通常会包含多个虚表指针 (vptr),每个带有虚函数的基类对应一个vptr。
内存布局:
Derived对象会首先包含Base1的子对象部分,其中包含一个指向Base1相关虚函数表的vptr_Base1。- 紧接着是
Base2的子对象部分,其中包含一个指向Base2相关虚函数表的vptr_Base2。 - 然后是
Derived自身新增的成员。
虚函数表结构:
Derived类会生成多个虚函数表,每个虚表对应一个基类子对象。vtable_Base1(forBase1子对象):func1_base1指向Derived::func1_base1。func_common指向Derived::func_common。~Base1指向Derived::~Derived(通过调整this指针)。- 可能包含
func_derived的地址。
vtable_Base2(forBase2子对象):func1_base2指向Derived::func1_base2。func_common指向Derived::func_common。~Base2指向Derived::~Derived(通过调整this指针)。- 可能包含
func_derived的地址。
this指针调整 (Thunk):- 当通过
Base2*指针调用Derived对象的虚函数时,例如Base2* b2_ptr = new Derived(); b2_ptr->func_common();。 b2_ptr实际上指向Derived对象中Base2子对象的起始地址。这个地址与Derived对象的起始地址是不同的。- 为了让
Derived::func_common()能够正确访问Derived对象的成员,编译器会在vtable_Base2中为func_common放置一个特殊的“跳板函数”(thunk)。这个thunk会负责调整this指针(将其从Base2子对象的地址调整为Derived对象的起始地址),然后再跳转到Derived::func_common的实际实现。 - 析构函数也需要类似的
this指针调整,以确保在通过基类指针delete派生类对象时,能够正确调用派生类的析构函数。
- 当通过
总结: 多重继承下,一个派生类对象会包含多个虚表指针,每个虚表指针指向一个与特定基类相关的虚函数表。这些虚函数表可能包含指向派生类重写函数的地址,并且在必要时会通过“thunk”机制调整this指针,以确保多态调用的正确性。
12、Epoll:Epoll的水平触发和边缘触发有什么区别?使用场景是什么?
epoll是Linux下一种高效的I/O多路复用机制,用于同时监听大量文件描述符(fd)上的I/O事件。它支持两种事件通知模式:水平触发(Level Triggered, LT)和边缘触发(Edge Triggered, ET)。
12.1、水平触发 (Level Triggered, LT)
- 特点:
- 只要文件描述符上还有未处理的I/O事件,
epoll_wait就会一直通知该事件。 即使你只读取了一部分数据,只要缓冲区中还有数据可读,或者写缓冲区还有空间可写,epoll_wait就会在下一次调用时再次返回该事件。 - 兼容性好: 行为与
select/poll类似,更容易编程。
- 只要文件描述符上还有未处理的I/O事件,
- 使用场景:
- 默认模式:
epoll的默认工作模式就是LT。 - 简单编程: 适用于大多数I/O场景,因为即使没有一次性处理完所有数据,下次
epoll_wait也会再次通知,降低了编程的复杂性。 - 阻塞I/O: LT模式下,通常可以配合阻塞I/O使用,因为即使只读了一部分,下次还会通知,不会丢失事件。
- 默认模式:
12.2、边缘触发 (Edge Triggered, ET)
- 特点:
- 只有当文件描述符上的I/O状态发生“边沿变化”时,
epoll_wait才会通知该事件。 例如,当有新数据从不可读变为可读时,只会通知一次。如果应用程序没有一次性将所有数据读完,那么在下一次epoll_wait调用时,即使缓冲区中还有数据,也不会再收到该事件的通知,直到有新的数据到达,再次发生状态变化。 - 高性能: 减少了
epoll_wait的调用次数和事件通知次数,提高了效率。 - 编程复杂: 要求应用程序必须一次性处理完所有可用的I/O数据(“读到EAGAIN/EWOULDBLOCK”或“写到EAGAIN/EWOULDBLOCK”),否则会丢失事件。
- 只有当文件描述符上的I/O状态发生“边沿变化”时,
- 使用场景:
- 高性能服务器: 适用于需要处理大量并发连接,并且对性能要求极高的场景,如Web服务器、数据库代理等。
- 非阻塞I/O: ET模式下,文件描述符必须设置为非阻塞模式。因为如果使用阻塞I/O,一次
read或write操作可能会因为数据不足或缓冲区满而阻塞,导致无法处理其他事件。 - 全量读写: 应用程序必须循环读取/写入数据,直到
read()返回0(EOF)或-1且errno为EAGAIN/EWOULDBLOCK,表示数据已读完或缓冲区已满。
对比总结:
| 特性 | 水平触发 (LT) | 边缘触发 (ET) |
|---|---|---|
| 通知机制 | 只要有事件就通知 | 只有状态发生变化时才通知 |
| 重复通知 | 会 | 不会 |
| 编程难度 | 简单 | 复杂,需要一次性处理所有数据 |
| I/O模式 | 可配合阻塞或非阻塞I/O | 必须配合非阻塞I/O |
| 性能 | 相对较低 | 相对较高 |
| 默认模式 | 是 | 否 |
示例: 假设一个socket有100字节数据可读。
- LT模式: 应用程序读取了50字节。下次
epoll_wait仍然会通知该socket可读,直到所有100字节都被读完。 - ET模式: 应用程序读取了50字节。下次
epoll_wait不会再通知该socket可读,除非有新的数据到达。如果应用程序没有一次性读完所有100字节,剩下的50字节就会“丢失”事件通知,除非主动去读。
13、TCP拥塞控制与TIME_WAIT:讲一下TCP拥塞控制机制?TIME_WAIT状态的作用是什么?
13.1、TCP拥塞控制机制:
TCP拥塞控制是为了防止过多的数据注入到网络中,导致网络资源饱和,从而降低网络的吞吐量和增加延迟。
它主要通过以下四种算法协同工作:
- 慢启动 (Slow Start): 连接建立初期,
cwnd指数级增长,快速探测网络带宽。 - 拥塞避免 (Congestion Avoidance): 当
cwnd达到ssthresh后,cwnd线性增长,保守利用带宽。 - 快速重传 (Fast Retransmit): 通过接收到三个重复ACK来快速判断丢包,并立即重传。
- 快速恢复 (Fast Recovery): 配合快速重传,在丢包后不进入慢启动,直接进入拥塞避免,加速恢复。
13.2、TIME_WAIT状态的作用是什么?
TIME_WAIT是TCP连接终止过程(四次挥手)中的一个状态,由主动关闭连接的一方进入。它在TCP连接中扮演着非常重要的角色,主要有两个作用:
可靠地终止TCP连接 (Reliable Connection Termination):
TIME_WAIT状态的持续时间是2MSL (Maximum Segment Lifetime),即两个报文段的最大生存时间。MSL是指一个报文段在网络中能够存活的最长时间。- 在
TIME_WAIT状态下,主动关闭方会等待2MSL时间,以确保它发送的最后一个ACK报文段能够到达被动关闭方。如果这个ACK丢失了,被动关闭方会重传它的FIN报文段,主动关闭方在TIME_WAIT状态下收到重传的FIN后,会再次发送ACK并重新启动2MSL计时器。 - 这个等待期确保了被动关闭方能够收到最终的
ACK,从而使其连接状态从LAST_ACK正确地迁移到CLOSED,实现了TCP连接的可靠关闭。
防止旧连接的数据包对新连接造成干扰 (Preventing Delayed Segments from Old Connections):
- 假设一个连接在关闭后立即被重新建立,并且使用了相同的IP地址和端口号对(即相同的四元组)。
- 如果在旧连接关闭过程中,网络中仍然有延迟的、属于旧连接的数据包(例如,旧连接的
FIN或ACK报文段)滞留,这些数据包可能会在2MSL之后才到达。如果此时没有TIME_WAIT状态,并且新的连接已经建立,这些延迟的数据包可能会被新连接错误地接收,导致数据混乱。 TIME_WAIT状态的存在,确保了在2MSL时间内,所有属于旧连接的延迟数据包都已从网络中消失。这样,当一个新的连接使用相同的四元组建立时,就不会受到旧连接残留数据包的干扰。
TIME_WAIT状态的缺点:
- 如果服务器作为主动关闭方,并且有大量的短连接,可能会产生大量的
TIME_WAIT状态的socket,占用系统资源(端口号和内存),导致新的连接无法建立(端口耗尽)。
如何优化TIME_WAIT:
- 调整内核参数: 在Linux中,可以通过调整
net.ipv4.tcp_tw_reuse和net.ipv4.tcp_tw_recycle(已废弃)等内核参数来重用或快速回收TIME_WAIT状态的socket。但这些参数的使用需要谨慎,可能带来一些副作用。 - 负载均衡: 使用负载均衡器,将请求分发到多个后端服务器,避免单个服务器产生过多
TIME_WAIT。 - 长连接: 尽量使用HTTP长连接,减少连接的建立和关闭次数。
