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需要装箱成IntegerHashMap还需要维护桶、节点、冲突处理等额外结构
这意味着:
- 对象更多
- 内存更碎
- GC 压力更大
而 Android Framework 里恰好大量存在这种模型:
viewId -> stateresourceId -> cachepid -> processdisplayId -> contextuserId -> 某类状态
这些 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.javaframeworks/base/core/java/android/util/ContainerHelpers.javaframeworks/base/core/java/com/android/internal/util/GrowingArrayUtils.javaframeworks/base/core/java/com/android/internal/util/ArrayUtils.java
2.2 几个辅助类各做什么
SparseArray.java- 定义容器本体。
- 管理键值存储、增删改查、延迟删除、压缩回收、遍历与内容比较。
ContainerHelpers.java- 提供不做参数校验的二分查找实现。
SparseArray的get()、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)是最小 keykeyAt(size - 1)是最大 keyvalueAt(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 = falsemSize修正为真实有效元素个数
而 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()
注意:
SparseArray的removeAt()也不是立即压缩- 这点和
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[]二分查找
意义:
- 给
SparseArray、LongSparseArray、ArrayMap提供统一基础能力
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 = 0mGarbage = 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 -> ObjectLongSparseArray<E>:long -> ObjectSparseIntArray:int -> intSparseBooleanArray:int -> booleanSparseLongArray:int -> longArrayMap<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 状态保存
ViewViewGroupFragment
典型形式:
SparseArray<Parcelable>
原因很直接:
viewId本身是int- 状态对象数量有限
- 状态恢复依赖稳定映射关系
Activity / Fragment 内部状态表
例如:
Activity中的对话框表FragmentManager中的对象和状态缓存
它们都属于"整数 ID 管对象"的小规模状态结构。
资源缓存
例如:
resourceId -> WeakReference<resource>
资源 ID 是典型 int 键,和 SparseArray 天然契合。
系统服务状态管理
服务端代码里常见:
pid -> ProcessRecorduserId -> 某类状态集合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 == true,size()会先执行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 架构设计时,真正应该学到的部分。