C++
一、C++的三大特性
封装,继承,多态。
请介绍一下什么是封装?
答:封装的意义在于保护或者防止数据被我们故意破坏。在面向对象程序设计中,数据被看作是一个中心的元素并且和使用它的方法关联很密切,所以说封装就是将数据与操作数据的方法进行有机结合,隐藏对象的属性和实现细节,仅对外公开接口来和对象进行交互。C++实现封装的方式就是用类将对象的属性和方法结合在一起,通过访问权限选择性的将其接口提供给外部的用户使用。类只是一个模型一样的东西,它是对象的一个抽象,而对象则是类的具体实例。定义出一个类并不会分配实际的内存空间,实例化出的对象占用实际的物理空间来存储成员,准确的来说是存储成员变量,而成员函数是存储在公共的代码段。访问权限则是由访问限定符来实现的,public修饰的成员在类外可以被直接访问,protected和private修饰的成员则不能类外直接访问(在这里protected和private是类似的)。访问权限作用域从该访问限定符出现的位置一直到下一个访问限定符出现时为止。因为C++要兼容C语言,所以C++中struct可以当作结构体来使用,也可以用来定义类,它和class定义类是一样的,唯一的区别就是class默认访问权限为private,struct为public。
请介绍一下什么是继承?
答:继承机制是面向对象程序设计使代码可以复用的最重要手段,它允许程序员在保持原有类特性的基础上进行扩展增加功能,这样产生的新的类叫做派生类。以前我们接触的都是函数的复用,而继承则是类层次上的复用。
继承关系有public继承,protected继承,private继承;访问限定符有public访问,protected访问,private访问。public继承时,基类的public成员,protected成员,private成员在派生类中分别依旧是public成员,protected成员,private成员;protected继承时,基类的public成员,protected成员,private成员在派生类中分别protected成员,rotected成员,private成员,也就是说如果基类成员不想在类外直接被访问,但需要在派生类中能访问,就定义为protected(可以看出保护成员限定符是因继承才出现的);private继承时,派生类中均是private成员。关键字class时默认的继承方式是private,使用struct时默认的继承方式是public,不过最好显示的写出继承方式。
在继承体系中,派生类对象可以赋值给基类的对象,引用或者指针,而基类对象不能赋值给派生类对象;基类的指针可以通过强制类型转换赋值给派生类的指针,但是必须基类的指针是指向派生类对象的,如果不是则可能产生越界访问。
在继承体系中,基类和派生类都有自己的作用域,如果子类和父类有同名成员那么子类将屏蔽父类对同名成员的直接访问,如果在子类成员函数要访问那么可以使用基类::基类成员进行访问。如果是成员函数的隐藏,那么只要函数名相同就构成隐藏。
派生类的六个默认成员函数:1.派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认的构造函数,那么派生类必须在构造函数初始化列表显式调用基类构造函数。 2.派生类的拷贝构造函数必须调用基类的拷贝构造函数来完成基类的拷贝初始化。 3.派生类的operator=必须要调用基类的operator=完成基类的复制。 4.派生类的析构函数会在被调用后自动调用基类的析构函数清理基类的成员。 5.派生类对象初始化先调用基类的构造函数再调用派生类构造函数。 6.派生类对象析构清理先调用派生类析构再调用基类析构
友元关系是不能继承的,也就是说基类不能访问子类的私有和保护成员。(友元类可访问本类);基类定义了static静态成员,则整个继承体系里面只有一个这样的成员。
虚拟继承可以解决菱形继承的数据冗余和二义性问题,从上至下分别为B偏移量表地址,B成员变量,C偏移量表地址,C成员变量,D成员变量,A成员变量。B偏移量表地址存放的偏移量加上偏移量表地址就是基类A的地址,也就是说可以通过偏移量找到下面的基类A。
复用的方式除了类继承还有一种对象组合。类继承方式基类的内部细节对子类可见,相当于每一个子类对象就是一个基类对象,基类的改变也会对派生类对象进行改变。而组合是,假设B类组合了A类那么每个B类对象中就有一个A类对象,这样就是说组合类之间没有很强的依赖关系,耦合度低代码的可维护度也较好。但是也不是说继承是没有用处的,如果说要实现多态就必定要使用继承。
请介绍一下什么是多态?
分为静态多态和动态多态。静态多态就是在系统编译期间就确定了程序运行到这里要执行哪一个函数,比如函数重载和泛型编程,动态多态是利用虚函数实现了运行时多态,也就是说在系统编译的时候不知道要调用哪一个函数,只有在运行到这里时才能确定接下来要跳转到哪一个函数的帧栈。
动态多态是在不同继承关系的对象去调用同一函数,产生了不同的行为。在继承中构成多态要满足两个条件:1.必须通过基类引用或指针来调用虚函数。 2.被调用的函数必须是虚函数,且该虚函数必须被派生类重写。虚函数是指被virtual修饰的类成员函数。虚函数的重写则是指派生类中有一个与基类完全相同的虚函数,即函数名,返回值,参数列表完全相同,那么这就叫做虚函数的重写。但是并不是所有的虚函数重写都要满足这个条件,它有两个例外:1.协变:基类和派生类的返回值可以不同,但是必须是基类返回基类指针或引用,派生类返回派生类指针或引用 2.析构函数重写:如果基类的析构函数为虚函数,那么无论加有virtual关键字,只要定义派生类的析构函数就构成了重写。
多态的调用依赖于虚表指针(初始化列表阶段才初始化),调用流程是:
1.从对象的前四个字节中取虚表的地址
2.传递this指针,也就是给虚函数传参
3.从虚表中取对应虚函数的地址
4.调用该虚函数。虚表指针是为了指向虚表,而虚表则是存放虚函数指针的指针数组。
派生类虚函数生成的流程:
1.先将基类中的虚表内容拷贝一份到派生类虚表中
2.如果派生类重写了基类的虚函数,那么就用派生类自己的虚函数覆盖虚表中基类的虚函数
3.派生类自己新增加的虚函数按其在派生类的声明顺序加在派生类虚表的后边(多继承派生类中未重写的虚函数放在第一个继承基类部分的虚表中)。
什么是接口继承和实现继承
在虚函数的后面加上=0,则这个虚函数为纯虚函数。包含纯虚函数的类叫做抽象类,是不能实例化出对象的。派生类继承后也不能实例化出对象,必须要重写纯虚函数才能实例化出对象。
普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实现。虚函数的继承是
一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态,继承的是接口。所以如果不实现多态,不要把函数定义成虚函数。
重载,重写,重定义
函数重载是指同一作用域下声明多个功能类似的函数,这些同名函数的形参列表必须不同,不能根据返回值不同来判定它是重载函数。函数重写是指两个函数分别在基类和派生类的作用域下,必须都是虚函数,且函数名,返回值,参数列表均相同。重定义也是在派生类和基类的作用域下,但是分两种情况:1.如果函数原型相同但是没有virtual修饰,那么就构成重写 2.如果有virtual修饰,那么只要函数名相同,返回值或参数列表不同则构成重定义
二、泛型编程
函数模板代表了一个函数家族,该函数模板与类型无关,在使用时被参数化,根据实参类型产生函数的特定类型版本。
函数模板的实例化是用不同类型的参数使用函数模板时,称为模板的实例化。分为隐式实例化和显式实例化:隐式实例化是让编译器根据实参推演模板参数的实际类型;显式实例化则是在函数名后边的<>中指定模板参数实际类型:Add
类模板的实例化需要在类模板名字后跟<>,然后将实例化的类型放在<>中。
模板参数分为类类型形参与非类型形参。类类型形参指出现在模板参数列表中,跟在class或者typename后面的参数类型名称;非类型形参就是用一个常量作为模板的一个参数,一般只能整形,浮点数类对象和字符串是不可以的
模板的特化
通常情况下使用模板可以实现一些与类型无关的代码,但对于一些特殊类型的可能会得到一些错误的结果,此时就要对模板进行特化。即:在原模板类的基础上,针对特殊类型所进行特殊化的实现方式。分为函数模板特化和类模板特化。
函数模板特化步骤:1.必须要先有一个基础的函数模板 2.关键字template后跟一对空的尖括号<> 3.函数名后跟一对<>,<>中指定需要特化的类型 4.函数形参表,必须要与模板函数的基础参数类型相同
类模板特化分为全特化和偏特化。全特化是指将模板参数表中所有的参数都确定化,偏特化是进一步进行条件限制。有两种方式:1.部分特化 2.参数进一步限制(比如给出多种不同类型的参数,T*,T&等)
类型萃取:为什么要进行类型萃取。因为部分代码可能内置类型无碍,但是自定义类型就会出错,比如说memcpy,因为可能会涉及到深浅拷贝问题,所以提供一个方法,使它遇到内置类型就用memcpy来拷贝,遇到自定义类型就用另外的方法。
1 | struct _true_type{}; //代表内置类型 |
三、动态内存管理
首先我们要知道C++中的内存分布:栈,共享内存映射段,堆,数据段(静态区),代码段(常量区)。栈是向下增长的,存放非静态局部变量,函数参数,返回值等,向下增长;共享内存映射段是高效的I/O映射方式,用于装载一个共享的动态内存库,用户可使用系统接口创建共享内存来做进程间通信;堆是用来程序运行时进行动态内存分配,是向上增长的;数据段存储全局数据和静态数据;代码段存放可执行的代码和只读常量。
所以说动态内存管理实际上是用户对堆区的操作。在C语言中,使用malloc/calloc/realloc来进行对堆的操作,而C++中则通过new/delete操作符来进行动态内存管理。new/delete在底层分别调用operator new和operator delete这两个系统提供的全局函数来申请或者释放空间。而operator也是通过malloc来申请空间的,如果malloc申请空间成功就直接返回,否则就执行用户所提供的空间不足应对措施,如果用户给出了该措施就继续申请,如果没有就抛异常。operator delete是通过free来释放空间的。如果申请的是内置类型的空间,new和malloc,delete和free基本类似,不同的地方是:new/delete申请和释放的是单个元素的空间,new[]和delete[]申请的是连续空间,而且new在申请空间失败时会抛出异常,malloc会返回NULL。自定义类型时new会调用operator new函数申请空间,然后在申请的空间上执行构造函数完成对象的构造。
malloc/free与new/delete的区别:
1.malloc/free是函数,new/delete是操作符
2.malloc申请的空间不会初始化,new可以初始化
3.malloc申请时需要手动计算空间大小并传递,new只需要在后面跟上空间类型即可
4.malloc返回值为void*在使用时必须强转,new则不需要
5.malloc申请空间失败时返回NULL,new会抛异常
6.申请自定义类型对象时malloc只会开辟空间,而new会调用构造函数,delete会调用析构函数
7.new/delete比malloc/free效率稍微低一点。
堆与栈的区别
1.管理方式不同,堆是由程序员控制申请释放的,栈是操作系统自动分配释放的
2.空间大小不同,每个进程拥有栈的大小为1M(64位windows),10M(64位Linux),理论上可申请的堆大小是虚拟内存大小
3.生长方向不同,堆向上生长内存由低到高,栈向下生长由高到低
4.分配方式不同,堆是动态分配没有静态分配,栈有两种方式——静态(操作系统完成)和动态(alloca)
5.分配效率不同
6.存放内容不同,栈存放的是函数返回地址,相关参数,局部变量,寄存器内容等
定位new表达式
在已分配的原始内存空间中调用构造函数初始化一个对象
new(place_address) type place_address必须是一个指针
1 | void Test() |
内存泄漏
是指程序中己动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。内存泄漏分为堆内存泄漏和系统资源泄露。
堆内存泄露是指程序执行中通过malloc/realloc/calloc/new等从堆中分配的一块内存,用完以后必须通过调用相应的free或者delete删掉。系统资源泄露指使用系统分配的资源比如:套接字,文件描述符,管道等没有使用相应的函数释放掉导致系统资源的浪费,严重时可能会导致系统效能减少系统执行不稳定。
如何避免:1.养成良好的编码规范 2.采用RALL思想或者智能指针来管理资源
智能指针
智能指针的作用是管理一个指针,因为存在以下这种情况:申请的空间在函数结束时忘记释放,造成内存泄漏。使用智能指针可以很大程度上的避免这个问题,因为智能指针就是一个类,当超出了类的作用域是,类会自动调用析构函数,析构函数会自动释放资源。所以智能指针的作用原理就是在函数结束时自动释放内存空间,不需要手动释放内存空间。
1.auto_ptr是用管理权转移的思想,所以对象被拷贝或者复制以后原有的对象就空了。
1 | auto_ptr< string> p1 (new string ("I reigned lonely as a cloud.”)); |
此时不会报错,原因是p1的所有权给了p2,但是当程序去访问p1时会报错,所以auto_ptr存在潜在内存崩溃问题
2.unique_ptr是用简单粗暴的防止拷贝,保证同一时间内只有一个智能指针可以指向该对象。当程序试图将一个 unique_ptr 赋值给另一个时,如果源 unique_ptr 是个临时右值,编译器允许这么做;如果源 unique_ptr 将存在一段时间,编译器将禁止这么做。
1 | unique_ptr<string> pu1(new string ("hello world")); |
3.shared_ptr的原理:是通过引用计数的方式来实现多个shared_ptr对象共享资源,除了可以通过new来构造,还可以通过传入auto_ptr, unique_ptr,weak_ptr来构造。
- shared_ptr在其内部,给每个资源都维护了着一份计数,用来记录该份资源被几个对象共享。
- 在对象被销毁时(也就是析构函数调用),就说明自己不使用该资源了,对象的引用计数减一。
- 如果引用计数是0,就说明自己是最后一个使用该资源的对象,必须释放该资源;如果不是0,就说明除了自己还有其他对象在使用该份资源,不能释放该资源,否则其他对象就成野指针了。
在shared_ptr中,可能会因为循环引用造成系统奔溃问题,所以使用weak_ptr来解决, 它指向一个 shared_ptr 管理的对象. 进行该对象的内存管理的是那个强引用的 shared_ptr.,weak_ptr只是提供了对管理对象的一个访问手段,我们不能通过weak_ptr直接访问对象的方法。
std::shared_ptr的线程安全问题
通过下面的程序我们来测试shared_ptr的线程安全问题。需要注意的是shared_ptr的线程安全分为两方面:
- 智能指针对象中引用计数是多个智能指针对象共享的,两个线程中智能指针的引用计数同时++或–,这
个操作不是原子的,引用计数原来是1,++了两次,可能还是2.这样引用计数就错乱了。会导致资源未
释放或者程序崩溃的问题。所以只能指针中引用计数++、–是需要加锁的,也就是说引用计数的操作是
线程安全的。
- 智能指针管理的对象存放在堆上,两个线程中同时去访问,会导致线程安全问题。
四、C++11
1.列表初始化
在C++98中允许对数组元素用{}进行初始值设置,但是对于一些自定义的类型却无法使用这样的初始化。C++11扩大了初始化列表)的使用范围,使其可用于所有的内置类型和用户自定义的类型。使用初始化列表时可添加等号(=),也可不添加。多个对象想要支持列表初始化,需给该类(模板类)添加一个带有initializer_list类型参数的构造函数即可。注意:initializer_list是系统自定义的类模板,该类模板中主要有三个方法:begin()、end()迭代器以及获取区间中元素个数的方法size()。
1 |
|
2.变量类型推导
- 如果e是一个没有带括号的标记符表达式(如arr[3]中的arr)或者类成员访问表达式,那么decltype(e)就是e所命名的实体的类型。如果e是一个被重载的函数,则会编译失败。
- 假设e的类型是T,如果e是将亡值(比如临时变量或者函数局部变量),decltype(e)被推导为T&&
- 假设e的类型是T,如果e是一个左值,则decltype(e)就是T的引用
- 假设e的类型是T,则decltype(e)就是T
返回值类型追踪:把函数的返回值移至参数声明之后,复合符号->decltype(left+right)被称为:追踪返回类型。原本函数返回值位置由auto占据,这样就可以由编译器推导函数类型了
1 | template<class T1, class T2> |
3.缺省
在C++11中,可以在默认函数定义或者声明时加上=default,从而显式的指示编译器生成该函数的默认版本,用=default修饰的函数称为显式缺省函数。
4.右值引用
将一个对象中资源移动到另一个对象中的方式,称之为移动语义。在C++11中如果需要实现移动语义,必须
使用右值引用。右值引用,顾名思义就是对右值的引用。在C++11中,右值由两个概念组成:将亡值和纯右值,纯右值是C++98的概念,指的是用于识别临时变量和一些不跟对象关联的值。比如:常量、一些运算表达式(1+3)等
右值引用最长常见的一个使用地方就是与移动语义相结合,减少不必要的资源开辟来提高代码效率。
1 | String&& GetString(char* pStr) |
另一个较常见的地方是:给一个匿名对象取别名,延长匿名对象的声明周期
1 | String GetString(char* pStr) |
要注意的是:1. 与引用一样,右值引用在定义时必须初始化。 2.通常情况下,右值引用不能引用左值(变量名)
C++11中,std::move()函数并不搬移任何东西,唯一的功能就是将一个左值强制转化为右值引用,通过右值引用使用该值,实现移动语义。而被转化的左值,其生命周期并没有随着左右值的转化而改变,即std::move转化的左值变量value不会被销毁,要注意move更多的是用在声明周期即将结束的对象上。
移动语义中一些问题:
1.如果将移动构造函数声明为常右值引用或者返回右值的函数声明为常量,都会导致移动语义无法实现。
2.在C++11中,拷贝构造/移动构造/赋值/移动赋值函数必须同时提供或者同时不提供,程序才能保证类同时具有拷贝和移动语义
5.lamda表达式
[capture_list]/(parameters)mutable->return_type{statement};
1.capture_list是指捕捉列表,编译器就是根据[]来判断接下来的代码是否为lamda函数。它描述了上下文中那些数据可以被lambda使用,以及使用的方式传值还是传引用。
[var]:表示值传递方式捕捉变量var [=]:表示值传递方式捕获所有父作用域中的变量(包括this)
[&var]:表示引用传递捕捉变量var [&]:表示引用传递捕捉所有父作用域中的变量(包括this)
[this]:表示值传递方式捕捉当前的this指针
要注意捕捉列表不允许重复捕捉,lamda表达式之间不能相互赋值,即便看起来类型相同。
2.参数列表,如果不需要参数就可以连()一起省略
3.默认情况下lamda表达式是一个const函数,他可以取消其常量性。使用该修饰符时参数列表不能为空
4.返回值类型。返回值类型明确情况下,也可省略,由编译器对返回类型进行推导。
函数对象和lamda表达式
函数对象,又称为仿函数,即可以想函数一样使用的对象,就是在类中重载了operator()运算符的类对象。从使用方式上来看,函数对象与lambda表达式完全一样。函数对象将rate作为其成员变量,在定义对象时给出初始值即可,lambda表达式通过捕获列表可以直接将该变量捕获到。实际在底层编译器对于lambda表达式的处理方式,完全就是按照函数对象的方式处理的,即:如果定义了一个lambda表达式,编译器会自动生成一个类,在该类中重载了operator()。
五、关联式容器
红黑树
是一种二叉搜索树,但在每个结点上增加一个存储位表示结点的颜色,可以是Red或Black。 通过对任何一条从根到叶子的路径上各个结点着色方式的限制,红黑树确保没有一条路径会比其他路径长出俩倍,因而是接近平衡的。
红黑树的性质
每个结点不是红色就是黑色
根节点是黑色的
如果一个节点是红色的,则它的两个孩子结点是黑色的
对于每个结点,从该结点到其所有后代叶结点的简单路径上,均包含相同数目的黑色结点
每个叶子结点都是黑色的(此处的叶子结点指的是空结点)
在结点的定义中,默认把结点颜色给成红色。
平衡二叉树的性质
它是一 棵空树或它的左右两个子树的高度差的绝对值不超过1,并且左右两个子树都是一棵平衡二叉树。这个方案很好的解决了二叉查找树退化成链表的问题,把插入,查找,删除的时间复杂度最好情况和最坏情况都维持在O(logN)。但是频繁旋转会使插入和删除牺牲掉O(logN)左右的时间,不过相对二叉查找树来说,时间上稳定了很多。
区别:
红黑树和AVL树都是高效的平衡二叉树,增删改查的时间复杂度都是O( nlog2n),红黑树不追求绝对平衡,其只需保证最长路径不超过最短路径的2倍,相对而言,降低了插入和旋转的次数,所以在经常进行增删的结构中性能比AVL树更优,而且红黑树实现比较简单,所以实际运用中红黑树更多。
哈希结构
不经过任何比较,一次直接从表中得到要搜索的元素。 如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时通过该函数可以很快找到该元素。
哈希冲突
不同关键字通过相同哈希哈数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞。
怎么解决:
1.闭散列
当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置中的下一个空位置中去(填入表中元素/散列表长度在0.7~0.8之间,否则触发增容)
线性探测
从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止(删除时采用了假删除)
二次探测
找下一个空位置的方法为:Hi= (H0 +i^2)% m,或者:Hi= (H0 -i^2)% m。其中:i = 1,2,3…, 是通过散列函数Hash(x)对元素的关键码key 进行计算得到的位置,m是表的大小
2.开散列
又叫链地址法,首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称之为桶各个桶中元素通过一个单链表链接起来,各链表的头节点存储在哈希表中
开散列中在元素个数和桶个数相同时可以给哈希表增容
常用哈希函数
1.直接定制:取关键字的某个线性函数为散列地址:Hash(Key)= A*Key + B
2.除留余数法
设散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数,按照哈希函数:Hash(key) = key% p(p<=m),将关键码转换成哈希地址
3.平方去中法(了解,对关键字进行平方,取其中间元素)
位图和布隆过滤器
六、小知识点
static的作用
1.修饰全局变量,将之变为全局静态变量,存储在静态区,整个程序运行期间一直存在,作用域在声明他的文件之外是不可见的,准确地说是从定义之处开始,到文件结尾。
2.修饰局部变量,将之变为局部静态变量,作用域仍为局部作用域,当定义它的函数或者语句块结束的时候,作用域结束。但是当局部静态变量离开作用域后,并没有销毁,而是仍然驻留在内存当中,只不过我们不能再对它进行访问,直到该函数再次被调用,并且值不变;
3.修饰函数,函数的实现使用static修饰,那么这个函数只可在本cpp内使用,不会同其他cpp中的同名函数引起冲突;
4.类的静态成员,静态成员是类的所有对象中共享的成员,而不是某个对象的成员。对多个对象来说,静态数据成员只存储一处,供所有对象共用
5.类的静态成员函数,静态成员函数和静态数据成员一样,它们都属于类的静态成员,它们都不是对象成员。因此,对静态成员的引用不需要用对象名。
四种cast转换
static_cast
a、用于类层次结构中基类和派生类之间指针或引用的转换
上行转换(派生类—->基类)是安全的;
下行转换(基类—->派生类)由于没有动态类型检查,所以是不安全的。
b、用于基本数据类型之间的转换,如把int转换为char,这种带来安全性问题由程序员来保证
c、把空指针转换成目标类型的空指针
d、把任何类型的表达式转为void类型
使用特点
a、主要执行非多态的转换操作,用于代替C中通常的转换操作
b、隐式转换都建议使用static_cast进行标明和替换
reinterpret_cast
用于将一种类型转换为另一种不同的类型(不到万不得已,不用使用这个转换符,高危操作)
使用特点:
a、reinterpret_cast是从底层对数据进行重新解释,依赖具体的平台,可移植性差
b、reinterpret_cast可以将整型转换为指针,也可以把指针转换为数组
c、reinterpret_cast可以在指针和引用里进行肆无忌惮的转换
const_cast
a、常量指针转换为非常量指针,并且仍然指向原来的对象
b、常量引用被转换为非常量引用,并且仍然指向原来的对象
3.使用特点:
a、cosnt_cast是四种类型转换符中唯一可以对常量进行操作的转换符
b、去除常量性是一个危险的动作,尽量避免使用。一个特定的场景是:类通过const提供重载时,一般都是非常量函数调用const_cast将参数转换为常量,然后调用常量函数,然后得到结果再调用const_cast 去除常量性
dynamic_cast
只有在派生类之间转换时才使用dynamic_cast,type-id必须是类指针,类引用或者void*。
使用特点:
a、基类必须要有虚函数,因为dynamic_cast是运行时类型检查,需要运行时类型信息,而这个信息是存储在类的虚函数表中,只有一个类定义了虚函数,才会有虚函数表(如果一个类没有虚函数,那么一般意义上,这个类的设计者也不想它成为一个基类)。
b、对于下行转换,dynamic_cast是安全的(当类型不一致时,转换过来的是空指针),而static_cast是不安全的(当类型不一致时,转换过来的是错误意义的指针,可能造成踩内存,非法访问等各种问题)
c、dynamic_cast还可以进行交叉转换
向上转型:子类对象指针->父类指针/引用(不需要转换,赋值兼容规则)
向下转型:父类对象指针->子类指针/引用(用dynamic_cast转型是安全的)
指针和引用的区别
1.引用在定义时必须初始化,指针没有要求
2.引用在初始化时引用一个实体后不能再引用其他实体,而指针任意时刻均可指向其他同一类型实体
3.没有NULL引用,只有NULL的指针
4.在sizeof中含义不同
5.++操作不同,引用是引用的实体增加1,指针自加即指针先后偏移一个类型大小
6.有多级指针没有多级引用
7.访问实体方式不同,指针需要显式解引用,引用则是编译器处理
this指针
C++编译器给每个成员函数增加了一个隐藏的指针参数,让该指针指向当前对象。在函数体中所有成员变量的操作都是通过该指针去访问。
特性:1.this指针的类型 类类型* const 2.只能在成员函数中使用 3.this指针本质上其实是一个成员函数的形参,是对象调用成员函数时将对象地址作为实参传递给this形参 4.this指针是成员函数第一个隐含的指针形参
5.this指针存在栈上
Linux
一、小知识点
gcc和g++编译的区别
1.对于.c文件,gcc视之为C源文件,g++视为C++源文件
2.对于.cpp文件,gcc和g++都视为C++源文件。区别就是gcc能编译C++程序,但不能自动调用链接的C++库,g++则会自动调用连接的C++库
3.在编译阶段,g++会调用gcc,也就是说在这个阶段两者是等价的
4.链接阶段,gcc命令无法自动和C++程序使用的库链接,需手动链接(gcc -istdc++ ,但GCC编译.c文件时能自动链接C库
程序编译过程
1.预处理:删除#define并展开宏,处理#include及所有的条件预编译指令,删除注释 gcc -E hello.c -o hello.i
2.编译:检查语法规范性,检查无误后生成汇编代码 gcc -S hello.i -o hello.s
3.汇编:生成机器可识别代码 gcc -c hello.s -o hello.o
4.链接:链接库文件,生成可执行文件或者库文件
函数库分为静态库和动态库两种
- 静态库是指编译链接时,把库文件的代码全部加入到可执行文件中,因此生成的文件比较大,但在运行时也就不需要库文件了。其后缀名一般为.a
- 动态库与之相反,在编译链接时并没有把库文件的代码加入到可执行文件中,而是在程序执行时由运行时链接文件加载库,这样可以节省系统的开销。动态库一般后缀名为”.so”
- gcc默认生成的二进制程序,是动态链接的
文件描述符
文件描述符是从0开始的小整数,当我们打开文件时操作系统在内存中要创建相应的数据结构来描述目标文件,于是就有了file结构体,表示一个已经打开的文件对象。而进程执行open系统调用,所以必须让进程与文件联系起来。每个进程都有一个指针*files,指向一张表files_struct,该表最重要的部分就是有一个指针数组,每个元素都是一个指向打开文件的指针,所以本质上文件描述符就是该数组的下标。分配原则:最小未使用分配
inode
文件系统分为:超级块-i结点表-数据区
其中超级块中存放文件系统本身的结构信息;i结点表存放文件属性如文件大小,所有者,最近修改时间等;数据区存放文件内容。
创建一个新文件主要有4个操作:
1.存储属性:内核先找到一个空闲的i结点(263466),内核把文件信息记录到其中
2.存储数据:该文件需要存储在三个磁盘块 ,内核找到了三个空闲块:300,500,800.将内核缓冲区的第一块数据复制到300,下一块复制到500,以此类推
3.记录分配情况:文件内容按顺序300,500,800存放。内核在inode上的磁盘分布区记录了上述块列表
4.添加文件名到目录:新的文件名abc,Linux将入口(263466,abc)添加到目录文件,文件名和inode之间的对应关系将文件名和文件的内容及属性连接起来。
硬链接:
1.具有相同inode节点号的多个文件互为硬链接文件;
2.删除硬链接文件或者删除源文件任意之一,文件实体并未被删除;
3.只有删除了源文件和所有对应的硬链接文件,文件实体才会被删除;
4.硬链接文件是文件的另一个入口;
5.可以通过给文件设置硬链接文件来防止重要文件被误删;
6.创建硬链接命令 ln 源文件 硬链接文件;
7.硬链接文件是普通文件,可以用rm删除;
8.对于静态文件(没有进程正在调用),当硬链接数为0时文件就被删除
软链接(类似于快捷方式)
1.软链接里面存放的是源文件的路径,指向源文件;
2.删除源文件,软链接依然存在,但无法访问源文件内容;
3.创建软链接命令 ln -s 源文件 软链接文件;
4.软链接和源文件是不同的文件,文件类型也不同,inode号也不同;
5.软链接的文件类型是“l”,可以用rm删除。
可重入函数
一个函数在执行的过程中被打断,然后会再被从头执行一次,执行完后,再回来把刚才没执行完的部分执行完。函数是公共代码,这样的执行是允许的。函数的执行可以被打断,打断之后还可以再从头执行,执行完后接着执行刚才没有执行的代码,然后第一次执行的代码(被打断的函数)执行结果还是正确的。也就是说,这个函数执行,无论中间把这个函数再嵌入执行多少遍,怎么嵌入,最终执行完都不会对函数内部功能/程序逻辑造成影响,并且执行的结果都是正确的,这样的函数就是可重入函数。
常用的可重入函数:
- 不要使用全局变量,防止别的代码覆盖这些变量的值。
- 调用这类函数之前先关掉中断,调用完之后马上打开中断。防止函数执行期间被中断进入别的任务执行。
- 使用信号量(互斥条件)
不可重入函数
函数执行期间,被中断,从头执行这个函数,执行完毕后再返回刚才的中断点继续执行,此时由于刚才的中断导致了现在从新在中断点执行时发生了不可预料的错误。也就是说,如果函数在不同的地方/时序进行调用,会对函数的功能逻辑造成影响,这种函数就称为不可重入函数。
常见的不可重入函数:
- 使用了静态数据结构
- 调用了malloc和free等
- 调用了标准I/O函数
- 进行了浮点运算
如何判断是可重入函数
一个函数中判断是否是可重入函数:
1.是否对全局性(static)的数据进行修改操作
2.这个操作是否是原子性(不可被打断)的
不可重入函数:对全局性变量的进行修改操作,且这个操作不是原子性的
如何避免写出不可重入函数
如何避免写出不可重入函数
不使用或者互斥使用全局变量,不使用静态局部变量,只使用局部变量;在函数中动态分配的内存只在本函数中使用,不会传递函数外使用;只要保证局部特性,函数中使用的所有东西都只有局部性,对外不公开,用完即释放,就可以保证可重入。这样,不管怎么重叠,反正本层的函数的东西只有本层能够使用,其他层的函数无法使用,就相当于隔离每一层的变量。这样,不管重叠多少次,都不可能相互影响。这样每一层函数执行的结果都是正确的。
volatile
首先我们需要直到编译器优化这一概念,在本次线程内, 当读取一个变量时,为提高存取速度,编译器优化时有时会先把变量读取到一个寄存器中;以后,再取变量值时,就直接从寄存器中取值;当变量值在本线程里改变时,会同时把变量的新值copy到该寄存器中,以便保持一致。
这样会出现两种问题:1.当变量在因别的线程等而改变了值,该寄存器的值不会相应改变,从而造成应用程序读取的值和实际的变量值不一致。 2.当该寄存器在因别的线程等而改变了值,原变量的值不会改变,从而造成应用程序读取的值和实际的变量值不一致。
而volatile则就是为了解决这个问题,它直接存取原始内存地址,也就是保证了内存可见性。
信号
产生信号(Core Dump)
1.通过终端按键产生信号
SIGINT的默认处理动作时终止进程,SIGQUIT的默认动作是终止进程并Core Dump。
什么是core dump
Core Dump用来保存当前程序运行的数据以及调用堆栈信息,当一个进程异常终止时,可以选择把用户空间内存数据全部保存到磁盘上,文件名通常是core。进程异常终止通常是因为由BUG,比如非法内存访问导致段错误(访问越界),事后可以用调试器检查core文件来查清错误原因,这叫做事后调试。一个进程允许产生多大的core文件取决于进程的Resource Limit(保存在PCB中)。默认是不允许产生core文件的,因为core文件中可能包含用户密码等敏感信息,不安全。
为什么不用gdb呢?
答:如果程序运行错误,可以直接通过core文件来gdb调试(有些错误可能是偶然发生的,可能在gdb调试时并不会显露出来,只有在core dump记录之后才能快速定位进行调试)
CoreDump默认关闭:确保隐私安全/资源占用,
1.记录的信息里面可能有隐秘性信息(如用户名和密码)
2.Core Dump 文件很大且不会自动清理
CoreDump打开:ulimit -c (int)当这个值为0 时则关闭。
2.调用系统函数向进程发信号
kill命令是调用kill函数实现的,kill函数可以给一个指定的进程发送指定的信号。raise函数可以给当前进程发送指定信号。
1 | #include <sigmal.h> |
abort函数可以使当前进程接收到信号而异常终止
1 | #include <stdlib.h> |
3.由软件条件产生信号
SIGCHLD信号
在讲解僵尸进程时讲过用wait和waitpid函数清理僵尸进程,父进程可以阻塞的等待子进程结束,也可以非阻塞的查询是否有子进程结束等待清理(轮询方式)。采用第一种方式是父进程阻塞了就不能处理自己的工作了,而采用第二种方式父进程在处理自己工作的同时还要记得是不是轮询一下,程序实现比较复杂。
其实,子进程在终止时会给父进程发送SIGCHLD信号,该信号的默认处理动作是忽略,父进程可以自己定义SIGCHLD的处理函数,这样父进程只需要专心处理自己的工作不必关心子进程了。子进程终止时会通知父进程,父进程在信号处理函数中wait清理子进程即可。
1 |
|
死锁发生的条件以及如何解决死锁
死锁是指两个或两个以上进程在执行过程中,因争夺资源而造成的下相互等待的现象。死锁发生的四个必要条件如下:
1.互斥条件:进程对所分配到的资源不允许其他进程访问,若其他进程访问该资源,只能等待,直至占有该资源的进程使用完成后释放该资源;
2.请求和保持条件:进程获得一定的资源后,又对其他资源发出请求,但是该资源可能被其他进程占有,此时请求阻塞,但该进程不会释放自己已经占有的资源
3.不可剥夺条件:进程已获得的资源,在未完成使用之前,不可被剥夺,只能在使用后自己释放
4.环路等待条件:进程发生死锁后,必然存在一个进程-资源之间的环形链
解决死锁的方法即破坏上述四个条件之一,主要方法如下:
资源一次性分配,从而剥夺请求和保持条件
可剥夺资源:即当进程新的资源未得到满足时,释放已占有的资源,从而破坏不可剥夺的条件
资源有序分配法:系统给每类资源赋予一个序号,每个进程按编号递增的请求资源,释放则相反,从而破坏环路等待的条件
二、进程间通信
分为管道,System V IPC(消息队列,共享内存,信号量)和POSIX IPC(消息队列,共享内存,信号量,互斥量,条件变量,读写锁)
管道
我们把一个进程连接到另一个进程的一个数据流称之为管道,分为匿名管道和命名管道
匿名管道
1 |
|
读写规则:
1.当没有数据可读时,
O_NONBLOCK disable:read阻塞,即进程暂停执行一直到有数据来为止
O_NONBLOCK enable:read调用返回-1
2.当管道满时,
O_NONBLOCK disable:write调用阻塞直到有进程读走数据
O_NONBLOCK enable:调用返回-1
3.如果所有管道写端对应的文件描述符被关闭则read返回0
4.如果所有管道读端对应的文件描述符被关闭,则write操作会产生信号SIGPIPE,进而可能会导致write进程退出
管道特点:
1.只能用于具有共同祖先的进程之间进行通信(如父子进程)
2.管道提供流式服务
3.一般而言,进程退出则管道释放
4.管道是半双工的,数据只能向一个方向流动,需要双方进程通信时,需要建立起两个管道
命名管道
管道应用的一个限制就是只能在具有共同祖先的进程间进行通信,如果我们想在不相关的进程之间交换数据可以使用FIFO文件来做这项工作,它经常被称为命名管道,它其实是一种特殊类型的文件。
1 | 命名管道可以从命令行上创建: |
命名管道打开规则:
1.如果当前打开操作是为读而打开FIFO时:
O_NONBLOCK disable:阻塞直到有相应进程为写而打开FIFO
O_NONBLOCK enable:立即返回成功
2.如果当前打开操作是为写打开FIFO时:
O_NONBLOCK disable:阻塞直到有相应的进程为读打开FIFO
O_NONBLOCK enable:立即返回失败
区别
1.匿名管道由pipe函数创建并打开
2.命名管道由mkfifo创建,打开用open
消息队列
消息队列提供了一个从一个进程向另外一个进程发送一块数据的方法,每个数据块都被认为是一个类型,接收者进程接收的数据块可以有不同的类型值,虽然也有不足:1.每个消息的最大长度是有上限的 2.每个消息队列的总的字节数是有上限的 3.系统上消息队列的总数也有上限
内核为每个IPC对象维护了一个数据结构struct ipc_perm,用于标识消息队列,让进程知道当前操作的是哪个消息队列。每一个msqid_ds(消息队列结构)表示一个消息队列,并通过msqid_ds.msg_first、msg_last维护一个先进先出的msg链表队列,当发送一个消息到该消息队列时,把发送的消息构造成一个msg的结构对象,并添加msqid_ds.msg_first、msg_last维护的链表队列。
1.创建和访问消息队列
1 | int msgget(key_t key,int msgflg); |
2.消息队列的控制函数
1 | int msgctl(int msqid,int cmd,struct msqid_ds* buf); |
3.把像一条消息添加到消息队列中
1 | int msgsnd(int msqid,const void* msgp,size_t msgsz,int msgflag); |
4.从一个消息队列接收信息
1 | ssize_t msgrcv(int msgid,void* msgp,size_t msgsz,long msgtype,int msgflg); |
共享内存
共享内存区是最快的IPC形式,一旦这样的内存映射到共享它的进程的地址空间这些进程间数据传递不再涉及到内核,换句话说也就是进程不再通过执行进入内核的系统调用来传递彼此的数据。
1.创建共享内存
1 | int shmget(key_t key,size_t size,int shmflg); |
2.将共享内存段连接到进程地址空间
1 | void* shmat(int shmid,const void* shmaddr,int shmflg); |
3.将共享内存段与当前进程脱离
1 | int shmdt(const void* shmaddr); |
4.控制共享内存
1 | int shmctl(int shmid,int cmd,struct shmid_ds* buf); |
互斥量
1.初始化互斥量
1 |
|
2.销毁互斥量
1 | 注意:1.使用PTHREAD_MUTEX_INITIALIZER初始化的互斥量不需要销毁 |
3.互斥量加锁和解锁
1 | int pthread_mutex_lock(pthread_mutex_t* mutex); |
条件变量
当一个线程互斥的访问某个变量时,它可能发现在其他线程改变状态之前什么都做不了,例如一个线程访问队列时,发现队列为空他就只能等待,直到其他线程将一个节点添加到队列中。
1.初始化
1 | int pthread_cond_init(pthread_cond_t* cond,const pthread_condattr_t* attr); |
2.销毁
1 | int pthread_cond_destroy(pthread_cond_t* cond); |
3.等待条件满足
1 | int pthread_cond_wait(pthread_cond_t* cond,pthread_cond_t mutex); |
4.唤醒等待
1 | int pthread_cond_broadcast(pthread_cond_t* cond); //全部唤醒 |
为什么pthread_cond_wait需要互斥量?
1.条件等待是线程间同步的一种手段,如果只有一个线程,条件不满足就会一直等下去,所以必须要有另一个线程通过某些动作使条件满足,而条件不会无缘无故就满足了必然会牵扯到共享数据的变化,所以一定要用互斥锁来保证数据安全。
条件变量使用规范:
1.等待条件代码
1 | pthread_mutex_lock(&mutex); |
2.给条件发送信号代码
1 | pthread_mutex_lock(&mutex); |
信号量
1.初始化信号量
1 | #include <semaphore.h> |
2.销毁信号量
1 | int sem_destroy(sem_t* sem); |
3.等待信号量
1 | 功能:等待信号量,会将信号量的值减1 |
4.发布信号量
1 | 功能,发布信号量,表示资源使用完毕可以归还资源了。将信号量值加1 |
读写锁
本质是一种自旋锁。自旋锁与互斥量类似,它不是通过休眠使进程阻塞,而是在获取锁之前一直处于忙等(自旋)阻塞状态。用在以下情况:锁持有的时间短,而且线程并不希望在重新调度上花太多的成本。”原地打转”
写独占,读共享,写锁优先级较高
1.初始化
1 | int pthread_rwlock_init(pthread_rwlock_t* rwlock,const pthread_rwlockattr_t* attr); |
2.销毁
1 | int pthread_rwlock_destroy(pthread_rwlock_t* rwlock); |
3.加锁和解锁
1 | int pthread_rwlock_rdlock(pthread_rwlock_t* rwlock); |
三、进程线程
进程概念
首先从定义上讲,进程是执行中的一段程序,也就是说程序一旦被加载执行它就是一个进程。进程是资源分配的一个基本单位,也是调度运行的基本单位。
进程信息被放在一个叫做PCB(进程控制块)的数据结构中。而在Linx操作系统下的PCB是task_struct,其中的数据有:1.标识符,描述进程的唯一标识符用来区别与其他的进程 2.状态:任务状态,退出信号等 3.优先级:相对于其他进程的优先级 4.程序计数器:程序中即将被执行的下一条指令的地址 5.上下文数据:进程执行时处理器的寄存器中的数据。
进程的状态
有R运行状态,S睡眠状态(可中断睡眠),D磁盘休眠状态(不可中断睡眠状态),T停止状态,X死亡状态,Z僵死状态。而Z是一个比较特殊的状态,当进程退出并且父进程没有读取到子进程退出的返回代码时就会产生僵尸进程,僵尸进程会以终止状态保持在进程表中,并且会一直在等待父进程读取退出状态代码,所以说只要子进程退出父进程还在运行,但父进程没有读取子进程状态子进程就进入Z状态。而这样会造成很大的危害:进程的退出状态必须被维持下去,因为他要告诉父进程,可父进程一直不读取那么子进程就会一直处于Z状态,而维护退出状态是需要用数据维护的也属于进程的基本信息,所以保存在task_struct中,也就是说会造成内存资源的浪费。与之相对的还有一种叫做孤儿进程,父进程提前退出子进程就称之为孤儿进程,被1号init进行领养,也是被init进程回收。
进程创建
调用fork,当控制转移到内核中的fork代码后,内核分配新的内存块和内存数据结构给子进程,将父进程部分数据结构内容拷贝至子进程,添加子进程到系统进程列表中,fork返回,开始调度器调度。fork调用失败的原因:1.系统中有太多进程 2.实际用户的进程数超过了限制。与vfork的区别,vfork用于创建一个子进程,而子进程和父进程共享地址空间,fork的子进程具有独立的地址空间。而且vfork保证子进程先运行,在它调用exec或exit之后父进程才能被调度运行。
fork和vfork的区别
fork和vfork的区别:
fork( )的子进程拷贝父进程的数据段和代码段;vfork( )的子进程与父进程共享数据段
fork( )的父子进程的执行次序不确定;vfork( )保证子进程先运行,在调用exec或exit之前与父进程数据是共享的,在它调用exec或exit之后父进程才可能被调度运行。
vfork( )保证子进程先运行,在它调用exec或exit之后父进程才可能被调度运行。如果在调用这两个函数之前子进程依赖于父进程的进一步动作,则会导致死锁。
- 当需要改变共享数据段中变量的值,则拷贝父进程。
进程终止
分为正常终止和异常退出。正常终止是指(可通过echo $?查看进程退出码):1.从main函数返回 2.调用exit 3._exit void _exit(int status),虽然参数是int,但是实际只有低8位被父进程调用。exit函数也会调用exi(),在此之前还执行用户通过atexit或on_exit定义的清理函数,关闭所有打开的流,所有的缓冲数据均被写入。异常退出有ctrl+c信号终止
进程等待
子进程退出以后父进程如果不管不顾就会造成僵尸进程问题,进而导致内存泄露。所以父进程通过进程等待的方式回收子进程资源,获取子进程退出信息。(在Linux下,要想不产生僵尸进程还有一种方法:将SIGCHLD的默认处理动作改成SIG_IGN,这样fork出来的子进程在终止时会自动清理掉不会产生僵尸进程也不会通知父进程)
1 | //wait方法 |
线程概念
线程是单个进程中执行中的一条执行流,是CPU调度的基本单位。
1.线程是一种轻量级的进程,与进程相比,线程给操作系统带来创建、维护和管理的负担要轻,意味着线程的代价或开销比较小。
2.线程没有地址空间,线程包含在进程的地址空间中。线程上下文只包含一个堆栈、一个寄存器、一个优先权,线程文本包含在他的进程的文本片段中,进程拥有的所有资源都属于线程。所有的线程共享进程的内存和资源。 同一进程中的多个线程共享代码段,数据段和堆的。但是每个线程拥有自己的栈段和寄存器,栈段又叫运行时段,用来存放所有局部变量和临时变量。
3.子进程不对任何其他子进程施加控制,进程的线程可以对同一进程的其它线程施加控制。子进程不能对父进程施加控制,进程中所有线程都可以对主线程施加控制(进程内的任何线程都可以销毁、挂起、恢复和更改其它线程的优先权)
4.父和子进程使用进程间通信机制,同一进程的线程通过读取和写入数据到进程变量来通信。
线程的缺点:1.缺乏访问控制 2.性能损失(线程数量远大于CPU数时,会因为资源总数固定而导致额外的同步和调度开销)
线程共享:1.虚拟地址空间 2.文件状态表 3.信号处理方式 4.用户ID和组ID 5.当前工作目录
线程间独有:1.栈 2.组寄存器 3.线程id 4.调度优先级
1 | 在Linux下,目前线程的实现是使用Native POSIX Thread Libaray(NPTL)。在这种实现下,线程又被称之为轻量级进程(Light Weighted Process)。每一个用户态的线程,在内核中都对应一个调度实体,也拥有一个属于自己的进程描述符。没有线程之前,每一个进程对应内核中的一个进程描述符,对应一个进程ID。引入线程之后,每个线程有自己的进程描述符,而getpid函数时需要返回相同的进程ID,于是引入了线程组概念。多线程的进程,又被称为线程组。线程组ID等于主线程ID。ps -eLf | head -l && ps -eLf |greap a.out |grep -v greap(L选项会显示LWP:线程ID,即调用gettid()系统调用的返回值 NLWP:线程组内线程个数) |
多进程与多线程区别
多进程优点:
1.多进程每个进程相互独立,子进程崩溃也不影响主程序的稳定性
2.通过增加CPU就容易扩充性能
3.可以尽量减少线程加锁/解锁的影响,极大的提高性能
4.每个子进程都有2G的地址空间和相关资源,总体能达到的性能上限非常大
多进程缺点:
1.逻辑控制复杂,需要与主进程交互
2.多进程调度开销比较大
多线程优点:
1.无需跨边界传输
2.逻辑控制较简单
3.所有线程共享虚拟地址空间
4.线程方式消耗的总资源比进程方式少
多线程缺点:
1.每个线程与主程序共用地址空间,受限于2GB地址空间
2.线程之间加锁和线程同步比较麻烦
3.一个线程的崩溃可能会导致整个程序的不稳定
4.到达一定的线程数量之后即便再加CPU也不能提高性能
5.线程多了本身的调度会很麻烦,会消耗较多的CPU
四、网络
HTTP
请求报文格式:
方法 URL 版本号
请求头部
空行
请求正文
响应报文格式:
版本号 状态码 状态码描述
消息报头
空行
响应正文
常见方法
浏览器访问URL流程
1.打开浏览器输入网址,浏览器对用户输入的网址做初步的格式化检查
2.根据域名到DNS中进行IP查找,浏览器先查自己内存中的DNS Cache,如果有则直接返回,如果没有则查看本地的hosts文件
3.如果还是没有操作系统就向本地域名服务器发起请求查找本地的DNS缓存(基于UDP),如果有返回给操作系统然后操作系统返回给浏览器
4.如果依旧没有操作系统就会向DNS域名系统的根域名服务器发起请求得到自己想要的结果
5.当拿到服务器的IP地址后,DNS会把结果返回给浏览器
6.知道浏览器想要去拜访某一个IP地址后,浏览器使用TCP传输将http请求消息打包发送给服务端
7.服务端接收请求并返回响应(Web服务器解析请求,定位请求资源。服务器将资源复本写到TCP套接字,由客户端读取。)
8.释放TCP链接(四次挥手)
9.客户端解析资源数据
客户端首先解析状态行,查看表明请求是否成功的状态代码。然后解析每一个响应头,响应头告知以下为若干字节的HTML文档和文档的字符集。客户端读取响应数据,根据HTML的语法对其进行格式化,并在浏览器窗口显示。
GET与POST方法的区别
- GET把参数包含在URL中,POST通过正文传参
- 其用的传输层协议还是一样的
- GET因为是在URL中的,所以浏览器限制了其长度。在HTTP协议中,并没有对BODY(正文)和URL的长度进行长度限制,对URL限制的大多数是浏览器和服务器的原因。大多数浏览器通常都会限制url长度在2K个字节,而大多数服务器最多处理64K大小的url。超过的部分,恕不处理。浏览器就不用说了,服务器是因为处理长URL要消耗比较多的资源,为了性能和安全(防止恶意构造长URL来攻击服务器)考虑,会给URL限制长度
cookie和session
1.cookie,第一次登陆后服务器返回一些数据给浏览器,然后浏览器保存在本地,当该用户发送第二次请求的时候就会自动把上次请求存储的cookie数据自动的携带给服务器,服务器通过浏览器携带的数据就能判断当前用户是哪一个。但是cookie存放的数据有限,不同的浏览器有不同的存储限制,但一般不超过4K
2.session是存在服务器的一种用来存放用户数据的类HashTable结构,浏览器第一次发送请求时,服务器自动生成了一个HashTable和一个SessionID来唯一标识这个HashTable,并将其通过响应发送给浏览器。浏览器第二次发送请求时会将前一次服务器响应中的SessionID放在请求中发送到服务器上,服务器从请求中提取出SessionID并和保存的所有SessionID进行对比,找到这个用户所对应的HashTable。
HTTP和HTTPS的区别
HTTP协议和HTTPS协议区别如下:
1)HTTP协议是以明文的方式在网络中传输数据,而HTTPS协议传输的数据则是经过TLS加密后的,HTTPS具有更高的安全性
2)HTTPS在TCP三次握手阶段之后,还需要进行SSL 的handshake,协商加密使用的对称加密密钥
3)HTTPS协议需要服务端申请证书,浏览器端安装对应的根证书
4)HTTP协议端口是80,HTTPS协议端口是443
HTTPS优点:
1.HTTPS传输数据过程中使用密钥进行加密,所以安全性更高
2.HTTPS协议可以认证用户和服务器,确保数据发送到正确的用户和服务器
HTTPS缺点:
1.HTTPS握手阶段延时较高:由于在进行HTTP会话之前还需要进行SSL握手,因此HTTPS协议握手阶段延时增加
2.HTTPS部署成本高:一方面HTTPS协议需要使用证书来验证自身的安全性,所以需要购买CA证书;另一方面由于采用HTTPS协议需要进行加解密的计算,占用CPU资源较多,需要的服务器配置或数目高
TCP/UDP
区别
- UDP是无连接不可靠面向数据报的(应用层交给UDP多长的报文,UDP就原样发送既不会拆分也不会合并),最大数据长度是64k(16位源端口 16位目的端口 16位报文长度 16位UDP校验和)
- TCP是有连接可靠面向字节流的
保证可靠性的机制
连接管理机制
三次握手
主机A发送SYN=1,并随即产生一个序列号seq number=x,主机B通过SYN知道主机A要联机
主机B收到请求以后向主机A发送SYN+确认ACK,和确认号ack number = x+1,随机产生一个序列号seq=y,主机A收到确认好ack number后检查是否自己发出去的序列号seq number+1,以及ACK=1,如果正确,主机A会再发送seq+1,ACK给主机B,主机B收到后确认seq+1和ACK正确则建立连接成功。
四次挥手
客户端进程发出连接释放报文,并且停止发送数据。释放数据报文首部,FIN=1,其序列号为seq=u(等于前面已经传送过来的数据的最后一个字节的序号加1)。
服务器收到连接释放报文,发出确认报文,ACK=1和确认序号ack=u+1,并且带上自己的序列号seq=v,此时,服务端就进入了CLOSE-WAIT(关闭等待)状态。服务器通知高层的应用进程,客户端向服务器的方向就释放了,这时候处于半关闭状态,即客户端已经没有数据要发送了,但是服务器若发送数据,客户端依然要接受。这个状态还要持续一段时间,也就是整个CLOSE-WAIT状态持续的时间。
客户端收到服务器的确认请求后,此时,客户端就进入FIN-WAIT-2(终止等待2)状态,等待服务器发送连接释放报文。
服务器将最后的数据发送完毕后,就向客户端发送连接释放报文,FIN=1,服务器很可能又发送了一些数据,假定此时的序列号为seq=w。
客户端收到服务器的连接释放报文后,必须发出确认,ACK=1,ack=w+1,而自己的序列号是seq=u+1,此时,客户端就进入了TIME-WAIT(时间等待)状态。注意此时TCP连接还没有释放,必须经过2∗MSL(最长报文段寿命)的时间后才能进入CLOSED状态
TIME_WAIT
主动断开的一方会处于这个状态
CLOSE_WAIT(上边已经提到了)
确认应答机制
超时重传机制
流量控制
接收端将自己可以接收的缓冲区大小放入TCP首部中的“窗口大小”字段,通过ACK通知发送端,接受端一旦发现自己的缓冲区快满了就会把窗口大小设置成一个更小的值通知给发送端,发送端接收到这个值以后会减慢自己的发送速度,如果接收端缓冲区满了就会将窗口置为0,这是发送方不再发送数据,但是要定期发送一个窗口探测数据段,使接收端把窗口大小告诉发送端(窗口大小为16位,最大65535,但是选项中还有一个窗口扩大因子M,所以实际窗口大小是窗口字段的值左移M位)
拥塞控制
拥塞控制是防止过多的数据注入网络,使得网络中的路由器或者链路过载。流量控制是点对点的通信量控制,而拥塞控制是全局的网络流量整体性的控制。发送双方都有一个拥塞窗口——cwnd。
1、慢开始
最开始发送方的拥塞窗口为1,由小到大逐渐增大发送窗口和拥塞窗口。每经过一个传输轮次,拥塞窗口cwnd加倍。当cwnd超过慢开始门限,则使用拥塞避免算法,避免cwnd增长过大。
2、拥塞避免
每经过一个往返时间RTT,cwnd就增长1。
在慢开始和拥塞避免的过程中,一旦发现网络拥塞,就把慢开始门限设为当前值的一半,并且重新设置cwnd为1,重新慢启动。(乘法减小,加法增大)
3、快重传
接收方每次收到一个失序的报文段后就立即发出重复确认,发送方只要连续收到三个重复确认就立即重传(尽早重传未被确认的报文段)。
4、快恢复
当发送方连续收到了三个重复确认,就乘法减半(慢开始门限减半),将当前的cwnd设置为慢开始门限,并且采用拥塞避免算法(连续收到了三个重复请求,说明当前网络可能没有拥塞)。
提高性能的机制
滑动窗口
一次发送多条数据,不必每条等待到后再发下一条
在这种情况下,如果发生了丢包:
1.ACK包丢了,那么不碍事,会通过后面的ACK来确认
2.数据包丢了的话(假设已经到了700),发送端会一直收到1001这样的ACK,表示我要1001,如果发送端主机连续3次收到这个ACK,就会把1001~2000重新发送,这个时候如果收到了1001,那么再次返回的ACK就是7001了(2001~7000已经放在接收端的接收缓冲区了)这就是快重传
延迟应答
每隔N个包就应答一次,超过最大延迟时间就应答一次(一般N为2,最大延迟时间为200ms)
捎带应答
IP
ARP
MAC
DNS
ICMP
虽然它是基于IP协议工作的,但是并不是传输层的功能,依旧把它归结为网络层协议,对于一个新搭建的网络往往要先验证网络是否畅通,但是IP协议不提供可靠传输,如果数据丢包了IP协议并不能通知传输层是否丢包以及丢包的原因,所以使用ICMP协议。
主要功能有:确认IP包是否成功到达目标地址,通知在发送过程中IP包被丢弃的原因
ping
基于ICMP,是在网络层,而端口号是在传输层
traceroute
打印出可执行主机一直到目标主机之间经历多少路由器
NAT技术
主要是结局IP地址不足的问题,它能够将私有IP对外通信时转为全局IP,也就是一种将私有IP和全局IP相互转化的方法,全局IP要求唯一但是私有IP不需要,在不同的局域网中出现相同的私有IP是不影响的。
如果局域网中多个主机都访问同一个外网服务器,那么对于服务器返回的数据目的IP都是相同的,如何判断转发给哪个局域网的主机呢?这就引出了NAPT技术,使用IP+port来建立这个关联,这个关联关系是由NAT路由器自动维护的。例如在TCP的情况下,建立连接时就会生成这个表项断开连接时就会删除这个表项
缺陷:1.转换表的生成和销毁都要额外的开销 2.无法从NAT外部向内部服务器建立连接
NAT和代理服务器
代理服务器看起来和NAT设备有一点像,客户端向代理服务器发送请求,代理服务器将请求转发给真正请求的服务器,服务器返回结果后,代理服务器又把结果回传给客户端
区别
1.从应用上讲,NAT是网络基础设备,解决的是IP不足的问题,代理服务器则是更贴近具体应用
2.从底层上讲,NAT是工作在网络层,代理服务器是在应用层
3.从部署位置上看,NAT一般集成在防火墙,路由器等硬件设备上,代理服务器则是一个软件程序,需要部署在服务器上
五、IO模型
阻塞 非阻塞 信号驱动(通知应用程序合适可以开始拷贝数据) 多路复用 异步(数据拷贝完成后再通知应用程序)
同步通信VS异步通信
同步就是在发出一个调用时在没有得到结果之前该调用就不返回,但是一旦调用返回那么就得到了返回值,也就是说由调用者主动等待这个调用的结果
异步刚好相反,调用在发出之后,这个调用就直接返回了,所以没有返回结果,也就是说当一个异步过程调用发出后,调用者不会立刻得到结果,而是在调用发出后被调用者通过状态、通知来通知调用者,或者通过回调函数
同步访问VS互斥访问
同步是指散布在不同任务之间的若干程序片断,它们的运行必须严格按照规定的某种先后次序来运行,这种先后次序依赖于要完成的特定的任务
互斥是散布在不同任务之间的若干程序片断,当某个任务运行其中一个程序片段时,其它任务就不能运行它们之中的任一程序片段,只能等到该任务运行完这个程序片段后才可以运行。
多路复用
select函数
1 | 函数原型:int select(int nfds,fd_set *readfds,fd_set *writefds, |
fd_set接口
1 | void FD_ZERO(fd_set* set); //清除描述词组set的全部位 |
所以常见的代码段是这样的
1 | fd_set readset; |
poll函数
1 | int poll(struct pollfd* fds,nfds_t nfds,int timeout); |
epoll函数
1 | 1.int epoll_create(int size); |
红黑树+就绪队列+回调机制,所以处理就绪事件的事件复杂度为O(1)
两种工作方式LT(水平触发,默认)和ET(边缘触发)
LT模式
当epoll检测到socket上事件就绪时可以不立刻进行处理或者只处理一部分,也就是说假设有2K的数据,只读了1K数据,缓冲区还剩1K数据,在第二次调用epoLL_wait时,epoll_wait仍然会立刻返回并通知socket读事件就绪,直到缓冲区上所有的数据都被处理完,epoll_wait才不会立刻返回。
支持阻塞读写和非阻塞读写
ET模式
当epoll检测到socket上事件就绪时必须立刻处理。如上所述,虽然只读了1K的数据,缓冲区还剩1K的数据,在第二次调用epoll_wait的时候不会再立即返回了。也就是说ET模式下文件描述符上的事件就绪后只有一次处理机会。也就是说一直读到read的返回值小于请求值,或者遇到EAGAIN错误
只支持非阻塞的读写
为什么要是非阻塞的:当数据就绪时需要read,直到出错或完成为止。假如当前fd为阻塞,那么在读完缓冲区后如果对端没有关闭写端那么该read函数会一直阻塞,影响到后续的fd及后续逻辑
select与epoll的区别
1.select可监控的文件描述符sizeof(fdset)的值,一般32位机默认是1024个,64位机默认是2048。而epoll虽然有,但是却很大,1G内存的机器上可以打开10万左右的连接,2G内存的机器可以打开20万左右的连接
2.select每次需要把fd集合从用户态传递到内核态,开销较大,而epoll通过内核和用户空间共享一块内存来实现
3.select是遍历,所以随着FD的增加会造成遍历速度慢的“线性下降性能问题”,而epoll使用的是回调机制,只有活跃的socket才会主动调用callback,所以在活跃socket较少的情况下,使用epoll没有前面两者的线性下降的性能问题,但是所有socket都很活跃的情况下,可能会有性能问题
4.表面上看epoll的性能最好,但是在连接数少并且连接都十分活跃的情况下,select和poll的性能可能比epoll好,毕竟epoll的通知机制需要很多函数回调