Skip to content

百度C++后端研发一面,all in秋招!

来源:https://www.nowcoder.com/feed/main/detail/4daefd484cea46a1901c1d646a231485

1、raft选举?选举失败怎么办?

Raft 选举机制

Raft 是一种共识算法,其核心目标是理解性(understandability),即易于理解和实现。Raft 将共识问题分解为三个子问题:Leader 选举(Leader Election)、日志复制(Log Replication)和安全性(Safety)。

Leader 选举是 Raft 算法的基础。在 Raft 中,集群中的节点有三种角色:

  1. Follower(跟随者):被动响应 Leader 或 Candidate 的请求,不发送任何请求。
  2. Candidate(候选者):在选举期间,向其他节点发送请求投票(RequestVote)RPC,并等待响应。
  3. Leader(领导者):处理所有客户端请求,并负责日志复制到 Follower。

选举过程如下:

  1. 初始状态:所有节点都是 Follower。每个 Follower 都有一个随机的选举超时时间(通常在 150ms 到 300ms 之间)。
  2. 选举超时:如果 Follower 在选举超时时间内没有收到来自 Leader 的心跳(AppendEntries RPC)或 Candidate 的投票请求,它就会认为当前没有 Leader,并转换为 Candidate 状态。
  3. 发起选举:Candidate 会递增自己的当前任期(term),然后向集群中的所有其他节点发送 RequestVote RPC,请求它们给自己投票。
  4. 投票规则:
    • 每个节点在一个任期内只能投一票。
    • 如果 Candidate 的任期大于或等于接收者的任期,并且接收者的日志至少和 Candidate 的日志一样新(通过比较最后一条日志的任期和索引),接收者就会投票给 Candidate。
    • “日志一样新”的判断标准是:如果两者的最后一条日志的任期不同,任期大的日志更新;如果任期相同,索引大的日志更新。
  5. 赢得选举:如果 Candidate 收到超过半数(N/2 + 1)的投票,它就赢得选举,成为 Leader。然后,Leader 会立即向所有 Follower 发送心跳,以确立自己的 Leader 地位并阻止新的选举。
  6. 转变为 Follower:
    • 如果 Candidate 在等待投票期间收到来自更高任期 Leader 的心跳,它会立即转变为 Follower,并承认新的 Leader。
    • 如果 Candidate 收到来自其他 Candidate 的 RequestVote RPC,并且对方的任期更高,它也会转变为 Follower,并投票给对方(如果满足投票条件)。

选举失败的处理

选举失败通常发生在以下两种情况:

  1. 投票分裂(Split Vote):多个 Candidate 同时发起选举,导致没有一个 Candidate 获得多数票。例如,有 5 个节点,3 个 Candidate 同时发起选举,每个 Candidate 可能只获得 1 票(加上自己的),无法达到 3 票的多数。
  2. Candidate 崩溃:Candidate 在选举过程中崩溃,导致无法完成选举。

针对选举失败,Raft 采取以下策略:

  1. 随机选举超时:这是解决投票分裂的关键机制。每个 Follower 的选举超时时间是随机的,这使得不同节点转换为 Candidate 的时间错开,从而降低了同时发起选举的概率。如果发生投票分裂,没有 Candidate 获得多数票,它们会等待新的选举超时,然后再次发起选举。由于超时时间是随机的,下一次选举中很可能只有一个 Candidate 先超时并发起选举,从而更容易获得多数票。
  2. 任期递增:每次发起选举,Candidate 都会递增自己的任期。这确保了每次选举都是在一个新的“纪元”中进行,避免了旧任期内的混乱。
  3. Leader 心跳:Leader 会周期性地发送心跳包。如果 Follower 在选举超时时间内收到心跳,它会重置计时器,并保持 Follower 状态。这可以防止 Follower 在 Leader 正常工作时发起不必要的选举。
  4. 快速发现新 Leader:如果 Candidate 在选举过程中发现有更高任期的 Leader 存在,它会立即退回到 Follower 状态,并更新自己的任期,从而快速收敛到新的 Leader。

总的来说,Raft 通过随机选举超时机制有效地解决了投票分裂问题,并通过任期递增和心跳机制确保了选举的收敛性和稳定性。如果选举失败,节点会等待下一个随机超时时间,然后再次尝试发起选举,直到选出新的 Leader。

2、leader和follower挂了分别怎么处理?

在 Raft 算法中,Leader 和 Follower 的故障处理是保证系统高可用和数据一致性的关键。

Leader 故障处理

当 Leader 发生故障(例如崩溃、网络分区导致无法与其他节点通信)时,Raft 机制会确保系统能够快速恢复并选举出新的 Leader。

  1. Follower 检测 Leader 故障:
    • 每个 Follower 都会维护一个选举计时器(Election Timeout)。
    • 如果 Follower 在选举计时器超时时间内没有收到来自 Leader 的心跳包(AppendEntries RPC)或日志复制请求,它就会认为当前的 Leader 已经失效。
  2. Follower 转换为 Candidate:
    • 一旦选举计时器超时,Follower 会立即将自己的状态从 Follower 转换为 Candidate。
    • Candidate 会递增自己的当前任期(Term),然后向集群中的所有其他节点发送 RequestVote RPC,请求它们给自己投票。
  3. 选举新 Leader:
    • 其他节点(Follower 或其他 Candidate)会根据投票规则(在一个任期内只投一票,且投票给日志更新的 Candidate)进行投票。
    • 第一个获得集群中大多数节点(N/2 + 1)投票的 Candidate 将赢得选举,成为新的 Leader。
  4. 新 Leader 确立:
    • 新的 Leader 产生后,会立即向所有 Follower 发送心跳包,以确立自己的 Leader 地位,并阻止其他节点发起新的选举。
    • 如果旧 Leader 恢复,它会发现当前任期比自己高的新 Leader,便会立即降级为 Follower。

关键点

  • 随机选举超时:确保了在 Leader 故障时,不同的 Follower 会在不同的时间点发起选举,避免了投票分裂,提高了选举成功的概率。
  • 任期机制:Raft 的任期(Term)机制是其安全性的核心。每个任期都有一个唯一的递增编号。任何 RPC 请求中包含的任期号都会被接收者检查,如果接收者的任期号小于发送者,则接收者会更新自己的任期号并降级为 Follower。这确保了集群中只有一个 Leader,并且所有操作都在最新的任期内进行。
  • 日志完整性:Raft 规定,只有日志最完整的 Candidate 才能当选 Leader。这意味着新的 Leader 必然包含了所有已提交的日志条目,从而保证了数据的一致性。

Follower 故障处理

当 Follower 发生故障(例如崩溃、网络中断)时,Leader 会负责处理,以确保日志最终能够复制到所有健康的 Follower 上。

  1. Leader 检测 Follower 故障:
    • Leader 会定期向所有 Follower 发送心跳包和日志复制请求。
    • 如果 Leader 在一段时间内没有收到某个 Follower 的响应,它就会认为该 Follower 已经故障或不可达。
  2. Leader 重试日志复制:
    • Leader 会持续尝试向故障的 Follower 发送日志复制请求(AppendEntries RPC)。
    • Leader 会为每个 Follower 维护 nextIndex(下一个要发送给该 Follower 的日志条目的索引)和 matchIndex(已复制到该 Follower 的最高日志条目的索引)。
    • 当 Leader 发现某个 Follower 无法响应时,它会不断地递减 nextIndex,直到找到一个 Leader 和 Follower 都匹配的日志条目(即 prevLogIndexprevLogTerm 匹配)。
    • 从匹配点开始,Leader 会重新发送后续的日志条目给该 Follower。
  3. Follower 恢复:
    • 当故障的 Follower 恢复后,它会开始接收来自 Leader 的 AppendEntries RPC。
    • 如果 Follower 发现 Leader 发送的日志条目与自己的日志不一致,它会拒绝该请求,并告知 Leader 自己最新的日志信息。
    • Leader 会根据 Follower 的反馈,调整 nextIndex,直到找到一个匹配点,然后从该点开始同步日志。

关键点

  • 幂等性:AppendEntries RPC 是幂等的,这意味着 Leader 可以安全地多次发送相同的日志条目,而不会导致重复或不一致。
  • 一致性检查:AppendEntries RPC 包含 prevLogIndexprevLogTerm 字段,用于在日志复制前进行一致性检查。如果 Follower 的日志在该索引和任期上与 Leader 不匹配,Follower 会拒绝该请求,并让 Leader 调整 nextIndex
  • 最终一致性:Raft 保证只要大多数节点是健康的,系统就能继续运行并提交日志。故障的 Follower 恢复后,会通过 Leader 的日志复制机制最终与 Leader 保持一致。

总结来说,Raft 通过选举计时器和任期机制处理 Leader 故障,确保快速选出新的 Leader;通过 Leader 的重试机制和日志一致性检查处理 Follower 故障,确保日志最终能够复制到所有健康的节点,从而维护了整个集群的数据一致性和高可用性。

3、新节点加入后要复制全量日志吗,日志量太大怎么办?

在 Raft 算法中,新节点加入集群(或从故障中恢复)时,确实需要获取集群的最新状态,这通常涉及到日志的复制。然而,如果日志量非常大,直接复制全量日志会非常低效,甚至不可行。Raft 通过**快照(Snapshotting)**机制来高效地处理这种情况。

新节点加入的日志复制过程

当一个新节点(或一个从故障中恢复的旧节点)加入 Raft 集群时,它首先以 Follower 身份启动。它需要从 Leader 那里获取最新的已提交状态。这个过程通常分为两种情况:

  1. 日志同步:如果新节点的日志与 Leader 的日志差异不大,或者 Leader 的日志不是特别长,Leader 会通过标准的 AppendEntries RPC 机制,将缺失的日志条目发送给新节点,直到新节点的日志与 Leader 完全同步。
  2. 快照同步(针对日志量大或新节点):当新节点与 Leader 的日志差距非常大,或者新节点是一个全新的、没有任何日志的节点时,Leader 会使用**安装快照(InstallSnapshot)**RPC 来同步数据。这是处理大量日志的核心机制。

