C++面试核心知识点解析:虚函数、智能指针、内存管理、STL与网络协议

34 min

核心机制:虚函数的“骨架”

虚函数表是每一个类都有,而且只有一个,而虚函数指针每一个对象都会有。

虚函数表只存在只读数据段,在类编译时候产生,同时也会在对象内存布局初始化的时候生成一个成愿变量,指向类的虚函数表。

当通过基类去调用虚函数时,编译器回去访问对象内存的起始位置,然后去找到它的vptr,然后找到对应的虚函数表,取出函数地址并跳转执行,从而实现虚函数表的作用

1. 核心组件

组件全称归属存储位置本质
虚函数表Virtual Table (Class)只读数据段函数指针数组,存储虚函数入口地址
虚表指针Virtual Pointer对象 (Object)对象内存布局起始处一个隐藏的成员变量,指向类的 vtable

2. 实现原理(动态绑定过程)

当通过基类指针调用虚函数时,编译器生成的汇编代码执行以下三步(动态绑定):

  1. 取 vptr:访问对象内存起始位置,读取 vptr 的值。
  2. 找 vtable:根据 vptr 找到对应的虚函数表。
  3. 查表调用:根据编译期确定的索引(比如第1个虚函数索引为0),取出函数地址并跳转执行。

图解示例: Base *p = new Derive(); p->func(); p -> [vptr] -> [vtable] -> [&Derive::func] -> 执行代码

重点辨析:类 vs 对象

1. 虚函数表是类所有

  • 数量:每个含有虚函数的类(包括继承体系中的类)都有一个唯一的 vtable。
  • 共享:该类的所有对象共享同一个 vtable。
  • 内容
    • 如果派生类重写了基类虚函数 \rightarrow 表中对应位置存放派生类函数地址。
    • 如果派生类未重写 \rightarrow 表中对应位置存放基类函数地址。

2. 虚表指针是对象所有

  • 数量:每个对象实例内部都有一个隐藏的 vptr
  • 大小:增加 sizeof(void*) 的内存开销(32位系统4字节,64位系统8字节)。
  • 初始化时机:在构造函数的初始化列表阶段被初始化,指向该对象所属类的 vtable。

性能与复杂度分析

虚函数表式直接索引的,直接通过vptr和偏移量直接定位,不需要遍历,在编译的时候就已经确定了虚函数在表中的下标。

1. 查询复杂度:O(1)

  • 原因:虚函数的调用不是“搜索”过程,而是“索引”过程。
  • 机制:编译器在编译时已经确定了虚函数在表中的下标。运行时只需 vptr + offset 直接定位,无需遍历。

2. 性能开销(面试常考)

虽然时间复杂度是 O(1),但虚函数调用仍有一定开销:

  1. 指令缓存不友好:间接跳转(查表)比直接跳转(普通函数)更容易导致 CPU 分支预测失败。
  2. 内存开销:每个对象多存一个指针;每个类多存一张表。
  3. 内联失效:虚函数调用通常是运行时确定的,因此编译器通常无法内联虚函数。

面试高频追问(扩展点)

Q1:构造函数可以是虚函数吗?

构造函数不可以式虚函数,因为在构造函数执行的时候,对象还没有完全构造,虚函数指针和虚函数表都还在初始化,如果构造函数是虚函数,虚函数指针就无法指向正确的表了,

像是鸡和蛋的问题。

不可以。

  • 原因:虚函数依赖 vptr 指向 vtable。在构造函数执行时,对象尚未完全构造,vptr 正在被初始化。如果构造函数是虚函数,就没有 vptr 指向正确的表,造成逻辑死锁。
  • 此外:构造对象时,必须明确是哪个类,这与多态的“运行时确定”相悖。

Q2:析构函数为什么必须是虚函数?

虚函数可以触发动态绑定,调用派生类的析构函数,然后自动调用基类的析构函数,如果不是,可能会产生资源泄露。

为了防止内存泄漏。

  • 场景Base *p = new Derive(); delete p;
  • 若是虚函数:触发动态绑定,调用派生类析构函数,再自动调用基类析构函数(完整回收)。
  • 若非虚函数:静态绑定,只调用基类析构函数,派生类资源泄漏。

Q3:静态函数可以是虚函数吗?

静态函数没有this指针,虚函数表依赖于vptr,两者机制相互冲突

