1. 程式人生 > 其它 >Java基礎-1-從Hash到HashMap

Java基礎-1-從Hash到HashMap

目錄

hash是什麼

Java中hash可以認為是唯一編碼、摘要值,不同物件的計算方式不同。實質上將任意長度的輸入,通過雜湊演算法,變成固定長度的輸出,輸出值便是hash(雜湊)值。

hash值如何計算

  1. Object類的hash值為經過處理的JVM虛擬機器中分配的記憶體地址,這樣就可以區分出不同的物件,要比較物件中的值是否相等來判斷當前物件是否相等,就需要重寫物件的equals方法來判斷。
    /** java.lang.Object **/
    /** This is typically implemented by converting the internal
        address of the object into an integer **/
    public native int hashCode();
    
  2. String類的hash值是根據演算法計算字串內容,但是並不能保證不同的字串內容hash值不一樣,真正要比較內容時,需要使用equals方法逐字元比較。
    /** The value is used for character storage. */
    private final char value[];
    
    /** Cache the hash code for the string **/
    private int hash; // Default to 0
    
    public int hashCode() {
        // 此hash為字串剛建立時初始化值為0的值
        int h = hash;
        if (h == 0 && value.length > 0) {
            char val[] = value;
            // 基本方法就是用現有的hash值乘以31,再加上當前字元的unicode值(十進位制)
            for (int i = 0; i < value.length; i++) {
                h = 31 * h + val[i];
            }
            // 計算完畢以後給字串設定計算後的hash值用來快取
            hash = h;
        }
        return h;
    }
    
  3. Integer類的hash值是包含整數的數值。
    private final int value;
    
    public int hashCode() {
        return Integer.hashCode(value);
    }
    
    public static int hashCode(int value) {
        return value;
    }
    

Hash碰撞(衝突)

  • 因為Hash的特性,導致了有可能造成不同的輸入會生成相同的雜湊值,這就產生了Hash碰撞。
  • 如何解決:
    1. 開放定址法:再雜湊法,將產生衝突的hash值再次hash計算,依次進行,知道找到不衝突的hash地址。
    2. 再hash法:同時構造多個不同的hash函式,當計算出來的hash地址產生衝突時,計算下一個,知道衝突不再產生。
    3. 鏈地址法:將所有hash地址相同的元素狗造成一個成為同義詞鏈的單鏈表,將頭指標記錄在哈西表的單元中,在HashMap中使用。
    4. 建立公共溢位區:將hash表分為基本表和溢位表,和基本表發生衝突的一律填入溢位表。

HashMap

HashMap是使用到Hash,也是面試中被提問到最多的一種,其實現的原理就是雜湊+資料。因為儲存的資料是鍵值對,根據計算鍵的雜湊值計算出值應該存放的下標,從而實現查詢效率為O(1)。

1. HashMap初始化陣列大小的計算

  • 如果建立時HashMap的預設初始化大小為16,這是應該是設計者的經驗之為。
  • 如果初始化時,給HashMap添加了初始容量大小,則會計算出比傳入值大的離著最近的2n + 1的值作為初始容量,見下:
    /** java.util.HashMap **/
    // 最大容量,值為正整數二進位制30位為1的,十進位制是1073741824
    static final int MAXIMUM_CAPACITY = 1 << 30;
    
    // 預設載荷係數
    static final float DEFAULT_LOAD_FACTOR = 0.75f;
    
    // 帶參構造器
    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }
    
    // 帶參構造器,只保留關鍵業務程式碼
    public HashMap(int initialCapacity, float loadFactor) {
        if (initialCapacity > MAXIMUM_CAPACITY) {
            initialCapacity = MAXIMUM_CAPACITY;
        }
        tableSizeFor(initialCapacity);
    }
    
    // 計算比傳入值更大的最接近的2^n + 1的值作為HashMap初始陣列大小
    static final int tableSizeFor(int cap) {
        // 以防傳入的值正好是2^n值
        int n = cap - 1;
        // 以下操作是將傳入值二進位制位中從左到右第一個1及後續位全部置1來求得極限2^n值
        n |= n >>> 1;
        n |= n >>> 2;
        n |= n >>> 4;
        n |= n >>> 8;
        n |= n >>> 16;
        // 如果極限值超過了2^30 + 1(也就是1 << 30),則用最大值,如果小於,則加1湊夠1*10^n
        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
    }
    
  • 這裡會涉及到一個為什麼使用2n + 1方式進行初始化大小問題,原因請見下方HashMap擴容。

