c++八股文总结

文章目录

      • 1.引用和右值引用区别是什么?
      • 2.std::move和std::forward各有什么作用?
        • std::move
        • std::forward
      • 3.c++中多线程同步有哪些方案?
        • 1.互斥锁
        • 2.条件变量
        • 3.读写锁
        • 4.信号量
      • 4.构造函数可以是虚函数吗
      • 5.介绍下虚函数表和虚函数指针
      • 6.介绍下c++的程序内存结构
      • 7.shared_ptr原理
      • 8.shared_ptr是否是线程安全的?
      • 9.C++智能指针的引用计数为什么也要在堆区申请空间?
      • 10.讲讲epoll中的水平触发和边缘触发
      • 死锁产生的必要条件以及实际过程中预防死锁的方式?
      • 互斥锁和自旋锁区别
      • c++中unordered_map和map区别

1.引用和右值引用区别是什么?

  1. 一般认为,可以放在等号"="左边的是左值,可以放在等号右边的是右值。但是这个说法是错误的。
  2. 右值包括纯右值和将亡值。纯右值,比如常量,表达式值a+b将亡值,比如函数传值返回,表达式的中间结果。顾名思义,将亡值的空间马上就要被释放了
  3. 左值一般是可以修改的值,可以取地址的,通常是变量
  4. 引用,只能引用左值,不能引用右值。但是const引用既可以引用左值,又可以引用右值。
int main(){
    //a为左值,10为右值
	int a = 10;
	int& ra1 = a;
	const int& ra2 = a;
 
	//int& ra3 = 10;//编译错误,10为右值
	const int& ra3 = 10;
 
	return 0;
  1. 右值引用只能引用右值,不能引用左值。但是右值引用可以引用move之后的左值。因为move改变了左值的属性,变成了右值。

2.std::move和std::forward各有什么作用?

std::move

std::move用于将一个左值强制转换为右值引用,从而实现移动语义。移动语义可以避免在对象拷贝时发生内存分配和复制,提高程序运行效率。例如:

std::vector vec1 = {1, 2, 3};
std::vector vec2 = std::move(vec1); //将vec1的内容移动到vec2中
std::forward

std::forward用于实现完美转发,即在函数模板中将参数按原样转发给另一个函数。完美转发可以避免出现不必要的对象拷贝和移动,提高程序效率。例如:

template<typename T>
void func(T&& arg)
{
    other_func(std::forward<T>(arg));
}

在此例中,如果arg是左值引用,则会转发为左值引用,如果arg是右值引用,则会转发为右值引用。这样可以保证在调用other_func时,参数类型和传递方式与原始函数调用保持一致,避免不必要的对象拷贝和移动。


3.c++中多线程同步有哪些方案?

1.互斥锁

本质就是一个特殊的全局变量,拥有lock和unlock两种状态,unlock的互斥锁可以由某个线程获得,一旦获得,这个互斥锁会锁上变成lock状态,此后只有该线程由权力打开该锁,其他线程想要获得互斥锁,必须得到互斥锁再次被打开之后

2.条件变量
互斥量不是万能的,比如某个线程正在等待共享数据内某个条件出现,可
可能需要重复对数据对象加锁和解锁(轮询),但是这样轮询非常耗费时间和资源,
而且效率非常低,所以互斥锁不太适合这种情况
我们需要这样一种方法:当线程在等待满足某些条件时使线程进入睡眠状态,
一旦条件满足,就唤醒因等待满足特定条件而睡眠的线程
如果我们能够实现这样一种方法,程序的效率无疑会大大提高,而这种方法正是条件变量!
#include 
#include
#include
#include
#include
#include
#include
using namespace std;
pthread_cond_t qready=PTHREAD_COND_INITIALIZER; //cond
pthread_mutex_t qlock=PTHREAD_MUTEX_INITIALIZER; //mutex
int x=10,y=20;
void *f1(void *arg)
{
	cout<<"f1 start"<<endl;
	pthread_mutex_lock(&qlock);
	while(x<y)
	{
		pthread_cond_wait(&qready,&qlock);
	}
	pthread_mutex_unlock(&qlock);
	sleep(3);
	cout<<"f1 end"<<endl;
	return 0;
}

void *f2(void *arg)
{
	cout<<"f2 start"<<endl;
	pthread_mutex_lock(&qlock);
	x=20;
	y=10;
	cout<<"has a change,x="<<x<<" y="<<y<<endl;
	pthread_mutex_unlock(&qlock);
	if(x>y)
	{
		pthread_cond_signal(&qready);
	}
	cout<<"f2 end"<<endl;
	return 0;
}

int main()
{
	pthread_t tids[2];
	int flag;
	flag=pthread_create(&tids[0],NULL,f1,NULL);
	if(flag)
	{
		cout<<"pthread 1 create error "<<endl;
		return flag;
	}
	sleep(2);
	flag=pthread_create(&tids[1],NULL,f2,NULL);
	if(flag)
	{
		cout<<"pthread 2 create erro "<<endl;
		return flag;
	}
	sleep(5);
	return 0;
} 
分析:线程1不满足条件被阻塞,然后线程2运行,改变了条件,
线程2发行条件改变了通知线程1运行,然线程1不满足条件被阻塞,
然后线程2运行,改变了条件,线程2发行条件改变了通知线程1运行,
然后线程2结束,然后线程1继续运行,然后线程1结束,为了确保线程1先执行,
在创建线程2之前我们sleep了2秒
ps:
 条件变量通过运行线程阻塞和等待另一个线程发送信号的方法弥补互斥锁的不足,
 常常和互斥锁一起使用,使用时,条件变量被用来阻塞一个线程,当条件不满足时,
 线程往往解开响应的互斥锁并等待条件发生变化,一旦其他的某个线程改变了条件变量,
 它将通知响应的条件变量换线一个或多个正被此条件变量阻塞的线程,这些线程将重新
 锁定互斥锁并且重新测试条件是否满足

另外列出一个例子:

#include
#include
#include
#include
#include
#include

using namespace std;

std::mutex mutex1;
std::condition_variable cv;  // 条件变量一般需要和互斥锁一起使用

int globalStep = 0;

void work(int step) {
	std::this_thread::sleep_for(std::chrono::seconds(rand() % 3));
	cout<<"step:"<<step<<" prework is finished"<<endl;
	std::unique_lock<std::mutex> lock(mutex1);
	//  ①wait函数会先对传入的unique_lock进行unlock,并且阻塞当前线程,
	//  unlock和block的操作,是一个原子行为。
	//  然后将当前线程添加到当前condition_variable对象的等待线程列表中;
	cv.wait(lock, [&]{
		return step == globalStep;
	});
	cout<<"step:"<<step<<" get working"<<std::endl;
	this_thread::sleep_for(std::chrono::seconds(2));
	globalStep++;
	lock.unlock();
	// ②当唤醒线程调用notify_all或者notify_one时,
	// 就会解除wait线程的阻塞。解除阻塞之后,就会重新对unique_lock进行lock,
	// 然后wait函数返回;
	cv.notify_all();  // 通知所有条件变量可以去看条件是否满足
}

int main() {
	const int steps = 10;
	vector<thread> th;
	for(int i = 0; i < steps; i++) {
		th.emplace_back(thread(work, i));
	}
	for(int i = 0;i < steps; i++) {
		th[i].join();
	}
}
3.读写锁
4.信号量

信号量(sem)和互斥锁的区别:互斥锁只允许一个线程进入临界区,而信号量允许多个线程进入临界区

1)信号量初始化
int sem_init(&sem,pshared,v)
pshared为0表示这个信号量是当前进程的局部信号量
pshared为1表示这个信号量可以在多个进程之间共享
v为信号量的初始值
成功返回0,失败返回-1

2)信号量值的加减
int sem_wait(&sem):以原子操作的方式将信号量的值减去1
int sem_post(&sem):以原子操作的方式将信号量的值加上1

3)对信号量进行清理
int sem_destory(&sem)
通过信号量模拟2个窗口,10个客人进行服务的过程
#include 
#include
#include
#include
#include
#include
#include
#include
using namespace std;
int num=10;
sem_t sem;
void *get_service(void *cid)
{
	int id=*((int*)cid);
	if(sem_wait(&sem)==0)
	{
		sleep(5);
		cout<<"customer "<<id<<" get the service"<<endl;
		cout<<"customer "<<id<<" done "<<endl;
		sem_post(&sem);
	}
	return 0;
}