虚函数表存储在只读数据段,运行时候不应被修改。

不可以。

  • 原因:静态函数属于类,不依赖对象实例调用(没有 this 指针)。虚函数机制依赖对象的 vptr 来查表,两者机制冲突。

Q4:虚函数表存储在哪里?

  • 通常存储在程序的只读数据段,因为它在运行期间不应被修改。

速记口诀

虚表类属共享用,虚指对象藏身中。 构造不能把它虚,析构往往要清空。 查表只需 O(1),性能虽好内联穷。

Move相关

std::move知识强行把左值转换成右值的引用,move本质上知识一个类型转换函数。

真正实现移动的是将转移后的右值引用作为参数,去匹配目标类的移动构造函数或者移动赋值运算符发生的。

std::move知识移动语义的前提,它告诉编译器这个对象不再需要了,可以直接从它那里安全的窃取资源。

一、 核心原理:std::move 到底做了什么?

1. 本质定义

std::move 本身不进行任何数据移动,它只是一个类型转换。

  • 作用:将左值强制转换为右值引用。
  • 底层实现static_cast<typename std::remove_reference::type&&>(t)

2. 执行流程拆解

std::move 只是“移动语义”的发起者,而非执行者

  1. 转换阶段std::move(obj) 告诉编译器,“我以后不再需要 obj 的值了,把它当做右值处理”。
  2. 匹配阶段:转换后的右值引用去匹配重载函数。
    • 如果类有移动构造/赋值函数 \rightarrow 调用移动语义(窃取资源)。
    • 如果类没有移动语义 \rightarrow 退而求其次,调用拷贝构造/赋值(深拷贝)。

速记口诀std::move 只是车牌转换(左值变右值),真正开车(转移资源)的是移动构造函数。

二、 性能分析:大对象的优化效果

取悦于是否实现了移动语义,如果实现了移动语义,只需要拷贝指针和长度变量,然后将源对象进行置空即可,操作时O1的。

如果没有实现移动语义,那么编译器只能够去找到拷贝构造,这时候就需要逐字节去转移复制了。

案例:假设有一个 1KB 的对象,move 能优化吗?

答案取决于类是否实现了移动语义

场景类特性触发函数时间复杂度行为描述
场景 A实现了移动语义
(如 std::vector, std::string)
移动构造O(1)极大优化。仅拷贝指针(8字节)和长度变量,源对象指针置空。不拷贝 1KB 数据。
场景 B未实现移动语义
(如普通 struct,无指针成员)
拷贝构造O(N)无优化。编译器找不到移动构造函数,只能调用拷贝构造,逐字节复制 1KB 数据。

关键结论

  • 资源窃取:移动语义的核心优势在于指针所有权的转移
  • 纯数据类型:对于 int, char[] 或不包含堆指针的普通结构体,移动和拷贝的开销是一样的,都需要复制内存数据。

三、 面试高频追问

Q1:std::move 之后的对象还能用吗?

move后的对象依然存在,还没有被销毁,但是时未指定的,不建议对其进行操作

可以,但处于“有效但未指定”的状态。

  • 有效:对象未被销毁,析构函数仍会被正常调用,可以对其进行赋值或销毁。
  • 未指定:对象内部的资源(如指针)可能已被移走,不应该再读取其值(例如 std::move(str) 后打印 str,结果是不确定的,通常为空)。
  • 最佳实践move 之后,除了析构或重新赋值,不要对该对象做其他操作。

Q2:既然 std::move 只是转换,为什么不直接用 static_cast

可读性好

  • 可读性std::move 语义更清晰,明确表达了“我要转移资源”的意图。
  • 便利性static_cast 写法繁琐,且需要处理引用折叠和类型推导,容易出错。

四、 总结速查表

操作本质是否发生数据复制适用场景
拷贝深拷贝是 (O(N))对象需要独立存在,互不影响。
移动资源窃取否 (O(1))对象即将销毁,资源所有权需要转移。
std::move类型转换将左值转换为右值,配合移动语义使用。

一句话总结std::move承诺(承诺放弃所有权),移动构造是行动(窃取资源)。没有行动的承诺(无移动构造函数)等于空谈(退化为拷贝)。

智能指针

一、 智能指针核心原理

1. 三大智能指针速查表

一个是占用所有的资源,禁止拷贝,但是支持移动

一个是共享资源,现场安全,引用计数是原子操作。

另一个是观察者,不增加计数,用于解决weak_ptr,解决循环引用问题,必须lock()提升为shared_ptr。

指针类型所有权模式核心机制适用场景关注点
unique_ptr独占禁用拷贝,支持 move函数返回值、Pimpl 模式、资源独占轻量级,零开销,默认首选。
shared_ptr共享引用计数多对象共享同一资源线程安全(引用计数原子操作),有控制块开销。
weak_ptr观察者,不增加计数解决 shared_ptr 循环引用必须通过 lock() 提升为 shared_ptr 使用。

2. 面试高频追问

shared_ptr的读写不是线程安全的,多线程同时读写同一个shared_ptr的管理需要加锁。

auto_ptr进行拷贝的时候不会把源对象置空,被unique_ptr替换了。

  • Q: shared_ptr 是线程安全的吗?
    • A: 引用计数是线程安全的(原子操作),但对象的读写不是线程安全的。多线程同时读写同一个 shared_ptr 管理的对象需要加锁。
  • Q: 为什么 unique_ptrauto_ptr 好?
    • A: auto_ptr 进行拷贝时会发生“所有权转移”但源对象未置空,导致访问崩溃。unique_ptr 禁止拷贝,强制使用 move,语义更清晰安全。

new vs malloc 核心区别

1. 本质对比表

特性new / deletemalloc / free
性质C++ 运算符 (Operator)C 标准库函数
返回类型类型安全 (直接返回 T*)不安全 (返回 void*,需强转)
失败处理抛出 std::bad_alloc 异常返回 NULL
构造/析构自动调用构造函数和析构函数只分配内存,不调用构造/析构
重载允许重载 (自定义内存管理)不允许重载
分配大小编译器自动计算 sizeof(T)必须手动计算字节数

2. 核心差异总结

  • 面向对象 vs 面向过程new 是 C++ 面向对象的一部分,负责对象的生命周期管理;malloc 仅负责搬运内存字节。
  • 内存区域newmalloc 都在堆上分配,但 new 具体实现通常调用 operator new,而 operator new 底层通常基于 malloc 实现。

3. 内存释放的禁忌与陷阱

1. 禁忌:new 出来的对象能用 free 释放吗?

绝对禁止。

  • 对于类对象
    • 后果free 只释放内存,不会调用析构函数
    • 泄漏风险:如果对象内部持有资源(文件句柄、Socket、new 出来的内存),这些资源将永远无法释放。
  • 对于基础类型
    • 理论上:基础类型无析构函数,某些编译器实现下 free 可能不会崩溃。
    • 实际上:这是 未定义行为。不同编译器、调试模式下的内存管理器结构不同,混用会导致堆结构损坏。
  • 法则谁申请,谁释放;成对出现。 (new <-> delete, malloc <-> free)

2. 陷阱:数组释放需要写元素个数吗?

不需要,但必须用 delete[]

  • 原理
    • 执行 new T[n] 时,编译器会在内存块头部额外存储 数组大小元数据
    • 执行 delete[] 时,编译器会读取这个元数据,得知需要调用 n 次析构函数。
  • 错误后果
    • 使用 delete (不带 []) 释放数组:
      • 类类型:只调用第一个元素的析构函数,导致内存泄漏或崩溃。
      • 基础类型:可能不会崩溃,但行为未定义,破坏堆内存结构。

map vs B+ Tree

一、 核心架构对比:std::map vs B+ Tree

特性std::map (红黑树)B+ Tree
数据结构自平衡二叉搜索树多路平衡查找树
设计目标内存存储优化磁盘 I/O 优化
节点大小小(1个键值对 + 2个指针)大(对齐磁盘页,如 4KB/16KB,含多个键值对)
数据存储所有节点均存储数据仅叶子节点存储数据,非叶节点仅存索引
树的高度较高 ( log₂N )极矮 ( log_b N,通常 3-4 层)
范围查询较慢 (需中序遍历,节点跳跃)极快 (叶子节点形成有序链表)

二、 性能与空间深度剖析

1. 查找性能

  • 红黑树O(logN)O(\log N)
    • 在内存中速度极快。
    • 若用于磁盘,树高导致频繁随机 I/O,性能崩溃。
  • B+ TreeO(logbN)O(\log_b N)
    • 由于节点大(分支因子 bb 大),树极矮。
    • 磁盘 I/O 次数极少(通常 1-3 次),是磁盘存储的王者。

