Android集合之SparseArray、ArrayMap詳解

前言

作爲一個Anndroid開發人員來說,我們大多數情況下時使用的Java語言,自然在一些數據的處理時,使用到的集合框架也是Java的,比如HashMapHashSet等,但是你可否知道,Android因爲自身特殊的需求,也爲自己量身定製了“專屬”的集合類,查閱官方文檔,android.util包下,一共捕獲如下幾個類:SparseArray系列(SparseArraySparseBooleanArraySparseIntArraySparseLongArrayLongSparseArray),以及ArrayMapArraySet,我相信即便沒學過,看到這些類名,基本也能猜到一些它們的區別和用法了,下面我們就來好好學一學它們,開始吧!

目錄

1.使用方法
2.感受設計之美
3.優缺點及應用場景

正文

使用方法

按照我的習慣,我覺得不管學什麼,首先需要的就是會用“它”,感受一下它的用法,其次才能再談理論上的東西,下面我們先來學一學怎麼使用。
首先我們看一下SparseArray的使用方法

        //聲明
        SparseArray<String> sparseArray= new SparseArray<>();
        //增加元素,append方式
        sparseArray.append(0, "myValue");
        //增加元素,put方式
        sparseArray.put(1, "myValue");
        //刪除元素,二者等同
        sparseArray.remove(1);
        sparseArray.delete(1);
        //修改元素,put或者append相同的key值即可
        sparseArray.put(1,"newValue");
        sparseArray.append(1,"newValue");
        //查找,遍歷方式1
        for(int i=0;i<sparseArray.size();i++){
            Log.d(TAG,sparseArray.valueAt(i));
        }
        //查找,遍歷方式2
        for(int i=0;i<sparseArray.size();i++){
            int key = sparseArray.keyAt(i);
            Log.d(TAG,sparseArray.get(key));
        }

OK,很正常的使用方法,和hashmap 等數據結構基本一樣。
唯一不同的就是key和value的類型,hashmap的key值和value值爲泛型,但是SparseArray 的key值只能爲int 類型,value值爲Object類型,看到這,你可能會覺得很奇怪,這不是在使用上受到了很大的約束嘛,這樣約束的意義何在呢?

先別急,我們看看剩下的SparseArray 的雙胞胎兄弟姐妹們,LongSparseArraySparseArray 相比,唯一的不同就是key值爲long,所以,既然爲long ,那麼相對SparseArray 來說,它可以存儲的數據元素就比SparseArray 多。

順帶溫習一下,int的範圍是-2^31 到 2^31-1,而long是-2^63 到 2^63-1

然後輪到了SparseBooleanArraySparseIntArraySparseLongArray,這三兄弟相對SparseArray 來說就是value值是確定的,SparseBooleanArray的value固定爲boolean類型,SparseIntArray的value固定爲int類型,SparseLongArray的value固定爲long類型。

注意這裏的value中的值類型boolean、int、long都是小寫的,意味着是基本類型,而不是封裝類型

稍作總結一下,如下

SparseArray          <int, Object>
LongSparseArray      <long, Object>
SparseBooleanArray   <int, boolean>
SparseIntArray       <int, int>
SparseLongArray      <int, long>

ok,然後我們再看看ArrayMapArraySet 的使用

        ArrayMap<String,String> map=new ArrayMap<>();
        //增加
        map.put("xixi","haha");
        //刪除
        map.remove("xixi");
        //修改,put相同的key值即可
        map.put("xixi2","haha");
        map.put("xixi2","haha2");
        //查找,通過key來遍歷
        for(String key:map.keySet()){
            Log.d(TAG,map.get(key));
        }

OK,很正常的用法,和HashMap無異。ArraySet就不用我繼續說了吧,它們的關係就像HashMapHashSet一樣,它和HashSet都是不能存儲相同的元素。

額外說明一下,ArraySet使用要求sdk最小版本爲23,也就是minSdkVersion值必須大於等於23

感受設計之美

由於SparseArray 的三兄弟原理上和SparseArray 一樣,所以我們先來看SparseArray的設計思想
首先,我們來到SparseArray的源碼,其中定義瞭如下一些成員

    private static final Object DELETED = new Object();
    private boolean mGarbage = false;
    //需要說明一下,這裏的mKeys數組是按照key值遞增存儲的,也就是升序,這個在查找會講到爲什麼要保證升序
    private int[] mKeys;
    private Object[] mValues;
    private int mSize;

比較簡單,一共五個字段,DELETED是一個標誌字段,用於判斷是否刪除(這個後面分析到了,自然會就明白了),mGarbage也是一個標誌字段,用於確定當前是否需要垃圾回收,熟悉的mKeys數組用於存儲key,mValues數組用於存儲值,最後一個表示當前SparseArray有幾個元素,好了,接下來看最重要的增加方法,先看append 方法

    public void append(int key, E value) {
        if (mSize != 0 && key <= mKeys[mSize - 1]) {
            //當mSize不爲0並且不大於mKeys數組中的最大值時,因爲mKeys是一個升序數組,最大值即爲mKeys[mSize-1]
            //直接執行put方法,否則繼續向下執行
            put(key, value);
            return;
        }
        //當垃圾回收標誌mGarbage爲true並且當前元素已經佔滿整個數組,執行gc進行空間壓縮
        if (mGarbage && mSize >= mKeys.length) {
            gc();
        }
        //當數組爲空,或者key值大於當前mKeys數組最大值的時候,在數組最後一個位置插入元素。
        mKeys = GrowingArrayUtils.append(mKeys, mSize, key);
        mValues = GrowingArrayUtils.append(mValues, mSize, value);
        //元素加一
        mSize++;
    }

