Android数据容器之SparseArray

1,643 阅读12分钟

SparseArray是谷歌推出的一个数据容器,推荐在Android平台上替代key为int类型的HashMap。官方文档中给出的理由是相比HashMap更节省内存:1、避免了自动装箱的过程;2、数据结构不依赖于外部对象映射。因为对于移动端来说,内存的资源更加珍贵。

源码解析

以android.util.SparseArray为例,SDK=28

成员变量

private static final Object DELETED = new Object();     //标记value是否被删除
private boolean mGarbage = false;   //标记当前是否进行了垃圾回收

private int[] mKeys;    //存储key的数组,升序排列
private Object[] mValues;   //存储value的数组
private int mSize;   //当前实际的元素个数

初始化

SparseArray默认的无参构造方法的初始容量为10,但是经过内部处理后变为11。初始化后mKeys和mValues都是长度为11的未赋值数组


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

put

最重要的方法,key和value存储的过程

public void put(int key, E value) {
    //传进来的key会先进行一次二分查找,因此mkeys要保证是有序的
    int i = ContainerHelpers.binarySearch(mKeys, mSize, key);
    //如果找到了,说明原先对应该key的value已经存在,直接更新即可
    if (i >= 0) {
        mValues[i] = value;
    } else {
        //没有找到,对i进行一次取反操作,得到要插入的下标
        i = ~i;
        //如果要插入的下标正好值是DELETED,直接更新即可
        if (i < mSize && mValues[i] == DELETED) {
            mKeys[i] = key;
            mValues[i] = value;
            return;
        }

        
        if (mGarbage && mSize >= mKeys.length) {
            //容量不足,并且需要垃圾回收,就先进行回收操作
            gc();
    
            //空间压缩后数组元素变化,需要重新计算插入的位置
            i = ~ContainerHelpers.binarySearch(mKeys, mSize, key);
        }
        //插入元素
        mKeys = GrowingArrayUtils.insert(mKeys, mSize, i, key);
        mValues = GrowingArrayUtils.insert(mValues, mSize, i, value);
        mSize++;
    }
}

二分查找和插入元素共同保证了mKeys的有序,确定了key对应在数组中的位置后,也就确定了value在数组中的位置。如果key已经存在或者命中了DELETED,那么直接更新,数组元素数量不变,开始时mKeys.length=11,mSize=0,当mSize增加到>=mKeys.length的时候,如果数组中存在被删除的元素,则会先进行一次gc,提高了空间利用率,避免了数组的扩容。

binarySearch


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
}

二分查找的过程,不同于Arrays#binarySearch,如果没有查找到key,返回的是下边界的取反值。取反后返回负数,put方法继续向下执行,再次取反后就得到了要插入的位置,整个效率还是比较高的。比如:

mKeys={3,4,6,7,8},要插入的key为5,
开始二分查找lo=0,hi=4,经过两轮查找后lo=2,hi=1,那么最后lo=2就是5在mKeys中要插入的下标位置

delete

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

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

delete方法比较简单,如果key已经存在,并且对应的value值没有被标记过删除,就将value值标记为删除,并且将mGarbage置为true。标记的方法就是将value指向一个静态常量Object,remove(int key)也是用delete实现的。这样在put的时候如果该下标的值已被标记删除,直接更新,省去了删除元素和插入元素的过程,还是比较巧妙的。而在gc方法中SparseArray才真正进行了对mKeys和mValues的处理

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) {   //如果该位置的value没有被标记删除
            if (i != o) {   
                keys[o] = keys[i];      //将i处的元素向前移动到o处,使的所有非DELETED的元素都连续排列在数组前面
                values[o] = val;
                values[i] = null;   //释放空间
            }

            o++;    //只有没标记过删除o才会加一,当出现i>o的情况就说明前面已经有元素被标记了删除
        }
    }

    mGarbage = false;   //压缩(垃圾回收)结束
    mSize = o;      //更新压缩后的元素个数
}

通过一个很精干的算法回收了value数组中为DELETED的节点并且更新了数组有效元素个数。再回过头看put中的操作,假设这时的mKeys={0,3,4,5,6,7,8,9,10,11,12},mValues={"a","b","c","d","e","f","g","h","i","j","k"}

spA.remove(4); //spA代表该SparseArray对象
mValues={"a","b",*,"d","e","f","g","h","i","j","k"}    //*代表DELETED标记。

spA.put(15,"s")
i=~ContainerHelpers.binarySearch(mKeys, mSize, key)=12
这时候mSize=11并且mGarbage=true,命中条件,调用gc方法进行数组的压缩

//gc过后
mKeys={0,3,5,6,7,8,9,10,11,12,12}
mValues={"a","b","d","e","f","g","h","i","j","k",null}
mSize=10
i=~ContainerHelpers.binarySearch(mKeys, mSize, key)=11

//插入过后
mKeys={0,3,5,6,7,8,9,10,11,12,15}
mValues={"a","b","d","e","f","g","h","i","j","k","s"}
mSize=11

由于gc后mKeys发生了变化,需要重新索引,如上i在gc前计算等于12,gc后最终的索引是11。这里需要注意,由于gc过程mKeys向前补位后并没有像mValues一样释放空间,比如上述remove(4)后紧接着remove(6),再次gc过后会出现mKeys={0,3,5,7,8,9,10,11,12,11,12}这样的情况,但是这并不违反mKeys是有序的规则,因为i = ~ContainerHelpers.binarySearch(mKeys, mSize, key);入参是mSize,每次二分查找的过程lo=0,hi=size-1,hi位是按有效元素个数计数的,像上面最后两位的11和12不是有效key值,所以二分查找仍然有效。

get

public E get(int key, E valueIfKeyNotFound) {
    int i = ContainerHelpers.binarySearch(mKeys, mSize, key);
    //逻辑很简单,通过二分查找确定key在mKeys数组中的位置,找到了直接返回对应的value,否则返回默认值
    if (i < 0 || mValues[i] == DELETED) {
        return valueIfKeyNotFound;
    } else {
        return (E) mValues[i];
    }
}

indexOfValue、indexOfValueByValue

获取key和value的index,都是判断如果之前有元素被标记删除过,则先gc再查找。不同的是key是二分查找,而value因为是无序的,采用的是遍历数组的做法,没找到返回-1。这里面有个坑是indexOfValue内部是用==,比较的是地址,indexOfValueByValue才是用equals比较两个对象,该方法被标记为私有API。如果确实要通过value确定下标,可以用遍历SparseArray和valueAt的方法获取。

append

public void append(int key, E value) {
    if (mSize != 0 && key <= mKeys[mSize - 1]) {
        put(key, value);
        return;
    }

    if (mGarbage && mSize >= mKeys.length) {
        gc();
    }

    mKeys = GrowingArrayUtils.append(mKeys, mSize, key);
    mValues = GrowingArrayUtils.append(mValues, mSize, value);
    mSize++;
}

两种情况:1、数组为空 2、key大于当前mKeys中的所有元素,这个时候就省去了查找的过程,如果不需要扩容直接将value追加到尾部,需要的话也只要一次拷贝,这就是append针对存储的特殊情况作出的优化。

GrowingArrayUtils

不管是insert还是append,都会先计算是否需要扩容,不同的是insert可能需要两次拷贝,最终调用的都是System#arraycopy

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

扩容的机制是如果当前的size>4,则扩大为原先的2倍。

扩展的其他容器

SparseIntArray、SparseBooleanArray、SparseLongArray都是用来存储特定value的数据容器,没有泛型没有gc,数组类型都是基本数据类型,避免了对这几种value类型的装箱,进一步优化。LongSparseArray是确保key为long类型时使用的,能存储的key数据范围更大。

性能分析

速度比较

首先用HashMap来做一个100000条数据的正向插入:

public void testHashMap(View view) {
    HashMap<Integer, String> map = new HashMap<>();
    long start = System.currentTimeMillis();
    for (int i = 0; i <= 100000; i++) {
        map.put(i, String.valueOf(i));
    }
    long end = System.currentTimeMillis();
    System.out.println("HashMap消耗的时间:" + (end - start) );
}

运行5次取平均值,再把HashMap替换成SparseArray,运行结果如下(时间/ms)

序号 HashMap SparseArray
1 75 66
2 72 73
3 64 80
4 46 63
5 74 68
平均 66.2 70

再来一个反向插入

for (int i = 100000; i >= 0; i--) {
    map.put(i, String.valueOf(i));
}

运行结果就不贴了,HashMap和正向插入差不多,但是SparseArray却慢了10倍都不止。原因就是反向插入的话,SparseArray每次都要进行二分查找,最坏情况下新插入的key都会在数组的最前面,每次数组都要做拷贝,效率就会很低了。

内存比较

和HashMap比较,先看一下两者的内存占用情况的比较,工具是AndroidStudio自带的Profiler,测试代码是循环生成100000个HashMap/SparseArray对象,查看生成前和生成后Java Heap大小的差异

private HashMap<Integer, String> mMap = new HashMap<>();
private SparseArray<String> mSpa = new SparseArray<>();
    
public void test(){
    for (int i = 0; i < 100000; i++) {
        mMap.put(i, String.valueOf(i));
        //mSpa.put(i, String.valueOf(i));
    }
}

使用adb shell dumpsys meminfo <packageName>查看进程内存占用情况,下图是使用HashMap创建数据前内存使用情况(SparseArray和这个差不多):

Applications Memory Usage (in Kilobytes):
Uptime: 854810329 Realtime: 2161500060

