Skip to content

阿里智能信息部C++研发一面

1、手撕:写一个myString,实现构造,析构,拷贝构造,移动构造。

cpp
#include <iostream>
#include <cstring>
#include <algorithm>

class myString {
private:
    char* data; // 存储字符串数据的指针
    size_t length; // 字符串长度

public:
    // 默认构造函数
    myString() : data(nullptr), length(0) {
        std::cout << "Default Constructor called.\n";
    }

    // 带参构造函数 (char*)
    myString(const char* s) {
        std::cout << "Parameterized Constructor (char*) called.\n";
        if (s) {
            length = strlen(s);
            data = new char[length + 1];
            strcpy(data, s);
        } else {
            data = nullptr;
            length = 0;
        }
    }

    // 析构函数
    ~myString() {
        std::cout << "Destructor called for: ";
        if (data) {
            std::cout << data;
            delete[] data;
            data = nullptr;
        }
        std::cout << "\n";
    }

    // 拷贝构造函数 (深拷贝)
    myString(const myString& other) {
        std::cout << "Copy Constructor called for: " << (other.data ? other.data : "(empty)") << "\n";
        length = other.length;
        if (other.data) {
            data = new char[length + 1];
            strcpy(data, other.data);
        } else {
            data = nullptr;
        }
    }

    // 拷贝赋值运算符 (深拷贝,提供强异常安全保证)
    myString& operator=(const myString& other) {
        std::cout << "Copy Assignment Operator called for: " << (other.data ? other.data : "(empty)") << "\n";
        if (this != &other) { // 防止自我赋值
            // 创建临时对象,利用其构造函数进行深拷贝
            myString temp(other);
            // 交换资源,利用临时对象的析构函数释放旧资源
            std::swap(data, temp.data);
            std::swap(length, temp.length);
        }
        return *this;
    }

    // 移动构造函数 (C++11, 浅拷贝,接管资源)
    myString(myString&& other) noexcept {
        std::cout << "Move Constructor called for: " << (other.data ? other.data : "(empty)") << "\n";
        data = other.data;
        length = other.length;
        other.data = nullptr; // 将源对象置空,防止二次释放
        other.length = 0;
    }

    // 移动赋值运算符 (C++11, 浅拷贝,接管资源)
    myString& operator=(myString&& other) noexcept {
        std::cout << "Move Assignment Operator called for: " << (other.data ? other.data : "(empty)") << "\n";
        if (this != &other) { // 防止自我赋值
            delete[] data; // 释放当前对象的资源
            data = other.data;
            length = other.length;
            other.data = nullptr; // 将源对象置空
            other.length = 0;
        }
        return *this;
    }

    // 获取字符串长度
    size_t size() const { return length; }

    // 获取C风格字符串
    const char* c_str() const { return data ? data : ""; }

    // 打印字符串
    void print() const {
        std::cout << (data ? data : "(empty)") << " (Length: " << length << ")\n";
    }
};

int main() {
    std::cout << "--- Test Default Constructor ---\n";
    myString s0;
    s0.print();

    std::cout << "\n--- Test Parameterized Constructor ---\n";
    myString s1("Hello");
    s1.print();

    std::cout << "\n--- Test Copy Constructor ---\n";
    myString s2 = s1; // 调用拷贝构造函数
    s2.print();

    std::cout << "\n--- Test Copy Assignment Operator ---\n";
    myString s3;
    s3 = s1; // 调用拷贝赋值运算符
    s3.print();

    std::cout << "\n--- Test Move Constructor ---\n";
    myString s4 = std::move(s1); // 调用移动构造函数,s1资源被转移
    s4.print();
    s1.print(); // s1现在应该是空的

    std::cout << "\n--- Test Move Assignment Operator ---\n";
    myString s5("World");
    s5 = std::move(s2); // 调用移动赋值运算符,s2资源被转移
    s5.print();
    s2.print(); // s2现在应该是空的

    std::cout << "\n--- End of Main ---\n";
    return 0;
}

原理与细节:

  • 默认构造函数: 初始化 datanullptrlength0,表示一个空字符串。

  • 带参构造函数 (char*): 接收一个C风格字符串,为其动态分配内存,并进行深拷贝。如果传入 nullptr,则初始化为空字符串。

  • 析构函数: 负责释放 data 指向的动态分配内存,防止内存泄漏。在释放后将 data 置为 nullptr 是一个好习惯。

  • 拷贝构造函数: 当使用一个 myString 对象初始化另一个 myString 对象时调用(例如 myString s2 = s1;)。

    它必须进行深拷贝,即为新对象分配独立的内存空间,并将源对象的内容复制过来,而不是简单地复制指针。否则,两个对象会指向同一块内存,导致双重释放或数据损坏。

  • 拷贝赋值运算符 (operator=): 当一个 myString 对象赋值给另一个已存在的 myString 对象时调用(例如 s3 = s1;)。

    它也必须进行深拷贝。为了提供强异常安全保证(Strong Exception Safety),通常采用“copy-and-swap”惯用法:先创建一个源对象的临时副本(这会调用拷贝构造函数),然后将当前对象的资源与临时对象的资源进行交换。这样,如果拷贝过程中发生异常,原对象保持不变;如果拷贝成功,旧资源会在临时对象析构时自动释放。

  • 移动构造函数 (C++11 myString(myString&& other) noexcept): 当使用一个右值(通常是临时对象或经过 std::move 转换的对象)初始化另一个 myString 对象时调用。它执行浅拷贝,直接“窃取”源对象的资源(将源对象的 data 指针和 length 赋给新对象),然后将源对象的 data 指针置为 nullptrlength 置为 0。这样源对象就变成了“空”状态,其析构函数将不再释放资源。noexcept 关键字表示此函数不会抛出异常,这对于移动操作的优化至关重要。

  • 移动赋值运算符 (C++11 myString& operator=(myString&& other) noexcept): 当一个右值赋值给另一个已存在的 myString 对象时调用。它首先释放当前对象的资源,然后“窃取”源对象的资源,并将源对象置空。同样,noexcept 也是推荐的。

深拷贝与浅拷贝:

  • 浅拷贝: 仅仅复制指针,两个对象共享同一块内存资源。当其中一个对象修改资源或被析构时,可能导致另一个对象出现问题。
  • 深拷贝: 为新对象分配独立的内存,并将源对象的内容复制到新内存中。两个对象拥有独立的资源,互不影响。

“大三法则”与“大五法则”:

在C++98中,如果类管理着资源(如动态内存),通常需要自定义析构函数、拷贝构造函数和拷贝赋值运算符,这被称为**“大三法则”**。

C++11引入移动语义后,如果类需要支持高效的资源转移,还需要自定义移动构造函数和移动赋值运算符,这被称为**“大五法则”**。

如果一个类需要自定义这五个特殊成员函数中的任何一个,通常意味着它需要自定义所有这五个。

2、const可以用来修饰哪些东西,如果修饰成员函数表示什么?

const 是C++中一个非常重要的关键字,用于表示“常量”或“不可修改”。它可以修饰变量、指针、引用、函数参数、函数返回值以及类的成员函数。

const 可以修饰的东西:

  1. 变量:

    • const int a = 10;a 是一个常量整数,其值不能被修改。
    • int const b = 20;:与 const int b = 20; 相同,const 在类型名之后或之前都可以。
  2. 指针:

    • 指向常量的指针 (const int* p;): 指针 p 指向的值是常量,不能通过 p 修改 *p。但指针 p 本身可以指向其他地址。
      cpp
      const int a = 10;
      int b = 20;
      const int* p = &a; // p指向常量a,不能通过p修改a
      // *p = 100; // 错误:不能修改常量
      p = &b;       // 正确:p可以指向其他非const变量
      // *p = 200; // 错误:仍然不能通过p修改b
    • 常量指针 (int* const p;): 指针 p 本身是常量,一旦初始化就不能再指向其他地址。但 p 指向的值可以被修改。
      cpp
      int a = 10;
      int* const p = &a; // p是常量,必须初始化
      *p = 100;       // 正确:可以通过p修改a的值
      // p = nullptr;  // 错误:p是常量,不能改变指向
    • 指向常量的常量指针 (const int* const p;): 指针 p 及其指向的值都是常量,都不能被修改。
      cpp
      const int a = 10;
      const int* const p = &a; // p和*p都是常量
      // *p = 100; // 错误
      // p = nullptr; // 错误
  3. 引用:

    常量引用 (const int& r;): 引用 r 绑定到一个常量,不能通过 r 修改所引用的值。常量引用可以绑定到常量对象或非常量对象,甚至临时对象。这是C++中一个非常强大的特性,常用于函数参数传递,避免不必要的拷贝。

    cpp
    const int a = 10;
    int b = 20;
    const int& r1 = a; // 正确,r1引用常量a
    const int& r2 = b; // 正确,r2引用非常量b
    // r1 = 100; // 错误:不能通过r1修改a
    // r2 = 200; // 错误:不能通过r2修改b
  4. 函数参数:

    • void func(const int param);:参数 param 在函数内部不能被修改。对于基本类型,这通常是多余的,因为参数是按值传递的副本。
    • void func(const MyClass& obj);:参数 obj 是一个常量引用,函数内部不能通过 obj 修改传入的对象。这是传递大对象时最常用的方式,既避免了拷贝,又保证了对象的安全性。
    • void func(const char* str);:参数 str 是指向常量的指针,函数内部不能通过 str 修改字符串内容。
  5. 函数返回值:

    • const int get_value();:返回一个常量值。对于基本类型,意义不大。对于返回自定义类型对象,表示返回的对象是常量,不能被修改。
    • const MyClass& get_obj();:返回一个常量引用。表示返回的对象不能被修改。

const 修饰成员函数表示什么?

const 修饰类的成员函数时,它表示该成员函数是一个常量成员函数

其含义和作用是:

  1. 不能修改类的非静态成员变量: 在常量成员函数内部,不能修改任何非静态的成员变量(除了 mutable 关键字修饰的成员变量)。这是 const 成员函数最核心的约束。
  2. 不能调用非 const 成员函数: 在常量成员函数内部,只能调用其他常量成员函数,不能调用非 const 成员函数,因为非 const 成员函数可能会修改对象的状态。
  3. 可以被 const 对象和非 const 对象调用: 常量成员函数可以被 const 类型的对象和非 const 类型的对象调用。
  4. const 对象只能调用非 const 成员函数: 这是一个误解,非 const 对象可以调用 const 成员函数和非 const 成员函数。但是 const 对象只能调用 const 成员函数。

