资讯动态

Android性能优化:SparseArray与ArrayMap替换HashMap实战

发布时间:2026/10/14 4:49:59 来源:尧图企业网站定制
1. 从一次内存抖动说起为什么我要重新审视HashMap做Android开发的朋友大概率都遇到过这样的场景列表页滑动着滑动着Logcat里突然冒出几条GC日志帧率曲线跟着抖一下虽然不至于ANR但那种细微的卡顿感在低端机上特别明显。我最早排查这类问题时习惯性地去看图片加载、线程池、网络回调折腾了一圈才发现真正的元凶藏在一些不起眼的HashMap里——比如某个配置表、某个状态缓存、某个只有几十个键值对却频繁创建销毁的映射结构。HashMap本身没问题它是Java世界里最通用的键值对容器O(1)的查找效率、灵活的扩容机制写起来也顺手。问题在于Android的运行时环境和标准JVM不太一样。Android用的是ART早期是Dalvik它对内存的敏感度远高于桌面JVM而且移动端场景下我们存的数据往往是小规模、高频次、键类型固定的——比如int到Object的映射、String到View的映射。这种场景下HashMap的装箱开销、额外的Entry对象、默认16容量的数组分配累积起来就是一笔不小的内存账。SparseArray和ArrayMap就是在这个背景下进入我视野的。它们不是要取代HashMap而是在特定场景下用时间换空间或者用更聪明的数据结构换更少的内存分配。我花了大概两周时间在一个中型项目里做了系统性的替换和压测中间踩了不少坑也总结出一些判断标准。这篇文章就把整个优化过程拆开讲清楚什么场景该换、换了之后性能到底怎么样、源码层面它们是怎么省内存的、以及那些文档里不会写的注意事项。如果你正在做Android性能优化或者单纯想搞清楚这几个容器到底该怎么选下面的内容应该能帮你省下不少试错时间。2. SparseArray与ArrayMap的底层账本它们到底省在哪2.1 HashMap的内存开销拆解要理解SparseArray和ArrayMap的价值得先把HashMap的成本算清楚。一个HashMapInteger, String每存一对数据实际产生的对象包括一个Integer键对象装箱、一个Node对象Java 8之后是Node之前是Entry、一个数组槽位引用。Node内部又持有hash、key、value、next四个字段。也就是说存100条数据光Node对象就100个加上键的装箱对象内存占用是相当可观的。更关键的是扩容机制。HashMap默认容量16负载因子0.75存到第13个元素时就会触发扩容容量翻倍到32然后重新哈希所有元素。这个过程中会新建一个数组旧数组等待GC。在内存紧张的设备上这种分配-复制-丢弃的模式很容易触发GC。我用一个简单的测试验证过在一个循环里创建1000个HashMapInteger, Integer每个存20对数据然后立即丢弃。跑下来HashMap版本触发了十几次GC而换成SparseArray后GC次数降到个位数。这个差距在真实业务里可能被放大因为业务代码里的映射结构往往是长期存活、频繁读写的。2.2 SparseArray的二分查找与延迟清理SparseArray的设计思路很直接既然键是int那就别装箱了直接用两个数组——一个int[]存键一个Object[]存值。键数组始终保持有序查找时用二分查找复杂度从O(1)变成O(log n)。听起来是退步了但别忘了对于几十到几百个元素的规模二分查找的几次比较和HashMap的哈希计算加链表遍历相比实际耗时差距很小而内存省下来的却是实打实的。它的删除操作也很有意思。调用remove()时它并不是立即把数组元素删掉而是把对应位置的值设为DELETED标记。真正的清理发生在后续的put()或者手动调用gc()时。这样做的好处是避免每次删除都做数组搬移代价是删除后内存不会立刻释放。我在实际使用中遇到过一个问题一个SparseArray频繁增删后size()返回的是逻辑大小但内部数组可能还占着更大的空间。如果这个结构长期存活且增删频繁建议定期调用gc()或者干脆在合适的时机重建。2.3 ArrayMap的双数组与哈希缓存ArrayMap面向的是键为Object的场景比如String到View的映射。它的内部结构比SparseArray复杂一些一个int[]存每个键的哈希值一个Object[]交替存键和值。查找时先算哈希然后在哈希数组里二分查找找到后再比较键的equals。这里有个细节值得注意ArrayMap在查找时会先比较哈希值哈希相同再比较equals。这个顺序很重要因为equals可能很昂贵比如长字符串比较而哈希比较是整数比较快得多。我在看源码时发现ArrayMap的二分查找实现里对哈希冲突的处理是线性的也就是说如果多个键的哈希值相同它会逐个比较。所以如果你的键的哈希函数分布不均匀ArrayMap的性能会明显下降。ArrayMap同样有延迟清理机制删除时标记而不是立即搬移。它的扩容策略也比HashMap保守默认容量更小增长曲线更平缓。这些设计叠加起来使得ArrayMap在小规模数据下的内存占用通常只有HashMap的30%到50%。2.4 一张表看清三者的取舍维度HashMapSparseArrayArrayMap键类型任意Objectint任意Object查找复杂度O(1)平均O(log n)O(log n)内存占用高低中低扩容策略翻倍按需增长按需增长删除机制立即移除标记延迟清理标记延迟清理适用规模任意几百以内几百以内线程安全否否否这张表是我自己在选型时总结的核心判断依据就是数据规模和键类型。规模超过一千HashMap的O(1)优势会逐渐显现规模在几百以内SparseArray和ArrayMap的内存优势更值得考虑。3. 替换决策的实操框架不是所有HashMap都该换3.1 先判断键类型再看数据规模我见过一些团队一上来就把所有HashMap换成ArrayMap结果在某些场景下性能反而下降。问题出在键类型上。ArrayMap的键是Object查找时需要计算哈希和比较equals如果键是自定义对象且hashCode()实现得不好二分查找的优势就没了。而SparseArray只接受int键如果你现在的键是Integer换成SparseArray是顺理成章的如果键是String那就只能考虑ArrayMap。数据规模是第二个判断维度。我的经验阈值是这样的元素数量稳定在1000以内且增删不频繁优先考虑SparseArray或ArrayMap元素数量可能增长到几千甚至上万或者增删非常频繁HashMap更稳妥。这个阈值不是绝对的但可以作为初始判断。还有一个容易被忽略的点如果这个映射结构需要遍历SparseArray和ArrayMap的遍历效率其实比HashMap高因为它们内部是数组遍历时内存连续缓存友好。HashMap遍历需要走链表或红黑树指针跳转多。所以如果你的场景是读多写少且经常遍历替换的收益会更明显。3.2 替换前的基准测试怎么做直接改代码然后凭感觉判断是不靠谱的。我一般会写一个简单的基准测试用System.nanoTime()测量插入、查找、删除、遍历四个操作的耗时同时用Runtime.getRuntime().totalMemory() - freeMemory()粗略估算内存变化。更精确的做法是用Android Studio的Memory Profiler在替换前后分别抓取堆转储对比对象数量和占用大小。测试数据要贴近真实场景。比如你的映射结构实际存的是用户ID到用户对象的映射那就用真实的ID范围和对象大小来测。我试过用随机数测试结果和真实数据下的表现差异很大因为随机数的哈希分布和真实ID的哈希分布不一样。还有一个细节测试时要考虑JIT编译的影响。前几次运行可能因为解释执行而偏慢建议先跑几轮预热再取稳定后的数据。我在早期测试时没注意这点差点得出错误结论。3.3 那些不适合替换的场景有些场景我踩过坑明确不建议替换。第一是键值对数量可能动态增长到很大的情况比如一个全局缓存随着用户操作不断累积数据。这种场景下ArrayMap的二分查找会越来越慢而HashMap的O(1)基本稳定。第二是键的hashCode()计算成本很高的场景ArrayMap每次查找都要算哈希如果哈希计算本身很慢整体性能会受影响。第三是多线程环境这三个容器都不是线程安全的替换不能解决并发问题反而可能因为内部结构不同引入新的竞态条件。还有一个特殊情况如果这个HashMap会被序列化比如通过Parcelable传递那替换成SparseArray或ArrayMap需要额外实现序列化逻辑因为它们的内部结构不是标准的Map接口实现。这个成本要提前评估。4. 源码层面的关键细节put、get、remove到底做了什么4.1 SparseArray的put与二分查找插入SparseArray.put(int key, E value)的执行流程是这样的先用二分查找在mKeys数组里找key的位置。如果找到了直接替换mValues对应位置的值。如果没找到二分查找会返回一个负值表示应该插入的位置。然后检查当前容量是否足够不够就扩容接着用System.arraycopy把插入位置之后的元素整体后移一位最后写入新键值。这个后移一位的操作是O(n)的所以SparseArray的插入在数据量大时确实比HashMap慢。但实际测试中几百个元素的数组搬移在现代CPU上也就是微秒级别用户完全感知不到。真正需要注意的是批量插入场景如果你要连续插入几百个元素每次插入都搬移数组累积起来就慢了。这种场景建议先收集数据然后一次性构建或者直接用HashMap构建完再转换。get(int key)就是标准的二分查找找到返回mValues[i]找不到返回默认值。这里有个小优化SparseArray的二分查找实现里如果当前槽位的值是DELETED它会继续往两边找确保不会因为删除标记而漏掉真正存在的键。4.2 ArrayMap的哈希数组与冲突处理ArrayMap的内部有两个数组mHashes存哈希值mArray交替存键和值。indexOf()方法是核心它先用二分查找在mHashes里找目标哈希。找到后由于可能存在哈希冲突它会向前后扫描对每个相同哈希的位置比较键的equals。这里有个性能陷阱如果多个键的哈希值相同ArrayMap会退化成线性查找。我遇到过一种情况键是自定义对象hashCode()返回了一个固定值结果所有键的哈希都相同ArrayMap的性能直接崩了。所以使用ArrayMap的前提是键的hashCode()分布要均匀。put()操作在找到位置后如果需要插入新键会先检查容量然后搬移数组。ArrayMap的扩容策略是当容量不足时新容量取当前容量的1.5倍和当前元素数1中的较大值。这个策略比HashMap的翻倍更温和减少了内存浪费。4.3 延迟清理机制的实际影响前面提到SparseArray和ArrayMap的删除是标记式的。具体来说删除时把值设为DELETED对象键保持不变。后续的put()如果发现目标位置是DELETED会直接复用这个位置。gc()方法则会遍历数组把所有DELETED位置的值真正移除并压缩数组。这个机制带来的实际影响是删除操作很快但内存不会立即释放。如果你的场景是频繁增删且结构长期存活比如一个LRU缓存那标记会越积越多数组里有效元素的比例下降查找效率也会受影响。我的做法是在缓存淘汰逻辑里定期调用gc()或者在元素数量超过阈值时重建整个结构。还有一个坑size()返回的是有效元素数量不包括DELETED标记。但mKeys.length可能远大于size()。如果你用size()来预估内存占用会低估实际使用量。5. 真实项目中的替换实录与性能数据5.1 配置表场景SparseArray替换HashMap项目里有一个配置表键是配置IDint值是配置对象。原来用HashMapInteger, Config大约200条数据在应用启动时加载之后只读。替换成SparseArrayConfig后内存占用从约48KB降到约18KB启动阶段的GC次数从3次降到1次。查找耗时方面单次get从平均80纳秒变成平均120纳秒但因为这个配置表在启动后很少访问这点差异完全可以接受。这个场景的关键点是只读和规模小。如果是频繁读写的场景我会更谨慎地评估。5.2 视图缓存场景ArrayMap替换HashMap另一个场景是列表项里的视图缓存键是视图类型String值是View对象。原来用HashMapString, View每个列表项持有一个数量不多但创建频繁。替换成ArrayMapString, View后每个列表项的内存占用减少了约40%滑动时的帧率波动明显减小。这个场景的键是String而且字符串长度短、哈希分布均匀所以ArrayMap的二分查找效率很高。如果键是长字符串或者哈希分布不好效果可能没这么明显。5.3 一个反例高频增删场景下的性能回退我也踩过一个反例。有一个事件总线里的订阅者映射键是事件类型String值是订阅者列表。这个映射在运行时会频繁增删订阅者而且事件类型有几十种。我一开始换成了ArrayMap结果发现事件分发耗时增加了约15%。原因是每次增删都涉及数组搬移而且删除标记积累得快查找效率下降。后来我换回了HashMap但做了一些优化预设初始容量避免扩容用String的intern()减少重复键对象。这样内存占用降下来了性能也没损失。这个经历告诉我替换不是银弹得看具体场景。5.4 性能对比数据汇总场景原方案新方案内存变化查找耗时变化结论配置表200条只读HashMapSparseArray-62%50%推荐替换视图缓存20条频繁创建HashMapArrayMap-40%10%推荐替换事件订阅50条频繁增删HashMapArrayMap-35%15%不推荐全局缓存动态增长HashMapArrayMap-30%200%不推荐这张表的数据来自我自己的测试环境具体数值会因设备和数据分布而异但趋势可以作为参考。6. 替换后的验证与长期维护要点6.1 功能验证不能只看单元测试替换容器后单元测试通过不代表没问题。我遇到过一种情况单元测试里数据量小ArrayMap和HashMap行为一致但到了真实环境数据量大了ArrayMap的二分查找在哈希冲突时的线性扫描导致性能下降。所以验证时要在接近真实数据规模的条件下测试最好能跑一遍Monkey测试或者自动化UI测试观察是否有卡顿或异常。另外要注意null值的处理。HashMap允许null键和null值但SparseArray的键是int不能为nullArrayMap允许null键但内部处理逻辑和HashMap略有不同。如果原代码依赖null键的行为替换时要特别小心。6.2 内存泄漏排查的新关注点SparseArray和ArrayMap的延迟清理机制可能导致一种隐蔽的内存泄漏删除的元素值被标记为DELETED但如果没有触发gc()这些值对象仍然被数组引用无法被GC回收。如果值对象持有Context或者大对象就会造成泄漏。我在一个项目里就遇到过这个问题一个ArrayMap里删除了大量View对象但因为长期没有gc()这些View一直存活导致Activity无法回收。解决办法是在合适的生命周期节点调用gc()比如onPause()或者数据量变化超过阈值时。但也不能频繁调用因为gc()本身是O(n)的。6.3 代码可读性与团队协作替换容器后代码的可读性也需要考虑。SparseArray和ArrayMap的API和HashMap不完全一样比如SparseArray没有实现Map接口不能直接传给需要Map的方法。如果团队里有人不熟悉这两个类可能会写出错误的用法。我的做法是在替换的地方加注释说明为什么用这个容器以及注意事项。同时在代码规范里明确新增的映射结构如果键是int且规模小优先用SparseArray如果键是Object且规模小优先用ArrayMap其他情况用HashMap。6.4 持续监控与回退预案替换上线后要持续监控内存和帧率指标。我一般会在关键页面加上性能埋点对比替换前后的数据。如果发现异常要有快速回退的方案。因为容器替换通常涉及多处代码回退成本不低所以建议分批替换先在一个模块试点验证没问题后再推广。还有一个经验替换时保留原来的HashMap实现作为备选通过配置开关控制用哪个。这样出问题时可以快速切换不用重新发版。虽然会增加一点代码复杂度但在关键路径上值得。7. 一些零散但重要的经验补充关于SparseArray的keyAt()和valueAt()这两个方法在遍历时很有用但要注意它们返回的是内部数组的原始顺序也就是按键排序后的顺序。如果你需要按插入顺序遍历这两个类都不适合得用LinkedHashMap。ArrayMap有一个trimToSize()方法可以在数据稳定后调用把内部数组压缩到刚好容纳当前元素。我在配置表加载完成后会调一次能再省一点内存。但不要在频繁增删的场景调用因为下次插入又会触发扩容。SparseArray还有一个变体叫SparseIntArray专门用于int到int的映射省去了值对象的装箱。如果你的值也是int用这个比SparseArrayInteger更省内存。类似的还有SparseBooleanArray、SparseLongArray等按需选用。最后说一个我踩过的坑SparseArray的indexOfValue()方法用于根据值反查键但它是线性查找复杂度O(n)。如果频繁调用性能会很差。我见过有人在循环里调这个方法数据量大了之后直接卡死。如果确实需要反查建议额外维护一个反向映射。这些细节在官方文档里往往一笔带过但实际用起来才知道疼。希望这些经验能帮你少走点弯路。

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

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

免费获取报价 →
↑