目錄
Map大家族的那點事兒
Map
Map是一種用於快速查找的數據結構,它以鍵值對的形式存儲數據,每一個鍵都是唯一的,且對應着一個值,如果想要查找Map中的數據,只需要傳入一個鍵,Map會對鍵進行匹配並返回鍵所對應的值,可以說Map其實就是一個存放鍵值對的集合。Map被各種編程語言廣泛使用,只不過在名稱上可能會有些混淆,像Python中叫做字典(Dictionary),也有些語言稱其爲關聯數組(Associative Array),但其實它們都是一樣的,都是一個存放鍵值對的集合。至於Java中經常用到的HashMap也是Map的一種,它被稱爲散列表,關於散列表的細節我會在本文中解釋HashMap的源碼時提及。
Java還提供了一種與Map密切相關的數據結構:Set,它是數學意義上的集合,特性如下:
-
無序性:一個集合中,每個元素的地位都是相同的,元素之間也都是無序的。不過Java中也提供了有序的Set,這點倒是沒有完全遵循。
-
互異性:一個集合中,任何兩個元素都是不相同的。
-
確定性:給定一個集合以及其任一元素,該元素屬於或者不屬於該集合是必須可以確定的。
很明顯,Map中的key就很符合這些特性,Set的實現其實就是在內部使用Map。例如,HashSet就定義了一個類型爲HashMap的成員變量,向HashSet添加元素a,等同於向它內部的HashMap添加了一個key爲a,value爲一個Object對象的鍵值對,這個Object對象是HashSet的一個常量,它是一個虛擬值,沒有什麼實際含義,源碼如下:
private transient HashMap<E,Object> map;
// Dummy value to associate with an Object in the backing Map
private static final Object PRESENT = new Object();
public boolean add(E e) {
return map.put(e, PRESENT)==null;
}
小插曲過後,讓我們接着說Map,它是JDK的一個頂級接口,提供了三種集合視圖(Collection Views):包含所有key的集合、包含所有value的集合以及包含所有鍵值對的集合,Map中的元素順序與它所返回的集合視圖中的元素的迭代順序相關,也就是說,Map本身是不保證有序性的,當然也有例外,比如TreeMap就對有序性做出了保證,這主要因爲它是基於紅黑樹實現的。
所謂的集合視圖就是由集合本身提供的一種訪問數據的方式,同時對視圖的任何修改也會影響到集合。好比Map.keySet()
返回了它包含的key的集合,如果你調用了Map.remove(key)
那麼keySet.contains(key)
也將返回false
,再比如說Arrays.asList(T)
可以把一個數組封裝成一個List,這樣你就可以通過List的API來訪問和操作這些數據,如下列示例代碼:
String[] strings = {"a", "b", "c"};
List<String> list = Arrays.asList(strings);
System.out.println(list.get(0)); // "a"
strings[0] = "d";
System.out.println(list.get(0)); // "d"
list.set(0, "e");
System.out.println(strings[0]); // "e"
是不是感覺很神奇,其實Arrays.asList()
只是將傳入的數組與Arrays
中的一個內部類ArrayList
(注意,它與java.util
包下的ArrayList
不是同一個)做了一個”綁定“,在調用get()
時會直接根據下標返回數組中的元素,而調用set()
時也會直接修改數組中對應下標的元素。相對於直接複製來說,集合視圖的優點是內存利用率更高,假設你有一個數組,又很想使用List的API來操作它,那麼你不用new一個ArrayList
以拷貝數組中的元素,只需要一點額外的內存(通過Arrays.ArrayList
對數組進行封裝),原始數據依然是在數組中的,並不會複製成多份。
Map接口規範了Map數據結構的通用API(也含有幾個用於簡化操作的default方法,default是JDK8的新特性,它是接口中聲明的方法的默認實現,即非抽象方法)並且還在內部定義了Entry接口(鍵值對的實體類),在JDK中提供的所有Map數據結構都實現了Map接口,下面爲Map接口的源碼(代碼中的註釋太長了,基本都是些實現的規範,爲了篇幅我就儘量省略了)。
package java.util;
import java.util.function.BiConsumer;
import java.util.function.BiFunction;
import java.util.function.Function;
import java.io.Serializable;
public interface Map<K,V> {
// 查詢操作
/**
* 返回這個Map中所包含的鍵值對的數量,如果大於Integer.MAX_VALUE,
* 則應該返回Integer.MAX_VALUE。
*/
int size();
/**
* Map是否爲空。
*/
boolean isEmpty();
/**
* Map中是否包含key,如果是返回true,否則false。
*/
boolean containsKey(Object key);
/**
* Map中是否包含value,如果是返回true,否則false。
*/
boolean containsValue(Object value);
/**
* 根據key查找value,如果Map不包含該key,則返回null。
*/
V get(Object key);
// 修改操作
/**
* 添加一對鍵值對,如果Map中已含有這個key,那麼新value將覆蓋掉舊value,
* 並返回舊value,如果Map中之前沒有這個key,那麼返回null。
*/
V put(K key, V value);
/**
* 刪除指定key並返回之前的value,如果Map中沒有該key,則返回null。
*/
V remove(Object key);
// 批量操作
/**
* 將指定Map中的所有鍵值對批量添加到當前Map。
*/
void putAll(Map<? extends K, ? extends V> m);
/**
* 刪除Map中所有的鍵值對。
*/
void clear();
// 集合視圖
/**
* 返回包含Map中所有key的Set,對該視圖的所有修改操作會對Map產生同樣的影響,反之亦然。
*/
Set<K> keySet();
/**
* 返回包含Map中所有value的集合,對該視圖的所有修改操作會對Map產生同樣的影響,反之亦然。
*/
Collection<V> values();
/**
* 返回包含Map中所有鍵值對的Set,對該視圖的所有修改操作會對Map產生同樣的影響,反之亦然。
*/
Set<Map.Entry<K, V>> entrySet();
/**
* Entry代表一對鍵值對,規範了一些基本函數以及幾個已實現的類函數(各種比較器)。
*/
interface Entry<K,V> {
K getKey();
V getValue();
V setValue(V value);
boolean equals(Object o);
int hashCode();
public static <K extends Comparable<? super K>, V> Comparator<Map.Entry<K,V>> comparingByKey() {
return (Comparator<Map.Entry<K, V>> & Serializable)
(c1, c2) -> c1.getKey().compareTo(c2.getKey());
}
public static <K, V extends Comparable<? super V>> Comparator<Map.Entry<K,V>> comparingByValue() {
return (Comparator<Map.Entry<K, V>> & Serializable)
(c1, c2) -> c1.getValue().compareTo(c2.getValue());
}
public static <K, V> Comparator<Map.Entry<K, V>> comparingByKey(Comparator<? super K> cmp) {
Objects.requireNonNull(cmp);
return (Comparator<Map.Entry<K, V>> & Serializable)
(c1, c2) -> cmp.compare(c1.getKey(), c2.getKey());
}
public static <K, V> Comparator<Map.Entry<K, V>> comparingByValue(Comparator<? super V> cmp) {
Objects.requireNonNull(cmp);
return (Comparator<Map.Entry<K, V>> & Serializable)
(c1, c2) -> cmp.compare(c1.getValue(), c2.getValue());
}
}
// 比較和hashing
/**
* 將指定的對象與此Map進行比較是否相等。
*/
boolean equals(Object o);
/**
* 返回此Map的hash code。
*/
int hashCode();
// 默認方法(非抽象方法)
/**
* 根據key查找value,如果該key不存在或等於null則返回defaultValue。
*/
default V getOrDefault(Object key, V defaultValue) {
V v;
return (((v = get(key)) != null) || containsKey(key)) ? v : defaultValue;
}
/**
* 遍歷Map並對每個鍵值對執行指定的操作(action)。
* BiConsumer是一個函數接口(具有一個抽象方法的接口,用於支持Lambda),
* 它代表了一個接受兩個輸入參數的操作,且不返回任何結果。
* 至於它奇怪的名字,根據Java中的其他函數接口的命名規範,Bi應該是Binary的縮寫,意思是二元的。
*/
default void forEach(BiConsumer<? super K, ? super V> action) {
Objects.requireNonNull(action);
for (Map.Entry<K, V> entry : entrySet()) {
K k;
V v;
try {
k = entry.getKey();
v = entry.getValue();
} catch(IllegalStateException ise) {
// this usually means the entry is no longer in the map.
throw new ConcurrentModificationException(ise);
}
action.accept(k, v);
}
}
/**
* 遍歷Map,然後調用傳入的函數function生成新value對舊value進行替換。
* BiFunction同樣是一個函數接口,它接受兩個輸入參數並且返回一個結果。
*/
default void replaceAll(BiFunction<? super K, ? super V, ? extends V> function) {
Objects.requireNonNull(function);
for (Map.Entry<K, V> entry : entrySet()) {
K k;
V v;
try {
k = entry.getKey();
v = entry.getValue();
} catch(IllegalStateException ise) {
// this usually means the entry is no longer in the map.
throw new ConcurrentModificationException(ise);
}
// ise thrown from function is not a cme.
v = function.apply(k, v);
try {
entry.setValue(v);
} catch(IllegalStateException ise) {
// this usually means the entry is no longer in the map.
throw new ConcurrentModificationException(ise);
}
}
}
/**
* 如果指定的key不存在或者關聯的value爲null,則添加鍵值對。
*/
default V putIfAbsent(K key, V value) {
V v = get(key);
if (v == null) {
v = put(key, value);
}
return v;
}
/**
* 當指定key關聯的value與傳入的參數value相等時刪除該key。
*/
default boolean remove(Object key, Object value) {
Object curValue = get(key);
if (!Objects.equals(curValue, value) ||
(curValue == null && !containsKey(key))) {
return false;
}
remove(key);
return true;
}
/**
* 當指定key關聯的value與oldValue相等時,使用newValue進行替換。
*/
default boolean replace(K key, V oldValue, V newValue) {
Object curValue = get(key);
if (!Objects.equals(curValue, oldValue) ||
(curValue == null && !containsKey(key))) {
return false;
}
put(key, newValue);
return true;
}
/**
* 當指定key關聯到某個value時進行替換。
*/
default V replace(K key, V value) {
V curValue;
if (((curValue = get(key)) != null) || containsKey(key)) {
curValue = put(key, value);
}
return curValue;
}
/**
* 當指定key沒有關聯到一個value或者value爲null時,調用mappingFunction生成值並添加鍵值對到Map。
* Function是一個函數接口,它接受一個輸入參數並返回一個結果,如果mappingFunction返回的結果
* 也爲null,那麼將不會調用put。
*/
default V computeIfAbsent(K key,
Function<? super K, ? extends V> mappingFunction) {
Objects.requireNonNull(mappingFunction);
V v;
if ((v = get(key)) == null) {
V newValue;
if ((newValue = mappingFunction.apply(key)) != null) {
put(key, newValue);
return newValue;
}
}
return v;
}
/**
* 當指定key關聯到一個value並且不爲null時,調用remappingFunction生成newValue,
* 如果newValue不爲null,那麼進行替換,否則刪除該key。
*/
default V computeIfPresent(K key,
BiFunction<? super K, ? super V, ? extends V> remappingFunction) {
Objects.requireNonNull(remappingFunction);
V oldValue;
if ((oldValue = get(key)) != null) {
V newValue = remappingFunction.apply(key, oldValue);
if (newValue != null) {
put(key, newValue);
return newValue;
} else {
remove(key);
return null;
}
} else {
return null;
}
}
/**
* remappingFunction根據key與其相關聯的value生成newValue,
* 當newValue等於null時刪除該key,否則添加或者替換舊的映射。
*/
default V compute(K key,
BiFunction<? super K, ? super V, ? extends V> remappingFunction) {
Objects.requireNonNull(remappingFunction);
V oldValue = get(key);
V newValue = remappingFunction.apply(key, oldValue);
if (newValue == null) {
// delete mapping
if (oldValue != null || containsKey(key)) {
// something to remove
remove(key);
return null;
} else {
// nothing to do. Leave things as they were.
return null;
}
} else {
// add or replace old mapping
put(key, newValue);
return newValue;
}
}
/**
* 當指定key沒有關聯到一個value或者value爲null,將它與傳入的參數value
* 進行關聯。否則,調用remappingFunction生成newValue並進行替換。
* 如果,newValue等於null,那麼刪除該key。
*/
default V merge(K key, V value,
BiFunction<? super V, ? super V, ? extends V> remappingFunction) {
Objects.requireNonNull(remappingFunction);
Objects.requireNonNull(value);
V oldValue = get(key);
V newValue = (oldValue == null) ? value :
remappingFunction.apply(oldValue, value);
if(newValue == null) {
remove(key);
} else {
put(key, newValue);
}
return newValue;
}
}
需要注意一點,這些default方法都是非線程安全的,任何保證線程安全的擴展類都必須重寫這些方法,例如ConcurrentHashMap。
下圖爲Map的繼承關係結構圖,它也是本文接下來將要分析的Map實現類的大綱,這些實現類都是比較常用的,在JDK中Map的實現類有幾十個,大部分都是我們用不到的,限於篇幅原因就不一一講解了(本文包含許多源碼與對實現細節的分析,建議讀者抽出一段連續的空閒時間靜下心來慢慢閱讀)。
AbstractMap
AbstractMap是一個抽象類,它是Map接口的一個骨架實現,最小化實現了此接口提供的抽象函數。在Java的Collection框架中基本都遵循了這一規定,骨架實現在接口與實現類之間構建了一層抽象,其目的是爲了複用一些比較通用的函數以及方便擴展,例如List接口擁有骨架實現AbstractList、Set接口擁有骨架實現AbstractSet等。
下面我們按照不同的操作類型來看看AbstractMap都實現了什麼,首先是查詢操作:
可以發現這些操作都是依賴於函數entrySet()
的,它返回了一個鍵值對的集合視圖,由於不同的實現子類的Entry實現可能也是不同的,所以一般是在內部實現一個繼承於AbstractSet且泛型爲Map.Entry
的內部類作爲EntrySet,接下來是修改操作與批量操作:
// Modification Operations
/**
* 沒有提供實現,子類必須重寫該方法,否則調用put()會拋出異常。
*/
public V put(K key, V value) {
throw new UnsupportedOperationException();
}
/**
* 遍歷entrySet,先找到目標的entry,然後刪除。
*(還記得之前說過的嗎,集合視圖中的操作也會影響到實際數據)
*/
public V remove(Object key) {
Iterator<Entry<K,V>> i = entrySet().iterator();
Entry<K,V> correctEntry = null;
if (key==null) {
while (correctEntry==null && i.hasNext()) {
Entry<K,V> e = i.next();
if (e.getKey()==null)
correctEntry = e;
}
} else {
while (correctEntry==null && i.hasNext()) {
Entry<K,V> e = i.next();
if (key.equals(e.getKey()))
correctEntry = e;
}
}
V oldValue = null;
if (correctEntry !=null) {
oldValue = correctEntry.getValue();
i.remove();
}
return oldValue;
}
// Bulk Operations
/**
* 遍歷參數m,然後將每一個鍵值對put到該Map中。
*/
public void putAll(Map<? extends K, ? extends V> m) {
for (Map.Entry<? extends K, ? extends V> e : m.entrySet())
put(e.getKey(), e.getValue());
}
/**
* 清空entrySet等價於清空該Map。
*/
public void clear() {
entrySet().clear();
}
AbstractMap並沒有實現put()
函數,這樣做是爲了考慮到也許會有不可修改的Map實現子類繼承它,而對於一個可修改的Map實現子類則必須重寫put()
函數。
AbstractMap沒有提供entrySet()
的實現,但是卻提供了keySet()
與values()
集合視圖的默認實現,它們都是依賴於entrySet()
返回的集合視圖實現的,源碼如下:
/**
* keySet和values是lazy的,它們只會在第一次請求視圖時進行初始化,
* 而且它們是無狀態的,所以只需要一個實例(初始化一次)。
*/
transient Set<K> keySet;
transient Collection<V> values;
/**
* 返回一個AbstractSet的子類,可以發現它的行爲都委託給了entrySet返回的集合視圖
* 與當前的AbstractMap實例,所以說它自身是無狀態的。
*/
public Set<K> keySet() {
Set<K> ks = keySet;
if (ks == null) {
ks = new AbstractSet<K>() {
public Iterator<K> iterator() {
return new Iterator<K>() {
private Iterator<Entry<K,V>> i = entrySet().iterator();
public boolean hasNext() {
return i.hasNext();
}
public K next() {
return i.next().getKey();
}
public void remove() {
i.remove();
}
};
}
public int size() {
return AbstractMap.this.size();
}
public boolean isEmpty() {
return AbstractMap.this.isEmpty();
}
public void clear() {
AbstractMap.this.clear();
}
public boolean contains(Object k) {
return AbstractMap.this.containsKey(k);
}
};
keySet = ks;
}
return ks;
}
/**
* 與keySet()基本一致,唯一的區別就是返回的是AbstractCollection的子類,
* 主要是因爲value不需要保持互異性。
*/
public Collection<V> values() {
Collection<V> vals = values;
if (vals == null) {
vals = new AbstractCollection<V>() {
public Iterator<V> iterator() {
return new Iterator<V>() {
private Iterator<Entry<K,V>> i = entrySet().iterator();
public boolean hasNext() {
return i.hasNext();
}
public V next() {
return i.next().getValue();
}
public void remove() {
i.remove();
}
};
}
public int size() {
return AbstractMap.this.size();
}
public boolean isEmpty() {
return AbstractMap.this.isEmpty();
}
public void clear() {
AbstractMap.this.clear();
}
public boolean contains(Object v) {
return AbstractMap.this.containsValue(v);
}
};
values = vals;
}
return vals;
}
它還提供了兩個Entry的實現類:SimpleEntry與SimpleImmutableEntry,這兩個類的實現非常簡單,區別也只是前者是可變的,而後者是不可變的。
private static boolean eq(Object o1, Object o2) {
return o1 == null ? o2 == null : o1.equals(o2);
}
public static class SimpleEntry<K,V>
implements Entry<K,V>, java.io.Serializable
{
private static final long serialVersionUID = -8499721149061103585L;
private final K key;
private V value;
public SimpleEntry(K key, V value) {
this.key = key;
this.value = value;
}
public SimpleEntry(Entry<? extends K, ? extends V> entry) {
this.key = entry.getKey();
this.value = entry.getValue();
}
public K getKey() {
return key;
}
public V getValue() {
return value;
}
public V setValue(V value) {
V oldValue = this.value;
this.value = value;
return oldValue;
}
public boolean equals(Object o) {
if (!(o instanceof Map.Entry))
return false;
Map.Entry<?,?> e = (Map.Entry<?,?>)o;
return eq(key, e.getKey()) && eq(value, e.getValue());
}
public int hashCode() {
return (key == null ? 0 : key.hashCode()) ^
(value == null ? 0 : value.hashCode());
}
public String toString() {
return key + "=" + value;
}
}
/**
* 它與SimpleEntry的區別在於它是不可變的,value被final修飾,並且不支持setValue()。
*/
public static class SimpleImmutableEntry<K,V>
implements Entry<K,V>, java.io.Serializable
{
private static final long serialVersionUID = 7138329143949025153L;
private final K key;
private final V value;
public SimpleImmutableEntry(K key, V value) {
this.key = key;
this.value = value;
}
public SimpleImmutableEntry(Entry<? extends K, ? extends V> entry) {
this.key = entry.getKey();
this.value = entry.getValue();
}
public K getKey() {
return key;
}
public V getValue() {
return value;
}
public V setValue(V value) {
throw new UnsupportedOperationException();
}
public boolean equals(Object o) {
if (!(o instanceof Map.Entry))
return false;
Map.Entry<?,?> e = (Map.Entry<?,?>)o;
return eq(key, e.getKey()) && eq(value, e.getValue());
}
public int hashCode() {
return (key == null ? 0 : key.hashCode()) ^
(value == null ? 0 : value.hashCode());
}
public String toString() {
return key + "=" + value;
}
}
我們通過閱讀上述的源碼不難發現,AbstractMap實現的操作都依賴於entrySet()
所返回的集合視圖。剩下的函數就沒什麼好說的了,有興趣的話可以自己去看看。
TreeMap
TreeMap是基於紅黑樹(一種自平衡的二叉查找樹)實現的一個保證有序性的Map,在繼承關係結構圖中可以得知TreeMap實現了NavigableMap接口,而該接口又繼承了SortedMap接口,我們先來看看這兩個接口定義了一些什麼功能。
SortedMap
首先是SortedMap接口,實現該接口的實現類應當按照自然排序保證key的有序性,所謂自然排序即是根據key的compareTo()
函數(需要實現Comparable接口)或者在構造函數中傳入的Comparator實現類來進行排序,集合視圖遍歷元素的順序也應當與key的順序一致。SortedMap接口還定義了以下幾個有效利用有
package java.util;
public interface SortedMap<K,V> extends Map<K,V> {
/**
* 用於在此Map中對key進行排序的比較器,如果爲null,則使用key的compareTo()函數進行比較。
*/
Comparator<? super K> comparator();
/**
* 返回一個key的範圍爲從fromKey到toKey的局部視圖(包括fromKey,不包括toKey,包左不包右),
* 如果fromKey和toKey是相等的,則返回一個空視圖。
* 返回的局部視圖同樣是此Map的集合視圖,所以對它的操作是會與Map互相影響的。
*/
SortedMap<K,V> subMap(K fromKey, K toKey);
/**
* 返回一個嚴格地小於toKey的局部視圖。
*/
SortedMap<K,V> headMap(K toKey);
/**
* 返回一個大於或等於fromKey的局部視圖。
*/
SortedMap<K,V> tailMap(K fromKey);
/**
* 返回當前Map中的第一個key(最小)。
*/
K firstKey();
/**
* 返回當前Map中的最後一個key(最大)。
*/
K lastKey();
Set<K> keySet();
Collection<V> values();
Set<Map.Entry<K, V>> entrySet();
}
NavigableMap
然後是SortedMap的子接口NavigableMap,該接口擴展了一些用於導航(Navigation)的方法,像函數lowerEntry(key)
會根據傳入的參數key返回一個小於key的最大的一對鍵值對,例如,我們如下調用lowerEntry(6)
,那麼將返回key爲5的鍵值對,如果沒有key爲5,則會返回key爲4的鍵值對,以此類推,直到返回null(實在找不到的情況下)。
public static void main(String[] args) {
NavigableMap<Integer, Integer> map = new TreeMap<>();
for (int i = 0; i < 10; i++)
map.put(i, i);
assert map.lowerEntry(6).getKey() == 5;
assert map.lowerEntry(5).getKey() == 4;
assert map.lowerEntry(0).getKey() == null;
}
NavigableMap定義的都是一些類似於lowerEntry(key)
的方法和以逆序、升序排序的集合視圖,這些方法利用有序性實現了相比SortedMap接口更加靈活的操作。
package java.util;
public interface NavigableMap<K,V> extends SortedMap<K,V> {
/**
* 返回一個小於指定key的最大的一對鍵值對,如果找不到則返回null。
*/
Map.Entry<K,V> lowerEntry(K key);
/**
* 返回一個小於指定key的最大的一個key,如果找不到則返回null。
*/
K lowerKey(K key);
/**
* 返回一個小於或等於指定key的最大的一對鍵值對,如果找不到則返回null。
*/
Map.Entry<K,V> floorEntry(K key);
/**
* 返回一個小於或等於指定key的最大的一個key,如果找不到則返回null。
*/
K floorKey(K key);
/**
* 返回一個大於或等於指定key的最小的一對鍵值對,如果找不到則返回null。
*/
Map.Entry<K,V> ceilingEntry(K key);
/**
* 返回一個大於或等於指定key的最小的一個key,如果找不到則返回null。
*/
K ceilingKey(K key);
/**
* 返回一個大於指定key的最小的一對鍵值對,如果找不到則返回null。
*/
Map.Entry<K,V> higherEntry(K key);
/**
* 返回一個大於指定key的最小的一個key,如果找不到則返回null。
*/
K higherKey(K key);
/**
* 返回該Map中最小的鍵值對,如果Map爲空則返回null。
*/
Map.Entry<K,V> firstEntry();
/**
* 返回該Map中最大的鍵值對,如果Map爲空則返回null。
*/
Map.Entry<K,V> lastEntry();
/**
* 返回並刪除該Map中最小的鍵值對,如果Map爲空則返回null。
*/
Map.Entry<K,V> pollFirstEntry();
/**
* 返回並刪除該Map中最大的鍵值對,如果Map爲空則返回null。
*/
Map.Entry<K,V> pollLastEntry();
/**
* 返回一個以當前Map降序(逆序)排序的集合視圖
*/
NavigableMap<K,V> descendingMap();
/**
* 返回一個包含當前Map中所有key的集合視圖,該視圖中的key以升序(正序)排序。
*/
NavigableSet<K> navigableKeySet();
/**
* 返回一個包含當前Map中所有key的集合視圖,該視圖中的key以降序(逆序)排序。
*/
NavigableSet<K> descendingKeySet();
/**
* 與SortedMap.subMap基本一致,區別在於多的兩個參數fromInclusive和toInclusive,
* 它們代表是否包含from和to,如果fromKey與toKey相等,並且fromInclusive與toInclusive
* 都爲true,那麼不會返回空集合。
*/
NavigableMap<K,V> subMap(K fromKey, boolean fromInclusive,
K toKey, boolean toInclusive);
/**
* 返回一個小於或等於(inclusive爲true的情況下)toKey的局部視圖。
*/
NavigableMap<K,V> headMap(K toKey, boolean inclusive);
/**
* 返回一個大於或等於(inclusive爲true的情況下)fromKey的局部視圖。
*/
NavigableMap<K,V> tailMap(K fromKey, boolean inclusive);
/**
* 等價於subMap(fromKey, true, toKey, false)。
*/
SortedMap<K,V> subMap(K fromKey, K toKey);
/**
* 等價於headMap(toKey, false)。
*/
SortedMap<K,V> headMap(K toKey);
/**
* 等價於tailMap(fromKey, true)。
*/
SortedMap<K,V> tailMap(K fromKey);
}
NavigableMap接口相對於SortedMap接口來說靈活了許多,正因爲TreeMap也實現了該接口,所以在需要數據有序而且想靈活地訪問它們的時候,使用TreeMap就非常合適了。
紅黑樹
上文我們提到TreeMap的內部實現基於紅黑樹,而紅黑樹又是二叉查找樹的一種。二叉查找樹是一種有序的樹形結構,優勢在於查找、插入的時間複雜度只有O(log n)
,特性如下:
-
任意節點最多含有兩個子節點。
-
任意節點的左、右節點都可以看做爲一棵二叉查找樹。
-
如果任意節點的左子樹不爲空,那麼左子樹上的所有節點的值均小於它的根節點的值。
-
如果任意節點的右子樹不爲空,那麼右子樹上的所有節點的值均大於它的根節點的值。
-
任意節點的key都是不同的。
儘管二叉查找樹看起來很美好,但事與願違,二叉查找樹在極端情況下會變得並不是那麼有效率,假設我們有一個有序的整數序列:1,2,3,4,5,6,7,8,9,10,...
,如果把這個序列按順序全部插入到二叉查找樹時會發生什麼呢?二叉查找樹會產生傾斜,序列中的每一個元素都大於它的根節點(前一個元素),左子樹永遠是空的,那麼這棵二叉查找樹就跟一個普通的鏈表沒什麼區別了,查找操作的時間複雜度只有O(n)
。
爲了解決這個問題需要引入自平衡的二叉查找樹,所謂自平衡,即是在樹結構將要傾斜的情況下進行修正,這個修正操作被稱爲旋轉,通過旋轉操作可以讓樹趨於平衡。
紅黑樹是平衡二叉查找樹的一種實現,它的名字來自於它的子節點是着色的,每個子節點非黑即紅,由於只有兩種顏色(兩種狀態),一般使用boolean來表示,下面爲TreeMap中實現的Entry,它代表紅黑樹中的一個節點:
// Red-black mechanics
private static final boolean RED = false;
private static final boolean BLACK = true;
/**
* Node in the Tree. Doubles as a means to pass key-value pairs back to
* user (see Map.Entry).
*/
static final class Entry<K,V> implements Map.Entry<K,V> {
K key;
V value;
Entry<K,V> left;
Entry<K,V> right;
Entry<K,V> parent;
boolean color = BLACK;
/**
* Make a new cell with given key, value, and parent, and with
* {@code null} child links, and BLACK color.
*/
Entry(K key, V value, Entry<K,V> parent) {
this.key = key;
this.value = value;
this.parent = parent;
}
/**
* Returns the key.
*
* @return the key
*/
public K getKey() {
return key;
}
/**
* Returns the value associated with the key.
*
* @return the value associated with the key
*/
public V getValue() {
return value;
}
/**
* Replaces the value currently associated with the key with the given
* value.
*
* @return the value associated with the key before this method was
* called
*/
public V setValue(V value) {
V oldValue = this.value;
this.value = value;
return oldValue;
}
public boolean equals(Object o) {
if (!(o instanceof Map.Entry))
return false;
Map.Entry<?,?> e = (Map.Entry<?,?>)o;
return valEquals(key,e.getKey()) && valEquals(value,e.getValue());
}
public int hashCode() {
int keyHash = (key==null ? 0 : key.hashCode());
int valueHash = (value==null ? 0 : value.hashCode());
return keyHash ^ valueHash;
}
public String toString() {
return key + "=" + value;
}
}
任何平衡二叉查找樹的查找操作都是與二叉查找樹是一樣的,因爲查找操作並不會影響樹的結構,也就不需要進行修正,代碼如下:
public V get(Object key) {
Entry<K,V> p = getEntry(key);
return (p==null ? null : p.value);
}
final Entry<K,V> getEntry(Object key) {
// 使用Comparator進行比較
if (comparator != null)
return getEntryUsingComparator(key);
if (key == null)
throw new NullPointerException();
@SuppressWarnings("unchecked")
Comparable<? super K> k = (Comparable<? super K>) key;
Entry<K,V> p = root;
// 從根節點開始,不斷比較key的大小進行查找
while (p != null) {
int cmp = k.compareTo(p.key);
if (cmp < 0) // 小於,轉向左子樹
p = p.left;
else if (cmp > 0) // 大於,轉向右子樹
p = p.right;
else
return p;
}
return null; // 沒有相等的key,返回null
}
而插入和刪除操作與平衡二叉查找樹的細節是息息相關的,關於紅黑樹的實現細節,我之前寫過的一篇博客紅黑樹的那點事兒已經講的很清楚了,對這方面不瞭解的讀者建議去閱讀一下,就不在這裏重複敘述了。
集合視圖
最後看一下TreeMap的集合視圖的實現,集合視圖一般都是實現了一個封裝了當前實例的類,所以對集合視圖的修改本質上就是在修改當前實例,TreeMap也不例外。
TreeMap的headMap()
、tailMap()
以及subMap()
函數都返回了一個靜態內部類AscendingSubMap,從名字上也能猜出來,爲了支持倒序,肯定也還有一個DescendingSubMap,它們都繼承於NavigableSubMap,一個繼承AbstractMap並實現了NavigableMap的抽象類:
abstract static class NavigableSubMap<K,V> extends AbstractMap<K,V>
implements NavigableMap<K,V>, java.io.Serializable {
private static final long serialVersionUID = -2102997345730753016L;
final TreeMap<K,V> m;
/**
* (fromStart, lo, loInclusive) 與 (toEnd, hi, hiInclusive)代表了兩個三元組,
* 如果fromStart爲true,那麼範圍的下限(絕對)爲map(被封裝的TreeMap)的起始key,
* 其他值將被忽略。
* 如果loInclusive爲true,lo將會被包含在範圍內,否則lo是在範圍外的。
* toEnd與hiInclusive與上述邏輯相似,只不過考慮的是上限。
*/
final K lo, hi;
final boolean fromStart, toEnd;
final boolean loInclusive, hiInclusive;
NavigableSubMap(TreeMap<K,V> m,
boolean fromStart, K lo, boolean loInclusive,
boolean toEnd, K hi, boolean hiInclusive) {
if (!fromStart && !toEnd) {
if (m.compare(lo, hi) > 0)
throw new IllegalArgumentException("fromKey > toKey");
} else {
if (!fromStart) // type check
m.compare(lo, lo);
if (!toEnd)
m.compare(hi, hi);
}
this.m = m;
this.fromStart = fromStart;
this.lo = lo;
this.loInclusive = loInclusive;
this.toEnd = toEnd;
this.hi = hi;
this.hiInclusive = hiInclusive;
}
// internal utilities
final boolean tooLow(Object key) {
if (!fromStart) {
int c = m.compare(key, lo);
// 如果key小於lo,或等於lo(需要lo不包含在範圍內)
if (c < 0 || (c == 0 && !loInclusive))
return true;
}
return false;
}
final boolean tooHigh(Object key) {
if (!toEnd) {
int c = m.compare(key, hi);
// 如果key大於hi,或等於hi(需要hi不包含在範圍內)
if (c > 0 || (c == 0 && !hiInclusive))
return true;
}
return false;
}
final boolean inRange(Object key) {
return !tooLow(key) && !tooHigh(key);
}
final boolean inClosedRange(Object key) {
return (fromStart || m.compare(key, lo) >= 0)
&& (toEnd || m.compare(hi, key) >= 0);
}
// 判斷key是否在該視圖的範圍之內
final boolean inRange(Object key, boolean inclusive) {
return inclusive ? inRange(key) : inClosedRange(key);
}
/*
* 以abs開頭的函數爲關係操作的絕對版本。
*/
/*
* 獲得最小的鍵值對:
* 如果fromStart爲true,那麼直接返回當前map實例的第一個鍵值對即可,
* 否則,先判斷lo是否包含在範圍內,
* 如果是,則獲得當前map實例中大於或等於lo的最小的鍵值對,
* 如果不是,則獲得當前map實例中大於lo的最小的鍵值對。
* 如果得到的結果e超過了範圍的上限,那麼返回null。
*/
final TreeMap.Entry<K,V> absLowest() {
TreeMap.Entry<K,V> e =
(fromStart ? m.getFirstEntry() :
(loInclusive ? m.getCeilingEntry(lo) :
m.getHigherEntry(lo)));
return (e == null || tooHigh(e.key)) ? null : e;
}
// 與absLowest()相反
final TreeMap.Entry<K,V> absHighest() {
TreeMap.Entry<K,V> e =
(toEnd ? m.getLastEntry() :
(hiInclusive ? m.getFloorEntry(hi) :
m.getLowerEntry(hi)));
return (e == null || tooLow(e.key)) ? null : e;
}
// 下面的邏輯就都很簡單了,注意會先判斷key是否越界,
// 如果越界就返回絕對值。
final TreeMap.Entry<K,V> absCeiling(K key) {
if (tooLow(key))
return absLowest();
TreeMap.Entry<K,V> e = m.getCeilingEntry(key);
return (e == null || tooHigh(e.key)) ? null : e;
}
final TreeMap.Entry<K,V> absHigher(K key) {
if (tooLow(key))
return absLowest();
TreeMap.Entry<K,V> e = m.getHigherEntry(key);
return (e == null || tooHigh(e.key)) ? null : e;
}
final TreeMap.Entry<K,V> absFloor(K key) {
if (tooHigh(key))
return absHighest();
TreeMap.Entry<K,V> e = m.getFloorEntry(key);
return (e == null || tooLow(e.key)) ? null : e;
}
final TreeMap.Entry<K,V> absLower(K key) {
if (tooHigh(key))
return absHighest();
TreeMap.Entry<K,V> e = m.getLowerEntry(key);
return (e == null || tooLow(e.key)) ? null : e;
}
/** 返回升序遍歷的絕對上限 */
final TreeMap.Entry<K,V> absHighFence() {
return (toEnd ? null : (hiInclusive ?
m.getHigherEntry(hi) :
m.getCeilingEntry(hi)));
}
/** 返回降序遍歷的絕對下限 */
final TreeMap.Entry<K,V> absLowFence() {
return (fromStart ? null : (loInclusive ?
m.getLowerEntry(lo) :
m.getFloorEntry(lo)));
}
// 剩下的就是實現NavigableMap的方法以及一些抽象方法
// 和NavigableSubMap中的集合視圖函數。
// 大部分操作都是靠當前實例map的方法和上述用於判斷邊界的方法提供支持
.....
}
一個局部視圖最重要的是要能夠判斷出傳入的key是否屬於該視圖的範圍內,在上面的代碼中可以發現NavigableSubMap提供了非常多的輔助函數用於判斷範圍,接下來我們看看NavigableSubMap的迭代器是如何實現的:
/**
* Iterators for SubMaps
*/
abstract class SubMapIterator<T> implements Iterator<T> {
TreeMap.Entry<K,V> lastReturned;
TreeMap.Entry<K,V> next;
final Object fenceKey;
int expectedModCount;
SubMapIterator(TreeMap.Entry<K,V> first,
TreeMap.Entry<K,V> fence) {
expectedModCount = m.modCount;
lastReturned = null;
next = first;
// UNBOUNDED是一個虛擬值(一個Object對象),表示無邊界。
fenceKey = fence == null ? UNBOUNDED : fence.key;
}
// 只要next不爲null並且沒有超過邊界
public final boolean hasNext() {
return next != null && next.key != fenceKey;
}
final TreeMap.Entry<K,V> nextEntry() {
TreeMap.Entry<K,V> e = next;
// 已經遍歷到頭或者越界了
if (e == null || e.key == fenceKey)
throw new NoSuchElementException();
// modCount是一個記錄操作數的計數器
// 如果與expectedModCount不一致
// 則代表當前map實例在遍歷過程中已被修改過了(從其他線程)
if (m.modCount != expectedModCount)
throw new ConcurrentModificationException();
// 向後移動next指針
// successor()返回指定節點的繼任者
// 它是節點e的右子樹的最左節點
// 也就是比e大的最小的節點
// 如果e沒有右子樹,則會試圖向上尋找
next = successor(e);
lastReturned = e; // 記錄最後返回的節點
return e;
}
final TreeMap.Entry<K,V> prevEntry() {
TreeMap.Entry<K,V> e = next;
if (e == null || e.key == fenceKey)
throw new NoSuchElementException();
if (m.modCount != expectedModCount)
throw new ConcurrentModificationException();
// 向前移動next指針
// predecessor()返回指定節點的前任
// 它與successor()邏輯相反。
next = predecessor(e);
lastReturned = e;
return e;
}
final void removeAscending() {
if (lastReturned == null)
throw new IllegalStateException();
if (m.modCount != expectedModCount)
throw new ConcurrentModificationException();
// 被刪除的節點被它的繼任者取代
// 執行完刪除後,lastReturned實際指向了它的繼任者
if (lastReturned.left != null && lastReturned.right != null)
next = lastReturned;
m.deleteEntry(lastReturned);
lastReturned = null;
expectedModCount = m.modCount;
}
final void removeDescending() {
if (lastReturned == null)
throw new IllegalStateException();
if (m.modCount != expectedModCount)
throw new ConcurrentModificationException();
m.deleteEntry(lastReturned);
lastReturned = null;
expectedModCount = m.modCount;
}
}
final class SubMapEntryIterator extends SubMapIterator<Map.Entry<K,V>> {
SubMapEntryIterator(TreeMap.Entry<K,V> first,
TreeMap.Entry<K,V> fence) {
super(first, fence);
}
public Map.Entry<K,V> next() {
return nextEntry();
}
public void remove() {
removeAscending();
}
}
final class DescendingSubMapEntryIterator extends SubMapIterator<Map.Entry<K,V>> {
DescendingSubMapEntryIterator(TreeMap.Entry<K,V> last,
TreeMap.Entry<K,V> fence) {
super(last, fence);
}
public Map.Entry<K,V> next() {
return prevEntry();
}
public void remove() {
removeDescending();
}
}
到目前爲止,我們已經針對集合視圖討論了許多,想必大家也能夠理解集合視圖的概念了,由於SortedMap與NavigableMap的緣故,TreeMap中的集合視圖是非常多的,包括各種局部視圖和不同排序的視圖,有興趣的讀者可以自己去看看源碼,後面的內容不會再對集合視圖進行過多的解釋了。
HashMap
光從名字上應該也能猜到,HashMap肯定是基於hash算法實現的,這種基於hash實現的map叫做散列表(hash table)。
散列表中維護了一個數組,數組的每一個元素被稱爲一個桶(bucket),當你傳入一個key = "a"
進行查詢時,散列表會先把key傳入散列(hash)函數中進行尋址,得到的結果就是數組的下標,然後再通過這個下標訪問數組即可得到相關聯的值。
我們都知道數組中數據的組織方式是線性的,它會直接分配一串連續的內存地址序列,要找到一個元素只需要根據下標來計算地址的偏移量即可(查找一個元素的起始地址爲:數組的起始地址加上下標乘以該元素類型佔用的地址大小)。因此散列表在理想的情況下,各種操作的時間複雜度只有O(1)
,這甚至超過了二叉查找樹,雖然理想的情況並不總是滿足的,關於這點之後我們還會提及。
爲什麼是hash?
hash算法是一種可以從任何數據中提取出其“指紋”的數據摘要算法,它將任意大小的數據(輸入)映射到一個固定大小的序列(輸出)上,這個序列被稱爲hash code、數據摘要或者指紋。比較出名的hash算法有MD5、SHA。
hash是具有唯一性且不可逆的,唯一性指的是相同的輸入產生的hash code永遠是一樣的,而不可逆也比較容易理解,數據摘要算法並不是壓縮算法,它只是生成了一個該數據的摘要,沒有將數據進行壓縮。壓縮算法一般都是使用一種更節省空間的編碼規則將數據重新編碼,解壓縮只需要按着編碼規則解碼就是了,試想一下,一個幾百MB甚至幾GB的數據生成的hash code都只是一個擁有固定長度的序列,如果再能逆向解壓縮,那麼其他壓縮算法該情何以堪?
我們上述討論的僅僅是在密碼學中的hash算法,而在散列表中所需要的散列函數是要能夠將key尋址到buckets中的一個位置,散列函數的實現影響到整個散列表的性能。
一個完美的散列函數要能夠做到均勻地將key分佈到buckets中,每一個key分配到一個bucket,但這是不可能的。雖然hash算法具有唯一性,但同時它還具有重複性,唯一性保證了相同輸入的輸出是一致的,卻沒有保證不同輸入的輸出是不一致的,也就是說,完全有可能兩個不同的key被分配到了同一個bucket(因爲它們的hash code可能是相同的),這叫做碰撞衝突。總之,理想很豐滿,現實很骨感,散列函數只能儘可能地減少衝突,沒有辦法完全消除衝突。
散列函數的實現方法非常多,一個優秀的散列函數要看它能不能將key分佈均勻。首先介紹一種最簡單的方法:除留餘數法,先對key進行hash得到它的hash code,然後再用該hash code對buckets數組的元素數量取餘,得到的結果就是bucket的下標,這種方法簡單高效,也可以當做對集羣進行負載均衡的路由算法。
private int hash(Key key) {
// & 0x7fffffff 是爲了屏蔽符號位,M爲bucket數組的長度
return (key.hashCode() & 0x7fffffff) % M;
}
要注意一點,只有整數才能進行取餘運算,如果hash code是一個字符串或別的類型,那麼你需要將它轉換爲整數才能使用除留餘數法,不過Java在Object對象中提供了hashCode()
函數,該函數返回了一個int值,所以任何你想要放入HashMap的自定義的抽象數據類型,都必須實現該函數和equals()
函數,這兩個函數之間也遵守着一種約定:如果a.equals(b) == true
,那麼a與b的hashCode()
也必須是相同的。
下面爲String類的hashCode()
函數,它先遍歷了內部的字符數組,然後在每一次循環中計算hash code(將hash code乘以一個素數並加上當前循環項的字符):
/** The value is used for character storage. */
private final char value[];
/** Cache the hash code for the string */
private int hash; // Default to 0
public int hashCode() {
int h = hash;
if (h == 0 && value.length > 0) {
char val[] = value;
for (int i = 0; i < value.length; i++) {
h = 31 * h + val[i];
}
hash = h;
}
return h;
}
HashMap沒有采用這麼簡單的方法,有一個原因是HashMap中的buckets數組的長度永遠爲一個2的冪,而不是一個素數,如果長度爲素數,那麼可能會更適合簡單暴力的除留餘數法(當然除留餘數法雖然簡單卻並不是那麼高效的),順便一提,時代的眼淚Hashtable就使用了除留餘數法,它沒有強制約束buckets數組的長度。
HashMap在內部實現了一個hash()
函數,首先要對hashCode()
的返回值進行處理:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
該函數將key.hashCode()
的低16位和高16位做了個異或運算,其目的是爲了擾亂低位的信息以實現減少碰撞衝突。之後還需要把hash()
的返回值與table.length - 1
做與運算(table
爲buckets數組),得到的結果即是數組的下標。
table.length - 1
就像是一個低位掩碼(這個設計也優化了擴容操作的性能),它和hash()
做與操作時必然會將高位屏蔽(因爲一個HashMap不可能有特別大的buckets數組,至少在不斷自動擴容之前是不可能的,所以table.length - 1
的大部分高位都爲0),只保留低位,看似沒什麼毛病,但這其實暗藏玄機,它會導致總是隻有最低的幾位是有效的,這樣就算你的hashCode()
實現得再好也難以避免發生碰撞。這時,hash()
函數的價值就體現出來了,它對hash code的低位添加了隨機性並且混合了高位的部分特徵,顯著減少了碰撞衝突的發生(關於hash()
函數的效果如何,可以參考這篇文章An introduction to optimising a hashing strategy)。
HashMap的散列函數具體流程如下圖:
解決衝突
在上文中我們已經多次提到碰撞衝突,但是散列函數不可能是完美的,key分佈完全均勻的情況是不存在的,所以碰撞衝突總是難以避免。
那麼發生碰撞衝突時怎麼辦?總不能丟棄數據吧?必須要有一種合理的方法來解決這個問題,HashMap使用了叫做分離鏈接(Separate chaining,也有人翻譯成拉鍊法)的策略來解決衝突。它的主要思想是每個bucket都應當是一個互相獨立的數據結構,當發生衝突時,只需要把數據放入bucket中(因爲bucket本身也是一個可以存放數據的數據結構),這樣查詢一個key所消耗的時間爲訪問bucket所消耗的時間加上在bucket中查找的時間。
HashMap的buckets數組其實就是一個鏈表數組,在發生衝突時只需要把Entry(還記得Entry嗎?HashMap的Entry實現就是一個簡單的鏈表節點,它包含了key和value以及hash code)放到鏈表的尾部,如果未發生衝突(位於該下標的bucket爲null),那麼就把該Entry做爲鏈表的頭部。而且HashMap還使用了Lazy策略,buckets數組只會在第一次調用put()
函數時進行初始化,這是一種防止內存浪費的做法,像ArrayList也是Lazy的,它在第一次調用add()
時纔會初始化內部的數組。
不過鏈表雖然實現簡單,但是在查找的效率上只有O(n)
,而且我們大部分的操作都是在進行查找,在hashCode()
設計的不是非常良好的情況下,碰撞衝突可能會頻繁發生,鏈表也會變得越來越長,這個效率是非常差的。Java 8對其實現了優化,鏈表的節點數量在到達閾值時會轉化爲紅黑樹,這樣查找所需的時間就只有O(log n)
了,閾值的定義如下:
1 2 3 4 5 6 7 8 9 |
/** * The bin count threshold for using a tree rather than list for a * bin. Bins are converted to trees when adding an element to a * bin with at least this many nodes. The value must be greater * than 2 and should be at least 8 to mesh with assumptions in * tree removal about conversion back to plain bins upon * shrinkage. */ static final int TREEIFY_THRESHOLD = 8; |
如果在插入Entry時發現一條鏈表超過閾值,就會執行以下的操作,對該鏈表進行樹化;相對的,如果在刪除Entry(或進行擴容)時發現紅黑樹的節點太少(根據閾值UNTREEIFY_THRESHOLD),也會把紅黑樹退化成鏈表。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 |
/** * 替換指定hash所處位置的鏈表中的所有節點爲TreeNode, * 如果buckets數組太小,就進行擴容。 */ final void treeifyBin(Node<K,V>[] tab, int hash) { int n, index; Node<K,V> e; // MIN_TREEIFY_CAPACITY = 64,小於該值代表數組中的節點並不是很多 // 所以選擇進行擴容,只有數組長度大於該值時纔會進行樹化。 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 { 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); // 從頭部開始構造樹 } } // 該函數定義在TreeNode中 final void treeify(Node<K,V>[] tab) { TreeNode<K,V> root = null; for (TreeNode<K,V> x = this, next; x != null; x = next) { next = (TreeNode<K,V>)x.next; x.left = x.right = null; if (root == null) { // 初始化root節點 x.parent = null; x.red = false; root = x; } else { K k = x.key; int h = x.hash; Class<?> kc = null; for (TreeNode<K,V> p = root;;) { int dir, ph; K pk = p.key; // 確定節點的方向 if ((ph = p.hash) > h) dir = -1; else if (ph < h) dir = 1; // 如果kc == null // 並且k沒有實現Comparable接口 // 或者k與pk是沒有可比較性的(類型不同) // 或者k與pk是相等的(返回0也有可能是相等) else if ((kc == null && (kc = comparableClassFor(k)) == null) || (dir = compareComparables(kc, k, pk)) == 0) dir = tieBreakOrder(k, pk); // 確定方向後插入節點,修正紅黑樹的平衡 TreeNode<K,V> xp = p; if ((p = (dir <= 0) ? p.left : p.right) == null) { x.parent = xp; if (dir <= 0) xp.left = x; else xp.right = x; root = balanceInsertion(root, x); break; } } } } // 確保給定的root是該bucket中的第一個節點 moveRootToFront(tab, root); } static int tieBreakOrder(Object a, Object b) { int d; if (a == null || b == null || (d = a.getClass().getName(). compareTo(b.getClass().getName())) == 0) // System.identityHashCode()將調用並返回傳入對象的默認hashCode() // 也就是說,無論是否重寫了hashCode(),都將調用Object.hashCode()。 // 如果傳入的對象是null,那麼就返回0 d = (System.identityHashCode(a) <= System.identityHashCode(b) ? -1 : 1); return d; } |
解決碰撞衝突的另一種策略叫做開放尋址法(Open addressing),它與分離鏈接法的思想截然不同。在開放尋址法中,所有Entry都會存儲在buckets數組,一個明顯的區別是,分離鏈接法中的每個bucket都是一個鏈表或其他的數據結構,而開放尋址法中的每個bucket就僅僅只是Entry本身。
開放尋址法是基於數組中的空位來解決衝突的,它的想法很簡單,與其使用鏈表等數據結構,不如直接在數組中留出空位來當做一個標記,反正都要佔用額外的內存。
當你查找一個key的時候,首先會從起始位置(通過散列函數計算出的數組索引)開始,不斷檢查當前bucket是否爲目標Entry(通過比較key來判斷),如果當前bucket不是目標Entry,那麼就向後查找(查找的間隔取決於實現),直到碰見一個空位(null),這代表你想要找的key不存在。
如果你想要put一個全新的Entry(Map中沒有這個key存在),依然會從起始位置開始進行查找,如果起始位置不是空的,則代表發生了碰撞衝突,只好不斷向後查找,直到發現一個空位。
開放尋址法的名字也是來源於此,一個Entry的位置並不是完全由hash值決定的,所以也叫做Closed hashing,相對的,分離鏈接法也被稱爲Open hashing或Closed addressing。
根據向後探測(查找)的算法不同,開放尋址法有多種不同的實現,我們介紹一種最簡單的算法:線性探測法(Linear probing),在發生碰撞時,簡單地將索引加一,如果到達了數組的尾部就折回到數組的頭部,直到找到目標或一個空位。
基於線性探測法的查找操作如下:
1 2 3 4 5 6 7 8 9 10 11 12 |
private K[] keys; // 存儲key的數組 private V[] vals; // 存儲值的數組 public V get(K key) { // m是buckets數組的長度,即keys和vals的長度。 // 當i等於m時,取模運算會得0(折回數組頭部) for (int i = hash(key); keys[i] != null; i = (i + 1) % m) { if (keys[i].equals(key)) return vals[i]; } return null; } |
插入操作稍微麻煩一些,需要在插入之前判斷當前數組的剩餘容量,然後決定是否擴容。數組的剩餘容量越多,代表Entry之間的間隔越大以及越早碰見空位(向後探測的次數就越少),效率自然就會變高。代價就是額外消耗的內存較多,這也是在用空間換取時間。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
public void put(K key, V value) { // n是Entry的數量,如果n超過了數組長度的一半,就擴容一倍 if (n >= m / 2) resize(2 * m); int i; for (i = hash(key); keys[i] != null; i = (i + 1) % m) { if (keys[i].equals(key)) { vals[i] = value; return; } } // 沒有找到目標,那麼就插入一對新的Entry keys[i] = key; vals[i] = value; n++; } |
接下來是刪除操作,需要注意一點,我們不能簡單地把目標key所在的位置(keys和vals數組)設置爲null,這樣會導致此位置之後的Entry無法被探測到,所以需要將目標右側的所有Entry重新插入到散列表中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
public V delete(K key) { int i = hash(key); // 先找到目標的索引 while (!key.equals(keys[i])) { i = (i + 1) % m; } V oldValue = vals[i]; // 刪除目標key和value keys[i] = null; vals[i] = null; // 指針移動到下一個索引 i = (i + 1) % m; while (keys[i] != null) { // 先刪除然後重新插入 K keyToRehash = keys[i]; V valToRehash = vals[i]; keys[i] = null; vals[i] = null; n--; put(keyToRehash, valToRehash); i = (i + 1) % m; } n--; // 當前Entry小於等於數組長度的八分之一時,進行縮容 if (n > 0 && n <= m / 8) resize(m / 2); return oldValue; } |
動態擴容
散列表以數組的形式組織bucket,問題在於數組是靜態分配的,爲了保證查找的性能,需要在Entry數量大於一個臨界值時進行擴容,否則就算散列函數的效果再好,也難免產生碰撞。
所謂擴容,其實就是用一個容量更大(在原容量上乘以二)的數組來替換掉當前的數組,這個過程需要把舊數組中的數據重新hash到新數組,所以擴容也能在一定程度上減緩碰撞。
HashMap通過負載因子(Load Factor)乘以buckets數組的長度來計算出臨界值,算法:threshold = load_factor * capacity
。比如,HashMap的默認初始容量爲16(capacity = 16
),默認負載因子爲0.75(load_factor = 0.75
),那麼臨界值就爲threshold = 0.75 * 16 = 12
,只要Entry的數量大於12,就會觸發擴容操作。
還可以通過下列的構造函數來自定義負載因子,負載因子越小查找的性能就會越高,但同時額外佔用的內存就會越多,如果沒有特殊需要不建議修改默認值。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
/** * 可以發現構造函數中根本就沒初始化buckets數組。 * (之前說過buckets數組會推遲到第一次調用put()時進行初始化) */ 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()確保initialCapacity必須爲一個2的N次方 this.threshold = tableSizeFor(initialCapacity); } |
buckets數組的大小約束對於整個HashMap都至關重要,爲了防止傳入一個不是2次冪的整數,必須要有所防範。tableSizeFor()
函數會嘗試修正一個整數,並轉換爲離該整數最近的2次冪。
1 2 3 4 5 6 7 8 9 10 11 12 |
/** * Returns a power of two size for the given target capacity. */ 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; } |
還記得數組索引的計算方法嗎?index = (table.length - 1) & hash
,這其實是一種優化手段,由於數組的大小永遠是一個2次冪,在擴容之後,一個元素的新索引要麼是在原位置,要麼就是在原位置加上擴容前的容量。這個方法的巧妙之處全在於&運算,之前提到過&運算只會關注n - 1(n = 數組長度)的有效位,當擴容之後,n的有效位相比之前會多增加一位(n會變成之前的二倍,所以確保數組長度永遠是2次冪很重要),然後只需要判斷hash在新增的有效位的位置是0還是1就可以算出新的索引位置,如果是0,那麼索引沒有發生變化,如果是1,索引就爲原索引加上擴容前的容量。
這樣在每次擴容時都不用重新計算hash,省去了不少時間,而且新增有效位是0還是1是帶有隨機性的,之前兩個碰撞的Entry又有可能在擴容時再次均勻地散佈開。下面是resize()
的源碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 |
final Node<K,V>[] resize() { Node<K,V>[] oldTab = table; // table就是buckets數組 int oldCap = (oldTab == null) ? 0 : oldTab.length; int oldThr = threshold; int newCap, newThr = 0; // oldCap大於0,進行擴容,設置閾值與新的容量 if (oldCap > 0) { // 超過最大值不會進行擴容,並且把閾值設置成Interger.MAX_VALUE if (oldCap >= MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return oldTab; } // 沒超過最大值,擴容爲原來的2倍 // 向左移1位等價於乘2 else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) newThr = oldThr << 1; // double threshold } // oldCap = 0,oldThr大於0,那麼就把閾值做爲新容量以進行初始化 // 這種情況發生在用戶調用了帶有參數的構造函數(會對threshold進行初始化) else if (oldThr > 0) // initial capacity was placed in threshold newCap = oldThr; // oldCap與oldThr都爲0,這種情況發生在用戶調用了無參構造函數 // 採用默認值進行初始化 else { // zero initial threshold signifies using defaults newCap = DEFAULT_INITIAL_CAPACITY; newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); } // 如果newThr還沒有被賦值,那麼就根據newCap計算出閾值 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; // 如果oldTab != null,代表這是擴容操作 // 需要將擴容前的數組數據遷移到新數組 if (oldTab != null) { // 遍歷oldTab的每一個bucket,然後移動到newTab for (int j = 0; j < oldCap; ++j) { Node<K,V> e; if ((e = oldTab[j]) != null) { oldTab[j] = null; // 索引j的bucket只有一個Entry(未發生過碰撞) // 直接移動到newTab if (e.next == null) newTab[e.hash & (newCap - 1)] = e; // 如果是一個樹節點(代表已經轉換成紅黑樹了) // 那麼就將這個節點拆分爲lower和upper兩棵樹 // 首先會對這個節點進行遍歷 // 只要當前節點的hash & oldCap == 0就鏈接到lower樹 // 注意這裏是與oldCap進行與運算,而不是oldCap - 1(n - 1) // oldCap就是擴容後新增有效位的掩碼 // 比如oldCap=16,二進制10000,n-1 = 1111,擴容後的n-1 = 11111 // 只要hash & oldCap == 0,就代表hash的新增有效位爲0 // 否則就鏈接到upper樹(新增有效位爲1) // lower會被放入newTab[原索引j],upper樹會被放到newTab[原索引j + oldCap] // 如果lower或者upper樹的節點少於閾值,會被退化成鏈表 else if (e instanceof TreeNode) ((TreeNode<K,V>)e).split(this, newTab, j, oldCap); else { // preserve order // 下面操作的邏輯與分裂樹節點基本一致 // 只不過split()操作的是TreeNode // 而且會將兩條TreeNode鏈表組織成紅黑樹 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; } |
使用HashMap時還需要注意一點,它不會動態地進行縮容,也就是說,你不應該保留一個已經刪除過大量Entry的HashMap(如果不打算繼續添加元素的話),此時它的buckets數組經過多次擴容已經變得非常大了,這會佔用非常多的無用內存,這樣做的好處是不用多次對數組進行擴容或縮容操作。不過一般也不會出現這種情況,如果遇見了,請毫不猶豫地丟掉它,或者把數據轉移到一個新的HashMap。
添加元素
我們已經瞭解了HashMap的內部實現與工作原理,它在內部維護了一個數組,每一個key都會經過散列函數得出在數組的索引,如果兩個key的索引相同,那麼就使用分離鏈接法解決碰撞衝突,當Entry的數量大於臨界值時,對數組進行擴容。
接下來以一個添加元素(put()
)的過程爲例來梳理一下知識,下圖是put()
函數的流程圖:
然後是源碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 |
public V put(K key, V value) { return putVal(hash(key), key, value, false, true); } final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node<K,V>[] tab; Node<K,V> p; int n, i; // table == null or table.length == 0 // 第一次調用put(),初始化table if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; // 沒有發生碰撞,直接放入到數組 if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, 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, key, value); // 節點爲鏈表 else { for (int binCount = 0; ; ++binCount) { // 未找到目標節點,在鏈表尾部鏈接新節點 if ((e = p.next) == null) { p.next = newNode(hash, key, value, 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; } } // 節點已存在,替換value if (e != null) { // existing mapping for key V oldValue = e.value; if (!onlyIfAbsent || oldValue == null) e.value = value; // afterNodeXXXXX是提供給LinkedHashMap重寫的函數 // 在HashMap中沒有意義 afterNodeAccess(e); return oldValue; } } ++modCount; // 超過臨界值,進行擴容 if (++size > threshold) resize(); afterNodeInsertion(evict); return null; } |
WeakHashMap
WeakHashMap是一個基於Map接口實現的散列表,實現細節與HashMap類似(都有負載因子、散列函數等等,但沒有HashMap那麼多優化手段),它的特殊之處在於每個key都是一個弱引用。
首先我們要明白什麼是弱引用,Java將引用分爲四類(從JDK1.2開始),強度依次逐漸減弱:
-
強引用: 就是平常使用的普通引用對象,例如
Object obj = new Object()
,這就是一個強引用,強引用只要還存在,就不會被垃圾收集器回收。 -
軟引用: 軟引用表示一個還有用但並非必需的對象,不像強引用,它還需要通過SoftReference類來間接引用目標對象(除了強引用都是如此)。被軟引用關聯的對象,在將要發生內存溢出異常之前,會被放入回收範圍之中以進行第二次回收(如果第二次回收之後依舊沒有足夠的內存,那麼就會拋出OOM異常)。
-
弱引用: 同樣是表示一個非必需的對象,但要比軟引用的強度還要弱,需要通過WeakReference類來間接引用目標對象。被弱引用關聯的對象只能存活到下一次垃圾回收發生之前,當觸發垃圾回收時,無論當前內存是否足夠,都會回收掉只被弱引用關聯的對象(如果這個對象還被強引用所引用,那麼就不會被回收)。
-
虛引用: 這是一種最弱的引用關係,需要通過PhantomReference類來間接引用目標對象。一個對象是否有虛引用的存在,完全不會對其生存時間構成影響,也無法通過虛引用來獲得對象實例。虛引用的唯一作用就是能在這個對象被回收時收到一個系統通知(結合ReferenceQueue使用)。基於這點可以通過虛引用來實現對象的析構函數,這比使用
finalize()
函數是要靠譜多了。
WeakHashMap適合用來當做一個緩存來使用。假設你的緩存系統是基於強引用實現的,那麼你就必須以手動(或者用一條線程來不斷輪詢)的方式來刪除一個無效的緩存項,而基於弱引用實現的緩存項只要沒被其他強引用對象關聯,就會被直接放入回收隊列。
需要注意的是,只有key是被弱引用關聯的,而value一般都是一個強引用對象。因此,需要確保value沒有關聯到它的key,否則會對key的回收產生阻礙。在極端的情況下,一個value對象A引用了另一個key對象D,而與D相對應的value對象C又反過來引用了與A相對應的key對象B,這就會產生一個引用循環,導致D與B都無法被正常回收。想要解決這個問題,就只能把value也變成一個弱引用,例如m.put(key, new WeakReference(value))
,弱引用之間的互相引用不會產生影響。
查找操作的實現跟HashMap相比簡單了許多,只要讀懂了HashMap,基本都能看懂,源碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
/** * Value representing null keys inside tables. */ private static final Object NULL_KEY = new Object(); /** * Use NULL_KEY for key if it is null. */ private static Object maskNull(Object key) { return (key == null) ? NULL_KEY : key; } /** * Returns index for hash code h. */ private static int indexFor(int h, int length) { return h & (length-1); } public V get(Object key) { // WeakHashMap允許null key與null value // null key會被替換爲一個虛擬值 Object k = maskNull(key); int h = hash(k); Entry<K,V>[] tab = getTable(); int index = indexFor(h, tab.length); Entry<K,V> e = tab[index]; // 遍歷鏈表 while (e != null) { if (e.hash == h && eq(k, e.get())) return e.value; e = e.next; } return null; } |
儘管key是一個弱引用,但仍需手動地回收那些已經無效的Entry。這個操作會在getTable()
函數中執行,不管是查找、添加還是刪除,都需要調用getTable()
來獲得buckets數組,所以這是種防止內存泄漏的被動保護措施。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 |
/** * The table, resized as necessary. Length MUST Always be a power of two. */ Entry<K,V>[] table; /** * Reference queue for cleared WeakEntries */ private final ReferenceQueue<Object> queue = new ReferenceQueue<>(); /** * Expunges stale entries from the table. */ private void expungeStaleEntries() { // 遍歷ReferenceQueue,然後清理table中無效的Entry for (Object x; (x = queue.poll()) != null; ) { synchronized (queue) { @SuppressWarnings("unchecked") Entry<K,V> e = (Entry<K,V>) x; int i = indexFor(e.hash, table.length); Entry<K,V> prev = table[i]; Entry<K,V> p = prev; while (p != null) { Entry<K,V> next = p.next; if (p == e) { if (prev == e) table[i] = next; else prev.next = next; // Must not null out e.next; // stale entries may be in use by a HashIterator e.value = null; // Help GC size--; break; } prev = p; p = next; } } } } /** * Returns the table after first expunging stale entries. */ private Entry<K,V>[] getTable() { expungeStaleEntries(); return table; } |
然後是插入操作與刪除操作,實現都比較簡單:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
public V put(K key, V value) { Object k = maskNull(key); int h = hash(k); Entry<K,V>[] tab = getTable(); int i = indexFor(h, tab.length); for (Entry<K,V> e = tab[i]; e != null; e = e.next) { if (h == e.hash && eq(k, e.get())) { V oldValue = e.value; if (value != oldValue) e.value = value; return oldValue; } } modCount++; Entry<K,V> e = tab[i]; // e被連接在new Entry的後面 tab[i] = new Entry<>(k, value, queue, h, e); if (++size >= threshold) resize(tab.length * 2); return null; } public V remove(Object key) { Object k = maskNull(key); int h = hash(k); Entry<K,V>[] tab = getTable(); int i = indexFor(h, tab.length); Entry<K,V> prev = tab[i]; Entry<K,V> e = prev; while (e != null) { Entry<K,V> next = e.next; if (h == e.hash && eq(k, e.get())) { modCount++; size--; if (prev == e) tab[i] = next; else prev.next = next; return e.value; } prev = e; e = next; } return null; } |
我們並沒有在put()
函數中發現key被轉換成弱引用,這是怎麼回事?key只有在第一次被放入buckets數組時才需要轉換成弱引用,也就是new Entry<>(k, value, queue, h, e)
,WeakHashMap的Entry實現其實就是WeakReference的子類。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 |
/** * The entries in this hash table extend WeakReference, using its main ref * field as the key. */ private static class Entry<K,V> extends WeakReference<Object> implements Map.Entry<K,V> { V value; final int hash; Entry<K,V> next; /** * Creates new entry. */ Entry(Object key, V value, ReferenceQueue<Object> queue, int hash, Entry<K,V> next) { super(key, queue); this.value = value; this.hash = hash; this.next = next; } @SuppressWarnings("unchecked") public K getKey() { return (K) WeakHashMap.unmaskNull(get()); } public V getValue() { return value; } public V setValue(V newValue) { V oldValue = value; value = newValue; return oldValue; } public boolean equals(Object o) { if (!(o instanceof Map.Entry)) return false; Map.Entry<?,?> e = (Map.Entry<?,?>)o; K k1 = getKey(); Object k2 = e.getKey(); if (k1 == k2 || (k1 != null && k1.equals(k2))) { V v1 = getValue(); Object v2 = e.getValue(); if (v1 == v2 || (v1 != null && v1.equals(v2))) return true; } return false; } public int hashCode() { K k = getKey(); V v = getValue(); return Objects.hashCode(k) ^ Objects.hashCode(v); } public String toString() { return getKey() + "=" + getValue(); } } |
有關使用WeakReference的一個典型案例是ThreadLocal,感興趣的讀者可以參考我之前寫的博客聊一聊Spring中的線程安全性。
LinkedHashMap
LinkedHashMap繼承HashMap並實現了Map接口,同時具有可預測的迭代順序(按照插入順序排序)。它與HashMap的不同之處在於,維護了一條貫穿其全部Entry的雙向鏈表(因爲額外維護了鏈表的關係,性能上要略差於HashMap,不過集合視圖的遍歷時間與元素數量成正比,而HashMap是與buckets數組的長度成正比的),可以認爲它是散列表與鏈表的結合。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
/** * The head (eldest) of the doubly linked list. */ transient LinkedHashMap.Entry<K,V> head; /** * The tail (youngest) of the doubly linked list. */ transient LinkedHashMap.Entry<K,V> tail; /** * 迭代順序模式的標記位,如果爲true,採用訪問排序,否則,採用插入順序 * 默認插入順序(構造函數中默認設置爲false) */ final boolean accessOrder; /** * Constructs an empty insertion-ordered <tt>LinkedHashMap</tt> instance * with the default initial capacity (16) and load factor (0.75). */ public LinkedHashMap() { super(); accessOrder = false; } |
LinkedHashMap的Entry實現也繼承自HashMap,只不過多了指向前後的兩個指針。
1 2 3 4 5 6 7 8 9 |
/** * HashMap.Node subclass for normal LinkedHashMap entries. */ static class Entry<K,V> extends HashMap.Node<K,V> { Entry<K,V> before, after; Entry(int hash, K key, V value, Node<K,V> next) { super(hash, key, value, next); } } |
你也可以通過構造函數來構造一個迭代順序爲訪問順序(accessOrder設爲true)的LinkedHashMap,這個訪問順序指的是按照最近被訪問的Entry的順序進行排序(從最近最少訪問到最近最多訪問)。基於這點可以簡單實現一個採用LRU(Least Recently Used)策略的緩存。
1 2 3 4 5 6 |
public LinkedHashMap(int initialCapacity, float loadFactor, boolean accessOrder) { super(initialCapacity, loadFactor); this.accessOrder = accessOrder; } |
LinkedHashMap複用了HashMap的大部分代碼,所以它的查找實現是非常簡單的,唯一稍微複雜點的操作是保證訪問順序。
1 2 3 4 5 6 7 8 |
public V get(Object key) { Node<K,V> e; if ((e = getNode(hash(key), key)) == null) return null; if (accessOrder) afterNodeAccess(e); return e.value; } |
還記得這些afterNodeXXXX命名格式的函數嗎?我們之前已經在HashMap中見識過了,這些函數在HashMap中只是一個空實現,是專門用來讓LinkedHashMap重寫實現的hook函數。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 |
// 在HashMap.removeNode()的末尾處調用 // 將e從LinkedHashMap的雙向鏈表中刪除 void afterNodeRemoval(Node<K,V> e) { // unlink LinkedHashMap.Entry<K,V> p = (LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after; p.before = p.after = null; if (b == null) head = a; else b.after = a; if (a == null) tail = b; else a.before = b; } // 在HashMap.putVal()的末尾處調用 // evict是一個模式標記,如果爲false代表buckets數組處於創建模式 // HashMap.put()函數對此標記設置爲true void afterNodeInsertion(boolean evict) { // possibly remove eldest LinkedHashMap.Entry<K,V> first; // LinkedHashMap.removeEldestEntry()永遠返回false // 避免了最年長元素被刪除的可能(就像一個普通的Map一樣) if (evict && (first = head) != null && removeEldestEntry(first)) { K key = first.key; removeNode(hash(key), key, null, false, true); } } // HashMap.get()沒有調用此函數,所以LinkedHashMap重寫了get() // get()與put()都會調用afterNodeAccess()來保證訪問順序 // 將e移動到tail,代表最近訪問到的節點 void afterNodeAccess(Node<K,V> e) { // move node to last LinkedHashMap.Entry<K,V> last; if (accessOrder && (last = tail) != e) { LinkedHashMap.Entry<K,V> p = (LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after; p.after = null; if (b == null) head = a; else b.after = a; if (a != null) a.before = b; else last = b; if (last == null) head = p; else { p.before = last; last.after = p; } tail = p; ++modCount; } } |
注意removeEldestEntry()
默認永遠返回false,這時它的行爲與普通的Map無異。如果你把removeEldestEntry()
重寫爲永遠返回true,那麼就有可能使LinkedHashMap處於一個永遠爲空的狀態(每次put()
或者putAll()
都會刪除頭節點)。
一個比較合理的實現示例:
1 2 3 |
protected boolean removeEldestEntry(Map.Entry eldest){ return size() > MAX_SIZE; } |
LinkedHashMap重寫了newNode()
等函數,以初始化或連接節點到它內部的雙向鏈表:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 |
// 鏈接節點p到鏈表尾部(或初始化鏈表) private void linkNodeLast(LinkedHashMap.Entry<K,V> p) { LinkedHashMap.Entry<K,V> last = tail; tail = p; if (last == null) head = p; else { p.before = last; last.after = p; } } // 用dst替換掉src private void transferLinks(LinkedHashMap.Entry<K,V> src, LinkedHashMap.Entry<K,V> dst) { LinkedHashMap.Entry<K,V> b = dst.before = src.before; LinkedHashMap.Entry<K,V> a = dst.after = src.after; // src是頭節點 if (b == null) head = dst; else b.after = dst; // src是尾節點 if (a == null) tail = dst; else a.before = dst; } Node<K,V> newNode(int hash, K key, V value, Node<K,V> e) { LinkedHashMap.Entry<K,V> p = new LinkedHashMap.Entry<K,V>(hash, key, value, e); linkNodeLast(p); return p; } Node<K,V> replacementNode(Node<K,V> p, Node<K,V> next) { LinkedHashMap.Entry<K,V> q = (LinkedHashMap.Entry<K,V>)p; LinkedHashMap.Entry<K,V> t = new LinkedHashMap.Entry<K,V>(q.hash, q.key, q.value, next); transferLinks(q, t); return t; } TreeNode<K,V> newTreeNode(int hash, K key, V value, Node<K,V> next) { TreeNode<K,V> p = new TreeNode<K,V>(hash, key, value, next); linkNodeLast(p); return p; } TreeNode<K,V> replacementTreeNode(Node<K,V> p, Node<K,V> next) { LinkedHashMap.Entry<K,V> q = (LinkedHashMap.Entry<K,V>)p; TreeNode<K,V> t = new TreeNode<K,V>(q.hash, q.key, q.value, next); transferLinks(q, t); return t; } |
遍歷LinkedHashMap所需要的時間與Entry數量成正比,這是因爲迭代器直接對雙向鏈表進行迭代,而鏈表中只會含有Entry節點。迭代的順序是從頭節點開始一直到尾節點,插入操作會將新節點鏈接到尾部,所以保證了插入順序,而訪問順序會通過afterNodeAccess()
來保證,訪問次數越多的節點越接近尾部。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 |
abstract class LinkedHashIterator { LinkedHashMap.Entry<K,V> next; LinkedHashMap.Entry<K,V> current; int expectedModCount; LinkedHashIterator() { next = head; expectedModCount = modCount; current = null; } public final boolean hasNext() { return next != null; } final LinkedHashMap.Entry<K,V> nextNode() { LinkedHashMap.Entry<K,V> e = next; if (modCount != expectedModCount) throw new ConcurrentModificationException(); if (e == null) throw new NoSuchElementException(); current = e; next = e.after; return e; } 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 = modCount; } } final class LinkedKeyIterator extends LinkedHashIterator implements Iterator<K> { public final K next() { return nextNode().getKey(); } } final class LinkedValueIterator extends LinkedHashIterator implements Iterator<V> { public final V next() { return nextNode().value; } } final class LinkedEntryIterator extends LinkedHashIterator implements Iterator<Map.Entry<K,V>> { public final Map.Entry<K,V> next() { return nextNode(); } } |
ConcurrentHashMap
我們上述所講的Map都是非線程安全的,這意味着不應該在多個線程中對這些Map進行修改操作,輕則會產生數據不一致的問題,甚至還會因爲併發插入元素而導致鏈表成環(插入會觸發擴容,而擴容操作需要將原數組中的元素rehash到新數組,這時併發操作就有可能產生鏈表的循環引用從而成環),這樣在查找時就會發生死循環,影響到整個應用程序。
Collections.synchronizedMap(Map<K,V> m)
可以將一個Map轉換成線程安全的實現,其實也就是通過一個包裝類,然後把所有功能都委託給傳入的Map實現,而且包裝類是基於synchronized
關鍵字來保證線程安全的(時代的眼淚Hashtable也是基於synchronized
關鍵字),底層使用的是互斥鎖(同一時間內只能由持有鎖的線程訪問,其他競爭線程進入睡眠狀態),性能與吞吐量差強人意。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
public static <K,V> Map<K,V> synchronizedMap(Map<K,V> m) { return new SynchronizedMap<>(m); } private static class SynchronizedMap<K,V> implements Map<K,V>, Serializable { private static final long serialVersionUID = 1978198479659022715L; private final Map<K,V> m; // Backing Map final Object mutex; // Object on which to synchronize SynchronizedMap(Map<K,V> m) { this.m = Objects.requireNonNull(m); mutex = this; } SynchronizedMap(Map<K,V> m, Object mutex) { this.m = m; this.mutex = mutex; } public int size() { synchronized (mutex) {return m.size();} } public boolean isEmpty() { synchronized (mutex) {return m.isEmpty();} } ............ } |
然而ConcurrentHashMap的實現細節遠沒有這麼簡單,因此性能也要高上許多。它沒有使用一個全局鎖來鎖住自己,而是採用了減少鎖粒度的方法,儘量減少因爲競爭鎖而導致的阻塞與衝突,而且ConcurrentHashMap的檢索操作是不需要鎖的。
在Java 7中,ConcurrentHashMap把內部細分成了若干個小的HashMap,稱之爲段(Segment),默認被分爲16個段。對於一個寫操作而言,會先根據hash code進行尋址,得出該Entry應被存放在哪一個Segment,然後只要對該Segment加鎖即可。
理想情況下,一個默認的ConcurrentHashMap可以同時接受16個線程進行寫操作(如果都是對不同Segment進行操作的話)。
分段鎖對於size()
這樣的全局操作來說就沒有任何作用了,想要得出Entry的數量就需要遍歷所有Segment,獲得所有的鎖,然後再統計總數。事實上,ConcurrentHashMap會先試圖使用無鎖的方式統計總數,這個嘗試會進行3次,如果在相鄰的2次計算中獲得的Segment的modCount次數一致,代表這兩次計算過程中都沒有發生過修改操作,那麼就可以當做最終結果返回,否則,就要獲得所有Segment的鎖,重新計算size。
本文主要討論的是Java 8的ConcurrentHashMap,它與Java 7的實現差別較大。完全放棄了段的設計,而是變回與HashMap相似的設計,使用buckets數組與分離鏈接法(同樣會在超過閾值時樹化,對於構造紅黑樹的邏輯與HashMap差別不大,只不過需要額外使用CAS來保證線程安全),鎖的粒度也被細分到每個數組元素(個人認爲這樣做的原因是因爲HashMap在Java 8中也實現了不少優化,即使碰撞嚴重,也能保證一定的性能,而且Segment不僅臃腫還有弱一致性的問題存在),所以它的併發級別與數組長度相關(Java 7則是與段數相關)。
1 2 3 4 5 |
/** * The array of bins. Lazily initialized upon first insertion. * Size is always a power of two. Accessed directly by iterators. */ transient volatile Node<K,V>[] table; |
尋址
ConcurrentHashMap的散列函數與HashMap並沒有什麼區別,同樣是把key的hash code的高16位與低16位進行異或運算(因爲ConcurrentHashMap的buckets數組長度也永遠是一個2的N次方),然後將擾亂後的hash code與數組的長度減一(實際可訪問到的最大索引)進行與運算,得出的結果即是目標所在的位置。
1 2 3 4 5 6 7 |
// 2^31 - 1,int類型的最大值 // 該掩碼錶示節點hash的可用位,用來保證hash永遠爲一個正整數 static final int HASH_BITS = 0x7fffffff; static final int spread(int h) { return (h ^ (h >>> 16)) & HASH_BITS; } |
下面是查找操作的源碼,實現比較簡單。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
public V get(Object key) { Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek; int h = spread(key.hashCode()); if ((tab = table) != null && (n = tab.length) > 0 && (e = tabAt(tab, (n - 1) & h)) != null) { if ((eh = e.hash) == h) { // 先嚐試判斷鏈表頭是否爲目標,如果是就直接返回 if ((ek = e.key) == key || (ek != null && key.equals(ek))) return e.val; } else if (eh < 0) // eh < 0代表這是一個特殊節點(TreeBin或ForwardingNode) // 所以直接調用find()進行遍歷查找 return (p = e.find(h, key)) != null ? p.val : null; // 遍歷鏈表 while ((e = e.next) != null) { if (e.hash == h && ((ek = e.key) == key || (ek != null && key.equals(ek)))) return e.val; } } return null; } |
一個普通的節點(鏈表節點)的hash不可能小於0(已經在spread()
函數中修正過了),所以小於0的只可能是一個特殊節點,它不能用while循環中遍歷鏈表的方式來進行遍歷。
TreeBin是紅黑樹的頭部節點(紅黑樹的節點爲TreeNode),它本身不含有key與value,而是指向一個TreeNode節點的鏈表與它們的根節點,同時使用CAS(ConcurrentHashMap並不是完全基於互斥鎖實現的,而是與CAS這種樂觀策略搭配使用,以提高性能)實現了一個讀寫鎖,迫使Writer(持有這個鎖)在樹重構操作之前等待Reader完成。
ForwardingNode是一個在數據轉移過程(由擴容引起)中使用的臨時節點,它會被插入到頭部。它與TreeBin(和TreeNode)都是Node類的子類。
爲了判斷出哪些是特殊節點,TreeBin和ForwardingNode的hash域都只是一個虛擬值:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 |
static class Node<K,V> implements Map.Entry<K,V> { final int hash; final K key; volatile V val; volatile Node<K,V> next; Node(int hash, K key, V val, Node<K,V> next) { this.hash = hash; this.key = key; this.val = val; this.next = next; } public final V setValue(V value) { throw new UnsupportedOperationException(); } ...... /** * Virtualized support for map.get(); overridden in subclasses. */ Node<K,V> find(int h, Object k) { Node<K,V> e = this; if (k != null) { do { K ek; if (e.hash == h && ((ek = e.key) == k || (ek != null && k.equals(ek)))) return e; } while ((e = e.next) != null); } return null; } } /* * Encodings for Node hash fields. See above for explanation. */ static final int MOVED = -1; // hash for forwarding nodes static final int TREEBIN = -2; // hash for roots of trees static final int RESERVED = -3; // hash for transient reservations static final class TreeBin<K,V> extends Node<K,V> { .... TreeBin(TreeNode<K,V> b) { super(TREEBIN, null, null, null); .... } .... } static final class ForwardingNode<K,V> extends Node<K,V> { final Node<K,V>[] nextTable; ForwardingNode(Node<K,V>[] tab) { super(MOVED, null, null, null); this.nextTable = tab; } ..... } |
可見性
我們在get()
函數中並沒有發現任何與鎖相關的代碼,那麼它是怎麼保證線程安全的呢?一個操作ConcurrentHashMap.get("a")
,它的步驟基本分爲以下幾步:
-
根據散列函數計算出的索引訪問table。
-
從table中取出頭節點。
-
遍歷頭節點直到找到目標節點。
-
從目標節點中取出value並返回。
所以只要保證訪問table與節點的操作總是能夠返回最新的數據就可以了。ConcurrentHashMap並沒有採用鎖的方式,而是通過volatile
關鍵字來保證它們的可見性。在上文貼出的代碼中可以發現,table、Node.val和Node.next都是被volatile
關鍵字所修飾的。
volatile
關鍵字保證了多線程環境下變量的可見性與有序性,底層實現基於內存屏障(Memory Barrier)。
爲了優化性能,現代CPU工作時的指令執行順序與應用程序的代碼順序其實是不一致的(有些編譯器也會進行這種優化),也就是所謂的亂序執行技術。亂序執行可以提高CPU流水線的工作效率,只要保證數據符合程序邏輯上的正確性即可(遵循happens-before
原則)。不過如今是多核時代,如果隨便亂序而不提供防護措施那是會出問題的。每一個cpu上都會進行亂序優化,單cpu所保證的邏輯次序可能會被其他cpu所破壞。
內存屏障就是針對此情況的防護措施。可以認爲它是一個同步點(但它本身也是一條cpu指令)。例如在IA32
指令集架構中引入的SFENCE
指令,在該指令之前的所有寫操作必須全部完成,讀操作仍可以亂序執行。LFENCE
指令則保證之前的所有讀操作必須全部完成,另外還有粒度更粗的MFENCE
指令保證之前的所有讀寫操作都必須全部完成。
內存屏障就像是一個保護指令順序的柵欄,保護後面的指令不被前面的指令跨越。將內存屏障插入到寫操作與讀操作之間,就可以保證之後的讀操作可以訪問到最新的數據,因爲屏障前的寫操作已經把數據寫回到內存(根據緩存一致性協議,不會直接寫回到內存,而是改變該cpu私有緩存中的狀態,然後通知給其他cpu這個緩存行已經被修改過了,之後另一個cpu在讀操作時就可以發現該緩存行已經是無效的了,這時它會從其他cpu中讀取最新的緩存行,然後之前的cpu纔會更改狀態並寫回到內存)。
例如,讀一個被volatile
修飾的變量V總是能夠從JMM(Java Memory Model)主內存中獲得最新的數據。因爲內存屏障的原因,每次在使用變量V(通過JVM指令use
,後面說的也都是JVM中的指令而不是cpu)之前都必須先執行load
指令(把從主內存中得到的數據放入到工作內存),根據JVM的規定,load
指令必鬚髮生在read
指令(從主內存中讀取數據)之後,所以每次訪問變量V都會先從主內存中讀取。相對的,寫操作也因爲內存屏障保證的指令順序,每次都會直接寫回到主內存。
不過volatile
關鍵字並不能保證操作的原子性,對該變量進行併發的連續操作是非線程安全的,所幸ConcurrentHashMap只是用來確保訪問到的變量是最新的,所以也不會發生什麼問題。
出於性能考慮,Doug Lea(java.util.concurrent
包的作者)直接通過Unsafe類來對table進行操作。
Java號稱是安全的編程語言,而保證安全的代價就是犧牲程序員自由操控內存的能力。像在C/C++中可以通過操作指針變量達到操作內存的目的(其實操作的是虛擬地址),但這種靈活性在新手手中也經常會帶來一些愚蠢的錯誤,比如內存訪問越界。
Unsafe從字面意思可以看出是不安全的,它包含了許多本地方法(在JVM平臺上運行的其他語言編寫的程序,主要爲C/C++,由JNI
實現),這些方法支持了對指針的操作,所以它才被稱爲是不安全的。雖然不安全,但畢竟是由C/C++實現的,像一些與操作系統交互的操作肯定是快過Java的,畢竟Java與操作系統之間還隔了一層抽象(JVM),不過代價就是失去了JVM所帶來的多平臺可移植性(本質上也只是一個c/cpp文件,如果換了平臺那就要重新編譯)。
對table進行操作的函數有以下三個,都使用到了Unsafe(在java.util.concurrent
包隨處可見):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
@SuppressWarnings("unchecked") static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) { // 從tab數組中獲取一個引用,遵循Volatile語義 // 參數2是一個在tab中的偏移量,用來尋找目標對象 return (Node<K,V>)U.getObjectVolatile(tab, ((long)i << ASHIFT) + ABASE); } static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i, Node<K,V> c, Node<K,V> v) { // 通過CAS操作將tab數組中位於參數2偏移量位置的值替換爲v // c是期望值,如果期望值與實際值不符,返回false // 否則,v會成功地被設置到目標位置,返回true return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v); } static final <K,V> void setTabAt(Node<K,V>[] tab, int i, Node<K,V> v) { // 設置tab數組中位於參數2偏移量位置的值,遵循Volatile語義 U.putObjectVolatile(tab, ((long)i << ASHIFT) + ABASE, v); } |
如果對Unsafe感興趣,可以參考這篇文章:Java Magic. Part 4: sun.misc.Unsafe
初始化
ConcurrentHashMap與HashMap一樣是Lazy的,buckets數組會在第一次訪問put()
函數時進行初始化,它的默認構造函數甚至是個空函數。
1 2 3 4 5 |
/** * Creates a new, empty map with the default initial table size (16). */ public ConcurrentHashMap() { } |
但是有一點需要注意,ConcurrentHashMap是工作在多線程併發環境下的,如果有多個線程同時調用了put()
函數該怎麼辦?這會導致重複初始化,所以必須要有對應的防護措施。
ConcurrentHashMap聲明瞭一個用於控制table的初始化與擴容的實例變量sizeCtl,默認值爲0。當它是一個負數的時候,代表table正處於初始化或者擴容的狀態。-1
表示table正在進行初始化,-N
則表示當前有N-1個線程正在進行擴容。
在其他情況下,如果table還未初始化(table == null
),sizeCtl表示table進行初始化的數組大小(所以從構造函數傳入的initialCapacity在經過計算後會被賦給它)。如果table已經初始化過了,則表示下次觸發擴容操作的閾值,算法stzeCtl = n - (n >>> 2)
,也就是n的75%,與默認負載因子(0.75)的HashMap一致。
1 |
private transient volatile int sizeCtl; |
初始化table的操作位於函數initTable()
,源碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
/** * Initializes table, using the size recorded in sizeCtl. */ private final Node<K,V>[] initTable() { Node<K,V>[] tab; int sc; while ((tab = table) == null || tab.length == 0) { // sizeCtl小於0,這意味着已經有其他線程進行初始化了 // 所以當前線程讓出CPU時間片 if ((sc = sizeCtl) < 0) Thread.yield(); // lost initialization race; just spin // 否則,通過CAS操作嘗試修改sizeCtl else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) { try { if ((tab = table) == null || tab.length == 0) { // 默認構造函數,sizeCtl = 0,使用默認容量(16)進行初始化 // 否則,會根據sizeCtl進行初始化 int n = (sc > 0) ? sc : DEFAULT_CAPACITY; @SuppressWarnings("unchecked") Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n]; table = tab = nt; // 計算閾值,n的75% sc = n - (n >>> 2); } } finally { // 閾值賦給sizeCtl sizeCtl = sc; } break; } } return tab; } |
sizeCtl是一個volatile
變量,只要有一個線程CAS操作成功,sizeCtl就會被暫時地修改爲-1,這樣其他線程就能夠根據sizeCtl得知table是否已經處於初始化狀態中,最後sizeCtl會被設置成閾值,用於觸發擴容操作。
擴容
ConcurrentHashMap觸發擴容的時機與HashMap類似,要麼是在將鏈表轉換成紅黑樹時判斷table數組的長度是否小於閾值(64),如果小於就進行擴容而不是樹化,要麼就是在添加元素的時候,判斷當前Entry數量是否超過閾值,如果超過就進行擴容。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 |
private final void treeifyBin(Node<K,V>[] tab, int index) { Node<K,V> b; int n, sc; if (tab != null) { // 小於MIN_TREEIFY_CAPACITY,進行擴容 if ((n = tab.length) < MIN_TREEIFY_CAPACITY) tryPresize(n << 1); else if ((b = tabAt(tab, index)) != null && b.hash >= 0) { synchronized (b) { // 將鏈表轉換成紅黑樹... } } } } ... final V putVal(K key, V value, boolean onlyIfAbsent) { ... addCount(1L, binCount); // 計數 return null; } private final void addCount(long x, int check) { // 計數... if (check >= 0) { Node<K,V>[] tab, nt; int n, sc; // s(元素個數)大於等於sizeCtl,觸發擴容 while (s >= (long)(sc = sizeCtl) && (tab = table) != null && (n = tab.length) < MAXIMUM_CAPACITY) { // 擴容標誌位 int rs = resizeStamp(n); // sizeCtl爲負數,代表正有其他線程進行擴容 if (sc < 0) { // 擴容已經結束,中斷循環 if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 || sc == rs + MAX_RESIZERS || (nt = nextTable) == null || transferIndex <= 0) break; // 進行擴容,並設置sizeCtl,表示擴容線程 + 1 if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)) transfer(tab, nt); } // 觸發擴容(第一個進行擴容的線程) // 並設置sizeCtl告知其他線程 else if (U.compareAndSwapInt(this, SIZECTL, sc, (rs << RESIZE_STAMP_SHIFT) + 2)) transfer(tab, null); // 統計個數,用於循環檢測是否還需要擴容 s = sumCount(); } } } |
可以看到有關sizeCtl的操作牽涉到了大量的位運算,我們先來理解這些位運算的意義。首先是resizeStamp()
,該函數返回一個用於數據校驗的標誌位,意思是對長度爲n的table進行擴容。它將n的前導零(最高有效位之前的零的數量)和1 << 15
做或運算,這時低16位的最高位爲1,其他都爲n的前導零。
1 2 3 4 |
static final int resizeStamp(int n) { // RESIZE_STAMP_BITS = 16 return Integer.numberOfLeadingZeros(n) | (1 << (RESIZE_STAMP_BITS - 1)); } |
初始化sizeCtl(擴容操作被第一個線程首次進行)的算法爲(rs << RESIZE_STAMP_SHIFT) + 2
,首先RESIZE_STAMP_SHIFT = 32 - RESIZE_STAMP_BITS = 16
,那麼rs << 16
等於將這個標誌位移動到了高16位,這時最高位爲1,所以sizeCtl此時是個負數,然後加二(至於爲什麼是2,還記得有關sizeCtl的說明嗎?1代表初始化狀態,所以實際的線程個數是要減去1的)代表當前有一個線程正在進行擴容,
這樣sizeCtl就被分割成了兩部分,高16位是一個對n的數據校驗的標誌位,低16位表示參與擴容操作的線程個數 + 1。
可能會有讀者有所疑惑,更新進行擴容的線程數量的操作爲什麼是sc + 1
而不是sc - 1
,這是因爲對sizeCtl的操作都是基於位運算的,所以不會關心它本身的數值是多少,只關心它在二進制上的數值,而sc + 1
會在低16位上加1。
tryPresize()
函數跟addCount()
的後半段邏輯類似,不斷地根據sizeCtl判斷當前的狀態,然後選擇對應的策略。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
private final void tryPresize(int size) { // 對size進行修正 int c = (size >= (MAXIMUM_CAPACITY >>> 1)) ? MAXIMUM_CAPACITY : tableSizeFor(size + (size >>> 1) + 1); int sc; // sizeCtl是默認值或正整數 // 代表table還未初始化 // 或還沒有其他線程正在進行擴容 while ((sc = sizeCtl) >= 0) { Node<K,V>[] tab = table; int n; if (tab == null || (n = tab.length) == 0) { n = (sc > c) ? sc : c; // 設置sizeCtl,告訴其他線程,table現在正處於初始化狀態 if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) { try { if (table == tab) { @SuppressWarnings("unchecked") Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n]; table = nt; // 計算下次觸發擴容的閾值 sc = n - (n >>> 2); } } finally { // 將閾值賦給sizeCtl sizeCtl = sc; } } } // 沒有超過閾值或者大於容量的上限,中斷循環 else if (c <= sc || n >= MAXIMUM_CAPACITY) break; // 進行擴容,與addCount()後半段的邏輯一致 else if (tab == table) { int rs = resizeStamp(n); if (sc < 0) { Node<K,V>[] nt; if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 || sc == rs + MAX_RESIZERS || (nt = nextTable) == null || transferIndex <= 0) break; if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)) transfer(tab, nt); } else if (U.compareAndSwapInt(this, SIZECTL, sc, (rs << RESIZE_STAMP_SHIFT) + 2)) transfer(tab, null); } } } |
擴容操作的核心在於數據的轉移,在單線程環境下數據的轉移很簡單,無非就是把舊數組中的數據遷移到新的數組。但是這在多線程環境下是行不通的,需要保證線程安全性,在擴容的時候其他線程也可能正在添加元素,這時又觸發了擴容怎麼辦?有人可能會說,這不難啊,用一個互斥鎖把數據轉移操作的過程鎖住不就好了?這確實是一種可行的解決方法,但同樣也會帶來極差的吞吐量。
互斥鎖會導致所有訪問臨界區的線程陷入阻塞狀態,這會消耗額外的系統資源,內核需要保存這些線程的上下文並放到阻塞隊列,持有鎖的線程耗時越長,其他競爭線程就會一直被阻塞,因此吞吐量低下,導致響應時間緩慢。而且鎖總是會伴隨着死鎖問題,一旦發生死鎖,整個應用程序都會因此受到影響,所以加鎖永遠是最後的備選方案。
Doug Lea沒有選擇直接加鎖,而是基於CAS實現無鎖的併發同步策略,令人佩服的是他不僅沒有把其他線程拒之門外,甚至還邀請它們一起來協助工作。
那麼如何才能讓多個線程協同工作呢?Doug Lea把整個table數組當做多個線程之間共享的任務隊列,然後只需維護一個指針,當有一個線程開始進行數據轉移,就會先移動指針,表示指針劃過的這片bucket區域由該線程負責。
這個指針被聲明爲一個volatile
整型變量,它的初始位置位於table的尾部,即它等於table.length
,很明顯這個任務隊列是逆向遍歷的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
/** * The next table index (plus one) to split while resizing. */ private transient volatile int transferIndex; /** * 一個線程需要負責的最小bucket數 */ private static final int MIN_TRANSFER_STRIDE = 16; /** * The next table to use; non-null only while resizing. */ private transient volatile Node<K,V>[] nextTable; |
一個已經遷移完畢的bucket會被替換成ForwardingNode節點,用來標記此bucket已經被其他線程遷移完畢了。我們之前提到過ForwardingNode,它是一個特殊節點,可以通過hash域的虛擬值來識別它,它同樣重寫了find()
函數,用來在新數組中查找目標。
數據遷移的操作位於transfer()
函數,多個線程之間依靠sizeCtl與transferIndex指針來協同工作,每個線程都有自己負責的區域,一個完成遷移的bucket會被設置爲ForwardingNode,其他線程遇見這個特殊節點就跳過該bucket,處理下一個bucket。
transfer()
函數可以大致分爲三部分,第一部分對後續需要使用的變量進行初始化:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
/** * Moves and/or copies the nodes in each bin to new table. See * above for explanation. */ private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) { int n = tab.length, stride; // 根據當前機器的CPU數量來決定每個線程負責的bucket數 // 避免因爲擴容線程過多,反而影響到性能 if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE) stride = MIN_TRANSFER_STRIDE; // subdivide range // 初始化nextTab,容量爲舊數組的一倍 if (nextTab == null) { // initiating try { @SuppressWarnings("unchecked") Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1]; nextTab = nt; } catch (Throwable ex) { // try to cope with OOME sizeCtl = Integer.MAX_VALUE; return; } nextTable = nextTab; transferIndex = n; // 初始化指針 } int nextn = nextTab.length; ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab); boolean advance = true; boolean finishing = false; // to ensure sweep before committing nextTab |
第二部分爲當前線程分配任務和控制當前線程的任務進度,這部分是transfer()
的核心邏輯,描述瞭如何與其他線程協同工作:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 |
// i指向當前bucket,bound表示當前線程所負責的bucket區域的邊界 for (int i = 0, bound = 0;;) { Node<K,V> f; int fh; // 這個循環使用CAS不斷嘗試爲當前線程分配任務 // 直到分配成功或任務隊列已經被全部分配完畢 // 如果當前線程已經被分配過bucket區域 // 那麼會通過--i指向下一個待處理bucket然後退出該循環 while (advance) { int nextIndex, nextBound; // --i表示將i指向下一個待處理的bucket // 如果--i >= bound,代表當前線程已經分配過bucket區域 // 並且還留有未處理的bucket if (--i >= bound || finishing) advance = false; // transferIndex指針 <= 0 表示所有bucket已經被分配完畢 else if ((nextIndex = transferIndex) <= 0) { i = -1; advance = false; } // 移動transferIndex指針 // 爲當前線程設置所負責的bucket區域的範圍 // i指向該範圍的第一個bucket,注意i是逆向遍歷的 // 這個範圍爲(bound, i),i是該區域最後一個bucket,遍歷順序是逆向的 else if (U.compareAndSwapInt (this, TRANSFERINDEX, nextIndex, nextBound = (nextIndex > stride ? nextIndex - stride : 0))) { bound = nextBound; i = nextIndex - 1; advance = false; } } // 當前線程已經處理完了所負責的所有bucket if (i < 0 || i >= n || i + n >= nextn) { int sc; // 如果任務隊列已經全部完成 if (finishing) { nextTable = null; table = nextTab; // 設置新的閾值 sizeCtl = (n << 1) - (n >>> 1); return; } // 工作中的擴容線程數量減1 if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) { // (resizeStamp << RESIZE_STAMP_SHIFT) + 2代表當前有一個擴容線程 // 相對的,(sc - 2) != resizeStamp << RESIZE_STAMP_SHIFT // 表示當前還有其他線程正在進行擴容,所以直接返回 if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT) return; // 否則,當前線程就是最後一個進行擴容的線程 // 設置finishing標識 finishing = advance = true; i = n; // recheck before commit } } // 如果待處理bucket是空的 // 那麼插入ForwardingNode,以通知其他線程 else if ((f = tabAt(tab, i)) == null) advance = casTabAt(tab, i, null, fwd); // 如果待處理bucket的頭節點是ForwardingNode // 說明此bucket已經被處理過了,跳過該bucket else if ((fh = f.hash) == MOVED) advance = true; // already processed |
最後一部分是具體的遷移過程(對當前指向的bucket),這部分的邏輯與HashMap類似,拿舊數組的容量當做一個掩碼,然後與節點的hash進行與操作,可以得出該節點的新增有效位,如果新增有效位爲0就放入一個鏈表A,如果爲1就放入另一個鏈表B,鏈表A在新數組中的位置不變(跟在舊數組的索引一致),鏈表B在新數組中的位置爲原索引加上舊數組容量。
這個方法減少了rehash的計算量,而且還能達到均勻分佈的目的,如果不能理解請去看本文中HashMap擴容操作的解釋。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 |
else { // 對於節點的操作還是要加上鎖的 // 不過這個鎖的粒度很小,只鎖住了bucket的頭節點 synchronized (f) { if (tabAt(tab, i) == f) { Node<K,V> ln, hn; // hash code不爲負,代表這是條鏈表 if (fh >= 0) { // fh & n 獲得hash code的新增有效位,用於將鏈表分離成兩類 // 要麼是0要麼是1,關於這個位運算的更多細節 // 請看本文中有關HashMap擴容操作的解釋 int runBit = fh & n; Node<K,V> lastRun = f; // 這個循環用於記錄最後一段連續的同一類節點 // 這個類別是通過fh & n來區分的 // 這段連續的同類節點直接被複用,不會產生額外的複製 for (Node<K,V> p = f.next; p != null; p = p.next) { int b = p.hash & n; if (b != runBit) { runBit = b; lastRun = p; } } // 0被放入ln鏈表,1被放入hn鏈表 // lastRun是連續同類節點的起始節點 if (runBit == 0) { ln = lastRun; hn = null; } else { hn = lastRun; ln = null; } // 將最後一段的連續同類節點之前的節點按類別複製到ln或hn // 鏈表的插入方向是往頭部插入的,Node構造函數的第四個參數是next // 所以就算遇到類別與lastRun一致的節點也只會被插入到頭部 for (Node<K,V> p = f; p != lastRun; p = p.next) { int ph = p.hash; K pk = p.key; V pv = p.val; if ((ph & n) == 0) ln = new Node<K,V>(ph, pk, pv, ln); else hn = new Node<K,V>(ph, pk, pv, hn); } // ln鏈表被放入到原索引位置,hn放入到原索引 + 舊數組容量 // 這一點與HashMap一致,如果看不懂請去參考本文對HashMap擴容的講解 setTabAt(nextTab, i, ln); setTabAt(nextTab, i + n, hn); setTabAt(tab, i, fwd); // 標記該bucket已被處理 advance = true; } // 對紅黑樹的操作,邏輯與鏈表一樣,按新增有效位進行分類 else if (f instanceof TreeBin) { TreeBin<K,V> t = (TreeBin<K,V>)f; TreeNode<K,V> lo = null, loTail = null; TreeNode<K,V> hi = null, hiTail = null; int lc = 0, hc = 0; for (Node<K,V> e = t.first; e != null; e = e.next) { int h = e.hash; TreeNode<K,V> p = new TreeNode<K,V> (h, e.key, e.val, null, null); if ((h & n) == 0) { if ((p.prev = loTail) == null) lo = p; else loTail.next = p; loTail = p; ++lc; } else { if ((p.prev = hiTail) == null) hi = p; else hiTail.next = p; hiTail = p; ++hc; } } // 元素數量沒有超過UNTREEIFY_THRESHOLD,退化成鏈表 ln = (lc <= UNTREEIFY_THRESHOLD) ? untreeify(lo) : (hc != 0) ? new TreeBin<K,V>(lo) : t; hn = (hc <= UNTREEIFY_THRESHOLD) ? untreeify(hi) : (lc != 0) ? new TreeBin<K,V>(hi) : t; setTabAt(nextTab, i, ln); setTabAt(nextTab, i + n, hn); setTabAt(tab, i, fwd); advance = true; } |
計數
在Java 7中ConcurrentHashMap對每個Segment單獨計數,想要得到總數就需要獲得所有Segment的鎖,然後進行統計。由於Java 8拋棄了Segment,顯然是不能再這樣做了,而且這種方法雖然簡單準確但也捨棄了性能。
Java 8聲明瞭一個volatile
變量baseCount用於記錄元素的個數,對這個變量的修改操作是基於CAS的,每當插入元素或刪除元素時都會調用addCount()
函數進行計數。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
private transient volatile long baseCount; private final void addCount(long x, int check) { CounterCell[] as; long b, s; // 嘗試使用CAS更新baseCount失敗 // 轉用CounterCells進行更新 if ((as = counterCells) != null || !U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) { CounterCell a; long v; int m; boolean uncontended = true; // 在CounterCells未初始化 // 或嘗試通過CAS更新當前線程的CounterCell失敗時 // 調用fullAddCount(),該函數負責初始化CounterCells和更新計數 if (as == null || (m = as.length - 1) < 0 || (a = as[ThreadLocalRandom.getProbe() & m]) == null || !(uncontended = U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) { fullAddCount(x, uncontended); return; } if (check <= 1) return; // 統計總數 s = sumCount(); } if (check >= 0) { // 判斷是否需要擴容,在上文中已經講過了 } } |
counterCells是一個元素爲CounterCell的數組,該數組的大小與當前機器的CPU數量有關,並且它不會被主動初始化,只有在調用fullAddCount()
函數時纔會進行初始化。
CounterCell是一個簡單的內部靜態類,每個CounterCell都是一個用於記錄數量的單元:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
/** * Table of counter cells. When non-null, size is a power of 2. */ private transient volatile CounterCell[] counterCells; /** * A padded cell for distributing counts. Adapted from LongAdder * and Striped64. See their internal docs for explanation. */ @sun.misc.Contended static final class CounterCell { volatile long value; CounterCell(long x) { value = x; } } |
註解@sun.misc.Contended
用於解決僞共享問題。所謂僞共享,即是在同一緩存行(CPU緩存的基本單位)中存儲了多個變量,當其中一個變量被修改時,就會影響到同一緩存行內的其他變量,導致它們也要跟着被標記爲失效,其他變量的緩存命中率將會受到影響。解決僞共享問題的方法一般是對該變量填充一些無意義的佔位數據,從而使它獨享一個緩存行。
ConcurrentHashMap的計數設計與LongAdder類似。在一個低併發的情況下,就只是簡單地使用CAS操作來對baseCount進行更新,但只要這個CAS操作失敗一次,就代表有多個線程正在競爭,那麼就轉而使用CounterCell數組進行計數,數組內的每個ConuterCell都是一個獨立的計數單元。
每個線程都會通過ThreadLocalRandom.getProbe() & m
尋址找到屬於它的CounterCell,然後進行計數。ThreadLocalRandom是一個線程私有的僞隨機數生成器,每個線程的probe都是不同的(這點基於ThreadLocalRandom的內部實現,它在內部維護了一個probeGenerator,這是一個類型爲AtomicInteger的靜態常量,每當初始化一個ThreadLocalRandom時probeGenerator都會先自增一個常量然後返回的整數即爲當前線程的probe,probe變量被維護在Thread對象中),可以認爲每個線程的probe就是它在CounterCell數組中的hash code。
這種方法將競爭數據按照線程的粒度進行分離,相比所有競爭線程對一個共享變量使用CAS不斷嘗試在性能上要效率多了,這也是爲什麼在高併發環境下LongAdder要優於AtomicInteger的原因。
fullAddCount()
函數根據當前線程的probe尋找對應的CounterCell進行計數,如果CounterCell數組未被初始化,則初始化CounterCell數組和CounterCell。該函數的實現與Striped64類(LongAdder的父類)的longAccumulate()
函數是一樣的,把CounterCell數組當成一個散列表,每個線程的probe就是hash code,散列函數也僅僅是簡單的(n - 1) & probe
。
CounterCell數組的大小永遠是一個2的n次方,初始容量爲2,每次擴容的新容量都是之前容量乘以二,處於性能考慮,它的最大容量上限是機器的CPU數量。
所以說CounterCell數組的碰撞衝突是很嚴重的,因爲它的bucket基數太小了。而發生碰撞就代表着一個CounterCell會被多個線程競爭,爲了解決這個問題,Doug Lea使用無限循環加上CAS來模擬出一個自旋鎖來保證線程安全,自旋鎖的實現基於一個被volatile
修飾的整數變量,該變量只會有兩種狀態:0和1,當它被設置爲0時表示沒有加鎖,當它被設置爲1時表示已被其他線程加鎖。這個自旋鎖用於保護初始化CounterCell、初始化CounterCell數組以及對CounterCell數組進行擴容時的安全。
CounterCell更新計數是依賴於CAS的,每次循環都會嘗試通過CAS進行更新,如果成功就退出無限循環,否則就調用ThreadLocalRandom.advanceProbe()
函數爲當前線程更新probe,然後重新開始循環,以期望下一次尋址到的CounterCell沒有被其他線程競爭。
如果連着兩次CAS更新都沒有成功,那麼會對CounterCell數組進行一次擴容,這個擴容操作只會在當前循環中觸發一次,而且只能在容量小於上限時觸發。
fullAddCount()
函數的主要流程如下:
-
首先檢查當前線程有沒有初始化過ThreadLocalRandom,如果沒有則進行初始化。ThreadLocalRandom負責更新線程的probe,而probe又是在數組中進行尋址的關鍵。
-
檢查CounterCell數組是否已經初始化,如果已初始化,那麼就根據probe找到對應的CounterCell。
-
如果這個CounterCell等於null,需要先初始化CounterCell,通過把計數增量傳入構造函數,所以初始化只要成功就說明更新計數已經完成了。初始化的過程需要獲取自旋鎖。
-
如果不爲null,就按上文所說的邏輯對CounterCell實施更新計數。
-
-
CounterCell數組未被初始化,嘗試獲取自旋鎖,進行初始化。數組初始化的過程會附帶初始化一個CounterCell來記錄計數增量,所以只要初始化成功就表示更新計數完成。
-
如果自旋鎖被其他線程佔用,無法進行數組的初始化,只好通過CAS更新baseCount。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 |
private final void fullAddCount(long x, boolean wasUncontended) { int h; // 當前線程的probe等於0,證明該線程的ThreadLocalRandom還未被初始化 // 以及當前線程是第一次進入該函數 if ((h = ThreadLocalRandom.getProbe()) == 0) { // 初始化ThreadLocalRandom,當前線程會被設置一個probe ThreadLocalRandom.localInit(); // force initialization // probe用於在CounterCell數組中尋址 h = ThreadLocalRandom.getProbe(); // 未競爭標誌 wasUncontended = true; } // 衝突標誌 boolean collide = false; // True if last slot nonempty for (;;) { CounterCell[] as; CounterCell a; int n; long v; // CounterCell數組已初始化 if ((as = counterCells) != null && (n = as.length) > 0) { // 如果尋址到的Cell爲空,那麼創建一個新的Cell if ((a = as[(n - 1) & h]) == null) { // cellsBusy是一個只有0和1兩個狀態的volatile整數 // 它被當做一個自旋鎖,0代表無鎖,1代表加鎖 if (cellsBusy == 0) { // Try to attach new Cell // 將傳入的x作爲初始值創建一個新的CounterCell CounterCell r = new CounterCell(x); // Optimistic create // 通過CAS嘗試對自旋鎖加鎖 if (cellsBusy == 0 && U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) { // 加鎖成功,聲明Cell是否創建成功的標誌 boolean created = false; try { // Recheck under lock CounterCell[] rs; int m, j; // 再次檢查CounterCell數組是否不爲空 // 並且尋址到的Cell爲空 if ((rs = counterCells) != null && (m = rs.length) > 0 && rs[j = (m - 1) & h] == null) { // 將之前創建的新Cell放入數組 rs[j] = r; created = true; } } finally { // 釋放鎖 cellsBusy = 0; } // 如果已經創建成功,中斷循環 // 因爲新Cell的初始值就是傳入的增量,所以計數已經完畢了 if (created) break; // 如果未成功 // 代表as[(n - 1) & h]這個位置的Cell已經被其他線程設置 // 那麼就從循環頭重新開始 continue; // Slot is now non-empty } } collide = false; } // as[(n - 1) & h]非空 // 在addCount()函數中通過CAS更新當前線程的Cell進行計數失敗 // 會傳入wasUncontended = false,代表已經有其他線程進行競爭 else if (!wasUncontended) // CAS already known to fail // 設置未競爭標誌,之後會重新計算probe,然後重新執行循環 wasUncontended = true; // Continue after rehash // 嘗試進行計數,如果成功,那麼就退出循環 else if (U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x)) break; // 嘗試更新失敗,檢查counterCell數組是否已經擴容 // 或者容量達到最大值(CPU的數量) else if (counterCells != as || n >= NCPU) // 設置衝突標誌,防止跳入下面的擴容分支 // 之後會重新計算probe collide = false; // At max size or stale // 設置衝突標誌,重新執行循環 // 如果下次循環執行到該分支,並且衝突標誌仍然爲true // 那麼會跳過該分支,到下一個分支進行擴容 else if (!collide) collide = true; // 嘗試加鎖,然後對counterCells數組進行擴容 else if (cellsBusy == 0 && U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) { try { // 檢查是否已被擴容 if (counterCells == as) {// Expand table unless stale // 新數組容量爲之前的1倍 CounterCell[] rs = new CounterCell[n << 1]; // 遷移數據到新數組 for (int i = 0; i < n; ++i) rs[i] = as[i]; counterCells = rs; } } finally { // 釋放鎖 cellsBusy = 0; } collide = false; // 重新執行循環 continue; // Retry with expanded table } // 爲當前線程重新計算probe h = ThreadLocalRandom.advanceProbe(h); } // CounterCell數組未初始化,嘗試獲取自旋鎖,然後進行初始化 else if (cellsBusy == 0 && counterCells == as && U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) { boolean init = false; try { // Initialize table if (counterCells == as) { // 初始化CounterCell數組,初始容量爲2 CounterCell[] rs = new CounterCell[2]; // 初始化CounterCell rs[h & 1] = new CounterCell(x); counterCells = rs; init = true; } } finally { cellsBusy = 0; } // 初始化CounterCell數組成功,退出循環 if (init) break; } // 如果自旋鎖被佔用,則只好嘗試更新baseCount else if (U.compareAndSwapLong(this, BASECOUNT, v = baseCount, v + x)) break; // Fall back on using base } } |
對於統計總數,只要能夠理解CounterCell的思想,就很簡單了。仔細想一想,每次計數的更新都會被分攤在baseCount和CounterCell數組中的某一CounterCell,想要獲得總數,把它們統計相加就是了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
public int size() { long n = sumCount(); return ((n < 0L) ? 0 : (n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE : (int)n); } final long sumCount() { CounterCell[] as = counterCells; CounterCell a; long sum = baseCount; if (as != null) { for (int i = 0; i < as.length; ++i) { if ((a = as[i]) != null) sum += a.value; } } return sum; } |
其實size()
函數返回的總數可能並不是百分百精確的,試想如果前一個遍歷過的CounterCell又進行了更新會怎麼樣?儘管只是一個估算值,但在大多數場景下都還能接受,而且性能上是要比Java 7好上太多了。
添加元素
添加元素的主要邏輯與HashMap沒什麼區別,有所區別的複雜操作如擴容和計數我們上文都已經深入解析過了,所以整體來說putVal()
函數還是比較簡單的,可能唯一需要注意的就是在對節點進行操作的時候需要通過互斥鎖保證線程安全,這個互斥鎖的粒度很小,只對需要操作的這個bucket加鎖。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 |
public V put(K key, V value) { return putVal(key, value, false); } /** Implementation for put and putIfAbsent */ final V putVal(K key, V value, boolean onlyIfAbsent) { if (key == null || value == null) throw new NullPointerException(); int hash = spread(key.hashCode()); int binCount = 0; // 節點計數器,用於判斷是否需要樹化 // 無限循環+CAS,無鎖的標準套路 for (Node<K,V>[] tab = table;;) { Node<K,V> f; int n, i, fh; // 初始化table if (tab == null || (n = tab.length) == 0) tab = initTable(); // bucket爲null,通過CAS創建頭節點,如果成功就結束循環 else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) { if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null))) break; // no lock when adding to empty bin } // bucket爲ForwardingNode // 當前線程前去協助進行擴容 else if ((fh = f.hash) == MOVED) tab = helpTransfer(tab, f); else { V oldVal = null; synchronized (f) { if (tabAt(tab, i) == f) { // 節點是鏈表 if (fh >= 0) { binCount = 1; for (Node<K,V> e = f;; ++binCount) { K ek; // 找到目標,設置value if (e.hash == hash && ((ek = e.key) == key || (ek != null && key.equals(ek)))) { oldVal = e.val; if (!onlyIfAbsent) e.val = value; break; } Node<K,V> pred = e; // 未找到節點,插入新節點到鏈表尾部 if ((e = e.next) == null) { pred.next = new Node<K,V>(hash, key, value, null); break; } } } // 節點是紅黑樹 else if (f instanceof TreeBin) { Node<K,V> p; binCount = 2; if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key, value)) != null) { oldVal = p.val; if (!onlyIfAbsent) p.val = value; } } } } // 根據bucket中的節點數決定是否樹化 if (binCount != 0) { if (binCount >= TREEIFY_THRESHOLD) treeifyBin(tab, i); // oldVal不等於null,說明沒有新節點 // 所以直接返回,不進行計數 if (oldVal != null) return oldVal; break; } } } // 計數 addCount(1L, binCount); return null; } |
至於刪除元素的操作位於函數replaceNode(Object key, V value, Object cv)
,當table[key].val
等於期望值cv時(或cv等於null),更新節點的值爲value,如果value等於null,那麼刪除該節點。
remove()
函數通過調用replaceNode(key, null, null)
來達成刪除目標節點的目的,replaceNode()
的具體實現與putVal()
沒什麼差別,只不過對鏈表的操作有所不同而已,所以就不多敘述了。
並行計算
Java 8除了對ConcurrentHashMap重新設計以外,還引入了基於Lambda表達式的Stream API。它是對集合對象功能上的增強(所以不止ConcurrentHashMap,其他集合也都實現了該API),以一種優雅的方式來批量操作、聚合或遍歷集合中的數據。
最重要的是,它還提供了並行模式,充分利用了多核CPU的優勢實現並行計算。讓我們看看如下的示例代碼:
1 2 3 4 5 6 7 8 9 10 |
public static void main(String[] args) { ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>(); String keys = "ABCDEFG"; for (int i = 1; i <= keys.length(); i++) { map.put(String.valueOf(keys.charAt(i - 1)), i); } map.forEach(2, (k, v) -> System.out.println("key-" + k + ":value-" + v + ". by thread->" + Thread.currentThread().getName())); } |
這段代碼通過兩個線程(包括主線程)並行地遍歷map中的元素,然後輸出到控制檯,輸出如下:
1 2 3 4 5 6 7 |
key-A:value-1. by thread->main key-D:value-4. by thread->ForkJoinPool.commonPool-worker-2 key-B:value-2. by thread->main key-E:value-5. by thread->ForkJoinPool.commonPool-worker-2 key-C:value-3. by thread->main key-F:value-6. by thread->ForkJoinPool.commonPool-worker-2 key-G:value-7. by thread->ForkJoinPool.commonPool-worker-2 |
很明顯,有兩個線程在進行工作,那麼這是怎麼實現的呢?我們先來看看forEach()
函數:
1 2 3 4 5 6 7 |
public void forEach(long parallelismThreshold, BiConsumer<? super K,? super V> action) { if (action == null) throw new NullPointerException(); new ForEachMappingTask<K,V> (null, batchFor(parallelismThreshold), 0, 0, table, action).invoke(); } |
parallelismThreshold
是需要並行執行該操作的線程數量,action
則是回調函數(我們想要執行的操作)。action
的類型爲BiConsumer,是一個用於支持Lambda表達式的FunctionalInterface,它接受兩個輸入參數並返回0個結果。
1 2 3 4 5 6 7 8 9 10 |
@FunctionalInterface public interface BiConsumer<T, U> { /** * Performs this operation on the given arguments. * * @param t the first input argument * @param u the second input argument */ void accept(T t, U u); |
看來實現並行計算的關鍵在於ForEachMappingTask對象,通過它的繼承關係結構圖可以發現,ForEachMappingTask其實就是ForkJoinTask。
集合的並行計算是基於Fork/Join框架實現的,工作線程交由ForkJoinPool線程池維護。它推崇分而治之的思想,將一個大的任務分解成多個小的任務,通過fork()
函數(有點像Linux的fork()
系統調用來創建子進程)來開啓一個工作線程執行其中一個小任務,通過join()
函數等待工作線程執行完畢(需要等所有工作線程執行完畢才能合併最終結果),只要所有的小任務都已經處理完成,就代表這個大的任務也完成了。
像上文中的示例代碼就是將遍歷這個大任務分解成了N個小任務,然後交由兩個工作線程進行處理。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
static final class ForEachMappingTask<K,V> extends BulkTask<K,V,Void> { final BiConsumer<? super K, ? super V> action; ForEachMappingTask (BulkTask<K,V,?> p, int b, int i, int f, Node<K,V>[] t, BiConsumer<? super K,? super V> action) { super(p, b, i, f, t); this.action = action; } public final void compute() { final BiConsumer<? super K, ? super V> action; if ((action = this.action) != null) { for (int i = baseIndex, f, h; batch > 0 && (h = ((f = baseLimit) + i) >>> 1) > i;) { // 記錄待完成任務的數量 addToPendingCount(1); // 開啓一個工作線程執行任務 // 其餘參數是任務的區間以及table和回調函數 new ForEachMappingTask<K,V> (this, batch >>>= 1, baseLimit = h, f, tab, action).fork(); } for (Node<K,V> p; (p = advance()) != null; ) // 調用回調函數 action.accept(p.key, p.val); // 與addToPendingCount()相反 // 它會減少待完成任務的計數器 // 如果該計數器爲0,代表所有任務已經完成了 propagateCompletion(); } } } |
其他並行計算函數的實現也都差不多,只不過具體的Task實現不同,例如search()
:
1 2 3 4 5 6 7 |
public <U> U search(long parallelismThreshold, BiFunction<? super K, ? super V, ? extends U> searchFunction) { if (searchFunction == null) throw new NullPointerException(); return new SearchMappingsTask<K,V,U> (null, batchFor(parallelismThreshold), 0, 0, table, searchFunction, new AtomicReference<U>()).invoke(); } |
爲了節省篇幅(說實話現在似乎很少有人能耐心看完一篇長文(:з」∠)),有關Stream API是如何使用Fork/Join框架進行工作以及實現細節就不多講了,以後有機會再說吧。
參考文獻