C++秋招冲刺训练营笔记

顶层const和底层const

变量自身不能改变的是顶层const,比如const int,int *const的常量指针,变量所指的对象或者所引用的对象是不能改变的,而变量自身是可以改变的是底层const,比如const int *的指向常量对象的非常量指针。

左值和右值

左值是有具体存储地址的值,表现为=左边的值,右值是没有具体存储地址,比如寄存器中的值,表现为=右边的值。名字的左值:该名字代表的存储单元的地址;名字的右值:该名字代表的存贮单元的内容。

智能指针
// 初始化方式1
std::unique_ptr<int> up1(new int(1));
std::unique_ptr<int[]> up2(new int[3]);
// 初始化方式2
std::unique_ptr<int> up3;
up3.reset(new int(1));
std::unique_ptr<int[]> up4;
up4.reset(new int[3]);
// 初始化方式3,推荐
std::unique_ptr<int> up5 = std::make_unique<int>(1);
std::unique_ptr<int[]> up6(std::make_unique<int[]>(3));
/* 没有尝试过std::unique_ptr<int> up(std::make_unique<int>(1));
 * 和std::unique_ptr<int[]> up = std::make_unique<int[]>(3);是否正确

这样获得的up内就包含了指向创建的内存的指针,可以用up.get()来获取该指针,和直接使用up是等价的。

shared_ptr和weak_ptr见cubox收藏,auto_ptr在C++11已经弃用。

模板

函数模板:

// 定义
template <typename T>
inline T const& Max (T const& a, T const& b) {
    return a < b ? b : a;
}
// 使用
int i = 1, j = 2;
cout << Max(i, j);

类模板:

// 定义
template <class T>
class Stack {
  private:
    vector<T> elems;
  public:
    void push(T const&);
    void pop();
    T top() const;
    bool empty() const{
      return elems.empty();
    }
};

template <class T>
void Stack<T>::push (T const& elem) {
    elems.push_back(elem);
}

template <class T>
void Stack<T>::pop () {
    if (elems.empty()) {
        throw out_of_range("Stack<>::pop(): empty stack");
    }
    elems.pop_back();
}

template <class T>
T Stack<T>::top () const
{
    if (elems.empty()) {
        throw out_of_range("Stack<>::top(): empty stack");
    }
    return elems.back();
}
// 使用
Stack<int> intStack;
Stack<string> stringStack;
generate函数
std::generate(v.begin(), v.end(), std::rand); //使用随机数填充vector向量v
count_if函数
int num = count_if(v.begin(), v.end(), f); //f是自定义的函数,返回类型为布尔类型,count_if函数统计vector向量v中符合f条件的元素个数
lambda表达式
[capture] (params) opt -> ret {};

其中carpture是捕获列表,params是参数,opt是选项,ret则是返回值的类型,body则是函数的具体实现。

  1. 捕获列表描述了lambda表达式可以访问上下文中的哪些变量:
    []:表示不捕获任何变量。
    [=]:表示按值捕获变量,也就是说在lambda函数内使用lambda之外的变量时,使用的是拷贝。
    [&]:表示按引用捕获变量,也就是说在lambda函数内使用lambda之外的变量时,使用的是引用。
    [this]:值传递捕获当前的this。

  2. params表示lambda的参数,用在{}中。

  3. opt表示lambda的选项,例如mutable。

  4. ret表示lambda的返回类型,也可以显示指明返回类型,lambda会自动推断返回类型,但是值得注意的是只有当lambda的表达式仅有一条return语句时,自动推断才是有效的。

静态变量

全局(静态)存储区:分为 DATA 段和 BSS 段。DATA 段(全局初始化区)存放初始化的全局变量和静态变量;BSS 段(全局未初始化区)存放未初始化的全局变量和静态变量。程序运行结束时自动释放。其中BBS段在程序执行之前会被系统自动清0,所以未初始化的全局变量和静态变量在程序执行之前已经为0。存储在静态数据区的变量会在程序刚开始运行时就完成初始化,也是唯一的一次初始化。

一般程序把新产生的动态数据存放在堆区,函数内部的自动变量存放在栈区。自动变量一般会随着函数的退出而释放空间,静态数据(即使是函数内部的静态局部变量)也存放在全局数据区。全局数据区的数据并不会因为函数的退出而释放空间。

**对于C语言的全局和静态变量,初始化发生在任何代码执行之前,属于编译期初始化。
而C++标准规定:**全局或静态对象当且仅当对象首次用到时才进行构造。

回调

自己的函数调用了别人的函数,其中别人的函数又调用了自己的函数,就是回调;回调是函数指针的应用场景。

比如自己调用sort函数,使用自己定义的cmp比较函数,这就是回调,因为sort调用了自己的cmp比较函数,并且是通过函数指针的形式调用的(sort在实现时寻找了cmp函数的入口地址)。

nullptr调用成员函数可以吗?为什么?

能,因为在编译时对象就绑定了函数地址,和指针空不空没关系。

说说什么是野指针,怎么产生的,如何避免?
  1. **概念:**野指针就是指针指向的位置是不可知的(随机的、不正确的、没有明确限制的)

  2. **产生原因:**释放内存后指针不及时置空(野指针),依然指向了该内存,那么可能出现非法访问的错误。这些我们都要注意避免。

  3. 避免办法:

    (1)初始化置NULL

    (2)申请内存后判空

    (3)指针释放后置NULL

    (4)使用智能指针

说说内联函数和宏函数的区别?
  1. 宏定义不是函数,但是使用起来像函数。预处理器用复制宏代码的方式代替函数的调用,省去了函数压栈退栈过程,提高了效率;而内联函数本质上是一个函数,内联函数一般用于函数体的代码比较简单的函数,不能包含复杂的控制语句,while、switch,并且内联函数本身不能直接调用自身。

  2. 宏函数是在预编译的时候把所有的宏名用宏体来替换,简单的说就是字符串替换 ;而内联函数则是在编译的时候进行代码插入,编译器会在每处调用内联函数的地方直接把内联函数的内容展开,这样可以省去函数的调用的开销,提高效率。

  3. 宏定义是没有类型检查的,无论对还是错都是直接替换;而内联函数在编译的时候会进行类型的检查,内联函数满足函数的性质,比如有返回值、参数列表等。

★说说new和malloc的区别,各自底层实现原理
  1. new是操作符,而malloc是函数

  2. new在调用的时候先分配内存,在调用构造函数,释放的时候调用析构函数;而malloc没有构造函数和析构函数。

  3. malloc需要给定申请内存的大小,返回的指针需要强转;new会调用构造函数,不用指定内存的大小,返回指针不用强转。

  4. new可以被重载;malloc不行。

  5. new分配内存更直接和安全。

  6. new发生错误抛出异常,malloc返回null

答案解析

malloc底层实现:当开辟的空间小于 128K 时,调用 brk()函数;当开辟的空间大于 128K 时,调用mmap()。malloc采用的是内存池的管理方式,以减少内存碎片。先申请大块内存作为堆区,然后将堆区分为多个内存块。当用户申请内存时,直接从堆区分配一块合适的空闲快。采用隐式链表将所有空闲块,每一个空闲块记录了一个未分配的、连续的内存地址。

new底层实现:关键字new在调用构造函数的时候实际上进行了如下的几个步骤:

  1. 创建一个新的对象

  2. 将构造函数的作用域赋值给这个新的对象(因此this指向了这个新的对象)

  3. 执行构造函数中的代码(为这个新对象添加属性)

  4. 返回新对象

★程序启动的过程
  1. 操作系统首先创建相应的进程分配私有的进程空间,然后操作系统的加载器负责把可执行文件的数据段和代码段映射到进程的虚拟内存空间中。

  2. 加载器读入可执行程序的导入符号表,根据这些符号表可以查找出该可执行程序的所有依赖的动态链接库。

  3. 加载器针对该程序的每一个动态链接库调用LoadLibrary

    (1)查找对应的动态库文件,加载器为该动态链接库确定一个合适的基地址

    (2)加载器读取该动态链接库的导入符号表和导出符号表,比较应用程序要求的导入符号是否匹配该库的导出符号

    (3)针对该库的导入符号表,查找对应的依赖的动态链接库,如有跳转,则跳到3

    (4)调用该动态链接库的初始化函数

  4. 初始化应用程序的全局变量,对于全局对象自动调用构造函数

请简述一下atomoic内存顺序(网上搜不到)

有六个内存顺序选项可应用于对原子类型的操作:

  1. memory_order_relaxed:在原子类型上的操作以自由序列执行,没有任何同步关系,仅对此操作要求原子性。

  2. memory_order_consume:memory_order_consume只会对其标识的对象保证该对象存储先行于那些需要加载该对象的操作。

  3. memory_order_acquire:使用memory_order_acquire的原子操作,当前线程的读写操作都不能重排到此操作之前。

  4. memory_order_release:使用memory_order_release的原子操作,当前线程的读写操作都不能重排到此操作之后。

  5. memory_order_acq_rel:memory_order_acq_rel在此内存顺序的读-改-写操作既是获得加载又是释放操作。没有操作能够从此操作之后被重排到此操作之前,也没有操作能够从此操作之前被重排到此操作之后。

  6. memory_order_seq_cst:memory_order_seq_cst比std::memory_order_acq_rel更为严格。memory_order_seq_cst不仅是一个"获取释放"内存顺序,它还会对所有拥有此标签的内存操作建立一个单独全序。

除非你为特定的操作指定一个顺序选项,否则内存顺序选项对于所有原子类型默认都是memory_order_seq_cst。

构造函数分类

默认构造函数、初始化构造函数、拷贝构造函数、移动构造函数

    //默认构造函数
    Student()
    {
       num=1001;
       age=18;       
    }
    //初始化构造函数
    Student(int n,int a):num(n),age(a){}
    //拷贝构造函数
    Test(const Test& t)
    {
        this->i = t.i;
        this->p = new int(*t.p);
    }
    //移动构造函数:用于将其他类型的变量,隐式转换为本类对象
    Student(int r)
   {
       int num=1004;
       int age= r;
    }
说说一个类,默认会生成哪些函数
  1. 无参的构造函数

  2. 拷贝构造函数

  3. 赋值运算符

    Empty& operator = (const Empty& copy)
    {
    }
    
  4. 析构函数(非虚)

★说说 C++ 类对象的初始化顺序,有多重继承情况下的顺序

参考答案

  1. 创建派生类的对象,基类的构造函数优先被调用(也优先于派生类里的成员类);

  2. 如果类里面有成员类,成员类的构造函数优先被调用(也优先于该类本身的构造函数);

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

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

  5. 派生类构造函数,作为一般规则派生类构造函数应该不能直接向一个基类数据成员赋值而是把值传递给适当的基类构造函数,否则两个类的实现变成紧耦合的(tightly coupled)将更加难于正确地修改或扩展基类的实现(基类设计者的责任是提供一组适当的基类构造函数)。

  6. 综上可以得出,初始化顺序:

    父类构造函数–>成员类对象构造函数–>自身构造函数

    其中成员变量的初始化与声明顺序有关构造函数的调用顺序是类派生列表中的顺序

    析构顺序和构造顺序相反。

简述下向上转型和向下转型
  1. 子类转换为父类:向上转型,使用dynamic_cast(expression),这种转换相对来说比较安全不会有数据的丢失;

  2. 父类转换为子类:向下转型,可以使用强制转换,这种转换时不安全的,会导致数据的丢失,原因是父类的指针或者引用的内存中可能不包含子类的成员的内存。

★模板的实例化和具体化
// #1 模板定义
template<class T>
struct TemplateStruct
{
    TemplateStruct()
    {
        cout << sizeof(T) << endl;
    }
};

// #2 模板显示实例化
template struct TemplateStruct<int>;

// #3 模板具体化
template<> struct TemplateStruct<double>
{
    TemplateStruct() {
        cout << "--8--" << endl;
    }
};

int main()
{
    TemplateStruct<int> intStruct;
    TemplateStruct<double> doubleStruct;

    // #4 模板隐式实例化
    TemplateStruct<char> llStruct;
}
运行结果:

4
--8--
1

★简述一下移动构造函数,什么库用到了这个函数?

C++11中新增了移动构造函数。与拷贝类似,移动也使用一个对象的值设置另一个对象的值。但是,又与拷贝不同的是,移动实现的是对象值真实的转移(源对象到目的对象):**源对象将丢失其内容,其内容将被目的对象占有。**移动操作的发生的时候,是当移动值的对象是未命名的对象的时候。这里未命名的对象就是那些临时变量,甚至都不会有名称。典型的未命名对象就是函数的返回值或者类型转换的对象。使用临时对象的值初始化另一个对象值,不会要求对对象的复制:因为临时对象不会有其它使用,因而,它的值可以被移动到目的对象。做到这些,就要使用移动构造函数和移动赋值:当使用一个临时变量对对象进行构造初始化的时候,调用移动构造函数。类似的,使用未命名的变量的值赋给一个对象时,调用移动赋值操作。

移动操作的概念对对象管理它们使用的存储空间很有用的,诸如对象使用new和delete分配内存的时候。在这类对象中,拷贝和移动是不同的操作:从A拷贝到B意味着,B分配了新内存,A的整个内容被拷贝到为B分配的新内存上。
而从A移动到B意味着分配给A的内存转移给了B,没有分配新的内存,它仅仅包含简单地拷贝指针。
看下面的例子:

// 移动构造函数和赋值
#include <iostream>
#include <string>
using namespace std;

class Example6 {
    string* ptr;
public:
    Example6 (const string& str) : ptr(new string(str)) {}
    ~Example6 () {delete ptr;}
    // 移动构造函数,参数x不能是const Pointer&& x,
    // 因为要改变x的成员数据的值;
    // C++98不支持,C++0x(C++11)支持
    Example6 (Example6&& x) : ptr(x.ptr) 
    {
        x.ptr = nullptr;
    }
    // move assignment
    Example6& operator= (Example6&& x) 
    {
        delete ptr; 
        ptr = x.ptr;
        x.ptr=nullptr;
        return *this;
    }
    // access content:
    const string& content() const {return *ptr;}
    // addition:
    Example6 operator+(const Example6& rhs) 
    {
        return Example6(content()+rhs.content());
    }
};
int main () {
    Example6 foo("Exam");           // 构造函数
    // Example6 bar = Example6("ple"); // 拷贝构造函数
    Example6 bar(move(foo));     // 移动构造函数
                                // 调用move之后,foo变为一个右值引用变量,
                                // 此时,foo所指向的字符串已经被"掏空",
                                // 所以此时不能再调用foo
    bar = bar+bar;             // 移动赋值,在这儿"="号右边的加法操作,
                                // 产生一个临时值,即一个右值
                                 // 所以此时调用移动赋值语句
    cout << "foo's content: " << foo.content() << '\n';
    return 0;
}

执行结果:

foo's content: Example
虚函数

C++中的虚函数的作用主要是实现了多态的机制。关于多态,简而言之就是用父类型的指针指向其子类的实例,然后通过父类的指针调用实际子类的成员函数。这种技术可以让父类的指针有“多种形态”,这是一种泛型技术。如果调用非虚函数,则无论实际对象是什么类型,都执行基类类型所定义的函数。非虚函数总是在编译时根据调用该函数的对象,引用或指针的类型而确定。如果调用虚函数,则直到运行时才能确定调用哪个函数,运行的虚函数是引用所绑定或指针所指向的对象所属类型定义的版本。虚函数必须是基类的非静态成员函数。虚函数的作用是实现动态联编,也就是在程序的运行阶段动态地选择合适的成员函数,在定义了虚函数后,可以在基类的派生类中对虚函数重新定义,在派生类中重新定义的函数应与虚函数具有相同的形参个数和形参类型。以实现统一的接口,不同定义过程。如果在派生类中没有对虚函数重新定义,则它继承其基类的虚函数。

class Person{
    public:
        //虚函数
        virtual void GetName(){
            cout<<"PersonName:xiaosi"<<endl;
        };
};
class Student:public Person{
    public:
        void GetName(){
            cout<<"StudentName:xiaosi"<<endl;
        };
};
int main(){
    //指针
    Person *person = new Student();
    //基类调用子类的函数
    person->GetName();//StudentName:xiaosi
}

虚函数(Virtual Function)是通过一张虚函数表(Virtual Table)来实现的。简称为V-Table。在这个表中,主是要一个类的虚函数的地址表,这张表解决了继承、覆盖的问题,保证其容真实反应实际的函数。这样,在有虚函数的类的实例中这个表被分配在了这个实例的内存中,所以,当我们用父类的指针来操作一个子类的时候,这张虚函数表就显得由为重要了,它就像一个地图一样,指明了实际所应该调用的函数。

纯虚函数是在基类中声明的虚函数,它在基类中没有定义,但要求任何派生类都要定义自己的实现方法。在基类中实现纯虚函数的方法是在函数原型后加“=0” virtualvoid GetName() =0。在很多情况下,基类本身生成对象是不合情理的。例如,动物作为一个基类可以派生出老虎、孔雀等子类,但动物本身生成对象明显不合常理。为了解决上述问题,将函数定义为纯虚函数,则编译器要求在派生类中必须予以重写以实现多态性。同时含有纯虚拟函数的类称为抽象类,它不能生成对象。这样就很好地解决了上述两个问题。将函数定义为纯虚函数能够说明,该函数为后代类型提供了可以覆盖的接口,但是这个类中的函数绝不会调用。声明了纯虚函数的类是一个抽象类。所以,用户不能创建类的实例,只能创建它的派生类的实例。必须在继承类中重新声明函数(不要后面的=0)否则该派生类也不能实例化,而且它们在抽象类中往往没有定义。定义纯虚函数的目的在于,使派生类仅仅只是继承函数的接口。纯虚函数的意义,让所有的类对象(主要是派生类对象)都可以执行纯虚函数的动作,但类无法为纯虚函数提供一个合理的缺省实现。所以类纯虚函数的声明就是在告诉子类的设计者,“你必须提供一个纯虚函数的实现,但我不知道你会怎样实现它”。

//抽象类
class Person{
    public:
        //纯虚函数
        virtual void GetName()=0;
};
class Student:public Person{
    public:
        Student(){
        };
        void GetName(){
            cout<<"StudentName:xiaosi"<<endl;
        };
};
int main(){
    Student student;
}
★构造函数不能是虚函数,析构函数可以

假如某个类的构造函数是虚函数,那么想要生成该类对象,就必须调用构造函数去构造该对象,但是由于构造函数是虚函数,想要调用该构造函数必须先去类的虚表中找到该子类的虚函数的入口地址,但是想要找到虚表,必须先有对象,因为对象的开始四个字节存放了指向虚表的指针,而不调用构造函数,又无法生成对象,所以矛盾了。

★关于虚表的一些思考

每个拥有虚函数的类都有一个虚表,父类有,子类也有,而每个这些类生成的每个对象的开始四个字节存放了指向本类虚表的指针,并且一个类的所有对象共享本类的虚表,只需要通过开始的四个字节去找本类的续表即可,虚表中存放了本类虚函数的地址;比如基类虚表存放了函数f的地址为a,而子类使用虚函数重载了f,子类虚表中函数f的地址为b,覆盖了父类的地址,那么Base *ptr = new Child(); 这个ptr指针类型是父类,但是它真正指向内存是一个子类,所以当ptr→f时,发现f是虚函数,首先从内存的前四个字节中的指针去找虚表,由于真正占有内存的是子类,所以指针指向子类的续表,子类虚表中,f函数的地址是b,所以调用的是b。

虚继承

虚继承是解决C++多重继承问题的一种手段,从不同途径继承来的同一基类,会在子类中存在多份拷贝。这将存在两个问题:其一,浪费存储空间;第二,存在二义性问题,通常可以将派生类对象的地址赋值给基类对象,实现的具体方式是,将基类指针指向继承类(继承类有基类的拷贝)中的基类对象的地址,但是多重继承可能存在一个基类的多份拷贝,这就出现了二义性。虚继承可以解决多种继承前面提到的两个问题。

#include<iostream>
using namespace std;
class A{
public:
    int _a;
};
class B :virtual public A
{
public:
    int _b;
};
class C :virtual public A
{
public:
    int _c;
};
class D :public B, public C
{
public:
    int _d;
};
//菱形继承和菱形虚继承的对象模型
int main()
{
    D d;
    d.B::_a = 1;
    d.C::_a = 2;
    d._b = 3;
    d._c = 4;
    d._d = 5;
    cout << sizeof(D) << endl;
    return 0;
}

分别从菱形继承和虚继承来分析:

菱形继承中A在B,C,D,中各有一份,虚继承中,A共享。

上面的虚继承表实际上是一个指针数组。B、C实际上是虚基表指针,指向虚基表。

虚基表:存放相对偏移量,用来找虚基类。

请问构造函数中的能不能调用虚方法

不要在构造函数中调用虚方法,从语法上讲,调用完全没有问题,但是从效果上看,往往不能达到需要的目的。派生类对象构造期间进入基类的构造函数时,对象类型变成了基类类型,而不是派生类类型。同样,进入基类析构函数时,对象也是基类类型。所以,虚函数始终仅仅调用基类的虚函数(如果是基类调用虚函数),不能达到多态的效果,所以放在构造函数中是没有意义的,而且往往不能达到本来想要的效果。

★请问拷贝构造函数的参数是什么传递方式,为什么
  1. 拷贝构造函数的参数必须使用引用传递。

  2. 如果拷贝构造函数中的参数不是一个引用,即形如CClass(const CClass c_class),那么就相当于采用了传值的方式(pass-by-value),而传值的方式会调用该类的拷贝构造函数,从而造成无穷递归地调用拷贝构造函数。因此拷贝构造函数的参数必须是一个引用。

    需要澄清的是,传指针其实也是传值,如果上面的拷贝构造函数写成CClass(const CClass* c_class),也是不行的。事实上,只有传引用不是传值外,其他所有的传递方式都是传值。

简述一下虚析构函数,什么作用
  1. 虚析构函数,是将基类的析构函数声明为virtual,举例如下:

    class TimeKeeper
    {
    public:    
      TimeKeeper() {}        
      virtual ~TimeKeeper() {}    
    };
    
  2. 虚析构函数的主要作用是防止内存泄露。

    定义一个基类的指针p,在delete p时,如果基类的析构函数是虚函数,这时只会看p所赋值的对象,如果p赋值的对象是派生类的对象,就会调用派生类的析构函数(毫无疑问,在这之前也会先调用基类的构造函数,在调用派生类的构造函数,然后调用派生类的析构函数,基类的析构函数,所谓先构造的后释放);如果p赋值的对象是基类的对象,就会调用基类的析构函数,这样就不会造成内存泄露。

    如果基类的析构函数不是虚函数,在delete p时,调用析构函数时,只会看指针的数据类型,而不会去看赋值的对象,这样就会造成内存泄露。

★仿函数
  1. 仿函数(functor)又称为函数对象(function object)是一个能行使函数功能的类。仿函数的语法几乎和我们普通的函数调用一样,不过作为仿函数的类,都必须重载operator()运算符,举个例子:

     class Func{
         public:
             void operator() (const string& str) const {
                 cout<<str<<endl;
             }
     };
     Func myFunc;
     myFunc("helloworld!");
    >>>helloworld!
    
  2. 仿函数既能想普通函数一样传入给定数量的参数,还能存储或者处理更多我们需要的有用信息。我们可以举个例子:

    假设有一个vector<string>,你的任务是统计长度小于5的string的个数,如果使用count_if函数的话,你的代码可能长成这样:

      bool LengthIsLessThanFive(const string& str) {
           return str.length()<5;    
      }
      int res=count_if(vec.begin(), vec.end(), LengthIsLessThanFive);
    

    其中count_if函数的第三个参数是一个函数指针,返回一个bool类型的值。一般的,如果需要将特定的阈值长度也传入的话,我们可能将函数写成这样:

      bool LenthIsLessThan(const string& str, int len) {
          return str.length()<len;
      }
    

    这个函数看起来比前面一个版本更具有一般性,但是他不能满足count_if函数的参数要求:count_if要求的是unary function(仅带有一个参数)作为它的最后一个参数。如果我们使用仿函数,是不是就豁然开朗了呢:

      class ShorterThan {
          public:
              explicit ShorterThan(int maxLength) : length(maxLength) {}
              bool operator() (const string& str) const {
                  return str.length() < length;
              }
          private:
              const int length;
      };
    
★explicit关键字作用

explicit关键字可以关闭类构造函数的隐式转换:

class Demo {
public:
  Demo();
  Demo(int a); // Demo demo = 1; 合法,等价于Demo demo(1);
  Demo(int a, int b); // Demo demo = 1; 不合法,无默认值参数数量大于1
  Demo(int a, int b = 2, int c = 3); // Demo demo = 1; 合法,等价于Demo demo(1, 2, 3);
}

class Demo {
public:
  Demo();
  explicit Demo(int a); // Demo demo = 1; 不合法,explicit关闭隐式转换
  Demo(int a, int b);
  explicit Demo(int a, int b = 2, int c = 3); // Demo demo = 1; 不合法,explicit关闭隐式转换
  

哪些函数不能被声明为虚函数

常见的不不能声明为虚函数的有:普通函数(非成员函数),静态成员函数,内联成员函数,构造函数,友元函数。

  1. 为什么C++不支持普通函数为虚函数?

    普通函数(非成员函数)只能被overload,不能被override,声明为虚函数也没有什么意思,因此编译器会在编译时绑定函数。

  2. 为什么C++不支持构造函数为虚函数?

    这个原因很简单,主要是从语义上考虑,所以不支持。因为构造函数本来就是为了明确初始化对象成员才产生的,然而virtual function主要是为了再不完全了解细节的情况下也能正确处理对象。另外,virtual函数是在不同类型的对象产生不同的动作,现在对象还没有产生,如何使用virtual函数来完成你想完成的动作。

    构造函数用来创建一个新的对象,而虚函数的运行是建立在对象的基础上,在构造函数执行时,对象尚未形成,所以不能将构造函数定义为虚函数。

  3. 为什么C++不支持内联成员函数为虚函数?

    其实很简单,那内联函数就是为了在代码中直接展开,减少函数调用花费的代价,虚函数是为了在继承后对象能够准确的执行自己的动作,这是不可能统一的。(再说了,inline函数在编译时被展开,虚函数在运行时才能动态的绑定函数

    内联函数是在编译时期展开,而虚函数的特性是运行时才动态联编,所以两者矛盾,不能定义内联函数为虚函数。

  4. 为什么C++不支持静态成员函数为虚函数?

    这也很简单,静态成员函数对于每个类来说只有一份代码,所有的对象都共享这一份代码,他也没有要动态绑定的必要性。

    静态成员函数属于一个类而非某一对象,没有this指针,它无法进行对象的判别。

  5. 为什么C++不支持友元函数为虚函数?

    因为C++不支持友元函数的继承,对于没有继承特性的函数没有虚函数的说法。

**思考:**当使用类的指针调用成员函数时,普通函数由指针类型决定,而虚函数由指针指向的实际类型决定;

虚函数实现的过程是:通过对象内存中的虚函数指针vptr找到虚函数表vtbl,再通过vtbl中的函数指针找到对应虚函数的实现区域并进行调用。每个子类对象中只含有一个虚函数指针vptr指向基类的虚函数表。

★虚函数表里存放的内容是什么时候写进去的?
  1. 虚函数表是一个存储虚函数地址的数组,以NULL结尾。虚表(vftable)在编译阶段生成,对象内存空间开辟以后,写入对象中的 vfptr,然后调用构造函数。即:虚表在构造函数之前写入。

  2. 除了在构造函数之前写入之外,我们还需要考虑到虚表的二次写入机制,通过此机制让每个对象的虚表指针都能准确的指向到自己类的虚表,为实现动多态提供支持。

类模板和模板类
  1. 类模板是模板的定义,不是一个实实在在的类,定义中用到通用类型参数。

  2. 模板类是实实在在的类定义,是类模板的实例化。类定义中参数被实际类型所代替。

★标准模板库STL组成部分
  1. 容器(Container)

    是一种数据结构, 如list, vector, 和deques,以模板类的方法提供。为了访问容器中的数据,可以使用由容器类输出的迭代器。

  2. 算法(Algorithm)

    是用来操作容器中的数据的模板函数。例如,STL用sort()来对一 个vector中的数据进行排序,用find()来搜索一个list中的对象, 函数本身与他们操作的数据的结构和类型无关,因此他们可以用于从简单数组到高度复杂容器的任何数据结构上。

  3. 迭代器(Iterator)

    提供了访问容器中对象的方法。例如,可以使用一对迭代器指定list或vector中的一定范围的对象。 迭代器就如同一个指针。事实上,C++ 的指针也是一种迭代器。 但是,迭代器也可以是那些定义了operator*()以及其他类似于指针的操作符方法的类对象。

  4. 仿函数(Function object)

    仿函数又称之为函数对象, 其实就是重载了操作符的struct,没有什么特别的地方。

  5. 适配器(Adaptor)

    简单的说就是一种接口类,专门用来修改现有类的接口,提供一中新的接口;或调用现有的函数来实现所需要的功能。主要包括3中适配器Container Adaptor、Iterator Adaptor、Function Adaptor。

  6. 空间配制器(Allocator)

    为STL提供空间配置的系统。其中主要工作包括两部分:

    (1)对象的创建与销毁;

    (2)内存的获取与释放。

STL容器分类及原理
  1. 顺序容器

    容器并非排序的,元素的插入位置同元素的值无关。包含vector、deque、list,具体实现原理如下:

    (1)vector 头文件

    动态数组。元素在内存连续存放。随机存取任何元素都能在常数时间完成。在尾端增删元素具有较佳的性能。

    (2)deque 头文件

    双向队列。元素在内存连续存放。随机存取任何元素都能在常数时间完成(仅次于vector)。在两端增删元素具有较佳的性能(大部分情况下是常数时间)。

    (3)list 头文件

    双向链表。元素在内存不连续存放。在任何位置增删元素都能在常数时间完成。不支持随机存取。

  2. 关联式容器

    元素是排序的;插入任何元素,都按相应的排序规则来确定其位置;在查找时具有非常好的性能;通常以平衡二叉树的方式实现。包含set、multiset、map、multimap,具体实现原理如下:

    (1)set/multiset 头文件

    set 即集合。set中不允许相同元素,multiset中允许存在相同元素。

    (2)map/multimap 头文件

    map与set的不同在于map中存放的元素有且仅有两个成员变,一个名为first,另一个名为second, map根据first值对元素从小到大排序,并可快速地根据first来检索元素。

    **注意:**map同multimap的不同在于是否允许相同first值的元素。

  3. 容器适配器

    封装了一些基本的容器,使之具备了新的函数功能,比如把deque封装一下变为一个具有stack功能的数据结构。这新得到的数据结构就叫适配器。包含stack,queue,priority_queue,具体实现原理如下:

    (1)stack 头文件

    栈是项的有限序列,并满足序列中被删除、检索和修改的项只能是最进插入序列的项(栈顶的项)。后进先出。

    (2)queue 头文件

    队列。插入只可以在尾部进行,删除、检索和修改只允许从头部进行。先进先出。

    (3)priority_queue 头文件

    优先级队列。内部维持某种有序,然后确保优先级最高的元素总是位于头部。最高优先级元素总是第一个出列。

说一下STL中迭代器的作用,有指针为何还要迭代器?
  1. 迭代器的作用

    (1)用于指向顺序容器和关联容器中的元素

    (2)通过迭代器可以读取它指向的元素

    (3)通过非const迭代器还可以修改其指向的元素

  2. 迭代器和指针的区别

    **迭代器不是指针,是类模板,表现的像指针。**他只是模拟了指针的一些功能,重载了指针的一些操作符,–>、++、–等。迭代器封装了指针,是一个”可遍历STL( Standard Template Library)容器内全部或部分元素”的对象,本质是封装了原生指针,是指针概念的一种提升,提供了比指针更高级的行为,相当于一种智能指针,他可以根据不同类型的数据结构来实现不同的++,–等操作。

    迭代器返回的是对象引用而不是对象的值,所以cout只能输出迭代器使用取值后的值而不能直接输出其自身。

  3. 迭代器产生的原因

    Iterator类的访问方式就是把不同集合类的访问逻辑抽象出来,使得不用暴露集合内部的结构而达到循环遍历集合的效果。

容器容器上的迭代器类别
vector随机访问
deque随机访问
list双向
set/multiset双向
map/multimap双向
stack不支持迭代器
queue不支持迭代器
priority_queue不支持迭代器
说说 STL 中 resize 和 reserve 的区别
  1. 首先必须弄清楚两个概念:

    (1)capacity:该值在容器初始化时赋值,指的是容器能够容纳的最大的元素的个数。还不能通过下标等访问,因为此时容器中还没有创建任何对象。

    (2)size:指的是此时容器中实际的元素个数。可以通过下标访问0-(size-1)范围内的对象。

  2. resize和reserve区别主要有以下几点:

    (1)resize既分配了空间,也创建了对象;reserve表示容器预留空间,但并不是真正的创建对象,需要通过insert()或push_back()等创建对象。

    (2)resize既修改capacity大小,也修改size大小;reserve只修改capacity大小,不修改size大小。

    (3)两者的形参个数不一样。 resize带两个参数,一个表示容器大小,一个表示初始值(默认为0);reserve只带一个参数,表示容器预留的大小。

答案解析

问题延伸:

resize 和 reserve 既有差别,也有共同点。两个接口的共同点是**它们都保证了vector的空间大小(capacity)最少达到它的参数所指定的大小。**下面就他们的细节进行分析。

为实现resize的语义,resize接口做了两个保证:

(1)保证区间[0, new_size)范围内数据有效,如果下标index在此区间内,vector[indext]是合法的;

(2)保证区间[0, new_size)范围以外数据无效,如果下标index在区间外,vector[indext]是非法的。

reserve只是保证vector的空间大小(capacity)最少达到它的参数所指定的大小n。在区间[0, n)范围内,如果下标是index,vector[index]这种访问有可能是合法的,也有可能是非法的,视具体情况而定。

说说 STL 容器动态链接可能产生的问题?
  1. 可能产生 的问题

    容器是一种动态分配内存空间的一个变量集合类型变量。在一般的程序函数里,局部容器,参数传递容器,参数传递容器的引用,参数传递容器指针都是可以正常运行的,而在动态链接库函数内部使用容器也是没有问题的,但是给动态库函数传递容器的对象本身,则会出现内存堆栈破坏的问题。

  2. 产生问题的原因

    容器和动态链接库相互支持不够好,动态链接库函数中使用容器时,参数中只能传递容器的引用,并且要保证容器的大小不能超出初始大小,否则导致容器自动重新分配,就会出现内存堆栈破坏问题。

vector 的实现原理

vector底层实现原理为一维数组(元素在空间连续存放)。

  1. 新增元素

    Vector通过一个连续的数组存放元素,如果集合已满,在新增数据的时候,就要分配一块更大的内存,将原来的数据复制过来,释放之前的内存,再插入新增的元素。插入新的数据分在最后插入push_back和通过迭代器在任何位置插入,这里说一下通过迭代器插入,通过迭代器与第一个元素的距离知道要插入的位置,即int index=iter-begin()。这个元素后面的所有元素都向后移动一个位置,在空出来的位置上存入新增的元素。

  2. 删除元素

    删除和新增差不多,也分两种,删除最后一个元素pop_back和通过迭代器删除任意一个元素erase(iter)。通过迭代器删除还是先找到要删除元素的位置,即int index=iter-begin();这个位置后面的每个元素都想前移动一个元素的位置。同时我们知道erase不释放内存只初始化成默认值。

    删除全部元素clear:只是循环调用了erase,所以删除全部元素的时候,不释放内存。内存是在析构函数中释放的。

C++11新特性

C++新特性主要包括包含语法改进和标准库扩充两个方面,主要包括以下11点:

  1. 语法的改进

    (1)统一的初始化方法

    (2)成员变量默认初始化

    (3)auto关键字 用于定义变量,编译器可以自动判断的类型(前提:定义一个变量时对其进行初始化)

    (4)decltype 求表达式的类型

    (5)智能指针 shared_ptr

    (6)空指针 nullptr(原来NULL)

    (7)基于范围的for循环

    (8)右值引用和move语义 让程序员有意识减少进行深拷贝操作

  2. 标准库扩充(往STL里新加进一些模板类,比较好用)

    (9)无序容器(哈希表) 用法和功能同map一模一样,区别在于哈希表的效率更高

    (10)正则表达式 可以认为正则表达式实质上是一个字符串,该字符串描述了一种特定模式的字符串

    (11)Lambda表达式

(1)统一的初始化方法

C++98/03 可以使用初始化列表(initializer list)进行初始化,但是这种初始化方式的适用性非常狭窄,只有数组和结构体可以使用初始化列表。在 C++11 中,初始化列表的适用性被大大增加了,它现在可以用于任何类型对象的初始化。

(2)成员变量默认初始化

(3)auto关键字

(4)decltype求表达式的类型

(5)智能指针

​和 unique_ptr、weak_ptr 不同之处在于,多个 shared_ptr 智能指针可以共同使用同一块堆内存。并且,由于该类型智能指针在实现上采用的是引用计数机制,即便有一个 shared_ptr 指针放弃了堆内存的“使用权”(引用计数减 1),也不会影响其他指向同一堆内存的 shared_ptr 指针(只有引用计数为 0 时,堆内存才会被自动释放)。

#include <iostream>
#include <memory>
using namespace std;
int main()
{
    //构建 2 个智能指针
    std::shared_ptr<int> p1(new int(10));
    std::shared_ptr<int> p2(p1);
    //输出 p2 指向的数据
    cout << *p2 << endl;
    p1.reset();//引用计数减 1,p1为空指针
    if (p1) {
        cout << "p1 不为空" << endl;
    }
    else {
        cout << "p1 为空" << endl;
    }
    //以上操作,并不会影响 p2
    cout << *p2 << endl;
    //判断当前和 p2 同指向的智能指针有多少个
    cout << p2.use_count() << endl;
    return 0;
}

/*    程序运行结果:        
            10
            p1 为空
            10
            1    
*/         

