深入理解HashMap(一)

DK_BurNIng發表於2018-01-23

HashMap主要用來做什麼?為什麼要這麼做?

hashmap我們都用過很多次了,主要目的就是為了加快我們的查詢速度。我們學過資料結構的都知道,陣列的查詢和修改速度很快,但是增加一個元素或者刪除一個元素就很慢,但是連結串列就反過來,連結串列是增加和刪除一個元素很快,查詢和修改就很慢。 通常來說,我們為了提高查詢的速度,那麼在插入元素的時候就要定義各種規則,就是增加複雜度了。 有不理解的可以 查詢相關演算法書裡 查詢這一章。而我們的hashmap正是將陣列和連結串列結合起來增加查詢速度的一種資料結構,對應的,我們 就要好好理解hashmap的put操作。

#圖解hashmap的資料結構

前面我們說過hashmap是陣列和連結串列結合起來的一種結構。

深入理解HashMap(一)

那麼看這張圖,陣列的每一個元素實際上就是一個連結串列。我們都知道hashmap在使用的時候是key-value的鍵值對查詢。 那麼在最理想的時候 應該是陣列的每一個元素,也就是對應的連結串列只有一個節點。這樣的效率就是最快的。 因為這樣幾乎就相當於在陣列裡查詢一個元素了。

但是如果臉不好,或者hash演算法寫的一般。就會出現 這個陣列其他元素都是空,然後我們插入的資料全部在一個位置上對應的連結串列裡:

深入理解HashMap(一)

這樣查詢起來就很慢了。前者o(1) 後者o(n)

看圖也可以瞭解到,我們的hashmap是允許key為null的,但是最多也只能有一個元素的key為null,雖然允許key為null, 但是我們並不鼓勵這麼做。

理解負載因子

   /**
     * The default initial capacity - MUST be a power of two.
     */
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

    /**
     * The maximum capacity, used if a higher value is implicitly specified
     * by either of the constructors with arguments.
     * MUST be a power of two <= 1<<30.
     */
    static final int MAXIMUM_CAPACITY = 1 << 30;

    /**
     * The load factor used when none specified in constructor.
     */
    static final float DEFAULT_LOAD_FACTOR = 0.75f;
複製程式碼

這是一段我電腦上jdk8的hashmap的原始碼,注意這裡我們可以知道,預設我們構造一個hashmap的時候預設大小是16個元素的陣列。

那麼這個DEFAULT_LOAD_FACTOR是幹啥的呢?

我們都知道如果我們的hashmap的陣列大小如果只有16個的話,當我們的資料也剛好是16的時候,最好的情況就是 一個元素 對應的連結串列只有2個結點。如果要插入32個資料的話,最好的結果 一個連結串列有2個節點。如果要插入128個資料的話,一個連結串列 就有8個節點了。查詢起來速度肯定會降低。 為了提高我們的查詢速度,我們顯然應該擴充陣列的大小, 那麼何時擴充陣列大小?就是當元素個數 大於 陣列長度*負載因子 的時候,我們就要擴充陣列大小為原來的一倍了。 比方說: 1.初始預設陣列長度為16 2.當插入的元素已經到了12個以上的時候,我們就要擴充陣列長度到32了。

為啥這個負載因子的值是0.75,我也不知道。。。。反正你知道有這麼個東西即可。0.75應該是最好的演算法把。

擴充陣列長度的意義和目的?

前面我們說到,擴充陣列長度以後可以增加查詢資料的速度。可能有人理解的不夠透徹,這裡我們舉一個簡單的例子幫助大家理解。

  1. 假設我們的陣列長度只有2. 並且要插入5,7,9 這3個元素。 hash演算法我們選擇 元素值和陣列長度取模。

  2. 那麼顯然, 579和2 取模以後,值都為1,那麼 5,7,9 這3個元素 都會插入在這個長度為2的陣列的位置1上。

  3. 那麼我們要查到579這3個元素,顯然就比較慢了,因為都要去a[1]這個位置對應的連結串列上去找。所以我們開始增加陣列長度

  4. 擴充陣列長度為4. 那麼579對4 取模以後,分別的值就為 1,3,1, 顯然 5和9就被放到了a[1]這個位置上。7被放到了 a[3] 這個位置上。

  5. 再擴充一次長度,那麼陣列長度就是8,579對8取模以後,分別對應的值就是5,7,1 顯然的5,7,9這3個元素對應的位置 就 是 a[5],a[7],a[1],此時我們的查詢效率和只有長度為2時候的陣列對比 效率明顯提高。

