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