(6)空指针nullptr

nullptr 是 nullptr_t 类型的右值常量,专用于初始化空类型指针。nullptr_t 是 C++11 新增加的数据类型,可称为“指针空值类型”。也就是说,nullpter 仅是该类型的一个实例对象(已经定义好,可以直接使用),如果需要我们完全定义出多个同 nullptr 完全一样的实例对象。值得一提的是,nullptr 可以被隐式转换成任意的指针类型。例如:

int * a1 = nullptr;
char * a2 = nullptr;
double * a3 = nullptr;

显然,不同类型的指针变量都可以使用 nullptr 来初始化,编译器分别将 nullptr 隐式转换成 int、char 以及 double* 指针类型。另外,通过将指针初始化为 nullptr,可以很好地解决 NULL 遗留的问题,比如:

#include <iostream>
using namespace std;
void isnull(void *c){
    cout << "void*c" << endl;
}
void isnull(int n){
    cout << "int n" << endl;
}
int main() {
    isnull(NULL);
    isnull(nullptr);
    return 0;
}

/*    程序运行结果:        
        int n
        void*c
*/   

(7)基于范围的for循环

(8)右值引用和move语义

C++11 标准新引入了另一种引用方式,称为右值引用,用 “&&” 表示。

需要注意的,和声明常量左值引用一样,右值引用也必须立即进行初始化操作,且只能使用右值进行初始化,比如:

