HashMap源碼解析,擴容機制及其思考

1.概述

HashMap是平常java開發中經常使用的類之一,是java設計中很是經典的一個類,它巧妙的設計思想與實現,還有涉及到的數據結構和算法,,值得咱們去深刻的學習。html

簡單來講,HashMap就是一個散列表,是基於哈希表的Map接口實現,它存儲的內容是鍵值對 (key-value) 映射,而且鍵值容許爲null(鍵的話只容許一個爲null)。java

1.1 注意事項

①根據鍵的hashCode存儲數據。(String,和Integer、Long、Double這樣的包裝類都重寫了hashCode方法,String比較特殊根據ascil碼還有本身的算法計算,Double作位移運算【具體看源碼的hashcode實現】,Integer,Long包裝類則是自身大小int值),
HashMap中的結構不能有基本類型,一方面是基本類型沒有hashCode方法,還有HashMap是泛型結構,泛型要求包容對象類型,而基本類型在java中不屬於對象。
②HashMap的存儲單位是Node<k,v>,能夠認做爲節點。
③Hashmap中的擴容的個數是針對size(內部元素(節點)總個數),而不是數組的個數。好比說初始容量爲16,第十三個節點put進來,無論前面十二個佔的數組位置如何,就開始擴容。node

1.2 hashmap幾個特徵

特徵 說明
是否容許重複數據 key若是重複會覆蓋,value容許重複
hashMap是否有序 無序,這裏的無序指的是遍歷HashMap的時候,獲得的順序大都跟put進去的順序不一致
hashMap是否線程安全 非線程安全,由於裏面的實現不是同步的,若是想要線程安全,推薦使用
鍵值是否容許爲空 key和value都容許爲空,但只容許一個爲空

2.一些概念

2.1.位運算

位運算是對整數在內存中的二進制位進行操做。算法

在java中 >> 表示右移 若該數爲正,則高位補0,若爲負數,高位補1數組

<<表示左移 跟右移相反 若是是正數在低位補0緩存

例如20的二進制爲0001 0100 20>>2爲 0101 0000 結果爲5(左高右低)安全

20<<2 爲 0101 0000 則爲80bash

java中>>>和>>的區別數據結構

>>>表示無符號右移,也叫邏輯右移。無論數字是正數仍是負數,高位都是補0
複製代碼

在hashMap源碼中有不少使用位運算的地方。例如:多線程

//之因此用1 << 4不直接用16,0000 0001 -> 0001 0000 則爲16,若是用16的話最後其實也是要轉換成0和1這樣的二進制,位運算的計算在計算機中是很是快的,直接用位運算表示大小以二進制形式去運行,在jvm中效率更高。
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;  //初始化容量
複製代碼

注意:左移沒有<<<運算符

2.2 位運算符-(與(&)、非(~)、,或(|)、異或(^))

①與運算(&)

咱們都知道&在java中表示與操做&表示按位與,這裏的位是指二進制位。都爲1才爲真(1),不然結果爲0,舉個簡單的例子

System.out.println(9 & 8); //1&1=1,1&0 0&1 0&0都=0,所以1001 1000 -> 1000 輸出爲8
複製代碼
②非運算(~)

源碼 -> 取反 -> 反碼 -> 加1 -> 補碼 -> 取反 -> 按位非值

在Java中,全部數據的表示方法都是以補碼的形式表示,若是沒有特殊說明,Java中的數據類型默認是int,int數據類型的長度是8位,一位是四個字節,就是32字節,32bit.

例如5的二進制爲0101

補碼後爲 00000000 00000000 00000000 00000101

取反後爲 11111111 11111111 11111111 11111010

【由於高位爲1 因此源碼爲負數,負數的補碼是其絕對值源碼取反,末尾再加1】

因此反着來末尾減1獲得反碼而後再取負數

末位減1:11111111 11111111 11111111 11111001

【後八位前面4位不動 後面 減1 1010減1 至關於 10-1爲9 後四位就是 1001 】

取反後再負數: 00000000 00000000 00000000 00000110 爲-6

System.out.println(~ 5); //輸出-6
複製代碼
③或運算(|)

只要有一個爲1,結果爲1,不然都爲0

System.out.println(5 | 15); //輸出爲15,0101或上1111,結果爲1111
複製代碼
④異或運算(^)

相同爲0(假),不一樣爲真(1)

System.out.println(5 ^ 15); //輸出10 0101異或1111結果爲1010
複製代碼

2.3 hashcode

hash意爲散列,hashcode是jdk根據對象的地址或者字符串或者數字算出來的int類型的數值,頂級父類Object類中含hashCode方法(native本地方法,是根據地址來計算值),有一些類會重寫該方法,好比String類。

重寫的緣由。爲了保證一致性,若是對象的equals方法被重寫,那麼對象的hashcode()也儘可能重寫。

簡單來講 就是hashcode()和equals()需保持一致性,若是equals方法返回true,那麼兩個對象的hashCode 返回也必須同樣。

不然可能會出現這種狀況。

假設一個類重寫了equals方法,其相等條件爲屬性相等就返回true,若是不重寫hashcode方法,那麼依據就是Object的依據比較兩個對象內存地址,則必然不相等,這就出現了equals方法相等可是hashcode不等的狀況,這不符合hashcode的規則,這種狀況可能會致使一系列的問題。

