[TOC]

C++基础

[TOC]

类的构造函数、析构函数、赋值函数、拷贝函数

赋值函数和拷贝构造函数的区别?

构造函数

对象不存在,没用别的对象初始化,在创建一个新的对象时调用构造函数

拷贝构造函数

对象不存在,但是使用别的已经存在的对象来进行初始化

赋值运算符

对象存在,用别的对象给它赋值,这属于重载“=”号运算符的范畴,“=”号两侧的对象都是已存在的

区别:

  1. 拷贝构造函数是函数,赋值运算符是运算符重载。

  2. 拷贝构造函数会生成新的类对象,赋值运算符不能。

  3. 拷贝构造函数是直接构造一个新的类对象,所以在初始化对象前不需要检查源对象和新建对象是否相同;赋值运算符需要上述操作并提供两套不同的复制策略,另外赋值运算符中如果原来的对象有内存分配则需要先把内存释放掉。

  4. 形参传递是调用拷贝构造函数(调用的被赋值对象的拷贝构造函数),但并不是所有出现”=”的地方都是使用赋值运算符,如下:

    1
    2
    3
    4
    Student s;
    Student s1 = s; // 调用拷贝构造函数
    Student s2;
    s2 = s; // 赋值运算符操作

移动构造函数与拷贝构造函数对比

  1. 拷贝赋值是通过拷贝构造函数来赋值,在创建对象时,使用同一类中之前创建的对象来初始化新创建的对象。

  2. 移动赋值是通过移动构造函数来赋值,二者的主要区别在于

    1)拷贝构造函数的形参是一个左值引用,而移动构造函数的形参是一个右值引用;

    2)拷贝构造函数完成的是整个对象或变量的拷贝,而移动构造函数是生成一个指针指向源对象或变量的地址,接管源对象的内存,相对于大量数据的拷贝节省时间和内存空间。

构造函数和析构函数抛出异常?

(1)构造函数可以抛出异常

无论何时,从构造函数中抛出异常都是可以的。动态创建对象要进行两个操作:分配内存和调用构造函数。若在分配内存时出错,会抛出bad_alloc异常;若在调用构造函数初始化时出错,会不会存在内存泄漏呢?答案是不会。new运算符保证不会出现内存泄漏:

(2)析构函数不推荐抛出异常,如果析构函数可能抛出异常,那么必须要求在析构函数内消化所有异常或者结束程序。

more effective c++提出两点理由(析构函数不能抛出异常的理由)

1)如果析构函数抛出异常,则异常点之后的程序不会执行,如果析构函数在异常点之后执行了某些必要的动作比如释放某些资源,则这些动作不会执行,会造成诸如资源泄漏的问题。 [正常情况下调用析构函数抛出异常导致资源泄露]

2)通常异常发生时,c++的机制会调用已经构造对象的析构函数来释放资源,此时若析构函数本身也抛出异常,则前一个异常尚未处理,又有新的异常,会造成程序崩溃的问题。 [在发生异常的情况下调用析构函数抛出异常,会导致程序崩溃]

解决方案:

  1. 如果某个操作可能会抛出异常,class应提供一个普通函数(而非析构函数),来执行该操作。目的是给客户一个处理错误的机会。

  2. 如果析构函数中异常非抛不可,那就用try catch来将异常吞下,必须要把这种可能发生的异常完全封装在析构函数内部,决不能让它抛出函数之外。

构造函数、析构函数的执行顺序?构造函数和拷贝构造的内部都干了啥?

1) 构造函数顺序

① 基类构造函数。如果有多个基类,则构造函数的调用顺序是某类在类派生表中出现的顺序,而不是它们在成员初始化表中的顺序。

② 成员类对象构造函数。如果有多个成员类对象则构造函数的调用顺序是对象在类中被声明的顺序,而不是它们出现在成员初始化表中的顺序。

③ 派生类构造函数。

2) 析构函数顺序

① 调用派生类的析构函数;

② 调用成员类对象的析构函数;

③ 调用基类的析构函数。

C++与C的区别

  1. C语言是C++的子集,C++可以很好兼容C语言。但是C++又有很多新特性,如引用、智能指针、auto变量等。
  2. C++是面对对象的编程语言;C语言是面对过程的编程语言。
  3. C语言有一些不安全的语言特性,如指针使用的潜在危险、强制转换的不确定性、内存泄露等。而C++对此增加了不少新特性来改善安全性,如const常量、引用、cast转换、智能指针、try—catch等等;
  4. C++可复用性高,C++引入了模板的概念,后面在此基础上,实现了方便开发的标准模板库STL。C++的STL库相对于C语言的函数库更灵活、更通用
  • struct与class的区别(4点)

  1. struct 一般用于描述一个数据结构集合,而 class 是对一个对象数据的封装;

  2. struct 中默认的访问控制权限是 public 的,而 class 中默认的访问控制权限是 private 的,例如:

    1
    2
    3
    4
    5
    6
    struct A{
    int iNum; // 默认访问控制权限是 public
    }
    class B{
    int iNum; // 默认访问控制权限是 private
    }
  3. 在继承关系中,struct 默认是公有继承,而 class 是私有继承;

  4. class 关键字可以用于定义模板参数,就像 typename,而 struct 不能用于定义模板参数,例如:

    1
    2
    3
    4
    5
    template
    <typename T, typename Y> // 可以把typename 换成 class
    int Func(const T& t, const Y& y)
    { //TODO
    }

答案解析

  1. C++ 中的 struct 是对 C 中的 struct 进行了扩充,它们在声明时的区别如下:

    C C++
    成员函数 不能有 可以
    静态成员 不能有 可以
    访问控制 默认public,不能修改 public/private/protected
    继承关系 不可以继承 可从类或者其他结构体继承
    初始化 不能直接初始化数据成员 可以
  2. 使用时的区别:C 中使用结构体需要加上 struct 关键字,或者对结构体使用 typedef 取别名,而 C++ 中可以省略 struct 关键字直接使用,例如:

    1
    2
    3
    4
    5
    6
    7
    8
    struct Student{  
    int iAgeNum;
    string strName;
    }
    typedef struct Student Student2; //C中取别名
    struct Student stu1; // C 中正常使用
    Student2 stu2; // C 中通过取别名的使用
    Student stu3; // C++ 中使用

C++结构体和C结构体的区别:

(1)C的结构体内不允许有函数存在,C++允许有内部成员函数,且允许该函数是虚函数。

(2)C的结构体对内部成员变量的访问权限只能是public,而C++允许public,protected,private三种。

(3)C语言的结构体是不可以继承的,C++的结构体是可以从其他的结构体或者类继承过来的。

(4)C 中使用结构体需要加上 struct 关键字,或者对结构体使用 typedef 取别名,而 C++ 中可以省略 struct 关键字直接使用。

类型转换

(1)const_cast: 把const属性去掉,即将const转换为非const(也可以反过来),const_cast只能用于指针或引用,并且只能改变对象的底层const(顶层const,本身是const,底层const,指向对象const);

(2)static_cast: 隐式类型转换,用于进行比较“自然”和低风险的转换,可以实现C++中内置基本数据类型之间的相互转换,enum、struct、 int、char、float等,能进行类层次间的向上类型转换和向下类型转换(向下不安全,因为没有进行动态类型检查)。它不能进行无关类型(如非基类和子类)指针之间的转换,也不能作用包含底层const的对象;

(3)dynamic_cast:动态类型转换,用于将基类的指针或引用安全地转换成派生类的指针或引用(也可以向上转换),若指针转换失败返回NULL,若引用返回失败抛出bad_cast异常(基类指针所指对象为基类类型,在这种情况下dynamic_cast在运行时做检查,转换失败,返回结果为0;)。dynamic_cast是在运行时进行安全性检查;dynamic_cast要求操作对象是指向一个多态类型(含有虚函数)的指针或引用,否则编译不通过;

(4)reinterpret_cast:reinterpret是重新解释的意思,此标识符的意思即为将数据的二进制形式重新解释,但是不改变其值,有着和C风格的强制转换同样的能力。它可以转化任何内置的数据类型为其他任何的数据类型,也可以转化任何指针类型为其他的类型。它甚至可以转化内置的数据类型为指针,无须考虑类型安全或者常量的情形。不到万不得已绝对不用(比较不安全)

  • static_cast和dynamic_cast的异同点?

答:二者都会做类型安全检查,只是static_cast在编译期进行类型检查,dynamic_cast在运行期进行类型检查。后者需要父类具备虚函数,而前者不需要。

多态,虚函数

什么是多态?C++的多态是如何实现的?

答:所谓多态,就是同一个函数名具有多种状态,或者说一个接口具有不同的行为;C++的多态分为编译时多态和运行时多态,编译时多态也称为为静态联编,通过重载和模板来实现,运行时多态称为动态联编,通过继承和虚函数来实现。

内联函数是虚函数?

  1. 虚函数可以是内联函数,内联是可以修饰虚函数的,但是当虚函数表现多态性的时候不能内联。
  2. 内联是在编译期建议编译器内联,而虚函数的多态性在运行期,编译器无法知道运行期调用哪个代码,因此虚函数表现为多态性时(运行期)不可以内联。
  3. inline virtual 唯一可以内联的时候是:编译器知道所调用的对象是哪个类(如 Base::who()),这只有在编译器具有实际对象而不是对象的指针或引用时才会发生。

虚函数实现动态多态的原理

虚函数是通过虚函数表来实现的,虚函数表包含了一个类(所有)的虚函数的地址,在有虚函数的类对象中,它内存空间的头部会有一个虚函数表指针(虚表指针),用来管理虚函数表。当子类对象对父类虚函数进行重写的时候,虚函数表的相应虚函数地址会发生改变,改写成这个虚函数的地址,当我们用一个父类的指针来操作子类对象的时候,它可以指明实际所调用的函数。

虚函数指针、虚函数表

C++虚函数表解析

  1. 虚函数指针:在含有虚函数类的对象中,指向虚函数表,在运行时确定
  2. 虚函数表:在程序只读数据段(.rodata section,见:目标文件存储结构),存放虚函数指针,如果派生类实现了基类的某个虚函数,则在虚表中覆盖原本基类的那个虚函数指针,在编译时根据类的声明创建。(每个包含虚函数的类都有一个虚表,虚表是属于类的,而不是属于某个具体的对象,一个类只需要一个虚表即可。同一个类的所有对象都使用同一个虚表。)

虚函数表的结构是怎样的?

虚函数表是一个函数指针数组,数组里存放的都是函数指针,指向虚函数所在的位置。 对象调用虚函数时,会根据虚指针找到虚表的位置,再根据虚函数声明的顺序找到虚函数在数组的哪个位置,找到虚函数的地址,从而调用虚函数。

虚函数调用是在编译时确定还是运行时确定的?如何确定调用哪个函数?

答:运行时确定,通过查找虚函数表中的函数地址确定。

更正:此处说法不严谨,应该是只有通过指针或者引用的方式调用虚函数是运行时确定,通过值调用的虚函数是编译期就可以确定的,参考这篇文章,虚函数一定是运行期才绑定么? - 知乎 (zhihu.com)

构造函数能不能为虚函数?为什么?

1、 从存储空间角度,虚函数相应一个指向vtable虚函数表的指针,这大家都知道,但是这个指向vtable的指针事实上是存储在对象的内存空间的。

问题出来了,假设构造函数是虚的,就须要通过 vtable来调用,但是对象还没有实例化,也就是内存空间还没有,怎么找vtable呢?所以构造函数不能是虚函数。

2、 从使用角度,虚函数主要用于在信息不全的情况下,能使重载的函数得到相应的调用。

构造函数本身就是要初始化实例,那使用虚函数也没有实际意义呀。

所以构造函数没有必要是虚函数。虚函数的作用在于通过父类的指针或者引用来调用它的时候可以变成调用子类的那个成员函数。而构造函数是在创建对象时自己主动调用的,不可能通过父类的指针或者引用去调用,因此也就规定构造函数不能是虚函数。

继承时,父类的析构函数是否为虚函数?

  1. C++中基类采用virtual虚析构函数是为了防止内存泄漏。

具体地说,如果派生类中申请了内存空间,并在其析构函数中对这些内存空间进行释放。

假设基类中采用的是非虚析构函数,当删除基类指针指向的派生类对象时就不会触发动态绑定,因而只会调用基类的析构函数,而不会调用派生类的析构函数。

那么在这种情况下,派生类中申请的空间就得不到释放从而产生内存泄漏。

所以,为了防止这种情况的发生,C++中基类的析构函数应采用virtual虚析构函数。

  1. 纯虚析构函数一定得定义,因为每一个派生类析构函数会被编译器加以扩张,以静态调用的方式调用其每一个虚基类以及上一层基类的析构函数。

因此,缺乏任何一个基类析构函数的定义,就会导致链接失败,最好不要把虚析构函数定义为纯虚析构函数。

在(基类的)构造函数和析构函数中调用虚函数会怎么样

可以,虚函数底层实现原理(但是最好不要在构造和析构函数中调用) 可以,但是没有动态绑定的效果,父类构造函数中调用的仍然是父类版本的函数,子类中调用的仍然是子类版本的函数。 这是因为在定义子类对象的时候,会先调用父类的构造函数,而此时虚函数表以及子类函数还没有被初始化,为了避免调用到未初始化的内存,C++标准规范中规定了在这种情况下,即在构造子类时调用父类的构造函数,而父类的构造函数中又调用了虚成员函数,这个虚成员函数即使被子类重写,也不允许发生多态的行为。所以使用的是静态绑定,调用了父类的函数。绝不在构造和析构函数中调用virtual函数:

  1. 不要在构造函数中调用虚函数的原因:因为父类对象会在子类之前进行构造,此时子类部分的数据成员还未初始化, 因此调用子类的虚函数是不安全的,故而C++不会进行动态联编。
  2. 不要在析构函数中调用虚函数的原因:析构函数是用来销毁一个对象的,在销毁一个对象时,先调用子类的析构函数,然后再调用基类的析构函数。所以在调用基类的析构函数时,派生类对象的数据成员已经“销毁”,这个时再调用子类的虚函数已经没有意义了。
  3. 总之,在构造和析构函数中,不要用虚函数。如果必须用,那么分离出一个Init函数和一个close函数,实现相关功能即可。

虚函数、纯虚函数

  1. 类里如果声明了虚函数,这个函数是实现的,哪怕是空实现,它的作用就是为了能让这个函数在它的子类里面可以被覆盖(override),这样的话,编译器就可以使用后期绑定来达到多态了。纯虚函数只是一个接口,是个函数的声明而已,它要留到子类里去实现。
  2. 虚函数在子类里面可以不重写;但纯虚函数必须在子类实现才可以实例化子类。
  3. 虚函数的类用于 “实作继承”,继承接口的同时也继承了父类的实现。纯虚函数关注的是接口的统一性,实现由子类完成。
  4. 带纯虚函数的类叫抽象类,这种类不能直接生成对象,而只有被继承,并重写其虚函数后,才能使用。抽象类被继承后,子类可以继续是抽象类,也可以是普通类。
  5. 虚基类是虚继承中的基类,具体见下文虚继承。

CSDN . C++ 中的虚函数、纯虚函数区别和联系

静态函数可以是虚函数么?为什么?

  1. static成员不属于任何类对象或类实例,所以即使给此函数加上virutal也是没有任何意义的。
  2. 静态与非静态成员函数之间有一个主要的区别。那就是静态成员函数没有this指针。所以无法访问vptr. 进而不能访问虚函数表。
  • 静态类型和动态类型,静态绑定和动态绑定的介绍

    • 静态类型:对象在声明时采用的类型,在编译期既已确定;
    • 动态类型:通常是指一个指针或引用目前所指对象的类型,是在运行期决定的;
    • 静态绑定:绑定的是静态类型,所对应的函数或属性依赖于对象的静态类型,发生在编译期;
    • 动态绑定:绑定的是动态类型,所对应的函数或属性依赖于对象的动态类型,发生在运行期;

    从上面的定义也可以看出,非虚函数一般都是静态绑定,而虚函数都是动态绑定(如此才可实现多态性)。 举个例子:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    #include <iostream>
    using namespace std;

    class A
    {
    public:
    /*virtual*/ void func() { std::cout << "A::func()\n"; }
    };
    class B : public A
    {
    public:
    void func() { std::cout << "B::func()\n"; }
    };
    class C : public A
    {
    public:
    void func() { std::cout << "C::func()\n"; }
    };
    int main()
    {
    C* pc = new C(); //pc的静态类型是它声明的类型C*,动态类型也是C*;
    B* pb = new B(); //pb的静态类型和动态类型也都是B*;
    A* pa = pc; //pa的静态类型是它声明的类型A*,动态类型是pa所指向的对象pc的类型C*;
    pa = pb; //pa的动态类型可以更改,现在它的动态类型是B*,但其静态类型仍是声明时候的A*;
    C *pnull = NULL; //pnull的静态类型是它声明的类型C*,没有动态类型,因为它指向了NULL;

    pa->func(); //A::func() pa的静态类型永远都是A*,不管其指向的是哪个子类,都是直接调用A::func();
    pc->func(); //C::func() pc的动、静态类型都是C*,因此调用C::func();
    pnull->func(); //C::func() 不用奇怪为什么空指针也可以调用函数,因为这在编译期就确定了,和指针空不空没关系;
    return 0;
    }Copy to clipboardErrorCopied

    如果将A类中的virtual注释去掉,则运行结果是:

    1
    2
    3
    pa->func();      //B::func() 因为有了virtual虚函数特性,pa的动态类型指向B*,因此先在B中查找,找到后直接调用;
    pc->func(); //C::func() pc的动、静态类型都是C*,因此也是先在C中查找;
    pnull->func(); //空指针异常,因为是func是virtual函数,因此对func的调用只能等到运行期才能确定,然后才发现pnull是空指针;Copy to clipboardErrorCopied

    在上面的例子中,

    • 如果基类A中的func不是virtual函数,那么不论pa、pb、pc指向哪个子类对象,对func的调用都是在定义pa、pb、pc时的静态类型决定,早已在编译期确定了。

    • 同样的空指针也能够直接调用no-virtual函数而不报错(这也说明一定要做空指针检查啊!),因此静态绑定不能实现多态;

    • 如果func是虚函数,那所有的调用都要等到运行时根据其指向对象的类型才能确定,比起静态绑定自然是要有性能损失的,但是却能实现多态特性;

      本文代码里都是针对指针的情况来分析的,但是对于引用的情况同样适用。

    至此总结一下静态绑定和动态绑定的区别:

    • 静态绑定发生在编译期,动态绑定发生在运行期;
    • 对象的动态类型可以更改,但是静态类型无法更改;
    • 要想实现动态,必须使用动态绑定;
    • 在继承体系中只有虚函数使用的是动态绑定,其他的全部是静态绑定;

类的内存模型,继承问题

c++中类对象的内存模型(布局)是怎么样的?

参考资料:C++内存模型 - MrYun - 博客园 (cnblogs.com)C++内存布局(上)_qinm的专栏-CSDN博客

答:一般遵循以下几点原则:

(1)如果是有虚函数的话,虚函数表的指针始终存放在内存空间的头部;

(2)除了虚函数之外,内存空间会按照类的继承顺序(父类到子类)和字段的声明顺序布局;

(3)如果有多继承,每个包含虚函数的父类都会有自己的虚函数表,并且按照继承顺序布局(虚表指针+字段);如果子类重写父类虚函数,都会在每一个相应的虚函数表中更新相应地址;如果子类有自己的新定义的虚函数或者非虚成员函数,也会加到第一个虚函数表的后面;

(4)如果有钻石继承,并采用了虚继承,则内存空间排列顺序为:各个父类(包含虚表)、子类、公共基类(最上方的父类,包含虚表),并且各个父类不再拷贝公共基类中的数据成员。

C++中类的数据成员和成员函数内存分布情况

对象的大小和对象中数据成员的大小是一致的,也就是说,成员函数不占用对象的内存。这是因为所有的函数都是存放在代码区的,不管是全局函数,还是成员函数。

要是成员函数占用类的对象空间,那么将是多么可怕的事情:定义一次类对象就有成员函数占用一段空间。

我们再来补充一下静态成员函数的存放问题:静态成员函数与一般成员函数的唯一区别就是没有this指针,因此不能访问非静态数据成员。

就像我前面提到的,所有函数都存放在代码区,静态函数也不例外。所有有人一看到 static 这个单词就主观的认为是存放在全局数据区,那是不对的。

钻石(菱形)继承存在什么问题,如何解决

【参考资料】:C++之钻石问题和解决方案(菱形继承问题)_Benson的专栏-CSDN博客C++:钻石继承与虚继承 - Tom文星 - 博客园 (cnblogs.com)什么是虚拟继承

答:会存在二义性的问题,因为两个父类会对公共基类的数据和方法产生一份拷贝,因此对于子类来说读写一个公共基类的数据或调用一个方法时,不知道是哪一个父类的数据和方法,也会导致编译错误。可以采用虚继承的方法解决这个问题(父类继承公共基类时用virtual修饰),这样就只会创造一份公共基类的实例,不会造成二义性。

空类有哪些函数?空类的大小?

如果你只是声明一个空类,不做任何事情的话,编译器会自动为你生成一个默认构造函数、一个拷贝默认构造函数、一个默认拷贝赋值操作符、一个默认析构函数、取址运算符和一个取址运算符const。这些函数只有在第一次被调用时,才会别编译器创建。所有这些函数都是inline和public的。

1
2
3
4
5
6
7
8
9
10
class Empty
{
public:
Empty(); // 缺省构造函数//
Empty( const Empty& ); // 拷贝构造函数//
~Empty(); // 析构函数//
Empty& operator=( const Empty& ); // 赋值运算符//
Empty* operator&(); // 取址运算符
const Empty* operator&() const; // 取址运算符 const
};

本来按道理而言空类应该是0个字节,但是在C++中空类会占1个字节,这是为了让对象的实例能够相互区别。具体来说,空类同样可以被实例化,并且每个实例在内存中都有独一无二的地址,因此,编译器会给空类隐含加上一个字节,这样空类实例化之后就会拥有独一无二的内存地址。如果没有这一个字节的占位,那么空类就无所谓实例化了,因为实例化的过程就是在内存中分配一块地址。

请说一下以下几种情况下,下面几个类的大小各是多少?

1
2
3
4
5
6
7
class A {};
int main(){
cout<<sizeof(A)<<endl;// 输出 1;
A a;
cout<<sizeof(a)<<endl;// 输出 1;
return 0;
}Copy to clipboardErrorCopied

空类的大小是1, 在C++中空类会占一个字节,这是为了让对象的实例能够相互区别。具体来说,空类同样可以被实例化,并且每个实例在内存中都有独一无二的地址,因此,编译器会给空类隐含加上一个字节,这样空类实例化之后就会拥有独一无二的内存地址。当该空白类作为基类时,该类的大小就优化为0了,子类的大小就是子类本身的大小。这就是所谓的空白基类最优化。

空类的实例大小就是类的大小,所以sizeof(a)=1字节,如果a是指针,则sizeof(a)就是指针的大小,即4字节。

1
2
3
4
5
6
7
class A { virtual Fun(){} };
int main(){
cout<<sizeof(A)<<endl;// 输出 4(32位机器)/8(64位机器);
A a;
cout<<sizeof(a)<<endl;// 输出 4(32位机器)/8(64位机器);
return 0;
}

因为有虚函数的类对象中都有一个虚函数表指针 __vptr,其大小是4字节

1
2
3
4
5
6
7
class A { static int a; };
int main(){
cout<<sizeof(A)<<endl;// 输出 1;
A a;
cout<<sizeof(a)<<endl;// 输出 1;
return 0;
}

静态成员存放在静态存储区,不占用类的大小, 普通函数也不占用类大小

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class A { int a; };
int main(){
cout<<sizeof(A)<<endl;// 输出 4;
A a;
cout<<sizeof(a)<<endl;// 输出 4;
return 0;
}

class A { static int a; int b; };
int main(){
cout<<sizeof(A)<<endl;// 输出 4;
A a;
cout<<sizeof(a)<<endl;// 输出 4;
return 0;
}

静态成员a不占用类的大小,所以类的大小就是b变量的大小 即4个字节

this指针

  • this指针是类的指针,指向对象的首地址。
  • this指针只能在成员函数中使用,在全局函数、静态成员函数中都不能用this。
  • this指针只有在成员函数中才有定义,且存储位置会因编译器不同有不同存储位置。
类的this指针有以下特点

(1)this只能在成员函数中使用,全局函数、静态函数都不能使用this。实际上,传入参数为当前对象地址,成员函数第一个参数为T * const this

如:

1
2
3
class A{
public: int func(int p){}
};

其中,func的原型在编译器看来应该是:

int func(A * const this,int p);

(2)由此可见,this在成员函数的开始前构造,在成员函数的结束后清除。这个生命周期同任何一个函数的参数是一样的,没有任何区别。当调用一个类的成员函数时,编译器将类的指针作为函数的this参数传递进去。如:

1
A a;a.func(10);//此处,编译器将会编译成:A::func(&a,10);

看起来和静态函数没差别,对吗?不过,区别还是有的。编译器通常会对this指针做一些优化,因此,this指针的传递效率比较高,例如VC通常是通过ecx(计数寄存器)传递this参数的。

this指针调用成员变量时,堆栈会发生什么变化?

当在类的非静态成员函数访问类的非静态成员时,编译器会自动将对象的地址传给作为隐含参数传递给函数,这个隐含参数就是this指针。

即使你并没有写this指针,编译器在链接时也会加上this的,对各成员的访问都是通过this的。

例如你建立了类的多个对象时,在调用类的成员函数时,你并不知道具体是哪个对象在调用,此时你可以通过查看this指针来查看具体是哪个对象在调用。This指针首先入栈,然后成员函数的参数从右向左进行入栈,最后函数返回地址入栈。

delete this会出现什么问题?对象还可以使用吗?

1、在类对象的内存空间中,只有数据成员和虚函数表指针,并不包含代码内容,类的成员函数单独放在代码段中。在调用成员函数时,隐含传递一个this指针,让成员函数知道当前是哪个对象在调用它。当调用delete this时,类对象的内存空间被释放。在delete this之后进行的其他任何函数调用,只要不涉及到this指针的内容,都能够正常运行。一旦涉及到this指针,如操作数据成员,调用虚函数等,就会出现不可预期的问题。

为什么是不可预期的问题?

delete this之后不是释放了类对象的内存空间了么,那么这段内存应该已经还给系统,不再属于这个进程。照这个逻辑来看,应该发生指针错误,无访问权限之类的令系统崩溃的问题才对啊?这个问题牵涉到操作系统的内存管理策略。delete this释放了类对象的内存空间,但是内存空间却并不是马上被回收到系统中,可能是缓冲或者其他什么原因,导致这段内存空间暂时并没有被系统收回。此时这段内存是可以访问的,你可以加上100,加上200,但是其中的值却是不确定的。当你获取数据成员,可能得到的是一串很长的未初始化的随机数;访问虚函数表,指针无效的可能性非常高,造成系统崩溃。

如果在类的析构函数中调用delete this,会发生什么?

会导致堆栈溢出。原因很简单,delete的本质是“为将被释放的内存调用一个或多个析构函数,然后,释放内存”。显然,delete this会去调用本对象的析构函数,而析构函数中又调用delete this,形成无限递归,造成堆栈溢出,系统崩溃。

内存管理(内存分配、内存对齐)

C++的内存分区:全局区、堆区、栈区、常量区、代码区

  1. 栈区(stack):存放函数形参和局部变量(auto类型),由编译器自动分配和释放

  2. 堆区(heap):该区由程序员申请后使用,需要手动释放否则会造成内存泄漏。如果程序员没有手动释放,那么程序结束时可能由OS回收。

  3. 全局/静态存储区:存放全局变量和静态变量(包括静态全局变量与静态局部变量),初始化的全局变量和静态局部变量放在一块,未初始化的放在另一块

  4. 文字常量区:常量在统一运行被创建,常量区的内存是只读的,程序结束后由系统释放。

  5. 程序代码区:存放程序的二进制代码,内存由系统管理

image-20220302172646048

一个程序的3个基本段:text段,data段,bss段

  • text段在内存中被映射为只读,但data段与bss段是可写的
  • text段:代码段,就是放程序代码的,编译时确定,只读
  • data段:存放在编译阶段(而非运行时)就能确定的数据,可读可写。也就是通常所说的静态存储区,赋了初值的全局变量和赋初值的静态变量存放在这个区域,常量也存在这个区域
  • bss段:已经定义但没赋初值的全局变量和静态变量存放在这个区域。

堆和栈的内存有什么区别?

(1)堆中的内存需要手动申请和手动释放,栈中内存是由OS自动申请和自动释放;

(2)堆能分配的内存较大(4G(32位机器)),栈能分配的内存较小(1M);

(3)在堆中分配和释放内存会产生内存碎片,栈不会产生内存碎片;

(4)堆的分配效率低,栈的分配效率高;

(5)堆地址从低向上,栈由高向下。

C++和C分别使用什么函数来做内存的分配和释放?有什么区别,能否混用?

C使用malloc/free,C++使用new/delete,前者是C语言中的库函数,后者是C++语言的运算符,对于自定义对象,malloc/free只进行分配内存和释放内存,无法调用其构造函数和析构函数,只有new/delete能做到,完成对象的空间分配和初始化,以及对象的销毁和释放空间,不能混用,具体区别如下:

(1)new分配内存空间无需指定分配内存大小,malloc需要;

(2)new返回类型指针,类型安全,malloc返回void*,再强制转换成所需要的类型;

(3)new是从自由存储区获得内存,malloc从堆中获取内存;

(4)对于类对象,new会调用构造函数和析构函数,malloc不会(核心)。

什么是内存对齐(字节对齐),为什么要做内存对齐,如何对齐?

对齐参数 偏移地址 大小 满足偏移地址%对齐参数 == 0

(1)内存对齐的原因:关键在于CPU存取数据的效率问题。为了提高效率,计算机从内存中取数据是按照一个固定长度的。比如在32位机上,CPU每次都是取32bit数据的,也就是4字节;若不进行对齐,要取出两块地址中的数据,进行掩码和移位等操作,写入目标寄存器内存,效率很低。内存对齐一方面可以节省内存,一方面可以提升数据读取的速度;

(2)内容:内存对齐指的是C++结构体中的数据成员,其内存地址是否为其对齐字节大小的倍数。

(3)对齐原则:1)结构体变量的首地址能够被其最宽基本类型成员的对齐值所整除;2)结构体内每一个成员的相对于起始地址的偏移量能够被该变量的大小整除;3)结构体总体大小能够被最宽成员大小整除;如果不满足这些条件,编译器就会进行一个填充(padding)。

(4)如何对齐声明数据结构时,字节对齐的数据依次声明,然后小成员组合在一起,能省去一些浪费的空间,不要把小成员参杂声明在字节对齐的数据之间。

什么是内存泄露?

简单地说就是申请了一块内存空间,使用完毕后没有释放掉。(1)new和malloc申请资源使用后,没有用delete和free释放; 使用智能指针shared_ptr时,产生循环引用,导致内存无法释放,会造成内存泄漏;(2)子类继承父类时,父类析构函数不是虚函数。(3)Windows句柄资源使用后没有释放。

  • 怎么检测?

第一:良好的编码习惯,使用了内存分配的函数,一旦使用完毕,要记得使用其相应的函数释放掉。

第二:将分配的内存的指针以链表的形式自行管理,使用完毕之后从链表中删除,程序结束时可检查改链表。

第三:使用智能指针。

第四:一些常见的工具插件,如ccmalloc、Dmalloc、Leaky、Valgrind等等。

valgrind原理

img

1,当要读写内存中的某个字节时,首先检查这个字节对应的A bit。如果该A bit显示该位置是无效位置,memcheck则会报告读写错误

2,内核(core)类似于一个虚拟的CPU环境,这样当内存中的某个字节被加载到真实的CPU时,该字节对应的V bit也会被加载到虚拟的CPU环境中。一旦寄存器中的值,被用来产生内存地址,或者该值能够影响程序输出,则memcheck会检查对应的V bits,如果该值尚未初始化,则会报告使用未初始化内存错误。

内存new/malloc/delete/free使用情况

  • new operator和operatornew的区别?

new就是new operator,调用new的时候编译器做了三件事,

1.是用operator new( )分配内存,

2.是调用构造函数(就是你new的类类型或者string等类型的构造函数)。

3.返回相应的指针。

new的底层是调用operator new( )分配内存的。该函数调用malloc申请内存。

参考链接:[C++ operator new和new operator区别](https://blog.csdn.net/qq_26079093/article/details/94211205#:~:text=06-11. 990. 一、 new operator 与 operator new,内存分配 ( new , operator new )面面观 (转)).

连接里面的handle函数是自定义的异常处理函数。

深入解析new、operator new、::new、placement new

  • 面试管说还有一个placement new知道么?

保持一块内存,反复构造析构,这样可以省略中间的多次分配内存。

在使用new关键字建立一个新的对象的时候,在编译器的第二步就是调用对象的构造函数生成类对象。这一步使用的就是placement new来实现的,即在取得了一块可以容纳指定类型对象的内存之后,在这块内存上构造一个对象。

参考链接:C++中new、operator new和placement new的区别

  • malloc的底层实现原理是什么?

申请内存小于128k用brk()函数。大于128k用mmap()函数。

1)当开辟的空间小于 128K 时,调用 brk()函数,malloc 的底层实现是系统调用函数 brk(),其主要移动指针 _enddata(此时的 _enddata 指的是 Linux 地址空间中堆段的末尾地址,不是数据段的末尾地址)

2)当开辟的空间大于 128K 时,mmap()系统调用函数来在虚拟地址空间中(堆和栈中间,称为“文件映射区域”的地方)找一块空间来开辟。

具体细节参考:

malloc 底层实现及原理 - 爱笑的张飞 - 博客园

讲的很清楚了。

同时为了方便申请内存更加高效和减少内存碎片,使用了内存池的设计方式。

参考链接:malloc底层实现及原理泄露的情况

  • 野指针产生与避免

野指针产生的原因:

  1. 创建指针时没有对指针进行初始化,导致指针指向一个随机的位置;
  2. 释放指针指向的内存后没有置空,从而指向垃圾内存;
  3. 在超越变量作用域下使用指针,如:在栈内存被释放之后,指向栈内存的指针会指向垃圾内存;

野指针的避免方法:

  1. 在创建指针时必须进行初始化;
  2. 使用delete释放指针指向的内存之后必须将指针置空;

需要在 new [] 一个对象数组时,需要保存数组的维度,C++ 的做法是在分配数组空间时多分配了 4 个字节的大小,专门保存数组的大小,在 delete [] 时就可以取出这个保存的数,就知道了需要调用析构函数多少次了。

  • delete this会发生什么?

在类的成员函数中能不能调用delete this?答案是肯定的,能调用,而且很多老一点的库都有这种代码。假设这个成员函数名字叫release,而delete this就在这个release方法中被调用,那么这个对象在调用release方法后,还能进行其他操作,如调用该对象的其他方法么?答案仍然是肯定 的,调用release之后还能调用其他的方法,但是有个前提:被调用的方法不涉及这个对象的数据成员和虚函数。说到这里,相信大家都能明白为什么会这样 了。

根本原因在于delete操作符的功能和类对象的内存模型。当一个类对象声明时,系统会为其分配内存空间。在类对象的内存空间中,只有数据成员和虚函数表指针,并不包含代码内容,类的成员函数单独放在代码段中。在调用成员函数时,隐含传递一个this指针,让成员函数知道当前是哪个对象在调用它。当 调用delete this时,类对象的内存空间被释放。在delete this之后进行的其他任何函数调用,只要不涉及到this指针的内容,都能够正常运行。一旦涉及到this指针,如操作数据成员,调用虚函数等,就会出现不可预期的问题。

为什么是不可预期的问题?delete this之后不是释放了类对象的内存空间了么,那么这段内存应该已经还给系统,不再属于这个进程。照这个逻辑来看,应该发生指针错误,无访问权限之类的令系统崩溃的问题才对啊?这个问题牵涉到操作系统的内存管理策略。delete this释放了类对象的内存空间,但是内存空间却并不是马上被回收到系统中,可能是缓冲或者其他什么原因,导致这段内存空间暂时并没有被系统收回。此时这段内存是可以访问的,你可以加上100,加上200,但是其中的值却是不确定的。当你获取数据成员,可能得到的是一串很长的未初始化的随机数;访问虚函数表,指针无效的可能性非常高,造成系统崩溃。

  • new和delete的实现原理, delete是如何知道释放内存的大小的?

    1、 new简单类型直接调用operator new分配内存;

    而对于复杂结构,先调用operator new分配内存,然后在分配的内存上调用构造函数;

    对于简单类型,new[]计算好大小后调用operator new;

    对于复杂数据结构,new[]先调用operator new[]分配内存,然后在p的前四个字节写入数组大小n,然后调用n次构造函数,针对复杂类型,new[]会额外存储数组大小;

    ① new表达式调用一个名为operator new(operator new[])函数,分配一块足够大的、原始的、未命名的内存空间;

    ② 编译器运行相应的构造函数以构造这些对象,并为其传入初始值;

    ③ 对象被分配了空间并构造完成,返回一个指向该对象的指针。

    2、 delete简单数据类型默认只是调用free函数;复杂数据类型先调用析构函数再调用operator delete;针对简单类型,delete和delete[]等同。假设指针p指向new[]分配的内存。因为要4字节存储数组大小,实际分配的内存地址为[p-4],系统记录的也是这个地址。delete[]实际释放的就是p-4指向的内存。而delete会直接释放p指向的内存,这个内存根本没有被系统记录,所以会崩溃。

    3、 需要在 new [] 一个对象数组时,需要保存数组的维度,C++ 的做法是在分配数组空间时多分配了 4 个字节的大小,专门保存数组的大小,在 delete [] 时就可以取出这个保存的数,就知道了需要调用析构函数多少次了。

  • malloc申请的存储空间能用delete释放吗?

    不能,malloc /free主要为了兼容C,new和delete 完全可以取代malloc /free的。

    malloc /free的操作对象都是必须明确大小的,而且不能用在动态类上。

    new 和delete会自动进行类型检查和大小,malloc/free不能执行构造函数与析构函数,所以动态对象它是不行的。

    当然从理论上说使用malloc申请的内存是可以通过delete释放的。不过一般不这样写的。而且也不能保证每个C++的运行时都能正常。

智能指针

智能指针主要解决一个内存泄露的问题,它可以自动地释放内存空间。因为它本身是一个类,当函数结束的时候会调用析构函数,并由析构函数释放内存空间。智能指针分为共享指针(shared_ptr), 独占指针(unique_ptr)和弱指针(weak_ptr):

(1)shared_ptr ,多个共享指针可以指向相同的对象,采用了引用计数的机制,当最后一个引用销毁时,释放内存空间;

(2)unique_ptr,保证同一时间段内只有一个智能指针能指向该对象(可通过move操作来传递unique_ptr);

(3)weak_ptr,用来解决shared_ptr相互引用时的死锁问题,如果说两个shared_ptr相互引用,那么这两个指针的引用计数永远不可能下降为0,资源永远不会释放。它是对对象的一种弱引用,不会增加对象的引用计数,和shared_ptr之间可以相互转化,shared_ptr可以直接赋值给它,它可以通过调用lock函数来获得shared_ptr。

  • shared_ptr的实现原理是什么?构造函数、拷贝构造函数和赋值运算符怎么写?shared_ptr是不是线程安全的?

(1)shared_ptr是通过引用计数机制实现的,引用计数存储着有几个shared_ptr指向相同的对象,当引用计数下降至0时就会自动销毁这个对象;

(2)具体实现:

1)构造函数:将指针指向该对象,引用计数置为1;

2)拷贝构造函数:将指针指向该对象,引用计数++;

3)赋值运算符:=号左边的shared_ptr的引用计数-1,右边的shared_ptr的引用计数+1,如果左边的引用技术降为0,还要销毁shared_ptr指向对象,释放内存空间。

(3)shared_ptr的引用计数本身是安全且无锁的,但是它指向的对象的读写则不是,因此可以说shared_ptr不是线程安全的。shared_ptr是线程安全的吗? - 云+社区 - 腾讯云 (tencent.com) shared_ptr<Foo> y = x但是 y=x 涉及两个成员(指向Foo类型对象指针和指向控制块的指针)的复制,这两步拷贝不会同时(原子)发生。
存在一种情况,智能指针A和B,首先A是nullptr,B指向一个对象,线程1中A=B,线程2中B =nullptr。

  • weak_ptr是为了解决shared_ptr的循环引用问题,那为什么不用raw ptr来解决这个问题?

答:一个weak_ptr绑定到shared_ptr之后不会增加引用计数,一旦最后一个指向对象的shared_ptr被销毁,对象就会被释放,即使weak_ptr指向对象,也还是会释放;raw指针,当对象销毁之后会变成悬浮指针。

各种关键字

static的作用?static变量什么时候初始化?

(1)当用于文件作用域的时候(即在.h/.cpp文件中直接修饰变量和函数),static意味着这些变量和函数只在本文件可见,其他文件是看不到也无法使用的,可以避免重定义的问题。

(2)当用于函数作用域时,即作为局部静态变量时,意味着这个变量是全局的,只会进行一次初始化,不会在每次调用时进行重置,但只在这个函数内可见。

(3)当用于类的声明时,即静态数据成员和静态成员函数,static表示这些数据和函数是所有类对象共享的一种属性,而非每个类对象独有。

(4)static变量在类的声明中不占用内存,因此必须在.cpp文件中定义类静态变量以分配内存。全局变量、文件域的静态变量和类的静态成员变量在main执行之前的静态初始化过程中分配内存并初始化;局部静态变量在第一次使用时分配内存并初始化。

const关键字:修饰变量、指针、类对象、类中成员函数

(1)const修饰符用来定义常量,具有不可变性。在类中,被const修饰的成员函数,不能修改类中的数据成员;

(2)指针常量指的是该指针本身是一个常量,不能被修改,但是指针指向的对象可以被修改,常量指针指的是这个指针指向的对象是一个常量,不能被修改,但是指针本身可以被修改。这涉及到一个顶层const和底层const的概念:顶层const,本身是const,底层const,指向的对象是const;

(3)const修饰的函数可以重载。const成员函数既不能改变类内的数据成员,也无法调用非const的成员函数;const类对象只能调用const成员函数,非const对象无论是否是const成员函数都能调用,但是如果有重载的非const函数,非const对象会优先调用重载后的非const函数。

extern的作用?

  1. 当它与”C”一起连用时,如: extern “C” void fun(int a, int b);则告诉编译器在编译fun这个函数名时按着C的规则去翻译相应的函数名而不是C++的;
  2. 当它作为一个对函数或者全局变量的外部声明,提示编译器遇到此变量或函数时,应该去其它模块中寻找其定义。

explicit的作用?

  1. explicit 修饰构造函数时,可以防止隐式转换和复制初始化
  2. explicit 修饰转换函数时,可以防止隐式转换,但 按语境转换 除外
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
struct B
{
explicit B(int) {}
explicit operator bool() const { return true; }
};
void doB(B b) {}
int main()
{
B b1(1); // OK:直接初始化
B b2 = 1; // 错误:被 explicit 修饰构造函数的对象不可以复制初始化
B b3{ 1 }; // OK:直接列表初始化
B b4 = { 1 }; // 错误:被 explicit 修饰构造函数的对象不可以复制列表初始化
B b5 = (B)1; // OK:允许 static_cast 的显式转换
doB(1); // 错误:被 explicit 修饰构造函数的对象不可以从 int 到 B 的隐式转换
if (b1); // OK:被 explicit 修饰转换函数 B::operator bool() 的对象可以从 B 到 bool 的按语境转换
bool b6(b1); // OK:被 explicit 修饰转换函数 B::operator bool() 的对象可以从 B 到 bool 的按语境转换
bool b7 = b1; // 错误:被 explicit 修饰转换函数 B::operator bool() 的对象不可以隐式转换
bool b8 = static_cast<bool>(b1); // OK:static_cast 进行直接初始化

return 0;
}

constexpr的作用?

答:这个关键字明确的告诉编译器应该去验证(函数或变量)在编译期是否就应该是一个常数(这样编译器就可以大胆进行优化)。

