HashMap提升版SparseArray,进阶版ArrayMap

前面提到HashMap在使用过程中会有浪费内存的问题,为了解决这个问题呢,谷歌官方提供了新的数据结构-SparseArray。这个数据结构从字面上理解呢,就是稀疏数组或者说稀疏阵列。那我们就重点分析下SparseArray是如何节省内存的吧。

进入源码可以看到SparseArray的源码不是很长,然后映入眼帘的是几个成员变量:

    private static final Object DELETED = new Object(); //标识符,标记删除的元素
    private boolean mGarbage = false; //是否回收内存

    @UnsupportedAppUsage(maxTargetSdk = 28) // Use keyAt(int)
    private int[] mKeys; //key数组
    @UnsupportedAppUsage(maxTargetSdk = 28) // Use valueAt(int), setValueAt(int, E)
    private Object[] mValues; //value数组
    @UnsupportedAppUsage(maxTargetSdk = 28) // Use size()
    private int mSize;//长度大小

我们可以看到SparseArray的设计思想和HashMap是一样的采用Key-Value键值对的存储方式,但是SparseArray采用的是方式是Key保存一个数组,Value保存一个数组。然后国际惯例我们先看构造函数:

    public SparseArray() {
        this(10);
    }

    public SparseArray(int initialCapacity) {
        if (initialCapacity == 0) {
            mKeys = EmptyArray.INT;
            mValues = EmptyArray.OBJECT;
        } else {
            mValues = ArrayUtils.newUnpaddedObjectArray(initialCapacity);
            mKeys = new int[mValues.length];
        }
        mSize = 0;
    }

这边没有什么特殊的,就是根据传入的长度进行初始化key数组和value数组,然后我们看下如何存入一个元素:

    public void put(int key, E value) {
        int i = ContainerHelpers.binarySearch(mKeys, mSize, key); //二分法查找,看key是否存在,如果没找到返回一个负值,而这个负值则为最后查找的地方(需要插入的地方)取反得到的

        if (i >= 0) {
            mValues[i] = value; //key存在,直接更新value
        } else {
            i = ~i;//因为binarySearch方法中返回的值取反,则是应该需要插入的位置

            if (i < mSize && mValues[i] == DELETED) { //这个ELETED说明之前有产出过元素,直接复用内存空间
                mKeys[i] = key;
                mValues[i] = value;
                return;
            }

            if (mGarbage && mSize >= mKeys.length) {
                gc(); //如果开辟的数组长度大于正在使用的长度,则进行垃圾回收

                // Search again because indices may have changed.
                i = ~ContainerHelpers.binarySearch(mKeys, mSize, key);//回收完后需要重新查找合适的位置因为可能已经有元素的位置移动了
            }

            mKeys = GrowingArrayUtils.insert(mKeys, mSize, i, key);//在key数组中插入新的key
            mValues = GrowingArrayUtils.insert(mValues, mSize, i, value);//在value数组中插入新的value
            mSize++;
        }
    }

概括一下插入操作就是先在key数组中通过二分法查看是否已经存在该key,如果已经存在,则直接更新value数组中相应下标的值。如果不存在该key,但是找到合适的位置,则进行判断是否是之前被标记过已删除的,是的话,更新key和value数组中对应的值,如果不是的话,就需要插入新的元素,数组新增一个item并采用arraycopy的方式将后面的元素后移然后将新的元素插入。然后看看查询是如何查询的

    public E get(int key) {
        return get(key, null);
    }

    public E get(int key, E valueIfKeyNotFound) {
        int i = ContainerHelpers.binarySearch(mKeys, mSize, key);

        if (i < 0 || mValues[i] == DELETED) {
            return valueIfKeyNotFound;
        } else {
            return (E) mValues[i];
        }
    }

通过二分法查询,key,如果没找到或者说找到的value已经被标记为DELETED,则返回null,否则就直接返回value值。

那如果删除一个元素呢?

    public void delete(int key) {
        int i = ContainerHelpers.binarySearch(mKeys, mSize, key);

        if (i >= 0) {
            if (mValues[i] != DELETED) {
                mValues[i] = DELETED;
                mGarbage = true;
            }
        }
    }

    public void remove(int key) {
        delete(key);
    }

    public void 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);
        }
        if (mValues[index] != DELETED) {
            mValues[index] = DELETED;
            mGarbage = true;
        }
    }

    public void removeAtRange(int index, int size) {
        final int end = Math.min(mSize, index + size);
        for (int i = index; i < end; i++) {
            removeAt(i);
        }
    }

删除的方法有好几个,remove 和delete方法是一样的,找到key所在的位置,并把value数组中相应位置的元素标记为DELETED。但是并未进行内存回收,然后看removeAt和removeAtRange,removeAt就是把指定位置下未标记未DELETED的元素进行标记同时修改垃圾回收标记为true,那我们前面有看到put方法中有对数组使用情况进行判断并出发了gc()方法,那具体是怎么进行回收的呢?

    private void gc() {
        // Log.e("SparseArray", "gc start with " + mSize);

        int n = mSize;
        int o = 0;
        int[] keys = mKeys;
        Object[] values = mValues;

        for (int i = 0; i < n; i++) {
            Object val = values[i];

            if (val != DELETED) {
                if (i != o) {
                    keys[o] = keys[i];
                    values[o] = val;
                    values[i] = null;
                }

                o++;
            }
        }

        mGarbage = false;
        mSize = o;

        // Log.e("SparseArray", "gc end with " + mSize);
    }

好像也不是jvm的gc那么高级的操作,这里做的也是简单地把数组中未标记为DELETED的元素全部前移,然后剩下的设置为null,这样做是为了节省后面查找的时间。SparseArray的源码差不多就这些了,比较简单.大致概括下其特点:使用int和Object类型的数组分别存储key和value,相较HashMap使用Node,SparseArray在存储单个key-value时更节省内存;SparseArray使用int数组存储int类型的key,避免了int到Integer的自动装箱机制。但是SparseArray有个比较头疼的地方就是其key只能使用int~

谷歌官方还推出一个新的存储结构ArrayMap.

ArrayMap中存储使用到两个数组:


    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 int mBaseCacheSize;
    static Object[] mTwiceBaseCache; //用于缓存大小为8的ArrayMap
    static int mTwiceBaseCacheSize;

    final boolean mIdentityHashCode;
    int[] mHashes;         //由key的hashcode所组成的数组
    Object[] mArray;       //由key-value对所组成的数组,是mHashes大小的2倍
    int mSize;             //成员变量的个数

这两个数组的关系是这样子的:

HashMap提升版SparseArray,进阶版ArrayMap

构造函数我们直接看这个方法:

    public ArrayMap(int capacity, boolean identityHashCode) {
        mIdentityHashCode = identityHashCode;

        // If this is immutable, use the sentinal EMPTY_IMMUTABLE_INTS
        // instance instead of the usual EmptyArray.INT. The reference
        // is checked later to see if the array is allowed to grow.
        if (capacity < 0) {
            mHashes = EMPTY_IMMUTABLE_INTS;
            mArray = EmptyArray.OBJECT;
        } else if (capacity == 0) {
            mHashes = EmptyArray.INT;
            mArray = EmptyArray.OBJECT;
        } else {
            allocArrays(capacity);
        }
        mSize = 0;
    }

接下来我们put一个新的元素:

    public V put(K key, V value) {
        final int osize = mSize;
        final int hash;
        int index;
        if (key == null) {
            hash = 0;//key是null的话,hash值给定为0
            index = indexOfNull();
        } else {
            hash = mIdentityHashCode ? System.identityHashCode(key) : key.hashCode();
            index = indexOf(key, hash);//在mHashes数组中查找位置
        }
        if (index >= 0) { //在mHashes数组中找到说明存过相同key
            index = (index<<1) + 1; //找到相应的位置 index * 2 + 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);//如果未使用过则给初始长度 4,第一次扩容按照2倍扩容,后续的按照1.5倍扩容

            if (DEBUG) Log.d(TAG, "put: grow from " + mHashes.length + " to " + n);

            final int[] ohashes = mHashes;
            final Object[] oarray = mArray;
            //这一步的工作就是,初始化mHashes和mArray数组为扩容后的大小,当且仅当n为BASE_SIZE或BASE_SIZE*2时,直接从对应的缓存数组中取,
            //复用内存空间,这样就不用再new数组,以免重新开辟空间,浪费内存n不为BASE_SIZE或BASE_SIZE*2时,以new初始化mHashes数组和mArray数组
            allocArrays(n); 

            if (CONCURRENT_MODIFICATION_EXCEPTIONS && osize != mSize) {
                throw new ConcurrentModificationException();
            }

            if (mHashes.length > 0) {
                //将旧的数组赋值给进行allocArrays操作之后的数组
                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);
            }

            //这里传递的size为mSize,也就是扩容之前的大小,并非扩容之后的大小n,参数一和参数二,也是扩容之前mHashes和mArray中的数据
	        //添加缓存:mSize为BASE_SIZE或BASE_SIZE*2时,将mHashes数组和mArray数组链接到对应的缓存链表中存起来;mSize不为BASE_SIZE或BASE_SIZE*2,不作处理
            freeArrays(ohashes, oarray, osize);
        }

        if (index < osize) {
        //如果待插入的位置小于mSize,则需要将mHashes数组index的位置空出来,相应的后面元素后移
		//同时mArray数组中index*2和index*2+1这两个位置也要空出来,相应的后面的元素后移
            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();
            }
        }
        //将新的hash值以及key-value 值分别插入到相应位置
        mHashes[index] = hash;
        mArray[index<<1] = key;
        mArray[(index<<1)+1] = value;
        mSize++;
        return null;
    }

    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;
    }

