Github配置ssh key

新增 SSH 密钥到 GitHub 帐户 - GitHub 文档

第一步
1
2
3
cd ~/.ssh
ls
//看是否存在 id_rsa 和 id_rsa.pub文件,如果存在,说明已经有SSH Key
第二步:生成ssh key

如果不存在ssh key,使用如下命令生成

1
2
ssh-keygen -t rsa -C "xxx@xxx.com"
//执行后一直回车即可
第三步:获取ssh key公钥内容(id_rsa.pub)
1
2
cd ~/.ssh
cat id_rsa.pub

复制该内容

第四步:Github账号上添加公钥

进入Settings设置
添加ssh key,把刚才复制的内容粘贴上去保存即

第五步:验证是否设置成功
1
ssh -T git@github.com

注意之后在clone仓库的时候要使用ssh的url,而不是https!

linux文件系统

Mysql问题

说一说事物隔离级别

SQL标准的事务隔离级别包括:

读未提交,读提交,可重复读,串行化

事务的四大特性有哪些?

原子性,一致性,隔离性,持久性

Linux系统目录

目录2

C++11新特性总结

C++11新特性总结

final关键字

使派生类不可覆盖它所修饰的虚函数

override描述符

如果派生类在虚函数声明时使用了override描述符,那么该函数必须重载其基类中的同名函数

关于左值,右值

C++中所有的值都必然属于左值、右值二者之一。左值是指表达式结束后依然存在的持久化对象,右值是指表达式结束时就不再存在的临时对象。所有的具名变量或者对象都是左值,而右值不具名。很难得到左值和右值的真正定义,但是有一个可以区分左值和右值的便捷方法:看能不能对表达式取地址,如果能,则为左值,否则为右值

右值引用

左值是指可以出现在=左侧者,

右值是指只能出现在=右侧者

临时对象是个右值

右值引用与move语义的关系
  • 关联性: 右值引用是实现move语义的基础
  • 作用: 允许”偷取”临时对象的资源而非复制
  • 应用场景: 容器操作中大量临时对象的处理

移动语义

右值则临时对象

1
c.insert(ite,Vtype(buf));

左值则使用move关键字

1
2
M c1(c);
M c2(std::move(c1));
  • 核心机制
    • 当赋值右侧是右值时,左侧对象可直接”偷取”资源
    • 避免不必要的资源分配和拷贝
  • 实现要点
    • 类需要同时实现拷贝和移动语义版本
    • 移动操作后原对象应处于有效但未定义状态
    • 容器需要支持右值版本的插入操作
  • 注意事项
    • 被移动后的对象不应再使用
    • 移动构造函数应标记为noexcept
    • 临时对象自动被视为右值
  • 典型应用
    • 容器扩容时的元素迁移
    • 返回临时对象的优化
    • 明确不再使用的左值资源转移

应用

  1. 函数返回值优化
  2. STL 容器的高效插入与操作
  3. 动态资源管理(如智能指针)

完美转发

STL的对比

容器类型 底层数据结构 元素存储特点 访问方式 是否支持随机访问 动态大小调整 典型应用场景
vector 动态数组 (连续内存) 按加入顺序连续存储 下标访问 ([]) 动态数组的替代,快速随机访问,大量数据按顺序存取。
deque 分段连续内存 按加入顺序存储(分段实现) 双向访问(头尾操作快) 需要快速在首尾插入与删除,而仍然支持随机访问的场景。
list 双向链表 不连续,每个元素存储指针 双向遍历 频繁插入/删除操作的场景,尤其在需要中间修改的情况下。
forward_list 单向链表 不连续,只有前向指针 单向遍历 内存占用较小的场景,在对链表操作需求简单时用作优化。
array 静态数组 (固定大小) 按加入顺序连续存储 下标访问 ([]) 小容量、固定大小、性能关键的场景,避免动态分配。
set 平衡二叉搜索树(通常是 Red-Black 树) 按键值自动排序 基于 key 查找和遍历 唯一值的集合,不允许重复值,支持快速有序的查找和插入。
multiset 平衡二叉搜索树 按键值自动排序 基于 key 查找和遍历 允许重复值的集合,多次插入同一值场景(按顺序存储)。
map 平衡二叉搜索树 按键值自动排序 基于 key 查找 有序的键值对储存,适合频繁按 key 查找具体值的场景。
multimap 平衡二叉搜索树 按键值自动排序 基于 key 查找 允许同一键值存在多个映射值的场景。
unordered_set 哈希表 无序存储 常数时间 key 查找 唯一值的集合,但无需排序,适合大量 key 快速判断是否存在的场景。
unordered_multiset 哈希表 无序存储 常数时间 key 查找 允许重复值的集合,但无序存储,适合大数据去重分析场景。
unordered_map 哈希表 无序存储 常数时间 key-value 查找 可快速通过 key 查找 value 的场景(无需排序)。
unordered_multimap 哈希表 无序存储 常数时间 key 查找 允许同一个 key 存储多个值(无需排序)的场景。
stack 基于 deque 实现 后进先出 (LIFO) 只访问顶端 (top) 后进先出的场景,比如函数调用栈、括号匹配等。
queue 基于 deque 实现 先进先出 (FIFO) 只访问头部和尾部 先进先出的场景,比如任务排队、消息队列等。
priority_queue 基于堆实现 按优先级排序 访问最大(默认情况)值 按优先级任务调度、动态获取最大/最小值的场景。
bitset 定长的位数组 每个位存储 true/false 位访问 ([]) 高效存储和操作布尔值,适合空间和位运算优化场景。

