C++面试题整合

内容是之前整理的一些面试知识点,本来想去互联网混口饭吃,发现实在是太卷了搞不过哈哈哈哈,现在已经更换赛道到嵌入式软开了。这里是整理的包含C++、操作系统、数据库、计网、设计模式、protobuf、kafka、rabbitMQ的部分知识,如果里面有错误还希望大家多多包涵,希望对大家有点帮助,2023秋招大家加油!

C++

虚函数表

https://www.cnblogs.com/tianzeng/p/9769932.html

基本对象模型

class MyClass
{
    int var;
public:
    virtual void fun()
    {}
};

image-20220823221552562

对象模型

1>  class MyClass    size(8):
1>      +---
1>   0    | {vfptr}
1>   4    | var
1>      +---
1>  
1>  MyClass::$vftable@:
1>      | &MyClass_meta
1>      |  0
1>   0    | &MyClass::fun
1>  
1>  MyClass::fun this adjustor: 0

MyClass的虚函数表虽然只有一条函数记录,但是它的结尾处是由4字节的0作为结束标记的。

adjust表示虚函数机制执行时,this指针的调整量,假如fun被多态调用的话,那么它的形式如下:

*(this+0)[0]()

总结虚函数调用形式,应该是:

*(this指针+调整量)[虚函数在vftable内的偏移]()

单重继承对象模型

class MyClassA:public MyClass
{
    int varA;
public:
    virtual void fun()
    {}
    virtual void funA()
    {}
};

子类如果重写了父类的虚函数(如fun),就会把虚函数表原本fun对应的记录(内容MyClass::fun)覆盖为新的函数地址(内容MyClassA::fun),否则继续保持原本的函数地址记录。如果子类定义了新的虚函数,虚函数表内会追加一条记录,记录该函数的地址(如MyClassA::funA)。

image-20220823222653946

对象模型

1>  class MyClassA    size(12):
1>      +---
1>      | +--- (base class MyClass)
1>   0    | | {vfptr}
1>   4    | | var
1>      | +---
1>   8    | varA
1>      +---
1>  
1>  MyClassA::$vftable@:
1>      | &MyClassA_meta
1>      |  0
1>   0    | &MyClassA::fun
1>   1    | &MyClassA::funA
1>  
1>  MyClassA::fun this adjustor: 0
1>  MyClassA::funA this adjustor: 0

多重继承模型

class MyClassB:public MyClass
{
    int varB;
public:
    virtual void fun()
    {}
    virtual void funB()
    {}
};
class MyClassC:public MyClassA,public MyClassB
{
    int varC;
public:
    virtual void funB()
    {}
virtual void funC()
    {}
};

image-20220823222640856

对象模型

1>  class MyClassC    size(28):
1>      +---
1>      | +--- (base class MyClassA)
1>      | | +--- (base class MyClass)
1>   0    | | | {vfptr}
1>   4    | | | var
1>      | | +---
1>   8    | | varA
1>      | +---
1>      | +--- (base class MyClassB)
1>      | | +--- (base class MyClass)
1>  12    | | | {vfptr}
1>  16    | | | var
1>      | | +---
1>  20    | | varB
1>      | +---
1>  24    | varC
1>      +---
1>  
1>  MyClassC::$vftable@MyClassA@:
1>      | &MyClassC_meta
1>      |  0
1>   0    | &MyClassA::fun
1>   1    | &MyClassA::funA
1>   2    | &MyClassC::funC
1>  
1>  MyClassC::$vftable@MyClassB@:
1>      | -12
1>   0    | &MyClassB::fun
1>   1    | &MyClassC::funB
1>  
1>  MyClassC::funB this adjustor: 12
1>  MyClassC::funC this adjustor: 0

多重继承下,子类不再具有自身的虚函数表,它的虚函数表与第一个父类的虚函数表合并了。同样的,如果子类重写了任意父类的虚函数,都会覆盖对应的函数地址记录。如果MyClassC重写了fun函数(两个父类都有该函数),那么两个虚函数表的记录都需要被覆盖!在这里我们发现MyClassC::funB的函数对应的adjust值是12,按照我们前边的规则,可以发现该函数的多态调用形式为:

*(this+12)[1]()

此处的调整量12正好是MyClassB的vfptr在MyClassC对象内的偏移量。

虚拟继承对象模型

class MyClassA:virtual public MyClass
class MyClassB:virtual public MyClass
class MyClassC:public MyClassA,public MyClassB

由于虚继承的本身语义,MyClassC内必须重写fun函数,因此我们需要再重写fun函数。这种情况下,MyClassC的对象模型如下:

1>  class MyClassC    size(36):
1>      +---
1>      | +--- (base class MyClassA)
1>   0    | | {vfptr}
1>   4    | | {vbptr}
1>   8    | | varA
1>      | +---
1>      | +--- (base class MyClassB)
1>  12    | | {vfptr}
1>  16    | | {vbptr}
1>  20    | | varB
1>      | +---
1>  24    | varC
1>      +---
1>      +--- (virtual base MyClass)
1>  28    | {vfptr}
1>  32    | var
1>      +---
1>  
1>  MyClassC::$vftable@MyClassA@:
1>      | &MyClassC_meta
1>      |  0
1>   0    | &MyClassA::funA
1>   1    | &MyClassC::funC
1>  
1>  MyClassC::$vftable@MyClassB@:
1>      | -12
1>   0    | &MyClassC::funB
1>  
1>  MyClassC::$vbtable@MyClassA@:
1>   0    | -4
1>   1    | 24 (MyClassCd(MyClassA+4)MyClass)
1>  
1>  MyClassC::$vbtable@MyClassB@:
1>   0    | -4
1>   1    | 12 (MyClassCd(MyClassB+4)MyClass)
1>  
1>  MyClassC::$vftable@MyClass@:
1>      | -28
1>   0    | &MyClassC::fun
1>  
1>  MyClassC::fun this adjustor: 28
1>  MyClassC::funB this adjustor: 12
1>  MyClassC::funC this adjustor: 0
1>  
1>  vbi:       class  offset o.vbptr  o.vbte fVtorDisp
1>           MyClass      28       4       4 0

image-20220823222626618

注意:对于在对象中存取虚基类的问题,虚基类表仅是Microsoft编译器的解决办法。在其他编译器中,一般采用在虚函数表中放置虚基类的偏移量的方式。

一般编译器实现动态多态方法:

1、通过vbptr找到对象的vtbl(this指针的调整量);

2、找到vfptr中对应函数的指针(虚函数表中记录的个数);

3、调用对象中指针指向的函数

*(this指针+调整量)[虚函数在vftable内的偏移]()

MyClassC中的fun()函数直接继承与基类!!!

**虚基类表每项记录了被继承的虚基类子对象相对于虚基类表指针的偏移量。**比如MyClassA的虚基类表第二项记录值为24,正是MyClass::vfptr相对于MyClassA::vbptr的偏移量,同理MyClassB的虚基类表第二项记录值12也正是MyClass::vfptr相对于MyClassA::vbptr的偏移量。

和虚函数表不同的是,虚基类表的第一项记录着当前子对象相对与虚基类表指针的偏移。MyClassA和MyClassB子对象内的虚表指针都是存储在相对于自身的4字节偏移处,因此该值是-4。假定MyClassA和MyClassC或者MyClassB内没有定义新的虚函数,即不会产生虚函数表,那么虚基类表第一项字段的值应该是0。

通过以上的对象组织形式,编译器解决了公共虚基类的多份拷贝的问题。通过每个父类的虚基类表指针,都能找到被公共使用的虚基类的子对象的位置,并依次访问虚基类子对象的数据。至于虚基类定义的虚函数,它和其他的虚函数的访问形式相同,本例中,如果使用虚基类指针MyClass*pc访问MyClassC对象的fun,将会被转化为如下形式:

*(pc+28)[0]()

C++ STL sort实现

https://feihu.me/blog/2014/sgi-std-sort/

堆排序的时间复杂度是O(NlogN),快速排序的平均时间复杂度为O(NlogN),最差为O(N2),但是实际表现是快速排序要比堆排序好很多,原因在于快速排序的局部性比较强,缓存的数据可以有更高的命中率,而堆排序每次的数据取值跨度太大,每次需要进行取值时没办法利用缓存快的特点。

三种排序方式:快速排序、堆排序、插入排序

当递归深度过深时使用堆排序递归深度(2log(end - start)),堆排序结束之后直接结束当前递归。此外还有最小分段阈值,数据长度小于该阈值时,再使用递归来排序显然不划算,递归的开销相对来说太大。而此时整个区间内部有多个元素个数少于16的子序列,每个子序列都有相当程度的排序,但又尚未完全排序,过多的递归调用是不可取的。而这种情况刚好插入排序最拿手,它的效率能够达到O(N)。

快速排序使用了单边递归,减少了递归函数调用,进一步优化。

CPU层级代码优化

image-20220507152658830

1.提高CPU缓存的命中率;(顺序访问)CPU缓存的访问速度要比内存快。在CPU进行读取数据时会把该数据的前面和后面都读取,那么图中第一种访问方式就会更快。在设计数组时尽可能让数组的大小与CPU缓存的大小一致,提高缓存命中率。

image-20220507152951286

2.分支预测器

当代码中存在大量的if或switch时,cpu会对判断语句有个预测,优先执行哪个判断条件,如果数据是有序的可以有效的降低系统熵,那么分支预测器可以有效的判断出将要执行的代码块。

如果我们知道我们的判断条件大量为真,那么就可以设计优先判断某个条件。

image-20220507153622871

CPU指令重排

https://zhuanlan.zhihu.com/p/62060524

为了使处理器内部的运算单元能尽量被充分利用,处理器可能会对输入的代码进行乱序执行优化,处理器会在计算之后将乱序执行的结果重组,并确保这一结果和顺序执行结果是一致的,但是这个过程并不保证各个语句计算的先后顺序和输入代码中的顺序一致。这就是指令重排序。

volatile关键字:

易变性

上半部分代码在对b赋值时,a的值是从CPU寄存器里面读出来的,而下半部分是从内存中读出来的。

image-20220823222609800

不可优化性

在编译时上半部分直接把abc用3,4,5进行替换了,下半部分会从内存中进行读取显示。

image-20220507155133221

多线程陷阱/顺序性

volitile并不能解决多线程顺序性问题,CPU执行优化后,check()函数还没有执行完,就会把finish和flag设置为true

image-20220507155421497

虽然C++中valitile可以避免编译器进行优化,但是CPU的优化无法控制。因此需要加锁来保证顺序。

image-20220823222555108

什么是RAII

RAII(Resource Acquisition Is Initialization)是由c++之父Bjarne Stroustrup提出的,中文翻译为资源获取即初始化,他说:使用局部对象来管理资源的技术称为资源获取即初始化;这里的资源主要是指操作系统中有限的东西如内存、网络套接字等等,局部对象是指存储在栈的对象,它的生命周期是由操作系统来管理的,无需人工介入;

#include <pthread.h>
#include <cstdlib>
#include <stdio.h>

class Mutex {
 public:
  Mutex();
  ~Mutex();

  void Lock();
  void Unlock(); 

 private:
  pthread_mutex_t mu_;

  // No copying
  Mutex(const Mutex&);
  void operator=(const Mutex&);
};


#include "mutex.h"

static void PthreadCall(const char* label, int result) {
  if (result != 0) {
    fprintf(stderr, "pthread %s: %s\n", label, strerror(result));
  }
}

Mutex::Mutex() { PthreadCall("init mutex", pthread_mutex_init(&mu_, NULL)); }

Mutex::~Mutex() { PthreadCall("destroy mutex", pthread_mutex_destroy(&mu_)); }

void Mutex::Lock() { PthreadCall("lock", pthread_mutex_lock(&mu_)); }

void Mutex::Unlock() { PthreadCall("unlock", pthread_mutex_unlock(&mu_)); }

如果直接使用mutex那么就需要记得上锁和解锁,如果在上锁和解锁之间出现了运行错误,可能这个锁永远也不会被解除。

#include "mutex.h"

class  MutexLock {
 public:
  explicit MutexLock(Mutex *mu)
      : mu_(mu)  {
    this->mu_->Lock();
  }
  ~MutexLock() { this->mu_->Unlock(); }

 private:
  Mutex *const mu_;
  // No copying allowed
  MutexLock(const MutexLock&);
  void operator=(const MutexLock&);
};


#include "mutexlock.hpp"
#include <unistd.h>
#include <iostream>

#define    NUM_THREADS     10000

int num=0;
Mutex mutex;

void *count(void *args) {
    MutexLock lock(&mutex);
    num++;
}


int main() {
    int t;
    pthread_t thread[NUM_THREADS];

    for( t = 0; t < NUM_THREADS; t++) {   
        int ret = pthread_create(&thread[t], NULL, count, NULL);
        if(ret) {   
            return -1;
        }   
    }

    for( t = 0; t < NUM_THREADS; t++)
        pthread_join(thread[t], NULL);
    std::cout << num << std::endl;
    return 0;
}

对mutex再做一层封装,利用MutexLock lock(&mutex);自动实现加锁和解锁。

C++为什么不像JAVA有垃圾回收机制

1.没有共同基类:C++是从C发展而成,允许直接操作指针,允许将一个类型转换为另一个类型,对于一个指针无法知道它真正指向的类型;而Java或C#都有一个共同基类

2.系统开销:垃圾回收所带来的系统开销,违反了C++的设计哲学,“不为不必要的功能支付代价”,不符合C++高效的特性,使得不适合做底层工作

3.消耗内存:C++产生的年代内存很少,垃圾回收机制需要占用更多的内存

4.替代方法:C++有析构函数、智能指针、引用计数去管理资源的释放,对GC的需求不迫切

map和unordered_map的区别

map: map内部实现了一个红黑树,该结构具有自动排序的功能,因此map内部的所有元素都是有序的,红黑树的每一个节点都代表着map的一个元素,因此,对于map进行的查找,删除,添加等一系列的操作都相当于是对红黑树进行这样的操作,故红黑树的效率决定了map的效率。
unordered_map: unordered_map内部实现了一个哈希表,因此其元素的排列顺序是杂乱的,无序的

unordered_map是一种空间换时间的方式,在内存比较大的情况下unordered_map会更好一点

C++右值引用解决了什么问题

https://zhuanlan.zhihu.com/p/335994370

1. 什么是左值、右值

左值可以取地址、位于等号左边;而右值没法取地址,位于等号右边

2. 什么是左值引用、右值引用

引用本质是别名,可以通过引用修改变量的值,传参时传引用可以避免拷贝,其实现原理和指针类似。 个人认为,引用出现的本意是为了降低C语言指针的使用难度,但现在指针+左右值引用共同存在,反而大大增加了学习和理解成本。

2.1 左值引用

左值引用大家都很熟悉,能指向左值,不能指向右值的就是左值引用

int a = 5;
int &ref_a = a; // 左值引用指向左值,编译通过
int &ref_a = 5; // 左值引用指向了右值,会编译失败

引用是变量的别名,由于右值没有地址,没法被修改,所以左值引用无法指向右值。

但是,const左值引用是可以指向右值的

2.2 右值引用

再看下右值引用,右值引用的标志是&&,顾名思义,右值引用专门为右值而生,可以指向右值,不能指向左值

int &&ref_a_right = 5; // ok
 
int a = 5;
int &&ref_a_left = a; // 编译不过,右值引用不可以指向左值
 
ref_a_right = 6; // 右值引用的用途:可以修改右值

2.3.1 右值引用有办法指向左值吗?

int a = 5; // a是个左值
int &ref_a_left = a; // 左值引用指向左值
int &&ref_a_right = std::move(a); // 通过std::move将左值转化为右值,可以被右值引用指向
 
cout << a; // 打印结果:5

在上边的代码里,看上去是左值a通过std::move移动到了右值ref_a_right中,那是不是a里边就没有值了?并不是,打印出a的值仍然是5。

std::move是一个非常有迷惑性的函数,不理解左右值概念的人们往往以为它能把一个变量里的内容移动到另一个变量,但事实上std::move移动不了什么,唯一的功能是把左值强制转化为右值,让右值引用可以指向左值。其实现等同于一个类型转换:static_cast<T&&>(lvalue)。 所以,单纯的std::move(xxx)不会有性能提升

同样的,右值引用能指向右值,本质上也是把右值提升为一个左值,并定义一个右值引用通过std::move指向该左值:

int &&ref_a = 5;
ref_a = 6; 
 
等同于以下代码:
 
int temp = 5;
int &&ref_a = std::move(temp);
ref_a = 6;

2.3.2 左值引用、右值引用本身是左值还是右值?

被声明出来的左、右值引用都是左值。 因为被声明出的左右值引用是有地址的,也位于等号左边。仔细看下边代码:

// 形参是个右值引用
void change(int&& right_value) {
    right_value = 8;
}
 
int main() {
    int a = 5; // a是个左值
    int &ref_a_left = a; // ref_a_left是个左值引用
    int &&ref_a_right = std::move(a); // ref_a_right是个右值引用
 
    change(a); // 编译不过,a是左值,change参数要求右值
    change(ref_a_left); // 编译不过,左值引用ref_a_left本身也是个左值
    change(ref_a_right); // 编译不过,右值引用ref_a_right本身也是个左值
     
    change(std::move(a)); // 编译通过
    change(std::move(ref_a_right)); // 编译通过
    change(std::move(ref_a_left)); // 编译通过
 
    change(5); // 当然可以直接接右值,编译通过
     
    cout << &a << ' ';
    cout << &ref_a_left << ' ';
    cout << &ref_a_right;
    // 打印这三个左值的地址,都是一样的
}

看完后你可能有个问题,std::move会返回一个右值引用int &&,它是左值还是右值呢? 从表达式int &&ref = std::move(a)来看,右值引用ref指向的必须是右值,所以move返回的int &&是个右值。所以右值引用既可能是左值,又可能是右值吗? 确实如此:右值引用既可以是左值也可以是右值,如果有名称则为左值,否则是右值

或者说:作为函数返回值的 && 是右值,直接声明出来的 && 是左值。 这同样也符合第一章对左值,右值的判定方式:其实引用和普通变量是一样的,int &&ref = std::move(a)int a = 5没有什么区别,等号左边就是左值,右边就是右值。

最后,从上述分析中我们得到如下结论:

  1. 从性能上讲,左右值引用没有区别,传参使用左右值引用都可以避免拷贝。
  2. 右值引用可以直接指向右值,也可以通过std::move指向左值;而左值引用只能指向左值(const左值引用也能指向右值)。
  3. 作为函数形参时,右值引用更灵活。虽然const左值引用也可以做到左右值都接受,但它无法修改,有一定局限性。
void f(const int& n) {
    n += 1; // 编译失败,const左值引用不能修改指向变量
}

void f2(int && n) {
    n += 1; // ok
}

int main() {
    f(5);
    f2(5);
}

3. 右值引用和std::move的应用场景

3.1 实现移动语义

在实际场景中,右值引用和std::move被广泛用于在STL和自定义类中实现移动语义,避免拷贝,从而提升程序性能。 在没有右值引用之前,一个简单的数组类通常实现如下,有构造函数拷贝构造函数赋值运算符重载析构函数等。深拷贝/浅拷贝在此不做讲解。

class Array {
public:
    Array(int size) : size_(size) {
        data = new int[size_];
    }
     
    // 深拷贝构造
    Array(const Array& temp_array) {
        size_ = temp_array.size_;
        data_ = new int[size_];
        for (int i = 0; i < size_; i ++) {
            data_[i] = temp_array.data_[i];
        }
    }
     
    // 深拷贝赋值
    Array& operator=(const Array& temp_array) {
        delete[] data_;
 
        size_ = temp_array.size_;
        data_ = new int[size_];
        for (int i = 0; i < size_; i ++) {
            data_[i] = temp_array.data_[i];
        }
    }
 
    ~Array() {
        delete[] data_;
    }
 
public:
    int *data_;
    int size_;
};

该类的拷贝构造函数、赋值运算符重载函数已经通过使用左值引用传参来避免一次多余拷贝了,但是内部实现要深拷贝,无法避免。 这时,有人提出一个想法:是不是可以提供一个移动构造函数,把被拷贝者的数据移动过来,被拷贝者后边就不要了,这样就可以避免深拷贝了,如:

class Array {
public:
    Array(int size) : size_(size) {
        data = new int[size_];
    }
     
    // 深拷贝构造
    Array(const Array& temp_array) {
        ...
    }
     
    // 深拷贝赋值
    Array& operator=(const Array& temp_array) {
        ...
    }
 
    // 移动构造函数,可以浅拷贝
    Array(const Array& temp_array, bool move) {
        data_ = temp_array.data_;
        size_ = temp_array.size_;
        // 为防止temp_array析构时delete data,提前置空其data_      
        temp_array.data_ = nullptr;
    }
     
 
    ~Array() {
        delete [] data_;
    }
 
public:
    int *data_;
    int size_;
};

这么做有2个问题:

  • 不优雅,表示移动语义还需要一个额外的参数(或者其他方式)。
  • 无法实现!temp_array是个const左值引用,无法被修改,所以temp_array.data_ = nullptr;这行会编译不过。当然函数参数可以改成非const:Array(Array& temp_array, bool move){...},这样也有问题,由于左值引用不能接右值,Array a = Array(Array(), true);这种调用方式就没法用了。

可以发现左值引用真是用的很不爽,右值引用的出现解决了这个问题,在STL的很多容器中,都实现了以右值引用为参数移动构造函数移动赋值重载函数,或者其他函数,最常见的如std::vector的push_backemplace_back。参数为左值引用意味着拷贝,为右值引用意味着移动。

class Array {
public:
    ......
 
    // 优雅
    Array(Array&& temp_array) {
        data_ = temp_array.data_;
        size_ = temp_array.size_;
        // 为防止temp_array析构时delete data,提前置空其data_      
        temp_array.data_ = nullptr;
    }
     
 
public:
    int *data_;
    int size_;
};
int main(){
    Array a;
 
    // 做一些操作
    .....
     
    // 左值a,用std::move转化为右值
    Array b(std::move(a));
}
// 例2:std::vector和std::string的实际例子
int main() {
    std::string str1 = "aacasxs";
    std::vector<std::string> vec;
     
    vec.push_back(str1); // 传统方法,copy
    vec.push_back(std::move(str1)); // 调用移动语义的push_back方法,避免拷贝,str1会失去原有值,变成空字符串
    vec.emplace_back(std::move(str1)); // emplace_back效果相同,str1会失去原有值
    vec.emplace_back("axcsddcas"); // 当然可以直接接右值
}
 
// std::vector方法定义
void push_back (const value_type& val);
void push_back (value_type&& val);
 
void emplace_back (Args&&... args);

在vector和string这个场景,加个std::move会调用到移动语义函数,避免了深拷贝。

除非设计不允许移动,STL类大都支持移动语义函数,即可移动的。 另外,编译器会默认在用户自定义的classstruct中生成移动语义函数,但前提是用户没有主动定义该类的拷贝构造等函数(具体规则自行百度哈)。 因此,可移动对象在<需要拷贝且被拷贝者之后不再被需要>的场景,建议使用std::move触发移动语义,提升性能。

还有些STL类是move-only的,比如unique_ptr,这种类只有移动构造函数,因此只能移动(转移内部对象所有权,或者叫浅拷贝),不能拷贝(深拷贝):

std::unique_ptr<A> ptr_a = std::make_unique<A>();

std::unique_ptr<A> ptr_b = std::move(ptr_a); // unique_ptr只有‘移动赋值重载函数‘,参数是&& ,只能接右值,因此必须用std::move转换类型

std::unique_ptr<A> ptr_b = ptr_a; // 编译不通过

std::move本身只做类型转换,对性能无影响。 我们可以在自己的类中实现移动语义,避免深拷贝,充分利用右值引用和std::move的语言特性。

4. 完美转发 std::forward

std::move一样,它的兄弟std::forward也充满了迷惑性,虽然名字含义是转发,但他并不会做转发,同样也是做类型转换.

与move相比,forward更强大,move只能转出来右值,forward都可以。

std::forward(u)有两个参数:T与 u。

a. 当T为左值引用类型时,u将被转换为T类型的左值;

b. 否则u将被转换为T类型右值。

举个例子,有main,A,B三个函数,调用关系为:main->A->B,建议先看懂2.3节对左右值引用本身是左值还是右值的讨论再看这里:

void B(int&& ref_r) {
    ref_r = 1;
}
 
// A、B的入参是右值引用
// 有名字的右值引用是左值,因此ref_r是左值
void A(int&& ref_r) {
    B(ref_r);  // 错误,B的入参是右值引用,需要接右值,ref_r是左值,编译失败
     
    B(std::move(ref_r)); // ok,std::move把左值转为右值,编译通过
    B(std::forward<int>(ref_r));  // ok,std::forward的T是int类型,属于条件b,因此会把ref_r转为右值
}
 
int main() {
    int a = 5;
    A(std::move(a));
}

C++为什么要有智能指针

使用原生指针会有对象生命周期管理的隐患,不管在什么时候什么地方析构什么对象都不合适。

智能指针有以下三种特点:

  • 具有RAII机制
  • 能像原生指针一样使用
  • 能够有效管理对象的生命周期

C++智能指针有哪些

  • unique_ptr

  • scoped_ptr

  • shared_ptr

  • weak_ptr

unique_ptr

对于一块内存资源,只允许一个指针指向它,是独有的资源。

shared_ptr

shared_ptr实现了共享拥有的概念,利用“引用计数”来控制堆上对象的生命周期。 原理也很简单,在初始化的时候引用计数设为1,每当被拷贝或者赋值的时候引用计数+1,析构的时候引用计数-1,直到引用计数被减到0,那么就可以delete掉对象的指针了。

shared_ptr是一种强引用,引用陈硕书中对强引用的描述就是:就好像对象上面绑了一根根的铁丝。对象身上的铁丝不全数卸干净,对象就无法得到释放,这个比喻还是很贴切的。因此shared_ptr也会带来一定的麻烦,比如他会意外地延长对象的寿命然后的空悬指针。

weak_ptr

weak_ptr并没有重载operator->和operator *操作符,因此不可直接通过weak_ptr使用对象,典型的用法是调用其lock函数来获得shared_ptr示例,进而访问原始对象。

shared_ptr对象相互引用造成的死锁问题

class B;
class A
{
public:
	shared_ptr<B> pb_;
	~A()
	{
		cout << "~A()" << endl;
	}
};