2. 計算值存放的陣列索引

  1. 計算key的hash值
    使用迴圈每一個字元執行 31 * hash + 字元的十進位制UTF-16碼值,得出來的hash值
  2. 充分雜湊
    充分雜湊值的計算是基於hash值的基礎上進行充分雜湊實現的,因為key的hash值是int型別的,將高16位和低16位進行位異或操作。這樣既可以不太過複雜導致影響效能,又可以做到充分雜湊,這樣產生的雜湊值便具有高位和低位的性質所以才滿足充分雜湊的要求,減少雜湊碰撞,充分使用列表中的每一塊記憶體。
    /** java.util.HashMap **/
    // 充分雜湊
    static final int hash(Object key) {
        int h;
        // 計算出key的hash值(如上),然後將二進位制hash值的高16位進行無符號向右移動16位,之後這兩個int型別的值做異或操作
        // 其次這裡的.hashCode(),如果是個私有物件的話,需要重寫hashCode()和equals()方法,否則會取到物件的虛擬記憶體地址,下面有講
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }
    
  3. 計算HashMap中維護列表索引
    計算索引值從數學邏輯上來講,就是使用雜湊值除以列表的長度取得的餘數,不過因為雜湊值會非常的大,這樣計算會非常消耗資源,由於列表的長度是2n + 1,那麼只要計算2n及以內的數值作為下標即可,這樣就可以對2n和雜湊值使用位與,這樣得到的值肯定就在2n及以內,故推算出效率更高的計算方式 n % hash(key) == (n - 1) & hash(key)
    /** java.util.HashMap **/
    // 內部維護的列表
    transient Node<K, V>[] table;
    
    // .put(key, value)方法(只取部分程式碼)
    final V putVal(int hash, K key, V value, ...) {
        // 快取HashMap中的列表
        Node<K, V>[] tab;
        // 快取目標列表下標的物件
        Node<K, V> p;
        // 計算後列表的長度和目標索引值
        int n, i;
        // 判斷內部列表是否為空或者長度為0,滿足任一進行擴容(擴容方法在下面),並快取內部列表到tab,快取列表長度到n。注意:如果HashMap只初始化而未填充時,填充第一個鍵值時table==null,會進入這裡,同時重新計算臨界值threshold。
        if ((tab = table) == null || (n = tab.length) == 0) {
            n = (tab = resize()).length;
        }
        // 計算目標索引值,並判斷在列表中是否已有使用,如果沒有使用直接把key和value放進去,如果有了則需要解決Hash衝突
        if ((p = tab[i = (n - 1) & hash]) == null) {
            tab[i] = newNode(hash, key, value, null);
        } else {
            // 當前列表索引的位置不為null,檢查當前key和put的key是否相等,相等則做更新操作。
            // 不相等的話需要檢查這個值是什麼型別的,如果是紅黑樹,則做樹的插入或者更新;如果不是那就是連結串列,遍歷連結串列是否有key一致的值,有則更新,無則新增,新增的時候需要檢查是否超過8個,超過8個就需要構建紅黑樹(Java 8後支援)。
            // 這裡面還有個問題,就是在判斷key是否一致的時候,使用的是.hash方法和.equals方法,如果key的型別是私有物件,沒有重寫equals和hashCode方法就會造成永遠無法對相同的物件進行更新或者獲取(因為獲取的是Object的hashCode方法,即虛擬記憶體地址),這樣大量的put會造成記憶體洩露,最後導致記憶體溢位(OOM)。
            // K k;
            // if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
        }
    }
    

