前言:
在我们学习一个新技术,新东西之前,我们往往会先考虑到以下几个问题:
- SparseArray是什么?
- SparseArray有什么优点和缺点?
- SparseArray是如何实现这些优点?
- SparseArray为什么会有这些缺点?
通过了解这些问题,我们才能做出合理的判断,来决定是否需要继续学习,以及如何正确的使用。这篇博客,将会照着这个思路,来浅析SparryArray。【SparryArray缩写成 SA】
一,SparseArray是什么 ?
SparseArray是一个类似于HashMap的集合类,其有别于HashMap的主要有以下几点:
- key是基本的数据类型int。【优缺参半,限制了key的类型】
- key,没有拆装箱操作,因为key是基本数据类型。【优点,减少内存占用】
- key与value的映射,没有中间商赚差价。即HashMap里面,为了解决HashMap的冲突问题,额外创建的TreeNode,当出现Hash冲突时,key与value的映射,还需要走一层链表遍历查找。【优点,逻辑更简单,没有创建额外的对象(链表)】
- 集合操作的时间更慢,因为增删查改的时间复杂度,HashMap为O(1)【最理想时为O(1),最不理想时是O(n),JDK8后改用红黑树,时间优化为O(log(n))】,而SparryArray为O(log2n)。O(log2n)标准的折半查找的时间复杂度,而其内部实现,也正是用了折半查找的算法。因此,当集合的量超过1千条时,建议还是用回HashMap。【优缺参半,实现逻辑更简单,速度更慢】
- 数据结构不同,SparseArray,内部是两个数组,即数组+数组的形式【附图1所示】,分别用来存储key和value,它们的下标是一一对应的。而HashMap,则是数组+链表的形式【附图2所示】。【同4】
- Android only, Java标准集合类库里面,没有这个类。其它Java项目,普遍不支持。但不排除有专门导入这个类文件的Java项目。【缺点】
二,为什么选择SparseArray?
通过第一部分,我们了解到,SparseArray是一个集合类,以及它相对于HashMap的几个区别点。通过区别点,我们了解到,当你需要使用HashMap来存储,以int类型为key,且数量较少[低于1k]时,使用SparseArray来代替HashMap,可以避免HashMap的自动拆装箱操作,以及键值对的两个数组的映射关系,一一对应(下标一致),不用像HashMap一样,依赖链表来存储value,计算出key的下标后,还得再匹配一遍链表,才能获取到key对应的value。因此,我们应该只在恰当的业务需求下,使用SparseArray来代表HashMap。否则,得不偿失。
三,SparseArray的实现原理是什么?
SparseArray的内部实现原理,很简单。其内部结构是由两个数组组成,分别用来存储key和value。但是,key和value虽然分别存储在两个不同的数组里面,但他们所在的下标是一致的。 比方说,我往SparseArray, 存入一组数据:300和abc。假设,300被存到key数组的下标为8的地方。那么,abc一定是被存到value数组的下标为8的地方。这样,在查找时,只要求得key或value的下标,就可以知道另一个的下标。
那么,问题来了。这个下标是怎么得出来的?那得从put(int,T)方法讲起,也是时候,放点代码,提提神了。来人,上代码!
public void put(int key, E value) {
int i = ContainerHelpers.binarySearch(mKeys, mSize, key);
if (i >= 0) {
mValues[i] = value;
} else {
i = ~i;
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.
i = ~ContainerHelpers.binarySearch(mKeys, mSize, key);
}
mKeys = GrowingArrayUtils.insert(mKeys, mSize, i, key);
mValues = GrowingArrayUtils.insert(mValues, mSize, i, value);
mSize++;
}
}
put方法,简简单单,只有10几行(空格不计)。我们遵循阅读代码的思路,自上到下,从第一行开始讲。
int i = ContainerHelpers.binarySearch(mKeys, mSize, key);
binarySearch方法的内部实现,我就不赘述了。里面,其实就是一个折半查找的实现。该方法接收三个参数,其中,mKeys是key数组,mSize是key数组的长度,key是要查找的对象。该方法的返回值,是key在key数组中的下标(或是应该存储的下标)。 这就可以回答上面的问题:这个下标是怎么得出来的。
该方法返回的下标,会有两种情况:大于等于0与小于0。
- i >= 0, 代表该key已经存在mKey数组里了。所以,直接用mValue[i] = value,覆盖掉已有的数据或是一个DELETED对象。 如果不明白,这里为什么也是mValue[i],请重新从头开始阅读第三部分。至于,DELETED对象,后面再解释,主要是性能的优化。
- i < 0,代表该key是新的,没有在mKey数组里面。则需要将key存放到下标为 ~i 的mKey数组里面,并且把value也存放到下标为 ~i 的mValue数组里面。 这里,为什么是~i,而不是i呢?因为,binarySearch搜索不到值时,会返回这个key需要存储的位置,但为了区分是新增的,所以,对其取反 ~, 变成负值。在判断完后,需要做实际的存储操作时,再对其进行二次取反操作,得到其原本应该存储的位置。如这行代码所示:
i = ~i;
为了遵循自上而下的思路,我们先不讨论数组元素的移动和数组的扩容,先重点看第7到第11行。
if (i < mSize && mValues[i] == DELETED) {
mKeys[i] = key;
mValues[i] = value;
return;
}
我们先看一下,第一个判断,i < mSize,这个是为了避免下标越界。如果SA容量不足以存放key在i的位置时,就不执行7-11行的代码。第二个判断,mValues[i] == DELETED是什么意思呢? 为了解答这个问题,让我们先来看看SA的remove方法,不用担心陷的太深,这个方法的代码很少:
public void remove(int key) {
delete(key);
}
public void delete(int key) {
int i = ContainerHelpers.binarySearch(mKeys, mSize, key);
if (i >= 0) {
if (mValues[i] != DELETED) {
mValues[i] = DELETED;
mGarbage = true;
}
}
}
可以看到,remove方法,实际上是调用了delete方法。在delete方法里面,会先求得key在mKey数组的下标。如果下标大于等于0,则代表这个key是存在的。接着,如果mValue[i] != DELETED,则将DELETED赋值给mValue[i],并且设置mGarbage为true。
先让我们来看看, DELETED是什么东西。
private static final Object DELETED = new Object();
DELETED 实际上是一个Object的对象,也是一个常量,用来标记该键值对已经删除了。类似于,null。那么,问题来了。
- 这里为什么不直接用null呢? 因为,SA允许存储null,如果直接用null,那就没办法区分,哪些是被删除的,哪些只是value为null的。
- 为什么不直接删除,而只是标记删除呢? 我们知道,mKeys和mValues都是有序数组。我们使用二分查找算法,查找某一个元素,需要的时间复杂度为O(log2n),但是找到并删除这个元素后,数组内部的元素都需要往前移动,时间复杂度为O(n)。所以,总耗时将会是O(log2n+n),每次删除都直接移动数组,而且是两个数组,开销比较大。所以,通过标记的方法,再在合适的时机,统一回收处理。详见SA的gc()方法。
在解析完DELETED的作用后,我们顺带讲讲mGarbage = true;
,这行代码的主要作用是标识,现在有数据需要回收,在下一次有增删查找的时候,会根据这个字段,来判断需不需要回收,因为增删查找时需要更新数据至最新状态。现在,让我们重新回到这几行代码:
if (i < mSize && mValues[i] == DELETED) {
mKeys[i] = key;
mValues[i] = value;
return;
}
上面的这行代码的意思时,如果key要存储的位置i,在数组的现有容量内,即0<i<mSize,避免越界,而mValues[i]==DELETED则用来判断,i所处的位置是不是已经被标记删除了。简而言之就是:i 小于数组的长度,并且i所在的位置的数据value已被标记为删除,则直接将新的value和key覆盖原有的,其实就是数组元素的复用。
解释完7-11行代码,再让我们看看第12-16行代码,这部分代码的功能是:当有数据需要被回收,并且数组的实际长度大于mKeys的长度,意思是说,有一个或多个key对应的value为DELETED,此时,就需要对被标记为DELETED的数据进行回收。回收完后,需要重新计算 i 的位置,因为回收后,下标的位置可能会改变。
if (mGarbage && mSize >= mKeys.length) {
gc();
// Search again because indices may have changed.
i = ~ContainerHelpers.binarySearch(mKeys, mSize, key);
}
这部分的代码,最关键的是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;
}
这段代码很简单,就是当发现某个元素的value为DELETED时,就用其后面的一个元素覆盖过来。从算法可以看得出来,只有当可回收的数量等于或小于不可回收的数量时,可回收的元素才能被充分的回收。回收后,其实数组的长度仍然不会变,只是数组末端的元素的value的值为null。其实,就是将部分或全部被记为可回收的键值对移到数组的末端。并通过mSize的改变,来确保后续的增删查改不会访问到这些“被删除”的元素。直到数组被重新put数据到这些元素的位置,重新使用这些坑位。 也就是说,如果你用SparseArray, 输入500个键值对,再删除其中499个。那么,SparseArray里面的mKeys和mVlues两个数组的实际长度,仍然是500,但是mSize的长度为1。
解释完,回收部分的代码,让我们继续看剩下的三行:
mKeys = GrowingArrayUtils.insert(mKeys, mSize, i, key);
mValues = GrowingArrayUtils.insert(mValues, mSize, i, value);
mSize++;
其中,GrowingArrayUtils.insert(mKeys, mSize, i, key)和GrowingArrayUtils.insert(mValues, mSize, i, value)
,是对数组,做一个插入操作。两行其实是一样的逻辑,只是对不同的数组做操作,我们这里只对mKeys数组做讲解。GrowingArrayUtils.insert(mKeys, mSize, i, key)
的意思是,将key这个元素,插入到长度为mSize的mKeys数组里面的i位置。下面是insert方法的源码实现。
public static <T> T[] insert(T[] array, int currentSize, int index, T element) {
assert currentSize <= array.length;
// 将数组中,自index为起始的部分,整体往后移一位。
// 然后,将要插入的元素element存放在index的位置。
if (currentSize + 1 <= array.length) {
System.arraycopy(array, index, array, index + 1, currentSize - index);
array[index] = element;
return array;
}
// 当数组现有的长度不足时,对数组进行扩容。当currentSize小于等于4时,扩容至8。
// 当currentSize大于4时,扩容为数组原长度的2倍。
@SuppressWarnings("unchecked")
T[] newArray = ArrayUtils.newUnpaddedArray((Class<T>)array.getClass().getComponentType(),
growSize(currentSize));
// 假设array = [1,2,3,5,6,7,8], element = 4.
// 复制旧数组下标为0到index的部分,到新数组.
System.arraycopy(array, 0, newArray, 0, index);
// newArray = [1,2,3,N,N,N,N,N]
// 将要插入的元素插入到新数组中,下标为index的位置.
newArray[index] = element;
// newArray = [1,2,3,4,N,N,N,N]
// 将旧数组自index的剩余部分,添加到新数组自index+1的位置后面的位置去。
System.arraycopy(array, index, newArray, index + 1, array.length - index);
// newArray = [1,2,3,4,5,6,7,8]
return newArray;
}
public static int growSize(int currentSize) {
return currentSize <= 4 ? 8 : currentSize * 2;
}
关于insert方法的实现细节,我也已经贴上源码,加上注释,方便你理解。现在,让我们回到最后的三行代码:
mKeys = GrowingArrayUtils.insert(mKeys, mSize, i, key);
mValues = GrowingArrayUtils.insert(mValues, mSize, i, value);
mSize++;
在执行完第17-18行代码后,就可以成功的往SA里面新增一条键值对。于是,最后,一行代码mSize++
,代表SA的长度加1。
到此为止,我们已经详细的讲解了SparseArray类的增删查改的实现原理,有人会说你没晒get方法,而实际上,get方法里面就简单几行,就是一个put方法的第一行,用二分查找,找到就返回对应的值,找不到就返回0(mKeys)或null(mValues)。在本文的后面,我也附上了自制的结构示意图,来帮助你的理解。
最后,感觉您的阅读和支持。如果还有其它疑问和纠正,欢迎在评论区指出,我会尽快解答和改正,谢谢!
附图:
【图1,SparseArray的内部结构示意图】
【图2,HashMap的内部结构示意图】