鄙人的2020秋招目前已经基本结束,最终拿了六家公司的offer,在综合比较之后目前已经有了明确的偏向,那就是去某公司转行Java,至于原因有很多层,我即使在做了这个决定半年后还是不敢肯定这个决定是否真的正确,或许时间才能给我答案吧。

文章很长,在这里枚举出对应的十个主要知识点。同时由于md编译器的排版实在比不上word,编辑的难受。

一、C++方面的问题
二、数据结构和算法
三、计算网络
四、操作系统
五、数据库
六、项目相关
七、游戏相关问题
八、智力与综合题
九、设计模式
十、开放问题

一、C++方面的问题:

第I部分 C++基础

C是面向过程的语言,C++是面向对象的语言

C++中new和delete是对内存分配的运算符,取代了C中的malloc和free

C++中有引用的概念,C中没有

C++引入了类的概念,C中没有

C++有函数重载,C中不能

C变量只能在函数的开头处声明和定义,而C++随时定义随时使用

1. 关键字相关

const的作用,宏定义与const的区别(const为变量,宏定义为展开)

const比宏的优势:

(1) 关键字const的作用是为给读你代码的人传达非常有用的信息,实际上,声明一个参数为常量是为了告诉了用户这个参数的应用目的。

(2) 通过给优化器一些附加的信息,使用关键字const能产生更紧凑的代码。

(3) 合理地使用关键字const可以使编译器很自然地保护那些不希望被改变的参数,防止其被无意的代码修改。简而言之,这样可以减少bug的出现。

const关键字至少有下列n个作用:

(1)欲阻止一个变量被改变,可以使用const关键字。在定义该const变量时,通常需要对它进行初始化,因为以后就没有机会再去改变它了;

(2)对指针来说,可以指定指针本身为const,也可以指定指针所指的数据为const,或二者同时指定为const;

(3)在一个函数声明中,const可以修饰形参,表明它是一个输入参数,在函数内部不能改变其值;(也可以修饰返回值,表示返回的为一个const值)

(4)对于类的成员函数,若指定其为const类型,则表明其是一个常函数,不能修改类的成员变量;

(5)对于类的成员函数,有时候必须指定其返回值为const类型,以使得其返回值不为“左值”。

顶层const与底层const:

顶层const表示对象本身为常量,如const int s = 12, int* const a = 12,底层const表示所指向的对象为常量(多用于指针),如const int* a = 12。

声明引用const的都是底层const,在进行拷贝时两边的对象都必须具有相同的底层const对象

const重载与非const重载

我们知道,如果函数名相同,在相同的作用域内,其参数类型、参数个数,参数顺序不同等能构成函数重载。有趣的是如果同时在类中,对于函数名相同参数列表也相同的成员函数的const函数和非const函数能够构成重载。

它们被调用的时机为:如果定义的对象是常量对象,则调用的是const成员函数,如果定义的对象是非常量对象,则调用重载的非const成员函数。

其核心原因在于带const的成员函数和不带const的成员函数的this指针是不同的:const的成员函数的this指针是:const A*类型的;不带const的成员函数的this指针是:A*类型的;

inline关键字是做什么用的?inline关键字在什么情况下会展开失败?

inline类似于宏替换,使用函数体替换调用处的函数名,省去了调用函数的开销,增快了代码的执行效率。但是又不是宏替换,inline函数是真正的函数,编译器会考虑语义。解决一些频繁调用的小函数对栈内存重复开辟所带来的消耗。

函数体内代码长度过大,包含复杂的结构控制语句(while,switch),包含内联函数本身,含有递归均会导致展开失败。所以inline不适用于本身函数量大的情况;同时inline也不能用于析构与构造函数(因为其可能使用了父类函数);

inline与define的区别:

1)宏不是函数,只是用起来像函数,只要定义了就会实现,而inline本身是函数,并且编译器会检查是否需要inline;

2)宏在预编译时进行替换,inline在编译时展开;

3)宏是直接的字符替换,而inline自带了参数检查。

static用法

(1)隐藏:(static函数,static变量均可)当同时编译多个文件时,所有未加static前缀的全局变量和函数都具有全局可见性。加了就是隐藏性

(2)用作变量或成员变量:因为static变量存放在静态存储区,所以它具备持久性和默认值0;

(3)用作静态成员函数:类的静态成员函数是属于整个类而非类的对象,所以它没有this指针,这就导致了它仅能访问类的静态成员变量和静态成员函数,并可以被外部自由访问。

struct和class的区别

(1)最本质的区别是默认的访问控制:默认的继承访问权限struct是public的,class是private的。

(2)少用的区别:class这个关键字还用于定义模板参数,就像typename(如template<typename
T, class A>)。但关键字struct不用于定义模板参数。

volatile关键字

三个特性:易变性、不可优化性与顺序性。

(1)易变性:该变量在寄存器中被使用后立即写回内存,下一条语句不会直接使用上一条语句对应的volatile变量的寄存器内容,而是重新从内存中读取。

(2)不可优化性:volatile告诉编译器,不要对我这个变量进行各种激进的优化,甚至将变量直接消除,保证程序员写在代码中的指令,一定会被执行。

(3)顺序性:运行时能保证Volatile变量间的顺序性,编译器不会进行乱序优化。但是对于Volatile变量与非Volatile变量的顺序,编译器不保证顺序,可能会进行乱序优化。(如果要彻底的阻止CPU的乱序优化执行能力,需要调用CPU中的barrier指令,这条指令的作用是阻止CPU将barrier指令之前的指令交换到barrier指令之后)

一个参数可以即是const又是volatile的吗?可以,一个例子是只读状态寄存器,是volatile是因为它可能被意想不到的被改变,是const告诉程序不应该试图去修改他

extern关键字

基本解释:extern可以置于变量或者函数前,以标示变量或者函数的定义在别的文件中,提示编译器遇到此变量和函数时在其他模块中寻找其定义。此外extern也可用来进行链接指定。

也就是说extern有两个作用:

(1)第一个,当它与"C"一起连用时,如: extern “C” void fun(int a, int b);则告诉编译器在编译fun这个函数名时按着C的规则去翻译相应的函数名而不是C++的,C++的规则在翻译这个函数名时会把fun这个名字变得面目全非,可能是fun@aBc_int_int#%$也可能是别的,这要看编译器的"脾气"了(不同的编译器采用的方法不一样),为什么这么做呢,因为C++支持函数的重载啊,在这里不去过多的论述这个问题,如果你有兴趣可以去网上搜索,相信你可以得到满意的解释!

(2)当extern不与"C"在一起修饰变量或函数时,如在头文件中: extern int g_Int;
它的作用就是声明函数或全局变量的作用范围的关键字,其声明的函数和变量可以在本模块活其他模块中使用,记住它是一个声明不是定义!也就是说B模块(编译单元)要是引用模块(编译单元)A中定义的全局变量或函数时,它只要包含A模块的头文件即可,在编译阶段,模块B虽然找不到该函数或变量,但它不会报错,它会在连接时从模块A生成的目标代码中找到此函数。

mutable关键字

用此关键字附加修饰类的非静态和非常量数据成员,使该成员一直处于可变状态,使得其即使在后置const的函数中仍然可以被更改

return返回值

返回一个值的方式和初始化一个变量或形参的方式完全一样:返回的值用于初始化调用点的一个临时量,该临时量就是函数调用的结果。

同时,返回局部对象的引用或指针是错误的,因函数完成后,这些局部对象所占用的存储空间也随之释放掉,而导致前面传出的指针和引用将不再指向有效的内存区域。(当然,可以传出const或static等类型的数据的指针和引用)

全局变量名字与局部变量名字相同的情况

当你定义了一个大全局变量后,全局都可以使用,但是要是你在子函数中(不是main函数)也定义了一个相同的变量,无论你是否用static,子函数就按子函数定义的来搞(即全局变量被局部变量覆盖了),子函数结束,并不会影响你的大全局变量。

std::move

std::move方法将左值转换为右值,优先触发移动构造函数。

void *memset(void *s, int ch, size_t n);

函数解释:将s中当前位置后面的n个字节(typedef unsigned int
size_t)用ch替换并返回s。用于给一整块内存填充值,主要用于初始化与数据清除。

assert用法

#include <assert.h> void assert( int expression );

assert的作用是先计算表达式 expression
,如果其值为假(即为0),那么它先向stderr打印一条出错信息,然后通过调用 abort
来终止程序运行。

sizeof与strlen

(1)sizeof对char类型计数包括尾’\0’字符,strlen对char类型计数不包括尾。同时sizeof对于字符指针和字符数组的判断方式是不同的,指针是指针大小,字符数组为数组包含尾字符的长度;

(2)size_t strlen(char const*
str)可见strlen就是判断字符串长度的,无论针对字符指针还是字符数组(字符数组名会退化成字符指针)

数字类型默认为unsigned int

数字类的常量默认符号位unsigned
int(没有符号位,所以最大为2^32位)注意对于0x80000000这样的为int型负数最小值的值,需要将其强制转换为int型

2. 指针与引用相关

值传递与引用传递区别

1)值传递(passl-by-value)过程中,被调函数的形式参数作为被调函数的局部变量处理,即在堆栈中开辟了内存空间以存放由主调函数放进来的实参的值,从而成为了实参的一个副本。值传递的特点是被调函数对形式参数的任何操作都是作为局部变量进行,不会影响主调函数的实参变量的值。

2)引用传递(pass-by-reference)过程中,被调函数的形式参数虽然也作为局部变量在堆栈中开辟了内存空间,但是这时存放的是由主调函数放进来的实参变量的地址。被调函数对形参的任何操作都被处理成间接寻址,即通过堆栈中存放的地址访问主调函数中的实参变量。正因为如此,被调函数对形参做的任何操作都影响了主调函数中的实参变量。

指针与引用的区别

指针:对于一个类型T,T* 就是指向T的指针类型,也即一个T* 类型的变量能够保存一个T对象的地址,而类型T是可以加一些限定词的,如const、volatile(当对象的值可能在程序的控制或检测之外被改变时,应该将该对象声明为volatile,告诉编译器不要对这样的对象优化)等等。

引用:引用是一个对象的别名,主要用于函数参数和返回值类型,符号X&表示X类型的引用。

区别本质: 指针指向一块内存,它的内容是所指内存的地址;而引用则是某块内存的别名,引用不改变指向。

1.引用不可以为空,但指针可以为空。定义一个引用的时候,必须初始化。因此使用指针之前必须做判空操作,而引用就不必。

2.引用不可以改变指向,对一个对象"至死不渝";但是指针可以改变指向,而指向其它对象。

3.引用的大小是所指向的变量的大小,因为引用只是一个别名而已;指针是指针本身的大小,4个字节(32位)。

4.const int* p-> 指向常量的指针,int * const p->本身是常量的指针.后者需要在定义的时候初始化。对于引用来讲int const & p=i;和const int &p=i;没什么区别,都是指指向的对象是常量。

5.引用和指针的++自增运算符意义不同,指针的++表示的地址的变化,一般是向下4个字节的大小(一个指针的大小),引用的++就是对应元素的++操作。

6.值传递和引用传递(值传递为数据复制,引用传递为起别名)

指针间转换需要注意的问题?

1.普通指针的转换,类型!!!

2.普通指针转换为共享指针时只能转换一次

3.当普通指针作为对象成员时,最好只赋值给一个对象变量,否则会出现,一个对象删除时,将指针删除,其他保存此指针的对象再删除时,无法删除一个已经删除的指针,而智能指针就可以多次赋值。

char*型指针与char[]型首地址指针:

C++特性,在cout char*类型(包括char[]的首地址)时,会cout其所指向的对象,而不是指针所存地址。如果需要得到地址,将其类型转换。而char[]的首地址已经变成了对应数组的地址,不会与字符常量的地址相同。

3. 函数相关

函数指针的定义及其使用方式

void(*pf)() = test_fun1;

(*pf)();

C++初始化列表使用
还有一些C++库函数的实现(比如strcpy之类的)

第II部分 C++标准库

1. STL相关

各种STL的实现方式(STL都不是线程安全的,自己加锁操作)

1)vector:内部实现是数组(三个成员,指向数组首部位置的指针,size数据量大小,capacity容量大小),一段连续的内存。

2)list:内部实现是双链表(prev与succ)

3)deque:内部分段连续存储实现(数据被表示为一个分段数组,容器中的元素分段存放在一个个大小固定的数组中,此外容器还需要维护一个存放这些数组首地址的索引(一个指针数组))

4)string:连续的内存(只有一个 char* 成员变量)

5)set,map:红黑树(平衡二叉树的一种)

6)unordered_map与unordered_set用哈希表(数组 + 链表)来实现。

7)stack:用deque 或 list来实现

8)queue:STL用deque实现,用栈也可以实现;priority_queue为vector

STL容器的sizeof

vector<type> temp;
sizeof(temp)的大小,跟容器里面存放多少数据无关,它是在编译期确定的一个值,仅跟具体的编译器有关。

个人理解是其中的四个成员变量:

allocator<T>* alloc; pointer _Myfirst; pointer _Mylast; pointer _Myend;

大小:size =_Mylast - _Myfirst;

容量:capacity =_Myend - _Myfirst;

每个成员都是指针,都是4个字节,因此共16字节。

vector和list的使用场景区别

1 如果你需要高效的随即存取,而不在乎插入和删除的效率,使用vector

2 如果你需要大量的插入和删除,而不关心随即存取,则应使用list

3 如果你需要随即存取,而且关心两端数据的插入和删除,则应使用deque

4 要用vector,除非你有大量、频繁、反复的插入删除操作,这时候可以用list,然后准备好数据之后,把它转为vector,用于以后的只读访问。

5 list的问题在于:如果您访问第一个项目,那么就像第一个缓存未命中一样,就像向量一样。但事情就是乱七八糟。几乎可以保证下一个元素不在缓存中。不仅如此,CPU不会知道您希望在“下一个”指针中使用该地址,因此它甚至不会开始加载该地址。这意味着每次推进迭代器时,你都会破坏缓存,停止可能正在为其他线程服务的内存总线,并且它只适用于一小块内存和一个指针(通常)。每个迭代器++都会发生这种情况。这是一个重大打击。

有序vector和list二分查找的时间复杂度分别是多少?

vector的二分相当于数组的二分,时间复杂度是O(logn),list没办法二分,只能每次从头到尾找,时间复杂度为O(n)。

vector自动扩容是按什么大小进行的?

缺省的情况下vector的扩展机制是按2倍大小进行扩展的。在整个大小扩展的过程中,主要的步骤是:a.为需要的新容量分配足够的内存;b.将元素从原来的内存拷贝到新内存中去;c.销毁原来内存中的元素;d.归还原来的内存。

为什么在vector扩容时,如果内存存储对象是类的话,为什么会调用移动构造函数:某些对象被vector等存储时,当其遇到扩容或者其他什么情况时,需要对vector内的数据进行旧到新的赋值,此时的旧内存的数据读取是通过迭代器指针的解引用所得,其并非是原始内存的左值引用,此时有代码:

alloc.construct(dest++, std::move(*elem++));

所以可见,是对(*elem)这个左值引用转右值引用赋值时调用的移动构造函数,有多少个调多少次。

迭代器失效(list的迭代器不能用+1,能用++)

注意!已经失效的迭代器不能进行++操作,所以程序会中断。

(1)在容器中添加元素后

如果容器是vector或string,且存储空间被重新分配,则指向容器的迭代器、指针和引用都会失效。如果存储空间为重新分配,指向插入位置之前的元素迭代器、指针和引用仍有效,但指向插入位置之后元素的迭代器、指针和引用均会失效;

对于deque,插入到首尾位置之外的任何位置都会导致迭代器、指针和引用失效。如果在首尾位置添加元素,迭代器会失效,但指向存在的元素的引用和指针不会失效

对于list和forward_list,指向容器的迭代器(包括首前和尾后迭代器)、指针和引用仍有效;

(2)在容器中删除元素后

对于list和forward_list,指向容器其他位置的迭代器(包括首前和尾后迭代器)、指针和引用仍然有效(它使用了不连续分配的内存,并且它的erase方法也会返回下一个有效的iterator,所以其使用迭代器前移或者使用erase返回的迭代器均可);

对于deque,如果在首尾之外的任何位置删除位置,那么指向被删除元素外其他元素的迭代器、引用或指针也会失效。如果是删除deque的首元素或尾元素,则尾后迭代器也会失效,但其他迭代器、引用和指针不受影响;

对于vector和string,指向被删除元素之前元素的迭代器、引用和指针仍有效,之后的全部失效。注意:当我们删除元素时,尾后迭代器总是会失效。(删除当前的iterator会使后面所有元素的iterator都失效。这是因为vetor,deque使用了连续分配的内存,删除一个元素导致后面所有的元素会向前移动一个位置。还好erase方法可以返回下一个有效的iterator,即后面的迭代器全部前移后的位置)

对于关联容器(如map, set,
multimap,multiset),删除当前的iterator,仅仅会使当前的iterator失效,只要在erase时,递增当前iterator即可。这是因为map之类的容器,使用了红黑树来实现,插入、删除一个结点不会对其他结点造成影响。

2. 关联容器相关

问了STL中map是利用何种结构和方法实现的。

红黑树特性:

(1)树根始终为黑色;

(2)外部节点始终为黑色;

(3)其余节点若为红色,则其孩子节点必为黑色(红节点之父必为黑色)

(4)从任一外部节点到根节点的沿途,黑节点的数目相等。

3. 智能指针相关

智能指针(实现一个智能指针,要求写出成员和构造函数析构函数)

智能指针注意shared_ptr、unique_ptr与weak_ptr,包括引用计数的缺点(不能解决循环引用问题,需要使用weak_ptr来实现),unique_ptr与auto_ptr的最大区别在其在离开当前作用域后会自动析构该作用域元素

C++智能指针有哪些?auto_ptr和share_ptr有什么区别?他们有什么作用?

STL一共给我们提供了四种智能指针:auto_ptr , unique_ptr , shared_ptr和weak_ptr

auto_ptr的初衷是用来实现智能指针的,实现内存的自动回收。那么如何实现智能的呢?智能指针最基本的概念是引用计数,也就是智能指针内部有一个计数器,记录了当前内存资源到底有多少指针在引用(可以引用这个资源),当新增加一个可以访问这个资源的引用时,计数器会加1,反之会减去1,当计数器为0时,智能指针会自动释放它所管理的资源。手动申请,自动释放,就是智能的体现。

STL容器可以放入智能指针吗?

auto_ptr不可以,其余的可以

第III部分 类设计者的工具(类与构造函数相关)

1. 类与sizeof相关

一个空的class类里有什么

1)构造函数(三五法则的五个东西)

2)拷贝构造函数,拷贝赋值运算符,移动构造函数,移动赋值运算符

3)析构函数

4)赋值运算符重载

5)取地址操作符重载

6)被const修饰的取地址操作符重载

定义一个class,编译器的内存分配

1)类的大小为类的非静态成员数据的类型大小之和,也 就是说静态成员数据不作考虑。

2)普通成员函数与sizeof无关。

3)虚函数由于要维护在虚函数表,所以要占据一个指针大小,也就是4字节。

4)类的总大小也遵守类似class字节对齐的,调整规则。

sizeof一个空类是多大?为什么?编译器为什么这么做?如果添加一个构造函数和析构函数呢?

1个字节,任何一个实例在内存中都占有一定的空间,也就有一个独一无二的地址,为了达到这个目的,编译器往往会给一个空类隐含的加一个字节,这样空类在实例化后在内存得到了独一无二的地址

那是被编译器插进去的一个char
,使得这个class的不同实体(object)在内存中配置独一无二的地址。也就是说这个char是用来标识类的不同对象的。

调用构造函数和析构函数只需要知道函数的地址即可,而这些函数的地址只与类型相关,而与类型的实例无关,编译器也不会因为这两个函数而在实例内添加任何额外信息

struct S{char a; int b; static long c; }请问sizeof(S)是多少?为什么与好处?

涉及到内存对齐机制。为什么要内存对齐?

(1)平台原因(移植原因): 不是所有的硬件平台都能访问任意地址上的任意数据的;某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常。

(2)性能原因: 数据结构(尤其是栈)应该尽可能地在自然边界上对齐。原因在于,为了访问未对齐的内存,处理器需要作两次内存访问;而对齐的内存访问仅需要一次访问。

静态数据成员被编译器放在程序的一个.data与.bss段中,它是类的一个数据成员.但是它不影响类的大小,不管这个类实际产生了多少实例,还是派生了多少新的类,静态成员数据在类中永远只有一个实体存在。

而类的非静态数据成员只有被实例化的时候,他们才存在.但是类的静态数据成员一旦被声明,无论类是否被实例化,它都已存在.可以这么说,类的静态数据成员是一种特殊的全局变量.

所以该类的size为:32位系统上,是8个字节,字节对齐,方便寻址操作(当CPU试图读取的数值没有正确的对齐时,CPU可以执行两种操作之一:产生一个异常条件;执行多次对齐的内存访问,以便读取完整的未对齐数据,若多次执行内存访问,应用程序的运行速度就会慢)

内存对齐原则

(1)数据成员对齐规则:结构(struct)(或联合(union))的数据成员,第一个数据成员放在offset为0的地方,以后每个数据成员存储的起始位置要从该成员大小或者成员的子成员大小(只要该成员有子成员,比如说是数组,结构体等)的整数倍开始(在32位上,4字节及以上没什么不同,但是64位的话就有不同了)(比如int在32位机为4字节,则要从4的整数倍地址开始存储)。

(2)个人理解的原则:保证下一个数据不会被4B(32位)或8B(64位)的最小页表大小所分割(如果本身长度已经大于了4B/8B,保证均分)

(3)结构体作为成员:如果一个结构里有某些结构体成员,则结构体成员要从其内部最大元素大小的整数倍地址开始存储.(struct a里存有struct b,b里有char,int ,double等元素,那b应该从8的整数倍开始存储.)

(4)收尾工作:结构体的总大小,也就是sizeof的结果,必须是其内部最大成员的整数倍。不足的要补齐。

2. 构造函数相关

构造函数能不能是虚函数?

(1)答案:不能,并且在编译器上写时后都会报错。

(2)原因:这是一个来自2002就很火的问题,结合《程序员面试宝典》与知乎用户左轻侯的回答来说,就是:虚函数采用一种虚调用的方法,这是一种可以在只有部分信息的情况下工作的机制,特别允许我们调用一个只知道接口而不知道其准确对象类型的函数。但是如果要调用构造函数则表示要去创建一个对象,在创建时编译器势必要知道对象的准确类型,如果将构造函数设为了虚函数,那么编译器怎么知道你想构建是继承树上的哪种类型呢?所以这在逻辑上是一个悖论,也就是C++语言规范直接规定构造函数时不能为虚函数的原因。

从代码的角度来分析,比如定义了一个类A和继承他的类B,类A使用虚构造函数。如果在调用A* a = new B这样的语句时,会先调用基类A的构造函数再调用B的,这时对于类A来说,其构造函数为虚函数,需要去调用虚函数指针,但是这时候的虚函数指针还只是被声明了但还未定义(在编译时期生成,在构造函数中被定义,其中虚函数表地址位于类的内存表的表头,物理位置随着编译器的不同而不同,其中VS存在常量区,Linux存在数据段的rodata区),也就是说类A的虚函数指针还根本不知道自己到底指向哪个虚表和哪个函数,那么也就无法执行这个虚函数了。

析构函数能不能是虚函数?

(1)答案:能,而且某些情况必须,不过也不要没事就加,编译器并不会提示你。

(2)能的原因:析构函数之所以有时需要变成虚函数,原因在于:在使用派生类B来实例化基类A型的指针时(A* a = new B),倘若基类A的析构函数不为虚函数,则其无法执行派生类B的析构函数,会导致可能在B的构造函数中new的对象没有被释放掉造成内存泄露。倘若将A的析构函数变为虚函数后,虽然对象类型为A,但是在B的实例化过程中a的虚函数指针已经指向了B中复写的析构函数,也就是说,此时a的析构函数实际为~B()函数,之后根据虚函数的调用原则,会在调用了派生类的析构函数后再调用基类的析构函数。

(3)不要没事加的原因:如果这个类不是基类就是一个普通类,那就没必要变为虚函数,因为虚函数指针虽然隐藏,但还是会占据一个正常指针(如4B)的大小,造成没必要的空间占据。

构造函数和析构函数中能不能使用虚函数?

(1)答案:能,但是它往往事与愿违(一直执行基类的虚函数实例),甚至造成崩溃,编译器也不会报错(be careful. It may not do what you expect),所以最好不要在构造函数与析构函数中使用虚函数(原因参考自刀心水的知乎文章)。

