透析HashMap-原始碼分析
閒談
HashMap是一個用來儲存<Key,Value>資料的類,憑藉其極其優秀的效率,一直是程式設計中最常用的基礎類。一直以來就是面試的熱點,感覺不問問HashMap的原理, 都不好意思是Java面試了。不好好看下HashMap,怎麼能說準備好了面試呢。
概述
我們都知道HashMap是基於陣列+連結串列的資料結構,可以高效的插入和查詢資料,克服陣列插入資料需要移位,而連結串列查詢資料需要遍歷的缺點,結構如下圖。
通過儲存的Key的雜湊值來計算<Key,Value>儲存的槽位(陣列上的位置),槽位上已經有其他<Key,Value
為避免出現這種情況,連結串列長度超過一定長度(預設為8),就直接生成紅黑樹(一種近似平衡二叉樹的結構)這樣的話最差的情況下,查詢的時間複雜度是O(logn)
常見面試題
1、HashMap的容量為什麼會是2的冪次方?
通過雜湊值運算儲存的槽位時取餘可以通過位運算來實現,效率高。
2、HashMap中負載因子為什麼是0.75?
關於這個問題,原始碼裡有段註釋“As a general rule,the default load factor (.75) offers a good * tradeoff between time and space costs.”意思是說負載因子0.75能在時間和空間上去得很好平衡。負載因子太高了,容易造成衝突。太小了,空間利用率又不高,還得頻繁擴容。那麼為什麼不是0.6,0.8呢?我在網上看到一個解釋覺得有一定道理,因為容量是2冪次方,容量*負載因子(擴容的臨界值)剛好會是整數。
3、HashMap連結串列轉化為數為什麼是8?
還是從原始碼中的註釋找答案,“Because TreeNodes are about twice the size of regular nodes”,樹節點佔用的空間是常規節點的兩倍,“Ideally,under random hashCodes,the frequency of nodes in bins follows a Poisson distribution with a parameter of about 0.5 on average for the default resizing threshold of 0.75。在隨機hashCode下,節點在槽中的分佈符合泊松分佈,在負載因子為0.75下,泊松分佈引數為0.5。槽中節點的個數及對應的概率如下
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
可以看到,但連結串列長度為8個的時候概率是非常非常低的,所以取樹化為8是為了降低樹化的概率,同時當連結串列太長時有可以通過轉換為樹,提高查詢效率(空間換時間)。
4、HashMap的Key或Value是否可以為null
可以,HashMap的Key或Value都可以為null,key為null的<key,value>鍵值對儲存在槽位為0的位置。
原始碼解析
直接開啟程式碼,來看看hashmap是怎麼實現的。⚠️注意:本文的分析基於JDK1.8
一、常量及成員變數
//預設初始化的容量16 二進位制"10000",即陣列的預設長度,或者叫做“槽”的長度
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
/**
"泊松分佈",負載因子,儲存元素超過該比例即擴容,預設0.75
即儲存的元素個數大於槽容量的0.75倍就擴容槽位
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//最大容量 2^30
static final int MAXIMUM_CAPACITY = 1 << 30;
//連結串列長度大於該值時轉成黑樹
static final int TREEIFY_THRESHOLD = 8;
//紅黑樹原生個數小於該值時轉成連結串列
static final int UNTREEIFY_THRESHOLD = 6;
//儲存元素的陣列,或者叫槽
transient Node<K,V>[] table;
//目前存放元素的個數
transient int size;
//修改的次數,可認為當前的版本
transient int modCount;
/*
擴容的閥值=容量*因子,當儲存的<key,value>數超過該變數,則擴容.
未初始化前,這個值會儲存首次初始化時的容量,建構函式中賦值*/
int threshold;
//因子
final float loadFactor;複製程式碼
以上各變數暫時不知道,可以先不理,先混個眼熟,一會程式碼裡用到會做說明。
二、建構函式
/**
建構函式,傳入初始容量,及因子
*/
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的冪次方’”,
為什麼是2的冪次方的,你先不要管,只要知道hashmap的容量是2的冪次方即可...
是不是有點繞?
例子 引數14,返回:2^4=16
引數 16<cap<=32,則返回2^5=32
********************************************************************/
static final int tableSizeFor(int cap) {
/**
這麼一大串位移看起來很頭疼?作用就是讓引數二進位制最高為1的位後面的所有位數變為1
舉個例子cap為 14則二進位制為
cap為14 0000 0000 0000 0000 0000 0000 0000 1110
n為cap-1 0000 0000 0000 0000 0000 0000 0000 1101
*/
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
//上面位移結果 0000 0000 0000 0000 0000 0000 0000 1111
//這個結果+1是 0000 0000 0000 0000 0000 0000 0001 0000 就是2^4=16
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
/*******************************************************************************
位移解析
為什麼這裡要-1?這裡-1其實是作用在傳進來的數已經是2^n
如16 0001 0000 減1 會是0000 1111,可以與傳16以下統一,位移最後的結果都是 0000 1111
*********************************************************************************/
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
還有點懵?再給個例子,自己去意會吧
0010 0000 0000 0000 0000 0000 0000 0001 引數2^29+1
0010 0000 0000 0000 0000 0000 0000 0000 -1後結果
0011 0000 0000 0000 0000 0000 0000 0000 移1位再|原來資料
0011 1100 0000 0000 0000 0000 0000 0000 移2位再|原來資料
0011 1111 1100 0000 0000 0000 0000 0000 移4位再|原來資料
0011 1111 1111 1111 1100 0000 0000 0000 移8位再|原來資料
0011 1111 1111 1111 1111 1111 1111 1111 移16位後再|原來資料
0100 0000 0000 0000 0000 0000 0000 0000 +1後得到的結果2^30
最後的結果是否就是大於引數的最小冪次方?2^30 就是大於2^29+1的最小冪次方
複製程式碼
傳初始容量建構函式:呼叫了上面的建構函式,因子為預設的0.75
public HashMap(int initialCapacity) {
this(initialCapacity,DEFAULT_LOAD_FACTOR);
}複製程式碼
無引數建構函式,所有引數使用預設值
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}複製程式碼
通過傳進來一個Map來初始化HashMap
public HashMap(Map<? extends K,? extends V> m) {
//因子使用預設值
this.loadFactor = DEFAULT_LOAD_FACTOR;
//將map複製到當前map中
putMapEntries(m,false);
}
final void putMapEntries(Map<? extends K,? extends V> m,boolean evict) {
int s = m.size();
if (s > 0) {
//1、槽還未初始化,計算要初始化的槽大小
//2、槽已經初始化且容量不夠,則通過resize()擴容
if (table == null) {
float ft = ((float)s / loadFactor) + 1.0F;
int t = ((ft < (float)MAXIMUM_CAPACITY) ?
(int)ft : MAXIMUM_CAPACITY);
if (t > threshold)
threshold = tableSizeFor(t);
}
else if (s > threshold)
resize();
//將源Map中的key、value儲存到當前Map中
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);
}
}
}
/********************************擴容函式********************************************/
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) {
/*
當前容量已經是最大容量,直接返回
否則直接容量擴充套件為原來的2倍
*/
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
} else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
//未初始化過,且初始化容量已經計算過,則容量設為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;
//定義新容量的槽,重新計算<key,value>的位置
@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;
/**
如果槽位沒有連結串列(紅黑樹),則直接計算槽位儲存的<key,value>新位置,
否則是連結串列則遍歷連結串列,重新計算連結串列上所有<key,value>的位置,
紅黑樹同樣做遍歷及計算。
這裡可以看到計算位置只需要(key的雜湊值)按位與(容量-1),
其實就是hash值對容量取餘,這就是容量是2的冪次方的好處,計算取餘非常方便。
*/
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;
/**
這裡的位置計算有一個小技巧,只“按位與”容量,
為0則槽位不變,為1則新槽位=(舊槽位位置+舊容量)*/
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;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
複製程式碼
階段總結
HashMap總共有4個建構函式,分別可以傳入容量、因子、Map。1、預設情況下,初始化容量會是16,因子會是0.75。2、如果傳入容量,則初始化的容量會是大於傳入的容量的 最小“2的冪次方”。
往HashMap中增刪查資料
一)、新增
新增一個<key,value>
public V put(K key,V value) {
return putVal(hash(key),true);
}
/**
這裡有個點需要說明以下,
1、hashmap不是直接使用物件的雜湊值,
而是會讓高16為按位異或低16位,這是為了儘量讓32位的hash值都能在計算槽位時起作用,減少衝突。
2、如果key是null,則hash值會取0,也就是key位null,則會儲存在槽位0的位置。
*/
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) {
Node<K,V>[] tab; Node<K,V> p; int n,i;
//如果還未進行初始化,則進行初始化
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//通過hash計算儲存位置,如果位置上沒有節點,則直接將節點放入該位置,槽位個數n為2的冪次方
//的好處就在這裡,這裡雜湊值對槽大小計算餘數,即要儲存的槽位置,通過按為與即可實現。
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash,null);
else {
Node<K,V> e; K k;
//如果計算出的位置有節點,且節點的key等於要新增節點的key,先不做什麼操作,只把已有節點儲存到e
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//如果是樹結構,往紅黑樹中增加節點。同樣,不做解析
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this,tab,hash,value);
/**
連結串列結構,則遍歷連結串列,直到連結串列尾部,或者連結串列中已經有相同的key。
如果遍歷到連結串列尾部,則新增新節點,如果key已經在連結串列中,則停止遍歷*/
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash,null);
//連結串列長度太長,將連結串列轉為紅黑樹
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;
}
}
//key已經存在於Map中,直接替換其value值
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
//移動節點到連結串列尾部
afterNodeAccess(e);
return oldValue;
}
}
//Map版本號+1
++modCount;
//超過閥值,直接擴容
if (++size > threshold)
resize();
//這個方法,HashMap沒做操作,主要是給擴充套件子類來使用
afterNodeInsertion(evict);
return null;
}複製程式碼
二)、HashMap中通過key獲取value
/**通過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> first,e; int n; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
//槽位上節點first,如果key等於first的key,直接返回first
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);
//連結串列結構,連結串列結構,直接往後遍歷連結串列,直到找到節點key等於傳進來的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中的節點
final Node<K,V> removeNode(int hash,Object key,Object value,boolean matchValue,boolean movable) {
Node<K,index;
//通過hashcode找到儲存的槽位
if ((tab = table) != null && (n = tab.length) > 0 &&
(p = tab[index = (n - 1) & hash]) != null) {
Node<K,V> node = null,e; K k; V v;
//如果槽位上的節點key等於要刪除的key,則將節點記錄到node
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
node = p;
//否則,槽上面的節點還有下一個節點
else if ((e = p.next) != null) {
//如果是樹結構
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);
}
}
//判斷是否找到節點
if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
//如果是樹,刪除數中的節點
if (node instanceof TreeNode)
((TreeNode<K,V>)node).removeTreeNode(this,movable);
/**
node為要刪除的節點,p為要刪除節點的刪一個節點,
如果要刪的是槽位置,則p跟node都指向槽位上的節點*/
else if (node == p)
tab[index] = node.next;
else
p.next = node.next;
//版本更改
++modCount;
--size;
afterNodeRemoval(node);
return node;
}
}
return null;
}複製程式碼
階段總結
至此,增刪查都已經解析完畢,程式碼相對也比較簡單,就是通過hash計算出槽位,再順槽位上的連結串列或者樹查詢元素。注意點:1、HashMap中key跟vlue是可以位null的,key為null會儲存在槽位為0的位置。2、可以看到,增刪都沒有做任何資料同步或者鎖,所以HashMap是非執行緒安全的。
版本號用武之地
通過HashMap物件,可以獲取到key的集合還有Value的集合,或者節點的集合,通過集合Iterator迭代器可以遍歷HashMap中所有的key、value或者節點。
//獲取key的集合、value的集合,還有節點的集合方法如下
hashMap.keySet();
hashMap.values();
hashMap.entrySet();
//再來看看這些集合共用的迭代器
abstract class HashIterator {
Node<K,V> next; // next entry to return
Node<K,V> current; // current entry
int expectedModCount; // for fast-fail
int index; // current slot
HashIterator() {
expectedModCount = modCount;
//.....省略不重要程式碼
}
public final boolean hasNext() {
return next != null;
}
final Node<K,V> nextNode() {
Node<K,V>[] t;
Node<K,V> e = next;
/*************關鍵程式碼*****************
迭代器建立的時候會儲存HashMap的版本,一旦發現當前的HashMap版本
與建立迭代器時候的HashMap版本不一致,則說明遍歷期間,HashMap被其他執行緒修改了,直接丟擲一個ConcurrentModificationException異常
**/
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
if (e == null)
throw new NoSuchElementException();
if ((next = (current = e).next) == null && (t = table) != null) {
do {} while (index < t.length && (next = t[index++]) == null);
}
return e;
}
//......省略不重要程式碼
}複製程式碼
階段總結:HashMap中有個變數,用來記錄HashMap的版本號。HashMap迭代器對於多執行緒修改的反應是“快速失敗”的方法。迭代過程,一旦發現HashMap被修改了,迭代元素就直接丟擲異常。