Android SparseArray

1. 为什么会有 SparseArray

SparseArray<E> 是 Android 为 int -> Object 映射设计的轻量级容器。

它不是为了在所有场景替代 HashMap<Integer, E>,而是为了在 Android 常见的中小规模整数键场景里,用更低的内存成本,换取足够好的读写性能。

设计思想:

  • 有序 int[]
  • 对应 Object[]
  • 二分查找
  • 延迟删除

1.1 为什么不直接用 HashMap<Integer, Object>

在 Java 里,如果要做 int -> Object 映射,最直观的写法是:

java 复制代码
HashMap<Integer, Object>

但在 Android 里,这种写法有两个天然问题:

  • int 需要装箱成 Integer
  • HashMap 还需要维护桶、节点、冲突处理等额外结构

这意味着:

  • 对象更多
  • 内存更碎
  • GC 压力更大

而 Android Framework 里恰好大量存在这种模型:

  • viewId -> state
  • resourceId -> cache
  • pid -> process
  • displayId -> context
  • userId -> 某类状态

这些 key 都是 int,而且很多场景数据量并不大。于是 Android 做了一个更贴近自身运行环境的折中方案:

  • 放弃哈希结构
  • 改用数组存储
  • 放弃 O(1) 理论查找
  • 改用 O(log n) 二分查找
  • 换来更好的内存效率

这就是 SparseArray 的出发点。

1.2 它到底在解决什么问题

SparseArray 真正解决的不是"如何做一个更快的 Map",而是"如何在 Android 这种内存敏感、对象敏感、GC 敏感的环境里,用更合适的容器承载整数键状态表"。

所以它的定位从一开始就很明确:

  • 面向 int
  • 面向中小规模数据
  • 面向内存效率优先
  • 面向 Framework 常见状态表场景

2. 基本结构

2.1 相关源码文件

  • frameworks/base/core/java/android/util/SparseArray.java
  • frameworks/base/core/java/android/util/ContainerHelpers.java
  • frameworks/base/core/java/com/android/internal/util/GrowingArrayUtils.java
  • frameworks/base/core/java/com/android/internal/util/ArrayUtils.java

2.2 几个辅助类各做什么

  • SparseArray.java
    • 定义容器本体。
    • 管理键值存储、增删改查、延迟删除、压缩回收、遍历与内容比较。
  • ContainerHelpers.java
    • 提供不做参数校验的二分查找实现。
    • SparseArrayget()put()delete()indexOfKey() 等都依赖它。
  • GrowingArrayUtils.java
    • 负责数组 append()insert() 以及容量增长。
    • SparseArray 的扩容和中间插入都走这里。
  • ArrayUtils.java
    • 负责创建 newUnpaddedIntArray()newUnpaddedObjectArray()
    • 这是 Android 为节省数组额外填充开销做的底层优化入口。

2.3 先看 5 个核心字段

核心字段只有 5 个:

java 复制代码
private static final Object DELETED = new Object();
private boolean mGarbage = false;
private int[] mKeys;
private Object[] mValues;
private int mSize;

mKeys

  • 保存所有 key
  • 始终按升序排列
  • 这是二分查找成立的前提

mValues

  • 保存所有 value
  • 下标和 mKeys 一一对应
  • mKeys[i] 对应 mValues[i]

mSize

  • 表示当前逻辑使用到的数组范围
  • 但在存在删除墓碑时,它不一定等于真实有效元素个数

DELETED

  • 删除标记对象
  • 专门用来表示"这个位置以前有值,现在被删除了"

它不能直接用 null 替代,因为:

  • SparseArray 合法支持 value 为 null
  • 如果删除也用 null,就无法区分"值就是 null"还是"这个槽位已删除"

mGarbage

  • 表示当前数组里是否存在 DELETED 槽位
  • 如果为 true,说明后续某些操作前需要执行压缩回收

到这里你应该能看出它的整体结构:

  • mKeys 保证有序
  • mValues 保存对象
  • DELETED + mGarbage 负责延迟删除

这是一种非常典型的 Android 风格设计:

  • 结构简单
  • 状态明确
  • 行为可预测
  • 以内存效率优先

2.4 初始化时做了什么

构造函数很简单:

java 复制代码
public SparseArray() {
    this(0);
}

public SparseArray(int initialCapacity) {
    if (initialCapacity == 0) {
        mKeys = EmptyArray.INT;
        mValues = EmptyArray.OBJECT;
    } else {
        mValues = ArrayUtils.newUnpaddedObjectArray(initialCapacity);
        mKeys = new int[mValues.length];
    }
    mSize = 0;
}

容量为 0 时,不分配真实数组:

  • 直接复用 EmptyArray.INT
  • 直接复用 EmptyArray.OBJECT

这意味着空 SparseArray 很轻。

需要分配时,使用 newUnpaddedObjectArray()

这不是普通 new Object[],而是 Android 在 ArrayUtils 中封装的更紧凑数组分配方式。

说明 SparseArray 的优化不是只停留在算法层,而是从数组分配这一层就开始考虑内存。

3. 读取原理

3.1 get()

查找核心代码:

java 复制代码
public E get(int key, E valueIfKeyNotFound) {
    int i = ContainerHelpers.binarySearch(mKeys, mSize, key);
    if (i < 0 || mValues[i] == DELETED) {
        return valueIfKeyNotFound;
    } else {
        return (E) mValues[i];
    }
}

SparseArray 不自己写查找逻辑,而是复用:

java 复制代码
static int binarySearch(int[] array, int size, int value)

这里要先看清返回值的语义:

  • 找到时返回下标
  • 找不到时返回负数,且 ~result 就是应插入位置

这使得同一套返回值既能服务 get(),也能服务 put()

3.2 为什么 get() 不需要先 gc()

这里有个很容易漏掉的细节。

删除后,SparseArray 并不会打乱 mKeys 的有序性,它只是把 mValues[i] 改成 DELETED。所以:

  • key 依然有序
  • 二分查找依然正确
  • 找到后再额外判断 mValues[i] == DELETED 即可

这就是延迟删除能够成立的根本原因。

3.3 读取这条路径在做什么取舍

SparseArray 选择的是:

  • 用 O(log n) 的查找复杂度
  • 换取更低的内存开销

这不是纯性能导向,而是 Android 场景下的综合平衡。

3.4 size()keyAt()valueAt() 为什么和 get() 不一样

java 复制代码
public int size() {
    if (mGarbage) {
        gc();
    }
    return mSize;
}

size() 可能不是纯 O(1),因为它可能顺手触发一次压缩。

keyAt(index)valueAt(index) 的语义不是"物理数组下标访问",而是"按 key 升序排列后的逻辑序号访问"。

因此源码会先在必要时 gc(),保证:

  • keyAt(0) 是最小 key
  • keyAt(size - 1) 是最大 key
  • valueAt(i) 一定与 keyAt(i) 对应

3.5 其他查找相关方法

indexOfKey(int key)

  • 先必要时 gc()
  • 再走 binarySearch()
  • 返回值和 get() 内部一致:
    • >= 0 表示找到
    • < 0 表示未找到,且编码了插入位置

indexOfValue(E value)

源码是线性扫描。

原因很简单:

  • key 数组有序
  • value 数组无序
  • 所以值查找不可能走二分

更重要的是,indexOfValue() 用的是 ==,不是 equals()

这意味着:

  • 它比较的是引用相等
  • 不是语义相等

这里也很容易看错。

indexOfValueByValue(E value)

  • 这是隐藏 API
  • 走的是 equals() 比较
  • 用来补足 indexOfValue() 的语义不足

4. 删除原理

4.1 delete() 为什么不直接搬移数组

删除代码:

java 复制代码
public void delete(int key) {
    int i = ContainerHelpers.binarySearch(mKeys, mSize, key);
    if (i >= 0) {
        if (mValues[i] != DELETED) {
            mValues[i] = DELETED;
            mGarbage = true;
        }
    }
}

删除只做两件事:

  • 把对应 value 标记成 DELETED
  • mGarbage 设为 true

它不会立即移动数组。

4.2 为什么这里要延迟删除

如果每次删除都做数组搬移:

  • 单次删除会带来 O(n) 移动成本
  • 连续删除会更差

而当前做法是:

  • 删除时先打墓碑
  • 需要干净视图时再统一压缩

这是一种典型的延迟清理策略。

这里体现的是:

  • 不把成本集中在每次删除上
  • 而是把多次删除的代价合并处理

这很适合 Android 里大量"状态表偶发删改,但整体规模不大"的场景。

4.3 gc() 怎么把删除落实到数组上

真正清理垃圾的是 gc()

java 复制代码
private void gc() {
    int n = mSize;
    int o = 0;
    int[] keys = mKeys;
    Object[] values = mValues;

    for (int i = 0; i < n; i++) {
        Object val = values[i];
        if (val != DELETED) {
            if (i != o) {
                keys[o] = keys[i];
                values[o] = val;
                values[i] = null;
            }
            o++;
        }
    }

    mGarbage = false;
    mSize = o;
}

