计算机内存与缓存

计算机的存储设备

计算机中包括三种类型的存储设备:

  • 硬盘(hard disk), 用于长期存储大量数据
  • 内存(random-access memory, RAM), 用于临时存储程序运行中正在处理的数据
  • 缓存(cache memory), 用于存储经常访问的数据和指令

在程序运行时,数据会从硬盘中被读取到内存中,供 CPU 计算使用。缓存可以看作 CPU 的一部分,它通过智能地从内存加载数据,给 CPU 提供高速的数据读取,从而显著提升程序的执行效率,减少对较慢的内存的依赖。

硬盘内存缓存
用途长期存储数据,包括操作系统、程序、文件等临时存储当前运行的程序和正在处理的数据存储经常访问的数据和指令,减少 CPU 访问内存的次数
易失性断电后数据不会丢失断电后数据会丢失断电后数据会丢失
容量较大,TB 级别较小,GB 级别非常小,MB 级别
速度较慢,几百到几千 MB/s较快,几十 GB/s非常快,几十到几百 GB/s
价格较便宜,几毛到几元 / GB较贵,几十到几百元 / GB非常贵,随 CPU 打包计价

硬盘难以被内存取代

  • 首先,内存中的数据在断电后会丢失,因此它不适合长期存储数据;
  • 其次,内存的成本是硬盘的几十倍,这使得它难以在消费者市场普及。

缓存的大容量和高速度难以兼得

  • 随着 L1、L2、L3 缓存的容量逐步增大,其物理尺寸会变大,与 CPU 核心之间的物理距离会变远,从而导致数据传输时间增加,元素访问延迟变高。
  • 在当前技术下,多层级的缓存结构是容量、速度和成本之间的最佳平衡点。

内存效率

内存是有限的,且同一块内存不能被多个程序共享,因此我们希望数据结构能够尽可能高效地利用空间。

数组的元素紧密排列,不需要额外的空间来存储链表节点间的引用(指针),因此空间效率更高。然而,数组需要一次性分配足够的连续内存空间,这可能导致内存浪费,数组扩容也需要额外的时间和空间成本。相比之下,链表以“节点”为单位进行动态内存分配和回收,提供了更大的灵活性。

在程序运行时,随着反复申请与释放内存,空闲内存的碎片化程度会越来越高,从而导致内存的利用效率降低。数组由于其连续的存储方式,相对不容易导致内存碎片化。相反,链表的元素是分散存储的,在频繁的插入与删除操作中,更容易导致内存碎片化。

内存碎片化

在程序运行过程中,反复申请和释放内存会导致内存碎片化,从而降低内存利用效率。

内存碎片化的本质是内存分配和释放的不连续操作导致空闲内存分散,无法高效利用。

理解这一机制有助于:

  1. 优化内存分配策略(如减少小对象分配)。
  2. 选择合适的工具(如内存池或高效分配器)。
  3. 在系统设计阶段规避潜在的内存瓶颈。

内存分配的基本机制

程序运行时,动态内存(堆内存)的分配和释放由内存管理器(如 malloc/free)负责。

内存管理器需要维护一个空闲内存块列表,记录哪些内存区域是未被占用的。

  • 内存申请:当程序申请内存时,内存管理器会从空闲列表中找到一个足够大的连续内存块分配给程序。
  • 内存释放:当程序释放内存时,内存管理器将释放的内存块重新标记为空闲,并合并相邻的空闲块(如果可能)。

内存碎片化的类型

内存碎片化分为两种类型:

外部碎片(External Fragmentation)

空闲内存分散在已分配内存块之间,形成许多小的、不连续的空闲块。

即使总的空闲内存足够,但缺乏足够大的连续内存块,导致无法满足后续较大的内存申请请求。

  • 假设堆内存初始为 100KB 连续空间。
  • 程序依次申请了 20KB、30KB、50KB 的内存块,然后释放中间的 30KB。
  • 此时空闲内存为 30KB,但被分割为两部分:20KB(已分配)和 30KB(空闲)之间夹着 50KB(已分配)。
  • 若程序需要申请 40KB,虽然总空闲内存为 30KB + 后续可能的其他空闲块,但无法找到连续的 40KB 空间,导致分配失败。
内部碎片(Internal Fragmentation)

分配给程序的内存块中,未被实际使用的部分。浪费已分配的内存空间。

  • 内存管理器按固定大小(如 16KB)分配内存块,但程序仅需要 10KB。
  • 剩余的 6KB 未被使用,但无法被其他程序利用。

内存碎片化的形成过程

初始状态

堆内存为连续的空闲块(例如 100KB)。

多次分配与释放
  • 程序依次申请不同大小的内存块(如 20KB、30KB、40KB)。
  • 随后释放部分内存块(如中间的 30KB)。
  • 释放的内存块被标记为空闲,但可能与相邻的空闲块合并(取决于内存管理器的策略)。
碎片化加剧
  • 反复的分配和释放操作会导致空闲内存块逐渐被分割成更小的、非连续的块。
  • 即使总空闲内存足够,但无法满足较大的连续内存请求。
示例场景
1
2
3
4
5
初始内存    [----------------100KB----------------]
分配后:    [A:20KB][B:30KB    ][C:50KB           ]
释放B后:   [A:20KB][---30KB---][C:50KB           ]
此时,总空闲内存为 30KB,但被分割为两部分(假设内存管理器未合并相邻空闲块)。
若申请 40KB,会失败,因为没有连续的 40KB 空间。

内存碎片化的影响

  • 内存利用率下降:大量小空闲块无法被有效利用。
  • 分配延迟增加:内存管理器需要更复杂的策略来查找可用内存块。
  • 程序崩溃风险:无法满足关键内存申请时,程序可能崩溃或抛出异常(如 OutOfMemoryError)。

内存管理器的应对策略

为减少碎片化,内存管理器会采用以下策略:

合并相邻空闲块(Coalescing)
  • 释放内存时,检查相邻块是否空闲,若空闲则合并为一个更大的块。
  • 示例:释放 B(30KB)后,若相邻块 A 或 C 是空闲的,则合并它们。
内存分配算法
  • 首次适应(First Fit):从空闲列表头部开始查找第一个足够大的块。
  • 最佳适应(Best Fit):查找最小的足够大的块。
  • 最差适应(Worst Fit):查找最大的块。
  • 伙伴系统(Buddy System):将内存按 2 的幂次大小分割,便于合并。
内存池(Memory Pool)
  • 预分配多个固定大小的内存块,减少内部碎片(但可能增加外部碎片)。

实际应用中的解决方案

  • 避免频繁小内存分配:使用对象池或缓存重用对象。
  • 使用高效的内存分配器:如 jemalloctcmalloc,优化碎片化问题。
  • 垃圾回收(GC):在托管语言(如 Java、C#)中,GC 会自动合并碎片(但仍有停顿时间成本)。
  • 内存压缩:移动已分配内存块以合并空闲空间(需暂停程序,如某些 GC 策略)。

缓存效率

缓存虽然在空间容量上远小于内存,但它比内存快得多,在程序执行速度上起着至关重要的作用。

由于缓存的容量有限,只能存储一小部分频繁访问的数据,因此当 CPU 尝试访问的数据不在缓存中时,就会发生缓存未命中(cache miss),此时 CPU 不得不从速度较慢的内存中加载所需数据。

显然,“缓存未命中”越少,CPU 读写数据的效率就越高,程序性能也就越好。

我们将 CPU 从缓存中成功获取数据的比例称为缓存命中率(cache hit rate),这个指标通常用来衡量缓存效率。

为了尽可能达到更高的效率,缓存会采取以下数据加载机制。

  • 缓存行:缓存不是单个字节地存储与加载数据,而是以缓存行为单位。相比于单个字节的传输,缓存行的传输形式更加高效。
  • 预取机制:处理器会尝试预测数据访问模式(例如顺序访问、固定步长跳跃访问等),并根据特定模式将数据加载至缓存之中,从而提升命中率。
  • 空间局部性:如果一个数据被访问,那么它附近的数据可能近期也会被访问。因此,缓存在加载某一数据时,也会加载其附近的数据,以提高命中率。
  • 时间局部性:如果一个数据被访问,那么它在不久的将来很可能再次被访问。缓存利用这一原理,通过保留最近访问过的数据来提高命中率。

实际上,数组和链表对缓存的利用效率是不同的,主要体现在以下几个方面。

  • 占用空间:链表元素比数组元素占用空间更多,导致缓存中容纳的有效数据量更少。
  • 缓存行:链表数据分散在内存各处,而缓存是“按行加载”的,因此加载到无效数据的比例更高。
  • 预取机制:数组比链表的数据访问模式更具“可预测性”,即系统更容易猜出即将被加载的数据。
  • 空间局部性:数组被存储在集中的内存空间中,因此被加载数据附近的数据更有可能即将被访问。

总体而言,数组具有更高的缓存命中率,因此它在操作效率上通常优于链表。但需要注意的是,高缓存效率并不意味着数组在所有情况下都优于链表。实际应用中选择哪种数据结构,应根据具体需求来决定。

想都是问题,做才是答案!
使用 Hugo 构建
主题 StackJimmy 设计