c++面试高频题-速记版

文章目录

c++ 编程

大端和小端

**大端序(Big-Endian)**将数据的低位字节存放在内存的高位地址,高位字节存放在低位地址。这种排列方式与数据用字节表示时的书写顺序一致,符合人类的阅读习惯。
小端序(Little-Endian),将一个多位数的低位放在较小的地址处,高位放在较大的地址处,则称小端序。小端序与人类的阅读习惯相反,但更符合计算机读取内存的方式,因为CPU读取内存中的数据时,是从低地址向高地址方向进行读取的。
在这里插入图片描述

c++内存模型

C++内存分为5个区域(堆栈全常代 )

堆 heap
由new分配的内存块,其释放编译器不去管,由我们程序自己控制(一个new对应一个delete)。如果程序员没有释放掉,在程序结束时OS会自动回收。涉及的问题:“缓冲区溢出”、“内存泄露”

栈 stack
是那些编译器在需要时分配,在不需要时自动清除的存储区。存放局部变量、函数参数。
存放在栈中的数据只在当前函数及下一层函数中有效,一旦函数返回了,这些数据也就自动释放了。

全局/静态存储区 (.bss段和.data段)
全局和静态变量被分配到同一块内存中。在C语言中,未初始化的放在.bss段中,初始化的放在.data段中;在C++里则不区分了。

常量存储区 (.rodata段)
存放常量,不允许修改(通过非正当手段也可以修改)

代码区 (.text段)
存放代码(如函数),不允许修改(类似常量存储区),但可以执行(不同于常量存储区)

也可以再答一个内存映射区
用于存储动态链接库以及调用mmap函数进行的文件映射。

C++中static关键字的作用

c/c++共有
1):修饰全局变量时,表明一个全局变量只对定义在同一文件中的函数可见。
2):修饰局部变量时,该变量的值只会初始化一次,不会因为函数终止而丢失。但是只能在函数内部使用。
3):修饰函数时,表明该函数只在同一文件中调用,不能被其他文件调用。

c++独有:
4):修饰类的数据成员,表明对该类所有对象共享该数据。
5):用static修饰类成员函数。类成员函数可以直接通过类名+函数名调用,无须新建对象。一个静态成员函数只能访问传入的参数、类的静态数据成员和全局变量。因为static修饰的函数中不能使用this指针。

const 和#define的区别

(1) 编译器处理方式不同
  #define宏是在预处理阶段展开。
  const常量是编译运行阶段使用。
(2) 类型和安全检查不同
  #define宏没有类型,不做任何类型检查,仅仅是展开。
  const常量有具体的类型,在编译阶段会执行类型检查。
(3) 存储方式不同
  #define宏仅仅是展开,有多少地方使用,就展开多少次,不会分配内存。(宏定义不分配内存,变量定义分配内存。)
  const常量会在内存中分配(可以是堆中也可以是栈中)。
(4) const 可以节省空间,避免不必要的内存分配
例如:

#define NUM 3.14159 //常量宏
const doulbe Num = 3.14159; //此时并未将Pi放入ROM中 ......
double i = Num; //此时为Pi分配内存,以后不再分配!
double I= NUM; //编译期间进行宏替换,分配内存
double j = Num; //没有内存分配
double J = NUM; //再进行宏替换,又一次分配内存!

const定义常量从汇编的角度来看,只是给出了对应的内存地址,而不是象#define一样给出的是立即数,所以,const定义的常量在程序运行过程中只有一份拷贝(因为是全局的只读变量,存在静态区),而 #define定义的常量在内存中有若干个拷贝。

(5) 提高了效率。 编译器通常不为普通const常量分配存储空间,而是将它们保存在符号表中,这使得它成为一个编译期间的常量,没有了存储与读内存的操作,使得它的效率也很高。

(6)有些集成化的调试工具可以对const常量进行调试,但是不能对宏常量进行调试。

说说内联函数和宏函数的区别

  • 宏定义不是函数,但是使用起来像函数。预处理器用复制宏代码的方式代替函数的调用,省去了函数压栈退栈过程,提高了效率;而内联函数本质上是一个函数,内联函数一般用于函数体的代码比较简单的函数,不能包含复杂的控制语句,while、switch,并且内联函数本身不能直接调用自身。
  • 宏函数是在预编译的时候把所有的宏名用宏体来替换,简单的说就是字符串替换 ;而内联函数则是在编译的时候进行代码插入,编译器会在每处调用内联函数的地方直接把内联函数的内容展开,这样可以省去函数的调用的开销,提高效率
  • 宏定义是没有类型检查的,无论对还是错都是直接替换;而内联函数在编译的时候会进行类型的检查,内联函数满足函数的性质,比如有返回值、参数列表等

什么是野指针?什么是悬空指针?

野指针(wild pointer)指的是没有被初始化的指针,指向不确定地址的指针变量。
悬空指针(dangling pointer)指的是指向已删除对象的指针。

指针和引用的区别

  • 指针是指向对象的地址,而引用只是一个别名
  • sizeof指针的大小是4(和编译器相关),而sizeof引用则是被引用对象的大小
  • 指针可以被初始化为NULL或者nullptr,而引用必须被初始化且必须是一个已有对象的引用。
  • 可以有const指针,但是没有const 引用。
  • 指针在使用中可以指向其他对象,但是引用一旦初始化之后不能被改变。
  • 可以有多级指针,但是没有多级引用。

new 和malloc的区别

1、new分配内存按照数据类型进行分配,malloc分配内存按照指定的大小分配;
2、new返回的是指定对象的指针,而malloc返回的是void*,因此malloc的返回值一般都需要进行类型转化。
3、new不仅分配一段内存,而且会调用构造函数,malloc不会。
4、new分配的内存要用delete销毁,malloc要用free来销毁;delete销毁的时候会调用对象的析构函数,而free则不会。
5、new是一个操作符可以重载,malloc是一个库函数。
6、malloc分配的内存不够的时候,可以用realloc扩容。扩容的原理?new没用这样操作。
7、new如果分配失败了会抛出bad_malloc的异常,而malloc失败了会返回NULL。
8、申请数组时: new[]一次分配所有内存,多次调用构造函数,搭配使用delete[],delete[]多次调用析构函数,销毁数组中的每个对象。而malloc则只能sizeof(int) * n。

求下列结构体的占用内存大小(内存地址对齐)

// 64位系统
struct A{
    char a;
    int b;
    short c;
};
// 总共占据12bytes

对齐原则:
1.成员相对于起始位置的偏移量/成员的大小 = 整数
2.结构体的总大小/最大成员的大小 = 整数

第一个变量a ,偏移量0,自身大小为1,0/1 = 0,满足第一个条件,总大小1/sizeif(int) = 1,满足第二个条件。
第二个变量b,偏移量1,自身大小为4,1/4 不为整数 ,不满足第一个条件,因此需要填充将第二个变量偏移量调整为4,4/4 = 1,总大小(4+4)/4 = 2,满足第二个条件。
第三个变量,偏移量8,自身大小为2,8/2 = 整数,满足第一个条件。总大小(8+2)/4 ,不为整数,不满足第二个条件。因为需要填充两个字节,总大小变为12。最终内存地址排列如下:

a   *   *   *
b   b   b   b
c   c   *   *

虚函数的实现

简单地说,每一个含有虚函数(无论是其本身的,还是继承而来的)的类都至少有一个与之对应的虚函数表(vptr指针指向该表),其中存放着该类所有的虚函数对应的函数指针。例:
在这里插入图片描述
其中:
B的虚函数表中存放着B::fooB::bar两个函数指针。
D的虚函数表中存放的既有继承自B的虚函数B::foo,又有重写(override)了基类虚函数B::barD::bar,还有新增的虚函数D::quz

虚函数表构造过程
从编译器的角度来说,B的虚函数表很好构造,D的虚函数表构造过程相对复杂。下面给出了构造D的虚函数表的一种方式(仅供参考):
在这里插入图片描述虚函数调用过程

以下面的程序为例:
在这里插入图片描述

虚函数表的创建的地方在哪里?

虚函数表在 .rodata ( Linux g++ );虚函数指针在对象里,对象在哪,虚函数指针就在哪。虚函数表在编译期生成。
在这里插入图片描述

多重继承下虚函数表的内存分布情况

有如下三个类A,B,C,其中,C继承于A和B。
A、B、C 三个类的实现如下:

class A
{
    virtual void func();
    virtual void funcA();
    int a;
};
class B 
{
    virtual void func();
    virtual void funcB();
    int b;
};
classC : public A,public B
{
public:
    virtual void func();
    virtual void funcC();
    int c;
}

C类对象的内存分布是如何的?
在这里插入图片描述

接下来,有如下的三组变量pa、pb、pc:

A *pa = &c;
B *pb = &c;
C *pc = &c;

问题1:pa、pb、pc三者关系是怎样的?
问题2:通过pa、pb、pc分别能访问哪些函数?
问题3:pa->func()访问的是那个类的函数?
问题4:如何通过pa 访问到A类中的函数?

在这里插入图片描述

问题1:pa和pc的值相同,都是对象c的首地址,pb和pa至今相差四个字节(int a造成的–>观察上图的内存空间分配)

问题2:基类指针指向派生类的对象,通过该基类指针所能访问的函数受类型的限制(运行时调用哪个函数受多态的影响)
基类指针pa可以调用的函数

C::func()
A::funcA()

基类指针pb可以调用的函数

C::func()
B::funcB()

派生类指针pc可以调用的函数

C::func()
A::funcA()
C::funcC()
B::funcB()

问题3:由于多态,会访问C类的func
问题4:通过加作用域:pa->A::func()

多重继承虚函数表的创建原则

多重继承会有多个虚函数表,几重继承,就会有几个虚函数表。
这些表按照派生的顺序依次排列,如果子类改写了父类的虚函数,那么就会用子类自己的虚函数覆盖虚函数表的相应的位置,如果子类有新的虚函数,那么就添加到第一个虚函数表的末尾

你知道智能指针吗?智能指针的原理

智能指针是一个类,这个类的构造函数中传入一个普通指针,析构函数中释放传入的指针。智能指针的类都是栈上的对象,所以当函数(或程序)结束时会自动被释放,

常用的智能指针

(1)std::auto_ptr,有很多问题。 不支持复制(拷贝构造函数)和赋值(operator =),但复制或赋值的时候不会提示出错。因为不能被复制,所以不能被放入容器中。
(2) C++11引入的unique_ptr, 也不支持复制和赋值,但比auto_ptr好,直接赋值会编译出错。实在想赋值的话,需要使用:std::move。

例如:

std::unique_ptr<int> p1(new int(5));
std::unique_ptr<int> p2 = p1; // 编译会出错
std::unique_ptr<int> p3 = std::move(p1); // 转移所有权, 现在那块内存归p3所有, p1成为无效的指针.

