【JDK1.8】JDK1.8集合源碼閱讀——HashMap

1、前言

筆者以前看過一篇關於jdk1.8的HashMap源碼分析,做者對裏面的解讀很到位,將代碼裏關鍵的地方都說了一遍,值得推薦。筆者也會順着他的順序來閱讀一遍,除了基礎的方法外,還添加了不少其餘補充內容。html


2、HashMap結構概覽

如下是HashMap的數據結構:java

集合圖

不一樣於以前的jdk的實現,1.8採用的是數組+鏈表+紅黑樹,在鏈表過長的時候能夠經過轉換成紅黑樹提高訪問性能。大多數狀況下,結構都以鏈表的形式存在,因此檢查是否存在樹節點會增長訪問方法的時間,可是相較於其優勢來講仍是能夠接受的。特別說明:樹結構裏還有不少指針引用,這裏沒畫出來。將在後續的LinkedHashMap和TreeMap中講解node


3、HashMap源碼閱讀

3.1 類的繼承關係

HashMapStruct

能夠看到HashMap繼承自AbstractMap,實現了Serializable和Cloneable。這裏筆者不打算介紹AbstractMap的源碼,由於閱讀以後發現比較簡單,有興趣的園友們能夠自行去看看,其中的keyset()values()方法與HashMap中的相似。Serializable接口表示HashMap實現了的序列化,Cloneable接口表示能夠合法的調用clone(),若是不實現該接口而調用clone,會報CloneNotSupportedException。關於Map接口的解析,能夠看我以前的文章編程


3.2 HashMap的成員變量

下面咱們先來看一下HashMap裏面的成員變量:數組

//默認初始化map的容量:16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
//map的最大容量:2^30
static final int MAXIMUM_CAPACITY = 1 << 30;
//默認的填充因子:0.75,能較好的平衡時間與空間的消耗
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//將鏈表(桶)轉化成紅黑樹的臨界值
static final int TREEIFY_THRESHOLD = 8;
//將紅黑樹轉成鏈表(桶)的臨界值
static final int UNTREEIFY_THRESHOLD = 6;
//轉變成樹的table的最小容量,小於該值則不會進行樹化
static final int MIN_TREEIFY_CAPACITY = 64;
//上圖所示的數組,長度老是2的冪次
transient Node<K,V>[] table;
//map中的鍵值對集合
transient Set<Map.Entry<K,V>> entrySet;
//map中鍵值對的數量
transient int size;
//用於統計map修改次數的計數器,用於fail-fast拋出ConcurrentModificationException
transient int modCount;
//大於該閾值,則從新進行擴容,threshold = capacity(table.length) * load factor
int threshold;
//填充因子
final float loadFactor;

​ 能夠看到,HashMap裏是以Node節點數組的形式存放數據的,Node數據結構比較簡單,這裏咱們也來看一下:數據結構

//Entry接口在筆者的總章裏有介紹。
static class Node<K,V> implements Map.Entry<K,V> {
  // key & value 的 hash值
  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;
  }

  public final K getKey()        { return key; }
  public final V getValue()      { return value; }
  public final String toString() { return key + "=" + value; }

  public final int hashCode() {
    return Objects.hashCode(key) ^ Objects.hashCode(value);
  }

  public final V setValue(V newValue) {
    V oldValue = value;
    value = newValue;
    return oldValue;
  }

  public final boolean equals(Object o) {
    if (o == this)
      return true;
    if (o instanceof Map.Entry) {
      Map.Entry<?,?> e = (Map.Entry<?,?>)o;
      if (Objects.equals(key, e.getKey()) &&
          Objects.equals(value, e.getValue()))
        return true;
    }
    return false;
  }
}

因爲比較簡單,這裏就不詳細介紹了哈。

併發

3.3 HashMap的構造函數

3.3.1 無參數構造函數

public HashMap() {
  //其餘成員變量也都是默認的
  this.loadFactor = DEFAULT_LOAD_FACTOR;
}


3.3.2 傳初始化容量(建議若是知道要使用的map容量,都使用這種)

public HashMap(int initialCapacity) {
  this(initialCapacity, DEFAULT_LOAD_FACTOR);
}