它就是一个双指针压缩过程:

  • i 负责扫描旧数组
  • o 负责写入新位置

所有不是 DELETED 的元素都会被前移,最终形成一个连续有效区间。

4.4 gc() 做完后数组会变成什么样

  • DELETED 槽位被彻底移除
  • 有效元素重新连续排列
  • mGarbage = false
  • mSize 修正为真实有效元素个数

values[i] = null 这一步不是多余的,它是为了:

  • 断开旧位置对对象的引用
  • 让 GC 可以回收不再需要的对象

4.5 哪些操作会触发 gc()

从源码看,典型包括:

  • size()
  • keyAt()
  • valueAt()
  • setValueAt()
  • indexOfKey()
  • indexOfValue()
  • put() / append() 在空间不足且存在垃圾时

可以看出一个清晰原则:

只有在必须得到"逻辑干净视图"或"必须腾空间"时,才执行 gc()

4.6 remove()removeAt()removeAtRange()

  • remove(int key) 只是 delete(key) 的别名
  • removeAt(int index) 是基于索引打 DELETED 标记
  • removeAtRange(int index, int size) 只是循环调用 removeAt()

注意:

  • SparseArrayremoveAt() 也不是立即压缩
  • 这点和 SparseIntArray 不一样

5. 写入原理

5.1 put() 为什么既能更新,也能插入

核心逻辑:

java 复制代码
public void put(int key, E value) {
    int i = ContainerHelpers.binarySearch(mKeys, mSize, key);

    if (i >= 0) {
        mValues[i] = value;
    } else {
        i = ~i;

        if (i < mSize && mValues[i] == DELETED) {
            mKeys[i] = key;
            mValues[i] = value;
            return;
        }

        if (mGarbage && mSize >= mKeys.length) {
            gc();
            i = ~ContainerHelpers.binarySearch(mKeys, mSize, key);
        }

        mKeys = GrowingArrayUtils.insert(mKeys, mSize, i, key);
        mValues = GrowingArrayUtils.insert(mValues, mSize, i, value);
        mSize++;
    }
}

第一步:先二分判断 key 是否存在。

  • 存在:直接覆盖 value
  • 不存在:根据 ~i 算出插入位置

第二步:优先复用墓碑位。

如果目标插入位置刚好是 DELETED,就直接复用该槽位。这一步很关键,因为它减少了:

  • 数组搬移
  • 扩容机会
  • 无意义的数据重排

第三步:空间不足时,先尝试清垃圾。

如果数组满了,但同时 mGarbage == true,源码不会立刻扩容,而是先:

  • gc()
  • 再重新计算插入位置

这说明 SparseArray 的扩容策略非常克制:

  • 先回收
  • 再扩容

第四步:最后才真正插入。

真正插入依赖 GrowingArrayUtils.insert()

  • 如果容量够,内部做数组搬移
  • 如果容量不够,先分配更大数组,再复制旧内容

这也解释了为什么:

  • SparseArray.get() 是 O(log n)
  • SparseArray.put() 最坏可能是 O(n)

5.2 append() 为什么更省事

源码:

java 复制代码
public void append(int key, E value) {
    if (mSize != 0 && key <= mKeys[mSize - 1]) {
        put(key, value);
        return;
    }

    if (mGarbage && mSize >= mKeys.length) {
        gc();
    }

    mKeys = GrowingArrayUtils.append(mKeys, mSize, key);
    mValues = GrowingArrayUtils.append(mValues, mSize, value);
    mSize++;
}

它只适合一种场景:

  • 新 key 严格大于当前最后一个 key
  • 也就是 key 递增,只做尾插

它快的原因也很直接:

  • 不用找中间插入位置
  • 不用整体搬移后半段数组

所以 append() 才是 SparseArray 真正的高效写入路径。

如果你的 key 天然递增,就尽量使用 append(),这比做很多微观优化更直接有效。

5.3 扩容是怎么配合完成的

SparseArray 的几个核心行为,实际上是委托给辅助类完成的。

ContainerHelpers

负责:

  • int[] / long[] 二分查找

意义:

  • SparseArrayLongSparseArrayArrayMap 提供统一基础能力

GrowingArrayUtils

负责:

  • append()
  • insert()
  • growSize()

扩容规则:

java 复制代码
currentSize <= 4 ? 8 : currentSize * 2

这说明它的增长策略是:

  • 小数组快速拉到可用规模
  • 之后按 2 倍增长

ArrayUtils

负责:

  • newUnpaddedIntArray()
  • newUnpaddedObjectArray()

