深入Java基礎(四)--哈希表(1)HashMap應用及源碼詳解

繼續深入Java基礎系列。今天是研究下哈希表,畢竟我們很多應用層的查找存儲框架都是哈希作為它的根數據結構進行封裝的嘛。

本系列:

(1)深入Java基礎(一)——基本數據類型及其包裝類

(2)深入Java基礎(二)——字符串家族

(3)深入Java基礎(三)--集合(1)集合父類以及父接口源碼及理解

(4)深入Java基礎(三)--集合(2)ArrayList和其繼承樹源碼解析以及其注意事項


文章結構:(1)哈希概述及HashMap應用;(2)HashMap源碼分析;(3)再次總結關鍵點


一、哈希概述及HashMap應用:

概述:詳細介紹

根據關鍵碼值(Key value)而直接進行訪問的數據結構。也就是說,它通過把關鍵碼值映射到表中一個位置來訪問記錄,以加快查找的速度。

對比:數組的特點是:尋址容易,插入和刪除困難;而鏈表的特點是:尋址困難,插入和刪除容易。哈希則是一種尋址容易,插入刪除也容易的數據結構。

實際應用:

1.Hash主要用于信息安全領域中加密算法,它把一些不同長度的信息轉化成雜亂的128位的編碼,這些編碼值叫做Hash值. 也可以說,Hash就是找到一種數據內容和數據存放地址之間的映射關系
2.查找:哈希表,又稱為散列,是一種更加快捷的查找技術。我們之前的查找,都是這樣一種思路:集合中拿出來一個元素,看看是否與我們要找的相等,如果不等,縮小范圍,繼續查找。而哈希表是完全另外一種思路:當我知道key值以后,我就可以直接計算出這個元素在集合中的位置,根本不需要一次又一次的查找!

HashMap概述:

Hash表 是一種邏輯數據結構,HashMap是Java中的一種數據類型(結構類型),它通過代碼實現了Hash表 這種數據結構,并在此結構上定義了一系列操作。

HashMap關鍵點羅列:

1.基于數組來實現哈希表的,數組就好比內存儲空間,數組的index就好比內存的地址;

2.每個記錄就是一個Entry 《K, V>對象,數組中存儲的就是這些對象;

3.HashMap的哈希函數 = 計算出hashCode + 計算出數組的index;

4.HashMap解決沖突:使用鏈地址法,每個Entry對象都有一個引用next來指向鏈表的下一個Entry;(也就是鏈地址法)

但是!JDK1.8升級了!!

JDK1.8之前:使用單向鏈表來存儲相同索引值的元素。在最壞的情況下,這種方式會將HashMap的get方法的性能從O(1)降低到O(n)。

在JDK1.8:為了解決在頻繁沖突時hashmap性能降低的問題,使用平衡樹來替代鏈表存儲沖突的元素。這意味著我們可以將最壞情況下的性能從O(n)提高到O(logn)。

在Java 8中使用常量TREEIFY_THRESHOLD來控制是否切換到平衡樹來存儲。目前,這個常量值是8,這意味著當有超過8個元素的索引一樣時,HashMap會使用樹來存儲它們。
這一動態的特性使得HashMap一開始使用鏈表,并在沖突的元素數量超過指定值時用平衡二叉樹替換鏈表。不過這一特性在所有基于hash table的類中并沒有,例如Hashtable和WeakHashMap。目前,只有ConcurrentHashMap,LinkedHashMap和HashMap會在頻繁沖突的情況下使用平衡樹。

5.裝填因子:默認為0.75;

(加載因子過高雖然減少了空間開銷,但同時也增加了查詢成本(在大多數 HashMap 類的操作中,包括 get 和 put 操作,都反映了這一點)。在設置初始容量時應該考慮到映射中所需的條目數及其加載因子,以便最大限度地減少 rehash 操作次數。如果初始容量大于最大條目數除以加載因子,則不會發生 rehash 操作。)

6.繼承于AbstractMap,實現了Map、Cloneable、java.io.Serializable接口。

這里寫圖片描述

7.不是線程安全的。它的key、value都可以為null。此外,HashMap中的映射不是有序的。

