摘要:舉個例子,比如我們要在哈希表中執(zhí)行插入操作查找操作同理,先通過哈希函數(shù)計算出實際存儲地址,然后從數(shù)組中對應(yīng)地址取出即可。這也是數(shù)組長度設(shè)計為必須為的次冪的原因。
前言
hashMap在平時工作和面試中,常常使用到和問到,本文將從一下幾個方面進行記錄:
什么是哈希表
HashMap實現(xiàn)原理
為何HashMap的數(shù)組長度一定是2的次冪?
1. 什么是哈希表
在討論哈希表之前,我們先大概了解下其他數(shù)據(jù)結(jié)構(gòu)在新增,查找等基礎(chǔ)操作執(zhí)行性能
數(shù)組:采用一段連續(xù)的存儲單元來存儲數(shù)據(jù)。對于指定下標(biāo)的查找,時間復(fù)雜度為O(1);通過給定值進行查找,需要遍歷數(shù)組,逐一比對給定關(guān)鍵字和數(shù)組元素,時間復(fù)雜度為O(n),當(dāng)然,對于有序數(shù)組,則可采用二分查找,插值查找,斐波那契查找等方式,可將查找復(fù)雜度提高為O(logn);對于一般的插入刪除操作,涉及到數(shù)組元素的移動,其平均復(fù)雜度也為O(n)
線性鏈表:對于鏈表的新增,刪除等操作(在找到指定操作位置后),僅需處理結(jié)點間的引用即可,時間復(fù)雜度為O(1),而查找操作需要遍歷鏈表逐一進行比對,復(fù)雜度為O(n)
二叉樹:對一棵相對平衡的有序二叉樹,對其進行插入,查找,刪除等操作,平均復(fù)雜度均為O(logn)。
哈希表:相比上述幾種數(shù)據(jù)結(jié)構(gòu),在哈希表中進行添加,刪除,查找等操作,性能十分之高,不考慮哈希沖突的情況下,僅需一次定位即可完成,時間復(fù)雜度為O(1),接下來我們就來看看哈希表是如何實現(xiàn)達到驚艷的常數(shù)階O(1)的。
而我們知道,數(shù)據(jù)的存儲結(jié)構(gòu)只有兩種方式:順序存儲結(jié)構(gòu) 和 鏈?zhǔn)酱鎯Y(jié)構(gòu)(像棧,隊列,樹,圖等是從邏輯結(jié)構(gòu)去抽象的,映射到內(nèi)存中,也這兩種物理組織形式),而在上面我們提到過,在數(shù)組中根據(jù)下標(biāo)查找某個元素,一次定位就可以達到,哈希表利用了這種特性,哈希表的主干就是數(shù)組。
比如我們要新增或查找某個元素,我們通過把當(dāng)前元素的關(guān)鍵字 通過某個函數(shù)映射到數(shù)組中的某個位置,通過數(shù)組下標(biāo)一次定位就可完成操作
存儲位置 = f(關(guān)鍵字)
其中,這個函數(shù)f一般稱為哈希函數(shù),這個函數(shù)的設(shè)計好壞會直接影響到哈希表的優(yōu)劣。舉個例子,比如我們要在哈希表中執(zhí)行插入操作:
查找操作同理,先通過哈希函數(shù)計算出實際存儲地址,然后從數(shù)組中對應(yīng)地址取出即可。
哈希沖突
然而萬事無完美,如果兩個不同的元素,通過哈希函數(shù)得出的實際存儲地址相同怎么辦?也就是說,當(dāng)我們對某個元素進行哈希運算,得到一個存儲地址,然后要進行插入的時候,發(fā)現(xiàn)已經(jīng)被其他元素占用了,其實這就是所謂的哈希沖突,也叫哈希碰撞。前面我們提到過,哈希函數(shù)的設(shè)計至關(guān)重要,好的哈希函數(shù)會盡可能地保證 計算簡單和散列地址分布均勻,但是,我們需要清楚的是,數(shù)組是一塊連續(xù)的固定長度的內(nèi)存空間,再好的哈希函數(shù)也不能保證得到的存儲地址絕對不發(fā)生沖突。那么哈希沖突如何解決呢?哈希沖突的解決方案有多種:開放定址法(發(fā)生沖突,繼續(xù)尋找下一塊未被占用的存儲地址),再散列函數(shù)法,鏈地址法,而HashMap即是采用了鏈地址法,也就是數(shù)組+鏈表的方式
HashMap實現(xiàn)原理
HashMap的主干類是一個Enty數(shù)組(jdk 1.7),每個Enty都包含有一個鍵值對(key-value)
我們可以看一下源碼:
static class Entryimplements Map.Entry { final K key; V value; Entry next;//存儲指向下一個Entry的引用,單鏈表結(jié)構(gòu) int hash;//對key的hashcode值進行hash運算后得到的值,存儲在Entry,避免重復(fù)計算 /** * Creates new entry. */ Entry(int h, K k, V v, Entry n) { value = v; next = n; key = k; hash = h; }
所以,HashMap的整體結(jié)構(gòu)如下
簡單來說,HashMap由數(shù)組+鏈表組成的,數(shù)組是HashMap的主體,鏈表則是主要為了解決哈希沖突而存在的,如果定位到的數(shù)組位置不含鏈表(當(dāng)前entry的next指向null),那么對于查找,添加等操作很快,僅需一次尋址即可;如果定位到的數(shù)組包含鏈表,對于添加操作,其時間復(fù)雜度為O(n),首先遍歷鏈表,存在即覆蓋,否則新增;對于查找操作來講,仍需遍歷鏈表,然后通過key對象的equals方法逐一比對查找。所以,性能考慮,HashMap中的鏈表出現(xiàn)越少,性能才會越好。
其他幾個重要字段
//實際存儲的key-value鍵值對的個數(shù) transient int size; //閾值,當(dāng)table == {}時,該值為初始容量(初始容量默認為16);當(dāng)table被填充了,也就是為table分配內(nèi)存空間后,threshold一般為 capacity*loadFactory。HashMap在進行擴容時需要參考threshold,后面會詳細談到 int threshold; //負載因子,代表了table的填充度有多少,默認是0.75 final float loadFactor; //用于快速失敗,由于HashMap非線程安全,在對HashMap進行迭代時,如果期間其他線程的參與導(dǎo)致HashMap的結(jié)構(gòu)發(fā)生變化了(比如put,remove等操作),需要拋出異常ConcurrentModificationException transient int modCount;
HashMap有4個構(gòu)造器,其他構(gòu)造器如果用戶沒有傳入initialCapacity 和loadFactor這兩個參數(shù),會使用默認值
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中沒有實際實現(xiàn),不過在其子類如 linkedHashMap中就會有對應(yīng)實現(xiàn) }
從上面這段代碼我們可以看出,在常規(guī)構(gòu)造器中,沒有為數(shù)組table分配內(nèi)存空間(有一個入?yún)橹付∕ap的構(gòu)造器例外),而是在執(zhí)行put操作的時候才真正構(gòu)建table數(shù)組
OK,接下來我們來看看put操作的實現(xiàn)吧
public V put(K key, V value) { //如果table數(shù)組為空數(shù)組{},進行數(shù)組填充(為table分配實際內(nèi)存空間),入?yún)閠hreshold,此時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 (Entrye = table[i]; e != null; e = e.next) { //如果該對應(yīng)數(shù)據(jù)已存在,執(zhí)行覆蓋操作。用新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++;//保證并發(fā)訪問時,若HashMap內(nèi)部結(jié)構(gòu)發(fā)生變化,快速響應(yīng)失敗 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這個方法用于為主干數(shù)組table在內(nèi)存中分配存儲空間,通過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中的這段處理使得數(shù)組長度一定為2的次冪,Integer.highestOneBit是用來獲取最左邊的bit(其他bit位為0)所代表的數(shù)值.
hash函數(shù)
//這是一個神奇的函數(shù),用了很多的異或,移位等運算,對key的hashcode進一步進行計算以及二進制位的調(diào)整等來保證最終獲取的存儲位置盡量分布均勻 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函數(shù)計算出的值,通過indexFor進一步處理來獲取實際的存儲位置
/**
* 返回數(shù)組下標(biāo) */ static int indexFor(int h, int length) { return h & (length-1); }
h&(length-1)保證獲取的index一定在數(shù)組范圍內(nèi),舉個例子,默認容量16,length-1=15,h=18,轉(zhuǎn)換成二進制計算為
1 0 0 1 0 & 0 1 1 1 1 __________________ 0 0 0 1 0 = 2
最終計算出的index=2。有些版本的對于此處的計算會使用 取模運算,也能保證index一定在數(shù)組范圍內(nèi),不過位運算對計算機來說,性能更高一些(HashMap中有大量位運算)
所以最終存儲位置的確定流程是這樣的:
再來看看addEntry的實現(xiàn):
void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) { resize(2 * table.length);//當(dāng)size超過臨界閾值threshold,并且即將發(fā)生哈希沖突時進行擴容 hash = (null != key) ? hash(key) : 0; bucketIndex = indexFor(hash, table.length); } createEntry(hash, key, value, bucketIndex); }
通過以上代碼能夠得知,當(dāng)發(fā)生哈希沖突并且size大于閾值的時候,需要進行數(shù)組擴容,擴容時,需要新建一個長度為之前數(shù)組2倍的新的數(shù)組,然后將當(dāng)前的Entry數(shù)組中的元素全部傳輸過去,擴容后的新數(shù)組長度為之前的2倍,所以擴容相對來說是個耗資源的操作。
為何HashMap的數(shù)組長度一定是2的次冪?
我們來繼續(xù)看上面提到的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); }
如果數(shù)組進行擴容,數(shù)組長度發(fā)生變化,而存儲位置 index = h&(length-1),index也可能會發(fā)生變化,需要重新計算index,我們先來看看transfer這個方法
void transfer(Entry[] newTable, boolean rehash) { int newCapacity = newTable.length; //for循環(huán)中的代碼,逐個遍歷鏈表,重新計算索引位置,將老數(shù)組數(shù)據(jù)復(fù)制到新數(shù)組中去(數(shù)組不存儲實際數(shù)據(jù),所以僅僅是拷貝引用而已) for (Entrye : table) { while(null != e) { Entry next = e.next; if (rehash) { e.hash = null == e.key ? 0 : hash(e.key); } int i = indexFor(e.hash, newCapacity); //將當(dāng)前entry的next鏈指向新的索引位置,newTable[i]有可能為空,有可能也是個entry鏈,如果是entry鏈,直接在鏈表頭部插入。 e.next = newTable[i]; newTable[i] = e; e = next; } } }
這個方法將老數(shù)組中的數(shù)據(jù)逐個鏈表地遍歷,扔到新的擴容后的數(shù)組中,我們的數(shù)組索引位置的計算是通過 對key值的hashcode進行hash擾亂運算后,再通過和 length-1進行位運算得到最終數(shù)組索引位置。
hashMap的數(shù)組長度一定保持2的次冪,比如16的二進制表示為 10000,那么length-1就是15,二進制為01111,同理擴容后的數(shù)組長度為32,二進制表示為100000,length-1為31,二進制表示為011111。從下圖可以我們也能看到這樣會保證低位全為1,而擴容后只有一位差異,也就是多出了最左位的1,這樣在通過 h&(length-1)的時候,只要h對應(yīng)的最左邊的那一個差異位為0,就能保證得到的新的數(shù)組索引和老數(shù)組索引一致(大大減少了之前已經(jīng)散列良好的老數(shù)組的數(shù)據(jù)位置重新調(diào)換)
還有,數(shù)組長度保持2的次冪,length-1的低位都為1,會使得獲得的數(shù)組索引index更加均勻,比如:
我們看到,上面的&運算,高位是不會對結(jié)果產(chǎn)生影響的(hash函數(shù)采用各種位運算可能也是為了使得低位更加散列),我們只關(guān)注低位bit,如果低位全部為1,那么對于h低位部分來說,任何一位的變化都會對結(jié)果產(chǎn)生影響,也就是說,要得到index=21這個存儲位置,h的低位只有這一種組合。這也是數(shù)組長度設(shè)計為必須為2的次冪的原因。
如果不是2的次冪,也就是低位不是全為1此時,要使得index=21,h的低位部分不再具有唯一性了,哈希沖突的幾率會變的更大,同時,index對應(yīng)的這個bit位無論如何不會等于1了,而對應(yīng)的那些數(shù)組位置也就被白白浪費了。
get方法
public V get(Object key) { //如果key為null,則直接去table[0]處去檢索即可。 if (key == null) return getForNullKey(); Entryentry = getEntry(key); return null == entry ? null : entry.getValue(); }
get方法通過key值返回對應(yīng)value,如果key為null,直接去table[0]處檢索。我們再看一下getEntry這個方法
et方法通過key值返回對應(yīng)value,如果key為null,直接去table[0]處檢索。我們再看一下getEntry這個方法
可以看出,get方法的實現(xiàn)相對簡單,key(hashcode)-->hash-->indexFor-->最終索引位置,找到對應(yīng)位置table[i],再查看是否有鏈表,遍歷鏈表,通過key的equals方法比對查找對應(yīng)的記錄。要注意的是,有人覺得上面在定位到數(shù)組位置之后然后遍歷鏈表的時候,e.hash == hash這個判斷沒必要,僅通過equals判斷就可以。其實不然,試想一下,如果傳入的key對象重寫了equals方法卻沒有重寫hashCode,而恰巧此對象定位到這個數(shù)組位置,如果僅僅用equals判斷可能是相等的,但其hashCode和當(dāng)前對象不一致,這種情況,根據(jù)Object的hashCode的約定,不能返回當(dāng)前對象,而應(yīng)該返回null,后面的例子會做出進一步解釋。
特別說明,本文參考自 :https://www.cnblogs.com/cheng...
文章版權(quán)歸作者所有,未經(jīng)允許請勿轉(zhuǎn)載,若此文章存在違規(guī)行為,您可以聯(lián)系管理員刪除。
轉(zhuǎn)載請注明本文地址:http://m.specialneedsforspecialkids.com/yun/72678.html
摘要:原文地址游客前言金三銀四,很多同學(xué)心里大概都準(zhǔn)備著年后找工作或者跳槽。最近有很多同學(xué)都在交流群里求大廠面試題。 最近整理了一波面試題,包括安卓JAVA方面的,目前大廠還是以安卓源碼,算法,以及數(shù)據(jù)結(jié)構(gòu)為主,有一些中小型公司也會問到混合開發(fā)的知識,至于我為什么傾向于混合開發(fā),我的一句話就是走上編程之路,將來你要學(xué)不僅僅是這些,豐富自己方能與世接軌,做好全棧的裝備。 原文地址:游客kutd...
閱讀 794·2021-11-12 10:36
閱讀 3373·2021-09-08 10:44
閱讀 2745·2019-08-30 11:08
閱讀 1402·2019-08-29 16:12
閱讀 2673·2019-08-29 12:24
閱讀 896·2019-08-26 10:14
閱讀 684·2019-08-23 18:32
閱讀 1173·2019-08-23 17:52