int main()
{
	sem_init(&sem,0,2);
	pthread_t customer[num];
	int flag;
	for(int i=0;i<num;i++)
	{
		int id=i;
		flag=pthread_create(&customer[i],NULL,get_service,&id);
		if(flag)
		{
			cout<<"pthread create error"<<endl;
			return flag;
		}else
		{
			cout<<"customer "<<i<<" arrived "<<endl;
		}
		sleep(1);
	}
	//wait all thread done
	for(int j=0;j<num;j++)
	{
		pthread_join(customer[j],NULL);
	}
	sem_destroy(&sem);
	return 0;
}
分析:信号量的值代表空闲的服务窗口,每个窗口一次只能服务一个人,有空闲窗口,
开始服务前,信号量-1,服务完成后信号量+1 

4.构造函数可以是虚函数吗

当类中声明虚函数时,编译器会在类中生成一个虚函数表,虚函数表是一个存储成员函数指针的数据结构。
虚函数表是由编译器自动生成与维护的,virtual成员函数会被编译器放入虚函数表中,当存在虚函数时,每个对象都有一个指向虚函数的指针(vptr指针)。在实现多态的过程中,父类和派生类都有vptr指针。
vptr的初始化:当对象在创建时,由编译器对vptr指针进行初始化。在定义子类对象时,vptr先指向父类的虚函数表,在父类构造完成之后,子类的vptr才指向自己的虚函数表。
如果构造函数是虚函数,那么调用构造函数就需要去找vptr,而此时vptr还没有初始化。
因此,构造函数不可以是虚函数。


5.介绍下虚函数表和虚函数指针

编译器为含有虚函数的类生成了一个虚表,vtable,这个表可以理解为是静态的,即属于该类不属于实例化的对象。虚表中存放了该类中的虚函数,虚表可以理解为一维数组,存放的是该类定义的虚函数的地址。基类有基类的虚表,派生类有派生类的虚表。虚表在编译时生成。当然虚表也可以继承。
同时每个含有虚函数的类在实例化时编译器会为对象生成一个虚指针,vptr,虚指针在构造函数中初始化虚指针初始化时指向该类实际指向对象的虚表,vtable,比如基类指针实际指向派生类,该虚指针就指向派生类的虚表;基类指针实际指向基类,则虚指针指向基类的虚函数表

当通过基类指针或者引用调用虚函数时,根据该对象的虚指针实际指向的虚表找到相应的虚函数,调用该虚函数。

虚表类似于类的静态成员,不属于某一个具体的对象,而且虚表的大小在编译时就可以确定,所以虚表肯定不是在堆栈段,虚表存储在数据段;而虚指针是属于一个具体的对象,所以虚指针和所属对象存放在同一个内存段

虚函数表vtable在Linux/Unix中存放在可执行文件的只读数据段中(rodata),这与微软的编译器将虚函数表存放在常量段存在一些差别。


6.介绍下c++的程序内存结构

一个程序被加载到内存中,这块内存首先就存在两种属性:静态分配内存和动态分配内存。
静态分配内存:是在程序编译和链接时就确定好的内存。
动态分配内存:是在程序加载、调入、执行的时候分配/回收的内存。
Text & Data & Bss

  • text: 也称为代码段(Code),用来存放程序执行代码,同时也可能会包含一些常量(如一些字符串常量等)。该段内存为静态分配,只读(某些架构可能允许修改)。
    这块内存是共享的,当有多个相同进程(Process)存在时,共用同一个text段。

  • data: 也有的地方叫GVAR(global value),用来存放程序中已经初始化的非零全局变量。静态分配。

data又可分为读写(RW)区域和只读(RO)区域。
-> RO段保存常量所以也被称为.constdata
-> RW段则是普通非常全局变量,静态变量就在其中

  • bss: 存放程序中为初始化的和零值全局变量。静态分配,在程序开始时通常会被清零。

text和data段都在可执行文件中,由系统从可执行文件中加载;而bss段不在可执行文件中,由系统初始化。