volatile的作用?

  1. volatile 关键字是一种类型修饰符,用它声明的类型变量表示可以被某些编译器未知的因素(操作系统、硬件、其它线程等)更改。所以使用 volatile 告诉编译器不应对这样的对象进行优化。
  2. volatile 关键字声明的变量,每次访问时都必须从内存中取出值(没有被 volatile 修饰的变量,可能由于编译器的优化,从 CPU 寄存器中取值
  3. const 可以是 volatile (如只读的状态寄存器)
  4. 指针可以是 volatile

mutable的作用?

答:可变的意思,使类中被声明为const的函数可以修改类中的非静态成员.(还可用于lambda表达式)

auto和deltype的作用和区别?

用于实现类型自动推导,让编译器来操心变量的类型;auto不能用于函数传参和推导数组类型,但deltype可以解决这个问题。

decltype(auto)是C++14新增的类型指示符,可以用来声明变量以及指示函数返回类型。在使用时,会将“=”号左边的表达式替换掉auto,再根据decltype的语法规则来确定类型。举个例子:

1
2
3
int e = 4;
const int* f = &e; // f是底层const
decltype(auto) j = f;//j的类型是const int* 并且指向的是e

构造函数default

default关键字可以显式要求编译器生成合成构造函数,防止在调用时相关构造函数类型没有定义而报错

构造函数delete

delete关键字可以删除构造函数、赋值运算符函数等,这样在使用的时候会得到友善的提示

左值和右值

(1)左值就是具有可寻址的存储单元,并且能由用户改变其值的量(能用取址符号 & 取出地址的皆为左值,剩下的都是右值),比如常见的变量:一个int,float,class等。左值具有持久的状态,直到离开作用域才销毁;右值表示即将销毁的临时对象,具有短暂的状态,比如字面值常量“hello”,返回非引用类型的表达式int func()等,都会生成右值;

(2)右值引用就是必须绑定到右值的引用,可以通过&&(两个取地址符)来获得右值引用;右值引用只能绑定到即将销毁的对象,因此可以自由地移动其资源;

(3)右值引用是为了支持移动操作而引出的一个概念,它只能绑定到一个将要销毁的对象,使用右值引用的移动操作可以避免无谓的拷贝,提高性能。使用std::move()函数可以将一个左值转换为右值引用。(从实现上讲,std::move基本等同于一个类型转换:static_cast<T&&>(lvalue);)。

  • 为什么要自己定义拷贝构造函数?什么是深拷贝和浅拷贝?

(1)拷贝构造函数的作用就是定义了当我们用同类型的另外一个对象初始化本对象的时候做了什么,在某些情况下,如果我们不自己定义拷贝构造函数,使用默认的拷贝构造函数,就会出错。比如一个类里面有一个指针,如果使用默认的拷贝构造函数,会将指针拷贝过去,即两个指针指向同个对象,那么其中一个类对象析构之后,这个指针也会被delete掉,那么另一个类里面的指针就会变成野指针(悬浮指针);

(2)这也正是深拷贝和浅拷贝的区别,浅拷贝只是简单直接地复制指向某个对象的指针,而不复制对象本身,新旧对象还是共享同一块内存。 但深拷贝会另外创造一个一模一样的对象,新对象跟原对象不共享内存,修改新对象不会改到原对象。

  • 什么是移动构造函数,和拷贝构造函数的区别?

C++11 移动构造函数_项脊轩-CSDN博客_c++ 移动构造

答:移动构造函数需要传递的参数是一个右值引用,移动构造函数不分配新内存,而是接管传递而来对象的内存,并在移动之后把源对象销毁;移动拷贝构造函数需要传递一个左值引用,可能会造成重新分配内存,性能更低。

(1)完成必要的内存移动,斩断原对象和内存的关系(赋值为nullptr)。
(2)确保移动后源对象处于一种“即便被销毁也没有什么问题”的一种状态

C++11新特性

  • nullptr替代 NULL
  • 引入了 auto 和 decltype 这两个关键字实现了类型推导
  • 基于范围的 for 循环for(auto& i : res){}
  • 类和结构体的中初始化列表
  • Lambda 表达式(匿名函数)
  • std::forward_list(单向链表)
  • 右值引用和move语义
  • 智能指针

inline 内联函数

  • 内联函数有什么作用?存不存在什么缺点

(1)作用是使编译器在函数调用点上展开函数,可以避免函数调用的开销;

(2)内联函数的缺点是可能造成代码膨胀,尤其是递归的函数,会造成大量内存开销,exe太大,占用CPU资源。此外,内联函数不方便调试,每次修改会重新编译头文件,增加编译时间。

  • 内联函数和宏有什么区别,有了宏为什么还需要内联函数?

(1)define宏命令是在预处理阶段对命令进行替换,inline是在编译阶段在函数调用点处直接展开函数,节省了函数调用的开销;

(2)define的话是不会对参数的类型进行检查的,因此会出现类型安全的问题,比如定义一个max命令,但是传递的时候可能会传递一个整数和一个字符串,就会出错,但是内联函数在编译阶段会进行类型检查;

(3)使用宏的时候可能要添加很多括号,比较容易出错。

sizeof与strlen对比

sizeof()

  • sizeof 对数组,得到整个数组所占空间大小。
  • sizeof 对指针,得到指针本身所占空间大小。

strlen()

strlen 是一个函数,它用来计算指定字符串 str 的长度,但不包括结束字符(即 null 字符)。

区别

  • strlen和sizeof区别?
    • sizeof是运算符,并不是函数,结果在编译时得到而非运行中获得;strlen是字符处理的库函数。
    • sizeof参数可以是任何数据的类型或者数据(sizeof参数不退化);strlen的参数只能是字符指针且结尾是’\0’的字符串。
    • 因为sizeof值在编译时确定,所以不能用来得到动态分配(运行时分配)存储空间的大小。

深拷贝与浅拷贝的区别

  1. 浅拷贝:又称值拷贝,将源对象的值拷贝到目标对象中去,本质上来说源对象和目标对象共用一份实体,只是所引用的变量名不同,地址其实还是相同的。举个简单的例子,你的小名叫西西,大名叫冬冬,当别人叫你西西或者冬冬的时候你都会答应,这两个名字虽然不相同,但是都指的是你。
  2. 深拷贝,拷贝的时候先开辟出和源对象大小一样的空间,然后将源对象里的内容拷贝到目标对象中去,这样两个指针就指向了不同的内存位置。并且里面的内容是一样的,这样不但达到了我们想要的目的,还不会出现问题,两个指针先后去调用析构函数,分别释放自己所指向的位置。即为每次增加一个指针,便申请一块新的内存,并让这个指针指向新的内存,深拷贝情况下,不会出现重复释放同一块内存的错误。
  3. 深拷贝的实现:深拷贝的拷贝构造函数和赋值运算符的重载传统实现:

指针与引用的区别

  • 指针是一个变量
    值为一个内存地址,不需要初始化,可以保存不同的地址
    通过指针可以访问对应内存地址中的值
    指针可以被const修饰成为常量或者只读变量
  • 引用只是一个变量的新名字
    对引用的操作(赋值,取地址等)都会传递到代表的变量.上
    const引用使其代表的变量具有只读属性
    引用必须在定义时初始化,之后无法代表其它变量
  • 从使用C++语言的角度来看
    引用与指针没有任何的关系
    引用是变量的新名字,操作引用就是操作对应的变量
  • 从C++编译器的角度来看
    为了支持新概念“引用”必须要一个有效的解决方案
    在编译器内部,使用指针常量来实现“引用”
    因此“引用”在定义时必须初始化

STL

STL各种容器底层实现

(1)vector,底层是一块具有连续内存的数组,vector的核心在于其长度自动可变。vector的数据结构主要由三个迭代器(指针)来完成:指向首元素的start,指向尾元素的finish和指向内存末端的end_of_storage。vector的扩容机制是:当目前可用的空间不足时,分配目前空间的两倍或者目前空间加上所需的新空间大小(取较大值),容量的扩张必须经过“重新配置、元素移动、释放原空间”等过程。

(2)list,底层是一个循环双向链表,链表结点和链表分开独立定义的,结点包含pre、next指针和data数据。

(3)deque,双向队列,由分段连续空间构成,每段连续空间是一个缓冲区,由一个中控器来控制。它必须维护一个map指针(中控器指针),还要维护start和finish两个迭代器,指向第一个缓冲区,和最后一个缓冲区。deque可以在前端或后端进行扩容,这些指针和迭代器用来控制分段缓冲区之间的跳转。

image-20220404103043218

(4)stack和queue,栈和队列。它们都是由deque作为底层容器实现的,他们是一种容器配接器,修改了deque的接口,具有自己独特的性质(此二者也可以用list作为底层实现);stack是deque封住了头端的开口,先进后出,queue是deque封住了尾端的开口,先进先出。

(5)priority_queue,优先队列。是由以vector作为底层容器,以heap作为处理规则,heap的本质是一个完全二叉树。

(6)set和map。底层都是由红黑树实现的。红黑树是一种二叉搜索树,但是它多了一个颜色的属性。红黑树的性质如下:1)每个结点非红即黑;2)根节点是黑的;3)如果一个结点是红色的,那么它的子节点就是黑色的;4)任一结点到树尾端(NULL)的路径上含有的黑色结点个数必须相同。通过以上定义的限制,红黑树确保没有一条路径会比其他路径多出两倍以上;因此,红黑树是一种弱平衡二叉树,相对于严格要求平衡的平衡二叉树来说,它的旋转次数少,所以对于插入、删除操作较多的情况下,通常使用红黑树。

补充:平衡二叉树(AVL)和红黑树的区别:AVL 树是高度平衡的,频繁的插入和删除,会引起频繁的rebalance(旋转操作),导致效率下降;红黑树不是高度平衡的,算是一种折中,插入最多两次旋转,删除最多三次旋转。

STL各种容器的查找、删除和插入的时间复杂度(性能比较)

【参考资料】:C++STL各种容器的性能比较【C++】STL各容器的实现,时间复杂度,适用情况分析_Y先森0.0-CSDN博客

(1)vector,vector支持随机访问(通过下标),时间复杂度是O(1);如果是无序vector查找的时间复杂度是O(n),如果是有序vector,采用二分查找则是O(log n);对于插入操作,在尾部插入最快,中部次之,头部最慢,删除同理。vector占用的内存较大,由于二倍扩容机制可能会导致内存的浪费,内存不足时扩容的拷贝也会造成较大性能开销;

(2)list由于底层是链表,不支持随机访问,只能通过扫描的方式查找,复杂度为O(n),但是插入和删除的速度快,只需要调整指针的指向。(有一种说法是链表每次插入和删除都需要分配和释放内存,会造成较大的性能开销,所以如果频繁地插入和删除,list性能并不好,但很多地方都说list插入删除性能好,这点我还没有验证,希望有人能指出);list不会造成内存的浪费,占用内存较小;

(3)deque支持随机访问,但性能比vector要低;支持双端扩容,因此在头部和尾部插入和删除元素很快,为O(1),但是在中间插入和删除元素很慢;

(4)set和map,底层基于红黑树实现,增删查改的时间复杂度近似O(log n),红黑树又是基于链表实现,因此占用内存较小;

(5)unordered_set和unordered_map,底层是基于哈希表实现的,是无序的。理论上增删查改的时间复杂度是O(1)(最差时间复杂度O(n)),实际上数据的分布是否均匀会极大影响容器的性能。

STL怎么做内存管理的,Allocator次级分配器的原理,内存池的优势和劣势?

(1)为了提升内存管理的效率,减少申请小内存造成的内存碎片问题,SGI STL采用了两级配置器,当分配的空间大小超过128B时,会使用第一级空间配置器,直接使用malloc()、realloc()、free()函数进行内存空间的分配和释放。当分配的空间大小小于128B时,将使用第二级空间配置器,采用了内存池技术,通过空闲链表来管理内存。

img

(2)次级配置器的内存池管理技术:每次配置一大块内存,并维护对应的自由链表(free list)。若下次再有相同大小的内存配置,就直接从自由链表中拔出。如果客户端释还小额区块,就由配置器回收到自由链表中;配置器共要维护16个自由链表,存放在一个数组里,分别管理大小为8-128B不等的内存块。分配空间的时候,首先根据所需空间的大小(调整为8B的倍数)找到对应的自由链表中相应大小的链表,并从链表中拔出第一个可用的区块;回收的时候也是一样的步骤,先找到对应的自由链表,并插到第一个区块的位置。

(3)优势:避免内存碎片(这里应该指的是外部碎片),不需要频繁从用户态切换到内核态,性能高效;劣势:仍然会造成一定的内存浪费,比如申请120B就必须分配128B(内部碎片)。

STL容器的push_back和emplace_back的区别?

emplace_back() 和 push_back() 的区别,就在于底层实现的机制不同。push_back() 向容器尾部添加元素时,首先会创建这个元素,然后再将这个元素拷贝或者移动到容器中(如果是拷贝的话,事后会自行销毁先前创建的这个元素);而 emplace_back() 在实现时,则是直接在容器尾部创建这个元素,省去了拷贝或移动元素的过程。

STL的排序用到了哪种算法,具体如何执行

【参考资料】:https://feihu.me/blog/2014/sgi-std-sort/

答:快速排序、插入排序和堆排序;当数据量很大的时候用快排,划分区段比较小的时候用插入排序,当划分有导致最坏情况的倾向的时候使用堆排序。

什么是哈希表?哈希表的长度为什么要是质数?如何处理冲突?哈希表怎么删除一个元素

【参考资料】图文并茂详解数据结构之哈希表 - 知乎 (zhihu.com)

(1)哈希表是一种根据关键码值直接访问数据的数据结构,它通过把关键码值映射到表中的一个位置来访问元素,以加快查找的速度。这个映射函数叫做哈希函数;

(2)哈希表的长度使用质数,可以降低发生冲突的概率,使哈希后的数据更加均匀,如果使用合数,可能会导致很多数据集中分布到一个点上,造成冲突;

(3)解决冲突的办法有开放定址法和拉链法,开放定址法包括线性测探、平方测探法;

