一文让你彻底理解 Java HashMap
前言
Map 這樣的?Key Value?在軟件開發中是非常經典的結構,常用于在內存中存放數據。
本篇主要想討論 ConcurrentHashMap 這樣一個并發容器,在正式開始之前我覺得有必要談談 HashMap,沒有它就不會有后面的 ConcurrentHashMap。
HashMap
眾所周知 HashMap 底層是基于?數組 + 鏈表?組成的,不過在 jdk1.7 和 1.8 中具體實現稍有不同。
Base 1.7
1.7 中的數據結構圖:
?
?
先來看看 1.7 中的實現。
?
?
這是 HashMap 中比較核心的幾個成員變量;看看分別是什么意思?
重點解釋下負載因子:
由于給定的 HashMap 的容量大小是固定的,比如默認初始化:
public HashMap() {this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR); } 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;threshold = initialCapacity;init(); }給定的默認容量為 16,負載因子為 0.75。Map 在使用過程中不斷的往里面存放數據,當數量達到了?16 * 0.75 = 12?就需要將當前 16 的容量進行擴容,而擴容這個過程涉及到 rehash、復制數據等操作,所以非常消耗性能。
因此通常建議能提前預估 HashMap 的大小最好,盡量的減少擴容帶來的性能損耗。
根據代碼可以看到其實真正存放數據的是
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
這個數組,那么它又是如何定義的呢?
?
?
Entry 是 HashMap 中的一個內部類,從他的成員變量很容易看出:
- key 就是寫入時的鍵。
- value 自然就是值。
- 開始的時候就提到 HashMap 是由數組和鏈表組成,所以這個 next 就是用于實現鏈表結構。
- hash 存放的是當前 key 的 hashcode。
知曉了基本結構,那來看看其中重要的寫入、獲取函數:
put 方法
public V put(K key, V value) {if (table == EMPTY_TABLE) {inflateTable(threshold);}if (key == null)return putForNullKey(value);int hash = hash(key);int i = indexFor(hash, table.length);for (Entry<K,V> e = table[i]; e != null; e = e.next) {Object k;if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {V oldValue = e.value;e.value = value;e.recordAccess(this);return oldValue;}}modCount++;addEntry(hash, key, value, i);return null; }- 判斷當前數組是否需要初始化。
- 如果 key 為空,則 put 一個空值進去。
- 根據 key 計算出 hashcode。
- 根據計算出的 hashcode 定位出所在桶。
- 如果桶是一個鏈表則需要遍歷判斷里面的 hashcode、key 是否和傳入 key 相等,如果相等則進行覆蓋,并返回原來的值。
- 如果桶是空的,說明當前位置沒有數據存入;新增一個 Entry 對象寫入當前位置。
當調用 addEntry 寫入 Entry 時需要判斷是否需要擴容。
如果需要就進行兩倍擴充,并將當前的 key 重新 hash 并定位。
而在?createEntry?中會將當前位置的桶傳入到新建的桶中,如果當前桶有值就會在位置形成鏈表。
get 方法
再來看看 get 函數:
public V get(Object key) {if (key == null)return getForNullKey();Entry<K,V> entry = getEntry(key);return null == entry ? null : entry.getValue(); } final Entry<K,V> getEntry(Object key) {if (size == 0) {return null;}int hash = (key == null) ? 0 : hash(key);for (Entry<K,V> e = table[indexFor(hash, table.length)];e != null;e = e.next) {Object k;if (e.hash == hash &&((k = e.key) == key || (key != null && key.equals(k))))return e;}return null; }- 首先也是根據 key 計算出 hashcode,然后定位到具體的桶中。
- 判斷該位置是否為鏈表。
- 不是鏈表就根據?key、key 的 hashcode?是否相等來返回值。
- 為鏈表則需要遍歷直到 key 及 hashcode 相等時候就返回值。
- 啥都沒取到就直接返回 null 。
Base 1.8
不知道 1.7 的實現大家看出需要優化的點沒有?
其實一個很明顯的地方就是:
當 Hash 沖突嚴重時,在桶上形成的鏈表會變的越來越長,這樣在查詢時的效率就會越來越低;時間復雜度為?O(N)。因此 1.8 中重點優化了這個查詢效率。
1.8 HashMap 結構圖:
?
?
先來看看幾個核心的成員變量:
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16 /*** The maximum capacity, used if a higher value is implicitly specified* by either of the constructors with arguments.* MUST be a power of two <= 1<<30.*/ static final int MAXIMUM_CAPACITY = 1 << 30; /*** The load factor used when none specified in constructor.*/ static final float DEFAULT_LOAD_FACTOR = 0.75f; static final int TREEIFY_THRESHOLD = 8; transient Node<K,V>[] table; /*** Holds cached entrySet(). Note that AbstractMap fields are used* for keySet() and values().*/ transient Set<Map.Entry<K,V>> entrySet; /*** The number of key-value mappings contained in this map.*/ transient int size;和 1.7 大體上都差不多,還是有幾個重要的區別:
- TREEIFY_THRESHOLD?用于判斷是否需要將鏈表轉換為紅黑樹的閾值。
- HashEntry 修改為 Node。
Node 的核心組成其實也是和 1.7 中的 HashEntry 一樣,存放的都是?key value hashcode next等數據。
再來看看核心方法。
put 方法
?
?
看似要比 1.7 的復雜,我們一步步拆解:
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;if ((tab = table) != null && (n = tab.length) > 0 &&(first = tab[(n - 1) & hash]) != null) {if (first.hash == hash && // always check first node((k = first.key) == key || (key != null && key.equals(k))))return first;if ((e = first.next) != null) {if (first instanceof TreeNode)return ((TreeNode<K,V>)first).getTreeNode(hash, key);do {if (e.hash == hash &&((k = e.key) == key || (key != null && key.equals(k))))return e;} while ((e = e.next) != null);}}return null; }get 方法看起來就要簡單許多了。
- 首先將 key hash 之后取得所定位的桶。
- 如果桶為空則直接返回 null 。
- 否則判斷桶的第一個位置(有可能是鏈表、紅黑樹)的 key 是否為查詢的 key,是就直接返回 value。
- 如果第一個不匹配,則判斷它的下一個是紅黑樹還是鏈表。
- 紅黑樹就按照樹的查找方式返回值。
- 不然就按照鏈表的方式遍歷匹配返回值。
從這兩個核心方法(get/put)可以看出 1.8 中對大鏈表做了優化,修改為紅黑樹之后查詢效率直接提高到了?O(logn)。
但是 HashMap 原有的問題也都存在,比如在并發場景下使用時容易出現死循環。
final HashMap<String, String> map = new HashMap<String, String>(); for (int i = 0; i < 1000; i++) {new Thread(new Runnable() {@Overridepublic void run() {map.put(UUID.randomUUID().toString(), "");}}).start(); }但是為什么呢?簡單分析下。
看過上文的還記得在 HashMap 擴容的時候會調用?resize()?方法,就是這里的并發操作容易在一個桶上形成環形鏈表;這樣當獲取一個不存在的 key 時,計算出的 index 正好是環形鏈表的下標就會出現死循環。
如下圖:
?
?
?
?
遍歷方式
還有一個值得注意的是 HashMap 的遍歷方式,通常有以下幾種:
Iterator<Map.Entry<String, Integer>> entryIterator = map.entrySet().iterator();while (entryIterator.hasNext()) {Map.Entry<String, Integer> next = entryIterator.next();System.out.println("key=" + next.getKey() + " value=" + next.getValue());}Iterator<String> iterator = map.keySet().iterator();while (iterator.hasNext()){String key = iterator.next();System.out.println("key=" + key + " value=" + map.get(key));}強烈建議使用第一種 EntrySet 進行遍歷。
第一種可以把 key value 同時取出,第二種還得需要通過 key 取一次 value,效率較低。
簡單總結下 HashMap:無論是 1.7 還是 1.8 其實都能看出 JDK 沒有對它做任何的同步操作,所以并發會出問題,甚至 1.7 中出現死循環導致系統不可用(1.8 已經修復死循環問題)。因此 JDK 推出了專項專用的 ConcurrentHashMap ,該類位于?java.util.concurrent?包下,專門用于解決并發問題。
堅持看到這里的朋友算是已經把 ConcurrentHashMap 的基礎已經打牢了,下面正式開始分析。ConcurrentHashMap
ConcurrentHashMap 同樣也分為 1.7 、1.8 版,兩者在實現上略有不同。
Base 1.7
先來看看 1.7 的實現,下面是他的結構圖:
?
?
如圖所示,是由 Segment 數組、HashEntry 組成,和 HashMap 一樣,仍然是數組加鏈表。
它的核心成員變量:
/*** Segment 數組,存放數據時首先需要定位到具體的 Segment 中。*/ final Segment<K,V>[] segments; transient Set<K> keySet; transient Set<Map.Entry<K,V>> entrySet;Segment 是 ConcurrentHashMap 的一個內部類,主要的組成如下:
static final class Segment<K,V> extends ReentrantLock implements Serializable {private static final long serialVersionUID = 2249069246763182397L;// 和 HashMap 中的 HashEntry 作用一樣,真正存放數據的桶transient volatile HashEntry<K,V>[] table;transient int count;transient int modCount;transient int threshold;final float loadFactor;}看看其中 HashEntry 的組成:
?
?
和 HashMap 非常類似,唯一的區別就是其中的核心數據如 value ,以及鏈表都是?Volatile?修飾的,保證了獲取時的可見性。
原理上來說:ConcurrentHashMap 采用了分段鎖技術,其中 Segment 繼承于 ReentrantLock。不會像 HashTable 那樣不管是 put 還是 get 操作都需要做同步處理,理論上 ConcurrentHashMap 支持 CurrencyLevel (Segment 數組數量)的線程并發。每當一個線程占用鎖訪問一個 Segment 時,不會影響到其他的 Segment。
下面也來看看核心的?put get?方法。
put 方法
public V put(K key, V value) {Segment<K,V> s;if (value == null)throw new NullPointerException();int hash = hash(key);int j = (hash >>> segmentShift) & segmentMask;if ((s = (Segment<K,V>)UNSAFE.getObject // nonvolatile; recheck(segments, (j << SSHIFT) + SBASE)) == null) // in ensureSegments = ensureSegment(j);return s.put(key, hash, value, false); }首先是通過 key 定位到 Segment,之后在對應的 Segment 中進行具體的 put。
final V put(K key, int hash, V value, boolean onlyIfAbsent) {HashEntry<K,V> node = tryLock() ? null :scanAndLockForPut(key, hash, value);V oldValue;try {HashEntry<K,V>[] tab = table;int index = (tab.length - 1) & hash;HashEntry<K,V> first = entryAt(tab, index);for (HashEntry<K,V> e = first;;) {if (e != null) {K k;if ((k = e.key) == key ||(e.hash == hash && key.equals(k))) {oldValue = e.value;if (!onlyIfAbsent) {e.value = value;++modCount;}break;}e = e.next;}else {if (node != null)node.setNext(first);elsenode = new HashEntry<K,V>(hash, key, value, first);int c = count + 1;if (c > threshold && tab.length < MAXIMUM_CAPACITY)rehash(node);elsesetEntryAt(tab, index, node);++modCount;count = c;oldValue = null;break;}}} finally {unlock();}return oldValue; }雖然 HashEntry 中的 value 是用 volatile 關鍵詞修飾的,但是并不能保證并發的原子性,所以 put 操作時仍然需要加鎖處理。
首先第一步的時候會嘗試獲取鎖,如果獲取失敗肯定就有其他線程存在競爭,則利用?scanAndLockForPut()?自旋獲取鎖。
?
?
?
?
再結合圖看看 put 的流程。
get 方法
public V get(Object key) {Segment<K,V> s; // manually integrate access methods to reduce overheadHashEntry<K,V>[] tab;int h = hash(key);long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null &&(tab = s.table) != null) {for (HashEntry<K,V> e = (HashEntry<K,V>) UNSAFE.getObjectVolatile(tab, ((long)(((tab.length - 1) & h)) << TSHIFT) + TBASE);e != null; e = e.next) {K k;if ((k = e.key) == key || (e.hash == h && key.equals(k)))return e.value;}}return null; }get 邏輯比較簡單:
只需要將 Key 通過 Hash 之后定位到具體的 Segment ,再通過一次 Hash 定位到具體的元素上。
由于 HashEntry 中的 value 屬性是用 volatile 關鍵詞修飾的,保證了內存可見性,所以每次獲取時都是最新值。
ConcurrentHashMap 的 get 方法是非常高效的,因為整個過程都不需要加鎖。
Base 1.8
1.7 已經解決了并發問題,并且能支持 N 個 Segment 這么多次數的并發,但依然存在 HashMap 在 1.7 版本中的問題。
那就是查詢遍歷鏈表效率太低。因此 1.8 做了一些數據結構上的調整。
首先來看下底層的組成結構:
?
image
看起來是不是和 1.8 HashMap 結構類似?
其中拋棄了原有的 Segment 分段鎖,而采用了?CAS + synchronized?來保證并發安全性。
?
image
也將 1.7 中存放數據的 HashEntry 改為 Node,但作用都是相同的。
其中的?val next?都用了 volatile 修飾,保證了可見性。
put 方法
重點來看看 put 函數:
?
image
- 根據 key 計算出 hashcode 。
- 判斷是否需要進行初始化。
- f?即為當前 key 定位出的 Node,如果為空表示當前位置可以寫入數據,利用 CAS 嘗試寫入,失敗則自旋保證成功。
- 如果當前位置的?hashcode == MOVED == -1,則需要進行擴容。
- 如果都不滿足,則利用 synchronized 鎖寫入數據。
- 如果數量大于?TREEIFY_THRESHOLD?則要轉換為紅黑樹。
get 方法
?
image
- 根據計算出來的 hashcode 尋址,如果就在桶上那么直接返回值。
- 如果是紅黑樹那就按照樹的方式獲取值。
- 就不滿足那就按照鏈表的方式遍歷獲取值。
總結
看完了整個 HashMap 和 ConcurrentHashMap 在 1.7 和 1.8 中不同的實現方式相信大家對他們的理解應該會更加到位。
其實這塊也是面試的重點內容,通常的套路是:
這一串問題相信大家仔細看完都能懟回面試官。
除了面試會問到之外平時的應用其實也蠻多,像之前談到的?Guava 中 Cache?的實現就是利用 ConcurrentHashMap 的思想。
同時也能學習 JDK 作者大牛們的優化思路以及并發解決方案。
總結
以上是生活随笔為你收集整理的一文让你彻底理解 Java HashMap的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Java注解解析-搭建自己的注解处理器(
- 下一篇: java美元兑换,(Java实现) 美元