快照(Snapshotting)机制

快照是 Raft 优化日志复制的一种方式,它将当前系统状态的某个时间点的数据“拍下来”,并将其作为日志的前缀。这样,Leader 就不需要保留所有历史日志,只需要保留从快照点之后的日志即可。

快照同步过程

  1. Leader 生成快照:Leader 会定期将其状态机的当前状态写入一个快照文件。这个快照包含了某个索引(lastIncludedIndex)之前的所有日志条目所对应的状态,以及该索引对应的任期(lastIncludedTerm)。
  2. Leader 发送 InstallSnapshot RPC:当 Leader 发现某个 Follower 的 nextIndex 远小于 Leader 的 lastIncludedIndex(即该 Follower 的日志远远落后于 Leader 的快照点),或者 Leader 无法通过 AppendEntries RPC 找到与 Follower 匹配的日志条目时,Leader 会向该 Follower 发送 InstallSnapshot RPC。
  3. InstallSnapshot RPC 内容:InstallSnapshot RPC 包含以下信息:
    • Leader 的当前任期。
    • 快照的 lastIncludedIndexlastIncludedTerm
    • 快照数据本身(通常是分块传输)。
  4. Follower 接收并安装快照:
    • Follower 收到 InstallSnapshot RPC 后,会首先检查 Leader 的任期。如果 Leader 的任期小于自己的任期,则拒绝。
    • 如果 Leader 的任期有效,Follower 会将快照数据写入本地文件,并用快照中的状态替换自己的状态机。
    • Follower 会丢弃所有索引小于或等于 lastIncludedIndex 的日志条目。
    • 如果 Follower 已经有部分日志条目在 lastIncludedIndex 之后,它会保留这些日志条目,并从 lastIncludedIndex 之后开始与 Leader 进行日志同步。
  5. Follower 响应:Follower 完成快照安装后,会向 Leader 发送响应,告知 Leader 快照安装成功。

日志量太大怎么办?

当日志量太大时,快照机制是解决这个问题的关键。具体来说:

  • 减少传输量:新节点不需要复制所有历史日志,只需要复制一个相对较小的快照文件,以及快照点之后的新日志。这大大减少了网络传输的数据量和时间。
  • 快速追赶:通过快照,新节点可以快速地将自己的状态机更新到接近 Leader 的最新状态,然后通过常规的日志复制机制追赶剩余的少量日志。
  • Leader 资源优化:Leader 不需要存储和管理所有历史日志。当日志被快照包含后,Leader 可以安全地删除这些旧的日志条目,从而节省存储空间。

总结

新节点加入 Raft 集群时,如果日志量不大,会通过常规的 AppendEntries RPC 进行日志同步。但当日志量太大时,Raft 会利用快照机制。Leader 会将当前状态机的快照发送给新节点,新节点加载快照后,再通过常规日志复制追赶快照点之后的新日志。这种机制有效地解决了全量日志复制的效率问题,确保了新节点能够快速、高效地加入集群,同时保证了系统的高可用性和数据一致性。

4、Proactor与Reactor?

Proactor 和 Reactor 是两种常见的异步 I/O 事件处理模式,它们都旨在解决高并发 I/O 操作的效率问题,但处理方式和侧重点有所不同。

Reactor 模式(反应器模式)

Reactor 模式是一种同步非阻塞 I/O 的多路复用模型。它的核心思想是,当 I/O 事件(如连接建立、数据可读、数据可写)发生时,操作系统会通知应用程序,然后应用程序主动去处理这些事件。它通常用于处理就绪事件

工作流程

  1. 事件多路复用器(Event Demultiplexer):这是 Reactor 模式的核心组件,例如 selectpollepoll(Linux)或 kqueue(FreeBSD/macOS)。它负责监听多个 I/O 事件源(如套接字),并在事件就绪时通知应用程序。
  2. Reactor(反应器):Reactor 对象本身是一个事件循环,它阻塞在事件多路复用器上,等待 I/O 事件的发生。一旦有事件就绪,Reactor 会将事件分发给相应的事件处理器(Handler)。
  3. 事件处理器(Event Handler):事件处理器负责处理具体的 I/O 操作。它通常包含两个阶段:
    • 事件就绪:当事件多路复用器通知某个 I/O 事件就绪时,事件处理器会被激活。
    • 执行 I/O 操作:事件处理器会主动调用非阻塞的 I/O 操作(如 readwrite)来完成数据传输。由于是非阻塞的,如果数据未完全准备好,操作会立即返回,不会阻塞线程。

特点

  • 同步非阻塞:I/O 操作本身是非阻塞的,但应用程序需要主动检查 I/O 是否就绪,并在就绪后进行同步的 read/write 操作。
  • 事件驱动:基于事件通知机制,当事件就绪时才进行处理。
  • 主动读取/写入:应用程序需要主动调用 read/write 来获取或发送数据。
  • 复杂性:应用程序需要管理 I/O 缓冲区、处理半包、粘包等问题,逻辑相对复杂。
  • 适用场景:适用于处理大量并发连接,但每个连接的 I/O 操作量相对较小,或者 I/O 操作可以快速完成的场景,如 Web 服务器、消息队列等。

Proactor 模式(前摄器模式)

Proactor 模式是一种异步 I/O 模型。它的核心思想是,应用程序发起一个异步 I/O 操作,然后立即返回,不阻塞当前线程。当 I/O 操作完成时,操作系统会通知应用程序,并提供操作结果(包括数据)。它通常用于处理完成事件

工作流程

  1. 异步操作发起者(Asynchronous Operation Initiator):应用程序发起一个异步 I/O 操作(如 aio_readaio_write),并提供一个回调函数或完成例程(Completion Handler)。
  2. Proactor(前摄器):Proactor 负责管理异步 I/O 操作。它通常会维护一个完成队列(Completion Queue)或使用操作系统提供的异步 I/O 机制(如 Windows 的 I/O Completion Ports, IOCP)。
  3. 完成事件分发器(Completion Event Demultiplexer):当异步 I/O 操作完成时,操作系统会将完成事件放入完成队列。Proactor 会从完成队列中取出完成事件,并将其分发给相应的完成例程。
  4. 完成例程(Completion Handler):完成例程是应用程序提供的回调函数,它在 I/O 操作完成后被调用。完成例程可以直接处理已经完成的数据,而不需要再次调用 read/write

特点

  • 异步非阻塞:I/O 操作本身是异步的,应用程序发起操作后立即返回,不需要等待 I/O 完成。当 I/O 完成时,系统会通知并提供结果。
  • 完成驱动:基于完成事件通知机制,当 I/O 操作完全完成后才进行处理。
  • 被动读取/写入:操作系统负责将数据读取到缓冲区或从缓冲区写入,应用程序只需要提供缓冲区和回调函数。
  • 简化逻辑:应用程序不需要管理 I/O 缓冲区和处理半包等问题,因为这些都由操作系统或 Proactor 框架完成,逻辑相对简单。
  • 适用场景:适用于 I/O 操作耗时较长、数据量较大,或者需要充分利用多核 CPU 的场景,如高性能文件服务器、数据库系统等。

对比总结

特性Reactor 模式Proactor 模式
I/O 模型同步非阻塞 I/O(多路复用)异步 I/O
事件类型就绪事件(I/O 可读/可写)完成事件(I/O 操作完成)
谁来读写应用程序主动调用 read/write操作系统/Proactor 框架负责读写,应用程序提供缓冲区
阻塞点Reactor 阻塞在事件多路复用器上等待就绪事件应用程序发起异步操作后立即返回,不阻塞
复杂性应用程序需要处理 I/O 缓冲区、半包等应用程序逻辑相对简单,由框架处理 I/O 细节
适用场景大量并发连接,I/O 操作量小且快速完成I/O 操作耗时较长、数据量大,充分利用多核 CPU
典型实现select/poll/epoll、Netty、Nginx、Node.jsWindows IOCP、Boost.Asio(部分)

在面试中,除了清晰地解释两者的区别,还可以强调:

  • 操作系统支持:Reactor 模式在各种操作系统上都有很好的支持(select/poll/epoll 是跨平台的),而 Proactor 模式的实现则高度依赖于操作系统提供的异步 I/O API(如 Windows 的 IOCP,Linux 的 io_uring 正在逐渐完善)。
  • 编程模型:Reactor 模式的编程模型通常是“事件循环 + 回调”,而 Proactor 模式更接近于“提交任务 + 完成通知”。
  • 性能考量:对于 CPU 密集型任务,两者可能没有显著差异。但对于 I/O 密集型任务,Proactor 模式理论上能更好地利用 I/O 并发,因为它允许在 I/O 操作进行时,CPU 可以执行其他任务,从而提高整体吞吐量。然而,实际应用中,Reactor 模式通过多线程或多进程结合非阻塞 I/O 也能达到非常高的性能。
  • 选择依据:选择哪种模式取决于具体的应用场景、操作系统支持以及开发团队对复杂性的接受程度。例如,在 Linux 上,由于 epoll 的高效性,Reactor 模式(如 Nginx、Redis)被广泛使用。而在 Windows 上,IOCP 的成熟使得 Proactor 模式成为高性能网络编程的首选。

5、什么场景使用异步IO,什么场景使用同步IO?

选择同步 I/O 还是异步 I/O 取决于具体的应用场景、性能需求以及对编程复杂度的权衡。

同步 I/O (Synchronous I/O)

同步 I/O 的特点是,当应用程序发起 I/O 操作(如读文件、网络请求)时,它会阻塞当前线程,直到 I/O 操作完成并返回结果。在 I/O 操作进行期间,CPU 处于等待状态,不能执行其他任务。

适用场景

  1. 简单、低并发的应用:

    • 脚本和工具:例如,简单的命令行工具、一次性数据处理脚本。这些应用通常不需要处理大量并发请求,同步 I/O 简单直观,易于编写和调试。
    • 配置读取、日志写入:在应用程序启动时读取配置文件,或者在运行时写入日志文件。这些操作通常是串行的,且对响应时间要求不高。
  2. CPU 密集型任务:

    • 如果应用程序的主要瓶颈在于 CPU 计算,而不是 I/O 操作,那么同步 I/O 的阻塞特性对整体性能影响不大。因为即使 I/O 操作是非阻塞的,CPU 也会忙于计算,无法充分利用 I/O 的并发性。
    • 例如:图像处理、视频编码、科学计算等。
  3. 对编程模型要求简单:

    同步 I/O 的编程模型符合人类的思维习惯,代码流程直观,易于理解和维护。对于初学者或小型项目,同步 I/O 是更自然的选择。

  4. 资源受限的环境:

    在某些嵌入式系统或资源非常有限的环境中,异步 I/O 框架可能引入额外的内存开销和复杂性,此时同步 I/O 可能是更合适的选择。

异步 I/O (Asynchronous I/O)

异步 I/O 的特点是,当应用程序发起 I/O 操作时,它会立即返回,不阻塞当前线程。I/O 操作在后台进行,当操作完成时,系统会通过回调、事件通知或 Future/Promise 等机制通知应用程序。这使得单个线程可以在等待 I/O 完成的同时执行其他任务,从而提高系统的并发性和吞吐量。

适用场景

  1. 高并发、I/O 密集型应用:

    • 网络服务器:如 Web 服务器(Nginx、Node.js)、API 网关、聊天服务器。这些应用需要同时处理成千上万的客户端连接,每个连接都可能涉及网络 I/O。异步 I/O 可以让服务器在等待一个客户端数据时,去处理其他客户端的请求,大大提高并发处理能力。
    • 数据库连接池:在应用程序需要频繁地与数据库进行交互时,异步 I/O 可以避免因数据库查询阻塞线程,提高数据库访问效率。
    • 文件服务器:需要同时处理大量文件读写请求的场景,如云存储服务。
  2. 响应时间敏感的应用:

    实时系统:例如,在线游戏服务器、金融交易系统。这些系统对响应时间有极高的要求,任何阻塞都可能导致用户体验下降或业务损失。异步 I/O 可以确保即使某个 I/O 操作耗时较长,也不会影响其他操作的响应。

  3. 利用多核 CPU 优势:

    异步 I/O 配合事件循环或线程池,可以更好地利用多核 CPU 的并行处理能力。当一个 I/O 操作在等待时,CPU 可以切换到其他任务,或者处理其他已完成的 I/O 事件。

  4. 微服务架构中的服务间通信:

    在微服务架构中,服务之间通常通过网络进行通信。使用异步 I/O 可以避免服务调用链中的阻塞,提高整个系统的吞吐量和弹性。

总结与权衡

特性同步 I/O异步 I/O
阻塞性阻塞当前线程不阻塞当前线程
并发性低(通常需要多线程/多进程实现并发)高(单个线程可处理多个并发 I/O)
编程模型简单、直观、顺序执行复杂(回调、事件循环、Future/Promise)
资源利用I/O 阻塞时 CPU 空闲I/O 阻塞时 CPU 可处理其他任务
适用场景低并发、CPU 密集型、简单工具、配置/日志读写高并发、I/O 密集型、网络服务器、实时系统

在实际项目中,往往会结合使用同步和异步 I/O。例如,一个 Web 服务器的核心网络通信部分会使用异步 I/O 来处理高并发连接,但对于一些内部的、非性能关键的本地文件操作,可能仍然使用同步 I/O 以简化代码。

关键在于识别瓶颈

  • 如果应用程序的瓶颈在于等待 I/O 完成,那么异步 I/O 是提高性能的关键。
  • 如果应用程序的瓶颈在于CPU 计算,那么即使使用异步 I/O,性能提升也有限,甚至可能因为异步框架的额外开销而略有下降。

因此,选择同步还是异步 I/O,是架构设计中一个重要的决策,需要根据应用的具体需求和性能目标进行权衡。

6、epoll,select和poll?

selectpollepoll 都是 Linux 系统中用于实现 I/O 多路复用(I/O Multiplexing)的系统调用。它们允许单个进程(或线程)同时监听多个文件描述符(File Descriptor, FD)上的 I/O 事件(如可读、可写、异常),并在事件就绪时通知应用程序,从而避免了阻塞式 I/O 带来的性能问题,提高了并发处理能力。

1. select

select 是最早的 I/O 多路复用机制,在 POSIX 标准中定义。

工作原理

  • 用户通过三个 fd_set 集合(readfdswritefdsexceptfds)来告诉内核需要监听哪些文件描述符的哪些事件。
  • 调用 select 后,内核会遍历所有被监听的文件描述符,检查是否有 I/O 事件就绪。如果没有,进程会阻塞,直到有事件就绪或超时。
  • 当有事件就绪时,内核会修改传入的 fd_set 集合,清除未就绪的 FD,只保留就绪的 FD,并返回就绪 FD 的数量。
  • 应用程序需要再次遍历 fd_set 集合,找出哪些 FD 就绪,然后进行相应的 I/O 操作。

缺点

  • 文件描述符数量限制:fd_set 是一个位图,通常由宏 FD_SETSIZE 限制,默认是 1024。这意味着 select 最多只能监听 1024 个文件描述符。虽然可以通过修改内核参数来增大,但仍然有上限。
  • 效率低下:
    • 每次调用 select 都需要将 fd_set 从用户空间拷贝到内核空间,FD 数量越多,拷贝开销越大。
    • 内核需要遍历所有被监听的 FD 来检查事件,FD 数量越多,遍历开销越大。
    • 应用程序也需要遍历所有 FD 来找出就绪的 FD,同样存在遍历开销。
  • “水平触发”模式:如果一个 FD 上的事件就绪,但应用程序没有完全处理(例如,只读取了一部分数据),那么下次调用 select 时,该 FD 仍然会被报告为就绪。这可能导致应用程序陷入忙循环。

2. poll

pollselect 的改进版本,解决了 select 的一些限制。

工作原理

  • poll 使用一个 pollfd 结构体数组来代替 fd_set。每个 pollfd 结构体包含一个文件描述符、要监听的事件(events 字段)和实际发生的事件(revents 字段)。
  • 调用 poll 后,内核同样会遍历所有被监听的 FD,检查事件。如果没有,进程阻塞。
  • 当有事件就绪时,内核会设置相应 pollfd 结构体的 revents 字段,并返回就绪 FD 的数量。
  • 应用程序需要遍历 pollfd 数组,检查 revents 字段来找出就绪的 FD。

优点

  • 没有文件描述符数量限制:poll 使用链表来存储 pollfd 结构体,理论上只受限于系统内存,不再有 1024 的硬性限制。
  • 接口更清晰:通过 eventsrevents 字段,可以更灵活地指定和获取事件类型。

缺点

  • 效率问题依然存在:
    • 虽然没有了 FD 数量限制,但每次调用 poll 仍然需要将整个 pollfd 数组从用户空间拷贝到内核空间。
    • 内核仍然需要遍历所有被监听的 FD 来检查事件。
    • 应用程序也需要遍历所有 FD 来找出就绪的 FD。
  • “水平触发”模式:与 select 相同,仍然是水平触发。

3. epoll

epoll 是 Linux 特有的,为解决 selectpoll 在大规模并发连接场景下的性能瓶颈而设计。它在 Linux 2.5.44 内核中引入,并被广泛应用于高性能网络服务器。

工作原理

epoll 引入了三个系统调用来管理:

  • epoll_create():创建一个 epoll 实例,返回一个 epoll 文件描述符。这个 FD 代表了内核中的一个事件表(event table)。
  • epoll_ctl():用于向 epoll 实例中添加、修改或删除要监听的文件描述符及其事件。它通过红黑树(Red-Black Tree)来管理 FD,并通过链表来管理就绪事件。
  • epoll_wait():阻塞等待 I/O 事件的发生。当有事件就绪时,内核会将就绪的 FD 列表直接拷贝到用户空间,应用程序无需遍历所有 FD。

优点

  • 没有文件描述符数量限制:与 poll 类似,只受限于系统内存。
  • 效率高:
    • 事件注册与就绪分离:epoll_ctl 负责注册和管理 FD,epoll_wait 只负责等待就绪事件。FD 列表只需要拷贝一次到内核,后续操作都在内核中进行。
    • 只返回就绪事件:epoll_wait 返回时,只会返回真正就绪的 FD 列表,应用程序无需遍历所有监听的 FD,大大减少了 CPU 开销。
    • 基于事件的通知:内核使用回调机制,当 FD 就绪时,会将其添加到就绪列表中,而不是每次都遍历所有 FD。
  • 支持两种触发模式:
    • 水平触发(LT, Level Triggered):默认模式,与 select/poll 相同。只要 FD 上有数据可读或可写,就会一直报告就绪,直到数据被完全处理。
    • 边缘触发(ET, Edge Triggered):只在状态发生变化时(例如,从不可读变为可读,或有新数据到达)报告一次就绪。应用程序必须一次性读完或写完所有数据,否则下次 epoll_wait 不会再报告该事件。ET 模式可以减少 epoll_wait 的调用次数,提高效率,但编程复杂度更高。

对比总结

