[Java]HashMap實現與哈希沖突,與HashTable的區別


對於 Map ,最直觀就是理解就是鍵值對,映射,key-value 形式。一個映射不能包含重復的鍵,一個鍵只能有一個值。平常我們使用的時候,最常用的無非就是 HashMap。

HashMap 實現了 Map 接口,允許使用 null 值 和 null 鍵,並且不保證映射順序。

HashMap 有兩個參數影響性能:

初始容量:表示哈希表在其容量自動增加之前可以達到多滿的一種尺度
加載因子:當哈希表中的條目超過了容量和加載因子的乘積的時候,就會進行重哈希操作。
如下成員變量源碼:

static final float DEFAULT_LOAD_FACTOR = 0.75f;
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
transient Node<K,V>[] table;

可以看到,默認加載因子為 0.75, 默認容量為 1 << 4,也就是 16。加載因子過高,容易產生哈希沖突,加載因子過小,容易浪費空間,0.75是一種折中。

另外,整個 HashMap 的實現原理可以簡單的理解成:當我們 put 的時候,首先根據 key 算出一個數值 x,然后在 table[x] 中存放我們的值。這樣有一個好處是,以后的 get 等操作的時間復雜度直接就是O(1),因為 HashMap 內部就是基於數組的一個實現。

put 方法的實現 與 哈希沖突

下面再結合代碼重點分析下 HashMap 的 put 方法的內部實現 和 哈希沖突的解決辦法:

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)
        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))))
            e = p;
        else if (p instanceof TreeNode)
            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);
                        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)
        resize();
    afterNodeInsertion(evict);
    return null;
}

首先我們看到 hash(key) 這個就是表示要根據 key 值算出一個數值,以此來決定在 table 數組的哪一個位置存放我們的數值。(Ps:這個 hash(key) 方法 也是大有講究的,會嚴重影響性能,實現得不好會讓 HashMap 的 O(1) 時間復雜度降到 O(n),在JDK8以下的版本中帶來災難性影響。它需要保證得出的數在哈希表中的均勻分布,目的就是要減少哈希沖突)

重要說明一下:

**JDK8 中哈希沖突過多,鏈表會轉紅黑樹,時間復雜度是O(logn),不會是O(n) **
**JDK8 中哈希沖突過多,鏈表會轉紅黑樹,時間復雜度是O(logn),不會是O(n) **
**JDK8 中哈希沖突過多,鏈表會轉紅黑樹,時間復雜度是O(logn),不會是O(n) **
然后,我們再看到:

if ((p = tab[i = (n - 1) & hash]) == null)
    tab[i] = newNode(hash, key, value, null);
else {
    ......

這就表示,如果沒有 哈希沖突,那么就可以放入數據 tab[i] = newNode(hash, key, value, null); 如果有哈希沖突,那么就執行 else 需要解決哈希沖突。

那么放入數據 其實就是 建立一個 Node 節點,該 Node節點有屬性 key,value,分別保存我們的 key 值 和 value 值,然后再把這個 Node 節點放入到 table 數組中,並沒有什么神秘的地方。

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;
    }
    
}

上述可以看到 Node 節點中 有一個 Node<K,V> next; ,其實仔細思考下就應該知道這個是用來解決哈希沖突的。下面再看看是如何解決哈希沖突的:

哈希沖突:通俗的講就是首先我們進行一次 put 操作,算出了我們要在 table 數組的 x 位置放入這個值。那么下次再進行一個 put 操作的時候,又算出了我們要在 table 數組的 x 位置放入這個值,那之前已經放入過值了,那現在怎么處理呢?

其實就是通過鏈表法進行解決。

首先,如果有哈希沖突,那么:

if (p.hash == hash &&
    ((k = p.key) == key || (key != null && key.equals(k))))
e = p;

需要判斷 兩者的 key 是否一樣的,因為 HashMap 不能加入重復的鍵。如果一樣,那么就覆蓋,如果不一樣,那么就先判斷是不是 TreeNode 類型的:

 else if (p instanceof TreeNode)
    e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);

