HashMap重點詳解
Map即映射表一般稱為散列表。開發中常用到這種數據結構,Java中HashMap和ConcurrentHashMap被用到的頻率較高,本文重點說下HashMap的實現原理以及設計思路。
HashMap的本質是一個數組,數組的每個索引被稱為桶,每個桶裏放著一個單鏈表,一個節點連著一個節點。很明顯通過下標來檢索數組元素時間復雜度為O(1),而且遍歷鏈表的時間復雜度是常數級別,所以整體的查詢復雜度仍為O(1)。我們先來看下HashMap的成員屬性:
// 默認的初始容量是16,必須是2的冪(這點很重要,後面講述原因) static final int DEFAULT_INITIAL_CAPACITY = 16;// 最大容量(必須是2的冪且小於2的30次方,傳入容量過大將被這個值替換) static final int MAXIMUM_CAPACITY = 1 << 30; // 默認加載因子 static final float DEFAULT_LOAD_FACTOR = 0.75f; // 存儲數據的Entry數組 transient Entry[] table; // HashMap的大小,它是HashMap實際保存的鍵值對的數量 transient int size; // HashMap的閾值(threshold = 容量*加載因子),就是通過它和//size進行比較來判斷是否需要擴容 int threshold; // 加載因子實際大小 final float loadFactor; // HashMap被改變的次數(用於快速失敗,後面詳細講) transient volatile int modCount;
成員屬性的意義如上所述,我們再來看下它們修飾符的設計含義:table和size以及modCount都被transient所修飾,transient為短暫的意思,java中只能用來修飾類成員變量,作用是對象序列化時被修飾的字段不會被序列化到目的地。很容易想到:map只要執行put或remove操作後三者的值都會產生變化,對於這種狀態常變(短暫)的屬性我們沒必要在對象序列化時將其值帶入。此外,modCount還被volitile修飾,這個關鍵字主要作用是使被修飾的變量在內存中的變化可被多線程所見,因為modCount用於快速失敗機制,所以寫線程執行時帶來的變化需及時被讀線程知道。
我們再看下Entry類:
static class Entry<K,V> implements Map.Entry<K,V> { final K key; V value; // 指向下一個節點 Entry<K,V> next; final int hash; // 構造函數。 // 輸入參數包括"哈希值(h)", "鍵(k)", "值(v)", "下一節點(n)" Entry(int h, K k, V v, Entry<K,V> n) { value = v; next = n; key = k; hash = h; } public final K getKey() { return key; } public final V getValue() { return value; } public final V setValue(V newValue) { V oldValue = value; value = newValue; return oldValue; } }
每個桶的Entry對象其實就是指的單鏈表,Entry作為hashMap的靜態內部類,實現了Map.Entry<K,V>接口。設計的很硬氣,所有的get&set都是final,不允許再被使用者重寫重定義了。
研究一種數據結構,知道了它的基本組成,就可進一步了解它的存取機制:map的get,put,remove。map無論是增刪查,經歷的第一步就是定位桶的位置,即通過對象的hashCode(其實map中又再次hash了一遍)來取模定位,然後遍歷桶中的鏈表元素進行equals比較。所以,我在這裏重點說下hashCode()和equals(Object o)兩個方法的關聯。
常說hashCode是equals的必要不充分條件,這個說法主要就是根據散列表來的。不重寫的情況下,hashCode默認返回對象在堆內存中的首地址,equals默認比較兩個對象在堆內存中的首地址。就equals而言,這種比較方式在實際業務中基本無意義,我們判斷兩個對象是否相等,通常根據他們的某些屬性值是否相等來判斷,就像根據ID和name我們就可以斷定一個員工的唯一性。eclipse或者idea現在都可默認為你的model生成equals方法,如下所示:
class Animal{ private int id; private String name; public int getId() { return id; } public void setId(int id) { this.id = id; } public String getName() { return name; } public void setName(String name) { this.name = name; } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || o.getClass()!=this.getClass()) return false; Animal animal = (Animal) o; if (id != animal.id) return false; return name != null ? name.equals(animal.name) : animal.name == null; } }
流程:如果首地址都相等那肯定就是一個對象,直接返回true,不等就繼續判斷是否同屬一個類,不是一個類那根本就不用繼續判斷直接false。這裏還是有爭議的,因為有的寫法是 !(o instanceof Animal),兩者的區別會在繼承中體現出來,比如我再創建一個子類Dog
class Dog extends Animal{ private double weight; public double getWeight() { return weight; } public void setWeight(double weight) { this.weight = weight; } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; if (!super.equals(o)) return false; Dog dog = (Dog) o; return Double.compare(dog.weight, weight) == 0; } }
Dog中添加了一個weight屬性,並在基類Animal的基礎上再次重寫了equals方法。看下面一段代碼:
Animal animal=new Animal(); animal.setId(1); animal.setName("dog"); Dog dog = new Dog(); dog.setId(1); dog.setName("dog");
dog.setWeight(1); System.out.print(animal.equals(dog));
如果按照 getClass() != o.getClass() 這個邏輯,兩者equals就直接false了,而按照!(o instanceof Animal)這個邏輯最終會返回true。理論講應該返回false的,否則weight這個字段的意義呢?被dog吃了?所以當該類下有子類時,equals中最好采用getClass()這種判斷方式。再看hashCode():
@Override public int hashCode() { int result = id; result = 31 * result + (name != null ? name.hashCode() : 0); return result; }
這時候就要思考為什麽hashCode值取決於ID和Name字段,我們知道在map裏尋找元素通過equals比較只是第二步驟,首要步驟是先定位到桶的位置(hash&length-1),如果兩個本equals的對象連hashCode都不相等,那就很容易造成下述3種情況:
1:get(key)的時候取不出來
2:put(k,v)的時候存了重復值
3:remove(key)的時候刪不掉
接下來,再了解下HashMap的構造方法:
// 指定“容量大小”和“加載因子”的構造函數 public HashMap(int initialCapacity, float loadFactor) { if (initialCapacity < 0) throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity); // HashMap的最大容量只能是MAXIMUM_CAPACITY if (initialCapacity > MAXIMUM_CAPACITY) initialCapacity = MAXIMUM_CAPACITY; if (loadFactor <= 0 || Float.isNaN(loadFactor)) throw new IllegalArgumentException("Illegal load factor: " + loadFactor); // 找出“大於initialCapacity”的最小的2的冪 int capacity = 1; while (capacity < initialCapacity) capacity <<= 1; // 設置“加載因子” this.loadFactor = loadFactor; // 設置“HashMap閾值”,當HashMap中存儲數據的數量達到threshold時,就需要將HashMap的容量加倍。 threshold = (int)(capacity * loadFactor); // 創建Entry數組,用來保存數據 table = new Entry[capacity]; init(); } // 指定“容量大小”的構造函數 public HashMap(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR); }
你不指定容量和加載因子時hashMap就按默認的給你,指定的話就按你的來,有意思的是hashmap怕你不夠懂它特意又對你賦的容量值進行了一次計算,轉化為小於該值的最大偶數。容量值為二次冪的設計魅力後面會講。
最後再簡單看下兩個方法我們奔主題了:
static int hash(int h) { h ^= (h >>> 20) ^ (h >>> 12); return h ^ (h >>> 7) ^ (h >>> 4); } // 返回索引值 // h & (length-1)保證返回值的小於length static int indexFor(int h, int length) { return h & (length-1); }
hashmap會對所有的key再重hash一次,至於為什麽這麽寫不需要理解,只需要知道一切都是最好的安排。indexFor則是用來定位key對應哪個桶。
準備完畢,開始看下get(key)的實現:
public V get(Object key) { if (key == null) return getForNullKey(); // 獲取key的hash值 int hash = hash(key.hashCode()); // 在“該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.equals(k))) return e.value; } return null; }
hashMap與hashTable其中不同的一點是前者允許key為null,這點設計的很取巧,把key為null的對象存在數組首位(table[0]),代碼如下:
private V getForNullKey() { for (Entry<K,V> e = table[0]; e != null; e = e.next) { if (e.key == null) return e.value; } return null; }
接下來的步驟就是:重hash->定位桶->遍歷桶中的鏈表一一比較。在判斷過程中會先判斷e.hash==hash,更印證了之前說的hashCode相等是equals成立的必要不充分條件。
再來看put方法的實現:
public V put(K key, V value) { // 若“key為null”,則將該鍵值對添加到table[0]中。 if (key == null) return putForNullKey(value); // 若“key不為null”,則計算該key的哈希值,然後將其添加到該哈希值對應的鏈表中。 int hash = hash(key.hashCode()); int i = indexFor(hash, table.length); for (Entry<K,V> e = table[i]; e != null; e = e.next) { Object k; // 若“該key”對應的鍵值對已經存在,則用新的value取代舊的value。然後退出! if (e.hash == hash && ((k = e.key) == key || key.equals(k))) { V oldValue = e.value; e.value = value; e.recordAccess(this); return oldValue; } } // 若“該key”對應的鍵值對不存在,則將“key-value”添加到table中 modCount++; addEntry(hash, key, value, i); return null; }
首先還是會先判斷key值是否為null,如果為null,則將該元素放置在數組0位置,如下圖所示:
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; }
我們知道在hashMap中存儲一個已有的key,新key對應的value值會替換掉old值。所以put操作會先判斷一下是否已經存在該key,存在的話就替換成新值返回老值。不存在執行addEntry返回null。這裏需要註意的是如果key之前存在過,替換舊值不會修改modCount,不存在該key則modCount+1。我們可以這麽認為,只有map中的元素數量增多或減少的情況下才認為map的結構的發生了變化。
接下來講一下重點方法:addEntry(xxx);擴容操作就是在這裏進行的
void addEntry(int hash, K key, V value, int bucketIndex) { // 保存“bucketIndex”位置的值到“e”中 Entry<K,V> e = table[bucketIndex]; // 設置“bucketIndex”位置的元素為“新Entry”, // 設置“e”為“新Entry的下一個節點” table[bucketIndex] = new Entry<K,V>(hash, key, value, e); // 若HashMap的實際大小 不小於 “閾值”,則調整HashMap的大小 if (size++ >= threshold) resize(2 * table.length); } void resize(int newCapacity) { Entry[] oldTable = table; int oldCapacity = oldTable.length; if (oldCapacity == MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return; } // 新建一個HashMap,將“舊HashMap”的全部元素添加到“新HashMap”中, // 然後,將“新HashMap”賦值給“舊HashMap”。 Entry[] newTable = new Entry[newCapacity]; transfer(newTable); table = newTable; threshold = (int)(newCapacity * loadFactor); } // 將HashMap中的全部元素都添加到newTable中 void transfer(Entry[] newTable) { Entry[] src = table; int newCapacity = newTable.length; for (int j = 0; j < src.length; j++) { Entry<K,V> e = src[j]; if (e != null) { src[j] = null; do { Entry<K,V> next = e.next; int i = indexFor(e.hash, newCapacity); e.next = newTable[i]; newTable[i] = e; e = next; } while (e != null); } } }
流程如下:
1:將新元素作為桶中鏈表的頭節點,如果達到閾值則第二步
2:擴容為原來2倍,如果之前容量已經是最大值了,則直接將閾值設為Int型的最大值返回(有點棄療的意思)
3:重新散列-->外循環遍歷每個桶,內循環遍歷每個桶中鏈表的每個節點,將每個節點定位到新的位置。
在多線程中,經過resize過程後,再涉及到叠代或者擴容操作時,會有一定幾率造成死循環或者數據丟失。
先看圖一:首先向length=2的map中插入三個元素(為方便畫圖這裏直接采用hash&length),最終桶1中形成鏈表3-7-5。
這時候A線程再添加一個元素,然後進行擴容操作,並將元素3房屋新的桶,此時元素3的next是7:
此時線程B添加了元素後也進行了擴容操作,且直接擴容完成,如下圖:
此時7的next指向了3而不再指向5
然後A線程繼續向下走的時候就出現了死循環問題,因為在線程A中3的next是指向7的,所以當再把7進行重定位時就出現了如下圖所示:
所以之後的遍歷或者擴容過程只要到了桶3,便會一直在7和3之間死循環。數據缺失的發生場景也是如此,可以自己分析。
下面來講下:為什麽map內部數組的長度要為2次冪。
我們知道數組的長度主要被用來做了這麽一件事,就是通過indexFor方法去定位key位於哪個桶,即 h & (length-1);
分析一下:&運算是同一位都為1時才為1,假如一個key的hash為43,即二進制為101011,map的長度為16
則indexFor:
101011
& 001111
——————
001011
為11,
當進行一次resize操作時,length=16<<1=32,再次進行indexFor操作:
101011
& 011111
——————
001011
依然為11。
我們可以很容易發現,如果length是2的次冪,length-1的二進制每位均是1,而擴容後-1二進制依然每位均是1,所以&的結果取決於hash的二進制,即有一半幾率該節點依然位於原來的桶(但節點依然是會移動的),一半幾率被分到了其他的桶,從而保證了擴容後節點分配的均衡性。這是其一。
其二:我們假如桶的長度不是2次冪,拿length=15舉例,length-1=14=1110。那麽這時候任何key與其&操作,最後一位都是0,這就意味著桶的第1個位置永遠都不會被放入元素!同理假如length-1=12=1100,那麽第1,2,3的位置也永遠不可能被放入元素。這會造成空間的浪費以及數據的分配不均。
以上,就是map的數組長度要為2次冪的奧秘所在。
順便在提一下除map外的其他容器的初始長度設定:拿StringBuilder來講,字符串相加時我們考慮到內存回收一般采用StringBuilder或StringBuffer的append來代替,那麽假如可以提前估算出一個字符串的大概長度,那麽請以這個大概長度直接在集合類的構造器中賦值進去,因為StringBuilder每次進行數組擴容的時候都會伴隨著元素的copy,頻繁的copy會一定程度上影響效率。ArrayList也是同理。
研究數據結構,我們還有一個重要的關註點就是元素的遍歷。java的集合類一般都會在內部實現一個叠代器即Iterator,它的意義是什麽呢?從客戶端角度來講,我可能並不關心目前操作的數據結構的內部實現,像ArrayList內部是個數組,LinkedList內部是個鏈表,HashMap內部又是個數組鏈表,I dont care。我只想拿它做個遍歷,而不是針對數組時使用索引遍歷,針對鏈表時使用xxx.next,map時又兩者並用,Iterator就解決了這個問題,它作為接口定義了hasNext(),next(),remove()三個核心方法。任何一個集合類,都可以通過自己的一個內部類去實現該接口然後對外提供遍歷方法和移除元素方法。現在通過hashmap的源碼來看下原理:
hashmap實現了三種Iterator,分別針對key,value,還有entry。源碼如下:
final class EntrySet extends AbstractSet<Map.Entry<K,V>> { public final int size() { return size; } public final void clear() { HashMap.this.clear(); } public final Iterator<Map.Entry<K,V>> iterator() { return new EntryIterator(); } } abstract class HashIterator { Node<K,V> next; // next entry to return Node<K,V> current; // current entry
int expectedModCount; // for fast-fail int index; // current slot HashIterator() { expectedModCount = modCount; Node<K,V>[] t = table; current = next = null; index = 0; if (t != null && size > 0) { // advance to first entry do {} while (index < t.length && (next = t[index++]) == null); } } public final boolean hasNext() { return next != null; } final Node<K,V> nextNode() { Node<K,V>[] t; Node<K,V> e = next; if (modCount != expectedModCount) throw new ConcurrentModificationException(); if (e == null) throw new NoSuchElementException(); if ((next = (current = e).next) == null && (t = table) != null) { do {} while (index < t.length && (next = t[index++]) == null); } return e; } public final void remove() { Node<K,V> p = current; if (p == null) throw new IllegalStateException(); if (modCount != expectedModCount) throw new ConcurrentModificationException(); current = null; K key = p.key; removeNode(hash(key), key, null, false, false); expectedModCount = modCount; } } final class KeyIterator extends HashIterator implements Iterator<K> { public final K next() { return nextNode().key; } } final class ValueIterator extends HashIterator implements Iterator<V> { public final V next() { return nextNode().value; } } final class EntryIterator extends HashIterator implements Iterator<Map.Entry<K,V>> { public final Map.Entry<K,V> next() { return nextNode(); } }
內部類的一大特征就是可以訪問主類的成員變量和成員方法,EntrySet和EntryIterator作為HashMap的內部類三者相輔相成,可以看到無論是next還是remove,實際上都是操作的主類hashMap的table。但是這種操作對外部是透明的,可以看到封裝的魅力。在HashIterator構造器中,modCount會被賦值到expectedModcount,顧名思義expectedModcount是期望的變化值,如果當前是多線程環境,進行next遍歷時,當前節點可能已被其他線程remove了,或者其他線程的put操作已經改變了當前節點的位置。這種情況下expectedModcount不再等於modCount,HashMap會認為該遍歷得到的數據是無效的,便執行快速失敗機制。這就是modCount被validate修飾的原因。當然這種快速失敗機制只是為了防止一定程度上的臟讀,而不是徹底解決並發問題。
說完Iterator我們再來談HashMap的遍歷方式,無需多說,數據量大的時候第一種遠高於第二種
/* * 通過entry set遍歷HashMap * 效率高! */ private static void iteratorHashMapByEntryset(HashMap map) { if (map == null) return ; System.out.println("\niterator HashMap By entryset"); String key = null; Integer integ = null; Iterator iter = map.entrySet().iterator(); while(iter.hasNext()) { Map.Entry entry = (Map.Entry)iter.next(); key = (String)entry.getKey(); integ = (Integer)entry.getValue(); System.out.println(key+" -- "+integ.intValue()); } } /* * 通過keyset來遍歷HashMap * 效率低! */ private static void iteratorHashMapByKeyset(HashMap map) { if (map == null) return ; System.out.println("\niterator HashMap By keyset"); String key = null; Integer integ = null; Iterator iter = map.keySet().iterator(); while (iter.hasNext()) { key = (String)iter.next(); integ = (Integer)map.get(key); System.out.println(key+" -- "+integ.intValue()); } }
第二種方法每當取得了key值後又進行了一次get(key)操作,不但無意義且影響效率。
以上是個人對HashMap的理解和分析,沒有什麽布局且作為第一版吧,本文粘貼的代碼小部分直接來源於jdk,大部分采用了下面的博客(https://www.cnblogs.com/skywang12345/p/3310835.html#a3),因為這邊博客已經將每行代碼做了什麽用中文講的很清楚了,我又在一些關鍵點上加了一些個人理解。不足之處還望大家指正。
HashMap重點詳解