HashMap是平常java開發中經常使用的類之一,是java設計中很是經典的一個類,它巧妙的設計思想與實現,還有涉及到的數據結構和算法,,值得咱們去深刻的學習。html
簡單來講,HashMap就是一個散列表,是基於哈希表的Map接口實現,它存儲的內容是鍵值對 (key-value) 映射,而且鍵值容許爲null(鍵的話只容許一個爲null)。java
①根據鍵的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
特徵 | 說明 |
---|---|
是否容許重複數據 | key若是重複會覆蓋,value容許重複 |
hashMap是否有序 | 無序,這裏的無序指的是遍歷HashMap的時候,獲得的順序大都跟put進去的順序不一致 |
hashMap是否線程安全 | 非線程安全,由於裏面的實現不是同步的,若是想要線程安全,推薦使用 |
鍵值是否容許爲空 | key和value都容許爲空,但只容許一個爲空 |
位運算是對整數在內存中的二進制位進行操做。算法
在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; //初始化容量
複製代碼
注意:左移沒有<<<運算符
咱們都知道&在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
複製代碼
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方法。
哈希桶的概念比較模糊,我的理解是數組表中一塊區域結果下面的單向鏈表組成的,在hashmap中,這個單向鏈表的頭部是所在數組上第一個元素,單向鏈表若是過長超過8,那麼這個"桶"就可能變成了紅黑樹(前提是數組長度達到64)。
在程序設定中,把一個對象經過某種算法或者說轉換機制對應到一個整形。
主要用於解決衝突的。
也稱爲散列表,這也是一種數據結構,能夠根據對象產生一個爲整數的散列碼(hashCode)。
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位的取中間位數
}
複製代碼
紅黑樹是一種自平衡二叉查找樹。是一種數據結構,又稱二叉b樹,(→_→ 2b樹?),紅黑樹本質上也是二叉查找樹。因此先理解下二叉查找樹。
二叉查找樹,又稱有序二叉樹,已排序二叉樹
它的三大特色以下
1.左子樹上全部結點的值均小於或等於它的根結點的值。
2.右子樹上全部結點的值均大於或等於它的根結點的值。
3.左、右子樹也分別爲二叉排序樹。
複製代碼
因爲二叉查找樹可能存在難以平衡呈線性的缺陷,因此出現的紅黑樹的概念。顧名思義,紅黑樹是隻有紅色和黑色節點的二叉樹。
它的5大性質以下。
1.節點是紅色或黑色。
2.根節點是黑色。
3.每一個葉子節點都是黑色的空節點(NIL節點)。
4 每一個紅色節點的兩個子節點都是黑色。(從每一個葉子到根的全部路徑上不能有兩個連續的紅色節點)
5.從任一節點到其每一個葉子的全部路徑都包含相同數目的黑色節點。
複製代碼
簡單來講紅黑樹是一種自平衡二叉查找樹,相比於普通的二叉查找樹,它的數據結構更爲複雜,可是在複雜的狀況也能經過自平衡(變色,左右旋轉)保持良好的性能。
關於紅黑樹,很形象的一組漫畫,查看這裏
紅黑樹的時間複雜度爲【吐槽下簡書這邊若是用數學公式太蛋疼了】:
O(logn)
此外,因爲它的設計任何不平衡將在三次旋轉內解決。
紅黑樹和avl樹(最先的自平衡二叉樹)的比較:
avl更加平衡,查詢速率稍強於紅黑樹,可是插入和刪除紅黑樹完爆avl樹,可能因爲hashMap的增刪也挺頻繁的,因此綜合考慮而選擇紅黑樹。
複製代碼
總結:紅黑樹是種能夠經過變色旋轉的自平衡二叉查找樹,對於hashMap來講,使用紅黑樹的好處在於,當有多個元素hash相同在同一數組下標的時候,使用紅黑樹在查找這些hash衝突的元素更快,它的時間複雜度從遍歷鏈表O(n)降到O(logN)。
算法複雜度分時間複雜度和空間複雜度。
時間複雜度:執行算法所須要的計算工做量
空間複雜度:執行算法所須要內存空間大小
時間和空間都是計算機資源的體現,算法的複雜性體如今運行該算法時計算機所需資源的大小。
複製代碼
這裏重點講下時間複雜度
(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))只關心其主幹部分。
複製代碼
常見算法的時間複雜度從小到大依次爲
求解算法的時間複雜度具體步驟爲:
①找出算法中執行次數最多的基本語句,通常是最內層的循環體。
②計算基本語句的數量級
③將基本語句執行次數的數量級放入大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++) {...} | 普通單層循環算法 |
平方級別 | N² | 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…) |
剛開始看hashMap源碼的時候,感受思路很亂不知道寫的啥東西,因此仍是得從它的【數據結構】開始入手。
不一樣於通常類的數據結構,從結構來說 HashMap = 數組 + 鏈表 + 紅黑樹(1.8開始加入,大程度的優化了HashMap的性能)
arrayList 數組
linkedList 雙向鏈表 查詢效率慢,需經過遍歷,新增或刪除快,好比說刪除一個元素 知道那個元素的上下引用 並改變關聯上下元素的引用指向便可。
複製代碼
在jdk8之前,若是發生頻繁碰撞的話,查找時間複雜度是O(1) + O(n) (先找在數組的位置再找鏈表),n若是比較大則嚴重影響了查找性能,而到了jdk8引入紅黑樹,O(1) + O(logN)。
①數組的優勢是查詢快,鏈表的優勢是增刪快,紅黑樹查詢性能較好,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.將舊數組的元素放到新數組中,從新作映射
若是舊的數組不爲空,則遍歷桶數組,並將鍵值對映射到新的桶數組中[樹節點和鏈表節點作不一樣操做]
複製代碼
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;
}
}
複製代碼
//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;
}
複製代碼
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;
}
複製代碼
擴容就是從新定義容量,在hashmap中,若是不斷的put元素,而hashMap對象中的數組沒法裝得下更多對象時,對象就須要進行擴容,擴大數組長度。這邊注意的是:
②在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;
}
複製代碼
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 數組的索引以內。而且一般分佈的比較均勻
在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);
}
}
複製代碼
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。
由於hashmap爲了性能,它的put,resize等操做都不是同步的,假設兩個線程同一時間作put操做,可能最後計算的size並不正確,值得一提的是jdk1.8之前多線程put甚至會致使閉環死循環,1.8開始不會有這個問題但依然存在線程安全問題。
jdk8前的閉環死循環。
這種問題在單線程下不存在,但在多線程下可能引發死循環致使cpu佔用太高。
若是hash衝突大,同一鏈表下下有多個節點容易出現這種問題。具體參考老生常談,HashMap的死循環
若想要線程安全
一、使用ConcurrentHashMap。(線程安全的hashMap)
二、使用Collections.synchronizedMap(Mao<K,V> m)方法把HashMap變成一個線程安全的Map。
複製代碼
在實際應用中,不管怎麼構造哈希函數,衝突也難以徹底避免。
HashMap根據鏈地址法(拉鍊法)來解決衝突,jdk8中若是鏈表長度大於8且節點數組長度大於64的時候,就把鏈表下全部節點轉爲紅黑樹,位於數組上的節點爲根節點,來維護hash衝突的元素,鏈表中衝突的元素能夠經過key的equals()方法來肯定。
複製代碼
先寫個例子測試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()方法】
加載因子是哈希表在其容量自動增長以前能夠達到多滿的一種尺度,它衡量的是一個散列表的空間的使用程度,負載因子越大表示散列表的裝填程度越高,反之越小。
簡單來講就是若是加載因子過小,空間利用率低,且太容易擴容對性能不太友好,設置過高,不及時擴容容易致使衝突概率大,將提升了查詢成本。因此0.75是很合適的值,通過試驗,在理想狀況下,使用隨機哈希碼,節點出現的頻率在hash桶中遵循泊松分佈【在頻率附近發生機率高,向兩邊對稱降低。】
詳細見 爲何HashMap中默認加載因子爲0.75
經常使用String,Integer這樣的key
主要緣由爲
這些類是Immutable(不可變的),String和基本類型的包裝類規範的重寫了hashCode()和equals()方法。做爲不可變類天生是線程安全的,並且能夠很好的優化好比能夠緩存hash值,避免重複計算等等,若是採用可變的對象類型,可能出現put進去就沒法查詢到的狀況。
若是想用自定義的類型做爲鍵,那麼須要遵照equals()和hashCode()方法的定義規則且不可變,對象插入到map後就不會再改變。
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也不同)。
咱們知道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);
}
複製代碼
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爲例總體流程大體以下
簡單來講,這種運算最後會致使1佔滿了它本身所佔位,好比說250,它的二進制爲
11111010,通過上面的或運算以後,最終將變爲11111111,這種狀況在加上1,就是大於這個數的最小二次冪。
HashMap的設計與實現十分的巧妙。jdk8更是有不少提高,還沒寫這篇博客對於HashMap的理解僅僅只在表面。閱讀源碼後才發現裏面還有很多的學問,因爲本人水平有限,雖然花了不少時間寫了不少但還有不少細節並不瞭解,好比說紅黑樹的代碼實現細節,也有可能有幾個地方描述錯誤或者不到位,若是文章有誤請指正,以便於我及時修改和學習。