1. 程式人生 > >Java集合類之HashMap原理小結

Java集合類之HashMap原理小結

1. 認識HashMap

HashMap是用來儲存key-value鍵值對的資料結構。
當我們建立HashMap的時候,如果不指定任何引數,它會為我們建立一個初始容量為16,負載因子為0.75的HashMap (load factor,記錄數/陣列長度)。當loadFactor達到0.75或指定值的時候,HashMap的總容量自動擴充套件一倍。

它的底層採用Entry陣列來儲存所有的key-value對。當需要儲存一個Entry物件時,會根據Hash演算法(key的hashCode值)來決定其儲存位置;當需要取出一個Entry時,也會根據Hash演算法找到其儲存位置,直接取出該Entry。由此可見:HashMap之所以能快速存、取它所包含的Entry,完全類似於現實生活中母親從小教我們的:不同的東西要放在不同的位置,需要時才能快速找到它。

如果兩個Entry的key的hashCode()返回值相同,那它們的儲存位置相同。如果這兩個Entry的key通過equals()比較返回true,新新增Entry的value將覆蓋集合中原有Entry的value,但key不會覆蓋。如果這兩個Entry的key通過equals()比較返回false,新新增的Entry將與集合中原有Entry形成Entry鏈,而且新新增的Entry位於Entry鏈的頭部。我們來看下圖:


注:圖片源自http://www.admin10000.com/doc...

2. 小結

HashMap底層實現:陣列+連結串列+紅黑樹
通常,只使用Entry陣列存放鍵值對,key的hashcode()值決定它的存放位置,equals()方法決定最終的值。
如果hash演算法設計的足夠好,是不會發生碰撞衝突的,但實際中肯定不存在這麼完美的事情。
當key的hashcode()相同,equals()方法返回不同時,會在相同的位置上形成一個連結串列,當連結串列長度大於8的時候,會轉化成紅黑樹,連結串列的查詢的時間複雜度為O(n),而紅黑樹為O(lgn),會提高查詢的效能。
當Entry陣列不足以容納更多的元素的時候,以負載因子為0.75,陣列長度為20來說,當陣列元素數到達15的時候,會自動觸發一次resize操作,會把舊的資料對映到新的雜湊表,陣列擴容到原來的2倍。

resize在多執行緒環境下,可能產生條件競爭
因為如果兩個執行緒都發現HashMap需要重新調整大小了,它們會同時試著調整大小。
在調整大小的過程中,儲存在連結串列中的元素的次序會反過來,因為移動到新的bucket位置的時候,HashMap並不會將元素放在連結串列的尾部,而是放在頭部,這是為了避免尾部遍歷(tail traversing,否則針對key的hashcode相同的Entry每次新增還要定位到尾節點)。
如果條件競爭發生了,可能出現環形連結串列。之後當我們get(key)操作時,就有可能發生死迴圈。
另外,既然都有併發的問題了,我們就該使用ConcurrentHashMap了。

不使用HashTable的原因
它使用synchronized來保證執行緒安全,會鎖住整個雜湊表。線上程競爭激烈的情況下效率非常低下,當一個執行緒訪問HashTable的同步方法時,其它執行緒訪問HashTable的同步方法只能進入阻塞或輪詢狀態。

3. ConcurrentHashMap

核心:採用segment分段鎖來保護不同段的資料,是執行緒安全且高效的。
當多執行緒訪問容器裡不同段的資料時,執行緒間就不會存在鎖競爭,從而可以有效提高併發訪問效率。

ConcurrentHashMap類圖

初始化中除了initialCapacity,loadFactor引數,還有一個重要的concurrency level,它決定了segment陣列的長度(預設是16,長度需要是2的N次方,與採用的雜湊演算法有關)。
每次get/put操作都會通過hash演算法定位到一個segment,然後再通過hash演算法定位到具體的entry。
get操作是不需要加鎖的,因為get方法裡將要使用的共享變數都定義成了volatile。

定義成volatile的變數,能夠線上程之間保持可見性,能夠被多執行緒同時讀,並且保證不會讀到過期的值,但是隻能被單執行緒寫(有一種情況可以被多執行緒寫,就是寫入的值不依賴於原值,像直接set值就可以,而i++這樣的操作就是非執行緒安全的)。

put方法在操作共享變數時必須加鎖,首先會定位到segment,然後在segment裡進行插入操作。
size方法,需要統計每個segment中count變數的值,然後加和。但是我們拿到的count值累加前可能已經發生了變化,那麼統計結果就不準確了。所以最安全的做法就是統計size的時候把所有segment的put,remove和clear方法全部鎖住,但是這種做法顯然非常低效。
ConcurrentHashMap的做法是先嚐試2次通過不鎖住segment的方式來統計各個segment大小,如果統計過程中,容器的count發生了變化,再採用加鎖的方式統計所有segment的大小(put、remove和clear操作元素前都會將modCount進行加1,所以可以通過在統計前後比較modCount是否發生變化來得知容器大小是否發生了變化)。

關於ConcurrentHashMap的迭代
使用了不同於傳統集合的快速失敗迭代器的另外一種迭代方式,我們稱為弱一致迭代器。在這種迭代方式中,當iterator被建立後集合再發生改變就不再是丟擲ConcurrentModificationException,取而代之是在改變時new新的資料從而不影響原有的資料,iterator完成後再將頭指標替換為新的資料,這樣iterator執行緒可以使用原來老的資料,而寫執行緒也可以併發的完成改變。更重要的,這保證了多個執行緒併發執行的連續性和擴充套件性,是效能提升的關鍵。

4. 拓展補充

4.1 LinkedList

HashMap使用了連結串列來儲存相同位置的Entry元素,接下來我們參考jdk原始碼實現一個簡化版的LinkedList,程式碼如下:

/**
 * 手動實現一個連結串列
 * Date: 7/24/2016
 * Time: 3:45 PM
 *
 * @author xiaodong.fan
 */
public class MyLinkedList<E> implements Iterable<E> {

  int size = 0;
  int modCount = 0;
  Node<E> first;
  Node<E> last;

  /**
   * 新增元素
   * @param e
   */
  public void add(E e) {
    final Node<E> l = last;
    final Node<E> newNode = new Node<>(l, e, null);
    last = newNode;
    if (l == null)
      first = newNode;
    else
      l.next = newNode;
    size++;
    modCount++;
  }

  /**
   * 移除元素
   * @param index
   * @return E
   */
  public E remove(int index) {
    checkElementIndex(index);

    Node<E> x = node(index);
    final E element = x.item;
    final Node<E> next = x.next;
    final Node<E> prev = x.prev;

    if (prev == null) {
      first = next;
    } else {
      prev.next = next;
      x.prev = null;
    }

    if (next == null) {
      last = prev;
    } else {
      next.prev = prev;
      x.next = null;
    }

    x.item = null;
    size--;
    modCount++;
    return element;
  }

  /**
   * 修改元素
   * @param index
   * @param element
   * @return E
   */
  public E set(int index, E element) {
    checkElementIndex(index);
    Node<E> x = node(index);
    E oldVal = x.item;
    x.item = element;
    modCount++;
    return oldVal;
  }

  /**
   * 獲取元素
   * @param index
   * @return E
   */
  public E get(int index) {
    checkElementIndex(index);
    return node(index).item;
  }

  /**
   * 迭代元素
   * @return Iterator<E>
   */
  @Override
  public Iterator<E> iterator() {
    return new Itr();
  }

  /*************************私有方法****************************/
  // 資料節點
  private static class Node<E> {
    E item;
    Node<E> next;
    Node<E> prev;

    Node(Node<E> prev, E element, Node<E> next) {
      this.item = element;
      this.next = next;
      this.prev = prev;
    }
  }