8.影響性能的因素:“初始容量” 和 “加載因子”

容量 是哈希表中桶的數量,初始容量 只是哈希表在創建時的容量。加載因子 是哈希表在其容量自動增加之前可以達到多滿的一種尺度。當哈希表中的條目數超出了加載因子與當前容量的乘積時,則要對該哈希表進行 rehash 操作(即重建內部數據結構),從而哈希表將具有大約兩倍的桶數。

HashMap基本操作:

public class TestHashMap {
    public static void main(String[] args) {

        HashMap<String, String> hashMap = new HashMap<String, String>();
        hashMap.put("fu", "輔助");
        hashMap.put("ad", "輸出");
        hashMap.put("sd", "上單");

        System.out.println(hashMap);//toString重寫了,所以可直接打出
        System.out.println("fu:" + hashMap.get("fu"));//拿出key為fu的鍵值
        System.out.println(hashMap.containsKey("fu"));//判斷是否存在fu的鍵
        System.out.println(hashMap.keySet());//返回一個key集合。
        System.out.println("判空:"+hashMap.isEmpty());//判空

        hashMap.remove("fu");
        System.out.println(hashMap.containsKey("fu"));//判斷是否存在fu的鍵

        Iterator it = hashMap.keySet().iterator();//遍歷輸出值。前提先拿到一個裝載了key的Set集合
        while(it.hasNext()) {
            String key = (String)it.next();
            System.out.println("key:" + key);
            System.out.println("value:" + hashMap.get(key));
        }

    }
}

二、HashTable重要的部分源碼分析:(基于jdk1.8)

public class HashMap<K,V> extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable {

    private static final long serialVersionUID = 362498820763181265L;
      // 默認的初始容量是16,必須是2的冪。 
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
    // 最大容量(必須是2的冪且小于2的30次方,傳入容量過大將被這個值替換) 
    static final int MAXIMUM_CAPACITY = 1 << 30;
    // 默認加載因子 
    static final float DEFAULT_LOAD_FACTOR = 0.75f;
    //!!Java 8 HashMap的分離鏈表。 在沒有降低哈希沖突的度的情況下,使用紅黑書代替鏈表。
    /*
    使用鏈表還是樹,與一個哈希桶中的元素數目有關。下面兩個參數中展示了Java 8的HashMap在使用樹和使用鏈表之間切換的閾值。當沖突的元素數增加到8時,鏈表變為樹;當減少至6時,樹切換為鏈表。中間有2個緩沖值的原因是避免頻繁的切換浪費計算機資源。
    */
    static final int TREEIFY_THRESHOLD = 8;
    static final int UNTREEIFY_THRESHOLD = 6;

//HashMap的一個內部類,實現了Map接口的內部接口Entry。Map接口的一系列方法
    static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;//哈希值
        final K key;
        V value;
        Node<K,V> next;//對下一個節點的引用(看到鏈表的內容,結合定義的Entry數組,哈希表的鏈地址法!!!實現)

        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; }//獲取Key
        public final V getValue()      { return value; }//獲取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數組,長度是2的冪。    
    // HashMap采用鏈表法解決沖突,每一個Entry本質上是一個單向鏈表    
    transient Node<K,V>[] table;
    //緩存我們裝載的Node,每個結點。這也是跟keySet一樣,可用于遍歷HashMap。遍歷使用這個比keySet是快多的喔,一會介紹并給例子。
    transient Set<Map.Entry<K,V>> entrySet;
    // HashMap的底層數組中已用槽的數量    
    transient int size;  
    // HashMap被改變的次數   
    transient int modCount;
    // HashMap的閾值,用于判斷是否需要調整HashMap的容量(threshold = 容量*加載因子)    
    int threshold;
    // 加載因子實際大小 
    final float loadFactor;

    /*
    構造器
    */
    public HashMap(int initialCapacity, float loadFactor) {
    //初始容量不能<0
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
     //初始容量不能 > 最大容量值,HashMap的最大容量值為2^30
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
     //負載因子不能 < 0
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);
        this.loadFactor = loadFactor;
        this.threshold = tableSizeFor(initialCapacity);
    }
    //當在實例化HashMap實例時,如果給定了initialCapacity,由于HashMap的capacity都是2的冪,因此這個方法用于找到大于等于initialCapacity的最小的2的冪(initialCapacity如果就是2的冪,則返回的還是這個數)。 
     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;
    }
    //本質還是上面的構造器,只不過不選擇加載因子而已
    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }
    //這里更是兩個都不選,都選取默認的大小。
    public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; 
    }     
    //這個大概了解就是,可以用Map來構造HashMap咯
    public HashMap(Map<? extends K, ? extends V> m) {
        this.loadFactor = DEFAULT_LOAD_FACTOR;
        putMapEntries(m, false);
    }
    //那堆獲取size,判空方法就不列了。
    // 獲取key對應的value    
    public V get(Object key) {
        Node<K,V> e;
        // 獲取key的hash值 
        return (e = getNode(hash(key), key)) == null ? null : e.value;
    }
    final Node<K,V> getNode(int hash, Object key) {
        Node<K,V>[] tab;
        Node<K,V> first, e; 
         int n; 
         K k;
         // 在“該hash值對應的鏈表”上查找“鍵值等于key”的元素。也就是取出這個鏈表上,索引對應的值。
         //這里一邊判斷一邊賦值了,1.把要查的那一行table數組給到臨時數組tab(那一行的table數組是通過hash計算得出的);2.把那一行的數組的第一個給到暫存結點first。(所謂第一個其實是單鏈表中頭部,鏈表的頭插法)    
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (first = tab[(n - 1) & hash]) != null) {
             // 直接命中
            if (first.hash == hash && // 每次都是校驗第一個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;
    }
    //紅黑樹查找法
    final TreeNode<K,V> getTreeNode(int h, Object k) {
            return ((parent != null) ? root() : this).find(h, k, null);
    }
     //根節點
    final TreeNode<K,V> root() {
            for (TreeNode<K,V> r = this, p;;) {
                if ((p = r.parent) == null)
                    return r;
                r = p;
            }
    }
    /** 
     * 從根節點p開始查找指定hash值和關鍵字key的結點 
     * 當第一次使用比較器比較關鍵字時,參數kc儲存了關鍵字key的 比較器類別 
     * 非遞歸式的樹查詢寫法。。。有點復雜。
    */  
    final TreeNode<K,V> find(int h, Object k, Class<?> kc) {
            TreeNode<K,V> p = this;//一開始是根節點,然后遍歷下去,表示當前結點
            do {
                int ph, dir; K pk;
                TreeNode<K,V> pl = p.left, pr = p.right, q;
                if ((ph = p.hash) > h) //如果給定哈希值小于當前節點的哈希值,進入左節點  
                    p = pl;
                else if (ph < h)//如果大于當前節點,進入右結點  
                    p = pr;
                else if ((pk = p.key) == k || (k != null && k.equals(pk))) //如果哈希值相等,且關鍵字相等,則返回當前節點,終止查找。  
                    return p;
                else if (pl == null) //如果左節點為空,則進入右結點  
                    p = pr;
                else if (pr == null)//如果右結點為空,則進入左節點  
                    p = pl;
                else if ((kc != null ||
                          (kc = comparableClassFor(k)) != null) &&
                         (dir = compareComparables(kc, k, pk)) != 0) //如果不按哈希值排序,而是按照比較器排序,則通過比較器返回值決定進入左右結點  
                    p = (dir < 0) ? pl : pr;
                else if ((q = pr.find(h, k, kc)) != null)//如果在右結點中找到該關鍵字,直接返回
                    return q;
                else
                    p = pl;  //進入左節點  
            } while (p != null);
            return null;
    }
    //同理推出是否包含某key的方法
    public boolean containsKey(Object key) {
        return getNode(hash(key), key) != null;
    }
    /*
    插入方法!!為了好理解,我們先去看下文講的jdk1.7的put吧,這個jdk1.8的紅黑樹、鏈式的轉換太復雜了,一會回來再看。
    */
    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;
        //判斷table是否為空
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;//創建一個新的table數組,用resize確定大小,并且獲取該數組的長度
             //根據鍵值key計算hash值得到插入的數組索引i,如果table[i]==null,直接新建節點添加
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {//如果對應的節點存在
            Node<K,V> e; K k;
            //判斷table[i]的首個元素是否和key一樣,如果相同直接覆蓋value
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
           //判斷table[i] 是否為treeNode,即table[i] 是否是紅黑樹,如果是紅黑樹,則直接在樹中插入鍵值對
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
          // 該鏈為鏈表,就用鏈地址法
            else {
            //遍歷table[i],判斷鏈表長度是否大于TREEIFY_THRESHOLD(默認值為8),大于8的話把鏈表轉換為紅黑樹,在紅黑樹中執行插入操作,否則進行鏈表的插入操作;遍歷過程中若發現key已經存在直接覆蓋value即可;
                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;
    }
    /*
        樹形化總過程:遍歷桶中的元素,創建相同個數的樹形節點,復制內容,建立起聯系
然后讓桶第一個元素指向新建的樹頭結點,替換桶的鏈表內容為樹形內容
但是我們發現,之前的操作并沒有設置紅黑樹的顏色值,現在得到的只能算是個二叉樹。在 最后調用樹形節點 hd.treeify(tab) 方法進行塑造紅黑樹。紅黑樹的構造就看我github不久后的復習筆記吧。
    */
    //如果沖突達到8位,就轉樹形結構,這就是轉型的代碼:
    //將桶內所有的 鏈表節點 替換成 紅黑樹節點
     final void treeifyBin(Node<K,V>[] tab, int hash) {
        int n, index; Node<K,V> e;
        //如果當前哈希表為空,或者哈希表中元素的個數小于 進行樹形化的閾值(默認為 64),就去新建/擴容
        if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
            resize();
            //如果哈希表中的元素個數超過了 樹形化閾值,進行樹形化
             // e 是哈希表中指定位置桶里的鏈表節點,從第一個開始
        else if ((e = tab[index = (n - 1) & hash]) != null) {
            TreeNode<K,V> hd = null, tl = null;//紅黑樹的頭、尾節點
            do {
             //新建一個樹形節點,內容和當前鏈表節點 e 一致
                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);
        }
    }
    /*
    擴容機制,也是確定容量的方法
    擴容(resize)就是重新計算容量,向HashMap對象里不停的添加元素,而HashMap對象內部的數組無法裝載更多的元素時,對象就需要擴大數組的長度,以便能裝入更多的元素。當然Java里的數組是無法自動擴容的,方法是使用一個新的數組代替已有的容量小的數組,就像我們用一個小桶裝水,如果想裝更多的水,就得換大水桶。
    */
    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) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
             // 沒超過最大值,就擴充為原來的2倍
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; // double threshold
        }
        else if (oldThr > 0) // initial capacity was placed in threshold
            newCap = oldThr;
        else {               // zero initial threshold signifies using defaults
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        // 計算新的resize上限
        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) {
         // 把每個bucket都移動到新的buckets中
            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;
                    else if (e instanceof TreeNode)
                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                    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;
                             // 原索引
                            if ((e.hash & oldCap) == 0) {
                                if (loTail == null)
                                    loHead = e;
                                else
                                    loTail.next = e;
                                loTail = e;
                            }
                            else { // 原索引+oldCap
                                if (hiTail == null)
                                    hiHead = e;
                                else
                                    hiTail.next = e;
                                hiTail = e;
                            }
                        } while ((e = next) != null);
                        if (loTail != null) {// 原索引放到bucket里
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        if (hiTail != null) { // 原索引+oldCap放到bucket里
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
    }
    /*
        刪除:
        1.計算哈希值找到對應的桶
        2.在桶中尋找,當然查之前要判斷桶類型是紅黑樹還是鏈表
        3.找到只會就刪除嘛,當然也要對應桶類型
    */
    public V remove(Object key) {
        Node<K,V> e;
        //查找到則返回該結點,沒有則返回null
        return (e = removeNode(hash(key), key, null, false, true)) == null ?
            null : e.value;
    }
    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 = e;
                    } while ((e = e.next) != null);
                }
            }
            //終于找到了??那就去刪除啊
            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;
            }
        }
        return null;
    }
    /*
        清空:遍歷所有桶的所有元素并至null
    */
    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;
        }
    }

}