(2)原因:在执行(A* a = new B)时,派生类B的构造函数会被触发,但是在执行派生类构造函数之前,基类A的构造函数会先被触发以构造新实例基类A那部分的成员变量。当执行到基类A构造函数的fun()时,即使此时创建的实例属于派生类B,执行的虚函数依然是基类A里面定义的版本。原因有二:

① 理论上讲,派生类的虚函数可能会用到派生类里多出来的成员变量,而这些成员变量在最开始执行基类的构造函数时还没有被分配资源初始化,为了杜绝这种危险操作所以C++选择执行基类A的虚函数fun。

② 更底层的原因在于,在第一阶段执行基类A构造函数的时候,a在运行时(runtime)里的类型信息本来就是被标记为基类的类型A的。只有到第二阶段执行派生类B的构造函数时,a实例才成为B类型。

基于相同的道理,执行析构函数的时候,一旦最开始触发的派生类B析构函数开始运行,派生类B部分的成员变量就被标记为未定义(undefined);而第二阶段进入基类A的析构函数,a实例的类型就被标记为基类,此时在虚函数和dynamic_cast等运行时相关的操作看来这就是一个A类型的实例(B中的实例化fun函数也被析构了),所以执行的也就是A类中的fun函数。

(3)相关代码与结果:这么写的本意是想在A类的析构与构造函数中调用B中实例的函数fun,但是实际上一直都是使用的基类的虚函数实例。

构造函数可以调用虚函数吗?语法上通过吗?语义上可以通过吗?

语法可以通过,但是语义不对。

总结来说:基类部分在派生类部分之前被构造,当基类构造函数执行时派生类中的数据成员还没被初始化。如果基类构造函数中的虚函数调用被解析成调用派生类的虚函数,而派生类的虚函数中又访问到未初始化的派生类数据,将导致程序出现一些未定义行为和bug,因此c++不让你走这条路。(在构造函数前面写virtual时编译器就会报错了)

析构函数可以抛出异常吗?为什么不能抛出异常?除了资源泄露,还有其他需考虑的因素吗?

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

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

拷贝构造函数作用及用途?什么时候需要自定义拷贝构造函数?
  1. 一个对象以值传递的方式传入函数体;
  2. 一个对象以值传递的方式从函数返回;
  3. 一个对象需要通过另外一个对象进行初始化;

自定义的原因往往是为了实现指针的深拷贝

拷贝赋值运算符与移动赋值运算符的四大要素:

① 将返回值设为该类型的引用(用于连续拷贝赋值)

②(只针对拷贝赋值运算符)将传入的形参设为常量引用(避免实参到形参执行一次拷贝构造函数)

③ 确定是否在开始赋值/移动前释放了自身本来有的内存(防止内存泄露)

④ 判断传入的参数是否为自身this,是的话则直接返回自身,否则会导致自身被析构没有元素了

拷贝构造函数,拷贝赋值运算符,移动构造函数,移动赋值运算符

拷贝构造函数为深拷贝,得到左值引用后,会先new出一块新内存,再将左值引用的数据拷贝到新内存

移动构造函数,得到右值引用后,会直接借用右值引用中的各种变量与数据,然后将右值引用中的数据都置为nullptr或初始值

拷贝赋值运算符,先删自己的内存段,然后new出新的内存段,再拷贝数据

移动赋值运算符,先删自己的内存段,然后直接用右值引用中的所有数据,然后给右值引用置空

C++析构和构造的顺序,为什么析构函数最好是虚函数

1)构造函数顺序:先基类、再数据成员中是类对象的构造函数、最后派生类构造函数的函数体

2)析构函数顺序:与构造函数相反

3)析构函数最好是虚函数:若派生类有一个动态内存分配的数据成员,而又将基类的指针指向派生类对象,同时基类的析构函数又不是虚函数的话,编译器就实施静态绑定,释放基类指针所指对象的空间时候只执行基类的析构函数,不执行派生类的析构函数,那派生类动态分配的数据成员所申请的空间就不能被释放,这就造成了内存泄漏。(如A* a = new B,使用子类的构造函数来构造积累的对象指针,如果析构函数不为虚,则如果在B的构造函数中new了B自己的成员,在执行a的析构时并不会释放这些成员,造成内存泄露)

为什么在vector<T>扩容时会调用T的移动构造函数?

某些对象T被vector等存储时,当其遇到扩容或者其他什么情况时,需要对vector内的数据进行旧到新的赋值,此时的旧内存的数据读取是通过迭代器指针的解引用所得,其并非是原始内存的左值引用,此时有代码:

alloc.construct(dest++, std::move(*elem++));

所以可见,是对(*elem)这个左值引用转右值引用赋值时调用的移动构造函数,有多少个调多少次。换句话说,如果T类型的内部有指针变量,那么这个指针所指向的内存在调用移动构造函数后是不变的。

为什么移动构造函数与移动赋值运算符要加noexpect
(1)避免其做额外的工作

由于移动操作属于“窃取”资源,它通常不分配任何资源。因此,移动操作通常不会抛出任何异常。当编写一个不抛出异常的移动操作时,我们应该将这个事情告诉标准库。我将看到,除非标准库知道我们的移动构造函数不会抛出异常,否则它会认为在移动我们的类对象时可能会抛出异常,并且为了处理这种可能性而做一些额外的工作

(2)让编译器在进行vector的扩容过程等类似情况下,执行移动而非拷贝

一方面,如果在移动过程中只移动了部分元素而不是全部元素,抛出一个异常,会产生问题,因为此时旧空间中的移动源元素已经被改变了,而新空间中未构造的元素可能尚不存在。在此情况下,vector无法满足自身保持不变的要求。

另一方面,如果是使用的拷贝构造函数,在拷贝过程中发生了异常,因为使用此函数其旧空间并不会改变,vector可以选择释放新分配的内存(此时未成功构造完成),返回原有的内存。

所以为了避免这种潜在问题,必须显示地告诉编译器,这个移动构造函数并不会抛出异常,否则在内存分配的过程中,他就必须使用拷贝构造函数而不是移动构造函数。说穿了,就是为了希望在vector重新分配内存的这类情况下,让我们自定义的对象进行移动而不是拷贝,需要显示地告诉标准库我们的移动构造函数可以安全使用

第III部分 类设计者的工具(面向对象与模板相关,封装、继承、多态)

1. 封装相关

类有哪几种权限,分别描述

public,protected,private

2. 继承相关

父类的那些函数不会被子类继承?

C++中,并不是所有的成员函数都能被子类继承,有三类成员函数不能被子类继承,分别是:构造函数(包括拷贝构造)、析构函数、赋值运算符重载函数。

  1. 菱形继承的虚函数的开销

  2. 继承有哪几种继承方式?

基类成员权限 public继承 private继承 protected继承

Public成员 public private protected

Private成员 private private private

Protected成员 protected private protected

继承默认是私有继承,私有继承后,基类所有成员在派生类中为private成员。私有基类的public成员和protected成员在私有派生类中的访问属性相当于派生类中的私有成员,即派生类的成员函数能访问它们,而在派生类外不能访问它们。私有基类的私有成员在派生类中称为不可访问的成员,只有基类的成员函数可以引用它们(对于子类来说,父类的private只能看不能用)。

3. 多态相关

C++多态有哪几种?

静态多态(函数重载和运算符重载),是在编译的时候,就确定调用函数的类型;

动态多态(虚函数实现),在运行的时候,才能确定调用的是哪个函数,动态绑定。运行基类指针指向派生类的对象,并调用派生类的函数。

a.应用形式上:静多态是发散式的,让相同的实现代码应用于不同的场合。动多态是收敛式的,让不同的实现代码应用于相同的场合。(重载)

b.思维方式上:静多态是泛型式编程风格,它看重的是算法的普适性;动多态是对象式编程风格,它看重的是接口和实现的分离度。

C++是怎么实现动态多态的?

虚函数表和指向虚函数表的vptr指针。这个需要注意vptr指针的分布初始化问题,是在构造函数的最前面指向,初始化列表和函数体之前完成的。

虚函数的作用以及实现原理(C++里面的虚函数的原理和实现)

虚函数是多态的基本实现形式,实现原理为虚函数指针(在执行时创建,存于类的申请空间中)与虚函数表(在编译时创建,存在.rodata常量存储区)

1)虚函数:声明成员函数为虚函数以后,就可以实现动态绑定,也就是基类指针可以指向派生类对象,实现相同函数名的派生类的特定行为

2)虚函数表:就是用来运行时查询,帮助系统将某一函数名绑定到虚成员函数表中特定入口地址。虚函数表是一个指针数组,其元素是虚函数的指针,每个元素对应一个虚函数的函数指针。需要指出的是,普通的函数即非虚函数,其调用并不需要经过虚表,所以虚表的元素并不包括普通函数的函数指针。

虚表内的条目,即虚函数指针的赋值发生在编译器的编译阶段,也就是说在代码的编译阶段,虚表就可以构造出来了。

设置虚函数须注意:

1:只有类的成员函数才能说明为虚函数;

2:静态成员函数不能是虚函数;

3:内联函数不能为虚函数;

4:构造函数不能是虚函数;

5:析构函数可以是虚函数,而且通常声明为虚函数。

overload以及overwrite的区别

1)覆写override:派生类函数覆盖基类函数, 基类函数必须有virtual
关键字,一种显示的表现当前函数被重载的关键字,加不加无影响。

2)重载overload:可以将语义、功能相似的几个函数用同一个名字表示,但参数不同(包括类型、顺序不同),即函数重载,重载不属于多态,属于面向过程的语言。

3)重写overwrite(隐藏):派生类的函数屏蔽了与其同名的基类函数;隐藏函数和被隐藏函数参数列表可以相同,也可以不同,但函数名一定相同同;当参数不同时,无论基类中的函数是否被virtual修饰,基类函数都是被隐藏,而不是被重写。

4)final:表示当前为重载的最后一个虚函数,之后对此函数复写会报错

overload的话,只有函数返回值类型不同,会重载吗

不会,当前返回类型不同而其他相同,编译器会报错,因为无法知道调用哪个

overload时的形参不同能否重载

对于非引用传参,形参是否const是等价的(即const
int与int等价)。但是当使用引用传参时,有无const是不同的(即const
int&与int&不同)。

使用指针传参时,指向const对象的指针和指向非const对象的指针做形参的函数是不同的(即const
int*与int*不同)。

纯虚函数与虚函数

纯虚函数即在虚函数后书写=0即可实现;、

在这个类中添加一个virtual函数后再sizeof,这时是多大?为什么?

C++编译器一旦发现一个类型有虚函数,就会为该类型生成虚函数表,并在该类型的每一个实例中添加一个指向虚函数表的指针,在32位系统中,一个指针占4个字节,因此sizeof得到4个字节

将这个类再virtual继承一个其它的空类,这是多大?为什么?

8个字节,这个类本身大小为4个字节,空类的大小为1个字节,但是加上虚基类表指针的4字节后,空类失效,故一起8个字节。(虚函数继承时如class B : public virtual A,B的sizeof为:(B自身数据成员的内存对齐大小 + 虚基类表指针大小(常为4) + A的sizeof大小)

子类的虚函数中能不能调用父类的虚函数,为什么?

可以,直接显示调用父类的虚函数即可,如base_class::v_func1();虚函数也是函数,子类的函数是可以调用父类的函数的,所以子类的虚函数是可以调用父类的虚函数的。vptr中是存放了虚函数的地址,是在调用父类虚函数时父类虚函数去查询的指针,而不是移动要显示的经过这个指针

有纯虚函数的类能不能实例化?

不能,有纯虚函数的类是抽象类,只能被继承,不能实例化。包含纯虚函是的类派生出来的类都必须重写这个纯虚函数

对象中的VPTR指针什么时候被初始化?

Vptr指针初始化的过程:

a.对象在创建的时,由编译器对VPTR指针进行初始化

b.只有当对象的构造完全结束后VPTR的指向才最终确定

c.父类对象的VPTR指向父类虚函数表

d.子类对象的VPTR指向子类虚函数表

当定义一个子类对象的时候比较麻烦,因为构造子类对象的时候会首先调用父类的构造函数然后再调用子类的构造函数。当调用父类的构造函数的时候,此时会创建Vptr指针(也可以认为Vptr指针是属于父类的成员,所以在子类中重写虚函数的时候virtual关键字可以省略,因为编译器会识别父类有虚函数,然后就会生成Vptr指针变量),该指针会指向父类的虚函数表;然后再调用子类的构造函数,此时Vptr又被赋值指向子类的虚函数表。

(执行父类的构造函数的时候Vptr指针指向的是父类的虚函数表,所以只能执行父类的虚函数)

上面的过程是Vptr指针初始化的过程。

这是因为这个原因,在构造函数中调用虚函数不能实现多态。(因为调了就是一直使用的父类的虚函数指针指向的东西,没有多态的效果)

平时开发当中多态用的多么? 多态的开销有多大?
如果父类和子类定义了一个函数名相同的函数,但不是虚函数,问父类指针指向的子类对象,调用这个函数是调用父类还是子类函数?同样,如果子类指针指向父类的对象,调用这个函数是调用父类还是子类函数?讲述一下它的原理

如果基础类和衍生类定义了相同名称的成员函数,那么通过对象指针调用成员函数时,到底调用那个函数要根据指针的原型来确定,而不是根据指针实际指向的对象类型确定。同时,父类指针可以指向子类指针,而子类指针不能指向父类(可以C风格强转得到)。

虚函数重写(覆盖)的实质就是复刻一张父类虚函数表,并根据内部实现修改表中对应虚函数的地址;

当父类指针指向子类对象时,可以调用父类的所有东西,因为这些都被继承了;但是对于父类中的虚函数,其仍会调用子类的重载函数,因为此时被继承的虚函数指针指向的虚函数表已经被换成了子类的虚函数表

当子类指针指向父类对象时,可以调用子类继承父类的东西,但是不能调用子类自身的东西(因为没有,所以通常不允许这种情况发生),对于虚函数同上,此时指向的内存中的虚函数表仍为父类的虚函数表

4. 模板相关

函数重载,模板template,用法和区别

1)函数的重载:(使用比较三种不同类型的变量做对比)

C++允许用同一函数名定义多个函数,这些函数的参数个数和参数类型不同。这就是函数重载。

重载函数的参数个数、参数类型或参数顺序3者中必须至少有一种不同,函数返回值类型可以相同也可以不同。

2)函数模板:

所谓函数模板。实际上是建立一个通用函数,其函数类型和形参类型不具体指定,用一个虚拟的类型来代表。这个通用函数就称为函数模板。凡是函数体相同的函数都可以用这个模板来代替,不必定以多个函数,只需在模板中定义一次即可。

3)模板实例化:

① 显示具体化:template<> void swap<job>(job &j1, job &j2);
显示具体化表示为某一特定的类型重写函数模板,声明的含义是使用独立的,专门的函数定义显示地为,特定类型生成函数定义。(对于某些特殊类型,比如自定义的类型,可能不适合模板实现,需要重新定义实现,此时可以使用显示具体化)

② 隐式实例化:在发生函数模板的调用时,不显示给出模板参数而经过参数推演,称之为函数模板的隐式模板实参调用(隐式调用)。如:

template <typename T> void func(T t) { cout<<t<<endl;}

func(5); //隐式模板实参调用,它会在运行到这里的时候才生成相应的实例,很显然的影响效率。

③ 显示实例化:为什么要有显示实例化?事实上,编译器只在要调用函数的时候才使用到函数,如果不使用显示实例化,每次调用函数时,模板都会消耗性能去推导使用的是哪个类型的函数,增加了程序运行时的负担;使用了显示实例化,则在编译时就已经处理了函数选择。

template [函数返回类型] [函数模板名]<实际类型列表>(函数参数列表)

实例化示例:template void swap<int>(int, int);(这样就不会影响运行时的效率,但编译时间随之增加。)

第IV部分 高级主题

1. 内存相关

malloc是如何实现的?

malloc基本的实现原理就是维护一个内存空闲链表,当申请内存空间时,搜索内存空闲链表,找到适配的空闲内存空间,然后将空间分割成两个内存块,一个变成分配块,一个变成新的空闲块。如果没有搜索到,那么就会用sbrk()才推进brk指针来申请内存空间。

C语言的内存分配

分配成功则返回一个指向内存首地址的void*型指针,如char* a = (char*)malloc(100)

alloca是向栈申请内存,因此无需释放。

malloc分配的内存是位于堆中的,并且没有初始化内存的内容,因此基本上malloc之后,调用函数memset来初始化这部分的内存空间,需要用Free方式释放空间.

calloc则将初始化malloc这部分的内存,设置为0.

realloc则对malloc申请的内存进行大小的调整.

free将malloc申请的内存最终需要通过该函数进行释放.

sbrk则是增加数据段的大小

如果物理内存是2G 如果mallco 4G可以么?会有什么问题?

malloc的实现与物理内存自然是无关的,分配到的内存只是虚拟内存,而且只是虚拟内存的页号,代表这块空间进程可以用,实际上还没有分配到实际的物理页面。

栈溢出(栈是从高地址向低地址存)

栈容易溢出是因为栈内存有限,当存的数据超出了限制则栈溢出(比如一个很深的递归),具体原因是变量越界占用了存放EIP(拓展指令指针)(存放函数返回后,下一步指令的地址)导致寻址错误。

利用漏洞:通过在存在栈溢出的部位植入恶意地址覆盖EIP指针的地址部分(比如放入一个存着类似于jmp
esp指令的地址),来绕过程序的判断,跳转到成功的条件分支。

在Windwos里,堆属于进程,栈属于线程,每个线程都有自己的栈。

检查new是否成功?

对于throw new,catch bad_alloc异常;对于nothrow
new,检查返回的指针是否为nullptr;当然也可以重载operator new来做些操作

内存泄漏、指针非法访问问题?
(1)简介

`内存泄漏`是当程序不正确地进行内存管理时出现的一种资源泄漏,表现为程序不再需要使用的内存空间并没有及时被释放掉。内存泄漏并非指物理内存的消失,而是在程序分配了某段内存后,由于设计错误,失去了对该段内存的控制,造成了内存的浪费.

(2)危害

内存泄漏减少计算机可用内存,从而影响了计算机的性能。如果内存泄漏过于严重,整个操作系统、应用程序甚至会崩溃,计算机性能会大打折扣。但是,一般情况下,在现代操作系统中,当一个应用程序结束的时候,该应用程序所占用的内存会被操作系统自动地全部释放,因此,内存泄漏的后果往往不会很严重,甚至不会被察觉到。但是,当长时间运行程序或者设备内存较小时,内存泄漏的问题就不容忽视。作为程序员,我们有必要尽力避免内存泄漏,养成良好的编程习惯.

(3)分类

内存泄漏尤其会发生在没有垃圾回收机制(Garbage
collection)的编程语言,例如:C和C++,也就是说程序并不会自动实现内存管理。对于C和C++这两种语言,我们主要关心两种类型的内存泄漏:

**堆内存泄漏:**程序通过`malloc`,`realloc`,`new`等函数在堆空间中申请了一块内存,但是在使用完后并没有用`free`,`delete`等函数将所申请的内存的内存释放掉,导致相应的那块内存一直被占用。

**系统资源泄漏:**程序在使用系统分配的资源比如Bitmap,handle等之后,并没有用相应的函数释放掉,导致相应内存的占用和系统资源的浪费。

(4)重载new和delete来实现内存管理

被重载的四个函数如下所示:

void *operator new (size_t); //分配一个对象

void *operator new[] (size_t); //分配一个数组

void operator delete (void* ) noexcept; //释放一个对象

void operator delete[] (void* ) noexcept; //释放一个数组

(详细记录方式)第一种方式是创建一个全局变量类Check,其内部主要使用map<void*,Entry
> m_map;其中的Entry用于存储被new的对象所在的源文件地址char*
File,以及new操作所在的行数int
Line。每当有对象被new出来则加入到map中,每当有对象被delete则将那个对象从map中删除,最后在调用Check自己的析构函数时,判断自己的map是否为空即可得知是否内存泄露。具体对于operator的特殊重载后的情况如下:

#define new new(FILE, _LINE_)

void* operator new (size_t size,char *m_file,int m_line);

void* operator new[] (size_t size,char *m_file,int m_line);

上面的函数通过__FILE__,__LINE__宏来获取当前文件名和当前行数,并通过宏定义将new换成了新的new,这样在进行new操作时就非常正常了。

但是其实现的重载只能针对非STL库的对象,在进行new操作时可以调用重载版本的new。但是对于STL库的普通定义方式,其会使用自己内部的allocator来分配空间,此时其使用的仍是原始版本的new和delete,从而无法对当前的file与line进行存储操作(因为其自己会new和delete内存,所以这种自定义的方式对于STL分配无效)。下面的版本是

void* operator new (size_t size) //STL库调用的new版本

(简单记录方式)第二种方式是不存储file和line,全变变量类Check的核心在于set<string>
fromNew与set<string>
fromDelete,前者用于存储已经new出了对象的地址,后者用于存储用于delete的对象的地址。在最后调用析构函数的时候,可以直接按顺序判断这两个set是否想相同即可,这样可以检测是否发生了内存泄露。其直接重载初始的对应的4个new函数与delete函数即可。

内存越界问题

通过重载`new`/`delete`实现对动态内存分配中内存泄漏的检测。

(1)简介

内存越界可以分为`读越界`和`写越界`,`读越界`是指当用户向系统申请了一块内存后(可以
位于堆空间,可以位于栈空间,也可以位于静态数据区),在读取数据时,超出了申请的范围;`写越界`是指用户在写入数据时,超出了申请的范围。

(2)危害

内存越界可能会导致程序的数据被篡改或者无法访问,从而使程序乃至系统发生不可预见的错误,这些错误可大可小,往往不可重现,对程序和系统的稳定性、安全性等方面构成了巨大的威胁。

(3)解决内存越界

和内存泄漏一样,避免内存越界的发生需要程序员良好的编程习惯,同时,这些错误也是难以发现的。因此,我们希望能够想出一种自动检测的方法,在此,通过重载`new`和`delete`,可以对堆空间中的`写越界`进行自动检测。(对于`读越界`重载`new`和`delete`难以实现检测)

(4)解决“写越界”

全局的类`WriteCheck`的实例对象的析构函数在`main`函数结束时一定会执行,所以我们的`检测函数`就位于这样一个类的析构函数中(这与处理`内存泄漏`时的算法思想类似)。

在`WriteCheck`类中建立一个关联容器`map`的对象`m_map`(这与我们在处理`内存泄漏`时的做法类似),`m_map`的键值为`void*`,该`void*`指向`new`分配的内存,`m_map`的映射值为一个类`Entry`,这个类存储了`new`分配内存的长度(`size_t`的值),以及调用`new[]`的文件名和行数。即在`m_map`
中建立了从`内存地址`到`相应内存信息`的映射。

重载`new`,在函数`operator new`中分配一块长于`size_t size`长度的内存(可默认设置为2倍大小,并在前面加上特定的头部数据,在后面加上特定的尾部数据),将多分配的内存初始化(例如初始化为0),并返回真实使用内存的首部位置。然后把`void*`,`size_t size`,`文件名`,`行数`信息存入2中所提到的`m_map`中。如果发生内存写越界,这些多分配的内存空间中的值就会和初始值不同,即发生了内存越界,最后,我们在1中提到的析构函数中进行判断即可。

注:实现内存写越界的自动检测并不用重载`delete`,也不会影响`main.cpp`的`main函数`中的顺序容器和关联容器的使用。

(5)解决“读越界”

可以实现对内存越界中`读越界`的自动检测。我们可以重载中括号运算符`[]`,但是,`[]`运算符只能够在类中重载,并不能在全局重载,所以,这种方法对int,char等数据类型的`读越界`也是无能为力的。

对于类中`[]`的重载,其基本思路是记录分配的数组长度,然后在调用`operator [int
index]`时,比较记录的长度和`index`的大小,即可判断是否发生`读越界`。

堆和栈的区别?

(1)管理方式不同。栈由操作系统自动分配释放,无需我们手动控制;堆的申请和释放工作由程序员控制,容易产生内存泄漏;

(2)空间大小不同。每个进程拥有的栈的大小要远远小于堆的大小。理论上,程序员可申请的堆大小为虚拟内存的大小,进程栈的大小64bits的Windows默认1MB

(3)生长方向不同。堆的生长方向向上,内存地址由低到高;栈的生长方向向下,内存地址由高到低。

(4)分配方式不同。堆都是动态分配的,没有静态分配的堆。栈有2种分配方式:静态分配和动态分配。静态分配是由操作系统完成的,比如局部变量的分配。动态分配由alloca函数进行分配,但是栈的动态分配和堆是不同的,他的动态分配是由操作系统进行释放,无需我们手工实现。