1. 底层数据结构

  • 动态数组:元素连续存放且可动态扩展的数组。
  • 链表:使用指针将元素链接而成的结构,可分为单向链表与双向链表。
  • 平衡二叉树:自平衡的二叉搜索树,常用的是红黑树(Red-Black Tree)。
  • 哈希表:以哈希函数作为索引,通过哈希冲突解决机制(如拉链法)高效存储数据。
  • :通常是二叉堆,支持动态调整以保证堆顶始终是最大值或最小值。

2. 元素存储特点

  • 连续存储vectorarray 采用连续存储,内存紧凑,随机访问快。
  • 分段连续存储deque 分段实现,以保证在头尾添加数据的高效性。
  • 不连续存储listforward_listset 等采用链表或树结构,插入/删除效率高,但随机访问速度较慢。

3. 访问方式

  • 下标访问 ([])vectorarray 支持随机访问,可迅速获取指定位置的元素。
  • 头尾访问stackqueue 限制为某些特定操作。
  • 优先访问priority_queue 只能访问堆顶元素(最大值/最小值)。
  • 基于 key 访问mapunordered_map 以键值对方式支持查找。

4. 是否支持随机访问

  • 支持随机访问的容器(如 vectorarraydeque 等)提供 O(1) 复杂度的下标访问,操作简单。
  • 不支持随机访问的容器(如 listset 等)需要遍历或依赖搜索结构,访问效率较低。

5. 动态大小调整

  • 动态容器(如 vectordequelist)可以根据元素数目动态分配或收缩内存。
  • 静态容器(如 array)在定义时需指定固定大小。

6. 应用场景

每种容器都有其典型应用场景,选择合适的容器取决于业务需求(如性能、顺序性、查找效率)。

根据需求选择不同容器:

  1. 需要快速随机访问:使用 vectorarray
  2. 需要频繁插入和删除:使用 listdeque(插入和删除表现良好)。
  3. 需要数据自动排序:使用 setmultiset(有序集合)或 mapmultimap(有序键值对)。
  4. 需要快速查找,不关心顺序:优先选择哈希容器,比如 unordered_setunordered_map
  5. 使用栈或队列模型:考虑 stackqueuepriority_queue

其他问题

Mysql问题

说一说事物隔离级别

SQL标准的事务隔离级别包括:

读未提交,读提交,可重复读,串行化

事务的四大特性有哪些?

原子性,一致性,隔离性,持久性

Linux系统目录

目录2

其他项目问题

为什么服务器要以root身份启动,不能以root身份运行

为什么要以 root 启动?

绑定低编号端口(如 80、443)。
配置共享内存、设备文件、调整资源等需要的高权限操作。

为什么不能以 root 运行?

潜在漏洞可能导致系统被完全攻破。
不符合最小权限原则。
服务隔离无法实现,其他服务可能受到影响。
意外操作可能导致灾难性的后果。
如何解决这个问题?

启动时以 root 完成特权操作,然后立刻降级为普通用户运行。
使用 setuid() 和 setgid() 等 Linux 用户管理接口实现特权降级。

关于项目

项目背景: 设计并实现了一个基于 Linux 平台的轻量级 HTTP 服务器,采用多 Reactor 多线程高并发模型,通过 epoll 提供高效的 I/O 复用。结合自动增长缓冲区定时器和异步日志等技术,实现了高性能和稳定运行的目标。

主要工作

内存优化:设计了内存池和 LFU 缓存,减少内存碎片,提升内存使用效率。

高效事件处理:利用 epoll 多路复用机制,高效监听和处理客户端连接及数据传输事件。

高并发模型:基于 Reactor 模型,实现 One Loop per Thread,支持多客户端并发连接。

动态缓冲区:实现自动增长缓冲区,动态调整大小以适配不同请求,优化内存分配。

连接管理:使用小根堆实现高效定时器,管理连接超时时间,防止长期空闲连接浪费资源。

异步日志:设计异步日志模块,基于单例模式和阻塞队列,实现高效日志写入,避免同步写入的性能开销。

关于项目

介绍一下

本项目是一个高性能的WEB服务器,使用C++实现,项目底层采用了多线程多Reactor的网络模型,并且在这基础上增加了内存池,高效的双缓冲异步日志系统,以及LFU的缓存。

服务器的网络模型是主从reactor加线程池的模式,IO处理使用了非阻塞IO和IO多路复用技术,具备处理多个客户端的http请求和ftp请求,以及对外提供轻量级储存的能力。

项目中的工作可以分为两部分,

一部分是服务器网络框架、日志系统、存储引擎等一些基本系统的搭建,

另一部分 是为了提高服务器性能所做的一些优化,比如缓存机制、内存池等一些额外系统的搭建。

最后还对系统中的部分功能进行了功能和压力测试。对于存储引擎的压力测试,

在本地测试下,存储引擎读操作的QPS可以达到36万,写操作的QPS可以达到30万。对于网络框架的测试,使用webbench创建1000个进程对服务器进行60s并发请求,测试结果表明,对于短连接的QPS为1.8万,对于长连接的QPS为5.2万。

项目难点

根据工作分为两部分

一部分是服务器网络框架,日志系统,存储引擎等一些基本系统的搭建,这部分的难点主要就是技术理解和选型,以及将一些开源的框架调整后应用到我的项目中去。

另一部分就是性能优化方面,比如缓存机制,内存池等一些额外系统的搭建。这部分的难点在于找出服务器的性能瓶颈所在,然后结合自己的想法突破瓶颈,提高服务器性能。

遇到的困难,怎么解决

一方面是对技术理解不够深刻,难以选出合适的技术框架,这部分主要是阅读作者的技术文档,找相关的解析文章看