示例:

cpp
class Point {
private:
    int x_;
    int y_;

public:
    Point(int x, int y) : x_(x), y_(y) {}

    // 常量成员函数:不能修改成员变量
    void print() const {
        // x_ = 10; // 错误:不能修改x_,因为print是const函数
        std::cout << "(" << x_ << ", " << y_ << ")\n";
    }

    // 非常量成员函数:可以修改成员变量
    void move(int dx, int dy) {
        x_ += dx;
        y_ += dy;
    }

    // 常量成员函数可以调用另一个常量成员函数
    void display_info() const {
        print(); // 正确:print() 是 const 函数
        // move(1, 1); // 错误:move() 是非 const 函数
    }
};

int main() {
    Point p1(1, 2);
    p1.print(); // 非const对象调用const函数,正确
    p1.move(3, 4); // 非const对象调用非const函数,正确
    p1.print();

    const Point p2(5, 6);
    p2.print(); // const对象调用const函数,正确
    // p2.move(1, 1); // 错误:const对象不能调用非const函数

    return 0;
}

const 成员函数的作用:

  • 提高代码的健壮性: 明确表示函数不会修改对象的状态,有助于编译器检查错误,避免意外修改。
  • 支持 const 对象: 只有 const 成员函数才能被 const 对象调用,这使得 const 对象能够使用其只读功能。
  • 更好的接口设计: 通过 const 关键字,可以向使用者清晰地表达函数的行为,哪些函数是只读的,哪些是可修改的。

3、引用和指针的区别?

