1.hashMap底層實現原理
可以訪問這篇文檔 --->傳送門
2.hashMap是怎樣取值和設置
HashMap基於hashing原理,我們通過put()和get()方法儲存和獲取對象。當我們將鍵值對傳遞給put()方法時,它調用鍵對象的hashCode()方法來計算hashcode,讓后找到bucket位置來儲存值對象。當獲取對象時,通過鍵對象的equals()方法找到正確的鍵值對,然后返回值對象。HashMap使用鏈表來解決碰撞問題,當發生碰撞了,對象將會儲存在鏈表的下一個節點中。 HashMap在每個鏈表節點中儲存鍵值對對象
當兩個不同的鍵對象的hashcode相同時會發生什么? 它們會儲存在同一個bucket位置的鏈表中。鍵對象的equals()方法用來找到鍵值對。
3.HashMap和Hashtable的區別
HashMap和Hashtable都實現了Map接口,但決定用哪一個之前先要弄清楚它們之間的分別。
主要的區別有:線程安全性,同步(synchronization),以及速度。
HashMap是非synchronized的,並可以接受null(HashMap可以接受為null的鍵值(key)和值(value),而Hashtable則不行)。
- HashMap是非synchronized,而Hashtable是synchronized,這意味着Hashtable是線程安全的,多個線程可以共享一個Hashtable;而如果沒有正確的同步的話,多個線程是不能共享HashMap的。Java 5提供了ConcurrentHashMap,它是HashTable的替代,比HashTable的擴展性更好。
- 由於Hashtable是線程安全的也是synchronized,所以在單線程環境下它比HashMap要慢。如果你不需要同步,只需要單一線程,那么使用HashMap性能要好過Hashtable。
- HashMap不能保證隨着時間的推移Map中的元素次序是不變的。
4.HashMap和HashSet的區別
1.什么是HashSet
HashSet實現了Set接口,它不允許集合中有重復的值,當我們提到HashSet時,第一件事情就是在將對象存儲在HashSet之前,要先確保對象重寫equals()和hashCode()方法,這樣才能比較對象的值是否相等,以確保set中沒有儲存相等的對象。如果我們沒有重寫這兩個方法,將會使用這個方法的默認實現。
public boolean add(Object o)方法用來在Set中添加元素,當元素值重復時則會立即返回false,如果成功添加的話會返回true。
2.什么是HashMap
HashMap實現了Map接口,Map接口對鍵值對進行映射。Map中不允許重復的鍵。Map接口有兩個基本的實現,HashMap和TreeMap。TreeMap保存了對象的排列次序,而HashMap則不能。HashMap允許鍵和值為null。HashMap是非synchronized的,但collection框架提供方法能保證HashMap synchronized,這樣多個線程同時訪問HashMap時,能保證只有一個線程更改Map。
public Object put(Object Key,Object value)方法用來將元素添加到map中
5.HashMap基礎
HashMap繼承了AbstractMap類,實現了Map,Cloneable,Serializable接口
HashMap的容量,默認是16
/** * The default initial capacity - MUST be a power of two. */ static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16 HashMap的加載因子,默認是0.75 /** * The load factor used when none specified in constructor. */ static final float DEFAULT_LOAD_FACTOR = 0.75f;
當HashMap中元素數超過容量*加載因子時,HashMap會進行擴容。
5.1HashMap實現原理
Node和Node鏈
首先來了解一下HashMap中的元素類型
HashMap類中的元素是Node類,翻譯過來就是節點,是定義在HashMap中的一個內部類,實現了Map.Entry接口。
Node類的定義如下:
/** * Basic hash bin node, used for most entries. (See below for * TreeNode subclass, and in LinkedHashMap for its Entry subclass.) */ static class Node<K,V> implements Map.Entry<K,V> { final int hash; final K key; V 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; } public final K getKey() { return key; } public final V getValue() { return value; } public final String toString() { return key + "=" + value; } public final int hashCode() { return Objects.hashCode(key) ^ Objects.hashCode(value); } public final V setValue(V newValue) { V oldValue = value; value = newValue; return oldValue; } public final boolean equals(Object o) { if (o == this) return true; if (o instanceof Map.Entry) { Map.Entry<?,?> e = (Map.Entry<?,?>)o; if (Objects.equals(key, e.getKey()) && Objects.equals(value, e.getValue())) return true; } return false; } }
可以看到,Node類的基本屬性有:
hash:key的哈希值
key:節點的key,類型和定義HashMap時的key相同
value:節點的value,類型和定義HashMap時的value相同
next:該節點的下一節點
值得注意的是其中的next屬性,記錄的是下一個節點本身,也是一個Node節點,這個Node節點也有next屬性,記錄了下一個節點,於是,只要不斷的調用Node.next.next.next……,就可以得到:
Node-->下個Node-->下下個Node……-->null
這樣的一個鏈表結構,而對於一個HashMap來說,只要明確記錄每個鏈表的第一個節點,就能順序遍歷鏈表上的所有節點。
拉鏈法
HashMap使用拉鏈法管理其中的每個節點。
由Node節點組成鏈表之后,HashMap定義了一個Node數組:
transient Node<K,V>[] table;
這個數組記錄了每個鏈表的第一個節點,於是最終形成了HashMap下面這樣的數據結構:
這種數組+鏈表的數據結構,使得HashMap可以較為高效的管理每一個節點。
關於Node數組 table
對於table的理解,對后面關於擴容的理解很有幫助。
table在第一次往HashMap中put元素的時候初始化。
如果HashMap初始化的時候沒有指定容量,那么初始化table的時候會使用默認的DEFAULT_INITIAL_CAPACITY參數,也就是16,作為table初始化時的長度。
如果HashMap初始化的時候指定了容量,HashMap會把這個容量修改為2的倍數,然后創建對應長度的table。
table在HashMap擴容的時候,長度會翻倍。
所以table的長度肯定是2的倍數。
修改容量的方法是這樣的:
/** * Returns a power of two size for the given target capacity. */ static final int tableSizeFor(int cap) { int n = cap - 1; n |= n >>> 1; n |= n >>> 2; n |= n >>> 4; n |= n >>> 8; n |= n >>> 16; return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1; }
所以要注意,如果要往HashMap中放1000個元素,又不想讓HashMap不停的擴容,最好一開始就把容量設為2048,設為1024不行,因為元素添加到七百多的時候還是會擴容。
散列算法
當調用HashMap.put()方法時,經歷了以下步驟:
1,對key進行hash值計算
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
2,hash值和table.length取模
取模的方法是(table.length - 1) & hash,算法直接舍棄了二進制hash值在table.length以上的位,因為那些位都代表table.length的2的n次方倍數。
取模的結果就是Node將要放入table的下標。
比如,一個Node的hash值是5,table長度是4,那么取余的結果是1,也就是說,這個Node將被放入table[1]所代表的鏈表(table[1]本身指向的是鏈表的第一個節點)。
3,添加元素
如果此時table的對應位置沒有任何元素,也就是table[i]=null,那么就直接把Node放入table[i]的位置,並且這個Node的next==null。
如果此時table對應位置是一個Node,說明對應的位置已經保存了一個Node鏈表,則需要遍歷鏈表,如果發現相同hash值則替換Node節點,如果沒有相同hash值,則把新的Node插入鏈表的末端,作為之前末端Node的next,同時新Node的next==null。
如果此時table對應位置是一個TreeNode,說明鏈表被轉換成了紅黑樹,則根據hash值向紅黑樹中添加或替換TreeNode。(JDK1.8)
4,如果添加元素之后,Node鏈表的節點數超過了8個,則該鏈表會考慮轉為紅黑樹。(JDK1.8)
5,如果添加元素之后,HashMap總節點數超過了閾值,則HashMap會進行擴容。
相關代碼是這樣的:
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; Node<K,V> p; int n, i; if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; if ((p = tab[i = (n - 1) & hash]) == null) //注釋1 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)))) //注釋2 e = p; else if (p instanceof TreeNode) //注釋3 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); //注釋4 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; if (++size > threshold) //注釋5 resize(); afterNodeInsertion(evict); return null; }
代碼解析:
1,注釋1,table對應位置無節點,則創建新的Node節點放入對應位置。
2,注釋2,table對應位置有節點,如果hash值匹配,則替換。
3,注釋3,table對應位置有節點,如果table對應位置已經是一個TreeNode,不再是Node,也就說,table對應位置是TreeNode,表示已經從鏈表轉換成了紅黑樹,則執行插入紅黑樹節點的邏輯。
4,注釋4,table對應位置有節點,且節點是Node(鏈表狀態,不是紅黑樹),鏈表中節點數量大於TREEIFY_THRESHOLD,則考慮變為紅黑樹。實際上不一定真的立刻就變,table短的時候擴容一下也能解決問題,后面的代碼會提到。
5,注釋5,HashMap中節點個數大於threshold,會進行擴容。
HashMap擴容機制 -----resize()
什么時候擴容:當向容器添加元素的時候,會判斷當前容器的元素個數,如果大於等於閾值(念yu值四聲)---即當前數組的長度乘以加載因子的值的時候,就要自動擴容啦。
擴容(resize)就是重新計算容量,向HashMap對象里不停的添加元素,而HashMap對象內部的數組無法裝載更多的元素時,對象就需要擴大數組的長度,以便能裝入更多的元素。當然Java里的數組是無法自動擴容的,方法是使用一個新的數組代替已有的容量小的數組,就像我們用一個小桶裝水,如果想裝更多的水,就得換大水桶。
先看一下什么時候,resize();
/** * HashMap 添加節點 * * @param hash 當前key生成的hashcode * @param key 要添加到 HashMap 的key * @param value 要添加到 HashMap 的value * @param bucketIndex 桶,也就是這個要添加 HashMap 里的這個數據對應到數組的位置下標 */ void addEntry(int hash, K key, V value, int bucketIndex) { //size:The number of key-value mappings contained in this map. //threshold:The next size value at which to resize (capacity * load factor) //數組擴容條件:1.已經存在的key-value mappings的個數大於等於閾值 // 2.底層數組的bucketIndex坐標處不等於null if ((size >= threshold) && (null != table[bucketIndex])) { resize(2 * table.length);//擴容之后,數組長度變了 hash = (null != key) ? hash(key) : 0;//為什么要再次計算一下hash值呢? bucketIndex = indexFor(hash, table.length);//擴容之后,數組長度變了,在數組的下標跟數組長度有關,得重算。 } createEntry(hash, key, value, bucketIndex); } /** * 這地方就是鏈表出現的地方,有2種情況 * 1,原來的桶bucketIndex處是沒值的,那么就不會有鏈表出來啦 * 2,原來這地方有值,那么根據Entry的構造函數,把新傳進來的key-value mapping放在數組上,原來的就掛在這個新來的next屬性上了 */ void createEntry(int hash, K key, V value, int bucketIndex) { HashMap.Entry<K, V> e = table[bucketIndex]; table[bucketIndex] = new HashMap.Entry<>(hash, key, value, e); size++; }
我們分析下resize的源碼,鑒於JDK1.8融入了紅黑樹,較復雜,為了便於理解我們仍然使用JDK1.7的代碼,好理解一些,本質上區別不大,具體區別后文再說
void resize(int newCapacity) { //傳入新的容量 Entry[] oldTable = table; //引用擴容前的Entry數組 int oldCapacity = oldTable.length; if (oldCapacity == MAXIMUM_CAPACITY) { //擴容前的數組大小如果已經達到最大(2^30)了 threshold = Integer.MAX_VALUE; //修改閾值為int的最大值(2^31-1),這樣以后就不會擴容了 return; } Entry[] newTable = new Entry[newCapacity]; //初始化一個新的Entry數組 transfer(newTable); //!!將數據轉移到新的Entry數組里 table = newTable; //HashMap的table屬性引用新的Entry數組 threshold = (int) (newCapacity * loadFactor);//修改閾值 }
代碼中可以看到,如果原有table長度已經達到了上限,就不再擴容了。
如果還未達到上限,則創建一個新的table,並調用transfer方法:
這里就是使用一個容量更大的數組來代替已有的容量小的數組,transfer()方法將原有Entry數組的元素拷貝到新的Entry數組里。
void transfer(Entry[] newTable) { Entry[] src = table; //src引用了舊的Entry數組 int newCapacity = newTable.length; for (int j = 0; j < src.length; j++) { //遍歷舊的Entry數組 Entry<K, V> e = src[j]; //取得舊Entry數組的每個元素 if (e != null) { src[j] = null;//釋放舊Entry數組的對象引用(for循環后,舊的Entry數組不再引用任何對象) do { Entry<K, V> next = e.next; //注釋1 int i = indexFor(e.hash, newCapacity); //!!重新計算每個元素在數組中的位置 //注釋2 e.next = newTable[i]; //標記[1] //注釋3 newTable[i] = e; //將元素放在數組上 //注釋4 e = next; //訪問下一個Entry鏈上的元素 //注釋5 } while (e != null); } } }
static int indexFor(int h, int length) { return h & (length - 1); }
transfer方法的作用是把原table的Node放到新的table中,使用的是頭插法,也就是說,新table中鏈表的順序和舊列表中是相反的,在HashMap線程不安全的情況下,這種頭插法可能會導致環狀節點。
其中的while循環描述了頭插法的過程,這個邏輯有點繞,下面舉個例子來解析一下這段代碼。
假設原有table記錄的某個鏈表,比如table[1]=3,鏈表為3-->5-->7,那么處理流程為:
1,注釋1:記錄e.next的值。開始時e是table[1],所以e==3,e.next==5,那么此時next==5。
2,注釋2,計算e在newTable中的節點。為了展示頭插法的倒序結果,這里假設e再次散列到了newTable[1]的鏈表中。
3,注釋3,把newTable [1]賦值給e.next。因為newTable是新建的,所以newTable[1]==null,所以此時3.next==null。
4,注釋4,e賦值給newTable[1]。此時newTable[1]=3。
5,注釋5,next賦值給e。此時e==5。
此時newTable[1]中添加了第一個Node節點3,下面進入第二次循環,第二次循環開始時e==5。