另一部分是编程遇到的困难,由于工程能力不足出现bug,这部分主要是通过日志定位bug,推断bug出现的原因并尝试修复,如果以自己能力无法修复,先问问ai能提供什么思路,或者搜索相关的博客。

内存优化

设计了内存池LFU 缓存

缓存机制

为什么选择LFU

因为最近加入的数据因为起始的频率很低,容易被淘汰,而早期的热点数据会一直占据缓存。

高效事件处理:

epoll 多路复用机制

采用非阻塞I/O模型,执行系统调用就立即返回,不检查事件是否发生,没有立即发生返回-1,errno设置为在处理中。所以要采用I/O通知机制(I/O复用和SIGIO信号)来得知就绪事件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
I/O多路复用技术
I/O 多路复用允许一个线程同时监视多个 I/O 文件描述符(如网络 socket),并在其中一个或多个文件描述符变为"可操作"时返回。应用程序可以据此进行相应的 I/O 操作(如读、写)。

1.1 select
简介
select 是一种最早的 I/O 多路复用接口,几乎所有主流平台都支持。

它允许程序监视多个文件描述符,查询它们是否可读、可写或出现错误。
select 的接口会使用三个位图(readset、writeset 和 exceptset)指定文件描述符的状态。
工作原理
调用 select 时,程序将文件描述符集(一个位图)传递给内核。
内核在超时时间内扫描这些文件描述符并返回那些状态发生变化的描述符(如变为可读或可写)。
用户态程序可根据返回结果进行相应的 I/O 操作。
缺点
支持的文件描述符数量有限(通常受 FD_SETSIZE 限制,默认 1024)。
每次调用时都需要将文件描述符的状态从用户态复制到内核态,这带来一定的性能开销。
内核需要线性遍历所有文件描述符(效率低),尤其在大并发连接时性能较差。
1.2 poll
简介
poll 是 select 的改进版本,克服了文件描述符数量限制的问题。

它使用一个数组结构而不是位图来描述文件描述符及其事件。
工作原理
用户定义一个 pollfd 数组,该数组中每一个元素保存一个文件描述符及其相关事件。
调用 poll 时,内核会遍历这个数组,检查哪些文件描述符有事件发生,并返回结果。
优点
支持任意数量的文件描述符,突破了 select 的 FD_SETSIZE 限制。
缺点
和 select 类似,每次调用都需要将监控的文件描述符数组从用户态复制到内核态,开销较大。
和 select 一样,内核需要线性遍历文件描述符,在高并发场景下效率仍然较低。
1.3 epoll
简介
epoll 是 Linux 平台下提供的高性能 I/O 多路复用接口,它是 select 和 poll 的替代品。

epoll 被设计用于解决 select 和 poll 的性能问题,是一种效率更高的方式处理大量并发连接的技术。
工作原理
epoll 的核心思想是使用事件驱动机制(Event-Driven)替代轮询机制。

创建一个 epoll 实例(epoll_create),用作事件管理器。
使用 epoll_ctl 向内核注册需要监听的具体文件描述符及其事件类型(关注可读、可写或异常事件)。
调用 epoll_wait,等待事件发生。
发生事件的文件描述符被加入到一个内核维护的就绪列表,并从中直接返回。
这避免了不必要的遍历额外文件描述符的开销。
优点
事件驱动模型:文件描述符有变化时通过回调机制加入就绪列表,只需处理活跃文件描述符。
无大小限制:最大受限于系统的内存资源,而非固定限制。
高性能:避免了线性遍历,即使监视十万连接,只需处理少量已就绪的描述符。
缺点
仅支持 Linux 系统,不跨平台。
epoll 的两种触发模式
LT(Level Trigger,水平触发): 默认模式,文件描述符只要处于就绪状态,就会不断返回。
ET(Edge Trigger,边缘触发): 更高效,只在文件描述符状态从未就绪到就绪时触发(适用于非阻塞 I/O)

IO多路复用

LT与ET

LT:水平触发模式,只要内核缓冲区有数据就一直通知,只要socket处于可读状态就一直返回sockfd;是默认的工作模式,支持阻塞IO和非阻塞IO

ET:边沿触发模式,只有状态发生变化才通知并且这个状态只会通知一次,只有当socket由不可写到可写或由不可读到可读,才会返回sockfd:只支持非阻塞IO

为什么用epoll,其他多路复用方式以及区别

高并发模型

基于 Reactor 模型,实现 One Loop per Thread

Reactor模式通常用同步I/O模型实现

Proactor模式通常用异步I/O模型实现

  1. 主线程往epoll内核事件表注册socket读就绪事件
  2. 主线程调用epoll_wait等待socket上有数据可读
  3. 当socket上有数据可读时,epoll_wait通知主线程,主线程将socket可读事件放入请求队列
  4. 工作线程被唤醒,读数据处理请求,然后往epoll内核事件表注测socket写就绪事件
  5. 主线程调用epoll_wait等待socket可写
  6. 当socket可写,epoll_wait通知主线程,主线程将socket可写事件放入请求队列
  7. 睡眠在请求队列的工作线程被唤醒,往socket上写入服务器处理客户请求的结果

动态缓冲区

实现自动增长缓冲区

1. 核心数据结构

1
2
3
4
5
6
class Buffer {
private:
std::vector<char> buffer_; // 主缓冲区(使用vector自动管理内存)
size_t readerIndex_; // 读指针(数据起始位置)
size_t writerIndex_; // 写指针(数据结束位置)
};

采用vector作为底层容器,自动处理内存分配/释放

读写指针分离设计,支持零拷贝操作

零拷贝是指计算机执行IO操作时,CPU不需要将数据从一个存储区域复制到另一个存储区域,从而可以减少上下文切换以及CPU的拷贝时间。它是一种I/O操作优化技术。

