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併發程式設計的藝術》迷你書