extern int i; //声明
int i; //定义
#define example( instr ) printf( "the input string is:\t%s\n", #instr )
#define example1( instr ) #instr
example( abc );//在编译时会展开成:printf("the input string is:\t%s\n","abc")
string str = example( abc );//会展开成:string str="abc"
#define exampleNum( n ) num##n//##前后空格可有可无
int num9 = 9;
int num = exampleNum( 9 );//会展开成:int num = num9
编译原理 :C++开发时,编译的过程主要包含下面四个步骤:
预处理器: 宏定义替换,头文件展开,条件编译展开,删除注释。
gcc -E选项可以得到预处理后的结果,扩展名为.i 或 .ii。
C/C++预处理不做任何语法检查,不仅是因为它不具备语法检查功能,也因为预处理命令不属于C/C++语句(这也是定义宏时不要加分号的原因),语法检查是编译器要做的事情。
预处理之后,得到的仅仅是真正的源代码。
编译器: 生成汇编代码,得到汇编语言程序(把高级语言翻译为机器语言),该种语言程序中的每条语句都以一种标准的文本格式确切的描述了一条低级机器语言指令。
gcc -S选项可以得到编译后的汇编代码文件,扩展名为.s。
汇编语言为不同高级语言的不同编译器提供了通用的输出语言。
汇编器: 生成目标文件。
gcc -c选项可以得到汇编后的结果文件,扩展名为.o。
.o文件,是按照的二进制编码方式生成的文件。
链接器: 生成可执行文件或库文件。
静态库:指编译链接时,把库文件的代码全部加入到可执行文件中,因此生成的文件比较大,但在运行时也就不再需要库文件了,其后缀名一般为“.a”。
动态库:在编译链接时并没有把库文件的代码加入到可执行文件中,而是在程序执行时由运行时链接文件加载库,这样可执行文件比较小,动态库一般后缀名为“.so”。
可执行文件:将所有的二进制文件链接起来融合成一个可执行程序,不管这些文件是目标二进制文件还是库二进制文件。
编译优化
GCC提供了为了满足用户不同程度的的优化需要,提供了近百种优化选项,用来对编译时间,目标文件长度,执行效率这个三维模型进行不同的取舍和平衡。优化的方法不一而足,总体上将有以下几类:
如果全部了解这些编译选项,对代码针对性的优化还是一项复杂的工作,幸运的是GCC提供了从O0-O3以及Os这几种不同的优化级别供大家选择,在这些选项中,包含了大部分有效的编译优化选项,并且可以在这个基础上,对某些选项进行屏蔽或添加,从而大大降低了使用的难度。
O0:不做任何优化,这是默认的编译选项。
O和O1:对程序做部分编译优化,编译器会尝试减小生成代码的尺寸,以及缩短执行时间,但并不执行需要占用大量编译时间的优化。
O2:是比O1更高级的选项,进行更多的优化。GCC将执行几乎所有的不包含时间和空间折中的优化。当设置O2选项时,编译器并不进行循环展开以及函数内联优化。与O1比较而言,O2优化增加了编译时间的基础上,提高了生成代码的执行效率。
O3:在O2的基础上进行更多的优化,例如使用伪寄存器网络,普通函数的内联,以及针对循环的更多优化。
Os:主要是对代码大小的优化, 通常各种优化都会打乱程序的结构,让调试工作变得无从着手。并且会打乱执行顺序,依赖内存操作顺序的程序需要做相关处理才能确保程序的正确性。
编译优化有可能带来的问题:
① 调试问题:正如上面所提到的,任何级别的优化都将带来代码结构的改变。例如:对分支的合并和消除,对公用子表达式的消除,对循环内load/store操作的替换和更改等,都将会使目标代码的执行顺序变得面目全非,导致调试信息严重不足。
② 内存操作顺序改变问题:在O2优化后,编译器会对影响内存操作的执行顺序。例如:-fschedule-insns允许数据处理时先完成其他的指令;-fforce-mem有可能导致内存与寄存器之间的数据产生类似脏数据的不一致等。对于某些依赖内存操作顺序而进行的逻辑,需要做严格的处理后才能进行优化。例如,采用Volatile关键字限制变量的操作方式,或者利用Barrier迫使CPU严格按照指令序执行。
NULL: 预处理变量,是一个宏,它的值是0,定义在头文件中,即#define NULL 0。
nullptr: C++ 11 中的关键字,是一种特殊类型的字面值,可以被转换成任意其他类型。
⭐在函数体内:只会被初始化一次,一个被声明为静态的变量在这一函数被调用过程中维持其值不变。
⭐在模块内(但在函数体外):一个被声明为静态的变量可以被模块内所用函数访问,但不能被模块外其它函数访问。它是一个本地的全局变量(只能被当前文件使用)。
⭐在模块内:一个被声明为静态的函数只可被这一模块内的其它函数调用。那就是,这个函数被限制在声明它的模块的本地范围内使用(只能被当前文件使用)。
**struct(结构体)与union(联合体)**是C语言中两种不同的数据结构,两者都是常见的复合结构,其区别主要表现在以下两个方面。
define
typedef
explicit关键字只能用于修饰只有一个参数的类构造函数,表明该构造函数是显示的, implicit, 意思是隐藏的, 类构造函数默认情况下即声明为implicit(隐式).隐式转换可以通过消除不必要的类型转换来提高源代码的可读性
在 C++ 中,mutable 是为了突破 const 的限制而设置的。被 mutable 修饰的变量,将永远处于可变的状态,即使在一个 const 函数中,甚至结构体变量或者类对象为 const,其 mutable 成员也可以被修改。
const 关键字,用于常成员函数,即“不允许在常成员函数内部修改对象状态的值。”
mutable 关键字,用于常成员函数,即“允许修改常成员函数内部不是对象状态的值。”
保证内存可见性: volatile可见性是通过汇编加上Lock前缀指令,触发底层的MESI缓存一致性协议来实现的,保证变量对其他线程的可见性。
保证内存有序性: 禁止指令重排序,指令的执行顺序并不一定会像我们编写的顺序那样执行,为了保证执行上的效率,JVM(包括CPU)可能会对指令进行重排序。 volatile有序性是通过内存屏障实现的。JVM和CPU都会对指令做重排优化,所以在指令间插入一个屏障点,就告诉JVM和CPU,不能进行重排优化。
不保证原子性: 尽管volatile关键字可以保证内存可见性和有序性,但不能保证原子性。也就是说,对volatile修饰的变量进行的操作,不保证多线程安全。
⭐实际使用过程中:
去掉数据类型,以此判断const修饰的是哪部分
1.只有一个cosnt时,如果const位于*的左侧:表示指针所指的数据常量,不能通过该指针修改实际数据;指针本身是变量,可以指向其他内存单元。
2.只有一个const时,如果const位于*右侧:表示指针本身时常量,不能指向其他内存单元;所指向的数据可以修改
3.如果有两个const位于*的左右两侧,表示指针和指针所指向的数据都不能修改
与类大小有关的因素:普通成员变量,虚函数,继承(单一继承,多重继承,重复继承,虚拟继承)
与类大小无关的因素:静态成员变量,静态成员函数及普通成员函数
空类即什么都没有的类,按上面的说法,照理说大小应该是0,但是,空类的大小为1,因为空类可以实例化,实例化必然在内存中占有一个位置,因此,编译器为其优化为一个字节大小。
空类中真的什么都没有吗??任何一个类中都有六个默认的成员函数,空类也不例外
所有类型指针大小都为4
用void* 定义一个void类型的指针,它不指向任何类型的数据,,它属于一种未确定类型的过渡型数据,因此如果要访问实际存在的数据,必须将void指针强转成为指定一个确定的数据类型的数据,如int、string等。void指针只支持几种有限的操作:与另一个指针进行比较;向函数传递void指针或从函数返回void指针;给另一个void指针赋值。:void只提供一个地址,没有指向。
**相当于指向指针的指针,本质还是一个指针,只是它指向的是一个地址。&很简单,即同级运算,从右到左,先进行地址&运算再进行指针运算。
重载的定义为:在同一作用域中,同名函数的形式参数(参数个数、类型或者顺序)不同时,构成函数重载。
隐藏定义:指不同作用域中定义的同名函数构成隐藏(不要求函数返回值和函数参数类型相同)。比如派生类成员函数隐藏与其同名的基类成员函数、类成员函数隐藏全局外部函数。
重写/覆盖 override 的定义:派生类中与基类同返回值类型、同名和同参数的虚函数重定义,构成虚函数覆盖,也叫虚函数重写。
重写与重载主要有以下不同:
(1)范围的区别:被重写的和重写的函数在两个类中,而重载和被重载的函数在同一个类中;
(2)参数的区别:被重写函数和重写函数的参数列表一定相同,而被重载函数和重载函数的参数列表一定不同;
(3)virtual的区别:重写的基类中被重写的函数必须要有virtual修饰,而重载函数和被重载函数可以被virtual修饰,也可以没有
隐藏和重写,重载有以下不同:
(1)与重载的范围不同:和重写一样,隐藏函数和被隐藏函数不在同一个类中;
(2)参数的区别:隐藏函数和被隐藏的函数的参数列表可以相同也可以不同,但是函数名肯定要相同。当参数不同时,无论基类的参数是否被virtual修饰,基类的函数都是被隐藏,而不是被重写。
虽然重载和覆盖都是实现多态的基础,但是两者实现的技术完全不相同,达到的目的也是完全不同的。覆盖是动态绑定的多态,重载是静态绑定的多态。
构造函数初始化列表以一个冒号开始,接着是以逗号分隔的数据成员列表,每个数据成员后面跟一个放在括号中的初始化式。例如︰
//构造函数初始化列表
Example::Example() : ival(0),dval(0.0){}//ival和dval是类的两个数据成员
//构造函数初始化
Example::Example()
{
ival = 0;
dval = 0.0;
}
的确,这两个构造函数的结果是一样的。但区别在于∶上面的构造函数(使用初始化列表的构造函数)显示的初始化类的成员﹔而没使用初始化列表的构造函数是对类的成员赋值,并没有进行显示的初始化。
初始化和赋值对内置类型的成员没有什么大的区别,像上面的任一个构造函数都可以。但有的时候 必须用带有初始化列表的构造函数︰
1.成员类型是没有默认构造函数的类。若没有提供显示初始化式,则编译器隐式使用成员类型的默认构造函数,若类没有默认构造函数,则编译器尝试使用默认构造函数将会失败。
2.const成员或引用类型的员。因为const对象或引用类型只能初始化,不能对他们赋值。
在C++中,赋值与初始化列表的使用情况也不一样,只能用初始化列表,而不能用赋值的情况一般有以下3种:
1.当类中含有const(常量)、reference (引用)成员变量时,只能初始化,不能对它们进行赋值。常量不能被赋值,只能被初始化,所以必须在初始化列表中完成,C++的引用也一定要初始化,所以必须在初始化列表中完成。
⒉.派生类在构造函数中要对自身成员初始化,也要对继承过来的基类成员进行初始化当基类没有默认构造函数的时候,通过在派生类的构造函数初始化列表中调用基类的构造函数实现。
3.如果成员类型是没有默认构造函数的类,也只能使用初始化列表。若没有提供显式初始化时,则编译器隐式使用成员类型的默认构造函数,此时编译器尝试使用默认构造函数将会失败
封装可以隐藏实现细节,使得代码模块化;继承可以扩展已存在的代码模块(类);它们的目的都是为了——代码重用。而多态则是为了实现另一个目的——接口重用!
封装可以隐藏实现细节,使得代码模块化;封装是把过程和数据包围起来,对数据的访问只能通过已定义的界面。面向对象计算始于这个基本概念,即现实世界可以被描绘成一系列完全自治、封装的对象,这些对象通过一个受保护的接口访问其他对象。在面向对象编程上可理解为:把客观事物封装成抽象的类,并且类可以把自己的数据和方法只让可信的类或者对象操作,对不可信的进行信息隐藏。
继承是指这样一种能力:它可以使用现有类的所有功能,并在无需重新编写原来的类的情况下对这些功能进行扩展。其继承的过程,就是从一般到特殊的过程。通过继承创建的新类称为“子类”或“派生类”。被继承的类称为“基类”、“父类”或“超类”。要实现继承,可以通过“继承”(Inheritance)和“组合”(Composition)来实现。在某些 OOP 语言中,一个子类可以继承多个基类。但是一般情况下,一个子类只能有一个基类,要实现多重继承,可以通过多级继承来实现。
继承概念的实现方式有三类:实现继承、接口继承和可视继承:
1)实现继承是指使用基类的属性和方法而无需额外编码的能力;
2)接口继承是指仅使用属性和方法的名称、但是子类必须提供实现的能力;
3) 可视继承是指子窗体(类)使用基窗体(类)的外观和实现代码的能力。
多态性(polymorphisn) 是允许你将父对象设置成为和一个或更多的他的子对象相等的技术,赋值之后,父对象就可以根据当前赋值给它的子对象的特性以不同的方式运作。简单的说,就是一句话:允许将子类类型的指针赋值给父类类型的指针。
实现多态,有二种方式,覆盖,重载。覆盖: 是指子类重新定义父类的虚函数的做法。重载: 是指允许存在多个同名函数,而这些函数的参数表不同(或许参数个数不同,或许参数类型不同,或许两者都不同)。
多态性可以简单地概括为**“一个接口,多种方法”**。
静态多态: 也称为编译期间的多态,编译器在编译期间完成的,编译器根据函数实参的类型(可能会进行隐式类型转换),可推断出要调用那个函数,如果有对应的函数就调用该函数,否则出现编译错误。
静态多态有两种实现方式:
函数重载: 包括普通函数的重载和成员函数的重载
函数模板的使用
动态多态(动态绑定): 即运行时的多态,在程序执行期间(非编译期)判断所引用对象的实际类型,根据其实际类型调用相应的方法,是用虚函数机制实现的。
不同点:
1、本质不同,静态多态在编译期决定,由模板具现完成,而动态多态在运行期决定,由继承、虚函数实现;
2、动态多态中接口是显式的,以函数签名为中心,多态通过虚函数在运行期实现,静态多台中接口是隐式的,以有效表达式为中心,多态通过模板具现在编译期完成
在类成员方法的声明(注意不是定义)前面加个virtual,该函数就变为虚函数,在虚函数声明语句后面加个 = 0 ,虚函数就变为纯虚函数。
class<类名>
{
virtual()函数返回值类型 虚函数名(形参表)=0;
...
};
定义一个函数为虚函数,不代表函数为不被实现的函数。定义他为虚函数是为了允许用基类的指针来调用子类的这个函数。
定义一个函数为纯虚函数,才代表函数没有被实现, 定义纯虚函数是为了实现一个接口,起到一个规范的作用,规范继承这个类的程序员必须实现这个函数。
当类中有了纯虚函数,这个类也称为抽象类
子类可以重新定义基类的虚函数,我们把这个行为称之为复写(override)
深度探索C++对象模型》的4.2节能够找到完美答案,具体摘抄如下:
“表格中的virtual functions地址是如何被建构起来的?在C++中,virtual functions(可经由其class object被调用)可以在 编译时期 获知。此外,这一组地址是固定不变的,执行期不可能新增或替换之。由于程序执行时,表格的大小和内容都不会改变,所以其建构和存取皆可以由编译器完全掌控,不需要执行期的任何介入。”
转载图片: link
1.虚函数表是全局共享的元素,即全局仅有一个.
2.虚函数表类似一个数组,类对象中存储vptr指针,指向虚函数表.即虚函数表不是函数,不是程序代码,不肯能存储在代码段.
3.虚函数表存储虚函数的地址,即虚函数表的元素是指向类成员函数的指针,而类中虚函数的个数在编译时期可以确定,即虚函数表的大小可以确定,即大小是在编译时期确定的,不必动态分配内存空间存储虚函数表,所以不再堆中.
根据以上特征,虚函数表类似于类中静态成员变量.静态成员变量也是全局共享,大小确定.
存放在全局数据区.
原理:
作用:
⭐这里顺便说一下继承体系下基类和派生类的构造函数/析构函数的调用次序:
✨析构函数可以且常常是虚函数
此时 vtable(虚函数表) 已经初始化了,完全可以把析构函数放在虚函数表里面来调用。
C++类有继承时,基类的析构函数必须为虚函数。如果不是虚函数,则使用时可能存在内存泄漏的问题。
虚函数就是类中使用关键 virtual修饰的成员函数,其目的是为了实现多态性。
创建一个类时,系统默认我们不会将该类作为基类,所以就将默认的析构函数定义成非虚函数,这样就不会占用额外的内存空间。
派生类自己的资源,同时又清理从基类继承过来的资源。而当基类的析构函数为非虚函数时,删除一个基类指针指向的派生类实例时,只清理了派生类从基类继承过来的资源,而派生类自己独有的资源却没有被清理,这显然不是我们希望的。所以说,如果一个类会被其他类继承,那么我们有必要将被继承的类(基类)的析构函数定义成虚函数。这样,释放基类指针指向的派生类实例时,清理工作才能全面进行,才不会发生内存泄漏。
✨构造函数不能是虚函数
1.从vptr 角度解释
2.从多态 角度解释
对象生命周期结束被销毁时。
delete指向对象的指针时,或者delete指向对象的基类类型的指针,而基类析构函数是虚函数。
对象A是对象B的成员,B的析构函数被调用时,对象A的析构函数也会被调用。
属性: new和delete是C++关键字,需要编译器支持;malloc和free是标准库函数,需要头文件支持。
参数: 使用new操作符申请内存分配时无须指定内存块的大小,编译器会根据类型信息自行计算。而malloc则需要显式地指出所需内存的尺寸。
返回类型: new操作符内存分配成功时,返回的是对象类型的指针,类型严格与对象匹配,无须进行类型转换,故new是符合类型安全性的操作符。而malloc内存分配成功则是返回void * ,需要通过强制类型转换将void*指针转换成我们需要的类型。
自定义类型: new会先调用operator new函数,申请足够的内存(通常底层使用malloc实现)。然后调用类型的构造函数,初始化成员变量,最后返回自定义类型指针。delete先调用析构函数,然后调用operator delete函数释放内存(通常底层使用free实现)。
malloc/free是库函数,只能动态的申请和释放内存,无法强制要求其做自定义类型对象构造和析构工作。
“重载”: C++允许自定义operator new 和 operator delete 函数控制动态内存的分配。
内存区域: new做两件事:分配内存和调用类的构造函数,delete是:调用类的析构函数和释放内存。而malloc和free只是分配和释放内存。
new操作符从自由存储区(free store)上为对象动态分配内存空间,而malloc函数从堆上动态分配内存。自由存储区是C++基于new操作符的一个抽象概念,凡是通过new操作符进行内存申请,该内存即为自由存储区。而堆是操作系统中的术语,是操作系统所维护的一块特殊内存,用于程序的内存动态分配,C语言使用malloc从堆上分配内存,使用free释放已分配的对应内存。自由存储区不等于堆,如上所述,布局new就可以不位于堆中。
分配失败: new内存分配失败时,会抛出bac_alloc异常。malloc分配内存失败时返回NULL。
内存泄漏:内存泄漏对于new和malloc都能检测出来,而new可以指明是哪个文件的哪一行,malloc确不可以。
一个C,C++程序编译时内存分为5大存储区:堆区,栈区,全局区,文字常量区,程序代码区。
C,C++中内存分配方式可以分为三种:
(1)从静态存储区域分配:
内存在程序编译时就已经分配好,这块内存在程序的整个运行期间都存在。例如全局变量,static变量等。
(2)在栈上分配:
在执行函数时,函数内局部变量的存储单元都在栈上创建,函数执行结束时这些存储单元自动被释放。栈内存分配运算内置于处理器的指令集中,效率很高,但是分配的内存容量有限。
(3)从堆上分配:
即动态内存分配。程序在运行的时候用malloc或new申请任意大小的内存,程序员负责在何时用free或delete释放内存。动态内存的生存期由程序员决定,使用灵活。
当在堆上分配了空间,就有责任回收它,否则运行的程序会出现内存泄漏。其次频繁地分配和释放不同大小的对空间将会产生堆
(1)kmalloc和vmalloc是分配内核的内存,malloc分配的是用户空间的内存。
(2)kmalloc 保证分配的内存在物理上是连续的,vmalloc保证的是在虚拟地址空间上的连续,malloc申请的内存不一定连续(用户空间存储以空间链表的方式组织(地址递增),每一个链表块包含一个长度、一个指向下一个链表块的指针以及一个指向自身的存储空间指针。)
(3)kmalloc能分配的大小有限,vmalloc与malloc能分配的空间大小相对较大。
(4)内存只有在要被DMA访问的时候才需要物理上连续。
(5)vmalloc要不kmalloc要慢。
用动态存储分配函数动态开辟的空间,在使用完毕后未释放,结果导致一直占据该内存单元。直到程序结束。(就是该内存空间使用完毕之后未回收)即所谓内存泄漏。
常见的内存泄露:
1.指针重新赋值;
2.错误的内存释放;
3.返回值的不正确处理。
如何避免:
1.确保没有在访问空指针;
2.每个内存分配函数都应该有一个 free 函数与之对应,alloca 函数除外;
3.每次分配内存之后都应该及时进行初始化,可以结合 memset 函数进行初始化,calloc 函数除外;
4.每当向指针写入值时,都要确保对可用字节数和所写入的字节数进行交叉核对;
5.在对指针赋值前,一定要确保没有内存位置会变为孤立的;
6.每当释放结构化的元素(而该元素又包含指向动态分配的内存位置的指针)时,都应先遍历子内存位置并从那里开始释放,然后再遍历回父节点;7.始终正确处理返回动态分配的内存引用的函数返回值。
需要特别注意下面几点:
1)调用free()释放内存后,不能再去访问被释放的内存空间。内存被释放后, 很有可能该指针仍然指向该内存单元,但这块内存已经不再属于原来的应用程序,此时的指针为悬挂指针(可以赋值为NULL)。
2)不能两次释放相同的指针。因为释放内存空间后,该空间就交给了内存分配子程序,再次释放内存空间会导致错误。也不能用free来释放非malloc()、calloc()和realloc()函数创建的指针空间,在编程时,也不要将指针进 行自加操作,使其指向动态分配的内存空间中间的某个位置,然后直接释放,这样也有可能引起错误。
3)在进行C语言程序开发中,malloc/free是配套使用的,即不需要的内存空间都需要释放回收。
由于内存区域总是有限的,不能无限制地分配下去,而且程序应尽量节省资源 所以当分配的内存区域不用时,则要释放它,以便其他的变量或程序使用。
内存溢出:(out of memory) 通俗理解就是内存不够,指程序要求的内存超出了系统所能分配的范围,通常在运行大型软件或游戏时,软件或游戏所需要的内存远远超出了你主机内安装的内存所承受大小,就叫内存溢出。比如申请一个int类型,但给了它一个int才能存放的数,就会出现内存溢出,或者是创建一个大的对象,而堆内存放不下这个对象,这也是内存溢出。
内存泄漏:(Memory Leak) 是指程序中己动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。一次内存泄露危害可以忽略,但内存泄露堆积后果很严重,无论多少内存,迟早会被占光。
内存泄露可能会导致内存溢出。
内存溢出会抛出异常,内存泄露不会抛出异常,大多数时候程序看起来是正常运行的。
在C/C++中对于内存分区来说,可以划分为四大内存分区。他们分别是堆、栈、全局/静态存储区和代码区。
1.堆区:
由编程人员手动申请,手动释放,若不手动释放,程序结束后由系统回收,生命周期是整个程序运行期间.使用malloc或者new进行堆的申请,堆的总大小为机器的虚拟内存的大小。
new操作符本质上是使用了malloc进行内存的申请,new和malloc的区别如下:
(1)malloc是C语言中的函数,而new是C++中的操作符。
(2)malloc申请之后返回的类型是void*,而new返回的指针带有类型。
(3)malloc只负责内存的分配而不会调用类的构造函数,而new不仅会分配内存,而且会自动调用类的构造函数。
2.栈区:
由系统进行内存的管理。主要存放函数的参数以及局部变量。在函数完成执行,系统自行释放栈区内存,不需要用户管理。整个程序的栈区的大小可以在编译器中由用户自行设定,VS中默认的栈区大小为1M,可通过VS手动更改栈的大小。
3.全局/静态存储区:
全局/静态存储区内的变量在程序编译阶段已经分配好内存空间并初始化。这块内存在程序的整个运行期间都存在,它主要存放静态变量、全局变量和常量。
(1) 是因为静态存储区内的变量若不显示初始化,则编译器会自动以默认的方式进行初始化,即静态存储区内不存在未初始化的变量。
(2)静态存储区内的常量分为常变量和字符串常量,一经初始化,不可修改。静态存储内的常变量是全局变量,与局部常变量不同,区别在于局部常变量存放于栈,实际可间接通过指针或者引用进行修改,而全局常变量存放于静态常量区则不可以间接修改。
(3)字符串常量存储在全局/静态存储区的常量区,字符串常量的名称即为它本身,属于常变量。
(4)数据区的具体划分,有利于我们对于变量类型的理解。不同类型的变量存放的区域不同。
4.代码区:
存放程序体的二进制代码。比如我们写的函数,都是在代码区的。
malloc 函数的实质是它有一个将可用的内存块连接为一个长长的列表的所谓空闲链表。
调用 malloc()函数时,它沿着连接表寻找一个大到足以满足用户请求所需要的内存块。(如果没有搜索到,那么就会用sbrk()才推进brk指针来申请内存空间)。
然后,将该内存块一分为二(一块的大小与用户申请的大小相等,另一块的大小就是剩下来的字节)。 接下来,将分配给用户的那块内存存储区域传给用户,并将剩下的那块(如果有的话)返回到连接表上。
调用 free 函数时,它将用户释放的内存块连接到空闲链表上。
到最后,空闲链会被切成很多的小内存片段,如果这时用户申请一个大的内存片段, 那么空闲链表上可能没有可以满足用户要求的片段了。于是,malloc() 函数请求延时,并开始在空闲链表上检查各内存片段,对它们进行内存整理,将相邻的小空闲块合并成较大的内存块。
⭐相同:
(1)都是地址的概念,指针指向某一内存、它的内容是所指内存的地址;引用则是某块内存的别名。
(2)从内存分配上看∶两者都占内存,程序为指针会分配内存,一般是4个字节;而引用的本质是指针常量,指向对象不能变,但指向对象的值可以变。两者都是地址概念,所以本身都会占用内存。
⭐区别:
(1)引用必须初始化,但是不分配存储空间。指针不声明时初始化,在初始化的时候需要分配存储空间。引用较比指针更加安全;
(2)引用指向一块特定的内存,不能被更改。不存在指向空值的引用,但是存在指向空值的指针。指针可指向任意一块内存,可以改变所指的对象;
(3)指针的大小确定,引用的大小根据所引用的类型确定;
(4)指针使用时必须解引用;
(5)定义一个指针和引用在汇编语言中表示相同;
(6)指针可以多级引用。
ps: 引用即指针常量,指向后不能修改指向的是哪个对象,想较于指针用法更简单,不需要担心内存泄漏,也更安全不会有野指针哪些乱七八糟的问题
⭐转换:
(1)把指针用*就可以转换成对象,可以用在引用参数中
(2)引用类型用&取地获得指针
句柄和指针其实是两个截然不同的概念。Windows系统用句柄标记系统资源,隐藏系统的信息。你只要知道有这个东西,然后去调用就行了,它是个32it的uint。指针则标记某个物理内存地址,两者是不同的概念。
在C语言中,我们知道要生成可执行文件,必须经历两个阶段,即编译、链接。在编译过程中,只有编译,不会涉及到链接。
在链接过程中,静态链接和动态链接就出现了区别。静态链接的过程就已经把要链接的内容已经链接到了生成的可执行文件中,就算你在去把静态库删除也不会影响可执行程序的执行;而动态链接这个过程却没有把内容链接进去,而是在执行的过程中,再去找要链接的内容,生成的可执行文件中并没有要链接的内容,所以当你删除动态库时,可执行程序就不能运行。
所谓的智能指针本质就是一个类模板,它可以创建任意的类型的指针对象,当智能指针对象使用完后,对象就会自动调用析构函数去释放该指针所指向的空间。所有的智能指针类模板中都需要包含一个指针对象,构造函数和析构函数。
1)auto_ptr
auto_ptr是c++98版本库中提供的智能指针,该指针解决上诉的问题采取的措施是管理权转移的思想,也就是原对象拷贝给新对象的时候,原对象就会被设置为nullptr,此时就只有新对象指向一块资源空间。 如果auto_ptr调用拷贝构造函数或者赋值重载函数后,如果再去使用原来的对象的话,那么整个程序就会崩溃掉(因为原来的对象被设置为nullptr),这对程序是有很大的伤害的.所以很多公司会禁用auto_ptr智能指针。
⭐auto_ptr以前是用在C98中,C++11被抛弃,头文件一般用来作为独占指针
⭐auto_ptr被赋值或者拷贝后,失去对原指针的管理
⭐auto_ptr不能管理数组指针,因为auto_ptr的内部实现中,析构函数中删除对象使用delete而不是delete[],释放内存的时候仅释放了数组的第一个元素的空间,会造成内存泄漏。
⭐ auto_ptr不能作为容器对象,因为STL容器中的元素经常要支持拷贝,赋值等操作。
2) unique_ptr
unique_ptr是c++11版本库中提供的智能指针,它直接将拷贝构造函数和赋值重载函数给禁用掉,因此,不让其进行拷贝和赋值。
⭐ C++11中用来替代auto_ptr
⭐拷贝构造和赋值运算符被禁用,不能进行拷贝构造和赋值运算
⭐ 虽然禁用了拷贝构造和赋值运算符,但unique_ptr可以作为返回值,用于从某个函数中返回动态申请内存的所有权,本质上是移动拷贝,就是使用std:move()函数,将所有权转移。
3) share_ptr
share_ptr是c++11版本库中的智能指针,shared_ptr允许多个智能指针可以指向同一块资源,并且能够保证共享的资源只会被释放一次,因此是程序不会崩溃掉。
shared_ptr采用的是引用计数原理来实现多个shared_ptr对象之间共享资源:
⭐ 多个指针可以指向相同的对象,调用release()计数-1,计数0时资源释放
⭐ .use_count()查计数
⭐ .reset()放弃内部所有权
⭐ share_ptr多次引用同一数据会导致内存多次释放
⭐ 循环引用会导致死锁,
⭐ 引用计数不是原子操作。
4) weak_ptr
⭐ 解决两个share_ptr互相引用产生死锁,计数永远降不到0,没办法进行资源释放,造成内存泄漏的问题。
⭐ 使用时配合share_ptr使用,把其中一个share_ptr更换为weak_ptr。
shared_ptr:解决资源忘记释放的内存泄漏问题,及悬空指针问题
unique_ptr:对象对其有唯一所有权
如何来让指针知道还有其他指针的存在呢?这个时候我们该引入引用计数的概念了。引用计数是这样一个技巧,它允许有多个相同值的对象共享这个值的实现。引用计数的使用常有两个目的:
简化跟踪堆中(也即C++中new出来的)的对象的过程。一旦一个对象通过调用new被分配出来,记录谁拥有这个对象是很重要的,因为其所有者要负责对它进行delete。但是对象所有者可以有多个,且所有权能够被传递,这就使得内存跟踪变得困难。引用计数可以跟踪对象所有权,并能够自动销毁对象。可以说引用计数是个简单的垃圾回收体系。这也是本文的讨论重点。
节省内存,提高程序运行效率。如何很多对象有相同的值,为这多个相同的值存储多个副本是很浪费空间的,所以最好做法是让左右对象都共享同一个值的实现。C++标准库中string类采取一种称为”写时复制“的技术,使得只有当字符串被修改的时候才创建各自的拷贝,否则可能(标准库允许使用但没强制要求)采用引用计数技术来管理共享对象的多个对象。这不是本文的讨论范围。
右值引用
auto and decltype
智能指针
bitset
tuple元组
列表初始化
lambda匿名函数
using三种用法
非受限联合体(union)
noexcept
initializer_list
目的是为提升软件的高内聚、低耦合特性。它无法像算法解决具体的实际问题,只是一种优化代码的推荐方式。
简单工厂
开放-封闭原则,我用我自己的话来说,就是如果你想增加新西瓜的产品的话。用简单工厂模式需要增加一个西瓜类,然后在水果工厂里面增加分支。用工厂模式的话需要增加一个西瓜类和一个西瓜类工厂。。。
不要改源码!!!如果用的是简单工厂模式就必须改源码中的条件分支,就必须修改原始代码。所以用工厂模式就不用改源码,只用增加代码即可,妈妈再也不用担心我被骂了。
抽象工厂
现在要卖苹果,苹果汁,梨子,梨子汁。简单工厂模式就是1个工厂卖四个产品。工厂模式就是创建四个工厂卖四个产品。抽象工厂模式就不一样:我可以创建两个工厂。一个专门卖苹果类的,一个专门卖梨子类的,除了这样的分类,还可以进行其他的分类,十分的灵活!
底层实现的机制不同 。
1.浅拷贝: 将原对象或原数组的引用直接赋给新对象,新数组,新对象/数组只是原对象的一个引用
2.深拷贝: 创建一个新的对象和数组,将原对象的各项属性的“值”(数组的所有元素)拷贝过来,是“值”而不是“引用”
总结: 浅拷贝只是对指针的拷贝,拷贝后两个指针指向同一个内存空间,深拷贝不但对指针进行拷贝,而且对指针指向的内容进行拷贝,经深拷贝后的指针是指向两个不同地址的指针。
当对象中存在指针成员时,除了在复制对象时需要考虑自定义拷贝构造函数,还应该考虑以下两种情形:
内存分配有静态分配和动态分配两种
为什么会产生这些小且不连续的空闲内存碎片呢?
1.什么是消息队列
2.消息队列的特点
消息队列可以实现消息的随机查询 。消息不一定要以先进先出的次序读取,编程时可以按消息的类型读取;
消息队列允许一个或多个进程向它写入或者读取消息;
与无名管道、命名管道一样,从消息队列中读出消息,消息队列中对应的数据都会被删除;
每个消息队列都有消息队列标识符,消息队列的标识符在整个系统中是唯一的;
消息队列是消息的链表,存放在内存中,由内核维护。只有内核重启或人工删除消息队列时,该消息队列才会被删除。若不人工删除消息队列,消息队列会一直存在于系统中。
3.抽象的理解消息队列
对于消息队列的操作,我们可以类比为这么一个过程:假如 A 有个东西要给 B,因为某些原因 A 不能当面直接给 B,这时候他们需要借助第三方托管(如银行),A 找到某个具体地址的建设银行,然后把东西放到某个保险柜里(如 1 号保险柜),对于 B 而言,要想成功取出 A 的东西,必须保证去同一地址的同一间银行取东西,而且只有 1 号保险柜的东西才是 A 给自己的。
而在消息队列操作中,键(key)值相当于地址,消息队列标示符相当于具体的某个银行,消息类型相当于保险柜号码。
同一个键(key)值可以保证是同一个消息队列,同一个消息队列标示符才能保证不同的进程可以相互通信,同一个消息类型才能保证某个进程取出是对方的信息。
这两个问题其实是一样的,
提高系统响应速度
提高系统稳定性
消息队列主要优点是可以实现:解耦、异步、削峰
缺点:
1)系统可用性降低:系统引入的外部依赖越多,越容易挂掉,本来你就是A系统调用BCD三个系统的接口就好了,人ABCD四个系统好好的,没啥问题,你偏加个MQ进来,万一MQ挂了咋整?MQ挂了,整套系统崩溃了,你不就完了么。
2)系统复杂性提高:硬生生加个MQ进来,你怎么保证消息没有重复消费?怎么处理消息丢失的情况?怎么保证消息传递的顺序性?头大头大,问题一大堆,痛苦不已
3)一致性问题:A系统处理完了直接返回成功了,人都以为你这个请求就成功了;但是问题是,要是BCD三个系统那里,BD两个系统写库成功了,结果C系统写库失败了,咋整?你这数据就不一致了。
(ps:MQ——Message Queue)
1.什么是消息队列
消息队列提供了一种从一个进程向另一个进程发送一个数据块的方法。消息队列是消息的链接表,存放在内核中并由消息队列标识符标识。 每个数据块都被认为含有一个类型,接收进程可以独立地接收含有不同类型的数据结构。我们可以通过发送消息来避免命名管道的同步和阻塞问题(命名管道要读端和写端都存在,否则出现阻塞)。但是消息队列与命名管道一样,每个数据块都有一个最大长度的限制。
2.消息队列的特点
消息队列可以实现消息的随机查询。消息不一定要以先进先出的次序读取,编程时可以按消息的类型读取;
消息队列允许一个或多个进程向它写入或者读取消息;
与无名管道、命名管道一样,从消息队列中读出消息,消息队列中对应的数据都会被删除;
每个消息队列都有消息队列标识符,消息队列的标识符在整个系统中是唯一的;
消息队列是消息的链表,存放在内存中,由内核维护。只有内核重启或人工删除消息队列时,该消息队列才会被删除。若不人工删除消息队列,消息队列会一直存在于系统中。
而在消息队列操作中,键(key)值相当于地址,消息队列标示符相当于具体的某个银行,消息类型相当于保险柜号码。
同一个键(key)值可以保证是同一个消息队列,同一个消息队列标示符才能保证不同的进程可以相互通信,同一个消息类型才能保证某个进程取出是对方的信息。
⭐优点
⭐缺点︰
int epoll_creat(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event);
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
首先创建一个epoll对象,然后使用epoll_ctl对这个对象进行操作,把需要监控的描述添加进去,这些描述如将会以epoll_event结构体的形式组成一颗红黑树,接着阻塞在epoll_wait,进入大循环,当某个fd上有事件发生时,内核将会把其对应的结构体放入到一个链表中,返回有事件发生的链表。
电平触发(level 模式):该模式就是只要还有没有处理的事件就会一直通知
当epoll_wait检测到文件描述符上有事件发生,并将此事件通知应用程序之后,应用程序可以不立即处理该事件,当下次调用epoll_wait时,还会向应用程序通知这个事件,直到此事件被处理。
如果用户没有处理就绪的文件描述符或者没有处理完,则内核会再次提醒
边沿触发(edge 模式):该模式是当状态发生变化时才会通知
当epoll_wait检测到文件描述符上有事件发生,并将此事件通知应用程序之后,应用程序必须立即处理该事件,并且需要将该事件处理完成,因为epoll_wait下次再被调用时,不会再向应用程序通知该事件。从而降低了同一事件被重复触发的次数,从而效率比LT模式高一些。
内核只会将就绪描述符通知用户一次,如果用户没有处理就绪的文件描述符或者没有处理完,则内核不会再次提醒,只能等下次事件触发,内核将fd重新插入到rdllist中去。
就绪列表引用着就绪的socket,所以它应能够快速的插入数据。
程序可能随时调用epoll_ctl添加监视socket,也可能随时删除。当删除时,若该socket已经存放在就绪列表中,它也应该被移除。
所以就绪列表应是一种能够快速插入和删除的数据结构。双向链表就是这样一种数据结构,epoll使用双向链表来实现就绪队列(对应上图的rdllist)。
既然epoll将“维护监视队列”和“进程阻塞”分离,也意味着需要有个数据结构来保存监视的socket。至少要方便的添加和移除,还要便于搜索,以避免重复添加。红黑树是一种自平衡二叉查找树,搜索、插入和删除时间复杂度都是O(log(N)),效率较好。epoll使用了红黑树作为索引结构(对应上图的rbr)。
✨ps:因为操作系统要兼顾多种功能,以及由更多需要保存的数据,rdlist并非直接引用socket,而是通过epitem间接引用,红黑树的节点也是epitem对象。同样,文件系统也并非直接引用着socket。为方便理解,本文中省略了一些间接结构。
epoll 接口 是为解决 Linux 内核处理大量文件描述符而提出的方案。该接口属于 Linux 下 多路 I/O 复用接口 中 select/poll 的增强。其经常应用于 Linux 下高并发服务型程序, 特别是在大量并发连接中只有少部分连接处于活跃下的情况 (通常是这种情况),在该情况下能显著的提高程序的 CPU 利用率。
epoll 采用的是 事件驱动,并且设计的十分高效。在用户空间获取事件时,不需要去遍历被监听描述符集合中所有的文件描述符,而是遍历那些被内核 I/O 事件异步唤醒之后 加入到就绪队列 并返回到用户空间的描述符集合。
epoll 提供了 两种触发模式,水平触发 (LT) 和边沿触发(ET)。当然,涉及到 I/O 操作也必然会有阻塞和非阻塞两种方案。目前效率相对较高的是 epoll+ET + 非阻塞 I/O 模型,在具体情况下应该合理选用当前情形中最优的搭配方案。
为了能够正常让客户端能正常连接到服务器,服务器必须遵循以下处理流程:
总结: 服务端先建立一个sockaddr_in这样的一个结构体存放ip地址和port号,然后再使用bind()函数把它和一个socket变量(sock)结合,然后开始listen监听套接字sock,再新建一个client的sockaddr_in这样一个结构,然后就可以利用accept函数去sock接受这样的一个client连接了。再之后就可以用recv函数来接收数据了。
阻塞 accept
服务器在繁忙过程时, 在建立三次握手之后, 调用accept之前, 如果出现客户端突然断开连接的情况, POSIX 指出这种情况 errno 设置为 CONNABORTED.
如: 三次握手之后, 客户端发送 RST后断开连接, 之后服务端调用accept准备执行连接但并不知道对端已经关闭, 这个时候accept就会阻塞, 直到有下一个已完成的连接准备好被accept为止. 这个问题在 TCP可能出现的异常[1] 中提到过, 只是实验没有成功.
上述描述的整个过程如下 :
非阻塞 accept
上述的问题也很容易解决, 解决这个问题的办法:
如果使用 select 来获知何时有已完成连接时, 总是把监听 socket 设置为非阻塞模式,并且在 accept 调用中忽略以下错误 : EWOULDBLOCK (Berkeley : 客户放弃连接时出现的错误)、 ECONNABORTED (POSIX : 客户放弃连接时出现的错误)、 EPROTO (SVR4 : 客户放弃连接时出现的错误) 和 EINTR (信号中断).
⭐怎么设置
默认情况下socket是blocking的,即函数accept(), recv/recvfrom, send/sendto,connect等,需等待函数执行结束之后才能够返回(此时操作系统切换到其他进程执行)。accpet()等待到有client连接请求并接受成功之后,recv/recvfrom需要读取完client发送的数据之后才能够返回。
可设置socket为non-blocking模式,即调用函数立即返回,而不是必须等待满足一定条件才返回。
设置socket为非阻塞non-blocking
#include
#include
sock = socket(PF_INET, SOCK_STREAM, 0);
int flags = fcntl(sock, F_GETFL, 0);
fcntl(sock, F_SETFL, flags | O_NONBLOCK);
这样使用原本blocking的各种函数,可以立即获得返回结果。通过判断返回的errno了解状态:
系统用一个4四元组来唯一标识一个TCP连接:
client最大tcp连接数
server最大tcp连接数
实际的tcp连接数
vim /etc/sysctl.conf 这个文件进行修改
net.ipv4.ip_local_port_range = 60000 60009
应用层:网络与用户应用软件之间的接口服务
表示层:格式化的表示与转换服务,例:加密、压缩
会话层:访问验证和会话管理在内的建立、维护之间的通信机制
传输层:建立、维护和取消传输连接功能,负责可靠传输数据PC
网络层:处理网络路由,确保数据及时传送(路由器)
数据链路层:负责无错传输,确认帧、发错重传(交换机)
物理层:提供机械、电气和过程特性,例:网卡、网线等
其中应用层、表示层和会话层由软件控制,传输层、网络层和数据链路层由操作系统控制,物理层有物理设备控制。
1、建立连接协议(三次握手)
(1)客户端发送一个带SYN标志的TCP报文到服务器。这是三次握手过程中的报文1.
(2) 服务器端回应客户端的,这是三次握手中的第2个报文,这个报文同时带ACK标志和SYN标志。因此它表示对刚才客户端SYN报文的回应;同时又标志SYN给客户端,询问客户端是否准备好进行数据通讯。
(3) 客户必须再次回应服务段一个ACK报文,这是报文段3.
2、连接终止协议(四次握手)
由于TCP连接是全双工的,因此每个方向都必须单独进行关闭。这原则是当一方完成它的数据发送任务后就能发送一个FIN来终止这个方向的连接。收到一个 FIN只意味着这一方向上没有数据流动,一个TCP连接在收到一个FIN后仍能发送数据。首先进行关闭的一方将执行主动关闭,而另一方执行被动关闭。
(1) TCP客户端发送一个FIN,用来关闭客户到服务器的数据传送(报文段4)。
(2) 服务器收到这个FIN,它发回一个ACK,确认序号为收到的序号加1(报文段5)。和SYN一样,一个FIN将占用一个序号。
(3) 服务器关闭客户端的连接,发送一个FIN给客户端(报文段6)。
(4) 客户段发回ACK报文确认,并将确认序号设置为收到序号加1(报文段7)。
TCP保证可靠性一般有以下几种方法:
(1)确认应答:ACK和序列号
(2)超时重传:发送数据包在一定的时间周期内没有收到相应的ACK,等待一定的时间,超时之后就认为这个数据包丢失,就会重新发送
(3)流量控制:控制发送方发送窗口的大小来实现流量控制
(4)拥塞控制:控制传输上流量
三次握手的目的是同步连接双方的序列号和确认号并交换 TCP 窗口大小信息。
在被动关闭连接情况下,在已经接收到FIN,但是还没有发送自己的FIN的时刻,连接处于CLOSE_WAIT状态。
通常来讲,CLOSE_WAIT状态的持续时间应该很短,正如SYN_RCVD状态。但是在一些特殊情况下,就会出现连接长时间处于CLOSE_WAIT状态的情况。
出现大量close_wait的现象,主要原因是某种情况下对方关闭了socket链接,但是我方忙与读或者写,没有关闭连接。代码需要判断socket,一旦读到0,断开连接,read返回负,检查一下errno,如果不是AGAIN,就断开连接。
解决方法
基本的思想就是要检测出对方已经关闭的socket,然后关闭它。
1.代码需要判断socket,一旦read返回0,断开连接,read返回负,检查一下errno,如果不是AGAIN,也断开连接。(注:在UNP 7.5节的图7.6中,可以看到使用select能够检测出对方发送了FIN,再根据这条规则就可以处理CLOSE_WAIT的连接)
2.给每一个socket设置一个时间戳last_update,每接收或者是发送成功数据,就用当前时间更新这个时间戳。定期检查所有的时间戳,如果时间戳与当前时间差值超过一定的阈值,就关闭这个socket。
3.使用一个Heart-Beat线程,定期向socket发送指定格式的心跳数据包,如果接收到对方的RST报文,说明对方已经关闭了socket,那么我们也关闭这个socket。
半连接攻击:
SYN攻击简介:
SYN攻击原理与实现:
UDP: 利用IP来提供面向无连接的一种通信协议
⭐以太网采用超时重发机制,单点的故障容易扩散,造成整个网络系统的瘫痪;对工业环境的适应能力问题,目前工业以太网的鲁棒性和抗干扰能力等都是值得关注的问题;数据的传输距离长、传输速率高;易与Internet连接,低成本、易组网,与计算机、服务器的接口十分方便,受到了广泛的技术支持。
⭐CAN现场总线的数据通信具有突出的可靠性、实时性和灵活性。主要表现在CAN为多主方式工作; CAN总线的节点分成不同的优先级;采用非破坏仲裁技术;报文采用短帧结构,数据出错率极低;节点在错误严重的情况下可自动关闭输出。不能与Internet互连,不能实现远程信息共享。其次,它不易与上位控制机直接接口,现有的CAN接口卡与以太网网卡相比大都价格昂贵。还有, CAN现场总线无论是其通信距离还是通信速率都无法和以太网相比。
慢启动:每次收到一个 ACK 报文将拥塞窗口(cwnd)加上一个 MSS,从 1 开始成指数级增长
拥塞避免:当 cwnd ≥ 慢启动阈值(sstresh)时,窗口按线性增长,当收到三个连续的冗余 ACK 后,进入快重启
快重启:sstresh=cwnd, cwnd = sstresh + 3*MSS(三次冗余的)并发送丢失的报文,每次收到冗余的就指数级增长直到收到新的 ACK,进入拥塞避免或者超时进入慢重启
物理层的任务就是透明地传送比特流。在物理层上所传数据的单位是比特。传递信息所利用的一些物理媒体,如双绞线、同轴电缆、光缆等,并不在物理层之内而是在物理层的下面。因此也有人把物理媒体当做第0层。
1、慢开始
最开始发送方的拥塞窗口为1,由小到大逐渐增大发送窗口和拥塞窗口。每经过一个传输轮次,拥塞窗口cwnd加倍。当cwnd超过慢开始门限,则使用拥塞避免算法,避免cwnd增长过大。
2、拥塞避免
每经过一个往返时间RTT,cwnd就增长1。在慢开始和拥塞避免的过程中,一旦发现网络拥塞,就把慢开始门限设为当前值的一半,并且重新设置cwnd为1,重新慢启动。(乘法减小,加法增大)
3、快重传接收方每次收到一个失序的报文段后就立即发出重复确认,发送方只要连续收到三个重复确认就立即重传(尽早重传未被确认的报文段)。
4、快恢复
当发送方连续收到了三个重复确认,就乘法减半(慢开始门限减半),将当前的cwnd设置为慢开始门限,并且采用拥塞避免算法(连续收到了三个重复请求,说明当前网络可能没有拥塞)。
采用快恢复算法时,慢开始只在建立连接和网络超时才使用。达到什么情况的时候开始减慢增长的速度?
采用慢开始和拥塞避免算法的时候
一旦cwnd>慢开始门限,就采用拥塞避免算法,减慢增长速度一旦出现丢包的情况,就重新进行慢开始,减慢增长速度
采用快恢复和快重传算法的时候
一旦cwnd>慢开始门限,就采用拥塞避免算法,减慢增长速度
一旦发送方连续收到了三个重复确认,就采用拥塞避免算法,减慢增长速度
HTTP长连接和短连接实质上是TCP的长连接和短连接。
浏览器根据请求的 URL 交给 DNS 域名解析,找到真实 IP ,向服务器发起请求;
服务器交给后台处理完成后返回数据,浏览器接收⽂件( HTML、JS、CSS 、图象等);
浏览器对加载到的资源( HTML、JS、CSS 等)进⾏语法解析,建立相应的内部数据结构 (如 HTML 的 DOM);
载⼊解析到的资源⽂件,渲染页面,完成。
详细简版:
1.从浏览器接收 url 到开启⽹络请求线程(这⼀部分可以展开浏览器的机制以及进程与线程 之间的关系)
2.开启⽹络线程到发出⼀个完整的 HTTP 请求(这⼀部分涉及到dns查询, TCP/IP 请求,五层因特⽹协议栈等知识)
3.从服务器接收到请求到对应后台接收到请求(这⼀部分可能涉及到负载均衡,安全拦截以及后台内部的处理等等)
4.后台和前台的 HTTP 交互(这⼀部分包括 HTTP 头部、响应码、报⽂结构、 cookie 等知 识,可以提下静态资源的 cookie 优化,以及编码解码,如 gzip 压缩等)
5.单独拎出来的缓存问题, HTTP 的缓存(这部分包括http缓存头部, ETag , catchcontrol 等)
6.浏览器接收到 HTTP 数据包后的解析流程(解析 html、 词法分析然后解析成 dom 树、解析 css ⽣成 css 规则树、合并成 render 树,然后 layout 、 painting 渲染、复合图层的合成、 GPU 绘制、外链资源的处理、 loaded 和 DOMContentLoaded 等)
7.CSS 的可视化格式模型(元素的渲染规则,如包含块,控制框, BFC , IFC 等概念)
8.JS 引擎解析过程( JS 的解释阶段,预处理阶段,执⾏阶段⽣成执⾏上下⽂, VO ,作 ⽤域链、回收机制等等)
9.其它(可以拓展不同的知识模块,如跨域,web安全, hybrid 模式等等内容)
浏览器中输入URL浏览器要将URL解析为IP地址,解析域名就要用到DNS协议,首先主机会查询DNS的缓存,如果没有就给本地DNS发送查询请求。DNS查询分为两种方式,一种是递归查询,一种是迭代查询。如果是迭代查询,本地的DNS服多器,回根理(服务器发送查询请求,根域名服务器告知该域名的一级域名服务器,然后本地服务器给该一级域名服务器发送查询请求,然后依次类推直到查询到该域名的IP地址。DNS服务器是基于UDP的,因此会用到UDP协议。
得到IP地址后,浏览器就要与服务器建立一个http连接。因此要用到http协议,http协议报文格式上面已经提到。http生成一个get请求报文,将该报文传给TCP层处理,所以还会用到TCP协议。如果采用https还会使用https协议先对http数据进行加密。TCP层如果有需要先将HTTP数据包分片,分片依据路径MTU和MSS。TCP的数据包然后会发送给IP层,用到IP协议。IP层通过路由选路,一跳一跳发送到目的地址。当然在一个网段内的寻址是通过以太网协议实现(也可以是其他物理层协议,比如PPP,SLIP),网协议需要直到目的IP地址的物理地址,有需要ARP协议。
1、DNS协议,http协议,https协议属于应用层
应用层是体系结构中的最高层。应用层确定进程之间通信的性质以满足用户的需要。这里的进程就是指正在运行的程序。应用层不仅要提供应用进程所需要的信息交换和远地操作,而且还要作为互相作用的应用进程的用户代理,来完成一些为进行语义上有意义的信息交换所必须的功能。应用层直接为用户的应用进程提供服务。
2、TCP/UDP属于传输层
传输层的任务就是负责主机中两个进程之间的通信。因特网的传输层可使用两种不同协议:即面向连接的传输控制协议TCP,和无连接的用户数据报协议UDP。面向连接的服务能够提供可靠的交付,但无连接服务则不保证提供可靠的交付,它只是“尽最大努力交付”。这两种服务方式都很有用,备有其优缺点。在分组交换网内的各个交换结点机都没有传输层。
3、IP协议,ARP协议属于网络层
网络层负责为分组交换网上的不同主机提供通信。在发送数据时,网络层将运输层产生的报文段或用户数据报封装成分组或包进行传送。在TCP/IP体系中,分组也叫作IP数据报,或简称为数据报。网络层的另一个任务就是要选择合适的路由,使源主机运输层所传下来的分组能够交付到目的主机。
4、数据链路层当发送数据时,数据链路层的任务是将在网络层交下来的IP数据报组装成帧,在两个相邻结点间的链路上传送以帧为单位的数据。每一帧包括数据和必要的控制信息(如同步信息、地址信息、差错控制、以及流量控制信息等)。控制信息使接收端能够知道一个帧从哪个比特开始和到哪个比特结束。控制信息还使接收端能够检测到所收到的帧中有无差错。
5、物理层
物理层的任务就是透明地传送比特流。在物理层上所传数据的单位是比特。传递信息所利用的一些物理媒体,如双绞线、同轴电缆、光缆等,并不在物理层之内而是在物理层的下面。因此也有人把物理媒体当做第0层。
进程是操作系统中最重要的抽象概念之一,是资源分配的基本单位,是独立运行的基本单位。
进程的经典定义就是一个执行中程序的实例。系统中的每个程序都运行在某个进程的上下文(context)中。
上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。
进程一般由以下的部分组成:
进程控制块PCB,是进程存在的唯一标志,包含进程标识符PID,进程当前状态,程序和数据地址,进程优先级、CPU现场保护区(用于进程切换),占有的资源清单等。
程序段
数据段
程序 只是一段可以执行的代码文件,通俗讲在 linux 上就是一个可执行文件。当一个程序运行时就被称为进程,即进程是运行状态的程序。
程序存储了一系列文件信息,这些信息描述了如何在运行时创建一个进程,包含了下面的内容:
内核在加载程序的时候会为其分配一个唯一标识符即进程号,linux 内核限制进程号需要小于等于32767每当创建一个进程的时候,内核就会顺序将下一个可用进程分配给其使用, 当进程号大于32767时,内核会重置进程号计数器,然后开始重新分配。 因为内核会运行一些守护进程和系统进程,所有一般会预留一些进程号给这些程序使用,所以一般从300开始重置, 类似于端口号1-1024为系统占用。
是进程划分的任务,是一个进程内可调度的实体,是CPU调度的基本单位,用于保证程序的实时性,实现进程内部的并发。
线程是操作系统可识别的最小执行和调度单位。每个线程都独自占用一个虚拟处理器:独自的寄存器组,指令计数器和处理器状态。
每个线程完成不同的任务,但是属于同一个进程的不同线程之间共享同一地址空间(也就是同样的动态内存,映射文件,目标代码等等),打开的文件队列和其他内核资源。
线程产生的原因: 进程可以使多个程序能并发执行,以提高资源的利用率和系统的吞吐量;但是其具有一些缺点:
进程在同一时刻只能做一个任务,很多时候不能充分利用CPU资源。
进程在执行的过程中如果发生阻塞,整个进程就会挂起,即使进程中其它任务不依赖于等待的资源,进程仍会被阻塞。
引入线程就是为了解决以上进程的不足,线程具有以下的优点:
从资源上来讲,开辟一个线程所需要的资源要远小于一个进程。
从切换效率上来讲,运行于一个进程中的多个线程,它们之间使用相同的地址空间,而且线程间彼此切换所需时间也远远小于进程间切换所需要的时间(这种时间的差异主要由于缓存的大量未命中导致)。
从通信机制上来讲,线程间方便的通信机制。对不同进程来说,它们具有独立的地址空间,要进行数据的传递只能通过进程间通信的方式进行。线程则不然,属于同一个进程的不同线程之间共享同一地址空间,所以一个线程的数据可以被其它线程感知,线程间可以直接读写进程数据段(如全局变量)来进行通信(需要一些同步措施)。
进程在运行过程有三种状态:就绪、运行、阻塞,创建和退出状态描述的是进程的创建过程和退出过程。
就绪(Ready):程序等待执行
运行(Running):程序正在执行,此线程正在执行,正在占用时间片
阻塞(Blocked):进程执行了某些操作需要等待其运行,如:IO 操作
还有两种特殊的状态
初始(Initial):进程在创建时的状态,操作系统在创建进程时要进行的工作包括分配和建立进程控制块表项、建立资源表格并分配资源、加载程序并建立地址空间
最终(Final):退出但没有清理,使得其他进程可以得取返回值,时间片已用完,此线程被强制暂停,等待下一个属于他的时间片到来,进程已结束,所以也称结束状态,释放操作系统分配的资源
进程在运行时有三种基本状态:就绪态、运行态和阻塞态 。
运行(running)态:进程占有处理器正在运行的状态。进程已获得CPU,其程序正在执行。在单处理机系统中,只有一个进程处于执行状态;在多处理机系统中,则有多个进程处于执行状态。
就绪(ready)态:进程具备运行条件,等待系统分配处理器以便运行的状态。 当进程已分配到除CPU以外的所有必要资源后,只要再获得CPU,便可立即执行,进程这时的状态称为就绪状态。在一个系统中处于就绪状态的进程可能有多个,通常将它们排成一个队列,称为就绪队列。
阻塞(wait)态:又称等待态或睡眠态,指进程不具备运行条件,正在等待某个时间完成的状态。
各状态之间的转换:
就绪→执行 处于就绪状态的进程,当进程调度程序为之分配了处理机后,该进程便由就绪状态转变成执行状态。
执行→就绪 处于执行状态的进程在其执行过程中,因分配给它的一个时间片已用完而不得不让出处理机,于是进程从执行状态转变成就绪状态。
执行→阻塞 正在执行的进程因等待某种事件发生而无法继续执行时,便从执行状态变成阻塞状态。
阻塞→就绪 处于阻塞状态的进程,若其等待的事件已经发生,于是进程由阻塞状态转变为就绪状态。
最短任务优先(SJF)
先来先出(FIFO)
高响应比优先(HRRN)
最短完成时间优先(STCF)
时间片轮转(RR)
多级反馈队列(MLFQ)
⭐进程和线程的主要差别在于它们是 不同的操作系统资源管理方式。
1.进程有独立的地址空间,线程有自己的堆栈和局部变量,但线程之间没有单独的地址空间;
2.进程和线程切换时,需要切换进程和线程的上下文,进程的上下文切换时间开销远远大于线程上下文切换时间,耗费资源较大,效率要差一些;
3.进程的并发性较低,线程的并发性较高;
4.每个独立的进程有一个程序运行的入口、顺序执行序列和程序的出口,但是线程不能够独立执行,必须依存在应用程序中,由应用程序提供多个线程执行控制
5.系统在运行的时候会为每个进程分配不同的内存空间;而对线程而言,除了CPU外,系统不会为线程分配内存(线程所使用的资源来自其所属进程的资源),线程组之间只能共享资源;
6.一个进程崩溃后,在保护模式下不会对其他进程产生影响,但是一个线程崩溃整个进程都死掉。所以多进程要比多线程健壮。
一个线程只能属于一个进程,而一个进程可以有多个线程,但至少有一个线程。线程依赖于进程而存在。
进程在执行过程中拥有独立的地址空间,而多个线程共享进程的地址空间。(资源分配给进程,同一进程的所有线程共享该进程的所有资源。同一进程中的多个线程共享代码段(代码和常量),数据段(全局变量和静态变量),扩展段(堆存储)。但是每个线程拥有自己的栈段,栈段又叫运行时段,用来存放所有局部变量和临时变量。)
进程是资源分配的最小单位,线程是CPU调度的最小单位。
通信:由于同一进程中的多个线程具有相同的地址空间,使它们之间的同步和通信的实现,也变得比较容易。进程间通信IPC,线程间可以直接读写进程数据段(如全局变量)来进行通信(需要一些同步方法,以保证数据的一致性)。
进程编程调试简单可靠性高,但是创建销毁开销大;线程正相反,开销小,切换速度快,但是编程调试相对复杂。
进程间不会相互影响;一个进程内某个线程挂掉将导致整个进程挂掉。
进程适应于多核、多机分布;线程适用于多核。
区别:
1.线程是程序执行的最小单位,而进程是操作系统分配资源的最小单位;
2.一个进程由一个或多个线程组成,线程是一个进程中代码的不同执行路线;
3.进程之间相互独立,但同一进程下的各个线程之间共享程序的内存空间(包括代码段、数据集、堆等)及一些进程级的资源(如打开文件和信号),某进程内的线程在其它进程不可见;
4.调度和切换:线程上下文切换比进程上下文切换要快得多。
共享资源
独占资源
1、孤儿进程:父进程退出,子进程还在运行的这些子进程都是孤儿进程,孤儿进程将被init进程(1号进程)所收养,并由init进程对他们完成状态收集工作。
2、僵尸进程:进程使用fork创建子进程,如果子进程退出,而父进程并没有调用wait 获waitpid 获取子进程的状态信息,那么子进程的进程描述符仍然保存在系统中的这些进程是僵尸进程
1.孤儿进程
2.僵尸进程
3.解决僵尸进程
⭐写时拷贝顾名思义就是“写的时候才分配内存空间”,这实际上是一种拖延战术。
数据传输:一个进程需要把它的数据发送给另一个进程 通知事件 资源共享 进程控制
进程是一个独立的资源分配单元,不同进程(这里所说的进程通常指的是用户进程)之间的资源是独立的,没有关联,
不能在一个进程中直接访问另一个进程的资源(例如打开的文件描述符)。但是,进程不是孤立的,不同的进程需要进行信息的交互和状态的传递等,因此需要进程间通信
线程间无需特别的手段进行通信,因为线程间可以共享一份全局内存区域,其中包括初始化数据段、未初始化数据段,以及堆内存段等,所以线程之间可以方便、快速地共享信息。只需要将数据复制到共享(全局或堆)变量中即可。不过,要考虑线程的同步和互斥,应用到的技术有:
临界区、互斥、信号量、事件
操作系统中,进程是具有不同的地址空间的,两个进程是不能感知到对方的存在的。有时候,需要多个进程来协同完成一些任务。当多个进程需要对同一个内核资源进行操作时,这些进程便是竞争的关系,操作系统必须协调各个进程对资源的占用,进程的互斥是解决进程间竞争关系的方法。进程互斥指若干个进程要使用同一共享资源时,任何时刻最多允许一个进程去使用,其他要使用该资源的进程必须等待,直到占有资源的进程释放该资源。当多个进程协同完成一些任务时,不同进程的执行进度不一致,这便产生了进程的同步问题。需要操作系统干预,在特定的同步点对所有进程进行同步,这种协作进程之间相互等待对方消息或信号的协调关系称为进程同步。进程互斥本质上也是一种进程同步。进程的同步方法:
互斥锁
读写锁
条件变量
记录锁(record locking)
信号量
屏障(barrier)
操作系统中,属于同一进程的线程之间具有相同的地址空间,线程之间共享数据变得简单高效。遇到竞争的线程同时修改同一数据或是协作的线程设置同步点的问题时,需要使用一些线程同步的方法来解决这些问题。
线程同步的方法:
互斥锁
读写锁
条件变量
信号量
自旋锁
屏障(barrier)
并发(concurrency):指宏观上看起来两个程序在同时运行,比如说在单核cpu上的多任务。但是从微观上看两个程序的指令是交织着运行的,指令之间交错执行,在单个周期内只运行了一个指令。这种并发并不能提高计算机的性能,只能提高效率(如降低某个进程的相应时间)。
并行(parallelism):指严格物理意义上的同时运行,比如多核cpu,两个程序分别运行在两个核上,两者之间互不影响,单个周期内每个程序都运行了自己的指令,也就是运行了两条指令。这样说来并行的确提高了计算机的效率。所以现在的cpu都是往多核方面发展。
借用皮卡丘的介绍: 什么是协程?
⭐操作系统保持跟踪进程运行所需的所有状态信息,这种状态,也就是上下文。
为什么会有上下文的切换?
上下文切换为什么要陷入内核?
(1)上一个进程的上下文信息还在内存和处理器当中,我们要保存这些信息的话,就必须陷入到内核态才可以。
(2)创建一个新的进程,以及它的上下文信息,并且将控制权交给这个新进程,这些都只有在内核态才能实现。
⭐ 进程的上下文切换涉及到从【用户态】->【内核态】->【用户态】的过程
⭐ 线程 的上下文切换涉及到从【用户态】->【内核态】->【用户态】的过程
⭐ 协程 只需在【用户态】即可完成上下文的切换
协程,即用户态线程。我们知道,在Linux下,线程有PCB,然后可以占用时间片去调度,但是在用户态线程中,该线程的执行不由内核做调度,由用户自己实现
可以这么理解,在用户进程A中,再实现了个调度器,调度用户线程,这些线程不像之前的线程,内核是感知不到的,它们只能感知到A的存在,用户态线程之间时间片只能争取内核分给进程A的时间片。
协程上下文切换只涉及CPU上下文切换,而所谓的CPU上下文切换是指少量寄存器(PC / SP / DX)的值修改,协程切换非常简单,就是把当前协程的 CPU 寄存器状态保存起来,然后将需要切换进来的协程的 CPU 寄存器状态加载的 CPU 寄存器上就 ok 了。
大量的进程 / 线程出现了新的问题 :
而协程刚好可以解决上述2个问题。协程运行在线程之上,当一个协程执行完成后,可以选择主动让出,让另一个协程运行在当前线程之上。并且,协程并没有增加线程数量,只是在线程的基础之上通过分时复用的方式运行多个协程,而且协程的切换在用户态完成,切换的代价比线程从用户态到内核态的代价小很多。
程序段(Text):程序代码在内存中的映射,存放函数体的二进制代码。
代码区指令根据程序设计流程依次执行,对于顺序指令,则只会执行一次(每个进程),如果反复,则需要使用跳转指令,如果进行递归,则需要借助栈来实现。
代码段: 代码段(code segment/textsegment )通常是指用来存放程序执行代码的一块内存区域。 这部分区域的大小在程序运行前就已经确定,并且内存区域通常属于只读, 某些架构也允许代码段为可写,即允许修改程序 在代码段中,也有可能包含一些只读的常数变量,例如字符串常量等。
代码区的指令中包括操作码和要操作的对象(或对象地址引用)。如果是立即数(即具体的 数值,如5),将直接包含在代码中;如果是局部数据,将在栈区分配空间,然后引用该数据地址;如果是BSS区和数据区 在代码中同样将引用该数据地址。另外,代码段还规划了局部数据所申请的内存空间信息。
初始化过的数据(Data):在程序运行初已经对变量进行初始化的数据。
只初始化一次。
数据段: 数据段(data segment )通常是指用来存放程序中已初始化的全局变量的一块内存区域。数据段属于静态内存分配。
data段中的静态数据区存放的是程序中已初始化的全局变量、静态变量和常量。
未初始化过的数据(BSS):在程序运行初未对变量进行初始化的数据。
在运行时改变其值。
BSS 段: BSS 段(bss segment )通常是指用来存放程序中未初始化的全局变量的一块内存区域。BSS 是英文Block Started by Symbol 的简称。
BSS 段属于静态内存分配, 即程序一开始就将其清零了。一般在初始化时BSS段部分将会清零。
堆 (Heap):存储动态内存分配,需要程序员手工分配,手工释放.注意它与数据结构中的堆是两回事,分配方式类似于链表。
用于动态内存分配。堆在内存中位于bss区和栈区之间。一般由程序员分配和释放,若程序员不释放,程序结束时有可能由OS回收。
堆(heap): 堆是用于存放进程运行中被动态分配的内存段,它的大小并不固定,可动态扩张或缩减。当进程调用malloc 等函数分配内存时,新分配的内存就被动态添加到堆上(堆被扩张);当利用free 等函数释放内存时,被释放的内存从堆中被剔除(堆被缩减)。
在将应用程序加载到内存空间执行时,操作系统负责代码段、数据段和BSS段的加载, 并将在内存中为这些段分配空间。栈段亦由操作系统分配和管理,而不需要程序员显示地管理;堆段由程序员自己管理,即显式地申请和释放空间。
栈 (Stack):存储局部、临时变量,函数调用时,存储函数的返回指针,用于控制函数的调用和返回。在程序块开始时自动分配内存,结束时自动释放内存,其操作方式类似于数据结构中的栈。
由编译器自动分配释放,存放函数的参数值、局部变量的值等。
存放函数的参数值、 局部变量的值,以及在进行任务切换时存放当前任务的上下文内容。其操作方式类似于数据结构中的栈。 每当一个函数被调用,该函数返回地址和一些关于调用的信息,比如某些寄存器的内容,被存储到栈区。 然后这个被调用的函数再为它的自动变量和临时变量在栈区上分配空间, 这就是C实现函数递归调用的方法。每执行一次递归函数调用,一个新的栈框架就会被使用, 这样这个新实例栈里的变量就不会和该函数的另一个实例栈里面的变量混淆。
栈(stack) :栈又称堆栈, 是用户存放程序临时创建的局部变量,也就是说我们函数括弧"{}"中定义的变量 (但不包括static 声明的变量,static 意味着在数据段中存放变量)。除此以外,在函数被调用时, 其参数也会被压入发起调用的进程栈中,并且待到调用结束后,函数的返回值也会被存放 回栈中。由于栈的先进先出特点, 所以栈特别方便用来保存/ 恢复调用现场。
从这个意义上讲,我们可以把堆栈看成一个寄存、交换临时数据的内存区。
内核空间和用户空间
Linux的虚拟地址空间范围为0~4G(intel x86架构32位),Linux内核将这4G字节的空间分为两部分,将最高的1G字节(从虚拟地址0xC0000000到0xFFFFFFFF)供内核使用,称为“内核空间”。而将较低的3G字节(从虚拟地址0x00000000到0xBFFFFFFF)供各个进程使用,称为“用户空间”。
因为每个进程可以通过系统调用进入内核,因此,Linux内核由系统内的所有进程共享。于是,从具体进程的角度来看,每个进程可以拥有4G字节的虚拟空间。
Linux使用两级保护机制:0级供内核使用,3级供用户程序使用,每个进程有各自的私有用户空间(0~3G),这个空间对系统中的其他进程是不可见的,最高的1GB字节虚拟内核空间则为所有进程以及内核所共享。
内核空间中存放的是内核代码和数据,而进程的用户空间中存放的是用户程序的代码和数据。不管是内核空间还是用户空间,它们都处于虚拟空间中。 虽然内核空间占据了每个虚拟空间中的最高1GB字节,但映射到物理内存却总是从最低地址(0x00000000),另外,使用虚拟地址可以很好的保护内核空间被用户空间破坏,虚拟地址到物理地址转换过程有操作系统和CPU共同完成(操作系统为CPU设置好页表,CPU通过MMU单元进行地址转换)。
⭐注: 多任务操作系统中的每一个进程都运行在一个属于它自己的内存沙盒中,这个沙盒就是虚拟地址空间(virtual address space),在32位模式下,它总是一个4GB的内存地址块。这些虚拟地址通过页表(page table)映射到物理内存,页表由操作系统维护并被处理器引用。每个进程都拥有一套属于它自己的页表。
为了避免操作系统和关键数据被用户程序破坏,将处理器的执行状态分为内核态和用户态。
内核态是操作系统管理程序执行时所处的状态,能够执行包含特权指令在内的一切指令,能够访问系统内所有的存储空间。
用户态是用户程序执行时处理器所处的状态,不能执行特权指令,只能访问用户地址空间。
用户程序运行在用户态,操作系统内核运行在内核态。
用户态的程序就不能随意操作内核地址空间,具有一定的安全保护作用;内核态线程共享内核地址空间
处理器从用户态切换到内核态的方法有三种:系统调用、异常和外部中断。
malloc()是一个API,这个函数在库中封装了系统调用brk。因此如果调用malloc,那么首先会引发brk系统调用执行的过程。brk()在内核中对应的系统调用服务例程为SYSCALL_DEFINE1(brk, unsigned long, brk),参数brk用来指定heap段新的结束地址,也就是重新指定mm_struct结构中的brk字段。
brk系统调用服务例程首先会确定heap段的起始地址min_brk,然后再检查资源的限制问题。接着,将新老heap地址分别按照页大小对齐,对齐后的地址分别存储与newbrk和okdbrk中。
brk()系统调用本身既可以缩小堆大小,又可以扩大堆大小。缩小堆这个功能是通过调用do_munmap()完成的。如果要扩大堆的大小,那么必须先通过find_vma_intersection()检查扩大以后的堆是否与已经存在的某个虚拟内存重合,如何重合则直接退出。否则,调用do_brk()进行接下来扩大堆的各种工作。
⭐什么是共享内存?
共享内存
优点:采用共享内存通信的一个显而易见的好处是效率高,因为进程可以直接读写内存,而不需要任何数据的拷贝。对于像管道和消息队列等通信方式,则需要在内核和用户空间进行四次的数据拷贝,而共享内存则只拷贝两次数据: 一次从输入文件到共享内存区,另一次从共享内存区到输出文件。实际上,进程之间在共享内存时,并不总是读写少量数据后就解除映射,有新的通信时,再重新建 立共享内存区域。而是保持共享区域,直到通信完毕为止,这样,数据内容一直保存在共享内存中,并没有写回文件。共享内存中的内容往往是在解除映射时才写回 文件的。因此,采用共享内存的通信方式效率是非常高的。
缺点: 共享内存没有提供同步的机制,这使得我们在使用共享内存进行进程间通信时,往往要借助其他的手段(信号量)来进行进程间的同步工作。
地址空间是一个非负整数地址的有序集合。
在一个带虚拟内存的系统中,CPU 从一个有N=pow(2,n)个地址的地址空间中生成虚拟地址,这个地址空间称为虚拟地址空间(virtual address space),现代系统通常支持 32 位或者 64 位虚拟地址空间。
一个系统还有一个物理地址空间(physical address space),对应于系统中物理内存的M 个字节。
地址空间的概念是很重要的,因为它清楚地区分了数据对象(字节)和它们的属性(地址)。
一旦认识到了这种区别,那么我们就可以将其推广,允许每个数据对象有多个独立的地址,其中每个地址都选自一个不同的地址空间。这就是虚拟内存的基本思想。
主存中的每字节都有一个选自虚拟地址空间的虚拟地址和一个选自物理地址空间的物理地址。
为了更加有效地管理内存并且少出错,现代系统提供了一种对主存的抽象概念,叫做虚拟内存(VM)。虚拟内存是硬件异常、硬件地址翻译、主存、磁盘文件和内核软件的完美交互,它为每个进程提供了一个大的、一致的和私有的地址空间。通过一个很清晰的机制,虚拟内存提供了三个重要的能力:
它将主存看成是一个存储在磁盘上的地址空间的高速缓存,在主存中只保存活动区域,并根据需要在磁盘和主存之间来回传送数据,通过这种方式,它高效地使用了主存。
它为每个进程提供了一致的地址空间,从而简化了内存管理。
它保护了每个进程的地址空间不被其他进程破坏。
因为切换后 TLB 无法被命中
什么是缺页中断 ,简单来说是因为操作系统采用了虚拟内存技术,程序代码/数据对应的内容并不一定是完全读入到内存中,在使用到时候发生缺页中断将对应的内容读入到内存中。
当一个进程发生缺页中断的时候,进程会陷入内核态,执行以下操作:
分配内存kmalloc()
重新分配内存krealloc()
释放内存kfree()
slab分配器(内存缓存的数据结构):每个内存缓存对应一个kmem_cache实例。每个内存节点对应一个kmem_cache_node实例。
kfree()函数如何知道对象属于哪个通用的内存缓存?
slab为一个或者多个连续的物理页,起始地址总是页长度的整数倍,不同slab中相同偏移的位图在处理器的一级缓存中索引相同。
读写锁与互斥锁类似,但读写锁允许更高的并行性,其特性为:读共享,写独占,写锁优先级最高
读写锁特性:读写锁是写模式加锁时,解锁前,所有对该锁加锁的线程都会被阻塞
读写锁是读模式加锁时,如果线程以读模式对其加锁已经成功,其他线程试图以写模式加锁的线程将阻塞,以读模式加锁的线程不受影响;如果当前同时有试图读模式加锁和写模式加锁的线程,优先满足写模式加锁,读锁、写锁并行阻塞。
互斥锁
互斥锁加锁失败后,会从用户态陷入到内核态,让内核帮助我们切换线程,虽然简化了使用锁的难度,但是存在一定的性能开销成本。
性能开销成本:两次线程上下文切换的成本。
1、当线程加锁失败时,内核将线程的状态从 【运行】 切换到睡眠状态,然后把CPU切换给其他线程运行;
2、当锁被释放时,之前睡眠状态的线程会变成就绪状态,然后内核就会在合适的时间把CPU切换给该线程运行;
⭐线程切换的上下文?
当两个线程属于同一个进程,因为虚拟内存是共享的,所以在切换时,虚拟内存这些资源就保持不动,只需要切换线程的私有数据、寄存器等不共享的数据。
上下切换的耗时大概在几十纳秒到几微秒之间,如果锁住的代码执行时间比较短,可能上下文切换的时间比锁住的代码执行时间还要长。
自旋锁
自旋锁和互斥锁类似,但其不是通过休眠使进程阻塞,而是在获取锁之前一直处于阻塞
加锁过程:
使用自旋锁的时候,当发生多线程竞争锁的情况,加锁失败的线程会忙等待,直到拿到锁。忙等待可以通过while循环实现,不过最好是使用CPU提供的PAUSE指令来实现。
自旋锁利用CPU周期一直自旋直到锁可用。由于一个自旋的线程永远不会放弃CPU,因此在单核CPU上,需要抢占式的调度器(不断通过时钟中断一个线程,运行其他线程)。
自旋的时间和被锁住的代码执行的时间成正比关系。
选择
死锁是指两个或两个以上进程在执行过程中,因争夺资源而造成的下相互等待的现象。产生死锁需要满足下面四个条件:
互斥条件:进程对所分配到的资源不允许其他进程访问,若其他进程访问该资源,只能等待,直至占有该资源的进程使用完成后释放该资源。
占有并等待条件:进程获得一定的资源后,又对其他资源发出请求,但是该资源可能被其他进程占有,此时请求阻塞,但该进程不会释放自己已经占有的资源。
非抢占条件:进程已获得的资源,在未完成使用之前,不可被剥夺,只能在使用后自己释放。
循环等待条件:进程发生死锁后,必然存在一个进程-资源之间的环形链。
解决死锁的方法即破坏产生死锁的四个必要条件之一,主要方法如下:
资源一次性分配,这样就不会再有请求了(破坏请求条件)。
只要有一个资源得不到分配,也不给这个进程分配其他的资源(破坏占有并等待条件)。
可抢占资源:即当进程新的资源未得到满足时,释放已占有的资源,从而破坏不可抢占的条件。
资源有序分配法:系统给每类资源赋予一个序号,每个进程按编号递增的请求资源,释放则相反,从而破坏环路等待的条件
基于锁的编程的缺点
无锁编程的定义
用户进程位于用户空间,内核进程位于系统空间,磁盘只能被内核直接访问。
在运行内核代码时,CPU工作在管理员模式,这对应于一些特殊的堆栈和内存环境,必须在系统调用时切换到这个环境中。系统调用结束后,CPU要切换到用户模式,又要将堆栈和内存环境恢复到用户模式的状态,这种内存环境的切换要耗费很多时间。
因此,系统调用所耗费的时间主要在两次环境切换上,如果用户程序中普通代码和系统调用交替出现,那么将产生很大的环境切换的开销。
CPU寄存器,与程序计数器(存储CPU正在执行的指令位置,或者即将执行的下一条指令的位置)共同组成CPU上下文。
CPU上下文切换指的是:在多任务操作系统中,为了提高CPU的利用率,可以让当前系统运行远多于CPU核数的线程。但是由于同时运行的线程数是由CPU核数来决定的,所以为了支持更多的线程运行,CPU会把自己的时间片轮流分给其他线程,这个过程就是上下文切换。
根据任务的不同,CPU的上下文切换可以分为几个不同场景(进程上下文切换、线程上下文切换、中断上下文切换)
进程上下文切换
已知进程运行空间分为内核空间和用户空间。内核空间可以访问所有资源,用户空间只能访问受限资源,不能访问内存等硬件设备。
从用户态到内核态的转变需要通过系统调用来完成。如open、read、write、close等对于文件的操作都属于系统调用。
系统调用的过程中会发生CPU上下文切换,先切换到内核态,执行内核态代码,再跳转回用户态代码。所以一次系统调用会发生两次CPU上下文切换,又称特权模式切换,不过仍然是同一个进程在运行。
进程由内核管理和调度,进程的切换只能发生在内核态,进程上下文不仅包括虚拟内存、栈、全局变量等用户空间资源,还包括内核堆栈、寄存器等内核空间状态。每次进程上下文切换需要几十纳秒到数微秒的CPU时间。
并且Linux通过TLB来管理虚拟内存到物理内存之间的映射,当虚拟内存更新后,TLB也需要刷新,内存的访问也会随之变慢。特别在多处理器系统上,缓存被多个处理器共享,刷新缓存不仅会影响当前处理器的进程,还会影响共享缓存的其他处理器的进程。
进程上下文切换发生的时机:
在进程调度的时候,需要进行切换上下文。Linux为每个CPU维护一个就绪队列,将活跃进程(正在运行和正在等待CPU的进程)
按照优先级和等待CPU的时间来排序,然后选择最需要CPU的进程运行。(优先级高和等待时间长的进程)
1、CPU时间被划分为一段段时间片,当某个进程的时间片耗尽,就会被系统挂起,切换到其他正在等待的进程
2、进程在系统资源不足时,要等到资源满足后才可以运行,此时这个进程也会被挂起,CPU让给其他进程
3、进程通过sleep睡眠函数主动挂起,CPU让给其他进程
4、当有优先级更高的进程运行,当前进程会被挂起
5、发生硬件中断,CPU进程会被中断挂起,执行内核中的中断服务程序
进程上下文切换
线程是调度的基本单位,而进程是资源拥有的基本单位。当进程中只有一个线程时,可以认为进程就等于线程。
当进程拥有多个线程时,这些线程会共享相同的虚拟内存和全局变量等资源。
线程主要就是私有数据、栈和寄存器等资源。
线程上下文切换分为两种情况:
1、两个线程属于不同线程,资源不共享,所以等同于进程上下文切换
2、两个线程属于同一个进程,只需要切换私有数据、寄存器等不共享的数据
中断上下文切换
与系统调用不同,中断上下文切换不涉及进程的用户态。所以中断过程打断了一个正处于用户态的进程,也不需要保存和恢复这个进程的虚拟内存、全局变量等用户资源。它只包括内核态中断服务程序执行所必须的状态:CPU寄存器、内核堆栈、硬件中断参数
线程池工作的四种情况:
线程池: 当进行并行的任务作业操作时,线程的建立与销毁的开销是,阻碍性能进步的关键,因此线程池,由此产生。使用多个线程,无限制循环等待队列,进行计算和操作。帮助快速降低和减少性能损耗。
多线程技术主要解决处理器单元内多个线程执行的问题,它可以显著减少处理器单元的闲置时间,增加处理器单元的吞吐能力。
假设一个服务器完成一项任务所需时间为:T1 创建线程时间,T2 在线程中执行任务的时间,T3 销毁线程时间。
如果:T1 + T3 远大于 T2,则可以采用线程池,以提高服务器性能。
线程池的组成 :
(1)首先,根据不同的需求选择线程池,如果需要单线程顺序执行,使用SingleThreadExecutor,如果已知并发压力,使用FixedThreadPool,固定线程数的大小,执行时间小的任务,可以使用CachedThreadPool,创建可缓存的线程池,可以无限扩大线程池,可以灵活回收空闲线程,最多可容纳几万个线程,线程空余60s会被回收,需要后台执行周期任务的,可以使用ScheduledThreadPool,可以延时启动和定时启动线程池,
(2)如何确认线程池的最大线程数目,分CPU密集型和IO密集型,如果是CPU密集型或计算密集型,因为CPU的利用率高,核心线程数可设置为n(核数)+1,如果是IO密集型,CPU利用率不高,可多给几个线程数,来进行工作,核心线程数可设置为2n(核数)
链接: link
lsof -i :80
netstat -tunlp | grep 80
0号进程
0号进程,通常也被称为idle进程,或者也称为swapper进程。
0号进程是linux启动的第一个进程,它的task_struct的comm字段为"swapper",所以也成为swpper进程。当系统中所有的进程起来后,0号进程也就蜕化为idle进程,当一个core上没有任务可运行时就会去运行idle进程。一旦运行idle进程则此core就可以进入低功耗模式了,在ARM上就是WFI。
1号进程
至此1号进程就完美的创建成功了,而且也成功执行了init可执行文件。
2号进程
2号进程,是由1号进程创建的。而且2号进程是所有内核线程父进程。
2号进程就是刚才rest_init中创建的另外一个内核线程。kernel_thread(kthreadd, NULL, CLONE_FS | CLONE_FILES);
当kernel_thread(kthreadd)返回时,2号进程已经创建成功了。而且会回调kthreadd函数
然后就是while循环,设置当前的进程的状态是TASK_INTERRUPTIBLE是可以中断的
判断kthread_create_list链表是不是空,如果是空则就调度出去,让出cpu
如果不是空,则从链表中取出一个,然后调用kthread_create去创建一个内核线程。
所以说所有的内核线程的父进程都是2号进程,也就是kthreadd。
⭐总结:
linux启动的第一个进程是0号进程,是静态创建的
在0号进程启动后会接连创建两个进程,分别是1号进程和2和进程。
1号进程最终会去调用可init可执行文件,init进程最终会去创建所有的应用进程。
2号进程会在内核中负责创建所有的内核线程
所以说0号进程是1号和2号进程的父进程;1号进程是所有用户态进程的父进程;2号进程是所有内核线程的父进程。
模式切换 不等同于 进程上下文切换
当进程调用系统调用或者发生中断时,CPU从用户模式(用户态)切换成内核模式(内核态),此时,无论是系统调用程序还是中断服务程序,都处于当前进程的上下文中,并没有发生进程上下文切换。
当系统调用或中断处理程序返回时,CPU要从内核模式切换回用户模式,此时会执行操作系统的调用程序。如果发现就需队列中有比当前进程更高的优先级的进程,则会发生进程切换:当前进程信息被保存,切换到就绪队列中的那个高优先级进程;否则,直接返回当前进程的用户模式,不会发生上下文切换。
每个进程都拥有两个堆栈:用户空间的堆栈和内核空间堆栈
这里的用户进程和系统进程使用同一个PCB,他们并不是两个实体进程,而是同一个进程的两个侧面。当调用系统调用或发生中断时,CPU切换到内核态,用户进程“变身”系统进程,此时的寄存器上下文保存在系统进程的堆栈上,以便系统调用返回后的恢复。
⭐与用户程序相关联的处理器执行模式(用户模式)和与操作系统相关联的处理器模式(内核模式)之间的切换,
进程切换必须在操作系统的内核模式下进行
⭐模式切换可在不改变运行态进程状态(有一些中断/异常不会引起进程状态转换,不会引起进程切换,只是在处理完成后把控制权交还给被中断进程。)的情况下发生,此时保存上下文并在以后恢复上下文需要的开销很少。但是若当前正运行进程将转换为另一种状态(就绪(如时钟中断)、阻塞(如系统调用)等),则操作系统必须让环境产生实质性的变化。
FIFO
LRU
LFU
链接: LRU\LFU
链表加多层索引的结构,就是跳表
快速排序是对冒泡排序的一种改进
基本思想:
注意:
⭐map和set都是C++的关联容器,其底层实现都是红黑树(RB-Tree )。
template<typename _Tp, typename _Alloc = std::allocator<_Tp> >
class vector : protected _Vector_base<_Tp, _Alloc>
vector其实也是一个模板,是顺序表的模板
函数名称 | 功能说明 |
---|---|
vector() | 无参构造 |
vector(size_type n, const value_type& val = value_type()) | 构造并初始化n个val |
vector (const vector& x) | 拷贝构造 |
vector (InputIterator first, InputIterator last) | 使用迭代器进行初始化构造 |
STL的分配器用于封装STL容器在内存管理上的底层细节。在C++中,其内存配置和释放如下:
⭐所以说,当不需要结果排好序时,最好用unordered_map,插入删除和查询的效率要高于map。
迭代器不是指针,是类模板,表现的像指针。
数组与链表的区别
哈希表
队列是一个特殊的线性表. 它仅允许在表的前面进行删除操作,并在表的后面进行插入操作. 执行插入操作的末端称为队列的尾部,执行删除操作的末端称为队列的首部
队列和栈是描述数据存取方式的概念,队列是先进先出,而堆栈是后进先出;队列和栈都可以使用数组或者链表实现。
链表(Linked list)是一种常见的基本数据结构,是一个线性表,但不以线性顺序存储数据,而是由节点组成,每个节点(节点)都存储数据变量(数据)和指针变量(节点下一个),并且有一个头节点(head)连接到下面的节点,最后一个节点指向null(null). 可以在链接列表类中定义添加,删除,插入,遍历,修改和其他方法,因此通常用于存储数据.
数组与链表是更加偏向数据存储方式的概念,数组在连续的空间中存储数据,随机读取效率高,但是数据添加删除的效率较低; 链表可以在非连续的空间中存储数据,随机访问效率低,数据添加删除效率高。
由于unordered_map内部采用的hashtable的数据结构存储,所以,每个特定的key会通过一些特定的哈希运算映射到一个特定的位置,我们知道,hashtable是可能存在冲突的(多个key通过计算映射到同一个位置),在同一个位置的元素会按顺序链在后面。所以把这个位置称为一个bucket是十分形象的(像桶子一样,可以装多个元素)。
所以unordered_map内部其实是由很多哈希桶组成的,每个哈希桶中可能没有元素,也可能有多个元素。
1、开放定址法——线性探测
线性探测法的地址增量di = 1, 2, … , m-1,其中,i为探测次数。该方法一次探测下一个地址,知道有空的地址后插入,若整个空间都找不到空余的地址,则产生溢出。
线性探测容易产生“聚集”现象。当表中的第i、i+1、i+2的位置上已经存储某些关键字,则下一次哈希地址为i、i+1、i+2、i+3的关键字都将企图填入到i+3的位置上,这种多个哈希地址不同的关键字争夺同一个后继哈希地址的现象称为“聚集”。聚集对查找效率有很大影响。
2、开放地址法——二次探测
3、链地址法
链地址法也成为拉链法。其基本思路是:将所有具有相同哈希地址的而不同关键字的数据元素连接到同一个单链表中。如果选定的哈希表长度为m,则可将哈希表定义为一个有m个头指针组成的指针数组T[0…m-1],凡是哈希地址为i的数据元素,均以节点的形式插入到T[i]为头指针的单链表中。并且新的元素插入到链表的前端,这不仅因为方便,还因为经常发生这样的事实:新近插入的元素最优可能不久又被访问。
HashMap是一个线程不安全的容器,主要体现在容量大于总量负载因子发生扩容时会出现环形链表从而导致死循环.
HashMap会进行resize操作,在resize操作的时候会造成线程不安全。下面将举两个可能出现线程不安全的地方。
1、put的时候导致的多线程数据不一致。
这个问题比较好想象,比如有两个线程A和B,首先A希望插入一个key-value对到HashMap中,首先计算记录所要落到的桶的索引坐标,然后获取到该桶里面的链表头结点,此时线程A的时间片用完了,而此时线程B被调度得以执行,和线程A一样执行,只不过线程B成功将记录插到了桶里面,假设线程A插入的记录计算出来的桶索引和线程B要插入的记录计算出来的桶索引是一样的,那么当线程B成功插入之后,线程A再次被调度运行时,它依然持有过期的链表头但是它对此一无所知,以至于它认为它应该这样做,如此一来就覆盖了线程B插入的记录,这样线程B插入的记录就凭空消失了,造成了数据不一致的行为。
2、另外一个比较明显的线程不安全的问题是HashMap的get操作可能因为resize而引起死循环(cpu100%)
堆是一棵顺序存储的完全二叉树。
其中每个结点的关键字都不大于其孩子结点的关键字,这样的堆称为小根堆。
其中每个结点的关键字都不小于其孩子结点的关键字,这样的堆称为大根堆。
堆排序就是把最大堆堆顶的最大数取出,将剩余的堆继续调整为最大堆,再次将堆顶的最大数取出,这个过程持续到剩余数只有一个时结束。
在堆中定义以下几种操作:
最大堆调整(Max-Heapify):将堆的末端子节点作调整,使得子节点永远小于父节点
创建最大堆(Build-Max-Heap):将堆所有数据重新排序,使其成为最大堆
堆排序(Heap-Sort):移除位在第一个数据的根节点,并做最大堆调整的递归运算
首先考虑,如何在不排序全部数组的情况下,只将第一个元素的最终序列位置找到呢?方法就是,将比这个元素大的数放到右半区,将比这个元素小的数放到左半区,那么这个元素插进这两半区的中间就行了呀。而快速排序就是将第一个元素排好之后,再将左右半区各看成是一个数组,再次排出两个半区第一个元素的最终序列,那么一直递归下去,直到每一个元素都排序好。
以下是具体操作流程:
快速排序最坏情况以及最坏时间复杂度:O(n^2)
最坏情况为,每一轮分不成两组,每一轮都只能把基准值索引放到第一个或者最后一个,因此一共需要n轮。
因此,最坏时间复杂度为O(n^2)
正序排列的越有序越坏,逆序排列的越有序越坏
举个例子,比如要从小到大排列,1 2 3 4 5 6 7 8 9 10
就排{1,2,3,4,5,6,7,8,9,10},
或者{10,9,8,7,6,5,4,3,2,1},都是最坏情况
因为这几种情况每一轮只能排出一个最大的或者最小的。
申请空间,使其大小为两个已经排序序列之和,该空间用来存放合并后的序列
设定两个指针,最初位置分别为两个已经排序序列的起始位置
比较两个指针所指向的元素,选择相对小的元素放入到合并空间,并移动指针到下一位置
重复步骤3直到某一指针到达序列尾
将另一序列剩下的所有元素直接复制到合并序列尾
GDB 调试常用命令:
p 打印
15) set scheduler-locking[on|off|step]
16)attach pid 添加一个进程调试
钩子实际上是一个处理消息的程序段,通过系统调用,把它挂入系统。每当特定的消息发出,在没有到达目的窗口前,钩子程序就先捕获该消息,亦即钩子函数先得到控制权。这时钩子函数即可以加工处理(改变)该消息,也可以不作处理而继续传递该消息,还可以强制结束消息的传递。
一个Hook都有一个与之相关联的指针列表,称之为钩子链表,由系统来维护。这个列表的指针指向指定的,应用程序定义的,被Hook子程调用的回调函数,也就是该钩子的各个处理子程。当与指定的Hook类型关联的消息发生时,系统就把这个消息传递到Hook子程。一些Hook子程可以只监视消息,或者修改消息,或者停止消息的前进,避免这些消息传递到下一个Hook子程或者目的窗口。最近安装的钩子放在链的开始,而最早安装的钩子放在最后,也就是后加入的先获得控制权。
把一段可执行的代码像参数传递那样传给其他代码,而这段代码会在某个时刻被调用执行,这就叫做回调。如果代码立即被执行就称为同步回调,如果在之后晚点的某个时间再执行,则称之为异步回调。回调函数其实就是一个通过函数指针调用的函数!
在回调中,主程序把回调函数像参数一样传入库函数。这样一来,只要我们改变传进库函数的参数,就可以实现不同的功能,这样有没有觉得很灵活?并且丝毫不需要修改库函数的实现,这就是解耦。再仔细看看,主函数和回调函数是在同一层的,而库函数在另外一层,想一想,如果库函数对我们不可见,我们修改不了库函数的实现,也就是说不能通过修改库函数让库函数调用普通函数那样实现,那我们就只能通过传入不同的回调函数了,这也就是在日常工作中常见的情况。
#include
#include // 包含Library Function所在读得Software library库的头文件
int Callback() // Callback Function
{
// TODO
return 0;
}
int main() // Main program
{
// TODO
Library(Callback);
// TODO
return 0;
}