(3) C++11或boost的shared_ptr,基于引用计数的智能指针。可随意赋值,直到内存的引用计数为0的时候这个内存会被释放。

(4)C++11或boost的weak_ptr,弱引用。 引用计数有一个问题就是互相引用形成环,这样两个指针指向的内存都无法释放。需要手动打破循环引用或使用weak_ptr。顾名思义,weak_ptr是一个弱引用,只引用,不计数。如果一块内存被shared_ptr和weak_ptr同时引用,当所有shared_ptr析构了之后,不管还有没有weak_ptr引用该内存,内存也会被释放。所以weak_ptr不保证它指向的内存一定是有效的,在使用之前需要检查weak_ptr是否为空指针。

auto_ptr 和 unique_ptr的区别

auto_ptr
采用所有权模式

auto_ptr<string> p1(new string("hello"));
auto_ptr<string> p2;
p2 = p1;

此时不会报错,p2剥夺了p1的所有权,但是当程序运行时访问p1将会报错。所以auto_ptr的缺点是:存在潜在的内存崩溃的问题。
unique_ptr
unique_ptr实现的是独占式拥有或严格拥有的概念,保证在同一时间内只有一个智能指针可以指向该对象。它对于避免资源泄露特别有用。
采用所有权模式,还是上面的例子

unique_ptr<string> p3(new string ("auto"));
unique_ptr<string> p4;
p4 = p3;//此时会报错

智能指针的实现

需要实现构造,析构,拷贝构造,=操作符重载,重载*-和>操作符。

#include <iostream>
using namespace std;

template <class T>
class Shared_mptr {
public:
	//空类构造,count,ptr均置空
	Shared_mptr() :count(0), ptr_((T*)0) {}
	//赋值构造,count返回int指针,必须new int,ptr指向值
	Shared_mptr(T* p) :count(new int(1)), ptr_(p) {}
	//拷贝构造,注意是&引用,此处注意的一点是,count需要+1
	Shared_mptr(Shared_mptr<T> &other) :count(&(++ *other.count)), ptr_(other.ptr_) {}
	//重载->返回T*类型
	T* operator->() { return ptr_; }
	//重载*返回T&引用
	T& operator*() { return *ptr_; }
	//重载=,此处需要将源计数减一,并判断是否需要顺便析构源,然后将thiscount+1,注意最后返回*this
	Shared_mptr<T>& operator=(Shared_mptr<T>& other) 
	{
		if (this == &other)
			return *this;
		++*other.count;
		if (this->ptr_&&--*this->count == 0)
		{
			delete ptr_;
			delete count;
			cout << "delete from =" << endl;
		}
		this->count = other.count;
		this->ptr_ = other.ptr_;
		return *this;
	}
	//析构,当ptr_存在且在此次析构后count==0,真正析构资源
	~Shared_mptr()
	{
		if (ptr_&&--*count == 0)
		{
			delete ptr_;
			delete count;
			cout << "delete from ~" << endl;
		}
	}
	//返回count
	int getRef()
	{
		return *count;
	}

private:
	int *count;//注意此处是count*,因为计数其实是同一个count,大家都以指针来操作;
	T* ptr_;
};

int main()
{
	Shared_mptr<int> pstr(new int(2));//注意此处是new int
	cout << "pstr:" << pstr.getRef() << " " << *pstr << endl;

	Shared_mptr<int> pstr2(pstr);
	cout << "pstr:" << pstr.getRef() << " " << *pstr << endl;
	cout << "pstr2:" << pstr2.getRef() << " " << *pstr2 << endl;

	Shared_mptr<int> pstr3(new int(4));
	cout << "pstr3:" << pstr3.getRef() << " " << *pstr3 << endl;

	pstr3 = pstr2;
	cout << "pstr:" << pstr.getRef() << " " << *pstr << endl;
	cout << "pstr2:" << pstr2.getRef() << " " << *pstr2 << endl;
	cout << "pstr3:" << pstr3.getRef() << " " << *pstr3 << endl;

	return 0;

}

shared_ptr导致循环引用

#include <iostream>
#include <memory>
using namespace std;

class B; // 前置声明
class A {
public:
    shared_ptr<B> ptr;
    ~A(){cout<<"A has been destroyed"<<endl;}
};

class B {
public:
    shared_ptr<A> ptr;
    ~B(){cout<<"B has been destroyed"<<endl;}
};

int main()
{
    {
        shared_ptr<A> pa(new A());
        shared_ptr<B> pb(new B());
        pa -> ptr = pb;
        pb -> ptr = pa;
    }
    return 0;
}

在这里插入图片描述
离开作用域之后,变为下图:
在这里插入图片描述
解决方法
解决方法很简单,把class A或者class B中的shared_ptr改成weak_ptr即可,由于weak_ptr不会增加shared_ptr的引用计数,所以A object和B object中有一个的引用计数为1,在pa和pb析构时,会正确地释放掉内存
参考文章:https://blog.csdn.net/zhwenx3/article/details/82789537

私有继承的作用是什么?

建立has-a关系

什么时候要重写拷贝构造函数?

凡是包含动态内存分配成员或者指针成员的类都应该重写拷贝构造函数。

构造函数和移动构造函数的区别

最大区别在于指针类型所指向内存是否有拷贝的动作

构造函数是否可以是虚函数

不可以,虚表是在构造函数执行时才建立的

构造函数是否可以抛出异常

可以,但是如果有动态内存需要进行释放

什么情况下必须使用构造函数初始化而不能进行赋值

成员变量存在常类、引用或者默认构造函数被禁用时

什么构造函数会在main 函数之前运行

全局变量的构造函数

怎么防止类对象被拷贝和赋值,

拷贝构造函数和赋值构造函数设为私有的或者使用c++11的delete

是否可以在构造函数中调用虚函数

可以。
但是不建议。
effictive c++第九条,绝不在构造和析构过程中调用virtual,因为构造函数中的base的虚函数不会下降到derived上。而是直接调用base类的虚函数

c++如何定义一个只能在堆上(栈上)生成对象的类?

只能在堆上
方法:将西沟函数设置为私有
原因:c++是静态绑定语言,编译器管理栈上对象的生命周期,编译器在为类对象分配栈空间时,会先检查类的析构函数的访问性,如果析构函数不可访问,则不能在栈上创建对象。

只能在栈上
方法:将new 和delete 重载为私有
原因:在对上生成对象,使用new 关键词操作,其过程分为两个阶段,第一阶段,使用new在堆上寻找可用空间,分配给对象;第二阶段,调用构造函数生成对象。将new操作设置为私有,那么第一阶段就无法完成,就不能够在堆上生成对象。

内存泄漏的种类

  • 堆内存泄漏。使用了new,没有delete。或者使用了malloc 没有free。
  • 系统资源泄漏。例如使用了socket,用完后没有关闭。
  • 没有将基类的析构函数定义为虚函数。这会造成子类资源不能释放。

如何判断内存泄漏?

内存泄漏通常是由于调用了malloc/new等内存申请的操作,但是缺少了对应的free/delete。为了判断内存是否泄露,我们一方面可以使用linux环境下的内存泄漏检查工具Valgrind,另一方面我们在写代码时可以添加内存申请和释放的统计功能,统计当前申请和释放的内存是否一致,以此来判断内存是否泄露。

四种强制类型转换

1, static_cast
用法:static_cast <类型说明符> (变量或表达式)

它主要有如下几种用法:
(1)用于类层次结构中基类和派生类之间指针或引用的转换
进行上行转换(把派生类的指针或引用转换成基类表示)是安全的
进行下行转换(把基类的指针或引用转换为派生类表示),由于没有动态类型检查,所以是不安全的
(2)用于基本数据类型之间的转换,如把int转换成char。这种转换的安全也要开发人员来保证
(3)把空指针转换成目标类型的空指针

double *c = new double;
void *d = static_cast<void*>(c);//正确,将double指针转换成void指针

(4)把任何类型的表达式转换为void类型
注意:static_cast不能转换掉expression的const、volitale或者__unaligned属性。

static_cast:可以实现C++中内置基本数据类型之间的相互转换。
2. const_cast
用法:const_cast<type_id> (expression)
该运算符用来修改类型的const或volatile属性。除了const 或volatile修饰之外, type_id和expression的类型是一样的。
常量指针被转化成非常量指针,并且仍然指向原来的对象;
常量引用被转换成非常量引用,并且仍然指向原来的对象;常量对象被转换成非常量对象。
3. reinterpret_cast
用法:reinterpret_cast<type_id> (expression)
type-id必须是一个指针、引用、算术类型、函数指针或者成员指针。
它可以把一个指针转换成一个整数,也可以把一个整数转换成一个指针(先把一个指针转换成一个整数,在把该整数转换成原类型的指针,还可以得到原先的指针值)。
在使用reinterpret_cast强制转换过程仅仅只是比特位的拷贝,因此在使用过程中需要特别谨慎!

4) dynamic_cast
用法:dynamic_cast<type_id> (expression)

  • 其他三种都是编译时完成的,dynamic_cast是运行时处理的,运行时要进行类型检查。
  • 不能用于内置的基本数据类型的强制转换。
  • dynamic_cast转换如果成功的话返回的是指向类的指针或引用,转换失败的话则会返回NULL。
  • 使用dynamic_cast进行转换的,基类中一定要有虚函数,否则编译不通过。
  • 在类的转换时,在类层次间进行上行转换时,dynamic_cast和static_cast的效果是一样的。在进行下行转换时,dynamic_cast具有类型检查的功能,比static_cast更安全。向上转换,即为子类指针指向父类指针(一般不会出问题);向下转换,即将父类指针转化子类指针。

c++11 新特性