class B
{
public:
	shared_ptr<A> pa_;
	~B()
	{
		cout << ~B()" << endl;
	}
};

void fun()
{
	shared_ptr<B> pb(new B());
	shared_ptr<A> pa(new A());
	cout << pb.use_count() << endl;	//1
	cout << pa.use_count() << endl;	//1
	pb->pa_ = pa;
	pa->pb_ = pb;
	cout << pb.use_count() << endl;	//2
	cout << pa.use_count() << endl;	//2
}

int main()
{
	fun();
	return 0;
}

对比shared_ptr的强引用,weak_ptr就如同他的字面意思一样,是一个“弱”指针。用回上面的比喻,把这种弱引用比喻成“棉线”也是十分贴切的。weak_ptr指向一个shared_ptr管理的对象,并且不会增加shared_ptr的引用计数。进行该对象的内存管理的是那个强引用的shared_ptr, weak_ptr只是提供了对管理对象的一个访问手段。 他只可以从一个shared_ptr或另一个weak_ptr对象构造,并且可以通过成员函数“提升为”一个shared_ptr。
为什么抛弃auto_ptr

#include <iostream>
#include <string>
#include <memory>
using namespace std;

int main() {
  auto_ptr<string> films[5] =
 {
  auto_ptr<string> (new string("Fowl Balls")),
  auto_ptr<string> (new string("Duck Walks")),
  auto_ptr<string> (new string("Chicken Runs")),
  auto_ptr<string> (new string("Turkey Errors")),
  auto_ptr<string> (new string("Goose Eggs"))
 };
 auto_ptr<string> pwin;
 pwin = films[2]; // films[2] loses ownership. 将所有权从films[2]转让给pwin,此时films[2]不再引用该字符串从而变成空指针

 cout << "The nominees for best avian baseballl film are\n";
 for(int i = 0; i < 5; ++i)
  cout << *films[i] << endl;
 cout << "The winner is " << *pwin << endl;
 cin.get();

 return 0;
}

运行下发现程序崩溃了,原因在上面注释已经说的很清楚,films[2]已经是空指针了,下面输出访问空指针当然会崩溃了。但这里如果把auto_ptr换成shared_ptr或unique_ptr后,程序就不会崩溃,原因如下:

  • 使用shared_ptr时运行正常,因为shared_ptr采用引用计数,pwin和films[2]都指向同一块内存,在释放空间时因为事先要判断引用计数值的大小因此不会出现多次删除一个对象的错误。

  • 使用unique_ptr时编译出错,与auto_ptr一样,unique_ptr也采用所有权模型,但在使用unique_ptr时,程序不会等到运行阶段崩溃,而在编译器因下述代码行出现错误:

这就是为何要摒弃auto_ptr的原因,一句话总结就是:避免潜在的内存崩溃问题

实现auto_ptr

实现shared_ptr

#include<iostream>
#include<mutex>
#include<thread>
using namespace std;

template<class T>
class Shared_Ptr{
public:
	Shared_Ptr(T* ptr = nullptr)
		:_pPtr(ptr)
		, _pRefCount(new int(1))
		, _pMutex(new mutex)
	{}
	~Shared_Ptr()
	{
		Release();
	}
	Shared_Ptr(const Shared_Ptr<T>& sp)
		:_pPtr(sp._pPtr)
		, _pRefCount(sp._pRefCount)
		, _pMutex(sp._pMutex)
	{
		AddRefCount();
	}
	Shared_Ptr<T>& operator=(const Shared_Ptr<T>& sp)
	{
		//if (this != &sp)
		if (_pPtr != sp._pPtr)
		{
			// 释放管理的旧资源
			Release();
			// 共享管理新对象的资源,并增加引用计数
			_pPtr = sp._pPtr;
			_pRefCount = sp._pRefCount;
			_pMutex = sp._pMutex;
			AddRefCount();
		}
		return *this;
	}
	T& operator*(){
		return *_pPtr;
	}
	T* operator->(){
		return _pPtr;
	}
	int UseCount() { return *_pRefCount; }
	T* Get() { return _pPtr; }
	void AddRefCount()
	{
		_pMutex->lock();
		++(*_pRefCount);
		_pMutex->unlock();
	}
private:
	void Release()
	{
		bool deleteflag = false;
		_pMutex->lock();
		if (--(*_pRefCount) == 0)
		{
			delete _pRefCount;
			delete _pPtr;
			deleteflag = true;
		}
		_pMutex->unlock();
		if (deleteflag == true)
			delete _pMutex;
	}
private:
	int *_pRefCount;
	T* _pPtr;
	mutex* _pMutex;
};

循环引用,下面这种情况不会释放资源,需要将_prev 和_next改为weak_ptr

#include<memory>
struct ListNode
{
	int _data;
	shared_ptr<ListNode> _prev;
	shared_ptr<ListNode> _next;
	~ListNode(){ cout << "~ListNode()" << endl; }
};
int main()
{
	shared_ptr<ListNode> node1(new ListNode);
	shared_ptr<ListNode> node2(new ListNode);
	cout << node1.use_count() << endl;
	cout << node2.use_count() << endl;
	node1->_next = node2;
	node2->_prev = node1;
	cout << node1.use_count() << endl;
	cout << node2.use_count() << endl;
	system("pause");
	return 0;
}

const关键字的作用

const修饰普通变量

const int  a = 7; 
int  b = a; // 正确
a = 8;       // 错误,不能改变
#include<iostream>
 
using namespace std;
 
int main(void)
{
    const int  a = 7;
    int  *p = (int*)&a;
    *p = 8;
    cout<<a;
    system("pause");
    return 0;
}

对于 const 变量 a,我们取变量的地址并转换赋值给 指向 int 的指针,然后利用 *p = 8; 重新对变量 a 地址内的值赋值,然后输出查看 a 的值,输出的结果仍然是 7。我们可以在 const 前面加上 volatile 关键字。

Volatile 关键字跟 const 对应相反,是易变的,容易改变的意思。所以不会被编译器优化,编译器也就不会改变对 a 变量的操作。

volatile const int  a = 7;

此时输出a的值为8

const 修饰指针变量

int a = 8;
int* const p = &a; //指针常量
const int *q = &a; //常量指针
*q = 9;// 错误
q = &b; //正确

*p = 9; // 正确
int  b = 7;
p = &b; // 错误

const参数传递和函数返回值

A:值传递的 const 修饰传递,一般这种情况不需要 const 修饰,因为函数会自动产生临时变量复制实参值。

B:当 const 参数为指针时,可以防止指针被意外篡改。

C:自定义类型的参数传递,需要临时对象复制参数,对于临时对象的构造,需要调用构造函数,比较浪费时间,因此我们采取 const 外加引用传递的方法。

并且对于一般的 int、double 等内置类型,我们不采用引用的传递方式。

Const 修饰返回值分三种情况。

A:const 修饰内置类型的返回值,修饰与不修饰返回值作用一样。

B: const 修饰自定义类型的作为返回值,此时返回的值不能作为左值使用,既不能被赋值,也不能被修改。

C: const 修饰返回的指针或者引用,是否返回一个指向 const 的指针,取决于我们想让用户干什么。

const修饰类成员函数

const 修饰类成员函数,其目的是防止成员函数修改被调用对象的值,如果我们不想修改一个调用对象的值,所有的成员函数都应当声明为 const 成员函数。

**注意:**const 关键字不能与 static 关键字同时使用,因为 static 关键字修饰静态成员函数,静态成员函数不含有 this 指针,即不能实例化,const 成员函数必须具体到某一实例。

const和define

类型和安全检查不同

宏定义是字符替换,没有数据类型的区别,同时这种替换没有类型安全检查,可能产生边际效应等错误;

const常量是常量的声明,有类型区别,需要在编译阶段进行类型检查

编译器处理不同

宏定义是一个"编译时"概念,在预处理阶段展开,不能对宏定义进行调试,生命周期结束于编译时期;

const常量是一个"运行时"概念,在程序运行使用,类似于一个只读数据

存储方式不同

宏定义是直接替换,不会分配内存,存储于程序的代码段中;

const常量需要进行内存分配,存储于程序的数据段中

定义域不同

void f1 ()
{
    #define N 12
    const int n 12;
}
void f2 ()
{
    cout<<N <<endl; //正确,N已经定义过,不受定义域限制
    cout<<n <<endl; //错误,n定义域只在f1函数中
}

定义后能否取消

宏定义可以通过#undef来使之前的宏定义失效

const常量定义后将在定义域内永久有效

是否可以做函数参数

宏定义不能作为参数传递给函数

const常量可以在函数的参数列表中出现

static关键字的作用

类外使用

  • 1)在修饰变量的时候,static 修饰的静态局部变量只执行初始化一次,而且延长了局部变量的生命周期,直到程序运行结束以后才释放。
  • (2)static 修饰全局变量的时候,这个全局变量只能在本文件中访问,不能在其它文件中访问,即便是 extern 外部声明也不可以。
  • (3)static 修饰一个函数,则这个函数的只能在本文件中调用,不能被其他文件调用。static 修饰的变量存放在全局数据区的静态变量区,包括全局静态变量和局部静态变量,都在全局数据区分配内存。初始化的时候自动初始化为 0。
  • (4)不想被释放的时候,可以使用static修饰。比如修饰函数中存放在栈空间的数组。如果不想让这个数组在函数调用结束释放可以使用 static 修饰。
  • (5)考虑到数据安全性(当程序想要使用全局变量的时候应该先考虑使用 static)。

静态全局变量有以下特点:

静态变量与普通变量

  • (1)静态变量都在全局数据区分配内存,包括后面将要提到的静态局部变量;
  • (2)未经初始化的静态全局变量会被程序自动初始化为0(在函数体内声明的自动变量的值是随机的,除非它被显式初始化,而在函数体外被声明的自动变量也会被初始化为 0);
  • (3)静态全局变量在声明它的整个文件都是可见的,而在文件之外是不可见的。

**优点:**静态全局变量不能被其它文件所用;其它文件中可以定义相同名字的变量,不会发生冲突。

(1)全局变量和全局静态变量的区别

  • 1)全局变量是不显式用 static 修饰的全局变量,全局变量默认是有外部链接性的,作用域是整个工程,在一个文件内定义的全局变量,在另一个文件中,通过 extern 全局变量名的声明,就可以使用全局变量。
  • 2)全局静态变量是显式用 static 修饰的全局变量,作用域是声明此变量所在的文件,其他的文件即使用 extern 声明也不能使用

静态局部变量有以下特点:

  • (1)该变量在全局数据区分配内存;
  • (2)静态局部变量在程序执行到该对象的声明处时被首次初始化,即以后的函数调用不再进行初始化;
  • (3)静态局部变量一般在声明处初始化,如果没有显式初始化,会被程序自动初始化为 0;
  • (4)它始终驻留在全局数据区,直到程序运行结束。但其作用域为局部作用域,当定义它的函数或语句块结束时,其作用域随之结束。

类内使用

  • (1)静态成员函数中不能调用非静态成员。
  • (2)非静态成员函数中可以调用静态成员。因为静态成员属于类本身,在类的对象产生之前就已经存在了,所以在非静态成员函数中是可以调用静态成员的。
  • (3)静态成员变量使用前必须先初始化(如 int MyClass::m_nNumber = 0;),否则会在 linker 时出错。

一般总结:在类中,static 可以用来修饰静态数据成员和静态成员方法。

静态数据成员

  • (1)静态数据成员可以实现多个对象之间的数据共享,它是类的所有对象的共享成员,它在内存中只占一份空间,如果改变它的值,则各对象中这个数据成员的值都被改变。
  • (2)静态数据成员是在程序开始运行时被分配空间,到程序结束之后才释放,只要类中指定了静态数据成员,即使不定义对象,也会为静态数据成员分配空间。
  • (3)静态数据成员可以被初始化,但是只能在类体外进行初始化,若未对静态数据成员赋初值,则编译器会自动为其初始化为 0。
  • (4)静态数据成员既可以通过对象名引用,也可以通过类名引用。

静态成员函数

  • (1)静态成员函数和静态数据成员一样,他们都属于类的静态成员,而不是对象成员。
  • (2)非静态成员函数有 this 指针,而静态成员函数没有 this 指针。
  • (3)静态成员函数主要用来访问静态数据成员而不能访问非静态成员。

extern关键字的作用

1.extern修饰变量的声明

如果文件a.c需要引用b.c中变量int v,就可以在a.c中声明extern int v,然后就可以引用变量v。前提是被引用的变量v的链接属性必须是外链接(external)的,即全局变量(不能是static变量)。在使用时如果将extern放在函数体内,那么只能在函数体内用。

2.extern修饰函数声明

与修饰变量一样,跟include相比,这样跟快,因为只是用了一个函数,并不是把所有的全部包含进来

3.extern修饰符可用于指示C或者C++函数的调用规范

比如在C++中调用C库函数,就需要在C++程序中用extern “C”声明要引用的函数。

这是给链接器用的,告诉链接器在链接的时候用C函数规范来链接。主要原因是C++和C程序编译完成后在目标代码中命名规则不同。

在C++中引用C语言中的函数和变量,在包含C语言头文件(假设为cExample.h)时,需进行下列处理:

extern “C”{

#include “cExample.h”

}

在C中引用C++语言中的函数和变量时,C++的头文件需添加extern"C"

C++11新特性

image-20220823222511404

nullptr