上述的這過程,我們稱之為rehash

理解jdk7 中的hashmap的原始碼

前文說過,對於hashmap來說,put操作是最重要的。我們就來看看jdk7種的put操作

/**
     * Associates the specified value with the specified key in this map.
     * If the map previously contained a mapping for the key, the old
     * value is replaced.
     *
     * @param key key with which the specified value is to be associated
     * @param value value to be associated with the specified key
     * @return the previous value associated with <tt>key</tt>, or
     *         <tt>null</tt> if there was no mapping for <tt>key</tt>.
     *         (A <tt>null</tt> return can also indicate that the map
     *         previously associated <tt>null</tt> with <tt>key</tt>.)
     */
    public V put(K key, V value) {
        //如果陣列為空 先構造一個陣列
        if (table == EMPTY_TABLE) {
            inflateTable(threshold);
        }
        //如果key為null的情況要特殊處理
        if (key == null)
            return putForNullKey(value);
        //通過傳進來的key的值 我們計算出一個hash的值    
        int hash = hash(key);
        //用hash的值和陣列長度一起計算出 這個key-value應該放入陣列的位置,也就是陣列中的索引值
        int i = indexFor(hash, table.length);
        //看看陣列這個索引位置下的連結串列有沒有key和傳進來的key是相等的,如果有那麼就替換掉,並且把老的值返回
        //發生這種情況時,因為return了 所以函式到這裡就結束掉了
        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;
    }
    
    
     /**
     * Returns index for hash code h.
     */
    static int indexFor(int h, int length) {
        // assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2";
        //h是計算出來的hash值,length是陣列長度 實際上這裡就是取模操作。
        //和我們前文中的例子是一樣的,就等於 h%length.這種寫法效率更高而已
        return h & (length-1);
    }
    
    
    void addEntry(int hash, K key, V value, int bucketIndex) {
        //這裡也好理解哈,如果發生了要擴充陣列長度的情況,那麼hash值要重新計算
        //重新計算的hash值 也要再利用一次重新計算出再陣列中的索引位置
        //threshold其實就是前文我們提到的 陣列長度*負載因子
        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);
    }

    //這裡我們重點看一下這個resize的操作
     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];
        //然後把老的陣列的值放到新的陣列裡面
        transfer(newTable, initHashSeedAsNeeded(newCapacity));
        //然後把新的陣列的索引賦值
        table = newTable;
        //最後重新計算這個閥值
        threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
    }
    
    //接著看我們的transfer函式 實際上jdk7和8 關於hashmap最大的不同就在這裡了
      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);
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            }
        }
    }
    
    //這個也比較簡單,實際上插入元素的時候,我們都是把新來的元素 放入連結串列的頭部,然後讓新來的元素的next指標指向
    //來之前的連結串列的頭部元素,所以jdk7的插入元素是在連結串列頭部插入。
    //在這個地方我們也可以再想明白一個問題,在transfer函式中,我們重新計算索引的時候,先去老的連結串列裡從連結串列頭部
    //取一個元素出來放入到新陣列的索引裡,取完第一個再取第二個
    //那麼後面的元素也是後計算出索引放入到新的陣列對應的連結串列裡。既然是後放入,那麼肯定後放入的會在連結串列的頭部了
    //這就代表一個結果:因為我們每次插入元素是在連結串列頭部插,所以如果新的陣列構造出來以後,我們的索引計算出來
    //仍舊相等的話,這2個元素仍舊會放到一個索引對應的連結串列中,只不過之前在頭部的,現在去了尾部。
    //也就是說在transfer的過程中,連結串列的順序被倒置
     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++;
    }