(5)分配效率不同。栈由操作系统自动分配,会在硬件层级对栈提供支持:分配专门的寄存器存放栈的地址,压栈出栈都有专门的指令执行,这就决定了栈的效率比较高。堆则是由C/C++提供的库函数或运算符来完成申请与管理,实现机制较为复杂,频繁的内存申请容易产生内存碎片。显然,堆的效率比栈要低得多。

(6)存放内容不同。栈存放的内容,函数返回地址、相关参数、局部变量和寄存器内容等。堆,一般情况堆顶使用一个字节的空间来存放堆的大小,而堆中具体存放内容是由程序员来填充的。

内存泄漏如何排查?

注意:我们的分析前提是Release版本,因为在Debug环境下,通过VLD这个库或者CRT库本身的内存泄漏检测函数能够分析出内存泄漏,相对而言比较简单。而服务器有很多问题需要在线上并发压力情况下才出现,因此讨论Debug版调试方法意义不大。

二、对象计数

方法:在对象构造时计数++,析构时–,每隔一段时间打印对象的数量

优点:没有性能开销,几乎不占用额外内存。定位结果精确。

缺点:侵入式方法,需修改现有代码,而且对于第三方库、STL容器、脚本泄漏等因无法修改代码而无法定位。

三、重载new和delete

方法:重载new/delete,记录分配点(甚至是调用堆栈),定期打印。

优点:没有看出

缺点:侵入式方法,需将头文件加入到大量源文件的头部,以确保重载的宏能够覆盖所有的new/delete。记录分配点需要加锁(如果你的程序是多线程),而且记录分配要占用大量内存(也是占用的程序内存)。

四、Hook Windows系统API

方法:使用微软的detours库,hook分配内存的系统Api:HeapAlloc/HeapRealloc/HeapFree(new/malloc的底层调用),记录分配点,定期打印。

优点:非侵入式方法,无需修改现有文件(hook
api后,分配和释放走到自己的钩子函数中),检查全面,对第三方库、脚本库等等都能统计到。

缺点:记录内存需要占用大量内存,而且多线程环境需要加锁。

五、使用DiagLeak检测

微软出品的内存泄漏分析工具,原理同hookapi方式。配合LDGraph可视化展示内存分配数据,更方便查找泄漏。

设计一个简单的垃圾回收系统

(1)标记与扫描

由于垃圾回收系统的本质是回收程序中未使用的内存,所以关键是要确定哪一块内存是未使用的。更具体地说,我们应该搜索不再被引用的变量。

如果考虑程序中的所有对象(变量),就像一个有向图,每个对象引用其他对象,同时也被一些对象引用。因此,那些没有任何引用的,不可到达的对象应该被回收。正如你所看到的,这个大问题已经简化为一个图问题

  • 在一个图中找到不可达的节点。

事实上,上述解决方案只是最朴素的方法,被称为标记和扫描。首先,垃圾回收系统执行树的遍历来对象引用,并标记所有访问的对象。在第二阶段,对于所有无法访问的对象,释放它们的内存。

但是系统如何跟踪那些不可达的对象呢?一个简单的方法是保存程序中的所有对象。每当一个新的对象被初始化时,将它添加到池中。(所有的对象在被创建时加入到一个池子里,称为对象池,而所有的对象在被引用或指针调用时,进行对象图的绘制。在进行显示析构时,对于对像从池子中删除。在进行垃圾回收时,对该图进行遍历,如果有存在池子中而没有在图中遍历的对象,对其进行回收。最值得注意的问题是整个系统在垃圾回收过程中必须暂停。换句话说,在垃圾回收时偶尔会冻结问题,工作集不允许改动。因此,这将显着影响对时间要求严格的应用的性能。)

你已经看到了,标记和扫描方法的本质与简单的图的遍历问题没有什么不同。(标记指在图中找不可达节点,扫描指在对象池中找未被标记的节点)这就是我总是强调基础数据结构/算法的原因,这是所有技术面试问题的基础。

(2)改进

鉴于标记和扫描的性能问题,现代垃圾回收系统采取了稍微不同的方法 - 三色着色。

让我简单介绍一下这个算法。简而言之,系统将所有对象标记为三种颜色:

白色 - 没有引用,应该回收的对象。

黑色 - 不可回收的可达对象。黑色的对象不引用白色的对象。

灰色 - 可从根到达的对象,但没有扫描是否引用了白色对象。

最初,从根引用的所有对象都是灰色的,白色的集合包括其他所有东西(黑色是空的)。系统每次挑选一个对象从灰色放到黑色中,并将其所有引用从白色移动到灰色。最后,灰色变成空的,所有的白色对象都被回收。

最显着的优势在于系统可以即时进行垃圾回收,这通过在分配对象和改动期间标记对象来实现。因此,程序不会长时间停止,性能得到改善。

(3)引用计数

那么有没有设计垃圾回收系统的其他方法,不会冻结程序呢?

一个自然的解决方案是引用计数,这个想法是非常简单的。垃圾回收的核心概念是当一个对象没有引用时,我们应该尽快回收它。那么为什么不跟踪每个对象的引用计数呢?

引用计数系统将为每个对象维护一个计数器,来计算它所拥有的引用数量。计数器在创建引用时递增,并且在销毁引用时递减。当计数器为
0 时,对象应该被回收。显然,系统可以在正确的时间释放内存,因此可以进行垃圾回收。

内存碎片的三种解决方案

解法1:规定门限制

在分割空区时,如果分割后的空区值<门的限值,则此空闲区不分割而是全部分配给用户:

解法2:内存拼接技术

将所有小空闲区集中构成大空区,其时机在三个:释放回收空间时/没有足够大的分配区间时/定期处理时,问题在于拼接会大量消耗资源。

解法3:解除程序占用连续内存才能运行的限制

程序分为多部分装入多个内存碎片分区中,在某一块内存碎片中建立一个内存分配表,在内存分配表中根据页偏移量来查表,得到其所在的内存碎片位置以及其中的偏移量。

解法4:内存池方式

根据分配出去的内存大小,内存池可以分为两类:

(1)Fixed-size Allocation

每次分配出去的内存单元(称为 unit 或者
cell)的大小为程序预先定义的值。释放内存块时,则只需要简单地挂回内存池链表中即可。又称为
“固定尺寸缓冲池”。

常规的做法是:将不同 unit size 的内存池整合在一起,以满足不同内存块大小的使用需求

(2)Variable-size allocation

不分配固定长度,内存的分配只是在一大块空闲的内存上滑动。优点是分配效率很高,缺点是成批地回收内存,因为释放的内存无法直接重复利用。

使用这种需要合理规划每块内存的管理区域,所以又叫做 “基于区域的”
内存管理。使用这种做法的分配器,举例有 Apache Portable Runtime 中的 apr_pool
工具。本文不讨论这种内存池。

2. 编译相关

C++编译后的函数符号和C语言编译后的函数符号有哪些区别?为什么

C++语言支持函数重载,C语言不支持函数重载。函数被C++编译后在库中的名字与C语言的不同。假设某个函数的原型为void
func(int x,int
y)。该函数被C编译器编译后在库中的名字为_foo,而C++编译器则会产生像_foo_int_int之类的名字。C++中提供了C连接交换指定符号
extern “C” 解决名字匹配问题。

解释一个Hello World程序从C到最终运行起来的过程。

基本上就是先编译,得到Symbol, 链接器Resolve Symbol,Printf属于动态链接库里面的内容,所以涉及到GOT和PLT表,然后操作系统开新的进程,Load二进制文件,将控制流跳到程序入口Main函数执行等等。面试官听到动态链接库,又问了我一些动态链接库的内容和进程的地址空间和内存的映射(基本上就是回答新进程的地址空间只是映射了Kernel代码,不用创建新的,动态链接库也是映射过来可以执行balabala;

程序有哪几种链接方式?分别说明区别?哪种效率高?如果一个动态库没有.lib和头文件,要怎么使用里面的函数?

1)静态链接:在程序运行之前,先将各个目标模块及它们所需的库函数,链接成一个完整的装配模块,以后不再拆开。我们把这种事先进行链接的方式称为静态链接方式。

2)装入时动态链接:将用户源程序编译后所得到的一组目标模块,在装入内存时,采用边装入边链接的链接方式。

3)运行时动态链接:这是指对某些目标模块的链接,是在程序执行中需要该目标模块时,才对它进行的链接。(便于修改和更新,便于实现对目标模块的共享)

静态链接与动态链接

(1)静态链接

特点:在生成可执行文件的时候(链接阶段),把所有需要的函数的二进制代码都包含到可执行文件中去。因此,链接器需要知道参与链接的目标文件需要哪些函数,同时也要知道每个目标文件都能提供什么函数,这样链接器才能知道是不是每个目标文件所需要的函数都能正确地链接。如果某个目标文件需要的函数在参与链接的目标文件中找不到的话,链接器就报错了。目标文件中有两个重要的接口来提供这些信息:一个是符号表(.o文件自己的函数),另外一个是重定位表(.o文件需要的函数)。

优点:在程序发布的时候就不需要的依赖库,也就是不再需要带着库一块发布,程序可以独立执行。

缺点:程序体积会相对大一些。

如果静态库有更新的话,所有可执行文件都得重新链接才能用上新的静态库。

(2)动态链接

特点:在编译的时候不直接拷贝可执行代码,而是通过记录一系列符号和参数,在程序运行或加载时将这些信息传递给操作系统,操作系统负责将需要的动态库加载到内存中,然后程序在运行到指定的代码时,去共享执行内存中已经加载的动态库可执行代码,最终达到运行时连接的目的。

优点:多个程序可以共享同一段代码,而不需要在磁盘上存储多个拷贝。

缺点:由于是运行时加载,可能会影响程序的前期执行性能。

(3)库

上面的文章多次提到库(lib)这个概念,所谓的库就是一些功能代码经过编译连接后的可执行形式。大家在Windows平台上见到的.dll文件和linux平台下so动态库都输入库。库也有静态lib和动态lib之分:

静态lib导出声明和实现都放在lib中。编译后所有代码都嵌入到宿主程序。

动态lib相当于一h文件,是对实现部分(.dll文件)的导出部分的声明。编译后只是将导出声明部分编译到宿主程序中,运行时候需要相应的dll文件支持。

3. 特殊工具相关

C++11本人用过的新特性

(1)long long类型;

(2)列表初始化;

(3)nullptr常量;

(4)auto类型指示符;

(5)类内初始化;

(6)范围for语句;

(7)定义vector的vector(实现二维数组);

(8)initializer_list类;

(9)=default生成默认构造函数,=delete阻止拷贝对象;

(10)用string处理文件名;

(11)to_string这样的数值转换int函数;

(12)lambda表达式;

(13)无序关联容器unordered_map与unordered_set

(14)智能指针shared_ptr,unique_ptr,weak_ptr

(15)移动构造函数与右值引用

(16)标准库move函数

(17)虚函数的override与final指示符

c++中类型转换机制?各适用什么环境?dynamic_cast转换失败时,会出现什么情况?(对指针,返回NULL,对引用,抛出bad_cast异常)

一、static_cast转换

1.基本用法:static_cast<type-id> expression

2.使用场景:(最常用转换)

a、用于类层次结构中基类和派生类之间指针或引用的转换

上行转换(派生类---->基类)是安全的;

下行转换(基类---->派生类)由于没有动态类型检查,所以是不安全的。

b、用于基本数据类型之间的转换,如把int转换为char,这种带来安全性问题由程序员来保证

c、把空指针转换成目标类型的空指针

d、把任何类型的表达式转为void类型

3.使用特点

a、主要执行非多态的转换操作,用于代替C中通常的转换操作

b、隐式转换都建议使用static_cast进行标明和替换

二、dynamic_cast转换

1.基本用法:dynamic_cast<type-id> expression

2.使用场景:只有在派生类之间转换时才使用dynamic_cast,type-id必须是类指针,类引用或者void*。(并且所被转换的类必须有虚函数,不然报错)

3.使用特点:

a、基类必须要有虚函数,因为dynamic_cast是运行时类型检查,需要运行时类型信息,而这个信息是存储在类的虚函数表中,只有一个类定义了虚函数,才会有虚函数表(如果一个类没有虚函数,那么一般意义上,这个类的设计者也不想它成为一个基类)。

b、对于下行转换,dynamic_cast是安全的(当类型不一致时,转换过来的是空指针),而static_cast是不安全的(当类型不一致时,转换过来的是错误意义的指针,可能造成踩内存,非法访问等各种问题)

c、dynamic_cast还可以进行交叉转换

三、const_cast转换

1.基本用法:const_cast<type-id>expression

2.使用场景:const或volatile属性的修改

a、常量指针转换为非常量指针,并且仍然指向原来的对象

b、常量引用被转换为非常量引用,并且仍然指向原来的对象

3.使用特点:

a、cosnt_cast是四种类型转换符中唯一可以对常量进行操作的转换符

b、去除常量性是一个危险的动作,尽量避免使用。一个特定的场景是:类通过const提供重载时,一般都是非常量函数调用const_cast<const
T>将参数转换为常量,然后调用常量函数,然后得到结果再调用const_cast
<T>去除常量性。

四、reinterpret_cast转换(随意转换,二进制映射)

1.基本用法:reinterpret_cast<type-id>expression

2.使用场景:不到万不得已,不用使用这个转换符,高危操作

3.使用特点:

a、reinterpret_cast是从底层对数据进行重新解释,依赖具体的平台,可移植性差

b、reinterpret_cast可以将整型转换为指针,也可以把指针转换为数组

c、reinterpret_cast可以在指针和引用里进行肆无忌惮的转换

union

共用体union,也叫联合体,在一个“联合”内可以定义多种不同的数据类型,
一个被说明为该“联合”类型的变量中,允许装入该“联合”所定义的任何一种数据,这些数据共享同一段内存,以达到节省空间的目的。union变量所占用的内存长度等于最长的成员的内存长度。

且联合体union的存放顺序是所有成员都从低地址开始存放,利用该特性就可以轻松地获得了CPU对内存采用Little-endian还是Big-endian模式读写。

在union里不允许存放带有构造函数、析构函数和复制构造函数等的类对象,但是可以存放对应的类对象指针。编译器无法保证类的构造函数和析构函数得到正确的调用,由此,就可能出现内存泄漏。所以,在C++中使用union时,尽量保持C语言中使用union的风格,尽量不要让union带有对象。

如何在main函数之前或之后打印信息(拓展到入口函数相关问题)

C++中全局对象的初始化在main之前执行,对象析构在main函数之后,定义一个全局变量类,然后在其构造函数中打印即可。

//使用C的定义方式可以实现某些函数在main函数之前与之后执行的情况

//__attribute((destructor))

//__attribute((constructor))

二、数据结构和算法:

第I部分 数据结构

向量与列表相关

向量和队列有什么区别

1)向量:能高效的进行随机存取,时间复杂度为o(1);
在进行插入和删除操作时,会造成内存块的拷贝,时间复杂度为o(n)。

实现机理:vector和数组类似,拥有一段连续的内存空间,并且起始地址不变。因此能高效的进行随机存取,时间复杂度为o(1);但因为内存空间是连续的,所以在进行插入和删除操作时,会造成内存块的拷贝,时间复杂度为o(n)。另外,当数组中内存空间不够时,会重新申请一块内存空间并进行内存拷贝。

2)队列:能高效的进行随机存取,时间复杂度为o(1);在内部方便的进行头尾部的插入和删除操作,时间复杂度为o(n)。

实现机理:deque的元素数据采用分块的线性结构进行存储,deque分成若干线性存储块,称为deque块。块的大小一般为512个字节,元素的数据类型所占用的字节数,决定了每个deque块可容纳的元素个数。

所有的deque块使用一个Map块进行管理,每个Map数据项记录各个deque块的首地址。Map是deque的中心部件,将先于deque块,依照deque元素的个数计算出deque块数,作为Map块的数据项数,创建出Map块。以后,每创建一个deque块,都将deque块的首地址存入Map的相应数据项中。

栈与队列相关

二叉树相关

图相关

图的搜索有哪几种方式?广搜要怎么做?需要什么额外空间吗

DFS和BFS,其中BFS需要开辟队列内存,DFS需要栈。

最小路径算法(楼主说了Dijkstra和floyd算法)

给定一个迷宫,部分坐标是无法通过的,求某两点间最短路径?(广搜+并查集)

从起点到终点的最短路径其实就是一个建立队列的过程:

1)从起点开始,先将其加入队列,设置距离为0;

2)从队列首端取出位置,将从这个位置能够到达的位置加入队列,并且让这些位置的距离为上一个位置的距离加上1;

3)循环2直到将终点添加到队列中,这说明我们已经找到了路径;

注意到在这个过程中,每次处理的位置所对应的距离是严格递增的,因此一旦找到终点,当时的距离就是最短距离;

简述Dijkstra算法的过程,描述一下A Star算法

A*算法:

1)把起点加入 open list 。

2)重复如下过程:

a.遍历 open list ,查找 F 值最小的节点,把它作为当前要处理的节点。

b.把这个节点移到 close list 。

c.对当前方格的 8 个相邻方格的每一个方格?

◆ 如果它是不可抵达的或者它在 close list 中,忽略它。否则,做如下操作。

◆ 如果它不在 open list 中,把它加入 open list
,并且把当前方格设置为它的父亲,记录该方格的 F , G 和 H 值。

◆ 如果它已经在 open list 中,检查这条路径 ( 即经由当前方格到达它那里 )
是否更好,用 G 值作参考。更小的 G
值表示这是更好的路径。如果是这样,把它的父亲设置为当前方格,并重新计算它的 G 和
F 值。如果你的 open list 是按 F 值排序的话,改变后你可能需要重新排序。

d.停止,当你

◆ 把终点加入到了 open list 中,此时路径已经找到了,或者

◆ 查找终点失败,并且 open list 是空的,此时没有路径。

3)保存路径。从终点开始,每个方格沿着父节点移动直至起点,这就是你的路径。

opened_table:采用优先队列实现的小顶堆,用于存放待扩展结点,同时利用F值作为排序指标;

closed_table:采用set(红黑树)实现,便于快速查找格是否存在于closed_table中;

搜索树相关

数据库B+树

B+树对比B树的三个优点:

1)对于块区的存储效率更高;

2)查询效率稳定;

3)支持范围查询;

B+树与B树的缺点:

会产生大量的随机IO,随着新数据的插入,叶子节点会慢慢分裂,逻辑上连续的叶子节点在物理上往往不连续,甚至分离的很远,但做范围查询时,会产生大量读随机IO。对于大量的随机写也一样,举一个插入key跨度很大的例子,如7->1000->3->2000

新插入的数据存储在磁盘上相隔很远,会产生大量的随机写IO。从上面可以看出,低下的磁盘寻道速度严重影响性能。

b树b+树b*树的区别

(b+树对于叶子结点有一个双向链表,b*树+非叶子结点)

给你一个表(数据很大),有用户名和数据,如何快速检索某条数据

对索引排序+二分查找,对索引建表,在新表里可以用hash、分区等操作(B+树)

红黑树实现

每次先按BST方法插入数据,每次都将插入节点变红,然后再自下而上调整节点颜色,使其满足红黑树性质

词典相关(表)

Hash的知识。(hash的构建与冲突处理。)

1)构建:a直接定址法b除留余数法c数字分析法d平方取中法e折叠法

2)冲突处理:a.开放定址法(线性探测、懒惰删除、平方探测、再散列)b拉链法,多槽位法,公共区溢出法

  1. hash冲突,怎么解决(散链表,双重hash,等等)

  2. hash冲突的链表法链表长度太长了咋办

一般是引入rehash机制,通过判断拉链长度与哈希表的桶的比例,当超过1/2等时,就自动扩充哈希表的桶到2倍。这样就可以降低拉链的长度等。参考java中HashMap
的实现,有个装载因子,默认3/4,如果超过就会自动扩容,然后rehash

set的底层实现是什么?红黑树是做什么用的?额外开销是多少?

set的底层实现是红黑树。红黑树是一种平衡二叉查找树。a结点是红色或黑色。b根节点是黑色,每个叶子结点都是黑色,c每个红色结点的两个孩子结点都是黑色。d从每个叶子到根的所有路径上不能有两个连续的红色结点。e从任一结点到其每个叶子的所有路径都包含相同数目的黑色结点。

红黑树和AVL树一样都对插入时间和删除时间以及查找时间提供了最好可能的最坏保障。时间复杂度是O(logn),需要额外的空间也是O(logn)

红黑树是有序的,Hash是无序的,根据需求来选择。

红黑树占用的内存更小(仅需要为其存在的节点分配内存),而Hash事先就应该分配足够的内存存储散列表(即使有些槽可能遭弃用)。

红黑树查找和删除的时间复杂度都是O(logn),Hash查找和删除的时间复杂度都是O(1)。如果数据是静态的,可以多考虑哈希表。

优先级队列相关

什么是优先队列

优先队列队首元素一定是当前队列中优先级最高的一个

优先队列可以用堆实现

堆是用最大堆还是最小堆实现优先队列,为什么?

默认是用最大堆实现优先队列(priority_queue<int, vector<int>, less<int>
>默认衍生自vector,数字大的优先级越高)

如果返回堆中最大的元素,要怎么做?

取堆顶元素,如果要取完后要删除,则把最后一个元素移至第一个元素,并将size自减1,然后从堆顶元素自上向下进行整堆(下滤))

堆了解么?怎么删除元素?怎么插入元素?

完全二叉树,删除为下滤:删除顶元素,将最后一个数据移到第一个,size自减1,然后从第一个节点自上而下进行整堆。插入为上滤:将当前元素放置到堆尾然后进行上滤

如果堆中某元素的序号是5,那他两个自孩子的序号分别是多少?

10,11

n个球要分成m堆每个堆不能为空,有多少种分发

串匹配相关

排序相关

在这里插入图片描述

说一下快排的时间复杂度

平均复杂度O(nlogn),最坏情况下O(n^2),最好情况下O(nlogn)

什么样的情况是快排的最坏情况,举个例子

Pivot轴点一直为最大或者最小数字,那么所有数都划分到一个序列去了

如何解决快排的的最坏情况(我说的随机打乱)

每次选择轴点时进行一次随机打乱

说说快排中随机打乱的具体实现

Swap(data[lo], data[lo + (rand()%(hi – lo + 1))];

Int pivot = data[lo];

要求我写快排,基本上就是说下思路就可以了,没什么难度。

快排的时间复杂度,轴点划分算法的直接选取与随机选取,递归与非递归

谈谈排序和堆排序的应用

AVL树和红黑树区别(一个通过旋转实现完全平衡,一个通过定义根结点的颜色)

第II部分 算法相关

给一系列整形数,其中除了一个数只有一个之外,其他数都有两个,请设计算法找到只有一个的那个数(抑或过去完事)

(我开始回答计数排序,后来他又问我有没有别的,我就说全放到set里,可以排除所有那些添加进set里让set元素个数不增加的所有元素,剩下的就是要找到那个数,不知道对不对,感觉应该还有更好的方法)

从头到尾将这n个数异或一遍,得到的数即为要找的那个数。

一个整型数组里除了两个数字之外,其他的数字都出现了两次。请写程序找出这两个只出现一次的数字。

思路:1)如果把数组中的所有数字都依次异或一遍,则可以消掉成对出现的数字,那么还有两个数字是单一的,肯定也不同,那么最终异或的结果肯定不是0。表示在二进制中肯定有一位是1,那么两个不同的数字,一定有一个在该位为1,另一个在该位为0。如果将整个数组按照该位是否为1分为两部分,那么这两部分各自包含一个单一数字。

一串数只出现一次,给定一个输入,让返回所有数对,数对中的两个数之和等于输入。(排序后前后双指针找O(nlogn))

我考虑了1min回答两种思路,第一种快排后两边向中间遍历,大了右边-1,小了左边+1,
O(nlogn)的复杂度,然后就是说不排序就和冒泡一样遍历,O(n2)的复杂度。

对数据压缩熟悉不(不熟悉,实习的时候直接调用导师接口,面试官就没接着问)

数据压缩解决40亿数中找重复:首先,32位int的范围是42亿,40亿整数中肯定有一些是连续的,我们可以先对数据进行一个外部排序,然后用一个初始的数和一个长度构成一个数据结构,来表示一段连续的数,举个例子。

如果数据是1 2 3 4 6 7……这种的,那么可以用(1,4)和(6,2)来表示,这样一来,连续的数都变成了2个数表示。

来了一个新数之后,就用二分法进行查找了。

这样一来,最差情况就是2亿多的断点,也就是2亿多的结构体,每个结构体8个字节,大概16亿字节,1.6GB,在内存中可以放下。