特性selectpollepoll
FD 限制1024(默认),硬性限制理论上无限制,受内存限制理论上无限制,受内存限制
效率每次调用都拷贝所有 FD,内核遍历所有 FD每次调用都拷贝所有 FD,内核遍历所有 FD注册时拷贝一次,只返回就绪 FD,内核事件通知
触发模式水平触发(LT)水平触发(LT)水平触发(LT)和边缘触发(ET)
编程接口fd_set 位图,操作复杂pollfd 结构体数组,相对清晰三个系统调用,接口更复杂但功能强大
适用场景早期或少量并发连接少量到中等并发连接大规模并发连接,高性能网络服务器

在面试中,除了清晰地解释三者的区别,还可以强调:

  • 演进关系:select -> poll -> epoll 是 Linux I/O 多路复用机制不断演进的过程,体现了操作系统为了适应高并发网络应用而进行的优化。
  • 性能瓶颈:selectpoll 的主要性能瓶颈在于每次调用都需要将所有监听的 FD 集合从用户空间拷贝到内核空间,以及内核需要遍历所有 FD 来检查事件。当 FD 数量非常大时,这两个操作的开销会变得非常显著。
  • epoll 的核心优化:epoll 通过将 FD 注册和事件等待分离,以及只返回就绪事件的机制,彻底解决了 selectpoll 的性能瓶颈。它不再需要每次都拷贝所有 FD,也不需要每次都遍历所有 FD,而是通过内核事件通知机制,只将就绪的 FD 返回给用户。
  • 触发模式的选择:水平触发模式更简单易用,但可能导致不必要的 epoll_wait 调用。边缘触发模式效率更高,但要求应用程序必须一次性处理完所有数据,否则可能丢失事件,编程难度更大。
  • 实际应用:在高性能网络编程中,epoll 已经成为 Linux 平台的事实标准,例如 Nginx、Redis、Node.js 等都大量使用了 epoll

7、操作系统的启动过程?

操作系统的启动过程是一个复杂而精密的协作过程,涉及到硬件、固件(BIOS/UEFI)和操作系统的多个阶段。理解这个过程对于深入理解计算机系统至关重要。

  1. 加电自检 (POST - Power-On Self Test)
  • 触发:当计算机加电或重启时,CPU 会从一个预设的地址(通常是 0xFFFF0,指向 BIOS/UEFI 的启动代码)开始执行指令。
  • 功能:BIOS (Basic Input/Output System) 或 UEFI (Unified Extensible Firmware Interface) 开始执行。它们首先进行加电自检,检查计算机的基本硬件设备是否正常工作,包括 CPU、内存、显卡、键盘、鼠标等。如果检测到严重错误,会通过蜂鸣声或屏幕信息报告。
  • 初始化:POST 成功后,BIOS/UEFI 会初始化一些基本的硬件设备,如中断控制器、定时器、DMA 控制器等。
  1. 寻找并加载引导程序 (Boot Loader)
  • 启动顺序:BIOS/UEFI 会根据用户在固件设置中配置的启动顺序(如硬盘、USB、光驱、网络)来寻找可引导设备。
  • 主引导记录 (MBR) 或 GUID 分区表 (GPT):
    • 对于传统 BIOS,它会在找到的第一个可引导设备的第一个扇区(通常是硬盘的 0 磁道 0 扇区)读取 MBR。MBR 包含一个小的引导程序(Boot Loader 的第一阶段)和分区表。
    • 对于 UEFI,它会读取 GPT,并根据 EFI 系统分区(ESP)中的信息找到引导程序(通常是 .efi 文件)。
  • 加载引导程序:BIOS/UEFI 将 MBR 中的引导程序(或 UEFI 找到的 .efi 引导程序)加载到内存中,并将控制权交给它。
  1. 引导程序阶段 (Boot Loader Stage)

引导程序通常分为多个阶段,以克服 MBR 空间有限的限制。以 Linux 上的 GRUB (Grand Unified Bootloader) 为例:

  • 第一阶段 (Stage 1):MBR 中的引导程序非常小,其主要任务是加载引导程序的第二阶段。
  • 第二阶段 (Stage 1.5 / Stage 2):
    • GRUB Stage 1.5:位于 MBR 和第一个分区之间(如果存在),负责加载文件系统驱动,以便能够读取文件系统中的 Stage 2。
    • GRUB Stage 2:这是引导程序的核心部分,通常位于文件系统中的 /boot/grub 目录下。它负责:
      • 显示启动菜单:允许用户选择要启动的操作系统(多系统共存时)。
      • 加载内核映像:根据用户的选择,将操作系统的内核映像(如 Linux 的 vmlinuz)和初始化内存盘(initramfsinitrd)加载到内存中。
      • 传递启动参数:向内核传递启动参数,如根文件系统位置、运行级别等。
  • 控制权移交:引导程序将控制权移交给已加载到内存中的操作系统内核。
  1. 内核初始化阶段 (Kernel Initialization)

当内核获得控制权后,它开始执行一系列初始化任务:

  • 解压内核:如果内核是压缩的(通常是),它会先进行自解压。
  • 初始化内存管理:设置页表、内存管理单元(MMU),初始化物理内存和虚拟内存系统。
  • 初始化中断和异常处理:设置中断描述符表(IDT),注册中断处理程序。
  • 初始化设备驱动:检测并初始化核心硬件设备,如 CPU、定时器、硬盘控制器、网络接口等。一些驱动可能在 initramfs 中。
  • 挂载根文件系统:
    • 如果使用了 initramfs(一个临时的根文件系统),内核会先挂载并执行其中的 init 程序。
    • initramfs 中的 init 程序会负责加载必要的模块(如硬盘驱动、文件系统驱动),然后找到并挂载真正的根文件系统(通常是 /)。
    • 一旦真正的根文件系统挂载成功,initramfs 的作用就完成了,控制权会移交给真正的根文件系统中的 init 程序。
  • 创建第一个用户态进程:内核会创建第一个用户态进程,通常是 init 进程(在 Linux 中是 systemdSysVinitUpstart 等)。init 进程的 PID 为 1。
  1. 用户态初始化阶段 (User-Space Initialization)

init 进程是所有其他用户态进程的祖先。它负责:

  • 启动系统服务:根据配置文件(如 inittabsystemd 单元文件),启动各种系统服务和守护进程,如网络服务、日志服务、图形界面服务等。
  • 设置运行级别/目标:根据配置进入不同的运行级别(如多用户模式、图形界面模式)。
  • 提供登录界面:当所有必要的服务启动完成后,系统会显示登录界面(命令行或图形界面),等待用户输入用户名和密码。

总结

整个启动过程可以概括为:

  1. 硬件自检与固件初始化 (BIOS/UEFI POST)
  2. 加载引导程序 (Boot Loader)
  3. 引导程序加载内核 (GRUB/LILO)
  4. 内核初始化 (内存、驱动、根文件系统)
  5. 用户态初始化 (init 进程启动服务)

这是一个从底层硬件到上层应用逐步接管控制权、逐步初始化系统资源的过程,确保了操作系统的稳定运行。

8、进程与线程?

进程和线程是操作系统中两个核心的概念,它们都是 CPU 调度的基本单位,但代表了不同层次的并发和资源管理。理解它们的区别和联系对于编写高效、并发的程序至关重要。

1. 进程 (Process)

进程是操作系统进行资源分配和调度的基本单位。它是程序的一次执行过程,是系统进行资源分配的独立实体。

主要特点

  • 独立性:每个进程都拥有独立的地址空间(虚拟内存空间),包括代码段、数据段、堆、栈等。进程之间的数据是隔离的,一个进程的崩溃通常不会影响其他进程。
  • 资源拥有者:进程是系统资源的拥有者,包括 CPU 时间、内存、文件句柄、I/O 设备等。操作系统为每个进程维护一个进程控制块(PCB),其中包含了进程的所有状态信息和资源清单。
  • 调度单位:操作系统以进程为单位进行调度,分配 CPU 时间片。
  • 通信方式:进程间通信(IPC)相对复杂,需要通过特定的机制,如管道、消息队列、共享内存、信号量、套接字等。
  • 创建与销毁开销:创建和销毁进程的开销较大,因为需要分配和回收独立的地址空间及其他资源。

2. 线程 (Thread)

线程是进程内部的一个执行单元,是 CPU 调度的最小单位。一个进程可以包含一个或多个线程。

主要特点

  • 共享性:同一个进程内的所有线程共享该进程的地址空间和大部分资源,包括代码段、数据段、文件描述符、堆等。这意味着线程之间可以直接访问进程的共享数据,通信效率高。
  • 独立执行流:每个线程有独立的程序计数器、栈、寄存器集合和线程本地存储(TLS)。线程可以独立地执行代码。
  • 调度单位:操作系统以线程为单位进行调度,分配 CPU 时间片。
  • 通信方式:线程间通信相对简单,可以直接通过共享内存进行数据交换,但需要注意同步和互斥问题(如使用互斥锁、条件变量、信号量等)。
  • 创建与销毁开销:创建和销毁线程的开销较小,因为它们共享进程的资源,不需要重新分配地址空间。

3. 进程与线程的对比总结

特性进程 (Process)线程 (Thread)
资源分配操作系统资源分配的基本单位不拥有资源,共享所属进程的资源
调度操作系统调度的基本单位操作系统调度的最小单位
独立性独立地址空间,相互隔离,一个崩溃不影响其他共享进程地址空间,一个崩溃可能影响同进程其他线程
开销创建、销毁、切换开销大创建、销毁、切换开销小
通信IPC 机制(管道、消息队列、共享内存等),复杂共享内存,需要同步机制(锁、条件变量等),简单
并发性进程间并发进程内并发,提高进程内部的并行度

4. 为什么引入线程?

