1. 程式人生 > >原始碼解析之HashMap實現原理

原始碼解析之HashMap實現原理

目錄

二,栗子

一,寫在前面

在日常開發中,HashMap因其可以儲存鍵值對的特點經常被使用,僅僅知道如何使用HashMap是遠遠不夠的。以知其然知其所以然的鑽研態度,本篇文章將以圖文,原始碼的方式去解析HashMap的實現原理。

二,栗子

首先咱們來看一段程式碼,比較簡單,就不多解釋啦~

程式碼如下:

import java.util.HashMap;

public class Test {

	public static void main(String[] args) {
		HashMap<Person, Integer> map = new HashMap<Person, Integer>();
		map.put(new Person(5, "bryant"), 8);
		map.put(new Person(3, "james"), 23);
		
		System.out.println(map.get(new Person(5, "kobe")));//8
		System.out.println(map.get(new Person(3, "lebron")));//23
	}
}

class Person {
	private int _id;
	private String name;
	public Person(int _id, String name) {
		super();
		this._id = _id;
		this.name = name;
	}
	
	@Override
	public int hashCode() {
		return new Integer(_id).hashCode();
	}
	
	@Override
	public boolean equals(Object obj) {
		if (obj == null) return false;
		return this._id == ((Person)obj)._id;
	}
	
}

定義了一個Person類,裡面有兩個欄位_id,name,分別重寫hashCode,equals方法,都是_id相同則返回true。

列印結果如下:

8
23

對上述程式碼做一下簡單的修改,刪掉hashCode方法,列印結果如下:

null
null

想必大家都知道一個這樣的知識點:在重寫一個類的equals方法時,需要去重寫hashCode方法。那麼為啥需要重寫hashCode方法呢?在下面的HashMap的原理解析中,就可以很好回答這個問題。之所以展示一個上述的栗子,是為了讓讀者有興趣跟著筆者的腳步,去一步步探索HashMap內部實現的奧祕。

三,HashMap設計思路

為了讓大家更好的理解HashMap的實現原理,下面會先介紹其設計思路。閱讀下面的內容,對照下圖會更易從整體上理解HashMap的設計思路。

為了實現高效的查詢,插入,刪除元素,HashMap底層採用陣列+連結串列+紅黑樹的資料結構。

陣列的特點:查詢操作效率較高,根據索引查詢只需要一次,但插入和刪除操作效率較低,會移動整個陣列。

連結串列的特點:查詢操作效率較低,需要遍歷整個連結串列,但插入刪除的效率較高,只需要改變其next引用即可。

為了高效的執行查詢,插入和刪除操作,HashMap採用了陣列+連結串列配合使用的方式,並在一定條件下將連結串列轉化為紅黑樹(會面會講到)。我們知道在put一個鍵值對時,包含有key,value兩個資料,在HashMap中提供了Node類來封裝鍵值對的資料。

Node類原始碼如下:

static class Node<K,V> implements Map.Entry<K,V> {
        final int hash; //雜湊函式的值
        final K key;    //key
        V value;        //value
        Node<K,V> next; //連結串列結構上的下一個元素

        Node(int hash, K key, V value, Node<K,V> next) {
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        }

    // ...
}

檢視Node的原始碼可知,是一個典型的連結串列結構,並實現了Entry介面,Entry是Map集合裡一個內部介面。

上面提到HashMap中採用了陣列的資料結構,因為它裡面維護了一個數組table,原始碼如下:

    /**
     * The table, initialized on first use, and resized as
     * necessary. When allocated, length is always a power of two.
     * (We also tolerate length zero in some operations to allow
     * bootstrapping mechanics that are currently not needed.)
     */
    transient Node<K,V>[] table;

HashMap的建構函式中並沒有初始化table陣列,那麼它是在哪裡初始化的呢,下面會一一解答。table陣列第一次初始化預設容量是16,在呼叫put方法存放一個鍵值對時,會做如下操作:

  1. 首先會呼叫雜湊函式去計算key對應的hash;
  2. 然後執行位運算hash&(table.length-1),得到結點Node在陣列中的儲存位置index;
  3. 若陣列的index位置沒有結點,則直接將該結點存入陣列;若該index位置有結點,又分如下兩種情況:
  • 該位置存放著一個連結串列(見上面的設計圖),在連結串列結構上插入元素,若key相同則替換其value值,不插入新的結點
  • 該位置存放著一個紅黑樹(見上面的設計圖),在紅黑樹上插入元素,若key相同則替換其value值,不插入新的結點

連結串列存在的目的?

在步驟1中,呼叫雜湊函式去計算key對應的hash,有可能存在多個不同的物件hash的值相同,也叫“雜湊碰撞”,“雜湊衝突”。在出現雜湊衝突時,多個key對應的儲存位置index是相同的,連結串列的next引用就是解決這種情況的。

紅黑樹存在的目的?

如果咱們要查詢的結點剛在連結串列的最下面,那麼每次都需要遍歷完整個連結串列,在連結串列的長度比較短的時候還可以。若任由連結串列長度無限的增加下去,勢必會使查詢操作的效率大大降低。因此,在HashMap底層規定當連結串列的結點數大於8時,會將連結串列轉化為紅黑樹。

紅黑樹是二叉樹的一種,它有左子樹小於根結點,右子樹大於根結點等特點。紅黑樹的查詢,插入刪除操作都比較高效,其層級比連結串列少方便查詢。關於紅黑樹的具體介紹,可參考文章初戀紅黑樹

HashMap中規定,在紅黑樹的結點個數小於6個時,會將紅黑樹轉化為連結串列結構。

位運算hash&(table.length-1)的原因?

hash變數是呼叫hash函式得到的值,檢視hash函式的原始碼:

    static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

如果key是null,則返回0;如果不為0,首先計算出key的hashCode值,再執行hashCode值的低16位和高16位的異或運算。

從設計原則上來說,更多的使用陣列的空間,不管是查詢,插入刪除都是很方便的,只需要根據Key對應的index值,並執行相關操作即可。越少的出現雜湊衝突,連結串列的長度越短,陣列的空間越被充分利用,HashMap操作資料的效率越高。

那麼如何減少雜湊衝突呢?

那麼需要key對應的儲存位置index儘可能的不同。

首先呼叫hash函式,將key的hashCode值的低16位於高16位進行異或運算,充分的使用hashCode的32個二進位制資料進行運算(int是4個位元組),得到變數hash。

然後執行位運算hash&(table.length-1),由於陣列長度是16,那麼table.length-1是15,二進位制表示:1111。我們思考這樣的一個問題,當陣列是16時,hash變數與的是1111,最後會得到hash變數最低4位的值,其範圍是0~15。當陣列是15時,hash變數與的是1110,那麼不管hash變數的最低1位是0或1,得到的值都是0。也就是說,1010,1011與上1110都是1010,兩個不同的hash變數有得到同一個儲存位置index的可能,這樣會更大概率出現雜湊衝突。因此,HashMap在設計陣列的初始長度為16,陣列的擴容也是乘以2。

小結:hash函式利用key的hashCode的高16位和低16位的異或運算,減少了雜湊衝突。設計陣列長度是16,在執行hash&(table.length-1)運算時,減少了雜湊衝突。減少了雜湊衝突,充分利用陣列空間,HashMap的查詢,插入和刪除操作會更高效。

四,邊界變數

    /**
     * The default initial capacity - MUST be a power of two.
     */
    //陣列的初始容量
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

    //允許的陣列的最大容量
    static final int MAXIMUM_CAPACITY = 1 << 30;

    //載入因子
    static final float DEFAULT_LOAD_FACTOR = 0.75f;

    //當連結串列的長度大於8,轉化為紅黑樹
    static final int TREEIFY_THRESHOLD = 8;

    //當紅黑樹的結點個數小於6,轉化為連結串列
    static final int UNTREEIFY_THRESHOLD = 6;

在HashMap中有一個threshold變數,threshold=陣列的大小*載入因子。當集合中的結點個數大於threshold時,會進行陣列擴容。

