Android中的数据结构解析(四)SparseArray和ArrayMap

Android数据结构解析系列:
Android中的数据结构解析(一)ArrayList、LinkedList、Vector
Android中的数据结构解析(二)HashSet、LinkedHashSet、TreeSet
Android中的数据结构解析(三)HashMap、HashTable、TreeMap

HashMap是Java和Android开发中非常常用的数据结构,相信大多数人都对它非常熟悉。然而,作为一名Android开发,仅仅只知道使用HashMap是不够的。在很多情况下,HashMap对内存的消耗较大,从而影响移动设备的性能。因此,为了内存优化和性能提升,Android提供了用来替代HashMap的api:SparseArray和ArrayMap。

SparseArray

先来看一下SparseArray的介绍:

/**
 * SparseArrays map integers to Objects.  Unlike a normal array of Objects,
 * there can be gaps in the indices.  It is intended to be more memory efficient
 * than using a HashMap to map Integers to Objects, both because it avoids
 * auto-boxing keys and its data structure doesn't rely on an extra entry object
 * for each mapping.
 */

可以看到,SparseArray只能存储key为int类型的数据。所以,可以使用SparseArray<Object>来替代HashMap<Integer, Object>。它在性能上优于HashMap的原因有两点:

1.避免了int转为Integer时的自动装箱
在上一节中讲过,HashMap是通过key值的hashCode方法来确定元素存放的位置的。所以,当key值是基本类型int时,会自动装箱成Integer对象。装箱过程中会创建对象,这个动作是很消耗内存的。而SparseArray避免了装箱的这个动作,从而提升了性能。

2.避免了额外的Entry对象
HashMap的底层实现是数组+链表的数据结构。HashMap的每一条数据都会用一个Entry对象进行记录:

static class HashMapEntry<K, V> implements Entry<K, V> {
    final K key;
    V value;
    final int hash;
    HashMapEntry<K, V> next;

除了key和value之外,还记录了hash值,和到下一个Entry对象的指针。而SparseArray只需要一个key数组和一个value数组存放数据(下面会讲到),不需要额外的Entry对象,从而节省了内存。

接下来看一下SparseArray的具体实现和常用方法:

构造方法:

    private int[] mKeys;
    private Object[] mValues;
    private int mSize;

    ……

    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分别存放在mKeys和mValues这两个数组中。调用无参构造方法时,默认初始化数组的长度为10。

再来看一下SparseArray中增删改查方法的实现:

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

        if (i >= 0) {
            //如果key存在则直接替换value
            mValues[i] = value;
        } else {
            //key不存在 对i取反(~)就是应该插入的位置
            i = ~i;

            //如果插入的位置刚好是被删除过的元素,则直接将删除掉的value替换为要插入的value
            if (i < mSize && mValues[i] == DELETED) {
                mKeys[i] = key;
                mValues[i] = value;
                return;
            }

            //如果曾经删除过元素且没有进行过gc,进行一次gc操作
            if (mGarbage && mSize >= mKeys.length) {
                gc();

                // gc后数组下标可能会改变 所以重新查找一遍
                i = ~ContainerHelpers.binarySearch(mKeys, mSize, key);
            }

            //插入的操作
            mKeys = GrowingArrayUtils.insert(mKeys, mSize, i, key);
            mValues = GrowingArrayUtils.insert(mValues, mSize, i, value);
            mSize++;
        }
    }

SparseArray存储的元素都是按元素的key值从小到大排列好的。put方法的第一行,先调用了ContainerHelpers.binarySearch(mKeys, mSize, key)查找key是否存在。看一下这个方法的源码:

    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
    }

就是一个简单的二分查找算法,如果找到key就返回key的位置,如果找不到就返回key应该插入位置的取反。二分查找是SparseArray的核心算法,可以快速查找到key的位置。

再回到put方法。在对key进行二分查找之后,如果key存在则直接替换掉相应的value,如果key不存在则在对应的位置插入key和value。

看到这里可能会有人迷糊了:中间那两个莫名其妙的if是干嘛的呢?好像删掉它们也不会有什么问题啊?这里就涉及到了SparseArray的一个巧妙的优化。我们知道,对数组进行插入和删除操作的代价是非常大的,例如在数组中间删除一个元素,那么这个元素后面的所有元素都要向前移动一个位置,如果删除操作多了,会极大的降低性能。

那么SparseArray的删除操作是怎么做的呢?来看一下delete方法:

    private static final Object DELETED = new Object();

    ……

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

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

首先还是用二分查找找到要删除key在数组中的位置,查找到之后,并不立即删除这个元素,而是将相应的value标记为DELETED。这样如果之后有插入的key和这个删除掉的key相同时,直接替换掉value,这样就省去了一次删除和一次插入操作,提升了性能。在执行垃圾回收gc方法时,会一次性将所有标记为DELETED的元素全部删除,使原本要执行多次的删除操作减少到了一次:

    private void gc() {
        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;
    }

看到这里,你应该不会再对put里那两个奇怪的if感到困惑了。再看一下get方法:

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

    @SuppressWarnings("unchecked")
    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];
        }
    }

很简单没有什么难点了

要注意的一点是,由于SparseArray使用了二分查找,并且每次插入/删除等操作时都需要查找一次,所以在数据量大的时候,虽然节省了内存,但效率肯定是不如HashMap的。这时候的性能提升就很不明显了。

所以,使用SparseArray替换HashMap适合于以下两个条件:
1.key为int类型。(long类型也可以,使用LongSparseArray)
2.数据量不大。(千级左右)

ArrayMap
相对于SparseArray,ArrayMap不限制key的类型,任何的HashMap都可以用ArrayMap替换。ArrayMap的内部存储方式依然是两个数组:

    int[] mHashes;
    Object[] mArray;

不过不同的是,ArrayMap的key和value全部存储在mArray数组中。存储形式可以表示为:[key1, value1, key2, value2, key3, value3……]
那么mHashes数组中存储的自然就是每一个元素的hash值,查找元素时,先计算key转换过后的hash值,在mHashes数组中找到对应的hash值的位置,然后就可以在mArray数组中找到对应的key和value了。

ArrayMap在性能上优于HashMap的原因有两点:

1.和SparseArray一样,由于使用了两个数组存储数据,不再需要额外创建Entry对象,因此节省了内存空间。

2.扩容时,HashMap会重新new一个长度为2倍的容器返回。而ArrayMap则是调用System.arraycopy方法copy数据,减少了内存开销。

ArrayMap在mHashes数组中查找hash值时,同样用的是二分查找,所以在数据量较大时效率较低。因此ArrayMap一样适合于在数据量不大时使用。

总结
综上所述,在数据量在千级或千级以内时,使用SparseArray和ArrayMap可以减少内存的消耗,提升性能。其中,在key值为int或long型时,使用SparseArray(LongSparseArray);key值为其他对象时,使用ArrayMap。

推荐阅读更多精彩内容