Java&Android 基礎知識梳理(10) - SparseArray 原始碼解析

澤毛發表於2018-01-25

一、基本概念

SparseArray的用法和keyint型別,valueObject型別的HashMap相同,和HashMap相比,先簡要介紹一下它的兩點優勢。

記憶體佔用

Java&Android 基礎知識梳理(8) - 容器類 我們已經學習過HashMap的內部實現,它內部是採用陣列的形式儲存每個Entry,並採用鏈地址法來解決Hash衝突的問題。但是採用陣列會遇到擴容的問題,預設情況下當陣列內的元素達到loadFactor的時候,會將其擴大為目前大小的兩倍,那麼就有可能造成空間的浪費。

SparseArray雖然也是採用陣列的方式來儲存Key/Value

private int[] mKeys;
private Object[] mValues;
複製程式碼

但是與HashMap使用普通陣列不同,它對存放ValuemValues陣列進行了優化,其建立方式為:

    public SparseArray(int initialCapacity) {
        if (initialCapacity == 0) {
            mKeys = EmptyArray.INT;
            mValues = EmptyArray.OBJECT;
        } else {
            //預設情況下,建立的 initialCapacity 大小為 10。
            mValues = ArrayUtils.newUnpaddedObjectArray(initialCapacity);
            mKeys = new int[mValues.length];
        }
        mSize = 0;
    }
複製程式碼

其中ArrayUtils.newUnpaddedObjectArray(initialCapacity)用於建立優化後的陣列,該方法實際上是一個Native方法,它解決了當陣列中的元素沒有填滿時造成的空間浪費。

SparseArray 淺析 一文中介紹了SparseArray對於陣列的優化方式,假設有一個9 x 7的陣列,在一般情況下它的儲存模型可以表示如下:

陣列儲存的一般模型

可以看到這種模型下的陣列當中存在大量無用的0值,記憶體利用率很低。而優化後的方案用兩個部分來表示陣列:

  • 第一部分:存放的是陣列的行數、列數、當前陣列中有效元素的個數
  • 第二部分:存放的是所有有效元素的行、列數、元素的值

陣列儲存的優化模型
mKeys則是用普通陣列實現的,通過查詢Key值所在的位置,再根據mValues陣列的屬性找到對應元素的行、列值,從而得到對應的元素值。

避免自動裝箱

對於HashMap來說,當我們採用put(1, Object)這樣的形式來放入一個元素時,會進行自動裝箱,即建立一個Integer物件放入到Entry當中。

SparseArray則不會存在這一問題,因為我們宣告的就是int[]型別的mKeys陣列。

二、原始碼解析

2.1 存放過程

    
    public void put(int key, E value) {
        //通過二分查詢法進行查詢插入元素所在位置。
        int i = ContainerHelpers.binarySearch(mKeys, mSize, key);
        //如果大於0,那麼直接插入。
        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);
            }
            //重新分配陣列,並插入新的 Key,Value。
            mKeys = GrowingArrayUtils.insert(mKeys, mSize, i, key);
            mValues = GrowingArrayUtils.insert(mValues, mSize, i, value);
            mSize++;
        }
    }
複製程式碼

2.2 讀取過程

    public E get(int key, E valueIfKeyNotFound) {
        //通過二分查詢,在 Key 陣列中得到對應 Value 的下標。
        int i = ContainerHelpers.binarySearch(mKeys, mSize, key);
        //取出下標對應的元素。
        if (i < 0 || mValues[i] == DELETED) {
            return valueIfKeyNotFound;
        } else {
            return (E) mValues[i];
        }
    }
複製程式碼

2.3 刪除過程

    public void delete(int key) {
        //二分查詢所在位置。
        int i = ContainerHelpers.binarySearch(mKeys, mSize, key);
        //將該位置的元素置為 DELETED,它是內部預先定義好的一個物件。
        if (i >= 0) {
            if (mValues[i] != DELETED) {
                mValues[i] = DELETED;
                mGarbage = true;
            }
        }
    }
複製程式碼

可以看到,在刪除元素的時候,它是用一個空的Object來標記該位置。在合適的時候(例如上面的put方法),才通過下面的gc()方法對mKeysmValues陣列 重新排列

   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;
    }
複製程式碼

2.4 二分查詢

    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
    }
複製程式碼

這裡用的是~,由於lo>=0,所以當無法查詢到對應元素的時候,返回值~lo一定<0。(~lo=-(lo+1)

這也是我們在2.1中看到,為什麼在i>=0時就可以直接替換的原因,因為只要i>=0,就說明之前已經存在一個Key相同的元素了。

而在返回值小於0時,對它再一次取~,就剛好可以得到 要插入的位置

三、SparseArray 的效率問題

瞭解了SparseArray的原理之後,我們可以分析出有以下幾方面有可能會影響SparseArray插入的效率:

  • 插入的效率。插入的效率其實主要跟Key值插入的先後順序有關,假如Key值是按 遞減順序 插入的,那麼每次我們都是在mValues[0]位置插入元素,這就要求把原來ValuesmKeys陣列中[0, xxx]位置元素複製到[1, xxx+1]的位置,而如果是 遞增插入 的則不會存在該問題,直接擴大陣列陣列的範圍之後再插入即可。
  • 查詢的效率。這點很明顯,因為採用了二分查詢,如果查詢的Key值位於折半處,那麼將會更快地找到對應的元素。

也就是說SparseArray在插入和查詢上,相對於HashMap並不存在明顯的優勢,甚至在某些情況下,效率還要更差一些。

Google之所以推薦我們使用SparseArray來替換HashMap,是因為在移動端我們的資料集往往都是比較小的,而在這種情況下,這兩者效率的差別幾乎可以忽略。但是在記憶體利用率上,由於採用了優化的陣列結構,並且避免了自動裝箱,SparseArray明顯更高,因此更推薦我們使用SparseArray

四、SparseArray 的衍生

SparseArray還有幾個衍生的類,它們的基本思想都是一樣的,即:

  • 用兩個陣列分別儲存keyvalue,通過下標管理對映關係。
  • 採用二分查詢法查詢現在mKeys陣列中對應找到所在元素的下標,再去mValues陣列中取出元素。

我們在平時使用的時候,可以根據實際的應用場景選取相應的集合型別。

Key 型別不同

假如keylong型:

  • LongSparseArraykeylongvalueObject

Value 型別不同

假如keyint,而value為下面三種基本資料型別之一,那麼可以採用以下三種集合來避免value的自動裝箱來進一步優化。

  • SparseLongArraykeyintvaluelong
  • SparseBooleanArraykeyintvalueboolean
  • SparseIntArraykeyintvalueint

Key 和 Value 型別都不同

假如keyvalue都不為基本資料型別,那麼可以採用:

  • ArrayMapkeyObjectvalueObject

相關文章