只有大小写英文字母的文本文档,数据量很大,如何压缩表示(因为我说了不懂数据压缩,就出了一个相关问题)

哈夫曼编码+详细实现过程,还可以对重复出现的字母加下标,对重复出现的子串编码(后面两种方法针对可能的具体问题,主要还是哈夫曼编码

一个数组,把所有奇数排在偶数前面,且保证奇数和奇数相对位置不变,偶数和偶数相对位置不变

再用一个数组

找出一个无序数组中大小后K个数据?

类似于快速排序的思想,随机选取一个元素,把所有小于等于这个元素的数据移到左边,所有大于这个元素的数据移动到右边。

如果这个元素成了第K个数,直接返回这个数。如果左边的个数大于K,不管右边的数了,在左边重复上面的过程。如果左边的个数等于T<K,不管左边的数了,重复上面的过程,只是K=K-T-1。平均情况下,第一次划分的时间复杂度是O(N),第二次就是O(N/2),总共是O(n+n/2+n/4+…)=O(n)

此题拓展:

O(n)的类似快排的计数算法,是O(n)的原因:理想情况下每次都在一半里面寻找,1/2+1/4+1/8+…,最后就是O(n)

对于K很小的情况:O(nlogk)的最大堆算法,两者的区别在于:前面那个算法比较理想的情况系数才等于2,可能分的情况不好的时候就性能很差,而我后面这个算法就很确定是O(nlogk),后来他就肯定了楼主说其实就是想考察前面这个算法不稳定。

如果说内存很小怎么办(楼主就说还是会采用第二个算法,因为只要满足k个大小的内存,后面的数据只要依次调入内存和最大堆堆顶元素进行比较就可以了)

如果k比内存还大怎么办(楼主就慌了,想到外部排序和归并,但具体怎么实现的就记不太清了,慌忙中就说了下外部排序和败者树(亚军一定是和冠军比输了的),其实也没怎么答好吧,后来他就没有继续问算法了)

如何实现100百万个数据取前1000大的数据的方法?

1.数据全部排序, 然后在排序后的集合中进行查找,最快的排序算法的时间复杂度一般为O(nlogn),如快速排序。但是在32位的机器上,每个float类型占4个字节,1亿个浮点数就要占用400MB的存储空间(空间复杂度O(n)),对于一些可用内存小于400M的计算机而言,很显然是不能一次将全部数据读入内存进行排序的。其实即使内存能够满足要求(我机器内存都是8GB),该方法也并不高效,因为题目的目的是寻找出最大的10000个数即可,而排序却是将所有的元素都排序了,做了很多的无用功。

1.1 数组局部冒泡排序:只排到1000的数,时间复杂度O(n*k)

2.局部淘汰法, 该方法与排序方法类似,用一个容器保存前10000个数,然后将剩余的所有数字——与容器内的最小数字相比,如果所有后续的元素都比容器内的10000个数还小,那么容器内这个10000个数就是最大10000个数。如果某一后续元素比容器内最小数字大,则删掉容器内最小元素,并将该元素插入容器,最后遍历完这1亿个数,得到的结果容器中保存的数即为最终结果了。此时的时间复杂度为O(n+m^2),其中m为容器的大小,即10000。

对于局部淘汰法,如果使用set存储,则时间复杂度变为O((n+k)logk),空间复杂度为O(k),只需维护一个k大小的set即可。

3.分治法, 将1亿个数据分成100份,每份100万个数据,找到每份数据中最大的10000个,最后在剩下的100*10000个数据里面找出最大的10000个。如果100万数据选择足够理想,那么可以过滤掉1亿数据里面99%的数据。100万个数据里面查找最大的10000个数据的方法如下:(适合多机器使用,即每组数据一个机器去算,然后最后取最大某范围的数据给一台机子去算)

用快速排序的方法,将数据分为2堆,如果大的那堆个数N大于10000个,继续对大堆快速排序一次分成2堆,如果大的那堆个数N大于10000个,继续对大堆快速排序一次分成2堆,如果大堆个数N小于10000个,其数量为n,就在小的那堆里面快速排序一次,找第10000-n大的数字,找到后即可确定最大的1W个数;递归以上过程,就可以找到第1w大的数。参考上面的找出第1w大数字,就可以类似的方法找到前10000大数字了。此种方法需要每次的内存空间为10^6*4=4MB,一共需要101次这样的比较。时间复杂度O(nlogn),空间复杂度k*O(n/k),算法思路如下:

i = partition(arr, 1, n);

如果i大于k,则说明arr[i]左边的元素都大于k,于是只递归arr[1,
i-1]里第k大的元素即可;

如果i小于k,则说明说明第k大的元素在arr[i]的右边,于是只递归arr[i+1,
n]里第k-i大的元素即可;

4.Hash法: Hash法不算一种方法,算是一种预处理方式,具体为构建map,如果这1亿个数里面有很多重复的数,先通过Hash法,把这1亿个数字去重复,这样如果重复率很高的话,会减少很大的内存用量,从而缩小运算空间,然后通过分治法或最小堆法查找最大的10000个数。

5.采用最小堆: 首先读入前10000个数来创建大小为10000的最小堆,建堆的时间复杂度为O(klogk),如果用FLOYD建堆则为0(k)(k为数组的大小即为10000),然后遍历后续的数字,并于堆顶(最小)数字进行比较。如果比最小的数小,则继续读取后续数字;如果比堆顶数字大,则替换堆顶元素并重新调整堆为最小堆。整个过程直至1亿个数全部遍历完为止。然后按照中序遍历的方式输出当前堆中的所有10000个数字。该算法的时间复杂度为O(nlogk),空间复杂度是O(k)(常数)。

给定1000亿个数据,里边有的数据有重复,要求设计一个算法删除重复数据?要求尽量快。

先取模分成小文件,然后每个文件使用hash_map或者trie树。

给定1000亿个数据,要找出其中最大的一个值,内存只有1G?

大文件变小文件,然后每个文件里hash_map统计最大的值,然后再归并排序。

洗牌算法,如何证明算法是随机的(每次计算当前位置数字出现的概率,均为1/n)

需要随机置乱的n个元素的数组array:

for( i =n-1;i>=1;i–) (0 =< j <= i)

交换array[i]和array[j]

end

100万个32位整数,如何最快找到中位数。能保证每个数是唯一的,如何实现O(N)算法?

假设100亿个数字保存在一个大文件中,依次读一部分文件到内存(不超过内存的限制),将每个数字用二进制表示,比较二进制的最高位(第32位,符号位,0是正,1是负),如果数字的最高位为0,则将这个数字写入file_0文件中;如果最高位为 1,则将该数字写入file_1文件中。

从而将100亿个数字分成了两个文件,假设 file_0文件中有 60亿 个数字,file_1文件中有40亿 个数字。那么中位数就在 file_0 文件中,并且是 file_0文件中所有数字排序之后的第 10 亿个数字。(file_1中的数都是负数,file_0中的数都是正数,也即这里一共只有40亿个负数,那么排序之后的第50亿个数一定位于file_0中)

现在,我们只需要处理 file_0 文件了(不需要再考虑file_1文件)。对于 file_0文件,同样采取上面的措施处理:将file_0文件依次读一部分到内存(不超内存限制),将每个数字用二进制表示,比较二进制的次高位(第31位),如果数字的次高位为0,写入file_0_0文件中;如果次高位为1,写入file_0_1文件中。

现假设file_0_0文件中有30亿个数字,file_0_1中也有30亿个数字,则中位数就是:file_0_0文件中的数字从小到大排序之后的第10亿个数字。

抛弃file_0_1文件,继续对 file_0_0文件 根据 次次高位(第30位)划分,假设此次划分的两个文件为:file_0_0_0中有5亿个数字,file_0_0_1中有25亿个数字,那么中位数就是file_0_0_1文件中的所有数字排序之后的 第 5亿 个数。

按照上述思路,直到划分的文件可直接加载进内存时,就可以直接对数字进行快速排序,找出中位数了。

你看,现在我百度一个ip地址可以查到那个IP的实际省市显地址,现在我给你每个县/村的ip段,要求你实现一下输入查找功能。

【大概是问数据库,我完全不会,瞎答了】

你看我打开一个谷歌网页,输入一些单词,他下面给我提示了一些可能我需要的选项,比如我输入一个tail出现了balabala。你现在给我实现一下这个功能,会用什么数据结构什么算法呢。

1)Trie是一颗存储多个字符串的树。相邻节点间的边代表一个字符,这样树的每条分支代表一则子串,而树的叶节点则代表完整的字符串。和普通树不同的地方是,相同的字符串前缀共享同一条分支。

2)hashmap统计:
先对这批海量数据预处理。具体方法是:维护一个Key为Query字串,Value为该Query出现次数的HashTable,即hash_map(Query,Value),每次读取一个Query,如果该字串不在Table中,那么加入该字串,并且将Value值设为1;如果该字串在Table中,那么将该字串的计数加一即可,最终在O(N)的时间复杂度内用Hash表完成了统计。

3)堆排序:借助堆这个数据结构,找出Top K,时间复杂度为N‘logK。即借助堆结构,我们可以在log量级的时间内查找和调整/移动。因此,维护一个K(该题目中是10)大小的小根堆,然后遍历300万的Query,分别和根元素进行对比。所以,我们最终的时间复杂度是:O(N)

  • N’ * O(logK),(N为1000万,N’为300万)。
给你11位电话号码,让你通过电话找名字
给你多个ipv4的区间,每个区间属于一个城市,如0.0.0.1-1.1.1.1属于北京,给你一个ipv4地址,你要输出所属的城市,如1.1.1.0输出北京。区间有可能会重叠,如果询问的ipv4属于多个城市,则输出所有所属城市。
平面最近点对

三、计算网络

第I部分 应用层相关

网络,dns、https和http,非对称加密和加密比较一下

(1)HTTPS 采用的加密方式

HTTPS
采用混合的加密机制,使用非对称密钥加密用于传输对称密钥来保证传输过程的安全性,之后使用对称密钥加密进行通信来保证通信过程的效率。(下图中的
Session Key 就是对称密钥)

(2)HTTPS使用的认证方式

通过使用证书来对通信方进行认证。数字证书认证机构(CA,Certificate
Authority)是客户端与服务器双方都可信赖的第三方机构。

服务器的运营人员向 CA 提出公开密钥的申请,CA
在判明提出申请者的身份之后,会对已申请的公开密钥做数字签名,然后分配这个已签名的公开密钥,并将该公开密钥放入公开密钥证书后绑定在一起,最后返回服务器。

进行 HTTPS
通信时,服务器会把证书(包含了公开密钥和签名)发送给客户端。客户端取得其中的公开密钥之后,先使用数字签名进行验证,如果验证通过,就可以开始通信了。(开始上面所说的混合加密通信方式)

(3)HTTPS的完整性保护

SSL提供报文摘要功能来进行完整性保护。

HTTP也提供了 MD5
报文摘要功能,但不是安全的。例如报文内容被篡改之后,同时重新计算 MD5
的值,通信接收方是无法意识到发生了篡改。

HTTPS的报文摘要功能之所以安全,是因为它结合了加密和认证这两个操作。试想一下,加密之后的报文,遭到篡改之后,也很难重新计算报文摘要,因为无法轻易获取明文。

(4)HTTPS 的缺点

因为需要进行加密解密等过程,因此速度会更慢;

需要支付证书授权的高额费用。

第II部分 传输层相关

TCP和UDP的区别?分别举例它们的上层协议?

TCP是基于连接的,可靠的,偏向于传输大量数据,速度慢,http,ftp,smtp,telnet使用了tcp;UDP是无连接的,不可靠的,偏向于传输少量数据,速度快,dns,tftp,rip,snmp,rtp,nfs等使用了udp。

TCP的可靠性怎么保证(三次握手、四次挥手、确认序列号)

1)检验和

典型的有CRC校验法。在要传输的k比特数据D后添加(n-k)比特冗余位。

2)序列号

通过序列号,可以去重、超时重传、数据有序到达。

3)确认应答

可以确认ACK之前的数据肯定到达了,保证了可靠性。

4)超时重传

发送的数据有可能因为网络拥堵,没有及时到达,发送端没有收到确认,超过计时器,就会进行重发。

发送端有可能收到许多重复确认,累计到一定次数,TCP认为网络或者接收端出现异常,重新发送丢失的数据包。

5)连接管理

通过三次握手四次挥手,也可提高可靠性。

6)流量控制

接收端处理数据的速度是有限的,如果发送端发的太快,接收端缓冲区容易满,会造成丢包以及引起丢包重传。

7)拥塞控制

发送数据的时候,不能刚开始就发送大量的数据,所以在不清楚网络状况的情况下,不能贸然发送大量的数据,有可能加重网络负担,TCP会使用慢启动机制,探探路,所以刚开始的时候,将拥塞窗口设为1,以后是指数增长,当达到阈值的时候,按照线性增长,到达拥塞窗口的最大值后,拥塞窗口重回1。

  1. TCP4层网络层次、3次握手

  2. TCP和UDP的知识点。TCP讲三次握手和四次挥手。

(1)三次握手

第一次握手:主机A发送同步报文段(SYN)请求建立连接。

第二次握手:主机B听到连接请求,就将该连接放入内核等待队列当中,并向主机A发送针对SYN的确认ACK,同时主机B也发送自己的请求建立连接(SYN)。

第三次握手:主机A针对**主机B**SYN的确认应答ACK。

(2)四次挥手

第一次挥手:当主机A发送数据完毕后,发送FIN结束报文段。

第二次挥手:主机B收到FIN报文段后,向主机A发送一个确认序号ACK(为了防止在这段时间内,对方重传FIN报文段)。

第三次挥手:主机B准备关闭连接,向主机A发送一个FIN结束报文段。

第四次挥手:主机A收到FIN结束报文段后,进入TIME_WAIT状态。并向主机B发送一个ACK表示连接彻底释放。(如果客户端的确认应答丢失,算上这个丢失报文的时间,再加上服务端重传FIN的时间(重传后客户端重新启动2MSL计时器),2MSL的时间足够使客户端收到重传的FIN报文段。所以客户端不能立即进入CLOSED状态。)

UDP如何实现可靠性传输?(在应用层实现)

UDP它不属于连接型协议,因而具有资源消耗小,处理速度快的优点,所以通常音频、视频和普通数据在传送时使用UDP较多,因为它们即使偶尔丢失一两个数据包,也不会对接收结果产生太大影响。

传输层无法保证数据的可靠传输,只能通过应用层来实现了。实现的方式可以参照tcp可靠性传输的方式,只是实现不在传输层,实现转移到了应用层。

主要是实现确认机制、重传机制、窗口确认机制

如果你不利用linux协议栈以及上层socket机制,自己通过抓包和发包的方式去实现可靠性传输,那么还必须实现如下功能:

① 发送:包的分片、包确认、包的重发

② 接收:包的调序、包的序号确认

目前有如下开源程序利用UDP实现了可靠的数据传输。分别为RUDP、RTP、UDT。

握手过程必定由客户端发起,但是挥手过程双方均可,不过最好是客户端

如果先结束服务器端后结束客户端,紧接着再重启服务器端就会出现绑定失败的错误 OSError: [Errno 98] Address already in use,等待一段时间后大概一分钟左右就能正常重启服务器端。

因为TCP是全双工的,所以客户端和服务端都可以先进行挥手。在socket编程中哪一方先执行close()/closesocket()或shutdown()操作,哪一方则先进行挥手(发送FIN包)。

以客户端先挥手为例,在TCP处于TIME_WAIT状态时,客户端从TIME_WAIT状态到CLOSED状态需要2MSL,因为客户端要确定服务端收到了ACK,如果服务端没收到ACK,客户端则一定会在2MSL时间内再收到一次FIN。而在socket编程中客户端一般不需要绑定,而服务器端一般都要绑定,所以在这段时间关了客户端再打开也无所谓。

但是如果先结束服务器端则是服务器端先进行挥手操作,那么服务器端从TIME_WAIT到CLOSED状态则需要2MSL。这段时间服务器端绑定的端口号被占用了,套接字不会释放。所以这段时间重启了服务器之后,会出现绑定失败的错误OSError:
[Errno 98] Address already in use。

MSL 是Maximum Segment Lifetime英文的缩写,中文可以译为“报文最大生存时间”,他是任何报文在网络上存在的最长时间,超过这个时间报文将被丢弃。RFC
793中规定了MSL为2分钟,实际应用中常用的是30秒,1分钟和2分钟等。

保护消息边界和流

保护消息边界,就是指传输协议把数据当作一条独立的消息在网上传输,接收端只能接收独立的消息。也就是说存在保护消息边界,接收端一次只能接收发送端发出的一个数据包。而面向流则是指无保护消息保护边界的,如果发送端连续发送数据,接收端有可能在一次接收动作中,会接收两个或者更多的数据包。

例如,我们连续发送三个数据包,大小分别是2k,4k
,8k,这三个数据包,都已经到达了接收端的网络堆栈中,如果使用UDP协议,不管我们使用多大的接收缓冲区去接收数据,我们必须有三次接收动作,才能够把所有的数据包接收完.而使用TCP协议,我们只要把接收的缓冲区大小设置在14k以上,我们就能够一次把所有的数据包接收下来,只需要有一次接收动作。

注意:

这就是因为UDP协议的保护消息边界使得每一个消息都是独立的。而流传输却把数据当作一串数据流,他不认为数据是一个一个的消息。所以有很多人在使用tcp协议通讯的时候,并不清楚tcp是基于流的传输,当连续发送数据的时候,他们时常会认识tcp会丢包。其实不然,因为当他们使用的缓冲区足够大时,他们有可能会一次接收到两个甚至更多的数据包,而很多人往往会忽视这一点,只解析检查了第一个数据包,而已经接收的其他数据包却被忽略了。所以大家如果要作这类的网络编程的时候,必须要注意这一点。

粘包出现原因

简单得说,在流传输中出现,UDP不会出现粘包,因为它有消息边界

(1)发送端需要等缓冲区满才发送出去,造成粘包

发送方引起的粘包是由TCP协议本身造成的,TCP为提高传输效率,发送方往往要收集到足够多的数据后才发送一包数据。若连续几次发送的数据都很少,通常TCP会根据优化算法(Nagle算法)把这些数据合成一包后一次发送出去,这样接收方就收到了粘包数据。

(2)接收方不及时接收缓冲区的包,造成多个包接收

接收方引起的粘包是由于接收方用户进程不及时接收数据,从而导致粘包现象。这是因为接收方先把收到的数据放在系统接收缓冲区,用户进程从该缓冲区取数据,若下一包数据到达时前一包数据尚未被用户进程取走,则下一包数据放到系统接收缓冲区时就接到前一包数据之后,而用户进程根据预先设定的缓冲区大小从系统接收缓冲区取数据,这样就一次取到了多包数据。

(3)粘包后包自身的情况有两种

一种是粘在一起的包都是完整的数据包,另一种情况是粘在一起的包有不完整的包。

不是所有的粘包现象都需要处理,若传输的数据为不带结构的连续流数据(如文件传输),则不必把粘连的包分开(简称分包)。但在实际工程应用中,传输的数据一般为带结构的数据,这时就需要做分包处理。

在处理定长结构数据的粘包问题时,分包算法比较简单;在处理不定长结构数据的粘包问题时,分包算法就比较复杂。特别是粘在一起的包有不完整的包的粘包情况,由于一包数据内容被分在了两个连续的接收包中,处理起来难度较大。实际工程应用中应尽量避免出现粘包现象。

粘包解决方案

(1)对于发送方引起的粘包现象,用户可通过编程设置来避免,TCP提供了强制数据立即传送的操作指令push,TCP软件收到该操作指令后,就立即将本段数据发送出去,而不必等待发送缓冲区满(禁用Nagle算法);

(2)对于接收方引起的粘包,则可通过优化程序设计(报文头,报文尾,校验和,长度)、精简接收进程工作量、提高接收进程优先级等措施,使其及时接收数据,从而尽量避免出现粘包现象;

(3)由接收方控制,将一包数据按结构字段,人为控制分多次接收(比如一结构三字段分三次接受),然后合并,通过这种手段来避免粘包。

以上提到的三种措施,都有其不足之处。

(1)第一种编程设置方法虽然可以避免发送方引起的粘包,但它关闭了优化算法,降低了网络发送效率,影响应用程序的性能,一般不建议使用。

(2)第二种方法只能减少出现粘包的可能性,但并不能完全避免粘包,当发送频率较高时,或由于网络突发可能使某个时间段数据包到达接收方较快,接收方还是有可能来不及接收,从而导致粘包。

(3)第三种方法虽然避免了粘包,但应用程序的效率较低,对实时应用的场合不适合。

一种比较周全的对策是:接收方创建一预处理线程,对接收到的数据包进行预处理,将粘连的包分开。对这种方法我们进行了实验,证明是高效可行的。

第III部分 网络层相关

第IV部分 网络接口层相关

第V部分 Socket相关

什么是 socket?

socket 的原意是“插座”,在计算机通信领域,socket
被翻译为“套接字”,它是计算机之间进行通信的一种约定或一种方式。通过 socket
这种约定,一台计算机可以接收其他计算机的数据,也可以向其他计算机发送数据。

其算不上是个协议,应该是应用层与传输层间的一个抽象层,是个编程接口。

UNIX/Linux 中的 socket 是什么?

在UNIX/Linux系统中,为了统一对各种硬件的操作,简化接口,不同的硬件设备也都被看成一个文件(其中的所有都被看成是一个文件)。对这些文件的操作等同于对磁盘上普通文件的操作。

为了表示和区分已经打开的文件,UNIX/Linux会给每个文件分配一个ID,这个ID就是一个整数,被称为文件描述符(File Descriptor)。例如:

① 用 0 来表示标准输入文件(stdin),它对应的硬件设备就是键盘;

② 用 1 来表示标准输出文件(stdout),它对应的硬件设备就是显示器。

UNIX/Linux 程序在执行任何形式的 I/O操作时,都是在读取或者写入一个文件描述符。一个文件描述符只是一个和打开的文件相关联的整数,它的背后可能是一个硬盘上的普通文件、FIFO、管道、终端、键盘、显示器,甚至是一个网络连接。

所以网络连接也是一个文件,它也有文件描述符。我们可以通过 socket()函数来创建一个网络连接,或者说打开一个网络文件,socket()的返回值就是文件描述符。有了文件描述符,我们就可以使用普通的文件操作函数来传输数据了,例如:(socket的ID往往很长很奇怪)

① 用 read() 读取从远程计算机传来的数据;

② 用 write() 向远程计算机写入数据。

可见只要用 socket() 创建了连接,剩下的就是文件操作了。

WIndow 系统中的 socket 是什么?

Windows 也有类似“文件描述符”的概念,但通常被称为“文件句柄”。因此,本教程如果涉及
Windows 平台将使用“句柄”,如果涉及 Linux
平台则使用“描述符”。(句柄是一种指向指针的指针,每个程序在启动时都会分配句柄,用于存储对象在内存中的位置,其中存储的位置会随着操作系统的变换而不断改变,但是其自身位置保持)

与 UNIX/Linux 不同的是,Windows 会区分 socket 和文件,Windows 就把 socket
当做一个网络连接来对待,因此需要调用专门针对 socket
而设计的数据传输函数,针对普通文件的输入输出函数就无效了。

流格式套接字与数据报格式套接字

(1)流格式套接字(SOCK_STREAM)

流格式套接字(Stream
Sockets)也叫“面向连接的套接字”,在代码中使用SOCK_STREAM表示。SOCK_STREAM是一种可靠的、双向的通信数据流,数据可以准确无误地到达另一台计算机,如果损坏或丢失,可以重新发送。

SOCK_STREAM 有以下几个特征(使用了TCP协议):

① 数据在传输过程中不会消失;

② 数据是按照顺序传输的;

③ 数据的发送和接收不是同步的(有的教程也称“不存在数据边界”)。

(2)数据报格式套接字(SOCK_DGRAM)

数据报格式套接字(Datagram
Sockets)也叫“无连接的套接字”,在代码中使用SOCK_DGRAM表示。

计算机只管传输数据,不作数据校验,如果数据在传输中损坏,或者没有到达另一台计算机,是没有办法补救的。也就是说,数据错了就错了,无法重传。因为数据报套接字所做的校验工作少,所以在传输效率方面比流格式套接字要高。

可以将SOCK_DGRAM比喻成高速移动的摩托车快递,它有以下特征(使用了UDP协议):

① 强调快速传输而非传输顺序;

② 传输的数据可能丢失也可能损毁;

③ 限制每次传输的数据大小;

④ 数据的发送和接收是同步的(有的教程也称“存在数据边界”)。

Socket函数创建套接字

