Skip to content

多益网络服务器C++开发正式批一面面经

来源:https://www.nowcoder.com/feed/main/detail/1fdb6b582e1843d5bbac3f200c262a82

1、对C++的理解以及C++的优缺点?

C++ 的优点

  1. 高性能:

    • C++ 继承了 C 语言的底层控制能力,可以直接操作硬件和内存管理,允许开发者在需要的地方进行细粒度优化。
    • 支持手动内存管理和高效的数据结构,适合高性能、系统级编程。
  2. 灵活性和控制:

    • C++ 允许开发者在运行时进行精细的资源管理(如内存、CPU),这使得它适合开发资源受限的应用程序(例如嵌入式系统)。
    • 提供了强大的语言特性,如 RAII(Resource Acquisition Is Initialization)等,帮助自动化资源管理。
  3. 丰富的标准库:

    • C++ 标准库(STL,Standard Template Library)提供了强大的数据结构和算法实现,极大简化了程序开发。
    • STL 中的容器、迭代器、算法和函数对象提供了灵活的工具箱,使得许多常见任务可以通过简单组合实现。
  4. 跨平台:

    C++ 在不同平台上都有编译器支持(如 Windows、Linux、macOS 等),并且可以编译出高效的机器代码,这使得 C++ 程序具有良好的跨平台能力。

  5. 面向对象和泛型编程支持:

    • C++ 支持封装、继承和多态等面向对象编程特性,使得代码模块化和可扩展性更好。
    • 通过模板机制,可以实现类型无关的代码复用和编译时优化。

C++ 的缺点

  1. 复杂性:

    • C++ 语言非常庞大,语法和特性复杂(如多继承、模板元编程等),开发者在学习和使用过程中需要掌握大量概念,难度较大。
    • 由于支持多种编程范式和底层操作,代码的可读性和维护性可能受到影响,尤其是当开发者没有严格遵守编码规范时。
  2. 内存管理复杂:

    • 尽管 C++ 提供了手动内存管理的灵活性,但这也带来了常见的内存问题,如内存泄漏、悬空指针、缓冲区溢出等。
    • 虽然 C++11 引入了智能指针(如 std::shared_ptr, std::unique_ptr)等机制来简化内存管理,但这仍然需要开发者对内存管理有深入的理解。
  3. 编译时间长:

    • C++ 的编译过程较为复杂,特别是当项目规模较大、使用了大量模板和头文件时,编译时间可能会非常长。
    • 模板元编程在编译时进行计算,也可能导致较长的编译时间和难以理解的错误信息。
  4. 不适合快速开发:

    • 与一些高级语言(如 Python、JavaScript)相比,C++ 的开发周期较长,尤其是在需要快速迭代的应用场景中,C++ 的编写和调试难度较大。
    • C++ 的严格类型检查和手动内存管理,虽然提高了程序的性能和安全性,但也增加了代码的编写量和开发复杂度。
  5. 库生态不如高级语言丰富:

    尽管 C++ 有强大的标准库,但与 Python、Java 等语言相比,其库生态相对较少,尤其是在现代 Web 开发和数据科学领域。

2、C++内存分配方式主要有哪几种?

1. 静态存储区分配

  • 分配方式:在程序编译时就分配好了内存,并在整个程序运行期间一直存在。
  • 存储对象:全局变量、静态变量(包括局部静态变量)、常量等。
  • 释放时机:程序结束时自动释放。
c++
int globalVar = 10;  // 全局变量
static int staticVar = 20;  // 静态变量

2. 栈区分配

  • 分配方式:局部变量、函数参数等在栈上分配,栈区的分配和释放速度很快,系统自动管理。
  • 存储对象:局部变量、函数参数等。
  • 释放时机:当函数返回时,栈上的内存会自动释放,局部变量的生命周期结束。
c++
void func() {
    int localVar = 5;  // 局部变量,栈上分配
}

3. 堆区分配

  • 分配方式:堆区分配是动态的,程序运行时通过 newmalloc 等函数进行手动分配,使用 deletefree 手动释放。适合存放需要动态分配的内存,例如较大的数据结构。
  • 存储对象:动态分配的对象或数组。
  • 释放时机:由程序员手动管理,如果不释放则会造成内存泄漏。
c++
int* p = new int(10);  // 堆上分配
delete p;  // 释放内存

4. 全局/静态变量区分配

  • 分配方式:全局变量和静态变量在程序开始时分配,在程序结束时释放。它们存在于内存中的全局区(或静态区),生命周期与程序相同。
  • 存储对象:全局变量和静态变量。
  • 释放时机:程序退出时由系统自动释放。
c++
static int staticVar = 5;  // 静态变量,全局/静态变量区分配

5. 常量区分配

  • 分配方式:常量存储在常量区,主要用于存储不可修改的数据,例如字符串常量和 const 修饰的变量。
  • 存储对象:字符串常量、const 修饰的常量。
  • 释放时机:常量区由系统管理,在程序结束时释放。
c++
const int constVar = 100;  // 常量,存储在常量区

总结

分配方式存储对象分配时机释放时机
静态存储区分配全局变量、静态变量、常量编译时程序结束时自动释放
栈区分配局部变量、函数参数函数调用时函数返回时自动释放
堆区分配动态分配的对象、数组运行时程序员手动释放
全局/静态变量区分配全局变量、静态变量程序开始时程序结束时自动释放
常量区分配字符串常量、const修饰的变量编译时程序结束时自动释放

3、介绍智能指针,智能指针的实现原理?

主要用于自动管理动态内存的分配和释放,避免手动使用 newdelete 导致的内存泄漏问题。它通过 RAII(Resource Acquisition Is Initialization)技术来确保当智能指针离开作用域时,自动释放所管理的资源(通常是内存)。

1. std::unique_ptr

特点:

  • 独占式拥有某个对象,保证在某个时刻只能有一个 unique_ptr 指向同一个对象。
  • 不支持复制操作,但可以通过移动语义转移所有权。
  • 适用于不需要共享资源的场景。

原理:unique_ptr 通过移动语义实现所有权的转移,不允许拷贝,因此只允许 move 构造和赋值。它的实现包含一个指针,当智能指针被销毁时,它会自动调用所指向对象的析构函数并释放内存。

c++
std::unique_ptr<int> p1(new int(10));
std::unique_ptr<int> p2 = std::move(p1);  // p1所有权转移给p2

实现关键点:

  • 内部通过指针管理对象。
  • 禁用拷贝构造和拷贝赋值(通过删除 copy constructorcopy assignment 实现)。
  • 实现移动构造和移动赋值操作,转移所有权。

2. std::shared_ptr

特点:

  • 支持多个智能指针共享管理同一对象,通过引用计数来跟踪有多少 shared_ptr 指向同一块内存,当最后一个 shared_ptr 被销毁时,才释放对象。
  • 适合多个地方需要共享资源的场景。

原理:shared_ptr 内部维护一个引用计数(use_count),每当一个新的 shared_ptr 指向同一对象时,引用计数加1;当某个 shared_ptr 离开作用域或被销毁时,引用计数减1。当引用计数为0时,自动释放资源。

c++
std::shared_ptr<int> p1(new int(20));
std::shared_ptr<int> p2 = p1;  // 引用计数加1

实现关键点:

  • 内部使用引用计数器(通常为 std::atomic 类型)来确保线程安全。
  • 当计数器降为0时,释放内存。
  • 拥有指向控制块的指针,该控制块包含实际对象的指针和引用计数。

3. std::weak_ptr

特点:

  • weak_ptr 是为了解决 shared_ptr 可能导致的循环引用问题,不影响引用计数。
  • weak_ptr 不能直接访问对象,需要先通过 lock() 方法将其提升为 shared_ptr 后才能访问对象。
  • 通常用于缓存或观察者模式,避免 shared_ptr 循环引用。

