Android数据结构之SparseArray

引言

在Android开发中,当须要存储键值对时,通常都是用java自带的HashMap。可是细心的同窗可能会发现,有时候若是实际的HashMap的key-vaule中的key是Integer时,AndroidStudio会提示一个warnning,具体是说推荐使用SparseArray替代HashMap:java

warnning

虽说warnning不影响实际功能,可是有个warnning放在那里总让人不爽。由于是lint静态扫描报的,能够用@SuppressLint("UseSparseArrays")忽略掉。可是既然google特意出了这么一个类用来替代key为Integer的HashMap,那是否是真的比HashMap更好用?android

优缺点

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有节约自动装箱开销的优势外,还提到SparseArray由于少了须要Map.Entry<K, V>做为辅助的存储结构引入的内存开销。数组

由于Map<K, V>的泛型声明,key必须是Integer不能是int,因此确实会带来自动装箱的问题。数据结构

这两个优势都是让SparseArraymore memory efficient的,这是由于SparseArray的诞生就是针对某些Android设备内存比较紧张的状况的。app

可是通常来讲,SparseArray是比Hashmap慢的,在数据集大小只有上百个的时候,差异不大。优化

使用

不论是HashMap仍是SpareArray,他们的做用都是维护一组逻辑上的key-value的对应关系。那么,在这组关系上最常作的操做就是存和取了。google

存/取

HashMap的存操做和取操做分别对应方法put(K key, V value)get(Object key),大概用过HashMap的没有不知道这两个方法的。而SpareArray对的两个方法分别是put(int key, E value)get(int key),和HashMap的方法看起来几乎没有区别,key为Integer的hashmap的相关代码能够无缝换成SpareArray。spa

SparseArray<String> sparseArray = new SparseArray<>();
sparseArray.put(200, "firstValue");
sparseArray.put(100, "secondValue");
System.out.println(sparseArray.get(100));

输出:
>> secondValue

HashMap<Integer, String> hashMap = new HashMap<>();
hashMap.put(200, "firstValue");
hashMap.put(100, "secondValue");
System.out.println(hashMap.get(100));

输出:
>> secondValue
复制代码

遍历

SpareArray的遍历要稍微麻烦些。3d

首先先创建一个概念,SparseArray执行put的时候实际上是按照key的大小有序插入的。简单来讲,SparseArray维护了各个键值对的排序关系,具体的规则是以key升序排列。因此不一样于HashMap只能经过key查找value,Sparse还能经过index查找value(或者key),方法是valueAt(int index)(或者keyAt(int index))。这里的index是升序排序中键值对的位置,index是SparseArray相比Map多出来的概念,看了后面的源码实现分析就好理解了。

拿上面的代码举例,put了key为100和200的两个键值对,size为2,200-"firstValue"这对key-value对在index 0的位置,100-"secondValue"这对键值对在index 1的位置。顺序是根据key的大小排的,跟put的前后顺序无关。因此valueAt(0)拿到的是"secondValue"

具体的遍历代码:

for (int index = 0; index < sparseArray.size(); index++) {
    System.out.println(String.format("key: %d, value: %s", sparseArray.keyAt(index), sparseArray.valueAt(index)));
}

输出:
>> key: 100, secondValue
>> key: 200, firstValue for (Map.Entry<Integer, String> entry : hashMap.entrySet()) {
    System.out.println(String.format("key: %d, value: %s", entry.getKey(), entry.getValue()));
}

输出:
>> key: 100, secondValue
>> key: 200, firstValue
复制代码

实现细节

和hashmap比较

大体讲下hashmao的原理。hashmap使用key的hashcode来决定entry的存放位置,解决hash冲突使用的开散列方法,因此hashmap的底层数据结构看起来是一个链表的数组,链表的节点是包含了key和value的Entry类。看起来就像下图:

hashmap

而SparseArray的底层数据结构更简单,只有int[] mKeysObject[] mValues两个数组。那这里就有个问题了:不一样于HashMap专门用一个Entry的类存放key跟value,SpareArray里key和value分别存放在两个数组里,那key和value是怎么对应上的?

答案就是,是根据index对应的,mKeys和mValues中index同样的key和value就是相互对应的。因此SparseArray实际存储的数据看起来是这样的:

sparsearray

HashMap中基于Entry创建的key-value对应关系会致使Entry占用内存,而sparse基于index的对应关系是逻辑的,节省下了Entry类的内存,这又是SparseArray的一个优势。

存/取

前面提到,SparseArray中实际存储的数据是有序的。那么保证有序的关键就在每次的存和删操做中:在本来有序的状况下,保证存和删操做后仍是有序的。

看存操做的实现,注释说明了关键点:

public void put(int key, E value) {
    // 二分查找找到此次插入的key应该插入哪一个位置能够保持整个结构的有序
    int i = ContainerHelpers.binarySearch(mKeys, mSize, key);

    if (i >= 0) {
        // >=0表示在mKeys的前size个元素中找到了key
        mValues[i] = value;
    } else {
        // <0在mKeys的前size个元素中没找到key(前面没有放过这个key的话就会找不到)
        // 不过返回的i的绝对值表示了key应该放在这个index以保持操做后的数组依然有序
        i = ~i;

        // 本次数据应该放入的位置是可用的,直接使用(这个key被标记删除了)
        if (i < mSize && mValues[i] == DELETED) {
            mKeys[i] = key;
            mValues[i] = value;
            return;
        }

        if (mGarbage && mSize >= mKeys.length) {
            // 真正删除前面标记删除的数据,具体下面会讲到
            gc();

            // Search again because indices may have changed.
            // gc可能改变了底层存储数据的数组的结构,再二分查找一次index
            i = ~ContainerHelpers.binarySearch(mKeys, mSize, key);
        }

        // 真正放入数据,若是mKeys和mValues的长度比i小,会引发扩容
        // 扩容相关的逻辑看下面分析
        mKeys = GrowingArrayUtils.insert(mKeys, mSize, i, key);
        mValues = GrowingArrayUtils.insert(mValues, mSize, i, value);
        mSize++;
    }
}
复制代码

因此保证全部存储的数据都是有序排列的关键就在于每次插入的时候如何肯定插入的新数据插入的位置。上面看到每次肯定实际插入的位置是基于二分查找肯定的。举个例子:

  • 原先的数据是mIndexs = {1, 4, 6, 8},size为4
  • 要插入的key是7
  • 第一次二分查找返回的index是-3,说明如今的数据中没有这个index,这个key应该被插入index为3的位置
  • 调用GrowingArrayUtils.insert将7插入index为3的位置,实际会引起mKeys扩容到8,原先的key8往右移
  • 最后的数据是mIndexs = {1, 4, 6 ,7, 8},保持了有序

其实实际插入数据的过程相似于优化后的插入排序,肯定了插入的位置后把这个位置后面的数据移动一位,而后把新数据放入空出来的位置。

取的过程很简单,一样是根据二分查找找到若是有这个key的话它应该在哪一个位置,若是找到的index<0反过来就证实了没有这个key:

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];
    }
}
复制代码

HashMap的取操做在hash分桶时时间复杂度为O(1),可是在发生hash冲突的时候最后会在链表中顺序查找,而SparseArray的取操做彻底依赖于二分查找,时间复杂度理论上老是O(nlogn),没有hash冲突致使访问慢的问题;不过HashMap的hash冲突通常不多,整体来讲SparseArray老是比HashMap慢些;并且二分查找的时间复杂度也决定了SparseArray不适合大量数据的场景。

删/gc

SparseArray删除数据是经过delete(int key)方法删除的。在删除一条数据的时候,不是直接执行实际的删除操做,而是先把要删除的数据标记为DELETE状态,在须要获取size、扩容、取数据的时候,会执行gc,一次性真正把前面标记的全部删除数据删掉。

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

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

gc的过程有点相似虚拟机的gc中的标记整理算法。具体就是遍历全部数据,收集全部没有被删除的数据移动到最前面。

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;
}
复制代码

这样作的好处有两个:

  • 若是在刚delete了一条数据后又放了一条相同key的数据进来,这条数据由于被覆盖了后面也不用执行真正的gc了,节省了操做时间
  • 若是一次性delete多条数据,能够把真正的删除操做放在一次gc中而不是屡次gc中,节省时间

扩容/缩容

前面提到,在put数据的时候可能会引起扩容。扩容的时机很简单,当底层的数组没有空余的空间存放新的数据时就会引起扩容。扩容的算法很简单,基本上就是翻倍,GrowingArrayUtils#growSize

public static int growSize(int currentSize) {
    return currentSize <= 4 ? 8 : currentSize * 2;
}
复制代码

不过须要注意的是,growSize算出来size不必定是扩容操做后真正的size,由于扩容时新的数组是调用ArrayUtils#newUnpaddedArray生成新数组的,这个方法涉及内存对齐,实际返回的数组的size通常比要求的大小要大。

SparseArray是没有缩容机制的。假如前面存了大量的数据致使数组扩容到了1024,哪怕调用clear清空全部数据底层数组的大小仍是1024。因此先存放大量数据在删到只剩少许须要长期持有的数据场景下,用SpareArray可能会致使空间的浪费。

总结

  • 建议使用SparseArray替换HashMap是由于得益于下面几点,SparseArray可能比HashMap更节省内存,而某些android设备上内存是紧缺资源:
    • 避免了Integer的自动装箱
    • 基于index创建的key和value的映射关系相比Map基于Entry结构创建key和value的映射关系节约内存
    • 某些场景如hash冲突下访问速度可能优于hashmap;不适合数据集比较大的场景。
  • SparseArray没有缩容机制。某些场景下不适合使用,好比:大量地put后大量地delete,而后长久持有SparseArray,致使大量的空位置无法被虚拟机gc,浪费内存
  • SparseArray通常来讲比Hashmap慢,由于二分查找比较慢,并且插入删除数据涉及数组的copy。在数据集不大时不明显
  • SparseArray每次插入删除数据都保证了全部存储数据的排列有序
  • SparseArray能够经过index定位数据,Hashmap不行
相关文章
相关标签/搜索