五,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; //臨時變數,指向table陣列
		Node<K, V> p;	  //臨時變數,執行陣列中位置為i的結點
		int n, i;		  //n,臨時變數,記錄陣列長度; i,臨時變數,記錄Node的儲存位置
		if ((tab = table) == null || (n = tab.length) == 0)
			n = (tab = resize()).length; //resize方法初始化陣列
		if ((p = tab[i = (n - 1) & hash]) == null) //數組裡沒有結點
			tab[i] = newNode(hash, key, value, null);
		else {
			Node<K, V> e;  //臨時變數,儲存key相同情況下的結點
			K k;
			if (p.hash == hash   //hash變數相同,且key相同
					&& ((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) {
						p.next = newNode(hash, key, value, null);
						if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
							treeifyBin(tab, hash); //連結串列長度為8時,轉化為紅黑樹
						break;
					}
					if (e.hash == hash  // 處理key相同的情況
							&& ((k = e.key) == key || (key != null && key
									.equals(k))))
						break;
					p = e;  //p = p.next
				}
			}
			if (e != null) { // existing mapping for key
				V oldValue = e.value;
				if (!onlyIfAbsent || oldValue == null)
					e.value = value;
				afterNodeAccess(e);
				return oldValue; //key相同情況下,返回舊的value值
			}
		}
		++modCount; //監測內部資料結構的變化,但不包括key相同的情況
		if (++size > threshold) //集合中元素大於12
			resize();   //陣列擴容
		afterNodeInsertion(evict);
		return null; //key不相同的情況,返回null
	}

程式碼中有具體解釋,這裡就不帶大家一行行分析putVal方法的原始碼了。

初次呼叫put方法,會呼叫resize方法初始化table陣列,執行hash&(tab.length-1)獲取結點在陣列的儲存位置,並直接將Node存入陣列。後面繼續呼叫put方法,先處理陣列中結點的Key與插入結點相同的情況,然後處理陣列中結點是紅黑樹,連結串列的情況。若陣列的結點是連結串列結構,遍歷連結串列並插入新的結點,並處理新舊結點的key相同的情況。若連結串列的長度大於8,則轉化為紅黑樹。新舊結點的key相同的情況,使用臨時變數e儲存舊結點,並返回e.value。當集合中鍵值對大於12時,呼叫resize方法擴容陣列。

六,resize方法

	final Node<K, V>[] resize() {
		Node<K, V>[] oldTab = table;  //臨時變數,指向陣列table
		int oldCap = (oldTab == null) ? 0 : oldTab.length;
		int oldThr = threshold;
		int newCap, newThr = 0;
		if (oldCap > 0) { //陣列有結點的情況
			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  //修改邊界值,擴大集合中允許的結點個數
		} else if (oldThr > 0) // initial capacity was placed in threshold
			newCap = oldThr;
		else { // zero initial threshold signifies using defaults
			
			//第一次呼叫put方法,陣列沒初始化的情況
			newCap = DEFAULT_INITIAL_CAPACITY;  //16
			newThr = (int) (DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);  //16 * 0.75 = 12
		}
		if (newThr == 0) {
			float ft = (float) newCap * loadFactor;
			newThr = (newCap < MAXIMUM_CAPACITY
					&& ft < (float) MAXIMUM_CAPACITY ? (int) ft
					: Integer.MAX_VALUE);
		}
		threshold = newThr; //修改threshold變數的值
		@SuppressWarnings({ "rawtypes", "unchecked" })
		Node<K, V>[] newTab = (Node<K, V>[]) new Node[newCap]; //初始化陣列,或建立擴容後的陣列
		table = newTab;  //修改table變數
		if (oldTab != null) { //處理陣列擴容的情況
			for (int j = 0; j < oldCap; ++j) { //遍歷舊陣列的結點
				Node<K, V> e;
				if ((e = oldTab[j]) != null) { //臨時遍歷e, 指向舊陣列中結點
					oldTab[j] = null;  //舊陣列結點置空
					if (e.next == null)
						//在新陣列中重新確定結點的位置,演算法與陣列大小為16時相同
						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;
							if ((e.hash & oldCap) == 0) { //若hash變數的第5位二進位制值為0
								if (loTail == null)
									loHead = e;
								else
									loTail.next = e;
								loTail = e;
							} else { //若hash變數的第5位二進位制值為1
								if (hiTail == null)
									hiHead = e;
								else
									hiTail.next = e;
								hiTail = e;
							}
						} while ((e = next) != null);
						if (loTail != null) {
							loTail.next = null;
							newTab[j] = loHead; //hash變數的第5位二進位制值為0的情況
						}
						if (hiTail != null) {
							hiTail.next = null;
							newTab[j + oldCap] = hiHead; //hash變數的第5位二進位制值為1的情況
						}
					}
				}
			}
		}
		return newTab;
	}

