[Framework] 理解 Android 中特有的集合类 ArrayMap
在我们的日常开发中经常用到的 Map 类是 HashMap,它的查询和插入数据的速度非常快,但是基于客户端的使用场景,它所占用的内存相对就大了一点点,在客户端我们的数据量绝大部分的时间都是很小的,所以 Google 基于 Android 开发了一款新的 Map 类,它的查询和插入数据的性能略逊于 HashMap (我觉得数据量低于 1000, 看不出明显的区别),内存使用优于 HashMap,它就是今天我们要讨论的 ArrayMap。
简述工作原理
在 ArrayMap 中 mArray 和 mHashes 可以说是最重要的两个成员变量,mArray 中存放的是 key-value,它的大小是容量的 2 倍,mHashes 中是按顺序存放的 key 对应的 hash 值,mArray 中存放的 key-value 的顺序和对应 key 的 hash 值在 mHashes 中存放的数据是一致的,也就是通过查询 key 对应的 hash 值在 mHashes 中的位置就能够直接得到 mArray 中对应的 key-value。前面也说到 hash 值是按顺序存放的,它的值的查找是通过二分法查找,性能也是非常不错的,查询的时间复杂度是 O(logn)( HashMap 最优秀的情况下复杂度是 O(1),最差才是 O(logn)),这里给一张图:
ArrayMap 中还有特有的缓存机制,它会缓存大小为 4 和 8 的 mArray 和 mHashes 对象,缓存的容量最大是 10,对应的静态变量分别是 mBaseCache 和 mTwiceBaseCache, 如果需要的 size 小于 4 那就直接从 mBaseCache 中去取,如果需要的 size 为 4 到 8 那直接从 mTwiceBaseCache 中去取。如果大于 8 就自己创建了。mBaseCache 可以说是一个链表,但是用的类型是 Object[],Object[0] 指向下一条数据,Object[1] 指向 mHashes,数组的其他值就是 null,而这个数组本身就是 mArray,这里给一张图:
对 ArrayMap 有过大致的了解后我们后面直接了解一下它的源码。
数据插入
put() 方法插入数据
直接看 put 方法:
@Override
public V put(K key, V value) {
final int osize = mSize;
final int hash;
int index;
if (key == null) {
hash = 0;
index = indexOfNull();
} else {
hash = mIdentityHashCode ? System.identityHashCode(key) : key.hashCode();
index = indexOf(key, hash);
}
if (index >= 0) {
index = (index<<1) + 1;
final V old = (V)mArray[index];
mArray[index] = value;
return old;
}
index = ~index;
if (osize >= mHashes.length) {
final int n = osize >= (BASE_SIZE*2) ? (osize+(osize>>1))
: (osize >= BASE_SIZE ? (BASE_SIZE*2) : BASE_SIZE);
if (DEBUG) Log.d(TAG, "put: grow from " + mHashes.length + " to " + n);
final int[] ohashes = mHashes;
final Object[] oarray = mArray;
allocArrays(n);
if (CONCURRENT_MODIFICATION_EXCEPTIONS && osize != mSize) {
throw new ConcurrentModificationException();
}
if (mHashes.length > 0) {
if (DEBUG) Log.d(TAG, "put: copy 0-" + osize + " to 0");
System.arraycopy(ohashes, 0, mHashes, 0, ohashes.length);
System.arraycopy(oarray, 0, mArray, 0, oarray.length);
}
freeArrays(ohashes, oarray, osize);
}
if (index < osize) {
if (DEBUG) Log.d(TAG, "put: move " + index + "-" + (osize-index)
+ " to " + (index+1));
System.arraycopy(mHashes, index, mHashes, index + 1, osize - index);
System.arraycopy(mArray, index << 1, mArray, (index + 1) << 1, (mSize - index) << 1);
}
if (CONCURRENT_MODIFICATION_EXCEPTIONS) {
if (osize != mSize || index >= mHashes.length) {
throw new ConcurrentModificationException();
}
}
mHashes[index] = hash;
mArray[index<<1] = key;
mArray[(index<<1)+1] = value;
mSize++;
return null;
}
这里首先通过 indexOf() (二分法查询,后续会分析) 方法去查询对应的 hash 的 index,如果没有查到它的值就是对应位置取反,查到了必然是大于等于 0。查询到了就表示这个 key 在 Map 中有,只需要修改对应的 value 就好了。
如果没有查询到返回的 index 取反就是这个新的 key 要插入的位置。插入新的值又分两种情况,分别是需要进行扩容和不需要进行扩容。
如果是扩容,首先会判断扩容后的容量,如果是当前容量大于等于 8,就会把容量乘以 1.5 倍,如果是小于 8 大于等于 4,容量就是 8,其他情况容量就是 4。这里还有一个多线程操作的判断,如果有其他线程修改直接抛出异常(后续的其他操作也有很多多线程的判断就不讲了)。然后调用 allocArrays() 分配数组(后面详细讲),然后通过 System.arraycopy() 移动数组中的数据把需要的位置空出来,最后调用 freeArrays()(后面详细讲) 把旧的 mHashes 和 mArray 回收。
如果不需要扩容,就直接通过 System.arraycopy() 移动数据就好了。
最后直接在 mHashes 和 mArray 中写入数据就完事了。
indexOf() 方法二分查询
@UnsupportedAppUsage(maxTargetSdk = 28) // Hashes are an implementation detail. Use indexOfKey(Object).
int indexOf(Object key, int hash) {
final int N = mSize;
// Important fast case: if nothing is in here, nothing to look for.
if (N == 0) {
return ~0;
}
int index = binarySearchHashes(mHashes, N, hash);
// If the hash code wasn't found, then we have no entry for this key.
if (index < 0) {
return index;
}
// If the key at the returned index matches, that's what we want.
if (key.equals(mArray[index<<1])) {
return index;
}
// Search for a matching key after the index.
int end;
for (end = index + 1; end < N && mHashes[end] == hash; end++) {
if (key.equals(mArray[end << 1])) return end;
}
// Search for a matching key before the index.
for (int i = index - 1; i >= 0 && mHashes[i] == hash; i--) {
if (key.equals(mArray[i << 1])) return i;
}
// Key not found -- return negative value indicating where a
// new entry for this key should go. We use the end of the
// hash chain to reduce the number of array entries that will
// need to be copied when inserting.
return ~end;
}
直接通过二分法搜索,如果没有找到,直接返回,如果找到了还会继续判断对应的 hash 值是不是对的,如果不是对的就会一个一个去遍历,直到找到为止。
这里说一句题外话,无论是 HashMap 还是 ArrayMap,我们的 key 必须满足:两个对象如果 equal() 为 true 那 hashcode 也一定是一样的,hashcode 一样,不一定 equal() 不一定为 true, 如果我们重写了 hashcode 或者 equal() 方法,就必须保证也重写另外的方法,来保证满足上面的条件。hashcode 是简单判断两个不相等对象的方法,如果 hashcode 相等还要用 equal() 方法再判断。
看看 binarySearchHashes() 二分查询的方法:
private static int binarySearchHashes(int[] hashes, int N, int hash) {
try {
return ContainerHelpers.binarySearch(hashes, N, hash);
} catch (ArrayIndexOutOfBoundsException e) {
if (CONCURRENT_MODIFICATION_EXCEPTIONS) {
throw new ConcurrentModificationException();
} else {
throw e; // the cache is poisoned at this point, there's not much we can do
}
}
}
static int binarySearch(int[] array, int size, int value) {
int lo = 0;
int hi = size - 1;
while (lo <= hi) {
final int mid = (lo + hi) >>> 1;
final int midVal = array[mid];
if (midVal < value) {
lo = mid + 1;
} else if (midVal > value) {
hi = mid - 1;
} else {
return mid; // value found
}
}
return ~lo; // value not present
}
朴实无华的二分查询,如果没有找到会在停的位置取反,然后返回结果。
allocArrays() 方法分配数组
@UnsupportedAppUsage(maxTargetSdk = 28) // Allocations are an implementation detail.
private void allocArrays(final int size) {
if (mHashes == EMPTY_IMMUTABLE_INTS) {
throw new UnsupportedOperationException("ArrayMap is immutable");
}
if (size == (BASE_SIZE*2)) {
synchronized (sTwiceBaseCacheLock) {
if (mTwiceBaseCache != null) {
final Object[] array = mTwiceBaseCache;
mArray = array;
try {
mTwiceBaseCache = (Object[]) array[0];
mHashes = (int[]) array[1];
if (mHashes != null) {
array[0] = array[1] = null;
mTwiceBaseCacheSize--;
if (DEBUG) {
Log.d(TAG, "Retrieving 2x cache " + mHashes
+ " now have " + mTwiceBaseCacheSize + " entries");
}
return;
}
} catch (ClassCastException e) {
}
// Whoops! Someone trampled the array (probably due to not protecting
// their access with a lock). Our cache is corrupt; report and give up.
Slog.wtf(TAG, "Found corrupt ArrayMap cache: [0]=" + array[0]
+ " [1]=" + array[1]);
mTwiceBaseCache = null;
mTwiceBaseCacheSize = 0;
}
}
} else if (size == BASE_SIZE) {
synchronized (sBaseCacheLock) {
if (mBaseCache != null) {
final Object[] array = mBaseCache;
mArray = array;
try {
mBaseCache = (Object[]) array[0];
mHashes = (int[]) array[1];
if (mHashes != null) {
array[0] = array[1] = null;
mBaseCacheSize--;
if (DEBUG) {
Log.d(TAG, "Retrieving 1x cache " + mHashes
+ " now have " + mBaseCacheSize + " entries");
}
return;
}
} catch (ClassCastException e) {
}
// Whoops! Someone trampled the array (probably due to not protecting
// their access with a lock). Our cache is corrupt; report and give up.
Slog.wtf(TAG, "Found corrupt ArrayMap cache: [0]=" + array[0]
+ " [1]=" + array[1]);
mBaseCache = null;
mBaseCacheSize = 0;
}
}
}
mHashes = new int[size];
mArray = new Object[size<<1];
}
如果需要的数组是等于 4 或者 8,那么就会从缓存中去拿,不过有可能会失败,这是由于多线程可能导致的一个 BUG,如果失败了缓存会被设置为空,就失去了缓存的能力。可以看到大佬的文章看看详细原因:深度解读ArrayMap优势与缺陷
如果需要的数组不是 4 或者 8,就直接新建一个。
freeArrays()
@UnsupportedAppUsage(maxTargetSdk = 28) // Allocations are an implementation detail.
private static void freeArrays(final int[] hashes, final Object[] array, final int size) {
if (hashes.length == (BASE_SIZE*2)) {
synchronized (sTwiceBaseCacheLock) {
if (mTwiceBaseCacheSize < CACHE_SIZE) {
array[0] = mTwiceBaseCache;
array[1] = hashes;
for (int i=(size<<1)-1; i>=2; i--) {
array[i] = null;
}
mTwiceBaseCache = array;
mTwiceBaseCacheSize++;
if (DEBUG) Log.d(TAG, "Storing 2x cache " + array
+ " now have " + mTwiceBaseCacheSize + " entries");
}
}
} else if (hashes.length == BASE_SIZE) {
synchronized (sBaseCacheLock) {
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++;
if (DEBUG) Log.d(TAG, "Storing 1x cache " + array
+ " now have " + mBaseCacheSize + " entries");
}
}
}
}
回收也是朴实无华,只回收大小为 4 或者 8 的数据,同时没有超过最大的容量 10,以大小为 4 的缓存为例,将当前要回收的 array[0] 指向当前 mBaseCache, array[1] 指向 hashes,array 的其他值设置为空,最后 mBaseCache 指向 array,这样就完成了把回收的 array 插入到缓存的头部。
数据查询
get() 方法查询数据
@Override
public V get(Object key) {
final int index = indexOfKey(key);
return index >= 0 ? (V)mArray[(index<<1)+1] : null;
}
indexOfKey 前面讲过,就不说了,然后直接从 mArray 中去拿 value,然后就没啦。
数据删除
remove() 方法删除数据
@Override
public V remove(Object key) {
final int index = indexOfKey(key);
if (index >= 0) {
return removeAt(index);
}
return null;
}
同样通过 indexOfKey() 方法去通过二分法查询对应的 index,如果大于等于 0 就表示查询到了,然后调用 removeAt() 方法删除。
public V removeAt(int index) {
if (index >= mSize && UtilConfig.sThrowExceptionForUpperArrayOutOfBounds) {
// The array might be slightly bigger than mSize, in which case, indexing won't fail.
// Check if exception should be thrown outside of the critical path.
throw new ArrayIndexOutOfBoundsException(index);
}
final Object old = mArray[(index << 1) + 1];
final int osize = mSize;
final int nsize;
if (osize <= 1) {
// Now empty.
if (DEBUG) Log.d(TAG, "remove: shrink from " + mHashes.length + " to 0");
final int[] ohashes = mHashes;
final Object[] oarray = mArray;
mHashes = EmptyArray.INT;
mArray = EmptyArray.OBJECT;
freeArrays(ohashes, oarray, osize);
nsize = 0;
} else {
nsize = osize - 1;
if (mHashes.length > (BASE_SIZE*2) && mSize < mHashes.length/3) {
// Shrunk enough to reduce size of arrays. We don't allow it to
// shrink smaller than (BASE_SIZE*2) to avoid flapping between
// that and BASE_SIZE.
final int n = osize > (BASE_SIZE*2) ? (osize + (osize>>1)) : (BASE_SIZE*2);
if (DEBUG) Log.d(TAG, "remove: shrink from " + mHashes.length + " to " + n);
final int[] ohashes = mHashes;
final Object[] oarray = mArray;
allocArrays(n);
if (CONCURRENT_MODIFICATION_EXCEPTIONS && osize != mSize) {
throw new ConcurrentModificationException();
}
if (index > 0) {
if (DEBUG) Log.d(TAG, "remove: copy from 0-" + index + " to 0");
System.arraycopy(ohashes, 0, mHashes, 0, index);
System.arraycopy(oarray, 0, mArray, 0, index << 1);
}
if (index < nsize) {
if (DEBUG) Log.d(TAG, "remove: copy from " + (index+1) + "-" + nsize
+ " to " + index);
System.arraycopy(ohashes, index + 1, mHashes, index, nsize - index);
System.arraycopy(oarray, (index + 1) << 1, mArray, index << 1,
(nsize - index) << 1);
}
} else {
if (index < nsize) {
if (DEBUG) Log.d(TAG, "remove: move " + (index+1) + "-" + nsize
+ " to " + index);
System.arraycopy(mHashes, index + 1, mHashes, index, nsize - index);
System.arraycopy(mArray, (index + 1) << 1, mArray, index << 1,
(nsize - index) << 1);
}
mArray[nsize << 1] = null;
mArray[(nsize << 1) + 1] = null;
}
}
if (CONCURRENT_MODIFICATION_EXCEPTIONS && osize != mSize) {
throw new ConcurrentModificationException();
}
mSize = nsize;
return (V)old;
}
首先判断当前数据大小,如果小于等于 1 ,就表示当前 Map 为空了,mArray 和 mHashes 分别指向空,然后把旧的数据通过 freeArrays() (前面有讲) 回收。
如果数据量大于 1 又分为两种情况,需要缩小容量和不需要缩小容量,只有当数据容量大于 8,同时当前的占用量小于容量的 1/3 就需要缩小容量。我们先看缩小容量的情况。当前数据的大小如果还是大于 8,那么就把原来的容量减半,如果不大于 8 了,就直接设置容量为 8 。然后通过 allocArrays()(前面有讲过) 方法重新分配。分配完成后需要把原有的数据通过 System.arraycopy() 方法移动到新的数据中去,当然排除要删除的数据。
如果不需要缩小容量就通过 System.arraycopy() 方法把要删除的数据的后面的数据向前移动一位,最后的一条数据指向空,这样就删除了对应的的数据。
最后
HashMap 的优势在于快,内部首先使用一个数组来存放数据(桶),内部还优化了 hashcode,使其经过取余运算后能够均匀分布存储到数组上,如果是发生 hash 碰撞后会以链表形式储存碰撞的数据,碰撞后也会检查扩容,默认是当前数据大于等于 0.75 倍数组的容量就扩容,数组默认为 16,扩容是直接变成 2 倍。它没有缩小容量的机制,也没有缓存机制,链表上的数据大于 8 以后,就会把链表转换成红黑树。
ArrayMap 的优势在于内存占用小,如果数据量小于等于 8 都是默认使用本地的缓存,如果 Map 为空了还会回收这部分缓存,如果是大于 8 的数据才需要重新分配空间,扩容机制是当前数据超过容量后才扩容,扩容是当前容量乘以 1.5 倍。删除数据后会检查当前数据量如果小于当前容量的 1/3,还会进行缩小容量,缩小的容量是原来的一半。通过简单二分查询和拷贝数组中的数据来查询和删除在速度上略逊一筹 HashMap 的数组 + 链表 + 红黑树的机制。
我个人认为大部分的应用 HashMap 和 ArrayMap 使用起来都差不多,某些特殊情况下可以根据 HashMap 和 ArrayMap 他们各自的优势再做取舍。
Android 中还有一些和 ArrayMap 类似的集合类,比如 ArraySet,SparseArray 等等,感兴趣可以找来看看。