解讀HashMap-對比JDK7和JDK8

前言

HashMap 算是咱們平常學習工做中遇到的比較多的一個類,它用於存儲 Key-Value 鍵值對。HashMap 容許使用 null 鍵和 null 值,在計算 hash 值時,null 鍵的 hash 值就是 0,HashMap 並不保證在執行某些操做後鍵值對的順序和原來相同,在多線程的環境下,使用 HashMap 須要注意線程安全問題。java

在 JDK1.8 以前,HashMap 底層採用數組+鏈表實現,即用鏈表處理衝突,同一 hash 值的元素都存儲在一個鏈表裏。可是當位於一個桶中的元素較多,即 hash 值相等的元素較多時,經過 key 值依次查找的效率較低。在 JDK1.8 中,HashMap 存儲採用數組+鏈表+紅黑樹實現,當鏈表長度超過閾值 8 且數組長度超過 64 時,將鏈表轉換爲紅黑樹,這樣大大減小了查找時間。node

在本文中,我會經過對 JDK1.7 和 JDk1.8 的比較,爲你介紹以下內容:算法

  • 增刪改查方法分析數組

  • resize 方法分析緩存

  • 樹的實現(後續有時間再寫,主要是圖須要畫更多)安全

  • 問答題(必看)多線程

一些沒有提到的細節,我會在最後以問答題的方式呈現。app

構造方法

在 JDK1.7 中的構造方法以下:dom

// 無參構造方法
public HashMap() {
    this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
}

// 參數爲容量大小
public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

// 參數爲容量大小 + 負載因子
public HashMap(int initialCapacity, float loadFactor) {
    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(); // 忽略這個
}

// 參數爲一個Map的子類
public HashMap(Map<? extends K, ? extends V> m) {
    this(Math.max((int) (m.size() / DEFAULT_LOAD_FACTOR) + 1,
                  DEFAULT_INITIAL_CAPACITY), DEFAULT_LOAD_FACTOR);
    inflateTable(threshold);

    putAllForCreate(m);
}
複製代碼

在 JDK1.8 中的構造方法以下:函數

// 無參構造方法
public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}

// 參數爲容量大小
public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

// 參數爲容量大小 + 負載因子
public HashMap(int initialCapacity, float loadFactor) {
    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;
    this.threshold = tableSizeFor(initialCapacity); // (*)
}

// 參數爲一個Map的子類
public HashMap(Map<? extends K, ? extends V> m) {
    this.loadFactor = DEFAULT_LOAD_FACTOR;
   
    putMapEntries(m, false);
}
複製代碼

分析:

  1. 無參構造方法的雖然寫法不一樣,可是實際效果是同樣的,這個很容易看出來。

  2. 注意到我上面註釋(*)的地方

    threshold = initialCapacity;
    複製代碼
    this.threshold = tableSizeFor(initialCapacity);
    複製代碼

    這兩行代碼都是把初始容量賦值給了threshold變量。咱們知道,threshold指的是 HashMap 存儲元素的閾值,超過了這個閾值就會對其進行擴容操做。難道這裏和咱們想的還不同?是的,這裏的threshlod只是用於暫存 HashMap 的容量,由於在 HashMap 中並不存在 capacity 這個成員變量。

    所不一樣的是,在 JDK1.7 中,threshold是傳入的初始容量,而在 JDK1.8 中,threshold是傳入的初始容量通過tableSizeFor方法進行向上取最近的 2 的次冪以後的容量值。舉個例子,若是傳入的容量是 12,那麼在 JDK1.7 中,在構造方法調用後,threshold值爲 12,在 JDK1.8 中,threshold值爲 16。

    tableSizeFor方法的源碼以下:

    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;
    }
    複製代碼

    通過這一系列的位運算,若是輸入值是 2 的冪,則原樣返回,若是不是 2 的冪,則向上取就近的冪。至於爲何能夠本身列舉下,這裏咱們只須要知道這個方法的做用就夠了。

    如今我有一個問題,爲何在 JDK1.7 裏threshold就不須要向上取 2 的次冪呢?答案是須要的,不過它不是在構造方法中完成的,而是在inflateTable方法中進行了 HashMap 的初始化

    inflateTable方法的源碼以下:

    private void inflateTable(int toSize) {
        // Find a power of 2 >= toSize
        int capacity = roundUpToPowerOf2(toSize);
    
        // threshold 真正的值
        threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
        table = new Entry[capacity];
        initHashSeedAsNeeded(capacity);
    }
    複製代碼

    看到上面的roundUpToPowerOf2方法了嗎?做用其實和tableSizeFor方法是同樣的,就是讓容量向上取最近的 2 的次冪。

    在這個方法中threshold纔是真正的進行初始化了,threshold = capacity * loadFactor

    同時也把table進行了初始化,我這裏特別提到初始化這三個字,上面一處我也特意加粗了。我強調的緣由是在 JDK1.8 中,初始化並無相似inflateTable這樣單獨的方法,而是在resize方法中完成的,也就是說,在 JDK1.8 中,resize等價於 JDK1.7 中的inflateTable + resize

  3. 咱們看傳入參數爲 Map 子類的構造方法。

    在JDk1.7中,初始化完loadFactor後,就直接調用inflateTable(threshold)方法初始化 HashMap 了。最後把調用putAllForCreate方法把全部 KV 裝入新的 HashMap 中,這個方法仍是比較簡單的。

    putAllForCreate方法源碼:

    private void putAllForCreate(Map<? extends K, ? extends V> m) {
        for (Map.Entry<? extends K, ? extends V> e : m.entrySet())
            // 點進去
            putForCreate(e.getKey(), e.getValue());
    }
    複製代碼

    putForCreate方法源碼:

    private void putForCreate(K key, V value) {
        // 獲取當前key的hash值
        int hash = null == key ? 0 : hash(key);
        // 找到hash值對應的bucket(哈希數組的位置)
        int i = indexFor(hash, table.length);
    
        // 若是當前bucket已經有元素佔據,則繼續向後找,若是找到有key相同的元素,那麼覆蓋原來的值
        for (Entry<K,V> e = table[i]; e != null; e = e.next) {
            Object k;
            if (e.hash == hash &&
                ((k = e.key) == key || (key != null && key.equals(k)))) {
                e.value = value;
                return;
            }
        }
    
        // 當前bucket首元素沒有被佔據,或者當前bucket中沒有相同元素,那麼就在桶的第一個位置添加該元素
        createEntry(hash, key, value, i);
    }
    複製代碼

    createEntry方法源碼:

    void createEntry(int hash, K key, V value, int bucketIndex) {
        Entry<K,V> e = table[bucketIndex];
        table[bucketIndex] = new Entry<>(hash, key, value, e);
        size++;
    }
    複製代碼

    注意到這一行table[bucketIndex] = new Entry<>(hash, key, value, e);說明是把新的節點放入到數組中,也就是鏈表的頭部,JDK1.7 插入元素時頭插法