append方法主要處理兩種情況:一:當前數組爲空,二:添加一個key值大於當前所有key值最大值的時候。這兩種情況都有一個共同的特點就是只需要在數組末尾直接插入就好了,不需要去關心插入在哪裏,相當於處理兩種簡單的極端情形,所以我們在使用SparseArray的時候,也要有意識的將這兩種情形下的元素添加,使用append來添加,提高效率。
其實,顧名思義,append,追加的意思嘛,看來取名字還都是有講究的,不是亂取的,嘿嘿。
接下來看put方法

    public void put(int key, E value) {
        //二分查找,這裏有個巨經典的處理,你相信我,要是不經典,我把好吃的給你。
        int i = ContainerHelpers.binarySearch(mKeys, mSize, key);
        //查找到
        if (i >= 0) {
            mValues[i] = value;
        } else {//沒有查找到
            i = ~i;//獲得二分查找結束後,lo的值
            //元素要添加的位置正好==DELETED,直接覆蓋它的值即可。
            if (i < mSize && mValues[i] == DELETED) {
                mKeys[i] = key;
                mValues[i] = value;
                return;
            }
            //垃圾回收,但是空間壓縮後,mValues數組和mKeys數組元素有變化,需要重新計算插入的位置
            if (mGarbage && mSize >= mKeys.length) {
                gc();

                //重新計算插入的位置
                i = ~ContainerHelpers.binarySearch(mKeys, mSize, key);
            }
            //在指定位置i出=處,插入元素
            mKeys = GrowingArrayUtils.insert(mKeys, mSize, i, key);
            mValues = GrowingArrayUtils.insert(mValues, mSize, i, value);
            mSize++;
        }
    }

首先,看上去似乎是一個很普通的方法,沒有任何異樣,但是仔細思考,其實暗藏玄機,我們看到作者先執行了一個二分查找,好,我們來到這個二分查找,如下

    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;  // 找到了
            }
        }
        return ~lo;  // 沒找到
    }

很熟悉,對吧,基本上都會寫,但是請你注意注意了,人家在沒找到的時候,返回了一個值,這個有什麼用,換做我們的話,一般怎麼處理,我想很多人和我一樣,返回一個-1,表示查找失敗不就可以了,是的,我也覺得這樣寫沒毛病。
現在我們假設自己是作者,我們來想下,他爲什麼返回一個lo的取反,這沒有道理嘛,有什麼用呢?

一時想不到,沒關係,我們再回頭看看put方法,第一步拿到二分查找的結果 i 之後,判斷 i 大於0,也就是查找到了,正常向下執行,然後else,也就是 i 值爲負,就是沒查找到,因爲我們在二分查找裏返回的是lo的取反,即便最後沒查找到,lo也是個正數,正數取反爲負數,達到了效果,這是妙用之一,這時你可能會想,我爲啥不直接返回個-1,不也達到了效果嗎?好,返回-1確實達到了效果,但是人家的返回值在完成了用於判斷是否查找成功這個使命之後,還有第二個使命,首先負數取反後,即可再次得到二分查找結束時lo的值,這個lo的值,我現在告訴你,這個位置是不是有點特殊,那特殊在哪呢,沒錯,這個值就是添加元素的插入位置,接下來的你:

(先懵一會 –>> 仔細思考一下 –>> 拿個筆畫一畫 –>> 哎喲嘿,好像還真是這麼回事 –>> 恍然大悟 –>> 發出感嘆:妙啊)

好了,你的流程走完了,這時你應該懂了這個lo值處理的巧妙之處,不懂的就讓我再囉嗦一會

假設我們有個數組 3 4 6 7 8。用二分查找來查找元素5
初始:lo=0 hi=4
第一次循環:mid=(lo+hi)/2=2 2位置對應6 6>5 查找失敗,下一輪循環 lo=0 hi=1
第二次循環:mid=(lo+hi)/2=0 0位置對應3 3<5 查找失敗,下一輪循環 lo=2 hi=1
lo>hi 循環終止
最終 lo=2 即5需要插入的下標位置
神奇不~

所以返回 ~lo的2個作用:一,用於判斷是否查找成功;二,用於記錄待添加元素的插入位置 (這操作真的完美!!)
好了,我們回到put方法,在拿到待插入元素應該插入的位置之後,我們就可以做出一系列操作了,但是你可能也注意到了一個地方,拿到插入的位置之後,它首先判斷需要插入的位置對應mValues 數組的值是不是爲DELETED,如果是的話,直接覆蓋,至於爲什麼這樣做,這個也就是下面我們要看的了,如果累了,可以喝口茶,接着再看,如下,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;
            }
        }
    }

SparseArray也有remove方法,不過remove方法是直接調用的delete方法,所以二者是一樣的效果,remove相當於是一個別名

看到這個delete方法,先讓我感嘆一下,真簡單吶,清爽,直接,乾脆。但是問題來了,就這幾個操作就實現了刪除?,逗我呢,這明明就沒有刪除嘛,okok,不急,我們還是耐心看下它到底做了啥,首先二分查找獲取刪除key的下標,然後如果成功查找,也就是 i>0 時,判斷如果對應key值的value如果不等於DELETED,那麼將值置爲DELETED,然後設置mGarbage爲true,也就是垃圾回收的標誌在這裏被設置爲了true,ok,然後呢,還是一臉懵啊,這也沒有刪除元素啊,只是做了個賦值操作,好,既然它核心就兩步賦值操作,我們不難想到,之前一直見到的一個叫gc() 的方法,我們來到這個方法

    private void gc() {
        //奇怪的作者沒有刪掉註釋,代碼強迫症的我好想給他把這句日誌的註釋刪掉,但是沒有權限,嚶嚶嚶!
        // Log.e("SparseArray", "gc start with " + mSize);

        int n = mSize;//壓縮前數組的容量
        int o = 0;//壓縮後數組的容量,初始爲0
        int[] keys = mKeys;//保存新的key值的數組
        Object[] values = mValues;//保存新的value值的數組

        for (int i = 0; i < n; i++) {
            Object val = values[i];

            if (val != DELETED) {//如果該value值不爲DELETED,也就是沒有被打上“刪除”的標籤
                if (i != o) {//如果前面已經有元素打上“刪除”的標籤,那麼 i 纔會不等於 o
                    //將 i 位置的元素向前移動到 o 處,這樣做最終會讓所有的非DELETED元素連續緊挨在數組前面
                    keys[o] = keys[i];
                    values[o] = val;
                    values[i] = null;//釋放空間
                }

                o++;//新數組元素加一
            }
        }
        //回收完畢,置爲false
        mGarbage = false;
        //回收之後數組的大小
        mSize = o;
        //哼!,這裏的註釋也沒刪
        // Log.e("SparseArray", "gc end with " + mSize);
    }