jdk1.8的擴容

使用的是2次冪的擴展(指長度擴為原來2倍),所以,元素的位置要么是在原位置,要么是在原位置再移動2次冪的位置。n為table的長度,圖(a)表示擴容前的key1和key2兩種key確定索引位置的示例,圖(b)表示擴容后key1和key2兩種key確定索引位置的示例,其中hash1是key1對應的哈希與高位運算結果。

這里寫圖片描述

元素在重新計算hash之后,因為n變為2倍,那么n-1的mask范圍在高位多1bit(紅色),因此新的index就會發生這樣的變化:

這里寫圖片描述

在擴充HashMap的時候,不需要像JDK1.7的實現那樣重新計算hash,只需要看看原來的hash值新增的那個bit是1還是0就好了,是0的話索引沒變,是1的話索引變成“原索引+oldCap”.

既省去了重新計算hash值的時間,而且同時,由于新增的1bit是0還是1可以認為是隨機的,因此resize的過程,均勻的把之前的沖突的節點分散到新的bucket了。這一塊就是JDK1.8新增的優化點。有一點注意區別,JDK1.7中rehash的時候,舊鏈表遷移新鏈表的時候,如果在新表的數組索引位置相同,則鏈表元素會倒置,但是從上圖可以看出,JDK1.8不會倒置。

