C++ 面试题

一、C++

1. C++ 中值传递和引用传递的区别?

  • 值传递:在函数调用时,会触发一次拷贝动作,所以对参数的修改不会影响原始的值。

  • 引用传递:在函数调用时,不会触发拷贝动作,但是对参数的修改也会影响到原始的值。

2. C 和 C++ 的区别?

  1. 面向对象&面向过程

    • C语言是一门面向过程的语言,以函数为中心,将问题分解为一系列步骤,通过调用函数来解决问题。关注程序的执行流程,强调怎么做。

    • C++是一门既面向对象又面向过程的语言,主要支持面向对象,以对象为中心,将问题分解为一系列相互交互的对象,每个对象中包含数据和操作数据的方法。强调谁来做。

  2. 函数重载

    • C语言不支持函数重载,函数名必须唯一才行。

    • C++支持函数重载主要通过参数类型和参数个数。

💡 补充:

函数重载基本规则:

  1. 函数名必须相同
  2. 参数列表必须不同
    • 参数类型不同
    • 参数个数不同
    • 参数顺序不同(仅当类型不同时有效)
  3. 返回值不同并不能区分重载

函数重载的原理:

主要是通过函数名修饰规则,每个编译器都会有自己的函数名修饰规则

  1. C语言中函数名修饰规则,直接使用定义或声明的函数名,在链接时,编译器会直接使用函数名寻找函数地址

  2. C++中函数名修饰规则(以 Gcc 为例),_Z + 函数名长度 + 函数名 + 类型首字母。

因此,这就是为什么C语言不支持函数重载,而C++支持,以及为什么返回值不能区分重载

  1. 模版

    • C语言不支持模版编程
    • C++支持模版,支持静态模版和动态形式的多态
  2. 内存管理

    • C语言使用 malloc 和 free 来申请和释放内存
    • C++由于对象的出现,使用 new 和 delete 操作符来管理内存,也支持使用智能指针来管理动态内存

💡 补充:

malloc/free 和 new/delete 的区别:

  1. malloc/free 是函数,new/delete是运算符
  2. malloc申请的空间不会初始化,new申请空间可以初始化
  3. 对于自定义类型,malloc/free不会调用构造和析构函数,new/delete 会调用构造函数析构函数
  4. malloc失败返回NULL,new失败抛异常
  5. malloc申请空间需要手动计算大小,new T[]中指定个数即可
  6. malloc返回 void* 需要类型转换,new指定类型,无需类型转换
  1. 标准库

    • C语言没有标准库和算法的支持,需要自己造轮子
    • C++有 STL,比如 vector、string、list、map等,以及算法库 algorithm,比如sort、find、reverse等

3. C++中的左值和右值?有什么区别?

  • 左值:可以出现在赋值运算符的左边,并且可以被取地址,通常是有名字的变量
  • 右值:不能出现在赋值运算符的左边,不可以被取地址,通常是字面量、常量、临时变量

4. 什么是引用折叠和万能引用

在 C++ 中,不能直接声明引用的引用 int && & x 是非法语法,但是,在模版函数中会间接出现。

template<typename T>
void f1(T &x) {}    

template<typename T>
void f2(T &&x) {}

引用折叠:只要其中有一个是左值引用,则最终结果就是左值引用。只有当两者都是右值引用时,结果才是右值引用。

万能引用:是在函数模版中使用 T&& 这种写法,配合引用折叠的推导规则,让函数既能绑定左值又能绑定右值。

  • 如果实参是左值时,T会被推导为左值引用类型,发生引用折叠。
  • 如果实参是右值时,T会被推导为非引用类型T,没有发生引用折叠。
template<typename T>
void f2(T &&x) {}

5. 什么是 C++ 的移动语义和完美转发?

移动语义和完美转移都是 C++11 引入的新特性。

核心思想是:与其费力的拷贝一个即将消亡的对象资源,不如直接将其资源移动过来,从而避免高昂的拷贝操作。

移动语义是搭配移动构造或移动赋值来使用。而移动构造和移动赋值的参数都需要是右值引用,在函数内部使用交换(swap)操作,从而避免拷贝。

完美转发要解决的核心问题是,在编写模版函数时,保持参数原有的值类别(左值、右值)的属性不变,继续向下传递。

当一个实参是右值传递给形参右值引用时,右值引用形参本质上也是一个有名字的变量,所以它的属性属于左值,在传递或使用时,会被当成左值处理。因此需要完美转发继续保持右值的属性。

💡 补充:

什么场景下需要用到移动构造和移动赋值?

  1. 当函数返回一个对象时(将亡值),用移动构造函数可以避免返回值拷贝
  2. 当需要一个大对象从一个容器移动到另一个容器时,用移动赋值可以避免资源的拷贝

6. C++ 中 move 有什么作用?它的原理是什么?

std::move 的作用是,无论输入参数是左值还是右值,都被强制转成右值,这样可以触发移动构造或移动赋值来转移该对象的资源,而不是拷贝。

std::move 后的对象能否使用取决于移动构造和移动赋值的实现,如果在移动构造或移动赋值中,并没有使用 swap 交换资源操作,而还是使用拷贝动作,那么原对象还是可以使用的。

// move
template <class T>
LIBC_INLINE constexpr cpp::remove_reference_t<T> &&move(T &&t) {
  return static_cast<typename cpp::remove_reference_t<T> &&>(t);
}

7. 什么是 RAII ?

RAII(Resource Acquisition Is Initialization)资源获取即初始化,核心思想是利用对象的生命周期来管理获取的动态资源(内存、文件指针、网络连接、互斥锁),从而避免资源泄漏。

  • 资源获取在构造函数中完成
  • 资源释放在析构函数中完成

这样,即使程序在执行过程中抛出异常或多路径返回,也能确保资源得到最终正确的释放,可以避免内存泄漏。

8. 介绍一下 C++ 中三种智能指针的使用场景

C++11 中主要引入了三种智能指针,分别是 std::unique_ptrstd::shared_ptrstd::weak_ptr

  1. std::unique_ptr 是一种独占所有权的智能指针,意味着同一时间内只能有一个 unique_ptr 指向特定对象。不支持拷贝构造,支持移动。

  2. std::shared_ptr 是一种共享所有权的智能指针,多个 shared_ptr 可以指向同一个对象,内部使用引用计数来确保当最后一个 shared_ptr 被销毁时,对象才会被销毁。支持拷贝构造。

  3. std::weak_ptr 是一种不拥有对象所有权的智能指针,它指向一个由 shared_ptr 所管理的对象,weak_ptr 主要解决 shared_ptr 之间的循环引用问题。

    • 循环引用:当两个对象相互持有 shared_ptr 时,并且导致引用计数无法减为0,此时造成循环引用问题,通过将 shared_ptr 换成 weak_ptr 来解决

9. C++11 中有哪些常用的新特性?

  1. auto 类型推导,可以让编译器在编译时推导出等号右侧表达式的类型,在一些复杂类型中使用,比如某个容器的迭代器,lambda表达式类型等。

  2. initializer_list 列表初始化,允许使用 {} 来构造对象/变量或传递参数,对于自定义类型,需要支持列表初始化的构造函数。

  3. 智能指针。

  4. std::thread 以及 RAII 风格的锁 std::lock_guardstd::unique_lock

  5. 移动构造和移动赋值。

  6. std::functionstd::bind

    • std::function 是一个类模版,可以包装任何的可调用对象,包括函数指针、仿函数、lambda、bind等
    • std::bind 是一个通用的函数适配器,它接受一个可调用对象及其部分或全部参数,并返回一个新的可调用对象,当你调用这个新对象,它会用你预先绑定好的参数去调用原始的可调用对象
  7. lambda 是一个匿名函数对象,在调用的地方就地定义函数,而无需单独命名和定义函数。

10. C++ 中 static 的作用?

  1. 修饰局部变量:当 static 修饰局部变量时,这个变量的存储位置会在静态区中保存,不会随着函数的局部作用于的结束而销毁,且该变量只会初始化一次。

  2. 修饰全局变量或函数:当 static 修饰全局变量或函数时,限制了这些变量和函数的作用域,他们只能文件内部访问,不能跨文件访问,避免在不同文件中命名冲突。

  3. 修饰类的成员变量或函数:在类内部,static 修饰的成员变量和函数属于类本身,而不是属于类的任何实例对象,这意味着该类所有对象共享一个 static 成员变量,无需每个对象都存储一份拷贝。static 成员函数可以在没有类实例的情况使用类名调用,并且 static 成员函数没有 this 指针。

11. C++ 中 const 的作用?

const 最主要的作用是声明一个常量,但 const 不仅可以用作普通常量,还可用作指针、引用、成员函数等。

  1. const 修饰普通常量。

  2. const 修饰引用,一般用于函数参数,表示函数不会修改传递的参数值。

  3. const 修饰指针,分为三种情况。

    • const int* ptr 指向常量的指针,不能通过解引用修改指向的内容,但可以修改指针的指向

    • int * const ptr 指针常量,不能修改指针的指向,但是可以修改指针指向的内容

    • const int* const ptr 指向常量的常量指针,不能修改指针指向,也不能修改指向的内容

  4. const 修改类成员函数,表示该函数不会修改类的任何成员变量,除非这些成员变量被声明为 mutable,本质就是 const obj * const this

12 C++ 中 define 和 const 的区别?

如果单纯从定义常量角度,优先使用 const

#define 是一个预处理指令,用于定义宏,在预处理阶段进行文本替换,没有类型检查。

const 是一个关键字,用于定义常量,在编译时确定类型和值。

  1. 类型安全:
  • #define 没有类型检查,单纯是替换,可能会有意向不到的错误。

  • const 是强类型,编译器会对其进行类型检查。

  1. 作用域:
  • #define 定义的宏在整个源文件中有效,直到被 undef

  • const 则遵循 C++ 的作用域规则,仅在定义的作用域内中有效。

13. C++ 中 inline 的作用?

inline 的作用是建议编译器在调用地方展开内联函数,以减少函数调用的开销。对于频繁调用的短小函数可以在一定程度上提升程序的效率。内联函数主要替换宏函数,宏函数在不加括号时,可能有意想不到的错误。

  • 内联是一种建议,编译器可以选择忽略 inline 关键字。

  • 类中的函数默认是内联函数。

  • 内联函数不能进行声明和定义的分离,会报链接错误。

14. struct 和 class 的区别?

在C语言中,struct 仅能用于定义变量,而在C++中,struct被扩展为类,不仅可以包含成员变量,也可以包含成员函数。

主要区别:

  • class 默认访问限定符 privatestruct 默认访问限定符 public(兼容C)。

  • struct 默认继承时是 public 继承,class 默认继承时 priavte 继承。

15. 什么是内存对齐?

内存对齐是计算机系统中一项重要的优化机制,它确保数据在内存的存储位置符合特定的要求,以提高访问效率。

基本规则:

  • 第一个成员偏移量为0

  • 后续成员偏移量对齐到 对齐数 的整数倍地址处。

    • 对齐数:min(编译器默认对齐数 ,该成员大小)
  • 结构体总大小为 最大对齐数 的整数倍地址处。

    • 最大对齐数:min(结构体中变量类型最大者,编译器默认对齐数)
  • 嵌套结构体的情况:嵌套结构对齐到自己的最大对齐数的整数倍处,结构体的整体大小就是 所有最大对齐数(含嵌套结构体的对齐数) 的整数倍。

    • 嵌套结构体的最大对齐数:min(结构体中变量类型最大者,编译器默认对齐数,嵌套结构体的最大对齐数)