程式碼中有具體解釋,這裡就不帶大家一行行分析resize方法的原始碼了。

resize方法做了兩件事,一個是初始化陣列,一個是陣列擴容。在陣列擴容時,會重新建立新的陣列,由於陣列的長度tab.length發生變化,hash&(tab.length-1)得到的值發生變化。例如陣列大小從16擴容到32時,tab.length-1是31,二進位制表示是11111。hash變數在進行與運算時,第5位二進位制會參與運算。若第5位二進位制是0,則位置不變;若是1,則陣列存放位置增加16,剛好是舊陣列的大小。

因此,遍歷連結串列重新確定結點的位置時,需要判斷(e.hash & oldCap) == 0,就是判斷hash的第5位二進位制是0還是1,從而確定連結串列中的結點在新陣列中的儲存位置。HashMap在擴容時,可能會改變結點在陣列中儲存位置,蛋糕重分,由此可知HashMap儲存元素的位置並不穩定。

七,get方法

    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) {  //table陣列該位置有結點
            if (first.hash == hash && // always check first node
                ((k = first.key) == key || (key != null && key.equals(k))))
            	//key相同的情況
                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); //遍歷連結串列,直到找到相同的key
            }
        }
        return null;
    }

程式碼中有具體解釋,這裡就不帶大家一行行分析get方法的原始碼了。

首先通過key獲取其在陣列中的儲存位置index,分三種情況尋找相同的key:

  1. 陣列中的結點的key是否相同;
  2. 陣列中的結點的key不相同,處理是連結串列的情況,並遍歷連結找到符合條件的Key;
  3. 陣列中的結點的key不相同,處理是紅黑樹的情況;

HashMap是如何判斷key是否相同呢?

if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
  • e.hash == hash,hash變數的值取決於key的hashCode的值,因此需要key的hashCode相同,也就是hashCode方法返回值要相同;
  • (k = e.key) == key,判斷兩個物件是否相同,則判定key相同;
  • key != null && key.equals(k) ,呼叫equals方法返回true,則判定key相同;

小結:想正確的獲取HashMap中集合的元素,判定key是否相同,要同時重寫的hashCode方法和equals方法。

八,關於HashMap實現原理的問答題

1,HashMap的實現原理,內部資料結構?

底層使用雜湊表,也就是陣列+連結串列,當連結串列長度超過8個時會轉化為紅黑樹,以實現查詢的時間複雜度為log n。

2,HashMap中put方法的過程?

呼叫雜湊函式獲取Key對應的hash值,再計算其陣列下標;

如果沒有出現雜湊衝突,則直接放入陣列;如果出現雜湊衝突,則以連結串列的方式放在連結串列後面;

如果連結串列的長度超過8,則會轉化為紅黑樹;

如果結點的key已經存在,則替換其value即可;

如果集合中的鍵值對大於12,呼叫resize方法進行陣列擴容;

3,雜湊函式怎麼實現的?

呼叫Key的hashCode方法獲取hashCode值,並將該值的高16位和低16位進行異或運算。

4,雜湊衝突怎麼解決?

將新結點新增在連結串列後面

5,陣列擴容的過程?

建立一個新的陣列,其容量為舊陣列的兩倍,並重新計算舊陣列中結點的儲存位置。結點在新陣列中的位置只有兩種,原下標位置或原下標+舊陣列的大小。

6,除了鏈地址法,雜湊衝突的其他解決方案?

開放定址法:發生雜湊衝突,尋找另一個未被佔用的陣列地址

再雜湊法:提供多個雜湊函式,直到不再產生衝突;

建立公共溢位區:將雜湊表分為基本表和溢位表,產生雜湊衝突的結點放入溢位表

                                                                                                                    O(∩_∩)O