(参考文章:https://blog.csdn.net/jiange_zh/article/details/79356417)
1. nullptr
nullptr 出现的目的是为了替代 NULL。
传统 C++ 会把 NULL、0 视为同一种东西。
而这依然会产生问题,将导致了 C++ 中重载特性会发生混乱,考虑:

void foo(char *);
void foo(int);

对于这两个函数来说,如果 NULL 又被定义为了 0 那么 foo(NULL); 这个语句将会去调用 foo(int),从而导致代码违反直观。
为了解决这个问题,C++11 引入了 nullptr 关键字,专门用来区分空指针、0。
当需要使用 NULL 时候,养成直接使用 nullptr的习惯。

2. 类型推导
C++11 引入了 auto 和 decltype 这两个关键字实现了类型推导,让编译器来操心变量的类型。
使用 auto 进行类型推导的一个最为常见而且显著的例子就是迭代器。在以前我们需要这样来书写一个迭代器:

for(vector<int>::const_iterator itr = vec.cbegin(); itr != vec.cend(); ++itr)

而有了 auto 之后可以:

// 由于 cbegin() 将返回 vector<int>::const_iterator 
// 所以 itr 也应该是 vector<int>::const_iterator 类型
for(auto itr = vec.cbegin(); itr != vec.cend(); ++itr);

3. Lambda 表达式
Lambda 表达式,实际上就是提供了一个类似匿名函数的特性,而匿名函数则是在需要一个函数,但是又不想费力去命名一个函数的情况下去使用的。

Lambda 表达式的基本语法如下:

[ caputrue ] ( params ) opt -> ret { body; };

4.initializer形参
同名头文件
如果一个函数不确定参数的数量 则可用此。

void print(initializer_list<string> list)
{
	for(auto pbeg = list.begin(); pbeg != list.end(); pbeg++)
	{
		cout << *pbeg;
	}
}
int main()
{
	string a = "\t\t草\n";
	string b = "\t\t\t李白(不许笑,就是我写的,我的草,我草!!!)\n";
	string c = "\t离离原上草,\n";
	string d = "\t越赚钱越少。\n";
	string e = "\t你若喜欢看,\n";
	string f = "\t一会点个赞。\n";
	print({a});
	print({b,c,d,e,f});
	return 0;
}

initializer_list的值默认是const,不能改变

5.委托构造
C++11 引入了委托构造的概念,这使得构造函数可以在同一个类中一个构造函数调用另一个构造函数,从而达到简化代码的目的:

class Base {
public:
    int value1;
    int value2;
    Base() {
        value1 = 1;
    }
    Base(int value) : Base() {  // 委托 Base() 构造函数
        value2 = 2;
    }
};

6.继承构造
在继承体系中,如果派生类想要使用基类的构造函数,需要在构造函数中显式声明。
假若基类拥有为数众多的不同版本的构造函数,这样,在派生类中得写很多对应的“透传”构造函数。如下:

struct A
{
  A(int i) {}
  A(double d,int i){}
  A(float f,int i,const char* c){}
  //...等等系列的构造函数版本
}struct B:A
{
  B(int i):A(i){}
  B(double d,int i):A(d,i){}
  B(folat f,int i,const char* c):A(f,i,e){}
  //......等等好多个和基类构造函数对应的构造函数
}

C++11的继承构造:

struct A
{
  A(int i) {}
  A(double d,int i){}
  A(float f,int i,const char* c){}
  //...等等系列的构造函数版本
}struct B:A
{
  using A::A;
  //关于基类各构造函数的继承一句话搞定
  //......
};

完整demo

#include<iostream>
using namespace std;
class A {
public:
	A(int a = 3, double b = 4) : m_a(a), m_b(b) 
	{
		printf("a = %d, b = %d", m_a, m_b);
	};
	void display() 
	{ 
		cout << m_a << " " << m_b << endl; 
	}


private:
	int m_a;
	double m_b;
};

class B : public A {
public:
	using A::A;
};
int main() {
	B b(4,5); //自动调用构造函数
	return 0;
}

STL 的组成模块

Standard Template Library,标准模板库,是C++的标准库之一,一套基于模板的容器类库,还包括许多常用的算法,提高了程序开发效率和复用性。STL包含6大部件:容器、迭代器、算法、仿函数、适配器和空间配置器。

容器:容纳一组元素的对象。
迭代器:提供一种访问容器中每个元素的方法。
函数对象:一个行为类似函数的对象,调用它就像调用函数一样。
算法:包括查找算法、排序算法等等。
适配器:用来修饰容器等,比如queue和stack,底层借助了deque。
空间配置器:负责空间配置和管理。

STL 容器有那些?适配器有哪些?

序列式容器
是一种线性结构,然后根据位置来存储和访问这些元素,这就是序列式容器。
Vector(向量)、deque(双端队列)、list(列表)

关联式容器
是一种非线性的树结构,和插入顺序无关
通过键(key)来高效地查找和读取元素
Set(集合)、multiset(多重结合)、map(映射)、multimap(多重映射)
容器适配器
容器适配器让一种已存在的容器类型采用另一种不同的抽象类型的工作方式实现。
例如:stack(栈)适配器可使任何一种顺序容器以栈的方式工作。
系统提供了三种序列式容器适配器:stack(栈)、queue(队列)以及 priority_queue(优先级队列)。所有的适配器都会在其基础顺序容器上定义一个新接口。

vector 、list 、map和unordered_map增删除改查时间 复杂度

vector

  • push_back O(1)
  • insert O(n)
  • pop_back O(1)
  • 下标访问和下标值修改 O(1)
  • erase O(n)
  • 查找某个值 O(n)

list

  • push_front O(1)
  • push_back O(1)
  • insert O(1)
  • erase O(1)
  • pop_front O(1)
  • pop_back O(1)
  • 不支持下表访问和下标值修改 O(n)
  • 查找某个值 O(n)

map

  • insert() O(logn)
  • erase() O(logn)
  • find() O(logn) 找不到返回a.end()

unordered_map

  • insert(); O(1)
  • earse(); O(1)
  • [ ]; O(1)

STL 迭代器失效的几种情况

代器失效分三种情况考虑,也是非三种数据结构考虑,分别为数组型,链表型,树型数据结构。

数组型数据结构:该数据结构的元素是分配在连续的内存中,insert和erase操作,都会使得删除点和插入点之后的元素挪位置,所以,插入点和删除掉之后的迭代器全部失效,也就是说insert(*iter)(或erase(*iter)),然后在iter++,是没有意义的。解决方法:erase(*iter)的返回值是下一个有效迭代器的值。 iter =cont.erase(iter);

链表型数据结构:对于list型的数据结构,使用了不连续分配的内存,删除运算使指向删除位置的迭代器失效,但是不会失效其他迭代器.解决办法两种,erase(*iter)会返回下一个有效迭代器的值,或者erase(iter++).

树形数据结构: 使用红黑树来存储数据,插入不会使得任何迭代器失效;删除运算使指向删除位置的迭代器失效,但是不会失效其他迭代器.erase迭代器只是被删元素的迭代器失效,但是返回值为void,所以要采用erase(iter++)的方式删除迭代器。

参考文章:https://blog.csdn.net/qq_36631379/article/details/108265456

STL一级空间配置器、二级空间配置器

STL下的空间配置器分位两级,他们没有高低之分,只有一个条件,当用户所需要的的内存大小:

  • 1.大于128字节时,交给一级配置器处理
  • 2.小于等于128字节时,交给二级配置器处理

空间配置器是STL用来管理和分配内存的类型。
STL有一级空间配置器和二级空间配置器。
一级空间配置器就是对new和delete做了个简单的包装。
二级空间配置器是利用的池的思想,用一个free-list维护很多不同大小的字节块(8-128bytes),然后每次就分配这样大小的内存块(就近取8的整数倍),如果超过128byte就直接用malloc分配。然后释放内存也放回free-list。如果free-list中没有对应的内存块了,就像内存池中申请内存(内存是一大块连续内存),然后返回用户需要的内存,其余插入到free-list中作为补充。如果内存池用完了,就调用malloc获取大块的连续内存。

在这里插入图片描述

常见的几种仿函数

算术类仿函数
用于算术运算,包括加法:plus,减法:minus,乘法:multiplies,除法:divides,取模:modulus,取反:negate。

关系类仿函数
用于进行关系运算,包括等于:equal_to,不等于:not_equal_to,大于:greater,大于等于:greater_equal,小于:less,小于等于:less_equal。

逻辑类仿函数
提供几种逻辑运算,包括逻辑运算and:logical_and,逻辑运算or:logical_or,逻辑运算not:logical_not。

证同(identity)、选择(select)、投射(project)
证同用于返回本身;选择用于接受一个pair,返回第一个元素或第二个元素;投射传回第一参数,忽略第二参数或相反。

来看一下他们的运用实例:

#include<functional>
#include<iostream>
using namespace std;
 
int main()
{
	//算术类仿函数
	plus<int> plusobj;
	minus<int> minusobj;
	multiplies<int> mulobj;
	divides<int> divobj;
	modulus<int> modobj;
	negate<int> negobj;
   
    //除下面的使用方式外,还可以用临时对象调用
	cout << plusobj(3, 5) << endl; //8
	cout << minusobj(3, 5) << endl; //-2
	cout << mulobj(3, 5) << endl; //15
	cout << divobj(3, 5) << endl; //0
	cout << modobj(3, 5) << endl; //3
	cout << negobj(3) << endl; //-3

	//关系类仿函数,不等于:,大于:greater<T>,大于等于:greater_equal<T>,小于:less<T>,小于等于:less_equal<T>。
	equal_to<int> equal_to_obj; 
	not_equal_to<int> not_equal_to_obj;
	greater<int> greater_obj;
	greater_equal<int> greater_equal_obj;
	less<int> less_obj;
	less_equal<int> less_equal_obj;
 
	cout << boolalpha << equal_to_obj(3, 5) << endl; //false
	cout << not_equal_to_obj(3, 5) << endl;//true
	cout << greater_obj(3, 5) << endl;//false
	cout << greater_equal_obj(3, 5) << endl;//false
	cout << less_obj(3, 5) << endl;//true
	cout << less_equal_obj(3, 5) << endl;//true

	//逻辑类仿函数
	logical_and<int> logical_and_obj;
	logical_or<int> logical_or_obj;
	logical_not<int> logical_not_obj;
 
	cout << logical_and_obj(1, 0) << endl; //false
	cout << logical_or_obj(1, 0) << endl;//true
	cout << logical_not_obj(1) << endl;//false

	return 0;
}

hash_table底层实现是什么?

unordered_map和unordered_set的底层数据结构讲一下。
底层为开链表实现,用一个数组存储桶,每个桶都是hash值相同的键的集合,用链表实现。通过hash函数找到对应的桶,然后在这个桶的链表上完成查找、删除、添加的操作。
hash_table的扩容机制,底层维护一个负载因子,表示当前元素个数/桶的个数,一般默认超过0.75就扩容,底层数据结构维护了扩容的规模变化。

gdb 调试正在运行的程序

调试步骤:

  • 编译时候带-g选项。
  • 运行程序。
  • ps找到进程号。
  • 启动gdb,使用attach选项,这时gdb会停止在程序的某处。
  • 按照GDB调试方法调试。当程序退出之后,依然可以使用run命令重启程序# 。

参考文章
https://www.nowcoder.com/discuss/637559?source_id=discuss_experience_nctrack&channel=-1
https://cplusplus.blog.csdn.net/category_10581430.html

计算机网络+网络编程

TCP和UDP的区别

在这里插入图片描述

计算机网络的结构?

一般来说,可以说TCP/IP体系结构,忽略物理层,有:

  • 应用层(HTTP、FTP、DNS等协议):实现应用到应用之间的通信;
  • 传输层(TCP、UDP等协议):实现进程到进程之间的通信;
  • 网络层(IP、ICMP等协议):实现主机到主机之间的通信;
  • 数据链路层(ARP等协议):实现点到点之间的通信。

DNS 两种查询方式

(1)递归查询:本机向本地域名服务器发出一次查询请求,就静待最终的结果。如果本地域名服务器无法解析,自己会以DNS客户机的身份向其它域名服务器查询,直到得到最终的IP地址告诉本机。
(2)迭代查询:本地域名服务器向根域名服务器查询,根域名服务器告诉它下一步到哪里去查询,然后它再去查,每次它都是以客户机的身份去各个服务器查询。(本机发多个请求查询ip)

四次挥手

在这里插入图片描述挥手请求可以是Client端,也可以是Server端发起的,我们假设是Client端发起:

第一次挥手: Client端发起挥手请求,向Server端发送标志位是FIN报文段,设置序列号seq,此时,Client端进入FIN_WAIT_1状态,这表示Client端没有数据要发送给Server端了。
第二次分手:Server端收到了Client端发送的FIN报文段,向Client端返回一个标志位是ACK的报文段,ack设为seq加1,Client端进入FIN_WAIT_2状态,Server端告诉Client端,我确认并同意你的关闭请求。
第三次分手: Server端向Client端发送标志位是FIN的报文段,请求关闭连接,同时Client端进入LAST_ACK状态。
第四次分手 : Client端收到Server端发送的FIN报文段,向Server端发送标志位是ACK的报文段,然后Client端进入TIME_WAIT状态。Server端收到Client端的ACK报文段以后,就关闭连接。此时,Client端等待2MSL的时间后依然没有收到回复,则证明Server端已正常关闭,那好,Client端也可以关闭连接了。

为什么要等待2MSL?

MSL:报文段最大生存时间,它是任何报文段被丢弃前在网络内的最长时间。有以下两个原因:

第一点:保证TCP协议的全双工连接能够可靠关闭:由于IP协议的不可靠性或者是其它网络原因,导致了Server端没有收到Client端的ACK报文,那么Server端就会在超时之后重新发送FIN,如果此时Client端的连接已经关闭处于CLOESD状态,那么重发的FIN就找不到对应的连接了,从而导致连接错乱,所以,Client端发送完最后的ACK不能直接进入CLOSED状态,而要保持TIME_WAIT,当再次收到FIN的收,能够保证对方收到ACK,最后正确关闭连接。
第二点:保证这次连接的重复数据段从网络中消失如果Client端发送最后的ACK直接进入CLOSED状态,然后又再向Server端发起一个新连接,这时不能保证新连接的与刚关闭的连接的端口号是不同的,也就是新连接和老连接的端口号可能一样了,那么就可能出现问题:如果前一次的连接某些数据滞留在网络中,这些延迟数据在建立新连接后到达Client端,由于新老连接的端口号和IP都一样,TCP协议就认为延迟数据是属于新连接的,新连接就会接收到脏数据,这样就会导致数据包混乱。所以TCP连接需要在TIME_WAIT状态等待2倍MSL,才能保证本次连接的所有数据在网络中消失。

TCP通信的异常情况及对应的解决方案。

1. 试图与一个不存在的端口建立连接:服务器端口还没有监听,我们的客户端就调用connect,视图与其建立连接。这时会发生什么呢?这符合触发RST分节的条件,目的为某端口的SYN分节到达,而端口没有监听,那么内核会立即响应一个RST,表示出错。客户端TCP收到这个RST之后则放弃这次连接的建立,并且返回给应用程序一个错误。正如上面所说,建立连接的过程对应用程序来说是不可见的,这是操作系统帮我们来完成的,所以即使进程没有启动,也可以响应客户端。
2 试图与一个不存在的主机上面的某个端口建立连接:这也是一种比较常见的情况,当某台服务器主机宕机了,而客户端并不知道,仍然尝试去与其建立连接。这个时候由于宕机,操作系统帮不上忙,服务器处于一种完全没有响应的状态。那么此时客户端的TCP会怎么办呢?客户端不会收到任何响应,那么等待6s之后再发一个SYN,若无响应则等待24s之后再发一个,若总共等待了75s后仍未收到响应就会返回ETIMEDOUT错误。这是TCP建立连接自己的一个保护机制,但是我们要等待75s才能知道这个连接无法建立,对于我们所有服务来说都太长了。更好的做法是在代码中给connect设置一个超时时间。
3 Server进程被阻塞:由于某些情况,服务器端进程无法响应任何请求,比如所在主机的硬盘满了,导致进程处于完全阻塞,通常我们测试时会用gdb模拟这种情况。上面提到过,建立连接的过程对应用程序是不可见的,那么,这时连接可以正常建立。当然,客户端进程也可以通过这个连接给服务器端发送请求,服务器端TCP会应答ACK表示已经收到这个分节(这里的收到指的是数据已经在内核的缓冲区里准备好,由于进程被阻塞,无法将数据从内核的缓冲区复制到应用程序的缓冲区),但永远不会返回结果。
4 我们杀死了server:这是线上最常见的操作,当一个模块上线时,OP同学总是会先把旧的进程杀死,然后再启动新的进程。那么在这个过程中TCP连接发生了什么呢。在进程正常退出时会自动调用close函数来关闭它所打开的文件描述符,这相当于服务器端来主动关闭连接——会发送一个FIN分节给客户端TCP;客户端要做的就是配合对端关闭连接,TCP会自动响应一个ACK,然后再由客户端应用程序调用close函数,也就是我们上面所描述的关闭连接的4次挥手过程。接下来,客户端还需要定时去重连,以便当服务器端进程重新启动好时客户端能够继续与之通信。
5 Server进程所在的主机宕机:客户端向服务器端发送分节,由于服务器端宕机,不会有任何响应,客户端持续重传,然而服务器始终不能应答,重传数次之后,大约4~10分钟才停止,之后返回一个ETIMEDOUT错误。

TCP 怎么保证传输过程的可靠性?

校验和:发送方在发送数据之前计算校验和,接收方收到数据后同样计算,如果不一致,那么传输有误。
确认应答,序列号:TCP进行传输时数据都进行了编号,每次接收方返回ACK都有确认序列号。
超时重传:如果发送方发送数据一段时间后没有收到ACK,那么就重发数据。
连接管理:三次握手和四次挥手的过程。
流量控制:TCP协议报头包含16位的窗口大小,接收方会在返回ACK时同时把自己的即时窗口填入,发送方就根据报文中窗口的大小控制发送速度。
拥塞控制:刚开始发送数据的时候,拥塞窗口是1,以后每次收到ACK,则拥塞窗口+1,然后将拥塞窗口和收到的窗口取较小值作为实际发送的窗口,如果发生超时重传,拥塞窗口重置为1。这样做的目的就是为了保证传输过程的高效性和可靠性。

TCP keep-alive 和HTTP keep-alive的区别?

http keep-alive是为了让tcp活得更久一点,以便在同一个连接上传送多个http,提高socket的效率。
tcp keep-alive是TCP的一种检测TCP连接状况的保鲜机制。tcp keep-alive保鲜定时器,支持三个系统内核配置参数:

1 echo 1800 > /proc/sys/net/ipv4/tcp_keepalive_time
2 echo 15 > /proc/sys/net/ipv4/tcp_keepalive_intvl
3 echo 5 > /proc/sys/net/ipv4/tcp_keepalive_probes

该例子就是TCP连接闲置1800s之后,开始隔15s,30s,45s,60s,75s发送一次心跳包,最后一次心跳包仍然没有回复,则掐断连接。

负载均衡有哪些实现方式?

DNS:这是最简单的负载均衡的方式,一般用于实现地理级别的负载均衡,不同地域的用户通过DNS的解析可以返回不同的IP地址,这种方式的负载均衡简单,但是扩展性太差,控制权在域名服务商。
Http重定向:通过修改Http响应头的Location达到负载均衡的目的,Http的302重定向。这种方式对性能有影响,而且增加请求耗时。
反向代理:作用于应用层的模式,也被称作为七层负载均衡,比如常见的Nginx,性能一般可以达到万级。这种方式部署简单,成本低,而且容易扩展。
IP:作用于网络层的和传输层的模式,也被称作四层负载均衡,通过对数据包的IP地址和端口进行修改来达到负载均衡的效果。常见的有LVS(Linux Virtual Server),通常性能可以支持10万级并发。
按照类型来划分的话,还可以分成DNS负载均衡、硬件负载均衡、软件负载均衡。

其中硬件负载均衡价格昂贵,性能最好,能达到百万级,软件负载均衡包括Nginx、LVS这种。

LT模式和ET模式,ET模式下accept()为什么一些连接会接收不了?

LT模式:LT是epoll默认的工作方式,支持阻塞和非阻塞两种机制。LT模式下内核会持续通知你文件描述符就绪了,然后你可以对这个就绪的fd进行I/O操作。如果不做任何操作,内核还是会继续通知你的。
ET模式:ET模式相对LT模式更加高效,只支持非阻塞模式。在这个模式下,当描述符从未就绪变为就绪时,内核通过epoll告诉你。然后它会假设你知道文件描述符已经就绪,并且不再为那个文件描述符发生更多的就绪通知。直到你做了某些操作导致那个文件描述符不再为就绪状态了。
ET模式在很大程度上减少了epoll事件被重复触发的次数,因此效率要比LT模式高。epoll工作在ET模式时,必须使用非阻塞套接口,以避免一个文件句柄的阻塞导致把其他文件描述符饿死。
ET模式下,只会触发一次读事件,如果不循环读取,除非新的连接到来,其它未被读入的链接不会触发IO事件。

怎么理解同步和阻塞?

首先,可以认为一个IO操作包含两个部分:

  • 1.发起IO请求
  • 2.实际的IO读写操作

同步异步在于第二个,实际的IO读写操作,如果操作系统帮你完成了再通知你,那就是异步,否则都叫做同步。
阻塞非阻塞在于第一个,发起IO请求,非阻塞IO 发起IO操作请求后就返回了,所以是非阻塞。

https整个过程中产生的三个随机数有什么用?

整个过程会生成3个key,分别是client端生成的随机数Aserver端生成的随机数B,client端生成了随机数pre-master
pre-master 会使用证书中的公钥进行加密在网络上进行传输,服务端使用私钥可以解开拿到pre-master。
在这里插入图片描述

对于客户端
当其生成了Pre-master secret之后,会结合原来的A、B随机数,用DH算法计算出一个master secret,紧接着根据这个master secret推导出hash secretsession secret

对于服务端
当其解密获得了Pre-master secret之后,会结合原来的A、B随机数,用DH算法计算出一个master secret,紧接着根据这个master secret推导出hash secretsession secret

在客户端和服务端的master secret是依据三个随机数推导出来的,它是不会在网络上传输的,只有双方知道,不会有第三者知道。同时,客户端推导出来的session secrethash secret与服务端也是完全一样的。

那么现在双方如果开始使用对称算法加密来进行通讯,使用哪个作为共享的密钥呢?过程是这样子的:

双方使用对称加密算法进行加密,用hash secret对HTTP报文做一次运算生成一个MAC,附在HTTP报文的后面,然后用session-secret加密所有数据(HTTP+MAC),然后发送。

接收方则先用session-secret解密数据,然后得到HTTP+MAC,再用相同的算法计算出自己的MAC,如果两个MAC相等,证明数据没有被篡改。

操作系统

进程和线程的关系

进程 = 资源管理 + 线程, 进程是资源分配单位,线程是 CPU 调度单位

根本区别:进程是操作系统资源分配的基本单位,而线程是任务调度和执行的基本单位

其他区别

  • 进程有自己独立的地址空间,每启动一个进程,系统都会为其分配地址空间,建立数据表来维护代码段、堆栈段和数据段,线程没有独立的地址空间,它使用相同的地址空间共享数据;
  • CPU切换一个线程比切换进程花费小;
  • 创建一个线程比进程开销小;
  • 线程占用的资源要⽐进程少很多。
  • 线程之间通信更方便,同一个进程下,线程共享全局变量,静态变量等数据,进程之间的通信需要以通信的方式(IPC)进行;(但多线程程序处理好同步与互斥是个难点)
  • 多进程程序更安全,生命力更强,一个进程死掉不会对另一个进程造成影响(源于有独立的地址空间),多线程程序更不易维护,一个线程死掉,整个进程就死掉了(因为共享地址空间);
  • 进程对资源保护要求高,开销大,效率相对较低,线程资源保护要求不高,但开销小,效率高,可频繁切换;

进程切换和线程切换代价对比

进程切换的开销到底有哪些?
开销分成两种,一种是直接开销、一种是间接开销。

直接开销就是在切换时,cpu必须做的事情,包括:

  • 切换页表全局目录
  • 切换内核态堆栈
  • 切换硬件上下文(进程恢复前,必须装入寄存器的数据统称为硬件上下文)
  • ip(instruction pointer):指向当前执行指令的下一条指令
    • bp(base pointer): 用于存放执行中的函数对应的栈帧的栈底地址
    • sp(stack poinger): 用于存放执行中的函数对应的栈帧的栈顶地址
    • cr3:页目录基址寄存器,保存页目录表的物理地址
  • 刷新TLB
  • 系统调度器的代码执行

间接开销主要指的是虽然切换到一个新进程后,由于各种缓存并不热,速度运行会慢一些。如果进程始终都在一个CPU上调度还好一些,如果跨CPU的话,之前热起来的TLB、L1、L2、L3因为运行的进程已经变了,所以以局部性原理cache起来的代码、数据也都没有用了,导致新进程穿透到内存的IO会变多。

线程切换和进程切换之间的主要区别在于:在线程切换期间,虚拟内存空间保持不变,而在进程切换期间则不然。两种类型都涉及将控制权交给操作系统内核以执行上下文切换。 切换进出OS内核的过程以及切换寄存器的成本是执行上下文切换的最大固定成本。

同一进程间的线程究竟共享哪些资源呢,而又各自独享哪些资源呢?

共享的资源有

  • a. 堆 由于堆是在进程空间中开辟出来的,所以它是理所当然地被共享的;因此new出来的都是共享的(16位平台上分全局堆和局部堆,局部堆是独享的)
  • b. 全局变量 它是与具体某一函数无关的,所以也与特定线程无关;因此也是共享的
  • c. 静态变量 虽然对于局部变量来说,它在代码中是“放”在某一函数中的,但是其存放位置和全局变量一样,存于堆中开辟的.bss和.data段,是共享的
  • d. 文件等公用资源 这个是共享的,使用这些公共资源的线程必须同步。Win32 提供了几种同步资源的方式,包括信号、临界区、事件和互斥体。

独享的资源有

  • a. 栈 栈是独享的
  • b. 寄存器 这个可能会误解,因为电脑的寄存器是物理的,每个线程去取值难道不一样吗?其实线程里存放的是副本,包括程序计数器PC

伙伴算法

伙伴算法用于解决外部内存碎片的问题。
Linux内核中引入了伙伴系统算法(buddy system)。把所有的空闲页框分组为11个块链表,每个块链表分别包含大小为1,2,4,8,16,32,64,128,256,512和1024个连续页框的页框块。最大可以申请1024个连续页框,对应4MB大小的连续内存。每个页框块的第一个页框的物理地址是该块大小的整数倍。
在这里插入图片描述

假设要申请一个256个页框的块,先从256个页框的链表中查找空闲块,如果没有,就去512个页框的链表中找,找到了则将页框块分为2个256个页框的块,一个分配给应用,另外一个移到256个页框的链表中。如果512个页框的链表中仍没有空闲块,继续向1024个页框的链表查找,如果仍然没有,则返回错误。

slab分配机制

slab分配器为每种对象分配一个高速缓存,这个缓存可以看做是同类型对象的一种储备。每个高速缓存所占的内存区又被划分多个slab,每个 slab是由一个或多个连续的页框组成。每个页框中包含若干个对象,既有已经分配的对象,也包含空闲的对象。slab分配器的大致组成图如下:
在这里插入图片描述

页面置换算法

1.先进先出调度算法(FIFO,First In First Out)
先进先出调度算法是根据页面进入内存的时间先后选择调度页面,该算法实现时需要将页面按照进入的时间先后组成一个队列,每次优先淘汰队首页面。他的优点是比较容易实现,能够利用主存储器中页面调度情况的历史信息,但是,他没有反映程序的局部性,因为最先调入主存的页面,很可能也是经常要使用的页面。

2.最近最不常用调度算法(LFU, Least Frequently Used)
也就是淘汰一定时期内被访问次数最少的页面,LFU关键是看一定时间段内页面被使用的频率。

3.最近最少使用页面调度算法(LRU,Least Recently Used)
也就是首先淘汰最长时间未被使用的页面,LRU关键是看页面最后一次被使用到发生调度的时间长短。

4.时钟置换算法
为每一页设置访问位,将内存中所有页面通过连接指针接成循环队列,当页面被访问时访问位置1,每次淘汰时,从指针当前位置开始循环遍历,将访问位为1的置为0,找到第一个访问位为0的将其淘汰。

5.最佳置换算法
每次淘汰时,找一个未来最长时间才会被访问的页面进行淘汰。
优点:缺页率低
缺点:需要预测未来,无法实现,但可以用来衡量其他置换算法。

讲一讲多进程通信?

有多种通信方式:匿名管道、有名管道、信号、消息队列、信号量、共享内存、套接字。
1)匿名管道:本质是内核缓冲区,可用于亲缘进程(父子进程,兄弟进程)间通信,半双工,一端读一端写,先进先出。
2)有名管道:本质就是一个文件,所以可以提供给没有亲缘关系的进程来通信,。
3)信号:信号可以在任何时候发给某一进程,这是一种异步通信方式。
4)消息队列:存放于内核的某个消息链表,允许多个进程进行读写。
5)信号量:信号量是一个计数器,提供原子的P、V操作,用于进程同步。
6)共享内存:使得多个进程可以可以直接读写同一块内存空间,是最快的可用IPC形式。是针对其他通信机制运行效率较低而设计的。但是共享内存如果中间涉及到写操作,往往需要同步机制进行辅助,比如信号量。
7)套接字:套接字主要用于不同主机的进程之间的通信。

各种线程同步方式(信号量、互斥锁、自旋锁、读写锁、条件变量、屏障barrier等)

信号量

//posix 无名信号量
int sem_init(sem_t* sem,int pshared,unsigned int value);
// 初始化一个信号量
// pshared表示是否在进程间共享,0表示只在线程间共享,否则进程间共享
// value为设置的初始值
int sem_destroy(sem_t* sem);
// 销毁一个线程
int sem_wait(sem_t* sem);
// P操作,对信号量-1
int sem_post(sem_t* sem);
// V操作,信号量+1
int sem_getvalue(sem_t* sem, int* valp);
// 返回信号量的值到valp

1、System V的信号量一般用于进程同步, 且是内核持续的, api为:semget、semctl、semop
2、Posix的有名信号量一般用于进程同步, 有名信号量是内核持续的. 有名信号量的api为:sem_open、sem_close、sem_unlink
3、Posix的无名信号量一般用于线程同步, 无名信号量是进程持续的, 无名信号量的api为:sem_init、sem_destroy

互斥锁

int pthread_mutex_init(pthread_mutex_t* mutex, const thread_mutexattr_t* mutexattr);
// 初始化一个互斥锁,mutexattr是相关设置参数
int pthread_mutex_lock(pthread_mutex_t* mutex);
// 对互斥锁加锁
int pthread_mutex_unlock(pthread_mutex_t* mutex);
// 解锁
int pthread_mutex_trylock(pthread_mutex_t* mutex;
// 非阻塞加锁,如果已经上锁,不会阻塞,避免死锁
int pthread_mutex_destroy(pthread_mutex_t* mutex);
// 用来撤销互斥锁的资源。

设置共享对象的属性为PTHREAD_PROCESS_SHARED可以实现跨进程之间的同步,但是需要将锁放在共享内存中。

读写锁

int pthread_rwlock_init(pthread_rwlock_t* rwlock, const pthread_rwlockattr_t* attr);
// 初始化读写锁
int pthread_destroy(pthread_rwlock_t* rwlock);
// 销毁读写锁
int pthread_rwlock_rdlock(pthread_rwlock_t* rwlock);
// 加读锁
int pthread_rwlock_wrlock(pthread_rwlock_t* rwlock);
// 加写锁
int pthread_rwlock_unlock(pthread_rwlock_t* rwlock);
// 解锁

自旋锁
自旋锁和互斥锁差不多,区别是自旋锁阻塞方式和互斥锁不同,互斥锁是让线程睡眠来实现阻塞,而自旋锁是通过不断循环让线程忙等待,适用于占用自旋锁时间比较短的情况。

int pthread_spin_init(__pthread_spinlock_t* __lock, int__pshared);
int pthread_spin_destroy(__pthread_spinlock_t* __lock);
int pthread_spin_trylock(__pthread_spinlock_t* __lock);
int pthread_spin_unlock(__pthread_spinlock_t* __lock);
int pthread_spin_lock(__pthread_spinlock_t* __lock);

fork和vfork的区别:

  1. fork( )的子进程拷贝父进程的数据段和代码段;vfork( )的子进程与父进程共享数据段

  2. fork( )的父子进程的执行次序不确定;vfork( )保证子进程先运行,在调用exec或exit之前与父进程数据是共享的,在它调用exec或exit之后父进程才可能被调度运行。

  3. vfork( )保证子进程先运行,在它调用exec或exit之后父进程才可能被调度运行。如果在调用这两个函数之前子进程依赖于父进程的进一步动作,则会导致死锁。

  4. 当需要改变共享数据段中变量的值,则拷贝父进程。

Linux的虚拟内存、物理内存

物理内存指的是真实的内存。
虚拟内存通过建立映射关系映射到物理内存上。
虚拟内存是硬件异常、硬件地址翻译、主存、磁盘文件和内核软件的完美交互,它为每个进程提供了一个大的、一致的和私有的地址空间。它有3个重要的能力:

  • 它将主存看成是一个存储在磁盘上的地址空间的高速缓存,在主存中只保存活动区域,并根据需要在主存和磁盘之间来回传送数据,通过这种方式,它高效地使用了主存。
  • 作为内存管理工具,简化内存管理:每个进程都有统一的线性地址空间(但实际上在物理内存中可能是间隔、支离破碎的),在内存分配中没有太多限制,每个虚拟页都可以被映射到任何的物理页上。这样也带来一个好处,如果两个进程间有共享的数据,那么直接指向同一个物理页即可。
  • 作为内存保护工具,隔离地址空间:进程之间不会相互影响;用户程序不能访问内核信息和代码。页表中的每个条目的高位部分是表示权限的位,MMU 可以通过检查这些位来进行权限控制(读、写、执行)。

请你说一说用户态和内核态区别

用户态和内核态是操作系统的两种运行级别,两者最大的区别就是特权级不同。用户态拥有最低的特权级,内核态拥有较高的特权级。运行在用户态的程序不能直接访问操作系统内核数据结构和程序。内核态和用户态之间的转换方式主要包括:系统调用,异常和中断。

内核空间和用户空间,为啥要这么区分;

对于 Linux 来说,通过区分内核空间和用户空间的设计,隔离了操作系统代码(操作系统的代码要比应用程序的代码健壮很多)与应用程序代码。

即便是单个应用程序出现错误也不会影响到操作系统的稳定性,这样其它的程序还可以正常的运行(Linux 可是个多任务系统啊!)。

所以,区分内核空间和用户空间本质上是要提高操作系统的稳定性及可用性

什么是系统调用?

进程在系统上的运行分为2个级别

  • 用户态(user mode):用户态运行的进程可以直接读取用户程序的数据
  • 内核态(kernel mode):系统态运行的程序可以访问计算机的任何资源,不受限制

平常我们运行的程序都是用户态的,如果想要将进程运行在内核态则需要利用系统调用。

在我们运行的用户程序中,凡是与系统级别的资源有关的操作(例如文件管理、进程控制、内存管理等)都必须通过系统调用方式向OS提出服务请求,并由OS代为完成。
系统调用的功能大致分为

  • 设备管理:完成设备的请求/释放以及设备的启动
  • 文件管理:完成文件的读写、删除、创建等功能
  • 进程控制:完成进程的创建、撤销、阻塞以及唤醒等功能
  • 内存管理:完成内存的分配、回收以及获取作业占用内存区大小和地址等功能

什么是分段和分页?

分段机制就是将逻辑地址通过段选择符段偏移量转化为线性地址的过程。

分页机制在段机制之后进行的,它通过页表查询进一步将线性地址转换为物理地址
在这里插入图片描述

linux内核中 逻辑地址、虚拟地址、线性地址和物理地址

逻辑地址,是由一个段选择符加上一个指定段内相对地址的偏移量(Offset)组成的,表示为 [段选择符段内偏移量],例如:[CSEIP]
虚拟地址,其实就是如上逻辑地址的段内偏移Offset。所以:
逻辑地址可以表示为 [段标识符虚拟地址]

分段操作可以从逻辑地址、虚拟地址推算出出线性地址
即:线性地址 = f(逻辑地址) = f(段标识符,虚拟地址)

分页操作可以从线性地址推算出物理地址
即:物理地址 = f(线性地址)

Linux内核将所有类型的段的 segment base address 都设成0(包括内核数据段、内核代码段、用户数据段、用户代码段等)。那么这样一来所有段都重合了,也就是不分段了,此外由于段限长是地址总线的寻址限度,所以这也就相当于所有段内空间跟整个线性空间重合了。

这样逻辑地址也就简化为了段内的偏移量(逻辑地址=虚拟地址)。

由于段基地址变为了0,那么线性地址=逻辑地址=虚拟地址
所以,在x86 linux内核里,逻辑地址、虚拟地址、线性地址,这是三个地址是一致的。
而物理地址则通过线性地址查询页表计算得到。

malloc的工作原理

malloc函数分配内存主要是使用brk和mmap系统调用

brk(): 小于128k
将数据段(.data)的最高地址指针_edata往高地址推;
mmap(): 大于128k
是在堆和栈之间(文件映射区域)找分配一块空闲的虚拟内存,

都是虚拟内存,没有分配物理内存。
在第一次访问已分配的虚拟地址空间的时候,发生缺页中断,操作系统负责分配物理内存,
然后建立虚拟内存和物理内存之间的映射关系。

例题1
进程使用malloc分配一块100M的内存,是马上就得到这块内存了吗?
不是,只是分配了虚拟内存。当第一次访问该虚拟空间的时候,发生缺页中断,操作系统才会分配内存。

epoll的工作原理

Linux epoll机制是通过红黑树和双向链表实现的。 首先通过epoll_create()系统调用在内核中创建一个eventpoll类型的句柄,其中包括红黑树根节点和双向链表头节点。然后通过epoll_ctl()系统调用,向epoll对象的红黑树结构中添加、删除、修改感兴趣的事件,返回0标识成功,返回-1表示失败。最后通过epoll_wait()系统调用判断双向链表是否为空,如果为空则阻塞。当文件描述符状态改变,fd上的回调函数被调用,该函数将fd加入到双向链表中,此时epoll_wait函数被唤醒,返回就绪好的事件。
在这里插入图片描述

进程调度算法

先来先服务调度算法
时间片轮转调度法
短作业(SJF)优先调度算法
最短剩余时间优先
高响应比优先调度算法
优先级调度算法
多级反馈队列调度算法

如何实现线程池

1.设置一个生产者消费者队列,作为临界资源
2.初始化n个线程,并让其运行起来,加锁去队列取任务运行
3.当任务队列为空的时候,所有线程阻塞
4.当生产者队列来了一个任务后,先对队列加锁,把任务挂在到队列上,然后使用条件变量去通知阻塞中的一个线程

请你说一说死锁发生的条件以及如何解决死锁

死锁是指两个或两个以上进程在执行过程中,因争夺资源而造成的下相互等待的现象。
死锁发生的四个必要条件如下:
互斥条件:进程对所分配到的资源不允许其他进程访问,若其他进程访问该资源,只能等待,直至占有该资源的进程使用完成后释放该资源;
请求和保持条件:进程获得一定的资源后,又对其他资源发出请求,但是该资源可能被其他进程占有,此时请求阻塞,但该进程不会释放自己已经占有的资源
不可剥夺条件:进程已获得的资源,在未完成使用之前,不可被剥夺,只能在使用后自己释放
环路等待条件:进程发生死锁后,必然存在一个进程-资源之间的环形链

解决死锁的方法即破坏上述四个条件之一,主要方法如下:
资源一次性分配,从而剥夺请求和保持条件
可剥夺资源:即当进程新的资源未得到满足时,释放已占有的资源,从而破坏不可剥夺的条件
资源有序分配法:系统给每类资源赋予一个序号,每个进程按编号递增的请求资源,释放则相反,从而破坏环路等待的条件

缺页中断

在请求分页系统中,可以通过查询页表中的状态位来确定所要访问的页面是否存在于内存中。每当所要访问的页面不在内存时,会产生一次缺页中断,此时操作系统会根据页表中的外存地址在外存中找到所缺的一页,将其调入内存。
缺页本身是一种中断,与一般的中断一样,需要经过4个处理步骤:

  • 1.保护CPU现场
  • 2.分析中断原因
  • 3.转入缺页中断处理程序进行处理
  • 4.恢复CPU现场,继续执行

但是缺页中断时由于所要访问的页面不存在与内存时,有硬件所产生的一种特殊的中断,因此,与一般的中断存在区别:

  • 1.在指令执行期间产生和处理缺页中断信号
  • 2.一条指令在执行期间,可能产生多次缺页中断 (如一条读取数据的多字节指令,指令本身跨越两个页面,若指令后一部分所在页面和数据所在页面均不在内存,则该指令的执行至少产生两次缺页中断)
  • 3.缺页中断返回时,执行产生中断的那一条指令,而一般的中断返回时,执行下一条指令

file_struct结构体、file结构体和inode结构体的联系

在这里插入图片描述

内存结构体pg_data_t、node和page的关系

在这里插入图片描述

数据库

数据库范式:

这个比较多,一般只说前三个范式即可。
1NF的定义为:符合1NF的关系中的每个属性都不可再分。表1所示的情况,就不符合1NF的要求。1NF是所有关系型数据库的最基本要求。
第二范式(2NF)在1NF的基础之上,消除了非主属性对于码的部分函数依赖。其定义是不存在非主属性对码存在部分函数依赖关系。
第三范式(3NF)在3NF的基础上,消除了非主属性对码的传递函数依赖。其定义是不存在非主属性对码存在传递函数依赖关系。

事务的四大特性(ACID)

原子性: 事务是最小的执行单位,不允许分割。事务的原子性确保动作要么全部完成,要么完全不起作用;
一致性: 执行事务前后,数据保持一致,多个事务对同一个数据读取的结果是相同的;
隔离性: 并发访问数据库时,一个用户的事务不被其他事务所干扰,各并发事务之间数据库是独立的;
持久性: 一个事务被提交之后。它对数据库中数据的改变是持久的,即使数据库发生故障也不应该对其有任何影响。

MYSQL MVCC机制

ReadView记录了事务的相关信息,用来与版本链配合使用,从而控制事务的可见性。ReadView中主要记录当前系统中还有哪些 活跃 的事务,用来 判断版本链中的哪个版本是当前事务可见的

已开启未提交的事务称为活跃事务。

ReadView中主要包含4个比较重要的内容:

  • m_ids:表示在生成ReadView时当前系统中活跃的读写事务的事务id列表。
  • min_trx_id:表示在生成ReadView时当前系统中活跃的读写事务中最小的事务id,也就是m_ids中的最小值。
  • max_trx_id:表示生成ReadView时系统中应该分配给下一个事务的id值。
  • creator_trx_id:表示生成该ReadView的事务的事务id。

在这里插入图片描述

注: max_trx_id 并不是 m_ids 中的最大值,事务id是递增分配的。比方说现在有id为1,2,3这三个事务,之后id为3的记录提交了。那么一个新的读事务在生成 Readview 时,m_ids就包括1和2,min_trx_id 的值就是1,max_trx_id的值就是4。

判断可见性的步骤就是

  • 如果记录的DB_TRX_ID列小于min_trx_id,即此事务是在ReadView创建前提交的,说明其可见。
  • 如果记录的DB_TRX_ID列大于max_trx_id,即此事务是在ReadView创建后开启的,说明其不可见。
  • 如果记录的DB_TRX_ID列在min_trx_idmax_trx_id之间,即此事务是活跃状态。则需要看该DB_TRX_ID在不在m_ids列表中,如果在,说明不可见,否则可见。

在MySQL中,读已提交(READ COMMITTED)和 可重复读(REPEATABLE READ)隔离级别的的一个非常大的区别就是它们生成ReadView的时机不同,所以可解决的问题不同。

在MySQL中,READ COMMITTED和REPEATABLE READ隔离级别的的一个非常大的区别就是它们生成ReadView的时机不同。

  • READ COMMITTED —— 每次读取数据前都生成一个ReadView
  • REPEATABLE READ —— 在第一次读取数据时生成一个ReadView

mvcc 可以解决幻读问题吗?

在快照读的情况下是可以解决“幻读”的问题的。(因为读到的都是同一个镜像)
在当前读的情况,需要配合next-key lock 才可以解决“幻读”问题。加了next-key lock后,加了记录锁和范围锁,会导致插入操作无法完成,从而解决幻读问题。

InnoDB的三种锁

  • 1,Record Lock:单个行记录上的锁。
  • 2,Gap Lock:间隙锁,锁定一个范围,但不包括记录本身。GAP锁的目的,是为了防止同一事务的两次当前读,出现幻读的情况。
  • 3,Next-Key Lock:1+2,锁定一个范围,并且锁定记录本身。对于行的查询,都是采用该方法,主要目的是解决幻读的问题。

B树和B+树的概念及其区别

B-树
B-树概述
B-树,这里的 B 表示 balance( 平衡的意思),B-树是一种多路自平衡的搜索树(B树是一颗多路平衡查找树)
它类似普通的平衡二叉树,不同的一点是B-树允许每个节点有更多的子节点。下图是 B-树的简化图.
在这里插入图片描述
B+ 树
B+树概述

B+树是B-树的变体,也是一种多路搜索树, 它与 B- 树的不同之处在于:

  • 所有关键字存储在叶子节点出现,内部节点(非叶子节点并不存储真正的 data)
  • 为所有叶子结点增加了一个链指针
    简化 B+树 如下图
    在这里插入图片描述
    B-树和B+树的区别
  • B+树内节点不存储数据,所有 data 存储在叶节点导致查询时间复杂度固定为 log n。而B-树查询时间复杂度不固定,与 key 在树中的位置有关,最好为O(1)。
  • B+树叶节点两两相连可大大增加区间访问性,可使用在范围查询等,而B-树每个节点 key 和 data 在一起,则无法区间查找。
  • B+树更适合外部存储。由于内节点无 data 域,每个节点能索引的范围更大更精确

redo log 和binlog的区别

redo log 与 binlog 的区别

  • redo log 是 InnoDB 引擎特有的;binlog 是 MySQL 的 Server 层实现的,所有引擎都可以使用。
  • redo log 是物理日志,记录的是“在某个数据页上做了什么修改”;binlog 是逻辑日志(sql语句),记录的是这个语句的原始逻辑
  • redo log 是循环写的,空间固定会用完;binlog 是可以追加写入的。

redo log恢复步骤

在这里插入图片描述

redolog中的事务如果经历了二阶段提交中的prepare阶段,则会打上prepare标识,如果经历commit阶段,则会打上commit标识(此时redolog和binlog均已落盘)。

Step1. 按顺序扫描redolog,如果redolog中的事务既有prepare标识,又有commit标识,就直接提交(复制redolog disk中的数据页到磁盘数据页)

Step2 .如果redolog事务只有prepare标识,没有commit标识,则说明当前事务在commit阶段crash了,binlog中当前事务是否完整未可知,此时拿着redolog中当前事务的XID(redolog和binlog中事务落盘的标识),去查看binlog中是否存在此XID

  • a.如果binlog中有当前事务的XID,则提交事务(复制redolog disk中的数据页到磁盘数据页)
  • b.如果binlog中没有当前事务的XID,则回滚事务(使用undolog来删除redolog中的对应事务)

可以将mysql redolog和binlog二阶段提交和广义上的二阶段提交进行对比,广义上的二阶段提交,若某个参与者超时未收到协调者的ack通知,则会进行回滚,回滚逻辑需要开发者在各个参与者中进行记录。mysql二阶段提交是通过xid进行恢复。

InnoDB和MyISAM

InnoDB
是 MySQL 默认的事务型存储引擎,只有在需要它不支持的特性时,才考虑使用其它存储引擎。

实现了四个标准的隔离级别,默认级别是可重复读(REPEATABLE READ)。在可重复读隔离级别下,通过多版本并发控制(MVCC)+ Next-Key Locking 防止幻影读。

主索引是聚簇索引,在索引中保存了数据,从而避免直接读取磁盘,因此对查询性能有很大的提升。

内部做了很多优化,包括从磁盘读取数据时采用的可预测性读、能够加快读操作并且自动创建的自适应哈希索引、能够加速插入操作的插入缓冲区等。

支持真正的在线热备份。其它存储引擎不支持在线热备份,要获取一致性视图需要停止对所有表的写入,而在读写混合场景中,停止写入可能也意味着停止读取。

MyISAM
设计简单,数据以紧密格式存储。对于只读数据,或者表比较小、可以容忍修复操作,则依然可以使用它。

提供了大量的特性,包括压缩表、空间数据索引等。

不支持事务。

不支持行级锁,只能对整张表加锁,读取时会对需要读到的所有表加共享锁,写入时则对表加排它锁。但在表有读取操作的同时,也可以往表中插入新的记录,这被称为并发插入(CONCURRENT INSERT)。

可以手工或者自动执行检查和修复操作,但是和事务恢复以及崩溃恢复不同,可能导致一些数据丢失,而且修复操作是非常慢的。

如果指定了 DELAY_KEY_WRITE 选项,在每次修改执行完成时,不会立即将修改的索引数据写入磁盘,而是会写到内存中的键缓冲区,只有在清理键缓冲区或者关闭表的时候才会将对应的索引块写入磁盘。这种方式可以极大的提升写入性能,但是在数据库或者主机崩溃时会造成索引损坏,需要执行修复操作。

比较

  • 事务:InnoDB 是事务型的,可以使用 Commit 和 Rollback 语句。
  • 并发:MyISAM 只支持表级锁,而 InnoDB 还支持行级锁。
  • 外键:InnoDB 支持外键。
  • 备份:InnoDB 支持在线热备份。
  • 崩溃恢复:MyISAM 崩溃后发生损坏的概率比 InnoDB 高很多,而且恢复的速度也更慢。
  • 其它特性:MyISAM 支持压缩表和空间数据索引。

MYSQL 最左匹配原则

最左前缀原则:顾名思义是最左优先,以最左边的为起点任何连续的索引都能匹配上。

  • 顾名思义,就是最左优先,在创建多列索引时,要根据业务需求,where子句中使用最频繁的一列放在最左边。
  • 最左前缀匹配原则,非常重要的原则,mysql会一直向右匹配直到遇到范围查询(>、<、between、like)就停止匹配,比如a = 1 and b = 2 and c > 3 and d = 4 如果建立(a,b,c,d)顺序的索引,d是用不到索引的,如果建立(a,b,d,c)的索引则都可以用到,a,b,d的顺序可以任意调整。
  • =和in可以乱序,比如a = 1 and b = 2 and c = 3 建立(a,b,c)索引可以任意顺序,mysql的查询优化器会帮你优化成索引可以识别的形式

当创建(a,b,c)复合索引时,想要索引生效的话,只能使用 a和ab、ac和abc三种组合!

实例:以下是常见的几个查询:

mysql>SELECT `a`,`b`,`c` FROM A WHERE `a`='a1' ; //索引生效
mysql>SELECT `a`,`b`,`c` FROM A WHERE `b`='b2' AND `c`='c2'; //索引失效
mysql>SELECT `a`,`b`,`c` FROM A WHERE `a`='a3' AND `c`='c3'; //索引生效,实际上值使用了索引a

mysql 乐观锁实现

在数据库中增加版本号字段:

idstatusnameversion
11book1

乐观锁sql语句

begin;
select @old_version:=`version`  from test.t_goods where id = 1;
update test.t_goods set status=2,version=version+1 where id = 1 and version=@old_version;
commit

首先通过select查询出当前记录中的版本号并保存到一个变量中,接下来去更新数据的时候,加上版本号的加以判断。由于update操作是当前读,可以读取到最新的版本号,当版本号被修改,上述更新操作便不能完成。

SQL慢查询优化

1、恰当地使用索引
必要时建立多级索引,分析执行计划,通过表数据统计等方式协助数据库走正确的查询方式,该走索引就走索引,该走全表扫描就走全表扫描;
2、对查询进行优化,尽可能避免全表扫描
首先考WHERE 及ORDER BY涉及列上建立索引
3、数据库表的大字段剥离
假如一个表的字段数有100多个,拆分字段,保证单条记录的数据量很小
4、字段冗余
减少跨库查询或多表连接操作
5、表的拆分
表分区和拆分,无论是业务逻辑上的拆分(如一个月一张报表、分库)还是无业务含义的分区
6、查询时不要返回不需要的行、列
7、减少SQL中函数运算与其它计算

什么是覆盖索引?

覆盖索引(covering index ,或称为索引覆盖)即从非主键索引中就能查到的记录,而不需要查询主键索引中的记录,避免了回表的产生减少了树的搜索次数,显著提升性能。
例题:
在这里插入图片描述
如果直接使用下列语句,则需要进行回表查询。

SELECT age FROM student WHERE name = '小李';

如果想要不回表,则需要使用联合索引

ALTER TABLE student DROP INDEX I_name;
ALTER TABLE student ADD INDEX I_name_age(name, age);

在这里插入图片描述那在我们再次执行如下sql后

SELECT age FROM student WHERE name = '小李'

流程为:

  • 在name,age联合索引树上找到名称为小李的节点
  • 此时节点索引里包含信息age 直接返回 12

非聚簇索引一定会回表查询吗?/单列索引,如果查询的字段不是主键,一定会搜索两次吗?

不一定,这涉及到查询语句所要求的字段是否全部命中了索引,如果全部命中了索引,那么就不必再进行回表查询。
举个简单的例子,假设我们在员工表的年龄上建立了索引,那么当进行select age from employee where age < 20的查询时,在索引的叶子节点上,已经包含了age信息,不会再次进行回表查询。

数据结构

数组和链表的区别

数组
数组是将元素在内存中连续存放,由于每个元素占用内存相同,可以通过下标迅速访问数组中任何元素。但是如果要在数组中增加一个元素,需要移动大量元素,在内存中空出一个元素的空间,然后将要增加的元素放在其中。同样的道理,如果想删除一个元素,同样需要移动大量元素去填掉被移动的元素。如果应用需要快速访问数据,很少或不插入和删除元素,就应该用数组。

链表
链表恰好相反,链表中的元素在内存中不是顺序存储的,而是通过存在元素中的指针联系到一起。比如:上一个元素有个指针指到下一个元素,以此类推,直到最后一个元素。如果要访问链表中一个元素,需要从第一个元素开始,一直找到需要的元素位置。但是增加和删除一个元素对于链表数据结构就非常简单了,只要修改元素中的指针就可以了。如果应用需要经常插入和删除元素你就需要用链表数据结构了。

快排

void QuickSort(SqList *L)
{ 
	QSort(L,1,L->length);
}
void QSort(SqList *L,int low,int high)
{
    int pivot;
    if(low < high)
    {
        pivot = Partition(L,low,high);
        QSort(L,low,pivot-1);
        QSort(L,pivot+1,high);
    }  
}
int Partition(SqList *L,int low,int high)
{
    int pivotkey=L->a[low];
    while(low < high)
    {
        while(low < high && L->a[high] >= pivotkey)
            high--;
        swap(L,low,high);
        while(low < high && L->a[low] <= pivotkey)
            low++;
        swap(L,low,high);
    }
    return low;
}
void swap(SqList *L,int i,int j)
{
    int temp = L->a[i];
    L->a[i] = L->a[j];
    L->a[j] = temp;
}

红黑树的性质

性质1 节点是红色或黑色。
性质2 根节点是黑色。
性质3 每个叶节点(NIL节点,空节点)是黑色的。
性质4 每个红色节点的两个子节点都是黑色。(从每个叶子到根的所有路径上不能有两个连续的红色节点)
性质5 从任一节点到其每个叶子的所有路径都包含相同数目的黑色节点。

这些约束的好处是:保持了树的相对平衡,同时又比AVL的插入删除操作的复杂性要低许多。

哈夫曼树

具有最小带权路径长度的二叉树成为哈夫曼树。
构造哈夫曼树的原则

  • 权值越大的叶节点越靠近根节点
  • 权值越小的叶节点越远离根节点

哈夫曼树的构造过程
在这里插入图片描述
哈夫曼编码
在这里插入图片描述
在这里插入图片描述

请你说一说Top(K)问题

1、直接全部排序(只适用于内存够的情况)
当数据量较小的情况下,内存中可以容纳所有数据。则最简单也是最容易想到的方法是将数据全部排序,然后取排序后的数据中的前K个。

这种方法对数据量比较敏感,当数据量较大的情况下,内存不能完全容纳全部数据,这种方法便不适应了。即使内存能够满足要求,该方法将全部数据都排序了,而题目只要求找出top K个数据,所以该方法并不十分高效,不建议使用。

2、快速排序的变形 (只使用于内存够的情况)

这是一个基于快速排序的变形,因为第一种方法中说到将所有元素都排序并不十分高效,只需要找出前K个最大的就行。

这种方法类似于快速排序,首先选择一个划分元,将比这个划分元大的元素放到它的前面,比划分元小的元素放到它的后面,此时完成了一趟排序。如果此时这个划分元的序号index刚好等于K,那么这个划分元以及它左边的数,刚好就是前K个最大的元素;如果index > K,那么前K大的数据在index的左边,那么就继续递归的从index-1个数中进行一趟排序;如果index < K,那么再从划分元的右边继续进行排序,直到找到序号index刚好等于K为止。再将前K个数进行排序后,返回Top K个元素。这种方法就避免了对除了Top K个元素以外的数据进行排序所带来的不必要的开销。

3、最小堆法

这是一种局部淘汰法。先读取前K个数,建立一个最小堆。然后将剩余的所有数字依次与最小堆的堆顶进行比较,如果小于或等于堆顶数据,则继续比较下一个;否则,删除堆顶元素,并将新数据插入堆中,重新调整最小堆。当遍历完全部数据后,最小堆中的数据即为最大的K个数。

4、分治法

将全部数据分成N份,前提是每份的数据都可以读到内存中进行处理,找到每份数据中最大的K个数。此时剩下NK个数据,如果内存不能容纳NK个数据,则再继续分治处理,分成M份,找出每份数据中最大的K个数,如果M*K个数仍然不能读到内存中,则继续分治处理。直到剩余的数可以读入内存中,那么可以对这些数使用快速排序的变形或者归并排序进行处理。

5、Hash法

如果这些数据中有很多重复的数据,可以先通过hash法,把重复的数去掉。这样如果重复率很高的话,会减少很大的内存用量,从而缩小运算空间。处理后的数据如果能够读入内存,则可以直接排序;否则可以使用分治法或者最小堆法来处理数据。

分布式理论

CAP理论

CAP定理又称CAP原则,指的是在一个分布式系统中,Consistency(一致性)Availability(可用性)Partition tolerance(分区容错性)最多只能同时三个特性中的两个,三者不可兼得。

Consistency (一致性):
“all nodes see the same data at the same time”,即更新操作成功并返回客户端后,所有节点在同一时间的数据完全一致,这就是分布式的一致性。一致性的问题在并发系统中不可避免,对于客户端来说,一致性指的是并发访问时更新过的数据如何获取的问题。从服务端来看,则是更新如何复制分布到整个系统,以保证数据最终一致。

Availability (可用性):
可用性指“Reads and writes always succeed”,即服务一直可用,而且是正常响应时间。好的可用性主要是指系统能够很好的为用户服务,不出现用户操作失败或者访问超时等用户体验不好的情况。

Partition Tolerance (分区容错性):

即分布式系统在遇到某节点或网络分区故障的时候,仍然能够对外提供满足一致性和可用性的服务。
分区容错性要求能够使应用虽然是一个分布式系统,而看上去却好像是在一个可以运转正常的整体。比如现在的分布式系统中有某一个或者几个机器宕掉了,其他剩下的机器还能够正常运转满足系统需求,对于用户而言并没有什么体验上的影响。

Raft

单个 Candidate 的竞选
有三种节点:Follower、Candidate 和 Leader。Leader 会周期性的发送心跳包给 Follower。每个 Follower 都设置了一个随机的竞选超时时间,一般为 150ms~300ms,如果在这个时间内没有收到 Leader 的心跳包,就会变成 Candidate,进入竞选阶段。

  • 下图展示一个分布式系统的最初阶段,此时只有 Follower 没有 Leader。Node A 等待一个随机的竞选超时时间之后,没收到 Leader 发来的心跳包,因此进入竞选阶段。
    在这里插入图片描述
  • 此时 Node A 发送投票请求给其它所有节点。
    在这里插入图片描述
  • 其它节点会对请求进行回复,如果超过一半的节点回复了,那么该 Candidate 就会变成 Leader。
    在这里插入图片描述
    之后 Leader 会周期性地发送心跳包给 Follower,Follower 接收到心跳包,会重新开始计时。
    在这里插入图片描述
    多个 Candidate 竞选
  • 如果有多个 Follower 成为 Candidate,并且所获得票数相同,那么就需要重新开始投票。例如下图中 Node B 和 Node D 都获得两票,需要重新开始投票。
  • 在这里插入图片描述
  • 由于每个节点设置的随机竞选超时时间不同,因此下一次再次出现多个 Candidate 并获得同样票数的概率很低。
  • 在这里插入图片描述
    数据同步
    来自客户端的修改都会被传入 Leader。注意该修改还未被提交,只是写入日志中。
    在这里插入图片描述
  • Leader 会把修改复制到所有 Follower。
  • 在这里插入图片描述
  • Leader 会等待大多数的 Follower 也进行了修改,然后才将修改提交 在这里插入图片描述
  • 此时 Leader 会通知的所有 Follower 让它们也提交修改,此时所有节点的值达成一致。
    在这里插入图片描述

nginx

nginx有哪些模块?

  • 性能相关配置
  • 时间驱动events相关的配置
  • http核心模块相关配置(ngx_http_core_module)
  • 访问控制模块(ngx_http_access_module)
  • 用户认证模块(ngx_http_auth_basic_module)
  • 状态查看模块(ngx_http_stub_status_module)
  • 日志记录模块(ngx_http_log_module)
  • 压缩相关选项(ngx_http_gzip_module)
  • httpsssl模块(ngx_http_ssl_module)
  • 重定向模块(ngx_http_rewrite_module)
  • 引用模块(ngx_http_referer_module)
  • 反向代理模块(ngx_http_proxy_module)
  • 代理模块(ngx_http_upstream_module)
  • ngx_stream_proxy_module模块

redis

缓存穿透及其解决办法

缓存穿透的概念
正常情况下,查询的数据都存在,如果请求一个不存在的数据,也就是缓存和数据库都查不到这个数据,每次都会去数据库查询,这种查询不存在数据的现象我们称为缓存穿透

穿透带来的问题
如果每次都拿一个不存在的id去查询数据库,可能会导致你的数据库压力增大

解决办法
缓存空值
之所以发生穿透,是因为缓存中没有存储这些数据的key,从而每次都查询数据库
我们可以为这些key在缓存中设置对应的值为null,后面查询这个key的时候就不用查询数据库了
当然为了健壮性,我们要对这些key设置过期时间,以防止真的有数据
BloomFilter
BloomFilter 类似于一个hbase set 用来判断某个元素(key)是否存在于某个集合中
我们把有数据的key都放到BloomFilter中,每次查询的时候都先去BloomFilter判断,如果没有就直接返回null
注意BloomFilter没有删除操作,对于删除的key,查询就会经过BloomFilter然后查询缓存再查询数据库,所以BloomFilter可以结合缓存空值用,对于删除的key,可以在缓存中缓存null

linux 命令

awk

1.只处理用户ID为奇数的行,并打印用户名和uid号

awk -F: '$3%2==1{print $1 $3}' /etc/passwd   #关系表达式考察

2.显示系统的普通用户,并打印用户名和ID

awk -F: '$3>=1000{print $1 $3}' /etc/passwd #关系表达式考察

3.统计普通用户的个数

awk -F: '$3>=1000{++count}END{print count}' /etc/passwd #模式考察
awk -F: '$3>=1000{++count| "wc -l"}' /etc/passwd #模式考察

算法题

反转链表


设计LRU缓存//创建一个自己的链表结构

参考解答
https://blog.csdn.net/qq_31442743/article/details/117823864


LeetCode142 环形链表

参考解答:https://blog.csdn.net/qq_41231926/article/details/86105434


剑指offer27 二叉树的镜像


多线程交替打印ABC

#include<iostream>
#include<vector>
#include<thread>
#include<condition_variable>
using namespace std;
mutex mu;
std::condition_variable cond_var;
int num=0;
void func(char ch)
{
	int n=ch-'A';
	for(int i=0;i<10;i++)
	{
		std::unique_lock<std::mutex> mylock(mu);
		cond_var.wait(mylock,[n]{return n==num;});
		cout<<ch;
		num=(num+1)%3;
		mylock.unlock();
		cond_var.notify_all();
	}
}
int main()
{
	vector<thread> pool;
	pool.push_back(thread(func,'A'));
	pool.push_back(thread(func,'B'));
	pool.push_back(thread(func,'C'));
	for(auto iter=pool.begin();iter!=pool.end();iter++)
	{
		iter->join();
	}
	return 0;
}

  • 3
    点赞
  • 28
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值