为什么要内存对齐?

  • 提高效率:对齐的数据,可以让CPU减少内存的访问次数。

  • 硬件限制:一些硬件架构要求比较对齐,使数据存储的起始地址可以整除数据实际占据内存的字节数。

16. C++ 中 sizeof 和 strlen 的区别?

  1. sizeof 是一个运算符,用于获取一个类型或一个对象的大小(以字节为单位),sizeof 在编译时计算结果。

  2. strlen 是一个库函数,用于计算C风格的字符串长度(不包含 \0),strlen 在运行时计算结果,需要遍历字符串内容。

💡 补充:

sizeof 对于指针,返回的是指针本身的大小,通常是4字节或8字节,取决于系统架构是32位还是64位。

17. C++ 中 explicit 的作用?

explicit 主要是防止单参数的构造函数被隐式类型转换,如果没有 explicit 关键字,Object obj = 10 这样的代码是可以编译通过的。

18. C++ 中 final 关键字的作用?

final 关键字在 C++11 中引入,主要用于防止类被继承或防止虚函数被重写。

  1. 防止类被继承:当一个类被声明为 final,则这个类不能被继承。

  2. 防止虚函数重写:当一个虚函数被声明为 final,则这个虚函数在派生类中不能被重写。

19. C++ 中的四种类型转换?

  1. static_cast 最常用的显示转换,使用场景:

    • 基本数据类型之间的转换
    • void* 指针转换为具体类型的指针
    • 类层次结构中的上行转换(派生类转换为基类)
  2. reinterpret_cast 用于在两种不相关类型之间进行转换,其本质是对原始数据的底层位模式进行重新解释,也就是说转换后对原有内存的访问解释已经完全改变了。使用场景:

    • 任意指针类型之间的转换
    • 指针和整数之间的转换
  3. const_cast 用于 const 类型到非 const 类型,去掉 const 属性。

  4. dynamic_cast 用于将基类的指针或者引用安全的转换为派生类的指针或引用。使用场景:

    • 安全的下行转换(基类 -> 派生类)

💡 补充:

  • 如果基类的指针或者引⽤时指向派⽣类对象的,则转换回派⽣类指针或者引⽤时可以成功的,如果基类的指针指向基类对象,则转换失败返回nullptr,如果基类引⽤指向基类对象,则转换失败,抛出bad_cast异常。

  • 其次dynamic_cast要求基类必须是多态类型,也就是基类中必须有虚函数。因为dynamic_cast是运⾏时通过虚表中存储的type_info判断基类指针指向的是基类对象还是派⽣类对象。

20. C++ 中 volatile 关键字的作用

在 C++ 中,volatile 关键字的作用是防止编译器对其变量进行优化,确保每次读写都是直接从内存中操作,而不是使用寄存器的值。

21. 什么是多态?

多态是面向对象的三大特征之一,指的是一个接口可以有多个不同的实现。

在 C++ 中,多态主要通过继承机制和虚函数实现。通过基类指针或者引用调用子类重写的虚函数,会根据对象的类型调用对应的函数实现。

重写:子类的虚函数与基类虚函数的返回值类型、函数名称、参数列表完全相同。

💡 补充:

在 C++ 中,多态分为静态多态和动态多态。静态多态通过函数重载和模版实现,而多态则是通过虚函数实现的。

  • 静态多态:函数在编译时确定调用哪个函数,这种方式提高了效率,但是灵活性差
  • 动态多态:函数在运行时确定调用哪个函数,则中方式更灵活,但是效率比较低

22. 多态的原理?

虚函数表机制

  1. 虚函数表:每个包含虚函数的类都有一个虚函数表,这是一个函数指针数组,存放该类所有的虚函数地址(重写的虚函数地址或从基类继承下来的虚函数)。

  2. 虚函数表指针:每个拥有虚函数表的对象在内存布局中包含一个隐藏的指针成员,指向该类的虚函数表。这个指针通常在内存布局的开始位置。

    • 基类对象的虚函数表存放基类虚函数的地址。同类型的对象共用一个虚表,所以基类和派生类都有各自独立的虚函数表,虚函数指针指向的是不同的虚函数表
    • 当基类包含虚函数时,即使派生类没有重写这些虚函数,派生类的虚函数表中仍会包含基类虚函数的地址。如果派生类还定义了新的虚函数,这些额外的虚函数也会被添加到派生类的虚函数表中。
    • 当派生类重写了基类的虚函数,派生类的虚函数表中对应的虚函数就会被覆盖成派生类重写的虚函数地址
    • 派生类的虚函数表中包含:1.基类的虚函数地址 2.派生类重写的虚函数地址完成覆盖 3.派生类自己的虚函数地址三个部分

多态的调用过程:

  1. 通过基类指针或引用找到该对象的虚函数表指针
  2. 在通过虚函数表指针找到指向的虚函数表
  3. 查找虚函数表找到对应的函数地址
  4. 调用该地址指向的函数

虚函数的调用比普通函数调用多了一个 vtable 查找过程,运行时略有开销。

💡 补充:

构造函数、静态成员函数和友元函数不能是虚函数。
析构函数可以是虚函数。

23. C++ 中析构函数一定要是虚函数吗?

C++ 中析构函数并一定要是虚函数,但是在多态条件下,一定建议声明其为虚函数

当基类的析构函数声明为 virtual 关键字,此时派生的类析构函数只要定义,则派生类的析构函数与基类析构函数构造重写,编译器会对析构函数名称做了特殊处理,统一处理为 destructor

析构函数声明为虚函数,主要为了解决当基类指针或引用指向派生类对象时,析构函数资源释放问题

多态条件下:

  • 析构函数不构成多态时:只会调用基类的析构函数,会导致派生类的析构函数不会被调用,从而引发内存泄漏
  • 析构函数构成多态时:编译器会先调用派生类的析构函数,随后自动调用基类的析构函数

24. C++ 中重载/重写/隐藏的区别?

  1. 重载

    • 两个函数在同一个作用域中
    • 函数名相同,参数不同(参数的类型、个数、顺序)。与返回值无关
  2. 重写/覆盖

    • 两个函数都是虚函数,分别在继承体系中的父类和子类的作用域中
    • 函数名、参数、返回值必须相同,协变除外
  3. 隐藏

    • 函数名/变量名相同,分别在继承体系中的父类和子类作用域中
    • 两个函数只要不构成重写,就是隐藏

💡 补充:

协变:派生类重写基类虚函数时,与基类虚函数返回值类型可以不同。

  • 即基类虚函数返回基类对象的指针或引用,派⽣类虚函数返回派⽣类对象的指针或者引⽤时,称为协变。

25. C++ 中的继承?什么是虚继承?

继承是使代码复用的重要手段,在原有类的基础上进行扩展,增加新的功能,这样产生的新类,被称为派生类。

虚继承主要用于解决菱形继承问题

菱形继承问题:假设有四个类A、B、C和D,其中B和C都继承自A,而D又同时继承自B和C。这样就形成一个菱形结构。若不使用虚继承,D类中会包含两个独立的A类对象,导致菱形继承存在二义性和数据冗余的问题。

  • 解决二义性:通过指定类域的方式解决
  • 解决数据冗余:通过虚继承的方式解决

虚继承是怎么解决的?

将A对象只在D对象中存储一份,而B和C对象中不存储A对象,而是存储一个虚基表指针,指向一个虚基表,在虚基表中存储与A对象地址的偏移量,通过对应虚基表中存储的偏移量访问A对象中的成员,解决数据存两份的问题。

在 C++ 中处理菱形继承问题时,virtual 关键字需要加在中间派生类继承基类的地方。

💡 补充:

基类定义了 static 静态成员,则整个继承体系里面只有一个这样的成员。无论派生出多少个子类,都只有一个 static 成员实例。

虚继承中:通过B的对象模型,发现菱形虚拟继承中B和C的对象模型跟D保持⼀致的⽅式去存储管理A对象,这样当B的指针访问A时,⽆论B指针切⽚指向D对象,还是B指针直接指向B对象,访问A成员都是通过虚基表指针的⽅式查找到A成员再访问。

26. C++ 中继承和组合的区别?

继承是一种 is-a 的关系,每个派生类都是一个基类。继承使派生类和基类之间的依赖关系很强,耦合度高(白箱复用)。

组合是一种 has-a 的关系,即 B 对象中有一个 A 对象。组合之间没有很强的依赖关系,耦合度低(黑箱复用)。

27. 什么是 C++ 的运算符重载?

通过运算符重载,我们可以定义对象/自定义类型如何使用运算符(+、-、*、/)等。

运算符重载由 operator + 运算符共同组成,具有形参和返回值。

  • 运算符重载函数的参数应与该运算符原本的操作数量保持一致
  • 当作为类的成员函数实现时,则左侧运算对象默认传给隐式的this指针,因此参数会比全局重载的运算符函数少一个

注意点:

  1. 只能重载已有的运算符,不能创建新的运算符
  2. 不能改变运算符的优先级、结合性和内在的语义
  3. .*::?:sizeof. 以上运算符不能被重载
  4. 区分前置++和后置++时,C++规定,后置++需要添加一个int形参,来与前置++构成函数重载

28. C++ 中 using 和 typedef 的区别?

using 在 C++11 中引入,usingtypedef 都可以为类型定义一个新的别名。

主要区别在于,using 可以定义模版别名,而 typedef 不能。并且 using 定义时语法更直观。

29. C++ 中 enum 和 enum class 的区别?

在 C++ 中,enumenum class (强枚举类型)主要的区别在于作用域和类型安全。

  1. 作用域:

    • enum:枚举成员直接可以使用,不需要枚举类型的前缀
    • enum class:枚举成员只能显式的指定枚举类型来使用
  2. 类型安全:

    • enum:传统枚举类型不安全,枚举成员会隐式转换为整数类型
    • enum class:强枚举类型是类型安全的,不能隐式的转换为其他类型,必须显示转换

30. C++ 中 new 和 malloc 的区别?delete 和 free 的区别?

  1. new vs malloc

    • new 是操作符,而 malloc 是函数
    • new 分配内存并调用构造函数,而 malloc 仅仅分配内存,不调用构造函数
    • new 可以指定类型,而 malloc 返回 void*,需要显示的类型转换
    • new 分配失败抛出 std::bad_alloc 异常,而 malloc 返回 NULL
  2. delete vs free

    • delete 是操作符,而 free 是函数
    • delete 释放内存并调用析构函数,而 free 仅仅释放内存,不调用析构函数
    • delete 必须和 new 配对使用,而 free 必须与 malloc 配对使用
    • deletedelete[] 是不同的,前者用于单一对象,后者用于数组。free 没有这种区分

31. C++ 中类定义中 delete 和 default 关键字作用?

delete 关键字用来禁用某些默认的成员函数,主要作用是禁用拷贝构造函数和拷贝赋值重载。

default 关键字用于显式的指示编译器为类的默认成员函数,生成默认的实现,经常使用在生成默认构造和默认析构函数上。

