先交代一个场景:上周代码 review,组里一个小兄弟指着一行 HashMap<Integer, String> 问我,lint 一直提示 UseSparseArrays,要不要按提示改掉?我看了一下他这段代码,是在一个列表页里维护 item type 与样式名的映射,数据量最多二十来条,确实符合 SparseArray 的适用条件。但问题没这么简单。改完结构之后,这个 map 要透传给一个自绘 View 的渲染层,那边用的是 Map 接口签名,还有一处要把它序列化进 JSON 做埋点上报。真要换成 SparseArray,这几处全部要动,收益只是一次性省下几 KB 内存。我当时给他的答案是:不用改,加一行 @SuppressLint("UseSparseArrays") 并写清楚理由。这引出了一个在安卓应用开发里被反复讨论的老话题——HashMap 和 SparseArray 到底该怎么选,为什么绝大多数项目里 HashMap 依然是默认选项,以及什么情况下我才会主动换成 SparseArray。这篇就把我这些年积累的取舍逻辑一次性说清楚。
1. “用 SparseArray 更省内存”这句话背后的真实结构差异
我见过太多文章一上来就甩结论:SparseArray 比 HashMap 省内存,所以能用就换。这个结论在内存维度是对的,但它省在什么地方、代价是什么,很多人其实没搞透。搞不透就容易在错误的场景里强行替换,最后性能反而更差。先把底层结构拆开。
1.1 两个数组加二分查找:SparseArray 到底长什么样
SparseArray 的存储结构是两个平行的 Java 数组:一个 int[] mKeys 存 key,一个 Object[] mValues 存 value,并且 key 在数组里按升序排列,一一对应。取某个 key 的值时,它调用的是 ContainerHelpers.binarySearch 在 mKeys 上做二分查找,找到下标后再从 mValues 里取对象。
这套设计的核心优势是省掉了所有对象包装。key 是原生 int,不经过 Integer.valueOf() 装箱;value 直接以引用形式躺在数组里,不像 HashMap 那样每个键值对还要额外创建一个 Node 节点。你可以把它想象成一叠按日期排好的纸质票据:找某一天的票据,折半翻一翻就能定位,很快。但要往两张票据中间插一张新票据,后面所有票都得整体往后挪,这就是数组复制的开销。
HashMap 则完全不同。它底层是一个 Node<K,V>[] table 桶数组,put 时先用 key.hashCode() 算出散列值,再通过扰动函数落到某个桶位,桶内冲突用链表解决,链表长度超过 8 且数组容量达到 64 时还会转成红黑树。get/put 的均摊时间复杂度是 O(1)。代价是每个键值对都有额外的 Node 或 TreeNode 对象,数组还要按 2 的幂次预留容量,扩容时更是要整表 rehash。
1.2 删除操作:墓碑标记与内存碎片化的真相
SparseArray 删除一个 key 时并不是立刻从数组里抠掉,而是把 mValues 对应位置替换成一个 DELETE 常量,类似标记一个墓碑,然后等下一次确定性操作(比如 get、put 或 size)时触发 gc() 一次性压缩数组,把有效元素往前挪。这么设计是为了避免每次 delete 都触发昂贵的 System.arraycopy。但代价也很隐蔽:如果你在一个循环里高频地 put 和 remove,数组里会堆积大量墓碑,size 虽然不大,底层数组却可能被撑得很长,内存占用并没有想象中那么少。
这个问题我在一个实时音频路由模块里真实踩过。那是一个不断重连、不断按 audio_port_handle_t 增删 client 记录的场景,表面上看数据量只有几十条,用 SparseArray 很合理,反复增删之后我发现 dumpsys 里分配的数组容量远大于有效条目数,碑太多,数组被撑大了。换回 HashMap 之后,这个问题消失,因为 HashMap 删除是直接从桶里摘掉节点,按桶操作,不牵连无关数据。
1.3 内存节省到底有多少:一个量级估算
很多文章说“SparseArray 能省 30%”,这个数字其实没头没尾。我按 1000 条 int -> 某个 Object 的数据量做一个粗略估算:
| 结构 | 主要内存开销 | 量级(64 位 JVM 指针压缩开启) |
|---|---|---|
| HashMap<Integer, Object> | 1000 个 Node 节点(约 32B/个) + 1000 个 Integer 装箱对象(约 16B/个,缓存区间外) + 桶数组预留容量(容量 2048,约 8KB) | 50KB 以上 |
| SparseArray | 1000 个 int key(4B/个) + 1000 个引用(4B/个,指针压缩) + 两个数组对象头 | 10KB 左右 |
注意这只是 1000 条的量级估算,真实项目会有 HashMap 扩容阈值、Integer 缓存命中、对象对齐等因素,不能当作精确数值。但这个量级差说明一件事:SparseArray 在内存上的优势是实打实的,尤其是当你需要维护几百个 int 到对象的映射时,差距能到几倍。
明确了结构差异之后,真正的工程问题来了:既然省内存是真的,为什么大家不全面替换?下面这三个原因,才是 HashMap 在安卓项目里活得好好的关键。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. HashMap 在安卓项目里没有被替换的三个现实原因
2.1 最容易被忽略的一点:SparseArray 根本没有实现 Map 接口
这是我最常对同事强调的一条。SparseArray 的类签名是 public class SparseArray<E> implements Cloneable,它没有实现 java.util.Map。这不是偷懒,是设计者刻意避免装箱和迭代器开销。但代价就是它跟以 Map 为约定的整个 Java 生态彻底脱节。
举几个真实场景:你的方法签名是 Map<Integer, String>,调用方传的是 HashMap,现在内部要改成 SparseArray,那这个方法签名就必须要改。你的数据要经过 Gson 序列化成 JSON,Gson 能直接序列化 Map,但对 SparseArray 需要自定义 JsonSerializer。你的数据要传到服务端 SDK,SDK 的接口回调给你 Map<String, Object>,你只能把 SparseArray 的键值对一个个拷进一个新的 HashMap 再传过去,等于省下的内存瞬间又填回去了。
最麻烦的是跨进线程或跨模块传递。系统 Binder 传输里,Bundle 支持 SparseArrayParcelable,但你得专门写自定义的 Parcelable。而 HashMap 天然能被几乎所有框架处理,序列化、反序列化、JSON 转换、数据库映射,全都默认支持。工程上“替换一个数据结构”从来不是替换一个类那么简单,它牵扯的是整个数据流路径上的每一处约定。
2.2 方法语义差异:put 没有返回值,get 无法区分“不存在”和“值为 null”
SparseArray 的 put(int key, E value) 返回值是 void,而 HashMap.put 返回被覆盖的旧值。在写缓存、做去重、统计覆盖次数时,HashMap 一行代码能拿到旧值,SparseArray 得先 get 再 put,多一次查找。这个差异看起来小,写多了就烦。
更隐蔽的是 null 语义。SparseArray 的 get(int key) 在 key 不存在时返回 null,如果 value 本身存的就是 null,你也拿到 null。用 HashMap 你可以用 containsKey 判断,用 SparseArray 默认的 get 也没问题,但一旦你开了 get(key, defaultVal) 重载,语义又容易让人绕进去。这类小坑在加班赶功能时最容易埋雷。
2.3 团队心智与 Kotlin 互操作:默认选择会极大影响协作效率
一个成熟项目里,代码不是一个人维护的。HashMap 是几乎所有开发者从第一门语言课程就接触的集合类,看到 map[key] = value、map.entrySet()、iterator.remove() 大家秒懂。SparseArray 的遍历方式是这样的:
java复制for (int i = 0; i < sparseArray.size(); i++) {
int key = sparseArray.keyAt(i);
E value = sparseArray.valueAt(i);
}
这个写法的 index 语义跟“按插入顺序拿到第几个条目”不一样,实际上它按 key 的升序返回。新同事很容易误以为 keyAt(i) 拿到的 i 和插入顺序有关,实际无关。团队里只要有一两个人对这个结构不够熟,代码 review 的成本就开始上升,出错的概率也在上升。
Kotlin 这边又叠加了一层。Kotlin 标准库没有对 SparseArray 做专门的操作符扩展,虽然 Android KTX 提供了 androidx.collection 里的一些集合工具,但你无法在 SparseArray 上用 map{}、filter{}、forEach{} 这种 Kotlin 集合函数。HashMap 则可以无缝享受 Kotlin 集合操作符的全部能力。一个数据结构跟工程里大多数人的表达习惯越接近,它的隐性成本就越低。这是我在做技术选型时必看的一项。
3. 认真测过的性能数据:差距发生在特定场景而不是一直存在
结构分析和生态分析之外,性能是另一个绕不开的维度。但很多人把性能差距理解成了“HashMap 一定快”或“SparseArray 一定省”,这都不准确。我在几台测试机上都跑过对比,结论是差距高度依赖数据量和操作模式。
3.1 读多写少:二分查找其实不慢
先看读取。SparseArray 的 get 是二分查找,1000 条数据只需要约 10 次比较,几千条数据也就 12 到 13 次比较。这个量级的比较在 CPU 眼里是纳秒级,跟 HashMap 的一次哈希计算加一次桶定位相比,体感差距基本可以忽略。更极端一点,在 8 万条数据里做顺序读取,SparseArray 的二分查找最多 17 次比较,依然很快。所以单纯说“HashMap 读得快所以万能”是不成立的。
而且 SparseArray 有个隐藏优势:内存连续。因为它用两个平行数组存储,遍历时 CPU 缓存命中率极高。我在一台 Pixel 6 上对 10000 条 Integer 到 String 的映射做全量遍历,SparseArray 的 keyAt/valueAt 顺序遍历比 HashMap 的 entrySet 遍历还要快那么一点点。原因就是 HashMap 的 Node 对象散落在堆的不同位置,遍历时指针到处跳,缓存不友好。
3.2 插入和删除越多,SparseArray 代价越明显
读取表现不错,但写入是另一回事。SparseArray 在插入一个新 key 时,先二分找到插入位置,再用 System.arraycopy 把后面的元素整体后移。最坏情况下插入到数组头部,等于把所有元素都往后挪了一位,时间复杂度 O(N)。删除同样存在数组移动和墓碑压缩的问题。
我做过一组实测,向两种结构里乱序插入 1000 条数据,SparseArray 的耗时大约是 HashMap 的 3 到 5 倍。这个量级在单次操作里毫秒都算不上,但如果你在滚动列表的 getView 或 onBindViewHolder 里反复做这种插入,累积起来就会在 systrace 上看出明显的 CPU 峰值。数据量再往上走,比如 10 万条乱序插入,SparseArray 几乎不可用,因为数组复制的总代价接近 O(N²),直接卡到肉眼可见。
把这两组结果放一起,取舍就很清晰了:
| 操作模式 | SparseArray 表现 | HashMap 表现 |
|---|---|---|
| 几百条内随机读 | 很好,二分查找 + 缓存友好 | 很好,O(1) |
| 几万条随机读 | 仍可用,但开始落后 | 优势明显 |
| 几千条乱序插入 | 明显偏慢 | 快,均摊 O(1) |
| 高频删除后重建 | 墓碑堆积,数组占用增大 | 按桶摘除,不牵连 |
| 全量遍历 | 连续内存,缓存友好 | 指针跳转,稍慢 |
4. 我坚持使用 HashMap 的典型场景
说完原理和数据,落到具体项目,我平时判断是否用 HashMap 其实有一套很简单的准则:看这个 map 的数据要流到哪里去,以及它被操作的模式是什么。下面这几类场景,我一定会坚持用 HashMap。
4.1 数据要交给第三方库或对外暴露接口时
最典型的是网络层。Retrofit 的 @FieldMap、Gson 的对象序列化、Moshi 的 JsonAdapter、EventBus 或者 LiveData 里传递的数据结构,几乎都以 Map 为最终形态。你从服务端解析出来的设备属性列表是 Map<String, String>,你要埋点上报的上游数据是 Map<String, Object>,这些地方根本不是你想换就能换的。
写 SDK 给别人用更是如此。你设计一个接口:
java复制void updateMetrics(Map<String, Integer> metrics);
如果内部用的是 SparseArray,为了适配这个签名,你反而不如直接用 HashMap。对外 API 最重要的是稳定和通用,让调用方在无文档情况下也能猜到数据结构的行为,这比省几 KB 内存更有价值。
4.2 数据量上千且需要频繁改动的缓存
缓存场景最怕的就是“看起来能缓存,结果 add 和 remove 一样频繁”。我维护过一个图片加载的内存缓存的辅助索引,key 是图片 URL 的 hash,value 是 Bitmap 的引用,常态数量在几千到上万。这种规模下 SparseArray 的二分加数组复制完全不是对手,HashMap 的 O(1) 修改才能保证快速滑动时的帧率稳定。
另外,如果缓存有并发访问需求(多个线程同时读和写),HashMap 至少可以用 ConcurrentHashMap 替代,而 SparseArray 没有线程安全变体,只能自己加锁。在复杂的业务模块里,“没有现成的并发实现”是一个极大的减分项。
4.3 遍历时需要安全删除的场景
业务里经常出现“按条件筛掉一部分数据”的循环。HashMap 可以这样写:
java复制Iterator<Map.Entry<Integer, String>> it = map.entrySet().iterator();
while (it.hasNext()) {
Map.Entry<Integer, String> entry = it.next();
if (entry.getValue().startsWith("temp_")) {
it.remove();
}
}
SparseArray 在遍历中删除要非常小心,因为它的 index 是实时变化的。你删掉 index=3 的数据后,原来 index=4 的数据会前移变成 index=3,如果你还按旧的 index 去操作,就会漏数据或越界。虽然可以先收集需要删除的 key 再统一 remove,但这段代码的复杂度和出错可能性,已经高过省内存带来的收益了。
5. 反过来,哪些场景我一定会换成 SparseArray
不要觉得我在无脑吹 HashMap。SparseArray 的存在不是没有道理,在某些场景它确实更合适。我自己的习惯是遇到下面这些条件组合时,会主动选 SparseArray。
5.1 int 为主键、数据量小、读多写少的映射
这是最标准的使用条件:key 是 int 或 Integer,value 是对象;数据量在 100 条以内(上限放宽到千条也不是不行,但要评估写入频率);整个生命周期里 get 远多于 put。
UI 里很常见。比如某个页面维护 int type -> View 样式对象 的映射,type 是服务端下发的常量,数量固定,总共就几种样式。这个场景用 SparseArray 天然合适,key 不用装箱,get 是高效的二分,内存开销远小于 HashMap。这类数据要是用 HashMap,每次 put 都有装箱、散列、Node 创建的噪音,属于杀鸡用牛刀。
5.2 内存余量极低、需要控制 GC 频率的组件
有些功能跑在低端机上,GC 一次就可能掉帧。SparseArray 省掉的 Integer 装箱和 Entry 对象,在这个场景里就是实实在在的活命内存。比如一个图片轮播组件里维护每张图的加载状态,一个视频播放器维护各类音轨参数,这些数据量很小、生命周期又长,用 SparseArray 能少创建一堆临时对象。
如果你还嫌 SparseArray 的 API 太原始,Android 生态里有现成的增强版:androidx.collection.SparseArrayCompat,它基本兼容 SparseArray 的语义但补了一些方法。另外 androidx.collection.ArrayMap 是一个实现了 java.util.Map 接口的优化版 Map,内部也用双数组加二分,既保留了 Map 的接口兼容性,又有内存优势。遇到“必须用 Map 接口,但希望省内存”的场景,我优先考虑 ArrayMap,而不是硬上 SparseArray。
5.3 你自己只是缺一个 int 到 Object 的简单注册表
最后一种情况是功能简单到不需要任何集合接口的复杂度。比如你在模块里维护一组监听器:
java复制private final SparseArray<OnDeviceStateListener> listeners = new SparseArray<>();
public void registerListener(int deviceId, OnDeviceStateListener listener) {
listeners.put(deviceId, listener);
}
这里 key 是设备 id,value 是监听器,没有网络传输,没有序列化,没有跨模块传递,也没有人在循环里安全删除。这种内部私有注册表,换成 SparseArray 干净利落,还能顺手消掉 lint 的 UseSparseArrays 警告。
6. 一次音频路由模块的实战复盘:HashMap 如何在 MTK 8.1 平台上撑住多应用同时录音
前面讲的都是方法论,下面给一个真实的项目案例:我做过一个基于 MTK 8.1 平台的多应用同时录音功能。这个需求本身很典型,也是当时的一个热门场景——系统默认的音频策略一般只允许前台应用独占录音,要让多个应用同时录,需要在 audio policy 和 HAL 层做定制。而所有录音 client 的管理,就是在动态维护各种映射表。
6.1 需求到底复杂在哪
Android 8.1(API 27)的音频框架里,每个录音流在 AudioFlinger 侧会被分配一个唯一的 audio_port_handle_t,这个句柄是 int 类型。多个应用同时录音时,系统需要维护以下映射:
- 端口句柄到录音客户端信息的映射:
audio_port_handle_t -> AudioClientRecord - 应用包名到该应用所有录音流句柄列表的映射:
packageName -> List<AudioPortHandle> - 设备路由策略到已激活录音流数量的映射,用于混音线程仲裁
第一个映射的 key 是 int,value 是自定义对象,数量通常只有几十个,看起来是 SparseArray 的绝佳使用场景。但实际写下来,我最后还是选了 HashMap,原因很有代表性。
6.2 为什么我没有在那一版里换成 SparseArray
第一,这些映射表要跨语言访问。AudioFlinger 的 native 层在做混音线程调度时,需要通过 JNI 反向查询当前有哪些录音 client。JNI 里遍历 HashMap 有现成的 FindClass("java/util/HashMap")、CallObjectMethod(entrySet) 一套标准操作,但遍历 SparseArray 就只能在 Java 侧先把它转换成一个数组或 Map 再传给 native,等于多一层复制。跨语言维护一个 Java 类内部的数组结构,成本远超省下的那点内存。
第二,这些映射表在 dumpsys 时是核心排障数据。系统出问题时,音频工程师第一件事就是抓 dumpsys,看每个录音流绑定到哪个 client、client 状态是什么。HashMap 直接打印 entrySet() 很直观,SparseArray 打印出来是 {index=0, key=xxx, value=xxx} 这种数组结构,肉眼排查效率低很多。做系统稳定性的人应该都懂,排障便利性也是技术选型的一环。
第三,这个场景的写操作比普通业务频繁得多。应用启动录音时注册、断音时注销、焦点变化时挂起恢复,一瞬间会有大量的 put 和 remove。SparseArray 的墓碑机制在这种反复增删下会积累空洞,我前面已经踩过坑。HashMap 按桶操作,增删只影响一个桶位,稳定性好得多。
第四,也是最重要的一点:同一个功能里还有 packageName -> List 这种以 String 为 key 的映射,SparseArray 根本用不了。既然这个模块里 HashMap 已经是必然选择,那第一个映射用 SparseArray 只会让上下层代码风格分裂,阅读和改造成本都变高。技术选型不能只看单个数据结构的局部最优,要看整个模块的整体一致性。
我在那个版本的代码里写了一段注释,大意是:这里明知数据量小、key 是 int,仍然使用 HashMap,因为该表需要被 JNI 层遍历、需要以直观形式输出到 dumpsys、且生命周期中存在高频增删。然后在这个方法上加了 @SuppressLint("UseSparseArrays")。这个注释后来帮过不止一次忙,至少让后面接手的人不会在 review 时反复问同一句“为什么不换 SparseArray”。
如果你也想在代码里压掉这个 lint 警告,可以直接:
java复制@SuppressLint("UseSparseArrays")
private final HashMap<Integer, AudioClientRecord> mRecordClients = new HashMap<>();
或者写在方法上。但我建议压警告之前,先确保自己在代码注释里写清楚了原因。没有原因的 @SuppressLint 比 lint 提示更糟糕。
我个人在实际操作中的体会是:HashMap 和 SparseArray 之间根本不是“谁替代谁”的关系,而是“谁更匹配当前数据的生命周期和外圈生态”。一个数据结构如果能让整条数据链路从创建、流转、排障到销毁都顺滑,它就是当前场景最合适的。内存优化永远要放在功能正确性、代码可读性和维护成本之后。这也是为什么我的默认选择永远是 HashMap,只有当我能在注释里写清楚“这里数据量小、读多写少、不跨模块”,我才会用 SparseArray。