nullptr是c++11用来表示空指针新引入的常量值,在c++中如果表示空指针语义时建议使用nullptr而不要使用NULL,因为NULL本质上是个int型的0,其实不是个指针

default

c++11引入default特性,多数时候用于声明构造函数为默认构造函数,如果类中有了自定义的构造函数,编译器就不会隐式生成默认构造函数

delete

c++中,如果开发人员没有定义特殊成员函数,那么编译器在需要特殊成员函数时候会隐式自动生成一个默认的特殊成员函数,例如拷贝构造函数或者拷贝赋值操作符,而我们有时候想禁止对象的拷贝与赋值,可以使用delete修饰

基于范围的for循环

int nArr[5] = {1,2,3,4,5};
for(int &x : nArr)
{
    x *=2;   //数组中每个元素倍乘
}

### auto & decltype

C++11引入了auto和decltype关键字,使用他们可以在编译期就推导出变量或者表达式的类型,方便开发者编码也简化了代码。

  • auto:让编译器在编译器就推导出变量的类型,可以通过=右边的类型推导出变量的类型。
auto a = 10; // 10是int型,可以自动推导出a是int
  • decltype:相对于auto用于推导变量类型,而decltype则用于推导表达式类型,这里只用于编译器分析表达式的类型,表达式实际不会进行运算。
cont int &i = 1;
int a = 2;
decltype(i) b = 2; // b是const int&

右值引用

Lambda

如果代码里面存在大量的小函数,而这些函数一般只被一两处调用,那么不妨将它们重构成Lambda表达式,也就是匿名函数。作用就是当你想用一个函数,但是又不想费神去命名一个函数。

列表初始化

智能指针

final & override

c++11关于继承新增了两个关键字,final用于修饰一个类,表示禁止该类进一步派生和虚函数的进一步重载,override用于修饰派生类中的成员函数,标明该函数重写了基类函数,如果一个函数声明了override但父类却没有这个虚函数,编译报错,使用override关键字可以避免开发者在重写基类函数时无意产生的错误。

constexpr

constexpr是c++11新引入的关键字,用于编译时的常量和常量函数,这里直接介绍constexpr和const的区别:

两者都代表可读,const只表示read only的语义,只保证了运行时不可以被修改,但它修饰的仍然有可能是个动态变量,而constexpr修饰的才是真正的常量,它会在编译期间就会被计算出来,整个运行过程中都不可以被改变,constexpr可以用于修饰函数,这个函数的返回值会尽可能在编译期间被计算出来当作一个常量,但是如果编译期间此函数不能被计算出来,那它就会当作一个普通函数被处理

构造函数委托

委托构造函数允许在同一个类中一个构造函数调用另外一个构造函数,可以在变量初始化时简化操作,通过代码来感受下委托构造函数的妙处吧:

image-20220509203553636

继承权限

分为两种情况,1.基类成员在派生类中的可见性 2.基类成员对派生类对象的可见性

image-20220718205357806

操作系统

什么是内存池

image-20220823222454784

如果程序需要大量的进行动态内存获取,这是十分消耗性能的,我们可以人为的维持一个可以获取的内存空间,如果需要就给用户,用户用完交回来而不是直接free,简单来说,内存池技术一次性获取到大块内存,然后在其之上自己管理内存的申请和释放,这样就绕过了标准库以及操作系统。

image-20220823222445333

三种内存池实现方式:

假设你的服务器程序非常简单,处理用户请求时只使用一种对象(数据结构),那么最简单的就是我们提前申请出一堆来,使用的时候拿出一个,使用完后还回去:

image-20220823222433012

第二种方式当内存池中的空闲内存不足以分配时我们就向malloc申请内存,只不过其大小是前一个的2倍:我们有一个指针free_ptr,指向接下来的空闲内存块起始位置,当向内存池分配内存时找到free_ptr并判断当前内存池剩余空闲是否足够就可以了,有就分配出去并修改free_ptr,否则向malloc再次成倍申请内存。

image-20220823222341170

第三种内存池会提前申请出一大段内存,然后将这一大段内存切分为大小相同的小内存块:

image-20220508011121080

然后我们自己来维护这些被切分出来的小内存块哪些是空闲的哪些是已经被分配的,比如我们可以使用栈这种数据结构,最初把所有空闲内存块地址push到栈中,分配内存是就pop出来一个,用户使用完毕后再push回栈里。

image-20220508011142689

从这里的设计我们可以看出,这种内存池有一个限制,这个限制就是说程序申请的最大内存不能超过这里内存块的大小,否则不足以装下用户数据,这需要我们对程序所涉及的业务非常了解才可以。

在使用线程池时,多个1线程会存在竞争关系,此时可以使用加锁的方式,或者线程局部存储技术。

什么是虚拟内存

虚拟内存 使得应用程序认为它拥有连续的可用的内存(一个连续完整的地址空间),而实际上,它通常是被分隔成多个物理内存碎片,还有部分暂时存储在外部磁盘存储器上,在需要时进行数据交换。与没有使用虚拟内存技术的系统相比,使用这种技术的系统使得大型程序的编写变得更容易,对真正的物理内存(例如RAM)的使用也更有效率。

  • 程序可以使用一系列相邻的虚拟地址来访问物理内存中不相邻的大内存缓冲区。
  • 程序可以使用一系列虚拟地址来访问大于可用物理内存的内存缓冲区。当物理内存的供应量变小时,内存管理器会将物理内存页(通常大小为 4 KB)保存到磁盘文件。数据或代码页会根据需要在物理内存与磁盘之间移动。
  • 不同进程使用的虚拟地址彼此隔离。一个进程中的代码无法更改正在由另一进程或操作系统使用的物理内存。

什么是零拷贝

正常一个程序想将一个文件发送出去,需要四次进程切换,以及4次拷贝

image-20220508012541832

mmap + write:

mmap代替read可以将内核缓存区的的数据映射到用户空间,减少一次拷贝。

image-20220508012927567

sendfile技术:只使用2次进程切换和两次DMA拷贝就完成。

image-20220508012812315

Linux软中断

为了避免由于中断处理程序执行时间过长,而影响正常进程的调度,Linux 将中断处理程序分为上半部和下半部:

  • 上半部,对应硬中断,由硬件触发中断,用来快速处理中断;

  • 下半部,对应软中断,由内核触发中断,用来异步处理上半部未完成的工作;

Linux 中的软中断包括网络收发、定时、调度、RCU 锁等各种类型,可以通过查看 /proc/softirqs 来观察软中断的累计中断次数情况,如果要实时查看中断次数的变化率,可以使用 watch -d cat /proc/softirqs 命令。

实现一个线程安全的栈

下面的例子是一个最简单的线程安全栈实现方式,lock_guard是一种RAII的思想,利用局部变量管理资源,让局部变量的生存周期结束,自动释放资源。在实现过程中pop()和top()会存在问题,下图是一种可能的操作顺序,这样同样一个值会被处理两次,且bug很难定位,一种解决方式就是将top()和pop()结合在一起,利用一个引用来传值。

image-20220508141128849

image-20220508140926516

进程间通信

1.匿名管道(针对父子进程情况)
2.命名管道
3.信号量
4.队列
5.共享内存
6.socket

信号量是怎么实现进程间通信的

信号量一般是和共享内存搭配起来一起实现进程间的通信,因为在使用共享内存的过程中,会涉及在同一时间多个进程对共享内存进行读写操作,信号量保证了进程间通信的互斥和同步。

如何创建一个守护进程

image-20220508132346763

1.nohub生成的进程严格来说不是守护进程,只是把一个进程在后台运行,运行完直接结束。

nohup 英文全称 no hang up(不挂起),用于在系统后台不挂断地运行命令,退出终端不会影响程序的运行。

2.fork创建比较灵活

(1)创建子进程,父进程退出。

经过这步以后,子进程就会成为孤儿进程(父进程先于子进程退出, 此时的子进程,成为孤儿进程,会被init进程收养)。使用fork()函数,如果返回值大于0,表示为父进程,exit(0),父进程退出,子进程继续。

(2)在子进程中创建新会话,使当前进程成为新会话组的组长。

使用setsid()函数,如果当前进程不是进程组的组长,则为当前进程创建一个新的会话期,使当前进程成为这个会话组的首进程,成为这个进程组的组长。

(3)改变当前目录为根目录。

由于守护进程在后台运行,开始于系统开启,终止于系统关闭,所以要将其目录改为系统的根目录下。进程在执行时,其文件系统不能被卸下。

(4)重新设置文件权限掩码。

进程从父进程那里继承了文件创建掩码,所以可能会修改守护进程存取权限位,所以要将文件创建掩码清除,umask(0);

(5)关闭文件描述符。

子进程从父进程那里继承了打开文件描述符。所以使用close即可关闭。

int main()
{   // 1创建子进程 ,父进程退出
  pid_t pid = fork();
  if(pid<0)
  {
   perror("fork error");
   return -1;
  }
  else if(pid>0)
  {
   exit(0);
  }
  else
  {
    while(1)
    {
      //2 组长
      setsid();
      // 改变路径至根目录
      chdir("/tmp"); 
      //重设文件掩码
      umask(0);
      //关闭文件描述符
      int des=getdtablesize();
      int i=0;
      for(i=0;i<des;i++)
      {
        close(i);
      }
    } 
    char buf[]="bat xld come!\n";
    int fd=open("xld.txt",O_WRONLY|O_CREAT |O_APPEND,0666);
     write(fd,buf,sizeof(buf));
     sleep(2);
    }
  return 0;
}

3.库函数直接创建,比较方便,但是灵活性不足

参数:

nochdir:=0将当前目录更改至“/”

noclose:=0将标准输入、标准输出、标准错误重定向至“/dev/null”

EPOLL,poll,select

工作队列

操作系统为了支持多任务,实现了进程调度的功能,会把进程分为“运行”和“等待”等几种状态。运行状态是进程获得cpu使用权,正在执行代码的状态;等待状态是阻塞状态,比如上述程序运行到recv时,程序会从运行状态变为等待状态,接收到数据后又变回运行状态。操作系统会分时执行各个运行状态的进程,由于速度很快,看上去就像是同时执行多个任务。

下图中的计算机中运行着A、B、C三个进程,其中进程A执行着上述基础网络程序,一开始,这3个进程都被操作系统的工作队列所引用,处于运行状态,会分时执行。

img

等待队列

当进程A执行到创建socket的语句时,操作系统会创建一个由文件系统管理的socket对象(如下图)。这个socket对象包含了发送缓冲区、接收缓冲区、等待队列等成员。等待队列是个非常重要的结构,它指向所有需要等待该socket事件的进程。

img

当程序执行到recv时,操作系统会将进程A从工作队列移动到该socket的等待队列中(如下图)。由于工作队列只剩下了进程B和C,依据进程调度,cpu会轮流执行这两个进程的程序,不会执行进程A的程序。所以进程A被阻塞,不会往下执行代码,也不会占用cpu资源

ps:操作系统添加等待队列只是添加了对这个“等待中”进程的引用,以便在接收到数据时获取进程对象、将其唤醒,而非直接将进程管理纳入自己之下。上图为了方便说明,直接将进程挂到等待队列之下。

唤醒进程

当socket接收到数据后,操作系统将该socket等待队列上的进程重新放回到工作队列,该进程变成运行状态,继续执行代码。也由于socket的接收缓冲区已经有了数据,recv可以返回接收到的数据。

select流程

select的实现思路很直接。假如程序同时监视如下图的sock1、sock2和sock3三个socket,那么在调用select之后,操作系统把进程A分别加入这三个socket的等待队列中。

preview

当任何一个socket收到数据后,中断程序将唤起进程。下图展示了sock2接收到了数据的处理流程。

preview

所谓唤起进程,就是将进程从所有的等待队列中移除,加入到工作队列里面。如下图所示。

image-20220508013633479

经由这些步骤,当进程A被唤醒后,它知道至少有一个socket接收了数据。程序只需遍历一遍socket列表,就可以得到就绪的socket。

这种简单方式行之有效,在几乎所有操作系统都有对应的实现。

但是简单的方法往往有缺点,主要是:

其一,每次调用select都需要将进程加入到所有监视socket的等待队列,每次唤醒都需要从每个队列中移除。这里涉及了两次遍历,而且每次都要将整个fds列表传递给内核,有一定的开销。正是因为遍历操作开销大,出于效率的考量,才会规定select的最大监视数量,默认只能监视1024个socket。

其二,进程被唤醒后,程序并不知道哪些socket收到数据,还需要遍历一次。

那么,有没有减少遍历的方法?有没有保存就绪socket的方法?这两个问题便是epoll技术要解决的。

补充说明: 本节只解释了select的一种情形。当程序调用select时,内核会先遍历一遍socket,如果有一个以上的socket接收缓冲区有数据,那么select直接返回,不会阻塞。这也是为什么select的返回值有可能大于1的原因之一。如果没有socket有数据,进程才会阻塞。

EPOLL

措施一:功能分离

select低效的原因之一是将“维护等待队列”和“阻塞进程”两个步骤合二为一。如下图所示,每次调用select都需要这两步操作,然而大多数应用场景中,需要监视的socket相对固定,并不需要每次都修改。epoll将这两个操作分开,先用epoll_ctl维护等待队列,再调用epoll_wait阻塞进程。显而易见的,效率就能得到提升。

image-20220508013609587