這里表示 是不是現在已經轉紅黑樹了(在大量哈希沖突的情況下,鏈表會轉紅黑樹),一般我們小數據的情況下,是不會轉的,所以這里暫時不考慮這種情況(Ps:本人也沒太深入研究紅黑樹,所以就不說這個了)。

如果是正常情況下,會執行下面的語句來解決哈希沖突:

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);
        break;
    }
    if (e.hash == hash &&
            ((k = e.key) == key || (key != null && key.equals(k))))
        break;
    p = e;
}

這里其實就是用鏈表法來解決。並且:

沖突的節點放在鏈表的最下面。
沖突的節點放在鏈表的最下面。
沖突的節點放在鏈表的最下面。

因為 首先有:p = tab[i = (n - 1) & hash] ,再 for 循環,然后有 if ((e = p.next) == null) { ,並且如果 當前節點的下一個節點有值的話,那么就 p = e;,這就說明了放在最下面。

強烈建議自己拿筆拿紙畫畫。

總結

一個映射不能包含重復的鍵,一個鍵只能有一個值。允許使用 null 值 和 null 鍵,並且不保證映射順序。
HashMap 解決沖突的辦法先是使用鏈表法,然后如果哈希沖突過多,那么會把鏈表轉換成紅黑樹,以此來保證效率。
如果出現了哈希沖突,那么新加入的節點放在鏈表的最后面。
參考

強烈建議看一下:

Java HashMap工作原理及實現
Java 8:HashMap的性能提升

HashTable

HashTable 是 HashMap 的線程安全版本。 內部的實現幾乎和 HashMap 一模一樣。例如:

同樣的有一個數組:

private transient Entry<?,?>[] table;

對於 put 方法:

public synchronized V put(K key, V value) {
    ......

    // Makes sure the key is not already in the hashtable.
    Entry<?,?> tab[] = table;
    int hash = key.hashCode();
    int index = (hash & 0x7FFFFFFF) % tab.length;
    @SuppressWarnings("unchecked")
    Entry<K,V> entry = (Entry<K,V>)tab[index];
    for(; entry != null ; entry = entry.next) {
        if ((entry.hash == hash) && entry.key.equals(key)) {
            V old = entry.value;
            entry.value = value;
            return old;
        }
    }

    addEntry(hash, key, value, index);
    return null;
}

這里可以看到, for 循環表示如果出現了哈希沖突,那么就放在最后一位。因為不斷的進行 entry = entry.next,直到 entry != null。需要注意的是,JDK8 中的 HashMap 如果有很多哈希沖突的話,那么是可能會把鏈表變成紅黑樹以此來提高效率。但是這里 HashTable 並沒有這樣做。

另外,從這里也可以看出,HashTable 實現多線程同步的主要方式是通過加 synchronized 關鍵字。

另外,對於 get 方法:

@SuppressWarnings("unchecked")
public synchronized V get(Object key) {
    Entry<?,?> tab[] = table;
    int hash = key.hashCode();
    int index = (hash & 0x7FFFFFFF) % tab.length;
    for (Entry<?,?> e = tab[index] ; e != null ; e = e.next) {
        if ((e.hash == hash) && e.key.equals(key)) {
            return (V)e.value;
        }
    }
    return null;
}

這里最明顯的就是 synchronized,其實還有很多其他的方法用的也是 synchronized。get 方法的處理也是先根據 key 定位到 table 的某一個位置,最后再 for 循環拿到該值(因為可能出現了哈希沖突,所以要 for 循環)。

總結

  • Hashtable的方法是同步的,HashMap則是非同步的,所以在多線程場合要手動同步HashMap,這個區別就像Vector和ArrayList一樣。
  • Hashtable不允許null值(key和value都不可以),HashMap允許null值(key和value都可以)。
  • Hashtable比HashMap多一個elements方法用於遍歷。
  • Hashtable使用Enumeration,HashMap使用Iterator。
  • 哈希值的使用不同,Hashtable直接使用對象的hashCode,而HashMap重新計算hash值,而且用與代替求模。
  • Hashtable中hash數組默認大小是11,增加的方式是 old*2+1。HashMap中hash數組的默認大小是16,而且一定是2的指數。


免責聲明!

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



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