LIFO Cache (Last In First Out Cache) — 是一种缓存算法,当缓存达到最大尺寸时,最后添加的元素将被淘汰。与考虑访问模式的 LRU 不同,LIFO 仅依赖于添加顺序:新元素淘汰上一个新元素。根据 Android Developers (2026),LIFO Cache 仅在狭窄场景下有效,如导航栈和操作撤销缓冲。
核心要点
LIFO Cache(Last In First Out Cache)— 是基于栈实现的有限大小缓存。当向已满的缓存添加新元素时,最新(最顶部)的元素被删除,新元素占据其位置。“Last In First Out”这个名称意味着最后进入缓存的元素将首先被删除。
这种策略与 LRU 和 FIFO 根本不同。LRU 试图保留最新的数据(按最后访问时间),FIFO 保留数据的“年龄”,而 LIFO 则有意牺牲新数据。这对于缓存来说可能显得不合逻辑,但对于某些特定的 场景,LIFO 被证明是最优解决方案。
经典的 LIFO Cache 实现使用基于数组或链表的栈。数组提供了紧凑存储和缓存局部性,但需要为 maxSize 预先分配内存。链表更灵活,但每个元素需要额外的指针内存(每个元素 8–16 字节)。
push(value) 操作将元素添加到栈顶。如果大小已达到 maxSize,则在插入前栈顶被删除。pop() 操作删除并返回栈顶元素——对于“撤销最后一个操作”的场景很有用。peek() 操作返回栈顶元素但不删除——用于查看最后保存的状态而不改变栈。
LIFO Cache 的工作原理非常简单:所有操作都在结构的一端——栈顶完成。添加新元素时,它被放在栈顶。如果栈已满,栈顶元素被弹出(删除),新元素占据其位置。淘汰始终只影响一个元素——栈顶,因此算法不需要遍历或搜索。
这一特性使 LIFO Cache 成为所有淘汰策略中最快的:所有操作均在 O(1) 时间内完成,无需任何额外的数据结构。不需要哈希表进行搜索,不需要双向链表进行重新排序——只需要一个简单的栈顶指针就够了。内存消耗极少:仅用于存储元素本身。
然而,简单性也有其缺点:LIFO Cache 不考虑访问数据的频率或最后访问时间。如果应用程序先请求数据 A、B、C,然后再次请求 A,那么在溢出时,将会删除 C(最后添加的),即使 A 已不再是最新。对于 通用缓存场景,这使得 LIFO 成为最差的选择,因为新数据通常是最有价值的。
对于基于数组的 LIFO Cache,大小在创建时设定,不会动态变化。如果栈已满并发生 push——栈顶元素被覆盖。对于基于链表的实现,内存按需要为每个元素分配,但在达到限制后,旧节点被断开,可以被垃圾回收器回收。在 移动应用中,建议使用数组实现 LIFO Cache,因为它不会给 GC 带来额外负担。
淘汰策略的选择直接影响缓存效率。LIFO、LRU 和 FIFO 代表了对同一个问题的不同解决方案:溢出时应该删除哪个元素?每种方法都对其自身的任务类别最优。
| 参数 | LIFO | FIFO | LRU |
|---|---|---|---|
| 淘汰标准 | 最后添加的 | 第一个添加的 | 最近最少使用的 |
| 结构 | 栈 | 队列 | HashMap + Doubly Linked List |
| 命中率 | 低(10–30%) | 中(40–60%) | 高(60–95%) |
| 实现复杂度 | 最小 | 低 | 中 |
| 内存消耗 | 最小 | 低 | 中(额外指针) |
LRU 通常提供最佳命中率,但需要更多内存且实现更复杂。FIFO — 性能和命中率之间的折衷,适合流式数据。LIFO — 最简单,但命中率低:仅当“最后进来——第一个出去”的语义与业务逻辑相符时应用(导航、操作撤销)。
尽管对于通用缓存适用性有限,LIFO Cache 在数据处理顺序与到达顺序相反的特定场景中有其应用。让我们来看看主要情况。
在移动应用中使用导航栈:打开新屏幕时,它被放在栈顶,按下“返回”按钮时被删除。如果栈的深度受到限制(例如最多 10 个屏幕),LIFO Cache 将在超过限制时自动删除最新的屏幕。这使得能够在不丢失之前打开的屏幕的情况下控制导航栈的 内存消耗。
撤销操作的机制(Undo)— LIFO 的经典例子。用户的每个操作都保存在栈中。调用 Undo 时,最后一个操作被撤销并移动到 Redo 栈。通过 LIFO Cache 限制栈大小确保在超过限制时,最早的操作(在栈底)保留,最新的操作被丢弃——这是合理的,因为 用户 通常撤销最近的操作,而旧的操作已不再有效。
在带有回溯(backtracking)的递归计算中,中间步骤的结果按 LIFO 顺序保存。当缓冲区溢出时,最后一个结果被丢弃——这是可以接受的,因为算法可以在必要时重新计算。这种方法应用于 解析器、编译器和带深度限制的图遍历算法。
让我们看看在 Kotlin 中使用固定大小数组实现 LIFO Cache。数组为移动设备提供了最佳性能和最小内存消耗。
class LifoCache<V>(
private val maxSize: Int
) {
private val array = arrayOfNulls<V>(maxSize)
private var top = -1
fun push(value: V) {
if (top == maxSize - 1) {
top-- // 当满时丢弃最早的
}
array[++top] = value
}
fun pop(): V? {
if (top == -1) return null
val result = array[top]
array[top--] = null
return result
}
fun peek(): V? {
return array[top]
}
}
top 索引指向栈顶。push 增大 top 并写入值;如果数组已满(top == maxSize - 1),在写入前 top 减小——栈顶被覆盖,实现了 LIFO 淘汰。pop 方法返回元素并减小 top,而 peek 仅读取栈顶元素不改变栈。
让我们看看在 Jetpack Compose 中使用 LIFO Cache 限制导航深度。打开新屏幕时,它被添加到栈中,超过限制时,最新的屏幕被淘汰。
class NavigationStack(maxDepth: Int = 10) {
private val cache = LifoCache<Screen>(maxDepth)
fun navigateTo(screen: Screen) {
cache.push(screen)
}
fun goBack(): Screen? {
return cache.pop()
}
fun currentScreen(): Screen? {
return cache.peek()
}
}
在这个示例中,NavigationStack 使用 LIFO Cache 存储屏幕历史。调用 navigateTo 时,屏幕被添加到栈中,调用 goBack 时删除最后一个。如果用户在限制为 10 的情况下打开了 11 个屏幕,最新的(第 11 个)将淘汰前一个(第 10 个)——第一个屏幕保留在栈中,这符合用户返回时的期望。这种策略在导航方面比 LRU 更有效:删除早已打开的屏幕(“首页”、“个人资料”)将导致意料之外的行为。
常见问题
LIFO 删除了很可能将被再次需要的新数据——这违反了引用局部性原则。多数应用程序都显示出最近请求的数据是最新的趋势,因此在一般场景下 LRU 或 LFU 提供了明显更好的命中率。
LIFO Cache 是一个有限容量的栈。栈按 LIFO 原则工作:最后添加的元素位于栈顶。溢出时,栈顶(最后一个元素)被删除,新元素占据其位置。一个带有一个 top 索引的数组就够了——不需要额外的结构。
LIFO 在新数据明显比旧数据更不值钱的场景下更有效:导航栈(最后一个屏幕应首先删除)、Undo/Redo(最后一个操作首先被撤销)、递归计算缓冲(backtracking)。在这些情况下,LIFO 不仅更简单,而且在语义上比 LRU 更正确。
可以,存在混合方法。例如,LIFO + FIFO:使用 LIFO 进行操作处理(指令栈)和 FIFO 进行长期存储(结果队列)。像 ARC(Adaptive Replacement Cache)这样的 自适应算法可以根据访问模式在 LRU 和 LFO 之间动态切换,但 LIFO 作为混合组件并不常见。
一个包含 N 个引用/值的数组占用恰好 N × 元素大小字节,加上数组对象本身的少量额外开销(在 JVM 中为 24–40 字节)。与 LRU 不同,不需要额外的 prev/next 指针(在双向链表中每个元素 16 字节)。对于内存有限的移动设备,基于数组的 LIFO 是最经济的实现。
总结
我们将开发一款交钥匙移动应用程序
IT Sectr自2017年以来为初创企业和企业打造iOS和Android应用程序。我们将为您提供咨询并提出最佳解决方案。