HashMap底層實現原理解析


前言

HashMap是Java中最常用的集合類框架,也是Java語言中非常典型的數據結構,同時也是我們需要掌握的數據結構,更重要的是進大廠面試必問之一。

數組特點

存儲區間是連續,且占用內存嚴重,空間復雜也很大,時間復雜為O(1)。

優點:是隨機讀取效率很高,原因數組是連續(隨機訪問性強,查找速度快)。

缺點:插入和刪除數據效率低,因插入數據,這個位置后面的數據在內存中要往后移的,且大小固定不易動態擴展。

鏈表特點

區間離散,占用內存寬松,空間復雜度小,時間復雜度O(N)。

優點:插入刪除速度快,內存利用率高,沒有大小固定,擴展靈活。

缺點:不能隨機查找,每次都是從第一個開始遍歷(查詢效率低)。

哈希表特點

以上數組和鏈表,大家都知道各自優缺點。那么我們能不能把以上兩種結合一起使用,從而實現查詢效率高和插入刪除效率也高的數據結構呢?答案是可以滴,那就是哈希表可以滿足,接下來我們一起復習HashMap中的put()和get()方法實現原理。

HashMap的put()和get()的實現

1、map.put(k,v)實現原理

第一步首先將k,v封裝到Node對象當中(節點)。

第二步它的底層會調用K的hashCode()方法得出hash值。

第三步通過哈希表函數/哈希算法,將hash值轉換成數組的下標,下標位置上如果沒有任何元素,就把Node添加到這個位置上。如果說下標對應的位置上有鏈表。此時,就會拿着k和鏈表上每個節點的k進行equals。如果所有的equals方法返回都是false,那么這個新的節點將被添加到鏈表的末尾。如其中有一個equals返回了true,那么這個節點的value將會被覆蓋。

2、map.get(k)實現原理

第一步:先調用k的hashCode()方法得出哈希值,並通過哈希算法轉換成數組的下標。

第二步:通過上一步哈希算法轉換成數組的下標之后,在通過數組下標快速定位到某個位置上。重點理解如果這個位置上什么都沒有,則返回null。如果這個位置上有單向鏈表,那么它就會拿着參數K和單向鏈表上的每一個節點的K進行equals,如果所有equals方法都返回false,則get方法返回null。如果其中一個節點的K和參數K進行equals返回true,那么此時該節點的value就是我們要找的value了,get方法最終返回這個要找的value。

3、為何隨機增刪、查詢效率都很高的原因是?

原因:增刪是在鏈表上完成的,而查詢只需掃描部分,則效率高。

HashMap集合的key,會先后調用兩個方法,hashCode and equals方法,這這兩個方法都需要重寫。

4、為什么放在hashMap集合key部分的元素需要重寫equals方法?

因為equals默認比較是兩個對象內存地址

HashMap集合的key特點:

5、HashMap總結

無序,不可重復為什么是無序的?因為不一定掛到哪一個單向鏈表上的,因此加入順序和取出也不一樣。怎么保持不可重復?使用equals方法來保證HashMap集合key不可重復,如key重復來,value就會覆蓋。存放在HashMap集合key部分的元素,其實就是存放在HashSet集合中,則HashSet集合也需要重寫equals和hashCode方法。hashmap集合的默認初始化容量為16,默認加載因子為0.75,也就是說這個默認加載因子是當hashMap集合底層數組的容量達到75%時,數組就開始擴容。hashmap集合初始化容量是2的陪數,為了達到散列均勻,提高hashmap集合的存取效率,

6、注意JDK8之后

JDK8之后,如果哈希表單向鏈表中元素超過8個,那么單向鏈表這種數據結構會變成紅黑樹數據結構。當紅黑樹上的節點數量小於6個,會重新把紅黑樹變成單向鏈表數據結構。

問題:

如果O1和O2的hash值相同,就會存放到同一個單向鏈表上,

如果不同,但由於哈希算法執行結束之后轉換的數組下標可能相同,此時會發上“哈希碰撞”。

7、高頻面試題

HashMap的工作原理是什么?

