一 概述
在移动设备端,内存资源很珍贵,HashMap 为实现快速查询带来了很大内存的浪费。为此,2013年5月20日 Google 工程师 Dianne Hackborn 在 Android 系统源码中新增 ArrayMap 类,从 Android 源码中发现有不少提交,专门把之前使用 HashMap 的地方改用 ArrayMap,不仅如此,大量的应用开发者中也广为使用。
然而,你是否了解这么广泛使用的基础数据结构存在缺陷?要回答这个问题,需要先从源码角度来理解 ArrayMap 的原理。
ArrayMap 是 Android 专门针对内存优化而设计的,用于取代 Java API 中的 HashMap 数据结构。
为了更进一步优化 key 是 int 类型的 Map,Android 再次提供效率更高的数据结构 SparseArray,可避免自动装箱过程。对于 key 为其他类型,则可以使用 ArrayMap。
HashMap 的查找和插入时间复杂度为 O(1) 的代价是牺牲大量的内存来实现的,而 SparseArray 和 ArrayMap 性能略逊于 HashMap,但更节省内存。
接下来,从源码看看 ArrayMap,为了全面解读,文章有点长,请耐心阅读。
二 源读 ArrayMap
2.1 基本成员变量
public final class ArrayMap<K, V> implements Map<K, V> {
private static final boolean CONCURRENT_MODIFICATION_EXCEPTIONS = true;
private static final int BASE_SIZE = 4; // 容量增量的最小值
private static final int CACHE_SIZE = 10; // 缓存数组的上限
static Object[] mBaseCache; // 用于缓存大小为 4 的 ArrayMap
static Object[] mTwiceBaseCache; // 用于缓存大小为 8 的 ArrayMap
static int mBaseCacheSize;
static int mTwiceBaseCacheSize;
final boolean mIdentityHashCode;
int[] mHashes; // 由 key 的 hashcode 所组成的数组
Object[] mArray; // 由 key-value 对所组成的数组,是 mHashes 大小的 2 倍
int mSize; // 成员变量的个数
}
ArrayMap 对象的数据储存格式如下图所示:
- mHashes 是一个记录所有 key 的 hashcode 值组成的数组,是从小到大的排序方式
- mArray 是一个记录着 key-value 键值对所组成的数组,是 mHashes 大小的2倍
其中 mSize 记录着该 ArrayMap 对象中有多少对数据,执行 put() 或者 append() 操作,则 mSize 会加 1,执行 remove(),则 mSize 会减 1。
mSize 往往小于 mHashes.length,如果 mSize 大于或等于 mHashes.length,则说明 mHashes 和 mArray 需要扩容。
ArrayMap 类有两个非常重要的静态成员变量 mBaseCache 和 mTwiceBaseCache,用于 ArrayMap 所在进程的全局缓存功能:
- mBaseCache:用于缓存大小为 4 的 ArrayMap,mBaseCacheSize 记录着当前已缓存的数量,超过 10 个则不再缓存
- mTwiceBaseCache:用于缓存大小为 8 的 ArrayMap,mTwiceBaseCacheSize 记录着当前已缓存的数量,超过 10 个则不再缓存
为了减少频繁地创建和回收 Map 对象,ArrayMap 采用了两个大小为 10 的缓存队列来分别保存大小为 4 和 8 的 Map 对象。为了节省内存使用了更加保守的内存扩张以及内存收缩策略。 接下来分别说说缓存机制和扩容机制。
2.2 缓存机制
ArrayMap 是专为 Android 优化而设计的 Map 对象,使用场景比较高频,很多场景可能起初都是数据很少,为了减少频繁地创建和回收,特意设计了两个缓存池,分别缓存大小为 4 和 8 的 ArrayMap 对象。要理解缓存机制,那就需要看看内存分配 (allocArrays) 和内存释放 (freeArrays)。
2.2.1 ArrayMap.freeArrays
private static void freeArrays(final int[] hashes, final Object[] array, final int size) {
if (hashes.length == (BASE_SIZE*2)) {
// 当释放的是大小为 8 的对象
synchronized (ArrayMap.class) {
// 当大小为 8 的缓存池的数量小于 10 个,则将其放入缓存池
if (mTwiceBaseCacheSize < CACHE_SIZE) {
array[0] = mTwiceBaseCache; // array[0] 指向原来的缓存池
array[1] = hashes;
for (int i=(size<<1)-1; i>=2; i--) {
array[i] = null; // 清空其他数据
}
mTwiceBaseCache = array; // mTwiceBaseCache 指向新加入缓存池的 array
mTwiceBaseCacheSize++;
}
}
} else if (hashes.length == BASE_SIZE) {
// 当释放的是大小为 4 的对象,原理同上
synchronized (ArrayMap.class) {
if (mBaseCacheSize < CACHE_SIZE) {
array[0] = mBaseCache;
array[1] = hashes;
for (int i=(size<<1)-1; i>=2; i--) {
array[i] = null;
}
mBaseCache = array;
mBaseCacheSize++;
}
}
}
}
最初 mTwiceBaseCache 和 mBaseCache 缓存池中都没有数据,在 freeArrays 释放内存时,如果同时满足释放的 array 大小等于 4 或者 8,且相对应的缓冲池个数未达上限,则会把该 arrya 加入到缓存池中。
加入的方式是将数组 array 的第 0 个元素指向原有的缓存池,第 1 个元素指向 hashes 数组的地址,第 2 个元素以后的数据全部置为 null。再把缓存池的头部指向最新的 array 的位置,并将该缓存池大小执行加 1 操作。具体如下所示。
ArrayMap 的 freeArrays() 的触发条件:
- 当执行 removeAt() 移除最后一个元素的情况
- 当执行 clear() 清理的情况
- 当执行 ensureCapacity() 在当前容量小于预期容量的情况下,先执行 allocArrays,再执行 freeArrays
- 当执行 put() 在容量满的情况下,先执行 allocArrays,再执行 freeArrays
2.2.2 ArrayMap.allocArrays
private void allocArrays(final int size) {
if (size == (BASE_SIZE*2)) {
// 当分配大小为 8 的对象,先查看缓存池
synchronized (ArrayMap.class) {
if (mTwiceBaseCache != null) {
// 当缓存池不为空时
final Object[] array = mTwiceBaseCache;
mArray = array; // 从缓存池中取出 mArray
mTwiceBaseCache = (Object[])array[0]; // 将缓存池指向上一条缓存地址
mHashes = (int[])array[1]; // 从缓存中 mHashes
array[0] = array[1] = null;
mTwiceBaseCacheSize--; // 缓存池大小减 1
return;
}
}
} else if (size == BASE_SIZE) {
// 当分配大小为 4 的对象,原理同上
synchronized (ArrayMap.class) {
if (mBaseCache != null) {
final Object[] array = mBaseCache;
mArray = array;
mBaseCache = (Object[])array[0];
mHashes = (int[])array[1];
array[0] = array[1] = null;
mBaseCacheSize--;
return;
}
}
}
// 分配大小除了 4 和 8 之外的情况,则直接创建新的数组
mHashes = new int[size];
mArray = new Object[size<<1];
}
当 allocArrays 分配内存时,如果所需要分配的大小等于 4 或者 8,且相对应的缓冲池不为空,则会从相应缓存池中取出缓存的 mArray 和 mHashes。
从缓存池取出缓存的方式是将当前缓存池赋值给 mArray,将缓存池指向上一条缓存地址,将缓存池的第 1 个元素赋值为 mHashes,再把 mArray 的第 0 和第 1 个位置的数据置为 null,并将该缓存池大小执行减 1 操作,具体如下所示。
ArrayMap 的 allocArrays 的触发时机:
- 当执行 ArrayMap 的构造函数的情况
- 当执行 removeAt() 在满足容量收紧机制的情况
- 当执行 ensureCapacity() 在当前容量小于预期容量的情况下,先执行 allocArrays,再执行 freeArrays
- 当执行 put() 在容量满的情况下,先执行 allocArrays,再执行 freeArrays
这里需要注意的是只有大小为 4 或者 8 的内存分配,才有可能从缓存池取数据,因为 freeArrays 过程放入缓存池的大小只有 4 或 8,对于其他大小的内存分配则需要创建新的数组。
优化小技巧,对于分配数据不超过 8 的对象的情况下,一定要创建 4 或者 8 大小,否则浪费了缓存机制。比如 ArrayMap[7] 就是不友好的写法,建议写成 ArrayMap[8]。
2.3 扩容机制
2.3.1 容量扩张
public V put(K key, V value) {
...
final int osize = mSize;
if (osize >= mHashes.length) {
// 当 mSize 大于或等于 mHashes 数组长度时需要扩容
final int n = osize >= (BASE_SIZE*2) ? (osize+(osize>>1))
: (osize >= BASE_SIZE ? (BASE_SIZE*2) : BASE_SIZE);
allocArrays(n); // 分配更大的内存【小节2.2.2】
}
...
}
当 mSize 大于或等于 mHashes 数组长度时,则扩容,完成扩容后需要将老的数组拷贝到新分配的数组,并释放老的数组内存。
- 当 map 个数满足条件 osize<4 时,则扩容后的大小为 4
- 当 map 个数满足条件 4<= osize < 8 时,则扩容后的大小为 8
- 当 map 个数满足条件 osize>=8 时,则扩容后的大小为原来的 1.5 倍
可见 ArrayMap 大小在不断增加的过程,size 的取值一般情况依次会是 4,8,12,18,27,40,60,…
2.3.2 容量收紧
public V removeAt(int index) {
final int osize = mSize;
final int nsize;
if (osize > 1) {
// 当 mSize 大于 1 的情况