引入线程主要是为了解决进程在某些场景下的不足:

  • 提高并发度:在多核 CPU 环境下,一个进程内的多个线程可以并行执行,充分利用多核优势,提高程序的吞吐量。
  • 降低开销:线程的创建、销毁和切换开销远小于进程,这使得在需要频繁切换执行流的场景下,线程更加高效。
  • 简化通信:同一进程内的线程共享地址空间,数据通信更加方便和高效,避免了复杂的 IPC 机制。
  • 响应性:对于 GUI 应用程序,可以将耗时的操作放在单独的线程中执行,避免主线程阻塞,从而保持界面的响应性。

5. 编程实践中的选择

  • 多进程:
    • 优点:稳定性高,一个进程崩溃不影响其他进程;资源隔离性好,安全性高。
    • 缺点:通信复杂,开销大。
    • 适用场景:需要高可靠性、安全性,或者不同任务之间需要强隔离的场景,如 Web 服务器(Nginx 的 Worker 进程)、数据库系统、分布式系统中的独立服务。
  • 多线程:
    • 优点:开销小,通信方便,提高并发度。
    • 缺点:共享资源导致同步问题(死锁、竞态条件),编程复杂性增加;一个线程崩溃可能导致整个进程崩溃。
    • 适用场景:需要频繁进行任务切换、共享大量数据、对响应时间要求高的场景,如 GUI 应用程序、高性能计算、网络服务器中的连接处理(如 Java 的 Tomcat 默认使用多线程)。

在面试中,除了清晰地解释概念,还可以强调:

  • 操作系统视角:从操作系统的角度看,进程是资源分配的单位,线程是 CPU 调度的单位。这是最核心的区别。
  • 资源共享与隔离:强调进程的资源隔离性带来的安全性和线程的资源共享带来的高效性,以及各自带来的编程挑战。
  • 实际应用:结合实际的软件系统(如浏览器、Web 服务器、数据库)来阐述它们如何利用进程和线程来实现并发和性能优化。
  • 权衡:没有绝对的优劣,只有适合不适合。选择进程还是线程,是根据应用的需求、系统架构和开发成本进行权衡的结果。

9、用户态与内核态?

用户态(User Mode)和内核态(Kernel Mode)是操作系统为了保护系统资源和提高系统稳定性而引入的两种 CPU 工作状态。它们是 CPU 的两种特权级别,决定了当前正在运行的代码可以访问哪些资源和执行哪些指令。

1. 用户态 (User Mode)

  • 定义:当应用程序(如浏览器、文字处理器、游戏等)运行时,CPU 通常处于用户态。
  • 权限:在用户态下,代码的权限受到严格限制。它只能访问用户程序自己的内存空间,不能直接访问内核空间或硬件设备。用户态程序也不能执行特权指令(如修改 CPU 寄存器、访问 I/O 端口等)。
  • 安全性:这种限制是为了保护操作系统的核心代码和数据不被用户程序随意修改或破坏,从而保证系统的稳定性和安全性。即使一个用户程序崩溃,也不会影响到整个操作系统。
  • 执行方式:用户态程序通过调用系统调用(System Call)来请求内核服务,从而间接访问受保护的资源或执行特权操作。

2. 内核态 (Kernel Mode)

  • 定义:当操作系统内核(如内存管理、进程调度、文件系统、设备驱动等)运行时,CPU 处于内核态。
  • 权限:在内核态下,代码拥有最高权限。它可以访问系统的所有内存空间(包括用户空间和内核空间),可以直接操作硬件设备,并可以执行所有特权指令。
  • 安全性:内核态是操作系统的核心,它的代码必须是高度可靠和安全的。任何在内核态下的错误都可能导致整个系统崩溃(例如,著名的“蓝屏死机”)。
  • 执行方式:内核态代码通常是操作系统的一部分,负责管理和协调系统资源。

3. 为什么需要用户态和内核态?

引入用户态和内核态的主要目的是为了实现保护和隔离:

  • 保护操作系统:防止用户程序恶意或无意地破坏操作系统内核的数据和代码,确保操作系统的稳定运行。
  • 保护用户程序:防止一个用户程序访问或修改另一个用户程序的内存空间,实现进程间的隔离。
  • 资源管理:操作系统作为资源的管理者,需要对硬件资源进行统一调度和分配。通过内核态,操作系统可以独占这些资源,并以受控的方式提供给用户程序。
  • 提高系统稳定性:即使某个用户程序出现错误,由于其权限受限,错误通常只影响该程序本身,而不会导致整个系统崩溃。

4. 用户态与内核态的切换

用户态和内核态之间的切换是操作系统运行过程中非常频繁的操作。切换通常发生在以下几种情况:

  • 系统调用 (System Call):用户程序需要访问受保护的资源(如读写文件、创建进程、网络通信等)时,会发起系统调用。系统调用是用户程序进入内核态的唯一合法途径。当用户程序发起系统调用时,CPU 会从用户态切换到内核态,执行相应的内核代码,完成服务后,再从内核态切换回用户态。
  • 中断 (Interrupt):硬件设备(如定时器、网卡、硬盘等)发生事件时,会向 CPU 发送中断信号。CPU 收到中断后,会暂停当前正在执行的用户程序,从用户态切换到内核态,执行中断服务程序(ISR)。中断处理完成后,再从内核态切换回用户态,继续执行被中断的用户程序。
  • 异常 (Exception):当用户程序执行过程中发生错误(如除零错误、访问非法内存地址、缺页中断等)时,会触发异常。CPU 会从用户态切换到内核态,执行异常处理程序。根据异常的类型,内核可能会终止该程序,或者尝试恢复。

切换过程

  1. 保存上下文:当从用户态切换到内核态时,CPU 会保存当前用户程序的上下文信息(如寄存器值、程序计数器等),以便在返回用户态时能够恢复执行。
  2. 切换栈:CPU 会从用户栈切换到内核栈,因为内核有自己的独立栈空间。
  3. 权限提升:CPU 的特权级别从用户态提升到内核态。
  4. 执行内核代码:CPU 开始执行相应的内核代码(系统调用处理函数、中断服务程序或异常处理程序)。
  5. 恢复上下文:内核代码执行完毕后,CPU 会恢复之前保存的用户程序上下文。
  6. 权限降级:CPU 的特权级别从内核态降级到用户态。
  7. 返回用户态:CPU 返回到用户程序中断或发起系统调用的位置,继续执行。

5. 性能开销

用户态和内核态的频繁切换会带来一定的性能开销,因为每次切换都需要保存和恢复 CPU 上下文、切换栈等。因此,在设计高性能系统时,会尽量减少不必要的系统调用和中断。

10、lazy allocation过程?

“Lazy Allocation”通常指的是延迟分配按需分配,在操作系统内存管理中是一个非常重要的概念,尤其是在虚拟内存系统中。它的核心思想是:只在真正需要时才分配和初始化资源,而不是在声明或预定时就立即分配。

这种策略可以显著提高系统效率、减少资源浪费,并改善程序的启动性能。

Lazy Allocation 的主要应用场景

Lazy Allocation 最典型的应用是在虚拟内存管理中,主要体现在以下几个方面:

  1. 进程创建时的内存分配(Copy-on-Write, COW):

    • 当使用 fork() 系统调用创建一个子进程时,子进程会继承父进程的地址空间。如果立即为子进程复制父进程的所有内存,开销会非常大,尤其当父进程内存占用很高时。
    • Lazy Allocation 策略:操作系统不会立即复制父进程的所有页面。相反,父子进程会共享相同的物理页面,但这些页面都被标记为只读。
    • 按需复制:只有当父进程或子进程尝试写入这些共享页面时,才会触发一个写时复制(Copy-on-Write, COW)机制。此时,操作系统会捕获到写操作,然后为写入方(父进程或子进程)复制一个新的物理页面,并修改其页表条目指向新的页面。这样,只有被修改的页面才会被复制,大大节省了内存和复制时间。
  2. 堆内存的延迟分配:

    • 当程序通过 malloc()new 请求一大块内存时,操作系统通常不会立即分配对应的物理页面。它可能只是在进程的虚拟地址空间中预留(reserve)一块区域,并更新进程的页表,但不会立即映射到物理内存。
    • Lazy Allocation 策略:只有当程序真正访问(读或写)到这块虚拟内存区域的某个页面时,才会触发一个缺页中断(Page Fault)。
    • 按需映射:操作系统捕获到缺页中断后,会为该虚拟页面分配一个物理页面,并更新页表,将虚拟页面映射到新分配的物理页面。然后,程序可以继续执行。
    • 优点:
      • 减少内存浪费:程序可能申请了大量内存,但实际只使用了其中一小部分。延迟分配可以避免为未使用的内存分配物理页面。
      • 提高启动速度:程序启动时不需要立即加载所有数据到内存,可以更快地进入运行状态。
  3. 栈内存的延迟增长:

    • 程序的栈通常会预留一个较大的虚拟地址空间,但初始时只分配少量物理页面。
    • Lazy Allocation 策略:当栈向下增长(或向上增长,取决于系统架构)并触及到未映射的虚拟页面时,会触发缺页中断,操作系统再按需分配新的物理页面给栈。
  4. 文件映射(Memory-Mapped Files):

    • 当一个文件被内存映射时(如使用 mmap() 系统调用),文件内容并不会立即全部加载到物理内存中。
    • Lazy Allocation 策略:只有当程序访问到文件映射区域的某个页面时,才会触发缺页中断,操作系统再从文件中读取相应的数据并加载到物理页面中。