所以,在hashMap中,key若是使用了自定義的類,最好要合理的重寫Object類的equals和hashcode方法。

2.4 哈希桶

哈希桶的概念比較模糊,我的理解是數組表中一塊區域結果下面的單向鏈表組成的,在hashmap中,這個單向鏈表的頭部是所在數組上第一個元素,單向鏈表若是過長超過8,那麼這個"桶"就可能變成了紅黑樹(前提是數組長度達到64)。

2.5 hash函數

在程序設定中,把一個對象經過某種算法或者說轉換機制對應到一個整形。

主要用於解決衝突的。

2.6 哈希表

也稱爲散列表,這也是一種數據結構,能夠根據對象產生一個爲整數的散列碼(hashCode)。

hash衝突

HashMap之因此有那麼快的查詢速度,是由於他的底層是由數組實現,經過key計算散列碼(hashCode)決定存儲的位置,HashMap中經過key的hashCode來計算hash值,只要hashCode相同,hash值也同樣,可是可能存在存的對象多了,不一樣對象計算出的hash值相同,這就是hash衝突。

舉個例子

HashMap<String,String> map = new HashMap<String,String>();
map.put("Aa", "haha");
map.put("BB","heihei");
System.out.println("Aa".hashCode()); //2112
System.out.println("BB".hashCode()); //2112
//這裏的Aa和BB爲String型,String類重寫了hashCode方法(根據ascil碼和特定的算法來計算,雖然很巧妙但也難以免不對對象hashCode相同的狀況),Aa和BB的hashCode值相同,相同的HashCode的hash值相同 
//根據源碼就算key不相同 但key.hashCode()相同 則會返回相同的hash,致使hash衝突
static final int hash(Object key) {//取關鍵key的hash值
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);//任何小於2的16次方的數 右移16位都爲0 2的16次方>>>16恰好爲1 任何一個數和0按位異或都爲這個數自己(1和0爲1 0和0爲0),因此這個hash()函數對於null的hash值 僅在hashcode大於2的16次方纔會調整值,這邊16設計的很巧妙,由於int恰好是32位的取中間位數
}
複製代碼

2.7 二叉查找樹和紅黑樹

紅黑樹是一種自平衡二叉查找樹。是一種數據結構,又稱二叉b樹,(→_→ 2b樹?),紅黑樹本質上也是二叉查找樹。因此先理解下二叉查找樹。

2.7.1二叉查找樹

二叉查找樹,又稱有序二叉樹,已排序二叉樹
它的三大特色以下
1.左子樹上全部結點的值均小於或等於它的根結點的值。
2.右子樹上全部結點的值均大於或等於它的根結點的值。
3.左、右子樹也分別爲二叉排序樹。
複製代碼
img_15bc610b523331178a92ad63dcfac5f8.png
二叉樹.png

2.7.2 紅黑樹(RBTree)

因爲二叉查找樹可能存在難以平衡呈線性的缺陷,因此出現的紅黑樹的概念。顧名思義,紅黑樹是隻有紅色和黑色節點的二叉樹。
它的5大性質以下。
1.節點是紅色或黑色。
2.根節點是黑色。
3.每一個葉子節點都是黑色的空節點(NIL節點)。
4 每一個紅色節點的兩個子節點都是黑色。(從每一個葉子到根的全部路徑上不能有兩個連續的紅色節點)
5.從任一節點到其每一個葉子的全部路徑都包含相同數目的黑色節點。
複製代碼

簡單來講紅黑樹是一種自平衡二叉查找樹,相比於普通的二叉查找樹,它的數據結構更爲複雜,可是在複雜的狀況也能經過自平衡(變色,左右旋轉)保持良好的性能。

關於紅黑樹,很形象的一組漫畫,查看這裏

在線模擬紅黑樹增刪的地址地址1地址2

紅黑樹的時間複雜度爲【吐槽下簡書這邊若是用數學公式太蛋疼了】:

O(logn)

它的高度爲:[logN,logN+1](理論上,極端的狀況下能夠出現RBTree的高度達到2
logN,但實際上很難遇到)。
*

此外,因爲它的設計任何不平衡將在三次旋轉內解決。

紅黑樹和avl樹(最先的自平衡二叉樹)的比較:
avl更加平衡,查詢速率稍強於紅黑樹,可是插入和刪除紅黑樹完爆avl樹,可能因爲hashMap的增刪也挺頻繁的,因此綜合考慮而選擇紅黑樹。
複製代碼

總結:紅黑樹是種能夠經過變色旋轉的自平衡二叉查找樹,對於hashMap來講,使用紅黑樹的好處在於,當有多個元素hash相同在同一數組下標的時候,使用紅黑樹在查找這些hash衝突的元素更快,它的時間複雜度從遍歷鏈表O(n)降到O(logN)。

2.8 複雜度

算法複雜度分時間複雜度和空間複雜度。

時間複雜度:執行算法所須要的計算工做量
空間複雜度:執行算法所須要內存空間大小
時間和空間都是計算機資源的體現,算法的複雜性體如今運行該算法時計算機所需資源的大小。
複製代碼

這裏重點講下時間複雜度

