总结了语法、数据结构、常见排序算法、操作系统、网络五大块常见校招面试题。欢迎补充与修正。
★★语法知识★★
一、C++与C的区别
面向对象与面向过程的区别
面向过程
面向过程编程是就分析出解决问题题的步骤,然后把这些步骤一步一步的实现,使用的时候一个一个的一次调用就可以了。
面向对象
面向对象编程就是把问题分解成各个对象,建立对象的目的不是为了完成一个步骤,而是为了描述某个市委在整个解决问题的步骤中的行为。
举个例子(玩五子棋)
使用面向过程的思想来考虑就是:开始游戏,白棋先走、绘制画面、轮到黑子、绘制画面、判断输赢、重复之前的过程,输出最终结果。
使用面向对象的思想来考虑就是:玩家系统、棋盘系统、判定系统、输出系统。
面向对象就是高度的将实物抽象化,也就是功能的划分,面向过程就是自顶向下编程,也就是步骤的划分
具体语言的区别
1、关键字不同
C99有32个关键字
C++98有63个关键字
一些关键字细微的区别
1、struct:在C语言猴子那个struct定义的变量中不能由函数,在C++中可以有函数
2、malloc:malloc的返回值是void*,在C语言中可以赋值给任意类型的指针,在C++中必须要进行强制类型转换,否则会报错。
3、class和struct:class是对struct的扩展,struct的默认访问权限是public,而class的默认访问全显示private
2、后缀名不同
C源文件的后缀是.c,C++源文件的后缀是.cpp,在VS中,如果在创建源文件的时候什么都不给,默认的就是.cpp
3、返回值不同
在C语言中,如果一个函数没有指定返回值得类型,默认的返回值为int类型,并且会返回一个随机数,一般为0xCCCCCCCC,C++中如果一个函数没有返回值,则必须要指定为void,否则编译不会通过。
4、参数列表不同
在C语言中,函数没有指定参数列表的时候,默认可以接受多个参数,但是不支持无名参数,在C++中,因为严格的参数类型检测,没有参数列表的函数,默认为void,不接受任何参数,但是他支持无名参数。
5、缺省参数
缺省参数的声明或定制函数时的参数指定一个默认值。在调用该函数时,如果没有指定实参则可以采用该默认值,则使用指定的参数。但是这在C语言中是不支持的。
6、函数重载
函数重载是函数的一种特殊情况,指的是在同一作用域中,声明几个功能类似的同名函数,这些同名函数的形参列表必须不同,或者是在类中使用const修饰的函数和没有使用const修饰的函数,常用来处理实现功能类似但是数据类型不同的问题。在C语言中没有函数重载,是因为C语言对函数名的修饰只是在函数名前添加一个下划线,但是C++对函数名的修饰会添加上该函数的返回值和参数列表。
7、标准输入输出
在C语言中使用的是scanf()和printf()来实现的,但是C++中是使用类来实现的。cin、cout对象,他们本身并不是C++语言的组成部分,在C++中不提供内在的输入输出运算符,这时与其他语言不相同的地方,他的输入和输出是通过C++中的类来实现的,cin和cout都是这些类的实例,是在C++语言的外部实现的。
8、动态内存管理
C语言使用的是malloc/free函数,C++在此基础上还添加了new/delete两个关键字。
9、const修饰的变量
C语言中const修饰的变量不可以用在定义数组时的大小,并且在定义的时候可以不设定初始值,但是在C++中修饰的变量在定义的时候必须要设定初始值,并且可以用在定义数组的大小,,如果不进行取地址或解引用的话,是存放在符号表中的,不开辟内存。
二、C++面向对象
面向对象的特点
维护性、复用性、扩展性。
封装体现了维护性,按照信息屏蔽的原则,把对象的属性和操作结合在一起,构成一个独立的对象。通过限制对属性和操作的访问权限,可以将属性隐藏在对象内部,对外提供一定的接口,在对象之外只能通过接口对对象进行操作,这样增加了对象的独立性,外部的对象不能直接操作对象的属性,只能使用对象提供的服务,从而保证了数据的可靠性。
继承体现了复用性,当定义了一个类后,又需要定义一个新类但是这个类与原来的类相比只是增加或修改了部分属性和操作,这时可以引用原来的类派生出新的类,新类中只需要描述自己特有的属性和操作,这样就大大的简化了对问题的描述,提高了程序的复用性。
多态体现了扩展性,多态就是一个接口多种实现,当需要添加新的模块功能的时候,不需要改变原来的功能,只需要添加新的即可,这样就实现了扩展性。
面向对象的优点
易于维护:可读性比较高,如果有改变的需求,由于继承的存在,维护也只是局部模块,所以说维护起来是非常方便和较低成本的。
质量高:可重用现有的,在以前的项目的领域中一杯测试过的类使系统满足业务需求并具有较高的质量。
效率高:在软件开发时,根据设计的需要对现实事件的事务进行抽象,产生类。这样结局问题的方法接近于日常生活和自然的思考方式,必定会提高软件开发的效率和质量。
1、c语言是面向过程的结构化 语言,易于调试和维护。
2、表现能力和处理能力极强,可以直接访问内存的物理地址。
3、C语言实现了对硬件的编程操作,也适合于引用软件的开发。
概述
封装可以使得代码模块化,继承可以扩展已经存在的代码,他们的目的是为了代码重用。而多态的目的是为了接口重用。
封装
封装是设计类的一个基本原理,是将抽象得到的数据和行为相结合,形成一个有机的整体,也就是将数据与对数据进行的操作进行有机的结合,从而形成类,其中的数据和函数都是类的成员。
继承
如果B是继承了A,那么就把这个B称为是A的子类,把A称为B的父类。继承可以使子类具有父类的各种属性和方法和方法,就不用再次编写相同的代码。子类继承父类的同时,可以重新定义某些属性,并重定义其中的一些方法,也就是隐藏父类中原有的属性和方法,使其获得于父类不同的功能。
单继承
单继承就是一个派生类继承一个基类。单继承的继承规则为:所有继承下来的基类成员变量存放在派生类添加的成员变量之前,也就是基类的成员变量的内存地址低于派生类的内存地址,可以看做是将基类的内存空间进行了一次拷贝,并且在拷贝的内存空间后面加上派生类自己的成员。
多继承
菱形继承
菱形继承存在的问题就是数据二义性,相应的解决方案就是虚拟继承。多继承的继承规则是:以单继承的方式按照父类声明的顺序继承每个父类,可以看做是按照声明的顺序将每个父类的内存空间拷贝到一起,并且在后面添加上派生类自己的成员。
虚拟继承
虚拟继承是解决C++中多重继承问题的一种手段,从不同途径继承来的同一基类,会在子类中存在多份拷贝。这样会存在两个问题,一个是对于存储空间的浪费,还有就是数据的二义性。虚拟继承就是针对这两个问题出现的,虚拟继承的底层实现原理与编译器相关,一般通过虚基类和虚基表实现,每个虚继承的子类都有一个虚基类指针和虚基表,当虚拟继承的子类被当做父类继承时,虚基类指针也会被继承。实际上vbptr指的是虚基类表指针,这个指针指向虚基类表,在虚基类表中记录了虚基类与本类的偏移地址,通过偏移地址,可以找到虚基类的成员,虚拟继承和虚函数有着相同之处,都是利用了虚指针和虚表,虚基类表中存储的是虚基类相对于直接继承类的便宜,而虚函数表中存储的时候虚函数的地址。
继承中的访问权限
父类的私有成员在子类中无论以什么方式继承都是不可见的,这个的不可见指的是private成员仍然被继承到了子类中,但是在语法上限制子类对象不管实在类内还是在类外都是无法访问它的。
子类以公有方式继承父类时,父类的成员在子类中保持原有的属性。
子类以保护方式继承父类时,父类中的公有成员在子类中成了保护成员。
子类以私有方式继承父类时,父类中所有成员在子类中都是私有的。
使用class时默认的继承方式时私有的,使用struct时则默认的继承方式是共有的。
还有一点就是友元是类级别的,不存在继承的问题,也就是子类不能继承父类的友元关系。
多态
多态可以简单的概括为“一个接口,多种方法”,字面意思是多种形态。多态分为静态多态和动态多态。
静态多态
静态多态也称作静态绑定或者是早绑定。地址的绑定是编译器在编译的时候完成的,编译器根据函数实参的类型,可以推断出要调用那个函数,这里可能会进行隐式的类型转换,如果有对应的函数就调用了,否则编译报错。静态多态又分为函数重载和泛型编程。
函数重载
函数重载是在相同的作用域中,只有函数的名称相同,参数个数或参数类型不同。编译器根据函数不同的参数表,对同名函数的名称修饰,然后这些同名函数就成了不同的函数。这个在C语言中是不支持的,因为c语言中对函数的名称修饰较为简单,在VS2013编译器中,c语言对函数名称修饰的处理只关注到了函数名,对函数名的修饰只是简单的在函数名前添加_,而c++语言除了函数名,还关注了函数的参数,对函数名的修饰时候要加上参数,通过对函数名称的修饰不同,编译器调用函数时所找的符号就不同。
泛型编程
泛型编程指的是编写独立于特定类型的代码,泛型编程在C++中的主要是实现为函数模板和类模板。泛型编程的特性有如下几点:
1、函数模板并不是真正的函数,他只是C++编译器生成具体的函数的一个模子。
2、函数模板本身并不生成函数,实际生成的函数是替换函数模板的那个函数,这种替换在编译期就绑定了。
3、函数模板不是只编译一份满足多重需要,而是为每一种替换他的函数编译一份。
4、函数模板不允许自动类型转换。
5、函数模板不可以设置默认模板参数。
动态多态
C++中的动态多态是基于虚函数的。对于相关的对象类型,确定他们之间的一个共同的功能集,然后在父类中把这些共同的功能声明为多个公共的虚函数接口。各个子类重写这些虚函数,完成具体的功能。操作函数通过指向基类的引用或指针来操作这些对象,对虚函数的调用会自动绑定到实际提供的子类对象上去。
虚函数
虚函数之所以叫做虚函数,是因为他的推迟联编和动态联编,一个类的虚函数的调用并不是在编译的时候确定的,而是在运行的时候确定的,虚函数通过基类的指针或者引用指向派生类对象实现多态。
纯虚函数
纯虚函数指的是在基类中声明的虚函数,但是没有在基类中定义,要求在任何派生类中都要定义自己实现方法。如果一个类中有纯虚函数,则这个类被称为抽象类,由于这个类的构建并不完成,所以不能生成一个对象。继承了抽象类的派生类必须要将纯虚函数实现,否则同样是抽象类,不能生成对象。
虚函数表
在C++中虚函数通话四通过虚函数表来实现的,这个表中存放的是虚函数的地址,他是属于类的,不属于某个具体的对象,在一个类中只有一个虚表,所有对象共享同一份虚表。为了指定对象的虚表,在对象构造的时候就在对象的内部包含了虚表指针_vfptr,一般是放在头部。
关于虚函数表有两种情况是要分清楚的,多继承和多重继承中的虚表是不一样的。
多继承指的是有一个子类继承了两个基类,比如说有A,B,C三个类,在A和B类中都有虚函数,C类依次继承了A类和B类,这时候C类中的虚表就有了A类和B类两个虚表,并且C类中的虚表指针是以A类虚表地址为基础的,如果想要获取到B类虚表的地址可以让指针向后偏移A类的大小或者给出一个B类的指针指向C类对象发生一个天然的转换,需要注意的是在C类中的重写的虚函数会覆盖A类和B类中的同名虚函数,如果C类中的虚函数在A类和B类中没有,就添加到A类的虚函数表中,但是A类指针不可以调用,如果是只在A类或者B类中有的虚函数,在C类中没有,那么只能是拥有虚函数的父类和C类可以调用。
多重继承就是B类继承了A类,C类继承了B类,在B类中的重写的虚函数会在虚函数表中覆盖A类的同名虚函数,并将B类新添加的虚函数放在B类虚函数表的末尾,C类也是如此,C类的虚表是从B类中继承的,在C类中的重写的虚函数会在虚函数表中覆盖B类的同名虚函数,并将C类新添加的虚函数放在C类虚函数表的末尾。
静态多态多态的比较
静态多态
优点
1、静态多态通过模板编程为C++带来了泛型设计的概念,比如STL。
2、静态多态是在编译期完成的,所以效率很高,编译器可以对其进行优化。
缺点
由于模板是实现静态多态,所以模板的不足也是静态多态的劣势,比如调试困难、编译耗时、代码膨胀。
动态多态
优点
1、实现与接口分离,可复用。
缺点
1、运行时绑定,导致一定程度上的运行时开销。
2、编译器无法对虚函数进行优化。
3、笨重的类继承体系,对接口的修改影响整个类层次。
不同点
本质不同:
早晚绑定,静态多态是在编译期决定的,由模板实现完成,而动态多态是在运行期间决定的,由继承、虚函数实现。
接口方式不同:
动态多态的接口是显式的,以函数名为中心,通过虚函数在运行期间实现,静态多态的接口是隐式的,以有效表达为中心,通过模板在编译期间完成。
应用形式上:
静多态是发散式的,让相同的实现代码应用于不同的场合。
动多态是收敛式的,让不同的实现代码应用于相同的场合。
思维方式上:
静多态是泛型式编程风格,它看重的是算法的普适性。
动多态是对象式编程风格,它看重的是接口和实现的分离度。
相同点
够可以实现多态性,静态多态/编译期多态,动态多态/运行期多态。
都可以是使接口和实现分离,一个是模板定义接口,类型参数定义实现,一个是基类定义接口,继承类负责实现。
虚函数面试题
- inliine函数可以实虚函数码?
不可以,因为inline函数没有地址,无法将他存放到虚函数表中。
- 静态成员可以是虚函数吗?
不能,因为静态成员函数中没有this指针,使用::成员函数的嗲用用方式无法访问虚函数表,所以静态成员函数无法放进虚函数表。
- 构造函数可以是虚函数吗?
不可以,因为对象中的虚函数指针是在对象构造的时候初始化的。
- 析构函数可以是虚函数吗?什么场景下析构函数是虚函数?
可以,最好将析构函数设置为虚函数,因为这样可以避免内存泄漏的问题,如果一个父类的指针指向了子类的的对象,如果子类对象中的虚函数没有写成多态的,他只会调用父类的析构函数,不会调用自己的析构函数,但是他创建对象的时候调用了构造函数,所以说就用子类的构造函数就应该该取调用他的析构函数,这样才能保证所有的必须释放的资源都是放了,才可以保证不会有内存泄漏。如果是多态的,就会先去调用子类的析构函数,然后再取调用父类的析构函数,这样子类和父类的资源就都可以释放。
- 对象访问普通函数快还是虚函数快?
如果是普通对象,是一样快的,如果是指针对象或者是引用对象,调用普通函数更快一些,因为构成了多态,运行时调用虚函数要先到虚函数表中去查找。这样然后才拿到韩式的地址,这样就不如直接可以拿到函数地址的普通函数快。
- 虚函数表时再什么阶段生成的?他存放在哪里?
虚函数时再编译阶段生成的,他一般存放再代码段,也就是常量区。
- 是否可以将类中的所有成员函数都声明称为虚函数,为什么?
虚函数是在程序运行的时候通过寻址操作才能确定真正要调用的的函数,而普通的成员函数在编译的时候就已经确定了要调用的函数。这个两者的区别,从效率上来说,虚函数的效率要低于普通成员函数,因为虚函数要先通过对象中的虚标指针拿到虚函数表的地址,然后再从虚函数表中找到对应的函数地址,最后根据函数地址去调用,而普通成员函数直接就可以拿到地址进行调用,所以没必要将所有的成员函数声明成虚函数。
- 虚函数表指针被编译器初始化的过程怎么理解的?
当类中声明了虚函数是,编译器会在类中生成一个虚函数表VS中存放在代码段,虚函数表实际上就是一个存放虚函数指针的指针数组,是由编译器自动生成并维护的。虚表是属于类的,不属于某个具体的对象,一个类中只需要有一个虚表即可。同一个类中的所有对象使用同一个虚表,为了让每个包含虚表的类的对象都拥有一个虚表指针,编译器在每个对象的头添加了一个指针,用来指向虚表,并且这个指针的值会自动被设置成指向类的虚表,每一个virtaul函数的函数指针存放在虚表中,如果是单继承,先将父类的虚表添加到子类的虚表中,然后子类再添加自己新增的虚函数指针,但是在VS编译器中我们通常看不到新添加的虚函数指针,是编译器故意将他们隐藏起来,如果是多继承,在子类中新添加的虚函数指针会存放在第一个继承父类的虚函数表中。
- 多态的分类?
静态绑定的多态的是通过函数的重载来实现的。动态绑定的多态是通过虚函数实现的。
- 为什么要引入抽象类和纯虚函数?
为了方便使用多态特性,在很多情况下由基类生成对象是很不合理的,纯虚函数在基类中是没有定义的,要求在子类必须加以实现,这种包含了纯虚函数的基类被称为抽象类,不能被实例化,如果子类没有实现纯虚函数,那么它他也是一个抽象类。
- 虚函数和纯虚函数有什么区别?
从基类的角度出发,如果一个类中声明了虚函数,这个函数是要在类中实现的,它的作用是为了能让这个函数在他的子类中能被重写,实现动态多态。纯虚函数,只是一个接口,一个函数声明,并没有在声明他的类中实现。对于子类来说它可以不重写基类中的虚函数,但是他必须要将基类中的纯虚函数实现。虚函数既继承接口的同时也继承了基类的实现,纯虚函数关注的是接口的统一性,实现完全由子类来完成。
- 什么是多态?他有什么作用?
多态就是一个接口多种实现,多态是面向对象的三大特性之一。多态分为静态多态和动态多态。静态多态包含函数重载和泛型编程,进程多态是程序调用函数,编译器决定使用哪个可执行的代码块。静态多态是由继承机制以及虚函实现的,通过指向派生类的基类指针或者引用,访问派生类中同名重写成员函数。堕胎的作用就是把不同子类对象都当作父类来看,可以屏蔽不同子类之间的差异,从而写出通用的代码,做出通用的编程,以适应需求的不断变化。
三、虚函数和纯虚函数
概述
虚函数,它虚就虚在所谓的“推迟联编”和“动态联编”上,一个类虚函数的调用并不是在编译时刻确定的,而是在运行的时候被确定。由于编写代码的时候并不能确定被调用的是基类函数还是那个派生类的函数,所以被称为虚函数。
虚函数只能借助指针或引用来达到多态的效果。常用的方式是基类指针或引用指向子类的对象。当有多个子类的继承时,可以统一用父类指针来表示各子类对象,但事实上所指的对象具体是哪一个,或者调用的函数是哪个子类中的函数,要在运行的时候才知道,这就实现了多态。
class parent
{public:vritual void fun(){cout <<"parent::fun" << endl;}
};class child : public parent
{public:void fun(){cout << "child :: fun" << endl;}
};int main()
{parent *a = new child;a->fun(); //这里的指针虽然是parent类型,但是指向的是child的fun函数。构成了多态。return 0;
}
语法
virtual void fun()=0; //纯虚函数
virtual void fun(); //虚函数
纯虚函数
纯虚函数是指在基类中声明的虚函数,并没有在基类中定义,要求在任何派生类中都要定义自己的实现方法。在类中有纯虚函数的类被称为抽象类,由于他的构建并不完整,所以不能用抽象类来生成对象。继承了抽象类的派生类必须要将纯虚函数实现,否则同样是抽象类,不能生成对象。
虚函数表
在C++中虚汗是是通过虚函数表来实现的(以下称为虚表),在这张表中存放的是虚函数的地址,它是属于类的,不属于某个具体的对象,一个类中只有一个虚表,在同一个类中的所有对象都是用类中唯一这个虚表。为了指定对象的虚表,在对象构造的时候就在对象内部包含了虚表指针。为此编译器在类中添加了一个*_vptr来指向虚表,并且这个指针的值会自动被设置为指向该类的虚表。在C++编译器中,虚表指针在存放在每个对象的头四个字节,并且虚函数表的末尾是以空指针结束。
关于虚函数表有两种情况是要分清楚的,多继承和多重继承这两种继承中的虚表是不一样的。
多继承指的是有一个子类继承了两个基类,比如说有A,B,C三个类,在A和B类中都有虚函数,C类依次继承了A类和B类,这时候C类中的虚表就有了A类和B类两个虚表,并且C类中的虚表指针是以A类虚表地址为基础的,如果想要获取到B类虚表的地址可以让指针向后偏移A类的大小或者给出一个B类的指针指向C类对象发生一个天然的转换,需要注意的是在C类中的重写的虚函数会覆盖A类和B类中的同名虚函数,如果C类中的虚函数在A类和B类中没有,就添加到A类的虚函数表中,但是A类指针不可以调用,如果是只在A类或者B类中有的虚函数,在C类中没有,那么只能是拥有虚函数的父类和C类可以调用。
多重继承就是B类继承了A类,C类继承了B类,在B类中的重写的虚函数会在虚函数表中覆盖A类的同名虚函数,并将B类新添加的虚函数放在B类虚函数表的末尾,C类也是如此,C类的虚表是从B类中继承的,在C类中的重写的虚函数会在虚函数表中覆盖B类的同名虚函数,并将C类新添加的虚函数放在C类虚函数表的末尾。
虚函数面试题
- inliine函数可以实虚函数码?
不可以,因为inline函数没有地址,无法将他存放到虚函数表中。
- 静态成员可以是虚函数吗?
不能,因为静态成员函数中没有this指针,使用::成员函数的嗲用用方式无法访问虚函数表,所以静态成员函数无法放进虚函数表。
- 构造函数可以是虚函数吗?
不可以,因为对象中的虚函数指针是在对象构造的时候初始化的。
- 析构函数可以是虚函数吗?什么场景下析构函数是虚函数?
可以,最好将析构函数设置为虚函数,因为这样可以避免内存泄漏的问题,如果一个父类的指针指向了子类的的对象,如果子类对象中的虚函数没有写成多态的,他只会调用父类的析构函数,不会调用自己的析构函数,但是他创建对象的时候调用了构造函数,所以说就用子类的构造函数就应该该取调用他的析构函数,这样才能保证所有的必须释放的资源都是放了,才可以保证不会有内存泄漏。如果是多态的,就会先去调用子类的析构函数,然后再取调用父类的析构函数,这样子类和父类的资源就都可以释放。
- 对象访问普通函数快还是虚函数快?
如果是普通对象,是一样快的,如果是指针对象或者是引用对象,调用普通函数更快一些,因为构成了多态,运行时调用虚函数要先到虚函数表中去查找。这样然后才拿到韩式的地址,这样就不如直接可以拿到函数地址的普通函数快。
- 虚函数表时再什么阶段生成的?他存放在哪里?
虚函数时再编译阶段生成的,他一般存放再代码段,也就是常量区。
- 是否可以将类中的所有成员函数都声明称为虚函数,为什么?
虚函数是在程序运行的时候通过寻址操作才能确定真正要调用的的函数,而普通的成员函数在编译的时候就已经确定了要调用的函数。这个两者的区别,从效率上来说,虚函数的效率要低于普通成员函数,因为虚函数要先通过对象中的虚标指针拿到虚函数表的地址,然后再从虚函数表中找到对应的函数地址,最后根据函数地址去调用,而普通成员函数直接就可以拿到地址进行调用,所以没必要将所有的成员函数声明成虚函数。
- 虚函数表指针被编译器初始化的过程怎么理解的?
当类中声明了虚函数是,编译器会在类中生成一个虚函数表VS中存放在代码段,虚函数表实际上就是一个存放虚函数指针的指针数组,是由编译器自动生成并维护的。虚表是属于类的,不属于某个具体的对象,一个类中只需要有一个虚表即可。同一个类中的所有对象使用同一个虚表,为了让每个包含虚表的类的对象都拥有一个虚表指针,编译器在每个对象的头添加了一个指针,用来指向虚表,并且这个指针的值会自动被设置成指向类的虚表,每一个virtaul函数的函数指针存放在虚表中,如果是单继承,先将父类的虚表添加到子类的虚表中,然后子类再添加自己新增的虚函数指针,但是在VS编译器中我们通常看不到新添加的虚函数指针,是编译器故意将他们隐藏起来,如果是多继承,在子类中新添加的虚函数指针会存放在第一个继承父类的虚函数表中。
- 多态的分类?
静态绑定的多态的是通过函数的重载来实现的。动态绑定的多态是通过虚函数实现的。
- 为什么要引入抽象类和纯虚函数?
为了方便使用多态特性,在很多情况下由基类生成对象是很不合理的,纯虚函数在基类中是没有定义的,要求在子类必须加以实现,这种包含了纯虚函数的基类被称为抽象类,不能被实例化,如果子类没有实现纯虚函数,那么它他也是一个抽象类。
- 虚函数和纯虚函数有什么区别?
从基类的角度出发,如果一个类中声明了虚函数,这个函数是要在类中实现的,它的作用是为了能让这个函数在他的子类中能被重写,实现动态多态。纯虚函数,只是一个接口,一个函数声明,并没有在声明他的类中实现。对于子类来说它可以不重写基类中的虚函数,但是他必须要将基类中的纯虚函数实现。虚函数既继承接口的同时也继承了基类的实现,纯虚函数关注的是接口的统一性,实现完全由子类来完成。
- 什么是多态?他有什么作用?
多态就是一个接口多种实现,多态是面向对象的三大特性之一。多态分为静态多态和动态多态。静态多态包含函数重载和泛型编程,进程多态是程序调用函数,编译器决定使用哪个可执行的代码块。静态多态是由继承机制以及虚函实现的,通过指向派生类的基类指针或者引用,访问派生类中同名重写成员函数。堕胎的作用就是把不同子类对象都当作父类来看,可以屏蔽不同子类之间的差异,从而写出通用的代码,做出通用的编程,以适应需求的不断变化。
四、引用和指针的区别
指针
对于一个类型T,T* 就是一个指向T的指针类型,也就是一个T*类型的变量能够保存一个T对象的地址,而类型T可以添加一些限定词,如const、volatile等等。
volatile提醒编译器它后面所定义的变量随时有可能改变。精确地说就是,遇到这个关键字声明的变量,编译器对访问该变量的代码就不再优化,从而可以提供对特殊地址的稳定访问;如果不使用volatile,则编译器对所声明的语句进行优化。
<1>中断服务程序中修改的供其他程序检测的变量需要加volatile;
<2>多任务环境下各任务间共享的标志应该加volatile;
<3>多存储器映射的硬件寄存器通常也要加volatile,因为每次对它的读写都可能有不同意义。
需要注意的是:频繁地使用volatile很可能会增加代码尺寸和降低代码性能,因此要合理地使用volatile。
引用
引用就是一个对象的别名,主要用于函数参数和返回值类型,类型+&+变量名 = 被引用的对象。
不同点
1、引用是某块内存的别名,而指针指向的是一块内存,他的内容是所指内存的地址。
2、引用在创建时必须被初始化,但是指针可以为空,在C语言中NULL,在C++中是nullptr。所以指针在使用的时候必须要判空,引用就没有必要。
3、引用不可以改变指向,一旦初始化了,就不能改变。指针可以改变自己的指向,可以指向其他对象。
4、对于引用来说,const int &a 和 int& const a没有区别,因为都表示指向的对象是常量。对于指针来说,const int *p 说明p是指向常量的指针, int * const p 说明p本身就是一个常量。
5、引用的大小是指向对象的大小,而指针在32位机上是四字节,在64位机上是8字节,是指针本身的大小。
6、对引用++操作就是对引用指向的对象进行++操作,但是对指针++操作,表示的是地址的变化,向后移动一个指针的大小。
7、指针传递和引用传递
指针传递传递的是地址,在函数中定义了一个局部的指针变量,存放的是实参的地址,消耗了内存,可以对地址进行加减操作,指向另一个变量,由于传递的是地址,所以不需要返回值,因为实际修改的就是实参的值。
引用传递同样是地址,但是不会在函数中消耗内存,直接对地址进行使用,对函数中的引用变量的加减操作直接影响外部的实参,并且不能指向另一个变量。在实际使用中传递引用的时候如果不希望实参被改变,通常要用const将其修饰。
五、深拷贝和浅拷贝
浅拷贝指的是将原始对象中的数据型字段拷贝到新对象中,将引用型对象的引用赋值到新对象中去,不把引用的对象复制进去,所以原始对象和新对象引用同一对象,新对象中的引用型字段发生变化会导致原始对象中对应的字段发生变化。
深拷贝是在引用方面不同,深拷贝就是重新创建一个新的和原始字段内容相同的字段,所以两者的引用是不同的。其中一个对象发生变化并不会影响另一个对象。
编译系统会在我们自己没有定义拷贝构造的时候调用默认的拷贝构造函数,进行浅拷贝,也就是两个对象使用同一份资源。当两个对象调用析构函数的时候,就会析构两次,导致内存泄漏。所以对含有指针成员的对象或类中存在资源的对象进行拷贝的时候,必须要自己定义拷贝构造函数,实现深拷贝,避免内存泄漏。
六、常见关键字的作用
static
static有静态局部变量、静态全局变量和静态方法三种使用方法,他们的共同点就是在本文件中声明的静态变量和静态方法是不能被其他文件所使用的,和对应的extern关键字,extern关键字声明的全局变量和函数在整个工程中都是可以被使用的。
全局变量
有static声明的全局变量,只能在函数体外部被定义,并且只能在本文件中有效,这点就是区别于普通的全局变量,普通的全局变量在其他的文件中也是可见的。在函数体重可以定义同名的局部变量,这时会隐藏这个静态的,如果要使用静态的全局变量,需要在变量名前添加::作用域运算符。
局部变量
static局部变量同样只能在本文件中使用,静态局部变量的生命周期不随着函数的结束而结束,只能在第一调用函数的时候回他进行初始化,之后调用就会跳过初始化,他会在函数结束之后在内存中保存当前的结果,而不会像普通的局部变量在清栈的时候销毁,在内存中他区别与局部变量的是局部变量每次调用函数时分配的内存空间可能是不一样的,但是静态局部变量具有全局唯一性的特点,每次调用使用的时候用的都是同一块内存空间,但是这也造成了一个不可重入的问题。(现在有两个进程A、B都要去调用这个函数fun(),如果是A先调用函数fun,在运行函数的时候突然失去了运行权,但是已经将局部变量修改成了自己要试用的值,由于使用的是同一块内存空间,进程B调用函数的时候也将局部变量修改成了自己要使用的值,当进程A需要继续执行的时候,由于这块内存空间中的值已经被修改了,所有进程A就得不到自己想要的结果)。
方法
static数据成员和成员函数
在C++中继承了C语言中的static这个关键字,并且在类中给了第三种定义方法,表示只属于一类而不是属于类的某个对象的变量和函数。这个和普通的成员最大的区别就是在类中是唯一的,并且在内存中只有一份,普通成员函数调用的时候需要传入this指针,但是静态成员函数调用的时候是没有this指针的,只能在调用的时候使用类名加作用域来调用。在设计多线程操作的时候,有POSIX库下的线程函数要求是全局的,所以普通的成员函数是无法直接作为线程函数的,但是静态的成员函数是可以做线程函数的。
static函数和普通函数
普通函数的定义和声明默认是extern的,在同一个工程中的其他文件中是可见的,如果在另一个文件中定义了相同的函数就会穿线重定义错误,当然这个重定义和继承中的 重定义是不一样的,这里的重定义指定的命名冲突。静态函数在内存中只有一份,但是普通的函数在每个被调用中都会维护一份拷贝。
extern
extern置于变量或函数前,用于标示变量或函数的定制在别的文件中,提示编译器遇到这个变量或函数要在其他的模块中查找。
extern “C”
如果是extern“C” void fun(int a, int b);这样是高数编译器在编译fun这个函数的时候要按照C的规则去编译,而不是按照C++的,这一点主要是与C++支持重载,C语言不支持重载和函数被C++编译器编译后在苦衷的名字与C语言的不同有关。
当extern不与“C”在一起修饰变量或者函数时,比如extern int a;他的作用就是声明函数或者全局变量的作用范围和关键字,其生命的函数和变量可以在本工程中的所有文件中使用。需要注意的是他只是一个声明,并不是定义。
const
使用const修饰类的成员变量的时候,必须要在初始化列表进行初始化,并且引用类型的成员变量和没有默认默认构造函数的对象成员也必须要在初始化列表进行初始化,如果有继承的关系,如果父类没有默认的构造函数,也必须要在初始化列表进行初始化,初始化列表对数据成员的初始化顺序时按照数据成员的声明顺序严格执行的。
const修饰成员函数的时候,一般是放在成员函数的最后面,修饰的类的成员函数中隐藏的this指针,代表不可以通过this指针修改类的数据成员,这个使用方法也可以与普通的相同的成员函数构成重载。
关于const还有一个问题就是传参和赋值的问题,一般来说使用const修饰的变量是安全的,没有使用const修饰的变量是不安全的,在传参的时候可以让非const修饰的变量传给const修饰的,但是const修饰的变量不可以传给非const修饰的形参,这就相当于将安全的变量交给了不安全的变量。
volatile
volatile一般用来修饰变量,他的存在是因为我们的程序在进行编译的时候编译器会进行一系列的优化,比如说某个变量被修饰为const,编译器就会认为这个值是只读的,就会在寄存器中保存这个变量的值,每次需要的时候直接从寄存器中读取,但是有的时候会在不经意间修改了这个变量的值,那么编译器是并不知道的,还是从寄存器中进行读取,这样就会造成结果不匹配。但是如果使用volatile声明后,就是相当与告诉编译器这个变量随时会给变,需要每次都要从内存中读取,不需要优化,从而避免了这个问题,volatile的应用场景最多的是多线程对
define、const、inline区别
define作用域程序的预处理节点,而预处理主要的工作是宏替换、去注释以及条件编译,而define起作用的地方就在宏替换阶段,只是单纯的将宏替换为代码。但是define只是单纯的代码替换,不会进行类型的检查,很容易出错。在C++中建议使用const、枚举定义常量,这样就会有类型检查。于是C++中有提供了一个inline关键字,可以实现和define相同的功能,并且支持类型检查和调试,一般生命在函数的定义前面,但是inline只是对编译器的一种建议,一般建议代码为3-5航左右,并且没有复杂的逻辑结构,例如循环、递归之类的。
七、malloc/free和new/delete的区别
1、malloc是从堆上开辟空间,而new是从自由存储区开辟空间。自由存储区是C++抽象出来的概念,不仅可以是堆,还可以是静态存储区。
2、malloc是函数,而new是关键字。
3、malloc对开辟的空间大小需要严格的指定,而new只需要对象名。
4、malloc开辟的空间既可以给单个对象使用也可以给数组使用,释放的方式都是free();而new开辟对象数组需要使用new[size],释放是使用delete[]。
5、malloc成功的返回值是void*,需要用户进行强转,申请空间失败会返回NULL,所以在使用的时候需要进行判空处理,new成功返回的是对象指针,不需要强转,失败抛出异常,但是为了最大程度的兼容C,C++的new也支持失败返回NULL,但是一般不使用。
6、new不仅负责开辟空间,还会去调用对象的构造函数和析构函数。
7、new申请空间的效率要低于malloc,因为new的底层是通过malloc实现的。
八、类中的成员函数占空间吗?怎么调用?
类中的普通成员函数和静态成员函数是不占用类的内存的,只有在类中函数有虚函数的时候才会在类中添加一个虚函数指针,增加一个指针的大小。类中的成员函数实际上与普通的全局函数一眼,只不过是在编译的时候在成员函数中国添加了一个指向当前对象的this指针,成员函数的地址是全局已知的,所以对象的内存空间中是没有必要去保存成员函数的地址的。在编译的时候就已经绑定了,类的属性值得是类中的数据成员,他们是实例化一个对象的时候就为数据成员分配了内存,但是成员函数是所有对象多公有的。
但是空类的大小是1个字节,这是为了保证两个不同对象的地址不同。类的实例化是在内存中分配一块地址,每个势力在内存中欧拥有独一无二的地址。同样的,空类也会实例化,所以编译器会给类隐含的添加一个字节,这样空类实例化后就有了独一无二的地址了。在一个空类中,在第一次实例化对象的时候就创建了默认的成员函数,并且这个成员函数是public和inline的。
九、NULL和nullptr的区别
传统意义上来说,c++把NULL、0视为同一种东西,有些编译器将NULL定义为 ((void*)0),有些将其定义为0
c++不允许直接将void隐式的转化为其他类型,但是如果NULL被定义为 ((void*)0),当编译char *p = NULL,NULL只好被定义为0。
还有:void func(int);void func(char*);
如果NULL被定义为0,func(NULL)会去调用void func(int),这是不合理的
所以引入nullptr,专门用来区分0、NULL。nullptr的类型为nullptr_t,能够隐式的转换为任何指针。所以用空指针就尽可能的使用nullptr。
十、智能指针
什么是智能指针?
智能指针最主要的作用就是管理一个指针,因为有可能申请的空间在函数结束的时候没有及时的释放,从而造成的了内存泄漏。使用智能指针是要是针对这个问题,因为只能指针是一个类,在他生命周期结束的时候回去调用析构函数进行资源的释放,不需要进行手动的释放资源。智能指针一共有auto_ptr、unique_ptr、shard_ptr、week_ptr四种。
auto_ptr:
是C++98提出来的智能指针,它采用的是所有权模式。就是将智能指针A赋值给智能指针B,就是将A的所有权交给了B,如果再想访问A就会出错,auto_ptr的缺点就是存在潜在的内存崩溃问题。
unique_ptr
他解决了auto_ptr的问题,从名字可以知道他是独有的智能指针,也就是说他不能进行赋值和拷贝操作,在unique_ptr中将拷贝构造函数和赋值运算符重载私有,并且将其删除。
shared_ptr
shared_ptr实现了共享的概念,多个智能指针可以指向同一份资源。他的原理是多个智能指针共同维护一份引用计数,当有第二个进行赋值和拷贝构造的时候回将引用计数+1,每当一个智能指针使用完需要调用析构函数的时候就会检查是不是最后一个使用资源的,如果不是不进行释放,否则要进行资源释放。但是在使用的时候会出现这么一个情况,就是相互引用产生的死锁问题,那么这两个智能指针的引用计数将永远不会下降为0,也就是资源不会得到释放。这时就需要week_ptr了。
week_ptr
week_ptr是一种不控制对象生命周期的智能指针,他指向一个sheard_ptr管理的对象,进行该对象的内存管理的是那个强引用的shared_ptr。week_ptr只是提供了对管理对象的一各访问的手段,他只能从一个shared_ptr或另一个weak_ptr对象构造,他的构造函数和析构函数是不会引起引用计数的改变。weak_ptr是用来解决shared_ptr相互引用时的死锁问题,如果说两个shared_ptr相互引用,那么这两个指针的引用计数永远不可能下降为0,资源永远不会释放。它是对对象的一种弱引用,不会增加对象的引用计数,和shared_ptr之间可以相互转化,shared_ptr可以直接赋值给它,它可以通过调用lock函数来获得shared_ptr。week_ptr只能是协助shared_ptr,他不能直接指向对象。
十一、默认构造函数
默认构造函数我认为是在调用时不需要显式地传入实参的构造函数。如果定义一个对象时没有使用初始化式,编译器就会使用默认的构造函数。编译器生成默认构造函数的情况用一句话可以进行概括,就是唯有默认构造函数被编译器需要的时候,编译器才会生成默认的构造函数。关于这个被编译器需要分为几种情况:当该类的类对象数据成员有默认构造函数时,当类的基类有默认的构造函数时、当该类的基类为虚基类时、当该类有虚函数时这四种情况。
默认构造函数中有这么几点需要注意:
1、不能让无参的默认构造函数和带缺省的默认构造函数同时存在,这样就会让编译器产生二义性,从而生成编译错误。
2、在使用无参默认构造函数的时候不能再对象名后面加括号,否则会产生警告,让编译器误认为是要声明一个函数,但是又没有找到该函数的定义,所以就产生了警告。
十二、前置++和后置++区别
1、从使用的角度来说:c++是先使用,后自增,++c是先自增,后使用
2、从系统的角度来说:c++表示取c的地址,把他放入寄存器中,然后增加内存中的c,使用寄存器中的值,对于++c表示取c的地址,自增后把他放入寄存器中使用,很显然++c的效率要比c++高,因为c++会生成一个临时变量。
3、如果不需要返回自增后之前的值,那么c++和++c的效果是一样的,但是要优先使用++c,效率高。
十三、lambda表达式
[函数对象参数](操作符重载函数参数)mutable或exception声明 ->返回值类型{函数体}
函数对象参数
空:没有使用任何的函数对象参数。
= :函数体内可以使用lambda所在作用域范围内所有可见的局部变量,并且是值传递。
& :函数体内可以使用lambda所在作用域范围内所有可见的局部变量,并且是引用传递。
a :将a按值传递,但是a的默认是const的,可以添加mutable修饰后变为普通的。
&a:将a按引用传递。
a,&b:将a按值传递,b按引用传递。
=,&a,&b:除了a和b,其他参数均按值传递。
&,a,b:除了a和b,其他参数均按引用传递。
this:函数体重可以使用lambda所在类中的所有成员变量
操作符重载函数参数
表示重载的()操作符的参数,没有参数的时候这个可以省略。
mutable或exception声明
按值传递函数对象参数时,加上mutable修饰可以修改值传递进来的拷贝。exception声明用于指定函数抛出的异常,如抛出整形的异常可以使用throw进行捕获。
->返回值类型
标识函数返回值的类型,当返回值为void或者函数同种只有一处return的地方,这部分可以省略。
{函数体}
表示函数的实现,这部分不可以省略,但是函数体可以为空。
十四、什么是右值?
左值和右值是编译器和程序中经常出现的词汇,在C++中被广泛认同的说法就是可以取地址的、有名字的就是左值,没有地址的,不能取取名字的就是右值。
在C++11中,右值由纯右值和将亡值组成。
纯右值,用于辨识临时变量和一个不跟对象有关联的值,比如说非引用返回的函数返回值,运算表达式、不跟对象关联的字面量值(true、false、常量等)、类型转换函数的返回值,lambda表达式。
将亡值,是C++11新增的跟右值引用相关的表达式,这样表达式通常是将要被移动的对象,比如说返回右值引用T&&的函数的返回值,move库函数的返回值,转换为T&&的类型转换函数的返回值。
除了纯右值和将亡值剩余的所有值都是左值。
十五、右值引用
右值引用:
C++中,左值通常指可以取地址,有名字的值就是左值,而不能取地址,没有名字的就是右值。而在指C++11中,右值是由两个概念构成,将亡值和纯右值。纯右值是用于识别临时变量和一些不跟对象关联的值,比如1+3产生的临时变量值,2、true等,而将亡值通常是指具有转移语义的对象,比如返回右值引用T&&的函数返回值等。
C++11中,右值引用就是对一个右值进行引用的类型。由于右值通常不具有名字,所以我们一般只能通过右值表达式获得其引用,比如:T && a=ReturnRvale();
假设ReturnRvalue()函数返回一个右值,那么上述语句声明了一个名为a的右值引用,其值等于ReturnRvalue函数返回的临时变量的值。基于右值引用可以实现转移语义和完美转发新特性。
十六、 说一说c++中四种cast转换
C++中四种类型转换是:static_cast, dynamic_cast, const_cast, reinterpret_cast
1、const_cast
用于将const变量转为非const
2、static_cast
用于各种隐式转换,比如非const转const,void*转指针等, static_cast能用于多态向上转化,如果向下转能成功但是不安全,结果未知;
3、dynamic_cast
用于动态类型转换。只能用于含有虚函数的类,用于类层次间的向上和向下转化。只能转指针或引用。向下转化时,如果是非法的对于指针返回NULL,对于引用抛异常。要深入了解内部转换的原理。
向上转换:指的是子类向基类的转换
向下转换:指的是基类向子类的转换
它通过判断在执行到该语句的时候变量的运行时类型和要转换的类型是否相同来判断是否能够进行向下转换。
4、reinterpret_cast
几乎什么都可以转,比如将int转指针,可能会出问题,尽量少用;
5、为什么不使用C的强制转换?
C的强制转换表面上看起来功能强大什么都能转,但是转化不够明确,不能进行错误检查,容易出错。
十七、数组和指针的区别
1、指针中保存的是数据的地址,而数组中保存的是数据。
2、指针是间接访问数据,要访问一个数据要先获得指针的内容,然后通过这个地址提取出数据,而数组是直接访问数据。
3、指针通常用于动态的数据结构,链表、链式二叉树等,数组通常用语固定数目并且数据类型相同的数据结构,顺序表等。
4、指针通过malloc或new申请内存,并且使用完要进行释放,数组则是隐式的分配和删除。
十八、函数指针与指针函数
十九、请你来说一下一个C++源文件从文本到可执行文件经历的过程?
对于C++源文件,从文本到可执行文件一般需要四个过程:
预处理阶段:对源代码文件中文件包含关系(头文件)、预编译语句(宏定义)进行分析和替换,生成预编译文件。
编译阶段:将经过预处理后的预编译文件转换成特定汇编代码,生成汇编文件后缀为.s
汇编阶段:将编译阶段生成的汇编文件转化成机器码,生成可重定位目标文件.o
链接阶段:将多个目标文件及所需要的库连接成最终的可执行目标文件.exe
二十、内存泄漏
内存泄漏(memory leak)是指由于疏忽或错误造成了程序未能释放掉不再使用的内存的情况。内存泄漏并非指内存在物理上的消失,而是应用程序分配某段内存后,由于设计错误,失去了对该段内存的控制,因而造成了内存的浪费。
内存泄漏的分类:
1、堆内存泄漏 (Heap leak)。对内存指的是程序运行中根据需要分配通过malloc,realloc new等从堆中分配的一块内存,再是完成后必须通过调用对应的 free或者delete 删掉。如果程序的设计的错误导致这部分内存没有被释放,那么此后这块内存将不会被使用,就会产生Heap Leak.
2、系统资源泄露(Resource Leak)。主要指程序使用系统分配的资源比如 Bitmap,handle ,SOCKET等没有使用相应的函数释放掉,导致系统资源的浪费,严重可导致系统效能降低,系统运行不稳定。
3、没有将基类的析构函数定义为虚函数。当基类指针指向子类对象时,如果基类的析构函数不是virtual,那么子类的析构函数将不会被调用,子类的资源没有正确是释放,因此造成内存泄露。
二十一、C++对象模型
★★数据结构★★
一、STL总结
什么是STL
STL是一套高效的C++程序库,采用泛型编程的思想对常见的数据结构和算法进程封装,里面处处体现着泛型编程的设计思想以及设计模式,现在已经被集成到了C++标准库中。STL里面包含了容器、适配器、空间配置器、迭代器、仿函数和算法。
六大组件
容器:就是各种数据结构,根据底层的实现由分为序列式容器和关联式容器。
适配器:是一种用来修饰容器或者仿函数或迭代器接口的东西。比如queue和stack。
空间配置器:负责空间配置与管理。从实现角度看,配置器是一个实现了动态空间配置、空间管理、空间释放额class template。
迭代器:扮演容器与算法之间的胶合剂,是所谓的“泛型指针”。
算法:各种常见算法,如sort,search,copy,erase等。
仿函数:行为类函数,可作为算法的某种策略,从实现角度看,仿函数是一种重载了operator()的class或class template。一般函数指针可视为狭义的仿函数。
他们之间的关系:空间配置器给容器分配存储空间,算法通过迭代器获取容器中的内容,仿函数可以协助算法完成各种操作,配接器用来套接适配仿函数
容器
其中容器分为序列容器和关联式容器两大类,序列容器包括静态数组array、动态数组vector、动态二维数组deque、带头结点的循环单链表forward_list、带头结点的双向循环链表list、字符串string。关联式容器根据地层的实现红黑树实现和哈希表实现两大类,关联式容器中的两大类主要区别于底层的实现,红黑树和哈希表,
红黑树是一种二叉搜索树,在每个节点上都增加了一个存储位表示结点的颜色,可以红色或者是黑色,通过对任何一条从根到叶子结点的路径上各个节点的颜色限制,确保没有一条路径会比其他路径长出2倍,所以是接近平衡的,是比较稳定的二叉搜索树,由于二叉搜索树的任意根节点总是大于它左子树的所有节点,小于他的右子树的所有节点,所以在查找的时候可以采用类似于二分查找的思想,快速找到某个节点,红黑树是一个平衡二叉树,他的查找时间复杂度也是logN。
哈希表是根据关键码值直接进行访问的数据结构。通过关键码值可以直接映射到哈希表中的一个位置来访问对应的值,以加快查找的速度,查找的时间复杂度是O(1)。哈希表主要解决的是哈希函数和哈希冲突,哈希函数也叫散列函数,要根据不同的输入值得到一个固定长度的消息摘要。理想的哈希函数要对不同的输入产生不同的不同的输出结果,同时还要满足同一性和雪崩效应。哈希冲突就是不同的关键码值通过哈希函数计算出相同的哈希地址。
解决哈希冲突的常见方法有闭散列和开散列两种,闭散列采用的是线性探测法,当发生哈希冲突时,如果哈希表没有被装满,就说明还可以将关键码值存放到下一个空位置,然后从冲突位置一次向后探测,直到空位置为止,闭散列可以缓解哈希冲突,但是不能彻底解决。开散列使用的是拉链法,先将关键码通过哈希函数计算得到相应的哈希地址,然后将相同的哈希地址的关键码放在同一个子集合中,这个子集合通常称之为桶,每个桶中的元素通过单链表的方式连接起来,然后将各个链表的头结点存放在哈希表中。
但是对于哈希表来说通的个数是固定,如果某个桶中的元素超过了8个,就要将这个桶中的单链表转换为红黑树,如果桶中的元素小于6个的时候要将红黑树结构再次转换为单链表。这个转换是由于桶中的元素是由链表保存的,链表的查找时间复杂度是O(N),而红黑树的查找时间复杂度是logN,但是当链表中的长度很小的时候,查找也是很快的,当链表不断变长了他的查找性能就会降低,需要转换成红黑树。一开始不将桶的结构初始化为树是因为一个红黑树的节点占用的空间是单链表节点的二倍,为了时间和空间的权衡只有当链表长度到8才进行单链表向红黑树的转换。但一个哈希表的离散性很好的情况下,将单链表转换为红黑树的概率很小,因为数据均匀分布在每个哈希桶中,几乎不会有哈希桶中的链表长度达到8,理想情况下哈希表中的所有哈希桶中节点分布频率会遵循泊松分布,长度为8的概率是6*10^-8,几乎是不可能的。
hash和红黑都是性能非常高的两个数据结构,但是他们还是有区别的,hash的查找速度比红黑树快,并且查找速度基本上是和数据量无关的,属于常数级别,但是红黑树的查找速度是logN的,但是hash还有hash函数,并且它的构造速度也是比较慢的,并且还需要实现分配足够的内存存储散列表,并且hash是无序的。红黑树所需要的内存较小,只需要为节点分配内存,并且红黑树中的节点是有序,它的查找时间复杂度是LogN,但是不能说就比常数大。基于这两个数据结构各自的特点和性能,STL将关联式容器分成了基于红黑树的map、set、multimap、multiset和基于hash的unordered_map、unordered_map、unordered_set、unordered_multimap、unordered_multiset。
vector
vector是线性容器,他的元素严格按照线性序列排序和动态数组很相识,他和数组一样,所有的元素存储在一块连续的存储空间中,这也意味着我们不仅可以使用迭代器访问元素,还可以使用指针偏移的方式访问,和常规数组不一样的是,vector能够自动存储元素,可以自动增长和缩小存储空间。和数组相比,虽然vector在在自动处理容量的大小时会消耗更多的内存,但是容器可以提供和数组一样的性能,而且可以很好的调整存储空间的大小。相比其他的序列容器,能更有效地访问容器中的元素和在末尾添加和删除元素,在其他位置添加和删除元素就不如其他序列容器了,并且在迭代器当面也不如list支持的好。
vector的迭代器,无论在迭代器位置进行增加元素还是删除元素都会导致所有的迭代器失效,因为vector中删除和添加元素后可能会改变容器的大小,所以要更新所有的迭代器,关于vector的空间这里需要注意的是size和capacity这两个成员函数的区别,size指的是当前容器中拥有元素的个数,而capacity指的是当前容器可以存放的元素个数。vector在分配空间这块他会分配一些额外的空间来适应可能的增长。不同的库采用的不同的策略权衡空间的使用和重新分配,在vs中PJ版本的STL是以1.5倍的方式进行扩容的,在gcc中的SGI版本的STL是按2倍的方式进行扩容的。
list
list是可以在常数范围内在任意位置进行插入和删除的序列式容器,并且该容器可以向前向后双向迭代。list的地层是一个双向链表结构,双向链表中每个元素存储在互不相关的独立节点中,在节点中通过指针指向其前一个元素和后一个元素。他与forward_list非常相似,最主要的区别就是forward_list是单链表,只能向后迭代,而list是双向链表,可以向前迭代也可以向后迭代。与其他容器相比较,list和forward_list最大的缺陷就是不支持在任意位置的随机访问,比如:要访问list的第6个元素,必须从头部或者尾部迭代到该位置,这段位置上的迭代需要线性的时间开销,list还需要开辟一些额外的空间,以保存每个节点相关联的信息。
list中也存在迭代器失效的问题,因为list的底层结构是双向循环链表,所以在list中插入时不会导致list的迭代器失效,只有在删除的时候才会导致迭代器失效,但是失效的仅是被删除节点的迭代器,其他的迭代器并不会受到影响。
vector和list的区别
底层结构
vector是动态的顺序表,在内存中是一段连续的空间,list使用的是带头结点的双向循环链表。
随机访问
vector支持随机访问,访问某个元素的时间复杂度是O(1),list不支持随机访问,访问某个元素的时间复杂度是O(n)
插入和删除
vector在任意位置的插入和删除的效率较低,需要搬移元素,时间复杂度为O(N),插入的时候还有可能会增容,增容操作中有开辟新空间、拷贝元素、释放旧空间,导致效率更低。list在任意位置插入和删除效率比较高,不需要搬移元素,只需要更改上下两个节点的指针指向,时间复杂度是O(1)。
空间利用率
vector的底层为一段连续的空间,不容易造成内存碎片,空间利用率高,缓存利用率高。list底层节点动态开辟,容易造成内存碎片,空间利用率低,缓存利用率低。
迭代器
vector使用的是原生态的指针,list是将原生态的指针进行了封装。vector在插入元素是要给所有的迭代器重新赋值,因为插入元素有可能会导致扩容,致使原来的迭代器会失效,并且删除元素的时候也会对迭代器进行重新赋值,同样也会使原来的迭代器失效。对弈list来说,他的底层是双向循环链表,插入元素时并不会导致迭代器失效,删除元素是只会导致当前的迭代器失效,其他的迭代器并不受影响。
使用场景
vector适用于需要高效存储,需要随机访问,并且不关心插入和删除的效率。list适用于大量的插入和删除操作,不需要随机访问,也不关心存储的场景。
set
set是按照一定次序进行存储的容器。set中元素只有一个value,并且每个value必须是唯一的,set中的元素不能再容器中进行修改,元素总是const的,但是可以容容器中对他们进行删除或者插入。并且元素总是按照其内部的比较对象所指是的特定的严格弱排序标准进行排序的,set访问元素的速度要比unordered_set慢,但是它允许直接迭代,并且是有序的,set的底层实现是红黑树。他与map/multimap不同的是set中只存放的value,但是他的底层存放的是由构成的键值对,由于set中不允许有重复的元素,所以可以使用set去重,
map
map是关联式容器,他按照特定的次序来存储由建值key和值value组合而成的元素。在map中,建值key通常用于排序和唯一标识元素,而值value中存储的是与建值key相关联的内容。建值key和值value的类型可能不同,并且在map中,key与value通过成员类型value_type绑定在一起。在map中,元素总是按照建值key进行比较排序,并且他访问某个元素时通常要比unordered_map慢,但是map中的元素直接进行迭代是有序的。他的底层实现是红黑树结构。map与multimap唯一的不同就是map中的key值是唯一的,但是multimap中的key是可以重复的。set于multiset的区别就是multiset中的元素是可以重复的。
list、set、map的区别
从结构上来说
list和set是存储的单列数据的集合,map中存储的是键值对这样的双列数据的集合。list的底层结构是双向循环列表,set和map的底层结构是红黑树。
从数据上来说
list中存储的数据是有顺序的,并且是允许有重复的,查找的时间复杂度是O(N),每个节点只有一个值value;map中的存储的数据是无序的,他的键值是不允许重复的,但是他的值是允许重复的,并且他的键值是有序的,查找时间复杂度是logN,每个节点是由键值key,值value构成的键值对,set中存储的数据是有序的,不允许重复,查找时间复杂度是logN,每个节点是值value,但是他的底层节点是value,value的键值对。
STL面试题
一、STL常用的容器有哪些以及各自的特点是什么?
1.vector:底层数据结构为数组 ,支持快速随机访问。
2.list:底层数据结构为双向链表,支持快速增删。
3.stack:底层一般用顺序表和链表实现,封闭头部即可,不用vector的原因应该是容量大小有限制,扩容耗时
4.queue:底层一般用顺序表和链表实现,封闭头部即可,不用vector的原因应该是容量大小有限制,扩容耗时(stack和queue其实是适配器,而不叫容器,因为是对容器的再封装)
5.set:底层数据结构为红黑树,有序,不重复。
6.multiset:底层数据结构为红黑树,有序,可重复。
7.map:底层数据结构为红黑树,有序,不重复。
8.multimap:底层数据结构为红黑树,有序,可重复。
9.hash_set:底层数据结构为hash表,无序,不重复。
10.hash_multiset:底层数据结构为hash表,无序,可重复 。
11.hash_map :底层数据结构为hash表,无序,不重复。
12.hash_multimap:底层数据结构为hash表,无序,可重复。
二、什么情况下用vector,什么情况下用list。
vector可以随机存储元素(即可以通过公式直接计算出元素地址,而不需要挨个查找),但在非尾部插入删除数据时,效率很低,适合对象简单,对象数量变化不大,随机访问频繁。
list不支持随机存储,适用于对象大,对象数量变化频繁,插入和删除频繁。
三、什么时候需要用hash_map,什么时候需要用map?
总体来说,hash_map 查找速度会比map快,而且查找速度基本和数据数据量大小,属于常数级别;而map的查找速度是log(n)级别。并不一定常数就比log(n)小,hash还有hash函数的耗时,明白了吧,如果你考虑效率,特别是在元素达到一定数量级时,考虑考虑hash_map。但若你对内存使用特别严格,希望程序尽可能少消耗内存,那么一定要小心,hash_map可能会让你陷入尴尬,特别是当你的hash_map对象特别多时,你就更无法控制了,而且hash_map的构造速度较慢。
四、请你来说一说STL迭代器删除元素
这个主要考察的是迭代器失效的问题。1.对于序列容器vector,deque来说,使用erase(itertor)后,后边的每个元素的迭代器都会失效,但是后边每个元素都会往前移动一个位置,但是erase会返回下一个有效的迭代器;2.对于关联容器map set来说,使用了erase(iterator)后,当前元素的迭代器失效,但是其结构是红黑树,删除当前元素的,不会影响到下一个元素的迭代器,所以在调用erase之前,记录下一个元素的迭代器即可。3.对于list来说,它使用了不连续分配的内存,并且它的erase方法也会返回下一个有效的iterator,因此上面两种正确的方法都可以使用。
五、请你来说一下STL中迭代器的作用,有指针为何还要迭代器
1、迭代器
Iterator(迭代器)模式又称Cursor(游标)模式,用于提供一种方法顺序访问一个聚合对象中各个元素, 而又不需暴露该对象的内部表示。或者这样说可能更容易理解:Iterator模式是运用于聚合对象的一种模式,通过运用该模式,使得我们可以在不知道对象内部表示的情况下,按照一定顺序(由iterator提供的方法)访问聚合对象中的各个元素。
由于Iterator模式的以上特性:与聚合对象耦合,在一定程度上限制了它的广泛运用,一般仅用于底层聚合支持类,如STL的list、vector、stack等容器类及ostream_iterator等扩展iterator。
2、迭代器和指针的区别
迭代器不是指针,是类模板,表现的像指针。他只是模拟了指针的一些功能,通过重载了指针的一些操作符,->、*、++、–等。迭代器封装了指针,是一个“可遍历STL( Standard Template Library)容器内全部或部分元素”的对象, 本质是封装了原生指针,是指针概念的一种提升(lift),提供了比指针更高级的行为,相当于一种智能指针,他可以根据不同类型的数据结构来实现不同的++,–等操作。
迭代器返回的是对象引用而不是对象的值,所以cout只能输出迭代器使用*取值后的值而不能直接输出其自身。
3、迭代器产生原因
Iterator类的访问方式就是把不同集合类的访问逻辑抽象出来,使得不用暴露集合内部的结构而达到循环遍历集合的效果。
- 说说你所知道的容器都有哪些?
- map与set的区别?使用map有哪些优势?
- map的底层实现,说下红黑树?
- map的迭代器会失效吗?什么情况下回失效?
- AVLTree和RBTree的对比,为什么map和set使用了红黑树?红黑树的优势是什么?
- AVLTree和RBTree所达到的平衡有什么区别?
- RBTree节点的颜色是红或者黑色?其他颜色行不行?
- RBTree是如何插入?如何旋转的?
二、哈希
哈希表是根据关键码值而直接进行访问的数据结构。通过关键码值映射到表中的一个位置来访问记录,以加快查找的速度。哈希表主要是解决两个问题,哈希函数和哈希冲突。
哈希函数
哈希函数也叫散列函数,他对不同的输出值得到一个固定长度的消息摘要。理想的哈希函数对不同的输入要产生不同的结构,同时散列结果也应当具有同一性和雪崩效应(微小的输入值发生的变化使得输出值发生巨大的变化)。
哈希冲突
哈希冲突就是不同的关键字通过相同的哈希函数计算出了相同的哈希地址。解决哈希冲突最常见的方法是闭散列和开散列,闭散列使用的是线性探测法,当发生哈希冲突时,如果哈希表没有被装满,说明可以将key值保存到下一个空位置中,从冲突发生的位置开始,依次向后探测,直到找到下一个空位置为止。开散列使用的是拉链法,先对关键码使用哈希函数计算哈希地址,具有相同的哈希地址的关键码归于同一个子集,称子集合为一个桶,每个桶中的元素通过一个单链表连接起来,然后将各个链表的头结点存放在哈希表中。由于哈希表中桶的个数是固定的,如果某个桶中的元素非常多,这样会影响真个哈希表的性能。一般来说如果桶中的元素过多,会将单链表的存储方式换成红黑树,或者是对哈希表进行增容。
十、二叉树的先序遍历、中序遍历和后序遍历
//二叉树的递归遍历
void PreOrde(BTree* root)
{
if(root)
{
cout << root->data << " ";
PreOrde(root->left);
PreOrde(root->right);
}
}
void InOrde(BTree* root)
{
if(root)
{
PreOrde(root->left);
cout << root->data << " ";
PreOrde(root->right);
}
}
void PostOrde(BTree* root)
{
if(root)
{
PreOrde(root->left);
PreOrde(root->right);
cout << root->data << " ";
}
}
void PreOrde1(BTree* root)
{
stack<BTree*> s;
BTree* cur = root;
while(!s.empty() || cur)
{while(cur){cout << cur->data << " ";s.push(cur);cur = cur->left;}if(!s.empty()){BTree* tmp = s.top();s.pop();cur = tmp->right;}
}
}
void InOrde1(BTree* root)
{
stack<BTree*> s;
BTree* cur = root;
while(!s.empty() || cur)
{while(cur){s.push(cur);cur = cur->left;}if(!s.empty()){BTree* tmp = s.top();cout << tmp->data << " ";s.pop();cur = tmp->right;}
}
}
void PasrOrde1(BTree* root)
{
stack<BTree*> s;
stack flag;
BTree* cur = root;
while(!s.empty() || cur)
{while(cur){s.push(cur);flag.push(false);cur = cur->left;}if(!s.empty()){if(flag.top()){cout << s.top()->data << " ";s.pop();flag.pop();}else{cur = s.top()->right;flag.top() = true;}}
}
}
三、链表环
判断是否有环
定义一个快指针和一个慢指针,快指针一次走两步,慢指针一次走两步,会出现两种情况,情况一指针走到了空的位置,那就说明这个链表不带环。情况二两个指针相遇,说明这个链表带环。
获得入环节点
如果不考虑空间复杂度,可以使用一个map来记录走过的节点,这个指针一直向后遍历如果遇到空,说明这个链表不带环,也就没有入环节点,如果没有遇到空,如果遇到第一个在map中存在的节点,就说明回到了出发点,这个节点就是环的入口节点。
如果不建立额外的空间,先使用快慢指针判断这个链表是否有环,如果有环将相遇节点记录,然后一个指针从链表的起始位置开始一次走一步,另一个指针从记录的节点开始一次走一步,当两个节点再次相遇,这个相遇节点就是环的入口节点。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-7uf3JqSR-1637763958108)(C:\Users\LENOVO\AppData\Roaming\Typora\typora-user-images\1565675547490.png)]
四、栈和队列
两个栈模拟实现队列
队列的特性就是只能在队尾进行插入操作,在队头进行删除操作,两个栈实现一个队列,一个栈s1负责入队列,另一个栈s2负责出队列,当删除队列的时候,如果s2中有元素,直接取栈顶元素,如果s2是空栈,将s1中的所有元素搬移到s2中,然后再取栈顶元素。
两个队列模拟实现栈
栈的特性就是只能在一端进行插入和删除操作,用两个队列一个用来进行入栈操作,另一个进行删除的时候才会用到,将有元素的栈的size-1个元素全部搬移到另一个空队列中,然后将最后一个元素删除,就完成了删除操作。
五、hashmap和map的区别?
map是STL中的一个关联式容器,它提供一对一的K-V的数据处理能力,由于这个特性,在我们需要完成Key-Value数据处理的时候可以很方便的调用。map的底层结构是红黑树,这棵树对数据有自动排序的功能,所以map中的数据都是有序的,并且查找的时间复杂度基本是LogN。他的特点是增加和删除节点对迭代器的影响很小,只对操作的节点有影响,但是对于迭代器来说,可以修改节点对应的V值,不能修改K值。
HashMap是基于哈希表的Map,它具有着map的特性。当我们将K值传递给put()方法时,它调用对象的hashCode()方法来计算hashcode,然后找到对应的位置来存放value。hashmap使用开散列的方法来解决哈希冲突。他是线程不安全的,hashmap中的初始容量和装填因子会影响他的性能。
1、他们的底层实现不同,map使用的是红黑树来实现,Hashmap使用的哈希表来实现。
2、他们的查找时间复杂度不同,map的时间复杂度是log(n),hashmap的时间复杂度O(1)。
3、map不允许有NULL值,但是hashmap允许有NULL。
★★排序算法★★
一、冒泡排序
冒泡排序时通过无序区中相邻记录的关键字间的比较和位置的交换,使关键字最小的元素如气泡似的逐步上浮直水面。有序区逐渐扩大,无序区逐渐缩小。
冒泡排序算法的原理如下:
- 比较相邻的元素。如果第一个比第二个大,就交换他们两个。
- 对每一对相邻元素做同样的工作,从开始第一对到结尾的最后一对。在这一点,最后的元素应该会是最大的数。
- 针对所有的元素重复以上的步骤,除了最后一个。
- 持续每次对越来越少的元素重复上面的步骤,直到没有任何一对数字需要比较
动画演示
冒泡排序是一种非常容易理解的排序
时间复杂度:O(N^2)
空间复杂度:O(1)
稳定性:稳定
普通冒泡
void bubble0(vector<int>& arr){ int size = arr.size(); for(int i = 0; i < size; ++i) { for(int j = 0; j < size-i-1; ++j) { if(arr[j] > arr[j+1]) { swap(arr[j+1], arr[j]); } } }}
优化一
第一种优化就是在交换的地方加一个标记,如果某一趟排序没有交换元素,说明这组数据已经有序,不用再继续下去。这样对部分连续有序而整体无序的数据大大提升了效率。
void bubble1(vector<int>& arr){ int size = arr.size(); for(int i = 0; i < size; ++i) { bool flag = true; for(int j = 0; j < size-i-1; ++j) { if(arr[j] > arr[j+1]) { flag = false; swap(arr[j+1], arr[j]); } } if(flag) return; }}
优化二
对于优化一来说,仅仅对部分连续有序而整体无序的数据效率高一些,如果这组数据时前面无序,但是后面的部分有序,效率就不是那么高了。因此可以在优化一的基础上记下最后一次交换的位置,这个位置后没有交换,必然是有序的,然后下一次排序从第一个比较到上次记录的位置结束即可。
void bubble2(vector<int>& arr){ int size = arr.size() -1; int pos = size; for(int i = 0; i < size; ++i) { bool flag = true; int k = 0; for(int j = 0; j < pos; ++j) { if(arr[j+1] < arr[j]) { flag = false; swap(arr[j+1], arr[j]); k = j; } } if(flag) return; pos = k; }}
优化三
冒泡算法经过优化二后效率有很大的提升,但是效率还可以继续优化,就是在一趟排序中确定两个最值,也就是一个正向查找最大值,另一个反向查找最小值
void bubble3(vector<int>& arr){ int size = arr.size() -1; int pos = size; int pos1 = 0; for(int i = 0; i < size; ++i) { bool flag = true; int k = 0; //正向冒最大值 for(int j = 0; j < pos; ++j) { if(arr[j+1] < arr[j]) { flag = false; swap(arr[j+1], arr[j]); k = j; } } if(flag) return; pos = k; //反向冒最小值 int n = pos1; for(int j = k; j > pos1; --j) { if(arr[j-1] > arr[j]) { swap(arr[j-1], arr[j]); n = j-1; flag = false; } } if(flag) return; pos1 = n; }}
二、选择排序
选择排序是一种简单直观的排序算法。
选择排序原理
- 初始时设第一个元素为最大值,并记录其下标为maxpos
- 从剩余未排序元素中继续寻找最大元素,如果当前元素比下标为maxpos的元素大,将maxpos更新到当前位置
- 一次遍历后,将下标为maxpos的元素与最后一个元素交换位置
- 以此类推,直到整个数组有序。
注意:选择排序与冒泡排序是区别的,冒泡排序通过依次交换相邻两个顺序不合法的元素位置,从而将当前最大元素放到合适的位置,而选择排序每遍历一次都记住了当前最大元素的位置,最后仅需一次交换操作即可将其放到合适的位置。
直接选择排序思考非常好理解,但是效率不是很好。实际中很少使用
时间复杂度:O(N^2)
空间复杂度:O(1)
稳定性:不稳定
void slectSort(vector<int>& arr){ int size = arr.size(); for (int i = 0; i < size - 1; ++i) { int maxPos = 0; for (int j = 1; j < size - i; ++j) { if (arr[j] > arr[maxPos]) maxPos = j; } if (maxPos != size-i-1) swap(arr[maxPos], arr[size - i-1]); }}
优化
选择排序的优化就是在原来的一次只标记一个最值,优化为一个标记两个最值,这样效率可以提升原来的一半。
void SlectSort(vector<int>& arr){ int size = arr.size(); int begin = 0; int end = size - 1; while (begin < end) { int minPos = begin; int maxPos = begin; int index = begin + 1; while (index <= end) { if (arr[minPos] > arr[index]) minPos = index; if (arr[maxPos] < arr[index]) maxPos = index; ++index; } if (maxPos != end) swap(arr[maxPos], arr[end]); //这段小代码在在下面介绍其作用 if (minPos == end) minPos = maxPos; if (minPos != begin) swap(arr[begin], arr[minPos]); ++begin; --end; }}
上面代码标记的一小段代码是为了防止minpos在最大值要插入的位置。比如序列(2,15,4,1)这时候的maxpos为1,minpos为3,调整过最大值后,序列就成了(2,1,4,15),这时候的minpos还是3,如果直接进行最小值交换,就恢复到之前的位置了,所以要加上这段判断代码。这样如果最小值在最大值要交换的位置,最大值交换后要将最小值的位置更新到maxpos的位置。
三、插入排序
直接插入排序是一种简单的插入排序法,其基本思想是:把待排序的记录按其关键码值的大小逐个插入到一个已经排好序的有序序列中,直到所有的记录插入完为止,得到一个新的有序序列 。实际中我们玩扑克牌时,就用了插入排序的思想
当插入第i(i>=1)个元素时,前面的arr[0],arr[1],…,arr[i-1]已经排好 序,此时用arr[i]与arr[i-1],arr[i-2]进行比较,找到插入位置即将array[i]插入,原来位置上的元素顺序后移。
元素集合越接近有序,直接插入排序算法的时间效率越高,其时间效率在O(n)与O(n^2)之间。直接插入排序的空间复杂度为O(1),它是一种稳定的排序算法。
元素集合越接近有序,直接插入排序算法的时间效率越高
时间复杂度:O(N^2)
空间复杂度:O(1),它是一种稳定的排序算法
稳定性:稳定
void InsertSort(vector<int>& arr){ int size = arr.size(); for (int i = 1; i < size; ++i) { //待插入元素 int key = arr[i]; //找插入位置 int end = i - 1; while (end >= 0 && arr[end] > key) { //向后搬移数据 arr[end + 1] = arr[end]; end--; } //开始插入 arr[end + 1] = key; }}
优化
普通的插入排序就是不断的依次将元素插入前面已排好序的序列中。由于前半部分为已排好序的数列,这样我们不用按顺序依次寻找插入点,可以采用折半查找的方法来加快寻找插入点的速度。
折半插入排序算法是一种稳定的排序算法,比普通插入算法明显减少了关键字之间比较的次数,因此速度比直接插入排序算法快,但记录移动的次数没有变,所以折半插入排序算法的时间复杂度仍然为O(n^2),与直接插入排序算法相同。
void InsertSort1(vector<int>& arr){ int size = arr.size(); for (int i = 1; i < size; ++i) { //待插入元素 int key = arr[i]; //找插入位置 int right = i - 1; int left = 0; if(arr[right] > key) { while (left <= right ) { int mid = left+((right-left)>>1); if(arr[mid] < key) left = mid+1; else right = mid-1; } } //开始搬移数据 int end = i -1; while(end >= left) { arr[end+1] = arr[end]; --end; } //开始插入数据 arr[end + 1] = key; }}
四、希尔排序
希尔排序法又称缩小增量法。希尔排序法的基本思想是:先选定一个整数,把待排序文件中所有记录分成个组,所有距离为的记录分在同一组内,并对每一组内的记录进行排序。然后,重复上述分组和排序的工作。当到达=1时,所有记录在统一组内排好序。
希尔排序是对直接插入排序的优化。
当gap > 1时都是预排序,目的是让数组更接近于有序。当gap == 1时,数组已经接近有序的了,这样就会很快。这样整体而言,可以达到优化的效果。我们实现后可以进行性能测试的对比。
希尔排序的时间复杂度不好计算,需要进行推导,推导出来平均时间复杂度: O(N^1.3 - N^2)
稳定性:不稳定
void ShellSort(vector<int>& arr){ int size = arr.size(); int gap = size; while (gap > 0) { gap = gap / 3 + 1; for (int i = gap; i < size; ++i) { //待插入元素 int key = arr[i]; //找插入位置 int end = i - gap; while (end >= 0 && arr[end] > key) { arr[end + gap] = arr[end]; end -= gap; } //开始插入 arr[end + gap] = key; } if(gap == 1) break; }}
五、快速排序
快速排序快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法,其基本思想为:任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
快速排序整体的综合性能和使用场景都是比较好的,所以才敢叫快速排序
时间复杂度:平均O(N*logN) 最坏:O(n^2)【每次的关键值都是最大值或者最小值】空间复杂度:O(logN)
稳定性:不稳定普通快排:
普通快排就是将当前序列的最后一个值作为标志,然后开始分割序列。
int Partion(vector<int>& arr, int left, int right){ int key = arr[right-1]; int begin = 0; int end = right-1; while (begin < end) { while (begin < end && arr[begin] <= key) begin++; while (begin < end && arr[end] >= key) end--; if (begin != end) swap(arr[begin], arr[end]); } if (begin != right - 1) swap(arr[begin], arr[right - 1]); return begin;}
优化一:挖坑法
挖坑法的原理就是在左边找到不符合条件的元素时,将这个元素放在end处,这时候begin位置就成了一个“坑”,在右边找到不符合条件的元素时,将这个元素放到begin位置,将之前的“坑”填好,以此类推,最后将标志key保存的值放在begin位置,将最后一个“坑”填满。
int Partion1(vector<int>& arr, int left, int right){ int end = right - 1; int begin = left; int key = arr[right - 1]; while (begin < end) { while (begin < end && arr[begin] <= key) begin++; if (begin < end) { arr[end] = arr[begin]; end--; } while (begin < end && arr[end] >= key) end--; if (begin < end) { arr[begin] = arr[end]; begin++; } } arr[begin] = key; return begin;}//齐头并进法int Partion2(vector<int>& arr, int left, int right){ int key = arr[right - 1]; int cur = left; int pre = cur - 1; while (cur < right) { if (arr[cur] < key && ++pre != cur) { swap(arr[pre], arr[cur]); } cur++; } if (++pre != right-1) swap(arr[pre], arr[right - 1]); return pre;}//三数取中法int MidNum(vector<int>& arr,int left, int right){ int mid = left + ((right - left) >> 1); if (arr[left] > arr[right]) { if (arr[mid] > arr[left]) mid = left; if (arr[mid] < arr[right]) mid = right; } else { if (arr[mid] < arr[left]) mid = left; if (arr[mid] > arr[right]) mid = right; } return mid;}int Partion3(vector<int>& arr, int left, int right){ int end = right - 1; int begin = left; int mid = MidNum(arr, left, end); swap(arr[mid], arr[right - 1]); int key = arr[right - 1]; while (begin < end) { while (begin < end && arr[begin] <= key) begin++; if (begin < end) { arr[end] = arr[begin]; end--; } while (begin < end && arr[end] >= key) end--; if (begin < end) { arr[begin] = arr[end]; begin++; } } arr[begin] = key; return begin;}void QuickSort(vector<int>& arr, int left, int right){ if (right - left > 1) { int key = Partion3(arr, left, right); QuickSort(arr, left, key); QuickSort(arr, key + 1, right); }}//非递归快排void QuickSortNor(vector<int>& arr){ int right = arr.size(); int left = 0; stack<int> s; s.push(right); s.push(left); while (!s.empty()) { left = s.top(); s.pop(); right = s.top(); s.pop(); if (right - left > 1) { int key = Partion3(arr, left, right); //保存右值 s.push(right); s.push(key + 1); //保存左值 s.push(key); s.push(left); } }}
六、堆排序
堆排序就是利用堆(堆的详细介绍)这种数据结构进行排序的算法,堆排序属于选择排序
时间复杂度:O(nlogn)
空间复杂度:O(1)
稳定性:不稳定
堆排序的步骤为:
1、基于所给元素创建一个大堆
2、使用堆删除的思想从最后一个结点向前调整
typedef struct Heap{ HPData* _array; int _size; int _capacity;}Heap;void HeapAdjust(HPData* array, int size, int root){ int child = root * 2 + 1; while (child < size) { if (child + 1 < size && array[child] < array[child + 1]) child += 1; if (array[child] > array[root]) { swap(array[child], array[root]); root = child; child = root * 2 + 1; } else return; }}void HeapSort(HPData* array, int size){ //建大堆 //找倒数第一个非叶子节点 int root = (size - 2) >> 1; for (; root >= 0; --root) HeapAdjust(array, size, root); //开始排序,使用删除节点的思想 int end = size - 1; while (end) { swap(array[0], array[end]); HeapAdjust(array, end, 0); end--; }}
七、归并排序
归并排序是简历在归并操作上的一中有效的排序算法,该算法是采用分治法的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列。显示每个子序列有序,再使子序列段间有序。若两个有序列表合并成一个有序列表,陈伟二路归并。
归并的缺点在于需要O(N)的空间复杂度,归并排序的思考更多的是解决在磁盘中的外排序问题。
时间复杂度:O(N*logN)
空间复杂度:O(N)
稳定性:稳定
void _MergeData(vector<int>& arr, int left, int mid, int right, vector<int>& temp){ int begin1 = left; int end1 = mid; int begin2 = mid; int end2 = right; int index = left; while (begin1 < end1 && begin2 < end2) { if (arr[begin1] < arr[begin2]) temp[index++] = arr[begin1++]; else temp[index++] = arr[begin2++]; } while (begin1 < end1) temp[index++] = arr[begin1++]; while (begin2 < end2) temp[index++] = arr[begin2++];}void _MergeSort(vector<int>& arr, int left, int right, vector<int>& temp){ if ((right - left) > 1) { int mid = left + ((right - left) >> 1); _MergeSort(arr, left, mid, temp); _MergeSort(arr, mid, right, temp); _MergeData(arr, left, mid, right, temp); copy(temp.begin() + left, temp.begin() + right, arr.begin() + left); }}
★★系统知识★★
一、进程
进程间通信
为什么要为用户提供程间通信方式?
因为进程的独立性,每个进程操作的都是自己虚拟地址空间中的虚拟地址,无法访问别人的地址,所以无法直接通信。
管道(半双工)
本质:内核中的一块缓冲区
原理:人多个进程访问到相同的缓冲区来实现通信,管道通信使用的系统调用的IO接口,遵循一切皆文件的思想。
匿名管道
一个进程创建匿名管道,操作系统再内核中重建一块缓冲区,并返回两个人文件描述符作为管道的操作句柄(一个用于读,一个用于写,方向的选择权交给用户),但是这个缓冲区在内核中没有标识。
操作接口
创建管道
int pipe(int pipefd[2]);pipefd:至少有两个int型元素的数组创建一个管道,通过pipefd获取系统返回的管道操作句柄,其中pipefd[0]: 用于从管道中读取数据pipefd[1]: 用于向管道中写入数据返回值成功返回 0 失败返回-1
ipc:进程间通信方式
管道必须创建于子进程之前,子进程这样才能复制到管道的操作句柄
管道的读写特性
1、若管道中没有数据,则read会阻塞等待,直到数据被写入
2、若管道中数据满了,则write会阻塞等待,直到数据被读取
3、若管道中的所有读端被关闭,则wirte会触发异常,进程退出
4、若管道的所有写端被关闭,则read会返回0
5、管道的read返回0,不仅仅指的是没有读到数据,还有可能是写端被关闭
grep make:从标准输入循环读取数据,对读到的数据进行过滤匹配
匿名管道的实现就是创建两个进程,一个运行ls,另一个运行grep
让ls这个进程标准输出,重定向到管道写入端
命名管道
在命名管道中,同一主机上的任意进程之间通信,命名管道在内核中这块缓冲区是由标识的,一维着所有的进程都可以通过这个标识找到这块缓冲区来实现通信。命名管道的标识符实际上是一个文件,可见于文件系统,意味着所有进程都可以通过打开文件来访问到内核中的缓冲区。
命名管道的打开特性:
若文件当前没有被已读的方式打开,则以O_WRONLY打开时会阻塞
若文件当前没有被已读的方式打开,则以O_RDONLY打开时会阻塞
命名管道的读写特性:类同于匿名管道
命名管道和匿名管道
匿名管道只能用于具有亲缘关系的进程间通信
命名管道可以作用于同一主机上任意进程间通信
管道特性
1、管道是半双工通信
2、管道的读写特性+(命名管道的打开特性)
3、管道的生命周期随进程(所有管道的操作句柄被关闭)
4、管道自带同步与互斥(管道的读写大小不超过PIPE_BUF时是安全的)
5、管道提供字节流服务,但是存在粘包问题
管道通信
管道通信的本质是通过内核中的缓冲区来实现通信,
进程1将数据从用户态缓冲区拷贝到内核态缓冲区,然后进程2将数据冲内核态缓冲区拷贝到用户态缓冲区,涉及两次用户态与内核态之间的数据拷贝。
共享内存
最快的进程间通信方式
共享内存原理
1、在物理内存中开辟一块内存空间
2、将这块空间通过页表映射到进程的虚拟地址空间中
3、进程可以直接通过进程虚拟地址空间访问这块物理内存,进行操作,若多个进程映射同一块物理内存,就可以实现相互通信,这样就可以通过虚拟地址改变内存中的数据,其他进程也会随之改变。
4、相较于其他进程间通信方式,少了两步内核态和用户态之间的数据拷贝
5、释放映射关系
6、删除共享内存
1、创建共享内存int shmget(key_t key, size_t size, int shmflg); key: 共性内存在操作系统中的标识符 size: 共享内存大小 shmflag: IPC_CREAT 共享内若存在则打开,否则创建 IPC_EXCL 与IPC_CREAT同时使用,若共享内存村在则报错返回 mode 贡献内存的操作权限 返回值: 正整数-->共享内存的操作句柄
2、将共享内存映射到虚拟地址空间void *shmat(int shmid, const void *shmaddr, int shmflg); shmid 创建共享内存返回的操作句柄 shmaddr 共享内存在虚拟地址空间中的首地址--通常置空(NULL),有操作系统来指定 shmflg SHM_RDONLY--映射之后,共享内存只读,通常置0,可读可写 返回值 映射首地址 失败(void*) -1
3、通过虚拟地址进行操作
mencpy
4、解除映射关系
int shmdt(const void *shmaddr); shmaddr shmat建立映射关系是返回的映射首地址
5、删除共享内存
int shmctl(int shmid, int cmd, struct shmid_ds *buf); shmid 共享内存操作句柄 cmd 共享内存操作()即将进行的操作 IPC_RMID buf 用于获取/设置共享内存信息
共享内存的删除流程:共向内存在删除的时候,首先会判断当前映射链接数是否为0,若为0直接删除,否则表示现在还有其他进程在使用,则共享内存不能立即被删除,但是会拒绝后续进程的映射链接,等待映射链接数为0时删除这块共享内存。
消息队列
操作系统在内核中为用户闯将一个队列,其他进程通过访问相同的队列进行通信,消息队列传输的是有类型的数据块,共享内存、消息队列的生命周期随内核
用于多个进程间有类型的数据块传输
ipcs 查看 -m 共享内存 -s 信号量 -q 消息队列ipcrm -m shmid 删除对应的共享内存 -m 共享内存 -s 信号量 -q 消息队列
信号量
实现进程间的同步与互斥,
本质:在内核中是一个计数器+唤醒+等待.
同步:保证多个进程之间对临界资源访问的时序合理性-—-等待与唤醒
互斥:保存多个进程之间同一时间对临界资源的唯一访问型性
原理:
进程对资源进行访问操作之前,先进行临界资源计数判断,
若计数<=0,则阻塞,等待创建资源 --计数-1 等待在等待队列中
若计数> 0,则直接返回,按照程序流程就可以直接擦做资源了,计数-1
有进程创建了资源,计数+1, 并唤醒等待队列上的那些进程
二、线程
线程是在Linux中使用PCB模拟实现的轻量级进程,进程从表面来说是一个运行起来的程序,但是从操作系统角度来说,进程就是操作系统堆为一个正在执行的程序而创建的描述符,操作系统通过对这个描述来对程序进行控制,这个描述信息就是PCB,所以说线程就是一个轻量级的进程,是一个进程等的子任务,线程共享进程中部分资源,包括数据段、代码段和扩展段,每个线程拥有自己的线程描述符、数据栈、用于存放上下文数据的寄存器、错误码、信号屏蔽字。线程是进程中的一个执行流,是操作系统调度和执行的最小单位。
线程间通信(同步)的方式
临界区:通过多线程的串行化来访问公共资源或一段代码,速度快,适合控制数据访问;
互斥量:采用互斥对象机制,只有拥有互斥对象的线程才有访问公共资源的权限。因为互斥对象只有一个,所以可以保证公共资源不会被多个线程同时访问
信号量:为控制具有有限数量的用户资源而设计的,它允许多个线程在同一时刻去访问同一个资源,但一般需要限制同一时刻访问此资源的最大线程数目。
事件(信号):通过通知操作的方式来保持多线程同步,还可以方便的实现多线程优先级的比较操作
三、进程与线程
什么是线程?
线程是在Linux中使用PCB模拟实现的轻量级进程,进程从表面来说是一个运行起来的程序,但是从操作系统角度来说,进程就是操作系统堆为一个正在执行的程序而创建的描述符,操作系统通过对这个描述来对程序进行控制,这个描述信息就是PCB,所以说线程就是一个轻量级的进程,是一个进程等的子任务,线程共享进程中部分资源,包括数据段、代码段和扩展段,每个线程拥有自己的线程描述符、数据栈、用于存放上下文数据的寄存器、错误码、信号屏蔽字。线程是进程中的一个执行流,是操作系统调度和执行的最小单位。
线程和进程的区别?
1、一个线程只能属于一个进程,而一个进程可以有一个或多个线程,线程是依赖于进程存在的。
2、进程在执行过程中拥有一个独立的内存单元,而多个线程共享同一个进程的内存。也就是说资源分配给进程,在这个进程中的所有线程共享其中的所有资源,同一个进程中所有线程共享代码段、数据段、扩展段。但是每个线程拥有自己的栈段,栈段又叫运行时段,用来存放所有局部变量和临时变量。栈寄存器(上下文数据)信号屏蔽字errno(错误码)线程标识符
3、进程是资源分配的最小单元,线程是CPU调度的最小单元
4、系统的开销:由于创建或撤销进程时,系统都要为他分配或回收,如内存空间、I/O设备等。因为操作系统所付出的开销将显著的大于在创建线程或撤销线程时的开销。类似地,在进行进程切换时,涉及到整个当前进程CPU环境的保存以及新被调度运行的进程的CPU环境的设置。而线程切换只需要保存和设置少量的寄存器的内容,并不涉及存储器管理方面的操作。所以进程切换的开销要远远大于线程切换的开销。
5、通信:由于同一个进程中的多个线程具有相同的地址空间,所以他们之间的同步和通信的实现也变得比较简单。进程间通信IPC有管道、共享内存、消息队列、信号量进行通信。线程间可以直接读写进程数据段来进行通信。在有的系统中,线程的切换、同步和通信都无需操作系统内核干预。
6、进程编程调试简单可靠性高,但是创建和销毁的开销较大,相对于线程来说正好相反,开销小、切换速度快,但是编程调试相对复杂。
7、进程之间不会相互影响,但是同一个进程中只有一个线程挂掉了将导致整个进程挂掉。
8、多线程适合于I/O密集的工作场景、多进程适合于CPU密集型的工作场景。
怎么实现线程池?
1、设置一个生产者消费者队列,作为临界资源。
2、初始化n个线程,并让其运行起来,以加解锁的方式去队列取任务运行。
3、当任务队列为空的时候所有的线程阻塞。
4、当生产者队列来了一个任务后,先对队列加锁,把任务挂在队列上,然后使用条件变量去通知阻塞中的一个线程。
四、线程安全
什么是线程安全
多线程对临界资源进程操作二不会产生二义性。
保证线程安全的机制
使用同步与互斥保证线程安全。
线程同步
同步就是协同步调,按预定的先后次序进行运行。线程同步是指多线程通过特定的设置(如互斥量,事件对象,临界区)来控制线程之间的执行顺序(即所谓的同步)也可以说是在线程之间通过同步建立起执行顺序的关系,如果没有同步,那线程之间是各自运行各自的!
线程互斥是指对于共享的进程系统资源,在各单个线程访问时的排它性。当有若干个线程都要使用某一共享资源时,任何时刻最多只允许一个线程去使用,其它要使用该资源的线程必须等待,直到占用资源者释放该资源。线程互斥可以看成是一种特殊的线程同步。
线程同步有四种方式:临界区、互斥对象、信号量、事件对象。
临界区、互斥对象:主要用于互斥控制,都具有拥有权的控制方法,只有拥有互斥对象的线程才能执行任务,所以拥有互斥对象的线程,执行完任务后一定要释放该对象。
信号量、事件对象:事件对象是以通知的方式进行控制,主要用于同步控制。
临界区
通过对多线程的串行化来访问公共资源或一段代码,速度快,适合控制数据访问。在任意时刻只允许一个线程对共享资源进行访问,如果有多个线程试图访问公共资源,那么在有一个线程进入后,其他试图访问公共资源的线程将被挂起,并一直等到进入临界区的线程离开,临界区在被释放后,其他线程才可以抢占。它并不是核心对象,不是属于操作系统维护的,而是属于进程维护的。
互斥对象
互斥对象和临界区很像,采用互斥对象机制,只有拥有互斥对象的线程才有访问公共资源的权限。因为互斥对象只有一个,所以能保证公共资源不会同时被多个线程同时访问。当前拥有互斥对象的线程处理完任务后必须将线程交出,以便其他线程访问该资源。
信号量
信号量也是内核对象。它允许多个线程在同一时刻访问同一资源,但是需要限制在同一时刻访问此资源的最大线程数目。
条件变量
条件变量是一种同步机制,允许线程挂起,直到共享数据上的某些条件得到满足。条件变量要和互斥锁相结合,避免出现条件竞争,就是一个线程预备等待一个条件变量,当它在真正等待之前,另一个线程恰好触发了该条件。
互斥锁
互斥从表面上理解就是相互排斥。所以互斥锁从字面上理解就是一个进程拥有了这个锁,他将排斥其他所有的进程访问被锁住的东西,其他的进程如果需要锁只能阻塞等待,等拥有锁的进程解锁后才能继续运行。在使用互斥锁的时候要注意一点就是解铃还须系铃人,如果拥有锁的进程不解锁,那么其他进程将永远不能得到互斥锁。
读写锁
互斥锁是排它锁,条件变量出现后和互斥锁配合能够有效地节省系统资源并提高线程之间的协同工作效率。互斥锁的目的是为了独占,条件变量的目的是为了等待和通知。对于文件来说,最常见的操作就是读和写,读文件不会修改文件的内容,所以多个进程同时读也是可以的,但是当写进程需要写数据的时候为了保证数据的一致性,所有读的进程都不能读数据,否则读到的数据可能是一半是旧的,一半是新的,这样就乱了。所以为了防止读数据的时候写入新的数据,在读数据的时候就必须对文件加锁,但是如果有两个进程要同时读,另一个进程就只能等待,从性能上讲,是浪费时间。所以这是要用到读写锁,读写锁的出现有效地解决了多进程并行读的问题,这样每个进程在读的时候需要申请读锁,进程之间相互不干扰。如果有进程要写数据,需要申请写锁,如果有读锁或者写锁存在,那么只能等待所有的锁都解锁了才可以进行写操作。有一点值得注意的是,读锁是所有进程共享的,但是写锁是互斥的。
记录锁
为了增加同步的性能,可以在读写锁的基础上进一步细分被锁对象的粒度。比如说写操作是针对文件的前1k字节,但是读操作是针对文件的后1k字节,这样就可以对文件的前1k上写锁,后1k上读锁,这样读和写就可以并发进行了,文件锁是记录锁的一个特例,记录锁针对的是文件中的某一部分内容,如果记录锁将整个文件上锁,这时候的记录锁就是一个文件锁。
线程互斥
五、死锁的条件以及解决的办法
什么死锁
死锁的条件
必须要满足以下的四个条件才可以发生死锁。
1、互斥条件
指的是某个资源同时只能让一个进程占有,比如说打印机。必须要在占有该资源的进程主动释放资源后其他进程才可以占有。这时有资源本身属性决定的。
2、不可抢占资源
进程获得的资源在没有使用完的情况下,这份资源不能被资源申请者强行占有,只能等该资源释放候才可以申请。
3、占有且申请条件
一个进程至少占有了一个资源,他又要申请新的资源,但是申请的资源被另一个进程所占有,这时进程阻塞,在他阻塞的时候仍然占有已有的资源。
4、循环等待条件
可以说是一个进程等待队列,p1等待p2所占有的资源,同时p1不对自己的资源进行释放,p2在等待p1所占有的资源,同样的p2也不对自己所占有的资源进行释放,这样就形成了一个进程的循环等待。
死锁的预防
死锁的预防就是保证系统不进入死锁的状态的一种策略。
1、破坏互斥条件
但是有些资源确实不能被共享,这是由资源的属性决定的。
2、破坏不可抢占条件
将资源的申请设置为可抢占式,也就是要求申请失败的进程要释放自己占有的资源给其他进程使用,但是会降低系统性能。
3、破坏占有且申请条件
直接一次性将自己所需要的资源申请完。当时这样有两个问题,一个是有时候不可预知需要什么资源,另一个是资源的利用率低,进程可能会长期占有自己可能用不到的资源。
4、破坏循环等待条件
将资源进行分类、编号,让进程按照排好的序号进行申请。存在的问题是对资源的编号可能是困难的,维护相应的序列也可能是困难的。
死锁的避免
死锁的避免是指不限制进程有关申请资源的命令,而是对进程所发出的每一个申请资源命令甲乙动态的检查,并且根据检查结果来判断是否进行资源分配。
银行家算法:我们可以把操作系统看作是银行家,操作系统管理的资源相当于银行家管理的资金,进程向操作系统请求分配资源相当于用户向银行家贷款。
为保证资金的安全,
银行家规定:
当一个顾客对资金的最大需求量不超过银行家现有的资金时就可接纳该顾客;
顾客可以分期贷款,但贷款的总数不能超过最大需求量;
当银行家现有的资金不能满足顾客尚需的贷款数额时,对顾客的贷款可推迟支付,但总能使顾客在有限的时间里得到贷款;
当顾客得到所需的全部资金后,一定能在有限的时间里归还所有的资金.
操作系统按照银行家制定的规则为进程分配资源,当进程首次申请资源时,要测试该进程对资源的最大需求量,如果系统现存的资源可以满足它的最大需求量则按当前的申请量分配资源,否则就推迟分配。当进程在执行中继续申请资源时,先测试该进程本次申请的资源数是否超过了该资源所剩余的总量。若超过则拒绝分配资源,若能满足则按当前的申请量分配资源,否则也要推迟分配。
六、IO多路转接
select
对大量的IO事件是否就绪进行监控,并且可以告诉进程哪一个IO就绪了,然后就可以对就绪的IO进行具体的操作。事件是否就绪指的是文件描述符可读/可写/异常。他的流程是这样的:用户将需要监控的文件描述符添加到一个描述符集合中,然后select将描述符集合拷贝到内核中,对集合中的描述符进行轮询判断,如果有描述符事件就绪了,select调用返回,并且返回描述符的个数。否则隔一会再进行轮询判断。在调用返回之前会将集合中没有就绪的时间描述符移除,在集合中只剩下就绪的文件描述符。返回之后进程判断有哪个文件描述符在集合中,然后对其进行操作。
select的缺点
select所能监控的文件描述符最多只有1024个,他每次都需要将文件描述符集合拷贝到内核中进行监控,在用户态和内核态之间进行数据的拷贝,在内核中要对所有的文件描述符进行轮询遍历判断,性能会随着描述符的增加而降低,每次返回的时候都需要将文件描述符集合中非就绪的描述符进行移除,再次监控的时候需要添加新的描述符,编码复杂,select虽然返回给用户文件描述符集合,但并不会告诉用户哪些文件描述符是就绪的,需要用户自己去遍历判断,性能同样会随着描述符的增多而降低。
select的优点
select遵循posix标准,可以跨平台使用,并且他监控超时的等待时间可以精细到微秒。
poll
poll的功能也是对大量的文件描述符事件进行监控,用户为每一个关心的文件描述符定义一个事件结构,内容为文件描述符+所关心的事件,poll将描述符事件结构数组拷贝到内核中进行监控,同样是轮询遍历数组中的描述符判断事件是否就绪,如果就绪了就将事件放到事件结构revents中,调用返回,否则会隔一段时候后继续遍历判断,当调用返回后,用户遍历事件结构数组,判断结构中的revents事件中是否包含了所关心的事件。
poll缺点
每次监控需要将所有的事件结构信息拷贝到内核中,在内核中进行轮询判断描述符事件是否就绪,会随着描述符的增多而降低性能,poll的返回同样不会直接告诉用户哪些描述符就绪了,需要用户轮询去遍历事件结构数组,判断哪个是用户所关心的,并且对其进行操作,poll相对于select来说没有大的改变,性能并没有提升多少,并且poll没有遵循POSIX标准,所以不能跨平台。
poll优点
poll采用事件结构的方式对描述符进行监控,简化了多个描述符集合的监控编码流程,poll监控的文件描述符的数量没有上限。
epoll
epoll可以说是Linux中最优秀的多路转接,他的性能是最高的,他在内核中创建eventpoll结构体,这个结构体是由红黑树(存放事件节点)和双向链表(存放就绪的文件描述符)实现。在linux2.6.8之前需要对epoll监控的文件描述符数量上限进行设置,但是在Linux2.6.8之后就忽略了这一参数,只要大于0就可以。epoll是一个异步操作,epoll开始监控后,告诉操作系统开始进程监控eventpoll结构体中红黑树中的所有事件节点,操作系统为每一个文件描述符就定义了 一个事件回溯,当文件描述符指定的事件就绪后,就将这个描述符指定的事件接收信息节点添加到eventpoll中的双向链表中。相当于直接告诉了用户哪些文件描述符就绪了。
epoll触发方式
水平触发
对于可读事件来说,文件描述符接收缓冲区数据大小只要大于低水位标记就会触发,对于可写事件来说只要是文件描述符的发送缓冲区空间大小只要大于低水位标记就会触发。水平触发是epoll的默认触发方式,EPOLLLT宏;
边缘触发
对于可读事件来说,描述符缓冲区中每当有新数据到来时才会触发一次,对于可写事件来说描述符发送缓冲区从没有剩余空间到有剩余空间时才会触发一次。边缘触发需要手动设置,EPOLLET。边缘触发每条数据值触发一次,这就意味着这次触发的过程中需要用户将所有的数据存取完毕,因为这条数据不会触发第二次,只有等到下一条数据到来才会触发下一次。由于边缘触发需要一次性将所有的数据都处理完,但是用户有可能也不知道数据有多长,所以要使用循环才可以将所有的数据处理完,但是循环会出现一个就是读到没有数据的时候就会阻塞。相应的解决方案是将IO操作设置为非阻塞的。
将描述符设置为非阻塞:int flag = fcntl(fd, F_GETFD,0) ----获取描述符属性fcntl(fd, F_GETFD, flag | O_NONBLOCK)---设置描述符属性为增加一个非阻塞属性
epoll优点
监控的文件描述符没有上限,采用事件结构对描述符进行监控,简化了多个描述符集合的监控流程,每条epoll的事件信息只向内核中拷贝一次,是一个异步阻塞操作,操作系统仅对描述符事件进行监控,并且使用的是事件回调方式,描述符就绪后直接拷贝到对应的位置,不需要轮询遍历判断,所以描述符的增加不会影响性能,在回调的过程中是将就绪的描述符添加到双向链表中,epoll_wait只需要判断双向链表就知道当前是否有描述符事件就绪,当epoll_wait返回时,是直接将就绪信息拷贝到之前给予的事件结构数组中,这就相当于直接将就绪的描述符告诉给了用户。
epoll缺点
epoll没有遵循POSIX标准,所以不能跨平台,他监控超时只能精确到毫秒。
同步异步和阻塞非阻塞的区别
阻塞是为了完成一个功能发起调用,如果当前不满足条件,要等待满足条件了才调用返回。非阻塞恰恰相反,如果不满足条件直接报错返回,阻塞和非阻塞强调的是发起一个调用后是否立即返回。同步指的是如果不满足条件需要进程进行等待,知道条件满足了才会调用返回,对于异步来说,他为了完成某个功能只发起一个调用,进程自身并不去完成,功能由操作系统完成后调用进程,所以说同步和异步强调的是发起调用后,功能是否有进程自己完成。同步功能是由进程自身完成的,通常是阻塞的,异步功能是由系统完成的,有阻塞也有非阻塞。在多路转接中select和poll是同步的,需要进程自己来实现轮询的遍历监控,epoll是异步阻塞的,因为功能的完成是操作系统完成的,而进程是调用epoll_wait阻塞等待操作系统完成监控。
场景
多路转接模型实现高并发模型相较于多线程/多进程消耗资源较少,但并不是说所有的场景都使用多路转接模型,它只是用于有大量的文件描述符需要监控,但是同一时间只有少量活跃,并且每个描述符的处理过程时间不能太长。
七、用户态和内核态
在inter x86结构的cpu中一共有四个级别,0-3级,0级特权最高,3级特权最低,这样做是为了让最关键的工作交给特权级最高的进程去执行,可以做到集中管理,减少有限资源的访问和使用冲突。
当一个进程在执行用户自己的代码时处于用户态,此时特权级最低,为3级,是普通用户进程运行的特权级,大部分用户直接面对的程序都是运行在用户态,并且Ring3状态不能访问Ring0状态的地址空间,包括代码和数据,当一个进程因为系统调用陷入内核代码中执行时处于内核运行态,此时特权级最高,为0级。执行的内核代码会使用当前进程的内核栈,每个进程都有自己的内核栈。内核态的进程执行完又会切换到Ring3,回到用户态。这样用户态的程序就不能随意操作内核地址空间,具有一定的安全保护作用。用户态切换到内核态通常有三种方式:系统调用、异常、外围设备的断。
系统电泳是用户态进程主动要求切换内核态的一种方式。用户态进程通过系统调用申请使用操作系统的服务程序完成工作。比如说fork()就是执行了一个创建新进程的系统调用。
异常是当cpu在执行运行在用户态的程序时,发生了一些没有预知的异常,这时会触发由当前运行进程切换到处理词异常的内核相关进程中,也就是切换到了内核态。
外围设备的中断指的是当外围设备完成用户请求的操作后,会向CPU发出相应的中断信号,这时CPU会暂停执行下一条即将要执行的指令而转到与中断信号对应的处理程序去执行,如果当前执行的执行时用户态下的程序,那么转换的过程自然就是由用户态转换到内核态。
这三种方式是系统在运行时有用户态切换到内核态的主要方式,其中系统调用可以认为是用户进程主动发起的,异常和外围设备中断是被动的。
八、什么是POSIX标准?
posix表示可移植操作系统接口,它定义了操作系统应该为应用程序提供的接口,这一标准带来的好处就是在一个POSIX兼容的操作系统中编写的符合其标准的应用程序可以直接在其他的POSIX支持的操作系统中无需修改而能够直接编译运行。
九、协程★★☆☆☆
协程可以认为是比线程更小的执行单元,它自带CPU上下文,只要在合适的时机,就可以把一个协程切换到另一个协程,只要在这个过程中保存或恢复CPU上下文那么程序还是可以运行的。
线程与协程的区别就是系统为了程序运行的高效性每个线程都有自己缓存Cacge等数据,操作系统还会帮助做这些数据的恢复操作,可以说线程的切换时非常耗性能,但是协程的切换只是单程的操作CPU的上下文,所以一秒钟切换上百万次系统都扛得住。但是协程有一个问题就是系统并不感知,所以操作系统不会对其做切换。在设计的时候可以是一个线程作为容器,然后再里面放置多个协程,构成一个协程池,在协程池中有一个被动的调度器,当有协程执行不了的时候,就要去通知协程调度器通过算法找到当前最需要CPU的协程,然后去执行。
十、Linux程序典型内存布局
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-YdIAz6xP-1637763958109)(C:\Users\LENOVO\AppData\Roaming\Typora\typora-user-images\1566374936321.png)]
十一、内存管理
虚拟内存
什么是虚拟内存
内存是一个程序运行的物质基础,由于内存空间总是有限的,所以让有限的空间运行较大的程序这个问题就出现了,Linux中就使用了虚拟内存技术解决了这个问题。虚拟内存是计算机系统管理的一种技术,可以让应用程序认为自己有拥有连续的可用内存,让系统看上去具有比实际物理内存大得多的内存空间,而实际上是被分隔成多个物理内存的碎片,还有部分暂时存储在外部磁盘存储器上,在需要时进行数据交换。由于程序是逐段被运行的,虚拟内存技术是先将一段程序导入内存中,执行完后导入下一段程序,给我们的感觉是内存变大了,实际上物理内存并没有发生变化。
每个进程都有自己独立的4G内存空间,各个进程的内存空间具有类似的结构,这个空间知识虚拟内存空间,每次访问内存空间的某个地址,都需要吧地址翻译成实际的物理内存地址。每一个进程都会建立自己的内存空间,用来存放这个进程的数据、代码等,这些内存空间都与对应的磁盘空间映射。所有进程都共享同一物理内存,每个进程只把自己目前需要的虚拟内存并保存到物理内存中。进程通过页表来记录哪些数据在物理内存上,哪些不在,如果在是在物理内存的哪里。页表一般分为两个部分,一部分记录此页是否在物理内存中,另一部分用来记录在物理内存中的地址,当一个进程需要访问某个虚拟地址的时候,要先去看页表,如果发丝昂对象的数据不在物理内存中,就会缺页异常,然后通过解决缺页异常处理后,访问对应的物理内存。
虚拟内存的优点
1、每个进程的内存空间都是一致并且是固定的,所以链接器在链接可执行文件的时候,可以设定内存地址,就可以不用去管这些数据最终实际的内存地址,这是独立内存空间的好处。当不同的进程使用的同样的代码的时候,比如说库文件中的代码,物理内存中可以只存储一份这样的代码,不同的进程只需要将自己的虚拟内存映射到对应的位置就可以,这样节省了内存。在程序需要分配连续的内存空间的时候,只需要在虚拟内存空间中分配连续的空间,而不需要实际的物理内存空间连续,这样可以利用物理内存中的内存碎片,提高了内存的利用率。
2、虚拟内存保证了读写内存的安全性。物理内存本身是不限制访问的,任何地址都可以读写,而操作系统要求不同的页面要具有不同的访问权限,这是利用CPU模式和MMU的内存保护机制实现的。
3、如果一个操作系统同时运行着很多进程,并且为各个进程分配的内存之和大于实际可用的物理内存,虚拟内存管理就可以使这种情况各个进程仍然可以正常运行。因为各个进程分配的只不过是虚拟内存的页面,这些页面的数据可以映射到物理页面,也可以临时保存到磁盘上而不占用物理页面,在磁盘上临时保存的虚拟内存页面可能是一个磁盘分区,也可能是一个磁盘文件,被称为交换设备。当物理内存不够用的时候,可以将一些不常用的物理页面中的数据临时保存到交换设备中,然后这个物理页面就可以被认为是空闲的,可以重新分配给进程使用,这个过程称为换出。如果进程要用到被换出的页面,就从交换设备再加载回物理内存,这个过程称为换入。换出和换入在操作系统中统称为换页,所以系统中可分配的内存总量为:物理内存的大小+交换设备的大小。
4、虚拟内存作为内存管理工具,大大的简化了内存管理,并且为每个进程提供了一个独立的虚拟地址空间,多个虚拟页面可以映射到同一个共享的物理页面上。按照需要进行页面的调度和独立的虚拟地址空间的结合,让虚拟内存简化了连接和加载,代码和数据共享,以及应用程序的内存分配。
简化链接:说的是独立的地址空间允许每个进程的内存映射使用相同的基本格式,不用考虑代码和数据在物理内存的地址。当不用的进程使用相同的代码的时候,比如说库文件中的代码,在物理内存中只存放了一份这样的代码,不同的进程只需要将自己的虚拟内存映射到对应的位置就可以。
简化加载:指的是虚拟内存让向内存中中加载可执行文件和共享对象文件变得容易。将一组连续的虚拟页面映射到任意一个文件中的任意位置的表示法称为内存映射。
简化共享:指的是独立地址空间为操作系统提供了一个管理用户进程和操作系统自身之间共享的机制。一般情况下每个进程都有自己独立的内存空间,里面存放着该进程的代码、数据、堆栈,这些内容都是不与其他进程所共享的。在操作系统中为了多个进程之间通讯的考虑,预留出了一块内存区,多个进程如果要实现共享,由操作系统创建页表,将相应的虚拟页映射到不连续的物理页面,然后将这个页表映射到进程的这块内存中,这样多个进程就可以对同一块物理内存进行读写,实现数据的共享。
简化内存分配:指的是虚拟内存向用户进程提供一个简单的分配额外空间的机制。当一个用户程序要求额外空间的时候,操作系统分配K个适当的连续的虚拟内存页面,并且将他们映射到物理内存中的K个任意页面,操作系统没有必要分配K个连续的物理内存页面。
鉴于《深入理解计算机系统》龚奕利·译
缺页异常解决办法
什么是缺页异常
在分页系统中,可以通过查询页表的状态为来确定要访问的页面时候存在内存中,每当所有访问的页面不在内存时,或者访问的页面在内存中但是时不可写的,会产生一次缺页中断。缺页本身就是一种中断,它与一般的中断一样,需要经过4个处理步骤
1、保护CPU现场。
2、分析中断的原因
3、转入缺页中断处理程序中进行处理
4、恢复CPU现场,继续执行
但是缺页中断时可能由于要访问的页面不在内存中,这时就会由硬件产生一种特殊的中断,缺页中断与一般的中断区别在于:在指令执行期间产生和处理缺页中断信号、一条指令在执行期间可能会产生多次缺页中断、缺页中断返回时执行的是产生中断的那一条指令,而不是像一般中断去执行下一条指令。
页面置换算法
进程运行过程中,如果发生缺页中断,但是内存中没有空间的物理块,为了能够把所缺的页面装入内存,系统必须要从内存中选择一页调出到磁盘的对换区。但此时应该吧那个页面换出,则需要根据一定的页面置换算法来确定。通常有最佳置换、先进先出置换、最近最久未使用置换、时钟置换算法。
最佳置换算法(OPT)
从主存中移除永远不需要的页面,如果没有这样的页面存在,则选择最长时间不需要的访问的页面。由于所选择的被淘汰的页面是以后永不使用的,或者是在长时间内不再被访问的页面,这样就可以保证获得最低的缺页率。
先进先出算法(FIFO)
是最简单的页面置换算法。这种算法的思想是当需要淘汰一个页面的时候,总是选择驻留主存事件最长的页面进行淘汰,也就是先进入主存的页面会被先淘汰。这是因为最早调入主存的页面的不再被使用的可能性最大。
FIFO算法还会产生当所分配的物理块树增大而页故障数不减少反而增加的异常现象,这是由Belady于1969年发现,所以称为Belady异常,只有FIFO算法会出现Belady异常,而LRU和OPT算法永远不会出现Belady异常。
需要注意的是内存的页面中最先进入队列的页面会被来的页面直接覆盖,而不是先出队,然后再从大队尾入队。
最近最久未使用算法(LRU)
这种算法的思想是:利用局部性原理,根据一个作业在执行中过去的页面访问历史来推测未来的行为。他认为过去一段时间里不曾被访问的页面,在最近的将来也不会再被访问。所以这种算法的实质就是当需要淘汰一个页面的时候,总是选择在最近一段时间内最久没有使用的页面淘汰。
时钟置换算法(CLOCK)
由于LRU算法的性能接近于OPT,但是实现起来比较困难,并且开销大,FIFO算法实现简单,但是性能差。所以师徒用比较小的开销接近LRU的性能,这类算法都是CLOCK算法的变体。
简单的CLOCK算法是给每一帧关联一个附加位,称为使用位。当某一页首次装入主存时,该帧的使用位设置为1,当给爷虽有在被访问到时,它的使用位也置为1。对于也替换算法来说,用于替换的候选帧集合看做一个循环缓冲区,并且有一个指针与之关联。当某一页被替换时,给指针被设置成指向缓冲区中的下一帧。当需要替换一页时,操作系统扫描缓冲区,以查找使用位被置为0的一帧。每当遇到一个使用位为1的帧时,操作系统就会将该位重新置为0,如果在这个过程开始时,缓冲区中所有的使用位均为0,则选择遇到的第一个帧替换;如果所有的帧使用位均为1,则指针在缓冲区中完整的循环一周,把所有的使用位都置为0,并且停留在最初始的位置上,替换该帧中的页。由于这个算法循环地检测各页面的情况,所以被称为CLOCK算法,又称为最近未使用算法。
MMU
内存管理单元简称MMU,他杜泽虚拟地址到物理地址的映射,并提供硬件机制的访问权限检查。MMU使得每个用户进程拥有自己独立的地址空间,并通过内存访问权限的检查保护每个进程所用的内存不被其他的进程破坏。
物理内存
在内核态申请内存比在用户态申请内存更为直接,他没有采用用户态的延迟分配内存技术。内核认为一旦有内核函数申请内存就必须立即满足该申请的请求,并且这个请求一定是合理的。相反,对于用户态申请内存的请求,内核总是尽量延后分配内存,用户进程总是先获得一个虚拟内存区的使用权,最终通过缺页异常获得一块真正的物理内存。
十二、动态库和静态库
什么是库?
平时在写代码的时候会经常添加一些头文件,添加这些头文件其实是让编译器从一个目录下去寻找这个文件,这个目录就是我们常说的库。在Linux中库一般存放在user/lib目录。库就是将一些常用的函数的目标文件打包在一起,提供相应的函数接口,以便于使用。
什么是静态库?
静态库就是在编译连接的时候,将库中的代码连接直接复制到可执行文件中,这样程序在运行的时候就不用去连接动态库了。静态库的这个连接过程就是静态链接。
什么是动态库?
动态库就是程序在运行的时候才去连接动态库的代码,可以多个程序共享动态库中的代码,这个动态库的连接过程就是动态链接,也就是在执行文件开始之前将外部函数的机器码有系统从磁盘上对应的动态库中向内存复制一份。
静态库和动态库的区别?
1、动态库是在运行时有系统调用库函数实现链接,代码较小巧。而静态库是在链接是复制到代码中,代码量比较庞大,冗余度高。
2、由于静态库是通过复制的方式,所以他在编译连接之后就不再需要静态库,代码的可以执行强,但是动态库由于是利用本地的库函数,如果将代码移植到其他电脑会出现运行bug等,可移植性差。
3、动态库必须放在指定的目录下完成连接,但是静态库只需要给出链接文件的路径就可以。
4、他们的相同点就是在库文件中不能出现main函数,库都是用来提供函数接口的,不暴露源代码,所有的库的目的都是为了增加代码的复用,可共享性,减小冗余。
5、在windows中静态库是后缀是.lib,动态库是.dll,在Linux中静态库是.a,动态库是so。
6、使用ar创建静态库
生成动态和静态库
gcc -fPIC -c child.c -o child.o 生成目标文件
gcc --share child.o -o lid+库名称+.so 动态库
gcc -ar rcd lib+库名称+.a 静态库
windows下的动态库 .dll 静态库 .lib
先生成二进制的.o文件gcc -fPIC -c child.c -o child.o生成静态库ar -rc libchild.a child.o生成动态库gcc --shared -fpic libchild.so child.o链库-l + 库名,但是库必须保存在lib、usr/lib、usr/local/lib目录下,否则要用-L + 文件路径 + -l + 库名
十三、软连接和硬链接
十四、线程的切换
★★网络知识★★
一、TCP和UDP的区别及应用场景
区别
1、面向连接和无连接角度
tcp建立一个链接需要三次握手,断开的时候需要进行四次挥手。TCP在断开是主动方要进入一个TIME_WAIT状态,要等待2MSL才会对连接(端口)释放,这个时间要根据系统来定,Windows一般为120秒。
但是UDP不需要建立连接,直接发起。
2、可靠和不可靠角度
TCP利用三次握手、四次挥手、ACK确认应答、重传机制等来保证可靠性,UDP没有。
TCP中保证可靠性的机制有:
校验和:用来校验数据是否损坏
定时器:用来确认分组是否丢失,丢失需要重传
序列号:用来检测丢失的分组和重传的分组。
确认应答ACK:用来让接收方通知发送方已经正确接受,以及期望的下一个分组。
否定确认:用来让接收方通知发送方没有被正确接受的分组。
窗口和流水线:用于增加信道的吞吐量。
3、有序性
TCP利用seq序列号来对包进行排序,UDP没有
4、面向字节流和面向报文
面向报文
面向报文的传输方式是应用层交给UDP多少报文,UDP就按原样发送,也就是一个发送一个报文。所以应用层就要选择合适的大小的报文交给UDP,如果报文太长,在IP层就会进行分片。
面向字节流
面向字节流的传输方式是虽然应用层交给TCP的是一次一个数据块,但是TCP会将这个数据块看成一连串无结构的字节流。在TCP的发送端有一个发送缓冲区,当数据块太大,TCP就将它划分的小一些在发送,如果数据块太小了,TCP就会等待累计够足够多的字节后再发送。
5、TCP有流量控制机制,UDP没有
6、TCP头部为20字节,UDP头部为8字节
应用场景
TCP的应用场景
对效率要求较低,但是对准确要求很高的场景。因为在使用TCP传输的时候需要对数据进行确认、重发、排序等操作,相对UDP来说效率降低了许多。这些场景都有:文件传输、远程登录。
UDP应用场景
对效率要求较高,对准确要求较低的场景,因为在使用UDP传输的时候没有对数据进行任何的检验操作,相对于TCP效率提升了很多。这些应用场景有:QQ聊天、在线视频、广播通信等。
二、TCP连接的建立和断开
TCP建立连接
tcp采用三次握手建立连接,
1、客户端向服务端发起SYN连接请求,这条连接请求中还包含了客户端的窗口大小
2、服务端收到连接、请求后,为客户端申请资源同时向客户端回复一个SYN+ACK,还有自己的窗口大小。
3、如果这条消息丢失,客户端没有收到这条消息,那么客户端不会做任何响应,也就不会向服务端发送ACK确认,由于服务端迟迟没有发送确认的ACK,服务器就会将之前申请的资源释放。如果客户端收到了这条消息,开始申请资源,同时回复服务端一个ACK确认,开始进行收发数据。
4、如果最后这条ACK消息丢失,导致服务端没有收到,服务端就认为连接失败,将之前的资源释放,但是客户端认为连接已经建立好了,就会向服务端发送数据。服务端由于没有收到最后一条ACK消息,就会以RST包对客户端进行响应,重新建立连接。
TCP断开连接
TCP采用四次挥手断开连接,
1、主动关闭方向被动方发送FIN请求,进入FIN_WAIT_1状态,等待被动方的ACK确认。
2、被动接收到FIN请求后,回复一个ACK确认,这时被动方进入CLOSE_WAIT状态,进入这个状态是为了将自己的所有要发送的数据都发送完,将自己申请的空间都释放完。这时候主动方接受到ACK请求没有直接断开连接,而是进入FIN_WAIT_2状态,在这个状态下,主动方只能接受数据,不能进行发送。
3、等被动方将自己的事情都处理完,向主动方发送FIN请求,进入LAST_ACK状态,等待主动方的ACK确认。
4、主动方接收到被动方的FIN请求后,进入TIME_WAIT状态,向被动方发送ACK,等待2MSL后,进入CLOSED的状态。这里的MSL是一个报文在网络中的最大生命周期,进入TIME_WAIT状态,并且等待2MSL是为了防止由于网络原因,最后一个ACK没有到达被动方,如果被动方一直没有收到ACK确认,会再次向主动方发送FIN请求,这时主动方重新进入TIMR_WAIT状态。将等待时间设置为2MSL还有一个原因就是要将网络中所有残留的数据消失,防止由于网络原因某些数据阻塞在网络中,如果没有等待2MSL而是立即重新建立连接,很有可能之前的滞留在网络中的数据到达了,这时就会造成数混乱。如果在2MSL之内主动方没有接收到被动方FIN请求,则认为对方已经接收到ACK,主动方进入CLOSED状态,也就是断开连接。对于被动方来说,一旦接收到了来自主动方的ACK确认,就会进入CLOSED状态,关闭连接。
三、socket编程过程
基于TCP的socket:
服务端程序:
创建一个socket,调用socket()函数,返回绑定IP地址、等信息到socket上,调用函数bind()设置允许的最大连接数,调用函数listen(),通常设置为5接受来自客户端的连接,调用accept(),使用send和recv开始收发数据 返回值: 成功返回实际接收字节数,错误返回的-1,对端已经关闭了连接返回0关闭网络连接
客户端程序
创建一个socket,调用socket()函数设置要连接的对方的IP地址和端口等属性连接服务器,调用函数connect()使用send和recv开始收发数据关闭网络连接
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-nzGXRIl7-1637763958110)(C:\Users\LENOVO\AppData\Roaming\Typora\typora-user-images\1566711302816.png)]
/************************************************************************* -> 文件名: tcp_socket.hpp -> 作者: bean -> 邮箱: 17331372728@163.com -> 创建时间: Fri 24 May 2019 06:05:41 AM PDT -> 备注: 封装一个TcpSocket类,向外提供轻便的socket接口 1.创建套接字 2.为套接字绑定地址 3.客户端向服务端发起连接请求 4.服务端开始监听 4.服务端获取一个已经连接成功的客户端的新建socket 5.客户端与服务端开始收发数据 6.关闭套接字 *************************************************************************/#include<iostream>#include<string>#include<stdlib.h>#include<stdio.h>#include<unistd.h>#include <sys/types.h>#include<sys/socket.h>#include<netinet/in.h>#include<arpa/inet.h>#include<string.h>using namespace std;#define CHECK_RET(q) if((q) == false){return -1;}class TcpSocket{ public: TcpSocket():_sockfd(-1) {} ~TcpSocket(){} bool Socket() { _sockfd = socket(AF_INET,SOCK_STREAM,IPPROTO_TCP); if(_sockfd < 0) { cout << "socket error" << endl; return false; } return true; } bool Bind(string &ip, uint16_t port) { struct sockaddr_in addr; addr.sin_family = AF_INET; addr.sin_port = htons(port); addr.sin_addr.s_addr = inet_addr(ip.c_str()); socklen_t len = sizeof(struct sockaddr_in); int ret = bind(_sockfd, (struct sockaddr*)&addr, len); if(ret < 0) { cout << "bind error" << endl; return false; } return true; } //开始监听,并且设置服务端的同一时间最大并发连接数 bool Listen(int baklog = 5) { //int listen(int sockfd, int backlog); //sockfd: 套接字描述符 //backlog: 同一时间最大连接数,设置内核中已完成连接的最大节点数 int ret = listen(_sockfd, baklog); if(ret < 0) { cout << "Listen error" << endl; return false; } return true; } //获取连接成功客户端socket,并且返回客户端的地址信息 bool Accept(TcpSocket &sock, struct sockaddr_in *addr = NULL) { //int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen); //sockfd: 套接字描述符 //addr: 用于存储客户端地址信息 //addrlen: 用于设置想要的地址长度和保存实际的地址长度 //返回值: 为客户端连接新建的socket描述符 //接下来与客户端的通信都是通过这个新的描述符实现的 struct sockaddr_in _addr; socklen_t len = sizeof(struct sockaddr_in); int newfd = accept(_sockfd, (struct sockaddr*)&_addr, &len); if(newfd < 0) { cout << "Accept error" << endl; return false; } sock.SetFd(newfd); if(addr != NULL) { memcpy(addr, &_addr, len); } return true; } //客户端向服务端发起连接请求 bool Connect(string &ip, uint16_t port) { //int connect(int sockfd, const struct sockaddr *addr,socklen_t addrlen); //sockfd: 套接字描述符 //addr: 服务端监听地址信息 //addrlen: 地址信息长度 struct sockaddr_in addr; addr.sin_family = AF_INET; addr.sin_port = htons(port); addr.sin_addr.s_addr = inet_addr(ip.c_str()); socklen_t len = sizeof(struct sockaddr_in); int ret = connect(_sockfd,(struct sockaddr*)&addr, len); //cout << ip << " "<< port << " " << ret<< " " <<"1" << endl; if(ret < 0) { cout << "connect error" << endl; return false; } cout << "来了,老弟!!!" << endl; return true; } //通信接口:tcp服务端也可以直接发送数据(因为连接已经发送成功) bool Recv(string &buff) { //ssize_t recv(int sockfd, void *buf, size_t len, int flags); //sockfd: 套接字描述符 //buf: 接收的数据存储 //len: 想要接收的数据长度 //flags: 0 默认是阻塞接收 // MSG_PEEK 接收数据但是数据不从接收缓冲区中移除 探测性接收 //返回值: 成功返回实际接收字节数,错误返回的-1,对端已经关闭了连接返回0 char tmp[1024] = {0}; int ret = recv(_sockfd, tmp, 1024, 0); if(ret == 0) { cout << "peer shutdown" << endl; return false; } else if(ret < 0) { cout << "recv error" << endl; return false; } buff.assign(tmp,ret); return true; } ssize_t Send(string &buff) { //ssize_t send(int sockfd, const void *buf, size_t len, int flags); //返回值: 成功就是实际发送的字节数 失败 -1 //如果连接已经断开,发送就会触发异常,导致进程退出 int ret = send(_sockfd, buff.c_str(), buff.size(), 0); if(ret < 0) { cout << "send error" << endl; return false; } return true; } bool Close() { close(_sockfd); _sockfd = -1; return true; } void SetFd(int sockfd) { _sockfd = sockfd; } private: int _sockfd;};/************************************************************************* *文件名: tcp_ser.cpp *作者: bean *邮箱: 17331372728@163.com * 创建时间: Fri 24 May 2019 07:27:34 AM PDT * 备注:通过封装的TcpSocket类实现服务端程序 * 1、创建套接字 * 2、绑定地址信息 * 3、开始监听 * 4、获取连接成功的客户端新建socket * 5、通过新建的socket与客户端进行通信 * 6、关闭套接字 *************************************************************************/#include"tcpsocket.hpp"int main(int argc, char* argv[]){ if(argc != 3) { cout << "./tcp_ser ip port" << endl; return -1; } string ip = argv[1]; uint16_t port = atoi(argv[2]); TcpSocket sock; CHECK_RET(sock.Socket()); CHECK_RET(sock.Bind(ip, port)); CHECK_RET(sock.Listen()); while(1) { //程序会卡在accept是应为用户无法获知客户端的新连接什么时候到来 //如果能知道什么时候盗垒就不会阻塞,只需要在来的时候调用一次就行 TcpSocket client; if(sock.Accept(client) == false) { continue; } while(1) { string buff; client.Recv(buff); cout << "Tcp->Client say:" << buff << endl; buff.clear(); cout << "Tcp->Server say:"; fflush(stdout); getline(cin,buff); client.Send(buff); } } sock.Close(); return 0;}/**************************************************************************文件名: tcp_ser.cpp*作者: bean*邮箱: 17331372728@163.com* 创建时间: Fri 24 May 2019 07:27:34 AM PDT* 备注:通过封装的TcpSocket类实现服务端程序* 1、创建套接字* 2、绑定地址信息* 3、开始监听* 4、获取连接成功的客户端新建socket* 5、通过新建的socket与客户端进行通信* 6、关闭套接字*************************************************************************/#include"tcpsocket.hpp"#include<signal.h>#include<sys/wait.h>void sigcb(int no){ while(waitpid(-1, NULL, WNOHANG) > 0);}int main(int argc, char* argv[]) if(argc != 3) { cout << "./tcp_ser ip port" << endl; return -1; } //如果多个进程同时退出了,要等他处理完了再退出 signal(SIGCHLD, sigcb); string ip = argv[1]; uint16_t port = atoi(argv[2]); TcpSocket sock; CHECK_RET(sock.Socket()); CHECK_RET(sock.Bind(ip, port)); CHECK_RET(sock.Listen()); while(1) { //程序会卡在accept是应为用户无法获知客户端的新连接什么时候到来 //如果能知道什么时候盗垒就不会阻塞,只需要在来的时候调用一次就行 TcpSocket client; if(sock.Accept(client) == false) { continue; } //创建一个子进程负责聊天 int pid = fork(); if(pid == 0) { while(1) { string buff; client.Recv(buff); cout << "Client say:" << buff << endl; cout << "Server asy:"; fflush(stdout); cin >> buff; client.Send(buff); } } client.Close(); } sock.Close(); return 0;}
基于UDP的socket
服务端流程
创建套接字文件描述符,调用socket();设置服务器地址和监听关口,初始化要绑定的网络地址结构。绑定监听端口,调用bind()函数,接收客户端数据,调用recvfrom()函数向客户端发送数据,调用sendto()函数向客户端发送数据关闭套接字,调用close()函数释放资源。
客户端流程
创建套接字文件描述符,调用socket()设置服务器地址和端口,struct sockaddr()向服务端发送数据,调用sendto(),接受服务端的数据,调用recvfrom()关闭套接字,调用close()函数释放资源。
/*************************************************************************-> 文件名: udp_socket.hpp-> 作者: bean-> 邮箱: 17331372728@163.com-> 创建时间: Thu 23 May 2019 04:20:18 AM PDT-> 备注:分装一个udp的套接字接口类 实现: 套接字创建、绑定地址、接收数据、发送数据、关闭套接字*************************************************************************/#include<iostream>#include<string>#include<sys/socket.h>#include<errno.h>#include<netinet/in.h>#include<arpa/inet.h>#include<stdio.h>#include<stdlib.h>#include<sys/types.h>#include<string.h>#include<unistd.h>#define CHECK_RET(q) if((q) == false){return -1;}using namespace std;class UdpSocket{ public: UdpSocket() { } ~UdpSocket() { } bool Socket() //创建套接字 { //int socket(int domain, int type, int protocol); //domain: 地址域,确定通信范围 // AF_INET 表示使用IPV4网络协议 //type: 套接字类型 // SOCK_STREAM 流式套接字----默认使用TCP // SOCK_DGRSM 数据包套接字--默认使用UDP //protocol: // 0 使用默认协议 // IPPROTO_TCP 6 TCP协议 // IPPROTO_UDP 17 UDP协议 //返回值:套接字操作句柄---文件描述符 失败:-1 _sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); if(_sock < 0) { perror("socket error"); return false; } return true; } bool Bind(string &ip, uint16_t port) //为套接字绑定地址信息 { struct sockaddr_in addr; addr.sin_family = AF_INET; //因为网络通信使用大端字节序,因此端口和IP地址信息都要转换成网络字节序 //uint16_t htons(uint16_t hostshort); // 将一个16为数据从主机字节序转换为网络字节序 //uint32_t htonl(uint32_t hostlong); //将一个32为数据从主机字节序转换为网络字节序 //不能使用htonl,因为端口只有2的字节,由于htonl是两个字节来转换的,所以经htonl后,还是原来的 addr.sin_port = htons(port); //in_addr_t inet_addr(const char *cp); //将字符串点分十进制IP地址,转换为网络字节序的整数IP地址 addr.sin_addr.s_addr = inet_addr(ip.c_str()); //int bind(int sockfd, const struct sockaddr *addr,socklen_t addrlen); //sockfd: 创建套接字返回套接字描述符 //addr: 要绑定的地址信息 //addrlen: 地址信息长度 //返回值: 成功:0 失败:-1 socklen_t len = sizeof(struct sockaddr_in); int ret = bind(_sock, (struct sockaddr*)&addr, len); if(ret < 0) { perror("Bind error"); return -1; } return true; } bool Recv(string &buf, sockaddr_in *_addr = NULL) //接收数据 { //recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen); //sockfd: 套接字描述符 //buf: 用于保存接收的数据 //len: 要接受的数据长度 //flags: 默认0,表示阻塞接收,没有数据就阻塞 //src_addr: 发送端的地址信息 //addelen: 用于获取地址信息长度(输入输出型参数) //返回值: 实际接收数据长度,错误返回-1 char tmp[1024] = {0}; struct sockaddr_in addr; socklen_t len = sizeof(struct sockaddr_in); int ret = recvfrom(_sock, tmp, 1024, 0,(struct sockaddr*)&addr, &len); if(ret < 0) { perror("recvfrom error"); return false; } buf.assign(tmp, ret); //从tmp中拷贝获取的实际长度到buf中 if(_addr != NULL)//如果用户想要获取发送端的地址,这是_addr就不为空,这时就将获取到的地址拷贝到_addr中 memcpy(_addr,&addr,len); return true; } bool Send(string &buf, struct sockaddr_in &addr) //发送数据 { //sendto(int sockfd, void *buf, size_t len, int flags,struct sockaddr *dest_addr, socklen_t addrlen); //dest_addr: 目的端地址信息 //addrlen: 发送地址信息长度 socklen_t len = sizeof(struct sockaddr_in); int ret = sendto(_sock, buf.c_str(), buf.size(), 0,(struct sockaddr*)&addr, len); if(ret < 0) { perror("sendto error"); return false; } return true; } bool Close() //关闭套接字 { close(_sock); _sock = -1; return true; } private: int _sock;};/*************************************************************************-> 文件名: udp_ser.cpp-> 作者: bean-> 邮箱: 17331372728@163.com-> 创建时间: Thu 23 May 2019 04:08:16 AM PDT-> 备注:使用UdpSock类完胜UDP服务端程序*************************************************************************/#include"udp_socket.hpp"int main(int argc, char *argv[]){ if(argc != 3) { cout << "./dup_srv ip port" << endl; return -1; } string ip = argv[1]; uint16_t port = atoi(argv[2]); UdpSocket sock; //创建套接字 CHECK_RET(sock.Socket()); CHECK_RET(sock.Bind(ip, port)); while(1) { string buff; struct sockaddr_in c_addr; sock.Recv(buff, &c_addr); cout << "client asy:" << buff.c_str() << endl; cout << "server say:"; fflush(stdout); getline(cin, buff); sock.Send(buff, c_addr); } sock.Close(); return 0;}/*************************************************************************-> 文件名: udp_cli.cpp-> 作者: bean-> 邮箱: 17331372728@163.com-> 创建时间: Fri 24 May 2019 05:06:34 AM PDT-> 备注: 通过udpsocket实现客户端程序*************************************************************************/#include "udp_socket.hpp"int main(int argc, char* argv[]){ if(argc != 3) { cout << "./dup_cli ip port" << endl; return -1; } string ip = argv[1]; uint16_t port = atoi(argv[2]); UdpSocket sock; CHECK_RET(sock.Socket()); struct sockaddr_in s_addr; s_addr.sin_family = AF_INET; s_addr.sin_port = htons(port); s_addr.sin_addr.s_addr = inet_addr(ip.c_str()); while(1) { string buff; cout << "client say:"; getline(cin, buff); sock.Send(buff, s_addr); buff.clear(); sock.Recv(buff); cout << "server say:" << buff << endl; } sock.Close(); return 0;}
四、应用层的协议
基于TCP的有FTP、Telnet、SMTP、HTTP、POP3与DNS
基于UDP的有TFTP、SNMP与DNS
其中DNS既可以基于TCP,也可以基于UDP。
FTP(File Transfer Protocol):文本传输协议 端口号: 20 和21一个端口用于控制,一个端口用于传输数据
Telnet:端口号为23,功能:远程管理,而在Linux中为SSH 端口号为22
SMTP: 发送邮件 TCP:25
POP3:接收邮件TCP:110
HTTP:超文本传输协议 TCP:80
HTTPS:相对于HTTP安全 对数据包进行加密 TCP:43DNS:域名解析服务 网络下标如果出现谈好很可能是DNS配置不对 TCP UDP:53
TFTP:简单文件传输协议 早先FTP服务器代码太大了相对于服务器的容量来说,所以主要传输一些小文件的时候就是用TFTP,对于网络设备的维护一直使用 端口号为69
五、HTTP协议
HTTP协议(超文本传输协议),是互联网上引用最广泛的一种网络协议,所有的www文件都要必须遵守这个标准。HTTP和TCp是不冲突的,HTTP定义在应用层、TCP是在传输层,解决的是传输层的逻辑。HTTP使用TCP不使用UDP是因为打开一个网页需要传送很多的数据,而TCP提供传输控制,按顺序数字数据和错误纠正,并且保证数据的可靠传输。
URL、URI、URN的区别
URI:用来唯一标识一个资源。由URL和URN组成。
URL:统一资源定位:通过描述资源的位置来标识资源
URN:通过名字来标识资源,与其位置没有关系,及时资源的位置发生了变化URN也不会变化
请求报文格式(请求行、请求头部、请求正文)
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-TPGBDTCi-1637763958112)(D:/%E6%9C%89%E9%81%93%E7%AC%94%E8%AE%B0/%E6%9C%89%E9%81%93%E6%96%87%E4%BB%B6/qqFD408D4C0254F01133B894C843A4BA78/3bcc5219fdc64197a7027c676ad4a85f/clipboard.png)]
请求行
请求方法:GET、HEAD、PUT、POST、TRACE、OPTIONS、DELETE
URL:通过描述资源的位置来标识资源。
协议版本:常见有1.0和1.1
常见的请求头
请求头部
请求头部为请求报文添加了一些附加信息,由“名/值”组成,每行一对,名和值之间使用冒号分割
常见的请求头
请求头 | 说明 |
---|---|
Host | 接受请求的服务器地址,可以是IP:端口号,也可以是域名 |
User-Agent | 发送请求的应用程序名称 |
Connection | 指定与连接相关的属性,如Connection:Keep-Alive |
Accept-Charset | 通知服务端可以发送的编码格式 |
Accept-Encoding | 通知服务端可以发送的数据压缩格式 |
Accept-Language | 通知服务端可以发送的语言 |
请求头部的最后会有一个空行,表示请求头部结束,接下来为请求报文,这一行必不可少
请求正文
响应报文格式
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ppe4AVAq-1637763958113)(D:/%E6%9C%89%E9%81%93%E7%AC%94%E8%AE%B0/%E6%9C%89%E9%81%93%E6%96%87%E4%BB%B6/qqFD408D4C0254F01133B894C843A4BA78/e011fb9e44af4a808701792c293fb77c/clipboard.png)]
状态行
协议版本
状态码:100 ~ 199信息状态码(1.1),200~299表示成功,300 ~ 399资源重定向, 400 ~ 499 表示客户端请求出错, 500 ~ 599表示服务端出错。
常见状态码
状态码 | 状态说明 |
---|---|
200 | 响应成功 |
301 | 永久重定向,搜索引擎将删除原地址,保留重定向地址 |
302 | 暂时重定向,重定向地址由响应投中的Locaition属性指定,由于搜索引擎的判定问题,较为复杂的URL容易被其他的网站使用更为精简的URL及302重定向劫持 |
304 | 缓冲文件并未过期,还可以继续使用,无需再次从服务端获取 |
400 | 客户端请求语法错误,不能被服务器识别 |
403 | 服务器收到了请求,但是拒绝提供服务 |
404 | 请求的资源不存在 |
500 | 服务器的内部错误 |
响应头部
与请求头部类似,为响应报文添加了一些附加信息
响应头 | 说明 |
---|---|
Server | 服务器应用程序软件的名称和版本 |
Content-Type | 响应正文的类型(是图片还是二进制字符串) |
Content-Length | 响应正文长度 |
Content-Charset | 响应正文使用的编码 |
Content-Encoding | 响应正文使用的数据压缩格式 |
Content-Language | 响应正文使用的语言 |
六、请求方法
HTTP1.0中定义了三种请求方法:GET,POST,HEAD方法。
HTTP1.1中新加了五种请求方法:OPTIONS,PUT,DELETE,TRACE,CONNECT
常见的请求方法
序号 | 方法 | 描述 |
---|---|---|
1 | GET | 请求指定的页面信息,并返回实体主体 |
2 | HEAD | 类似于GET请求,只不过返回的响应中没有具体的内容,用于获取报头 |
3 | POST | 向指定的资源提交数据进行处理请求。数据被包含在请求体中,POST请求可能会导致新资源的建立或已有资源的修改。 |
4 | PUT | 从客户端向服务器传送的数据取代指定的文档内容。 |
5 | DELETE | 请求服务器删除指定的页面。 |
6 | CONNECT | HTTP/1.1协议中预留该能够将连接改为管道方式的代理服务器 |
7 | OPTIONS | 允许客户端查看服务器的性能 |
8 | TRACE | 回显服务器收到的请求,主要用于测试或诊断 |
七、GET与POST比较
本质区别:
get是从指定资源中获取数据
post是向指定资源中提交将要被处理的数据
1、生成方式
get可以直接在URL地址栏中输入URL,还可以网页中的超链接生成
post只知道一种,就是将html中的form标签的method属性改为post
2、数据的传输方式
GET和POST只是HTTP协议中的两种请求方式,而HTTP是基于TCP/IP的应用层协议,所以用的都是同一个传输层的协议,所以在传输上没有区别。
在报文格式上,不带参数时最大的区别就是第一行方法名不同:POST方法请求报文第一行是这样的POST/url HTTP/1.1 GET方法请求报文第一行是这样的 GET/url HTTP/1.1
带参数的区别是GET请求的数据会附加在URL之后,以**?**分割URL,多个参数使用&连接,URL的编码格式采用的是ASCII编码,也就是说所有的非ASCII字符都要编码之后才可以传输,POST请求:POST请求会把请求的数据放置在HTTP请求包的包体中。所以说GET请求的数据会暴露在地址栏中,但是POST请求不会,所以POST相对于GET来说相对安全,但是如果是使用HTTP,那么他们都是不安全的,因为HTTP是明文传输,没有对数据进行加密,也就是说POST请求的数据也是可见的,但是使用HTTPS,POST的数据就是安全的。
3、传输数据的大小
在HTTP规范中,没有对URL的长度和传输的数据进行限制,但是在实际的开发中特定的浏览器和服务器对URL的长度是有限制的,所以在使用GET请求的时候传输的数据就会受URL的长度限制,一般是不超过2KB,但是对于POST来说,理论上来说并不会受到URL的限制,但是实际上各个服务器都会规定对POST提交的数据大小进行限制。
在实际开发中对URL的长度进行限制是因为对于服务器来说URL长了是一种负担,如果一个没有多少数据的会话,但是被人恶意的构造了一个几M大小的URL,并且不停的访问服务器,那么服务器的并发数显然就会下降。还有一种攻击就是将Content-Length设置为一个很大的数,然后只给服务器发送很短的数据,这时候服务器就会进行等待,只有等待超时了服务器才可以继续做其他的,这样也会降低服务器的效率,所以要给URL的长度进行限制。
GET在浏览器回退时是无害的,POST会再次提交请求。。
GET请求只能进行url编码,而POST支持多种编码方式。
GET请求参数会被完整保留在浏览器历史记录里,而POST中的参数不会被保留。
GET只接受ASCII字符的参数的数据类型,而POST没有限制
八、HTTP1.0和HTTP1.1的区别
主要有五个方面的区别:长连接、HOST域、宽带优化、消息传递、缓存
1、长连接
长连接指的是传输完成了保持TCP连接不断开,等待在同域名下继续用这个通道传输数据,反之就是短连接。
HTTP1.1支持长连接和请求的流水线处理,并且默认使用长连接,只有介入“connection:close”才断开连接。
HTTP1.0默认使用短连接,规定客户端和服务器只能保持短暂的连接,客户端的每次请求都需要与到服务端建立一个TCP连接,服务器完成请求处理后立即断开TCP连接,服务器不跟踪客户端也不记录之前的请求。
如果HTTP1.0想要建立长连接,可以在请求消息中包含Connection:Keep-Alive头域,如果服务器想要维持这条连接,在相应的消息中也会包含一个Connection:Keep-Alive的头域。
2、HOST域
HTTP1.1在Request消息头里多了一个Host域,并且是必须传的,HTTP1.0中没有这个域。
在HTTP1.0中认为每个服务器都绑定唯一一个IP地址,所以在请求消息中的URL并没有传递主机名。但随着虚拟主机技术的发展,在一台物理服务器上可以存在多个虚拟主机,并且他们共享一个IP地址。
HTTP1.1的请求小和响应消息都应支持Host头域,且请求消息中如果没有Host头域汇报稿一个错误(状态码400),并且服务器应该接受一绝对路径标记的资源请求。
3、带宽优化
HTTP1.0中,存在一些浪费宽带的现象 ,比如说客户端只是需要某个对象的一部分资源,但是服务器将整个对象都传送过来了。又比如下载大文件时不支持断点续传功能,所以在发生断链后不得不重新下载完整的包。
HTTP1.1中在请求消息中引入了range头域,它支持之请求资源的某个部分。他在响应消息中Content-Range头域声明了返回的这部分对象的偏移值和长度。如果服务器相应地反悔了对象所请求范围的内容,则响应状态码为206,它可以防止Cache将响应误以为是完整的一个对象。
HTTP支持只发送header信息,如果服务器认为客户端有权限请求服务器,则返回100,否则返回401,只有客户端接收到了100,才开始把请求body发送到服务器。如果接收到401,客户端就可以不用发送请求body了,这样可以节约带宽。
4、消息传递
HTTP消息中可以包含任意长度的尸体,通常他们使用Content-Length来给出消息结束的标志。但是对于很多动态产生的响应,只能通过缓冲完整的消息来判断消息的大小,但这样会加大延迟。如果不适用长连接,还可以通过连接关闭的信号来判定一个消息的结束。
HTTP1.1zongoing引入了Chunke来解决上面的这个问题,发送方将消息分割成若干个任意大小的数据块,每个数据块在发送时都会附上块的长度,最右用一个零长度的块作为消息的结束标志。这种方法允许发送方只缓冲消息的一个片段,还可以避免缓冲整个消息带来的过载。
在HTTP1.0中,有一个Content-MD5的头域,要计算这个头域需要发送方缓冲完整个消息后才能进行,这在HTTP1.1中,采用chunked分块传递的消息在最后一个块结束后会再传递一个拖尾,它包含一个或多个头域,这些头域是发送方在传递完所有块之后在计算出的值。
5、缓存
HTTP1.0中使用Expire头域来判断资源是否是新资源,并使用条件请求来判断资源是否仍然有效。
HTTP1.1在1.0的基础上加入了一些新特性,当缓存对象的生命超过了周期编程stale的时候,不需要直接抛弃stale对象,而是与源服务器进行重新激活。
九、HTTP流水线技术
HTTP流水线技术指的是在一个TCP链接内,多个HTTP请求可以并行,客户端不用等待上一次请求的结果返回,就可以发出下一次的请求,但是服务器必须要按照接收到客户端的请求的顺序依次会送响应的结果,以保证客户端可以区分出每次请求响应的内容。使用HTTP流水线技术必须要求客户端和服务端都要支持,目前有部分浏览器完全支持,而服务端的支持仅需要按照HTTP请求顺序正确返回,也就是先来先服务模式,只要服务器能够正确处理使用流水线技术的客户端的请求,那么服务器就算是支持了HTTP流水线技术。
GET 用于获取信息,是无副作用的,是幂等的,且可缓存 POST 用于修改服务器上的数据,有副作用,非幂等,不可缓存
十、HTTP无状态问题
http是无状态协议,无状态也就是对于十五处理没有记忆能力。缺少状态意味着如果后续处理需要之前的信息。使用cookie和session来解决无状态问题。
十一、cookie和session的区别
cookie
是客户端技术,程序把每个用户的数据以cookie的形式写给用户各自的浏览器。当用户使用浏览器再去访问服务器的时候,就会带着各自的数据过去,这样就服务器就知道当前是哪个用户,并处理对应的数据。会话cookie保存在浏览器的内存中,浏览器关闭后cookie就消失了,持久化cookie是保存在本地的磁盘上,一般会有一定的过期时间。
session
是服务器技术,利用这个技术,服务器可以为每个用户的浏览器创建一个独立的session对象,并且每个session都有自己唯一对应的ID,可以把各自的数据保存在自己的session中,这样当用户再去访问服务器的时候,服务器就可以用当前用户的session中取出数据为之服务。session都有过期时间,如果一段时间没有更新数据库,就会消失。
区别
1、cookie和session都是会话技术,cookie数据存放在客户端,session的数据存放在服务器。
2、cookie不是很安全,可以对本地存放的cookie进行cookie欺骗,考虑到安全应该使用session。
3、session会在一定的时间内保存在服务器中,当访问增多,会比较占用服务器性能,所有考虑到减轻服务器的负载,应该使用cookie
4、登陆注册等重要信息要存放在session中,其他的信息可以保存在cookie中
5、Cookie 只能保存ASCII字符串,如果是Unicode字符或者二进制数据需要先进行编码。Session能够存取很多类型的数据,包括String、Integer、List、Map等。
十九、怎么让客户端和服务端一直保持连接
通情况下tcp的连接是一直保持很长时间,但是有可能这个链接会一直空闲着,这样会造成资源浪费,所以TCP就提供了保活机制和心跳报文
保活机制
也就是keepalive,在客户端和服务端都可以设置,通常是服务端设置的。保活时长一般是2小时,如果这个tcp连接空闲了两个小时,这个保活计时器超时,服务端会向客户端发送一个探测报文,这时客户端通常处于这么几个状态。
1、客户端处于正常状态,收到探测报文后发送了响应报文,服务端知道客户端是正常的,将保活计时器复位。
2、客户端已经崩溃,正在关机或者重启,没有响应服务端的探测报文, 服务端由于没有收到回复,会每个75秒发送一个探测报文,如果连续发送10探测报文都没有响应,就会认为客户端挂了,会关闭TCP连接。
3、客户端已经重启,并且正常运行了,但是在重启之前关闭了tcp,这个客户端收到探测报文,回应一个RST,服务端收到这个报文将TCP连接关闭。
4、客户端正常运行,但是接收不到服务端的报文,可以说是和情况2是一样的,服务端并不能去放是网络问题还是客户端的问题,所以同样会将连接关闭。
心跳报文
每隔一段时间向对端发送一个较小的数据包,通知对方自己在线,并传送一些可能必要的数据包,并且定时检测对端返回的数据,如果连续几次没有在规定的时间中收到回复,则判断对端已经掉线,然后做进一步处理,这个方法适合客户端,在应用层开一个线程发送心跳包,检测对端情况。