對比jdk1.7:插入和擴容方法

說明:jdk1.7查詢是的計算哈希,遍歷單鏈表。因為它們怎么改結構,本質還是個哈希。而jdk1.8在沖突小于8的時候是像1.7的查詢方式,但大于8的時候,就會改成紅黑樹,使用二叉查找樹的結構性查詢方式了,并且jdk1.8的插入涉及的存儲結構的轉換,從鏈地址法沖突處理到8位后就改成紅黑樹存儲。

來看下1.7的插入和擴容機制源碼,簡單得不行。

/*
    可以看到很簡單的步驟:1.計算哈希,找到對應的Entry,;2.插入單鏈表到頭部
*/
public V put(K key, V value) {
        if (table == EMPTY_TABLE) {
            inflateTable(threshold);
        }
        if (key == null)
            return putForNullKey(value);//注意,HashMap可以存放null的key!!當然只能存一個!下面我們來看看,怎么放null的
        int hash = hash(key);
        int i = indexFor(hash, table.length);//計算哈希
        for (Entry<K,V> e = table[i]; e != null; e = e.next) {//計算的哈希值鎖定對應的Entry并且遍歷單鏈表
            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;
    }
private V putForNullKey(V value) {
//nullkey的話,就鎖定第一個Entry了,遍歷單鏈表找到插入位置先
//如果找到了e.key==null,就保存null值對應的原值oldValue,然后覆蓋原值,并返回oldValue
//如果在table[0]Entry鏈表中沒有找到就調用addEntry方法添加一個key為null的Entry
        for (Entry<K,V> 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++;
        addEntry(0, null, value, 0);//添加
        return null;
}
void addEntry(int hash, K key, V value, int bucketIndex) {
//判斷是否要擴容.hashmap每次擴容的大小為2倍原容量,默認容量為16,hashmap的capacity會一直是2的整數冪。
        if ((size >= threshold) && (null != table[bucketIndex])) {
            resize(2 * table.length);
            hash = (null != key) ? hash(key) : 0;
            bucketIndex = indexFor(hash, table.length);
        }
        createEntry(hash, key, value, bucketIndex);
    }
//擴容
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, initHashSeedAsNeeded(newCapacity));//將數據轉移到新的Entry數組里,并判斷是否需要更新哈希值
        table = newTable;//HashMap的table屬性引用新的Entry數組
        threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);//修改閾值
    }
void transfer(Entry[] newTable, boolean rehash) {
        int newCapacity = newTable.length;
        //根據put中鎖定的Entry去遍歷
        for (Entry<K,V> e : table) {
            while(null != e) {
                Entry<K,V> next = e.next;
                if (rehash) {//要不要重新分配hash
                    e.hash = null == e.key ? 0 : hash(e.key);
                }
                int i = indexFor(e.hash, newCapacity);//重新計算每個元素在數組中的位置,找到存放在新數組中的位置,再放置
                e.next = newTable[i];//標記
                newTable[i] = e; //將元素放在數組上
                e = next;//訪問下一個Entry鏈上的元素
            }
        }
    }