特性/维度引用指针
本质别名(Alias),是已存在对象的另一个名字存储内存地址的变量
空值不可为空,必须引用一个合法的对象可以为空(nullptr),表示不指向任何对象
初始化必须在定义时初始化,且一旦初始化不能改变引用对象可以在定义时不初始化,可以随时改变指向的对象
可变性一旦引用一个对象,就不能再引用其他对象(引用本身不可变)可以重新赋值,指向不同的对象(指针本身可变)
操作直接使用,无需解引用操作符 *需要解引用操作符 * 来访问所指向的对象,-> 访问成员
内存通常不占用独立内存(编译器优化),或占用少量内存(与指针大小相同)占用独立内存空间,大小通常为4或8字节(取决于系统架构)
算术运算不支持引用算术运算支持指针算术运算(如 p++, p+n
安全性相对安全,没有空引用,没有野引用相对不安全,可能出现空指针、野指针、悬垂指针等问题
多级不支持多级引用(如 int&& 并非二级引用)支持多级指针(如 int**
函数参数常用于函数参数传递,实现参数的按引用传递,避免拷贝常用于函数参数传递,实现参数的按地址传递,可修改原对象,也可传递空值

示例:

cpp
#include <iostream>

int main() {
    int a = 10;

    // 指针
    int* ptr = &a; // 指针ptr指向a的地址
    std::cout << "Pointer value: " << *ptr << "\n"; // 解引用访问a的值
    *ptr = 20; // 通过指针修改a的值
    std::cout << "a after ptr modification: " << a << "\n";
    int b = 30;
    ptr = &b; // 指针可以重新指向其他变量
    std::cout << "Pointer now points to b: " << *ptr << "\n";

    // 引用
    int& ref = a; // 引用ref是a的别名,必须初始化
    std::cout << "Reference value: " << ref << "\n"; // 直接使用,无需解引用
    ref = 40; // 通过引用修改a的值
    std::cout << "a after ref modification: " << a << "\n";
    // int c = 50;
    // ref = c; // 错误:引用一旦初始化不能改变引用对象,这行代码是把c的值赋给a,而不是让ref引用c

    // 空指针
    int* null_ptr = nullptr; // 指针可以为空
    // int& null_ref; // 错误:引用不能为空,必须初始化

    return 0;
}

总结:

引用可以看作是“受限制的指针”,它在设计上更加安全和简洁,通常用于函数参数传递以避免拷贝和在函数内部修改外部变量。指针则更加灵活和强大,可以进行算术运算,可以指向空,但同时也带来了更多的安全风险,需要程序员更谨慎地管理。

4、如何避免野指针、内存泄漏的问题?

野指针和内存泄漏是C++编程中常见的内存管理问题,它们可能导致程序崩溃、数据损坏或系统资源耗尽。有效避免这些问题需要良好的编程习惯、内存管理策略和工具。

4.1、避免野指针

野指针是指向无效内存区域(如已释放的内存、未初始化的内存、超出变量作用域的内存)的指针。访问野指针会导致未定义行为,通常是程序崩溃。

避免野指针的方法:

  1. 初始化指针: 在定义指针时就将其初始化为 nullptr(C++11及以后)或 NULL(C++98),确保指针有一个明确的初始状态。
    cpp
    int* p = nullptr; // 总是初始化指针
  2. 释放内存后将指针置为 nullptr: 当 deletefree 释放了指针所指向的内存后,应立即将该指针置为 nullptr,防止它成为悬垂指针(Dangling Pointer)。
    cpp
    int* p = new int(10);
    // ... 使用 p ...
    delete p;
    p = nullptr; // 避免悬垂指针
  3. 避免返回局部变量的地址或引用: 局部变量在函数返回后会被销毁,如果返回其地址或引用,外部的指针或引用将成为野指针/野引用。
    cpp
    int* create_local_int() {
        int local_var = 10;
        // return &local_var; // 错误:返回局部变量的地址,local_var在函数返回后被销毁
        return new int(local_var); // 正确:返回动态分配的内存地址
    }
  4. 智能指针: 使用智能指针(如 std::unique_ptr, std::shared_ptr)是避免野指针最有效的方法之一。它们通过RAII(Resource Acquisition Is Initialization)机制自动管理内存,当智能指针超出作用域时,会自动释放其管理的内存,并将其内部指针置为 nullptr
  5. 避免多次释放同一块内存: 多次 delete 同一个指针会导致未定义行为,可能损坏堆结构,从而产生野指针。
    cpp
    int* p = new int(10);
    delete p;
    // delete p; // 错误:重复释放
    p = nullptr; // 避免重复释放,因为delete nullptr是安全的
  6. 作用域管理: 确保指针的使用范围不超过其所指向内存的生命周期。

4.2、避免内存泄漏

内存泄漏是指程序在申请内存后,无法释放已申请的内存空间,导致系统内存的浪费,最终可能耗尽系统资源。这通常发生在动态分配的内存没有被正确 deletefree 的情况下。

避免内存泄漏的方法:

  1. 配对使用 newdelete (或 mallocfree): 每次使用 new 分配内存后,都必须有对应的 delete 来释放。对于数组,使用 new[] 分配内存,必须使用 delete[] 释放。
    cpp
    int* arr = new int[10];
    // ... 使用 arr ...
    delete[] arr; // 对应 new[]
    arr = nullptr;
  2. RAII (Resource Acquisition Is Initialization): 这是C++中管理资源的核心思想。将资源的生命周期与对象的生命周期绑定。当对象被创建时获取资源,当对象被销毁时(无论是正常退出作用域还是发生异常),自动释放资源。智能指针就是RAII的典型应用。
  3. 智能指针: 如前所述,智能指针是防止内存泄漏的利器。它们在对象销毁时自动调用 delete(或自定义删除器),确保内存被释放。
    • std::unique_ptr:独占所有权,确保资源只被一个指针管理。
    • std::shared_ptr:共享所有权,通过引用计数管理资源,当最后一个 shared_ptr 销毁时释放资源。
    • std::weak_ptr:配合 shared_ptr 解决循环引用问题,不增加引用计数。
  4. 容器: 使用标准库容器(如 std::vector, std::string, std::map 等),它们会自动管理其元素的内存,无需手动 newdelete
  5. 异常安全: 确保在代码抛出异常时,已分配的内存也能被正确释放。RAII和智能指针在这方面表现出色。
  6. 内存泄漏检测工具: 使用Valgrind (Linux)、AddressSanitizer (ASan) 等工具进行内存泄漏检测,它们可以在运行时发现内存错误。

5、智能指针有哪些?

C++11引入了智能指针(Smart Pointers),它们是C++ RAII(Resource Acquisition Is Initialization)思想的典范,用于自动管理动态分配的内存,从而有效避免内存泄漏和野指针问题。C++标准库提供了三种主要的智能指针:std::unique_ptrstd::shared_ptrstd::weak_ptr

5.1、std::unique_ptr (独占所有权指针)

  • 原理: unique_ptr 实现独占式拥有或严格拥有语义,即同一时间只有一个 unique_ptr 可以指向给定的对象。当 unique_ptr 被销毁时,它所指向的对象也会被自动释放。
  • 特点:
    • 独占性: 不可拷贝,但可以通过 std::move 转移所有权。
    • 轻量级: 不涉及引用计数,性能开销与裸指针相近。
    • 自动释放: 当 unique_ptr 超出作用域时,它会自动调用 delete 释放内存。
    • 可自定义删除器: 可以提供自定义的删除函数,用于释放非 new 分配的资源(如文件句柄、网络连接等)。
  • 使用场景:
    • 当对象需要独占式管理时,例如函数内部创建并返回一个新对象,或者作为类的成员变量,确保资源在其生命周期内只被一个所有者管理。
    • 管理C风格的资源(如 FILE*),通过自定义删除器实现RAII。

5.2、std::shared_ptr (共享所有权指针)

  • 原理: shared_ptr 实现共享式拥有语义,允许多个 shared_ptr 共同拥有同一个对象。它通过引用计数(reference count) 来管理对象的生命周期。当最后一个 shared_ptr 被销毁或重置时,如果引用计数归零,它所指向的对象会被自动释放。
  • 特点:
    • 共享性: 可拷贝,每次拷贝都会增加引用计数。
    • 自动释放: 引用计数归零时自动释放内存。
    • 线程安全(引用计数操作): 引用计数的增减是原子操作,保证了在多线程环境下引用计数的正确性,但被管理对象的访问不是线程安全的。
    • 可自定义删除器: 类似于 unique_ptr,也可以自定义删除器。
  • 使用场景:
    • 当多个对象或模块需要共享同一个资源,并且资源的生命周期由这些共享者共同决定时。
    • 在工厂函数中创建对象并返回 shared_ptr

5.3、std::weak_ptr (弱引用指针)

  • 原理: weak_ptr 是一种不控制对象生命周期的智能指针。它指向一个由 shared_ptr 管理的对象,但不会增加对象的引用计数。weak_ptr 可以用来检查 shared_ptr 所管理的对象是否仍然存在。
  • 特点:
    • 不控制生命周期: 不影响引用计数,因此不会阻止 shared_ptr 管理的对象的销毁。
    • 解决循环引用: 主要用于解决 shared_ptr 之间的循环引用问题,避免内存泄漏。
    • 安全性: 不能直接访问所指向的对象,需要通过 lock() 方法获取一个 shared_ptr 才能安全访问。如果对象已被销毁,lock() 会返回一个空的 shared_ptr
  • 使用场景:
    • 解决 shared_ptr 循环引用: 当两个或多个 shared_ptr 相互引用时,会导致引用计数永远无法归零,从而造成内存泄漏。将其中一个引用改为 weak_ptr 即可打破循环。
    • 观察者模式: 观察者持有对被观察者的 weak_ptr,可以在不影响被观察者生命周期的情况下观察其状态。
    • 缓存: 缓存中的对象可能被其他地方持有 shared_ptr,缓存自身可以使用 weak_ptr 来避免阻止对象的销毁。

5.4、std::auto_ptr (已废弃)

std::auto_ptr 是C++98中提供的第一个智能指针。它也实现了独占所有权,但在拷贝时会转移所有权,导致源指针变为空,这带来了很多意外行为和安全隐患。因此,在C++11中已被 std::unique_ptr 取代,并在C++17中被彻底移除,不应再使用。

6、unique_ptr如何转移所有权?

std::unique_ptr 实现的是独占所有权语义,这意味着它不能被拷贝。但是,它可以通过移动语义来转移其所管理对象的所有权。一旦所有权被转移,源 unique_ptr 将变为空(不再管理任何对象),而目标 unique_ptr 将接管对对象的管理。

转移 unique_ptr 所有权主要有两种方式:

  1. 使用 std::move: 这是最常见和推荐的方式。std::move 是一个函数模板,它将一个左值强制转换为右值引用,从而允许调用对象的移动构造函数或移动赋值运算符。

    示例:

    cpp
    #include <iostream>
    #include <memory> // For std::unique_ptr
    #include <utility> // For std::move
    
    class MyClass {
    public:
        MyClass() { std::cout << "MyClass Constructor\n"; }
        ~MyClass() { std::cout << "MyClass Destructor\n"; }
        void doSomething() { std::cout << "MyClass doing something\n"; }
    };
    
    // 函数返回 unique_ptr,所有权被转移给调用者
    std::unique_ptr<MyClass> createMyClass() {
        return std::make_unique<MyClass>(); // C++14 推荐用法
    }
    
    void processMyClass(std::unique_ptr<MyClass> obj) {
        // obj 现在拥有 MyClass 对象的所有权
        obj->doSomething();
        // obj 超出作用域时,MyClass 对象会被自动销毁
    }
    
    int main() {
        std::unique_ptr<MyClass> ptr1 = std::make_unique<MyClass>();
        ptr1->doSomething();
    
        // 方式一:通过 std::move 转移所有权给 ptr2
        std::unique_ptr<MyClass> ptr2 = std::move(ptr1); // 调用移动构造函数
        if (ptr1) {
            std::cout << "ptr1 is not null (unexpected)\n";
        } else {
            std::cout << "ptr1 is now null after move\n";
        }
        ptr2->doSomething();
    
        // 方式二:作为函数参数传递,所有权转移给函数参数 obj
        // 注意:这里需要再次创建一个 unique_ptr,因为 ptr2 的所有权会被转移给 processMyClass 函数
        std::unique_ptr<MyClass> ptr3 = createMyClass();
        processMyClass(std::move(ptr3)); // 调用移动构造函数,所有权转移给 processMyClass 的参数 obj
        if (ptr3) {
            std::cout << "ptr3 is not null (unexpected)\n";
        } else {
            std::cout << "ptr3 is now null after move to function\n";
        }
    
        // 方式三:从函数返回 unique_ptr,所有权转移给接收者
        std::unique_ptr<MyClass> ptr4 = createMyClass(); // createMyClass 返回的 unique_ptr 所有权转移给 ptr4
        ptr4->doSomething();
    
        return 0;
    }
  2. 函数返回 unique_ptr: 当函数返回一个 unique_ptr 对象时,编译器会自动执行移动语义(RVO/NRVO优化),将所有权从函数内部的 unique_ptr 转移给函数外部的接收 unique_ptr。这是一种特殊情况,即使没有显式使用 std::move,编译器也会进行优化。

    示例: 在上面的 createMyClass() 函数中,return std::make_unique<MyClass>(); 会自动进行所有权转移。

7、线程和进程的区别?

特性/维度进程 (Process)线程 (Thread)
资源拥有者操作系统进行资源分配(如内存、文件句柄)的基本单位。每个进程拥有独立的地址空间。进程内部的执行单元,不拥有独立的资源,而是共享所属进程的资源。
调度者操作系统内核操作系统内核
调度方式抢占式调度抢占式调度
独立性独立性强,进程之间相互隔离,一个进程崩溃不影响其他进程。独立性弱,同一进程内的线程共享资源,一个线程崩溃可能导致整个进程崩溃。
开销创建、销毁、切换开销大(涉及页表、TLB、缓存等)。创建、销毁、切换开销小(只涉及寄存器、栈等私有数据)。
内存拥有独立的虚拟地址空间。共享进程的虚拟地址空间(代码段、数据段、堆),但拥有独立的栈。
通信方式进程间通信(IPC)机制复杂,如管道、消息队列、共享内存、套接字等。线程间通信简单,直接通过共享内存、锁、信号量等。
并行能力在多核CPU上可实现真正的并行。在多核CPU上可实现真正的并行。
数量限制数量相对较少,受系统资源限制。数量相对较多,受进程资源限制。
错误影响一个进程的错误通常不会影响其他进程。一个线程的错误可能导致整个进程崩溃。
  • 进程是资源分配的单位,线程是CPU调度的单位。 进程是操作系统分配资源(如内存、文件句柄)的最小单位,而线程是CPU执行的最小单位。
  • 进程拥有独立的地址空间,线程共享进程的地址空间。 这是两者最核心的区别。进程之间内存隔离,安全性高;线程之间共享内存,通信高效但需要同步。
  • 进程切换开销大,线程切换开销小。 进程切换需要切换页表、刷新TLB和CPU缓存,开销大;线程切换只需保存和恢复少量寄存器和栈信息,开销小。

8、线程间和进程间分别是怎么通信?

进程间通信和线程间通信是操作系统中不同粒度并发实体之间交换数据和同步行为的方式。由于进程和线程的资源隔离特性不同,它们的通信机制也大相径庭。

8.1、程间通信 (IPC)

进程之间由于地址空间相互独立,不能直接访问对方的内存,因此需要通过操作系统提供的特定机制进行通信。

常用的IPC机制包括:

  1. 管道:

    • 原理: 管道是半双工的,数据只能单向流动。它是一个内核缓冲区,进程通过读写文件描述符来访问。分为匿名管道(只用于父子进程或兄弟进程)和命名管道(可用于任意无关进程)。
    • 特点: 简单易用,但只能在有亲缘关系的进程间使用匿名管道;命名管道可以用于无亲缘关系进程。
    • 使用场景: 简单的单向数据流传输,如 ls | grep
  2. 消息队列:

    • 原理: 消息队列是存储在内核中的消息链表,允许进程以间接方式发送和接收消息。发送者将消息放入队列,接收者从队列中取出消息。
    • 特点: 消息具有格式,可以独立于发送和接收进程的生命周期而存在(只要内核不重启);可以实现消息的优先级。
    • 使用场景: 客户端-服务器通信、数据包传输。
  3. 共享内存:

    • 原理: 允许不同进程映射同一块物理内存到各自的虚拟地址空间,从而可以直接读写这块内存,实现最快的IPC方式。
    • 特点: 速度最快,因为省去了数据拷贝。但需要额外的同步机制(如信号量、互斥锁)来保证数据一致性,避免竞态条件。
    • 使用场景: 大数据量传输、高性能计算。
  4. 信号量:

    • 原理: 信号量是一个计数器,用于控制多个进程对共享资源的访问。它主要用于进程间的同步,而不是数据传输。通过 P (等待) 和 V (发送信号) 操作来控制资源访问。
    • 特点: 只能用于同步,不能传输复杂数据。
    • 使用场景: 控制共享资源的访问数量,实现互斥或同步。
  5. 套接字:

    • 原理: 套接字是一种通信端点,可以用于同一机器上的进程间通信(Unix域套接字)或不同机器上的进程间通信(网络套接字)。
    • 特点: 最通用的IPC机制,可用于本地和网络通信,支持多种协议(TCP/UDP)。
    • 使用场景: 网络服务、分布式系统。
  6. 文件:

    • 原理: 进程可以通过读写同一个文件来共享数据。通过对文件进行加锁(如 flockfcntl)可以实现同步。
    • 特点: 简单,但效率较低,需要磁盘I/O。
    • 使用场景: 配置信息共享、持久化数据。

8.2、线程间通信

线程之间共享进程的地址空间,因此它们可以直接访问进程的全局变量、堆内存等共享数据。线程间通信主要关注如何安全、高效地访问这些共享数据,避免竞态条件和死锁。

  1. 共享内存:

    • 原理: 线程可以直接读写进程地址空间中的全局变量或动态分配的堆内存。
    • 特点: 最直接、效率最高的数据共享方式。但也是最危险的,必须配合同步机制。
    • 使用场景: 任何需要线程间共享数据的场景。
  2. 互斥锁:

    • 原理: 互斥锁是一种同步原语,用于保护共享资源,确保在任何时刻只有一个线程可以访问被保护的代码段(临界区)。当一个线程获得锁后,其他试图获取该锁的线程将被阻塞,直到锁被释放。
    • 特点: 实现互斥访问,防止竞态条件。
    • 使用场景: 保护共享数据结构、临界区代码。
  3. 读写锁:

    • 原理: 允许多个线程同时读取共享资源,但在写入时只允许一个线程写入。当有写操作时,所有读写操作都会被阻塞。
    • 特点: 适用于读多写少的场景,可以提高并发性。
    • 使用场景: 缓存、配置信息等。
  4. 条件变量:

    • 原理: 条件变量允许线程等待某个特定条件发生。它总是与互斥锁一起使用。当条件不满足时,线程释放互斥锁并进入等待状态;当条件满足时,另一个线程通知等待的线程,使其被唤醒并重新尝试获取互斥锁。
    • 特点: 实现线程间的等待-通知机制。
    • 使用场景: 生产者-消费者模型、线程池任务队列。
  5. 信号量:

    • 原理: 线程信号量与进程信号量类似,用于控制对共享资源的访问数量。可以用于实现互斥(二值信号量)或资源计数。
    • 特点: 灵活,可控制并发访问的数量。
    • 使用场景: 限制同时访问某个资源的线程数量。
  6. 原子操作:

    • 原理: 对于简单的变量操作(如增减、赋值),可以使用原子操作来保证其不可分割性,从而避免使用锁带来的开销。C++11提供了 <atomic> 库。
    • 特点: 性能高,适用于简单数据类型的无锁并发。
    • 使用场景: 计数器、标志位。

9、线程间有哪些锁?

9.1、互斥锁 (Mutex - std::mutex)

  • 原理: 最基本的同步原语。它提供对共享资源的独占访问。当一个线程锁定互斥量时,其他试图锁定该互斥量的线程将被阻塞,直到持有锁的线程释放它。
  • 特点:
    • 独占性: 任何时刻只有一个线程可以持有锁。
    • 阻塞: 未能获得锁的线程会进入阻塞状态。
    • 简单: 易于理解和使用。
  • 使用场景: 保护任何共享数据结构或代码临界区,防止多个线程同时修改。
  • 相关RAII封装: std::lock_guard (简单锁定,自动解锁)、std::unique_lock (更灵活的锁定,可手动解锁、延迟锁定等)。

9.2、递归互斥锁 (Recursive Mutex)

  • 原理: 允许同一个线程多次锁定它所持有的互斥量。每次加锁都需要对应一次解锁。如果一个线程尝试锁定一个已经被它自己持有的 std::mutex,会导致死锁。
  • 特点: 允许递归加锁,解决函数内部多次加锁的场景。
  • 缺点: 性能通常低于 std::mutex;可能掩盖设计问题,通常应避免使用,除非确实需要。
  • 使用场景: 当一个函数内部调用了另一个也需要锁定同一互斥量的函数时。

9.3、读写锁 (Shared Mutex / Read-Write Lock)

  • 原理: 允许多个线程同时进行读操作,但只允许一个线程进行写操作。当有写操作时,所有读写操作都会被阻塞。
  • 特点:
    • 高并发读: 适用于读多写少的场景,可以显著提高并发性能。
    • 写独占: 写操作仍然是独占的。
  • 使用场景: 缓存系统、配置管理、日志系统等,这些场景通常读操作远多于写操作。
  • 相关RAII封装: std::shared_lock (共享读锁)、std::unique_lock (独占写锁)。

9.4、条件变量 (Condition Variable)

  • 原理: 条件变量本身不是锁,它总是与互斥锁一起使用,用于实现线程间的等待-通知机制。一个线程等待某个条件发生,当条件不满足时,它会释放互斥锁并进入等待状态;另一个线程在条件满足时,会通知等待的线程,使其被唤醒并重新尝试获取互斥锁。
  • 特点:
    • 等待/通知: 允许线程在特定条件下暂停执行,并在条件满足时被唤醒。
    • 必须与互斥锁配合: 互斥锁用于保护条件本身以及共享数据。
  • 使用场景: 生产者-消费者模型、线程池任务队列、等待某些特定事件发生。

9.5、自旋锁 (Spinlock)

  • 原理: 当一个线程尝试获取自旋锁时,如果锁已被占用,它不会阻塞或休眠,而是会持续循环检查锁的状态,直到锁可用。它会“忙等”而不是“睡眠”。
  • 特点:
    • 非阻塞: 不涉及上下文切换,在锁持有时间极短的情况下性能优于互斥锁。
    • 忙等: 如果锁持有时间长,会导致CPU空转,浪费CPU资源。
  • 使用场景: 锁持有时间非常短,且上下文切换开销大于忙等开销的场景(如内核态)。在用户态通常不推荐,除非有特殊性能要求且能保证锁持有时间极短。

9.6、原子操作 (Atomic Operations - std::atomic)

  • 原理: 对于简单的变量操作(如增减、赋值、比较并交换),std::atomic 提供了原子性的保证,即这些操作是不可分割的,不会被其他线程中断。这在某些情况下可以避免使用锁,实现无锁编程。
  • 特点:
    • 无锁: 避免了锁带来的开销和死锁风险。
    • 性能高: 对于简单操作,通常比互斥锁更快。
    • 局限性: 仅适用于单个变量的原子操作,复杂数据结构仍需锁。
  • 使用场景: 计数器、标志位、实现无锁队列等。

10、条件变量怎么去通知另一个线程?唤醒之后就能获得锁吗?

条件变量(std::condition_variable)是C++中用于线程间等待-通知机制的同步原语。它总是与互斥锁(std::mutex)一起使用,以保护共享数据和避免竞态条件。

10.1、条件变量如何通知另一个线程?

条件变量通过以下两个主要方法来通知等待的线程:

  1. notify_one(): 唤醒一个(通常是等待时间最长的)正在等待该条件变量的线程。
  2. notify_all(): 唤醒所有正在等待该条件变量的线程。

工作流程:

假设有一个生产者-消费者模型:生产者生产数据,消费者消费数据。如果队列为空,消费者需要等待;如果队列已满,生产者需要等待。

  • 等待线程(消费者):

    1. 获取互斥锁,以保护对共享数据(如队列)的访问。
    2. 检查条件(例如,队列是否为空)。
    3. 如果条件不满足(队列为空),线程调用 wait() 方法。wait() 方法会原子性地释放互斥锁,并将线程放入等待队列中阻塞。当线程被唤醒时,它会重新获取互斥锁。
    4. 当线程被唤醒后,它会再次检查条件(因为可能是虚假唤醒),如果条件满足,则处理数据;否则继续等待。
  • 通知线程(生产者):

    1. 获取互斥锁,以保护对共享数据(如队列)的访问。
    2. 修改共享数据(例如,向队列中添加数据),使等待条件可能满足。
    3. 在释放互斥锁之前或之后(通常在释放锁之前),调用 notify_one()notify_all() 来唤醒一个或所有等待的线程。
    4. 释放互斥锁。

示例代码:

cpp
#include <iostream>
#include <thread>
#include <queue>
#include <mutex>
#include <condition_variable>

std::queue<int> data_queue; // 共享数据队列
std::mutex mtx;             // 互斥锁,保护队列
std::condition_variable cv; // 条件变量
bool finished = false;       // 结束标志

void producer() {
    for (int i = 0; i < 5; ++i) {
        std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 模拟生产时间
        std::unique_lock<std::mutex> lock(mtx); // 1. 获取互斥锁
        data_queue.push(i); // 2. 修改共享数据
        std::cout << "Producer produced: " << i << "\n";
        cv.notify_one(); // 3. 通知一个等待的消费者线程
        // 4. lock_guard 或 unique_lock 会在作用域结束时自动释放锁
    }
    std::unique_lock<std::mutex> lock(mtx);
    finished = true;
    cv.notify_all(); // 生产结束,通知所有等待线程
}

void consumer() {
    while (true) {
        std::unique_lock<std::mutex> lock(mtx); // 1. 获取互斥锁
        // 2. 检查条件:队列为空且生产未结束,则等待
        cv.wait(lock, [] { return !data_queue.empty() || finished; });

        if (finished && data_queue.empty()) { // 3. 条件满足,但已结束且队列为空,退出
            std::cout << "Consumer finished.\n";
            break;
        }

        int data = data_queue.front(); // 4. 处理数据
        data_queue.pop();
        std::cout << "Consumer consumed: " << data << "\n";
        // 5. lock_guard 或 unique_lock 会在作用域结束时自动释放锁
    }
}

int main() {
    std::thread p(producer);
    std::thread c1(consumer);
    std::thread c2(consumer);

    p.join();
    c1.join();
    c2.join();

    return 0;
}

10.2、唤醒之后就能获得锁吗?

不一定。 当一个线程被条件变量唤醒后,它并不能立即获得互斥锁。

wait() 函数的语义是:

  1. 原子性地释放互斥锁,并阻塞当前线程。
  2. 当被 notify_one()notify_all() 唤醒时,线程会重新尝试获取互斥锁
  3. 只有当线程成功获取到互斥锁后,wait() 函数才会返回。

这意味着:

  • 竞争: 如果有多个线程被唤醒,它们会竞争互斥锁。只有一个线程能成功获取锁,其他线程会继续阻塞或等待,直到锁被释放。
  • 虚假唤醒 (Spurious Wakeups): 线程可能在没有收到 notify 信号的情况下被唤醒(例如,操作系统调度器的原因)。因此,在 wait() 返回后,必须再次检查等待的条件是否真的满足。这是为什么 std::condition_variable::wait 通常与 while 循环和 Lambda 表达式一起使用的原因:cv.wait(lock, [] { return condition_is_met; });。这个 Lambda 表达式会在 wait 返回前被检查,如果为 false,线程会再次进入等待状态。

所以,被唤醒的线程需要重新竞争互斥锁,并且在获得锁后,还需要再次检查条件是否满足,才能继续执行其业务逻辑。

11、mmap如何实现通信呢,另一个进程怎么知道这个进程写入了东西呢?

mmap(memory map)是一种内存映射文件或设备的系统调用,它将文件或设备的一部分直接映射到进程的虚拟地址空间。当两个或多个进程将同一个文件或同一块匿名共享内存映射到各自的地址空间时,它们就可以通过读写这块共享内存区域来实现高效的进程间通信(IPC)。

11.1、mmap 实现通信的原理

  1. 创建共享内存区域:
    • 文件映射: 一个进程打开一个文件,然后使用 mmap 将该文件的一部分映射到自己的虚拟地址空间。其他进程也可以打开同一个文件,并将其映射到自己的地址空间。这样,所有映射了同一文件的进程就共享了这块物理内存区域。
    • 匿名映射: 进程可以不关联任何文件,直接使用 mmap 申请一块匿名共享内存(通常通过 MAP_SHARED | MAP_ANONYMOUS 标志)。这种方式通常需要父子进程通过 fork 继承映射区域,或者通过其他IPC机制(如文件描述符传递)将这块内存区域的句柄传递给无关进程。
  2. 读写共享内存: 一旦内存区域被映射,进程就可以像访问普通内存一样,通过指针直接读写这块共享区域。对这块内存的修改,对于所有映射了它的进程都是可见的。

11.2、另一个进程怎么知道这个进程写入了东西呢?

mmap 本身只提供了共享内存的能力,但它不提供任何同步机制。这意味着,如果一个进程写入了数据,另一个进程并不会自动知道数据何时被写入、写入了多少,或者是否已经写入完成。为了解决这个问题,需要结合其他IPC同步机制来通知和协调进程间的读写操作。

常用的同步机制包括:

11.2.1、信号量:
  • 原理: 可以使用命名信号量(sem_open)或无名信号量(sem_init,通常放在共享内存区域内)来同步对共享内存的访问。
  • 示例:
    • 一个信号量用于表示共享内存中是否有新数据可读(初始为0)。
    • 另一个信号量用于表示共享内存是否可写(初始为1)。
    • 写入进程: 在写入数据前,等待“可写”信号量(P操作);写入数据后,发送“可读”信号量(V操作)。
    • 读取进程: 在读取数据前,等待“可读”信号量(P操作);读取数据后,发送“可写”信号量(V操作)。
11.2.2、互斥锁:
  • 原理: 可以在共享内存区域内放置一个 pthread_mutex_t 类型的互斥锁(需要初始化为进程共享属性),以保护对共享内存的临界区访问。
  • 示例:
    • 写入进程在写入数据前加锁,写入后解锁。
    • 读取进程在读取数据前加锁,读取后解锁。
    • 这确保了在任何时刻只有一个进程能修改或读取共享内存中的特定部分,但仍需额外的机制来通知数据已更新。
11.2.3、条件变量 (Condition Variables):
  • 原理: 与互斥锁类似,条件变量也可以放置在共享内存中(需要初始化为进程共享属性),用于实现等待-通知机制。
  • 示例:
    • 结合互斥锁,当写入进程完成数据写入后,可以发送条件变量信号通知读取进程。
    • 读取进程在没有数据可读时,等待条件变量信号。
11.2.4、文件锁 (File Locks):

原理: 如果 mmap 映射的是文件,可以使用文件锁(如 flockfcntl)来锁定文件区域,从而实现进程间的同步。

11.2.5、轮询 (Polling) + 标志位:
  • 原理: 在共享内存中设置一个标志位(例如一个整数或布尔值)。写入进程写入数据后,修改标志位;读取进程定期检查标志位是否改变。为了效率,标志位最好是原子变量 (std::atomic)。
  • 特点: 实现简单,但效率最低,会浪费CPU资源进行忙等。
  • 使用场景: 简单、对实时性要求不高的场景,或作为其他同步机制的辅助。

示例(使用信号量和标志位结合):

假设共享内存中有一个数据区和一个标志位。

  • 进程 A (写入者):

    1. 等待 sem_write 信号量 (P操作),确保可以写入。
    2. 写入数据到共享内存。
    3. 设置共享内存中的标志位为“数据就绪”。
    4. 发送 sem_read 信号量 (V操作),通知读取者有数据可读。
  • 进程 B (读取者):

    1. 等待 sem_read 信号量 (P操作),等待数据就绪。
    2. 读取数据从共享内存。
    3. 清除共享内存中的标志位为“数据已读”。
    4. 发送 sem_write 信号量 (V操作),通知写入者可以写入。

这种结合方式可以确保数据的一致性和通知机制的有效性。总而言之,mmap 提供了高效的数据共享通道,但同步控制需要额外的IPC机制来完成。

12、tcp和udp的区别?

特性/维度TCP (传输控制协议)UDP (用户数据报协议)
连接性面向连接:通信前需要通过三次握手建立连接,通信结束后四次挥手释放连接。无连接:通信前无需建立连接,直接发送数据报。
可靠性可靠传输:提供数据校验、序号、确认应答、超时重传、流量控制、拥塞控制等机制,确保数据正确、完整、有序地到达。不可靠传输:不保证数据报的顺序、完整性或是否到达。尽最大努力交付。
有序性有序:数据按发送顺序到达。无序:数据报可能乱序到达。
流量控制:通过滑动窗口机制,根据接收方的接收能力动态调整发送速率,防止发送方发送过快导致接收方缓冲区溢出。:不提供流量控制,发送方可以以任意速率发送数据。
拥塞控制:通过慢启动、拥塞避免、快速重传、快速恢复等算法,根据网络拥塞状况动态调整发送速率,防止网络过载。:不提供拥塞控制,可能加剧网络拥塞。
传输方式字节流:数据被视为无边界的字节流,应用层需要自行处理消息边界。数据报:以数据报为单位发送和接收,保留消息边界。
头部开销较大(通常20字节,带选项可达60字节)较小(固定8字节)
速度相对较慢,因为需要进行连接建立、确认、重传等操作。相对较快,开销小,传输效率高。
适用场景对数据可靠性、完整性、有序性要求高的应用,如文件传输(FTP)、网页浏览(HTTP/HTTPS)、电子邮件(SMTP)等。对实时性要求高、允许少量丢包、或应用层自行处理可靠性的场景,如视频会议、在线游戏、DNS查询、NTP时间同步等。
  • TCP: 像打电话,先建立连接,保证通话质量,但有延迟和开销。适用于需要可靠数据传输的应用。
  • UDP: 像发短信,直接发送,不保证对方收到,但速度快,开销小。适用于对实时性要求高、容忍少量丢包的应用。

13、tcp的接收端应用层是否可能收到乱序的数据,在哪里实现排序的,tcp的缓存区是在哪?

13.1、TCP的接收端应用层是否可能收到乱序的数据?

不会。 TCP协议的一个核心特性就是提供可靠的、有序的字节流传输。这意味着,即使在网络传输过程中,IP层的数据包(segment)可能因为路由、拥塞等原因出现乱序到达、重复到达或丢失,TCP层也会通过其内部机制确保最终交付给应用层的数据是完整且按序的。

如果TCP接收端收到了乱序的数据包,它会将其缓存起来,等待缺失的数据包到达,然后重新排序并组装成正确的字节流,再向上交付给应用层。应用层永远不会直接感知到底层数据包的乱序。

13.2、在哪里实现排序的?

TCP的排序功能是在接收端的TCP协议栈中实现的。

具体来说,是通过以下机制:

  1. 序号: TCP的每个字节数据都有一个唯一的序号。发送方在发送数据时,会为每个TCP段(segment)分配一个起始序号。接收方根据这些序号来判断数据包的顺序。
  2. 确认应答: 接收方会发送确认应答(ACK),告知发送方它已成功接收到的下一个期望的序号。这不仅确认了已接收数据,也隐含地告知了发送方哪些数据是缺失的。
  3. 接收窗口 和接收缓冲区: 接收方维护一个接收窗口,表示自己当前能够接收的数据量。所有到达的数据包都会先存入接收缓冲区。如果数据包是乱序到达的,但其序号在接收窗口内,TCP接收端会将其缓存起来,并等待缺失的、序号更小的数据包到达。
  4. 重排序: 当所有缺失的数据包都到达并且可以组成一个连续的字节流时,TCP协议栈会将这些数据包按照序号顺序重新排列,然后将连续的、按序的数据交付给上层应用。

13.3、TCP的缓冲区在哪?

TCP的缓冲区主要有两个:发送缓冲区 (Send Buffer)接收缓冲区 (Receive Buffer)

  1. 发送缓冲区 (Send Buffer):

    • 位置: 位于发送方的TCP协议栈中。
    • 作用: 存储应用层待发送的数据,以及已发送但尚未收到确认的数据。发送方会从发送缓冲区中读取数据,并将其封装成TCP段发送出去。只有当收到对某个数据的确认后,该数据才会从发送缓冲区中移除。
    • 与拥塞控制和流量控制的关系: 发送窗口(由拥塞窗口和接收窗口共同决定)限制了发送缓冲区中可以发送但未确认的数据量。
  2. 接收缓冲区 (Receive Buffer):

    • 位置: 位于接收方的TCP协议栈中。
    • 作用: 存储从网络中接收到的数据,包括乱序到达的数据段。接收方会将这些数据段进行排序、去重,并组装成连续的字节流,然后等待应用层读取。接收缓冲区的大小决定了接收窗口的大小。
    • 与流量控制的关系: 接收窗口的大小直接反映了接收缓冲区中可用空间的多少,发送方会根据接收窗口的大小来调整发送速率,防止接收缓冲区溢出。

这两个缓冲区都是在操作系统内核空间中为每个TCP连接动态分配的内存区域。应用层通过系统调用(如 send()recv())与这些缓冲区进行交互,但并不直接访问它们。

14、tcp的拥塞控制?

TCP的拥塞控制是其核心机制之一,旨在防止过多的数据注入到网络中,从而避免或减轻网络拥塞,保证网络的稳定性和公平性。拥塞控制是一个端到端的行为,由发送方根据网络状况动态调整发送速率。

TCP拥塞控制主要包括四个核心算法:慢启动(Slow Start)、拥塞避免(Congestion Avoidance)、快速重传(Fast Retransmit)和快速恢复(Fast Recovery)

在介绍这些算法之前,需要理解两个关键概念:

  • 拥塞窗口 (Congestion Window, cwnd): 发送方维护的一个状态变量,它限制了在任何给定时间可以发送但尚未确认的数据量。cwnd 是发送方认为网络能够承受的数据量。
  • 慢启动阈值 (Slow Start Threshold, ssthresh): 一个阈值,用于区分慢启动和拥塞避免阶段。

14.1、慢启动 (Slow Start)

  • 原理: 当TCP连接刚建立时,发送方并不知道网络的拥塞状况,因此会以一个非常保守的速率发送数据。cwnd 的初始值通常设置为一个较小的值(例如1或2个MSS,最大报文段长度)。每当发送方收到一个ACK,cwnd 就会指数级增长(通常是 cwnd = cwnd + MSS,即翻倍)。
  • 目的: 快速探测网络的可用带宽,但又不过快地导致拥塞。
  • 阶段结束: 当 cwnd 达到 ssthresh 时,慢启动阶段结束,进入拥塞避免阶段。

14.2、拥塞避免 (Congestion Avoidance)

  • 原理: 当 cwnd 达到 ssthresh 后,为了避免过快地增加发送速率导致拥塞,cwnd 的增长方式变为线性增长。每当收到一个RTT(往返时间)内的所有ACK,cwnd 增加一个MSS(即 cwnd = cwnd + MSS)。
  • 目的: 在探测到网络带宽的同时,更加平稳地增加发送速率,避免拥塞。
  • 拥塞发生:
    • 超时重传 (Timeout): 如果发送方在设定的超时时间内没有收到某个数据包的ACK,就认为网络发生了严重拥塞。此时,ssthresh 会被设置为 cwnd 的一半(ssthresh = cwnd / 2),cwnd 会被重置为初始值(通常是1个MSS),然后重新进入慢启动阶段。
    • 收到3个重复ACK (Triple Duplicate ACKs): 如果发送方连续收到3个对同一个数据包的重复ACK,这通常意味着该数据包丢失,但网络可能并未严重拥塞(因为其他数据包仍然能够到达)。此时,会进入快速重传和快速恢复阶段。

14.3、快速重传 (Fast Retransmit)

  • 原理: 当发送方收到3个重复ACK时,它会立即重传丢失的数据包,而无需等待定时器超时。这是因为3个重复ACK表明网络中可能只是少量丢包,而不是严重的拥塞,并且接收方已经收到了后续的数据包。
  • 目的: 尽快重传丢失的数据包,提高传输效率,减少等待超时带来的延迟。
  • 阶段转换: 快速重传后,进入快速恢复阶段。

14.4、快速恢复 (Fast Recovery)

  • 原理: 快速重传后,发送方不会像超时重传那样将 cwnd 降到1并重新进入慢启动。而是:
    1. ssthresh 设置为 cwnd 的一半(ssthresh = cwnd / 2)。
    2. cwnd 设置为 ssthresh 的值加上3个MSS(因为收到了3个重复ACK,每个重复ACK代表一个数据包已经离开了网络)。
    3. 每收到一个重复ACK,cwnd 增加一个MSS。
    4. 当收到对重传数据包的ACK时,cwnd 会被设置为 ssthresh,然后进入拥塞避免阶段。
  • 目的: 在网络没有严重拥塞(只是少量丢包)的情况下,避免 cwnd 大幅下降,从而更平滑地恢复发送速率,提高网络利用率。

拥塞控制流程图:

TCP拥塞控制

15、对比http1.1、http2.0、http3.0?

HTTP(Hypertext Transfer Protocol)是万维网数据通信的基础协议。随着互联网的发展和应用需求的演变,HTTP协议也经历了多次迭代,从HTTP/1.1到HTTP/2.0,再到最新的HTTP/3.0,每次更新都旨在解决前一版本存在的性能瓶颈和效率问题。

特性/维度HTTP/1.1HTTP/2.0HTTP/3.0
传输协议TCPTCPUDP (基于QUIC协议)
连接方式队头阻塞(Head-of-Line Blocking, HOL)多路复用(Multiplexing)多路复用(Multiplexing)
请求/响应串行请求,管道化(Pipelining)有限支持并行请求,二进制分帧并行请求,二进制分帧
头部文本格式,重复发送,无压缩二进制格式,HPACK压缩,避免重复二进制格式,QPACK压缩,避免重复
服务器推送有(Server Push)有(Server Push)
安全性可选TLS/SSL强制推荐TLS/SSL强制TLS/SSL,集成在QUIC中
队头阻塞应用层和传输层(TCP)都存在队头阻塞应用层无队头阻塞,但底层TCP仍存在队头阻塞无应用层和传输层队头阻塞(QUIC流独立)
连接建立TCP三次握手 + TLS四次握手(HTTPS),共2-3个RTTTCP三次握手 + TLS四次握手(HTTPS),共2-3个RTTQUIC握手,首次连接1-2个RTT,后续0-RTT(如果支持)
连接迁移不支持,TCP连接基于IP和端口,切换网络需重建连接。不支持,TCP连接基于IP和端口,切换网络需重建连接。支持,QUIC连接基于Connection ID,可无缝切换IP地址。
性能性能瓶颈明显,并发请求受限,头部开销大。显著提升,减少RTT,提高并发,但TCP队头阻塞仍存在。进一步提升,解决TCP队头阻塞,0-RTT连接,连接迁移。

详细解析:

HTTP/1.1

  • 队头阻塞 (Head-of-Line Blocking, HOL): 这是HTTP/1.1最大的性能瓶颈。即使开启了持久连接(Keep-Alive)和管道化(Pipelining),HTTP/1.1仍然存在队头阻塞问题。管道化允许客户端在发送一个请求后,不等待响应就发送下一个请求,但服务器必须按顺序发送响应。如果第一个请求的响应延迟,后续所有请求的响应都会被阻塞。更底层的TCP协议也存在队头阻塞,一个TCP连接中,如果某个数据包丢失,后续所有数据包即使已到达接收方,也必须等待丢失数据包重传成功并按序组装后才能交付给应用层。
  • 文本协议: 头部信息是文本格式,冗余度高,且每次请求都会发送大量重复的头部字段,没有压缩机制。
  • 无服务器推送: 客户端需要什么就请求什么,服务器无法主动推送资源。

HTTP/2.0

HTTP/2.0 的主要目标是解决 HTTP/1.1 的性能问题,但它仍然基于 TCP 协议。

  • 二进制分帧: HTTP/2.0 将所有数据(包括头部和请求体)都封装成二进制帧,并允许多个帧在同一个TCP连接上交错发送。这解决了应用层的队头阻塞问题。
  • 多路复用 (Multiplexing): 允许在单个TCP连接上同时发送多个请求和接收多个响应,无需等待。每个请求/响应都分配一个流ID,数据帧带有流ID,可以乱序发送和接收,然后在接收端根据流ID重新组装。
  • 头部压缩 (HPACK): 引入 HPACK 算法,对HTTP头部进行压缩,减少了头部开销,特别是对于重复的头部字段。
  • 服务器推送 (Server Push): 服务器可以在客户端请求HTML页面时,主动推送与该页面相关的CSS、JavaScript等资源,减少客户端的请求次数。
  • TCP队头阻塞依然存在: 尽管HTTP/2.0解决了应用层的队头阻塞,但由于其底层仍然使用TCP,如果TCP连接中某个数据包丢失,整个TCP连接上的所有流都会受到影响,需要等待该数据包重传并按序交付,这就是传输层的队头阻塞。

HTTP/3.0

HTTP/3.0 的核心是基于 QUIC (Quick UDP Internet Connections) 协议,而QUIC是基于UDP实现的。这使得HTTP/3.0能够彻底解决TCP的队头阻塞问题,并提供更快的连接建立和更好的连接迁移能力。

  • 基于QUIC协议: QUIC是一个全新的传输层协议,它在UDP之上实现了可靠传输、流控制、拥塞控制、安全性(内置TLS 1.3)和多路复用等功能。
  • 彻底解决队头阻塞: QUIC的每个流都是独立的,即使一个流的数据包丢失,也只会影响该流,而不会阻塞同一连接上的其他流。这彻底解决了传输层的队头阻塞问题。
  • 0-RTT 连接建立: QUIC通过在第一次握手时缓存加密参数,可以在后续连接中实现0-RTT(零往返时间)连接建立,大大加快了连接速度。
  • 连接迁移 (Connection Migration): QUIC连接不依赖于IP地址和端口号,而是通过一个Connection ID来标识。这意味着客户端在网络切换(例如从Wi-Fi切换到蜂窝数据)时,可以保持QUIC连接不中断,而TCP连接则需要重新建立。
  • 头部压缩 (QPACK): QUIC使用 QPACK 协议进行头部压缩,它是 HPACK 的改进版本,针对QUIC的多流特性进行了优化。
  • 强制加密: QUIC协议本身就内置了TLS 1.3,所有通信都是加密的,提供了更高的安全性。

16、http3.0如何实现0RTT?

HTTP/3.0 实现 0-RTT (Zero Round-Trip Time) 连接建立的关键在于其底层传输协议 QUIC (Quick UDP Internet Connections)。0-RTT 的目标是在客户端和服务器之间建立安全连接时,能够立即发送应用数据,而无需额外的往返时间等待。

16.1、传统 TLS/TCP 握手与 0-RTT 的挑战

在传统的 HTTPS (HTTP/1.1 或 HTTP/2.0 over TLS over TCP) 中,建立一个安全连接通常需要多个往返时间:

  1. TCP 三次握手: 客户端和服务器之间需要进行三次握手来建立TCP连接,这需要 1个RTT
  2. TLS 握手: 在TCP连接建立后,客户端和服务器还需要进行TLS握手来协商加密参数并验证身份。对于完整的TLS 1.2握手,这通常需要 2个RTT(客户端Hello -> 服务器Hello/证书 -> 客户端密钥交换/Finished -> 服务器Finished)。TLS 1.3 已经优化到 1个RTT

因此,在最理想的情况下(TLS 1.3),建立一个HTTPS连接至少需要 1 (TCP) + 1 (TLS) = 2个RTT 才能开始发送应用数据。对于首次连接,这个延迟是不可避免的。

16.2、QUIC 实现 0-RTT 的原理

QUIC 通过以下机制实现了 0-RTT:

  1. 基于 UDP: QUIC 运行在 UDP 之上,避免了 TCP 的三次握手延迟。
  2. 集成 TLS 1.3: QUIC 将 TLS 1.3 握手深度集成到其自身的连接建立过程中,而不是在其之上独立运行。TLS 1.3 自身就支持 0-RTT 恢复。
  3. 连接 ID (Connection ID): QUIC 连接不绑定到 IP 地址和端口号,而是使用一个 Connection ID。这使得客户端在网络切换(如 Wi-Fi 到 4G)时,可以保持相同的 Connection ID,从而实现连接的无缝迁移,而无需重新建立连接和执行握手。
  4. 预共享密钥 (Pre-Shared Key, PSK) 和会话票证 (Session Ticket):
    • 首次连接: 客户端与服务器进行一次完整的 QUIC 握手(通常是 1-RTT)。在这个过程中,双方会协商出一组加密密钥,并且服务器会向客户端发送一个会话票证 (Session Ticket)。这个票证包含了加密的会话状态信息和用于恢复连接的预共享密钥 (PSK)。
    • 后续连接 (0-RTT): 当客户端再次连接到同一个服务器时,它可以在发送第一个 QUIC 客户端握手包 (Client Initial) 时,同时附带上应用数据。这个握手包会包含上次连接时服务器发给它的会话票证和预共享密钥。服务器收到这个包后,如果能够成功解密会话票证并验证 PSK,就可以立即使用预共享密钥解密应用数据并处理。这样,客户端在发送第一个数据包时就包含了应用数据,实现了 0-RTT。

0-RTT 的流程简化:

  1. 第一次连接 (1-RTT):

    • 客户端发送 ClientHello (包含支持的加密套件)。
    • 服务器响应 ServerHello (包含选定的加密套件、证书、会话票证等)。
    • 客户端验证服务器证书,并发送加密的 Finished 消息和应用数据(此时客户端已经有了密钥)。
    • 服务器验证 Finished 消息,并发送加密的 Finished 消息和应用数据。
    • 整个过程大约需要 1-RTT 才能开始双向应用数据传输。
  2. 后续连接 (0-RTT):

    • 客户端发送 ClientHello (包含上次连接获得的会话票证和 PSK) + 应用数据
    • 服务器收到后,如果能用 PSK 成功解密 ClientHello 和应用数据,则立即处理应用数据并响应。
    • 如果解密失败或 PSK 已过期,服务器会退回到 1-RTT 握手。

0-RTT 的安全性考虑:

尽管 0-RTT 提供了极大的性能优势,但它也存在一定的安全风险,主要是重放攻击 (Replay Attack)。因为客户端在收到服务器响应之前就发送了加密的应用数据,如果攻击者截获了这个数据包,并将其重放给服务器,服务器可能会重复执行相同的操作。

为了缓解这个问题,QUIC 和 TLS 1.3 采取了一些措施:

  • 重放检测: 服务器会维护一个会话票证的使用记录,或者使用一次性随机数(nonce)来检测和拒绝重放的 0-RTT 数据包。
  • 限制 0-RTT 数据: 通常只允许发送幂等(Idempotent)的请求(即重复执行多次也不会产生副作用的操作,如 GET 请求),对于非幂等请求(如 POST 请求),可能需要等待 1-RTT 握手完成后再发送。

通过这些机制,HTTP/3.0 结合 QUIC 协议,在保证安全性的前提下,实现了更快速的连接建立和更优异的性能。

17、说一下常用的数据结构?

数据结构是计算机存储、组织数据的方式,它们是算法设计的基础。选择合适的数据结构对于程序的性能至关重要。以下是一些最常用的数据结构及其特点:

17.1、数组 (Array)

  • 原理: 存储在内存中连续位置的同类型数据元素的集合。通过索引直接访问元素。
  • 特点:
    • 优点: 随机访问(通过索引)效率高,时间复杂度为O(1)。存储空间连续,缓存友好。
    • 缺点: 大小固定,一旦创建,大小不可变。插入和删除元素(尤其是在中间位置)效率低,需要移动大量元素,时间复杂度为O(n)。
  • 使用场景: 存储固定大小的数据集;需要快速随机访问元素;作为其他数据结构(如动态数组、哈希表)的底层实现。

17.2、链表 (Linked List)

  • 原理: 由一系列节点组成,每个节点包含数据元素和指向下一个(或上一个)节点的指针。节点在内存中可以不连续。
  • 特点:
    • 优点: 动态大小,可以方便地插入和删除元素,时间复杂度为O(1)(如果已知插入/删除位置)。
    • 缺点: 随机访问效率低,需要从头遍历,时间复杂度为O(n)。需要额外空间存储指针。
  • 类型: 单向链表、双向链表、循环链表。
  • 使用场景: 频繁插入和删除元素;实现栈、队列等。

17.3、栈 (Stack)

  • 原理: 一种“后进先出”(LIFO, Last In First Out)的数据结构。只允许在表的一端(栈顶)进行插入(push)和删除(pop)操作。
  • 特点: 操作简单,效率高。
  • 使用场景: 函数调用栈;表达式求值;括号匹配;深度优先搜索(DFS)。

17.4、队列 (Queue)

  • 原理: 一种“先进先出”(FIFO, First In First Out)的数据结构。只允许在表的一端插入(enqueue),在另一端删除(dequeue)。
  • 特点: 操作简单,效率高。
  • 使用场景: 任务调度;消息队列;广度优先搜索(BFS)。

17.5、哈希表 / 散列表 (Hash Table)

  • 原理: 通过哈希函数将键映射到数组中的一个位置,从而实现快速查找。通常由一个数组(桶)和哈希函数组成。
  • 特点:
    • 优点: 平均情况下插入、删除、查找操作的时间复杂度为O(1)。
    • 缺点: 最坏情况下(哈希冲突严重)可能退化到O(n)。需要处理哈希冲突(开放寻址法、链地址法)。
  • 使用场景: 快速查找(字典、关联数组);数据库索引;缓存;实现集合。

17.6、树 (Tree)

  • 原理: 由节点和边组成的非线性数据结构,具有层级关系。每个节点可以有零个或多个子节点。
  • 特点: 适用于表示层级关系的数据。
  • 类型:
    • 二叉树 (Binary Tree): 每个节点最多有两个子节点。
    • 二叉搜索树 (Binary Search Tree, BST): 左子树所有节点的值小于根节点,右子树所有节点的值大于根节点,且左右子树也分别是BST。平均查找、插入、删除时间复杂度为O(log n)。
    • 平衡二叉搜索树 (AVL Tree, Red-Black Tree): 在BST基础上通过旋转等操作保持树的平衡,确保最坏情况下的操作时间复杂度也是O(log n)。
    • B/B+树: 多叉平衡查找树,常用于数据库和文件系统索引,减少磁盘I/O。
  • 使用场景: 文件系统;数据库索引;路由算法;语法分析;游戏AI。

17.7、图 (Graph)

  • 原理: 由节点(顶点)和连接节点的边组成的抽象数据结构。可以表示任意对象之间的复杂关系。
  • 特点: 灵活,能表示复杂关系。
  • 类型: 有向图、无向图、带权图。
  • 使用场景: 社交网络;地图导航(最短路径);推荐系统;电路设计。

17.8、堆 (Heap)

  • 原理: 一种特殊的完全二叉树,满足堆属性(父节点的值总是大于或小于其所有子节点的值)。分为最大堆和最小堆。
  • 特点: 可以在O(1)时间获取最大/最小元素,插入和删除元素的时间复杂度为O(log n)。
  • 使用场景: 优先队列;堆排序;Top K 问题。

18、常用的设计模式?

详细可见文章:C++设计模式全解析

设计模式(Design Patterns)是软件工程中经过实践验证的、针对常见问题的一套可重用解决方案。它们提供了一种通用的语言来描述软件设计中的问题和解决方案,有助于提高代码的可读性、可维护性和可扩展性。GoF(Gang of Four)的《设计模式:可复用面向对象软件的基础》一书总结了23种经典设计模式,通常分为三大类:创建型模式、结构型模式和行为型模式。

以下是一些最常用和重要的设计模式:

18.1、创建型模式

关注对象的创建,将对象的创建与使用分离。

  1. 单例模式 (Singleton Pattern)

    • 目的: 确保一个类只有一个实例,并提供一个全局访问点。
    • 优点: 节省资源,避免重复创建对象;提供唯一访问点,方便控制。
    • 缺点: 可能导致代码耦合度高;多线程环境下需要考虑线程安全;不利于单元测试。
    • 使用场景: 日志对象、配置管理器、线程池、数据库连接池等。
  2. 工厂方法模式 (Factory Method Pattern)

    • 目的: 定义一个用于创建对象的接口,让子类决定实例化哪一个类。工厂方法使一个类的实例化延迟到其子类。
    • 优点: 客户端与具体产品解耦;符合“开闭原则”(对扩展开放,对修改关闭)。
    • 缺点: 每增加一个产品,就需要增加一个具体工厂类。
    • 使用场景: 数据库连接工厂、日志记录器工厂、UI控件创建等。
  3. 抽象工厂模式 (Abstract Factory Pattern)

    • 目的: 提供一个接口,用于创建相关或依赖对象的家族,而不需要明确指定具体类。
    • 优点: 客户端与具体产品和具体工厂解耦;易于切换产品系列。
    • 缺点: 增加新产品族需要修改抽象工厂及其所有具体工厂。
    • 使用场景: 跨平台UI工具包、数据库访问层(不同数据库提供不同连接、命令对象)。
  4. 建造者模式 (Builder Pattern)

    • 目的: 将一个复杂对象的构建与它的表示分离,使得同样的构建过程可以创建不同的表示。
    • 优点: 允许分步构建复杂对象;将构建代码与表示代码分离;可以控制构建过程。
    • 缺点: 如果产品内部变化复杂,建造者类会变得庞大。
    • 使用场景: 构建复杂对象(如汽车、房屋、文档),其中构建步骤是固定的,但表示可以不同。

18.2、结构型模式

关注如何将类和对象组合成更大的结构,以实现新的功能。

  1. 适配器模式 (Adapter Pattern)

    • 目的: 将一个类的接口转换成客户希望的另一个接口。适配器模式使原本由于接口不兼容而不能一起工作的那些类可以一起工作。
    • 优点: 提高类的复用性;透明地使用不兼容的接口。
    • 缺点: 增加代码复杂性;可能引入多层适配。
    • 使用场景: 封装旧的API以适应新的接口;不同系统之间的数据格式转换。
  2. 装饰器模式 (Decorator Pattern)

    • 目的: 动态地给一个对象添加一些额外的职责。就增加功能来说,装饰器模式相比生成子类更为灵活。
    • 优点: 比继承更灵活,避免了类爆炸;可以在运行时动态添加或移除功能。
    • 缺点: 装饰器层次多时,调试复杂。
    • 使用场景: Java I/O流、GUI组件的边框/滚动条、加密/压缩功能。
  3. 代理模式 (Proxy Pattern)

    • 目的: 为另一个对象提供一个替身或占位符,以控制对这个对象的访问。
    • 优点: 可以在不修改原始对象的情况下增加功能;实现延迟加载、访问控制、日志记录等。
    • 缺点: 增加系统复杂性;可能导致响应速度变慢。
    • 使用场景: 远程代理(RPC)、虚拟代理(延迟加载大对象)、安全代理(访问控制)、智能引用。

18.3、行为型模式

关注对象之间如何协作,以及如何分配职责。

  1. 观察者模式 (Observer Pattern)

    • 目的: 定义对象间的一种一对多依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都得到通知并被自动更新。
    • 优点: 实现了主题和观察者的解耦;支持广播通信。
    • 缺点: 观察者过多时,通知可能耗时;如果存在循环依赖,可能导致死循环。
    • 使用场景: GUI事件处理、消息发布/订阅系统、股票行情、新闻订阅。
  2. 策略模式 (Strategy Pattern)

    • 目的: 定义一系列的算法,把它们一个个封装起来,并且使它们可相互替换。本模式使得算法可独立于使用它的客户而变化。
    • 优点: 算法可以自由切换;避免了多重条件判断;符合“开闭原则”。
    • 缺点: 客户端必须知道所有策略类;增加类的数量。
    • 使用场景: 支付方式、排序算法、税收计算、文件压缩算法。
  3. 模板方法模式 (Template Method Pattern)

    • 目的: 定义一个操作中的算法的骨架,而将一些步骤延迟到子类中。模板方法使得子类可以在不改变算法结构的情况下,重新定义算法的某些特定步骤。
    • 优点: 封装了不变的部分,扩展了可变的部分;控制子类对父类行为的修改。
    • 缺点: 增加了类的数量;子类必须实现抽象方法。
    • 使用场景: 算法的骨架固定,但某些步骤需要定制(如泡咖啡和泡茶的步骤)。
  4. 迭代器模式 (Iterator Pattern)

    • 目的: 提供一种方法顺序访问一个聚合对象中各个元素,而又无需暴露该对象的内部表示。
    • 优点: 封装了集合的遍历逻辑;支持多种遍历方式;客户端与集合解耦。
    • 缺点: 增加了类的数量。
    • 使用场景: 遍历各种集合(如列表、树、图),标准库容器的迭代器。

19、手写单例模式。

单例模式(Singleton Pattern)是创建型模式中最简单也最常用的一种,它确保一个类只有一个实例,并提供一个全局访问点。

实现单例模式的关键在于:

  1. 私有化构造函数: 阻止外部直接通过 new 或构造函数创建对象。
  2. 提供一个静态方法: 用于获取类的唯一实例。
  3. 懒汉式或饿汉式: 决定实例何时创建。

下面介绍几种常见的C++单例模式实现方式。

19.1、饿汉式 (Eager Initialization)

饿汉式单例在程序启动时(或类加载时)就创建实例。它的优点是线程安全,因为实例在主线程启动前就已经创建完成,不存在多线程竞争问题。缺点是如果实例创建开销大且程序运行期间不一定用到,会造成资源浪费。

cpp
#include <iostream>
#include <mutex>

class SingletonEager {
private:
    // 私有构造函数,阻止外部直接创建实例
    SingletonEager() {
        std::cout << "SingletonEager instance created.\n";
    }

    // 阻止拷贝构造和拷贝赋值
    SingletonEager(const SingletonEager&) = delete;
    SingletonEager& operator=(const SingletonEager&) = delete;

public:
    // 静态方法,提供全局访问点
    static SingletonEager& getInstance() {
        // 静态成员变量在程序启动时(或首次访问时)初始化
        // C++11 保证了静态局部变量的初始化是线程安全的(Magic Static)
        static SingletonEager instance; 
        return instance;
    }

    void showMessage() const {
        std::cout << "Hello from SingletonEager!\n";
    }
};

int main() {
    std::cout << "Main function started.\n";
    // 首次调用 getInstance 会创建实例
    SingletonEager& s1 = SingletonEager::getInstance();
    s1.showMessage();

    SingletonEager& s2 = SingletonEager::getInstance();
    s2.showMessage();

    // 验证s1和s2是否是同一个实例
    if (&s1 == &s2) {
        std::cout << "s1 and s2 are the same instance.\n";
    }

    std::cout << "Main function finished.\n";
    return 0;
}

饿汉式特点:

  • 线程安全: C++11及以后,局部静态变量的初始化是线程安全的(Magic Static)。
  • 实现简单: 代码量少,易于理解。
  • 资源浪费: 如果实例一直未被使用,也会在程序启动时创建。

19.2、懒汉式 (Lazy Initialization) - 线程不安全版

懒汉式单例在第一次被使用时才创建实例。优点是按需创建,节省资源。缺点是在多线程环境下,如果不加锁,可能创建多个实例,导致线程不安全。

cpp
#include <iostream>

class SingletonLazyUnsafe {
private:
    static SingletonLazyUnsafe* instance;

    SingletonLazyUnsafe() {
        std::cout << "SingletonLazyUnsafe instance created.\n";
    }

    SingletonLazyUnsafe(const SingletonLazyUnsafe&) = delete;
    SingletonLazyUnsafe& operator=(const SingletonLazyUnsafe&) = delete;

public:
    static SingletonLazyUnsafe* getInstance() {
        if (instance == nullptr) { // 第一次检查
            instance = new SingletonLazyUnsafe();
        }
        return instance;
    }

    void showMessage() const {
        std::cout << "Hello from SingletonLazyUnsafe!\n";
    }

    // 提供一个静态方法用于释放资源,避免内存泄漏
    static void destroyInstance() {
        if (instance) {
            delete instance;
            instance = nullptr;
            std::cout << "SingletonLazyUnsafe instance destroyed.\n";
        }
    }
};

// 静态成员变量的定义和初始化
SingletonLazyUnsafe* SingletonLazyUnsafe::instance = nullptr;

int main() {
    std::cout << "Main function started.\n";
    // 在多线程环境下,这里的 if (instance == nullptr) 判断和 new 操作可能导致竞态条件
    SingletonLazyUnsafe* s1 = SingletonLazyUnsafe::getInstance();
    s1->showMessage();

    SingletonLazyUnsafe* s2 = SingletonLazyUnsafe::getInstance();
    s2->showMessage();

    if (s1 == s2) {
        std::cout << "s1 and s2 are the same instance.\n";
    }
    
    SingletonLazyUnsafe::destroyInstance(); // 手动释放资源
    std::cout << "Main function finished.\n";
    return 0;
}

懒汉式(线程不安全)特点:

  • 懒加载: 实例在第一次调用时才创建。
  • 线程不安全: 在多线程环境下,多个线程可能同时通过 if (instance == nullptr) 检查,导致创建多个实例。

19.3、懒汉式 (Lazy Initialization) - 线程安全版 (双重检查锁定 DCLP)

为了解决懒汉式的线程不安全问题,可以使用互斥锁。双重检查锁定(Double-Checked Locking Pattern, DCLP)是一种优化方案,它在加锁前和加锁后都进行一次 nullptr 检查,以减少锁的竞争开销。

需要注意的是,为了确保DCLP的正确性,instance 指针必须声明为 volatile(在某些旧的C++标准或编译器中)或使用 std::atomic(C++11及以后推荐)。

cpp
#include <iostream>
#include <mutex>
#include <atomic> // C++11 推荐使用 std::atomic

class SingletonLazySafeDCLP {
private:
    // 使用 std::atomic 保证内存可见性和指令重排的正确性
    static std::atomic<SingletonLazySafeDCLP*> instance;
    static std::mutex mtx;

    SingletonLazySafeDCLP() {
        std::cout << "SingletonLazySafeDCLP instance created.\n";
    }

    SingletonLazySafeDCLP(const SingletonLazySafeDCLP&) = delete;
    SingletonLazySafeDCLP& operator=(const SingletonLazySafeDCLP&) = delete;

public:
    static SingletonLazySafeDCLP* getInstance() {
        // 第一次检查:如果实例已存在,则无需加锁,直接返回
        SingletonLazySafeDCLP* tmp = instance.load(std::memory_order_acquire);
        if (tmp == nullptr) {
            std::lock_guard<std::mutex> lock(mtx);
            // 第二次检查:加锁后再次检查,确保只有一个线程创建实例
            tmp = instance.load(std::memory_order_relaxed);
            if (tmp == nullptr) {
                tmp = new SingletonLazySafeDCLP();
                instance.store(tmp, std::memory_order_release);
            }
        }
        return tmp;
    }

    void showMessage() const {
        std::cout << "Hello from SingletonLazySafeDCLP!\n";
    }

    static void destroyInstance() {
        SingletonLazySafeDCLP* tmp = instance.load(std::memory_order_relaxed);
        if (tmp) {
            delete tmp;
            instance.store(nullptr, std::memory_order_relaxed);
            std::cout << "SingletonLazySafeDCLP instance destroyed.\n";
        }
    }
};

// 静态成员变量的定义和初始化
std::atomic<SingletonLazySafeDCLP*> SingletonLazySafeDCLP::instance = nullptr;
std::mutex SingletonLazySafeDCLP::mtx;

int main() {
    std::cout << "Main function started.\n";
    SingletonLazySafeDCLP* s1 = SingletonLazySafeDCLP::getInstance();
    s1->showMessage();

    SingletonLazySafeDCLP* s2 = SingletonLazySafeDCLP::getInstance();
    s2->showMessage();

    if (s1 == s2) {
        std::cout << "s1 and s2 are the same instance.\n";
    }

    SingletonLazySafeDCLP::destroyInstance();
    std::cout << "Main function finished.\n";
    return 0;
}

懒汉式(DCLP)特点:

  • 懒加载: 按需创建实例。
  • 线程安全: 通过 std::mutexstd::atomic 保证了线程安全。
  • 性能优化: 减少了锁的竞争,只有在第一次创建实例时才需要加锁。
  • 复杂性: 实现相对复杂,需要注意内存屏障和原子操作。