核心的壓縮方法思想也很簡單,主要就是通過之前設置的DELETED 標籤來判斷是否需要刪除,然後進行數組前移操作,將不需要刪除的元素排在一起,最後設置新的數組大小和設置mGarbage 爲false。

這裏我當初咋一看的時候,有個問題沒想明白,就是對數組進行了賦值操作,更改的也是方法裏聲明的keysvalues 數組,但是我沒有更改 mKeys數組和mValues 數組嘛,這個其實是基礎知識,數組在賦值的時候,是傳遞的引用,對數組來說,就是地址,就是兩個數組指向了內存中同一塊地址,修改任意一個,都會影響另外一個,不信的話,你可以試試下面的例子,看看運行結果
public static void main(String[] args) {
    int[] a=new int[] {5,4,8};
    int[] b=a;
    b[1]=10;
    System.out.println(a[1]);
}

到這爲止,我們明白了SparseArray 的刪除元素的機理,概括說來,就是刪除元素的時候,咱們先不刪除,通過value賦值的方式,給它貼一個標籤,然後我們再gc的時候再根據這個標誌進行壓縮和空間釋放,那麼這樣做的意圖是什麼呢?我爲啥不直接在delete方法裏直接刪除掉,多幹淨爽快?繞那麼大圈子,反正不是刪除?
別急,我們回過頭來看put 方法

            //元素要添加的位置正好==DELETED,直接覆蓋它的值即可。
            if (i < mSize && mValues[i] == DELETED) {
                mKeys[i] = key;
                mValues[i] = value;
                return;
            }

還記得這一段嗎,在添加元素的時候,我們發現如果對應的元素正好被標記了“刪除”,那麼我們直接覆蓋它即可,有沒有一種頓悟的感覺!也就是說,作者這樣做,和我們每次delete都刪除元素相比,可以直接省去刪除元素的操作,要知道這在效率上是一個很可觀的提高,但是並不是每次put元素都是這樣的情況,因爲還有gc() 方法來回收,那麼我們再仔細想想,和每次delete元素相比,設置“刪除”標誌位,然後空間不足的時候,調用gc方法來一次性壓縮空間,是不是效率上又有了一個提高。

僅僅只是一個刪除元素的方法,作者的處理就使用了足足兩個小技巧來提升效率,達到最優:一,刪除設置“標誌位”,來延遲刪除,實現數據位的複用,二,在空間不足時,使用gc() 函數來一次性壓縮空間。從中可見作者的良苦用心,每一個設計都可以堪稱是精髓!。
我們再總結一下SparseArray中的優秀設計

  • 延遲刪除機制(當仁不讓的排第一)
  • 二分查找的返回值處理
  • 利用gc函數一次性壓縮空間,提高效率

好了,到這爲止,相信你對SparseArray的基本工作原理有了一個比較清晰的認識!

中場休息:喝口茶,活動一下,稍後再來

我們接着來看ArrayMap

在開始接下來的內容前,希望大家能對hashmap 的設計原理有一個比較深入的瞭解,因爲學習的過程中,單一的學習某個東西,可能沒有感受,即便是人家的優秀設計,也體會不到巧妙之處,但是一旦有了一個對比,學習起來就會有一種大局觀,這對學習是非常有利的。

簡單補充下hashmap的實現原理,採用數組+鏈表的結構來實現,添加元素時,首先計算key的hash值,然後根據這個hash值,定位到下標,如果衝突的話,則在該下標節點處鏈上一個鏈表,以頭插法添加新元素爲鏈表頭結點,如果鏈表長度超過指定長度,則轉換爲紅黑樹。
hashmap解決衝突的辦法叫做鏈地址法,或者叫拉鍊法。

先來看看ArrayMap 裏面重要的成員變量

    //是否置hashcode值爲唯一,也就是固定值
    final boolean mIdentityHashCode;
    int[] mHashes;//存儲key的hash值
    Object[] mArray;//存儲key值和value值
    int mSize;//集合大小
    //ArrayMap對象轉換爲MapCollections
    MapCollections<K, V> mCollections;

當然我們最需要關心的就是mHashes[]mArray[] 這兩個數組.
接下來,看到增加元素的方法,append方法,如下

    public void append(K key, V value) {
        int index = mSize;
        //獲取key的hash值
        final int hash = key == null ? 0
                : (mIdentityHashCode ? System.identityHashCode(key) : key.hashCode());
        if (index >= mHashes.length) {
            throw new IllegalStateException("Array is full");
        }
        //當前數組不爲空,hash值小於 mHashes[]數組最大的元素時(mHashes數組爲遞增有序數組)
        if (index > 0 && mHashes[index-1] > hash) {
            RuntimeException e = new RuntimeException("here");
            e.fillInStackTrace();
            Log.w(TAG, "New hash " + hash
                    + " is before end of array hash " + mHashes[index-1]
                    + " at index " + index + " key " + key, e);
            put(key, value);//交給put方法處理
            return;
        }
        //當前數組爲空,或者hash值大於 mHashes[]數組最大的元素時
        mSize = index+1;//數組元素數量+1
        mHashes[index] = hash;//在mHashes數組index下標處放入key的hash值
        index <<= 1;//相當於乘2操作,爲什麼要用移位操作呢,因爲移位操作效率高
        mArray[index] = key;//在mArray數組index*2下標處放入key值
        mArray[index+1] = value;//在mArray數組index*2+1下標處放入value值
    }

具體的分析,代碼中已經給的比較明白了,通過這個append方法,我們可以看到ArrayMap裏的兩個核心數組mHashes[]mArray[] 是如何存儲數據的,即,mHashes按照升序(這裏看不出來升序,下面的查找會分析到)存儲所有的key值計算出來的hash值,然後對於指定的key值計算出來的hash值存儲的位置index,對應到mArray數組中,key就是index*2value 就是index*2+1 分析到這裏,其實查找的方法我們也知道了,只需要計算keyhash值,得到index後,對應的keyvaluemArray數組中查找即可。
對應的存儲結構用一張圖來表示如下
這裏寫圖片描述