措施二:就绪列表

select低效的另一个原因在于程序不知道哪些socket收到数据,只能一个个遍历。如果内核维护一个“就绪列表”,引用收到数据的socket,就能避免遍历。如下图所示,计算机共有三个socket,收到数据的sock2和sock3被rdlist(就绪列表)所引用。当进程被唤醒后,只要获取rdlist的内容,就能够知道哪些socket收到数据。

preview

如下图所示,当某个进程调用epoll_create方法时,内核会创建一个eventpoll对象(也就是程序中epfd所代表的对象)。eventpoll对象也是文件系统中的一员,和socket一样,它也会有等待队列。

image-20220508013531026

创建一个代表该epoll的eventpoll对象是必须的,因为内核要维护“就绪列表”等数据,“就绪列表”可以作为eventpoll的成员。

维护监视列表

创建epoll对象后,可以用epoll_ctl添加或删除所要监听的socket。以添加socket为例,如下图,如果通过epoll_ctl添加sock1、sock2和sock3的监视,内核会将eventpoll添加到这三个socket的等待队列中。

preview

当socket收到数据后,中断程序会操作eventpoll对象,而不是直接操作进程。

接收数据

当socket收到数据后,中断程序会给eventpoll的“就绪列表”添加socket引用。如下图展示的是sock2和sock3收到数据后,中断程序让rdlist引用这两个socket。

preview

eventpoll对象相当于是socket和进程之间的中介,socket的数据接收并不直接影响进程,而是通过改变eventpoll的就绪列表来改变进程状态。

当程序执行到epoll_wait时,如果rdlist已经引用了socket,那么epoll_wait直接返回,如果rdlist为空,阻塞进程。

阻塞和唤醒进程

假设计算机中正在运行进程A和进程B,在某时刻进程A运行到了epoll_wait语句。如下图所示,内核会将进程A放入eventpoll的等待队列中,阻塞进程。

preview

当socket接收到数据,中断程序一方面修改rdlist,另一方面唤醒eventpoll等待队列中的进程,进程A再次进入运行状态(如下图)。也因为rdlist的存在,进程A可以知道哪些socket发生了变化。

image-20220508013453761

Linux内核初始化

  • 创建0号进程:INIT_TASK(init_task)

  • 异常处理类中断服务程序挂接:trap_init()

  • 内存初始化:mm_init()

  • 调度器初始化sched_init()

  • 剩余初始化:rest_init() 主要包括了区分内核态和用户态、初始化1号进程和初始化2号进程。

rest_init() 的一大工作是,用 kernel_thread(kernel_init, NULL, CLONE_FS)创建第二个进程,这个是 1 号进程。1 号进程对于操作系统来讲,有“划时代”的意义,因为它将运行一个用户进程,并从此开始形成用户态进程树。这里主要需要分析的是如何完成从内核态到用户态切换的过程。kernel_thread()代码如下所示,可见其中最主要的是第一个参数指针函数fn决定了栈中的内容,根据fn的不同将生成1号进程和后面的2号进程。

rest_init 另一大事情就是创建第三个进程,就是 2 号进程。kernel_thread(kthreadd, NULL, CLONE_FS | CLONE_FILES)又一次使用 kernel_thread 函数创建进程。这里需要指出一点,函数名 thread 可以翻译成“线程”,这也是操作系统很重要的一个概念。从内核态来看,无论是进程,还是线程,我们都可以统称为任务(Task),都使用相同的数据结构,平放在同一个链表中。这里的函数kthreadd,负责所有内核态的线程的调度和管理,是内核态所有线程运行的祖先。

什么是C10K

https://time.geekbang.org/column/article/262085?noteid=5345622

服务器如何处理1万的并发请求;

主要是epoll非阻塞模型 + 线程池

如何解决C1000K问题

C1000K 的解决方法,本质上还是构建在 epoll 的非阻塞 I/O 模型上。只不过,除了 I/O 模型之外,还需要从应用程序到 Linux 内核、再到 CPU、内存和网络等各个层次的深度优化,特别是需要借助硬件,来卸载那些原来通过软件处理的大量功能。

如何解决C10M

要解决这个问题,最重要就是跳过内核协议栈的冗长路径,把网络包直接送到要处理的应用程序那里去。这里有两种常见的机制,DPDK 和 XDP。第一种机制,DPDK,是用户态网络的标准。它跳过内核协议栈,直接由用户态进程通过轮询的方式,来处理网络接收。

img

第二种机制,XDP(eXpress Data Path),则是 Linux 内核提供的一种高性能网络数据路径。它允许网络包,在进入内核协议栈之前,就进行处理,也可以带来更高的性能。XDP 底层跟我们之前用到的 bcc-tools 一样,都是基于 Linux 内核的 eBPF 机制实现的。

image-20220508153408223

你可以看到,XDP 对内核的要求比较高,需要的是 Linux 4.8 以上版本,并且它也不提供缓存队列。基于 XDP 的应用程序通常是专用的网络应用,常见的有 IDS(入侵检测系统)、DDoS 防御、 cilium 容器网络插件等。

避免死锁

image-20220508151502992

image-20220508151520767

image-20220508151531993

超时检测是指拿到锁之后进行计时,如果超时那么就释放自己持有的锁,这种方式对于高并发并不有好,本来就等待了一段时间,又把锁给释放了。

死锁检测是指我将线程和持有的锁记录一下,如果我拿到这个锁会造成死锁问题,那我就不拿。著名的死锁检测算法:银行家算法https://blog.csdn.net/qq_33414271/article/details/80245715

管道是如何进行进程间通信的

管道创建出来后会有文件描述符,分别指向读端和写端,通常一个进程关闭读取fd,一个进程关闭写入fd,一般只支持单向通信。

image-20220508152542398

IO模型

https://www.zhihu.com/question/19732473/answer/241673170

程序分段

一个由C/C++编译的程序占用的内存分为以下几个部分
1、栈区(stack)— 由编译器自动分配释放 ,存放函数的参数值,局部变量的值等。其
操作方式类似于数据结构中的栈。
2、堆区(heap) — 一般由程序员分配释放, 若程序员不释放,程序结束时可能由OS回
收 。注意它与数据结构中的堆是两回事,分配方式倒是类似于链表,呵呵。
3、全局区(静态区)(static)—,全局变量和静态变量的存储是放在一块的,初始化的
全局变量和静态变量在一块区域, 未初始化的全局变量和未初始化的静态变量在相邻的另
一块区域。 - 程序结束后由系统释放。
4、文字常量区 —常量字符串就是放在这里的。 程序结束后由系统释放

5、程序代码区—存放函数体的二进制代码。

堆和栈的申请方式:

栈由系统自动分配,速度较快,在windows下栈是向低地址扩展的数据结构,是一块连续的内存区域,大小是2MB。

堆需要程序员自己申请,并指明大小,速度比较慢。在C中用malloc,C++中用new。另外,堆是向高地址扩展的数据结构,是不连续的内存区域,堆的大小受限于计算机的虚拟内存。因此堆空间获取和使用比较灵活,可用空间较大。

image-20220428203916809

image-20220428203826607

char s1[] = “aaaaaaaaaaaaaaa”;
char *s2 = “bbbbbbbbbbbbbbbbb”;
aaaaaaaaaaa是在运行时刻赋值的;
而bbbbbbbbbbb是在编译时就确定的;
但是,在以后的存取中,在栈上的数组比指针所指向的字符串(例如堆)快。
比如:
#include
void main()
{
char a = 1;
char c[] = “1234567890”;
char *p =“1234567890”;
a = c[1];
a = p[1];
return;
}
对应的汇编代码
10: a = c[1];
00401067 8A 4D F1 mov cl,byte ptr [ebp-0Fh]
0040106A 88 4D FC mov byte ptr [ebp-4],cl
11: a = p[1];
0040106D 8B 55 EC mov edx,dword ptr [ebp-14h]
00401070 8A 42 01 mov al,byte ptr [edx+1]
00401073 88 45 FC mov byte ptr [ebp-4],al
第一种在读取时直接就把字符串中的元素读到寄存器cl中,而第二种则要先把指针值读到
edx中,再根据edx读取字符,显然慢了。

线程与进程

image-20220508183135885

image-20220508184239000

image-20220420160312013

image-20220508183103644

malloc分配内存原理

malloc的实现方式其实是内存池的方式,malloc本身利用链表管理了很多个内存块,如果用户申请的内存大小大于malloc可以提供的极限,malloc会再去向操作系统申请内存空间(系统调用brk(), sbrk(), mmap(),)

堆的大小由start_brk 和brk决定,但是可以使用系统调用sbrk() 或brk()增加brk的值,达到增大堆空间的效果,但是系统调用代价太大,涉及到用户态和内核态的相互转换。所以,实际中系统分配较大的堆空间,进程通过malloc()库函数在堆上进行空间动态分配,堆如果不够用malloc可以进行系统调用,增大brk的值。

  • brk()和sbrk()

由之前的进程地址空间结构分析可以知道,要增加一个进程实际的可用堆大小,就需要将break指针向高地址移动。Linux通过brk和sbrk系统调用操作break指针。

  • mmap函数

mmap函数第一种用法是映射磁盘文件到内存中;而malloc使用的mmap函数的第二种用法,即匿名映射,匿名映射不映射磁盘文件,而是向映射区申请一块内存。

当申请小内存的时,malloc使用sbrk分配内存;当申请大内存时,使用mmap函数申请内存;但是这只是分配了虚拟内存,还没有映射到物理内存,当访问申请的内存时,才会因为缺页异常,内核分配物理内存。

image-20220823223545792

下面的步骤是malloc分配的过程,如果malloc管理的内存块可以满足要求,有三种匹配方式,第一种是依次遍历,找到第一个满足大小的内存块返回,第二种是满足的下一个内存块。第三种是全部遍历整个链表,返回最合适的一个。

分割空闲块是malloc找到的内存块大于需求,我们会把多余的内存分割出来放在链表中。

第三种是如果malloc管理的内存中没有大于申请大小的内存,此时会尝试能不能合并空闲块,如果不能那就向操作系统申请。

image-20220509110441259

如何来选择合适的锁

互斥锁:加锁失败后,线程会释放CPU,给其他线程

自旋锁:加锁失败后,线程会忙等待,直到它拿到锁

image-20220508194519072

互斥锁加锁失败后,会从用户态陷入内核态,让内核帮我们切换线程,虽然简化了使用锁的难度,但是存在一定的性能开销成本;

使用自旋锁时,在单核CPU上需要抢占式调度器(即通过时钟中断一个线程,运行其他线程),因为一个自旋的线程不会放弃CPU

读写锁:适用于能明确区分读操作和写操作的场景

image-20220508191432144

读写锁分为读优先和写优先,读优先锁优先服务读操作,只有没有读操作时,才可以写;写优先同理,都会造成饿死现象;

image-20220508191817913

上面的锁都是悲观锁,认为多线程同时修改共享资源的概率比较高,要上锁;

乐观锁是无锁编程,在线文档编辑就需要乐观锁

image-20220508191945926

image-20220823223508996

编译链接过程

https://blog.csdn.net/kang___xi/article/details/79571137

gcc -v 执行预处理生成一个ASCII码的中间文件.i

gcc -S 通过编译器将.i文件翻译为一个ASCII码汇编语言文件.s

gcc -c通过汇编器将.s翻译成一个可重定向目标文件.o

链接器将多个.o以及系统目标文件创建为一个可执行目标文件。

image-20220823223451617

数据:数据指的是称序中定义的全局变量和静态变量。还有一种特殊的数据叫做常量。数据存放的区域有三个地方:.data段、.bss段和.rodata段。对于初始化不为0的全局变量和静态变量存放在.data段,对于未初始化或者初始化值为0的段存放在.bss段中,而且不占目标文件的空间

指令:程序代码

符号:在程序中,所有数据都会产生符号,而对于代码段只有函数名会产生符号。而且符号的作用域有global和local之分,对于未用static修饰过的全局变量和函数产生的均是global符号,这样的变量和函数可以被其他文件所看见和引用;而使用static修饰过的变量和函数,它们的作用域仅局限于当前文件,不会被其他文件所看见,即其他文件中也无法引用local符号的变量和函数。虚拟内存空间布局:

虚拟空间布局

image-20220823223438769

符号表如下图,第一列是符号的地址,由于编译的时候不分配地址,所以放的是零地址或者偏移量;第二列是符号的作用域(g代表global,l代表local),前面讨论了用static修饰过的符号均是local的(不明白的搜一下static关键字的作用)

image-20220823223423637

链接过程

1.链接
链接过程分为两步,第一步是合并所有目标文件的段,并调整段偏移和段长度,合并符号表,分配内存地址;第二步是链接的核心,进行符号的重定位。

(1)合并段

   所有相同属性的段进行合并,组织在一个页面上,这样更节省空间。如.text段的权限是可读可执行,.rodata段也是可读可执行,所以将两者合并组织在一个页面上;同理合并.data段和.bss段。

(2)合并符号表

   链接阶段只处理所有obj文件的global符号,local符号不作任何处理。

(3)符号解析

   符号解析指的是所有引用符号的地方都要找到符号定义的地方。

(4)分配内存地址

   在编译过程中不分配地址(给的是零地址和偏移),直到符号解析完成以后才分配地址

(5)符号重定位

    因为在编译过程中不分配地址,所以在目标文件所以数据出现的地方都给的是零地址,所有函数调用的地方给的是相对于下一条指令的地址的偏移量。在符号重定位时,要把分配的地址回填到数据和函数调用出现的地方,而且对于数据而言填的是绝对地址,而对函数调用而言填的是偏移量。

image-20220823223357538

可执行程序

image-20220823223347417

首先看一下可执行文件的头部,如下图,里面记录了函数的入口点地址为0x08048094(后面会解释这个值的来由),还有就是size of this headers,程序头部占52个字节,然后还有三个program headers,每个program headers占32字节,共占3*32=96字节,所以程序头部+program heades=52+96=0x94,而从虚拟地址空间布局可知.text段正好是从0x08048000开始的,所以可执行程序的入口点就是0x08048000+0x94=0x08048094:

image-20220823223338419

然后看看这三个program headers里面的内容,第一个load项的属性是可读可执行,其实存放的就是代码段;第二个load项的属性是可读可写,其实存放的就是数据段。这两个load项的意义在于它指示了哪些段会被加载到同一个页面中:

image-20220823223328365

当双击一个可执行程序时,首先解析其文件头部ELF header获取entry point address程序入口点地址,然后按照两个load项的指示将相应的段通过mmap()函数映射到虚拟页面中(虚拟页面存在于虚拟地址空间中),最后再通过多级页表映射将虚拟页面映射到物理页面中。

说完编译链接,最后说明如何将VP映射到PP就打工告成了。

分为三步,1.首先是创建虚拟地址到物理内存的映射(创建内核地址映射结构体),创建页目录和页表;2. 再就是加载代码段和数据段;3.把可执行文件的入口地址写到CPU的PC寄存器中。

什么是动态链接、静态链接

https://blog.csdn.net/kang___xi/article/details/80210717

静态链接和动态链接两者最大的区别就在于链接的时机不一样,静态链接是在形成可执行程序前,而动态链接的进行则是在程序执行时。

静态编译

在我们的实际开发中,不可能将所有代码放在一个源文件中,所以会出现多个源文件,而且多个源文件之间不是独立的,而会存在多种依赖关系,如一个源文件可能要调用另一个源文件中定义的函数,但是每个源文件都是独立编译的,即每个*.c文件会形成一个*.o文件,为了满足前面说的依赖关系,则需要将这些源文件产生的目标文件进行链接,从而形成一个可以执行的程序。这个链接的过程就是静态链接
链接器在链接静态链接库的时候是以目标文件为单位的。比如我们引用了静态库中的printf()函数,那么链接器就会把库中包含printf()函数的那个目标文件链接进来,如果很多函数都放在一个目标文件中,很可能很多没用的函数都被一起链接进了输出结果中。由于运行库有成百上千个函数,数量非常庞大,每个函数独立地放在一个目标文件中可以尽量减少空间的浪费,那些没有被用到的目标文件就不要链接到最终的输出文件中。
静态链接的缺点很明显,一是浪费空间,二是更新困难;

动态编译

动态链接的基本思想是把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接在一起形成一个完整的程序,而不是像静态链接一样把所有程序模块都链接成一个单独的可执行文件。

假设现在有两个程序program1.o和program2.o,这两者共用同一个库lib.o,假设首先运行程序program1,系统首先加载program1.o,当系统发现program1.o中用到了lib.o,即program1.o依赖于lib.o,那么系统接着加载lib.o,如果program1.o和lib.o还依赖于其他目标文件,则依次全部加载到内存中。当program2运行时,同样的加载program2.o,然后发现program2.o依赖于lib.o,但是此时lib.o已经存在于内存中,这个时候就不再进行重新加载,而是将内存中已经存在的lib.o映射到program2的虚拟地址空间中,从而进行链接(这个链接过程和静态链接类似)形成可执行程序。
动态链接地址是如何重定位的呢?
虽然动态链接把链接过程推迟到了程序运行时,但是在形成可执行文件时(注意形成可执行文件和执行程序是两个概念),还是需要用到动态链接库。比如我们在形成可执行程序时,发现引用了一个外部的函数,此时会检查动态链接库,发现这个函数名是一个动态链接符号,此时可执行程序就不对这个符号进行重定位,而把这个过程留到装载时再进行。

为什么Linux与windows程序不能通用

一个可执行的二进制文件包含的不仅仅是机器指令,还包括各种数据、程序运行资源,机器指令只是其中的一部分。

一 个可执行文件要被执行的时候,操作系统需要为其分配资源,这些资源包括:内存空间(物理的和虚拟的),进程、线程资源等等,其中可执行文件的机器指令一般 都放在代码段(汇编语言里称之为text段),其它资源可能放到数据段以及其它段里,这里“段”(segment)可以大致的理解为一段内存范围。操作系 统(Windows/Linux)需要知道这个可执行文件需要多大的内存,有多少个段,分别载入到哪些内存地址上。可执行文件需要告诉操作系统,要为可执行文件准备哪些东西它才能运行。

可执行文件在执行之前,操作系统要有一些准备工作,因为不同的操作系统,准备工作是不同的,所以可执行文件的格式不完全相同。Windows上大部分可执行文件为PE格式,Linux里大部分可执行文件为ELF格式。格式不同导致了不同的可执行文件无法跨平台直接使用。这是原因之一。

为什么用户空间保留了128M空间

这个大小没有讲究吧,这个东西比较随意,也许只是设计这个的人觉得128M合适。出现这个区域的一个可以猜测的原因是,让0地址变得不允许访问,你在使用指针时,初始为0x0,以后如果没有赋值就直接访问会报错,让你的错误直接显现出来,而不是有可能不出错。

为什么需要线程池

如何定位内存泄漏

image-20220508195607365

怎么理解内存中的buffer和cache

image-20220823223253817

image-20220508201031202

1、Buffer(缓冲区)是系统两端处理速度平衡(从长时间尺度上看)时使用的。它的引入是为了减小短期内突发I/O的影响,起到流量整形的作用。比如生产者——消费者问题,他们产生和消耗资源的速度大体接近,加一个buffer可以抵消掉资源刚产生/消耗时的突然变化。
2、Cache(缓存)则是系统两端处理速度不匹配时的一种折衷策略。因为CPU和memory之间的速度差异越来越大,所以人们充分利用数据的局部性(locality)特征,通过使用存储系统分级(memory hierarchy)的策略来减小这种差异带来的影响。
3、假定以后存储器访问变得跟CPU做计算一样快,cache就可以消失,但是buffer依然存在。比如从网络上下载东西,瞬时速率可能会有较大变化,但从长期来看却是稳定的,这样就能通过引入一个buffer使得OS接收数据的速率更稳定,进一步减少对磁盘的伤害。
4、TLB(Translation Lookaside Buffer,翻译后备缓冲器)名字起错了,其实它是一个cache.

什么是消息队列

数据结构

什么是跳表

https://www.jianshu.com/p/9d8296562806

跳表是可以二分查找的有序链表,空间复杂度为O(N),查找时间复杂度为O(logN)

image-20220823223242819

外部排序简单应用

image-20220509165324183

第一种方法就是合并多个有序数组的思路,利用一个优先队列,每个文件都设置一个指针,每次从队列中取出一个元素,写入到一个新文件。将该元素的下一个压入队列,不断重复,当十个文件遍历完成就可以了。

但是内存有1个G,所以一次读取可以直接读入50M。写入的时候也是一次性写入500M;

第二种方法是创建10个IO流,读取一次比较一次。这样可以利用顺序IO和缓存的优势提高排序速度。

排序算法

https://zhuanlan.zhihu.com/p/57088609

https://zhuanlan.zhihu.com/p/68672733?utm_source=wechat_session&utm_medium=social&utm_oi=791048238638698496&utm_campaign=shareopn

B树和B+树

https://zhuanlan.zhihu.com/p/54102723

B树特点

preview

  1. 树内的每个节点都存储数据
  2. 叶子节点之间无指针相邻

B+树特点

preview

  1. 数据只出现在叶子节点
  2. 所有叶子节点增加了一个链指针

**(1)**B树的树内存储数据,因此查询单条数据的时候,B树的查询效率不固定,最好的情况是O(1)。我们可以认为在做单一数据查询的时候,使用B树平均性能更好。但是,由于B树中各节点之间没有指针相邻,因此B树不适合做一些数据遍历操作。

**(2)**B+树的数据只出现在叶子节点上,因此在查询单条数据的时候,查询速度非常稳定。因此,在做单一数据的查询上,其平均性能并不如B树。但是,B+树的叶子节点上有指针进行相连,因此在做数据遍历的时候,只需要对叶子节点进行遍历即可,这个特性使得B+树非常适合做范围查询。

B树优点:

B树相对于B+树的优点是,如果经常访问的数据离根节点很近,而B树的非叶子节点存储关键字数据的地址,所以这种数据检索的时候会要比B+树快。

B+树优点:

B+树的层级更少:相较于B树B+每个非叶子节点存储的关键字数更多,树的层级更少所以查询数据更快;

B+树查询速度更稳定:B+所有关键字数据地址都存在叶子节点上,所以每次查找的次数都相同所以查询速度要比B树更稳定;

B+树天然具备排序功能:B+树所有的叶子节点数据构成了一个有序链表,在查询大小区间的数据时候更方便,数据紧密性很高,缓存的命中率也会比B树高。

B+树全节点遍历更快:B+树遍历整棵树只需要遍历所有的叶子节点即可,而不需要像B树一样需要对每一层进行遍历,这有利于数据库做全表扫描。

计算机网络

tcp和udp有什么区别

如何提升TCP三次握手性能

TCP四次挥手

如何提升四次挥手性能

HTTP2.0在1.1上有什么优化

HTTP 2.0新特性 增加二进制分帧 压缩头部(header compression) 多路复用(multiplexing) 请求优先级 服务器提示(server push)

HTTPS的ssl的验证过程是怎么样的

简述从输入网站到浏览器显示的过程

UDP怎么实现可靠传输

TCP三次握手

image-20220421094201726

image-20220421094213862

image-20220421101518590

image-20220421101533362

image-20220823223146368

有了mac地址为什么还需要IP地址

https://www.zhihu.com/question/21546408

HTTPS加密原理

https://zhuanlan.zhihu.com/p/43789231

TCP流量控制算法

TCP拥塞控制

TCP/IP七层协议

什么是爬虫,列举5种反爬机制

数据库

什么是Mysql幻读

Mysql的普通索引和唯一索引的区别

Mysql为什么选择B+树

单纯从性能速度上看,平衡二叉树(这里暂时不展开各种数据结构)查找效率高于B+树。为什么不用了?

核心原因是受限于磁盘i/o读取速度。mysql一般用于存储比较大的数据,使用的都是机械硬盘。机械硬盘一次数据读取的时间是毫秒级的,和内存读取远远不在一个量级。
如果使用二叉树这种多层级结构,会导致磁盘的多次读取,每读取下一层数据,都是一次磁盘重新寻址。
所以B树,B+树 这种多叉树的优势有体现出来了。一个4层的B+树,基本能覆盖上亿数据的查找。

Mysql为什么选择B+树而不用跳表

图中应该是单项循环链表。

image-20220823223112183

image-20220507164434883

mysql有几种隔离级别

image-20220823223119927

image-20220511182259758

例子:

mysql> create table T(c int) engine = InnoDB
insert into T(c) values(1)

image-20220511182416633

image-20220823223030432

image-20220511182429137

image-20220511182540364

image-20220823223052474

设计模式

单例模式

懒汉模式+多线程安全(双重校验锁)

只有被需要的时候才实例化,需要加锁,双重校验锁的目的是避免多个线程加锁消耗资源。之所以双重检验锁是为了避免每次调用getSingleton都加锁

public class Singleton {  
    private volatile static Singleton singleton;  
    private Singleton (){}  
    public static Singleton getSingleton() {  
        if (singleton == null) {  
            synchronized (Singleton.class) {  
                if (singleton == null) {  
                    singleton = new Singleton();  
                }  
            }  
        }  
    	return singleton;  
    }  
}

饿汉模式+多线程安全

类一加载直接创建实例,无需锁;

public class Singleton {  
    private static Singleton instance = new Singleton();  
    private Singleton (){}  
    public static Singleton getInstance() {  
    	return instance;  
    }  
}

工厂模式

https://zhuanlan.zhihu.com/p/83535678 基础

https://zhuanlan.zhihu.com/p/83537599 进阶

  • 工厂模式介绍:

简单工厂模式:

image-20220823223015077

preview

简单工厂模式的结构组成

  1. 工厂类:工厂模式的核心类,会定义一个用于创建指定的具体实例对象的接口。

  2. 抽象产品类:是具体产品类的继承的父类或实现的接口。

  3. 具体产品类:工厂类所创建的对象就是此具体产品实例。

简单工厂模式的特点:

工厂类封装了创建具体产品对象的函数。

简单工厂模式的缺陷:

扩展性非常差,新增产品的时候,需要去修改工厂类。

工厂方法模式

具体情形:

现各类鞋子抄的非常火热,于是为了大量生产每种类型的鞋子,则要针对不同品牌的鞋子开设独立的生产线,那么每个生产线就只能生产同类型品牌的鞋。

UML图:

img

