SparseArray 与 ArrayMap

986 阅读4分钟

SparseArray

存储 int 为 Key,Object 为 value 的键值对。key 值按升序排序,通过二分查找可快速定位 key 所在的下标

  1. 因为 Int 为 key,所以不会出现 hash 冲突,但依旧存在扩容问题
  2. 删除时并不会直接移动数组,而是将对应值设置成 DELETED(一个特殊的 object),在合适时机统一处理。减少了数组的移动,提高性能

看一下 put 就可明白所有逻辑


public void put(int key, E value) {
    // 二分查找
    int i = ContainerHelpers.binarySearch(mKeys, mSize, key);

    if (i >= 0) {
        // key 已存在,因为不会出现 hash 冲突,所以直接替换掉旧值即可
        mValues[i] = value;
    } else {
        // key 不存在。返回的是 key 要插入位置取反
        i = ~i;
        // i 表示 key 要插入的位置
        
        if (i < mSize && mValues[i] == DELETED) {
            // 相应位置已删除,直接替换
            mKeys[i] = key;
            mValues[i] = value;
            return;
        }
        
        // 到这里说明第 i 个位置有元素,就需要向数组中插入新元素
        
        // mSize 并不是当前已有的键值对个数。在 remove() 时并不会更新 mSize
        // 所以 mSize 包含已删除的键值对
        
        if (mGarbage && mSize >= mKeys.length) {
            // 如果已经达到扩容条件
            // 先 gc 一次,删除掉已被 remove 的元素,同时会更新 mSize
            gc();

            // Search again because indices may have changed.
            // 更新要插入的下标。有可能在 gc 时数组发生了变动
            i = ~ContainerHelpers.binarySearch(mKeys, mSize, key);
        }
        
        // 扩容。同时将 key 插入第 i 个位置
        mKeys = GrowingArrayUtils.insert(mKeys, mSize, i, key);
        mValues = GrowingArrayUtils.insert(mValues, mSize, i, value);
        mSize++;
    }
}

// 返回 size 时,首先要进行 gc。因为 remove() 时并没有更新 size
public int size() {
    if (mGarbage) {
        gc();
    }

    return mSize;
}

ArrayMap

底层基于数组实现的,key,value 可为任何类型的 map

底层使用两个数组:

一个存储 key 的 hashCode(按 hashCode 升序排列)(为方便记为 hash 数组),一个存储 key/value(为方便记为 value 数组)

先通过 hashCode 计算出下标 index,value 数组中 2*index 与 2*index+1 就是对应的 key-value

  1. 因为 key 可为任意类型,存在 hash 冲突
  2. 需要扩容。逻辑也很简单:重新 new 两个数组,将数据复制到新数组中
  3. 每一次扩容都相当于丢弃两个旧的数组,有点浪费。ArrayMap 基于这点,实现了数组缓存。这有点奇怪,为啥 SparseArray 不缓存?而且很奇怪,只缓存 hash 数组长度为 4 或 8 的两个数组,想不出为啥。

上面算是 ArrayMap 的所有逻辑,整个 put() 方法能涉及到所有点


public V put(K key, V value) {
    final int osize = mSize;
    final int hash;
    int index;
    if (key == null) { // key 可为 null
        hash = 0;
        index = indexOfNull();
    } else {
        hash = mIdentityHashCode ? System.identityHashCode(key) : key.hashCode();
        // indexof() 计算出 key 所在的下标,这里就有 hash 冲突的处理逻辑
        index = indexOf(key, hash);
    }
    if (index >= 0) {
        // key 已存在,计算出 value 数组中相应的下标
        // 然后替换掉旧值
        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);

        final int[] ohashes = mHashes;
        final Object[] oarray = mArray;
        
        // 复用。不能复用就直接重新 new
        allocArrays(n);

        if (mHashes.length > 0) {
            // 复制,将旧数据复制到新数组中
            System.arraycopy(ohashes, 0, mHashes, 0, ohashes.length);
            System.arraycopy(oarray, 0, mArray, 0, oarray.length);
        }
        // 回收旧的数组
        freeArrays(ohashes, oarray, osize);
    }

    if (index < osize) {
        // index 指新 key-value 要插入的位置
        // 如果不是在末尾,就需要将尾部若干数据集体后移
        System.arraycopy(mHashes, index, mHashes, index + 1, osize - index);
        System.arraycopy(mArray, index << 1, mArray, (index + 1) << 1, (mSize - index) << 1);
    }
    // 在 index 处插入新的 key-value
    mHashes[index] = hash;
    mArray[index<<1] = key;
    mArray[(index<<1)+1] = value;
    mSize++;
    return null;
}

上面可以说是 ArrayMap 的核心逻辑。hash 冲突及数组复用问题,通过不同的方法实现。

hash 冲突

主要是 indexOf() 方法:

  1. 先通过二分查找,拿到候选下标。因为存在 hash 冲突,这个位置只能说明两个 key 的 hash 值相同,实际上可能并不一样
  2. 以候选下标为中心,向后、前搜索,查找是否有指定的 key
  3. 第二步搜不到,将新 key-value 插入到 hash 值相同的最后位置

int indexOf(Object key, int hash) {
    final int N = mSize;

    if (N == 0) {
        return ~0;
    }
    // 二分查找
    int index = binarySearchHashes(mHashes, N, hash);

    // hash 值根本不存在,就意味着 key-value 可以直接插入到 index 处
    if (index < 0) {
        return index;
    }

    // hash 值存在,而且也是要查找的 key,直接返回
    if (key.equals(mArray[index<<1])) {
        return index;
    }
    
    // 否则,说明出现 hash 冲突。那就往后、往前线性搜索
    // Search for a matching key after the index.
    int end;
    // 这个循环不断往后执行,最终 end 指向 hash 相同处的后一个元素
    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.
    // 往前遍历。这里并没有更新 end,只是检索 key 是否存在
    for (int i = index - 1; i >= 0 && mHashes[i] == hash; i--) {
        if (key.equals(mArray[i << 1])) return i;
    }
    
    // 遍历完整个 hash 冲突的 key,并没有新插入的 key,那就将新 kv 插入到最后
    return ~end;
}

数组缓存

主要涉及 allocArrays 与 freeArrays 两个方法。因为一次需要缓存两个数组,而且有多组。所以有两个问题需要解决:

  1. 两个数组如何对应?
  2. 多组缓存如何组织?

对于第二个问题好解决,用链表即可。第一个,一般想法是新建一个类,分别记录两个数组。但这完全浪费了 value 是一个 object 数组。所以ArrayMap 采取的思路是:value 数组第 0 个元素存储上一个 value 数组的地址,value 数组第 1 个元素存储与之对应的 hash 数组地址。这样既将数组对应上,也将所有组都串成链表。

另外要注意:数组的复用是多个 ArrayMap 共用,因此指向复用链表的变量是静态的。

理解了上述思路,freeArrays 方法就非常简单了,忽略。

学习

因为 ArrayMap 存在复用,但要求 hash 数组长度为 4 或 8。因此,尽量将 ArrayMap 的大小指定成 4/8,方便复用

其他问题

在 gityuan 这篇文章 gityuan.com/2019/01/13/… 中提出了缓存环导致缓存错误。在新版本的 allocArrays() 已经加 try-catch 处理,并且在发生异常时清空所有的缓存