2. 范围查询(关键考点)

  • 红黑树:需要通过中序遍历(左-根-右)来获取有序数据,涉及大量的指针跳转和回溯,不利于缓存预读。
  • B+ Tree:叶子节点之间通过指针串联成双向链表。进行范围查询(如 SELECT * WHERE id > 10 AND id < 100)时,只需定位起点,然后沿链表顺序遍历即可。

3. 内存/空间占用

  • 红黑树:空间利用率较低。每个节点都需要额外存储颜色位、父/左/右指针,且节点分散,内存碎片化严重。
  • B+ Tree:空间利用率高。节点内部紧凑排列键值对,且非叶节点不存数据,极大地降低了树高,减少了索引占用的空间。

三、 面试必问:为何数据库索引选择 B+ Tree?

围绕减少IO次数,数据库非叶子节点是存储索引的,对于千万级数据,树高只有3层。

意味着3次磁盘I/O。

而且叶子节点之间会形成链表,有利于进行顺序IO。

这是数据库面试的经典题,答案核心围绕 “磁盘 I/O”“范围查询” 展开。

1. 减少 I/O 次数(最主要原因)

  • 磁盘瓶颈:数据库数据量大,无法全部装入内存,查找性能主要取决于磁盘读取次数。
  • 矮胖树:B+ Tree 一个节点(页)可以存上千个索引键。对于千万级数据,树高通常只有 3 层,意味着只需 3 次磁盘 I/O 即可找到数据。
  • 红黑树劣势:作为二叉树,树高极高,查找数据可能需要几十次 I/O,效率极低。

2. 极致的范围查询

  • 业务场景:数据库查询中范围查询非常频繁。
  • B+ Tree 优势:数据集中在叶子节点并形成链表,范围查询只需遍历链表,属于顺序 I/O,速度快。
  • 红黑树劣势:范围查询需要在树中进行大量随机跳转,属于随机 I/O,在磁盘上效率极低。

3. 磁盘页对齐

  • B+ Tree 的节点大小设计为与磁盘块(Page)大小一致(如 4KB),一次 I/O 能读入整个节点,利用率最大化。

补充考点:为什么不用 Hash 索引?

虽然 Hash 表 O(1)O(1) 查找极快,但数据库仍首选 B+ Tree,原因如下:

  1. 不支持范围查询:Hash 索引是无序的,无法进行 ><BETWEEN 查询。
  2. 不支持排序:无法利用索引进行 ORDER BY 操作。
  3. 哈希冲突:大量重复键会导致性能退化。

STL 内存池

一、 STL 内存池实现原理(SGI STL 经典模型)

1. 双级配置器架构

STL 并不是对所有内存请求都一视同仁,而是采用了分级策略:

层级处理对象机制目的
一级配置器> 128 字节直接调用 malloc / free大对象直接申请,避免复杂管理开销。
二级配置器≤ 128 字节内存池 + 自由链表解决小对象频繁分配造成的内存碎片性能下降

2. 二级配置器核心结构(重点)

  • 自由链表数组
    • 维护 16 个链表,分别管理大小为 8, 16, 24, …, 128 字节的内存块。
    • 对齐规则:任何小内存请求都会向上取整到 8 的倍数(例如申请 12 字节,实际分配 16 字节)。
  • 内存池
    • 一块由配置器向系统申请的大块连续内存区域,用于填充自由链表。

3. 分配与回收流程

  • 分配
    1. 根据请求大小计算属于哪个链表(索引 = (size + 7) / 8 - 1)。
    2. 命中:链表非空 \rightarrow 直接弹出第一节点(O(1))。
    3. 未命中:链表为空 \rightarrow 向内存池申请一大块内存(默认 20 个目标大小的块) \rightarrow 切分后挂载到链表 \rightarrow 返回一块。
  • 回收
    • 不直接 free,而是将内存块重新挂载回对应的自由链表,供下次复用。

现代 C++ 注记:现代标准库(如 GCC 的新版本)通常简化了 std::allocator,直接调用 ::operator new,复杂的内存池机制更多转移到 std::pmr (Polymorphic Memory Resource) 或第三方库(如 tcmalloc, jemalloc)中实现。

二、 Vector 的内存布局辨析

1. 核心法则:对象与数据分离

