Limesn
Limesn
发布于 2026-09-11 / 1 阅读
0

C++ 常见八股文

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 的区别?

区别strlensizeof
本质库函数,运行时计算运算符,编译时确定
功能计算字符串长度(到 \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 关键字的作用?

  1. 修饰变量:定义常量,不可修改。const int x = 10;
  2. 修饰指针
    • const int *p:指向常量的指针,不能通过 p 修改所指内容。
    • int *const p:常量指针,指针本身不可改指向。
    • const int *const p:两者都不可改。
  3. 修饰函数参数:防止函数内部修改参数,常用于引用/指针传参。void func(const string &s)
  4. 修饰函数返回值:返回值不可被修改。
  5. 修饰成员函数void func() const; 承诺不修改成员变量(mutable 变量除外),const 对象只能调用 const 成员函数。
  6. 修饰引用const int &r = x; 不可通过引用修改对象,可绑定临时对象。

编译期常量:C++11 起 constexpr 可用于编译期求值,比 const 更强。



5. C++ 中 static 关键字的作用?

使用场景作用
局部变量延长生命周期到程序结束,只初始化一次,存在全局/静态区
全局变量/函数限制作用域为当前编译单元(内部链接),避免符号冲突
类成员变量所有对象共享一份,属于类而非对象,必须在类外定义初始化
类成员函数属于类而非对象,无 this 指针,只能访问静态成员,可通过类名直接调用

注意

  • 静态成员变量在类内声明,类外定义(C++17 可用 inline static 直接初始化)。
  • 静态成员函数不能声明为 constvirtualvolatile
  • 局部静态变量的初始化在 C++11 后是线程安全的(Magic Statics)。


6. C++ 中 class 和 struct 的区别?

区别classstruct
默认访问权限privatepublic
默认继承方式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 为例):

  1. 内存池管理:维护多个 bin(空闲块链表),按大小分类:
    • fast bin:小内存块(≤64B),LIFO 单链表,快速分配。
    • unsorted bin:刚释放的大块,未分类。
    • small bin / large bin:按大小排序的双向链表。
  2. 分配流程:先查 fast bin → unsorted bin → small/large bin → 都不够则通过 brk() 扩展堆或 mmap() 映射新内存。
  3. 块头信息:每个分配块前有 chunk header,记录块大小、是否使用、前一块大小等。
  4. 内存对齐:返回的指针按 16 字节(64 位)对齐。

free 实现

  1. 根据传入指针找到 chunk header。
  2. 标记为空闲,加入对应 bin。
  3. 合并相邻空闲块(除 fast bin 外),减少外部碎片。
  4. 若顶部空闲块足够大,通过 sbrk() 归还给 OS。


9. new / delete 与 malloc / free 的异同?

区别new / deletemalloc / 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;       // 未定义行为!

可以返回引用的情况

  1. 返回传入的引用参数:int& func(int& x) { return x; }
  2. 返回静态变量:int& func() { static int x; return x; }
  3. 返回堆上对象:int& func() { return *new int; }(但需手动释放,不推荐)
  4. 返回成员变量:int& get() { return m_x; }(对象生命周期内有效)


12. extern "C" 的用法?

extern "C" 告诉 C++ 编译器以 C 语言的方式进行链接,即不进行名字修饰 (name mangling)。

主要用途

  1. C++ 调用 C 代码:在 C++ 中包含 C 头文件时。
  2. 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. 拷贝构造函数的调用时机?

拷贝构造函数在以下情况被调用:

  1. 用一个对象初始化另一个对象
    A a1;
    A a2 = a1;   // 拷贝构造(不是赋值!)
    A a3(a1);    // 拷贝构造
    
  2. 函数参数按值传递
    void func(A a);  // 调用时实参拷贝给形参
    func(a1);
    
  3. 函数返回值按值返回(未被 RVO/NRVO 优化时):
    A func() { A a; return a; }  // 返回时拷贝构造临时对象
    
  4. 异常处理中按值抛出/捕获异常对象

注意

  • 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. 构造函数可以设置成虚函数吗?为什么?

不可以,构造函数不能声明为虚函数。

原因

  1. 虚函数依赖 vptr:虚函数调用需要通过对象的 vptr 找到 vtable。但构造函数执行时,对象正在构造中,vptr 尚未正确初始化(构造函数中 vptr 指向当前类的 vtable,而非最终派生类的)。
  2. 类型未确定:构造函数的作用是创建对象,在构造完成前对象的动态类型尚未确定,虚函数的动态绑定没有意义。
  3. 语义矛盾:虚函数的目的是通过基类指针调用派生类实现,但构造函数是从基类到派生类依次执行的,构造基类部分时派生类还不存在。

相关概念

  • 构造函数中调用虚函数,会调用当前类的版本,不是派生类版本(因为派生类还没构造)。
  • 析构函数可以且应该声明为虚函数(多态基类),确保通过基类指针删除派生类对象时正确调用派生类析构函数。


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

红黑树的性质

  1. 节点非红即黑。
  2. 根节点是黑色。
  3. 叶子节点(NIL)是黑色。
  4. 红色节点的子节点必须是黑色(不能有连续红节点)。
  5. 从任一节点到其每个叶子的所有路径包含相同数量的黑节点。

保证树高 ≤ 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)。
  • 通过 _Select1st(取 pair 的 first)和 _Identity(取自身)等萃取器提取键。

multimap/multiset:也是红黑树,但允许重复键(插入时不做唯一性检查)。



24. C++11 的新特性有哪些?

核心语言特性

  1. auto:类型推导,编译器根据初始化表达式推导类型。
  2. decltype:推导表达式的类型。
  3. 范围 for 循环for (auto& x : container)
  4. nullptr:类型安全的空指针常量,替代 NULL。
  5. 右值引用 &&:支持移动语义和完美转发。
  6. 移动构造/移动赋值:转移资源所有权,避免深拷贝。
  7. std::move:将左值转为右值引用。
  8. std::forward:完美转发,保持值类别。
  9. lambda 表达式:匿名函数,支持捕获。
  10. 智能指针unique_ptrshared_ptrweak_ptr
  11. 统一初始化 {}:列表初始化,防止窄化转换。
  12. =default / =delete:显式控制默认/删除特殊成员函数。
  13. override / final:显式标记重写/禁止继承和重写。
  14. constexpr:编译期常量表达式。
  15. 委托构造函数:构造函数调用同类另一个构造函数。
  16. 继承构造函数using Base::Base; 继承基类构造函数。
  17. 可变参数模板template<typename... Args>
  18. 静态断言 static_assert:编译期断言。
  19. 强类型枚举 enum class:有作用域、不会隐式转换。
  20. 线程支持std::threadstd::mutexstd::condition_variable
  21. 原子操作 std::atomic
  22. std::function / std::bind:可调用对象封装。
  23. std::array:固定大小数组容器。
  24. std::unordered_map / unordered_set:哈希表容器。
  25. 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. 面向对象的三大特性?

  1. 封装 (Encapsulation)

    • 将数据和操作数据的方法绑定在类中,隐藏内部实现细节。
    • 通过访问控制符(public/private/protected)限制外部访问。
    • 目的:降低耦合、保护数据安全、便于维护。
  2. 继承 (Inheritance)

    • 派生类(子类)继承基类(父类)的成员和接口。
    • 实现代码复用和层次化设计。
    • C++ 支持单继承、多继承、虚继承。
    • 派生类可扩展新成员,也可重写(override)基类虚函数。
  3. 多态 (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) 实现。

实现机制

  1. 虚函数表 (vtable)

    • 每个有虚函数的类,编译器为其生成一张虚函数表,存储在只读数据段。
    • 表中按虚函数声明顺序存放各虚函数的函数指针。
    • 派生类重写的虚函数,在表中对应位置替换为派生类函数地址。
  2. 虚函数表指针 (vptr)

    • 每个有虚函数的对象包含一个隐藏的 vptr,指向其类的 vtable。
    • vptr 在构造函数中初始化,在析构函数中调整。
    • vptr 占用对象内存(64 位系统 8 字节)。
  3. 动态绑定过程

    Base* p = new Derived();
    p->virtualFunc();
    ① 通过 p 找到对象的 vptr
    ② 通过 vptr 找到 Derived 类的 vtable
    ③ 根据函数在 vtable 中的索引,找到 Derived::virtualFunc 的地址
    ④ 调用该函数
    

必要条件

  • 必须通过指针或引用调用虚函数(对象直接调用会静态绑定)。
  • 函数必须声明为 virtual
  • 派生类必须重写该虚函数(函数签名一致)。

静态多态:通过模板和函数重载在编译期实现,无运行时开销。



30. 基类和派生类的构造函数和析构函数的执行顺序?

构造顺序(从内到外)

  1. 基类构造函数(多继承时按继承声明顺序,虚继承优先)。
  2. 成员变量构造(按声明顺序,不是初始化列表顺序)。
  3. 派生类构造函数体。

析构顺序(从外到内,与构造相反)

  1. 派生类析构函数体。
  2. 成员变量析构(按声明逆序)。
  3. 基类析构函数。
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说明
符号位 S1 bit1 bit0 正,1 负
指数 E8 bit11 bit偏移表示,bias = 127/1023
尾数 M23 bit52 bit隐含整数位 1,不存储

关键特性

  • 尾数隐含前导 1(hidden bit),节省 1 bit 提高精度。
  • 指数用偏移码表示,避免负指数。
  • 特殊值:E 全 0 表示非规格化数/0;E 全 1 表示 ∞ 或 NaN。
  • 浮点数精度有限,比较时应使用容差(如 fabs(a-b) < 1e-6)。


2. 可执行程序是如何生成的?

