1 HashMap java7
HashMap在Java7時是使用數組+鏈表的數據結構實現的,那麼具體怎麼實現?
我們在使用HashMap時,一般是使用的無參構造創建:
Map<Integer, String> map = new HashMap<>();
跟進去無參構造函數源碼:
// 默認數組大小16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
// 最大數組大小10億多
static final int MAXIMUM_CAPACITY = 1 << 30;
// 負載率
static final float DEFAULT_LOAD_FACTOR = 0.75f;
public HashMap() {
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR );
}
public HashMap(int initialCapacity, float loadFactor) {
if (initicalCapacity < 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);
}
我們可以發現,HashMap在被創建出來的時候,並沒有創建出數組,只是計算了數組的大小和負載率,那麼數組在什麼時候被創建出來?
1.1 put(key, value)
public V put(K key, V value) {
// HashMap在put的時候才創建數組
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
// 如果key爲null
if (key == null)
return putForNullKey(value);
// 對key進行hash算法,內部是拿到hashCode後再對hashCode進行二次哈希也就是擾動計算
int hash = hash(key);
// 將hash後的值與數組容量取模獲取數組下標位置,降低碰撞
// 這裏的取模是使用與位運算
int i = indexFor(hash, table.length);
// 開始存儲
for (Entry<K, V> e = table[i]; e != null; e = e.next) {
Object k;
// 如果找到hash相同、key也相同的值就做替換
if (e.hash == hash && ((k == e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
// 沒在數組和鏈表中找到對應的key,就把它添加進來
modCount++;
addEntry(hash, key, value, i);
return null;
}
private void inflateTable(int toSize) {
// 內部使用了位運算,讓數組索引保持2的冪次
int capacity = roundUpToPowerOf2(toSize);
// toSize最開始是16,創建數組後threshold變成了12
threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
table = new Entry[capacity]; // 數組還是16大小
initHashSeedAsNeeded(capacity);
}
private V putForNullKey(V value) {
// 遍歷鏈表
for (Entry<K, V> 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++; // 修改的次數,處理多線程寫與預期不同及時拋出異常
addEntry(0, null, value, 0);
return null;
}
final int hash(Object k) {
int h = hashSeed; // hashSeed一直是0
if (0 != h && k instanceof String) {
return sum.misc.Hashing.stringHash32((String) k);
}
// 獲得hashCode
h ^= k.hashCode();
// 下面的位運算是對hashCode再進行二次hash
// 也就是擾動計算,讓計算出來的數組索引減少碰撞
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
static int indexFor(int h, int length) {
// h得到的hash值可能非常大,這時候通過與位運算,能夠達到將一個非常大的值輸出爲很小的值
return h & (length - 1);
}
void addEntry(int hash, K key, V value, int bucketIndex) {
// 判斷數組是否需要擴容
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);
}
void createEntry(int hash, K key, V value, int bucketIndex) {
Entry<K, V> e = table[bucketIndex];
// 每次都會把新創建的節點放在前面,新節點的指針entry.next指向上一個
// 最新插入的節點會更容易被訪問到,這種方式也就是頭插法
table[bucketIndex] = new Entry<>(hash, key, value, e);
size++;
}
class Entry {
V value;
K key;
int hash;
Entry<K, V> next;
Entry(int h, K k, V v, Entry<K, V> n) {
value = v;
next = n;
key = k;
hash = h;
}
}
根據上面的代碼分析,當我們調用 hashMap.put(key, value)
時
-
首先會先創建一個數組,這個數組默認大小爲16(每個數組都有一個鏈表)
-
然後會對傳入的
key
獲取它的hashCode,再對hashCode進行位運算(即擾動計算)減少在存儲時的碰撞 -
將計算拿到的hashCode與當前HashMap內的數組的長度進行取模操作,得到一個非常小的值爲數組索引,這樣能夠減少數組的存儲索引
-
遍歷數組鏈表,如果查找到相同的則替換;如果沒有,先判斷數組是否需要擴容,需要則擴容,創建一個Entry數據,使用
頭插法
讓新的數據節點指向舊的數據節點
hashMap.put(key, value)
流程圖如下:
1.2 get(key)
public V get(Object key) {
if (key == null)
return getForNullKey();
Entry<K, V> entry = getEntry(key);
return null == entry ? null : entry.getValue();
}
private V getForNullKey() {
if (size == 0) {
return null;
}
for (Entry<K, V> e = table[0]; e != null; e = e.next) {
if (e.key == null)
return e.value;
}
return null;
}
final Entry<K, V> getEntry(Object key) {
if (size == 0) {
return null;
}
int hash = (key == null) ? 0 :hash(key);
for (Entry<K, V> e = table[indexFor(hash, table.length)]; e != null; e = e.next) {
Object k;
if (e.hash == hash &&
((k = e.key) == key || ((key != null && key.equals(k))))
return e;
}
return null;
}
根據上面源碼,獲取就比較簡單了,只是存入數據時獲取角標索引然後去鏈表查找數據:
-
對傳入的
key
獲取它的hashCode,再對hashCode進行位運算(即擾動計算)減少在存儲時的碰撞 -
將計算拿到的hashCode與當前HashMap內的數組的長度進行取模操作,得到一個非常小的值爲數組索引,這樣能夠減少數組的存儲索引
-
遍歷數組鏈表,如果查找到相同的則返回數據
2 HashMap java8
還是先通過無參構造看下有什麼不同:
/**
* 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; // all other fields defaulted
}
相比起java7的HashMap,java8的無參構造只是設置了一個負載率,沒有去設置數組的大小了。
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
// 相比起java7,hash計算方式不一樣,但其實是一樣的原理
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
// onlyIfAbsent:在找到key相同的時候,該參數爲true則不覆蓋原有的數值
// map.putIfAbsent(key, value)
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 最開始數組爲空的時候獲取數組長度,n爲數組長度,和java7一樣也是16
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// i = (n - 1) & hash其實和java7一樣二次哈希取模拿到數組索引
// 這個位置第一次添加數據
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
// 第二次添加數據
else {
Node<K,V> e; K k;
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, key, value);
else {
// 遍歷數組索引的節點列表
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
// 節點沒有數據,插入數據,java8就沒有使用頭插法,而是將數據插入到尾部
p.next = newNode(hash, key, value, null);
// TREEIFY_THRESHOLD = 8
// 當前數組索引的節點數量超過8個,但數組長度沒超過64個,就先擴容,
// 當前數組索引的節點數量超過8個,且數組長度超過64個,將當前數組索引的鏈表節點轉換成紅黑樹節點
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;
// java8的擴容比java7的簡單,只需要判斷長度是否需要擴容,需要就直接擴容
// 但和java7不同的是,java7是先擴容後插入數據,java8是先插入數據後擴容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
// MIN_TREEIFY_CAPACITY = 64
// 當前數組索引的節點數量超過8個,但數組長度沒超過64個,就先擴容
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
// 當前數組索引的節點數量超過8個,且數組長度超過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);
if ((tab[index] = hd) != null)
hd.treeify(tab);
}
}
-
在存入數據的時候創建數組,數組大小爲16
-
獲取
key
的hashCode然後做位運算(即擾動計算)然後再和HashMap長度取模做二次哈希拿到數組索引-
如果是第一次存入則直接在該數組索引位置添加節點,然後檢查是否需要擴容,需要則擴容
-
如果不是第一次存入:
-
判斷到節點數據是否相同則替換
-
如果當前數組索引位置的鏈表數量超過8,但數組長度沒有超過64,進行擴容
-
如果當前數組索引位置的鏈表數量超過8,且數組長度超過64,將鏈表轉換成紅黑樹
-
如果該數組索引位置已經是一個紅黑樹,用紅黑樹的方式添加數據
-
-
3 兩個版本的HashMap對比
相同點:
-
創建數組都是在
put()
的時候創建,數組初始長度是16 -
獲取存儲數組索引的位置相同(key.hashcode->位運算擾動計算->二次哈希取模)
-
數組擴容大小爲原大小的2倍
不同點:
-
java8的HashMap數據結構是
數組+鏈表+紅黑樹
,以及在滿足情況下(數組索引位置超過8且數組長度大於64的時候)會轉換成數組+紅黑樹
;java7的HashMap數據結構是數組+鏈表
-
java8數據結構爲數組+鏈表時,存儲鏈表數據不是頭插法,java7是頭插法
-
java8會先插入數據後再判斷擴容;java7是先判斷擴容再插入數據
4 小結
通過上面的分析,在面對面試官提問HashMap的問題,不外乎這幾個問題:
- java7 HashMap的數據結構是怎樣的?
答:數組+鏈表
- java7 HashMap怎麼在鏈表上添加數據,在鏈表的前面還是鏈表的後面?
答:頭插法
- java7 HashMap是怎麼預防和解決Hash衝突的?
答:二次哈希 + 拉鍊法
- java7 HashMap默認容量是多少?爲什麼是16可以是15嗎?
答:默認容量是16,需要是2的冪次方
- java7 HashMap的數組是什麼時候創建的?
答:首次調用put()時創建
- java7和java8 HashMap數據結構有什麼不同?
答:java7 HashMap數據結構是數組+鏈表,java8 HashMap數據結構是數組+鏈表+紅黑樹
- java7和java8插入數據的方式?
答:java7的鏈表是從前面插入的,java8的鏈表從後面插入
- 擴容後存儲位置的計算方式?
答:java7通過再次indexFor()找到數組位置,java8通過高低位的桶直接在鏈表尾部添加
- HashMap什麼時候會把鏈表轉化爲紅黑樹?
答:鏈表長度超過8,並且數組長度超過64