std::vector 是典型的控制器模式,它由两部分组成:

  1. 控制块:包含 3 个指针(_M_start, _M_finish, _M_end_of_storage),大小固定(通常 24 字节)。
  2. 数据块:存储实际元素的连续内存空间。

2. 两种创建方式对比

创建方式代码示例Vector 对象位置元素数据位置分配次数释放责任
栈对象vector<int> v(4, 100);1 次 (仅元素)出作用域自动析构 (RAII)
堆对象vector<int>* p = new vector<int>(4, 100);2 次 (对象 + 元素)必须手动 delete p

3. 详细执行流程

  • vector<int> v(4, 100)

    1. 栈上分配 24 字节给 v 对象。
    2. 构造函数在堆上分配 4 * sizeof(int) 内存。
    3. 构造 4 个 int 并赋值为 100。
    4. 出作用域:栈帧回缩,v 析构 \rightarrow 触发 deallocate 释放堆内存。
  • new vector<int>(4, 100)

    1. 堆上分配 24 字节给 vector 对象。
    2. 构造函数再次在堆上分配 4 * sizeof(int) 内存。
    3. 风险:如果忘记 delete,会导致 24 字节的 vector 对象泄漏,同时导致其管理的元素内存泄漏。

三、 面试高频追问

Q1:为什么要设计 128 字节的分界线?

  • 碎片问题:频繁申请释放小内存(如 Node 节点)会在堆上产生大量无法利用的微小碎片(外部碎片)。
  • 性能问题malloc 是系统调用,开销大。对于小对象,内存池通过链表管理,分配速度接近 O(1),且避免了系统调用开销。

Q2:Vector 的扩容机制是怎样的?

  • 触发:当 size == capacity 且需要插入新元素时。
  • 策略:申请原大小 2 倍(或 1.5 倍,取决于编译器实现)的新内存。
  • 开销:需要将旧数据拷贝/移动到新内存,释放旧内存。这也是为什么 vectorpush_back 可能会导致迭代器失效。

私有变量

一、 如何访问类中的私有成员变量?

1. 方法概览

方法类型原理安全性适用场景
公开接口标准范式提供 public 的 Getter 方法安全常规业务开发,保持封装性。
友元特殊授权friend 关键字打破封装壁垒可控运算符重载、两个类紧密耦合时。
指针偏移黑科技利用内存布局规律,通过地址偏移强行读取危险面试题、无源码修改权限的Debug、Hack。

2. 详细机制

  • 公开接口:最推荐的方式。只暴露读权限,不暴露写权限,维护类的不变性。
  • 友元
    • 单向授权:A 声明 B 是朋友,B 能访问 A 的私有成员,但 A 不能访问 B 的。
    • 破坏封装:过多的友元会使代码耦合度变高,需慎用。
  • 指针偏移
    • 原理:C++ 对象在内存中是连续存储的。如果知道成员变量的声明顺序和内存对齐规则,就可以通过对象起始地址加上偏移量定位到私有成员。
    • 风险:极度依赖编译器实现、平台架构(32/64位)、内存对齐策略。一旦类定义改变(如增加成员),代码即刻失效。

3. 代码示例(指针偏移黑科技)

class Secret {
private: 
    int a = 10; 
    int b = 20; 
};

// 假设我们知道 a 是第一个成员
Secret s;
int* p = (int*)(&s); // 强转对象地址
std::cout << *p << std::endl; // 输出 10 (读取私有变量 a)

二、 二维数组遍历:行优先 vs 列优先

1. 核心原理:CPU 缓存局部性

现代 CPU 访问内存的速度远慢于访问缓存。CPU 读取内存时,会将目标地址及其附近的数据一起加载到缓存行(Cache Line,通常 64 字节)中。

  • C++ 内存布局:采用行主序。即 A[0][0], A[0][1], A[0][2]… 在物理内存上是连续的。

2. 性能对比分析

遍历方式内存访问模式缓存表现性能评级
行遍历
for(i) { for(j) { A[i][j]... } }
连续访问
A[i][j] 时,A[i][j+1] 已在缓存中
缓存命中率高
CPU 流水线效率高
优 (快)
列遍历
for(j) { for(i) { A[i][j]... } }
跳跃访问
A[i][j] 后,下一个读 A[i+1][j],两者内存跨度大
缓存未命中
频繁触发 CPU 等待内存加载
差 (慢)

3. 性能差异量化

在数据量较大时,行遍历的速度通常是列遍历的 几倍甚至十几倍

  • 原因:列遍历导致 Cache 频繁失效,CPU 大部分时间在等待数据从内存传输到缓存,而非计算。

三、 面试高频追问

Q1:除了 Getter,还有没有更“C++”的方式访问私有成员?

  • 可以使用 模板特化漏洞(仅在某些旧标准或特定编译器下有效),利用模板实例化时的宽松检查。
  • 更工程化的做法是使用 反射库(如 C++26 正在引入的 Static Reflection)或宏定义技巧,但在标准 C++ 中,Getter + Friend 是唯二的标准解法。

Q2:为什么 C++ 要设计为行主序?

  • 历史原因:C 语言设计之初,数组名退化为指针是指向首行,行内指针算术运算(ptr++)自然对应行内遍历,符合直觉。
  • 应用场景:大部分算法(矩阵乘法、图像处理)习惯按行处理数据。

Q3:如果是列主序的语言(如 MATLAB, Fortran),遍历方式怎么选?

  • 在列主序语言中,列遍历(内层循环遍历行)是连续内存访问,性能更优。
  • 法则内层循环索引对应的内存应该是连续的

HTTP

一、 浏览器访问 HTTP 服务器的协议栈

1. 协议分层全景图

从用户输入 URL 到页面展示,数据包经历了自顶向下的封装过程:

协议层核心协议作用关键动作
应用层HTTP / HTTPS定义业务语义生成 GET/POST 请求报文。
DNS域名解析www.example.com 解析为 IP 地址。
传输层TCP可靠传输三次握手建立连接;对 HTTP 报文分段、编号。
UDP快速传输DNS 查询通常使用 UDP(速度快)。
网络层IP寻址与路由将 TCP 段封装成 IP 包,决定数据包如何从源到目的。
ICMP网络诊断Ping 命令使用的协议。
数据链路层ARP地址映射将下一跳的 IP 地址解析为 MAC 地址。
Ethernet/WiFi局域网传输将 IP 包封装成帧,通过物理地址传输。

2. 简易流程口诀

DNS 先解析,ARP 找 MAC。 TCP 三次握,HTTP 发请求。 数据包层层封装,网卡发比特流。

二、 为什么有了 TCP 还需要 HTTP?

1. 核心矛盾:传输 vs 应用

这是一个经典的**“汽车 vs 货物”**问题。

协议协议层核心能力局限性
TCP传输层“传得对”
保证数据不丢、不乱、不重。
“看不懂”
只认识字节流,不知道字节代表什么(是图片还是文本?是请求还是响应?)。
HTTP应用层“搞得懂”
定义了数据格式、请求方法(GET/POST)、状态码(200/404)。
“传不动”
本身不具备传输能力,必须依赖底层传输服务。

2. 分层设计的意义

  • 解耦
    • 如果没有 HTTP,开发者直接用 TCP 发送字节流,就必须自己定义一套“协议”(如:怎么表示请求结束?怎么表示文件类型?)。
    • HTTP 提供了一套标准化的应用语义,让浏览器和服务器能“说同一种语言”。
  • 复用
    • HTTP 可以跑在 TCP 上,也可以跑在 QUIC (HTTP/3) 上。
    • SMTP (邮件)、FTP (文件) 也可以跑在 TCP 上。
    • TCP 负责修路,HTTP 负责跑车。

三、 面试高频追问

Q1:既然 HTTP 是应用层协议,那 HTTPS 是哪一层的?

  • 答案:HTTPS = HTTP + SSL/TLS。
  • SSL/TLS 工作在应用层和传输层之间(表示层),它对 HTTP 数据进行加密后再交给 TCP。

Q2:为什么 DNS 使用 UDP 而不是 TCP?

  • 速度快:UDP 无需三次握手,解析延迟低。
  • 数据小:DNS 查询报文通常很小(< 512 字节),UDP 完全够用。
  • :当响应包很大(如 DNSSEC)或区域传送时,DNS 也会使用 TCP。

Q3:TCP 是“字节流”服务,HTTP 如何区分一个个请求?

  • TCP 不保证边界,可能发生粘包/拆包。
  • HTTP 通过 Content-Length 头部标记请求体的长度,或者使用 Transfer-Encoding: chunked 分块传输,从而在字节流中划分出一个个独立的 HTTP 报文。