Java集合——HashMap(jdk1.7)

午夜12點發表於2018-04-09

Java集合三大體系——List、Set、Map,而Set是基於Map實現的.在Map中HashMap作為其中常用類,面試中的常客.記得之前有次面試回答,HashMap是連結串列雜湊的資料結構,其容量是16,負載因子0.75,當大於容量*負載因子會進行2倍擴容,put操作是將key的hashcode值進行一次hash計算,key的equals方法找到鍵值對進行替換返回被舊資料,若沒有找到會插入到連結串列中,HashMap執行緒不安全.當面試官聽到這些以後第一個問題為什麼容量是16,15、14不行嗎?為什麼2倍擴容?為什麼HashMap建議不可變物件用Key?自己當時思考得不夠深入,還沒問到ConcurrentHashMap我就已經心慌了...下面我來聊一聊我對HashMap的看法

HashMap簡述

繼承關係

先看下HashMap的繼承關係:

Java集合——HashMap(jdk1.7)
從圖中可以看到HashMap實現了Map、Serializable、Cloneable介面,繼承了AbstractMap抽象類.那麼既然已經繼承了AbstractMap,而AbstractMap實現了Map介面,這相當於hashmap已經實現了map介面,可為什麼Hashmap還要去實現Map介面,在集合中有很多這樣的情況,網上找了下結構大致有以下答案:
①.新增Map介面宣告是為了Class類的getInterfaces這個方法能夠直接獲取到Map介面
②.mistake是一個錯誤
③.為了java api的文件生成工具而優化,產生更精確的型別的文件

HashMap資料結構

Java集合——HashMap(jdk1.7)
1.7的HashMap採用陣列+單連結串列實現,雖然HashMap定義了hash函式來避免衝突,但還是會出現兩個不同的Key經過計算後桶的位置一樣,HashMap採用了連結串列來解決,可如果位於連結串列中的結點過多,1.7的HashMap通過key值依次查詢效率太低,所以在1.8中HashMap進行了改良,採用陣列+連結串列+紅黑樹來實現,當連結串列長度超過閾值8時,將連結串列轉換為紅黑樹.
再來看看Entry中有哪些屬性,在1.8中Entry改名為Node,屬性不變,1.8改動後面會說,主講1.7


    static class Entry implements Map.Entry {
        /** 賤物件*/
        final K key;
        /** 值物件*/
        V value;
        /** 指向下一個Entry物件*/
        Entry next;
        /** 鍵物件雜湊值*/
        int hash;
    }    
複製程式碼

1.7 HashMap原始碼分析

HashMap關鍵屬性


    /**
     * 預設初始容量16——必須是2的冪
     */
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; 
    
    /**
     * HashMap儲存的鍵值對數量
     */
    transient int size;
    
    /**
     * 預設負載因子0.75
     */
    static final float DEFAULT_LOAD_FACTOR = 0.75f;
    
    /**
     * 擴容閾值,當size大於等於其值,會執行resize操作
     * 一般情況下threshold=capacity*loadFactor
     */
    int threshold;
    
    /**
     * Entry陣列
     */
    transient Entry[] table = (Entry[]) EMPTY_TABLE;
    
    /**
     * 記錄HashMap修改次數,fail-fast機制
     */
    transient int modCount;
    
    /**
     * hashSeed用於計算key的hash值,它與key的hashCode進行按位異或運算
     * hashSeed是一個與例項相關的隨機值,用於解決hash衝突
     * 如果為0則禁用備用雜湊演算法
     */
     transient int hashSeed = 0;
複製程式碼

HashMap構造方法


    /**
     * 指定容量及負載因子構造方法
     */
    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;
        //空方法,讓其子類重寫例如LinkedHashMap
        init();
    }
    
    /**
     * 預設構造方法,採用預設容量16,預設負載因子0.75
     */
    public HashMap() {
        this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
    }
    
    /**
     * 指定容量構造方法,負載因子預設0.75
     */
    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }
複製程式碼

從這3個構造方法中我們可以發現雖然指定了初始化容量大小,但此時的table還是空,是一個空陣列,且擴容閾值為初始容量.在其put操作前,會建立陣列.


    /**
     * 根據已有Map構造新HashMap的構造方法
     * 初始容量:引數map大小除以預設負載因子+1與預設容量的最大值
     * 初始負載因子:預設負載因子0.75
     */
    public HashMap(Map m) {
        this(Math.max((int) (m.size() / DEFAULT_LOAD_FACTOR) + 1,
                      DEFAULT_INITIAL_CAPACITY), DEFAULT_LOAD_FACTOR);
        inflateTable(threshold);
        //把傳入的map裡的所有元素放入當前已構造的HashMap中
        putAllForCreate(m);
    }  
複製程式碼

這個構造方法便是在put操作前呼叫inflateTable方法,inflate意為膨脹,這個方法我們來看下,注意剛也提到了此時的threshold擴容閾值是初始容量


    private void inflateTable(int toSize) {
        //返回不小於number的最小的2的冪數,最大為MAXIMUM_CAPACITY
        int capacity = roundUpToPowerOf2(toSize);
        //設定擴容閾值,值為容量*負載因子與最大容量+1的較小值
        threshold = (int) Math.min(capacity * loadFactor,
MAXIMUM_CAPACITY + 1); //建立陣列 table = new Entry[capacity]; //初始化HashSeed值 initHashSeedAsNeeded(capacity); } /** * 返回不小於number的最小的2的冪數,最大為MAXIMUM_CAPACITY */ private static int roundUpToPowerOf2(int number) { // assert number >= 0 : "number must be non-negative"; /** * 若number不小於最大容量則為最大容量 * 若number小於最大容量大於1,則為不小於number的最小的2的冪數 * 若都不是則為1 */ return number >= MAXIMUM_CAPACITY ? MAXIMUM_CAPACITY : (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1; } 複製程式碼

從這裡我們可以到HashMap建立了一個2的冪數容量的陣列,那為什麼一定要這樣設計?後面我會介紹.

put方法

我往HashMap中新增元素呼叫最多就是這個put方法
public V put(K key, V value)
我們來看下其程式碼實現:


    public V put(K key, V value) {
        //若陣列為空時建立陣列
        if (table == EMPTY_TABLE) {
            inflateTable(threshold);
        }
        //若key為null
        if (key == null)
            return putForNullKey(value);
        //對key進行hash計算,獲取hash值    
        int hash = hash(key);
        //根據剛得到的hash值與陣列長度計算桶位置
        int i = indexFor(hash, table.length);
        //遍歷桶中連結串列
        for (Entry e = table[i]; e != null; e = e.next) {
            Object k;
            //key值與hash值都相同的話進行替換
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                V oldValue = e.value;
                e.value = value;
                //空方法,讓其子類重寫例如LinkedHashMap
                e.recordAccess(this);
                //返回舊值
                return oldValue;
            }
        }
        //記錄修改
        modCount++;
        //連結串列中不存在此鍵,則呼叫addEntry方法向連結串列中新增新結點
        addEntry(hash, key, value, i);
        return null;
    } 
複製程式碼

從上面的原始碼我們可以看到:
①HashMap首先判斷陣列是否為空,若為空呼叫inflateTable進行擴容.
②接著判斷key是否為null,若為null就呼叫putForNullKey方法進行put.所以HashMap允許Key為null
③再將key進行一次雜湊計算,得到的雜湊值和當前陣列長度計算得到陣列中的索引
④然後遍歷該陣列索引下的連結串列,若key的hash和傳入key的hash相同且key的equals放回true,那麼直接覆蓋 value
⑤最後若不存在,那麼在此連結串列中頭插建立新結點

逐步來介紹(第一步就不說了上文已闡述過),第二步最主要就是putForNullKey方法,從中我們可以發現若key為null會先從0位置桶上鍊表遍歷,若找到結點key為null的進行替換,不存在則新增結點.方法內的addEntry後續講


private V putForNullKey(V value) {
        //遍歷0位置桶上的連結串列,若存在結點Entry的key為null替換value,返回舊值
        for (Entry e = table[0]; e != null; e = e.next) {
            if (e.key == null) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }
        modCount++;
        //若不存在,0位置桶上的連結串列中新增新結點
        addEntry(0, null, value, 0);
        return null;
    }
複製程式碼

第三步中先看下HashMap的hash演算法,獲取鍵物件雜湊值並將補充雜湊函式應用於該物件結果雜湊,防止質量差的雜湊函式,注意:空鍵總是對映到雜湊0,因此索引為0,1.8的hash方法已進行過優化,


    final int hash(Object k) {
        // 當h不為0且鍵物件型別為String用此演算法,1.8已刪除
        int h = hashSeed;
        if (0 != h && k instanceof String) {
            return sun.misc.Hashing.stringHash32((String) k);
        }
        h ^= k.hashCode();
        //此函式確保在每個位元位置上僅以恆定倍數不同的hashCode具有有限的碰撞數量(在預設負載因子下約為8)
        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
    }  
複製程式碼

根據所計算的值與陣列長度計算桶位置:


    static int indexFor(int h, int length) {
        return h & (length-1);
    } 
複製程式碼

此方法對陣列的長度取模運算,得到的餘數進行下表訪問,那麼既然是取模運算為什麼不直接h%length,因為其效率很低,所以採用位運算.從中我們可以看出,假設length為16,當我們h為1,17時算出桶的索引都為1這種情況就稱為衝突(k1≠k2,而f(k1)=f(k2)).當有衝突時HashMap採用鏈地址法(把所有的同義詞用單連結串列連線起來的方法)處理衝突
其次假設length分別為16,15,14時,他們的衝突次數:

length = 16 length = 15 length = 14
h h&length-1 結果 h&length-1 結果 h&length-1 結果
0 0000 & 1111 0000 0 0000 & 1110 0000 0000 & 1101 0000
1 0001 & 1111 0001 1 0001 & 1110 0000 0001 & 1101 0001
2 0010 & 1111 0010 2 0010 & 1110 0010 0010 & 1101 0000
3 0011 & 1111 0011 3 0011 & 1110 0010 0011 & 1101 0001
4 0100 & 1111 0100 4 0100 & 1110 0100 0100 & 1101 0100
5 0101 & 1111 0101 5 0101 & 1110 0100 0101 & 1101 0101
6 0110 & 1111 0110 6 0110 & 1110 0110 0110 & 1101 0100
7 0111 & 1111 0111 7 0111 & 1110 0110 0111 & 1101 0101
8 1000 & 1111 1000 8 1000 & 1110 1000 1000 & 1101 1000
9 1001 & 1111 1001 9 1001 & 1110 1000 1001 & 1101 1001
10 1010 & 1111 1010 10 1010 & 1110 1010 1010 & 1101 1000
11 1011 & 1111 1011 11 1011 & 1110 1010 1011 & 1101 1001
12 1100 & 1111 1100 12 1100 & 1110 1100 1100 & 1101 1100
13 1101 & 1111 1101 13 1101 & 1110 1100 1101 & 1101 1101
14 1110 & 1111 1110 14 1110 & 1110 1110 1110 & 1101 1100
15 1111 & 1111 1111 15 1111 & 1110 1110 1111 & 1101 1101
0個衝突 8個衝突 8個衝突
從中我們就可以知道為什麼不是15,14而必須要是2的冪數,length為16時在[0,15]區間內衝突為0,且雨露均沾分佈均勻每個桶都可能會存放資料,而為15,14時不僅有衝突而且有些空間永遠不會存放資料這就導致了資源浪費,並且雜湊就不會出現下標越界得到一個異常.
那麼初始容量為什麼要為16,而不是8,32呢?我認為若是8的話擴容閾值為6,沒放幾個就會擴容;而32的話又不會放那麼多,資源浪費.

第四步中若key的hash和傳入key的hash相同且key的equals放回true,那麼直接覆蓋value.key的hash值是根據其hashcode值進行hash雜湊計算得到的,那麼當我們用可變物件時其hashcode值很容易會變化,那麼就會帶來風險找不到原來的value,所以HashMap建議使用不可變物件作為Key

最後一步addEntry方法建立新結點,程式碼如下


    void addEntry(int hash, K key, V value, int bucketIndex) {
        //當前hashmap中的鍵值對數量超過擴容閾值,進行2倍擴容
        if ((size >= threshold) && (null != table[bucketIndex])) {
            //2倍擴容
            resize(2 * table.length);
            //擴容後,桶的數量增加了,重新對鍵進行雜湊碼的計算
            hash = (null != key) ? hash(key) : 0;
            //根據鍵的新雜湊碼和新的桶數量重新計算桶索引值
            bucketIndex = indexFor(hash, table.length);
        }
        //建立結點
        createEntry(hash, key, value, bucketIndex);
    }
    
    /**
     * 頭插結點
     * 將原本在陣列中存放的連結串列頭置入到新的Entry之後,將新的Entry放入陣列中
     */
    void createEntry(int hash, K key, V value, int bucketIndex) {
        Entry e = table[bucketIndex];
        table[bucketIndex] = new Entry<>(hash, key, value, e);
        size++;
    }
複製程式碼

先不看擴容情況,當不需要擴容時,hashmap採用頭插法插入結點,為什麼要頭插而不是尾插,因為後插入的資料被使用的頻次更高,而單連結串列無法隨機訪問只能從頭開始遍歷查詢,所以採用頭插.突然又想為什麼不採用二維陣列的形式利用線性探查法來處理衝突,陣列末尾插入也是O(1),可陣列其最大缺陷就是在於若不是末尾插入刪除效率很低,其次若新增的資料分佈均勻那麼每個桶上的陣列都需要預留記憶體.
再來看看擴容:


    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];
        //將舊Entry陣列轉移到新Entry陣列中去
        transfer(newTable, initHashSeedAsNeeded(newCapacity));
        table = newTable;
        //重新設定擴容閾值
        threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
    } 
複製程式碼

transfer方法遍歷舊陣列所有Entry,根據新的容量逐個重新計算索引頭插儲存在新陣列中,擴容相當麻煩,所以如果當我們知道需要新增多少資料時最好指定容量初始化.


    /**
     * 將舊Entry陣列轉移到新Entry陣列中去
     */
    void transfer(Entry[] newTable, boolean rehash) {
        //獲取新陣列的長度
        int newCapacity = newTable.length;
        for (Entry e : table) {
            while(null != e) {
                Entry next = e.next;
                if (rehash) {
                    e.hash = null == e.key ? 0 : hash(e.key);
                }
                //重新計算索引
                int i = indexFor(e.hash, newCapacity);
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            }
        }
    }  
複製程式碼

在這裡可能會出現環形連結串列導致死迴圈.先假設容量為4,負載因子預設0.75,擴容閾值3,HashMap當前儲存如下:

Java集合——HashMap(jdk1.7)
單執行緒情況下若再新增一個元素,HashMap會擴容重新佈局可能如下:
Java集合——HashMap(jdk1.7)
那當我們多執行緒操作HashMap呢?
假定有兩個執行緒同時要新增資料到此HashMap,在擴容時 Thread1正準備處理Entry1,執行完Entry<K,V> next = e.next掛起執行Thread2,此時E為Entry1,next為Entry2
Thread2執行完transfer方法

Java集合——HashMap(jdk1.7)
此時Thread1恢復執行,
將Entry1插入到新陣列中去,然後e為Entry2,輪到下次迴圈時next由於Thread2的操作變為了Entry1

Java集合——HashMap(jdk1.7)
因為Thread2執行過整個transfer方法所以Entry2和Entry1在新雜湊表中一定會再次衝突,然後將Entry2頭插連結串列,再次e為Entry1,next為null

