JAVA8 HashMap源碼介紹

一、前言

本文對jdk8的HashMap一些常見的部分代碼進行了介紹,並沒有介紹所有的方法,如果對其他的方法感興趣的同學可以自己去閱讀以下源碼或自行百度

 

二、HashMap結構概覽

以下是HashMap的數據結構:

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

 

 

三、HashMap源碼閱讀

3.1 類的繼承關係

可以看到HashMap繼承自AbstractMap,實現了Serializable和Cloneable。這裏筆者不打算介紹AbstractMap的源碼,因爲閱讀之後發現比較簡單,有興趣的園友們可以自行去看看,其中的keyset()values()方法與HashMap中的類似。Serializable接口表示HashMap實現了的序列化,Cloneable接口表示可以合法的調用clone(),如果不實現該接口而調用clone,會報CloneNotSupportedException。

 

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,後面的4、8、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()爲例):

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

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

 

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

 

 

四、總結

下面是一些關於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桶結構,但是當桶太大的時候,會轉換成紅黑樹,可以增加在桶太大情況下訪問效率,但是大多數情況下,結構都以桶的形式存在,所以檢查是否存在樹節點會增加訪問方法的時間

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章