java之hashmap

心得:相較於JDK 1.7,Java 8中的HashMap有了較大的性能提升。修改了hash和resize方式,增加了紅黑樹的支持。

學習參考資料
(1)[jdk7 HashMap的死循環](https://blog.csdn.net/maohoo/article/details/81531925)

1. HashMap要點

(1)結構特點:Java中的HashMap是基於“數組+鏈表”的方式(鏈表法解決衝突),到了Java 8,應該是“數組+鏈表/紅黑樹”的方式。
(2)線程安全:HashMap是不安全,Collections Framework中有兩種線程安全的實現:Collections.synchronizedMap(new java.util.HashMap<>());和ConcurrentHashMap,前者是鎖整個表,後者是16個分段鎖:
學習參考資料(1),展示了Java 7中,併發出現“死循環”的一種情形,就是在resize過程中,遷移Entry到新桶中是產生了一個有環的鏈表造成的,Java 7中resize的transfer是在鏈表頭部插入新節點,Java 8中的新節點的插入是尾部;
對於Java 8中resize一個桶中的如果是鏈表的話,會被分兩個鏈表一個保留在原來索引位置上,一個在(oldCap + oldIndex)位置,因爲是在尾部插入,所以它們相對位置不變,但HashMap還是線程不安全的;
(3)性能特點:HashMap可以在常數時間內增加,刪除,查找元素,但這也是一種平均情況,使用load factor裝載因子計算閾值就是爲了減少衝突過多,帶來的性能退化;
(4)Java 8相對於Java 7中HashMap的區別和優化:;
(1)計算hash值的方法:Java 7中會基於一個隨機種子計算hash值,這樣每次resize如果得到不同的隨機種子,那麼原來一個桶中的元素,會被“隨機”散列到桶數組中,Java 8放棄了這種做法,基於key的hashCode(通過異或計算綜合高位和地位),舊桶的元素如上面所說只可能散列到兩個確定位置的桶中,基於好的hashCode計算,這也是隨機分佈的,這樣可以簡化了計算並且省去了隨機種子的計算;
(2)紅黑樹的應用:當桶的數量超過MIN_TREEIFY_CAPACITY時,向一個元素數達到TREEIFY_THRESHOLD的桶中插入節點時會將桶中的鏈表轉化爲紅黑樹實現,也就是變O(n)的查找轉變爲O(log n);
(5)HashMap中優化性能的設計
(1)何時進行resize:和ArrayList一次擴展爲原大小的3/2類似,HashMap的桶數組一次擴展爲原數組的2倍,控制擴展和移動的次數;
(2)桶數組的容量是2的冪次方,這樣設計有三個好處,一是2的冪次方減1正好可以得到一個計算index的掩碼,二是擴展大小時一次位運算(<<)既可以計算出新的容量同時有保持了2的冪次方這一特點,三是進行遷移舊桶元素時,可以方便計算出元素新桶數組中兩個位置;
(6)HashMap的應用
根據HashMap特點,可以知道它可以實現常數時間的精確查找,插入和刪除,可以通過它建立在內存中一些簡單的運行時緩存數據;
但是顯然哈希表不支持很好的範圍查找,另外的對於過多的數據在Java 7中可能造成退化成鏈表的情形,因此一個好的hashCode實現是十分重要的,當然過大的數據也不太可能到放在內存裏(內存泄漏,HashMap中有大量過期數據是個需要注意的問題,當然我們可以使用WeakHashMap);

PS:覆蓋了equals,一定要覆蓋hashCode函數,否則equals相等,hashCode不相等就扯淡了。

2. 結構

2.1 重要的值

容量:

//默認初始化容量,HashMap容量必須是2的冪次方
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 16
//最大容量不得超過1<<30
static final int MAXIMUM_CAPACITY = 1 << 30;

擴展:

(1)容量 × 裝載因子:超過這個閾值,將進行resize擴展爲原來大小的2倍;
(2)桶中元素樹結構化(用紅黑樹代替鏈表):桶中元素數超過TREEIFY_THRESHOLD並且桶的數量超過MIN_TREEIFY_CAPACITY會進行樹結構化,否則超過TREEIFY_THRESHOLD使用resize擴展桶容量;

//默認裝載因子,0.75是權衡空間和時間開銷之後的綜合考慮
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//超過這個閾值將使用紅黑樹組織桶中的結點,而不是鏈表
static final int TREEIFY_THRESHOLD = 8;
//只有表的大小超過這個閾值,桶纔可以被轉換成樹而不是鏈表(爲超過這個值時,應該使用resize)
//這個值是TREEIFY_THRESHOLD的4倍,以便resizing和treeification之間產生衝突
static final int MIN_TREEIFY_CAPACITY = 64;

2.2 重要的屬性

(1)桶數組(table):延遲加載,第一次插入數據前分配內存,桶的大小是2的冪次方,好處是便於快速計算hash值和擴展;
(2)閾值和裝載因子(threshold,loadFactor):有capacity × load factor,用一個變量保存它是因爲每次put新鍵值對都要檢查它,顯然不能每次都計算;
(3)鍵值對數量(size);
(4)修改計數器:fail-fast機制,這個機制不能用於維護正確性,只能用於調試bug;
(5)視圖(entrySet,keySet,values):鍵值對的保存方式使用“數組+鏈表/紅黑樹”,這三個視圖基於這個實現採用集合方式返回數據,主要用於遍歷;

//延遲加載,長度總爲2的冪次方
transient Node<K,V>[] table;
//鍵值對數量
transient int size;

//fail-fast
transient int modCount;

//下一次resize的閾值 (capacity * load factor)
int threshold;
//裝載因子
final float loadFactor;

//視圖
//鍵值對緩存,它們的映射關係集合保存在entrySet中,即使Key在外部修改導致hashCode變化,緩存中還可以找到映射關係
transient Set<Map.Entry<K,V>> entrySet;
transient volatile Set<K> keySet;
transient volatile Collection<V> values;

2.3 構造器

構造器重載版本:
(1)指定初始容量和裝載因子:不指定是使用默認的值,延遲初始化到第一次添加鍵值對;
(2)拷貝構造器:使用默認裝載因子,容量大小是不小於DEFAULT_INITIAL_CAPACITY的最小的超過傳入鍵值對數量的2的冪次方;

//傳入指定初始化容量,將計算好threshold的值,第一次放入元素時分配threshold大小的數組
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;
    //此時table還未分配到內存,threshold就是將要分配的數組大小
    this.threshold = tableSizeFor(initialCapacity);
}
public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
public HashMap(Map<? extends K, ? extends V> m) {
    this.loadFactor = DEFAULT_LOAD_FACTOR;
    putMapEntries(m, false);
}