int num = 10;
//int && a = num;  //右值引用不能初始化为左值
int && a = 10;

和常量左值引用不同的是,右值引用还可以对右值进行修改。例如:

int && a = 10;
a = 100;
cout << a << endl;
/*    程序运行结果:        
        100    
*/

另外值得一提的是,C++ 语法上是支持定义常量右值引用的,例如:

const int&& a = 10;//编译器不会报错

但这种定义出来的右值引用并无实际用处。一方面,右值引用主要用于移动语义和完美转发,其中前者需要有修改右值的权限;其次,常量右值引用的作用就是引用一个不可修改的右值,这项工作完全可以交给常量左值引用完成。

move 本意为 “移动”,但该函数并不能移动任何数据,它的功能很简单,就是将某个左值强制转化为右值。基于 move() 函数特殊的功能,其常用于实现移动语义。move() 函数的用法也很简单,其语法格式如下:

move( arg ) //其中,arg 表示指定的左值对象。该函数会返回 arg 对象的右值形式。
//程序实例
#include <iostream>
using namespace std;
class first {
public:
    first() :num(new int(0)) {
        cout << "construct!" << endl;
    }
    //移动构造函数
    first(first &&d) :num(d.num) {
        d.num = NULL;
        cout << "first move construct!" << endl;
    }
public:    //这里应该是 private,使用 public 是为了更方便说明问题
    int *num;
};
class second {
public:
    second() :fir() {}
    //用 first 类的移动构造函数初始化 fir
    second(second && sec) :fir(move(sec.fir)) {
        cout << "second move construct" << endl;
    }
public:    //这里也应该是 private,使用 public 是为了更方便说明问题
    first fir;
};
int main() {
    second oth;
    second oth2 = move(oth);
    //cout << *oth.fir.num << endl;   //程序报运行时错误
    return 0;
}