这三段内存就组成了我们编写的程序的本体,但是一个程序运行起来,还需要更多的数据和数据间的交互,否则这个程序就是死的,无用的。所以我们还需要为更多的数据和数据交互提供一块内存——堆栈。
堆栈(Heap& Stack)
堆和栈都是动态分配内存,两者空间大小都是可变的。

Stack: 栈,存放Automatic Variables,按内存地址由高到低方向生长,其最大大小由编译时确定,速度快,但自由性差,最大空间不大。

Heap: 堆,自由申请的空间,按内存地址由低到高方向生长,其大小由系统内存/虚拟内存上限决定,速度较慢,但自由性大,可用空间大。
每个线程都会有自己的栈,但是堆空间是共用的。

text、data(gvar)、bss 在内存中地址较低低的位置(low level address),而堆栈则在相对较搞的位置。
堆(Heap)往高地址方向生长,栈(Stack)往低地址方向生长。
c++八股文总结_第1张图片


7.shared_ptr原理

基本原理:就是记录对象被引用的次数,当引用次数为 0 的时候,也就是最后一个指向该对象的共享指针析构的时候,共享指针的析构函数就把指向的内存区域释放掉。

特点:它所指向的资源具有共享性,即多个shared_ptr可以指向同一份资源,并在内部使用引用计数机制来实现这一点。
共享指针内存:每个 shared_ptr 对象在内部指向两个内存位置:

指向对象的指针;
用于控制引用计数数据的指针。
1.当新的 shared_ptr 对象与指针关联时,则在其构造函数中,将与此指针关联的引用计数增加1。
2.当任何 shared_ptr 对象超出作用域时,则在其析构函数中,它将关联指针的引用计数减1。
如果引用计数变为0,则表示没有其他 shared_ptr 对象与此内存关联,在这种情况下,
它使用delete函数删除该内存。

智能指针引用计数改变:
构造函数(sptr1)
,赋值操作符(sptr2=sptr3),
拷贝构造函数(std::shared_ptr sptr2=sptr1)
以及调用reset()函数(sptr1.reset()),
拷贝的智能指针shared_ptr生命周期结束


8.shared_ptr是否是线程安全的?

多线程对于同一个 shared_ptr 实例的读操作(访问)可以保证线程安全;
但对于同一个 shared_ptr 实例的写操作(改变一个 shared_ptr 指向的对象)则需要同步,
否则会发生 race condition。
即 shared_ptr 的引用计数本身是线程安全且无锁的,
但 shared_ptr 对象本身的读写则不是,因为 shared_ptr 有两个数据成员,
读写操作不能原子化:
多个线程可以同时读取一个 shared_ptr 对象;
多个线程同时读写一个 shared_ptr 对象,则需要加锁。
 以上讨论是 shared_ptr 对象本身的线程安全级别,而非其管理的对象的安全级别,shared_ptr 所管理的对象的并发操作是否为线程安全的,取决于具体所管理的对象,必要时需要使用一些同步手段加以保证。
shared_ptr 的数据结构
shared_ptr 是引用计数型智能指针,计数值保存在堆上动态分配的内存中。具体而言,shared_ptr包含两个成员,一个是 Foo 类型的指针,指向被管理的对象;一个 ref_count 指针,指向堆上的控制块:
c++八股文总结_第2张图片
由于 shared_ptr 间的拷贝涉及两个成员的复制,而这两步拷贝不会原子性地发生:
步骤 1:复制 ptr 指针:
在这里插入图片描述
步骤 2:复制 ref_count 指针,并递增引用计数(此递增为线程安全的):
在这里插入图片描述
多线程无保护读写 shared_ptr 可能出现的 race condition
而要写入一个 shared_ptr(即修改一个 shared_ptr 变量),由于涉及到对象的析构,则有可能带来 race condition。
考虑以下场景,有三个 shared_ptr:

shared_ptr g(new Foo);		// 线程间共享的 shared_ptr 对象
shared_ptr x;		// 线程 A 的局部变量
shared_ptr n(new Foo);		// 线程 B 的局部变量