接下來我們再順着看put方法,但是在看put方法之前,有沒有一種似曾相識的感覺,有沒有覺得append的這個邏輯套路和思想與SparseArray中的append方法的思想很像,二者都是類似的邏輯,先獲取key的hash值,然後和存儲hash值的數組作對比,如果小於最大值,則交由put處理,其它情況數組爲空以及大於最大值,則append自己直接在數組末端添加即可,後續的操作也是一模一樣,只不過根據不同的場景使用了不同的方法而已,可見很多東西是有共性的,如果我們自己需要設計這樣某個容器的添加方法時,也可以採納這種思想。

通過上面的對比分析,我們多多少少能猜到這裏的一些邏輯,我們現在來看看put方法

    @Override
    public V put(K key, V value) {
        final int hash;
        int index;
        if (key == null) {//key爲空時,取hash值爲定值0
            hash = 0;
            index = indexOfNull();
        } else {
            //根據mIdentityHashCode判斷是否使用固定的hash值
            hash = mIdentityHashCode ? System.identityHashCode(key) : key.hashCode();
            index = indexOf(key, hash);//通過hash值計算下標值,最終也是使用的二分查找
        }
        if (index >= 0) {//如果找到了,說明之前已經put過這個key值了,這時直接覆蓋對應value值
            //mHashes數組中的index值,對應的value值在mArray中index*2+1處
            index = (index<<1) + 1;
            final V old = (V)mArray[index];//記錄舊值
            mArray[index] = value;//覆蓋舊值,增加新的value值
            return old;
        }
        //如果index<0,也就是沒有根據key的hash值在mhashes數組中找到對應的下標值
        index = ~index;//哇,經典復現!!!(具體SparseArray裏纔講過)獲取key的hash值要插入的位置
        if (mSize >= mHashes.length) {//如果數組容量已滿
            //獲取擴容的大小,這個就是一個稍顯複雜的三目運算符,應該--問題不大!就不贅述了,嘿嘿
            final int n = mSize >= (BASE_SIZE*2) ? (mSize+(mSize>>1))
                    : (mSize >= BASE_SIZE ? (BASE_SIZE*2) : BASE_SIZE);

            if (DEBUG) Log.d(TAG, "put: grow from " + mHashes.length + " to " + n);
            //接下來這三步,進行了allocArrays一個操作,我們暫且不管,放一放,待會再來收拾它
            final int[] ohashes = mHashes;
            final Object[] oarray = mArray;
            allocArrays(n);
            //將舊的數組賦值給進行allocArrays操作之後的數組
            if (mHashes.length > 0) {
                if (DEBUG) Log.d(TAG, "put: copy 0-" + mSize + " to 0");
                System.arraycopy(ohashes, 0, mHashes, 0, ohashes.length);
                System.arraycopy(oarray, 0, mArray, 0, oarray.length);
            }
            //進行一個叫freeArrays的操作,我們和allocArrays一樣,待會再來收拾它
            freeArrays(ohashes, oarray, mSize);
        }
        //如果待插入的位置小於mSize,則需要將mHashes數組index的位置空出來,相應的後面元素後移
        //同時mArray數組中index*2和index*2+1這兩個位置也要空出來,相應的後面的元素後移
        if (index < mSize) {
            if (DEBUG) Log.d(TAG, "put: move " + index + "-" + (mSize-index)
                    + " to " + (index+1));
            System.arraycopy(mHashes, index, mHashes, index + 1, mSize - index);
            System.arraycopy(mArray, index << 1, mArray, (index + 1) << 1, (mSize - index) << 1);
        }
        //呼!終於可以進行插入操作了
        mHashes[index] = hash;
        mArray[index<<1] = key;
        mArray[(index<<1)+1] = value;
        mSize++;
        return null;
    }

中間有一些沒見過的函數,沒事,我們先讓它囂張一會會,待會再來收拾,整個函數大體流程爲:通過key計算hash值,再使用得到的hash值二分查找(indexOf)在mhashes 數組中的index 下標值,我們追蹤到indexfor方法裏面,最終會發現它也是調用的ContainerHelpers.binarySearch 方法,熟悉不,沒錯,就是我們SparseArray 中使用的二分查找的方法,通過這個方法,我們在沒有查找到元素時,只需要將返回值取反即可獲取待插入元素需要插入的位置,然後接下來數組容量滿的時候進行的一大串操作先不管,然後會執行數組移動的工作,爲相應的元素插入騰出空間,最後插入,結束。

通過整個插入方法的流程,我們知道了ArrayMap裏面數據的存儲結構,以及其中的關係,我們接着往下。

接下來我們就來收拾剛纔遇見的兩個神祕大魔頭函數,但是在收拾之前,我們先來看看ArrayMap 裏與之相關的另外一些成員變量,它們的定義如下

    //多次出現的BASE_SIZE ,固定值爲4,至於爲什麼是4,分析完了,就知道了
    private static final int BASE_SIZE = 4;
    //緩存數組數量的最大值,也就是說最多隻能緩存10個數組
    private static final int CACHE_SIZE = 10;

    //源碼中對這幾個數組作了英文註釋說明,這裏簡要介紹下
    //這四個成員變量完成的功能就是:緩存小數組以避免頻繁的用new創建新的數組,避免消耗內存
    static Object[] mBaseCache;//用來緩存容量爲BASE_SIZE的mHashes數組和mArray數組
    static int mBaseCacheSize;//代表mBaseCache緩存的數組數量,控制緩存數量
    static Object[] mTwiceBaseCache;//用來緩存容量爲BASE_SIZE*2的mHashes數組和mArray數組
    static int mTwiceBaseCacheSize;//代表mTwiceBaseCache緩存的數組數量,控制緩存數量