工厂方法模式的结构组成:

  1. 抽象工厂类:工厂方法模式的核心类,提供创建具体产品的接口,由具体工厂类实现。

  2. 具体工厂类:继承于抽象工厂,实现创建对应具体产品对象的方式。

  3. 抽象产品类:它是具体产品继承的父类(基类)。

  4. 具体产品类:具体工厂所创建的对象,就是此类。

工厂方法模式的特点:

  • 工厂方法模式抽象出了工厂类,提供创建具体产品的接口,交由子类去实现。
  • 工厂方法模式的应用并不只是为了封装具体产品对象的创建,而是要把具体产品对象的创建放到具体工厂类实现。

工厂方法模式的缺陷:

  • 每新增一个产品,就需要增加一个对应的产品的具体工厂类。相比简单工厂模式而言,工厂方法模式需要更多的类定义。
  • 一条生产线只能一个产品。

抽象工厂模式

具体情形:

鞋厂为了扩大了业务,不仅只生产鞋子,把运动品牌的衣服也一起生产了。

UML图:

image-20220823222958838

抽象工厂模式的结构组成(和工厂方法模式一样):

  1. 抽象工厂类:工厂方法模式的核心类,提供创建具体产品的接口,由具体工厂类实现。

  2. 具体工厂类:继承于抽象工厂,实现创建对应具体产品对象的方式。

  3. 抽象产品类:它是具体产品继承的父类(基类)。

  4. 具体产品类:具体工厂所创建的对象,就是此类。

抽象工厂模式的特点:

提供一个接口,可以创建多个产品族中的产品对象。如创建耐克工厂,则可以创建耐克鞋子产品、衣服产品、裤子产品等。

抽象工厂模式的缺陷:

同工厂方法模式一样,新增产品时,都需要增加一个对应的产品的具体工厂类。

模板工厂

为了解决添加新的东西需要修改工厂类的问题,提取了模板工厂

产品注册模板类 + 单例工厂模板类

观察者模式

https://developer.aliyun.com/article/296846

其他

protobuf是什么

https://halfrost.com/protobuf_encode/#toc-17

Protocol Buffers 是一种轻便高效的结构化数据存储格式,可以用于结构化数据串行化,或者说序列化。它很适合做数据存储或 RPC 数据交换格式。可用于通讯协议、数据存储等领域的语言无关、平台无关、可扩展的序列化结构数据格式。

  • 第一点:我们可以考察 Protobuf 序列化后的信息内容。您可以看到 Protocol Buffer 信息的表示非常紧凑,这意味着消息的体积减少,自然需要更少的资源。比如网络上传输的字节数更少,需要的 IO 更少等,从而提高性能。
  • 第二点:我们需要理解 Protobuf 封解包的大致过程,从而理解为什么会比 XML 快很多。
  • image-20220823222940284

img

Varint 编码:

Varint 是一种紧凑的表示数字的方法。它用一个或多个字节来表示一个数字,值越小的数字使用越少的字节数。这能减少用来表示数字的字节数。

Varint 中的每个字节(最后一个字节除外)都设置了最高有效位(msb),这一位表示还会有更多字节出现。每个字节的低 7 位用于以 7 位组的形式存储数字的二进制补码表示,最低有效组首位。

如果用不到 1 个字节,那么最高有效位设为 0 ,如下面这个例子,1 用一个字节就可以表示,所以 msb 为 0.

0000 0001

如果需要多个字节表示,msb 就应该设置为 1 。例如 300,如果用 Varint 表示的话:

1010 1100 0000 0010

1. Message Structure 编码

protocol buffer 中 message 是一系列键值对。message 的二进制版本只是使用字段号(field’s number 和 wire_type)作为 key。每个字段的名称和声明类型只能在解码端通过引用消息类型的定义(即 .proto 文件)来确定。这一点也是人们常常说的 protocol buffer 比 JSON,XML 安全一点的原因,如果没有数据结构描述 .proto 文件,拿到数据以后是无法解释成正常的数据的。

img

message Test1 {
  required int32 a = 1;
}

如果存在上面这样的一个 message 的结构,如果存入 150,在 Protocol Buffer 中显示的二进制应该为 08 96 01 。

末尾 3 位表示的是 value 的类型,这里是 000,即 0 ,代表的是 varint 值。右移 3 位,即 0001,这代表的就是字段号(field number)。tag 的例子就举这么多,接下来举一个 value 的例子,还是用 varint 来举例:

96 01 = 1001 0110  0000 0001000 0001  ++  001 0110 (drop the msb and reverse the groups of 7 bits)10010110128 + 16 + 4 + 2 = 150

可以 96 01 代表的数据就是 150 。

2. Signed Integers 编码

从上面的表格里面可以看到 wire_type = 0 中包含了无符号的 varints,但是如果是一个无符号数呢?

一个负数一般会被表示为一个很大的整数,因为计算机定义负数的符号位为数字的最高位。如果采用 Varint 表示一个负数,那么一定需要 10 个 byte 长度。

为何 32 位和 64 位的负数都需要 10 个 byte 长度呢?

C

inline void CodedOutputStream::WriteVarint32SignExtended(int32 value) {
WriteVarint64(static_cast<uint64>(value));
}

因为源码里面是这么规定的。32 位的有符号数都会转换成 64 位无符号来处理。至于源码为什么要这么规定呢,猜想可能是怕 32 位的负数转换会有溢出的可能。(只是猜想)

为此 Google Protocol Buffer 定义了 sint32 这种类型,采用 zigzag 编码。将所有整数映射成无符号整数,然后再采用 varint 编码方式编码,这样,绝对值小的整数,编码后也会有一个较小的 varint 编码值。

Zigzag 映射函数为:

C

Zigzag(n) = (n << 1) ^ (n >> 31), n 为 sint32 时

Zigzag(n) = (n << 1) ^ (n >> 63), n 为 sint64 时

按照这种方法,-1 将会被编码成 1,1 将会被编码成 2,-2 会被编码成 3,如下表所示:

需要注意的是,第二个转换 (n >> 31) 部分,是一个算术转换。所以,换句话说,移位的结果要么是一个全为0(如果n是正数),要么是全部1(如果n是负数)。

当 sint32 或 sint64 被解析时,它的值被解码回原始的带符号的版本。

3. Non-varint Numbers

Non-varint 数字比较简单,double 、fixed64 的 wire_type 为 1,在解析时告诉解析器,该类型的数据需要一个 64 位大小的数据块即可。同理,float 和 fixed32 的 wire_type 为5,给其 32 位数据块即可。两种情况下,都是高位在后,低位在前。

说 Protocol Buffer 压缩数据没有到极限,原因就在这里,因为并没有压缩 float、double 这些浮点类型

4. 字符串

image-20220823222923681

img

wire_type 类型为 2 的数据,是一种指定长度的编码方式:key + length + content,key 的编码方式是统一的,length 采用 varints 编码方式,content 就是由 length 指定长度的 Bytes。

举例,假设定义如下的 message 格式:

message Test2 {
  optional string b = 2;
}

设置该值为"testing",二进制格式查看:

C

12 07 74 65 73 74 69 6e 67

74 65 73 74 69 6e 67 是“testing”的 UTF8 代码。

此处,key 是16进制表示的,所以展开是:

12 -> 0001 0010,后三位 010 为 wire type = 2,0001 0010 右移三位为 0000 0010,即 tag = 2。

length 此处为 7,后边跟着 7 个bytes,即我们的字符串"testing"。

所以 wire_type 类型为 2 的数据,编码的时候会默认转换为 T-L-V (Tag - Length - Value)的形式

消息系统

为什么要有消息系统

解耦、异步、削峰

解耦是指将消费者和生产者分割开来。异步是指当用户请求时可以直接返回,剩下的事情慢慢做。削峰是指面对突然的大量的请求,可以暂时将请求保存下来慢慢处理。

消息系统的缺点

1.系统可用性降低
系统引入的外部依赖越多,越容易挂掉。本来你就是 A 系统调用 BCD 三个系统的接口就好了,ABCD 四个系统还好好的,没啥问题,你偏加个 MQ 进来,万一 MQ 挂了咋整?MQ 一挂,整套系统崩溃,你不就完了?如何保证消息队列的高可用,可以点击这里查看。

2.系统复杂度提高
硬生生加个 MQ 进来,你怎么保证消息没有重复消费?怎么处理消息丢失的情况?怎么保证消息传递的顺序性?头大头大,问题一大堆,痛苦不已。

3.一致性问题

A 系统处理完了直接返回成功了,人都以为你这个请求就成功了;但是问题是,要是 BCD 三个系统那里,BD 两个系统写库成功了,结果 C 系统写库失败了,咋整?你这数据就不一致了

image-20220823222843496

image-20220507003734412

RabbitMq

RabbitMQ是实现了高级消息队列协议(AMQP)的开源消息代理软件(亦称面向消息的中间件)。RabbitMQ服务器是用Erlang语言编写的,而群集和故障转移是构建在开放电信平台框架上的。所有主要的编程语言均有与代理接口通讯的客户端库。

1.RabbitMq如何保证数据不丢失

image-20220507125922310

生产者弄丢了数据

生产者将数据发送到 RabbitMQ 的时候,可能数据就在半路给搞丢了,因为网络问题啥的,都有可能。

此时可以选择用 RabbitMQ 提供的事务功能,就是生产者发送数据之前开启 RabbitMQ 事务 channel.txSelect ,然后发送消息,如果消息没有成功被 RabbitMQ 接收到,那么生产者会收到异常报错,此时就可以回滚事务 channel.txRollback ,然后重试发送消息;如果收到了消息,那么可以提交事务 channel.txCommit 。

但是问题是,RabbitMQ 事务机制(同步)一搞,基本上吞吐量会下来,因为太耗性能。

所以一般来说,如果你要确保说写 RabbitMQ 的消息别丢,可以开启 confirm 模式,在生产者那里设置开启 confirm 模式之后,你每次写的消息都会分配一个唯一的 id,然后如果写入了 RabbitMQ 中,RabbitMQ 会给你回传一个 ack 消息,告诉你说这个消息 ok 了。如果 RabbitMQ 没能处理这个消息,会回调你的一个 nack 接口,告诉你这个消息接收失败,你可以重试。而且你可以结合这个机制自己在内存里维护每个消息 id 的状态,如果超过一定时间还没接收到这个消息的回调,那么你可以重发。

事务机制和 confirm 机制最大的不同在于,事务机制是同步的,你提交一个事务之后会阻塞在那儿,但是 confirm 机制是异步的,你发送个消息之后就可以发送下一个消息,然后那个消息 RabbitMQ 接收了之后会异步回调你的一个接口通知你这个消息接收到了。

所以一般在生产者这块避免数据丢失,都是用 confirm 机制的。

RabbitMq弄丢了数据

就是 RabbitMQ 自己弄丢了数据,这个你必须开启 RabbitMQ 的持久化,就是消息写入之后会持久化到磁盘,哪怕是 RabbitMQ 自己挂了,恢复之后会自动读取之前存储的数据,一般数据不会丢。除非极其罕见的是,RabbitMQ 还没持久化,自己就挂了,可能导致少量数据丢失,但是这个概率较小。

设置持久化有两个步骤:

创建 queue 的时候将其设置为持久化
这样就可以保证 RabbitMQ 持久化 queue 的元数据,但是它是不会持久化 queue 里的数据的。

第二个是发送消息的时候将消息的 deliveryMode 设置为 2
就是将消息设置为持久化的,此时 RabbitMQ 就会将消息持久化到磁盘上去。

必须要同时设置这两个持久化才行,RabbitMQ 哪怕是挂了,再次重启,也会从磁盘上重启恢复 queue,恢复这个 queue 里的数据。

注意,哪怕是你给 RabbitMQ 开启了持久化机制,也有一种可能,就是这个消息写到了 RabbitMQ 中,但是还没来得及持久化到磁盘上,结果不巧,此时 RabbitMQ 挂了,就会导致内存里的一点点数据丢失。

所以,持久化可以跟生产者那边的 confirm 机制配合起来,只有消息被持久化到磁盘之后,才会通知生产者 ack 了,所以哪怕是在持久化到磁盘之前,RabbitMQ 挂了,数据丢了,生产者收不到 ack ,你也是可以自己重发的。

消费端弄丢了数据

这个时候得用 RabbitMQ 提供的 ack 机制,简单来说,就是你必须关闭 RabbitMQ 的自动 ack ,可以通过一个 api 来调用就行,然后每次你自己代码里确保处理完的时候,再在程序里 ack 一把。这样的话,如果你还没处理完,不就没有 ack 了?那 RabbitMQ 就认为你还没处理完,这个时候 RabbitMQ 会把这个消费分配给别的 consumer 去处理,消息是不会丢的。

2.如何保证高可用

使用镜像集群模式(高可用性),你创建的 queue,无论元数据还是 queue 里的消息都会存在于多个实例上,就是说,每个 RabbitMQ 节点都有这个 queue 的一个完整镜像,包含 queue 的全部数据的意思。然后每次你写消息到 queue 的时候,都会自动把消息同步到多个实例的 queue 上。

image-20220507131707900

这样的话,好处在于,你任何一个机器宕机了,没事儿,其它机器(节点)还包含了这个 queue 的完整数据,别的 consumer 都可以到其它节点上去消费数据。坏处在于,第一,这个性能开销也太大了吧,消息需要同步到所有机器上,导致网络带宽压力和消耗很重!第二,这么玩儿,不是分布式的,就没有扩展性可言了,如果某个 queue 负载很重,你加机器,新增的机器也包含了这个 queue 的所有数据,并没有办法线性扩展你的 queue。你想,如果这个 queue 的数据量很大,大到这个机器上的容量无法容纳了,此时该怎么办呢?