HashMap中的“死鎖”是怎么回事?

HashMap中能put兩個相同key嗎?為什么?

HashMap中的鍵值可以為null嗎?原理?

HashMap擴容機制?

 

 

HashMap 的長度為什么是2的冪次方

為了能讓 HashMap 存取高效,盡量較少碰撞,也就是要盡量把數據分配均勻。我們上面也講到了過了,Hash 值的范圍值-2147483648到2147483648,前后加起來大概40億的映射空間,只要哈希函數映射得比較均勻松散,一般應用是很難出現碰撞的。但問題是一個40億長度的數組,內存是放不下的。所以這個散列值是不能直接拿來用的。用之前還要先做對數組的長度取模運算,得到的余數才能用來要存放的位置也就是對應的數組下標。這個數組下標的計算方法是“ (n - 1) & hash ”。(n代表數組長度)。這也就解釋了 HashMap 的長度為什么是2的冪次方。

這個算法應該如何設計呢?

我們首先可能會想到采用%取余的操作來實現。但是,重點來了:“取余(%)操作中如果除數是2的冪次則等價於與其除數減一的與(&)操作(也就是說 hash%length==hash&(length-1)的前提是 length 是2的 n 次方;)。” 並且 采用二進制位操作 &,相對於%能夠提高運算效率,這就解釋了 HashMap 的長度為什么是2的冪次方。

HashMap 多線程操作導致死循環問題(多線程和高並發會導致死循環)

在多線程下,進行 put 操作會導致 HashMap 死循環,原因在於 HashMap 的擴容 resize()方法。由於擴容是新建一個數組,復制原數據到數組。由於數組下標掛有鏈表,所以需要復制鏈表,但是多線程操作有可能導致環形鏈表。復制鏈表過程如下:

以下模擬2個線程同時擴容。假設,當前 HashMap 的空間為2(臨界值為1),hashcode 分別為 0 和 1,在散列地址 0 處有元素 A 和 B,這時候要添加元素 C,C 經過 hash 運算,得到散列地址為 1,這時候由於超過了臨界值,空間不夠,需要調用 resize 方法進行擴容,那么在多線程條件下,會出現條件競爭,模擬過程如下:

線程一:讀取到當前的 HashMap 情況,在准備擴容時,線程二介入

 

 

線程二:讀取 HashMap,進行擴容

 

 

線程一:繼續執行

 

 
 

這個過程為,先將 A 復制到新的 hash 表中,然后接着復制 B 到鏈頭(A 的前邊:B.next=A),本來 B.next=null,到此也就結束了(跟線程二一樣的過程),但是,由於線程二擴容的原因,將 B.next=A,所以,這里繼續復制A,讓 A.next=B,由此,環形鏈表出現:B.next=A; A.next=B

注意:jdk1.8已經解決了死循環的問題。

HashSet 和 HashMap 區別

如果你看過 HashSet 源碼的話就應該知道:HashSet 底層就是基於 HashMap 實現的。(HashSet 的源碼非常非常少,因為除了 clone() 方法、writeObject()方法、readObject()方法是 HashSet 自己不得不實現之外,其他方法都是直接調用 HashMap 中的方法。)

 

 

1,currentHashMap的介紹

currentHashMap是線程安全並且高效的一種容器,我們就需要研究一下currentHashMap為什么既能夠保證線程安全,又可以保證高效的操作

currentHashMap使用的原因

為什么使用currentHashMap,這時候我們就需要和HashMap以及HashTable進行比較
HashMap線程不安全的原因?
在多線程的情況下,HashMap的操作會引起死循環,導致CPU的占有量達到100%,所以在並發的情況下,我們不會使用HashMap.
至於為什么會引起死循環,大概是因為HashMap的Entry鏈表會形成鏈式的結構,一旦形成了Entry的鏈式結構,鏈表中的next指針就會一直不為空,這樣就會導致死循環
不使用HashTable的原因?
其中使用synchronize來保證線程安全,即當有一個線程擁有鎖的時候,其他的線程都會進入阻塞或者輪詢狀態,這樣會使得效率越來越低
使用currentHashMap的鎖分段技術可以有效的提高並發訪問率
HashTable訪問效率低下的原因,就是因為所有的線程在競爭同一把鎖.如果容器中有多把鎖,不同的鎖鎖定不同的位置,這樣線程間就不會存在鎖的競爭,這樣就可以有效的提高並發訪問效率,這就是currentHashMap所使用的鎖分段技術
將數據一段一段的存儲,然后為每一段都配一把鎖,當一個線程只是占用其中的一個數據段時,其他段的數據也能被其他線程訪問