mBaseCache數組和mTwiceBaseCache數組實現的功能基本一樣,只不過mBaseCache是用來緩存容器存儲的元素數量爲BASE_SIZE的數組,mTwiceBaseCache是用來緩存數量爲BASE_SIZE*2的數組,它們都是一個指向數組對象的鏈表指針,每個數組對象中,數組的第一個元素指向下一個數組,第二個元素指向對應的hash值數組,餘下爲空。

瞭解上面這些相關的成員變量之後,我們首先收拾freeArrays 方法,他的代碼如下

    private static void freeArrays(final int[] hashes, final Object[] array, final int size) {
        //參數hashes數組對應mHashes數組   參數array數組對應mArray數組  size代表容器元素數量
        if (hashes.length == (BASE_SIZE*2)) {
            synchronized (ArrayMap.class) {//防止多線程不同步
                //如果沒有達到緩存數量上限
                if (mTwiceBaseCacheSize < CACHE_SIZE) {
                    array[0] = mTwiceBaseCache;//將array的第一個元素指向緩存數組
                    array[1] = hashes;//將array的第二個元素指向hashes數組
                    for (int i=(size<<1)-1; i>=2; i--) {
                        array[i] = null;//將從下標2起始的位置,全部置空,釋放空間
                    }
                    //將緩存數組指向設置完畢的array數組
                    //也就是將array數組添加到緩存數組的鏈表頭
                    mTwiceBaseCache = array;
                    mTwiceBaseCacheSize++;//緩存完畢,緩存數組的數量加一
                    if (DEBUG) Log.d(TAG, "Storing 2x cache " + array
                            + " now have " + mTwiceBaseCacheSize + " entries");
                }
            }
        } else if (hashes.length == BASE_SIZE) {//完全一樣的邏輯,同上
            synchronized (ArrayMap.class) {
                if (mBaseCacheSize < CACHE_SIZE) {
                    array[0] = mBaseCache;
                    array[1] = hashes;
                    for (int i=(size<<1)-1; i>=2; i--) {
                        array[i] = null;
                    }
                    mBaseCache = array;
                    mBaseCacheSize++;
                    if (DEBUG) Log.d(TAG, "Storing 1x cache " + array
                            + " now have " + mBaseCacheSize + " entries");
                }
            }
        }
    }

經過代碼中的註釋分析,我相信大家心中有數了,但是可能還是有點懵,我們再稍微分析一下,整體結構就是分爲兩種情況,一種是hashes 數組長度爲 BASE_SIZE,一種是hashes 數組長度爲BASE_SIZE*2 就兩種情況,而且這兩種情況的邏輯一模一樣,只不過換了些成員變量而已,對於大小爲其它的情況,這個函數不作任何處理,所以我們這裏就挑BASE_SIZE*2 這種情況來分析,經過代碼中逐行的解釋,我們發現作者就是在構建一個單向鏈表,鏈表中的每個節點(這個節點就是處理過後的mArray數組)有兩個對象(分別爲下標0的值和下標1的值),一個指向下一個節點(相當於next指針),剩下一個就是指向存放hashes值的數組,也就是構建了一個存放mHashes數組的鏈表
這個鏈表的結構如下:
圖源自網絡
那作者這樣做圖個啥呢,用一個鏈表把存放hashes值的數組串起來幹嘛,有啥用呢,好的,我們帶着疑問再來收拾另外一個allocArrays 方法(其實已經說了是緩存,就先假裝不知道嘛),如下

    private void allocArrays(final int size) {
        //mHashes數組容量爲0,直接拋出異常
        //EMPTY_IMMUTABLE_INTS這個值是mHashes數組的初始值,是一個大小爲0的int數組
        //直接寫mHashes.length==0不好嗎,真是一個奇怪的作者,莫非暗藏玄機?暫且留作疑問
        if (mHashes == EMPTY_IMMUTABLE_INTS) {
            throw new UnsupportedOperationException("ArrayMap is immutable");
        }
        //如果大小爲BASE_SIZE*2=8,這時緩存使用mTwiceBaseCache數組來緩存
        if (size == (BASE_SIZE*2)) {
            synchronized (ArrayMap.class) {//防止多線程操作帶來的不同步
                if (mTwiceBaseCache != null) {
                    final Object[] array = mTwiceBaseCache;
                    //將mArray指向mTwiceBaseCache(相當於緩存鏈表的頭指針)
                    //初始化mArray的大小(其實裏面0號位置和1號位置也有數據,只不過沒有意義)
                    mArray = array;
                    //將mTwiceBaseCache的指針指向頭節點數組的0號元素,也就是指向第二個緩存數組
                    mTwiceBaseCache = (Object[])array[0];
                    //獲取頭節點數組array的1號元素指向的hash值數組,並賦給mHashes數組
                    mHashes = (int[])array[1];
                    //將mTwiceBaseCache緩存鏈表的頭節點0號元素和1號元素置空,釋放
                    array[0] = array[1] = null;
                    //緩存數組的數量減一
                    mTwiceBaseCacheSize--;
                    if (DEBUG) Log.d(TAG, "Retrieving 2x cache " + mHashes
                            + " now have " + mTwiceBaseCacheSize + " entries");
                    return;//結束
                }
            }
        } else if (size == BASE_SIZE) {//使用mBaseCache數組來緩存,同上
            synchronized (ArrayMap.class) {
                if (mBaseCache != null) {
                    final Object[] array = mBaseCache;
                    mArray = array;
                    mBaseCache = (Object[])array[0];
                    mHashes = (int[])array[1];
                    array[0] = array[1] = null;
                    mBaseCacheSize--;
                    if (DEBUG) Log.d(TAG, "Retrieving 1x cache " + mHashes
                            + " now have " + mBaseCacheSize + " entries");
                    return;//結束
                }
            }
        }
        //如果size既不等於BASE_SIZE,也不等於BASE_SIZE*2
        //那麼就new新的數組來初始化mHashes和mArray,裏面數據均爲空,相當於沒有使用緩存
        mHashes = new int[size];
        mArray = new Object[size<<1];
    }

