项目:
- 代码传到github上去!
- 不要过多的去描述项目的业务!
- 描述项目的设计,重写的开源库,有什么样的好处!高并发,缓存,解耦网络层、业务层、db层的强耦合!用到了微服务、消息队列,redis就是将业务层和db层解耦了!
介绍项目:
- 项目背景;为什么做!(聊天服务器,想根据某个业务场景,设计一个高并发的业务的聊天服务器!)写完服务器测试一下性能,支持同时在线的10000人的在线聊天!再提升,怎么做,加集群!还有种方式将它的服务拆分成非常小的服务,做成基于分布式的架构,基于微服务的模型!
- 聊天服务器项目的架构设计,网络层、服务层、数据传输采用的是Json协议,网络使用的是muduo库,基于epoll+多线程的高并发模型!
面试官说:你写的项目没有必要啊?
- 介绍为什么写这个项目!
- 每一次写一个项目都有收获!把前面…知识在项目上得到了实践锻炼,真正的了解了多线程开发,线程通信,在项目中得应用,和之前学习知识点后做的demo是完全不一样的;智能指针…真正的感受到了他的好处!
项目中遇到了哪些问题?
- 设计的问题,分析性能原因,引入了redis,也是问题!
- 碰见死锁!
- 碰见段错误,挂掉了!
- 测试服务器的瓶颈的时候,fd的上限的设置,怎么解决的?
- 项目的稳定性,安全性!
IT技能的每一个描述点,能从项目的什么地方引入进去!
项目如何保证一件事情是否完成?三种情况;
- 协议本身帮你做了;
- 基于协议实现的第三方框架帮你做了;
- 自己写的应用,自己做的!
项目中怎样实现高并发的?
- muduo库本身就已经实现了高并发了,将muduo库中的说一下
TCP保证发送数据不丢且不重?
- 协议本身就保证了;
- 不丢:超时重传机制;
- 不重:序号和确认应答机制;
应用保证发送数据不丢且不重?
- 也是类似于TCP的重传机制、序号和确认应答机制;
- 将TCP在传输层协议上的一个机制,在应用层上再来一遍!
面试的时候聊到TCP协议和UDP协议,面试官经常给你一种场景让你设计;
- TCP基于连接的,是流式协议;UDP是无连接的,是用户数据包协议,通信双方不管能不能收到,发送方只管发,接收方尽量收!
- TCP有安全的保障机制,超时重传、乱序重排、滑动窗口、流量控制、拥塞控制!在协议上保证数据可靠性传输!UDP不可靠的!
- TCP的ACK响应也是会占用网络带宽的!(现在带宽很好,光纤入户,可以不考虑! )
- 现在设计服务器类的程序,都是采用TCP协议!
- TCP虽然耗费了一点点的多余的流量,但是换来了非常可靠的传输!UDP不行!
- 服务器一定首先要保证高可用和高可靠!
TCP的三次握手和四次挥手!
2.1、进程池(nginx)/线程池(muduo)/内存池/连接池(数据库)
- 不用池的话开销太大了,临时生成以及销毁代价都大!使用池;
- 需要用资源,从池里面拿,用完了也不释放,再返回池中;
- 进程和线程,在Linux环境下,创建进程线程时,底层用的也是克隆,内核中用哪个的还是fork!
- 项目中碰到小块内存的频繁开辟和释放时,malloc和free的效率是比较低的!使用内存池来提高小块内存的使用效率!
- 连接池也是一样,不断的TCP三次握手和四次挥手,效率降低,资源消耗大!
2.2、线程池的线程为什么要动态扩容?
不进行动态扩容的话,线程池就是死的,数量固定好不好?客观的看!
不进行动态扩容:
- muduo库的线程池没有动态扩容!采用Reactor模型 + epoll + 线程池,线程池中线程数量:IO数量 + 工作线程数量 和CPU的核数相同,这样才能最大利用CPU进行并行的处理!
进行动态扩容:
- 所有的池都是有一个初始的数量的!
- 如果所有的线程都被占用,此时由耗时的任务,需要扩容产生新的线程!
- 线程的上下文切换开销很大;所以要线程池;业务并发量无法预估的情况下;可以保证系统资源消耗小,性能得到保证;
- 当在一定时间内,有部分线程一直处于空闲状态的话,就将多余的线程释放掉!
线程开销:
- 32位linux下,4G虚拟内存, 3G用户, 每个进程能pthread_create 能创建380左右个线程 ,3G / 8M = 380+(8M为线程栈的大小)
- 线程的创建开销和进程的创建开销一样,都是很大的;
- 线程太多,线程本身占用的内存比较大,业务可以使用的内存就少了;
- 使用查看线程栈的大小,1000-2000线程的话,内存会被占一半了!
- 线程的上下文切换;
- 造成服务器锯齿状负载;(1000-2000线程在阻塞等待时,当有事件到达时,那一瞬间,系统的cpu和内存消耗是非常大的,很容易造成服务器崩溃!所有我们不允许创建很多线程!)
- 聊天服务器采用的是Reactor模型,一个IO线程复杂监听新连接,剩下的线程属于工作线程!一个线程在epoll上监听了很多个客户端的套接字,接收到一个用户的请求之后,就会开始做业务!做业务的过程中会涉及到数据库的curd;
- 如果一个工作线程阻塞了,导致注册在这个工作线程上的epoll上的所有的套接字后面发生的读写事件,当前线程无法处理了!
gdb调试,大秦坑王,有专门的调试文章!
解决方法:
- 使用连接池,当经过一定时间(设置为1-2s)无响应,会返回错误!这样和数据库交互的时候就不会产生阻塞!
- 工作线程不阻塞,工作这个线程上的其他clientfd就可以处理事件了!
从swap函数引入,形参想要改变实参的值,使用值传递不行;
C语言中使用指针,C++中使用引用!
在windows直接断点调试,发现指针和引用的汇编代码是完全一样的!
在Linux下,直接-g,使用gdb调试,disassemble查看反汇编代码!
在查看汇编指令的时候,定义一个引用,实际上也是要在栈上,
开辟4个字节的内存,将它要引用的变量内存的地址放到4个字节中;
通过引用变量来引用它所指向的变量的时候,实际上汇编上也是从之前底层开辟的那4个字节的内存的地址,自动做一个解引用的操作!
当通过指针解引用的时候,实际上是从指针的 4个字节取出指向的内存的地址,去访问所指向的内存;
可以看出在汇编的层面上是100%的相似! 即汇编层面是一样的,在语言层面是不同的!
C++中开发时尽量使用引用:
- 实际上引用起来和指针一样,但是引用必须初始化,所有引用相对指针安全一些;
- 但是指针也不能说不好,C和C++最大的一个特点,通过指针可以在内存上漫游,通过指针的++和–可以在内存上偏移;引用不能偏移,只要使用引用,就会自动偏移了!
- 指针给我们的扩展性较大,没有解引用指针,指针可以p+1,p+2进行偏移!
- 引用没有办法做内存上的偏移,直接进行的解引用了!
挖坑题!
- 传递数组根本不会发生拷贝,传递数组名永远传递的是数组起始地址,传递的是指针;
- 因为只传递了数组的地址,我们在使用的时候,需要传入数组的长度传入!
- 直接传入数组的地址,就像当于指针传递,在函数的内部是可以修改数组的内容;如果不想修改,在数组参数前面加上const!可以读取数组内容,不可以修改数组内容!
- 再延伸,数组=》stl容器!stl容器就是我们的准备好的!
vecto容器和数组的区别:
- 容器不用自己管理内存,不够了可以扩容…
- 当传递一个容器的时候,实参到形参是一个拷贝构造的过程!如果函数中传递一个容器,性能就会大大下降,有拷贝构造的过程;这时应该通过引用进行传递!引用也是可以修改容器的,因此需要通过const常引用!
const修饰的是指针本身还是指针指向;
:
- const修饰p,表示 p不能被赋值,p可以被赋值;
:
- const修饰的是p,表示p不可以被赋值,*p可以被赋值;
仅仅是上面这样简单的回答吗?
怎么发散?
- 从实践的角度进行发散,表示我们用过!
实践角度进行发散:
- 主要用在调用函数时, 实参是通过指针传递给形参,在形参中,我们只想访问实参,不想修改实参!所以形参的指针,在最前面加上const!可以读* p。不可以修改* p;
- ,即和this指针相关,p不可以被修改,不能给this指针赋值,this指针永远指向调用方法的对象的!可以通过this指针可以修改指向对象的值(*p可以赋值),但是不可以修改this的指向(p不可以修改)!
const修饰的额值都是常量,常量都是不能被修改吗?
如果直接a = 20,这是绝对不可以的!
怎么改?
int *p = (int *) &a;
*p = 20;
这样就已经被修改了!
为什么又可以修改了?
- const修饰的变量不能被修改只是在编译阶段,语法级别上;
- 实际上,在汇编上,并不是说const直接将内存的属性给改了,从原来的可读可写变为只读不写,不可能的!
- const只是立足于语法层面,编译阶段,编译器能够检测出对一个const修饰的常量赋值了!但是经过编译以后,就到了汇编指令上,这个时候就没有const了, const只是在语法层面不能修改!
- C语言中,const是常变量;C++语言中,const是常量!
从实践的角度去回答:
- 定义一个const常对象去调用普通方法时,我发现是调用不了的!
为什么调用不了?
- C++用一个对象调用一个方法,汇编之后,实际的调用过程还是一个C函数的调用!只是 将调用对象的地址当成实参传递给函数;所有在成员方法编译的时候,都会加一个形参this来接收对象的地址,但是this是一个指向非const对象的const指针,不能接收const对象;
所有当常对象调用成员方法的时候,成员方法的后面需要加const,影响了this指针,让this指变成指向const对象的const指针,这样形参this就可以接收对象了!常对象就可以调用常成员方法了!
限制:
- 在常方法中,只能访问对象的成员变量,不可以改变!
- mutable修饰的变量是依旧可以修改的!
this指针
- 每个对象拥有一个this指针,通过this指针来访问自己的地址。
- this指针并不是对象的一部分,this指针所占的内存大小是不会反应在sizeof操作符上的。
- this指针只能在成员函数中使用,全局函数、静态函数都不能使用this指针;
- 在普通成员函数中,this是一个指向非const对象的const指针(如类类型为Student,那么this就是Student *const类型的指针);
- 在const成员函数中,this指针是一个指向const对象的const指针(如类类型为Student,那么this就是const Student * const类型的指针)
- this指针在成员函数开始执行前构造,在成员函数执行结束后销毁。
const对象与const成员函数
- 在成员函数参数列表后面加上const修饰,表示函数内this指针是一个指向常量对象的常量指针,不能修改成员变量。
- 一个const成员函数如果以引用的形式返回*this,那么它的返回类型是常量引用;
- 对于const对象或者const对象的引用和指针,对象内的成员变量是不能修改的,因此只能调用const成员函数,不会修改成员变量;
- 对于非const对象,既可以调用const成员函数,也可以调用非const成员函数。
常量成员函数特点
- 常量成员函数不能改变调用它的对象的内容。
- 常量对象,以及常量对象的引用或指针都只能调用常量成员函数。
- 不能在一个常量对象上调用普通成员函数。
- 如果常量成员函数在类外部定义,也必须在参数列表后面加上const关键字。
- const成员函数中可以修改添加了mutable关键字的成员变量的值。
- 静态成员函数与对象无关,因此不能被声明为const。
定义必须初始化的成员变量,必须放在初始化列表中(const变量、引用成员变量)
-
从vector的底层说起,它是一个动态扩容的数组;
-
容器依赖空间配置器allocator来进行管理内存,空间配置器中有4个方法非常重要!
-
vector只负责对象的构造、析构,内存管理都是靠allocator空间配置器来管理的。
-
在Linux下是 2倍扩容的!vs2017,2019下是1.5倍扩容;(从实践的角度,编写代码,不断的插入元素,每次打印vector的容量以及实际大小,探究扩容机制。capacity返回vector的容量,sizeof是返回vector中实际元素的大小)
-
小结下,这就是vector如何管理内存的;
-
话锋一转!还可以介绍一下vector的其他重要的方法;(vector还有很多其他有用的方法,比如说…)
-
所有C++ STL容器在进行管理底层对象内存的时候,不可能直接用new和delete,为什么不能用,上课的时候说过!
-
所有容器都必须依赖allocator空间配置器,里面有4个方法:construct、destory、allocate、deallocate;
-
construct、destory负责对象的构造和析构;allocate、deallocate负责内存的开辟和释放,就是在vector管理内存的时候,一定要将对象new的对象的构造和内存开辟分隔开,将delete的对象的析构和内存的释放分隔开!(原因:初始化一个vector的时候,如果用new的话,不仅仅会开辟一个内存,还会构造一些没有用的对象,不符合我们的逻辑,所有底层的内存开辟是靠allocate来的,allocate和deallocate都是用的默认C库的malloc和free)
-
如果应用层使用vector比较频繁的话,小块内存使用比较频繁,可以使用内存池来替换allocate和deallocate中使用的malloc和free;
-
再扩展,在C++11以后提供的带右值引用的拷贝构造和赋值重载!很大程度的提高了vector拷贝和构造的性能!
- 在自己实践的过程中,有没有接触过迭代器的失效?
- 当我们用vector解决问题的时候,有可能实际到遍历vector,即读取时,迭代器不会发生失效;
- 在进行insert和 erase的时候,迭代器就会失效,insert和erase中接收的都是迭代器,不仅仅是vector,相关的容器的方法,一般接收的都是迭代器,我后面再说!
- 迭代器的底层实际上是对一个指针的封装,拿vector来说,vector的迭代器的底层是一个指针,vector的迭代器的指针指向的是vector底层的一个数组;
- 迭代器通过该运算符重载,以用相同的方式 遍历不同的容器! 因为不同的容器的遍历方式都被封装在迭代器的++,- - 运算符中;
- insert可能回到值vector容器的扩容,内存位置都变了,迭代器的底层是一个指针,指向一个地址,扩容之后,迭代器肯定就失效了!
- 怎么处理?insert和erase返回值会返回新位置的迭代器,需要将迭代器的循环变量更新下;
- erase删除元素之后,后面所有的元素都有向前挪,erase从首元素到当前删除元素之间的迭代器还是有效的!但是当前位置到末尾元素迭代器都会发生变化,需要更新!
现在开始扩展:
- 迭代器对于容器时非常重要的!
- 每一种容器都有字节的迭代器,因为每一种容器的底层数据结构都是不一样的;
- 但是实际上写代码的时候,发现迭代器遍历容器的代码都是一模一样的,这是怎么做到的?因为迭代器提供++、- -运算符重载函数,虽然不同容器,不同数据结构元素的迭代方式不一样,但是不同数据结构的不同迭代方式都被封装在迭代器++、- -运算符重载函数中,所以用迭代器操纵不同容器,代码是一模一样的!
- 不仅容器方法接收的增加删除用的迭代器,泛型算法用的也是迭代器!
- 意思就是vector里面装的是大对象,vector的拷贝构造会造成很多大对象的拷贝构造;
- 赋值会先临时构造右边的对象,根据右边的资源,给左边的对象开辟内存,再给它的内存上构造很多对象,再将右边临时对象析构,将内存析构掉!效率是很低的!
- 视情况采用std::move调用vector的右值引用拷贝构造和赋值重载(右值引用又称为移动语义,不会按照右边的尺寸给左边开辟内存,构造对象,而是直接将右边的资源全部拿过来!)
- vector除了拷贝构造和赋值,其他方法也都提供了带右值引用的方法!
将关联容器 + 底层数据结构 + 常用的方法融合到一块!
map/multimap的底层都是红黑树,说完这个可以说数据结构的的哈希表和红黑树!
延伸到哈希表,可以说哈希冲突,解决方案!
说到红黑树,可以将BST和AVL都说一下,这些都属于二叉搜索树,只是各自有各自的特点!
如果只说 map的key是可以重复的,multimap的key是不可以重复读的是不行的!
回答问题尽量从实践的角度出发!
思路:
- 什么时候会用到map,做什么的时候会用到map;从实践的角度切到map;
- 然后再切到map/multimap的区别上!
- 实践 + 理论!
map和multimap都称为映射表,主要处理数据有关联关系的键值对的场景! 比如说用一个学生的学号对应一个学生成绩,通过快速查找学号来得到学生的数据!
map和multimap主要的区别是key值是否能重复!value是可以重复的!
接着介绍底层的数据结构红黑树 !所以map增删查的时间复杂度都是O(log2n),效率非常快的!
红黑树一般运用在对于key有排序性要求的场景下!
再延伸,就说,实际上大部分场景对key的有序性是没有要求的,我一般在看一些开源库的时候,muduo网络库的时候,都是用的是unordered_map 和 unordered_set!点到为止!
再延伸,Linux的虚拟内存管理用的就是红黑树,效率是非常高的!早期的Linux的内存管理用的是AVL,最后Linux3.6版本之后又改成了红黑树!因为红黑树相对于AVL树,它的优势是…
这样我们就延伸到了AVL树,是一个平衡树,它是一个基于内存的!
接着说,还有基于IO操作的平衡树,B树和B+树…这是数据库索引常用的,它的好处是…
问题的核心详细说出来,相关联的内容点到为止,等待面试官提问!
顺序容器vector、list、deque
- 尤其是vector和list,映射与基础数据的数组和链表;
- 说到容器,将其迭代器、泛型算法都说说!
真正工作时,使用裸指针的情况是非常少的!
说起它的时候,还是要从实践的角度说起,要他有什么用?
- 目的: 自动释放资源(对象资源,文件资源),就是担心有些资源用完了没有被释放,就会产生资源泄漏!
- 智能指针利用栈上对象出作用域自动析构特点,将资源的释放代码写到智能指针的析构函数中!
面试时这样介绍:
- 在C语言中,动态分配堆内存用的是malloc+free;在C++中用的是new+delete;
- 但是手动开辟,可能会 free和delete忘记写了,或者其他文件资源忘记关闭了!
- 为了自动解决这些问题,使用智能指针,利用的栈上对象出作用域自动析构特点;
智能指针从大的方向上分有两类,不带引用计数的和带引用计数的!
- 不带引用计数:C++标准库之前提供的auto_ptr,介绍一下;auto_ptr问题:会自动交出管理对象的所有权!所以auto_ptr在拷贝构造和赋值之后,原来的auto_ptr就不能再使用了,这是坑,他没有任何防范措施,理论上是可以使用的,但是一旦使用就会出错!auto_ptr不能用在容器中,容器的拷贝构造会涉及到每一个元素的拷贝构造和赋值,最后还是涉及到auto_ptr的拷贝构造和赋值!
- 不带引用计数的还有unique_ptr,我们推荐的,unique_ptr去掉了左值的拷贝构造和赋值重载,公布出了右值的拷贝构造和赋值重载,好处是…
- 带引用计数:如果想要多个智能指针管理一个资源,使用引用计数,shared_ptr,底层使用CAS操作来保证引用计数加减的原子操作,可以直接使用在多线程环境下!
经典问题:有了shared_ptr为什么还要weak_ptr,强弱智能指针?
- 从实践出发,如果只使用shared_ptr会出现循环引用问题,介绍自己实践的例子,怎么解决?
还介绍了shared_ptr和weak_ptr在解决多线程访问共享C++对象的线程安全的问题(课上讲的)
还有enable_shared_from_this和shared_from_this获取当前对象的一个智能指针;
- shared_from_this:在当前对象的成员函数中如何获取当前对象的一个shared_ptr指针;
- 不能通过当前对象的this指针直接穿件一个shared_ptr,会导致两个shared_ptr都认为只有自己持有了这个资源,最后释放资源的时候,会释放2次,出现问题!(大秦坑王blog)
继续延伸:make_shared
作用:为shared_ptr填坑;
shared_ptr的操作分为2步:
- 构造shared_ptr会给它传入一个new的内存,shared_ptr底层还会new一个控制资源的控制块,也相当于一个内存块,上面记录了资源的地址 和资源的引用计数;
- 现在假设管理的外部资源new成功了,底层的控制块new失败了,怎么办?存shared_ptr本身就没有创建起来,最后资源就不会帮我们自动释放,智能指针本身的创建有问题;
- C++11以后给我们提供了make_shared,获取一个指向资源的shared_ptr,非常安全的!
C++14以后提供了make_unique,补不带引用计数的unique_ptr的坑的!
面试参考的问题:
- 1、大数据求topK的问题;
- 2、查重的问题;(真没有思路,哈希表!增删查的时间复杂度都是O(1))
哈希表的缺点:
- 空间换时间,占用内存大!
- 常见的链式哈希表,发生哈希冲突的元素放在一个桶中,一个节点表示一个元素,链表串起来,一个节点包含 该节点元素 + 下一个节点的地址!假设存储的是int类型,,总共80万个子节;
查重优化的话:
1、位图法;
- 一个位记录一个数字,1个字节8个位,1个字节可以记录8个数字;
- 位图法非常节省内存,只能判断数字是否出现过;
- 是用数组来记录的,必须先知道,10万个整数最大的位是多少;需要根据最大的数组来定义位图数组的大小!
2、布隆过滤器;
- 存在一定误判;
- 布隆过滤器说 数字不在,那么数字一定不在;布隆过滤器说数字在,数字不一定在;
- 我们需要提前说明,这个问题允不允许一定数量的误判!
- 布隆过滤器常用于redis的缓存中,比如在缓存中,命中key的话,允许一定的误判,提高身体缓存的效率!
1、哈希表;
- 一个数组元素先构建哈希表,另外一个数组的元素在这个哈希表中查;
- 问题:题目中的有序数组没有用到,不是最优的!
- 时间复杂度: O(m)xO(1)
2、二分查找;
- 遍历第一个数组的每一个元素,在第二个数组中进行二分查找;
- 时间复杂度: O(mlogn)
- 问题:还是没有将两个数组都有序全部用上,只用了一个数组是有序的!
3、双指针;
- 时间复杂度: O(m) + O(n) = O(m + n)
总结对比:
- 两个数组的个数相近(使用双指针); 比如都是10000个,二分查找时间O(10000*13)= O(130000),双指针O(20000);
- 两个数组的个数相差很大(使用二分法); 一个10个,一个20000个;双指针O(10 + 20000),二分查找O(14*10);