hashMap普通功能使用並不複雜,如:(Kotlin寫法)
var hMap: HashMap<Int?, String> = hashMapOf()
hMap.put(1, "a")
//或 hMap[1] = "a"
注意:我們在使用 HashMap 時,最好選擇不可變對象作爲 key。如:String、Integer 等不可變類型作爲 key。不要使用可變對象作爲key
參考
HashMap存儲數據是非有序的,且是非線程安全的。如果需要線程安全,去看下ConcurrentHashMap
HashMap的底層是 數組+鏈表+紅黑樹(JDK1.8 增加了紅黑樹部分)。HashMap增刪改查等常規操作,都有不錯的執行效率,是ArrayList和LinkedList等數據結構的一種折中實現。創建一個HashMap,如果沒有指定初始大小,默認底層hash表數組的大小爲16。
其實,HashMap的底層hash表數組的大小,都是2的冪。這是因爲,要進行hash運行,得到hash值時,有位運算(位運算的效率高)
HashMap的核心元素有:
1、size:用於記錄 HashMap 實際存儲元素的個數;
2、loadFactor:負載因子。默認 0.75;
3、threshold:擴容的閾值,達到閾值便會觸發擴容機制 resize;
4、Node<K,V>[] table; 底層數組,充當哈希表的作用,用於存儲對應 hash
位置的元素 Node<K,V>,此數組長度總是 2 的 N 次冪
5、Node<K,V>:元素節點,單鏈表結構:
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
......
忽略其他
......
}
基本理論概念,就先說到這裏了。下面,開始一些問題、源碼的說明。
1、在創建HashMap的時候,有構造函數,可以自由設置容量(及擴展因子)。上面說了,底層數組(充當hash表)的長度,一定是2的N次冪。但是我們隨便設置容量的時候,是沒有報錯的,爲什麼會這樣。我們隨便傳進去的容量值,hashMap做了什麼處理麼?
示例代碼:
var hMap: HashMap<Int?, String> = HashMap(12, 0.5f)
hMap.put(1, "a")
看下源碼:
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;
this.threshold = tableSizeFor(initialCapacity);
}
傳進去的容量值,最後通過方法tableSizeFor,變成了閾值 threshold
/**
* Returns a power of two size for the given target capacity.
* 返回給定目標容量的2倍冪。
*/
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;
}
把這個 tableSizeFor 方法複製出來,跑一些測試數據:
Log.e("tableSizeFor = 1 =", "${MyUtils.tableSizeFor(1)}")
Log.e("tableSizeFor = 3 =", "${MyUtils.tableSizeFor(2)}")
Log.e("tableSizeFor = 5 =", "${MyUtils.tableSizeFor(3)}")
Log.e("tableSizeFor = 12 =", "${MyUtils.tableSizeFor(12)}")
Log.e("tableSizeFor = 13 =", "${MyUtils.tableSizeFor(13)}")
Log.e("tableSizeFor = 16 =", "${MyUtils.tableSizeFor(16)}")
Log.e("tableSizeFor = 17 =", "${MyUtils.tableSizeFor(17)}")
tableSizeFor = 1 =: 1
tableSizeFor = 3 =: 2
tableSizeFor = 5 =: 4
tableSizeFor = 12 =: 16
tableSizeFor = 13 =: 16
tableSizeFor = 16 =: 16
tableSizeFor = 17 =: 32
結論:創建HashMap時,隨便傳進去的容量值,HashMap會進行對應的轉換,即便不是2的N次冪,也會變成2的N次冪對應的值
至此,初始化
var hMap: HashMap<Int?, String> = HashMap(12, 0.5f)
這句話,我們看完了。
接下來,看第二句:
hMap.put(1, "a")
put下的源碼:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0) // 1
n = (tab = resize()).length;
......
這裏是講解初始化方法,其他代碼先忽略,後面講增刪改查,會詳細說明
......
}
因爲最開始初始化的時候,沒有創建數組,所以,1 那裏的判斷條件,是true,會走 resize()
resize方法源碼: 現在是初始化的情況。第一次走到這個方法裏
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table; //初始化,table是null,所以,oldTab=null
int oldCap = (oldTab == null) ? 0 : oldTab.length; // oldCap=0
int oldThr = threshold; //傳進來12,經過處理,已經變成了16
int newCap, newThr = 0;
if (oldCap > 0) { // oldCap = 0
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // oldThr=16,會走到這裏
newCap = oldThr; // newCap = 16
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) { //上面沒對newThr處理,所有,它是0
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr; //經過上面的計算,newThr = 容量值*擴展因子
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
......
忽略
......
return newTab;
}
上面的代碼中,我加了註釋。
結論就是:
1、如果用 var hMap: HashMap<Int?, String> = hashMapOf() 創建,容量是默認的16,擴展因子是默認的 0.75,擴展閾值是 16 * 0.75 = 12;
2、如果用 var hMap: HashMap<Int?, String> = HashMap(12, 0.5f) 創建,容量是16(傳進來的容量,會被轉化爲2的N次冪),擴展因子是 0.5f,擴展閾值,是 16 * 0.5 = 8
===============================
這裏詳細說下增(put)、查方法(get)。刪除(remove)、替換(replace)方法都並不複雜,其中,remove方法中,注意下鏈表的節點變換即可。至於替換,我個人用的不多,因爲put方法中有覆蓋值的功能,我更多的,是用put(沒有就存,有就覆蓋)
增:put
源碼:
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;
if ((tab = table) == null || (n = tab.length) == 0) // ==1
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null) // ==2
tab[i] = newNode(hash, key, value, null);
else {
// ==3
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k)))) // ==4
e = p;
else if (p instanceof TreeNode) // ==5
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
// ==6
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) { // ==7
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)))) // ==8
break;
p = e;
}
}
if (e != null) { // existing mapping for key // ==9
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold) // ==10
resize();
afterNodeInsertion(evict);
return null;
}
注意源碼中添加是註釋 1-10。下面,會對應做說明
源碼說明:
1、判斷底層數組(hash表)是否爲空,如果爲空,就走 resize方法,裏面會創建;
2、根據插入的鍵值 key 的 hash 值,通過 (n-1) & hash (hash表長度-1 & 當前元素的hash值),計算出存儲位置 table[i](一個節點)。如果這個存儲位置沒有元素存放,則將新增節點存儲在此位置 table[i]
3、走到這裏,說明key算出來的hash位置上,已經有值了。
4、要存儲的位置有值,且這個值的key及key對應的hash值,和當前傳進來的操作元素一致,就保存下來,做保存操作;
5、當前存儲位置即有元素,有不和當前操作元素一致,則證明此位置 table[i] 已經發生了hash衝突。判斷頭節點是否是 treeNode,如果是,則證明此位置的結構是紅黑樹,以紅黑樹的方式新增節點。
6、當前存儲位置即有元素,有不和當前操作元素一致,則證明此位置 table[i] 已經發生了hash衝突。且,當前位置的結構,不是紅黑樹,是一個普通的單鏈表。
7、鏈表中不存在操作元素,將新元素結點放置此鏈表的最後一位, 然後判斷鏈接個數,滿足一定條件,就去進行“樹”相關操作
8、如果鏈表中已經存在對應的 key,則覆蓋 value
9、 已存在對應 key,如果允許修改,則修改 value 爲新值
10、當前HashMap中,個數是否大於等於閾值,如果滿足條件,就擴容。
注意:
put的源碼中 ,有這麼一句
if (binCount >= TREEIFY_THRESHOLD - 1)
treeifyBin(tab, hash);
翻譯過來就是:鏈表中的節點個數大於等於8,就走“樹”的方法。
再去 treeifyBin 看下
MIN_TREEIFY_CAPACITY = 64;
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
else if (...)
......
......
總結下:這個“樹”相關的方法中,先進行判斷,如果滿足一定條件(數組長度小於一定範圍),優先進行擴容。
也就是說,擴容有2個情況會觸發:
1、HashMap中,元素個數大於閾值;
2、鏈表中元素個數大於等於8,且底層數組長度小於64
這就奇怪了,鏈表的時間複雜度是O(n),紅黑樹的時間複雜度O(logn),很顯然,紅黑樹的複雜度是優於鏈表的。爲什麼,在調用“樹”相關方法的時候,還是要優先去擴容呢?爲什麼不直接用樹去替代鏈表呢?
繼續翻看源碼。在一段註釋說明中找到了如下信息:
Because TreeNodes are about twice the size of regular nodes, we
use them only when bins contain enough nodes to warrant use
(see TREEIFY_THRESHOLD). And when they become too small (due to
removal or resizing) they are converted back to plain bins. In
usages with well-distributed user hashCodes, tree bins are
rarely used. Ideally, under random hashCodes, the frequency of
nodes in bins follows a Poisson distribution
(http://en.wikipedia.org/wiki/Poisson_distribution) with a
parameter of about 0.5 on average for the default resizing
threshold of 0.75, although with a large variance because of
resizing granularity. Ignoring variance, the expected
occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
factorial(k)). The first values are:
0: 0.60653066
1: 0.30326533
2: 0.07581633
3: 0.01263606
4: 0.00157952
5: 0.00015795
6: 0.00001316
7: 0.00000094
8: 0.00000006
more: less than 1 in ten million
翻譯:
因爲樹形節點大約是常規節點的兩倍大,所以我們
只有當箱子包含足夠的節點時才使用它們
(見TREEIFY_THRESHOLD)。當它們變得太小(由於
移除或調整大小)它們被轉換回普通的箱子。在
使用分佈良好的用戶哈希碼,樹箱是
很少使用。理想情況下,在隨機哈希碼下,的頻率
bin中的節點遵循泊松分佈
(http://en.wikipedia.org/wiki/Poisson_distribution)
參數大約0.5的平均默認大小
閾值爲0.75,儘管由於
調整粒度。忽略方差,期望
列表大小k的出現次數爲(exp(-0.5) * pow(0.5, k) /
factorial (k))。第一個值是:
**忽略部分數字
8: 0.00000006 (千萬分之6)
更多:不到千萬分之一
源碼中說的很清楚了,樹,需要的節點更多,佔的空間較大,需要有足夠的空間下,纔去使用。典型的空間換性能。在性能差異不大的情況下,當然是使用空間越少越好了。
鏈表中元素到達8個的情況非常少,如果真到了那個時候,說明表已經“不堪重負”了,性能上會有影響,在那種特殊情況下,不得已,用空間換取性能,即:把鏈表換成紅黑樹
查:get
hMap.get(1)
對應源碼
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;
}
查的代碼很好理解了:
1、先調用 hash(key)方法計算出 key 的 hash 值
2、判斷hash數組是否是空,或者存儲位置是否有值,如果爲空或者沒有值,就返回null
3、不爲空,且有值的時候,就去判斷存儲位置鏈表的頭結點,如果頭結點是符合條件的,就返回頭結點。
4、如果頭結點不是符合要求的,就進行後續的判斷:
4.1:是否是紅黑樹,如果是,就走紅黑樹邏輯;
4.2:不是紅黑樹,就是單鏈表形式,注意,有個 do…while,就是去“遍歷”。如果找到,就返回,如果沒有找到,就返回 null