Java基礎系列:瞭解HashMap

來,進來的小夥伴們,咱們認識一下。html

我是俗世遊子,在外流浪多年的Java程序猿java

前言

前面兩篇咱們聊了聊關於集合容器中的List集合,其中包含兩個子類:node

  • ArrayList
  • LinkedList

若是尚未看過的小夥伴,能夠先返回去看看面試

我在LinkedList中留下了幾個思考問題,不知道有沒有想到的,咱們在評論區裏探討探討啊api

這篇咱們聊一聊Map,咱們再回顧一下,前面介紹的時候,咱們說過,MapList很大的一個區別在於:數組

  • List存儲數據是單一元素,而Map存儲數據是K-V形式存儲的

爲何要有K,V形式來存儲數據?

在傳統的系統中,咱們的數據大概10W,百W的存儲就足夠了,可是在一些特殊的應用或者大數據平臺中,涉及到千萬甚至更多的數據,安全

本人在上家公司開發的廣告投放系統中,天天至少會產生2000W的數據數據結構

這時若是咱們想從其中查找到某一條數據就很是麻煩,涉及到性能等的問題,而經過K,V形式存儲,咱們就至關於對某一個值添加了索引,經過這個索引咱們就能很快定位到數據,提升系統的性能。oracle

關於K,V形式的存儲,咱們在工做中還會用到如:less

  • Session
  • Redis
  • HBase
  • ...

好,瞭解到這一點以後,咱們繼續日後看。

前面咱們講到,集合中全部的父類是Collection,可是Map是單獨的一套接口,這裏不能混在一塊兒,下面咱們來看看Map的實現子類:

  • HashMap
  • LinkedHashMap
  • TreeMap

瞭解ArrayList中我也給出了一張思惟導圖

咱們就一個一個來看

最重要的一點:面試出場率賊高了【9月份面試一個月,80%的公司都有問到(當時不懂啊-_-||)】

這裏,在聊今天的主角:HashMap以前,咱們先來簡單的認識一下什麼是哈希表

HashMap很長,文采略爛,你們要有耐心哦

哈希表

什麼是哈希表?

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

來源:百度百科

PS:概念都不是人話,不用記他,直接看結構

哈希表分爲多種類型,下面咱們來看到的是在HashMap底層實現的結構:

HashMap中哈希表結構

上面是一個數組,在內存中是一塊連續的內存空間,Key值取到hashCode碼而後再對數組長度作取模操做,獲得對應的下標位置,而後將Value值放到對應下標位置的地方;若是在對應下標位置的地方存在元素,那麼就已鏈表的形式追加

這種方式在散列函數中稱爲:除法散列法

除法散列法

取值也是同樣:經過關鍵key的哈希取到對應下標以後,若是對應位置只有一個數據,那麼就直接取出,不然就在鏈表中進行比對而後再取出對應的數據

簡單對哈希表認識一下,咱們繼續聊HashMap

HashMap

背景說明:JDK1.8

老規矩,咱們來先來看一看HashMap的一個類分佈圖

HashMap

操做方法

屬性值介紹

咱們都是這樣來構造HashMap的:

Map<String, String> hashMap = new HashMap<>();
Map<String, String> hashMap = new HashMap<>(20);

這樣一個無參的構造方式,咱們來看看具體作了什麼?

/**MUST be a power of two.*/
// 默認初始化長度
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
// 負載因子,負責斷定數據存儲到什麼地步的時候進行擴容
static final float DEFAULT_LOAD_FACTOR = 0.75f;

public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}

按照咱們以前的經驗,存儲數據須要在內存中開闢空間,可是在HashMap的構造方法中並無這麼作,包括其餘有參的構造方法:

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

// 除外,傳遞參數不一樣,
public HashMap(Map<? extends K, ? extends V> m) {
    this.loadFactor = DEFAULT_LOAD_FACTOR;
    putMapEntries(m, false);
}

這裏值得咱們借鑑:若是直接在定義構造方法的時候就在內存中開闢空間的話,若是不存儲數據的話,那麼這塊內存不就被浪費了麼

前面咱們都知道,在定義數組的時候須要指定數組的長度,而哈希表中有采用數組的結構,那麼若是不定義指定長度的話,默認的一個長度就是屬性值:DEFAULT_INITIAL_CAPACITY,等於 16

咱們重點還要關注它的註釋:MUST be a power of two WHY?