出奇的相似有木有,都是分爲兩種情況直接處理,一種BASE_SIZE*2,一種BASE_SIZE,而且從代碼中可以看到這兩者處理的邏輯一模一樣,ok,好辦了,我們同樣的就挑BASE_SIZE*2 這種情況來分析,首先將mTwiceBaseCache 賦給mArray ,這一步就是初始化mArray 數組,然後將mTwiceBaseCache指向下一個節點,然後將頭結點的1號元素,也就是緩存的hashes數組直接賦值給mhashes數組,這一步就是初始化mhashes 數組,初始化完畢後,將使用過的節點置空,緩存數組的數量減一,完畢!

這兩個方法分析完畢之後,我們心中基本有了個大致的概念,freeArrays 方法就是添加緩存的,allocArrays 就是取緩存的,然後對於這裏的緩存,主要是如下幾點需要澄清和注意

  • 只有當容器數量爲BASE_SIZEBASE_SIZE*2 這兩種情況下才緩存
  • 這裏的緩存可能跟平時所理解的緩存不太一樣,這裏緩存主要是實現內存空間的複用緩存,主要是內存空間上的,而不是mHashes數組和mArray數組中數據的值的緩存
  • allocArrays這個操作會清空mHashes數組和mArray數組中的值。所在在這個操作之前,需要保存它們的值,然後在操作結束之後,用System.arraycopy方法再給他們賦值

接下來我們再回頭看到put 方法裏的那段繞過的代碼,現在再來看看這兩個函數到底做了什麼,如下

if (mSize >= mHashes.length) {//數組容量滿的時候
    //計算擴容的大小
    final int n = mSize >= (BASE_SIZE*2) ? (mSize+(mSize>>1))
        : (mSize >= BASE_SIZE ? (BASE_SIZE*2) : BASE_SIZE);

    if (DEBUG) Log.d(TAG, "put: grow from " + mHashes.length + " to " + n);
    //保存mHashes和mArray的數據
    final int[] ohashes = mHashes;
    final Object[] oarray = mArray;

    //這一步的工作就是,初始化mHashes和mArray數組爲擴容後的大小,如何初始化?
    //情形一:當且僅當n爲BASE_SIZE或BASE_SIZE*2時,直接從對應的緩存數組中取,複用內存空間
    //從緩存數組中取,有什麼好處呢?就不用再new數組,以免重新開闢空間,浪費內存
    //情形二:n不爲BASE_SIZE或BASE_SIZE*2時,以new初始化mHashes數組和mArray數組
    allocArrays(n);
    //初始化完畢之後,再分別給對應的mHashes數組和mArray數組賦值
    if (mHashes.length > 0) {
        if (DEBUG) Log.d(TAG, "put: copy 0-" + mSize + " to 0");
            //給初始化後還未賦值的mHashes數組賦值
            System.arraycopy(ohashes, 0, mHashes, 0, ohashes.length);
            System.arraycopy(oarray, 0, mArray, 0, oarray.length);//同上
    }
    //添加緩存
    //注意注意注意,這裏傳遞的size爲mSize,也就是擴容之前的大小,並非擴容之後的大小n
    //參數一和參數二,也是擴容之前mHashes和mArray中的數據
    //如何添加緩存?
    //情形一:mSize爲BASE_SIZE或BASE_SIZE*2時
    //將mHashes數組和mArray數組鏈接到對應的緩存鏈表中存起來
    //情形二:mSize不爲BASE_SIZE或BASE_SIZE*2,什麼鳥事都不做
    freeArrays(ohashes, oarray, mSize);
}

通過註釋,我們可以很清楚的看到具體的每一步做了什麼。

現在我們再來縷一下思路,關於擴容和緩存這裏做個小總結:
當我們在put一個元素的時候

  • (1).如果當前元素已滿,那麼就需要擴容,擴容的大小怎麼確定,就是那個三目運算符,如果<4,則擴容至4,如果>4並且<8,則擴容至8,如果大於8,則按1.5倍增長。
  • (2)得到擴容後的大小之後:
    • 如果擴容後大小爲BASE_SIZE或BASE_SIZE*2,那麼以直接從緩存數組中取的方式來創建數組
    • 如果擴容後的大小不是上述兩種情況,那麼以new的方式來創建數組,開闢新的空間,消耗內存
  • (3)給擴容後的mHashes數組和mArray賦值
  • (4)添加擴容之前的數組爲緩存(當然前提是擴容之前,size爲BASE_SIZE或BASE_SIZE*2)

這個是緩存在put 函數中的使用,機智的你肯定還會猜想,在其它的地方應該也是有使用的,這個我們不急,待會就又見到他啦。

綜上,我們已經分析完了整個緩存的思想,整個緩存最終實現的效果總結成一句話就是:
避免了頻繁的創建數組帶來的內存消耗 (哎,爲了一點內存,google設計人員也真的是煞費苦心啊)

現在我們對緩存有了一個清晰的認識之後,我們再來思考之前源碼中的註釋提出的一個問題:爲什麼BASE_SIZE要設定爲4,從整個緩存的流程中我們可以看到,這個值主要就一個作用:當前數組是否需要緩存。那爲什麼數組長度爲4的時候就要緩存呢?其實你想一下,平常在Android開發中能用到這些容器的場景,是不是都有這些特點:使用頻率高,容納數據量小。所以如果我們不做緩存處理,每次都爲了一點點數據的存儲去開闢一個新的數組空間,那麼必然會浪費掉很多內存。所以4和8這個值是一個相對意義上的“小”值,這個大小基本能最大程度涵蓋我們開發中大多數的應用場景,能最大限度上避免新開內存帶來的內存消耗,當然你可能會說,那剩下的那些存儲量很大的場景呢?這些場景咱們不緩存也罷,畢竟遇到的少,沒有必要去做處理,咱們要考慮大局。

呼!終於收拾完了這兩個大魔頭,歇會,我們接着繼續。

弄清楚了插入方法和緩存思想之後,其實剩下的方法都是些蝦兵蟹將之類的雜兵了,但是雜兵也要清,那就花點時間清一下唄。