意义:

  • 让数组分配更紧凑
  • 进一步体现 Android 对内存细节的关注

5.4 其他结构维护方法

clone()

java 复制代码
clone = (SparseArray<E>) super.clone();
clone.mKeys = mKeys.clone();
clone.mValues = mValues.clone();

这是浅拷贝:

  • 键数组和值数组本身被复制了
  • 但 value 指向的对象没有深拷贝

所以:

  • 改副本的结构,不影响原对象
  • 但如果 value 本身是可变对象,内部状态仍然共享

clear()

  • 遍历 mValues 并置空
  • mSize = 0
  • mGarbage = false
  • 不会缩容

这点很重要:

  • clear() 后容量还在
  • 适合之后继续复用
  • 但如果你期待"清空后立即释放数组内存",源码并没有这么做

toString()

  • 内部调用 size(),因此会先确保视图干净
  • 再按 keyAt() / valueAt() 升序拼接
  • 特别处理 value == this 的自引用情况,避免无限递归

contentEquals() / contentHashCode()

这两个方法是 Android 新增的内容级比较能力。

源码注释明确说:

  • 出于向后兼容原因,不能直接覆盖 Object.equals() / Object.hashCode()
  • 所以额外提供手动调用的内容比较方法

这是一个很典型的 Framework API 演进案例:

  • 想增强语义
  • 但又不能破坏老行为
  • 所以选择新增 API,而不是改变基类契约

6. 放到容器体系里再看

6.1 它在解决什么问题

如果只从 API 看 SparseArray,容易觉得它只是"另一个 Map"。但从源码看,它真正表达的是 Android 的 4 个设计思想。

专用容器替代通用容器

已知 key 是 int,就没必要使用 HashMap<Integer, E> 这种更重的通用方案。

内存优先于理论上的最优复杂度

它宁可把查找从理想 O(1) 换成 O(log n),也要减少装箱和额外节点对象。

把高频路径做轻,把重操作往后放

  • get() 不主动 gc()
  • delete() 不立即搬移
  • put() 先复用墓碑位,再考虑扩容

说明它的优化重点是:

  • 高频路径尽量轻
  • 重操作按需触发

让行为保持简单、稳定

SparseArray 没有哈希冲突、没有桶迁移、没有红黑树分支,整个行为模型非常稳定。

这对 Framework 底层容器来说很重要。

6.2 它在 Android 容器体系里的位置

SparseArray 不是孤立存在的,它属于 Android 的轻量容器家族。

主要成员有:

  • SparseArray<E>: int -> Object
  • LongSparseArray<E>: long -> Object
  • SparseIntArray: int -> int
  • SparseBooleanArray: int -> boolean
  • SparseLongArray: int -> long
  • ArrayMap<K, V>: 小规模通用键值容器

这个家族有一个共同点:

  • 都想减少对象数量
  • 都偏向数组型实现
  • 都更适合中小规模数据

6.3 和其他容器放在一起看

HashMap<Integer, E> 对比

SparseArray 适合:

  • key 是 int
  • 数据量中小
  • 内存敏感
  • 读多写少

HashMap<Integer, E> 更适合:

  • 数据规模更大
  • 随机写入频繁
  • 需要标准 Map 接口
  • 更看重通用性

ArrayMap<K, V> 对比

ArrayMap 更通用,但也更复杂:

  • 它要处理对象 key
  • 内部是 hash 数组 + 键值交错数组
  • 先按 hash 二分,再处理冲突区间

SparseArray 更纯粹:

  • key 直接是 int
  • 直接对 key 二分
  • 路径更短,语义更清晰

SparseIntArray 对比

这点很值得注意。

SparseIntArray 没有 DELETED 墓碑机制,删除时直接搬移数组。这说明:

  • SparseArray<E> 侧重对象场景和延迟回收
  • SparseIntArray 侧重原始类型场景和更直接的结构维护

所以同属 Sparse 家族,不同实现也会根据值类型做不同权衡。


7. Android 应用

7.1 Framework 里为什么经常能看到它

从 Framework 代码里,能看到很多典型使用场景。

View 状态保存

  • View
  • ViewGroup
  • Fragment

典型形式:

  • SparseArray<Parcelable>

原因很直接:

  • viewId 本身是 int
  • 状态对象数量有限
  • 状态恢复依赖稳定映射关系

Activity / Fragment 内部状态表

例如:

  • Activity 中的对话框表
  • FragmentManager 中的对象和状态缓存

它们都属于"整数 ID 管对象"的小规模状态结构。

资源缓存

例如:

  • resourceId -> WeakReference<resource>

