C++ 面试高频题精简精校版(按面试频次分类)
按面试出现频次分为 🔥 高频题(97题)、📌 中频题(66题)、💡 低频题(34题),建议按优先级复习。
📊 频次分布总览
| 频次 | 题量 | 复习建议 |
|---|---|---|
| 🔥 高频题 | 97 题 | 必须熟练掌握,能脱口而出 |
| 📌 中频题 | 66 题 | 理解原理,能口述要点 |
| 💡 低频题 | 34 题 | 了解即可,时间充裕时浏览 |
一、C/C++ 基础
🔥 高频题(32题)
面试中最常出现,核心知识点,建议重点掌握,能熟练默写。
1. C++ 程序的内存由哪几个部分组成?各有什么作用和特点?
C++ 程序内存分为 5 大区域:
| 区域 | 作用 | 特点 |
|---|---|---|
| 栈区 (stack) | 存放函数参数、局部变量 | 编译器自动分配释放,操作方式类似数据结构中的栈,向低地址增长 |
| 堆区 (heap) | 动态分配内存 | 程序员手动分配释放(new/delete、malloc/free),若不释放程序结束时由 OS 回收;分配方式类似链表,向高地址增长 |
| 全局/静态区 (static) | 存放全局变量、静态变量 | 编译时分配,程序整个运行期间都存在;初始化的和未初始化的分开存放 |
| 文字常量区 | 存放常量字符串、const 常量 | 只读,程序结束后由系统释放 |
| 程序代码区 | 存放函数体的二进制代码 | 只读,共享 |
2. 重写、重载、重定义的区别?
| 概念 | 发生范围 | 条件 | 多态性 |
|---|---|---|---|
| 重载 (overload) | 同一作用域 | 函数名相同,参数列表(类型/个数/顺序)不同 | 静态多态(编译期) |
| 重写 (override) | 基类与派生类之间 | 派生类重新实现基类的虚函数,函数签名完全一致 | 动态多态(运行期) |
| 重定义 (redefine) | 基类与派生类之间 | 派生类重新定义基类的非虚函数,函数名相同(参数可不同) | 无多态,隐藏基类函数 |
关键点:
- 重载看参数,不看返回值。
- 重写要求函数签名完全一致(C++11 可用
override关键字检查)。 - 重定义会隐藏基类同名函数,需用
using Base::func;引入。
3. strlen 和 sizeof 的区别?
| 区别 | strlen | sizeof |
|---|---|---|
| 本质 | 库函数,运行时计算 | 运算符,编译时确定 |
| 功能 | 计算字符串长度(到 \0 为止,不含 \0) | 计算类型/变量所占内存字节数 |
| 参数 | 接受 char* 字符串 | 接受类型名或变量名 |
| 结果 | 字符串实际字符数 | 内存大小,含 \0 和对齐填充 |
char str[] = "hello";
strlen(str); // 5
sizeof(str); // 6(含末尾 '\0')
char *p = "hello";
strlen(p); // 5
sizeof(p); // 8(64位系统指针大小)
4. C++ 中 const 关键字的作用?
- 修饰变量:定义常量,不可修改。
const int x = 10; - 修饰指针:
const int *p:指向常量的指针,不能通过 p 修改所指内容。int *const p:常量指针,指针本身不可改指向。const int *const p:两者都不可改。
- 修饰函数参数:防止函数内部修改参数,常用于引用/指针传参。
void func(const string &s) - 修饰函数返回值:返回值不可被修改。
- 修饰成员函数:
void func() const;承诺不修改成员变量(mutable 变量除外),const 对象只能调用 const 成员函数。 - 修饰引用:
const int &r = x;不可通过引用修改对象,可绑定临时对象。
编译期常量:C++11 起 constexpr 可用于编译期求值,比 const 更强。
5. C++ 中 static 关键字的作用?
| 使用场景 | 作用 |
|---|---|
| 局部变量 | 延长生命周期到程序结束,只初始化一次,存在全局/静态区 |
| 全局变量/函数 | 限制作用域为当前编译单元(内部链接),避免符号冲突 |
| 类成员变量 | 所有对象共享一份,属于类而非对象,必须在类外定义初始化 |
| 类成员函数 | 属于类而非对象,无 this 指针,只能访问静态成员,可通过类名直接调用 |
注意:
- 静态成员变量在类内声明,类外定义(C++17 可用
inline static直接初始化)。 - 静态成员函数不能声明为
const、virtual、volatile。 - 局部静态变量的初始化在 C++11 后是线程安全的(Magic Statics)。
6. C++ 中 class 和 struct 的区别?
| 区别 | class | struct |
|---|---|---|
| 默认访问权限 | private | public |
| 默认继承方式 | private 继承 | public 继承 |
| 模板参数 | 可用 class 关键字 | 不能用 struct 作模板参数关键字 |
本质相同:C++ 中 class 和 struct 几乎没有区别,都可以有成员函数、构造函数、析构函数、继承、多态等。唯一区别就是默认访问权限和默认继承方式。
使用惯例:
struct:用于纯数据结构(POD),成员都是 public,无复杂行为。class:用于封装行为和数据,有明确的访问控制。
7. C++ 中类型转换有哪几种?
C++ 有 4 种显式类型转换运算符,比 C 风格强制转换更安全、更明确:
| 转换符 | 用途 | 特点 |
|---|---|---|
| static_cast | 良性转换:基本类型间、上行转换(派生→基类)、void* 转换 | 编译期检查,无运行时开销,不做动态类型检查 |
| dynamic_cast | 多态类型间的下行转换(基类→派生)、交叉转换 | 运行时检查 RTTI,失败返回 nullptr(指针)或抛 bad_cast(引用),要求基类有虚函数 |
| const_cast | 去除或添加 const/volatile 属性 | 唯一能去除 const 的转换,但修改原本 const 的对象是未定义行为 |
| reinterpret_cast | 底层重新解释:指针↔整数、不同类型指针互转 | 最危险,完全依赖编译器实现,不可移植,仅用于底层操作 |
C 风格转换:(type)expr 会依次尝试 static_cast → const_cast → reinterpret_cast,不够明确,不推荐。
8. 简述 malloc / free 的实现原理。
malloc 实现(以 glibc ptmalloc 为例):
- 内存池管理:维护多个 bin(空闲块链表),按大小分类:
- fast bin:小内存块(≤64B),LIFO 单链表,快速分配。
- unsorted bin:刚释放的大块,未分类。
- small bin / large bin:按大小排序的双向链表。
- 分配流程:先查 fast bin → unsorted bin → small/large bin → 都不够则通过
brk()扩展堆或mmap()映射新内存。 - 块头信息:每个分配块前有 chunk header,记录块大小、是否使用、前一块大小等。
- 内存对齐:返回的指针按 16 字节(64 位)对齐。
free 实现:
- 根据传入指针找到 chunk header。
- 标记为空闲,加入对应 bin。
- 合并相邻空闲块(除 fast bin 外),减少外部碎片。
- 若顶部空闲块足够大,通过
sbrk()归还给 OS。
9. new / delete 与 malloc / free 的异同?
| 区别 | new / delete | malloc / free |
|---|---|---|
| 所属 | C++ 运算符 | C 标准库函数 |
| 返回类型 | 具体类型指针(类型安全) | void*(需强制转换) |
| 内存分配 | 调用 operator new → malloc | 直接分配原始内存 |
| 构造/析构 | new 调用构造函数,delete 调用析构函数 | 不调用构造/析构 |
| 失败处理 | 抛 std::bad_alloc 异常 | 返回 NULL |
| 数组分配 | new[] / delete[](分别调用多个构造/析构) | 需手动计算大小 |
| 可重载 | 可重载 operator new/delete | 不可重载 |
| 内存大小 | 由编译器根据类型自动计算 | 需手动指定字节数 |
相同点:都在堆上分配内存,都需要手动释放,不能混用(new 的对象不能用 free,malloc 的内存不能用 delete)。
配对使用:new ↔ delete,new[] ↔ delete[],malloc ↔ free。
10. 指针和引用的区别?
| 区别 | 指针 | 引用 |
|---|---|---|
| 本质 | 存储地址的变量 | 变量的别名(绑定到已存在对象) |
| 初始化 | 可以不初始化,可为空(nullptr) | 必须初始化,且绑定后不可改指向 |
| 多级 | 可以有多级指针(int**) | 没有引用的引用(int& & 非法,折叠后为 int&) |
| sizeof | 指针本身大小(4/8 字节) | 所引用对象的大小 |
| 自增 | 指向地址移动 | 所引用对象的值 +1 |
| 作为参数 | 可传空,需判空 | 一定有效,无需判空 |
| 重新指向 | 可以改变指向 | 不能,只能修改所指对象的值 |
使用建议:
- 能用引用就用引用(更安全、更高效)。
- 需要表示"无对象"或需要改变指向时用指针。
- 函数参数优先用
const T&,避免拷贝且不修改。
11. 引用作为函数返回时为什么不能返回局部变量?
因为局部变量在函数返回时生命周期结束,栈空间被回收,返回的引用成为悬垂引用 (dangling reference),访问它是未定义行为。
int& bad() {
int x = 10; // 局部变量,栈上
return x; // 错误:返回局部变量的引用
} // x 在这里被销毁
int& r = bad(); // r 指向已销毁的栈内存
cout << r; // 未定义行为!
可以返回引用的情况:
- 返回传入的引用参数:
int& func(int& x) { return x; } - 返回静态变量:
int& func() { static int x; return x; } - 返回堆上对象:
int& func() { return *new int; }(但需手动释放,不推荐) - 返回成员变量:
int& get() { return m_x; }(对象生命周期内有效)
12. extern "C" 的用法?
extern "C" 告诉 C++ 编译器以 C 语言的方式进行链接,即不进行名字修饰 (name mangling)。
主要用途:
- C++ 调用 C 代码:在 C++ 中包含 C 头文件时。
- C 调用 C++ 代码:在 C++ 中导出可被 C 调用的函数。
// C++ 头文件
#ifdef __cplusplus
extern "C" {
#endif
void c_function(int x);
int another_func(const char *s);
#ifdef __cplusplus
}
#endif
注意事项:
extern "C"内不能有函数重载、默认参数、模板等 C++ 特性。- 变量也可以用
extern "C"声明。 - 只能在全局命名空间使用,不能在类内或命名空间内使用。
- 一个
extern "C"块可以包含多个声明。
13. 内联函数和宏定义的区别?
| 区别 | 内联函数 (inline) | 宏定义 (#define) |
|---|---|---|
| 处理阶段 | 编译期(编译器决定是否内联) | 预处理期(纯文本替换) |
| 类型检查 | 有完整的类型检查 | 无类型检查,纯文本替换 |
| 参数求值 | 参数只求值一次 | 参数可能被多次求值(副作用问题) |
| 调试 | 可以调试(未内联时) | 无法调试,无符号信息 |
| 返回值 | 有明确返回类型 | 无返回类型,需小心括号 |
| 作用域 | 遵循函数作用域 | 不受作用域限制,直到 #undef |
| 递归 | 可以递归(但递归不会内联) | 不能递归 |
// 宏的副作用问题
#define SQUARE(x) ((x)*(x))
int a = 5;
SQUARE(++a); // 展开为 ((++a)*(++a)),a 被加两次!
// 内联函数安全
inline int square(int x) { return x * x; }
square(++a); // a 只加一次
C++ 建议:优先用内联函数或 constexpr,避免宏。宏仅用于条件编译和 include guard。
14. 拷贝构造函数的调用时机?
拷贝构造函数在以下情况被调用:
- 用一个对象初始化另一个对象:
A a1; A a2 = a1; // 拷贝构造(不是赋值!) A a3(a1); // 拷贝构造 - 函数参数按值传递:
void func(A a); // 调用时实参拷贝给形参 func(a1); - 函数返回值按值返回(未被 RVO/NRVO 优化时):
A func() { A a; return a; } // 返回时拷贝构造临时对象 - 异常处理中按值抛出/捕获异常对象。
注意:
A a2 = a1;是拷贝构造,不是赋值运算符。赋值是对已存在对象:a2 = a1;- C++11 起,返回值优化 (RVO/NRVO) 和移动语义可减少拷贝构造调用。
- 拷贝构造函数参数必须是 const 引用(见下题)。
15. 为什么拷贝构造函数必须传引用不能传值?
如果拷贝构造函数按值传参,会导致无限递归:
// 错误!按值传参
A(const A other) { // 调用拷贝构造时,实参需要拷贝给形参 other
// ... // 而拷贝给形参又需要调用拷贝构造函数
} // → 无限递归,栈溢出
原理:按值传递参数本身就需要调用拷贝构造函数来创建形参副本。如果拷贝构造函数自己也按值传参,那么为了创建形参就需要再次调用拷贝构造函数,形成无限递归。
正确写法:
A(const A& other) { // 传引用,不需要拷贝
// 深拷贝成员
}
参数加 const 是因为:
- 不修改源对象。
- 可以接受 const 对象和临时对象(右值)作为参数。
16. 构造函数初始化和列表初始化的区别?
这里指构造函数体内赋值和初始化列表的区别:
// 方式1:构造函数体内赋值
A(int x, const string& s) {
m_x = x; // 先默认构造 m_x,再赋值
m_s = s; // 先默认构造 m_s,再赋值
}
// 方式2:初始化列表
A(int x, const string& s) : m_x(x), m_s(s) {
// 直接构造,无默认构造步骤
}
| 区别 | 函数体内赋值 | 初始化列表 |
|---|---|---|
| 执行顺序 | 先默认构造成员,再赋值 | 直接构造成员 |
| 效率 | 低(多一次默认构造+赋值) | 高(直接构造) |
| const 成员 | 不能初始化(const 必须在构造时初始化) | 可以 |
| 引用成员 | 不能初始化 | 可以 |
| 无默认构造的成员 | 不能初始化 | 可以 |
| 基类构造 | 不能指定参数 | 可以调用带参基类构造 |
初始化列表顺序:按成员在类中声明的顺序初始化,不是按列表书写顺序。析构顺序与构造顺序相反。
17. 虚函数表的作用和存储的地址?
虚函数表 (vtable) 是 C++ 实现动态多态的核心数据结构。
作用:存储类中所有虚函数的地址,实现运行时动态绑定。
存储内容:
- 每个有虚函数的类有一张虚函数表,存储在程序的只读数据段。
- 表中按虚函数声明顺序存放各虚函数的函数指针。
- 派生类重写的虚函数,在表中对应位置替换为派生类函数地址。
- 多重继承时,派生类可能有多个虚函数表。
对象中的 vptr:
- 每个有虚函数的对象包含一个虚函数表指针 (vptr),指向对应类的虚函数表。
- vptr 在构造函数中初始化,指向当前类的 vtable。
- vptr 占用对象内存(64 位系统 8 字节)。
对象内存 类的虚函数表(只读数据段)
┌──────────┐ ┌──────────────────┐
│ vptr ───┼────→│ &Base::func1 │
│ 成员变量 │ │ &Base::func2 │
└──────────┘ └──────────────────┘
调用过程:obj.func() → 通过 vptr 找到 vtable → 根据函数索引找到函数地址 → 调用。
18. 构造函数可以设置成虚函数吗?为什么?
不可以,构造函数不能声明为虚函数。
原因:
- 虚函数依赖 vptr:虚函数调用需要通过对象的 vptr 找到 vtable。但构造函数执行时,对象正在构造中,vptr 尚未正确初始化(构造函数中 vptr 指向当前类的 vtable,而非最终派生类的)。
- 类型未确定:构造函数的作用是创建对象,在构造完成前对象的动态类型尚未确定,虚函数的动态绑定没有意义。
- 语义矛盾:虚函数的目的是通过基类指针调用派生类实现,但构造函数是从基类到派生类依次执行的,构造基类部分时派生类还不存在。
相关概念:
- 构造函数中调用虚函数,会调用当前类的版本,不是派生类版本(因为派生类还没构造)。
- 析构函数可以且应该声明为虚函数(多态基类),确保通过基类指针删除派生类对象时正确调用派生类析构函数。
19. 虚函数和纯虚函数的区别?
| 区别 | 虚函数 | 纯虚函数 |
|---|---|---|
| 声明 | virtual void func(); | virtual void func() = 0; |
| 实现 | 基类必须提供实现(可空) | 基类可以不提供实现(也可提供) |
| 类的类型 | 类可实例化 | 包含纯虚函数的类是抽象类,不能实例化 |
| 派生类要求 | 可重写也可不重写 | 派生类必须实现所有纯虚函数,否则仍是抽象类 |
| 目的 | 提供默认实现,允许派生类覆盖 | 定义接口规范,强制派生类实现 |
class Base {
public:
virtual void func1() { /* 默认实现 */ } // 虚函数
virtual void func2() = 0; // 纯虚函数
};
class Derived : public Base {
public:
void func2() override { /* 必须实现 */ } // 不实现则 Derived 也是抽象类
};
注意:
- 纯虚函数也可以在类外提供实现(
void Base::func2() {}),派生类可通过Base::func2()调用。 - 析构函数可以是纯虚函数,但必须提供实现(否则派生类析构时链接错误)。
- 接口类(只有纯虚函数)相当于 Java 的 interface。
20. 为什么析构函数一般写成虚函数?
为了在多态场景下正确释放资源。
问题场景:
class Base {
public:
~Base() { /* 非虚析构 */ }
};
class Derived : public Base {
public:
~Derived() { /* 释放派生类资源 */ }
};
Base* p = new Derived();
delete p; // 只调用 Base::~Base(),不调用 Derived::~Derived()!
// → 派生类资源泄漏,未定义行为
将基类析构函数声明为 virtual 后:
class Base {
public:
virtual ~Base() { } // 虚析构
};
Base* p = new Derived();
delete p; // 通过 vtable 动态绑定,先调用 Derived::~Derived(),再调用 Base::~Base()
规则:
- 基类有虚函数时,析构函数必须是虚函数(多态基类)。
- 不作为基类、不涉及多态的类,析构函数不必是虚函数(虚函数表指针会增加对象大小)。
- 析构函数可以是纯虚函数,但必须提供实现。
21. 动态多态和静态多态的实现过程?
静态多态(编译期多态):
- 在编译期确定调用哪个函数。
- 实现方式:函数重载、模板、运算符重载。
- 优点:无运行时开销,性能好。
- 缺点:灵活性低,编译后固定。
// 函数重载
void func(int x);
void func(double x); // 编译期根据参数类型决定调用哪个
// 模板
template<typename T>
T add(T a, T b) { return a + b; } // 编译期实例化
动态多态(运行期多态):
- 在运行期根据对象的实际类型决定调用哪个函数。
- 实现方式:继承 + 虚函数。
- 实现过程:基类指针/引用指向派生类对象 → 通过 vptr 找到 vtable → 根据函数索引找到派生类函数地址 → 调用。
- 优点:灵活,运行时可切换行为。
- 缺点:有运行时开销(vptr 查找、间接调用),对象体积增大。
class Base { virtual void func(); };
class Derived : public Base { void func() override; };
Base* p = new Derived();
p->func(); // 运行时确定调用 Derived::func()
C++17 补充:std::variant + std::visit 可实现类型安全的静态多态替代方案。
22. STL 中 vector 删除元素,迭代器如何变化?
erase 方法:
iterator erase(iterator pos); // 删除单个元素,返回指向被删元素下一个元素的迭代器
iterator erase(iterator first, iterator last); // 删除范围,返回 last 的下一个位置
迭代器变化:
- 被删除元素及其之后的所有迭代器、引用、指针全部失效。
- 删除点之前的迭代器仍然有效。
- erase 返回指向被删元素下一个位置的新有效迭代器。
正确的遍历删除方式:
// 方式1:使用 erase 返回值
for (auto it = vec.begin(); it != vec.end(); ) {
if (should_delete(*it)) {
it = vec.erase(it); // 更新迭代器
} else {
++it;
}
}
// 方式2:C++20 std::erase / erase_if
std::erase_if(vec, [](int x){ return x % 2 == 0; });
错误写法:
for (auto it = vec.begin(); it != vec.end(); ++it) {
if (should_delete(*it)) {
vec.erase(it); // 错误:it 已失效,++it 是未定义行为
}
}
23. map、set 是怎么实现的?红黑树怎么同时实现这两种容器?
map 和 set 底层都是红黑树 (red-black tree),具体是 STL 中的 _Rb_tree。
红黑树的性质:
- 节点非红即黑。
- 根节点是黑色。
- 叶子节点(NIL)是黑色。
- 红色节点的子节点必须是黑色(不能有连续红节点)。
- 从任一节点到其每个叶子的所有路径包含相同数量的黑节点。
保证树高 ≤ 2log(n+1),插入/删除/查找都是 O(log n)。
map 和 set 的区别:
- set:键就是值,红黑树节点只存储键。
- map:键值对,红黑树节点存储
pair<const Key, Value>,按键排序。
同一棵红黑树实现两种容器:
- STL 的
_Rb_tree是一个通用的红黑树模板,通过模板参数区分:- set 的
_Rb_tree节点值类型就是 Key,比较器比较 Key。 - map 的
_Rb_tree节点值类型是pair<const Key, Value>,比较器只比较 pair 的 first(Key)。
- set 的
- 通过
_Select1st(取 pair 的 first)和_Identity(取自身)等萃取器提取键。
multimap/multiset:也是红黑树,但允许重复键(插入时不做唯一性检查)。
24. C++11 的新特性有哪些?
核心语言特性:
- auto:类型推导,编译器根据初始化表达式推导类型。
- decltype:推导表达式的类型。
- 范围 for 循环:
for (auto& x : container)。 - nullptr:类型安全的空指针常量,替代 NULL。
- 右值引用 &&:支持移动语义和完美转发。
- 移动构造/移动赋值:转移资源所有权,避免深拷贝。
- std::move:将左值转为右值引用。
- std::forward:完美转发,保持值类别。
- lambda 表达式:匿名函数,支持捕获。
- 智能指针:
unique_ptr、shared_ptr、weak_ptr。 - 统一初始化 {}:列表初始化,防止窄化转换。
- =default / =delete:显式控制默认/删除特殊成员函数。
- override / final:显式标记重写/禁止继承和重写。
- constexpr:编译期常量表达式。
- 委托构造函数:构造函数调用同类另一个构造函数。
- 继承构造函数:
using Base::Base;继承基类构造函数。 - 可变参数模板:
template<typename... Args>。 - 静态断言 static_assert:编译期断言。
- 强类型枚举 enum class:有作用域、不会隐式转换。
- 线程支持:
std::thread、std::mutex、std::condition_variable。 - 原子操作 std::atomic。
- std::function / std::bind:可调用对象封装。
- std::array:固定大小数组容器。
- std::unordered_map / unordered_set:哈希表容器。
- std::tuple:元组。
25. lambda 函数的全部知识?
基本语法:
[capture-list](params) mutable -> return-type { body }
1. 捕获列表:
[]:不捕获任何变量。[x]:按值捕获 x。[&x]:按引用捕获 x。[=]:按值捕获所有用到的外部变量。[&]:按引用捕获所有用到的外部变量。[=, &x]:默认按值,x 按引用。[&, x]:默认按引用,x 按值。[this]:捕获 this 指针。- C++14:
[x = expr]初始化捕获(广义捕获)。
2. 参数列表:和普通函数一样,C++14 起可用 auto(泛型 lambda)。
3. mutable:允许修改按值捕获的变量(默认按值捕获的变量在 lambda 内是 const)。
4. 返回类型:可省略,编译器自动推导(单 return 语句时)。复杂情况需显式指定 -> type。
5. 函数体:执行逻辑。
本质:lambda 是编译器生成的匿名类(闭包类型)的对象,捕获的变量成为类的成员变量,operator() 是函数调用。
注意事项:
- 按引用捕获时,需确保被引用变量的生命周期长于 lambda。
- lambda 可赋值给
std::function。 - 无捕获的 lambda 可隐式转换为函数指针。
- 递归 lambda 需要用
std::function或 Y-combinator。
26. C++ 中的智能指针?
C++11 引入三种智能指针,在 <memory> 头文件中:
1. unique_ptr(独占所有权):
- 同一时间只能有一个 unique_ptr 指向对象。
- 不可拷贝,只能移动(
std::move)。 - 轻量,无额外开销(和裸指针一样大)。
auto p = std::make_unique<int>(42); // C++14
auto p2 = std::move(p); // 所有权转移,p 变为 nullptr
2. shared_ptr(共享所有权):
- 多个 shared_ptr 可指向同一对象,通过引用计数管理生命周期。
- 引用计数为 0 时自动释放对象。
- 有额外开销(控制块,包含引用计数、删除器等)。
- 线程安全:引用计数的增减是原子操作,但对象本身的访问不是线程安全的。
auto p1 = std::make_shared<int>(42);
auto p2 = p1; // 引用计数 +1
p1.use_count(); // 2
3. weak_ptr(弱引用):
- 不拥有对象,不增加引用计数。
- 用于解决 shared_ptr 的循环引用问题。
- 通过
lock()获取 shared_ptr(对象已释放则返回空)。 - 通过
expired()检查对象是否已释放。
auto sp = std::make_shared<int>(42);
std::weak_ptr<int> wp = sp;
if (auto p = wp.lock()) { /* 对象还在 */ }
循环引用问题:
struct A { std::shared_ptr<B> b; };
struct B { std::shared_ptr<A> a; }; // 循环引用,内存永远不释放
// 解决:将其中一个改为 weak_ptr
选择建议:
- 默认用 unique_ptr,需要共享时用 shared_ptr。
- 观察者模式、打破循环引用用 weak_ptr。
- 避免用裸指针管理内存,优先用
make_unique/make_shared。
27. 面向对象的三大特性?
-
封装 (Encapsulation):
- 将数据和操作数据的方法绑定在类中,隐藏内部实现细节。
- 通过访问控制符(public/private/protected)限制外部访问。
- 目的:降低耦合、保护数据安全、便于维护。
-
继承 (Inheritance):
- 派生类(子类)继承基类(父类)的成员和接口。
- 实现代码复用和层次化设计。
- C++ 支持单继承、多继承、虚继承。
- 派生类可扩展新成员,也可重写(override)基类虚函数。
-
多态 (Polymorphism):
- 同一接口,不同实现。
- 静态多态:编译期确定,如函数重载、模板。
- 动态多态:运行期确定,通过继承 + 虚函数实现,基类指针/引用指向派生类对象时调用派生类方法。
- 目的:提高代码灵活性和可扩展性,符合开闭原则(对扩展开放,对修改关闭)。
28. 什么是多态?
多态(Polymorphism)指同一接口具有多种不同的实现形式,即"一个接口,多种方法"。
C++ 中的多态分为两类:
1. 静态多态(编译期多态):
- 在编译时就确定调用哪个函数。
- 实现方式:函数重载、运算符重载、模板。
- 优点:无运行时开销。
void print(int x);
void print(double x); // 重载,编译期根据参数决定
print(3); // 调用 print(int)
print(3.14); // 调用 print(double)
2. 动态多态(运行期多态):
- 在运行时根据对象的实际类型决定调用哪个函数。
- 实现条件:继承 + 虚函数 + 基类指针/引用。
- 实现机制:虚函数表 (vtable) + 虚函数表指针 (vptr)。
class Animal { virtual void speak(); };
class Dog : public Animal { void speak() override { cout << "汪汪"; } };
class Cat : public Animal { void speak() override { cout << "喵喵"; } };
Animal* a = new Dog();
a->speak(); // 运行时确定调用 Dog::speak(),输出"汪汪"
a = new Cat();
a->speak(); // 运行时确定调用 Cat::speak(),输出"喵喵"
多态的意义:提高代码的可扩展性和灵活性,新增功能只需新增派生类,不需修改原有代码(开闭原则)。
29. C++ 的多态如何实现?
C++ 动态多态通过 继承 + 虚函数 + 虚函数表 (vtable) 实现。
实现机制:
-
虚函数表 (vtable):
- 每个有虚函数的类,编译器为其生成一张虚函数表,存储在只读数据段。
- 表中按虚函数声明顺序存放各虚函数的函数指针。
- 派生类重写的虚函数,在表中对应位置替换为派生类函数地址。
-
虚函数表指针 (vptr):
- 每个有虚函数的对象包含一个隐藏的 vptr,指向其类的 vtable。
- vptr 在构造函数中初始化,在析构函数中调整。
- vptr 占用对象内存(64 位系统 8 字节)。
-
动态绑定过程:
Base* p = new Derived(); p->virtualFunc(); ① 通过 p 找到对象的 vptr ② 通过 vptr 找到 Derived 类的 vtable ③ 根据函数在 vtable 中的索引,找到 Derived::virtualFunc 的地址 ④ 调用该函数
必要条件:
- 必须通过指针或引用调用虚函数(对象直接调用会静态绑定)。
- 函数必须声明为
virtual。 - 派生类必须重写该虚函数(函数签名一致)。
静态多态:通过模板和函数重载在编译期实现,无运行时开销。
30. 基类和派生类的构造函数和析构函数的执行顺序?
构造顺序(从内到外):
- 基类构造函数(多继承时按继承声明顺序,虚继承优先)。
- 成员变量构造(按声明顺序,不是初始化列表顺序)。
- 派生类构造函数体。
析构顺序(从外到内,与构造相反):
- 派生类析构函数体。
- 成员变量析构(按声明逆序)。
- 基类析构函数。
class Base {
public:
Base() { cout << "Base 构造\n"; }
~Base() { cout << "Base 析构\n"; }
};
class Member {
public:
Member() { cout << "Member 构造\n"; }
~Member() { cout << "Member 析构\n"; }
};
class Derived : public Base {
Member m_;
public:
Derived() { cout << "Derived 构造\n"; }
~Derived() { cout << "Derived 析构\n"; }
};
// 输出:
// Base 构造
// Member 构造
// Derived 构造
// Derived 析构
// Member 析构
// Base 析构
注意:
- 构造函数中调用虚函数,会调用当前类的版本(派生类还未构造)。
- 多态基类的析构函数必须是虚函数,否则通过基类指针删除派生类对象时不会调用派生类析构函数。
- 虚继承时,虚基类的构造函数由最派生类负责调用,且只调用一次。
31. 深拷贝和浅拷贝?如何实现?
浅拷贝 (Shallow Copy):
- 只拷贝对象的成员变量的值,包括指针的值(地址)。
- 拷贝后两个对象的指针指向同一块内存。
- 问题:一个对象释放内存后,另一个对象的指针成为野指针;析构时 double free。
- 编译器默认生成的拷贝构造函数和赋值运算符就是浅拷贝。
深拷贝 (Deep Copy):
- 不仅拷贝成员变量,还为指针指向的资源分配新的内存并拷贝内容。
- 拷贝后两个对象拥有独立的资源,互不影响。
- 需要自定义拷贝构造函数和赋值运算符。
class String {
char* data_;
int size_;
public:
// 深拷贝构造函数
String(const String& other) {
size_ = other.size_;
data_ = new char[size_ + 1]; // 分配新内存
strcpy(data_, other.data_); // 拷贝内容
}
// 深拷贝赋值运算符(Copy-and-Swap 惯用法)
String& operator=(String other) { // 按值传参,自动拷贝
swap(*this, other);
return *this;
}
~String() { delete[] data_; }
};
什么时候需要深拷贝:类中包含指针成员且拥有该指针指向的资源时。
C++11 替代方案:用智能指针(unique_ptr 独占、shared_ptr 共享)管理资源,避免手动深拷贝。
32. virtual() = 0 是什么意思?
这是纯虚函数 (pure virtual function) 的声明语法。
virtual void func() = 0; // 纯虚函数
含义:
- 声明了一个虚函数,但在基类中不提供实现(也可以提供,见下)。
= 0告诉编译器该函数是纯虚的。- 包含纯虚函数的类是抽象类 (abstract class),不能实例化。
- 派生类必须实现所有纯虚函数,否则派生类也是抽象类。
class Shape {
public:
virtual double area() const = 0; // 纯虚函数,定义接口
virtual ~Shape() = default;
};
class Circle : public Shape {
public:
double area() const override { return 3.14 * r_ * r_; } // 必须实现
};
Shape s; // 错误:抽象类不能实例化
Shape* p = new Circle(); // 正确
注意:
- 纯虚函数也可以在类外提供实现:
void Shape::func() {},派生类可通过Shape::func()调用。 - 纯虚析构函数必须提供实现(否则链接错误)。
- 接口类(所有函数都是纯虚函数)相当于 Java 的 interface。
📌 中频题(24题)
比较常见,部分公司会问到,建议理解并能口述要点。
1. 浮点数的编码方式是什么?简述原理。
遵循 IEEE 754 标准,将实数表示为科学计数法形式:
V = (-1)^S × (1 + M) × 2^(E - bias)
| 组成 | 单精度 float | 双精度 double | 说明 |
|---|---|---|---|
| 符号位 S | 1 bit | 1 bit | 0 正,1 负 |
| 指数 E | 8 bit | 11 bit | 偏移表示,bias = 127/1023 |
| 尾数 M | 23 bit | 52 bit | 隐含整数位 1,不存储 |
关键特性:
- 尾数隐含前导 1(hidden bit),节省 1 bit 提高精度。
- 指数用偏移码表示,避免负指数。
- 特殊值:E 全 0 表示非规格化数/0;E 全 1 表示 ∞ 或 NaN。
- 浮点数精度有限,比较时应使用容差(如
fabs(a-b) < 1e-6)。
2. 可执行程序是如何生成的?
四个步骤:预处理 → 编译 → 汇编 → 链接
- 预处理:展开头文件(#include)、宏替换(#define)、去掉注释、处理条件编译(#ifdef)。生成
.i文件。 - 编译:将预处理后的代码翻译成汇编代码,进行语法/语义检查、优化。生成
.s文件。 - 汇编:将汇编代码翻译成机器码(二进制目标文件)。生成
.o/.obj文件。 - 链接:将多个目标文件和库文件合并,解析符号引用、分配地址,生成可执行文件(
.exe/ ELF)。
链接又分为静态链接(库代码嵌入可执行文件)和动态链接(运行时加载共享库 .so/.dll)。
3. 可执行程序是如何变成进程的?
- 加载:OS 将可执行文件从磁盘读入内存,通过缺页中断按需加载代码段和数据段。
- 创建进程控制块 (PCB):OS 为进程分配 PID,创建 task_struct,记录进程状态、寄存器、内存映射、文件描述符等。
- 建立虚拟地址空间:创建页表,将进程的虚拟地址映射到物理内存。
- 设置运行上下文:初始化程序计数器(PC)指向入口函数(
_start→main),设置栈指针。 - 调度执行:进程进入就绪队列,OS 调度器分配 CPU 时间片后开始执行。
4. 在 C 语言中如何调用 C++ 函数?
C 不能直接调用 C++ 函数,因为 C++ 有名字修饰 (name mangling)(支持函数重载),而 C 没有。
方法:在 C++ 中用 extern "C" 声明需要被 C 调用的函数,禁止名字修饰:
// C++ 文件
#ifdef __cplusplus
extern "C" {
#endif
void my_function(int x); // 以 C 方式链接
#ifdef __cplusplus
}
#endif
/* C 文件 */
extern void my_function(int x);
my_function(42);
注意:extern "C" 内不能有函数重载、默认参数等 C++ 特性。对于成员函数,需提供非成员包装函数。
5. 常见的 C/C++ 缺陷和陷阱有哪些?
- 内存管理:野指针、悬垂指针、内存泄漏、重复释放、越界访问。
- 未定义行为 (UB): signed 整数溢出、移位越界、取消引用空指针、访问已释放内存等,结果不可预测。
- 全局/静态变量初始化顺序:不同编译单元间的全局变量初始化顺序未定义(static initialization order fiasco)。
- 隐式类型转换:整型提升、有符号/无符号比较、精度丢失(如
float转int截断)。 - 运算符优先级:如
<<低于+,==高于&,易写错。 - 数组越界:C/C++ 不做边界检查,缓冲区溢出可导致安全漏洞。
- 头文件重复包含:需用
#ifndef或#pragma once保护。 - 临时对象生命周期:返回局部变量的引用/指针、绑定临时对象的引用延长生命周期的误用。
6. 二维数组是什么?函数指针是什么?
二维数组:数组的数组,在内存中按行优先连续存储。
int arr[3][4]; // 3行4列,arr 是 int[4] 类型的数组
// arr[i][j] 等价于 *(*(arr+i)+j)
- 二维数组名退化为指向第一行的指针(
int (*)[4]),不是int**。 - 作为函数参数时,第二维大小必须指定:
void func(int arr[][4], int rows)。
函数指针:指向函数的指针,存储函数的入口地址。
// 声明:返回值类型 (*指针名)(参数类型列表)
int (*fp)(int, int); // fp 是指向"返回int、接受两个int参数"的函数的指针
// 赋值与调用
fp = add;
int result = fp(3, 4); // 等价于 (*fp)(3, 4)
函数指针常用于回调函数、策略模式、跳转表等场景。
7. 函数指针和指针函数的区别?
| 概念 | 定义 | 本质 |
|---|---|---|
| 函数指针 | 指向函数的指针 | 是指针,存储函数入口地址 |
| 指针函数 | 返回指针的函数 | 是函数,返回值为指针类型 |
// 函数指针:返回值类型 (*指针名)(参数列表)
int (*fp)(int, int); // fp 是指向函数的指针
// 指针函数:返回值类型 *函数名(参数列表)
int *func(int x); // func 是返回 int* 的函数
记忆技巧:看 * 和谁结合——*fp() 中 () 优先级高,fp 先和 () 结合是函数;(*fp)() 中 * 先和 fp 结合是指针。
8. void* 的大小是多少?
void* 是指针类型,其大小等于系统的地址总线宽度:
- 32 位系统:4 字节
- 64 位系统:8 字节
注意:
void*可以指向任意类型的数据,但不能直接解引用(*p非法),需先转换为具体类型指针。- C++ 中不允许
void*隐式转换为其他类型指针(C 可以),需用static_cast显式转换。 - 函数指针不能直接用
void*存储(POSIX 允许但标准不保证)。
9. 为何 free(p) 时只需传递堆空间地址?
因为 malloc 分配时在返回指针的前面存储了块的元信息(chunk header),包含块大小等。
┌──────────────┬─────────────────────┐
│ chunk header │ 用户数据区 │
│ (大小/标志) │ p 指向这里 │
└──────────────┴─────────────────────┘
free(p) 时:
- 通过
p - sizeof(header)找到块头。 - 从块头读取块大小,知道要释放多少内存。
- 标记为空闲,插入空闲链表,合并相邻块。
注意:
- 必须释放 malloc 返回的原始指针,不能释放偏移后的指针。
- 同一块不能重复 free(double free)。
- 栈上内存不能用 free 释放。
10. malloc 申请内存后,怎么保证一定申请到了?
malloc 分配失败时返回 NULL,因此必须检查返回值:
int *p = (int*)malloc(sizeof(int) * 100);
if (p == NULL) {
// 分配失败,处理错误
perror("malloc failed");
exit(EXIT_FAILURE);
}
C++ new 的区别:
new失败默认抛std::bad_alloc异常(不会返回 NULL)。new (nothrow)失败返回 nullptr。
注意事项:
- 不要假设 malloc 一定成功,尤其是分配大块内存或内存紧张时。
- malloc(0) 的行为由实现定义,可能返回 NULL 或唯一指针。
- Linux 下 malloc 可能出现"过度承诺"(overcommit),分配成功不代表物理内存真的可用,写入时才可能 OOM。
11. 静态变量什么时候初始化?
分两种情况:
1. 全局/命名空间/类静态变量:
- 编译期确定初始值的(常量初始化):在程序加载时初始化,早于 main。
- 需要动态初始化的(如调用构造函数):在 main 函数之前,按同一编译单元内定义顺序初始化;不同编译单元间顺序未定义。
- 程序结束时按初始化逆序销毁。
2. 局部静态变量:
- 在第一次执行到定义处时初始化(懒初始化)。
- C++11 起,初始化是线程安全的(Magic Statics),多个线程同时首次访问时只有一个线程执行初始化。
- 程序结束时销毁(在全局变量之后)。
注意:
- 静态变量只初始化一次,后续调用跳过初始化。
- 不同编译单元全局静态变量的初始化顺序问题称为 "static initialization order fiasco",可用局部静态变量或 Meyers Singleton 规避。
12. inline 函数的使用?
inline 建议编译器在调用点直接展开函数体,消除函数调用开销。
使用场景:
- 函数体短小(通常 1-3 行),调用频繁。
- 类内定义的成员函数默认是 inline。
- 头文件中定义的函数需加 inline,避免多重定义错误。
注意事项:
- inline 只是建议:编译器可以忽略,函数体过大、有循环、递归、虚函数、取地址等情况通常不会内联。
- 定义必须可见:inline 函数的定义必须放在头文件中,每个编译单元都能看到。
- 不能和 virtual 共存:虚函数需要运行时绑定,一般不会内联(通过对象直接调用时可能内联)。
- 调试困难:内联后没有独立函数符号,断点可能不生效。
- 代码膨胀:过度内联会增大可执行文件体积,导致指令缓存命中率下降。
C++17 补充:inline 也可用于变量,解决头文件中全局变量的多重定义问题。
13. 虚函数表里存放的内容是什么时候写进去的?
虚函数表在编译期确定内容,在程序加载时写入内存(只读数据段)。
具体过程:
- 编译期:编译器分析每个类的虚函数,为每个有虚函数的类生成一张虚函数表,表中填入各虚函数的地址。
- 基类的 vtable 填基类虚函数地址。
- 派生类的 vtable:未重写的填基类地址,重写的填派生类地址,新增的虚函数追加在表后。
- 链接期:符号解析,确定虚函数的最终地址。
- 程序加载时:OS 将 vtable 加载到内存的只读数据段,之后不再修改。
vptr 的设置时机:
- 对象的 vptr 在构造函数执行时被设置为指向对应类的 vtable。
- 构造过程中,vptr 会随构造层次变化:基类构造时指向基类 vtable,派生类构造时指向派生类 vtable。
- 析构时反向变化。
14. 模板和实现可不可以不写在一个文件里?
可以,但需要特殊处理。默认情况下,模板的定义和实现必须放在同一个文件(通常是头文件)中,因为模板是编译期实例化的,编译器需要看到完整定义才能生成具体类型的代码。
分离的方法:
方法1:显式实例化 (Explicit Instantiation)
// template.h - 声明
template<typename T>
class MyClass {
public:
void func();
};
// template.cpp - 实现 + 显式实例化
#include "template.h"
template<typename T>
void MyClass<T>::func() { /* 实现 */ }
// 显式实例化只支持指定类型
template class MyClass<int>;
template class MyClass<double>;
缺点:只能用于已知类型,无法支持任意类型。
方法2:包含模型 (Inclusion Model) - 最常用
将实现写在头文件中,或在头文件末尾 #include "template_impl.hpp"。
方法3:C++11 extern template
在头文件中用 extern template class MyClass<int>; 阻止隐式实例化,在某个 cpp 中显式实例化。减少编译时间。
为什么默认不能分离:模板不是具体代码,是"代码生成蓝图",编译器在实例化时才生成具体代码,需要看到完整定义。
15. 假设有一个指针,如何做到多次使用,一次释放?
使用 shared_ptr 实现共享所有权,引用计数归零时自动释放。
#include <memory>
auto sp = std::make_shared<MyClass>();
// 多次使用,每次拷贝 shared_ptr,引用计数 +1
void use1(std::shared_ptr<MyClass> p) { /* 使用 */ }
void use2(std::shared_ptr<MyClass> p) { /* 使用 */ }
use1(sp); // 拷贝,引用计数 +1,函数结束后 -1
use2(sp); // 同上
// 所有 shared_ptr 都销毁时,引用计数为 0,自动释放
其他方案:
- 资源获取即初始化 (RAII):用类管理资源,构造时获取,析构时释放。
- 所有权转移:用 unique_ptr + move,明确唯一所有者。
- 引用计数自定义实现:自己管理引用计数(不推荐,shared_ptr 已实现)。
注意:
- shared_ptr 不是线程安全的(对象访问需加锁),但引用计数增减是原子的。
- 避免循环引用,必要时用 weak_ptr。
- 不要用同一个裸指针初始化多个 shared_ptr(会导致 double free),应用
shared_ptr拷贝。
16. 32 位/64 位系统具体指什么?对系统有何影响?
32 位/64 位指的是 CPU 的通用寄存器宽度和数据总线宽度,即一次能处理的数据位数。
| 对比项 | 32 位系统 | 64 位系统 |
|---|---|---|
| 指针大小 | 4 字节 | 8 字节 |
| 虚拟地址空间 | 4 GB (2^32) | 16 EB (2^64),实际受硬件限制 |
| 用户可用内存 | 约 3 GB(1 GB 给内核) | 理论上极大 |
| 通用寄存器 | 32 位 | 64 位 |
| 一次运算数据量 | 32 bit | 64 bit |
| long 类型 | 4 字节 (LP32/ILP32) | 8 字节 (LP64,Linux/Mac) |
对系统的影响:
- 内存寻址能力:64 位可支持远超 4GB 的内存,适合大内存需求(数据库、科学计算)。
- 性能:64 位寄存器可一次处理更多数据,部分运算更快;但指针变大导致内存占用增加、缓存压力增大。
- 兼容性:64 位系统通常可运行 32 位程序(需安装 32 位库),反之不行。
- 数据模型:注意
long在 Windows 64 位仍是 4 字节 (LLP64),在 Linux/Mac 是 8 字节 (LP64)。 - 文件大小:64 位原生支持大文件(>2GB),32 位需用
off64_t等扩展。
17. 虚拟内存和物理内存的区别?
| 区别 | 物理内存 | 虚拟内存 |
|---|---|---|
| 本质 | 实际的硬件 RAM | OS 抽象出的地址空间概念 |
| 容量 | 有限,由内存条决定 | 可远大于物理内存(借助磁盘) |
| 地址 | 物理地址,直接对应 RAM 单元 | 虚拟地址,每个进程独立 |
| 进程隔离 | 所有进程共享物理内存 | 每个进程有独立的虚拟地址空间,互相隔离 |
| 管理方式 | 由 OS 内存管理器分配物理页 | 通过页表映射虚拟页到物理页/磁盘 |
| 连续性 | 物理上可能不连续 | 进程看到的是连续的地址空间 |
虚拟内存的核心机制:
- 分页:将虚拟地址空间和物理内存都划分为固定大小的页(通常 4KB)。
- 页表:维护虚拟页到物理页的映射。
- 缺页中断:访问的页不在物理内存时,OS 将其从磁盘加载到内存。
- 页面置换:物理内存不足时,将不常用的页换出到磁盘(LRU 等算法)。
- TLB:快表,缓存页表项,加速地址翻译。
优势:进程隔离、安全保护、内存超卖(overcommit)、简化编程模型(每个进程认为自己独占内存)。
18. 面向过程和面向对象的区别?
| 区别 | 面向过程 (POP) | 面向对象 (OOP) |
|---|---|---|
| 核心思想 | 以函数/过程为中心,关注"怎么做" | 以对象为中心,关注"谁来做" |
| 基本单位 | 函数 | 类/对象 |
| 特性 | 无封装、继承、多态 | 封装、继承、多态 |
| 数据与行为 | 数据和操作分离 | 数据和行为封装在对象中 |
| 适用场景 | 算法密集、流程明确的小型程序 | 大型复杂系统、需求易变的项目 |
| 扩展性 | 差,修改需求可能改动大量函数 | 好,通过继承和多态扩展 |
| 代表语言 | C | C++、Java、Python |
面向对象三大特性:
- 封装:隐藏内部实现,对外提供接口,降低耦合。
- 继承:子类复用父类代码和接口,实现代码复用和层次结构。
- 多态:同一接口不同实现,运行时根据对象类型调用对应方法(动态多态)。
C++ 的特点:C++ 是多范式语言,同时支持面向过程、面向对象、泛型编程、函数式编程等,不强制使用 OOP。
19. 空类里有什么函数?
一个空类 class Empty {}; 中,编译器会隐式生成以下成员函数(在需要时才生成):
- 默认构造函数:
Empty() - 拷贝构造函数:
Empty(const Empty&) - 移动构造函数(C++11):
Empty(Empty&&) - 拷贝赋值运算符:
Empty& operator=(const Empty&) - 移动赋值运算符(C++11):
Empty& operator=(Empty&&) - 析构函数:
~Empty()
注意:
- 这些函数只有在被使用时才会被编译器生成(not declared until used)。
sizeof(Empty)= 1(空类大小不为 0,确保不同对象有不同地址)。- 如果用户声明了任何构造函数,编译器不再生成默认构造函数。
- C++11 可用
=default显式要求生成,=delete禁止生成。
20. public/private/protected 继承的关系及应用场景?
| 继承方式 | 基类 public 成员 | 基类 protected 成员 | 基类 private 成员 |
|---|---|---|---|
| public 继承 | 派生类中为 public | 派生类中为 protected | 不可直接访问 |
| protected 继承 | 派生类中为 protected | 派生类中为 protected | 不可直接访问 |
| private 继承 | 派生类中为 private | 派生类中为 private | 不可直接访问 |
应用场景:
-
public 继承(最常用):
- 表示 "is-a" 关系,派生类是基类的一种。
- 基类接口对外可见,符合里氏替换原则。
- 例:
class Dog : public Animal。
-
protected 继承:
- 基类的 public 成员在派生类中变为 protected,对外不可见,但对派生类的子类可见。
- 用于希望继承基类实现,但不暴露基类接口,且允许进一步派生的场景。
- 较少使用。
-
private 继承:
- 表示 "根据...实现"(is-implemented-in-terms-of),不是 is-a 关系。
- 基类所有成员在派生类中变为 private,完全隐藏。
- 用于复用基类实现但不继承接口的场景。
- 通常优先用组合(包含一个基类对象)替代 private 继承,因为组合更灵活、耦合更低。
// private 继承 vs 组合
class Timer { public: void start(); };
// private 继承
class Widget : private Timer { /* 复用 Timer 实现 */ };
// 组合(推荐)
class Widget {
private:
Timer timer_; // 包含一个 Timer 对象
};
21. string 的赋值操作是深拷贝还是浅拷贝?
深拷贝。std::string 的赋值运算符会分配新的内存并拷贝字符串内容,两个 string 对象拥有独立的内存。
std::string s1 = "hello";
std::string s2 = s1; // 深拷贝,s2 有自己的内存
s2[0] = 'H'; // 修改 s2 不影响 s1
cout << s1; // "hello"
cout << s2; // "Hello"
注意:
- 历史上某些标准库实现过写时复制 (COW, Copy-On-Write),即拷贝时先共享内存,修改时才深拷贝。但 C++11 标准后,COW 实现因线程安全问题被禁止(至少 libstdc++ 和 libc++ 都不再使用 COW)。
std::string内部有 SSO(小字符串优化),短字符串直接存在对象内部,连堆分配都不需要。- C++17 引入
std::string_view,是浅拷贝(只拷贝指针和长度,不拥有数据),用于只读视图,需注意源字符串的生命周期。
22. 什么时候重载赋值运算符与拷贝构造函数?
需要自定义拷贝构造函数和赋值运算符的情况:类中包含指针成员或管理动态分配的资源(如文件句柄、网络连接、互斥锁等),且需要深拷贝语义时。
规则三 (Rule of Three):如果需要自定义析构函数、拷贝构造函数、拷贝赋值运算符中的任何一个,通常三个都需要自定义。
规则五 (Rule of Five, C++11):在规则三基础上,加上移动构造函数和移动赋值运算符。
class Resource {
int* data_;
public:
Resource() : data_(new int[100]) {}
~Resource() { delete[] data_; } // 1. 自定义析构
Resource(const Resource& other) // 2. 拷贝构造
: data_(new int[100]) {
std::copy(other.data_, other.data_+100, data_);
}
Resource& operator=(const Resource& other) { // 3. 拷贝赋值
if (this != &other) {
delete[] data_;
data_ = new int[100];
std::copy(other.data_, other.data_+100, data_);
}
return *this;
}
Resource(Resource&& other) noexcept // 4. 移动构造
: data_(other.data_) {
other.data_ = nullptr;
}
Resource& operator=(Resource&& other) noexcept { // 5. 移动赋值
if (this != &other) {
delete[] data_;
data_ = other.data_;
other.data_ = nullptr;
}
return *this;
}
};
不需要自定义的情况:类的成员都是值类型(如 int、double、std::string、智能指针),编译器默认生成的浅拷贝就足够(因为这些成员自己管理资源)。
零规则 (Rule of Zero):优先使用智能指针和标准容器,让它们管理资源,自己不需要写任何特殊成员函数。
23. 什么地方需要用到拷贝构造函数?
拷贝构造函数在以下场景被自动调用:
-
用一个对象初始化另一个新对象:
A a1; A a2 = a1; // 拷贝构造(注意:不是赋值!) A a3(a1); // 拷贝构造 A a4 = A(a1); // 拷贝构造(可能被优化) -
函数参数按值传递:
void func(A a); // 形参 a 是实参的拷贝 func(a1); // 调用拷贝构造 -
函数返回值按值返回(未被 RVO/NRVO 优化时):
A func() { A a; return a; // 用 a 拷贝构造返回值临时对象 } -
异常处理:按值抛出异常对象、按值捕获异常对象时。
-
STL 容器操作:如
vector.push_back()、容器扩容时元素拷贝、insert等。
注意:
A a2 = a1;是拷贝构造,a2 = a1;(a2 已存在)是赋值运算符。- C++11 起,移动构造函数在右值场景下优先于拷贝构造。
- 拷贝构造函数的参数必须是 const 引用,否则会无限递归。
24. sizeof(A) 是多少?(空类/含虚函数类等)
需要根据类的具体成员来计算,遵循内存对齐规则:
常见情况:
// 1. 空类
class Empty {};
sizeof(Empty); // 1(空类大小不为0,确保不同对象地址不同)
// 2. 只有一个 int 成员
class A { int x; };
sizeof(A); // 4
// 3. 有虚函数
class B { virtual void func(); };
sizeof(B); // 8(64位)/ 4(32位),只有 vptr,没有成员变量
// 4. 有成员变量和虚函数
class C {
int x; // 4
virtual void func(); // vptr 8
};
sizeof(C); // 16(vptr 8 + int 4 + 对齐填充 4)
// 5. 继承
class D : public C {
int y; // 4
};
sizeof(D); // 16(C 的 16 + int 4 → 对齐到 16?实际是 24,取决于布局)
内存对齐规则:
- 每个成员的偏移量必须是其自身大小的整数倍。
- 结构体总大小必须是最大成员大小的整数倍。
- 可通过
#pragma pack(n)或alignas修改对齐方式。
影响 sizeof 的因素:
- 非静态成员变量(按声明顺序排列,受对齐影响)。
- 虚函数表指针 vptr(有虚函数时)。
- 虚基类表指针 vbtbl(虚继承时)。
- 对齐填充字节。
- 静态成员、成员函数不占对象大小。
💡 低频题(9题)
较少出现或较偏,时间充裕时了解即可。
1. 什么时候分配内存会产生内存碎片?
内存碎片分为内部碎片和外部碎片,主要产生场景:
- 频繁动态分配/释放小块内存:释放的内存块大小各异、位置分散,无法有效合并成连续大块,形成外部碎片。
- 内存分配算法的对齐需求:为满足内存对齐,分配比实际需要更大的块,导致内部碎片。
- 多进程/多线程并发分配:不同执行流交错申请释放,使空闲块分散。
- 长期运行程序:长时间运行后,小块分配释放累积,碎片问题加剧。
缓解方法:内存池、对象池、减少小块频繁分配、使用 slab 分配器等。
2. 负数的编码方式是什么?简述原理。
计算机中负数采用补码表示。
原理:
- 最高位为符号位,0 表示正,1 表示负。
- 正数的原码、反码、补码相同。
- 负数补码 = 绝对值的原码 → 除符号位外取反(反码)→ 加 1。
- 补码的本质是模运算:在有限位编码中,
x - y = x + (-y的补码),减法可统一为加法。
优势:统一加减法运算、0 的表示唯一(原码中 +0 和 -0 不同)、简化硬件电路设计。
3. string 的 SSO 模式?
SSO (Small String Optimization,小字符串优化):当字符串较短时,直接将数据存储在 string 对象内部的栈空间,避免堆分配。
原理:
std::string对象通常包含指针、大小、容量三个成员(共 24 字节,64 位)。- 当字符串长度 ≤ 某个阈值(通常 15 字节)时,复用内部缓冲区存储数据,不调用
new。 - 超过阈值时,才在堆上分配内存。
优势:
- 短字符串无需堆分配,减少内存碎片和分配开销。
- 提高缓存命中率,访问更快。
不同实现:
- libstdc++ (GCC):阈值 15 字节。
- libc++ (Clang):阈值 22 字节(用更紧凑的表示)。
- MSVC:阈值 15 字节。
4. 动态编译与静态编译?
| 区别 | 静态编译 | 动态编译 |
|---|---|---|
| 链接方式 | 静态链接,库代码嵌入可执行文件 | 动态链接,运行时加载共享库 |
| 可执行文件大小 | 大 | 小 |
| 运行依赖 | 无依赖,可直接运行 | 依赖 .so/.dll 文件 |
| 内存占用 | 每个进程一份库代码 | 多进程共享库代码(共享内存) |
| 升级库 | 需重新编译程序 | 替换库文件即可 |
| 启动速度 | 快(无需加载库) | 稍慢(需解析符号) |
| 兼容性 | 好(自包含) | 需确保库版本兼容 |
静态编译命令:
g++ -static main.cpp -o app # 静态链接所有库
动态编译命令(默认):
g++ main.cpp -o app -lmylib # 动态链接 libmylib.so
实际选择:发布独立工具用静态编译;系统级库用动态编译节省资源。
5. 类中静态函数占用内存吗?
不占用对象的内存。静态成员函数属于类,不属于任何对象,没有 this 指针。
内存分布:
- 静态成员变量:存储在全局/静态区,所有对象共享一份,不计入 sizeof(类)。
- 静态成员函数:代码存储在程序代码区,和普通函数一样只有一份,不计入对象大小。
- 非静态成员函数:代码也只有一份,通过 this 指针区分不同对象,也不计入对象大小。
class A {
int x; // 4 字节
static int y; // 不在对象中
void func() {} // 不在对象中
static void s_func() {} // 不在对象中
};
sizeof(A); // 4(只有非静态成员变量 x,可能有对齐填充)
对象内存只包含:非静态成员变量 + 虚函数表指针(如有虚函数)+ 对齐填充。
6. 动态指针的判空?
动态指针(new 出来的指针)判空需要注意:
C++ new 的行为:
- 默认
new失败抛std::bad_alloc异常,不会返回 nullptr,所以if (p == nullptr)在异常模式下永远为真(除非用了 nothrow)。 new (std::nothrow)失败返回 nullptr,需要判空。
// 方式1:异常模式(默认)
try {
int* p = new int[1000000];
// p 一定非空
} catch (const std::bad_alloc& e) {
// 分配失败处理
}
// 方式2:nothrow 模式
int* p = new (std::nothrow) int[1000000];
if (p == nullptr) {
// 分配失败
}
其他判空场景:
- 函数返回动态指针时,调用方应判空。
malloc返回的指针必须判空(失败返回 NULL)。- 智能指针
unique_ptr/shared_ptr可用if (ptr)或ptr != nullptr判空。 - 避免野指针:delete 后应置为 nullptr,否则判空无意义。
7. 编译器的优化级别及基本优化思想?
GCC/Clang 优化级别:
| 级别 | 说明 |
|---|---|
| -O0 | 不优化(默认),编译最快,调试信息最准确 |
| -O1 | 基本优化:删除未使用代码、常量折叠、分支优化,不显著增加编译时间 |
| -O2 | 推荐的发布级别:在 O1 基础上增加指令调度、循环优化、内联小函数等,几乎不增加代码体积 |
| -O3 | 最高级别:在 O2 基础上增加向量化、循环展开、更激进的内联,可能增大代码体积,极少数情况可能有精度/行为差异 |
| -Os | 优化代码大小,适合嵌入式/受限环境 |
| -Ofast | O3 + 不严格遵守 IEEE 浮点标准,可能改变浮点结果 |
基本优化思想:
- 常量传播与折叠:编译期计算常量表达式,直接替换结果。
- 死代码消除:删除不可达或无副作用的代码。
- 公共子表达式消除 (CSE):重复计算的表达式只算一次。
- 循环优化:循环展开、循环不变量外提、循环合并、循环分块。
- 函数内联:将小函数体展开到调用点,消除调用开销。
- 向量化 (SIMD):将标量循环转换为 SIMD 指令,并行处理多个数据。
- 指令调度:重排指令减少流水线停顿和数据依赖。
- 寄存器分配:尽量将常用变量放在寄存器,减少内存访问。
- 缓存优化:改进数据访问模式和布局,提高缓存命中率。
- 尾调用优化:将尾递归转为循环,避免栈溢出。
注意:优化可能改变程序行为(如浮点精度、未定义行为的表现),调试时用 -O0,发布时用 -O2。
8. A 继承 B、C 两个空类,对 A 强转成 B、C,地址空间有什么变化?
这涉及多重继承中的对象布局和指针调整。
class B {}; // 空类,sizeof(B) = 1
class C {}; // 空类,sizeof(C) = 1
class A : public B, public C {};
对象布局(典型实现):
A 对象内存:
┌──────────┐ ← this (A*) 同时也是 B 子对象地址
│ B 子对象 │
├──────────┤ ← C 子对象地址(可能有偏移)
│ C 子对象 │
└──────────┘
指针转换时的地址变化:
A* → B*:地址不变(B 是第一个基类,B 子对象在 A 对象起始位置)。A* → C*:地址增加一个偏移量(C 子对象在 B 子对象之后),编译器自动调整指针。
A a;
A* pa = &a;
B* pb = static_cast<B*>(pa); // pb == pa,地址不变
C* pc = static_cast<C*>(pa); // pc = pa + sizeof(B),地址增加
反向转换:
B* → A*:如果 B 是第一个基类,地址不变;否则需要调整。C* → A*:地址减去偏移量。dynamic_cast可在运行时安全处理这些转换(需要虚函数支持)。
空基类优化 (EBO):如果基类为空,编译器可能将其大小优化为 0(不占空间),此时偏移可能为 0。但多个空基类仍需区分地址。
9. 如果有一块地址空间,怎么在这个地址空间内调用构造函数?
使用定位 new (placement new),在已分配的内存上调用构造函数。
#include <new> // placement new
// 假设有一块已分配的内存
void* memory = malloc(sizeof(MyClass));
// 在指定地址上构造对象(不分配新内存,只调用构造函数)
MyClass* obj = new (memory) MyClass(constructor_args);
// 使用对象...
obj->doSomething();
// 销毁:显式调用析构函数(不要用 delete!因为内存不是 new 分配的)
obj->~MyClass();
// 释放内存(根据分配方式)
free(memory);
关键点:
- placement new 语法:
new (address) Type(args),在 address 指向的已分配内存上构造对象。 - 必须显式调用析构函数:
obj->~MyClass();,因为delete会同时释放内存,而内存不是 new 分配的。 - 内存对齐:确保传入的地址满足类型的对齐要求,否则是未定义行为。
- C++17 替代:
std::construct_at(更安全的 placement new 封装)和std::destroy_at。
应用场景:内存池、对象池、自定义分配器、在共享内存中构造对象等。
二、STL
🔥 高频题(5题)
面试中最常出现,核心知识点,建议重点掌握,能熟练默写。
1. vector 底层实现?
std::vector 底层是动态数组,在堆上分配一段连续内存。
核心成员(典型实现):
_M_start:指向数组起始位置_M_finish:指向已使用元素的末尾(size 位置)_M_end_of_storage:指向分配内存的末尾(capacity 位置)
内存布局:
┌───┬───┬───┬───┬───┬───┬───┬───┐
│ 0 │ 1 │ 2 │ 3 │ │ │ │ │
└───┴───┴───┴───┴───┴───┴───┴───┘
↑ ↑ ↑
start finish end_of_storage
size = 4 capacity = 8
关键特性:
- 动态扩容:当 size == capacity 时,重新分配更大的内存(通常 1.5 倍或 2 倍),将旧元素拷贝/移动到新内存,释放旧内存。
- 随机访问 O(1):连续内存,支持
[]和at()。 - 尾端插入 O(1) 均摊:
push_back不扩容时 O(1),扩容时 O(n),均摊 O(1)。 - 中间插入/删除 O(n):需要移动元素。
- 迭代器失效:扩容会导致所有迭代器失效;插入/删除点之后的迭代器失效。
vs 数组:vector 自动管理内存,可动态增长;数组大小固定。
2. vector、list 在添加删除的效率方面有什么不同?
| 操作 | vector | list (双向链表) |
|---|---|---|
| 尾部插入 | O(1) 均摊(扩容时 O(n)) | O(1) |
| 头部插入 | O(n)(移动所有元素) | O(1) |
| 中间插入 | O(n)(移动插入点之后的元素) | O(1)(已知迭代器位置) |
| 尾部删除 | O(1) | O(1) |
| 头部删除 | O(n) | O(1) |
| 中间删除 | O(n) | O(1)(已知迭代器位置) |
| 随机访问 | O(1) | O(n)(需遍历) |
| 查找 | O(n)(可二分查找 O(log n)) | O(n) |
| 内存连续性 | 连续,缓存友好 | 不连续,节点分散,缓存不友好 |
关键区别:
- vector 在已知位置插入/删除需要移动元素,但随机访问快。
- list 在已知迭代器位置插入/删除是 O(1),但找到位置需要 O(n) 遍历。
- vector 连续内存对 CPU 缓存友好,遍历速度远快于 list。
实际选择:
- 大多数场景用 vector(即使有中间插入,缓存优势可能弥补移动开销)。
- 只有在频繁中间插入/删除且位置已知时才考虑 list。
- C++11 后,
std::list使用场景越来越少,Bjarne Stroustrup 也建议优先用 vector。
3. vector 迭代器失效的情况有哪些?
迭代器失效指迭代器不再指向有效的元素,使用失效迭代器是未定义行为。
| 操作 | 失效的迭代器 |
|---|---|
| push_back / emplace_back | 若触发扩容,所有迭代器、引用、指针失效;未扩容则仅尾后迭代器失效 |
| insert / emplace | 若触发扩容,全部失效;未扩容则插入点及之后的迭代器失效 |
| erase | 被删除元素及之后的迭代器失效(erase 返回下一个有效迭代器) |
| pop_back | 尾元素迭代器和尾后迭代器失效 |
| clear | 所有迭代器失效 |
| resize | 若缩小,被删元素及之后失效;若扩大且触发扩容,全部失效 |
| reserve | 若容量增大,全部失效;容量不变则不失效 |
| shrink_to_fit | 若实际收缩,全部失效 |
| swap | 两个 vector 的迭代器互换(仍有效,但指向对方容器) |
正确的遍历删除方式:
// 使用 erase 返回值
for (auto it = vec.begin(); it != vec.end(); ) {
if (condition(*it)) {
it = vec.erase(it);
} else {
++it;
}
}
// C++20
std::erase_if(vec, condition);
范围 for 循环中删除元素会导致迭代器失效,不要在范围 for 中 erase。
4. map 和 unordered_map 的区别?
| 区别 | map | unordered_map |
|---|---|---|
| 底层实现 | 红黑树 | 哈希表(数组 + 链表/红黑树) |
| 元素顺序 | 按键有序排列(升序) | 无序 |
| 查找 | O(log n) | O(1) 平均,O(n) 最坏 |
| 插入 | O(log n) | O(1) 平均 |
| 删除 | O(log n) | O(1) 平均 |
| 内存占用 | 较小(每个节点含颜色、指针等) | 较大(哈希表数组 + 节点) |
| 键的要求 | 需定义 operator< 或比较器 | 需定义 std::hash 特化和 operator== |
| 迭代器类型 | 双向迭代器 | 前向迭代器 |
| 迭代器稳定性 | 插入删除不影响其他迭代器 | rehash 时所有迭代器失效 |
| 适用场景 | 需要有序遍历、范围查询 | 频繁查找、不需要顺序 |
unordered_map 底层:
- 一个桶数组(bucket array),每个桶是一个链表(或 C++11 后链表过长时转红黑树)。
- 通过哈希函数将键映射到桶索引。
- 负载因子 = 元素数 / 桶数,超过阈值(默认 1.0)时 rehash(扩容并重新哈希)。
选择建议:
- 需要有序或范围查询用 map。
- 追求查找速度、键可哈希用 unordered_map。
- 自定义类型作为键时,map 只需定义比较器,unordered_map 需同时定义哈希和相等比较。
5. vector、list、map 在内存中的数据结构有什么区别?
| 容器 | 底层数据结构 | 内存布局 |
|---|---|---|
| vector | 动态数组 | 一段连续的堆内存,元素紧密排列 |
| list | 双向链表 | 节点分散在堆内存中,每个节点含数据 + 前后指针 |
| map | 红黑树 | 节点分散在堆内存中,每个节点含数据 + 颜色 + 左右子指针 + 父指针 |
内存布局示意:
vector: [1][2][3][4][5][ ][ ][ ] 连续内存
list: [1]↔[3]↔[5]↔[2]↔[4] 节点分散,指针相连
↑ 节点在内存中可能不连续
map: [3]
/ \
[1] [5]
\ /
[2][4] 树节点分散,父子兄弟指针相连
内存开销对比(64 位系统,存 int):
- vector:每个元素 4 字节 + 整体 3 个指针(24 字节管理开销)。
- list:每个节点 4 + 16(前后指针)= 20 字节 → 对齐到 24 字节。
- map:每个节点 4 + 颜色(4) + 3 指针(24) = 32 字节。
对性能的影响:
- vector 连续内存,CPU 缓存命中率高,遍历极快。
- list/map 节点分散,缓存不友好,遍历慢(每次访问可能缓存未命中)。
- 这也是为什么即使有中间插入,vector 有时仍比 list 快的原因。
📌 中频题(7题)
比较常见,部分公司会问到,建议理解并能口述要点。
1. STL 包括哪些内容?
STL (Standard Template Library,标准模板库) 由六大组件组成:
| 组件 | 说明 | 示例 |
|---|---|---|
| 容器 (Containers) | 存储数据的数据结构 | vector、list、deque、map、set、unordered_map |
| 算法 (Algorithms) | 操作容器的通用算法 | sort、find、copy、for_each、accumulate |
| 迭代器 (Iterators) | 连接容器和算法的桥梁 | begin()、end()、反向迭代器、插入迭代器 |
| 仿函数 (Functors) | 重载 operator() 的类对象 | greater、less、plus、自定义比较器 |
| 适配器 (Adapters) | 改造容器/迭代器/仿函数的接口 | stack、queue、priority_queue、reverse_iterator |
| 空间配置器 (Allocators) | 内存分配与释放 | std::allocator、自定义分配器 |
核心思想:将数据结构和算法分离,通过迭代器连接,实现高度复用。算法不关心容器类型,只要迭代器满足要求即可。
2. vector 和 deque 的区别?
| 区别 | vector | deque |
|---|---|---|
| 底层结构 | 单块连续动态数组 | 多块连续缓冲区(分段连续),由中控数组管理 |
| 内存布局 | 完全连续 | 分段连续,内部不保证完全连续 |
| 随机访问 | O(1),极快 | O(1),但需两次寻址(先找缓冲区,再找元素),稍慢 |
| 头部插入/删除 | O(n)(需移动所有元素) | O(1) |
| 尾部插入/删除 | O(1) 均摊 | O(1) 均摊 |
| 中间插入/删除 | O(n) | O(n)(比 vector 稍快,只需移动一段) |
| 扩容 | 重新分配整块内存,拷贝所有元素 | 只需新增缓冲区,不移动现有元素 |
| 迭代器失效 | 扩容全部失效 | 插入/删除可能使部分迭代器失效 |
| 内存释放 | 需 shrink_to_fit 或 swap 技巧 | 可自动释放前端缓冲区 |
| 适用场景 | 频繁随机访问、尾端操作 | 频繁头尾操作、双端队列 |
deque 底层:由一个 map(中控数组,存储缓冲区指针)和多个固定大小的缓冲区(通常 512 字节)组成。头部可向前扩展,尾部可向后扩展。
使用建议:
- 需要高效随机访问用 vector。
- 需要频繁在头尾插入删除用 deque。
- stack 和 queue 默认底层容器是 deque。
3. 释放 vector 内存的处理方式?
vector 析构时自动释放内存,但在以下场景需要手动处理:
1. 清空元素但不释放内存:
vec.clear(); // size 变为 0,capacity 不变,内存不释放
2. 释放多余容量(收缩到 fit):
// C++11 方式
vec.shrink_to_fit(); // 请求释放多余容量,不保证一定执行
// 通用方式(swap 技巧)
std::vector<int>(vec).swap(vec); // 用拷贝构造的临时 vector 交换,临时对象析构释放旧内存
3. 彻底释放内存:
// 方式1:空 vector 交换
std::vector<int>().swap(vec);
// 方式2:先 clear 再 shrink
vec.clear();
vec.shrink_to_fit();
// 方式3:直接让 vector 离开作用域,析构自动释放
注意:
clear()只析构元素,不释放内存(capacity 不变)。resize(0)也只改变 size,不释放内存。- vector 的内存只能在 vector 析构或 swap/shrink 时释放,不能单独释放某个元素的内存。
- 如果 vector 存储的是指针,需要先 delete 每个指针再清空 vector,否则内存泄漏。
4. unordered_map 和 unordered_set 的区别?
| 区别 | unordered_map | unordered_set |
|---|---|---|
| 存储内容 | 键值对 (key-value) | 只有键 (key) |
| 元素类型 | std::pair<const Key, Value> | Key |
| 用途 | 关联数组,通过键查找值 | 集合,判断元素是否存在、去重 |
| 插入 | insert({key, value}) / emplace(key, value) / [] | insert(value) |
| 查找 | find(key) 返回指向 pair 的迭代器 | find(key) 返回指向元素的迭代器 |
| operator[] | 支持(键不存在时插入默认值) | 不支持 |
| at() | 支持 | 不支持 |
| 底层 | 哈希表 | 哈希表 |
| 复杂度 | O(1) 平均 | O(1) 平均 |
共同点:
- 都是无序关联容器。
- 底层都是哈希表。
- 键必须可哈希(
std::hash)且可比较相等(operator==)。 - 不允许重复键(multi 版本允许)。
使用场景:
- unordered_map:字典、缓存、计数。
- unordered_set:去重、存在性检查、集合运算。
5. clear 和 erase 的区别?
| 区别 | clear | erase |
|---|---|---|
| 功能 | 删除容器中所有元素 | 删除单个元素或范围内的元素 |
| 参数 | 无参数 | 迭代器(单个)或迭代器范围 |
| 返回值 | void | 指向被删元素下一个位置的迭代器 |
| size 变化 | 变为 0 | 减少删除的数量 |
| capacity | 不变(内存不释放) | 不变 |
| 迭代器失效 | 所有迭代器失效 | 被删元素及之后失效(vector/deque),仅被删元素失效(list/map/set) |
// clear:删除所有
vec.clear();
map.clear();
// erase:删除单个
vec.erase(vec.begin() + 5);
map.erase(key); // 关联容器可按键删除
auto it = map.find(key);
if (it != map.end()) map.erase(it);
// erase:删除范围 [first, last)
vec.erase(vec.begin(), vec.begin() + 3);
注意:
clear()等价于erase(begin(), end())。- 两者都不释放容器内存(capacity 不变),需用
shrink_to_fit或 swap 释放。 - 遍历中删除元素必须用 erase 的返回值更新迭代器,不能用失效迭代器自增。
6. swap 函数的作用?
std::swap 用于交换两个对象的值。
基本用法:
int a = 1, b = 2;
std::swap(a, b); // a=2, b=1
std::vector<int> v1 = {1,2,3}, v2 = {4,5,6};
v1.swap(v2); // 成员函数版本,O(1) 交换内部指针
std::swap(v1, v2); // 通用版本,通常调用成员 swap
容器 swap 的特点:
- 对于 vector、deque、map、set 等容器,swap 是 O(1) 的,只交换内部指针/句柄,不交换元素。
- 迭代器在 swap 后仍然有效,但指向对方容器。
- 对于 array,swap 是 O(n) 的(需要逐个交换元素)。
常见应用:
- 释放 vector 内存:
std::vector<int>().swap(vec); // 与空 vector 交换,释放内存 std::vector<int>(vec).swap(vec); // 收缩到 fit - 实现拷贝赋值运算符(Copy-and-Swap 惯用法):
T& operator=(T other) { // 按值传参,自动拷贝 swap(*this, other); return *this; } // other 析构释放旧资源 - pimpl 惯用法中交换实现指针。
- 排序算法中交换元素。
自定义类型:应为自定义类型提供 swap 成员函数或 std::swap 特化,避免默认的三次拷贝(尤其是管理资源的类)。
7. erase 的返回值?
erase 的返回值是指向被删除元素的下一个元素的迭代器(C++11 起,之前是 void)。
vector / string / deque:
iterator erase(iterator pos);
iterator erase(iterator first, iterator last);
// 返回指向被删元素下一个位置的迭代器(可能是 end())
list / map / set / unordered_map:
iterator erase(iterator pos); // 返回下一个迭代器
size_type erase(const key_type& key); // 按键删除,返回删除的数量
iterator erase(iterator first, iterator last); // 范围删除
为什么需要返回值:erase 后,被删元素的迭代器失效,需要用返回值获取下一个有效迭代器继续遍历。
正确用法:
// 遍历删除(正确)
for (auto it = vec.begin(); it != vec.end(); ) {
if (should_delete(*it)) {
it = vec.erase(it); // 更新迭代器
} else {
++it;
}
}
// map 按键删除
int n = m.erase(key); // n 为删除的元素个数(0 或 1)
// 范围删除
vec.erase(vec.begin() + 2, vec.begin() + 5); // 删除索引 2,3,4
C++20 补充:std::erase 和 std::erase_if 非成员函数,更简洁:
std::erase(vec, value); // 删除所有等于 value 的元素
std::erase_if(vec, pred); // 删除满足谓词的元素
💡 低频题(1题)
较少出现或较偏,时间充裕时了解即可。
1. 自己实现 unordered_map 会考虑什么问题?
-
哈希函数设计:
- 选择好的哈希函数,减少碰撞(如 MurmurHash、CityHash)。
- 对自定义类型提供哈希特化。
- 处理哈希洪水攻击(可能需要随机化哈希种子)。
-
冲突处理:
- 链地址法(separate chaining):每个桶挂链表,最常用。
- 开放寻址法(open addressing):线性探测、二次探测、双重哈希。
- 链表过长时转红黑树(C++11 unordered_map 的优化)。
-
扩容与 rehash:
- 负载因子阈值(通常 0.75 或 1.0)。
- 扩容时桶数通常翻倍(或取质数)。
- rehash 过程:重新计算所有元素的桶索引,迁移到新表。
- 渐进式 rehash(Redis 方式):避免一次性迁移阻塞。
-
桶数组管理:
- 初始桶数(通常 8 或 16)。
- 桶数取质数可减少碰撞(但现代实现常用 2 的幂,用位运算代替取模)。
-
迭代器实现:
- 需跟踪当前桶和桶内链表位置。
- rehash 后迭代器失效。
-
内存管理:
- 节点分配(可使用内存池)。
- 析构时正确释放所有节点和桶数组。
-
线程安全:
- STL 容器非线程安全,并发访问需外部加锁。
- 读可并发,写需独占。
-
接口设计:
insert、erase、find、operator[]、at、clear。bucket_count、load_factor、rehash、reserve。
三、Linux
🔥 高频题(22题)
面试中最常出现,核心知识点,建议重点掌握,能熟练默写。
1. 内存泄漏怎么检查,怎么避免?
检查工具:
1. Valgrind (memcheck):
valgrind --tool=memcheck --leak-check=full --show-leak-kinds=all ./program
- 最常用,能检测内存泄漏、越界访问、use-after-free、未初始化变量。
- 缺点:程序运行慢 10-50 倍。
2. AddressSanitizer (ASan):
g++ -fsanitize=address -g -O1 main.cpp -o program
./program
- 编译时插入检测代码,运行速度比 Valgrind 快(慢约 2 倍)。
- 能检测越界、use-after-free、double-free、内存泄漏(需
detect_leaks=1)。
3. LeakSanitizer (LSan):
g++ -fsanitize=leak -g main.cpp -o program
- 专门检测内存泄漏,开销很小。
4. mtrace:
- glibc 内置,
mtrace()跟踪 malloc/free,生成日志后用mtrace命令分析。
5. 其他工具:
massif(Valgrind 工具,内存使用分析)heaptrack、Intel Inspector、Purify
避免内存泄漏的方法:
- 使用智能指针:
std::unique_ptr、std::shared_ptr,RAII 自动释放。 - 使用标准容器:
std::vector、std::string自动管理内存。 - RAII 惯用法:资源在构造时获取,析构时释放。
- 配对检查:malloc/free、new/delete、new[]/delete[] 配对使用。
- 代码审查:关注异常路径、提前 return、goto 等是否遗漏释放。
- 静态分析:cppcheck、clang-tidy 等工具提前发现。
- 避免裸指针:尽量用智能指针或引用代替裸指针。
- 异常安全:用 RAII 确保异常抛出时资源也能释放。
2. 多进程和多线程的区别?
| 区别 | 多进程 | 多线程 |
|---|---|---|
| 定义 | 多个独立的进程 | 一个进程内的多个执行流 |
| 地址空间 | 各自独立,互不共享 | 共享进程的地址空间 |
| 资源 | 各自拥有独立资源(文件描述符、内存等) | 共享进程资源(堆、全局变量、文件描述符) |
| 通信方式 | IPC:管道、消息队列、共享内存、信号量、Socket | 直接读写共享变量,需同步机制 |
| 创建/切换开销 | 大(需分配独立地址空间、复制资源) | 小(共享地址空间,只需保存寄存器和栈) |
| 稳定性 | 高(一个进程崩溃不影响其他进程) | 低(一个线程崩溃可能导致整个进程崩溃) |
| 数据共享 | 需 IPC,复杂但安全 | 直接共享,简单但需同步 |
| CPU 利用 | 可利用多核 | 可利用多核 |
| 编程复杂度 | 较低(进程隔离) | 较高(需处理竞态、死锁) |
| 适用场景 | 需要隔离、稳定性要求高、任务独立 | 需要频繁共享数据、任务紧密协作 |
进程和线程的关系:
- 进程是资源分配的最小单位,线程是 CPU 调度的最小单位。
- 一个进程至少有一个线程(主线程)。
- 线程有自己的栈、寄存器、程序计数器,但共享进程的堆、代码段、数据段。
选择建议:
- 任务独立、需要隔离 → 多进程。
- 任务紧密、频繁共享数据 → 多线程。
- 现代 C++ 推荐用
std::thread,配合std::mutex、std::condition_variable同步。
3. 进程间通信方式有哪些?
| 方式 | 特点 | 适用场景 |
|---|---|---|
| 管道 (Pipe) | 半双工,单向数据流,父子进程间 | 简单数据传递,如 shell 管道 | |
| 命名管道 (FIFO) | 半双工,有文件路径,可用于无关进程 | 无亲缘关系进程间通信 |
| 消息队列 (Message Queue) | 消息链表,有类型,按优先级读取 | 结构化消息传递 |
| 共享内存 (Shared Memory) | 最快的 IPC,直接映射同一块物理内存 | 大量数据高速传输,需配合信号量同步 |
| 信号量 (Semaphore) | 计数器,用于进程间同步和互斥 | 保护共享资源、生产者消费者 |
| 信号 (Signal) | 异步通知,如 SIGKILL、SIGTERM | 简单事件通知、进程控制 |
| 套接字 (Socket) | 全双工,可跨机器,基于网络协议 | 网络通信、跨主机 IPC |
| 内存映射 (mmap) | 映射同一文件到多个进程地址空间 | 共享内存的一种实现 |
对比:
- 速度:共享内存 > 内存映射 > 消息队列 > 管道 > Socket
- 是否需要内核拷贝:共享内存/mmap 不需要(直接读写),其他需要内核态拷贝。
- 同步:共享内存/mmap 需要额外同步机制(信号量/互斥锁),消息队列/管道自带同步。
POSIX vs System V:
- 消息队列、信号量、共享内存都有 POSIX 和 System V 两套 API。
- 推荐用 POSIX 版本(更现代、接口更好)。
4. 什么是同步、异步?
同步 (Synchronous):
- 调用方发出请求后,阻塞等待结果返回,才能继续执行。
- 调用顺序和执行顺序一致。
- 例子:普通函数调用、
read()阻塞读、join()等待线程。
int result = calculate(); // 阻塞等待 calculate 完成
print(result); // 拿到结果后才继续
异步 (Asynchronous):
- 调用方发出请求后,不等待结果,立即返回继续执行。
- 结果通过回调、通知、future 等方式稍后获取。
- 例子:
std::async、IOCP、epoll、JavaScript 回调。
std::future<int> f = std::async(std::launch::async, calculate);
doOtherWork(); // 不等待,继续做别的
int result = f.get(); // 需要结果时再获取(可能阻塞)
对比:
| 区别 | 同步 | 异步 |
|---|---|---|
| 调用方状态 | 阻塞等待 | 立即返回,继续执行 |
| 结果获取 | 调用返回即结果 | 回调/通知/future |
| 编程模型 | 简单、顺序 | 复杂、事件驱动 |
| 资源利用 | 等待时占用线程 | 等待时可做其他事 |
| 调试 | 容易 | 较难(执行顺序不直观) |
相关概念:
- 阻塞/非阻塞:描述调用方是否等待,关注调用方状态。
- 同步/异步:描述结果如何通知,关注通信机制。
- 同步可以是阻塞的也可以是非阻塞的(如非阻塞 read 轮询)。
- 异步一定是非阻塞的。
5. 对多线程的理解?生产者-消费者问题?
多线程:一个进程内创建多个执行流,共享进程地址空间,通过 OS 调度在多核上并行执行,提高 CPU 利用率和程序响应性。
生产者-消费者问题 (Producer-Consumer):经典的多线程同步问题,也叫有界缓冲区问题。
问题描述:
- 生产者线程生产数据,放入缓冲区。
- 消费者线程从缓冲区取出数据消费。
- 缓冲区大小有限。
- 生产者不能在缓冲区满时放入,消费者不能在缓冲区空时取出。
C++11 实现:
#include <queue>
#include <mutex>
#include <condition_variable>
template<typename T>
class BoundedBuffer {
std::queue<T> queue_;
std::mutex mutex_;
std::condition_variable not_full_; // 缓冲区不满,生产者可放
std::condition_variable not_empty_; // 缓冲区不空,消费者可取
size_t capacity_;
public:
BoundedBuffer(size_t cap) : capacity_(cap) {}
void produce(const T& item) {
std::unique_lock<std::mutex> lock(mutex_);
not_full_.wait(lock, [this]{ return queue_.size() < capacity_; });
queue_.push(item);
not_empty_.notify_one();
}
T consume() {
std::unique_lock<std::mutex> lock(mutex_);
not_empty_.wait(lock, [this]{ return !queue_.empty(); });
T item = queue_.front();
queue_.pop();
not_full_.notify_one();
return item;
}
};
关键同步原语:
- 互斥锁:保护缓冲区的并发访问。
- 条件变量 not_full:缓冲区满时生产者等待,消费者取出后通知。
- 条件变量 not_empty:缓冲区空时消费者等待,生产者放入后通知。
注意:
- 条件变量的 wait 必须用 while 循环(或 wait 的谓词版本),防止虚假唤醒。
- 多个生产者/消费者时用
notify_one,全部唤醒用notify_all。 - 这是线程安全的经典模式,广泛用于线程池、消息队列。
6. 什么是死锁?
死锁 (Deadlock):多个线程/进程互相等待对方持有的资源,导致都无法继续执行,永久阻塞。
死锁的四个必要条件(Coffman 条件):
- 互斥 (Mutual Exclusion):资源不能被多个线程同时持有,只能独占。
- 占有并等待 (Hold and Wait):线程持有至少一个资源,同时等待其他线程持有的资源。
- 不可剥夺 (No Preemption):资源不能被强行夺走,只能由持有者主动释放。
- 循环等待 (Circular Wait):存在一组线程,形成循环等待链,每个线程等待下一个线程持有的资源。
死锁示例:
std::mutex m1, m2;
void thread1() {
std::lock_guard<std::mutex> l1(m1); // 持有 m1
std::lock_guard<std::mutex> l2(m2); // 等待 m2(被 thread2 持有)→ 死锁
}
void thread2() {
std::lock_guard<std::mutex> l2(m2); // 持有 m2
std::lock_guard<std::mutex> l1(m1); // 等待 m1(被 thread1 持有)→ 死锁
}
预防死锁的方法(破坏四个条件之一):
- 破坏互斥:尽量用可共享资源(如只读数据),但很多资源必须互斥。
- 破坏占有并等待:一次性申请所有需要的资源,申请不到就不持有任何资源。
- 破坏不可剥夺:持锁线程申请新资源失败时,释放已持有的资源(如
try_lock)。 - 破坏循环等待:对资源编号,所有线程按固定顺序申请资源(如总是先锁 m1 再锁 m2)。
避免死锁:
- 银行家算法:分配资源前判断是否安全。
- 用
std::lock(m1, m2)同时加多个锁(内部按固定顺序,避免死锁)。 - 用
std::scoped_lock(C++17)同时管理多个互斥锁。
检测与恢复:
- 运行时检测死锁(如 Linux 的
lockdep、Java 的 JMX)。 - 检测到后终止一个线程或剥夺资源。
7. 线程池有什么好处?
线程池:预先创建一组线程,任务到来时从池中取一个空闲线程执行,任务完成后线程不销毁,回到池中等待下一个任务。
好处:
-
降低资源消耗:
- 避免频繁创建和销毁线程的开销(创建线程需分配栈、寄存器、内核对象,销毁需回收)。
- 重复利用已有线程,减少系统调用和内存分配。
-
提高响应速度:
- 任务到来时无需等待线程创建,直接用已有线程执行。
- 减少任务等待时间,提高吞吐量。
-
控制并发数:
- 限制同时运行的线程数量,避免线程过多导致:
- 频繁上下文切换(CPU 开销大)。
- 内存耗尽(每个线程有独立栈,默认 8MB)。
- 资源竞争加剧(锁竞争、缓存失效)。
- 根据 CPU 核心数和任务类型设置合理的线程数。
- 限制同时运行的线程数量,避免线程过多导致:
-
统一管理:
- 集中管理线程的生命周期、任务队列、监控统计。
- 支持优雅关闭(等待任务完成再退出)。
- 支持任务排队、优先级调度、拒绝策略。
-
提高稳定性:
- 线程池中的线程异常退出后可自动补充新线程。
- 避免无限制创建线程导致系统崩溃(OOM)。
适用场景:
- 任务量大但每个任务执行时间短(如 Web 服务器请求处理)。
- 需要限制并发数的场景。
- 需要异步执行任务但不想每次创建线程。
不适用场景:
- 任务执行时间很长(线程池优势不明显)。
- 需要线程特定状态长期保留(如线程局部存储的复杂状态)。
线程数设置:
- CPU 密集型任务:线程数 = CPU 核心数 + 1。
- I/O 密集型任务:线程数 = CPU 核心数 × (1 + 等待时间/计算时间),通常较多。
8. 讲一下线程池?
线程池组成:
┌─────────────────────────────────────┐
│ 线程池 │
│ ┌───────────────────────────────┐ │
│ │ 任务队列 (Queue) │ │
│ │ [task1][task2][task3]... │ │
│ └───────────────────────────────┘ │
│ │
│ ┌──────┐ ┌──────┐ ┌──────┐ │
│ │worker1│ │worker2│ │worker3│ ... │ 工作线程
│ └──────┘ └──────┘ └──────┘ │
│ │
│ 核心线程数 | 最大线程数 | 拒绝策略 │
└─────────────────────────────────────┘
核心参数(以 Java ThreadPoolExecutor 为例,C++ 类似):
- 核心线程数 (corePoolSize):常驻线程数,即使空闲也不销毁。
- 最大线程数 (maximumPoolSize):线程池最多创建的线程数。
- 任务队列 (workQueue):存放待执行任务的阻塞队列。
- 存活时间 (keepAliveTime):非核心线程空闲超过此时间则销毁。
- 拒绝策略 (RejectedExecutionHandler):队列满且线程数达上限时的处理策略。
工作流程:
- 任务提交,若当前线程数 < 核心线程数,创建新核心线程执行。
- 若核心线程都在忙,任务加入任务队列。
- 若队列满,且当前线程数 < 最大线程数,创建非核心线程执行。
- 若线程数已达最大,执行拒绝策略(Abort/CallerRuns/Discard/DiscardOldest)。
C++ 简单实现:
class ThreadPool {
std::vector<std::thread> workers_;
std::queue<std::function<void()>> tasks_;
std::mutex mutex_;
std::condition_variable condition_;
bool stop_ = false;
public:
ThreadPool(size_t n) {
for (size_t i = 0; i < n; ++i) {
workers_.emplace_back([this] {
while (true) {
std::function<void()> task;
{
std::unique_lock<std::mutex> lock(mutex_);
condition_.wait(lock, [this]{ return stop_ || !tasks_.empty(); });
if (stop_ && tasks_.empty()) return;
task = std::move(tasks_.front());
tasks_.pop();
}
task();
}
});
}
}
template<class F>
auto submit(F&& f) -> std::future<decltype(f())> {
using ReturnType = decltype(f());
auto task = std::make_shared<std::packaged_task<ReturnType()>>(std::forward<F>(f));
std::future<ReturnType> result = task->get_future();
{
std::unique_lock<std::mutex> lock(mutex_);
tasks_.emplace([task](){ (*task)(); });
}
condition_.notify_one();
return result;
}
~ThreadPool() {
{
std::unique_lock<std::mutex> lock(mutex_);
stop_ = true;
}
condition_.notify_all();
for (auto& w : workers_) w.join();
}
};
9. 什么是线程安全?
线程安全 (Thread Safe):多线程环境下,代码/函数/数据结构能被多个线程同时调用而不产生错误结果(如数据竞争、状态不一致、未定义行为)。
线程不安全的原因:
- 数据竞争 (Data Race):多个线程同时访问共享变量,且至少有一个是写操作,没有同步保护。
- 竞态条件 (Race Condition):程序行为依赖于线程执行的时序,结果不确定。
- 状态不一致:多步操作中间状态被其他线程观察到。
实现线程安全的方法:
-
互斥锁 (Mutex):
- 保护临界区,同一时刻只有一个线程执行。
std::mutex、std::lock_guard、std::unique_lock。
-
原子操作 (Atomic):
- 不可分割的操作,硬件级保证。
std::atomic<int>、fetch_add、compare_exchange。- 适用于简单计数器、标志位。
-
不可变对象 (Immutable):
- 对象创建后不可修改,多线程只读访问天然安全。
- 如
const对象、字符串。
-
线程局部存储 (TLS):
thread_local变量,每个线程有独立副本,不共享。- 避免同步开销。
-
消息传递:
- 线程间通过消息队列通信,不共享可变状态。
- 如 Actor 模型、Go channel。
-
无锁数据结构 (Lock-Free):
- 用 CAS (Compare-And-Swap) 等原子操作实现,不用锁。
- 复杂但性能高,如无锁队列。
线程安全的级别:
- 线程安全:多线程同时调用安全。
- 条件线程安全:读操作并发安全,写操作需外部同步(如大多数 STL 容器)。
- 线程不安全:多线程调用需外部加锁。
注意:
- STL 容器不是线程安全的:多线程读安全,至少一个线程写时需外部加锁。
std::shared_ptr的引用计数是线程安全的,但对象本身的访问不是。- 函数内的局部变量是线程安全的(每个线程有独立栈)。
10. 多线程间共享数据,用什么方式保证安全性?
| 方式 | 说明 | 适用场景 |
|---|---|---|
| 互斥锁 std::mutex | 独占访问,同一时刻一个线程 | 通用临界区保护 |
| 读写锁 std::shared_mutex | 多读单写,读并发写独占 | 读多写少的共享数据 |
| 自旋锁 | 忙等待,不睡眠 | 锁持有时间极短 |
| 原子操作 std::atomic | 硬件级原子,无锁 | 简单计数器、标志位 |
| 条件变量 | 等待/通知,配合互斥锁 | 生产者消费者、事件等待 |
| 信号量 | 计数器,控制并发数 | 资源池、限流 |
| 屏障 std::barrier | 所有线程到达后继续 | 并行计算阶段同步 |
| 线程局部存储 thread_local | 每线程独立副本,不共享 | 避免共享的场景 |
| 不可变对象 const | 创建后只读,天然安全 | 配置数据、只读表 |
| 消息队列/无锁队列 | 通过消息传递,不直接共享 | Actor 模型、解耦 |
最佳实践:
- 优先用 RAII 锁:
std::mutex mtx;
void func() {
std::lock_guard<std::mutex> lock(mtx); // 构造加锁,析构解锁
// 临界区
} // 自动解锁,即使异常也安全
-
缩小临界区:只保护必须保护的代码,减少锁持有时间。
-
避免死锁:
- 按固定顺序加锁。
- 用
std::lock(m1, m2)同时加多个锁。 - C++17 用
std::scoped_lock。
-
优先用原子操作:简单的计数/标志用
std::atomic,比锁高效。 -
减少共享:能用线程局部存储或消息传递就不用共享可变数据。
注意:
- 双重检查锁定 (DCLP) 在 C++11 前有问题,C++11 后可用
std::call_once或原子操作实现。 - 静态局部变量的初始化在 C++11 后是线程安全的(Magic Statics)。
11. 什么是线程安全函数?工作中如何保证线程安全?
线程安全函数:可被多个线程同时调用而不产生数据竞争或错误结果的函数。
函数线程安全的条件:
- 不访问非局部的可变共享数据(全局变量、静态变量)。
- 若访问共享数据,必须有同步保护。
- 不返回指向内部静态可变数据的指针。
- 调用的其他函数也是线程安全的。
反例(线程不安全):
// 不安全:使用静态变量
char* unsafe_itoa(int n) {
static char buf[20]; // 多线程调用会互相覆盖
sprintf(buf, "%d", n);
return buf;
}
// 不安全:修改全局变量
int counter = 0;
void unsafe_increment() {
counter++; // 非原子操作,多线程数据竞争
}
正例(线程安全):
// 安全:只用局部变量
void safe_func(int x) {
int result = x * 2; // 局部变量,每线程独立栈
print(result);
}
// 安全:用原子操作
std::atomic<int> counter{0};
void safe_increment() {
counter++; // 原子操作
}
// 安全:用互斥锁保护共享数据
std::mutex mtx;
int shared_data = 0;
void safe_modify() {
std::lock_guard<std::mutex> lock(mtx);
shared_data++;
}
工作中保证线程安全的方法:
-
尽量避免共享:
- 用局部变量代替全局/静态变量。
- 用
thread_local存储线程私有数据。 - 用不可变对象(创建后只读)。
-
正确使用同步原语:
- 互斥锁保护临界区,用 RAII(
lock_guard)。 - 原子操作处理简单计数。
- 条件变量处理事件等待。
- 互斥锁保护临界区,用 RAII(
-
代码审查:
- 检查是否有未保护的共享数据访问。
- 检查锁的获取顺序是否一致(防死锁)。
- 检查是否有竞态条件。
-
工具检测:
- ThreadSanitizer (TSan):
-fsanitize=thread,检测数据竞争和死锁。 - Helgrind(Valgrind 工具):线程错误检测。
- 静态分析:clang-tidy、Coverity。
- ThreadSanitizer (TSan):
-
测试:
- 压力测试:多线程高并发运行。
- 长时间运行测试(偶发问题)。
- 用不同调度顺序、不同 CPU 数测试。
-
设计层面:
- 明确哪些数据是共享的,哪些是线程私有的。
- 用消息传递代替共享内存(如 Actor 模型)。
- 单一职责,减少函数间的隐式共享。
12. 从输入 URL 到显示页面的全过程?
-
DNS 解析:
- 浏览器检查缓存 → 系统缓存 (hosts) → 本地 DNS 服务器 → 根 DNS → 顶级域 DNS → 权威 DNS。
- 将域名解析为 IP 地址。
-
TCP 连接建立:
- 浏览器与服务器 IP 的 80/443 端口进行 TCP 三次握手。
- HTTPS 还需 TLS 握手(协商加密套件、交换密钥、验证证书)。
-
发送 HTTP 请求:
- 浏览器构造 HTTP 请求报文(请求行、请求头、请求体)。
- 通过 TCP 连接发送给服务器。
-
服务器处理:
- 反向代理(Nginx)接收请求,转发给应用服务器。
- 应用服务器处理业务逻辑,可能查询数据库、缓存。
- 生成 HTTP 响应报文(状态行、响应头、响应体)。
-
返回响应:
- 服务器将响应通过 TCP 连接发回浏览器。
- 浏览器接收响应,根据状态码处理(200 成功、301 重定向、404 未找到等)。
-
浏览器渲染:
- 解析 HTML 构建 DOM 树。
- 解析 CSS 构建 CSSOM 树。
- 合并为渲染树 (Render Tree)。
- 布局 (Layout/Reflow):计算元素位置和大小。
- 绘制 (Paint):将像素绘制到屏幕。
- 合成 (Composite):多层合并显示。
-
断开连接:
- TCP 四次挥手断开连接(或保持长连接 Keep-Alive)。
13. select、poll、epoll 的区别?
| 区别 | select | poll | epoll |
|---|---|---|---|
| 数据结构 | fd_set(位图,固定大小) | pollfd 数组(动态) | 红黑树 + 就绪链表 |
| 最大连接数 | 1024(FD_SETSIZE,需重新编译内核修改) | 无限制(数组大小可调) | 无限制(受内存限制) |
| 效率 | O(n),每次调用都需遍历所有 fd | O(n),同 select | O(1),只返回就绪的 fd |
| 内存拷贝 | 每次调用都需将 fd_set 从用户态拷贝到内核态 | 每次调用都需拷贝 pollfd 数组 | 仅在注册/修改时拷贝,就绪列表通过共享内存 |
| 工作模式 | LT(水平触发) | LT(水平触发) | LT(水平触发)+ ET(边沿触发) |
| fd 集合 | 每次调用后需重置(fd_set 被修改) | 不需要重置,revents 单独标记 | 通过 event 结构单独管理 |
| 适用场景 | 连接数少且都活跃 | 同 select | 大量连接、高并发、连接活跃度不均 |
select 的缺点:
- 连接数上限 1024。
- 每次调用都需遍历所有 fd,连接多时效率低。
- fd_set 每次调用后被修改,需重新设置。
- 用户态/内核态频繁拷贝。
epoll 的优势:
- 事件驱动:只返回就绪的 fd,不需要遍历全部。
- 内存共享:通过 mmap 共享内存,避免频繁拷贝。
- 两种触发模式:
- LT (Level Triggered,水平触发):只要缓冲区有数据就一直通知,类似 select,不容易出错。
- ET (Edge Triggered,边沿触发):只在状态变化时通知一次,需一次性读完数据(非阻塞 + 循环读),效率更高但编程复杂。
- 三个系统调用:
epoll_create:创建 epoll 实例。epoll_ctl:注册/修改/删除 fd 及其事件。epoll_wait:等待事件,返回就绪的 fd。
使用建议:高并发服务器(如 Nginx、Redis)都用 epoll。
14. epoll 边沿触发 (ET) 的具体实现方式?
ET (Edge Triggered,边沿触发):只在 fd 状态发生变化时(如新数据到达)通知一次,之后即使缓冲区还有数据也不再通知,直到下一次状态变化。
实现要点:
- 设置非阻塞:ET 模式必须配合非阻塞 I/O,否则 read/write 可能阻塞在单个 fd 上,导致整个事件循环卡死。
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
- 注册 ET 事件:
struct epoll_event ev;
ev.events = EPOLLIN | EPOLLET; // 加上 EPOLLET 标志
ev.data.fd = fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev);
- 循环读取直到 EAGAIN:ET 只通知一次,必须一次性把缓冲区数据读完,否则剩余数据不会再触发事件。
while (1) {
ssize_t n = read(fd, buf, sizeof(buf));
if (n > 0) {
// 处理数据
} else if (n == 0) {
// 对端关闭连接
close(fd);
break;
} else {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
// 数据已全部读完
break;
}
// 真正的错误
close(fd);
break;
}
}
- 写事件同理:ET 模式下 EPOLLOUT 只在可写状态变化时通知一次,需循环写直到 EAGAIN。
ET vs LT:
| 区别 | ET(边沿触发) | LT(水平触发) |
|---|---|---|
| 通知次数 | 状态变化时通知一次 | 只要满足条件就一直通知 |
| 必须非阻塞 | 是 | 否(也可以用阻塞) |
| 编程复杂度 | 高(需循环读/写直到 EAGAIN) | 低(类似 select) |
| 效率 | 高(减少重复通知) | 较低(可能重复通知) |
| 适用场景 | 高性能、连接数大 | 通用、简单 |
注意事项:
- ET 模式下,如果 read 没有读完数据,剩余数据不会再触发 EPOLLIN,导致数据"丢失"(实际还在缓冲区,但不会通知)。
- 必须用非阻塞 I/O。
- 每次 read 要循环到 EAGAIN。
- 对端关闭连接时 read 返回 0,需处理。
15. LT 和 ET 的区别及应用场景?
| 区别 | LT (Level Triggered,水平触发) | ET (Edge Triggered,边沿触发) |
|---|---|---|
| 触发条件 | 只要 fd 上有数据可读/可写,就一直触发 | 只在 fd 状态变化时(新数据到达、从不可写变可写)触发一次 |
| 通知次数 | 多次(每次 epoll_wait 都返回) | 一次(状态变化时) |
| 是否必须非阻塞 | 否 | 是(必须配合非阻塞 I/O) |
| 数据读取方式 | 读一次即可,没读完下次还会触发 | 必须循环读直到 EAGAIN,否则剩余数据不再触发 |
| 编程复杂度 | 低,简单不易出错 | 高,需处理非阻塞和循环读写 |
| 效率 | 较低(重复通知、重复遍历) | 高(只通知一次,减少系统调用) |
| 默认模式 | select/poll/epoll 默认都是 LT | epoll 需显式设置 EPOLLET |
应用场景:
LT 适用场景:
- 连接数较少,对性能要求不高。
- 编程简单优先,不想处理复杂的非阻塞逻辑。
- 数据量小,一次 read 就能读完。
- 初学者或快速开发。
- 写事件:LT 模式下 EPOLLOUT 只要可写就一直触发,需注意写完后及时移除 EPOLLOUT,否则 CPU 空转。
ET 适用场景:
- 高并发服务器,连接数大(上万甚至十万)。
- 性能要求高,希望减少系统调用和重复通知。
- 数据量大,需要高效处理。
- Nginx、Redis、高性能网络库(muduo、libevent 可选 ET)。
ET 编程注意:
- 所有 fd 设为非阻塞。
- read/write 循环到 EAGAIN。
- 处理 EINTR(被信号中断)。
- 注意写事件:ET 下 EPOLLOUT 只在从不可写变可写时触发一次,如果发送缓冲区一直有空间,不会重复触发。需要主动注册 EPOLLOUT,写完后移除。
- accept 也要循环:ET 模式下新连接到达只通知一次,需循环 accept 直到 EAGAIN(多个连接同时到达时)。
选择建议:
- 大多数场景用 LT 即可,简单可靠。
- 追求极致性能、连接数巨大时用 ET。
- 很多高性能框架(如 Netty)默认用 LT,因为 ET 编程容易出错且收益不一定大。
16. TCP 的"粘包"问题?
粘包:TCP 是面向字节流的协议,没有消息边界。发送方发送的多个数据包可能被接收方当作一个包接收,或者一个包被拆成多个包接收。
产生原因:
- 发送方 Nagle 算法:将多个小数据包合并成一个发送。
- 接收方缓存:接收方读取不及时,多个包在缓冲区中合并。
- TCP 分段:MSS (最大分段大小) 限制,大包被拆分成多个段。
- 应用层写入:多次 write 的数据被 TCP 合并发送。
注意:粘包不是 TCP 的 bug,而是字节流协议的特性。UDP 是面向数据报的,不会粘包。
解决方法(应用层处理):
1. 固定长度消息:
- 每条消息固定长度,不足补零。
- 接收方按固定长度读取。
- 缺点:浪费空间,不适合变长消息。
2. 特殊分隔符:
- 消息之间用特殊字符分隔(如
\n、\r\n、\0)。 - 接收方按分隔符切分。
- 缺点:消息内容不能包含分隔符(需转义)。
- 例:HTTP 头部用
\r\n\r\n分隔。
3. 消息头 + 消息体(长度前缀):
- 最常用的方法。消息头包含消息体长度(如 4 字节整数)。
- 接收方先读固定长度的头部,解析出消息体长度,再读指定长度的消息体。
┌──────────┬──────────────────┐
│ 长度(4B) │ 消息体(N B) │
└──────────┴──────────────────┘
4. 自定义协议:
- 设计完整的应用层协议,包含魔数、版本、消息类型、长度、序列化数据、校验和等。
- 例:Protobuf + 长度前缀、Thrift、gRPC。
接收方处理逻辑(以长度前缀为例):
// 维护接收缓冲区
std::string recv_buf;
void on_data(const char* data, size_t len) {
recv_buf.append(data, len);
while (recv_buf.size() >= 4) { // 至少有消息头
uint32_t msg_len = *reinterpret_cast<const uint32_t*>(recv_buf.data());
if (recv_buf.size() >= 4 + msg_len) {
// 完整消息到达,处理
handle_message(recv_buf.data() + 4, msg_len);
recv_buf.erase(0, 4 + msg_len);
} else {
break; // 消息不完整,等待更多数据
}
}
}
17. TCP 和 UDP 的区别?
| 区别 | TCP | UDP |
|---|---|---|
| 连接性 | 面向连接,需三次握手建立连接 | 无连接,直接发送数据 |
| 可靠性 | 可靠,确认重传、序号、校验和 | 不可靠,可能丢包、乱序、重复 |
| 数据边界 | 字节流,无消息边界(粘包) | 面向数据报,有消息边界 |
| 传输效率 | 较低(握手、确认、重传、拥塞控制开销) | 高(无额外开销) |
| 实时性 | 较低(重传可能导致延迟) | 高(发完即走,不等待确认) |
| 流量控制 | 有(滑动窗口) | 无 |
| 拥塞控制 | 有(慢启动、拥塞避免、快重传、快恢复) | 无 |
| 连接数 | 点对点,一对一 | 一对一、一对多、多对多(支持广播/组播) |
| 首部开销 | 20-60 字节 | 8 字节 |
| 适用场景 | 文件传输、Web、邮件、远程登录(需可靠传输) | 视频/音频直播、DNS、游戏、物联网(追求实时性,可容忍少量丢包) |
TCP 保证可靠性的机制:
- 序列号和确认号:每个字节都有序号,接收方确认收到的字节。
- 超时重传:发送方在超时时间内未收到确认则重传。
- 校验和:检测传输过程中的数据损坏。
- 流量控制:滑动窗口,防止发送方发得太快导致接收方处理不过来。
- 拥塞控制:根据网络状况调整发送速率。
UDP 的优势场景:
- 实时性要求高,丢一两个包不影响(视频会议、直播、游戏)。
- 网络状况好,丢包率低。
- 需要广播/组播。
- 简单请求-响应,自己实现轻量级可靠机制。
选择建议:默认用 TCP,除非明确需要 UDP 的特性。
18. TCP 三次握手建立连接的过程?
客户端 (Client) 服务器 (Server)
| |
| 1. SYN (seq=x) |
| ────────────────────────────────────→ |
| |
| 2. SYN+ACK (seq=y, ack=x+1) |
| ←──────────────────────────────────── |
| |
| 3. ACK (seq=x+1, ack=y+1) |
| ────────────────────────────────────→ |
| |
| 连接建立,开始传输数据 |
详细过程:
-
第一次握手 (SYN):
- 客户端向服务器发送 SYN 报文,序列号 seq = x(随机生成)。
- 客户端状态变为 SYN_SENT。
- 同步位 SYN = 1,表示请求建立连接。
-
第二次握手 (SYN+ACK):
- 服务器收到 SYN 后,回复 SYN+ACK 报文。
- 确认号 ack = x + 1(表示期望收到的下一个字节序号)。
- 服务器自己的序列号 seq = y(随机生成)。
- 服务器状态变为 SYN_RCVD。
- 服务器为该连接分配资源(TCB)。
-
第三次握手 (ACK):
- 客户端收到 SYN+ACK 后,回复 ACK 报文。
- 确认号 ack = y + 1。
- 序列号 seq = x + 1。
- 客户端状态变为 ESTABLISHED。
- 服务器收到 ACK 后,状态也变为 ESTABLISHED。
为什么是三次而不是两次?
- 确认双方的收发能力:三次握手能确认客户端的发送/接收能力和服务器的发送/接收能力都正常。两次握手只能确认客户端到服务器的单向能力。
- 防止已失效的连接请求到达服务器:如果客户端的一个旧 SYN 延迟到达服务器,服务器回复 SYN+ACK 后,如果是两次握手就会建立连接,但客户端并不想建立,浪费服务器资源。三次握手中客户端可以忽略这个旧 SYN 的回复,不发送第三次 ACK,连接就不会建立。
- 同步初始序列号:双方需要交换并确认各自的初始序列号 (ISN),三次握手刚好完成双向同步。
第三次握手可以携带数据:TCP 标准允许第三次握手的 ACK 报文中携带数据(但有些实现可能不支持)。
19. TCP 四次挥手的过程?
客户端 (Client) 服务器 (Server)
| |
| 1. FIN (seq=u) |
| ────────────────────────────────────→ |
| 状态: FIN_WAIT_1 |
| |
| 2. ACK (ack=u+1) |
| ←──────────────────────────────────── |
| 状态: FIN_WAIT_2 | 状态: CLOSE_WAIT
| |
| 3. FIN (seq=w) |
| ←──────────────────────────────────── |
| | 状态: LAST_ACK
| 4. ACK (ack=w+1) |
| ────────────────────────────────────→ |
| 状态: TIME_WAIT (2MSL) |
| | 状态: CLOSED
| 等待 2MSL 后 → CLOSED |
详细过程:
-
第一次挥手 (FIN):
- 客户端发送 FIN 报文,序列号 seq = u。
- 表示客户端没有数据要发送了,请求关闭连接。
- 客户端状态变为 FIN_WAIT_1。
-
第二次挥手 (ACK):
- 服务器收到 FIN 后,回复 ACK,确认号 ack = u + 1。
- 服务器状态变为 CLOSE_WAIT。
- 客户端收到 ACK 后,状态变为 FIN_WAIT_2。
- 此时客户端到服务器的方向关闭了(半关闭),但服务器可能还有数据要发送。
-
第三次挥手 (FIN):
- 服务器数据发送完毕后,发送 FIN 报文,序列号 seq = w。
- 服务器状态变为 LAST_ACK。
-
第四次挥手 (ACK):
- 客户端收到 FIN 后,回复 ACK,确认号 ack = w + 1。
- 客户端状态变为 TIME_WAIT。
- 服务器收到 ACK 后,状态变为 CLOSED。
- 客户端等待 2MSL(最长报文寿命的两倍)后,状态变为 CLOSED。
为什么是四次而不是三次?
- TCP 是全双工的,两个方向需要分别关闭。
- 服务器收到客户端的 FIN 后,可能还有数据要发送,不能立即回复 FIN,只能先回复 ACK。
- 等服务器数据发完后,再单独发送 FIN。
- 因此 ACK 和 FIN 分开发送,共四次。
- 如果服务器没有数据要发送,ACK 和 FIN 可以合并,变成三次挥手。
为什么 TIME_WAIT 需要 2MSL?
- 确保最后一个 ACK 能到达服务器:如果客户端的最后一个 ACK 丢失,服务器会重传 FIN,客户端在 TIME_WAIT 期间可以重发 ACK。2MSL 足够让 ACK 和可能的 FIN 重传都完成。
- 让本次连接的所有报文在网络中消失:防止旧连接的延迟报文被新连接误认为是新数据。2MSL 确保所有报文都过期。
20. 为什么 TIME_WAIT 状态需要经过 2MSL 才能返回到 CLOSED?
MSL (Maximum Segment Lifetime,最长报文寿命):TCP 报文在网络中存活的最长时间,RFC 建议 2 分钟,Linux 实际通常为 30 秒(可通过 /proc/sys/net/ipv4/tcp_fin_timeout 调整,但实际 MSL 固定为 30 秒,TIME_WAIT 时长为 60 秒)。
2MSL 的原因:
1. 确保最后一个 ACK 能到达被动关闭方:
- 主动关闭方发送最后一个 ACK 后进入 TIME_WAIT。
- 如果这个 ACK 在网络中丢失,被动关闭方 (LAST_ACK 状态) 会超时重传 FIN。
- 主动关闭方在 TIME_WAIT 期间收到重传的 FIN 后,可以重新发送 ACK。
- 1MSL 足够 ACK 到达被动关闭方,另 1MSL 足够被动关闭方重传的 FIN 到达主动关闭方。
- 如果没有 TIME_WAIT,主动关闭方直接关闭,就无法处理被动关闭方重传的 FIN,导致被动关闭方一直停留在 LAST_ACK 无法正常关闭。
2. 确保本次连接的所有报文都在网络中消失:
- 防止旧连接的延迟报文 (wandering duplicate) 被新连接误认为是新数据。
- 2MSL 确保本次连接产生的所有报文都在网络中过期消失。
- 这样新连接(使用相同的四元组:源IP、源端口、目的IP、目的端口)不会收到旧连接的残留报文。
TIME_WAIT 的影响:
- 主动关闭方在 TIME_WAIT 期间,该四元组不能被复用(默认情况下)。
- 高并发短连接服务器(如 HTTP 服务器主动关闭连接)可能产生大量 TIME_WAIT,耗尽端口资源。
处理大量 TIME_WAIT 的方法:
- 调整
tcp_tw_reuse:允许将 TIME_WAIT 的端口用于新的出站连接(需配合时间戳)。 - 让客户端主动关闭:服务器保持连接,由客户端发起关闭,TIME_WAIT 在客户端。
- 使用长连接 (Keep-Alive):减少连接建立和关闭的频率。
- 调整
tcp_fin_timeout:减少 FIN_WAIT_2 的时间(不直接影响 TIME_WAIT)。 - 增加本地端口范围:
ip_local_port_range调大,可用端口更多。 - 使用 SO_REUSEADDR:允许绑定处于 TIME_WAIT 的地址(对服务器监听端口有效)。
注意:tcp_tw_recycle 已在 Linux 4.12 中移除,因为在 NAT 环境下会导致问题,不要使用。
21. HTTP 和 HTTPS 的区别?
| 区别 | HTTP | HTTPS |
|---|---|---|
| 全称 | HyperText Transfer Protocol | HyperText Transfer Protocol Secure |
| 端口 | 80 | 443 |
| 安全性 | 明文传输,不安全 | 加密传输,安全 |
| 加密 | 无 | TLS/SSL 加密 |
| 证书 | 不需要 | 需要 CA 颁发的 SSL 证书 |
| 性能 | 快(无加密开销) | 稍慢(握手和加密解密开销,但 HTTP/2 通常只在 HTTPS 下支持) |
| SEO | 权重较低 | Google 优先收录,权重更高 |
| 协议层次 | 应用层直接基于 TCP | HTTP + TLS/SSL + TCP |
| 数据完整性 | 不保证,可被篡改 | 保证,有消息认证码 (MAC) |
| 身份验证 | 无 | 验证服务器身份(证书) |
| 适用场景 | 内部系统、无敏感数据 | 涉及登录、支付、个人信息等敏感数据 |
HTTPS 工作原理:
- TCP 三次握手。
- TLS 握手:
- 客户端发送 ClientHello(支持的加密套件、随机数)。
- 服务器回复 ServerHello(选定加密套件、随机数)+ 证书(含公钥)。
- 客户端验证证书合法性(CA 签名、有效期、域名匹配)。
- 客户端生成预主密钥 (Pre-Master Secret),用服务器公钥加密后发送。
- 双方根据预主密钥和随机数生成会话密钥 (Session Key)。
- 发送 Finished 消息,握手完成。
- 加密通信:之后所有 HTTP 数据用会话密钥对称加密传输。
HTTPS 的优势:
- 机密性:第三方无法窃听内容。
- 完整性:消息认证码检测数据是否被篡改。
- 身份认证:证书验证服务器身份,防止钓鱼和中间人攻击。
- SEO 友好:搜索引擎优先展示 HTTPS 网站。
- 支持 HTTP/2:主流浏览器只在 HTTPS 下支持 HTTP/2。
HTTPS 的成本:
- 证书费用(现在有 Let's Encrypt 免费证书)。
- 握手延迟(TLS 1.3 已大幅减少)。
- 加密解密的 CPU 开销(现代 CPU 有 AES-NI 硬件加速,开销很小)。
22. 什么时候产生 TIME_WAIT?大规模 TIME_WAIT 怎么处理?
什么时候产生 TIME_WAIT:
- TCP 连接中,主动关闭连接的一方在发送最后一个 ACK 后进入 TIME_WAIT 状态。
- 即四次挥手中,主动发送 FIN 的一方(客户端或服务器),在收到对方的 FIN 并回复 ACK 后,进入 TIME_WAIT。
- 持续时间为 2MSL(Linux 通常 60 秒,MSL=30 秒)。
- 之后才进入 CLOSED 状态,释放连接资源。
常见场景:
- HTTP/1.0 默认短连接,服务器发送完响应后主动关闭连接 → 服务器端产生大量 TIME_WAIT。
- 客户端主动关闭连接 → 客户端产生 TIME_WAIT。
- 高并发短连接服务(如 API 网关、反向代理)容易产生大量 TIME_WAIT。
大规模 TIME_WAIT 的危害:
- 端口耗尽:TIME_WAIT 状态的连接占用四元组(源IP、源端口、目的IP、目的端口),客户端出站连接的本地端口有限(默认约 3 万个),大量 TIME_WAIT 会导致无法建立新连接(
Cannot assign requested address)。 - 内存占用:每个 TIME_WAIT 连接占用内核内存(tcp_timewait 结构),数量极多时消耗内存。
- 性能下降:内核需要管理大量 TIME_WAIT 连接。
处理方法:
1. 调整内核参数:
# 允许将 TIME_WAIT 端口用于新的出站连接(需时间戳支持)
sysctl -w net.ipv4.tcp_tw_reuse=1
# 增大本地端口范围
sysctl -w net.ipv4.ip_local_port_range="1024 65535"
# 减少 FIN_WAIT_2 超时(间接减少 TIME_WAIT 产生)
sysctl -w net.ipv4.tcp_fin_timeout=15
# 增大 TIME_WAIT 最大数量(防止被攻击)
sysctl -w net.ipv4.tcp_max_tw_buckets=262144
2. 让客户端主动关闭连接:
- 服务器保持连接,由客户端发起关闭,TIME_WAIT 发生在客户端,不影响服务器端口。
- HTTP/1.1 默认 Keep-Alive,连接复用,减少关闭频率。
3. 使用长连接 (Keep-Alive):
- 复用 TCP 连接,避免频繁建立和关闭连接。
- HTTP/1.1、HTTP/2、数据库连接池、RPC 框架都默认使用长连接。
- 设置合理的连接空闲超时,及时释放不活跃连接。
4. SO_REUSEADDR:
- 服务器端
setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &on),允许绑定处于 TIME_WAIT 的地址。 - 对服务器监听端口有效,重启服务时不会因 TIME_WAIT 而绑定失败。
5. 负载均衡/分布式:
- 多实例部署,分散连接数。
- 四层负载均衡 (LVS)、七层负载均衡 (Nginx) 分发流量。
6. 使用 SO_LINGER(不推荐):
- 设置
SO_LINGER为 0,关闭连接时直接发送 RST 而不是正常的四次挥手,跳过 TIME_WAIT。 - 但这可能导致数据丢失,不推荐用于正常通信。
注意:
tcp_tw_recycle已在 Linux 4.12 移除,NAT 环境下会导致连接问题,不要使用。tcp_tw_reuse只对出站连接(客户端)有效,对入站连接(服务器)无效。- 最根本的解决方法是减少短连接,使用长连接。
📌 中频题(18题)
比较常见,部分公司会问到,建议理解并能口述要点。
1. Linux 中常用的命令?
文件与目录:
ls:列出目录内容(-l详细,-a含隐藏,-h人类可读)cd:切换目录pwd:显示当前路径mkdir:创建目录(-p递归创建)rm:删除文件/目录(-r递归,-f强制)cp:复制(-r递归目录)mv:移动/重命名touch:创建空文件/更新时间戳ln:创建链接(-s软链接)
文件查看:
cat:查看文件内容less/more:分页查看head/tail:查看文件头/尾(-f实时跟踪)grep:搜索文本(-r递归,-i忽略大小写,-n行号)find:查找文件wc:统计行数/字数/字节数
权限与用户:
chmod:修改权限chown:修改所有者sudo:以管理员权限执行su:切换用户
进程与系统:
ps:查看进程(aux全格式)top/htop:实时系统监控kill:终止进程(-9强制)df:磁盘使用情况du:目录大小(-sh汇总)free:内存使用uname:系统信息(-a全部)
网络:
ifconfig/ip addr:查看网络接口netstat/ss:查看网络连接ping:测试连通性curl/wget:下载文件ssh:远程登录
其他:
tar:打包压缩(-czf压缩,-xzf解压)gzip/zip/unzip:压缩解压echo:输出文本history:命令历史man:帮助手册
2. Linux 查看内存、磁盘、端口、进程、线程的命令?
| 类别 | 命令 | 说明 |
|---|---|---|
| 内存 | free -h | 内存和 swap 使用情况 |
top / htop | 实时监控,含内存 | |
cat /proc/meminfo | 详细内存信息 | |
vmstat 1 | 虚拟内存统计(每秒刷新) | |
pmap -x <pid> | 进程内存映射 | |
| 磁盘 | df -h | 文件系统磁盘使用 |
du -sh <dir> | 目录大小 | |
iostat -x 1 | 磁盘 I/O 统计 | |
iotop | 进程级 I/O 监控 | |
lsblk | 块设备列表 | |
| 端口 | netstat -tlnp | 监听的 TCP 端口(-u UDP) |
ss -tlnp | 更现代的网络连接查看 | |
lsof -i :端口号 | 查看占用端口的进程 | |
nmap <ip> | 扫描远程主机端口 | |
| 进程 | ps aux | 所有进程详细信息 |
ps -ef | 全格式进程树 | |
top / htop | 实时进程监控 | |
pstree | 进程树 | |
kill <pid> / kill -9 <pid> | 终止进程 | |
nice / renice | 调整进程优先级 | |
| 线程 | ps -T -p <pid> | 查看进程的线程 |
ps -eLf | 所有线程 | |
top -H -p <pid> | 实时查看进程线程 | |
pstree -p <pid> | 线程树 | |
cat /proc/<pid>/task/ | 线程目录 |
3. gdb 调试工具用过哪些功能?
常用 gdb 命令:
| 功能 | 命令 | 说明 |
|---|---|---|
| 启动 | gdb ./program | 启动调试 |
gdb ./program core | 调试 core dump | |
gdb -p <pid> | 附加到运行中的进程 | |
| 运行 | run / r | 运行程序 |
run args | 带参数运行 | |
continue / c | 继续运行 | |
next / n | 单步跳过(不进入函数) | |
step / s | 单步进入(进入函数) | |
finish | 运行到当前函数返回 | |
until | 运行到指定行 | |
| 断点 | break 行号 / b | 设置断点 |
break 函数名 | 在函数入口设断点 | |
break 文件:行号 | 指定文件设断点 | |
break 行号 if 条件 | 条件断点 | |
info breakpoints | 查看断点 | |
delete 编号 | 删除断点 | |
disable/enable 编号 | 禁用/启用断点 | |
| 查看 | print 变量 / p | 打印变量值 |
print *array@len | 打印数组 | |
ptype 变量 | 查看类型 | |
list / l | 显示源码 | |
backtrace / bt | 查看调用栈 | |
frame 编号 | 切换栈帧 | |
info locals | 查看局部变量 | |
info args | 查看函数参数 | |
display 变量 | 每次停住时自动显示 | |
x/格式 地址 | 查看内存 | |
| 其他 | watch 变量 | 数据断点(变量变化时停住) |
set var x=10 | 修改运行中变量的值 | |
call 函数() | 调用函数 | |
quit / q | 退出 gdb |
编译要求:需加 -g 参数保留调试信息:g++ -g -O0 main.cpp -o program
4. 模块偶发崩溃如何定位?
定位步骤:
1. 保留现场:
- 确保系统生成 core dump:
ulimit -c unlimited,检查/proc/sys/kernel/core_pattern。 - 保留崩溃时的 core 文件和对应版本的可执行文件(带调试信息)。
2. 分析 core dump:
gdb ./program core.12345
bt # 查看崩溃时的调用栈
info locals # 查看局部变量
frame N # 切换栈帧
3. 增加日志:
- 在关键路径加详细日志(函数入口/出口、关键变量值、错误码)。
- 用日志级别控制,崩溃前的日志能缩小范围。
4. 内存错误检测:
- AddressSanitizer (ASan):
-fsanitize=address -g,检测越界、use-after-free、double-free 等。 - Valgrind:
valgrind --tool=memcheck ./program,检测内存泄漏和非法访问(慢但准确)。 - UndefinedBehaviorSanitizer (UBSan):
-fsanitize=undefined,检测未定义行为。
5. 线程问题检测:
- ThreadSanitizer (TSan):
-fsanitize=thread,检测数据竞争和死锁。 - 偶发崩溃很多是竞态条件,TSan 很有效。
6. 复现:
- 压力测试:循环运行、并发测试、边界条件测试。
- 故障注入:模拟网络异常、磁盘满、内存不足等。
- 用
gdb --args+ 条件断点在可疑位置停住。
7. 静态分析:
clang --analyze、cppcheck、Coverity等工具提前发现潜在问题。
8. 监控:
- 崩溃时记录寄存器状态、栈信息、内存映射。
- 用
backtrace()+backtrace_symbols()在程序内部捕获崩溃栈。
5. 什么是 coredump 文件?
**core dump(核心转储)**是程序异常终止时,OS 将进程的内存映像、寄存器状态、调用栈等信息保存到一个文件中,用于事后调试。
生成条件:
- 进程收到某些信号并终止:
SIGSEGV(段错误)、SIGABRT(abort)、SIGFPE(浮点异常)、SIGILL(非法指令)、SIGBUS(总线错误)等。 - 需开启 core dump:
ulimit -c unlimited(默认大小为 0,不生成)。
core 文件位置:
- 由
/proc/sys/kernel/core_pattern控制:core:当前目录下生成core文件。/tmp/core.%e.%p.%t:指定路径和命名格式(%e 程序名,%p PID,%t 时间)。
- Ubuntu 默认通过
apport服务管理,core 文件可能在/var/lib/apport/coredump/。
调试 core 文件:
gdb ./program core.12345
(gdb) bt # 查看崩溃调用栈
(gdb) info locals # 查看局部变量
(gdb) frame N # 切换栈帧
(gdb) list # 查看源码
注意事项:
- core 文件可能很大(等于进程内存大小),注意磁盘空间。
- 可执行文件需带调试信息(
-g),否则只能看到函数地址。 - 生产环境可考虑用
coredumpctl(systemd)管理 core 文件。 - 容器中需确保
ulimit -c和 core_pattern 配置正确。
6. mmap 的应用场景?
mmap (memory map) 将文件或设备映射到进程的虚拟地址空间,通过指针直接读写,替代 read/write 系统调用。
应用场景:
1. 大文件读写:
- 映射大文件(如数据库文件、日志文件),随机访问效率高。
- 避免 read/write 的内核缓冲区拷贝开销。
2. 进程间通信 (IPC):
- 多个进程映射同一个文件(或匿名映射),共享内存区域。
- 比消息队列、管道效率高(直接内存读写,无需内核拷贝)。
- 需配合信号量/互斥锁同步。
3. 动态库加载:
- 动态链接器用 mmap 将
.so文件映射到进程地址空间。 - 代码段只读共享,数据段私有写时复制。
4. 零拷贝 (Zero-Copy):
- 网络发送文件时,用 mmap + sendfile 避免数据在用户态和内核态之间拷贝。
- Kafka、RocketMQ 等消息队列用 mmap 提升性能。
5. 匿名映射分配内存:
mmap(NULL, size, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0)分配大块内存。- malloc 在分配大块内存(>128KB)时内部用 mmap。
6. 写时复制 (COW):
fork()创建子进程时,用 mmap 的写时复制机制共享父进程内存,只有写入时才拷贝。
7. 内存映射 I/O:
- 映射设备文件(如
/dev/mem),直接访问硬件寄存器(驱动开发)。
关键参数:
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
// prot: PROT_READ/PROT_WRITE/PROT_EXEC/PROT_NONE
// flags: MAP_SHARED(共享,修改写回文件)/ MAP_PRIVATE(写时复制)
// MAP_ANONYMOUS(匿名映射,不关联文件)
注意:
- 映射大小必须是页大小(通常 4KB)的整数倍。
- 访问越界会触发 SIGSEGV。
- 需用
msync将修改刷回磁盘,munmap解除映射。
7. 线程间通信的方式?
线程共享进程地址空间,通信比进程简单,主要是同步与互斥:
| 方式 | 说明 |
|---|---|
| 全局变量/共享内存 | 线程直接读写进程的全局变量和堆,最简单的通信方式 |
| 互斥锁 (Mutex) | 保护共享资源,同一时刻只有一个线程访问 |
| 条件变量 (Condition Variable) | 线程间等待/通知机制,如生产者消费者 |
| 信号量 (Semaphore) | 计数器,控制同时访问资源的线程数 |
| 读写锁 (RWLock) | 多读单写,读并发、写独占 |
| 自旋锁 (Spinlock) | 忙等待,适合锁持有时间极短的场景 |
| 屏障 (Barrier) | 所有线程到达屏障后才继续执行 |
| 消息队列/无锁队列 | 线程间传递消息,避免显式锁 |
| future/promise | C++11 异步任务结果传递 |
| 线程局部存储 (TLS) | thread_local,每个线程独立副本,避免共享 |
C++11 标准库:
#include <mutex> // std::mutex, std::lock_guard, std::unique_lock
#include <condition_variable> // std::condition_variable
#include <future> // std::async, std::future, std::promise
#include <atomic> // std::atomic 原子操作
#include <thread> // std::thread
注意:
- 共享变量访问必须同步,否则是数据竞争(未定义行为)。
- 避免死锁:按固定顺序加锁、用
std::lock同时加多个锁、设置超时。 - 优先用
std::lock_guard(RAII)代替手动 lock/unlock。
8. Linux 进程同步的机制?
| 机制 | 说明 | 适用场景 |
|---|---|---|
| 互斥锁 (Mutex) | 二值信号量,同一时刻只有一个进程持有 | 保护临界区、共享资源 |
| 信号量 (Semaphore) | 计数器,控制同时访问资源的进程数 | 资源池管理、生产者消费者 |
| 条件变量 (Condition Variable) | 等待/通知机制,需配合互斥锁 | 事件等待、生产者消费者 |
| 读写锁 (RWLock) | 多读单写 | 读多写少的共享数据 |
| 自旋锁 (Spinlock) | 忙等待,不睡眠 | 锁持有时间极短、内核态 |
| 屏障 (Barrier) | 所有进程到达后才继续 | 并行计算阶段同步 |
| 文件锁 (File Lock) | flock/fcntl,对文件加锁 | 多进程访问同一文件 |
| 原子操作 | 不可中断的操作,如 atomic_t | 简单计数器、标志位 |
POSIX 信号量:
- 命名信号量:有名字,多进程共享,
sem_open。 - 无名信号量:放在共享内存中,多进程共享,
sem_init。
System V 信号量:
semget、semop、semctl,功能强大但接口复杂。
注意:
- 进程间同步必须用进程间共享的同步对象(放在共享内存中或命名对象)。
- 普通
pthread_mutex_t默认是进程内的,需设置PTHREAD_PROCESS_SHARED属性才能进程间共享。 - 避免死锁:按固定顺序获取资源、设置超时、用
try系列函数。
9. 进程的状态?
Linux 进程有以下状态:
| 状态 | 符号 | 说明 |
|---|---|---|
| 运行态 | R (Running) | 正在 CPU 上运行,或在运行队列中等待调度 |
| 可中断睡眠态 | S (Sleeping) | 等待事件/资源,可被信号唤醒 |
| 不可中断睡眠态 | D (Disk Sleep) | 等待 I/O 完成,不可被信号中断(kill 也杀不死) |
| 停止态 | T (Stopped) | 被 SIGSTOP/SIGTSTP 暂停,可被 SIGCONT 恢复 |
| 僵尸态 | Z (Zombie) | 进程已结束,但父进程未调用 wait() 回收,PCB 仍存在 |
| 死亡态 | X (Dead) | 进程即将被销毁,父进程已回收 |
状态转换:
创建 → 就绪(R) ↔ 运行(R)
↓ 等待资源
可中断睡眠(S) ← 事件到达/信号
↓ 等待I/O
不可中断睡眠(D) ← I/O完成
↓ 收到SIGSTOP
停止(T) ← SIGCONT
↓ 执行结束/收到SIGKILL
僵尸(Z) → 父进程wait → 死亡(X)
查看进程状态:
ps aux # STAT 列显示状态
ps -eo pid,stat,comm # 只看 PID、状态、命令
top # 实时显示
僵尸进程处理:
- 父进程应调用
wait()/waitpid()回收子进程。 - 父进程先退出,子进程被 init/systemd 收养,会自动回收。
- 僵尸进程无法用 kill 杀死(已死亡),只能杀父进程让 init 收养回收。
10. 写时拷贝 (Copy-On-Write)?
写时拷贝 (COW, Copy-On-Write):一种延迟复制优化策略,多个对象共享同一份数据,只有当某个对象需要修改数据时,才真正复制一份。
应用场景:
1. fork() 创建子进程:
- 子进程创建时不复制父进程的物理内存,只复制页表,共享物理页。
- 页表标记为只读。
- 当子进程或父进程写入某页时,触发缺页异常,内核才复制该页(分配新物理页,拷贝内容,标记为可写)。
- 大大减少 fork 的开销(尤其是大进程)。
2. std::string(历史上):
- 某些旧实现(如旧版 libstdc++)的 string 拷贝构造采用 COW,拷贝时共享数据,修改时才复制。
- C++11 标准后 COW 实现因线程安全问题被禁止,改为深拷贝 + SSO。
3. 虚拟内存 mmap:
MAP_PRIVATE映射采用写时复制,多个进程共享文件页,写入时才复制私有副本。
4. 其他:
- Git 的对象存储、Docker 的镜像层、一些数据库的 MVCC。
优点:
- 减少不必要的内存拷贝,节省内存和时间。
- fork 后子进程立即 exec 时,完全不需要复制内存。
缺点:
- 写入时触发缺页异常,有额外开销。
- 多线程环境下 COW 的引用计数管理复杂(需要原子操作)。
11. 自旋锁 (Spinlock)?
自旋锁 (Spinlock):一种忙等待锁,当锁被占用时,等待线程循环检查锁是否可用("自旋"),而不是睡眠阻塞。
特点:
- 不睡眠,不释放 CPU,持续轮询。
- 适用于锁持有时间极短的场景(如内核中保护几个变量的操作)。
- 避免了进程/线程上下文切换的开销(切换开销可能比锁本身还大)。
适用场景:
- 多核处理器(单核上自旋无意义,应用互斥锁)。
- 锁持有时间很短(几条指令)。
- 中断上下文(不能睡眠,只能用自旋锁)。
- 内核态同步。
不适用场景:
- 锁持有时间长(浪费 CPU)。
- 单核 CPU(自旋的线程占着 CPU,持锁线程无法运行)。
- 用户态通常用互斥锁,自旋锁用得少。
C++ 中的自旋锁:
#include <atomic>
class SpinLock {
std::atomic<bool> flag{false};
public:
void lock() {
while (flag.exchange(true, std::memory_order_acquire)) {
// 自旋等待
}
}
void unlock() {
flag.store(false, std::memory_order_release);
}
};
自旋锁 vs 互斥锁:
| 区别 | 自旋锁 | 互斥锁 |
|---|---|---|
| 等待方式 | 忙等待,占 CPU | 睡眠,释放 CPU |
| 上下文切换 | 无 | 有(睡眠/唤醒) |
| 适用场景 | 锁持有时间极短 | 锁持有时间较长 |
| 单核 | 不适用 | 适用 |
| 中断上下文 | 可用 | 不可用(不能睡眠) |
优化:可在自旋中加入 cpu_relax() / pause 指令减少功耗,或设置自旋上限,超过后退化为互斥锁(自适应锁,如 Linux 内核的 mutex)。
12. 可重入函数是什么意思?为什么一定是线程安全的?
可重入函数 (Reentrant Function):可以在执行过程中被中断(如信号、中断),然后再次被调用(重入),而不会产生错误结果的函数。
可重入的条件:
- 不使用全局或静态的可变数据。
- 不返回指向全局/静态可变数据的指针。
- 只调用可重入函数。
- 不使用不可重入的标准库函数(如
malloc、printf、strtok)。 - 不修改自身代码(自修改代码)。
可重入 vs 线程安全:
| 区别 | 可重入 | 线程安全 |
|---|---|---|
| 定义 | 可被中断后重入执行不出错 | 多线程同时调用不出错 |
| 关注场景 | 中断/信号处理、单线程重入 | 多线程并发 |
| 是否允许锁 | 不允许(可能死锁) | 允许 |
| 是否允许静态数据 | 不允许可变静态数据 | 允许,但需同步保护 |
| 关系 | 可重入 → 一定线程安全 | 线程安全 → 不一定可重入 |
为什么可重入函数一定是线程安全的?
- 可重入函数不使用任何全局/静态可变数据,所有数据都是局部的(在栈上)。
- 每个线程有独立的栈,因此多个线程同时调用可重入函数时,各自操作自己的局部数据,互不干扰。
- 没有共享数据,就没有数据竞争,因此天然线程安全。
线程安全但不可重入的例子:
// 线程安全(有锁),但不可重入(用了全局变量+锁)
std::mutex mtx;
int counter = 0;
int increment() {
std::lock_guard<std::mutex> lock(mtx);
return ++counter;
}
// 如果在信号处理函数中调用 increment(),而信号中断了正在持锁的 increment(),
// 会导致死锁(同一线程重复加锁,普通 mutex 不可重入)。
可重入的例子:
// 可重入:只用局部变量
int square(int x) {
int result = x * x; // 局部变量
return result;
}
// 可重入:用调用者提供的缓冲区
void safe_itoa(int n, char* buf, int size) {
snprintf(buf, size, "%d", n); // 结果写入调用者的缓冲区
}
注意:
- 信号处理函数中只能调用可重入函数(POSIX 定义了 async-signal-safe 函数列表)。
malloc、free、printf等大多数标准库函数不可重入。
13. write 阻塞的原因有哪些?
write() 系统调用在以下情况可能阻塞:
-
套接字发送缓冲区满:
- TCP 套接字的发送缓冲区 (send buffer) 被数据填满,对端接收慢或网络拥塞导致数据发不出去。
- 这是最常见的原因。
-
对端接收窗口为 0 (Zero Window):
- 对端接收缓冲区满,TCP 通告窗口大小为 0,发送方不能再发送数据。
- 需等待对端处理数据后窗口恢复。
-
流量控制/拥塞控制:
- 网络拥塞,TCP 拥塞窗口减小,发送速率受限。
-
文件写入慢:
- 写入磁盘文件时,磁盘 I/O 慢(如机械磁盘、磁盘满、I/O 等待)。
- 写入管道 (pipe) 时,管道缓冲区满且对端未读取。
-
Nagle 算法:
- TCP 默认启用 Nagle 算法,小数据包会等待确认或攒够一定量才发送,可能导致延迟。
- 可用
TCP_NODELAY选项禁用。
-
套接字为阻塞模式:
- 默认套接字是阻塞的,write 会一直等到有空间或出错。
- 设为非阻塞 (O_NONBLOCK) 时,write 会立即返回 EAGAIN/EWOULDBLOCK。
-
内核缓冲区锁竞争:
- 多线程同时 write 同一套接字,内核需加锁保护,可能短暂阻塞。
解决方法:
- 使用非阻塞 I/O + epoll,监听 EPOLLOUT 事件。
- 应用层维护发送缓冲区,write 不全时缓存剩余数据。
- 调整发送缓冲区大小:
setsockopt(sock, SOL_SOCKET, SO_SNDBUF, ...)。 - 禁用 Nagle:
setsockopt(sock, IPPROTO_TCP, TCP_NODELAY, ...)。 - 检查对端是否正常接收。
14. 调用 send 发送数据不全怎么办?
send() 返回实际发送的字节数,可能小于请求发送的字节数(尤其是非阻塞套接字或发送缓冲区满时),需要处理。
原因:
- 发送缓冲区满,TCP 不能一次性接收所有数据。
- 非阻塞模式下,send 不等待,立即返回已发送的部分。
- 信号中断(EINTR)。
解决方法:循环发送:
ssize_t send_all(int sockfd, const void* buf, size_t len) {
const char* ptr = (const char*)buf;
size_t remaining = len;
while (remaining > 0) {
ssize_t sent = send(sockfd, ptr, remaining, 0);
if (sent > 0) {
ptr += sent;
remaining -= sent;
} else if (sent == 0) {
// 对端关闭?send 返回 0 比较少见
break;
} else {
if (errno == EINTR) {
continue; // 被信号中断,重试
}
if (errno == EAGAIN || errno == EWOULDBLOCK) {
// 非阻塞模式,发送缓冲区满
// 方法1:阻塞等待(不推荐,会卡住事件循环)
// 方法2:注册 EPOLLOUT,等可写时继续发送剩余数据
// 这里返回已发送的字节数,上层缓存剩余数据
return len - remaining;
}
return -1; // 真正的错误
}
}
return len; // 全部发送完成
}
非阻塞 + epoll 的完整方案:
- 应用层维护每个连接的发送缓冲区(如
std::string或环形缓冲区)。 - 调用 send 时,先尝试发送,如果没发完,剩余数据存入发送缓冲区。
- 注册 EPOLLOUT 事件。
- epoll_wait 返回 EPOLLOUT 时,继续发送缓冲区中的数据。
- 发送缓冲区为空时,移除 EPOLLOUT(避免一直触发空转)。
// 伪代码
class Connection {
std::string send_buf_;
bool writing_ = false;
void send(const char* data, size_t len) {
if (!writing_) {
ssize_t n = ::send(fd_, data, len, MSG_DONTWAIT);
if (n > 0 && n < len) {
send_buf_.assign(data + n, len - n);
register_epollout();
writing_ = true;
} else if (n < 0 && errno == EAGAIN) {
send_buf_.assign(data, len);
register_epollout();
writing_ = true;
}
} else {
send_buf_.append(data, len); // 追加到缓冲区
}
}
void on_writable() { // EPOLLOUT 触发
ssize_t n = ::send(fd_, send_buf_.data(), send_buf_.size(), MSG_DONTWAIT);
if (n > 0) {
send_buf_.erase(0, n);
if (send_buf_.empty()) {
unregister_epollout();
writing_ = false;
}
}
}
};
注意:
- 阻塞套接字下 send 通常会发送全部数据(除非出错或被信号中断),但仍建议检查返回值。
- 不要假设 send 一定发送全部数据。
- TCP 是字节流,没有消息边界,发送不全是正常现象。
15. TCP 的超时重传机制?
超时重传 (Retransmission Timeout, RTO):TCP 发送方在发送数据后启动定时器,如果在指定时间内未收到接收方的确认 (ACK),则重新发送该数据。
核心机制:
-
RTT 测量:
- RTT (Round-Trip Time):报文段从发送到收到确认的往返时间。
- TCP 持续测量 RTT,动态调整 RTO。
-
RTO 计算(Jacobson/Karels 算法):
- 平滑 RTT (SRTT):
SRTT = (1 - α) × SRTT + α × RTT,α 通常为 1/8。 - RTT 偏差 (RTTVAR):
RTTVAR = (1 - β) × RTTVAR + β × |SRTT - RTT|,β 通常为 1/4。 - RTO:
RTO = SRTT + 4 × RTTVAR。 - RTO 至少为 1 秒(Linux 实现),最大约 120 秒。
- 平滑 RTT (SRTT):
-
重传定时器:
- 每个发送的报文段都有一个重传定时器。
- 定时器超时后,重传该报文段。
- 指数退避 (Exponential Backoff):每次重传后,RTO 翻倍(RTO = RTO × 2),最多重传约 15 次后放弃连接。
-
快速重传 (Fast Retransmit):
- 不等待 RTO 超时,收到 3 个重复的 ACK (DupACK) 就立即重传。
- 因为重复 ACK 说明后续报文已到达,只有丢失的那个需要重传。
- 比超时重传快得多。
-
选择确认 (SACK, Selective Acknowledgment):
- 接收方在 ACK 中告知发送方哪些报文段已收到、哪些丢失。
- 发送方只需重传丢失的报文段,不需要重传所有后续报文。
- 提高重传效率。
超时后的行为:
- 重传丢失的报文段。
- 拥塞窗口 (cwnd) 减半(或重置为 1,取决于实现)。
- 慢启动阈值 (ssthresh) 调整为当前窗口的一半。
- RTO 翻倍(指数退避)。
注意:
- TCP 的超时重传保证了数据的可靠传输。
- 但频繁超时重传会导致性能下降,说明网络拥塞或丢包严重。
- 应用层无法直接控制 RTO,但可以通过调整 TCP 参数(如
tcp_retries1、tcp_retries2)影响重传行为。
16. TCP 通信过程的状态变化?
TCP 状态机(11 个状态):
| 状态 | 说明 |
|---|---|
| CLOSED | 初始状态,连接未建立或已完全关闭 |
| LISTEN | 服务器端,等待客户端连接请求 |
| SYN_SENT | 客户端已发送 SYN,等待服务器的 SYN+ACK |
| SYN_RCVD | 服务器已收到 SYN 并发送 SYN+ACK,等待客户端的 ACK |
| ESTABLISHED | 连接已建立,可以传输数据 |
| FIN_WAIT_1 | 主动关闭方已发送 FIN,等待对方的 ACK |
| FIN_WAIT_2 | 主动关闭方收到 ACK,等待对方的 FIN |
| CLOSE_WAIT | 被动关闭方收到 FIN 并发送 ACK,等待应用层关闭 |
| CLOSING | 双方同时发送 FIN,等待对方的 ACK(较少见) |
| LAST_ACK | 被动关闭方已发送 FIN,等待对方的 ACK |
| TIME_WAIT | 主动关闭方收到 FIN 并发送 ACK,等待 2MSL 后关闭 |
状态转换图:
建立连接(三次握手):
CLOSED → (主动 connect, 发SYN) → SYN_SENT → (收SYN+ACK, 发ACK) → ESTABLISHED
CLOSED → (被动 listen) → LISTEN → (收SYN, 发SYN+ACK) → SYN_RCVD → (收ACK) → ESTABLISHED
关闭连接(四次挥手):
主动关闭方:
ESTABLISHED → (发FIN) → FIN_WAIT_1 → (收ACK) → FIN_WAIT_2 → (收FIN, 发ACK) → TIME_WAIT → (2MSL后) → CLOSED
被动关闭方:
ESTABLISHED → (收FIN, 发ACK) → CLOSE_WAIT → (应用层close, 发FIN) → LAST_ACK → (收ACK) → CLOSED
同时关闭:
ESTABLISHED → (发FIN) → FIN_WAIT_1 → (收FIN, 发ACK) → CLOSING → (收ACK) → TIME_WAIT → CLOSED
查看连接状态:
netstat -tlnp # 查看监听状态
netstat -tan # 查看所有 TCP 连接状态
ss -tan # 更现代的工具
常见状态问题:
- 大量 TIME_WAIT:主动关闭方多,高并发短连接服务器常见。可调整
tcp_tw_reuse、tcp_tw_recycle(已废弃)、tcp_fin_timeout。 - 大量 CLOSE_WAIT:被动关闭方应用层没有调用 close(),连接泄漏。需检查代码是否正确关闭连接。
- 大量 SYN_RCVD:可能遭受 SYN Flood 攻击。可启用
tcp_syncookies。
17. 网络的七层模型?
OSI 七层模型(Open Systems Interconnection Reference Model):
| 层级 | 名称 | 功能 | 典型协议/设备 |
|---|---|---|---|
| 7 | 应用层 (Application) | 为应用程序提供网络服务,定义报文语义 | HTTP、HTTPS、FTP、SMTP、DNS、SSH |
| 6 | 表示层 (Presentation) | 数据格式转换、加密解密、压缩解压缩 | SSL/TLS、ASCII、JPEG、MPEG |
| 5 | 会话层 (Session) | 建立、管理、终止会话,同步点 | RPC、SQL、NetBIOS |
| 4 | 传输层 (Transport) | 端到端的数据传输,流量控制,差错恢复 | TCP、UDP、端口号 |
| 3 | 网络层 (Network) | 路由选择,逻辑寻址 (IP),分组转发 | IP、ICMP、ARP、路由器 |
| 2 | 数据链路层 (Data Link) | 物理寻址 (MAC),帧的封装,差错检测 | Ethernet、PPP、交换机、网卡 |
| 1 | 物理层 (Physical) | 比特流传输,电气/机械/功能规范 | 网线、光纤、集线器、中继器 |
数据封装过程(发送方):
应用层数据 → 加应用层首部 → 表示层 → 会话层
→ 传输层:加 TCP/UDP 首部(含端口)→ 段 (Segment)
→ 网络层:加 IP 首部(含 IP 地址)→ 包/分组 (Packet)
→ 数据链路层:加帧首部和尾部(含 MAC 地址、FCS)→ 帧 (Frame)
→ 物理层:比特流 (Bit)
解封装过程(接收方):从物理层到应用层,逐层去掉首部,最终得到应用层数据。
TCP/IP 四层模型(实际使用的模型,将 OSI 七层合并):
| TCP/IP 四层 | 对应 OSI 七层 |
|---|---|
| 应用层 | 应用层 + 表示层 + 会话层 |
| 传输层 | 传输层 |
| 网络层 | 网络层 |
| 网络接口层 | 数据链路层 + 物理层 |
五层模型(教学常用,在 TCP/IP 四层基础上将网络接口层拆为数据链路层和物理层):
应用层 → 传输层 → 网络层 → 数据链路层 → 物理层。
18. HTTP 有哪些常用方法?
| 方法 | 作用 | 幂等 | 安全 | 有请求体 |
|---|---|---|---|---|
| GET | 获取资源 | 是 | 是 | 通常无 |
| POST | 提交数据/创建资源 | 否 | 否 | 有 |
| PUT | 全量更新资源(替换) | 是 | 否 | 有 |
| PATCH | 部分更新资源 | 否 | 否 | 有 |
| DELETE | 删除资源 | 是 | 否 | 通常无 |
| HEAD | 获取响应头(无响应体) | 是 | 是 | 无 |
| OPTIONS | 查询服务器支持的方法/CORS 预检 | 是 | 是 | 无 |
| CONNECT | 建立隧道(HTTPS 代理) | 否 | 否 | 无 |
| TRACE | 回显请求(调试用,有安全风险) | 是 | 否 | 无 |
常用方法详解:
GET:
- 请求指定资源,参数在 URL 查询字符串中。
- 幂等:多次调用结果相同。
- 安全:不修改服务器资源。
- 长度受限(URL 长度限制,不同浏览器不同)。
- 可被缓存、收藏为书签。
POST:
- 向服务器提交数据,请求体中包含数据。
- 非幂等:多次提交可能创建多个资源。
- 常用于表单提交、创建资源、登录等。
- 数据不在 URL 中,相对安全(但仍需 HTTPS 加密)。
PUT:
- 用请求体中的数据全量替换目标资源。
- 幂等:多次 PUT 相同数据结果相同。
- 如果资源不存在,有些实现会创建。
PATCH:
- 对资源进行部分更新(只更新指定字段)。
- 非幂等(取决于补丁内容)。
- 比 PUT 更高效,不需要发送完整资源。
DELETE:
- 删除指定资源。
- 幂等:删除已删除的资源结果相同(都是不存在)。
HEAD:
- 和 GET 类似,但服务器只返回响应头,不返回响应体。
- 用于检查资源是否存在、获取元信息(如 Content-Length、Last-Modified),效率高。
OPTIONS:
- 查询服务器对指定资源支持的 HTTP 方法(响应头 Allow)。
- CORS 中的预检请求 (preflight) 使用 OPTIONS。
幂等性 (Idempotent):多次执行相同请求,服务器状态结果相同。GET、PUT、DELETE、HEAD、OPTIONS 是幂等的;POST、PATCH 不是。
安全性 (Safe):请求不修改服务器资源。GET、HEAD、OPTIONS 是安全的。
💡 低频题(19题)
较少出现或较偏,时间充裕时了解即可。
1. 创建软链接的命令是什么?
ln -s 源文件 链接名
示例:
ln -s /usr/local/bin/python3 /usr/bin/python # 创建软链接
ls -l /usr/bin/python # 查看链接指向
软链接 vs 硬链接:
| 区别 | 软链接 (symbolic link) | 硬链接 (hard link) |
|---|---|---|
| 命令 | ln -s 源 链接 | ln 源 链接 |
| 本质 | 独立文件,存储源文件路径 | 指向同一 inode 的目录项 |
| 跨文件系统 | 可以 | 不可以 |
| 链接到目录 | 可以 | 不可以(root 除外,不推荐) |
| 源文件删除后 | 链接失效(悬空链接) | 仍可访问(inode 引用计数 > 0) |
| 文件类型 | l(链接文件) | 与源文件相同 |
| inode | 不同 | 相同 |
常用场景:
- 创建命令快捷方式(如
/usr/bin/python→ 实际安装路径) - 配置文件管理(多版本切换)
- 库文件版本管理(
lib.so→lib.so.1.2.3)
2. /proc 文件夹下放的是什么?
/proc 是虚拟文件系统 (procfs),不占用实际磁盘空间,内容在内存中动态生成,是内核与用户空间交互的窗口。
主要内容:
1. 进程信息(每个进程一个目录,以 PID 命名):
/proc/[pid]/status:进程状态(名称、PID、PPID、内存、权限等)/proc/[pid]/cmdline:启动命令行/proc/[pid]/cwd:当前工作目录(符号链接)/proc/[pid]/exe:可执行文件路径(符号链接)/proc/[pid]/maps:内存映射/proc/[pid]/fd/:打开的文件描述符/proc/[pid]/environ:环境变量/proc/self:当前进程的符号链接
2. 系统信息:
/proc/cpuinfo:CPU 信息/proc/meminfo:内存信息/proc/version:内核版本/proc/uptime:系统运行时间/proc/loadavg:系统负载/proc/filesystems:支持的文件系统/proc/modules:已加载内核模块/proc/mounts:挂载信息
3. 内核参数(可读写,sysctl 对应):
/proc/sys/:可修改的内核参数/proc/sys/net/ipv4/ip_forward:IP 转发/proc/sys/vm/swappiness:swap 使用倾向/proc/sys/kernel/hostname:主机名
特点:
- 大部分文件大小为 0,读取时才由内核动态生成内容。
- 可通过
cat查看,部分可通过echo修改(立即生效,重启失效)。 - 永久修改需写入
/etc/sysctl.conf。
3. Linux 下有哪些文件类型?
用 ls -l 查看,第一个字符表示文件类型:
| 符号 | 类型 | 说明 |
|---|---|---|
- | 普通文件 | 文本、二进制、压缩包等 |
d | 目录文件 | 文件夹 |
l | 符号链接 | 软链接,指向另一个文件 |
c | 字符设备 | 按字符流访问,如终端、键盘 /dev/tty |
b | 块设备 | 按块访问,如磁盘 /dev/sda |
p | 命名管道 (FIFO) | 进程间通信 |
s | 套接字 (socket) | 进程间/网络通信 |
查看文件类型的命令:
ls -l filename # 第一个字符
file filename # 详细描述文件类型
stat filename # 详细信息
特殊文件:
/dev/null:黑洞,写入丢弃,读取返回 EOF。/dev/zero:读取返回无限的 0 字节。/dev/random、/dev/urandom:随机数生成器。/dev/tty:当前终端。
4. gdb 堆栈信息不准怎么办?
可能原因和解决方法:
1. 编译优化级别过高:
- 优化(-O2/-O3)可能内联函数、消除变量、重排代码,导致栈帧不完整。
- 解决:用
-O0 -g重新编译,关闭优化。
2. 缺少调试信息:
- 未加
-g参数,或 strip 掉了符号表。 - 解决:用
-g(或-ggdb)重新编译,不要 strip。
3. 帧指针被优化掉:
-fomit-frame-pointer(高优化级别默认开启)消除了帧指针寄存器,gdb 无法正确回溯栈。- 解决:加
-fno-omit-frame-pointer编译。
4. 汇编代码/手写汇编:
- 没有正确的 CFI(Call Frame Information)调试信息。
- 解决:在汇编中添加
.cfi_*伪指令。
5. 栈被破坏:
- 缓冲区溢出、野指针写入破坏了栈上的返回地址和帧指针。
- 解决:用
valgrind、AddressSanitizer检测内存错误。
6. 信号处理函数中:
- 信号处理函数的栈帧可能不完整。
- 解决:
bt可能仍能显示,用info signals查看信号处理。
7. 多线程问题:
- 需切换到正确线程:
thread apply all bt查看所有线程栈。
手动处理:
- 用
x/100gx $rsp查看原始栈内存,手动分析返回地址。 - 用
info symbol 地址查看地址对应的函数。
5. 循环内出现问题,gdb 调试要等很久怎么办?
加速调试的方法:
1. 条件断点:
break file.cpp:100 if i == 9999 # 只在 i=9999 时停住
break func if ptr == nullptr # 指针为空时停住
避免每次循环都中断。
2. 跳过前 N 次:
break file.cpp:100
continue 1000 # 跳过断点 1000 次,第 1001 次才停
3. 数据断点 (watchpoint):
watch variable # 变量值变化时停住
watch *address # 内存地址内容变化时停住
rwatch variable # 读时停住
awatch variable # 读写时停住
直接定位变量被异常修改的位置。
4. 反向调试:
record # 开始记录执行
reverse-step # 反向单步
reverse-continue # 反向继续
从崩溃点往回找原因。
5. 在循环外加断点,手动修改变量:
break file.cpp:90 # 循环前
set var i = 9990 # 直接跳到接近问题的迭代
continue
6. 用日志代替断点:
- 在循环内加
printf或日志,运行到崩溃后分析日志。 - 比反复中断快得多。
7. 缩小循环范围:
- 二分法:先跑前半段看是否崩溃,逐步缩小范围。
- 修改代码临时减少循环次数。
8. 采样分析:
- 用
perf record+perf report采样,看程序卡在哪里。 - 不需要中断程序。
6. Linux 文件系统读入文件的过程?
以 read() 系统调用读取文件为例:
-
路径解析:
- 内核根据文件路径(如
/home/user/file.txt)逐级查找目录项 (dentry)。 - 从根目录
/开始,查找home→user→file.txt。 - 查找过程中可能需要从磁盘读取目录块(有 dentry cache 时可加速)。
- 内核根据文件路径(如
-
获取 inode:
- 找到文件的目录项后,获取其 inode 编号。
- inode 存储文件的元数据:大小、权限、所有者、时间戳、数据块指针等。
- inode 中有 12 个直接块指针、1 个一级间接块、1 个二级间接块、1 个三级间接块,定位数据块。
-
检查权限:
- 检查进程是否有读权限。
-
检查页缓存 (Page Cache):
- 内核先在页缓存中查找文件对应的数据页。
- 若命中(缓存已有数据),直接从缓存拷贝到用户缓冲区,返回。
- 若未命中,触发缺页,进入下一步。
-
提交 I/O 请求:
- 内核根据 inode 的数据块指针,计算文件偏移对应的磁盘块号。
- 向块设备层提交读请求(bio 结构)。
- I/O 调度器(如 CFQ、deadline、noop)合并、排序请求,减少磁盘寻道。
-
磁盘读取:
- 磁盘控制器执行读操作,将数据从磁盘读到内存缓冲区。
- DMA 控制器直接传输数据到内存,不占用 CPU。
- 读取完成后触发中断,通知内核 I/O 完成。
-
填充页缓存:
- 读取的数据页加入页缓存,后续访问可直接命中。
-
拷贝到用户空间:
- 内核将数据从页缓存拷贝到用户提供的缓冲区。
read()返回实际读取的字节数。
优化机制:
- 预读 (readahead):内核预测顺序读,提前读取后续页面到缓存。
- 页缓存:热点数据缓存在内存,减少磁盘 I/O。
- O_DIRECT:绕过页缓存,直接 I/O(数据库常用)。
7. Linux 程序运行找不到动态库 .so 文件的解决办法?
错误信息:error while loading shared libraries: libxxx.so: cannot open shared object file: No such file or directory
三种解决办法:
1. 设置环境变量 LD_LIBRARY_PATH:
export LD_LIBRARY_PATH=/path/to/libs:$LD_LIBRARY_PATH
./program
- 临时生效,只对当前 shell 有效。
- 适合测试和开发环境。
2. 将库路径写入 /etc/ld.so.conf 配置文件:
echo "/path/to/libs" | sudo tee /etc/ld.so.conf.d/myapp.conf
sudo ldconfig # 更新缓存
- 永久生效,系统级配置。
ldconfig会扫描配置的目录,更新/etc/ld.so.cache缓存。
3. 编译时指定库的运行路径 (rpath):
g++ main.cpp -o program -L/path/to/libs -lxxx -Wl,-rpath,/path/to/libs
- 编译时将库路径写入可执行文件,运行时自动查找。
- 适合发布独立应用。
- 也可用
$ORIGIN表示可执行文件所在目录:-Wl,-rpath,'$ORIGIN/lib'。
其他方法:
- 将
.so文件复制到系统默认库目录(/usr/lib、/usr/local/lib),然后ldconfig。 - 创建软链接:
ln -s /path/to/libxxx.so /usr/lib/libxxx.so。
查看程序依赖的库:
ldd ./program # 列出所有依赖的动态库及其路径
8. 什么是孤儿进程?
孤儿进程 (Orphan Process):父进程先退出,子进程还在运行,此时子进程成为孤儿进程。
处理机制:
- 孤儿进程会被 init 进程(PID 1) 或 systemd 收养。
- init 进程会成为孤儿进程的新父进程,并负责在其退出时调用
wait()回收资源。 - 因此孤儿进程不会变成僵尸进程,没有危害。
示例:
#include <unistd.h>
#include <stdio.h>
int main() {
pid_t pid = fork();
if (pid == 0) {
// 子进程
sleep(10); // 父进程已退出,子进程成为孤儿
printf("Child (orphan) parent pid: %d\n", getppid()); // 父PID变为1
} else {
// 父进程
printf("Parent exiting, child pid: %d\n", pid);
// 父进程直接退出,不 wait
}
return 0;
}
孤儿进程 vs 僵尸进程:
| 区别 | 孤儿进程 | 僵尸进程 |
|---|---|---|
| 定义 | 父进程先退出,子进程仍运行 | 子进程先退出,父进程未回收 |
| 状态 | 正常运行 (R/S) | 僵尸态 (Z) |
| 危害 | 无,被 init 收养回收 | 占用 PID 和 PCB 资源,大量存在会耗尽 PID |
| 处理 | 自动被 init 收养 | 需父进程 wait,或杀父进程让 init 回收 |
应用:守护进程 (daemon) 的创建就是故意让进程成为孤儿,脱离终端控制。
9. 结束进程的方式有哪些?
1. 正常退出:
return从 main 返回。exit(status):标准库函数,执行清理(atexit 函数、刷新缓冲区、关闭文件)后退出。_exit(status)/_Exit(status):系统调用,直接退出,不执行清理。abort():异常终止,产生 SIGABRT 信号,生成 core dump。
2. 信号终止:
kill <pid> # 默认发送 SIGTERM (15),请求进程正常终止
kill -9 <pid> # 发送 SIGKILL (9),强制终止,不可捕获/忽略
kill -15 <pid> # SIGTERM,可被进程捕获做清理
kill -2 <pid> # SIGINT,等同 Ctrl+C
kill -l # 列出所有信号
pkill process_name # 按名称杀进程
killall process_name # 杀死所有同名进程
常见信号:
| 信号 | 编号 | 说明 | 可捕获 |
|---|---|---|---|
| SIGTERM | 15 | 请求终止,默认动作 | 是 |
| SIGKILL | 9 | 强制杀死 | 否 |
| SIGINT | 2 | 终端中断 (Ctrl+C) | 是 |
| SIGQUIT | 3 | 终端退出 (Ctrl+),生成 core | 是 |
| SIGSTOP | 19 | 暂停进程 | 否 |
| SIGSEGV | 11 | 段错误 | 是(但通常不恢复) |
3. 其他方式:
xkill:图形界面点击杀死窗口。systemctl stop service:停止服务进程。- 进程收到致命错误(段错误、断言失败)自动终止。
注意:
- 优先用 SIGTERM,给进程清理机会;SIGKILL 是最后手段。
- D 状态(不可中断睡眠)的进程连 SIGKILL 也杀不死,需等待 I/O 完成或重启。
10. 什么是会话 (session)?
会话 (Session) 是一个或多个进程组的集合,由会话首进程 (session leader) 创建,会话 ID 等于首进程的 PID。
相关概念:
- 进程组 (Process Group):一个或多个进程的集合,进程组 ID 等于组长进程的 PID。
- 会话:一个或多个进程组的集合。
- 控制终端 (Controlling Terminal):会话可以关联一个控制终端,用于输入输出和信号传递。
会话中的进程组:
- 前台进程组:接收控制终端输入的进程组,同一时刻只有一个。
- 后台进程组:不接收终端输入的进程组,可以有多个。
创建会话:
pid_t setsid(void); // 创建新会话,调用者成为会话首进程和进程组组长
- 调用者不能是进程组组长,否则失败。
- 新会话没有控制终端。
查看会话:
ps -ejH # 查看会话ID、进程组ID、进程树
ps -o pid,ppid,pgid,sid,comm # 查看各进程的会话ID
应用:
- 守护进程创建:
fork()→ 子进程setsid()创建新会话脱离终端 → 再次fork()确保不是会话首进程。 - 终端登录:用户登录时,shell 进程创建一个新会话,关联终端。
- nohup:使进程忽略 SIGHUP 信号,终端关闭后进程继续运行。
SIGHUP 信号:当控制终端断开时,会向会话首进程发送 SIGHUP,默认终止会话内所有进程。守护进程通过脱离控制终端避免收到此信号。
11. 守护进程与后台进程的区别?
| 区别 | 守护进程 (Daemon) | 后台进程 |
|---|---|---|
| 定义 | 长期运行的后台服务进程,脱离终端 | 在后台运行的普通进程 |
| 终端 | 完全脱离控制终端,无 stdin/stdout/stderr | 仍关联终端,只是不占用前台 |
| 会话 | 创建独立会话 (setsid),是会话首进程 | 属于当前 shell 的会话 |
| 生命周期 | 通常随系统启动,长期运行 | 随 shell 会话,终端关闭可能终止 |
| 工作目录 | 通常设为根目录 / | 继承当前目录 |
| 文件权限掩码 | 通常设为 0 (umask 0) | 继承 shell 的 umask |
| 启动方式 | systemd 服务、init 脚本、daemon 命令 | & 后缀、nohup、bg |
| 例子 | sshd、nginx、mysqld | sleep 100 & |
创建守护进程的步骤:
1. fork(),父进程退出,子进程继续
2. setsid(),创建新会话,脱离控制终端
3. 再次 fork(),确保不是会话首进程(防止重新获取控制终端)
4. chdir("/"),改变工作目录到根目录
5. umask(0),清除文件权限掩码
6. 关闭 stdin/stdout/stderr,重定向到 /dev/null
后台进程:
./program & # 后台运行,仍关联终端
nohup ./program & # 忽略 SIGHUP,终端关闭后继续运行
关键区别:守护进程完全独立于终端和登录会话,是系统级服务;后台进程仍受当前 shell 和终端管理。
12. 信号量处理耗费多长时间?信号量同步有什么问题?
信号量操作耗时:
- P 操作 (wait/sem_wait) 和 V 操作 (post/sem_post) 本身是原子操作,耗时极短(纳秒级,几条指令)。
- 但如果信号量值为 0,P 操作会导致线程睡眠阻塞,等待时间取决于何时被 V 操作唤醒,可能很长(毫秒到秒级)。
- 睡眠/唤醒涉及内核态切换(系统调用),开销比纯用户态操作大(约几微秒)。
信号量同步的问题:
-
优先级反转 (Priority Inversion):
- 低优先级线程持有信号量,高优先级线程等待,中等优先级线程抢占低优先级运行,导致高优先级等待更久。
- 解决:优先级继承协议(PIP)、优先级天花板协议(PCP)。
-
死锁:
- 多个信号量的获取顺序不一致可能导致死锁。
- 需按固定顺序获取。
-
活锁 (Livelock):
- 线程都在运行但都在等待对方,不断重试但无法推进。
- 比死锁更难检测。
-
竞态条件:
- 信号量使用不当(如忘记 P/V、P/V 不配对)仍会导致数据竞争。
-
性能开销:
- 信号量操作涉及内核态(System V 信号量)或原子操作(POSIX 无名信号量),有一定开销。
- 对于极短的临界区,自旋锁可能更高效。
-
调试困难:
- 信号量相关的 bug(死锁、竞态)难以复现和调试,依赖时序。
-
计数错误:
- 信号量初值设置错误、V 操作次数多于 P 操作,可能导致资源过度分配。
选择建议:
- 互斥用
std::mutex(比信号量简单高效)。 - 资源计数用信号量。
- 事件通知用条件变量(比信号量更灵活)。
13. 登录 shell 进程是如何启动的?
以 Linux 文本登录为例:
-
init/systemd 启动 getty:
- 系统启动后,init(或 systemd)为每个终端(tty)启动
getty进程。 - getty 打开终端设备,设置终端属性,显示登录提示符("login:")。
- 系统启动后,init(或 systemd)为每个终端(tty)启动
-
用户输入用户名:
- getty 读取用户输入的用户名。
- getty 执行
login程序,将用户名作为参数传入。
-
login 验证用户:
- login 提示输入密码。
- login 验证用户名和密码(通过
/etc/passwd、/etc/shadow、PAM 模块)。 - 验证失败则退出,getty 重新显示登录提示符。
-
登录成功后设置环境:
- login 设置用户的 UID/GID、环境变量(HOME、USER、SHELL、PATH 等)。
- login 将当前工作目录切换到用户的家目录(
/etc/passwd中指定)。 - login 打开终端的 stdin/stdout/stderr。
-
启动 shell:
- login 执行用户的登录 shell(
/etc/passwd中指定,如/bin/bash)。 - shell 以登录 shell 模式启动(
-bash,argv[0] 前加-表示登录 shell)。
- login 执行用户的登录 shell(
-
shell 初始化:
- 登录 shell 依次读取执行:
/etc/profile(系统级环境配置)/etc/profile.d/*.sh(系统级脚本)~/.bash_profile、~/.bash_login、~/.profile(用户级,按顺序找第一个存在的)
- 非登录 shell(如终端中开新标签)读取
~/.bashrc。
- 登录 shell 依次读取执行:
-
shell 交互:
- shell 显示命令提示符,等待用户输入命令。
- 用户输入命令后,shell 解析、fork+exec 执行命令,等待命令完成后显示提示符。
图形登录:通过显示管理器(gdm、lightdm)验证用户后启动桌面环境(GNOME、KDE),桌面环境再启动终端模拟器时启动非登录 shell。
14. sleep() 调用后进程有哪些过程?sleep 过程中占用 CPU 吗?
sleep() 的执行过程:
-
调用 sleep(seconds):
- 进程调用
sleep()库函数(内部通常用nanosleep或alarm+pause系统调用实现)。
- 进程调用
-
进程状态切换:
- 内核将进程状态从 运行态 (R) 改为 可中断睡眠态 (S)。
- 进程从 CPU 运行队列中移除,加入等待队列(等待定时器到期)。
- 内核设置一个定时器(基于 jiffies 或高精度定时器 hrtimer),在指定时间后触发。
-
调度其他进程:
- CPU 调度器选择另一个就绪进程运行。
- sleep 的进程此时不占用 CPU。
-
定时器到期:
- 指定时间到达后,定时器中断触发。
- 内核将 sleep 进程的状态从睡眠态改为就绪态 (R),加入运行队列。
-
被调度恢复运行:
- 调度器在合适时机选中该进程,恢复上下文,继续执行。
sleep()返回 0(正常结束)或剩余秒数(被信号中断)。
sleep 过程中占用 CPU 吗?
- 不占用。进程处于睡眠状态,被移出运行队列,CPU 可以执行其他进程。
- 只有在 sleep 开始(设置定时器)和结束(唤醒、调度)的瞬间有极少量 CPU 开销。
- 这和忙等待 (busy waiting) 不同:忙等待(如
while(!timeout)循环)会持续占用 CPU。
被信号中断:
- sleep 期间进程可被信号唤醒(如 SIGINT),此时 sleep 返回剩余秒数,进程处理信号后继续执行。
nanosleep更精确,返回时可通过第二个参数获取剩余时间,便于循环重试。
精度:
sleep()以秒为单位,精度受内核定时器频率(HZ,通常 250/300/1000)影响。usleep()微秒级,nanosleep()纳秒级(高精度定时器 hrtimer 支持)。
15. 1G 文件从 A 机器发送到 B 机器,怎么发?
方案选择:
1. 简单方案:scp / rsync
scp largefile user@B:/path/
# 或 rsync(支持断点续传、增量同步)
rsync -avz --progress largefile user@B:/path/
- 优点:简单,rsync 支持压缩和断点续传。
- 缺点:单连接,速度可能受限。
2. 高性能方案:分块并行传输
- 将 1G 文件分成多个块(如 10 个 100MB 块)。
- 多线程/多连接并行发送不同块。
- B 端按序号合并。
- 充分利用带宽,比单连接快。
3. 零拷贝方案:sendfile
- 服务器端用
sendfile()系统调用,直接将文件数据从磁盘通过内核发送到网卡,不经过用户态拷贝。 - 比传统的 read+send 快(减少两次内存拷贝和上下文切换)。
sendfile(out_fd, in_fd, &offset, count);
4. 协议选择:
- TCP:可靠传输,自动处理丢包重传,适合文件传输。
- UDP:不可靠,需自己实现可靠传输(如 UDT、QUIC),但在高延迟高丢包网络下可能比 TCP 快。
- HTTP:简单,支持 Range 请求断点续传。
5. 优化手段:
- 调大 TCP 缓冲区:
setsockopt(SO_SNDBUF/SO_RCVBUF),增大窗口。 - 启用 TCP 窗口缩放:
tcp_window_scaling=1(默认开启)。 - 调整拥塞控制算法:BBR 比 cubic 在高带宽长延迟链路下更快。
- 压缩:如果文件可压缩(文本、日志),先压缩再传输(gzip、lz4)。
- 断点续传:记录已传输偏移,中断后从断点继续(HTTP Range、rsync)。
- P2P/多源下载:从多个节点同时下载不同片段(BitTorrent 方式)。
推荐方案:
- 内网、简单需求:
scp或rsync。 - 高性能、自定义传输:TCP + sendfile + 大缓冲区 + 可能的分块并行。
- 跨公网、不稳定网络:rsync(断点续传)或 HTTP Range。
16. 如何根据 IP 获取对方的 MAC 地址?
使用 ARP (Address Resolution Protocol,地址解析协议)。
ARP 工作原理:
- ARP 用于将 IP 地址解析为对应的 MAC 地址(物理地址)。
- 工作在数据链路层,只在同一局域网 (LAN) 内有效。
ARP 解析过程:
-
检查 ARP 缓存:
- 主机先检查自己的 ARP 缓存表(
arp -a查看),如果有目标 IP 对应的 MAC,直接使用。
- 主机先检查自己的 ARP 缓存表(
-
发送 ARP 请求:
- 如果缓存中没有,主机广播 ARP 请求报文:
- 源 MAC:自己的 MAC
- 目的 MAC:FF:FF:FF:FF:FF:FF(广播地址)
- 源 IP:自己的 IP
- 目的 IP:目标 IP
- 问题:"谁拥有 IP 地址 x.x.x.x?请告诉我你的 MAC 地址。"
- 如果缓存中没有,主机广播 ARP 请求报文:
-
目标主机回复 ARP 响应:
- 局域网内所有主机都收到广播,但只有目标 IP 匹配的主机会回复。
- 目标主机单播发送 ARP 响应报文:
- 源 MAC:目标主机的 MAC
- 目的 MAC:请求方的 MAC
- 内容:"IP x.x.x.x 的 MAC 地址是 xx:xx:xx:xx:xx:xx"
-
更新 ARP 缓存:
- 请求方收到 ARP 响应后,将 IP-MAC 映射存入 ARP 缓存。
- 之后使用该 MAC 地址封装数据帧进行通信。
查看 ARP 缓存:
arp -a # 查看 ARP 缓存表
ip neigh show # 更现代的命令
arp -d <ip> # 删除指定 ARP 条目
跨网段获取 MAC:
- ARP 只能在同一局域网内工作。
- 如果目标 IP 在不同网段,主机获取的是网关 (默认路由器) 的 MAC 地址,而不是目标主机的 MAC。
- 数据包先发送到网关,由网关路由到目标网络后,再通过目标网络的 ARP 获取目标主机 MAC。
ARP 相关问题:
- ARP 欺骗 (ARP Spoofing):攻击者发送伪造的 ARP 响应,将自己的 MAC 伪装成网关或其他主机,实现中间人攻击。可通过静态 ARP、ARP 防火墙 (arpwatch)、DAI (Dynamic ARP Inspection) 防范。
- 免费 ARP (Gratuitous ARP):主机主动发送自己 IP 的 ARP 请求,用于检测 IP 冲突和更新其他主机的 ARP 缓存。
- 反向 ARP (RARP):根据 MAC 获取 IP(已被 DHCP 取代)。
17. 怎样加快大文件在网络中传输?(滑动窗口与拥塞控制)
从 TCP 滑动窗口和拥塞控制角度优化:
1. 增大滑动窗口(流量控制):
- TCP 接收窗口 (rwnd) 限制发送方的发送量。
- 增大接收缓冲区:
setsockopt(sock, SOL_SOCKET, SO_RCVBUF, &size)和SO_SNDBUF。 - 缓冲区越大,窗口越大,在途数据越多,吞吐量越高。
- 带宽延迟积 (BDP) = 带宽 × RTT,理想窗口大小应 ≥ BDP。
- 例:1Gbps 带宽,100ms RTT,BDP = 1Gbps × 0.1s = 12.5MB,窗口至少 12.5MB 才能跑满带宽。
- 启用窗口缩放 (Window Scaling):
tcp_window_scaling=1(默认开启),支持最大 1GB 窗口。
2. 优化拥塞控制:
- 选择合适的拥塞控制算法:
cubic(Linux 默认):通用场景。bbr(Bottleneck Bandwidth and RTT):Google 开发,在高带宽长延迟 (BDP 大) 链路下比 cubic 快很多,不依赖丢包来判断拥塞。- 查看:
sysctl net.ipv4.tcp_congestion_control - 修改:
sysctl -w net.ipv4.tcp_congestion_control=bbr
- 增大慢启动阈值:慢启动阶段窗口指数增长,达到 ssthresh 后线性增长。初始 ssthresh 通常很大,慢启动能快速增长。
- 避免丢包:丢包会触发拥塞控制(窗口减半),严重影响吞吐量。确保网络质量,减少丢包。
3. 应用层优化:
- 分块并行传输:将大文件分成多块,多连接并行传输,充分利用带宽(单连接可能受拥塞窗口限制)。
- 零拷贝:用
sendfile()或splice(),避免数据在用户态和内核态之间拷贝。 - 禁用 Nagle 算法:
TCP_NODELAY,减少小包延迟(对大文件传输影响不大)。 - 启用 TCP Fast Open:减少握手延迟。
- 压缩:对可压缩文件先压缩再传输(gzip、lz4、zstd)。
4. 内核参数调优:
# 增大 TCP 缓冲区范围
sysctl -w net.core.rmem_max=16777216
sysctl -w net.core.wmem_max=16777216
sysctl -w net.ipv4.tcp_rmem="4096 87380 16777216"
sysctl -w net.ipv4.tcp_wmem="4096 65536 16777216"
# 启用窗口缩放和选择确认
sysctl -w net.ipv4.tcp_window_scaling=1
sysctl -w net.ipv4.tcp_sack=1
# 使用 BBR 拥塞控制
sysctl -w net.core.default_qdisc=fq
sysctl -w net.ipv4.tcp_congestion_control=bbr
5. 协议选择:
- 高延迟高丢包网络:考虑 QUIC (基于 UDP),比 TCP 更快建立连接、更好的拥塞控制、无队头阻塞。
- 内网稳定网络:TCP 已足够。
18. SSH 基于 TCP 还是 UDP?
SSH (Secure Shell) 基于 TCP,默认端口 22。
原因:
- SSH 需要可靠传输,保证命令和数据不丢失、不乱序。
- TCP 提供可靠的字节流传输,适合 SSH 的交互式会话和文件传输。
- SSH 的加密、认证、压缩都建立在可靠传输之上。
SSH 协议栈:
应用层:SSH 协议(远程登录、SCP、SFTP、端口转发)
↓
传输层:TCP(端口 22)
↓
网络层:IP
SSH 的组成:
- 传输层协议 (SSH-TRANS):提供服务器认证、机密性、完整性。基于 TCP。
- 用户认证协议 (SSH-USERAUTH):客户端向服务器认证用户身份(密码、公钥、键盘交互)。
- 连接协议 (SSH-CONNECT):在加密隧道上复用多个通道(交互式 shell、端口转发、X11 转发等)。
基于 UDP 的类似协议:
- Mosh (Mobile Shell):基于 UDP,支持漫游、间歇性连接、低延迟,适合移动网络。但 Mosh 初始连接仍用 SSH (TCP) 建立,之后切换到 UDP。
SSH 常见用途:
- 远程登录服务器:
ssh user@host - 文件传输:
scp file user@host:/path/、sftp - 端口转发:
ssh -L 8080:localhost:80 user@host(本地转发) - 密钥认证:免密码登录
- X11 转发:运行远程图形程序
19. 网卡的中断、几级缓存、网络瓶颈怎么解决?
网卡中断:
- 网卡收到数据包后,通过硬件中断通知 CPU 处理。
- 中断处理分为两部分:
- 硬中断 (Top Half):快速处理,将数据包从网卡缓冲区拷贝到内核,调度软中断。
- 软中断 (Bottom Half):在中断上下文外处理,完成协议栈解析、将数据交给应用层。
- 中断亲和性 (IRQ Affinity):将网卡中断绑定到特定 CPU 核心,避免中断在多核间漂移,提高缓存命中率。
- 查看:
cat /proc/interrupts - 设置:
echo <cpu_mask> > /proc/irq/<irq_num>/smp_affinity
- 查看:
- RSS (Receive Side Scaling):多队列网卡,将接收流量分散到多个队列,每个队列绑定不同 CPU 核心,并行处理。
- RPS/RFS:软件实现的接收侧扩展,在单队列网卡上将软中断分散到多核。
- 中断合并 (Interrupt Coalescing):网卡将多个数据包合并后触发一次中断,减少中断频率,降低 CPU 开销(但增加延迟)。
CPU 缓存级别:
- L1 缓存:最小最快,每个核心独占,分为 L1i(指令)和 L1d(数据),通常 32-64KB,延迟约 1-3 个时钟周期。
- L2 缓存:每个核心独占,通常 256KB-1MB,延迟约 10-15 个时钟周期。
- L3 缓存:所有核心共享,通常几 MB 到几十 MB,延迟约 30-40 个时钟周期。
- 内存 (RAM):延迟约 100-200 个时钟周期。
- 缓存越大越慢,越小越快。数据访问遵循局部性原理(时间局部性、空间局部性)。
网络瓶颈及解决方法:
| 瓶颈类型 | 表现 | 解决方法 |
|---|---|---|
| 带宽瓶颈 | 网卡/链路带宽跑满 | 升级网卡/链路、链路聚合 (bonding/teaming)、压缩、CDN |
| CPU 瓶颈 | 软中断占满 CPU、ksoftirqd 高 | RSS 多队列、中断亲和性、DPDK/用户态协议栈、升级 CPU |
| 连接数瓶颈 | 大量 TIME_WAIT、端口耗尽 | 调整 tcp_tw_reuse、长连接、增大端口范围、负载均衡 |
| 内存瓶颈 | 缓冲区不足、频繁 swap | 增大 TCP 缓冲区、关闭 swap、增加内存 |
| 延迟瓶颈 | RTT 大、排队延迟 | 就近部署 (CDN/边缘节点)、BBR 拥塞控制、减少跳数 |
| 应用层瓶颈 | 应用处理慢、阻塞 I/O | 异步 I/O (epoll/io_uring)、多线程/多进程、缓存、数据库优化 |
具体优化手段:
- 网卡多队列 (RSS):充分利用多核。
- 中断绑定:将网卡中断和应用线程绑定到不同核心,避免竞争。
- 增大缓冲区:
SO_RCVBUF、SO_SNDBUF、tcp_rmem、tcp_wmem。 - 拥塞控制算法:BBR 替代 cubic。
- 用户态协议栈:DPDK、Solarflare OpenOnload,绕过内核协议栈,极致性能。
- io_uring:Linux 5.1+ 的异步 I/O 接口,比 epoll 更高效。
- 零拷贝:sendfile、splice、mmap,减少内存拷贝。
- 监控工具:
sar、nstat、ss、ethtool、perf、bcc/BPF定位瓶颈。
四、数据结构与算法
🔥 高频题(10题)
面试中最常出现,核心知识点,建议重点掌握,能熟练默写。
1. 红黑树的思想、特点?红黑树和平衡二叉树的区别?
红黑树 (Red-Black Tree) 是一种自平衡的二叉搜索树,通过节点颜色和旋转操作保持平衡。
红黑树的五条性质:
- 每个节点非红即黑。
- 根节点是黑色。
- 叶子节点(NIL 空节点)是黑色。
- 红色节点的两个子节点必须是黑色(不能有连续红节点)。
- 从任一节点到其每个叶子的所有路径包含相同数量的黑节点(黑高相同)。
特点:
- 插入、删除、查找都是 O(log n)。
- 最长路径不超过最短路径的 2 倍(性质 4 和 5 保证)。
- 插入最多 2 次旋转,删除最多 3 次旋转,比 AVL 树的旋转次数少。
- 广泛用于 STL 的 map/set/multimap/multiset、Java 的 TreeMap、Linux 进程调度 CFS 等。
红黑树 vs AVL 树(平衡二叉树):
| 区别 | 红黑树 | AVL 树 |
|---|---|---|
| 平衡标准 | 弱平衡:最长路径 ≤ 2×最短路径 | 严格平衡:左右子树高度差 ≤ 1 |
| 插入旋转 | 最多 2 次 | 最多 O(log n) 次(沿路径回溯) |
| 删除旋转 | 最多 3 次 | 最多 O(log n) 次 |
| 查找效率 | 稍低(树可能稍高) | 更高(严格平衡,树更矮) |
| 插入/删除效率 | 更高(旋转少) | 较低(旋转多) |
| 适用场景 | 插入删除频繁(如 STL 容器) | 查找密集、插入删除少 |
为什么 STL 用红黑树而不是 AVL?
- 红黑树插入删除的旋转次数更少,性能更稳定。
- 实际应用中插入删除比纯查找更常见。
- 红黑树的实现相对简单。
2. 海量数据 Top K 问题?
问题:从海量数据(如 10 亿条)中找出出现频率最高的前 K 个。
常见场景:热搜词统计、日志分析、推荐系统。
解法:
方法1:分治 + 哈希 + 小顶堆(最常用)
- 分治:将海量数据通过哈希取模分到 N 个小文件中(如
hash(key) % 1000),相同 key 一定在同一个文件中。 - 哈希统计:对每个小文件,用哈希表统计每个 key 的出现次数。
- 小顶堆求 Top K:
- 维护一个大小为 K 的小顶堆。
- 遍历所有文件的统计结果,如果当前元素频率 > 堆顶(最小元素),则替换堆顶并调整堆。
- 最终堆中就是 Top K 元素。
- 时间复杂度:O(N) 分治 + O(M log K) 堆操作(M 为不同 key 数)。
- 空间复杂度:每个小文件可放入内存。
方法2:哈希表 + 排序(数据量可放入内存时)
- 直接用哈希表统计频率,然后按频率排序取前 K。
- 时间 O(M log M),空间 O(M)。
方法3:位图/布隆过滤器(判断是否存在)
- 不需要精确计数时,用布隆过滤器判断元素是否出现过。
- 有一定误判率,但空间效率极高。
方法4:Trie 树(字符串场景)
- 对字符串建立 Trie 树,在节点中计数。
- 适合字符串前缀统计。
方法5:MapReduce 分布式处理
- Map 阶段分片统计,Reduce 阶段合并。
- 适合超大规模数据(Hadoop/Spark)。
注意事项:
- 哈希分治时要确保相同 key 分到同一文件。
- 小顶堆比排序更高效(O(M log K) vs O(M log M)),K 远小于 M 时优势明显。
- 内存不够时必须分治,每块能放入内存。
- 数据有倾斜时(某些 key 特别多),单个文件可能仍超内存,需二次分治。
3. 两数之和 (LeetCode 1)?
问题:给定整数数组 nums 和目标值 target,找出和为 target 的两个整数的下标。
方法1:哈希表(O(n) 时间,O(n) 空间)
vector<int> twoSum(vector<int>& nums, int target) {
unordered_map<int, int> map; // 值 -> 下标
for (int i = 0; i < nums.size(); i++) {
int complement = target - nums[i];
if (map.count(complement)) {
return {map[complement], i};
}
map[nums[i]] = i;
}
return {};
}
- 遍历数组,对每个元素查找
target - nums[i]是否在哈希表中。 - 找到则返回两个下标,找不到则将当前元素存入哈希表。
- 一次遍历完成,时间 O(n),空间 O(n)。
方法2:暴力枚举(O(n²) 时间,O(1) 空间)
for (int i = 0; i < n; i++)
for (int j = i + 1; j < n; j++)
if (nums[i] + nums[j] == target)
return {i, j};
- 简单但效率低,大数据量不适用。
方法3:排序 + 双指针(O(n log n) 时间)
- 先排序,然后左右指针向中间移动。
- 但排序会打乱原始下标,需要额外记录下标。
- 适合只需要返回值而不是下标的场景。
扩展:
- 三数之和:排序 + 固定一个数 + 双指针,O(n²)。
- 四数之和:排序 + 双层循环 + 双指针,O(n³)。
- 两数之和 II(有序数组):双指针,O(n) 时间 O(1) 空间。
4. 如何判断单链表是否有环?
方法:快慢指针(Floyd 判圈算法,O(n) 时间,O(1) 空间)
bool hasCycle(ListNode* head) {
ListNode* slow = head;
ListNode* fast = head;
while (fast && fast->next) {
slow = slow->next; // 慢指针走一步
fast = fast->next->next; // 快指针走两步
if (slow == fast) return true; // 相遇则有环
}
return false; // 快指针到达尾部则无环
}
原理:
- 如果链表有环,快指针一定能追上慢指针(类似操场跑步,快的一定能套圈)。
- 如果无环,快指针会先到达尾部。
- 时间 O(n),空间 O(1)。
求环的入口节点:
ListNode* detectCycle(ListNode* head) {
ListNode* slow = head, *fast = head;
while (fast && fast->next) {
slow = slow->next;
fast = fast->next->next;
if (slow == fast) {
// 相遇后,一个指针从头开始,一个从相遇点开始,每次都走一步
ListNode* p = head;
while (p != slow) {
p = p->next;
slow = slow->next;
}
return p; // 环的入口
}
}
return nullptr;
}
- 数学证明:设头到入口距离为 a,入口到相遇点距离为 b,相遇点到入口距离为 c。
- 相遇时:慢指针走了 a+b,快指针走了 a+b+n(b+c)(n 圈)。
- 快指针速度是慢指针 2 倍:2(a+b) = a+b+n(b+c) → a = (n-1)(b+c)+c。
- 所以从头走 a 步 = 从相遇点走 c 步 + (n-1) 圈,刚好在入口相遇。
其他方法:
- 哈希表:遍历链表,用 set 记录访问过的节点,遇到已访问的则有环。O(n) 时间,O(n) 空间。
- 标记法:每个节点加一个访问标记(修改节点结构,不推荐)。
5. 反转单链表?
迭代法(O(n) 时间,O(1) 空间)
ListNode* reverseList(ListNode* head) {
ListNode* prev = nullptr;
ListNode* cur = head;
while (cur) {
ListNode* next = cur->next; // 保存下一个节点
cur->next = prev; // 反转指针
prev = cur; // prev 前移
cur = next; // cur 前移
}
return prev; // prev 指向新的头节点
}
递归法(O(n) 时间,O(n) 空间(递归栈))
ListNode* reverseList(ListNode* head) {
if (!head || !head->next) return head; // 空链表或只有一个节点
ListNode* newHead = reverseList(head->next); // 递归反转后面的链表
head->next->next = head; // 将 head 接到反转后链表的尾部
head->next = nullptr;
return newHead;
}
反转链表的一部分(LeetCode 92,反转从 left 到 right 的节点):
ListNode* reverseBetween(ListNode* head, int left, int right) {
ListNode dummy(0);
dummy.next = head;
ListNode* pre = &dummy;
for (int i = 0; i < left - 1; i++) pre = pre->next;
ListNode* cur = pre->next;
for (int i = 0; i < right - left; i++) {
ListNode* next = cur->next;
cur->next = next->next;
next->next = pre->next;
pre->next = next;
}
return dummy.next;
}
K 个一组反转链表(LeetCode 25):
- 每 K 个节点为一组,组内反转,不足 K 个不反转。
- 用迭代或递归实现。
6. B 树和 B+ 树的特点及应用场景?
B 树 (B-Tree) 和 B+ 树 (B+ Tree) 都是多路平衡搜索树,专为磁盘等外部存储设计,减少 I/O 次数。
B 树特点:
- 每个节点可以存储多个关键字和多个子节点(m 阶 B 树最多 m 个子节点)。
- 每个节点既存索引(关键字),也存数据(卫星信息)。
- 所有节点都可能存数据,不只是叶子节点。
- 所有叶子节点在同一层。
- 中序遍历可得到有序序列。
B+ 树特点:
- 非叶子节点只存索引(关键字),不存数据,数据只存在叶子节点。
- 叶子节点包含所有关键字和对应的数据,叶子节点之间用链表连接(双向链表)。
- 非叶子节点的关键字都在子节点中出现(是子节点中最大/最小关键字的冗余)。
- 所有叶子节点在同一层。
- 查询任何数据都要走到叶子节点(查询路径长度稳定)。
B 树 vs B+ 树:
| 区别 | B 树 | B+ 树 |
|---|---|---|
| 数据存储 | 所有节点都存数据 | 只有叶子节点存数据 |
| 查询稳定性 | 不稳定(可能在非叶子节点找到) | 稳定(必须走到叶子节点) |
| 范围查询 | 需中序遍历,多次 I/O | 叶子节点链表,顺序扫描,高效 |
| 节点存储 | 节点存数据,单节点能存的关键字少 | 非叶子节点只存索引,单节点能存更多关键字,树更矮 |
| 空间利用 | 较低 | 较高(冗余索引换取查询效率) |
| 插入删除 | 较复杂 | 较简单(只在叶子节点操作) |
应用场景:
| 数据结构 | 应用场景 |
|---|---|
| B+ 树 | MySQL 数据库索引(InnoDB、MyISAM)、文件系统索引(NTFS、ext4)、大多数关系型数据库 |
| B 树 | MongoDB 索引(WiredTiger 用 B 树变体)、一些文件系统、需要在非叶子节点直接获取数据的场景 |
| B 树* | B+ 树变体,非叶子节点也有链表,空间利用率更高,一些数据库/文件系统使用 |
为什么数据库索引用 B+ 树而不是 B 树?
- 范围查询高效:B+ 树叶子节点链表,范围查询只需找到起点后顺序扫描,B 树需要中序遍历多次 I/O。
- 查询更稳定:B+ 树所有查询都走到叶子节点,路径长度相同,性能稳定。
- 树更矮:B+ 树非叶子节点不存数据,单页能存更多关键字,树的高度更低,I/O 次数更少。
- 全表扫描快:B+ 树只需扫描叶子节点链表,B 树需要遍历整棵树。
7. Hash 表处理冲突的方式?
哈希冲突:不同关键字通过哈希函数得到相同的哈希地址。
常见处理方法:
1. 链地址法 (Separate Chaining):
- 每个桶挂一个链表,冲突的元素放在同一个链表中。
- 链表过长时(Java 8 阈值 8)转为红黑树。
- STL unordered_map、Java HashMap 都用这种方法。
- 优点:简单,删除方便,装载因子可大于 1。
- 缺点:链表节点分散,缓存不友好;额外指针开销。
2. 开放寻址法 (Open Addressing):
- 所有元素都存在哈希表数组中,冲突时探测下一个空位。
- 线性探测:依次检查下一个位置
(hash+1) % n、(hash+2) % n...- 简单,但容易产生"聚集"(primary clustering),连续占用导致探测序列变长。
- 二次探测:
(hash + i²) % n,减少聚集。 - 双重哈希:用第二个哈希函数决定步长
(hash1 + i × hash2) % n,聚集最少。 - 优点:数组连续,缓存友好,无指针开销。
- 缺点:删除复杂(需标记删除或重新哈希),装载因子不能太高(通常 ≤ 0.7),聚集问题。
- 应用:Python 字典、Redis 字典(早期)、Go map。
3. 再哈希法 (Rehashing):
- 准备多个哈希函数,冲突时换一个哈希函数。
hash2(key)、hash3(key)...- 优点:不易聚集。
- 缺点:计算多个哈希函数开销大,可能仍冲突。
4. 建立公共溢出区:
- 哈希表分基本表和溢出表,冲突的元素都放入溢出表。
- 简单,但溢出区查找是线性的,冲突多时效率低。
负载因子 (Load Factor):
α = 元素数 / 桶数。- α 越大,冲突概率越高,查找越慢。
- 链地址法 α 可 > 1(链表变长)。
- 开放寻址法 α 通常 < 0.7,超过则 rehash(扩容)。
Rehash(扩容):
- 当负载因子超过阈值时,创建更大的哈希表(通常 2 倍),将所有元素重新哈希到新表。
- 渐进式 rehash(Redis):不一次性迁移,而是在后续操作中逐步迁移,避免阻塞。
选择建议:
- 通用场景用链地址法(实现简单,性能稳定)。
- 对缓存性能要求高、元素小的场景用开放寻址法。
8. 一致性哈希?
一致性哈希 (Consistent Hashing) 是一种哈希算法,解决分布式系统中节点增减时数据迁移量大的问题。
传统哈希的问题:
hash(key) % N分配数据到 N 个节点。- 当节点数从 N 变为 N+1 时,几乎所有 key 的映射位置都会改变,需要大量数据迁移。
一致性哈希原理:
- 哈希环:将哈希空间(如 0 ~ 2^32-1)组织成一个环。
- 节点映射:将每个服务器节点(通过 IP/ID 哈希)映射到环上的某个位置。
- 数据映射:将每个 key 哈希到环上的位置,然后顺时针找到第一个节点,该节点就是数据存储的节点。
哈希环 (0 ~ 2^32-1)
0
/ \
/ \
节点A 节点B
\ key1 /
\ /
节点C
- key1 顺时针找到的第一个节点是节点 B,所以 key1 存在节点 B。
节点增减的影响:
- 新增节点:只影响新节点到其逆时针方向上一个节点之间的数据,只需迁移这部分数据到新节点。
- 删除节点:该节点的数据迁移到其顺时针下一个节点。
- 其他数据不受影响。
虚拟节点 (Virtual Nodes):
- 问题:节点少时,数据分布可能不均匀(某些节点负责的环范围大)。
- 解决:每个物理节点对应多个虚拟节点(如 150 个),虚拟节点分布在环上。
- 数据映射到虚拟节点,虚拟节点再映射到物理节点。
- 虚拟节点越多,分布越均匀,节点增减时迁移量越小。
应用场景:
- 分布式缓存(Redis Cluster、Memcached 客户端)。
- 分布式存储(DynamoDB、Cassandra)。
- 负载均衡(需要会话保持且动态扩缩容)。
- CDN 内容路由。
优点:
- 节点增减时只影响相邻节点的数据,迁移量小。
- 可扩展性好,适合动态分布式系统。
缺点:
- 实现复杂。
- 节点少时数据可能不均(需虚拟节点解决)。
- 不支持精确的节点权重(可通过虚拟节点数量调整)。
9. LRU 实现原理?
LRU (Least Recently Used,最近最少使用) 缓存淘汰算法:当缓存满时,淘汰最久未使用的数据。
数据结构:哈希表 + 双向链表
哈希表:key -> 链表节点指针(O(1) 查找)
双向链表:按访问时间排序,头部是最近使用,尾部是最久未使用
head ↔ [节点1] ↔ [节点2] ↔ [节点3] ↔ tail
最近使用 最久未使用(淘汰)
C++ 实现:
class LRUCache {
struct Node {
int key, value;
Node* prev;
Node* next;
Node(int k, int v) : key(k), value(v), prev(nullptr), next(nullptr) {}
};
int capacity_;
unordered_map<int, Node*> map_;
Node* head_; // 哑节点,简化边界处理
Node* tail_;
// 将节点移到头部(最近使用)
void moveToHead(Node* node) {
removeNode(node);
addToHead(node);
}
void removeNode(Node* node) {
node->prev->next = node->next;
node->next->prev = node->prev;
}
void addToHead(Node* node) {
node->next = head_->next;
node->prev = head_;
head_->next->prev = node;
head_->next = node;
}
Node* removeTail() {
Node* node = tail_->prev;
removeNode(node);
return node;
}
public:
LRUCache(int capacity) : capacity_(capacity) {
head_ = new Node(0, 0);
tail_ = new Node(0, 0);
head_->next = tail_;
tail_->prev = head_;
}
int get(int key) {
if (!map_.count(key)) return -1;
Node* node = map_[key];
moveToHead(node); // 访问后移到头部
return node->value;
}
void put(int key, int value) {
if (map_.count(key)) {
Node* node = map_[key];
node->value = value;
moveToHead(node);
} else {
Node* node = new Node(key, value);
map_[key] = node;
addToHead(node);
if (map_.size() > capacity_) {
Node* removed = removeTail(); // 淘汰尾部
map_.erase(removed->key);
delete removed;
}
}
}
};
操作复杂度:get 和 put 都是 O(1)。
C++ STL 简化实现:用 std::list(双向链表)+ unordered_map(存迭代器):
class LRUCache {
int cap;
list<pair<int,int>> cache; // 链表,头部最近使用
unordered_map<int, list<pair<int,int>>::iterator> map;
public:
LRUCache(int capacity) : cap(capacity) {}
int get(int key) {
if (!map.count(key)) return -1;
auto it = map[key];
cache.splice(cache.begin(), cache, it); // 移到头部
return it->second;
}
void put(int key, int value) {
if (map.count(key)) {
map[key]->second = value;
cache.splice(cache.begin(), cache, map[key]);
} else {
if (cache.size() == cap) {
map.erase(cache.back().first);
cache.pop_back();
}
cache.push_front({key, value});
map[key] = cache.begin();
}
}
};
其他缓存淘汰算法:
- LFU (Least Frequently Used):淘汰使用次数最少的,需维护频率计数。
- FIFO:先进先出,淘汰最早进入的。
- ARC (Adaptive Replacement Cache):自适应,结合 LRU 和 LFU。
- 2Q:两个队列,热数据和冷数据分离。
10. 快速排序算法思想?
快速排序 (Quick Sort) 是一种基于分治思想的排序算法。
核心思想:
- 选择基准 (Pivot):从数组中选择一个元素作为基准。
- 分区 (Partition):将数组分为两部分,小于基准的放左边,大于基准的放右边,基准在中间。
- 递归排序:对左右两部分分别递归进行快速排序。
分区过程(Hoare 分区 / Lomuto 分区):
int partition(vector<int>& nums, int left, int right) {
int pivot = nums[right]; // 选最后一个元素为基准
int i = left - 1; // i 指向小于基准区域的末尾
for (int j = left; j < right; j++) {
if (nums[j] <= pivot) {
i++;
swap(nums[i], nums[j]);
}
}
swap(nums[i + 1], nums[right]); // 基准放到正确位置
return i + 1;
}
void quickSort(vector<int>& nums, int left, int right) {
if (left < right) {
int pi = partition(nums, left, right);
quickSort(nums, left, pi - 1); // 排序左半部分
quickSort(nums, pi + 1, right); // 排序右半部分
}
}
复杂度分析:
- 平均时间:O(n log n)。
- 最好时间:O(n log n)(每次分区均匀)。
- 最坏时间:O(n²)(每次选到最大/最小元素,如已排序数组选首元素为基准)。
- 空间:O(log n)(递归栈),最坏 O(n)。
- 不稳定排序:相等元素的相对顺序可能改变。
优化方法:
- 随机化基准:随机选择基准元素,避免最坏情况(已排序数组)。
- 三数取中:取首、中、尾三个元素的中位数作为基准。
- 小数组用插入排序:当子数组长度 < 阈值(如 10)时,用插入排序(小数组插入排序更快)。
- 尾递归优化:只递归较小的一半,较大的一半用循环,减少栈深度。
- 三路快排 (Dutch National Flag):将数组分为小于、等于、大于基准三部分,适合有大量重复元素的场景。
三路快排:
void quickSort3Way(vector<int>& nums, int left, int right) {
if (left >= right) return;
int pivot = nums[left + rand() % (right - left + 1)];
int lt = left, gt = right, i = left;
while (i <= gt) {
if (nums[i] < pivot) swap(nums[i++], nums[lt++]);
else if (nums[i] > pivot) swap(nums[i], nums[gt--]);
else i++;
}
quickSort3Way(nums, left, lt - 1);
quickSort3Way(nums, gt + 1, right);
}
应用:快速排序是实际应用中最快的通用排序算法之一,C 的 qsort、C++ 的 std::sort( introsort,快排+堆排+插入排序混合)都基于快排。
📌 中频题(6题)
比较常见,部分公司会问到,建议理解并能口述要点。
1. 负载均衡算法?
负载均衡 (Load Balancing) 将请求分发到多个服务器,提高系统吞吐量和可用性。
常见算法:
| 算法 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 轮询 (Round Robin) | 按顺序依次分配给每台服务器 | 简单、公平 | 不考虑服务器性能差异和负载 |
| 加权轮询 (Weighted RR) | 按权重比例分配,性能好的服务器权重高 | 考虑服务器性能差异 | 静态权重,不反映实时负载 |
| 随机 (Random) | 随机选择一台服务器 | 简单 | 不均匀,概率上均匀 |
| 加权随机 | 按权重随机选择 | 考虑性能差异 | 仍有随机性 |
| 最少连接 (Least Connections) | 分配给当前连接数最少的服务器 | 动态适应负载 | 需维护连接数,长连接场景有效 |
| 加权最少连接 | 结合权重和最少连接 | 兼顾性能和负载 | 复杂 |
| 源地址哈希 (IP Hash) | 对客户端 IP 哈希,同一 IP 始终分配到同一服务器 | 会话保持,不需要共享 session | 负载可能不均,某 IP 流量大时压垮单台 |
| URL Hash | 对请求 URL 哈希,同一 URL 分配到同一服务器 | 缓存命中高(CDN 场景) | 负载可能不均 |
| 一致性哈希 (Consistent Hashing) | 在哈希环上分配,节点增减只影响相邻节点 | 动态扩缩容影响小 | 实现复杂,需虚拟节点解决不均 |
| 最快响应 (Fastest Response) | 分配给响应时间最短的服务器 | 用户体验好 | 需监控响应时间,可能导致雪崩 |
| 健康检查 + 故障转移 | 自动剔除故障节点,恢复后重新加入 | 高可用 | 需健康检查机制 |
选择建议:
- 服务器性能相近、无状态服务:轮询。
- 服务器性能差异大:加权轮询。
- 需要会话保持:IP Hash 或 Cookie 会话保持。
- 长连接/连接耗时差异大:最少连接。
- 动态扩缩容频繁:一致性哈希。
- 生产环境通常结合健康检查和多种算法。
2. 合并两个有序数组或链表?
合并两个有序数组(in-place,nums1 有足够空间):
void merge(vector<int>& nums1, int m, vector<int>& nums2, int n) {
int i = m - 1, j = n - 1, k = m + n - 1;
while (i >= 0 && j >= 0) {
if (nums1[i] > nums2[j]) {
nums1[k--] = nums1[i--];
} else {
nums1[k--] = nums2[j--];
}
}
while (j >= 0) {
nums1[k--] = nums2[j--];
}
}
- 从后往前合并,避免覆盖 nums1 中未处理的元素。
- 时间 O(m+n),空间 O(1)。
合并两个有序链表:
ListNode* mergeTwoLists(ListNode* l1, ListNode* l2) {
ListNode dummy(0);
ListNode* cur = &dummy;
while (l1 && l2) {
if (l1->val < l2->val) {
cur->next = l1;
l1 = l1->next;
} else {
cur->next = l2;
l2 = l2->next;
}
cur = cur->next;
}
cur->next = l1 ? l1 : l2;
return dummy.next;
}
- 时间 O(m+n),空间 O(1)。
- 用哑节点简化头节点处理。
递归版本(链表):
ListNode* mergeTwoLists(ListNode* l1, ListNode* l2) {
if (!l1) return l2;
if (!l2) return l1;
if (l1->val < l2->val) {
l1->next = mergeTwoLists(l1->next, l2);
return l1;
} else {
l2->next = mergeTwoLists(l1, l2->next);
return l2;
}
}
扩展:合并 K 个有序链表
- 方法1:两两合并,O(N log K)。
- 方法2:小顶堆,每次取 K 个链表头中最小的,O(N log K)。
3. 实现一个栈,O(1) 时间求最小元素?
思路:用两个栈,一个数据栈存元素,一个辅助栈存当前最小值。
class MinStack {
stack<int> data_; // 数据栈
stack<int> min_; // 最小栈,栈顶始终是当前最小值
public:
void push(int x) {
data_.push(x);
if (min_.empty() || x <= min_.top()) {
min_.push(x); // 新元素 <= 当前最小值时,入最小栈
}
}
void pop() {
if (data_.top() == min_.top()) {
min_.pop(); // 弹出的是当前最小值时,最小栈也弹出
}
data_.pop();
}
int top() {
return data_.top();
}
int getMin() {
return min_.top(); // O(1)
}
};
优化:减少辅助栈空间
- 当新元素 > 当前最小值时,辅助栈重复压入当前最小值(而不是不压入),这样 pop 时两个栈同步弹出,不需要判断。
void push(int x) {
data_.push(x);
min_.push(min_.empty() ? x : min(x, min_.top()));
}
void pop() {
data_.pop();
min_.pop(); // 同步弹出
}
- 缺点:辅助栈空间始终和数据栈一样大。
另一种优化:差值法(O(1) 额外空间)
- 栈中存储元素与当前最小值的差值,用一个变量存当前最小值。
- 弹出时根据差值恢复之前的最小值。
- 空间更优但有溢出风险。
4. 怎么判断单链表是否相交?
问题:判断两个单链表是否相交,如果相交返回第一个相交节点。
方法1:双指针法(O(n+m) 时间,O(1) 空间,最优)
ListNode* getIntersectionNode(ListNode* headA, ListNode* headB) {
if (!headA || !headB) return nullptr;
ListNode* pA = headA;
ListNode* pB = headB;
while (pA != pB) {
pA = pA ? pA->next : headB; // A 走完后走 B
pB = pB ? pB->next : headA; // B 走完后走 A
}
return pA; // 相交则返回交点,不相交则返回 nullptr
}
原理:
- 指针 A 走完链表 A 后走链表 B,指针 B 走完链表 B 后走链表 A。
- 两个指针走的总长度相同(lenA + lenB)。
- 如果相交,它们会在交点相遇(因为交点之后的公共部分长度相同)。
- 如果不相交,它们会同时走到 nullptr(都走了 lenA+lenB)。
方法2:哈希集合法(O(n+m) 时间,O(n) 空间)
- 遍历链表 A,将所有节点存入哈希集合。
- 遍历链表 B,检查每个节点是否在哈希集合中。
- 第一个在集合中的节点就是交点。
方法3:尾节点法(判断是否相交,但不返回交点)
- 分别遍历到两个链表的尾节点,如果尾节点相同则相交。
- 但不能找到第一个交点。
方法4:长度差法
- 分别计算两个链表的长度 lenA、lenB。
- 长链表先走 |lenA - lenB| 步,然后两个链表同时走,第一个相同节点就是交点。
注意:
- 链表相交指的是节点引用相同(地址相同),不是节点值相同。
- 相交后的链表共享尾部,形状像 "Y" 而不是 "X"(单链表每个节点只有一个 next)。
5. 快排和堆排的应用场景?
| 区别 | 快速排序 | 堆排序 |
|---|---|---|
| 平均时间 | O(n log n) | O(n log n) |
| 最坏时间 | O(n²)(可优化避免) | O(n log n)(稳定) |
| 空间 | O(log n)(递归栈) | O(1)(原地排序) |
| 稳定性 | 不稳定 | 不稳定 |
| 缓存友好 | 好(顺序访问) | 差(堆是树结构,随机访问) |
| 实际速度 | 快(常数因子小) | 较慢(常数因子大) |
| 适用场景 | 通用排序、内存足够、追求平均速度 | 内存受限、需要保证最坏 O(n log n)、Top K 问题 |
快速排序应用场景:
- 通用的内存排序,大多数场景的首选。
- 数据量较大且内存足够。
- 对平均性能要求高,能接受极少数最坏情况(通过随机化基准基本避免)。
- C++
std::sort、JavaArrays.sort(基本类型)都用快排变体。
堆排序应用场景:
- Top K 问题:求最大/最小的 K 个元素,用小顶堆/大顶堆,O(n log K)。
- 优先队列:任务调度、Dijkstra 最短路径、哈夫曼编码。
- 内存严格受限:需要 O(1) 额外空间且保证 O(n log n) 最坏时间。
- 实时系统:不能接受快排最坏 O(n²) 的场景。
- 排序本身用得少(因为实际比快排慢),但堆数据结构应用广泛。
Top K 问题对比:
- 用快排思想(快速选择):O(n) 平均时间,O(1) 空间,但会修改原数组。
- 用堆排序(小顶堆):O(n log K) 时间,O(K) 空间,不修改原数组,适合流式数据。
- 海量数据 Top K:堆排序更适合(不需要全部数据放入内存)。
选择建议:
- 纯排序用快排(更快、缓存友好)。
- Top K、优先队列、动态最值用堆。
- 内存极度受限且需保证最坏性能用堆排序。
6. std::sort 的实现方法?
std::sort 是 C++ STL 的排序算法,采用 Introsort (内省排序),是快速排序、堆排序、插入排序的混合算法。
实现原理(GCC libstdc++):
-
快速排序为主:
- 默认使用快速排序,三数取中选择基准(首、中、尾的中位数)。
- 递归分区,平均 O(n log n)。
-
递归深度监控(防止最坏情况):
- 监控递归深度,当深度超过
2 × log2(n)时,认为快排退化(遇到最坏情况)。 - 切换到堆排序,保证最坏 O(n log n)。
- 这就是 Introsort 的核心:快排平均快,堆排保底。
- 监控递归深度,当深度超过
-
小数组用插入排序:
- 当子数组长度小于阈值(通常 16)时,切换到插入排序。
- 小数组插入排序比快排快(常数因子小,无递归开销,缓存友好)。
- 插入排序对近乎有序的数据效率极高。
伪代码:
void sort(first, last) {
if (last - first <= 1) return;
if (last - first <= 16) {
insertion_sort(first, last); // 小数组插入排序
return;
}
if (depth_limit <= 0) {
heap_sort(first, last); // 递归过深,堆排序保底
return;
}
depth_limit--;
pivot = median_of_three(*first, *(mid), *(last-1));
p = partition(first, last, pivot); // 分区
sort(first, p); // 递归左半
sort(p + 1, last); // 递归右半
}
复杂度:
- 平均 O(n log n),最坏 O(n log n)(Introsort 保证)。
- 空间 O(log n)(递归栈)。
- 不稳定排序。
其他 STL 排序算法:
std::stable_sort:稳定排序,归并排序实现,O(n log n) 时间,O(n) 额外空间。std::partial_sort:部分排序,堆排序实现,只排序前 K 个元素,O(n log K)。std::nth_element:快速选择,O(n) 平均时间,找第 K 大/小元素。std::sort_heap:堆排序。
为什么用 Introsort?
- 纯快排最坏 O(n²),不可接受。
- 纯堆排序常数因子大,实际比快排慢。
- Introsort 结合两者优点:平均情况快排的速度,最坏情况堆排的保证。
💡 低频题(4题)
较少出现或较偏,时间充裕时了解即可。
1. 有损压缩和无损压缩算法?
| 区别 | 无损压缩 | 有损压缩 |
|---|---|---|
| 原理 | 消除数据中的冗余,解压后完全恢复原始数据 | 丢弃人眼/人耳不敏感的信息,解压后不能完全恢复 |
| 数据恢复 | 100% 恢复,无信息损失 | 有信息损失,质量下降 |
| 压缩比 | 较低(2:1 到 8:1) | 较高(10:1 到 100:1) |
| 适用数据 | 文本、程序、可执行文件、医疗影像 | 图像、音频、视频 |
| 典型算法 | Huffman、LZW、DEFLATE (gzip/zip)、LZ77/LZ78、算术编码、Run-Length Encoding (RLE) | JPEG、MP3、AAC、H.264/H.265、WebP、Opus |
无损压缩算法:
-
Huffman 编码:
- 根据字符出现频率构建最优二叉树,频率高的字符用短编码,频率低的用长编码。
- 前缀编码,无歧义。
- 广泛用于 DEFLATE、JPEG(熵编码阶段)。
-
LZ77/LZ78 (Lempel-Ziv):
- 用"长度-偏移"对替换重复出现的字符串。
- LZ77 用滑动窗口,LZ78 用字典。
- DEFLATE (gzip/zip/png) = LZ77 + Huffman。
-
LZW:
- LZ78 的变体,动态构建字典。
- 用于 GIF、TIFF、旧版 Unix compress。
-
算术编码 (Arithmetic Coding):
- 将整个消息编码为 [0,1) 区间的一个小数。
- 比 Huffman 更接近熵极限,但实现复杂、有专利问题(已过期)。
- 用于 JPEG2000、H.264 的 CABAC。
-
RLE (行程编码):
- 将连续重复的字符表示为"计数+字符"。
- 简单,适合有大量连续重复的数据(如位图、传真)。
有损压缩算法:
-
JPEG:
- 图像分 8×8 块 → DCT 变换 → 系数量化(丢弃高频)→ Zigzag 扫描 → RLE + Huffman。
- 人眼对亮度敏感、对色度不敏感,可降低色度采样率 (4:2:0)。
-
MP3/AAC:
- 心理声学模型,丢弃人耳听不到的频率(掩蔽效应)。
- 变换编码、量化、霍夫曼编码。
-
H.264/H.265:
- 帧内预测(空间冗余)、帧间预测(时间冗余,运动估计/补偿)、变换、量化、熵编码。
- H.265 (HEVC) 比 H.264 压缩率提高约 50%。
2. 三个有序序列,查找公共部分?
问题:给定三个有序数组,找出它们的公共元素(交集)。
方法1:三指针法(最优,O(n) 时间,O(1) 空间)
vector<int> intersection(vector<int>& a, vector<int>& b, vector<int>& c) {
vector<int> result;
int i = 0, j = 0, k = 0;
while (i < a.size() && j < b.size() && k < c.size()) {
if (a[i] == b[j] && b[j] == c[k]) {
result.push_back(a[i]);
i++; j++; k++;
} else {
// 移动指向最小值的指针
int mn = min({a[i], b[j], c[k]});
if (a[i] == mn) i++;
if (b[j] == mn) j++;
if (c[k] == mn) k++;
}
}
return result;
}
- 时间复杂度:O(n1 + n2 + n3),每个指针最多遍历完自己的数组。
- 空间复杂度:O(1)(不计结果)。
- 处理重复元素:如果数组有重复,找到公共元素后跳过所有相同值。
方法2:哈希集合法(O(n) 时间,O(n) 空间)
- 先用第一个数组构建哈希集合。
- 用第二个数组过滤,得到前两个的交集集合。
- 再用第三个数组过滤。
- 适合数组很大但内存足够的情况,不需要有序。
方法3:二分查找法(O(n log n) 时间)
- 遍历最短数组的每个元素,在另外两个数组中二分查找。
- 时间 O(min(n) × log(max(n)))。
- 适合一个数组特别短的情况。
选择建议:数组有序时优先用三指针法,时间空间最优。
3. 100G 文本,求重复最多的行?(多机多进程)
问题:100G 文本文件,每行 80 字符,一台机器 8G 内存,多机多进程求重复最多的行。
思路:分治 + 哈希统计 + 归并
单机方案(内存不够时):
-
分治(哈希分片):
- 遍历 100G 文件,对每行内容计算哈希值
hash(line) % N(N 如 1000)。 - 将每行写入对应的小文件(共 N 个文件,每个约 100MB)。
- 相同行一定在同一个小文件中。
- 每台机器处理一部分小文件。
- 遍历 100G 文件,对每行内容计算哈希值
-
哈希统计:
- 对每个小文件(可放入 8G 内存),用
unordered_map<string, int>统计每行出现次数。 - 多进程并行处理不同的小文件。
- 对每个小文件(可放入 8G 内存),用
-
求 Top 1(或 Top K):
- 每个小文件统计完后,记录出现次数最多的行。
- 所有小文件的结果归并,找出全局重复最多的行。
- 可用小顶堆维护 Top K。
多机分布式方案(MapReduce 思想):
-
Map 阶段:
- 将 100G 文件切分成多个块(如 64MB/块),分发到多台机器。
- 每台机器对自己的块进行哈希统计(局部统计)。
- 输出
<line, count>键值对。
-
Shuffle 阶段:
- 按 key (line) 哈希分区,相同 line 分发到同一个 Reduce 节点。
- 网络传输中间结果。
-
Reduce 阶段:
- 每个 Reduce 节点对相同 line 的 count 求和,得到全局计数。
- 所有 Reduce 结果归并,找出重复最多的行。
内存估算:
- 每行 80 字节 + 计数 4 字节 + 哈希表开销(约 40 字节/条目)≈ 124 字节/条目。
- 8G 内存可存约 6000 万条不同行。
- 如果单个分片仍超内存,增大分片数 N(二次分治)。
注意事项:
- 哈希分片要均匀,避免数据倾斜(某些行特别多导致单个文件过大)。
- 数据倾斜时对热点 key 可进一步分治或用组合 key。
- 多机间数据传输是瓶颈,需尽量减少 Shuffle 数据量(Combiner 局部合并)。
- 容错:某台机器失败时重新分配任务。
4. 将奇数放于数组前面,偶数放于后面?
方法:双指针(O(n) 时间,O(1) 空间)
void reOrderArray(vector<int>& array) {
int left = 0, right = array.size() - 1;
while (left < right) {
// 左指针找偶数
while (left < right && array[left] % 2 == 1) left++;
// 右指针找奇数
while (left < right && array[right] % 2 == 0) right--;
// 交换
if (left < right) {
swap(array[left], array[right]);
}
}
}
- 左指针从左往右找偶数,右指针从右往左找奇数,找到后交换。
- 时间 O(n),空间 O(1)。
- 不保证奇数之间和偶数之间的相对顺序。
如果要求保持相对顺序(稳定):
void reOrderArrayStable(vector<int>& array) {
vector<int> odd, even;
for (int x : array) {
if (x % 2 == 1) odd.push_back(x);
else even.push_back(x);
}
array.clear();
array.insert(array.end(), odd.begin(), odd.end());
array.insert(array.end(), even.begin(), even.end());
}
- 时间 O(n),空间 O(n)。
- 类似快速排序的 partition 操作,稳定版本需要额外空间。
扩展:这是快速排序中 partition 的基础操作,也可用于荷兰国旗问题(三色排序)。
五、数据库
🔥 高频题(11题)
面试中最常出现,核心知识点,建议重点掌握,能熟练默写。
1. 数据库事务是什么?
事务 (Transaction) 是数据库操作的最小逻辑单元,由一组 SQL 语句组成,这组语句要么全部成功执行,要么全部不执行(回滚)。
事务的典型场景:银行转账(A 扣钱 + B 加钱必须同时成功或同时失败)。
事务的 ACID 特性:
- 原子性 (Atomicity):事务是不可分割的最小单位,要么全部提交,要么全部回滚。通过 undo log(回滚日志)实现。
- 一致性 (Consistency):事务执行前后,数据库从一个一致性状态转变到另一个一致性状态,数据完整性约束不被破坏。
- 隔离性 (Isolation):多个事务并发执行时,一个事务的执行不受其他事务干扰,事务之间相互隔离。通过锁和 MVCC 实现。
- 持久性 (Durability):事务一旦提交,对数据的修改就是永久性的,即使系统崩溃也不会丢失。通过 redo log(重做日志)实现。
事务控制语句:
BEGIN; -- 开启事务
START TRANSACTION;
COMMIT; -- 提交事务
ROLLBACK; -- 回滚事务
SAVEPOINT sp1; -- 设置保存点
ROLLBACK TO sp1; -- 回滚到保存点
MySQL 事务实现:
- InnoDB 存储引擎支持事务,MyISAM 不支持。
- 通过 undo log 保证原子性,redo log 保证持久性,锁 + MVCC 保证隔离性。
- 默认自动提交 (autocommit=1),每条 SQL 都是一个事务。
2. 数据库事务有哪些特性?(ACID)
见上题,ACID 四特性:
| 特性 | 英文 | 说明 | 实现机制 |
|---|---|---|---|
| 原子性 | Atomicity | 事务不可分割,全成功或全失败 | undo log(回滚日志) |
| 一致性 | Consistency | 事务前后数据保持一致状态,约束不被破坏 | 由原子性、隔离性、持久性共同保证 |
| 隔离性 | Isolation | 并发事务互不干扰 | 锁机制 + MVCC(多版本并发控制) |
| 持久性 | Durability | 提交后修改永久保存,崩溃不丢失 | redo log(重做日志)+ WAL(预写式日志) |
ACID 之间的关系:
- 原子性和持久性是基础。
- 隔离性是并发场景下的核心,通过隔离级别控制。
- 一致性是最终目标,由其他三个特性共同保证。
并发事务带来的问题(隔离性要解决的):
- 脏读:读到其他事务未提交的数据。
- 不可重复读:同一事务内两次读取同一数据,结果不同(其他事务修改并提交)。
- 幻读:同一事务内两次范围查询,结果行数不同(其他事务插入/删除)。
3. 事务的隔离级别有哪些?
SQL 标准定义了 4 种隔离级别,从低到高:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 说明 |
|---|---|---|---|---|
| 读未提交 (Read Uncommitted) | 可能 | 可能 | 可能 | 最低级别,可读到其他事务未提交的数据 |
| 读已提交 (Read Committed, RC) | 不可能 | 可能 | 可能 | 只能读到已提交的数据,Oracle、SQL Server 默认 |
| 可重复读 (Repeatable Read, RR) | 不可能 | 不可能 | 可能(InnoDB 通过 MVCC+间隙锁解决) | 同一事务内多次读取同一数据结果一致,MySQL InnoDB 默认 |
| 串行化 (Serializable) | 不可能 | 不可能 | 不可能 | 最高级别,事务串行执行,性能最差 |
MySQL InnoDB 的实现:
- 默认隔离级别是 可重复读 (RR)。
- 在 RR 级别下,通过 MVCC(快照读) 解决不可重复读。
- 通过 Next-Key Lock(记录锁 + 间隙锁) 解决幻读(当前读时)。
- 所以 InnoDB 在 RR 级别下实际上已经避免了幻读。
查看和设置隔离级别:
-- 查看
SELECT @@transaction_isolation;
SHOW VARIABLES LIKE 'transaction_isolation';
-- 设置当前会话
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- 设置全局
SET GLOBAL TRANSACTION ISOLATION LEVEL REPEATABLE READ;
隔离级别与性能的权衡:
- 隔离级别越高,数据一致性越好,但并发性能越差(加锁更多)。
- 隔离级别越低,并发性能越好,但可能出现脏读、不可重复读、幻读等问题。
- 大多数应用用 RC 或 RR 即可,不需要 Serializable。
4. 不可重复读和幻读的区别?
| 区别 | 不可重复读 (Non-repeatable Read) | 幻读 (Phantom Read) |
|---|---|---|
| 定义 | 同一事务内,两次读取同一行数据,结果不同 | 同一事务内,两次范围查询,返回的行数不同 |
| 原因 | 其他事务修改 (UPDATE) 了该行数据并提交 | 其他事务插入 (INSERT) 或删除 (DELETE) 了符合条件的行并提交 |
| 关注点 | 数据内容的变化 | 行数的变化 |
| 解决方法 | 行锁(锁定读取的行)、MVCC 快照读 | 间隙锁 (Gap Lock)、Next-Key Lock、表锁 |
| 隔离级别 | RC 级别可能出现,RR 级别通过 MVCC 解决 | RR 级别可能出现(标准),InnoDB 通过间隙锁解决 |
不可重复读示例:
事务A:SELECT balance FROM account WHERE id=1; -- 读到 1000
事务B:UPDATE account SET balance=500 WHERE id=1; COMMIT;
事务A:SELECT balance FROM account WHERE id=1; -- 读到 500,两次结果不同 → 不可重复读
幻读示例:
事务A:SELECT * FROM account WHERE balance > 1000; -- 读到 3 行
事务B:INSERT INTO account(balance) VALUES(2000); COMMIT;
事务A:SELECT * FROM account WHERE balance > 1000; -- 读到 4 行,多了一行 → 幻读
MySQL InnoDB 的解决方案:
- 快照读 (普通 SELECT):通过 MVCC 读取快照,RR 级别下第一次读取建立快照,后续读取同一快照,避免不可重复读和幻读。
- 当前读 (SELECT ... FOR UPDATE / LOCK IN SHARE MODE、INSERT、UPDATE、DELETE):通过 Next-Key Lock(记录锁 + 间隙锁)锁定范围,防止其他事务插入,避免幻读。
5. 什么是聚簇索引?
聚簇索引 (Clustered Index):索引的叶子节点存储的是完整的行数据,数据和索引存储在一起。
特点:
- 一个表只能有一个聚簇索引(因为数据只能按一种物理顺序存储)。
- 叶子节点就是数据页,包含完整的行记录。
- 数据按聚簇索引的键值物理排序存储。
- 查询通过聚簇索引可以直接拿到行数据,不需要回表。
InnoDB 的聚簇索引:
- InnoDB 表是索引组织表 (IOT),数据按主键聚簇存储。
- 如果定义了主键,主键就是聚簇索引。
- 如果没有主键,选择第一个非空唯一索引作为聚簇索引。
- 如果都没有,InnoDB 自动生成一个隐藏的 6 字节自增 ID (ROW_ID) 作为聚簇索引。
聚簇索引 vs 非聚簇索引(二级索引/辅助索引):
| 区别 | 聚簇索引 | 非聚簇索引(二级索引) |
|---|---|---|
| 叶子节点存储 | 完整行数据 | 索引列值 + 主键值 |
| 数量 | 一个表只能一个 | 可以有多个 |
| 查询效率 | 高,直接拿到行数据,无需回表 | 需回表(通过主键再查聚簇索引) |
| 物理排序 | 数据按索引键物理排序 | 索引按索引键排序,数据不按此排序 |
| 插入顺序影响 | 按主键顺序插入快,乱序插入可能页分裂 | 影响较小 |
回表:通过二级索引查到主键值后,再用主键值去聚簇索引中查找完整行数据的过程。
覆盖索引:如果查询的列都在二级索引中,不需要回表,称为覆盖索引,性能更好。
聚簇索引的优缺点:
- 优点:主键查询快(直接拿数据)、范围查询快(数据物理有序)、覆盖索引优化。
- 缺点:插入乱序主键导致页分裂(性能下降)、二级索引需要回表、主键不宜过大(二级索引叶子节点存主键,主键大则二级索引占空间大)。
6. 数据库索引怎么用?适合什么场景?什么时候索引失效?
索引的作用:加速数据检索,类似书的目录。
适合建索引的场景:
- WHERE 条件中的列:经常用于查询条件的列。
- JOIN 连接的列:多表连接时,连接字段建索引。
- ORDER BY 排序的列:索引已排序,避免 filesort。
- GROUP BY 分组的列:索引有序,分组效率高。
- DISTINCT 去重的列:索引有序,去重快。
- 高选择性列:区分度高的列(如用户 ID),不适合低选择性列(如性别)。
- 范围查询的列:BETWEEN、>、< 等范围查询。
不适合建索引的场景:
- 数据量小的表:全表扫描更快,索引开销大。
- 频繁更新的列:更新时需同时维护索引,开销大。
- 低选择性列:如性别、状态(只有几个值),索引效果差。
- TEXT/BLOB 大字段:不适合直接建索引,可用前缀索引。
- 很少在查询中使用的列:索引占用空间,维护有开销。
索引失效的常见原因:
-
对索引列使用函数或运算:
WHERE YEAR(create_time) = 2023; -- 失效 WHERE create_time >= '2023-01-01' AND create_time < '2024-01-01'; -- 有效 -
隐式类型转换:
-- phone 是 VARCHAR,传入数字导致转换,失效 WHERE phone = 13800138000; WHERE phone = '13800138000'; -- 有效 -
不符合最左前缀原则(联合索引):
-- 联合索引 (a, b, c) WHERE a = 1 AND b = 2 AND c = 3; -- 全用到 WHERE a = 1 AND b = 2; -- 用到 a, b WHERE a = 1; -- 用到 a WHERE b = 2 AND c = 3; -- 失效(跳过 a) WHERE a = 1 AND c = 3; -- 只用到 a -
使用 OR 连接非索引列:
WHERE indexed_col = 1 OR no_index_col = 2; -- 失效(OR 两边都要有索引才行) -
LIKE 以通配符开头:
WHERE name LIKE '%abc'; -- 失效 WHERE name LIKE 'abc%'; -- 有效(前缀匹配) -
使用 NOT、!=、<>:
WHERE status != 1; -- 可能失效(优化器可能选择全表扫描) -
IS NULL / IS NOT NULL:可能导致索引失效(取决于数据分布)。
-
索引列参与字符串拼接:
WHERE CONCAT(first_name, last_name) = '张三'; -- 失效
索引类型:
- B+ 树索引:最常用,InnoDB 默认,支持等值查询、范围查询、排序。
- 哈希索引:Memory 引擎默认,只支持等值查询,不支持范围和排序。
- 全文索引:FULLTEXT,用于文本搜索。
- 空间索引:SPATIAL,地理数据。
7. B 树和 B+ 树的区别?
见数据结构与算法章节第 14 题。
核心区别总结:
- B 树所有节点都存数据,B+ 树只有叶子节点存数据。
- B+ 树叶子节点用链表连接,范围查询更高效。
- B+ 树非叶子节点只存索引,单页能存更多关键字,树更矮,I/O 更少。
- 数据库索引(MySQL InnoDB)用 B+ 树。
8. 乐观锁和悲观锁?
悲观锁 (Pessimistic Locking):
- 假设并发冲突一定会发生,每次操作前都加锁,确保同一时间只有一个事务操作数据。
- 类似于"先锁后用"。
实现方式:
- 数据库行锁:
SELECT ... FOR UPDATE; - 表锁、分布式锁(Redis、ZooKeeper)。
BEGIN;
SELECT * FROM account WHERE id = 1 FOR UPDATE; -- 加行锁
UPDATE account SET balance = balance - 100 WHERE id = 1;
COMMIT; -- 释放锁
优点:安全,数据一致性好。
缺点:并发性能差,可能死锁,持锁时间长影响吞吐量。
适用场景:写多冲突多、数据一致性要求高(如金融转账)。
乐观锁 (Optimistic Locking):
- 假设并发冲突很少发生,操作时不加锁,提交时检查是否有冲突,有冲突则回滚重试。
- 类似于"先做后查"。
实现方式:
- 版本号机制:
-- 表中加 version 字段
UPDATE account SET balance = balance - 100, version = version + 1
WHERE id = 1 AND version = 5; -- 只有 version 匹配才更新
-- 影响行数为 0 说明被其他事务修改,需重试
- 时间戳机制:
UPDATE account SET balance = balance - 100, update_time = NOW()
WHERE id = 1 AND update_time = '2023-01-01 12:00:00';
- CAS (Compare-And-Swap):
- 原子操作,比较当前值是否等于预期值,等于则更新。
- Java
AtomicInteger、RedisWATCH+MULTI/EXEC。
优点:并发性能好,无死锁,持锁时间短。
缺点:冲突多时重试开销大,可能 ABA 问题(可用版本号解决)。
适用场景:读多写少、冲突概率低(如库存扣减、秒杀)。
对比:
| 区别 | 悲观锁 | 乐观锁 |
|---|---|---|
| 假设 | 冲突一定发生 | 冲突很少发生 |
| 加锁时机 | 操作前 | 提交时检查 |
| 实现 | 数据库行锁、分布式锁 | 版本号、时间戳、CAS |
| 并发性能 | 较低 | 较高 |
| 冲突场景 | 适合写多冲突多 | 适合读多写少 |
| 死锁 | 可能 | 不会 |
| 重试 | 不需要 | 冲突时需重试 |
9. Redis 常见数据结构及使用场景?
| 数据结构 | 说明 | 使用场景 |
|---|---|---|
| String (字符串) | 最基本类型,二进制安全,最大 512MB | 缓存对象(JSON)、计数器(INCR/DECR)、分布式锁(SETNX)、Session 存储 |
| List (列表) | 双向链表,可从两端操作 | 消息队列(LPUSH/BRPOP)、最新列表(如最新文章)、任务队列、时间线 |
| Hash (哈希) | 键值对集合,类似 Map | 存储对象(用户信息、商品信息),比 String 存 JSON 更节省空间,可单独修改字段 |
| Set (集合) | 无序不重复集合 | 标签系统、共同好友(交集 SINTER)、抽奖(去重)、点赞/收藏(去重)、UV 统计 |
| ZSet (有序集合) | 有序不重复集合,每个元素有 score | 排行榜(游戏积分、热搜榜)、延迟队列(score 为时间戳)、范围查找、优先级队列 |
| Bitmap (位图) | String 类型的位操作 | 签到/打卡、用户在线状态、布隆过滤器、DAU 统计(节省空间) |
| HyperLogLog | 基数统计算法,占 12KB | UV 统计(不要求精确)、大规模去重计数,有 0.81% 误差 |
| Geo (地理空间) | 经纬度存储和查询 | 附近的人、地理位置搜索、距离计算 |
| Stream (流) | 5.0+ 新增,持久化消息队列 | 消息队列(支持消费者组、ACK),替代 List 做 MQ |
| Pub/Sub (发布订阅) | 发布订阅模式 | 实时消息通知、广播,不持久化,不保证消息不丢失 |
String 常用命令:
SET key value / GET key
INCR key / DECR key / INCRBY key n
SETEX key seconds value (带过期时间)
SETNX key value (不存在才设置,用于分布式锁)
MGET key1 key2 / MSET key1 value1 key2 value2
Hash 常用命令:
HSET user:1 name "张三" age 20
HGET user:1 name
HGETALL user:1
HINCRBY user:1 age 1
HEXISTS user:1 name
ZSet 常用命令:
ZADD rank 100 "user1" 90 "user2"
ZRANGE rank 0 -1 WITHSCORES (升序)
ZREVRANGE rank 0 9 WITHSCORES (Top 10)
ZSCORE rank "user1"
ZINCRBY rank 10 "user1"
选择建议:
- 简单缓存用 String。
- 对象存储用 Hash(字段多且需单独修改)。
- 列表/队列用 List。
- 去重/集合运算用 Set。
- 排序/排行榜用 ZSet。
- 计数/签到用 Bitmap。
- 大规模 UV 用 HyperLogLog。
10. Redis 持久化机制?
Redis 提供两种持久化方式:RDB 和 AOF,可同时使用。
RDB (Redis Database):
- 在指定时间间隔内,将内存中的数据快照写入磁盘(二进制文件 dump.rdb)。
- 触发方式:
- 自动:
save 900 1(900秒内至少1个key修改)、save 300 10、save 60 10000。 - 手动:
SAVE(阻塞,不推荐)、BGSAVE(fork 子进程,非阻塞)。
- 自动:
- 优点:文件紧凑、恢复速度快、适合备份和全量复制、对性能影响小(子进程处理)。
- 缺点:可能丢失最后一次快照后的数据(数据安全性不如 AOF)、fork 子进程时大数据量可能短暂阻塞。
AOF (Append Only File):
- 将每条写命令追加到 AOF 文件末尾,恢复时重新执行所有命令。
- 同步策略(
appendfsync):always:每条命令都同步,最安全,性能最差。everysec(默认):每秒同步一次,最多丢失 1 秒数据,性能好。no:由 OS 决定何时同步,最快,最不安全。
- AOF 重写 (BGREWRITEAOF):AOF 文件会越来越大,重写时生成最小化的命令集,压缩文件大小。fork 子进程处理,不阻塞。
- 优点:数据安全性高(最多丢 1 秒)、可读性好(文本格式)、自动重写压缩。
- 缺点:文件比 RDB 大、恢复速度比 RDB 慢、每次写命令有性能开销。
混合持久化 (Redis 4.0+):
aof-use-rdb-preamble yes- AOF 重写时,前半段是 RDB 格式的全量数据,后半段是增量的 AOF 命令。
- 结合了 RDB 恢复快和 AOF 数据安全的优点。
- 恢复时先加载 RDB 部分,再执行增量 AOF 命令。
选择建议:
- 只做缓存、可丢数据:不用持久化或只用 RDB。
- 数据安全要求高:AOF(everysec)+ RDB(定期备份)。
- 最佳实践:同时开启 RDB 和 AOF,Redis 重启时优先用 AOF 恢复(数据更完整)。
- 混合持久化:Redis 4.0+ 推荐开启。
数据恢复优先级:AOF > RDB(如果 AOF 开启,优先用 AOF 恢复,因为 AOF 数据更完整)。
11. Redis 缓存雪崩、缓存穿透、缓存预热?
缓存雪崩 (Cache Avalanche):
- 定义:大量缓存 key 在同一时间过期,或 Redis 宕机,导致大量请求同时打到数据库,数据库压力骤增甚至崩溃。
- 原因:
- 大量 key 设置了相同的过期时间。
- Redis 节点宕机或网络故障。
- 缓存服务重启。
- 解决方案:
- 过期时间加随机值:
TTL = base_time + random(0, 300s),避免同时过期。 - Redis 高可用:主从复制 + 哨兵 + 集群,避免单点故障。
- 熔断降级:数据库访问量过大时,限流/降级,返回默认值。
- 多级缓存:本地缓存 + Redis,Redis 挂了本地缓存还能顶一阵。
- 预热缓存:系统启动前提前加载热点数据。
- 过期时间加随机值:
缓存穿透 (Cache Penetration):
- 定义:查询一个数据库和缓存中都不存在的数据,每次请求都穿透到数据库,恶意攻击时可能压垮数据库。
- 原因:
- 恶意攻击,构造不存在的 key。
- 业务逻辑错误,查询了不存在的数据。
- 解决方案:
- 缓存空值:查询不到也缓存一个空值(如 NULL、特殊标记),设置较短过期时间,避免重复查库。
- 布隆过滤器 (Bloom Filter):在缓存前加一层布隆过滤器,key 不存在则直接拦截,不查缓存和数据库。
- 参数校验:接口层校验请求参数,过滤非法请求。
- 限流:对异常 IP 或高频请求限流。
缓存击穿 (Cache Breakdown):
- 定义:某个热点 key 过期的瞬间,大量并发请求同时打到数据库。
- 和雪崩的区别:雪崩是大量 key 同时过期,击穿是单个热点 key 过期。
- 解决方案:
- 互斥锁 (Mutex):缓存失效时,只让一个请求去查库并更新缓存,其他请求等待重试。
- 热点数据永不过期:热点 key 不设过期时间,异步更新缓存。
- 提前续期:快过期时异步刷新缓存。
缓存预热 (Cache Warmup):
- 定义:系统启动或上线前,提前将热点数据加载到缓存中,避免启动后大量请求直接打到数据库。
- 方法:
- 启动时加载:应用启动时自动加载热点数据(如热门商品、配置信息)。
- 定时任务:定时刷新热点数据缓存。
- 访问统计:统计访问频率,自动预热高频 key。
- 手动触发:提供预热接口,上线前手动调用。
- 注意:预热数据量不宜过大,避免启动慢;按访问频率优先级预热。
📌 中频题(6题)
比较常见,部分公司会问到,建议理解并能口述要点。
1. 如何对索引进行优化?
-
选择合适的索引列:
- 优先为 WHERE、JOIN、ORDER BY、GROUP BY 中的列建索引。
- 选择高选择性(区分度高)的列。
- 避免在频繁更新的列上建过多索引。
-
使用联合索引,遵循最左前缀原则:
- 将等值查询的列放前面,范围查询的列放后面。
- 把区分度高的列放前面。
- 一个联合索引可替代多个单列索引,减少索引数量。
-
使用覆盖索引避免回表:
- 查询的列都包含在索引中,不需要回表查询聚簇索引。
- 例:
SELECT name FROM user WHERE age = 20;,建联合索引(age, name)就是覆盖索引。
-
避免索引失效:
- 不对索引列使用函数、运算。
- 避免隐式类型转换。
- LIKE 不以通配符开头。
- 遵循最左前缀原则。
-
控制索引数量:
- 索引不是越多越好,每个索引都占用空间,且 INSERT/UPDATE/DELETE 时需维护。
- 删除未使用的索引(通过
sys.schema_unused_indexes查看)。 - 避免重复索引(如已有 (a,b),再建 (a) 就是冗余)。
-
使用前缀索引:
- 对长字符串列(如 VARCHAR(255)),只索引前 N 个字符。
CREATE INDEX idx_name ON table(name(20));- 节省索引空间,但区分度可能降低,需选择合适的前缀长度。
-
优化排序和分组:
- ORDER BY 的列建索引,避免 filesort。
- GROUP BY 的列建索引,避免临时表。
- 联合索引的顺序要同时满足 WHERE 和 ORDER BY。
-
使用强制索引(谨慎):
SELECT * FROM table FORCE INDEX(idx_name) WHERE ...- 优化器选错索引时使用,但通常应先分析原因(统计信息过期等)。
-
定期分析和优化表:
ANALYZE TABLE table;更新统计信息,帮助优化器选择正确索引。OPTIMIZE TABLE table;整理碎片,重建索引。
-
监控慢查询:
- 开启慢查询日志,分析慢 SQL,针对性加索引。
EXPLAIN分析执行计划,查看索引使用情况。
EXPLAIN 关键指标:
type:访问类型,从好到差:system > const > eq_ref > ref > range > index > ALL。key:实际使用的索引。rows:预计扫描的行数。Extra:Using index(覆盖索引)、Using where、Using filesort(需优化)、Using temporary(需优化)。
2. 创建索引一定能加快检索速度吗?为什么?
不一定。 索引在大多数情况下能加速查询,但在某些场景下反而可能变慢或没有效果。
索引能加速的原因:
- B+ 树索引将查找复杂度从全表扫描 O(n) 降到 O(log n)。
- 索引有序,可加速范围查询、排序、分组。
- 覆盖索引可避免回表,直接从索引获取数据。
索引不能加速甚至变慢的场景:
-
数据量小的表:
- 全表扫描只需读几个数据页,而索引查询需要先读索引页再回表读数据页,反而更慢。
- 优化器会自动选择全表扫描。
-
低选择性列:
- 如性别(男/女)、状态(0/1),索引过滤后仍需扫描大量行。
- 回表开销大,优化器可能选择全表扫描。
-
查询返回大部分数据:
- 如果查询返回表中 30% 以上的数据,回表的随机 I/O 开销可能超过全表扫描的顺序 I/O。
- 优化器会选择全表扫描。
-
索引失效:
- 对索引列使用函数、隐式转换、不符合最左前缀等,索引不生效,等于没建。
-
频繁写入的表:
- 每次 INSERT/UPDATE/DELETE 都需要维护索引,写入变慢。
- 索引越多,写入开销越大。
-
索引碎片过多:
- 长期更新导致索引碎片,索引页利用率低,查询时 I/O 增多。
- 需
OPTIMIZE TABLE重建索引。
-
统计信息过期:
- 优化器基于统计信息选择索引,统计信息不准可能选错索引。
- 需
ANALYZE TABLE更新统计信息。
-
ORDER BY 与索引顺序不一致:
- 虽然有索引,但排序方向或列顺序不匹配,仍需 filesort。
结论:索引是双刃剑,需要根据查询模式合理设计,不是建得越多越好。要通过 EXPLAIN 分析执行计划,验证索引是否被有效使用。
3. 共享锁与独占锁?
共享锁 (Shared Lock, S锁 / 读锁):
- 也叫读锁,多个事务可以同时持有同一数据的共享锁。
- 持有共享锁时,其他事务可以读(加共享锁),但不能写(不能加独占锁)。
SELECT ... LOCK IN SHARE MODE;(MySQL 8.0 前)或SELECT ... FOR SHARE;(MySQL 8.0+)。
独占锁 (Exclusive Lock, X锁 / 写锁):
- 也叫写锁,一个事务持有独占锁时,其他事务不能再持有该数据的任何锁(共享锁或独占锁)。
- 持有独占锁时,其他事务不能读也不能写(除非用快照读/MVCC)。
SELECT ... FOR UPDATE;- INSERT、UPDATE、DELETE 自动加独占锁。
锁的兼容性矩阵:
| 共享锁 (S) | 独占锁 (X) | |
|---|---|---|
| 共享锁 (S) | 兼容 | 不兼容 |
| 独占锁 (X) | 不兼容 | 不兼容 |
InnoDB 中的锁类型:
| 锁类型 | 说明 |
|---|---|
| 记录锁 (Record Lock) | 锁定单行记录 |
| 间隙锁 (Gap Lock) | 锁定索引记录之间的间隙,防止插入 |
| Next-Key Lock | 记录锁 + 间隙锁,锁定左开右闭区间,RR 级别默认 |
| 意向锁 (Intention Lock) | 表级锁,表示事务打算对表中的行加锁,分为 IS 和 IX |
| 自增锁 (AUTO-INC Lock) | 插入自增列时的表级锁 |
意向锁的作用:
- 意向共享锁 (IS):事务打算给行加共享锁。
- 意向独占锁 (IX):事务打算给行加独占锁。
- 意向锁之间全部兼容,意向锁与表级锁互斥检查。
- 提高效率:加表锁前只需检查意向锁,不需要逐行检查行锁。
快照读 vs 当前读:
- 快照读 (普通 SELECT):不加锁,读取 MVCC 快照,不阻塞。
- 当前读 (SELECT ... FOR UPDATE、INSERT、UPDATE、DELETE):加锁,读取最新数据。
4. Redis 线程模型?
Redis 是单线程模型(核心网络 I/O 和命令执行是单线程),但不是完全单线程。
Redis 6.0 之前:
- 完全单线程:网络 I/O(读写)、命令解析、命令执行都在一个线程中。
- 基于 I/O 多路复用 (epoll) + 事件驱动 (Reactor 模式)。
- 单线程为什么快?
- 纯内存操作,速度极快(纳秒级)。
- I/O 多路复用,单线程处理大量并发连接,避免线程切换开销。
- 避免了多线程的锁竞争和上下文切换。
- 数据结构高效(跳表、哈希表等)。
Redis 6.0+ 多线程:
- 核心命令执行仍是单线程(保证数据一致性,避免锁)。
- 网络 I/O 多线程:引入多线程处理网络读写(read/write)和协议解析,提高网络 I/O 吞吐量。
- 命令执行仍在主线程串行执行。
- 配置:
io-threads 4(I/O 线程数)、io-threads-do-reads yes(开启读多线程)。
Redis 的后台线程:
- 即使是单线程版本,也有后台线程/子进程处理耗时操作:
- BGSAVE:fork 子进程生成 RDB 快照。
- BGREWRITEAOF:fork 子进程重写 AOF。
- 异步删除 (unlink):4.0+ 引入,大 key 删除时放到后台线程,不阻塞主线程。
- lazy free:
lazyfree-lazy-eviction、lazyfree-lazy-expire等,淘汰/过期 key 时异步删除。
Redis 单线程的瓶颈:
- 单个命令执行时间过长(如
KEYS *、大集合操作)会阻塞所有其他命令。 - 网络 I/O 瓶颈(6.0 前)。
- 单 CPU 核心利用率(只能用一个核)。
为什么不用多线程执行命令?
- 多线程需要加锁保护数据,增加复杂度和性能开销。
- 上下文切换开销。
- Redis 操作都是内存操作,单线程已经足够快(10万+ QPS)。
- 可通过集群(Redis Cluster)横向扩展,利用多机多核。
5. C++ Map 也是缓存型数据结构,为什么不用 Map 而用 Redis?
虽然 C++ 的 std::map/unordered_map 也能做缓存,但和 Redis 有本质区别:
| 对比维度 | C++ Map (进程内) | Redis |
|---|---|---|
| 存储位置 | 进程内存 | 独立服务的内存 |
| 共享性 | 只能本进程访问 | 多进程、多机器共享 |
| 持久化 | 无(进程退出数据丢失) | RDB/AOF 持久化 |
| 过期策略 | 需自己实现 | 内置 TTL 过期 |
| 淘汰策略 | 需自己实现 | LRU/LFU 等内置淘汰 |
| 并发安全 | 需自己加锁 | 单线程模型,天然线程安全 |
| 数据结构 | map/unordered_map | String/Hash/List/Set/ZSet 等丰富 |
| 分布式 | 不支持 | 支持集群、主从、哨兵 |
| 网络访问 | 无(函数调用) | TCP 协议,有网络开销 |
| 容量限制 | 受单进程内存限制 | 可集群扩展,容量大 |
| 跨语言 | 仅 C++ | 支持多种语言客户端 |
核心区别:
- 共享性:C++ Map 是进程内的,多个服务实例无法共享缓存;Redis 是独立服务,所有实例都能访问。
- 持久化:Redis 支持 RDB/AOF,重启不丢数据;C++ Map 进程退出就没了。
- 分布式扩展:Redis 支持集群、主从复制、哨兵高可用;C++ Map 只能单机单进程。
- 功能丰富:Redis 内置过期、淘汰、发布订阅、事务、Lua 脚本等;C++ Map 需自己实现。
- 语言无关:Redis 支持 Java/Python/Go/C++ 等所有语言;C++ Map 只能 C++ 用。
什么时候可以用 C++ Map 做缓存?
- 单机单进程应用。
- 缓存数据量小,不需要共享。
- 对访问延迟要求极高(纳秒级,Redis 是毫秒级网络开销)。
- 不需要持久化和高可用。
- 可使用 LRU 缓存库(如 C++17 的
std::lru_cache或自己实现)。
实际项目中:通常用 本地缓存 (C++ Map/LRU) + Redis 分布式缓存 的多级缓存架构:
- 先查本地缓存(最快),未命中再查 Redis。
- 本地缓存存热点数据,Redis 存全量缓存。
- 本地缓存通过消息队列/订阅机制更新和失效。
6. Redis 有序集合底层实现?
Redis 的 ZSet (Sorted Set,有序集合) 底层使用 跳表 (Skiplist) + 哈希表 (Hash) 双重结构。
为什么用两种结构?
- 哈希表:存储 member → score 的映射,实现 O(1) 的按 member 查找 score。
- 跳表:按 score 有序存储,实现范围查询、排名查询、按 score 范围查找等。
- 两种结构保存相同的数据,通过指针共享,不额外占用太多空间。
跳表 (Skiplist) 结构:
- 跳表是一种有序数据结构,在链表基础上增加多级索引。
- 最底层是完整的有序链表(L0)。
- 上层是下层的子集索引(L1、L2...),随机决定每个节点有多少层。
- 查找时从最高层开始,逐层下降,类似二分查找,O(log n)。
L3: head ─────────────────────────────────→ 100 → null
L2: head ───────────────→ 50 ────────────→ 100 → null
L1: head ───→ 20 ───────→ 50 ───→ 80 ───→ 100 → null
L0: head → 10 → 20 → 30 → 50 → 60 → 80 → 90 → 100 → null
跳表的特点:
- 查找、插入、删除都是 O(log n) 平均时间。
- 实现比红黑树简单。
- 范围查询高效(找到起点后沿底层链表顺序遍历)。
- 内存占用比红黑树稍大(多层指针)。
为什么用跳表而不是红黑树?
- 范围查询更高效:跳表底层是有序链表,范围查询只需找到起点后顺序遍历;红黑树需要中序遍历,更复杂。
- 实现简单:跳表的插入删除比红黑树简单(不需要旋转、变色等复杂操作)。
- 无锁友好:跳表更易实现并发(虽然 Redis 是单线程,但设计上考虑了)。
- 内存灵活:跳表的层数随机,平均空间效率好。
ZSet 的编码方式:
- ziplist(压缩列表):元素少(< 128 个)且每个 member 短(< 64 字节)时使用,节省内存。数据紧凑存储,连续内存。
- skiplist + hashtable:元素多或 member 长时,转为跳表+哈希表结构,提高效率。
- 转换阈值由
zset-max-ziplist-entries(默认128)和zset-max-ziplist-value(默认64)控制。
ZSet 常用操作复杂度:
- ZADD:O(log n)
- ZREM:O(log n)
- ZSCORE:O(1)(哈希表)
- ZRANK/ZREVRANK:O(log n)(跳表)
- ZRANGE/ZREVRANGE:O(log n + m)(m 为返回元素数)
- ZRANGEBYSCORE:O(log n + m)
六、设计模式
🔥 高频题(4题)
面试中最常出现,核心知识点,建议重点掌握,能熟练默写。
1. 单例模式的构造函数?
单例模式 (Singleton):保证一个类只有一个实例,并提供全局访问点。
构造函数的特点:
- 构造函数必须是 private(或 protected):防止外部直接创建实例。
- 拷贝构造函数和赋值运算符必须删除 (C++11 =delete):防止拷贝实例。
- 移动构造函数也应删除。
class Singleton {
private:
Singleton() {} // 私有构造函数
~Singleton() {}
Singleton(const Singleton&) = delete; // 禁止拷贝
Singleton& operator=(const Singleton&) = delete; // 禁止赋值
Singleton(Singleton&&) = delete; // 禁止移动
Singleton& operator=(Singleton&&) = delete;
public:
static Singleton& getInstance() {
static Singleton instance; // C++11 局部静态变量,线程安全
return instance;
}
};
单例模式的实现方式:
1. 饿汉式 (Eager Initialization):
class Singleton {
private:
static Singleton instance; // 程序启动时就创建
Singleton() {}
public:
static Singleton& getInstance() { return instance; }
};
Singleton Singleton::instance; // 类外定义
- 优点:简单,线程安全(静态变量在 main 前初始化)。
- 缺点:无论是否使用都创建,浪费资源;不能延迟初始化。
2. 懒汉式 (Lazy Initialization) - 双重检查锁定 (DCLP):
class Singleton {
private:
static std::atomic<Singleton*> instance;
static std::mutex mutex;
Singleton() {}
public:
static Singleton* getInstance() {
Singleton* tmp = instance.load(std::memory_order_acquire);
if (!tmp) {
std::lock_guard<std::mutex> lock(mutex);
tmp = instance.load(std::memory_order_relaxed);
if (!tmp) {
tmp = new Singleton();
instance.store(tmp, std::memory_order_release);
}
}
return tmp;
}
};
- C++11 前需要 DCLP,C++11 后推荐用局部静态变量(Meyers Singleton)。
3. Meyers Singleton(推荐,C++11+):
static Singleton& getInstance() {
static Singleton instance; // 局部静态变量
return instance;
}
- C++11 起,局部静态变量的初始化是线程安全的(Magic Statics)。
- 懒加载,第一次调用时创建。
- 最简单,推荐使用。
4. 模板单例:
template<typename T>
class Singleton {
public:
static T& getInstance() {
static T instance;
return instance;
}
protected:
Singleton() = default;
~Singleton() = default;
};
单例的适用场景:
- 配置管理器、日志管理器、线程池、连接池、缓存管理器。
- 需要全局唯一实例且频繁访问的对象。
单例的缺点:
- 全局状态,增加耦合,不利于单元测试(难以 mock)。
- 隐藏依赖关系。
- 多线程下需注意线程安全(C++11 局部静态已解决)。
- 生命周期管理复杂(何时销毁)。
2. 如何使用单例模式?注意事项?
使用方法:
// 获取单例实例
Singleton& s = Singleton::getInstance();
s.doSomething();
// 或指针方式
Singleton* p = Singleton::getInstance();
p->doSomething();
注意事项:
-
线程安全:
- C++11 之前,懒汉式需要双重检查锁定 (DCLP)。
- C++11 之后,推荐用局部静态变量(Meyers Singleton),初始化线程安全。
- 但单例对象的成员方法如果有共享数据,仍需自己加锁保护。
-
禁止拷贝和赋值:
- 拷贝构造、赋值运算符、移动构造都要
=delete。 - 否则可能通过拷贝创建第二个实例,破坏单例。
- 拷贝构造、赋值运算符、移动构造都要
-
构造函数私有:
- 构造函数、析构函数设为 private/protected。
- 防止外部 new、delete。
-
生命周期管理:
- 局部静态变量的单例在程序结束时自动析构。
- 注意析构顺序:如果单例 A 依赖单例 B,且 B 先析构,A 析构时访问 B 会崩溃。
- 可用
atexit或引用计数管理析构顺序。
-
避免滥用:
- 单例是全局状态,增加耦合,不利于测试和扩展。
- 能用依赖注入就不用单例。
- 不要为了"方便访问"而用单例,应确认真的需要全局唯一实例。
-
可测试性:
- 单例难以 mock,单元测试时可能需要重置状态。
- 可通过接口抽象 + 依赖注入替代单例,便于测试。
-
多进程/分布式:
- 单例只保证单进程内唯一,多进程/分布式环境下每个进程都有自己的实例。
- 分布式场景需用分布式锁或共享存储。
-
序列化问题:
- 如果单例可序列化,反序列化可能创建新实例。
- 需重写序列化方法保证反序列化后仍是同一实例。
单例 vs 静态类:
- 单例有实例,可以实现接口、继承、多态。
- 静态类只有静态方法,不能多态,不能作为参数传递。
- 需要多态和接口时用单例,纯工具函数用静态类。
3. 观察者模式?
观察者模式 (Observer Pattern):定义对象间的一对多依赖关系,当一个对象状态改变时,所有依赖它的对象都收到通知并自动更新。
角色:
- 主题 (Subject / Observable):被观察的对象,维护观察者列表,提供注册/注销/通知方法。
- 观察者 (Observer):定义更新接口,在主题状态改变时被调用。
- 具体主题 (ConcreteSubject):具体状态,状态改变时通知观察者。
- 具体观察者 (ConcreteObserver):实现更新接口,响应主题变化。
C++ 实现:
#include <vector>
#include <algorithm>
#include <iostream>
// 观察者接口
class Observer {
public:
virtual void update(int state) = 0;
virtual ~Observer() = default;
};
// 主题(被观察者)
class Subject {
private:
std::vector<Observer*> observers_;
int state_ = 0;
public:
void attach(Observer* obs) {
observers_.push_back(obs);
}
void detach(Observer* obs) {
observers_.erase(std::remove(observers_.begin(), observers_.end(), obs), observers_.end());
}
void notify() {
for (Observer* obs : observers_) {
obs->update(state_);
}
}
void setState(int state) {
state_ = state;
notify(); // 状态改变,通知所有观察者
}
};
// 具体观察者
class ConcreteObserverA : public Observer {
public:
void update(int state) override {
std::cout << "ObserverA 收到通知,状态: " << state << std::endl;
}
};
class ConcreteObserverB : public Observer {
public:
void update(int state) override {
std::cout << "ObserverB 收到通知,状态: " << state << std::endl;
}
};
// 使用
int main() {
Subject subject;
ConcreteObserverA obsA;
ConcreteObserverB obsB;
subject.attach(&obsA);
subject.attach(&obsB);
subject.setState(1); // 两个观察者都收到通知
subject.detach(&obsA);
subject.setState(2); // 只有 obsB 收到通知
}
应用场景:
- 事件监听系统(按钮点击、消息订阅)。
- MVC 模式中 Model 变化通知 View 更新。
- 发布-订阅系统。
- 数据变化联动(如表格数据变化自动更新图表)。
- 配置热更新。
优点:
- 主题和观察者松耦合,主题不需要知道观察者的具体实现。
- 符合开闭原则,新增观察者不需要修改主题代码。
- 支持广播通信,一对多通知。
缺点:
- 通知顺序不确定,观察者过多时通知可能耗时。
- 观察者和主题之间可能有循环依赖,导致循环通知。
- 观察者收到通知后如果执行耗时操作,会阻塞主题的通知流程(可用异步通知优化)。
- 弱引用问题:观察者销毁后如果没有 detach,会导致悬垂指针(可用 weak_ptr 解决)。
C++11 改进:
- 可用
std::function+std::bind简化观察者,不需要继承接口。 - 可用
std::shared_ptr/weak_ptr管理观察者生命周期,避免悬垂指针。 - 信号槽库(如 Boost.Signals2、libsigc++)是观察者模式的高级实现。
4. 使用过的设计模式及应用场景?
常用设计模式及实际应用:
| 设计模式 | 应用场景 | 实际例子 |
|---|---|---|
| 单例模式 | 全局唯一实例 | 配置管理器、日志器、线程池、连接池 |
| 工厂模式 | 创建对象,隐藏创建逻辑 | 日志工厂(创建不同级别日志)、对象池 |
| 抽象工厂 | 创建一族相关对象 | 跨平台 UI 组件(Windows/Linux 按钮、菜单) |
| 建造者模式 | 构建复杂对象,分步构建 | HTTP 请求构建、SQL 查询构建、配置对象 |
| 原型模式 | 复制对象,避免重复初始化 | 单元格复制、游戏角色克隆 |
| 适配器模式 | 接口兼容 | 第三方库接口封装、新旧系统对接 |
| 装饰器模式 | 动态增加功能 | IO 流(BufferedReader 装饰 FileReader)、中间件 |
| 代理模式 | 控制访问 | 远程代理(RPC)、虚拟代理(懒加载图片)、保护代理(权限控制) |
| 外观模式 | 简化子系统接口 | 统一封装多个复杂子系统的调用 |
| 桥接模式 | 抽象和实现分离 | 不同形状+不同颜色的组合 |
| 策略模式 | 算法可替换 | 排序算法选择、支付方式(微信/支付宝/银行卡) |
| 观察者模式 | 事件通知 | 事件监听、消息订阅、MVC 数据更新 |
| 命令模式 | 请求封装为对象 | 撤销/重做、任务队列、宏命令 |
| 状态模式 | 状态机 | 订单状态流转、TCP 连接状态 |
| 迭代器模式 | 遍历集合 | STL 迭代器、遍历不同容器 |
| 模板方法 | 算法骨架,子类实现步骤 | 游戏加载流程、测试用例框架 |
| 访问者模式 | 数据结构和操作分离 | 编译器 AST 遍历、报表统计 |
| 责任链模式 | 请求逐级处理 | 审批流程、过滤器链、异常处理链 |
| 中介者模式 | 集中管理对象交互 | 聊天室、MVC 中的 Controller |
| 备忘录模式 | 保存恢复状态 | 游戏存档、编辑器撤销、快照 |
| 享元模式 | 共享细粒度对象 | 字符串常量池、棋子、字体 |
| 组合模式 | 树形结构统一处理 | 文件系统、UI 组件树、组织架构 |
项目中常用的几个:
- 单例模式:日志管理器、配置管理器、线程池。
- 工厂模式:根据配置创建不同的数据库连接、不同的序列化器。
- 策略模式:支付模块,不同支付方式(微信/支付宝/银联)封装为不同策略,运行时切换。
- 观察者模式:事件总线,模块间解耦通信(如数据更新通知 UI 刷新)。
- 装饰器模式:网络请求中间件(日志装饰、鉴权装饰、重试装饰),动态叠加功能。
- 代理模式:RPC 远程调用,本地代理对象转发请求到远程服务。
- 模板方法:算法竞赛题解框架,固定流程,子类实现具体步骤。
- 建造者模式:构建复杂的 HTTP 请求或数据库查询,链式调用。
设计模式的选择原则:
- 不要为了用模式而用模式,根据实际问题选择。
- 优先用简单方案,复杂时再引入设计模式。
- 设计模式是经验总结,不是银弹,过度使用会增加复杂度。
📌 中频题(2题)
比较常见,部分公司会问到,建议理解并能口述要点。
1. 为什么用组合而不要用继承?
组合 (Composition):在类中包含其他类的对象作为成员,通过调用成员对象的方法来复用功能。
继承 (Inheritance):派生类继承基类,自动获得基类的属性和方法。
优先使用组合的原因:
-
耦合度低:
- 继承是白盒复用,派生类能看到基类的内部实现,耦合紧密。
- 组合是黑盒复用,通过接口交互,不依赖内部实现,耦合低。
-
灵活性高:
- 继承在编译期确定,运行时不能改变继承关系。
- 组合可以在运行时动态替换成员对象,更灵活。
- 组合可以有选择地复用功能,继承必须接受基类所有接口(包括不需要的)。
-
避免继承的问题:
- 脆弱基类问题:基类修改可能破坏派生类。
- 菱形继承/多继承歧义:多继承可能导致方法冲突、数据冗余。
- 继承层次过深:难以理解和维护。
- 基类接口膨胀:派生类继承了不需要的方法。
-
符合设计原则:
- 合成复用原则 (CRP):优先使用组合而非继承达到复用目的。
- 里氏替换原则 (LSP):继承必须满足 is-a 关系,组合是 has-a 关系。
- 依赖倒置原则 (DIP):组合依赖抽象接口,更易扩展。
- 开闭原则 (OCP):组合更容易扩展,不修改原有代码。
什么时候用继承?
- 真正的 is-a 关系:派生类确实是基类的一种。
- 需要多态:通过基类指针/引用调用派生类方法。
- 需要复用基类的接口和实现,且接口稳定。
什么时候用组合?
- has-a 关系:一个类拥有另一个类的功能。
- 需要灵活替换实现。
- 只需要复用部分功能。
- 避免多继承的复杂性。
示例对比:
// 继承:鸟是动物(is-a),合理
class Animal { virtual void speak(); };
class Bird : public Animal { void speak() override; };
// 组合:汽车有发动机(has-a),用组合
class Engine { void start(); };
class Car {
Engine engine_; // 组合
public:
void start() { engine_.start(); }
};
// 错误:Car 继承 Engine(汽车不是发动机)
经验法则:"组合优于继承"是面向对象设计的重要原则,但不是绝对。关键是判断是 is-a 还是 has-a 关系。
2. 适配器模式?
适配器模式 (Adapter Pattern):将一个类的接口转换成客户期望的另一个接口,使原本接口不兼容的类可以一起工作。
角色:
- 目标接口 (Target):客户期望的接口。
- 适配器 (Adapter):实现目标接口,内部持有被适配者的引用,将请求转换后转发给被适配者。
- 被适配者 (Adaptee):已存在的、接口不兼容的类。
两种实现方式:
1. 对象适配器(组合,推荐):
// 目标接口
class Target {
public:
virtual void request() = 0;
virtual ~Target() = default;
};
// 被适配者(已有类,接口不兼容)
class Adaptee {
public:
void specificRequest() {
cout << "Adaptee 的特定请求" << endl;
}
};
// 适配器(组合 Adaptee)
class Adapter : public Target {
private:
Adaptee* adaptee_;
public:
Adapter(Adaptee* adaptee) : adaptee_(adaptee) {}
void request() override {
adaptee_->specificRequest(); // 转换调用
}
};
// 使用
Adaptee* adaptee = new Adaptee();
Target* target = new Adapter(adaptee);
target->request(); // 客户用 Target 接口,实际调用 Adaptee
2. 类适配器(多继承):
class Adapter : public Target, private Adaptee {
public:
void request() override {
specificRequest(); // 直接调用继承的方法
}
};
- 用多继承,同时继承 Target 和 Adaptee。
- 缺点:不够灵活,只能适配一个 Adaptee 类,C++ 多继承有复杂性。
- 推荐用对象适配器(组合)。
应用场景:
- 使用第三方库,但接口不符合现有系统。
- 新旧系统接口兼容。
- 统一多个不同接口的类。
- 封装遗留代码。
实际例子:
- STL 的
stack、queue是适配器,底层用deque。 - Java 的
Arrays.asList()将数组转为 List。 - 电源适配器(220V 转 5V)。
优点:
- 解耦客户和被适配者,不需要修改原有代码。
- 符合开闭原则,新增适配器不影响现有代码。
- 提高类的复用性。
缺点:
- 增加了一层间接调用,代码复杂度略增。
- 过多适配器会使系统混乱。
七、项目(网盘/搜索引擎项目)
🔥 高频题(2题)
面试中最常出现,核心知识点,建议重点掌握,能熟练默写。
1. 线程池如何实现?项目中怎么用?
见 Linux 章节第 33 题(线程池实现)。
项目中的应用(网盘项目):
线程池的分工:
- I/O 线程池:处理网络 I/O(epoll 事件循环),接收连接、读写数据。通常 1-4 个线程。
- 业务工作线程池:处理业务逻辑(文件解析、数据库操作、计算 MD5)。通常 CPU 核心数 × 2。
- 磁盘 I/O 线程池:处理文件读写(上传下载的磁盘操作)。避免阻塞业务线程。
典型架构:
客户端连接 → I/O 线程(epoll,接收数据)
↓ 放入任务队列
业务线程池(处理业务逻辑)
↓ 需要磁盘操作时
磁盘线程池(读写文件)
项目中的具体使用:
- 上传文件:I/O 线程接收数据 → 业务线程解析协议 → 磁盘线程写入文件 → 计算 MD5(可在业务线程或专用线程)。
- 下载文件:业务线程查数据库获取文件路径 → 磁盘线程读取文件分片 → I/O 线程发送给客户端。
- MD5 计算:大文件 MD5 计算耗时,用专门的计算线程池,避免阻塞。
- 数据库操作:数据库查询可能阻塞,用线程池异步处理。
线程数设置:
- I/O 线程:1-4 个(epoll 单线程已足够,多线程需注意负载均衡)。
- 业务线程:CPU 核心数 × 2(混合 I/O 和计算)。
- 磁盘线程:2-4 个(磁盘 I/O 并发度有限,过多反而竞争)。
任务队列:
- 用阻塞队列(mutex + condition_variable)实现。
- I/O 线程往队列放任务,工作线程从队列取任务。
- 队列有最大长度,满时拒绝或阻塞(背压)。
2. 缓存和数据库如何同步?
缓存(Redis)和数据库(MySQL)同步的常见策略:
1. Cache-Aside(旁路缓存,最常用):
- 读:先读缓存,命中则返回;未命中则读数据库,然后写入缓存。
- 写:先更新数据库,再删除缓存(不是更新缓存)。
读:cache → miss → db → write cache → return
写:update db → delete cache
- 为什么是删除缓存而不是更新缓存?
- 更新缓存可能写入脏数据(并发场景)。
- 删除缓存更简单,下次读时自动从数据库加载最新值。
- 懒加载,只缓存被读取的数据。
2. 先更新数据库,再删除缓存的并发问题:
- 极端情况:缓存刚好失效 → 线程A读数据库(旧值)→ 线程B更新数据库 + 删除缓存 → 线程A写入缓存(旧值)→ 缓存脏数据。
- 概率很低(需要缓存刚好失效,且读数据库比写数据库慢),但理论上存在。
- 解决方案:
- 缓存过期时间:给缓存设置 TTL,即使有脏数据也会在 TTL 后过期。
- 延迟双删:更新数据库后删除缓存,延迟一段时间(如 500ms)再删一次。
- 分布式锁:读缓存未命中时加锁,防止并发写脏数据。
3. Write-Through(写穿透):
- 写操作同时更新缓存和数据库,缓存层负责同步。
- 优点:缓存和数据库始终一致。
- 缺点:写操作延迟高(需等两者都完成),写入不频繁的数据也会被缓存。
4. Write-Behind / Write-Back(异步写回):
- 写操作只更新缓存,异步批量写入数据库。
- 优点:写性能极高。
- 缺点:数据可能丢失(缓存宕机),一致性差。
- 适用于写密集、可容忍少量丢失的场景(如计数器、日志)。
5. 基于消息队列的最终一致性:
- 更新数据库后,发送消息到 MQ。
- 消费者接收消息,删除/更新缓存。
- 保证最终一致性,适合高并发场景。
- 可用 Canal 订阅 MySQL binlog,异步更新缓存(对业务代码无侵入)。
6. 缓存预热:
- 系统启动前,提前将热点数据加载到缓存。
- 避免启动后大量请求直接打到数据库。
项目中的方案:
- 主要用 Cache-Aside(先更数据库,再删缓存)。
- 缓存设置合理的过期时间(如 5-30 分钟),兜底一致性。
- 热点数据(如用户信息、文件元数据)用缓存,非热点直接查数据库。
- 数据库更新后通过 MQ 异步删除缓存,保证最终一致。
- 用 Canal 监听 binlog,数据变更时自动失效缓存。
关键原则:
- 缓存和数据库的强一致性很难保证,通常追求最终一致性。
- 给缓存设置过期时间是最简单的兜底方案。
- 不要在高并发场景下追求强一致性(性能代价大),用分布式锁 + 过期时间平衡。
📌 中频题(3题)
比较常见,部分公司会问到,建议理解并能口述要点。
1. 客户端发消息给服务器,服务器如何解析?
典型流程(以自定义二进制协议为例):
-
接收数据:
- 服务器通过 epoll 监听客户端连接的可读事件。
- 调用
read/recv读取数据到应用层缓冲区。 - TCP 是字节流,可能一次读不完一个完整消息,也可能读到多个消息,需处理粘包/半包。
-
解析消息头:
- 自定义协议通常包含固定长度的消息头:魔数、版本、消息类型、消息体长度、序列号等。
- 先读取固定长度的消息头,解析出消息体长度。
-
读取消息体:
- 根据消息头中的长度字段,读取完整的消息体。
- 如果缓冲区数据不足一个完整消息,等待更多数据到达。
-
反序列化:
- 将消息体的二进制数据反序列化为具体的消息对象(Protobuf/JSON/自定义)。
- 根据消息类型字段,反序列化为对应的消息结构。
-
分发处理:
- 根据消息类型,调用对应的处理函数(消息分发器/回调表)。
- 如登录消息 → 登录处理函数,上传文件消息 → 上传处理函数。
-
构造响应:
- 处理完成后,构造响应消息,序列化,加上消息头,发送给客户端。
协议格式示例:
┌─────────────────────────────────────────────────┐
│ 消息头(固定长度) │ 消息体(变长) │
├────┬────┬──────┬──────┼──────────────────────────┤
│魔数│版本│类型 │长度 │ 序列化后的消息体 │
│4B │1B │2B │4B │ (Protobuf/JSON) │
└────┴────┴──────┴──────┴──────────────────────────┘
消息分发实现:
// 消息类型 -> 处理函数的映射
std::unordered_map<uint16_t, std::function<void(const Message&)>> handlers_;
void registerHandler(uint16_t type, Handler handler) {
handlers_[type] = handler;
}
void dispatch(const Message& msg) {
auto it = handlers_.find(msg.type);
if (it != handlers_.end()) {
it->second(msg); // 调用对应处理函数
}
}
关键点:
- 处理粘包/半包(应用层缓冲区 + 长度字段)。
- 协议设计要有魔数(校验数据合法性)、版本号(兼容升级)。
- 消息分发用表驱动(类型→函数映射),避免大量 if-else/switch。
- 大消息/文件传输可能需要分片传输和重组。
2. 多个用户上传同一份文件如何处理?秒传如何实现?
核心思路:文件去重 + 秒传
1. 文件秒传(上传前检查):
- 客户端上传文件前,先计算文件的 MD5/SHA256 哈希值。
- 将哈希值发送给服务器,服务器检查该哈希是否已存在。
- 如果已存在,直接将该文件关联到当前用户(不重复存储),返回"秒传成功"。
- 如果不存在,才开始实际上传。
2. 文件去重存储:
- 服务器端维护文件哈希 → 物理文件路径的映射表。
- 多个用户上传相同文件时,只存储一份物理文件。
- 用户文件表中记录用户ID + 文件哈希 + 文件名(逻辑关联)。
- 引用计数:记录有多少用户引用该文件,删除时引用计数减 1,归零时才真正删除物理文件。
3. 秒传的完整流程:
客户端 服务器
│ │
│ 1. 计算文件 MD5 │
│ ───────────────────────────────→ │
│ │ 2. 查数据库,MD5 是否已存在
│ │
│ 3a. 已存在 → 返回秒传成功 │
│ ←─────────────────────────────── │
│ (不传输文件内容) │
│ │
│ 3b. 不存在 → 开始上传文件内容 │
│ ───────────────────────────────→ │
│ (分片上传) │
│ │ 4. 接收完成,校验 MD5
│ │ 5. 存储文件,记录 MD5 映射
│ 6. 返回上传成功 │
│ ←─────────────────────────────── │
4. 大文件分片上传 + 秒传优化:
- 大文件计算整个文件 MD5 较慢,可先计算文件大小 + 前 N 字节哈希做快速判断。
- 分片上传时,每个分片也计算哈希,已存在的分片可以秒传(断点续传 + 分片去重)。
- 上传中断后,已上传的分片不需要重传。
5. 数据库设计:
-- 文件表(物理文件)
CREATE TABLE files (
id BIGINT PRIMARY KEY,
md5 CHAR(32) UNIQUE, -- 文件哈希
size BIGINT, -- 文件大小
storage_path VARCHAR(255), -- 物理存储路径
ref_count INT DEFAULT 0, -- 引用计数
created_at TIMESTAMP
);
-- 用户文件表(逻辑关联)
CREATE TABLE user_files (
id BIGINT PRIMARY KEY,
user_id BIGINT,
file_id BIGINT, -- 关联 files 表
file_name VARCHAR(255), -- 用户自定义文件名
parent_dir BIGINT, -- 所在目录
created_at TIMESTAMP,
FOREIGN KEY (file_id) REFERENCES files(id)
);
6. 删除处理:
- 用户删除文件时,只删除 user_files 记录,files.ref_count 减 1。
- ref_count 归零时,才删除物理文件和 files 记录。
- 可延迟删除(垃圾回收),避免误删恢复困难。
注意事项:
- MD5 有碰撞风险(理论上),重要场景可用 SHA256 或 MD5+SHA1 双重哈希。
- 客户端计算 MD5 耗时(大文件),可用多线程分块计算再合并。
- 秒传接口需鉴权,防止恶意用户通过 MD5 遍历他人文件(需验证用户是否有权限)。
- 小文件(< 阈值)可直接上传,不走秒传流程(计算 MD5 开销可能比上传还大)。
3. 负载均衡怎么做的?
项目中的负载均衡(网盘/搜索引擎):
1. 客户端负载均衡:
- 客户端维护服务器列表,通过负载均衡算法选择服务器。
- 算法:轮询、随机、加权轮询、最小连接数。
- 优点:不经过中间层,延迟低。
- 缺点:客户端需维护服务器列表,服务端上下线需通知客户端。
2. 服务端负载均衡(Nginx 反向代理):
- Nginx 作为反向代理,接收客户端请求,转发给后端服务器。
- 配置:
upstream backend {
server 192.168.1.10:8080 weight=3; # 加权轮询
server 192.168.1.11:8080 weight=2;
server 192.168.1.12:8080 backup; # 备份服务器
keepalive 32; # 长连接
}
server {
listen 80;
location / {
proxy_pass http://backend;
proxy_set_header Host $host;
}
}
- Nginx 支持的负载均衡算法:轮询(默认)、加权轮询、ip_hash(会话保持)、least_conn(最少连接)、fair(响应时间,第三方模块)。
3. 四层负载均衡 (LVS):
- 工作在传输层,基于 IP+端口转发,性能极高(百万级 PPS)。
- 模式:NAT、DR(直接路由,常用)、TUN(隧道)。
- 适合大流量入口,后面再接七层负载均衡。
4. 一致性哈希(分布式存储场景):
- 文件存储在多个存储节点,根据文件 MD5 哈希到对应节点。
- 用一致性哈希,节点增减时只迁移相邻节点的数据。
- 虚拟节点解决数据倾斜。
5. 健康检查:
- 负载均衡器定期检查后端服务器健康状态(HTTP 请求/TCP 连接)。
- 故障服务器自动剔除,恢复后重新加入。
- Nginx:
max_fails、fail_timeout配置。
6. 会话保持:
- 用户登录状态需要保持在同一服务器时:
- ip_hash:根据客户端 IP 哈希到同一服务器。
- Cookie/Token:无状态,服务器不存 session,任意服务器都能处理(推荐)。
- Session 共享:Session 存 Redis,所有服务器共享(推荐)。
项目中的具体方案:
- 入口层:LVS(四层)+ Nginx(七层)双层负载均衡。
- 业务层:Nginx 加权轮询分发到多台业务服务器。
- 存储层:一致性哈希分发文件到存储节点。
- 数据库层:读写分离,读请求负载均衡到多个从库。
- 健康检查:Nginx 被动健康检查 + 主动健康检查模块。
💡 低频题(1题)
较少出现或较偏,时间充裕时了解即可。
1. 虚拟文件目录如何设计?
网盘的虚拟文件系统设计:用户看到的目录结构是逻辑上的,物理存储可能是分布式的。
1. 数据库设计(树形目录):
-- 文件/目录表(统一表,用类型区分)
CREATE TABLE nodes (
id BIGINT PRIMARY KEY, -- 节点ID
user_id BIGINT NOT NULL, -- 所属用户
parent_id BIGINT DEFAULT 0, -- 父目录ID,0表示根目录
name VARCHAR(255) NOT NULL, -- 文件名/目录名
type TINYINT NOT NULL, -- 0=目录, 1=文件
file_id BIGINT, -- 文件类型时关联物理文件
size BIGINT DEFAULT 0, -- 文件大小
created_at TIMESTAMP,
updated_at TIMESTAMP,
UNIQUE KEY uk_user_parent_name (user_id, parent_id, name) -- 同一目录下名称唯一
);
-- 物理文件表(去重存储)
CREATE TABLE files (
id BIGINT PRIMARY KEY,
md5 CHAR(32) UNIQUE,
size BIGINT,
storage_path VARCHAR(255),
ref_count INT DEFAULT 0
);
2. 目录树操作:
- 创建目录:插入一条 type=0 的记录,parent_id 指向父目录。
- 列出目录:
SELECT * FROM nodes WHERE user_id=? AND parent_id=?。 - 移动文件/目录:更新 parent_id。
- 重命名:更新 name。
- 删除目录:递归删除子目录和文件(或用软删除标记)。
- 路径解析:从根目录开始,逐级查找 name,找到目标节点。
3. 递归删除/移动的优化:
- 直接递归 SQL 效率低,可用闭包表 (Closure Table) 或路径枚举 (Path Enumeration) 优化。
- 闭包表:
CREATE TABLE tree_paths (
ancestor BIGINT, -- 祖先节点
descendant BIGINT, -- 后代节点
depth INT, -- 深度差
PRIMARY KEY (ancestor, descendant)
);
-- 查询某目录的所有后代:SELECT descendant FROM tree_paths WHERE ancestor=?
-- 删除某目录的所有后代:DELETE FROM tree_paths WHERE descendant IN (子查询)
- 路径枚举:每个节点存完整路径,如
/dir1/dir2/file.txt,方便前缀查询但移动时需更新所有子节点路径。
4. 根目录设计:
- 每个用户有一个虚拟根目录(parent_id=0 或专门的根节点ID)。
- 用户之间的目录完全隔离,通过 user_id 区分。
- 共享文件/目录时,创建共享链接或共享记录,不直接修改目录结构。
5. 性能优化:
- 目录列表分页(LIMIT/OFFSET 或游标分页)。
- 热点目录缓存(Redis 缓存目录列表)。
- 数据库索引:(user_id, parent_id) 联合索引。
- 大目录(文件数 > 1万)考虑分表或专用存储。
6. 特殊功能:
- 最近访问:记录文件访问时间,展示最近文件。
- 搜索:文件名搜索用数据库 LIKE 或 Elasticsearch。
- 回收站:删除时移入回收站(标记 deleted),可恢复。
- 分享:生成分享链接,设置密码/有效期。
八、开放性问题(HR/行为面试)
🔥 高频题(5题)
面试中最常出现,核心知识点,建议重点掌握,能熟练默写。
1. 离职原因?
回答原则:
- 不要抱怨前公司、前领导、前同事。
- 从职业发展角度回答,强调积极因素。
- 避免说"加班太多""工资太低""人际关系不好"等负面原因。
参考回答:
"我在上一家公司工作了 X 年,学到了很多,也参与了几个重要项目。但随着公司业务方向调整,我的技术成长空间变得有限。我希望能在一个更有技术挑战、更符合我长期职业规划的平台发展。贵公司在 [技术方向/业务领域] 方面的投入和成就非常吸引我,所以我希望能加入这个团队。"
常见的合理离职原因:
- 职业发展瓶颈,寻求更大的成长空间。
- 公司业务调整/裁员/倒闭。
- 搬家/城市变化。
- 寻求更有挑战性的技术方向。
- 薪资与市场水平差距较大(可委婉表达)。
避免说的:
- "和领导/同事合不来"(显得人际能力差)。
- "加班太多太累"(显得不能吃苦)。
- "公司太烂"(显得不感恩、不职业)。
- "工资太低"(显得只看钱,可作为次要因素)。
2. 未来的职业规划?
回答原则:
- 短期(1-2年):踏实做好本职工作,快速融入团队,深入业务和技术。
- 中期(3-5年):成为某个领域的技术专家,能独立负责复杂模块/项目。
- 长期(5年以上):根据公司发展,向技术专家或技术管理方向发展,为公司创造更大价值。
- 结合应聘公司的业务和技术栈,说明规划与公司发展契合。
参考回答:
"短期来看,我希望在入职后的 1-2 年内快速熟悉公司的业务和技术栈,扎实做好分配给我的每一项工作,成为团队中可靠的一员。中期 3-5 年,我希望能在 [C++ 后端/高性能服务/分布式系统] 方向深入钻研,成为这个领域的技术专家,能够独立负责复杂系统的设计和优化。长期来看,我希望能随着公司的成长,在技术深度和团队协作上都有提升,无论是走技术专家路线还是技术管理路线,都能为团队和公司创造更大的价值。"
注意:
- 不要说"我想创业""我想转行"等让公司担心稳定性的话。
- 不要说得太具体(如"3年内当上主管"),显得功利。
- 强调和公司共同成长,而不是只考虑个人。
3. 处理过的难度最高或挑战性最大的问题?
用 STAR 法则回答:
Situation(情境):描述问题背景,为什么难。
Task(任务):你需要完成什么目标。
Action(行动):你具体做了什么(重点,体现技术能力和解决问题的思路)。
Result(结果):最终效果,最好有数据支撑。
参考框架(技术岗):
"在 [项目名] 中,我们遇到了 [问题描述,如高并发下服务响应延迟飙升/内存泄漏/数据不一致]。当时的情况是 [具体困难,如 QPS 达到 X,延迟从 Yms 升到 Zms]。
我的任务是定位并解决这个问题,保证系统稳定性。
我首先做了 [第一步,如用 perf/valgrind/gprof 等工具分析性能瓶颈],发现 [根因,如某个热点函数频繁加锁导致竞争/某个大对象未释放导致内存泄漏]。然后我 [解决方案,如优化锁粒度改为无锁数据结构/引入智能指针管理生命周期/增加缓存层]。过程中还遇到了 [次要困难],通过 [方法] 解决。
最终,系统 [量化结果,如 QPS 提升了 X%,延迟降低到 Yms,内存占用减少 Z%],问题得到彻底解决。这个过程让我对 [技术点] 有了更深入的理解。"
关键点:
- 选一个真实的、有技术含量的问题。
- 重点讲你的分析思路和行动,而不是问题本身有多难。
- 结果尽量量化(性能提升百分比、解决了什么具体问题)。
- 体现你的学习能力和解决问题的方法论。
4. 你有什么想问的?
这是面试的最后环节,也是展示你对公司和岗位兴趣的机会。不要说"没有问题"。
推荐问的问题:
关于团队和技术:
- "团队目前的技术栈是什么?主要用 C++ 哪个标准?"
- "这个岗位主要负责哪些模块?日常工作中技术挑战最大的部分是什么?"
- "团队的代码规范和 Code Review 流程是怎样的?"
- "团队目前在技术上最大的痛点或正在攻克的难题是什么?"
关于成长和发展:
5. "公司对新人的培养机制是怎样的?有没有导师制度?"
6. "这个岗位的晋升路径和考核标准是什么?"
7. "团队内部有没有技术分享、学习交流的机制?"
关于业务和公司:
8. "公司/团队目前的业务重点是什么?未来 1-2 年的发展方向?"
9. "这个岗位是因为业务扩张还是人员流动?"
避免问的问题:
- 薪资、福利、加班(这些可以在 HR 面或 offer 阶段问,技术面问显得只看待遇)。
- 百度就能查到的公司基本信息(显得没做功课)。
- 太宏大或与岗位无关的问题。
- "公司会不会裁员""会不会加班到很晚"等负面问题。
建议:准备 2-3 个有深度的问题,根据面试中聊到的内容灵活提问。好的问题能体现你的思考深度和对岗位的热情。
5. 期望薪资?
回答原则:
- 提前了解市场行情(脉脉、看准网、BOSS直聘等),给出合理范围。
- 不要直接说一个固定数字,给一个范围(如 25K-30K),留谈判空间。
- 强调更看重发展平台和成长空间,薪资是次要考虑。
- 如果当前有 offer,可以用现有 offer 作为谈判筹码(但不要虚张声势)。
参考回答:
"根据我对市场行情的了解,结合我的工作经验和技术能力,我期望的薪资范围是 XK-YK。当然,薪资不是我最看重的因素,我更看重的是贵公司的平台、技术氛围和成长空间。如果有机会加入,我相信我的能力和贡献能够匹配相应的薪资。"
注意:
- 不要报太高(超出市场水平太多会被认为不切实际),也不要报太低(显得不自信,也可能被压价)。
- 了解公司的薪资结构(基本工资、年终奖、股票期权、补贴等),不要只看月薪。
- 如果 HR 压价,可以强调自己的核心竞争力和能带来的价值。
- 最终决定综合考虑薪资、发展、团队、加班等因素,不要只看钱。
九、面试真题(笔试/代码题)
🔥 高频题(6题)
面试中最常出现,核心知识点,建议重点掌握,能熟练默写。
1. #include "file.h" 和 #include <file.h> 的区别?
| 区别 | #include "file.h" | #include <file.h> |
|---|---|---|
| 搜索顺序 | 先在当前源文件所在目录搜索,找不到再去系统目录搜索 | 直接去系统目录(标准库路径)搜索 |
| 用途 | 包含自定义的头文件(项目内的头文件) | 包含标准库头文件或系统头文件 |
| 示例 | #include "myclass.h" | #include <iostream>、#include <cstdio> |
详细搜索路径:
"":当前目录 → 编译时-I指定的目录 → 系统标准目录。<>:编译时-I指定的目录 → 系统标准目录。
注意:
- 标准库头文件也可以用
""包含,但不推荐(效率稍低,语义不清晰)。 - 自定义头文件不要用
<>,可能找不到。 - 头文件中应有 include guard(
#ifndef/#define/#endif或#pragma once),防止重复包含。
2. #define 和 typedef 的区别?
| 区别 | #define(宏定义) | typedef(类型别名) |
|---|---|---|
| 本质 | 预处理阶段的文本替换 | 编译阶段的类型定义 |
| 处理时机 | 预处理期(编译前) | 编译期 |
| 类型检查 | 无(纯文本替换,不检查类型) | 有(编译器做类型检查) |
| 作用域 | 不受作用域限制,直到 #undef 或文件结束 | 有作用域(块内/命名空间/类内) |
| 指针类型 | #define PINT int* → PINT a,b; 展开为 int* a,b;(b 是 int,不是指针!) | typedef int* PINT; → PINT a,b; 两个都是指针 |
| 调试 | 无法调试(宏已被替换,无符号) | 可以调试(有类型信息) |
| 递归 | 不能递归定义 | 可以定义递归类型(如链表节点) |
| 适用场景 | 常量、简单函数宏、条件编译 | 类型别名、简化复杂类型声明 |
经典陷阱:
#define PINT int*
PINT a, b; // 展开为 int* a, b; → a 是 int*,b 是 int!(错误)
typedef int* PINT2;
PINT2 a, b; // a 和 b 都是 int*(正确)
C++11 补充:推荐用 using 代替 typedef,语法更清晰,支持模板别名:
using PINT = int*; // 等价于 typedef int* PINT;
template<typename T>
using Vec = std::vector<T>; // 模板别名,typedef 不支持
3. 值传递、指针传递(地址传递)的区别?
| 区别 | 值传递 | 指针传递(地址传递) | 引用传递(C++) |
|---|---|---|---|
| 本质 | 传递参数的副本 | 传递变量的地址(指针) | 传递变量的别名(引用) |
| 修改参数 | 不影响实参(修改的是副本) | 可通过指针修改实参 | 可通过引用修改实参 |
| 内存开销 | 拷贝整个对象,大对象开销大 | 只拷贝指针(4/8字节) | 无拷贝(引用是别名) |
| 安全性 | 安全,不影响外部 | 可能传空指针,需判空 | 一定有效,无需判空 |
| 语法 | void func(int x) | void func(int* x) | void func(int& x) |
| 调用 | func(a) | func(&a) | func(a) |
示例:
// 值传递:不影响实参
void passByValue(int x) {
x = 100; // 修改的是副本
}
// 指针传递:可修改实参
void passByPointer(int* x) {
if (x) *x = 100; // 通过指针修改实参
}
// 引用传递:可修改实参(C++推荐)
void passByReference(int& x) {
x = 100; // 直接修改实参
}
int main() {
int a = 10;
passByValue(a); // a 仍为 10
passByPointer(&a); // a 变为 100
passByReference(a); // a 变为 100
}
选择建议:
- 不需要修改实参且对象小:值传递。
- 不需要修改实参但对象大:
const T&(const 引用传递,避免拷贝且不修改)。 - 需要修改实参:引用传递(C++ 优先)或指针传递(C 风格,可选空时用指针)。
- 指针传递还可表示"可选参数"(传 nullptr 表示不需要)。
4. static 的多种用法及本质共同点?
见 C/C++ 基础第 13 题。
本质共同点:
- 改变存储位置:static 变量存储在全局/静态区,而不是栈上。
- 延长生命周期:static 局部变量的生命周期延长到程序结束。
- 限制作用域/可见性:static 全局变量/函数限制在当前编译单元(内部链接)。
- 属于类而非对象:static 成员属于类,所有对象共享,不依赖具体实例。
核心本质:static 的核心是**"静态"**——位置固定、生命周期长、不随实例/调用变化。
5. 常见内存泄漏/溢出情景?
内存泄漏(Memory Leak):动态分配的内存没有释放,程序无法再访问这块内存。
- malloc/new 后没有 free/delete:
void func() {
int* p = new int[100];
// ... 忘记 delete[] p
} // p 离开作用域,内存泄漏
- 异常导致跳过释放:
void func() {
int* p = new int[100];
doSomething(); // 如果抛异常,后面的 delete 不会执行
delete[] p; // 内存泄漏
}
- 基类析构函数不是虚函数:
Base* p = new Derived();
delete p; // 基类析构非虚,只调用 Base::~Base(),Derived 部分泄漏
- 指针重新赋值未释放旧内存:
int* p = new int[100];
p = new int[200]; // 旧内存泄漏,p 指向新内存
- 容器中存指针,清空容器未 delete:
vector<int*> v;
v.push_back(new int(1));
v.clear(); // 指针被清除,但指向的内存未释放
- 文件描述符/句柄未关闭(资源泄漏,类似内存泄漏)。
内存溢出(Buffer Overflow):写入的数据超过了缓冲区的大小,覆盖了相邻内存。
- 数组越界写入:
char buf[10];
strcpy(buf, "this is a very long string"); // 溢出!
- sprintf 不检查长度:
char buf[10];
sprintf(buf, "%s", long_string); // 溢出
- 循环越界:
int arr[10];
for (int i = 0; i <= 10; i++) arr[i] = i; // i=10 越界
- 整数溢出导致分配不足:
int size = width * height; // 乘法溢出,size 变成负数或很小
char* buf = new char[size]; // 分配不足,后续写入溢出
- 字符串未以 '\0' 结尾,导致 strlen/strcpy 越界。
防范方法:
- 用智能指针(unique_ptr/shared_ptr)管理内存(RAII)。
- 用标准容器(vector/string)代替裸数组。
- 用安全函数(strncpy/snprintf/strlcpy)代替不安全函数。
- 开启编译器警告和 AddressSanitizer。
- 代码审查,关注异常路径和提前 return 的资源释放。
6. C/C++ 编译过程(涉及 .h .c .cpp .o .a 文件)?
见 C/C++ 基础第 5 题。
各文件类型说明:
| 文件 | 说明 |
|---|---|
| .h / .hpp | 头文件,包含声明(函数声明、类定义、宏、模板),通过 #include 包含 |
| .c | C 源文件 |
| .cpp / .cc / .cxx | C++ 源文件 |
| .i / .ii | 预处理后的文件(.i 是 C,.ii 是 C++) |
| .s / .S | 汇编代码文件 |
| .o / .obj | 目标文件(编译后的二进制,未链接) |
| .a / .lib | 静态库(多个 .o 的归档) |
| .so / .dll / .dylib | 动态共享库 |
| 可执行文件 | 链接后的可执行程序(Linux: ELF, Windows: PE, Mac: Mach-O) |
编译流程:
.cpp → [预处理] → .ii → [编译] → .s → [汇编] → .o → [链接] → 可执行文件
.h 被 #include 进 .cpp,在预处理阶段展开
.a/.so 在链接阶段被链接进可执行文件
(全文完)
本文档基于原《500道C++面试高频题实录》整理,对答案进行了精简、错误修正和补充解答。涵盖 C/C++ 基础、STL、Linux、数据结构与算法、数据库、设计模式、项目经验、开放性问题、面试真题等九大模块。
#(注:内容由AI生成)