3.3.3 傳初始化容量以及填充因子

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;
  //tableSizeFor()是用來將初始化容量轉化大於輸入參數且最近的2的整數次冪的數,好比initialCapacity = 7,那麼轉化後就是8。
  this.threshold = tableSizeFor(initialCapacity);
}

​ tableSizeFor(),將初始化容量轉化大於或等於最接近輸入參數的2的整數次冪的數:函數

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

|是或運算符,好比說0100 | 0011 = 0111>>>是無符號右移,忽略符號位,空位都以0補齊,好比說0100 >>> 2 = 0001,如今來講一下這麼作的目的:

源碼分析

​ 首先>>>|的操做的目的就是把n從最高位的1如下都填充爲1,以010011爲例,010011 >>> 1 = 001001,而後001001 | 010011 = 011011,而後再把011011無符號右移兩位:011011 >>> 2 = 000110,而後000110 | 011011 = 011111,後面的四、八、16計算過程就都省去了,int類型爲32位,因此計算到16就所有結束了,最終獲得的就是最高位及其如下的都爲1,這樣就能保證獲得的結果確定大於或等於原來的n且爲奇數,最後再加上1,那麼確定是:大於且最接近輸入值的2的整數次冪的數


​ 那麼爲何要先cap - 1呢,咱們能夠先思考如下,若是傳進來的自己就是2的整數冪次,好比說01000,10進制是8,那麼若是不減,獲得的結果就是16,顯然不對。因此先減1的目的是cap若是剛好是2的整數次冪,那麼返回的也是自己。


​ 合起來獲得這個tableSizeFor()方法的目的:返回大於或等於最接近輸入參數的2的整數次冪的數。另外,筆者特地回去看了JDK1.7的源碼,發現1.7用的是roundUpToPowerOf2()方法,裏面用到裏了>>以及減操做,性能上來講確定還1.8的高。佈局


3.3.4 傳map轉化爲HashMap的構造函數

public HashMap(Map<? extends K, ? extends V> m) {
  this.loadFactor = DEFAULT_LOAD_FACTOR;
  putMapEntries(m, false);
}

​ putMapEntries():

//evict表示是否是初始化map,false表示是初始化map
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
  //獲取m中鍵值對的數量
  int s = m.size();
  if (s > 0) {
    if (table == null) {
      //計算map的容量,鍵值對的數量 = 容量 * 填充因子
      float ft = ((float)s / loadFactor) + 1.0F;
      int t = ((ft < (float)MAXIMUM_CAPACITY) ?
               (int)ft : MAXIMUM_CAPACITY);
      //若是容量大於了閾值,則從新計算閾值。
      if (t > threshold)
        threshold = tableSizeFor(t);
    }
    //若是table已經有,且鍵值對數量大於了閾值,進行擴容
    else if (s > threshold)
      resize();
    for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
      K key = e.getKey();
      V value = e.getValue();
      putVal(hash(key), key, value, false, evict);
    }
  }
}


3.4 HashMap中重要的方法解析

3.4.1 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;
  //先是判斷一通table是否爲空以及根據hash找到存放的table數組的下標,並賦值給臨時變量
  if ((tab = table) != null && (n = tab.length) > 0 &&
      (first = tab[(n - 1) & hash]) != null) {
    //老是先檢查數組下標第一個節點是否知足key,知足則返回
    if (first.hash == hash &&
        ((k = first.key) == key || (key != null && key.equals(k))))
      return first;
    //若是第一個與key不相等,則循環查看桶
    if ((e = first.next) != null) {
      //檢查是否爲樹節點,是的話採用樹節點的方法來獲取對應的key的值
      if (first instanceof TreeNode)
        return ((TreeNode<K,V>)first).getTreeNode(hash, key);
      //do-while循環判斷,直到找到爲止
      do {
        if (e.hash == hash &&
            ((k = e.key) == key || (key != null && key.equals(k))))
          return e;
      } while ((e = e.next) != null);
    }
  }
  return null;
}

​ 能夠發現源碼做者很喜歡在判斷的時候賦值,不知道這個是否是個編程的好習慣。!?(・_・;?


3.4.2 put()

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

/**
 * Implements Map.put and related methods
 * @param hash key的hash值
 * @param key
 * @param value
 * @param onlyIfAbsent 若是爲true,則在有值的時候不會更新
 * @param evict false表示在建立map
 */
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;
  //若是tab對應的數組位置爲空,則建立新的node,並指向它
  if ((p = tab[i = (n - 1) & hash]) == null)
    // newNode方法就是返回Node:return new Node<>(hash, key, value, next);
    tab[i] = newNode(hash, key, value, null); 
  else {
    Node<K,V> e; K k;
    //若是比較hash值和key的值都相等,說明要put的鍵值對已經在裏面,賦值給e
    if (p.hash == hash &&
        ((k = p.key) == key || (key != null && key.equals(k))))
      e = p;
    //若是p節點是樹節點,則執行插入樹的操做
    else if (p instanceof TreeNode)
      e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
    //不是樹節點且數組中第一個也不是,則在桶中查找
    else {
      for (int binCount = 0; ; ++binCount) {
        //找到了最後一個都不知足的話,則在最後插入節點。注意這裏的e = p.next,賦值兼具判斷都在if裏了
        if ((e = p.next) == null) 
          p.next = newNode(hash, key, value, null);
          //以前field說明中的,若是桶中的數量大於樹化閾值,則轉化成樹,第一個是-1
          if (binCount >= TREEIFY_THRESHOLD - 1)
            treeifyBin(tab, hash);
          break;
        }
        //在桶中找到了對應的key,賦值給e,退出循環
        if (e.hash == hash &&
            ((k = e.key) == key || (key != null && key.equals(k))))
          break;
        //沒有找到,則繼續向下一個節點尋找
        p = e;
      }
    }
    //上面循環中找到了e,則根據onlyIfAbsent是否爲true來決定是否替換舊值
    if (e != null) {
      V oldValue = e.value;
      if (!onlyIfAbsent || oldValue == null)
        e.value = value;
      //鉤子函數,用於給LinkedHashMap繼承後使用,在HashMap裏是空的
      afterNodeAccess(e);
      return oldValue;
    }
  }
  //修改計數器+1
  ++modCount;
  //實際大小+1, 若是大於閾值,從新計算並擴容
  if (++size > threshold)
    resize();
  //鉤子函數,用於給LinkedHashMap繼承後使用,在HashMap裏是空的
  afterNodeInsertion(evict);
  return null;
}

​ 能夠看到真正執行put的是裏面的putVal()方法。裏面的插入邏輯一步步下來仍是很清晰的。


3.4.3 resize()

​ 經過調用resize()對map進行擴容操做。