Java集合——HashMap(jdk1.7)
由於頭插Entry1插入連結串列,將Entry1指向了Entry2,此時環形連結串列出現了,當我們操作環形連結串列的桶就會gg.也因為HashMap本就執行緒不安全,所以sun不認為這是個問題,若有併發場景就用ConcurrentHashMap

另外這讓我想起一道經典面試題連結串列反轉,自己動手實現了下


    public class Node {

    private Node next;

    private Object item;

    public Node(Object item, Node next) {
        this.item = item;
        this.next = next;
    }

    public static Node get(){
        Node last = new Node(6, null);
        Node fifth = new Node(5,last);
        Node fourth = new Node(4, fifth);
        Node third = new Node(3, fourth);
        Node second = new Node(2, third);
        Node first = new Node(1, second);
        return first;
    }

    public static void outPut(Node node){
        while(node != null){
            System.out.print(node.item);
            node = node.next;
        }
    }

    public static Node reverse(Node node){
        Node newNode = node;
        Node temp = null;
        while (node != null && node.next != null){
            Node next = node.next;
            node.next = temp;
            temp = node;
            newNode = new Node(next.item, node);
            node = next;
        }
        return newNode;
    }

    public static void main(String[] args) {
        Node first = get();//獲取單連結串列頭結點
        outPut(first);//輸出整條連結串列資料
        first = reverse(first);
        outPut(first);
    }
} 
複製程式碼

get方法

知道了put原理,get操作就很好理解了,先看下程式碼:


    /**
     * 返回到指定鍵所對映的值,若不存在返回null
     */
    public V get(Object key) {
        //與put一樣單獨處理
        if (key == null)
            return getForNullKey();
        Entry entry = getEntry(key);
        return null == entry ? null : entry.getValue();
    } 
複製程式碼

與存null key一樣,從0位置上的桶上獲取


    private V getForNullKey() {
        if (size == 0) {
            return null;
        }
        for (Entry e = table[0]; e != null; e = e.next) {
            if (e.key == null)
                return e.value;
        }
        return null;
    } 
複製程式碼

getEntry


    final Entry getEntry(Object key) {
        //size為0,即hashmap為空,返回null
        if (size == 0) {
            return null;
        }
        //對key進行hash計算,獲取hash值
        int hash = (key == null) ? 0 : hash(key);
        //根據hash值與陣列長度獲取桶位置,遍歷對應桶上鍊表
        for (Entry e = table[indexFor(hash, table.length)];
             e != null;
             e = e.next) {
            Object k;
            //key值與hash值都相同的話返回結點
            if (e.hash == hash &&
                ((k = e.key) == key || (key != null && key.equals(k))))
                return e;
        }
        //若不存在返回null
        return null;
    } 
複製程式碼

小結

本篇主要圍繞著java7HashMap原始碼講解其原理做個小結:
①因為其put操作對key為null場景做了單獨處理,所以HashMap允許null作為Key
②因為HashMap在算桶index時根據key的hashcode值進行hash計算獲取hash值與陣列length-1進行與運算,length-1的二進位制位全為1,這樣可以分佈均勻避免衝突,所以HashMap容量要為2的冪數
③因為HashMap的操作會圍繞key的hashcode進行hash計算,而可變物件其hashcode很容易變化,所以HashMap建議用不可變物件作為Key.
④HashMap執行緒不安全擴容方法可能會導致環形連結串列死迴圈,所以若需要多執行緒場景下操作可以使用ConcurrentHashMap
⑤.當發生衝突時,HashMap採用鏈地址法(拉鍊法)處理衝突,然後根據key的hash以及equals方法具體獲取key所對應的Entry
⑥.為什麼HashMap初始容量定為16,我認為若是8的話擴容閾值為6,沒放幾個就會擴容;而32的話又不會放那麼多,資源浪費

參考

https://coolshell.cn/articles/9606.html
http://www.cnblogs.com/chenssy/p/3521565.html

相關文章