(4)线性测探法并不会真正的删除一个元素,而是做一个标记,否则可能会导致正常的查找出错(利用线性探测法解决hash冲突 - 寻觅beyond - 博客园 (cnblogs.com)

STL哈希表底层扩容

hashtable中的bucket所维护的list既不是list也不是slist,而是其自己定义的由hashtable_node数据结构组成的linked-list,而bucket聚合体本身使用vector进行存储。hashtable的迭代器只提供前进操作,不提供后退操作

在hashtable设计bucket的数量上,其内置了28个质数[53, 97, 193,…,429496729],在创建hashtable时,会根据存入的元素个数选择大于等于元素个数的质数作为hashtable的容量(vector的长度),其中每个bucket所维护的linked-list长度也等于hashtable的容量。如果插入hashtable的元素个数超过了bucket的容量,就要进行重建table操作,即找出下一个质数,创建新的buckets vector,重新计算元素在新hashtable的位置。

STL 迭代器删除元素

这个主要考察的是迭代器失效的问题。

对于序列容器 vector, deque来说,使⽤ erase(itertor) 后,后边的每个元素的迭代器都会失效,但是后边每个元素都会往前移动⼀个位置,但是 erase 会返回下⼀个有效的迭代器;

对于关联容器 map set 来说,使⽤了 erase(iterator) 后,当前元素的迭代器失效,但是其结构是红⿊树,删除当前元素的,不会影响到下⼀个元素的迭代器,所以在调⽤ erase 之前,记录下⼀个元素的迭代器即可。

对于 list 来说,它使⽤了不连续分配的内存,并且它的 erase ⽅法也会返回下⼀个有效的iterator,因此上⾯两种正确的⽅法都可以使⽤。

map与unordered_map对比

  1. map实现机理

    map内部实现了一个红黑树(红黑树是非严格平衡的二叉搜索树,而AVL是严格平衡二叉搜索树),红黑树有自动排序的功能,因此map内部所有元素都是有序的,红黑树的每一个节点都代表着map的一个元素。因此,对于map进行的查找、删除、添加等一系列的操作都相当于是对红黑树进行的操作。map中的元素是按照二叉树(又名二叉查找树、二叉排序树)存储的,特点就是左子树上所有节点的键值都小于根节点的键值,右子树所有节点的键值都大于根节点的键值。使用中序遍历可将键值按照从小到大遍历出来。

  2. unordered_map实现机理

    unordered_map内部实现了一个哈希表(也叫散列表),通过把关键码值映射到Hash表中一个位置来访问记录,查找时间复杂度可达O(1),其中在海量数据处理中有着广泛应用。因此,元素的排列顺序是无序的。

map的下标操作和insert区别

insert接受一个pair参数,并且返回值也是一个pair。返回值pair中的first元素是一个
迭代器,如果数据插入成功,则返回插入关键字位置,用->解引用可以提取pair类型元素。second成员是一个bool类型变量,如果关键字已在map中,insert什么也不做,second返回false,插入失败;如果关键字不存在,元素被插入,second返回true.即:insert 含义是:如果key存在,则插入失败,如果key不存在,就创建这个key-value。实例: map.insert((key, value))

利用下标操作的含义是:如果这个key存在,就更新value;如果key不存在,就创建这个key-value对 实例:map[key] = value

map 和 set 区别

map 和 set 都是 C++ 的关联容器,其底层实现都是红⿊树(RB-Tree)。
由于 map 和 set 所开放的各种操作接⼝, RB-tree 也都提供了,所以⼏乎所有的 map 和 set的操作⾏为,都只是转调 RB-tree 的操作⾏为。
map 和 set 区别在于:

(1) map 中的元素是 key-value(关键字—值)对:关键字起到索引的作⽤,值则表示与索引相关联的数据; Set与之相对就是关键字的简单集合, set 中每个元素只包含⼀个关键字。

(2) set 的迭代器是 const 的,不允许修改元素的值; map允许修改value,但不允许修改key。其原因是因为map和set是根据关键字排序来保证其有序性的,如果允许修改key的话,那么⾸先需要删除该键,然后调节平衡,再插⼊修改后的键值,调节平衡,如此⼀来,严重破坏了map和set的结构,导致iterator失效,不知道应该指向改变前的位置,还是指向改变后的位置。所以STL中将set的迭代器设置成const,不允许修改迭代器的值;⽽map的迭代器则不允许修改key值,允许修改value值。

(3) map⽀持下标操作, set不⽀持下标操作。 map可以⽤key做下标, map的下标运算符[ ]将关键码作为下标去执⾏查找,如果关键码不存在,则插⼊⼀个具有该关键码和mapped_type类型默认值的元素⾄map中,因此下标运算符[ ]在map应⽤中需要慎⽤,onst_map不能⽤,只希望确定某⼀个关键值是否存在⽽不希望插⼊元素时也不应该使⽤,mapped_type类型没有默认值也不应该使⽤。如果find能解决需要,尽可能⽤find。

STL ⾥ resize 和 reserve 的区别

resize():改变当前容器内含有元素的数量(size()), eg: vectorv; v.resize(len);v的size变为len,如果原来v的size⼩于len,那么容器新增(len-size)个元素,元素的值为默认为0.当v.push_back(3);之后,则是3是放在了v的末尾,即下标为len,此时容器是size为len+1;

reserve():改变当前容器的最⼤容量(capacity) ,它不会⽣成元素,只是确定这个容器允许放⼊多少对象,如果reserve(len)的值⼤于当前的capacity(),那么会重新分配⼀块能存len个对象的空间,然后把之前v.size()个对象通过 copy construtor 复制过来,销毁之前的内存;

计算机网络

1、OSI7层网络模型:应用层、表示层、会话层、运输层、网络层、链路层、物理层
2、TCP/IP四层网络模型:应用层、传输层、网际层、网络访问层
综合OSI与TCP/IP模型,学习五层网络模型:
从上向下架构:应用层、运输层、网络层、链路层、物理层
链路层:
3、MTU

网络层

说说 MAC地址和IP地址分别有什么作用

参考回答

  1. IP地址是IP协议提供的一种统一的地址格式,它为互联网上的每一个网络和每一台主机分配一个逻辑地址,以此来屏蔽物理地址的差异。而MAC地址,指的是物理地址,用来定义网络设备的位置。
  2. IP地址的分配是根据网络的拓扑结构,而不是根据谁制造了网络设置。若将高效的路由选择方案建立在设备制造商的基础上而不是网络所处的拓朴位置基础上,这种方案是不可行的。
  3. 当存在一个附加层的地址寻址时,设备更易于移动和维修。例如,如果一个以太网卡坏了,可以被更换,而无须取得一个新的IP地址。如果一个IP主机从一个网络移到另一个网络,可以给它一个新的IP地址,而无须换一个新的网卡。
  4. 无论是局域网,还是广域网中的计算机之间的通信,最终都表现为将数据包从某种形式的链路上的初始节点出发,从一个节点传递到另一个节点,最终传送到目的节点。数据包在这些节点之间的移动都是由ARP(Address Resolution Protocol:地址解析协议)负责将IP地址映射到MAC地址上来完成的

网络层:

什么是ARP协议 (Address Resolution Protocol)?

ARP解决了同一个局域网上的主机和路由器IP和MAC地址的解析。

  • 每台主机都会在自己的ARP缓冲区中建立一个ARP列表,以表示IP地址和MAC地址的对应关系。
  • 当源主机需要将一个数据包要发送到目的主机时,会首先检查自己 ARP列表中是否存在该 IP地址对应的MAC地址,如果有,就直接将数据包发送到这个MAC地址;如果没有,就向本地网段发起一个ARP请求的广播包,查询此目的主机对应的MAC地址。此ARP请求数据包里包括源主机的IP地址、硬件地址、以及目的主机的IP地址。
  • 网络中所有的主机收到这个ARP请求后,会检查数据包中的目的IP是否和自己的IP地址一致。如果不相同就忽略此数据包;如果相同,该主机首先将发送端的MAC地址和IP地址添加到自己的ARP列表中,如果ARP表中已经存在该IP的信息,则将其覆盖,然后给源主机发送一个 ARP响应数据包,告诉对方自己是它需要查找的MAC地址。
  • 源主机收到这个ARP响应数据包后,将得到的目的主机的IP地址和MAC地址添加到自己的ARP列表中,并利用此信息开始数据的传输。
  • 如果源主机一直没有收到ARP响应数据包,表示ARP查询失败。

6、为啥有IP地址还需要MAC地址?同理,为啥有了MAC地址还需要IP地址?

如果我们只使用 MAC 地址进行寻址的话,我们需要路由器记住每个 MAC 地址属于哪一个子网,不然每一次路由器收到数据包时都要满世界寻找目的 MAC 地址。而我们知道 MAC 地址的长度为 48 位,也就是说最多总共有 2 的 48 次方个 MAC 地址,这就意味着每个路由器需要 256 T 的内存,这显然是不现实的。

和 MAC 地址不同,IP 地址是和地域相关的,在一个子网中的设备,我们给其分配的 IP 地址前缀都是一样的,这样路由器就能根据 IP 地址的前缀知道这个设备属于哪个子网,剩下的寻址就交给子网内部实现,从而大大减少了路由器所需要的内存。

只有当设备连入网络时,才能根据他进入了哪个子网来为其分配 IP 地址,在设备还没有 IP 地址的时候或者在分配 IP 地址的过程中,我们需要 MAC 地址来区分不同的设备。

7、网络层转发数据报的流程

8、子网划分、子网掩码

9、网络控制报文协议ICMP

ICMP(Internet Control Message Protocol)是因特网控制报文协议,主要是实现 IP 协议中未实现的部分功能,是一种网络层协议。该协议并不传输数据,只传输控制信息来辅助网络层通信。主机或路由器用ICMP来发送差错报告报文和询问报文,其主要的功能是验证网络是否畅通(确认接收方是否成功接收到 IP 数据包)以及辅助 IP 协议实现可靠传输(若发生 IP 丢包,ICMP 会通知发送方 IP 数据包被丢弃的原因,之后发送方会进行相应的处理)。

10、ICMP应用举例:PING、traceroute

Ping
Ping(Packet Internet Groper),即因特网包探测器,是一种工作在网络层的服务命令,主要用于测试网络连接量。本地主机通过向目的主机发送 ICMP Echo 请求报文,目的主机收到之后会发送 Echo 响应报文,Ping 会根据时间和成功响应的次数估算出数据包往返时间以及丢包率从而推断网络是否通畅、运行是否正常等。

TraceRoute
TraceRoute 是 ICMP 的另一个应用,其主要用来跟踪一个分组从源点耗费最少 TTL 到达目的地的路径。TraceRoute 通过逐渐增大 TTL 值并重复发送数据报来实现其功能,首先,TraceRoute 会发送一个 TTL 为 1 的 IP 数据报到目的地,当路径上的第一个路由器收到这个数据报时,它将 TTL 的值减 1,此时 TTL = 0,所以路由器会将这个数据报丢掉,并返回一个差错报告报文,之后源主机会接着发送一个 TTL 为 2 的数据报,并重复此过程,直到数据报能够刚好到达目的主机。此时 TTL = 0,因此目的主机要向源主机发送 ICMP 终点不可达差错报告报文,之后源主机便知道了到达目的主机所经过的路由器 IP 地址以及到达每个路由器的往返时间。

路由器与交换机的区别

  • 交换机:交换机用于局域网,利用主机的物理地址(MAC 地址)确定数据转发的目的地址,它工作于数据链路层。
  • 路由器:路由器通过数据包中的目的 IP 地址识别不同的网络从而确定数据转发的目的地址,网络号是唯一的。路由器根据路由选择协议和路由表信息从而确定数据的转发路径,直到到达目的网络,它工作于网络层。

TCP/UDP

  • TCP三次握手及状态变化

image-20220304101418589

三次握手是 TCP 连接的建立过程。在握手之前,主动打开连接的客户端结束 CLOSE 阶段,被动打开的服务器也结束 CLOSE 阶段,并进入 LISTEN 阶段。随后进入三次握手阶段:

① 首先客户端向服务器发送一个 SYN 包,并等待服务器确认,其中:

标志位为 SYN,表示请求建立连接;
序号为 Seq = x(x 一般取随机数);
随后客户端进入 SYN-SENT 阶段。
② 服务器接收到客户端发来的 SYN 包后,对该包进行确认后结束 LISTEN 阶段,并返回一段 TCP 报文,其中:

标志位为 SYN 和 ACK,表示确认客户端的报文 Seq 序号有效,服务器能正常接收客户端发送的数据,并同意创建新连接;
序号为 Seq = y;
确认号为 Ack = x + 1,表示收到客户端的序号 Seq 并将其值加 1 作为自己确认号 Ack 的值,随后服务器端进入 SYN-RECV 阶段。

③ 客户端接收到发送的 SYN + ACK 包后,明确了从客户端到服务器的数据传输是正常的,从而结束 SYN-SENT 阶段。并返回最后一段报文。其中:

标志位为 ACK,表示确认收到服务器端同意连接的信号
序号为 Seq = x + 1,表示收到服务器端的确认号 Ack,并将其值作为自己的序号值;
确认号为 Ack= y + 1,表示收到服务器端序号 seq,并将其值加 1 作为自己的确认号 Ack 的值。
随后客户端进入 ESTABLISHED。
当服务器端收到来自客户端确认收到服务器数据的报文后,得知从服务器到客户端的数据传输是正常的,从而结束 SYN-RECV 阶段,进入 ESTABLISHED 阶段,从而完成三次握手。

  • 两次握手可以吗?

考虑两种情况:
1.客户端第一个报文滞留了
client 发出的第一个连接请求报文段并没有丢失,而是在某个网络结点长时间的滞留了,以致延误到连接释放以后的某个时间才到达 server。本来这是一个早已失效的报文段。但 server 收到此失效的连接请求报文段后,就误认为是 client 再次发出的一个新的连接请求。于是就向 client 发出确认报文段,同意建立连接。假设不采用 “三次握手”,那么只要 server 发出确认,新的连接就建立了。由于现在 client 并没有发出建立连接的请求,因此不会理睬 server 的确认,也不会向 server 发送数据。但 server 却以为新的运输连接已经建立,并一直等待 client 发来数据。这样,server 的很多资源就白白浪费掉了。采用 “三次握手” 的办法可以防止上述现象发生。例如刚才那种情况,client 不会向 server 的确认发出确认。server 由于收不到确认,就知道 client 并没有要求建立连接。

2.两次握手无法保证Client正确接收第二次握手的报文(Server无法确认Client是否收到),也无法保证Client和Server之间成功互换初始序列号。

  • 第 2 次握手传回了 ACK,为什么还要传回 SYN

从全双工的角度看,第一次握手的SYN是客户端向服务端的连接请求,第二次握手的SYN是服务端向客户端的连接请求,两边都是需要的,ACK 是为了告诉客户端发来的数据已经接收无误,而传回 SYN 是为了把自己的初始序列号(Seq)同步给客户端。

  • 三次握手异常情况

image-20220405115339668

  • TCP四次挥手及状态变化

image-20220303193846259

  1. 第一次挥手:Client将FIN置为1,发送一个序列号seq给Server;进入FIN_WAIT_1状态;
  2. 第二次挥手:Server收到FIN之后,发送一个ACK=1,acknowledge number=收到的序列号+1;进入CLOSE_WAIT状态。此时客户端已经没有要发送的数据了,但仍可以接受服务器发来的数据。
  3. 第三次挥手:Server将FIN置1,发送一个序列号给Client;进入LAST_ACK状态;
  4. 第四次挥手:Client收到服务器的FIN后,进入TIME_WAIT状态;接着将ACK置1,发送一个acknowledge number=序列号+1给服务器;服务器收到后,确认acknowledge number后,变为CLOSED状态,不再向客户端发送数据。客户端等待2*MSL(报文段最长寿命)时间后,也进入CLOSED状态。完成四次挥手。
  • 为什么不能把服务器发送的ACK和FIN合并起来,变成三次挥手(CLOSE_WAIT状态意义是什么)?

因为服务器收到客户端断开连接的请求时,可能还有一些数据没有发完,这时先回复ACK,表示接收到了断开连接的请求。等到数据发完之后再发FIN,断开服务器到客户端的数据传送。

  • 客户端TIME_WAIT状态的意义是什么?

1.第四次挥手时,客户端发送给服务器的ACK有可能丢失,TIME_WAIT状态就是用来重发可能丢失的ACK报文。如果Server没有收到ACK,就会重发FIN,如果Client在2*MSL的时间内收到了FIN,就会重新发送ACK并再次等待2MSL,防止Server没有收到ACK而不断重发FIN。

2.MSL(Maximum Segment Lifetime),指一个片段在网络中最大的存活时间,2MSL就是一个发送和一个回复所需的最大时间。如果直到2MSL,Client都没有再次收到FIN,那么Client推断ACK已经被成功接收,则结束TCP连接。

  • TIME_WAIT 状态会导致什么问题,怎么解决?

我们考虑高并发短连接的业务场景,在高并发短连接的 TCP 服务器上,当服务器处理完请求后主动请求关闭连接,这样服务器上会有大量的连接处于 TIME_WAIT 状态,服务器维护每一个连接需要一个 socket,也就是每个连接会占用一个文件描述符,而文件描述符的使用是有上限的,如果持续高并发,会导致一些正常的 连接失败。

解决方案:修改配置或设置 SO_REUSEADDR 套接字,使得服务器处于 TIME-WAIT 状态下的端口能够快速回收和重用。

  • TCP与UDP的区别及应用场景(建立连接、一对一、字节流、流量控制、拥塞控制、首部字节、所需资源)

  1. TCP是面向连接的,UDP是无连接的;
    什么叫无连接? UDP发送数据之前不需要建立连接
  2. TCP是可靠的,UDP不可靠;
    什么叫不可靠? UDP接收方收到报文后,不需要给出任何确认
  3. TCP只支持点对点通信,UDP支持一对一、一对多、多对一、多对多;
  4. TCP是面向字节流的,UDP是面向报文的;
    什么意思? 面向字节流是指发送数据时以字节为单位,一个数据包可以拆分成若干组进行发送,而UDP一个报文只能一次发完。
  5. TCP有拥塞控制机制,UDP没有。网络出现的拥塞不会使源主机的发送速率降低,这对某些实时应用是很重要的,比如媒体通信,游戏;
  6. TCP首部开销(20字节)比UDP首部开销(8字节)要大
  7. UDP 的主机不需要维持复杂的连接状态表
  • UDP、TCP首部报文格式(SYN、ACK、FIN、RST必须知道)

img

UDP 首部字段只有 8 个字节,包括源端口、目的端口、长度、检验和。12 字节的伪首部是为了计算检验和临时添加的。

img

TCP 首部格式比 UDP 复杂。

序号:用于对字节流进行编号,例如序号为 301,表示第一个字节的编号为 301,如果携带的数据长度为 100 字节,那么下一个报文段的序号应为 401。

确认号:期望收到的下一个报文段的序号。例如 B 正确收到 A 发送来的一个报文段,序号为 501,携带的数据长度为 200 字节,因此 B 期望下一个报文段的序号为 701,B 发送给 A 的确认报文段中确认号就为 701。

数据偏移:指的是数据部分距离报文段起始处的偏移量,实际上指的是首部的长度。

控制位:八位从左到右分别是 CWR,ECE,URG,ACK,PSH,RST,SYN,FIN。

CWR:CWR 标志与后面的 ECE 标志都用于 IP 首部的 ECN 字段,ECE 标志为 1 时,则通知对方已将拥塞窗口缩小;

ECE:若其值为 1 则会通知对方,从对方到这边的网络有阻塞。在收到数据包的 IP 首部中 ECN 为 1 时将 TCP 首部中的 ECE 设为 1;

URG:该位设为 1,表示包中有需要紧急处理的数据,对于需要紧急处理的数据,与后面的紧急指针有关;

ACK:该位设为 1,确认应答的字段有效,TCP规定除了最初建立连接时的 SYN 包之外该位必须设为 1;

PSH:该位设为 1,表示需要将收到的数据立刻传给上层应用协议,若设为 0,则先将数据进行缓存;

RST:该位设为 1,表示 TCP 连接出现异常必须强制断开连接;

SYN:用于建立连接,该位设为 1,表示希望建立连接,并在其序列号的字段进行序列号初值设定;

FIN:该位设为 1,表示今后不再有数据发送,希望断开连接。当通信结束希望断开连接时,通信双方的主机之间就可以相互交换 FIN 位置为 1 的 TCP 段。

每个主机又对对方的 FIN 包进行确认应答之后可以断开连接。不过,主机收到 FIN 设置为 1 的 TCP 段之后不必马上回复一个 FIN 包,而是可以等到缓冲区中的所有数据都因为已成功发送而被自动删除之后再发 FIN 包;

窗口:窗口值作为接收方让发送方设置其发送窗口的依据。之所以要有这个限制,是因为接收方的数据缓存空间是有限的。

  • TCP流量控制

使用滑动窗口协议实现流量控制。防止发送方发送速率太快,接收方缓存区不够导致溢出。接收方会维护一个接收窗口 receiver window(窗口大小单位是字节),接受窗口的大小是根据自己的资源情况动态调整的,在返回ACK时将接受窗口大小放在TCP报文中的窗口字段告知发送方。发送窗口的大小不能超过接受窗口的大小,只有当发送方发送并收到确认之后,才能将发送窗口右移。

发送窗口的上限为接受窗口和拥塞窗口中的较小值。接受窗口表明了接收方的接收能力,拥塞窗口表明了网络的传送能力。

  • 什么是零窗口(接收窗口为0时会怎样)?

如果接收方没有能力接收数据,就会将接收窗口设置为0,这时发送方必须暂停发送数据,但是会启动一个持续计时器(persistence timer),到期后发送一个大小为1字节的探测数据包,以查看接收窗口状态。如果接收方能够接收数据,就会在返回的报文中更新接收窗口大小,恢复数据传送

拥塞控制和流量控制的区别
拥塞控制往往是一种全局的,防止过多的数据注入到网络之中,而TCP连接的端点只要不能收到对方的确认信息,猜想在网络中发生了拥塞,但并不知道发生在何处,因此,流量控制往往指点对点通信量的控制,是端到端的问题。

  • TCP拥塞控制(慢开始、拥塞避免、快重传、快恢复。

如果网络出现拥塞,分组将会丢失,此时发送方会继续重传,从而导致网络拥塞程度更高。因此当出现拥塞时,应当控制发送方的速率。这一点和流量控制很像,但是出发点不同。流量控制是为了让接收方能来得及接收,而拥塞控制是为了降低整个网络的拥塞程度。

img

TCP 主要通过四个算法来进行拥塞控制:

慢开始、拥塞避免、快重传、快恢复。

发送方需要维护一个叫做拥塞窗口(cwnd)的状态变量,注意拥塞窗口与发送方窗口的区别:拥塞窗口只是一个状态变量,实际决定发送方能发送多少数据的是发送方窗口。

为了便于讨论,做如下假设:

  • 接收方有足够大的接收缓存,因此不会发生流量控制;
  • 虽然 TCP 的窗口基于字节,但是这里设窗口的大小单位为报文段。

image-20220315133525026

  1. 慢启动:刚开始发送数据时,先把拥塞窗口(congestion window)设置为一个最大报文段MSS的数值,每收到一个新的确认报文之后,就把拥塞窗口加1个MSS。这样每经过一个传输轮次(或者说是每经过一个往返时间RTT),拥塞窗口的大小就会加倍
  2. 拥塞避免:当拥塞窗口的大小达到慢开始门限(slow start threshold)时,开始执行拥塞避免算法,拥塞窗口大小不再指数增加,而是线性增加,即每经过一个传输轮次只增加1MSS.
  3. 快重传:快重传要求接收方在收到一个失序的报文段后就立即发出重复确认(为的是使发送方及早知道有报文段没有到达对方)而不要等到自己发送数据时捎带确认。快重传算法规定,发送方只要一连收到三个重复确认就应当立即重传对方尚未收到的报文段,而不必继续等待设置的重传计时器时间到期。
  4. 快恢复:当发送方连续收到三个重复确认时,就把慢开始门限减半,然后执行拥塞避免算法。不执行慢开始算法的原因:因为如果网络出现拥塞的话就不会收到好几个重复的确认,所以发送方认为现在网络可能没有出现拥塞。
    也有的快重传是把开始时的拥塞窗口cwnd值再增大一点,即等于 ssthresh + 3*MSS 。这样做的理由是:既然发送方收到三个重复的确认,就表明有三个分组已经离开了网络。这三个分组不再消耗网络的资源而是停留在接收方的缓存中。可见现在网络中减少了三个分组。因此可以适当把拥塞窗口扩大些。

img

TCP如何保证可靠性的?

  • 数据分块:应用数据被分割成 TCP 认为最适合发送的数据块。
  • 序列号和确认应答:TCP 给发送的每一个包进行编号,在传输的过程中,每次接收方收到数据后,都会对传输方进行确认应答,即发送 ACK 报文,这个 ACK 报文当中带有对应的确认序列号,告诉发送方成功接收了哪些数据以及下一次的数据从哪里开始发。除此之外,接收方可以根据序列号对数据包进行排序,把有序数据传送给应用层,并丢弃重复的数据。
  • 校验和: TCP 将保持它首部和数据部分的检验和。这是一个端到端的检验和,目的是检测数据在传输过程中的任何变化。如果收到报文段的检验和有差错,TCP 将丢弃这个报文段并且不确认收到此报文段。
  • 流量控制: TCP 连接的双方都有一个固定大小的缓冲空间,发送方发送的数据量不能超过接收端缓冲区的大小。当接收方来不及处理发送方的数据,会提示发送方降低发送的速率,防止产生丢包。TCP 通过滑动窗口协议来支持流量控制机制。
  • 拥塞控制: 当网络某个节点发生拥塞时,减少数据的发送。
  • ARQ协议: 也是为了实现可靠传输的,它的基本原理就是每发完一个分组就停止发送,等待对方确认。在收到确认后再发下一个分组。
  • 超时重传: 当 TCP 发出一个报文段后,它启动一个定时器,等待目的端确认收到这个报文段。如果超过某个时间还没有收到确认,将重发这个报文段。

UDP为什么是不可靠的?

UDP 只有一个 socket 接收缓冲区,没有 socket 发送缓冲区,即只要有数据就发,不管对方是否可以正确接收。而在对方的 socket 接收缓冲区满了之后,新来的数据报无法进入到 socket 接受缓冲区,此数据报就会被丢弃,因此 UDP 不能保证数据能够到达目的地,此外,UDP 也没有流量控制和重传机制,故UDP的数据传输是不可靠的。

和 TCP 建立连接时采用三次握手不同,UDP 中调用 connect 只是把对端的 IP 和 端口号记录下来,并且 UDP 可多多次调用 connect 来指定一个新的 IP 和端口号,或者断开旧的 IP 和端口号(通过设置 connect 函数的第二个参数)。和普通的 UDP 相比,调用 connect 的 UDP 会提升效率,并且在高并发服务中会增加系统稳定性。

当 UDP 的发送端调用 bind 函数时,就会将这个套接字指定一个端口,若不调用 bind 函数,系统内核会随机分配一个端口给该套接字。当手动绑定时,能够避免内核来执行这一操作,从而在一定程度上提高性能。

TCP 最大连接数限制

Client 最大 TCP 连接数
client 在每次发起 TCP 连接请求时,如果自己并不指定端口的话,系统会随机选择一个本地端口(local port),该端口是独占的,不能和其他 TCP 连接共享。TCP 端口的数据类型是 unsigned short,因此本地端口个数最大只有 65536,除了端口 0不能使用外,其他端口在空闲时都可以正常使用,这样可用端口最多有 65535 个。

Server最大 TCP 连接数
server 通常固定在某个本地端口上监听,等待 client 的连接请求。不考虑地址重用(Unix 的 SO_REUSEADDR 选项)的情况下,即使 server 端有多个 IP,本地监听端口也是独占的,因此 server 端 TCP 连接 4 元组中只有客户端的 IP 地址和端口号是可变的,因此最大 TCP 连接为客户端 IP 数 × 客户端 port 数,对 IPV4,在不考虑 IP 地址分类的情况下,最大 TCP 连接数约为 2 的 32 次方(IP 数)× 2 的 16 次方(port 数),也就是 server 端单机最大 TCP 连接数约为 2 的 48 次方。

然而上面给出的是只是理论上的单机最大连接数,在实际环境中,受到明文规定(一些 IP 地址和端口具有特殊含义,没有对外开放)、机器资源、操作系统等的限制,特别是 sever 端,其最大并发 TCP 连接数远不能达到理论上限。对 server 端,通过增加内存、修改最大文件描述符个数等参数,单机最大并发 TCP 连接数超过 10 万 是没问题的。

SYN泛洪攻击。如何解决?

SYN Flood 是种典型的 DoS(拒绝服务)攻击,其目的是通过消耗服务器所有可用资源使服务器无法用于处理合法请求。通过重复发送初始连接请求(SYN)数据包,攻击者能够压倒目标服务器上的所有可用端口,导致目标设备根本不响应合法请求。

在 TCP 建立连接的过程中,因为服务端不确定自己发给客户端的 SYN-ACK 消息或客户端反馈的 ACK 消息是否会丢在半路,所以会给每个待完成的半开连接状态设一个定时器,如果超过时间还没有收到客户端的 ACK 消息,则重新发送一次 SYN-ACK 消息给客户端,直到重试超过一定次数时才会放弃。

服务端为了维持半开连接状态,需要分配内核资源维护半开连接。当攻击者伪造海量的虚假 IP 向服务端发送 SYN 包时,就形成了 SYN FLOOD 攻击。攻击者故意不响应 ACK 消息,导致服务端被大量注定不能完成的半开连接占据,直到资源耗尽,停止响应正常的连接请求。

解决方法:

  • 直接的方法是提高 TCP 端口容量的同时减少半开连接的资源占用时间,然而该方法只是稍稍提高了防御能力;

  • 部署能够辨别恶意 IP 的路由器,将伪造 IP 地址的发送方发送的 SYN 消息过滤掉,该方案作用一般不是太大;

上述两种方法虽然在一定程度上能够提高服务器的防御能力,但是没有从根本上解决服务器资源消耗殆尽的问题,而以下几种方法的出发点都是在发送方发送确认回复后才开始分配传输资源,从而避免服务器资源消耗殆尽。

SYN Cache:该方法首先构造一个全局 Hash Table,用来缓存系统当前所有的半开连接信息。在 Hash Table 中的每个桶的容量大小是有限制的,当桶满时,会主动丢掉早来的信息。当服务端收到一个 SYN 消息后,会通过一个映射函数生成一个相应的 Key 值,使得当前半连接信息存入相应的桶中。当收到客户端正确的确认报文后,服务端才开始分配传输资源块,并将相应的半开连接信息从表中删除。和服务器传输资源相比,维护表的开销要小得多。

SYN Cookies:该方案原理和 HTTP Cookies 技术类似,服务端通过特定的算法将半开连接信息编码成序列号或者时间戳,用作服务端给客户端的消息编号,随 SYN-ACK 消息一同返回给连接发起方,这样在连接建立完成前服务端不保存任何信息,直到发送方发送 ACK 确认报文并且服务端成功验证编码信息后,服务端才开始分配传输资源。若请求方是攻击者,则不会向服务端会 ACK 消息,由于未成功建立连接,因此服务端并没有花费任何额外的开销。

然而该方案也存在一些缺点,由于服务端并不保存半开连接状态,因此也就丧失了超时重传的能力,这在一定程度上降低了正常用户的连接成功率。此外,客户端发送给服务端的确认报文存在传输丢失的可能,当 ACK 确认报文丢失时,服务端和客户端会对连接的成功与否产生歧义,此时就需要上层应用采取相应的策略进行处理了。

SYN Proxy:在客户端和服务器之间部署一个代理服务器,类似于防火墙的作用。通过代理服务器与客户端进行建立连接的过程,之后代理服务器充当客户端将成功建立连接的客户端信息发送给服务器。这种方法基本不消耗服务器的资源,但是建立连接的时间变长了(总共需要 6 次握手)。

TCP粘包

为什么会发生TCP粘包和拆包?

① 发送方写入的数据大于套接字缓冲区的大小,此时将发生拆包。

② 发送方写入的数据小于套接字缓冲区大小,由于 TCP 默认使用 Nagle 算法,只有当收到一个确认后,才将分组发送给对端,当发送方收集了多个较小的分组,就会一起发送给对端,这将会发生粘包。

③ 进行 MSS (最大报文长度)大小的 TCP 分段,当 TCP 报文的数据部分大于 MSS 的时候将发生拆包。

④ 发送方发送的数据太快,接收方处理数据的速度赶不上发送端的速度,将发生粘包。

常见解决方法

① 在消息的头部添加消息长度字段,服务端获取消息头的时候解析消息长度,然后向后读取相应长度的内容。

② 固定消息数据的长度,服务端每次读取既定长度的内容作为一条完整消息,当消息不够长时,空位补上固定字符。但是该方法会浪费网络资源。

③ 设置消息边界,也可以理解为分隔符,服务端从数据流中按消息边界分离出消息内容,一般使用换行符。

什么时候需要处理粘包问题?

当接收端同时收到多个分组,并且这些分组之间毫无关系时,需要处理粘包;而当多个分组属于同一数据的不同部分时,并不需要处理粘包问题。

22、TCP心跳包

24、UDP如何实现可靠传输

应用层:

DNS域名系统。采用TCP还是UDP协议?为什么?

DNS 为什么同时使用 TCP 和 UDP

当进行区域传送(主域名服务器向辅助域名服务器传送变化的那部分数据)时会使用 TCP,因为数据同步传送的数据量比一个请求和应答的数据量要多,而 TCP 允许的报文长度更长,因此为了保证数据的正确性,会使用基于可靠连接的 TCP。

当客户端向 DNS 服务器查询域名 ( 域名解析) 的时候,一般返回的内容不会超过 UDP 报文的最大长度,即 512 字节。用 UDP 传输时,不需要经过 TCP 三次握手的过程,从而大大提高了响应速度,但这要求域名解析器和域名服务器都必须自己处理超时和重传从而保证可靠性。

26、FTP协议(了解)

27、HTTP请求报文与响应报文首部结构

简述 HTTP 1.0,1.1,2.0 的主要区别

参考回答

1、长连接:HTTP1.0不支持长连接,每进行一次通信就要断开TCP连接;HTTP1.1支持长连接,一个TCP连接中可以传送多个HTTP请求和响应,减少了TCP连接重复建立和断开所造成的额外开销,减轻了服务端的负载。
2、管线化:HTTP1.0工作方式为非流水线方式,发送请求后需等待并收到响应,才能发送下一个请求;HTTP1.1支持流水线工作方式,可以同时并行发送多个请求,不需要一个接一个地等待响应。

HTTP2.0相比HTTP1.1支持的特性:

  • 新的二进制格式:HTTP1.1 基于文本格式传输数据;HTTP2.0采用二进制格式传输数据,解析更高效。
  • 多路复用:在一个连接里,允许同时发送多个请求或响应,并且这些请求或响应能够并行的传输而不被阻塞,避免 HTTP1.1 出现的”队头堵塞”问题。
  • 头部压缩,HTTP1.1的header带有大量信息,而且每次都要重复发送;HTTP2.0 把header从数据中分离,并封装成头帧和数据帧,使用特定算法压缩头帧,有效减少头信息大小。并且HTTP2.0在客户端和服务器端记录了之前发送的键值对,对于相同的数据,不会重复发送。比如请求a发送了所有的头信息字段,请求b则只需要发送差异数据,这样可以减少冗余数据,降低开销。
  • 服务端推送:HTTP2.0允许服务器向客户端推送资源,无需客户端发送请求到服务器获取。

HTTP与HTTPS对比(4点)

  1. HTTP 协议以明文方式发送内容,数据都是未加密的,安全性较差。HTTPS 数据传输过程是加密的,安全性较好。
  2. HTTP 和 HTTPS 使用的是完全不同的连接方式,用的端口也不一样,前者是 80 端口,后者是 443 端口。
  3. HTTPS 协议需要到数字认证机构(Certificate Authority, CA)申请证书,一般需要一定的费用。
  4. HTTP 页面响应比 HTTPS 快,主要因为 HTTP 使用 3 次握手建立连接,客户端和服务器需要握手 3 次,而 HTTPS 除了 TCP 的 3 次握手,还需要经历一个 SSL 协商过程。

HTTPS加密流程

总结https的加密流程为:
(1)服务器将自己的公开密钥提交给数字认证机构;
(2)数字认证机构用其私有密钥给公开密钥署数字签名并颁发公钥证书,然后发送给服务器;
(3)服务器将公钥证书(包括公开密钥+数字签名)发送给客户端;(数字签名相当于CA机构的私有密钥)
(4)客户端使用数字认证机构的公开密钥验证数字签名,从而验证服务器公开密钥的真实性;(客户端操作系统中已事先置入了CA数字认证机构的公开密钥)
(5)客户端随机生成共享密钥,使用公开密钥加密传送共享密钥给服务器;
(6)服务端使用私有密钥解密获取客户端的共享密钥;
(7)客户端和服务端使用共享密钥进行通信。

  1. HTTPS(Hyper Text Transfer Protocol over Secure Socket Layer)是以安全为目标的 HTTP 协议,在 HTTP 的基础上通过传输加密和身份认证的方式保证了传输过程的安全性。其工作流程如下:

    ① 客户端发起一个 HTTPS 请求,并连接到服务器的 443 端口,发送的信息主要包括自身所支持的算法列表和密钥长度等;

    ② 服务端将自身所支持的所有加密算法与客户端的算法列表进行对比并选择一种支持的加密算法,然后将它和其它密钥组件一同发送给客户端。

    ③ 服务器向客户端发送一个包含数字证书的报文,该数字证书中包含证书的颁发机构、过期时间、服务端的公钥等信息。

    ④ 最后服务端发送一个完成报文通知客户端 SSL 的第一阶段已经协商完成。

    ⑤ SSL 第一次协商完成后,客户端发送一个回应报文,报文中包含一个客户端生成的随机密码串,称为 pre_master_secre,并且该报文是经过证书中的公钥加密过的。

    ⑥ 紧接着客户端会发送一个报文提示服务端在此之后的报文是采用pre_master_secre 加密的。

    ⑦ 客户端向服务端发送一个 finish 报文,这次握手中包含第一次握手至今所有报文的整体校验值,最终协商是否完成取决于服务端能否成功解密。

    ⑧ 服务端同样发送与第 ⑥ 步中相同作用的报文,已让客户端进行确认,最后发送 finish 报文告诉客户端自己能够正确解密报文。

    当服务端和客户端的 finish 报文交换完成之后,SSL 连接就算建立完成了,之后就进行和 HTTP 相同的通信过程,唯一不同的是在 HTTP 通信过程中并不是采用明文传输,而是采用对称加密的方式,其中对称密钥已经在 SSL 的建立过程中协商好了。

HTTPS 采用对称加密和非对称加密相结合的方式,首先使用 SSL/TLS 协议进行加密传输,为了弥补非对称加密的缺点,HTTPS 采用证书来进一步加强非对称加密的安全性,通过非对称加密,客户端和服务端协商好之后进行通信传输的对称密钥,后续的所有信息都通过该对称秘钥进行加密解密,完成整个 HTTPS 的流程。

HTTP请求方法和状态码

HTTP(HyperText Transfer Protocol,超文本传输协议)是一种用于分布式、协作式和超媒体信息系统的应用层协议。HTTP 是万维网的数据通信的基础。

请求方法

方法 意义
OPTIONS 请求一些选项信息,允许客户端查看服务器的性能
GET 请求指定的页面信息,并返回实体主体
HEAD 类似于 get 请求,只不过返回的响应中没有具体的内容,用于获取报头
POST 向指定资源提交数据进行处理请求(例如提交表单或者上传文件)。数据被包含在请求体中。POST请求可能会导致新的资源的建立和/或已有资源的修改
PUT 从客户端向服务器传送的数据取代指定的文档的内容
DELETE 请求服务器删除指定的页面
TRACE 回显服务器收到的请求,主要用于测试或诊断

状态码(Status-Code)

  • 1xx:表示通知信息,如请求收到了或正在进行处理
    • 100 Continue:继续,客户端应继续其请求
    • 101 Switching Protocols 切换协议。服务器根据客户端的请求切换协议。只能切换到更高级的协议,例如,切换到 HTTP 的新版本协议
  • 2xx:表示成功,如接收或知道了
    • 200 OK: 请求成功
  • 3xx:表示重定向,如要完成请求还必须采取进一步的行动
    • 301 Moved Permanently: 永久移动。请求的资源已被永久的移动到新 URL,返回信息会包括新的 URL,浏览器会自动定向到新 URL。今后任何新的请求都应使用新的 URL 代替
  • 4xx:表示客户的差错,如请求中有错误的语法或不能完成
    • 400 Bad Request: 客户端请求的语法错误,服务器无法理解
    • 401 Unauthorized: 请求要求用户的身份认证
    • 403 Forbidden: 服务器理解请求客户端的请求,但是拒绝执行此请求(权限不够)
    • 404 Not Found: 服务器无法根据客户端的请求找到资源(网页)。通过此代码,网站设计人员可设置 “您所请求的资源无法找到” 的个性页面
    • 408 Request Timeout: 服务器等待客户端发送的请求时间过长,超时
  • 5xx:表示服务器的差错,如服务器失效无法完成请求
    • 500 Internal Server Error: 服务器内部错误,无法完成请求
    • 503 Service Unavailable: 由于超载或系统维护,服务器暂时的无法处理客户端的请求。延时的长度可包含在服务器的 Retry-After 头信息中
    • 504 Gateway Timeout: 充当网关或代理的服务器,未及时从远端服务器获取请求

说说 GET请求和 POST 请求的区别【位置、明文密文、安全、长度、场景】

  1. get 提交的数据会放在 URL 之后,并且请求参数会被完整的保留在浏览器的记录里,由于参数直接暴露在 URL 中,可能会存在安全问题,因此往往用于获取资源信息。而 post 参数放在请求主体中,并且参数不会被保留,相比 get 方法,post 方法更安全,主要用于修改服务器上的资源。
  2. get 请求只支持 URL 编码,post 请求支持多种编码格式。
  3. get 只支持 ASCII 字符格式的参数,而 post 方法没有限制。
  4. get 提交的数据大小有限制(这里所说的限制是针对浏览器而言的),而 post 方法提交的数据没限制
  5. get 方式需要使用 Request.QueryString 来取得变量的值,而 post 方式通过 Request.Form 来获取。
  6. get 方法产生一个 TCP 数据包,post 方法产生两个(并不是所有的浏览器中都产生两个)。

对于GET方式的请求,浏览器会把http header和data一并发送出去,服务端响应200,请求成功。

对于POST方式的请求,浏览器会先发送http header给服务端,告诉服务端等一下会有数据过来,服务端响应100 continue,告诉浏览器我已经准备接收数据,浏览器再post发送一个data给服务端,服务端响应200,请求成功。

什么是cookie和session?

由于HTTP协议是无状态的协议,需要用某种机制来识具体的用户身份,用来跟踪用户的整个会话。常用的会话跟踪技术是cookie与session。

cookie就是由服务器发给客户端的特殊信息,而这些信息以文本文件的方式存放在客户端,然后客户端每次向服务器发送请求的时候都会带上这些特殊的信息。说得更具体一些:当用户使用浏览器访问一个支持cookie的网站的时候,用户会提供包括用户名在内的个人信息并且提交至服务器;接着,服务器在向客户端回传相应的超文本的同时也会发回这些个人信息,当然这些信息并不是存放在HTTP响应体中的,而是存放于HTTP响应头;当客户端浏览器接收到来自服务器的响应之后,浏览器会将这些信息存放在一个统一的位置。 自此,客户端再向服务器发送请求的时候,都会把相应的cookie存放在HTTP请求头再次发回至服务器。服务器在接收到来自客户端浏览器的请求之后,就能够通过分析存放于请求头的cookie得到客户端特有的信息,从而动态生成与该客户端相对应的内容。网站的登录界面中“请记住我”这样的选项,就是通过cookie实现的。
img

cookie工作流程

(1)客户端第一次发送请求给服务器端;
(2)服务端创建Cookie(包含用户的信息,对应一个识别码)并将其发送给客户端,客户端会保存cookie文件;
(3)客户端再次访问服务端时,会自动在请求报文中加入Cookie值发送给服务端;
(4)服务端接受到报文后,通过Cookie值就能确认用户身份。

session原理:首先浏览器请求服务器访问web站点时,服务器首先会检查这个客户端请求是否已经包含了一个session标识、称为SESSIONID,如果已经包含了一个sessionid则说明以前已经为此客户端创建过session,服务器就按照sessionid把这个session检索出来使用,如果客户端请求不包含session id,则服务器为此客户端创建一个session,并且生成一个与此session相关联的独一无二的sessionid存放到cookie中,这个sessionid将在本次响应中返回到客户端保存,这样在交互的过程中,浏览器端每次请求时,都会带着这个sessionid,服务器根据这个sessionid就可以找得到对应的session。以此来达到共享数据的目的。 这里需要注意的是,session不会随着浏览器的关闭而死亡,而是等待超时时间。

(1)客户端第一次访问服务端;
(2)服务端创建一个sessionId和session形成一个映射关系,session保存在后端数据库,然后将sessionId发送给客户端并保存;
(3)客户端再次访问服务端时,会自动在请求报文的首部行中加入sessionId;
(4)服务端根据sessionId去查询Session对象,从而区分不同用户。

img

Cookie和Session的区别?(4点)

  • 作用范围不同,Cookie 保存在客户端,Session 保存在服务器端。
  • 有效期不同,Cookie 可设置为长时间保持,比如我们经常使用的默认登录功能,Session 一般失效时间较短,客户端关闭或者 Session 超时都会失效。
  • 隐私策略不同,Cookie 存储在客户端,容易被窃取;Session 存储在服务端,安全性相对 Cookie 要好一些。
  • 存储大小不同, 单个 Cookie 保存的数据不能超过 4K;对于 Session 来说存储没有上限,但出于对服务器的性能考虑,Session 内不要存放过多的数据,并且需要设置 Session 删除机制。

输入一个URL到显示页面的流程(越详细越好,搞明白这个,网络这块就差不多了)

  1. 浏览器查询 DNS,获取域名对应的IP地址:具体过程包括浏览器搜索自身的DNS缓存、搜索操作系统的DNS缓存、读取本地的Host文件和向本地DNS服务器进行查询等。对于向本地DNS服务器进行查询,如果要查询的域名包含在本地配置区域资源中,则返回解析结果给客户机,完成域名解析(此解析具有权威性);如果要查询的域名不由本地DNS服务器区域解析,但该服务器已缓存了此网址映射关系,则调用这个IP地址映射,完成域名解析(此解析不具有权威性)。如果本地域名服务器并未缓存该网址映射关系,那么将根据其设置发起递归查询或者迭代查询;
  2. 浏览器获得域名对应的IP地址以后,浏览器向服务器请求建立链接,发起三次握手;
  3. TCP/IP链接建立起来后,浏览器向服务器发送HTTP请求;
  4. 服务器接收到这个请求,并根据路径参数映射到特定的请求处理器进行处理,并将处理结果及相应的视图返回给浏览器;
  5. 浏览器解析并渲染视图,若遇到对js文件、css文件及图片等静态资源的引用,则重复上述步骤并向服务器请求这些资源;
  6. 浏览器根据其请求到的资源、数据渲染页面,最终向用户呈现一个完整的页面。

操作系统

进程和线程

进程与线程区别(资源调度,关系,拥有资源,内存,通信,健壮

  • 参考回答

    1. 进程:程序是指令、数据及其组织形式的描述,而进程则是程序的运行实例,包括程序计数器、寄存器和变量的当前值。
    2. 线程:微进程,一个进程里更小粒度的执行单元。一个进程里包含多个线程并发执行任务。
    3. 协程:协程是微线程,在子程序内部执行,可在子程序内部中断,转而执行别的子程序,在适当的时候再返回来接着执行。

    区别

    1. 线程与进程的区别
  • 进程(Process)是系统进行资源分配和调度的基本单位,线程(Thread)是CPU调度和分派的基本单位;

  • 线程依赖于进程而存在,一个进程至少有一个线程;

    • 进程有自己的独立地址空间,线程共享所属进程的地址空间;
  • 进程是拥有系统资源的一个独立单位,而线程自己基本上不拥有系统资源,只拥有一点在运行中必不可少的资源(如程序计数器,一组寄存器和栈),和其他线程共享本进程的相关资源如内存、I/O、cpu等;

    • 在进程切换时,涉及到整个当前进程CPU环境的保存环境的设置以及新被调度运行的CPU环境的设置,而线程切换只需保存和设置少量的寄存器的内容,并不涉及存储器管理方面的操作,可见,进程切换的开销远大于线程切换的开销;
  • 线程之间的通信更方便,同一进程下的线程共享全局变量等数据,而进程之间的通信需要以进程间通信(IPC)的方式进行;

    • 多线程程序只要有一个线程崩溃,整个程序就崩溃了,但多进程程序中一个进程崩溃并不会对其它进程造成影响,因为进程有自己的独立地址空间,因此多进程更加健壮
    1. 线程与协程的区别:

      (1)协程执行效率极高。协程直接操作栈基本没有内核切换的开销,所以上下文的切换非常快,切换开销比线程更小。

      (2)协程不需要多线程的锁机制,因为多个协程从属于一个线程,不存在同时写变量冲突,效率比线程高。

      (3)一个线程可以有多个协程。

    2. 进程之间私有和共享的资源

      • 私有:地址空间、堆、全局变量、栈、寄存器
    • 共享:代码段,公共数据,进程目录,进程 ID
  1. 线程之间私有和共享的资源
    • 私有:线程栈,寄存器,程序计数器

    • 共享:堆,地址空间,全局变量,静态变量

进程切换

image-20220411192821098

  • 就绪状态(ready):等待被调度
  • 运行状态(running)
  • 阻塞状态(waiting):等待资源

应该注意以下内容:

  • 只有就绪态和运行态可以相互转换,其它的都是单向转换。就绪状态的进程通过调度算法从而获得 CPU 时间,转为运行状态;而运行状态的进程,在分配给它的 CPU 时间片用完之后就会转为就绪状态,等待下一次调度。
  • 阻塞状态是缺少需要的资源从而由运行状态转换而来,但是该资源不包括 CPU 时间,缺少 CPU 时间会从运行态转换为就绪态。

进程切换流程

如果想要从A进程切换到 B 进程,必定要先从用户态切换到内核态,因为这个切换的工作你不能让用户进程去实现,不然当 CPU 在用户进程手上的时候,他可以选择一直执行,不让出 CPU,这肯定是不允许的。所以操作系统需要先挂起正在占用 CPU 的 A 进程,才能切换到 B 进程。

由于从用户态切换到内核态的时候,CPU 是在用户进程手中,所以这个是通过硬中断来实现的。在从用户态切换到内核态之前需要保存用户进程的上下文,以便下一次执行时可以继续之前的工作。

这个上下文就是进程执行的环境,包括所有的寄存器变量,进程打开的文件、内存信息等。一个进程的上下文可以分为用户级上下文,寄存器上下文,系统级上下文。用户级上下文存储的是用户进程的内存数据以及堆栈数据等;寄存器上下文是一些通用寄存器;系统级上下文是内核栈、PCB (进程控制块)等。

进程的调度算法

不同环境的调度算法目标不同,因此需要针对不同环境来讨论调度算法。

1. 批处理系统

批处理系统没有太多的用户操作,在该系统中,调度算法目标是保证吞吐量和周转时间(从提交到终止的时间)。

1.1 先来先服务 first-come first-serverd(FCFS)

非抢占式的调度算法,按照请求的顺序进行调度。

有利于长作业,但不利于短作业,因为短作业必须一直等待前面的长作业执行完毕才能执行,而长作业又需要执行很长时间,造成了短作业等待时间过长。

1.2 短作业优先 shortest job first(SJF)

非抢占式的调度算法,按估计运行时间最短的顺序进行调度。

长作业有可能会饿死,处于一直等待短作业执行完毕的状态。因为如果一直有短作业到来,那么长作业永远得不到调度。

1.3 最短剩余时间优先 shortest remaining time next(SRTN)

最短作业优先的抢占式版本,按剩余运行时间的顺序进行调度。 当一个新的作业到达时,其整个运行时间与当前进程的剩余时间作比较。如果新的进程需要的时间更少,则挂起当前进程,运行新的进程。否则新的进程等待。

2. 交互式系统

交互式系统有大量的用户交互操作,在该系统中调度算法的目标是快速地进行响应。

2.1 时间片轮转

将所有就绪进程按 FCFS 的原则排成一个队列,每次调度时,把 CPU 时间分配给队首进程,该进程可以执行一个时间片。当时间片用完时,由计时器发出时钟中断,调度程序便停止该进程的执行,并将它送往就绪队列的末尾,同时继续把 CPU 时间分配给队首的进程。

时间片轮转算法的效率和时间片的大小有很大关系:

  • 因为进程切换都要保存进程的信息并且载入新进程的信息,如果时间片太小,会导致进程切换得太频繁,在进程切换上就会花过多时间。
  • 而如果时间片过长,那么实时性就不能得到保证。

image-20220411192939650

2.2 优先级调度

为每个进程分配一个优先级,按优先级进行调度。

为了防止低优先级的进程永远等不到调度,可以随着时间的推移增加等待进程的优先级。

2.3 多级反馈队列

一个进程需要执行 100 个时间片,如果采用时间片轮转调度算法,那么需要交换 100 次。

多级队列是为这种需要连续执行多个时间片的进程考虑,它设置了多个队列,每个队列时间片大小都不同,例如 1,2,4,8,..。进程在第一个队列没执行完,就会被移到下一个队列。这种方式下,之前的进程只需要交换 7 次。

每个队列优先权也不同,最上面的优先权最高。因此只有上一个队列没有进程在排队,才能调度当前队列上的进程。

可以将这种调度算法看成是时间片轮转调度算法和优先级调度算法的结合。

[image-20220411193003902

3. 实时系统

实时系统要求一个请求在一个确定时间内得到响应。

分为硬实时和软实时,前者必须满足绝对的截止时间,后者可以容忍一定的超时。

线程同步的方式:锁、信号量、信号、屏障

  • 锁机制:包括互斥锁/量(mutex)、读写锁(reader-writer lock)、自旋锁(spin lock)、条件变量(condition)
    • 互斥锁/量(mutex):提供了以排他方式防止数据结构被并发修改的方法。
    • 读写锁(reader-writer lock):允许多个线程同时读共享数据,而对写操作是互斥的。
    • 自旋锁(spin lock)与互斥锁类似,都是为了保护共享资源。互斥锁是当资源被占用,申请者进入睡眠状态;而自旋锁则循环检测保持者是否已经释放锁。
    • 条件变量(condition):可以以原子的方式阻塞进程,直到某个特定条件为真为止。对条件的测试是在互斥锁的保护下进行的。条件变量始终与互斥锁一起使用。
  • 信号量机制(Semaphore)
    • 无名线程信号量
    • 命名线程信号量
  • 信号机制(Signal):类似进程间的信号处理
  • 屏障(barrier):屏障允许每个线程等待,直到所有的合作线程都达到某一点,然后从该点继续执行。

线程间的通信目的主要是用于线程同步,所以线程没有像进程通信中的用于数据交换的通信机制

进程同步

1. 临界区

对临界资源进行访问的那段代码称为临界区。

为了互斥访问临界资源,每个进程在进入临界区之前,需要先进行检查。

1
2
3
// entry section
// critical section;
// exit section

2. 同步与互斥

  • 同步:多个进程因为合作产生的直接制约关系,使得进程有一定的先后执行关系。
  • 互斥:多个进程在同一时刻只有一个进程能进入临界区。

3. 信号量

信号量(Semaphore)是一个整型变量,可以对其执行 down 和 up 操作,也就是常见的 P 和 V 操作。

  • down : 如果信号量大于 0 ,执行 -1 操作;如果信号量等于 0,进程睡眠,等待信号量大于 0;
  • up :对信号量执行 +1 操作,唤醒睡眠的进程让其完成 down 操作。

down 和 up 操作需要被设计成原语,不可分割,通常的做法是在执行这些操作的时候屏蔽中断。

如果信号量的取值只能为 0 或者 1,那么就成为了 互斥量(Mutex) ,0 表示临界区已经加锁,1 表示临界区解锁。

1
2
3
4
5
6
7
8
9
10
11
12
13
typedef int semaphore;
semaphore mutex = 1;
void P1() {
down(&mutex);
// 临界区
up(&mutex);
}

void P2() {
down(&mutex);
// 临界区
up(&mutex);
}

使用信号量实现生产者-消费者问题

问题描述:使用一个缓冲区来保存物品,只有缓冲区没有满,生产者才可以放入物品;只有缓冲区不为空,消费者才可以拿走物品。

因为缓冲区属于临界资源,因此需要使用一个互斥量 mutex 来控制对缓冲区的互斥访问。

为了同步生产者和消费者的行为,需要记录缓冲区中物品的数量。数量可以使用信号量来进行统计,这里需要使用两个信号量:empty 记录空缓冲区的数量,full 记录满缓冲区的数量。其中,empty 信号量是在生产者进程中使用,当 empty 不为 0 时,生产者才可以放入物品;full 信号量是在消费者进程中使用,当 full 信号量不为 0 时,消费者才可以取走物品。

注意,不能先对缓冲区进行加锁,再测试信号量。也就是说,不能先执行 down(mutex) 再执行 down(empty)。如果这么做了,那么可能会出现这种情况:生产者对缓冲区加锁后,执行 down(empty) 操作,发现 empty = 0,此时生产者睡眠。消费者不能进入临界区,因为生产者对缓冲区加锁了,消费者就无法执行 up(empty) 操作,empty 永远都为 0,导致生产者永远等待下,不会释放锁,消费者因此也会永远等待下去。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#define N 100
typedef int semaphore;
semaphore mutex = 1;
semaphore empty = N;
semaphore full = 0;

void producer() {
while(TRUE) {
int item = produce_item();
down(&empty);
down(&mutex);
insert_item(item);
up(&mutex);
up(&full);
}
}

void consumer() {
while(TRUE) {
down(&full);
down(&mutex);
int item = remove_item();
consume_item(item);
up(&mutex);
up(&empty);
}
}

4. 管程

使用信号量机制实现的生产者消费者问题需要客户端代码做很多控制,而管程把控制的代码独立出来,不仅不容易出错,也使得客户端代码调用更容易。

c 语言不支持管程,下面的示例代码使用了类 Pascal 语言来描述管程。示例代码的管程提供了 insert() 和 remove() 方法,客户端代码通过调用这两个方法来解决生产者-消费者问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
monitor ProducerConsumer
integer i;
condition c;

procedure insert();
begin
// ...
end;

procedure remove();
begin
// ...
end;
end monitor;

管程有一个重要特性:在一个时刻只能有一个进程使用管程。进程在无法继续执行的时候不能一直占用管程,否则其它进程永远不能使用管程。

管程引入了 条件变量 以及相关的操作:wait()signal() 来实现同步操作。对条件变量执行 wait() 操作会导致调用进程阻塞,把管程让出来给另一个进程持有。signal() 操作用于唤醒被阻塞的进程。

使用管程实现生产者-消费者问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// 管程
monitor ProducerConsumer
condition full, empty;
integer count := 0;
condition c;

procedure insert(item: integer);
begin
if count = N then wait(full);
insert_item(item);
count := count + 1;
if count = 1 then signal(empty);
end;

function remove: integer;
begin
if count = 0 then wait(empty);
remove = remove_item;
count := count - 1;
if count = N -1 then signal(full);
end;
end monitor;

// 生产者客户端
procedure producer
begin
while true do
begin
item = produce_item;
ProducerConsumer.insert(item);
end
end;

// 消费者客户端
procedure consumer
begin
while true do
begin
item = ProducerConsumer.remove;
consume_item(item);
end
end;

进程间通信:PIPE、FIFO、消息队列、信号量、信号、共享内存、socket

  • 管道(PIPE)
    • 有名管道(FIFO):一种半双工的通信方式,它允许无亲缘关系进程间的通信
      • 优点:可以实现任意关系的进程间的通信
      • 缺点:
        1. 长期存于系统中,使用不当容易出错
        2. 缓冲区有限
    • 无名管道:一种半双工的通信方式,只能在具有亲缘关系的进程间使用(父子进程)
      • 优点:简单方便
      • 缺点:
        1. 局限于单向通信
        2. 只能创建在它的进程以及其有亲缘关系的进程之间
        3. 缓冲区有限
  • 信号量(Semaphore):一个计数器,可以用来控制多个线程对共享资源的访问
    • 优点:可以同步进程
    • 缺点:信号量有限
  • 信号(Signal):一种比较复杂的通信方式,用于通知接收进程某个事件已经发生
  • 消息队列(Message Queue):是消息的链表,存放在内核中并由消息队列标识符标识
    • 优点:可以实现任意进程间的通信,并通过系统调用函数来实现消息发送和接收之间的同步,无需考虑同步问题,方便
    • 缺点:信息的复制需要额外消耗 CPU 的时间,不适宜于信息量大或操作频繁的场合
  • 共享内存(Shared Memory):映射一段能被其他进程所访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问
    • 优点:无须复制,快捷,信息量大
    • 缺点:
      1. 通信是通过将共享空间缓冲区直接附加到进程的虚拟地址空间中来实现的,因此进程间的读写操作的同步问题
      2. 利用内存缓冲区直接交换信息,内存的实体存在于计算机中,只能同一个计算机系统中的诸多进程共享,不方便网络通信
  • 套接字(Socket):可用于不同计算机间的进程通信
    • 优点:
      1. 传输数据为字节级,传输数据可自定义,数据量小效率高
      2. 传输数据时间短,性能高
      3. 适合于客户端和服务器端之间信息实时交互
      4. 可以加密,数据安全性强
    • 缺点:需对传输的数据进行解析,转化成应用级的数据

  • 互斥锁与自旋锁的底层区别

自旋锁:适用于锁持有时间短的情况

互斥锁是通过休眠使线程阻塞,此时cpu会进行线程调度,将阻塞的线程挂起并读入下一个要运行线程的内存内容,等到解锁后,被挂起的线程变为可运行状态,等待cpu调用。当互斥锁持有的时间很短时,线程调度上下文切换的时间远远大于阻塞的时间,此时进行线程调度不是最优方法。自旋锁与互斥锁类似,但其在获取锁之前一直处于忙等(未让出cpu)阻塞状态。因此,自旋锁通常用于以下情况:锁被持有的时间短,而且线程并不希望在重新调度上花费太多的成本。

读写锁:适用于读操作远大于写的情况

互斥锁只有两种状态:加锁状态和不加锁状态。读写锁有三种状态:读模式下加锁状态,写模式下加锁状态,不加锁状态。一次只有一个线程可以占有写模式的读写锁,但是多个线程可以同时占有读模式下的读写锁。

写模式下加锁:独占式锁,在该锁被解锁之前,所有试图对该锁进行加锁的线程都会被阻塞;
读模式下加锁:共享式锁,所有线程都可以以读模式来访问该锁,但是写模式下的线程都会在获取锁之前阻塞,直到所有读模式下的线程释放读锁;

孤儿进程与僵尸进程

  1. 孤儿进程:是指一个父进程退出后,而它的一个或多个子进程还在运行,那么这些子进程将成为孤儿进程。孤儿进程将被init进程(进程号为1)所收养,并且由init进程对它们完整状态收集工作。

  2. 僵尸进程:是指一个进程使用fork函数创建子进程,如果子进程退出,而父进程并没有调用wait()或者waitpid()系统调用取得子进程的终止状态,那么子进程的进程描述符仍然保存在系统中,占用系统资源,这种进程称为僵尸进程。

  3. 如何解决僵尸进程

    (1)一般,为了防止产生僵尸进程,在fork子进程之后我们都要及时使用wait系统调用;同时,当子进程退出的时候,内核都会给父进程一个SIGCHLD信号,所以我们可以建立一个捕获SIGCHLD信号的信号处理函数,在函数体中调用wait(或waitpid),就可以清理退出的子进程以达到防止僵尸进程的目的。

    (2)使用kill命令

    ​ 打开终端并输入下面命令:

    1
    ps aux | grep Z 

    ​ 会列出进程表中所有僵尸进程的详细内容。

    ​ 然后输入命令:

    1
    kill -s SIGCHLD pid(父进程pid)

死锁及避免

原因

  • 系统资源不足
  • 资源分配不当
  • 进程运行推进顺序不合适

产生条件

  • 互斥条件
  • 占有且等待条件:线程占有已经分配给它们的资源(如锁)并且等待其他的资源(也就是说不会主动释放)
  • 不可抢占条件(也就是说不会被动释放)
  • 环路等待条件:每个进程都在等待下一个进程占有的资源

预防

  • 打破互斥条件:改造独占性资源为虚拟资源,大部分资源已无法改造。
  • 打破不可抢占条件:当一进程占有一独占性资源后又申请一独占性资源而无法满足,则退出原占有的资源。
  • 打破占有且申请条件:采用资源预先分配策略,即进程运行前申请全部资源,满足则运行,不然就等待,这样就不会占有且申请。
  • 打破循环等待条件:实现资源有序分配策略,对所有设备实现分类编号,所有进程只能采用按序号递增的形式申请资源。
  • 有序资源分配法
  • 银行家算法

多进程与多线程间的对比、优劣与选择

对比

对比维度 多进程 多线程 总结
数据共享、同步 数据共享复杂,需要用 IPC;数据是分开的,同步简单 因为共享进程数据,数据共享简单,但也是因为这个原因导致同步复杂 各有优势
内存、CPU 占用内存多,切换复杂,CPU 利用率低 占用内存少,切换简单,CPU 利用率高 线程占优
创建销毁、切换 创建销毁、切换复杂,速度慢 创建销毁、切换简单,速度很快 线程占优
编程、调试 编程简单,调试简单 编程复杂,调试复杂 进程占优
可靠性 进程间不会互相影响 一个线程挂掉将导致整个进程挂掉 进程占优
分布式 适应于多核、多机分布式;如果一台机器不够,扩展到多台机器比较简单 适应于多核分布式 进程占优

优劣

优劣 多进程 多线程
优点 编程、调试简单,可靠性较高 创建、销毁、切换速度快,内存、资源占用小
缺点 创建、销毁、切换速度慢,内存、资源占用大 编程、调试复杂,可靠性较差

选择

  • 需要频繁创建销毁的优先用线程
  • 需要进行大量计算的优先使用线程
  • 强相关的处理用线程,弱相关的处理用进程
  • 可能要扩展到多机分布的用进程,多核分布的用线程
  • 都满足需求的情况下,用你最熟悉、最拿手的方式

多进程与多线程间的对比、优劣与选择来自:多线程还是多进程的选择及区别

请你说说什么是守护进程,如何实现?

参考回答

  1. 守护进程:守护进程是运行在后台的一种生存期长的特殊进程。它独立于控制终端,处理一些系统级别任务。

  2. 如何实现

    (1)创建子进程,终止父进程。方法是调用fork() 产生一个子进程,然后使父进程退出。

    (2)调用setsid() 创建一个新会话。

    (3)将当前目录更改为根目录。使用fork() 创建的子进程也继承了父进程的当前工作目录。

    (4)重设文件权限掩码。文件权限掩码是指屏蔽掉文件权限中的对应位。

    (5)关闭不再需要的文件描述符。子进程从父进程继承打开的文件描述符。

8、管道与消息队列对比

9、fork进程的底层:读时共享,写时复制

进程、线程的中断切换的过程是怎样的?

进程上线文切换

​ 和CPU上下文切换相比,进程上下文切换在保存当前进程的内核状态和 CPU 寄存器之前,需要先把该进程的虚拟内存、栈等保存下来。而加载了下一进程的内核态后,还需要刷新进程的虚拟内存和用户栈。下面是发生进程上线文切换的几个场景:

​ a)为了保证所有进程可以得到公平调度,CPU 时间被划分为一段段的时间片,这些时间片再被轮流分配给各个进程。这样,当某个进程的时间片耗尽了,就会被系统挂起,切换到其它正在等待 CPU 的进程运行。

​ b)进程在系统资源不足(比如内存不足)时,要等到资源满足后才可以运行,这个时候进程也会被挂起,并由系统调度其他进程运行。

​ c)当进程通过睡眠函数 sleep 这样的方法将自己主动挂起时,自然也会重新调度。

​ d)当有优先级更高的进程运行时,为了保证高优先级进程的运行,当前进程会被挂起,由高优先级进程来运行。

