標籤:android hashmap 記憶體最佳化 稀疏數組
序言
身為一個有代碼潔癖的程式員,在寫Android應用的時候,我總是會去注意
- 代碼規範(Google Android Guideline)
- 能一行搞定的代碼,絕不寫兩行
- 決不讓編譯器(intellij, as)右邊捲軸有黃色
- 不重複自己
當然了,實際開發中,編譯器報的warning有些不太好避免,比如有些null 指標,編譯器從android源碼來看,覺得不會出現null 指標,但是實際情況下….你懂得,部分rom手賤改壞了源碼,結果就crash了,所以我們能做的,就是盡量減少warning。
扯了這麼多,說回主題,有時候在HashMap申明那行,intellij會報warning,說用SparseArray更好,那麼SparseArray究竟是什麼東西,為什麼更好,為什麼提示說更省記憶體呢?
本文以api 21的源碼為準
SparseArray源碼分析
// E對應HashMap的Valuepublic class SparseArray<E> implements Cloneable { // 用來最佳化刪除效能,標記已經刪除的對象 private static final Object DELETED = new Object(); // 用來最佳化刪除效能,標記是否需要記憶體回收 private boolean mGarbage = false; // 儲存索引,整數索引從小到大被映射在該數組 private int[] mKeys; // 儲存物件 private Object[] mValues; // 實際大小 private int mSize;
建構函式:
public SparseArray(int initialCapacity) { if (initialCapacity == 0) { // EmptyArray是一個不需要數組分配的輕量級表示。 mKeys = EmptyArray.INT; mValues = EmptyArray.OBJECT; } else { mValues = ArrayUtils.newUnpaddedObjectArray(initialCapacity); mKeys = new int[mValues.length]; } mSize = 0; }
這裡也充分節約了記憶體。newUnpaddedObjectArray最後指向了VMRuntime的一個native方法
/** * 返回一個至少長minLength的數組,但可能更大。增長的大小來自於避免數組後的任何padding。padding的大小依賴於componentType和記憶體 Clerk的實現 */public native Object newUnpaddedArray(Class<?> componentType, int minLength);
Get方法使用了二分尋找
/** * 獲得指定key的映射對象,或者null如果沒有該映射。 */public E get(int key) { return get(key, null);}@SuppressWarnings("unchecked")public E get(int key, E valueIfKeyNotFound) { // 二分尋找 int i = ContainerHelpers.binarySearch(mKeys, mSize, key); // 如果沒找到或者該value已經被標記刪除 if (i < 0 || mValues[i] == DELETED) { return valueIfKeyNotFound; } else { return (E) mValues[i]; }}
對應的二分尋找實現這裡就不贅述了,大致就是一個對有序數組一分為二比較中間值和目標值的迴圈尋找。
其中比較巧妙的一點是在沒有找到的時候會返回一個~low的index,其有兩個作用:
- 告訴調用者沒有找到
- 調用者可以直接用~result獲得該元素應該插入的位置(-(insertion point) - 1)。
對應的put方法
/** * 添加一個指定key到指定object的映射,如果之前有一個指定key的映射則直接替換掉原映射object。 */public void put(int key, E value) { int i = ContainerHelpers.binarySearch(mKeys, mSize, key); if (i >= 0) { // 該key原來有了,替換掉 mValues[i] = value; } else { // 做一個負運算,獲得應該插入的index i = ~i; // size足夠且原value已經被標記為刪除 if (i < mSize && mValues[i] == DELETED) { mKeys[i] = key; mValues[i] = value; return; } // 走到這裡就說麼i超出了size,或者對應元素是有效 // 被標記為需要記憶體回收且SparseArray大小不小於keys數組長度 if (mGarbage && mSize >= mKeys.length) { // 壓縮空間(這裡源碼有點逗,竟然還有Log.e的注釋留在那裡,看來Android源碼工程師也是要調試的),會壓縮數組,把無效的值都去掉,保證連續有效值 gc(); // 再次尋找因為索引可能改變 i = ~ContainerHelpers.binarySearch(mKeys, mSize, key); } // 插入,如果size不夠則會重新分配數組 mKeys = GrowingArrayUtils.insert(mKeys, mSize, i, key); mValues = GrowingArrayUtils.insert(mValues, mSize, i, value); // 實際大小加1 mSize++; }}
在看看remove(del)方法
/** * 如果有的話,刪除對應key的映射 */public void delete(int key) { // 又是二分尋找 int i = ContainerHelpers.binarySearch(mKeys, mSize, key); // 存在則標記對應value為DELETED,且置位mGarbage if (i >= 0) { if (mValues[i] != DELETED) { mValues[i] = DELETED; mGarbage = true; } }}/** * {@link #delete(int)}的別名. */public void remove(int key) { delete(key);}/** * 刪除指定索引的映射(這個有點暴力啊,用的應該比較少吧,直接指定位置了) */public void removeAt(int index) { // 就是delete裡面那段方法,如此說來為什麼delete不調用removeAt而要重複這段代碼呢 if (mValues[index] != DELETED) { mValues[index] = DELETED; mGarbage = true; }}
大致看了crud的這幾個方法,應該有個一個初步瞭解了吧,SparseArray因為key是一個純粹的整數數組,避免了auto-boxing key和額外的資料結構去映射K/V關係,從而節省了記憶體。
當然了,這裡也有一個tradeoff,由於key數組需要有序,所以每次都會相對簡單的寫操作更花費時間,要去二分尋找,要在數組刪除/插入元素。所以對應地為了最佳化,因為了mGarbage和DELETED,將可能的多次gc合并為一次,延遲到必須的時候執行。
源碼注釋裡也提到了,該類不適合用於大資料量,上百個entry的時候差別在50%以內,尚可以接受,畢竟在移動端我們缺的,往往是記憶體,而不是CPU。
HashMap源碼分析
相較SparseArray,HashMap的實現就複雜一點了,因為其支援多種key(甚至null),還要實現Iterator。這裡我們主要看看基本操作實現。
/** * transient關鍵字表示不需要序列化。 * Hash表,null key的在下面。 * HashMapEntry定義了K/V映射,hash值,以及next元素 */transient HashMapEntry<K, V>[] table;/** * 該entry代表null key, 或者不存在該映射. */transient HashMapEntry<K, V> entryForNullKey;/** * hash map的映射數量. */transient int size;/** * 結構修改的時候自增,來做(最大努力的)並發修改檢測 */transient int modCount;/** * hash表會在大小超過該闕值的時候做rehash。通常該值為0.75 * capacity, 除非容量為0,即上面的EMPTY_TABLE聲明。 */private transient int threshold;public HashMap(int capacity) { if (capacity < 0) { throw new IllegalArgumentException("Capacity: " + capacity); } if (capacity == 0) { // 和SparseArray類似,所有空的執行個體共用同一個EMPTY表示 HashMapEntry<K, V>[] tab = (HashMapEntry<K, V>[]) EMPTY_TABLE; table = tab; // 強制先put()來替換EMPTY_TABLE threshold = -1; return; } if (capacity < MINIMUM_CAPACITY) { capacity = MINIMUM_CAPACITY; } else if (capacity > MAXIMUM_CAPACITY) { capacity = MAXIMUM_CAPACITY; } else { // 難道是為了記憶體padding capacity = Collections.roundUpToPowerOfTwo(capacity); } makeTable(capacity);}/** * 根據給定容量分配一個hash表,並設定對應闕值。 * @param newCapacity 必須是2的次數 */private HashMapEntry<K, V>[] makeTable(int newCapacity) {// 這麼做難道是為了同步性考慮HashMapEntry<K, V>[] newTable = (HashMapEntry<K, V>[]) new HashMapEntry[newCapacity]; table = newTable; threshold = (newCapacity >> 1) + (newCapacity >> 2); // 3/4 capacity return newTable;}
然後是插入的代碼
/** * 映射指定key到指定value,如果有原對應mapping返回其value,否則返回null。 */@Override public V put(K key, V value) { if (key == null) { // key為空白直接去putValueForNullKey方法 return putValueForNullKey(value); } // 計算該key的hash值,根據key本身的hashCode做二次hash int hash = Collections.secondaryHash(key); HashMapEntry<K, V>[] tab = table; int index = hash & (tab.length - 1); for (HashMapEntry<K, V> e = tab[index]; e != null; e = e.next) { // 原來有對應entry了,直接修改value,然後返回oldValue if (e.hash == hash && key.equals(e.key)) { preModify(e); V oldValue = e.value; e.value = value; return oldValue; } } // 沒有現存的entry,建立一個 modCount++; // size超出闕值,double容量,重新計算index if (size++ > threshold) { tab = doubleCapacity(); index = hash & (tab.length - 1); } // 在table的index位置插入一個新的HashMapEntry,其next是自己 addNewEntry(key, value, hash, index); return null;}private V putValueForNullKey(V value) { HashMapEntry<K, V> entry = entryForNullKey; // 和上面類似的邏輯,如果存在則替換,否則建立該HashMapEntry if (entry == null) { addNewEntryForNullKey(value); size++; modCount++; return null; } else { preModify(entry); V oldValue = entry.value; entry.value = value; return oldValue; }}
姑且說到這裡,大致可以清楚,HashMap是一個以hash為中心的實現,在size上,也只有double的邏輯,而沒有remove後是否縮小capacity的邏輯。時間複雜度O(1)的代價就是耗費大量記憶體來儲存資料。
比較
HashMap -> 快,浪費記憶體
SparseArray -> 會有效能損耗, 節約記憶體
我們做一個簡單的效能測試,不帶參數初始化HashMap和SparseArray,同樣儲存Integer->String的映射
用:符號來分割HashMap和SparseArray的耗時(毫秒)
| 實驗 |
第一次 |
第二次 |
第三次 |
第四次 |
| 依次put 10000個數 |
13:7 |
12:6 |
76:4 |
14:6 |
| 隨機put 10000個數 |
16:83 |
18:85 |
15:76 |
17:78 |
4次測試結果相近(有一個噪音資料76/4有待研究),HashMap在有序的key時候,更耗時,而在無需key的時候則是SparseArray更耗時,而HashMap則沒有太大的效能差別。
再對隨機put後的結果做了10000次get後,獲得了7:3,7:3,8:3的結果,可見get操作上,SparseArray效能更好,但即便在10000個entry的時候,差別其實也並不大。
在記憶體上,看到SparseArray在Allocation Tracker中為32,而HashMap則達到了69632這個可怕的數字。。。。。。
結論
在key為整數的情況下,考慮到移動端往往K/V對不會太大,所以用SparseArray能更節省記憶體,且效能損耗在可接受的範圍。
從實驗結果看,SparseArray相較於HashMap,大大節省了記憶體,對移動端實在是不二的選擇。
著作權聲明:本文為博主原創文章,未經博主允許不得轉載。
intellij老是警告的SparseArray是什麼 - HashMap的替代者