19 | 散列表(中):如何打造一个工业级水平的散列表?
問題引入:如何實現(xiàn)一個工業(yè)級的散列表?
主要要求:
對于動態(tài)散列表來說,不管我們?nèi)绾卧O(shè)計散列函數(shù),選擇什么樣的散列沖突解決方法。隨著數(shù)據(jù)的不斷增加,散列表總會出現(xiàn)裝載因子過高的情況。這個時候,我們就需要啟動動態(tài)擴容。
散列表碰撞攻擊原理:
如果精心構(gòu)造數(shù)據(jù),使得所有的數(shù)據(jù)經(jīng)過散列函數(shù)之后,都散列到同一個槽里。當(dāng)使用的是基于鏈表的沖突解決方法,散列表就會退化為鏈表,查詢的時間復(fù)雜度就從 O(1) 急劇退化為 O(n)。如果散列表中有 10 萬個數(shù)據(jù),退化后的散列表查詢的效率就下降了 10 萬倍。如果之前運行 100 次查詢只需要 0.1 秒,那現(xiàn)在就需要 1 萬秒。因為查詢操作消耗大量 CPU 或者線程資源,導(dǎo)致系統(tǒng)無法響應(yīng)其他請求,從而達到拒絕服務(wù)攻擊(DoS)的目的這
如何設(shè)計一個可以應(yīng)對各種異常情況的工業(yè)級散列表,來避免在散列沖突的情況下,散列表性能的急劇下降,并且能抵抗散列碰撞攻擊?
好的散列函數(shù):
- 首先,不能太復(fù)雜。過于復(fù)雜的散列函數(shù)會消耗很多計算時間,影響到散列表性能。
- 其次,散列函數(shù)生成的值要盡可能隨機并且均勻分布,避免或者最小化散列沖突,并且散列到每個槽里的數(shù)據(jù)也會比較平均,不會出現(xiàn)某個槽內(nèi)數(shù)據(jù)特別多的情況。
裝載因子過大了怎么辦?
- 對于動態(tài)散列表來說,數(shù)據(jù)集合是頻繁變動的,無法預(yù)估將要加入的數(shù)據(jù)個數(shù),無法事先申請一個足夠大的散列表。隨著數(shù)據(jù)加入,裝載因子慢慢變大。當(dāng)裝載因子大到一定程度之后,散列沖突就會變得不可接受。這個時候如何處理呢?—— “動態(tài)擴容”嗎?
- 如何做數(shù)組、棧、隊列的動態(tài)擴容的。針對散列表,當(dāng)裝載因子過大時,也可以進行動態(tài)擴容,重新申請一個更大的散列表,將數(shù)據(jù)搬移到這個新散列表中。假設(shè)每次擴容我們都申請一個原來散列表大小兩倍的空間。那經(jīng)過擴容之后,新散列表的裝載因子就下降為原來的一半。
- 針對數(shù)組的擴容,數(shù)據(jù)搬移操作比較簡單。但是,針對散列表的擴容,數(shù)據(jù)搬移操作要復(fù)雜很多。因為散列表的大小變了,數(shù)據(jù)的存儲位置也變了,所以我們需要通過散列函數(shù)重新計算每個數(shù)據(jù)的存儲位置。
對于支持動態(tài)擴容的散列表,插入操作的時間復(fù)雜度是多少呢?插入一個數(shù)據(jù),最好情況下,不需要擴容,最好時間復(fù)雜度是 O(1)。最壞情況下,散列表裝載因子過高,啟動擴容,重新申請內(nèi)存空間,重新計算哈希位置,并且搬移數(shù)據(jù),所以時間復(fù)雜度是 O(n)。用攤還分析法,均攤情況下,時間復(fù)雜度接近最好情況,就是 O(1)。實際上,對于動態(tài)散列表,隨著數(shù)據(jù)的刪除,散列表中的數(shù)據(jù)會越來越少,空閑空間會越來越多。
- 當(dāng)散列表裝載因子超過某個閾值時需要進行擴容。裝載因子閾值需要選擇得當(dāng)。如果太大,會導(dǎo)致沖突過多;如果太小,會導(dǎo)致內(nèi)存浪費嚴(yán)重。裝載因子閾值的設(shè)置要權(quán)衡時間、空間復(fù)雜度。如果內(nèi)存空間不緊張,對執(zhí)行效率要求很高,可以降低負(fù)載因子的閾值;相反,如果內(nèi)存空間緊張,對執(zhí)行效率要求又不高,可以增加負(fù)載因子的值,甚至可以大于 1。
如何避免低效的擴容?
大部分情況下,動態(tài)擴容的散列表插入一個數(shù)據(jù)都很快,但是在特殊情況下,當(dāng)裝載因子已經(jīng)到達閾值,需要先進行擴容,再插入數(shù)據(jù)。這個時候,插入數(shù)據(jù)就會變得很慢,甚至?xí)o法接受。如果散列表當(dāng)前大小為 1GB,要想擴容為原來的兩倍大小,那就需要對 1GB 的數(shù)據(jù)重新計算哈希值,并且從原來的散列表搬移到新的散列表很耗時
如果我們的業(yè)務(wù)代碼直接服務(wù)于用戶,盡管大部分情況下,插入一個數(shù)據(jù)的操作都很快,但是,極個別非常慢的插入操作,也會讓用戶崩潰。這個時候,“一次性”擴容的機制就不合適了。為了解決一次性擴容耗時過多的情況,將擴容操作穿插在插入操作的過程中,分批完成。當(dāng)裝載因子觸達閾值之后,只申請新空間,但并不將老的數(shù)據(jù)搬移到新散列表中。當(dāng)有新數(shù)據(jù)要插入時,將新數(shù)據(jù)插入新散列表中,并且從老的散列表中拿出一個數(shù)據(jù)放入到新散列表。每次插入一個數(shù)據(jù)到散列表,都重復(fù)上面的過程。經(jīng)過多次插入操作之后,老散列表中數(shù)據(jù)就一點一點全部搬移到新散列表中。這樣沒有了集中的一次性數(shù)據(jù)搬移,插入操作就都變得很快了。
如何選擇沖突解決方法?
開放尋址法和鏈表法在實際的軟件開發(fā)中都非常常用。Java 中 LinkedHashMap 就采用了鏈表法解決沖突,ThreadLocalMap 是通過線性探測的開放尋址法來解決沖突。
這兩種沖突解決方法各有什么優(yōu)勢和劣勢,又各自適用哪些場景?
1. 開放尋址法
優(yōu)點:
- 開放尋址法不需要拉很多鏈表。散列表中的數(shù)據(jù)都存儲在數(shù)組中,可以有效地利用 CPU 緩存加快查詢速度。
- 序列化起來比較簡單。鏈表法包含指針,序列化起來就沒那么容易。序列化很多場合都會用到。
缺點:
- 用開放尋址法解決沖突的散列表,刪除數(shù)據(jù)的時候比較麻煩,需要特殊標(biāo)記已經(jīng)刪除掉的數(shù)據(jù)。在開放尋址法中,所有的數(shù)據(jù)都存儲在一個數(shù)組中,比起鏈表法來說沖突的代價更高。
- 使用開放尋址法解決沖突的散列表,裝載因子的上限不能太大。比鏈表法更浪費內(nèi)存空間。
總結(jié)一下“”當(dāng)數(shù)據(jù)量比較小、裝載因子小的時候,適合采用開放尋址法。這也是 Java 中的ThreadLocalMap使用開放尋址法解決散列沖突的原因。
2. 鏈表法:
優(yōu)點:
- 鏈表法對內(nèi)存的利用率比開放尋址法要高。鏈表結(jié)點可以在需要的時候再創(chuàng)建,并不需要事先申請。這也是鏈表優(yōu)于數(shù)組的地方。
- 鏈表法比起開放尋址法,對大裝載因子的容忍度更高。開放尋址法只能適用裝載因子小于 1 的情況。接近 1 時,就可能會有大量的散列沖突,導(dǎo)致大量的探測、再散列等,性能會下降很多。鏈表法只要散列函數(shù)的值隨機均勻,即便裝載因子變成 10,也就是鏈表的長度變長,雖然查找效率有所下降,但是比起順序查找還是快很多。
缺點:
- 鏈表因為要存儲指針,所以對于比較小的對象的存儲,是比較消耗內(nèi)存的,還有可能會讓內(nèi)存的消耗翻倍。
- 鏈表中的結(jié)點是零散分布在內(nèi)存中,不是連續(xù)的,對 CPU 緩存是不友好的,這方面對于執(zhí)行效率也有一定的影響。
當(dāng)然,如果我們存儲的是大對象,存儲的對象的大小遠遠大于一個指針的大小(4個字節(jié)或者 8 個字節(jié)),那鏈表中指針的內(nèi)存消耗在大對象面前就可以忽略了。實際上對鏈表法稍加改造,可以實現(xiàn)一個更加高效的散列表。將鏈表法中的鏈表改造為其他高效的動態(tài)數(shù)據(jù)結(jié)構(gòu),比如跳表、紅黑樹。這樣,即便出現(xiàn)散列沖突,極端情況下,所有的數(shù)據(jù)都散列到同一個桶內(nèi),那最終退化成的散列表的查找時間也只不過是 O(logn)。這樣也就有效避免了前面講到的散列碰撞攻擊。
工業(yè)級散列表舉例分析-hashmap
Java 中的 HashMa是怎么應(yīng)用:
1. 初始大小HashMap 默認(rèn)的初始大小是 16,默認(rèn)值是可以設(shè)置的,如果事先知道大概的數(shù)據(jù)量有多大,可以通過修改默認(rèn)初始大小,減少動態(tài)擴容的次數(shù),這樣會大大提高 HashMap 的性能。
2. 裝載因子和動態(tài)擴容最大裝載因子默認(rèn)是 0.75,當(dāng) HashMap 中元素個數(shù)超過 0.75*capacity(capacity 表示散列表的容量)的時候,就會啟動擴容,每次擴容都會擴容為原來的兩倍大小。
3. 散列沖突解決方法HashMap 底層采用鏈表法來解決沖突。即使負(fù)載因子和散列函數(shù)設(shè)計得再合理,也免不了會出現(xiàn)拉鏈過長的情況,一旦出現(xiàn)拉鏈過長,則會嚴(yán)重影響 HashMap 的性能。在 JDK1.8 版本中,對 HashMap 做進一步優(yōu)化引入了紅黑樹。而當(dāng)鏈表長度太長(默認(rèn)超過 8)時,鏈表就轉(zhuǎn)換為紅黑樹。可以利用紅黑樹快速增刪改查的特點,提高 HashMap 的性能。當(dāng)紅黑樹結(jié)點個數(shù)少于 8 個的時候,又會將紅黑樹轉(zhuǎn)化為鏈表。因為在數(shù)據(jù)量較小的情況下,紅黑樹要維護平衡,比起鏈表來,性能上的優(yōu)勢并不明顯。
4. 散列函數(shù)散列函數(shù)的設(shè)計并不復(fù)雜,追求的是簡單高效、分布均勻。
| int hash(Object key) { ?int h = key.hashCode(); return (h ^ (h >>> 16)) & (capicity -1); //capicity表示散列表的大小 } |
其中,hashCode() 返回的是 Java 對象的 hash code
創(chuàng)作挑戰(zhàn)賽新人創(chuàng)作獎勵來咯,堅持創(chuàng)作打卡瓜分現(xiàn)金大獎總結(jié)
以上是生活随笔為你收集整理的19 | 散列表(中):如何打造一个工业级水平的散列表?的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 关于操作系统的学习总结
- 下一篇: 《深入理解Java虚拟机:JVM高级特性