2. 自动增长机制

(1) 扩容触发条件

writableBytes() < 待写入数据量时自动扩容

通过vector的resize实现:

1
2
3
4
5
6
7
void append(const char* data, size_t len) {
if (writableBytes() < len) {
makeSpace(len); // 扩容操作
}
std::copy(data, data+len, beginWrite());
writerIndex_ += len;
}

(2) 智能扩容策略

1
2
3
4
5
6
7
8
9
10
11
12
void makeSpace(size_t len) {
if (writableBytes() + prependableBytes() < len) {
// 需要真正扩容:vector.resize(writerIndex_ + len)
buffer_.resize(writerIndex_ + len);
} else {
// 通过移动数据复用空间
size_t readable = readableBytes();
std::copy(begin()+readerIndex_, begin()+writerIndex_, begin());
readerIndex_ = 0;
writerIndex_ = readable;
}
}

3. 高性能IO优化

(1) 双缓冲区读操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
ssize_t readFd(int fd, int* saveErrno) {
char extrabuf[65536]; // 64KB栈缓冲区
iovec vec[2];

vec[0].iov_base = begin() + writerIndex_;
vec[0].iov_len = writableBytes();
vec[1].iov_base = extrabuf;
vec[1].iov_len = sizeof(extrabuf);

// 根据剩余空间决定使用1个还是2个缓冲区
const int iovcnt = (writableBytes() < sizeof(extrabuf)) ? 2 : 1;
ssize_t n = readv(fd, vec, iovcnt);

// 处理读入的数据...
}

使用readv系统调用实现分散读

优先使用主缓冲区空间,不足时使用栈缓冲区过渡

避免频繁扩容带来的性能损耗

(2) 写操作优化

1
2
3
ssize_t writeFd(int fd, int* saveErrno) {
return ::write(fd, peek(), readableBytes());
}

直接使用write系统调用

peek()返回有效数据起始指针,避免内存拷贝

4. 关键特性总结

  1. 智能扩容:按需自动增长,兼顾内存使用效率

  2. 零拷贝设计:读写指针分离,减少内存拷贝

  3. 双缓冲策略:栈空间+主缓冲区组合优化IO性能

  4. 线程安全:单次IO操作原子性保证

  5. 内存高效:自动回收已读区域空间

典型工作流程:

  1. 读取数据时优先使用主缓冲区空间

  2. 空间不足时暂存到栈缓冲区

  3. 触发自动扩容后合并数据

  4. 写入数据时直接操作有效数据区域

连接管理

使用小根堆实现高效定时器,管理连接超时时间

1
2
using Entry = std::pair<Timestamp, Timer*>; // 以时间戳作为键值获取定时器
using TimerList = std::set<Entry>; // 底层使用红黑树管理,自动按照时间戳进行排序
1
2
// 定时器管理红黑树插入此新定时器
timers_.insert(Entry(when, timer));

异步日志

设计异步日志模块,基于单例模式和阻塞队列

日志系统是多生产者,单消费者的任务场景

多生产者负责把日志写入缓冲区,单消费者负责把缓冲区数据写入文件

img

前端往后端写,后端往硬盘写

双缓冲技术 ,写满就交换,相当于将多条日志拼接成一个大buffer传送到后端然后写入文件,减少了线程开销

字节面试准备

字节面试准备

1. C++ 基础

(1) 面向对象的三个特性

C++ 是一种支持面向对象编程的语言,其面向对象编程的三个核心特性是:封装(Encapsulation)、继承(Inheritance)、多态(Polymorphism)。

  • 封装
    封装是将数据(成员变量)和操作这些数据的代码(成员函数)绑定在一起,形成一个整体(类)。
    优点:

    • 限制对内部成员的访问,保护数据隐私(通过 publicprotectedprivate 访问权限控制关键字)。
    • 提高代码的可维护性和复用性。
  • 继承
    继承是一种用于实现代码复用和建立层次化关系的机制。子类可以继承父类的属性和行为。
    优点:

    • 重用父类代码。
    • 可以通过父类指针或引用操作多态对象。
  • 多态
    多态是允许通过基类引用或指针访问派生类对象,并调用派生类的重写方法。基于动态绑定(Run-time Polymorphism)的多态主要通过虚函数实现。
    优点:

    • 提高代码的扩展性和灵活性,可以使用统一的接口处理不同类型的对象。

(2) 多态和虚函数的底层实现

  • 多态的实现

    • 多态的核心是通过 虚函数表(Virtual Table)和虚函数表指针实现的。
    • 编译器会在类中生成一个指向虚函数表的指针,称为 vptr
    • 虚函数表是一个数组,存储了该类的所有虚函数的函数指针。
    • 在运行时,通过 vptr 指向的虚函数表,根据实际的动态类型调用对应的方法。
  • 虚函数的底层实现

    • 类中有虚函数时,类会生成一个虚函数表(存储虚函数指针)。
    • 每个对象的内存布局中会加入一个指向虚函数表的指针(vptr)。
    • 通过 vptr 和虚函数表在运行时找到并调用具体的函数。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Base {
public:
virtual void display() { std::cout << "Base" << std::endl; }
};

class Derived : public Base {
public:
void display() override { std::cout << "Derived" << std::endl; }
};

int main() {
Base* obj = new Derived();
obj->display(); // 执行 Derived::display()
}

区别场景:带虚函数与不带虚函数

  • 如果类中没有虚函数,那么对象内存只存储成员变量。
  • 带虚函数的类,内存中会存储额外的虚函数表指针。
  • 因此,带虚函数的对象的内存占用会比没有虚函数的对象多。

(3) 多继承的特殊情况(菱形继承问题)

场景:如何区分多继承中调用的同名成员和方法?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <iostream>
class A {
public:
int x;
void func() { std::cout << "A::func()" << std::endl; }
};

class B : public A {
public:
void func() { std::cout << "B::func()" << std::endl; }
};

class C : public A {
public:
void func() { std::cout << "C::func()" << std::endl; }
};

class D : public B, public C {};

int main() {
D obj;
// 调用 B::func 或 C::func?
obj.B::func(); // 通过类名加作用域区分
obj.C::func();
return 0;
}

菱形继承问题的解决方式:

  • 普通继承会导致 A 的成员在 BC 中各有一份(两份副本)。
  • 使用 虚继承 可以解决该问题,确保 A 的成员在多继承的子类中只有一份。
1
2
3
4
class A { public: int x; };
class B : virtual public A {};
class C : virtual public A {};
class D : public B, public C {};

D 类中只有一份 A::x


3. 内存

(1) malloc 和 new 的区别

  1. malloc

    • 只分配内存,不调用构造函数。
    • 返回 void* 需要强制类型转换。
    • 需要 free 手动释放。
  2. new

    • 分配内存并自动调用构造函数初始化对象。
    • 返回目标类型的指针,无需强转。
    • 使用 delete 释放内存并调用析构函数。

(2) 只分配内存或只调用构造函数

  1. 只分配内存(不调用构造函数):

    • 可使用 operator new
      1
      void* ptr = ::operator new(sizeof(MyClass)); // 只分配内存
  2. 只调用构造函数(对象已分配内存):

    • 可通过 placement new
      1
      MyClass* obj = new(ptr) MyClass(args); // 在已有内存 `ptr` 上构造对象

operator new:用来分配原始内存,不涉及对象的构造。
operator delete:用来释放原始内存,不涉及对象的析构。
普通的 new 调用等价于:

1
2
MyClass* obj = static_cast<MyClass*>(::operator new(sizeof(MyClass))); // 分配内存
new (obj) MyClass(); // 构造对象

普通的 delete 调用等价于:

1
2
obj->~MyClass();             // 调用析构函数
::operator delete(obj); // 释放内存

注意:new不能重载,只有operator new才能重载

(3) sizeof 结构体的对齐规则

C++ 中结构体内存对齐主要受到以下因素影响:

  1. 每个成员变量的对齐方式由编译器决定(通常与成员类型的大小相关)。
  2. 结构体的总大小必须是最大对齐成员的倍数。

示例:

1
2
3
4
5
6
7
8
9
10
struct S1 {
char A; // 1 字节
char B; // 1 字节
int C; // 4 字节
};
struct S2 {
char A; // 1 字节
int C; // 4 字节
char B; // 1 字节
};
  • 对于 S1,内存布局为:A + B + padding + C(总大小 8)
  • 对于 S2,内存布局为:A + padding + C + B + padding(总大小 12)

4. STL 和智能指针

(1) 智能指针概览

C++ 标准库提供了三种智能指针:

  1. std::unique_ptr: 独占所有权,不可复制。
  2. std::shared_ptr: 共享所有权,使用引用计数。
  3. std::weak_ptr: 使用弱引用,依赖 shared_ptr,避免循环引用。

(2) std::shared_ptr 的实际应用

  • 场景:共享资源的生命周期管理(如线程池中的任务对象)。
  • 示例:
    1
    2
    3
    4
    std::shared_ptr<int> p = std::make_shared<int>(10);
    {
    std::shared_ptr<int> q = p; // 引用计数+1
    } // q 离开作用域,引用计数-1

使用智能指针(如 std::shared_ptr)的优点

自动化的内存管理,避免内存泄漏和悬垂指针。简化代码逻辑,减少人工管理内存的复杂性。更容易与标准容器(如 std::vector)配合使用。不使用智能指针是可以的,

但代价是:

更容易引入 bug,例如内存泄漏、悬垂指针问题。
增加代码维护难度,并且可能需要大量的单元测试来覆盖所有的边界情况。
难以保证代码的健壮性,特别是在复杂的资源关系中。

RAII是什么

RAII(Resource Acquisition Is Initialization,资源获取即初始化)是一种资源管理的编程惯用法,是 C++ 中处理资源管理的重要设计理念。它通过将资源的生命周期与对象的生命周期绑定来确保资源得到正确的分配和释放,从而避免资源泄漏。

核心思想

  1. 资源绑定到对象的生命周期

    • 当一个对象在栈上或通过堆分配时,与该对象相关联的资源(例如内存、文件句柄、锁、网络连接等)也随之初始化。
    • 当该对象离开作用域(或者析构)时,资源会被自动释放。
  2. 依靠析构函数来释放资源

    • C++ 中,当一个对象生命周期结束时,其析构函数会被自动调用。因此,可以利用析构函数自动释放资源,而无需手动释放。
    • 通过 RAII,资源分配后不用担心异常、早退或其他复杂逻辑干扰正确释放。

RAII 的优点

  1. 自动化资源管理:用户不需要手动释放资源,不用担心遗漏或错误。
  2. 异常安全:即使代码中间发生异常,析构函数仍然会自动释放资源。
  3. 代码简洁:减少了显式的资源释放代码,避免了冗余或错误。

应用场景

RAII 在内存分配、文件操作、线程锁、数据库连接等场景广泛应用。例如:

  1. 智能指针
    • std::unique_ptr:独占资源,适合动态内存管理。
    • std::shared_ptr:共享资源,适合多个对象共享动态内存。
  2. 互斥锁
    • std::lock_guard<std::mutex>std::unique_lock
  3. 文件与流对象
    • C++ 的 IO 流类如 std::ifstreamstd::ofstream
  4. 其他自定义的资源管理类
    • 如管理数据库连接、网络套接字、句柄等。

