JDK源碼分析(5)之 HashMap 相關


HashMap作為我們最常用的數據類型,當然有必要了解一下他內部是實現細節。相比於 JDK7 在JDK8 中引入了紅黑樹以及hash計算等方面的優化,使得 JDK8 中的HashMap效率要高於以往的所有版本,本文會詳細介紹相關的優化,但是主要還是寫 JDK8 的源碼。

一、整體結構

1. 類定義

public class HashMap<K,V> extends AbstractMap<K,V>
  implements Map<K,V>, Cloneable, Serializable {}

hashmap

可以看到HashMap是完全基於Map接口實現的,其中AbstractMapMap接口的骨架實現,提供了Map接口的最小實現。
HashMap看名字也能猜到,他是基於哈希表實現的(數組+鏈表+紅黑樹):

hashmap結構

2. 構造函數和成員變量

public HashMap(int initialCapacity)
public HashMap()
public HashMap(Map<? extends K, ? extends V> m)

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

HashMap一共有四個構造函數,其主要作用就是初始化loadFactorthreshold兩個參數:

  • threshold:擴容的閾值,當放入的鍵值對大於這個閾值的時候,就會發生擴容;
  • loadFactor:負載系數,用於控制閾值的大小,即threshold = table.length * loadFactor;默認情況下負載系數等於0.75,當它值越大時:哈希桶空余的位置越少,空間利用率越高,同時哈希沖突也就越嚴重,效率也就越低;相反它值越小時:空間利用率越低,效率越高;而0.75是對於空間和效率的一個平衡,通常情況下不建議修改;

但是對於上面構造函數當中this.threshold = tableSizeFor(initialCapacity);,這里的閾值並沒有乘以負載系數,是因為在構造函數當中哈希桶table[]還沒有初始化,在往里put數據的時候才會初始化,而tableSizeFor是為了得到大於等於initialCapacity的最小的2的冪;

transient Node<K,V>[] table;            // 哈希桶
transient Set<Map.Entry<K,V>> entrySet; // 映射關系Set視圖
transient int size;                     // 鍵值對的數量
transient int modCount;                 // 結構修改次數,用於實現fail-fast機制

哈希桶的結構如下:

static class Node<K,V> implements Map.Entry<K,V> {
  final int hash;       // 用於尋址,避免重復計算
  final K key;
  V value;
  Node<K,V> next;
  ...
  public final int hashCode() {
    return Objects.hashCode(key) ^ Objects.hashCode(value);
  }
}

其中Node<K,V> next還有一個TreeNode子類用於實現紅黑樹,需要注意的是這里的hashCode()所計算的hash值只用於在遍歷的時候獲取hash值,並非尋址所用hash;

二、Hash表

既然是Hash表,那么最重要的肯定是尋址了,在HashMap中采用的是除留余數法,即table[hash % length],但是在現代CPU中求余是最慢的操作,所以人們想到一種巧妙的方法來優化它,即length為2的指數冪時,hash % length = hash & (length-1),所以在構造函數中需要使用tableSizeFor(int cap)來調整初始容量;

/**
 * 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后面全是0
  • 有效位都是1的二進制加1,就可以得到2的冪

以33為例,如圖:

tableSizeFor

因為int是4個字節32位,所以最多只需要將高位的16位與低位的16位做或運算就可以得到2的冪,而int n = cap - 1;是為了避免cap本身就是2的冪的情況;這個算是真是厲害,看了很久才看明白,實在汗顏。

**計算 hash **

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

這里重新計算hash是因為在hash & (length-1)計算下標的時候,實際只有hash的低位參與的運算容易產生hash沖突,所以用異或是高位的16位也參與運算,以減小hash沖突,要理解這里首先要明白,

  • & 操作之后只會保留下都是1的有效位
  • length-1(2的n次方-1)實際上就是n和1
  • & 操作之后hash所保留下來的也只有低位的n個有效位,所以實際只有hash的低位參與了運算

具體如圖所示:

hashMap哈希算法例圖

三、重要方法講解

對於Map而言最重要的當然是GetPut等操作了,所以下面將介紹與之相關的操作;

1. put方法

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

/**
 * Implements Map.put and related methods * * @param hash hash for key
 * @param key the key
 * @param value the value to put
 * @param onlyIfAbsent if true, don't change existing value
 * @param evict if false, the table is in creation mode.
 * @return previous value, or null if none
 */
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
  Node<K,V>[] tab; Node<K,V> p; int n, i;
  // 如果沒有初始化哈希桶,就使用resize初始化
  if ((tab = table) == null || (n = tab.length) == 0)
    n = (tab = resize()).length;
  // 如果hash對應的哈希槽是空的,就直接放入
  if ((p = tab[i = (n - 1) & hash]) == null)
    tab[i] = newNode(hash, key, value, null);
  else {
    Node<K,V> e; K k;
    // 如果已經存在key,就替換舊值
    if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
      e = p;
    // 如果已經是樹節點,就用putTreeVal遍歷樹賦值
    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);
          // 如果鏈表長度大於8,則轉換為紅黑樹
          if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
            treeifyBin(tab, hash);
          break;
        }
        // 找到key對應的節點則跳出遍歷
        if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
          break;
        p = e;
      }
    }
    // e是最后指向的節點,如果不為空,說明已經存在key,則替換舊的value
    if (e != null) { // existing mapping for key
      V oldValue = e.value;
      if (!onlyIfAbsent || oldValue == null)
        e.value = value;
      afterNodeAccess(e);
      return oldValue;
    }
  }
  // 新增節點時結構改變modCount加1
  ++modCount;
  if (++size > threshold)
    resize();
  afterNodeInsertion(evict);
  return null;
}

