1. 程式人生 > >java HashMap類

java HashMap類

HashMap主要實現了Map介面,本文主要介紹HashMap的幾個方法,如果涉及到原始碼,都是基於jdk11的

行文結構

目錄

1雜湊表和連結串列

2HashMap的實現原理 (原始碼角度)

3HashMap的put,get方法

4HashMap的擴容


1雜湊表和連結串列

介紹幾個概念:

陣列:

採用一段連續的儲存單元來儲存資料.對給定下標的查詢,時間複雜度為O(1),對給定值的查詢,時間複雜度為O(n),n為陣列長度,因為要遍歷整個陣列,依次比較給定值和陣列中的各元素,陣列實現查詢和修改快,實現增加和刪除慢,比如除了在尾部增加和刪除,在其他地方的增加和刪除,其他元素都會響應移動,所以慢點

線性連結串列:

連結串列可以看成是一根斷了的自行車鏈條,連結串列上的每個節點的儲存地址是不連續的,連結串列的增加和刪除操作快,時間複雜度為O(1),而查詢慢,時間複雜度為O(n),原因是增加和刪除的時候,只需要將新節點加入到兩個連結串列的節點之間,或者將舊節點從兩個連結串列節點之間刪除,將兩個連結串列的指向關係重新寫下就好了

二叉樹

二叉樹有多種結構,比如平衡二叉樹,對當前節點來說,節點左邊的元素總比節點右邊的元素要大,對其插入,查詢,刪除操作的平均時間複雜度都是O(logn)