3. HashMap擴容

  1. 為什麼要擴容(是不是有點智障的問題)
    • HashMap中維護著一個table(列表)、size(當前容量)、threshold(臨界值)和預設值為0.75的loadFactor(載荷係數)。一個HashMap實際的最大容量(threshold)實際上是根據 table.length(當前列表的長度) * 設定的載荷係數計算而出。
    • 這個0.75的載荷係數是在利用率和碰撞機率中較優的設定,更大的係數雖然利用率提高了,但是可能會增大碰撞機率導致儲存讀取效率降低;更小的係數雖然降低了碰撞機率,但是會造成記憶體的浪費,而且在擴容時需要重新計算每個鍵的hash值並重新放入列表中。
    • 在put方法中,最後計算size值的時候,和臨界值進行的對比,如果大於臨界值,則進行擴容。
      // put()->putVal,摘取部分程式碼
      final V putVal(int hash, K key, V value, ...) {
          // more code...
          if (++size > threshold) {
              resize();
          }
      }
      
  2. resize()方法
    resize方法就是對HashMap進行擴容的方法,主要是初始化table(第一次put時)、計算臨界值(第一次put時根據初始化值 * 載荷係數得出,以後擴大臨界值,直接使用 << 1,就相當於隨著table一起擴大了)、table中資料重新計算索引然後衝新添到列表中。
    // 只摘取部分方法
    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;
            }
            // 後續擴容時計算新陣列大小和臨界值,只需要將二進位制位向前推一下即可(在保證int不溢位的時候)
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                    oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; // double threshold
        }
        // 只有賦初始值的第一次put的時候才會走到這裡,因為初始化時計算的臨界值其實是列表的最大長度而不是真的臨界值
        else if (oldThr > 0) // initial capacity was placed in threshold
            newCap = oldThr;
        // 完全沒有賦初始值的第一次put走這裡,用的是預設大小列表長度16和臨界值12
        else {               // zero initial threshold signifies using defaults
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        // 賦初始值的第一次put會計算臨界值的具體數值,後續擴容在上方
        if (newThr == 0) {
            float ft = (float)newCap * loadFactor;
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                    (int)ft : Integer.MAX_VALUE);
        }
        // 重新給臨界值賦予正確的數值
        threshold = newThr;
        // 下面就是給列表擴容後重新計算hash值重新規劃
    
  3. 為什麼列表容量大小是2n + 1,擴容也要乘2呢
    • 首先因為計算鍵索引的公式是:(列表長度 - 1) & hash,這樣做的好處是效率高、離散度高碰撞率低、空間利用率高,算出來的直接就是索引值。
    • 列表長度-1正好是2n,二進位制中每一位都是1,和hash按位與出來的結果,不同資料不容易產生碰撞,例如:
      // 列表長度為16時,與數值8和9分別按位與
      1000 & 1111 = 1000
      1001 & 1111 = 1001
      // 列表長度為15時,與數值8和9分別按位與,則會產生碰撞
      1000 & 1110 = 1000
      1001 & 1110 = 1000
      
    • 因為最後一位永遠是0,造成0001、0011、0101、1001、1011、0111、1101這幾個索引無法存放資料,造成極大浪費。
    • 正因為如此,在多方面考慮下,選用了2n + 1來作為列表長度,正因如此,所以擴容的時候需要保證這個比例,就要以2的倍數來擴容。