四个步骤:预处理 → 编译 → 汇编 → 链接

  1. 预处理:展开头文件(#include)、宏替换(#define)、去掉注释、处理条件编译(#ifdef)。生成 .i 文件。
  2. 编译:将预处理后的代码翻译成汇编代码,进行语法/语义检查、优化。生成 .s 文件。
  3. 汇编:将汇编代码翻译成机器码(二进制目标文件)。生成 .o / .obj 文件。
  4. 链接:将多个目标文件和库文件合并,解析符号引用、分配地址,生成可执行文件(.exe / ELF)。

链接又分为静态链接(库代码嵌入可执行文件)和动态链接(运行时加载共享库 .so/.dll)。



3. 可执行程序是如何变成进程的?

  1. 加载:OS 将可执行文件从磁盘读入内存,通过缺页中断按需加载代码段和数据段。
  2. 创建进程控制块 (PCB):OS 为进程分配 PID,创建 task_struct,记录进程状态、寄存器、内存映射、文件描述符等。
  3. 建立虚拟地址空间:创建页表,将进程的虚拟地址映射到物理内存。
  4. 设置运行上下文:初始化程序计数器(PC)指向入口函数(_startmain),设置栈指针。
  5. 调度执行:进程进入就绪队列,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++ 缺陷和陷阱有哪些?

  1. 内存管理:野指针、悬垂指针、内存泄漏、重复释放、越界访问。
  2. 未定义行为 (UB): signed 整数溢出、移位越界、取消引用空指针、访问已释放内存等,结果不可预测。
  3. 全局/静态变量初始化顺序:不同编译单元间的全局变量初始化顺序未定义(static initialization order fiasco)。
  4. 隐式类型转换:整型提升、有符号/无符号比较、精度丢失(如 floatint 截断)。
  5. 运算符优先级:如 << 低于 +== 高于 &,易写错。
  6. 数组越界:C/C++ 不做边界检查,缓冲区溢出可导致安全漏洞。
  7. 头文件重复包含:需用 #ifndef#pragma once 保护。
  8. 临时对象生命周期:返回局部变量的引用/指针、绑定临时对象的引用延长生命周期的误用。


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) 时:

  1. 通过 p - sizeof(header) 找到块头。
  2. 从块头读取块大小,知道要释放多少内存。
  3. 标记为空闲,插入空闲链表,合并相邻块。

注意

  • 必须释放 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,避免多重定义错误。

注意事项

  1. inline 只是建议:编译器可以忽略,函数体过大、有循环、递归、虚函数、取地址等情况通常不会内联。
  2. 定义必须可见:inline 函数的定义必须放在头文件中,每个编译单元都能看到。
  3. 不能和 virtual 共存:虚函数需要运行时绑定,一般不会内联(通过对象直接调用时可能内联)。
  4. 调试困难:内联后没有独立函数符号,断点可能不生效。
  5. 代码膨胀:过度内联会增大可执行文件体积,导致指令缓存命中率下降。

C++17 补充inline 也可用于变量,解决头文件中全局变量的多重定义问题。



13. 虚函数表里存放的内容是什么时候写进去的?

虚函数表在编译期确定内容,在程序加载时写入内存(只读数据段)。

具体过程

  1. 编译期:编译器分析每个类的虚函数,为每个有虚函数的类生成一张虚函数表,表中填入各虚函数的地址。
    • 基类的 vtable 填基类虚函数地址。
    • 派生类的 vtable:未重写的填基类地址,重写的填派生类地址,新增的虚函数追加在表后。
  2. 链接期:符号解析,确定虚函数的最终地址。
  3. 程序加载时: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,自动释放

其他方案

  1. 资源获取即初始化 (RAII):用类管理资源,构造时获取,析构时释放。
  2. 所有权转移:用 unique_ptr + move,明确唯一所有者。
  3. 引用计数自定义实现:自己管理引用计数(不推荐,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 bit64 bit
long 类型4 字节 (LP32/ILP32)8 字节 (LP64,Linux/Mac)

对系统的影响

  1. 内存寻址能力:64 位可支持远超 4GB 的内存,适合大内存需求(数据库、科学计算)。
  2. 性能:64 位寄存器可一次处理更多数据,部分运算更快;但指针变大导致内存占用增加、缓存压力增大。
  3. 兼容性:64 位系统通常可运行 32 位程序(需安装 32 位库),反之不行。
  4. 数据模型:注意 long 在 Windows 64 位仍是 4 字节 (LLP64),在 Linux/Mac 是 8 字节 (LP64)。
  5. 文件大小:64 位原生支持大文件(>2GB),32 位需用 off64_t 等扩展。


17. 虚拟内存和物理内存的区别?

区别物理内存虚拟内存
本质实际的硬件 RAMOS 抽象出的地址空间概念
容量有限,由内存条决定可远大于物理内存(借助磁盘)
地址物理地址,直接对应 RAM 单元虚拟地址,每个进程独立
进程隔离所有进程共享物理内存每个进程有独立的虚拟地址空间,互相隔离
管理方式由 OS 内存管理器分配物理页通过页表映射虚拟页到物理页/磁盘
连续性物理上可能不连续进程看到的是连续的地址空间

虚拟内存的核心机制

  1. 分页:将虚拟地址空间和物理内存都划分为固定大小的页(通常 4KB)。
  2. 页表:维护虚拟页到物理页的映射。
  3. 缺页中断:访问的页不在物理内存时,OS 将其从磁盘加载到内存。
  4. 页面置换:物理内存不足时,将不常用的页换出到磁盘(LRU 等算法)。
  5. TLB:快表,缓存页表项,加速地址翻译。

优势:进程隔离、安全保护、内存超卖(overcommit)、简化编程模型(每个进程认为自己独占内存)。



18. 面向过程和面向对象的区别?

区别面向过程 (POP)面向对象 (OOP)
核心思想以函数/过程为中心,关注"怎么做"以对象为中心,关注"谁来做"
基本单位函数类/对象
特性无封装、继承、多态封装、继承、多态
数据与行为数据和操作分离数据和行为封装在对象中
适用场景算法密集、流程明确的小型程序大型复杂系统、需求易变的项目
扩展性差,修改需求可能改动大量函数好,通过继承和多态扩展
代表语言CC++、Java、Python

面向对象三大特性

  1. 封装:隐藏内部实现,对外提供接口,降低耦合。
  2. 继承:子类复用父类代码和接口,实现代码复用和层次结构。
  3. 多态:同一接口不同实现,运行时根据对象类型调用对应方法(动态多态)。

C++ 的特点:C++ 是多范式语言,同时支持面向过程、面向对象、泛型编程、函数式编程等,不强制使用 OOP。



19. 空类里有什么函数?

一个空类 class Empty {}; 中,编译器会隐式生成以下成员函数(在需要时才生成):

  1. 默认构造函数Empty()
  2. 拷贝构造函数Empty(const Empty&)
  3. 移动构造函数(C++11):Empty(Empty&&)
  4. 拷贝赋值运算符Empty& operator=(const Empty&)
  5. 移动赋值运算符(C++11):Empty& operator=(Empty&&)
  6. 析构函数~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不可直接访问

应用场景

  1. public 继承(最常用)

    • 表示 "is-a" 关系,派生类是基类的一种。
    • 基类接口对外可见,符合里氏替换原则。
    • 例:class Dog : public Animal
  2. protected 继承

    • 基类的 public 成员在派生类中变为 protected,对外不可见,但对派生类的子类可见。
    • 用于希望继承基类实现,但不暴露基类接口,且允许进一步派生的场景。
    • 较少使用。
  3. 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. 什么地方需要用到拷贝构造函数?

拷贝构造函数在以下场景被自动调用:

  1. 用一个对象初始化另一个新对象

    A a1;
    A a2 = a1;   // 拷贝构造(注意:不是赋值!)
    A a3(a1);    // 拷贝构造
    A a4 = A(a1); // 拷贝构造(可能被优化)
    
  2. 函数参数按值传递

    void func(A a);  // 形参 a 是实参的拷贝
    func(a1);        // 调用拷贝构造
    
  3. 函数返回值按值返回(未被 RVO/NRVO 优化时):

    A func() {
        A a;
        return a;  // 用 a 拷贝构造返回值临时对象
    }
    
  4. 异常处理:按值抛出异常对象、按值捕获异常对象时。

  5. 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,取决于布局)

内存对齐规则

  1. 每个成员的偏移量必须是其自身大小的整数倍。
  2. 结构体总大小必须是最大成员大小的整数倍。
  3. 可通过 #pragma pack(n)alignas 修改对齐方式。

影响 sizeof 的因素

  • 非静态成员变量(按声明顺序排列,受对齐影响)。
  • 虚函数表指针 vptr(有虚函数时)。
  • 虚基类表指针 vbtbl(虚继承时)。
  • 对齐填充字节。
  • 静态成员、成员函数不占对象大小。


💡 低频题(9题)

较少出现或较偏,时间充裕时了解即可。

1. 什么时候分配内存会产生内存碎片?

内存碎片分为内部碎片外部碎片,主要产生场景:

  1. 频繁动态分配/释放小块内存:释放的内存块大小各异、位置分散,无法有效合并成连续大块,形成外部碎片。
  2. 内存分配算法的对齐需求:为满足内存对齐,分配比实际需要更大的块,导致内部碎片。
  3. 多进程/多线程并发分配:不同执行流交错申请释放,使空闲块分散。
  4. 长期运行程序:长时间运行后,小块分配释放累积,碎片问题加剧。

缓解方法:内存池、对象池、减少小块频繁分配、使用 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优化代码大小,适合嵌入式/受限环境
-OfastO3 + 不严格遵守 IEEE 浮点标准,可能改变浮点结果

基本优化思想

  1. 常量传播与折叠:编译期计算常量表达式,直接替换结果。
  2. 死代码消除:删除不可达或无副作用的代码。
  3. 公共子表达式消除 (CSE):重复计算的表达式只算一次。
  4. 循环优化:循环展开、循环不变量外提、循环合并、循环分块。
  5. 函数内联:将小函数体展开到调用点,消除调用开销。
  6. 向量化 (SIMD):将标量循环转换为 SIMD 指令,并行处理多个数据。
  7. 指令调度:重排指令减少流水线停顿和数据依赖。
  8. 寄存器分配:尽量将常用变量放在寄存器,减少内存访问。
  9. 缓存优化:改进数据访问模式和布局,提高缓存命中率。
  10. 尾调用优化:将尾递归转为循环,避免栈溢出。

注意:优化可能改变程序行为(如浮点精度、未定义行为的表现),调试时用 -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);

关键点

  1. placement new 语法new (address) Type(args),在 address 指向的已分配内存上构造对象。
  2. 必须显式调用析构函数obj->~MyClass();,因为 delete 会同时释放内存,而内存不是 new 分配的。
  3. 内存对齐:确保传入的地址满足类型的对齐要求,否则是未定义行为。
  4. 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

关键特性

  1. 动态扩容:当 size == capacity 时,重新分配更大的内存(通常 1.5 倍或 2 倍),将旧元素拷贝/移动到新内存,释放旧内存。
  2. 随机访问 O(1):连续内存,支持 []at()
  3. 尾端插入 O(1) 均摊push_back 不扩容时 O(1),扩容时 O(n),均摊 O(1)。
  4. 中间插入/删除 O(n):需要移动元素。
  5. 迭代器失效:扩容会导致所有迭代器失效;插入/删除点之后的迭代器失效。