具體過程如圖所示:

hashMap put方法執行流程圖

2. resize方法

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) {
    // 如果hash桶已經完成初始化,並且已達最大容量,則直接返回
    if (oldCap >= MAXIMUM_CAPACITY) {
      threshold = Integer.MAX_VALUE;
      return oldTab;
    }
    // 如果擴大2倍沒有超過最大容量,則擴大兩倍
    else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY)
      newThr = oldThr << 1; // double threshold
  }
  // 如果threshold已經初始化,則初始化容量為threshold
  else if (oldThr > 0)      // initial capacity was placed in threshold
    newCap = oldThr;
  // 如果threshold和哈希桶都沒有初始化,則使用默認值
  else {                    // zero initial threshold signifies using defaults
    newCap = DEFAULT_INITIAL_CAPACITY;
    newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
  }
  // 重新計算threshold
  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;
            }
            // 節點重新放置后位置+oldCap
            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
}

上面的擴容過程需要注意的是,因為哈希桶長度總是2的冪,所以在擴大兩倍之后原來的節點只可能在原位置或者原位置+oldCap,具體判斷是通過(e.hash & oldCap) == 0實現的;

  • 之前將了 & 操作只保留了都是1的有效位
  • oldCap 是2的n次方,實際也就是在n+1的位置為1,其余地方為0
  • 因為擴容是擴大2倍,實際上也就是在hash上取了 n+1位,那么就只需要判斷多取的第n+1位是否為0

如圖所示:

hashmap擴容后位置判斷

3. get方法

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

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))))
      return first;
    if ((e = first.next) != null) {
      if (first instanceof TreeNode)
        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;
}

相較於其他方法get方法就要簡單很多了,只是用hash取到對應的hash槽,在依次遍歷即可。

4. clone方法

public Object clone() {
  HashMap<K,V> result;
  try {
    result = (HashMap<K,V>)super.clone();
  } catch (CloneNotSupportedException e) {
    // this shouldn't happen, since we are Cloneable
    throw new InternalError(e);
  }
  result.reinitialize();
  result.putMapEntries(this, false);
  return result;
}

對於clone方法這里有一個需要注意的地方,result.putMapEntries(this, false),這里在put節點的時候是用的this,所以這只是淺復制,會影響原map,所以在使用的時候需要注意一下;

至於其他方法還有很多,但大致思路都是一致的,大家可以在看一下源碼。

四、HashMap不同版本對比

1. hash均勻的時候使用get

Number Of Records Java 5 Java 6 Java 7 Java 8
10,000 4 ms 3 ms 4 ms 2 ms
100,000 7 ms 6 ms 8 ms 4 ms
1,000,000 99 ms 15 ms 14 ms 13 ms

2. hash不均勻的時候使用get

Number Of Records Java 5 Java 6 Java 7 Java 8
10,000 197 ms 154 ms 132 ms 15 ms
100,000 30346 ms 18967 ms 19131 ms 177 ms
1,000,000 3716886 ms 2518356 ms 2902987 ms 1226 ms
10,000,000 OOM OOM OOM 5775 ms

3. hash均勻的時候使用put

Number Of Records Java 5 Java 6 Java 7 Java 8
10,000 17 ms 12 ms 13 ms 10 ms
100,000 45 ms 31 ms 34 ms 46 ms
1,000,000 384 ms 72 ms 66 ms 82 ms
10,000,000 4731 ms 944 ms 1024 ms 99 ms

4. hash不均勻的時候使用put

Number Of Records Java 5 Java 6 Java 7 Java 8
10,000 211 ms 153 ms 162 ms 10 ms
100,000 29759 ms 17981 ms 17653 ms 93 ms
1,000,000 3527633 ms 2509506 ms 2902987 ms 333 ms
10,000,000 OOM OOM OOM 3970 ms

從以上對比可以看到 JDK8 的 HashMap 無論 hash 是否均勻效率都要好得多,這里面hash算法的改良功不可沒,並且因為紅黑樹的引入使得它在hash不均勻甚至在所有key的hash都相同的情況,任然表現良好;
另外這里我數據我是摘至 Performance Improvement for HashMap in Java 8,里面還有更詳細的圖表,大家有興趣可以看一下;

總結

  1. 擴容需要重排所有節點特別損耗性能,所以估算map大小並給定一個合理的負載系數,就顯得尤為重要了。
  2. HashMap 是線程不安全的。
  3. 雖然 JDK8 中引入了紅黑樹,將極端hash的情況影響降到了最小,但是從上面的對比還是可以看到,一個好的hash對性能的影響仍然十分重大,所以寫一個好的hashCode()也非常重要。

參考

https://tech.meituan.com/java_hashmap.html
https://blog.csdn.net/fan2012huan/article/details/51097331
https://www.nagarro.com/en/blog/post/24/performance-improvement-for-hashmap-in-java-8


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM