關於HashMap,這些你瞭解嗎

前言

  在面試中,只要涉及了Java基礎,HashMap幾乎是逃不過的,關於HashMap能考的東西有很多,那麼你都瞭解了嗎,本文中就將帶大家認識什麼是HashMap。

關於數據結構

  最基礎的問題,什麼是HashMap,它的數據結構又是怎樣的?首先所有的Map都是由key-value的鍵值對組成的,這樣的鍵值對在Java1.7之前的HashMap中是Entry,在Java1.8之後是Node。我們可以理解爲一個個的節點,HashMap就是由一個個這樣的節點組成的鏈表數組。不是鏈表,也不是數組,而是元素是鏈表的數組。

爲什麼要使用鏈表和數組組合作爲HashMap的數據結構?

  採用數組的原因我們都知道是因爲特性可以幫助快速定位元素,那麼爲什麼還要使用鏈表呢?我們在有限容量的數組中使用hash插入元素時,必然可能出現兩個元素的hash值相同的情況,這就是所謂的哈希衝突,HashMap中的鏈表最初就是解決哈希衝突的。

使用鏈表數組的話當鏈表長度很大時查詢效率不是很低嗎?

  沒有錯,當鏈表長度很長時查詢效率的確會受到影響,所以在Java1.8中對HashMap的結構做了優化,當鏈表長度達到閾值8的時候,鏈表結構會轉爲紅黑樹,提升查詢時的效率將查詢的時間複雜度從O(n)降低到了O(logn),關於紅黑樹的介紹就不在這裏過多贅述。

關於源碼

在Java中是如何實現HashMap的呢?

  在Java中1.7前和1.8後HashMap的源碼是有較大不同的原因是在1.8對HashMap做了多個方面的優化。我們來看看1.8中HashMap的源碼。
  首先來看看HashMap的成員變量

成員變量

//默認初始容量
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
//最大容量
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;
//最小樹化容量
static final int MIN_TREEIFY_CAPACITY = 64;

DEFAULT_INITIAL_CAPACITY 源碼中用的是位運算1 << 4,實際上就是16。那麼爲什麼要用位運算呢?
主要原因是位運算直接對內存數據進行操作,不需要轉成十進制,因此處理速度非常快。

爲什麼默認初始容量是16而不是其他的數?

這就不得不提到源碼中index的計算方式

index = (n - 1) & hash

  這裏的n其實就取自容量,當容量爲2的整數次冪時,容量減一的二進制表示的所有位都是1,所以只要hash值本身是均勻的,節點排列的index就是均勻的,容量爲2的整數次冪實際上是爲了實現均勻分佈。

什麼是負載因子,爲什麼是0.75?

  爲了減少衝突概率,當HashMap的數組長度達到一個臨界值就會觸發擴容,把所有元素rehash再放回容器中,這是一個非常耗時的操作,而這個臨界值由負載因子和當前的容量大小來決定。
在源碼中有這樣一段註釋

	/*
     * nodes in bins follows a Poisson distribution
     * (http://en.wikipedia.org/wiki/Poisson_distribution) with a
     * parameter of about 0.5 on average for the default resizing
     * threshold of 0.75, although with a large variance because of
     * resizing granularity. Ignoring variance, the expected
     * occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
     * factorial(k)). The first values are:
     *
     * 0:    0.60653066
     * 1:    0.30326533
     * 2:    0.07581633
     * 3:    0.01263606
     * 4:    0.00157952
     * 5:    0.00015795
     * 6:    0.00001316
     * 7:    0.00000094
     * 8:    0.00000006
     * more: less than 1 in ten million
     * /

  在理想情況下,使用隨機哈希碼,節點出現的頻率在hash桶中遵循泊松分佈,同時給出了桶中元素的個數和概率的對照表。
  從上表可以看出當桶中元素到達8個的時候,概率已經變得非常小,也就是說用0.75作爲負載因子,每個碰撞位置的鏈表長度超過8個是幾乎不可能的。HashMap負載因子爲0.75是空間和時間成本的一種折中。

節點類

  我們都知道了HashMap的數據結構是由鏈表數組構成,實際上在源碼中就是以節點爲單位的鏈表數組,在Java1.7之前用Entry表示,1.8後改爲Node,Node也是實現自Entry接口。

    static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        V value;
        Node<K,V> next;

        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; }
        public final V getValue()      { return 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;
        }
    }

  我們可以看到在節點類中除了鍵值對key和value以外還有兩個成員變量,一個是當前節點的哈希值hash,還有下一個節點的引用next。眼尖的朋友可能看到了,節點類中是沒有key的set方法的,因爲鍵值對中的key是不允許形成節點之後再做改動的。除了get,set方法之外,還可以看到equals和hashCode方法,都重寫自Object類。

hashCode與equals

爲什麼要重寫equals方法和hashCode方法?

  因爲在Object類中equals的默認實現是

return (a == b) || (a != null && a.equals(b));

  即只有當兩個對象引用指向同一地址時才被判斷爲相等,所以當我們需要用對象中的一些屬性的值來作爲哦判斷對象相等的依據時需要重寫equals方法,又因爲在HashSet,HashMap等集合中,判斷元素相等用到了元素的hashCode方法,所以爲了避免hashCode與equals判斷對象是否相等時產生不一致的情況,同樣需要重寫hashCode方法。

hash方法

  HashMap中的hash方法通過對key的hashCode做二次處理來獲取hash值。

    static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

  當key爲null時直接返回0,當key不爲null時,將key的hashCode無符號右移16位後與本身異或。
在Object類中的hashCode()方法返回值是31位的,右移16位恰好是一半,右移時高位補0。

HashMap的初始化

  HashMap的構造方法一共有四個

	/**
	 * 無參構造函數(沒有傳入任何參數,只將默認負載因子賦值)
	 */
    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);
    }

  第三種構造方法實際上就是先對初始化容量和負載因子做了邊界值判斷,然後爲容量threshold賦了值,賦值過程中用到了tableSizeFor(initialCapacity)方法。

tableSizeFor方法

    /**
     * Returns a power of two size for the given target capacity.
     */
    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的整數次冪的的最小值。
  那麼是怎麼做到的呢,首先下面這一步,將容量減一的值右移一位在與原值進行或操作,取出最高位的1,假定原值爲01XXXX…,那麼這一次操作後原值變爲011XXXX…

n |= n >>> 1;

經過

  n |= n >>> 2;
  n |= n >>> 4;
  n |= n >>> 8;
  n |= n >>> 16;

最高一位的1後所有的位都變爲1,將這樣的值加1,恰好是2的整數次冪。

那爲什麼在操作前要先將cap-1呢?

  因爲如果cap本身就是2的整數次冪且不減一,經過上面的操作後,產生的容量就是原來的二倍大小,不是最小的了。

HashMap的put操作

  當我們調用put方法時實際上調用了另一個方法putVal,方法中除了傳入key,value和key的hash值之外還傳了兩個布爾類型的參數,onlyIfAbsent爲true是隻在當前key對應的原value爲null或不存在時做覆蓋或插入操作,爲false時則可以覆蓋原有value值;evict在HashMap中無作用,用到這個參數的方法void afterNodeInsertion(boolean evict) { }在LinkedHashMap實現,這裏不做介紹。
  我們可以看到HashMap中的put方法默認是會覆蓋相同key的原值的,如果我們不希望覆蓋,可以調用HashMap中提供的另一個方法,同樣是調用putVal方法只是onlyIfAbsent值改成了true。

    public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }
    public V putIfAbsent(K key, V value) {
        return putVal(hash(key), key, value, true, 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;
        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;
            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);
            else {
                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;
    }

  從添加元素調用的putVal方法我們可以發現,在添加元素的過程中調用了resize()方法調整容量,在HashMap中容量的初始化並不是在構造方法中執行的,而是到第一次添加元素的時候才初始化容量。
  添加元素時需要判斷是否需要擴容,當前元素應該放在哪個桶,是否已存在相同的key,是否是樹節點等。
  我們可以看到

p.next = newNode(hash, key, value, null);

  新添加的節點被添加在鏈表尾部,這也是1.8的改動之一,在1.7和之前的版本中,新的節點都是添加在鏈表頭的,因爲新節點被認爲被訪問的概率更大。

那爲什麼1.8又要改成尾部插入呢?

  這是因爲當插入元素達到擴容的條件時,擴容的過程並不是簡單的複製一遍,而是所有元素會有一個rehash操作,此時使用頭插法如果有多個線程同時操作有可能出現鏈表中某兩個節點形成環,爲了避免鏈表成環,1.8之後都採用尾插法。

後記

  關於HashMap的知識點其實還有很多,比如和HashTable,HashSet等集合的對比,併發問題的處理,JUC下的ConcurrentHashMap,源碼中提到的紅黑樹,泊松分佈,布隆過濾器等等,篇幅原因不在本篇介紹,感興趣的朋友可以在評論中評論您感興趣的知識點,您的評論就是我的素材,您的支持就是我的動力,點關注,不迷路!

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