  // 快速失敗迭代器
  private class Itr implements Iterator<E> {
    // 迭代當前位置
    int cursor = 0;

    // 上一個迭代位置
    int lastRet = -1;

    // 迭代過程中判斷是否有併發修改
    int expectedModCount = modCount;

    public boolean hasNext() {
      return cursor != size;
    }

    public E next() {
      checkForComodification();
      try {
        int i = cursor;
        E next = get(i);
        lastRet = i;
        cursor = i + 1;
        return next;
      } catch (IndexOutOfBoundsException e) {
        checkForComodification();
        throw new NoSuchElementException();
      }
    }

    public void remove() {
      if (lastRet < 0) {
        throw new IllegalStateException();
      }

      checkForComodification();
      try {
        MyLinkedList.this.remove(lastRet);
        if (lastRet < cursor) {
          cursor--;
        }
        lastRet = -1;
        expectedModCount = modCount;
      } catch (IndexOutOfBoundsException e) {
        throw new ConcurrentModificationException();
      }
    }

    final void checkForComodification() {
      if (modCount != expectedModCount)
        throw new ConcurrentModificationException();
    }
  }

  private Node<E> node(int index) {
    if (index < (size >> 1)) {
      Node<E> x = first;
      for (int i = 0; i < index; i++)
        x = x.next;
      return x;
    } else {
      Node<E> x = last;
      for (int i = size - 1; i > index; i--)
        x = x.prev;
      return x;
    }
  }

  private void checkElementIndex(int index) {
    if (!(index >= 0 && index < size))
      throw new IndexOutOfBoundsException("Index: "+index+", Size: "+size);
  }

}

4.2 實現LRU快取

LRU是Least Recently Used 的縮寫,翻譯過來就是“最近最少使用”,LRU快取就是使用這種原理實現,簡單的說就是快取一定量的資料,當超過設定的閾值時就把一些過期的資料刪除掉。那怎麼確定刪除哪條過期資料呢,採用LRU演算法實現的話就是將最老的資料刪掉。
LinkedHashMap自身已經實現了順序儲存,預設情況下是按照元素的新增順序儲存,也可以啟用按照訪問順序儲存(指定建構函式第3個引數為true即可),也就是最近讀取的資料放在最前面,最早讀取的資料放在最後面。它還有一個判斷是否刪除最老資料的方法,預設是返回false,即不刪除資料。所以我們可以使用LinkedHashMap很方便的實現LRU快取。程式碼如下:

/**
 * LRU快取(當容量達到最大值時,刪除最近最少使用的記錄)
 * @param <K>
 * @param <V>
 */
public class LRULinkedHashMap<K, V> extends LinkedHashMap<K, V> {
  
  private static final long serialVersionUID = 1L;
  private final int maxCapacity;
  private static final float DEFAULT_LOAD_FACTOR = 0.75f;
  private final Lock lock = new ReentrantLock();

  public LRULinkedHashMap(int maxCapacity) {
    super(maxCapacity, DEFAULT_LOAD_FACTOR, true);
    this.maxCapacity = maxCapacity;
  }

  @Override
  protected boolean removeEldestEntry(java.util.Map.Entry<K, V> eldest) {
    return size() > maxCapacity;
  }

  @Override
  public V get(Object key) {
    try {
      lock.lock();
      return super.get(key);
    } finally {
      lock.unlock();
    }
  }

  @Override
  public V put(K key, V value) {
    try {
      lock.lock();
      return super.put(key, value);
    } finally {
      lock.unlock();
    }
  }

  @Override
  public int size() {
    try {
      lock.lock();
      return super.size();
    } finally {
      lock.unlock();
    }
  }
}

5. 參考文章

HashMap的工作原理
LRU快取實現(Java)
Hashtable與ConcurrentHashMap區別
《java併發程式設計的藝術》迷你書