雜湊表

       雜湊表` 是以一種容易找到它們的方式儲存的項的集合。雜湊表的每個位置,通常稱為一個槽,可以容納一個項,並且由從 0 開始的整數值命名。例如,我們有一個名為 0 的槽,名為 1 的槽,名為 2 的槽,以上。最初,雜湊表不包含項,因此每個槽都為空。我們可以通過使用列表來實現一個雜湊表,每個元素初始化為`null`.

hash的方式是通過雜湊的方式將元素均勻分佈到hash表中.即對給定關鍵字比如key值經過hash函式計算後,得到它要儲存的地址

     給定項的集合,將每個項對映到唯一槽的雜湊函式被稱為完美雜湊函式。如果我們知道項和集合將永遠不會改變,那麼可以構造一個完美的雜湊函式。不幸的是,給定任意的項集合,沒有系統的方法來構建完美的雜湊函式。幸運的是,我們不需要雜湊函式是完美的,仍然可以提高效能。總是具有完美雜湊函式的一種方式是增加散列表的大小,使得可以容納項範圍中的每個可能值。這保證每個項將具有唯一的槽。雖然這對於小數目的項是實用的,但是當可能項的數目大時是不可行的。即便hash表中的位置沒有佔滿,雜湊函式還是可能計算出兩個不同的元素的hash值相同的,

每個元素要想放入到雜湊表,首先要通過hash函式計算它的hash值,當兩個項雜湊到同一個槽時,我們必須有一個系統的方法將第二個項放在散列表中。這個過程稱為衝突解決。

在不考慮雜湊衝突的情況下,新增,刪除,查詢等操作僅需要一次計算hash表中位置,即可定位完成操作,時間複雜度為O(1)

   解決衝突的一種方法是查詢散列表,嘗試查詢到另一個空槽以儲存導致衝突的項。一個簡單的方法是從原始雜湊值位置開始,然後以順序方式移動槽,直到遇到第一個空槽。注意,我們可能需要回到第一個槽(迴圈)以查詢整個散列表。這種衝突解決過程被稱為開放定址,因為它試圖在散列表中找到下一個空槽或地址。通過系統地一次訪問每個槽,我們執行稱為線性探測的開放定址技術。

然而,在HashMap中,解決雜湊衝突採用的是陣列+連結串列/紅黑樹的方式,即如果hash值相同,那麼對應hash表的那個槽處成為一個連結串列,往連結串列上增加元素.,那位可能會說,既然hash表上可以有連結串列,連結串列的長度是無限的,初始化HashMap後,不需要再擴容了,理論上初始化的HashMap的雜湊表長度是16,是可以儲存無限多元素,實際上如果連結串列/紅黑樹過大,查詢,增加,刪除等操作需要遍歷連結串列/紅黑樹的話,效率就變低了,所以容量的使用達到一定量了,還是擴容吧

畫個圖,說明下HashMap的儲存樣例

 

2HashMap的實現原理 (原始碼角度)

開始前,先說明幾個名詞

陣列結構的每一個槽稱為
桶中存放的每一個數據稱為bin

size,Map中存放的鍵值對個數,包括陣列中的元素和每個桶中(包括連結串列或者紅黑樹中)元素的總和

capacity,HashMap中桶的數量

loadFactor,填充因子,也叫裝載因子,計算HashMap中實時裝載因子的公式為size/capacity(待驗證)

threshold: 當HashMap的size大於threshold時會執行resize擴容操作

2.1先看一些成員變數,即屬性:


     * The default initial capacity - MUST be a power of two.
//預設的初始化容量即陣列長度必須是2的多少次冪,在這裡3效率要高是1左移4,即16,左移比直接乘效率高 
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
//最大容量1*(2^30)  
    static final int MAXIMUM_CAPACITY = 1 << 30;
//預設的填充因子是0.75,即達到現有容量的0.75倍即開始擴容    
    static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 這是一個閾值,當桶(bucket)上的連結串列元素個數大於這個值時會轉成紅黑樹,put方法的程式碼裡有用到
    static final int TREEIFY_THRESHOLD = 8;
// 擴容時,如果一個桶中的元素個數小於這個值,轉化為連結串列 
    static final int UNTREEIFY_THRESHOLD = 6;
//  樹形化和擴容的選擇閾值,只有陣列的大小超過這個閾值,單個桶才可以被轉換成樹而不是連結串列(
陣列長度小於這個值時,應該使用resize擴容,增加表的長度而不是增加桶的深度)
//這個值最少是TREEIFY_THRESHOLD的4倍,以避免擴容和樹化之間產生衝突
    static final int MIN_TREEIFY_CAPACITY = 64;
//陣列的長度,即表的長度
transient Node<k,v>[] table;
transient Set<map.entry<k,v>> entrySet;
// 存放元素的個數,注意這個不等於陣列的長度。
transient int size;
// 每次擴容和更改map結構的計數器
transient int modCount;
// 臨界值 當實際大小(容量*填充因子)超過臨界值時,會進行擴容
int threshold;
// 填充因子
final float loadFactor;

2.2構造方法:

構造方法,初始化了一些屬性

 public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }
    public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    }
    public HashMap(Map<? extends K, ? extends V> m) {
        this.loadFactor = DEFAULT_LOAD_FACTOR;
        putMapEntries(m, false);
    }
//上述三個構造方法都呼叫下面這個構造方法
//下面這個構造方法大部分都在判斷傳入的值是否合法,看最後一行就行了
    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;
   //此時table還未分配到記憶體,threshold就是將要分配的陣列大小
        this.threshold = tableSizeFor(initialCapacity);
    }
//這個函式的返回值是2的n次方,且返回值大於等於cap(構造方法中傳入的容量)
 static final int tableSizeFor(int cap) {
        int n = -1 >>> Integer.numberOfLeadingZeros(cap - 1);
        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
    }

構造方法保證初始化的陣列長度比傳入值大且最接近2的n次冪的一個數,比如傳進去100,構造的陣列長度為128

HashMap的實現原理,

為什麼hashMap的長度一定要是2的冪?

我們知道往HashMap中新增一個元素時1呼叫元素自身的hashCode方法,獲取hashCode值,儘量將值雜湊開,再呼叫hash函式,

static final int hash(Object key) {
        int h;
          //使用hashCode的值與(hashCode的值無符號右移16位)做異或操作
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }
//hashCode是類繼承自Object的一個重寫的方法,返回的值儘量雜湊開
//(h>>>16)為了避免hash碰撞(hash collisons)將高位分散到低位上了,這是綜合考慮了速度,效能等各方面因素之後做出的

hash裡面做了一件事,判斷key是否為空,為空返回hash值為0,不為空返回hashCode值與右移16位的hashCode值的異或值,目的還是將hash值均勻雜湊,下一步是要查詢索引,用到了hash值,看下一行程式碼,

if ((p = tab[i = (n - 1) & hash]) == null)  //計算索引,判斷索引處的桶是否為空
計算索引的方法為i=(n-1)&hash

我們知道n為hashMap的容量即桶的數量,如果n為2的冪,(n-1)&hash保證獲取的索引值在陣列範圍內,如下圖,hashCode值為一個大數值,經過hash運算後,再與n-1位與運算,得到的索引就在陣列長度範圍內

如果多個key計算出來的索引值一樣,那就都放入同一個位置的桶中,插入到連結串列或者紅黑樹中
查詢也是相似流程,定位到索引後,如果bucket的節點的key不是我們需要的(也就是發生了衝突),則通過keys.equals()在鏈中比較,時間複雜度為O(1)+O(n)。jdk8之後改為了利用紅黑樹替換連結串列,這樣複雜度就變成了O(1)+O(logn)了

3HashMap的put,get方法

put:

1、對key的hashCode()做hash,然後再計算index;  如果沒碰撞直接放到桶bucket裡;如果碰撞了,以連結串列的形式存在同buckets裡,如果索引處的桶的連結串列長度大於閾值,(預設為8),就將連結串列轉成紅黑樹,(轉成紅黑樹是在陣列長度大於64時才能轉),如果節點已經存在同一個key值就替換old value(保證key的唯一性);如果桶bucket的個數超過(load factor * current capacity),就擴容。

put方法原始碼:

public V put(K key, V value) {
    // 對key的hashCode()做hash運算
        return putVal(hash(key), key, value, false, true);
    }

putVal方法原始碼:

 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)
            //key的hash值與(陣列table的長度-1)進行相與得出在陣列中的位置(與取餘一樣,但這樣效率高),如果為空說明陣列上這個位置沒被佔用,建立一個Node內部類節點賦值給陣列。
            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))))
            //判斷原陣列儲存的是否同一個key元素,如果是,通過變數e記錄下該位置,在下面把該元素的value替換為新值
                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) {
        //如果遍歷完連結串列後,還沒找到相同key的元素,說明該連結串列沒有原值,new一個node新增到連結串列
                        p.next = newNode(hash, key, value, null);
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
             //如果遍歷的連結串列長度大於=8,嘗試轉換為樹形結構
                            treeifyBin(tab, hash);
                        break;
                    }
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
             //如果遍歷過程中找到相同key的元素,e記錄位置後跳出迴圈
                        break;
                    p = e;
                }
            }
            if (e != null) { // existing mapping for key
        ////如果e不為空說明找到原來的key,把原來node上的值賦予新值
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        if (++size > threshold)
////新增一個元素後size加1,如果目前存在元素數量大於threshold=capacity*loadFactor後進行擴容,
            resize();
        afterNodeInsertion(evict);
        return null;
    }

2 get方法:

呼叫key的hashCode()方法計算其hashcode值,再對key的hashCode值做hash運算,再計算index;如果該 index 對應的桶沒有元素,則直接返回 null;index對應的桶中有元素,判斷桶中第一個節點的key是否跟get(key)的key值相同,相同則返回此位置的元素

不同,則對該桶下的樹結構或者連結串列結構查詢,找到則返回元素的value值,找不到則返回null
 

public V get(Object key) {
        Node<K,V> e;
        return (e = getNode(hash(key), key)) == null ? null : e.value;
    }

getNode方法的原始碼:

final Node<K,V> getNode(int hash, Object key) {
        Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (first = tab[(n - 1) & hash]) != null) {
            if (first.hash == hash && // always check first node
                ((k = first.key) == key || (key != null && key.equals(k))))
      //  table陣列的這個桶的第一個節點正好是取的值
                return first;
            if ((e = first.next) != null) {
                if (first instanceof TreeNode)
      //如果是樹形結構通過樹形方法遍歷取node
                    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;
    }

4HashMap的擴容

當HashMap中的元素個數超過陣列大小*負載因子時,就會進行陣列擴容,loadFactor的預設值為0.75.預設情況下,陣列大小為16,那麼當HashMap中元素個數超過16*0.75=12的時候,就把陣列的大小擴充套件為 2*16=32,即擴大一倍,然後重新計算每個元素在陣列中的位置,而這是一個非常消耗效能的操作,所以如果我們已經預知HashMap中元素的個數,那麼預設元素的個數能夠有效的提高HashMap的效能。

擴容時對原有元素的處理,桶中沒有元素的,不處理,桶中只有一個元素節點,重新計算index(計算方式為hash&(newCap-1)),桶中有多個節點,因為capcity總是2的冪,擴容變成原來的2倍,從二進位制來看,只是向左移動一位,此時不會重新計算所有index,而是看看原來的hash值新增的那個bit位是0還是1,(因為容量擴大了一倍,因此影響結果的是hash之前沒有參與運算的最右側位值,通過 hash & oldCap 便能得到),0的話索引沒變,1的話索引變為"原索引+oldCap".

resize函式原始碼:

 final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table;
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        //如果舊陣列為空,舊錶容量為0,舊錶不為空的話,舊錶容量為舊陣列的長度
        int oldThr = threshold;
        int newCap, newThr = 0;
        if (oldCap > 0) {  //如果舊錶容量大於0
            if (oldCap >= MAXIMUM_CAPACITY) {
            ////判斷舊錶容量是否為最大容量,如果是最大容量,不擴容
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
            //舊錶容量*2以後還沒達到最大容量,加倍
                newThr = oldThr << 1; // double threshold
        }  //如果舊錶容量等於0
        else if (oldThr > 0) // initial capacity was placed in threshold
            newCap = oldThr;   ////使用預設設定新增容量為16
        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) {  //如果舊錶第j個位置上的桶不為空
                    oldTab[j] = null;
                    if (e.next == null)
       //如果舊陣列這個位置儲存的桶裡是單個node物件沒有連結串列,
       //直接把該node的hash值和新的table-1進行相與取得新的儲存位置
                        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) {
            //(e.hash & oldCap)判斷mask範圍在高位多1bit是否有值,如果有值新位置為(原索引+oldCap)否則為原索引位置
                                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;
    }

resize後新索引位置圖解:根據原始碼分析

 if ((e = oldTab[j]) != null) {  //如果舊錶第j個位置上的桶不為空
                    oldTab[j] = null;
                    if (e.next == null)
       //如果舊陣列這個位置儲存的桶裡是單個node物件沒有連結串列,
       //直接把該node的hash值和新的table-1進行相與取得新的儲存位置
                        newTab[e.hash & (newCap - 1)] = e;

上面幾行程式碼(resize方法裡的)說了一件事,如果舊陣列中莫個桶中只有一個元素,那這個元素在新陣列中的索引值為e.hash&(newCap-1),

  do {
                            next = e.next;
                            if ((e.hash & oldCap) == 0) {
            //(e.hash & oldCap)判斷mask範圍在高位多1bit是否有值,如果有值新位置為(原索引+oldCap)否則為原索引位置
                                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;//新增到(舊索引+舊錶長度)位置
                        }
                    }

上面幾行程式碼(resize方法裡的)做了一件事,處理桶中非單個元素的事情,if ((e.hash & oldCap) == 0) 這行計算的是e.hash&(oldCap),而不是之前咱們計算索引的hash&(n-1),(注:n和oldCap都表示舊陣列的長度,把高位遮蔽),這行計算出來的就是hash值與原陣列長度相與的結果的高位,咱們知道地位的結果不會變,高位為1,重新計算出來的索引值比舊陣列中的索引值多了OldCap,如果為0,計算出來在新陣列的索引值也不會變的


   樹化函式:

putVal函式中
           if (binCount >= TREEIFY_THRESHOLD - 1)
                treeifyBin(tab, hash);

//如果一個桶中的元素個數大於8,會呼叫此函式,判斷能否將連結串列轉換為紅黑樹,如不能樹化則進行resize擴容操作
final void treeifyBin(Node<K, V>[] tab, int hash) {
		// e是hash值和陣列長度計算後,得到連結串列的首節點,
		int n, index;
		Node<K, V> e;
		if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
			resize();
		// 如果元素陣列長度已經大於等於了 MIN_TREEIFY_CAPACITY,這個桶bucket就要轉換成樹形結構
		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);
		}

 

參考:https://blog.csdn.net/dhfzhishi/article/details/78173191

https://www.jianshu.com/p/f2361d06da82

https://blog.csdn.net/weixin_39220472/article/details/80459364

                 

 

 

 

Options : History : Feedback : Donate Close