用以个人在找工作学习过程中积累的八股文,来源于CSDN等网络资源总结。
1.CPP语言
1、封装、继承、多态
- 封装:将具体实现过程和数据封装成一个函数,只通过接口进行访问。
- 继承:子类继承父类的特征和行为,复用了基类的全体数据和成员函数。基类的构造函数、析构函数、友元函数、静态数据成员、静态成员函数不能被派生类继承。
- 多态:不同继承类的对象对同一消息作出不同的响应,基类的指针指向或绑定到派生类的对象,使得基类指针呈现不同的表现形式。提高了代码的可替代性和可扩展性。
2、static关键字(限定作用域)
声明为静态成员函数/静态数据成员的作用,
将函数不再与类的特定实例相关联,意味着可以在不创建类的对象实例的情况下直接被调用。用作用域运算符 ::来访问,无法通过隐式的this指针访问。
在函数内声明,将变量的作用域仅限于该函数。
在文件范围内声明的静态变量,仅在该文件内可见。
在类中声明的静态成员变量属于类,而不是类的某个对象,意味着所有对象共享同一个静态成员变量,并且可以不实例化,使用作用域运算符,直接调用静态成员函数。静态成员函数只能访问静态成员变量和其他静态成员函数。
常用于执行与类相关但不依赖特定对象实例的操作,比如实现工具函数或提供全局的功能。静态成员变量:因为定义为静态,所以只与该类有关,与实例化对象无关,所有静态成员变量共享一份副本,不管类实例化多少次,静态成员变量在内存中只存在一份。
在一个.cpp文件中声明了静态static,则该变量/函数只属于该文件,即便其他文件包含了这个文件的头文件,也无法访问该变量。
3、多态
不同对象对同一消息,做出不同的响应。分为
动态多态(利用虚函数和继承实现,编译时不知道要执行哪个函数,只有运行到这才知道),
静态多态(利用重载实现,编译时就知道要执行哪个函数)和重定义(在继承关系中,子类实现了一个和父类名字一样的函数)
4、虚函数和纯虚函数
虚函数(有函数体实现,可以被实例化)和纯虚函数(没函数体,不能被实例化,一个类有纯虚函数则为抽象类(该类不可实例化),在虚函数的函数声明后面加上=0,即声明为纯虚函数)。
class test(){
//虚函数
virtual void test_virtual(){
//函数体
};
//纯虚函数,无函数体,且添加=0声明
virtual void test_virtual =0;
}
5、内存分区
在linux下,操作系统会为每个进程分配虚拟的地址空间,包含:
- 代码区(存放代码)
- 数据区(存放全局变量、静态变量等)
- 堆区(用于程序员动态分配的,若程序员不释放,程序结束时可能由OS回收。注意它与数据结构中的堆是两回事, 分配方式类似于链表。如new,malloc等)
- 栈区(先进先出队列,存放函数的参数值,局部变量等值)以实现作用域功能。
- 内存映射区域:通过mmap系统调用,将其他对象或者文件映射到该进程地址空间区域内。
6、final标识符
是cpp11的新标识符,用于声明不想被继承的类或函数,保障了函数的完整性。放在虚函数后面,则该虚函数无法被重写,表示阻止虚函数的重载。
class FinalClass final {
// 类声明被标记为 final,不能被继承
};
7、虚函数的实现
虚函数表(vtable)(每个包含虚函数的类会生产一个虚函数表,是一个存储类中所有虚函数指针的数组,每个多态类都有一个自己的虚函数表,虚函数表包含了指向该类的虚函数实现指针。即该表中指针指向虚函数的实现)
和虚表指针(vptr)(在对象的内存布局中,编译器会添加一个额外的虚函数指针,用以指向对应类的虚函数表,让程序能够动态的调用虚函数)。
即:虚函数表内指针指向虚函数实现,而虚表指针指向该对应类的虚函数表。
8、拷贝构造函数
实例化一个新对象时,用之前的对象来初始化他,即需要拷贝构造函数。
如果不自己定义的话,编译器会自动添加浅拷贝。
class MyClass(){
//拷贝构造函数
MyClasss(const MyClass& other){}; //声明为const 是因为不希望改变原对象,默认浅拷贝,即资源共享,如果想要深拷贝,需要在函数体内用new重新申请内存空间
}
9、浅拷贝和深拷贝
浅拷贝只是简单的复制对象的成员变量值,包括指针成员变量的地址值,原始对象和拷贝对象共享相同的资源,如果一个对象修改了资源,另一个对象也会受到影响。
而深拷贝会复制对象的所有内容,并重新分配资源,意味着原始对象和拷贝对象拥有各自独立的资源副本,彼此之间不受影响,当类中包含动态分配的资源,或者需要保证对象之间完全独立时,必须使用深拷贝。
10、匿名函数(也称lambda表达式)
即没有名字的函数。
本质是一个对象,在定义的时候会创建一个栈对象(因为是临时对象,所以存储在栈区),内部通过重载()符号实现函数调用的外表,使用匿名函数,可以免去函数的声明和定义。这样匿名函数仅在调用函数的时候才会创建函数对象,而调用结束后立即释放,所以匿名函数比非匿名函数更节省空间。
[capture list](parameters) -> return_type { body }
#include <iostream>
int main() {
int x = 10;
int y = 20;
// 使用 lambda 表达式定义匿名函数,捕获变量 x
auto add = [x, y](int a, int b) -> int {
return a + b + x + y;
};
// 调用匿名函数
std::cout << add(5, 7) << std::endl; // 输出 42
return 0;
}
capture list:捕获上下文中的变量,可以为空或包含一个或多个变量。
parameters:函数参数列表,与普通函数的参数类似。
return_type:函数返回类型,可以省略,编译器会自动推导。
body:函数体,包含函数的具体实现。
11、智能指针
智能指针是类模板,原理是利用类在销毁时会自动调用类的析构函数,从而在析构函数里面将内存释放,简化了内存管理。
是 C++ 中用于管理动态分配的内存资源的一种工具。与传统的裸指针相比,智能指针具有许多优点,包括自动内存管理、避免内存泄漏、减少程序出错的可能性等。常用的三个智能指针
- std::unique_ptr
独享它指向的对象,也就是说,同时只有一个unique_ptr指向同一个对象,当这个unique_ptr被销毁时,指向的对象也随即被销毁。具有排他性。通过std::move()转移所有权。
#include <memory>
std::unique_ptr<int> ptr(new int(42)); // 创建一个 unique_ptr,指向动态分配的整数对象
- std::shared_ptr
是一种共享所有权的智能指针,多个std::shared_ptr 可以共同拥有一个对象。内部使用引用计数来跟踪对象的引用数量,当引用计数为零时,资源被释放。即创建多个指针指向同一个对象。
#include <memory>
std::shared_ptr<int> ptr1 = std::make_shared<int>(42); // 创建一个 shared_ptr,指向动态分配的整数对象
std::shared_ptr<int> ptr2 = ptr1; // 多个 shared_ptr 共享同一个对象
//使用例子
#include <iostream>
#include <memory>
class MyClass {
public:
MyClass() { std::cout << "MyClass Constructor" << std::endl; }
~MyClass() { std::cout << "MyClass Destructor" << std::endl; }
};
void useSharedPtr() {
std::shared_ptr<MyClass> ptr1(new MyClass()); // 创建 shared_ptr
{
std::shared_ptr<MyClass> ptr2 = ptr1; // 共享所有权
std::cout << "ptr2 reference count: " << ptr2.use_count() << std::endl;
} // ptr2 离开作用域,被销毁
std::cout << "ptr1 reference count: " << ptr1.use_count() << std::endl;
// ptr1 自动释放所管理的资源
}
int main() {
useSharedPtr();
return 0;
}
- std::weak_ptr
循环引用是指两个或多个对象相互引用,形成一个闭环,使得他们无法被自动释放,从而导致内存泄漏。在使用shared_ptr时,如果两个对象相互持有shared_ptr,就会导致引用计数永远无法归零,资源无法释放。
是一种弱引用的智能指针,它不增加对象的引用计数,不共享对象的所有权。主要用于解决std::shared_ptr循环引用导致的内存泄漏问题。通过lock()方法获得一个与std::shared_ptr共享对象的指针,如果对象销毁,则返回空指针,与std::shared_ptr 一起使用,避免循环引用。
#include <memory>
std::shared_ptr<int> sharedPtr = std::make_shared<int>(42);
std::weak_ptr<int> weakPtr = sharedPtr; // 创建一个 weak_ptr,不增加对象的引用计数
// 获取一个 shared_ptr,如果对象还存在,返回一个 shared_ptr,否则返回空指针
std::shared_ptr<int> sharedPtr2 = weakPtr.lock(); //weak_ptr::lock函数可以将其提升为shared_ptr,函数返回为一个shared_ptr
//例子
//循环引用的代码
#include <iostream>
#include <memory>
class B; // 前向声明
class A {
public:
std::shared_ptr<B> ptrB;
~A() { std::cout << "A Destructor" << std::endl; }
};
class B {
public:
std::shared_ptr<A> ptrA;
~B() { std::cout << "B Destructor" << std::endl; }
};
void createCycle() {
std::shared_ptr<A> a(new A);
std::shared_ptr<B> b(new B);
a->ptrB = b;
b->ptrA = a;
// 此时,a 和 b 的引用计数都不为 0,无法释放
}
int main() {
createCycle();
// 由于循环引用,A 和 B 的析构函数不会被调用
return 0;
}
//解决代码
#include <iostream>
#include <memory>
class B; // 前向声明
class A {
public:
std::shared_ptr<B> ptrB;
~A() { std::cout << "A Destructor" << std::endl; }
};
class B {
public:
std::weak_ptr<A> ptrA; // 使用 weak_ptr 代替 shared_ptr
~B() { std::cout << "B Destructor" << std::endl; }
};
void createCycle() {
std::shared_ptr<A> a(new A);
std::shared_ptr<B> b(new B);
a->ptrB = b;
b->ptrA = a; // 使用 weak_ptr 打破循环引用
// 当 a 和 b 离开作用域时,它们的析构函数会被正常调用
}
int main() {
createCycle();
// A 和 B 的析构函数会被调用,内存正常释放
return 0;
}
在这个示例中,B 类中的 ptrA 由 std::shared_ptr 改为 std::weak_ptr。这样就打破了循环引用,因为 weak_ptr 不增加引用计数。当 a 和 b 离开作用域时,它们的引用计数归零,内存得以正常释放。
12.右值引用和左值引用
左值引用:左值引用是对左值的引用,即可以放在赋值运算符左边的表达式。左值引用使用 & 符号表示,形如 T&,其中 T 是引用的类型。左值引用主要用于传递和操作具有持久性的对象,如局部变量或函数返回的左值。
int x = 42;
int& lref = x; // lref 是 x 的左值引用
右值引用:右值引用是对右值的引用。为一个临时变量(亡值)取别名
即临时对象或将要销毁的对象。右值引用使用 && 符号表示,形如 T&&,其中 T 是引用的类型。右值引用主要用于实现移动语义和完美转发,通过将资源从临时对象“窃取”到另一个对象来提高效率。
int&& rref = 42; // rref 是一个右值引用,指向临时对象 42
其中,++i是左值,i++是右值。i++需要创建一个副本自加,然后才赋值,所以++i效率更高。
移动语义:
- 传统上,通过复制构造函数和赋值运算符进行对象的拷贝操作,这可能会涉及深拷贝,即将数据从一个对象复制到另一个对象。
- 移动语义允许在不复制数据的情况下将资源(例如内存、文件句柄等)从一个对象转移到另一个对象,通过将右值引用(Rvalue Reference)绑定到临时对象来实现。
- 移动语义通过 std::move() 函数来实现,它将左值转换为右值引用,从而可以使用移动构造函数或移动赋值运算符。
- 移动语义主要用于提高性能,减少不必要的对象拷贝操作。
完美转发:
- 完美转发允许函数接受任意数量和类型的参数,并将它们转发给另一个函数,同时保留原始参数的值和类型。
- 在 C++11 之前,如果想要编写一个可以转发参数的函数模板,必须为每一种可能的情况都编写一个重载版本,这导致了代码的重复和不够灵活。
- 完美转发通过引入右值引用和模板参数推导来解决这个问题,使用 std::forward() 函数来实现参数的转发。
- 完美转发主要用于编写泛型代码,例如实现通用的包装器类或工厂函数。
左值(lvalue):
左值是指可以出现在赋值运算符的左边的表达式,也就是可以取地址的表达式。
具体来说,左值是指有名称且在内存中有确定位置的表达式,可以通过取地址操作获取其在内存中的位置。
例如,变量、函数返回的左值引用以及数组元素都是左值。
右值(rvalue):
右值是指不能出现在赋值运算符的左边的表达式,也就是临时生成的、没有名称的值。
具体来说,右值是指无法取地址的临时对象或字面常量,例如常量、临时变量、以及表达式的结果。
在 C++11 中,右值引用的引入进一步扩展了右值的概念,右值引用可以绑定到临时对象,并允许移动语义的实现。
13. 左值引用和指针的区别
是否初始化:指针可以不用初始化,引用必须初始化
性质不同:指针是一个变量,引用是对被引用的对象取一个别名
占用内存单元不同:指针有自己的空间地址,引用和被引用对象占同一个空间。
14. 指针
是存储地址的变量。
15.define和const的区别
编译阶段:define是在编译预处理阶段进行简单的文本替换,const是在编译阶段确定其值
安全性:define定义的宏常量没有数据类型,只是进行简单的替换,不会进行类型安全检查;const定义的常量是有类型的,是要进行类型判断的
内存占用:define定义的宏常量,在程序中使用多少次就会进行多少次替换,内存中有多个备份,占用的是代码段的内存;const定义常量占用静态存储区域的空间,程序运行过程中只有一份
调试:define定义的宏常量不能调试,因为在预编译阶段就已经进行替换了;const定义的常量是可以进行调试的。
16.一个程序运行的步骤
- 预编译:将头文件编译,进行宏替换,输出.i文件。
- 编译:将其转化为汇编语言文件,主要做词法分析,语义分析以及检查错误,检查无误后将代码翻译成汇编语言,生成.s文件
- 汇编:汇编器将汇编语言文件翻译成机器语言,生成.o文件(二进制文件)
- 链接:将目标文件和库链接到一起,生成可执行文件.exe。
17.锁的底层原理
锁的底层是通过CAS,atomic 机制实现。
CAS机制:全称为Compare And Swap(比较相同再交换)可以将比较和交换操作转换为原子操作,CAS操作依赖于三个值:内存中的值V,旧的预估值X,要修改的新值B,如果旧的预估值X等于内存中的值V,就将新的值B保存在内存之中。(就是每一个线程从主内存复制一个变量副本后,进行操作,然后对其进行修改,修改完后,再刷新回主内存前。再取一次主内存的值,看拿到的主内存的新值与当初保存的快照值,是否一样,如果不一样,说明有其他线程修改,本次修改放弃,重试。)
atomic机制(原子性):如18问。
18.原子操作
原子操作是指不会被线程调度机制打断的操作,这种操作一旦开始,就一直运行到结束,中间不会有任何切换到另一个线程。
原理是:在X86的平台下,CPU提供了在指令执行期间对总线加锁的手段,CPU中有一根引线#HLOCK pin连接到北桥,如果汇编语言的程序在程序中的一条指令前面加上了前缀“LOCK”,经过汇编之后的机器码就使CPU在执行这条指令的时候把#HLOCKpin的电平拉低持续到这条指令结束的时候放开,从而把总线锁住,这样别的CPU就暂时不能够通过总线访问内存了,保证了多处理器环境中的原子性。
19、class和struct的区别
默认继承权限不同:class默认继承的是private继承,struct默认是public继承,除此之外几乎没区别
class可定义类模板,而struct不行,cpp保留struct是为了向下兼容C。
20、类的声明周期
全局对象:在main开始前被创建,main退出后被销毁。
静态对象:在第一次进行作用域时被创建,在main退出后被销毁。
局部对象:在进入作用域时被创建,在退出作用域时被销毁。
New创建的对象直到内存被释放(程序员手动/程序执行完由操作系统释放)的时候都存在。
21、父类的构造函数不能为虚函数,这种操作导致的结果
- 对象构造顺序问题
当一个派生类对象被创建时,构造函数的调用顺序是从基类开始,逐层向下调用派生类的构造函数。虚函数的主要目的是支持运行时多态。但是,在基类的构造函数执行时,派生类对象的部分还没有被构造,也就没有多态,所以调用虚函数可能会导致未定义行为 - 虚函数表的初始化
虚函数的调用是通过虚函数表来查找的,而虚函数表由类实例化对象的vptr指针指向,该虚表指针需要调用构造函数完成初始化,如果构造函数为虚函数,那么调用构造函数就需要去寻找vptr,但此时vptr还没有完成初始化(因为先调用基类的构造函数,再调用派生类的构造函数),导致无法构造对象。
析构函数可以并且经常被定为虚函数:当我们使用父类指针指向子类时,当父类的析构函数为非虚函数时,只会调用父类的析构函数,子类的析构函数不会被调用,从而造成内存泄漏。
而当把父类的析构函数定为虚函数之后,再使用父类执行指向子类并且删除该对象时,会先调用派生类的析构函数,再调用基类的析构函数。
22、vector、list、deque的优缺点
vector
优点:
- 是动态数组,支持高效的随机访问(即通过索引访问元素),O(1)
- 内存连续。在内存中是连续存储的,适合缓存友好的操作,能够提高性能
- 在容器末尾插入和删除操作非常快,O(1)
- 支持自动扩容,处理大数据量方便
deque
- 双端操作高效,支持两端进行快速插入和删除操作,O(1),
- 支持通过索引访问元素,O(1)
- 不需要整体重分配,deque是分段存储的(每段内存块是连续的,但各段不连续),所以即使扩容也不需要整体重分配内存
list
- 双向链表,每个节点都包含指向前后节点的指针,因此在任何位置进行插入和删除操作都是高效的。
- 不需要移动元素,由于链表的插入和删除操作不涉及移动其他元素,适用于频繁插入和删除的场景。
- 不支持下标随机访问
23、指针占用的大小
64位操作系统上占8字节,32位的占4字节。我们平时所说的计算机多少位是指计算机CPU中通用寄存器一次性处理、传输、暂时保存的信息的最大长度。即CPU在单位时间内能一次处理的二进制的位数,因此CPU所能访问的内存所有地址由多少位组成,而8比特位表示1字节,就可以得出在不同位数的机器中指针的大小。
24、野指针和内存泄露
内存泄漏:是指程序中已动态分配的堆内存由于某种原因未释放或无法释放,造成系统资源的浪费,导致程序运行缓慢甚至系统崩溃的严重后果。
避免:使用智能指针管理资源,在释放对象数组时使用delete,尽量避免在堆上分配内存。
野指针:指向一个已经删除的对象或未申请访问受限内存区域的指针。
避免:对指针进行初始化,用合法的可访问的内存地址来对指针初始化,指针用完释放内存,将指针赋值nullprt。
25、malloc和new的区别
malloc/free 是标准库函数,new/delete是CPP运算符
new 返回有类型的指针,malloc返回无类型的指针。
26、迭代器和指针的区别
迭代器不是指针,而是一个类模板,通过重载了指针的一些操作符模拟了指针的一些功能,迭代器返回的是对象应用而不是对象的值。
指针能够指向函数而迭代器不行,迭代器只能指向容器。
27、map和unordered_map
map内部实现是一个红黑树(平衡二叉树),内部所有的元素都是有序的,而hashmap则是内部实现了一个哈希表。
map优点:有序性,插入、删除、查找的时间复杂度都是logN
map缺点:空间占用率高
unordered_map优点:查找效率非常高,O(1),缺点:哈希表的建立比较费时间。
28、vector扩容,resize和reserve的区别
使用resize改变的是vector的大小(size),可能会添加或删除元素,如果新的大小比当前大小大,则会插入新元素(默认值初始化),如果相比较小,则会移除多余的元素。
使用reserve用于预先分配vector的容量,改变的是vector的容积(capacity),不会改变当前元素的数量,仅仅是为了优化内存使用和性能。
29、vector扩容为了避免重复扩容做了哪些机制
当vector容量不够时,本身内存会以1.5或者2倍增长,以减少扩容次数
引入了reserve,可以自定义vector最大容量。
30、移动构造和拷贝构造的区别
移动构造函数用于创建一个新的对象,该对象是从现有对象通过“偷取”资源得到的,而不是深拷贝。这通常通过将原对象的指针或资源指向新对象,同时将原对象的指针或资源置空来实现。这种方式避免了不必要的深拷贝操作,提高了性能,特别是在处理临时对象时。属于浅拷贝。移动构造函数本质上是基于指针的拷贝,实现对堆区内存所有权的移交,在一些特定场景下,可以减少不必要的拷贝。比如,用一个临时对象或者右值对象初始化类实例时,我们可以使用move()函数,将一个左值对象转变为右值对象
拷贝构造则是将传入的对象复制一份然后放进新的内存中,是一种深拷贝。
31、lamda表达式(匿名函数)捕获列表捕获的方式有哪些,如果是引用捕获要注意什么
按值捕获和引用捕获,默认的引用捕获可能会导致悬挂引用,引用捕获会导致闭包包含一个局部变量的引用或者形参的引用,如果一个由lambda创建的闭包的生命周期超过了局部变量或者形参的生命期,那么闭包的引用将会空悬。解决方法是对个别参数使用值捕获。
32、哈希碰撞的处理方式
- 开放定址法:去寻找一个新的空闲的哈希地址
- 再哈希法:同时构造多个哈希函数,等发生哈希冲突时就使用其他哈希函数知道不发生冲突为止,虽然不易发生聚集,但是增加了计算时间
- 链地址法:将所有的哈希地址相同的记录都链接在同一链表中
- 建立公共溢出区:将哈希表分为基本表和溢出表,将发生冲突的都存放在溢出表中。
33、unordered_map 的扩容过程
当unordered_map 中的元素数量达到桶的负载因子(0.75)时,会重新分配桶的数量(通常会按照原有桶的数量*2的方式进行扩容,但是具体的增长策略也可以通过修改容器中的max_load_factor成员变量来进行调整),并将所有的元素重新哈希到新的桶中。
34、赋值的区别
执行
string A = "123456";
string B = A;
执行的区别:执行第一句,会创建临时常量123456并赋值给A,常量用后会被销毁。而第二句是调用string类的拷贝构造函数,用A的值来初始化B,AB享有各自独立的存储空间。
35、push_back() 和emplace_back()的区别
push_back()和emplace_back()区别
push_back()接受一个已构造的对象,并将其拷贝或移动到向量的末尾,使用push_back()时,新元素是先在调用处构造,然后再复杂或移动到向量内部。可能会涉及额外的拷贝或移动操作,特别是对于复杂对象,这些操作可能会比较昂贵。
emplace_back() 直接在向量的末尾原地构造对象,它接受构造函数的参数,并直接在向量内部调用构造函数创建对象,这样可以避免不必要的拷贝或移动,直接在目标位置构造对象,从而提高性能。
36、const用法
char const * 和const char * 等价
const char * 是指向常量的指针,const修饰的是指针指向的对象,即指针指向的该对象是常量,不可修改。
char * const 定义的是一个指针常量,表示该指针不可修改。
*前面是被指对象的约束,* 后面加则是该指针的约束
2.计算机网络
1、计算机网络的五层模型
应用层:用于用户交互,工作在操作系统的用户态,负责给应用程序提供统一的接口,HTTP,DNS,FTP
传输层:提供端到端的数据传输,TCP/UDP协议
网络层:跨网络传输,负责数据的路由、转发、分片,IP协议,ICMP等
数据链路层:负责数据的封帧和差错检测,以及mac寻址,连接物理硬件,mac地址,
物理层:提供物理硬件支持,负责在物理网络中传输数据帧。
2、HTTP
3、TCP
4、IP
3.数据结构与算法
1、逻辑结构:
顺序存储
链式存储
2、常用数据结构类型
- 线性表、单链表、静态链表、循环链表、双向链表
- 栈(先进后出)和队列(先进先出)、循环队列
- 串
- 树:二叉树、线索二叉树、哈夫曼树、红黑树
- 图:最小生成树、最短路径、拓扑排序、关键路径
3、算法
- 查找
- 排序
1、递归算法
4、红黑树(平衡二叉树)
五条性质:
1、每个节点要么是红色,要么是黑色
2、根节点是黑色的
3、叶子节点是黑色的空节点
4、所有红色节点的两个孩子,都是黑色的
5、任意节点到其可到达的叶子节点之间有相同数量的黑色节点。
左节点比根节点小,右节点比根节点大。
并且动态调整树的平衡(节点的旋转算法维护平衡)。
4.操作系统
1、mmap系统调用
是Linux系统中的系统调用,用于将文件或者其他对象映射到进程的地址空间,从而使得文件的内容可以像内存一样被直接访问。使用 mmap 函数可以实现一些高效的文件读写操作,尤其适用于处理大文件或者需要频繁读写的文件。它也被用于一些 IPC(进程间通信)机制,例如共享内存。需要注意的是,使用 mmap 函数时需要小心处理内存映射区域的权限和生命周期,以避免出现内存泄漏或者非法访问的问题。
为什么不全部使用mmap来分配内存?
因为mmap是系统调用,执行时需要系统进入内核态,然后再回到用户态,状态的切换增加了开销,并且mmap分配的内存释放的时候都会归还给操作系统,于是每次mmap分配的虚拟地址都是缺页状态,然后在第一次访问该虚拟地址的时候就会触发缺页中断。
2、内存对齐是什么,为什么要内存对齐,好处?
一般来说调用sizeof 直接返回变量类型占用的字节数,例如:int占用4个字节,float占用8个字节,sizeof指针,在32位系统中,一个地址需要4个字节,在64位系统中,一个地址需要8个字节。
但是,如果sizeof一个结构体,结构体包含占用一个字节的char,和占用四个字节的int,则返回值为8。这就是因为内存对齐机制,按4个字节对齐,当然也可以强制修改为按1个字节对齐。
在内存对齐中,数据被存储在内存中的地址必须是某个特定值的倍数。这个特定值通常称为对齐边界或对齐要求。例如,如果一个数据的对齐要求是4字节,那么这个数据的起始地址必须是4的倍数。
内存对齐是处理器为了提高处理性能而对存取数据的起始地址所提出的一种要求。
有些CPU可以访问任意地址上的任意数据,而有些CPU只能在特定的地址访问数据,因此不同硬件平台具有差异性,这样的代码就不具有移植性,如果在编译时将进行对齐,这就具有平台的移植性。CPU每次寻址有时需要消耗时间的,并且CPU访问内存的时候并不是逐个字节访问,而是以字长为单位访问,所以数据结构应该尽可能地在自然边界上对齐,如果访问未对齐内存,处理器需要做多次内存访问,而对齐的内存访问可以减少访问次数,提升性能。
优:提高程序的运行效率,增强程序的可移植性。
3、进程之间的通信方式(IPC)
管道:管道分为匿名管道和命名管道,管道本质上是一个内核中的一个缓存,当进程创建管道后会返回两个文件描述符,一个写入端一个输出端。缺点:半双工通信,一个管道只能一个进程写,一个进程读。不适合进程间频繁的交换数据。
两个描述符均在一个进程里面,并没有起到进程间通信的作用,通过fork创建子进程,创建的子进程会复杂父进程的文件描述符,这样就做到了两个进程各有两个fd[0] 和fd[1]。
为了避免混乱,通过
父进程关闭读取的fd[0],只保留写入的fd[1]
子进程关闭写入的fd[1],只保留读取的fd[0]
消息队列:可以边发边收,但是每个消息体都有最大长度限制,每个进程消息队列所包含的消息体的总数量也有上限并且在通信过程中存在用户态和内核态之间的数据拷贝问题(因为需要进程A通过消息体,使用系统调用把数据传送给内核地址空间,然后进程B通过系统调用,从内核的地址空间读取消息体,从而获取数据)。
共享内存:由操作系统在内存中分配一段共享地址空间,供两个进程读写,操作系统只需要分配地址空间这一个内核态,分配完成就由两个进程自己访问,解决了消息队列存在的内核态和用户态之间的数据拷贝问题。
信号量:本质上是一个计数器,当使用共享内存的通信方式时,如果有多个进程同时往共享内存中写入数据,有可能先写的进程的内容被其他进程覆盖了,信号量就用于实现进程间的互斥和同步PV操作不限于信号量±1,而且可以任意加减正整数。
信号:上述的进程间通信都是常规状态下的工作模式,对于异常情况下的工作模式,就需要用【信号】的方式来通知进程。
在linux操作系统中,为了响应各种各样的事件,提供了几十种信号,分别代表不同的含义,用 kill -l命令可以查看所有的信号。
套接字:Socket套接字,用于不同进程之间的通信,可通过网络,得知道对方的IP和端口号。
4、线程之间的通信方式
信号量:对多个资源的访问,控制数量,阻塞或唤醒线程。
条件变量:在等待某个条件时进入阻塞状态,直到该条件被满足并通知线程唤醒,条件变量通常与互斥量一起使用,为防止竞争条件和确保同步。signal(),wait()
互斥量: mutex,对临界资源的互斥访问。
5、多线程会发生什么问题,线程同步有哪些手段
会引发资源竞争问题,频繁上锁会导致程序运行效率低下,甚至会导致死锁
线程同步手段:使用atomic原子变量,使用互斥量也就是上锁,使用条件变量或信号量制约对共享资源的并发访问。
6、为了实现线程安全,除了加锁还有其他方式吗?
除了锁之外还可以使用
- 互斥量(防止多个线程来同时访问共享资源,从而避免数据竞争的问题)
- 原子操作(原子操作不可分割,使用原子操作可以确保在多线程环境中操作是安全的,原子性是通过开/关中断的特权指令实现的)
- 条件变量(协调线程之间的协作,用来在现场之间传递信号,从而控制线程的执行流程)等方式
7、进程和线程概念,协程
进程是系统进行资源分配和调度的基本单位,进程本质上是运行中的程序是动态的,需要将进程运行的当前状态,所需资源等信息保持到进程控制块(PCB)中,操作系统为了管理进程设计的数据结构叫进程控制块,里面存的字段可以分为进程标识符、处理机状态、进程调度信息、进程控制信息。
线程是c任务调度和执行的基本单位,CPU上真正运行的是线程。同一个进程的多个线程共享同一个进程资源。
协程:是一种比线程更轻量级的并发机制,它们运行在单一线程内,由程序自身调度,而非由操作系统内核调度。特点:调度由程序员控制,所以开销非常小,非抢占式:在运行时不会被其他协程打断,除非协程主动放弃控制权。共享上下文,减少了线程切换中上下文切换的开销,资源效率高:由于协程切换开销小,可以在一个线程中创建管理大量协程,比线程和进程更高效。
协程分为对称协程(协程之间的切换是对等的,任何一个协程都可以将控制权转移给另一个协程,更灵活,但是管理和调度也更复杂)和非对称协程(协程的控制权转移是单向的,即从调用者协程到被调用者协程,然后再返回到调用者协程,这种模型类似线程切换)。
有栈协程 Stackful Coroutine:每一个协程都会有自己的调用栈,有点儿类似于线程的调用栈,这种情况下的协程实现其实很大程度上接近线程,主要不同体现在调度上。有栈协程(Stackful Coroutines)每个协程都有自己独立的栈。这意味着每个协程在执行时会维护自己的调用栈,从而可以支持更复杂的调用和嵌套。
无栈协程 Stackless Coroutine:协程没有自己的调用栈,挂起点的状态通过状态机或者闭包等语法来实现。它们不维护独立的栈,而是通过保存必要的上下文信息来进行切换。通常通过状态机或生成器的方式实现。
8、进程的五种状态
创建态、就绪态、运行态、阻塞态、终止态。
9、进程调度测量
分为抢占式(操作系统可以在进程执行时剥夺CPU使用权,分配给其他进程使用)和非抢占式(进程获得CPU资源后,一直运行到进程结束或者阻塞才释放CPU的使用权)
进程调度算法:
- 短作业优先:进程所需时间较短的优先进入cpu,会导致饥饿
- 优先级调度:进程在创建时被赋予优先级,优先级高的优先调度,会导致饥饿
- 时间片轮转:为每个进程执行设置时间片,时间片用完就交出cpu,不会导致饥饿。
- 先进先出算法,FIFO:容易造成短进程等待时间较长
- 最高响应比优先:对短进程有利,长进程由于等待时间变成会导致响应比增加,不至于让长进场饥饿,响应比(等待时间+所需运行时间/所需运行时间)
- 多级反馈队列轮转算法:将进程就绪队列分为多个优先级不同的队列,刚创建的进程和因IO未用完时间片的进程排在最高优先级队列,2-3哥时间片还未执行完的进程放入,较低优先级的队列,只有优先级更高的队列为空时才会调度当前队列,会导致饥饿。
10、僵尸进程、孤儿进程、守护进程
僵尸进程:子进程被杀死,父进程没有回收子进程的资源,子进程虽然退出了,但是仍保留原有PCB,当大量僵尸进程在内存中时,会消耗内存资源;
孤儿进程:父进程被杀死,子进程被PID为1的initi进程收养。
守护进程:常驻后台运行的进程。
11、进程和线程的切换需要哪些开销
进程切换的开销:1、切换页表,2、切换内核态堆栈,3、将寄存器中的上下文保存到PCB中4、刷新快表,快表是在高速缓存中为了更快的进行地址翻译而将部分的页表项保存在高速缓存中。
线程切换的开销:1、切换内核栈2、切换硬件上下文。
两者开销差距较大的在于页表的切换上,因为进程是资源分配的基本单位,而线程切换,资源不需要切换。进程切换后,调表刷新,一段时间内内存命中率都会较低。
12、系统调用
应用程序如果想要访问硬件资源,需要使用操作系统为其提供的接口,使CPU从用户态->内核态->用户态,主要出于资源访问的安全考虑,让操作系统管理资源,而应用程序需要使用资源的时候,通过操作系统提供的系统调用接口来向操作系统申请分配资源。
13、从用户态进入内核态的方式
- 系统调用
- 异常,如果进行运行在用户态的时候发生了异常事件,就会触发切换,例如:缺页异常。
- 外部中断:当外设完成了用户的请求后,会向cpu发送中断信息,cpu会暂停当前执行,转而执行中断信号对应的处理程序。
14、局部性原理
空间局部性:已经访问过的页面相邻的页面也有很大概率在接下来被访问。
时间局部性:最近访问过的页面,很可能在接下来也会访问。
15、程序运行类型----IO密集型/CPU密集型
CPU密集型:也叫计算密集型,指的是IO可以在很短时间内完成,而CPU的运算占据主要的工作,CPU负载高
IO密集型:指的是系统CPU性能相对IO设备好很多,需要频繁的调用IO读写操作,CPU负载不高。
16、缓冲区溢出
指计算机缓冲区填充数据时超过了缓冲区最大容量,导致数据被写到缓冲区以外。
危害:程序崩溃,跳转到意料之外的代码
原因:程序中没有仔细检查用户输入。
17、操作系统内存管理—缺页中断、内存页
分页:将内存从物理上划分为大小相同的页框(LInux一般为4K),进程所需的数据分别存放在多个页中,这些页离散地分布在内存中,因此需要一张记录内存逻辑地址到物理地址映射关系的表,也就是页表。分页系统重,访问数据需要两次访问内存,第一次访问的是内存中的页表,根据页号和页内偏移量计算查找出实际的物理地址,第二次根据物理地址访问内存读取数据。
分段:将进程的逻辑空间划分为若干不同大小的段(长度由连续逻辑的长度决定),段地址是二维的,一维是段号、二维是段内偏移。因为多个段之间也是离散分布在内存中的,所以需要段表来记录逻辑地址到物理地址的映射关系。
分页和分段的区别:
- 页是物理单位,方便管理内存空间,用户不可见;段是逻辑单位,根据用户需要自行划分,对用户可见
- 段的大小不固定,页的大小固定,一般为4K
- 段向用户提供二维地址空间,页向用户提供一维地址空间
- 分页的主要目的是为了实现虚拟内存,提高内存利用率,而分段是为了程序和数据能够根据逻辑进行划分,从而更好地进行管理
段页式存储管理
先把逻辑空间按段式管理分成若干段,在将段空间按页式管理分成若干页,段页地址就可以通过段号、段内页号、页内地址找到属于那一段那一页,综合两者优点。
18、虚拟内存
虚拟内存概述:作为操作系统的内存管理技术,把程序使用的内存划分,(根据局部性原理)将部分暂时不用的内存放置在辅存。替换策略发生在缓存-主存层次/主存-辅存层次。解决速度/容量问题。如果访问页不在内存,则发出缺页中断,发起页面置换。
逻辑地址空间:指进程可以使用的内存空间,大小仅受CPU地址长度限制(如32位的最大逻辑空间为4G–2^32字节,64位的最大逻辑地址空间为2的64次方字节)进程运行时可以使用的相对地址空间。
物理地址空间:物理内存的存储空间,进程运行时在物理内存分配和使用的地质空间。
缺页中断:当进程读取的页号在页表中不存在时,会触发缺页中断,处理缺页中断的方法是从外存中找出目标页,然后装入内存中
具体做法:
- 从外存中找到目标页
- 检查当前内存中的空闲是否足够目标页装入
- 若空间足够,则直接装入目标页,更新页表,中断处理结束
- 若空间不够,则执行页面置换算法,淘汰页面直到空间足够为止,然后装入目标页,中断处理结束。
19、为什么虚拟地址切换会比较耗时
进行虚拟地址到物理地址的转换需要去查页表,而查找页表是一个很慢的操作,所以为了加快该过程就会在高速缓存中用快表去缓存最近查找过的虚拟地址,但每个进程都有自己的页表,一旦发生进程切换,页表就需要切换,快表也会被清空,会导致进程切换后快表的命中率非常低,此时大量时间花在查页表上,表现出来的就是程序运行速度变慢。
线程切换不会导致快表失效,因为线程切换无需切换地址空间,所以线程切换比进程切换快。
20、同步和互斥
同步:两个进程在完成某项任务的时候,需要有个执行上的先后关系,某一个步骤得在另一个步骤之前。
互斥:两个进程彼此互斥,则表示同时只能有一个进程进行,对临界资源的访问必须互斥地访问。
21、信号量(semaphore)和互斥量(mutex)区别
互斥量:一种用于确保在任意时刻只有一个线程可以访问某一共享资源的同步机制,二元状态,互斥量只有两种状态:锁定(locked)和未锁定(unlocked),所有权:互斥量是有所有权的,锁定互斥量的线程必须在完成资源访问后解锁互斥量。阻塞行为:如果一个线程试图锁定已经被另一个线程锁定的互斥量,它将被阻塞,直到互斥量被解锁。用于保护临界资源,确保同一时间只有一个线程可以进入。
信号量:计数器:信号量包含一个计数器,表示当前可用资源的数量,计数器可以是正数、零或负数,无所有权,任何线程都可以增加或减少信号量的计数。阻塞和唤醒:如果信号量的计数为零,试图减少信号量的线程将被阻塞,直到其他线程增加信号量。用于控制对有限数量资源的访问,如数据库连接池、资源池等。适用于需要限制同时访问共享资源的线程数量的场景。
22、线程池(thread pool)
线程池通过预创建一定数量的线程来处理任务,从而避免频繁创建和销毁线程所带来的开销,线程池中的线程在完成任务后不会终止,而是等待分配新的任务,这样可以更高效地管理和使用系统资源。
23、分段和分页的区别
分段:将内存划分为若干大小不同的内存段,每一段表示一个逻辑单元,如代码段、数据段、堆段、栈段等。每个段都有一个段基地址和段界限,描述段的起始地址和长度。
- 按逻辑划分为大小不等的段
- 段表:使用段表来管理各个段的信息
- 段间隔离:不同段之间相互独立,可以提高安全性和稳定性。
- 会产生外部碎片、内存交换效率低。
解决外部碎片的方法就是swap内存交换,先将一部分段换出内存到外存的交换空间,然后再换回内存,但是换回的时候,地址要与前一段紧凑。因此,当频繁的内存交换会导致效率低下。
分页存储 是一种将内存划分为若干固定大小页面的内存管理方式,内存被划分为固定大小的页帧,每个页面映射到一个页帧。
- 固定大小,linux下,每一页为4Kb
- 页表 来记录每个页面映射到的页帧地址
- 虚拟地址,通过页表转换成物理地址访问内存
- 页内保护,每个页面可以独立设置访问权限。
当进程访问的虚拟地址在页表中查不到时,系统就会产生一个缺页异常,进入系统内存态,从新分配物理内存、更新进程页表,恢复进程的运行。
段页式管理方式:
段页式内存管理实现的方式:
先将程序划分为多个有逻辑意义的段,也就是前面提到的分段机制;
接着再把每个段划分为多个页,也就是对分段划分出来的连续空间,再划分固定大小的页;
这样,地址结构就由段号、段内页号和页内位移三部分组成。
linux系统下的虚拟空间分段。
内存分段
24、malloc,free,new,delete区别
malloc:是C语言提供的函数。从堆上分配内存。需要显示地指出所需内存的尺寸,没有调用对象的构造函数。返回的是指向那块内存的(void*)类型的指针,一般需要进程类型转换。free只是释放内存空间。malloc不允许重载。
malloc申请内存的时候,会有两种方式向操作系统申请堆内存
方式一:通过brk()系统调用从堆分配内存
方式二:通过mmap()系统调用在文件映射区分配内存,通过 mmap() 系统调用中「私有匿名映射」的方式,在文件映射区分配一块内存,也就是从文件映射区“偷”了一块内存。如下图:
malloc() 源码里默认定义了一个阈值:
如果用户分配的内存小于 128 KB,则通过 brk() 申请内存;
如果用户分配的内存大于 128 KB,则通过 mmap() 申请内存;
malloc(1)会分配多大的虚拟内存?
malloc()在分配内存的时候,并不是按用户预期申请的字节数来分配内存空间大小,而是会预分配更大的空间作为内存池。具体预分配大多空间跟malloc使用的内存管理器有关。
对于 「malloc 申请的内存,free 释放内存会归还给操作系统吗?」这个问题,我们可以做个总结了:
malloc 通过 brk() 方式申请的内存,free 释放内存的时候,并不会把内存归还给操作系统,而是缓存在 malloc 的内存池中,待下次使用;
malloc 通过 mmap() 方式申请的内存,free 释放内存的时候,会把内存归还给操作系统,内存得到真正的释放
malloc申请的地址会多16个字节,这16个字节保存了该内存块的描述信息,如该内存块的大小,free()的时候,只需要传入一个内存地址,对该内存地址向左偏移16个字节,就可以分析出当前需要释放的内存块的大小。
new:是CPP中关键字(操作符),不是函数,不依赖头文件。从自由存储区分配内存。申请内存是按照对象申请,会调用对象的构造函数。返回的是申请对象类型的指针。delete会调用对象的析构函数。可以重载new/delete操作符。
25、OOM(内存溢出)
内存溢出(out of memory),是指应用系统中存在无法回收的内存或使用的内存过多,最终使得程序运行时要用到的内存大于能提供的最大内存,此时程序就运行不了了,系统会提示内存溢出。
linux有OOM机制,操作系统会kill 内存溢出的进程。
26、LRU(least recently Used,最近最少使用)
是一种常见的缓存替换策略,用于管理计算机内存或缓存中的数据,当缓存(或内存)已满且需要腾出空间给新的数据时,LRU策略会选择最近最少使用的那块数据进行替换。这种策略基于局部性原理。
LRU算法的实现可以采用以下几种方式:
LRU实现方式
使用链表(双向链表)和哈希表:
链表:双向链表用于保存数据项,链表中的数据按访问顺序排列。最近访问的数据放在链表头部,最久未使用的数据放在链表尾部。
哈希表:哈希表用于存储键值对,以快速定位数据项并在链表中移动它们。
当访问某个数据时,将该数据移动到链表头部。当需要替换数据时,从链表尾部移除数据。
计数器:
为每个数据项维护一个计数器,记录上一次访问的时间戳。
每次访问数据时,更新该数据项的时间戳。
需要替换数据时,选择时间戳最早的那个数据项进行替换。
LRU算法对应的两个问题
1、预读失效:导致缓存命中率下降。预读机制,基于空间局部性原理,操作系统会预读邻近的几个页的数据。预读失效即多读的页没有被访问。
2、缓存污染:导致缓存命中率下降。在批量读取数据的时候,由于数据被访问了一次,这些大量数据都会被加入到活跃LRU链表里,然后之前缓存在活跃LRU链表里的热点数据全部被淘汰了,如果这些大量的数据在很长一段时间内都不会被访问的话,那么整个活跃LRU链表(或者young区域)就被污染了。
linux和MySQL的innodb引擎通过改进传统的LRU链表来避免预读失效带来的影响,具体的改进如下
linux操作系统实现了两个LRU链表:活跃LRU链表和非活跃LRU链表
MySQL的Innodb存储引擎是在一个LRU链表上划分来2个区域:young区域和old区域
两个改进方式设计思想都是类似的,都是将数据分为冷数据和热数据,然后分别进行LRU算法。不像传统的LRU算法那样,所有数据都只用一个LRU算法管理。
解决缓存污染的影响:
传统LRU算法只要数据被访问一次,就将数据加入活跃LRU链表(或者young区域),这种LRU算法进入活跃LRU链表的门槛太低,正因为门槛太低,才导致在发生缓存污染的时候,很容易将原本活跃在LRU链表里的热点数据淘汰。
linux操作系统:在内存页被访问第二次的时候,才将页从inactive list 升级到active list里。
MySQL Innodb:在内存页被访问第二次的时候,并不会马上将该页从old区域升级到young区域,因为还要进行停留在old区域的时间判断
如果第二次的访问时间与第一次访问的时间在1秒内(默认值),那么该页就不会从old区域升级到young区域
如果两次时间超过1秒,那么该页就会从old区域升级到young区域。
27、虚拟内存空间划分
划分为内核空间和用户空间。
其中用户空间可进一步划分为:
代码段:用来存储程序运行所需的机器指令
数据段:存储初始化的全局变量和静态变量
BSS段:存储未初始化的全部变量和静态变量,默认初始值为0
堆:由程序有自由分配的区域,用于程序运行时动态申请内存的堆。
待分配的区域:
文件映射与匿名营社区:mmap系统调用,将其他文件映射到该进程的用户空间的存储位置,动态链接库中的代码段,数据段,bss段加载在这。
栈:存储局部变量、函数和临时变量,利用栈的先进先出实现作用域的功能
内核空间:存储内核运行环境
在64位系统中,可以表示2^64个虚拟地址空间,但是实际上只使用了48位来描述虚拟内存空间,所能表达的虚拟空间为258TB,其中高128TB为内核空间,低128TB为用户空间。
就是前边提到的由高 16 位空闲地址造成的 canonical address 空洞。在这段范围内的虚拟内存地址是不合法的,因为它的高 16 位既不全为 0 也不全为 1,不是一个 canonical address,所以称之为 canonical address 空洞。
在代码段跟数据段的中间还有一段不可以读写的保护段,它的作用是防止程序在读写数据段的时候越界访问到代码段,这个保护段可以让越界访问行为直接崩溃,防止它继续往下运行。
用户态虚拟内存空间与内核态虚拟内存空间分别占用 128T,其中低128T 分配给用户态虚拟内存空间,高 128T 分配给内核态虚拟内存空间。
28、锁
加锁的目的是保证共享资源在任意时间里,只有一个线程访问,这样就可以避免多线程导致共享数据错乱的问题。
互斥锁和自旋锁是最底层的锁机制,
当已经有一个线程加锁后,其他线程加锁则会失败,互斥锁和自旋锁对于加锁失败后的处理方式是不一样的
互斥锁:互斥锁加锁失败后,线程会释放CPU,给其他线程。互斥锁是一种「独占锁」,比如当线程 A 加锁成功后,此时互斥锁已经被线程 A 独占了,只要线程 A 没有释放手中的锁,线程 B 加锁就会失败,于是就会释放 CPU 让给其他线程,既然线程 B 释放掉了 CPU,自然线程 B 加锁的代码就会被阻塞。
自旋锁:加锁失败后,线程会忙等待,直到它拿到锁。也就是说自旋锁是在「用户态」完成加锁和解锁操作,不会主动产生线程上下文切换,所以相比互斥锁来说,会快一些,开销也小一些。自旋锁是通过 CPU 提供的 CAS 函数(Compare And Swap),使用自旋锁的时候,当发生多线程竞争锁的情况,加锁失败的线程会「忙等待」,直到它拿到锁。这里的「忙等待」可以用 while 循环等待实现,不过最好是使用 CPU 提供的 PAUSE 指令来实现「忙等待」,因为可以减少循环等待时的耗电量。
==互斥锁会阻塞线程,从而导致上下文的切换。而线程的上下文切换需要几十纳秒到几微妙之间,如果锁住的代码执行时间比较短,可能导致上下文切换的时间比锁住的代码执行时间还要长,因此此时需要使用自旋锁。==自旋锁与互斥锁使用层面比较相似,但实现层面上完全不同:当加锁失败时,互斥锁用「线程切换」来应对,自旋锁则用「忙等待」来应对。
读写锁:由读锁和写锁两部分组成,如果只读取共享资源用【读锁】加锁,如果要修改共享资源用【写锁】加锁
读写锁适用于能明确区分读操作和写操作的场景。
读写锁的工作原理是:
当「写锁」没有被线程持有时,多个线程能够并发地持有读锁,这大大提高了共享资源的访问效率,因为「读锁」是用于读取共享资源的场景,所以多个线程同时持有读锁也不会破坏共享资源的数据。
但是,一旦「写锁」被线程持有后,读线程的获取读锁的操作会被阻塞,而且其他写线程的获取写锁的操作也会被阻塞。
所以说,写锁是独占锁,因为任何时刻只能有一个线程持有写锁,类似互斥锁和自旋锁,而读锁是共享锁,因为读锁可以被多个线程同时持有。
乐观锁与悲观锁:
前面提到的互斥锁、自旋锁、读写锁,都是属于悲观锁。悲观锁做事比较悲观,它认为多线程同时修改共享资源的概率比较高,于是很容易出现冲突,所以访问共享资源前,先要上锁。那相反的,如果多线程同时修改共享资源的概率比较低,就可以采用乐观锁。
乐观锁做事比较乐观,它假定冲突的概率很低,它的工作方式是:先修改完共享资源,再验证这段时间内有没有发生冲突,如果没有其他线程在修改资源,那么操作完成,如果发现有其他线程已经修改过这个资源,就放弃本次操作。我们都知道在线文档可以同时多人编辑的,如果使用了悲观锁,那么只要有一个用户正在编辑文档,此时其他用户就无法打开相同的文档了,这用户体验当然不好了。那实现多人同时编辑,实际上是用了乐观锁,它允许多个用户打开同一个文档进行编辑,编辑完提交之后才验证修改的内容是否有冲突。
29、零拷贝、DMA(direct memory access)技术
DMA(直接内存访问)技术:允许硬件子系统在不经过CPU干预的情况下,直接与系统内存进行数据传输。这种技术可以显著提高数据传输效率,减轻CPU的负担,使其可以处理其他任务。使用DMA控制器进行数据传输的过程。
早期 DMA 只存在在主板上,如今由于 I/O 设备越来越多,数据传输的需求也不尽相同,所以每个 I/O 设备里面都有自己的 DMA 控制器。
零拷贝:是一种计算机数据传输技术,意在在两个位置之间移动数据时尽量减少或消除数据在CPU内存之间的拷贝。传统的数据传输方式往往需要多个拷贝步骤,而零拷贝技术可以直接将数据从一个位置传输到另一个为止,从而提高效率,减少CPU负载,并提升整体系统性能。
如何实现零拷贝?
通常有两种:
1、mmap+write
使用mmap替换read()系统调用函数
mmap() 系统调用函数会直接把内核缓冲区里的数据「映射」到用户空间,这样,操作系统内核与用户空间就不需要再进行任何的数据拷贝操作。可以减少一次数据拷贝的过程,但这还不是最理想的零拷贝,因为仍然需要通过 CPU 把内核缓冲区的数据拷贝到 socket 缓冲区里,而且仍然需要 4 次上下文切换,因为系统调用还是 2 次
2、sendfile
首先,它可以替代前面的 read() 和 write() 这两个系统调用,这样就可以减少一次系统调用,也就减少了 2 次上下文切换的开销。
其次,该系统调用,可以直接把内核缓冲区里的数据拷贝到 socket 缓冲区里,不再拷贝到用户态,这样就只有 2 次上下文切换,和 3 次数据拷贝。如下图:
但是这还不是真正的零拷贝技术,如果网卡支持 SG-DMA(The Scatter-Gather Direct Memory Access)技术(和普通的 DMA 有所不同),我们可以进一步减少通过 CPU 把内核缓冲区里的数据拷贝到 socket 缓冲区的过程。
第一步,通过 DMA 将磁盘上的数据拷贝到内核缓冲区里;
第二步,缓冲区描述符和数据长度传到 socket 缓冲区,这样网卡的 SG-DMA 控制器就可以直接将内核缓存中的数据拷贝到网卡的缓冲区里,此过程不需要将数据从操作系统内核缓冲区拷贝到 socket 缓冲区中,这样就减少了一次数据拷贝;
这就是所谓的零拷贝(Zero-copy)技术,因为我们没有在内存层面去拷贝数据,也就是说全程没有通过 CPU 来搬运数据,所有的数据都是通过 DMA 来进行传输的。
零拷贝技术的文件传输方式相比传统文件传输的方式,减少了 2 次上下文切换和数据拷贝次数,只需要 2 次上下文切换和数据拷贝次数,就可以完成文件的传输,而且 2 次的数据拷贝过程,都不需要通过 CPU,2 次都是由 DMA 来搬运。
所以,总体来看,零拷贝技术可以把文件传输的性能提高至少一倍以上。
于是,在高并发的场景下,针对大文件的传输的方式,应该使用「异步 I/O + 直接 I/O」来替代零拷贝技术。
所以,传输文件的时候,我们要根据文件的大小来使用不同的方式:
传输大文件的时候,使用「异步 I/O + 直接 I/O」;
传输小文件的时候,则使用「零拷贝技术」;
5.mysql数据库
6.Linux系统
常用高级一点的命令
ps aux 查看进程状态
top 查看系统资源使用情况
7.socket网络编程
1.socket的多路复用
select、poll、epoll都是IO多路复用的一种机制,可以监视多个文件描述符,一旦某个文件描述符进入读或写就绪状态,就能够通知系统进行相应的读写操作。
select
优点:
- 可移植性好,因为在某些Unix系统中不支持poll和epoll
- 对于超时时间提供了更好的精度:微妙,而poll和epoll都是毫秒级
缺点: - 支持监听的文件描述符fd的数量有限,最大数量默认是1024个
- select需要维护一个用来存放文件描述符的数据结构,每次调用select都需要把fd集合从用户区拷贝到内核区,而select系统调用后有需要把fd集合从内核区拷贝到用户区,这个系统开销在fd数量很多的时候会很大。
poll
优点(相对于select)
没有最大文件描述符数量的限制,poll基于链表存储主要解决了这个最大文件描述符数量的限制(上限为操作系统能支持的能开启的最大文件描述符数量),优化了编程接口,减少了函数调用参数,并且,每次调用select函数时,都必须重置该函数的三个fd_set类型的参数值,而poll不需要重置。
缺点
poll和select一样同样都需要维护一个用来存放文件描述符的数据结构,当注册的文件描述符无限多时,会使得用户态和内核区之间传递该数据结构的复制开销很大。每次poll系统调用时,需要把文件描述符fd从用户态拷贝到内核区,然后poll系统调用返回前,又需要把文件描述符fd集合从内核区拷贝到用户区,这个内存拷贝的系统开销在fd数量很多的时候会很大。
epoll
优点:和poll一样没有最大文件描述符数量的限制,epoll虽然也需要维护用来存放文件描述符的数据结构(epoll_event),但是它只需要将该数据结构拷贝一次,不需要重复拷贝,并且它只在调用epoll_ctl系统调用时拷贝一次要监听的文件描述符数据结构到内核区,在调用epoll_wait的时候不需要再把所有的要监听的文件描述符重复拷贝进内核区,这就解决了select和poll种内存复制开销的问题。
缺点:目前只有Linux操作系统支持epoll,不支持跨平台使用,而Unix操作系统上是使用kqueue。
Epoll水平触发(LT):对于读操作,只要缓冲区内容不为空,LT模式返回读就绪。 对于写操作,只要缓冲区还不满,LT模式会返回写就绪。
Epoll边缘触发(ET):对于读操作,当缓冲区由不可读变为可读的时候,有新数据到达时,进程修改了EPOLL_CTL_MOD修EPOLLIN事件时。
在ET模式下,缓冲区从不可读变成可读,会唤醒应用进程,缓冲区数据变少的情况,则不会再唤醒应用进程。
当被监控的文件描述符上有可读写事件发生时,epoll_wait()会通知处理程序去读写。如果这次没有把数据全部读写完(如读写缓冲区太小),那么下次调用epoll_wait()时,它不会通知你,也就是它只会通知你一次,直到该文件描述符上出现第二次可读写事件才会通知你。通常配合将文件描述符设置为非阻塞状态一起使用,这种模式比水平触发效率高,系统不会充斥大量你不关心的就绪文件描述符。
5、多线程为什么会发射死锁
多线程中易发生多线程对资源进行竞争,如果一个进程集合里面的每一个进程都在等待这个集合中的另一个进程释放资源才能继续往下执行,若无外力他们将无法推进,这种情况就是死锁。
产生死锁的四个条件:
- 互斥条件:资源在某一时刻只能由一个进程/线程占用,如果一个资源被其他进程占用,其他请求该资源的进程必须等待。
- 请求和保持条件:一个进程已经获得了至少一个资源,同时还在等待其他被其他进程占用的资源。
- 不可剥夺条件:资源不能被强制从占用它的进程中剥夺,必须由占用该资源的进行自行释放。
- 环路等待条件:存在一个进程链,使得链中每一个进程都在等待下一个进程所持有的资源。
破坏以上任意条件都可解除死锁。
6、线程有哪些状态,线程锁有哪些?
线程状态:创建态、就绪态(其他资源已分配,CPU没分配)、运行态(上CPU且所需资源到位)、阻塞态(因为系统调用,等待某种事件的发生,如打印机资源空出来,下CPU)、终止态。
线程锁的种类:互斥锁、条件锁、自旋锁、读写锁、递归锁。