浅析SparseArray的源码实现

前言:

在我们学习一个新技术,新东西之前,我们往往会先考虑到以下几个问题:

  1. SparseArray是什么?
  2. SparseArray有什么优点和缺点?
  3. SparseArray是如何实现这些优点?
  4. SparseArray为什么会有这些缺点?

通过了解这些问题,我们才能做出合理的判断,来决定是否需要继续学习,以及如何正确的使用。这篇博客,将会照着这个思路,来浅析SparryArray。【SparryArray缩写成 SA】

一,SparseArray是什么 ?

SparseArray是一个类似于HashMap的集合类,其有别于HashMap的主要有以下几点:

  1. key是基本的数据类型int。【优缺参半,限制了key的类型
  2. key,没有拆装箱操作,因为key是基本数据类型。【优点,减少内存占用
  3. key与value的映射,没有中间商赚差价。即HashMap里面,为了解决HashMap的冲突问题,额外创建的TreeNode,当出现Hash冲突时,key与value的映射,还需要走一层链表遍历查找。【优点,逻辑更简单,没有创建额外的对象(链表)
  4. 集合操作的时间更慢,因为增删查改的时间复杂度,HashMap为O(1),而SparryArray为O(log2n)。O(log2n)标准的折半查找的时间复杂度,而其内部实现,也正是用了折半查找的算法。因此,当集合的量超过1千条时,建议还是用回HashMap。【优缺参半,实现逻辑更简单,速度更慢
  5. 数据结构不同,SparseArray,内部是两个数组,即数组+数组的形式【附图1所示】,分别用来存储key和value,它们的下标是一一对应的。而HashMap,则是数组+链表的形式【附图2所示】。【同4
  6. 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。那么,问题来了。

  1. 这里为什么不直接用null呢? 因为,SA允许存储null,如果直接用null,那就没办法区分,哪些是被删除的,哪些只是value为null的。
  2. 为什么不直接删除,而只是标记删除呢? 我们知道,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
【图1,SparseArray的内部结构示意图】


在这里插入图片描述
【图2,HashMap的内部结构示意图】


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章