不管是 Windows 还是 Linux,都使用 socket() 函数来创建套接字。socket()
在两个平台下的参数是相同的,不同的是返回值。

Linux 中的一切都是文件,每个文件都有一个整数类型的文件描述符;socket
也是一个文件,也有文件描述符。使用 socket() 函数创建套接字以后,返回值就是一个
int 类型的文件描述符。

Windows 会区分 socket 和普通文件,它把 socket 当做一个网络连接来对待,调用
socket() 以后,返回值是 SOCKET 类型,用来表示一个套接字。

(1)LINUX下的socket()函数

在Linux下使用<sys/socket.h>头文件中socket()函数来创建套接字,原型为:

int socket(int af, int type, int protocol);

(1)af为地址族(Address Family),也就是IP地址类型,常用的有AF_INET 和 AF_INET6。AF 是“Address Family”的简写,INET是“Inetnet”的简写。AF_INET 表示 IPv4地址,例如 127.0.0.1;AF_INET6 表示 IPv6 地址,例如 1030::C9B4:FF12:48AA:1A2B。

大家需要记住127.0.0.1,它是一个特殊IP地址,表示本机地址,后面的教程会经常用到。

你也可以使用 PF 前缀,PF 是“Protocol Family”的简写,它和 AF
是一样的。例如,PF_INET 等价于 AF_INET,PF_INET6 等价于AF_INET6。

(2)type为数据传输方式 /
套接字类型,常用的有SOCK_STREAM(流格式套接字/面向连接的套接字) 和
SOCK_DGRAM(数据报套接字/无连接的套接字)。

(3) protocol 表示传输协议,常用的有 IPPROTO_TCP 和 IPPTOTO_UDP,分别表示 TCP
传输协议和 UDP 传输协议,其往往和数据传输方式对应。

第三个参数的意义:

正如大家所想,一般情况下有了 af 和 type
两个参数就可以创建套接字了,操作系统会自动推演出协议类型,除非遇到这样的情况:有两种不同的协议支持同一种地址类型和数据传输类型。如果我们不指明使用哪种协议,操作系统是没办法自动推演的。

(4)示例

TCP:int tcp_socket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);

如果使用 SOCK_DGRAM 传输方式,那么满足这两个条件的协议只有
UDP,因此可以这样来调用 socket() 函数:

UDP:int udp_socket = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);

上面两种情况都只有一种协议满足条件,可以将 protocol 的值设为
0,系统会自动推演出应该使用什么协议,如下所示:

int tcp_socket = socket(AF_INET, SOCK_STREAM, 0); //创建TCP套接字

int udp_socket = socket(AF_INET, SOCK_DGRAM, 0); //创建UDP套接字

(2)在Windows下创建socket

Windows 下也使用 socket() 函数来创建套接字,原型为:

SOCKET socket(int af, int type, int protocol);

除了返回值类型不同,其他都是相同的。Windows 不把套接字作为普通文件对待,而是返回
SOCKET 类型的句柄。请看下面的例子:

SOCKET sock = socket(AF_INET, SOCK_STREAM, 0); //创建TCP套接字

bind() 函数

socket()函数用来创建套接字,确定套接字的各种属性,然后服务器端要用 bind()
函数将套接字与特定的 IP 地址和端口绑定起来,只有这样,流经该 IP
地址和端口的数据才能交给套接字处理。

类似地,客户端也要用 connect()
函数建立连接。Socket为当前套接字,addr类内部存了当前的协议,IP地址与端口号,addrlen为当前socket的长度。

所以bind函数为绑定服务器socket与sockaddr信息的操作,connect是绑定客户端与socket与sockaddr信息的操作。服务器的绑定用于监听对应的IP与端口号,客户端用于连接对应的IP与端口号。

(1)bind() 函数的原型

int bind(int sock, struct sockaddr *addr, socklen_t addrlen); //Linux

int bind(SOCKET sock, const struct sockaddr *addr, int addrlen); //Windows

下面以 Linux 为例进行讲解,Windows 与此类似。

(2)sock含义

sock为socket文件描述符,addr 为 sockaddr 结构体变量的指针,addrlen 为 addr
变量的大小,可由 sizeof()计算得出。

下面的代码,将创建的套接字与IP地址 127.0.0.1、端口 1234 绑定:

① 创建套接字

int serv_sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);

② 创建sockaddr_in结构体变量

struct sockaddr_in serv_addr;

memset(&serv_addr, 0, sizeof(serv_addr)); //每个字节都用0填充

serv_addr.sin_family = AF_INET; //使用IPv4地址

serv_addr.sin_addr.s_addr = inet_addr(“127.0.0.1”); //具体的IP地址

serv_addr.sin_port = htons(1234); //端口

③ 将套接字和IP、端口绑定

bind(serv_sock, (struct sockaddr*)&serv_addr, sizeof(serv_addr));

这里我们使用 sockaddr_in 结构体,然后再强制转换为 sockaddr 类型。

(3)sockaddr_in 结构体

sockaddr_in 结构体的成员变量如下:

struct sockaddr_in

{

sa_family_t sin_family; //地址族(Address Family),也就是地址类型

uint16_t sin_port; //16位的端口号

struct in_addr sin_addr; //32位IP地址

char sin_zero[8]; //不使用,一般用0填充

};

(1)sin_family 和 socket() 的第一个参数的含义相同,取值也要保持一致。

(2)sin_prot 为端口号。uint16_t 的长度为两个字节,理论上端口号的取值范围为 0-65536,但 0-1023 的端口一般由系统分配给特定的服务程序,例如 Web服务的端口号为 80,FTP 服务的端口号为 21,所以我们的程序要尽量在 1024~65536之间分配端口号。端口号需要用 htons() 函数转换。

(3)sin_addr 是 struct in_addr 结构体类型的变量,下面会详细讲解。

(4)sin_zero[8] 是多余的8个字节,没有用,一般使用 memset() 函数填充为0。上面的代码中,先用 memset() 将结构体的全部字节填充为0,再给前3个成员赋值,剩下的 sin_zero 自然就是 0 了。

(4)in_addr 结构体

sockaddr_in 的第3个成员是 in_addr类型的结构体,该结构体只包含一个成员,如下所示:

struct in_addr { in_addr_t s_addr; //32位的IP地址 };

in_addr_t 在头文件 <netinet/in.h> 中定义,等价于 unsigned long,长度为4个字节。也就是说,s_addr 是一个整数,而IP地址是一个字符串,所以需要inet_addr() 函数进行转换,例如:

unsigned long ip = inet_addr(“127.0.0.1”); printf("%ld\n", ip);

运行结果:16777343

(5)第二个参数的类型为sockaddr,而代码中却使用 sockaddr_in

bind() 第二个参数的类型为sockaddr,而代码中却使用 sockaddr_in,然后再强制转换为
sockaddr,这是为什么呢?

sockaddr 结构体的定义如下:

struct sockaddr

{

sa_family_t sin_family; //地址族(Address Family),也就是地址类型

char sa_data[14]; //IP地址和端口号

};

sockaddr 和 sockaddr_in的长度相同,都是16字节,只是将IP地址和端口号合并到一起,用一个成员 sa_data表示。要想给 sa_data赋值,必须同时指明IP地址和端口号,例如”127.0.0.1:80“,遗憾的是,没有相关函数将这个字符串转换成需要的形式,也就很难给sockaddr 类型的变量赋值,所以使用 sockaddr_in来代替。这两个结构体的长度相同,强制转换类型时不会丢失字节,也没有多余的字节。

可以认为,sockaddr 是一种通用的结构体,可以用来保存多种类型的IP地址和端口号,而sockaddr_in 是专门用来保存 IPv4 地址的结构体。另外还有 sockaddr_in6,用来保存IPv6 地址,它的定义如下:

struct sockaddr_in6

{

sa_family_t sin6_family; //(2)地址类型,取值为AF_INET6

in_port_t sin6_port; //(2)16位端口号

uint32_t sin6_flowinfo; //(4)IPv6流信息

struct in6_addr sin6_addr; //(4)具体的IPv6地址

uint32_t sin6_scope_id; //(4)接口范围ID

};

正是由于通用结构体 sockaddr使用不便,才针对不同的地址类型定义了不同的结构体。但这也正是bind函数参数中不同的原因,首先是针对当前socket的sock,再是通用格式sockaddr,再是当前socket的字节数sizeof。

connect() 函数

connect() 函数用来建立连接,它的原型为:

int connect(int sock, struct sockaddr *serv_addr, socklen_t addrlen); //Linux

int connect(SOCKET sock, const struct sockaddr *serv_addr, int addrlen);
//Windows

各个参数的说明和 bind() 相同,不再赘述。

listen() 函数

对于服务器端程序,使用 bind() 绑定套接字后,还需要使用 listen()
函数让套接字进入被动监听状态,再调用 accept() 函数,就可以随时响应客户端请求。

(1)listen原型

通过 listen() 函数可以让套接字进入被动监听状态,它的原型为:

int listen(int sock, int backlog); //Linux

int listen(SOCKET sock, int backlog); //Windows

sock 为需要进入监听状态的套接字,backlog 为请求队列的最大长度。

所谓被动监听,是指当没有客户端请求时,套接字处于“睡眠”状态,只有当接收到客户端请求时,套接字才会被“唤醒”来响应请求。

(2)请求队列

当套接字正在处理客户端请求时,如果有新的请求进来,套接字是没法处理的,只能把它放进缓冲区,待当前请求处理完毕后,再从缓冲区中读取出来处理。如果不断有新的请求进来,它们就按照先后顺序在缓冲区中排队,直到缓冲区满。这个缓冲区,就称为请求队列(Request Queue)。

缓冲区的长度(能存放多少个客户端请求)可以通过 listen() 函数的 backlog参数指定,但究竟为多少并没有什么标准,可以根据你的需求来定,并发量小的话可以是10或者20。

如果将 backlog 的值设置为SOMAXCONN,就由系统来决定请求队列长度,这个值一般比较大,可能是几百,或者更多。

当请求队列满时,就不再接收新的请求,对于Linux客户端会收到 ECONNREFUSED错误,对于Windows客户端会收到WSAECONNREFUSED 错误。

**注意:**listen() 只是让套接字处于监听状态,并没有接收请求。接收请求需要使用
accept() 函数。

accept() 函数

当套接字处于监听状态时,可以通过 accept() 函数来接收客户端请求。

(1)accept函数原型

int clntSock = accept(int sock, struct sockaddr *addr, socklen_t *addrlen);
//Linux

SOCKET clntSock = accept(SOCKET sock, struct sockaddr *addr, int *addrlen);
//Windows

它的参数与 listen() 和 connect() 是相同的:sock 为服务器端套接字,addr 为
sockaddr_in 结构体变量,addrlen 为参数 addr 的长度,可由 sizeof() 求得。

(2)含义诠释

accept() 返回一个新的套接字来和客户端通信,addr 保存了客户端的IP地址和端口号,而sock是服务器端的套接字,大家注意区分。后面和客户端通信时,要使用这个新生成的套接字clntSock,而不是原来服务器端的套接字。(通常为了实现多个客户端连接,应该是每连一次就再生成一个新的套接字用于下一个连接。每个生成的套接字clntSock,都用于与当前连接的客户端通信)

最后需要说明的是:listen()只是让套接字进入监听状态,并没有真正接收客户端请求,listen()后面的代码会继续执行,直到遇到accept()。accept()会阻塞程序执行(后面代码不能被执行),直到有新的请求到来。

Linux下数据的接收和发送(send与write)

Linux 不区分套接字文件和普通文件,使用 write() 可以向套接字中写入数据,使用read() 可以从套接字中读取数据。

前面我们说过,两台计算机之间的通信相当于两个套接字之间的通信,在服务器端用write() 向套接字写入数据,客户端就能收到,然后再使用 read()从套接字中读取出来,就完成了一次通信。

(1)write() 的原型

ssize_t write(int fd, const void *buf, size_t nbytes);

fd 为要写入的文件的描述符,buf 为要写入的数据的缓冲区地址,nbytes为要写入的数据的字节数。

size_t 是通过 typedef 声明的 unsigned int 类型;ssize_t 在 “size_t"前面加了一个"s”,代表 signed,即 ssize_t 是通过 typedef 声明的 signed int 类型。

write() 函数会将缓冲区 buf 中的 nbytes 个字节写入文件fd,成功则返回写入的字节数,失败则返回 -1。

(2)read() 的原型为:

ssize_t read(int fd, void *buf, size_t nbytes);

fd 为要读取的文件的描述符,buf 为要接收数据的缓冲区地址,nbytes为要读取的数据的字节数。

read() 函数会从 fd 文件中读取 nbytes 个字节并保存到缓冲区buf,成功则返回读取到的字节数(但遇到文件结尾即FIN则返回0),失败则返回 -1。

Windows下数据的接收和发送(send与recv)

Windows 和 Linux 不同,Windows区分普通文件和套接字,并定义了专门的接收和发送的函数。

(1)从服务器端发送数据使用 send() 函数,它的原型为:

int send(SOCKET sock, const char *buf, int len, int flags);

sock为要发送数据的套接字,buf为要发送的数据的缓冲区地址(即应用程序的缓冲区),len为要送的数据的字节数,flags为发送数据时的选项。

返回值和前三个参数不再赘述,最后的 flags 参数一般设置为 0 或NULL,初学者不必深究。

至于如何实现循环接受,在一个循环中,或者用定时器不断的调用recv函数,来获取某个socket的数据即可。记得每处理一次数据清空一次缓冲区。

(2)在客户端接收数据使用 recv() 函数,它的原型为:

int recv(SOCKET sock, char *buf, int len, int flags);

socket缓冲区

每个 socket 被创建后,都会分配两个缓冲区,输入缓冲区和输出缓冲区。

(1)缓冲区作用

write()/send()并不立即向网络中传输数据,而是先将数据写入缓冲区中,再由TCP协议将数据从缓冲区发送到目标机器。一旦将数据写入到缓冲区,函数就可以成功返回,不管它们有没有到达目标机器,也不管它们何时被发送到网络,这些都是TCP协议负责的事情。

TCP协议独立于 write()/send()函数,数据有可能刚被写入缓冲区就发送到网络,也可能在缓冲区中不断积压,多次写入的数据被一次性发送到网络,这取决于当时的网络情况、当前线程是否空闲等诸多因素,不由程序员控制。

read()/recv()函数也是如此,也从输入缓冲区中读取数据,而不是直接从网络中读取。

(2)当发送端速度很快时

如果发送端特别快的时候,缓冲区很快就被填满(socket默认的是1024×8=8192字节),这时候我们应该根据情况设置缓冲区的大小,可以通过setsockopt函数实现。通过getsockopt函数查看缓冲区的大小。

getsockopt(s, SOL_SOCKET, SO_RCVBUF, &rcv_size, &optlen);

setsockopt(s,SOL_SOCKET,SO_RCVBUF, (char *)&rcv_size, optlen);

如果应用进程一直没有读取,buffer满了之后,发生的动作是:通知对端TCP协议中的窗口关闭。这个便是滑动窗口的实现。保证TCP套接口接收缓冲区不会溢出,从而保证了TCP是可靠传输。因为对方不允许发出超过所通告窗口大小的数据。这就是TCP的流量控制,如果对方无视窗口大小而发出了超过窗口大小的数据,则接收方TCP将丢弃它。

(3)UDP的接收缓冲区

每个UDP Socket都有一个接收缓冲区,没有发送缓冲区。有数据就直接发送,不管对方是否能够正确接收,也不管对端接收缓冲区是否已经满了。

UDP是没有流量控制的;快的发送者可以很容易地就淹没慢的接收者,导致接收方的UDP丢弃数据报。

(4)I/O缓冲区特性

① I/O缓冲区在每个TCP套接字中单独存在;

② I/O缓冲区在创建套接字时自动生成;

③ 即使关闭套接字也会继续传送输出缓冲区中遗留的数据;

④ 关闭套接字将丢失输入缓冲区中的数据。

输入输出缓冲区的默认大小一般都是8K,可以通过getsockopt()函数获取

unsigned optVal; int optLen = sizeof(int);

getsockopt(servSock, SOL_SOCKET, SO_SNDBUF, (char*)&optVal, &optLen);

printf(“Buffer length: %d\n”, optVal);

运行结果:Buffer length: 8192

socket的I/O缓冲区模型

一个输入操作包括两个阶段:等待数据准备好;从内核向进程复制数据。

对于一个套接字上的输入操作,第一步通常涉及等待数据从网络中到达。当所等待数据到达时,它被复制到内核中的某个缓冲区。第二步就是把数据从内核缓冲区复制到应用进程缓冲区(即复制到代码里的buf)。

Unix / Linux有五种 I/O 模型:阻塞式 I/O、非阻塞式 I/O、I/O 复用(select 和 poll)、信号驱动式 I/O(SIGIO)与异步 I/O(AIO)。

(1)阻塞式 I/O

当前调用的socket所在的应用进程被阻塞,直到数据从内核缓冲区复制到应用进程缓冲区中才返回。

应该注意到,在阻塞的过程中,除了当前的那个进程,其它应用进程还可以执行,因此阻塞不意味着整个操作系统都被阻塞。因为其它应用进程还可以执行,所以不消耗CPU 时间,这种模型的 CPU 利用率会比较高。

比如recv()函数用于接收 Socket 传来的数据,并复制到应用进程的缓冲区 buf中。这里把 recv() 当成系统调用,用于把数据准备好。

① 当使用 write()/send() 发送数据时:

  1. 首先会检查缓冲区,如果缓冲区的可用空间长度小于要发送的数据,那么
    write()/send()
    会被阻塞(暂停执行),直到缓冲区中的数据被发送到目标机器,腾出足够的空间,才唤醒
    write()/send() 函数继续写入数据。

  2. 如果TCP协议正在向网络发送数据,那么输出缓冲区会被锁定,不允许写入,write()/send()
    也会被阻塞,直到数据发送完毕缓冲区解锁,write()/send() 才会被唤醒。

  3. 如果要写入的数据大于缓冲区的最大长度,那么将分批写入。

  4. 直到所有数据被写入缓冲区 write()/send() 才能返回。

② 当使用 read()/recv() 读取数据时:

  1. 首先会检查缓冲区,如果缓冲区中有数据,那么就读取,否则函数会被阻塞,直到网络上有数据到来。

  2. 如果要读取的数据长度小于缓冲区中的数据长度,那么就不能一次性将缓冲区中的所有数据读出,剩余数据将不断积压,直到有read()/recv() 函数再次读取。