初始状态:
在这里插入图片描述
线程 A 执行 x = g(即 read g),并完成了步骤 1,但还没来得及执行步骤 2。此时切换到了线程 B:
在这里插入图片描述
同时线程执行 g = n(即 write g),并完成了两个步骤:
先是步骤 1:
在这里插入图片描述再是步骤 2:
在这里插入图片描述
此时修改 g 的 ref_count 指针,引用计数递减为 0,触发 Foo1 对象的销毁,x.ptr 成了空悬指针。
最后回到线程 A,完成步骤 2:
c++八股文总结_第3张图片
 多线程无保护地读写 g,造成了 x 指针的空悬,这也是多线程读写同一个 shared_ptr 必须加锁的原因。
 当然 race condition 远不止这一种,其他线程交织还是可能造成其他错误。

9.C++智能指针的引用计数为什么也要在堆区申请空间?

重点在于我们需要一种机制,使得一个智能指针更新rf时,所有指向同一资源的智能指针rf一起更新。所以严格来说,只要你找到一种比较好的“广而告之”的机制就行,heap变量+保存指针这种方式最自然,那官方就用这种了

10.讲讲epoll中的水平触发和边缘触发

在 epoll 中,水平触发(Level-Triggered,LT)和边缘触发(Edge-Triggered,ET)是两种不同的事件触发模式。它们的区别在于:

水平触发模式(LT):当被监听的文件描述符上有可读或可写事件发生时,每次 epoll_wait() 调用都会返回该文件描述符上的就绪事件。如果应用程序没有立即处理该事件,下次 epoll_wait() 调用时仍会返回该事件。也就是说,如果该文件描述符上一直有事件没有被处理,epoll_wait() 将会一直返回该文件描述符上的事件,直到应用程序处理了该事件。

边缘触发模式(ET):当被监听的文件描述符上有可读或可写事件发生时,只有在该文件描述符状态发生变化时,epoll_wait() 才会返回该事件。也就是说,如果该文件描述符上有事件未被处理,下次 epoll_wait() 只会在该文件描述符状态发生变化时返回该事件,而不是在每次调用 epoll_wait() 时都返回该事件。

总之,水平触发模式适合于使用阻塞 I/O 的情况
边缘触发模式适合于使用非阻塞 I/O 和基于事件驱动的编程模型。
边缘触发模式对应的处理方式更加高效,但对编程模型的要求也更高。


死锁产生的必要条件以及实际过程中预防死锁的方式?

死锁是指两个或多个进程在执行过程中,由于竞争资源而造成的一种僵局,每个进程都在等待其他进程释放所持有的资源,导致它们都无法继续执行。死锁是并发编程中的一种常见问题,为了避免死锁的发生,我们首先要了解死锁产生的必要条件,然后采取相应的预防措施。

死锁产生的必要条件有四个,通常被称为"死锁四条件":

  1. 互斥条件(Mutual Exclusion):至少有一个资源被排他性地控制,即一次只能被一个进程使用。
  2. 请求与保持条件(Hold and Wait):一个进程因请求资源而被阻塞时,已经获得的资源保持不放。
  3. 不可剥夺条件(No Preemption):已经分配给进程的资源不能被强制性地剥夺,只能由持有资源的进程显式地释放。
  4. 循环等待条件(Circular Wait):存在一组进程{P1, P2, …, Pn},每个进程都在等待下一个进程所持有的资源。

要预防死锁,可以采取以下方式:

  1. 破坏死锁四条件:通过合理的资源分配策略,破坏死锁四条件中的一个或多个条件,从而阻止死锁的产生。

    • 互斥条件:尽量使用共享资源而非排他资源。
    • 请求与保持条件:要求进程在请求资源时一次性请求所需的所有资源,或者使用资源时先释放已经持有的资源。
    • 不可剥夺条件:允许在适当的时候强制性地剥夺进程所持有的资源。
    • 循环等待条件:对资源进行全局排序,要求进程按照顺序请求资源,避免形成循环等待。
  2. 超时机制(Timeouts):为资源的请求设置超时机制,如果在一定时间内没有获得所需资源,进程将释放已经持有的资源,从头开始请求资源。

  3. 死锁检测与恢复:周期性地检测系统中是否发生了死锁,一旦检测到死锁,可以通过资源剥夺或进程终止的方式进行恢复。

  4. 避免(Avoidance):使用预防性的资源分配策略,通过安全序列算法来避免分配资源后出现死锁。

  5. 死锁预防设计:在系统设计阶段,考虑到资源竞争的情况,设计系统使其不容易产生死锁,例如避免长时间持有多个资源,减少资源竞争等。

