HashMap實現原理及源碼分析,hashmap原理源碼

來源:互聯網
上載者:User

HashMap實現原理及源碼分析,hashmap原理源碼

  雜湊表(hash table)也叫散列表,是一種非常重要的資料結構,應用情境及其豐富,許多緩衝技術(比如memcache)的核心其實就是在記憶體中維護一張大的雜湊表,而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一般稱為雜湊函數,這個函數的設計好壞會直接影響到雜湊表的優劣。舉個例子,比如我們要在雜湊表中執行插入操作:

  

  尋找操作同理,先通過雜湊Function Compute出實際儲存地址,然後從數組中對應地址取出即可。

  雜湊衝突

  然而萬事無完美,如果兩個不同的元素,通過雜湊函數得出的實際儲存地址相同怎麼辦?也就是說,當我們對某個元素進行雜湊運算,得到一個儲存地址,然後要進行插入的時候,發現已經被其他元素佔用了,其實這就是所謂的雜湊衝突,也叫雜湊碰撞。前面我們提到過,雜湊函數的設計至關重要,好的雜湊函數會儘可能地保證 計算簡單散列地址分布均勻,但是,我們需要清楚的是,數組是一塊連續的固定長度的記憶體空間,再好的雜湊函數也不能保證得到的儲存地址絕對不發生衝突。那麼雜湊衝突如何解決呢?雜湊衝突的解決方案有多種:開放定址法(發生衝突,繼續尋找下一塊未被佔用的儲存地址),再散列函數法,鏈地址法,而HashMap即是採用了鏈地址法,也就是數組+鏈表的方式,

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.75final float loadFactor;//用於快速失敗,由於HashMap非安全執行緒,在對HashMap進行迭代時,如果期間其他線程的參與導致HashMap的結構發生變化了(比如put,remove等操作),需要拋出異常ConcurrentModificationExceptiontransient 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);    }

以上hashFunction Compute出的值,通過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的table數組長度一定是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方法。希望本篇文章能協助到大家,同時也歡迎討論指正,謝謝支援!

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.