当面试遇到SparseArray

Android SparseArray ,稀疏数组,是Android 1.0引入的(可能很多同学不知道),Android 系统开发SparseArray的目的是为了节省内存。这篇文章你将到学习如下内容:

1、SparseArray如何存储数据?
2、删除数据方式
3、存储数据的方式以及原理,包括如何保持高效
4、优点、缺点、实用场景

一、如何存储数据?

大家都知道java中的HashMap是采用数组+链表存储(java1.8以后,链表超过8个采用红黑树存储,下次补充HashMap分析),而SparseArray类结构灰常简单,如下:

public class SparseArray implements Cloneable {
      // 当调用delete/Remove的时候,作用后面分析 
        private static final Object DELETED = new Object();
        // 标记是否需要回收对象(压缩数组),true:标识需要进行数据压缩,    false:标识不需要
        private boolean mGarbage = false;
        // 保存key的,注意类型,是int类型,这就是为什么SparseArray只能保存int类    型的Key。注意这是有序数组,具体原因我们后面分析
        private int[] mKeys;
        // 保存valuse对象,长度和mKeys一样长---必须的,否则OOI(OutOfIndex)
        private Object[] mValues;
        // 标识当前存储了多少个数据(表示怀疑人生,通过数组mKeys或者Values不  就直接可以获取了吗?为什么还要单独设置呢,不是脱裤放屁,多次一举吗?)。
        private int mSize; 
}

通过上面我们就分析了SparseArray存储数据的结构了,是不是认为非常简单,感觉自己原理都想清楚了,不就往数组里面添加数据吗?(当你面试时后,你会发现你错了!)。Google出品,比是精品,所以肯定有他的高妙之处。

二、构造函数

SparseArray提供了两个构造函数,如下:
无参构造函数

public SparseArray() {
    /** 调用有参构造函数,这种设计思路,
      * 我们应该在多在开发过程中使用,特别是方法重载时,
      * 一定要做到代码复用,避免相同逻辑写多次
      *(最怕这样的代码,每次修改     必定出bug)   
     */
        this(10); // 默认系统会大小为10的数组
}

指定默认数组大小:

public SparseArray(int initialCapacity) {
        // 如果大小为0
        if (initialCapacity == 0) {
          // 默认0大小数组int[0],疑问1
            mKeys = EmptyArray.INT; 
                // 默认0大小的object数组
            mValues = EmptyArray.OBJECT;
        } else {
           // 通过Api获取一个对象,疑问2
            mValues = ArrayUtils.newUnpaddedObjectArray(initialCapacity);
           // 学问三,这里为什么又这样了呢?
            mKeys = new int[mValues.length];
        }
        mSize = 0;
  }

我们看到有参数的构造函数也很简单,但是如代码注释,有两处有疑问:
疑问1:当initialCapacity为0时,可以直接new int[initialCapacity]来完成,不更省事吗?但是Google的工程师精益求精,还是采用对象复用来减少对象创建和内存占用(一个数组引用的内存空间都要节约出来,佩服
疑问2ArrayUtils .newUnpaddedObjectArray是android 隐藏的一个通过native创建数组的方式。功能如下:
1) ArrayUtils是android hide的一个api,这个类提供了一些高效的处理数组的方法(为什么不开发出来呢?好东西要分享嘛!)
2) 这个类提供了基本类型和Object数组的创建方式,比如:newUnpaddedIntArray等。也提供了泛型创建
3) 内部是通过:(Object[])VMRuntime.getRuntime().newUnpaddedArray(Object.class, minLen);跑虚拟机了?native?
4) 继续跟踪到VMRuntime,其中getRuntime返回了VMRuntime的单例(一个虚拟机对应一个runtime原来饿汉单例模式)。还是如上面猜想,是nativie创建数组:
// 这个方法返回大于等于指定大小的数组,why?考虑数组有扩展

@FastNative // 这是什么?高效native?注释说明:是art虚拟机下快速调用jni的方式
public native Object newUnpaddedArray(Class componentType, int minLength);

说明:该方法 native通过类型和大小实现内存分配,有兴趣的可以继续研究native创建数组。

三、删除数据

SparseArray提供了两类删除数据的方法:delete和remove系列。

3.1 delete系列

delete系列删除数据逻辑:
1、 灰常简单,通过二分法查找,
2、 如果找到,则将对应位置设置DELETED值。这也是Google设计的高效指之处。避免每次删除都对数组做排序等操作而影响性能。这里学问比较大,我们把存储数据分析完,在分析这里。

删除指定key:

public void delete(int key) {
        // 通过二分查找。(坑位1:有个高效的代码)
        int i = ContainerHelpers.binarySearch(mKeys, mSize, key);
        /**  二分查找返回值大于等于0,标识找到了数据,
        * 小于标识未找到。因为是二分法查找,所以不会不会数组越界。
        * 但是部分remove方法不保证
        */
        if (i >= 0) {
            // 如果找到位置不等于DELETED,则设置为DELETED。
            if (mValues[i] != DELETED) {
                mValues[i] = DELETED;
                // 标记需要回收数组,什么时间回收,我们后面分享。
                mGarbage = true;
            }
        }
}

以上就是delete方法的原理,逻辑很简单,通过二分查找法,将找到的位置标记为DELETED。

3.2 remove系列

提供了3个remove重载方法,分别是:
1、 remove(key)删除对应的key,他是直接调用了delete方法,原理一样。
2、 remove(index)删除指定索引数据。代码如下:

public void removeAt(int index) {
       /**  直接判断index位置是否被标记删除。
        *  坑人:为什么不判断数组越界呢?
        *   我们写代码的要求是提供高质量的Api,
        *   不至于别人传个参数,就把我们搞死吧。
        */
        if (mValues[index] != DELETED) {
            mValues[index] = DELETED;
            mGarbage = true;
        }
    }

特别说明:这里不保证数组越界,使用的时候,特别注意(感觉google这里设计的不合理)
3、 public void removeAtRange(int index, int size)从指定位置index开始,删除size个数据。代码如下:

public void removeAtRange(int index, int size) {
       // 如果超出了数组大小,则删除index后所有的数据。
        final int end = Math.min(mSize, index + size);
        for (int i = index; i < end; i++) {
       // 调用了前面的removeAt(index),说明removeAtRange数组越界。
            removeAt(i);
        }
}

以上

删除数据总结:

1、 delete系列:通过二分查找到对于的index(保证数据组不越界),然后将对应valuses设置为DELETED,并标记需要做数据压缩回收。
2、 remove系列:巨坑!!!,没有判断数据越界,使用的时候,特别注意,别线上崩溃了!删除原理也是将对应位置标记为DELETED,并标记需压缩回收。
疑问1:删除的时候,只是标记了删除位置,什么时间真正删除呢?
疑问2:删除的时候,没有修改mSize大小(没执行mSize - -操作)?又是什么时间呢?

四、存储数据

SparseArray提供了两类存储数据的方法,分别是:putappend方法。

4.1 put方法

直接撸代码:

public void put(int key, E value) {
       // 熟悉的二分查找!!前面提到了高效和技巧,一会我们就把他吃掉。
       int i = ContainerHelpers.binarySearch(mKeys, mSize, key);
        // 找到则保存到对应位置
        if (i >= 0) {
            mValues[i] = value;
        } else {
        //疑问4: 未找到,取反?什么鬼?
            i = ~i;
            / ** 如果小于,且标记删除,则直接赋值?那没有并标记删除呢?
               * 前面的if保证。
               */
            if (i < mSize && mValues[i] == DELETED) {
                mKeys[i] = key;
                mValues[i] = value;
                return;
            }

         // 如果需要回收数据,前面说过mSize是保存数据数量,
            // 为什么这里还存在大于的情况?
            if (mGarbage && mSize >= mKeys.length) {
                /**  执行gc,注意这个gc不是System.gc哦!记住这个方法,
                  *   后面会分析,并能回答掉存储数据遗留的两个问题。
                  */
                gc();

          
            // 数据压缩后,index也发生了变化,所以需要重新查找。    
i = ~ContainerHelpers.binarySearch(mKeys, mSize, key);
            }
             /** 插入数据,这里比较简单,如果mSize+1小于mKey的length,
                 * 则采用System.arraycopy实现数组移动并插入,
                 *  反之则创建一个数组,建议将源码作为自己的util,
                  * 后续做数据插入或扩充,比如androidx扩展库中已经有了!。
              */
            mKeys = GrowingArrayUtils.insert(mKeys, mSize, i, key);
            mValues = GrowingArrayUtils.insert(mValues, mSize, i, value);
              // 添加的时候,数据大小做了++,还是没看到什么时间- -。
            mSize++;
        }
    }

代码原理比较简单,可以通过注释能看明白,我们重点分析ContainerHelpers.binarySearchgc()方法,

4.1.1binarySearch(分发查找)

static int binarySearch(int[] array, int size, int value) {
        //记录二分查找左侧开始位置。
        int lo = 0;
        int hi = size - 1;
        while (lo <= hi) {
           // 通过>>>1取中间位置。
            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
            }
        }
        // 未找到,返回了取反的左侧开始位置?返回-1不可以吗?
        return ~lo;  // value not present
}
源码说明:

1、>>>无符号右移,>>有符号右移。可以理解除以2,有时候面试时问:最快的除以2操作是什么?就是右移左移,原因是操作二进制,而计算机只识别二进制,所以快!

疑问4:

为什么找不到时,取反二分查找的index就可以直接插入到数组呢?

举个例子: 假设现在数组存放:2,4,6,8,10

查找关键词 7:

第一次二分: lo =0,hi =4,mid =2(5>>1);array[mid=2] = 6 < 7。所以lo = 2+1.
第二次二分:lo = 3,hi =4,mid =(3+4)>>2=3;array[mid=3] = 8>7,所以hi = 3-1
第三次二分: lo =3 > hi =2,所以退出了查找,最终返回-3这里我们就发现了,在退出查找循环时,肯定没找到,而lo记录的就是要插入的位置。不信?

查找关键词 1:

第一次二分: lo =0,hi =4 ,mid =2(5>>1),array[mid=2] = 6 > 1``。所以hi= 2-1=1 **第二次二分:**mid =(0+1)>>1= 0, lo = 0,hi = 1;arr[mid =0] = 2>1。所以hi= 0(mid)-1=-1; **第三次二分:** ``hi =- 1,lo = 0,退出循环,返回:0:。还是插入关键词1的位置。还不信?

查找关键词 11:

第一次二分: lo =0,hi =4 ,mid =2(5>>1),array[mid=2] = 6 <11。所以lo= mid + 1=3
第二次二分: lo = 3,hi = 4, mid =(3+4)>>1= 3,;arr[mid =3] = 8 <11。所以lo= 3(mid)+1= 4
第三次二分: hi =4,lo = 4, , mid =(4+4)>>1= 4,;arr[mid =4] = 10 <11。所以lo= 4(mid)+1= 5
第四次二分: hi = 4,lo=5,退出循环。是11插入的位置。信了吧!
也就是说二分查找退出时lo永远是找不到元素插入的位置。所以插入时直接取反就可以插入数据了。只是区分了是否删除,如果没有删除元素,则需要采用数组移动的方式来插入。

高妙之处一:

假设二分查找没有返回-lo,而是返回了-1,那是不是,我们需要做两次查找呢?第一次判断是否deleted,第二次找到插入的位置?通过这样算法效率直接提升了2倍!

高妙之处二:

通过记录需要插入的位置,顺便保证了mKey,存储的有顺序。这就是我们在代码中为什么没看到排序相关代码。

4.1.2 gc方法

顾名思义垃圾回收,那么回收谁呢?删除的时候做了2个操作,没做一个操作(设置deleted,设置mGarbage,但是么有mSize--),对的,他就是做这三件事。代码:

private void gc() {
        …
        for (int i = 0; i < n; i++) {
        Object val = values[i];
        // 只有么有被标记删除才进入
        if (val != DELETED) {
            // 如果I !=o,说明遇到了删除数据,
                if (i != o) {
                    /** 做数据的移动,并且把数据设置null(
                     * 不一定所有delete都重置为null了)
                     */
                    keys[o] = keys[i];
                    values[o] = val;
                    values[i] = null;
                }
                o++;
            }
        }

       // 标记已完成数据压缩 
        mGarbage = false;
       // 重置了实际数据大小 
        mSize = o;
}
数组压缩说明:

gc做了数据移动和实际大小(非deleted)计算。但是还有个疑问,数组大小并没有重置,内存不是还在吗?他什么时间清空呢?答案在:GrowingArrayUtils.insert,通过数据大小和数组大小做数组裁剪。

五、获取数据

SparseArray提供了get系列获取数据。原理比较简单,通过二分查找,如果找到且不是删除的,则返回,其他返回null或默认值。

六、总结

6.1、双数组存储

采用SparseArray采用两个数组存储了key和valuse,其中key必须是int类型(其他类型的Sparse是可以支持。比如:SparseLongArray, LongSparseArray注意两个区别,类型在前面的,标识key是long类型,类型在中间的,标识valuse是long类型)

6.2 逻辑删除

SparseArray在删除数据的时候,只是标记,不做物理删除。好处:重复利用位置和多次删除后,在获取数据或者put数据时,一次就可以全部删除,这也是SparseArray的好处,避免数组创建,减少内存,这是主要目的,所以牺牲了性能,haspmap是通过牺牲内存,来提高性能。

6.3 二分查找,并记录插入位置

SparseArray 二分查找,返回插入位置取反,一举3得(确定是否找到、确定插入位置,保证mKey有序),值得我们在工作中借鉴。

6.4 大量数据时性能不好

SparseArray每次插入或者获取数据时,需要做一次二分查找,性能较hashmap直接hash慢,所以大量数据时不建议使用,google官方减少,容纳100个数据时,性能小于50%。

6.5 remove需判断数组越界

通过remove(index)的时候,一定要注意数组越界判断。否则崩溃。

6.6 没有扩展,每次只新增一个位置

SparseArray,没有扩容说法,每次都是新增一个索引位且只在put的时候。

6.7 实用范围

如果相同位置对象删除、插入操作频繁,则非常适合。如果大量删除、查询连续操作,则效果不好。适合数据量较少的情况下。

以上。

你可能感兴趣的:(当面试遇到SparseArray)