3. 操作

3.1 核心內部操作

計算容量,hash值,索引值:

(1)計算容量:tableSizeFor()方法,使用位運算快速計算;
(2)hash值的計算:因爲容量最小是16,而計算索引值的時候用(容量-1)作爲掩碼的,那可能因爲hash值的高位不會被計算而導致衝突的概率增加
(3)計算索引值:JDK 7中有一個indexFor方法計算索引值,Java 8中去掉了,但是邏輯沒有變,比如在putVal方法中p = tab[i = (n - 1) & hash]

//找到最小的大於等於cap的2的冪次方,二進制位運算
static final int tableSizeFor(int cap) {
    int n = cap - 1; //減1是爲了排除“100000”這種情況
    n |= n >>> 1; //這裏位運算就是在用最高位的1“鋪滿所有位”
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

//計算key的hash值,這裏在hashCode基礎上做了一次“高位向低位傳播”
//因爲計算索引值是(cap - 1) & hash,當cap小於等於16時高位將無法起作用
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

重新分配桶數組(resize方法,三步:先確定桶的大小,再創建數組對象,最後給舊桶中元素搬家)

(1)桶數組不爲空,擴展爲原大小兩倍(newCap = oldCap << 1)2倍擴展+閾值是一個重要的分配優化策略,這樣可以大大減少分配數組對象並複製元素的次數

    //一,原數組不爲空
    if(oldCap > 0) {
        //如果oldCap已經爲最大容量
        if(oldCap >= MAXIMUM_CAPACITY) {
            threshold = MAXIMUM_CAPACITY;
            return oldTab;
        } else if((newCap = oldCap << 1) <= MAXIMUM_CAPACITY &&
                oldCap > DEFAULT_INITIAL_CAPACITY)
            threshold = oldThr << 1; //增加閾值
    }

(2)桶數組爲空,第一次分配,結合不同構造器的情況細節稍有不同:

    //重新創建table數組
    //原數組爲空,oldThr不爲空,擴展爲oldThr大小
    else if(oldThr > 0)
        newCap = oldThr;
    //原數組爲空,oldThr爲空,全部使用默認值
    else {
        //全部使用默認值
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_INITIAL_CAPACITY * loadFactor);
    }