32. C++ 中 this 指针的作用?

this 指针是一个隐含在每个非静态成员函数中的参数,它指向的是调用该成员函数的对象的地址,主要作用包括:

  • 访问类的成员变量和成员函数,特别是当局部变量和成员变量同名时,用 this 指针可以作出区分
  • 可以通过返回 *this 指针来支持链式调用
  • 在基类指针或引用调用派生类对象时,利用 this 指针也可以构造多态

33. C++ 中 vector 的原理?resize 和 reserve 的区别?

vector 底层是一个顺序表,由 startfinishend_of_storage 三个指针,分别指向不同的位置。

  • start:指向数组的首元素
  • finish:指向最后一个元素的下一个位置
  • end_of_storage:指向分配内存的末尾

resize 指的是 finish 的位置,reserve 指的是 end_of_storage 的位置。

  • resize(n):调整 vector 的大小为 n (也就是调整的是 finish 指针)

    • n 大于当前大小,会向 vector 末尾添加初始化的新元素
    • n 小于当前大小,会删除超出部分的元素
    • n 大于 capacity 会自动扩容,满足容量需求(调整 finishend_of_storage
  • reserve(n):预分配内存,确保 vector 可以存储 n 个元素(调整的是 end_of_storage

34. C++ 中的迭代器失效是什么?解决方案是什么?

C++ 中的迭代器失效指的是容器(vector、list等)由于修改操作(插入、删除等),导致之前获取的迭代器不再指向预期的元素,甚至引发未定义的行为。

迭代器失效的场景:

  • vector 插入元素时,底层空间扩容时,导致所有迭代器失效。

    • insert:在未扩容时,插入位置及其之后的迭代器失效,因为元素被向后移动。解决方案:insert 返回新插入位置的迭代器
    • erase:被删除位置及其之后的迭代器失效,因为元素被向前移动。解决方案:erase 返回删除元素的下一个位置迭代器
  • list 由于底层是链表,是一个一个节点,所以插入时不会有迭代器失效问题。list 迭代器失效主要发生在元素被删除时。

    • erase:被删除位置的迭代器失效。解决方案:erase 返回删除元素的下一个位置迭代器

35. C++ 中 deque 的原理?

deque 是 C++ 容器库中的双端队列,主要用于实现适配器(栈、队列),它允许在队列的两端高效的插入和删除元素。

deque 支持随机访问,可以通过下标访问元素,deque 内部使用分段的连续空间组合而成,通过中控(指针数组)来管理多个内存块。

  • map:中控数组(指针数组)存储指向各个缓冲区(buffer)的指针
  • buffer:实际存储元素的连续内存块(存储元素的个数通常是固定的)
  • __deque_iterator:deque 的迭代器,包含四个指针(firstlastcurnode),分别指向缓冲区开始、缓冲区结尾、当前元素、以及回指中控数组的二级指针

deque 扩容时,不需要移动元素,只需分配新的 buffer,更新中控数组即可,因为每个缓冲区都是独立分布的。

deque 的随机访问能力不如 vector,中间元素的插入删除不如 list

35. C++ 中 map 和 unordered_map 的区别?

  1. 底层实现:

    • map:基于有序的红黑树
    • unordered_map:基于无序的哈希表
  2. 时间复杂度:

    • map:插入、删除、查找的时间复杂度时 O(log n)
    • unordered_map:插入、删除、查找的时间复杂为 O(1)
  3. 内存使用:红黑树相比于哈希表内存使用的比较小。

  4. 迭代器稳定性:map 是树形结构,插入后迭代器有效,而 unordered_map 底层使用到了顺序表,扩容时,导致迭代器失效。

  5. 运算符重载:

    • map 要求 key 支持小于和大于运算符
    • unordered_map 要求 key 支持等于以及取模操作

36. C++ 中 vector 和 list 的区别?

vector 的底层是顺序表,list 的底层是双向循环链表。

  1. 存储空间:

    • vector 物理上是连续的
    • list 逻辑上连续,但物理上不连续
  2. 随机访问:

    • vector 支持随机访问 O(1)
    • list 不支持,需要遍历 O(N)
  3. 任意位置的插入删除:

    • vector 需要移动元素 O(N)
    • list 只需要改变指针的指向 O(1)
  4. 内存使用率:

    • vector 只存储元素本身,无额外指针开销,内存利用率高
    • list 每个元素需额外存储前后节点的指针,内存开销更大

37. C++ 中 lock_guard 和 unique_lock 的区别?

两者都是 RAII 风格的锁管理类,用于管理互斥锁。

  1. lock_guard 是一个简单轻量级的锁管理类,在构造时锁定互斥锁,析构时释放互斥锁。

  2. unique_lock 提供了更复杂和灵活的功能。它允许显式的锁定和解锁、延迟锁定操作,还支持锁的所有权转移。条件变量只能使用 unique_lock

38. C++ 中 thread 的 join 和 detach 的区别?

joindetachstd::thread 的成员方法:

  • join:阻塞当前调用的线程,等待子线程完成。
  • detach:将子线程从调用线程中分离出来,子线程在后台独立执行,不会阻塞当前调用线程。

简单说,join 是一种同步机制,保证子线程完成后主线程再继续,通常使用在需要关心子线程的执行结果,或只能确保子线程执行完后,主线程才能执行的场景。detach 则是不关心子线程的调用结果。

39. C++ 中 memcpy 和 memmove 的区别?

两者是 C 风格的内存拷贝函数,但他们的主要区别在于处理内存重叠区域的能力。

  1. memcpy:用于从源地址复制指定数量的字节到目标地址。如果源地址和目标地址产生重叠,行为是为定义的,因为memcpy不处理重叠。
  2. memmove:用于从源地址复制指定数量的字节到目标地址。但与 memcpy 不同的是,如果源地址和目标地址产生重叠,memmove 保证重叠情况下的数据也是被正确复制的。

这也就意味着 memmovememcpy 做更多的工作,memmove 需要判断是否是重叠场景,决定从前复制还是从后复制。

40. 模版的优缺点?

优点

  1. 代码重用性:模版允许我们编写与类型无关的代码,减少了重复代码,提高代码的重用性。
  2. 类型安全:模版可以在编译时进行类型检查,避免了运行时错误,提高了程序的安全性。
  3. 灵活性强:模版可以用来实现泛型编程,实现更为通用的算法。

缺点

  1. 编译时间增加:因为模版会在编译时生成具体类型的代码,会导致编译时间显著增加。
  2. 难以调试:模版引起的错误往往信息量巨大且难以理解。
  3. 可读性和维护性:由于模版代码的泛型特性,代码的可读性和维护性会下降,比如标准库的代码,真的比较难理解。

41. C++ 的栈溢出?

栈溢出(Stack Overflow)是指程序使用的栈空间超过了操作系统为该线程预留的栈空间大小,栈空间被耗尽的一种现象,导致程序崩溃。

出现栈溢出的情况:

  1. 递归调用很深,撑爆栈空间
  2. 大局部变量,定义了很多过大的局部变量,超过了栈空间的限制

42. 什么是 C++ 的回调函数?为什么需要回调函数?

回调函数是一种通过可调用对象(函数指针、仿函数、std::function、lambda)将一个函数作为参数传递给另一个函数的机制。

实际上就是把函数的调用权从一个地方转移到另一个地方,这个调用会在未来某个时刻执行,而不是立即执行。

  • 异步编程:在异步操作中,比如网络请求、文件读取、事件处理等,可以在操作完成后调用回调函数,而主程序可以继续执行其他任务,避免等待。
  • 解耦代码:回调函数有助于将代码模块化和解耦,允许我们创建更灵活和可复用的代码。比如,一个通用的排序算法可以接受一个比较函数,来控制升序还是降序。

43. C++ 中为什么要使用 nullptr 而不是 NULL?

在 C++ 中 NULL 是一个宏,通常是 #define NULL 0,它实际上是个整形值。而 nullptr 是 C++11 推出的新关键字,有具体的类型 std::nullptr_t 能够明确表示空指针。

44. 什么是大端序?什么是小端序?

大端序和小端序指的是数据在内存中的存放顺序。

  • 大端序:将数据的低位保存在内存的高地址处,高位存储在内存的低地址处。
  • 小端序:将数据的低位存储在内存的低地址处,高位存储在内存的高地方处。
int num = 0x11223344;
假设从左向右是低地址到高地址
大端存储:11 22 33 44
小端存储:44 33 22 11

如何判断当前环境是否是大端还是小端?

bool isLittle() {
    int num = 1;
    return *(reinterpret_cast<char*>(&num)) == 1;
}

指针中存储的地址是指向内容的低地址num低位是1,如果低地址也是1,就是小端存储,否则是大端存储。

45. C++ 中深拷贝和浅拷贝?

浅拷贝:浅拷贝只是简单的复制对象的值,而不复制对象所拥有的资源或内存。也就是说,两个对象共享同一个资源,当一个对象修改了资源,另一个对象也会受影响。(默认生成的拷贝构造和赋值就是浅拷贝)

深拷贝:深拷贝不仅复制对象的值,还会对于其动态申请的资源进行新的内存分配,并复制其的内容,这样,两个对象看到的就不是同一个资源,当其中一个修改也不会影响另外一个。

46. C++ 中友元类和友元函数有什么作用?

两者主要用于提供访问其他对象私有成员和保护成员的权限。

友元关系是一种单向的访问权限,并不会破坏封装性,同时也不会牵涉到类之间的继承关系。

友元的替代方案:gettersetter 方法

47. C++ 中如何设计一个线程安全的类?

  1. 使用互斥锁保护共享资源。
  2. 部分逻辑可以使用无锁编程,原子变量控制。
  3. 使用线程任务队列形式,当跨线程操作时,A线程不直接操作B线程中的内容,而是通过将要执行的任务投递到B线程的任务队列中,来通知B线程执行任务,这样保证所有的操作都在该线程中执行。

💡 补充:

读写锁:有时我们需要实现的场景是多线程可以同时读数据,但写数据时需要占据锁。这可以使用 std::shared_mutex(C++17)来实现。

48. C++ 中的 extern C 是什么?

使用 extern "C" 来告诉编译器按照 C 语言的编译方式处理某些代码,因为 C++ 支持函数重载,而 C 语言不支持。C++编译器会对函数名就行修饰,extern "C" 的作用就是让编译器按照 C 方式编译,避免函数名被修饰,保证 C语言库里的函数能被正确调用。

在 C++ 代码中包含 C语言头文件时,用 extern "C" 进行声明:

extern "C" {
    #include "xxx.h"
}

49. C++ 指针和引用的区别?

  1. 引用语法代表变量的别名,不开辟空间,而指针是存储一个变量的自己,需要开辟空间
  2. 引用定义必须初始化,指针定义可以不初始化
  3. 引用不可以改变指向,指针可以改变指向
  4. sizeof(引用)算的是被引用对象的大小,sizeof(指针)表示指针的大小(根据系统架构决定,32位4字节,64位8字节)
  5. 不存在空的引用,必须有具体实体,但是存在空的指针

💡 补充:

引用的底层是通过指针实现的。

50. C++14、17的新特性?

C++14:

  1. 允许 lambda 表达式使用 auto 作为参数类型,使其成为泛型
  2. C++11中不能直接用 auto 做函数的返回类型,需要配合尾置返回类型。C++14可以直接用 auto 做返回类型,自动推导返回类型
  3. 二进制字面量,通过0b声明二进制数 0b1010
  4. 数字分隔符 int million = 100'0000
  5. std::make_unique
  6. 字面量后缀

C++17:

  1. 结构化绑定,可以解包元组、结构体、pair
  2. 内联变量,解决头文件中定义全局变量的重复的问题
  3. std::optional 表示可能有值也可能没值的语义,替代指针判空或特殊值
  4. std::string_view 字符串的零拷贝只读视图,它不拥有数据,大幅减少 std::string 临时对象
  5. 文件系统库 std::filesystem 标准化的目录遍历和路径操作

51. C++ 中的 shared_from_this 是什么?

shared_from_this 让一个类的成员函数能够安全的获取该对象的 std::shared_ptr 指针,这样可以避免直接创建 shared_ptr 而导致的双重释放问题和潜在的悬挂指针问题。

双重释放问题:是因为 shared_ptr 的机制,shared_ptr(裸指针)都会创建一个全新的、独立的控制块。注意:当一个 shared_ptr 赋值或者构造一个 shared_ptr 时才会引用计数+1。
所以当你构造用 this 构造多个 shared_ptr 时,每个 shared_ptr 都是全新的控制快,每个引用计数都是1,这样最终导致多重释放问题。

悬挂指针问题:如果传裸 this 制作,可能调用对象已经释放了,造成悬空指针。用 shared_from_this 保证持有一份引用计数,使用完才释放。

shared_from_this() 的使用条件:

  1. 该对象被 shared_ptr 管理
  2. 类继承 enable_shared_from_this<T>

52. 介绍下 C++ 程序从编写到可执行的整个过程?

  1. 编写源文件

  2. 编译(预处理、编译、汇编)

    • 预处理
      • 头文件展开
      • 宏替换
      • 条件编译
      • 去注释
    • 编译
      • 词法分析
      • 语法分析
      • 生成汇编代码
    • 汇编
      • 将汇编代码转换为机器码(二进制)
  3. 链接:将所有目标文件和所需的库文件链接起来,生成最终的可执行文件

    • 符号解析:找到所有的外部符号(如函数、变量)在目标文件和库文件中的定义
    • 地址分配:将目标代码放置到内存中的适当位置
    • 重定位:调整程序内所有的地址引用,使它们指向正确的内存位置

53. 什么是 C++ 中的 auto 和 decltype?

两者都是 C++11 引入的新特性,主要用于类型推断。

  1. auto 用于自动推断变量的类型。编译器根据变量的初始化表达式来推导变量的类型,这样开发者就无需显示的声明类型。
  2. decltype 用于推断表达式类型。它会返回一个表达式所对应的类型信息,而不进行计算,在模版编程中特别有用。

使用场景:

auto 适合在处理复杂的STL容器类型时,可以避免类型名过长。

  • std::unordered_map<std::string , std::string>::iterator 可以直接用 auto 代替

在模版元编程中,decltype 可以解决很多类型推断的问题。

template<typename T1 , typename T2>
auto add(T1 a, T2 b) -> decltype(a + b) {
    return a + b;
}

这里,通过 decltype 来推断 a+b 的返回类型,这样我们就不需要在知道 T1T2 的具体类型时显式的声明返回类型。

54. C++ 中为什么 new[] 和 delete[] 一定要配对使用?

一定要配对使用。

  1. 如果使用 new[] 分配内存,但用 delete 来释放,导致析构函数调用不完整,造成内存泄漏
  2. 如果使用 new 分配内存,但用 delete[] 来释放,可能导致为定义行为

具体来说,当用 new[] 分配一块连续的内存时,编译器不仅会开辟实际数据的内存空间,还会在实际数据的内存空间头部多开辟一个4字节的空间,用于存储数组的大小信息,这也就是为什么 new[] 的时候需要指定开辟的个数,而 delete[] 释放时不需要指定。

而且,delete[] 会负责调用数组中每个对象的析构函数,再释放整个开辟的空间。

  • new[] 使用 delete 不仅会内存泄漏还会导致程序奔溃,因为 delete 不同于 delete[],它只认为这是一个对象占用的空间,不会访问前4个字节释放内存,导致释放不完整的空间,所以程序挂掉。

  • new 使用 delete[] 释放,会出现为定义行为,因为释放时会向前多访问4个字节的数据,通常是随机值,可能调用随机次数的析构函数,并且程序挂掉,释放空间的位置不正确。

💡 补充:

malloc 申请的内存,可以用 delete 释放吗?

  • malloc 申请的内存不能用 delete 释放。正确的做法是配对使用,混用会导致未定义的行为,因为使用 malloc 开辟空间并不会调用构造函数,而使用 delete 释放空间会调用析构函数

55. 什么场景下会出现内存泄漏?

  1. 申请了空间,但是未释放空间(忘记释放、抛异常、多路径返回)

    • 解决:释放空间或使用智能指针管理动态申请空间
  2. 智能指针的循环引用,两者相互持有,导致引用计数不为0,内存无法释放

    • 解决:使用 weak_ptr 代替 shared_ptr
  3. 父进程 fork 子进程进程但没有进行 wait,导致子进程僵尸,从而内存泄漏

    • 解决:父进程等待子进程

56. 介绍 C++ 中 shared_ptr 的原理?shared_ptr 是否线程安全?

shared_ptr 是共享所有权的智能指针,意味着多个 shared_ptr 可以同时指向一个对象,它在底层实现上,通过维护了一个引用计数,来管理对象的生命周期。

当新构造一个对象时,引用计数初始化为1,拷贝对象时,引用计数加1,析构对象时,引用计数减1,当最后一个 shared_ptr 释放时,引用计数为0时,所持有的资源才会被释放。

线程安全性:

shared_ptr 底层的引用计数是线程安全的,但是 shared_ptr 管理的对象不是线程安全,需要额外的保护机制,比如使用互斥锁。

57. 为什么使用 make_shared?

C++ 中创建 shared_ptr 有两种方式,一种是直接把裸指针传递进去,一种是使用 make_shared

如果使用裸指针传递,shared_ptr 管理的对象和引用计数的空间需要分配两次,而使用 make_shared 只需分配,效率挺高了,并且也减少了内存碎片。

还有就是一些特殊情况下,直接使用 new 传递,可能会由于异常导致分配的内存未释放的问题,而 make_shared 不存在这样的情况,因为它是全有或全无的过程。

58. C++ 中如何使用线程局部存储?原理是什么?

在 C++ 中使用线程局部存储可以使用 thread_local 关键字,它的作用是在每个线程中创建并维护独立的变量,不同线程间互不影响。

线程局部存储的原理是每一个线程都有一块独立的栈空间,用来存储线程的局部变量。而线程的局部存储变量会存储在这块区域中,当编译器看到 thread_local 关键字时,它会确保每个线程都会为该变量分配独立的存储空间。

59. C++ 如何实现线程池?给出大体思路?

  1. 定义一个任务类型 std::function<void()>
  2. 维护一个线程池 std::vector<std::thread>
  3. 维护一个任务队列,方便给线程池投递任务 std::queue<std::function<void()>>
  4. 提供一个接口,可以向任务队列中添加任务,这个接口需要使用互斥锁和条件变量保证线程安全
  5. 维护一个标志位,表示线程运行状态还是结束状态 bool running
#include <vector>
#include <thread>
#include <queue>
#include <functional>

class ThreadPool {
    public:
        explicit ThreadPool(size_t num);
        ~ThreadPool();
        void AppendTask(const std::function<void()>& task);
    private:
        std::vector<std::thread> _workers;
        std::queue<std::function<void()>> _tasks;
        std::mutex _mutex;
        std::condition_variable _condition;
        bool _stop;

        void workerRun();
};

将一个新任务添加到任务队列中,需要注意线程安全,当添加完成后通知工作线程有新的任务可执行。

void ThreadPool::AppendTask(const std::function<void()>& task) {
    if(_stop) return;
    std::unique_lock<std::mutex> lock(_mutex);
    _tasks.push(task);
    _condition.notify_one();
}
void ThreadPool::workerRun() {
    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(); // 执行任务
    }
}
ThreadPool::~ThreadPool() {
    {
        std::unique_lock<std::mutex> lock(_mutex);
        _stop = true;
    }
    _condition.notify_all();
    for(std::thread& worker : _workers) {
        worker.join();
    }
}

60. 介绍一下 C++ 中的返回值优化?

返回值优化(RVO)是 C++ 编译器的一种优化技术,在 C++17时被纳入标准。主要针对函数返回一个局部对象的场景。

若没有返回值优化,正常的流程是,局部对象拷贝构造临时对象,临时对象再拷贝构造外部接收函数返回值的对象,需要两次拷贝构造。而返回值优化则是,直接在使用局部对象构造外部接收函数返回值的对象,这样优化了中间的拷贝和析构的过程。

RVO和移动语义的区别:两者虽然都可以避免多余的没有必要的拷贝,但是两者原理不相同,一个是直接在返回值的位置上构造那个对象,另一个是使用移动代替拷贝。

61. C++ 中如何实现一个单例模式?

常见的单例模式有懒汉模式和饿汉模式。归纳为以下几点:

  • 饿汉模式:实例在程序开始时就创建,直接在类中初始化静态成员
  • 懒汉模式:实例在首次使用时才被创建,节省资源
  1. 将构造函数、拷贝构造和赋值运算符禁用掉 delete 或设置为 private
  2. 在类中提供一个静态的、返回类实例的 public 方法
  3. 使用局部静态变量初始化类实例,并确保线程安全
class Singleton {
    private:
        Singleton() = delete;
        Singleton(const Singleton&) = delete;
        Singleton& operator=(const Singleton&) = delete;

        static std::mutex _mutex;
        static Singleton* _instance;
    public:
        static Singleton* getInstance() {
            if(_instance == nullptr) {
                std::lock_guard<std::mutex> lock(_mutex);
                if(_instance == nullptr) {
                    _instance = new Singleton();
                }
            }
            return _instance;
        }
};
std::mutex Singleton::_mutex;
Singleton* Singleton::_instance;

双重检测,第一层 if(_instance == nullptr) 主要防止每次读取也需要获取锁,降低效率。

62. C++ 中 vector 的 push_back 和 emplace_back 有什么区别?

两者都用于在 vector 的末尾添加元素,主要区别在于:

  1. push_back 接受一个已存在的对象作为参数,进行拷贝或移动,触发拷贝还是移动取决于传递的对象是左值还是右值
  2. emplace_back 接受构造函数的参数,直接在 vector 的内存空间中调用该对象的构造函数,避免了额外的拷贝或移动

63. C++ 成员变量的初始化顺序是固定的吗?

成员变量的初始化不由初始化列表中的顺序决定,而是按照在类中声明的顺序初始化的。

64. C++ 的迭代器和指针有什么区别?

迭代器是像指针一样,但是底层实现有时候可以用原生指针,有时候则需要单独封装一个类。

迭代器主要用于遍历容器,屏蔽了容器底层的数据结构,让我们可以用一致的方式也就是迭代器遍历不同的容器。

迭代器有多种类型:输入迭代器、输出迭代器、单向、双向迭代器、随机迭代器,这些迭代器提供了不同的遍历能力和操作方式,以及算法库中的算法就分迭代器的类型。

65. 介绍一下 std::sort 的原理?底层是快速排序吗?

std::sort 不是单纯的快速排序,而是使用了一种名为 introspective sort(内省排序)的算法。

内省排序是快速排序、堆排序和插入排序的结合体,它结合了这些算法优点的同时避免它们的缺点,特别是快速排序在最坏情况下性能下降问题。

快速排序:内省排序首先使用快速排序算法,利用快速排序分而治之的特点,通过选取一个 key 元素,将数组分为两个子数组,一边是小于 key 的元素,一边是大于 key 的元素,然后递归的对这两个子数组进行快速排序,平均复杂度为 O(logn)。
堆排序:内省排序会通过限制快速排序的递归深度,避免其最坏的性能问题,递归深度通常是对数据长度取对数在乘上一个常数(在 GCC 的实现 2 * log(len)),如果快排的递归深度超过了这个限制,会自动切换为堆排序。
插入排序:当数组大小减少到一定程度(10个元素左右),内省排序会通过插入排序处理小数组,插入排序在处理小数组上非常高效。

66. C++ 函数调用的原理是什么?什么是栈帧?

当一个函数被调用时,程序会执行以下几个步骤:

  1. 保存当前上下文:包括当前的程序计数器(执行到哪条指令的位置)和寄存器内容,当函数调用完毕后需要恢复上下文
  2. 分配栈帧:为调用的函数分配内存,以便管理函数的局部变量,参数和返回地址等数据
  3. 传递参数:将调用参数压入栈或通过寄存器传递给被调用函数
  4. 跳转到被调用函数的代码:程序计数器更新为被调用函数的入口地址
  5. 执行被调用函数:运行函数体的代码
  6. 返回调用点:在函数执行完毕后,恢复之前保存的上下文并跳转回调用点

栈帧是每次调用函数在栈上分配的内存区域:

  1. 函数的参数
  2. 返回地址,函数结束后程序需要返回的地址
  3. 局部变量
  4. 保存的上下文信息

二、操作系统(Linux)

1. 进程和线程的区别?

进程是系统资源分配的实体,每个进程资源有PCB(task_struct)、独立的虚拟地址空间、文件描述符表、独立的页表,进程可以看作是一个运行的程序实例。进程之间是相互独立的。

线程属于进程,一个进程可以包含多个线程,线程共享进程的内存地址空间和资源(比如文件描述符表、数据段),但每个线程拥有自己的栈空间和寄存器。

区别:

  1. 进程之间是相互隔离的,线程之间是共享资源的,只有线程的栈空间和寄存器是私有的
  2. 创建线程的开销比创建进程要小的多,进程切换上下文的开销也比线程大很多(进程会切换地址空间,导致缓存全部失效)
  3. 进程通信比较麻烦,线程比较简单,只需注意互斥和同步问题
  4. 进程的稳定性更强(进程崩溃不影响其他进程),而线程只要有一个挂掉,就会导致进程也就是所有线程挂掉

💡 补充:

在 Linux 内核中,进程和线程其实都是用 task_struct 结构体表示的。
线程本质上就是共享了地址空间的进程,所以 Linux 里经常叫 “轻量级进程”

2. 进程的状态?

  1. 运行态(R):进程正在CPU上执行或在运行队列中等待CPU调度
  2. 睡眠态(S):进程正在等待某个事件完成(如I/O、用户输入等),这个睡眠也可以叫做可中断睡眠
  3. 深度睡眠(D):进程在等待不可中断的I/O,不能被信号终止
  4. 暂停态(T):进程可以通过发送 SIGSTOP 信号暂停,可以发送 SIGCONT 信号让进程继续运行
  5. 终止态(X):进程完全终止,瞬时状态
  6. 僵尸态(Z):当前进程已经运行结束,但是父进程并没有回收子进程(获取子进程的退出结果),导致子进程的 PCB 要一直在内存中维持,僵尸进程会导致内存泄漏

3. 什么是用户态和内核态?

用户态和内核态是操作系统中的两种运行模式,用于区分普通应用程序与操作系统内核的权限级别。

在经典的 32位 Linux 内核配置中,进程的虚拟地址空间默认被划分为:

  • 0GB - 3GB 用户空间
  • 3GB - 4GB 内核空间

CPU 通过 CS 寄存器中的两个比特位来区分当前正在执行代码的权限:

  • 0: 内核态,拥有满级权限,可以直接访问硬件资源和执行各种特权操作,比如内存管理、进程调度。当程序调用系统调用或硬件中断时,操作系统会将其切换为内核态
  • 3: 用户态,权限受限,不能直接访问硬件,必须通过系统调用让内核为其执行敏感操作

4. 进程间的通信方式有哪些?

进程间的通信方式主要有:管道、消息队列、共享内存、信号量、套接字,适用场景各不相同

管道是最简单的 IPC 方式,数据从一端写入,从另一端读出,单向流动。管道分为匿名管道和命名管道:

  • 匿名管道:只能用于有亲缘关系的进程,比如父子进程
  • 命名管道:在文件系统中有个名字,不相关的进程也能用

共享内存是最快的 IPC 方式,多个进程直接读写同一块物理内存,不需要数据拷贝。但因为没有内置的同步机制,必须配合信号量或互斥锁使用,否则会出现竞争场景(数据不一致问题)。

消息队列允许进程发送和接受带类型的消息,消息在队列中排队,接收方可以按类型选择性的读取,比管道灵活,但是每条消息有大小限制,Linux默认是8KB。

信号量本身不传递数据,是用来做同步的,可以控制多个进程对共享资源的访问顺序,实现互斥或者限流。

套接字最灵活,既能在本机进程间通信,也能跨网络通信。

5. 为什么需要虚拟地址空间?

物理内存的局限性:

  1. 程序直接使用物理地址时,内存会被分割成不连续的块,导致无法分配大块连续内存
  2. 程序可以随意访问任何物理地址,可能破坏其他进程或内核数据

虚拟内存的优势:

  1. 内存隔离

    • 每个进程拥有独立的虚拟地址空间,从 0x0000... 开始,仿佛独占整个内存
    • 进程无法直接访问其他进程或内核的物理内存(体现进程独立性)
  2. 无序变有序

    • 虚拟地址对程序呈现为连续的地址空间,实际物理内存是可以分散的
    • 解决物理内存碎片化问题
  3. 内存扩展

    • 虚拟地址空间可以大于物理内存,通过磁盘交换实现无限内存的假象
    • 按需加载,未活跃数据会被唤出到磁盘

6. 什么是分段、什么是分页?

分段和分页都是操作系统管理内存的方式,解决的问题不一样。

  • 分段:是按程序的逻辑结构来划分内存、代码放代码段、数据放数据段等,每个段大小不固定
  • 分页:把内存切成固定大小的块,一般4KB一页

7. 什么是软中断?什么是硬中断?

硬中断是硬件设备发送给 CPU 的信号,比如网卡收到数据包、键盘按下按键、定时器时间到了等,都会触发硬中断。

CPU 收到信号后会立刻停下手头的活,跳去执行对应的中断处理程序。

软中断是程序主动触发的中断,比如 x86 的 int 指令、ARM 的 svc 指令。系统调用就是靠这个实现,用户程序想读文件、发网络包,都需要通过软中断陷入内核。

信号捕捉的流程(倒8):

  1. 在执行用户空间代码时,因为中断(软中断、硬中断)、异常(除0、访问非法内存)或系统调用进入内核
  2. 内核处理完毕后,在准备返回用户空间之前,会先处理当前进程递达的信号
  3. 如果信号的处理动作时用户自定义的信号处理函数函数,则回到用户态执行信号的处理
  4. 信号的处理函数返回时会执行特殊的系统调用 sigreturn 再次进入内核态
  5. 返回用户态从上次被中断的地方继续向下执行

8. 什么是I/O?

I 是 Input 输入,O 是 Output 输出。

输入:外部设备产生数据,比如键盘按下了某个按键,网卡收到了数据,磁盘读取文件,数据从磁盘搬到内存,CPU 再从内存中读取。

输出:CPU 把结果写到内存,将内存中的数据写入到磁盘或外部设备,比如显示器显示画面,网卡发送数据,磁盘写文件。

9. I/O 为什么会被阻塞?I/O 为什么慢?

因为 I/O 的本质是 等 + 拷贝,当没有数据到来的时候,进程就会被挂起等待,所以高效的 IO 关键,是在单位时间里降低 IO 过程中的等待占比。

网络 I/O 阻塞的常见场景:

连接的阻塞:

  • accept() 阻塞:服务器在调用 accept 时,会阻塞在这个调用上,直到有新的客户端连接请求到来,此时,服务器会等待客户端的连接,无法继续执行其他操作
  • connect() 阻塞:客户端在调用 connect 尝试连接服务器时,如果连接不能立即建立(如服务器响应慢或网络不通畅),connect 会阻塞,直到连接成功或超时

数据传输的阻塞:

  • recv()read() 阻塞:当使用这些函数从套接字中读取数据时,如果缓冲区没有数据(如对方尚未发送数据),进程会被阻塞,直到有数据到来时
  • send()write() 阻塞:当缓冲区已满(如网络拥塞或对方接受缓慢),数据无法立即写入时,send()write() 会被阻塞,等待缓冲区有空闲时间再发数据

10. 如何理解Linux中一切皆文件?

一切皆文件是 Linux 的设计哲学。它指的是操作系统将所有的资源(硬件设备、进程信息、网络套接字、管道)等都抽象为字节流(文件),并通过统一的文件描述符进行访问。

思想的实现主要是依靠内核中的虚拟文件系统(VFS)。计算机的哲学有一句是没有什么问题是添加一层软件层解决不了的,而 VFS 可以理解为是软件层(抽象层),在上层看来屏蔽了底层硬件和具体文件系统的差异,

  • 对上,提供了统一的系统调用接口(openreadwriteclose等)
  • 对下,要求设备驱动必须要实现这些回调函数

优势:

  • 统一的交互接口,极大的简化了程序的开发,只需要一套 API 即可完成百分之 90% 的 IO操作

11. I/O 模型有哪些?

  1. 阻塞I/O:调用 read 后线程卡住了,数据没来就一直等,数据来了还得等内核把数据拷贝到用户空间,整个过程什么都干不了
  2. 非阻塞I/O:调用 read 时如果数据没准备好,内核会立刻返回一个错误码 EWOULDBLOCK 告诉你没数据,线程不用一直等,但是需要不断轮询
  3. I/O多路复用:一个线程可以同时监控成百上千个连接,哪个连接有数据就绪就处理哪个。Linux常用的系统调用是 selectpollepoll
  4. 信号驱动I/O:注册一个信号处理函数,数据准备好后内核发送信号通知,省去了轮询的开销。(问题是多次的信号触发,如果没有及时处理,只会被当一次处理)
  5. 异步I/O:调用 aio_read 后立刻返回,内核在后台完成 数据的等待 + 拷贝数据 全部工作,完成后会通过回调通知,整个过程没有任何阻塞点

12. 阻塞和非阻塞的区别?

阻塞和非阻塞描述的是调用方在等待结果时的状态,是一直等待结果就绪,还是可以先去干别的事。

阻塞I/O与非阻塞I/O:

  • 阻塞会因为IO条件不具备,而导致阻塞等待(进程被挂起到等待队列中),直到条件就绪。

  • 非阻塞检测到IO条件不具备,出错返回。本质的区别是等待数据就绪的方式不同。

非阻塞 I/O 相比于阻塞 I/O 的效率提升,本质上源自于其在等待数据就绪期间能够执行其他任务,而非缩短了内核与用户空间之间的数据拷贝时间,实际数据拷贝量是完全相同的,这一环节在两种模型下是同等耗时且不可避免的。

13. 同步 vs 异步?

同步和异步的核心区别在于:调用方是否需要等待结果返回才能继续向下执行。

同步调用直到操作完成,拿到结果,才能执行后面的代码。

异步调用,发送请求后立即返回,调用方可以接着干别的事,等操作完成了会通知回调或future这类机制告诉你结果。

同步I/O与异步I/O:

  • IO等于 等 + 拷贝,只要程序参与了I/O操作的 等待或数据拷贝中的任意一个阶段,就是同步I/O,只有这两个阶段全部由内核完成,才是真正的异步I/O。

14. Select、Poll、Epoll 之间有什么区别?

它们都是操作系统中用于 I/O 多路复用的机制,让一个线程能同时监控多个文件描述符的状态。

  1. select

是最早的实现,用固定长度的位图表示文件描述符集。每次调用都需要把整个位图从用户态拷贝到内核态。

  • select 系统调用的参数是输入输出型的,因此需要构建辅助数组,将监控的文件描述符永久保存到辅助数组中。
  • 内核会线性扫描所有的文件描述符检查状态
  • 支持的文件描述符数量有上限(1024)
  1. poll

改进了 select 的文件描述数量限制,用动态数组来存储文件描述符,但每次调用还是需要把整个数组拷贝到内核,内核还是需要线性遍历。

  1. epoll

epoll 是 Linux 最高效的多路复用机制,用红黑树管理注册的文件描述符,用就绪链表存储触发的事件。

  • epoll模型的核心三部分:管理文件描述符及事件的红黑树,存放就绪事件的就绪队列,以及事件发生时由内核出发的回调函数
  • 红黑树每一个节点都会注册一个回调机制,当该文件描述符上的事件就绪,会自动调用回调,激活红黑树的节点到就绪队列中,只有指针操作,时间复杂度O(1)(select、poll需要遍历O(N))
  • epoll管理的文件描述符没有数量限制,解决了用户设置监听与内核告诉用户哪些事件就绪的分离,通过事件回调,避免使用遍历

epoll 的 LT 模式和 ET 模式

  • LT:水平触发,只要有事件就绪,就会一直通知(即使数据没有读取完毕,也会通知)
  • ET:边缘触发,只有当事件的就绪状态变化时,才会通知(如果数据没有读取完毕,除非有新数据到来,否则不会通知)

因此使用 ET 模式必须一次性读取完所有的数据,否则会造成数据的丢失。用户读取时无法预知底层缓冲区是否读完,需要使用非阻塞I/O进行循环读取,直到读取到 EAGAIN 表示缓冲区已为空。

15. 什么是 Reactor?

Reactor 模型是一种高性能的网络编程架构,其核心思想是采用事件驱动与I/O多路复用技术实现单线程或者少量线程高效管理成千上万个连接。

将新到来的连接的文件描述符统一注册到 epoll 中进行监控(1.事件驱动),主线程通过 epoll_wait 同步的等待多个连接上的I/O就绪,当某一个连接出现可读、可写等请求时(2.阻塞或非阻塞对应epoll的LT或ET模式),epoll 会返回其就绪的文件描述符以及就绪的时间,在由 Reactor 中的事件派发器触发对应的回调函数(3.事件的分发与处理)。

核心思想是把I/O事件和处理程序解耦,通过一个事件派发器来管理时间和响应操作。

单 Reactor 与 多 Reactor 模型:

  • 单 Reactor 单线程:事件的监听与事件的处理(接受数据、处理数据、返回响应)都在一个线程中进行,实现简单,但高并发场景下单个线程容易成为瓶颈
  • 单 Reactor 多线程:事件的监听与事件的I/O处理均在主线程的 Reactor 中完成,而数据的处理则通过多线程完成。
    • 单 Reactor 承担所有的事件监听和I/O读取,如果有一个描述符读取的很慢,则会导致不能及时处理事件的监听
  • 多 Reactor 多线程:又被称为主从 Reactor 模型
    • 主 Reactor 主线程,主要负责事件的监听
    • 子 Reactor 子线程,主要负责处理事件的I/O,将读取到的数据交给线程池处理,工作线程处理完毕后,由子 Reactor 进行响应

16. 死锁是什么?如何预防和避免死锁?

多个线程相互等待对方持有的资源且都不释放自己的资源,这种现象称为死锁。

具有有四个必要条件,必须同时满足才会触发死锁:

  1. 互斥:同一个时刻,只能有一个线程能使用共享资源
  2. 占有且等待:一个线程已经占有至少一个资源,但又在等待另一个资源,而此时该资源被其他线程占有
  3. 不可剥夺:线程占有的资源不能被剥夺
  4. 环路等待:即线程A在等待线程B占有的资源,而线程B在等待线程A占有的资源

这四大必要条件,只要能够破坏其一,就能避免死锁

  1. 避免互斥条件
  2. 破坏占有切等待,采用线程一次性请求所需的所有资源,没有获得即释放现有的部分资源(比如C++的std::lock同时锁多把锁)
  3. 破坏不可剥夺,如果一个线程得不到所需的资源,应释放它所持有的资源
  4. 破坏环路等待,每个线程按相同的顺序获取资源

破坏环路等待条件很常见,一般开发过程中,只要我们保证资源加锁顺序是一致的,基本都可以避免死锁的发生。

三、计算机网络

1. HTTP 1.0、1.1、2.0、3.0 有什么区别?

  1. HTTP 1.0 到 1.1 的核心升级,解决连接成本问题:

最大的区别是:长链接(Keep-Alive)

HTTP 1.0 是短连接,每次请求都要三次握手、四次挥手,效率极低。HTTP 1.1 默认开启长链接,同一个 TCP 连接可以复用,不用频繁建立连接。

补充:HTTP 1.1 的长连接(Keep-Alive),和 WebSocket 的长连接,完全是两码事

  • HTTP 1.1 的长链接,本质是短连接的有序复用。
  • 客户端发完请求,服务返回响应,不立即关闭TCP连接,而是保持一小段时间,如果这段时间内客户端还有新的请求,就直接服用现成的TCP连接,省区握手开销。但是还必须要按照一问一答的形式(发完一个请求,必须等响应回来,才能发送下一个请求,著名的队头阻塞问题)
    WebSocket的长连接本质是全双工持久连接,这个TCP连接永久保持,连接建立后,没有请求响应概念,服务器可以随时主动往客户端推数据,客户端也可以随时往服务器发数据,两者互不干扰,完全并行。
  1. HTTP 1.1 到 2.0 的核心革命,解决队头阻塞与带宽问题:

    • 多路复用:HTTP 1.1 虽然有长连接,但请求是串行的。如果前面的请求卡住了,后面都得等,这叫做队头阻塞。HTTP 2.0 引入了流的概念,它把一个 TCP 连接拆成了无数个逻辑上的流,每个请求打散成二进制帧乱序发送,到了服务端再组装(物理上还是串行,只是把原来一个一个大任务切碎 + 交叉乱序发送,实现了逻辑上的并行)
    • 二进制分帧:1.x 是纯文本协议,解析慢且容易出错。2.0全面改为二进制格式,解析更快
    • 头部压缩:1.1 的 Header 往往很大,而且每次请求都要重新发,浪费带宽。2.0在客户端和服务端维护一张索引表,重复的 Header 往往只要发一个索引ID,大大减少了传输量
  2. HTTP 2.0 到 HTTP 3.0 最大的区别,不在 HTTP 本身,而是底层的传输层变了,从 TCP 变成了 UDP。

    • 传输层协议不同:
      • HTTP/2:基于 TCP
      • HTTP/3:基于 UDP ,使用 Google 研发的 QUIC 协议,在 UDP 之上自己实现了一套可靠的传输机制,甩掉了TCP的历史包袱
    • 彻底解决队头阻塞:
      • HTTP/2:虽然它把请求拆成了逻辑流,但是 TCP 不知道这些流的存在,一旦丢了一个数据包,TCP 就会暂停所有流的数据,等待重传。
      • HTTP/3:QUIC 是真正独立的,流A 丢包了,只会阻塞流A、流B、流C照样继续,互不影响
    • 建立连接的速度:
      • HTTP/2:需要 TCP 三次握手 + TLS 握手
      • HTTP/3:QUIC 把传输层握手和加密握手合并了(如果之前连接过,客户端会缓存上一次的Token,下次连接时,直接在第一个包里就带上加密后的业务数据发送给服务端)。

2. 常见的 HTTP 状态吗有哪些?

  1. 1xx 信息响应

    • 100 Continue 表示服务器收到了请求的初步部分,客户端可以继续发送请求体
    • 101 Switching Protocols 表示服务器同意切换协议,比如从 HTTP 升级到 WebSocket
  2. 2xx 成功

    • 200 OK 最常见表示请求成功
    • 201 Created 表示资源创建成功(POST 请求创建新用户或新订单时返回这个)
    • 204 No Content 表示请求成功但没有响应体(DELETE 删除资源后通常返回这个)
  3. 3xx 重定向

    • 301 Moved Permanently 表示永久重定向,浏览器会自动更新书签,搜索引擎也会更新
    • 302 Found 表示临时重定向,资源暂时在别的地方,下次还是要访问原来的地址
  4. 4xx 客户端错误

    • 400 Bad Request 表示请求格式有问题(参数不对或JSON格式错了)
    • 401 Unauthorized 表示未认证(没登录或 token 过期了)
    • 403 Forbidden 表示认证过了但是没有权限访问(普通用户访问管理员资源就返回这个)
    • 404 Not Found 表示请求资源不存在
  5. 5xx 服务器错误

    • 500 Internal Server Error 服务端代码异常(比如空指针、抛异常、数据库连接失败)
    • 502 Bad Gateway 说明网关或代理从上游拿到了无效的响应,上游服务挂了或响应格式不对
    • 503 Service Unavailable 表示服务暂时不可用
    • 504 Gateway Timeout 表示网关等待上游响应超时了,上游服务处理太慢

3. HTTP 请求包含哪些内容,请求头和请求体有哪些类型?

HTTP 请求由四部分组成:请求行、请求头、空行、请求体。

  • 请求行:请求方法、资源路径、协议版本。比如 GET /api/users HTTP/1.1
  • 请求头:一堆键值对,告诉服务器客户端的各种信息
  • 空行
  • 请求体:一般在 POST、PUT 这类方法里才有,GET 请求一般没有请求体

几个重要的请求头

  • Host:HTTP/1.1强制要求的头,Host是域名 www.baidu.com
  • Content-Type:告诉服务器请求体是什么格式
  • Accept:告诉服务器客户端想要什么格式的响应
  • User-Agent:标识客户端信息
  • Cookie:带上之前服务器设置的 Cookie,用于会话管理

请求方法的语义:

  • GET 获取资源,不应该对服务器数据产生副作用,所以它是幂等的,请求100次结果应该一样
  • POST 提交数据,比如创建资源,不是幂等的,提交100次可能创建100条记录
  • PUT 更新资源,是幂等的,更新100次和更新1次效果是一样
  • DELETE 删除资源,也是幂等的

幂等的本质就是:不管操作执行多少次,最终产生的副作用(也就是对服务器数据的影响)执行多次和执行一次完全一样。

4. HTTP 和 HTTPS 有什么区别?

  1. 最本质的区别是安全性

HTTP 是明文传输,就会有三大风险

  • 被窃听(账号密码泄漏)
  • 被篡改(网页被植入广告)
  • 被冒充(钓鱼网站)

HTTPS 在 HTTP 下层加入了一个 SSL/TLS 协议。通过加密解决了窃听,通过校验解决篡改,通过数字证书解决了冒充。

  1. 端口

HTTP 默认 80,HTTPS 默认 443。

  1. 搜索引擎态度

现在谷歌、百度都优先收录 HTTPS 网站,如果还是 HTTP,浏览器会提示不安全

5. HTTP 中 GET 和 POST 的区别是什么?

GET 用于获取资源,不应该改变服务器状态;POST 用于提交数据,通常会产生副作用,比如创建或更新资源。

  • 参数传递不同:

    • GET 把参数拼接到 URL 上,长度会收到限制
    • POST 把参数放在请求体中,理论上没有大小限制
      但是它俩都是明文传输。只是 GET 会回显,POST 不会。
  • 幂等性不同:

    • GET 按规范是幂等的,同一个请求发10次结果都是一样的
    • POST 不是幂等的,发10次可能创建10条数据
      但是不是死的,有人拿 GET 搞提交,拿 POST 搞查询,具体看代码逻辑。

6. 什么是 RESTful 风格?

RESTful 是一种 API 设计风格,用 HTTP 协议本身的方法(GET/POST/PUT/DELETE)来表示对资源的操作,URL里只放名词,不放动词。

  • GET 负责获取资源,不会修改资源的状态。是幂等的
  • POST 负责创建资源,向服务器发送新数据,对服务器的数据有副作用,不是幂等的
  • PUT 全量更新,是幂等的
  • PATCH 部分更新,非幂等的
  • DELETE 负责删除,幂等的

7. WebSocket 与 HTTP 有什么区别?

WebSocket 和 HTTP 最本质的区别就在于通信方向。

  • HTTP 是单向的,一问一答的形式,是短连接
  • WebSocket 是双向的,是长链接,双方可以相互发送消息

HTTP 实时性太差了,比如做一个在线的聊天室。WebSocket 就是为了解决这个痛点的,复用了 HTTP 握手流程,第一次请求还是 HTTP,带上 Upgrade: websocket 头告诉服务器要升级协议,当服务器返回 101 状态码,之后就切换为 WebSocket 协议了。

8. 服务端是如何解析 HTTP 请求的数据?

服务端收到的 HTTP 请求就是一段文本,格式是固定的:请求行 + 请求头 + 空行 + 请求体。服务端按这个格式逐行解析。

  1. 先读第一行,就是请求行,按空格切分得到请求方法、资源路径、协议版本

    • 资源路径需要考虑是否有查询字符串,以及是访问动态资源还是静态资源,如果是静态的中的 /,需要加上 /index.html
  2. 接着逐行读取请求头,每行都是 key: value 格式,遇到空行就停

    • Content-Type 告诉请求体是什么格式
    • Content-Length 告诉请求体有多大
  3. 空格之后就是请求体了,服务端根据 Content-Length 读指定长度的数据(解决粘包问题),再根据 Content-Type 决定怎么解析

  4. 处理完请求生成响应,响应格式也是固定的:响应行 + 响应头 + 空行 + 响应正文

但是解析 HTTP 请求时,始终要回答需要注意的问题是,读到不是一个完整的报文怎么办?

可以通过保存当前解析到的数据 + 当前解析到哪部分的标识位解决。

9. 什么是 TCP 连接?

TCP 是传输层协议,它给应用层提供可靠的、面向连接的、字节流服务。简单说,就是它保证数据靠谱的从一台机器传到另一台机器,不会丢、不乱序、不重复。

核心特点:

  1. 面向连接,通信前必须建立连接(三次握手),通信后需要释放连接(四次挥手)
  2. 可靠传输,通过序列号、ACK确认应答、超时重传这些机制保证数据不丢、不重、不乱序
  3. 流量控制,通过滑动窗口告诉发送完我还能接收多少
  4. 拥塞控制,通过慢启动、拥塞避免、快速重传,防止把整个网络塞爆
  5. 全双工通信,双方可以同时发送和接收数据

TCP 解决的核心问题是:在不可靠的 IP 网络层之上实现可靠传输。

IP 层只管把包发出去,丢了不管、乱序不管、重复不管。

10. TCP 和 UDP 有什么区别?

TCP 是面向连接、可靠的流协议,它保证数据不丢、不乱、不错,适合对准确性要求高的场景,如传文件、浏览网页。

UDP 是无连接、不可靠的数据包协议,它不保证可靠,适合对实时性要求高,能容忍少量丢包的场景,如游戏、通话。

  • 面向连接,无连接
  • 可靠、不可靠
  • 面向字节流、用户数据报

11. 什么是 TCP 的粘包?

粘包是 TCP 面向字节流特性带来的问题。

粘包就是发送方发了多个消息,接收一次收到时粘在一起了,分不清楚哪到哪是一条消息。

产生的原因:

  1. 面向字节流,没有消息边界的概念,发送方发送10次,接收方可以1次接收到10次的数据
  2. Nagle 算法会把多个小包攒在一起发,省带宽但会粘包
  3. MTU 限制,超过网络最大传输单元的包会被 IP 层分片

解决方案:

  1. 定长消息,规定每条消息固定长度
  2. 分隔符,用特殊字符分割消息
  3. 长度字段 + 内容,消息头部带长度字段,告诉接收方后面有多少字节

为什么 UDP 没有粘包问题?

因为 UDP 是面向数据报的协议,每个 UDP 数据报都是独立的,有明确的边界。UDP 报头中有 16 位长度字段。

TCP 报头没有长度字段。

12. 说说 TCP 的三次握手?

TCP 三次握手就是客户端和服务器通过发送和确认三个包,保证双方的收发能力正常,且会同步初始化的序列号,建立可靠连接的过程。

  1. 第一次握手(客户端 -> 服务器)

客户端发送一个 SYN 报文,并携带一个随机生成的初始化序列号。
此时客户端处于 SYN_SENT(同步已发送)状态。

  1. 第二次握手(服务器 -> 客户端)

服务端收到 SYN 后,回发一个 SYN + ACK 报文。确认号是客户端的序列号 + 1,同时自己也生成一个初始化序列号。
此时服务端处于 SYN_RCVD(同步收到)状态。

  1. 第三次握手(客户端 -> 服务端)

客户端收到 SYN + ACK 后,发送一个 ACK 报文。确认号是服务端序列号 + 1。
此时客户端进入 ESTABLISHED (已建立连接)状态。服务端收到 ACK 后,也进入 ESTABLISTED 状态,连接正式建立。

为什么需要三次握手?

  1. 确认双方的收发能力
  2. 防止历史连接的建立,减少通信双方不必要的资源消耗
  3. 帮助通信双方同步初始化序列号

13. TCP 三次握手时,发送 SYN 之后就宕机了会怎么样?

如果客户端发送 SYN 后宕机,服务端会因为收不到 ACK 而不断超市重传 SYN + ACK 报文,直到达到最大重试次数(5次)后,就会断开这个半连接。

指数退避重传:重传采用指数退避策略:1s、2s、…

14. TCP 协议是如何保证可靠传输的?

具体来说有这么几个关键机制:

  1. 序列号和确认序列号:TCP 给每个字节都编了号,接收方收到数据后回复 ACK 告诉发送方,我收到了第 N 个字节之前的所有数据,发送方就知道哪些数据到达了,哪些可能丢了。
  2. 超时重传:发送方发完数据会启动一个计时器,如果超时还没收到 ACK,就认为数据丢了,直接重发。超时时间不是写死的,会根据网络动态调整。
  3. 滑动窗口做流量控制:接收方会告诉发送方,我的缓冲区还能装多少字节,发送完不能超过这个量的数据,避免把接收方撑爆。
  4. 拥塞控制:发送方维护一个拥塞窗口,通过慢启动、拥塞避免、快速重传、快速恢复这套算法,探测网络承载能力。
  5. 连接管理:三次握手确保双方都准备好了再传数据,四次挥手确保数据传完了再断开,TIME_WAIT 确保网络残留旧报文不会干扰新链接。

15. 说说 TCP 的四次挥手?

  1. 第一次挥手(客户端说:我没数据发了):客户端发送一个 FIN 包,并停止发送数据,进入 FIN_WAIT_1 状态。服务器收到 FIN 后,表示不再接收数据,但仍可能继续发送数据。
  2. 第二次挥手(服务端说:我知道了,你等会儿):服务端收到 FIN,回一个 ACK 包。此时服务器进入 CLOSE_WAIT(关闭等待状态),客户端收到后进入 FIN_WAIT_2 状态。
  3. 第三次挥手(服务端说:我也没数据发了):服务端的数据也发完了,它也发送一个 FIN 包给客户端,进入 LAST_ACK (最后确认)状态。
  4. 第四次挥手(客户端说:好的,再见):客户端收到 FIN,回一个 ACK 包。此时客户端进入 TIME_WAIT 状态,等待 2MSL(防止 ACK 丢失) 时间后才真正关闭。服务端一收到这个 ACK,就立马关闭连接。
为什么建立连接是三次,断开却是四次?

其实建立连接也是四次,只不过服务器的职责就是接收客户端的连接,所以通过捎带应答的方式,把 SYN 和 ACK 合并成一起发送回去了。

断开不一样,客户端发 FIN 只能代表客户端没数据了,服务端收到后,可能服务端还有数据没发送完毕,只有当服务端也没数据发送了,才会发送 FIN,所以是四次。

但是挥手不一定需要四次,也可以三次,如果服务端收到客户端的 FIN 时,恰好也没数据发送了,就也可以通过捎带应答的方式合并成一个包。
FIN_WAIT_2 状态会一只等下去吗?如果服务端一直不发 FIN 怎么办?
  • 不会无限等。这个状态有超时时间,默认是60秒,这种情况一般是服务端程序有 bug,比如没有正确调用 close。
CLOSE_WAIT 状态积压很多是什么原因?
  • 典型的服务端 bug,服务端收到了客户端的 FIN 并回了 ACK,但是应用层没有调用 close() 关闭。
主动关闭方一定是客户端吗?
  • 不一定。TCP 连接是对等的,谁先调用 close() 谁就是主动关闭方。服务端检测客户端超时不发送数据,或者主动关掉空闲连接,也是服务端主动关闭。

16. 为什么 TCP 挥手需要有 TIME_WAIT 状态?

TIME_WAIT 是主动关闭连接的一方在发送完最后一个 ACK 后进入的状态。

主要作用有两个:

  1. 为了确保连接可靠关闭:客户端发了最后一个 ACK,如果这个 ACK 在网络中丢了。服务端收不到 ACK,会以为客户端没收到自己的 FIN,于是服务端会重发 FIN。
  2. 为了防止旧数据干扰新连接:旧连接关闭后,网络里可能还有一些迟到的旧数据报在网络中传输(因为网络是很复杂的,什么现象都可能发生)。如果上一个连接刚断,立马在原来的 IP 和端口上建立一个新链接。可能会导致意想不到的问题。

为什么 TIME_WAIT 等待的是 2MSL?

MSL 是 TCP 报文在网络中可以存活的最大时间。正好是一来一回的路程:

  • 去程:我发的 ACK 包,最长可能在路上跑 1 MSL
  • 回程:如果对方没收到 ACK,重发的 FIN 包,最长可能也在路上跑 1 MSL

17. 什么是 ARP?

ARP 是把 IP 地址转换为 MAC 地址。为什么需要这个?因为 IP 只是逻辑地址,数据包在局域网里最终靠 MAC 地址送到具体网卡的。

ARP 工作流程:

假设在一个局域网内,主机A要给主机B发数据,A知道B的IP地址,但不知道MAC:

  1. A先查本地的ARP缓存表,看有没有B的IP对应的MAC记录,有的话直接使用,没有进入下一步
  2. A发一个ARP请求广播包,谁的IP地址是xxx,把你的MAC告诉我,这个包会发到局域网内的所有设备
  3. B收到广播发现是在问自己,就给A进行回复,其他局域网的设备收到后发现自己不是会直接丢弃
  4. A收到响应后,把这个IP-MAC映射到ARP缓存表中

18. OSI 七层模型是什么?

OSI 七层模型是国际标准化组织定义的,把网络通信拆分成七层,每层只管自己的事。

从下往上依此是:

  1. 物理层:最底层,管的是比特流怎么在物理介质上传输。网线、光纤、无限电波这些都是物理层的东西
  2. 数据链路层:同一个子网内,一个主机怎么把数据交给另一个主机,ARP协议、以太网在这一层
  3. 网络层:跨网络的数据传输,IP协议在这层
  4. 传输层:提供端到端的通信。TCP 提供可靠传输,UDP提供快速但不保证可靠的传输
  5. 会话层:管理通信会话的建立
  6. 表示层:序列化反序列化、加密解密,SSL/TLS的加密功能在这层
  7. 应用层:面向用户,处理具体的业务。HTTP、SMTP、DNS 这些都是应用层协议

为什么要分层:

  1. 分层的好处是解耦。每层只需要关心自己这层的职责,这样改动某一处的实现不会影响其他层
  2. 另一个好处是标准化。各厂商按照同一套标准实现,设备才能互通,都需要遵守 OSI 标准

封装:数据从应用层往下走,没经过一层就加一个报头,这个过程叫封装。
解包:就是封装的逆过程。
分用:就是告诉上层,这个数据包是发送给上层哪个具体协议的。

封装:

19. TCP/IP 四层模型是什么?

从下到上:数据链路层、网络层、传输层、应用层。

主要是将会话层、表示层、应用层合并为了应用层。

OSI是理论标准,TCP/IP是工程实践。

20. TCP 和 IP 分别解决了什么问题?为什么不放在同一层?

IP 解决的是让数据报能从源主机到达目标主机。TCP 解决的是可靠性问题,保证数据完成、不重、不漏。TCP 弥补了 IP 不可靠的问题。

不放在同一层还是为了解耦和,每层之间不会相互影响。

为什么有了 IP 还需要 MAC 地址?

IP 地址只是逻辑地址,用来跨网络寻址,告诉数据报最终要去哪。MAC 是物理地址,用来在同一个局域网中找到具体设备。
路由器只能把包送到目标网络(引导方向),但是在网络内部找具体机器还是靠 MAC 地址(每一跳都是ARP过程)

21. Cookie、Session、Token 之间有什么区别?

Cookie、Session、Token 都是用来做身份识别和状态管理的,核心区别在于存储位置和使用场景。

  • Cookie 是存在浏览器本地的一小段数据,每次请求会自动带上
  • Session 是把用户状态存在服务器,只给客户端一个 session_id
  • Token 是一个自包含的凭证,服务端不存数据,验签就能确认身份

22. 从网络角度看,用户从输入网址到网页显示,期间发生了什么?

这是一个从应用层到物理层,再跨网络到达服务器,最后原路返回的过程。核心就是数据的封装和解包。

第一个阶段:准备与打包

  1. 解析 URL,提取协议、域名和资源路径
  2. DNS解析,将域名转换成IP地址
  3. 浏览器通过 TCP 三次握手与服务器建立可靠性连接
  4. 如果是 HTTPS,在 TCP 握手后,还要进行 TLS 握手,确保数据传输安全
  5. 构造 HTTP 请求
  6. 贯穿协议栈(请求序列化、添加传输层报头(源端口、目的端口)、网络层报头(源IP、目的IP)、数据链路层报头(源MAC、目的MAC))

第二个阶段:传输过程

主要是路由转发和ARP过程

  1. 将数据转发给同一网段的路由器
  2. 路由器根据 IP 地址,查找路由表,决定下一跳去哪

路由器会把旧的 MAC 去掉,源 MAC 换成自己的 MAC,目的 MAC 换成下一跳设备。同时 IP 里的 TTL 生存时间会减1。

第三阶段:处理与响应

  1. 服务器收到并解包与分用(去掉 MAC 头、去掉 IP 头、去掉 TCP 头)最终将纯净的 HTTP 数据交给服务器的应用层
  2. 处理与响应,生成 HTTP 响应报文,经历同样的封装过程,原路返回给浏览器
  3. 浏览器收到后,渲染 HTML 代码,解析 DOM 树等

交换机和路由器的区别:

交换机工作在数据链路层,根据 MAC 地址转发,作用范围是局域网内部。路由器工作在IP层,根据 IP 地址转发,作用范围在网络之间。

NAT 作用:

家用设备用的都是私有 IP,这些地址在公网是访问不到的,路由器通过 NAT 将私有 IP 转换成公网 IP,这样几十台内网设备可以共用一个公网 IP 上网。

私有 IP 是可以重复的,公网 IP 是不能重复的,因此公网 IP 是很珍贵的资源,NAT 技术主要为了解决公网IP不够用的问题。但是 NAT 也带来一些问题,比如公网设备无法主动访问内网设备。

四、数据库

1. MySQL 的索引类型有哪些?

从数据结构角度看:

  1. B+ 树索引:最长用的,InnoDB 和 MyISAM 默认都用它。多层平衡树结构,叶子节点用链表串起来,既能快速定位单条记录,又能高效做范围扫描
  2. 哈希索引
  3. 全文索引

从 InnoDB 存储方式看:

  1. 聚簇索引:主键索引就是聚簇索引,叶子节点直接存完整的行数据,数据按主键顺序存储,一张表只能有一个聚簇索引
  2. 非聚簇索引:也叫二级索引,叶子节点只存索引值和主键值。查完二级索引还得拿着主键去聚簇索引中再查一次,这个过程叫做回表

从索引性质看:

  1. 主键索引:唯一且非空,每张表只能有一个。InnoDB 里主键索引就是聚簇索引
  2. 唯一索引:保证列值不重复,允许有NULL,可以有多个NULL
  3. 普通索引:没有唯一约束,纯粹为了加速查询

2. MySQL InnoDB 中的聚簇索引和非聚簇索引有什么区别?

聚簇索引:主键索引就是聚簇索引,叶子节点直接存完整的行数据,数据按主键顺序存储,一张表只能有一个聚簇索引。
非聚簇索引:也叫二级索引,叶子节点只存索引值和主键值。查完二级索引还得拿着主键去聚簇索引中再查一次,这个过程叫做回表。

核心区别就是叶子节点存的东西不一样:聚簇索引存完整数据,非聚簇索引存主键值和列值,这会是非簇聚索引可能多了一部回表操作。

主键索引的 B+ 树结构:

  1. 非叶子节点存储主键和页号,用来快速定位叶子节点
  2. 叶子节点存储完整的数据行
  3. 叶子节点之间有双向链表链接,方便范围查询

二级索引的 B+ 树结构:

和主键索引的区别就在叶子节点。非聚簇索引的叶子节点只存索引列的值和主键值,不存完整的数据行。

2.1 为什么主键要有序?

因为如果主键无序,新插入的记录可能要塞到页中间,容量满了就会触发页分裂,性能很差。所以主键最好是自增的,这样新数据永远追加到末尾,不会产生页分裂。

2.2 回表的代价?

回表是有代价的,因为聚簇索引和非聚簇索引是两棵独立的 B+ 树,回表就意味着要额外走一遍 B+ 树查找。如果查询返回大量数据,每条都要回表,可能产生大量随机 I/O,性能急剧下降。

2.3 如果一张表没有主键,InnoDB 怎么处理聚簇索引?

InnoDB 会找第一个非空的唯一索引作为聚簇索引。如果也没有,InnoDB 会自动生成一个隐藏列作为簇聚索引,但还是最好显示指定主键。

2.4 MySQL 的覆盖索引是什么?

覆盖索引是指二级索引中已经包含了查询需要的所有字段,查询时直接从索引里拿数据,不用再回表去主键索引取完整数据行。

3. 为什么 MySQL 选择使用 B+ 树作为索引结构?

核心原因:磁盘 IO 次数少。数据库的数据存储在磁盘上,磁盘要比内存慢,所以索引的设计目标就是尽量减少磁盘访问次数。

B+树的特性:

  1. 树矮。B+ 树是多叉树,10亿数据只需要三次IO即可
  2. 非叶子节点不存数据,存索引
  3. 叶子节点使用双向链表串起来,支持范围查询

4. MySQL 事务?

事务就是一组不可分割的操作序列,要么全部执行成功,要么全部执行失败。

事务的四大特性:

  1. 原子性:一个事务中的所有操作,要么全部完成,要么全部不完成
  2. 一致性:事务执行完后,操作是可预期的
  3. 隔离性:多个事务并发执行时,应该是相互隔离的,互不干扰。隔离性又存在四种隔离级别
  4. 持久性:事务处理提交后,对数据的修改是永久的

事务的隔离级别(从低到高):

  1. 读未提交:事务能看到别的事务还没有提交的数据,相当于没有隔离型。典型问题是脏读
  2. 读提交:只能看到其他事务已经提交的数据,脏读问题没有了,但同一个事务里两次查同一条数据,结果可能不一样,这是不可重复读
  3. 可重复读:事务开始后,就像给当前数据库拍了一张快照,在多次执行查询时,看到的是同一个数据。(MySQL默认的隔离级别)
  4. 串行化:最高的隔离级别,相当于事务一个一个执行

脏读、不可重复读和幻读分别是什么?

这三个都是并发事务带来的数据一致性问题

  1. 脏读:读到了别的事物还没有提交的数据,万一事务事务回滚了,读到的数据压根不存在
  2. 不可重复读:同一个事务两次读同一个数据,结果不一样,因为中间有其他事务改了这行数据并提交了,强调的是数据内容变了
  3. 幻读:同一个事务里两次执行同样的范围查询,返回的行数不一样。因为中间有别的事物插入或删除了符合条件的数据,强调数据行变了
Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