资讯动态

安卓性能优化:HashMap与SparseArray如何选?别再盲目替换

发布时间:2026/9/10 8:59:18 来源:尧图企业网站定制
最近在做内存优化专项顺手把项目里几百处HashMap过了一遍。在这个过程中发现一个很有趣的现象网上关于“安卓性能优化”的文章里SparseArray几乎被捧成了标配但真实项目里HashMap依然无处不在甚至很多被“优化”成SparseArray的代码过了几个月又被人悄悄改回了HashMap。这不是谁的错而是因为很多优化建议只讲了“内存更小”这个结论却没讲清楚为什么SparseArray内存小、什么时候小、小到什么程度。这篇文章我想站在一个普通安卓开发者的角度把HashMap和SparseArray的底层原理、性能分水岭、代码可读性、集合框架兼容性这几个维度重新捋一遍说说为什么在实际开发中HashMap往往是更合理的选择以及什么时候才值得换SparseArray。内容对刚入门的新手和准备面试的中级开发都适用能帮你在写代码时少一点教条多一点根据。1. 先从一次“性能优化”翻车说起1.1 SparseArray 被过度神话了吗SparseArray 是安卓 SDK 自带的一个容器类设计目标很明确用int做 key避免Integer自动装箱从而减少内存占用。这一点在官方文档和大量博客里被反复强调于是很多人形成了一个思维定式看到MapInteger, X就想换成SparseArrayX。但问题是SparseArray 的内部结构是“两个数组加二分查找”它的优势有非常强的适用条件。数据量小的时候确实很省内存但数据量一旦上去插入和删除的代价会非常难看。更麻烦的是SparseArray 没有实现Map接口不能直接传给需要Map的方法导致它在现代安卓开发中经常需要额外写适配代码。我见过最夸张的一次是有同事把某个网络响应里的MapString, String强行拆成SparseArray结果因为 key 是 String 类型还得自己维护一个 String 到 int 的映射表原本十行代码解决的问题变成了五十行而且没有任何性能收益。这种“为了优化而优化”的做法才是真正需要警惕的。1.2 HashMap 在安卓项目里依然无处不在打开任何一个成熟安卓项目的源码HashMap依然是最常见的容器之一。原因很简单它的通用性太强了。几乎所有的 JSON 解析库默认反序列化出来的对象字段都对应到HashMap或LinkedHashMap。Retrofit、OkHttp 的请求头、表单参数底层都用Map接收。Kotlin 协程、Flow、Room 返回的数据结构里Map 也是默认容器。大量的 SDK 接口、AIDL 方法、组件间通信参数类型直接就是Map。也就是说哪怕你下定决心全部用 SparseArray也很难绕开 HashMap因为它已经渗透到了框架层和第三方库的接口定义里。与其想着“消灭 HashMap”不如搞清楚它在什么场景下是合理且高效的。1.3 先给结论没有绝对优劣只有场景匹配这篇文章不是为了证明 HashMap 比 SparseArray 好而是想说清楚“为什么在很多场景下HashMap 反而是更理性的选择”。我的结论可以先放在这里键是int且数据量在几百以内SparseArray 确实更省内存适合内存敏感、遍历频繁的场景。键类型不确定、需要存 null、需要集合框架互操作、数据量可能增长到千级以上HashMap 更合适。绝大多数业务代码里的 Map用 HashMap 都不会成为性能瓶颈真正的瓶颈在网络请求、数据库查询和布局绘制上。接下来从底层原理开始逐步拆解这个结论是怎么来的。2. 底层原理对比为什么 HashMap 快SparseArray 省内存2.1 HashMap数组 链表 红黑树HashMap 的核心结构是一张哈希表。简单说就是根据 key 的hashCode()计算出一个 hash 值。用 hash 值定位到数组的某个桶bucket。如果多个 key 落在同一个桶里就用链表把它们串起来。当链表长度超过阈值默认 8并且数组容量大于等于 64 时链表会转成红黑树避免极端情况下查询退化成 O(n)。这里有一个很容易被忽略的细节HashMap 在 Java 8 和 Android 的较新版本里已经对哈希碰撞做了优化。红黑树的引入意味着即使在最坏情况下查找复杂度也能控制在 O(log n)而不是链表时代的 O(n)。我们来看一个典型的 put 过程final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { NodeK,V[] tab; int n, i; if ((tab table) null || (n tab.length) 0) n (tab resize()).length; if ((p tab[i (n - 1) hash]) null) tab[i] newNode(hash, key, value, null); else { // 处理哈希碰撞 } }注意i (n - 1) hash这一步它要求数组容量 n 是 2 的幂这样可以用位运算代替取模性能更高。这也是为什么 HashMap 在扩容时总是把容量翻倍。所以 HashMap 的随机查找理想情况下是 O(1)。这个 O(1) 不是“平均接近 O(1)”而是绝大部分情况下就是一次数组下标访问加上偶尔的链表/红黑树遍历。2.2 SparseArray两个数组 二分查找SparseArray 的内部结构非常朴素它维护了两个数组int[] mKeys存放所有的 key。Object[] mValues存放对应的 value。查找一个 key 时SparseArray 会对 mKeys 做二分查找。二分查找的时间复杂度是 O(log n)看起来也很快但要注意它每次查找都是数组访问加比较而且当元素数量少时二分查找的常数项可能比 HashMap 的位运算大不少。更关键的问题在插入和删除。当你在位置 i 插入一个新 key 时如果 mKeys 数组中 i 位置已经存在元素就需要把 i 之后的所有元素整体后移一位。这个操作使用的是System.arraycopy虽然底层是 native 方法但数据量大时仍然有开销。删除操作更特殊。SparseArray 删除一个元素后并不会立刻压缩数组而是先在 mValues 对应位置放一个DELETED占位对象等到下次需要插入或调用gc()时才真正执行数组压缩。这个设计是为了避免频繁删除导致频繁的数据搬移但如果删完马上又要遍历性能落差会非常明显。2.3 时间/空间复杂度对照表把两者的复杂度放在一起看会更直观操作HashMap理想情况SparseArray说明随机查找O(1)O(log n)HashMap 直接定位桶SparseArray 需要二分查找插入O(1) 均摊O(n) 最坏SparseArray 可能需要移动数组元素删除O(1) 均摊O(n) 最坏删除本身标记后续 gc 压缩数组遍历O(capacity size)O(n)SparseArray 遍历更紧凑缓存友好内存较大每个 Entry 有额外对象头较小纯数组这就是 SparseArray 的核心优势注意HashMap 的 O(1) 是理想情况发生大量哈希碰撞时可能退化但红黑树机制大幅降低了退化风险。SparseArray 的 O(log n) 看起来不错但插入和删除最坏是 O(n)。真实业务里如果 map 的数据量超过一千甚至上万SparseArray 的插入耗时可能会让卡顿变得肉眼可见。3. 数据规模与性能分水岭实测数据3.1 我做的简单基准测试光看理论不够我写了一个简单的基准测试在 Android 模拟器和真机上分别跑过。测试逻辑大致是分别用HashMapInteger, String和SparseArrayString执行 1 万次、10 万次、50 万次随机插入和随机读取记录耗时。// 基准测试核心代码片段仅供参考 public void runBenchmark(int size) { HashMapInteger, String hashMap new HashMap(); SparseArrayString sparseArray new SparseArray(); long start System.nanoTime(); for (int i 0; i size; i) { hashMap.put(i, value- i); } long hashPutTime System.nanoTime() - start; start System.nanoTime(); for (int i 0; i size; i) { sparseArray.put(i, value- i); } long sparsePutTime System.nanoTime() - start; Log.d(Benchmark, size size hashPut hashPutTime / 1000 us sparsePut sparsePutTime / 1000 us); }这里要提醒一句System.nanoTime()在 Android 上可以用于耗时对比但测试数据受设备、ART 版本、CPU 调度影响很大最好多跑几轮取平均值并且先做预热。3.2 数据规模对查找、插入、删除的影响我实测下来结果大致是这样的不代表所有机型仅供参考数据量小于 100 时两者差异几乎可以忽略。SparseArray 偶尔还会更快因为数组遍历和二分查找的常数项不高但 HashMap 需要处理装箱和哈希计算。数据量在 1000 左右时HashMap 的插入开始领先但 SparseArray 的随机查找依然不慢。此时 SparseArray 的内存优势明显。数据量到 1 万以上时SparseArray 的插入开销明显增大因为每次中间插入都可能触发System.arraycopy。HashMap 虽然也有扩容开销但均摊后依然稳定。数据量到 10 万以上时SparseArray 基本不适合继续使用除非你的场景是“一次性构建后只读”否则插入和删除会非常吃力。这里的关键不是给出一个精确的分水岭数字而是说明SparseArray 的优化是有边界的不是只要用了就比 HashMap 好。很多业务场景里一个 Map 会承载几千上万条数据这时候选 HashMap 反而更稳。3.3 为什么“使用 SparseArray”不是银弹如果只看内存SparseArray 确实比 HashMap 省但省多少取决于 value 的类型和 Entry 的数量。HashMap 每个 Entry 都是一个 Node 对象包含 hash、key、value、next 四个字段对象头和字段对齐带来的额外内存非常大。而 SparseArray 只有两个数组没有对象头开销。但内存不是唯一指标。安卓应用的性能优化尤其是 UI 线程上的优化更应该关注“会不会掉帧”“会不会卡顿”。一个在子线程里处理几千条数据、耗时几十毫秒的操作不会对用户体验造成太大影响。相反如果你为了内存优化强行把 HashMap 换成 SparseArray导致代码里到处是类型转换和适配逻辑反而增加了出错概率和维护成本。我个人的习惯是先看 profile 结果再决定是否优化。只要 memory profiler 没有显示某个 Map 占用了大量内存就默认不动它。这在大多数项目里都是更理性的做法。4. 更关键的原因可空性、泛型与集合框架兼容性4.1 SparseArray 的局限不能存 null、不支持泛型集合操作SparseArray 在 Java/Kotlin 的使用上有两个很别扭的限制。第一个是 null 语义。SparseArray 的get(int key)在找不到 key 时返回 null但如果你真的往里面存过 null 值get返回的也是 null这就导致你无法区分“key 不存在”和“value 就是 null”。虽然可以调用indexOfKey(key)来判断但多一步操作代码就不够直观。HashMap 里可以用containsKey和get配合语义清晰虽然 Java 8 之后的 Map 也有getOrDefault和computeIfAbsent但至少不会因为这个细节写错逻辑。第二个是集合框架互操作。SparseArray 没有实现Map接口所以它不能直接用Collections.unmodifiableMap包装不能直接传给要求Map参数的方法也不能优雅地参与 Java 8 Stream 操作。Kotlin 里虽然有扩展函数可以转换但每次都要toMap()或者手动遍历代码非常啰嗦。4.2 与 Java/Kotlin 集合体系互通现代安卓开发几乎不可能只在一个类内部使用容器Map 通常会在多个模块、多个线程、多个方法之间传递。HashMap 是java.util.Map的标准实现天然兼容所有集合工具类。而 SparseArray 是安卓 SDK 的类只在安卓环境里存在。如果你在做一些纯 Java 的单元测试或者打算把部分逻辑抽到公共模块甚至跨平台模块里SparseArray 根本没法用。这一点在做 Kotlin Multiplatform、Jetpack Compose 状态管理或者服务端共享代码时尤其明显。Kotlin 的mapOf、associate、groupBy等函数返回的都是标准 Map协程和 Flow 里的操作符也几乎全部基于标准 Map。在这些场景下强行引入 SparseArray 等于给自己挖坑。4.3 序列化、JSON 解析和网络层的真实情况再来看看安卓项目里最常见的 Map 来源JSON 解析。Gson 在反序列化一个 JSON 对象时默认会把一个对象解析成LinkedHashMapString, Object。Moshi 的默认行为也类似。Retrofit 的FieldMap、OkHttp 的Headers底层都是 Map。如果你硬要换成 SparseArray需要写各种 TypeAdapter、自定义反序列化器工作量翻倍收益却很小。而且很多第三方 SDK 的方法签名里就直接是MapString, String你不可能要求对方改成 SparseArray。遇到这种接口HashMap 就是唯一合理的选择。5. 开发效率与代码可读性HashMap 更容易被理解和维护5.1 团队协作与代码审查中的实际感受代码不是写给自己看的是写给别人维护的。HashMap 是每个 Java/Kotlin 开发者在学习阶段就会接触的基础容器看到HashMapString, Object就能迅速理解它的用途。SparseArray 则需要额外学习成本特别是它的put、get、remove方法和 Map 的语义有一定差异索引查找、gc 机制也不是一眼能看懂的。我在代码审查时经常发现SparseArray 用多了会出现一个通病为了适配不同接口团队会封装各种工具方法比如sparseToMap、mapToSparse。这些工具方法本身没有技术含量却增加了代码体积和测试面。更麻烦的是如果这些方法写得有 Bug数据丢失或者类型错误很难排查。所以我的建议是除非这个 Map 确实在内存热点路径上否则默认用 HashMap让代码更易读、更易维护。优化要基于数据而不是基于“听说 SparseArray 更省内存”。5.2 Kotlin 生态Map 是首选如果你用 Kotlin 写业务代码你会发现标准库几乎处处都在向 Map 倾斜。val map mapOf( key1 to value1, key2 to value2 ) val filtered map.filter { it.key.startsWith(k) } val mapped map.mapValues { it.value.uppercase() }这些操作全部基于标准 Map 接口。再看协程和 Flowflow.map { it.toMap() }同样离不开 Map。Kotlin 的withDefault、toMap、groupBy、associateWith等扩展函数让 Map 的开发效率远超 SparseArray。硬要逆着生态走只会让自己别扭。5.3 什么时候换 SparseArray 才值得那么 SparseArray 到底还有没有用当然有。我总结了几个值得切换的判断条件key 类型必须是int并且你能保证这个 key 不会出现“需要区分不存在和 null”的情况。数据量预估在几百到一两千之间不会发生数量级增长。这个 Map 创建和销毁非常频繁且生命周期短比如只在某个onDraw或者列表绑定过程中临时使用。你已经用 Memory Profiler 验证过HashMap 在这个点位确实造成了内存压力。满足这些条件SparseArray 或者 AndroidX 里的ArrayMap都是不错的选择。如果不满足我的建议是老老实实用 HashMap。我还想说一下ArrayMap它和 SparseArray 类似也用两个数组存储但支持任意类型 key。它和 SparseArray 一样适合“小数据量、频繁创建”的场景在大数据量时同样存在插入慢的问题。所以在实际项目里我对这两个类的态度是一致的默认不用确认热点再切。6. HashMap 使用中的常见坑与排查技巧6.1 并发修改异常不管用 HashMap 还是 SparseArray在遍历过程中修改集合都会抛出ConcurrentModificationException。这个坑在安卓开发里特别常见因为很多集合是在主线程读取、子线程写入的。解决方案有几种使用ConcurrentHashMap但要注意它不允许 null key。使用Collections.synchronizedMap包装但迭代时仍要手动加锁。使用CopyOnWriteArrayList或ConcurrentSkipListMap看具体场景。如果是 Kotlin可以用toMap()先做快照再遍历。排查技巧日志里看到ConcurrentModificationException时不要只盯着抛出异常的线程还要看另一个线程是不是正在往同一个 map 里写数据。线程对比经常能直接定位问题。6.2 容量预估与负载因子HashMap 默认初始容量是 16负载因子是 0.75。当你插入的元素数量超过容量 * 0.75时HashMap 会触发扩容扩容会重新计算所有 key 的 hash 并搬移数据这个过程虽然均摊后不慢但在数据量大的时候依然会产生一次性卡顿。更好的做法是预估容量提前指定初始容量// 预计会放 100 个元素 int expectedSize 100; int initialCapacity (int) (expectedSize / 0.75f) 1; HashMapString, String map new HashMap(initialCapacity);这里的1是为了应对向上取整的误差。这个公式在很多源码里都能看到比如 Guava 的Maps.newHashMapWithExpectedSize就是用类似思路实现的。还有一个容易踩的坑如果初始容量不是 2 的幂HashMap 内部会计算成大于该值的最小 2 的幂。所以不用自己强行传 1024、2048 这种数直接传预估数量除以 0.75 再加 1 就行。6.3 equals 和 hashCode 的坑HashMap 的查找依赖 key 的hashCode()和equals()。如果 key 是自定义对象这两个方法没实现好会出现“存进去却查不到”的情况。有一次排查线上问题发现某个缓存模块偶发数据丢失最后定位到原因是 key 是一个可变对象有人改动了对象字段导致 hash 变了HashMap 再查就找不到原来的位置。解决方案很简单key 使用不可变对象比如 String、Integer或者自定义类时只根据不可变字段计算 hash。SparseArray 不存在这个问题因为它的 key 是基本类型 int天然稳定。如果你正好需要“一个完全不用担心 hashCode 的 int 键容器”SparseArray 确实有价值。6.4 哈希碰撞与被攻击风险理论上恶意构造大量 hash 相同的 String key可以让 HashMap 的某个桶无限拉长。Java 8 之前这个漏洞可以造成 DoSJava 8 之后引入了红黑树和TreeBin把最坏情况从 O(n) 降到 O(log n)。在安卓上使用旧版 JDK 编译的代码有可能不包含红黑树逻辑所以如果你在处理外部传入的不可信 key比如 URL 参数、用户输入最好对 key 长度做限制或者在入口层做一遍 whitelist 校验。这是比较容易被忽略的安全点。另外String 的 hashCode 计算是 O(n)n 是字符串长度。如果你拿超长字符串做 key每次 put/get 都要重新计算 hash对性能的影响不像内存优化那么直观但也是真实存在的。一般建议 key 尽量短或者干脆用 int 枚举。7. 踩过几次坑之后我的优化习惯踩过几次坑之后我养成了两个习惯分享给大家参考。第一个习惯是“先埋点再优化”。性能优化最忌讳拍脑袋。如果你觉得某个 HashMap 占内存先用 Memory Profiler 抓一下如果你觉得某个列表卡顿先用 Perfetto 看主线程堆栈。很多时候真正的问题根本不在容器选择上而是布局层级太深、图片加载太大、数据库查询太慢。为了一个不存在的问题引入 SparseArray属于自我感动。第二个习惯是“默认用标准集合热点再做特殊化”。这个原则不仅适用于 HashMap 和 SparseArray也适用于 ArrayList 和 LinkedList、StringBuilder 和 String。标准集合的可读性、生态、可维护性都是经过大规模验证的特殊化容器只有在被证明是热点时才有换的价值。如果你现在正在纠结要不要把MapInteger, X改成 SparseArray我的建议是先问自己三个问题这个 Map 的数据量有多少它的生命周期有多长它是否处于内存敏感路径如果三个问题都指向“是”那就放心换只要有一个答案模糊就继续用 HashMap。毕竟在绝大多数情况下HashMap 不是性能瓶颈而是那个最容易被理解的默认选项。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价