HashMap源碼解析

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

發佈了199 篇原創文章 · 獲贊 7 · 訪問量 1萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章