HashMap 中的變量

JDK1.7中的變量:

// 默認Entry數組的初始化容量,爲16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;

// Entry數組的最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;

// 默認加載因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;

// 初始化的Entry空數組
static final Entry<?,?>[] EMPTY_TABLE = {};

// 哈希數組
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;

// 默認的閾值,當一個鍵值對的鍵是String類型時,且map的容量達到了這個閾值,就啓用備用哈希(alternative hashing)。備用哈希能夠減小String類型的key計算哈希碼(更容易)發生哈希碰撞的發生率。該值能夠經過定義系統屬性jdk.map.althashing.threshold來指定。若是該值是1,表示強制老是使用備用哈希;若是是-1則表示禁用
static final int ALTERNATIVE_HASHING_THRESHOLD_DEFAULT = Integer.MAX_VALUE;

// HashMap的鍵值對數量
transient int size;

int threshold;

final float loadFactor;

// 結構性變化計數器
transient int modCount;

// 哈希種子值,默認爲0
transient int hashSeed = 0;
複製代碼

JDK1.8 中的變量:

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
static final int MAXIMUM_CAPACITY = 1 << 30;
static final float DEFAULT_LOAD_FACTOR = 0.75f;

// 哈希桶上的元素數量增長到此值後,將鏈表轉換爲紅黑樹
static final int TREEIFY_THRESHOLD = 8;

// 哈希桶上的紅黑樹上的元素數量減小到此值時,將紅黑樹轉換爲鏈表
static final int UNTREEIFY_THRESHOLD = 6;

// 哈希數組的容量至少增長到此值,且知足TREEIFY_THRESHOLD的要求時,將鏈表轉換爲紅黑樹
static final int MIN_TREEIFY_CAPACITY = 64;

transient Node<K,V>[] table;
transient Set<Map.Entry<K,V>> entrySet;
transient int size;
int threshold;
final float loadFactor;
transient int modCount;
複製代碼

put 方法分析

JDK1.8

put方法源碼:

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}
複製代碼