/*    程序运行结果:
          construct!
        first move construct!
        second move construct
*/            

(9)无序容器(哈希表)

无序容器功能
unordered_map存储键值对 <key, value> 类型的元素,其中各个键值对键的值不允许重复,且该容器中存储的键值对是无序的。
unordered_multimap和 unordered_map 唯一的区别在于,该容器允许存储多个键相同的键值对。
unordered_set不再以键值对的形式存储数据,而是直接存储数据元素本身(当然也可以理解为,该容器存储的全部都是键 key 和值 value 相等的键值对,正因为它们相等,因此只存储 value 即可)。另外,该容器存储的元素不能重复,且容器内部存储的元素也是无序的。
unordered_multiset和 unordered_set 唯一的区别在于,该容器允许存储值相同的元素。

(10)正则表达式

符号意义
^匹配行的开头
$匹配行的结尾
.匹配任意单个字符
[…]匹配[]中的任意一个字符
(…)设定分组
\转义字符
\d匹配数字[0-9]
\D\d 取反
\w匹配字母[a-z],数字,下划线
\W\w 取反
\s匹配空格
\S\s 取反
+前面的元素重复1次或多次
*前面的元素重复任意次
?前面的元素重复0次或1次
{n}前面的元素重复n次
{n,}前面的元素重复至少n次
{n,m}前面的元素重复至少n次,至多m次
|逻辑或

(11)Lambda匿名函数

int num[4] = {4, 3, 2, 1};
sort(num, num + 4, [=](int x, int y) -> bool {return x < y;});
weak_ptr 能不能知道对象计数为 0,为什么?

weak_ptr是一种不控制对象生命周期的智能指针,它指向一个shared_ptr管理的对象。进行该对象管理的是那个引用的shared_ptr。weak_ptr只是提供了对管理 对象的一个访问手段。weak_ptr设计的目的只是为了配合shared_ptr而引入的一种智能指针,配合shared_ptr工作,它只可以从一个shared_ptr或者另一个weak_ptr对象构造,它的构造和析构不会引起计数的增加或减少

weak_ptr 如何解决 shared_ptr 的循环引用问题?

为了解决循环引用导致的内存泄漏,引入了弱指针weak_ptr,weak_ptr的构造函数不会修改引用计数的值,从而不会对对象的内存进行管理,其类似一个普通指针,但是不会指向引用计数的共享内存,但是可以检测到所管理的对象是否已经被释放,从而避免非法访问。

shared_ptr线程安全性

多线程环境下,调用不同shared_ptr实例的成员函数是不需要额外的同步手段的,即使这些shared_ptr拥有的是同样的对象。但是如果多线程访问(有写操作)同一个shared_ptr,则需要同步,否则就会有race condition 发生。也可以使用 shared_ptr overloads of atomic functions来防止race condition的发生。

多个线程同时读同一个shared_ptr对象是线程安全的,但是如果是多个线程对同一个shared_ptr对象进行读和写,则需要加锁。

多线程读写shared_ptr所指向的同一个对象,不管是相同的shared_ptr对象,还是不同的shared_ptr对象,也需要加锁保护。

智能指针有没有内存泄露的情况

智能指针发生内存泄露的情况

当两个对象同时使用一个shared_ptr成员变量指向对方,会造成循环引用,使引用计数失效,从而导致内存泄露。

智能指针的内存泄漏如何解决?

为了解决循环引用导致的内存泄漏,引入了弱指针weak_ptr,weak_ptr的构造函数不会修改引用计数的值,从而不会对对象的内存进行管理,其类似一个普通指针,但是不会指向引用计数的共享内存,但是可以检测到所管理的对象是否已经被释放,从而避免非法访问。

★C++11 中四种类型转换

C++中四种类型转换分别为const_cast、static_cast、dynamic_cast、reinterpret_cast,四种转换功能分别如下:

  1. const_cast

    将const变量转为非const

  2. static_cast

    最常用,可以用于各种隐式转换,比如非const转const,static_cast可以用于类向上转换,但向下转换能成功但是不安全。

  3. dynamic_cast

    只能用于含有虚函数的类转换,用于类向上和向下转换,只能转换指针引用类型

    向上转换:指子类向基类转换。

    向下转换:指基类向子类转换。

    这两种转换,子类包含父类,当父类转换成子类时可能出现非法内存访问的问题。

    dynamic_cast通过判断变量运行时类型和要转换的类型是否相同来判断是否能够进行向下转换。类型可以是指针,引用,void*。dynamic_cast可以做类之间上下转换,向上转换时无条件的,向下转换的时候会进行类型检查,类型相等成功转换,类型不等转换失败。运用RTTI技术,RTTI是”Runtime Type Information”的缩写,意思是运行时类型信息,它提供了运行时确定对象类型的方法。在c++层面主要体现在dynamic_cast和typeid,vs中虚函数表的-1位置存放了指向type_info的指针,对于存在虚函数的类型,dynamic_cast和typeid都会去查询type_info。

  4. reinterpret_cast

    reinterpret_cast可以做任何类型的转换,不过不对转换结果保证,容易出问题。

auto和const的结合使用

(1) auto 与 const 结合的用法

a. 当类型不为引用时,auto 的推导结果将不保留表达式的 const 属性;

b. 当类型为引用时,auto 的推导结果将保留表达式的 const 属性。

(2)程序实例如下

int  x = 0;
const  auto n = x;  //n 为 const int ,auto 被推导为 int
auto f = n;      //f 为 const int,auto 被推导为 int(const 属性被抛弃)
const auto &r1 = x;  //r1 为 const int& 类型,auto 被推导为 int
auto &r2 = r1;  //r1 为 const int& 类型,auto 被推导为 const int 类型
C++11可变参数模板

还是看C++Primer吧

★Linux中查看进程运行状态的指令、查看内存使用情况的指令、tar解压文件的参数。

  1. 查看进程运行状态的指令:ps命令。“ps -aux | grep PID”,用来查看某PID进程状态

  2. 查看内存使用情况的指令:free命令。“free -m”,命令查看内存使用情况。

  3. tar解压文件的参数

    五个命令中必选一个

    -c: 建立压缩档案

    -x:解压

    -t:查看内容

    -r:向压缩归档文件末尾追加文件

    -u:更新原压缩包中的文件

    这几个参数是可选的

    -z:有gzip属性的

    -j:有bz2属性的

    -Z:有compress属性的

    -v:显示所有过程

    -O:将文件解开到标准输出

★文件权限怎么修改

Linux文件的基本权限就有九个,分别是owner/group/others三种身份各有自己的read/write/execute权限

修改权限指令:chmod

答案解析

举例:文件的权限字符为 -rwxrwxrwx 时,这九个权限是三个三个一组。其中,我们可以使用数字来代表各个权限。

各权限的分数对照如下:

rwx
421

每种身份(owner/group/others)各自的三个权限(r/w/x)分数是需要累加的,

例如当权限为: [-rwxrwx—] ,则分数是:

owner = rwx = 4+2+1 = 7

group = rwx = 4+2+1 = 7

others= — = 0+0+0 = 0

所以我们设定权限的变更时,该文件的权限数字就是770!变更权限的指令chmod的语法是这样的:

[root@www ~]# chmod [-R] xyz 文件或目录 
选项与参数: 
xyz : 就是刚刚提到的数字类型的权限属性,为 rwx 属性数值的相加。 
-R : 进行递归(recursive)的持续变更,亦即连同次目录下的所有文件都会变更

# chmod 770 douya.c //即修改douya.c文件的权限为770

★说说常用的Linux命令

  1. cd命令:用于切换当前目录

  2. ls命令:查看当前文件与目录

  3. grep命令:该命令常用于分析一行的信息,若当中有我们所需要的信息,就将该行显示出来,该命令通常与管道命令一起使用,用于对一些命令的输出进行筛选加工。

  4. cp命令:复制命令

  5. mv命令:移动文件或文件夹命令

  6. rm命令:删除文件或文件夹命令

  7. ps命令:查看进程情况

  8. kill命令:向进程发送终止信号

  9. tar命令:对文件进行打包,调用gzip或bzip对文件进行压缩或解压

  10. cat命令:查看文件内容,与less、more功能相似

  11. top命令:可以查看操作系统的信息,如进程、CPU占用率、内存信息等

  12. pwd命令:命令用于显示工作目录。

★说说软链接和硬链接的区别。

  1. 定义不同

    软链接又叫符号链接,这个文件包含了另一个文件的路径名。可以是任意文件或目录,可以链接不同文件系统的文件。

    硬链接就是一个文件的一个或多个文件名。把文件名和计算机文件系统使用的节点号链接起来。因此我们可以用多个文件名与同一个文件进行链接,这些文件名可以在同一目录或不同目录。

  2. 限制不同

    硬链接只能对已存在的文件进行创建,不能交叉文件系统进行硬链接的创建;

    软链接可对不存在的文件或目录创建软链接;可交叉文件系统

  3. 创建方式不同

    硬链接不能对目录进行创建,只可对文件创建;

    软链接可对文件或目录创建;

  4. 影响不同

    删除一个硬链接文件并不影响其他有相同 inode 号的文件。

    删除软链接并不影响被指向的文件,但若被指向的原文件被删除,则相关软连接被称为死链接(即 dangling link,若被指向路径文件被重新创建,死链接可恢复为正常的软链接)。

★简述GDB常见的调试命令,什么是条件断点,多进程下如何调试。

GDB调试:gdb调试的是可执行文件,在gcc编译时加入 -g ,告诉gcc在编译时加入调试信息,这样gdb才能调试这个被编译的文件 gcc -g tesst.c -o test

GDB命令格式:

  1. quit:退出gdb,结束调试

  2. **list:**查看程序源代码

    list 5,10:显示5到10行的代码

    list test.c:5, 10: 显示源文件5到10行的代码,在调试多个文件时使用

    list get_sum: 显示get_sum函数周围的代码

    list test,c get_sum: 显示源文件get_sum函数周围的代码,在调试多个文件时使用

  3. reverse-search:字符串用来从当前行向前查找第一个匹配的字符串

  4. **run:**程序开始执行

  5. help list/all:查看帮助信息

  6. **break:**设置断点

    break 7:在第七行设置断点

    break get_sum:以函数名设置断点

    break 行号或者函数名 if 条件:以条件表达式设置断点

  7. watch 条件表达式:条件表达式发生改变时程序就会停下来

  8. **next:**继续执行下一条语句 ,会把函数当作一条语句执行

  9. **step:**继续执行下一条语句,会跟踪进入函数,一次一条的执行函数内的代码

**条件断点:**break if 条件 以条件表达式设置断点

**多进程下如何调试:**用set follow-fork-mode child 调试子进程

或者set follow-fork-mode parent 调试父进程

说说什么是大端小端,如何判断大端小端?

小端模式的有效字节存储在低的存储器地址。小端一般为主机字节序;常用的X86结构是小端模式。很多的ARM,DSP都为小端模式。

大端模式的有效字节存储在低的存储器地址。大端为网络字节序;KEIL C51则为大端模式。

有些ARM处理器还可以由硬件来选择是大端模式还是小端模式。

如何判断:我们可以根据联合体来判断系统是大端还是小端。因为联合体变量总是从低地址存储。

int fun1(){  
    union test{   
        char c;   
        int i; 
    };  
    test t; t.i = 1;  
    //如果是大端,则t.c为0x00,则t.c != 1,反之是小端  
    return (t.c == 1);  
}  

★一个线程占多大内存?

参考回答

一个linux的线程大概占8M内存。

答案解析

linux的栈是通过缺页来分配内存的,不是所有栈地址空间都分配了内存。因此,8M是最大消耗,实际的内存消耗只会略大于实际需要的内存(内部损耗,每个在4k以内)。

虚拟内存

  1. 为什么要用虚拟内存:因为早期的内存分配方法存在以下问题:

    (1)进程地址空间不隔离。会导致数据被随意修改。

    (2)内存使用效率低。

    (3)程序运行的地址不确定。操作系统随机为进程分配内存空间,所以程序运行的地址是不确定的。

  2. 使用虚拟内存的好处

    (1)扩大地址空间。每个进程独占一个4G空间,虽然真实物理内存没那么多。

    (2)内存保护:防止不同进程对物理内存的争夺和践踏,可以对特定内存地址提供写保护,防止恶意篡改。

    (3)可以实现内存共享方便进程通信

    (4)可以避免内存碎片,虽然物理内存可能不连续,但映射到虚拟内存上可以连续。

  3. 使用虚拟内存的缺点

    (1)虚拟内存需要额外构建数据结构,占用空间。

    (2)虚拟地址到物理地址的转换,增加了执行时间。

    (3)页面换入换出耗时。

    (4)一页如果只有一部分数据,浪费内存。

虚拟地址到物理地址怎么映射的?

操作系统为每一个进程维护了一个从虚拟地址到物理地址的映射关系的数据结构,叫页表。页表中的每一项都记录了这个页的基地址。

三级页表转换方法:(两步)

逻辑地址转线性地址:段起始地址+段内偏移地址=线性地址

线性地址转物理地址:

(1)每一个32位的线性地址被划分为三部分:页目录索引(DIRECTORY,10位)、页表索引(TABLE,10位)、页内偏移(OFFSET,12位)

(2)从cr3中取出进程的页目录地址(操作系统调用进程时,这个地址被装入寄存器中)

页目录地址 + 页目录索引 = 页表地址

页表地址 + 页表索引 = 页地址

页地址 + 页内偏移 = 物理地址

堆栈溢出

堆栈溢出就是不顾堆栈中分配的局部数据块大小,向该数据块写入了过多的数据,导致数据越界。常指调用堆栈溢出,本质上一种数据结构的满溢情况。堆栈溢出可以理解为两个方面:堆溢出和栈溢出。

  1. 堆溢出:比如不断的new 一个对象,一直创建新的对象,而不进行释放,最终导致内存不足。将会报错:OutOfMemory Error。

  2. 栈溢出:一次函数调用中,栈中将被依次压入:参数,返回地址等,而方法如果递归比较深或进去死循环,就会导致栈溢出。将会报错:StackOverflow Error。

malloc实现原理

malloc底层实现:当开辟的空间小于 128K 时,调用 brk()函数;当开辟的空间大于 128K 时,调用mmap()。malloc采用的是内存池的管理方式,以减少内存碎片。先申请大块内存作为堆区,然后将堆区分为多个内存块。当用户申请内存时,直接从堆区分配一块合适的空闲快。采用隐式链表将所有空闲块,每一个空闲块记录了一个未分配的、连续的内存地址。

★说说进程、线程、协程是什么,区别是什么?

参考回答

  1. 进程:程序是指令、数据及其组织形式的描述,而进程则是程序的运行实例,包括程序计数器、寄存器和变量的当前值。

  2. 线程:微进程,一个进程里更小粒度的执行单元。一个进程里包含多个线程并发执行任务。

  3. 协程:协程是微线程,在子程序内部执行,可在子程序内部中断,转而执行别的子程序,在适当的时候再返回来接着执行。

区别

  1. 线程与进程的区别

    (1)一个线程从属于一个进程;一个进程可以包含多个线程。

    (2)一个线程挂掉,对应的进程挂掉;一个进程挂掉,不会影响其他进程。

    (3)进程是系统资源调度的最小单位;线程CPU调度的最小单位。

    (4)进程系统开销显著大于线程开销;线程需要的系统资源更少。

    (5)进程在执行时拥有独立的内存单元,多个线程共享进程的内存,如代码段、数据段、扩展段;但每个线程拥有自己的栈段和寄存器组。

    (6)进程切换时需要刷新TLB并获取新的地址空间,然后切换硬件上下文和内核栈,线程切换时只需要切换硬件上下文和内核栈。

    (7)通信方式不一样。

    (8)进程适应于多核、多机分布;线程适用于多核

  2. 线程与协程的区别:

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

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

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

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

参考回答

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

  2. 如何实现

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

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

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

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

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

答案解析

实现代码如下:

#include <stdio.h>  
#include <stdlib.h>  
#include <string.h>  
#include <fcntl.h>  
#include <unistd.h>  
#include <sys/wait.h>  
#include <sys/types.h>  
#include <sys/stat.h>  

#define MAXFILE 65535  

int main(){  
    //第一步:创建进程   
    int pid = fork();  
    if (pid > 0)  
        exit(0);//结束父进程   
    else if (pid < 0){  
        printf("fork error!\n");  
        exit(1);//fork失败,退出   
    }  
    //第二步:子进程成为新的会话组长和进程组长,并与控制终端分离   
       setsid();  
    //第三步:改变工作目录到  
    chdir("/");  
    //第四步:重设文件创建掩模   
    umask(0);  
    //第五步:关闭打开的文件描述符  
    for (int i=0; i<MAXFILE; ++i)   
        close(i); 
        sleep(2);  
    }  
    return 0;  
}  

进程通信

进程间通信主要包括管道系统IPC(包括消息队列、信号量、信号、共享内存)、套接字socket

  1. 管道:包括无名管道和命名管道,无名管道半双工,只能用于具有亲缘关系的进程直接的通信(父子进程或者兄弟进程),可以看作一种特殊的文件;命名管道可以允许无亲缘关系进程间的通信。

  2. 系统IPC

    消息队列:消息的链接表,放在内核中。消息队列独立于发送与接收进程,进程终止时,消息队列及其内容并不会被删除;消息队列可以实现消息的随机查询,可以按照消息的类型读取。

    信号量semaphore:是一个计数器,可以用来控制多个进程对共享资源的访问。信号量用于实现进程间的互斥与同步。

    信号:用于通知接收进程某个事件的发生。

    内存共享:使多个进程访问同一块内存空间。

  3. 套接字socket:用于不同主机直接的通信。

进程同步

  1. 信号量semaphore:是一个计数器,可以用来控制多个进程对共享资源的访问。信号量用于实现进程间的互斥与同步。P操作(递减操作)可以用于阻塞一个进程,V操作(增加操作)可以用于解除阻塞一个进程。

  2. 管道:一个进程通过调用管程的一个过程进入管程。在任何时候,只能有一个进程在管程中执行,调用管程的任何其他进程都被阻塞,以等待管程可用。

  3. 消息队列:消息的链接表,放在内核中。消息队列独立于发送与接收进程,进程终止时,消息队列及其内容并不会被删除;消息队列可以实现消息的随机查询,可以按照消息的类型读取。

管道实现原理

操作系统在内核中开辟一块缓冲区(称为管道)用于通信。管道是一种两个进程间进行单向通信的机制。因为这种单向性,管道又称为半双工管道,所以其使用是有一定的局限性的。半双工是指数据只能由一个进程流向另一个进程(一个管道负责读,一个管道负责写);如果是全双工通信,需要建立两个管道。管道分为无名管道和命名管道,无名管道只能用于具有亲缘关系的进程直接的通信(父子进程或者兄弟进程),可以看作一种特殊的文件,管道本质是一种文件;命名管道可以允许无亲缘关系进程间的通信。

