面试真题 | 字节推荐架构C++
以下是整理后的C++面试问题列表,按照面试流程和时间顺序排列:
一面
开局自我介绍
1. 教育背景
- 询问本科背景。
- 询问研究生研究方向。
2. 简历项目
- 请介绍简历中的项目经历。
3. 智能指针
- 解释智能指针的概念和作用。
智能指针的概念与作用
1. 概念
智能指针是 C++ 中一种封装原生指针的类模板,通过 RAII(资源获取即初始化) 机制,实现对动态分配内存的自动管理。其核心设计思想是:
• 对象生命周期绑定:智能指针对象的析构函数会自动释放所管理的内存,无需手动 delete
。
• 所有权语义:通过类型系统(如独占或共享)明确资源的所有权归属,避免内存泄漏或非法访问。
常见智能指针类型包括:
• std::unique_ptr
:独占所有权,同一时间仅允许一个指针管理资源。
• std::shared_ptr
:共享所有权,通过引用计数管理资源生命周期。
• std::weak_ptr
:弱引用,配合 shared_ptr
解决循环引用问题,不增加引用计数。
2. 核心作用
(1) 自动内存管理,避免内存泄漏
• 资源自动释放:当智能指针对象超出作用域时,析构函数自动调用 delete
或自定义删除器释放资源。
• 异常安全性:即使程序因异常中断,智能指针仍能确保资源释放,避免传统指针因代码分支遗漏释放的问题。
(2) 防止悬空指针(Dangling Pointer)
• 自动置空机制:部分智能指针(如 shared_ptr
)在引用计数归零后,自动将内部指针置为 nullptr
,避免访问已释放的内存区域。
(3) 管理共享资源的所有权
• 共享所有权(shared_ptr
):通过引用计数机制,允许多个指针共享同一资源,计数归零时自动释放。
• 独占所有权(unique_ptr
):确保资源仅被单一所有者控制,适用于文件句柄等需严格独占的场景。
(4) 解决循环引用问题
• 弱引用(weak_ptr
):打破 shared_ptr
的循环依赖(如两个对象互相持有对方的 shared_ptr
),防止引用计数永不为零导致的内存泄漏。
class B;
class A {
public:
std::shared_ptr<B> b_ptr;
};
class B {
public:
std::weak_ptr<A> a_ptr; // 使用 weak_ptr 替代 shared_ptr
};
(5) 简化代码并提升可维护性
• 隐藏手动管理细节:减少 new
/delete
的显式调用,降低代码冗余和出错概率。
• 明确资源所有权:通过类型名称(如 unique_ptr
)直观表达设计意图,增强代码可读性。
3. 技术实现与扩展
• 引用计数:shared_ptr
内部维护引用计数器,拷贝时递增,析构时递减,归零时释放资源。
• 移动语义:unique_ptr
支持移动构造/赋值,实现资源所有权的安全转移。
• 自定义删除器:允许指定资源释放方式(如 fclose
关闭文件句柄),适配非 new
分配的资源。
总结
智能指针通过封装原生指针并引入所有权模型,解决了 C++ 手动内存管理的核心痛点(泄漏、悬空指针、所有权混乱)。合理选择 unique_ptr
、shared_ptr
或 weak_ptr
,可显著提升代码的安全性与可维护性。
4. 手写shared_ptr
- 要求手写一个
shared_ptr
的简单实现。
手写简化版 shared_ptr
实现
以下是基于 C++11 标准的 shared_ptr
核心实现代码,聚焦引用计数机制和资源管理逻辑,省略线程安全等复杂扩展功能。实现包含构造函数、拷贝控制、运算符重载和基础接口,完整支持共享所有权语义。
1. 核心组件与类定义
#include <iostream>
#include <utility> // for std::swap
// 引用计数管理类
class RefCount {
private:
int count = 1; // 初始引用计数为1
public:
void increment() noexcept { ++count; }
int decrement() noexcept { return --count; }
int get() const noexcept { return count; }
};
// shared_ptr 类模板
template <typename T>
class SharedPtr {
private:
T* ptr = nullptr; // 托管对象的原始指针
RefCount* ref = nullptr; // 引用计数对象指针
public:
// 默认构造函数(托管空指针)
SharedPtr() noexcept = default;
// 显式构造函数(接管原始指针)
explicit SharedPtr(T* raw_ptr) noexcept : ptr(raw_ptr), ref(new RefCount()) {}
// 拷贝构造函数(共享所有权)
SharedPtr(const SharedPtr& other) noexcept : ptr(other.ptr), ref(other.ref) {
if (ref) ref->increment();
}
// 移动构造函数(转移所有权)
SharedPtr(SharedPtr&& other) noexcept : ptr(other.ptr), ref(other.ref) {
other.ptr = nullptr;
other.ref = nullptr;
}
// 析构函数(释放资源)
~SharedPtr() noexcept {
if (ref && ref->decrement() == 0) {
delete ptr;
delete ref;
}
}
// 拷贝赋值运算符
SharedPtr& operator=(const SharedPtr& rhs) noexcept {
if (this != &rhs) {
// 释放当前资源
this->~SharedPtr();
// 接管新资源
ptr = rhs.ptr;
ref = rhs.ref;
if (ref) ref->increment();
}
return *this;
}
// 移动赋值运算符
SharedPtr& operator=(SharedPtr&& rhs) noexcept {
if (this != &rhs) {
// 释放当前资源
this->~SharedPtr();
// 转移新资源
ptr = rhs.ptr;
ref = rhs.ref;
rhs.ptr = nullptr;
rhs.ref = nullptr;
}
return *this;
}
// 解引用运算符
T& operator*() const noexcept { return *ptr; }
// 成员访问运算符
T* operator->() const noexcept { return ptr; }
// 获取原始指针
T* get() const noexcept { return ptr; }
// 获取引用计数
int use_count() const noexcept { return ref ? ref->get() : 0; }
// 重置指针(释放当前资源)
void reset(T* raw_ptr = nullptr) noexcept {
SharedPtr temp(raw_ptr);
swap(temp);
}
// 交换两个 SharedPtr 的内容
void swap(SharedPtr& other) noexcept {
std::swap(ptr, other.ptr);
std::swap(ref, other.ref);
}
};
2. 核心机制解析
-
引用计数管理
• 每个SharedPtr
对象共享一个RefCount
实例。 • 构造函数初始化RefCount
对象,拷贝构造时递增计数,析构时递减计数。当计数归零时,释放托管对象和RefCount
对象。 -
资源所有权转移
• 移动构造函数和移动赋值运算符直接接管资源,原对象置空,避免引用计数错误更新。 -
运算符重载
•operator*
和operator->
提供与原生指针一致的访问语义。 •reset()
方法通过临时对象交换实现资源释放和接管,避免中间状态风险。
3. 测试用例
class TestObject {
public:
TestObject() { std::cout << "TestObject constructed\n"; }
~TestObject() { std::cout << "TestObject destroyed\n"; }
};
int main() {
// 场景1:基本生命周期管理
{
SharedPtr<TestObject> p1(new TestObject); // 构造
SharedPtr<TestObject> p2 = p1; // 拷贝构造(计数=2)
std::cout << "Use count: " << p2.use_count() << "\n"; // 输出2
} // p1和p2析构,计数归零,对象销毁
// 场景2:移动语义
SharedPtr<TestObject> p3(new TestObject);
SharedPtr<TestObject> p4 = std::move(p3); // 移动构造,p3置空
std::cout << "p3 is null? " << (p3.get() == nullptr) << "\n"; // 输出1
// 场景3:reset 方法
p4.reset(new TestObject); // 原对象销毁,托管新对象
return 0;
}
输出结果:
TestObject constructed
Use count: 2
TestObject destroyed
TestObject constructed
p3 is null? 1
TestObject destroyed
TestObject constructed
TestObject destroyed
4. 扩展优化方向
-
线程安全
在RefCount
中使用原子操作(如std::atomic<int>
)实现线程安全的引用计数更新。 -
自定义删除器
添加模板参数Deleter
,允许通过std::function
或函数指针自定义资源释放逻辑。 -
循环引用处理
引入WeakPtr
类,通过弱引用计数避免SharedPtr
循环引用导致的内存泄漏。
总结
此实现完整覆盖了 shared_ptr
的核心功能,通过引用计数机制实现了动态资源的共享所有权管理。重点需关注拷贝/移动语义的正确性及引用计数的线程安全扩展。实际应用中建议直接使用标准库 std::shared_ptr
,但其底层原理与此简化版高度一致。
5. TCP与UDP的区别
- 描述TCP(传输控制协议)和UDP(用户数据报协议)的主要区别。
TCP与UDP的核心区别
1. 连接机制
• TCP(传输控制协议):面向连接,需通过“三次握手”建立稳定连接(类似打电话前拨号),确保通信双方准备好数据传输。传输结束后通过“四次挥手”断开连接。
• UDP(用户数据报协议):无连接,直接发送数据包(类似发短信),无需预先建立通道,也无需断开连接。
2. 可靠性
• TCP:可靠传输,通过确认应答(ACK)、超时重传、数据排序、流量控制、拥塞控制等机制,保证数据无差错、不丢失、按序到达。
• UDP:不可靠传输,无确认、重传或排序机制,数据可能丢失、重复或乱序(如视频丢帧或语音断续)。
3. 传输效率与速度
• TCP:效率较低,因需维护连接状态、处理重传和流量控制,额外开销大(首部20~60字节)。适用于对延迟不敏感的场景(如文件传输、邮件)。
• UDP:效率高,无连接和复杂控制机制,首部仅8字节,传输速度快、延迟低。适合实时性要求高的场景(如直播、在线游戏、语音通话)。
4. 流量控制与拥塞控制
• TCP:内置动态调整机制,根据网络状况(如带宽、丢包率)调节发送速率,避免网络过载。例如,慢启动、拥塞避免算法。
• UDP:无流量或拥塞控制,以恒定速率发送数据,可能加剧网络拥堵。
5. 数据结构和应用场景
• TCP:面向字节流,数据按顺序分割为字节流传输,保证完整性。典型应用:
• 高可靠性需求:网页浏览(HTTP/HTTPS)、文件传输(FTP)、数据库通信。
• UDP:面向数据报,每个数据包独立处理,支持广播和多播。典型应用:
• 实时性优先:视频会议(Zoom)、在线游戏(王者荣耀)、DNS查询。
• 容忍部分数据丢失:物联网传感器数据上报。
总结对比表
特性TCPUDP连接方式 | 面向连接(三次握手) | 无连接 |
可靠性 | 高(确认、重传、排序) | 低(无保障) |
传输效率 | 慢(额外控制开销) | 快(无控制机制) |
流量控制 | 有(动态调整速率) | 无 |
数据顺序 | 保证按序到达 | 可能乱序 |
首部开销 | 20~60字节 | 8字节 |
适用场景 | 文件传输、邮件、网页 | 直播、游戏、实时通信 |
实际案例说明
• 视频网站(如B站):视频流用UDP传输(容忍丢帧),但暂停、快进等操作切换为TCP以确保请求准确。
• 银行转账:必须使用TCP保证交易数据完整,而语音通话(如微信)用UDP降低延迟。
• QQ消息:默认UDP发送,若网络差导致丢包,可能切换TCP重传重要信息。
6. 堆和栈的区别
- 解释堆(heap)和栈(stack)在内存管理上的区别。
堆(Heap)和栈(Stack)是内存管理中两种核心机制,它们在分配方式、生命周期、性能特征和应用场景上有本质区别。以下从技术实现和实际应用角度深入解析两者的差异:
一、内存分配机制
-
分配方式
• 栈:由编译器自动分配和释放,遵循后进先出(LIFO)原则。例如函数中的局部变量int a
会由编译器在栈上分配空间,函数结束后自动回收。
• 堆:需程序员手动申请(如malloc
/new
)和释放(如free
/delete
)。堆内存的生命周期由程序员控制,若未释放则可能导致内存泄漏。 -
内存结构
• 栈:连续的内存区域,通过调整栈顶指针(如x86的ESP寄存器)实现快速分配。在Windows中默认大小约1-2MB,超出会导致栈溢出(Stack Overflow)。
• 堆:非连续内存,通过链表管理空闲内存块。分配时需要遍历链表寻找合适空间,可能产生内存碎片,但可动态扩容(受限于系统虚拟内存)。
二、性能与访问效率
-
分配速度
• 栈:分配仅需移动栈指针,速度极快(纳秒级)。例如函数调用时参数入栈仅需一条CPU指令。
• 堆:需通过系统调用(如brk
或mmap
)动态分配,速度较慢(微秒级)。频繁分配可能触发垃圾回收(GC)或内存整理。 -
数据访问
• 栈:直接通过变量名访问,无需指针解引用。例如char s1[] = "abc"
的数组元素直接存储在栈中,读取时无需二次寻址。
• 堆:需通过指针间接访问。例如char* s2 = "abc"
的指针变量在栈中,实际数据在堆中,访问需两次内存操作。
三、生命周期与作用域
-
作用域限制
• 栈:变量仅在作用域内有效。例如函数内的局部变量无法被外部访问,函数返回后栈帧销毁。
• 堆:数据可跨作用域存在。例如通过new
创建的全局对象,需显式释放才能回收。 -
内存泄漏风险
• 栈:无泄漏风险,由编译器自动管理。
• 堆:若未释放则持续占用内存。例如未配对的malloc
/free
会导致程序运行时内存逐渐耗尽。
四、应用场景对比
特性栈堆适用场景 | 局部变量、函数调用上下文 | 动态数据结构(如链表、树)、大型数据 |
容量 | 较小(默认MB级) | 较大(受系统虚拟内存限制) |
线程安全 | 线程私有,无需同步 | 全局共享,需锁机制保护 |
碎片问题 | 无 | 频繁分配释放易产生碎片 |
五、典型案例分析
-
栈溢出
void recursive() { char buffer[1024]; // 每次递归消耗1KB栈空间 recursive(); // 递归过深导致栈溢出 }
递归调用未设终止条件,栈空间迅速耗尽。
-
堆内存泄漏
void create_leak() { int* ptr = new int[100]; // 分配后未释放 } // 函数结束,ptr被销毁,但堆内存仍存在
未调用
delete[]
导致100个int内存永久泄漏。
六、现代技术演进
• 智能指针:C++的std::unique_ptr
和Qt的QScopedPointer
通过RAII(资源获取即初始化)自动管理堆内存,减少手动释放的负担。
• 内存池:通过预分配大块堆内存并自行管理,减少碎片和分配开销,常见于高频交易系统。
堆和栈的区别本质是自动化与灵活性的权衡。理解两者差异,能帮助开发者在性能优化(如高频计算优先用栈)与资源管理(如跨作用域数据用堆)间做出合理选择。
7. HTTP协议版本
- 询问最新的HTTP协议版本是基于什么。
HTTP协议的最新版本:HTTP/3
1. 基于QUIC协议构建
最新的HTTP协议版本是HTTP/3,其核心创新在于底层传输协议的改变。HTTP/3完全摒弃了传统的TCP协议,转而基于UDP协议和**QUIC(Quick UDP Internet Connections)**框架实现。QUIC由Google设计,旨在通过UDP的高效性解决TCP的固有缺陷(如队头阻塞、高延迟)。
2. QUIC协议的核心优势
• 基于UDP的高效传输:UDP无需建立复杂连接,减少了三次握手的开销,连接建立时间可缩短至0-RTT(首次连接1-RTT),显著降低延迟。 • 内置TLS加密:QUIC将TLS 1.3加密协议集成到传输层,所有通信默认加密,提升了安全性。 • 多路复用与独立流:每个HTTP请求/响应作为独立的数据流传输,彻底解决队头阻塞问题(TCP中一个数据包丢失会影响后续所有数据),提升并发性能。 • 连接迁移支持:设备在网络切换(如Wi-Fi转4G)时,QUIC通过连接ID保持会话连续性,无需重新握手。
3. HTTP/3的改进亮点
• 性能优化:相比HTTP/2的TCP多路复用,HTTP/3在丢包率高的网络环境下(如移动网络)表现更稳定,传输效率提升30%-50%。 • 头部压缩升级:采用QPACK算法替代HTTP/2的HPACK,支持乱序传输下的动态压缩,减少冗余数据。 • 兼容性增强:QUIC协议在用户空间实现,无需操作系统内核更新,更易部署。
4. 实际应用场景
• 实时通信:如视频会议、在线游戏,对低延迟和抗抖动要求极高。 • 移动端服务:适应网络频繁切换和高丢包环境,提升移动应用体验。 • 大规模内容分发:CDN服务商(如Cloudflare)已广泛支持HTTP/3,加速全球资源加载。
5. 与HTTP/2的对比
特性HTTP/2HTTP/3底层协议 | TCP | UDP + QUIC |
加密支持 | 可选(依赖HTTPS) | 强制集成TLS 1.3 |
队头阻塞 | 存在(TCP层) | 完全解决(独立流) |
连接建立速度 | 1-RTT(需握手) | 0-RTT(已缓存会话) |
总结
HTTP/3通过QUIC协议重构了传输层,结合UDP的高效性和QUIC的可靠性,成为下一代Web通信的核心标准。其设计解决了TCP的固有瓶颈,并在安全性、性能、移动适配等方面实现突破。目前主流浏览器(如Chrome、Firefox)和服务器(如Nginx、Apache)已逐步支持HTTP/3,未来将推动更快速、稳定的互联网服务。
8. select
和poll
的区别
- 描述
select
和poll
系统调用在I/O多路复用上的区别。
select
和poll
系统调用的核心区别
1. 文件描述符数量限制
• select:使用固定大小的位掩码(fd_set
),默认最大支持1024个文件描述符。虽然可通过修改FD_SETSIZE
宏调整上限,但会导致内核与用户态数据结构不匹配(如Solaris系统存在兼容性问题)。 • poll:采用动态长度的struct pollfd
数组,理论上无硬性限制,仅受系统资源(如内存)约束。例如在BSD/OS 2.1系统中,poll
可支持数万个并发连接。
2. 数据结构和参数设计
• select:通过三个独立的fd_set
位掩码(读/写/异常)传递描述符集合,每次调用需重置所有参数。例如:
fd_set read_fds;
FD_ZERO(&read_fds);
FD_SET(fd1, &read_fds);
select(max_fd+1, &read_fds, NULL, NULL, NULL);
每次调用需遍历0到max_fd
所有描述符,存在大量无效检查。
• poll:使用统一的事件结构体数组,通过events
和revents
分离输入输出参数。例如:
struct pollfd fds[2];
fds[0].fd = fd1;
fds[0].events = POLLIN; // 仅需设置一次
poll(fds, 2, 5000); // 无需重新初始化
这种设计减少冗余参数传递,避免重复初始化。
3. 性能与时间复杂度
• 时间复杂度:两者均为**O(n)**轮询,需遍历所有监控的描述符。但poll
通过指定数组长度减少无效遍历(例如仅监控10个活跃描述符时,poll
只需检查10项,而select
需遍历1024位掩码)。 • 内存拷贝开销:select
每次调用需将完整位掩码从用户态拷贝至内核态,而poll
仅传递数组指针和长度,减少数据拷贝量。
4. 超时处理与返回值
• 超时精度:select
使用timeval
结构(微秒级精度),但实际受系统时钟分辨率限制(通常10ms);poll
使用毫秒级超时参数,精度略低但更易用。 • 剩余超时:select
修改timeval
参数返回剩余时间,需用户重新计算;poll
不修改超时参数,简化重试逻辑。
5. 兼容性与扩展性
• 系统兼容性:select
在BSD和Windows系统广泛支持,移植性更好;早期poll
仅在System V系统原生支持(如SVR3需额外处理fd=-1
的特殊情况)。 • 事件类型:poll
支持更丰富的事件类型(如POLLRDHUP
用于TCP对端关闭连接),而select
仅支持读/写/异常三类基础事件。
性能对比实测
在相同并发量下(如1000个活跃连接),poll
因避免无效遍历,吞吐量比select
高约15-20%。但两者本质仍为线性轮询,与epoll
的事件驱动模型(时间复杂度O(1))相比,性能差距在万级连接时呈指数级扩大。
设计演进关系
poll
是select
的改进版,解决了描述符限制和参数冗余问题,但未改变轮询本质。后续epoll
通过事件回调机制(红黑树+就绪链表)彻底突破性能瓶颈,成为高并发场景首选。
9. select
支持的最大数量
- 询问
select
系统调用支持的最大文件描述符数量。
select
系统调用支持的最大文件描述符数量
1. 默认限制:1024
select
系统调用默认支持的最大文件描述符数量为1024。这是由于Linux内核中fd_set
结构体的设计限制,其底层通过一个固定大小的位图(bitmap)表示文件描述符集合,每个位对应一个文件描述符的监视状态。
• 实现细节:fd_set
结构体由long int
数组构成,数组大小为__FD_SETSIZE / (8 * sizeof(long))
,而__FD_SETSIZE
在标准库中被定义为1024。因此,位图最多只能覆盖0~1023的文件描述符。
• 历史原因:早期硬件资源有限,1024的设计基于当时的硬件条件和性能权衡,同时与进程默认打开的最大文件描述符数量(通常为1024)保持一致。
2. 限制的根源
• 位图数据结构:select
通过位图轮询文件描述符的状态,每个文件描述符占用一个比特位。例如,文件描述符4对应位图的第4位。这种设计导致扩展性受限。
• 内核宏定义硬编码:__FD_SETSIZE
的值为1024,直接限制了位图容量。即使系统支持更大的文件描述符数量(如修改ulimit
),select
也无法突破这一限制。
3. 突破限制的尝试与局限性
虽然理论上可以通过修改__FD_SETSIZE
宏并重新编译内核来扩展select
的支持上限,但实际应用中存在以下问题:
- 兼容性风险:修改宏定义可能导致标准库函数(如
FD_SET
、FD_ISSET
)的未定义行为,甚至内存越界。 - 性能瓶颈:
select
采用轮询机制,时间复杂度为O(n)。即使支持更多文件描述符,遍历大量描述符的开销会导致性能急剧下降。 - 替代方案更优:对于高并发场景,推荐使用
epoll
(Linux特有)或poll
(无数量限制),两者均避免了select
的性能和扩展性缺陷。
4. 设计缺陷与优化建议
• 缺陷:
• 覆盖式位图:每次调用select
后,内核会修改传入的fd_set
位图,导致用户需在循环中重复初始化。
• 超时机制异常:返回的剩余超时时间会覆盖原值,需每次调用前重置超时参数。
• 优化方向:
• 使用poll
替代:支持无限制文件描述符,且无需重新初始化数据结构。
• 结合多线程:将连接分片到多个s
实例中处理,但需额外管理复杂性。
剩余60%内容,订阅专栏后可继续查看/也可单篇购买
【C/C++面试必考必会】专栏,直击面试核心,精选C/C++及相关技术栈中面试官最爱的必考点!从基础语法到高级特性,从内存管理到多线程编程,再到算法与数据结构深度剖析,一网打尽。助你快速构建知识体系,轻松应对技术挑战。希望专栏能让你在面试中脱颖而出,成为技术岗的抢手人才。