​ e)发生硬件中断时,CPU 上的进程会被中断挂起,转而执行内核中的中断服务程序。

线程上下文切换

​ 线程与进程最大的区别在于,线程是调度的基本单位,而进程则是资源拥有的基本单位。说白了,所谓内核中的任务调度,实际上的调度对象是线程;而进程只是给线程提供了虚拟内存、全局变量等资源。所以,对于线程和进程,我们可以这么理解:

​ a)当进程只有一个线程时,可以认为进程就等于线程。

​ b)当进程拥有多个线程时,这些线程会共享相同的虚拟内存和全局变量等资源。这些资源在上下文切换时是不需要修改的。

​ c)另外,线程也有自己的私有数据,比如栈和寄存器等,这些在上下文切换时也是需要保存的。

​ 这么一来,线程的上下文切换其实就可以分为两种情况:

​ a)前后两个线程属于不同进程。此时,因为资源不共享,所以切换过程就跟进程上下文切换是一样。

​ b)前后两个线程属于同一个进程。此时,因为虚拟内存是共享的,所以在切换时,虚拟内存这些资源就保持不动,只需要切换线程的私有数据、寄存器等不共享的数据。

阻塞IO与非阻塞IO

  • 同步阻塞IO(Blocking IO):用户线程发起IO读/写操作之后,线程阻塞,直到可以开始处理数据;对CPU资源的利用率不够;
  • 同步非阻塞IO(Non-blocking IO):发起IO请求之后可以立即返回,如果没有就绪的数据,需要不断地发起IO请求直到数据就绪;不断重复请求消耗了大量的CPU资源;
  • IO多路复用
  • 异步IO(Asynchronous IO):用户线程发出IO请求之后,继续执行,由内核进行数据的读取并放在用户指定的缓冲区内,在IO完成之后通知用户线程直接使用。(定时器)

同步与异步的概念

同步,就是调用某个东西是,调用方得等待这个调用返回结果才能继续往后执行。异步,和同步相反 调用方不会理解得到结果,而是在调用发出后调用者可用继续执行后续操作,被调用者通过状体来通知调用者,或者通过回掉函数来处理这个调用

阻塞和非阻塞 强调的是程序在等待调用结果(消息,返回值)时的状态. 阻塞调用是指调用结果返回之前,当前线程会被挂起。调用线程只有在得到结果之后才会返回。非阻塞调用指在不能立刻得到结果之前,该调用不会阻塞当前线程。 对于同步调用来说,很多时候当前线程还是激活的状态,只是从逻辑上当前函数没有返回而已,即同步等待时什么都不干,白白占用着资源。

同步和异步强调的是消息通信机制 (synchronous communication/ asynchronous communication)。所谓同步,就是在发出一个”调用”时,在没有得到结果之前,该“调用”就不返回。但是一旦调用返回,就得到返回值了。换句话说,就是由“调用者”主动等待这个“调用”的结果。而异步则是相反,”调用”在发出之后,这个调用就直接返回了,所以没有返回结果。换句话说,当一个异步过程调用发出后,调用者不会立刻得到结果。而是在”调用”发出后,”被调用者”通过状态、通知来通知调用者,或通过回调函数处理这个调用

静态链接与动态链接的过程

编译链接

静态链接

由链接器在链接时将库的内容直接加入到可执行程序中。a.out包含file1.o,file2.o,libc.a三个文件,运行的时候与原始的file1.o,file2.o,libc.a三个文件没有任何关系,不需要它们就可以直接运行。