原理:weak_ptr 不参与引用计数的增加,但可以观测对象是否还存在。它不直接拥有对象,而是持有一个弱引用(弱引用指向 shared_ptr 的控制块),可以通过 lock() 获得一个临时的 shared_ptr

c++
std::shared_ptr<int> sp = std::make_shared<int>(30);
std::weak_ptr<int> wp = sp;  // wp 不增加引用计数

if (std::shared_ptr<int> temp = wp.lock()) {
    // 成功锁定,使用temp
}

实现关键点:

  • weak_ptr 通过控制块(与 shared_ptr 共享控制块)监控资源的生命周期,但不增加引用计数。
  • 通过 lock() 方法判断对象是否仍然存在,并返回一个 shared_ptr

4. std::auto_ptr(已废弃)

特点:

  • 早期C++的智能指针,已经在C++11中被废弃,无法正确处理对象所有权的转移和多指针场景。
  • 不支持复制语义,当复制时会导致所有权被转移,而原指针会被置为 nullptr

原理:通过拷贝操作将资源所有权转移(浅拷贝),但这会带来一些潜在问题,特别是在函数传参或返回值时容易引发所有权的不明确性。

c++
std::auto_ptr<int> p1(new int(10));
std::auto_ptr<int> p2 = p1;  // p1被置为nullptr,p2获得所有权

实现关键点:由于其实现存在所有权混乱的问题,已经在现代C++中被替代为 unique_ptrshared_ptr

智能指针实现的核心思想

智能指针的核心原理是通过类对象封装原始指针,并重载指针运算符(如 operator*operator->),使智能指针像原始指针一样使用。此外,智能指针通过析构函数自动管理资源的释放,确保异常发生时不会泄漏资源。

  1. 内存管理:智能指针自动调用 delete 来释放堆上的对象,避免手动管理的复杂性。
  2. 控制块:shared_ptrweak_ptr 使用控制块来管理引用计数和资源状态。
  3. 所有权转移:unique_ptr 通过移动语义进行所有权转移,确保一个对象在任一时刻只有一个拥有者。

4、基类的析构函数为什么要定义成虚函数?

C++ 中,如果希望通过基类指针删除一个派生类对象,基类的析构函数应该定义为虚函数。这样做是为了确保正确调用派生类的析构函数,从而避免内存泄漏和资源管理问题。

原因

当你通过基类指针(或引用)操作派生类对象时,如果基类的析构函数不是虚函数,那么在对象被删除时,编译器只会调用基类的析构函数,而不会调用派生类的析构函数。这会导致派生类中特有的资源(如动态分配的内存、打开的文件或其他外部资源)无法被正确释放,进而引发内存泄漏或资源泄漏问题。

例子:

c++
class Base {
public:
    ~Base() {
        std::cout << "Base destructor" << std::endl;
    }
};

class Derived : public Base {
public:
    ~Derived() {
        std::cout << "Derived destructor" << std::endl;
    }
};

如果执行以下操作:

Base* obj = new Derived();
delete obj;

由于 Base 的析构函数不是虚函数,编译器在 delete 时只会调用 Base 的析构函数,而不会调用 Derived 的析构函数,因此 Derived 类中的资源不会被释放。输出会是:

c++
Base destructor

解决方案:将基类的析构函数设为虚函数

通过将基类的析构函数定义为虚函数,可以确保派生类的析构函数在通过基类指针删除派生类对象时得到正确调用。

修改后的代码:

c++
class Base {
public:
    virtual ~Base() {
        std::cout << "Base destructor" << std::endl;
    }
};

class Derived : public Base {
public:
    ~Derived() {
        std::cout << "Derived destructor" << std::endl;
    }
};

现在,如果再次执行相同的操作:

c++
Base* obj = new Derived();
delete obj;

输出将变为:

c++
Derived destructor
Base destructor

这意味着派生类的析构函数 ~Derived() 得到了正确调用,派生类的资源将会被正常释放。

5、指针和引用的区别,什么情况使用引用什么情况使用指针?

区别

特性指针引用
定义保存变量的地址,可以修改所指向的对象是某个变量的别名,引用对象不可改变
语法使用 * 解引用,使用 & 取地址直接使用变量名操作引用
是否可以为空可以为空(指向 nullptr引用必须绑定一个有效的对象
是否可以重新指向可以指向不同的对象绑定后不可更改引用对象
使用场景需要动态分配内存或不确定对象的存在时使用对象一定存在且不会更改时使用引用,常用于参数传递
是否需要解引用操作符需要显式使用 * 进行解引用不需要解引用符,直接访问引用变量即可
适用场景动态内存管理、数组、遍历链表等参数传递、返回值优化、常量引用避免拷贝

具体分析

  1. 初始化:

    指针:可以声明一个指针而不初始化,指针可以指向空或某个变量的地址。

    c++
    int* ptr;  // 未初始化
    ptr = nullptr;  // 可以指向空
    ptr = &a;  // 指向变量 a

    引用:在声明引用时,必须立即初始化,且引用一旦绑定一个对象,就不能再指向其他对象。

    c++
    int a = 10;
    int& ref = a;  // 引用必须初始化
  2. 是否可以为空:

    指针:指针可以指向 nullptr 或者其他无效地址,这意味着在使用指针之前,必须检查指针是否为 nullptr 以防止空指针错误。

    c++
    int* ptr = nullptr;  // 可以指向空
    if (ptr) {
        // 确保指针有效才使用
    }

    引用:引用必须绑定有效对象,不能为空。因此,引用的使用更加安全,不需要检查是否为有效对象。

    c++
    int& ref = a;  // 始终指向 a,不能为空
  3. 是否可以重新绑定:

    指针:可以随时改变指向的对象。

    c++
    int a = 10, b = 20;
    int* ptr = &a;
    ptr = &b;  // 可以重新指向 b

    引用:一旦引用被初始化为某个对象,它就无法重新绑定到其他对象。

    c++
    int a = 10, b = 20;
    int& ref = a;
    ref = b;  // 这里并不是重新绑定,而是修改 a 的值为 b 的值
  4. 语法简洁性:

    指针:操作指针时需要使用解引用操作符 *,这增加了一些语法复杂性。

    c++
    int a = 10;
    int* ptr = &a;
    *ptr = 20;  // 通过指针修改 a 的值

    引用:引用的使用更加简洁,像使用普通变量一样直接使用即可,不需要解引用操作。

    c++
    int a = 10;
    int& ref = a;
    ref = 20;  // 直接修改 a 的值
  5. 指针的灵活性与引用的安全性:

    • 指针:由于指针可以为空,也可以指向不同的对象,因此它更加灵活,但也带来潜在的空指针或悬空指针问题。
    • 引用:引用始终有效,因此更安全,特别是在参数传递或返回值时使用,可以避免很多指针相关的问题。

什么时候使用指针?

  1. 动态内存管理:当你需要动态分配内存并手动管理内存的生命周期时,使用指针(或智能指针)。

    c++
    int* ptr = new int(10);  // 动态分配内存
    delete ptr;  // 手动释放内存
  2. 处理数组和链表:指针可以用于遍历数组、链表等数据结构,特别是在你需要访问内存中的多个元素时。

    c++
    int arr[] = {1, 2, 3, 4};
    int* ptr = arr;
    for (int i = 0; i < 4; i++) {
        std::cout << *(ptr + i) << std::endl;
    }
  3. 需要修改函数参数并且可能为空:如果你需要在函数中修改传入参数,并且该参数可能为空,使用指针。

    c++
    void modify(int* ptr) {
        if (ptr) {
            *ptr = 20;  // 修改指针指向的值
        }
    }
  4. 当对象的生命周期不确定时:如果对象的生命周期不确定,或可能指向不同的对象,使用指针可以提供更大的灵活性。

什么时候使用引用?

  1. 函数参数传递:引用通常用于函数参数传递,避免拷贝大量数据,同时可以安全地操作传递的对象。

    c++
    void modify(int& ref) {
        ref = 20;  // 修改引用对象的值
    }
  2. 返回函数结果:当需要返回函数内部的对象或者成员时,可以使用引用,以避免复制大量数据。

    c++
    int& getElement(int arr[], int index) {
        return arr[index];  // 返回数组中的元素的引用
    }
  3. 常量引用:使用 const 引用来确保在函数中不会修改传入对象,尤其是传递大对象时,引用可以避免拷贝的开销。

    c++
    void print(const std::string& str) {
        std::cout << str << std::endl;
    }
  4. 操作容器元素:当你在迭代容器并修改其中的元素时,使用引用可以直接操作容器中的元素。

    c++
    for (int& elem : vec) {
        elem *= 2;  // 通过引用修改容器元素
    }

6、C++中深拷贝和浅拷贝的区别,追问:什么情况用浅拷贝什么情况用深拷贝,实际编码中使用过拷贝函数吗?

区别

浅拷贝

浅拷贝是指在对象复制时,复制对象的成员变量值而不复制动态分配的内存。对于指针成员变量,浅拷贝只复制指针的地址(即指向相同的内存区域),不会创建新对象。

c++
class Example {
public:
    int* data;

    Example(int value) {
        data = new int(value);
    }

    // 浅拷贝:默认的拷贝构造函数
    Example(const Example& other) {
        data = other.data;
    }
};

在浅拷贝中,多个对象共享同一块内存区域,如果其中一个对象修改了数据,其他对象的指针也会指向相同的数据。这种方式容易导致悬空指针内存泄漏问题。

深拷贝

深拷贝是在对象复制时,不但复制对象的成员变量,还重新分配内存并复制数据内容。深拷贝确保新对象拥有自己的独立数据副本,不共享原始对象的数据,避免了潜在的指针问题。

c++
class Example {
public:
    int* data;

    Example(int value) {
        data = new int(value);
    }

    // 深拷贝:自定义拷贝构造函数
    Example(const Example& other) {
        data = new int(*(other.data));  // 重新分配内存并复制数据
    }

    ~Example() {
        delete data;
    }
};

深拷贝可以避免多个对象共享同一内存的问题,因此在对象的生命周期结束时,不会因多个对象尝试释放同一块内存而导致崩溃。

区别总结

区别点浅拷贝深拷贝
内存分配只复制对象的指针,多个对象共享同一内存区域重新分配内存,创建独立的数据副本
适用场景适用于只读的数据共享,或无需修改的场景适用于需要修改数据,或涉及动态分配内存的对象
问题风险可能导致悬空指针或重复释放内存安全且不会产生内存管理问题
复杂度实现简单,速度快需要额外的内存分配和复制操作,较为复杂

什么时候使用浅拷贝,什么时候使用深拷贝?

浅拷贝:

  • 数据无需独立且多个对象可以共享相同的数据时,使用浅拷贝。例如在某些场景下,多个对象共享只读数据,浅拷贝可以节省内存和提高效率。
  • 如果类中不涉及动态分配的内存(如类成员都是基础类型或 STL 容器类),默认的浅拷贝是安全的。

深拷贝:

  • 如果对象中有动态分配的内存,且需要对象之间的数据独立(即对象的修改不会影响其他对象),必须使用深拷贝。
  • 当对象生命周期复杂(如多个对象在不同的时刻析构)时,深拷贝避免了潜在的悬空指针和内存管理问题。

实际编码中是否使用过拷贝函数?

在实际开发中,特别是在管理动态内存或涉及复杂对象关系的场景中,深拷贝是非常常见的。开发者需要通过自定义拷贝构造函数赋值运算符来实现深拷贝,以确保内存管理的安全和正确性。

c++
class Example {
public:
    int* data;

    Example(int value) {
        data = new int(value);
    }

    // 自定义拷贝构造函数
    Example(const Example& other) {
        data = new int(*(other.data));  // 深拷贝
    }

    // 自定义赋值运算符
    Example& operator=(const Example& other) {
        if (this == &other) {
            return *this;  // 防止自我赋值
        }
        delete data;  // 先释放旧数据
        data = new int(*(other.data));  // 深拷贝
        return *this;
    }

    ~Example() {
        delete data;
    }
};

在上面的代码中,自定义了拷贝构造函数和赋值运算符,实现了深拷贝,避免了多个对象共享同一块动态内存区域导致的问题。

7、进程间通信的方式?

进程间通信(IPC,Inter-Process Communication)是指多个进程之间相互交换数据的方式。由于进程间地址空间是独立的,因此需要通过特定的机制进行通信。

1. 管道

管道是一种单向通信方式,主要用于具有亲缘关系的进程(如父子进程)之间的通信。管道将数据从一个进程的输出发送到另一个进程的输入。管道分为两类:

  • 匿名管道(Unnamed Pipe):只能用于父子进程之间,通信是单向的,进程必须通过fork()生成子进程。
  • 命名管道(Named Pipe,FIFO):通过在文件系统中创建一个特殊文件,可以实现任意进程之间的通信,允许双向通信。
c++
int pipefd[2];
pipe(pipefd);  // 创建管道
write(pipefd[1], "message", strlen("message"));  // 写入数据
read(pipefd[0], buffer, sizeof(buffer));  // 读取数据

2. 消息队列

消息队列是一种链式存储结构,用于存放进程之间传递的消息。消息队列可以在内核中保持消息,并且允许进程以消息的形式发送和接收数据。

优点:

  • 支持随机读取消息,而不像管道只能顺序读取。
  • 适合用于多个进程之间的通信。
c++
int msgid = msgget(key, IPC_CREAT | 0666);  // 创建消息队列
msgsnd(msgid, &message, sizeof(message), 0);  // 发送消息
msgrcv(msgid, &message, sizeof(message), 0, 0);  // 接收消息

3. 共享内存

共享内存允许多个进程共享同一块内存区域,这是最快的通信方式,因为进程可以直接访问共享的内存地址。共享内存通常需要同步机制(如信号量)来避免多个进程同时修改数据时的冲突。

优点:

  • 高效,多个进程可以直接访问同一内存,不需要通过内核来传递数据。 缺点:
  • 需要同步机制(如信号量)来避免竞争条件。
c++
int shmid = shmget(key, size, IPC_CREAT | 0666);  // 创建共享内存段
char* mem = (char*)shmat(shmid, nullptr, 0);  // 连接到共享内存
strcpy(mem, "Hello World");  // 写入数据
shmdt(mem);  // 断开共享内存连接

4. 信号量

信号量是一种用于控制进程间同步的机制,主要用于控制多个进程对共享资源的访问。信号量本质上是一个计数器,用来控制对共享资源的访问权限。

  • 二值信号量:类似于互斥锁,用于保证某个时刻只有一个进程访问某一共享资源。
  • 计数信号量:允许多个进程同时访问共享资源,但数量有限。
c++
sem_t sem;
sem_init(&sem, 0, 1);  // 初始化信号量
sem_wait(&sem);  // P操作:等待信号量
// 临界区
sem_post(&sem);  // V操作:释放信号量

5. 信号

信号是一种有限的通信机制,用于通知进程某个事件的发生。信号是一种异步通信机制,进程可以通过捕捉和处理信号来执行特定的操作。常见的信号包括 SIGINTSIGKILLSIGTERM 等。

优点:

  • 简单,可以快速通知进程事件的发生。 缺点:
  • 信号只能传递有限的信息,且信号处理不适合复杂的通信场景。
c++
signal(SIGINT, signal_handler);  // 注册信号处理函数

6. 套接字

套接字是一种通用的通信方式,可以在不同机器之间通过网络实现进程间通信。套接字不仅支持本地进程之间的通信,还支持跨网络的进程通信。

  • 本地域套接字(Unix Domain Socket):用于同一台机器上进程之间的通信。
  • 网络套接字(TCP/IP Socket):用于跨网络的进程间通信。
c++
int sockfd = socket(AF_INET, SOCK_STREAM, 0);  // 创建套接字
bind(sockfd, (struct sockaddr*)&addr, sizeof(addr));  // 绑定地址
listen(sockfd, 5);  // 监听连接
accept(sockfd, (struct sockaddr*)&client, &client_len);  // 接收连接

7. 文件

进程之间可以通过读写同一个文件进行通信。虽然文件不是高效的通信方式,但它是一种非常简单且适合持久化存储的方式。

8、数据库索引的作用?追问:索引是怎么提高扫描的速度的,使用索引的缺点,索引占用的是什么空间?

数据库索引的作用

数据库索引的主要作用是加速数据的查询。它可以像书本的目录一样,通过减少扫描数据的范围,快速定位到查询所需的数据。尤其在大规模数据表中,索引可以显著减少查询的时间,提升数据库的性能。

索引如何提高扫描速度

索引通过特定的数据结构(如B树、B+树、哈希表等)对数据进行组织,从而提高查询效率。索引提高扫描速度的方式主要体现在以下几方面:

  1. 减少数据扫描量:如果没有索引,数据库查询时需要全表扫描,即逐行检查数据。通过索引,查询可以在更小的数据范围内定位到结果,减少了扫描的记录数。
  2. 通过有序的数据结构:索引通常使用有序的数据结构(如B+树),数据库可以在这些结构上进行二分查找,而不是逐行扫描全表。这样,查询效率从O(n)提升到O(log n),极大地减少了访问的数据量。
  3. 避免随机磁盘读取:索引的使用使得数据访问更有序,减少了大量的随机磁盘I/O,提高了查询效率。尤其是当数据被按顺序读取时,磁盘访问更高效。

使用索引的缺点

虽然索引能提高查询性能,但它也有一些缺点:

  1. 增加存储空间:索引需要额外的存储空间来保存索引结构。创建索引时,数据库需要维护这些索引数据,这会占用磁盘空间。对于非常大的数据表,索引的空间消耗也可能很大。
  2. 插入、删除和更新操作变慢:每当有插入、删除或更新操作时,除了修改数据表本身,还需要更新相应的索引。因此,索引会导致这些操作变慢,尤其在频繁插入、更新的表中,索引维护的开销比较大。
  3. 过多的索引会降低性能:如果表上有太多索引,虽然查询可能变快,但插入和更新操作的代价也会增加。因此,必须在索引的数量和数据库性能之间做出权衡。

索引占用的空间

索引占用的是磁盘存储空间。具体来说,索引占用的空间取决于所使用的数据结构和索引类型:

  • B树/B+树索引:这些树形结构的节点存储在磁盘中,会占用额外的磁盘块。
  • 哈希索引:哈希表也会占用一定的磁盘空间来存储哈希值及指向实际数据的指针。
  • 聚簇索引(Clustered Index):数据按索引顺序存储,数据本身成为索引的一部分,会影响表数据的存储结构。
  • 非聚簇索引(Non-clustered Index):索引和数据分开存储,索引存储的是键值和指向数据的指针,需要额外的磁盘空间来保存这些索引项。

9、索引为什么使用B+树?

1. B+树具有高度平衡性

B+树是一种自平衡的多路搜索树。每次插入、删除操作后,B+树都会通过分裂或合并节点保持平衡。因此,B+树的深度较小,使得在查询数据时,磁盘I/O操作次数减少,能够在更短的时间内访问到数据。

2. B+树节点存储更高效

B+树的内部节点只存储索引键,不存储具体数据,而叶子节点才存储数据或指向数据的指针。因此,每个内部节点可以容纳更多的键,这使得B+树的扇出(每个节点的子节点数量)较大,树的高度较低。这一点非常重要,因为数据库系统中,每次从磁盘加载一个节点都需要进行磁盘I/O操作,B+树的这种结构减少了磁盘I/O次数。

  • 扇出大:每次磁盘I/O操作可以读取更多的索引键,查询路径更短。
  • 数据集中在叶子节点:所有数据都位于叶子节点,这便于顺序读取,并且通过链表将叶子节点连接起来,非常适合范围查询。

3. 顺序访问效率高

B+树的叶子节点之间通过链表相连,形成一个有序的双向链表。这种设计使得B+树可以高效地进行范围查询,因为可以从叶子节点中按顺序遍历出结果。例如,执行SELECT * FROM table WHERE key >= 10 AND key <= 100时,B+树能够直接从找到的叶子节点开始顺序遍历,效率非常高。

相比之下,B树的所有节点都存储数据,这使得B树的结构不适合顺序扫描,因为无法通过链表方便地遍历数据。

4. 查询性能稳定

由于B+树所有的查询操作都是从根节点到叶子节点的路径,因此所有数据访问路径都具有相同的深度。相比之下,哈希表虽然查找速度快,但不支持范围查询,且哈希冲突时查找性能不如B+树稳定。

5. 磁盘存储优化

B+树的设计特别适合于磁盘存储系统,因为它能够很好地利用磁盘的特性。每次从磁盘读取数据时,会读取一个完整的磁盘块。B+树的内部节点较少且扇出大,树的高度小,意味着在磁盘上存储的索引结构占用空间较少,并且访问数据时能减少I/O操作,提升整体效率。

10、对进程、线程、协程的理解?

1. 进程 (Process)

  • 定义:进程是计算机中正在执行的程序的实例。每个进程都有自己的内存空间、数据栈和其他用于跟踪执行的辅助数据。
  • 特点:
    • 独立性:进程之间相互独立,各自有自己的内存空间,不能直接访问其他进程的内存。
    • 开销大:进程的创建、销毁和上下文切换都比较耗费资源。
    • 资源拥有:每个进程拥有系统资源,如文件句柄和网络连接等。
  • 使用场景:进程通常用于运行不同的应用程序或服务,例如 Web 服务器、数据库等。

2. 线程 (Thread)

  • 定义:线程是进程的一个执行单元,是 CPU 调度的基本单位。一个进程可以包含多个线程,它们共享进程的资源,如内存和文件句柄。
  • 特点:
    • 轻量级:线程比进程更轻量,创建和销毁的开销较小。
    • 共享资源:同一进程内的线程共享进程的内存空间,通信更高效。
    • 同步问题:多线程之间需要处理共享资源的竞争问题,通常需要使用锁等机制来保证线程安全。
  • 使用场景:线程适用于需要并发执行的任务,如处理用户请求、下载文件等。

3. 协程 (Coroutine)

  • 定义:协程是一种用户态的轻量级线程,允许在单个线程内实现多任务并发。协程通过协作的方式进行切换,而不是依赖操作系统的调度。
  • 特点:
    • 极轻量级:创建和切换协程的开销极小,通常比线程要小得多。
    • 非抢占式调度:协程由程序员手动控制执行的上下文切换,而不是由操作系统自动调度。
    • 可控性强:协程可以在需要的时候挂起和恢复,非常适合处理异步任务和并发操作。
  • 使用场景:协程非常适合 I/O 密集型操作,如网络请求、文件读取等,能够高效地处理大量并发请求。