百度C++实习一二三面面经
公众号:阿Q技术站
来源:https://www.nowcoder.com/feed/main/detail/a15fa17c081546638ff2299e6c3faac6
一面
1、介绍项目
2、epoll底层原理?
epoll
是 Linux 下的一种 I/O 事件通知机制,用于高效处理大量的 I/O 事件。它是 Linux 内核提供的一种多路复用机制,可以同时监视多个文件描述符,当其中任何一个文件描述符就绪(有数据可读或可写)时,epoll
就会通知应用程序进行相应的处理。
epoll
的底层原理主要包括以下几个方面:
- 数据结构:
epoll
使用了三个主要的数据结构来实现高效的事件通知,分别是红黑树(RB-tree)、就绪链表和事件表。其中,红黑树用于存储所有被监视的文件描述符,就绪链表用于存储当前就绪的文件描述符,事件表用于存储每个文件描述符的事件类型和回调函数等信息。 - 注册事件:应用程序可以通过
epoll_ctl
系统调用向epoll
中注册需要监视的文件描述符和相应的事件类型(如可读、可写等)。这些注册的信息被存储在红黑树中。 - 等待事件:当应用程序调用
epoll_wait
等待事件时,epoll
内核模块会检查红黑树中的所有文件描述符,如果有文件描述符的事件已经就绪,则将其添加到就绪链表中,并通知应用程序。 - 处理事件:应用程序从就绪链表中取出就绪的文件描述符,并进行相应的 I/O 操作。在处理完事件后,应用程序可以再次调用
epoll_wait
等待下一次事件通知。
3、LRU的实现原理?
LRU(Least Recently Used,最近最少使用)是一种常见的缓存淘汰策略,用于在缓存空间不足时决定哪些数据项应该被淘汰,以便为新的数据项腾出空间。LRU 的实现原理如下:
- 数据结构:LRU 通常使用一个哈希表和一个双向链表来实现。哈希表用于快速查找缓存中是否存在某个数据项,双向链表用于记录数据项的访问顺序,最近访问的数据项在链表的头部,最久未被访问的数据项在链表的尾部。
- 访问数据项:当一个数据项被访问时(读取或写入),如果它已经存在于缓存中,则将其从原来的位置移动到链表的头部,表示它是最近被访问过的。如果数据项不存在于缓存中,则将其添加到链表的头部,并在哈希表中记录它的位置。
- 淘汰数据项:当缓存空间不足时,需要淘汰链表尾部的数据项,因为它们是最久未被访问的。通过删除链表尾部的节点,并在哈希表中删除相应的记录,来释放空间给新的数据项。
- 时间复杂度:LRU 的关键操作是移动节点和删除节点,这两个操作在双向链表中的时间复杂度都是 O(1),因此整体的时间复杂度也是 O(1)。
4、redis为什么快?
- 内存存储:Redis 主要将数据存储在内存中,内存的读写速度远高于磁盘存储。这使得 Redis 能够快速地响应读写请求,适用于对读写性能要求较高的场景。
- 单线程模型:Redis 使用单线程模型来处理客户端请求,避免了多线程间的锁竞争和上下文切换开销。虽然单线程模型在处理高并发请求时可能存在瓶颈,但由于 Redis 大部分操作都是内存操作,因此单线程能够充分发挥 CPU 的性能。
- 非阻塞 I/O:Redis 使用非阻塞 I/O 处理网络请求,可以在单线程中处理多个客户端的请求,避免了线程切换和同步等开销。
- 数据结构多样性:Redis 提供了丰富的数据结构,如字符串、列表、哈希表、集合、有序集合等,每种数据结构都有专门的命令和优化策略,可以满足不同场景的需求,提高了数据的存储和访问效率。
- 持久化机制:Redis 提供了多种持久化机制,如 RDB 和 AOF,可以根据需求选择合适的持久化方式。持久化可以将数据写入磁盘,保证数据的可靠性,但默认情况下 Redis 只在内存中操作,避免了磁盘 I/O 的性能开销。
- 高效的网络通信协议:Redis 使用自定义的 RESP(REdis Serialization Protocol)协议进行客户端和服务器之间的通信,该协议简单高效,减少了通信开销。
5、TIME_WAIT?
TIME_WAIT 是 TCP 协议的一种状态,表示连接已经被正常关闭,但是仍然在等待一段时间(称为 TIME_WAIT 时间)才会彻底关闭。TIME_WAIT 状态通常出现在主动关闭连接的一方,在发送了 FIN 报文之后,等待对方确认的阶段。
TIME_WAIT 状态的存在是为了确保已经关闭的连接的最后一个数据包能够被对方正确接收和处理。在这个状态下,连接的一方会保持一段时间的等待,在这段时间内不会再接收到来自对方的数据,但是可以处理对方在关闭连接时可能发送的重传数据包,以确保数据的可靠传输。
TIME_WAIT 状态的持续时间通常由操作系统内核参数决定,一般情况下是 2 倍的最大报文段生存时间(Maximum Segment Lifetime,MSL)。MSL 是一个固定的时间值,通常为 2 分钟,它表示一个 TCP 报文在网络中最长的生存时间。
TIME_WAIT 状态的存在有一些作用和影响:
- 确保连接的最后数据包能够被正确处理,避免了数据包的丢失或者混乱。
- 防止旧的重复数据包被误认为是新的连接。
- 可以让已经关闭的连接在一段时间内不能被重新使用,避免了连接复用时出现的问题。
6、服务端大量连接处于TIME_WAIT状态会影响服务器性能,如何处理?
- 调整内核参数:可以通过修改操作系统的内核参数来调整 TIME_WAIT 状态的处理。例如,可以调整
net.ipv4.tcp_tw_reuse
参数为 1,表示允许将 TIME_WAIT 状态的连接用于新的连接。这样可以减少 TIME_WAIT 状态连接占用的资源。 - 调整连接超时时间:可以调整操作系统的 TCP 连接超时时间,减少 TIME_WAIT 状态的持续时间。可以通过修改
net.ipv4.tcp_fin_timeout
参数来调整,默认值为 60 秒,可以根据实际情况进行调整。 - 增加服务器资源:如果服务器资源允许,可以通过增加服务器的内存和处理器等资源来缓解 TIME_WAIT 状态带来的影响。这样服务器就能够更好地处理大量的 TIME_WAIT 状态连接。
- 优化应用程序:如果可能的话,可以优化应用程序的设计和实现,减少连接的建立和关闭次数,从而减少 TIME_WAIT 状态连接的数量。
- 使用连接池:对于需要频繁连接数据库或者其他服务的应用程序,可以考虑使用连接池技术,减少连接的建立和关闭次数,从而减少 TIME_WAIT 状态连接的数量。
7、STL哪些容器是线程安全的,认识哪些STL容器,map、set底层原理?
在标准模板库(STL)中,通常情况下容器都不是线程安全的,这意味着在多线程环境下对容器的并发操作可能会导致不可预测的结果。然而,C++11 引入了一些线程安全的容器,它们位于 std::
命名空间下的 mutex
头文件中。这些线程安全的容器包括:
- std::mutex:互斥锁,用于在多线程环境下保护临界区,确保同一时间只有一个线程可以访问临界区的资源。
- std::lock_guard:互斥锁的封装,用于在作用域结束时自动释放互斥锁,防止忘记释放锁而导致的死锁。
- std::unique_lock:更加灵活的互斥锁,可以手动地锁定和释放,也可以在构造函数中锁定,在析构函数中释放。
- std::condition_variable:条件变量,用于在多线程环境下等待某个条件的发生或者通知其他线程条件的发生。
- std::atomic:原子操作类型,提供了一些基本的原子操作,如原子加载、存储、交换等,用于在多线程环境下保证操作的原子性。
序列式容器
序列式容器按照元素的线性顺序存储和访问元素,包括以下几种:
- std::vector:动态数组,可以动态增长和缩减大小。支持随机访问,但在中间插入或删除元素的代价较高。
- std::deque:双端队列,类似于动态数组,但在两端都可以进行高效的插入和删除操作。
- std::list:双向链表,支持高效的插入和删除操作,但不支持随机访问。
- std::forward_list:单向链表,与
std::list
类似,但只支持单向遍历和插入操作。 - std::array:固定大小的数组,大小在编译时确定,不支持动态增长。
关联式容器
关联式容器使用树状结构(通常是红黑树)来存储元素,并且提供了基于键的高效查找,包括以下几种:
- std::set:集合,存储不重复的元素,按照元素的值进行排序。
- std::map:映射,存储键值对,按照键的值进行排序。键是唯一的,用于快速查找值。
- std::multiset:多重集合,类似于
std::set
,但允许存储重复的元素。 - std::multimap:多重映射,类似于
std::map
,但允许存储重复的键值对。
其他容器
除了上述的序列式容器和关联式容器,STL 还提供了一些其他的容器,如:
- std::stack:栈,后进先出(LIFO)的容器适配器,基于
std::deque
或std::list
实现。 - std::queue:队列,先进先出(FIFO)的容器适配器,基于
std::deque
或std::list
实现。 - std::priority_queue:优先队列,按照优先级排序的队列,基于
std::vector
或std::deque
实现。
map
和 set
的底层原理:
-
map:
map
是 C++ STL 中的关联容器,底层通常基于红黑树实现。红黑树是一种自平衡的二叉搜索树,具有以下特点:- 每个节点要么是红色,要么是黑色。
- 根节点是黑色的。
- 每个叶子节点(NIL 节点,空节点)是黑色的。
- 不能有两个相邻的红色节点。
- 从任一节点到其每个叶子的所有路径都包含相同数目的黑色节点。
这些特点保证了红黑树的平衡性,使得在最坏情况下的插入、删除、查找操作的时间复杂度为 O(log n)。
-
set:
set
是 C++ STL 中的关联容器,底层通常也基于红黑树实现。它与map
的区别在于,set
中的元素是唯一的,而map
中的元素是键值对,并且按照键的大小进行排序。
8、GDB多线程调试?
给个示例步骤:
- 编译程序时开启调试信息:在编译程序时,需要使用
-g
参数来开启调试信息,这样 GDB 才能够正确地识别源代码和进行调试。 - 启动 GDB:在命令行中输入
gdb
命令启动 GDB 调试器,然后使用file
命令加载要调试的可执行文件。 - 设置断点:使用
break
命令在要调试的代码行设置断点。如果你知道要调试的函数名,也可以直接使用break function_name
来设置断点。 - 启动程序:使用
run
命令启动程序,GDB 会在程序运行到断点处停止。 - 查看线程信息:使用
info threads
命令查看当前程序中的线程信息,可以查看线程的 ID、状态等信息。 - 切换线程:使用
thread <thread_id>
命令切换到指定的线程,然后可以使用backtrace
命令查看该线程的调用栈。 - 观察变量:在断点处停止后,可以使用
print
命令观察变量的值,也可以使用watch
命令设置监视点,当变量的值发生变化时自动停止程序。 - 继续执行:使用
continue
命令继续执行程序,直到下一个断点或者程序结束。 - 退出 GDB:在调试结束后,使用
quit
命令退出 GDB 调试器。
9、遇到过Coredump吗,怎么排查原因的?
Coredump 是指当一个程序发生严重错误(如段错误、内存访问错误等)时,操作系统会将程序的内存转储(Dump)到一个文件中,这个文件就是 coredump 文件。这个文件包含了程序崩溃时的内存状态,可以通过分析 coredump 文件来定位程序崩溃的原因。
给一个参考的排查 coredump 的步骤:
- 获取 coredump 文件:当程序发生崩溃时,操作系统会在当前工作目录或者指定的目录下生成一个 coredump 文件。首先需要获取这个文件,可以通过配置系统使其生成 coredump 文件,或者在程序崩溃时手动获取。
- 分析 coredump 文件:可以使用 GDB 或者其他调试工具来分析 coredump 文件。首先需要加载 coredump 文件到调试器中,然后可以使用
bt
命令查看调用栈,定位程序崩溃的位置。通过查看调用栈可以找到导致崩溃的函数调用链,从而定位问题所在。 - 查看内存状态:在分析 coredump 文件时,可以查看程序崩溃时的内存状态,包括堆栈、寄存器状态、内存中的变量值等。这些信息可以帮助分析程序崩溃的原因。
- 重现问题:如果可能的话,可以尝试重现程序崩溃的问题。在开发环境中调试程序,通过输入相同的输入数据或者执行相同的操作,尝试重现程序崩溃的场景,从而更好地分析问题。
- 排查代码逻辑:根据分析的结果,可以进一步排查代码逻辑,查找可能的 bug。可以通过代码审查、单元测试等方式来找出程序中的问题。
- 修复问题:一旦找到了程序崩溃的原因,就可以对程序进行修复。修复的方式可能包括修改代码逻辑、增加错误处理代码、优化内存管理等。
10、进程、线程、协程?
- 进程(Process):
- 进程是操作系统分配资源的基本单位,每个进程拥有独立的地址空间、内存、文件描述符等资源。
- 进程之间的通信需要通过进程间通信(IPC)机制,如管道、信号量、消息队列等。
- 进程之间的切换开销较大,因为需要切换地址空间、内存映射、文件描述符等资源。
- 线程(Thread):
- 线程是进程中的执行单元,同一进程内的线程共享相同的地址空间和资源。
- 线程之间的通信比进程间通信更加高效,可以直接访问共享的内存空间。
- 线程之间的切换开销较小,因为线程共享相同的地址空间和资源,切换时不需要切换这些资源。
- 协程(Coroutine):
- 协程是一种用户态的轻量级线程,可以在同一个线程内实现并发执行。
- 协程通过 yield 和 resume 操作来实现在不同代码段之间的切换,可以在一个线程内实现多个任务的并发执行。
- 协程通常比线程更加轻量级,切换开销更小,适合于高并发、高性能的场景。
11、手撕线程池?
#include <iostream>
#include <vector>
#include <queue>
#include <thread>
#include <functional>
#include <mutex>
#include <condition_variable>
class ThreadPool {
public:
// 构造函数,创建指定数量的工作线程
ThreadPool(size_t threads) : stop(false) {
for (size_t i = 0; i < threads; ++i)
workers.emplace_back(
// 创建工作线程的 lambda 函数
[this] {
for (;;) {
std::function<void()> task;
{
// 使用互斥锁保护任务队列
std::unique_lock<std::mutex> lock(this->queue_mutex);
// 等待条件满足或线程池停止
this->condition.wait(lock,
[this] { return this->stop || !this->tasks.empty(); });
// 如果线程池停止且任务队列为空,线程退出
if (this->stop && this->tasks.empty())
return;
// 从任务队列取出任务
task = std::move(this->tasks.front());
this->tasks.pop();
}
// 执行任务
task();
}
}
);
}
// 添加任务到任务队列
template<class F, class... Args>
void enqueue(F&& f, Args&&... args) {
using return_type = typename std::result_of<F(Args...)>::type;
auto task = std::make_shared<std::packaged_task<return_type()>>(
std::bind(std::forward<F>(f), std::forward<Args>(args)...)
);
{
// 使用互斥锁保护任务队列
std::unique_lock<std::mutex> lock(queue_mutex);
// 如果线程池已停止,抛出异常
if (stop)
throw std::runtime_error("enqueue on stopped ThreadPool");
// 将任务添加到队列中
tasks.emplace([task]() { (*task)(); });
}
// 通知一个等待的线程开始执行任务
condition.notify_one();
}
// 析构函数,停止所有工作线程并等待它们执行完毕
~ThreadPool() {
{
// 使用互斥锁保护停止标志
std::unique_lock<std::mutex> lock(queue_mutex);
stop = true;
}
// 通知所有等待的线程停止执行
condition.notify_all();
// 等待所有工作线程执行完毕
for (std::thread &worker : workers)
worker.join();
}
private:
std::vector<std::thread> workers; // 工作线程列表
std::queue<std::function<void()>> tasks; // 任务队列
std::mutex queue_mutex; // 互斥锁,保护任务队列
std::condition_variable condition; // 条件变量,用于线程间通信
bool stop; // 线程池停止标志
};
int main() {
ThreadPool pool(4); // 创建一个拥有4个工作线程的线程池
// 向线程池中添加8个任务
for (int i = 0; i < 8; ++i) {
pool.enqueue([i] {
// 输出任务编号
std::cout << "Task " << i << " executed" << std::endl;
});
}
return 0;
}
- 构造函数:在构造函数中创建了指定数量的线程,并将它们添加到线程池中。
- enqueue 函数:enqueue 函数用于向线程池中添加任务。它接受一个可调用对象和其参数,并将其封装为一个任务添加到任务队列中。
- 任务执行:每个工作线程会不断地从任务队列中取出任务并执行,直到线程池被销毁。
- 停止线程池:在析构函数中,首先设置 stop 标志为 true,然后通知所有工作线程停止执行,并等待所有工作线程执行完毕后退出。
12、了解RPC、zookeeper吗?
RPC(Remote Procedure Call)
RPC 是一种远程过程调用的机制,允许一个程序调用另一个地址空间(通常是另一台机器上)的过程或方法,就像调用本地的过程一样。RPC 通常用于构建分布式系统,它隐藏了底层通信细节,使得远程调用看起来像是本地调用。
RPC 的基本原理是客户端调用远程过程时,本地的 RPC 客户端代理会将调用封装成网络消息,并发送给远程的 RPC 服务器。远程的 RPC 服务器接收到消息后,执行对应的过程,并将结果返回给客户端。整个过程对于客户端来说就像调用本地函数一样,对于开发者来说隐藏了网络通信的复杂性。
常见的 RPC 框架包括 gRPC、Apache Thrift、Dubbo 等,它们提供了丰富的功能,如多语言支持、序列化协议、负载均衡等,可以帮助开发者构建可靠的分布式系统。
ZooKeeper
ZooKeeper 是一个开源的分布式协调服务,主要用于解决分布式系统中的一致性、配置管理、命名服务、分布式锁等问题。它提供了简单的文件系统接口,可以用于存储和管理分布式系统的配置信息,同时也提供了高性能和可靠的分布式锁服务,用于协调分布式系统中的各个节点。
ZooKeeper 的核心概念包括以下几点:
- 节点(Node):ZooKeeper 中的基本数据单元,类似于文件系统中的文件或目录。
- ZNode:ZooKeeper 中的节点,每个节点都有一个路径标识,可以包含数据和子节点。
- Watcher:Watcher 是一种事件通知机制,可以用于监视节点的状态变化,并在节点状态发生变化时通知客户端。
- 临时节点(Ephemeral Node):临时节点是一种特殊的节点,它在创建它的客户端会话结束后自动删除。
ZooKeeper 提供了简单而强大的 API,可以用于创建、删除、读取和更新节点,并且支持事务操作。它还具有高可用性和高性能的特点,适用于构建大规模分布式系统中的协调服务。
13、C++11的function和bind?
std::function
std::function
是一个通用的函数封装器,可以用来包装任何可以调用的目标,例如普通函数、函数指针、成员函数指针、lambda 表达式等。它可以将函数的调用和实现分离,使得函数对象可以像普通对象一样传递、存储和调用。
使用 std::function
需要指定函数的签名,即函数的参数类型和返回类型。以下是 std::function
的基本用法:
#include <functional>
#include <iostream>
// 普通函数
int add(int a, int b) {
return a + b;
}
int main() {
// 使用 std::function 封装普通函数
std::function<int(int, int)> func = add;
// 调用封装的函数
std::cout << func(1, 2) << std::endl; // 输出 3
return 0;
}
例子中,std::function<int(int, int)>
表示一个参数为两个 int 类型,返回类型为 int 的函数对象。func
封装了 add
函数,并可以像普通函数一样进行调用。
std::bind
std::bind
是一个用于参数绑定的函数模板,可以将函数的部分参数绑定到指定的值,生成一个新的可调用对象。它可以用于延迟求值、改变函数的参数顺序等场景。
以下是 std::bind
的基本用法:
#include <functional>
#include <iostream>
// 函数模板
template <typename T>
void print(const T& t) {
std::cout << t << std::endl;
}
int main() {
// 使用 std::bind 绑定函数参数
auto func = std::bind(print<int>, 123);
// 调用绑定后的函数
func(); // 输出 123
return 0;
}
例子中,std::bind(print<int>, 123)
绑定了 print
函数的第一个参数为 123
,生成了一个新的可调用对象 func
,调用 func()
就相当于调用了 print<int>(123)
。
二面(50min)
1、介绍项目
2、传值、传指针、传引用的区别?
- 传值(Pass by Value):
- 传值是将实参的值复制一份传递给形参,形参在函数内部是实参的一个拷贝,函数对形参的修改不会影响实参本身。
- 传值适用于传递基本数据类型或小型对象,对于大型对象或需要频繁复制的对象,传值会带来较大的性能开销。
- 传指针(Pass by Pointer):
- 传指针是将实参的地址传递给形参,形参在函数内部可以通过指针间接访问实参,从而对实参进行修改。
- 传指针可以避免在函数调用时复制大型对象,但需要注意指针可能为空(null)或者指向无效的内存区域,需要进行有效性检查。
- 传引用(Pass by Reference):
- 传引用是将实参的引用传递给形参,形参在函数内部和实参引用同一块内存,函数对形参的修改会直接影响实参本身。
- 传引用可以避免在函数调用时复制对象,并且更加直观,但需要注意引用不能为空,并且不能修改绑定的对象。
对于选择使用哪种传递方式,需要根据具体的场景和需求来决定:
- 如果函数需要修改实参的值,且实参是大型对象或者需要频繁复制的对象,可以考虑使用传引用或传指针。
- 如果函数只需要读取实参的值,或者实参是小型对象或者基本数据类型,可以考虑使用传值。
- 如果函数需要返回多个值,可以使用传指针或传引用,通过形参修改实参的值。
3、拷贝构造函数可以传值吗?
拷贝构造函数是一种特殊的构造函数,用于在创建对象时,通过复制另一个同类型对象的值来初始化新对象。拷贝构造函数通常有一个参数,表示要复制的对象的引用。在 C++ 中,拷贝构造函数的参数可以是值传递或引用传递。
值传递的拷贝构造函数
class MyClass {
public:
int data;
// 值传递的拷贝构造函数
MyClass(int val) : data(val) {}
// 拷贝构造函数
MyClass(const MyClass obj) : data(obj.data) {}
};
例子中,MyClass
类有一个拷贝构造函数,它的参数 obj
是按值传递的。这种方式虽然可以正常工作,但是会带来一些性能上的开销,因为在调用拷贝构造函数时会将整个对象进行复制。因此,通常情况下更推荐使用引用传递的方式来定义拷贝构造函数。
引用传递的拷贝构造函数
class MyClass {
public:
int data;
// 引用传递的拷贝构造函数
MyClass(int val) : data(val) {}
// 拷贝构造函数
MyClass(const MyClass& obj) : data(obj.data) {}
};
例子中,MyClass
类的拷贝构造函数的参数 obj
是通过引用传递的。这样做的好处是在调用拷贝构造函数时不会产生额外的拷贝开销,因为只是传递了对象的引用,而不是整个对象的副本。
4、了解的stl容器,哪些是线程安全的?
-
std::mutex
和std::lock_guard
:C++11 引入的
std::mutex
类和std::lock_guard
类可以用于实现线程安全的访问控制。通过在对容器进行操作前使用std::lock_guard
对互斥锁进行加锁,可以保证同一时间只有一个线程可以访问容器。 -
std::shared_mutex
(C++14 引入):std::shared_mutex
是 C++14 新增的共享互斥量,支持多个线程同时对数据进行读取,但在写入时会进行排他锁定。这使得在读多写少的场景下,可以提高并发性能。 -
std::atomic
:std::atomic
类模板提供了对特定类型的原子操作,可以用于实现线程安全的计数器等数据结构。 -
std::queue
和std::priority_queue
的线程安全封装:尽管
std::queue
和std::priority_queue
本身不是线程安全的,但可以通过在多线程环境下使用互斥锁或使用std::deque
等线程安全的底层容器来实现线程安全的队列。 -
std::shared_ptr
和std::weak_ptr
:std::shared_ptr
和std::weak_ptr
提供了线程安全的引用计数机制,可以用于管理动态分配的对象的生命周期。
5、vector的resize和reserver?
resize
resize
方法用于改变 std::vector
的大小,可以增加或减少元素的数量。当增加元素时,新元素将使用默认值进行初始化;当减少元素时,多余的元素将被删除。
#include <vector>
#include <iostream>
int main() {
std::vector<int> vec = {1, 2, 3};
// 增加元素,使用默认值初始化
vec.resize(5); // 现在 vec 包含 {1, 2, 3, 0, 0}
// 减少元素
vec.resize(2); // 现在 vec 包含 {1, 2}
return 0;
}
reserve
reserve
方法用于预留容器的存储空间,但不改变容器的大小。这样做可以避免由于容器重新分配内存而导致的多次分配和拷贝,从而提高了 push_back
操作的性能。
#include <vector>
#include <iostream>
int main() {
std::vector<int> vec;
// 预留存储空间
vec.reserve(100); // 预留至少能容纳 100 个元素的存储空间
// 添加元素
for (int i = 0; i < 100; ++i) {
vec.push_back(i);
}
return 0;
}
6、map和unordered_map的区别?
std::map
std::map
是一个基于红黑树实现的关联容器,它提供了有序的键值对存储,并且对键进行了排序。在 std::map
中,每个键值对都是唯一的,如果插入已存在的键,则会替换原有的值。
std::map
的特点包括:
- 键值对有序存储,根据键的比较结果进行排序。
- 插入、删除和查找操作的平均时间复杂度为 O(log n),其中 n 是容器中元素的数量。
- 适用于需要有序存储和快速查找的场景,例如需要按键进行范围查询或遍历的情况。
std::unordered_map
std::unordered_map
是一个基于哈希表实现的关联容器,它提供了无序的键值对存储,并且不对键进行排序。在 std::unordered_map
中,每个键值对都是唯一的,如果插入已存在的键,则会替换原有的值。
std::unordered_map
的特点包括:
- 键值对无序存储,根据键的哈希值进行存储和查找。
- 插入、删除和查找操作的平均时间复杂度为 O(1),在最坏情况下为 O(n),其中 n 是容器中元素的数量。
- 适用于需要快速插入、删除和查找的场景,例如大量数据的快速查找或存储。
选择使用场景
- 如果需要有序存储和按键范围查询,或者对元素的顺序有要求,可以选择使用
std::map
。 - 如果对元素的顺序没有要求,但需要快速的插入、删除和查找操作,可以选择使用
std::unordered_map
。
7、select、poll、epoll?
select
、poll
和 epoll
都是用于多路复用 I/O 的系统调用,它们允许一个进程监视多个文件描述符,一旦其中一个文件描述符就绪,就通知进程进行相应的 I/O 操作。
select
select
是 Unix 系统提供的最早的多路复用 I/O 函数之一,它使用一个位图(fd_set)来表示一组文件描述符,并通过参数设置和返回值来实现 I/O 多路复用。select
的主要特点包括:
- 参数限制:在一些早期的系统上,
select
的文件描述符数量存在限制,通常是 1024 个。 - 每次调用都需要将所有的文件描述符集合传递给内核,内核需要遍历整个集合来检查就绪状态,这在文件描述符数量较多时可能会带来性能问题。
poll
poll
是对 select
的改进,它使用一个数组(pollfd
结构体数组)来表示一组文件描述符,并通过参数设置和返回值来实现 I/O 多路复用。poll
的主要特点包括:
- 没有
select
的文件描述符数量限制,可以处理更多的文件描述符。 - 每次调用时不需要传递所有的文件描述符集合,而是传递一个
pollfd
数组,内核不需要遍历整个集合来检查就绪状态。
epoll
epoll
是 Linux 系统特有的多路复用 I/O 机制,相比于 select
和 poll
,epoll
具有更高的性能和更好的扩展性。epoll
的主要特点包括:
- 支持边缘触发和水平触发两种模式,边缘触发模式在同一就绪事件上只触发一次,需要用户自己保证数据完全读取或写入;水平触发模式则在就绪事件未处理完毕时仍然会触发。
epoll
使用事件就绪通知机制,只需要在需要监视的文件描述符上注册一次,不需要每次都重新传递所有的文件描述符。- 支持一个进程打开的文件描述符数量几乎没有限制,可以处理成千上万个文件描述符。
8、了解rpc、kafka吗?
同上
9、 c++内存分配方式?
- 栈内存分配
栈内存由编译器自动管理,用于存储函数的局部变量、函数参数和函数调用的上下文信息。栈内存的分配和释放由系统自动管理,当一个函数被调用时,它的局部变量被分配到栈上,当函数返回时,这些变量的内存空间会被自动释放。栈内存的分配速度很快,但是大小有限,通常在几 MB 到几十 MB 之间。
- 堆内存分配
堆内存由程序员手动管理,用于动态分配内存。通过 new
关键字在堆上分配内存,通过 delete
关键字释放堆上的内存。堆内存的分配速度相对较慢,因为需要进行内存的管理和释放,而且容易产生内存泄漏和内存碎片问题。堆内存的大小通常受到系统限制,可以动态增长。
- 全局/静态存储区
全局/静态存储区用于存储全局变量和静态变量,在程序启动时分配,在程序结束时才释放。全局/静态存储区的分配和释放由系统自动管理,它的大小也是有限的。
- 内存池
内存池是一种预先分配一定大小内存块的方式,用于减少频繁地进行内存分配和释放。内存池可以提高内存分配的效率和减少内存碎片,适用于需要频繁地分配和释放小块内存的场景。
- 自定义内存分配器
在 C++ 中,可以通过自定义内存分配器来实现对内存分配和释放的控制。自定义内存分配器可以用于实现内存池、特定内存分配策略等,从而满足特定的性能和内存管理需求。
- 共享内存
共享内存是一种特殊的内存分配方式,它允许多个进程共享同一块物理内存。在多进程通信中,共享内存可以用于高效地进行数据交换,避免了复制数据的开销。
- 内存映射
内存映射是一种将文件或其他设备映射到进程的地址空间的方式。通过内存映射,可以将文件内容直接映射到进程的地址空间中,从而实现文件的高效读写操作。
10、c++main函数之前程序会做什么?
- 加载程序到内存: 操作系统会将程序的可执行文件加载到内存中,并分配相应的资源。
- 初始化静态存储区: 全局变量和静态变量存储在程序的静态存储区,在程序开始执行之前,这些变量会被初始化为默认值(如果有的话)或者指定的初值。
- 设置程序的入口点: 操作系统会将程序的执行控制权交给程序的入口点,即
main
函数。main
函数是程序的入口,程序从这里开始执行。 - 初始化运行时环境: 运行时环境的初始化包括初始化 C++ 运行时库和一些全局状态,例如堆栈指针的设置等。
- 执行静态构造函数: 如果程序中定义了全局对象或静态对象,这些对象的构造函数会在
main
函数执行之前被调用。 - 执行操作系统特定的初始化工作: 操作系统可能会做一些与平台相关的初始化工作,例如设置信号处理器、初始化文件系统等。
11、new/delete和malloc/free的区别?
- 使用的语言:
new
和delete
是 C++ 中的关键字,用于动态分配和释放内存。malloc
和free
是 C 语言中的函数,用于动态分配和释放内存。
- 返回类型:
new
返回的是分配对象的指针类型,而且不需要指定分配的大小,因为它能够自动计算对象的大小。malloc
返回的是void*
类型的指针,需要通过类型转换来适应不同的数据类型,并且需要手动指定分配的大小。
- 初始化和清理:
new
分配的内存会调用对象的构造函数进行初始化,并在对象被删除时调用析构函数进行清理。malloc
分配的内存不会自动调用构造函数和析构函数,需要手动管理对象的生命周期。
- 类型安全性:
new
和delete
是类型安全的,它们能够自动计算对象的大小并进行类型检查。malloc
和free
不是类型安全的,需要手动计算分配的大小,并且需要手动进行类型转换。
- 可重载性:
new
和delete
可以被重载,允许用户自定义内存分配和释放的行为。malloc
和free
不能被重载。
- 异常处理:
new
在分配失败时会抛出std::bad_alloc
异常,可以通过nothrow
参数来禁止异常抛出。malloc
在分配失败时返回NULL
,需要手动检查分配是否成功。
12、智能指针?
-
std::shared_ptr
:-
原理:
std::shared_ptr
是基于引用计数的智能指针,用于管理动态分配的对象。它维护一个引用计数,当计数为零时,释放对象的内存。 -
使用场景:适用于多个智能指针需要共享同一块内存的情况。例如,在多个对象之间共享某个资源或数据。
-
std::shared_ptr<int> sharedInt = std::make_shared<int>(42); std::shared_ptr<int> anotherSharedInt = sharedInt; // 共享同一块内存
-
-
std::unique_ptr
:-
原理:
std::unique_ptr
是独占式智能指针,意味着它独占拥有所管理的对象,当其生命周期结束时,对象会被自动销毁。 -
使用场景:适用于不需要多个指针共享同一块内存的情况,即单一所有权。通常用于资源管理,例如动态分配的对象或文件句柄。
-
std::unique_ptr<int> uniqueInt = std::make_unique<int>(42); // uniqueInt 的所有权是唯一的
-
-
std::weak_ptr
:-
原理:
std::weak_ptr
是一种弱引用指针,它不增加引用计数。它通常用于协助std::shared_ptr
,以避免循环引用问题。 -
使用场景:适用于协助解决
std::shared_ptr
的循环引用问题,其中多个shared_ptr
互相引用,导致内存泄漏。 -
std::shared_ptr<int> sharedInt = std::make_shared<int>(42); std::weak_ptr<int> weakInt = sharedInt;
-
-
std::auto_ptr
(已废弃):-
原理:
std::auto_ptr
是C++98标准引入的智能指针,用于独占地管理对象。但由于其存在潜在的问题,已在C++11中被废弃。 -
使用场景:在C++98标准中,可用于独占性地管理动态分配的对象。不推荐在现代C++中使用。
-
std::auto_ptr<int> autoInt(new int(42)); // 已废弃
-
13、判断链表是否有环?
思路:
- 定义两个指针,分别命名为 slow 和 fast,初始时都指向链表的头节点。
- 每次迭代中,慢指针 slow 前进一步,快指针 fast 前进两步。
- 如果链表中存在环,则快指针 fast 最终会追上慢指针 slow,即它们会在某个节点相遇。
- 如果链表不存在环,则快指针 fast 会先到达链表的末尾,此时可以判断链表不包含环。
参考代码:
#include <iostream>
// 链表节点的定义
struct ListNode {
int val;
ListNode* next;
ListNode(int x) : val(x), next(nullptr) {}
};
// 判断链表是否有环的函数
bool hasCycle(ListNode* head) {
if (head == nullptr || head->next == nullptr) {
// 如果链表为空或只有一个节点,肯定没有环
return false;
}
ListNode* slow = head; // 慢指针,每次前进一步
ListNode* fast = head; // 快指针,每次前进两步
while (fast != nullptr && fast->next != nullptr) {
slow = slow->next; // 慢指针前进一步
fast = fast->next->next; // 快指针前进两步
if (slow == fast) {
// 如果快慢指针相遇,则链表中存在环
return true;
}
}
// 快指针到达链表末尾,说明链表中不存在环
return false;
}
int main() {
// 创建一个有环的链表
ListNode* head = new ListNode(1);
head->next = new ListNode(2);
head->next->next = new ListNode(3);
head->next->next->next = head; // 将链表的尾节点指向头节点,形成环
// 判断链表是否有环
bool hasCycleResult = hasCycle(head);
// 输出结果
std::cout << "链表是否有环:" << (hasCycleResult ? "是" : "否") << std::endl;
// 释放链表内存
delete head->next->next;
delete head->next;
delete head;
return 0;
}
14、一道shell题目:查询访问时间过长的ip?
要查询访问时间过长的 IP,可以借助 Linux 系统下的 awk
和 sort
命令来实现。假设我们有一个名为 access.log
的日志文件,其中记录了每次访问的 IP 和访问时间。
我们可以通过以下步骤来查询访问时间过长的 IP:
- 首先使用
awk
命令从日志文件中提取出 IP 和访问时间,并计算访问时间的差值(假设以秒为单位),然后输出到一个临时文件中。假设日志文件的格式为IP 访问时间
,并且访问时间的格式为YYYY-MM-DD HH:MM:SS
,可以使用如下命令:
awk '{split($2, a, /[\[\]:]/); "date -d\""a[2]" "a[3]"\""| getline d; print $1, $2, systime()-mktime(d)}' access.log > temp.log
命令中,split($2, a, /[\[\]:]/)
用于将访问时间按照 [
、]
、:
进行分割,并将结果保存到数组 a
中;"date -d\""a[2]" "a[3]"\""| getline d
用于将访问时间转换为时间戳;systime()-mktime(d)
用于计算当前时间与访问时间的差值。
- 然后使用
sort
命令对临时文件中的记录按照访问时间的差值进行排序,并输出到另一个临时文件中:
sort -k3 -n temp.log > sorted_temp.log
命令中,-k3
表示按照第三列进行排序(即访问时间的差值),-n
表示按照数值进行排序。
- 最后使用
awk
命令输出访问时间超过阈值的 IP:
awk '{if ($3 > 60) print $1}' sorted_temp.log
命令中,if ($3 > 60)
表示筛选出访问时间超过 60 秒的记录,并输出对应的 IP。