Hashtable中,是按照除法散列法中的規範來作的:也就是上面說的不太接近2的整數冪的素數,可是爲何在HashMap中就沒有采用這種規範,而是要採用2的N次冪呢?咱們後面再具體說

一樣,咱們還要在關注一個點:

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的N次冪的數,因此說,哪怕傳遞的參數不知足規定,在代碼中也會幫咱們進行調整

一樣在HashMap中還存在一個屬性loadFactor:表示負載因子,簡單來講就是該屬性決定了HashMap容器空間何時該擴容,默認是0.75

好比:初始長度爲16,負載因子是0.75,那麼當容器中存儲了> (16 * 0.75 = 12)的時候,就會進行擴容操做

下面咱們再來看兩個屬性

static final int TREEIFY_THRESHOLD = 8;
static final int UNTREEIFY_THRESHOLD = 6;

這兩個屬性值是在JDK1.8以後加進來的,簡單來講就是:當鏈表長度>8的時候,鏈表會轉成紅黑樹的結構存儲數據,當紅黑樹的節點<6個的時候,會轉成鏈表的形式

  • 也就是說,在JDK1.8中,HashMap的底層結構採用的是哈希表+紅黑樹的形式

也就是下面的結構:

HashMap底層結構

關於爲何是8轉紅黑樹?

在屬性值DEFAULT_INITIAL_CAPACITY上面有一段註釋,給出了分析:

/*
     * the frequency of
     * 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
     *
     */

也就是說,經過計算,在k=8的時候,接近於0,因此定義爲8,提高檢索的效率,這裏涉及到一個叫作《泊松分佈》:這是一種統計與機率學裏常見到的離散機率分佈

介紹的話推薦你們看這一篇:如何通俗理解泊松分佈

方法說明

瞭解完基本的屬性值以後,咱們來看具體的操做方法,仍是同樣的,在此以前咱們來看兩個類:

  • Node
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;
    }
    // ...
}

這個類你們確定不陌生,前面在聊LinkedList的時候就已經見過了,不過這個是單向鏈表的方式

  • TreeNode
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
    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;
    TreeNode(int hash, K key, V val, Node<K,V> next) {
        super(hash, key, val, next);
    }
    //...
}

這是關於紅黑樹的具體類

下面咱們繼續,

put

咱們是這樣調用的:

hashMap.put("key1", "value1");

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

前面也說到,肯定關鍵值Key在數組中的位置,那麼咱們先來看看是如何進行hash運算的:

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

爲了可以讓計算出來索引位置更分散,因此先(h &gt;&gt;&gt; 16),一樣,再經過 ^運算讓哈希值的高低位都能參與運算,從而減小哈希碰撞的概率

擾動函數

下面咱們看具體的實現:

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;                            // 註釋1
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);               // 註釋2
    else {
        Node<K,V> e; K k;
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))     // 註釋4
            e = p;
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);// 註釋5.2
        else {
            for (int binCount = 0; ; ++binCount) {              // 鏈表的插入過程
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    if (binCount >= TREEIFY_THRESHOLD - 1)              // 註釋5
                        treeifyBin(tab, hash);
                    break;
                }
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        if (e != null) {                                    // 註釋4.2
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    if (++size > threshold)                                 // 註釋3
        resize();   
    afterNodeInsertion(evict);
    return null;
}
開闢空間和擴容

前面說到,在構造方法中什麼都沒作,只有在實際添加元素的時候纔會開闢空間。在上面註釋1的地方就是開闢空間的過程,同時在註釋3的地方,該方法也是咱們擴容的過程,調用的都是同一個方法resize()

一步步來,先剖析putVal()方法,而後咱們在來看resize()

在第一次put(),只是建立了一個Node數組,沒有其餘操做, 也就是在內存中開闢空間,而後咱們繼續往下看

計算索引下標

第一次put()元素,那麼確定會進入到註釋2的位置,經過 & 來計算當前元素所處的下標位置並賦值

同時,若是以前有看過JDK1.7的源碼的話,會發如今1.7中有這樣一個方式:

static int indexFor(int h, int length) {
    return h & (length - 1);
}

JDK1.8中:

i = (n - 1) & hash

舉個例子:

(n-1)以後的二進制: 01111

hash = 18,轉成二進制: 10010

&運算以後: 00010

經過計算,i=2

在設置成2的N次冪以後,在計算下標位置的時候能夠保證(n-1)的後幾位必定是1,方便進行 & 運算,而&的效率要高於%運算。

多用用位運算符,那不是擺設。O(∩_∩)O

繼續調用put()添加元素,註釋4註釋4.2是相輔相成的,走到這裏會判斷key是否存在,若是存在key,那麼e仍是會獲得從HashMap中數組索引位置上獲得的key,在註釋4.2的地方將value進行賦值操做,也就是覆蓋原先的值並返回舊值。

也就是說:在HashMap,不存在重複元素,若是在同一個key上存儲了多個元素,那麼只會存儲最新的元素

hashMap.put("key1", "value1");
hashMap.put("key1", "value2");
System.out.println(hashMap);

// {key1=value2}
轉紅黑樹存儲數據

前面咱們也說過,HashMap在JDK1.8的版本中:當鏈表長度>8的時候會將存儲結構轉成紅黑樹來存儲,那麼在註釋5的地方咱們就獲得了驗證,一樣,咱們來看一下轉換過程:

final void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index; Node<K,V> e;
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
        resize();
    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);
    }
}

前面咱們已經看過TreeNode這個類,這樣也就對應到了咱們上面的底層結構圖

同時咱們能夠看到註釋5.2的地方,若是存儲已經轉換成紅黑樹的形式,那麼就對紅黑樹進行插入操做

紅黑樹後面聊

resize()

接下來咱們看resize(),上面知道初始化的時候是建立數組的過程,那麼咱們看有值的時候作了什麼事情

final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;                                 
    int oldCap = (oldTab == null) ? 0 : oldTab.length;              // oldCap = 16
    int oldThr = threshold;                                         // oldThr = 12
    int newCap, newThr = 0;
    if (oldCap > 0) {
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&       // newCap = 32
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold               // newThr = 24
    }
    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);
    }
    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) {
        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 {
                            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;
                    }
                }
            }
        }
    }
    return newTab;
}

後面註釋舉了個小栗子。咱們也能夠看到

  • 容器長度的擴容是成倍擴容的
  • 判斷容器擴容的依據,也就是說在初始經過(容器長度 * 負載因子)獲得的擴容依據的數也會成倍增加的,因此這裏咱們要注意一下
數據遷移

JDK1.8中數據遷移是判斷:當前hash & 舊容器長度的結果:

  • 若是結果是0,在新數組中仍是在老位置
  • 若是結果不是0,那麼在新數組中的位置爲:原數組中的位置 + 原數組的容器長度

具體邏輯在這裏:

// e.hash = 65 經過計算爲0,
if ((e.hash & oldCap) == 0) {
    if (loTail == null)
        loHead = e;
    else
        loTail.next = e;
    loTail = e;
}
// e.hash = 6366 經過計算不爲0,走else
else {
    if (hiTail == null)
        hiHead = e;
    else
        hiTail.next = e;
    hiTail = e;
}

// 原數組中的位置和新數組中位置相同
if (loTail != null) {
    loTail.next = null;
    newTab[j] = loHead;
}
// 新數組中的位置改變
if (hiTail != null) {
    hiTail.next = null;
    newTab[j + oldCap] = hiHead;
}

HashMap關鍵點也就聊完了,下面咱們來總結一下

總結

  • HashMap底層採用哈希表+紅黑樹的結構來存儲,無序存儲。當哈希錶鏈表長度>8的時候會將鏈表轉成紅黑樹,判斷基準是經過採用泊松分佈通過驗證以後得出的結論;而後在當紅黑樹的節點<6的時候將紅黑樹的結構轉回到鏈表(官方沒有明確表示爲何是6)
  • HashMap在默認狀況下容器長度爲:16,且若是須要擴容的話,那麼會擴容當前容器長度的2倍。且達到擴容的條件時當前容器長度*負載因子,在後續擴容過程當中,擴容條件爲成倍擴容
  • 這裏咱們在構造的時候也能夠傳入固定參數,可是這裏須要注意:若是須要自定義容器長度的話,最好定義的長度是2的N次冪:
    • 由於在經過key的哈希值查找數組索引的時候會採用&計算,性能對比%更高
  • 同時在數據遷移的過程當中,判斷key的哈希值與原數組長度經過運算以後是否爲0,等於0的話就是在新數組中的下標位置不變,不然的話就等於將當前下標位置 + 原數組的長度即在新數組中的索引位置
  • HashMap是線程不安全的類,若是想要保證線程安全的話:
    • 本身加鎖
    • Collections.synchronizedMap()
    • 採用ConcurrentHashMap

文檔

更多關於HashMap使用方法推薦查看其文檔:

HashMapAPI文檔

相關文章
相關標籤/搜索