总结

RAII 的核心就是将资源的生命周期绑定到对象的生命周期,通过构造函数和析构函数确保资源的正确分配和释放。这种机制有效避免了手动管理资源时的各种问题(如资源泄漏、重复释放等),是 C++ 中非常重要的编程思想,尤其在异常安全和代码简洁性方面效果显著。

面试中,如果被问到 RAII,你可以直接引用 C++ 智能指针、文件流或锁管理类作为例子展开解释。进一步可以说明 RAII 提升了程序的安全性及简洁性,使 C++ 的资源管理更加工程化和易用化。

5. 操作系统

(1) 并发与并行的区别

  • 并发(Concurrency):多个任务在逻辑上同时进行,但实际上可能是按时间片交替执行。
  • 并行(Parallelism):多个任务在物理上真正同时运行(需要多核 CPU 支持)。

(2) 最大并发线程池设计

  • 关键点:
    1. 使用任务队列管理任务。
    2. 通过信号量或条件变量限制线程并发数量。
    3. 动态创建、销毁线程降低资源消耗(即线程复用)。

6. 网络

(1) HTTPS 的通信过程

  1. 客户端发送请求,包括支持的协议版本、加密套件等。
  2. 服务器发送证书,客户端验证证书的合法性。
  3. TLS 握手,双方协商对称密钥(通过非对称加密交换密钥)。
  4. 加密通信,之后的所有数据使用对称密钥加密。

7. 项目挑战

Buffer 的思想

  • 临时缓存用于解决数据处理速度的差异(如网络接收速度慢于处理速度)。
  • **自动增长:**通过动态分配内存扩容实现,常用 指数增长(2 倍增长) 策略;也可以按照具体业务需要调整。

智能指针

智能指针

unique_ptr

作用域指针,不能复制

栈分配指针,当死亡时,自动释放所管理的内存,无需显示调用delete

1
2
3
std::unique_ptr<int> p1 = std::make_unique<int>(10); // 管理动态分配的内存
// std::unique_ptr<int> p2 = p1; // 错误!unique_ptr不支持拷贝
std::unique_ptr<int> p2 = std::move(p1); // 通过 std::move 转移所有权

只能显示调用构造函数,因为其构造函数有explicit关键字,没有了构造函数的隐式转换

最好的调用还是使用make_unique会捕获异常,不会产生悬空指针问题

shared_ptr

追踪引用计数,如果引用为0,则释放内存

需要分配内存用于计数

1
shared_ptr<Entity> p = make_shared<Entity>();

作用主要包括:

  1. 自动管理动态分配的对象,避免手动调用 delete
  2. 支持共享所有权,让多个 shared_ptr 可以安全地访问同一对象。
  3. 借助引用计数机制,实现对象的生命周期控制,当最后一个 shared_ptr 销毁后,自行释放资源。

unique_ptr:独占所有权,不支持多个指针管理同一个对象;更轻量且不存在循环引用问题。

weak_ptr:用于观察 shared_ptr 管理的对象,不增加引用计数;主要用于辅助 shared_ptr,避免循环引用的问题。

std::weak_ptr

std::weak_ptr 是 C++11 引入的一种智能指针,和 std::shared_ptr 一起使用,用于避免 循环引用 问题,同时提供了一种对 std::shared_ptr 所管理对象的弱引用(non-owning reference)。它不改变所管理对象的引用计数。

  • 循环引用问题
    在使用 std::shared_ptr 时,如果两个对象互相以 shared_ptr 引用彼此,会导致内存泄漏,因为它们的引用计数无法递减到 0。
    • shared_ptr 通过引用计数管理对象的生命周期,当引用计数为 0 时,自动释放对象。
    • 如果存在循环引用,两个对象会始终持有对方,这样它们的引用计数永远不会减为 0,因此无法释放内存。
  • 非拥有性的弱引用
    有时候,一个对象只需要 “观察” 对另一个对象的引用,而无需控制它的生命周期。这时使用 std::weak_ptr 是更合理的选择。

std::weak_ptr 提供了一种临时、不影响生命周期的引用,从而解决了上述问题。

总结

std::weak_ptr 的主要使用场景包括:

  1. 解决 std::shared_ptr 的循环引用问题
  2. 跨组件之间的非拥有性引用,例如缓存对象的管理。
  3. 事件监听器或回调函数,避免悬垂指针的产生
  4. 在弱引用需求场景下提供更加灵活的资源管理,而不是一味增加强引用计数。

std::weak_ptr 的特点

  1. 不控制对象的生命周期
  2. 检测对象是否已销毁
    • 可以通过调用 weak_ptrexpired() 方法来检查被引用的对象是否已经销毁。
  3. 使用 lock() 转换为 shared_ptr
    • 如果需要安全地访问被引用的对象,可以调用 weak_ptrlock() 方法,返回一个临时的 shared_ptr。如果对象已销毁,lock() 会返回一个空指针。

假设我们有两个类 AB,它们通过 std::shared_ptr 互相引用。如果没有使用 weak_ptr,将会发生循环引用,导致内存泄漏。

示例代码(循环引用问题)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <iostream>
#include <memory>

class B; // 前向声明

class A {
public:
std::shared_ptr<B> ptrB; // A 持有共享指针引用 B
~A() { std::cout << "A destroyed" << std::endl; }
};

class B {
public:
std::shared_ptr<A> ptrA; // B 持有共享指针引用 A
~B() { std::cout << "B destroyed" << std::endl; }
};