vs 数组:vector 自动管理内存,可动态增长;数组大小固定。



2. vector、list 在添加删除的效率方面有什么不同?

操作vectorlist (双向链表)
尾部插入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 的区别?

区别mapunordered_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 的区别?

区别vectordeque
底层结构单块连续动态数组多块连续缓冲区(分段连续),由中控数组管理
内存布局完全连续分段连续,内部不保证完全连续
随机访问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_mapunordered_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 的区别?

区别clearerase
功能删除容器中所有元素删除单个元素或范围内的元素
参数无参数迭代器(单个)或迭代器范围
返回值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) 的(需要逐个交换元素)。

常见应用

  1. 释放 vector 内存
    std::vector<int>().swap(vec);  // 与空 vector 交换,释放内存
    std::vector<int>(vec).swap(vec);  // 收缩到 fit
    
  2. 实现拷贝赋值运算符(Copy-and-Swap 惯用法)
    T& operator=(T other) {  // 按值传参,自动拷贝
        swap(*this, other);
        return *this;
    }  // other 析构释放旧资源
    
  3. pimpl 惯用法中交换实现指针
  4. 排序算法中交换元素

自定义类型:应为自定义类型提供 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::erasestd::erase_if 非成员函数,更简洁:

std::erase(vec, value);        // 删除所有等于 value 的元素
std::erase_if(vec, pred);      // 删除满足谓词的元素


💡 低频题(1题)

较少出现或较偏,时间充裕时了解即可。

1. 自己实现 unordered_map 会考虑什么问题?

  1. 哈希函数设计

    • 选择好的哈希函数,减少碰撞(如 MurmurHash、CityHash)。
    • 对自定义类型提供哈希特化。
    • 处理哈希洪水攻击(可能需要随机化哈希种子)。
  2. 冲突处理

    • 链地址法(separate chaining):每个桶挂链表,最常用。
    • 开放寻址法(open addressing):线性探测、二次探测、双重哈希。
    • 链表过长时转红黑树(C++11 unordered_map 的优化)。
  3. 扩容与 rehash

    • 负载因子阈值(通常 0.75 或 1.0)。
    • 扩容时桶数通常翻倍(或取质数)。
    • rehash 过程:重新计算所有元素的桶索引,迁移到新表。
    • 渐进式 rehash(Redis 方式):避免一次性迁移阻塞。
  4. 桶数组管理

    • 初始桶数(通常 8 或 16)。
    • 桶数取质数可减少碰撞(但现代实现常用 2 的幂,用位运算代替取模)。
  5. 迭代器实现

    • 需跟踪当前桶和桶内链表位置。
    • rehash 后迭代器失效。
  6. 内存管理

    • 节点分配(可使用内存池)。
    • 析构时正确释放所有节点和桶数组。
  7. 线程安全

    • STL 容器非线程安全,并发访问需外部加锁。
    • 读可并发,写需独占。
  8. 接口设计

    • inserterasefindoperator[]atclear
    • bucket_countload_factorrehashreserve


三、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 工具,内存使用分析)
  • heaptrackIntel InspectorPurify

避免内存泄漏的方法

  1. 使用智能指针std::unique_ptrstd::shared_ptr,RAII 自动释放。
  2. 使用标准容器std::vectorstd::string 自动管理内存。
  3. RAII 惯用法:资源在构造时获取,析构时释放。
  4. 配对检查:malloc/free、new/delete、new[]/delete[] 配对使用。
  5. 代码审查:关注异常路径、提前 return、goto 等是否遗漏释放。
  6. 静态分析:cppcheck、clang-tidy 等工具提前发现。
  7. 避免裸指针:尽量用智能指针或引用代替裸指针。
  8. 异常安全:用 RAII 确保异常抛出时资源也能释放。


2. 多进程和多线程的区别?

区别多进程多线程
定义多个独立的进程一个进程内的多个执行流
地址空间各自独立,互不共享共享进程的地址空间
资源各自拥有独立资源(文件描述符、内存等)共享进程资源(堆、全局变量、文件描述符)
通信方式IPC:管道、消息队列、共享内存、信号量、Socket直接读写共享变量,需同步机制
创建/切换开销大(需分配独立地址空间、复制资源)小(共享地址空间,只需保存寄存器和栈)
稳定性高(一个进程崩溃不影响其他进程)低(一个线程崩溃可能导致整个进程崩溃)
数据共享需 IPC,复杂但安全直接共享,简单但需同步
CPU 利用可利用多核可利用多核
编程复杂度较低(进程隔离)较高(需处理竞态、死锁)
适用场景需要隔离、稳定性要求高、任务独立需要频繁共享数据、任务紧密协作

进程和线程的关系

  • 进程是资源分配的最小单位,线程是 CPU 调度的最小单位。
  • 一个进程至少有一个线程(主线程)。
  • 线程有自己的栈、寄存器、程序计数器,但共享进程的堆、代码段、数据段。

选择建议

  • 任务独立、需要隔离 → 多进程。
  • 任务紧密、频繁共享数据 → 多线程。
  • 现代 C++ 推荐用 std::thread,配合 std::mutexstd::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 条件)

  1. 互斥 (Mutual Exclusion):资源不能被多个线程同时持有,只能独占。
  2. 占有并等待 (Hold and Wait):线程持有至少一个资源,同时等待其他线程持有的资源。
  3. 不可剥夺 (No Preemption):资源不能被强行夺走,只能由持有者主动释放。
  4. 循环等待 (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 持有)→ 死锁
}

预防死锁的方法(破坏四个条件之一):

  1. 破坏互斥:尽量用可共享资源(如只读数据),但很多资源必须互斥。
  2. 破坏占有并等待:一次性申请所有需要的资源,申请不到就不持有任何资源。
  3. 破坏不可剥夺:持锁线程申请新资源失败时,释放已持有的资源(如 try_lock)。
  4. 破坏循环等待:对资源编号,所有线程按固定顺序申请资源(如总是先锁 m1 再锁 m2)。

避免死锁

  • 银行家算法:分配资源前判断是否安全。
  • std::lock(m1, m2) 同时加多个锁(内部按固定顺序,避免死锁)。
  • std::scoped_lock(C++17)同时管理多个互斥锁。

检测与恢复

  • 运行时检测死锁(如 Linux 的 lockdep、Java 的 JMX)。
  • 检测到后终止一个线程或剥夺资源。


7. 线程池有什么好处?

线程池:预先创建一组线程,任务到来时从池中取一个空闲线程执行,任务完成后线程不销毁,回到池中等待下一个任务。

好处

  1. 降低资源消耗

    • 避免频繁创建和销毁线程的开销(创建线程需分配栈、寄存器、内核对象,销毁需回收)。
    • 重复利用已有线程,减少系统调用和内存分配。
  2. 提高响应速度

    • 任务到来时无需等待线程创建,直接用已有线程执行。
    • 减少任务等待时间,提高吞吐量。
  3. 控制并发数

    • 限制同时运行的线程数量,避免线程过多导致:
      • 频繁上下文切换(CPU 开销大)。
      • 内存耗尽(每个线程有独立栈,默认 8MB)。
      • 资源竞争加剧(锁竞争、缓存失效)。
    • 根据 CPU 核心数和任务类型设置合理的线程数。
  4. 统一管理

    • 集中管理线程的生命周期、任务队列、监控统计。
    • 支持优雅关闭(等待任务完成再退出)。
    • 支持任务排队、优先级调度、拒绝策略。
  5. 提高稳定性

    • 线程池中的线程异常退出后可自动补充新线程。
    • 避免无限制创建线程导致系统崩溃(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):队列满且线程数达上限时的处理策略。

工作流程

  1. 任务提交,若当前线程数 < 核心线程数,创建新核心线程执行。
  2. 若核心线程都在忙,任务加入任务队列。
  3. 若队列满,且当前线程数 < 最大线程数,创建非核心线程执行。
  4. 若线程数已达最大,执行拒绝策略(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):程序行为依赖于线程执行的时序,结果不确定。
  • 状态不一致:多步操作中间状态被其他线程观察到。