Lazy Allocation 的优点

  • 提高内存利用率:避免为未使用的虚拟地址空间分配物理内存,从而减少物理内存的浪费。
  • 加快程序启动速度:程序无需在启动时加载所有数据,可以更快地开始执行。
  • 支持更大的虚拟地址空间:允许程序使用比实际物理内存更大的虚拟地址空间,因为只有被访问的部分才需要物理内存。
  • 优化 fork() 性能:通过写时复制(COW)机制,显著降低了 fork() 的开销。

Lazy Allocation 的缺点/挑战

  • 缺页中断开销:每次发生缺页中断都需要操作系统介入,进行页面分配和页表更新,这会带来一定的性能开销。如果缺页中断过于频繁,可能会影响性能。
  • 内存碎片:频繁的页面分配和回收可能导致物理内存碎片化。
  • 复杂性:实现延迟分配机制需要操作系统更复杂的内存管理逻辑,包括页表管理、缺页中断处理、页面置换算法等。

11、多级页表的好处?

多级页表(Multi-level Page Table),也称为分级页表(Hierarchical Page Table),是现代操作系统中管理虚拟内存和物理内存映射的一种常用技术。它解决了单级页表在管理巨大虚拟地址空间时所面临的存储效率问题。

1. 单级页表的局限性

在理解多级页表的好处之前,我们先回顾一下单级页表的局限性:

  • 虚拟地址空间巨大:现代计算机系统通常支持 32 位或 64 位虚拟地址空间。例如,一个 32 位系统可以寻址 4GB (2^32 字节) 的虚拟内存,而 64 位系统可以寻址 16EB (2^64 字节) 的虚拟内存。
  • 页表项(PTE)数量庞大:如果采用单级页表,并且页大小为 4KB (2^12 字节),那么一个 32 位系统需要 2^32 / 2^12 = 2^20 = 1M 个页表项。每个页表项通常占用 4 字节,那么一个进程的页表就需要 4MB (1M * 4B) 的连续物理内存来存储。
  • 内存浪费:
    • 连续性要求:单级页表必须存储在连续的物理内存中,这在物理内存碎片化时很难满足。
    • 稀疏性问题:一个进程通常不会使用其全部虚拟地址空间,而是只使用其中的一小部分(例如,代码段、数据段、堆、栈等)。如果采用单级页表,即使大部分虚拟地址空间未被使用,也需要为所有可能的虚拟地址分配页表项,导致大量的页表项是空的,浪费了大量内存。

2. 多级页表的工作原理

多级页表将一个巨大的页表分解成更小的、可管理的页表。它通过引入多层索引来查找物理页帧号。以二级页表为例:

  • 虚拟地址的划分:虚拟地址被划分为多个部分,例如:
    • 页目录号(Page Directory Number):用于索引页目录表。
    • 页表号(Page Table Number):用于索引页表。
    • 页内偏移(Page Offset):用于定位页内的具体字节。
  • 页目录表(Page Directory Table):第一级页表,其页表项指向第二级页表(页表)。
  • 页表(Page Table):第二级页表,其页表项指向最终的物理页帧。

查找物理地址的过程是:

  1. 使用虚拟地址的页目录号在页目录表中找到对应的页目录项。
  2. 页目录项指向一个页表(第二级页表)的物理地址。
  3. 使用虚拟地址的页表号在该页表中找到对应的页表项。
  4. 页表项指向最终的物理页帧的物理地址。
  5. 结合页内偏移,得到最终的物理地址。

3. 多级页表的好处

多级页表主要解决了单级页表的内存浪费和连续性问题,带来了以下显著好处:

  • 节省内存空间(主要优势):
    • 按需分配:多级页表不需要为整个虚拟地址空间预先分配所有页表项。只有当某个虚拟地址范围被实际使用时,才会创建对应的页表。例如,如果一个进程只使用了虚拟地址空间的两端(代码段和栈),那么中间未使用的巨大区域就不需要分配对应的页表,从而节省了大量内存。
    • 稀疏性利用:由于进程的虚拟地址空间通常是稀疏的,多级页表可以有效地利用这种稀疏性,只为实际使用的虚拟地址范围分配页表。
  • 无需连续物理内存:
    • 页目录表和各个页表都可以存储在不连续的物理内存中。这大大降低了对物理内存连续性的要求,使得内存管理更加灵活。
  • 更高效的内存管理:
    • 当进程的虚拟地址空间增长时(如堆的扩展),只需要按需分配新的页表,而不是重新分配整个巨大的单级页表。
    • 在进程退出时,可以更容易地释放不再需要的页表。
  • 支持更大的虚拟地址空间:
    • 通过增加页表的级数,可以有效地管理非常大的虚拟地址空间(如 64 位系统),而不会导致页表本身占用过多的物理内存。

4. 缺点

  • 地址转换开销增加:每次进行虚拟地址到物理地址的转换时,需要多次访问内存(例如,二级页表需要两次内存访问:一次访问页目录表,一次访问页表)。这会增加地址转换的时间。
  • 解决方案:为了缓解地址转换开销,现代 CPU 通常会使用转换后备缓冲区(TLB - Translation Lookaside Buffer)。TLB 是一个高速缓存,用于存储最近使用的虚拟地址到物理地址的映射。如果映射在 TLB 中命中,就可以避免多次内存访问,大大加速地址转换过程。

12、cpp多态?

C++ 中的多态(Polymorphism)是面向对象编程(OOP)的三大特性之一(封装、继承、多态)。它允许我们使用一个基类指针或引用来操作派生类对象,从而实现“一个接口,多种实现”的效果。多态性增强了代码的灵活性、可扩展性和可维护性。

1. 多态的分类

C++ 中的多态主要分为两种:

  • 编译时多态(静态多态):在程序编译阶段确定函数调用。主要通过函数重载(Function Overloading)和运算符重载(Operator Overloading)实现。
    • 函数重载:在同一个作用域内,函数名相同但参数列表(参数类型、参数个数、参数顺序)不同的函数。
    • 运算符重载:允许为用户自定义类型重新定义运算符的行为。
    • 模板(Templates):泛型编程,虽然不是严格意义上的多态,但也能实现“一个接口,多种类型”的效果。
  • 运行时多态(动态多态):在程序运行阶段确定函数调用。主要通过虚函数(Virtual Functions)和继承实现。

2. 运行时多态的实现条件

要实现 C++ 的运行时多态,必须满足以下三个条件:

  1. 必须有继承关系:基类和派生类之间必须存在继承关系。
  2. 必须有虚函数:基类中必须声明虚函数(使用 virtual 关键字),并且派生类可以重写(Override)这些虚函数。
  3. 必须通过基类指针或引用调用虚函数:只有通过基类的指针或引用来调用虚函数时,才能表现出多态性。如果通过对象本身调用,或者通过派生类指针/引用调用,则会发生静态绑定。

3. 运行时多态的实现原理

C++ 运行时多态的实现依赖于虚函数表(Virtual Table, vtable)和虚函数指针(Virtual Pointer, vptr)。

  • 虚函数表(vtable):
    • 当一个类中声明了虚函数时,编译器会为该类生成一个虚函数表。虚函数表是一个函数指针数组,其中存储了该类中所有虚函数的地址。
    • 如果派生类重写了基类的虚函数,那么在派生类的虚函数表中,对应位置的函数指针会指向派生类中重写的函数。
    • 如果派生类没有重写基类的虚函数,那么在派生类的虚函数表中,对应位置的函数指针会指向基类中的虚函数。
  • 虚函数指针(vptr):
    • 当一个类中声明了虚函数时,该类的每个对象都会在其内存布局的起始位置包含一个虚函数指针(vptr)。
    • vptr 指向该对象所属类的虚函数表。

调用过程

  1. 当通过基类指针或引用调用一个虚函数时,编译器会生成代码,首先通过基类指针/引用找到对象的 vptr。
  2. 然后,通过 vptr 找到该对象所属类的虚函数表。
  3. 接着,在虚函数表中找到对应虚函数的地址(根据虚函数在类声明中的顺序),并调用该地址处的函数。

这样,即使基类指针指向的是派生类对象,也能正确调用到派生类中重写的虚函数,从而实现了运行时多态。

4. 纯虚函数与抽象类

  • 纯虚函数:在虚函数声明的末尾加上 = 0,表示这是一个纯虚函数。纯虚函数没有函数体,它强制派生类必须实现该函数。
    cpp
    virtual void pureVirtualFunc() = 0;
  • 抽象类:如果一个类中包含至少一个纯虚函数,那么这个类就是抽象类。抽象类不能被实例化(不能创建对象),它只能作为基类被继承。抽象类的作用是定义一个接口规范,强制派生类实现某些行为。

5. 多态的优点

  • 代码可扩展性:当需要添加新的派生类时,无需修改基类或使用基类指针/引用的现有代码,只需实现新的派生类并重写虚函数即可。这符合“开闭原则”(对扩展开放,对修改关闭)。
  • 代码可维护性:通过统一的接口处理不同类型的对象,降低了代码的耦合度,使得代码更易于理解和维护。
  • 代码复用性:基类可以定义通用的行为,派生类可以复用这些行为,并根据需要重写特定行为。
  • 灵活性:可以在运行时动态地决定调用哪个函数版本,增加了程序的灵活性。

6. 多态的缺点

  • 性能开销:相比于静态绑定,虚函数调用需要额外的开销(查找虚函数表、间接调用),但这种开销通常非常小,在大多数应用中可以忽略不计。
  • 内存开销:每个包含虚函数的类都会有一个虚函数表,每个该类的对象都会有一个虚函数指针(vptr),这会增加一定的内存开销。
  • 调试复杂性:运行时多态使得函数调用路径在编译时无法完全确定,可能增加调试的复杂性。

13、智能指针?

智能指针(Smart Pointers)是 C++11 及更高版本中引入的一种 RAII(Resource Acquisition Is Initialization)机制,用于管理动态分配的内存。它们本质上是封装了原始指针的类模板,通过在对象生命周期结束时自动释放所指向的内存,从而避免了传统原始指针可能导致的内存泄漏和悬空指针问题。

