HashMap的实现与优化
轉(zhuǎn)載自?HashMap的實(shí)現(xiàn)與優(yōu)化
HashMap的優(yōu)化與實(shí)踐
本文是基于作者在github上的Android 問(wèn)題交流討論壇提問(wèn)而產(chǎn)生的一篇文章,也是自己早打算開(kāi)坑的一篇文章。文章首先介紹了hashMap的一些基本知識(shí),然后介紹了它在JDK8下的實(shí)現(xiàn)原理,最后著重介紹了幾個(gè)面試中處理大數(shù)據(jù)的方法,文章比較長(zhǎng),我也寫(xiě)了好久,希望各位能夠讀完并發(fā)表意見(jiàn)。
Android 題交流討論壇是開(kāi)源達(dá)人 Trinea 在gitHub上組建的一個(gè)討論組織,那里的提問(wèn)與回答都非常靠譜。
HashMap的復(fù)雜度
如圖是ArrayList/LinkedList/HashMap三個(gè)數(shù)據(jù)結(jié)構(gòu)的復(fù)雜度對(duì)比,可以看出HashMap整體上性能都非常不錯(cuò),但是不穩(wěn)定,為O(N/Buckets),N就是以數(shù)組中沒(méi)有發(fā)生碰撞的元素。
| 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) |
注:發(fā)生碰撞實(shí)際上是非常稀少的,所以N/Bucket_size約等于1
HashMap是對(duì)Array與Link的折衷處理,Array與Link可以說(shuō)是兩個(gè)速度方向的極端,Array注重于數(shù)據(jù)的獲取,而處理修改(添加/刪除)的效率非常低;Link由于是每個(gè)對(duì)象都保持著下一個(gè)對(duì)象的指針,查找某個(gè)數(shù)據(jù)需要遍歷之前所有的數(shù)據(jù),所以效率比較低,而在修改操作中比較快。
復(fù)雜度是如何考察的?
對(duì)于數(shù)據(jù)結(jié)構(gòu),在時(shí)間上我們需要考察Acessing ,Search, Deletion/Insertion的平均與最差的復(fù)雜度。在空間上,我們要考慮維護(hù)這個(gè)數(shù)據(jù)結(jié)構(gòu)所占用的內(nèi)存空間。
常見(jiàn)的數(shù)據(jù)結(jié)構(gòu)與排序的復(fù)雜度都在這里
HashMap的實(shí)現(xiàn)
本文以JDK8的API實(shí)現(xiàn)進(jìn)行分析
1. 什么是hash,什么是碰撞?
- Hash:是一種信息摘要算法,它還叫做哈希,或者散列。我們平時(shí)使用的MD5,SHA1,SSL中的公私鑰驗(yàn)證都屬于Hash算法,通過(guò)輸入key進(jìn)行Hash計(jì)算,就可以獲取key的HashCode(),比如我們通過(guò)校驗(yàn)MD5來(lái)驗(yàn)證文件的完整性。
- 碰撞:好的Hash算法可以出計(jì)算幾乎出獨(dú)一無(wú)二的HashCode,如果出現(xiàn)了重復(fù)的hashCode,就稱作碰撞;
2. HashMap中是如何實(shí)現(xiàn)寫(xiě)入與讀取的?
HashMap實(shí)現(xiàn)了Map接口,保存著K-V這樣的集合。我們以put操作為例
2.1. 對(duì)key進(jìn)行Hash計(jì)算
在JDK8中,由于使用了紅黑樹(shù)來(lái)處理大的鏈表開(kāi)銷,所以hash這邊可以更加省力了,只用計(jì)算hashCode并移動(dòng)到低位就可以了
| 12345 | static final int hash(Object key) {????int h;????//計(jì)算hashCode,并無(wú)符號(hào)移動(dòng)到低位????return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);} |
下面給出幾個(gè)常用的哈希碼的算法。
2.2. 獲取到當(dāng)前的位置
計(jì)算了Hash,我們現(xiàn)在要把它插入數(shù)組中了
| 1 | i = (tab.length - 1) & hash; |
通過(guò)位運(yùn)算,確定了當(dāng)前的位置,因?yàn)镠ashMap數(shù)組的大小總是2^n,所以實(shí)際的運(yùn)算就是 (0xfff…ff) & hash ,這里的tab.length-1相當(dāng)于一個(gè)mask,濾掉了大于當(dāng)前長(zhǎng)度位的hash,使每個(gè)i都能插入到數(shù)組中。
2.3. 生成包裝類
這個(gè)對(duì)象是一個(gè)包裝類,Node<K,V>,內(nèi)部有key,value,hash還有next,可以看出來(lái)它是一個(gè)鏈表。
| 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. 插入包裝類到數(shù)組
(1). 如果輸入當(dāng)前的位置是空的,就插進(jìn)去,如圖,左為插入前,右為插入后
| 1234567891011 | 0?????????? 0|?????????? |1 -> null?? 1 - > null|?????????? |2 -> null?? 2 - > null|?????????? | ..-> null?? ..- > null|?????????? | i -> null?? i - > new node|?????????? |n -> null?? n - > null |
(2). 如果當(dāng)前位置已經(jīng)有了node,且它們發(fā)生了碰撞,則新的放到前面,舊的放到后面,這叫做鏈地址法處理沖突。
| 1234567891011 | 0?????????? 0|?????????? |1 -> null?? 1 - > null|?????????? |2 -> null?? 2 - > null|?????????? | ..-> null?? ..- > null|?????????? | i -> old??? i - > new - > old|?????????? |n -> null?? n - > null |
我們可以發(fā)現(xiàn),失敗的hashCode算法會(huì)導(dǎo)致HashMap的性能下降為鏈表,所以想要避免發(fā)生碰撞,就要提高h(yuǎn)ashCode結(jié)果的均勻性。當(dāng)然,在JDK8中,采用了紅黑二叉樹(shù)進(jìn)行了處理,這個(gè)我們后面詳細(xì)介紹。
什么是Hash攻擊?
通過(guò)請(qǐng)求大量key不同,但是hashCode相同的數(shù)據(jù),讓HashMap不斷發(fā)生碰撞,硬生生的變成了SingleLinkedList
| 123456789 | 0|1 -> a ->b -> c -> d(撞!撞!撞!復(fù)雜度由O(1)變成了O(N))|2 -> null(本應(yīng)該均勻分布,這里卻是空的)|3 -> null|4 -> null |
這樣put/get性能就從O(1)變成了O(N),CPU負(fù)載呈直線上升,形成了放大版DDOS的效果,這種方式就叫做hash攻擊。在Java8中通過(guò)使用TreeMap,提升了處理性能,可以一定程度的防御Hash攻擊。
3. 擴(kuò)容
如果當(dāng)表中的75%已經(jīng)被占用,即視為需要擴(kuò)容了
| 1 | (threshold = capacity * load factor ) < size |
它主要有兩個(gè)步驟:
1. 容量加倍
左移1位,就是擴(kuò)大了兩倍,用位運(yùn)算取代了乘法運(yùn)算
| 12 | newCap = oldCap << 1;newThr = oldThr << 1; |
2. 遍歷計(jì)算Hash
| 123456789101112131415161718192021222324252627282930313233343536373839404142434445 | for (int j = 0; j < oldCap; ++j) {????????????????Node<K,V> e;????????????????//如果發(fā)現(xiàn)當(dāng)前有Bucket????????????????if ((e = oldTab[j]) != null) {????????????????????oldTab[j] = null;????????????????????//如果這里沒(méi)有碰撞????????????????????if (e.next == null)????????????????????????//重新計(jì)算Hash,分配位置????????????????????????newTab[e.hash & (newCap - 1)] = e;????????????????????//這個(gè)見(jiàn)下面的新特性介紹,如果是樹(shù),就填入樹(shù)????????????????????else if (e instanceof TreeNode)????????????????????????((TreeNode<K,V>)e).split(this, newTab, j, oldCap);????????????????????//如果是鏈表,就保留順序....目前就看懂這點(diǎn)????????????????????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;????????????????????????}????????????????????}????????????????}????????????} |
由此可以看出擴(kuò)容需要遍歷并重新賦值,成本非常高,所以選擇一個(gè)好的初始容量非常重要。
如何提升性能?
比如我現(xiàn)在有1000個(gè)數(shù)據(jù),需要 1000/0.75 = 1333 ,又 1024 < 1333 < 2048,所以最好使用2048作為初始容量。
HashMap與HashTable的主要區(qū)別
在很多的Java基礎(chǔ)書(shū)上都已經(jīng)說(shuō)過(guò)了,他們的主要區(qū)別其實(shí)就是Table加了線程同步保護(hù)
- HashTable線程更加安全,代價(jià)就是因?yàn)樗直┑奶砑恿送芥i,所以會(huì)有性能損失。
- 其實(shí)有更好的concurrentHashMap可以替代HashTable
JDK8中HashMap的新特性
如果某個(gè)桶中的鏈表記錄過(guò)大的話(當(dāng)前是TREEIFY_THRESHOLD = 8),就會(huì)把這個(gè)鏈動(dòng)態(tài)變成紅黑二叉樹(shù),使查詢最差復(fù)雜度由O(N)變成了O(logN)。
| 12345678910111213 | //e 為臨時(shí)變量,p為當(dāng)前的鏈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的裝箱空間效率
在筆試題中,一般“內(nèi)存”是完全能夠使用的,而在現(xiàn)實(shí)中HashMap空間效率之低,你卻不一定知道。
比如定義了一個(gè)?HashMap<Long,Long>
1. Long的裝箱
在對(duì)象頭中,加入額外的指針8Bype,加入8Bype的MarkWord(hashcode與鎖信息),這里就是16Byte
也就是說(shuō),long在裝箱后,效率為 8/24 = 1/3
2. Map.Entry的裝箱
字段空間: hash(4) + padding(4) + next(8) = 16Byte,這里的padding是字節(jié)對(duì)齊
對(duì)象頭: 16Byte,指針+MarkWord
也就是說(shuō),維護(hù)一個(gè)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
計(jì)算結(jié)果可能有差異,本文主要在強(qiáng)調(diào)裝箱過(guò)程造成的損失
在Android中使用SparseArray代替HashMap
官方推薦使用SparseArray([spɑ:s] [?'re?],稀疏的數(shù)組)或者LongSparseArray代替HashMap,目前國(guó)內(nèi)好像涉及的比較少,容我先粘貼一段
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.
總的來(lái)說(shuō)就是:
- SparseArray使用基本類型(Primitive)中的int作為Key,不需要Pair<K,V>或者Entry<K,V>這樣的包裝類,節(jié)約了內(nèi)存;
- SpareAraay維護(hù)的是一個(gè)排序好的數(shù)組,使用二分查找數(shù)據(jù),即O(log(N)),每次插入數(shù)據(jù)都要進(jìn)行排序,同樣耗時(shí)O(N);而HashMap使用hashCode來(lái)加入/查找/刪除數(shù)據(jù),即O(N/buckets_size);
- 總的來(lái)說(shuō),就是SparseArray針對(duì)Android嵌入式設(shè)備進(jìn)行了優(yōu)化,犧牲了微小的時(shí)間性能,換取了更大的內(nèi)存優(yōu)化;同時(shí)它還有別的優(yōu)化,比如對(duì)刪除操作做了優(yōu)化;
- 如果你的數(shù)據(jù)非常少(實(shí)際上也是如此),那么使用SpareArray也是不錯(cuò)的;
在筆試中的使用
1. 查重與分組問(wèn)題
某公司正在做一個(gè)尋找走失兒童的公益項(xiàng)目,現(xiàn)在有一個(gè)函數(shù),可以輸入兩個(gè)圖片,并返回這個(gè)兒童是否重復(fù)。請(qǐng)你設(shè)計(jì)一個(gè)系統(tǒng),幫助他們尋找兒童。
A:假設(shè)你現(xiàn)在有一個(gè)機(jī)器,請(qǐng)寫(xiě)出你的數(shù)據(jù)結(jié)構(gòu)與處理流程,設(shè)計(jì)的思路。
B:如果你有多臺(tái)機(jī)器,如果縮短請(qǐng)求的時(shí)間?
A:我們可以把它分解為兩個(gè)部分,一個(gè)是數(shù)據(jù)結(jié)構(gòu)一個(gè)是上傳流程。
(1). 對(duì)于數(shù)據(jù)結(jié)構(gòu)來(lái)說(shuō),一個(gè)是對(duì)兒童信息進(jìn)行包裝,另一個(gè)是實(shí)現(xiàn)兒童信息的高效查找。對(duì)于兒童信息包裝類來(lái)說(shuō),除了加入兒童的圖片,姓名,生日等基本信息外,特別要注意重寫(xiě)equals與hashCode,這個(gè)equals就是題目所說(shuō)的比較函數(shù)。對(duì)于查找的實(shí)現(xiàn)來(lái)說(shuō),首先我們建立一個(gè)HashSet,用于存儲(chǔ)兒童信息。網(wǎng)友上傳后,服務(wù)器通過(guò)對(duì)圖像計(jì)算出特征Hash值,并查Hash表,如果HashCode相同,則返回所在的組;如果不相同,就加入hash表中。(2). 對(duì)于多圖上傳問(wèn)題,使用生產(chǎn)者-消費(fèi)者阻塞隊(duì)列就可以實(shí)現(xiàn)盡快的依次返回照片所在的組。
B:
TOP10的實(shí)現(xiàn)
搜索引擎會(huì)通過(guò)日志文件把用戶每次檢索使用的所有檢索串都記錄下來(lái),每個(gè)查詢串的長(zhǎng)度為1-255字節(jié)。
假設(shè)目前有一千萬(wàn)個(gè)記錄(這些查詢串的重復(fù)度比較高,雖然總數(shù)是1千萬(wàn),但如果除去重復(fù)后,不超過(guò)3百萬(wàn)個(gè)。一個(gè)查詢串的重復(fù)度越高,說(shuō)明查詢它的用戶越多,也就是越熱門。),請(qǐng)你統(tǒng)計(jì)最熱門的10個(gè)查詢串,要求使用的內(nèi)存不能超過(guò)1G。
這個(gè)題目與上個(gè)題目類似,我們選擇使用HashMap,key為查詢值,val為計(jì)數(shù),內(nèi)存使用為 3 * 256 M == 768M < 1G,然后我們不斷的put就可以了,偽代碼
| 1234567 | HashMap<String, Integer> map = new HashMap<String,Integer>();//如果內(nèi)存再多點(diǎn)的話,我們就可以把初始化容量湊個(gè)1024的整數(shù),減少擴(kuò)容損失。while(fileLog.hasNext()){????String queue = fileLog.next();????map.put(queue, map.get(queue) + 1);} |
接著使用堆排序遍歷即可,堆的大小為10,復(fù)雜度為10xO(LogN)。
綜上,O(10^7) +10 * O(Log(3×10^6));
使用WeakHashMap作為緩沖
在動(dòng)態(tài)代理等耗時(shí)操作中,為了實(shí)現(xiàn)復(fù)用,使用了HashMap實(shí)現(xiàn)緩存,下面的一個(gè)例子是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";????}} |
附錄一:集合中元素的排序方式:
剛剛對(duì)比SparseArray與HashMap,我怕各位繞進(jìn)去了,講下Java中集合的排序方式
- 按照添加/刪除的順序,比如FIFO,FILO,常見(jiàn)的比如Queue,Stack就是按照這個(gè)順序?qū)崿F(xiàn)的;
- 按照Hash表進(jìn)行排序,比如HashMap的table中的元素是通過(guò)hash運(yùn)算隨機(jī)均勻排列的(至少理論上是),可以通過(guò)計(jì)算Key的Hash值快速查找到Value
- 按照自定義的排序,比如按照數(shù)字大小,拼音首字母排序,常見(jiàn)的有線性順序表,二叉查找樹(shù),以及高度封裝好的TreeMap,它們需要實(shí)現(xiàn)Comaparable接口以進(jìn)行進(jìn)一步的自定義CompareTo操作。
附錄二:hashCode, == ,equals, Comparable的區(qū)別?
- == : 這個(gè)就是內(nèi)存地址的比較,從C語(yǔ)言開(kāi)始我們就知道這個(gè)了。
- hashCode/equals:對(duì)于Object來(lái)說(shuō),hashCode就是地址的取樣運(yùn)算,而equals就是判斷地址是否相同;在實(shí)際使用中,特別是在集合(Set)中,特別是HashSet,為了防止插入的是兩個(gè)相同的值,我們更注重內(nèi)容上的對(duì)比而不是地址的對(duì)比,需要通過(guò)計(jì)算hashCode來(lái)判斷是否equals,所以兩個(gè)方法要同時(shí)重寫(xiě),比如String。
- Comparable: 在集合需要排序(SortSet)的情況下,就需要給對(duì)象實(shí)現(xiàn)Comparable接口,比如TreeMap。這個(gè)在Android開(kāi)發(fā)中實(shí)際需要手動(dòng)寫(xiě)的情況并不多,畢竟包多,在ORM框架中一般都幫你寫(xiě)好了。
HashMap在Android項(xiàng)目中的使用
后記
看這種JDK源碼又累又欣賞,特別是if((n.next=p) != null)這樣的代碼頻頻出現(xiàn)卻忘了運(yùn)行順序,說(shuō)明了自己的基礎(chǔ)不足(然并卵,現(xiàn)在已經(jīng)能寫(xiě)出這種WTF的代碼了)。這是我第一次寫(xiě)分析而不是寫(xiě)過(guò)程,希望有問(wèn)題能夠提出,無(wú)論是文章排版還是技術(shù)上的問(wèn)題都可以提出來(lái)。
最后打個(gè)廣告
我目前正在準(zhǔn)備技術(shù)面試,所以關(guān)于Java所有的文章有個(gè)總結(jié),不妨關(guān)注一下。
Reference
總結(jié)
以上是生活随笔為你收集整理的HashMap的实现与优化的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問(wèn)題。
- 上一篇: 如何提高手机的上网速度
- 下一篇: 苹果手机听不到声音怎样办