2025暑期实习面试问题汇总
2025-04-30 12:24:32 # 面试

1. TCP相比于UDP延迟的原因

机制 TCP 行为(延迟原因) UDP 行为(无延迟)
连接管理 三次握手 + 四次挥手(2-4 个 RTT) 无连接,即发即走
可靠性 确认 + 重传(等待 ACK + 重传延迟) 无确认,不重传
流量与拥塞控制 动态调整速率(限速导致延迟) 无控制,全速发送
有序交付 缓存乱序包(等待顺序) 乱序交付,无需等待
头部与处理复杂度 复杂头部 + 状态维护(处理耗时)20字节 简单头部,无状态(8 字节

还有就是每次启动是拥塞控制都是慢启动,导致一开始发送的数据不会很多

2. 前缀++和后缀++

2.1 区别

  1. 返回值不同
    • 前缀++(++i):先对变量递增,返回递增后的新值
    • 后缀++(i++):先返回变量的原值,再对变量递增。
  2. 实现机制
    • 自定义类型中,后缀++需创建临时对象保存原值(多一次拷贝构造),而前缀++直接修改后返回自身引用,无额外开销。

2.2 哪个效率更高

前缀++效率更高(避免临时对象创建和拷贝)。

2.3 分别在什么场景下使用

  1. 后缀++

    • 需要原值时使用,例如:

      1
      2
      int j = i++;    // j 为原值,i 递增  
      *dest++ = *src++; // 经典指针操作
    • 内置类型且对代码风格无强制要求时,可酌情使用。

  2. 前缀++

    • 循环迭代(For 循环)

      1
      2
      3
      for (auto it = vec.begin(); it != vec.end(); ++it) {
      // 使用 it 操作元素
      }

      **为什么用前缀++**:

      • 效率优先:在循环中,迭代器(如 std::vector<int>::iterator)或复杂对象的前缀++直接修改自身并返回引用,避免了后缀++的临时对象创建和拷贝(见下文实现对比)。
      • 无需原值:循环中只需要更新后的迭代器值,不需要旧值。前缀++的语义更贴合需求。

      对比后缀++的潜在开销

      1
      2
      3
      4
      5
      6
      // 后缀++的实现伪代码(自定义类型)
      Iterator operator++(int) {
      Iterator temp = *this; // 临时对象拷贝
      ++(*this); // 调用前缀++
      return temp; // 返回旧值(临时对象)
      }

      后缀++多了一次临时对象的构造和拷贝,而前缀++直接操作自身。

总结:

默认推荐使用前缀++,除非明确需要原值。因为前缀++返回引用,无需拷贝;后缀++必须返回临时值,多了一层拷贝。

3. 发生哈希冲突时有什么解决方法?拉链法可能导致哈希值相同的结点挂起来导致查询效率变慢,如何解决?

冲突主要取决于:

  (1)散列函数,一个好的散列函数的值应尽可能平均分布。

  (2)处理冲突方法。

  (3)负载因子的大小。太大不一定就好,而且浪费空间严重,负载因子和散列函数是联动的

解决冲突的办法:拉链法和开放寻址法

  1. 拉链法(Separate Chaining)
  • 原理:将哈希到同一位置的元素通过链表(或动态数组)串联起来。
  • 问题:链表过长会导致查询时间复杂度退化为 **O(n)**。
  • 优化方案
    • 链表转红黑树:当链表长度超过阈值(如 Java 的 HashMap 中链表长度超过 8)时,将链表转为红黑树,查询复杂度降为 **O(log n)**。
    • 动态扩容:当哈希表的负载因子(元素数量/桶数量)超过阈值(如 0.75)时,扩容哈希表并重新哈希(Rehashing),减少冲突概率。
    • 改进哈希函数,减少冲突概率,比如使用混合哈希。
  1. 开放寻址法(Open Addressing)
  • 原理:冲突时按一定规则(线性探测、平方探测、双重哈希等)寻找下一个空闲位置。
  • 问题:容易产生“聚集现象”(多个元素堆积在相邻位置),导致查询效率下降。
  • 优化方案
    • 双重哈希(Double Hashing):使用第二个哈希函数计算步长,减少聚集。
    • 布谷鸟哈希(Cuckoo Hashing):使用多个哈希函数和多个哈希表,强制冲突元素迁移到其他位置。

为什么链表长度超过8时才将其转为红黑树?

当链表长度大于或等于阈值(默认为 8)的时候,如果同时还满足容量(该红黑树最大能容纳多少子结点)大于或等于 MIN_TREEIFY_CAPACITY(默认为 64)的要求,就会把链表转换为红黑树。

同样,后续如果由于删除或者其他原因调整了大小,当红黑树的节点小于或等于 6 个以后,又会恢复为链表形态。

img

在图中我们可以看到,有一些槽点是空的,有一些是拉链,有一些是红黑树。

更多的时候我们会关注,为何转为红黑树以及红黑树的一些特点,可是,为什么转化的这个阈值要默认设置为 8 呢?要想知道为什么设置为 8,那首先我们就要知道为什么要转换,因为转换是第一步。

每次遍历一个链表,平均查找的时间复杂度是 O(n),n 是链表的长度。红黑树有和链表不一样的查找性能,由于红黑树有自平衡的特点,可以防止不平衡情况的发生,所以可以始终将查找的时间复杂度控制在 O(log(n))。

最初链表还不是很长,所以可能 O(n) 和 O(log(n)) 的区别不大,但是如果链表越来越长,那么这种区别便会有所体现。所以为了提升查找性能,需要把链表转化为红黑树的形式

那么为什么不一开始就使用红黑树?

那为什么不一开始就用红黑树,反而要经历一个转换的过程呢?其实在 JDK 的源码注释中已经对这个问题作了解释:

1
2
3
4
Because TreeNodes are about twice the size of regular nodes,
use them only when bins contain enough nodes to warrant use
(see TREEIFY_THRESHOLD). And when they become too small (due
removal or resizing) they are converted back to plain bins.

通过查看源码可以发现,默认是链表长度达到 8 就转成红黑树,而当长度降到 6 就转换回去,这体现了时间和空间平衡的思想.

最开始使用链表的时候,空间占用是比较少的,而且由于链表短,所以查询时间也没有太大的问题。可是当链表越来越长,需要用红黑树的形式来保证查询的效率。对于何时应该从链表转化为红黑树,需要确定一个阈值,这个阈值默认为 8,并且在源码中也对选择 8 这个数字做了说明,原文如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
 
In usages with well-distributed user hashCodes, tree bins
are rarely used. Ideally, under random hashCodes, the
frequency of nodes in bins follows a Poisson distribution
(http://en.wikipedia.org/wiki/Poisson_distribution) with a
parameter of about 0.5 on average for the default resizing
threshold of 0.75, although with a large variance because
of resizing granularity. Ignoring variance, the expected
occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
factorial(k)). The first values are:

0: 0.60653066
1: 0.30326533
2: 0.07581633
3: 0.01263606
4: 0.00157952
5: 0.00015795
6: 0.00001316
7: 0.00000094
8: 0.00000006
more: less than 1 in ten million

上面这段话的意思是,如果 hashCode 分布良好,也就是 hash 计算的结果离散好的话,那么红黑树这种形式是很少会被用到的,因为各个值都均匀分布,很少出现链表很长的情况。在理想情况下,链表长度符合泊松分布,各个长度的命中概率依次递减,当长度为 8 的时候,概率仅为 0.00000006。这是一个小于千万分之一的概率,通常我们的 Map 里面是不会存储这么多的数据的,所以通常情况下,并不会发生从链表向红黑树的转换。

为什么 HashMap 的链表长度符合泊松分布?

在 HashMap 中,当哈希函数分布良好时,每个键值对(Entry)会被均匀地分配到不同的桶(Bucket)中。此时,每个桶中链表的长度可以看作是一个独立事件,且每个键被分配到某个特定桶的概率极低(因为桶的数量通常很大)。这正是泊松分布的应用场景。

具体来说:

  1. 二项分布的近似
    当键的数量 n 很大,且每个键被分配到某个桶的概率 p很小时,二项分布 B(n,p)可以近似为泊松分布 P(λ),其中 λ=np。
  2. HashMap 中的参数
    • 默认情况下,HashMap 的负载因子(Load Factor)为 0.75,当元素数量达到阈值时,桶的数量会翻倍(扩容)。
    • 假设哈希函数完美均匀,每个键被分配到某个桶的概率 p=1桶数量p=桶数量1。
    • 在扩容过程中,桶的平均元素数 λλ 通常较小(例如,Java 8 的 HashMap 实现中,λ≈0.5λ≈0.5)。

什么时候不会转换为红黑树?

如果table长度小于常量MIN_TREEIFY_CAPACITY时,不会变为红黑树,而是调用resize()方法进行扩容。MIN_TREEIFY_CAPACITY的默认值是64。显然HashMap认为,虽然链表长度超过了8,但是table长度太短,只需要扩容然后重新散列一下就可以

总结

事实上,链表长度超过 8 就转为红黑树的设计,更多的是为了防止用户自己实现了不好的哈希算法时导致链表过长,从而导致查询效率低, 而此时转为红黑树更多的是一种保底策略,用来保证极端情况下查询的效率。

通常如果 hash 算法正常的话,那么链表的长度也不会很长,那么红黑树也不会带来明显的查询时间上的优势,反而会增加空间负担。所以通常情况下,并没有必要转为红黑树,所以就选择了概率非常小,小于千万分之一概率,也就是长度为 8 的概率,把长度 8 作为转化的默认阈值

4. 发生粘包/拆包有什么处理方式

粘包原因

  1. 客户端的发送频率远高于服务器的接收频率,接收方没有及时接收缓冲区的包,造成多个包接收(客户端发送了一段数据,服务端只收了一小部分,服务端下次再收的时候还是从缓冲区拿上次遗留的数据,产生粘包)recv会产生黏包(如果recv接受的数据量(1024)小于发送的数据量,第一次只能接收规定的数据量1024,第二次接收剩余的数据量)
  2. 发送端需要等缓冲区满才发送出去,造成粘包(tcp底层的安全和效率机制不允许字节数特别少的小包发送频率过高,tcp会在底层累计数据长度到一定大小才一起发送,发送数据时间间隔很短,数据也很小,会合到一起,产生粘包)send 也可能发生粘包现象。(连续send少量的数据发到输出缓冲区,由于缓冲区的机制,也可能在缓冲区中不断积压,多次写入的数据被一次性发送到网络

拆包原因

  1. 应用层程序写入数据(发送方发送数据)大于套接字缓冲区大小,会发生拆包
  2. 发送的数据大于协议的 MTU (最大传输单元,超过这个量要分成多个报文段),因此必须拆包

解决方法

  1. 固定缓冲区大小,发送方和接收方规定固定大小的缓冲区,也就是发送和接收都使用固定大小的 byte[] 数组长度,当字符长度不够时使用空字符弥补。(虽然这种方式可以解决粘包和半包的问题,但这种固定缓冲区大小的方式增加了不必要的数据传输,因为这种方式当发送的数据比较小时会使用空字符来弥补,所以这种方式就大大的增加了网络传输的负担,所以它也不是最佳的解决方案)
  2. TLV编码,将消息长度封装到消息体前面(实现方式的编码成本较大也不够优雅)
  3. 特殊字符结尾,按行读取。以特殊字符结尾就可以知道流的边界了,因此也可以用来解决粘包和半包的问题,这种解决方案的核心是,借助 C++ 标准库中的 std::ifstreamstd::ofstream 配合 std::getline 函数来实现类似 Java 中 BufferedReaderBufferedWriter 的功能。也就是在写入数据时,在每行数据末尾添加 '\n' 作为结尾,在读取数据时,使用 std::getline 函数按行读取数据,这样就能确定数据流的边界,从而解决粘包和半包的问题。。
  4. 使用序列化框架(如 Protocol Buffers、JSON)定义消息结构,框架自动处理消息边界

什么情况下的粘包不需要处理?

  1. 连续的数据流不需要处理。如一个在线视频,它是一个连续不断的流, 不需要考虑分包。
  2. 每发一个消息,建一次连接的情况。比如HTTP1.0短连接。
  3. 发送端使用了TCP强制数据立即传送的操作指令push。
  4. UDP, 前面已说明白了。在这在强调一下,UDP不需要处理。

当最后一段数据包过短时(如半包),如何解决?

  1. 填充至固定长度
    发送方将消息填充到固定长度(如 1024 字节),接收方按固定长度读取。
    示例:用 0x00 填充剩余空间,接收后去除填充数据。
    缺点:增加冗余数据。
  2. 消息头记录实际长度
    在消息头中添加字段记录消息实际长度,接收方按该长度读取。
    示例:消息头包含 4 字节的 length 字段,接收方循环读取直到凑够长度。
    优点:精准控制,避免冗余。
  3. 分包合并逻辑
    接收方维护一个缓冲区,将收到的小包暂存,按协议规则合并完整消息后再处理。
    示例:收到多个小包时,按消息头中的长度字段判断是否需要继续等待。

注意

短连接情况下,请求之后,收到回答,立马断开连接,不会发生粘包。但是有可能发送拆包。

5. 如何查看某个端口有没有被占用

简要回答

  • 在Linux中,查看端口是否被占用的方法按场景可分为:
    1. 快速检查端口监听ss -tuln | grep :端口号netstat -tuln | grep :端口号
    2. 定位占用进程sudo lsof -i :端口号sudo netstat -tulnp | grep :端口号
    3. 深度分析连接状态ss -s 或解析 /proc/net/tcp 文件。
    4. 验证端口对外开放性nmap -p 端口号 localhosttelnet localhost 端口号
      1. 如果需要检查远程主机端口是否可用,使用 telnet <目标主机 IP> <端口号>
      2. 或者使用nc -zv <目标主机 IP> <端口号>-z:表示以零 I/O 模式连接,仅扫描端口而不进行数据传输。-v:表示详细输出,显示连接结果。
      3. 或者使用nmap -p <端口号> <目标主机 IP>,扫描结果会显示端口的状态,如 “open”(开放)、“closed”(关闭)等

详细回答

  1. 快速检查端口是否被监听

    • 命令ss -tuln | grep :端口号netstat -tuln | grep :端口号
    • 参数解析
      -t:检查TCP协议端口。
      -u:检查UDP协议端口。
      -l:仅显示监听状态(LISTEN)的端口。
      -n:禁用域名解析,直接显示IP和端口号。
    • 适用场景:快速验证端口是否被监听(如启动服务前的检查)
  2. 定位占用端口的进程

    • 命令sudo lsof -i :端口号sudo netstat -tulnp | grep :端口号

    • 输出示例

      1
      2
      COMMAND  PID  USER   FD   TYPE DEVICE SIZE/OFF NODE NAME  
      nginx 1234 root 6u IPv4 123456 0t0 TCP *:80 (LISTEN)
    • 关键信息
      COMMAND:进程名称(如nginx)。
      PID:进程ID(如1234)。
      USER:运行进程的用户(如root)。

    • 权限说明:普通用户可能无法查看所有进程,需使用 sudo 提权。

  3. 深度分析连接状态

    • 命令ss -s(统计连接状态) 或 grep -w "端口号(16进制)" /proc/net/tcp(解析底层TCP连接)。
    • ss -s命令输出内容:总连接数、各状态(ESTABLISHEDTIME_WAITLISTEN)的统计。
    • /proc/net/tcp 文件字段说明local_address:本地地址(格式 IP:端口,16进制);state:连接状态(如 0A 表示 LISTEN)。
  4. 验证端口对外开放性

    • 命令nmap -p 端口号 localhosttelnet localhost 端口号
    • namp参数分解:**-sT:TCP 全连接扫描(模拟正常连接行为);-p**:指定扫描的端口号。
    • namp输出状态open:端口开放且可访问;closed:端口未被占用;filtered:端口被防火墙拦截。

工具对比

  1. 工具对比:ss vs netstat,如下表所示:
特性 ss netstat
数据来源 直接读取内核socket表 解析/proc/net文件
速度 更快(适合高负载服务器) 较慢
维护状态 活跃维护(推荐使用) 已逐步淘汰
  1. 端口状态解析(ss/netstat输出):
  • LISTEN:端口正在监听,等待连接。
  • ESTABLISHED:已建立活跃连接。
  • TIME_WAIT:连接已关闭,等待内核清理(可能短暂占用端口)。
  • CLOSE_WAIT:远程已关闭连接,本地未释放。

6. 哈希表什么时候进行扩容?负载因子

扩容原因

负载因子(Load Factor)是衡量哈希表中元素填充程度的指标,它等于哈希表中已存储的元素数量与哈希表容量(桶的数量)的比值。当负载因子达到预先设定的阈值时,哈希表就会进行扩容操作

负载因子(Load Factor) 是衡量哈希表(Hash Table)或哈希集合(Hash Set)等动态数据结构中元素填充程度的指标。它决定了哈希表在何时需要扩容以维持高效的插入、查询和删除操作。

1
负载因子 = size / capacity
  • 当负载因子超过预设阈值时(如 0.75),哈希表会自动扩容(如容量翻倍),并重新分配元素。
  • 负载因子越高,哈希表越拥挤,哈希冲突(多个元素映射到同一个桶)的概率越大,导致性能下降。

C++中,我们可以通过 max_load_factor 成员函数来获取和设置最大负载因子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>
#include <unordered_map>

int main() {
std::unordered_map<int, int> myMap;
// 获取当前最大负载因子
float currentLoadFactor = myMap.max_load_factor();
std::cout << "Current max load factor: " << currentLoadFactor << std::endl;

// 设置新的最大负载因子
myMap.max_load_factor(0.8f);
std::cout << "New max load factor: " << myMap.max_load_factor() << std::endl;

return 0;
}

扩容原理

如果HashMap初始化的时候没有指定容量,那么初始化table的时候会使用默认的DEFAULT_INITIAL_CAPACITY参数,也就是16,作为table初始化时的长度。

如果HashMap初始化的时候指定了容量,HashMap会把这个容量修改为2的倍数,然后创建对应长度的table。table在HashMap扩容的时候,长度会翻倍。

所以要注意,如果要往HashMap中放1000个元素,又不想让HashMap不停的扩容,最好一开始就把容量设为2048,设为1024不行,因为元素添加到七百多的时候还是会扩容(负载因子)。

当HashMap决定扩容时,会调用HashMap类中的resize(int newCapacity)方法,参数是新的table长度。

1
2
3
4
5
6
7
8
9
10
11
12
13
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}

Entry[] newTable = new Entry[newCapacity];
transfer(newTable, initHashSeedAsNeeded(newCapacity));
table = newTable;
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}

代码中可以看到,如果原有table长度已经达到了最大上限,就不再扩容了,MAXIMUM_CAPACITY 代表哈希表所能达到的最大容量。如果还未达到上限,则创建一个新的table,并调用transfer方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* Transfers all entries from current table to newTable.
*/
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next; //注释1
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity); //注释2
e.next = newTable[i]; //注释3
newTable[i] = e; //注释4
e = next; //注释5
}
}
}

transfer方法的作用是把原table的Node放到新的table中,使用的是头插法,也就是说,新table中链表的顺序和旧列表中是相反的,在HashMap线程不安全的情况下,这种头插法可能在多线程下会导致环状节点

注意:这种逆序的扩容方式在多线程时有可能出现环形链表,出现环形链表的原因大概是这样的:线程1准备处理节点,线程二把HashMap扩容成功,链表已经逆向排序,那么线程1在处理节点时就可能出现环形链表。

因此建议使用尾插法,减少环形链表的风险。

7. HTTP的content_type

Content-Type(内容类型),一般是指网页中存在的 Content-Type,用于定义网络文件的类型和网页的编码,决定浏览器将以什么形式、什么编码读取这个文件,这就是经常看到一些 PHP 网页点击的结果却是下载一个文件或一张图片的原因。

Content-Type 头部字段用于说明 HTTP 请求或响应实体主体的媒体类型

语法格式:

1
2
Content-Type: text/html; charset=utf-8
Content-Type: multipart/form-data; boundary=something

img

常见的媒体格式类型如下:

  • text/html : HTML格式
  • text/plain :纯文本格式
  • text/xml : XML格式
  • image/gif :gif图片格式
  • image/jpeg :jpg图片格式
  • image/png:png图片格式

以application开头的媒体格式类型:

  • application/xhtml+xml :XHTML格式
  • application/xml: XML数据格式
  • application/atom+xml :Atom XML聚合格式
  • application/json: JSON数据格式
  • application/pdf:pdf格式
  • application/msword : Word文档格式
  • application/octet-stream : 二进制流数据(如常见的文件下载)
  • application/x-www-form-urlencoded : <form encType="">中默认的encType,form表单数据被编码为key/value格式发送到服务器(表单默认的提交数据的格式)

另外一种常见的媒体格式是上传文件之时使用的:

  • multipart/form-data : 需要在表单中进行文件上传时,就需要使用该格式

HTTP报文头包含什么内容?

HTTP服务器不会保存关于客户的任何信息,是一个无状态协议请求头响应头两部分共同组成一个表征的http报文头格式。

HTTP请求头包括以下字段:

  • Host:指定服务器的主机名和端口号,用于确定请求的目标服务器(也可以是域名)。
  • Request-line:包含用于描述请求类型要访问的资源以及所使用的HTTP版本的信息
  • Accept:指定客户端所能接受的内容类型,通常用于告知服务器客户端支持哪些媒体类型(如HTML、XML、JSON等)
  • User-Agent:客户端使用的浏览器类型和版本号,供服务器统计用户代理信息
  • Cookie:包含了之前由服务器通过Set-Cookie响应头设置的Cookie信息,用于在客户端和服务器之间维护会话状态,如果请求中包含cookie信息,则通过这个字段将cookie信息发送给Web服务器
  • Connection:客户端请求后是否需要保持长连接
