Java集合三大體系——List、Set、Map,而Set是基於Map實現的.在Map中HashMap作為其中常用類,面試中的常客.記得之前有次面試回答,HashMap是連結串列雜湊的資料結構,其容量是16,負載因子0.75,當大於容量*負載因子會進行2倍擴容,put操作是將key的hashcode值進行一次hash計算,key的equals方法找到鍵值對進行替換返回被舊資料,若沒有找到會插入到連結串列中,HashMap執行緒不安全.當面試官聽到這些以後第一個問題為什麼容量是16,15、14不行嗎?為什麼2倍擴容?為什麼HashMap建議不可變物件用Key?自己當時思考得不夠深入,還沒問到ConcurrentHashMap我就已經心慌了...下面我來聊一聊我對HashMap的看法
HashMap簡述
繼承關係
先看下HashMap的繼承關係:
①.新增Map介面宣告是為了Class類的getInterfaces這個方法能夠直接獲取到Map介面
②.mistake是一個錯誤
③.為了java api的文件生成工具而優化,產生更精確的型別的文件
HashMap資料結構
1.7的HashMap採用陣列+單連結串列實現,雖然HashMap定義了hash函式來避免衝突,但還是會出現兩個不同的Key經過計算後桶的位置一樣,HashMap採用了連結串列來解決,可如果位於連結串列中的結點過多,1.7的HashMap通過key值依次查詢效率太低,所以在1.8中HashMap進行了改良,採用陣列+連結串列+紅黑樹來實現,當連結串列長度超過閾值8時,將連結串列轉換為紅黑樹.再來看看Entry中有哪些屬性,在1.8中Entry改名為Node,屬性不變,1.8改動後面會說,主講1.7
static class Entry implements Map.Entry {
/** 賤物件*/
final K key;
/** 值物件*/
V value;
/** 指向下一個Entry物件*/
Entry next;
/** 鍵物件雜湊值*/
int hash;
}
複製程式碼
1.7 HashMap原始碼分析
HashMap關鍵屬性
/**
* 預設初始容量16——必須是2的冪
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
/**
* HashMap儲存的鍵值對數量
*/
transient int size;
/**
* 預設負載因子0.75
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
/**
* 擴容閾值,當size大於等於其值,會執行resize操作
* 一般情況下threshold=capacity*loadFactor
*/
int threshold;
/**
* Entry陣列
*/
transient Entry[] table = (Entry[]) EMPTY_TABLE;
/**
* 記錄HashMap修改次數,fail-fast機制
*/
transient int modCount;
/**
* hashSeed用於計算key的hash值,它與key的hashCode進行按位異或運算
* hashSeed是一個與例項相關的隨機值,用於解決hash衝突
* 如果為0則禁用備用雜湊演算法
*/
transient int hashSeed = 0;
複製程式碼
HashMap構造方法
/**
* 指定容量及負載因子構造方法
*/
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;
//設定擴容閾值
threshold = initialCapacity;
//空方法,讓其子類重寫例如LinkedHashMap
init();
}
/**
* 預設構造方法,採用預設容量16,預設負載因子0.75
*/
public HashMap() {
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
}
/**
* 指定容量構造方法,負載因子預設0.75
*/
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
複製程式碼
從這3個構造方法中我們可以發現雖然指定了初始化容量大小,但此時的table還是空,是一個空陣列,且擴容閾值為初始容量.在其put操作前,會建立陣列.
/**
* 根據已有Map構造新HashMap的構造方法
* 初始容量:引數map大小除以預設負載因子+1與預設容量的最大值
* 初始負載因子:預設負載因子0.75
*/
public HashMap(Map m) {
this(Math.max((int) (m.size() / DEFAULT_LOAD_FACTOR) + 1,
DEFAULT_INITIAL_CAPACITY), DEFAULT_LOAD_FACTOR);
inflateTable(threshold);
//把傳入的map裡的所有元素放入當前已構造的HashMap中
putAllForCreate(m);
}
複製程式碼
這個構造方法便是在put操作前呼叫inflateTable方法,inflate意為膨脹,這個方法我們來看下,注意剛也提到了此時的threshold擴容閾值是初始容量
private void inflateTable(int toSize) {
//返回不小於number的最小的2的冪數,最大為MAXIMUM_CAPACITY
int capacity = roundUpToPowerOf2(toSize);
//設定擴容閾值,值為容量*負載因子與最大容量+1的較小值
threshold = (int) Math.min(capacity * loadFactor,
MAXIMUM_CAPACITY + 1);
//建立陣列
table = new Entry[capacity];
//初始化HashSeed值
initHashSeedAsNeeded(capacity);
}
/**
* 返回不小於number的最小的2的冪數,最大為MAXIMUM_CAPACITY
*/
private static int roundUpToPowerOf2(int number) {
// assert number >= 0 : "number must be non-negative";
/**
* 若number不小於最大容量則為最大容量
* 若number小於最大容量大於1,則為不小於number的最小的2的冪數
* 若都不是則為1
*/
return number >= MAXIMUM_CAPACITY
? MAXIMUM_CAPACITY
: (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1;
}
複製程式碼
從這裡我們可以到HashMap建立了一個2的冪數容量的陣列,那為什麼一定要這樣設計?後面我會介紹.
put方法
我往HashMap中新增元素呼叫最多就是這個put方法
public V put(K key, V value)
我們來看下其程式碼實現:
public V put(K key, V value) {
//若陣列為空時建立陣列
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
//若key為null
if (key == null)
return putForNullKey(value);
//對key進行hash計算,獲取hash值
int hash = hash(key);
//根據剛得到的hash值與陣列長度計算桶位置
int i = indexFor(hash, table.length);
//遍歷桶中連結串列
for (Entry e = table[i]; e != null; e = e.next) {
Object k;
//key值與hash值都相同的話進行替換
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
//空方法,讓其子類重寫例如LinkedHashMap
e.recordAccess(this);
//返回舊值
return oldValue;
}
}
//記錄修改
modCount++;
//連結串列中不存在此鍵,則呼叫addEntry方法向連結串列中新增新結點
addEntry(hash, key, value, i);
return null;
}
複製程式碼
從上面的原始碼我們可以看到:
①HashMap首先判斷陣列是否為空,若為空呼叫inflateTable進行擴容.
②接著判斷key是否為null,若為null就呼叫putForNullKey方法進行put.所以HashMap允許Key為null
③再將key進行一次雜湊計算,得到的雜湊值和當前陣列長度計算得到陣列中的索引
④然後遍歷該陣列索引下的連結串列,若key的hash和傳入key的hash相同且key的equals放回true,那麼直接覆蓋 value
⑤最後若不存在,那麼在此連結串列中頭插建立新結點
逐步來介紹(第一步就不說了上文已闡述過),第二步最主要就是putForNullKey方法,從中我們可以發現若key為null會先從0位置桶上鍊表遍歷,若找到結點key為null的進行替換,不存在則新增結點.方法內的addEntry後續講
private V putForNullKey(V value) {
//遍歷0位置桶上的連結串列,若存在結點Entry的key為null替換value,返回舊值
for (Entry e = table[0]; e != null; e = e.next) {
if (e.key == null) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
//若不存在,0位置桶上的連結串列中新增新結點
addEntry(0, null, value, 0);
return null;
}
複製程式碼
第三步中先看下HashMap的hash演算法,獲取鍵物件雜湊值並將補充雜湊函式應用於該物件結果雜湊,防止質量差的雜湊函式,注意:空鍵總是對映到雜湊0,因此索引為0,1.8的hash方法已進行過優化,
final int hash(Object k) {
// 當h不為0且鍵物件型別為String用此演算法,1.8已刪除
int h = hashSeed;
if (0 != h && k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
h ^= k.hashCode();
//此函式確保在每個位元位置上僅以恆定倍數不同的hashCode具有有限的碰撞數量(在預設負載因子下約為8)
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
複製程式碼
根據所計算的值與陣列長度計算桶位置:
static int indexFor(int h, int length) {
return h & (length-1);
}
複製程式碼
此方法對陣列的長度取模運算,得到的餘數進行下表訪問,那麼既然是取模運算為什麼不直接h%length,因為其效率很低,所以採用位運算.從中我們可以看出,假設length為16,當我們h為1,17時算出桶的索引都為1這種情況就稱為衝突(k1≠k2,而f(k1)=f(k2)).當有衝突時HashMap採用鏈地址法(把所有的同義詞用單連結串列連線起來的方法)處理衝突
其次假設length分別為16,15,14時,他們的衝突次數:
length = 16 | length = 15 | length = 14 | ||||||
h | h&length-1 | 結果 | h&length-1 | 結果 | h&length-1 | 結果 | ||
0 | 0000 & 1111 | 0000 | 0 | 0000 & 1110 | 0000 | 0000 & 1101 | 0000 | |
1 | 0001 & 1111 | 0001 | 1 | 0001 & 1110 | 0000 | 0001 & 1101 | 0001 | |
2 | 0010 & 1111 | 0010 | 2 | 0010 & 1110 | 0010 | 0010 & 1101 | 0000 | |
3 | 0011 & 1111 | 0011 | 3 | 0011 & 1110 | 0010 | 0011 & 1101 | 0001 | |
4 | 0100 & 1111 | 0100 | 4 | 0100 & 1110 | 0100 | 0100 & 1101 | 0100 | |
5 | 0101 & 1111 | 0101 | 5 | 0101 & 1110 | 0100 | 0101 & 1101 | 0101 | |
6 | 0110 & 1111 | 0110 | 6 | 0110 & 1110 | 0110 | 0110 & 1101 | 0100 | |
7 | 0111 & 1111 | 0111 | 7 | 0111 & 1110 | 0110 | 0111 & 1101 | 0101 | |
8 | 1000 & 1111 | 1000 | 8 | 1000 & 1110 | 1000 | 1000 & 1101 | 1000 | |
9 | 1001 & 1111 | 1001 | 9 | 1001 & 1110 | 1000 | 1001 & 1101 | 1001 | |
10 | 1010 & 1111 | 1010 | 10 | 1010 & 1110 | 1010 | 1010 & 1101 | 1000 | |
11 | 1011 & 1111 | 1011 | 11 | 1011 & 1110 | 1010 | 1011 & 1101 | 1001 | |
12 | 1100 & 1111 | 1100 | 12 | 1100 & 1110 | 1100 | 1100 & 1101 | 1100 | |
13 | 1101 & 1111 | 1101 | 13 | 1101 & 1110 | 1100 | 1101 & 1101 | 1101 | |
14 | 1110 & 1111 | 1110 | 14 | 1110 & 1110 | 1110 | 1110 & 1101 | 1100 | |
15 | 1111 & 1111 | 1111 | 15 | 1111 & 1110 | 1110 | 1111 & 1101 | 1101 | |
0個衝突 | 8個衝突 | 8個衝突 |
那麼初始容量為什麼要為16,而不是8,32呢?我認為若是8的話擴容閾值為6,沒放幾個就會擴容;而32的話又不會放那麼多,資源浪費.
第四步中若key的hash和傳入key的hash相同且key的equals放回true,那麼直接覆蓋value.key的hash值是根據其hashcode值進行hash雜湊計算得到的,那麼當我們用可變物件時其hashcode值很容易會變化,那麼就會帶來風險找不到原來的value,所以HashMap建議使用不可變物件作為Key
最後一步addEntry方法建立新結點,程式碼如下
void addEntry(int hash, K key, V value, int bucketIndex) {
//當前hashmap中的鍵值對數量超過擴容閾值,進行2倍擴容
if ((size >= threshold) && (null != table[bucketIndex])) {
//2倍擴容
resize(2 * table.length);
//擴容後,桶的數量增加了,重新對鍵進行雜湊碼的計算
hash = (null != key) ? hash(key) : 0;
//根據鍵的新雜湊碼和新的桶數量重新計算桶索引值
bucketIndex = indexFor(hash, table.length);
}
//建立結點
createEntry(hash, key, value, bucketIndex);
}
/**
* 頭插結點
* 將原本在陣列中存放的連結串列頭置入到新的Entry之後,將新的Entry放入陣列中
*/
void createEntry(int hash, K key, V value, int bucketIndex) {
Entry e = table[bucketIndex];
table[bucketIndex] = new Entry<>(hash, key, value, e);
size++;
}
複製程式碼
先不看擴容情況,當不需要擴容時,hashmap採用頭插法插入結點,為什麼要頭插而不是尾插,因為後插入的資料被使用的頻次更高,而單連結串列無法隨機訪問只能從頭開始遍歷查詢,所以採用頭插.突然又想為什麼不採用二維陣列的形式利用線性探查法來處理衝突,陣列末尾插入也是O(1),可陣列其最大缺陷就是在於若不是末尾插入刪除效率很低,其次若新增的資料分佈均勻那麼每個桶上的陣列都需要預留記憶體.
再來看看擴容:
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
//建立新的陣列
Entry[] newTable = new Entry[newCapacity];
//將舊Entry陣列轉移到新Entry陣列中去
transfer(newTable, initHashSeedAsNeeded(newCapacity));
table = newTable;
//重新設定擴容閾值
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
複製程式碼
transfer方法遍歷舊陣列所有Entry,根據新的容量逐個重新計算索引頭插儲存在新陣列中,擴容相當麻煩,所以如果當我們知道需要新增多少資料時最好指定容量初始化.
/**
* 將舊Entry陣列轉移到新Entry陣列中去
*/
void transfer(Entry[] newTable, boolean rehash) {
//獲取新陣列的長度
int newCapacity = newTable.length;
for (Entry e : table) {
while(null != e) {
Entry next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
//重新計算索引
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
複製程式碼
在這裡可能會出現環形連結串列導致死迴圈.先假設容量為4,負載因子預設0.75,擴容閾值3,HashMap當前儲存如下:
那當我們多執行緒操作HashMap呢?
假定有兩個執行緒同時要新增資料到此HashMap,在擴容時 Thread1正準備處理Entry1,執行完Entry<K,V> next = e.next掛起執行Thread2,此時E為Entry1,next為Entry2
Thread2執行完transfer方法 此時Thread1恢復執行,
將Entry1插入到新陣列中去,然後e為Entry2,輪到下次迴圈時next由於Thread2的操作變為了Entry1 因為Thread2執行過整個transfer方法所以Entry2和Entry1在新雜湊表中一定會再次衝突,然後將Entry2頭插連結串列,再次e為Entry1,next為null 由於頭插Entry1插入連結串列,將Entry1指向了Entry2,此時環形連結串列出現了,當我們操作環形連結串列的桶就會gg.也因為HashMap本就執行緒不安全,所以sun不認為這是個問題,若有併發場景就用ConcurrentHashMap
另外這讓我想起一道經典面試題連結串列反轉,自己動手實現了下
public class Node {
private Node next;
private Object item;
public Node(Object item, Node next) {
this.item = item;
this.next = next;
}
public static Node get(){
Node last = new Node(6, null);
Node fifth = new Node(5,last);
Node fourth = new Node(4, fifth);
Node third = new Node(3, fourth);
Node second = new Node(2, third);
Node first = new Node(1, second);
return first;
}
public static void outPut(Node node){
while(node != null){
System.out.print(node.item);
node = node.next;
}
}
public static Node reverse(Node node){
Node newNode = node;
Node temp = null;
while (node != null && node.next != null){
Node next = node.next;
node.next = temp;
temp = node;
newNode = new Node(next.item, node);
node = next;
}
return newNode;
}
public static void main(String[] args) {
Node first = get();//獲取單連結串列頭結點
outPut(first);//輸出整條連結串列資料
first = reverse(first);
outPut(first);
}
}
複製程式碼
get方法
知道了put原理,get操作就很好理解了,先看下程式碼:
/**
* 返回到指定鍵所對映的值,若不存在返回null
*/
public V get(Object key) {
//與put一樣單獨處理
if (key == null)
return getForNullKey();
Entry entry = getEntry(key);
return null == entry ? null : entry.getValue();
}
複製程式碼
與存null key一樣,從0位置上的桶上獲取
private V getForNullKey() {
if (size == 0) {
return null;
}
for (Entry e = table[0]; e != null; e = e.next) {
if (e.key == null)
return e.value;
}
return null;
}
複製程式碼
getEntry
final Entry getEntry(Object key) {
//size為0,即hashmap為空,返回null
if (size == 0) {
return null;
}
//對key進行hash計算,獲取hash值
int hash = (key == null) ? 0 : hash(key);
//根據hash值與陣列長度獲取桶位置,遍歷對應桶上鍊表
for (Entry e = table[indexFor(hash, table.length)];
e != null;
e = e.next) {
Object k;
//key值與hash值都相同的話返回結點
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
}
//若不存在返回null
return null;
}
複製程式碼
小結
本篇主要圍繞著java7HashMap原始碼講解其原理做個小結:
①因為其put操作對key為null場景做了單獨處理,所以HashMap允許null作為Key
②因為HashMap在算桶index時根據key的hashcode值進行hash計算獲取hash值與陣列length-1進行與運算,length-1的二進位制位全為1,這樣可以分佈均勻避免衝突,所以HashMap容量要為2的冪數
③因為HashMap的操作會圍繞key的hashcode進行hash計算,而可變物件其hashcode很容易變化,所以HashMap建議用不可變物件作為Key.
④HashMap執行緒不安全擴容方法可能會導致環形連結串列死迴圈,所以若需要多執行緒場景下操作可以使用ConcurrentHashMap
⑤.當發生衝突時,HashMap採用鏈地址法(拉鍊法)處理衝突,然後根據key的hash以及equals方法具體獲取key所對應的Entry
⑥.為什麼HashMap初始容量定為16,我認為若是8的話擴容閾值為6,沒放幾個就會擴容;而32的話又不會放那麼多,資源浪費
參考
https://coolshell.cn/articles/9606.html
http://www.cnblogs.com/chenssy/p/3521565.html