(1)時間頻度
用T(n)表示
一個算法執行所消耗的時間,理論上不能算出來而是經過運行測試得知,但不可能也不必對每一個算法都作上機測試,只需知道哪一個算法花費時間多哪一個花費少便可。在算法中一個算法花費的時間和這個算法執行的次數成正比。
在一個算法中,語句執行次數稱爲時間頻度(或稱爲語句頻度),記作爲T(n),這裏的n表明問題的規模。暫且不考慮這個T是啥,把它理解爲一個函數。
(2)時間複雜度 
用O(f(n))表示
當n變化時,時間頻度T(n)也會不斷變化,可是它是個不肯定的函數,咱們想知道它呈現的規律是什麼樣的。這個時候引入了時間複雜度的概念。
前面說T(n)是個不肯定的函數,它表明算法中基本操做重複執行的次數是問題規模n的某個函數。
假設有某個輔助函數f(n),當n趨近∞,T(n)/f(n)的極限值不爲0切位常數,那麼能夠認爲f(n)和T(n)爲同一數量級的函數,記作爲T(n)=O(f(n)),稱O(f(n)) 爲算法的漸進時間複雜度,簡稱時間複雜度。

f(n)雖然沒有規定但通常都儘量取簡單的函數
例如 O(2n²+n +1) = O (3n²+n+3) = O (7n² + n) = O ( n² ) 省去了係數,只保留最高階項。
時間頻度不一樣時,時間複雜度有可能相同,例如T(n)=n²+3n+4與T(n)=4n²+2n+1它們的頻度不一樣,但時間複雜度相同,都爲O(n²)。

總結二者關係:時間複雜度就是對時間頻度函數的一層包裝,它的特色(大O表示法)爲
①省去係數爲1處理②保留最高項
若是把T(n)當作爲一棵樹,那麼O(f(n))只關心其主幹部分。
複製代碼

常見算法的時間複雜度從小到大依次爲

img_8f75b001aae9edc9b86bb1e9fc7c7f6d.png
複雜度比較

求解算法的時間複雜度具體步驟爲:
①找出算法中執行次數最多的基本語句,通常是最內層的循環體。
②計算基本語句的數量級
③將基本語句執行次數的數量級放入大O記號中
複製代碼

舉幾個例子

O(1),又稱常數階,通常來講算法中沒有循環體,執行次數爲常數那麼時間複雜度就爲O(1),例如

int sum = 0,n = 100; //執行一次  
sum = (1+n)*n/2; //執行一次  
System.out.println (sum); //執行一次 
//上面的算法運行次數爲f(n)=3,那麼根據大O表示法,該算法的時間複雜度爲O(1)
複製代碼

爲何O(logN),對數階不用底數

如紅黑樹的查找複雜爲O(logN)

這裏面有個可能存在的疑問,有時候時間複雜度都用包含O(logN)這樣的描述 可是沒有明確說明n的底數是多少,一般底數爲2來計算

這種描述其實也是合理的,算法中log級別的時間複雜度都是因爲使用了分治思想,這個底數直接由分治的複雜度決定。當n趨近於無窮大,兩個大小比較也只是一個常數,因此這種時候O(logN)統一表明對數複雜度。
\lim_{n\rightarrow+\infty} Ο(\log_x{n})/Ο(\log_y{n}) = C

其它簡單舉例

描述 增加數量級 典型代碼 說明
常數階 1 a = b + c 普通簡單算法操做
對數階 logN 二叉樹中的二分法 二分策略
線性級別 N for(int i = 0;i < 10; i++) {...} 普通單層循環算法
平方級別 for(int i = 0;i < 10; i++) {for(int j = 0; j < 10) {...}} 雙層循環,例如冒泡排序
指數級別 2的n次方 一個揹包大小必定時,找出不大於揹包全部物品組合,假設有3個物品,a,b,c,可能的組合有8種。(a,b,c,ab,ac,bc,abc+空(揹包過小一個都容納不下)) 窮舉查找(揹包問題www.cnblogs.com/tinaluo/p/5…)

3. HashMap的內部實現(基於jdk1.8)

剛開始看hashMap源碼的時候,感受思路很亂不知道寫的啥東西,因此仍是得從它的【數據結構】開始入手。

img_550edf98975a3d557599d6792129fba5.jpe
不一樣於通常類的數據結構,從結構來說 HashMap = 數組 + 鏈表 + 紅黑樹(1.8開始加入,大程度的優化了HashMap的性能)
arrayList  數組
linkedList 雙向鏈表 查詢效率慢,需經過遍歷,新增或刪除快,好比說刪除一個元素 知道那個元素的上下引用 並改變關聯上下元素的引用指向便可。
複製代碼

3.1 數組和鏈表

img_fdee83a22ddec90d5d4b5779804d0cd8.png
數組和鏈表.png

3.2 HashMap數據結構(數組+鏈表+紅黑樹)

在jdk8之前,若是發生頻繁碰撞的話,查找時間複雜度是O(1) + O(n) (先找在數組的位置再找鏈表),n若是比較大則嚴重影響了查找性能,而到了jdk8引入紅黑樹,O(1) + O(logN)。

img_97ec833243a8d841f179b1fd3d54c982.png
hashmap.png

大體思路

①數組的優勢是查詢快,鏈表的優勢是增刪快,紅黑樹查詢性能較好,hashMap的存儲方式結合了它們的優勢,那麼hashMap的存儲單元又能夠在數組裏,又能夠在某個數組下的鏈表裏。還有可能在紅黑樹當中。
②咱們已經知道HashMap是鍵值對的存在,且能夠爲各類類型,那麼它又是以鍵值對的方式存在,它的最小存儲單位是以Node節點爲存儲單位。
這個Node結構大概有Key,Value,記錄所在數組索引,以及記錄鏈表指針的東西。
大概結構以下
static class Node<K,V> implements Map.Entry<K,V> {
  final int hash;
  final K key;
  V value;
  Node<K,V> next;
  ...
}

③新來的Node節點怎麼放?
HashMap利用hashcode來肯定存放的位置,可是又有個疑問,假設map對象key爲String型
HashMap<String, String> map = new HashMap<String, String>();
map.put("1", "first");

//這個時候看put方法 
put方法的大體思路爲
①對key作hash運算,經過hash值計算index下標位置
②若是沒衝突直接放在桶上
③若是衝突了,以鏈表的形式存在桶裏面,達到必定條件鏈表變爲紅黑樹
④若是節點已經存在,則替換舊的value(保證惟一性)
⑤若是桶的個數超過了 加載因子乘當前容量,則作resize操做

//能夠注意到有個hash函數
public V put(K key, V value) {
   return putVal(hash(key), key, value, false, true);
}

//hash函數 
static final int hash(Object key) {
   int h;
   return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

//上述代碼String類型的1的Hashcode爲49超過了HashMap的初始長度16,這個時候"1"這個key放在哪。這裏
//經過巧妙的設計存放在合適的位置 4.3.3作分析
p = tab[i = (n - 1) & hash],


//這裏的p爲Node<K,V>對象,n爲當前哈希桶數組長度,進行與運算後,由於這是第一個插入的元素,無需擴容長度爲16,那麼49 & 15 = 1,說明在的第二個位置。

④新節點插入後何時開始擴容
接下來不斷的插入的元素 通過hash函數和計算索引位置後,均可以根據它的散列性插入到不一樣的16個位置,
當元素個數達到16 * 0.75 即12時,繼續插入新的時候,開始擴容。
【這裏注意一下並非說佔滿12個位置纔開始擴容,而是12個節點,根據散列性分佈12個節點,佔...5,6,7,8...個位置都有可能,好比說key爲Integer類型,假如key爲Integer類型,有五個節點key分別爲3,19,12,28,44這個時候3,19在同一個位置,12,28,44在同一個位置,這個時候5個節點就佔了兩個位置】


⑤resize()方法進行擴容操做。
1.先判斷節點數組是否爲空,並取它的容量(節點個數),建立新數組,大小時新的capacity
若是不爲空:
若是容量超過最大值不作擴容,不然位運算一位作容量乘2處理,
若是爲空:
桶數組容量爲默認容量16,即有默認放16個桶,閾值默認爲默認容量乘默認加載因子 12
2.將舊數組的元素放到新數組中,從新作映射
若是舊的數組不爲空,則遍歷桶數組,並將鍵值對映射到新的桶數組中[樹節點和鏈表節點作不一樣操做]
複製代碼

4.源碼分析

4.1 基本存儲單位Node節點

static class Node<K,V> implements Map.Entry<K,V> { //實現Entry接口 存儲的是鍵值對的映射
    final int hash; //hash值,用於記錄數組所在位置
    final K key; //用於匹配
    V value; //值
    Node<K,V> next; //用於記錄單鏈表下一節點 用於解決hash衝突(即hash值同樣該存在哪裏的問題)
    Node(int hash, K key, V value, Node<K,V> next) {
        this.hash = hash;
        this.key = key;
        this.value = value;
        this.next = next;
    }
    public final K getKey()        { return key; }
    public final V getValue()      { return value; }
    public final String toString() { return key + "=" + value; }
    public final int hashCode() {
        return Objects.hashCode(key) ^ Objects.hashCode(value);
    }
    public final V setValue(V newValue) {//賦值
        V oldValue = value;
        value = newValue;
        return oldValue;
    }
    public final boolean equals(Object o) {
        if (o == this)
            return true;
        if (o instanceof Map.Entry) {
            Map.Entry<?,?> e = (Map.Entry<?,?>)o;
            if (Objects.equals(key, e.getKey()) &&
                Objects.equals(value, e.getValue()))
                return true;
        }
        return false;
    }
}
複製代碼

4.2 HashMap中的幾個重要實現:hash函數,put、get、resize

//put
public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}
複製代碼
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    //哈希表數組節點 
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    //若是爲空 調用resize以默認大小16擴容 
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    //經過(n - 1) & hash計算存放索引位置 此處設計很巧妙
    if ((p = tab[i = (n - 1) & hash]) == null)
      //若是tab[i]爲空 該下標下沒有節點 則直接新建一個Node放在該位置 
        tab[i] = newNode(hash, key, value, null);
    else {
        //下標上有節點 說明有hash衝突
        Node<K,V> e; K k;
        //若是插入的新節點key已經存在,那麼直接覆蓋整個節點
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        //若是爲紅黑樹節點
        else if (p instanceof TreeNode)
            //調用紅黑樹插入鍵值對的putTreeVal方法
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else {
            //無論tab[index]是否爲空,p節點已經爲 tab[index]上
            //若是有衝突 且不爲紅黑樹節點 那麼此時遍歷鏈表節點 binCount計算鏈表長度
            for (int binCount = 0; ; ++binCount) {
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                   //鏈表長度大於等於7,調用treeifyBin對鏈表進行樹化
                    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;
            }
        }
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    //插入成功後 再根據實際判斷是否到到閾值 好比說如今容量16(桶的個數16) 正在插第13個元素時 到達則擴容 
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}
複製代碼

get方法

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) {
        if (first.hash == hash && // always check first node 
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        if ((e = first.next) != null) {
            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;
}
複製代碼

4.4.5 resize()

擴容就是從新定義容量,在hashmap中,若是不斷的put元素,而hashMap對象中的數組沒法裝得下更多對象時,對象就須要進行擴容,擴大數組長度。這邊注意的是:

①假如初始大小爲默認值16,何時擴容,咱們能夠知道閾值是16
0.75即12,這個12是指hashMap的size(全局變量,每次put+1.remove-1),put後爲大於12即13時開始執行resize方法擴容。
*

在java中數組是不可以自動擴容的,是採用一個新的大容量數組代替原有的小數組,就比如用一個小桶裝水,若是想用一個桶裝更多的水,就換一個大桶再把原來小桶的水裝過去。

③擴容後,普通鏈表上的節點包括紅黑樹都得從新映射。

對於hashmap來講
何時換大桶:達到閾值的時候
換多大的桶:原有小桶的兩倍大小
但桶的大小也是有限的,對於hashMap,最大的桶能容納包含2^30個數,大於的話就再也不擴容,就隨裏面碰撞了。(實際上也很難用到這麼大的容量)

final Node<K,V>[] resize() {
    //table爲全局變量transient Node<K,V>[] table; 賦值給oldTab
    Node<K,V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;//舊錶數組個數
    int oldThr = threshold;
    int newCap, newThr = 0;
    if (oldCap > 0) { //若是舊容量大於0    
        //超過最大值就不擴容了,隨它碰撞去吧 -。-
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        //×2還沒超過最大值,新數組就擴容爲原來兩倍 閾值也作×2處理
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold 
    }
    //若是原來的閾值 > 0且舊容量爲0,則將新容量設爲原來的閾值,初始化有參給threshold賦值會有此狀況
    else if (oldThr > 0) 
        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) {
                oldTab[j] = null;
                //若是該桶只有一個節點(說明下面沒有鏈表,或者說只有一個鏈表節點)
                if (e.next == null)
                    //e.hash & (newCap - 1)肯定元素存放位置
                    newTab[e.hash & (newCap - 1)] = e;
                else if (e instanceof TreeNode)
                    //紅黑樹節點
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                else { 
                    //鏈表節點且當前鏈表節點不止1個
                    Node<K,V> loHead = null, loTail = null;
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    do {
                        next = e.next;
                        //根據e.hash & oldCap 判斷節點存放位置
                        //若是爲0 擴容還在原來位置 若是爲1 新的位置爲 舊的index + oldCap 下面如何擴容有作介紹
                        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;//將鏈表的尾節點的next設置爲空
                        newTab[j] = loHead;
                    }
                    if (hiTail != null) {
                        hiTail.next = null;// 將鏈表的尾節點 的next 設置爲空
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}
複製代碼

4.3 HashMap經典代碼 p = tab[i = (n - 1) & hash])

p = tab[i = (n - 1) & hash])
複製代碼

當hashCode小於65536,散列是很規律的,基本上索引的位置就是

由於小於這個數右移16爲都爲0,且和佔位符都爲0的值異或後的hashcode就是自身的值。

這個值比較特殊

轉換爲二進制:00000000000000010000000000000000,右移16的話00000000000000000000000000000001並不全爲0

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
複製代碼

key的hashcode爲65536

轉爲二進制:h=key.hashCode() 00000000000000010000000000000000

跟右移16位的再作異或操做 00000000000000000000000000000001

hash = h ^(h>>>16) 00000000000000010000000000000001

計算hash 00000000000000010000000000000001

​ 00000000000000000000000000001111

結果 1

可是65536 % 16 = 0

key的hashcode爲17 異或相同爲0 不一樣爲假

轉爲二進制:h=key.hashCode() 00000000000000000000000000010001

跟右移16位的再作異或操做 00000000000000000000000000000000

hash = h ^(h>>16) 00000000000000000000000000010001

計算hash 00000000000000000000000000010001

​ 00000000000000000000000000001111

​ 00000000000000000000000000000001

作個小測試,假設這個時候桶的個數爲16,代碼以下

for (int key = 65533; key < 65543; key++) { //從65536開始變得有點"特別"
    System.out.println("key爲:" + key +  ",索引位置:" + ((key ^ (key >>> 16)) & 15));//假設初始容量爲16 測試沒擴容時這些數的索引位置
}
//輸出結果爲,能夠發現從65536開始不爲0而是1,有點特殊,而後相鄰兩個索引位置呈1,3的增加,具體可畫圖嘗試
i爲:65533,輸出13
i爲:65534,輸出14
i爲:65535,輸出15
i爲:65536,輸出1
i爲:65537,輸出0
i爲:65538,輸出3
i爲:65539,輸出2
i爲:65540,輸出5
i爲:65541,輸出4
i爲:65542,輸出7
複製代碼

這段代碼主要是計算索引位置的,HashMap 底層數組的長度老是 2 的 n 次方

當 length 老是 2 的倍數時,h& (length-1),將是一個很是巧妙的設計:

hash值 length(假設長度爲16) h & length - 1
5 16 5
6 16 6
15 16 15
16 16 0
17 16 1

能夠看到計算獲得的索引值老是位於 table 數組的索引以內。而且一般分佈的比較均勻

4.4 樹形化treeifyBin()

在jdk8之前,若是發生頻繁碰撞的話,查找時間複雜度是O(1) + O(n) (先找在數組的位置再找鏈表),n若是比較大則嚴重影響了查找性能,而到了jdk8引入紅黑樹,O(1) + O(logN)。

jdk1.8中,若是一個桶中元素個數超過TREEIFY_THRESHOLD(8)時,就用紅黑樹替換鏈表以提高速度(主要是查找)

//將桶內全部鏈表節點換成紅黑樹節點
final void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index; Node<K,V> e;
    //若是當前哈希表爲空 或者哈希表中元素 MIN_TREEIFY_CAPACITY默認爲64,對於這個值能夠認爲,若是節點數組長度小於64,就不必去進行結構轉換,而是經過resize()操做,這樣原先一個鏈表的元素可能會進行從新分配。
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
        resize(); //擴容
    //大於等於64 就樹化 鏈表上的普通節點變成樹節點
    else if ((e = tab[index = (n - 1) & hash]) != null) {      
        TreeNode<K,V> hd = null, tl = null; //定義首、尾節點
        do {
            TreeNode<K,V> p = replacementTreeNode(e, null); //普通節點 -> 樹節點
            if (tl == null) //若是尾節點爲空 說明尚未根節點
                hd = p; //首節點(根節點) 指向當前節點
            else { //尾節點不爲空 
                p.prev = tl; //當前樹節點前一個節點指向尾節點
                tl.next = p; //尾節點後一個節點 指向當前節點
            }
            tl = p; 
        } while ((e = e.next) != null); //繼續遍歷鏈表
      
        //這個時候只是把Node對象變成TreeNode對象,把單向鏈表變成雙向鏈表
        if ((tab[index] = hd) != null)
            hd.treeify(tab);
    }
}
複製代碼

5.思考

1.HashMap和HashTable的區別是什麼

HashMap和Hashtable都實現了Map接口

HashMap功能上幾乎能夠等價於Hashtable,除了HashMap是非synchronized的,並能夠接受null(HashMap能夠接受爲null的鍵值(key)和值(value),而Hashtable則不行)。
HashMap是非synchronized,而Hashtable是synchronized,這意味着Hashtable是線程安全的
因爲Hashtable是線程安全的也是synchronized,因此在單線程環境下它比HashMap要慢。若是你不須要同步,只須要單一線程,那麼使用HashMap性能要好過Hashtable。
HashMap不能保證隨着時間的推移Map中的元素次序是不變的。

因爲性能問題,以及HashTable處理Hash衝突比HashMap遜色不少,如今HashTable已經不多使用了。但因爲線程安全以及之前的項目還在使用,SUN依然還保留着它並無加Deprecated過期註解。

摘自hashtable源碼

If a thread-safe implementation is not needed, it is recommended to use HashMap in place of Hashtable. If a thread-safe highly-concurrent implementation is desired, then it is recommended to use java.util.concurrent.ConcurrentHashMap in place of Hashtable.

簡單來講就是不須要線程安全,那麼使用HashMap,若是須要線程安全,那麼使用ConcurrentHashMap。

2.HashMap爲何線程不安全,若是想要線程安全怎麼作

由於hashmap爲了性能,它的put,resize等操做都不是同步的,假設兩個線程同一時間作put操做,可能最後計算的size並不正確,值得一提的是jdk1.8之前多線程put甚至會致使閉環死循環,1.8開始不會有這個問題但依然存在線程安全問題。

jdk8前的閉環死循環。

這種問題在單線程下不存在,但在多線程下可能引發死循環致使cpu佔用太高。

若是hash衝突大,同一鏈表下下有多個節點容易出現這種問題。具體參考老生常談,HashMap的死循環

若想要線程安全
一、使用ConcurrentHashMap。(線程安全的hashMap)
二、使用Collections.synchronizedMap(Mao<K,V> m)方法把HashMap變成一個線程安全的Map。
複製代碼

3.HashMap是怎麼解決Hash衝突的

在實際應用中,不管怎麼構造哈希函數,衝突也難以徹底避免。
HashMap根據鏈地址法(拉鍊法)來解決衝突,jdk8中若是鏈表長度大於8且節點數組長度大於64的時候,就把鏈表下全部節點轉爲紅黑樹,位於數組上的節點爲根節點,來維護hash衝突的元素,鏈表中衝突的元素能夠經過key的equals()方法來肯定。
複製代碼

4.HashMap是怎麼擴容的

先寫個例子測試hashMap有沒有在擴容。

public static void main(String[] args) throws NoSuchMethodException, InvocationTargetException, IllegalAccessException {
    HashMap<Integer,String> o = new HashMap<>(1);
    System.out.println(o.size()); //0 size爲元素個數
    //擴容條件是 若是沒有定義初始容量 默認擴容至16 若是沒有 根據put的狀況擴容
    //put的過程當中 若是插入一個元素事後的size > 閾值(加載因子 * 最近容量)
    /**
     * 代碼體現 put後執行
     *   if (++size > threshold)
     *         resize();
     */
    //有定義容量的話會採用大於這個數的最小二次冪 第一次初始化爲1 則輸出爲2 4 5 11  111 11
    HashMap<Integer,String> map = new HashMap<>(1);
    map.put(1, "一");
    //因爲方法由final修飾 利用反射機制獲取容量值
    Class<?> mapType = map.getClass();
    Method capacity = mapType.getDeclaredMethod("capacity");
    capacity.setAccessible(true); //因爲capacity方法由final修飾 暴力獲取
    System.out.println("capacity : " + capacity.invoke(map)); //capacity : 2
 
    map.put(2, "二");
    capacity = mapType.getDeclaredMethod("capacity");
    capacity.setAccessible(true);
    System.out.println("capacity : " + capacity.invoke(map)); //capacity : 4 當前容量爲2 插入該元素後size爲 2 > 2 * 3/4 開始擴容

    //當前容量爲4 此時已有2個 3 = 4 * 3/4 不進行擴容
    map.put(3, "三");
    capacity = mapType.getDeclaredMethod("capacity");
    capacity.setAccessible(true);
    System.out.println("capacity : " + capacity.invoke(map)); //capacity : 4 當前容量爲2 插入該元素後size爲 3 = 4 * 3/4 不擴容

    map.put(4, "四");
    capacity = mapType.getDeclaredMethod("capacity");
    capacity.setAccessible(true);
    System.out.println("capacity : " + capacity.invoke(map));//capacity : 8  當前容量爲4 此時已有4個 4 > 4 * 3/4 開始擴容
}
複製代碼

上面的例子能夠看出put後,hashmap確實有進行擴容,hashMap的擴容機制與其它的集合邊長不太同樣,它是經過當前hash桶個數乘2進行擴容

hashMap主要是經過resize()方法擴容

假設oldTable的key的hash爲15,7,4,5,8,1,hashMap爲初始容量爲8的數組桶,存儲位置以下

index 0 1 2 3 4 5 6 7
hash 8 1 4 5 7,15

當put一個新元素 假設爲9,且加載因子使用默認的0.75,在內存空間中新的存儲位置以下

index 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
hash 1 4 5 7 8 9 15

能夠看到擴容以後8跑到了第9個位置,15跑到了第16個位置,舊的8,1,4,5在各自的鏈表上只有一個節點

根據 e.hash & (newCap - 1) 至關於 與上15後,都爲本身自己因此位置保持不變

可是鏈表上不止有一個節點的狀況,好比說上面的7,15存放的位置

這個時候是先根據 e.hash & oldCap判斷元素在數組的位置是否須要移動

好比說 7 & 8 = 0111 & 1000 = 0 ; 15 & 8 = 1111 & 1000 = 1,規律是比較高位的第一個 好比說15爲高位,第一個爲1,若是高位爲1那麼與後結果也爲1

當e.hash & oldCap == 0時

鏈表上節點位置保持不變

當e.hash & oldCap == 1時

鏈表上節點的位置爲原位置的index + oldCap 好比說15,新的索引位置爲7+8爲15

值得一提的是,jdk1.8的resize()方法相比與以前作了點優化,JDK1.7中rehash的時候,舊鏈表遷移新鏈表的時候,若是在新表的數組索引位置相同,則鏈表元素會倒置,但JDK1.8不會倒置,jdk8經過e.hash & oldCap,經過0和1的值均勻把以前的衝突的節點分散到新的bucket了,這樣作更爲高效。

代碼見【4.4.5 resize()方法】

5.loadFactor加載因子爲什麼爲0.75f

加載因子是哈希表在其容量自動增長以前能夠達到多滿的一種尺度,它衡量的是一個散列表的空間的使用程度,負載因子越大表示散列表的裝填程度越高,反之越小。
簡單來講就是若是加載因子過小,空間利用率低,且太容易擴容對性能不太友好,設置過高,不及時擴容容易致使衝突概率大,將提升了查詢成本。因此0.75是很合適的值,通過試驗,在理想狀況下,使用隨機哈希碼,節點出現的頻率在hash桶中遵循泊松分佈【在頻率附近發生機率高,向兩邊對稱降低。】
詳細見 爲何HashMap中默認加載因子爲0.75

6.hashMap中通常使用什麼類型的元素做爲key,爲何?

經常使用String,Integer這樣的key
主要緣由爲
這些類是Immutable(不可變的),String和基本類型的包裝類規範的重寫了hashCode()和equals()方法。做爲不可變類天生是線程安全的,並且能夠很好的優化好比能夠緩存hash值,避免重複計算等等,若是採用可變的對象類型,可能出現put進去就沒法查詢到的狀況。
若是想用自定義的類型做爲鍵,那麼須要遵照equals()和hashCode()方法的定義規則且不可變,對象插入到map後就不會再改變。

HashMap的key能夠是可變對象嗎?

7.源碼中爲何要用transient修飾桶數組table

transient Node<K,V>[] table;
複製代碼

在java中,被transient關鍵字修飾的變量不會被默認的序列化機制序列化。

hashMap實現了Serializable接口,經過實現readObject/writeObject兩個方法自定義了序列化的內容,size不用多說了,通常涉及到大小能夠直接計算的就不必再序列化。

爲何不序列化table?緣由有下

1.table大多數狀況是沒法存滿的。好比說桶數組容量是16,只put了一個元素,這會形成序列化未使用的部分。形成浪費。

2.同一個鍵值對在不一樣jvm下,所處桶的位置多是不一樣的,在不一樣的jvm下反序列化可能發生錯誤。(hashmap的get/put/remove等方法剛開始都是經過hash找到鍵所在的桶位置,就是數組下標,但若是鍵沒有重寫hashCode方法,就會調用Object的hashCode方法,而Object的hashcode方法是navtive(本地方法)的,這裏的hashcode是對對象內存地址的映射得出的int結果,具體怎麼計算不得而知,可是在不一樣jvm下,可能有不一樣的hashcode實現,這樣產生的hash也不同)。

8.HashMap的key若是爲null,怎麼查找值

咱們知道hashMap只容許一個爲null的key,若是key爲null,由於key爲null,那麼hash爲0,那麼p = tab[i = (n - 1) & hash 也必定爲0,因此是從數組上第一個位置的鏈表下查找。

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
複製代碼

6.使用建議

1.默認狀況下HashMap的容量是16,可是,若是用戶經過構造函數指定了一個數字做爲容量,那麼Hash會選擇大於該數字的第一個2的冪做爲容量。(1->二、7->八、9->16)

在初始化HashMap的時候,應該儘可能指定其大小。尤爲是當你已知map中存放的元素個數時。(《阿里巴巴Java開發規約》)

這邊能夠看下hashMap的4個構造方法,通常採用3,但若是已經知道個數,建議用2(加載因子0.75很合適不建議改動)

//1 自定義傳初始容量和加載因子
public HashMap(int initialCapacity, float loadFactor) {
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal initial capacity: " +
                                           initialCapacity);
    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;
    if (loadFactor <= 0 || Float.isNaN(loadFactor))
        throw new IllegalArgumentException("Illegal load factor: " +
                                           loadFactor);
    this.loadFactor = loadFactor;
    this.threshold = tableSizeFor(initialCapacity);
}

//2 自定義初始大小 調1構造方法,加載因子使用默認大小
public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

//3 最經常使用的無參構造方法
public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}

