面试真题 | 诺瓦星云[20240901]
@[toc]
1.介绍c++线程的创建的方法thread()创建
C++ 线程创建方法 - 使用 std::thread
在C++中,线程的创建可以通过<thread>
库中的std::thread
类来实现。以下是一个简单的示例,展示了如何使用std::thread
的构造函数来创建一个线程:
#include <iostream>
#include <thread>
void threadFunction() {
std::cout << "Hello from thread!" << std::endl;
}
int main() {
std::thread t(threadFunction); // 创建线程
// 等待线程结束
t.join();
return 0;
}
在这个例子中,std::thread
的构造函数接收了一个函数(或可调用对象,如函数指针、lambda表达式、绑定表达式等)作为参数,并在新的线程中执行这个函数。t.join()
调用是必需的,以确保主线程等待新创建的线程完成其执行。
追问
-
问题: 在多线程环境下,如果多个线程需要访问和修改同一个共享数据,可能会引发什么问题?如何避免这些问题?
答案: 在多线程环境下,对共享数据的访问可能引发竞态条件(race condition),导致数据不一致或未定义行为。为了避免这些问题,可以使用互斥锁(如
std::mutex
)、读写锁(如std::shared_mutex
)、条件变量(std::condition_variable
)或原子操作(std::atomic
)等同步机制来保护对共享数据的访问。 -
问题:
std::thread
的detach()
方法有什么作用?使用它时需要注意什么?答案:
std::thread
的detach()
方法用于将线程与std::thread
对象分离,使得线程在后台执行,而std::thread
对象可以被销毁。一旦线程被分离,就无法再通过std::thread
对象对其进行操作(如join()
或再次detach()
)。使用detach()
时需要特别注意,因为如果后台线程访问了主线程中的局部变量(在std::thread
对象销毁后这些局部变量可能已不再存在),将导致未定义行为。此外,分离的线程必须自行管理其生命周期,确保所有资源被正确释放。 -
问题: C++11标准中引入了哪些特性来支持多线程编程?除了
std::thread
之外,还有哪些重要的类或函数?答案: C++11标准引入了一系列支持多线程编程的特性,包括但不限于:
std::thread
:用于表示和管理线程。std::mutex
、std::recursive_mutex
、std::timed_mutex
等互斥锁:用于保护共享数据。std::lock_guard
、std::unique_lock
:用于自动管理互斥锁的生命周期,简化互斥锁的使用。std::condition_variable
、std::condition_variable_any
:用于线程间的同步,等待某个条件成立。std::atomic
:提供无锁的原子操作,适用于简单数据的线程安全访问。std::future
、std::promise
、std::packaged_task
:用于异步编程,支持从异步操作中获取结果。std::async
:一个启动异步任务的便捷函数,返回一个std::future
对象。
-
问题: 在C++中,如何使用
std::mutex
来避免竞态条件,并给出一个示例代码?答案:
std::mutex
(互斥锁)是C++中用于控制对共享资源访问的一种同步机制。它可以防止多个线程同时进入临界区(即访问共享资源的代码段)。以下是一个使用std::mutex
来避免竞态条件的示例代码:#include <iostream> #include <thread> #include <mutex> std::mutex mtx; // 全局互斥锁 int shared_data = 0; // 共享数据 void increment() { mtx.lock(); // 锁定互斥锁 ++shared_data; // 安全地修改共享数据 mtx.unlock(); // 解锁互斥锁 } int main() { std::thread t1(increment); std::thread t2(increment); t1.join(); t2.join(); std::cout << "Shared data: " << shared_data << std::endl; // 预期输出为2 return 0; }
在这个例子中,
mtx
是一个全局的std::mutex
对象,用于保护对shared_data
的访问。当increment
函数被两个线程分别调用时,它们会先尝试锁定mtx
。由于互斥锁的特性,同一时间只有一个线程能够成功锁定它,从而避免了竞态条件。 -
问题:
std::atomic
类型与互斥锁相比有什么优势?在什么情况下应该优先考虑使用std::atomic
?答案:
std::atomic
类型提供了无锁的原子操作,这意味着它们可以直接在硬件层面进行操作,而不需要使用互斥锁来避免竞态条件。与互斥锁相比,std::atomic
通常具有更低的开销和更高的性能,尤其是在对单个数据项进行频繁操作时。应该优先考虑使用
std::atomic
的情况包括:- 当只需要对单个整型数据进行原子操作时。
- 当性能是关键因素,且不需要保护多个变量或执行更复杂的同步操作时。
- 当不需要担心死锁问题时(因为
std::atomic
不涉及锁机制)。
然而,需要注意的是,
std::atomic
并不是万能的。在某些情况下,例如需要保护多个共享资源或执行更复杂的同步逻辑时,仍然需要使用互斥锁或其他同步机制。 -
问题: C++中如何处理线程间的通信?有哪些常用的机制?
答案: C++中处理线程间通信的常用机制包括:
- 共享内存:线程可以通过访问共享内存(如全局变量、静态变量等)来交换信息。这种方式需要适当的同步机制(如互斥锁、条件变量等)来避免竞态条件。
- 消息传递:虽然C++标准库本身不直接支持消息传递机制,但可以通过其他方式(如使用队列、管道等)来模拟。在C++中,这通常涉及使用额外的库或框架。
- 条件变量:
std::condition_variable
是C++11引入的一种同步机制,它允许一个或多个线程在某个条件成立时被唤醒。这可以用于实现生产者-消费者模型等复杂的线程间通信场景。 - 原子操作:
std::atomic
不仅可以用于避免竞态条件,还可以在某些情况下用于线程间的简单通信(例如,通过修改一个原子变量来通知其他线程某个事件的发生)。
每种机制都有其适用的场景和优缺点,选择哪种机制取决于具体的应用需求和性能考虑。
2.介绍c++虚函数和底层实现
C++ 虚函数及其底层实现介绍
虚函数(Virtual Functions) 在 C++ 中是一种用于实现多态性的机制。当一个类中的成员函数被声明为虚函数时,这意味着该函数的行为可以在派生类中被重写(Override)。在通过基类指针或引用调用虚函数时,实际调用的函数版本会根据对象的实际类型(即运行时类型)来确定,而不是根据指针或引用的静态类型。这允许我们编写不依赖于对象具体类型的代码,从而提高了程序的灵活性和可扩展性。
底层实现:
在大多数现代编译器和操作系统中,虚函数的实现依赖于虚函数表(Virtual Function Table,简称 VTable)。每个包含虚函数的类都会有一个与之对应的虚函数表,表中存储了该类所有虚函数的地址。当对象被创建时,该对象的内部会包含一个指向其类虚函数表的指针(通常称为虚指针,vptr)。通过这个虚指针和虚函数表,程序在运行时能够确定并调用正确的虚函数版本。
构造函数和析构函数:
- 构造函数:不能被声明为虚函数,因为构造时对象的类型已经确定,且虚函数机制依赖于对象的完整类型信息。
- 析构函数:通常声明为虚函数,特别是在设计基类时,以确保通过基类指针删除派生类对象时,能够调用到正确的析构函数,避免资源泄漏。
追问
-
问题: 虚函数调用的开销主要体现在哪些方面?如何减少这种开销?
答案: 虚函数调用的开销主要来自于两个方面:一是通过虚函数表间接调用函数引入的间接寻址开销;二是虚函数表本身占用的内存空间。为了减少这种开销,可以采取以下措施:
- 在对性能要求极高的代码路径中,考虑使用其他设计模式(如策略模式)替代虚函数。
- 尽量减少虚函数的调用次数,特别是避免在循环或高频调用的函数中频繁使用虚函数。
- 使用编译器优化选项,某些编译器可能提供针对虚函数调用的优化策略。
-
问题: 如果在构造函数或析构函数中调用虚函数,会发生什么?这种行为是否总是可预测的?
答案: 在构造函数或析构函数中调用虚函数,其行为可能不是预期的,因为此时对象的类型尚未完全构造或已经开始析构,虚函数表可能指向的是基类版本的函数,而不是派生类重写的版本。这会导致调用的函数可能不是预期的派生类版本,从而引起逻辑错误或资源泄露。因此,在构造函数和析构函数中调用虚函数是不推荐的做法,其行为通常不是可预测的。
-
问题: 如何实现纯虚函数和抽象类?纯虚函数和虚析构函数在类设计中分别扮演什么角色?
答案: 纯虚函数是在基类中声明的,但没有提供具体实现的虚函数,其声明以
= 0
结尾。包含至少一个纯虚函数的类被称为抽象类,抽象类不能被实例化。纯虚函数在类设计中用于指定派生类必须实现的接口,从而强制派生类遵循一定的行为模式。虚析构函数在基类中被声明为虚函数,目的是确保通过基类指针删除派生类对象时,能够调用到派生类的析构函数,从而正确释放派生类特有的资源。虚析构函数在类设计中扮演着防止资源泄露的重要角色。
3.c++虚函数表(底层实现)
C++虚函数表(VTable)的底层实现介绍
在C++中,虚函数表(Virtual Table,简称VTable)是实现多态性的关键机制之一。当一个类包含至少一个虚函数时,编译器会为这个类创建一个虚函数表。虚函数表是一个存储了类中所有虚函数地址的数组(或类似结构),每个对象在实例化时都会包含一个指向其所属类虚函数表的指针(通常称为vptr)。
实现细节:
-
虚函数表的创建:当编译器遇到包含虚函数的类时,它会为该类创建一个虚函数表。表中每个条目对应类中的一个虚函数,存储的是该虚函数的地址。
-
vptr的添加:编译器会在每个包含虚函数的类的对象中插入一个隐藏的指针(vptr),指向该对象的虚函数表。这个指针的初始化通常发生在对象的构造函数中。
-
多态调用:当通过基类指针或引用调用虚函数时,程序实际上是通过vptr找到虚函数表,然后根据虚函数表中的地址来调用相应的函数。这允许子类覆盖基类的虚函数,并在运行时根据对象的实际类型调用正确的函数。
-
构造函数和析构函数:构造函数和析构函数虽然可以是虚的,但它们的调用并不通过虚函数表。构造函数在对象完全构造之前就被调用,此时对象的vptr可能还未被初始化或指向基类的虚函数表。析构函数在对象生命周期结束时调用,但通常不鼓励通过基类指针删除派生类对象,除非基类析构函数是虚的,以确保正确的析构顺序和清理。
追问
-
问题:在多继承场景下,虚函数表是如何工作的?特别是当多个基类都包含虚函数时。
答案:在多继承中,每个包含虚函数的基类都会为派生类贡献一个虚函数表。派生类对象内部会包含多个vptr,每个vptr指向一个基类对应的虚函数表。这称为“虚函数表的菱形问题”或“钻石继承问题”。为了解决这个问题,C++引入了虚基类(virtual base class)的概念,使得虚基类在继承体系中的虚函数表只被共享一次,无论它被多少个子类继承。
-
问题:构造函数和析构函数为什么通常不推荐设为虚函数,但在某些情况下又必须设为虚函数?
答案:构造函数设为虚函数没有意义,因为构造函数是在对象完全构造之前调用的,此时对象的类型(包括vptr)还未完全确定。析构函数则不同,当通过基类指针删除派生类对象时,如果基类的析构函数不是虚的,那么只会调用基类的析构函数,导致派生类部分未被正确析构,造成资源泄漏。因此,当基类被用作多态基类时,其析构函数应该声明为虚的,以确保通过基类指针删除派生类对象时能够调用到正确的析构函数。
-
问题:如何手动访问或修改虚函数表(假设这是可行的,尽管通常不推荐)?
答案:直接访问或修改虚函数表在标准C++中是不被支持的,因为虚函数表是编译器实现的一部分,其细节对程序员是隐藏的。然而,在某些特定环境或编译器下,可能通过一些非标准或黑客手段(如内存操作)来间接访问或修改虚函数表。但这种做法非常危险,因为它破坏了C++的类型安全和封装性,可能导致未定义行为。在正常的软件开发中,应该避免这种做法。
4.作为函数参数时指针和引用的区别
指针和引用作为函数参数的区别
在C++中,指针和引用都可以作为函数参数传递,但它们之间存在几个关键的区别:
-
初始化:
- 指针:在作为函数参数时,可以不进行初始化(尽管这通常不是个好习惯,因为它可能导致未定义行为)。
- 引用:必须被初始化,并且一旦与某个对象绑定后,就不能再改变为引用另一个对象。
-
空值:
- 指针:可以指向空(nullptr或NULL),表示不指向任何对象。
- 引用:必须始终引用一个有效的对象,不能是空引用。
-
语法:
- 指针:使用
*
符号进行解引用,访问指向的对象。 - 引用:在声明时通过
&
符号引入,但在后续使用中与普通变量无异,不需要额外的解引用操作。
- 指针:使用
-
修改:
- 通过指针和引用传递给函数的参数,都可以在函数内部被修改,并且这些修改会反映到原始对象上(除非是指向常量或引用常量)。但是,指针还可以改变其指向(即指向另一个对象),而引用一旦绑定就不能改变。
追问
-
问题:在函数参数传递中,什么情况下你会优先使用指针而不是引用?
答案:
- 当需要传递可选参数(即可能不指向任何对象的参数)时,指针更为合适,因为引用必须始终绑定到一个对象上。
- 当需要修改指针本身(即让它指向另一个对象)时,只能使用指针。
- 当涉及大型对象或对象数组的传递,并且不需要修改对象本身(只通过指针/引用访问),使用指针可以减少拷贝开销(尽管在现代C++中,传递大型对象或容器时通常会考虑使用移动语义或引用传递以避免拷贝)。
-
问题:在C++中,智能指针(如
std::shared_ptr
和std::unique_ptr
)作为函数参数时,与裸指针相比有哪些优势?答案:
- 自动内存管理:智能指针能够自动管理其指向对象的生命周期,减少内存泄漏的风险。
- 所有权语义明确:
std::unique_ptr
表示独占所有权,而std::shared_ptr
表示共享所有权。这有助于设计更清晰的接口和减少错误。 - 线程安全(对于
std::shared_ptr
):在多线程环境中,std::shared_ptr
提供了安全的共享对象访问机制,通过原子操作管理所有权计数。 - 简化接口设计:使用智能指针可以简化接口设计,使得函数不再需要处理原始指针的复杂逻辑,如检查空指针等。
-
问题:在嵌入式系统中,由于资源限制,使用引用作为函数参数可能面临哪些挑战?
答案:
- 栈空间限制:嵌入式系统往往具有有限的栈空间。虽然引用本身不占用额外的栈空间(因为它只是一个别名),但如果引用的对象很大或复杂,并且这些对象是在栈上分配的,那么可能会耗尽栈空间。
- 指针和引用的选择:在嵌入式系统中,由于资源受限,程序员可能更倾向于使用指针,因为它们提供了更多的灵活性(如改变指向、检查空值等),并且在某些情况下可能减少内存使用(如通过指针共享大型数据结构)。
- 代码大小和性能:虽然引用和指针在大多数情况下对代码大小和性能的影响可以忽略不计,但在嵌入式系统的极端情况下,这些微小的差异也可能变得重要。
5.如何对传入的指针区域进行释放
回答
在嵌入式系统或任何C/C++编程环境中,对传入的指针区域进行释放通常指的是释放该指针所指向的动态分配的内存。这通常通过free
(在C中)或delete
(在C++中,如果是指针指向单个对象)或delete[]
(如果是指针指向对象数组)来完成。然而,为了确保安全和正确性,你需要遵循几个关键步骤:
-
检查指针是否为空:在尝试释放内存之前,先检查指针是否为
NULL
或nullptr
(在C++中),这可以防止程序在尝试释放未分配的内存时崩溃。 -
释放内存:使用
free
(C)或delete
/delete[]
(C++)来释放指针所指向的内存。 -
将指针置为NULL/nullptr:释放内存后,将指针设置为
NULL
(C)或nullptr
(C++),这是一个好习惯,可以防止野指针问题。
示例代码(C++):
void releaseMemory(int* ptr) {
if (ptr != nullptr) {
delete[] ptr; // 假设ptr是指向一个int数组的指针
ptr = nullptr; // 将指针置为nullptr,但注意这不会改变调用者的指针值
}
}
// 注意:上面的函数无法直接修改调用者的指针值,因为C++按值传递指针。
// 要修改调用者的指针值,需要使用指针的指针或引用。
void releaseMemorySafe(int*& ptr) {
if (ptr != nullptr) {
delete[] ptr;
ptr = nullptr; // 现在可以修改调用者的指针值了
}
}
追问
-
问题:在C++中,为什么我们常说“不要使用
delete
来释放通过new[]
分配的内存,反之亦然”?答案:在C++中,
new
和delete
用于单个对象的动态内存分配和释放,而new[]
和则用于对象数组的动态内存分配和释放。它们之间必须正确匹配使用,因为不仅为对象数组分配内存,还可能执行额外的构造操作(如调用构造函数),而则在释放内存前执行相应的析构操作(如调用析构函数)。如果不匹配使用(如用释放分配的内存),则可能导致未定义行为,包括但不限于内存泄漏、资源未正确释放或程序崩溃。
剩余60%内容,订阅专栏后可继续查看/也可单篇购买
让实战与真题助你offer满天飞!!! 每周更新!!! 励志做最全ARM/Linux嵌入式面试必考必会的题库。 励志讲清每一个知识点,找到每个问题最好的答案。 让你学懂,掌握,融会贯通。 因为技术知识工作中也会用到,所以踏实学习哦!!!