请注意,以上方法可以在不同的场景中综合使用,具体的预防死锁策略需要根据具体的应用环境和需求来确定。


互斥锁和自旋锁区别

互斥锁(Mutex)和自旋锁(Spinlock)是并发编程中用于保护共享资源的两种常见锁机制,它们有一些重要的区别:

  1. 等待方式:

    • 互斥锁:当一个线程请求加锁但锁已经被其他线程占用时,该线程会被阻塞挂起,直到锁被释放,然后该线程才能获得锁并继续执行。
    • 自旋锁:当一个线程请求加锁但锁已经被其他线程占用时,该线程不会被挂起,而是会一直循环忙等(自旋)直到获得锁,这意味着线程会一直占用CPU资源在等待锁上。
  2. CPU开销:

    • 互斥锁:由于线程在等待时会被挂起,因此不会占用CPU资源,但在线程被唤醒时需要进行上下文切换,这会引入一定的开销
    • 自旋锁:由于线程在等待时一直自旋,不会被挂起,所以不涉及上下文切换,但会占用CPU资源
  3. 等待时间:

    • 互斥锁:当线程请求互斥锁时,如果锁已经被其他线程持有,则线程会被阻塞挂起,等待时间可能较长。
    • 自旋锁:当线程请求自旋锁时,如果锁已经被其他线程持有,该线程会不断自旋,等待时间较短。
  4. 使用场景:

    • 互斥锁:适用于保护临界资源,当临界资源的访问时间较长或有可能引起较长时间的阻塞时,使用互斥锁更为合适。
    • 自旋锁:适用于保护临界资源,但临界资源的访问时间较短,且在短时间内可获得锁,这样自旋的开销相对较小。

总的来说,选择互斥锁还是自旋锁要根据具体的场景和需求来决定。如果资源的争用时间较长,使用互斥锁可能更合适,因为它能够有效利用线程的等待时间;而如果资源的争用时间很短,且线程能够迅速获得锁,使用自旋锁可以减少上下文切换的开销。有些情况下,还可以结合两种锁的特点,使用适应性锁来在低竞争情况下使用自旋锁,在高竞争情况下切换为互斥锁,以获得更好的性能。


c++中unordered_map和map区别

在 C++ 中,std::unordered_mapstd::map 是两种不同的关联容器,它们在实现和使用上有以下区别:

  1. 底层实现方式:

    • std::unordered_map:是哈希表(hash table)实现的关联容器。使用哈希函数将键映射到桶,提供了常数时间复杂度 O(1) 的平均查找、插入和删除操作。
    • std::map:是红黑树(Red-Black Tree)实现的关联容器。通过维护平衡二叉搜索树的性质,提供了 O(log n) 的查找、插入和删除操作。
  2. 元素的有序性:

    • std::unordered_map:不保持元素的插入顺序,元素存储在不同的桶中,没有特定的顺序。
    • std::map:按照键的大小进行有序存储,即元素按键值从小到大排列。
  3. 键类型要求:

    • std::unordered_map:要求键类型支持哈希函数,即要求定义了 std::hash 的特化。
    • std::map:要求键类型支持 < 操作符,即键类型需要能够进行大小比较。
  4. 性能:

    • std::unordered_map:在大多数情况下,哈希表提供了更快的平均插入、查找和删除操作,尤其是对于大量元素的操作。
    • std::map:虽然在某些情况下可能慢于哈希表,但它提供了有序存储和范围查询的特性。

选择使用 std::unordered_map 还是 std::map 取决于具体的需求。如果你需要快速的查找、插入和删除,并且不关心元素的顺序,可以选择 std::unordered_map。如果你需要有序存储元素,并且需要支持范围查询等操作,可以选择 std::map。当然,也可以根据实际情况进行性能测试和分析,选择最适合的容器。


你可能感兴趣的:(c++面试八股文,c++,开发语言)