实现线程安全的方法

  1. 互斥锁 (Mutex)

    • 保护临界区,同一时刻只有一个线程执行。
    • std::mutexstd::lock_guardstd::unique_lock
  2. 原子操作 (Atomic)

    • 不可分割的操作,硬件级保证。
    • std::atomic<int>fetch_addcompare_exchange
    • 适用于简单计数器、标志位。
  3. 不可变对象 (Immutable)

    • 对象创建后不可修改,多线程只读访问天然安全。
    • const 对象、字符串。
  4. 线程局部存储 (TLS)

    • thread_local 变量,每个线程有独立副本,不共享。
    • 避免同步开销。
  5. 消息传递

    • 线程间通过消息队列通信,不共享可变状态。
    • 如 Actor 模型、Go channel。
  6. 无锁数据结构 (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 模型、解耦

最佳实践

  1. 优先用 RAII 锁
std::mutex mtx;
void func() {
    std::lock_guard<std::mutex> lock(mtx);  // 构造加锁,析构解锁
    // 临界区
}  // 自动解锁,即使异常也安全
  1. 缩小临界区:只保护必须保护的代码,减少锁持有时间。

  2. 避免死锁

    • 按固定顺序加锁。
    • std::lock(m1, m2) 同时加多个锁。
    • C++17 用 std::scoped_lock
  3. 优先用原子操作:简单的计数/标志用 std::atomic,比锁高效。

  4. 减少共享:能用线程局部存储或消息传递就不用共享可变数据。

注意

  • 双重检查锁定 (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++;
}

工作中保证线程安全的方法

  1. 尽量避免共享

    • 用局部变量代替全局/静态变量。
    • thread_local 存储线程私有数据。
    • 用不可变对象(创建后只读)。
  2. 正确使用同步原语

    • 互斥锁保护临界区,用 RAII(lock_guard)。
    • 原子操作处理简单计数。
    • 条件变量处理事件等待。
  3. 代码审查

    • 检查是否有未保护的共享数据访问。
    • 检查锁的获取顺序是否一致(防死锁)。
    • 检查是否有竞态条件。
  4. 工具检测

    • ThreadSanitizer (TSan)-fsanitize=thread,检测数据竞争和死锁。
    • Helgrind(Valgrind 工具):线程错误检测。
    • 静态分析:clang-tidy、Coverity。
  5. 测试

    • 压力测试:多线程高并发运行。
    • 长时间运行测试(偶发问题)。
    • 用不同调度顺序、不同 CPU 数测试。
  6. 设计层面

    • 明确哪些数据是共享的,哪些是线程私有的。
    • 用消息传递代替共享内存(如 Actor 模型)。
    • 单一职责,减少函数间的隐式共享。


12. 从输入 URL 到显示页面的全过程?

  1. DNS 解析

    • 浏览器检查缓存 → 系统缓存 (hosts) → 本地 DNS 服务器 → 根 DNS → 顶级域 DNS → 权威 DNS。
    • 将域名解析为 IP 地址。
  2. TCP 连接建立

    • 浏览器与服务器 IP 的 80/443 端口进行 TCP 三次握手。
    • HTTPS 还需 TLS 握手(协商加密套件、交换密钥、验证证书)。
  3. 发送 HTTP 请求

    • 浏览器构造 HTTP 请求报文(请求行、请求头、请求体)。
    • 通过 TCP 连接发送给服务器。
  4. 服务器处理

    • 反向代理(Nginx)接收请求,转发给应用服务器。
    • 应用服务器处理业务逻辑,可能查询数据库、缓存。
    • 生成 HTTP 响应报文(状态行、响应头、响应体)。
  5. 返回响应

    • 服务器将响应通过 TCP 连接发回浏览器。
    • 浏览器接收响应,根据状态码处理(200 成功、301 重定向、404 未找到等)。
  6. 浏览器渲染

    • 解析 HTML 构建 DOM 树。
    • 解析 CSS 构建 CSSOM 树。
    • 合并为渲染树 (Render Tree)。
    • 布局 (Layout/Reflow):计算元素位置和大小。
    • 绘制 (Paint):将像素绘制到屏幕。
    • 合成 (Composite):多层合并显示。
  7. 断开连接

    • TCP 四次挥手断开连接(或保持长连接 Keep-Alive)。


13. select、poll、epoll 的区别?

区别selectpollepoll
数据结构fd_set(位图,固定大小)pollfd 数组(动态)红黑树 + 就绪链表
最大连接数1024(FD_SETSIZE,需重新编译内核修改)无限制(数组大小可调)无限制(受内存限制)
效率O(n),每次调用都需遍历所有 fdO(n),同 selectO(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 状态发生变化时(如新数据到达)通知一次,之后即使缓冲区还有数据也不再通知,直到下一次状态变化。

实现要点

  1. 设置非阻塞:ET 模式必须配合非阻塞 I/O,否则 read/write 可能阻塞在单个 fd 上,导致整个事件循环卡死。
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
  1. 注册 ET 事件
struct epoll_event ev;
ev.events = EPOLLIN | EPOLLET;  // 加上 EPOLLET 标志
ev.data.fd = fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev);
  1. 循环读取直到 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;
    }
}
  1. 写事件同理: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 默认都是 LTepoll 需显式设置 EPOLLET

应用场景

LT 适用场景

  • 连接数较少,对性能要求不高。
  • 编程简单优先,不想处理复杂的非阻塞逻辑。
  • 数据量小,一次 read 就能读完。
  • 初学者或快速开发。
  • 写事件:LT 模式下 EPOLLOUT 只要可写就一直触发,需注意写完后及时移除 EPOLLOUT,否则 CPU 空转。

ET 适用场景

  • 高并发服务器,连接数大(上万甚至十万)。
  • 性能要求高,希望减少系统调用和重复通知。
  • 数据量大,需要高效处理。
  • Nginx、Redis、高性能网络库(muduo、libevent 可选 ET)。

ET 编程注意

  1. 所有 fd 设为非阻塞。
  2. read/write 循环到 EAGAIN。
  3. 处理 EINTR(被信号中断)。
  4. 注意写事件:ET 下 EPOLLOUT 只在从不可写变可写时触发一次,如果发送缓冲区一直有空间,不会重复触发。需要主动注册 EPOLLOUT,写完后移除。
  5. accept 也要循环:ET 模式下新连接到达只通知一次,需循环 accept 直到 EAGAIN(多个连接同时到达时)。

选择建议

  • 大多数场景用 LT 即可,简单可靠。
  • 追求极致性能、连接数巨大时用 ET。
  • 很多高性能框架(如 Netty)默认用 LT,因为 ET 编程容易出错且收益不一定大。


16. TCP 的"粘包"问题?

粘包:TCP 是面向字节流的协议,没有消息边界。发送方发送的多个数据包可能被接收方当作一个包接收,或者一个包被拆成多个包接收。

产生原因

  1. 发送方 Nagle 算法:将多个小数据包合并成一个发送。
  2. 接收方缓存:接收方读取不及时,多个包在缓冲区中合并。
  3. TCP 分段:MSS (最大分段大小) 限制,大包被拆分成多个段。
  4. 应用层写入:多次 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 的区别?

区别TCPUDP
连接性面向连接,需三次握手建立连接无连接,直接发送数据
可靠性可靠,确认重传、序号、校验和不可靠,可能丢包、乱序、重复
数据边界字节流,无消息边界(粘包)面向数据报,有消息边界
传输效率较低(握手、确认、重传、拥塞控制开销)高(无额外开销)
实时性较低(重传可能导致延迟)高(发完即走,不等待确认)
流量控制有(滑动窗口)
拥塞控制有(慢启动、拥塞避免、快重传、快恢复)
连接数点对点,一对一一对一、一对多、多对多(支持广播/组播)
首部开销20-60 字节8 字节
适用场景文件传输、Web、邮件、远程登录(需可靠传输)视频/音频直播、DNS、游戏、物联网(追求实时性,可容忍少量丢包)

TCP 保证可靠性的机制

  1. 序列号和确认号:每个字节都有序号,接收方确认收到的字节。
  2. 超时重传:发送方在超时时间内未收到确认则重传。
  3. 校验和:检测传输过程中的数据损坏。
  4. 流量控制:滑动窗口,防止发送方发得太快导致接收方处理不过来。
  5. 拥塞控制:根据网络状况调整发送速率。

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)            |
     | ────────────────────────────────────→ |
     |                                        |
     |         连接建立,开始传输数据           |

详细过程

  1. 第一次握手 (SYN)

    • 客户端向服务器发送 SYN 报文,序列号 seq = x(随机生成)。
    • 客户端状态变为 SYN_SENT
    • 同步位 SYN = 1,表示请求建立连接。
  2. 第二次握手 (SYN+ACK)

    • 服务器收到 SYN 后,回复 SYN+ACK 报文。
    • 确认号 ack = x + 1(表示期望收到的下一个字节序号)。
    • 服务器自己的序列号 seq = y(随机生成)。
    • 服务器状态变为 SYN_RCVD
    • 服务器为该连接分配资源(TCB)。
  3. 第三次握手 (ACK)

    • 客户端收到 SYN+ACK 后,回复 ACK 报文。
    • 确认号 ack = y + 1。
    • 序列号 seq = x + 1。
    • 客户端状态变为 ESTABLISHED
    • 服务器收到 ACK 后,状态也变为 ESTABLISHED

为什么是三次而不是两次?

  1. 确认双方的收发能力:三次握手能确认客户端的发送/接收能力和服务器的发送/接收能力都正常。两次握手只能确认客户端到服务器的单向能力。
  2. 防止已失效的连接请求到达服务器:如果客户端的一个旧 SYN 延迟到达服务器,服务器回复 SYN+ACK 后,如果是两次握手就会建立连接,但客户端并不想建立,浪费服务器资源。三次握手中客户端可以忽略这个旧 SYN 的回复,不发送第三次 ACK,连接就不会建立。
  3. 同步初始序列号:双方需要交换并确认各自的初始序列号 (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                 |

详细过程

  1. 第一次挥手 (FIN)

    • 客户端发送 FIN 报文,序列号 seq = u。
    • 表示客户端没有数据要发送了,请求关闭连接。
    • 客户端状态变为 FIN_WAIT_1
  2. 第二次挥手 (ACK)

    • 服务器收到 FIN 后,回复 ACK,确认号 ack = u + 1。
    • 服务器状态变为 CLOSE_WAIT
    • 客户端收到 ACK 后,状态变为 FIN_WAIT_2
    • 此时客户端到服务器的方向关闭了(半关闭),但服务器可能还有数据要发送。
  3. 第三次挥手 (FIN)

    • 服务器数据发送完毕后,发送 FIN 报文,序列号 seq = w。
    • 服务器状态变为 LAST_ACK
  4. 第四次挥手 (ACK)

    • 客户端收到 FIN 后,回复 ACK,确认号 ack = w + 1。
    • 客户端状态变为 TIME_WAIT
    • 服务器收到 ACK 后,状态变为 CLOSED
    • 客户端等待 2MSL(最长报文寿命的两倍)后,状态变为 CLOSED

为什么是四次而不是三次?

  • TCP 是全双工的,两个方向需要分别关闭。
  • 服务器收到客户端的 FIN 后,可能还有数据要发送,不能立即回复 FIN,只能先回复 ACK。
  • 等服务器数据发完后,再单独发送 FIN。
  • 因此 ACK 和 FIN 分开发送,共四次。
  • 如果服务器没有数据要发送,ACK 和 FIN 可以合并,变成三次挥手。

为什么 TIME_WAIT 需要 2MSL?

  1. 确保最后一个 ACK 能到达服务器:如果客户端的最后一个 ACK 丢失,服务器会重传 FIN,客户端在 TIME_WAIT 期间可以重发 ACK。2MSL 足够让 ACK 和可能的 FIN 重传都完成。
  2. 让本次连接的所有报文在网络中消失:防止旧连接的延迟报文被新连接误认为是新数据。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 的方法

  1. 调整 tcp_tw_reuse:允许将 TIME_WAIT 的端口用于新的出站连接(需配合时间戳)。
  2. 让客户端主动关闭:服务器保持连接,由客户端发起关闭,TIME_WAIT 在客户端。
  3. 使用长连接 (Keep-Alive):减少连接建立和关闭的频率。
  4. 调整 tcp_fin_timeout:减少 FIN_WAIT_2 的时间(不直接影响 TIME_WAIT)。
  5. 增加本地端口范围ip_local_port_range 调大,可用端口更多。
  6. 使用 SO_REUSEADDR:允许绑定处于 TIME_WAIT 的地址(对服务器监听端口有效)。

注意tcp_tw_recycle 已在 Linux 4.12 中移除,因为在 NAT 环境下会导致问题,不要使用。



21. HTTP 和 HTTPS 的区别?

区别HTTPHTTPS
全称HyperText Transfer ProtocolHyperText Transfer Protocol Secure
端口80443
安全性明文传输,不安全加密传输,安全
加密TLS/SSL 加密
证书不需要需要 CA 颁发的 SSL 证书
性能快(无加密开销)稍慢(握手和加密解密开销,但 HTTP/2 通常只在 HTTPS 下支持)
SEO权重较低Google 优先收录,权重更高
协议层次应用层直接基于 TCPHTTP + TLS/SSL + TCP
数据完整性不保证,可被篡改保证,有消息认证码 (MAC)
身份验证验证服务器身份(证书)
适用场景内部系统、无敏感数据涉及登录、支付、个人信息等敏感数据

HTTPS 工作原理

  1. TCP 三次握手
  2. TLS 握手
    • 客户端发送 ClientHello(支持的加密套件、随机数)。
    • 服务器回复 ServerHello(选定加密套件、随机数)+ 证书(含公钥)。
    • 客户端验证证书合法性(CA 签名、有效期、域名匹配)。
    • 客户端生成预主密钥 (Pre-Master Secret),用服务器公钥加密后发送。
    • 双方根据预主密钥和随机数生成会话密钥 (Session Key)。
    • 发送 Finished 消息,握手完成。
  3. 加密通信:之后所有 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 的危害

  1. 端口耗尽:TIME_WAIT 状态的连接占用四元组(源IP、源端口、目的IP、目的端口),客户端出站连接的本地端口有限(默认约 3 万个),大量 TIME_WAIT 会导致无法建立新连接(Cannot assign requested address)。
  2. 内存占用:每个 TIME_WAIT 连接占用内核内存(tcp_timewait 结构),数量极多时消耗内存。
  3. 性能下降:内核需要管理大量 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 等。
  • Valgrindvalgrind --tool=memcheck ./program,检测内存泄漏和非法访问(慢但准确)。
  • UndefinedBehaviorSanitizer (UBSan)-fsanitize=undefined,检测未定义行为。

5. 线程问题检测

  • ThreadSanitizer (TSan)-fsanitize=thread,检测数据竞争和死锁。
  • 偶发崩溃很多是竞态条件,TSan 很有效。

6. 复现

  • 压力测试:循环运行、并发测试、边界条件测试。
  • 故障注入:模拟网络异常、磁盘满、内存不足等。
  • gdb --args + 条件断点在可疑位置停住。

7. 静态分析

  • clang --analyzecppcheckCoverity 等工具提前发现潜在问题。

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/promiseC++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 信号量

  • semgetsemopsemctl,功能强大但接口复杂。

注意

  • 进程间同步必须用进程间共享的同步对象(放在共享内存中或命名对象)。
  • 普通 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):可以在执行过程中被中断(如信号、中断),然后再次被调用(重入),而不会产生错误结果的函数。

