本文翻译自 Google 工程师 Romain Guy 的个人博客,Romain 是 Android 创始人级别的工程师,大家应该可以在每年的 Google IO 上都见到他的身影。
Hashmap
是一种非常常见的数据结构,不出意外 Jetpack Compose 在编写的时候也有大量用到。Kotlin 可以通过调用mutableMapOf()
轻松创建一个新的可变 Hashmap
,然后根据需要使用映射(map
),大多数开发者基本上也都这样用。然而,在搞 Jetpack Compose 这样的工具包时,我们想尽量降低工具包对应用程序性能的影响。
所以让我们先看一下mutableMapOf()
的实现:
kotlin
public inline fun <K, V> mutableMapOf(): MutableMap<K, V> = LinkedHashMap()
LinkedHashMap
是一个 expected
类,在 Android 平台的实现取决于 Java 的 同名类:
kotlin
public actual typealias LinkedHashMap<K, V> = java.util.LinkedHashMap<K, V>
由于这个 API 身经百战,见得多了,在 Kotlin 中作为标准 Hashmap
确实是个不错的选择^1^。然而LinkedHashMap
的实现并不算"内存友好",因为每个插入到映射(map
)中的条目都会创建一个新的 LinkedHashMapEntry
实例(对于 HashMap
、HashMap.Node
这样的基类也一样),在我们的使用场景之下,就带来了一些负面影响:
- 每个条目使用的内存比必要的多。在
LinkedHashMap
中存储一个Int
需要额外的 3 个指针以及哈希副本,外加一个对象的开销。 - 内存访问,特别是调用迭代(iterations)时不够友好,尤其是随着堆内存的混乱而逐渐加剧。
- 增大 GC 的压力,尤其在旧设备和/或旧版本的 Android Runtime(ART)上跑的时候压力山大。
为了解决这些问题,我们开发了一个名为 ScatterMap
的新Hashmap
,旨在更加高效利用内存。这个新的 API 已经作为 androidx.collection
库的一部分,并在版本 1.4 中最近达到了候选发布(RC)状态。
ScatterMap
的一些特性包括:
- 每个条目的开销为 1 字节。
- 除非映射需要扩展其内部存储,否则插入不会产生分配。
- 迭代(iterations)无需额外分配内存。
- 常见 API 的实现都无需额外分配内存(例如,
removeIf()
是inline
的,以避免分配 predicate lambda)。 - 键(Key)和值(Value)被线性存储,当在映射(
map
)上进行迭代时,可以更好地进行内存缓存,无需额外的间接引用。 - 在插入、移除、检索和迭代方面的性能要优于
LinkedHashMap
,或至少与LinkedHashMap
相当。 - API 与 Kotlin 的映射(
map
)一致(工厂函数、ScatterMap
与MutableScatterMap
等)。 - 提供专门的变体以存储基本类型(例如
IntIntMap
或ObjectFloatMap
),无需自动装箱。
以下是在一台运行 Android 13 的 Pixel 6 手机上的性能跑分数据^2^:
测试项 | LinkedHashMap | ScatterMap |
---|---|---|
插入 1000 个元素 | 43,073 ns |
24,654 ns |
移除 1000 个元素 | 6,642 ns |
7,840 ns |
迭代 1000 个元素 | 10,308 ns |
4,044 ns |
查询 1000 个元素^3^ | 9,832 ns |
10,678 ns |
内存分配^4^ | 1,003 |
4 |
请注意,在这个测试前提之下,LinkedHashMap
的迭代测试成绩已经是它能够跑出的最高分数了,因为所有元素都是一个接一个立刻创建的,这就使得它们具有了良好的内存局部性。而 ScatterMap
的性能则能够做到随时间保持不变。
ScatterMap
的主要缺点是它没有实现标准的 Map
接口,这会导致不友好的内存行为。不过你可以通过调用 asMap()
方法从 ScatterMap
中获取一个 Map
或者 MutableMap
,因为默认的 API 尽量提供了很多 Map
中的常见功能,如 forEach()
、removeIf()
、any()
、count()
等。
所以,ScatterMap
是更好的 HashMap
吗?答案是肯定的,如果你需要优化内存分配、内存使用和内存访问,我认为它是值得一试的,你的职责就是根据实际使用场景去选择最终要用哪种数据结构。
在未来的文章中,我将解释 ScatterMap
的由来,它的内部工作原理,以及如何优化为生成高效的 ARM 64 汇编^5^,以及如何利用 SIMD 指令进一步提升性能。
- 我其实对这个结论也持保留意见,
LinkedHashMap
保证了可预测的迭代顺序,如果你依赖这种行为,在实际开发过程中接受Map
或MutableMap
这样的泛型类型,可能会导致一些微妙的错误。 - 这些是最初的跑分数据,在这之后
ScatterMap
有进行一些小的优化。︎ - 方法就是调 1000 次
get(key: K)
。 - 指的是插入 1000 个元素。
- 为此我还写了一个 kotlin-explorer的反汇编工具。 .