(3)分配新內存:

    //Node[]不具備類型檢查的能力,因此要通過強制類型轉換
    //另外,不能創建參數化類型的數組
    @SuppressWarnings({"rawtypes","unchecked"})
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;

將元素“移動”到新桶中也分幾種情況:
(4)桶中只有一個元素:

if(e.next == null)
    //桶中只有一個元素,不可能是TreeNode直接放入新表的指定位置
    newTab[e.hash & (newCap - 1)] = e;

(5)第一個元素是紅黑樹節點:

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

(6)桶中存放的是鏈表:
相對與Java 7這裏也有優化:
不存在rehash重新計算的可能,由於hash值不變,容量是直接×2的,因此舊桶中的一個鏈表實際上最多會被分成兩個鏈表一個在原來的索引位置(oldIndex)上,另一個就在oldIndex+oldCap位置上。

/*
桶中存在一個鏈表,需要將鏈表重新整理到新表當中,因爲newCap是oldCap的兩倍所以原節點的索引值要麼和原來一樣,要麼就是原(索引+oldCap)和JDK 1.7中實現不同這裏不存在rehash,直接使用原hash值JDK 1.7中resize過程是在鏈表頭插入,這裏是在鏈表尾插入
*/
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
do {
    if((e.hash & oldCap) == 0) {
        //和原索引值一樣
        if(loTail == null)
            loHead = e;
        else
            loTail.next = e;
        loTail = e;
    } else {
        if(hiTail == null)
            hiHead = e;
        else
            hiTail.next = e;
        hiTail = e;
    }
} while ((e = e.next) != null);
if(loTail != null) {
    loTail.next = null;
    newTab[j] = loHead;
}
if(hiTail != null) {
    hiTail.next = null;
    newTab[j + oldCap] = hiHead;
}

添加鍵值對

(1)第一次插入或桶是空的:

    //如果是第一次添加元素
    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);

(2)桶中有元素,首先檢查第一個元素,因爲樹結構必須大於2個節點,再分類型檢查;

//首先檢查第一個節點
if(p.hash == hash &&
    ((k = p.key) == key || (key != null && key.equals(k))))
    e = p;

(3)桶中是紅黑樹:

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

(4)桶中是鏈表,添加新節點,如果達到了TREEIFY_THRESHOLD,需要檢查是否要轉換爲紅黑樹結構,treeifyBin()會檢查桶數組的大小是否超過MIN_TREEIFY_CAPACITY(64),不超過只是進行resize擴展,否則才轉換樹:

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

查找鍵值對(根據hash值和key查找)

這裏hash值有兩個作用:
一是根據hash確定桶的位置,基於良好的hashCode實現,這一步正式HashMap常數操作時間的保證。
(2)hash和key本質上是key的hashCode和equals方法的應用,hashCode不相等,equals必然不相等,hashCode相等再檢查equals是否相等。反映到程序上就是一個短路優化。

如果桶數組不爲空,而且對應的桶(hash & (table.length - 1))中有節點:
(1)首先檢查桶中第一個節點:

//總是檢查第一個節點的原因:無論是樹結構還是鏈表,都可以方便的檢查第一個節點,樹結構的節點數必然大於1
//先檢查hash,利用好短路特性
if(first.hash == hash &&
    ((k = first.key) == key || (key != null && key.equals(k))))
    return first;

(2)多於一個節點,檢查類型,分別處理:

    //多於一個節點,繼續檢查
        if((e = first.next) != null) {
            if(first instanceof TreeNode)
                return (TreeNode)first.getTreeNode(hast, key);
            do {
                if(e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }

刪除鍵值對

基本的操作和添加大致相同,另外

如果桶數組不爲空,而且對應的桶(hash & (table.length - 1))中有節點:
(1)檢查桶中第一個節點:

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

(2)多於一個節點,檢查類型,分別處理:

        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 = e;
                } while ((e = e.next) != null);
            }
        }

(3)找了要刪除的節點之後,:

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;
    //刪除非表頭節點
    else
        p.next = node.next;
    ++modCount;
    --size;
    afterNodeRemoval(node);
    return node;
}

清除(clear方法)

清除所有節點,這裏只是將“桶給清空了”,鏈表或者紅黑樹本身並沒有置空操作;