面试题:如何避免消息重复投递或重复消费?
在消息生产时,MQ 内部针对每条生产者发送的消息生成一个 inner-msg-id,作为去重的依据(消息投递失败并重传),避免重复的消息进入队列;在消息消费时,要求消息体中必须要有一个 bizId(对于同一业务全局唯一,如支付 ID、订单 ID、帖子 ID 等)作为去重的依据,避免同一条消息被重复消费。

3.消息基于什么传输?

由于 TCP 连接的创建和销毁开销较大,且并发数受系统资源限制,会造成性能瓶颈。RabbitMQ 使用信道的方式来传输数据。信道是建立在真实的 TCP 连接内的虚拟连接,且每条 TCP 连接上的信道数量没有限制。

4.消息如何分发?

若该队列至少有一个消费者订阅,消息将以循环(round-robin)的方式发送给消费者。每条消息只会分发给一个订阅的消费者(前提是消费者能够正常处理消息并进行确认)。通过路由可实现多消费的功能

5.消息怎么路由?

消息提供方->路由->一至多个队列消息发布到交换器时,消息将拥有一个路由键(routing key),在消息创建时设定。通过队列路由键,可以把队列绑定到交换器上。消息到达交换器后,RabbitMQ 会将消息的路由键与队列的路由键进行匹配(针对不同的交换器有不同的路由规则);

常用的交换器主要分为一下三种:

fanout:如果交换器收到消息,将会广播到所有绑定的队列上

direct:如果路由键完全匹配,消息就被投递到相应的队列

topic:可以使来自不同源头的消息能够到达同一个队列。使用 topic 交换器时,可以使用通配符

6.多个消费者监听一个队列时,消息如何分发

轮询
默认的策略,消费者轮流,平均地接收消息
公平分发

根据消费者的能力来分发消息,给空闲的消费者发送更多消息

7.无法被路由的消息去了哪里

无设置的情况下,无法路由(Routing key错误)的消息会被直接丢弃
解决方案:
将mandatory设置为true,并配合ReturnListener,实现消息的回发

声明交换机时,指定备份的交换机

8.消息在什么时候会变成死信

消息拒绝并且没有设置重新入队
消息过期
消息堆积,并且队列达到最大长度,先入队的消息会变成DL

9.RabbitMQ如何实现延时队列

利用TTL(队列的消息存活时间或者消息存活时间),加上死信交换机

10.如何保证消息幂等性

1.生产者方面:

可以对每条消息生成一个msgID,以控制消息重复投递

2.消费者方面

消息体中必须携带一个业务ID,如银行流水号,消费者可以根据业务ID去重,避免重复消费

11.如何保证消息的顺序性

一个队列只有一个消费者的情况下才能保证顺序,否则只能通过全局ID实现(每条消息都一个msgId,关联的消息拥有一个parentMsgId。可以在消费端实现前一条消息未消费,不处理下一条消息;也可以在生产端实现前一条消息未处理完毕,不发布下一条消息)

Kafka

https://zhuanlan.zhihu.com/p/446774729?utm_source=wechat_session&utm_medium=social&utm_oi=791048238638698496&utm_campaign=shareopn

1.核心概念

生产者:Producer 往Kafka集群生成数据

消费者:Consumer 往Kafka里面去获取数据,处理数据、消费数据 Kafka的数据是由消费者自己去拉去Kafka里面的数据

主题:topic

分区:partition 默认一个topic有一个分区(partition),自己可设置多个分区(分区分散存储在服务器不同节点上)

2.集群架构

一个kafka服务器就是一个broker,Topic只是逻辑上的概念,partition在磁盘上就体现为一个目录,是真正存储数据的地方。

Consumer Group:消费组 消费数据的时候,都必须指定一个group id,指定一个组的id假定程序A和程序B指定的group id号一样,那么两个程序就属于同一个消费组。

特殊: 比如,有一个主题topicA程序A去消费了这个topicA,那么程序B就不能再去消费topicA(程序A和程序B属于一个消费组);再比如程序A已经消费了topicA里面的数据,现在还想重新再次消费topicA的数据,是不可以的,但是重新指定一个group id号以后,可以消费。不同消费组之间没有影响,消费组需自定义,消费者名称程序自动生成(独一无二)。

Controller:Kafka节点里面的一个主节点,借助zookeeper。

3.kafka性能高的原因

1.Kafka磁盘顺序写保证写数据性能

kafka写数据: 顺序写,往磁盘上写数据时,就是追加数据,没有随机写的操作。 经验 : 如果一个服务器磁盘达到一定的个数,磁盘也达到一定转数,往磁盘里面顺序写(追加写)数据的速度和写内存的速度差不多 生产者生产消息,经过kafka服务先写到os cache 内存中,然后经过sync顺序写到磁盘上。

2.零拷贝读取数据

消费者读取数据流程(正常情况下):

  1. 消费者发送请求给kafka服务
  2. kafka服务去os cache缓存读取数据(缓存没有就去磁盘读取数据)
  3. 从磁盘读取了数据到os cache缓存中
  4. os cache复制数据到kafka应用程序中
  5. kafka将数据(复制)发送到socket cache中
  6. socket cache通过网卡传输给消费者

kafka linux sendfile技术 — 零拷贝

1.消费者发送请求给kafka服务

2.kafka服务去os cache缓存读取数据(缓存没有就去磁盘读取数据)

3.从磁盘读取了数据到os cache缓存中

4.os cache直接将数据发送给网卡

5.通过网卡将数据传输给消费者

image-20220823222813557

4.日志保存

Kafka中一个主题,一般会设置分区;比如创建了一个 topic_a ,然后创建的时候指定了这个主题有三个分区。 其实在三台服务器上,会创建三个目录。 服务器1(kafka1)创建目录topic_a-0:。目录下面是我们文件(存储数据),kafka数据就是message,数据存储在log文件里。.log结尾的就是日志文件,在kafka中把数据文件就叫做日志文件 。 一个分区下面默认有n多个日志文件(分段存储),一个日志文件默认1G 。服务器2(kafka2):创建目录topic_a-1: 服务器3(kafka3):创建目录topic_a-2:

image-20220823222750941

5.二分法定位数据

Kafka里面每一条消息,都有自己的offset(相对偏移量),存在物理磁盘上面,

Position:物理位置(磁盘上面哪个地方)也就是说一条消息就有两个位置

offset:相对偏移量(相对位置)

position:磁盘物理位置

稀疏索引: Kafka中采用了稀疏索引的方式读取索引,kafka每当写入了4k大小的日志(.log),就往index里写入一个记录索引。其中会采用二分查找。

image-20220823222739235

6.优秀架构思考

Kafka — 高并发、高可用、高性能

高可用:多副本机制

高并发:网络架构设计 三层架构:多selector -> 多线程 -> 队列的设计(NIO)

高性能:

写数据:

  1. 把数据先写入到OS Cache
  2. 写到磁盘上面是顺序写,性能很高

读数据:

  1. 根据稀疏索引,快速定位到要消费的数据
  2. 零拷贝机制 减少数据的拷贝 减少了应用程序与操作系统上下文切换

7.Kafka如何保证数据不丢失

消费端弄丢了数据

唯一可能导致消费者弄丢数据的情况,就是说,你消费到了这个消息,然后消费者那边自动提交了 offset,让 Kafka 以为你已经消费好了这个消息,但其实你才刚准备处理这个消息,你还没处理,你自己就挂了,此时这条消息就丢咯。

这不是跟 RabbitMQ 差不多吗,大家都知道 Kafka 会自动提交 offset,那么只要关闭自动提交 offset,在处理完之后自己手动提交 offset,就可以保证数据不会丢。但是此时确实还是可能会有重复消费,比如你刚处理完,还没提交 offset,结果自己挂了,此时肯定会重复消费一次,自己保证幂等性就好了。

生产环境碰到的一个问题,就是说我们的 Kafka 消费者消费到了数据之后是写到一个内存的 queue 里先缓冲一下,结果有的时候,你刚把消息写入内存 queue,然后消费者会自动提交 offset。然后此时我们重启了系统,就会导致内存 queue 里还没来得及处理的数据就丢失了。

Kafka 弄丢了数据

这块比较常见的一个场景,就是 Kafka 某个 broker 宕机,然后重新选举 partition 的 leader。大家想想,要是此时其他的 follower 刚好还有些数据没有同步,结果此时 leader 挂了,然后选举某个 follower 成 leader 之后,不就少了一些数据?这就丢了一些数据啊。

生产环境也遇到过,我们也是,之前 Kafka 的 leader 机器宕机了,将 follower 切换为 leader 之后,就会发现说这个数据就丢了。

所以此时一般是要求起码设置如下 4 个参数:

给 topic 设置 replication.factor 参数:这个值必须大于 1,要求每个 partition 必须有至少 2 个副本。
在 Kafka 服务端设置 min.insync.replicas 参数:这个值必须大于 1,这个是要求一个 leader 至少感知到有至少一个 follower 还跟自己保持联系,没掉队,这样才能确保 leader 挂了还有一个 follower 吧。
在 producer 端设置 acks=all :这个是要求每条数据,必须是写入所有 replica 之后,才能认为是写成功了。
在 producer 端设置 retries=MAX (很大很大很大的一个值,无限次重试的意思):这个是要求一旦写入失败,就无限重试,卡在这里了。
我们生产环境就是按照上述要求配置的,这样配置之后,至少在 Kafka broker 端就可以保证在 leader 所在 broker 发生故障,进行 leader 切换时,数据不会丢失。

生产者会不会弄丢数据

如果按照上述的思路设置了 acks=all ,一定不会丢,要求是,你的 leader 接收到消息,所有的 follower 都同步到了消息之后,才认为本次写成功了。如果没满足这个条件,生产者会自动不断的重试,重试无限次。

8.高可用性

Kafka 一个最基本的架构认识:由多个 broker 组成,每个 broker 是一个节点;你创建一个 topic,这个 topic 可以划分为多个 partition,每个 partition 可以存在于不同的 broker 上,每个 partition 就放一部分数据。

这就是天然的分布式消息队列,就是说一个 topic 的数据,是分散放在多个机器上的,每个机器就放一部分数据。

实际上 RabbitMQ 之类的,并不是分布式消息队列,它就是传统的消息队列,只不过提供了一些集群、HA(High Availability, 高可用性) 的机制而已,因为无论怎么玩儿,RabbitMQ 一个 queue 的数据都是放在一个节点里的,镜像集群下,也是每个节点都放这个 queue 的完整数据。

Kafka 0.8 以前,是没有 HA 机制的,就是任何一个 broker 宕机了,那个 broker 上的 partition 就废了,没法写也没法读,没有什么高可用性可言。

比如说,我们假设创建了一个 topic,指定其 partition 数量是 3 个,分别在三台机器上。但是,如果第二台机器宕机了,会导致这个 topic 的 1/3 的数据就丢了,因此这个是做不到高可用的。

image-20220823222716103

Kafka 0.8 以后,提供了 HA 机制,就是 replica(复制品) 副本机制。每个 partition 的数据都会同步到其它机器上,形成自己的多个 replica 副本。所有 replica 会选举一个 leader 出来,那么生产和消费都跟这个 leader 打交道,然后其他 replica 就follower。写的时候,leader 会负责把数据同步到所有 follower 上去,读的时候就直接读 leader 上的数据即可。只能读写 leader?很简单,要是你可以随意读写每个 follower,那么就要 care 数据一致性的问题,系统复杂度太高,很容易出问题。Kafka 会均匀地将一个 partition 的所有 replica 分布在不同的机器上,这样才可以提高容错性。

这么搞,就有所谓的高可用性了,因为如果某个 broker 宕机了,没事儿,那个 broker 上面的 partition 在其他机器上都有副本的。如果这个宕机的 broker 上面有某个 partition 的 leader,那么此时会从 follower 中重新选举一个新的 leader 出来,大家继续读写那个新的 leader 即可。这就有所谓的高可用性了。

写数据的时候,生产者就写 leader,然后 leader 将数据落地写本地磁盘,接着其他 follower 自己主动从 leader 来 pull 数据。一旦所有 follower 同步好数据了,就会发送 ack 给 leader,leader 收到所有 follower 的 ack 之后,就会返回写成功的消息给生产者。(当然,这只是其中一种模式,还可以适当调整这个行为)

消费的时候,只会从 leader 去读,但是只有当一个消息已经被所有 follower 都同步成功返回 ack 的时候,这个消息才会被消费者读到。

Kafka和RabbitMq怎么选择

https://www.cnblogs.com/siyuanwai/p/15770079.html

1.消息的顺序,rabbitMq不保证多个消费者消费一个队列时的顺序,选择Kafka

2.消息匹配,rabbitMq独有的优势,利用交换器和路由建可以很方便的实现匹配;

3.消息超时,rabbitMq可以设置TTL,Kafka实现很复杂

4.消息保持,Kafka将消息保存在磁盘中,即使消费完也不会马上删除,更有优势

5.消息错误处理,Kafka比较残暴,必须处理完错误消息,而RabbitMq比较温柔可以将其转移到其他队列

6.吞吐量,kafka要更加优秀

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值