複製程式碼

jdk8與jdk7的不同

主要有兩點。

1.hash衝突,也就是如果計算出來hash值相同的時候,我們不是放到一個連結串列裡面嗎?jdk7是在頭部插入新的元素, jdk8是在尾部插入新來的元素。

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;
            //如果hash相等key也相等 那麼先拿著引用 等會直接替換掉老值 
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            else if (p instanceof TreeNode)//這邊是紅黑樹的邏輯,jdk8在連結串列長度超過8的時候會轉紅黑樹,這屬於資料結構
            //的範疇 我們日後再說。
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {
                for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) {
                        //這邊應該明顯的看出來,新來的元素在連結串列的位置是在尾部的,而不是jdk7種的頭部
                        p.next = newNode(hash, key, value, null);
                        //轉紅黑樹的操作 如果連結串列長度大於8的話 紅黑樹的問題屬於資料結構問題 我們日後再說
                        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;
    }

複製程式碼
  1. 第二點也就是最大的不同,jdk8的resize()操作比jdk7快了很多,並且jdk7 resize連結串列會倒過來,而jdk8不會。

    考慮如下場景:

    初始長度為16的陣列,我們加入2個元素 一個hashcode是5 一個hashcode是21. 我們對16取餘之後, 計算出來的索引位置 5 對應的是a[5],21對應的是也是a[5] 於是這2個就存在同一個連結串列中。

    當元素越來越多終於超過閾值的時候,陣列擴充到32這個長度,這個時候5 和21 再對32取餘 5對應的還是a[5],而21對應的就是a[21] 這個位置了,我們注意 21這個hashcode,之前對應的位置 是在a[5] 擴充一倍以後在21 位置增加了16 ,這個16 實際上就是擴充的長度,這個數學公式可以抽象為

    擴充前位置 oldIndex 擴充後位置 要麼保持不變 要麼是oldIndex+擴充前的長度.

    有了這套數學規律,我們在resize的時候就可以優化一下了,不需要像jdk7中重新計算hash和index了。

    下面就是jdk8種resize的主要過程

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;
                    else if (e instanceof TreeNode)
                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                    else { // preserve order
                        //這個連結串列用來表示擴充以後 新增bit為0的連結串列 也就是說這個連結串列上的元素擴充前後位置不變
                        Node<K,V> loHead = null, loTail = null;
                        //這個用來表示擴充以後要挪動位置的連結串列 挪動位置也就是說新增的bit為1了。
                        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 {
                                if (hiTail == null)
                                    hiHead = e;
                                else
                                    hiTail.next = e;
                                hiTail = e;
                            }
                        } while ((e = next) != null);
                        if (loTail != null) {
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        if (hiTail != null) {
                            hiTail.next = null;
                            //這個地方 對應著上文我們的公式
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
複製程式碼

多執行緒中的hashmap

眾所周知的hashmap明顯是不支援執行緒同步的,最大的原因就是hashmap的resize過程中極易被執行緒干擾, 很有可能中間的resize操作 transfer操作 的執行順序被打亂,要知道transfer操作的是連結串列, 很容易出現 你指向我我指向你這種迴圈連結串列,一旦出現迴圈連結串列的情況,基本程式就是死迴圈要報錯了。

何況即使不出現這種極端情況,put操作不加鎖的話,隨意的修改值,也會導致get出來的和你put進去的並不一致。

深入理解HashMap(一)

我們的hashtable就是採用的比較極端的方法,直接對put方法進行加鎖了。這樣雖然一勞永逸,但是效率極低。 不推薦使用。

當然我們還可以換一種方法。

HashMap hm=new HashMap();
Collections.synchronizedMap(hm);
複製程式碼

這樣也可以保持執行緒同步。看看原理:

public static <K,V> Map<K,V> synchronizedMap(Map<K,V> m) {
        return new SynchronizedMap<>(m);
    }

    /**
     * @serial include
     */
    private static class SynchronizedMap<K,V>
        implements Map<K,V>, Serializable {
        private static final long serialVersionUID = 1978198479659022715L;

        private final Map<K,V> m;     // Backing Map
        final Object      mutex;        // Object on which to synchronize

        SynchronizedMap(Map<K,V> m) {
            this.m = Objects.requireNonNull(m);
            mutex = this;
        }

        SynchronizedMap(Map<K,V> m, Object mutex) {
            this.m = m;
            this.mutex = mutex;
        }

        public int size() {
            synchronized (mutex) {return m.size();}
        }
        public boolean isEmpty() {
            synchronized (mutex) {return m.isEmpty();}
        }
        public boolean containsKey(Object key) {
            synchronized (mutex) {return m.containsKey(key);}
        }
        public boolean containsValue(Object value) {
            synchronized (mutex) {return m.containsValue(value);}
        }
        public V get(Object key) {
            synchronized (mutex) {return m.get(key);}
        }

        public V put(K key, V value) {
            synchronized (mutex) {return m.put(key, value);}
        }
        public V remove(Object key) {
            synchronized (mutex) {return m.remove(key);}
        }
        public void putAll(Map<? extends K, ? extends V> map) {
            synchronized (mutex) {m.putAll(map);}
        }
        public void clear() {
            synchronized (mutex) {m.clear();}
        }

        private transient Set<K> keySet;
        private transient Set<Map.Entry<K,V>> entrySet;
        private transient Collection<V> values;

複製程式碼

比hashtable好一點,但是鎖加的還是太多了。有提升但是依舊不夠好。

ConcurrentHashMap 才是解決此類問題的最終方案。

簡單來說,ConcurrentHashMap比上述方案效率都要更高的原因主要就是

我們可以把hashmap當做銀行的集合,比如說 這個集合裡面 有工商銀行,有建設銀行,招商銀行,農業銀行,等等。

前面2者幾乎就是 只要你來存錢,不管你是想去哪個銀行存,你都得排隊。

而ConcurrentHashMap的粒度會降低到,只要你來存錢,只會在你想去的銀行門口排隊。效率明顯更高。

要真正理解ConcurrentHashMap的原始碼,我們需要對volatile transient 關鍵字有很深的瞭解,同時還要了解ReentrantLock這個鎖機制,這裡先賣個關子,ConcurrentHashMap我們日後再說,大家瞭解一下即可。

使用時的經驗總結

(1) HashMap:它根據鍵的hashCode值儲存資料,大多數情況下可以直接定位到它的值,因而具有很快的訪問速度,但遍歷順序卻是不確定的。 HashMap最多隻允許一條記錄的鍵為null,允許多條記錄的值為null。HashMap非執行緒安全,即任一時刻可以有多個執行緒同時寫HashMap,可能會導致資料的不一致。如果需要滿足執行緒安全,可以用 Collections的synchronizedMap方法使HashMap具有執行緒安全的能力,或者使用ConcurrentHashMap。

(2) Hashtable:Hashtable是遺留類,很多對映的常用功能與HashMap類似,不同的是它承自Dictionary類,並且是執行緒安全的,任一時間只有一個執行緒能寫Hashtable,併發性不如ConcurrentHashMap,因為ConcurrentHashMap引入了分段鎖。Hashtable不建議在新程式碼中使用,不需要執行緒安全的場合可以用HashMap替換,需要執行緒安全的場合可以用ConcurrentHashMap替換。

(3) LinkedHashMap:LinkedHashMap是HashMap的一個子類,儲存了記錄的插入順序,在用Iterator遍歷LinkedHashMap時,先得到的記錄肯定是先插入的,也可以在構造時帶引數,按照訪問次序排序。

(4) TreeMap:TreeMap實現SortedMap介面,能夠把它儲存的記錄根據鍵排序,預設是按鍵值的升序排序,也可以指定排序的比較器,當用Iterator遍歷TreeMap時,得到的記錄是排過序的。如果使用排序的對映,建議使用TreeMap。在使用TreeMap時,key必須實現Comparable介面或者在構造TreeMap傳入自定義的Comparator,否則會在執行時丟擲java.lang.ClassCastException型別的異常。

相關文章