1. 虚函数(Virtual Function)
-
定义:用
virtual
声明,允许派生类重写(覆盖)基类函数,实现运行时多态 -
核心特性:
- 动态绑定:通过基类指针/引用调用虚函数时,实际调用的是对象类型的函数(运行时确定)
- 虚函数表(vtable):每个包含虚函数的类有一个虚函数表,存储虚函数地址;对象内存中包含指向该表的指针(vptr)
- 虚析构函数:若基类指针指向派生类对象,基类析构函数必须为虚函数,否则可能导致资源泄漏
class Animal {
public:
virtual void speak() { cout << "Animal speaks" << endl; }
};
class Dog : public Animal {
public:
void speak() override { cout << "Dog barks" << endl; }
};
2. 纯虚函数(Pure Virtual Function)
-
定义:在基类中声明但没有实现的虚函数,形式为
virtual 返回类型 函数名() = 0;
-
核心特性:
- 抽象类:包含纯虚函数的类称为抽象类,不能实例化,只能作为基类
- 强制派生类实现:派生类必须实现所有纯虚函数,否则仍为抽象类
- 接口规范:纯虚函数定义接口,派生类提供具体实现
class Shape { public: virtual double area() const = 0; // 纯虚函数 }; class Circle : public Shape { public: double area() const override { return 3.14 * r * r; } };
3.若基类有其他虚函数(如普通虚函数或纯虚函数),则析构函数必须为虚函数。
若基类析构函数非虚,通过基类指针删除派生类对象时,只会调用基类析构函数,导致派生类独有的资源(如动态内存、文件句柄)泄漏。
class AbstractBase {
public:
virtual ~AbstractBase() = 0; // 声明为纯虚析构函数
};
// 必须提供纯虚析构函数的实现
AbstractBase::~AbstractBase() {
// 基类析构逻辑(可空)
}
class Derived : public AbstractBase {
public:
~Derived() override {
// 派生类析构逻辑
}
};
int main() {
AbstractBase* obj = new Derived();
delete obj; // 正确调用Derived和AbstractBase的析构函数
return 0;
}
4. C++构造函数不能是虚函数的原因:
-
对象构造顺序:
构造函数调用从基类到派生类逐层执行。虚函数表(vtable)在基类构造函数完成后才初始化,若构造函数为虚函数,此时无法通过虚机制动态分派。 -
静态绑定需求:
构造对象的类型必须在编译时明确(如new Derived
),无需运行时多态。虚函数的核心是动态绑定,与构造的静态特性冲突。 -
逻辑矛盾:
虚函数依赖已初始化的虚表指针(vptr),但vptr是在构造函数中初始化的。若构造函数为虚函数,调用时vptr尚未就绪,导致无法找到实际函数地址。
2. 修饰符public ,private,protected
以下是C++中访问修饰符在继承中的区别表格及核心总结:
基类成员访问权限 | public继承 | protected继承 | private继承 |
---|---|---|---|
public | 在派生类仍为public | 在派生类变为protected | 在派生类变为private |
protected | 在派生类仍为protected | 在派生类仍为protected | 在派生类变为private |
private | 不可访问 | 不可访问 | 不可访问 |
访问修饰符 | 权限范围 |
---|---|
public | 类内、派生类、类外部均可直接访问 |
protected | 类内和派生类可访问,类外部不可访问 |
private | 仅类内可访问,派生类和外部均不可访问 |
3.友元(Friend)
允许非成员函数或另一个类访问当前类的私有成员。
- 派生类不能继承基类的友元关系。
- 友元类的派生类也无法访问原类的私有成员。
class A {
int secret;
friend void printSecret(const A& a); // 友元函数
friend class B; // 友元类
};
void printSecret(const A& a) {
cout << a.secret; // 合法访问私有成员
}
4.Singleton
确保一个类只有一个实例,并提供全局访问点,常用于需要全局唯一对象或共享资源的场景。
#include <mutex>
class Singleton {
public:
// 删除拷贝构造和赋值操作
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
// 获取唯一实例的静态方法
static Singleton& getInstance() {
static Singleton instance; // C++11 保证局部静态变量的线程安全性
return instance;
}
// 示例方法
void doSomething() {
// 业务逻辑
}
private:
// 私有化构造函数
Singleton() = default;
~Singleton() = default;
};
5.线程与进程
-
进程
- 是操作系统资源分配的最小单位,代表程序的一次执行实例(如运行中的微信程序)。
- 拥有独立的内存空间(代码段、数据段、堆栈等)、文件句柄和系统资源
- 进程间完全隔离,一个进程崩溃不会影响其他进程
-
线程
- 是CPU调度的最小单位,属于进程内的执行单元( 如数据库应用中处理查询的多个子任务)。
- 共享进程的资源(内存、文件等),但拥有独立的栈和程序计数器
- 线程崩溃可能导致整个进程终止
6.什么是缓存
答:缓存,就是数据交换的缓冲区,是一种用于临时存储数据的高效存储机制,其主要目的是加快访问速度、减轻后台系统压力,从而提升整体性能。我们平时说的缓存大多是指内存。目的是,把读写速度慢的介质的数据保存在读写速度快的介质中(这里的快与慢是相对概念),从而提高读写速度,减少时间消耗。例如:
- CPU高速缓存:告诉缓存的读写速度远高于内存。
-
- CPU读数据时,如果在高速缓存中找到所需数据,就不需要读内存
- CPU写数据时,先写到高速缓存,再写回内存。
- 磁盘缓存:磁盘缓存其实就把常用的磁盘数据保存在内存中,内存读写速度也是远高于磁盘的。
-
- 读数据时从内存中读取
- 写数据时,可先写回内存,定时或定量写回到磁盘,或者是同步写回。
7.智能指针
作用:自动管理动态内存,避免内存泄漏(基于RAII机制)。
常见类型:
- **
unique_ptr
**- 独占所有权,不可复制,仅支持移动(
std::move
)。 - 适用于单一所有权的场景(如工厂模式返回对象)。
- 独占所有权,不可复制,仅支持移动(
- **
shared_ptr
**- 共享所有权,通过引用计数管理资源,计数为0时释放内存。
- 可能产生循环引用问题(需结合
weak_ptr
解决)。
- **
weak_ptr
**- 不增加引用计数,解决
shared_ptr
循环引用问题。 - 需通过
lock()
转为shared_ptr
以访问对象。
- 不增加引用计数,解决
底层原理:
- 利用RAII(资源获取即初始化),在析构函数中自动释放资源。
- 推荐使用
make_shared
/make_unique
(避免直接new
,更高效且安全)
智能指针可以自动释放内存,避免内存泄漏。那如果内存泄露了怎么办
- 使用
_CrtDumpMemoryLeaks()
(调试模式运行),程序结束时会输出泄漏信息。
#include <crtdbg.h>
int main() {
_CrtSetDbgFlag(_CRTDBG_ALLOC_MEM_DF | _CRTDBG_LEAK_CHECK_DF);
int* leak = new int(42); // 故意泄漏
return 0;
}
Detected memory leaks!
Dumping objects -> {123} normal block at 0x00AABBCC, 4 bytes long.
Data: <*> CD CD CD CD
8.多态(运行时多态)
核心机制:通过虚函数表(vtable)和虚表指针(vptr)实现动态绑定。
- 虚函数表(vtable)
- 每个含虚函数的类有一个vtable,存储该类虚函数的地址。
- 派生类继承基类vtable,并覆盖重写的虚函数地址。
- 虚表指针(vptr)
- 每个对象内部含一个vptr,指向所属类的vtable。
- 调用虚函数时,通过vptr查找vtable,再执行对应的函数。
实现条件:
- 基类定义虚函数(
virtual
关键字)。 - 派生类重写(
override
)基类虚函数。 - 通过基类指针或引用调用虚函数。
9.堆和栈的区别
1. 内存管理方式
-
栈:由编译器自动管理(隐式分配/释放)。
函数中的局部变量、参数、返回值等由编译器自动压栈(分配)和弹栈(释放),无需手动干预。栈:向低地址方向增长(地址递减)cpp
void func() { int a = 10; // 栈上分配,函数结束自动释放 }
-
堆:需程序员手动管理(显式分配/释放)。堆:向高地址方向增长(地址递增)
通过new
/malloc
申请内存,delete
/free
释放,忘记释放会导致内存泄漏。cpp
void func() { int* p = new int(10); // 堆上分配,需手动 delete delete p; // 必须显式释放 }
2. 分配与释放效率
- 栈:高效且严格有序。
分配/释放仅需移动栈指针(如push
/pop
操作),无碎片问题。 - 堆:低效且灵活。
需在运行时动态查找可用内存块,频繁分配/释放不同大小内存会导致外碎片。
3. 内存大小限制
- 栈:空间较小(默认几MB)。
由编译器或操作系统预设,超出会引发栈溢出(Stack Overflow)。 - 堆:空间较大(受系统虚拟内存限制)。
理论上可达数GB(如64位系统),但受物理内存和程序逻辑约束。
4. 生命周期
- 栈:与作用域绑定。
变量随函数调用结束自动销毁(如局部变量)。 - 堆:与显式释放操作绑定。
内存生命周期由程序员控制,可跨函数传递(如动态对象)。
5. 访问方式
- 栈:直接通过变量名访问。
内存地址连续,硬件优化支持(如CPU缓存)。 - 堆:通过指针间接访问。
内存地址可能分散,访问速度稍慢。
6. 碎片问题
- 栈:无碎片。
严格的先进后出(FILO)机制保证内存连续。 - 堆:可能存在外碎片。
频繁分配/释放不同大小内存块会导致空闲内存不连续。
7. 应用场景
- 栈:
适合局部变量、函数调用、临时对象(如std::string
的短字符串优化)。 - 堆:
适合动态数据结构(如链表、树)、大块内存(如图像缓存)、需长期存在的对象。
总结回答示例
“栈由编译器自动管理,分配高效但空间有限,适合局部变量;堆需手动管理,空间大但可能碎片化,适合动态内存需求。栈变量随作用域结束销毁,堆内存需显式释放。实际开发中,优先使用栈,避免不必要的堆分配以提高性能。”
维度 | 堆(Heap) | 栈(Stack) |
---|---|---|
管理方式 | 手动管理(new /malloc 和delete /free ) | 编译器自动管理(压栈/弹栈) |
分配效率 | 低(需动态查找可用内存块) | 高(仅移动栈指针) |
内存大小 | 受系统虚拟内存限制(理论上可达数GB) | 默认较小(如Windows默认1MB,Linux 8MB) |
生命周期 | 由程序员控制(显式释放前一直存在) | 与作用域绑定(如函数结束时自动释放) |
碎片问题 | 可能存在外碎片(频繁分配不同大小内存块) | 无碎片(严格先进后出) |
访问速度 | 较慢(需指针间接访问) | 极快(CPU缓存优化,直接寻址) |
典型用途 | 动态数据结构(链表、树)、大内存对象(图像缓存) | 局部变量、函数参数、临时对象 |
10.内存碎片问题
造成堆内存利用率很低的一个主要原因就是内存碎片化。内存碎片化就是计算机程序在运行过程中,频繁地内存分配与释放引起的内存空间不连续性问题,可能导致内存利用率降低甚至无法分配所需的内存。内存碎片主要分为内碎片和外碎片两种类型。
1.内碎片
:
•定义:内碎片指已分配的内存块未被实际使用的部分。即程序请求的内存小于分配的内存块大小时,多余的部分形成内碎片。
•产生原因:内存分配器通常按固定的对齐规则分配内存块(如对齐到4字节或8字节),分配大小往往是申请大小的倍数。
•举例:程序需要13字节内存,但内存分配器按16字节对齐规则分配了16字节。多出的3字节就是内碎片
2.外碎片
•定义:外碎片是指系统中有足够总量的空间内存,但这些空闲内存不连续,无法满足一个较大的分配请求。
•产生原因:频繁的小内存块的分配和释放导致内存分布变得零散和不连续的小块空闲内存无法自动组合成足够大的连续块。
•举例:系统中有多个小块空闲内存,总量为100MB,但是由于这些空闲内存块彼此不连续,无法分配一个需要50MB的大块。
3.tip:
内存池的固定大小块分配等机制,可以减少有效外碎片,内存池的内存分配策略根据实际需求制定的越精细产生的内碎片越少。ps:内存碎片是不可能减少的。
11.程序内存的五大区域
区域 | 存储内容 | 特点 |
---|---|---|
**.text段** | 编译后的机器代码(函数、指令) | 只读,不可修改 |
**.data段** | 已初始化的全局变量、静态变量(如 int a = 10; ) | 程序启动时加载,生命周期与程序一致 |
**.bss段** | 未初始化的全局变量、静态变量(如 int b; ) | 程序启动时清零,不占磁盘空间 |
堆(Heap) | 动态分配的内存(new /malloc 分配的对象) | 手动管理,需显式释放 |
栈(Stack) | 局部变量、函数参数、返回值等 | 自动管理,随作用域结束释放 |
12. new/delete
vs malloc/free
特性 | new/delete (C++) | malloc/free (C) |
---|---|---|
本质 | C++运算符 | C标准库函数 |
构造/析构 | 调用对象的构造函数和析构函数 | 仅分配/释放内存,不处理对象生命周期 |
类型安全 | 类型明确(如 new int ),无需计算内存大小 | 需手动计算字节数(如 malloc(sizeof(int)) ) |
异常处理 | 分配失败时抛出 std::bad_alloc 异常 | 返回 NULL ,需手动检查 |
内存对齐 | 按类型对齐规则处理 | 需手动指定对齐方式 |
重载 | 支持运算符重载(自定义内存分配逻辑) | 不可重载 |
示例 | cpp int* p = new int(10); delete p; | c int* p = malloc(sizeof(int)); free(p); |
13.什么是内存池?
内存池是一种预分配内存并进行重复利用的技术,通过减少频繁的动态内存分配与释放操作,从而提高程序运行效率。内存池通常预先分配一块大的内存区域,将其划分为多个小块,每次需要分配内存时直接从这块区域中分配,而不是调用系统的动态分配函数(如new或malloc)。简单来说就是申请一块较大的内存块(不够继续申请),之后将这块内存的管理放在应用层执行,减少系统调用带来的开销。
为什么要做内存池?
性能优化:
·1减少动态内存分配开销:系统级内存分配(如malloc/new)需要处理复杂逻辑(如内存合并、碎片整理),导致性能较低,而内存池通过预分配和简单的管理逻辑显著提高了分配和释放的效率。
·2避免内存碎片:动态分配内存会产生内存碎片,尤其在大量小对象频繁分配和释放的场景中,导致的后果就是:当程序长时间运行时,由于所申请的内存块的大小不定,频繁使用时会造成大量的内存碎片从而降低程序和操作系统的性能。内存池通过管理固定大小的内存块,可以有效避免碎片化。
·3降低系统调用频率:系统级内存分配(如malloc)需要进入内核态,频繁调用会有较高的性能开销。内存池通过减少系统调用频率提高程序效率。
确定性(实时性):
·4稳定的分配时间:使用内存池可以使分配和释放操作的耗时更加可控和稳定,适合实时性有严格要求的系统。
内存池的应用场景:
高频小对象分配:
·游戏开发:游戏中大量小对象(如粒子、子弹、NPC)的动态分配和释放非常频繁,使用内存池可以显著优化性能。
·网络编程:网络编程中,大量请求和响应对象(如消息报文)和频繁创建和销毁非常适合使用内存池。
·内存管理库:一些容器或数据结构(如std::vector或std::deque)在内部可能使用内存池来优化分配性能。
14.互斥锁(mutex与thread)
#include <iostream>
#include <mutex>
#include <thread>
std::mutex mtx;
int shared_data = 0;
void increment() {
std::lock_guard<std::mutex> guard(mtx); // 自动加锁
for (int i = 0; i < 10000; ++i) {
++shared_data;
}
}
int main() {
std::thread t1(increment); //分别是独立的线程
std::thread t2(increment);
t1.join(); //会阻塞主线程(main函数),直到执行完毕
t2.join();
std::cout << "Result: " << shared_data << std::endl; // 正确输出 20000
return 0;
}
互斥锁与自旋锁的区别?
- :
- 互斥锁:线程阻塞等待,适用于临界区较长或高竞争场景
- 自旋锁:忙等待(不释放 CPU),适用于短临界区且多核环境
#include <iostream> // std::cout
#include <chrono> // std::chrono::milliseconds
#include <thread> // std::thread
#include <mutex> // std::timed_mutex
std::timed_mutex mtx;
void fireworks() {
// waiting to get a lock: each thread prints "." every 200ms:
while (!mtx.try_lock_for(std::chrono::milliseconds(200))) {
std::cout << "-";
}
// got a lock! - wait for 1s, then this thread prints "***"
std::this_thread::sleep_for(std::chrono::milliseconds(1000));
std::cout << "*\n";
mtx.unlock();
}
int main ()
{
std::thread threads[10];
// spawn 10 threads:
for (int i=0; i<10; ++i)
threads[i] = std::thread(fireworks);
for (auto& th : threads) th.join();
return 0;
}
15.RAII 机制
-
什么是 RAII?它的核心思想是什么?
- 答案:RAII(Resource Acquisition Is Initialization)即“资源获取即初始化”,核心思想是将资源(如内存、文件句柄、锁)的生命周期与对象的生命周期绑定。对象构造时获取资源,析构时自动释放资源
- 优势:防止资源泄漏,简化代码,提高异常安全性。例如
std::lock_guard
和智能指针均基于 RAII
-
举例说明 RAII 的实际应用场景。
- 答案:
- 智能指针(如
std::unique_ptr
)管理堆内存 - 文件流(如
std::fstream
)自动关闭文件 - 互斥锁(如
std::lock_guard
)自动加锁/解锁
- 智能指针(如
- 答案:
深入问题:
- RAII 如何避免内存泄漏?
- 答案:通过对象析构确保资源释放,即使发生异常或提前返回也能执行
- RAII 在 STL 中的应用有哪些?
- 答案:容器(如
std::vector
)自动管理元素内存;智能指针封装动态资源
- 答案:容器(如
16. C++中的命名返回值优化(NRVO)
作用:编译器优化技术,消除返回局部对象时的拷贝开销,直接在调用处构造对象。
-
NRVO是什么?
避免返回局部对象时的拷贝,直接在目标地址构造。 -
NRVO和RVO的区别?
RVO优化匿名临时对象(如return X();
),NRVO优化已命名的局部对象。 -
触发NRVO的条件?
- 返回的局部对象类型与函数声明一致
- 无分支返回不同对象
-
移动语义与NRVO的关系?
若NRVO未触发,优先调用移动构造函数(若无则拷贝)。 -
如何确保NRVO生效?
- 单一返回路径
- 避免返回参数或全局对象
直白解释:编译器“偷偷”把要返回的局部对象,直接创建在调用者的内存位置,跳过了函数内构造+返回时复制的步骤。
// 正常流程(无NRVO):
Test createTest() {
Test obj; // 1. 在函数栈构造对象
return obj; // 2. 调用拷贝构造函数,复制到返回值的临时内存
}
int main() {
Test t = createTest(); // 3. 再调用一次拷贝构造函数,复制到t的内存
// 输出: 构造 -> 拷贝构造 -> 拷贝构造
}
// NRVO优化后:
Test createTest() {
// 编译器直接在主函数t的内存地址构造对象!
Test obj; // 1. 直接在t的内存构造
return obj; // 2. 无任何拷贝
}
int main() {
Test t = createTest(); // 输出: 构造
}
17. C++ 的原子操作:
标准原子类型列表
类型 | 等效别名 | 适用场景 |
---|---|---|
atomic<bool> | atomic_bool | 布尔标志 |
atomic<char> | - | 字符型原子操作 |
atomic<signed char> | - | 有符号字符 |
atomic<unsigned char> | - | 无符号字符 |
atomic<short> | atomic_short | 16位整数 |
atomic<unsigned short> | atomic_ushort | 16位无符号整数 |
atomic<int> | atomic_int | 32位整数 |
atomic<unsigned int> | atomic_uint | 32位无符号整数 |
atomic<long> | atomic_long | 长整型(平台相关) |
atomic<unsigned long> | atomic_ulong | 无符号长整型 |
atomic<long long> | atomic_llong | 64位整数 |
atomic<unsigned long long> | atomic_ullong | 64位无符号整数 |
atomic<T*> | - | 指针类型(如atomic<int*> ) |
atomic<size_t> | - | 大小类型 |
atomic<ptrdiff_t> | - | 指针差值类型 |
atomic<用户自定义类型> | - | 需满足is_trivially_copyable |
通用原子操作(所有类型支持)
操作类别 | 函数/运算符 | 说明 |
---|---|---|
加载 | load(memory_order) |
原子读取当前值(默认 多线程读取共享变量的最新值 |
存储 | store(val, memory_order) |
原子写入新值 设置标志位或更新共享状态 |
交换 | exchange(val, order) |
原子替换值并返回旧值 实现无锁队列或交换共享资源 |
比较交换 | compare_exchange_weak/strong |
CAS操作(核心并发原语) 比较当前值与期望值,若相等则更新为新值,否则返回失败。
|
运算符重载 | = , operator T() | 赋值和隐式转换(实际调用store /load ) |
17. C++ 默认的参数传递规则
当直接传递对象给函数(包括线程函数)时:
- 默认行为是按值传递(即拷贝一个副本)。
- 函数内部修改的是副本,原对象不会被修改。
void modify(int& val) { val = 100; } // 意图修改外部变量
int main() {
int x = 0;
std::thread t(modify, x); // 错误!x 会被拷贝,无法修改原值
t.join();
std::cout << x; // 输出 0(未修改)
}
- 线程函数
modify
试图通过引用修改x
,但因std::thread
的构造函数默认拷贝参数,实际修改的是副本。
18. std里面的各种东西:
std::ref
的作用
std::ref
将对象包装成 std::reference_wrapper
,强制让函数或线程按引用传递参数:
std::thread t(modify, std::ref(x)); // 通过 std::ref 传递引用
t.join();
std::cout << x; // 输出 100(成功修改原值)
std::ref(x)
生成一个轻量级包装器,告诉线程构造函数:“不要拷贝 x
,而是传递它的引用”