hashMap底層的實現原理


 

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和TreeMapTreeMap保存了對象的排列次序,而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。

 


原文鏈接:

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM