本文主要是從jdk源碼入手, 結合常用操作, 圖文並茂, 探討Java中HashMap的一些設計與實現原理.
1.HashMap集合簡介(初探)
HashMap基於哈希表的Map接口實現,是以key-value存儲形式存,及主要用來存放鍵值對. HashMap的實現不是同步的,這意味着它不是線程安全的. 它的key,value都可以爲null.此外,HashMap中的映射不是有序的.
- jdk1.8之前HashMap由數組+鏈表組成, 數組是HashMap的主體,鏈表則是主要爲了解決哈希衝突(兩個對象調用的hashCode方法計算的哈希碼值一致導致計算的數組索引值相同)而存在的(採用"拉鍊法解決衝突")
- jdk1.8之後在解決哈希衝突時有了較大的變化, 當鏈表長度大於閾值(或者紅黑樹的邊界值, 默認值爲8) 並且 當前數組的長度大於64時, 此時此索引位置上的所有數據改爲使用紅黑樹存儲.
補充: 將鏈表轉換成紅黑樹前會判斷, 即使閾值大於8, 但是數組長度小於64, 此時並不會將鏈表變爲紅黑樹. 而是選擇進行數組擴容.
這樣做的目的是因爲數組比較小, 儘量避開紅黑樹結構,這種情況下變爲紅黑樹結構,反而會降低效率,因爲紅黑樹需要進行 左旋,右旋, 變色 這些操作來保持平衡. 同時數組長度小於64時, 搜索時間要相對快些.
所以綜上所述爲了提高性能和減少搜索時間, 底層在閾值大於8並且數組長度大於64時,鏈表才轉換爲紅黑樹.具體可以參考 treeifyBin
方法.
當然雖然增了紅黑樹作爲底層數據, 結構變得複雜了,但是閾值大於8並且數組長度大於64時,鏈表轉換爲紅黑樹時,效率也變得更高效.
HashMap特點:
- 存取無序
- 鍵和值都可以是null,但是鍵位置只能是一個null
- 鍵位置是唯一的,底層的數據結構控制鍵的
- jdk1.8 之前數據結構是: 鏈表+數組
jdk1.8之後是: 鏈表+數組+紅黑樹 - 閾值 > 8 and 數組長度大於64,纔將鏈表轉換爲紅黑樹,變爲紅黑樹的目的是爲了更高效地查詢
2. HashMap集合底層的數據結構
2.1 數據結構
- jdk1.8之前 HashMap由 數組+鏈表 數據結構組成
- jdk1.8之後 HashMap由 數組+鏈表+紅黑樹 數據結構組成
2.2 HashMap 底層的數據結構存儲數據的過程
pulic static void main(String[] args){
//創建HashMap集合對象
HashMap<String, Integer> hm = new HashMap<>();
hm.put("柳巖",18);
hm.put("楊冪",28);
hm.put("劉德華",40);
//hm.put("柳巖",18);
hm.put("柳巖",20);
System.out.println(hm);
}
{楊冪=28, 柳巖=20, 劉德華=40}
-
HashMap<String,Integer> hm = new HashMap<>();
當創建HashMap集合對象的時候.
- jdk8之前: 構造方法中創建一個長度爲16的
Entry[] table
用來存儲鍵值對數據的. - jdk8之後: 構造方法中不創建數組了,而是在第一次調用put方法時創建的數組
Node[] table
用來存儲鍵值對數據的
- jdk8之前: 構造方法中創建一個長度爲16的
-
假設向hm中存儲
柳巖-18
數據,根據柳巖調用String類中重寫hashCode()方法計算出值, 然後結合數組長度採用某種算法(散列算法)計算出向Node數組中存儲數據的空間的索引值.如果計算出的索引空間沒有數據,則直接將
柳巖-18
存儲到數組中, 舉例:計算出的索引位3面試題: 哈希表底層採用何種算法計算hash值? 還有哪些算法可以計算出hash值? 底層採用的key的hashCode方法的值結合數組長度進行無符號右移(>>>),按位異或^,按位與& 計算出索引號 還可以採用: 平方取中法,取餘數,僞隨機法 10%8 ==> 2, 11%8 ==>3
-
向哈希表中存儲數據
劉德華-40
,假設"劉德華"
計算出的hashCode方法結合數組長度計算出的索引值爲3,那麼此時數組空間不是null,此時底層會比較"柳巖"
和"劉德華"
的hash值是否一致, 若不一致,則在此空間上劃出一個結點來存儲鍵值對數據劉德華-40
(拉鍊法) -
假設向哈希表中存儲數據
柳巖-20
,那麼首先根據柳巖調用hashCode方法結合數組長度計算出的索引肯定是3. 此時比較後存儲的數據柳巖
和已經存在的數據的hash值是否相等, 如果hash值相等,此時發生哈希碰撞
那麼底層會調用柳巖所屬類String
的equals方法比較兩個內容是否相等:
相等: 則將後面添加的數據的value覆蓋之前的value
不相等: 那麼繼續向下和其他的數據的key進行比較,若都不相等, 則劃出一個結點存儲數據
哪怕string不同也有可能hashCode方法值相等:
String a = "重地"; String b = "通話"; System.out.println(a.hashCode()+ " " + b.hashCode()); System.out.println(a.equals(b)); /* 1179395 1179395 false */
如果結點個數(鏈表長度)大於閾值8並且數組長度大於64 則將鏈表變爲 紅黑樹.
2. 當兩個對象的hashCode相等會怎麼樣?
產生衝突(哈希碰撞),如key值內容相同則替換就得value值,不然連接到鏈表後面,鏈表長度超過閾值8就轉換爲紅黑樹存儲.
3. 何時發生哈希碰撞和什麼是哈希碰撞?
只要兩個元素的key計算的hashcode相同就會發生衝突.
jdk8前使用鏈表解決哈希碰撞.jdk8後使用鏈表+紅黑樹解決
4. 如果兩個鍵的hashcode相同,如何存儲鍵值對?
hashCode相等. 通過equals方法比較內容是否相等.
相同: 則新的value覆蓋老的value值
不想同: 則將新的鍵值對添加到哈希表中.
在不斷地添加數據的過程中, 會涉及到擴容的問題, 當超出臨界值(且要存放的位置非空時)時,擴容 .默認的擴容方法爲: 擴容爲原來容量的2倍,並將原有的數據複製過來.
通過上述描述,當位於一個鏈表中元素衆多,即hash值相等但是內容不等的元素較多時,通過key值依次查找的效率較低. 而jdk1.8中,哈希表存儲在鏈表長度大於8並且數組長度大於64時將鏈表轉換爲紅黑樹.jdk8在hash表中引入紅黑樹主要是爲了 查找效率更更高.
傳統HashMap的缺點,1.8爲什麼引入紅黑樹? 這樣結構不就變得更麻煩了嘛? 爲何閾值大於8才換成紅黑樹?
1.8之前HashMap的實現是數組+鏈表, 即使哈希函數取得再好,也很難達到元素的百分百均勻分佈.當HashMap中有大量的元素都放在同一個桶中時,這個桶下有一條長長的鏈表, 這個時候HashMap就相當於一個單鏈表, 假如單鏈表有n個元素, 遍歷的時間就是O(n).
1.8爲解決這一問題, 使用 `紅黑樹(查找時間複雜度爲O(logn)) 來優化這個問題.當鏈表長度很小的時候,即使遍歷,速度也很快,但是當鏈表長度不斷變長,對查詢也存在影響.
一些說明:
size
表示HashMap中K-V的實時數量, 注意這個不等於數組的長度.threshold
(臨界值) =capacity
(容量) * loaFactor(加載因子). 這個值是當前已佔用數組長度的最大值. size 超過這個臨界值就會重新reszie
. 擴容後的HashMap容量是之前容量的兩倍.
3. HashMap的繼承關係
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable {
說明
- Cloneable 空接口,表示可以克隆.創建並返回HashMap對象的一個副本.
- Serializable 序列化接口. 屬於標記性接口. HashMap對象可以被序列化和反序列化.
- AbstractMap 父類提供了Map實現接口. 以最大限度地減少實現此接口所需要的工作.
補充: 爲甚HashMap基礎AbstractMap而AbstractMap類實現了Map接口, 那爲啥HashMap還要去實現Map接口呢? 同樣ArrayList也是如此.
這是一個失誤. 最開始寫Java框架時, 以爲會有一些1價值, 直到其意識到毫無價值.
4 HashMap 集合類的成員
4.1 成員變量
1. 序列化版本號
private static final long serialVersionUID = 362498820763181265L;
由於實現了序列化接口, 所以需要一個默認的序列化版本號.
2. 集合的初始化容量(必須是2的n次冪)
/**
* The default initial capacity - MUST be a power of two.
*/
// 1<< 4 相當於 1*(2^4)
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
問題: 爲啥是2的次冪?如果輸入的值並非2的n次冪而是比如10 會怎樣?
3. HashMap 構造方法還可以指定集合的初始化容量的大小:
public HashMap(int initialCapacity) //構造一個帶指定初始容量和默認加載因子(0.75)的空HashMap
根據上述我們知道了, 當向HashMap中添加一個元素的時候, 需要根據key的hash值,去確定其在數組中的具體位置.HashMap 爲了存取高效 ,要儘量較少碰撞,就是要儘量把數據分配均勻, 每個鏈表長度大致相同, 這個實現就在把數據存到哪個鏈表上的算法.
這個算法實際就是取模, hash % length
, 計算機中直接求餘的效率不如位運算. 所以源碼中做了優化,使用 hash&(length -1)
, 而實際上 hash % length
等於hash&(length -1)
的前提就是length是2的 n 次冪.
爲什麼這樣能均勻分佈減少碰撞呢?
- 2的n次方實際就是 1後面n個0,
- 2的n次方-1 實際就是n個1
舉例:
說明: 按位與運算: 相同的二進制位上都是1的時候,結果才爲1, 否則爲0
例如長度爲8:
3 & (8-1) = 3
0000 0011
0000 0111
----------
0000 0011
13 & (8-1) = 5
0000 1101
0000 0111
---------
0000 0101
例如長度爲9:
3 & (9-1)
0000 0011
0000 1000
---------
0
2 & (9-1)
0000 0010
0000 1000
---------
0 碰撞,而當length爲8時不會
13 & (9-1)
0000 1101
0000 1000
---------
0000 1000
如果不是2的n次冪,計算出的索引特別容易相同, 及其容易發生哈希碰撞,造成其餘數組空間很大程度上並沒有存儲數據,鏈表或者紅黑樹過長,效率較低
小結:
-
由上可看出,當我們根據key的hash確定其在數組的位置時,如果n爲2的冪次方,可以保證數據的均勻插入,如果n不是2的冪次方,可能數組的一些位置永遠不會有數據,浪費數組空間,加大沖突的可能.
-
一般我們會想通過 % 取餘來確定位置, 這樣也行, 只不過性能不如 & 運算.而且當n是2的冪次方時:
hash & (length-1) =hash % length
-
因此, HashMap容量爲2的n次方的原因,就是爲了數據的均勻分佈,減少hasn衝突. 畢竟hash衝突也多,代表數組中的一個鏈的長度就會越大,這樣的話會降低hashmap的性能.
-
如果創建的HashMap對象輸入的數組長度不是2的n次方時,HashMap會通過移位運算和或運算得到2的n次方數, 並且是距離那個數最近的數字(比如輸入10, 獲得16), 源代碼如下:
public HashMap(int initialCapacity, float loadFactor) { if (initialCapacity < 0) throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity); if (initialCapacity > MAXIMUM_CAPACITY) //最大2^30 initialCapacity = MAXIMUM_CAPACITY; if (loadFactor <= 0 || Float.isNaN(loadFactor)) throw new IllegalArgumentException("Illegal load factor: " + loadFactor); this.loadFactor = loadFactor; this.threshold = tableSizeFor(initialCapacity); }
static final int tableSizeFor(int cap) { int n = cap - 1; n |= n >>> 1; n |= n >>> 2; n |= n >>> 4; n |= n >>> 8; n |= n >>> 16; return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1; }
說明:
如果給定了initialCapacity(假設爲10), 由於HashMap的capacity必須都是2的冪,因此這個方法用於找到大於等於initialCapacity的最小的2的次冪(此處爲16),然後返回.下面分析這個算法:
-
爲什麼要對cap減1操作呢?
int n = cap - 1;
這是爲了防止,cap本身就是2的n次冪, 若不進行此操作,則執行完該方法則會得到這個cap的二倍,比如輸入8, 不進行-1的話返回16
-
現在來看這些個無符號右移. 若果n這時爲0了(經過了cap-1),則經過後面幾次無符號右移依然是0,最後返回capacity的值爲1(最後有個n+1的操作). 這裏討論不爲0的情況.
-
注意: | 按位或運算: 相同位置上都是0的時候才爲0, 否則爲1
cap = 10 int n =cap-1; == > 9 n |= n >>> 1 00000000 00000000 00000000 00001001 9 >>> 1 00000000 00000000 00000000 00000100 4 -------------------------------------- 00000000 00000000 00000000 00001101 13 最高位右邊相鄰位爲1 n=13 n |= n >>> 2 00000000 00000000 00000000 00001101 13 >>>2 00000000 00000000 00000000 00000011 3 --------------------------------------- 00000000 00000000 00000000 00001111 15 最高兩位右邊相鄰兩位爲1 -- 此時最高4位爲1 n=15 00000000 00000000 00000000 00001111 15 >>> 4 00000000 00000000 00000000 00000000 0 ---------------------------------------- 00000000 00000000 00000000 00001111 15 最高位有8個連續的1, 但是這裏沒有8位,不變...
以此類推, 容量最大也就是32bit的正數, 最後一次
>>> 16
將變爲連續的32個1(但這已經是負數了. 在執行tableSizeFor之前, 對initialCapacity做了判斷, 如果大於MAXIMUM_CAPACITY = 2^30
,則取MAXIMUM_CAPACITY.所以這裏的移位操作之後,最大30個1,不會大於等於MAXIMUM_CAPACITY. 30個1,加1後爲2^30
綜上, 10 變成 16就是這樣得到的~
-
4. 默認的負載因子, 默認值爲0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;
5. 集合最大容量
//集合最大容量的上限是: 2的30次冪
static final int MAXIMUM_CAPACITY = 1 << 30;
當鏈表的值超過8, 則會轉紅黑樹(1.8之後)
//當桶(bucket)上的結點數大於這個值時會轉成紅黑樹
static final int TREEIFY_THRESHOLD = 8
面試題: 爲什麼Map桶中結點個數超過8 才轉爲紅黑樹 ?
8 這個閾值定義在HashMap中, 在源碼註釋中只說明了8是bin(bin就是bucket桶)從鏈轉換成紅黑樹的閾值,但是並沒有說爲什麼是8:
在HashMap中174行有一段說明
* Because TreeNodes are about twice the size of regular nodes, we
* use them only when bins contain enough nodes to warrant use
* (see TREEIFY_THRESHOLD). And when they become too small (due to
* removal or resizing) they are converted back to plain bins. In
* usages with well-distributed user hashCodes, tree bins are
* rarely used. Ideally, under random hashCodes, the frequency of
* nodes in bins follows a Poisson distribution
* (http://en.wikipedia.org/wiki/Poisson_distribution) with a
* parameter of about 0.5 on average for the default resizing
* threshold of 0.75, although with a large variance because of
* resizing granularity. Ignoring variance, the expected
* occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
* factorial(k)). The first values are:
因爲樹的結點大約是普通結點的兩倍(有指向), 我們只在箱子包含足夠多結點時才使用樹結點(參考 TREEIFY_THRESHOLD
). 當他們變得太小(由於刪除或者調整)時,就會被轉換爲普通的桶. 在使用分佈良好的用戶HashCodes時, 很少使用樹箱.理想情況下,箱子中的結點的頻率服從泊松分佈
(http://en.wikipedia.org/wiki/Poisson_distribution) ,默認調整閾值爲0.75,平均參數約爲0.5 ,儘管由於調整粒度的差異很大.忽略方差,列表大小k的預期出現次數(exp(-0.5) * pow(0.5, k) / factorial(k)): 第一個值爲:
* 0: 0.60653066
* 1: 0.30326533
* 2: 0.07581633
* 3: 0.01263606
* 4: 0.00157952
* 5: 0.00015795
* 6: 0.00001316
* 7: 0.00000094
* 8: 0.00000006
* more: less than 1 in ten million
TreeNodes 佔用空間是普通Nodes的兩倍, 所以只有當bin包含足夠多的結點時纔會轉成 TreeNodes
, 而是否足夠多就是TREEIFY_THRESHOLD
決定的. 當bin中結點變少時(長度降到6)就又轉爲普通bin.
這樣就解釋了爲什麼不是一開始就轉換爲TreeNodes, 而是需要一定結點數才轉爲TreeNodes,說白了就是權衡,空間和時間
這段內容還說: 當HashCode離散性很好時,樹形bin用到的概率很小,因爲數據均勻分佈在每個bin中,幾乎不會有bin中鏈表長度達到閾值. 但是在隨機hashcode下,離散性可能會變差,然而jdk又不能阻止用戶實現這種不好的hash算法,因此就可能導致不均勻的數據分佈.不過理想情況下隨機hashCode方法下所有bin中結點分佈頻率滿足泊松分佈.可以看到,一個bin中鏈表長度達到8個元素的概率爲0.00000006
. 幾乎是不可能事件.所以,之所以選擇8,不是隨便決定的,而是根據概率統計得到.
簡而言之,選擇8是因爲符號泊松分佈,超過8的時候,概率已經非常小了.所以選擇8
另外還有如下說法:
紅黑樹的平均查找長度爲
log(n)
, 如果長度爲8,平均查找長度爲log(8)=3,鏈表平均查找長度爲n/2
,當長度爲8時,平均查找長度爲4,這纔有轉換爲樹的必要;鏈表長度若爲小於等於6.6/2=3,而log(6)=2.6,雖然速度也快些,但轉化爲樹和生成樹的時間並不會太短.
6. 當鏈表的值小於6會從紅黑樹轉回鏈表
//當桶bucket上的結點數小於這個值時樹轉換爲鏈表
static final int UNTERRIFY_THRESHOLD = 6;
7.
當前Map裏面的數量超過這個值時, 表中的桶才能進行樹形化,否則桶內元素太多時會擴容,而不是樹形化爲了避免進行擴容,樹形化選擇的衝突,這個值不能小於 4 * TREEIFY_THRESOLD(8)
//桶中結構轉化爲紅黑樹對應的數組長度最小值
static final int MIN_TREEIFY_CAPACITY = 64
8. table用來初始化(必須是2的n次冪)
重點
//存儲元素的數組
transient Node<K,V>[] table;
table 在jdk8中我們瞭解到HashMap是由數組加鏈表加紅黑樹來組成的結構. 其中tale就是HashMap中的數組,8之前爲
Entry<K,V>
類型. 1.8之後只是換樂觀名字Node<K,V>
,都實現一樣的接口: Map.Entry<K,V>
負責村村鍵值對數據.
9. 用來存放緩存 (不那麼重要)
//存放具體元素的集合
transient Set<Map.Entry<K,V>> entrySets;
10. HashMap中存放元素的個數
重點
//存放元素的個數,注意這不等於數組的長度
transient int size;
size爲HashMap中K-V的實時數量,不是table的長度.
11. 用來記錄HashMap的修改次數
//每次擴容和更改HashMap的修改次數
transient int modCount;
12 . 用來調整大小下一個容量的閾值
計算方式爲(容量 *負載因子)
//臨界值 當實際大小([容量capatocy=16]*[負載因子0.75])超過臨界值[threshold]時,會進行擴容(翻倍)
int threshold;
13. 哈希表的加載因子
重點
//加載因子
final float loadFactor;
說明:
-
loadFactor
加載因子,是用來衡量HashMap的滿的程度, 表示HashMap的疏密程度, 影響hash操作到同一個位置的概率,計算HashMap的實時加載因子的方法爲: size/capacity, 而不是佔用桶的數量去除以capacity. capacity是桶的數量,也即是table.lengthloadFactor太大導致查找元素效率低,太小導致數組利用率低,存放的數據會很分散. loadFactor的默認值0.75f是官方給出的比較好的臨界值.
當HashMap裏面容納的元素達到HashMap數組長度的0.75時,表示HashMap太擠,需要擴容,而這個過程涉及到rehash,數據複製等操作,非常消耗性能. 所以開發中儘量減少擴容次數,可以通過創建集合對象時指定初始容量來儘量避免.
另外在HashMap構造器中也可以指定loadFactor
面試題:爲啥默認0.75的threshold啊? 0.4 那麼16*0.4 ---> 6 如果滿6個就進行擴容會造成數組利用率太低 0.9 那麼16*0.9 ---> 14 那麼這樣導致鏈表有點多了,導查找元素效率低
- threshold計算公式:
capacity(數組默認長度16)*loadFactor(負載因子默認0.75)
. 這個值是當前佔用數組長度的最大值.當Size >= threshold時,那麼就要考慮對數組進行擴容.也就是說,這個數用來衡量數組是否需要擴容的一個標準.
- threshold計算公式:
4.2 構造方法
HashMap中重要的構造方法如下:
1, 構造個空的HashMap,默認初始容量(16) 和默認負載因子(0.75)
/**
* Constructs an empty <tt>HashMap</tt> with the default initial capacity
* (16) and the default load factor (0.75).
*/
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // 默認因子0.75賦給loadFactor,並沒有創建數組
}
2, 構造一個具有指定的初始容量和默認loadFactor的HashMap
/**
* Constructs an empty <tt>HashMap</tt> with the specified initial
* capacity and the default load factor (0.75).
*
* @param initialCapacity the initial capacity.
* @throws IllegalArgumentException if the initial capacity is negative.
*/
public HashMap(int initialCapacity) { //指定容量
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
3, 構件一個具有指定初始容量和loadFactor的hashMap
/**
* Constructs an empty <tt>HashMap</tt> with the specified initial
* capacity and load factor.
*
* @param initialCapacity the initial capacity
* @param loadFactor the load factor
* @throws IllegalArgumentException if the initial capacity is negative
* or the load factor is nonpositive
*/
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY) //大於 2^30
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor)) //小於0或者不是一個小數
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor; //檢查完畢,局部變量傳給成員變量
this.threshold = tableSizeFor(initialCapacity);//放進去10,threshold爲16,照理應該是12,之後put()會修改
}
說明:對於 this.threshold = tableSizeFor(initialCapacity);
疑問解答:
tableSizeFor(initialCapacity)判斷指定的初始化容量時候爲2的n次冪,如果不是則變爲最小的離它最近的那個2的n次冪.這點前面已經講過.
但是注意,在taleSizeFor內部將計算後的數據直接返回賦給threshold,有人覺得應該這麼寫:
this.threshold = tableSizeFor(initialCapacity) * this.loadFactor;
這樣才符合threshold的意思(當HashMap的Size達到threshold這個閾值時會擴容).
但是注意, 在jdk8以後的構造方法中,並沒有對table這個成員變量進行初始化,table的初始化被推遲到了put方法中**,在put方法會對threshold重新計算**,put方法的具體實現下面會繼續講解
4, 包含另一個Map的構造函數
public HashMap(Map<? extends K, ? extends V> m) { //將原來的集合m內容放在新的集合裏面
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
int s = m.size();
if (s > 0) { //如果有數據,才進行拷貝
if (table == null) { // pre-size
float ft = ((float)s / loadFactor) + 1.0F; //思考這裏爲啥要 +1
int t = ((ft < (float)MAXIMUM_CAPACITY) ?
(int)ft : MAXIMUM_CAPACITY);
if (t > threshold) //這裏threshold爲0
threshold = tableSizeFor(t);
}
else if (s > threshold)
resize();
for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
K key = e.getKey();
V value = e.getValue();
putVal(hash(key), key, value, false, evict);
}
}
}
面試題: float ft = ((float)s / loadFactor) + 1.0F; //思考這裏爲啥要 +1f
?
s/loadFactor 結果是小數,加1.0F與(int)ft相當於是對小數做一個向上取整以儘可能保證更大容量,加大容量能夠保證減少resize的調用次數. 所有+1.0f是爲了獲得更大的容量.
例如:原來集合的元素是6個,那麼6/0.75是8,是2的n次冪,那麼新的數組的大小就是8了. 然後原來數組的數據就會存儲到長度爲8的新的數組中,這樣會導致在存儲元素的時候, 容量不夠,還得繼續擴容, 那麼性能降低了; 而如果+1呢, 數組長度直接變爲16了,這樣可以減少數組的擴容.
4.3 HashMap的成員方法
4.3.1 增加方法 put
主要步驟:
- 通過hash值計算出key映射到哪個桶;
- 如果桶上沒衝突,直接插入;
- 如果出現衝突,則需要衝突處理:
- 如果該桶使用紅黑樹處理衝突,則調用紅黑樹的方法插入數據
- 否則如果採用傳統的鏈式方法插入. 如果鏈的長度達到臨界值,則把鏈轉換爲紅黑樹;
- 如果桶中存在重複的鍵,則爲該鍵替換新的值value
- 如果size大於閾值threshold,則進行擴容;
具體方法如下:
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
/**
1) key=null:可以看出當key爲null時hash值爲0
1) key不等於null:
首先計算出key的hashCode賦值給h, 然後與h無符號右移16位的二進制進行按位異或^ 得到最後的hash值
*/
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
...
if ((p = tab[i = (n - 1) & hash]) == null) //這裏就是上面所說的求摸策略,這裏的n表示數組長爲16
...
}
上面可知HashMap是支持key爲空的,而hashTable是直接用key來獲取HashCode所有key爲空會拋異常.
同時上面也就解釋了 HashMap的長度爲什麼要是2的冪 .因爲HashMap使用的方法很巧, 它通過hash & (table.length-1)來得到該對象的保存位,前面說過HashMap底層數組的長度總是2的n次方,這是HashMap在速度上的優化. 當length總是2的n次方時, hash & (length-1)
運算等價於對length取模,也就是hash%length
,但是&比%具有更高的效率. 比如n%32 = n & (32 - 1)
下面是一些位運算解析: 注意:異或運算^: 相同爲0,不同爲1
(key.hashCode()) ^ (h>>>16)
i = (n - 1) & hash
1111 1111 1111 1111 1111 0000 1110 1010 h = key.hashCode()
0000 0000 0000 0000 1111 1111 1111 1111 h >>>16
----------------------------------------------- 異或操作
1111 1111 1111 1111 0000 1111 0001 0101 返回給: hash
0000 0000 0000 0000 0000 0000 0000 1111 n = 16-1
------------------------------------------------ 與操作
0000 0000 0000 0000 0000 0000 0000 0101 i=5 = i = (n - 1) & hash
簡單來看就是:
- 高16bit不變,低16bit和高16bit做了一個異或(得到的hashcode轉化爲32位二進制,前16位和後16位做了個異或)
爲啥是這樣操作呢?
如果當n即數組長度很小,假設是16的話,那麼n-1爲1111,這樣的值與hashCode()直接按位與操作,實際上只使用了哈希值的後四位.如果hash值高位變化很大,低位變化很小,這樣就很容易造成hash衝突了,所以這裏吧高低位都利用起來,從而解決了一個問題.如下
1111 1111 1111 1111 1111 0000 1110 1010 h1 = key.hashCode()
1010 1011 0001 1111 1111 0000 1110 1010 h2 = key.hashCode() //高位變化很大
此時如果不進行右移16位再異或操作,而是直接和數字長度進行按位與, 則會h1和h2衝突
現在來詳細看putVal()方法,看它到底做了什麼
主要參數:
- hash: 即key的hash值, 通過hashCode()方法產生的結果再與右移16位進行異或操作得到
- key: 原始key
- value: 要存放的值
- onlyifAsent: 如果true表示 不更改現有的值
- evict: 如果false表示table爲創建狀態
/**
* Implements Map.put and related methods
*
* @param hash hash for key
* @param key the key
* @param value the value to put
* @param onlyIfAbsent if true, don't change existing value
* @param evict if false, the table is in creation mode.
* @return previous value, or null if none
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)//table爲null或者長度爲0, 初始擴容
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null) //這裏就是上面所說的求摸策略,這裏的n表示數組長爲16
tab[i] = newNode(hash, key, value, null); //tab[i]爲空,直接創建節點
else { //此處已有節點
Node<K,V> e; K k;
if (p.hash == hash && //tab[i]處的已存在節點處的hash == 新插入數據的hash
((k = p.key) == key || (key != null && key.equals(k)))) //並且 (比較兩者地址是否相等 或者 key內容相等)
e = p;
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
for (int binCount = 0; ; ++binCount) { //用for循環找到最後一個節點
if ((e = p.next) == null) { //如果p的後繼e爲null
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // 加之前是否比閾值8小1,也就是說現在節點數是否等於8
treeifyBin(tab, hash); //鏈表轉爲紅黑樹
break;
}
if (e.hash == hash && //表名p的後繼不爲null
((k = e.key) == key || (key != null && key.equals(k)))) //比較地址 或者 內容
break;
p = e; //p後移, 遍歷鏈表
}
}
if (e != null) { // existing mapping for key 替換策略
V oldValue = e.value; //得到舊的值
if (!onlyIfAbsent || oldValue == null)//如果 可更改 並且舊值不爲null
e.value = value; //將新值賦到該處的value
afterNodeAccess(e);
return oldValue; //返回舊值
}
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
接下來來看看結點數大於等於8轉換爲紅黑樹的函數(上面30行,真的會轉換嗎?)
4.3.2 增加方法 put
/**
* Replaces all linked nodes in bin at index for given hash unless
* table is too small, in which case resizes instead.
*/
final void treeifyBin(Node<K,V>[] tab, int hash) { //傳入數組tab,和待插入元素結點的hash
int n, index; Node<K,V> e;
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY) // MIN_TREEIFY_CAPACITY=64
resize(); //不轉化,而是擴容
else if ((e = tab[index = (n - 1) & hash]) != null) { //此時n=64, 根據hash獲得桶中元素,賦給e
TreeNode<K,V> hd = null, tl = null; //頭結點hd,尾節點tl
do {
TreeNode<K,V> p = replacementTreeNode(e, null); //將鏈表結點變爲樹節點p
if (tl == null) //第一次,p賦給頭結點
hd = p;
else {
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null); //循環至鏈表的最後一個結點
if ((tab[index] = hd) != null)
hd.treeify(tab);
}
}
下面我結合圖進行說明上述代碼完成了怎樣的一些操作:
-
查看9,10,12,13,14 行, 初次操作,第一次循環:
此時頭結點hd,尾節點tl 同時指向tab[index]轉換的結點p
-
如果e.next != null,繼續循環,此時e指向下一個結點,
回到12 行,又將其變成p
接下來執行15~17行:
p.prev = tl; //P的前驅節點指向tl
tl.next = p;//tl的後繼指向現在的p
}
tl = p; //尾節點變爲p
經過上述操作變爲如下:
- e繼續向後尋找下一個結點, 重複剛剛的動作,得到: (藍色是本次操作)
好了,當e後面沒有結點後將不再繼續,從而執行到最後一行:
if ((tab[index] = hd) != null)
hd.treeify(tab); //旋轉等一些操作
在此之前將鏈表上的每一個結點都轉換爲了TreeNode(不過left和right指針都沒用), 同時相鄰兩個結點相互指向, 形式上來看更像是雙向鏈表.
而最後一行, hd.treeify(tab);
就是構造紅黑樹的關鍵了.
/**
* Forms tree of the nodes linked from this node.
* @return root of tree
*/
final void treeify(Node<K,V>[] tab) {
TreeNode<K,V> root = null;
for (TreeNode<K,V> x = this, next; x != null; x = next) { //將調用函數的hd命名爲x,next指向下一個結點, 再依次向下進行遍歷鏈表
next = (TreeNode<K,V>)x.next;
x.left = x.right = null; //x的left和right置空
if (root == null) { //第一次,表名根節點
x.parent = null;
x.red = false; // 當前節點的紅色屬性設爲false(把當前節點設爲黑色)
root = x; //現在這個 hd = x = root了, 即根節點指向到當前節點
}
else { //已經存在根節點了
K k = x.key; //當前鏈表節點的key
int h = x.hash; //取得當前鏈表節點的hash
Class<?> kc = null;// 定義key所屬的Class
for (TreeNode<K,V> p = root;;) { // 從根節點開始遍歷,此遍歷沒有設置邊界,只能從內部跳出
// GOTO1
int dir, ph; // dir 標識方向(左右)、ph標識當前樹節點的hash值
K pk = p.key;// 當前樹節點的key
if ((ph = p.hash) > h) // 如果當前樹節點hash值 大於 當前鏈表節點的hash值
dir = -1;// 標識當前鏈表節點會放到當前樹節點的左側
else if (ph < h)
dir = 1;// 右側
/*
* 如果兩個節點的key的hash值相等,那麼還要通過其他方式再進行比較
* 如果當前鏈表節點的key實現了comparable接口,並且當前樹節點和鏈表節點是相同Class的實例,那麼通過comparable的方式再比較兩者。
* 如果還是相等,最後再通過tieBreakOrder比較一次
*/
else if ((kc == null &&
(kc = comparableClassFor(k)) == null) ||
(dir = compareComparables(kc, k, pk)) == 0)
dir = tieBreakOrder(k, pk);
TreeNode<K,V> xp = p;// 保存當前樹節點
/*
* 如果dir 小於等於0 :
* 當前鏈表節點一定放置在當前樹節點的左側,但不一定是該樹節點的左孩子,也可能是左孩子的右孩子 或者 更深層次的節點。
* 如果dir 大於0 :
* 當前鏈表節點一定放置在當前樹節點的右側,但不一定是該樹節點的右孩子,也可能是右孩子的左孩子 或者 更深層次的節點。
* 如果當前樹節點不是葉子節點,那麼最終會以當前樹節點的左孩子或者右孩子 爲 起始節點 再從GOTO1 處開始 重新尋找自己(當前鏈表節點)的位置
* 如果當前樹節點就是葉子節點,那麼根據dir的值,就可以把當前鏈表節點掛載到當前樹節點的左或者右側了。
* 掛載之後,還需要重新把樹進行平衡。平衡之後,就可以針對下一個鏈表節點進行處理了。
*/
if ((p = (dir <= 0) ? p.left : p.right) == null) {
x.parent = xp; // 當前鏈表節點 作爲 當前樹節點的子節點
if (dir <= 0)
xp.left = x; // 作爲左孩子
else
xp.right = x; // 作爲右孩子
root = balanceInsertion(root, x); //重新平衡
break;
}
}
}
}
// 把所有的鏈表節點都遍歷完之後,最終構造出來的樹可能經歷多個平衡操作,根節點目前到底是鏈表的哪一個節點是不確定的
// 因爲我們要基於樹來做查找,所以就應該把 tab[N] 得到的對象一定根是節點對象,而目前只是鏈表的第一個節點對象,所以要做相應的處理。
//把紅黑樹的根節點設爲 其所在的數組槽 的第一個元素
//首先明確:TreeNode既是一個紅黑樹結構,也是一個雙鏈表結構
//下面這個方法裏做的事情,就是保證樹的根節點一定也要成爲鏈表的首節點
moveRootToFront(tab, root);
}
小結一下putVal()
其主要完成了這幾個事情:
- 當桶數組 table 爲空時,通過擴容的方式初始化 table
- 查找要插入的鍵值對是否已經存在,存在的話根據條件判斷是否用新值替換舊值
- 如果不存在,則將鍵值對鏈入鏈表中,並根據鏈表長度決定是否將鏈表轉爲紅黑樹
- 判斷鍵值對數量是否大於閾值,大於的話則進行擴容操作
(關於`紅黑樹數據結構的一些操作,之後會補上)
4.3.3 擴容方法: resize
擴容機制
想要了解HashMap的擴容機制要有這兩個問題
- 什麼時候才需要擴容
- HashMap的擴容是什麼
1.什麼時候才需要擴容
當HashMap中的元素個數超過數組大小(數組長度)*loadFactor(負載因子)
時,就會進行數組擴容,loadFactor的默認值(DEFAUTL_LOAD_FACTOR)是.75. 也就是說,默認情況下,數組大小爲16, 那麼當HashMap中的元素個數超過 16 * .75 = 12(這個值就是閾值或者邊界值threshold)的時候,就把數組的大小擴展爲2*16=32
, 然後重新計算每個元素在數組中的位置, 而這是個非常耗時的操作,所以如果我們已經預知HashMap中元的個數,那麼是能夠有效地提高HashMap性能的.
補充:
當HashMap其中的一個鏈表對象個數如果達到了8個, 此時如果數組長度沒有達到64,那麼HashMap會先擴容解決; 如果已經達到了64, 那麼這個鏈表會變成紅黑樹,節點類型由Node變爲TreeNode類型.當然,如果映射關係被移除後,下次執行resize方法時判斷樹的節點個數小於6, 也會再次把樹轉換爲鏈表
綜上::
- 當HashMap中的元素個數超過
數組大小(數組長度)*loadFactor(負載因子)
時,會進行數組擴容 - 當一個鏈表對象個數如果達到了8個, 此時如果數組長度沒有達到64,也會進行擴容
2.HashMap的擴容是什麼?
進行擴容, 會伴隨着一次**重新hash分配,**並且會遍歷hash表中所有的元素,是非常耗時的. 在編寫程序中,儘量避免resize
HashMap在進行擴容時,不需要重新計算hash值,1.8使用的rehash方式非常巧妙,因爲每次擴容都是翻倍,與原來計算的(n-1) & hash
的結果相比,只是多了一個bit位,所以節點要麼就在**原來的位置,**要麼就被分配到"**原位置+舊容量"**這個位置
原數組長度: n=16
(n-1) & hash
0000 0000 0000 0000 0000 0000 0000 1111 15
hash1(key1):1111 1111 1111 1111 0000 1111 0000 0101
----------------------------------------------------
0000 0000 0000 0000 0000 0000 0000 0101 索引:5
0000 0000 0000 0000 0000 0000 0000 1111 15
hash2(key2):1111 1111 1111 1111 0000 1111 0001 0101
----------------------------------------------------
0000 0000 0000 0000 0000 0000 0000 0101 索引:5
數組長度擴容==> 16*2=32
(n-1) & hash
0000 0000 0000 0000 0000 0000 0001 1111 31
hash1(key1):1111 1111 1111 1111 0000 1111 0000 0101
----------------------------------------------------
0000 0000 0000 0000 0000 0000 0000 0101 索引:5
0000 0000 0000 0000 0000 0000 0001 1111 31
hash2(key2):1111 1111 1111 1111 0000 1111 0001 0101
----------------------------------------------------
0000 0000 0000 0000 0000 0000 0001 0101 索引:5+16=21
現在可以很好理解上面那句話了.
00101 --> 5
0101 ==resize擴容(16*2)=>
10101 --> 5+16(oldCap)
搞懂了核心機制後,下面來看看源代碼:
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) { //oldCap不爲空
if (oldCap >= MAXIMUM_CAPACITY) { //大於最大容量 2^30
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && //左移一位(擴大一倍)也不能大於最大容量
oldCap >= DEFAULT_INITIAL_CAPACITY) //並且 原來容量應大於
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
//將原數組中的內容拷貝過來
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) { //j位置不空,將其賦給e
oldTab[j] = null;
if (e.next == null) //是否有後繼(是鏈表)?
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) { //高位處爲0,放在原位置不動(標記lo和hi)
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;//放在j+oldCap位置上
}
}
}
}
}
return newTab;
}
4.3.4 刪除方法(remove)
理解了put方法之後, remove方法已經沒有什麼難度, 重複的內容不做詳細介紹
刪除先是找到元素的位置,如果是鏈表就遍歷鏈表找到元素後刪除,如果用紅黑樹遍歷後找到之後刪除,樹小於6的時候要轉回成鏈表
//方法的具體實現在removeNode方法中, 所以重點看removeNode方法
public V remove(Object key) {
Node<K,V> e;
return (e = removeNode(hash(key), key, null, false, true)) == null ?
null : e.value;
}
removeNode方法:
/**
* Implements Map.remove and related methods.
*
* @param hash hash for key
* @param key the key
* @param value the value to match if matchValue, else ignored
* @param matchValue if true only remove if value is equal
* @param movable if false do not move other nodes while removing
* @return the node, or null if none
*/
final Node<K,V> removeNode(int hash, Object key, Object value,
boolean matchValue, boolean movable) {
Node<K,V>[] tab; Node<K,V> p; int n, index;
if ((tab = table) != null && (n = tab.length) > 0 && //table不空 且 長度大於0
(p = tab[index = (n - 1) & hash]) != null) { //該索引賦給index, 該處節點賦給p,不能爲空
Node<K,V> node = null, e; K k; V v;
if (p.hash == hash && //p的hash 等於 傳入的hash:
((k = p.key) == key || (key != null && key.equals(k))))//比較key內容相等
node = p; //表明tab[index]即爲我們所要刪除的: node 存放tab[index]
else if ((e = p.next) != null) { //tab[index]不是我們要刪的
if (p instanceof TreeNode) //是紅黑樹
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
else { //是鏈表
do {
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {
node = e;
break;
}
p = e;
} while ((e = e.next) != null); //遍歷鏈表,找出key內容相等
}
}
if (node != null && (!matchValue || (v = node.value) == value || //node不空
(value != null && value.equals(v)))) {
if (node instanceof TreeNode)//是紅黑樹節點,紅黑樹刪除
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
else if (node == p)//tab[index]是需要刪除的節點
tab[index] = node.next;
else //需要刪除的節點在鏈表中
p.next = node.next;
++modCount;
--size;
afterNodeRemoval(node);
return node;
}
}
return null;
}
4.3.5 查找元素方法(get)
查找方法,通過元素的Key找到Value
代碼如下:
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) { //first 存放所求到的元素
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))//hash等 並且 key內容也等
return first;
if ((e = first.next) != null) { //如果fist不爲所求,但還有後繼
if (first instanceof TreeNode)//是樹節點,在樹中找
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do { //是鏈表, 按照鏈表方式遍歷比較
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
下面來關注下樹的中尋找的getTreeNode(hash,key)
:
final TreeNode<K,V> getTreeNode(int h, Object k) {
return ((parent != null) ? root() : this).find(h, k, null);
}
又調用了find方法:
/**
* Finds the node starting at root p with the given hash and key.
* The kc argument caches comparableClassFor(key) upon first use
* comparing keys.
*/
final TreeNode<K,V> find(int h, Object k, Class<?> kc) {
TreeNode<K,V> p = this;
do {
int ph, dir; K pk;
TreeNode<K,V> pl = p.left, pr = p.right, q;
if ((ph = p.hash) > h)//往左邊找
p = pl;
else if (ph < h) //往右邊找
p = pr;
else if ((pk = p.key) == k || (k != null && k.equals(pk)))//找到了
return p;
else if (pl == null)//左邊爲空
p = pr;
else if (pr == null)//右邊爲空
p = pl;
else if ((kc != null ||
(kc = comparableClassFor(k)) != null) &&
(dir = compareComparables(kc, k, pk)) != 0)
p = (dir < 0) ? pl : pr;
else if ((q = pr.find(h, k, kc)) != null //遞歸調用(整個查找方式類似折半查找)
return q;
else
p = pl;
} while (p != null);
return null;
}
小結:
1.get方法實現的步驟:
-
通過hash值獲取該key映射到的桶
-
桶上的key就是要查找的key,則直接找到並返回
-
桶上的key不是要找的key,則查看後續的節點:
a: 如果後續節點是紅黑樹節點,通過調用紅黑樹的方法根據key獲取value
b:如果後續節點是鏈表節點,則通過遍歷鏈表根據key獲取value
3.查找紅黑樹.由於之前添加時已經保證這個樹是有序的了,因此查找時基本就是折半查找
4.這裏和插入時一樣,如果對比節點的哈希值和要查找的哈希值相等,就會判斷key是否相等,相等直接返回; 不相等就從子樹遞歸查找
- 若爲樹,則在樹中通過key.equals(k)查找, O(logn)
- 若爲鏈表,則在鏈表中通過key.equals(k)查找, O(n)
4.3.6 遍歷HashMap集合的幾種方式
創建實驗用例
public static void main(String[] args) {
HashMap<String,Integer> hm = new HashMap<>();
hm.put("aaa",1000);
hm.put("bbb",1200);
hm.put("ccc",1400);
hm.put("aaa",1400);
...
}
-
分別獲取Key和Values
簡言之:
hashmap.keySet()
獲取keys,hashmap.values()
獲取values/** * 分別獲取Keys和Values * @param hm */ private static void method1(HashMap<String, Integer> hm){ //獲取所有key Set<String> keys = hm.keySet(); for (String key : keys) System.out.println(key); //獲取所有value Collection<Integer> values = hm.values(); for (Integer value : values) System.out.println(value); }
-
通過iterator獲取
簡言之:先通過
hashmap.entrySet
獲取鍵值對集合entries
,再用Iterator逐一遍歷/** * 使用iterator迭代器迭代 * */ private static void method2(HashMap<String,Integer> hm){ Set<Map.Entry<String , Integer>> entries = hm.entrySet(); for (Iterator<Map.Entry<String,Integer>> it = entries.iterator(); it.hasNext();){ Map.Entry<String,Integer> entry = it.next(); System.out.println(entry.getKey() + "---" + entry.getValue()); } }
-
通過get(key)方式 (不建議使用–兩次使用迭代器,不建議使用)
簡言之:先用
keySet()
獲取所有的key,在通過hashmap.get(key)
獲得valuekeySet 其實是遍歷2次,一次轉爲Iterator對象,另一次從hashmap中去除key所對應的value. 而entrySet只是遍歷了一次就把key和value都放在了entry中.
/** * 通過get(key) */ private static void method3(HashMap<String , Integer> hm){ Set<String> keys = hm.keySet(); for (String key : keys){ Integer value = hm.get(key); System.out.println(key + "===" value); } }
-
jdk8 以後使用Map接口中的默認方法
forEach(BiConsumer<? super K, ? super V> action)
使用很簡單:
/** * jdk8 以後使用Map接口中的默認方法 */ private static void method4(HashMap<String, Integer> hm) { hm.forEach((key,value)->{ //函數式接口 System.out.println(key + "----" + value); }); }
進入forEach中看看:
//HashMap.java @Override public void forEach(BiConsumer<? super K, ? super V> action) { Node<K,V>[] tab; if (action == null) throw new NullPointerException(); if (size > 0 && (tab = table) != null) { int mc = modCount; for (int i = 0; i < tab.length; ++i) { for (Node<K,V> e = tab[i]; e != null; e = e.next) action.accept(e.key, e.value); } if (modCount != mc) throw new ConcurrentModificationException(); } }
BiConsumer
是個啥東東? 和Consumer有什麼關係? 進去看看.@FunctionalInterface public interface BiConsumer<T, U> { /** * Performs this operation on the given arguments. * * @param t the first input argument * @param u the second input argument */ void accept(T t, U u); default BiConsumer<T, U> andThen(BiConsumer<? super T, ? super U> after) { Objects.requireNonNull(after); return (l, r) -> { accept(l, r); after.accept(l, r); }; } }
和Consumer很像,其實是Consumer相關的函數式接口,
Consumer
中有一個核心方法:void accept(T t); //對給定的參數T執行定義的操作
說白了Comsumer就是給定義一個參數,對其進行(消費)處理,處理的方式可以是任意操作.(抽象方法嘛)
這裏
BiConsumer
莫不是給定兩個參數進行操作.void accept(T t, U u);
關於這個函數式接口再說兩句, java.util.function中 Function, Supplier, Consumer, Predicate和其他函數式接口廣泛用在支持lambda表達式的API中。這些接口有一個抽象方法,會被lambda表達式的定義所覆蓋.
回到
forEach
中可以看到, K,V或其父類, 經過一系列操作,將逐一打印出key和value; 換句話說, 傳入給forEach函數的兩個參數,即是每個entry的key和value.補充一點:
<? super T> 與 <? extend U>
,前者表示任何T泛型的父類或者T(T是下限),後者表示任何U泛型子類或者就是U(U是上限). 上面的函數實現參數BiConsumer<? super K, ? super V> action
使用super,表示K和V就是其下限(子類),能對K和V進行的操作就一定能不會蹦(子類能滿足,父類也能行).
關於這一點, 有機會會專門寫一寫諸如<T extends Comparable<? super T>>
這樣的…
5 關於HashMap初始化再談
5.1 HashMap初始化問題描述
如果我們確切知道有多少個鍵值對要進行存儲,那麼我們在初始化HashMap的時候就應該指定它的容量,以防止HashMap自動擴容,影響使用效率.
默認情況下HashMap的容量爲16.但是若用戶通過構造函數指定了一個數字作爲其容量,那麼其會選擇大於該數字的第一個2的冪作爲容量(3–>4 , 16–>32), 這是前面已經談過的.
<阿里巴巴Java開發手冊>建議我們設置HashMap的初始化容量
[推薦]集合初始化時,指定集合初始值大小
說明: HashMap使用HashMap(int initialCapacity)初始化.
爲啥?
HashMap的擴容機制,就是當達到擴容條件時會進行擴容. HashMap的擴容條件就是當HashMap中的元素個數(size)超過臨界值(threshold)時會自動擴容. threshold = loadFactor *capacity
so, 如果我們沒有設置初始化容量大小,隨着元素不斷增加,HashMap可能會發生多次擴容,而HashMap中的擴容機制會在每次擴容是進行拷貝, 重新hash,很影響性能的.
不過, 設置初始化容量時,設置的數值不同也會很影響性能,那麼當我們已知HasMap中即將存放KV個數時,容量設置爲多少較好呢? 比如我們有20個KV時,是直接給20麼?
5.2 HashMap中容量的初始化
正例: initialCapacity = (需要存儲的元素個數 / 負載因子) + 1 . 負載因子loadFactor默認爲0.75
仔細想想, 假如我們就有7個KV, 然後我們設置HashMap(7), 經過jdk處理後,會被設置成8. 但是,這個HashMap在元素個數達到8*0.75=6的時候就會擴容了, 這不是我們希望看到的, 我們應該儘量減少擴容.
也即是說, 如果我們通過 initialCapacity/0.75 + 1.0
計算: 7 / 0.75 + 1 =10, 經過jdk處理後, 會被變成16 , 這便大大減少了擴容機率.
簡單說就是: 你有7個元素需要HashMap操作, 通過計算使用HashMap(10)
, 這時內部自動幫你擴容到16,threshold爲12. 當然這麼操作會犧牲一些內存.