C++ 面试题
睿联面试
linux swap分区什么作用
在Linux中,swap分区(或swap文件)的作用是当系统的物理内存(RAM)不足时,将内存中不常用的数据暂时存储到硬盘上的swap空间中,从而释放物理内存供其他更紧急的任务使用。这个过程称为“交换出”(swapping out)。当需要再次访问这些数据时,再将其从swap分区读回内存,即“交换入”(swapping in)。swap空间可以被视为物理内存的扩展,但需要注意的是,硬盘的读写速度远低于内存,因此过度依赖swap会导致系统性能下降。
- 扩展虚拟内存:当物理内存(RAM)不足时,系统可以将不常用的内存页面移动到Swap空间中,从而释放RAM供更紧急的任务使用
- 内存管理:即使系统有足够的物理内存,内核有时也会将长时间未活动的内存页面移动到Swap中
- 休眠支持:当系统进入休眠状态时,会将RAM中的内容保存到Swap空间中,以便恢复时使用
- 避免内存不足:防止系统因内存耗尽而意外终止关键进程
Linux文件有几种权限,分别是什么
Linux文件的基本权限类型
Linux系统中,每个文件和目录都有三种基本的权限类型,每种权限类型对应三种不同的用户类别,总共形成9种权限设置。
三种基本权限类型
读取权限 (Read - r)
- 对文件:允许查看文件内容
- 对目录:允许列出目录中的内容
写入权限 (Write - w)
- 对文件:允许修改或删除文件内容
- 对目录:允许在目录中创建、删除或重命名文件
执行权限 (Execute - x)
- 对文件:允许将文件作为程序或脚本执行
- 对目录:允许进入和访问目录中的内容
三种用户类别
权限表示方式
符号表示法
使用字符表示权限,例如:rwxr-xr--
- 前三个字符:所有者权限
- 中间三个字符:所属组权限
- 最后三个字符:其他用户权限
数字表示法(八进制表示)
使用数字表示权限,例如:755
- 每个数字代表一组权限(所有者、组、其他用户)
- 每个数字是r(4)、w(2)、x(1)的和
- 例如:7 = 4+2+1 (rwx),5 = 4+0+1 (r-x)
特殊权限
除了基本权限外,Linux还有三种特殊权限:
Set User ID (SUID - s)
- 当设置在可执行文件上时,程序会以文件所有者的权限运行
- 数字表示为4000
Set Group ID (SGID - s)
- 当设置在可执行文件上时,程序会以文件所属组的权限运行
- 当设置在目录上时,在该目录中创建的新文件会继承目录的组所有权
- 数字表示为2000
Sticky Bit (t)
- 当设置在目录上时,只有文件所有者、目录所有者或root用户才能删除目录中的文件
- 常用于/tmp等共享目录
- 数字表示为1000
查看文件权限
使用ls -l命令可以查看文件的详细权限信息:
1 | -rwxr-xr-- 1 user group 4096 Jan 1 12:00 filename |
- 第一个字符表示文件类型(-表示普通文件,d表示目录)
- 接下来的9个字符表示权限(rwxr-xr–)
- 后面跟着的数字表示链接数
- 然后显示所有者和所属组
- 最后是文件大小、修改时间和文件名
修改文件权限
使用chmod命令
1
2
3
4
5
6
7
8
9# 符号模式
chmod u+x filename # 给所有者添加执行权限
chmod g-w filename # 移除所属组的写入权限
chmod o=r filename # 设置其他用户只有读取权限
chmod a+x filename # 给所有用户添加执行权限
# 数字模式
chmod 755 filename # rwxr-xr-x
chmod 644 filename # rw-r--r--使用chown命令更改所有者和组
1
2
3chown user filename # 更改文件所有者
chown user:group filename # 同时更改所有者和组
chown :group filename # 只更改组使用chgrp命令更改组
1
chgrp group filename # 更改文件所属组
权限示例
-rw-------(600):只有所有者可以读写-rw-r--r--(644):所有者可以读写,其他用户只能读-rwxr-xr-x(755):所有者可以读、写、执行,其他用户可以读和执行-rwx--x--x(711):所有者可以读、写、执行,其他用户只能执行drwx------(700):只有所有者可以读、写和进入目录drwxr-xr-x(755):所有者可以完全访问,其他用户可以列出目录内容和进入目录
理解Linux文件权限对于系统安全和正确管理文件访问至关重要。正确的权限设置可以保护系统免受未授权访问,同时确保合法用户能够完成所需的工作。
抽象类和接口有什么区别
在 C++ 语言层面 只有“抽象类”的概念,没有专门的 interface 关键字,因此 “接口” 只是一种设计约定。
可以把区别总结为一句话:
C++ 的“接口”就是 所有成员函数都是纯虚函数(或默认实现)且没有数据成员 的一种 特殊抽象类。
- 抽象类(general abstract class)
- 至少含一个纯虚函数
= 0。 - 可以同时拥有
‑ 数据成员
‑ 非虚成员函数 / 虚函数 / 纯虚函数
‑ 构造函数、析构函数、静态成员 - 主要目的是 提供公共实现 + 强制接口。
1 | class AbstractSocket { |
- “接口”(idiom-level interface)
- 约定 所有函数 纯虚(C++11 后可给默认实现),无数据成员。
- 纯接口类通常析构函数也写成纯虚或虚析构,防止泄漏。
1 | class IStream { // 习惯用 I 前缀 |
- 与 Java/C# 的对比
| 特性 | C++ 抽象类 | Java/C# interface |
|---|---|---|
| 关键字 | 无 | interface |
| 数据成员 | 可以有 | 不能有(Java ≤7) |
| 默认实现 | 可以有 | Java8+ 支持 default 方法 |
| 多继承 | 支持多继承(含接口) | 只能多接口单继承 |
- 何时用哪种
- 需要公共代码 + 接口 → 普通抽象类。
- 只想要协议/约定 → 纯虚类(接口)。
- 菱形继承 → 接口 + 虚继承,避免重复数据。
结论
在 C++ 里,“接口”就是纯虚类的另一种说法;抽象类是更宽泛的概念,两者语法上统一,区别只在于 设计意图和成员组成。
MYSQL索引失效的几种情况
MySQL索引是提高查询性能的关键工具,但在某些情况下索引可能不会按预期工作,导致查询性能下降。以下是MySQL索引失效的常见情况:
1. 对索引列使用函数或表达式
1 | -- 索引失效 |
2. 使用LIKE模糊查询以通配符开头
1 | -- 索引失效(前导%) |
3. 隐式类型转换
1 | -- 假设user_id是字符串类型,但使用了数字 |
4. 使用OR条件且部分列无索引
1 | -- 如果age列没有索引,即使name有索引也会失效 |
5. 不符合最左前缀原则(复合索引)
1 | -- 假设有复合索引 (col1, col2, col3) |
6. 使用不等于(!=或<>)操作
1 | -- 索引通常失效 |
7. 对索引列使用IS NULL或IS NOT NULL
1 | -- 可能使索引失效,取决于数据分布和MySQL版本 |
8. 数据分布不均匀
1 | -- 当某个值占比过高时,MySQL可能认为全表扫描更快 |
9. 使用ORDER BY与索引不匹配
1 | -- 假设有索引 (category, price) |
10. 表数据量很小
1 | -- 当表很小(如少于1000行)时,MySQL可能选择全表扫描而不是使用索引 |
11. 索引统计信息过时
1 | -- 当表数据发生重大变化后,索引统计信息可能不准确 |
12. 使用负向查询条件
1 | -- 索引通常失效 |
诊断索引使用情况
可以使用EXPLAIN命令检查查询是否使用了索引:
1 | EXPLAIN SELECT * FROM users WHERE name = 'John'; |
查看结果中的key列可以知道使用了哪个索引,Extra列可以提供额外信息。
最佳实践
- 定期分析查询性能并使用EXPLAIN诊断
- 确保索引统计信息最新
- 考虑查询模式设计复合索引
- 避免过度索引,因为索引会占用空间并降低写操作性能
- 使用覆盖索引(索引包含所有查询字段)提高性能
通过理解这些索引失效的情况,可以更好地设计和优化数据库索引,提高查询性能。
C++网络编程如何处理粘包,超时重传,断点续传
好的,这是一个非常深入的C++网络编程问题,涉及到实现可靠网络通信的核心机制。我将为你详细解释这三个问题的处理方案。
1. 粘包/拆包问题 (Sticky Packet / Unpacking)
问题根源:TCP是面向字节流的协议,它只保证数据的有序和可靠传输,不维护消息边界。发送端多次发送的数据可能会被接收端一次接收(粘包),或者一次发送的数据被多次接收(拆包)。
解决方案:设计应用层协议
需要在应用层设计协议来界定消息的边界。以下是四种常见方案:
方案一:定长消息
- 每个消息都是固定长度(例如128字节)。
- 不足部分用特定字符(如
\0)填充。 - 优点:处理简单。
- 缺点:浪费带宽,不灵活。
方案二:分隔符
- 使用特殊字符或字符串作为消息结束标志(如
\r\n\r\n)。 - 优点:简单,类似HTTP等文本协议。
- 缺点:消息内容本身不能包含分隔符,需要转义,增加复杂度。
方案三:长度前缀(最常用、最推荐)
- 在消息体前附加一个固定长度的包头,包头中包含消息体的长度。
- 接收方先读固定长度的包头,解析出长度N,再读取后续N字节的数据。
C++ 代码示例(长度前缀法):
1 | // 发送端 |
注意:MSG_WAITALL 标志会尝试阻塞直到读取到请求的字节数,但并非所有系统都保证完全做到。生产环境需要循环读取。
2. 超时重传 (Timeout Retransmission)
问题根源:网络是不稳定的,数据包可能会丢失、损坏或严重延迟。发送方需要一种机制来确认对方已收到数据,否则应重新发送。
解决方案:确认应答(ACK) + 超时计时器
这本质上是实现一个类似TCP的简易可靠传输机制。
- 序列号 (Sequence Number):为每个发送的数据包分配一个唯一的、递增的序列号(Seq)。
- 确认应答 (Acknowledgment, ACK):接收方收到数据包后,必须向发送方发送一个ACK包,ACK包中包含它期望收到的下一个序列号(即上一个Seq+1)。
- 重传计时器 (Retransmission Timer):发送方每发送一个数据包,就启动一个计时器。如果在计时器超时前未收到对应的ACK,则重新发送该数据包。
C++ 伪代码逻辑:
1 | // 发送方逻辑 |
注意事项:
- 超时时间 (RTO):动态计算(如Jacobson/Karels算法)比固定值更优,可根据网络RTT(往返时间)调整。
- 重复ACK:快速重传机制(收到3个重复ACK立即重传)可以更快地修复丢包,而不必等待超时。
- 窗口机制:上述是停等协议,效率低。通常使用滑动窗口协议进行流水线传输,允许多个包未被确认。
3. 断点续传 (Resume from Breakpoint)
问题根源:在传输大文件(如视频、安装包)时,网络中断或程序崩溃。重新传输时,不希望从0开始,而是从上次中断的地方继续。
解决方案:记录已传输进度 + 校验
这通常发生在应用层,尤其是文件传输协议中(如FTP、HTTP/1.1的Range头)。
- 发送方:能够根据偏移量读取文件的一部分。
- 接收方:
- 记录进度:在本地非易失性存储(如磁盘文件)中记录已成功接收的文件大小(偏移量)。
- 请求续传:在重新建立连接后,首先询问发送方文件信息(如文件名、大小、MD5),然后告知对方自己已接收的偏移量。
- 发送方:从接收方指定的偏移量开始发送数据。
C++ 伪代码逻辑:
1 | // 接收端主逻辑 |
关键点:
- 进度存储:必须可靠地存储在磁盘上,防止程序崩溃后进度丢失。
- 文件校验:使用哈希(如MD5、SHA1)在传输结束后验证文件完整性,防止传输错误或文件在服务端发生变化。
- 原子性:文件写入和进度更新要尽可能保证原子性,避免产生中间状态。
总结与关联
| 机制 | 解决的核心问题 | 关键技术点 |
|---|---|---|
| 粘包处理 | 消息边界 | 应用层协议设计(长度前缀法) |
| 超时重传 | 数据丢失 | 序列号、ACK、重传计时器(简易ARQ协议) |
| 断点续传 | 传输中断 | 记录偏移量、校验和 |
在实际项目中,这三个机制往往需要结合使用:
- 你使用长度前缀法来封装你的消息(Packet),每个Packet可以携带文件数据、ACK信息、断点续传的控制命令等。
- 在传输文件数据Packet时,使用序列号和ACK机制来保证可靠性。
- 同时,接收方需要将已成功接收并确认(ACK)的数据安全地写入文件并记录进度,以实现断点续传。
实现这些机制非常复杂,通常建议优先使用现有的、成熟的网络库(如Boost.Asio)或协议,它们已经高效、稳定地实现了这些功能。自己实现主要是为了理解和学习底层原理。
TCP的流式传输如何理解
好的,这是一个非常核心的网络概念。理解TCP的流式传输(Stream-Oriented Transmission)是理解TCP所有行为和特性的基石。
我会用一个非常形象的比喻来帮你彻底理解它。
核心比喻:水管送积木
想象一下,有两个小朋友,小明和小红,他们隔着墙用一根水管来传送积木。
- 水管 (TCP连接):这根水管很神奇,它保证水的流动是有序的(先倒进去的水先出来)和可靠的(不会漏掉任何一滴水)。
- 积木 (应用层消息):小明想送给小红一些搭好的积木,比如一座小房子和一辆小汽车。
- 水 (字节数据):小明无法直接塞积木,他必须把积木拆成最基础的小木块(字节),然后混在水里,通过水管送过去。
这就是TCP流式传输的核心:它传输的不是一块块完整的“积木”(消息),而是无结构的“小木块”字节流(byte stream)。
流式传输的关键特征与影响
基于这个比喻,我们可以推导出TCP的所有重要特性:
1. 无消息边界 (No Message Boundaries)
- 小明这边:他分两次倒水:第一次倒“房子积木”的小木块,第二次倒“汽车积木”的小木块。
- 小红这边:她拿桶接水,她无法直接知道小木块什么时候是“房子”的结束,什么时候是“汽车”的开始。她可能:
- 一次接到所有“房子”和“汽车”的木块。(粘包)
- 先接到“房子”的一部分木块,过了一会儿又接到剩下的“房子”木块和全部“汽车”木块。(拆包)
- 以任意其他方式接收到这些木块。
这就是“粘包/拆包”问题的根本原因。TCP不维护应用层的消息边界,它只保证字节的顺序是正确的。
应对策略(应用层的责任):
小红和小明必须事先约定好如何区分积木。例如:
- 长度前缀法:小明先发送一个数字,告诉小红下一个积木由多少个小木块组成。这是最常用、最有效的方法。
[4字节长度][房子数据][4字节长度][汽车数据] - 分隔符法:在每个积木的木块后面放一个特殊的、积木本身不会有的小木块(如一个红色木块)作为结束标志。
[房子数据][红色木块][汽车数据][红色木块] - 固定长度法:所有积木都必须由同样数量的小木块组成。
2. 有序和可靠 (Ordered & Reliable)
- 有序:水管保证先倒进去的小木块一定先出来。即使网络路径很复杂,TCP协议栈也会对收到的数据包进行排序,确保提交给应用层的是正确的字节流顺序。
- 可靠:如果一个小木块在水管里丢了(网络丢包),小明会发现小红没有确认收到,他就会重新发送那个丢失的木块(超时重传)。这保证了最终所有小木块都会到达小红那里。
3. 面向连接 (Connection-Oriented)
在开始传送小木块之前,小明和小红必须先建立连接(打个电话:“喂,我们开始用水管了哦?”)。传送结束后,他们也要断开连接(“喂,我传完了,关水管了哦?”)。这就是著名的三次握手和四次挥手。
与UDP数据报传输的对比
为了更好地理解“流”,我们和UDP的“数据报”(Datagram)模式做个对比。
| 特性 | TCP (流式) | UDP (数据报式) |
|---|---|---|
| 比喻 | 水管送积木(字节流) | 邮局寄信(消息/包) |
| 数据视图 | 无边界的数据流 | 有边界的独立数据包 |
| 是否维护边界 | 否,可能合并或拆分 | 是,接收到的永远是发送方发送的完整数据包 |
| 可靠性 | 可靠,自动重传、校验 | 不可靠,可能丢失、重复、乱序 |
| 连接性 | 面向连接 | 无连接 |
| 顺序 | 保证数据顺序 | 不保证顺序 |
| 开销 | 较大(有头部、重传等机制) | 较小 |
- 如果你用UDP发送3条消息:“Hello”、“World”、“!”,接收方会精确地收到3次数据,每次分别得到”Hello”, “World”, “!”。
- 如果你用TCP发送同样的3条消息,接收方可能一次收到”HelloWorld!”,也可能分两次收到”Hell”和”oWorld!”,或者任何其他组合。应用层看到的是一个连续的字节流,需要自己去找消息的边界。
编程模型上的体现
在C/C++中使用Socket编程时,这种感觉非常明显:
发送端:你可以调用多次
send()来发送一份完整的数据。1
2send(sockfd, "Hello ", 6, 0); // 发送第一部分
send(sockfd, "World", 5, 0); // 发送第二部分接收端:你可能一次
recv()就收到 “Hello World”,也可能第一次recv()收到 “Hello W”,第二次recv()才收到 “orld”。
接收方的 recv() 函数返回的是当前内核接收缓冲区中有多少字节可读,而不是发送方一次 send() 的字节数。
总结
TCP的流式传输可以理解为:
- 一个双向的、连续的、有序的字节流管道:数据像水一样在这个管道中流动。
- 对应用层消息无感知:TCP只关心字节的正确性和顺序,不关心也不维护这些字节代表什么业务含义、哪几个字节是一条完整消息。
- 优势:这种模型极大地简化了网络底层复杂性(如分片、重组、路由),为上层提供了一个极其简单、稳定、可靠的字节传输通道。
- 代价:应用层开发者必须自己负责定义消息的格式和边界(即“协议”),这是所有基于TCP的应用编程(如HTTP、FTP、自定义游戏协议)的首要任务。
理解了“流”,你就理解了TCP行为的本质,也就明白了为什么必须处理粘包,以及为什么TCP如此强大和流行。
面试官会从基础概念、深入原理和实战应用三个层面
来考察你。以下是他们很可能会问的问题,我为你进行了分类和梳理:
一、进程与线程 (Process & Thread)
这是面试的重点,问题会由浅入深。
1. 基础概念
- 进程和线程的根本区别是什么?
- 期望答案:进程是操作系统资源分配的基本单位(拥有独立的地址空间、文件句柄、系统资源),而线程是CPU调度和执行的基本单位(是进程内的一个执行流,共享进程的资源)。创建进程开销大,创建线程开销小。一个进程崩溃一般不会影响其他进程,但一个线程崩溃会导致整个进程崩溃。
- 在Windows中,创建进程和线程的API是什么?
- 期望答案:创建进程主要是
CreateProcess函数,它可以指定可执行文件路径、命令行参数、安全属性等。创建线程是CreateThread函数(或C运行时库的_beginthreadex,后者在多线程CRT中更安全,会初始化线程局部存储)。
- 期望答案:创建进程主要是
- 线程有哪些状态?
- 期望答案:就绪(Ready)、运行(Running)、等待/阻塞(Waiting/Blocked)、终止(Terminated)。
2. 线程同步与通信(必问!)
这是核心中的核心,一定会深入问。
为什么需要线程同步?
- 期望答案:当多个线程访问共享资源(全局变量、内存数据、文件等)时,为了防止出现竞态条件(Race Condition) 和数据不一致,必须进行同步。
请说出你知道的Windows线程同步机制,并比较它们的区别和适用场景。
- 期望答案:(这是一个综合题,考察知识体系)
- 临界区(CRITICAL_SECTION):用户态同步对象,只能在同一进程的线程间使用。速度快,但不可跨进程。
- 互斥量(Mutex):内核态同步对象。可以跨进程使用(有名字),但速度比临界区慢。拥有“所有权”,哪个线程锁定(
WaitForSingleObject)就必须由哪个线程释放(ReleaseMutex)。 - 信号量(Semaphore):内核态同步对象。维护一个计数器,用于控制同时访问共享资源的线程数量。可以跨进程。
- 事件(Event):内核态同步对象。用于通知一个或多个线程“某个事件已发生”。分为手动重置(Manual-Reset)和自动重置(Auto-Reset)两种,非常灵活,是实现生产者-消费者模型的利器。
- 互锁函数(Interlocked Functions):如
InterlockedIncrement,InterlockedCompareExchange。用于对单个变量进行原子操作,效率最高。
- 期望答案:(这是一个综合题,考察知识体系)
WaitForSingleObject和WaitForMultipleObjects是做什么的?- 期望答案:它们是等待内核对象变为“有信号”状态的核心API。可以等待Mutex、Event、Semaphore、Process、Thread等多种对象。
什么是死锁(Deadlock)?产生死锁的必要条件是什么?如何避免和预防死锁?
- 期望答案:两个或以上的线程互相等待对方持有的资源,导致都无法继续执行。
- 必要条件:互斥、持有并等待、非抢占、循环等待。
- 预防:按固定的顺序申请锁;使用
WaitForMultipleObjects来同时申请所有需要的锁;设置超时时间(WaitForSingleObject的超时参数)。
3. 进程间通信(IPC)
- Windows下有哪些进程间通信的方式?
- 期望答案:
- 内存映射文件(Memory-Mapped File):最常用、效率最高的方式之一。
- 命名管道(Named Pipe)
- 邮件槽(Mailslot)
- 共享内存(通常通过内存映射文件实现)
- Windows消息(
PostMessage,SendMessage,但有限制,如只能用于GUI进程) - Socket(即使是本机进程间也可用)
- RPC(远程过程调用)
- 期望答案:
二、内存管理 (Memory Management)
1. 基础概念
虚拟内存是什么?为什么需要它?
- 期望答案:让每个进程都拥有一个独立的、连续的虚拟地址空间,由操作系统和CPU硬件共同映射到物理内存。它提供了内存保护(进程隔离)、简化了内存管理(程序员使用虚拟地址)、允许使用比物理内存更大的地址空间(通过分页到磁盘)。
Windows中,一个进程的虚拟地址空间布局是怎样的?
- 期望答案:以32位进程为例(4GB空间),用户模式(0x00000000 - 0x7FFFFFFF,2GB),内核模式(0x80000000 - 0xFFFFFFFF,2GB)。代码、堆、栈、DLL等都分布在用户模式空间。64位系统空间巨大,布局原理类似。
2. API与机制
- Windows提供了哪些操作内存的API?
- 期望答案:
VirtualAlloc/VirtualFree:直接从操作系统 reserve(保留) 或 commit(提交) 虚拟内存页,粒度较大(通常64KB),是堆管理的基础。HeapAlloc/HeapFree:在堆上分配内存,是对VirtualAlloc的封装,粒度更小,更常用。C的malloc和 C++ 的new最终可能会调用它们。
- 期望答案:
- 什么是内存泄漏?在Windows下如何检测和调试内存泄漏?
- 期望答案:程序未能释放不再使用的内存。
- 检测方法:使用工具如 Visual Studio 诊断工具、CRT库内置的检测功能(
_CrtDumpMemoryLeaks)、第三方工具(VLD, Dr. Memory, WinDbg)。
- 什么是堆碎片?如何避免?
- 期望答案:频繁地分配和释放不同大小的内存块,会导致大量小的空闲内存块分散在堆中,虽然总空闲内存足够,但无法分配连续的大块内存。避免方法:使用内存池(Memory Pool) 对象池来管理固定大小的对象分配。
三、消息机制 (Message Mechanism)
这主要针对Windows GUI程序,但消息循环的概念也适用于其他场景(如 PeekMessage 实现的游戏循环)。
- 什么是消息循环(Message Loop)?它的基本流程是什么?
- 期望答案:Windows GUI程序的核心,一个不断调用
GetMessage/PeekMessage->TranslateMessage->DispatchMessage的循环。用于从消息队列中获取消息(鼠标、键盘、窗口消息等),并将其分发给对应的窗口过程(Window Procedure)处理。
- 期望答案:Windows GUI程序的核心,一个不断调用
PostMessage和SendMessage有什么区别?- 期望答案:这是经典问题。
PostMessage:异步。将消息放入消息队列后立即返回,不等待处理。SendMessage:同步。直接调用目标窗口的窗口过程,等待它处理完毕后才返回。
- 期望答案:这是经典问题。
- 什么是窗口过程(Window Procedure,
WndProc)?- 期望答案:每个窗口都有一个处理消息的函数,它是一个回调函数,原型为
LRESULT CALLBACK WndProc(HWND, UINT, WPARAM, LPARAM)。里面通常是一个巨大的switch-case结构来处理不同的消息(WM_PAINT,WM_DESTROY,WM_COMMAND等)。
- 期望答案:每个窗口都有一个处理消息的函数,它是一个回调函数,原型为
四、综合与实战 (C++ Specific & Practical)
- C++的
new操作符在Windows下底层是如何实现的?它与malloc和HeapAlloc有什么关系?- 期望答案:
new操作符通常会调用C运行时库的malloc,而CRT的malloc在Windows下最终会调用HeapAlloc来从默认的进程堆中分配内存。new还会调用构造函数。
- 期望答案:
- 在多线程环境中使用C++ STL容器需要注意什么?
- 期望答案:STL容器本身不是线程安全的。如果多个线程同时读写同一个容器,需要手动加锁进行同步。通常的做法是用一个临界区或互斥量保护对容器的所有访问操作。
- 如果你有一个GUI程序无响应(“未响应”状态),你可能如何排查?
- 期望答案:这通常是主线程(UI线程)被长时间阻塞导致的。可以使用 Windbg 或 Visual Studio 附加到进程,暂停(Break) 执行,查看所有线程的调用栈(Call Stack)。很可能会发现主线程卡在某个耗时的操作(如文件读写、网络请求、复杂计算)或死锁上。黄金法则:不要在UI线程上做任何耗时操作!
面试建议
- 结合代码示例:在解释概念时,如果能说出相关的API函数名甚至简单伪代码,会大大加分。例如,解释事件时,可以提到
CreateEvent,SetEvent,WaitForSingleObject。 - 强调实战经验:如果你有过相关开发经验,一定要说出来。例如,“我在之前的项目中用命名管道实现过进程通信…”或“我用内存映射文件处理过大型数据文件…”。
- 理解原理,不止于表面:不要只背概念,要理解为什么这么设计。例如,为什么有临界区还要有互斥量?(因为要跨进程)。
- 准备一个项目:准备一个能体现你Windows编程能力的项目,并清晰地阐述你在其中负责的模块,用到了哪些上述技术。
祝你面试顺利!这表明你面对的将是一个技术含量很高的职位,好好准备,机会很大
腾讯一面C++
好的,我们来逐一解答这些问题。答案会保持简洁、直击要点,便于理解和记忆。
1. 开发一个 TCP 客户端,要分几步?
通常分为 4 个核心步骤:
- 创建套接字 (Socket):调用
socket(AF_INET, SOCK_STREAM, 0)创建一个流式套接字。 - 建立连接 (Connect):调用
connect()函数,指定服务器的 IP 地址和端口号,进行三次握手。 - 数据传输 (Send/Receive):连接成功后,使用
send()和recv()函数与服务器进行通信。 - 关闭连接 (Close):通信完毕,调用
close()或closesocket()关闭套接字,发起四次挥手。
2. TCP 协议具体是干嘛的?
TCP(传输控制协议)是一种面向连接的、可靠的、基于字节流的传输层通信协议。
- 面向连接:通信前必须先建立连接(三次握手)。
- 可靠性:通过序列号、确认应答、超时重传、流量控制、拥塞控制等机制来保证数据不丢失、不重复、按序到达。
- 基于字节流:传输的数据没有消息边界,应用层需要自己处理粘包/拆包问题。
3. 讲讲三次握手和四次挥手?
三次握手 (建立连接):
- 客户端 -> 服务器:发送 SYN 包 (SYN=1, seq=x),进入 SYN_SENT 状态。
- 服务器 -> 客户端:发送 SYN-ACK 包 (SYN=1, ACK=1, ack=x+1, seq=y),进入 SYN_RCVD 状态。
- 客户端 -> 服务器:发送 ACK 包 (ACK=1, ack=y+1),双方进入 ESTABLISHED 状态,连接建立。
四次挥手 (断开连接):
- 主动方 -> 被动方:发送 FIN 包 (FIN=1, seq=u),进入 FIN_WAIT_1 状态。
- 被动方 -> 主动方:发送 ACK 包 (ACK=1, ack=u+1),进入 CLOSE_WAIT 状态。(此时半关闭,被动方可能还有数据要发送)
- 被动方 -> 主动方:数据发送完毕后,发送 FIN 包 (FIN=1, seq=w, ack=u+1),进入 LAST_ACK 状态。
- 主动方 -> 被动方:发送 ACK 包 (ACK=1, ack=w+1),进入 TIME_WAIT 状态(等待 2MSL 确保对方收到ACK),之后关闭。被动方收到ACK后立即关闭。
4. 一个 UDP 包最多能装多少数据?
理论上,一个 UDP 数据包的最大负载长度是 65507 字节。
- 计算方式:IP 数据包最大长度 65535 字节 - IP 头部最小 20 字节 - UDP 头部 8 字节 = 65507 字节。
- 但实际上,为了避免被网络层分片(分片容易丢失导致整个包无效),通常应保证 UDP 包大小 <= MTU - IP头 - UDP头。在以太网中,MTU通常是1500字节,所以推荐的有效载荷约为
1500 - 20 - 8 = 1472字节。
5. 结构体和模板类有啥区别?
| 特性 | 结构体 (struct) | 模板类 (template class) |
|---|---|---|
| 核心目的 | 组织数据。将不同类型的数据组合成一个新的复合类型。 | 泛型编程。编写与数据类型无关的通用代码。 |
| 默认访问权限 | public | private |
| 编程范式 | 更偏向于面向过程/数据抽象 | 是泛型编程和元编程的核心 |
| 实例化 | 编译时确定其成员和大小 | 是一个代码生成工具,根据传入的类型参数在编译时生成具体的类 |
简单说:struct 是一种数据类型,template class 是生成各种 class 的“模具”。
6. Linux 的 /proc 目录是干嘛的?
/proc 是一个虚拟文件系统,它不占用磁盘空间,而是内核映射到内存中的一个接口。
- 作用:提供了查看和动态修改内核运行参数和系统状态的窗口。
- 内容示例:
/proc/cpuinfo:CPU 信息/proc/meminfo:内存信息/proc/<PID>/:某个进程的详细信息(如命令行、内存映射、打开的文件等)/proc/sys/:内核参数,可以sysctl命令修改
7. 说几个你用过的调试工具?
- GDB:Linux 下强大的命令行调试器,用于 C/C++。
- Strace:跟踪进程执行的系统调用,排查程序行为异常的神器。
- Valgrind:主要用于检测内存泄漏、内存越界等问题。
- Wireshark:网络抓包分析工具,用于分析网络协议、排查网络问题。
- IDE 内置调试器:如 Visual Studio, CLion, VSCode 的调试插件,提供图形化界面。
8. MySQL 引擎是啥?
MySQL 存储引擎是负责数据的存储、检索和管理的底层软件组件。MySQL 采用了插件式架构,支持多种存储引擎,你可以为不同的表选择不同的引擎。
- InnoDB (默认):支持事务、行级锁、外键,提供崩溃恢复能力,适用于大多数需要ACID特性的应用。
- MyISAM (旧默认):不支持事务和行级锁,只有表锁,但读性能很高,适用于大量读、少量写且不需要事务的场景(现已被淘汰)。
- Memory:所有数据存储在内存中,速度极快,但服务器重启后数据丢失。
9. DDoS 是啥?
分布式拒绝服务攻击。
- 目的:通过海量的恶意流量(如伪造的请求、垃圾数据包)淹没目标服务器、服务或网络,耗尽其资源(带宽、CPU、内存),使其无法为正常用户提供服务的攻击方式。
- “分布式”含义:攻击流量来自被黑客控制的、分布在全球的大量“肉鸡”(被感染的计算机、IoT设备等)组成的僵尸网络,难以简单屏蔽。
10. Redis 有哪些数据结构?
Redis 不仅是简单的 Key-Value 存储,其 Value 支持多种丰富的数据结构:
- String:字符串,最基础的类型。
- List:列表,双向链表,支持左右推送。
- Hash:哈希表,适合存储对象。
- Set:无序集合,自动去重。
- Sorted Set:有序集合,每个元素关联一个分数(score)用于排序。
- Bitmap / HyperLogLog / Geospatial:更高级的特殊类型。
11. Redis 缓存慢了怎么办?怎么做持久化?
慢了怎么办 (排查思路):
- 排查慢查询:使用
SLOWLOG GET命令。 - 检查持久化阻塞:如果配置了 RDB 快照或 AOF 重写,大数据量时可能会阻塞主线程。
- 检查内存使用:是否达到上限,触发淘汰策略?使用
info memory。 - 检查网络:是否存在带宽瓶颈或延迟。
- 检查 bigkey:大的复合结构(如包含百万元素的hash)的操作会很慢。
- 考虑分片:使用 Redis Cluster 将数据分布到多个实例。
- 排查慢查询:使用
持久化方式:
- RDB (快照):在指定时间间隔生成数据的二进制快照文件(
.rdb)。优点:文件小,恢复快。缺点:可能会丢失最后一次快照之后的数据。 - AOF (追加文件):记录每一个写操作命令到日志文件。优点:数据 durability 高,最多丢失1秒数据。缺点:文件大,恢复慢。
- 混合持久化 (推荐):同时开启 RDB 和 AOF。重写时,先把当前数据以 RDB 格式写入 AOF 文件开头,再将期间的写命令以 AOF 格式追加到文件。兼具速度和数据安全性。
- RDB (快照):在指定时间间隔生成数据的二进制快照文件(
12. 聊聊消息队列?
消息队列是一种异步的服务间通信方式。发送者(生产者)将消息放入队列,接收者(消费者)从队列中取出并处理消息。
- 核心作用:
- 解耦:分离生产者和消费者,互不影响。
- 异步:生产者发送后即可返回,无需等待消费者处理完成。
- 削峰填谷:应对突发流量,消息队列作为缓冲区,避免系统被冲垮。
- 常见产品:Kafka, RabbitMQ, RocketMQ, Redis Stream。
13. 说几个 Agent 框架?
Agent 指常驻在被管理机器上的代理程序,用于采集数据、执行任务、接受控制。
- Telegraf: metrics 采集 Agent,是监控系统 InfluxDB 的组成部分。
- Datadog Agent: Datadog 监控平台的代理。
- Elastic Beat (如 Filebeat, Metricbeat): Elastic Stack (ELK) 的数据采集器。
- Prometheus Node Exporter: 用于暴露主机 metrics 给 Prometheus 抓取。
- Zabbix Agent: Zabbix 监控系统的代理。
14. MCP 是啥?
Model Context Protocol (模型上下文协议)。
- 背景:由 Anthropic 等公司提出,旨在解决 AI 助手(如 Claude)如何与外部工具、数据源和工作流更安全、高效地集成的问题。
- 作用:它是一个开放标准,定义了 AI 模型与外部服务器(提供工具、数据等的“资源”)之间如何进行通信。它让模型能够动态地发现、调用外部资源,而无需将这些功能的细节硬编码到模型本身。
15. MCP 用的是什么通信协议?
MCP 的核心通信不绑定于某一特定传输层协议,它可以在不同的协议上运行。
但其 消息格式 是基于 JSON-RPC 2.0 的。
通信可以在 stdio (标准输入输出)、SSE (Server-Sent Events) 或 WebSocket 等传输协议上进行。
例如,一个常见的部署方式是 MCP 服务器(资源提供方)与 AI 客户端(如 Claude IDE 插件)通过 stdio 进行通信,交换 JSON-RPC 2.0 格式的消息。
字节一面C++
好的,我们来逐一解答这些问题。答案会保持简洁、直击要点,便于面试时快速组织语言。
1. Http请求中有哪些请求方式?
最常用的有5种,总共有9种(但一些不常用):
- GET:请求获取指定的资源。
- POST:向指定资源提交数据,请求服务器进行处理(例如提交表单或上传文件)。
- PUT:替换指定的资源(全部更新)。
- DELETE:请求服务器删除指定的资源。
- PATCH:对资源进行部分修改。
- 其他(了解即可):HEAD(获取报文头)、OPTIONS(询问支持的方法)、TRACE、CONNECT。
2. 说一下Https是如何保证链接安全的?
HTTPS 通过 SSL/TLS 协议在 HTTP 之下提供了一个安全层,从三个方面保证安全:
1. **加密**:防止通信内容被窃听。(混合加密机制)
2. **认证**:防止身份被冒充。(数字证书机制)
3. **完整性保护**:防止内容被篡改。(摘要算法)
3. Https的加密方式是怎样的?对称还是非对称?
HTTPS 采用 混合加密 机制,结合了非对称加密和对称加密的优点:
1. **非对称加密 (用于握手阶段)**:在建立连接时,使用非对称加密(如RSA、ECDSA)来安全地交换一个**会话密钥**(`Pre-Master Secret`)。这个过程可以防止密钥被窃听。
2. **对称加密 (用于传输阶段)**:连接建立后,双方使用上一步协商出的同一个会话密钥(`Master Secret`)进行对称加密(如AES、ChaCha20)通信。这是因为对称加密的计算效率远高于非对称加密。
4. Http的状态码都有哪些,代表什么意思?
状态码分为5类:
- 1xx (信息性):请求已被接收,继续处理。 (如 100 Continue)
- 2xx (成功):请求已成功被服务器接收、理解、并接受。 (如 200 OK, 201 Created)
- 3xx (重定向):需要后续操作才能完成这一请求。 (如 301 Moved Permanently, 302 Found, 304 Not Modified)
- 4xx (客户端错误):请求含有词法错误或者无法被执行。 (如 400 Bad Request, 401 Unauthorized, 403 Forbidden, 404 Not Found)
- 5xx (服务器错误):服务器在处理某个正确请求时发生错误。 (如 500 Internal Server Error, 502 Bad Gateway, 503 Service Unavailable)
5. TCP是如何实现可靠传输的呢?
主要通过以下机制:
1. **序列号与确认应答 (ACK)**:每个字节都有序号,接收方收到后必须发送ACK确认。如果发送方在一定时间内没收到ACK,就认为丢包。
2. **超时重传**:对未收到ACK的包进行重传。
3. **连接管理**:通过三次握手建立可靠连接,四次挥手释放连接。
4. **流量控制**:通过滑动窗口机制,根据接收方的处理能力来动态调整发送速率,防止接收方缓冲区溢出。
5. **拥塞控制**:通过慢启动、拥塞避免、快重传、快恢复等算法来探测网络状况,防止过多的数据注入网络导致网络瘫痪。
6. 在浏览器中输入url后会发生哪些事情?
这是一个经典问题,过程非常复杂,简化后核心步骤如下:
1. **DNS解析**:浏览器将域名解析为对应的IP地址。
2. **建立TCP连接**:与服务器进行三次握手,建立TCP连接。
3. **发送HTTP请求**:浏览器构建HTTP请求报文,并通过TCP连接发送给服务器。
4. **服务器处理请求并返回响应**:服务器处理请求,并返回HTTP响应报文(包含状态码、HTML文件等)。
5. **浏览器解析渲染页面**:
* 解析HTML构建DOM树。
* 解析CSS构建CSSOM树。
* 将DOM和CSSOM合并成渲染树(Render Tree)。
* 进行布局(Layout)计算每个节点的几何信息。
* 绘制(Painting)页面像素信息。
* 合成(Compositing)层并显示到屏幕上。
6. **断开连接**:完成数据交换后,通过四次挥手断开TCP连接。
7. C++指针和引用的差别是什么?
| 特性 | 指针 (Pointer) | 引用 (Reference) |
|---|---|---|
| 本质 | 是一个变量,存储的是另一个变量的内存地址 | 是一个变量的别名,和原变量是同一个东西 |
| 初始化 | 可以不初始化(但危险),可以指向NULL | 必须初始化,且一旦绑定不能改变指向 |
| 操作 | 可以进行++, --等算术运算 |
没有这种算术运算 |
| 空值 | 可以指向nullptr |
不能绑定到空值 |
| 多级 | 可以有指针的指针 (**ptr) |
没有引用的引用 |
8. 说一下动态链接和静态链接是什么,以及各自的优缺点
静态链接:在编译链接期,将库的代码直接拷贝到最终的可执行文件中。
- 优点:执行速度快(无需运行时加载),移植性好(不依赖系统环境)。
- 缺点:可执行文件体积大,库升级需要重新编译整个程序。
动态链接:在运行时才将所需的库文件加载到内存中并与程序连接。
- 优点:可执行文件体积小,多个程序可共享同一个库(节省内存),库升级方便(只需替换库文件)。
- 缺点:执行速度稍慢,有依赖问题(程序运行时需要系统存在对应版本的库)。
9. 说一下深拷贝和浅拷贝的区别
- 浅拷贝:只拷贝对象的基本数据成员和指针的值(即地址),而不拷贝指针所指向的内存。结果是两个对象的指针成员指向同一块内存。容易引发重复释放、悬垂指针等问题。
- 深拷贝:不仅拷贝基本数据成员,还会为指针成员重新分配内存,并拷贝指针所指向的内容。结果是两个对象完全独立,互不影响。
简单比喻:浅拷贝是复制一张名片(只复制了地址),深拷贝是按照名片地址找到那栋楼并自己也盖一栋一模一样的(复制了内容)。
10. 进程通信的解耦机制?
解耦的核心是让进程不直接通信,而是通过一个中间实体(Intermediary) 来间接通信。常见的解耦机制有:
- 消息队列 (Message Queue):进程将消息放入队列,另一个进程从队列中取出。发送者和接收者不需要同时运行,也不需要知道对方的存在。
- 共享内存 (Shared Memory):虽然需要同步机制(如信号量)配合,但它将通信的“数据缓冲区”与进程解耦,任何进程都可以访问。
- 命名管道 (FIFO) 或 网络Socket:提供了一种标准的通信通道,进程只需向通道读写,而不关心另一端是谁。
11. linux进程通信的几种方式以及各自的应用场景
1. **管道 (Pipe)**:单向通信。用于有亲缘关系(父子进程)的进程间通信。`ls | grep test`。
2. **命名管道 (FIFO)**:克服了管道没有名字的限制,可用于无亲缘关系的进程。
3. **消息队列 (Message Queue)**:消息的链表,克服了管道字节流模型的限制。用于需要按特定消息单元通信的场景。
4. **共享内存 (Shared Memory)**:最快的IPC方式。多个进程共享同一块内存空间。需要与信号量等同步机制配合使用。适用于对通信速度要求极高的场景,如大数据交换。
5. **信号量 (Semaphore)**:主要用作**进程间同步**,控制多个进程对共享资源的访问。
6. **信号 (Signal)**:一种异步通信机制,用于通知接收进程某个事件已经发生(如 `kill -9`)。
7. **套接字 (Socket)**:最通用的IPC方式,不仅可用于同一台主机的进程间通信,还可用于网络通信。
12. 说一下数据库的范式
范式是设计数据库表结构的规范,目的是减少数据冗余,提高数据一致性。
- 第一范式 (1NF):原子性。字段是不可再分的最小单元。
- 第二范式 (2NF):在满足1NF的基础上,消除非主属性对候选码的部分函数依赖。即每个非主字段必须完全依赖于整个主键(针对联合主键)。
- 第三范式 (3NF):在满足2NF的基础上,消除非主属性对候选码的传递函数依赖。即非主字段不能依赖于另一个非主字段。
- 巴斯-科德范式 (BCNF):在3NF的基础上,消除主属性对候选码的部分和传递函数依赖。
通常,设计到第三范式就足够满足大多数应用需求。
13. 说一下多线程死锁的原因吧
死锁是指两个或两个以上的线程在执行过程中,因争夺资源而造成的一种互相等待的现象。死锁产生的四个必要条件(缺一不可):
1. **互斥条件**:一个资源每次只能被一个线程使用。
2. **请求与保持条件**:一个线程因请求资源而阻塞时,对已获得的资源保持不放。
3. **不剥夺条件**:线程已获得的资源,在未使用完之前,不能强行剥夺。
4. **循环等待条件**:若干线程之间形成一种头尾相接的循环等待资源关系。
14. 如何避免死锁呢?
只要破坏死锁四个必要条件中的任意一个即可。
1. 破坏“请求与保持”:一次性申请所有所需资源,否则不执行。
2. 破坏“不剥夺”:如果一个线程申请新资源失败,它必须释放已占有的所有资源。
3. 破坏“循环等待”:给所有资源统一编号,线程必须按编号的递增顺序申请资源。(最常用且实用的策略)
4. 使用超时机制:在尝试获取锁时设置超时时间,超时后放弃并释放已有资源,避免无限期等待。
百度一面C++
好的,我们来逐一解答这些面试题。答案会保持清晰、准确,并包含必要的深度。
C++的多态是如何实现的?
C++的多态主要通过 虚函数 (Virtual Function) 和 动态绑定 (Dynamic Binding) 来实现,其核心技术是 虚函数表 (vtable) 和 虚函数表指针 (vptr)。
实现机制:
虚函数表 (vtable):
- 编译器会为每一个包含虚函数的类自动生成一个虚函数表。
- 虚函数表是一个函数指针数组,其中的每个元素指向该类的一个虚函数的实际实现地址。
虚函数表指针 (vptr):
- 编译器会在包含虚函数的类的对象中自动添加一个隐藏的成员变量——虚函数表指针 (
vptr)。 - 当一个对象被创建时,它的
vptr会被初始化,指向其所属类的vtable。
- 编译器会在包含虚函数的类的对象中自动添加一个隐藏的成员变量——虚函数表指针 (
动态绑定过程:
- 当程序通过一个基类指针或引用调用一个虚函数时(例如
basePtr->func();),编译器不会直接生成调用具体函数的代码。 - Instead,它会生成代码来执行以下操作:
a. 通过对象内部的vptr找到该对象对应的vtable。
b. 在vtable中找到被调用虚函数对应的函数指针(位置在编译时就已确定)。
c. 通过该函数指针调用正确的函数(派生类的覆盖版本)。
- 当程序通过一个基类指针或引用调用一个虚函数时(例如
示例:
1 | class Base { |
总结: 多态的实现代价是每个对象需要额外的空间(vptr)和每次调用虚函数需要一次间接寻址(查表),但换来了极大的灵活性。
vector的插入复杂度,map的插入复杂度
std::vector的插入复杂度:- 在末尾插入 (
push_back):平均复杂度为 O(1)。虽然在某些情况下需要重新分配内存并拷贝所有元素(此时为 O(n)),但通过扩容策略(通常是翻倍),其均摊 (Amortized) 复杂度是 O(1)。 - 在中间或开头插入 (
insert):复杂度为 O(n)。因为需要将插入点之后的所有元素都向后移动一位。
- 在末尾插入 (
std::map(通常用红黑树实现) 的插入复杂度:- 插入一个元素 (
insert):O(log n)。因为红黑树是平衡二叉搜索树,插入操作需要先查找位置 (O(log n)),再进行最多常数次的旋转调整以保持平衡。
- 插入一个元素 (
了解std::move()吗?…
std::move()是什么?std::move()本质上是一个类型转换函数,而非“移动”操作。它将一个左值强制转换为右值引用。它的作用是标识一个对象的值不再需要,其资源可以被“移动”而非拷贝,从而允许高效的资源转移。如果想使用std::move(),在类中做什么样的配合?
要配合std::move实现高效的资源转移,类需要定义移动构造函数 (Move Constructor) 和移动赋值运算符 (Move Assignment Operator)。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18class MyString {
char* data;
public:
// 移动构造函数
MyString(MyString&& other) noexcept : data(other.data) {
other.data = nullptr; // 关键:置空源对象,防止其析构时释放资源
}
// 移动赋值运算符
MyString& operator=(MyString&& other) noexcept {
if (this != &other) {
delete[] data; // 释放自己的资源
data = other.data; // 接管资源
other.data = nullptr;
}
return *this;
}
// ... 拷贝构造、析构等函数
};int a = 3; int b = move(3);,那a和b的值现在分别是什么?int a = 3;:a的值是 3。int b = move(3);:3是一个字面量,本身就是右值。std::move(3)的结果仍然是右值。对于内置类型(如int),移动和拷贝是没有区别的,因为它们的“资源”就是值本身,复制成本极低。所以b的值也是 3。- 关键点:
std::move()本身不产生任何移动操作,它只是将一个表达式转换为右值。只有当一个类定义了移动语义(如移动构造函数)时,这个右值才会被用来触发移动操作而不是拷贝操作。对于没有移动语义的类或内置类型,std::move()后依然会进行拷贝。
了解C++中的模板吗?实际使用过吗?
了解:C++模板是一种支持泛型编程的工具。它允许你编写与类型无关的代码。编译器会根据使用时提供的具体类型,在编译期实例化出对应的代码。
- 模板函数:
template <typename T> T max(T a, T b) { return (a > b) ? a : b; } - 模板类:
template <typename T> class Stack { ... };
- 模板函数:
实际使用:
- STL容器:每天都在用,如
vector<int>,map<string, int>。 - STL算法:如
sort(myVec.begin(), myVec.end()),find等,都是函数模板。 - 自定义工具:比如编写一个泛型的日志函数、一个序列化工具类,或者实现一个线程安全的队列模板。
- STL容器:每天都在用,如
std::sort()底层使用什么排序算法?…
std::sort()的底层算法:它并非单一算法,而是一种混合算法 内省排序 (Introsort)。- 主要使用快速排序。
- 当递归深度过深(接近最坏情况 O(n²) 时,转为堆排序(保证最坏时间复杂度为 O(n log n))。
- 当排序的元素数量很少时(例如 <= 16),转为插入排序(因为对于小数据量,插入排序的常数因子小,实际效率更高)。
排序算法复杂度:
算法 平均时间复杂度 最坏时间复杂度 空间复杂度 快速排序 O(n log n) O(n²) O(log n) ~ O(n) 堆排序 O(n log n) O(n log n) O(1) 插入排序 O(n²) O(n²) O(1)
用过多线程编程吗?
是的,用过。
- 使用的API/库:主要使用 C++11 标准库中的
<thread>,<mutex>,<condition_variable>,<future>等。也使用过 POSIX Threads (pthreads)。 - 常见任务:
- 创建线程执行并发任务(计算、I/O)。
- 使用互斥锁 (
std::mutex) 和锁保护 (std::lock_guard) 来保护共享数据,避免竞态条件。 - 使用条件变量 (
std::condition_variable) 来实现线程间的等待和通知机制(生产者-消费者模型)。 - 使用异步操作 (
std::async,std::future) 来获取后台任务的结果。
遍历求和效率问题…
先遍历行再遍历列的效率远高于先遍历列再遍历行。
原因:这与CPU缓存的工作机制(局部性原理)密切相关。
- 内存布局:C/C++中的多维数组在内存中是行主序 (Row-Major) 连续存储的。
array[0][0],array[0][1],array[0][2]… 的地址是连续的。 - CPU缓存与缓存行 (Cache Line):
- CPU访问内存时,并非一次只读一个字节,而是会一次性读取一个缓存行(通常为64字节)到高速缓存中。
- 如果你按行遍历,
array[i][j]之后访问的array[i][j+1]有很大的概率已经在缓存中了(空间局部性),CPU直接命中缓存,速度极快。
- 低效的列遍历:
- 如果你按列遍历,例如访问
array[0][0]后跳去访问array[1][0],这两个元素在内存中相距10000 * sizeof(int)个字节。它们极不可能在同一个缓存行内。 - 每次访问都会导致缓存未命中 (Cache Miss),CPU必须去速度慢得多的主内存中读取数据,这会浪费大量等待时间(称为停滞周期)。
- 这还会导致之前被加载到缓存中的有效数据(如一整行的数据)可能被频繁换出,缓存利用率极低。
- 如果你按列遍历,例如访问
结论: 编写循环时,应尽量让最内层的循环遍历连续的内存地址,以最大化缓存命中率,这是最重要的性能优化手段之一。
机器学习方面了解哪些?知道有几种优化器吗?
了解方面:了解机器学习的基本流程(数据预处理、模型定义、训练、评估)、常见的监督学习(分类、回归)和无监督学习(聚类)任务,以及深度学习的基础(神经网络、反向传播)。
优化器 (Optimizer):优化器是用于在训练过程中更新模型参数(权重和偏置)以最小化损失函数的算法。
- SGD (随机梯度下降):最基础,但容易震荡,收敛慢。
- SGD with Momentum:引入“动量”概念,加速SGD并抑制震荡。
- AdaGrad:为每个参数自适应地调整学习率,适合稀疏数据。
- RMSprop:是AdaGrad的改进,解决其学习率急剧下降的问题。
- Adam (最常用):结合了Momentum和RMSprop的优点,通常收敛快且效果好,是默认的推荐选择。
- AdamW:Adam的变体,修正了权重衰减(L2正则化)的实现,通常泛化能力更好。
八股文
以下是对这些问题的详细解答,涵盖 C++ 语言特性、内存模型、设计模式、操作系统、编译链接、网络协议等多个方面:
1️⃣ 一个 C++ 类的大小会受哪些因素影响?
- 非静态成员变量:每个非静态成员变量占用内存,对齐(alignment)会影响总大小。
- 虚函数:引入虚函数会导致类中包含一个虚表指针(vptr),通常为 4 或 8 字节(32/64 位系统)。
- 继承:基类的成员变量会并入派生类,可能引入额外的 vptr(多继承时可能多个)。
- 对齐(Alignment):编译器会根据成员变量的对齐要求插入填充字节(padding)。
- 空类:大小为 1 字节(用于区分不同实例)。
- 静态成员变量:不占用实例大小(存放在全局数据区)。
2️⃣ 虚表指针在类里是怎么分布的?
- 通常位于类实例的起始位置(最常见,便于多态访问),但也可能在其他位置(取决于编译器实现)。
- 每个多态类(含虚函数或继承自多态类)至少有一个 vptr。
- 多继承时,可能包含多个 vptr(每个基类一个)。
3️⃣ 多继承且每个父类都有虚函数时,内存布局和虚表指针如何分布?
- 派生类实例包含所有基类的子对象(按声明顺序排列)。
- 每个基类子对象可能包含自己的 vptr(如果该基类有多态性)。
- 派生类可能有一个额外的 vptr(用于自己的虚函数)。
- 虚表(vtable)包含:
- 基类的虚函数指针(可能被重写)
- 派生类新增的虚函数指针
- 可能引入虚基类指针(vbptr)(如果涉及虚继承)。
示例(假设两个基类):
1 | class A { virtual void f(); }; |
内存布局(简化):
C对象包含A子对象(vptr_A)、B子对象(vptr_B)、C的成员(如果有)vptr_A指向的虚表包含:A::f(或C::f若重写)、C::hvptr_B指向的虚表包含:B::g(或C::g若重写)、以及可能的调整信息(thunk)
4️⃣ 若基类构造函数里调用自身被派生类重写的虚函数,最终调到哪里?
- 调用的是基类自己的版本(不是派生类的重写版本)。
- 原因:在基类构造期间,派生类尚未构造完成,虚表指针指向基类的虚表(动态类型是基类)。
5️⃣ 虚函数调用相比普通函数有哪些性能开销?
- 间接调用:通过 vptr 找到虚表,再通过偏移找到函数地址(多一次指针解引用)。
- 无法内联:通常编译器无法内联虚函数(除非 devirtualization 优化)。
- 缓存不友好:虚表访问可能导致缓存未命中(尤其是跨多态层次调用)。
6️⃣ 讲一下 C++11 的右值引用。
- 右值引用(
T&&)用于标识可移动的临时对象(右值)。 - 允许高效转移资源(如动态内存、文件句柄),避免深拷贝。
- 支持移动语义(move semantics)和完美转发(perfect forwarding)。
7️⃣ 右值引用实现移动语义主要用来做什么?
- 避免不必要的拷贝:将资源(如指针)从临时对象“窃取”到新对象。
- 典型应用:容器(
vector、string)的移动构造/赋值、智能指针、std::unique_ptr。
8️⃣ 为什么移动构造函数通常标记为 noexcept?
- 保证移动操作不会抛出异常,允许标准库在异常安全时使用移动(如
vector扩容)。 - 若未标记
noexcept,容器可能选择拷贝(以保证强异常安全)。
9️⃣ std::forward 的作用是什么?
- 用于完美转发(perfect forwarding),保持参数的值类别(左值/右值)。
- 在模板中转发参数时,避免不必要的拷贝或丢失右值属性。
- 通常与通用引用(
T&&)配合使用。
🔟 解释一下 RAII。
- 资源获取即初始化(Resource Acquisition Is Initialization):
- 在构造函数中获取资源(如内存、文件、锁)。
- 在析构函数中释放资源。
- 确保资源不被泄露(即使发生异常)。
1️⃣1️⃣ RAII 与异常处理机制如何配合?
- 析构函数会在栈展开(stack unwinding)过程中被调用,确保资源释放。
- 例如:
std::lock_guard在异常时自动释放锁;智能指针自动释放内存。
1️⃣2️⃣ 了解 C++ 的异常安全吗?
- 四个级别(由 Herb Sutter 提出):
- 无保证(No guarantee):可能泄露资源或破坏状态。
- 基本保证(Basic guarantee):不发生泄露,状态有效(但不一定原始状态)。
- 强保证(Strong guarantee):操作成功或状态回滚(如事务)。
- 不抛保证(Nothrow guarantee):承诺不抛出异常。
1️⃣3️⃣ std::shared_ptr 管理动态数组时如何正确释放?
- 默认的
delete不支持数组,需提供自定义删除器:
1 | std::shared_ptr<int[]> p(new int[10], std::default_delete<int[]>()); |
1️⃣4️⃣ dynamic_cast 与 static_cast 有什么区别?
dynamic_cast:- 用于多态类型(含虚函数),在运行时检查转换安全性。
- 失败返回
nullptr(指针)或抛出异常(引用)。
static_cast:- 编译时转换,不进行运行时检查(可能不安全)。
- 可用于非多态类型、数值转换、向上/向下转换(但向下转换不安全)。
1️⃣5️⃣ 设计模式了解吗?
- 是,设计模式是解决常见软件设计问题的可重用方案(如单例、工厂、观察者等)。
1️⃣6️⃣ 设计模式的 SOLID 原则清楚吗?
- S:单一职责原则(一个类只负责一个功能)。
- O:开闭原则(对扩展开放,对修改关闭)。
- L:里氏替换原则(子类应能替换基类)。
- I:接口隔离原则(多个专用接口优于一个通用接口)。
- D:依赖倒置原则(依赖抽象而非具体实现)。
1️⃣7️⃣ STL 的 allocator 干什么用?
- 用于管理内存分配和释放,实现与容器解耦。
- 允许自定义内存分配策略(如池分配器、共享内存分配器)。
1️⃣8️⃣ vector 的动态扩容机制是怎样的?
- 当
size() == capacity()时,插入新元素会触发扩容:- 分配新内存(通常为原大小的 2 倍或 1.5 倍,取决于实现)。
- 将旧元素移动或拷贝到新内存。
- 释放旧内存。
1️⃣9️⃣ vector 扩容后如何决定使用 move 还是 copy?
- 如果元素类型具有
noexcept移动构造函数,则使用移动(否则可能拷贝以保证强异常安全)。 - 可通过
std::move_if_noexcept判断。
2️⃣0️⃣ SFINAE 知道吗?
- 替换失败不是错误(Substitution Failure Is Not An Error):
- 在模板重载解析时,如果替换模板参数失败,则跳过该候选,而不是报错。
- 用于 enable/disable 模板重载(常与
std::enable_if配合)。
2️⃣1️⃣ std::shared_ptr 的控制块通常有哪些数据成员?
- 引用计数(use_count):共享所有权计数。
- 弱引用计数(weak_count):弱引用计数(用于
weak_ptr)。 - 删除器(deleter):自定义释放函数。
- 分配器(allocator):用于分配控制块和内存(可选)。
2️⃣2️⃣ 原子引用计数如何实现?
- 使用原子操作(如
std::atomic<int>)确保线程安全。 - 操作(递增/递减)使用原子指令(如
fetch_add、compare_exchange_strong)。
2️⃣3️⃣ 原子引用计数存放于何处?
- 在
std::shared_ptr的控制块(control block)中(动态分配)。
2️⃣4️⃣ 描述一个程序的完整编译过程。
- 预处理:处理宏、头文件包含等(生成
.i文件)。 - 编译:词法分析、语法分析、语义分析、优化,生成汇编代码(
.s)。 - 汇编:将汇编代码转换为机器码(目标文件
.o)。 - 链接:合并目标文件和库,解析符号引用(生成可执行文件)。
2️⃣5️⃣ 动态链接的大致过程?
- 程序运行时由动态链接器(如
ld-linux.so)加载共享库(.so/.dll)。 - 步骤:
- 查找共享库(在标准路径或
LD_LIBRARY_PATH)。 - 映射库到进程地址空间。
- 重定位符号地址。
- 查找共享库(在标准路径或
2️⃣6️⃣ 动态链接的重定位过程?
- 重定位:修改代码中的符号引用(如函数、变量),使其指向正确的地址。
- 使用 PLT(过程链接表) 和 GOT(全局偏移表) 实现延迟绑定(lazy binding)。
2️⃣7️⃣ ELF 文件结构是怎样的?
- ELF 头:标识文件类型、架构等。
- 节区(Sections):存储代码、数据(如
.text、.data、.bss、.rodata)。 - 段(Segments):用于加载(如可加载的代码段、数据段)。
- 符号表、重定位表等。
2️⃣8️⃣ BSS 段的作用?
- 存储未初始化的全局变量和静态变量(实际不占文件空间,运行时初始化为0)。
2️⃣9️⃣ 操作系统如何把 ELF 文件加载成进程?
- 解析 ELF 头,检查有效性。
- 映射段到内存(代码段只读,数据段可写)。
- 初始化 BSS 段为0。
- 设置堆栈。
- 动态链接(如果需要)。
- 跳转到入口点(如
_start)。
3️⃣0️⃣ 系统调用时参数如何传递并进入内核?
- x86-64:通过寄存器(
rdi、rsi、rdx、r10、r8、r9)传递参数,使用syscall指令。 - x86:通过寄存器传递,使用
int 0x80或sysenter。 - 内核态切换:CPU 切换到特权模式,跳转到系统调用处理函数。
3️⃣1️⃣ 用户态传递大 buffer 时内核如何处理?
- 通过指针传递(用户空间地址),内核需要验证地址合法性并拷贝数据(如
copy_from_user)。 - 避免直接访问用户空间(防止内核崩溃或安全漏洞)。
3️⃣2️⃣ 为什么操作系统使用多级页表管理虚拟内存?
- 节省内存:多级页表仅分配实际使用的部分(稀疏地址空间),而不像单级页表需要连续完整映射。
3️⃣3️⃣ 多级页表如何进行地址映射?
- 虚拟地址分割为多个索引(如四级页表:PML4、PDP、PD、PT),逐级查表得到物理地址。
- 每级页表项(PTE)存储下一级页表的物理地址或最终页框地址。
3️⃣4️⃣ 发生缺页中断时操作系统会做哪些事?
- 检查访问是否合法(地址是否在进程空间)。
- 分配物理页帧(可能需换出其他页)。
- 从磁盘(如交换区或文件)读取数据到物理页。
- 更新页表。
- 重新执行触发缺页的指令。
3️⃣5️⃣ 操作系统如何管理堆内存?
- 通过 brk 和 mmap 系统调用扩展堆空间。
- 使用内存分配器(如 glibc 的 malloc)管理空闲块(链表或树结构),处理分配和释放。
3️⃣6️⃣ 操作系统如何实现互斥锁?
- 原子指令(如 CAS)实现用户态锁(如自旋锁)。
- 系统调用(如
futex)在竞争时使线程睡眠,避免忙等待。
3️⃣7️⃣ 如何避免死锁?
- 破坏死锁条件:
- 互斥:无法避免。
- 持有并等待:一次性获取所有资源。
- 不可抢占:允许抢占资源。
- 循环等待:按顺序申请资源(资源排序)。
3️⃣8️⃣ 死锁已发生时如何定位?
- 工具:
pstack、gdb查看线程堆栈,分析锁的持有和等待关系。 - 日志:记录锁获取顺序。
3️⃣9️⃣ 了解 AddressSanitizer 的实现原理吗?
- 通过编译时插桩和影子内存(shadow memory)检测内存错误(如越界、use-after-free)。
- 每 8 字节应用内存对应 1 字节影子内存,标识可访问性。
4️⃣0️⃣ 计算机网络的五层协议有哪些?
- 物理层
- 数据链路层
- 网络层(IP)
- 传输层(TCP/UDP)
- 应用层(HTTP/FTP等)
4️⃣1️⃣ ping 命令工作在哪一层?
- 网络层(使用 ICMP 协议)。
4️⃣2️⃣ IP 协议头部有哪些字段?
- 版本、头部长度、服务类型、总长度、标识、分片偏移、生存时间(TTL)、协议、校验和、源/目的IP地址等。
4️⃣3️⃣ TCP 三次握手过程?
- Client → Server:SYN(seq=x)
- Server → Client:SYN-ACK(seq=y, ack=x+1)
- Client → Server:ACK(seq=x+1, ack=y+1)
4️⃣4️⃣ 为什么握手三次、挥手四次?
- 握手三次:确保双方收发能力正常(最少三次)。
- 挥手四次:因为 TCP 半关闭(一方发送 FIN 后仍可接收数据),需分别关闭读写方向。
4️⃣5️⃣ 描述 CMakeLists.txt 的基本结构。
1 | cmake_minimum_required(VERSION 3.10) |
如有进一步探讨需求,可随时提出。
心影随行C++一面
volatile关键字
好的,我们来详细、深入地探讨一下 volatile 关键字。这是一个非常重要但又容易被误解的关键字,尤其是在涉及多线程编程时。
核心思想:为什么需要 volatile?
volatile 的根本作用是禁止编译器进行某些激进的优化。这些优化在操作普通内存时是安全且有效的,但在操作“特殊内存”时会导致程序行为错误。
所谓“特殊内存”,指的是其值可能被程序本身之外的代理改变的内存。例如:
- 内存映射的硬件寄存器:例如,一个指向硬件状态的指针。硬件可以随时改变这个状态,而编译器并不知道。
- 被中断服务程序修改的变量:在主程序流程中,一个变量可能被中断服务程序(ISR)修改。
- 被另一个线程修改的变量(注意:这是常见的误解,我们后面会详细说)。
如果没有 volatile,编译器会基于它对程序流的理解来进行优化,它假设程序是唯一能改变内存中值的实体。
volatile 到底做了什么?
当你将一个变量声明为 volatile 后,你是在给编译器一个强烈的提示:“这个变量的值可能会以编译器无法察觉的方式突然改变”。
具体来说,volatile 关键字确保了两点:
禁止编译器优化掉读写操作:
- 无优化:对于普通变量,如果编译器发现两次读取该变量之间没有代码修改它,它可能会为了效率而将第二次读取优化掉,直接使用第一次读取时缓存在寄存器中的值。
- 有
volatile:每次使用volatile变量时,都必须从它的内存地址中重新读取;每次赋值给volatile变量时,都必须立即写回它的内存地址中。编译器不能做任何“省略”或“缓存”其值的优化。
防止指令重排(在与硬件交互时尤为重要):
- 编译器或CPU为了优化性能,可能会在不影响单线程程序逻辑的前提下,对指令的执行顺序进行重排。
- 对于
volatile变量的访问,编译器会在生成的指令序列中插入“内存屏障”,确保所有对volatile变量的读写操作之间的顺序不会被重排。例如,对一个volatile变量的写操作一定不会重排到另一个volatile变量的读操作之后。
经典示例:没有 volatile 导致的错误
1 |
|
问题:当你启用编译器优化(如 -O2)时,这个程序很可能会陷入死循环。
原因:
编译器在优化时看到 while (status == 0) 循环,它发现循环体内没有任何代码能修改 status 的值。因此,它“聪明地”得出结论:status 永远为 0。于是,它将代码优化成类似这样:
1 | mov eax, DWORD PTR [status] ; 第一次从内存读取 status 到寄存器 eax |
注意,status 的值只被读取了一次并缓存在寄存器 eax 中,之后循环永远检查的是 eax,而不是真正的内存地址。即使中断服务程序修改了内存中 status 的值,这个循环也永远看不到。
解决方案:使用 volatile
1 | volatile int status = 0; // 告诉编译器,这个值可能“突然”改变 |
现在,编译器每次判断 status == 0 时,都必须从 status 的真实内存地址重新读取值。生成的汇编代码会像这样:
1 | .L2: |
这样,当中断服务程序修改 status 后,循环就能正确退出。
volatile 与多线程编程(非常重要!)
这是一个非常常见的误区:试图用 volatile 来解决多线程数据竞争和可见性问题。
结论:volatile 不适用于多线程同步。
为什么?
原子性(Atomicity):
volatile不保证操作的原子性。像x++这样的操作(读-改-写),即使x是volatile,在多线程下仍然是一个非原子操作,会导致数据竞争。- 正确的工具是:原子类型(
std::atomic) 或 互斥锁(std::mutex)。
顺序性(Ordering):
volatile防止了编译器重排,但不足以防止现代CPU的运行时指令重排(内存重排)。std::atomic提供了严格的内存顺序控制(如memory_order_seq_cst),可以防止CPU级别的重排,从而确保一个线程的写操作能被另一个线程以正确的顺序观察到。
可见性(Visibility):
- 这是
volatile唯一能部分提供的属性(确保每次从内存读,每次写回内存)。 - 然而,在现代多核CPU架构中,由于每个核心可能有自己的缓存,一个线程的写操作即使写回了主内存,其他线程的缓存也可能不会立即失效并更新。这需要内存屏障指令来保证。
std::atomic在执行操作时会插入适当的内存屏障,确保修改对其他线程立即可见。而volatile在C/C++标准中并不要求生成内存屏障。
- 这是
简单比喻:
volatile:像是说“别偷懒,每次都要去黑板上看最新通知,别用你笔记本上记的旧内容”。std::atomic或mutex:像是不仅要求大家看黑板,还安排了一个老师(锁/内存屏障)来协调大家看通知的顺序,防止拥挤和混乱(数据竞争)。
因此,在多线程中,应该使用 std::atomic<bool> 或 std::mutex,而不是 volatile bool。
volatile 的正确使用场景
内存映射I/O(Memory-mapped I/O):
这是volatile最经典的使用场景。操作硬件时,你会将指针指向一个特定的硬件地址,通过读写这个地址来与控制寄存器或数据缓冲区进行交互。1
2volatile uint32_t *const hardware_reg = (uint32_t*)0xFEEDBEEF;
*hardware_reg = 0x1; // 发送一个命令给硬件被信号处理程序修改的变量:
在主程序中,一个变量可能会被一个信号处理函数异步地修改。1
2
3
4
5
6
7
8
9
10
11volatile sig_atomic_t signal_received = 0;
void signal_handler(int sig) {
signal_received = 1;
}
int main() {
signal(SIGINT, signal_handler);
while (!signal_received) { // 必须 volatile 以确保检查最新值
// 正常工作
}
printf("Received signal, exiting...\\n");
}在裸机嵌入式系统中的全局变量:
在没有任何操作系统的嵌入式系统中,如果存在一个主循环和一个中断服务程序共享的全局变量,这个变量必须声明为volatile。
C++ 与 Java/C# 中 volatile 的区别
请注意,这个区别非常重要:
- 在 Java 和 C# 中:
volatile关键字被赋予了更强的语义。它确实能保证变量的可见性和一定的顺序性(禁止指令重排),可以用于简单的无锁多线程编程。它的行为更接近于 C++ 中的std::atomic默认内存顺序(顺序一致性)。 - 在 C 和 C++ 中:
volatile的语义要弱得多,如前所述,它只与编译器优化和“特殊内存”相关,与多线程同步无关。
切勿将 Java 中对 volatile 的理解套用到 C++ 中!
总结
| 特性 | volatile |
std::atomic (默认) |
std::mutex |
|---|---|---|---|
| 防止编译器优化 | 是 | 是 | 是(通过锁的API) |
| 保证原子性 | 否 | 是 | 是(保护代码块) |
| 保证顺序性 | 有限(仅编译器) | 是(CPU内存屏障) | 是(更强的顺序保证) |
| 保证可见性 | 有限(仅编译器) | 是(CPU缓存一致性) | 是 |
| 主要用途 | 内存映射IO、信号处理 | 无锁多线程编程 | 保护临界区 |
核心建议:
- 当你操作硬件寄存器或被异步修改(如中断)的变量时,使用
volatile。 - 当你在进行多线程编程时,需要共享数据,请使用
std::atomic(对于简单变量)或std::mutex(对于代码块或复杂数据结构)。不要使用volatile。
影石一面
好的,这是一份对这些经典C++和计算机基础面试题的详细解答,旨在帮助您准备面试。
C++ 核心
1. C++类的大小由什么决定?
- 非静态成员变量:所有非静态数据成员的大小之和。遵循内存对齐规则。
- 内存对齐(Padding):编译器为了提升访问效率,会在成员之间或末尾插入空白字节,确保成员在自然边界上对齐。
- 虚函数(vptr):如果类包含虚函数(或继承自有虚函数的类),则会有一个指向虚函数表(vtable)的指针(vptr)。在64位系统上通常占8字节。
- 继承:基类的非静态成员也会成为派生类的一部分,影响其大小。
- 注意:静态成员变量、成员函数、普通函数都不占用类实例的大小。
2. 单继承下虚函数表数量?多继承为什么会有多个虚函数表及对应表头指针?
- 单继承:通常只有一个虚函数表(vtable)。派生类和基类共享一个vtable,派生类的新虚函数会追加到这个vtable的末尾。
- 多继承:
- 派生类会拥有多个虚函数表,每个直接基类对应一个(如果该基类有虚函数)。
- 原因:为了满足不同基类指针的动态绑定。当一个
Derived*被转换为Base2*时,它的地址可能需要调整(this指针偏移)。每个基类对应的vtable中,不仅记录了该视角下可用的虚函数地址,也隐含了进行这种this指针偏移的信息。多个vptr(每个位于子对象开始处)可以快速定位到对应的vtable。
3. 虚函数相比普通函数的性能开销?
- 间接调用开销:需要通过vptr找到vtable,再通过vtable中的偏移找到函数地址,比直接调用多两次内存访问。
- 编译器优化障碍:虚函数通常是运行时绑定(动态多态),阻碍了内联、过程间优化等编译期优化。
- 缓存不友好:vtable和函数代码可能不在缓存中,导致缓存缺失(Cache Miss)。但vptr本身通常在缓存中。
- 注意:在绝对性能要求极高的场景(如硬件驱动、高频交易核心循环)需谨慎使用,但在大多数应用场景下,这点开销是值得的,它带来了设计的灵活性。
4. 虚函数重写的时机?
- 时机:发生在运行时(Runtime)。当通过基类的指针或引用调用虚函数时,具体调用哪个版本的函数(基类还是派生类)取决于指针或引用所指向的对象的实际类型。
5. 什么是右值引用?
- 定义:右值引用(
T&&)是C++11引入的一种引用类型,它专门用于绑定到即将被销毁/临时的对象(右值)。 - 目的:支持移动语义(Move Semantics)和完美转发(Perfect Forwarding),从而避免不必要的深拷贝,提升性能。
6. move 的操作过程?
std::move()本质上是一个静态转换,它不做任何实际的移动操作。- 过程:
std::move(x)将左值x无条件地转换为右值引用(static_cast<typename std::remove_reference<T>::type&&>(t))。 - 效果:转换后,
x被标记为一个“可被移动的”右值。随后,当这个结果被传递给一个接受右值引用参数的函数(如移动构造函数、移动赋值运算符)时,该函数才会真正执行“移动”操作(通常是 pilfering(窃取)资源并将源对象置于有效但未定义的状态)。
7. string 类型的移动构造做了哪些事情?
1. 直接“窃取”源字符串(右值)内部的指针(指向堆上的字符数组)。
2. 将本对象的内部指针指向这块偷来的内存。
3. 将源对象的内部指针设置为 `nullptr`(或一个指向空字符串的小缓冲区),使其变为空字符串状态。
4. 拷贝大小、容量等信息。
- 结果:避免了为目标字符串分配新内存和拷贝字符内容的昂贵操作,操作复杂度接近O(1)。
8. forward 函数?为什么不用forward会变成左值?
- 完美转发(Perfect Forwarding):
std::forward<T>(arg)用于在模板函数中保持参数原有的值类别(左值性或右值性)。 - 为什么不用会变左值:在模板函数内部,所有具名的参数(包括右值引用参数,如
T&& t)本身都是左值(因为它们有名字,可以取地址)。如果直接将这个左值t传递给另一个函数,它永远会被当作左值处理,无法触发接收函数的移动语义版本。std::forward会根据模板参数T的推导结果,有条件地将t转换回它原始的值类别(如果是用右值初始化的,就转回右值),从而实现“完美”转发。
9. C++ 的 RAII 机制核心是什么?
- 核心思想:将资源(内存、文件句柄、锁、套接字等)的生命周期与对象的生命周期相绑定。
- 具体实现:在构造函数中获取资源,在析构函数中释放资源。这样,只要对象超出作用域(无论是正常离开还是因异常离开),其析构函数就会被自动调用,从而确保资源被安全释放。
10. RAII 如何配合异常处理的流程?
- RAII是C++中处理异常安全性的基石。
- 当异常被抛出时,会发生栈展开(Stack Unwinding):当前作用域内已构造的局部对象的析构函数会被自动调用。
- 因此,即使函数因异常而提前退出,RAII对象(如 std::lock_guard, std::unique_ptr, std::ifstream)也会被正常销毁,它们所管理的资源会被自动释放,从而避免了资源泄漏。
11. dynamic_cast、static_cast 的区别?
| 特性 | static_cast |
dynamic_cast |
|---|---|---|
| 时间 | 编译期 | 运行时 |
| 检查 | 无运行时检查,不安全 | 有运行时类型检查(RTTI),安全 |
| 开销 | 无额外开销 | 有查找RTTI的开销 |
| 用途 | 相关类型间的转换(如数值类型、void*、有继承关系的指针**(向下转换不安全)**) | 主要用于沿继承层级的安全向下转换(Downcasting) |
| 失败 | 不安全转换导致未定义行为 | 对指针返回nullptr,对引用抛出std::bad_cast |
12. 设计模式的原则?
- SOLID 原则:
- S:单一职责原则(Single Responsibility)
- O:开闭原则(Open-Closed)
- L:里氏替换原则(Liskov Substitution)
- I:接口隔离原则(Interface Segregation)
- D:依赖倒置原则(Dependency Inversion)
13. 单例模式怎么实现?
核心:保证一个类只有一个实例,并提供一个全局访问点。
C++实现要点(懒汉式,C++11线程安全):
1
2
3
4
5
6
7
8
9
10
11
12
13class Singleton {
public:
static Singleton& getInstance() {
static Singleton instance; // C++11保证局部静态变量初始化是线程安全的
return instance;
}
// 删除拷贝构造和赋值操作
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
private:
Singleton() = default; // 私有化构造函数
~Singleton() = default;
};
14. 观察者模式的应用场景?
- 场景:当一个对象(Subject)的状态变化需要通知其他多个对象(Observers),且这些对象是未知的或动态变化的。
- 例子:GUI事件处理(按钮点击通知多个处理器)、发布-订阅系统、模型-视图(MVC)架构中模型通知视图更新。
15. 工厂模式的分类及作用?
- 简单工厂:一个工厂类根据传入的参数不同创建不同的产品对象。不属于GoF 23种设计模式。
- 工厂方法:定义一个创建对象的接口,但让子类决定实例化哪一个类。将对象的创建延迟到子类。
- 抽象工厂:提供一个接口,用于创建相关或依赖对象家族,而不需要指定它们的具体类。
- 作用:解耦客户端代码和具体类的创建过程,提高代码的灵活性和可维护性。
STL
16. STL 的空间分配器是怎么设计的?
- STL的
std::allocator在现代C++中主要是一个薄包装,底层通常调用::operator new和::operator delete。 - 但STL容器的设计允许用户自定义分配器。为了效率,SGI STL等实现设计了两级分配器:
- 第一级:直接使用
malloc和free,处理大块内存请求。 - 第二级:使用内存池和自由链表来管理小块内存,避免频繁向系统申请释放内存,减少内存碎片,提升小对象分配效率。
- 第一级:直接使用
17. STL 是怎么调用 allocator 的?(如 vector
1. `vector` 模板类内部会 typedef 其分配器类型 `using allocator_type = Allocator;`。
2. `vector` 内部持有一个分配器成员和一个指向动态数组的指针。
3. 当 `vector` 需要分配内存时(如在构造函数或 `resize` 时),它会通过 `std::allocator_traits<Allocator>::allocate(allocator, n)` 来请求分配 `n * sizeof(T)` 字节的内存。
4. 在分配的内存上构造对象时,会调用 `std::allocator_traits<Allocator>::construct(allocator, p, args...)`,这通常会使用 **placement new** 在地址 `p` 上以 `args` 为参数构造一个 `T` 类型的对象。
5. 析构时,先调用 `destroy` 销毁对象,再调用 `deallocate` 释放内存。
18. vector 扩容过程?
1. 当 `push_back` 等操作导致 `size() == capacity()` 时,`vector` 需要扩容。
2. 分配一块新的、更大的内存(通常是旧容量的 **2倍** 或 **1.5倍**,取决于实现)。
3. 将旧内存中的所有元素**移动**或**拷贝**到新内存中。
- C++11后,如果元素类型提供了 `noexcept` 的移动构造函数,会优先使用**移动**,否则使用**拷贝**。
4. **析构**并**释放**旧内存中的所有对象和旧内存块本身。
5. 更新内部的指针,指向新内存,并更新容量值。
19. vector 扩容时如何判断哪些元素需要移动哪些需要拷贝?
- 编译器在编译期通过
std::is_nothrow_move_constructible<T>::value这类类型特质(type trait)来查询。 - 判断规则:如果
T的移动构造函数是noexcept的(或者不抛出异常),则扩容时会使用移动构造。否则,出于强异常安全保证的考虑,会使用拷贝构造,因为如果在移动中途抛出异常,源对象可能已被修改,无法恢复原有状态。
20. push_back 和 emplace_back 区别?
push_back(const T& value): 接受一个已存在的对象,将其拷贝或移动到容器末尾。push_back(T&& value): 接受一个右值,将其移动到容器末尾。emplace_back(Args&&... args): 接受构造参数包,直接在容器末尾的内存处原地构造(in-place construction)一个新对象,无需创建临时对象。- 优势:
emplace_back避免了临时对象的创建和拷贝/移动操作,通常更高效。
智能指针 & 内存管理
21. shared_ptr 的控制块设计?
shared_ptr包含两个原始指针:一个指向被管理的对象,一个指向控制块。- 控制块是一个动态分配的对象,通常包含:
- 引用计数(use_count):当前有多少个
shared_ptr共享 ownership。 - 弱引用计数(weak_count):当前有多少个
weak_ptr在观察。 - 删除器(Deleter):如何删除被管理对象。
- 分配器(Allocator):如何分配/释放控制块本身(通常可忽略)。
- 引用计数(use_count):当前有多少个
22. shared_ptr 的引用计数存储在哪里?
- 存储在控制块中。控制块的内存是在创建第一个
shared_ptr(通常通过std::make_shared或std::allocate_shared)时动态分配的。
23. 程序编译过程?(源码到二进制)
1. **预处理(Preprocessing)**:处理宏(`#define`)、文件包含(`#include`)、条件编译(`#ifdef`)等,生成一个单一的翻译单元(`.i` 文件)。
2. **编译(Compilation)**:将预处理后的源代码进行词法分析、语法分析、语义分析、优化,**翻译成汇编代码**(`.s` 文件)。
3. **汇编(Assembly)**:将汇编代码**翻译成机器指令**,生成**目标文件**(`.o` 或 `.obj` 文件),包含二进制代码和数据。
4. **链接(Linking)**:将一个或多个目标文件以及所需的库文件合并在一起,**解析符号引用**(如函数调用),分配最终的内存地址,生成最终的可执行文件(`.exe`, `.out`)或库文件。
24. 动态链接为什么要加上 -fPIC 标记?
- PIC(Position-Independent Code):位置无关代码。
- 原因:动态链接库(
.so,.dll)在运行时可以被加载到进程内存空间的任意地址。使用-fPIC编译可以生成这样的代码:它不包含绝对地址,而是通过全局偏移表(GOT) 来访问全局变量和函数。这样,库代码只需加载一份到内存,就可以被多个进程共享,每个进程只需有自己的数据段副本,极大地节省了内存。
操作系统 & 系统编程
25. 进程初始化时操作系统做了什么?
1. 创建独立的**虚拟地址空间**。
2. 建立**页表**,将可执行文件的**代码段(.text)和数据段(.data, .bss)** 映射到该地址空间。
3. 设置**栈**和**堆**区域。
4. 将CPU的指令寄存器(如EIP/RIP)设置为程序的入口点(如 `_start`),开始执行程序。
26. 操作系统怎么分配进程的虚拟地址?
- 操作系统内核为每个进程维护一个虚拟内存地址空间的布局结构(例如,在Linux中是通过
mm_struct描述的)。 - 布局通常是标准的:代码段、数据段、BSS段、堆(向上增长)、内存映射区域、栈(向下增长)、内核空间。
- 当进程通过
malloc()或brk()/sbrk()请求堆内存,或通过mmap()请求内存映射时,内核的虚拟内存管理器会在进程的虚拟地址空间中找到一段足够大的空闲区域(hole) 分配给该请求,并更新页表。
27. 操作系统怎么实现从虚拟地址到物理地址的映射?
- 通过页表(Page Table) 数据结构来实现。
- 过程(MMU):
- CPU发出一个虚拟地址。
- 内存管理单元(MMU) 拦截该地址。
- MMU查询页表,找到该虚拟页号(VPN)对应的物理页框号(PFN)。
- 如果该页表项有效,MMU将PFN与页内偏移组合成物理地址。
- 如果页表项无效(页不在内存中),则触发缺页异常(Page Fault),由操作系统负责将所需的页从磁盘调入物理内存,并更新页表,然后重新执行导致异常的指令。
28. 页表初始化时会不会把所有虚拟内存都映射到物理内存?
- 不会。那样做极其低效,浪费物理内存。
- 延迟分配(Demand Paging):操作系统只建立最基本的映射(如代码段、数据段)。对于堆、栈等大部分区域,只在进程首次访问某虚拟页时,才通过缺页异常处理程序为其分配物理页框并建立映射。这是一种“按需分配”的策略。
29. C++ 常见的锁的类型?
std::mutex:基本的互斥锁。std::recursive_mutex:可重入互斥锁。std::timed_mutex:带超时功能的互斥锁。std::shared_mutex(C++17):读写锁,允许共享读,独占写。std::lock_guard/std::unique_lock/std::shared_lock:RAII包装器,用于自动管理锁的生命周期。
30. 互斥锁怎么实现?
- 用户态实现:通常使用原子操作(如CAS, Compare-And-Swap)来实现自旋锁(Spinlock),但纯自旋会浪费CPU。
- 内核态协助:现代操作系统的互斥锁(如futex)是混合型的:
- Fast Path:先在用户态尝试一次原子操作获取锁,如果成功则立即返回,开销很小。
- Slow Path:如果获取失败,则通过系统调用进入内核,将线程挂起到等待队列上休眠,让出CPU。当锁被释放时,内核会唤醒等待的线程。
31. 死锁的四个必要条件?
1. **互斥**:一个资源每次只能被一个进程使用。
2. **占有并等待**:一个进程因请求资源而阻塞时,对已获得的资源保持不放。
3. **不可抢占**:进程已获得的资源,在未使用完之前,不能强行被剥夺。
4. **循环等待**:若干进程之间形成一种头尾相接的循环等待资源关系。
32. 死锁怎么调试?
- 观察现象:程序卡住,CPU占用率低。
- 工具:
- GDB: attach到进程,
thread apply all bt查看所有线程的调用栈。通常会发现多个线程都在__lll_lock_wait等类似的锁等待函数中,并且调用栈显示它们正在相互等待对方持有的锁。 - Helgrind / DRD (Valgrind工具):用于检测线程错误,包括死锁。
- 操作系统命令:如Linux下的
pstack <pid>。
- GDB: attach到进程,
计算机网络
33. 计算机网络协议分层?
- OSI七层模型:物理层、数据链路层、网络层、传输层、会话层、表示层、应用层。
- TCP/IP五层模型(更实用):
- 物理层
- 数据链路层(如Ethernet)
- 网络层(如IP, ICMP)
- 传输层(如TCP, UDP)
- 应用层(如HTTP, DNS, FTP)
34. ping 命令工作在哪一层?
- 网络层。它使用 ICMP(Internet Control Message Protocol)协议的回送请求(Echo Request)和回送应答(Echo Reply)报文。
35. IP 头字段有哪些?
- 重要字段:版本(4/6)、首部长度、服务类型(TOS)、总长度、标识、标志、片偏移、生存时间(TTL)、协议(指示上层是TCP/UDP等)、首部校验和、源IP地址、目的IP地址、选项(可选)。
36. TCP 三次握手流程?
1. **SYN**:客户端发送一个SYN=1,seq=`x` 的包给服务器。进入SYN_SENT状态。
2. **SYN-ACK**:服务器收到后,回复一个SYN=1,ACK=1,ack=`x+1`,seq=`y` 的包。进入SYN_RCVD状态。
3. **ACK**:客户端收到后,再发送一个ACK=1,ack=`y+1`,seq=`x+1` 的包给服务器。完成连接,双方进入ESTABLISHED状态。
- 目的:交换初始序列号(ISN),确认双方的收发能力正常。
调试 & 其他
37. 内存泄漏怎么定位?
- 工具:
- Valgrind (memcheck):Linux下最经典的工具。
valgrind --leak-check=full ./your_program。 - AddressSanitizer (ASan):GCC/Clang编译选项,速度快,开销低。
-fsanitize=address -g。 - 智能指针:从代码设计上避免泄漏。
- Valgrind (memcheck):Linux下最经典的工具。
- 步骤:使用工具运行程序,工具会报告泄漏内存的分配位置(调用栈)。
38. 内存泄漏的影响?
- 短期影响:进程的虚拟内存占用(RSS)持续上升。
- 长期影响:
- 耗尽可用内存,导致系统变慢,交换(swapping)加剧。
- 可能引发
std::bad_alloc异常,导致程序崩溃。 - 对于长时间运行的服务(如服务器、后台进程),即使是缓慢的泄漏,最终也必然导致程序或系统崩溃。
小米面试题
1. std::vector的扩容机制是什么?扩容时代价多大?如何避免频繁扩容?
扩容机制:
当std::vector的size()即将超过当前capacity()时,会发生扩容。其机制是:
- 分配一块新的、更大的内存空间(通常是当前容量的 2倍 或 1.5倍,取决于标准库实现,MSVC是1.5倍,GCC是2倍)。
- 将原有内存中的所有元素移动或拷贝到新的内存空间中。
- 释放原有的内存空间。
- 更新内部的指针和容量标记。
扩容代价:
代价非常大,主要体现在:
- 时间代价: O(N)。需要将旧元素全部复制/移动到新空间。这会使当前进行
push_back等操作的摊销时间复杂度变高。 - 空间代价: 需要一块连续的、更大的内存。这可能导致内存碎片。
- 迭代器失效: 所有指向原vector的迭代器、指针、引用都会立即失效。
如何避免频繁扩容:
- 使用
reserve()预分配: 如果事先知道元素的大致数量,可以使用vec.reserve(n)预先分配足够的内存,这样在添加前n个元素时就可以完全避免扩容。 - 使用初始化构造函数:
std::vector<int> vec(1000);直接创建一个包含1000个默认初始化元素的vector,其容量至少为1000。
2. C++中malloc和new的区别?delete和delete[]能混用吗?
| 特性 | malloc / free |
new / delete |
|---|---|---|
| 语言 | C库函数 | C++运算符 |
| 内存来源 | 堆(Heap) | 自由存储区(Free Store),通常也是堆 |
| 返回值 | void*(需要强制转换) |
正确类型的指针 |
| 参数 | 所需内存的字节数 | 类型(编译器自动计算大小) |
| 初始化 | 不调用构造函数,内存内容是未初始化的 | 会调用构造函数,完成对象初始化 |
| 失败行为 | 返回NULL |
抛出std::bad_alloc异常(除非用nothrow) |
| 分配大小 | 返回分配的确切字节数 | 取决于编译器实现,可能包含管理信息 |
| 重载 | 不可重载 | 可重载(类成员重载和全局重载) |
| 析构 | free不调用析构函数 |
delete会调用析构函数 |
delete和delete[]能混用吗?
绝对不能混用!
delete用于释放new分配的单个对象。delete[]用于释放new[]分配的对象数组。- 混用会导致未定义行为(Undefined Behavior),最常见的后果是:
- 对于有析构函数的类,
delete数组会导致只调用第一个元素的析构函数,后续元素的析构函数不会被调用,导致资源泄漏。 - 破坏内存管理结构,导致程序崩溃。
- 对于有析构函数的类,
3. 如果类中有const成员或引用成员,能否自动生成移动构造函数?为什么?
不能。
原因:const成员和引用成员在初始化后,其绑定的值或对象就不可更改。
移动语义的本质是“资源偷取”,它需要将源对象的资源“转移”到目标对象,然后将源对象置于一个“有效但未定义”的状态(通常设为nullptr或0)。
然而,对于const成员和引用成员,编译器无法生成代码来修改它们(因为它们是只读的),所以无法实现“资源偷取”这一核心操作。
因此,编译器不会为含有const成员或引用成员的类自动生成默认的移动构造函数和移动赋值运算符。如果你需要移动操作,必须手动定义它们。
4. 什么是内存对齐?为什么需要内存对齐?如何手动控制结构体的内存对齐?
什么是内存对齐:
数据在内存中的起始地址必须是某个值(通常是其自身大小或平台字长)的整数倍。
为什么需要:
- 性能原因: 现代CPU并非以字节为单位读写内存,而是以“块”(如64字节缓存行)为单位。如果数据未对齐,一个数据可能横跨两个内存块,需要两次内存访问才能读完,效率低下。
- 硬件原因: 某些架构(如ARM, SPARC)的CPU根本无法访问未对齐的内存地址,会直接抛出硬件异常。
如何手动控制:
- C++11标准方式: 使用
alignas说明符。1
2
3
4struct alignas(16) MyStruct { // 强制该结构体按16字节对齐
int a;
char b;
}; - 编译器扩展(常用): 使用
#pragma pack1
2
3
4
5
6
7
struct MyStruct {
int a; // 地址偏移 0
char b; // 地址偏移 4
// 总共大小是5,没有填充字节
};
5. 进程和线程的区别?进程间通信有哪些方式?哪种效率最高?
| 特性 | 进程 (Process) | 线程 (Thread) |
|---|---|---|
| 资源拥有 | 是资源分配的基本单位,拥有独立的地址空间、文件描述符等 | 是CPU调度的基本单位,共享进程的资源 |
| 切换代价 | 高(需要切换页表、刷新TLB等) | 低(只需切换栈和寄存器等少量上下文) |
| 独立性 | 一个进程崩溃不会影响其他进程 | 一个线程崩溃会导致整个进程崩溃 |
| 通信机制 | 复杂(需要IPC机制) | 简单(可直接读写共享的进程内存) |
进程间通信(IPC)方式:
- 管道 (Pipe) / 命名管道 (FIFO):单向字节流,适用于父子进程或有亲缘关系的进程。
- 消息队列 (Message Queue):消息的链表,允许非亲缘进程通信。
- 共享内存 (Shared Memory):效率最高。将同一块物理内存映射到不同进程的虚拟地址空间,进程可以直接读写该内存,无需内核拷贝。需要配合信号量或互斥锁等同步机制使用。
- 信号量 (Semaphore):主要用于同步,而不是传递数据。
- 信号 (Signal):一种异步通知机制。
- 套接字 (Socket):最通用的IPC,可以跨网络通信。
效率最高: 共享内存。因为它避免了数据在用户态和内核态之间的拷贝。
6. 线程同步有哪些机制?std::mutex、std::lock_guard、std::unique_lock的区别?
线程同步机制:
互斥锁 (Mutex)、条件变量 (Condition Variable)、信号量 (Semaphore)、读写锁 (Read-Write Lock)、自旋锁 (Spinlock)、屏障 (Barrier)、原子操作 (Atomic Operations) 等。
C++中三者的区别:
std::mutex:是基础锁对象,提供lock(),unlock(),try_lock()等基本操作。需要程序员手动调用unlock()释放锁,否则会导致死锁。std::lock_guard:是RAII包装器。在构造时自动锁定mutex,在析构时自动解锁。它非常轻量,但不能手动控制加解锁时机,作用域结束时必然解锁。std::unique_lock:也是RAII包装器,但比lock_guard更灵活。它允许:- 延迟锁定(
defer_lock)。 - 手动调用
lock()和unlock()。 - 条件变量必须配合
unique_lock使用。 - 所有权可以移动(
move)。 - 性能上比
lock_guard有微小开销。
- 延迟锁定(
总结: 优先使用lock_guard,在需要更灵活控制的场景(如条件变量)使用unique_lock。
7. 什么是虚假共享(False Sharing)?如何避免?
什么是虚假共享:
现代CPU为每个核心都有独占的高速缓存(L1/L2 Cache),缓存与内存交换数据的基本单位是缓存行(Cache Line,通常为64字节)。
如果两个无关的变量A和B恰好位于同一个缓存行上, Core1 频繁修改A,Core2 频繁读取B。那么每次Core1修改A时,都会导致Core2的整个缓存行失效,需要重新从内存加载,即使B的值实际上并没有变化。这种因为缓存行共享而导致的不必要的缓存失效和同步,就是虚假共享。它会严重损害多线程程序的性能。
如何避免:
- 缓存行对齐: 让每个频繁被独立线程访问的变量独占一个缓存行。
1
2
3
4
5struct alignas(64) MyData { // 64字节对齐
int counter1;
// 这里会有大约60字节的填充(padding)
};
MyData data[2]; // data[0]和data[1]必然在不同的缓存行 - 使用线程本地存储(TLS): 如果可能,将数据声明为
thread_local,从根本上避免共享。 - 重新设计数据结构: 将可能被不同线程频繁修改的成员变量分开存放。
8. 手撕:实现一个线程安全的环形队列(支持多生产者多消费者)
1 |
|
要点:
- **使用
count_**来判断队列空和满,这是最清晰的方式。 - 使用互斥锁 (
mutex_) 保护共享数据 (head_,tail_,count_,buffer_)。 - 使用两个条件变量 (
not_empty_,not_full_) 进行线程间通知,避免忙等待。 notify_one()用于唤醒一个等待线程,适用于多生产者多消费者场景。
9. 如何使用Valgrind或ASAN排查内存泄漏和越界问题?你在项目中用过吗?
Valgrind (Memcheck工具):
- 编译: 使用
-g选项编译程序,包含调试信息。 - 运行:
valgrind --leak-check=full --show-leak-kinds=all ./your_program arg1 arg2 - 查看输出: Valgrind会详细报告:
- 内存泄漏: 在哪个位置分配的内存没有被释放。
- 越界读写: 对非法内存地址的访问。
- 使用未初始化值: 使用了未初始化的变量。
- 重复释放: 对同一块内存释放了两次。
ASAN (AddressSanitizer,编译时插桩):
- 编译和链接: 在gcc/clang中添加
-fsanitize=address -g选项。 - 运行: 直接运行程序
./your_program。 - 查看输出: 程序崩溃或退出时,ASAN会在控制台输出非常清晰的错误报告,包括错误类型(堆溢出、栈溢出、释放后使用等)、调用栈、内存映射情况。
使用经验:
这是一个展示你工程经验的好机会。可以回答:“是的,我在项目中经常使用。ASAN因为性能开销相对较小(约2倍),通常集成在CI/CD的Debug构建中,用于日常开发阶段的检查。而Valgrind更加强大和全面,在遇到一些ASAN难以定位的复杂内存问题时,会使用Valgrind进行更深层次的分析。”
10. 如何用GDB调试死锁?thread apply all bt 这个命令有什么用?
调试死锁步骤:
- 运行程序,当发生死锁(卡住)时,用
Ctrl+C(SIGINT信号)中断程序。 - 在GDB中,使用
thread apply all bt命令。thread apply all: 表示将后续命令应用于所有线程。bt(backtrace): 打印线程的调用栈。
- 查看每个线程的调用栈,重点看每个线程当前正停在哪个函数、哪一行代码,持有了哪个锁。通常你会发现两个或多个线程的调用栈显示它们正在互相等待对方持有的锁,从而定位死锁位置。
thread apply all bt 的作用:
一次性打印出程序中所有线程的调用栈信息。 这是调试多线程程序(如死锁、卡顿)最核心、最常用的命令。
11. 什么是虚函数表?多重继承下的虚函数表结构是怎样的?
虚函数表 (vtable):
是一个编译期为每个包含虚函数的类(或该类的子类)自动创建的静态函数指针数组。每个数组元素指向一个虚函数的实际实现。
- 每个包含虚函数的类对象在其内存布局的最开头(通常如此)会有一个隐藏的
vptr(虚函数表指针),指向该类的虚函数表。 - 调用虚函数时,运行时会通过对象的
vptr找到vtable,再根据函数在表中的偏移量找到正确的函数地址进行调用。
多重继承下的虚函数表:
会更加复杂。派生类会包含多个vptr,每个vptr指向一个对应基类的虚函数表。
- 第一个基类的虚函数表与单继承时类似。
- 后续基类的虚函数表会前置一个偏移量(offset),用于
this指针调整。因为当使用第二个基类指针指向派生类对象时,this指针需要偏移到对象中该基类子对象的位置。 - 派生类重写的函数会覆盖所有相关基类虚函数表中的对应项。
- 派生类新增的虚函数一般会放在第一个基类的虚函数表的末尾。
12. Epoll的水平触发和边缘触发有什么区别?使用场景是什么?
| 模式 | 水平触发 (LT - Level-Triggered) | 边缘触发 (ET - Edge-Triggered) |
|---|---|---|
| 工作方式 | 只要文件描述符处于就绪状态(读缓冲区非空/写缓冲区未满),就会持续通知。 | 只在文件描述符状态发生变化时(如从不可读变为可读)通知一次。 |
| 编程复杂性 | 简单。即使一次没有处理完所有数据,下次调用epoll_wait还会再次通知。 |
复杂。收到通知后,必须循环读写直到返回EAGAIN或EWOULDBLOCK错误,确保把所有数据都处理完,否则会因为再无事件通知而丢失数据。 |
| 性能 | 理论上可能通知次数更多。 | 通知次数更少,效率更高。 |
| 默认行为 | epoll的默认模式。 |
需要设置 EPOLLET 标志。 |
使用场景:
- LT模式: 编程更简单,不易出错,是默认的选择。适用于大多数场景。
- ET模式: 效率更高,可以减少
epoll_wait的系统调用次数。但要求应用程序必须使用非阻塞I/O,并且一次事件触发后必须彻底处理完所有数据。常用于需要极高性能的网络服务器(如Nginx)。
13. 讲一下TCP拥塞控制机制?TIME_WAIT状态的作用是什么
TCP拥塞控制机制:
目的是避免网络因为负载过重而出现“拥塞崩溃”。主要包括四个核心算法:
- 慢启动 (Slow Start): 连接开始时,拥塞窗口 (
cwnd) 从1个MSS开始,每收到一个ACK,cwnd就翻倍(指数增长)。直到超过慢启动阈值 (ssthresh) 或发生丢包。 - 拥塞避免 (Congestion Avoidance): 当
cwnd>=ssthresh时,进入拥塞避免阶段,每RTT时间cwnd只加1(线性增长),谨慎探询更多带宽。 - 快速重传 (Fast Retransmit): 收到3个重复的ACK时,推断数据包丢失,立即重传丢失的报文,而不必等待超时。
- 快速恢复 (Fast Recovery): 在快速重传后,将
ssthresh设置为当前cwnd的一半,并将cwnd设置为新的ssthresh(或略大),然后直接进入拥塞避免阶段(而非慢启动)。这是对“ Tahoe”算法的改进(“Reno”算法)。
TIME_WAIT状态的作用:
TCP连接主动关闭的一方(先发送FIN的一方)会进入TIME_WAIT状态,持续时间为 2MSL(Maximum Segment Lifetime,报文最大生存时间)。
其两个核心作用:
- 可靠地终止连接: 确保最后一个ACK(对对方FIN的确认)能够被对方收到。如果这个ACK丢失,对方会超时重传FIN,处于
TIME_WAIT状态的一方可以重新回应一个ACK。 - 让旧的重复报文失效: 等待足够长的时间(2MSL),使得本次连接产生的所有报文都在网络中消失,这样下一个相同四元组(源IP、源端口、目的IP、目的端口)的新连接就不会收到属于旧连接的、延迟到达的报文,从而避免数据错乱。
影石二面
好的,这是一组非常深入和经典的C++面试题。我将为您逐一进行详细解答。
1. 哈希表的冲突怎么解决?知道负载因子吗?如果往哈希表大量插入数据会怎么办?
哈希冲突的解决方法:
链地址法 (Separate Chaining):
- 原理: 哈希表的每个桶(bucket)不再直接存储数据,而是存储一个链表的头指针。所有哈希到同一位置的元素都会被放入这个链表中。
- 优点: 实现简单,有效处理冲突,负载因子可以大于1。
- 缺点: 需要额外的指针空间。如果链表过长,查找性能会退化为O(n)。
- 优化: 当链表长度超过一定阈值时,Java的HashMap会将其转换为红黑树,以保证最坏情况下的查找效率。
开放定址法 (Open Addressing):
- 原理: 所有元素都存放在哈希表数组本身中。当发生冲突时,按照某种探测方法(Probing Sequence)在数组中寻找下一个空槽。
- 常见探测方法:
- 线性探测 (Linear Probing):
h(k, i) = (h'(k) + i) % m - 二次探测 (Quadratic Probing):
h(k, i) = (h'(k) + c1*i + c2*i²) % m - 双重散列 (Double Hashing):
h(k, i) = (h1(k) + i * h2(k)) % m
- 线性探测 (Linear Probing):
- 优点: 不需要额外的链表结构,所有数据都在一个数组中,缓存友好( locality)。
- 缺点: 删除操作复杂(需要特殊标记,如“墓碑”标记),负载因子必须小于1,且接近1时性能下降非常快。
负载因子 (Load Factor):
负载因子 λ = 哈希表中已存储的元素个数 / 哈希表的桶总数。
它是一个衡量哈希表空间使用程度的指标。负载因子越高,发生冲突的概率就越大,哈希表的性能就越低。
大量插入数据会发生什么?
当持续插入数据,负载因子会不断增大。为了将负载因子维持在一个合理的范围内(例如0.75),哈希表会进行扩容 (Rehashing):
- 创建一个新的、更大的桶数组(通常是原大小的2倍左右,并取一个质数大小以减少哈希冲突)。
- 遍历旧哈希表中的每一个元素,根据新的数组大小重新计算其哈希值,并将其插入到新数组的对应位置。
- 释放旧数组。
扩容的代价非常高,时间复杂度是O(n)。 这也是为什么要求哈希函数计算要快的原因之一。
2. 红黑树的特性?为什么不用二叉平衡树?
红黑树 (Red-Black Tree) 的五条特性:
- 每个节点要么是红色,要么是黑色。
- 根节点是黑色的。
- 所有叶子节点(NIL节点,空节点)都是黑色的。
- 如果一个节点是红色的,则它的两个子节点都是黑色的。(即不存在两个相邻的红色节点)
- 从任意一个节点到其所有后代叶子节点的简单路径上,均包含相同数目的黑色节点。(称为“黑色高度”相等)
为什么是红黑树而不是严格的AVL树?
AVL树是高度平衡的二叉搜索树,其左右子树高度差严格不超过1。查找性能略优于红黑树(O(log n) vs O(log n),常数因子更小)。
然而,红黑树在实际应用(如STL的std::map, Linux内核调度)中更受欢迎,原因在于:
| 方面 | AVL树 | 红黑树 |
|---|---|---|
| 平衡度 | 严格平衡 | 近似平衡(最长路径不超过最短路径的2倍) |
| 查找性能 | 更优(O(log n),常数小) | 优(O(log n)) |
| 插入/删除性能 | 较差。需要频繁的旋转操作来维持严格平衡。 | 更优。插入和删除通常需要更少的旋转(最多3次)来恢复平衡。 |
| 适用场景 | 适合查询多,插入删除很少的场景(如数据库索引)。 | 适合插入、删除、查询操作混合的场景,综合性能更好。这是STL选择它的主要原因。 |
总结: 红黑树在插入和删除操作上提供了更好的性能,虽然查找稍慢,但总体复杂度仍是O(log n),在现代计算机上这点差异往往可以忽略。它在保证高效查询的同时,大幅提升了数据修改的效率,取得了更好的综合性能权衡。
3. deque的底层实现?插入和修改的复杂度?双端的插入和删除是怎么实现的?
底层实现:std::deque(双端队列)通常不是简单的动态数组,而是一种“分段连续”的数据结构。它由多个固定大小的数组(称为块或缓冲区)和一個中央映射结构(map,一个指针数组)组成。map中的每个指针指向一个块。deque的迭代器非常复杂,需要包含当前元素指针、当前块首尾指针以及指向map的指针。
复杂度:
- 随机访问 (operator[]): O(1)。因为需要先通过
map找到对应的块,再在块内进行索引,但仍然是常数时间。 - 在首尾插入或删除 (push/pop_front/back): O(1)。这是
deque的核心优势。 - 在中间插入或删除 (insert/erase): O(n)。因为需要移动元素。
双端操作的实现原理:
push_back: 检查最后一个块是否还有空间。如果有,直接放入;如果没有,就分配一个新的块,并将其地址添加到map的末尾,然后放入新元素。push_front: 检查第一个块是否还有空间。如果有,直接放入;如果没有,就分配一个新的块,并将其地址添加到map的开头(如果map前面没空间了,可能需要重新分配更大的map),然后放入新元素。
正是因为这种“块”式的设计,使得在deque的首尾添加元素通常只是在一个固定大小的块内操作,效率非常高,而不需要像vector那样大规模地移动所有元素。
4. 迭代器失效的状态或者原因有哪些?
迭代器失效指的是迭代器指向的元素已经不再有效(例如被删除),或者其含义发生了变化(例如在vector中插入元素后,后面的元素移动了位置)。
| 容器 | 导致失效的操作 | 原因 |
|---|---|---|
std::vector |
insert, erase, push_back, pop_back, 任何导致扩容的操作(如reserve) |
插入/删除导致元素移动;扩容导致整个底层数组地址改变。 |
std::deque |
insert, erase(在中间), push/pop_front/back(通常不使所有迭代器失效,但会使个别迭代器失效) |
复杂的内部结构。在首尾插入可能只影响个别块,但在中间插入会导致大量元素移动。 |
std::list |
erase |
只有被删除的那个元素的迭代器会失效。insert不会使任何迭代器失效。 |
std::map/set |
erase |
只有被删除的那个元素的迭代器会失效。insert不会使任何迭代器失效。 |
std::unordered_map/set |
insert(可能导致rehash), erase |
Rehash会使所有迭代器失效。erase只使被删除元素的迭代器失效。 |
黄金法则: 在修改容器后,不要使用修改前获取的迭代器,除非你明确知道该操作不会使迭代器失效。
5. 编译器会给一个类默认生成哪些函数?自定义有参构造函数后,若未加 =delete,编译器仍会生成默认构造函数吗?
编译器默认生成的函数(如果代码中没有显式定义):
- 默认构造函数
- 析构函数
- 拷贝构造函数
- 拷贝赋值运算符 (
operator=)
从C++11开始,如果未显式定义,编译器还会尝试生成:
5. 移动构造函数
6. 移动赋值运算符
自定义有参构造函数后的变化:
一旦你定义了任何构造函数(包括有参构造函数),编译器就不会再自动生成默认的无参构造函数。
除非你使用 = default explicitly要求编译器生成:
1 | class MyClass { |
或者,除非你定义的构造函数的所有参数都有默认值,从而使其成为一个默认构造函数:
1 | class MyClass { |
6. 讲一讲virtual关键字?虚函数的实现机制?
virtual关键字的作用:
- 实现多态 (Polymorphism): 用于声明虚函数。允许通过基类的指针或引用来调用派生类中重写的函数版本。
- 用于析构函数: 将基类的析构函数声明为虚函数,是确保通过基类指针删除派生类对象时,能正确调用整个析构链(先派生析构,再基类析构)的关键。如果一个类可能被继承,它的析构函数几乎总是应该声明为
virtual。
虚函数的实现机制:
基于 虚函数表 (vtable) 和 虚函数表指针 (vptr)。
- vtable: 编译器为每一个包含虚函数的类(或从它派生的类)创建一个虚函数表。这是一个静态数组,存储着该类所有虚函数的函数指针。
- vptr: 编译器在包含虚函数的类的每个对象实例中,隐式地添加一个指针成员(
vptr),它指向该类的虚函数表。 - 调用过程: 当通过基类指针调用虚函数
p->foo()时:- 程序通过
p找到对象的vptr。 - 通过
vptr找到类的vtable。 - 在
vtable中找到foo函数对应的槽位(偏移量在编译期确定)。 - 调用该槽位中存储的函数地址。
- 程序通过
这个过程发生在运行时,因此称为动态绑定或晚期绑定。
7. 死锁产生的原因以及解决方法?
死锁产生的四个必要条件(Coffman条件):
- 互斥 (Mutual Exclusion): 一个资源每次只能被一个进程使用。
- 占有并等待 (Hold and Wait): 一个进程因请求资源而阻塞时,对已获得的资源保持不放。
- 不可剥夺 (No Preemption): 进程已获得的资源,在未使用完之前,不能强行剥夺。
- 循环等待 (Circular Wait): 若干进程之间形成一种头尾相接的循环等待资源关系。
解决方法:
- 预防 (Prevention): 破坏死锁的四个必要条件之一。
- 破坏“占有并等待”:进程在开始运行前,必须一次性申请所有所需资源。
- 破坏“不可剥夺”:允许系统剥夺进程已占有的资源。
- 破坏“循环等待”:给资源统一编号,进程必须按编号顺序申请资源。
- 避免 (Avoidance): 在资源分配前,判断这次分配是否会导致系统进入不安全状态。经典算法是银行家算法。
- 检测与恢复 (Detection & Recovery): 允许死锁发生,然后定期检测死锁并解除它(如剥夺资源、回滚进程、杀死进程)。
- 鸵鸟策略 (Ostrich Algorithm): 忽略死锁问题,假设它不会发生。
编程中的最佳实践:
- 按固定顺序上锁: 所有线程都以相同的顺序获取锁,可以破坏“循环等待”条件。
- 使用RAII管理锁: 使用
std::lock_guard或std::unique_lock,确保锁在作用域结束时一定会被释放。 - 尝试上锁: 使用
std::try_lock或std::timed_mutex,如果获取不到锁就放弃已有的锁,过一会再试。
8. 一个程序本来只要运行1s,现在运行了1min该怎么排查?
这是一个典型的性能问题排查场景,需要系统性地分析。
** profiling (性能剖析):**
- CPU Profiler: 使用
gprof、perf(Linux)、VTune(Intel)等工具,找出程序在哪些函数上花费了最多的时间。这是最直接有效的方法。 关注“热点”函数。 - 内存 Profiler: 使用
valgrind --tool=massif或heaptrack,检查是否有频繁的内存分配/释放(可能导致碎片)或不必要的拷贝。
- CPU Profiler: 使用
检查算法复杂度: 是否引入了时间复杂度更高的新算法?数据规模是否急剧增大,导致O(n²)的算法无法承受?
检查I/O:
- 是否在循环中进行了重复的、昂贵的文件或数据库操作?
- 是否在等待网络响应?使用
strace或tcpdump查看系统调用和网络活动。
检查锁竞争: 如果是多线程程序,使用
perf锁分析或helgrind检查是否存在激烈的锁竞争,导致线程大部分时间在等待。检查外部资源: 程序依赖的数据库、网络服务、API接口是否变慢了?
对比法: 使用
git bisect等工具,定位是哪个代码提交引入了性能衰退。
9. 类的全局静态实例什么时候初始化的?比如static A a;
初始化时机:
全局静态对象(包括类的实例)的初始化发生在main函数执行之前。
具体过程:
在程序启动时,会有一段特殊的启动代码(crt0.o或类似的库负责),它主要做两件事:
- 初始化静态存储区的数据:
- 将
.bss段(未初始化数据)清零。 - 将
.data段(已初始化数据)从编译后的镜像中拷贝到内存。 - 调用全局对象和静态对象的构造函数(对于C++)。
- 将
- 调用
main()函数。
注意: 不同编译单元(.cpp文件)中的全局静态对象的初始化顺序是未定义的。如果一个全局对象a(在a.cpp中)的构造函数依赖于另一个全局对象b(在b.cpp中)已经被构造,这会导致难以发现的错误。这就是著名的静态初始化顺序问题。
解决方法: 使用“构造 On First Use”惯用法,将全局对象用函数包裹起来:
1 | // 代替 `static A a;` |
10. 最大连续子数组的和,空间复杂度从O(n)优化到O(1),时间复杂度从O(n)到O(n/2)
经典算法:Kadane’s Algorithm (贪心)
- 时间复杂度: O(n)
- 空间复杂度: O(1)
- 思想: 遍历数组,维护一个“当前子数组和” (
current_sum)。如果current_sum加上当前元素后比当前元素本身还小,说明之前的子数组是负收益,不如从当前元素重新开始。同时,用一个变量记录遍历过程中出现的最大值 (max_sum)。
1 | int maxSubArray(vector<int>& nums) { |
关于“时间复杂度从O(n)到O(n/2)”:
这个说法不太常见。Kadane算法已经是理论上的最优解(O(n))。O(n/2)本质上还是O(n),只是常数因子减半,在实际应用中意义不大,而且通常需要更复杂的逻辑(例如同时从数组头尾开始扫描),代码可读性会变差。在面试中,给出标准的Kadane算法并解释清楚其思想就是最佳答案。
如果非要追求理论上的“一半”循环,可以写一个同时从两头向中间遍历的版本,但最坏情况依然是O(n),且代码复杂,容易出错,并不实用。








