HashMap的優化與實踐

HashMap的優化與實踐

本文是基於作者在github上的Android 問題交流討論壇提問而產生的一篇文章,也是自己早打算開坑的一篇文章。文章首先介紹了hashMap的一些基本知識,然後介紹了它在JDK8下的實現原理,最後着重介紹了幾個面試中處理大數據的方法,文章比較長,我也寫了好久,希望各位能夠讀完並發表意見。

Android 題交流討論壇是開源達人 Trinea 在gitHub上組建的一個討論組織,那裏的提問與回答都非常靠譜。

HashMap的複雜度

如圖是ArrayList/LinkedList/HashMap三個數據結構的複雜度對比,可以看出HashMap整體上性能都非常不錯,但是不穩定,爲O(N/Buckets),N就是以數組中沒有發生碰撞的元素。

  獲取 查找 添加/刪除 空間  
ArrayList O(1) O(1) O(N) O(N)
LinkedList O(N) O(N) O(1) O(N)
HashMap O(N/Bucket_size) O(N/Bucket_size) O(N/Bucket_size) O(N)

注:發生碰撞實際上是非常稀少的,所以N/Bucket_size約等於1

HashMap是對Array與Link的折衷處理,Array與Link可以說是兩個速度方向的極端,Array注重於數據的獲取,而處理修改(添加/刪除)的效率非常低;Link由於是每個對象都保持着下一個對象的指針,查找某個數據需要遍歷之前所有的數據,所以效率比較低,而在修改操作中比較快。

複雜度是如何考察的?

對於數據結構,在時間上我們需要考察Acessing ,Search, Deletion/Insertion的平均與最差的複雜度。在空間上,我們要考慮維護這個數據結構所佔用的內存空間。

常見的數據結構與排序的複雜度都在這裏

HashMap的實現

本文以JDK8的API實現進行分析

1. 什麼是hash,什麼是碰撞?

  • Hash:是一種信息摘要算法,它還叫做哈希,或者散列。我們平時使用的MD5,SHA1,SSL中的公私鑰驗證都屬於Hash算法,通過輸入key進行Hash計算,就可以獲取key的HashCode(),比如我們通過校驗MD5來驗證文件的完整性。
  • 碰撞:好的Hash算法可以出計算幾乎出獨一無二的HashCode,如果出現了重複的hashCode,就稱作碰撞;
<code>&gt;就算是MD5這樣優秀的算法也會發生碰撞,即兩個不同的key也有可能生成相同的MD5。</code>

2. HashMap中是如何實現寫入與讀取的?

HashMap實現了Map接口,保存着K-V這樣的集合。我們以put操作爲例

2.1. 對key進行Hash計算

在JDK8中,由於使用了紅黑樹來處理大的鏈表開銷,所以hash這邊可以更加省力了,只用計算hashCode並移動到低位就可以了

1
2
3
4
5
static final int hash(Object key) {
    int h;
    //計算hashCode,並無符號移動到低位
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

下面給出幾個常用的哈希碼的算法。

  1. Object類的hashCode.返回對象的內存地址經過處理後的結構,由於每個對象的內存地址都不一樣,所以哈希碼也不一樣。這個是native方法,這個取決於JVM的設計,一般是某種地址的偏移。
  2. String類的hashCode.根據String類包含的字符串的內容,根據一種特殊算法返回哈希碼,只要字符串的內容相同,返回的哈希碼也相同。
  3. Integer等包裝類,返回的哈希碼就是Integer對象裏所包含的那個整數的數值,例如Integer i1=new Integer(100),i1.hashCode的值就是100 。由此可見,2個一樣大小的Integer對象,返回的哈希碼也一樣。
  4. int,char這樣的基礎類,它們不需要hashCode,如果需要存儲時,將進行自動裝箱操作,計算方法同上。

2.2. 獲取到當前的位置

計算了Hash,我們現在要把它插入數組中了

1
i = (tab.length - 1) & hash;

通過位運算,確定了當前的位置,因爲HashMap數組的大小總是2^n,所以實際的運算就是 (0xfff…ff) & hash ,這裏的tab.length-1相當於一個mask,濾掉了大於當前長度位的hash,使每個i都能插入到數組中。

2.3. 生成包裝類

這個對象是一個包裝類,Node<K,V>,內部有key,value,hash還有next,可以看出來它是一個鏈表。

1
2
3
4
5
6
7
static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        V value;
        Node<K,V> next;
        //getter and setter .etc.
}

2.4. 插入包裝類到數組

(1). 如果輸入當前的位置是空的,就插進去,如圖,左爲插入前,右爲插入後

1
2
3
4
5
6
7
8
9
10
11
0           0
|           |
1 -> null   1 - > null
|           |
2 -> null   2 - > null
|           |
..-> null   ..- > null
|           |
i -> null   i - > new node
|           |
n -> null   n - > null

(2). 如果當前位置已經有了node,且它們發生了碰撞,則新的放到前面,舊的放到後面,這叫做鏈地址法處理衝突。

1
2
3
4
5
6
7
8
9
10
11
0           0
|           |
1 -> null   1 - > null
|           |
2 -> null   2 - > null
|           |
..-> null   ..- > null
|           |
i -> old    i - > new - > old
|           |
n -> null   n - > null

我們可以發現,失敗的hashCode算法會導致HashMap的性能下降爲鏈表,所以想要避免發生碰撞,就要提高hashCode結果的均勻性。當然,在JDK8中,採用了紅黑二叉樹進行了處理,這個我們後面詳細介紹。

什麼是Hash攻擊?

通過請求大量key不同,但是hashCode相同的數據,讓HashMap不斷髮生碰撞,硬生生的變成了SingleLinkedList

1
2
3
4
5
6
7
8
9
0
|
1 -> a ->b -> c -> d(撞!撞!撞!複雜度由O(1)變成了O(N))
|
2 -> null(本應該均勻分佈,這裏卻是空的)
|
3 -> null
|
4 -> null

這樣put/get性能就從O(1)變成了O(N),CPU負載呈直線上升,形成了放大版DDOS的效果,這種方式就叫做hash攻擊。在Java8中通過使用TreeMap,提升了處理性能,可以一定程度的防禦Hash攻擊。

3. 擴容

如果當表中的75%已經被佔用,即視爲需要擴容了

1
(threshold = capacity * load factor ) < size

它主要有兩個步驟:

1. 容量加倍

左移1位,就是擴大了兩倍,用位運算取代了乘法運算

1
2
newCap = oldCap << 1;
newThr = oldThr << 1;

2. 遍歷計算Hash

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
for (int j = 0; j < oldCap; ++j) {
                Node<K,V> e;
                //如果發現當前有Bucket
                if ((e = oldTab[j]) != null) {
                    oldTab[j] = null;
                    //如果這裏沒有碰撞
                    if (e.next == null)
                        //重新計算Hash,分配位置
                        newTab[e.hash & (newCap - 1)] = e;
                    //這個見下面的新特性介紹,如果是樹,就填入樹
                    else if (e instanceof TreeNode)
                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                    //如果是鏈表,就保留順序....目前就看懂這點
                    else { // preserve order
                        Node<K,V> loHead = null, loTail = null;
                        Node<K,V> hiHead = null, hiTail = null;
                        Node<K,V> next;
                        do {
                            next = e.next;
                            if ((e.hash & oldCap) == 0) {
                                if (loTail == null)
                                    loHead = e;
                                else
                                    loTail.next = e;
                                loTail = e;
                            }
                            else {
                                if (hiTail == null)
                                    hiHead = e;
                                else
                                    hiTail.next = e;
                                hiTail = e;
                            }
                        } while ((e = next) != null);
                        if (loTail != null) {
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        if (hiTail != null) {
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }

由此可以看出擴容需要遍歷並重新賦值,成本非常高,所以選擇一個好的初始容量非常重要。

如何提升性能?

  1. 解決擴容損失:如果知道大致需要的容量,把初始容量設置好以解決擴容損失;
    比如我現在有1000個數據,需要 1000/0.75 = 1333 ,又 1024 < 1333 < 2048,所以最好使用2048作爲初始容量。
  2. 解決碰撞損失:使用高效的HashCode與loadFactor,這個…由於JDK8的高性能出現,這兒問題也不大了。
  3. 解決數據結構選擇的錯誤:在大型的數據與搜索中考慮使用別的結構比如TreeMap,這個就是積累了,一般需要key排序時,建議使用TreeMap;

HashMap與HashTable的主要區別

在很多的Java基礎書上都已經說過了,他們的主要區別其實就是Table加了線程同步保護

  • HashTable線程更加安全,代價就是因爲它粗暴的添加了同步鎖,所以會有性能損失。
  • 其實有更好的concurrentHashMap可以替代HashTable

JDK8中HashMap的新特性

如果某個桶中的鏈表記錄過大的話(當前是TREEIFY_THRESHOLD = 8),就會把這個鏈動態變成紅黑二叉樹,使查詢最差複雜度由O(N)變成了O(logN)。

1
2
3
4
5
6
7
8
9
10
11
12
13
//e 爲臨時變量,p爲當前的鏈
for (int binCount = 0; ; ++binCount) {
    if ((e = p.next) == null) {
        p.next = newNode(hash, key, value, null);
        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
            treeifyBin(tab, hash);
        break;
    }
    if (e.hash == hash &&
        ((k = e.key) == key || (key != null && key.equals(k))))
        break;
    p = e;
}

JDK8在其他地方也有提升,更多的可以看這裏

HashMap的裝箱空間效率

在筆試題中,一般“內存”是完全能夠使用的,而在現實中HashMap空間效率之低,你卻不一定知道。

比如定義了一個 HashMap<Long,Long>

1. Long的裝箱

在對象頭中,加入額外的指針8Bype,加入8Bype的MarkWord(hashcode與鎖信息),這裏就是16Byte

也就是說,long在裝箱後,效率爲 8/24 = 1/3

2. Map.Entry的裝箱

字段空間: hash(4) + padding(4) + next(8) = 16Byte,這裏的padding是字節對齊

對象頭: 16Byte,指針+MarkWord

也就是說,維護一個Entry需要32Byte的空間

1
2
3
4
5
6
7
static class Node<K,V> implements Map.Entry<K,V>
{   
    final int hash;   
    final K key;   
    V value;   
    Node<K,V> next;
}

3. 總效率

8/(24 + 32) = 1/7

計算結果可能有差異,本文主要在強調裝箱過程造成的損失

在Android中使用SparseArray代替HashMap

官方推薦使用SparseArray([spɑ:s] [ə'reɪ],稀疏的數組)或者LongSparseArray代替HashMap,目前國內好像涉及的比較少,容我先粘貼一段

Note that this container keeps its mappings in an array data structure, using a binary search to find keys. The implementation is not intended to be appropriate for data structures that may contain large numbers of items. It is generally slower than a traditional HashMap, since lookups require a binary search and adds and removes require inserting and deleting entries in the array.

For containers holding up to hundreds of items, the performance difference is not significant, less than 50%.
To help with performance, the container includes an optimization when removing keys: instead of compacting its array immediately, it leaves the removed entry marked as deleted. The entry can then be re-used for the same key, or compacted later in a single garbage collection step of all removed entries. This garbage collection will need to be performed at any time the array needs to be grown or the the map size or entry values are retrieved.

總的來說就是:

  • SparseArray使用基本類型(Primitive)中的int作爲Key,不需要Pair<K,V>或者Entry<K,V>這樣的包裝類,節約了內存;
  • SpareAraay維護的是一個排序好的數組,使用二分查找數據,即O(log(N)),每次插入數據都要進行排序,同樣耗時O(N);而HashMap使用hashCode來加入/查找/刪除數據,即O(N/buckets_size);
  • 總的來說,就是SparseArray針對Android嵌入式設備進行了優化,犧牲了微小的時間性能,換取了更大的內存優化;同時它還有別的優化,比如對刪除操作做了優化;
  • 如果你的數據非常少(實際上也是如此),那麼使用SpareArray也是不錯的;

在筆試中的使用

1. 查重與分組問題

某公司正在做一個尋找走失兒童的公益項目,現在有一個函數,可以輸入兩個圖片,並返回這個兒童是否重複。請你設計一個系統,幫助他們尋找兒童。

  1. 網友可以同時上傳一批圖片
  2. 系統能夠把所有圖片分類並歸爲一組
  3. 網友上傳圖片後,網頁要儘快返回該照片所在的組。

A:假設你現在有一個機器,請寫出你的數據結構與處理流程,設計的思路。
B:如果你有多臺機器,如果縮短請求的時間?

A:我們可以把它分解爲兩個部分,一個是數據結構一個是上傳流程。

(1). 對於數據結構來說,一個是對兒童信息進行包裝,另一個是實現兒童信息的高效查找。對於兒童信息包裝類來說,除了加入兒童的圖片,姓名,生日等基本信息外,特別要注意重寫equals與hashCode,這個equals就是題目所說的比較函數。對於查找的實現來說,首先我們建立一個HashSet,用於存儲兒童信息。網友上傳後,服務器通過對圖像計算出特徵Hash值,並查Hash表,如果HashCode相同,則返回所在的組;如果不相同,就加入hash表中。(2). 對於多圖上傳問題,使用生產者-消費者阻塞隊列就可以實現儘快的依次返回照片所在的組。

B:

  1. 考慮將Hash計算後取餘,分配給其他計算機進行存儲與查詢,我就想到這一個…..其它的嘛,大家幫我想想。
  2. 常規做法,Select反代,管道回調

TOP10的實現

搜索引擎會通過日誌文件把用戶每次檢索使用的所有檢索串都記錄下來,每個查詢串的長度爲1-255字節。
假設目前有一千萬個記錄(這些查詢串的重複度比較高,雖然總數是1千萬,但如果除去重複後,不超過3百萬個。一個查詢串的重複度越高,說明查詢它的用戶越多,也就是越熱門。),請你統計最熱門的10個查詢串,要求使用的內存不能超過1G。

這個題目與上個題目類似,我們選擇使用HashMap,key爲查詢值,val爲計數,內存使用爲 3 * 256 M == 768M < 1G,然後我們不斷的put就可以了,僞代碼

1
2
3
4
5
6
7
HashMap<String, Integer> map = new HashMap<String,Integer>();
//如果內存再多點的話,我們就可以把初始化容量湊個1024的整數,減少擴容損失。
 
while(fileLog.hasNext()){
    String queue = fileLog.next();
    map.put(queue, map.get(queue) + 1);
}

接着使用堆排序遍歷即可,堆的大小爲10,複雜度爲10xO(LogN)。

綜上,O(10^7) +10 * O(Log(3×10^6));

使用WeakHashMap作爲緩衝

在動態代理等耗時操作中,爲了實現複用,使用了HashMap實現緩存,下面的一個例子是Picasso的Transformation操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public final class PaletteTransformation implements Transformation {
    private static final PaletteTransformation INSTANCE = new PaletteTransformation();
    private static final Map<Bitmap, Palette> CACHE = new WeakHashMap<>();
 
    public static PaletteTransformation instance() {
        return INSTANCE;
    }
 
    public static Palette getPalette(Bitmap bitmap) {
        return CACHE.get(bitmap);
    }
 
    private PaletteTransformation() {
    }
 
    @Override
    public Bitmap transform(Bitmap source) {
        Palette palette = Palette.generate(source);
        CACHE.put(source, palette);
        return source;
    }
 
    @Override
    public String key() {
        return "PaletteTransformation";
    }
}

附錄一:集合中元素的排序方式:

剛剛對比SparseArray與HashMap,我怕各位繞進去了,講下Java中集合的排序方式

  • 按照添加/刪除的順序,比如FIFO,FILO,常見的比如Queue,Stack就是按照這個順序實現的;
  • 按照Hash表進行排序,比如HashMap的table中的元素是通過hash運算隨機均勻排列的(至少理論上是),可以通過計算Key的Hash值快速查找到Value
  • 按照自定義的排序,比如按照數字大小,拼音首字母排序,常見的有線性順序表,二叉查找樹,以及高度封裝好的TreeMap,它們需要實現Comaparable接口以進行進一步的自定義CompareTo操作。

附錄二:hashCode, == ,equals, Comparable的區別?

  • == : 這個就是內存地址的比較,從C語言開始我們就知道這個了。
  • hashCode/equals:對於Object來說,hashCode就是地址的取樣運算,而equals就是判斷地址是否相同;在實際使用中,特別是在集合(Set)中,特別是HashSet,爲了防止插入的是兩個相同的值,我們更注重內容上的對比而不是地址的對比,需要通過計算hashCode來判斷是否equals,所以兩個方法要同時重寫,比如String。
  • Comparable: 在集合需要排序(SortSet)的情況下,就需要給對象實現Comparable接口,比如TreeMap。這個在Android開發中實際需要手動寫的情況並不多,畢竟包多,在ORM框架中一般都幫你寫好了。

HashMap在Android項目中的使用

  1. Bundle與Parcelable,實際上就是Android中對HashMap與Serializable的接口實現(當然底層已經是IPC了);
  2. 反射時使用WeakReference做緩存,比如Retrofit等動態代理框架;
  3. 廣播的底層結構是HashMap<IBinder, ReceiverList>;

後記

看這種JDK源碼又累又欣賞,特別是if((n.next=p) != null)這樣的代碼頻頻出現卻忘了運行順序,說明了自己的基礎不足(然並卵,現在已經能寫出這種WTF的代碼了)。這是我第一次寫分析而不是寫過程,希望有問題能夠提出,無論是文章排版還是技術上的問題都可以提出來。

最後打個廣告
我目前正在準備技術面試,所以關於Java所有的文章有個總結,不妨關注一下

Reference

  1. http://bigocheatsheet.com/
  2. https://github.com/android-cn/android-discuss
  3. http://www.ibm.com/developerworks/cn/java/j-jtp08223/
  4. http://www.jianshu.com/p/9a48bcbdfece
  5. http://www.jianshu.com/p/4e13fcdf9ab5
  6. http://blog.csdn.net/zimo2013/article/details/39692245
  7. http://coderbee.net/index.php/java/20131018/519
  8. http://www.guokr.com/blog/747926/
  9. http://blog.csdn.net/v_JULY_v/article/details/6256463
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章