public void clear() {
    Node<K,V>[] tab;
    modCount++;
    if ((tab = table) != null && size > 0) {
        size = 0;
        for (int i = 0; i < tab.length; ++i)
            tab[i] = null;
    }
}

視圖操作

HashMap本身是數組加鏈表的關係,但如果需要遍歷的話以Set接口來遍歷顯然是一種很統一的設計。

因此Map接口提供了Set視圖,基於HashMap的存儲方式,實現了對鍵值對集合鍵集合值集合視圖訪問。

Set的遍歷關鍵一點是Iterator的實現:

HashIterator

依據HashMap“數組+鏈表/紅黑樹”的存儲特點,HashMap包含一個骨架類:HashIterator
PS:HashMap中紅黑樹的實現,TreeNode維護了next節點,可以通過next以類似鏈表的方式遍歷;
HashIterator的迭代方式,是沿着桶數組找到一個非空的,迭代這個鏈表/紅黑樹,迭代完之後,找到下一個非空的桶繼續遍歷;

視圖迭代器的實現:

//鍵集合迭代器
final class KeyIterator extends HashIterator
    implements Iterator<K> {
    public final K next() { return nextNode().key; }
}

final class ValueIterator extends HashIterator
implements Iterator<V> {
public final V next() { return nextNode().value; }
}

final class EntryIterator extends HashIterator
implements Iterator<Map.Entry<K,V>> {
public final Map.Entry<K,V> next() { return nextNode(); }
}

視圖集合實現:

首先一個共同的特點是不能基於視圖Set以及它們的迭代器執行添加操作;
remove,clear,contains基於HashMap對應方法實現的。

3. HashMap的“樹化”

前面說過當桶的數量大於MIN_TREEIFY_CAPACITY(64)並且一個桶中的元素數超過TREEIFY_THRESHOLD時就會將這個桶中的鏈表變成紅黑樹結構,但是在樹化的同時,這個紅黑樹保持了節點之間的“next”鏈接關係,使得可以向鏈表一樣遍歷,這在迭代其中十分有用,那它是如何保持的呢?

TreeNode的結構:

和普通的紅黑樹節點相比,TreeNode多了兩個引用變量:next和prev,這說明它同時保持了一個雙向鏈表的結構,之所以要是雙向鏈表是因爲,在添加,刪除是用使用紅黑樹的操作,但是爲了支持鏈表同時也要維護鏈表鏈接,顯然再遍歷一邊找到前序節點就又退化成鏈表了,故而使用雙向鏈表。

    TreeNode<K,V> parent;  // red-black tree links
    TreeNode<K,V> left;
    TreeNode<K,V> right;
    TreeNode<K,V> prev;    // needed to unlink next upon deletion
    boolean red;

樹化過程(treeifyBin()方法):

(1)當小於MIN_TREEIFY_CAPACITY,不要樹化,通過resize擴展桶數組:

if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
        resize();

(2)超過MIN_TREEIFY_CAPACITY,開始樹化,首先是替換鏈表節點對象(Node)爲TreeNode節點,建立雙向鏈表。在從頭開始進行樹化。

else if ((e = tab[index = (n - 1) & hash]) != null) {
        TreeNode<K,V> hd = null, tl = null;
        do {
            TreeNode<K,V> p = replacementTreeNode(e, null);
            if (tl == null)
                hd = p;
            else {
                p.prev = tl;
                tl.next = p;
            }
            tl = p;
        } while ((e = e.next) != null);
        if ((tab[index] = hd) != null)
            hd.treeify(tab);
    }

(3)樹化中的節點順序問題,紅黑樹是搜索樹,因此需要節點是有序的,但是HashMap的類型參數沒有Comparable的限定,因此當key對象類型未實現Comparable接口,將使用這個對象的原始hashCode(即Object的hashCode,無論有沒有覆蓋hashCode方法,null的hashCode爲0)進行比較;

static int tieBreakOrder(Object a, Object b) {
        int d;
        if (a == null || b == null ||
            (d = a.getClass().getName().
             compareTo(b.getClass().getName())) == 0)
            d = (System.identityHashCode(a) <= System.identityHashCode(b) ?
                 -1 : 1);
        return d;
    }

(4)基於此樹化中的兩個疑問就弄清了:一是如何保持鏈表結構,二是有序性的獲得,TreeNode.treeify這個方法的工作就是從這個節點開始遍歷鏈表插入每個節點到紅黑樹中,每次插入之後修補黑平衡性這已經是很熟悉的內容了;

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章