格蓝若 C++软件开发 一面 (被拷打)
1. 简单做个自我介绍,说说你目前在哪里,为什么选择C++方向?
面试官您好,我是XXX,目前在XX大学读XX专业,XX人。我主要的技术方向是C++后端开发,选择C++是因为它性能强大,在高性能系统、游戏引擎、底层开发等领域应用广泛。我对底层原理比较感兴趣,喜欢研究内存管理、多线程、网络编程这些技术。做过几个项目,包括高性能服务器、分布式存储系统等,积累了一些实战经验。平时也会看开源项目的代码,学习优秀的设计思想。
2. 详细介绍一下你简历中的某个项目,包括背景、架构、你的职责和遇到的挑战。
我做的项目是一个分布式缓存系统,类似Redis的功能。项目背景是为了解决业务系统中频繁访问数据库导致的性能瓶颈,需要一个高性能的缓存层。
技术架构上,采用C++实现核心引擎,使用epoll实现高并发网络IO,支持多种数据结构(string、list、hash、set)。采用主从复制保证高可用,使用一致性哈希实现分布式。数据持久化支持RDB快照和AOF日志两种方式。
我主要负责网络模块和数据结构的实现。网络模块使用Reactor模式,主线程accept连接,工作线程池处理请求。为了提高性能,使用了零拷贝技术,减少数据拷贝次数。数据结构方面,实现了跳表、哈希表等,针对不同场景优化。
遇到的最大挑战是内存管理。一开始使用标准的new/delete,发现频繁分配释放导致内存碎片严重,性能下降。后来实现了内存池,预分配大块内存,按需分配小块,大幅提升了性能。还实现了LRU淘汰策略,当内存不足时自动淘汰最少使用的数据。
3. 你提到项目的性能瓶颈,具体是CPU瓶颈还是IO瓶颈?如何定位和优化的?
项目初期主要是CPU瓶颈。通过perf工具分析发现,CPU主要消耗在几个地方:一是字符串拷贝,每次请求都会拷贝多次数据;二是内存分配,频繁的new/delete占用了大量CPU;三是锁竞争,多线程访问共享数据时锁等待严重。
针对这些问题做了优化。字符串拷贝方面,使用了string_view避免不必要的拷贝,对于大数据使用移动语义而不是拷贝。内存分配方面,实现了内存池和对象池,预分配内存,复用对象。锁竞争方面,使用了分段锁,将数据分成多个分片,每个分片独立加锁,大幅降低了锁冲突。
后期随着并发量增加,出现了磁盘IO瓶颈。持久化时大量写磁盘,影响了主线程性能。优化方案是使用异步IO,将持久化操作放到后台线程,主线程不阻塞。还使用了批量写入,将多次写操作合并成一次,减少系统调用次数。使用mmap内存映射文件,减少用户态和内核态的数据拷贝。
经过这些优化,QPS从最初的5万提升到了20万,延迟从10ms降到了2ms以内。
4. 你提到使用了异步IO实现日志和持久化的解耦,具体是怎么实现的?
我使用了双缓冲机制实现解耦。具体来说,维护两个缓冲区A和B,业务线程将数据写入当前缓冲区A,后台IO线程负责将另一个缓冲区B的数据刷到磁盘。当A写满或者定时器触发时,交换A和B的指针,这样业务线程继续写新的A,IO线程刷新旧的B。
这种设计的好处是,业务线程写内存非常快,不会被磁盘IO阻塞。交换指针只需要一个原子操作,开销很小。IO线程可以批量写入,提高效率。即使IO线程暂时跟不上,数据也在内存缓冲区中,不会丢失。
具体实现上,使用了条件变量进行线程同步。业务线程写满缓冲区后,通知IO线程开始刷盘。IO线程刷盘完成后,通知业务线程可以继续写入。为了防止内存无限增长,设置了缓冲区大小上限,当两个缓冲区都满时,业务线程会阻塞等待。
还做了一些优化,比如使用线程本地缓冲区,每个业务线程有自己的小缓冲区,减少锁竞争。使用无锁队列传递数据,进一步提高并发性能。
5. 说说你熟悉的C++11/14/17特性,重点介绍几个你在项目中用到的。
我比较熟悉的特性包括智能指针、右值引用、Lambda表达式、并发库等。
智能指针方面,项目中大量使用shared_ptr管理共享资源,比如连接对象、缓存数据等。使用unique_ptr管理独占资源,比如文件句柄、网络socket。通过智能指针,基本消除了内存泄漏问题。还使用了weak_ptr解决循环引用,比如在观察者模式中,被观察者持有观察者的weak_ptr,避免相互持有导致的内存泄漏。
右值引用和移动语义在性能优化中起了很大作用。在容器操作时,使用std::move避免拷贝,特别是在返回大对象时。实现了移动构造函数和移动赋值运算符,让对象可以高效转移所有权。在函数参数传递时,使用完美转发std::forward保持参数的值类别。
Lambda表达式让代码更简洁。在使用STL算法时,用lambda作为谓词函数。在创建线程时,用lambda封装任务逻辑。在实现回调机制时,用lambda作为回调函数,避免定义额外的函数对象。
并发库方面,使用std::thread创建工作线程,使用std::mutex和std::lock_guard保护共享数据,使用std::condition_variable实现线程间同步,使用std::atomic实现无锁编程。这些标准库让多线程编程更安全更方便。
6. C++中不同类型的数据分别存储在内存的哪些区域?各有什么特点?
C++程序的内存分为几个区域。
代码区存储程序的机器指令,这个区域是只读的,防止程序被意外修改。多个进程可以共享同一份代码,节省内存。
全局数据区分为两部分,已初始化的全局变量和静态变量存在数据段,未初始化的存在BSS段。这些变量在程序启动时分配,程序结束时释放,生命周期是整个程序运行期间。
堆区用于动态内存分配,通过new或malloc申请。堆从低地址向高地址增长,大小理论上只受系统内存限制。堆内存需要程序员手动管理,忘记释放会导致内存泄漏,过早释放会导致悬空指针。堆的分配和释放比较慢,而且容易产生内存碎片。
栈区存储局部变量、函数参数、返回地址等。栈从高地址向低地址增长,大小有限,Linux默认是8MB。栈内存由编译器自动管理,函数调用时分配,返回时释放,非常高效。但栈空间有限,递归过深或者局部变量太大会导致栈溢出。
常量区存储字符串常量和const修饰的全局变量,这个区域是只读的,试图修改会导致段错误。
在实际编程中,要根据数据的生命周期和大小选择合适的存储位置。临时的小对象用栈,长期的或大对象用堆,全局共享的用全局变量。
7. 详细说说Linux的文件系统,inode的作用是什么?
Linux文件系统的核心是inode机制。inode是索引节点,存储了文件的元数据,但不包含文件名。
每个inode包含的信息有:文件大小、文件类型(普通文件、目录、链接等)、权限位、所有者和所属组、时间戳(创建时间、修改时间、访问时间)、链接计数(有多少个文件名指向这个inode)、数据块指针(指向实际存储文件内容的磁盘块)。
文件系统的结构是这样的:超级块存储文件系统的整体信息,比如块大小、inode数量等。inode表是所有inode的集合,每个inode有一个唯一的编号。数据块区域存储实际的文件内容。目录也是一种特殊的文件,它的内容是文件名到inode号的映射表。
当我们访问一个文件时,比如/home/user/file.txt,系统首先找到根目录的inode,读取根目录的数据块,找到home对应的inode号。然后读取home目录的inode和数据块,找到user对应的inode号。最后读取user目录,找到file.txt对应的inode号。通过这个inode,就能找到文件的数据块,读取文件内容。
硬链接和软链接的区别在于,硬链接是多个文件名指向同一个inode,删除一个文件名不影响其他文件名,只有当链接计数为0时才真正删除文件。软链接是创建一个新的inode,内容是目标文件的路径,类似Windows的快捷方式,删除原文件会导致软链接失效。
这种设计的好处是,文件名和文件内容分离,可以有多个文件名指向同一个文件。移动文件只需要修改目录项,不需要移动数据。可以通过inode号快速定位文件。
8. TCP和UDP的区别是什么?分别适用于什么场景?能举几个具体的例子吗?
TCP和UDP是传输层的两个核心协议,特点完全不同。
TCP是面向连接的协议。通信前需要三次握手建立连接,通信后需要四次挥手断开连接。TCP提供可靠传输,通过序号、确认、重传机制保证数据不丢失、不重复、按序到达。TCP有流量控制,通过滑动窗口防止发送方发送过快。TCP有拥塞控制,根据网络状况动态调整发送速率。TCP是面向字节流的,没有消息边界,需要应用层自己处理粘包拆包。TCP的头部至少20字节,加上连接管理,开销比较大。
UDP是无连接的协议。不需要建立连接,直接发送数据。UDP不保证可靠性,数据可能丢失、重复、乱序,也不会重传。UDP没有流量控制和拥塞控制,发送方可以以任意速率发送。UDP是面向数据报的,有明确的消息边界。UDP头部只有8字节,开销很小。UDP支持一对一、一对多、多对多通信,可以广播和组播。
具体应用场景:
TCP适合对可靠性要求高的场景。比如HTTP/HTTPS协议,网页内容必须完整准确传输。文件传输FTP,文件不能有任何错误。邮件传输SMTP,邮件内容不能丢失。远程登录SSH,命令和输出必须准确。数据库连接,保证事务的一致性。
UDP适合对实时性要求高、可以容忍少量丢包的场景。比如视频直播,偶尔丢几帧不影响观看,但延迟必须低。语音通话,丢包会有杂音但可以接受,延迟高会影响交流。在线游戏,需要实时同步玩家位置,偶尔丢包可以通过插值补偿。DNS查询,单次请求响应,丢了重发就行,不需要建立连接的开销。物联网设备,资源受限,需要轻量级协议。
在我的项目中,缓存系统的客户端和服务器通信使用TCP,保证数据准确。但监控系统上报指标使用UDP,因为偶尔丢失一个数据点不影响整体趋势,而且UDP开销小,适合高频上报。
9. 哈希表的冲突解决策略有哪些?如何减少哈希冲突?
哈希表冲突是指不同的key经过哈希函数计算后得到相同的索引。解决冲突主要有两大类方法。
开放寻址法是当发生冲突时,按某种规则寻找下一个空位。线性探测是最简单的,冲突时顺序查找下一个位置,直到找到空位。优点是实现简单,缺存局部性好。缺点是容易形成聚集,连续的位置都被占用,查找效率下降。二次探测是按平方序列探测,比如冲突时探测+1、+4、+9的位置,可以减少聚集。双重哈希是用第二个哈希函数计算探测步长,进一步减少聚集。
链地址法是每个哈希桶维护一个链表,冲突的元素加入链表。C++的unordered_map就是用这种方法。优点是实现简单,不会因为冲突导致其他位置被占用。缺点是需要额外的指针空间,链表过长时查找效率低。可以优化为当链表长度超过阈值时转换为红黑树,Java的HashMap就是这样做的。
减少哈希冲突的方法有很多。首先是选择好的哈希函数,要求分布均匀、计算快速、雪崩效应好(输入微小变化导致输出大幅变化)。常用的有除留余数法、乘法哈希、MurmurHash等。
其次是合理设置负载因子。负载因子是已存储元素数量除以哈希表容量,负载因子越高,冲突概率越大。一般设置为0.75左右,当超过这个值时进行扩容。扩容时创建一个更大的哈希表,将所有元素重新哈希插入,这个过程叫rehash。
还可以使用质数作为哈希表容量。质数可以让哈希值分布更均匀,减少冲突。在扩容时,选择下一个质数作为新容量。
针对特定数据设计哈希函数也很重要。如果知道数据的特征,比如都是连续的整数,或者字符串有特定模式,可以设计更好的哈希函数,让冲突最小化。
在我的项目中,实现缓存的哈希表时,使用了链地址法,选择了MurmurHash作为哈希函数,设置负载因子为0.75,容量选择质数。这样在百万级数据量下,冲突率控制在很低的水平,查询性能很好。
10. 如何在Linux中让程序在后台运行?如何管理后台进程的生命周期?
让程序在后台运行有几种方法,各有特点。
最简单的是在命令后加&符号,比如./program &。这样程序在后台运行,终端可以继续输入其他命令。但这种方式有个问题,当终端关闭时,程序会收到SIGHUP信号而终止。
使用nohup命令可以解决这个问题,nohup ./program &。nohup会忽略SIGHUP信号,即使终端关闭,程序也继续运行。程序的输出会重定向到nohup.out文件。这种方式适合运行一次性的长时间任务。
使用screen或tmux可以创建虚拟终端。先启动screen,在screen中运行程序,然后按Ctrl+A+D分离会话。这样即使断开SSH连接,程序仍在screen中运行。需要时可以用screen -r重新连接。这种方式的好处是可以随时查看程序输出,适合需要交互的程序。
更规范的方式是使用systemd创建系统服务。编写一个service文件,定义程序的启动命令、工作目录、环境变量等。然后用systemctl start启动服务,systemctl enable设置开机自启。systemd会管理进程的生命周期,崩溃时自动重启,还可以设置资源限制。这种方式适合生产环境的守护进程。
管理后台进程的方法也有很多。jobs命令可以查看当前终端启动的后台任务。fg %n可以将后台任务调到前台,bg %n可以将暂停的任务放到后台继续运行。
ps命令可以查看所有进程,ps aux | grep program可以找到特定程序的进程。top或htop
剩余60%内容,订阅专栏后可继续查看/也可单篇购买
本专栏系统梳理C++技术面试核心考点,涵盖语言基础、面向对象、内存管理、STL容器、模板编程及经典算法。从引用指针、虚函数表、智能指针等底层原理,到继承多态、运算符重载等OOP特性从const、static、inline等关键字辨析,到动态规划、KMP算法、并查集等手写实现。每个知识点以面试答题形式呈现,注重原理阐述而非冗长代码,帮助你快速构建完整知识体系,从容应对面试官提问,顺利拿下offer。