4. HashMap解決Hash衝突時使用的連結串列和紅黑樹

  1. 產生衝突如何處理
    當HashMap的鍵產生Hash衝突時,一般使用連結串列來儲存衝突的資料,不過這樣查改的效率就會降低到O(n),因為連結串列需要從頭到位依次查詢,因為效率降低,所以Java8中,將單條超過連結串列長度閾值(8)且HashMap中資料個數大於等於64的連結串列轉換為紅黑樹,這樣查改效率就可以提升到\(O(log_2n)\)。但是因為紅黑樹的一個節點相當於連結串列兩個節點的大小,導致不會在一開始就使用紅黑樹。
    // 連結串列切換樹的最大長度
    static final int TREEIFY_THRESHOLD = 8;
    // 連結串列切換樹最小列表長度
    static final int MIN_TREEIFY_CAPACITY = 64;
    
    // 摘取部分程式碼
    final V putVal(int hash, K key, V value, ...) {
        // 這部分程式碼是table為空擴容和目標索引為null時操作,見上面計算索引程式碼
        // more...
        // 初始化一個"指標"e和key值
        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)
            // 紅黑樹更新或新增成功後會返回null
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        // 這裡是連結串列操作
        else {
            // 遍歷連結串列p的時候統計著有多少個
            for (int binCount = 0; ; ++binCount) {
                // 因為p是連結串列物件,所以要遍歷p,e就是連結串列的下一個,如果下一個為空,則將資料填充進去
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    // 如果當前連結串列的個數大於或者等於TREEIFY_THRESHOLD(因為從0開始,所以要減1)
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        // 連結串列轉為樹
                        treeifyBin(tab, hash);
                    break;
                }
                // 如果遍歷的以後發現連結串列中的key和put的key相同,那就是更新操作
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                // 如果不想等也不為null的時候,因為e指向下一個,那麼p就需要等於e,然後再次迴圈的時候,e就指向下下個
                p = e;
            }
        }
        // 如果找到了需要填充的地方,把新值填充進去就可以了,除了TreeNode
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    
    // 連結串列轉樹也有條件的,必須列表長度大於64的時候,否則就是擴容列表(精簡程式碼)
    final void treeifyBin(Node<K,V>[] tab, int hash) {
        int n;
        if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
            resize();
    }
    
  2. 紅黑樹
    主要涉及到二叉查詢樹和2-3查詢樹,詳見大紅本
    1. 滿足條件
      • 紅連結均為左連結
      • 沒有任何一個節點同時和兩條紅連結相連
      • 樹為完美黑色平衡,即任意空連結到根結點的路徑上黑連結數量相同
    2. 優點
      • 將紅連結畫平,空連結到根結點距離相同;紅連結合併,就得到一個平衡的2-3查詢樹。所以紅黑樹就是將二叉查詢樹中簡潔高效的查詢方法和2-3樹中搞笑的平衡插入演算法相結合
    3. 紅連結標識:指向當前節點的連結是紅色的,當前節點就標識為red。
    4. 旋轉
      如果遇到紅色右連結或者兩條連續紅連結,則需要進行旋轉。
      1. 左旋轉是將右連結轉到左邊,將連結向父節點的當前節點的指標移動到右子樹,然後將當前節點作為右子樹的左子樹,然後當前節點的右子樹承接原右子樹的左子節點。
      2. 右旋轉就是左旋轉相反的操作。
    5. 插入節點
      每次插入一個節點,就需要標記新增節點為red。正是因為新增的節點是red,就可能產生紅色右連結或者連續紅連結,此時就需要通過旋轉和顏色轉換來實現平衡。
    6. 顏色轉換
      當節點的左右節點都是紅連結,就需要將此兩紅連結轉為黑連結,然後將節點連結的父節點置紅。如果是根節點連線了兩紅連結,那麼轉換的時候,樹的高度會加一(因為樹的根節點總是黑色的)。
    7. 總結:
      • 如果右子節點是紅色,左子節點是黑色,進行左旋轉。
      • 如果左子節點是紅色,且它的左子節點也是紅色,則進行右旋轉。
      • 如果左右子節點都是紅色,進行顏色轉換,高度+1。
  3. 連結串列轉樹
    無需贅述,就是一個LinkedList。
  4. 樹轉連結串列
    當樹的節點少於等於6的時候,會將樹重新轉化為連結串列。

5. 為什麼一定要重寫equals和hashCode方法

  1. hashCode方法主要用於將entity放入HashMap、HashSet等框架中,放入時校驗規律為:
    • 兩物件相等,hashCode一定相等;hashCode相等,但物件不一定相等。
    • 兩物件不等,hashCode不一定不等;hashCode不等,兩物件一定不等。
  2. 不重寫equals方法,兩個物件相等比較的就是虛擬記憶體地址;不重寫hashCode方法,計算出來的就是物件的虛擬記憶體地址的hashCode。這對於HashMap和HashSet中存放、修改、讀取資料是否準確來說是至關重要的。不重寫,資料就可能無法讀取、修改和錯誤的存放。導致記憶體洩露以至於記憶體溢位產生OOM問題。
  3. HashMap中如何使用到
    • put方法:先對key獲取hashCode索引值,然後根據索引找到Entry物件,然後使用equals對key值進行對比,對比成功再寫入或更新value值。
    • get方法:和put方法一樣,只不過最後的寫入操作變為讀取value操作。
    • 一般使用的HashMap和HashSet方法,都會以字串或者數值的形式來作為key值,此時因為這兩種都有官方的方法進行重寫equals和hashCode,不用擔心會出問題。最主要的是以自定義物件作為key值的場景時需要注意重寫這兩個方法,否則獲取的都是記憶體地址,無法正常使用!