//4 將別的map對象映射到自身存儲,不多用
public HashMap(Map<? extends K, ? extends V> m) {
    this.loadFactor = DEFAULT_LOAD_FACTOR;
    putMapEntries(m, false);
}
複製代碼

這邊講解一下tableSizeFor方法。簡述一下該方法的做用:

若是自定義容量大小時(調1或2的構造方法),傳入一個初始容量大小,大於輸入參數且最近的2的整數次冪的數。好比10,則返回16,75返回128

不這麼作的缺點

假設HashMap須要放置1024個元素,因爲沒有設置初始容量大小,隨着元素不斷增長,容量7次被迫擴大。而resize過程須要重建hash表,這會嚴重影響性能。

/**
 * Returns a power of two size for the given target capacity.
 */
static final int tableSizeFor(int cap) {
    //cap-1的目的是由於若是cap是2的冪數不作-1操做的話 那麼最後執行完右移操做的話,返回的值將會是原有值得兩倍。若是n爲0的話,即cap=1,通過後面幾回操做返回的爲0,最後返回的capacity仍然爲1(最後有加1的操做)
    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;
}
複製代碼

解釋一下這段代碼

在java中,|=的做用是比較兩個對象是否相等

a|=b的意思就是把a和b按位或而後賦值給a

以10爲例總體流程大體以下

img_eadde165ab656c39debed0afc9a20492.png
算法流程

簡單來講,這種運算最後會致使1佔滿了它本身所佔位,好比說250,它的二進制爲

11111010,通過上面的或運算以後,最終將變爲11111111,這種狀況在加上1,就是大於這個數的最小二次冪。

7.總結

HashMap的設計與實現十分的巧妙。jdk8更是有不少提高,還沒寫這篇博客對於HashMap的理解僅僅只在表面。閱讀源碼後才發現裏面還有很多的學問,因爲本人水平有限,雖然花了不少時間寫了不少但還有不少細節並不瞭解,好比說紅黑樹的代碼實現細節,也有可能有幾個地方描述錯誤或者不到位,若是文章有誤請指正,以便於我及時修改和學習。

8.參考連接

HashMap 源碼詳細分析(JDK1.8)

HashMap resize方法的理解(一)

JDK 源碼中 HashMap 的 hash 方法原理是什麼

hashMap死循環問題

淺談jdk8爲什麼線程不安全

yq.aliyun.com/articles/65…

相關文章
相關標籤/搜索