资源 ID 是典型 int 键,和 SparseArray 天然契合。

系统服务状态管理

服务端代码里常见:

  • pid -> ProcessRecord
  • userId -> 某类状态集合
  • displayId -> 某显示对象

这些都说明 SparseArray 在 Android 架构里承担的是:

"小到中等规模整数主键状态表"的基础设施角色。

7.2 什么时候适合用,什么时候不适合用

适合用的场景

  • key 是 int
  • 数据量不大
  • 更在意内存效率
  • 读多写少
  • 可以利用递增 key 的 append() 快路径

不适合用的场景

  • 数据量很大
  • 随机插入删除频繁
  • 需要标准 Map 接口能力
  • 需要线程安全

写代码时的几个建议

  • 已知规模时传 initialCapacity
  • key 递增时优先用 append()
  • 删除密集阶段避免频繁 size() / keyAt() / valueAt()
  • 不要把它当并发容器使用

7.3 几个容易看错的点

误区 1:SparseArray 一定比 HashMap

不对。

源码自己的类注释已经说得很清楚:

  • 它的目标首先是更省内存
  • 查找依赖二分查找
  • 插入和删除可能需要数组插入 / 删除操作
  • 对大量数据结构并不合适

正确说法应该是:

  • 对中小规模、int 键、内存敏感场景,SparseArray 往往更合适
  • 但不是所有场景都更快

误区 2:delete() 就是把元素真正删掉了

不对。

  • delete() 只是把值标成 DELETED
  • 真正压缩数组是在 gc()

误区 3:size() 永远是 O(1)

不对。

  • 如果 mGarbage == truesize() 会先执行 gc()
  • 所以一次 size() 可能带 O(n) 压缩成本

误区 4:indexOfValue() 用的是 equals()

不对。

  • indexOfValue() 用的是 ==
  • 真正走 equals() 的是隐藏方法 indexOfValueByValue()

误区 5:clone() 是深拷贝

不对。

  • 它只是结构浅拷贝
  • value 指向的对象仍然共享

误区 6:clear() 会把数组容量缩回去

不对。

  • clear() 清空的是逻辑数据和引用
  • 但不会主动缩容

8. 最后再顺一遍

它的底层结构是什么

  • 有序 int[] mKeys
  • 对应 Object[] mValues
  • 通过下标建立键值映射

为什么比 HashMap<Integer, E> 更省内存

  • 避免 Integer 装箱
  • 避免额外节点对象
  • 数组存储更紧凑

为什么查找是 O(log n)

  • 因为 key 有序
  • 通过二分查找定位

为什么删除看起来更轻

  • 删除时只打 DELETED 标记
  • 真正物理删除在 gc() 中统一完成

为什么 append() 更快

  • 因为它只做尾插
  • 跳过中间插入和数组搬移

如果这 5 句话能结合源码讲顺,SparseArray 这个点基本就已经吃透了。

再回到整体看一遍

SparseArray 不是一个"更快的 HashMap",而是一个更适合 Android 的专用容器。

它的价值不在于单个操作理论最优,而在于整体权衡非常清晰:

  • 用有序数组换掉哈希结构
  • 用二分查找换掉装箱和节点开销
  • 用延迟删除换掉每次删除的立即搬移
  • 用按需 gc() 换掉不必要的重排成本

所以理解 SparseArray,本质上不是在记 API,而是在理解 Android Framework 的容器设计哲学:

  • 先看场景
  • 再做权衡
  • 用专用结构解决明确问题

这也是你在做 Android 架构设计时,真正应该学到的部分。

相关推荐
liang_jy2 小时前
Activity 启动流程扩展篇(一)—— startActivityInner 任务决策全解析
android·源码
冬奇Lab3 小时前
RAG 系列(四):文档处理——从原始文件到高质量 Chunk
人工智能·llm·源码
NPE~3 小时前
[App逆向]脱壳实战
android·教程·逆向·android逆向·逆向分析
木易 士心3 小时前
别再只会用 drawCircle 了!一文搞懂 Android Canvas 底层机制
android
AtOR CUES5 小时前
MySQL——表操作及查询
android·mysql·adb
怣疯knight6 小时前
安卓App无法增加自定义图片作为图标功能
android
jinanwuhuaguo7 小时前
OpenClaw联邦之心——从孤岛记忆到硅基集体潜意识的拓扑学革命(第二十三篇)
android·人工智能·kotlin·拓扑学·openclaw
Gary Studio9 小时前
安卓HAL C++基础-命名域
android
诸神黄昏EX9 小时前
Android Google XTS
android