  3. 直到读取到数据后 read()/recv() 函数才会返回,否则就一直被阻塞。

这就是TCP套接字的阻塞模式。所谓阻塞,就是上一步动作没有完成,下一步动作将暂停,直到上一步动作完成后才能继续,以保持同步性。

(2)非阻塞式 I/O

应用进程执行系统调用之后,内核返回一个错误码。应用进程可以继续执行,但是需要不断的执行系统调用来获知I/O是否完成,这种方式称为轮询(polling)。

由于 CPU 要处理更多的系统调用,因此这种模型的 CPU 利用率比较低。

(3)I/O 复用(其实现方式是三种形式,而非send/read)

多路复用的本质是同步非阻塞I/O,多路复用的优势并不是单个连接处理的更快,而是在于能处理更多的连接,即1个进程处理多个连接事件。

使用 select 或者 poll等待数据,并且可以等待多个套接字中的任何一个变为可读。这一过程会被阻塞,当某一个套接字可读时返回,之后再使用recvfrom 把数据从内核复制到进程中。它可以让单个进程具有处理多个 I/O事件的能力。又被称为 Event Driven I/O,即事件驱动 I/O。

如果一个 Web 服务器没有 I/O 复用,那么每一个 Socket连接都需要创建一个线程去处理。如果同时有几万个连接,那么就需要创建相同数量的线程。相比于多进程和多线程技术,I/O复用不需要进程线程创建和切换的开销,系统开销更小。

可以理解为,I/O多路复用技术通过把多个I/O的阻塞复用到同一个select阻塞上,一个进程监视多个描述符socket(这个进程就是上图中中间的那根黑线这样就不需要),一旦某个描述符socket就位,就在这个进程里的同一个线程里执行真正操作,也可以启动线程方式执行真正操作(比如使用线程池)。

真正的操作是指:这个进程能够调用服务器代码段中对应这个描述符socket的read或者write函数。

(4)信号驱动 I/O

应用进程使用 sigaction系统调用,内核立即返回,应用进程可以继续执行,也就是说等待数据阶段应用进程是非阻塞的。内核在数据到达时向应用进程发送SIGIO 信号,应用进程收到之后在信号处理程序中调用 recvfrom将数据从内核复制到应用进程中。

相比于非阻塞式 I/O 的轮询方式,信号驱动 I/O 的 CPU 利用率更高。

(5)异步 I/O

应用进程执行 aio_read系统调用会立即返回,应用进程可以继续执行,不会被阻塞,内核会在所有操作完成之后向应用进程发送信号。

异步 I/O 与信号驱动 I/O 的区别在于,异步 I/O 的信号是通知应用进程 I/O完成,而信号驱动 I/O 的信号是通知应用进程可以开始 I/O。

(6)五大 I/O 模型比较

同步I/O:将数据从内核缓冲区复制到应用进程缓冲区的阶段(第二阶段),应用进程会阻塞。

异步 I/O:第二阶段应用进程不会阻塞。

同步 I/O 包括阻塞式 I/O、非阻塞式 I/O、I/O 复用和信号驱动 I/O,它们的主要区别在第一个阶段。而非阻塞式 I/O 、信号驱动 I/O 和异步 I/O在第一阶段不会阻塞。I/O复用的特色在于一个进程处理多个I/O,而阻塞、非阻塞、信号驱动以及异步I/O都是一个进程对应一个I/O的形式。

I/O复用的三种形式
(1)select(三种事件类型数组,直接修改socket)

int select(int n, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

可以监听三类文件描述符,writefds(写状态),readfds(读状态),exceptfds(异常状态)。fd_set使用数组实现,数组大小使用 FD_SETSIZE 定义。timeout 为超时参数,调用 select会一直阻塞直到有描述符的事件到达或者等待的时间超过 timeout。

我们在select函数中告诉内核需要监听的不同状态的文件描述符以及能接受的超时时间,函数会返回所有状态下就绪的描述符的个数(成功调用返回结果大于0,出错返回结果为 -1,超时返回结果为0),但是并不会告诉你哪些就绪了,所以只能通过遍历所有监视的fd_set,来找到就绪的描述符

缺点1: select 会修改传入的描述符数组,而 poll
不会(当select()返回后,该数组中就绪的文件描述符便会被内核修改标志位(变成ready));

缺点2: select 的描述符类型使用数组实现,FD_SETSIZE 大小默认为
1024,因此默认只能监听 1024 个描述符。如果要监听更多描述符的话,需要修改
FD_SETSIZE 之后重新编译;

缺点3: 每次调用select,都需要把待监控的fd集合从用户态拷贝到内核态,当fd很大时,开销很大(速度慢的原因1);

缺点4: 每次调用select,都需要轮询一遍所有的fd,查看就绪状态,并且一旦返回0以上的值,需要再遍历一遍来查找哪些就绪了(速度慢的原因2);

缺点5: select并非线程安全,如果一个线程对某个描述符调用了 select 或者
poll,另一个线程关闭了该描述符,会导致调用结果不确定。

(2)poll(链表/数组的pollfd结构体)

与select轮询所有待监听的描述符机制类似,但poll使用pollfd结构表示要监听的描述符。

int poll(struct pollfd *fds, nfds_t nfds, int timeout);

struct pollfd {short events; short revents;};

对于这个结构体,实现方式往往为使用数组或者链表来存多个结构体。

pollfd结构包括了events(要监听的事件)和revents(实际发生的事件)。而且也需要在函数返回后遍历pollfd来获取就绪的描述符。(说穿了就是修复了select的1024上限问题以及传入数组修改问题)

相对于select,poll已不存在最大文件描述符限制,且poll提供了更多的事件类型,并且对描述符的重复利用上比 select 高。

缺点1: 每次调用poll,都需要把待监控的fd集合从用户态拷贝到内核态,当fd很大时,开销很大(速度慢的原因1);

缺点2: 每次调用poll,都需要轮询一遍所有的fd,查看就绪状态,并且一旦返回0以上的值,需要再遍历一遍来查找哪些就绪了(速度慢的原因2);

缺点3: poll并非线程安全,如果一个线程对某个描述符调用了 select 或者
poll,另一个线程关闭了该描述符,会导致调用结果不确定。

可见select与poll都不是线程安全的,这就意味着,不管服务器有多强悍,你也只能在一个线程里面处理一组I/O流。所以经典的I/O复用就是一个线程在那处理各种I/O流。同时,几乎所有的系统都支持select,但是只有比较新的系统支持 poll。

(3)epoll(注册节点红黑树,就绪链表)

创建函数: int epoll_create(int size);

创建epoll句柄,会占用一个fd值,使用完成以后,要关闭。

注册函数: int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

提前注册好要监听的事件类型,监听事件(文件可写,可读,挂断,错误)。不用每次都去轮询一遍注册的fd,而只是通过epoll_ctl把所有fd拷贝进内核一次,并为每一个fd指定一个回调函数。(此时所有已注册的描述符都维护在一颗红黑树上面)

当就绪,会调用回调函数,把就绪的文件描述符和事件类型加入一个就绪链表,并拷贝到用户空间内存,应用程序不用亲自从内核拷贝。类似于在信号中注册所有的发送者和接收者,或者Task中注册所有任务的handler。

**查询函数:**int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);

监听epoll_ctl中注册的文件描述符和事件,在就绪链表中查看有没有就绪的fd,不用去遍历所有fd。相当于直接去遍历结果集合,可以直接得到哪些就绪并得知其事件类型,而且百分百命中,不用每次都去重新查找所有的fd,用户索引文件的事件复杂度为O(1)。

从上面的描述可以看出,epoll只需要将描述符从进程缓冲区向内核缓冲区拷贝一次,并且进程不需要通过轮询来获得事件完成的描述符。

缺点1: epoll 仅适用于 Linux OS。

优点1: epoll 比 select 和 poll 更加灵活而且没有描述符数量限制。

优点2: epoll
对多线程编程更有友好,一个线程调用了epoll_wait()而另一个线程关闭了同一个描述符也不会产生像
select 和poll 的不确定情况。

(4)epoll工作模式

epoll 的描述符事件有两种触发模式:LT(level trigger)和 ET(edge trigger)。

**LT 模式:**当 epoll_wait()检测到描述符事件到达时,将此事件通知进程,进程可以不立即处理该事件,下次调用epoll_wait()会再次通知进程。是默认的一种模式,并且同时支持 Blocking 和 No-Blocking。

**ET 模式:**和 LT 模式不同的是,通知之后进程必须立即处理事件,下次再调用epoll_wait() 时不会再得到事件到达的通知。很大程度上减少了 epoll 事件被重复触发的次数,因此效率要比 LT 模式高。只支持 No-Blocking,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。

(5)三种方式的应用场景

很容易产生一种错觉认为只要用 epoll 就可以了,select 和 poll
都已经过时了,其实它们都有各自的使用场景。

① select 应用场景:

select 的 timeout 参数精度为 1ns,而 poll 和 epoll 为 1ms,因此 select
更加适用于实时性要求比较高的场景,比如核反应堆的控制。

select 可移植性更好,几乎被所有主流平台所支持。

② poll 应用场景:(应该尽可能的使用poll)

poll 没有最大描述符数量的限制,如果平台支持并且对实时性要求不高,应该使用 poll
而不是 select。

③ epoll 应用场景:

只需要运行在 Linux平台上,有大量的描述符需要同时轮询,并且这些连接最好是长连接。

需要同时监控小于 1000 个描述符,就没有必要使用epoll,因为这个应用场景下并不能体现 epoll 的优势。

需要监控的描述符状态变化多,而且都是非常短暂的,也没有必要使用 epoll。因为 epoll中的所有描述符都存储在内核中,造成每次需要对描述符的状态改变都需要通过epoll_ctl() 进行系统调用,频繁系统调用降低效率。并且epoll的描述符存储在内核,不容易调试。

优雅地断开TCP连接

调用 close()/closesocket()函数意味着完全断开连接,即不能发送数据也不能接收数据,这种“生硬”的方式有时候会显得不太“优雅”。

上图演示了两台正在进行双向通信的主机。主机A发送完数据后,单方面调用close()/closesocket()断开连接,之后主机A、B都不能再接受对方传输的数据。实际上,是完全无法调用与数据收发有关的函数。

一般情况下这不会有问题,但有些特殊时刻,需要只断开一条数据传输通道,而保留另一条。使用
shutdown() 函数可以达到这个目的,它的原型为:

int shutdown(int sock, int howto); //Linux

int shutdown(SOCKET s, int howto); //Windows

sock 为需要断开的套接字,howto 为断开方式。

(1)howto 在 Linux 下有以下取值

SHUT_RD:断开输入流。套接字无法接收数据(即使输入缓冲区收到数据也被抹去),无法调用输入相关函数。

SHUT_WR:断开输出流。套接字无法发送数据,但如果输出缓冲区中还有未传输的数据,则将传递到目标主机。

SHUT_RDWR:同时断开 I/O 流。相当于分两次调用 shutdown(),其中一次以 SHUT_RD
为参数,另一次以 SHUT_WR 为参数。

(2)howto 在 Windows 下有以下取值

SD_RECEIVE:关闭接收操作,也就是断开输入流。

SD_SEND:关闭发送操作,也就是断开输出流。

SD_BOTH:同时关闭接收和发送操作。

至于什么时候需要调用 shutdown() 函数,下节我们会以文件传输为例进行讲解。

(3)close()/closesocket()和shutdown()的区别(发送FIN包的时机不同)

确切地说,close() / closesocket()用来关闭套接字,将套接字描述符(或句柄)从内存清除,之后再也不能使用该套接字,与C语言中的fclose()类似。应用程序关闭套接字后,与该套接字相关的连接和缓存也失去了意义,TCP协议会自动触发关闭连接的操作。

shutdown() 用来关闭连接,而不是套接字,不管调用多少次shutdown(),套接字依然存在,直到调用 close() / closesocket()将套接字从内存清除。

调用 close()/closesocket() 关闭套接字时,或调用 shutdown()关闭输出流时,都会向对方发送 FIN 包。FIN 包表示数据传输完毕,计算机收到 FIN包就知道不会再有数据传送过来了。

默认情况下,close()/closesocket()会立即向网络中发送FIN包,不管输出缓冲区中是否还有数据,而shutdown()会等输出缓冲区中的数据传输完毕再发送FIN包。也就意味着,调用close()/closesocket() 将丢失输出缓冲区中的数据,而调用 shutdown()不会(因为FIN包发出的意思就是指当前不会再发data了)。

大数据(如文件)的传输实现

一个例子:client 从 server 下载一个文件并保存到本地。

编写这个程序需要注意两个问题:

(1)文件大小不确定

有可能比缓冲区大很多,调用一次 write()/send()函数不能完成文件内容的发送。接收数据时也会遇到同样的情况。

要解决这个问题,可以使用 while 循环,例如:

//Server 代码

int nCount;

while( (nCount = fread(buffer, 1, BUF_SIZE, fp)) > 0 )

{

send(sock, buffer, nCount, 0);

}

//Client 代码

int nCount;

while( (nCount = recv(clntSock, buffer, BUF_SIZE, 0)) > 0 )

{

fwrite(buffer, nCount, 1, fp);

}

对于 Server 端的代码,当读取到文件末尾,fread() 会返回 0,结束循环。

对于 Client 端代码,有一个关键的问题,就是文件传输完毕后让 recv() 返回 0,结束while 循环。因为读取完缓冲区中的数据 recv() 并不会返回0,而是被阻塞,直到缓冲区中再次有数据(也就是最后一次recv会阻塞,因为默认是阻塞式I/O,而此时recv缓冲区没有数据,所以最后一次recv阻塞)。

(2)Client 端如何判断文件接收完毕(何时结束 while 循环)

最简单的结束 while 循环的方法当然是文件接收完毕后让 recv() 函数返回0,那么,如何让 recv() 返回 0 呢?recv() 返回 0的唯一时机就是收到FIN包时(即接受到文件尾部时)。

FIN 包表示数据传输完毕,计算机收到 FIN包后就知道对方不会再向自己传输数据,当调用 read()/recv()函数时,如果缓冲区中没有数据,就会返回 0,表示读到了”socket文件的末尾“。

这里我们调用 shutdown() 来发送FIN包:server 端直接调用 close()/closesocket()会使输出缓冲区中的数据失效,文件内容很有可能没有传输完毕连接就断开了,而调用shutdown() 会等待输出缓冲区中的数据传输完毕。

(3)总结

对于程序员来说,自己调用close/closesocket函数的意思是立即发送FIN包,丢弃自己write/send缓冲区中的所有数据。此时下面的操作都交给了TCP层的四次挥手去处理,大可不必管。而调用shutdown函数则类似于QT中的disconnectFromHost,在缓存区中的数据发送完成后再发送FIN包。而作为接收数据的那方,当其read/recv操作接收到FIN后退出循环,即可调用自己的close/closesocket或shutdown函数来发送自己的FIN包了。

稳健点,可以在首先调用close/closesocket或shutdown函数的一端,在这些代码的后面加上read/recv这样的语句来确保对另一端FIN包的接收工作。

四、操作系统

第I部分 进程管理

进程与线程相关

进程的同步、互斥与通信

在多道程序设计系统中,同一时刻可能有许多进程,这些进程之间存在两种基本关系:竞争关系和协作关系。进程的互斥、同步、通信都是基于这两种基本关系而存在的。

① 为了解决进程间竞争关系(间接制约关系)而引入进程互斥;

② 为了解决进程间松散的协作关系(直接制约关系)而引入进程同步;

③ 为了解决进程间紧密的协作关系而引入进程通信。

并发进程之间的交互必须满足两个基本要求:同步和通信。(同步与互斥往往用于体现对资源的竞争关系与协作关系,而通信往往用于不同进程之间的信息交换关系上)

进程竞争资源时要实施互斥,互斥是一种特殊的同步,实质上需要解决好进程同步问题,而进程同步是一种进程通信。通过修改信号量,进程之间可建立起联系,相互协调运行和协同工作。但是信号量与PV操作只能传递信号,没有传递数据的能力。

有些情况下进程之间交换的信息量虽很少,例如,仅仅交换某个状态信息,但很多情况下进程之间需要交换大批数据,例如,传送一批信息或整个文件,这可以通过一种新的通信机制来完成,进程之间互相交换信息的工作称之为进程通信IPC(InterProcess
Communication)(主要是指大量数据的交换)。

进程交互的基本概念明晰

进程间的交互有三种程度:(其实个人理解是1和2可以合并,即自己完成后不会直接告诉其他进程,而是释放自己拥有的资源;而3是在完成后会即时的通知其他进程)

(1)进程之间相互不知道对方的存在(互不相干的进程间对资源的竞争)

竞争进程面临三个控制问题:假如多个进程要访问一个不可共享的资源,比如一台老式打印机。

① 互斥操作,是对于控制那个不可共享资源的过程的泛称,往往都是需要类似于抽象函数entercritical(Ra)与exitcritical(Ra)的操作的工具(函数的参数可以为一个变量,一个文件或其他共享对象),来实现对临界区的进入与退出控制,以及对临界资源Ra的限制;

② 临界资源,即那个不可共享资源的学名;

③ 临界区,指的是使用临界资源的那部分程序(不同进程中对应相同临界资源的临界区互不相同)(一次只允许有一个程序在临界区中运行)。

同时由于互斥也存在两个额外的控制问题:

① 死锁:等一个等不到的资源;

② 饥饿:一直都等不到要的资源;

(2)进程间接知道对方的存在(共享I/O缓存区之类的对象,合作行为)

这种方式往往指使用的存储在资源(设备或存储器)中,实现的方式同样与抽象函数entercritical(Ra)与exitcritical(Ra)类似,但是可以用读/写方式来访问数据,其同样也存在死锁与互斥问题,并存在数据一致性的问题。

(3)进程直接知道对方的存在(通过彼此ID互相通信,合作行为)

通信可由各类型的消息组成,发送消息与接收消息的原语由程序设计语言提供,或由操作系统内核提供,不涉及到资源的交互,所以只存在某个进程一直没有被通信到的饥饿情况。

互斥方法的实现分类

(1)软件方式:让并发执行的进程承担互斥操作责任,这类进程需要与另一进程合作,而不需要程序设计语言或操作系统辅助支持。其会增加开销并存在一定的缺陷;

(2)专用机器指令:减小开销,但难以成为通用方案;

(3)操作系统提供:使用操作系统或者程序设计语言中提供的某种级别的支持,属于目前使用最多也是最好用的。

互斥实现中的操作系统提供方法

多个进程可以通过操作系统定义的简单的信号来进行合作。

(1)计数信号量 / 一般信号量(Semaphore)

用于在进程间传递信号的一个整数值,一共有初始化、递减和递增三种操作。如果要通过信号量s传送信号,进程须执行原语semSignal(s),可简称为signal();如果要通过信号量s接受信号,进程须执行原语semWait(s),可简称为wait()。

一般把信号量视为一个值为整数的变量,其定义操作如下:

① 一个信号量可以初始化成非负数;

② wait()操作使操作量减1。若值为负数,则执行阻塞wait(),否则进程继续执行,即P操作;

③ signal()操作使信号量加1。若值小于等于零,则被wait()操作阻塞的进程将会被解决阻塞,即V操作。

而根据这个信号量的值可以发现,当信号量(>=0)时,其表示的是不被阻塞的进程数,当其(<0)时,其表示的是阻塞在等待队列中的进程数。

其三大核心结论如下:

① 通常,在对信号量减1之前,无法提前知道该信号量是否会被阻塞;

② 当进程对一个信号量加1之后,会唤醒另一个进程,两个进程并发运行。而在一个单处理器系统中,同样无法知道哪个进程会立即继续运行;

③ 向信号量发出信号后,不需要知道是否有另一个进程正在等待,被解除阻塞的进程数要么没有,要么是1。

(2)二元信号量

只取0和1值的信号量。其核心特性如下:

① 二元信号量可以初始化为0或1;

② wait()操作检查信号量的值,若值为0,则进程执行wait()时就会受阻。若值为1,则将值改为0,并继续执行该进程;

③ signal()操作检查是否有任何进程在该信号量上受阻,则通过signal()操作,受阻的进程会被唤醒;若没有进程受阻,则值设置为1。

(3)互斥锁mutex

一个编制标志位,与二元信号量往往没有太大区别,可以理解为对二元信号量的一个包装。传统的定义区别是,为互斥量加锁与解锁的进程必须为同一个进程,但是二元信号量可以实现在一个进程中加锁,在另一个进程中解锁的操作。

(4)强信号量与弱信号量辨析

无论是一般信号量还是二元信号量,都需要使用队列来保存于信号量上等待的进程,采用先进先出FIFO的队列移出策略定义的信号量,称为强信号量(更为方便,且是操作系统提供的典型形式)。而没有规定移出顺序策略的信号量,称为弱信号量(可以理解为每次从队列中随机取一个出来)。

管程的引入

信号量的缺点:PV操作由于多进程的互用性,其代码往往分布在整个程序中,很难从信号量上的这些操作看出所产生的整体效果。

而管程是一种程序设计语言结构,它提供的功能与信号量相同,但更易于控制,在多种程序设计语言中都得到了实现。

(1)使用信号的管程

管程是由一个或多个过程、一个初始化序列和局部数据组成的软件模块,其主要特点如下:

① 局部数据变量只能通过管程的过程访问,任何外部过程都不能访问;

② 一个进程通过调用管程的一个过程进入管程;

③ 在任何时候,只能有一个进程在管程中执行,调用管程的任何其他进程都被阻塞,以等待管程可用。

管程内部使用条件变量来支持同步,这些条件变量包含在管程中,并且只有在管程中才能被访问。其中通过两个函数来操作条件变量:

① cwait©:调用进程的执行在条件c上堵塞,管程现在可被另一个进程调用;

② csignal©:恢复执行在cwait之后因某些条件而被阻塞的进程,若有多个这样的进程,选择其中一个,若没有这样的进程则什么也不做。

当某个进程调用管程中的某个过程时,其将会进入管程的入口等待队列。但是管程中的进程始终只有一个,其可能会由于某个条件x未完成而将自己暂时阻塞在条件x的阻塞等待队列中,此时其会调用函数cwait(x),使管程可以被进程队列的下一个进程使用。当管程中执行的进程发现条件x变化时,调用函数csignal(x),通知相应的条件队列,其条件已改变,立即运行条件队列中的进程。此时原本运行在管程中的进程(即发出csignal的管程)需要立即退出管程,或者阻塞在管程上

此时将条件队列中的进程移动至管程的等待队列。

这种管程的优点在于,所有的同步机制都被限制在管程内部,因此不但易于验证同步的正确性,而且易于检测出错误。此外,若一个管程被正确地编写,那么所有进程对受保护资源的访问都是正确的;而对于信号量,只有当所有访问资源的进程被正确地编写,资源访问才是正确的。

(2)使用通知和广播的管程

对上述管程进行了优化,其仍然会发送csignal(x)来通知条件x的条件队列,但是发射信号的进程继续执行。通知的结果是在将来合适且处理器可用时恢复位于条件队列头的进程。

消息传递

进程交互时,必须满足两个基本要求:同步和通信,为实施互斥,进程间需要同步;为实现合作,进程间需要交换信息。消息传递的实际功能以一对原语的形式提供:

send(destination, message)与receive(source, message)

一个进程以消息的形式给另一个指定的目标进程发送send信息;进程通过执行receive原语接收信息,receive原语中指明发送消息的源进程和消息。

进程和线程的区别

1)进程是系统资源分配(CPU以外)的基本单位

2)线程是系统独立调度的基本单位(CPU的分配单位)

线程安全

所以线程安全指的是,在堆内存中的数据或者全局变量由于可以被任何线程访问到,在没有限制的情况下存在被意外修改的风险,需要解决这种风险。

即堆内存空间在没有保护机制的情况下,对多线程来说是不安全的地方,因为你放进去的数据,可能被别的线程“破坏”。

进程线程协程的区别

协程,是一种比线程更加轻量级的存在,协程不是被操作系统内核所管理,而完全是由程序所控制(也就是在用户态执行)。这样带来的好处就是性能得到了很大的提升,不会像线程切换那样消耗资源。

子程序,或者称为函数,在所有语言中都是层级调用,比如A调用B,B在执行过程中又调用了C,C执行完毕返回,B执行完毕返回,最后是A执行完毕。所以子程序调用是通过栈实现的,一个线程就是执行一个子程序。子程序调用总是一个入口,一次返回,调用顺序是明确的。而协程的调用和子程序不同。

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

假设由协程执行,在执行A的过程中,可以随时中断,去执行B,B也可能在执行过程中中断再去执行A,结果可能是:1 2 x y 3 z。

协程的特点在于是一个线程执行,那和多线程比,协程有何优势?

极高的执行效率:因为子程序切换不是线程切换,而是由程序自身控制,因此,没有线程切换的开销,和多线程比,线程数量越多,协程的性能优势就越明显;

不需要多线程的锁机制:因为只有一个线程,也不存在同时写变量冲突,在协程中控制共享资源不加锁,只需要判断状态就好了,所以执行效率比多线程高很多。

线程间怎么共享资源

线程直接读/写进程数据段(如全局变量)来通信

线程使用共享资源会出现什么问题?需要怎么做?

操作系统具有异步性,如果对共享资源的访问不加以约制,一些具有相互制约、相互合作的线程得到的最终结果可能是错的

设置临界区,对临界区资源设置信号量,进行同步和互斥

多线程同步方式

1)软件实现方法(皮特森算法(双标志和单标志):

Pi: flag[i]=true; turn=j; while(flag[j]&&turn==j); )

2)硬件实现方法(中断屏蔽方法(屏蔽中断/关中断)、硬件指令方法(原子操作))

3)锁

4)信号量(PV操作)

5)管程(解决临界区分散所带来的管理和控制问题,包括共享结构数据说明、一组操作、设置共享数据初始值语句)

如何进行线程同步?在Windows下举例?分用户模式下同步和内核模式下同步 讨论?

用户模式下的方法有:原子操作(例如一个单一的全局变量),临界区。

内核模式下的方法有:事件,信号量,互斥量。

同步机制应遵循的准则

1)空闲让进

2)忙则等待

3)有限等待

4)让权等待

进程间通信有哪些算法or多进程通信方式

1)共享存储(PV操作对共享空间读写进行同步互斥)

2)消息传递(1直接通信方式,挂在接收进程的消息缓冲队列上2间接通信方式,即信箱)

3)管道通信(管道是连接读进程和写进程通信的共享文件,限制管道大小,缓冲区允许一边写入另一边读出,管道通信是半双工通信)

4)客户机-服务器系统(包括:套接字(socket),远程过程调用和远程方法调用)

进程间通信有哪几种方式?在特定环境(比如两个程序需要共享一个文本)下哪种效率最高?Windows下如何进行内存共享?

无名管道(父子进程),有名管道FIFO,信号量,信号,高级管道,消息队列,共享内存,sokect等,共享内存的效率最高,因为它可以直接读写内存,而不需要任何的数据拷贝。windows下主要通过映射机制实现的。共享内存的方式原理就是将一份物理内存映射到不同进程各自的虚拟地址空间中,这样每个进程都可以读取同一份数据,从而实现进程通信。因为是通过内存操作实现通信,因此是一种最高效的数据交换方法。

进程和线程的区别、进程如何调度(扯了进程维护线程池,临界区、事务、信号量、信号)

1)先来先服务调度算法

2)短作业优先调度算法

3)优先级调度算法

4)高响应比优先调度算法(响应比=(等待时间+要求服务时间)/要求服务时间)

5)时间片轮转调度算法

6)多级反馈队列调度算法

简单说一下进程间切换发生的事情

1)保存处理机上下文,包括程序计数器和其他寄存器

2)更新PCB信息

3)把PCB移入相应的队列,如就绪、阻塞队列

4)选择另一个进程执行,并更新其PCB

5)更新内存管理的数据结构

6)恢复处理机上下文

进程切换和线程切换
(1)进程切换分两步:

① 切换页目录以使用新的地址空间;

② 切换内核栈和硬件上下文。

对于linux来说,线程和进程的最大区别就在于地址空间,对于线程切换,第1步是不需要做的,第2是进程和线程切换都要做的。

(2)为什么虚拟地址切换很慢

现在我们已经知道了进程都有自己的虚拟地址空间,把虚拟地址转换为物理地址需要查找页表,页表查找是一个很慢的过程,因此通常使用Cache来缓存常用的地址映射,这样可以加速页表查找,这个cache就是TLB,Translation Lookaside Buffer,我们不需要关心这个名字只需要知道TLB本质上就是一个cache,是用来加速页表查找的。由于每个进程都有自己的虚拟地址空间,那么显然每个进程都有自己的页表,那么当进程切换后页表也要进行切换,页表切换后TLB就失效了,cache失效导致命中率降低,那么虚拟地址转换为物理地址就会变慢,表现出来的就是程序运行会变慢,而线程切换则不会导致TLB失效,因为线程线程无需切换地址空间,因此我们通常说线程切换要比较进程切换块,原因就在这里。

进程安全如何保证(扯了进程的数据同步和锁的实现

安全状态:能找到一个分配资源的序列让所有进程都顺利完成

进程在什么情况下会互锁

多个进程同时占有对方需要的资源而同时请求对方的资源,而它们在得到请求之前不会释放所占有的资源

(1)系统资源的竞争

(2)进程推进顺序非法(信号量使用不当,A等B的消息,B等A的消息)

互锁怎么解决

1)预防死锁(设置某些限制条件,破坏死锁四个必要条件之一)

2)避免死锁(动态分配资源过程中,用某种方法防止系统进入不安全状态)

3)死锁检测及解除(剥夺资源、撤销进程、进程回退)

给定两个线程,A,B两个锁,举个造成死锁的例子?

程序中使用多个互斥量时,如果允许一个线程一直占有第一个互斥量,并且在试图锁住第二个互斥量时处于阻塞状态,但是拥有第二个互斥量的线程也在试图锁住第一个互斥量,这时就会发生死锁。因为两个线程都在相互请求另一个线程拥有的资源,所以这两个线程都无法向前运行,于是就产生死锁。如果所有线程总是在对互斥量B加锁之前锁住互斥量A,那么使用这两个互斥量不会产生死锁

一个线程挂掉是否影响进程

1.进程(主线程)创建了多个线程,多个子线程均拥有自己独立的栈空间(存储函数参数、局部变量等),但是多个子线程和主线程共享堆、全局变量等非栈内存。

2.如果子线程的崩溃是由于自己的一亩三分地引起的,那就不会对主线程和其他子线程产生影响,但是如果子线程的崩溃是因为对共享区域造成了破坏,那么大家就一起崩溃了。