#include <unistd.h>  
int pipe(int fd[2]);  

pipe()函数创建的管道处于一个进程中间,因此一个进程在由 pipe()创建管道后,一般再使用fork() 建立一个子进程,然后通过管道实现父子进程间的通信。管道两端可分别用描述字fd[0]以及fd[1]来描述。注意管道的两端的任务是固定的,即一端只能用于读,由描述字fd[0]表示,称其为管道读端;另 一端则只能用于写,由描述字fd[1]来表示,称其为管道写端。如果试图从管道写端读取数据,或者向管道读端写入数据都将发生错误。一般文件的 I/O 函数都可以用于管道,如close()、read()、write()等。

具体步骤如下:

  1. 父进程调用pipe开辟管道,得到两个文件描述符指向管道的两端。

  2. 父进程调用fork创建子进程,那么子进程也有两个文件描述符指向同一管道。

  3. 父进程关闭管道读端,子进程关闭管道写端。父进程可以往管道里写,子进程可以从管道里读,管道是用环形队列实现的,数据从写端流入从读端流出,这样就实现了进程间通信。

#include<unistd.h>    
#include<stdio.h>    
#include<stdlib.h>    
#include<string.h>    
#define INPUT  0     
#define OUTPUT 1    

int main(){    
    //创建管道    
    int fd[2];    
    pipe(fd);    
    //创建子进程    
    pid_t pid = fork();    
    if (pid < 0){    
        printf("fork error!\n");    
        exit(-1);    
    }    
    else if (pid == 0){//执行子进程  
        printf("Child process is starting...\n");  
        //子进程向父进程写数据,关闭管道的读端   
        close(fd[INPUT]);  
        write(fd[OUTPUT], "hello douya!", strlen("hello douya!"));  
        exit(0);  
    }  
    else{//执行父进程  
        printf ("Parent process is starting......\n");  
        //父进程从管道读取子进程写的数据 ,关闭管道的写端    
        close(fd[OUTPUT]);    
        char buf[255];  
        int output = read(fd[INPUT], buf, sizeof(buf));  
        printf("%d bytes of data from child process: %s\n", output, buf);  
    }  
    return 0;    
}   

简述mmap的原理和使用场景

参考回答

