淺析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的內部結構示意圖】


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