3.举个栗子:主线程是一节车厢的乘务员,诸多乘客(也就是子线程)就是经过乘务员(主线程)检票确定可以进入车厢的,也就是主线程创建了诸多子线程,每个子线程有自己独立的区域(座位啊啥的),但是诸多乘客和乘务员共享走廊啊卫生间啊等等,如果其中一名乘客座位坏了,摔了(可以认为奔溃了),那么其他乘客和乘务员都不受影响,但是如果乘客将卫生间给破坏了,他也无法使用卫生间(崩溃了),其他乘客和乘务员也不能用卫生间,好吧,那么大家一起憋着吧(崩溃了)。

总体来说,线程没有独立的地址空间,如果崩溃,会发信号,如果没有错误处理的handler,OS一般直接杀死进程。就算是有handler了处理,一般也会导致程序崩溃,因为很有可能其他线程或者进程的数据被破坏了。

问了对sleep的理解。(进程中的概念)
进程A能否通过地址访问进程B?
多线程需要注意的问题?

同步与互斥,全局变量的锁操作

死锁相关

线程死锁的几个条件是什么?

(1)互斥条件:指线程对所分配的资源进行排他性使用,即在一段时间内某资源只由一个线程占用。

(2)请求和保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。

(3)不可剥夺条件:进程已获得的资源,在未使用之前,不能强行剥夺。

(4)循环等待条件:指在发生死锁时,必然存在一个进程资源循环等待链,链中每一个进程已获得的资源同时被链中下一个进程所请求

银行家算法

上图中有五个进程,四个资源。左边的图表示已经分配的资源,右边的图表示还需要分配的资源。最右边的E、P 以及 A分别表示:总资源、已分配资源以及可用资源,注意这三个为向量,而不是具体数值,例如A=(1020),表示 4个资源分别还剩下 1/0/2/0。

检查一个状态是否安全的算法如下:

① 查找右边的矩阵是否存在一行小于等于向量
A。如果不存在这样的行,那么系统将会发生死锁,状态是不安全的。

② 假若找到这样一行,将该进程标记为终止,并将其已分配资源加到 A 中。

③ 重复以上两步,直到所有进程都标记为终止,则状态时安全的。

如果一个状态不是安全的,需要拒绝进入这个状态。

可见银行家算法的核心在于,在每次分配资源时检测目前所有进程需要分配的资源大小。如果没有所需资源小于当前未分配资源,那么代表当前状态为死锁状态,拒绝进入此状态(资源分配不足导致的死锁)。而如果找到了就将那个进程标记为终止,将其所拥有的的资源返回未分配资源并循环。如果最后所有进程均为终止则代表此状态OK,否则表示会造成死锁(资源分配不当导致的环路型死锁)。

第II部分 内存管理

cache的作用和实现机制,讲了LRU、FIFO和LEU,详细介绍了LRU的三种实现

Cache作用:调节CPU与主存读取速度不一致的矛盾

Cache实现机制:将Cache和主存都分成若干大小相等的块,Cache中存储主存中最活跃的若干块副本

页面调度算法:

最佳置换算法OPT:选择以后不用的页面

最近最久未使用LRU:选择最近最久未使用的页面(堆栈类算法,需要寄存器和栈的硬件支持)

最不经常使用LFU:将一段时间内访问次数最少的页面换出

先进先出FIFO:选择最先装入内存的页面(基于队列)

时钟置换算法CLOCK(NRU):选择最近未用的页面

改进的时钟算法:考虑页面修改问题

问了操作系统的调度,页表之类的问题,然后讲了讲缓存算法。(LRU要求讲一下怎么实现)

LRU的实现:

1)用一个数组来存储数据,给每一个数据项标记一个访问时间戳,每次插入新数据项的时候,先把数组中存在的数据项的时间戳自增,并将新数据项的时间戳置为0并插入到数组中。每次访问数组中的数据项的时候,将被访问的数据项的时间戳置为0。当数组空间已满时,将时间戳最大的数据项淘汰。

2)利用一个链表来实现,每次新插入数据的时候将新数据插到链表的头部;每次缓存命中(即数据被访问),则将数据移到链表头部;那么当链表满的时候,就将链表尾部的数据丢弃。

3)利用链表和hashmap。当需要插入新的数据项的时候,如果新数据项在链表中存在(一般称为命中),则把该节点移到链表头部,如果不存在,则新建一个节点,放到链表头部,若缓存满了,则把链表最后一个节点删除即可。在访问数据的时候,如果数据项在链表中存在,则把该节点移到链表头部,否则返回-1。这样一来在链表尾部的节点就是最近最久未访问的数据项。

对于第一种方法,需要不停地维护数据项的访问时间戳,另外,在插入数据、删除数据以及访问数据时,时间复杂度都是O(n)。对于第二种方法,链表在定位数据的时候时间复杂度为O(n)。所以在一般使用第三种方式来是实现LRU算法。

基址变址寻址

1)相对寻址:PC的内容加上指令格式中的形式地址A而形成操作数的有效地址

2)基址寻址:将CPU中基址寄存器(BR)的内容加上指令格式中的形式地址A,而形成操作数的有效地址(基址寄存器内容由操作系统确定,内容不变,形式地址可变,用于分配存储空间)

3)变址寻址:变址寄存器(IX)的内容加上指令格式中的形式地址A,而形成操作数的有效地址(变址寄存器内容可由用户改变,形式地址A不变,用于数组)

mmu虚拟内存映射(MMU负责虚拟地址映射为物理地址)

虚拟内存是一些系统页文件,存放在磁盘上,每个系统页文件大小为4K,物理内存也被分页框,每个页框大小也为4K,这样虚拟页文件和物理内存页就可以对应,实际上虚拟内存就是用于物理内存的临时存放的磁盘空间。页文件就是内存页,物理内存中每页叫物理页,磁盘上的页文件叫虚拟页,物理页+虚拟页就是系统所有使用的页文件的总和。

段页地址映射

段页地址映射:进程以逻辑分为多段,每段有段名且长度不定。一个进程一个段表,一个段一个页表(也就是很多段与很多页表),逻辑地址中有【段号+页号+偏移量】。

其中分页是为了用于实现虚拟内存,分段是为了使进程中的程序与数据可以被划分为逻辑上独立的地址空间(段中空余空间往往较多,用于应付动态的数据增长)。

Windows提供了3种方法来进行内存管理:

1)虚拟内存,最适合用来管理大型对象或者结构数组;

2)内存映射文件,最适合用来管理大型数据流(通常来自文件)以及在单个计算机上运行多个进程之间共享数据;

3)内存堆栈,最适合用来管理大量的小对象。

Windows操纵内存可以分两个层面:物理内存和虚拟内存。,

虚拟内存组成部分:

1) 页表机制

2) 中断机构

3) 地址变换机构

4) 内存和外存

第III部分 设备管理

CPU的大端模式与小端模式

在各种体系的计算机中通常采用的字节存储机制主要有两种:Big-Endian和Little-Endian,即大端模式和小端模式。

Big-Endian和Little-Endian的定义如下:

  1. Little-Endian:就是低位字节排放在内存的低地址端,高位字节排放在内存的高地址端。

  2. Big-Endian:就是高位字节排放在内存的低地址端,低位字节排放在内存的高地址端。

debug与release的区别,未初始化的变量?

Debug:调试版本,包含调试信息,所以容量比Release大很多,并且不进行任何优化(优化会使调试复杂化,因为源代码和生成的指令间关系会更复杂),便于程序员调试。Debug模式下生成两个文件,除了.exe或.dll文件外,还有一个.pdb文件,该文件记录了代码中断点等调试信息

Release:发布版本,不对源代码进行调试,编译时对应用程序的速度进行优化,使得程序在代码大小和运行速度上都是最优的。(调试信息可在单独的PDB文件中生成)。Release模式下生成一个文件.exe或.dll文件

32位与64位的区别?X86框架?

所能存储的物理内存地址最大为2^32与2^64次方,所以32位内存最多只有4GB,而64位能到128GB

64位操作系统相对32位操作系统理论上性能会相应提升1倍;

64位操作系统支持多达128 GB的内存和多达16
TB的虚拟内存,而32位CPU和操作系统最大只可支持3.5G内存;

64位软件比32位软件要少,64位电脑可以安装32位操作系统,64位操作系统可以安装32位软件。

CPU占用过高如何处理?

1.从进程与线程的角度:可能出现了执行死循环的线程,尽量避免死循环,以及尽量在线程/进程无需运行时进行sleep操作

2.从代码的角度:

(1)请使用初始化而不是赋值

(2)把声明放在合适的位置上

(3)初始化列表

(4)调用过多的函数使用inline内联函数

普通函数的调度过程?

第IV部分 文件管理

简单介绍一下 Linux 文件系统?

在Linux操作系统中,所有被操作系统管理的资源,例如网络接口卡、磁盘驱动器、打印机、输入输出设备、普通文件或是目录都被看作是一个文件。

也就是说在LINUX系统中有一个重要的概念:一切都是文件。其实这是UNIX哲学的一个体现,而Linux是重写UNIX而来,所以这个概念也就传承了下来。在UNIX系统中,把一切资源都看作是文件,包括硬件设备。UNIX系统把每个硬件都看成是一个文件,通常称为设备文件,这样用户就可以用读写文件的方式实现对硬件的访问。

Linux支持5种文件类型

五、数据库

数据库系统原理

事务

概念:事务指的是满足 ACID(事务的四个特性的统称) 特性的一组操作,可以通过
Commit 提交一个事务,也可以使用 Rollback 进行回滚。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-uYUFfs8z-1570673071297)(media/c442ecf0e703f2158ad5e3fc8bafc37f.png)]

(1)原子性(Atomicity)

事务被视为不可分割的最小单元,事务的所有操作要么全部提交成功,要么全部失败回滚。

回滚可以用回滚日志来实现,回滚日志记录着事务所执行的修改操作,在回滚时反向执行这些修改操作即可。

(2)一致性(Consistency)

数据库在事务执行前后都保持一致性状态。在一致性状态下,所有事务对一个数据的读取结果都是相同的。

(3)隔离性(Isolation)

一个事务所做的修改在最终提交以前,对其它事务是不可见的。

(4)持久性(Durability)

一旦事务提交,则其所做的修改将会永远保存到数据库中。即使系统发生崩溃,事务执行的结果也不能丢失。往往使用重做日志来保证持久性。

MySQL 默认采用自动提交模式(AUTOCOMMIT)。也就是说,如果不显式使用START
TRANSACTION语句来开始一个事务,那么每个查询都会被当做一个事务自动提交。

并发一致性问题

在并发环境下,事务的隔离性很难保证,因此会出现很多并发一致性问题。

(1)丢失修改

T1和T2两个事务都对一个数据进行修改,T1先修改,T2随后修改,T2的修改覆盖了T1的修改,当T1再去SELECT时发现数据已经改变了。

(2)读脏数据

T1修改一个数据,T2随后读取这个数据。如果T1撤销了这次修改,那么T2读取的数据是脏数据。

(3)不可重复读

T2读取一个数据,T1对该数据做了修改。如果T2再次读取这个数据,此时读取的结果和第一次读取的结果不同。

(4)幻影读

T1读取某个范围的数据,T2在这个范围内插入新的数据,T1再次读取这个范围的数据,此时读取的结果和和第一次读取的结果不同。

产生并发不一致性问题主要原因是破坏了事务的隔离性,解决方法是通过并发控制来保证隔离性。并发控制可以通过封锁来实现,但是封锁操作需要用户自己控制,相当复杂。数据库管理系统提供了事务的隔离级别,让用户以一种更轻松的方式处理并发一致性问题。

封锁
(1)封锁粒度

MySQL 中提供了两种封锁粒度:行级锁以及表级锁。

应该尽量只锁定需要修改的那部分数据,而不是所有的资源。锁定的数据量越少,发生锁争用的可能就越小,系统的并发程度就越高。

但是加锁需要消耗资源,锁的各种操作(包括获取锁、释放锁、以及检查锁状态)都会增加系统开销。因此封锁粒度越小,系统开销就越大。

在选择封锁粒度时,需要在锁开销和并发程度之间做一个权衡。

(2)封锁类型

① 读写锁

排它锁(Exclusive),简写为 X 锁,又称写锁。

共享锁(Shared),简写为 S 锁,又称读锁。

有以下两个规定:

一个事务对数据对象 A 加了 X 锁,就可以对 A
进行读取和更新。加锁期间其它事务不能对 A 加任何锁。

一个事务对数据对象 A 加了 S 锁,可以对 A
进行读取操作,但是不能进行更新操作。加锁期间其它事务能对 A 加 S 锁,但是不能加 X
锁。

② 意向锁(意向锁IX与IS是表级别锁)

使用意向锁(Intention Locks)可以更容易地支持多粒度封锁。

在存在行级锁和表级锁的情况下,事务 T 想要对表 A 加 X
锁,就需要先检测是否有其它事务对表 A 或者表 A 中的任意一行加了锁,那么就需要对表
A 的每一行都检测一次,这是非常耗时的。

意向锁在原来的 X/S 锁之上引入了 IX/IS,IX/IS
都是表锁,用来表示一个事务想要在表中的某个数据行上加 X 锁或 S
锁。有以下两个规定:

一个事务在获得某个数据行对象的 S 锁之前,必须先获得表的 IS
锁(比如每有一个读者则获取一个IS锁)或者更强的锁;

一个事务在获得某个数据行对象的 X 锁之前,必须先获得表的 IX 锁。

通过引入意向锁,事务 T 想要对表 A 加 X 锁,只需要先检测是否有其它事务对表 A 加了
X/IX/S/IS 锁,如果加了就表示有其它事务正在使用这个表或者表中某一行的锁,因此事务
T 加 X
锁失败。当事务T想要对A加S锁,则只需要检测当前是否有IX/X锁被占据,,然后判断IS锁是否被占据。

解释如下:(IX、IS可以理解为读者编者问题中的读者互斥锁与编者互斥锁)

任意 IS/IX 锁之间都是兼容的,因为它们只是表示想要对表加锁,而不是真正加锁;

S 锁只与 S 锁和 IS 锁兼容,也就是说事务 T 想要对数据行加 S
锁,其它事务可以已经获得对表或者表中的行的 S 锁。

(3)三级封锁协议

① 一级封锁协议

事务 T 要修改数据 A 时必须加 X 锁,直到 T 结束才释放锁。

可以解决丢失修改问题,因为不能同时有两个事务对同一个数据进行修改,那么事务的修改就不会被覆盖。

② 二级封锁协议

在一级的基础上,要求读取数据 A 时必须加 S 锁,读取完马上释放 S 锁。

可以解决读脏数据问题,因为如果一个事务在对数据 A 进行修改,根据 1
级封锁协议,会加 X 锁,那么就不能再加 S 锁了,也就是不会读入数据。

③ 三级封锁协议

在二级的基础上,要求读取数据 A 时必须加 S 锁,直到事务结束了才能释放 S 锁。

可以解决不可重复读的问题,因为读 A 时,其它事务不能对 A 加 X
锁,从而避免了在读的期间数据发生改变。

(4)两段锁协议

加锁和解锁分为两个阶段进行。

可串行化调度是指,通过并发控制,使得并发执行的事务结果与某个串行执行的事务结果相同。

事务遵循两段锁协议是保证可串行化调度的充分条件。例如以下操作满足两段锁协议,它是可串行化调度。

lock-x(A)…lock-s(B)…lock-s©…unlock(A)…unlock©…unlock(B)

但不是必要条件,例如以下操作不满足两段锁协议,但是它还是可串行化调度。

lock-x(A)…unlock(A)…lock-s(B)…unlock(B)…lock-s©…unlock©

(5)MySQL 隐式与显示锁定

MySQL 的 InnoDB
存储引擎采用两段锁协议,会根据隔离级别在需要的时候自动加锁,并且所有的锁都是在同一时刻被释放,这被称为隐式锁定。

InnoDB 也可以使用特定的语句进行显示锁定:

SELECT … LOCK In SHARE MODE;

SELECT … FOR UPDATE;

隔离级别(往往根据隔离级别走)
(1)未提交读(READ UNCOMMITTED)

事务中的修改,即使没有提交,对其它事务也是可见的。

(2)提交读(READ COMMITTED)

一个事务只能读取已经提交的事务所做的修改。换句话说,一个事务所做的修改在提交之前对其它事务是不可见的。

(3)可重复读(REPEATABLE READ)

保证在同一个事务中多次读取同样数据的结果是一样的。

(4)可串行化(SERIALIZABLE)

强制事务串行执行。需要加锁实现,而其它隔离级别通常不需要。

多版本并发控制

多版本并发控制(Multi-Version Concurrency Control, MVCC)是 MySQL 的 InnoDB
存储引擎实现隔离级别的一种具体方式,用于实现提交读和可重复读这两种隔离级别。而未提交读隔离级别总是读取最新的数据行,无需使用
MVCC。可串行化隔离级别需要对所有读取的行都加锁,单纯使用 MVCC 无法实现。

(1)版本号

系统版本号:是一个递增的数字,每开始一个新的事务,系统版本号就会自动递增。

事务版本号:事务开始时的系统版本号。

(2)隐藏的列

MVCC 在每行记录后面都保存着两个隐藏的列,用来存储两个版本号:

创建版本号:指示创建一个数据行的快照时的系统版本号;

删除版本号:如果该快照的删除版本号大于当前事务版本号表示该快照有效,否则表示该快照已经被删除了。

① SELECT

多个事务必须读取到同一个数据行的快照,并且这个快照是距离现在最近的一个有效快照。但是也有例外,如果有一个事务正在修改该数据行,那么它可以读取事务本身所做的修改,而不用和其它事务的读取结果一致。

把没有对一个数据行做修改的事务称为 T,T
所要读取的数据行快照的创建版本号必须小于等于 T 的版本号,因为如果大于 T
的版本号,那么表示该数据行快照是其它事务的最新修改,因此不能去读取它。除此之外,T
所要读取的数据行快照的删除版本号必须是未定义或者大于 T
的版本号,因为如果小于等于 T
的版本号,那么表示该数据行快照是已经被删除的,不应该去读取它。

② INSERT

将当前系统版本号作为数据行快照的创建版本号。

③ DELETE

将当前系统版本号作为数据行快照的删除版本号。

④ UPDATE

将当前系统版本号作为更新前的数据行快照的删除版本号,并将当前系统版本号作为更新后的数据行快照的创建版本号。可以理解为先执行
DELETE 后执行 INSERT。

关系数据库设计理论
(1)函数依赖

记 A->B 表示 A 函数决定 B,也可以说 B 函数依赖于 A。

如果 {A1,A2,… ,An}
是关系的一个或多个属性的集合,该集合函数决定了关系的其它所有属性并且是最小的,那么该集合就称为键码。

对于 A->B,如果能找到 A 的真子集 A’,使得 A’-> B,那么 A->B
就是部分函数依赖,否则就是完全函数依赖。

对于 A->B,B->C,则 A->C 是一个传递函数依赖。

(2)异常

以下的学生课程关系的函数依赖为 {Sno, Cname} -> {Sname, Sdept, Mname,
Grade},键码为 {Sno, Cname}。也就是说,确定学生和课程之后,就能确定其它信息。

Sno Sname Sdept Mname Cname Grade

1 学生-1 学院-1 院长-1 课程-1 90

2 学生-2 学院-2 院长-2 课程-2 80

2 学生-2 学院-2 院长-2 课程-1 100

3 学生-3 学院-2 院长-2 课程-2 95

不符合范式的关系,会产生很多异常,主要有以下四种异常:

**冗余数据:**例如 学生-2 出现了两次。

**修改异常:**修改了一个记录中的信息,但是另一个记录中相同的信息却没有被修改。

**删除异常:**删除一个信息,那么也会丢失其它信息。例如删除了 课程-1
需要删除第一行和第三行,那么 学生-1 的信息就会丢失。

**插入异常:**例如想要插入一个学生的信息,如果这个学生还没选课,那么就无法插入。

(3)范式

范式理论是为了解决以上提到四种异常。

高级别范式的依赖于低级别的范式,1NF 是最低级别的范式。

**① 第一范式 (1NF):**属性不可分。

**② 第二范式 (2NF):**每个非主属性完全函数依赖于键码。可以通过分解来满足。

分解前:

Sno Sname Sdept Mname Cname Grade

1 学生-1 学院-1 院长-1 课程-1 90

2 学生-2 学院-2 院长-2 课程-2 80

2 学生-2 学院-2 院长-2 课程-1 100

3 学生-3 学院-2 院长-2 课程-2 95

以上学生课程关系中,{Sno, Cname} 为键码,有如下函数依赖:

Sno -> Sname, Sdept

Sdept -> Mname

Sno, Cname-> Grade

Grade 完全函数依赖于键码,它没有任何冗余数据,每个学生的每门课都有特定的成绩。

Sname, Sdept 和 Mname
都部分依赖于键码,当一个学生选修了多门课时,这些数据就会出现多次,造成大量冗余数据。

分解后:

关系-1

Sno Sname Sdept Mname

1 学生-1 学院-1 院长-1

2 学生-2 学院-2 院长-2

3 学生-3 学院-2 院长-2

有以下函数依赖:

Sno -> Sname, Sdept

Sdept -> Mname

关系-2

Sno Cname Grade

1 课程-1 90

2 课程-2 80

2 课程-1 100

3 课程-2 95

有以下函数依赖:

Sno, Cname -> Grade

**(3)第三范式 (3NF):**非主属性不传递函数依赖于键码。

上面的 关系-1 中存在以下传递函数依赖:

Sno -> Sdept -> Mname

可以进行以下分解:

关系-11

Sno Sname Sdept

1 学生-1 学院-1

2 学生-2 学院-2

3 学生-3 学院-2

关系-12

Sdept Mname

学院-1 院长-1

学院-2 院长-2

MySQL相关(语法,引擎,数据类型,索引)

B+Tree原理
(1)数据结构

BTree指的是BalanceTree,也就是平衡树。平衡树是一颗查找树,并且所有叶子节点位于同一层。

B+Tree是基于BTree和叶子节点顺序访问指针进行实现,它具有BTree的平衡性,并且通过顺序访问指针来提高区间查询的性能。

在B+Tree中,一个节点中的key从左到右非递减排列,如果某个指针的左右相邻key分别是keyi和keyi+1,且不为null,则该指针指向节点的所有key大于等于keyi且小于等于keyi+1。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-hpIJK2Ti-1570673071298)(media/7d0e6248d09439bab1c652284b3a9627.png)]

(2)操作

进行查找操作时,首先在根节点进行二分查找,找到一个key所在的指针,然后递归地在指针所指向的节点进行查找。直到查找到叶子节点,然后在叶子节点上进行二分查找,找出key所对应的data。

插入删除操作会破坏平衡树的平衡性,因此在插入删除操作之后,需要对树进行一个分裂、合并、旋转等操作来维护平衡性。

(3)与红黑树的比较

红黑树等平衡树也可以用来实现索引,但是文件系统及数据库系统普遍采用B+Tree作为索引结构,主要有以下两个原因:

① 更少的查找次数

平衡树查找操作的时间复杂度和树高h相关,O(h)=O(logdN),其中d为每个节点的出度。

红黑树的出度为2,而B+Tree的出度一般都非常大,所以红黑树的树高h很明显比B+Tree大非常多,查找的次数也就更多。

② 利用磁盘预读特性

为了减少磁盘I/O操作,磁盘往往不是严格按需读取,而是每次都会预读。预读过程中,磁盘进行顺序读取,顺序读取不需要进行磁盘寻道,并且只需要很短的磁盘旋转时间,速度会非常快。

操作系统一般将内存和磁盘分割成固定大小的块,每一块称为一页,内存与磁盘以页为单位交换数据。数据库系统将索引的一个节点的大小设置为页的大小,使得一次I/O就能完全载入一个节点。并且可以利用预读特性,相邻的节点也能够被预先载入。

MySQL索引

索引是在存储引擎层实现的,而不是在服务器层实现的,所以不同存储引擎具有不同的索引类型和实现。具体分为普通索引(NORMAL)、唯一索引(UNIQUE,往往为主键)、全文索引(FULLTEXT)、空间数据索引(SPATIAL)。

B-Tree索引、B±Tree索引与哈希索引等属于这些索引的实现方式。

(1)B+Tree索引(两种引擎都是B+ Tree树,效率均为logm(N))

是大多数MySQL存储引擎的默认索引类型。因为不再需要进行全表扫描,只需要对树进行搜索即可,所以查找速度快很多。

因为B+Tree的有序性,所以除了用于查找,还可以用于排序和分组。

可以指定多个列作为索引列,多个索引列共同组成键。

适用于全键值、键值范围和键前缀查找,其中键前缀查找只适用于最左前缀查找。如果不是按照索引列的顺序进行查找,则无法使用索引。

InnoDB的B+Tree索引分为主索引(聚簇索引)和辅助索引(非聚簇索引)。主索引的叶子节点data域记录着完整的数据记录,这种索引方式被称为聚簇索引。因为无法把数据行存放在两个不同的地方,所以一个表只能有一个聚簇索引