putVal方法源碼:

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) {
        // 初始化哈希數組,後面會將resize方法
        tab = resize();

        n = tab.length;
    }
    // p指向hash所在的哈希槽上的首個元素。 (length - 1) & hash 返回的是元素存放的索引
    // 若是哈希槽爲空,則在該槽上放置首個元素(普通Node)
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    // 若是哈希槽不爲空,則須要在哈希槽後面連接更多的元素
    else {
        Node<K,V> e;
        K k;

        /* * 對哈希槽中的首個元素進行判斷 * * 只有哈希值一致(還說明不了key是否一致),且key也相同(必要時須要用到equals()方法)時, * 這裏才認定是存在同位元素(在HashMap中佔據相同位置的元素) */
        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);

        // 上面兩種狀況都是針對首個元素的判斷,下面就是其餘元素的判斷
        // 遍歷哈希槽後面元素(binCount統計的是插入新元素以前遍歷過的元素數量)
        else {
            for (int binCount = 0; ; ++binCount) {
                // 若是沒有找到同位元素,則須要插入新元素
                if ((e = p.next) == null) {
                    // 插入一個普通結點
                    p.next = newNode(hash, key, value, null);
                    // 哈希槽上的元素數量增長到TREEIFY_THRESHOLD後,將從鏈表轉換爲紅黑樹
                    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;
            // 若是onlyIfAbsent爲false,或者原來的值爲null,那麼就覆蓋
            if (!onlyIfAbsent || oldValue == null)
                // 更新舊值
                e.value = value;

            // 回調接口,不用管
            afterNodeAccess(e);
            return oldValue;
        }
    }

    // HashMap的更改次數加一,只有新增和刪除纔會更新,修改是不會的
    ++modCount;
    // 若是哈希數組的容量已超過閾值,則須要對哈希數組擴容
    if (++size > threshold)
        // 後面講
        resize();
    // 回調接口,不用管
    afterNodeInsertion(evict);
    // 若是插入的是全新的元素,在這裏返回null
    return null;
}
複製代碼

JDK1.7

put方法源碼:

public V put(K key, V value) {
    // 若是哈希數組還未初始化,則調用inflateTable初始化
    if (table == EMPTY_TABLE) {
        inflateTable(threshold);
    }
    
    // 若是是key是null,那麼單獨調用putForNullKey添加
    if (key == null)
        return putForNullKey(value);
                              
    int hash = hash(key);
    // 獲取桶的位置
    int i = indexFor(hash, table.length);
    // 若是當前桶已經有元素佔據,則繼續向後找,若是找到有key相同的元素,那麼覆蓋原來的值
    for (Entry<K,V> e = table[i]; e != null; e = e.next) {
        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++;
    // 當前桶首元素沒有被佔據,或者當前桶中沒有相同元素,那麼就在桶的第一個位置添加該元素
    addEntry(hash, key, value, i);
    return null;
}
複製代碼

addEntry方法源碼:

void addEntry(int hash, K key, V value, int bucketIndex) {
    // 若是HashMap的大小超過閾值,而且當前桶不爲空,那麼進行擴容操做
    if ((size >= threshold) && (null != table[bucketIndex])) {
        // 擴容到原來的兩倍
        resize(2 * table.length);
        // 不爲null進行hash
        hash = (null != key) ? hash(key) : 0;
        // 獲取桶的位置
        bucketIndex = indexFor(hash, table.length);
    }

    // 頭插法建立新的節點
    createEntry(hash, key, value, bucketIndex);
}
複製代碼

resize 方法分析

JDK1.7

resize方法源碼:

void resize(int newCapacity) {
    Entry[] oldTable = table;
    int oldCapacity = oldTable.length;
    // 原來大小已經達到最大值,就不擴容了
    if (oldCapacity == MAXIMUM_CAPACITY) {
        threshold = Integer.MAX_VALUE;
        return;
    }

    // 擴容後新的Entry數組
    Entry[] newTable = new Entry[newCapacity];
    // 將原來的元素轉移到新的Entry數組,initHashSeedAsNeeded方法決定是否從新計算String類型的hash值
    transfer(newTable, initHashSeedAsNeeded(newCapacity));
    // 更新table
    table = newTable;
    // 更新threshold
    threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
複製代碼

transfer方法源碼:

void transfer(Entry[] newTable, boolean rehash) {
    int newCapacity = newTable.length;
    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);
            // 若是i位置原來沒有值,則直接插入;有值,採用頭插法
            e.next = newTable[i];
            newTable[i] = e;
            e = next;
        }
    }
}
複製代碼

initHashSeedAsNeeded方法源碼:

final boolean initHashSeedAsNeeded(int capacity) {
    // 若是hashSeed != 0,表示當前正在使用備用哈希
    boolean currentAltHashing = hashSeed != 0;
    // 若是vm啓動了且map的容量大於閾值,使用備用哈希
    boolean useAltHashing = sun.misc.VM.isBooted() &&
        (capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
    // 異或操做,若是兩值同時爲false,或同時爲true,都算是false
    boolean switching = currentAltHashing ^ useAltHashing;
    if (switching) {    
        // 改變hashSeed的值,使hashSeed!=0,rehash時String類型會使用新hash算法
        hashSeed = useAltHashing
            ? sun.misc.Hashing.randomHashSeed(this)
            : 0;
    }
    return switching;
}
複製代碼

Holder中維護的ALTERNATIVE_HASHING_THRESHOLD是觸發啓用備用哈希的閾值,該值表示,若是 HashMap 的容量(Entry 數組大小)達到了該值,啓用備用哈希。

Holder會嘗試讀取 JVM 啓動時傳入的參數-Djdk.map.althashing.threshold並賦值給ALTERNATIVE_HASHING_THRESHOLD。它的值有以下含義:

  • ALTERNATIVE_HASHING_THRESHOLD = 1,老是使用備用哈希
  • ALTERNATIVE_HASHING_THRESHOLD = -1,禁用備用哈希

initHashSeedAsNeeded(int capacity)方法中,會判斷若是 HashMap 的容量(Entry 數組大小)是否大於等於ALTERNATIVE_HASHING_THRESHOLD,是的話就會生成一個隨機的哈希種子hashSeed,該種子會在hash方法中使用到。

上述操做實際上就是爲了防止哈希碰撞攻擊,只對 String 有效,由於 String 的hashcode方法是公開的。咱們本身定義的類的hashcode方法就不須要這種操做了。

在JDK1.7裏,通過 resize 後的鏈表元素會倒置。

JDK1.8

resize方法源碼:

final Node<K,V>[] resize() {

    Node<K,V>[] oldTab = table;
    // 舊容量
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    // 舊閾值
    int oldThr = threshold;
    // 新容量,新閾值
    int newCap, newThr = 0;

    // 若是哈希數組已經初始化(非首次進來)
    if (oldCap > 0) {
        // 若是哈希表數組容量已經超過最大容量
        if (oldCap >= MAXIMUM_CAPACITY) {
            // 將HashMap的閾值更新爲容許的最大值
            threshold = Integer.MAX_VALUE;
            // 不須要更改哈希數組(容量未發生變化),直接返回
            return oldTab;
        }
        // newCap = oldCap << 1 嘗試將哈希表數組容量加倍,若是容量成功加倍(沒有達到上限),則將閾值也加倍
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
    }
    // 若是哈希數組還未初始化(首次進來)
    // 若是實例化HashMap時已經指定了初始容量,則將哈希數組當前容量初始化爲與舊閾值同樣大 this.threshold = tableSizeFor(initialCapacity);
    else if (oldThr > 0) // initial capacity was placed in threshold
        // oldThr在這裏實際上就是原始capacity,由於capacity暫存在threshold裏
        newCap = oldThr;

    // 若是實例化HashMap時沒有指定初始容量,則使用默認的容量與閾值
    else {               // zero initial threshold signifies using defaults
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }

    /* * 至此,若是newThr==0,則可能有如下兩種情形: * 1.哈希數組已經初始化,且哈希數組的容量還未超出最大容量, * 可是,在執行了加倍操做後,哈希數組的容量達到了上限 * 2.哈希數組還未初始化,但在實例化HashMap時指定了初始容量 */
    if (newThr == 0) {
        //
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft              // 針對第二種狀況,將閾值更新爲初始容量*裝載因子
                : Integer.MAX_VALUE);  // 針對第一種狀況,將閾值更新爲最大值
    }

    // 更新閾值
    threshold = newThr;

    // 至此,說明哈希數組須要初始化,或者須要擴容,即建立新的哈希數組
    @SuppressWarnings({"rawtypes","unchecked"})
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];

    // 初始化數組,不須要擴容的話直接返回
    table = newTab;

    // 若是是擴容,則須要將舊元素複製到新容器
    if (oldTab != null) {
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;

            // 若是當前哈希槽上存在元素
            if ((e = oldTab[j]) != null) {
                // 置空該哈希槽
                oldTab[j] = null;

                // 若是該哈希槽上只有一個元素
                if (e.next == null)
                    // 因爲總容量變了,因此須要從新哈希
                    newTab[e.hash & (newCap - 1)] = e;

                // 若是該哈希槽上連接了不止一個元素,且該元素是TreeNode類型
                else if (e instanceof TreeNode)
                    // 拆分成黑樹以適應新的容量要求
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);

                // 若是該哈希槽上連接了不止一個元素,且該元素是普通Node類型
                else { // preserve order

                    // 低位鏈表:存放擴容以後數組下標 = 當前數組下標
                    Node<K,V> loHead = null, loTail = null;
                    // 高位鏈表:存放擴容以後數組下標 = 當前數組下標 + 擴容前數組大小
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;

                    do {
                        next = e.next;
                        // 擴容前16,擴容後32,好比在最後一個哈希桶索引爲15的元素進行以下操做:
                        // hash = 0 1111
                        // oldCap = 1 0000
                        // e.hash & oldCap = 0 0000
                        if ((e.hash & oldCap) == 0) {
                            // 若是沒有尾,說明鏈表爲空
                            if (loTail == null)
                                // 鏈表爲空時,頭節點指向該元素
                                loHead = e;
                            else
                                // 若是有尾,那麼鏈表不爲空,把該元素掛到鏈表的最後
                                loTail.next = e;
                            // 把尾節點設置爲當前元素
                            loTail = e;
                        }
                        // hash = 1 1111
                        // oldCap = 1 0000
                        // e.hash & oldCap = 1 0000
                        else {
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);

                    // 低位的元素組成的鏈表仍是放置在原來的位置
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }

                    // 高位的元素組成的鏈表放置的位置只是在原有位置上偏移了老數組的長度個位置
                    // 例:hash爲17在老數組放置在0下標,在新數組放置在16下標
                    // hash爲18在老數組放置在1下標,在新數組放置在17下標
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}
複製代碼

分析:

  1. threshold = newThr;看到了嗎?threshold在這裏才更新爲真正的閾值,以前都是暫存容量的。

  2. 對於鏈表結構節點的從新分配,不一樣於 JDK1.7 中須要從新進行 index 的計算,在 JDK1.8 中,是經過分組的方式存儲在低位和高位鏈表中。

    舉個例子:

    有一個哈希表的容量爲16,其中一個元素的 hash 值爲:1001 1111,那麼通過計算,最後這個元素在哈希表中的位置是 15

    ​ n - 1: 0000 1111

    ​ hash:1001 1111

    ​ index:0000 1111 = 15

    另有一個元素的 hash 值爲:1000 1111,那麼通過計算,最後這個元素在哈希表中的位置也是 15

    ​ n - 1: 0000 1111

    ​ hash:1000 1111

    ​ index:0000 1111 = 15


    能夠發現,在擴容前這兩個元素都是存放在了索引爲 15 的哈希桶中。可是擴容後就不同了,因爲容量變成了原來的兩倍 32,那麼哈希表的索引也就會發生改變

    ​ n - 1: 0001 1111

    ​ hash:1001 1111

    ​ index:0001 1111 = 31 = 15 + 16

    ​ n - 1: 0001 1111

    ​ hash:1000 1111

    ​ index:0000 1111 = 15

    注意到我加粗的數字,擴容後的索引位置貌似和 hash 值的第 5 位有關,也就是說,咱們只須要考慮第 5 位是 0 仍是 1,若是是 1 就放在高位,若是是 0 就放在低位,沒錯,事實就是如此,那該如何判斷呢?咱們發現,哈希表原來的容量是16,轉換成二進制恰好是 0001 0000,這樣不就能夠經過讓元素的 hash 值和原來的數組容量進行 & 運算來判斷第 5 位了。若是第 5 位是 1,說明存放在高位,數組索引爲原位置+原數組大小,不然是 0,說明存在在低位,也就是原位置

    在 JDK1.8 中確實就是這麼作的,見以下代碼:

    // 讓元素的哈希值與擴容前的數組大小進行&運算,爲0存放在低位鏈表loHead loTail
    if ((e.hash & oldCap) == 0) {
        // 若是沒有尾,說明鏈表爲空
        if (loTail == null)
            // 鏈表爲空時,頭節點指向該元素
            loHead = e;
        else
            // 若是有尾,那麼鏈表不爲空,把該元素掛到鏈表的最後
            loTail.next = e;
        // 把尾節點設置爲當前元素
        loTail = e;
    }
    // 爲1存放在高位鏈表hiHead hiTail
    else {
        if (hiTail == null)
            hiHead = e;
        else
            hiTail.next = e;
        hiTail = e;
    }
    複製代碼
    // 低位的元素組成的鏈表仍是放置在 原來的位置
    if (loTail != null) {
        loTail.next = null;
        newTab[j] = loHead;
    }
    
    // 高位的元素組成的鏈表放置的位置是在 原有位置上偏移了原來數組的長度個位置
    if (hiTail != null) {
        hiTail.next = null;
        newTab[j + oldCap] = hiHead;
    }
    複製代碼

實際效果以下圖:

JDK1.8擴容從新分配

remove 方法分析

JDK1.7

remove方法源碼:

public V remove(Object key) {
    Entry<K,V> e = removeEntryForKey(key);
    return (e == null ? null : e.value);
}
複製代碼

removeEntryForKey方法源碼:

final Entry<K,V> removeEntryForKey(Object key) {
    if (size == 0) {
        return null;
    }
    int hash = (key == null) ? 0 : hash(key);
    int i = indexFor(hash, table.length);
    Entry<K,V> prev = table[i];
    Entry<K,V> e = prev;

    while (e != null) {
        Entry<K,V> next = e.next;
        Object k;
        if (e.hash == hash &&
            ((k = e.key) == key || (key != null && key.equals(k)))) {
            // 找到能夠刪除的元素,刪除須要標誌結構性變化
            modCount++;
            size--;
            // 須要刪除的元素恰好是桶中第一個元素,那麼讓table[i]指向後一個元素
            if (prev == e)
                table[i] = next;
            else
                prev.next = next;
            e.recordRemoval(this);
            return e;
        }
        prev = e;
        e = next;
    }

    return e;
}
複製代碼

JDK1.8

remove方法源碼:

public boolean remove(Object key, Object value) {
    return removeNode(hash(key), key, value, true, true) != null;
}
複製代碼

removeNode方法源碼:

final Node<K,V> removeNode(int hash, Object key, Object value, boolean matchValue, boolean movable) {
    Node<K,V>[] tab;
    Node<K,V> p;
    int n, index;
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (p = tab[index = (n - 1) & hash]) != null) {
        Node<K,V> node = null, e;
        K k;
        V v;

        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            node = p;
        else if ((e = p.next) != null) {
            if (p instanceof TreeNode)
                node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
            else {
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key ||
                         (key != null && key.equals(k)))) {
                        node = e;
                        break;
                    }
                    // p指向的是node的前一個節點
                    p = e;
                } while ((e = e.next) != null);
            }
        }

        /* * 從HashMap中移除匹配的元素 * 可能只須要匹配hash和key就行,也可能還要匹配value,這取決於matchValue參數 */
        if (node != null && (!matchValue || (v = node.value) == value ||
                             (value != null && value.equals(v)))) {
            if (node instanceof TreeNode)
                ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
            // 刪除的是第一個節點
            else if (node == p)
                tab[index] = node.next;
            // 刪除中間的節點,node表示待刪元素,即讓node的前一個節點p的下一個節點指向node的下一個節點
            else
                p.next = node.next;
            ++modCount;
            --size;
            // 回調接口
            afterNodeRemoval(node);
            return node;
        }
    }
    return null;
}
複製代碼

removeNode方法參數說明:

  • matchValue:移除元素時是否須要考慮 value 的匹配問題
  • movable:移除元素後若是紅黑樹根結點發生了變化,那麼是否須要改變結點在鏈表上的順序

get 方法分析

JDK1.7

get方法源碼:

public V get(Object key) {
    if (key == null)
        return getForNullKey();
    Entry<K,V> entry = getEntry(key);

    return null == entry ? null : entry.getValue();
}
複製代碼

getEntry方法源碼:

final Entry<K,V> getEntry(Object key) {
    if (size == 0) {
        return null;
    }

    int hash = (key == null) ? 0 : hash(key);
    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;
}
複製代碼

根據給定的 keyhash查找對應的(同位)元素,若是找不到,則返回 null

JDK1.8

get方法源碼:

public V get(Object key) {
    Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}
複製代碼

getNode方法源碼:

final Node<K,V> getNode(int hash, Object key) {
    Node<K,V>[] tab;
    Node<K,V> first, e;
    int n;
    K k;

    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
        if (first.hash == hash && // always check first node
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        if ((e = first.next) != null) {
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            do {
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}
複製代碼

根據給定的 keyhash查找對應的(同位)元素,若是找不到,則返回 null

刪除和獲取我都不在詳細分析了,基本和添加差很少。

問答

爲何使用數組+鏈表或紅黑樹?

數組是用來肯定哈希桶的位置,利用元素的 key 的 hash 值對數組長度取模獲得。鏈表或紅黑樹是用來解決 hash 衝突問題,當出現 hash 值同樣的情形,就在數組上的對應位置造成一條鏈表或一棵樹。

**PS:**這裏的 hash 值並非指 hashcode,而是將 hashcode 高低十六位異或過的(JDK1.8)。

HashMap 的 get 過程(JDK1.8)

對 key 的 hashCode 進行 hash 運算,計算在哈希數組中的下標獲取 bucket 位置,若是在桶的首位上就能夠找到就直接返回,不然在樹中找或者鏈表中遍歷尋找。

HashMap 的 put 過程(JDK1.8)

putVal添加元素的過程:

  1. 若是哈希數組沒有初始化,那麼調用resize方法初始化哈希數組
  2. 獲取添加元素在哈希數組中的索引,判斷該位置是否有元素,若是沒有,那麼直接添加便可
  3. 若是已經有元素佔用,那麼判斷該位置存放的是鏈表仍是紅黑樹。若是是鏈表,判斷當前位置的第一個元素的 hashcode 和 key 是否和本身的相同,相同則由 onlyIfAbsent 肯定是否須要覆蓋(或者自己是null直接覆蓋);若是是紅黑樹,則直接調用 putTreeVal 方法存放。
  4. 首元素判斷完後,若是不知足條件,那麼開始遍歷後面的節點,若是到了鏈表末尾仍是沒有找到相同的元素,那麼直接在尾部添加當前元素。若是在這期間遍歷的元素數量達到樹化的條件,那麼須要將原來的鏈表轉換爲紅黑樹。
  5. 若是遍歷期間找到和本身 hashcode 和 key 相同的元素,那麼由 onlyIfAbsent 肯定是否須要覆蓋(或者自己是null直接覆蓋)
  6. 若是添加了新元素而不是覆蓋原有值,須要 modCount 加1,表示發生了一次結構性變化。若是 size大於 threshold,則須要擴容resize

爲何用 (n-1)&hash 而不是 hash%n

這個問題也就是爲何 HashMap 擴容須要是2的次冪

這裏的 n 表明哈希表的長度,哈希表習慣將長度設置爲 2 的 n 次方,這樣剛好能夠保證 (n - 1) & hash 的計算獲得的索引值老是位於 table 數組的索引以內。例如:hash=15,n=16 時,結果爲 15;hash=17,n=16 時,結果爲 1。

但若是用 hash%n,那麼若是 hash 是負數就會出現結果也是負數,而且%運算的效率低。

爲何 JDK1.8 不直接使用紅黑樹,而是保留了鏈表?

HashMap 在 JDK1.8 及之後的版本中引入了紅黑樹結構,若桶中鏈表元素個數大於等於 8 時,鏈表轉換成樹結構;若桶中鏈表元素個數小於等於 6 時,樹結構還原成鏈表。由於紅黑樹的平均查找長度是 log(n),長度爲 8 的時候,平均查找長度爲 3,若是繼續使用鏈表,平均查找長度爲 8/2=4,這纔有轉換爲樹的必要。鏈表長度若是是小於等於 6,6/2=3,雖然速度也很快的,可是轉化爲樹結構和生成樹的時間並不會過短。

選擇 6 和 8,中間有個差值 7 能夠有效防止鏈表和樹頻繁轉換(相似於複雜度震盪)。假設一下,若是設計成鏈表個數超過 8 則鏈表轉換成樹結構,鏈表個數小於 8 則樹結構轉換成鏈表,若是一個 HashMap 不停的插入、刪除元素,鏈表個數在 8 左右徘徊,就會頻繁的發生樹轉鏈表、鏈表轉樹,效率會很低。

第二種回答:

由於紅黑樹須要進行左旋,右旋,變色這些操做來保持平衡,而單鏈表不須要。 當元素小於8個的時候,此時作查詢操做,鏈表結構已經能保證查詢性能。當元素大於8個的時候,此時須要紅黑樹來加快查詢速度,可是新增節點的效率變慢了。全部才選取 8 這個數字做爲鏈表轉爲紅黑樹的閾值,由於發生哈希衝突的機率知足泊松分佈,當發生8次哈希碰撞的機率幾乎爲千萬分之六,即之後不多會有元素再次添加到這個桶中,這樣即便紅黑樹的新增元素效率低,也不會有多大影響了,由於幾乎沒有哈希桶中元素會超過8個。

固然這都得益於哈希函數設計的好,若是本身設計的哈希函數分佈不均勻,好比咱們把對象的hashcode都統一返回一個常量,最終的結果就是 HashMap 會退化爲一個鏈表,get 方法的性能降爲 O(n),使用紅黑樹能夠將性能提高到 O(log(n)),因此應該避免這種狀況的發生。

談一下 HashMap 中 hash 函數是怎麼實現的

用高16位與低16位進行異或

一、至於爲何要這樣呢?

hashcode是一個32位的值,用高16位與低16位進行異或,緣由在於求index是是用 (n-1) & hash ,若是hashmap的capcity很小的話,那麼對於兩個高位不一樣,低位相同的hashcode,可能最終會裝入同一個桶中。那麼會形成hash衝突,好的散列函數,應該儘可能在計算hash時,把全部的位的信息都用上,這樣才能儘量避免衝突。

二、爲何使用異或運算?

經過寫出真值表能夠看出:異或運算爲 50%的0和 50%的1,所以對於合併均勻的機率分佈很是有用。

a | b | a AND b

---+---+--------

0 | 0 | 0

0 | 1 | 0

1 | 0 | 0

1 | 1 | 1

a | b | a OR b

---+---+--------

0 | 0 | 0

0 | 1 | 1

1 | 0 | 1

1 | 1 | 1

a | b | a XOR b

---+---+--------

0 | 0 | 0

0 | 1 | 1

1 | 0 | 1

1 | 1 | 0

hash 衝突有哪些解決辦法?

鏈地址法

開放地址法

  • 線性探測。遇到哈希衝突 +1 到下一個判斷
  • 平方探測。遇到哈希衝突 +1 +4 +9 +16
  • 二次哈希。遇到哈希衝突 + hash2(key)

再哈希法

公共溢出區域法

HashMap 在什麼條件下擴容?

JDK1.7

存放新值的時候當前已有元素的個數必須大於等於閾值,且當前加入的數據發生了 hash 衝突

JDK1.8

一、初始化哈希數組時會調用 resize 方法

二、put 時若是哈希數組的容量已超過閾值,則須要對哈希數組擴容

三、在樹化前,會先檢查哈希數組長度,若是哈希數組的長度小於64,則進行擴容,而不是進行樹化

HashMap 擴容優化

在 JDK1.7 中,HashMap 整個擴容過程就是分別取出數組元素,通常該元素是最後一個放入鏈表中的元素,而後遍歷以該元素爲頭(頭插法)的單向鏈表元素,依據每一個被遍歷元素的 hash 值計算其在新數組中的下標,而後進行交換。這樣的擴容方式會將原來哈希衝突的單向鏈表尾部變成擴容後單向鏈表的頭部

而在 JDK 1.8 中,HashMap 對擴容操做作了優化。因爲擴容數組的長度是 2 倍關係,因此對於假設初始 tableSize = 4 要擴容到 8 來講就是 0100 到 1000 的變化(左移一位就是 2 倍),在擴容中只用判斷原來的 hash 值和左移動的一位(newtable 的值)按位與操做是 0 或 1 就行,0 的話索引不變,1 的話索引變成原索引加上擴容前數組。

之因此能經過這種「與運算「來從新分配索引,是由於 hash 值原本就是隨機的,而 hash 按位與上 newTable 獲得的 0(擴容前的索引位置)和 1(擴容前索引位置加上擴容前數組長度的數值索引處)就是隨機的,因此擴容的過程就能把以前哈希衝突的元素再隨機分佈到不一樣的索引中去。

通常使用什麼做爲 HashMap 的鍵?

通常用 Integer、String 這種不可變類做爲 HashMap 的 key。

String 最爲經常使用,由於:

  • 由於字符串是不可變的,因此在它建立的時候 hashcode 就被緩存了,不須要從新計算。這就使得字符串很適合做爲 Map 中的鍵,字符串的處理速度要快過其它的鍵對象。這就是 HashMap中 的鍵每每都使用字符串。
  • 由於獲取對象的時候要用到 equals() 和 hashCode() 方法,那麼鍵對象正確的重寫這兩個方法是很是重要的,這些類已經很規範的覆寫了 hashCode() 以及 equals() 方法。

LoadFactor 負載因子的設計

默認 LoadFactor 值爲 0.75。爲何是 0.75 這個值呢?

這是由於對於使用鏈表法的哈希表來講,查找一個元素的平均時間是 O(n),這裏的 n 指的是遍歷鏈表的長度,所以加載因子越大,對空間的利用就越充分,這就意味着鏈表的長度越長,查找效率也就越低。若是設置的加載因子過小,那麼哈希表的數據將過於稀疏,對空間形成嚴重浪費。

HashMap 與 HashTable 區別

Hashtable 能夠看作是線程安全版的 HashMap,二者幾乎「等價」(固然仍是有不少不一樣)。

Hashtable 幾乎在每一個方法上都加上 synchronized(同步鎖),實現線程安全。

HashMap 能夠經過 Collections.synchronizeMap(hashMap) 進行同步。

區別:

  • HashMap 繼承於 AbstractMap,而 Hashtable 繼承於 Dictionary;
  • 線程安全不一樣。Hashtable 的幾乎全部函數都是同步的,即它是線程安全的,支持多線程。而HashMap 的函數則是非同步的,它不是線程安全的。若要在多線程中使用 HashMap,須要咱們額外的進行同步處理;
  • null 值。HashMap 的 key、value 均可覺得 null。Hashtable 的 key、value 都不能夠爲 null;
  • 迭代器 (Iterator)。HashMap 的迭代器 (Iterator) 是 fail-fast 迭代器,而 Hashtable 的 enumerator 迭代器不是 fail-fast 的。因此當有其它線程改變了 HashMap 的結構(增長或者移除元素),將會拋出ConcurrentModificationException。
  • 容量的初始值和增長方式都不同:HashMap 默認的容量大小是 16;增長容量時,每次將容量變爲「原始容量x2」。Hashtable 默認的容量大小是 11;增長容量時,每次將容量變爲「原始容量x2 + 1」;
  • 添加 key-value 時的 hash 值算法不一樣:HashMap 添加元素時,是使用自定義的哈希算法。Hashtable 沒有自定義哈希算法,而直接採用的 key 的 hashCode()。
  • 速度。因爲 Hashtable 是線程安全的也是 synchronized,因此在單線程環境下它比 HashMap 要慢。若是你不須要同步,只須要單一線程,那麼使用 HashMap 性能要好過 Hashtable。

紅黑樹中爲何新加入的節點老是紅色的?

由於被插入前的樹結構是構建好的,一旦咱們進行添加黑色的節點,不管添加在哪裏都會破壞原有路徑上的黑色節點的數量平等關係,因此插入紅色節點是正確的選擇。

相關文章
相關標籤/搜索