【源码解析】说说Android中的数据结构(ArrayMap、SparseArray)

1,033 阅读5分钟

引言

ArrayMap,SparseArray都是Google针对Android平台而推出的特有的数据结构。早年Android设备配置比较低,内存比较宝贵,ArrayMap是Android专门针对内存优化而设计的,用于取代Java API中的HashMap数据结构。

​ 本文从源码分析ArrayMap,SparseArray的实现原理,并分析一下使用场景。

SparseArray

​ SparseArray是一种key-value类型存储数据的结构。底层使用了两个数组来维持这种对应关系。

构造方法

public SparseArray() {
    this(10);
}

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

​ 默认构造方法将初始容量定义为10,mKeysmValues两个数组分别存储键和值。

put

public void put(int key, E value) {
  //二分查找确认下标
    int i = ContainerHelpers.binarySearch(mKeys, mSize, key);

    if (i >= 0) {
      //数组中已经存在对应的key 直接覆盖
        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方法的精髓就是binarySearch的实现了,Google在不少地方都用到了这个设计,来看看具体源码:

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
}

binarySearch的实现其实并不复杂,会根据数组的实际大小进行查找,关键在于未找到对应数值时候的return ~lo,这时候已经查找过整个数组,最后停留在lo没找到这个数据,所以这个数据在数组中的下标也确定为lo,然后取反返回,方便外部判断。如果返回的数据为负数,说明数组中没有这个元素,那么直接插入的返回值取反的位置即可。因为是根据size进行查找,因此插入的元素比数组中所有元素还要大的时候,它的下标总会是实际size+1,也就是说put插入始终是按顺序插入,数组中的数据都是连续的。

​ 这里再看一下GrowingArrayUtils.insert方法的实现:

 public static int[] insert(int[] array, int currentSize, int index, int element) {
        assert currentSize <= array.length;
	//容量还够用
        if (currentSize + 1 <= array.length) {
        //需要把大于此元素的数据向右侧拷贝
            System.arraycopy(array, index, array, index + 1, currentSize - index);
            array[index] = element;
            return array;
        }
        //扩容为原来的两倍
        int[] newArray = ArrayUtils.newUnpaddedIntArray(growSize(currentSize));
   	//拷贝index左侧数据
        System.arraycopy(array, 0, newArray, 0, index);
        newArray[index] = element;
        System.arraycopy(array, index, newArray, index + 1, array.length - index);
        return newArray;
    }

insert方法会将元素插入到指定位置,并将数据中大于此数据的元素向右侧拷贝。当容量不够用的时候,会将数组扩容一倍,再整体进行拷贝。

append

public void append(int key, E value) {
//如果key没有key数组里的最大的数值大则调用put方法
    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++;
}

​ 如果能确定要插入的元素的key已有元素的key都要大的时候,则可以调用append方法插入新元素,可以避免进行二分查找而直接插入,提高效率。

remove/delete

remove方法会调用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;
        }
    }
}

remove方法并不会将key移除,而是把对应的位置位置的value设置为DELETED并标记为回收,保持了数组的连续性。

removeAt

removeAt方法在于你确认了数据位置的时候使用,不用进行search操作,直接移除对应下标的元素,因而复杂度会是O(1)。

get

get方法会使用二分查找进行查找,其复杂度为O(log2n),如果未找到对应的元素,则返回用户设定的默认数据。

使用场景

SparseArray使用了基础类型int当key,避免了自动拆装箱引起的性能损耗,类似的封装还有SparseIntArraySparseBooleanArray,原理都一样。采用二分查找进行数据查找,在数据量不是很大的情况下存取性能都还算可以,数据量比较大的时候因为存在数组扩容和数据拷贝等情况,性能会有所损耗。同时存在回收机制,使数组大小不会太大,比较节省内存。在Android中,可以用在RecyclerView列表复用保存控件状态等情况使用。

ArrayMap

ArrayMap的实现原理和思路和SparseArray很相象。SparseArray采用int当key,那我们计算Object的hashcode,得到的也是int类型,因此也可以采用类似的原理。

ArrayMap底层还是采用了两个数组来保存数据,mHashes数用来保存key的hash,mArray数组则用来保存键值对。

构造

public ArrayMap(int capacity, boolean identityHashCode) {
        mIdentityHashCode = identityHashCode;

        // If this is immutable, use the sentinal EMPTY_IMMUTABLE_INTS
        // instance instead of the usual EmptyArray.INT. The reference
        // is checked later to see if the array is allowed to grow.
        if (capacity < 0) {
            mHashes = EMPTY_IMMUTABLE_INTS;
            mArray = EmptyArray.OBJECT;
        } else if (capacity == 0) {
            mHashes = EmptyArray.INT;
            mArray = EmptyArray.OBJECT;
        } else {
            allocArrays(capacity);
        }
        mSize = 0;
    }

ArrayMap默认构造创建的是2个空数组,如果我们自行传入capacity则会调用allocArrays方法创建两个数组,其中mArray会是mHashes两倍的容量。

allocArrays

​ 分配数组大小。ArrayMap存在缓存机制,如果分配的数组大小为BASE_SIZE或者BASE_SIZE的两倍,则会触发这个缓存机制。BASE_SIZE的大小为4。