2,currentHashMap的結構

currentHashMap是由Segment和HashEntry組成的.Segment是一種可重入的鎖(Reentranlock),Segment在其中扮演鎖的角色;HashEntry用於存儲數據.一個CurrentHashMap包括一個Segment數組.一個Segment元素包括一個HashEntry數組,HashEntry是一種鏈表型的結構,每一個Segment維護着HashEntry數組中的元素,當要對HashEntry中的數據進行修改的時候,我們必須先要獲得與它對應的Segment


HashMap:

最后用一張圖來表來說明一下ConcurrentHashMap吧:【源碼與說明

 

ConcurrentHashMap 和 Hashtable 的區別主要體現在實現線程安全的方式上不同。

底層數據結構: JDK1.7的 ConcurrentHashMap 底層采用 分段的數組+鏈表 實現,JDK1.8 采用的數據結構跟HashMap1.8的結構一樣,數組+鏈表/紅黑二叉樹。Hashtable 和 JDK1.8 之前的 HashMap 的底層數據結構類似都是采用 數組+鏈表 的形式,數組是 HashMap 的主體,鏈表則是主要為了解決哈希沖突而存在的;

實現線程安全的方式(重要): ① 在JDK1.7的時候,ConcurrentHashMap(分段鎖) 對整個桶數組進行了分割分段(Segment),每一把鎖只鎖容器其中一部分數據,多線程訪問容器里不同數據段的數據,就不會存在鎖競爭,提高並發訪問率。(默認分配16個Segment,比Hashtable效率提高16倍。) 到了 JDK1.8 的時候已經摒棄了Segment的概念,而是直接用 Node 數組+鏈表+紅黑樹的數據結構來實現,並發控制使用 synchronized 和 CAS 來操作。(JDK1.6以后 對 synchronized鎖做了很多優化) 整個看起來就像是優化過且線程安全的 HashMap,雖然在JDK1.8中還能看到 Segment 的數據結構,但是已經簡化了屬性,只是為了兼容舊版本;② Hashtable(同一把鎖) :使用 synchronized 來保證線程安全,效率非常低下。當一個線程訪問同步方法時,其他線程也訪問同步方法,可能會進入阻塞或輪詢狀態,如使用 put 添加元素,另一個線程不能使用 put 添加元素,也不能使用 get,競爭會越來越激烈效率越低。


  HashTable性能差主要是由於所有操作需要競爭同一把鎖,而如果容器中有多把鎖,每一把鎖鎖一段數據,這樣在多線程訪問時不同段的數據時,就不會存在鎖競爭了,這樣便可以有效地提高並發效率。這就是ConcurrentHashMap所采用的"分段鎖"思想。

  

ConcurrentHashMap源碼分析   

ConcurrentHashMap采用了非常精妙的"分段鎖"策略,ConcurrentHashMap的主干是個Segment數組。

 final Segment<K,V>[] segments;

  Segment繼承了ReentrantLock,所以它就是一種可重入鎖(ReentrantLock)。在ConcurrentHashMap,一個Segment就是一個子哈希表,Segment里維護了一個HashEntry數組,並發環境下,對於不同Segment的數據進行操作是不用考慮鎖競爭的。(就按默認的ConcurrentLeve為16來講,理論上就允許16個線程並發執行,有木有很酷)

  所以,對於同一個Segment的操作才需考慮線程同步,不同的Segment則無需考慮



 

 

 

 

To:https://www.jianshu.com/p/ef84c1aa53f3

http://baijiahao.baidu.com/s?id=1665667572592680093&wfr=spider&for=pc

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM