1. 程式人生 > 程式設計 >Java HashSet(雜湊集),HashMap(雜湊對映)的簡單介紹

Java HashSet(雜湊集),HashMap(雜湊對映)的簡單介紹

簡介

本篇將簡單講解Java集合框架中的HashSet與HashMap。

雜湊集(HashSet)

快速入門

  • 底層原理:動態陣列加單向連結串列或紅黑樹。JDK 1.8之後,當連結串列長度超過閾值8時,連結串列將轉換為紅黑樹。
  • 查閱HashSet的原始碼,可以看到HashSet的底層是HashMap,HashSet相當於只用了HashMap鍵Key的部分,當需要進行新增元素操作時,其值Value始終為常量PRESENT = new Object()。以下為HashSet的程式碼片段:
private transient HashMap<E,Object> map;

public HashSet() {
 map = new HashMap<>();
}

public boolean add(E e) {
 return map.put(e,PRESENT)==null;
}

public Iterator<E> iterator() {
  return map.keySet().iterator();
}
  • 上面說到,在JDK 1.8之後,當連結串列長度超過閾值8時,連結串列將轉為紅黑樹;當連結串列長度小於6時,紅黑樹重新轉為連結串列。那麼為什麼閾值是8呢?
  • 閾值定義為8,符合數學概率論上的泊松分佈Poisson。根據泊松分佈,一個桶bucket是很難被填滿達到長度8的。
  • 一旦用於儲存資料的連結串列長度達到閾值8,則很大的可能是該HashSet所使用的雜湊函式效能不佳、或存在惡意程式碼向集中添加了很多具有相同雜湊碼的值,此時轉為平衡二叉樹可以提高效能。

散列表

  • 連結串列LinkedList、陣列Array或陣列列表ArrayList都有一個共同的缺點:根據值查詢元素速度慢。一旦存放的資料較多,查詢速度將十分緩慢。
  • 如果應用中開發者不在意元素的排列順序,此時推薦使用的資料結構為散列表。散列表用於快速查詢物件。
  • 使用散列表的關鍵是物件必須具備一個雜湊碼,通過物件內HashCode()方法即可計算得到物件的雜湊碼。一般情況下,不同資料的物件將產生不同的雜湊碼。
  • 下表顯示了使用String類中hashCode()方法成的雜湊碼:
字串 雜湊碼
"Lee" 76268
"lee" 107020
"eel" 100300
  • 在Java中,散列表HashTable使用動態陣列加連結串列或紅黑樹的形式實現。
  • 動態陣列中的每個位置被稱為桶bucket。要想查詢元素位於散列表中的位置,需要首先計算元素的雜湊碼,然後與桶的總數取餘,所得到的結果就是儲存這個元素的桶的索引。
  • 假設動態陣列為table,物件a的雜湊碼為hashCode,則元素將存放在table的索引為hashCode % table.size(),通常將該索引值成為雜湊值,它與雜湊碼是不一樣的。

Java HashSet(雜湊集),HashMap(雜湊對映)的簡單介紹

  • 例如,如果某個物件的雜湊碼為76268,並且有128個桶,那麼這個物件應該儲存在第108號桶中,因為76268%128=108。
  • 如果在這個桶中沒有其他的元素,此時將元素直接插入到桶中即可;但如果桶已經被填充,這種現象被稱為雜湊衝突hash collision。發生雜湊衝突,需要將新物件與桶中的所有物件進行比較,檢視這個物件是否已經存在。
  • 此時如果雜湊碼合理地隨機分佈(可以理解為雜湊函式hashCode()合理),桶的數目也足夠大,需要比較的次數就會很少。
  • 在Java 8中,桶滿時會從連結串列變為平衡二叉樹。如果選擇的雜湊函式不好,會產生很多衝突,或者如果有惡意程式碼試圖在散列表中填充多個有相同雜湊碼的值,這樣改為平衡二叉樹能提高效能。
  • 如果需要更多地控制散列表的效能,可以指定一個初始的桶數。桶數是指用於收集具有相同雜湊值的桶的數目。如果要插入到散列表中的元素太多,就會增加衝突數量,降低檢索的效能。
  • 如果大致知道最終會有多少個元素要插入到散列表中,就可以設定桶數。通常,將桶數設定為預計元素個數的75%~150%。有些研究人員認為:最好將桶數設定為一個素數,以防止鍵的聚集。不過,對此並沒有確鑿的證據。
  • 標準類庫使用的桶數是2的次冪,預設值為16(為表大小提供的任何值,都將自動轉換為2的下一個冪值)。
  • 但是,並不總能夠知道需要儲存多少個元素,也有可能最初的估計過低。如果散列表太滿,就需要再雜湊rehashed。如果要對散列表再雜湊,就需要建立一個桶數更多的表,並將所有元素插入到這個新表中,然後丟棄原來的表。裝填因子load factor可以確定何時對散列表進行再雜湊。
  • 例如,如果裝填因子是0.75(預設值),說明表中已經填滿了75%以上,就會自動再雜湊,新表的桶數是原來的兩倍。對於大多數程式來說,裝填因子為0.75是合理的。
  • 散列表可以用於實現很多重要的資料結構,其中最簡單的是集型別。集是沒有重複元素的元素集合,其中add方法首先會在這個集中查詢要新增的物件,如果不存在,就新增這個物件。
  • Java集合框架提供了一個HashSet類,它實現了基於散列表的集。可以用add方法新增元素。contains方法已經被重新定義,用來快速查詢某個元素是否已經在集中。它只檢視一個桶中的元素,而不必檢視集合中所有元素。
  • 雜湊集迭代器將依次訪問所有的桶,由於雜湊將元素分散在表中,所以會以一種看起來隨機的順序訪問元素。只有不關心集合中元素的順序時,才應該使用HashSet。
  • 而HashSet的實現基於HashMap,在隨後會對HashMap的部分原始碼進行分析,以瞭解其初始容量及擴容機制。