** MEMINFO in pid 16304 [com.example.dataset] **
                   Pss  Private  Private  SwapPss     Heap     Heap     Heap
                 Total    Dirty    Clean    Dirty     Size    Alloc     Free
                ------   ------   ------   ------   ------   ------   ------
  Native Heap     5870     5836        0      215    22528    18214     4313
  Dalvik Heap        0        0        0        0     4192     2096     2096
        Stack       80       80        0        0                           
       Ashmem       13        0       12        0                           
      Gfx dev      392      392        0        0                           
    Other dev        2        0        0        0                           
     .so mmap    10058      416     5740       22                           
    .apk mmap      289        0        0        0                           
    .ttf mmap       54        0        0        0                           
    .dex mmap     3656       12     1756        0                           
    .oat mmap      387        0       28        0                           
    .art mmap     7397     7000       20      102                           
   Other mmap       19        4        0        0                           
   EGL mtrack    20144    20144        0        0                           
    GL mtrack     5896     5896        0        0                           
      Unknown     6131     6088        0       54                           
        TOTAL    60781    45868     7556      393    26720    20310     6409
 
 App Summary
                       Pss(KB)
                        ------
           Java Heap:     7020
         Native Heap:     5836
                Code:     7952
               Stack:       80
            Graphics:    26432
       Private Other:     6104
              System:     7357
 
               TOTAL:    60781       TOTAL SWAP PSS:      393

使用HashMap创建数据后内存使用情况:

** MEMINFO in pid 16304 [com.example.dataset] **
                   Pss  Private  Private  SwapPss     Heap     Heap     Heap
                 Total    Dirty    Clean    Dirty     Size    Alloc     Free
                ------   ------   ------   ------   ------   ------   ------
  Native Heap     6414     6380        0      215    22528    18977     3550
  Dalvik Heap        0        0        0        0    19243     9622     9621
        Stack       88       88        0        0                           
       Ashmem       13        0       12        0                           
      Gfx dev      752      752        0        0                           
    Other dev        2        0        0        0                           
     .so mmap    10105      424     5740       22                           
    .apk mmap      289        0        0        0                           
    .ttf mmap       54        0        0        0                           
    .dex mmap     3800       12     1828        0                           
    .oat mmap      398        0       28        0                           
    .art mmap     7592     7112       52       99                           
   Other mmap       19        4        0        0                           
   EGL mtrack    30216    30216        0        0                           
    GL mtrack     6032     6032        0        0                           
      Unknown    17420    17296        0       32                           
        TOTAL    83562    68316     7660      368    41771    28599    13171
 
 App Summary
                       Pss(KB)
                        ------
           Java Heap:     7164
         Native Heap:     6380
                Code:     8032
               Stack:       88
            Graphics:    37000
       Private Other:    17312
              System:     7586
 
               TOTAL:    83562       TOTAL SWAP PSS:      368

使用SparseArray创建数据后内存使用情况:

** MEMINFO in pid 16467 [com.example.dataset] **
                   Pss  Private  Private  SwapPss     Heap     Heap     Heap
                 Total    Dirty    Clean    Dirty     Size    Alloc     Free
                ------   ------   ------   ------   ------   ------   ------
  Native Heap     6326     6288        0      214    22528    19060     3467
  Dalvik Heap        0        0        0        0    10923     5462     5461
        Stack       88       88        0        0                           
       Ashmem       13        0       12        0                           
      Gfx dev      756      756        0        0                           
    Other dev        2        0        0        0                           
     .so mmap    10108      424     5740       22                           
    .apk mmap      299        0       20        0                           
    .ttf mmap       54        0        0        0                           
    .dex mmap     3737       12     1768        0                           
    .oat mmap      398        0       28        0                           
    .art mmap     7592     7112       52       99                           
   Other mmap       19        4        0        0                           
   EGL mtrack    30216    30216        0        0                           
    GL mtrack     6044     6044        0        0                           
      Unknown    10306    10180        0       32                           
        TOTAL    76325    61124     7620      367    33451    24522     8928
 
 App Summary
                       Pss(KB)
                        ------
           Java Heap:     7164
         Native Heap:     6288
                Code:     7992
               Stack:       88
            Graphics:    37016
       Private Other:    10196
              System:     7581
 
               TOTAL:    76325       TOTAL SWAP PSS:      367

以Dalvik Heap- Heap Alloc作为参考标准,多次比较前后使用内存,计算出来SparseArray相比HashMap能减少30%-40%的内存。

总结

  1. 和HashMap相比,SparseArray少了自动装箱的过程(int->Integer),因为HashMap的key存储的是包装类型
  2. 和HashMap相比,更节约内存,结构更简单。因为HashMap采用数组+链表或树存储数据,有一个额外的Entry用来存储hash、key、value和下一个Entry节点(Node),而SparseArray内部只维护了两个一维数组用来存储key和value
  3. SparseArray的核心是二分查找,时间复杂度是O(logN),如果没有查找到,那么取反返回左边界,再次取反后,即为应该插入的数组下标;
  4. 如名字一样,稀疏数组的概念,在这里被用来实现delete,并不是真正的删除,而是做一个标记,如果再次插入元素的时候刚好在该位置上则实现了重用,否则在空间不足的情况下再进行一次压缩,效率和空间都得到了优化。
  5. SparseArray不是被设计做大数据量存储的,当有很多数据存储时其效率低于HashMap。因为它基于二分查找,而HashMap在不冲突的情况下直接通过hash算出下标,时间复杂度为O(1)
  6. 如果对内存要求比较高,并且数据量在千以内,可以使用SparseArray,否则还是HashMap的效率比较高