final Node<K,V>[] resize() {
  Node<K,V>[] oldTab = table;
  //擴容/縮容前的容量
  int oldCap = (oldTab == null) ? 0 : oldTab.length;
  //舊的閾值
  int oldThr = threshold;
  int newCap, newThr = 0;
  //說明以前已經初始化過map
  if (oldCap > 0) {
    //達到了最大的容量,則將閾值設爲最大,而且返回舊的table
    if (oldCap >= MAXIMUM_CAPACITY) {
      threshold = Integer.MAX_VALUE;
      return oldTab;
    }
    //若是兩倍的舊容量小於最大的容量且舊容量大於等於默認初始化容量,則舊的閾值也擴大兩倍。
    //oldCap << 1,其實就是*2的意思。
    else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
             oldCap >= DEFAULT_INITIAL_CAPACITY)
      newThr = oldThr << 1; // double threshold
  }
  //舊容量爲0且舊閾值大於0,則賦值給新的容量(應該是針對初始化的時候指定了其容量的構造函數出現的這種狀況)
  else if (oldThr > 0)
    newCap = oldThr;
  //這種狀況就是調用無參數的構造函數
  else {               
    newCap = DEFAULT_INITIAL_CAPACITY;
    newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
  }
  // 新閾值爲0,則經過:新容量*填充因子 來計算
  if (newThr == 0) {
    float ft = (float)newCap * loadFactor;
    newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
              (int)ft : Integer.MAX_VALUE);
  }
  threshold = newThr;
  //根據新的容量來初始化table,並賦值給table
  @SuppressWarnings({"rawtypes","unchecked"})
  Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
  table = newTab;
  //若是舊的table裏面有存放節點,則初始化給新的table
  if (oldTab != null) {
    for (int j = 0; j < oldCap; ++j) {
      Node<K,V> e;
      //將下標爲j的數組賦給臨時節點e
      if ((e = oldTab[j]) != null) {
        //清空
        oldTab[j] = null;
        //若是該節點沒有指向下一個節點,則直接經過計算hash和新的容量來肯定新的下標,並指向e
        if (e.next == null)
          newTab[e.hash & (newCap - 1)] = e;
        //若是爲樹節點,按照樹節點的來拆分
        else if (e instanceof TreeNode)
          ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
        //e還有其餘的節點,將該桶拆分紅兩份(不必定均分)
        else {
          //loHead是拆分後的,鏈表的頭部,tail爲尾部
          Node<K,V> loHead = null, loTail = null;
          Node<K,V> hiHead = null, hiTail = null;
          Node<K,V> next;
          do {
            next = e.next;
            //根據e的hash值和舊的容量作位與運算是否爲0來拆分,注意以前是 e.hash & (oldCap - 1)
            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;
}

​ 能夠看到,resize()方法對整個數組以及桶進行了遍歷,極其耗費性能,因此再次強調在咱們明確知道map要用的容量的時候,使用指定初始化容量的構造函數

​ 在resize前和resize後的元素佈局以下:
集合圖

再次強調一下,拆分後的結果不必定是均分,要看你存的值


3.4.4 remove()

public V remove(Object key) {
  Node<K,V> e;
  //與以前的put、get同樣,remove也是調用其餘的方法
  return (e = removeNode(hash(key), key, null, false, true)) == null ?
    null : e.value;
}
/**
 * Implements Map.remove and related methods
 *
 * @param hash key的hash值
 * @param key 
 * @param value 與下面的matchValue結合,若是matchValue爲false,則忽略value
 * @param matchValue 爲true,則判斷是否與value相等
 * @param movable 主要跟樹節點的remove有關,爲false,則不移動其餘的樹節點
 */
final Node<K,V> removeNode(int hash, Object key, Object value,
                               boolean matchValue, boolean movable) {
  Node<K,V>[] tab; Node<K,V> p; int n, index;
  //老規矩,仍是先判斷table是否爲空之類的邏輯,注意賦值操做
  if ((tab = table) != null && (n = tab.length) > 0 &&
      (p = tab[index = (n - 1) & hash]) != null) {
    Node<K,V> node = null, e; K k; V v;
    //對下標節點進行判斷,若是相同,則賦給臨時節點
    if (p.hash == hash &&
        ((k = p.key) == key || (key != null && key.equals(k))))
      node = p;
    else if ((e = p.next) != null) {
      //爲樹節點,則按照樹節點的操做來進行查找並返回
      if (p instanceof TreeNode)
        node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
      else {
        //do-while循環查找
        do {
          if (e.hash == hash &&
              ((k = e.key) == key ||
               (key != null && key.equals(k)))) {
            node = e;
            break;
          }
          p = e;
        } while ((e = e.next) != null);
      }
    }
    //若是找到了key對應的node,則進行刪除操做
    if (node != null && (!matchValue || (v = node.value) == value ||
                         (value != null && value.equals(v)))) {
      //爲樹節點,則進行樹節點的刪除操做
      if (node instanceof TreeNode)
        ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
      //若是p == node,說明該key所在的位置爲數組的下標位置,因此下標位置指向下一個節點便可
      else if (node == p)
        tab[index] = node.next;
      //不然的話,key在桶中,p爲node的上一個節點,p.next指向node.next便可
      else
        p.next = node.next;
      //修改計數器
      ++modCount;
      --size;
      //鉤子函數,與上同
      afterNodeRemoval(node);
      return node;
    }
  }
  return null;
}

​ 這裏提到裏的remove的話,確定與之聯想到的就是其拋出ConcurrentModificationException。舉個栗子:

Map<String, Integer> map = new HashMap<>();
map.put("GoddessY", 1);
map.put("Joemsu", 2);
for (String a : map.keySet()) {
  if ("GoddessY".equals(a)) {
    map.remove(a);
  }
}

​ 這裏咱們再來看一下其在循環過程當中拋出該異常的源碼(以keySet()爲例):

public Set<K> keySet() {
  Set<K> ks;
  return (ks = keySet) == null ? (keySet = new KeySet()) : ks;
}

final class KeySet extends AbstractSet<K> {
  public final Iterator<K> iterator()     { return new KeyIterator(); }
}

final class KeyIterator extends HashIterator implements Iterator<K> {
  public final K next() { return nextNode().key; }
}

abstract class HashIterator {
  //指向下一個節點
  Node<K,V> next;
  //指向當前節點
  Node<K,V> current;
  //迭代前的修改次數
  int expectedModCount;
  //當前下標
  int index;

  HashIterator() {
    //注意這裏:將修改計數器值賦給expectedModCount
    expectedModCount = modCount;
    //下面一頓初始化。。。
    Node<K,V>[] t = table;
    current = next = null;
    index = 0;
    //在table數組中找到第一個下標不爲空的節點。
    if (t != null && size > 0) {
      do {} while (index < t.length && (next = t[index++]) == null);
    }
  }
  //經過判斷next是否爲空,來決定是否hasNext()
  public final boolean hasNext() {
    return next != null;
  }
  //這裏就是拋出ConcurrentModificationException的地方
  final Node<K,V> nextNode() {
    Node<K,V>[] t;
    Node<K,V> e = next;
    //若是modCount與初始化傳進去的modCount不一樣,則拋出併發修改的異常
    if (modCount != expectedModCount)
      throw new ConcurrentModificationException();
    if (e == null)
      throw new NoSuchElementException();
    //若是一個下標對應的桶空了,則接着在數組裏找其餘下標不爲空的桶,同時賦值給next
    if ((next = (current = e).next) == null && (t = table) != null) {
      do {} while (index < t.length && (next = t[index++]) == null);
    }
    return e;
  }
  //使用迭代器的remove不會拋出ConcurrentModificationException異常,緣由以下:
  public final void remove() {
    Node<K,V> p = current;
    if (p == null)
      throw new IllegalStateException();
    if (modCount != expectedModCount)
      throw new ConcurrentModificationException();
    current = null;
    K key = p.key;
    removeNode(hash(key), key, null, false, false);
    //注意這裏:對expectedModCount從新進行了賦值。因此下次比較的時候仍是相同的
    expectedModCount = modCount;
  }
}

​ 那麼咱們再回到上面的測試代碼,咱們再來看一個有趣的問題,若是我把"GoddessY".equals(a)換成"Joemsu".equals(a)還會拋出異常嗎?有興趣的園友們能夠試一試,找出緣由可以加深對源碼的理解!(づ。◕‿‿◕。)づ


3.4.5 treeifyBin()

​ 最後咱們再來看一下將桶變成紅黑樹的代碼吧,具體的樹結構之類的大概會放在TreeMap裏講解,這裏不仔細介紹。

final void treeifyBin(Node<K,V>[] tab, int hash) {
  int n, index; Node<K,V> e;
  //這裏MIN_TREEIFY_CAPACITY派上了用場,及時單個桶數量達到了樹化的閾值,總的容量沒到,也不會進行樹化
  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 {
      // 返回樹節點 return new TreeNode<>(p.hash, p.key, p.value, next);
      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);
  }
}


4、總結

​ 下面是一些關於HashMap的特徵:

  1. 容許key和value爲null

  2. 基本上和Hashtable(已棄用)類似,除了非同步以及鍵值能夠爲null

  3. 不能保證順序

  4. 訪問集合的時間與map的容量和鍵值對的大小成比例

  5. 影響HashMap性能的兩個變量:填充因子和初始化容量

  6. 一般來講,默認的填充由於0.75是一個時間和空間消耗的良好平衡。較高的填充由於減小了空間的消耗,可是增長了查找的時間

  7. 最好可以在建立HashMap的時候指定其容量,這樣能存儲效率比使其存儲空間不夠後自動增加更高。畢竟從新調整耗費性能

  8. 使用大量具備相同hashcode值的key,將下降hash表的表現,最好能實現key的comparable

  9. 注意hashmap是不一樣步的。若是要同步請使用Map m = Collections.synchronizedMap(new HashMap(...));

  10. 除了使用迭代器的remove方法外其的其餘方式刪除,都會拋出ConcurrentModificationException.

  11. map一般狀況下都是hash桶結構,可是當桶太大的時候,會轉換成紅黑樹,能夠增長在桶太大狀況下訪問效率,可是大多數狀況下,結構都以桶的形式存在,因此檢查是否存在樹節點會增長訪問方法的時間

    最後謝謝各位園友觀看,若是有描述不對的地方歡迎指正,與你們共同進步!

相關文章
相關標籤/搜索