ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

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

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的成本算清楚。一个HashMap<Integer, String>,每存一对数据,实际产生的对象包括:一个Integer键对象(装箱)、一个Node对象(Java 8之后是Node,之前是Entry)、一个数组槽位引用。Node内部又持有hash、key、value、next四个字段。也就是说,存100条数据,光Node对象就100个,加上键的装箱对象,内存占用是相当可观的。

更关键的是扩容机制。HashMap默认容量16,负载因子0.75,存到第13个元素时就会触发扩容,容量翻倍到32,然后重新哈希所有元素。这个过程中会新建一个数组,旧数组等待GC。在内存紧张的设备上,这种"分配-复制-丢弃"的模式很容易触发GC。

我用一个简单的测试验证过:在一个循环里创建1000个HashMap<Integer, 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

项目里有一个配置表,键是配置ID(int),值是配置对象。原来用HashMap<Integer, Config>,大约200条数据,在应用启动时加载,之后只读。替换成SparseArray<Config>后,内存占用从约48KB降到约18KB,启动阶段的GC次数从3次降到1次。查找耗时方面,单次get从平均80纳秒变成平均120纳秒,但因为这个配置表在启动后很少访问,这点差异完全可以接受。

这个场景的关键点是"只读"和"规模小"。如果是频繁读写的场景,我会更谨慎地评估。

5.2 视图缓存场景:ArrayMap替换HashMap

另一个场景是列表项里的视图缓存,键是视图类型(String),值是View对象。原来用HashMap<String, View>,每个列表项持有一个,数量不多但创建频繁。替换成ArrayMap<String, 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,不能为null;ArrayMap允许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,用这个比SparseArray<Integer>更省内存。类似的还有SparseBooleanArray、SparseLongArray等,按需选用。

最后说一个我踩过的坑:SparseArray的indexOfValue()方法用于根据值反查键,但它是线性查找,复杂度O(n)。如果频繁调用,性能会很差。我见过有人在循环里调这个方法,数据量大了之后直接卡死。如果确实需要反查,建议额外维护一个反向映射。

这些细节在官方文档里往往一笔带过,但实际用起来才知道疼。希望这些经验能帮你少走点弯路。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进