可重入的条件

  1. 不使用全局或静态的可变数据。
  2. 不返回指向全局/静态可变数据的指针。
  3. 只调用可重入函数。
  4. 不使用不可重入的标准库函数(如 mallocprintfstrtok)。
  5. 不修改自身代码(自修改代码)。

可重入 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 函数列表)。
  • mallocfreeprintf 等大多数标准库函数不可重入


13. write 阻塞的原因有哪些?

write() 系统调用在以下情况可能阻塞:

  1. 套接字发送缓冲区满

    • TCP 套接字的发送缓冲区 (send buffer) 被数据填满,对端接收慢或网络拥塞导致数据发不出去。
    • 这是最常见的原因。
  2. 对端接收窗口为 0 (Zero Window)

    • 对端接收缓冲区满,TCP 通告窗口大小为 0,发送方不能再发送数据。
    • 需等待对端处理数据后窗口恢复。
  3. 流量控制/拥塞控制

    • 网络拥塞,TCP 拥塞窗口减小,发送速率受限。
  4. 文件写入慢

    • 写入磁盘文件时,磁盘 I/O 慢(如机械磁盘、磁盘满、I/O 等待)。
    • 写入管道 (pipe) 时,管道缓冲区满且对端未读取。
  5. Nagle 算法

    • TCP 默认启用 Nagle 算法,小数据包会等待确认或攒够一定量才发送,可能导致延迟。
    • 可用 TCP_NODELAY 选项禁用。
  6. 套接字为阻塞模式

    • 默认套接字是阻塞的,write 会一直等到有空间或出错。
    • 设为非阻塞 (O_NONBLOCK) 时,write 会立即返回 EAGAIN/EWOULDBLOCK。
  7. 内核缓冲区锁竞争

    • 多线程同时 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 的完整方案

  1. 应用层维护每个连接的发送缓冲区(如 std::string 或环形缓冲区)。
  2. 调用 send 时,先尝试发送,如果没发完,剩余数据存入发送缓冲区。
  3. 注册 EPOLLOUT 事件。
  4. epoll_wait 返回 EPOLLOUT 时,继续发送缓冲区中的数据。
  5. 发送缓冲区为空时,移除 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),则重新发送该数据。

核心机制

  1. RTT 测量

    • RTT (Round-Trip Time):报文段从发送到收到确认的往返时间。
    • TCP 持续测量 RTT,动态调整 RTO。
  2. RTO 计算(Jacobson/Karels 算法):

    • 平滑 RTT (SRTT)SRTT = (1 - α) × SRTT + α × RTT,α 通常为 1/8。
    • RTT 偏差 (RTTVAR)RTTVAR = (1 - β) × RTTVAR + β × |SRTT - RTT|,β 通常为 1/4。
    • RTORTO = SRTT + 4 × RTTVAR
    • RTO 至少为 1 秒(Linux 实现),最大约 120 秒。
  3. 重传定时器

    • 每个发送的报文段都有一个重传定时器。
    • 定时器超时后,重传该报文段。
    • 指数退避 (Exponential Backoff):每次重传后,RTO 翻倍(RTO = RTO × 2),最多重传约 15 次后放弃连接。
  4. 快速重传 (Fast Retransmit)

    • 不等待 RTO 超时,收到 3 个重复的 ACK (DupACK) 就立即重传。
    • 因为重复 ACK 说明后续报文已到达,只有丢失的那个需要重传。
    • 比超时重传快得多。
  5. 选择确认 (SACK, Selective Acknowledgment)

    • 接收方在 ACK 中告知发送方哪些报文段已收到、哪些丢失。
    • 发送方只需重传丢失的报文段,不需要重传所有后续报文。
    • 提高重传效率。

超时后的行为

  • 重传丢失的报文段。
  • 拥塞窗口 (cwnd) 减半(或重置为 1,取决于实现)。
  • 慢启动阈值 (ssthresh) 调整为当前窗口的一半。
  • RTO 翻倍(指数退避)。