雜湊對映(HashMap)

快速入門

  • 底層原理:動態陣列加單向連結串列或紅黑樹。JDK 1.8之後,當連結串列長度超過閾值8時,連結串列將轉換為紅黑樹。預設散列表中的動態陣列長度為16,雜湊因子為0.75,擴容閾值為12。
  • 擴容機制:擴容後散列表中的動態陣列長度,變為舊動態陣列的兩倍。擴容閾值為雜湊因子與動態陣列長度的乘積。
  • 以下為HashMap中代表單向連結串列結構的Node<K,V>類,與代表紅黑樹結構的TreeNode<K,V>類。
// HashMap.java原始碼
// 基於單向連結串列的用於儲存資料的物件
static class Node<K,V> implements Map.Entry<K,V> {
 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;
 }
 ...
}

// 基於紅黑樹的用於儲存資料的物件
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
 TreeNode<K,V> parent; // red-black tree links
 TreeNode<K,V> left;
 TreeNode<K,V> right;
 TreeNode<K,V> prev; // needed to unlink next upon deletion
 boolean red;
 TreeNode(int hash,V val,V> next) {
  super(hash,key,val,next);
 }
 ...
}

二次雜湊

雜湊對映HashMap只對鍵進行雜湊,與鍵關聯的值不進行雜湊。以下為HashMap中的部分原始碼:

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

static final int hash(Object key) {
 int h;
 return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
  • 所有使用put()方法存入HashMap中的鍵值對,都會在內部呼叫putVal()進行新增元素操作。putVal()方法的第一個引數則需要提供key的雜湊碼。
  • 此處並沒有直接使用key.hashCode(),而是使用了HashMap中的hash()方法對key進行二次雜湊。二次雜湊可以理解為在物件呼叫它的雜湊函式之後,再進行一次額外的計算。二次雜湊有助於獲得更好的雜湊碼。

擴容機制

  • HashMap中的動態陣列初始容量為16,預設的雜湊因子為0.75,即在容量到達16 * 0.75 = 12時,會對動態陣列進行擴容處理,上限容量被稱為threshold。
  • 擴容後的HashMap,其動態陣列容量為原來的2倍,由於雜湊因子不會改變,因此threshold也為原來的2倍。
  • 以下為HashMap中resize()、putVal()的原始碼:
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;
  }
  else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
     oldCap >= DEFAULT_INITIAL_CAPACITY)
   newThr = oldThr << 1; // double threshold
 }
 else if (oldThr > 0) // initial capacity was placed in threshold
  newCap = oldThr;
 else {    // zero initial threshold signifies using defaults
  newCap = DEFAULT_INITIAL_CAPACITY;
  newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
 }
 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;
      }
      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;
}

final V putVal(int hash,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; // 第一個resize()是進行動態陣列Node<K,V>[]初始化的操作,不會進行擴容
 if ((p = tab[i = (n - 1) & hash]) == null)
  tab[i] = newNode(hash,null);
 else {
  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)
   e = ((TreeNode<K,V>)p).putTreeVal(this,tab,hash,value);
  else {
   for (int binCount = 0; ; ++binCount) {
    if ((e = p.next) == null) {
     p.next = newNode(hash,null);
     if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
      treeifyBin(tab,hash);
     break;
    }
    if (e.hash == hash &&
     ((k = e.key) == key || (key != null && key.equals(k))))
     break;
    p = e;
   }
  }
  if (e != null) { // existing mapping for key
   V oldValue = e.value;
   if (!onlyIfAbsent || oldValue == null)
    e.value = value;
   afterNodeAccess(e);
   return oldValue;
  }
 }
 ++modCount;
 // 當HashMap中元素數量大於閾值threshold,則會進行擴容resize()操作
 if (++size > threshold)
  resize();
 afterNodeInsertion(evict);
 return null;
}
  • 通過原始碼可以知道,HashMap在初始化的時候並不會立即為動態陣列分配記憶體,直到呼叫putVal()為止,才會在putVal()中呼叫resize()方法初始化動態陣列。
  • 動態陣列Node<K,V>[]將在resize()中完成初始化或擴容的操作。
  • 其中有關初始化的關鍵程式碼為:
newCap = DEFAULT_INITIAL_CAPACITY; // DEFAULT_INITIAL_CAPACITY = 1 << 4,即預設大小為16。
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); // threshold = newCap * 0.75,即預設為12。
  • 有關於擴容的關鍵程式碼為:
if (oldCap > 0) { // 當動態陣列擁有預設容量時,如果再次呼叫resize(),則一定會進行擴容操作
 if (oldCap >= MAXIMUM_CAPACITY) {
  threshold = Integer.MAX_VALUE;
  return oldTab;
 } else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) { // 容量為原來的2倍
  newThr = oldThr << 1; // 閾值為原來的2倍
 }
}

總結

以上為所有關於HashSet、HashMap的粗略介紹。
如果希望瞭解更多的內容,可以前往JDK閱讀原始碼。

以上就是Java HashSet(雜湊集),HashMap(雜湊對映)的簡單介紹的詳細內容,更多關於Java HashSet和HashMap的資料請關注我們其它相關文章!