原理mmap是一种内存映射文件的方法,即将一个文件或者其它对象映射到进程的地址空间,实现文件磁盘地址和进程虚拟地址空间中一段虚拟地址的一一对映关系。实现这样的映射关系后,进程就可以采用指针的方式读写操作这一段内存,而系统会自动回写脏页面到对应的文件磁盘上,即完成了对文件的操作而不必再调用read, write等系统调用函数。相反,内核空间对这段区域的修改也直接反映用户空间,从而可以实现不同进程间的文件共享。如下图:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-lE5Lc0nY-1661051614678)(https://uploadfiles.nowcoder.com/images/20210519/59_1621415737288/7F7B2DE3C7D83F7F31D18D49C8BBCF01)]

使用场景

  1. 对同一块区域频繁读写操作。

  2. 可用于实现用户空间和内核空间的高效交互。

  3. 可提供进程间共享内存及相互通信。

  4. 可实现高效的大规模数据传输。

协程的轻量级表现在哪里

  1. 协程调用跟切换比线程效率高:协程执行效率极高。协程不需要多线程的锁机制,可以不加锁的访问全局变量,所以上下文的切换非常快。

  2. 协程占用内存少:执行协程只需要极少的栈内存(大概是4~5KB),而默认情况下,线程栈的大小为1MB。

  3. 切换开销更少:协程直接操作栈基本没有内核切换的开销,所以切换开销比线程少。

常见信号

编号为1 ~ 31的信号为传统UNIX支持的信号,是不可靠信号(非实时的)。不可靠信号和可靠信号的区别在于前者不支持排队,可能会造成信号丢失,而后者不会。

而常见信号如下

信号代号信号名称说 明
1SIGHUP该信号让进程立即关闭.然后重新读取配置文件之后重启
2SIGINT程序中止信号,用于中止前台进程。相当于输出 Ctrl+C 快捷键
8SIGFPE在发生致命的算术运算错误时发出。不仅包括浮点运算错误,还包括溢出及除数为 0 等其他所有的算术运算错误
9SIGKILL用来立即结束程序的运行。本信号不能被阻塞、处理和忽略。般用于强制中止进程
14SIGALRM时钟定时信号,计算的是实际的时间或时钟时间。alarm 函数使用该信号
15SIGTERM正常结束进程的信号,kill 命令的默认信号。如果进程已经发生了问题,那么这 个信号是无法正常中止进程的,这时我们才会尝试 SIGKILL 信号,也就是信号 9
17SIGCHLD子进程结束时, 父进程会收到这个信号。
18SIGCONT该信号可以让暂停的进程恢复执行。本信号不能被阻断
19SIGSTOP该信号可以暂停前台进程,相当于输入 Ctrl+Z 快捷键。本信号不能被阻断

其中最重要的就是 “1”、“9”、“15”、"17"这几个信号。

线程通信

线程间的通信方式包括临界区、互斥量、信号量、条件变量、读写锁

  1. 临界区:每个线程中访问临界资源的那段代码称为临界区(Critical Section)(临界资源是一次仅允许一个线程使用的共享资源)。每次只准许一个线程进入临界区,进入后不允许其他线程进入。不论是硬件临界资源,还是软件临界资源,多个线程必须互斥地对它进行访问。

  2. 互斥量:采用互斥对象机制,只有拥有互斥对象的线程才可以访问。因为互斥对象只有一个,所以可以保证公共资源不会被多个线程同时访问。

  3. 信号量:计数器,允许多个线程同时访问同一个资源。

  4. 条件变量:通过条件变量通知操作的方式来保持多线程同步。

  5. 读写锁:读写锁与互斥量类似。但互斥量要么是锁住状态,要么就是不加锁状态。读写锁一次只允许一个线程写,但允许一次多个线程读,这样效率就比互斥锁要高。

线程同步

线程间的同步方式包括互斥锁、信号量、条件变量、读写锁

  1. 互斥锁:采用互斥对象机制,只有拥有互斥对象的线程才可以访问。因为互斥对象只有一个,所以可以保证公共资源不会被多个线程同时访问。

  2. 信号量:计数器,允许多个线程同时访问同一个资源。

  3. 条件变量:通过条件变量通知操作的方式来保持多线程同步。

  4. 读写锁:读写锁与互斥量类似。但互斥量要么是锁住状态,要么就是不加锁状态。读写锁一次只允许一个线程写,但允许一次多个线程读,这样效率就比互斥锁要高。

说说什么是死锁,产生的条件,如何解决?

  1. 死锁: 是指多个进程在执行过程中,因争夺资源而造成了互相等待。此时系统产生了死锁。比如两只羊过独木桥,若两只羊互不相让,争着过桥,就产生死锁。

  2. 产生的条件:死锁发生有四个必要条件

    (1)互斥条件:进程对所分配到的资源不允许其他进程访问,若其他进程访问,只能等待,直到进程使用完成后释放该资源;

    (2)请求保持条件:进程获得一定资源后,又对其他资源发出请求,但该资源被其他进程占有,此时请求阻塞,而且该进程不会释放自己已经占有的资源;

    (3)不可剥夺条件:进程已获得的资源,只能自己释放,不可剥夺;

    (4)环路等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。

  3. 如何解决

    (1)资源一次性分配,从而解决请求保持的问题;

    (2)可剥夺资源:当进程新的资源未得到满足时,释放已有的资源;

    (3)资源有序分配:资源按序号递增,进程请求按递增请求,释放则相反。

单核机器上写多线程,是否加锁

在单核机器上写多线程程序,仍然需要线程锁。

原因:因为线程锁通常用来实现线程的同步和通信。在单核机器上的多线程程序,仍然存在线程同步的问题。因为在抢占式操作系统中,通常为每个线程分配一个时间片,当某个线程时间片耗尽时,操作系统会将其挂起,然后运行另一个线程。如果这两个线程共享某些数据,不使用线程锁的前提下,可能会导致共享数据修改引起冲突。

互斥锁以及与读写锁区别

  1. 互斥锁机制:mutex,用于保证在任何时刻,都只能有一个线程访问该对象。当获取锁操作失败时,线程会进入睡眠,等待锁释放时被唤醒。

  2. 互斥锁和读写锁

    (1) 读写锁区分读者和写者,而互斥锁不区分

    (2)互斥锁同一时间只允许一个线程访问该对象,无论读写;读写锁同一时间内只允许一个写者,但是允许多个读者同时读对象。

自旋锁和互斥锁的使用场景

  1. 互斥锁用于临界区持锁时间比较长的操作,比如下面这些情况都可以考虑

    (1)临界区有IO操作

    (2)临界区代码复杂或者循环量大

    (3)临界区竞争非常激烈

    (4)单核处理器

  2. 自旋锁就主要用在临界区持锁时间非常短且CPU资源不紧张的情况下。

说说线程池的设计思路,线程池中线程的数量由什么确定?

参考回答

  1. 设计思路

    实现线程池有以下几个步骤:

    (1)设置一个生产者消费者队列,作为临界资源。

    (2)初始化n个线程,并让其运行起来,加锁去队列里取任务运行。

    (3)当任务队列为空时,所有线程阻塞。

    (4)当生产者队列来了一个任务后,先对队列加锁,把任务挂到队列上,然后使用条件变量去通知阻塞中的一个线程来处理。

  2. 线程池中线程数量

    线程数量和哪些因素有关:CPU,IO、并行、并发

    如果是CPU密集型应用,则线程池大小设置为:CPU数目+1

    如果是IO密集型应用,则线程池大小设置为:2*CPU数目+1

    最佳线程数目 = (线程等待时间与线程CPU时间之比 + 1)* CPU数目

    所以线程等待时间所占比例越高,需要越多线程。线程CPU时间所占比例越高,需要越少线程。

答案解析

  1. 为什么要创建线程池

    创建线程和销毁线程的花销是比较大的,这些时间有可能比处理业务的时间还要长。这样频繁的创建线程和销毁线程,再加上业务工作线程,消耗系统资源的时间,可能导致系统资源不足。同时线程池也是为了提升系统效率。

  2. 线程池的核心线程与普通线程:

    任务队列可以存放100个任务,此时为空,线程池里有10个核心线程,若突然来了10个任务,那么刚好10个核心线程直接处理;若又来了90个任务,此时核心线程来不及处理,那么有80个任务先入队列,再创建核心线程处理任务;若又来了120个任务,此时任务队列已满,不得已,就得创建20个普通线程来处理多余的任务。

    以上是线程池的工作流程。

简述Linux零拷贝的原理?

参考回答

  1. 什么是零拷贝

    所谓「零拷贝」描述的是计算机操作系统当中,CPU不执行将数据从一个内存区域,拷贝到另外一个内存区域的任务。通过网络传输文件时,这样通常可以节省 CPU 周期和内存带宽。

  2. 零拷贝的好处

    (1)节省了 CPU 周期,空出的 CPU 可以完成更多其他的任务

    (2)减少了内存区域之间数据拷贝,节省内存带宽

    (3)减少用户态和内核态之间数据拷贝,提升数据传输效率

    (4)应用零拷贝技术,减少用户态和内核态之间的上下文切换

  3. 零拷贝原理

    在传统 IO 中,用户态空间与内核态空间之间的复制是完全不必要的,因为用户态空间仅仅起到了一种数据转存媒介的作用,除此之外没有做任何事情。

    (1)Linux 提供了 sendfile() 用来减少我们的数据拷贝和上下文切换次数。

    过程如图:

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-2vumYLYs-1661051614679)(https://uploadfiles.nowcoder.com/images/20210519/59_1621416095244/40791E38EBE0D59410B12EC4CB574CAF)]

    a. 发起 sendfile() 系统调用,操作系统由用户态空间切换到内核态空间(第一次上下文切换)

    b. 通过 DMA 引擎将数据从磁盘拷贝到内核态空间的输入的 socket 缓冲区中(第一次拷贝)

    c. 将数据从内核空间拷贝到与之关联的 socket 缓冲区(第二次拷贝)

    d. 将 socket 缓冲区的数据拷贝到协议引擎中(第三次拷贝)

    e. sendfile() 系统调用结束,操作系统由用户态空间切换到内核态空间(第二次上下文切换)

    根据以上过程,一共有 2 次的上下文切换,3 次的 I/O 拷贝。我们看到从用户空间到内核空间并没有出现数据拷贝,从操作系统角度来看,这个就是零拷贝。内核空间出现了复制的原因: 通常的硬件在通过DMA访问时期望的是连续的内存空间。

    (2)mmap 数据零拷贝原理

    如果需要对数据做操作,Linux 提供了mmap 零拷贝来实现。

简述epoll和select的区别,epoll为什么高效?

参考回答

  1. 区别

    (1)每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大;而epoll保证了每个fd在整个过程中只会拷贝一次。

    (2)每次调用select都需要在内核遍历传递进来的所有fd;而epoll只需要轮询一次fd集合,同时查看就绪链表中有没有就绪的fd就可以了。

    (3)select支持的文件描述符数量太小了,默认是1024;而epoll没有这个限制,它所支持的fd上限是最大可以打开文件的数目,这个数字一般远大于2048。

  2. epoll为什么高效

    (1)select,poll实现需要自己不断轮询所有fd集合,直到设备就绪,期间可能要睡眠和唤醒多次交替。而epoll只要判断一下就绪链表是否为空就行了,这节省了大量的CPU时间。

    (2)select,poll每次调用都要把fd集合从用户态往内核态拷贝一次,并且要把当前进程往设备等待队列中挂一次,而epoll只要一次拷贝,而且把当前进程往等待队列上挂也只挂一次,这也能节省不少的开销。

多路IO复用技术有哪些,区别是什么?

参考回答

  1. select,poll,epoll都是IO多路复用的机制,I/O多路复用就是通过一种机制,可以监视多个文件描述符,一旦某个文件描述符就绪(一般是读就绪或者写就绪),能够通知应用程序进行相应的读写操作。

  2. 区别

    (1)poll与select不同,通过一个pollfd数组向内核传递需要关注的事件,故没有描述符个数的限制,pollfd中的events字段和revents分别用于标示关注的事件和发生的事件,故pollfd数组只需要被初始化一次。

    (2)select,poll实现需要自己不断轮询所有fd集合,直到设备就绪,期间可能要睡眠和唤醒多次交替。而epoll只要判断一下就绪链表是否为空就行了,这节省了大量的CPU时间。

    (3)select,poll每次调用都要把fd集合从用户态往内核态拷贝一次,并且要把当前进程往设备等待队列中挂一次,而epoll只要一次拷贝,而且把当前进程往等待队列上挂也只挂一次,这也能节省不少的开销。

简述socket中select,epoll的使用场景和区别,epoll水平触发与边缘触发的区别?

参考回答

  1. select,epoll的使用场景:都是IO多路复用的机制,应用于高并发的网络编程的场景。I/O多路复用就是通过一种机制,可以监视多个文件描述符,一旦某个文件描述符就绪(一般是读就绪或者写就绪),能够通知应用程序进行相应的读写操作。

  2. select,epoll的区别

    (1)每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大;而epoll保证了每个fd在整个过程中只会拷贝一次。

    (2)每次调用select都需要在内核遍历传递进来的所有fd;而epoll只需要轮询一次fd集合,同时查看就绪链表中有没有就绪的fd就可以了。

    (3)select支持的文件描述符数量太小了,默认是1024;而epoll没有这个限制,它所支持的fd上限是最大可以打开文件的数目,这个数字一般远大于2048。

  3. epoll水平触发与边缘触发的区别

    LT模式(水平触发)下,只要这个fd还有数据可读,每次 epoll_wait都会返回它的事件,提醒用户程序去操作;

    而在ET(边缘触发)模式中,它只会提示一次,直到下次再有数据流入之前都不会再提示了,无论fd中是否还有数据可读。

说说Reactor、Proactor模式。

参考回答

在高性能的I/O设计中,有两个比较著名的模式Reactor和Proactor模式,其中Reactor模式用于同步I/O,而Proactor运用于异步I/O操作。

  1. Reactor模式:Reactor模式应用于同步I/O的场景。Reactor中读操作的具体步骤如下:

    读取操作:

    (1)应用程序注册读就需事件和相关联的事件处理器

    (2)事件分离器等待事件的发生

    (3)当发生读就需事件的时候,事件分离器调用第一步注册的事件处理器

    (4)事件处理器首先执行实际的读取操作,然后根据读取到的内容进行进一步的处理

  2. Proactor模式:Proactor模式应用于异步I/O的场景。Proactor中读操作的具体步骤如下:

    (1)应用程序初始化一个异步读取操作,然后注册相应的事件处理器,此时事件处理器不关注读取就绪事件,而是关注读取完成事件,这是区别于Reactor的关键。

    (2)事件分离器等待读取操作完成事件。

    (3)在事件分离器等待读取操作完成的时候,操作系统调用内核线程完成读取操作,并将读取的内容放入用户传递过来的缓存区中。这也是区别于Reactor的一点,Proactor中,应用程序需要传递缓存区。

    (4)事件分离器捕获到读取完成事件后,激活应用程序注册的事件处理器,事件处理器直接从缓存区读取数据,而不需要进行实际的读取操作。

  3. 区别:从上面可以看出,Reactor中需要应用程序自己读取或者写入数据,而Proactor模式中,应用程序不需要用户再自己接收数据,直接使用就可以了,操作系统会将数据从内核拷贝到用户区

5种IO模型

IO模型的类型。
(1)阻塞IO:调用者调用了某个函数,等待这个函数返回,期间什么也不做,不停的检查这个函数有没有返回,必须等这个函数返回后才能进行下一步动作。

(2)非阻塞IO:非阻塞等待,每隔一段时间就去检查IO事件是否就绪。没有就绪就可以做其他事情。

(3)信号驱动IO:Linux用套接口进行信号驱动IO,安装一个信号处理函数,进程继续运行并不阻塞,当IO事件就绪,进程收到SIGIO信号,然后处理IO事件。

(4)IO多路复用:Linux用select/poll函数实现IO复用模型,这两个函数也会使进程阻塞,但是和阻塞IO所不同的是这两个函数可以同时阻塞多个IO操作。而且可以同时对多个读操作、写操作的IO函数进行检查。知道有数据可读或可写时,才真正调用IO操作函数。

(5)异步IO:Linux中,可以调用aio_read函数告诉内核描述字缓冲区指针和缓冲区的大小、文件偏移及通知的方式,然后立即返回,当内核将数据拷贝到缓冲区后,再通知应用程序。用户可以直接去使用数据。

前四种模型–阻塞IO、非阻塞IO、多路复用IO和信号驱动IO都属于同步模式,因为其中真正的IO操作(函数)都将会阻塞进程,只有异步IO模型真正实现了IO操作的异步性。

BIO、NIO有什么区别?

参考回答

BIO(Blocking I/O)阻塞IO。调用者调用了某个函数,等待这个函数返回,期间什么也不做,不停的检查这个函数有没有返回,必须等这个函数返回后才能进行下一步动作。

NIO(New I/O)同时支持阻塞与非阻塞模式,NIO的做法是叫一个线程不断的轮询每个IO的状态,看看是否有IO的状态发生了改变,从而进行下一步的操作。

socket网络编程中客户端和服务端用到哪些函数?

参考回答

  1. 服务器端函数

    (1)socket创建一个套接字

    (2)bind绑定ip和port

    (3)listen使套接字变为可以被动链接

    (4)accept等待客户端的链接

    (5)write/read接收发送数据

    (6)close关闭连接

  2. 客户端函数

    (1)创建一个socket,用函数socket()

    (2)bind绑定ip和port

    (3)连接服务器,用函数connect()

    (4)收发数据,用函数send()和recv(),或read()和write()

    (5)close关闭连接

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值