可见主索引中的关键字是主键值,而辅助索引中的关键字为不同的列的数据,当通过列的数据在辅助索引中进行查找后,还需要进行在主索引中的回表

而辅助索引的叶子节点的data域记录着主键的值,因此在使用辅助索引进行查找时,需要先查找到主键值,然后再到主索引中进行查找。

(2)哈希索引

哈希索引能以O(1)时间进行查找,但是失去了有序性:

  1. 无法用于排序与分组;

  2. 只支持精确查找,无法用于部分查找和范围查找。

InnoDB存储引擎有一个特殊的功能叫**“自适应哈希索引”**,当某个索引值被使用的非常频繁时,会在B+Tree索引之上再创建一个哈希索引,这样就让B+Tree索引具有哈希索引的一些优点,比如快速的哈希查找。

(3)全文索引

MyISAM存储引擎支持全文索引,用于查找文本中的关键词,而不是直接比较是否相等。查找条件使用MATCHAGAINST,而不是普通的WHERE。

全文索引使用倒排索引实现,它记录着关键词到其所在文档的映射。

InnoDB存储引擎在MySQL5.6.4版本中也开始支持全文索引。

(4)空间数据索引

MyISAM存储引擎支持空间数据索引(R-Tree),可以用于地理数据存储。空间数据索引会从所有维度来索引数据,可以有效地使用任意维度来进行组合查询。必须使用GIS相关的函数来维护数据。

(5)InnoDB与MyISAM的索引不同之处

第一个重大区别是InnoDB的数据文件本身就是索引文件,而MyISAM索引文件和数据文件是分离的,索引文件仅保存数据记录的地址。而在InnoDB中,表数据文件本身就是按B+Tree组织的一个索引结构,这棵树的叶节点data域保存了完整的数据记录。这个索引的key是数据表的主键,因此InnoDB表数据文件本身就是主索引。

第二个与MyISAM索引的不同是InnoDB的辅助索引data域存储相应记录主键的值而不是地址。也就是InnoDB的所有辅助索引都引用主键作为data域(即聚簇索引与非聚簇索引的区别,MyISAM没有这种聚簇索引概念)。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-fRsWKyNG-1570673071300)(media/cf2f7eeca662a5a6c952430608f57465.png)]

索引优化(针对InnoDB引擎)
(1)独立的列

在进行查询时,索引列不能是表达式的一部分,也不能是函数的参数,否则无法使用索引。

例如下面的查询不能使用actor_id列的索引:

SELECT actor_id FROM sakila.actor WHERE actor_id + 1 = 5 ;

(2)多列索引(组合索引)

在需要使用多个列作为条件进行查询时,使用多列索引比使用多个单列索引性能更好。例如下面的语句中,最好把actor_id和film_id设置为多列索引。使用AND或者OR会产生索引合并的查询方式,也可以考虑用UNION写。

SELECT film_id, actor_id FROM sakila.film_actor WHERE actor_id = 1 AND film_id =
1;(遵循最左前缀原则,即从左到右匹配条件,如不匹配则短路)

(3)索引列的顺序

让选择性(不重复的索引值和数据表的记录总数的比值,其越高查询效率越高)最强的索引列放在前面。

索引的选择性是指:不重复的索引值和记录总数的比值。最大值为1,此时每个记录都有唯一的索引与其对应。选择性越高,每个记录的区分度越高,查询效率也越高。

(4)前缀索引

对于BLOB、TEXT和VARCHAR类型的列,必须使用前缀索引,只索引开始的部分字符。而前缀长度的选取需要根据索引选择性来确定。

(5)覆盖索引

索引包含所有需要查询的字段的值,往往使用B-Tree结构实现,使得非聚簇索引在查询时无需回表。

具有以下优点:

① 索引通常远小于数据行的大小,只读取索引能大大减少数据访问量。

② 一些存储引擎(例如MyISAM)在内存中只缓存索引,而数据依赖于操作系统来缓存。因此,只访问索引可以不使用系统调用(通常比较费时)。

③ 对于InnoDB引擎,若辅助索引能够覆盖查询,则无需访问主索引。

(6)索引的优点

① 大大减少了服务器需要扫描的数据行数。

② 帮助服务器避免进行排序和分组,以及避免创建临时表(B+Tree索引是有序的,可以用于ORDERBY和GROUPBY操作。临时表主要是在排序和分组过程中创建,不需要排序和分组,也就不需要创建临时表)。

③ 将随机I/O变为顺序I/O(B+Tree索引是有序的,会将相邻的数据都存储在一起)。

(7)索引的使用条件

① 对于非常小的表、大部分情况下简单的全表扫描比建立索引更高效;

② 对于中到大型的表,索引就非常有效;

③ 但是对于特大型的表,建立和维护索引的代价将会随之增长。这种情况下,需要用到一种技术可以直接区分出需要查询的一组数据,而不是一条记录一条记录地匹配,例如可以使用分区技术。

④ 索引不是无敌的,随着索引量增加其维护的代价也会越来越高。

⑤ 不建议使用过长的字段作为主键,因为所有辅助索引都引用主索引,过长的主索引会令辅助索引变得过大。

⑥ 不建议使用非单调的字段在InnoDB中作为主键,因为InnoDB数据文件本身是一颗B+Tree,非单调的主键会造成在插入新记录时数据文件为了维持B+Tree的特性而频繁的分裂调整,十分低效,而使用自增字段作为主键(这些往往都是主键)则是一个很好的选择。

存储引擎
(1)InnoDB

是 MySQL默认的事务型存储引擎,只有在需要它不支持的特性时,才考虑使用其它存储引擎。

实现了四个标准的隔离级别,默认级别是可重复读(REPEATABLE READ)。在可重复读隔离级别下,通过多版本并发控制(MVCC)+ Next-Key Locking防止幻影读。

主索引是聚簇索引,在索引中保存了数据,从而避免直接读取磁盘,因此对查询性能有很大的提升。

内部做了很多优化,包括从磁盘读取数据时采用的可预测性读、能够加快读操作并且自动创建的自适应哈希索引、能够加速插入操作的插入缓冲区等。

支持真正的在线热备份。其它存储引擎不支持在线热备份,要获取一致性视图需要停止对所有表的写入,而在读写混合场景中,停止写入可能也意味着停止读取。

(2)MyISAM

设计简单,数据以紧密格式存储。对于只读数据,或者表比较小、可以容忍修复操作,则依然可以使用它。

提供了大量的特性,包括压缩表、空间数据索引等。

不支持事务,不支持行级锁,只能对整张表加锁,读取时会对需要读到的所有表加共享锁,写入时则对表加排它锁。但在表有读取操作的同时,也可以往表中插入新的记录,这被称为并发插入(CONCURRENT INSERT)。

可以手工或者自动执行检查和修复操作,但是和事务恢复以及崩溃恢复不同,可能导致一些数据丢失,而且修复操作是非常慢的。

如果指定了 DELAY_KEY_WRITE选项,在每次修改执行完成时,不会立即将修改的索引数据写入磁盘,而是会写到内存中的键缓冲区,只有在清理键缓冲区或者关闭表的时候才会将对应的索引块写入磁盘。这种方式可以极大的提升写入性能,但是在数据库或者主机崩溃时会造成索引损坏,需要执行修复操作。

(3)比较

事务:InnoDB 是事务型的,可以使用 Commit 和 Rollback 语句。

并发:MyISAM 只支持表级锁,而 InnoDB 还支持行级锁。

外键:InnoDB 支持外键(外键用于与另一张表的关联,是另一表的主键)。

备份:InnoDB 支持在线热备份。

崩溃恢复:MyISAM 崩溃后发生损坏的概率比 InnoDB 高很多,而且恢复的速度也更慢。

其它特性:MyISAM 支持压缩表和空间数据索引。

数据类型
(1)整型 INTEGER

TINYINT, SMALLINT, MEDIUMINT, INT, BIGINT 分别使用 8, 16, 24, 32, 64
存储空间,一般情况下越小的列越好。

INT(11)中的数字只是规定了交互工具显示字符的个数,对于存储计算来说是没有意义的。

(2)浮点数 FLOAT 和 DOUBLE

FLOAT 和 DOUBLE 为浮点类型,DECIMAL 为高精度小数类型。CPU原生支持浮点运算,但是不支持 DECIMAl 类型的计算,因此 DECIMAL的计算比浮点类型需要更高的代价。

FLOAT、DOUBLE 和 DECIMAL 都可以指定列宽,例如 DECIMAL(18, 9) 表示总共 18 位,取 9 位存储小数部分,剩下 9 位存储整数部分。

(3)字符串 CHAR自动填充 VARCHAR不自动填充

主要有 CHAR 和 VARCHAR 两种类型,一种是定长的,一种是变长的。

VARCHAR 这种变长类型能够节省空间,因为只需要存储必要的内容。但是在执行 UPDATE时可能会使行变得比原来长,当超出一个页所能容纳的大小时,就要执行额外的操作。MyISAM会将行拆成不同的片段存储,而 InnoDB 则需要分裂页来使行放进页内。

在进行存储和检索时,会保留 VARCHAR 末尾的空格,而会删除 CHAR 末尾的空格。

(4)时间和日期 DATETIME 和 TIMESTAMP

MySQL 提供了两种相似的日期时间类型:DATETIME 和 TIMESTAMP。

① DATETIME

能够保存从 1000 年到 9999 年的日期和时间,精度为秒,使用 8
字节的存储空间。它与时区无关。

默认情况下,MySQL 以一种可排序的、无歧义的格式显示 DATETIME 值,例如“2008-01-16
22:37:08”,这是 ANSI 标准定义的日期和时间表示方法。

② TIMESTAMP(时间戳)

和 UNIX 时间戳相同,保存从 1970 年 1 月 1 日午夜(格林威治时间)以来的秒数,使用4 个字节,只能表示从 1970 年到 2038年。它和时区有关,也就是说一个时间戳在不同的时区所代表的具体时间是不同的。

MySQL 提供了 FROM_UNIXTIME() 函数把 UNIX 时间戳转换为日期,并提供了
UNIX_TIMESTAMP() 函数把日期转换为 UNIX 时间戳。

默认情况下,如果插入时没有指定 TIMESTAMP 列的值,会将这个值设置为当前时间。

应该尽量使用 TIMESTAMP,因为它比 DATETIME 空间效率更高。

六、项目相关

Qt中管理内存的方式

在QT中,一切继承自QT自有类的类,如果存在parent指针,那么当parent指针delete时,该类中的指针(它们都属于parent指针对应的child指针)也会被delete。

综上,如果我们的窗口对应的类所对应的parent指针为NULL的话,我们还是要进行一次手动的内存管理。

Qt中信号槽的使用

QT的信号槽书写形式有两种,新标准与旧标准,两种均可使用。

QObject::connect(&button, &QPushButton::clicked,&app, &QApplication::quit);

QObject::connect(button, SIGNAL(clicked()), &a, SLOT(quit()));

新标准的好处在于使用函数指针,在编译时会进行类型检查,从而可以在编译期间提示某些信号槽存在问题。如果使用旧标准,其是使用SIGNAL和SLOT这两个宏将函数名变成字符串,从而没有编译期的错误检查,不过如果没有连接成功,在QT的应用程序输出中会有debug输出提醒,不过不方便查询。

Qt::DirectConnection:按顺序处理与当前信号相关联的所有槽函数(只用于单线程,某种情况下就是一个函数调用,这个信号发送后立即执行对应的槽函数,再回到信号发送的地方)

Qt::QueuedConnection:将当前signal包装成一个事件,放入到对应对象所处的线程中的事件队列中去(可用于单线程与多线程)

Qt::BlockConnection:将当前signal包装成一个事件,放入到对应对象所处的线程中的事件队列中去,同时发射信号的线程堵塞,直到那个事件完成,停止堵塞(只能用于多线程)

Qt::AutoConnection:根据发送对象与接收对象是否在同一线程选择直接连接还是队列连接。

Qt::UniqueConnection:用于标志某个信号槽连接为单独的

Qt中信号槽是怎么实现的

MOC文件中的字符串表,对象表,各个signal的函数实现与metacall调用函数的实现;

每个对象都有一个ConnectionVector,根据信号的索引,用于存储每个信号自己的ConnectionList。创建信号槽的实质,就是将(信号所在对象的指针,信号在所在对象内部的索引,槽函数所在对象的指针,槽函数在所在对象内部的索引,连接类型)这五个主要信息构件成一个Connection类,将其存储在对应信号的ConnectionList中。同时,这个Connection也会存储在槽函数所在对象的反向链表中,用于删除。

当emit信号时,相当于在调用信号的函数,而信号函数的实质是activate函数,其会调用当前信号的ConnectionList中的所有Connection,即会调用所有Connection中的槽函数对象。但是根据连接类型的不同,调用方式也不同。如果是直接连接会直接调用;如果是队列连接会将这个调用转换为一个事件Event,传入到槽函数所在对象的事件队列中去,当执行到此事件时函数调用才会实现。在槽函数中调用时使用metacall函数调用对应索引位置的槽函数,并将信号的参传入。至

(4)Qt中view/model是怎么实现的

MQTT简介

(1)简介

MQTT(Message Queuing Telemetry
Transport,消息队列遥测传输协议),是一种基于发布/订阅(publish/subscribe)模式的"轻量级"通讯协议,该协议构建于TCP/IP协议上,由IBM在1999年发布。MQTT最大优点在于,可以以极少的代码和有限的带宽,为连接远程设备提供实时可靠的消息服务。作为一种低开销、低带宽占用的即时通讯协议,使其在物联网、小型设备、移动应用等方面有较广泛的应用。

(2)MQTT协议实现方式

实现MQTT协议需要客户端和服务器端通讯完成,在通讯过程中,MQTT协议中有三种身份:发布者(Publish)、代理(Broker)(服务器)、订阅者(Subscribe)。其中,消息的发布者和订阅者都是客户端,消息代理是服务器,消息发布者可以同时是订阅者。

MQTT传输的消息分为:主题(Topic)和负载(payload)两部分:

Topic,可以理解为消息的类型,订阅者订阅(Subscribe)后,就会收到该主题的消息内容(payload);

payload,可以理解为消息的内容,是指订阅者具体要使用的内容。

(3)MQTT协议数据包结构

在MQTT协议中,一个MQTT数据包由:固定头(Fixed header)、可变头(Variable header)、消息体(payload)三部分构成。MQTT数据包结构如下:

固定头(Fixed header)。存在于所有MQTT数据包中,表示数据包类型及数据包的分组类标识。(用于存id,目前没有再使用)

可变头(Variable header)。存在于部分MQTT数据包中,数据包类型决定了可变头是否存在及其具体内容。(用于存topic)

消息体(Payload)。存在于部分MQTT数据包中,表示客户端收到的具体内容。(用于存payload)

Unity3D的光照有

平行光(太阳光),区域光(静态物体的光照贴图),点光源(蜡烛),聚光灯(手电筒)

Unity3D的UI控件

Text:文本;Image:图片;Button:按钮;Todggle:单选/多选按钮:Slider:滑动条;Scrollbar:滚动条;Dropdown:复选框;InputField:输入框;Canvas:画布;Panel:面板;Scroll
View:左右上下的滚动列表;EventSystem:事件系统

(一个canvas画布,拉一个plane平面)

  1. Unity3D脚本的生命周期?

  2. Unity3D的内存管理?

  3. Unity3D的UI如何渲染?

七、游戏相关问题

游戏中,地图很大,英雄的技能释放半径和英雄的坐标已知,如何知道每个英雄的技能范围内的对手

当点击英雄技能时触发事件,该事件获取该技能的范围,计算当前视野内敌方英雄的坐标,并计算其坐标距离释放技能英雄的距离,看是否小于圆的半径即可获知其是否会被打到。

问了游戏排行榜的数据结构应该怎么设计 (自己实现平衡二叉搜索树)

我先说只取前几名的话堆排序,如果不是前几名的话要看查询多还是改动多,然后说目前只能想到BST。然后又聊会说可以先分组,再排序,就和Query的select一样,最后join就可以了。后来上网上看了下,网上答案是桶排序和红黑树:(自己实现排序树,平衡二叉搜索树)

我们可以把积分[0, 1,000,000)作为一级区间;再把一级区间分为两个2级区间[0,500,000), [500,000, 1,000,000),然后把二级区间二分为4个3级区间[0, 250,000),[250,000, 500,000), [500,000, 750,000), [750,000,1,000,000),依此类推,最终我们会得到1,000,000个21级区间[0,1), [1,2) … [999,999, 1,000,000)。这实际上是把区间组织成了一种平衡二叉树结构,根结点代表一级区间,每个非叶子结点有两个子结点,左子结点代表低分区间,右子结点代表高分区间。树形分区结构需要在更新时保持一种不变量,非叶子结点的count值总是等于其左右子结点的count值之和(同时其可以创建存储左右子树count大小的索引,即存自己的count数的同时还有两个子树的count值,加快速度)。

其中的count就是每个叶子节点分段如[999,999, 1,000,000)处的人数。每当某人的积分变化时,减少其原本所在的积分区间人数count,增加其变化到的积分区间人数count,并更新两者的父节点直至更新完毕。(设初始排名是0进入排序搜索,如果积分在左区间则加右区间的count数,否则不加)(对于每个区间内的排名,可以将这些数据存起来用map或set按字典序排名)

你看看我们阴阳师手游里面,可以摇绳子【手机给我展示了一下,真的是像绳子不是单摆】,现在我要你来实现一个绳子,要可以摇那种。
问了在2D的环境下,有极大数量的物体(假设为小球),如何优化使得系统开销和性能最好?
检测npc之间的距离
实现定时模块

封装一个Timer类,用于实现定时器,其内部有两个multimap,对应的数据类型为<相对Time ,TimerTask>与<绝对Time,TimerTask>,绝对Time为当前系统的时间来处理的,相对Time为本定时器的第一个任务进来后自己内部开始计时的(从1开始计数然后增加)。其中的TimerTask对应的是同一个任务的指针。

内部还有三个线程,一个用于检测绝对时间multimap,一个用于检测相对时间multimap,一个用于在两者到时间后将对应的TimerTask插入到期执行队列中进行执行,当任务执行后修改标志位,避免重复执行。

每次涉及到两个muitimap的插入删除等操作时,加锁控制。

一条公路上有多个点,每个点都有一辆车,给定公路坐标轴,车的速度和行驶方向,求最早两辆车相遇的时间

我能想到的,就是将所有的点赋予单独的计算方式,当点之间模型发生碰撞时,进行计算与上报。

一条直线上多个点运动知道所有点的位置,和速度包括方向。当两个点相碰时,追及或对撞两个点消失,问什么时候达到稳定状态,也就是以后都不会发生碰撞。问时间

详见本人之前写的一篇文章

一个二维平面,上面有很多店铺,(x,y)坐标,有的稀疏有的密集,现在要求给定点的最近店铺是哪一个?BFS?那么有没有更优的?比如说落在了稀疏区域怎么办?

先圆形取点缩小搜索范围,A*算法计算当前点到所有店铺的距离,取最小的

一个装备,1-4级升级50%概率,5-9级也是50%概率,但是失败了会掉一级,现在要求升到9级的期望

当装备等级位于1-4级时,其公式为:

E(K) = 0.5*1 + 0.5*(E(K) + 1)

意思为0.5的概率一次成功,另一0.5概率在失败这次的基础上再E(K)

当装备位于5-9级时,其公式为:

E(K) = 0.5*1 + 0.5*(E(K-1) + E(K) + 1)

意思是0.5的概率一次成功,另一0.5概率再失败后变成K-1级,所以再这次失败的基础上再加上E(K)与E(K-1)

最后计算得到9级的期望为36次

三个技能框,A,B,C三个技能,不同组合是不同结果,总共有多少个?可以重复使用。假如说不同的顺序一样呢?ABC和BAC一样

第一个问题,根据排列组合思想,应该是n^n,每个位置n个可能;

第二个问题,根据

字符串逆置,传char*

传char*要注意判断’\0’的情况

100w的数据,name,26个字符,要找匹配BC的那些项。ABCD可行,BCDA可行,FBDC不可行。这里我说前缀树,然后问题在于不是B打头怎么办,面试官提示是否可以对前缀树的结构进行改进?不进行遍历来判断?

每个数据实现一颗后缀树,对所有后缀打头为B的进行搜索判断

(标准Trie树只适合前缀匹配和全字匹配,并不适合后缀和子串匹配。而后缀树在这方面则非常合适)

给定一个矩阵,只能从大数走向小数,求最长路径(代码为小到大)

具体方法为遍历矩阵中的每个点,然后计算每个点的最大可达路径长度。其中带有记忆化搜索,即当到达一个之前搜索过的点时,会直接使用起计算过的数据。二维数组f即为记忆化搜索数据(初始值全部设置为-1),二维数组g为原本的数字数据。

这样最大的优势在于极大的加快了搜索速度,高效地利用了计算数据。

class Solution

{

public:

int n, m;

vector<vector<int>> f, g;

int next[4][2]={1,0,-1,0,0,-1,0,1};

int dp(int x, int y)

{

if (f[x][y] != -1) return f[x][y];//记忆话搜索的的核心,搜过就标记

f[x][y] = 1;//注意这里要初始化为1

for (int i = 0; i < 4; i ++ )

{

int a=x+next[i][0];

int b=y+next[i][1];

if (a >= 0 && a < n && b >= 0 && b < m && g[a][b] < g[x][y])

f[x][y] = max(f[x][y], dp(a, b) + 1);

}

return f[x][y];

}

int longestIncreasingPath(vector<vector<int>>& matrix) {

if (matrix.empty()) return 0;

g = matrix;

n = g.size(), m = g[0].size();

f = vector<vector<int>>(n, vector<int>(m, -1));

int res = 1;

for (int i = 0; i < n; i ++ )

for (int j = 0; j < m; j ++ )

res = max(res, dp(i, j));

return res;

}

};

补充一题:现在有一堆标记为危险的ip地址,如何管理,比如20.30.40.50,第三段区间为40-60的有危险,或者某一段出现特定值为危险,设计存储,查询ip的方法

八、智力与综合题

有100盏灯,从1~100编号,开始灯的状态是亮的,然后按照1的倍数,2的倍数,3的倍数。。。一直到100的倍数翻转,问你最后熄灭的是哪几盏灯。

当时应该仔细想好再写代码的,一开始思路略微麻烦了一些,其实类似素数筛那样走一遍就可以了,大概nlogn。

假如有100个小球有碰撞的检测(其中有一个为用户),如果需要统计小球的碰撞的次数总和,如果减少性能要求的前提下进行实现?
假如有100个小球有碰撞的检测(每一个都是用户),如果需要统计小球的碰撞的次数总和,如果减少性能要求的前提下进行实现?
动态规划(最短路径算法)

九、设计模式

你用过哪些设计模式?
工厂方法和抽象工厂有哪些区别?

工厂方法模式属于对象创建型模式,它定义一个用户创建对象的接口,让子类决定实例化哪一个类。工厂方法模式使一个类的实例化延迟到其子类。具体来说就是一个一个抽象产品类,派生出很多个具体产品类;同时,一个抽象工厂类,派生出多个具体工厂类。而每个具体工厂类只能创建一个具体产品类的实例。

抽象工厂模式也属于对象创建型模式,它提供了一个创建一系列相关或相互依赖对象的接口,而无须制定它们具体的类。具体来说就是在多个抽象产品类中,每个抽象产品类可以派生出多个具体产品类。一个抽象工厂类,可以派生出多个具体工厂类。每个具体工厂类可以创建多个具体产品类的实例。

区别:工厂方法模式只有一个抽象产品类,而抽象工厂模式有多个。工厂方法模式的具体工厂类只能创建一个具体产品类的实例,而抽象工厂模式可以创建多个。

工厂方法:说白了就是一个方法,这个方法是创建具体的产品的,它要求所有的工厂都具有同一个签名的方法,必要时重写该方法;

抽象工厂:不能直接创建产品,只能创建工厂,即抽象工厂创建的产品是工厂。

十、开放问题

问了实现工程的代码量是越多越好还是越少越好?
  1. 开发遇到的问题:那些比较简单,哪些比较需要花时间解决?

  2. 聊了聊做的项目的,谈了谈怎么设计的

  3. MVC架构有什么了解?

  4. 前端有什么了解?

  5. 对于游戏开发有什么兴趣?

  6. 对于后台开发的认识?

  7. 对于AI这一块有什么兴趣?

  8. 对于信息安全计算有什么了解?

  9. 如何判断空间中的一点在一个球体内?

  10. 面向组件的好处?

  11. 有哪些设计模式?

Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