HashMap的底層結構和實現原理
雜湊表(hash table)也叫散列表,是一種非常重要的資料結構,應用場景及其豐富,許多快取技術(比如memcached)的核心其實就是在記憶體中維護一張大的雜湊表,而HashMap的實現原理也常常出現在各類的面試題中,重要性可見一斑。本文會對java集合框架中的對應實現HashMap的實現原理進行講解,然後會對JDK7的HashMap原始碼進行分析。
目錄
五、總結
一、什麼是雜湊表
在討論雜湊表之前,我們先大概瞭解下其他資料結構在新增,查詢等基礎操作執行效能
陣列:採用一段連續的儲存單元來儲存資料。對於指定下標的查詢,時間複雜度為O(1);通過給定值進行查詢,需要遍歷陣列,逐一比對給定關鍵字和陣列元素,時間複雜度為O(n),當然,對於有序陣列,則可採用二分查詢,插值查詢,斐波那契查詢等方式,可將查詢複雜度提高為O(logn);對於一般的插入刪除操作,涉及到陣列元素的移動,其平均複雜度也為O(n)
線性連結串列:對於連結串列的新增,刪除等操作(在找到指定操作位置後),僅需處理結點間的引用即可,時間複雜度為O(1),而查詢操作需要遍歷連結串列逐一進行比對,複雜度為O(n)
二叉樹:對一棵相對平衡的有序二叉樹,對其進行插入,查詢,刪除等操作,平均複雜度均為O(logn)。
雜湊表:相比上述幾種資料結構,在雜湊表中進行新增,刪除,查詢等操作,效能十分之高,不考慮雜湊衝突的情況下,僅需一次定位即可完成,時間複雜度為O(1),接下來我們就來看看雜湊表是如何實現達到驚豔的常數階O(1)的。
我們知道,資料結構的物理儲存結構只有兩種:順序儲存結構和鏈式儲存結構(像棧,佇列,樹,圖等是從邏輯結構去抽象的,對映到記憶體中,也這兩種物理組織形式),而在上面我們提到過,在陣列中根據下標查詢某個元素,一次定位就可以達到,雜湊表利用了這種特性,雜湊表的主幹就是陣列
比如我們要新增或查詢某個元素,我們通過把當前元素的關鍵字 通過某個函式對映到陣列中的某個位置,通過陣列下標一次定位就可完成操作。
儲存位置 = f(關鍵字)
其中,這個函式f一般稱為雜湊函式,這個函式的設計好壞會直接影響到雜湊表的優劣。舉個例子,比如我們要在雜湊表中執行插入操作:
查詢操作同理,先通過雜湊函式計算出實際儲存地址,然後從陣列中對應地址取出即可。
雜湊衝突
然而萬事無完美,如果兩個不同的元素,通過雜湊函式得出的實際儲存地址相同怎麼辦?也就是說,當我們對某個元素進行雜湊運算,得到一個儲存地址,然後要進行插入的時候,發現已經被其他元素佔用了,其實這就是所謂的雜湊衝突
二、HashMap實現原理
HashMap的主幹是一個Entry陣列。Entry是HashMap的基本組成單元,每一個Entry包含一個key-value鍵值對。
//HashMap的主幹陣列,可以看到就是一個Entry陣列,初始值為空陣列{},主幹陣列的長度一定是2的次冪,至於為什麼這麼做,後面會有詳細分析。 transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
Entry是HashMap中的一個靜態內部類。程式碼如下
static class Entry<K,V> implements Map.Entry<K,V> { final K key; V value; Entry<K,V> next;//儲存指向下一個Entry的引用,單鏈表結構 int hash;//對key的hashcode值進行hash運算後得到的值,儲存在Entry,避免重複計算 /** * Creates new entry. */ Entry(int h, K k, V v, Entry<K,V> n) { value = v; next = n; key = k; hash = h; }
所以,HashMap的整體結構如下
簡單來說,HashMap由陣列+連結串列組成的,陣列是HashMap的主體,連結串列則是主要為了解決雜湊衝突而存在的,如果定位到的陣列位置不含連結串列(當前entry的next指向null),那麼對於查詢,新增等操作很快,僅需一次定址即可;如果定位到的陣列包含連結串列,對於新增操作,其時間複雜度依然為O(1),因為最新的Entry會插入連結串列頭部,僅需簡單改變引用鏈即可,而對於查詢操作來講,此時就需要遍歷連結串列,然後通過key物件的equals方法逐一比對查詢。所以,效能考慮,HashMap中的連結串列出現越少,效能才會越好。
其他幾個重要欄位
//實際儲存的key-value鍵值對的個數 transient int size; //閾值,當table == {}時,該值為初始容量(初始容量預設為16);當table被填充了,也就是為table分配記憶體空間後,threshold一般為 capacity*loadFactory。HashMap在進行擴容時需要參考threshold,後面會詳細談到 int threshold; //負載因子,代表了table的填充度有多少,預設是0.75 final float loadFactor; //用於快速失敗,由於HashMap非執行緒安全,在對HashMap進行迭代時,如果期間其他執行緒的參與導致HashMap的結構發生變化了(比如put,remove等操作),需要丟擲異常ConcurrentModificationException transient int modCount;
HashMap有4個構造器,其他構造器如果使用者沒有傳入initialCapacity 和loadFactor這兩個引數,會使用預設值
initialCapacity預設為16,loadFactory預設為0.75
我們看下其中一個
public HashMap(int initialCapacity, float loadFactor) {
//此處對傳入的初始容量進行校驗,最大不能超過MAXIMUM_CAPACITY = 1<<30(230) if (initialCapacity < 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; threshold = initialCapacity;
init();//init方法在HashMap中沒有實際實現,不過在其子類如 linkedHashMap中就會有對應實現 }
從上面這段程式碼我們可以看出,在常規構造器中,沒有為陣列table分配記憶體空間(有一個入參為指定Map的構造器例外),而是在執行put操作的時候才真正構建table陣列
OK,接下來我們來看看put操作的實現吧
public V put(K key, V value) { //如果table陣列為空陣列{},進行陣列填充(為table分配實際記憶體空間),入參為threshold,此時threshold為initialCapacity 預設是1<<4(24=16) if (table == EMPTY_TABLE) { inflateTable(threshold); } //如果key為null,儲存位置為table[0]或table[0]的衝突鏈上 if (key == null) return putForNullKey(value); int hash = hash(key);//對key的hashcode進一步計算,確保雜湊均勻 int i = indexFor(hash, table.length);//獲取在table中的實際位置 for (Entry<K,V> e = table[i]; e != null; e = e.next) { //如果該對應資料已存在,執行覆蓋操作。用新value替換舊value,並返回舊value Object k; if (e.hash == hash && ((k = e.key) == key || key.equals(k))) { V oldValue = e.value; e.value = value; e.recordAccess(this); return oldValue; } } modCount++;//保證併發訪問時,若HashMap內部結構發生變化,快速響應失敗 addEntry(hash, key, value, i);//新增一個entry return null; }
先來看看inflateTable這個方法
private void inflateTable(int toSize) { int capacity = roundUpToPowerOf2(toSize);//capacity一定是2的次冪 threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);//此處為threshold賦值,取capacity*loadFactor和MAXIMUM_CAPACITY+1的最小值,capaticy一定不會超過MAXIMUM_CAPACITY,除非loadFactor大於1 table = new Entry[capacity]; initHashSeedAsNeeded(capacity); }
inflateTable這個方法用於為主幹陣列table在記憶體中分配儲存空間,通過roundUpToPowerOf2(toSize)可以確保capacity為大於或等於toSize的最接近toSize的二次冪,比如toSize=13,則capacity=16;to_size=16,capacity=16;to_size=17,capacity=32.
private static int roundUpToPowerOf2(int number) { // assert number >= 0 : "number must be non-negative"; return number >= MAXIMUM_CAPACITY ? MAXIMUM_CAPACITY : (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1; }
roundUpToPowerOf2中的這段處理使得陣列長度一定為2的次冪,Integer.highestOneBit是用來獲取最左邊的bit(其他bit位為0)所代表的數值.
hash函式
//這是一個神奇的函式,用了很多的異或,移位等運算,對key的hashcode進一步進行計算以及二進位制位的調整等來保證最終獲取的儲存位置儘量分佈均勻 final int hash(Object k) { int h = hashSeed; if (0 != h && k instanceof String) { return sun.misc.Hashing.stringHash32((String) k); } h ^= k.hashCode(); h ^= (h >>> 20) ^ (h >>> 12); return h ^ (h >>> 7) ^ (h >>> 4); }
以上hash函式計算出的值,通過indexFor進一步處理來獲取實際的儲存位置
/** * 返回陣列下標 */ static int indexFor(int h, int length) { return h & (length-1); }
h&(length-1)保證獲取的index一定在陣列範圍內,舉個例子,預設容量16,length-1=15,h=18,轉換成二進位制計算為
1 0 0 1 0
& 0 1 1 1 1
__________________
0 0 0 1 0 = 2
最終計算出的index=2。有些版本的對於此處的計算會使用 取模運算,也能保證index一定在陣列範圍內,不過位運算對計算機來說,效能更高一些(HashMap中有大量位運算)
所以最終儲存位置的確定流程是這樣的:
再來看看addEntry的實現:
void addEntry(int hash, K key, V value, int bucketIndex) { if ((size >= threshold) && (null != table[bucketIndex])) { resize(2 * table.length);//當size超過臨界閾值threshold,並且即將發生雜湊衝突時進行擴容 hash = (null != key) ? hash(key) : 0; bucketIndex = indexFor(hash, table.length); } createEntry(hash, key, value, bucketIndex); }
通過以上程式碼能夠得知,當發生雜湊衝突並且size大於閾值的時候,需要進行陣列擴容,擴容時,需要新建一個長度為之前陣列2倍的新的陣列,然後將當前的Entry陣列中的元素全部傳輸過去,擴容後的新陣列長度為之前的2倍,所以擴容相對來說是個耗資源的操作。
三、為何HashMap的陣列長度一定是2的次冪?
我們來繼續看上面提到的resize方法
void resize(int newCapacity) { Entry[] oldTable = table; int oldCapacity = oldTable.length; if (oldCapacity == MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return; } Entry[] newTable = new Entry[newCapacity]; transfer(newTable, initHashSeedAsNeeded(newCapacity)); table = newTable; threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1); }
如果陣列進行擴容,陣列長度發生變化,而儲存位置 index = h&(length-1),index也可能會發生變化,需要重新計算index,我們先來看看transfer這個方法
void transfer(Entry[] newTable, boolean rehash) { int newCapacity = newTable.length;
//for迴圈中的程式碼,逐個遍歷連結串列,重新計算索引位置,將老陣列資料複製到新陣列中去(陣列不儲存實際資料,所以僅僅是拷貝引用而已) for (Entry<K,V> e : table) { while(null != e) { Entry<K,V> next = e.next; if (rehash) { e.hash = null == e.key ? 0 : hash(e.key); } int i = indexFor(e.hash, newCapacity);
//將當前entry的next鏈指向新的索引位置,newTable[i]有可能為空,有可能也是個entry鏈,如果是entry鏈,直接在連結串列頭部插入。 e.next = newTable[i]; newTable[i] = e; e = next; } } }
這個方法將老陣列中的資料逐個連結串列地遍歷,扔到新的擴容後的陣列中,我們的陣列索引位置的計算是通過 對key值的hashcode進行hash擾亂運算後,再通過和 length-1進行位運算得到最終陣列索引位置。
hashMap的陣列長度一定保持2的次冪,比如16的二進位制表示為 10000,那麼length-1就是15,二進位制為01111,同理擴容後的陣列長度為32,二進位制表示為100000,length-1為31,二進位制表示為011111。從下圖可以我們也能看到這樣會保證低位全為1,而擴容後只有一位差異,也就是多出了最左位的1,這樣在通過 h&(length-1)的時候,只要h對應的最左邊的那一個差異位為0,就能保證得到的新的陣列索引和老陣列索引一致(大大減少了之前已經雜湊良好的老陣列的資料位置重新調換),個人理解。
還有,陣列長度保持2的次冪,length-1的低位都為1,會使得獲得的陣列索引index更加均勻,比如:
我們看到,上面的&運算,高位是不會對結果產生影響的(hash函式採用各種位運算可能也是為了使得低位更加雜湊),我們只關注低位bit,如果低位全部為1,那麼對於h低位部分來說,任何一位的變化都會對結果產生影響,也就是說,要得到index=21這個儲存位置,h的低位只有這一種組合。這也是陣列長度設計為必須為2的次冪的原因。
如果不是2的次冪,也就是低位不是全為1此時,要使得index=21,h的低位部分不再具有唯一性了,雜湊衝突的機率會變的更大,同時,index對應的這個bit位無論如何不會等於1了,而對應的那些陣列位置也就被白白浪費了。
get方法
public V get(Object key) {
//如果key為null,則直接去table[0]處去檢索即可。 if (key == null) return getForNullKey(); Entry<K,V> entry = getEntry(key); return null == entry ? null : entry.getValue(); }
get方法通過key值返回對應value,如果key為null,直接去table[0]處檢索。我們再看一下getEntry這個方法
final Entry<K,V> getEntry(Object key) { if (size == 0) { return null; } //通過key的hashcode值計算hash值 int hash = (key == null) ? 0 : hash(key); //indexFor (hash&length-1) 獲取最終陣列索引,然後遍歷連結串列,通過equals方法比對找出對應記錄 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; }
可以看出,get方法的實現相對簡單,key(hashcode)–>hash–>indexFor–>最終索引位置,找到對應位置table[i],再檢視是否有連結串列,遍歷連結串列,通過key的equals方法比對查詢對應的記錄。要注意的是,有人覺得上面在定位到陣列位置之後然後遍歷連結串列的時候,e.hash == hash這個判斷沒必要,僅通過equals判斷就可以。其實不然,試想一下,如果傳入的key物件重寫了equals方法卻沒有重寫hashCode,而恰巧此物件定位到這個陣列位置,如果僅僅用equals判斷可能是相等的,但其hashCode和當前物件不一致,這種情況,根據Object的hashCode的約定,不能返回當前物件,而應該返回null,後面的例子會做出進一步解釋。
四、重寫equals方法需同時重寫hashCode方法
關於HashMap的原始碼分析就介紹到這兒了,最後我們再聊聊老生常談的一個問題,各種資料上都會提到,“重寫equals時也要同時覆蓋hashcode”,我們舉個小例子來看看,如果重寫了equals而不重寫hashcode會發生什麼樣的問題
/** * Created by chengxiao on 2016/11/15. */ public class MyTest { private static class Person{ int idCard; String name; public Person(int idCard, String name) { this.idCard = idCard; this.name = name; } @Override public boolean equals(Object o) { if (this == o) { return true; } if (o == null || getClass() != o.getClass()){ return false; } Person person = (Person) o; //兩個物件是否等值,通過idCard來確定 return this.idCard == person.idCard; } } public static void main(String []args){ HashMap<Person,String> map = new HashMap<Person, String>(); Person person = new Person(1234,"喬峰"); //put到hashmap中去 map.put(person,"天龍八部"); //get取出,從邏輯上講應該能輸出“天龍八部” System.out.println("結果:"+map.get(new Person(1234,"蕭峰"))); } }
實際輸出結果:
結果:null
如果我們已經對HashMap的原理有了一定了解,這個結果就不難理解了。儘管我們在進行get和put操作的時候,使用的key從邏輯上講是等值的(通過equals比較是相等的),但由於沒有重寫hashCode方法,所以put操作時,key(hashcode1)–>hash–>indexFor–>最終索引位置 ,而通過key取出value的時候 key(hashcode1)–>hash–>indexFor–>最終索引位置,由於hashcode1不等於hashcode2,導致沒有定位到一個數組位置而返回邏輯上錯誤的值null(也有可能碰巧定位到一個數組位置,但是也會判斷其entry的hash值是否相等,上面get方法中有提到。)
所以,在重寫equals的方法的時候,必須注意重寫hashCode方法,同時還要保證通過equals判斷相等的兩個物件,呼叫hashCode方法要返回同樣的整數值。而如果equals判斷不相等的兩個物件,其hashCode可以相同(只不過會發生雜湊衝突,應儘量避免)。
五、總結
本文描述了HashMap的實現原理,並結合原始碼做了進一步的分析,也涉及到一些原始碼細節設計緣由,最後簡單介紹了為什麼重寫equals的時候需要重寫hashCode方法。希望本篇文章能幫助到大家,同時也歡迎討論指正,謝謝支援!
相關推薦
HashMap的底層結構和實現原理
雜湊表(hash table)也叫散列表,是一種非常重要的資料結構,應用場景及其豐富,許多快取技術(比如memcached)的核心其實就是在記憶體中維護一張大的雜湊表,而HashMap的實現原理也常常出現在各類的面試題中,重要性可見一斑。本文會對java集合框架中的對應實現HashMap的實現原理進行講解
HashMap的原始碼,實現原理,底層結構
總的來說,HashMap就是陣列+連結串列的組合實現,每個陣列元素儲存一個連結串列的頭結點,本質上來說是雜湊表“拉鍊法”的實現。 HashMap的連結串列元素對應的是一個靜態內部類Entry,Entry主要包含key,value,next三個元素 主要有put和get方法,
C++底層資料結構的實現原理
1.vector 底層資料結構為陣列 ,支援快速隨機訪問 2.list 底層資料結構為雙向連結串列,支援快速增刪 3.deque 底層資料結構為一箇中央控制器和多個緩衝區,詳細見STL原始碼剖析P146,支援首
HashMap的put和get原理,結合源碼分析詳細分析
ava als lac get() 默認 part pac general extend HashMap(java7) public class HashMap<K,V> extends AbstractMap<K,V> implements
淺談BloomFilter【上】基本概念和實現原理
pty 是否 的人 它的 構建 網絡爬蟲 ace head filters ? ??在日常生活中。包括在設計計算機軟件時,我們常常要推斷一個元素是否在一個集合中。
直播平臺運營的技術和實現原理
直播源碼陌陌的財報、微吼直播的轉型,不管怎麽看都是直播再一次掀起熱潮的信號,直播源碼的需求更在這時達到了巔峰。但是,你知道直播平臺運營的技術和實現原理嗎? 下面就是重點內容了哦:一個朋友破解了AirPlay和Chromecast協議,然後開發了一套技 術能夠截獲和播放任何手機(iOS或是Android)屏幕上
深入理解Lua的閉包一:概念、應用和實現原理
觀點 數組 line Language 場景 test 詞法 nil 實參 本文首先通過具體的例子講解了Lua中閉包的概念,然後總結了閉包的應用場合,最後探討了Lua中閉包的實現原理。 閉包的概念 在Lua中,閉包(closure)是由一個函數和該函數會訪問到的
類的繼承和實現原理
類的繼承 self. base ict pass nbsp class 輸出結果 ase 1.類的繼承實例 class Hero: aa = ‘11‘ def __init__(self, name, life, damage): sel
理解Web應用程式的程式碼結構和執行原理(3)
1、理解Web應用程式的執行原理和機制 Web應用程式是基於瀏覽器/伺服器模式(也稱B/S架構)的應用程式,它開發完成後,需要部署到Web伺服器上才能正常執行,與使用者互動的客戶端是網頁瀏覽器。 瀏覽器負責顯示來自伺服器的資料和接受使用者的輸入資料,也
Linux : select()詳解 和 實現原理【轉】
https://www.cnblogs.com/sky-heaven/p/7205491.html#4119169 轉自:http://blog.csdn.net/huntinux/article/details/39289317 原文:http://blog.csdn.n
Python Web開發中,WSGI協議的作用和實現原理詳解
首先理解下面三個概念: WSGI:全稱是Web Server Gateway Interface,WSGI不是伺服器,python模組,框架,API或者任何軟體,只是一種規範,描述web server如何與web application通訊的規範。 uwsgi:與WSGI一樣是一種協議,是uWSGI伺服器
Docker的技術細節和實現原理
Docker 內部 要理解 Docker 內部構建,需要理解以下三種部件: Docker 映象 - Docker images Docker 倉庫 - Docker registeries Docker 容器 - Docker containers Docker 映象 Docker 映象是 Docker
LeakCanary的使用和實現原理
Android 應用記憶體洩漏問題,一直是效能優化的重點。在不清楚記憶體洩漏的大致範圍時,通過人為測試模擬重現或無目的地分析 heap dump等方法來檢測,都太繁瑣、耗時且定位不準。 什麼
硬碟的物理結構和工作原理
硬碟的結構可分為外部結構和內部結構。 下面就西數500G的硬碟為例,來講解一下硬碟的結構。 硬碟外部結構 硬碟的外部結構主要包括金屬固定面板、控制電路板和介面三部分。以下實物圖拍攝:(用了美圖秀秀,不僅臉蛋漂亮連硬碟都變的很漂亮,好劉濞啊。) 金屬固定面板 硬碟外部會有一個金
HashMap與HashSet的實現原理(圖文)
2018年11月17日 19:55:30 JAVA_55555 閱讀數:10 個人分類: java
ArrayList、LinkedList、Vector的區別和實現原理
ArrayList、LinkedList、Vector是集合中經常拿來比較和麵試的一個問題,我這裡簡要概括一下他們的區別和實現原理。儲存結構ArrayList和Vector是按照順序將元素儲存(從下表為0開始),刪除元素時,刪除操作完成後,需要使部分元素移位,預設的初始容量都
[轉]B+樹的結構和實現程式碼
/*******************************************************//* btrees.c */ #include #include #include "btrees.h" btree search(typekey, btree); btree insert(ty
計算機機械硬碟的結構和工作原理
學習作業系統的檔案系統必須要了解一下計算機的硬碟的結構和工作方式,否則,對存取效率等問題不能很好的理解,今天學習了一下機械硬碟的結構和原理,在這裡和大家分享一下。 由上圖所示,機械硬碟主要由以下幾部分組成:機械手臂(Boom),磁頭(He
微服務架構的核心要點和實現原理
微服務架構中職能團隊的劃分 傳統單體架構將系統分成具有不同職責的層次,對應的專案管理也傾向於將大的團隊分成不同的職能團隊,主要包括:使用者互動UI團隊、後臺業務邏輯處理團隊與資料存取ORM團隊、DBA團隊等。每個團隊只對自己分層的職責負責,並對使用方提供元件服務質量保證
說說zookeeper【叄】_工作機制和實現原理
本文簡單說說zookeeper的工作機制。 總體來說,客戶端先和zookeeper伺服器建立起一個TCP長連線(session),之後根據ACL許可權的設定,可在zookeeper伺服器上對目