1. 为什么需要智能指针?

传统的 C++ 原始指针在管理动态内存时存在以下问题:

  • 内存泄漏(Memory Leak):如果程序员忘记 deletenew 出来的内存,或者在 delete 之前程序发生异常,导致 delete 语句没有被执行,就会造成内存泄漏。
  • 悬空指针(Dangling Pointer):当一块内存被 delete 释放后,如果还有其他指针指向这块内存,这些指针就变成了悬空指针。再次使用这些悬空指针会导致未定义行为。
  • 重复释放(Double Free):对同一块内存进行多次 delete 操作,也会导致未定义行为,甚至程序崩溃。

智能指针通过 RAII 机制,将资源的生命周期与对象的生命周期绑定。当智能指针对象超出作用域时,其析构函数会自动调用 delete 来释放所管理的内存,从而有效解决了上述问题。

2. C++11 提供的三种智能指针

C++11 标准库提供了三种智能指针:std::unique_ptrstd::shared_ptrstd::weak_ptr

2.1 std::unique_ptr (独占式智能指针)

  • 特性:unique_ptr 独占所管理的对象,即同一时间只有一个 unique_ptr 可以指向给定的对象。它不能被复制,但可以被移动(std::move)。
  • 所有权:unique_ptr 拥有其所指向对象的所有权。当 unique_ptr 被销毁时,它会自动 delete 所指向的对象。
  • 使用场景:
    • 当需要确保对象只有一个所有者时。
    • 作为函数返回值,将所有权转移给调用者。
    • 在容器中存储动态分配的对象。
  • 示例:
    cpp
    #include <memory>
    #include <iostream>
    
    class MyClass {
    public:
        MyClass() { std::cout << MyClass() { std::cout << "MyClass constructor\n"; }
        ~MyClass() { std::cout << "MyClass destructor\n"; }
        void greet() { std::cout << "Hello from MyClass!\n"; }
    };
    int main() {
        // 创建一个 unique_ptr
        std::unique_ptr<MyClass> ptr1(new MyClass());
        ptr1->greet();
    
        // 所有权转移:ptr1 的所有权转移给 ptr2,ptr1 变为 nullptr
        std::unique_ptr<MyClass> ptr2 = std::move(ptr1);
        if (ptr1 == nullptr) {
            std::cout << "ptr1 is now null.\n";
        }
        ptr2->greet();
    
        // ptr2 超出作用域时,MyClass 对象会被自动销毁
        return 0;
    }

2.2 std::shared_ptr (共享式智能指针)

  • 特性:shared_ptr 允许多个智能指针共享同一个对象的所有权。它通过引用计数(Reference Count)来管理对象的生命周期。
  • 所有权:当一个新的 shared_ptr 复制指向同一个对象时,引用计数会增加。当一个 shared_ptr 被销毁或指向另一个对象时,引用计数会减少。只有当引用计数变为 0 时,所指向的对象才会被自动 delete
  • 使用场景:
    • 当多个部分需要共享同一个对象,并且对象的生命周期由这些共享者共同决定时。
    • 在工厂函数中返回对象。
  • 示例:
    cpp
    #include <memory>
    #include <iostream>
    
    class MyClass {
    public:
        MyClass() { std::cout << "MyClass constructor\n"; }
        ~MyClass() { std::cout << "MyClass destructor\n"; }
        void greet() { std::cout << "Hello from MyClass!\n"; }
    };
    
    void func(std::shared_ptr<MyClass> p) {
        std::cout << "Inside func, ref count: " << p.use_count() << "\n";
    }
    
    int main() {
        std::shared_ptr<MyClass> ptr1(new MyClass());
        std::cout << "ptr1 ref count: " << ptr1.use_count() << "\n"; // 1
    
        std::shared_ptr<MyClass> ptr2 = ptr1; // 复制,引用计数增加
        std::cout << "ptr1 ref count: " << ptr1.use_count() << "\n"; // 2
        std::cout << "ptr2 ref count: " << ptr2.use_count() << "\n"; // 2
    
        func(ptr1); // 传递给函数,引用计数增加再减少
        std::cout << "After func, ptr1 ref count: " << ptr1.use_count() << "\n"; // 2
    
        ptr1.reset(); // 释放所有权,引用计数减少
        std::cout << "After ptr1 reset, ptr2 ref count: " << ptr2.use_count() << "\n"; // 1
    
        // ptr2 超出作用域时,引用计数变为 0,MyClass 对象会被自动销毁
        return 0;
    }

2.3 std::weak_ptr (弱引用智能指针)

  • 特性:weak_ptr 是一种不拥有对象所有权的智能指针。它指向一个由 shared_ptr 管理的对象,但不会增加对象的引用计数。
  • 使用场景:
    • 主要用于解决 shared_ptr 导致的循环引用(Circular Reference)问题(详见下一个问题)。
    • 观察者模式中,观察者持有被观察者的弱引用,避免被观察者无法被销毁。
    • 缓存管理中,缓存项可以持有对象的弱引用,当对象不再被其他 shared_ptr 引用时,缓存项可以自动失效。
  • 使用方法:
    • weak_ptr 不能直接访问所指向的对象,需要通过 lock() 方法获取一个 shared_ptr。如果对象已被销毁,lock() 会返回一个空的 shared_ptr
    • expired() 方法可以检查所指向的对象是否已被销毁。
  • 示例:
    cpp
    #include <memory>
    #include <iostream>
    
    class MyClass {
    public:
        MyClass() { std::cout << "MyClass constructor\n"; }
        ~MyClass() { std::cout << "MyClass destructor\n"; }
    };
    
    int main() {
        std::shared_ptr<MyClass> shared_ptr_obj = std::make_shared<MyClass>();
        std::weak_ptr<MyClass> weak_ptr_obj = shared_ptr_obj; // weak_ptr 不增加引用计数
    
        if (auto sp = weak_ptr_obj.lock()) { // 尝试获取 shared_ptr
            std::cout << "Object is still alive.\n";
        } else {
            std::cout << "Object has been destroyed.\n";
        }
    
        shared_ptr_obj.reset(); // 销毁 shared_ptr_obj,MyClass 对象被销毁
    
        if (auto sp = weak_ptr_obj.lock()) {
            std::cout << "Object is still alive. (This should not happen)\n";
        } else {
            std::cout << "Object has been destroyed.\n";
        }
    
        return 0;
    }

3. 智能指针的优势总结

  • 自动内存管理:通过 RAII 机制,自动释放动态分配的内存,避免内存泄漏。
  • 安全性:有效避免悬空指针和重复释放等问题。
  • 异常安全:即使在发生异常时,也能保证内存的正确释放。
  • 简化代码:无需手动管理 newdelete,使代码更简洁、更易读。
  • 提高代码健壮性:减少了因内存管理错误而导致的程序崩溃和未定义行为。

14、循环引用?

循环引用(Circular Reference)是使用引用计数(Reference Counting)机制管理内存时,一个常见且难以解决的问题。在 C++ 中,它主要发生在 std::shared_ptr 之间,导致对象无法被正确释放,从而引发内存泄漏。

1. 什么是循环引用?

当两个或多个 std::shared_ptr 对象相互持有对方的 std::shared_ptr,形成一个闭环时,就会发生循环引用。在这种情况下,即使外部已经没有 shared_ptr 指向这个闭环中的任何对象,它们的引用计数也永远不会降为零,导致这些对象及其所占用的内存永远无法被释放。

示例

考虑两个类 AB,它们相互持有对方的 shared_ptr

cpp
#include <memory>
#include <iostream>

class B;

class A {
public:
    std::shared_ptr<B> b_ptr;
    A() { std::cout << "A constructor\n"; }
    ~A() { std::cout << "A destructor\n"; }
    void set_b(std::shared_ptr<B> b) { b_ptr = b; }
};

class B {
public:
    std::shared_ptr<A> a_ptr;
    B() { std::cout << "B constructor\n"; }
    ~B() { std::cout << "B destructor\n"; }
    void set_a(std::shared_ptr<A> a) { a_ptr = a; }
};

int main() {
    std::shared_ptr<A> pa = std::make_shared<A>();
    std::shared_ptr<B> pb = std::make_shared<B>();

    // 互相引用
    pa->set_b(pb); // pa 引用 pb,pb 的引用计数变为 2 (pa->b_ptr 和 pb)
    pb->set_a(pa); // pb 引用 pa,pa 的引用计数变为 2 (pb->a_ptr 和 pa)

    std::cout << "pa use_count: " << pa.use_count() << "\n"; // 2
    std::cout << "pb use_count: " << pb.use_count() << "\n"; // 2

    // main 函数结束时,pa 和 pb 超出作用域,引用计数各自减 1
    // pa 的引用计数变为 1 (pb->a_ptr 仍然持有)
    // pb 的引用计数变为 1 (pa->b_ptr 仍然持有)
    // 引用计数都不会降到 0,导致 A 和 B 的析构函数都不会被调用,发生内存泄漏。

    return 0;
}

运行上述代码,你会发现 A destructorB destructor 不会被打印出来,这表明 AB 对象没有被销毁,发生了内存泄漏。

2. 为什么 shared_ptr 会导致循环引用?

shared_ptr 的引用计数机制是基于“强引用”的。只要有一个 shared_ptr 指向对象,该对象的引用计数就不会为零,对象就不会被销毁。当形成循环时,即使外部所有 shared_ptr 都已失效,环内的 shared_ptr 仍然相互持有强引用,导致引用计数永远大于零,从而无法触发析构。

3. 如何解决循环引用?

解决 shared_ptr 循环引用的关键是打破强引用循环,引入弱引用。C++ 标准库提供了 std::weak_ptr 来实现弱引用。

std::weak_ptr 的作用

  • weak_ptr 是一种不拥有对象所有权的智能指针。它指向一个由 shared_ptr 管理的对象,但不会增加对象的引用计数。
  • weak_ptr 可以被看作是 shared_ptr 的一个“观察者”,它知道对象是否存在,但不会阻止对象的销毁。
  • shared_ptr 管理的对象被销毁时,所有指向它的 weak_ptr 都会自动失效。

解决方案:

在循环引用中,将其中一个 shared_ptr 改为 weak_ptr,从而打破强引用循环。

修改上面的示例:将 B 类中对 A 的引用改为 weak_ptr

cpp
#include <memory>
#include <iostream>

class B;

class A {
public:
    std::shared_ptr<B> b_ptr;
    A() { std::cout << "A constructor\n"; }
    ~A() { std::cout << "A destructor\n"; }
    void set_b(std::shared_ptr<B> b) { b_ptr = b; }
};

class B {
public:
    std::weak_ptr<A> a_ptr; // 改为 weak_ptr
    B() { std::cout << "B constructor\n"; }
    ~B() { std::cout << "B destructor\n"; }
    void set_a(std::shared_ptr<A> a) { a_ptr = a; }
};

int main() {
    std::shared_ptr<A> pa = std::make_shared<A>();
    std::shared_ptr<B> pb = std::make_shared<B>();

    // 互相引用
    pa->set_b(pb); // pa 引用 pb,pb 的引用计数变为 2 (pa->b_ptr 和 pb)
    pb->set_a(pa); // pb 引用 pa,pa 的引用计数保持 1 (只有 pa)

    std::cout << "pa use_count: " << pa.use_count() << "\n"; // 2
    std::cout << "pb use_count: " << pb.use_count() << "\n"; // 2

    // main 函数结束时:
    // 1. pa 和 pb 超出作用域,各自的引用计数减 1。
    // 2. pa 的引用计数变为 1 (因为 pa->b_ptr 仍然持有 pb,pb 的引用计数为 1)。
    // 3. pb 的引用计数变为 1 (因为 pb->a_ptr 是 weak_ptr,不增加 pa 的引用计数)。
    // 4. 当 pa 的引用计数变为 0 时 (因为 pb->a_ptr 是 weak_ptr,不阻止 pa 的销毁),A 对象被销毁。
    // 5. A 对象销毁时,其成员 b_ptr (shared_ptr<B>) 被销毁,导致 pb 的引用计数减 1,变为 0。
    // 6. pb 的引用计数变为 0 时,B 对象被销毁。

    return 0;
}

运行修改后的代码,你会看到 A destructorB destructor 被正确打印,表明内存得到了正确释放。

4. 如何判断何时使用 weak_ptr

通常,当两个对象之间存在相互引用,并且这种引用关系是非拥有关系(即一个对象不应该阻止另一个对象的销毁)时,就应该考虑使用 weak_ptr

  • 父子关系:通常父对象拥有子对象,子对象可以持有父对象的 weak_ptr
  • 观察者模式:观察者通常持有被观察者的 weak_ptr,这样当被观察者被销毁时,观察者不会阻止其销毁。
  • 缓存:缓存中的对象可以持有实际数据的 weak_ptr,当数据不再被其他地方强引用时,缓存中的条目可以自动失效。

15、手撕LRU?

LRU(Least Recently Used)缓存淘汰算法是一种常见的缓存策略,其核心思想是:如果一个数据在最近一段时间没有被访问,那么在将来它被访问的可能性也很小。 因此,当缓存空间不足时,优先淘汰最长时间未被使用的数据。

1. LRU 算法的核心思想

LRU 算法的实现需要满足两个主要操作:

  • get(key):如果 key 存在于缓存中,返回其对应的值,并将该 key 移动到缓存的“最新”位置。如果 key 不存在,返回特殊值(如 -1)。
  • put(key, value)
    • 如果 key 已经存在,更新其值,并将其移动到缓存的“最新”位置。
    • 如果 key 不存在:
      • 如果缓存已满,淘汰“最旧”的数据,然后插入新数据。
      • 如果缓存未满,直接插入新数据。
    • 插入新数据后,将其放在缓存的“最新”位置。

2. LRU 算法的数据结构选择

为了高效地实现 LRU 算法,我们需要两种数据结构:

  1. 哈希表(std::unordered_mapstd::map):
    • 用于存储 keyvalue 的映射,以及 key 到链表节点的映射。
    • 通过 key 可以在 O(1) 的时间复杂度内快速查找数据是否存在,并获取其对应的链表节点。
  2. 双向链表(std::list 或自定义双向链表):
    • 用于维护数据的访问顺序。链表头部代表最新访问的数据,链表尾部代表最旧访问的数据。
    • 当数据被访问或插入时,需要将其从链表中移除并添加到链表头部。
    • 当缓存满时,可以直接从链表尾部移除最旧的数据。
    • 双向链表允许在 O(1) 时间复杂度内删除任意节点(给定节点指针)。

3. C++ 实现 LRU Cache

我们将使用 std::list 作为双向链表,std::unordered_map 作为哈希表。

cpp
#include <iostream>
#include <list>
#include <unordered_map>

class LRUCache {
private:
    // 双向链表节点,存储 key-value 对
    // list 存储的是 pair<int, int>,代表 (key, value)
    std::list<std::pair<int, int>> cacheList;

    // 哈希表,存储 key 到链表节点的迭代器映射
    // unordered_map 存储的是 key 到 list<pair<int, int>>::iterator 的映射
    std::unordered_map<int, std::list<std::pair<int, int>>::iterator> cacheMap;

    int capacity; // 缓存容量

public:
    LRUCache(int capacity) : capacity(capacity) {}

    int get(int key) {
        // 1. 检查 key 是否存在于哈希表中
        auto it = cacheMap.find(key);
        if (it == cacheMap.end()) {
            // key 不存在,返回 -1
            return -1;
        }

        // 2. key 存在,获取对应的链表节点(pair<key, value>)
        // it->second 是指向 cacheList 中 pair<key, value> 的迭代器
        std::pair<int, int> kv = *(it->second);

        // 3. 将该节点从链表中移除
        cacheList.erase(it->second);

        // 4. 将该节点移动到链表头部(表示最近访问)
        cacheList.push_front(kv);

        // 5. 更新哈希表中 key 对应的迭代器,指向新的链表头部节点
        cacheMap[key] = cacheList.begin();

        // 6. 返回 value
        return kv.second;
    }

    void put(int key, int value) {
        // 1. 检查 key 是否存在于哈希表中
        auto it = cacheMap.find(key);
        if (it != cacheMap.end()) {
            // key 存在,更新其值,并将其移动到链表头部
            // 先从链表中移除旧节点
            cacheList.erase(it->second);
            // 插入新节点到头部
            cacheList.push_front({key, value});
            // 更新哈希表中 key 对应的迭代器
            cacheMap[key] = cacheList.begin();
        } else {
            // key 不存在
            // 2. 检查缓存是否已满
            if (cacheList.size() >= capacity) {
                // 缓存已满,需要淘汰最旧的数据(链表尾部)
                // 从哈希表中移除被淘汰的 key
                cacheMap.erase(cacheList.back().first);
                // 从链表中移除尾部节点
                cacheList.pop_back();
            }

            // 3. 插入新数据到链表头部
            cacheList.push_front({key, value});

            // 4. 在哈希表中添加 key 到新链表头部节点的映射
            cacheMap[key] = cacheList.begin();
        }
    }

    // 辅助函数:打印缓存内容(从最新到最旧)
    void printCache() {
        std::cout << "Cache (LRU -> MRU): ";
        for (const auto& p : cacheList) {
            std::cout << "(" << p.first << ", " << p.second << ") ";
        }
        std::cout << "\n";
    }
};

// 示例用法
int main() {
    LRUCache cache(2); // 容量为 2 的 LRU 缓存

    cache.put(1, 1);
    cache.printCache(); // (1, 1)

    cache.put(2, 2);
    cache.printCache(); // (2, 2) (1, 1)

    std::cout << "Get 1: " << cache.get(1) << "\n"; // 返回 1
    cache.printCache(); // (1, 1) (2, 2) - 1 变为最新

    cache.put(3, 3); // 缓存满,淘汰最旧的 2
    cache.printCache(); // (3, 3) (1, 1) - 2 被淘汰

    std::cout << "Get 2: " << cache.get(2) << "\n"; // 返回 -1
    cache.printCache(); // (3, 3) (1, 1)

    cache.put(4, 4); // 缓存满,淘汰最旧的 1
    cache.printCache(); // (4, 4) (3, 3) - 1 被淘汰

    std::cout << "Get 1: " << cache.get(1) << "\n"; // 返回 -1
    std::cout << "Get 3: " << cache.get(3) << "\n"; // 返回 3
    cache.printCache(); // (3, 3) (4, 4) - 3 变为最新

    return 0;
}

4. 时间复杂度分析

  • get(key)
    • 哈希表查找:O(1) 平均时间复杂度。
    • 链表删除:O(1) 时间复杂度(因为有迭代器)。
    • 链表插入头部:O(1) 时间复杂度。
    • 哈希表更新:O(1) 平均时间复杂度。
    • 总计:O(1) 平均时间复杂度。
  • put(key, value)
    • 哈希表查找:O(1) 平均时间复杂度。
    • 链表删除(如果存在):O(1) 时间复杂度。
    • 链表尾部删除(如果缓存满):O(1) 时间复杂度。
    • 链表插入头部:O(1) 时间复杂度。
    • 哈希表插入/更新:O(1) 平均时间复杂度。
    • 总计:O(1) 平均时间复杂度。