int main() {
// 创建循环引用
auto a = std::make_shared<A>();
auto b = std::make_shared<B>();
a->ptrB = b; // A 持有 B
b->ptrA = a; // B 持有 A

// 离开 main() 作用域时,A 和 B 的引用计数不会减到 0,导致内存泄漏
return 0;
}

运行结果:

1
# 没有输出,因为 `A` 和 `B` 无法正常析构,发生内存泄漏。

解决循环引用的正确做法
将其中一个引用改为 std::weak_ptr,避免两个对象互相增加引用计数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class B; // 前向声明

class A {
public:
std::weak_ptr<B> ptrB; // 弱引用 B
~A() { std::cout << "A destroyed" << std::endl; }
};

class B {
public:
std::shared_ptr<A> ptrA; // 共享指针持有 A
~B() { std::cout << "B destroyed" << std::endl; }
};

int main() {
auto a = std::make_shared<A>();
auto b = std::make_shared<B>();
a->ptrB = b; // A 弱引用 B
b->ptrA = a; // B 持有 A

// 离开作用域时,A 和 B 都将正确析构
return 0;
}

运行结果:

1
2
B destroyed
A destroyed

通过将某一侧的引用改为 std::weak_ptr,打破了循环引用。


跨组件间的弱引用

如果某些对象之间并无强依赖关系,但仍需临时引用,则可以使用 std::weak_ptr

示例 1:缓存管理
在缓存系统中,如果一个对象的存在依赖于被缓存的内容,则可以使用 std::weak_ptr

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#include <iostream>
#include <memory>
#include <unordered_map>
#include <string>

class CachedObject {
public:
CachedObject(std::string name) : name(name) {
std::cout << name << " created" << std::endl;
}
~CachedObject() {
std::cout << name << " destroyed" << std::endl;
}
void printName() { std::cout << "Object name: " << name << std::endl; }

private:
std::string name;
};

int main() {
std::unordered_map<std::string, std::weak_ptr<CachedObject>> cache;

{
auto obj1 = std::make_shared<CachedObject>("Object1");
cache["key1"] = obj1;

auto obj2 = std::make_shared<CachedObject>("Object2");
cache["key2"] = obj2;

// 使用缓存中的对象
if (auto obj = cache["key1"].lock()) {
obj->printName(); // 输出:Object name: Object1
}
} // obj1 和 obj2 均超出作用域,被释放

// 尝试访问释放的对象
if (cache["key1"].expired()) {
std::cout << "Object1 no longer exists" << std::endl; // 输出
}

return 0;
}

运行结果:

1
2
3
4
5
6
Object1 created
Object2 created
Object name: Object1
Object2 destroyed
Object1 destroyed
Object1 no longer exists

事件回调(防止悬垂引用)

如果某类对象注册了一个事件监听器或回调函数,而监听器的生命周期可能比被观察的对象短,那么可以使用 std::weak_ptr 避免访问悬垂的指针。

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
#include <iostream>
#include <memory>
#include <functional>

class EventSource;

class Listener {
public:
Listener(std::shared_ptr<EventSource> source) : source(source) {}
void onEvent() {
if (auto src = source.lock()) { // 检查 source 是否仍有效
std::cout << "Event handled" << std::endl;
} else {
std::cout << "Source no longer exists" << std::endl;
}
}

private:
std::weak_ptr<EventSource> source;
};

class EventSource : public std::enable_shared_from_this<EventSource> {
public:
void fireEvent() {
if (listener) listener(); // 调用回调
}

void setListener(std::function<void()> callback) {
listener = callback;
}

private:
std::function<void()> listener;
};

int main() {
std::shared_ptr<EventSource> source = std::make_shared<EventSource>();
{
auto listener = std::make_shared<Listener>(source);
source->setListener([listener]() { listener->onEvent(); });

source->fireEvent(); // 输出:Event handled
}

// Listener 已销毁,无悬垂引用
source->fireEvent(); // 不输出,Listener 已解除绑定

return 0;
}

腾讯面试准备

算法和数据结构

刷题

C++的stl和新特性

常量指针和指针常量

1
const int *p = &a;//常量指针,指针指向常量,指针指向的变量不能通过指针修改,但是指针指向的值可以改变
1
int *const p = &a;//指针常量,是一个常量,不能改变指向的对象(地址),但是可以改变地址的内容

野指针和悬浮指针

野指针是指向已经被释放或者⽆效的内存地址的指针

悬浮指针是指向已经被销毁的对象的引⽤

区别就是一个是指针一个是引用

网络编程

网络编程的基本流程

(1) 服务端代码流程

1
2
3
4
5
6
1. socket()             // 创建一个套接字
2. bind() // 将套接字与特定 IP 地址和端口绑定
3. listen() // 开启监听,等待客户端连接
4. accept() // 相应客户端的连接请求,接受新的连接
5. send()/recv() // 用于和客户端通信(读取请求或者写入响应
6. close() // 关闭连接

(2) 客户端代码流程

1
2
3
4
1. socket()             // 创建一个套接字
2. connect() // 连接到服务端指定的 IP 地址和端口
3. send()/recv() // 用于发送请求或读取服务端的响应
4. close() // 关闭连接

调试命令,gdb/vscode

额外:linux操作系统的内存管理,文件系统,进程和线程调度

进程间的通信机制:信号量,条件变量,生产者消费者

异步通信:回调函数,生产者消费者(promise-future和消息队列)

同步通信:阻塞式调用,文件或网络操作

引用和指针的区别

1. 什么是引用和指针?

  1. 引用(Reference)

    • 引用是某个变量的别名,声明后与该变量绑定在一起,不能再绑定其他变量。
    • 本质上是一个语法糖,用更简洁的方式访问变量。

    示例代码:

    1
    2
    3
    int a = 10;
    int& ref = a; // 引用 `ref` 绑定到变量 `a`
    ref = 20; // 实际修改的是 `a`
  2. 指针(Pointer)

    • 指针是存储变量地址的一种特殊变量,通过指针可以间接访问或操作存储在内存地址上的值。
    • 指针可以指向不同的变量或内存单元。

    示例代码:

    1
    2
    3
    int a = 10;
    int* ptr = &a; // 指针 ptr 存储变量 a 的地址
    *ptr = 20; // 实际修改的是 a

2. 引用和指针的区别

特性 引用 指针
定义方式 使用 & 声明 使用 * 声明
是否可以为空 引用必须绑定到变量 指针可以是 nullptr 或空
绑定后是否可以更改 一旦绑定不能更改 指针可以重新指向其他变量
语法 直接使用,无需解引用符号 需要用 * 解引用
内存布局 编译器实现(可能是指针) 明确存储变量地址
需要初始化 声明时必须初始化 可以声明后再初始化
别名关系 是原变量的别名 独立的变量
运算 不支持运算 可以进行加减运算
灵活性 较低,限定性强 较高,可以动态分配内存

3. 区别详解

(1) 是否必须初始化

  • 引用:

    • 引用在声明的时候必须初始化,否则无法通过编译。
    1
    2
    int a = 10;
    int& ref; // 编译错误,引用必须绑定变量
  • 指针:

    • 指针声明后可以不初始化,但最好将其初始化(例如初始化为 nullptr),否则容易产生未定义行为。
    1
    2
    int* ptr;  // 未初始化,非法操作可能导致未定义行为
    int* ptr = nullptr; // 推荐初始化为 nullptr

(2) 是否可以为空

  • 引用:

    • 引用不能指向空(nullptr),它必须始终绑定到有效的变量。
  • 指针:

    • 指针可以指向空内存区域(nullptr),表示它当前没有指向任何变量。

    示例:

    1
    2
    int* ptr = nullptr;  // 合法
    int& ref = nullptr; // 编译错误,引用必须绑定到变量

(3) 绑定后是否可以更改

  • 引用:

    • 引用一旦绑定到变量,就不能再绑定到其他变量,引用始终是它所绑定变量的别名。
    1
    2
    3
    int a = 10, b = 20;
    int& ref = a; // ref 绑定到 a
    ref = b; // 修改的是 a 的值,而不是重新绑定到 b
  • 指针:

    • 指针可以随时更改指向,可以指向其他变量或内存单元。
    1
    2
    3
    int a = 10, b = 20;
    int* ptr = &a; // ptr 指向 a
    ptr = &b; // ptr 改为指向 b

(4) 使用上的差异

  • 引用:

    • 如果将一变量赋值给引用,引用直接操作变量本身。
    1
    2
    3
    int a = 10;
    int& ref = a;
    ref = 20; // 改变的是 a,a 的值变为 20
  • 指针:

    • 要改变指针指向变量的值,需要解引用(*)。
    1
    2
    3
    4
    int a = 10, b = 20;
    int* ptr = &a; // 指针指向 a
    *ptr = 30; // 修改 a 的值为 30
    ptr = &b; // 改为指向 b

(5) 运算能力

  • 引用:
    • 引用不支持指针的算术运算,例如加减法。
  • 指针:
    • 指针可以进行算术运算,例如指针递增/递减,用于访问数组元素。
    1
    2
    3
    int arr[3] = {1, 2, 3};
    int* ptr = arr; // 指向数组的第一个元素
    ptr++; // 指向下一个元素

(6) 内存特性

  • 引用在编译器实现中,可能会转换为指针,但它对开发者是透明的。
  • 指针本质上是一个变量,存储的是某个地址,并且占用内存。

4. 引用与指针的适用场景

(1) 适用引用

  1. 函数参数传递

    • 使用引用避免拷贝实参,提高性能。
    • 常用于不需要修改参数的地方(const 引用)。
    1
    2
    3
    void print(const int& x) {
    std::cout << x << std::endl;
    }
  2. 返回值

    • 返回引用允许函数直接返回变量本身,而不是拷贝。
    1
    2
    3
    int& getValue(int& a) {
    return a;
    }
  3. 别名

    • 为变量创建更简化的别名。
    1
    2
    int a = 42;
    int& alias = a; // alias 是 a 的别名

(2) 适用指针

  1. 动态内存管理

    • 使用指针分配和释放动态内存。
    1
    2
    int* ptr = new int(10);
    delete ptr; // 手动释放内存
  2. 数组与迭代

    • 指针常用来访问数组元素。
    1
    2
    3
    4
    int arr[3] = {1, 2, 3};
    for (int* ptr = arr; ptr < arr + 3; ++ptr) {
    std::cout << *ptr << " ";
    }
  3. 数据结构

    • 指针是数据结构(如链表、树)的核心基础。
    1
    2
    3
    4
    struct Node {
    int data;
    Node* next;
    };
  4. 需要动态改变指向时

    • 指针可以灵活地改变指向,适合需要频繁切换指向的场景。

5. 总结对比

特征 引用 指针
需要初始化 必须初始化 可以先声明后初始化
是否可以为空 不可以为空 可以是空指针(nullptr
绑定是否可更改 绑定后不可更改 可通过重新赋值更改指向
语法复杂度 更简单 更复杂,需要使用 *
灵活性 受限 更灵活
常见场景 函数传参、别名 动态内存、复杂结构

引用更简单、更安全,适合大多数普通变量操作;指针更灵活,适合动态内存和复杂结构的场景。在实际开发中,应根据场景和需求选择适合的工具。