1
2
3
4
5
6
GET /index.html HTTP/1.1
Host: www.example.com
Accept: text/html, application/xhtml+xml, */*
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:123.0) Gecko/20100101 Firefox/123.0
Cookie: sessionid=abcdefg1234567
Connection: keep-alive

请求报文的第一行叫做请求行,其后继的行叫作首部行。请求行有三个字段:方法字段、URL字段和HTTp版本字段。一般在首部行(和附加的额外一行)后有一个“实体体”,使用GET方法时实体体为空,而使用POST时实体体需要用户写入相关内容。如下图:

img

响应报文由一个初始状态行、若干个首部行和实体体组成。实体体是报文的主要组成部分,它包含了所请求的对象本身;状态行有3个字段:协议版本字段、状态码和相应状态信息。

HTTP响应头包括以下字段:

  • Status-line:包含协议版本、状态码和状态消息
  • Content-Type:指定了响应体的内容类型。与上同
  • Content-Length:指定了响应体的长度。与上同
  • Set-Cookie:用于设置新的Cookie或更新已有的Cookie
  • Server:指定了响应的服务器软件信息
  • Connection:表示是否需要保持长连接(keep-alive)
1
2
3
4
5
6
HTTP/1.1 200 OK
Content-Type: text/html; charset=UTF-8
Content-Length: 1024
Set-Cookie: sessionid=abcdefg1234567; HttpOnly; Path=/
Server: Apache/2.2.32 (Unix) mod_ssl/2.2.32 OpenSSL/1.0.1e-fips mod_bwlimited/1.4
Connection: keep-alive

常用的状态码如下:

image-20250403154155832

8. delete this 合法吗

当一个对象调用成员函数时,this 指针指向该对象本身。delete this 意味着在对象的成员函数内部将该对象本身从内存中释放。在满足一定条件下,语法层面是允许这么做的。

  • 对象必须是通过 new 操作符在堆上分配的delete 操作符只能用于释放通过 new 分配的内存。如果对象是在栈上创建的,使用 delete this 会引发未定义行为
  • 在调用 delete this 之后,不能再访问该对象的成员:因为对象已经被销毁,再访问其成员会导致未定义行为。
  • 在调用 delete this 之后,不能再使用 this 指针:同样,因为对象已被销毁,继续使用 this 指针会引发未定义行为。

9. 常见的哈希算法

MD5 和 SHA-1 可以说是目前应用最广泛的Hash算法,而它们都是以 MD4 为基础设计的:

(1) MD4

 它适用在32位字长的处理器上用高速软件实现–它是基于 32 位操作数的位操作来实现的,产生32位的哈希值,但由于其较低的安全性和容易碰撞的特点,已逐渐不再被推荐在加密领域使用。

(2) MD5

  MD5(RFC 1321)是 Rivest 于1991年对MD4的改进版本。它对输入仍以512位分组,其输出是4个32位字的级联(128位的哈希值),与 MD4 相同。MD5比MD4来得复杂,并且速度较之要慢一点,但更安全,在抗分析和抗差分方面表现更好。但由于其较低的安全性和容易碰撞(collision)的特点,已逐渐不再被推荐在加密领域使用。

(3) SHA-1 及其他

  SHA1是由NIST NSA设计为同DSA一起使用的,它对长度小于264的输入,产生长度为160位的散列值,因此抗穷举(brute-force)性更好。SHA-1 设计时基于和MD4相同原理,并且模仿了该算法。

哈希的作用是什么?

1.数据完整性验证:哈希函数可以为任意长度的数据生成固定长度的哈希值。通过对数据进行哈希计算,可以得到一个唯一的摘要值,用于验证数据的完整性。通过比较不同时间或不同地点生成的哈希值,可以确定数据是否被篡改或损坏。一般用于验证文件是否被修改。

2.加密与安全:哈希函数在密码学中扮演着重要角色。它们被用于存储密码、数字签名、消息认证等方面。密码哈希函数将用户的密码转换成固定长度的哈希值,并将其存储在数据库中,而不是明文保存密码。这样可以在验证用户身份时,直接比较哈希值,而不会使原始密码暴露在可能的攻击下。

3.数据索引和快速查找:哈希函数常用于索引和散列查找算法,如散列表(Hash Table),Bloom Filter 等。它们将数据映射到固定大小的哈希表中的位置,以实现高效的数据访问和查找操作。哈希函数可以减少搜索的复杂度,提高数据检索的速度。

4.数据分片和负载均衡:在分布式系统中,哈希函数可用于根据数据的特征将其均匀地分配到多个节点上。通过使用哈希函数,可以将数据均匀地散列到不同的存储节点上,实现负载均衡和数据的分布式存储。

5.数据唯一性标识:哈希函数可以将任意长度的数据映射为固定长度的哈希值。这样的哈希值通常可以作为数据的唯一标识符,用于数据的比较、去重和识别等应用场景。

10. C++有很多long类型的变量,如何求他们的平均值,注意要防备数值溢出

方法:边累加边计算平均值

1
2
3
4
5
6
7
8
double calculateAverage(const std::vector<long>& numbers) {
double average = 0;
int n = numbers.size();
for (int i = 0; i < n; ++i) {
average += (numbers[i] - average) / (i + 1);
}
return average;
}
  • 在循环中,average += (numbers[i] - average) / (i + 1); 这行代码的作用是在每一次迭代时更新平均值,避免了对所有数求和可能导致的溢出。

公式推导如下:

image-20250408152041591

average += (numbers[i] - average) / (i + 1)其实就是average =average + (numbers[i] - average) / (i + 1)

11. C++函数传参过程

C++有三种参数传递方式:指针传递,引用传递,值传递

值传递其实将实参拷贝一份存储至栈中,形参和实参互不影响。

指针参数传递本质上是值传递,它所传递的是一个地址值。值传递过程中,被调函数的形参作为被调函数的局部变量处理,会在栈中开辟内存空间以存放由主调函数传递进来的实参值,从而形成了实参的一个副本。值传递的特点是,被调函数对形参的任何操作都是作为局部变量进行的,不会影响主调函数的实参变量的值实参指针不会变)。但我们可以通过解引用*来修改指针所指向对象的值。

引用参数传递过程中,被调函数的形参也作为局部变量在栈中开辟了内存空间,但是这时存放的是由主调函数放进来的实参变量的地址。被调函数对形参(本体)的任何操作都被处理成间接寻址,即通过栈中存放的地址访问主调函数中的实参变量。因此,被调函数对形参的任何操作都会影响主调函数中的实参变量。

引用传递和指针传递是不同的,虽然他们都是在被调函数栈空间上的一个局部变量,但是任何对于引用参数的处理都会通过一个间接寻址的方式操作到主调函数中的相关变量。而对于指针传递的参数,如果改变被调函数中的指针地址,它将应用不到主调函数的相关变量。如果想通过指针参数传递来改变主调函数中的相关变量(地址),那就得使用指向指针的指针或者指针引用

编译的角度来讲,程序在编译时分别将指针和引用添加到符号表上,符号表中记录的是变量名及变量所对应地址。指针变量在符号表上对应的地址值为指针变量的地址值,而引用在符号表上对应的地址值为引用对象的地址值(与实参名字不同,地址相同)。符号表生成之后就不会再改因此指针可以改变其指向的对象(指针变量中的值可以改),而引用对象则不能修改

12. C++函数调用过程

当程序调用一个函数时,CPU首先需要将函数的参数和返回地址等信息保存到栈空间中,并跳转到函数的入口处开始执行函数代码。当函数执行完毕后,程序又会从函数返回的地方继续执行。以下是C++函数调用的具体过程:

  1. 函数参数的传递
    当程序调用一个函数时,需要将函数的实参传递给该函数

C++提供了三种基本的参数传递方式:值传递、指针传递和引用传递。对于值传递,程序会将实参的值复制一份,然后传递给函数;而对于引用传递,则直接将实参的内存地址作为参数传递给函数,在函数内部可以通过指针来访问实参的值。

  1. 构建栈帧
    在进行函数调用时,程序需要为每个函数创建一个新的栈帧(stack frame),并将它压入当前线程的栈顶。栈帧是用来存储函数内部数据(如局部变量)、函数参数、返回地址等信息的。

  2. 跳转到函数入口处
    完成了参数的传递和栈帧初始化后,程序将跳转到函数的入口处开始执行函数内部代码。

  3. 执行函数内部代码
    在函数内部,程序将按照一般的顺序执行语句。在函数内部可以使用参数、局部变量、全局变量等等。

  4. 返回值传递
    当函数执行完毕后,会返回调用点继续执行程序。如果该函数需要返回一个值,则会将返回值存储在函数栈帧中,并将栈指针移回到对应的栈帧,最后把返回值传递给调用者

  5. 恢复栈状态
    当函数返回时,程序需要从当前栈顶弹出且销毁本次函数调用的栈帧,恢复上一次函数调用时的环境。同时,栈指针也需要相应地向下移动。

函数栈的主要作用包括

  • 存储函数的上下文信息,包括参数、局部变量和返回地址。
  • 确保函数调用的嵌套顺序和控制流的正确性。
  • 允许递归函数的实现,因为每个递归调用都会创建一个新的栈帧。
  • 在函数执行完毕后,从栈中弹出栈帧以释放内存。

递归与函数栈

递归是一种特殊的函数调用,其中函数可以调用自身。函数栈在递归中发挥关键作用,因为每个递归调用都会创建一个新的栈帧,使得程序可以追踪多层嵌套的递归调用。

1
2
3
4
5
def factorial(n):
if n == 0:
return 1
else:
return n * factorial(n - 1)

在上述的递归示例中,每次调用 factorial 函数时,都会创建一个新的栈帧,存储参数 n 和返回地址。当递归结束时,栈帧会逐个出栈,计算出最终的结果。

13. c++特性发展历程(每个版本更新的特点), 以及每个版本主要更新的特性

  • C++11引入了许多现代编程语言的特性,极大地增强了语言的表达能力和性能。
    • 自动类型推导:引入 auto 关键字,编译器可以自动推导变量的类型,简化代码。
    • 范围 for 循环:提供了一种更简洁的方式来遍历容器中的元素。
    • Lambda 表达式:允许在代码中定义匿名函数,增强了代码的灵活性。
    • 智能指针std::unique_ptrstd::shared_ptrstd::weak_ptr 等智能指针,帮助管理动态内存,避免内存泄漏。
    • 右值引用和移动语义:提高了对象的移动效率,减少不必要的拷贝。
  • C++14是C++11的小扩展版本,主要对 C++11 中引入的特性进行了改进和完善。
    • 泛型 Lambda 表达式:Lambda 表达式的参数类型可以使用 auto,使代码更加灵活。
    • 返回类型推导:允许函数返回类型由编译器自动推导。
  • C++20引入了许多现代化的特性,如概念、协程等,进一步提升了语言的性能和表达能力。
    • 协程(Coroutines):支持异步编程,简化了异步代码的编写。
    • 范围(Ranges):提供了一种更简洁和安全的方式来处理序列数据。
    • 可管理的线程类(jthread):为多线程编程提供了更方便、更安全的使用方式。

14. 1000*1000的彩色图片占多少内存

一张 1000×1000 的彩色图片(通常为 RGB 或 BGR 格式,每个像素占用 3 个 8 位通道(0~255是一个通道),即红、绿、蓝各 1 字节)的内存占用计算公式为:像素总数 × 每个像素的字节数。像素总数为 1000×1000=1,000,000,每个像素占 3 字节,因此总内存约为 1,000,000×3=3,000,000 字节(Byte)。换算为更易读的单位:3,000,000 字节≈2.86MB(1MB=1,048,576 字节),实际应用中若包含透明度通道(ARGB,4 字节 / 像素),则内存占用约为 4MB。该计算基于未压缩的原始像素数据,实际存储时图片格式(如 JPEG、PNG)会因压缩算法影响文件大小,但内存中原始像素数据的占用量与此直接相关。

15. 协程(三优点,三缺点)⭐

什么是协程?

什么是协程

协程可以理解为一种非常轻量级的 “微型线程”

就像我们在一个程序里可以同时做几件事,比如一边下载文件,一边播放音乐,线程可以让程序同时干这些不同的事儿。但线程切换的时候,需要操作系统在内核层面进行复杂的操作,比较耗费资源和时间

而协程呢,是在程序内部由开发人员自己来控制切换的。它也能让程序同时做多个任务,比如一个函数里,执行到一半,开发人员觉得可以先去做另一个任务了,就手动把当前的状态保存下来,去执行另一个任务,等那个任务做完了,再回来接着做之前没做完的。协程有自己的一些东西,像寄存器上下文(可以理解为记录当前运行到什么程度的一些信息)和栈(用来保存一些数据和函数调用关系等),这样就能保证每次切换回来都能接着上次的状态继续运行,就好像时间暂停了一下,等再回来还是原来的样子,继续往下走。所以说协程是轻量级的,它不需要像线程那样依赖操作系统内核来切换,自己就能搞定,这样就节省了很多资源和时间,让程序运行得更高效

但注意:

  1. 协程在一个线程中是并发使用的,不是并行,目的是避免线程阻塞,它通过频繁的用户主动切换充分利用系统分配给线程的时间片。
  2. 协程的栈帧数据存放在堆区,因为协程是用户态实现的,内核并不知道协程的存在,因此协程是用户主动创建的,数据存放在堆区。

为什么要用协程

  1. 目前主流语言基本上都选择了多线程作为并发设施,与线程相关的概念就是抢占式多任务(Preemptive multitasking),而与协程相关的是协作式多任务。其实不管是进程还是线程,每次阻塞、切换都需要陷入系统调用(system call),先让CPU跑操作系统的调度程序,然后再由调度程序决定该跑哪一个进程(线程)。而且由于抢占式调度执行顺序无法确定的特点,使用线程时需要非常小心地处理同步问题,而协程完全不存在同步问题(协程的切换是用户主动让出的,而只有当前协程退出后,其他协程才能使用共享资源),我们只需要处理好线程之间的同步即可,同一个线程内的多协程不需要进行同步处理

    这意味着在单个线程内的协程之间,对共享资源的访问可以通过代码逻辑严格控制顺序(如在操作共享数据时不主动释放控制权),避免了线程因抢占导致的执行顺序不确定性和竞态条件。而线程依赖操作系统内核的抢占式调度,执行时机不可预测,必须通过锁、信号量等同步机制保证数据一致性。协程的协作式特性使其在单线程内天然规避了抢占带来的同步问题,简化了并发编程模型。

  2. 因为协程是用户自己来编写调度逻辑的,对于我们的CPU来说,协程其实是单线程(单线程内同一时间只能运行一个协程,只有当前协程主动让出执行权 (如遇到 I/O 等待、显式调用挂起函数),其他协程才能获得运行机会),所以CPU不用去考虑怎么调度、切换上下文,这就省去了CPU的切换开销,所以协程在一定程度上又好于多线程。因此,协程的上下文切换开销小于线程。

  3. 其次,每个线程都有自己的线程栈,和一个线程默认大小是10MB,如果针对一个客户端连接建立一个线程进行管理,那么针对10000个客户呢?那么就需要100000MB,大概需要100G的内存,但这样无疑太消耗资源了,而服务器的内存配置只有区区8G,这时候你有2种选择,一是选择增加服务器,二是选择提高代码效率。而协程运行在线程之上,当一个协程执行完成后,可以选择主动让出,让另一个协程运行在当前线程之上。协程并没有增加线程数量,只是在线程的基础之上通过分时复用的方式运行多个协程,而且协程的切换在用户态完成,切换的代价比线程从用户态到内核态的代价小很多。

    回到上面的问题,我们只需要启动100个线程,每个线程上运行100个协程,这样不仅减少了线程切换开销,而且还能够同时处理10000个读取数据库的任务,很好的解决了上述任务。

    因此,协程第三个优点是能帮我们节约内存资源

缺点

  1. 无法使用 CPU 的多核:协程的本质是个单线程,它不能同时用上单个 CPU 的多个核,协程需要和进程配合才能运行在多CPU上。当然我们日常所编写的绝大部分应用都没有这个必要,就比如网络爬虫来说,限制爬虫的速度还有其他的因素,比如网站并发量、网速等问题都会是爬虫速度限制的因素。除非做一些密集型应用,这个时候才可能会用到多进程和协程。

  2. 处处都要使用非阻塞代码:假设协程运行在线程之上,并且协程调用了一个阻塞IO操作,这时候会发生什么?实际上操作系统并不知道协程的存在,它只知道线程,因此在协程调用阻塞IO操作的时候,操作系统会让线程进入阻塞状态,当前的协程和其它绑定在该线程之上的协程都会陷入阻塞而得不到调度,这往往是不能接受的。

    因此在协程中不能调用导致线程阻塞的操作。也就是说,协程只有和异步IO结合起来,才能发挥最大的威力。

  3. 协程对CPU密集型的任务也没有太大的好处,CPU密集型的任务本身不需要大量的线程切换,因此协程的作用也十分有限,反而还增加了协程切换的开销。

16. 有了http为什么还需要websocket

HTTP 是 “客户端拉取数据” 的协议,而 WebSocket 是 “服务器主动推送 + 双向实时通信” 的协议,二者互补而非替代WebSocket 填补了 HTTP 在实时交互领域的空白,让实时应用开发更高效。

HTTP1.0和HTTP1.1可以说因为设计的缺陷导致只能单工通信,那么HTTP2.0和HTTP3.0已经可以实现双工通信了,那么为什么还需要WebSocket呢?

  1. HTTP/2 的双向通信是 “有限双工”
    • 基于请求 - 响应模型:客户端必须先发起请求(如 GET),服务器才能回复响应。即使 HTTP/2 允许在同一连接上交错传输多个请求和响应(多路复用),但服务器无法主动发起通信(例如推送实时消息)。
    • 服务器推送(Server Push)的局限性:服务器只能在客户端请求资源时,预先推送相关资源(如 HTML 中的 CSS、JS 文件),但无法主动发送任意数据(如聊天消息、实时通知)。
      • 客户端需要通过 Link 头或 PUSH_PROMISE 帧明确告知服务器哪些资源可以推送。
  2. WebSocket 是 “全双工”
    • 无请求 - 响应限制:连接建立后,客户端和服务器可以随时主动发送数据(如服务器主动推送消息,客户端无需等待请求)。
    • 持久化连接:一次握手后保持长连接,无需频繁断开和重连。
    • 轻量级协议:
      • 数据帧结构简单(仅 2 字节头部 + 有效载荷),无冗余的 HTTP 头部。
      • 支持二进制数据传输,适合实时音视频流等场景。

总结

HTTP/2 是 HTTP 协议的性能优化,而 WebSocket 是专为实时双向通信设计的独立协议。两者并非竞争关系,而是在不同场景下发挥作用:

  • HTTP/2:优化传统 Web 应用的传输效率(如网页加载、文件下载)。
  • WebSocket:解决 HTTP/2 无法满足的实时通信需求(如聊天、游戏、物联网)。

实际开发中,常结合两者使用:HTTP/2 用于传输静态资源,WebSocket 用于实时数据交互,以实现高效、低延迟的 Web 应用。

WebSocket解释

WebSocket 是基于 TCP 的一种新的应用层网络协议。它提供了一个全双工的通道,允许服务器和客户端之间实时双向通信。因此,在 WebSocket 中,浏览器和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输,客户端和服务器之间的数据交换变得更加简单。

优点:

  1. 全双工通信:客户端与服务器可实时双向主动发送数据(区别于 HTTP 的请求 - 响应单向模式)。
  2. 一次握手建立持久连接:通过 HTTP 握手(Upgrade: websocket 头)升级为 WebSocket 连接,后续通信无需重复握手,节省网络开销(Websocket一次握手不是客户端和服务端只需一次握手,而是在 TCP 连接已建立的前提下,通过一次 HTTP 请求和响应完成协议升级)。
  3. 长连接特性:连接持续存在,适合实时场景(如聊天、直播、实时通知),避免 HTTP 短连接的频繁建联损耗。

缺点:

  1. 不支持无连接: WebSocket 是一种持久化的协议,这意味着连接不会在一次请求之后立即断开。这是有利的,因为它消除了建立连接的开销,但是也可能导致一些资源泄漏的问题。
  2. 数据流不兼容: WebSocket 的数据流格式与 HTTP 不同,这意味着在不同的网络环境下,WebSocket 的表现可能会有所不同。

在这里插入图片描述

WebSocket原理

HTTP 的生命周期通过 Request 来界定,也就是一个 Request 一个 Response ,那么在 HTTP1.0 中,这次 HTTP 请求就结束了。

在 HTTP1.1 中进行了改进,使得有一个 keep-alive,也就是说,在一个 HTTP 连接中,可以发送多个 Request,接收多个 Response。但是请记住 Request = Response, 在 HTTP 中永远是这样,也就是说一个 Request 只能有一个 Response。而且这个 Response 也是被动的,不能主动发起

首先 WebSocket 是基于 HTTP 协议的,或者说借用了 HTTP 协议来完成一部分握手。

前面提到,WebSocket复用了HTTP的握手通道。具体指的是,客户端通过HTTP请求与WebSocket服务端协商升级协议。协议升级完成后,后续的数据交换则遵照WebSocket的协议。

1、客户端:申请协议升级

首先,客户端发起协议升级请求。可以看到,采用的是标准的HTTP报文格式,且只支持GET方法。

1
2
3
4
5
6
7
GET / HTTP/1.1
Host: localhost:8080
Origin: http://127.0.0.1:3000
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Version: 13
Sec-WebSocket-Key: w4v7O6xFTi36lq3RNcgctw==

重点请求首部意义如下:

  • Connection: Upgrade:表示要升级协议
  • Upgrade: websocket:表示要升级到websocket协议。
  • Sec-WebSocket-Version: 13:表示websocket的版本。如果服务端不支持该版本,需要返回一个Sec-WebSocket-Versionheader,里面包含服务端支持的版本号。
  • Sec-WebSocket-Key:与后面服务端响应首部的Sec-WebSocket-Accept是配套的,提供基本的防护,比如恶意的连接,或者无意的连接。

注意,上面请求省略了部分非重点请求首部。由于是标准的HTTP请求,类似Host、Origin、Cookie等请求首部会照常发送。在握手阶段,可以通过相关请求首部进行 安全限制、权限校验等。

2、服务端:响应协议升级

服务端返回内容如下,状态代码101表示协议切换。到此完成协议升级,后续的数据交互都按照新的协议来。

1
2
3
4
HTTP/1.1 101 Switching Protocols
Connection:Upgrade
Upgrade: websocket
Sec-WebSocket-Accept: Oy4NRAQ13jhfONC7bP8dTKb4PTU=

备注:每个header都以\r\n结尾,并且最后一行加上一个额外的空行\r\n。此外,服务端回应的HTTP状态码只能在握手阶段使用。过了握手阶段后,就只能采用特定的错误码。

3、Sec-WebSocket-Accept的计算

Sec-WebSocket-Accept根据客户端请求首部的Sec-WebSocket-Key计算出来。

计算公式为:

  1. Sec-WebSocket-Key258EAFA5-E914-47DA-95CA-C5AB0DC85B11拼接。
  2. 通过SHA1计算出摘要,并转成base64字符串。

伪代码如下:

1
>toBase64( sha1( Sec-WebSocket-Key + 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 )  )

Sec-WebSocket-Key/Accept的作用

前面提到了,Sec-WebSocket-Key/Sec-WebSocket-Accept在主要作用在于提供基础的防护,减少恶意连接、意外连接。

作用大致归纳如下:

  1. 避免服务端收到非法的websocket连接
  2. 确保服务器不是普通 HTTP 服务器,而是支持 WebSocket 升级的合法端点。例如,若一个 HTTP 服务器收到 WebSocket 握手请求,因无法生成正确的 Sec-WebSocket-Accept,会返回错误响应,客户端则不会建立 WebSocket 连接。
  3. Sec-WebSocket-Key主要目的并不是确保数据的安全性,因为Sec-WebSocket-Key、Sec-WebSocket-Accept的转换计算公式是公开的,而且非常简单,最主要的作用是预防一些常见的意外情况(非故意的)。

强调:Sec-WebSocket-Key/Sec-WebSocket-Accept 的换算,只能带来基本的保障,但连接是否安全、数据是否安全、客户端/服务端是否合法的 ws客户端、ws服务端,其实并没有实际性的保证。

WebSocket工作流程

  • 握手阶段

    • 客户端发送 HTTP 请求,包含 Sec-WebSocket-KeyUpgrade: websocket 头。
    • 服务器解析请求,验证 Upgrade 头,计算 Sec-WebSocket-Accept(计算方式是公开的,只是为了预防收到非法的websocket连接),并返回包含该字段的 HTTP 响应(状态码 101 Switching Protocols)。
    • 客户端验证 Sec-WebSocket-Accept 是否正确,正确则建立 WebSocket 连接,否则失败。
  • 数据传输阶段

    • 客户端向服务端发送数据,服务端收到数据后将其转发给其他客户端。

    • 服务端向客户端发送数据,客户端收到数据后进行处理。

双方如何进行相互传输数据的,具体的数据格式是怎么样的呢?

WebSocket 的每条消息可能会被切分成多个数据帧(最小单位)。发送端会将消息切割成多个帧发送给接收端,接收端接收消息帧,并将关联的帧重新组装成完整的消息。

发送方 -> 接收方:ping。

接收方 -> 发送方:pong。

ping 、pong 的操作,对应的是 WebSocket 的两个控制帧

  • 关闭阶段

    • 客户端向服务端发送关闭请求,请求中包含一个WebSocket的随机密钥。

    • 服务端接收到关闭请求后,向客户端发送关闭响应,关闭响应中包含服务端生成的随机密钥。

    • 客户端收到关闭响应后,关闭WebSocket连接。

总的来说,WebSocket通过握手阶段、数据传输阶段和关闭阶段实现了服务器和客户端之间的实时双向通信。

17. 二进制数据可以通过\0和\n进行粘包处理吗

在二进制数据传输中,不能直接使用 \0(空字符)或 \n(换行符)进行粘包处理,原因如下:

  • 二进制数据(如图像、文件、自定义协议数据)是 字节流,可能包含任意字节(包括 \0\n、甚至不可打印字符)。
    • 例如:一张 PNG 图片中可能自然包含 0x00(对应 \0)或 0x0A(对应 \n),若用这些字符作为分隔符,会被误判为消息边界,导致解析错误。
    • 二进制数据允许任意字节组合,而 \0\n 是普通字节(ASCII 编码分别为 0x000x0A),无法保证在数据中 “绝对不存在”,导致解包逻辑不可靠。

那么二进制数据如何处理粘包?

  1. TLV编码:在每个消息前添加一个 固定长度的字段(如 4 字节整数),表示后续消息体的字节长度
  2. 定长消息缓存区:每个消息的长度固定(不足的填充\0),接收方按固定长度读取。
  3. 自定义分隔符(需确保分隔符不在数据中出现):选择一个 不会出现在数据中的特殊字节序列(如 0xAA55 或更长的魔数)作为分隔符

总结

  • 文本数据(如 JSON、XML):可使用 \0\n\r\n 作为分隔符(数据中天然不含或可转义处理)处理粘包问题。
  • 二进制数据:必须使用 长度前缀法自定义魔数分隔符,禁止依赖 \0\n 等普通字节,否则会因数据中包含这些字符而导致解包错误。

18. TCP中超时重传时间是如何计算的?

超时重传时间RTO的值应该略大于报文往返RTT的值,且是动态变化的。

RTT 指的是数据发送时刻到接收到确认的时刻的差值,也就是包的往返时间。

RTT

RTO应该略大于RTT,且是动态变化的,如果RTO过大或者过小,可能会导致以下情况:

  • 当超时时间 RTO 较大时,重发就慢,丢了老半天才重发,没有效率,性能差;
  • 当超时时间 RTO 较小时,会导致可能并没有丢就重发,于是重发的就快,会增加网络拥塞,导致更多的超时,更多的超时导致更多的重发。

至此,可能大家觉得超时重传时间 RT0的值计算,也不是很复杂嘛。

好像就是在发送端发包时记下 t0,然后接收端再把这个 ack回来时再记一个 t1,于是 RTT=t1 - t0 。没那么简单,这只是一个采样,不能代表普遍情况。

实际上「报文往返 RTT 的值」是经常变化的,因为我们的网络也是时常变化的。也就因为「报文往返 RTT的值」 是经常波动变化的,所以「超时重传时间 RTO 的值」应该是一个动态变化的值。

我们来看看 Linux 是如何计算 RTO 的呢?

估计往返时间,通常需要采样以下两个:

  • 需要 TCP 通过采样 RTT 的时间,然后进行加权平均,算出一个平滑 RTT 的值,而且这个值还是要不断变化的,因为网络状况不断地变化。
  • 除了采样 RTT,还要采样 RTT 的波动范围,这样就避免如果 RTT 有一个大的波动的话,很难被发现的情况。

RTO的计算公式如下:

RFC6289 建议的 RTO 计算

其中 SRTT 是计算平滑的RTT, DevRTR 是计算平滑的RTT 与 最新 RTT 的差距。

在 Linux 下,α=0.125,β=0.25,μ=1,ā=4。别问怎么来的,问就是大量实验中调出来的。

如果超时重发的数据,再次超时的时候,又需要重传的时候,TCP 的策略是超时间隔加倍。也就是每当遇到一次超时重传的时候,都会将下一次超时时间间隔设为先前值的两倍。两次超时,就说明网络环境差,不宜频繁反复发送。

19. 对称加密/非对称加密/哈希算法

  • 对称加密算法,也称为私钥加密算法,是指加密和解密使用相同密钥的加密算法。
    • 常用算法:
      • AES(高级加密标准):当前主流,支持 128/192/256 位密钥,安全性高,广泛用于数据传输(如 HTTPS 中的 TLS 协议)。
      • ChaCha20:轻量级算法,适合移动端或资源受限场景。
    • 应用场景:
      • 数据传输加密:HTTPS 中用 AES 加密 HTTP 请求 / 响应体。
      • 文件加密:本地文件加密(如 TrueCrypt)。
      • VPN:加密网络流量(如 OpenVPN 默认用 AES)。
    • 优缺点:
      • 优点速度快、效率高,适合处理大数据。
      • 缺点:密钥丢失会造成数据泄露风险。
  • 非对称加密:使用一对关联密钥(公钥 + 私钥) 的算法,公钥(Public Key):公开,用于加密数据或验证签名。私钥(Private Key):保密,用于解密数据或生成签名。
    • 常用算法:
      • RSA:最经典,用于密钥交换和数字签名(如 HTTPS 证书中的公钥),密钥长度需≥2048 位(1024 位已不安全)。
      • ECC(椭圆曲线加密):同等安全性下密钥更短(如 256 位 ECC≈3072 位 RSA),适合移动设备和物联网。
      • Diffie-Hellman(DH):不直接加密,用于安全协商对称密钥(如 TLS 握手阶段生成共享密钥)。
    • 应用场景:
      • 密钥交换:HTTPS 中服务器用公钥加密随机数(客户端生成的对称密钥),服务器私钥解密后双方共享对称密钥。
      • 数字签名:发送方用私钥对数据哈希值签名,接收方用公钥验证签名,确保数据未篡改且来源可信(如软件签名、区块链)。
      • 身份认证:客户端通过公钥验证服务器证书(CA 用私钥签名的证书)。
    • 优缺点:
      • 优点无需提前共享密钥,适合公开网络通信;支持数字签名和身份认证。
      • 缺点速度慢,仅用于加密少量数据(如对称密钥);私钥丢失后无法恢复。
  • 摘要算法:将任意长度输入映射为固定长度输出(哈希值 / 摘要) 的单向函数
    • 特点:
      • 单向性:无法通过哈希值反推原始数据
      • 唯一性:不同输入大概率生成不同哈希值
      • 定长输出:无论输入多长,输出长度固定
    • 应用场景:
      • 数据完整性校验:下载文件时对比哈希值(如 Linux 镜像的 SHA-256 校验)。
      • 密码存储:存储密码的哈希值而非明文(加盐后更安全,如 bcrypt、SHA-256 加盐)。
      • 数字签名基础:先对数据哈希,再用私钥对哈希值签名(因哈希值固定且远短于原文,提升效率)。
    • 常用算法:
      • SHA-256:SHA-2 系列,当前主流,用于 HTTPS、区块链(如比特币)、文件校验,输出 256 位。
      • MD5:曾广泛使用,但已被证明可碰撞(不同输入生成相同哈希值),安全性不足,HTTPS 中禁用。
    • 优缺点:
      • 优点:快速生成摘要,高效校验数据完整性;单向性保障数据不可还原。
      • 缺点:无法加密数据(仅用于完整性);需防范碰撞攻击(选择安全算法如 SHA-256 及以上)。

20. 梯子为什么可以让我们跳过防火墙

防火墙

防火墙就像网络世界的 “关卡”,会按规则检查每一份数据:

  • 封 IP / 端口:直接禁止访问被标记的服务器地址(如国外网站 IP),或封锁特定通信端口(如限制访问境外服务器常用的端口)。
  • 查内容:解析数据内容,比如拦截包含敏感关键词的网页请求,或阻断特定协议(如 P2P、未经允许的加密流量)。
  • 改 DNS:篡改域名解析结果,让你输入 “谷歌.com” 却连到错误的地址,根本找不到真实服务器。

VPN

  1. 加密通道:把数据变成 “乱码”
    它把你的上网数据(比如访问视频网站的请求)用高强度加密技术 “打包”,防火墙看到的只是无意义的二进制数据,像一堆乱码,无法识别你要访问的是被封锁的网站。
  2. 借道中转站:绕开 IP 封锁
    你先连接到梯子的 “中间服务器”(比如位于香港、日本的合法服务器),再由这台服务器代替你访问目标网站。防火墙只能看到你在和中间服务器通信(IP 未被封锁),而中间服务器和目标网站的交互在防火墙之外,管不着。
  3. 伪装成 “合法流量”
    梯子会把数据伪装成防火墙允许的形式,比如通过 443 端口(HTTPS 专用端口)传输,让防火墙误以为是正常的加密网页浏览;或者模仿常见协议(如 WebSocket),让检查系统分不清是普通上网还是 “翻墙”。

为什么防火墙不一刀切

防火墙有个矛盾:既要封锁违规流量,又要保证正常上网功能(比如不能禁止所有 HTTPS 流量,否则你连银行网站都打不开)。梯子正是利用了这一点:

  • 加密让防火墙 “看不懂” 内容,只能放行;
  • 走常用端口(80/443)让防火墙 “不敢乱封”,怕误伤正常网站;
  • 中间服务器用合法 IP(比如云服务商的地址),防火墙无法全部拦截。

21. 内存泄漏如何检测

C++相比于其他语言,内存需要开发者自行申请和释放,如果操作不当就容易造成内存泄漏。导致虚拟内存消耗越来越多,堆内存逐渐被消耗完导致程序崩溃。

  1. 分配的内存没有手动释放,可通过智能指针进行管理,对于类对象,可以通过RAII技术
  2. 智能指针的循环引用,通过引入weak_ptr解决
  3. 基类的析构函数没有定义成虚函数,举例来说就是,一个基类的指针指向一个派生类的对象,在使用完毕准备销毁时,如果基类的析构函数没有定义成虚函数,那 么编译器根据指针类型就会认为当前对象的类型是基类,调用基类的析构函数 (该对象的析构函数的函数地址早就被绑定为基类的析构函数),仅执行基类的析构,派生类的自身内容将无法被析构,造成内存泄漏。解决方法很简单,基类析构定义为虚函数即可。

出现内存泄漏的主要可能大概是以上三种,但是还有一些特殊的,比如STL内存分配后不会归还堆中,而是归还给STL的空间配置器。我们平常可以使用sTL中的对象代替数组,比如string和vector、array会自行管理内存,可以用来代替数组

如何排查?

  1. 笨方法,日志。每次分配内存的时候将指针地址打印出来,释放的时候也打印内存,然后再程序结束后,通过分配和释放的差(如果分配的条数大于释放的条数),基本就可以确定出现内存泄漏,然后根据日志的地址进行定位
  2. 统计,也就是日志的具体实现,创建三个自定义函数,一个用于内存分配,第二个用于内存释放,最后一个用于检查内存泄漏
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
static unsigned int allocated  = 0;
static unsigned int deallocated = 0;
void *Memory_Allocate (size_t size)
{
void *ptr = NULL;
ptr = malloc(size);
if (NULL != ptr) {
++allocated;
} else {
//Log error
}
return ptr;
}
void Memory_Deallocate (void *ptr) {
if(pvHandle != NULL) {
free(ptr);
++deallocated;
}
}
int Check_Memory_Leak(void) {
int ret = 0;
if (allocated != deallocated) {
//Log error
ret = MEMORY_LEAK;
} else {
ret = OK;
}
return ret;
}
  1. 使用工具。在Linux上比较常用的内存泄漏检测工具是valgrind,所以咱们就以valgrind为工具,进行检测。

我们首先看一段代码:

1
2
3
4
5
6
7
8
9
10
#include <stdlib.h>

void func (void){
char *buff = (char*)malloc(10);
}

int main (void){
func(); // 产生内存泄漏
return 0;
}

通过gcc -g leak.c -o leak命令进行编译

执行valgrind --leak-check=full ./leak

在上述的命令执行后,会输出如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
==9652== Memcheck, a memory error detector
==9652== Copyright (C) 2002-2017, and GNU GPL'd, by Julian Seward et al.
==9652== Using Valgrind-3.15.0 and LibVEX; rerun with -h for copyright info
==9652== Command: ./leak
==9652==
==9652==
==9652== HEAP SUMMARY:
==9652== in use at exit: 10 bytes in 1 blocks
==9652== total heap usage: 1 allocs, 0 frees, 10 bytes allocated
==9652==
==9652== 10 bytes in 1 blocks are definitely lost in loss record 1 of 1
==9652== at 0x4C29F73: malloc (vg_replace_malloc.c:309)
==9652== by 0x40052E: func (leak.c:4)
==9652== by 0x40053D: main (leak.c:8)
==9652==
==9652== LEAK SUMMARY:
==9652== definitely lost: 10 bytes in 1 blocks
==9652== indirectly lost: 0 bytes in 0 blocks
==9652== possibly lost: 0 bytes in 0 blocks
==9652== still reachable: 0 bytes in 0 blocks
==9652== suppressed: 0 bytes in 0 blocks
==9652==
==9652== For lists of detected and suppressed errors, rerun with: -s
==9652== ERROR SUMMARY: 1 errors from 1 contexts (suppressed: 0 from 0)

valgrind的检测信息将内存泄漏分为如下几类:

definitely lost:确定产生内存泄漏

indirectly lost:间接产生内存泄漏

possibly lost:可能存在内存泄漏

still reachable:即使在程序结束时候,仍然有指针在指向该块内存,常见于全局变量

主要上面输出的下面几句:

1
2
==9652==    by 0x40052E: func (leak.c:4)
==9652== by 0x40053D: main (leak.c:8)

提示在main函数(leak.c的第8行)fun函数(leak.c的第四行)产生了内存泄漏,通过分析代码,原因定位,问题解决。

valgrind不仅可以检测内存泄漏,还有其他很强大的功能,由于本文以内存泄漏为主,所以其他的功能就不在此赘述了,有兴趣的可以通过valgrind --help来进行查看

valgrind的检测原理

  • 基于影子内存:Valgrind 为程序中的每一块内存分配了对应的影子内存。影子内存中的每个字节对应着程序实际内存中的一个区域(通常是 8 字节或 16 字节)。通过影子内存来记录实际内存的状态,比如是否被初始化、是否可读可写等。当程序访问内存时,Valgrind 会检查影子内存中对应的状态信息。如果程序试图访问未初始化的内存,或者进行非法的读写操作,Valgrind 就会根据影子内存的状态检测到这些错误,并报告给用户。
  • 拦截系统调用和库函数:Valgrind 会拦截程序对系统调用和一些标准库函数的调用,如mallocfreenewdelete等。在这些函数被调用时,Valgrind 会进行额外的检查和记录。例如,在调用malloc分配内存时,Valgrind 会记录下分配的内存地址和大小,并在影子内存中标记相应区域为已分配且未初始化。当调用free释放内存时,Valgrind 会更新影子内存的状态,标记该区域为可用。通过这种方式,Valgrind 可以准确地跟踪内存的分配和释放情况,检测出内存泄漏、重复释放等错误。

Valgrind 是在程序运行期发挥作用的,在程序执行过程中对其进行监测和分析。

22. QT可以跨平台吗,原理是什么

Qt 可以跨平台。它能够在多种操作系统上运行,如 Windows、Linux、macOS、Android、iOS 等,甚至在一些嵌入式系统中也有广泛应用。

  • 中间层与平台插件:Qt 在底层和应用程序之间提供了一个中间层,称为 Qt 平台插件。这个中间层(每个操作系统的中间层都不同)负责与具体的操作系统进行交互,并向上提供统一的接口给 Qt 框架。
  • 抽象层与封装:Qt 通过一系列的类和接口,将底层操作系统的差异进行了抽象和封装。例如,对于不同操作系统的窗口管理、图形绘制、事件处理等功能,Qt 提供了统一的 QWidget、QPainter、QEvent 等类来进行操作。开发者使用这些统一的类来编写代码,而不必直接面对不同操作系统的具体实现细节,从而实现了代码的跨平台性。

23. QT的槽函数是什么,如何触发

槽函数本质上是类的成员函数,不过它有特殊之处,可与信号建立连接。信号发出时,与之相连的槽函数会被调用。槽函数的定义和普通成员函数类似,但需在类定义中使用特定的宏声明,而且其访问权限可以是publicprotected或者private

1. 使用 QObject::connect 函数连接信号和槽

这是最常用的触发槽函数的方式。借助 QObject::connect 函数把信号和槽连接起来,当信号发出时,对应的槽函数就会被调用。

2. 使用 Lambda 表达式作为槽

从 Qt 5 开始,你可以使用 Lambda 表达式作为槽函数

3. 手动触发信号

在某些情况下,你可以手动调用信号,从而触发与之相连的槽函数

24. CPU利用率不高(占用率过高),如何排查

当CPU利用率不高但是占用率过高时,说明虽然系统中有很少的进程需要CPU处理,但现有的进程却消耗了大量的CPU资源。这种情况通常发生在单个进程占用大量CPU时,或者系统存在调度问题。

不管什么问题,既然是CPU飙升,肯定是查一下耗CPU的线程,然后看看GC。

核心排查步骤

1.执行top -c或者 ps axj | grep 文件名”命令:查看所有进程占系统CPU的排序。(COMMAND列)。PID那一列就是进程号。

2.执行top -H -p pid进程号”命令:查看该进程下的所有线程占CPU的情况。

3.执行printf "%x\n" 12346 # 输出 0x3036(用于后续堆栈匹配)命令 :后续查看线程堆栈信息展示的都是十六进制,为了找到咱们的线程堆栈信息,咱们需要把线程号转成16进制

4.执行gdb -p 12345或者pstack 12345 | grep 线程id获取线程堆栈信息,查看load过高原因,定位到具体函数

问题查询

  1. 如果是多线程并发的话可能是因为自旋锁或者活锁造成的,这时候我们需要将其换为互斥锁或者通过条件变量使其挂起
  2. 如果是互斥锁使用不当,导致死锁
  3. 代码复杂度过高,某些复杂算法,甚至算法BUG,无限循环递归等等,修改算法即可
  4. 频繁的内存分配释放导致内存碎片化严重,导致分配内存时搜索合适的内存块比较耗时,采用内存池的方法解决内存碎片
  5. 内存泄漏导致内存持续增长
  6. 比如I/O操作过多。I/O操作过多问题中最常见的一类是程序打印了过多的日志。曾经在后羿系统就发生这样的例子,由于程序输出的日志从二进制升级为了字符串,整体的I/O量增加了30%,导致程序的吞吐量从3.8万降低到了2.1万,几乎下降一半。还有一个典型的I/O问题是程序中有很多的DEBUG日志,虽然最终在线上没有开启DEBUG日志打印,但是程序在运行过程中还是会走到DEBUG日志相关的程序逻辑,只是不进行日志的输出。如果在日志输出的地方,存在复杂的计算逻辑,那么程序的性能也下降。

25. 死锁如何检测

在 Linux 下,我们可以使用 pstack + gdb 工具来定位死锁问题。

pstack 命令可以显示每个线程的栈跟踪信息(函数调用过程),它的使用方式也很简单,只需要 pstack <pid> 就可以了。

这个命令在排查进程问题时非常有用,比如我们发现一个服务一直处于work状态(如假死状态,好似死循环),使用这个命令就能轻松定位问题所在;可以在一段时间内,多执行几次pstack,若发现代码栈总是停在同一个位置,那个位置就需要重点关注,很可能就是出问题的地方;

那么,在定位死锁问题时,我们可以多次执行 pstack 命令查看线程的函数调用过程,多次对比结果,确认哪几个线程一直没有变化,且是因为在等待锁,那么大概率是由于死锁问题导致的。

我用 pstack 输出了我前面模拟死锁问题的进程的所有线程的情况,我多次执行命令后,其结果都一样,如下:

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
$ ps -u
$ pstack 87746
Thread 3 (Thread 0x7f60a610a700 (LWP 87747)):
#0 0x0000003720e0da1d in __lll_lock_wait () from /lib64/libpthread.so.0
#1 0x0000003720e093ca in _L_lock_829 () from /lib64/libpthread.so.0
#2 0x0000003720e09298 in pthread_mutex_lock () from /lib64/libpthread.so.0
#3 0x0000000000400725 in threadA_proc ()
#4 0x0000003720e07893 in start_thread () from /lib64/libpthread.so.0
#5 0x00000037206f4bfd in clone () from /lib64/libc.so.6
Thread 2 (Thread 0x7f60a5709700 (LWP 87748)):
#0 0x0000003720e0da1d in __lll_lock_wait () from /lib64/libpthread.so.0
#1 0x0000003720e093ca in _L_lock_829 () from /lib64/libpthread.so.0
#2 0x0000003720e09298 in pthread_mutex_lock () from /lib64/libpthread.so.0
#3 0x0000000000400792 in threadB_proc ()
#4 0x0000003720e07893 in start_thread () from /lib64/libpthread.so.0
#5 0x00000037206f4bfd in clone () from /lib64/libc.so.6
Thread 1 (Thread 0x7f60a610c700 (LWP 87746)):
#0 0x0000003720e080e5 in pthread_join () from /lib64/libpthread.so.0
#1 0x0000000000400806 in main ()

....

$ pstack 87746
Thread 3 (Thread 0x7f60a610a700 (LWP 87747)):
#0 0x0000003720e0da1d in __lll_lock_wait () from /lib64/libpthread.so.0
#1 0x0000003720e093ca in _L_lock_829 () from /lib64/libpthread.so.0
#2 0x0000003720e09298 in pthread_mutex_lock () from /lib64/libpthread.so.0
#3 0x0000000000400725 in threadA_proc ()
#4 0x0000003720e07893 in start_thread () from /lib64/libpthread.so.0
#5 0x00000037206f4bfd in clone () from /lib64/libc.so.6
Thread 2 (Thread 0x7f60a5709700 (LWP 87748)):
#0 0x0000003720e0da1d in __lll_lock_wait () from /lib64/libpthread.so.0
#1 0x0000003720e093ca in _L_lock_829 () from /lib64/libpthread.so.0
#2 0x0000003720e09298 in pthread_mutex_lock () from /lib64/libpthread.so.0
#3 0x0000000000400792 in threadB_proc ()
#4 0x0000003720e07893 in start_thread () from /lib64/libpthread.so.0
#5 0x00000037206f4bfd in clone () from /lib64/libc.so.6
Thread 1 (Thread 0x7f60a610c700 (LWP 87746)):
#0 0x0000003720e080e5 in pthread_join () from /lib64/libpthread.so.0
#1 0x0000000000400806 in main ()

可以看到,Thread 2 和 Thread 3 一直阻塞获取锁(pthread_mutex_lock)的过程,而且 pstack 多次输出信息都没有变化,那么可能大概率发生了死锁。

但是,还不能够确认这两个线程是在互相等待对方的锁的释放,因为我们看不到它们是等在哪个锁对象,于是我们可以使用 gdb 工具进一步确认。

整个 gdb 调试过程,如下:

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
// gdb 命令
$ gdb -p 87746

// 打印所有的线程信息
(gdb) info thread
3 Thread 0x7f60a610a700 (LWP 87747) 0x0000003720e0da1d in __lll_lock_wait () from /lib64/libpthread.so.0
2 Thread 0x7f60a5709700 (LWP 87748) 0x0000003720e0da1d in __lll_lock_wait () from /lib64/libpthread.so.0
* 1 Thread 0x7f60a610c700 (LWP 87746) 0x0000003720e080e5 in pthread_join () from /lib64/libpthread.so.0
//最左边的 * 表示 gdb 锁定的线程,切换到第二个线程去查看

// 切换到第2个线程
(gdb) thread 2
[Switching to thread 2 (Thread 0x7f60a5709700 (LWP 87748))]#0 0x0000003720e0da1d in __lll_lock_wait () from /lib64/libpthread.so.0

// bt 可以打印函数堆栈,却无法看到函数参数,跟 pstack 命令一样
(gdb) bt
#0 0x0000003720e0da1d in __lll_lock_wait () from /lib64/libpthread.so.0
#1 0x0000003720e093ca in _L_lock_829 () from /lib64/libpthread.so.0
#2 0x0000003720e09298 in pthread_mutex_lock () from /lib64/libpthread.so.0
#3 0x0000000000400792 in threadB_proc (data=0x0) at dead_lock.c:25
#4 0x0000003720e07893 in start_thread () from /lib64/libpthread.so.0
#5 0x00000037206f4bfd in clone () from /lib64/libc.so.6

// 打印第三帧信息,每次函数调用都会有压栈的过程,而 frame 则记录栈中的帧信息
(gdb) frame 3
#3 0x0000000000400792 in threadB_proc (data=0x0) at dead_lock.c:25
27 printf("thread B waiting get ResourceA \n");
28 pthread_mutex_lock(&mutex_A);

// 打印mutex_A的值 , __owner表示gdb中标示线程的值,即LWP
(gdb) p mutex_A
$1 = {__data = {__lock = 2, __count = 0, __owner = 87747, __nusers = 1, __kind = 0, __spins = 0, __list = {__prev = 0x0, __next = 0x0}},
__size = "\002\000\000\000\000\000\000\000\303V\001\000\001", '\000' <repeats 26 times>, __align = 2}

// 打印mutex_B的值 , __owner表示gdb中标示线程的值,即LWP
(gdb) p mutex_B
$2 = {__data = {__lock = 2, __count = 0, __owner = 87748, __nusers = 1, __kind = 0, __spins = 0, __list = {__prev = 0x0, __next = 0x0}},
__size = "\002\000\000\000\000\000\000\000\304V\001\000\001", '\000' <repeats 26 times>, __align = 2}

我来解释下,上面的调试过程:

  1. 通过 info thread 打印了所有的线程信息,可以看到有 3 个线程,一个是主线程(LWP 87746),另外两个都是我们自己创建的线程(LWP 87747 和 87748);
  2. 通过 thread 2,将切换到第 2 个线程(LWP 87748);
  3. 通过 bt,打印线程的调用栈信息,可以看到有 threadB_proc 函数,说明这个是线程 B 函数,也就说 LWP 87748 是线程 B;
  4. 通过 frame 3,打印调用栈中的第三个帧的信息,可以看到线程 B 函数,在获取互斥锁 A 的时候阻塞了;
  5. 通过 p mutex_A,打印互斥锁 A 对象信息,可以看到它被 LWP 为 87747(线程 A) 的线程持有着;
  6. 通过 p mutex_B,打印互斥锁 A 对象信息,可以看到他被 LWP 为 87748 (线程 B) 的线程持有着;

因为线程 B 在等待线程 A 所持有的 mutex_A, 而同时线程 A 又在等待线程 B 所拥有的mutex_B, 所以可以断定该程序发生了死锁。

26. 使用协程是因为协程更轻量化,但如果机器跑不满,进程和线程和协程又有什么区别?

即使机器跑不慢,协程的 “用户态调度” 和 “超高并发承载能力” 仍是核心优势,例如在处理大量轻量级任务(如事件驱动的回调)时,协程的代码逻辑更简洁(避免回调地狱),且无需担心线程切换开销。

27. 进程在Linux里调度用了什么算法?

【原创】(五)Linux进程调度-CFS调度器 - LoyenWang - 博客园

进程有很多调度算法,比如先来先服务调度算法、最短作业优先调度算法、高响应比调度算法、时间片轮转调度算法、最高优先级调度算法。

但在linux中,进程的调度算法是完全公平调度器(Completely Fair Scheduler, CFS),主要用于普通进程(非实时进程)。CFS采用了红黑树算法来管理所有的调度实体sched_entity,算法效率为O(log(n))CFS跟踪调度实体sched_entity的虚拟运行时间vruntime,平等对待运行队列中的调度实体sched_entity,将执行时间少的调度实体sched_entity排列到红黑树的左边。

img

  • 每个sched_latency周期内,根据各个任务的权重值,可以计算出运行时间runtime
  • 运行时间runtime可以转换成虚拟运行时间vruntime
  • 根据虚拟运行时间的大小,插入到CFS红黑树中,虚拟运行时间少的调度实体放置到左边;
  • 在下一次任务调度的时候,选择虚拟运行时间少的调度实体来运行

28. app发生卡死有哪些情况?

  1. 主线程阻塞
  2. 死锁/活锁/资源饥饿
  3. 内存访问错误/内存泄漏
  4. 无限循环/递归深度溢出

29. 浏览器的每个标签页是一个线程还是一个进程,为什么是进程,还有没有其他方面能体现进程之间隔离性的优点

现代主流浏览器(如 Chrome、Firefox)的每个标签页通常对应 独立的进程(而非线程),这种设计选择主要源于 进程隔离性 的核心优势。

核心原因:

  • 进程隔离:让每个标签页在独立进程中运行,某标签页的渲染引擎(如 Blink)或 JS 引擎(如 V8)崩溃时,仅终止该进程,其他标签页和浏览器主进程仍可正常工作。
  • 安全性:标签页可能加载恶意网页(如利用缓冲区溢出漏洞),进程沙箱技术(如 Chrome 的 Renderer Process Sandbox)可限制其权限(如禁止直接访问文件系统、网络),恶意代码只能在当前进程内活动,无法突破隔离边界
  • 资源控制:每个进程可独立设置资源配额(如 CPU 时间、内存上限),避免单个标签页因无限循环或内存泄漏拖垮整个浏览器(例如某标签页占用 100% CPU 时,其他标签页仍可流畅运行)。

为什么不使用线程?

  • 内存共享风险:线程共享地址空间,一个线程的内存越界或野指针操作可能破坏其他线程的数据,导致整个浏览器崩溃(如早期 Firefox 单线程渲染的隐患)。
  • 调度复杂性:多线程需精细管理锁和同步机制,容易引发死锁或竞态条件(如多个线程同时修改渲染数据),而进程天然无需考虑此类问题。
  • 权限隔离困难:线程无法单独限制系统调用权限(如允许某个线程访问文件系统,禁止另一个),而进程可通过沙箱轻松实现权限分级。

30. 如果webserver用多线程,开了一个线程池,每个线程服务一个链接,现在大部分线程都发生了阻阳塞(慢链接),线程池的线程都用满了就处理不了新的链接了,此时服务端cpu很空,这个时候没办法处理其他请求,有什么办法能够解决这个问题?

  1. 动态调整线程池大小:可以根据系统负载和请求情况动态地增加或减少线程池中的线程数量。例如,当检测到线程池中的线程大部分处于阻塞状态,且有新的请求不断积压时,适当增加线程数量。
  2. 采用异步I/O而不是同步I/O,将一些非关键的任务(如日志记录、数据统计)异步处理,避免阻塞主线程。可以使用消息队列将这些任务发送到后台处理程序中。
  3. 分布式架构,将服务拆分为多个微服务,每个微服务可以独立部署和扩展。通过消息队列(如 Kafka、RabbitMQ)将请求进行解耦和分发,提高系统的整体处理能力。

31. 什么时候使用异步IO,什么时候使用同步IO

因素 同步 IO 异步 IO
编程复杂度 简单(顺序执行,易于调试) 复杂(回调、Promise、协程,需处理异步状态)
并发模型 线程池(每个连接一个线程,易阻塞) 事件驱动 / 协程(少量线程处理大量连接)
资源占用 高(线程数多,内存 / 上下文切换开销大) 低(线程数少,资源利用率高)
适用任务类型 CPU 密集型、低并发、顺序执行任务 IO 密集型、高并发、大量等待任务
延迟与吞吐量 适合低并发下的低延迟 适合高并发下的高吞吐量
典型场景 本地工具、简单 API 服务 Web 服务器、实时通信、分布式系统
  • 选同步 IO:任务简单、CPU 密集、并发低、需要顺序执行,或异步编程成本过高。
  • 选异步 IO:IO 密集、高并发、需优化资源占用,且语言 / 框架提供高效异步支持。

32. 假设线程有两个电脑AB,此时他们已经建立了tcp链接,现在需要从A发送两个大文件到B,你该怎么往文件描述符里写,包括B机怎么去从文件描述符里

发送方(电脑 A)

  1. 创建套接字并连接:创建一个 TCP 套接字,然后连接到电脑 B 的指定地址和端口。
  2. 发送文件:
    • 对每个文件,先获取文件大小并发送给接收方。
    • 等待接收方的确认信息。
    • 把文件分块读取,每次读取 4096 字节,再将这些数据块发送给接收方。
    • 等待接收方确认文件接收完成。
  3. 关闭套接字:文件发送完毕后,关闭套接字。

接收方(电脑 B)

  1. 创建套接字并监听:创建一个 TCP 套接字,绑定到指定的地址和端口,然后开始监听连接。
  2. 接收文件:
    • 接收发送方发送的文件大小。
    • 发送确认信息。
    • 从套接字接收数据,将其写入文件,直到接收到的字节数达到文件大小。
    • 发送文件接收完成确认信息。
  3. 关闭连接:所有文件接收完毕后,关闭连接和套接字。

是否能保证B机接收到的文件内容一定与A机发送的内容一致,是否能保证一个字节都不能错?

  • 通过TCP协议保证可靠性数据传输
    • 序列号和ACK保证数据是完整和有序的
    • 通过校验和保证数据完整性
  • 应用层进行粘包和拆包处理,保证数据不会缺失或多余

即使有上面的两种方式能保证数据的可靠完整,但仍有可能发生数据丢失:

  1. 文件损坏(非传输问题)
  • 风险:发送方读取文件时出错(如磁盘坏道),或接收方写入文件时出错(如磁盘空间不足)。
  • 解决方案:
    • 发送方读取文件后计算哈希值(如 MD5/SHA-1),并发送给接收方。
    • 接收方写入文件后计算哈希值,与发送方的哈希值对比,不一致则重新传输。
  1. 异常中断(如断电、程序崩溃)
  • 风险:传输过程中一方意外终止,导致文件不完整。
  • 解决方案:
    • 支持断点续传:发送方记录已发送字节位置,接收方记录已接收位置,中断后从断点继续传输。
    • 使用临时文件:接收方先写入临时文件,校验通过后重命名为目标文件,避免 Partial 文件残留。
  1. TCP 校验和未覆盖的场景
  • 理论上:TCP 校验和覆盖数据链路层以上的错误,但物理层错误(如光纤信号干扰)极罕见,且现代硬件通常会进一步校验。
  • 实际中:对于金融、医疗等对数据一致性要求极高的场景,应用层哈希校验是必要补充。

33. python装饰器/多线程/多进程

装饰器本质上是一个函数,它接收一个函数作为参数,并且返回一个新的函数。这个新函数通常会在原函数的基础上添加一些额外的功能,比如日志记录、性能测试、权限验证等

python的协程使用 yield 关键字来暂停执行,并返回一个值。通过 send() 方法可以向生成器发送值,从而恢复生成器的执行。

34. 进制之间的互相转化

  1. 十进制: 都是以0-9这九个数字组成,不能以0开头
  2. 二进制: 由0和1两个数字组成。
  3. 八进制: 由0-7数字组成,为了区分与其他进制的数字区别,开头都是以0开始
  4. 十六进制:由0-9和A-F组成。为了区分于其他数字的区别,开头都是以0x开始

整数部分转换

1、十进制转二进制

(1)十进制转二进制的转换原理:除以2,反向取余数,直到商为0终止。

(2)具体做法:

将某个十进制数除2得到的整数部分保留,作为第二次除2时的被除数,得到的余数依次记下,重复上述步骤,直到整数部分为0就结束,将所有得到的余数最终逆序输出,则为该十进制对应的二进制数。

例如:9(十进制)→1001(二进制)

img

2、十进制转八进制

(1)转换原理:除以8,反向取余数,直到商为0终止。

(2)具体步骤与二进制一样

例如:十进制数796转换成八进制数:

将796除8取得第一个余数为4,将除8得到的整数部分99作为第二次的被除数,重复上述步骤,直至最终整数部分为0就结束。将取得的所有余数逆序输出

则为:796–>1434

img

3、十进制转十六进制

(1)转换原理:除以16,反向取余数,直到商为0终止。
(2)具体步骤也和二进制、八进制一样,重复上述做法即可得到十六进制数。
例如:十进制数796转换为十六进制数
即为:796–>31c

img

小数部分转换

1、十进制转二进制

(1)原理:十进制小数转换成二进制小数采用 “乘2取整顺序输出” 法。

例题: 0.68D = ______ B(精确到小数点后5位)
如下所示,0.68乘以2,取整,然后再将小数乘以2,取整,直到达到题目要求精度。得到结果:0.10101B.
例如:十进制小数0.68转换为二进制数
具体步骤:
0.68* 2=1.36 –>1
0.36* 2=0.72 –>0
0.72* 2=1.44 –>1
0.44* 2=0.88–>0
0.88* 2=1.76 –>1
已经达到了题目要求的精度,最后将取出的整数部分顺序输出即可
则为:0.68D–>0.10101B

2、十进制转八进制

(1)原理:十进制小数转换成八进制小数采用 “乘8取整,顺序输出” 法。

(2)思路和十进制转二进制一样,参考如下例题:

例题: 10.68D = ______ Q(精确到小数点后3位)
解析:如下图所示,整数部分除以8取余数,直到无法整除。小数部分0.68乘以8,取整,然后再将小数乘以8,取整,直到达到题目要求精度。得到结果:12.534Q.

例如:十进制数10.68转换成八进制数,分为整数部分和小数部分求解
步骤:
(1)整数部分
10/8=1 –>2
1/8=0 –>1
倒序输出为12
(2)小数部分
0.68* 8=5.44 –>5
0.44* 8=3.52 –>3
0.52* 8=4.16 –>4
已经达到了题目要求的精度,即可结束
则小数部分为:0.68–>0.534
因此10.68D –>12.534Q

转换为十进制

img

img

35. linux下进程和线程的调度有区别吗

相同点:

  • 调度算法通用:Linux 内核采用的调度算法(像 CFS,即完全公平调度算法)对进程和线程都适用。CFS 算法会依据任务的虚拟运行时间来分配 CPU 时间片,不管是进程还是线程,都会按照这个规则来竞争 CPU 资源。
  • 调度实体:在 Linux 内核的调度体系中,进程和线程都被视为调度实体(task_struct)。内核在调度时,主要关注的是这些调度实体的属性和状态,而不区分它是进程还是线程

不同点:

  • 资源占用和调度开销
  • 同步和通信方式不同

36. docker底层是如何隔离的

Docker主要就是借助 Linux 内核技术Namespace来做到隔离的,Linux Namespaces机制提供一种资源隔离方案。PID,IPC,Network等系统资源不再是全局性的,而是属于某个特定的Namespace。每个namespace下的资源对于其他namespace下的资源都是透明,不可见的。因此在操作系统层面上看,就会出现多个相同pid的进程。系统中可以同时存在两个进程号为0,1,2的进程,由于属于不同的namespace,所以它们之间并不冲突。而在用户层面上只能看到属于用户自己namespace下的资源,例如使用ps命令只能列出自己namespace下的进程。这样每个namespace看上去就像一个单独的Linux系统。

37. 守护进程如何建立

守护进程的特点

  1. 脱离终端:守护进程在运行时不与任何终端关联,因此它们不能接收来自终端的输入或向终端输出信息。这一特性使得守护进程能够在无人值守的服务器环境中持续运行。
  2. 后台运行:守护进程在后台运行,不占用用户的交互会话,因此不会影响用户的其他操作。
  3. 持久运行守护进程通常在系统启动时启动,并一直运行直到系统关闭。它们提供不间断的服务,如文件系统监控、网络服务、打印队列管理等。
  4. 资源管理:守护进程需要妥善管理资源,包括文件描述符、内存分配等,以确保系统资源的高效利用和避免泄漏。
  5. 错误处理与日志记录:守护进程需要能够处理运行时可能出现的错误,并将相关信息记录到日志文件中,以便于问题的诊断和追踪。

创建一个守护进程通常涉及以下步骤:

1)fork()创建子进程,父进程exit()退出
这是创建守护进程的第一步。由于守护进程是脱离控制终端的,因此,完成第一步后就会在Shell终端里造成程序已经运行完毕的假象。之后的所有工作都在子进程中完成,而用户在Shell终端里则可以执行其他命令,从而在形式上做到了与控制终端的脱离,在后台工作。

2)在子进程中调用 setsid() 函数创建新的会话
在调用了fork()函数后,子进程全盘拷贝了父进程的会话期、进程组、控制终端等,虽然父进程退出了,但会话期、进程组、控制终端等并没有改变,因此,这还不是真正意义上的独立开来,而 setsid() 函数能够使进程完全独立出来。

3)再次 fork() 一个孙进程并让子进程退出
为什么要再次fork呢,假定有这样一种情况,之前的父进程fork出子进程以后还有别的事情要做,在做事情的过程中因为某种原因阻塞了,而此时的子进程因为某些非正常原因要退出的话,就会形成僵尸进程,所以由子进程fork出一个孙进程以后立即退出,孙进程作为守护进程会被init接管,此时无论父进程想做什么都随它了。

  • 隐患 1:子进程可能仍能打开终端(UNIX 系统特性)

  • 在某些 UNIX 实现中,若子进程是会话组长(通过 setsid() 成为新会话的组长),它仍有可能重新打开终端(尽管概率低,但存在风险)。

  • 第二次 fork 后:

    子进程再次 fork 出孙进程,然后子进程立即退出。此时:

    • 孙进程不再是会话组长(会话组长是子进程,已退出),彻底失去打开终端的能力。
    • 孙进程的父进程变为 init 进程(PID=1),由 init 进程负责回收其资源。
  • 隐患 2:父进程未退出导致的僵尸进程风险

  • 假设第一次 fork 后,父进程没有退出(而是继续执行其他任务,甚至阻塞),此时若子进程意外退出:

    • 子进程会成为僵尸进程,直到父进程调用 wait() 回收资源(但父进程可能长期阻塞,无法及时回收)。
  • 解决方案:

    • 第一次 fork 的子进程(中间层)在第二次 fork 后立即退出,孙进程成为孤儿,被 init 进程接管。
    • 即使原始父进程(如 shell)或中间层子进程意外阻塞或崩溃,孙进程的生命周期由 init 进程管理,不会产生僵尸进程。

4)在孙进程中调用 chdir() 函数,让根目录 ”/” 成为孙进程的工作目录

这一步也是必要的步骤,使用fork创建的子进程继承了父进程的当前工作目录。由于在进程运行中,当前目录所在的文件系统(如“/mnt/usb”)是不能卸载的,这对以后的使用会造成诸多的麻烦(比如系统由于某种原因要进入单用户模式)。因此,通常的做法是让”/“作为守护进程的当前工作目录,这样就可以避免上述的问题,当然,如有特殊需要,也可以把当前工作目录换成其他的路径,如/tmp,改变工作目录的常见函数是chdir。

5)在孙进程中调用 umask() 函数,设置进程的文件权限掩码为0
文件权限掩码是指屏蔽掉文件权限中的对应位。比如,有个文件权限掩码是050,它就屏蔽了文件组拥有者的可读与可执行权限。由于使用fork函数新建的子进程继承了父进程的文件权限掩码,这就给该子进程使用文件带来了诸多的麻烦。因此,把文件权限掩码设置为0,可以大大增强该守护进程的灵活性。设置文件权限掩码的函数是umask。在这里,通常的使用方法为umask(0)。

6)在孙进程中关闭任何不需要的文件描述符
同文件权限码一样,用fork函数新建的子进程会从父进程那里继承一些已经打开了的文件。这些被打开的文件可能永远不会被守护进程读写,但它们一样消耗系统资源,而且可能导致所在的文件系统无法卸下。

在上面的第2)步之后,守护进程已经与所属的控制终端失去了联系。因此从终端输入的字符不可能达到守护进程,守护进程中用常规方法(如printf)输出的字符也不可能在终端上显示出来。所以,文件描述符为0、1和2 的3个文件(常说的输入、输出和报错)已经失去了存在的价值,也应被关闭。

7)守护进程退出处理
当用户需要外部停止守护进程运行时,往往会使用 kill 命令停止该守护进程。所以,守护进程中需要编码来实现 kill 发出的signal信号处理,达到进程的正常退出。

38. 客户端time_wait过多怎么办

原因:

  1. 短连接频繁主动关闭
    客户端作为主动关闭方(如 HTTP 客户端、微服务调用方),在每次请求后立即关闭连接,导致大量 TIME_WAIT 堆积。
  2. 端口资源有限
    客户端端口范围默认通常是 32768-60999(约 2.8 万个端口),高并发下短连接快速消耗端口,未及时释放。
  3. 挥手过程异常
    网络延迟或丢包导致最后一个 ACK 未到达服务端,触发重传,延长 TIME_WAIT 持续时间(默认 2MSL,约 2-4 分钟)。

解决方法:

  1. 调整 tcp_tw_reuse 参数:该参数允许在 TIME_WAIT 状态的连接上复用本地地址和端口,允许端口在 TIME_WAIT 状态下被重新使用,避免端口占用导致端口资源消耗完。
  2. 调整 tcp_tw_recycle 参数:该参数可以加快 TIME_WAIT 状态的回收速度(一般不用调整该参数)。
  3. 客户端复用连接:避免频繁地建立和关闭连接,尽量复用已有的连接。例如,在使用 HTTP 协议时,可以使用长连接(HTTP/1.1 支持),通过设置 Connection: keep - alive 头信息来保持连接的持续打开状态。
  4. 强制关闭