1 總覽
WARNING!!: 本文字數較多,內容較為完整並且部分內容難度較大,閱讀本文需要較長時間,建議讀者分段並耐心閱讀.
本文會對 Android 中常用的資料結構進行原始碼解析,包括 HashMap(有紅黑樹) + ArrayMap
本文ArrayMap的原始碼來自 Android Framework API 28 和 AndroidX
//AndroidX
implementation 'androidx.collection:collection:1.1.0-alpha03'
複製程式碼
2 HashMap<K,V>
本著全世界都知道面試一定會問 HashMap 的前提下,我們第一個分析 HashMap .
首先說明,併發時只讀不寫是沒有問題的,但是併發時有讀有寫時會出現問題, HashMap 不是執行緒安全的,並且在JDK<=1.7
多執行緒情況下呼叫put
引起擴容時還有可能導致迴圈連結串列問題,從而死迴圈使 CPU 佔用率變成 100% .本文分析的是JDK=1.8
這裡只是簡單提一下,不做展開討論,有興趣的同學可以自行查閱資料.
HashMap 是一種雜湊表,是一種典型的用空間換時間的資料結構,在內部使用拉鍊法處理 Hash 碰撞,也就是說在 Hash 後自己要放到表裡時,發現自己的坑已經被別人佔了,那就把之前的佔坑者作為連結串列的頭結點,自己作為下一個結點連到後面去.
//就像這樣
[ Node<K,V> , null , Node<K,V> , null , Node<K,V> , null , Node<K,V> , null , null ......]
↓ ↓ ↓
Node<K,V> Node<K,V> Node<K,V>
↓ ↓
Node<K,V> Node<K,V>
↓
Node<K,V>
複製程式碼
在 JDK 1.8 中,連結串列長度是有一個臨界值的,因為過長的連結串列會增大平均搜尋時間,所以當連結串列長度大於 8 時,將連結串列轉換為紅黑樹(本文不會規避這個話題,會講紅黑樹,所以中間有一段會非常高能,請讀者做好準備),以提高搜尋效率.
在理想情況下 HashMap 查詢元素的的時間複雜度為O(1)
,這個複雜度會隨負載因子的變大而變大,當負載因子變大時,同樣容量的 HashMap 中能夠儲存更多的元素,但是同時也會導致 Hash 碰撞變得更加頻繁,從而降低 HashMap 的搜尋效率.
2.1 構造器
首先當然要從構造器開始說起:
這裡我們要區分容量 (Capacity) 和大小 (Size) 這兩個概念,容量是指 HashMap 中桶的數量,也就是存放同一個 Hash 的位置的數量,而大小則是該 HashMap 中總共存了多少個鍵值對.
initialCapacity 為初始容量,這個值並不是 HashMap 實際的容量,因為 HashMap 的容量必須是 2 的冪,所以這個值在後面會被處理,變成 2 的冪, loadFactor 負載因子, MAXIMUM_CAPACITY 是 HashMap 的最大容量在程式碼中是一個int,值為1<<30
,因為最高位是符號位所以不能<<31
,這樣一來就能表示出用 int 儲存的 2 的冪的最大正整數
//建構函式內主要是做一些檢查引數的工作
//重點看tableSizeFor這個方法
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
這個方法裡.我們進一步追蹤到tableSizeFor
:
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;
}
複製程式碼
這麼多位移運算子這是要幹啥呢?
前面說過 HashMap 的容量必須是 2 的冪,tableSizeFor
所做的工作其實就是找到最接近於你所給 initialCapacity 的 2 的冪.
要解析這裡的程式碼,我們要先知道引數 cap 的範圍是怎樣的,通過看之前的建構函式,我們知道引數 cap 肯定是大於 0 的,所以接下來就要分兩種情況了.
- 如果是 cap 為 1 的情況.那麼
n |= n >>> 1
和下面的 4 行程式碼都可以跳過,因為>>>
是無符號右移運算子,當 cap 為 1 時,n 為 0,對其進行>>>
和|=
後其值還是0,最後經過下面的兩個三元運算子,獲得的返回值是 1 ,也就是 2 的 0 次冪. - 如果 cap 不是 1 ,那我們先假定它是 9 , 9 不是 2 的冪,它的二進位制表達為
...0001001
,那麼 n 的二進位制表示就是...0001000
執行>>> 1
得到 ...0000100
執行|=
得到n為 ...0001100
執行>>> 2
得到 ...0000011
執行|=
得到n為 ...0001111
執行>>> 4
得到 ...0000000
執行|=
得到n為 ...0001111
...
複製程式碼
最終我們會得到...0001111
也就是1 + 2 + 4 + 8 = 15,誒不是說好的 2 的冪嗎?這不是 15 嗎,看到return
語句的最後一句了沒有,還要+1
,所以返回的值是 16 還是 2 的冪,並且是最接近於 9 的二的冪.
那如果我們拿 8 作為 cap 會怎樣呢? 8 是 2 冪,二進位制表示為...0001000
,進行-1
後就是...0000111
執行>>> 1
得到 ...0000011
執行|=
得到n為 ...0000111
...
複製程式碼
你會發現他變回去了,這就是這個演算法的神奇之處...所以下面的就不用分析了.
這是 HashMap 的另一個構造器,就是呼叫了上面的那個構造器而已. DEFAULT_LOAD_FACTOR 的值為 0.75 聽說是經過測試過的比較理想的值,自己沒有測試過.
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
複製程式碼
這個構造器也差不多,不過沒有設定 initialCapacity ,其實是它在擴容函式reszie
中設定了,這樣構造的 HashMap 擁有預設容量 16.
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR;
}
複製程式碼
下面繼續看下一個構造器
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
複製程式碼
putMapEntries
方法除了構造器會呼叫,其實在其他時候也會被呼叫但是構造器呼叫時引數 evict 為 false ,其他時候是 true .
這裡涉及到幾個欄位,要先說一下,分別是table
,threshold
,loadFactor
//HashMap管理的節點表,在第一次使用時初始化,並根據需要調整大小
//分配時,長度總是2的冪
//用陣列儲存,也可以叫它桶
transient Node<K,V>[] table;
//擴容的臨界值,由負載因子*當前容量得到
int threshold;
//負載因子
//是當前最多能使用的桶於總桶數的一個比例
final float loadFactor;
複製程式碼
我們跟著來到 putMapEntries
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
int s = m.size();
//s等於0就什麼都不做
if (s > 0) {
//table==null,也就是第一次初始化
if (table == null) { // pre-size
//加進來的Map的大小除以負載因子得出新的臨界值
float ft = ((float)s / loadFactor) + 1.0F;
int t = ((ft < (float)MAXIMUM_CAPACITY) ?
(int)ft : MAXIMUM_CAPACITY);
//如果新的臨界值比當前的大,則將它轉換為2的冪
if (t > threshold)
threshold = tableSizeFor(t);
}
//當前表非空,加進來的Map大小已經大於舊的臨界值,直接擴容
else if (s > threshold)
resize();
//將值依次插入表中
for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
K key = e.getKey();
V value = e.getValue();
putVal(hash(key), key, value, false, evict);
}
}
}
複製程式碼
2.2 擴容
因為上面涉及到了resize
這個方法,而且這個方法非常重要,是 HashMap 的擴容方法,不接著講它就講不下去了.
final Node<K,V>[] resize() {
//舊錶
Node<K,V>[] oldTab = table;
//舊錶大小
int oldCap = (oldTab == null) ? 0 : oldTab.length;
//之前的臨界值
int oldThr = threshold;
int newCap, newThr = 0;
//如果當前表非空
if (oldCap > 0) {
//並且如果容量到達上限,不進行擴容,直接返回舊錶
//並將臨界值設定為不可能到達的Integer.MAX_VALUE
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
//否則舊容量左移一位得到新容量,也就是翻倍
//如果翻倍後新的容量仍然小於最大容量
//並且舊容量是大於預設初始容量DEFAULT_INITIAL_CAPACITY(值為16)的
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
//那麼臨界值也左移一位翻倍
newThr = oldThr << 1;
}
//如果當前表不是空表,並且已有臨界值
//這種情況對應前面在建構函式中
//使用tableSizeFor(initialCapacity)對threshold的賦值
//表本身是空的,沒有元素,所以要進行一次擴容
else if (oldThr > 0)
newCap = oldThr;
//在沒有初始化臨界值時
//先給他設定新的容量為DEFAULT_INITIAL_CAPACITY(值為16)
//然後使用預設負載計算出臨界值
//這種情況對應上文中的第三個建構函式
else {
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
//防止新表的臨界值為0,重新計算臨界值
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
//new 新表
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
//如果舊錶不為空
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
//取出相應位置的節點
if ((e = oldTab[j]) != null) {
//並將原位置置空,減少GC壓力
oldTab[j] = null;
//如果這個節點(桶)只有一個元素
//也就是沒有發生過Hash碰撞,沒有別的節點連在後面
if (e.next == null)
//那麼直接將其Hash到新表裡
//我們之前一直說HashMap的容量是2的冪,這時它派上了用場
//這裡也用了一種神奇的演算法
//下面這行程式碼相當於hash對newCap取模
//只不過使用位運算效率更高
//不相信的話我們可以試試
//hash=7 ...0111
//newCap=4 ...0100
//newCap-1 ...0011
//hash&(newCap - 1) ...0011
//0011等於3,沒錯就是這麼神奇,而且這並不是偶然
//但在這裡有一點需要注意
//不同的Hash值經過上面的計算後可能會得到相同的結果
//這也就是說
//在一個桶中連成的連結串列上的不同的節點的Hash值有可能是不同的
//所以在同一個桶中並不代表他們的Hash值就一定相等了
newTab[e.hash & (newCap - 1)] = e;
//這裡是對紅黑樹的處理,這裡先暫時跳過,下面專門講
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else {
//下面開始處理有Hash碰撞發生的桶
//也就是那些連成連結串列的
//要放在原本位置的連結串列
Node<K,V> loHead = null, loTail = null;
//要放在新位置的連結串列
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
//開始處理
do {
next = e.next;
//用節點的Hash與舊錶容量進行與運算
//其實也就是跟取模差不多
//只是現在用來判斷Hash值是否大於等於舊錶容量
//若計算結果為0,則表示小於舊錶容量
//則放在原來的位置
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;
}
//這裡舉個例子說清楚一點
//由於擴容後的HashMap的容量是原來的兩倍
//如果之前的容量是32,那麼擴容後就是64
//Hash值為16的會被放到原來的位置16
//Hash值為48的原本是和16放一起的
//但是擴容後就被放到48這個位置了
}
}
}
}
return newTab;
}
複製程式碼
2.3 增刪查
2.3.1 增加元素
一般我們都是呼叫put
對 HashMap 進行新增操作,今天我們對它的原始碼進行分析
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
複製程式碼
跟蹤到putVal
,我們發現上面的putMapEntries
其實也呼叫了該函式
Node<K,V> newNode(int hash, K key, V value, Node<K,V> next) {
return new Node<>(hash, key, value, next);
}
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)
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;
//先依次使用Hash,引用,以及equals函式比較相等性
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
//若完全相等,直接賦值給下一步需要處理的變數e
e = p;
//若為紅黑樹,則作特殊處理
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
//否則遍歷該桶的連結串列
for (int binCount = 0; ; ++binCount) {
//在桶中沒找到Key相等性完全一致的節點
//則建立新節點對該桶的連結串列進行尾插
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
//長度大於TREEIFY_THRESHOLD(值為8)
//轉換為紅黑樹
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
//找到了就退出迴圈,下一步要處理的變數是e
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
//e不為null,也就是有要處理的節點
if (e != null) {
//儲存舊值
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
//寫入新值
e.value = value;
//空方法,LinkedHashMap實現
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
//更新size,並判斷是否要擴容
if (++size > threshold)
resize();
//空方法,LinkedHashMap實現
afterNodeInsertion(evict);
return null;
}
複製程式碼
我們注意到put
還有一個hash
方法,它的實現是這樣的
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
複製程式碼
HashMap 作為 JDK 中泛用的集合,必須考慮各種極端情況,所以是不能假設作為 K 泛型引數的型別有良好定義的hashCode
方法的,所以在內部還要在 hash 一次,這樣做能讓 hash 碰撞更少的發生從而提升 HashMap 的效率.
2.3.2 刪除元素
我們通常使用remove
來移除 HashMap 中的元素
public V remove(Object key) {
Node<K,V> e;
return (e = removeNode(hash(key), key, null, false, true)) == null ?
null : e.value;
}
複製程式碼
跟蹤到removeNode
方法
final Node<K,V> removeNode(int hash, Object key, Object value,
boolean matchValue, boolean movable) {
Node<K,V>[] tab; Node<K,V> p; int n, index;
//若表非空,則用Hash計算出應該在表中的所在的桶的下標,並獲取該節點
if ((tab = table) != null && (n = tab.length) > 0 &&
(p = tab[index = (n - 1) & hash]) != null) {
Node<K,V> node = null, e; K k; V v;
//能到這裡說明節點非空
//與之前一樣,比較key相等性
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
//若完全相等,直接賦值給下一步需要處理的變數node
node = p;
//相等性不匹配,且有下一個節點時
else if ((e = p.next) != null) {
//紅黑樹獲取節點要特殊處理,下文展開討論
if (p instanceof TreeNode)
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
//在桶中遍歷連結串列查詢節點
else {
do {
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {
node = e;
break;
}
p = e;
} while ((e = e.next) != null);
}
}
//有需要處理的節點
//之前傳參時matchValue=false,不需要匹配值
if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
//紅黑樹特殊處理,下文討論
if (node instanceof TreeNode)
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
//從連結串列中將該節點移除
//p為連結串列頭,移除連結串列頭
else if (node == p)
tab[index] = node.next;
//node為連結串列的中間節點
else
p.next = node.next;
++modCount;
//減小size
--size;
afterNodeRemoval(node);
//返回舊值
return node;
}
}
return null;
}
複製程式碼
但是當我們呼叫另一個過載時matchValue
為 true ,這時就要匹配值了.
@Override
public boolean remove(Object key, Object value) {
//此時matchValue為true
return removeNode(hash(key), key, value, true, true) != null;
}
複製程式碼
2.3.3 查詢元素
我們一般使用get
來獲取 HashMap 中的值
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
複製程式碼
跟蹤到getNode
方法,相比起前幾個方法,這個方法就簡單許多
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
//判斷表是否非空,該Hash位置的桶中是否有節點
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;
}
複製程式碼
當我們使用某種型別作為 HashMap 泛型引數 K (也是就是鍵-值對中的鍵)時,該型別的物件的hashCode
函式返回的值應該是不變的,否則當 HashMap 進行 Hash 時可能會得到錯誤的位置,有可能導致key-value
對實際儲存到了 HashMap 中,但就是找不到的情況.
2.4 紅黑樹
WARNING: 前方即將進入高能區,不瞭解二叉樹的建議先去看一點二叉樹的有關知識再來看本節.
每次看別人在解析 HashMap 的時候一講到紅黑樹就戛然而止,要不然說後面單獨開一篇文章來講,要不然就直接太監了,所以我看別人都不說那索性我就自己研究去了.
需要讀者注意的是,看紅黑樹這一節需要你有一定二叉樹的基礎知識,並且有一定耐心去理解,筆者會盡可能地講清楚,但不可能面面俱到,如果在閱讀過程中發現有不能理解的名詞,還請自行百度.
2.4.1 什麼是紅黑樹?
紅黑樹是一種自平衡二叉查詢樹,雖然它的實現非常複雜,但即使是在最壞情況下執行也能有很好的效率.比如當紅黑樹上有N
個元素時,它可以在O(log N)
時間內做查詢,插入和刪除.
當 HashMap 在桶中的連結串列長度超過 8 時使用它,連結串列在做查詢時的時間複雜度是O(N)
,使用紅黑樹會將效率提高不少.
作為一種查詢樹,它需要符合一些規則:
- 若左子樹不空,則左子樹上所有結點的值均小於它的根結點的值
- 若右子樹不空,則右子樹上所有結點的值均大於或等於它的根結點的值
- 左、右子樹也分別為二叉排序樹
一顆紅黑樹的樣子,大概就像這樣(圖是用Process On畫的)
2.4.2 紅黑樹的5個性質
除了二叉查詢樹所具有的一些性質,所有的紅黑樹還具有以下的 5 個性質:
- 在紅黑樹中,每個節點都有顏色,要麼是紅的,要麼是黑的
- 紅黑樹的根節點時黑色的
- 每個葉子節點(在 Java 中 為
null
)都是黑色的 - 如果一個節點是紅色的,那麼它的子節點都是黑色的
- 從任意節點到每個葉子節點的路徑上,黑色節點的數目相同
因為插入,查詢,刪除操作時,最壞的情況下的時間都與二叉樹的樹高有關,根據性質 4 我們知道不會有兩個直接相連的紅色節點.接著,根據性質 5 我們又可以知道,因為所有最長的路徑都有相同數目的黑色節點,這就保證了沒有可能會有一條路徑的長度能有其他路徑的兩倍這麼長.
上面的性質使紅黑樹達到了相對平衡,但實際上,紅黑樹也是最接近平衡的二叉樹.
2.4.3 樹化
之前我們一講到TreeNode
就跳過,現在我們對它進行分析
我們先分析TreeNode
這個巢狀類是怎麼來的
//追根溯源
//HashMap.TreeNode
// -繼承-> LinkedHashMap.LinkedHashMapEntry
// -繼承->HashMap.Node
// -實現->Map.Entry
//這樣的好處是TreeNode既可以當做LinkedHashMap.LinkedHashMapEntry來使用
//也可以當做HashMap.Node來使用
//TreeNode包含以下幾個欄位
//父節點
TreeNode<K,V> parent;
//左子節點
TreeNode<K,V> left;
//右子節點
TreeNode<K,V> right;
//TreeNode也是由之前的連結串列樹化而來的
//prev指向的是原本還是連結串列時的前一個節點
//刪除時需要取消下一個連結
TreeNode<K,V> prev;
//是不是紅色節點
boolean red;
//並從HashMap.Node繼承了next
//Node是連結串列節點,next指向連結串列中的下一個節點
Node<K,V> next;
複製程式碼
回憶之前的程式碼,我們知道當連結串列長度超過 8 時會呼叫treeifyBin
這個方法對連結串列進行樹化
TreeNode<K,V> replacementTreeNode(Node<K,V> p, Node<K,V> next) {
return new TreeNode<>(p.hash, p.key, p.value, next);
}
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
//MIN_TREEIFY_CAPACITY的值為64
//這是會觸發樹化的容量最小值
//若未達到這個值
//則HashMap選擇的策略是使用resize進行擴容以減少Hash衝突,而非樹化
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
//先檢查一波這個地方是否真的要樹化(是否為空)
else if ((e = tab[index = (n - 1) & hash]) != null) {
//其實這裡先把它轉換成了雙連結串列方便下一步操作
//hd是連結串列頭,tl是連結串列尾
TreeNode<K,V> hd = null, tl = null;
do {
//HashMap.Node將轉換為HashMap.TreeNode
TreeNode<K,V> p = replacementTreeNode(e, null);
//若尾巴為空說明是第一次迴圈
if (tl == null)
//先設定頭結點
hd = p;
else {
//將當前新生成的節點p的前驅設定為原本的尾巴
p.prev = tl;
//然後原本的尾巴的下一個節點指向新生成的節點
tl.next = p;
}
//更新尾巴
tl = p;
} while ((e = e.next) != null);
//給節點表賦值
if ((tab[index] = hd) != null)
//並開始實際的樹化
hd.treeify(tab);
}
}
//繼續跟蹤原始碼到TreeNode#treeify
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) {
x.parent = null;
//根節點是黑色的
x.red = false;
root = x;
}
//否則取出該節點的Hash值和Key值,準備進行插入
//變數x是帶插入節點
else {
K k = x.key;
int h = x.hash;
Class<?> kc = null;
//從根節點開始遍歷紅黑樹,查詢插入位置
//變數p代表的是當前遍歷到的節點
for (TreeNode<K,V> p = root;;) {
//dir代表兩個節點比較的結果
//ph是p的Hash值
int dir, ph;
//pk是p的Key值
K pk = p.key;
//如果要插入的節點的Hash值小於當前遍歷到的節點
if ((ph = p.hash) > h)
//比較結果為-1,繼續往左子樹找
dir = -1;
else if (ph < h)
//否則為1,繼續往右子樹找
dir = 1;
//如果出現兩者相等的情況
//則呼叫comparableClassFor
//瞄一眼作為Key的類是否實現了Comparable
//如果實現了
//就繼續呼叫compareComparables進行比較
//如果比較結果還是相等
//就到tieBreakOrder中去比較
else if ((kc == null &&
(kc = comparableClassFor(k)) == null) ||
(dir = compareComparables(kc, k, pk)) == 0)
dir = tieBreakOrder(k, pk);
TreeNode<K,V> xp = p;
//按照每次比較得到的結果
//不是樹葉則選擇左子節點還是右子節點
//如果該節點不是樹葉(不為null),則繼續向下找
//否則先將x其插入到那個樹葉原有的位置
//上述過程實際上就是將其先變成一顆二叉查詢樹的過程
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;
}
}
}
}
//把紅黑樹的根節點移動成為桶中的第一個元素
moveRootToFront(tab, root);
}
//回到剛才跳過的tieBreakOrder看看
//我們得知是呼叫了System#identityHashCode
//這個函式是根據物件在JVM中的的實際地址來返回Hash的
static int tieBreakOrder(Object a, Object b) {
int d;
if (a == null || b == null ||
(d = a.getClass().getName().
compareTo(b.getClass().getName())) == 0)
d = (System.identityHashCode(a) <= System.identityHashCode(b) ?
-1 : 1);
return d;
}
複製程式碼
balanceInsertion
這個方法比較複雜並且會在增加元素時用到,所以我們放到下文的增加元素中來講.
2.4.4 左旋與右旋
現在我們補充一些二叉樹左旋與右旋的知識,為後面做鋪墊.
左旋的步驟:
- 選定一個節點 N 作為左旋操作的支點
- 該節點 N 代替 N 原本的父節點P的位置
- 原本的父節點 P 變成 N 的左子節點
- 如果 N 原本有左子節點,那麼這個左子節點現在變成P的右子節點
是不是感覺像繞口令一樣?那畫個圖吧(圖是用Process On畫的)
搞清楚左旋之後,右旋就可以類比出來了,下面是右旋的步驟:- 選定一個節點 N 作為右旋操作的支點
- 該節點 N 代替 N 原本的父節點P的位置
- 原本的父節點 P 變成 N 的右子節點
- 如果 N 原本有右子節點,那麼這個右子節點現在變成 P 的左子節點
還是繼續畫一個圖:
搞清楚左旋右旋的原理後,接著我們看看左旋和右旋在Java
中是如何實現的
//左旋
//root是樹根
//p代表上圖左旋中的A
static <K,V> TreeNode<K,V> rotateLeft(TreeNode<K,V> root,
TreeNode<K,V> p) {
TreeNode<K,V> r, pp, rl;
//因為p是A,所以r就是C了
if (p != null && (r = p.right) != null) {
//就像上圖一樣,C的左子節點若存在
//就變成A的右子節點
if ((rl = p.right = r.left) != null)
rl.parent = p;
//C取代A變成子樹樹根
if ((pp = r.parent = p.parent) == null)
//如果A之前剛好是二叉樹的樹根
//則要保證它是黑色的
(root = r).red = false;
else if (pp.left == p)
//替換A
pp.left = r;
else
//替換A
pp.right = r;
//C的左節點變成A
r.left = p;
p.parent = r;
}
return root;
}
//右旋以此類推
//不再贅述
static <K,V> TreeNode<K,V> rotateRight(TreeNode<K,V> root,
TreeNode<K,V> p)
複製程式碼
這裡最後說個題外話,讓大家放鬆一下,在rotateLeft
方法的原始碼上面有這麼一句註釋
// Red-black tree methods, all adapted from CLR
複製程式碼
它的意思就是:紅黑樹的方法,均改變自CLR
看到這個的我就當場就笑出來了,因為筆者半年前還是一個學C#
的,現在轉Android
之後,總感覺Java
之於C#
有一種莫名的諷刺感,下面這段話可能能夠更貼切地表達我的心情:
震驚!從響應式程式設計到MVVM, Microsoft 研究出來的新技術竟然總是最先在Java
上大規模使用!Microsoft 竟然在背後源源不斷地為 Java
提供技術支援? Oracle 坐享其成恐成最大贏家.
2.4.5 增加元素
在給紅黑樹增加元素時有兩個步驟:
- 把紅黑樹當做二叉查詢樹做插入操作
- 把插入後的二叉查詢樹重新調整成紅黑樹
給二叉排序樹插入元素的思路很簡單,一句話就能講清楚.因為樹是已經排序過的,從根節點開始遍歷和比較,如果要插入的節點的Hash小於遍歷到的節點的Hash,則進入左子樹,否則進入右子樹,如此遞迴,直到找到一個空葉子把節點插入即可.
由於插入後紅黑樹可能會退化成二叉查詢樹,所以接下來對二叉查詢樹進行調整使它重新變成紅黑樹.左旋和右旋不會改變二叉查詢樹的性質,所以在給原紅黑樹按照二叉查詢樹的排序規則插入新的節點後,我們需要使用左旋和右旋來使這顆二叉查詢樹重新調整成紅黑樹.
在插向紅黑樹插入一個元素時,我們把要新插入的設定成紅色,至於為什麼要這麼做,我們結合紅黑樹的 5 條性質來分析一下就知道了.
- 在紅黑樹中,每個節點都有顏色,要麼是紅的,要麼是黑的 ( √ 已經是黑色的)
- 紅黑樹的根節點時黑色的 ( ? 除非是空樹,否則插入新節點不影響根節點)
- 每個葉子節點(在 Java 中 為
null
)都是黑色的 ( √ 影響不了該性質,故滿足) - 如果一個節點是紅色的,那麼它的子節點都是黑色的 ( ? 可能會有兩個紅色的節點相連)
- 從任意節點到每個葉子節點的路徑上,黑色節點的數目相同( √ 插入的是紅色節點,不會增加黑色節點的數量)
這樣我們在調整紅黑樹的時候就只需要考慮性質 2 和 4 帶來的問題就可以了.
在插入時會出現以下幾種情況:
- 若之前是空樹,那麼插入後將節點糾正為黑色即可
- 插入後父節點是黑色的,這種情況不用處理,因為紅色節點不影響平衡
- 插入後父節點是紅色的,因為根節點必須是黑色的,所以被插入節點的祖父節點(父節點的父節點)必然存在,此時就要根據叔叔節點(祖父節點的另一個子節點)的情況進行進一步處理.
為了分析第三種情況,下面我們設一些變數方便進一步說明, x 為當前要處理的節點, xp 為父 x 的父節點, xpp 為 x 的祖父節點(父節點的父節點), xu 為叔叔節點(祖父節點的另一個子節點).並且在一開始,我們把插入的節點當做 x.
注意!!: 下面的情況只適用於 xp 是 xpp 的左子節點的情況, xp 是 xpp 的右子節點的情況需要進行 對稱(左右交換) 處理,如果你在自己嘗試的時候一定要注意這個大前提.
- 第一種情況是紅叔,也就是自己( x )紅,父親( xp )紅,叔叔( xu )也紅的情況,此時祖父節點( xpp )必然是黑色,在這種情況下需要變色,將 xp 和 xu 由紅色=>黑色,將 xpp 由黑色=>紅色,在此次調整完畢後, xpp 可能會破壞性質 4 (不允許有兩個相連的紅節點),所以將當前的 xpp 設定為 x 進行進一步調整.
- 第二種情況是黑叔並且自己( x )是自己父節點( xpp )的右子節點,此時將當前的 xp 設定為 x,然後以 x 為支點進行左旋,經過此次調整後,問題其實並沒有解決,只是把情況二變成了情況三,所以還是要為 x 進行進一步調整.
- 第三種情況是黑叔並且自己( x )是自己父節點( xpp )的左子節點,此時祖父節點( xpp )必然是黑色,是需要把 xpp 由黑色=>紅色,把 xp 由紅色=>黑色,然後以 xpp 為支點進行右旋,這樣就解決了問題.
下面我們畫兩張圖來解釋一下.下面這棵紅黑樹調整的步驟:
- 插入 81 (在 82 的左子節點),紅黑樹退化成二叉查詢樹
- 符合三紅的情況,直接變色,然後將 xpp 變成 x
- 還是符合三紅的情況,變色
- 此時演算法已經到達根節點,將根節點塗黑即可 還是繼續上面那張圖:
- 插入 150 (在 120 的右子節點),紅黑樹退化成二叉查詢樹
- 是黑叔並且 x 是右子節點,把 xp 變成 x,然後直接左旋
- 還是黑叔並且 x 是現在是左子節點,將 xp 塗黑,然後將 xpp 塗紅,右旋
- 已經重新變成紅黑樹
這個演算法的核心思想就是:將影響紅黑樹性質的紅色節點向上移動到根節點,並將根節點設定成黑色.
經過上面的一通分析,我們只是僅僅知道了原理,離原始碼分析實際上還很遠,不得不說這資料結構真的太狠了,感覺比 HashMap 本身還要複雜.
之前我們對 HashMap 的原始碼進行分析時,一分析到putTreeVal
時就跳過了,現在我們在重點看看它的原始碼
//獲取根節點
final TreeNode<K,V> root() {
for (TreeNode<K,V> r = this, p;;) {
//直到父節點為null
if ((p = r.parent) == null)
return r;
r = p;
}
}
TreeNode<K,V> newTreeNode(int hash, K key, V value, Node<K,V> next) {
return new TreeNode<>(hash, key, value, next);
}
//可以看到這裡和TreeNode#treeify差不多
//就是嘗試這在紅黑樹裡找已近存過指定Key的節點
//如果實在在不到,就給該Key插入一個新的節點
final TreeNode<K,V> putTreeVal(HashMap<K,V> map, Node<K,V>[] tab,
int h, K k, V v) {
//可能會用到的變數,如果Key實現了Comparable
Class<?> kc = null;
//標記是否已經遍歷查詢過
boolean searched = false;
//拿到跟節點
TreeNode<K,V> root = (parent != null) ? root() : this;
//從根節點開始遍歷
for (TreeNode<K,V> p = root;;) {
int dir, ph; K pk;
//如果遍歷到的節點的Hash值大於要插入的Key的Hash值
//則繼續dir賦值為-1
//會繼續往左子樹搜尋
if ((ph = p.hash) > h)
dir = -1;
//否則往右子樹搜尋
else if (ph < h)
dir = 1;
//大於和小於的情況已近被排除,現在Hash必然相等
//如果Key也相等,那就直接返回
else if ((pk = p.key) == k || (k != null && k.equals(pk)))
//這裡並沒有直接修改節點的value
//但你若回去看putVal,就可以知道它實際上在putVal中修改了
return p;
//若是上面那個else if沒有匹配
//則說明Hash相等,但是Key不等,這下就要進一步比較
else if ((kc == null &&
(kc = comparableClassFor(k)) == null) ||
(dir = compareComparables(kc, k, pk)) == 0) {
//到這裡只有兩種情況
//1.Key沒有實現Comparable
//2.實現了Comparable並且比較還是相等了
//我們知道通過上面的比較,Hash是已經相等了
//但是Key不等,所以就要在該節點的左右子樹繼續進行搜尋
if (!searched) {
TreeNode<K,V> q, ch;
//遍歷查詢只會進行一次
//待會繼續向下找時不會再遍歷
//只是找插入位置
searched = true;
//find是一個遞迴方法,比較簡單
//作用是在這個子樹裡查詢與指定的Key完全匹配的節點
//本著抓大放小的原則
//這裡我選擇跳過,節約大家時間
//感興趣的話建議自己去看原始碼
//下面這裡短路求值,只要在一邊找到了,就直接返回
if (((ch = p.left) != null &&
(q = ch.find(h, k, kc)) != null) ||
((ch = p.right) != null &&
(q = ch.find(h, k, kc)) != null))
return q;
}
//如果沒有找到
//那就使用物件地址進行比較
dir = tieBreakOrder(k, pk);
}
TreeNode<K,V> xp = p;
//根據上面得到的dir
//瞄一眼左子樹或右子樹是否為空
//為空就插入新節點
//如果不為空,即p!=null
//則下一次迭代的節點p被賦值,繼循迴圈
if ((p = (dir <= 0) ? p.left : p.right) == null) {
Node<K,V> xpn = xp.next;
//建立新節點
TreeNode<K,V> x = map.newTreeNode(h, k, v, xpn);
if (dir <= 0)
xp.left = x;
else
xp.right = x;
//同時還要保持雙連結串列的結構
xp.next = x;
x.parent = x.prev = xp;
if (xpn != null)
((TreeNode<K,V>)xpn).prev = x;
//沒完,現在還只是一顆二叉查詢樹而已
//還要進行平衡處理
moveRootToFront(tab, balanceInsertion(root, x));
//返回null,則外層的putVal啥都不做
return null;
}
}
}
複製程式碼
下面就是期待已久的balanceInsertion
了,如果你理解了上面紅黑樹的理論知識,看這個你會覺得很輕鬆.
//新插入的元素雖然使整棵樹依然保持為二叉查詢樹
//但是這棵樹可能還不夠平衡
//所以要進行調整
//該方法的返回值是調整完成後的樹根
static <K,V> TreeNode<K,V> balanceInsertion(TreeNode<K,V> root,
TreeNode<K,V> x) {
//將新插入的節點初始化為紅色
x.red = true;
//x->當前節點
//xp->父親節點
//xpp->祖父節點
//xppl和xppr->分別是祖父節點的左子節點或右子節點
//現在我們開始回憶剛才在理論介紹中羅列的幾種情況
for (TreeNode<K,V> xp, xpp, xppl, xppr;;) {
//之前是空樹
if ((xp = x.parent) == null) {
//塗黑,然後返回即可
x.red = false;
return x;
}
//這裡是方法的第二個出口
//如果父節點是黑的
//又或者是祖父節點已經不存在
else if (!xp.red || (xpp = xp.parent) == null)
//演算法結束,直接返回根節點
return root;
//若父節點是祖父節點的左子節點
if (xp == (xppl = xpp.left)) {
//出現三紅的情況,變色
if ((xppr = xpp.right) != null && xppr.red) {
xppr.red = false;
xp.red = false;
xpp.red = true;
x = xpp;
}
//否則就要看自己是左子節點還是右子節點
else {
//是右子節點
if (x == xp.right) {
//更新當前節點x,並左旋
root = rotateLeft(root, x = xp);
//更新xp和xpp
xpp = (xp = x.parent) == null ? null : xp.parent;
}
//若是左子節點
if (xp != null) {
//父節點變黑
xp.red = false;
if (xpp != null) {
//祖父變紅
xpp.red = true;
//右旋,且當前節點不變
root = rotateRight(root, xpp);
}
}
}
}
//若父節點是祖父節點的右子節點
//與上面類似,只是把左右交換了而已,故不再贅述
else {
if (xppl != null && xppl.red) {
xppl.red = false;
xp.red = false;
xpp.red = true;
x = xpp;
}
else {
if (x == xp.left) {
root = rotateRight(root, x = xp);
xpp = (xp = x.parent) == null ? null : xp.parent;
}
if (xp != null) {
xp.red = false;
if (xpp != null) {
xpp.red = true;
root = rotateLeft(root, xpp);
}
}
}
}
}
}
複製程式碼
2.4.6 刪除元素
PS: 望理解,筆者能力有限,待日後補充.....
2.4.7 查詢元素
在查詢元素時呼叫的是getTreeNode
方法,該方法比較簡單,呼叫了我們之前分析過的find
方法.
final TreeNode<K,V> getTreeNode(int h, Object k) {
return ((parent != null) ? root() : this).find(h, k, null);
}
複製程式碼
3 ArrayMap<K,V>
其次,在 Android 中除了 HashMap 可能最重要的就是也實現 Map 介面的 ArrayMap 了,它也是非執行緒安全的,但也是允許併發地只讀不寫.
ArrayMap 在內部使用開地址法處理Hash碰撞,也就是說存值時對Key先Hash一波,然後算出自己在陣列中應該存放的位置的下標,如果自己拿著下標去存的時候發現自己的坑已經被別人給佔了,那自己就往後佔一個坑,如果坑還被佔,那就繼續往下一個坑看,直到有沒被佔的坑位置把自己放進去.
ArrayMap 中內部的陣列是經過排序的,並使用二分查詢法搜尋元素,所以時間複雜度是O(log N)
,比 HashMap 慢,而且資料規模越大慢得越多,個人認為在資料規模<=100
時使用 ArrayMap 是比較理想的,如果超過這個值還是推薦你用 HashMap 吧.
注: 系統 ArrayMap 與 AndroidX 的 ArrayMap 實現上有所不同,下文中的 ArrayMap 來自AndroidX
3.1 適用場景
Google 說 ArrayMap 是一種節省記憶體的 Map 實現,比較適用於 APP 開發這種 Map 中元素較少的情況.
但是然並卵,在看開源專案的原始碼時,我發現實際上是很多人還是更傾向於使用 HashMap .我個人也比較推崇使用 HashMap ,因為 ArrayMap 不夠跨平臺,節省的那點記憶體也實在是杯水車薪,而且 HashMap 速度更快,也能適應未來資料規模變大時的改變.
我的意見是,在自己寫的庫和記憶體不吃緊的APP裡,最好不要用ArrayMap.
3.2 構造器
ArrayMap 繼承自 SimpleArrayMap 並實現了 Map 介面, ArrayMap 這個類裡基本上是空的,具體實現都在 SimpleArrayMap 中.
public class ArrayMap<K, V> extends SimpleArrayMap<K, V> implements Map<K, V>
複製程式碼
從 ArrayMap 追蹤原始碼到 SimpleArrayMap ,檢視其構造器
public ArrayMap() {
super();
}
//實際呼叫
public SimpleArrayMap() {
//EMPTY_INTS為new int[0]
mHashes = ContainerHelpers.EMPTY_INTS;
//EMPTY_OBJECTS為new Object[0]
mArray = ContainerHelpers.EMPTY_OBJECTS;
mSize = 0;
}
//涉及的兩個欄位
//mHashes用來儲存Key值對應的Hash值
//它的大小就是ArrayMap的容量
int[] mHashes;
//與HashMap不一樣,沒有Node這一說
//只使用一個陣列mArray來儲存Key和Value
//mArray的長度永遠是mHashes的兩倍
//Key的Hash在mHashes中所在的位置的下標index
//在沒有Hash衝突時index*2的位置就是mArray中存Key的位置
//index*2+1的位置就是mArray中存Value的位置
Object[] mArray;
//在mArray中Key和Value以以下的形式儲存
//我們舉一個容量為4的例子
//那麼mArray的長度就等於8
//在ArrayMap中Key和Value以相鄰的形式存在mArray中
//[ Key1 , Value1 , null , null , null , null , Key2 , Value2 ]
// ↑ ↑ ↑ ↑
// 鍵值對KV 鍵值對KV
複製程式碼
可以看到這個構造器基本沒做什麼事,那我們繼續看下一個構造器
public ArrayMap(int capacity) {
super(capacity);
}
//實際呼叫
public SimpleArrayMap(int capacity) {
//若初始容量為0,就和上面那個建構函式一樣
if (capacity == 0) {
//ContainerHelpers是一個工具類
mHashes = ContainerHelpers.EMPTY_INTS;
mArray = ContainerHelpers.EMPTY_OBJECTS;
}
//否則分配新陣列
else {
allocArrays(capacity);
}
mSize = 0;
}
複製程式碼
3.3 快取與擴容
順著原始碼繼續跟蹤到allocArrays
下面面涉及到幾個欄位,這裡提前說明一下,分別是mBaseCache
,mBaseCacheSize
,mTwiceBaseCache
和mTwiceBaseCacheSize
.
這就不得不說到 ArrayMap 的快取機制, ArrayMap 的空間策略十分保守,對於用來儲存 Hash 的mHashes
與用來儲存 Key 和 Value 的mArray
有兩個快取,分別用來快取容量為 4 和 8 ArrayMap 在擴容時被換下的陣列,並且連成連結串列.
//第一個快取,實際上是一個連結串列
//用來快取長度為4的ArrayMap在擴容時換下的陣列
static @Nullable Object[] mBaseCache;
//第一個快取的大小
static int mBaseCacheSize;
//第二個快取,實際上是一個連結串列
//用來快取長度為8的ArrayMap在擴容時換下的陣列
static @Nullable Object[] mTwiceBaseCache;
//第二個快取的大小
static int mTwiceBaseCacheSize;
//以mBaseCache為例子
//連成的連結串列大概像這樣
//mBaseCache=>[ Object[] , int[] , null , null.......]
// ↑ ↑
// ↑ 對應的mHashes
// ↑
// 另一個長度為4的陣列=>[ Object[] , int[] , null , null......]
// ↑ ↑
// ↑ next mHashes
// next...
複製程式碼
上面的連結串列,不知道你看懂了沒有,反正我第一次看的的時候就在想,居然還有這種操作?充分利用了陣列空間又實現了連結串列結構.
當有 ArrayMap 例項進行擴容有換下的mHash
和mArray
剛好滿足條件(也就是mHash
的大小為 4 mArray
的大小為 8 以及mHash
的大小為 8 mArray
的大小為 16 時)時,就由 ArrayMap 上的這些的靜態快取來接收這些陣列.
因為這些欄位是靜態的,所有例項共享,併發的時候就可能會出問題,所以我們看到了下面的程式碼使用了synchronized
關鍵字在同步程式碼塊中執行修改操作.
//該函式也是ArrayMap的擴容函式
private void allocArrays(final int size) {
//BASE_SIZE的值為4,若要分配的大小等於8
if (size == (BASE_SIZE*2)) {
synchronized (ArrayMap.class) {
//若為容量為8的快取非空
if (mTwiceBaseCache != null) {
//取出該快取賦值到array上
final Object[] array = mTwiceBaseCache;
//因為長度合適,可以直接mArray
mArray = array;
//更換頭結點
mTwiceBaseCache = (Object[])array[0];
//取出快取的int[]
mHashes = (int[])array[1];
//最後清理陣列
array[0] = array[1] = null;
//連結串列長度減1
mTwiceBaseCacheSize--;
if (DEBUG) System.out.println(TAG + " Retrieving 2x cache " + mHashes
+ " now have " + mTwiceBaseCacheSize + " entries");
return;
}
}
}
//長度為4時的情況大同小異,不再贅述
else if (size == BASE_SIZE) {
synchronized (ArrayMap.class) {
if (mBaseCache != null) {
final Object[] array = mBaseCache;
mArray = array;
mBaseCache = (Object[])array[0];
mHashes = (int[])array[1];
array[0] = array[1] = null;
mBaseCacheSize--;
if (DEBUG) System.out.println(TAG + " Retrieving 1x cache " + mHashes
+ " now have " + mBaseCacheSize + " entries");
return;
}
}
}
//沒有合適的快取,則給它new新的陣列
mHashes = new int[size];
//size左移一位相當於乘2
mArray = new Object[size<<1];
}
複製程式碼
3.4 增刪查
3.4.1 增加元素
ArrayMap 並沒有實現put
方法,put
方法由它的父類 SimpleArrayMap 實現.
不過我們現在先不看put
,而是先看indexOf
,這個方法,否則接下來看put
的時候會很難受
//這個函式如果返回正值
//則表示所hash的key在表中被找到
//將返回其在陣列mHashes中的下標
//若返回負值
//則表示所hash的key在表中沒有被找到
//將返回可以在表中插入的下標用~運算子按未取反後的值
int indexOf(Object key, int hash) {
final int N = mSize;
//如果陣列為空那這個表裡就沒什麼東西好找的
if (N == 0) {
//將0按位取反
//告訴呼叫方若要插入值,在0這個下標進行插入
return ~0;
}
//如果表非空,則使用Hash進入二分搜尋
//如果找不到就返回一個負數
//也就是用~按位取反過的插入位置
//找到了就返回該Hash在mHashes中的下標
int index = binarySearchHashes(mHashes, N, hash);
//找不到了就直接返回
if (index < 0) {
return index;
}
//如果在mHash中找到了,那麼還要比較Key
//看看自己的坑是不是已經被別人給佔了
//如果佔坑的剛好是自己,就將下標返回
//index<<1是將index乘2的意思
if (key.equals(mArray[index<<1])) {
return index;
}
//否則在mArray使用線性探索法一直向後找
//直到在下一個節點的Hash與自己的Hash不等
//或者在中mArray找到自己為止
int end;
for (end = index + 1; end < N && mHashes[end] == hash; end++) {
//找到了就返回
if (key.equals(mArray[end << 1])) return end;
}
//向後找找不到就向前找
for (int i = index - 1; i >= 0 && mHashes[i] == hash; i--) {
//找到了就返回
if (key.equals(mArray[i << 1])) return i;
}
//沒找到,返回插入位置
//我們可以看到這個演算法非常傾向於減元素插入到儘可能靠後的位置
//這樣可以減少插入時需要複製的陣列條目的數量
return ~end;
}
複製程式碼
然後我們繼續進到剛才沒講的binarySearchHashes
方法
//可以看到這個方法啥都沒做
//而是把工作轉給ContainerHelpers.binarySearch去做了
//同時我們也可以在這裡看到ArrayMap是不允許併發程式設計的
//其中CONCURRENT_MODIFICATION_EXCEPTIONS的值為true
//表示如果發生ArrayIndexOutOfBoundsException那麼一定是由於併發引起的
private static int binarySearchHashes(int[] hashes, int N, int hash) {
try {
return ContainerHelpers.binarySearch(hashes, N, hash);
} catch (ArrayIndexOutOfBoundsException e) {
if (CONCURRENT_MODIFICATION_EXCEPTIONS) {
throw new ConcurrentModificationException();
} else {
throw e;
}
}
}
//我們繼續看ContainerHelpers.binarySearch
//這是一個非常常規的二分搜尋
static int binarySearch(int[] array, int size, int value) {
//區間頭
int lo = 0;
//區間尾
int hi = size - 1;
while (lo <= hi) {
//又是位運算,>>>為無符號右移運算子
//(lo + hi) >>> 1代表的就是兩數相加後除2,不過效率更高
int mid = (lo + hi) >>> 1;
//取出中值
int midVal = array[mid];
//比較Hash,若傳入的Hash更大
//說明該Hash在區間的後半段
//頭lo變成中位數的位置+1
if (midVal < value) {
lo = mid + 1;
}
//否則就是在前半段
else if (midVal > value) {
hi = mid - 1;
}
//又或者說找到了
else {
return mid; // value found
}
}
//如果坑是沒被佔過的
//也就是說該Hash在mHashes中不存在
//那麼返回的是該Hash在陣列中該插入位置
return ~lo; // value not present
}
複製程式碼
indexOf
講完了,但是我們現在還是不能開始講put
......因為 ArrayMap 的 Key 可以為null
,所以要對為null
的 Key 做特殊處理,那就是indexOfNull
這個方法
int indexOfNull() {
final int N = mSize;
//這裡和indexOf一樣
if (N == 0) {
return ~0;
}
//也是二分搜尋,只不過現在的Hash=0
//這裡要注意的一點是雖然null的Hash=0
//但不代表其他Key值的Hash就不可以為0了
//Hash只是保證在Key相等的情況下Hash一定相等
//但是不保證Hash相等的情況下Key一定相等
//所以其他非null的Key依然可能得到為0的Hash
//所以依然可能會發生Hash碰撞
int index = binarySearchHashes(mHashes, N, 0);
//如果找不到,返回可插入的位置
if (index < 0) {
return index;
}
//如果找到的位置的Key剛好就是null
//那沒什麼好說了,直接返回
if (null == mArray[index<<1]) {
return index;
}
//依然是線性探索法,向後找
int end;
for (end = index + 1; end < N && mHashes[end] == 0; end++) {
if (null == mArray[end << 1]) return end;
}
//找不到就向前找
for (int i = index - 1; i >= 0 && mHashes[i] == 0; i--) {
if (null == mArray[i << 1]) return i;
}
//找不到就返回插入位置
return ~end;
}
複製程式碼
有了上面的充足準備後,現在我們可以開始看put
了
public V put(K key, V value) {
//表的舊大小
final int osize = mSize;
final int hash;
//該Key的Hash在mHash中的下標
int index;
//如果Key為null,則Hash值為0
if (key == null) {
hash = 0;
index = indexOfNull();
} else {
hash = key.hashCode();
index = indexOf(key, hash);
}
//如果index是大於0的,那就說明Key在表中被找到
if (index >= 0) {
index = (index<<1) + 1;
//儲存舊值並設定新值
final V old = (V)mArray[index];
mArray[index] = value;
return old;
}
//否則index就是新值的插入位置
index = ~index;
//如果表中的元素數量已經大於等於mHashes的大小
//此時就說明ArrayMap需要進行一輪擴容
if (osize >= mHashes.length) {
//若之前的大小>=8,則只擴容50%(>>1等價於除2)
//若之前的大小>=4<=8,則變成8
//若之前的大小<=4,則變成4
//想清楚為什麼ArrayMap會省記憶體了嗎?
//因為HashMap擴容是翻倍
//而ArrayMap擴容時在小容量時有兩級快取
//在大容量時也最多擴容50%
final int n = osize >= (BASE_SIZE*2) ? (osize+(osize>>1))
: (osize >= BASE_SIZE ? (BASE_SIZE*2) : BASE_SIZE);
if (DEBUG) System.out.println(TAG + " put: grow from " + mHashes.length + " to " + n);
//先把兩個這陣列存在臨時變數中
final int[] ohashes = mHashes;
final Object[] oarray = mArray;
//擴容後mHashes和mArray就被設定成新陣列了
allocArrays(n);
//依然是不允許併發,這裡有併發檢查
//如果在擴容時給ArrayMap新增元素,那就會報錯
if (CONCURRENT_MODIFICATION_EXCEPTIONS && osize != mSize) {
throw new ConcurrentModificationException();
}
if (mHashes.length > 0) {
if (DEBUG) System.out.println(TAG + " put: copy 0-" + osize + " to 0");
//將舊陣列內的值拷貝到新陣列
System.arraycopy(ohashes, 0, mHashes, 0, ohashes.length);
System.arraycopy(oarray, 0, mArray, 0, oarray.length);
}
//釋放舊陣列,如果他們符合條件,就會被回收
freeArrays(ohashes, oarray, osize);
}
//如果這次put進來的Key沒有排在陣列的最後
if (index < osize) {
if (DEBUG) System.out.println(TAG + " put: move " + index + "-" + (osize-index)
+ " to " + (index+1));
//那麼就要移動陣列的元素,給當前要插入的值騰出位置
//這個過程實際上就是對陣列進行排序
//System.arraycopy這個方法在Android上是一個native方法
//所以它的效率會更高
System.arraycopy(mHashes, index, mHashes, index + 1, osize - index);
System.arraycopy(mArray, index << 1, mArray, (index + 1) << 1, (mSize - index) << 1);
}
if (CONCURRENT_MODIFICATION_EXCEPTIONS) {
if (osize != mSize || index >= mHashes.length) {
throw new ConcurrentModificationException();
}
}
//最後才是給陣列對應的位置賦值
mHashes[index] = hash;
mArray[index<<1] = key;
mArray[(index<<1)+1] = value;
mSize++;
return null;
}
複製程式碼
3.4.2 刪除元素
下面我們開始分析remove
@Nullable
public V remove(Object key) {
final int index = indexOfKey(key);
//大於0,說明才有Key
if (index >= 0) {
return removeAt(index);
}
return null;
}
//跟蹤到indexOfKey,可以發現都是上面我們已經分析過的方法
public int indexOfKey(@Nullable Object key) {
return key == null ? indexOfNull() : indexOf(key, key.hashCode());
}
//那麼繼續跟蹤到removeAt
public V removeAt(int index) {
//先拿出舊值
final Object old = mArray[(index << 1) + 1];
final int osize = mSize;
final int nsize;
//如果當前就只存了一個元素
if (osize <= 1) {
// Now empty.
if (DEBUG) System.out.println(TAG + " remove: shrink from " + mHashes.length + " to 0");
//那麼就檢查一下能否回收這兩個陣列
freeArrays(mHashes, mArray, osize);
//並將原陣列置空
mHashes = ContainerHelpers.EMPTY_INTS;
mArray = ContainerHelpers.EMPTY_OBJECTS;
nsize = 0;
}
//如果存著的不只一個元素
else {
//大小減1
nsize = osize - 1;
//如果ArrayMap的容量是大於8,並且最多隻使用了三分之一
//那麼就要重新分配空間,減少記憶體使用
if (mHashes.length > (BASE_SIZE*2) && mSize < mHashes.length/3) {
//計算出新的容量
//如果移除該元素之前ArrayMap所存的大小(Szie)大於8
//則容量(Capacity)收縮到原大小(Size)的1.5倍,否則收縮到8
final int n = osize > (BASE_SIZE*2) ? (osize + (osize>>1)) : (BASE_SIZE*2);
if (DEBUG) System.out.println(TAG + " remove: shrink from " + mHashes.length + " to " + n);
//暫存舊陣列
final int[] ohashes = mHashes;
final Object[] oarray = mArray;
//分配新陣列
allocArrays(n);
if (CONCURRENT_MODIFICATION_EXCEPTIONS && osize != mSize) {
throw new ConcurrentModificationException();
}
//和之前一樣,從舊陣列拷貝
if (index > 0) {
if (DEBUG) System.out.println(TAG + " remove: copy from 0-" + index + " to 0");
System.arraycopy(ohashes, 0, mHashes, 0, index);
System.arraycopy(oarray, 0, mArray, 0, index << 1);
}
//然後移動陣列元素
//將要移除的陣列下標後的元素往前移覆蓋掉要移除的元素
if (index < nsize) {
if (DEBUG) System.out.println(TAG + " remove: copy from " + (index+1) + "-" + nsize
+ " to " + index);
System.arraycopy(ohashes, index + 1, mHashes, index, nsize - index);
System.arraycopy(oarray, (index + 1) << 1, mArray, index << 1,
(nsize - index) << 1);
}
}
//如果沒有達成收縮條件
//則直接移動
else {
if (index < nsize) {
if (DEBUG) System.out.println(TAG + " remove: move " + (index+1) + "-" + nsize
+ " to " + index);
System.arraycopy(mHashes, index + 1, mHashes, index, nsize - index);
System.arraycopy(mArray, (index + 1) << 1, mArray, index << 1,
(nsize - index) << 1);
}
mArray[nsize << 1] = null;
mArray[(nsize << 1) + 1] = null;
}
}
if (CONCURRENT_MODIFICATION_EXCEPTIONS && osize != mSize) {
throw new ConcurrentModificationException();
}
//更新大小
mSize = nsize;
//返回舊值
return (V)old;
}
複製程式碼
最後我們再來看一下用於回收陣列的freeArrays
private static void freeArrays(final int[] hashes, final Object[] array, final int size) {
//如果長度是8,則進入同步程式碼塊
if (hashes.length == (BASE_SIZE*2)) {
synchronized (ArrayMap.class) {
//最多隻會快取10個陣列
//CACHE_SIZE=10
if (mTwiceBaseCacheSize < CACHE_SIZE) {
//為把array作為新的頭結點做準備
array[0] = mTwiceBaseCache;
array[1] = hashes;
//除了前兩個item其他置空
for (int i=(size<<1)-1; i>=2; i--) {
array[i] = null;
}
//更新頭結點
mTwiceBaseCache = array;
//連結串列長度加1
mTwiceBaseCacheSize++;
if (DEBUG) System.out.println(TAG + " Storing 2x cache " + array
+ " now have " + mTwiceBaseCacheSize + " entries");
}
}
}
//大小為4時同理,不在贅述
else if (hashes.length == BASE_SIZE) {
synchronized (ArrayMap.class) {
if (mBaseCacheSize < CACHE_SIZE) {
array[0] = mBaseCache;
array[1] = hashes;
for (int i=(size<<1)-1; i>=2; i--) {
array[i] = null;
}
mBaseCache = array;
mBaseCacheSize++;
if (DEBUG) System.out.println(TAG + " Storing 1x cache " + array
+ " now have " + mBaseCacheSize + " entries");
}
}
}
}
複製程式碼
3.4.3 查詢元素
現在我們來看看get
的原始碼
public V get(Object key) {
//我們看到實際上是呼叫getOrDefault
return getOrDefault(key, null);
}
//跳到getOrDefault,可以發現它非常簡單
//indexOfKey之前我們已經分析過,這裡只是對負值做判斷
//若index為負值則返回null
public V getOrDefault(Object key, V defaultValue) {
final int index = indexOfKey(key);
return index >= 0 ? (V) mArray[(index << 1) + 1] : defaultValue;
}
複製程式碼
4 結語
在這裡特別感謝掘金的運營負責人@優弧大大,這篇文章在寫到7000字的時候,因為我的失誤,險些造成腰斬,謝謝大大幫我找回.
自己在寫這篇文章的時候也遇到很多困難,比如在看 HashMap 中的紅黑樹的時候,由於網上文章質量良莠不齊,很多文章都存在對紅黑樹的理解不夠透徹的問題,而且自己演算法基礎也比較差,看得幾近奔潰,但是我不喜歡妥協,通過看書和參考對比大量文章堅持下來了,雖然到最後紅黑樹刪除那塊還是鴿了,因為我覺得自己都弄不清楚的東西怎麼能跟別人講清楚呢?所以乾脆別講,免得害人害己,等日後我水平達到了再補上了,通過這一趟下來感覺自己也提升了不少.
同時,在此也提醒一下各位讀者,對於複雜的資料結構和演算法理解必須捧著權威書本來做參考,不可以輕易相信網上的部落格,因為紅黑樹作為一種複雜的高階資料結構,是沒有多少人能將它完全講清楚的,或多或少都會存在一些謬誤,這對你的理解是極其有害的.所以對於本篇的態度也是一樣,本篇文章僅供您的參考,如有謬誤還請指出.
最後,這篇文章真的很來之不易,綜上種種才讓大家能在今天看到這篇文章.
如果喜歡我的文章別忘了給我點個贊,拜託了這對我來說真的很重要(我想當聯合編輯啊QAQ).