[image-20220220191456232

Linux下静态库的创建和使用

  • 编译静态库源码: gcc -c lib.c -o libo
  • 生成静态库文件: ar -q lib.a lib.o
  • 使用静态库编译: gcc main.c lib.a -o main.out

image-20220220191509821

1
2
3
4
5
6
7
8
9
BASH
fengyun@ubuntu:~/share$ gcc -c slib.c -o slib.o
fengyun@ubuntu:~/share$ ar -q slib.a slib.o
ar: 正在创建 slib.a
fengyun@ubuntu:~/share$ gcc test1.c slib.a -o test1.out
fengyun@ubuntu:~/share$ ./test1.out
Name: Static Lib
Result: 5
fengyun@ubuntu:~/share$

Linux ar命令用于建立或修改备存文件,或是从备存文件中抽取文件。

ar可让您集合许多文件,成为单一的备存文件。在备存文件中,所有成员文件皆保有原来的属性与权限。

动态链接

  • 可执行程序在运行时才动态加载库进行链接
  • 库的内容不会进入可执行程序当中

image-20220220191521275

lib1.so和lib2.so动态库生成的stub1和stub2,是最终生成的可执行程序可以使用的内容,程序看不到其它内容。

1 静态链接库的优点

(1) 代码装载速度快,执行速度略比动态链接库快;

(2) 只需保证在开发者的计算机中有正确的.LIB文件,在以二进制形式发布程序时不需考虑在用户的计算机上.LIB文件是否存在及版本问题,可避免DLL地狱等问题。

2 动态链接库的优点

(1) 更加节省内存并减少页面交换;

(2) DLL文件与EXE文件独立,只要输出接口不变(即名称、参数、返回值类型和调用约定不变),更换DLL文件不会对EXE文件造成任何影响,因而极大地提高了可维护性和可扩展性;

(3) 不同编程语言编写的程序只要按照函数调用约定就可以调用同一个DLL函数;

(4)适用于大规模的软件开发,使开发过程独立、耦合度小,便于不同开发者和开发组织之间进行开发和测试。

3 不足之处

(1) 使用静态链接生成的可执行文件体积较大,包含相同的公共代码,造成浪费;

(2) 使用动态链接库的应用程序不是自完备的,它依赖的DLL模块也要存在,如果使用载入时动态链接,程序启动时发现DLL不存在,系统将终止程序并给出错误信息。而使用运行时动态链接,系统不会终止,但由于DLL中的导出函数不可用,程序会加载失败;速度比静态链接慢。当某个模块更新后,如果新模块与旧的模块不兼容,那么那些需要该模块才能运行的软件,统统撕掉。这在早期Windows中很常见。

用户态和内核态

什么是用户态和内核态

为了限制不同程序的访问能力,防止一些程序访问其它程序的内存数据,CPU划分了用户态和内核态两个权限等级。

  • 用户态只能受限地访问内存,且不允许访问外围设备,没有占用CPU的能力,CPU资源可以被其它程序获取;
  • 内核态可以访问内存所有数据以及外围设备,也可以进行程序的切换。

所有用户程序都运行在用户态,但有时需要进行一些内核态的操作,比如从硬盘或者键盘读数据,这时就需要进行系统调用,使用陷阱指令,CPU切换到内核态,执行相应的服务,再切换为用户态并返回系统调用的结果。

如何从用户态切换到内核态?

  • 系统调用:比如读取命令行输入。本质上还是通过中断实现
  • 用户程序发生异常时:比如缺页异常
  • 外围设备的中断:外围设备完成用户请求的操作之后,会向CPU发出中断信号,这时CPU会转去处理对应的中断处理程序

虚拟内存概念(非常重要)

最开始的时候,程序运行就是直接放进内存的,但是内存空间有限,程序放的时候成块放,有时候会造成内存空间的浪费或者造成大量的内存碎片,于是决定在磁盘上找一个空间,用来扩大内存,磁盘这段空间就成为虚拟内存。光扩大内存还不行,必须要减少内存碎片,所以,将物理内存进行了分页,每一页大小相等,同时将虚拟内存也进行了分页。平时不需要的就放在磁盘中,需要运行的磁盘通过虚拟内存放到实际的主存中,这样就不害怕主存太满放不下了。虚拟内存存放在磁盘里,它里面包括不存在的,未映射的和已经映射的内存地址。但是如果分页太多的话,每次调用进程就会特别占空间,所以又在内存中划分出一块空间,作为一级页表,后续需要哪一个页,再调用对应的页表进行查询转换。页表中存放的是虚拟地址和物理地址的映射关系。mmu负责根据页表将虚拟地址转换为实际地址。TLB里面是高速缓存,每次映射前可以先从TLB中查找是否有想用的缓存,可以大大提高转换效率。页表中的有效位会判断是否物理地址是否为空。缺页时会从主存里面选择一个牺牲页,并把该牺牲页的内容拷回到磁盘里,然后用需要的地址取代它。

17、MMU地址翻译的具体流程

18、缺页处理过程

缺页置换算法:最久未使用算法、先进先出算法、最佳置换算法

在程序运行过程中,如果要访问的页面不在内存中,就发生缺页中断从而将该页调入内存中。此时如果内存已无空闲空间,系统必须从内存中调出一个页面到磁盘中来腾出空间。页面置换算法的主要目标是使页面置换频率最低(也可以说缺页率最低)。

  • 最佳页面置换算法OPT(Optimal replacement algorithm):置换以后不需要或者最远的将来才需要的页面,是一种理论上的算法,是最优策略;
  • 先进先出FIFO:置换在内存中驻留时间最长的页面。缺点:有可能将那些经常被访问的页面也被换出,从而使缺页率升高;
  • 第二次机会算法SCR:按FIFO选择某一页面,若其访问位为1,给第二次机会,并将访问位置0;
  • 时钟算法 Clock:SCR中需要将页面在链表中移动(第二次机会的时候要将这个页面从链表头移到链表尾),时钟算法使用环形链表,再使用一个指针指向最老的页面,避免了移动页面的开销;
  • 最近未使用算法NRU(Not Recently Used):检查访问位R、修改位M,优先置换R=M=0,其次是(R=0, M=1);
  • 最近最少使用算法LRU(Least Recently Used):置换出未使用时间最长的一页;实现方式:维护时间戳,或者维护一个所有页面的链表。当一个页面被访问时,将这个页面移到链表表头。这样就能保证链表表尾的页面是最近最久未访问的。
  • 最不经常使用算法NFU:置换出访问次数最少的页面
局部性原理
  • 时间上:最近被访问的页在不久的将来还会被访问;
  • 空间上:内存中被访问的页周围的页也很可能被访问。
什么是颠簸现象

颠簸本质上是指频繁的页调度行为。进程发生缺页中断时必须置换某一页。然而,其他所有的页都在使用,它置换一个页,但又立刻再次需要这个页。因此会不断产生缺页中断,导致整个系统的效率急剧下降,这种现象称为颠簸。内存颠簸的解决策略包括:

  • 修改页面置换算法;
  • 降低同时运行的程序的数量;
  • 终止该进程或增加物理内存容量。

1、IO多路复用:select、poll、epoll的区别(非常重要,几乎必问,回答得越底层越好,要会使用)

I/O 多路复用,可以通过一次系统调用,检查多个文件描述符的状态。这是 I/O 多路复用的主要优点,相比于非阻塞 I/O,在文件描述符较多的场景下,避免了频繁的用户态和内核态的切换,减少了系统调用的开销。

I/O 多路复用相当于将「遍历所有文件描述符、通过非阻塞 I/O 查看其是否就绪」的过程从用户线程移到了内核中,由内核来负责轮询。

进程可以通过 select、poll、epoll 发起 I/O 多路复用的系统调用,这些系统调用都是同步阻塞的:如果传入的多个文件描述符中,有描述符就绪,则返回就绪的描述符;否则如果所有文件描述符都未就绪,就阻塞调用进程,直到某个描述符就绪,或者阻塞时长超过设置的 timeout 后,再返回。I/O 多路复用内部使用非阻塞 I/O 检查每个描述符的就绪状态。

2、手撕一个最简单的server端服务器(socket、bind、listen、accept这四个API一定要非常熟练)

3、线程池

4、基于事件驱动的reactor模式

5、边沿触发与水平触发的区别

6、非阻塞IO与阻塞IO区别

参考书籍:《Unix网络编程》
ps:网络编程掌握以上几点就够了,要搞明白还是要花很久时间的。

五、数据结构与算法刷题(2个月)
1、数组
2、链表
3、栈
4、队列
5、堆
6、二叉树:二叉搜索树、平衡树、红黑树
7、B树、B+树
8、哈希表及哈希冲突
9、排序算法:冒泡排序、简单选择排序、插入排序、希尔排序、归并排序、堆排序、快速排序
(要求能够面试时手写出堆排序和快速排序
10、二分法:旋转数组找target
11、回溯法:全排列、复原IP地址
12、动态规划(掌握基本的动态规划的几个题其实就够了,如:斐波那契数列、接雨水、股票的最佳买入时机)

mySQL数据库

数据存储引擎

存储引擎是MYSQL的核心技术,不同的存储引擎使用不同的存储机制、索引技巧、锁定水平并最终提供不同的功能和能力。常见的引擎分为三种:InnoDB存储引擎(MYSQL默认的事务性引擎)、MyISAM存储引擎、Memory存储引擎。
三种存储引擎的功能对比如下表所示:
在这里插入图片描述
总结三种引擎的使用选择如下:

  1. MyISAM :由于MyISAM不支持事务、不支持外键、支持全文检索和表级锁定,读写相互阻塞,读取速度快,节约资源,所以如果应用是以查询操作插入操作为主,只有很少的更新和删除操作,并且对事务的完整性、并发性要求不是很高,那么选择这个存储引擎是非常合适的。
  2. InnoDB : 是MySQL的默认存储引擎, 由于InnoDB支持事务、支持外键、行级锁定 ,支持所有辅助索引(5.5.5后不支持全文检索),高缓存,所以用于对事务的完整性有比较高的要求,在并发条件下要求数据的一致性读写频繁的操作,那么InnoDB存储引擎是比较合适的选择,比如BBS、计费系统、充值转账等
  3. MEMORY:将所有数据保存在RAM中,在需要快速定位记录和其他类似数据环境下,可以提供更快的访问。MEMORY的缺陷就是对表的大小有限制,太大的表无法缓存在内存中,其次是要确保表的数据可以恢复,数据库异常终止后表中的数据是可以恢复的。MEMORY表通常用于更新不太频繁的小表,用以快速得到访问结果。

数据库范式

1、第一范式:数据库表的每一列都是不可分割的基本数据项,即同一列不能有多个值;
2、第二范式:在第一范式的基础上,非主键列完全依赖于主键,而不能是依赖于主键的一部分。
如:设计订单信息表时,商品名称、商品价格等商品信息与表的主键不相关,而只与商品编号相关,因此可将表设计为订单信息表和商品信息表。
3、第三范式:在第二范式的基础上,非主键列只依赖于主键,不依赖于其他非主键。
如果一张表中出现另一张表的非主键,可以将这两张表用外键关联,而不是将另一张表的非主键直接写在当前表中。
设计数据库表的时候,要尽量遵守三范式,如果不遵守,必须有足够的理由。

索引

  • 索引优缺点

优点

索引大大减小了服务器需要扫描的数据量,从而大大加快数据的检索速度,这也是创建索引的最主要的原因。
索引可以帮助服务器避免排序和创建临时表
索引可以将随机IO变成顺序IO
索引对于InnoDB(对索引支持行级锁)非常重要,因为它可以让查询锁更少的元组,提高了表访问并发性
关于InnoDB、索引和锁:InnoDB在二级索引上使用共享锁(读锁),但访问主键索引需要排他锁(写锁)
通过创建唯一性索引,可以保证数据库表中每一行数据的唯一性。
可以加速表和表之间的连接,特别是在实现数据的参考完整性方面特别有意义。
在使用分组和排序子句进行数据检索时,同样可以显著减少查询中分组和排序的时间。
通过使用索引,可以在查询的过程中,使用优化隐藏器,提高系统的性能。

缺点

  • Mysql 数据库索引。B+ 树和 B 树的区别

MySQL数据库索引和存储引擎有关,MyISAM和InnoDB只支持BTREE索引。MEMORY和HEAP支持HASH和BTREE索引

B+树和B树的区别

  1. B+树非叶子节点只存储关键字和指向子节点的指针,而B树还存储了数据,在同样大小的情况下,B+树可以存储更多的关键字
  2. B+树叶子节点存储了所有关键字和数据,并且多个节点用链表连接。可以快速支撑范围查找
  3. B+树非叶子节点不存储数据,所以查询时间复杂度固定为O(logN),B树查询时间复杂度不固定,最好是O(1)
  • 为什么 B+ 树比 B 树更适合应用于数据库索引,除了数据库索引,还有什么地方用到了(操作系统的文件索引)

因为B树叶子节点和非叶子结点都存储了数据,这样就导致了非叶子结点能保存的关键字和指针变少,如果要保存大量数据,只能增加树的高度,导致IO操作变多,查询性能降低

除数据库索引,还有操作系统的文件索引用到了B树。参考文章:操作系统 文件索引结构

  • 聚簇索引和非聚簇索引

聚簇索引和非聚簇索引

  1. 聚簇索引,又叫主键索引,每个表只有一个主键索引,叶子节点保存主键的值和数据
  2. 非聚簇索引,又叫辅助索引,叶子节点保存索引字段的值和主键的值

1.前缀索引

对于列的值较长,比如BLOB、TEXT、VARCHAR,就必须建立前缀索引,即将值的前一部分作为索引。这样既可以节约空间,又可以提高查询效率。但无法使用前缀索引做 ORDER BYGROUP BY,也无法使用前缀索引做覆盖扫描。

2.覆盖索引

select的数据列从索引中就能获得,不必再从数据表中读取。如果一个索引包含了(或覆盖了)满足查询语句中字段与条件的数据就叫 做覆盖索引。

当发起一个被索引覆盖的查询(也叫作索引覆盖查询)时,在EXPLAINExtra列可以看到“Using index”的信息

  • 索引失效

索引失效情况

索引失效的情况

  • 最左前缀原则

假设有联合索引(a,b,c):
①where条件为(a,b,c)、(b,a,c)、(c,a,b)等,会走联合索引;
②where条件为(a)、(a,b)、(a,b,c),会走联合索引;
③where条件为(b)、(c)、(b,c),不会走索引,会全表扫描;
④where条件为(a,c)时,会走索引,但只使用a的索引。

最左前缀原则:查询从索引的最左前列开始并且不跳过索引中的列,通俗易懂的来说就是:带头大哥不能死、中间兄弟不能断

事务

  • 事务四大特性

事务是一个操作序列,这些操作要么全部执行,要么都不执行。

事务具有四大特性:A(原子性)、C(一致性)、I(隔离性)、D(持久性)

1、原子性(Atomicity): 事务开始后的所有操作要么全部完成,要么全部不完成,不能只完成一部分。事务执行过程中发生错误,会回滚已有操作并恢复到事务开始前的状态。
2、一致性(Consistency): 事务开始前和结束后,数据库的完整性没有被破坏。比如:A向B转账1000元,A的账户中会减少1000元,而B的账户中会增加1000元。一致性关注数据的可见性,中间状态的数据对外部不可见,只有最初状态和最终状态的数据对外可见。
3、隔离性(Isolation): 多个事务并发执行时,同一时间只允许一个事务请求同一数据,不同的事务之前不会互相干扰。如:A在从一张银行卡取款的过程中,其他人不能向这张银行卡转账。
4、持久性(Durability): 事务完成之后,事务对数据库的所有更改应该保存在数据库中,不能回滚。

  • MySQL事务如何实现

  1. 原子性:通过undo log实现的。每条数据变更都伴随一条undo log日志的生成,当系统发生错误或执行回滚根据undo log做逆向操作
  2. 持久性:通过redo log实现的。redo log记录了数据的修改日志。数据持久化到磁盘,先是储存到缓冲池里,然后缓冲池中的数据定期同步到磁盘中,如果系统宕机,可能会丢失数据,系统重启后会读取redo log恢复数据
  3. 隔离性:mysql数据库通过MVCC + next-key机制实现了隔离性
  4. 一致性:以上3大特性,保障了事务的一致性
  • 事务并发的三大问题

1、脏读: 一个事务读取到了另一个事务未提交的数据。

例子:事务A修改了数据但还未提交,事务B读取到了事务A修改的数据。然后事务A因为某些错误回滚了,这个时候事务B读取到的数据就是脏的,这就是脏读。

2、不可重复读:在同一事务内,事务两次读取到的数据是不一样的。(原数据中同一条数据被修改或被删除)

例子:事务A读取了一条数据之后,事务B修改了这条数据并提交了事务,然后事务A再次读取这条数据,就会发现两次结果不一致。这就是不可重复读。

3、幻读: 一个事务多次读取同一数据,另一事务在其读取过程中对该数据进行了插入或删除(insert操作)并提交,导致这个事务前后读取的数据结果不一致。

例子:事务A使用一定的条件查询,然后事务B增加了符合条件的记录,当事务A再次查询的时候,发现两次查询的结果集不一样,好像产生了幻觉。这就是幻读。

  • Mysql 有哪些隔离级别

事务隔离级别 脏读 不可重复读 幻读
读未提交(read-uncommitted)
读已提交(read-committed)
可重复读(repeatable-read)
串行化(serializable)

读未提交(Read Uncommitted):一个事务还没提交时,它做的变更就能被别的事务看到。解决更新丢失问题。如果一个事务已经开始写操作,那么其他事务则不允许同时进行写操作,但允许其他事务读此行数据。

读已提交(Read Committed):一个事务提交之后,它做的变更才会被其他事务看到。解决了脏读。读取数据的事务允许其他事务继续访问(访问指读和写)该行数据,但是未提交的写事务将会禁止其他事务访问该行。

可重复读取(Repeatable Read):可重复读是指,一个事务执行过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的。解决了不可重复读取和脏读取,但是有时可能出现幻读数据。读取数据的事务将会禁止写事务(但允许读事务),写事务则禁止任何其他事务。Mysql默认使用该隔离级别。

串行化(Serializable):对于同一行记录,“写”会加“写锁”,“读”会加“读锁”。当出现读写锁冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行。解决了幻读的提供严格的事务隔离。它要求事务序列化执行,事务只能一个接着一个地执行,不能并发执行。

  • Binlog 和 Redo log 的区别是什么,分别是什么用?

  1. binlog是二进制文件,记录了对数据库执行更改的所有操作,不包括 select、show,因为这两个操作没有对数据本身做修改。但是若操作了数据,但是数据没有发生变化,也会记录到binlog。常用来数据恢复,数据备份。
  2. redo log又叫做重做日志文件,记录了事务的修改,不管事务是否提交都记录下来。在实例和介质失败时,InnoDB存储引擎会使用redo log恢复到之前的状态,保证数据的完整性

https://blog.csdn.net/wanbin6470398/article/details/81941586

第一:redo log是在InnoDB存储引擎层产生,而binlog是MySQL数据库的上层产生的,并且二进制日志不仅仅针对INNODB存储引擎,MySQL数据库中的任何存储引擎对于数据库的更改都会产生二进制日志。

第二:两种日志记录的内容形式不同。MySQL的binlog是逻辑日志,其记录是对应的SQL语句。而innodb存储引擎层面的重做日志是物理日志。

第三:两种日志与记录写入磁盘的时间点不同,二进制日志只在事务提交完成后进行一次写入。而innodb存储引擎的重做日志在事务进行中不断地被写入,并日志不是随事务提交的顺序进行写入的。

第四:binlog不是循环使用,在写满或者重启之后,会生成新的binlog文件,redo log是循环使用。

第五:binlog可以作为恢复数据使用,主从复制搭建,redo log作为异常宕机或者介质故障后的数据恢复使用。

MVCC 多版本并发控制

MVCC是通过在每行记录后面保存两个隐藏的列来实现的。这两个列,一个保存了行的创建时间,一个保存行的过期时间(或删除时间)。当然存储的并不是实际的时间值,而是系统版本号(system version number)。每开始一个新的事务,系统版本号都会自动递增。事务开始时刻的系统版本号会作为事务的版本号,用来和查询到的每行记录的版本号进行比较。

SELECT

InnoDB会根据以下两个条件检查每行记录:

  1. InnoDB只查找版本早于当前事务版本的数据行(也就是,行的系统版本号小于或等于事务的系统版本号),这样可以确保事务读取的行,要么是在事务开始前已经存在的,要么是事务自身插入或者修改过的。
  2. 行的删除版本要么未定义,要么大于当前事务版本号。这可以确保事务读取到的行,在事务开始之前未被删除。

只有符合上述两个条件的记录,才能返回作为查询结果

INSERT

InnoDB为新插入的每一行保存当前系统版本号作为行版本号。

DELETE

InnoDB为删除的每一行保存当前系统版本号作为行删除标识。

UPDATE

InnoDB为插入一行新记录,保存当前系统版本号作为行版本号,同时保存当前系统版本号到原来的行作为行删除标识。

MySQL主从复制,读写分离

  • 为什么要主从复制

1、高可用性: 若主库发生故障,可快速切换到其中一个从库,从而保证系统业务的可用性。
2、负载均衡: 主库用于写数据,各个从库用于读数据,实现读写分离,将流量分布到各个库上,从而实现负载均衡。
3、可扩展性好: 当业务量很大的时候,为了抗住更多的读请求,可以增加从库,从而分担流量。

  • 主从复制的原理

MySQL主从复制是一个异步的复制过程,主库发送更新事件到从库,从库读取更新记录,并执行更新记录,使得从库的内容与主库保持一致。
在这里插入图片描述主从复制的流程为:
①当主库进行insert、update、delete操作时,会按顺序写入到binlog(二进制日志)中;
②从库启动I/O线程,跟主库建立客户端连接;
③主库启动binlog dump线程,读取主库上binlog的内容发送给从库的I/O线程;
④从库的I/O线程接收到 binlog 内容后,将内容写入到本地的 relay log(中继日志);
⑤从库启动SQL线程,读取relay log的内容,并完成对从库数据的更新。

上图为一个从库的流程,实际中,有N个从库,主库就会对应有N个binlog dump线程,而每个从库都会有自己的I/O线程和SQL线程。

Linux命令

df(全称:disk free)

df 命令以磁盘分区为单位查看文件系统中磁盘空间的使用情况

  • df -h

单纯使用df命令,其实不利于我们直接查看分区中空间使用情况,所以我们更常用df -h来进行查看,-h选项的意思是-human-readable:使用人类可读的格式,这也是比较常见的查看方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
fengyun@ubuntu:~/share/sign$ df -h
df: /run/user/1000/gvfs: 传输端点尚未连接
文件系统 容量 已用 可用 已用% 挂载点
udev 938M 0 938M 0% /dev
tmpfs 195M 1.7M 193M 1% /run
/dev/sda5 20G 12G 7.0G 62% /
tmpfs 971M 0 971M 0% /dev/shm
tmpfs 5.0M 4.0K 5.0M 1% /run/lock
tmpfs 971M 0 971M 0% /sys/fs/cgroup
/dev/loop0 56M 56M 0 100% /snap/core18/2284
/dev/loop1 219M 219M 0 100% /snap/gnome-3-34-1804/72
/dev/loop2 219M 219M 0 100% /snap/gnome-3-34-1804/77
/dev/loop3 249M 249M 0 100% /snap/gnome-3-38-2004/99
/dev/loop5 56M 56M 0 100% /snap/core18/2344
/dev/loop4 62M 62M 0 100% /snap/core20/1376
/dev/loop6 128K 128K 0 100% /snap/bare/5
/dev/loop8 44M 44M 0 100% /snap/snapd/15177
/dev/loop9 51M 51M 0 100% /snap/snap-store/547
/dev/loop10 63M 63M 0 100% /snap/gtk-common-themes/1506
/dev/loop11 44M 44M 0 100% /snap/snapd/14978
/dev/loop12 248M 248M 0 100% /snap/gnome-3-38-2004/87
/dev/loop13 55M 55M 0 100% /snap/snap-store/558
/dev/loop14 66M 66M 0 100% /snap/gtk-common-themes/1519
/dev/sda1 511M 4.0K 511M 1% /boot/efi
tmpfs 195M 36K 195M 1% /run/user/1000
vmhgfs-fuse 346G 297G 50G 86% /mnt/hgfs
/dev/loop15 62M 62M 0 100% /snap/core20/1405

  • df -i选项

有时候面试官大大也会问你,如何查看分区inode使用情况,这个也是使用Linux可能会遇到的问题,那如何查看呢?使用-i选项

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
fengyun@ubuntu:~/share/sign$ df -i
df: /run/user/1000/gvfs: 传输端点尚未连接
文件系统 Inodes 已用(I) 可用(I) 已用(I)% 挂载点
udev 240098 494 239604 1% /dev
tmpfs 248465 931 247534 1% /run
/dev/sda5 1277952 276937 1001015 22% /
tmpfs 248465 1 248464 1% /dev/shm
tmpfs 248465 5 248460 1% /run/lock
tmpfs 248465 19 248446 1% /sys/fs/cgroup
/dev/loop0 10847 10847 0 100% /snap/core18/2284
/dev/loop1 18500 18500 0 100% /snap/gnome-3-34-1804/72
/dev/loop2 18500 18500 0 100% /snap/gnome-3-34-1804/77
/dev/loop3 17495 17495 0 100% /snap/gnome-3-38-2004/99
/dev/loop5 10849 10849 0 100% /snap/core18/2344
/dev/loop4 11777 11777 0 100% /snap/core20/1376
/dev/loop6 29 29 0 100% /snap/bare/5
/dev/loop8 482 482 0 100% /snap/snapd/15177
/dev/loop9 15841 15841 0 100% /snap/snap-store/547
/dev/loop10 62342 62342 0 100% /snap/gtk-common-themes/1506
/dev/loop11 480 480 0 100% /snap/snapd/14978
/dev/loop12 17441 17441 0 100% /snap/gnome-3-38-2004/87
/dev/loop13 17311 17311 0 100% /snap/snap-store/558
/dev/loop14 65095 65095 0 100% /snap/gtk-common-themes/1519
/dev/sda1 0 0 0 - /boot/efi
tmpfs 248465 90 248375 1% /run/user/1000
vmhgfs-fuse 0 0 0 - /mnt/hgfs
/dev/loop15 11778 11778 0 100% /snap/core20/1405

du(全称:disk usage)

du命令也是检查硬盘使用情况,但是两者是有一定区别的。

  • du 命令是统计文件或目录及其子目录的硬盘空间使用情况,一般可以帮我们快速定位目录下是否存在超大文件或其他特殊大小的文件。
  • df 命令是统计磁盘分区整体的使用情况。
  • du 命令会直接到特定目录内查找所有文件数据,并统计累加,所以命令执行时会耗费一点儿时间。
  • df 命令直接从文件系统中提取信息,所以比较快速。
1
2
3
4
5
-a或--all             #列出所有的文件和目录容量大小而不仅仅列出目录容量大小
-s或--summarize #仅显示总计,只列出最后加总的值
-h或--human-readable #以K,M,G为单位,提高信息的可读性
-c或--total #除了列出文件和目录的容量大小外,最后在列出总容量
--max-depth=N #递归显示(仅仅是显示)时的递归深度小于等于N。--max-depth=0相当于-s参数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

#方便测试,给大家建立了如下目录结构
[whb@VM_0_12_centos test]$ tree .
.
|-- dir1
| |-- dir2
| | `-- file2.txt
| `-- file1.txt
|-- dirx
| `-- filex.txt
`-- file.txt
[whb@VM_0_12_centos test]$ du #默认统计各个目录+目录下文件大小(目录容量),但只以目录形式显示
480 ./dirx
400 ./dir1/dir2
660 ./dir1
1148 .
1
2
3
4
5
6
7
8
9
10

[whb@VM_0_12_centos test]$ du -a #列出所有的文件大小和目录容量而不仅仅列出目录容量,默认只统计目录容量
4 ./file.txt
476 ./dirx/filex.txt
480 ./dirx #这里为何是480?回看一下我们定义的概念,你就明白了
396 ./dir1/dir2/file2.txt
400 ./dir1/dir2
256 ./dir1/file1.txt
660 ./dir1
1148 .

cat /proc/cpuinfo

  • 查看CPU基本硬件信息
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
fengyun@ubuntu:~/share/sign$ cat /proc/cpuinfo
processor : 0
vendor_id : GenuineIntel
cpu family : 6
model : 158
model name : Intel(R) Core(TM) i5-9300H CPU @ 2.40GHz
stepping : 10
microcode : 0xde
cpu MHz : 2400.001
cache size : 8192 KB
physical id : 0
siblings : 1
core id : 0
cpu cores : 1
apicid : 0
initial apicid : 0
fpu : yes
fpu_exception : yes
cpuid level : 22
wp : yes
...........

top(实时进程状态)

Linux top命令用于实时显示 process 的动态。

使用权限:所有使用者。

1
top [-] [d delay] [q] [c] [S] [s] [i] [n] [b]

选项:

  • -d 秒数:指定 top 命令每隔几秒更新。默认是 3 秒;
  • -b:使用批处理模式输出。一般和”-n”选项合用,用于把 top 命令重定向到文件中;
  • -n 次数:指定 top 命令执行的次数。一般和”-“选项合用;
  • -p 进程PID:仅查看指定 ID 的进程;
  • -s:使 top 命令在安全模式中运行,避免在交互模式中出现错误;
  • -u 用户名:只监听某个用户的进程;

在 top 命令的显示窗口中,还可以使用如下按键,进行一下交互操作:

  • ? 或 h:显示交互模式的帮助;
  • P:按照 CPU 的使用率排序,默认就是此选项;
  • M:按照内存的使用率排序;
  • N:按照 PID 排序;
  • T:按照 CPU 的累积运算时间排序,也就是按照 TIME+ 项排序;
  • k:按照 PID 给予某个进程一个信号。一般用于中止某个进程,信号 9 是强制中止的信号;
  • r:按照 PID 给某个进程重设优先级(Nice)值;
  • q:退出 top 命令;

1.查看占用内存最多的前 N 个进程

  • 先执行 top 命令, 再使用快捷键 M 即可按内存占用降序排序

2.查看 CPU 占用最多的前 N 个进程

  • 先执行 top 命令, 再使用快捷键 P 即可按照 CPU 占用降序排序

3.查看某个进程中的线程情况

1
top -p [pid]

然后再使用快捷键 H 即可查看线程相关信息

cat /proc/meminfo

查看内存基本容量信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
fengyun@ubuntu:~/share/sign$ cat /proc/meminfo 
MemTotal: 1987724 kB
MemFree: 85284 kB
MemAvailable: 1002196 kB
Buffers: 215668 kB
Cached: 329224 kB
SwapCached: 27432 kB
Active: 406764 kB
Inactive: 449960 kB
Active(anon): 114820 kB
Inactive(anon): 198272 kB
Active(file): 291944 kB
Inactive(file): 251688 kB
Unevictable: 0 kB
Mlocked: 0 kB
SwapTotal: 945416 kB
SwapFree: 555956 kB
Dirty: 0 kB
Writeback: 0 kB
...

free

free 命令用来显示系统内存状态,包括系统物理内存、虚拟内存(swap 交换分区)、共享内存和系统缓存的使用情况,其输出和 top 命令的内存部分非常相似。

选项 含义
-b 以 Byte(字节)为单位,显示内存使用情况。
-k 以 KB 为单位,显示内存使用情况,此选项是 free 命令的默认选项。
-m 以 MB 为单位,显示内存使用情况。
-g 以 GB 为单位,显示内存使用情况。
-t 在输出的最终结果中,输出内存和 swap 分区的总量。
-o 不显示系统缓冲区这一列。
-s 间隔秒数 根据指定的间隔时间,持续显示内存使用情况。
1
2
3
4
fengyun@ubuntu:~/share/sign$ free
总计 已用 空闲 共享 缓冲/缓存 可用
内存: 1987724 795200 82544 2824 1109980 1003556
交换: 945416 389460 555956

uname -a

查看Linux 查看系统版本

1
2
fengyun@ubuntu:~/share/sign$ uname -a
Linux ubuntu 5.13.0-35-generic #40~20.04.1-Ubuntu SMP Mon Mar 7 09:18:32 UTC 2022 x86_64 x86_64 x86_64 GNU/Linux

netstat

使用 netstat 命令查看网络连接情况

参数解释:

  • -a 显示所有选项
  • -t (tcp)仅显示tcp相关选项
  • -u (udp)仅显示udp相关选项
  • -n 拒绝显示别名,能显示数字的全部转化成数字。
  • -p 显示建立相关链接的程序名

ifconfig

查看ip地址信息

由于一台主机可能同时具备多个网络接口, 查看到的 ip 地址也就可能有多个。

网络接口对应到主机的网卡或者虚拟网卡设备. 一台主机可以具有多个网卡, 也就可以有多个 IP 地址。

umask

Linux umask命令指定在建立文件时预设的权限掩码

umask可用来设定[权限掩码]。[权限掩码]是由3个八进制的数字所组成,将现有的存取权限减掉权限掩码后,即可产生建立文件时预设的权限

语法:

1
umask [-S][权限掩码]

参数说明

-S  以文字的方式来表示权限掩码。

1
2
3
4
5
6
7
fengyun@ubuntu:~$ umask
0002
fengyun@ubuntu:~$ umask -S
u=rwx,g=rwx,o=rx
fengyun@ubuntu:~$ mkdir test1
fengyun@ubuntu:~$ ls -d -l test1/
drwxrwxr-x 2 fengyun fengyun 4096 Apr 17 07:09 test1/

注意:在上面的输出信息中,”drwxrwxr-x”=”777-022=775”。

chmod

chmod 在使用时,可以针对指定用户进行文件访问权限的设置或者增减,也可以通过八进制数字的形式设置文件的访问权限。

1
2
3
4
5
6
#高频选项
-R 或 --recursive   #递归处理,将指定目录下的所有文件及子目录一并处理
<访问者>+<权限设置>   #开启权限范围的文件或目录的该项权限设置。
<访问者>-<权限设置>   #关闭权限范围的文件或目录的该项权限设置。
<访问者>=<权限设置>   #指定权限范围的文件或目录的该项权限设置。
+t #设置目录文件的沾滞位

chown

文件的所有者拥有对文件的权限进行修改的权力,但是若我们想要将这个权力交给另一个用户,也就是修改一个文件的所有者,那么就需要使用chown命令(ps:全称change owner)。

1
选项 -R         #对目录下的所有档案与子目录进行相同的拥有者变更(即以递回的方式逐个变更)

chgrp

文件所属组中成员,具备对一个文件对应所属组的访问权限,若要将这个权力移交到另一个用户组中则需要使用chgrp命令(ps:全称change group)。

1
-R或--recursive  #递归处理,将指定目录下的所有文件及子目录一并处理。

su

su 命令可让一个普通用户切换为超级用户或其他用户,并可临时拥有所切换用户的权限。

1
2
3
fengyun@ubuntu:~$ su - root
密码:
root@ubuntu:~#

sudo

sudo 是系统管理员允许让普通用户执行一些或者全部的root命令的一个工具(执行身份一般当然要短暂成为root喽)。

并且sudo不是对shell的一个代替,它是面向每个命令的,对于每个命令都可以使用sudo进行提权操作。使用sudo 提权操作不仅减少了root用户的登录和管理时间,同样也提高了安全性。

/etc/sudoers配置文件的修改:

任何用户都可以随时使用sudo指令来对自己的操作进行提权吗?

不是这样的,若是这样的话,则root管理员用户形同虚设,只有管理员将指定用户添加入/etc/sudoers中,这个用户才可以进行提权操作,因为它是系统管理员集中的管理用户的使用权限和使用的主机的配置文件。

grep

图片

我们的测试文件test.txt内容如下:

1
2
3
4
5
I love linux
i love LINUX
i Love Linux
I LOVE LINUX
i love Linu.*
  • -v 选项,反向匹配:
1
2
3
[root@localhost ~]# grep -v love test.txt
i Love Linux
I LOVE LINUX
  • -i 选项,忽略大小写:
1
2
3
4
5
[root@localhost ~]# grep -i love test.txt
I love linux
i love LINUX
i Love Linux
I LOVE LINUX
  • -n 选项,显示匹配行的行号:
1
2
3
[root@localhost ~]# grep -n love test.txt
1:I love linux
2:i love LINUX
  • -E 支持扩展正则,| 是扩展正则中的特殊含义的符号,代表任意一个匹配(不是管道哦):
1
2
3
4
[root@localhost ~]#  grep -E 'linux|LINUX' test.txt
I love linux
i love LINUX
I LOVE LINUX
  • -F 不要按正则来解析,就要字符串本身:
1
2
[root@localhost ~]#  grep -F "Linu.*" test.txt
i love Linu.*
  • -c 只显示匹配的行数:
1
2
[root@localhost ~]# cat /etc/passwd | grep -c "root" # 查找passwd文件中有多少行有root
2

find

高频选项:

  • -name filename : 文件名称符合 filename 的文件 , 大小写敏感
  • -iname filname : 文件名称符合 name 的文件,忽略大小写
  • -empty : 空文件
  • -size:指定文件大小

uniq

uniq 命令用于检查及删除文本文件中重复出现的行列;如果使用该命令不加任何命令行参数,则视为删除指定文本文件当中重复的行之后进行输出;如果指定输出文件,则输出到指定文件当中。

高频选项:

1
2
3
4
-c:在每列旁边显示该行重复出现的次数
-u:仅显示出现一次的行
[输入文件] 指定已排序好的文本文件。如果不指定此项,则从标准读取数据
[输出文件] 指定输出的文件。如果不指定此选项,则将内容显示到标准输出设备(显示终端)

sort

sort 命令可以针对文本文件的内容,按行进行排序。在排序的时候以指定分隔符对文本文件进行内容分列。对指定列进行升序或降序排列,并且在排序的同时可以指定是否忽略大小写。

高频选项:

1
2
3
4
5
6
7
8
9
10
11
12
sort [-bcdfimMnr][-o<输出文件>][-t<分隔字符>][+<起始栏位>-<结束栏位>][--help][--verison][文件]
-b:忽略每行前面开始的空格字符,空格数量不固定时,该选项几乎是必须要使用的
-f:将小写字母视为大写字母
-h:使用易读性数字(例如:2K、1G)
-k:以哪个区间 (field) 来进行排序
-n:依照数值的大小排序
-o<输出文件>:将排序后的结果存入指定的文件
-r:降序
-u:忽略相同行
-t<分隔字符>:指定分隔符,默认的分隔符为空白字符和非空白字符之间的空字符
--help 显示帮助。
--version 显示版本信息。

sed

读取一行,按照模式进行匹配,匹配上了通过对应的命令进行处理,然后继续下一行的处理

https://mp.weixin.qq.com/s/YAmS9Me1bAbkGJ3oHuGP_w

awk

AWK是一种样式扫描与处理的工具,其功能与 sed 和 grep 命令类似,却又远远强大于它们。

AWK不仅支持数据的扫描以及样式过滤,同时它还包含样式装入、流控制、数学运算符、进程控制语句甚至于内置变量和函数,几乎具备了一门完整编程语言的精美特性,因此,AWK 创始者们将其定义为样式、扫描、处理语言。

cat/less/more/head/tail

项目

以一个master进程,多个worker子进程作为整体进程框架

  • 1.可以充分利用多核机器,增强并发处理能力。多个进程可以占用不同的CPU核心来工作
  • 2.多 worker 间可以实现负载均衡。一个请求到来时更容易被分配到负载较轻的worker工作进程中处理。
  • 3.Master 监控并统一管理 worker 行为。在 worker 异常后,可以主动拉起 worker 进程,从而提升了系统的可靠性。并且由 Master 进程控制服务运行中的程序升级、配置项修改等操作,从而增强了整体的动态可扩展与热更的能力。

利用单例类实现内存分配,读取配置文件和crc32校验算法

我使用的是懒汉模式,但实际而言发挥的是饿汉模式,因为懒汉模式不保证线程安全,因此那些内存分配的类,读配置文件的类,crc32校验的类都是在master进程中执行的,执行完之后才fork生成worker子进程,这样我就保证了线程安全

借助epoll[LT模式]同时监听多个I/O实现高并发通讯技术

首先是4个worker进程都绑定ip和端口,返回不同的监听socket,然后将这个监听socket加入红黑树当中,连接池里面给他分配一个连接,如果三次握手进来了,epoll返回可写事件通知,我就通过epoll的event.data.ptr取出连接池的具体连接,调用监听socket连接的可读函数,这个可读函数会将调用accept()函数返回真正用于通信交流的socket,然后将这个真正用于通信交流的socket加入红黑树中并且分配一个连接池的连接设置好连接的读写处理函数,如果客户端有数据发过来,这个时候epoll会监听到,然后主线程会将数据收取到连接池中,并且会new一块新内存存储整个消息,这个消息除了包头包体以外还加上了消息头,消息头是存储了连接池连接和序号,然后将消息加入到收包的消息队列中,激发一个线程池中的线程来处理消息,线程池里会根据消息的操作码等信息调用相应的逻辑处理函数用来生成发送的包,线程池里面的线程处理完后生成一个发送消息放入发消息队列当中,

然后sem_post激发发消息线程来发包。然后发包线程取到那个消息,通过消息头获取连接池内具体连接,然后用一个atomic<int>iThrowsendCount来标记是否加入epoll监控。对于没加入epoll监控的可以让连接池的连接的发包指针指向消息队列的消息,但如果当前连接已经加入了epoll监控必须等待epoll监控的消息发完了再处理这个连接的包。对于取到的消息如果1.sendsize>0且成功发送了整个包的所有数据,一下就发送出去这很顺利,那么把堆里面的存放消息的那块内存释放掉即可2.sendsize>0但是没有全部发送完毕(EAGAIN),数据只发出去了一部分,但肯定是因为 发送缓冲区满了,EPOLL_MOD给当前连接增加一个监听可写事件

构建线程池处理业务逻辑,使用状态机解析客户端请求,线程间同步技术包括互斥量,信号量等

借助pthread_cont_wait()和

对于m_pthreadCond 而言pthread_cond_wait(&m_pthreadCond, &m_pthreadMutex); 刚开始时初始状态,没有什么东西来激发它,会卡在这里,而且m_pthreadMutex会被释放掉;
第一个线程执行到这一句的时候,m_pthreadMutex会被释放掉,第二个线程得以在while循环中往下执行。如果有100个线程,最终结果是100个线程都会卡在这里并且m_pthreadMutex会被释放掉。这100个线程都在等待m_pthreadCond这个条件。

pthread_cond_signal唤醒一个等待该条件的线程,也就是可以唤醒卡在pthread_cond_wait(&m_pthreadCond, &m_pthreadMutex)的线程

构建连接池并且实现了对客户端关闭连接的延迟回收

一个连接,如果我们程序判断这个连接不用了;那么 不 应该把这个连接立即放到空闲队列里,而是 应该放到一个地方;
等待一段时间【60】,60秒之后,我再真正的回收这个连接到 连接池/空闲队列 中去,这种连接才可以真正的分配给其他用户使用;为什么要等待60秒,就是需要确保即便用户张三真断线了,那么我执行的该用户的业务逻辑也一定能在这个等待时间内全部完成;这个连接不立即回收是非常重要的,有个时间缓冲非常重要;这个可以在极大程度上确保服务器的稳定。

处理了惊群问题和已知的安全问题如恶意用户持续连入,积压过多数据包等

该用户收消息太慢【或者干脆不收消息】,累积的该用户的发送队列中有的数据条目数过大,认为是恶意用户,直接切断

一些特点

消息头 :连接池的连接指针和序列号(用于判断包是否过时)
对于连接池的每个连接都是有一个序号iCurrsequence,连接初始化的时候++iCurrsequence,连接释放的时候++iCurrsequence,因此当收到一个包的时候,将连接连接池的序号iCurrsequence记录在包的消息头中,当处理完这个包后想要发回给客户端的时候可以比较一下包的iCurrsequence与连接池的iCurrsequence是否一致,如果不一致说明连接已经断开了,丢弃即可。

困难

  1. 无法从epoll连接取不出来了,epoll绑定event.data.ptr这个操作只在EPOLL_CTL_ADD的时候做一次即可,但是发现EPOLL_CTL_MOD似乎会破坏掉ev.data.ptr,因此不管是EPOLL_CTL_ADD,还是EPOLL_CTL_MOD,ev.data.ptr都要去重新绑定!!!

miniOS

汇编与C语言混合实现了可以人机交互的x86 32位操作系统

自定义了进程模型并且实现了进程各个状态间的切换

独立实现了动态内存分配,包括链表分配法和固定小碎片分配法

实现了进程互斥锁与事件机制,可以控制进程间的执行顺序

实现了键盘驱动程序和一些shell任务与用户进行交互

设计并实现了文件系统,可以对硬盘进行读写

自我介绍

面试官你好,很荣幸今天能够来参加xx面试,我叫冯云,来自华中科技大学,今年大三,我从大一开始成绩就一直保持在前15%这样一个不错的成绩,大二的时候就选择了C++这门语言作为深入学习的一门语言,并且把计算机网络操作系统等核心课程给修完了,大三的时候呢决定直接找工作不读研,并且进一步的学习了C++并且完成了一个服务器项目。

你的优点?

年轻,我是个00后,而且不读研,进去直接工作通常来说是比别人更年轻三岁,意味着我试错成本是更高的,我有更多的时间去发展我的技术

做事比较有条理,上大学时别的同学都喜欢借我的笔记,我的个人物品和学习资料都很有规律,我宿舍的时候,舍友很容易就能找到他们需要用的资料,包括电脑上的文件。我觉得有条理是一种习惯,只要坚持每个人都可以做到。然后可以看看我的博客,分门别类也是比较清楚的

面经

钉钉一面

使用过哪些种类的智能指针?有什么区别以及使用场合?用的时候的注意事项?

基类与子类的成员的内存分布情况?(代码段,成员变量,虚函数指针)

析构函数抛出异常?

一个指针数组,类型是基类的指针类型,实际存放的是子类的指针?如果执行delete []有什么问题?
(第一个对象释放完后,搜索的指针会往后移动,对第二个对象做析构以及内存回收,基类的内存大小可能更小,但是实际而言子类的内存更大,这个时候指针位置可能没有对准子类对象的头部)

右值引用和move(底层执行的是static_cast)

auto 直接声明一个变量会报错吗?(编译器都不知道类型,怎么给他分配内存?当然报错)

constexpr?(修饰变量和函数)

noexcept?如果已经声明了noexcept但是又抛出了异常会怎么样?(会报错)

const?mutable?

volatile?

万能引用?引用折叠?

默认初始化和值初始化?

值初始化:

(1)在数组初始化的过程中,如果提供的初始值数量少于数组的大小,剩下的元素会进行值初始化;

(2)静态static变量、***定义在块作用域外的全局变量,***如果没有显式的初始值,将执行值初始化;

(3)当我们通过书写形如T()的表达式(例如 int())显式地请求值初始化时;

默认初始化:

(1)当我们在块作用域内(类内也属于块作用域内)不使用任何初始值定义一个非静态变量时;

(2)当一个类本身含有类类型成员且使用合成的默认构造函数时;

(3)当类类型的成员没有在构造函数初始值列表中显式地初始化时;

同步异步,阻塞非阻塞(还需强化一下)

HTTPS加密(数字签名?)

刷题奇怪函数

tolower()

1
2
3
string s = "ABCDEFG";
for( int i = 0; i < s.size(); i++ )
s[i] = tolower(s[i]);

美团一面

  1. vector吗?vector底层是怎么实现的啊

  2. arraylist

  3. map和unordered_map

  4. 哈希表拉链法和线性探测法的区别与优缺点(空间局部性去考虑)

  5. map线程安全吗?

  6. 哈希做缓存如何提升效率并且保证线程安全?
    将所有public方法都加上synchronized: 相当于设置了一把全局锁,所有操作都需要先获取锁(比如直接使用Collections.synchronizedMap()方法对其进行加锁操作),java.util.HashTable就是这么做的,性能很低

    2.由于每个桶在逻辑上是相互独立的,将每个桶都加一把锁,如果两个线程各自访问不同的桶,就不需要争抢同一把锁了。这个方案的并发性比单个全局锁的性能要好,不过锁的个数太多,也有很大的开销。

    3.锁分离(Lock Stripping)技术:第2个方法把锁的压力分散到了多个桶,理论上是可行的的,但是假设有1万个桶,就要新建1万个ReentrantLock实例,开销很大。可以将所有的桶均匀的划分为16个部分,*每一部分成为一个段(Segment),每个段上有一把锁,这样锁的数量就降低到了16个,**JDK 7里的java.util.concurrent.ConcurrentHashMap就是这个思路*
    4. **在JDK8里,ConcurrentHashMap的实现又有了很大变化*,它在锁分离的基础上,大量利用了了CAS指令*。并且底层存储有一个小优化
    ,当链表长度太长(默认超过8)时,链表就转换为红黑树。链表太长时,增删查改的效率比较低,改为红黑树可以提高性能。
    ***实现的难度有点高,JDK8里的ConcurrentHashMap有6000多行代码,JDK7才1500多行。*
    **

  7. B+数底层实现,mysql索引为何使用B+树而不是红黑树

  8. 自旋锁和互斥锁

  9. TCP/UDP,视频的传输底层用什么协议比较好?TCP如何保证各个数据包的有序性?TCP的重发机制?

  10. 算法题:二叉树的最小深度

雷火一面

  1. 在 ip 地址为192.168.2.23/255.255.254.0 主机所在的子网里, 广播地址为________________

192.168.3.255

  1. 阅读下面程序,写出在32位系统运行后的结果:________________
1
2
3
4
5
6
7
char str[] = "glad to test something";
char *p = str;
p++;
int *p1 = static_cast<int *>(p);
p1++;
p = static_cast<char *>(p1);
printf("result is %s", p);

result is to test something

  1. 有两张mysql的表记录了员工的一些基本信息如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
mysql> select * from test1;
id name age
1 aaa 25
2 bbb 31
4 ddd 30
5 eee 28

mysql> select * from test2;
id gender
1 M
2 F
3 F
5 M

两张表的id都是PRIMARY KEY,现在希望查询出所有在test1表和test2表均有记录的员工的信息(即交集),并按照性别和年龄排序(所有女性排在男性前面,同性别再按年龄降序排)。

用一条sql语句实现

1

  1. 以下函数中,不能定义为虚函数的有____________ (多选题)

A. 构造函数 B. 析构函数 C. 静态成员函数 D. 内联函数 E. operator=

ACD

  1. C 语言中下面的表达式中值为 0 的是________________

A. 3/5
B. 5>>3
C. !3
D. ~3

ABC

C++11的原子操作

雷火二面

  1. 菱形继承的内存分布和问题
  2. map和unordered_map
  3. fork出一个子进程是共用socket吗
  4. 自旋锁的底层实现?CAS
  5. 内核态和用户态

腾讯音乐二面

1.函数调用栈