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

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

1、總體結構

1. 類定義

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

hashmap

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

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兩個參數:node

  • 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;安全

2、Hash表

既然是Hash表,那麼最重要的確定是尋址了,在HashMap中採用的是除留餘數法,即table[hash % length],可是在現代CPU中求餘是最慢的操做,因此人們想到一種巧妙的方法來優化它,即length爲2的指數冪時,hash % length = hash & (length-1),因此在構造函數中須要使用tableSizeFor(int cap)來調整初始容量;app

/**
 * 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爲例,如圖:post

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哈希算法例圖

3、重要方法講解

對於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,因此在使用的時候須要注意一下;

至於其餘方法還有不少,但大體思路都是一致的,你們能夠在看一下源碼。

4、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
http://www.javashuo.com/article/p-uahonyxg-me.html
https://www.nagarro.com/en/blog/post/24/performance-improvement-for-hashmap-in-java-8

相關文章
相關標籤/搜索