找了一張好圖,說明JDK1.7的。原文

這里寫圖片描述
假設原來大小只是2,那么擴容根據2的次冪,就是到4了。然后重新計算分配hash,元素的位置要么是在原位置,要么是在原位置再移動2次冪的位置。所有的Node重新rehash的過程。

三、再次總結關鍵點:

(1)實現了Map、Cloneable、java.io.Serializable接口。意味著支持全部map操作,支持被克隆,支持序列化,能通過序列化去傳輸。

(2)HashMap的函數則是非同步的,它不是線程安全的。

若要在多線程中使用HashMap,需要我們額外的進行同步處理。 對HashMap的同步處理可以使用Collections類提供的synchronizedMap靜態方法,或者直接使用JDK 5.0之后提供的java.util.concurrent包里的ConcurrentHashMap類。

(3)HashMap的key、value都可以為null。

HashMap的key、value都可以為null。 當HashMap的key為null時,HashMap會將其固定的插入table[0]位置(即HashMap散列表的第一個位置);而且table[0]處只會容納一個key為null的值,當有多個key為null的值插入的時候,table[0]會保留最后插入的value。

(4)HashMap只支持Iterator(迭代器)遍歷。是“從前向后”的遍歷數組;再對數組具體某一項對應的鏈表,從表頭開始進行遍歷。

方式是:1.通過entrySet()獲取“Map.Entry集合”。2. 通過iterator()獲取“Map.Entry集合”的迭代器,再進行遍歷。

public class TestHashMap {
    public static void main(String[] args) {

        HashMap<String, String> hashMap = new HashMap<String, String>();
        for (int i = 0; i < 100000; i++) {
            hashMap.put(String.valueOf(i), "fuzhu");
        }
        
        Iterator it = hashMap.keySet().iterator();//遍歷輸出值。前提先拿到一個裝載了key的Set集合
        long start = System.nanoTime();
        while(it.hasNext()) {
            String key = (String)it.next();
            //System.out.println("key:" + key);
            //System.out.println("value:" + hashMap.get(key));
        }
        long time = System.nanoTime() - start;

        long start2 = System.nanoTime();
        Iterator it2 = hashMap.entrySet().iterator();
        while(it2.hasNext()) {
            Map.Entry key = (java.util.Map.Entry)it2.next();
            //System.out.println("key:" + key);
          //  System.out.println("value:" + hashMap.get(key));
        }
        long time2 = System.nanoTime() - start2;
        System.out.println("測試耗時1!!!!"+time);
        System.out.println("---------------------------------");
        System.out.println("測試耗時2!!!!"+time2);
    }
}
/*
    最終結果:
    如果注釋部分不執行,keySet方式是比entrySet快得多(對應下圖1),但是注釋部分執行時,也就是真正的遍歷取值時,entrySet比keySet快得多(對應下圖2)
*/
//原因:keySet方式拿到的是裝載String的set(需要再次轉換拿到key),而entrySet方式拿到的是裝載Map.Entry類型的set,無須再次轉換,直接getvalue

圖1

這里寫圖片描述

圖2

這里寫圖片描述

(5)HashMap的工作原理:

通過hash的方法,通過put和get存儲和獲取對象。存儲對象時,我們將K/V傳給put方法時,它調用hashCode計算hash從而得到bucket桶位置,然后根據TREEIFY_THRESHOLD判斷桶裝載的類型,紅黑樹還是鏈表,再進一步存儲,HashMap會根據當前bucket的占用情況自動調整容量(超過Load Facotr則resize為原來的2倍)。獲取對象時,我們將K傳給get,它調用hashCode計算hash從而得到bucket位置,然后根據TREEIFY_THRESHOLD判斷桶裝載的類型,并進一步調用equals()方法確定鍵值對。

在Java 8中,如果一個bucket桶中碰撞沖突的元素超過某個限制(默認是8),則使用紅黑樹來替換鏈表,從而提高速度。

(6)equals()和hashCode()的都有什么作用?

通過對key的hashCode()進行hashing,并計算下標( n-1 & hash),從而獲得buckets的位置。如果產生碰撞,則利用key.equals()方法去鏈表或樹中去查找對應的節點

因為hashcode相同,所以它們的bucket位置相同,‘碰撞沖突’會發生。因為HashMap使用鏈表或紅黑樹存儲對象,這個Entry(包含有鍵值對的Map.Entry對象)會存儲在鏈表或紅黑樹中。

找到bucket桶位置之后,會調用keys.equals()方法去找到鏈表中正確的節點,最終找到要找的值對象。因此,設計HashMap的key類型時,如果使用不可變的、聲明作final的對象,并且采用合適的equals()和hashCode()方法的話,將會減少碰撞的發生,提高效率。

(7)HashMap添加元素時,是使用自定義的哈希算法。HashMap不支持contains(Object value)方法,沒有重寫toString()方法。

(8)HashMap的大小超過了負載因子(load factor)定義的容量,會怎樣?

超過了負載因子(默認0.75),則會重新resize一個原來長度兩倍的HashMap,并且重新調用hash方法。默認的負載因子大小為0.75,也就是說,當一個map填滿了75%的bucket時候,和其它集合類(如ArrayList等)一樣,將會創建原來HashMap大小的兩倍的bucket數組,來重新調整map的大小,并將原來的對象放入新的bucket數組中。這個過程叫作rehashing,jdk1.7會重新計算哈希值,但是jdk1.8很少重新計算,只需要看看原來的hash值新增的那個bit是1還是0就好了,是0的話索引沒變,是1的話索引變成“原索引+oldCap”。

(9)重新調整HashMap大小出現的線程問題:

當重新調整HashMap大小的時候,確實存在條件競爭,因為如果兩個線程都發現HashMap需要重新調整大小了,它們會同時試著調整大小。在調整大小的過程中,存儲在鏈表中的元素的次序會反過來,因為移動到新的bucket位置的時候,HashMap并不會將元素放在鏈表的尾部,而是放在頭部,這是為了避免尾部遍歷(tail traversing)。如果條件競爭發生了,那么就死循環了。因此在并發環境下,我們使用CurrentHashMap來替代HashMap

(10)為什么String, Interger這樣的wrapper類適合作為鍵?

因為String是不可變的,也是final的,而且已經重寫了equals()和hashCode()方法了。其他的wrapper類也有這個特點。不可變性是必要的,因為為了要計算hashCode(),就要防止鍵值改變,如果鍵值在放入時和獲取時返回不同的hashcode的話,那么就不能從HashMap中找到你想要的對象。不可變性還有其他的優點如線程安全。如果你可以僅僅通過將某個field聲明成final就能保證hashCode是不變的,那么請這么做吧。因為獲取對象的時候要用到equals()和hashCode()方法,那么鍵對象正確的重寫這兩個方法是非常重要的。如果兩個不相等的對象返回不同的hashcode的話,那么碰撞的幾率就會小些,這樣就能提高HashMap的性能

參考:此博主此文章

(11)使用的取舍:

1. 若在單線程中,我們往往會選擇HashMap;而在多線程中,則會選擇Hashtable。

2.若不能插入null元素,則選擇Hashtable;否則,可以選擇HashMap。

3.在多線程中,我們可以自己對HashMap進行同步,也可以選擇ConcurrentHashMap。當HashMap和Hashtable都不能滿足自己的需求時,還可以考慮新定義一個類,繼承或重新實現散列表;當然,一般情況下是不需要的了。


好了,深入Java基礎(四)--哈希表(1)HashMap應用及源碼詳解講完了。本博客系列是這個系列的第五篇,閱讀這些源碼收獲很大,當然過程也是很有點苦逼的,比如這篇我之前不小心沒保存,前前后后4天才寫完,當然也不后悔,這是積累的必經一步,我會繼續出這個系列文章,分享經驗給大家。歡迎在下面指出錯誤,共同學習!!你的點贊是對我最好的支持!!

更多內容,可以訪問JackFrost的博客

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容