查找原理之前已經提到過了,我們現在就當做驗證,看看它的代碼是否如預期,代碼如下

    @Override
    public V get(Object key) {
        final int index = indexOfKey(key);//二分查找獲取下標
        //mHashes數組中index位置對應mArray中index*2+1的位置,使用位移操作以提高效率
        return index >= 0 ? (V)mArray[(index<<1)+1] : null;
    }

這個方法看着非常乾淨利落,如果上面的都弄懂了,這個get方法的代碼理解是完全沒有難度的。

接下來我們再看看稍顯複雜的刪除代碼,remove 方法如下

    @Override
    public V remove(Object key) {
        final int index = indexOfKey(key);//計算下標index
        if (index >= 0) {//如果找到了
            return removeAt(index);//跳轉到removeAt
        }
        //否則返回null
        return null;
    }

我們接着來到removeAt 方法,略長,但是不難,我們靜下心來一點點看

    public V removeAt(int index) {
        final Object old = mArray[(index << 1) + 1];//又是神祕操作,待會再看用來幹嘛的
        if (mSize <= 1) {//如果數組爲空或者只有一個元素,那麼直接將數組置空即可
            // Now empty.
            if (DEBUG) Log.d(TAG, "remove: shrink from " + mHashes.length + " to 0");
            freeArrays(mHashes, mArray, mSize);//又看到這個待會要收拾的方法了
            mHashes = EmptyArray.INT;//置空,INT爲一個大小爲0的int數組
            //奇怪的作者爲什麼要使用這個置空方法,是因爲簡潔嘛?真想問問他
            mArray = EmptyArray.OBJECT;//置空,OBJECT爲一個大小爲0的Object數組
            mSize = 0;//數組大小置0
        } else {
            //當數組長度大於BASE_SIZE*2=8並且當前元素數量小於總容量的1/3時
            if (mHashes.length > (BASE_SIZE*2) && mSize < mHashes.length/3) {
                // 嘿嘿,通過下面的英文註釋,可以看到下面的操作是在幹嘛了
                // Shrunk enough to reduce size of arrays.  We don't allow it to
                // shrink smaller than (BASE_SIZE*2) to avoid flapping between
                // that and BASE_SIZE.
                //翻譯過來就是,收縮足夠的空間來減少數組大小,也就是說這樣是爲了避免連續
                //刪除元素導致大量無用內存,這些內存需要及時釋放,以提高內存效率
                //(哎,再感嘆一次,爲了一點點點點的內存,設計人員真的是煞費苦心啊)
                //但是註釋裏也說了,還要控制數組不能收縮到小於8的值,以避免“抖動”
                //這個抖動我本來想具體解釋下的,但是我感覺這個東西完全可以意會,我就不言傳了
                //所以就留給你們自己感受這個“抖動”吧!哈哈

                //計算新的容量,如果大於8,那麼就收縮爲當前元素數量的1.5倍,否則,就置爲8
                final int n = mSize > (BASE_SIZE*2) ? (mSize + (mSize>>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這個方法了,嘿嘿,現在我們知道他是幹嘛的了
                allocArrays(n);//濃縮成一句話:複用內存以初始化mHashes數組和mArray數組
                //數組元素減一
                mSize--;
                //如果刪除的下標index值大於0,則賦值以恢復mHashes和mArray數組index之前的數據
                if (index > 0) {
                    //將之前保存的數組的值賦值給初始化之後的mHashes和mArray數組,恢復數據
                    //但是注意到第五個參數index,表示這一步只是賦值了刪除元素index之前的數據
                    if (DEBUG) Log.d(TAG, "remove: copy from 0-" + index + " to 0");
                    System.arraycopy(ohashes, 0, mHashes, 0, index);
                    System.arraycopy(oarray, 0, mArray, 0, index << 1);
                }
                //如果index小於容器元素數量,則賦值index之後的數據
                if (index < mSize) {
                    if (DEBUG) Log.d(TAG, "remove: copy from " + (index+1) + "-" + mSize
                            + " to " + index);
                    //對mHashes數組和mArray數組作前移操作,前移index位置以後的元素
                    System.arraycopy(ohashes, index + 1, mHashes, index, mSize - index);
                    //當然對mArray來說,就是前移index*2+2之後的數據元素
                    System.arraycopy(oarray, (index + 1) << 1, mArray, index << 1,
                            (mSize - index) << 1);
                }
            } else {//當前數組容量<8或者大於總容量的1/3時,不需要收縮數組容量
                mSize--;//直接減小1
                if (index < mSize) {
                    if (DEBUG) Log.d(TAG, "remove: move " + (index+1) + "-" + mSize
                            + " to " + index);
                    //前移index之後的元素
                    System.arraycopy(mHashes, index + 1, mHashes, index, mSize - index);
                    //前移index*2+2之後的元素
                    System.arraycopy(mArray, (index + 1) << 1, mArray, index << 1,
                            (mSize - index) << 1);
                }
                //前移後,最後一個元素空出來了,及時置空,以釋放內存
                mArray[mSize << 1] = null;
                mArray[(mSize << 1) + 1] = null;
            }
        }
        //呼!分析完了
        return (V)old;
    }

remove 方法我在註釋中已經分析的非常詳盡了,主要就是一個地方注意的就是空間的收縮問題,根據源碼我們可以明確的看到如果當前數據元素小於數組長度的1/3,並且長度大於8時才收縮,爲什麼是這個界限呢,因爲一個數組裏容納的元素1/3都不到,可見效率是非常低的,所以爲了節約內存要及時收縮,但是爲什麼要保證大於8呢,本來是想留給你們自己意會的,我就還是再囉嗦一下,做一下解釋。

因爲如果我不控制一個界限的話,那麼我在低數據量的操作時(<=8,也就是BASE_SIZE*2),我就有兩種選擇,要麼每次都從緩存中去取,要麼完全不使用緩存,每次都直接前移index之後的元素,首先我們肯定不能使用後者,這樣是完全違背作者設計緩存的思想的,好,那我們現在採用第一種,因爲沒有了>=8的條件,只需要滿足 < mHashes.leng/3即可,但是在數組長度只有8的時候,8/3=2,也就是說我當前數據元素有1個的時候,1<2,所以要去緩存取,但是我只需要再增加一個元素,也就是mSize=2時就不需要去緩存取了,那我再刪除一個元素,又只有一個元素了,這時又要去緩存取,那我不斷的增加刪除元素,是不是就會頻繁的去在這兩種情況之間切換,導致“抖動”。
所以我們的解決辦法就是添加一個下邊界,來避免這種情況的發生。(設計人員,您費心了)

至此,我們弄懂了所有的增刪查方法,綜上:可以看到allocArrays 方法和freeArrays 方法主要出現在需要調整數組容量大小的地方,比如putremove 然後再調整的時候,根據數組的長度,來判斷是否選擇緩存,所以我們不難想到這兩兄弟還會出現的第三個地方:沒錯,就是容量擴充的方法。如下:

    public void ensureCapacity(int minimumCapacity) {
        if (mHashes.length < minimumCapacity) {
            final int[] ohashes = mHashes;
            final Object[] oarray = mArray;
            allocArrays(minimumCapacity);
            if (mSize > 0) {
                System.arraycopy(ohashes, 0, mHashes, 0, mSize);
                System.arraycopy(oarray, 0, mArray, 0, mSize<<1);
            }
            freeArrays(ohashes, oarray, mSize);
        }
    }

弄懂了之前的方法之後,這個方法的理解對我們來說簡直是小菜一碟呀,同樣的套路,同樣的操作!三步走:保存當前值 -> 初始化大小 -> 移動元素 -> 緩存之前的數組 ,不對,好像是四步,嘿嘿!

好啦,我們ArrayMap的原理也弄懂了,做個小小的總結,總結下它的優秀設計

  • 存儲結構,兩個數組存儲,一個存key的hash,一個存key和value(設計是最棒的)
  • 數組緩存設計(那2個大魔頭弄懂就行)
  • 刪除元素時的數組容量及時收縮
  • 刪除元素時的下界控制,防止抖動

最後附上一下官方介紹自己親兒子ArrayMap的視頻鏈接
點我查看Google介紹自己的親兒子ArrayMap

最後,不得不感嘆一下,優秀的思想真的有種讓人如允甘醇一般的暢快之感。

優缺點及應用場景

其實在分析源碼的時候我們就或多或少可以感受到,它們的優點和缺點,這裏我就簡單明瞭,直接亮出它們的優缺點
SparseArray 優點

  • 通過它的三兄弟可以避免存取元素時的裝箱和拆箱(關於裝箱和拆箱帶來的效率問題,可以查看我的這篇文章,Java裝箱和拆箱詳解
  • 頻繁的插入刪除操作效率高(延遲刪除機制保證了效率)
  • 會定期通過gc函數來清理內存,內存利用率高
  • 放棄hash查找,使用二分查找,更輕量

SparseArray缺點

  • 二分查找的時間複雜度O(log n),大數據量的情況下,效率沒有HashMap高
  • key只能是int 或者long

SparseArray應用場景

  • item數量爲 <1000級別的
  • 存取的value爲指定類型的,比如boolean、int、long,可以避免自動裝箱和拆箱問題。

ArrayMap優點

  • 在數據量少時,內存利用率高,及時的空間壓縮機制
  • 迭代效率高,可以使用索引來迭代(keyAt()方法以及valueAt() 方法),相比於HashMap迭代使用迭代器模式,效率要高很多

ArrayMap缺點

  • 存取複雜度高,花費大
  • 二分查找的O(log n )時間複雜度遠遠小於HashMap
  • ArrayMap沒有實現Serializable,不利於在Android中藉助Bundle傳輸。

ArrayMap應用場景

  • item數量爲 <1000 級別的,尤其是在查詢多,插入數據和刪除數據不頻繁的情況
  • Map中包含子Map對象

如果覺得這些優缺點一大堆,還是很迷,我就再精簡一下二者使用的取捨:

(1) 首先二者都是適用於數據量小的情況,但是SparseArray以及他的三兄弟們避免了自動裝箱和拆箱問題,也就是說在特定場景下,比如你存儲的value值全部是int類型,並且key也是int類型,那麼就採用SparseArray,其它情況就採用ArrayMap。
(2) 數據量多的時候當然還是使用HashMap啦

其實我們對比一下它們二者,有很多共性,,也就是它們的出發點都是不變的就是以時間換空間,比如它們都有即時空間壓縮機制,SparseArray 採用的延遲刪除和gc機制來保證無用空間的及時壓縮,ArrayMap 採用的刪除時通過邏輯判斷來處理空間的壓縮,其實說白了,它們的設計者都是力求一種極致充分的內存利用率,這也必然導致了所帶來的問題,例如插入刪除邏輯複雜等,不過這和我們使用起來一點都不違背,我們只需要在合適的場景進行取捨即可。

知道了它們的優缺點,我們纔可以在實際應用場景中選取合適的容器來輔助我們的開發,提升我們程序的性能纔是最關鍵的,畢竟人家Google設計人員煞費苦心爲Android量身打造設計的容器,肯定是有使用的需求和場景的,所以我們在開發中要多對比,有取捨的使用容器。

結語

這應該是我目前寫的最長長長的一篇博客了,花了很長時間,本來準備拆解爲兩篇的,結果寫完SparseArray就停不下來了,直接就接着把ArrayMap也寫了,然後在決定敘述順序的時候也改了很多次,在這個過程中,對源碼的閱讀和理解,我覺得是很有幫助的,而且我覺得最最要的是,在閱讀源碼的時候,總是可以發現並學習到別人的優秀設計,有些設計是真的厲害,切身體會到設計之美的感覺用醍醐灌頂來形容,我覺得一點都不爲過。
最後,本人能力有限,文章中難免可能會出現紕漏,歡迎大家留言指正,我看到了都會及時回覆並更正,關於SparseArray和ArrayMap這兩個容器的其它疑問,也歡迎大家留言討論,好了,本篇到此結束,祝大家天天開心!

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