HashMap的实现与优化
轉載自?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,就稱作碰撞;
2. HashMap中是如何實現寫入與讀取的?
HashMap實現了Map接口,保存著K-V這樣的集合。我們以put操作為例
2.1. 對key進行Hash計算
在JDK8中,由于使用了紅黑樹來處理大的鏈表開銷,所以hash這邊可以更加省力了,只用計算hashCode并移動到低位就可以了
| 12345 | static final int hash(Object key) {????int h;????//計算hashCode,并無符號移動到低位????return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);} |
下面給出幾個常用的哈希碼的算法。
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,可以看出來它是一個鏈表。
| 1234567 | 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). 如果輸入當前的位置是空的,就插進去,如圖,左為插入前,右為插入后
| 1234567891011 | 0?????????? 0|?????????? |1 -> null?? 1 - > null|?????????? |2 -> null?? 2 - > null|?????????? | ..-> null?? ..- > null|?????????? | i -> null?? i - > new node|?????????? |n -> null?? n - > null |
(2). 如果當前位置已經有了node,且它們發生了碰撞,則新的放到前面,舊的放到后面,這叫做鏈地址法處理沖突。
| 1234567891011 | 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
| 123456789 | 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位,就是擴大了兩倍,用位運算取代了乘法運算
| 12 | newCap = oldCap << 1;newThr = oldThr << 1; |
2. 遍歷計算Hash
| 123456789101112131415161718192021222324252627282930313233343536373839404142434445 | 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;????????????????????????}????????????????????}????????????????}????????????} |
由此可以看出擴容需要遍歷并重新賦值,成本非常高,所以選擇一個好的初始容量非常重要。
如何提升性能?
比如我現在有1000個數據,需要 1000/0.75 = 1333 ,又 1024 < 1333 < 2048,所以最好使用2048作為初始容量。
HashMap與HashTable的主要區別
在很多的Java基礎書上都已經說過了,他們的主要區別其實就是Table加了線程同步保護
- HashTable線程更加安全,代價就是因為它粗暴的添加了同步鎖,所以會有性能損失。
- 其實有更好的concurrentHashMap可以替代HashTable
JDK8中HashMap的新特性
如果某個桶中的鏈表記錄過大的話(當前是TREEIFY_THRESHOLD = 8),就會把這個鏈動態變成紅黑二叉樹,使查詢最差復雜度由O(N)變成了O(logN)。
| 12345678910111213 | //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的空間
| 1234567 | 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. 查重與分組問題
某公司正在做一個尋找走失兒童的公益項目,現在有一個函數,可以輸入兩個圖片,并返回這個兒童是否重復。請你設計一個系統,幫助他們尋找兒童。
A:假設你現在有一個機器,請寫出你的數據結構與處理流程,設計的思路。
B:如果你有多臺機器,如果縮短請求的時間?
A:我們可以把它分解為兩個部分,一個是數據結構一個是上傳流程。
(1). 對于數據結構來說,一個是對兒童信息進行包裝,另一個是實現兒童信息的高效查找。對于兒童信息包裝類來說,除了加入兒童的圖片,姓名,生日等基本信息外,特別要注意重寫equals與hashCode,這個equals就是題目所說的比較函數。對于查找的實現來說,首先我們建立一個HashSet,用于存儲兒童信息。網友上傳后,服務器通過對圖像計算出特征Hash值,并查Hash表,如果HashCode相同,則返回所在的組;如果不相同,就加入hash表中。(2). 對于多圖上傳問題,使用生產者-消費者阻塞隊列就可以實現盡快的依次返回照片所在的組。
B:
TOP10的實現
搜索引擎會通過日志文件把用戶每次檢索使用的所有檢索串都記錄下來,每個查詢串的長度為1-255字節。
假設目前有一千萬個記錄(這些查詢串的重復度比較高,雖然總數是1千萬,但如果除去重復后,不超過3百萬個。一個查詢串的重復度越高,說明查詢它的用戶越多,也就是越熱門。),請你統計最熱門的10個查詢串,要求使用的內存不能超過1G。
這個題目與上個題目類似,我們選擇使用HashMap,key為查詢值,val為計數,內存使用為 3 * 256 M == 768M < 1G,然后我們不斷的put就可以了,偽代碼
| 1234567 | 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操作
| 123456789101112131415161718192021222324252627 | 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項目中的使用
后記
看這種JDK源碼又累又欣賞,特別是if((n.next=p) != null)這樣的代碼頻頻出現卻忘了運行順序,說明了自己的基礎不足(然并卵,現在已經能寫出這種WTF的代碼了)。這是我第一次寫分析而不是寫過程,希望有問題能夠提出,無論是文章排版還是技術上的問題都可以提出來。
最后打個廣告
我目前正在準備技術面試,所以關于Java所有的文章有個總結,不妨關注一下。
Reference
總結
以上是生活随笔為你收集整理的HashMap的实现与优化的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 如何提高手机的上网速度
- 下一篇: HashMap 实现原理