这一部分的过程可以大致概括如:如果key不为null,求得hash值,并在mHashes数组中通过二分查找的方法找到相应的位置,如果存在该hash值,则在mArray数组中计算出相应的下标,更新key和value;如果没找到则先判断数组长度是否够用,不够用的话,需要进行扩容(第一次put,会设置长度为基本长度4,第一次扩容会扩容到2倍,后续按照1.5倍进行扩容)。扩容后再计算mArray中需要插入新元素的位置,经过一些列高端操作(下面分析这些操作)之后,将新的值进行插入。刚才看put方法的源码中使用了两个方法allocArrays()和 freeArrays(),那么下面我们来了解下这两个方法都干了啥:

首先我们看ArrayMap中有两个非常重要的静态成员变量mBaseCache和mTwiceBaseCacheSize,用于ArrayMap所在进程的全局缓存功能:

mBaseCache:用于缓存大小为4的ArrayMap,mBaseCacheSize记录着当前已缓存的数量,超过10个则不再缓存;mTwiceBaseCacheSize:用于缓存大小为8的ArrayMap,mTwiceBaseCacheSize记录着当前已缓存的数量,超过10个则不再缓存。

很多场景可能起初都是数据很少,为了减少频繁地创建和回收Map对象,ArrayMap采用了两个大小为10的缓存队列来分别保存大小为4和8的Map对象。为了节省内存有更加保守的内存扩张以及内存收缩策略。

    private void allocArrays(final int size) {
	    //mHashes数组容量为0,直接抛出异常
	    //EMPTY_IMMUTABLE_INTS这个值是mHashes数组的初始值,是一个大小为0的int数组
        if (mHashes == EMPTY_IMMUTABLE_INTS) {
            throw new UnsupportedOperationException("ArrayMap is immutable");
        }
        //如果大小为BASE_SIZE*2=8,这时缓存使用mTwiceBaseCache数组来缓存
        if (size == (BASE_SIZE*2)) {
            synchronized (ArrayMap.class) {//防止多线程操作带来的不同步
                if (mTwiceBaseCache != null) {
                    final Object[] array = mTwiceBaseCache;
                    //将mArray指向mTwiceBaseCache(相当于缓存链表的头指针)
                    //初始化mArray的大小(其实里面0号位置和1号位置也有数据,只不过没有意义)
                    mArray = array;
                    //将mTwiceBaseCache的指针指向头节点数组的0号元素,也就是指向第二个缓存数组
                    mTwiceBaseCache = (Object[])array[0];
                    //获取头节点数组array的1号元素指向的hash值数组,并赋给mHashes数组
                    mHashes = (int[])array[1];
                    //将mTwiceBaseCache缓存链表的头节点0号元素和1号元素置空,释放
                    array[0] = array[1] = null;
                    //缓存数组的数量减一
                    mTwiceBaseCacheSize--;
                    if (DEBUG) Log.d(TAG, "Retrieving 2x cache " + mHashes
                            + " now have " + mTwiceBaseCacheSize + " entries");
                    return;//结束
                }
            }
        } else if (size == BASE_SIZE) {
            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--;
                    if (DEBUG) Log.d(TAG, "Retrieving 1x cache " + mHashes
                            + " now have " + mBaseCacheSize + " entries");
                    return;//结束
                }
            }
        }
		//如果size既不等于BASE_SIZE,也不等于BASE_SIZE*2
		//那么就new新的数组来初始化mHashes和mArray,里面数据均为空,相当于没有使用缓存
        mHashes = new int[size];
        mArray = new Object[size<<1];
    }

当allocArrays分配内存时,如果所需要分配的大小等于4或者8,且相对应的缓冲池不为空,则会从相应缓存池中取出缓存的mArray和mHashes。从缓存池取出缓存的方式是将当前缓存池赋值给mArray,将缓存池指向上一条缓存地址,将缓存池的第1个元素赋值为mHashes,再把mArray的第0和第1个位置的数据置为null,并将该缓存池大小执行减1操作,具体如下所示:

HashMap提升版SparseArray,进阶版ArrayMap

这里需要注意的是只有大小为4或者8的内存分配才有可能从缓存池取数据,因为freeArrays过程放入缓存池的大小只有4或8,对于其他大小的内存分配则需要创建新的数组。 优化小技巧,对于分配数据不超过8的对象的情况下,一定要创建4或者8大小,否则浪费了缓存机制。

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操作。具体如下所示:

HashMap提升版SparseArray,进阶版ArrayMap

那如果我们删除一个元素呢?

    public V removeAt(int index) {
        final Object old = mArray[(index << 1) + 1];
        if (mSize <= 1) {//如果数组为空或者只有一个元素,那么直接将数组置空即可
            // Now empty.
            if (DEBUG) Log.d(TAG, "remove: shrink from " + mHashes.length + " to 0");
            freeArrays(mHashes, mArray, mSize);
            mHashes = EmptyArray.INT;//置空
            mArray = EmptyArray.OBJECT;
            mSize = 0;
        } else {
	        //当数组长度大于BASE_SIZE*2=8并且当前元素数量小于总容量的1/3时
            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.
                //收缩足够的空间来减少数组大小,为了避免连续删除元素导致大量无用内存,这些内存需要及时释放,以提高内存效率,还要控制数组不能收缩到小于8的值,以避免“抖动”
                //计算新的容量,如果大于8,那么就收缩为当前元素数量的1.5倍,否则,就置为8
                final int n = mSize > (BASE_SIZE*2) ? (mSize + (mSize>>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);//复用内存以初始化mHashes数组和mArray数组
				//数组元素减一
                mSize--;
                //如果删除的下标index值大于0,则赋值以恢复mHashes和mArray数组index之前的数据
                if (index > 0) {
	                //将之前保存的数组的值赋值给初始化之后的mHashes和mArray数组,恢复数据
	                //但是注意到第五个参数index,表示这一步只是赋值了删除元素index之前的数据
                    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);
                }
                //如果index小于容器元素数量,则赋值index之后的数据
                if (index < mSize) {
                    if (DEBUG) Log.d(TAG, "remove: copy from " + (index+1) + "-" + mSize
                            + " to " + index);
                    //对mHashes数组和mArray数组作前移操作,前移index位置以后的元素
                    System.arraycopy(ohashes, index + 1, mHashes, index, mSize - index);
                    //对mArray来说,就是前移index*2+2之后的数据元素
                    System.arraycopy(oarray, (index + 1) << 1, mArray, index << 1,
                            (mSize - index) << 1);
                }
            } else {//当前数组容量<8或者大于总容量的1/3时,不需要收缩数组容量
                mSize--;//直接减小1
                if (index < mSize) {
                    if (DEBUG) Log.d(TAG, "remove: move " + (index+1) + "-" + mSize
                            + " to " + index);
                    //前移index之后的元素
                    System.arraycopy(mHashes, index + 1, mHashes, index, mSize - index);
                    //前移index*2+2之后的元素
                    System.arraycopy(mArray, (index + 1) << 1, mArray, index << 1,
                            (mSize - index) << 1);
                }
                //前移后,最后一个元素空出来了,及时置空,以释放内存
                mArray[mSize << 1] = null;
                mArray[(mSize << 1) + 1] = null;
            }
        }
        return (V)old;
    }

上述方法可以概括删除的过程为:通过二分查找key的index,再根据index来选择移除动作;当被移除的是ArrayMap的最后一个元素,则释放该内存,否则只做移除操作,这时会根据容量收紧原则来决定是否要收紧,当需要收紧时会创建一个更小内存的容量。

上面的方法都看过之后,相信对于查询方法的了解应该是轻而易举了吧,那这里就不多解释了,直接看源码:

    public V get(Object key) {
        final int index = indexOfKey(key);
        return index >= 0 ? (V)mArray[(index<<1)+1] : null;
    }

对于ArrayMap源码的解读,着实耗了不少精力,网上很多资料讲的都是点到为止,而且很多都是基于SimpleArrayMap讲解的,我这边查看源码时有时候也是一头雾水,好在找到两个大佬写的博客很是清晰,这里贴出来,大家一起学习学习:

深度解读ArrayMap优势与缺陷

Android集合之SparseArray、ArrayMap详解

文章写到这里,我的周末也差不多结束了,希望后面能抽出更多的时间继续~

上一篇:数据结构与算法:稀疏sparsearray数组


下一篇:『数据结构与算法』稀疏数组