注意

  • TCP 的超时重传保证了数据的可靠传输。
  • 但频繁超时重传会导致性能下降,说明网络拥塞或丢包严重。
  • 应用层无法直接控制 RTO,但可以通过调整 TCP 参数(如 tcp_retries1tcp_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_reusetcp_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.solib.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. 栈被破坏

  • 缓冲区溢出、野指针写入破坏了栈上的返回地址和帧指针。
  • 解决:用 valgrindAddressSanitizer 检测内存错误。

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() 系统调用读取文件为例:

  1. 路径解析

    • 内核根据文件路径(如 /home/user/file.txt)逐级查找目录项 (dentry)。
    • 从根目录 / 开始,查找 homeuserfile.txt
    • 查找过程中可能需要从磁盘读取目录块(有 dentry cache 时可加速)。
  2. 获取 inode

    • 找到文件的目录项后,获取其 inode 编号。
    • inode 存储文件的元数据:大小、权限、所有者、时间戳、数据块指针等。
    • inode 中有 12 个直接块指针、1 个一级间接块、1 个二级间接块、1 个三级间接块,定位数据块。
  3. 检查权限

    • 检查进程是否有读权限。
  4. 检查页缓存 (Page Cache)

    • 内核先在页缓存中查找文件对应的数据页。
    • 若命中(缓存已有数据),直接从缓存拷贝到用户缓冲区,返回。
    • 若未命中,触发缺页,进入下一步。
  5. 提交 I/O 请求

    • 内核根据 inode 的数据块指针,计算文件偏移对应的磁盘块号。
    • 向块设备层提交读请求(bio 结构)。
    • I/O 调度器(如 CFQ、deadline、noop)合并、排序请求,减少磁盘寻道。
  6. 磁盘读取

    • 磁盘控制器执行读操作,将数据从磁盘读到内存缓冲区。
    • DMA 控制器直接传输数据到内存,不占用 CPU。
    • 读取完成后触发中断,通知内核 I/O 完成。
  7. 填充页缓存

    • 读取的数据页加入页缓存,后续访问可直接命中。
  8. 拷贝到用户空间

    • 内核将数据从页缓存拷贝到用户提供的缓冲区。
    • 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 # 杀死所有同名进程

常见信号

信号编号说明可捕获
SIGTERM15请求终止,默认动作
SIGKILL9强制杀死
SIGINT2终端中断 (Ctrl+C)
SIGQUIT3终端退出 (Ctrl+),生成 core
SIGSTOP19暂停进程
SIGSEGV11段错误是(但通常不恢复)

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 命令& 后缀、nohupbg
例子sshd、nginx、mysqldsleep 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 操作唤醒,可能很长(毫秒到秒级)。
  • 睡眠/唤醒涉及内核态切换(系统调用),开销比纯用户态操作大(约几微秒)。

信号量同步的问题

  1. 优先级反转 (Priority Inversion)

    • 低优先级线程持有信号量,高优先级线程等待,中等优先级线程抢占低优先级运行,导致高优先级等待更久。
    • 解决:优先级继承协议(PIP)、优先级天花板协议(PCP)。
  2. 死锁

    • 多个信号量的获取顺序不一致可能导致死锁。
    • 需按固定顺序获取。
  3. 活锁 (Livelock)

    • 线程都在运行但都在等待对方,不断重试但无法推进。
    • 比死锁更难检测。
  4. 竞态条件

    • 信号量使用不当(如忘记 P/V、P/V 不配对)仍会导致数据竞争。
  5. 性能开销

    • 信号量操作涉及内核态(System V 信号量)或原子操作(POSIX 无名信号量),有一定开销。
    • 对于极短的临界区,自旋锁可能更高效。
  6. 调试困难

    • 信号量相关的 bug(死锁、竞态)难以复现和调试,依赖时序。
  7. 计数错误

    • 信号量初值设置错误、V 操作次数多于 P 操作,可能导致资源过度分配。

选择建议

  • 互斥用 std::mutex(比信号量简单高效)。
  • 资源计数用信号量。
  • 事件通知用条件变量(比信号量更灵活)。


13. 登录 shell 进程是如何启动的?

以 Linux 文本登录为例:

  1. init/systemd 启动 getty

    • 系统启动后,init(或 systemd)为每个终端(tty)启动 getty 进程。
    • getty 打开终端设备,设置终端属性,显示登录提示符("login:")。
  2. 用户输入用户名

    • getty 读取用户输入的用户名。
    • getty 执行 login 程序,将用户名作为参数传入。
  3. login 验证用户

    • login 提示输入密码。
    • login 验证用户名和密码(通过 /etc/passwd/etc/shadow、PAM 模块)。
    • 验证失败则退出,getty 重新显示登录提示符。
  4. 登录成功后设置环境

    • login 设置用户的 UID/GID、环境变量(HOME、USER、SHELL、PATH 等)。
    • login 将当前工作目录切换到用户的家目录(/etc/passwd 中指定)。
    • login 打开终端的 stdin/stdout/stderr。
  5. 启动 shell

    • login 执行用户的登录 shell(/etc/passwd 中指定,如 /bin/bash)。
    • shell 以登录 shell 模式启动(-bash,argv[0] 前加 - 表示登录 shell)。
  6. shell 初始化

    • 登录 shell 依次读取执行:
      • /etc/profile(系统级环境配置)
      • /etc/profile.d/*.sh(系统级脚本)
      • ~/.bash_profile~/.bash_login~/.profile(用户级,按顺序找第一个存在的)
    • 非登录 shell(如终端中开新标签)读取 ~/.bashrc
  7. shell 交互

    • shell 显示命令提示符,等待用户输入命令。
    • 用户输入命令后,shell 解析、fork+exec 执行命令,等待命令完成后显示提示符。

图形登录:通过显示管理器(gdm、lightdm)验证用户后启动桌面环境(GNOME、KDE),桌面环境再启动终端模拟器时启动非登录 shell。



14. sleep() 调用后进程有哪些过程?sleep 过程中占用 CPU 吗?

sleep() 的执行过程

  1. 调用 sleep(seconds)

    • 进程调用 sleep() 库函数(内部通常用 nanosleepalarm + pause 系统调用实现)。
  2. 进程状态切换

    • 内核将进程状态从 运行态 (R) 改为 可中断睡眠态 (S)
    • 进程从 CPU 运行队列中移除,加入等待队列(等待定时器到期)。
    • 内核设置一个定时器(基于 jiffies 或高精度定时器 hrtimer),在指定时间后触发。
  3. 调度其他进程

    • CPU 调度器选择另一个就绪进程运行。
    • sleep 的进程此时不占用 CPU
  4. 定时器到期

    • 指定时间到达后,定时器中断触发。
    • 内核将 sleep 进程的状态从睡眠态改为就绪态 (R),加入运行队列。
  5. 被调度恢复运行

    • 调度器在合适时机选中该进程,恢复上下文,继续执行。
    • 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 方式)。

推荐方案

  • 内网、简单需求:scprsync
  • 高性能、自定义传输:TCP + sendfile + 大缓冲区 + 可能的分块并行。
  • 跨公网、不稳定网络:rsync(断点续传)或 HTTP Range。


16. 如何根据 IP 获取对方的 MAC 地址?

使用 ARP (Address Resolution Protocol,地址解析协议)

ARP 工作原理

  • ARP 用于将 IP 地址解析为对应的 MAC 地址(物理地址)。
  • 工作在数据链路层,只在同一局域网 (LAN) 内有效。

ARP 解析过程

  1. 检查 ARP 缓存

    • 主机先检查自己的 ARP 缓存表(arp -a 查看),如果有目标 IP 对应的 MAC,直接使用。
  2. 发送 ARP 请求

    • 如果缓存中没有,主机广播 ARP 请求报文:
      • 源 MAC:自己的 MAC
      • 目的 MAC:FF:FF:FF:FF:FF:FF(广播地址)
      • 源 IP:自己的 IP
      • 目的 IP:目标 IP
      • 问题:"谁拥有 IP 地址 x.x.x.x?请告诉我你的 MAC 地址。"
  3. 目标主机回复 ARP 响应

    • 局域网内所有主机都收到广播,但只有目标 IP 匹配的主机会回复。
    • 目标主机单播发送 ARP 响应报文:
      • 源 MAC:目标主机的 MAC
      • 目的 MAC:请求方的 MAC
      • 内容:"IP x.x.x.x 的 MAC 地址是 xx:xx:xx:xx:xx:xx"
  4. 更新 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 的组成

  1. 传输层协议 (SSH-TRANS):提供服务器认证、机密性、完整性。基于 TCP。
  2. 用户认证协议 (SSH-USERAUTH):客户端向服务器认证用户身份(密码、公钥、键盘交互)。
  3. 连接协议 (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)、多线程/多进程、缓存、数据库优化

具体优化手段

  1. 网卡多队列 (RSS):充分利用多核。
  2. 中断绑定:将网卡中断和应用线程绑定到不同核心,避免竞争。
  3. 增大缓冲区SO_RCVBUFSO_SNDBUFtcp_rmemtcp_wmem
  4. 拥塞控制算法:BBR 替代 cubic。
  5. 用户态协议栈:DPDK、Solarflare OpenOnload,绕过内核协议栈,极致性能。
  6. io_uring:Linux 5.1+ 的异步 I/O 接口,比 epoll 更高效。
  7. 零拷贝:sendfile、splice、mmap,减少内存拷贝。
  8. 监控工具sarnstatssethtoolperfbcc/BPF 定位瓶颈。


四、数据结构与算法

🔥 高频题(10题)

面试中最常出现,核心知识点,建议重点掌握,能熟练默写。

1. 红黑树的思想、特点?红黑树和平衡二叉树的区别?

红黑树 (Red-Black Tree) 是一种自平衡的二叉搜索树,通过节点颜色和旋转操作保持平衡。

红黑树的五条性质

  1. 每个节点非红即黑。
  2. 根节点是黑色。
  3. 叶子节点(NIL 空节点)是黑色。
  4. 红色节点的两个子节点必须是黑色(不能有连续红节点)。
  5. 从任一节点到其每个叶子的所有路径包含相同数量的黑节点(黑高相同)。

特点

  • 插入、删除、查找都是 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:分治 + 哈希 + 小顶堆(最常用)

  1. 分治:将海量数据通过哈希取模分到 N 个小文件中(如 hash(key) % 1000),相同 key 一定在同一个文件中。
  2. 哈希统计:对每个小文件,用哈希表统计每个 key 的出现次数。
  3. 小顶堆求 Top K
    • 维护一个大小为 K 的小顶堆。
    • 遍历所有文件的统计结果,如果当前元素频率 > 堆顶(最小元素),则替换堆顶并调整堆。
    • 最终堆中就是 Top K 元素。
  4. 时间复杂度:O(N) 分治 + O(M log K) 堆操作(M 为不同 key 数)。
  5. 空间复杂度:每个小文件可放入内存。

方法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 树?

  1. 范围查询高效:B+ 树叶子节点链表,范围查询只需找到起点后顺序扫描,B 树需要中序遍历多次 I/O。
  2. 查询更稳定:B+ 树所有查询都走到叶子节点,路径长度相同,性能稳定。
  3. 树更矮:B+ 树非叶子节点不存数据,单页能存更多关键字,树的高度更低,I/O 次数更少。
  4. 全表扫描快: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 的映射位置都会改变,需要大量数据迁移。

一致性哈希原理

  1. 哈希环:将哈希空间(如 0 ~ 2^32-1)组织成一个环。
  2. 节点映射:将每个服务器节点(通过 IP/ID 哈希)映射到环上的某个位置。
  3. 数据映射:将每个 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) 是一种基于分治思想的排序算法。

核心思想

  1. 选择基准 (Pivot):从数组中选择一个元素作为基准。
  2. 分区 (Partition):将数组分为两部分,小于基准的放左边,大于基准的放右边,基准在中间。
  3. 递归排序:对左右两部分分别递归进行快速排序。

分区过程(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)。
  • 不稳定排序:相等元素的相对顺序可能改变。

优化方法

  1. 随机化基准:随机选择基准元素,避免最坏情况(已排序数组)。
  2. 三数取中:取首、中、尾三个元素的中位数作为基准。
  3. 小数组用插入排序:当子数组长度 < 阈值(如 10)时,用插入排序(小数组插入排序更快)。
  4. 尾递归优化:只递归较小的一半,较大的一半用循环,减少栈深度。
  5. 三路快排 (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、Java Arrays.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++)

  1. 快速排序为主

    • 默认使用快速排序,三数取中选择基准(首、中、尾的中位数)。
    • 递归分区,平均 O(n log n)。
  2. 递归深度监控(防止最坏情况)

    • 监控递归深度,当深度超过 2 × log2(n) 时,认为快排退化(遇到最坏情况)。
    • 切换到堆排序,保证最坏 O(n log n)。
    • 这就是 Introsort 的核心:快排平均快,堆排保底。
  3. 小数组用插入排序

    • 当子数组长度小于阈值(通常 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

无损压缩算法

  1. Huffman 编码

    • 根据字符出现频率构建最优二叉树,频率高的字符用短编码,频率低的用长编码。
    • 前缀编码,无歧义。
    • 广泛用于 DEFLATE、JPEG(熵编码阶段)。
  2. LZ77/LZ78 (Lempel-Ziv)

    • 用"长度-偏移"对替换重复出现的字符串。
    • LZ77 用滑动窗口,LZ78 用字典。
    • DEFLATE (gzip/zip/png) = LZ77 + Huffman。
  3. LZW

    • LZ78 的变体,动态构建字典。
    • 用于 GIF、TIFF、旧版 Unix compress。
  4. 算术编码 (Arithmetic Coding)

    • 将整个消息编码为 [0,1) 区间的一个小数。
    • 比 Huffman 更接近熵极限,但实现复杂、有专利问题(已过期)。
    • 用于 JPEG2000、H.264 的 CABAC。
  5. RLE (行程编码)

    • 将连续重复的字符表示为"计数+字符"。
    • 简单,适合有大量连续重复的数据(如位图、传真)。

有损压缩算法

  1. JPEG

    • 图像分 8×8 块 → DCT 变换 → 系数量化(丢弃高频)→ Zigzag 扫描 → RLE + Huffman。
    • 人眼对亮度敏感、对色度不敏感,可降低色度采样率 (4:2:0)。
  2. MP3/AAC

    • 心理声学模型,丢弃人耳听不到的频率(掩蔽效应)。
    • 变换编码、量化、霍夫曼编码。
  3. 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 内存,多机多进程求重复最多的行。

思路:分治 + 哈希统计 + 归并

单机方案(内存不够时)

  1. 分治(哈希分片)

    • 遍历 100G 文件,对每行内容计算哈希值 hash(line) % N(N 如 1000)。
    • 将每行写入对应的小文件(共 N 个文件,每个约 100MB)。
    • 相同行一定在同一个小文件中。
    • 每台机器处理一部分小文件。
  2. 哈希统计

    • 对每个小文件(可放入 8G 内存),用 unordered_map<string, int> 统计每行出现次数。
    • 多进程并行处理不同的小文件。
  3. 求 Top 1(或 Top K)

    • 每个小文件统计完后,记录出现次数最多的行。
    • 所有小文件的结果归并,找出全局重复最多的行。
    • 可用小顶堆维护 Top K。

多机分布式方案(MapReduce 思想)

  1. Map 阶段

    • 将 100G 文件切分成多个块(如 64MB/块),分发到多台机器。
    • 每台机器对自己的块进行哈希统计(局部统计)。
    • 输出 <line, count> 键值对。
  2. Shuffle 阶段

    • 按 key (line) 哈希分区,相同 line 分发到同一个 Reduce 节点。
    • 网络传输中间结果。
  3. 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. 数据库索引怎么用?适合什么场景?什么时候索引失效?

索引的作用:加速数据检索,类似书的目录。

适合建索引的场景

  1. WHERE 条件中的列:经常用于查询条件的列。
  2. JOIN 连接的列:多表连接时,连接字段建索引。
  3. ORDER BY 排序的列:索引已排序,避免 filesort。
  4. GROUP BY 分组的列:索引有序,分组效率高。
  5. DISTINCT 去重的列:索引有序,去重快。
  6. 高选择性列:区分度高的列(如用户 ID),不适合低选择性列(如性别)。
  7. 范围查询的列:BETWEEN、>、< 等范围查询。

不适合建索引的场景

  1. 数据量小的表:全表扫描更快,索引开销大。
  2. 频繁更新的列:更新时需同时维护索引,开销大。
  3. 低选择性列:如性别、状态(只有几个值),索引效果差。
  4. TEXT/BLOB 大字段:不适合直接建索引,可用前缀索引。
  5. 很少在查询中使用的列:索引占用空间,维护有开销。

索引失效的常见原因

  1. 对索引列使用函数或运算

    WHERE YEAR(create_time) = 2023;  -- 失效
    WHERE create_time >= '2023-01-01' AND create_time < '2024-01-01';  -- 有效
    
  2. 隐式类型转换

    -- phone 是 VARCHAR,传入数字导致转换,失效
    WHERE phone = 13800138000;
    WHERE phone = '13800138000';  -- 有效
    
  3. 不符合最左前缀原则(联合索引)

    -- 联合索引 (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
    
  4. 使用 OR 连接非索引列

    WHERE indexed_col = 1 OR no_index_col = 2;  -- 失效(OR 两边都要有索引才行)
    
  5. LIKE 以通配符开头

    WHERE name LIKE '%abc';   -- 失效
    WHERE name LIKE 'abc%';   -- 有效(前缀匹配)
    
  6. 使用 NOT、!=、<>

    WHERE status != 1;  -- 可能失效(优化器可能选择全表扫描)
    
  7. IS NULL / IS NOT NULL:可能导致索引失效(取决于数据分布)。

  8. 索引列参与字符串拼接

    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)

  • 假设并发冲突很少发生,操作时不加锁,提交时检查是否有冲突,有冲突则回滚重试。
  • 类似于"先做后查"。

实现方式

  1. 版本号机制
-- 表中加 version 字段
UPDATE account SET balance = balance - 100, version = version + 1
WHERE id = 1 AND version = 5;  -- 只有 version 匹配才更新
-- 影响行数为 0 说明被其他事务修改,需重试
  1. 时间戳机制
UPDATE account SET balance = balance - 100, update_time = NOW()
WHERE id = 1 AND update_time = '2023-01-01 12:00:00';
  1. CAS (Compare-And-Swap)
    • 原子操作,比较当前值是否等于预期值,等于则更新。
    • Java AtomicInteger、Redis WATCH + 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基数统计算法,占 12KBUV 统计(不要求精确)、大规模去重计数,有 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 提供两种持久化方式:RDBAOF,可同时使用。

RDB (Redis Database)

  • 在指定时间间隔内,将内存中的数据快照写入磁盘(二进制文件 dump.rdb)。
  • 触发方式:
    • 自动:save 900 1(900秒内至少1个key修改)、save 300 10save 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 宕机,导致大量请求同时打到数据库,数据库压力骤增甚至崩溃。
  • 原因
    1. 大量 key 设置了相同的过期时间。
    2. Redis 节点宕机或网络故障。
    3. 缓存服务重启。
  • 解决方案
    1. 过期时间加随机值TTL = base_time + random(0, 300s),避免同时过期。
    2. Redis 高可用:主从复制 + 哨兵 + 集群,避免单点故障。
    3. 熔断降级:数据库访问量过大时,限流/降级,返回默认值。
    4. 多级缓存:本地缓存 + Redis,Redis 挂了本地缓存还能顶一阵。
    5. 预热缓存:系统启动前提前加载热点数据。

缓存穿透 (Cache Penetration)

  • 定义:查询一个数据库和缓存中都不存在的数据,每次请求都穿透到数据库,恶意攻击时可能压垮数据库。
  • 原因
    1. 恶意攻击,构造不存在的 key。
    2. 业务逻辑错误,查询了不存在的数据。
  • 解决方案
    1. 缓存空值:查询不到也缓存一个空值(如 NULL、特殊标记),设置较短过期时间,避免重复查库。
    2. 布隆过滤器 (Bloom Filter):在缓存前加一层布隆过滤器,key 不存在则直接拦截,不查缓存和数据库。
    3. 参数校验:接口层校验请求参数,过滤非法请求。
    4. 限流:对异常 IP 或高频请求限流。

缓存击穿 (Cache Breakdown)

  • 定义:某个热点 key 过期的瞬间,大量并发请求同时打到数据库。
  • 和雪崩的区别:雪崩是大量 key 同时过期,击穿是单个热点 key 过期。
  • 解决方案
    1. 互斥锁 (Mutex):缓存失效时,只让一个请求去查库并更新缓存,其他请求等待重试。
    2. 热点数据永不过期:热点 key 不设过期时间,异步更新缓存。
    3. 提前续期:快过期时异步刷新缓存。

缓存预热 (Cache Warmup)

  • 定义:系统启动或上线前,提前将热点数据加载到缓存中,避免启动后大量请求直接打到数据库。
  • 方法
    1. 启动时加载:应用启动时自动加载热点数据(如热门商品、配置信息)。
    2. 定时任务:定时刷新热点数据缓存。
    3. 访问统计:统计访问频率,自动预热高频 key。
    4. 手动触发:提供预热接口,上线前手动调用。
  • 注意:预热数据量不宜过大,避免启动慢;按访问频率优先级预热。


📌 中频题(6题)

比较常见,部分公司会问到,建议理解并能口述要点。

1. 如何对索引进行优化?

  1. 选择合适的索引列

    • 优先为 WHERE、JOIN、ORDER BY、GROUP BY 中的列建索引。
    • 选择高选择性(区分度高)的列。
    • 避免在频繁更新的列上建过多索引。
  2. 使用联合索引,遵循最左前缀原则

    • 将等值查询的列放前面,范围查询的列放后面。
    • 把区分度高的列放前面。
    • 一个联合索引可替代多个单列索引,减少索引数量。
  3. 使用覆盖索引避免回表

    • 查询的列都包含在索引中,不需要回表查询聚簇索引。
    • 例:SELECT name FROM user WHERE age = 20;,建联合索引 (age, name) 就是覆盖索引。
  4. 避免索引失效

    • 不对索引列使用函数、运算。
    • 避免隐式类型转换。
    • LIKE 不以通配符开头。
    • 遵循最左前缀原则。
  5. 控制索引数量

    • 索引不是越多越好,每个索引都占用空间,且 INSERT/UPDATE/DELETE 时需维护。
    • 删除未使用的索引(通过 sys.schema_unused_indexes 查看)。
    • 避免重复索引(如已有 (a,b),再建 (a) 就是冗余)。
  6. 使用前缀索引

    • 对长字符串列(如 VARCHAR(255)),只索引前 N 个字符。
    • CREATE INDEX idx_name ON table(name(20));
    • 节省索引空间,但区分度可能降低,需选择合适的前缀长度。
  7. 优化排序和分组

    • ORDER BY 的列建索引,避免 filesort。
    • GROUP BY 的列建索引,避免临时表。
    • 联合索引的顺序要同时满足 WHERE 和 ORDER BY。
  8. 使用强制索引(谨慎)

    • SELECT * FROM table FORCE INDEX(idx_name) WHERE ...
    • 优化器选错索引时使用,但通常应先分析原因(统计信息过期等)。
  9. 定期分析和优化表

    • ANALYZE TABLE table; 更新统计信息,帮助优化器选择正确索引。
    • OPTIMIZE TABLE table; 整理碎片,重建索引。
  10. 监控慢查询

    • 开启慢查询日志,分析慢 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)。
  • 索引有序,可加速范围查询、排序、分组。
  • 覆盖索引可避免回表,直接从索引获取数据。

索引不能加速甚至变慢的场景

  1. 数据量小的表

    • 全表扫描只需读几个数据页,而索引查询需要先读索引页再回表读数据页,反而更慢。
    • 优化器会自动选择全表扫描。
  2. 低选择性列

    • 如性别(男/女)、状态(0/1),索引过滤后仍需扫描大量行。
    • 回表开销大,优化器可能选择全表扫描。
  3. 查询返回大部分数据

    • 如果查询返回表中 30% 以上的数据,回表的随机 I/O 开销可能超过全表扫描的顺序 I/O。
    • 优化器会选择全表扫描。
  4. 索引失效

    • 对索引列使用函数、隐式转换、不符合最左前缀等,索引不生效,等于没建。
  5. 频繁写入的表

    • 每次 INSERT/UPDATE/DELETE 都需要维护索引,写入变慢。
    • 索引越多,写入开销越大。
  6. 索引碎片过多

    • 长期更新导致索引碎片,索引页利用率低,查询时 I/O 增多。
    • OPTIMIZE TABLE 重建索引。
  7. 统计信息过期

    • 优化器基于统计信息选择索引,统计信息不准可能选错索引。
    • ANALYZE TABLE 更新统计信息。
  8. 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 模式)
  • 单线程为什么快?
    1. 纯内存操作,速度极快(纳秒级)。
    2. I/O 多路复用,单线程处理大量并发连接,避免线程切换开销。
    3. 避免了多线程的锁竞争和上下文切换。
    4. 数据结构高效(跳表、哈希表等)。

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 freelazyfree-lazy-evictionlazyfree-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_mapString/Hash/List/Set/ZSet 等丰富
分布式不支持支持集群、主从、哨兵
网络访问无(函数调用)TCP 协议,有网络开销
容量限制受单进程内存限制可集群扩展,容量大
跨语言仅 C++支持多种语言客户端

核心区别

  1. 共享性:C++ Map 是进程内的,多个服务实例无法共享缓存;Redis 是独立服务,所有实例都能访问。
  2. 持久化:Redis 支持 RDB/AOF,重启不丢数据;C++ Map 进程退出就没了。
  3. 分布式扩展:Redis 支持集群、主从复制、哨兵高可用;C++ Map 只能单机单进程。
  4. 功能丰富:Redis 内置过期、淘汰、发布订阅、事务、Lua 脚本等;C++ Map 需自己实现。
  5. 语言无关: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) 平均时间。
  • 实现比红黑树简单。
  • 范围查询高效(找到起点后沿底层链表顺序遍历)。
  • 内存占用比红黑树稍大(多层指针)。

为什么用跳表而不是红黑树?

  1. 范围查询更高效:跳表底层是有序链表,范围查询只需找到起点后顺序遍历;红黑树需要中序遍历,更复杂。
  2. 实现简单:跳表的插入删除比红黑树简单(不需要旋转、变色等复杂操作)。
  3. 无锁友好:跳表更易实现并发(虽然 Redis 是单线程,但设计上考虑了)。
  4. 内存灵活:跳表的层数随机,平均空间效率好。

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();

注意事项

  1. 线程安全

    • C++11 之前,懒汉式需要双重检查锁定 (DCLP)。
    • C++11 之后,推荐用局部静态变量(Meyers Singleton),初始化线程安全。
    • 但单例对象的成员方法如果有共享数据,仍需自己加锁保护。
  2. 禁止拷贝和赋值

    • 拷贝构造、赋值运算符、移动构造都要 =delete
    • 否则可能通过拷贝创建第二个实例,破坏单例。
  3. 构造函数私有

    • 构造函数、析构函数设为 private/protected。
    • 防止外部 new、delete。
  4. 生命周期管理

    • 局部静态变量的单例在程序结束时自动析构。
    • 注意析构顺序:如果单例 A 依赖单例 B,且 B 先析构,A 析构时访问 B 会崩溃。
    • 可用 atexit 或引用计数管理析构顺序。
  5. 避免滥用

    • 单例是全局状态,增加耦合,不利于测试和扩展。
    • 能用依赖注入就不用单例。
    • 不要为了"方便访问"而用单例,应确认真的需要全局唯一实例。
  6. 可测试性

    • 单例难以 mock,单元测试时可能需要重置状态。
    • 可通过接口抽象 + 依赖注入替代单例,便于测试。
  7. 多进程/分布式

    • 单例只保证单进程内唯一,多进程/分布式环境下每个进程都有自己的实例。
    • 分布式场景需用分布式锁或共享存储。
  8. 序列化问题

    • 如果单例可序列化,反序列化可能创建新实例。
    • 需重写序列化方法保证反序列化后仍是同一实例。

单例 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 组件树、组织架构

项目中常用的几个

  1. 单例模式:日志管理器、配置管理器、线程池。
  2. 工厂模式:根据配置创建不同的数据库连接、不同的序列化器。
  3. 策略模式:支付模块,不同支付方式(微信/支付宝/银联)封装为不同策略,运行时切换。
  4. 观察者模式:事件总线,模块间解耦通信(如数据更新通知 UI 刷新)。
  5. 装饰器模式:网络请求中间件(日志装饰、鉴权装饰、重试装饰),动态叠加功能。
  6. 代理模式:RPC 远程调用,本地代理对象转发请求到远程服务。
  7. 模板方法:算法竞赛题解框架,固定流程,子类实现具体步骤。
  8. 建造者模式:构建复杂的 HTTP 请求或数据库查询,链式调用。

设计模式的选择原则

  • 不要为了用模式而用模式,根据实际问题选择。
  • 优先用简单方案,复杂时再引入设计模式。
  • 设计模式是经验总结,不是银弹,过度使用会增加复杂度。


📌 中频题(2题)

比较常见,部分公司会问到,建议理解并能口述要点。

1. 为什么用组合而不要用继承?

组合 (Composition):在类中包含其他类的对象作为成员,通过调用成员对象的方法来复用功能。
继承 (Inheritance):派生类继承基类,自动获得基类的属性和方法。

优先使用组合的原因

  1. 耦合度低

    • 继承是白盒复用,派生类能看到基类的内部实现,耦合紧密。
    • 组合是黑盒复用,通过接口交互,不依赖内部实现,耦合低。
  2. 灵活性高

    • 继承在编译期确定,运行时不能改变继承关系。
    • 组合可以在运行时动态替换成员对象,更灵活。
    • 组合可以有选择地复用功能,继承必须接受基类所有接口(包括不需要的)。
  3. 避免继承的问题

    • 脆弱基类问题:基类修改可能破坏派生类。
    • 菱形继承/多继承歧义:多继承可能导致方法冲突、数据冗余。
    • 继承层次过深:难以理解和维护。
    • 基类接口膨胀:派生类继承了不需要的方法。
  4. 符合设计原则

    • 合成复用原则 (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 的 stackqueue 是适配器,底层用 deque
  • Java 的 Arrays.asList() 将数组转为 List。
  • 电源适配器(220V 转 5V)。

优点

  • 解耦客户和被适配者,不需要修改原有代码。
  • 符合开闭原则,新增适配器不影响现有代码。
  • 提高类的复用性。

缺点

  • 增加了一层间接调用,代码复杂度略增。
  • 过多适配器会使系统混乱。


七、项目(网盘/搜索引擎项目)

🔥 高频题(2题)

面试中最常出现,核心知识点,建议重点掌握,能熟练默写。

1. 线程池如何实现?项目中怎么用?

见 Linux 章节第 33 题(线程池实现)。

项目中的应用(网盘项目)

线程池的分工

  1. I/O 线程池:处理网络 I/O(epoll 事件循环),接收连接、读写数据。通常 1-4 个线程。
  2. 业务工作线程池:处理业务逻辑(文件解析、数据库操作、计算 MD5)。通常 CPU 核心数 × 2。
  3. 磁盘 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. 客户端发消息给服务器,服务器如何解析?

典型流程(以自定义二进制协议为例)

  1. 接收数据

    • 服务器通过 epoll 监听客户端连接的可读事件。
    • 调用 read/recv 读取数据到应用层缓冲区。
    • TCP 是字节流,可能一次读不完一个完整消息,也可能读到多个消息,需处理粘包/半包。
  2. 解析消息头

    • 自定义协议通常包含固定长度的消息头:魔数、版本、消息类型、消息体长度、序列号等。
    • 先读取固定长度的消息头,解析出消息体长度。
  3. 读取消息体

    • 根据消息头中的长度字段,读取完整的消息体。
    • 如果缓冲区数据不足一个完整消息,等待更多数据到达。
  4. 反序列化

    • 将消息体的二进制数据反序列化为具体的消息对象(Protobuf/JSON/自定义)。
    • 根据消息类型字段,反序列化为对应的消息结构。
  5. 分发处理

    • 根据消息类型,调用对应的处理函数(消息分发器/回调表)。
    • 如登录消息 → 登录处理函数,上传文件消息 → 上传处理函数。
  6. 构造响应

    • 处理完成后,构造响应消息,序列化,加上消息头,发送给客户端。

协议格式示例

┌─────────────────────────────────────────────────┐
│ 消息头(固定长度)     │ 消息体(变长)            │
├────┬────┬──────┬──────┼──────────────────────────┤
│魔数│版本│类型  │长度  │ 序列化后的消息体           │
│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_failsfail_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. 你有什么想问的?

这是面试的最后环节,也是展示你对公司和岗位兴趣的机会。不要说"没有问题"。

推荐问的问题

关于团队和技术

  1. "团队目前的技术栈是什么?主要用 C++ 哪个标准?"
  2. "这个岗位主要负责哪些模块?日常工作中技术挑战最大的部分是什么?"
  3. "团队的代码规范和 Code Review 流程是怎样的?"
  4. "团队目前在技术上最大的痛点或正在攻克的难题是什么?"

关于成长和发展
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):动态分配的内存没有释放,程序无法再访问这块内存。

  1. malloc/new 后没有 free/delete
void func() {
    int* p = new int[100];
    // ... 忘记 delete[] p
}  // p 离开作用域,内存泄漏
  1. 异常导致跳过释放
void func() {
    int* p = new int[100];
    doSomething();  // 如果抛异常,后面的 delete 不会执行
    delete[] p;     // 内存泄漏
}
  1. 基类析构函数不是虚函数
Base* p = new Derived();
delete p;  // 基类析构非虚,只调用 Base::~Base(),Derived 部分泄漏
  1. 指针重新赋值未释放旧内存
int* p = new int[100];
p = new int[200];  // 旧内存泄漏,p 指向新内存
  1. 容器中存指针,清空容器未 delete
vector<int*> v;
v.push_back(new int(1));
v.clear();  // 指针被清除,但指向的内存未释放
  1. 文件描述符/句柄未关闭(资源泄漏,类似内存泄漏)。

内存溢出(Buffer Overflow):写入的数据超过了缓冲区的大小,覆盖了相邻内存。

  1. 数组越界写入
char buf[10];
strcpy(buf, "this is a very long string");  // 溢出!
  1. sprintf 不检查长度
char buf[10];
sprintf(buf, "%s", long_string);  // 溢出
  1. 循环越界
int arr[10];
for (int i = 0; i <= 10; i++) arr[i] = i;  // i=10 越界
  1. 整数溢出导致分配不足
int size = width * height;  // 乘法溢出,size 变成负数或很小
char* buf = new char[size]; // 分配不足,后续写入溢出
  1. 字符串未以 '\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 包含
.cC 源文件
.cpp / .cc / .cxxC++ 源文件
.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生成)