synchronized (sBaseCacheLock) {
  //之前有缓存数据则尝试获取
    if (mBaseCache != null) {
        final Object[] array = mBaseCache;
        mArray = array;
        try {
            mBaseCache = (Object[]) array[0];
            mHashes = (int[]) array[1];
            if (mHashes != null) {
                array[0] = array[1] = null;
                mBaseCacheSize--;
                if (DEBUG) {
                    Log.d(TAG, "Retrieving 1x cache " + mHashes
                            + " now have " + mBaseCacheSize + " entries");
                }
                return;
            }
        } catch (ClassCastException e) {
        }
        // Whoops!  Someone trampled the array (probably due to not protecting
        // their access with a lock).  Our cache is corrupt; report and give up.
        Slog.wtf(TAG, "Found corrupt ArrayMap cache: [0]=" + array[0]
                + " [1]=" + array[1]);
        mBaseCache = null;
        mBaseCacheSize = 0;
    }
}

put

put方法会先对keyhashcode,然后根据hashcodemHashes数组里进行查找对应的index。

int indexOf(Object key, int hash) {
    final int N = mSize;

    // Important fast case: if nothing is in here, nothing to look for.
    if (N == 0) {
        return ~0;
    }

    int index = binarySearchHashes(mHashes, N, hash);

    if (index < 0) {
        return index;
    }

    // 如果找到了 则用equals判断是否为同一个key 如果是则返回
    if (key.equals(mArray[index<<1])) {
        return index;
    }

    // 尝试在index之后找到对应的key
    int end;
    for (end = index + 1; end < N && mHashes[end] == hash; end++) {
        if (key.equals(mArray[end << 1])) return end;
    }

     // 尝试在index之前找到对应的key
    for (int i = index - 1; i >= 0 && mHashes[i] == hash; i--) {
        if (key.equals(mArray[i << 1])) return i;
    }

    // Key not found -- return negative value indicating where a
    // new entry for this key should go.  We use the end of the
    // hash chain to reduce the number of array entries that will
    // need to be copied when inserting.
    return ~end;
}

indexOf方法还是沿用了之前的思路,先尝试从mHashes数组里匹配,如果hash匹配上了,则从mArray中通过equals方法匹配key。mArray中关联的key和value是连着排列的。找到关联的index,说明存在这个key,则会覆盖掉value。

index = ~index;//取反 获取应该插入的下标
if (osize >= mHashes.length) {
    //hash表容量不够了 触发扩容
    final int n = osize >= (BASE_SIZE*2) ? (osize+(osize>>1))
            : (osize >= BASE_SIZE ? (BASE_SIZE*2) : BASE_SIZE);

    if (DEBUG) Log.d(TAG, "put: grow from " + mHashes.length + " to " + n);

    final int[] ohashes = mHashes;
    final Object[] oarray = mArray;
    allocArrays(n);

    if (CONCURRENT_MODIFICATION_EXCEPTIONS && osize != mSize) {
        throw new ConcurrentModificationException();
    }
    //复制数据到新的hash表
    if (mHashes.length > 0) {
        if (DEBUG) Log.d(TAG, "put: copy 0-" + osize + " to 0");
        System.arraycopy(ohashes, 0, mHashes, 0, ohashes.length);
        System.arraycopy(oarray, 0, mArray, 0, oarray.length);
    }
    //释放并缓存旧数据
    freeArrays(ohashes, oarray, osize);
}

if (index < osize) {
    if (DEBUG) Log.d(TAG, "put: move " + index + "-" + (osize-index)
            + " to " + (index+1));
    //复制key更大的元素到该元素的右边
    System.arraycopy(mHashes, index, mHashes, index + 1, osize - index);
    System.arraycopy(mArray, index << 1, mArray, (index + 1) << 1, (mSize - index) << 1);
}

if (CONCURRENT_MODIFICATION_EXCEPTIONS) {
    if (osize != mSize || index >= mHashes.length) {
        throw new ConcurrentModificationException();
    }
}
mHashes[index] = hash;
mArray[index<<1] = key;
mArray[(index<<1)+1] = value;
mSize++;

​ 如果需要新插入数据,则会判断当前hash数组的大小是否可以容纳更多元素,如果不够放了则会触发扩容,然后将旧数组整体复制到新数组中。同时,会对旧数据进行缓存。最后,如果插入的元素不是最大的,会将数组中更大的部分整体拷贝到其右侧,保持有序性。

append

append方法类似于SparseArray中的实现,也是在你比较确定key的hash值比较大的时候使用。可以免去查找过程直接插入的数组中,提高效率。

remove

remove方法也需要先进行查找,如果hash命中,则会调用removeAt方法从对应的下标移除数据。removeAt中有一系列内存优化的操作。如果数组当前的mHashes数组长度大于8并且实际容量小于mHashes的数组长度的三分之一,则会触发收缩操作。在进行计算后,会重新分配一个合适的数组大小,然后进行数组拷贝。节约内存。

  //...
if (mHashes.length > (BASE_SIZE*2) && mSize < mHashes.length/3) {
  
    final int n = osize > (BASE_SIZE*2) ? (osize + (osize>>1)) : (BASE_SIZE*2);

    if (DEBUG) Log.d(TAG, "remove: shrink from " + mHashes.length + " to " + n);

    final int[] ohashes = mHashes;
    final Object[] oarray = mArray;
    allocArrays(n);
  //...

get

get无外乎是一个查找功能,使用的方法也还是二分查找,因为可能存在hash碰撞,所以最后确定下标还得依据equals方法是否返回一致来确认。

使用场景

ArrayMap的出现,是因为当时Android设备配置较低,所以设计上存在一定的时间换空间。底层采用数组实现,插入数据和删除数据都会存在数据拷贝的情况。因而在数据量比较大的时候,性能是不如HashMap的。因而可以得出结论,当设备配置较低,或者存储数据量不大的时候,采用ArrayMap可能会有更好的性能和更优的内存占用,但是数据量较大的时候,还是老老实实的用HashMap吧。