目錄
-
簡單概念
上面一篇博客分析了Lrucache的實現原理,我們知道他是通過自己實現同步,然後利用LinkedHashMap來實現的Lru功能。這篇文章就分析一下LinkedHashMap,首先說說它的一些基本的概念(基於jdk1.8)。
1 首先它是一個關聯數組,哈希表。他不是線程安全的,key和value可以爲空,這也是與hashtable一族的最大區別。這個特性也是它的父類HashMap的特性繼承而來。
2 linkedHashMap內部維護了一個雙向鏈表,因爲這個原因,因此它的遍歷順序是固定的,可以按照存儲順序排序。並且設置參數accessOrder爲true可以使它可以按照訪問順序進行輸出,這也是實現Lrucache功能的基礎。而Hashmap不具有這個功能,它的存儲順序可能會因爲擴容的原因導致變化。
LinkedHashMap的基本存儲結構如下:
在LinkedHashMap中初始化的時候數組長度是16,加載因子是0.75,所以擴容的閾值是12, 當數組長度大於12就需要擴容了。這個加載因子不能太大,或者太小。太大的話導致數組長度太大,遍歷的時間複雜度會越來越大,太小的話會導致頻繁的擴容,內存空間會大量損失,所以官方的0.75是時間空間的一個相互兼容的結果。數組中每個元素下面的鏈表也有一個最大長度,目前默認值是8,如果鏈表的長度大於8, 那麼久將鏈表改爲紅黑樹方式存儲數據。因爲紅黑樹的查詢時間複雜度是O(log n)級別。這樣操作起來更加高效。
-
源碼
介紹了基本概念,在源碼裏總有一個buckets,他表示數組中的每個元素。一般稱爲哈希桶,其實也可以稱爲哈希表。
//這個表示數組的初始大小,使用位移的方式是爲了計算更加快捷,源碼中很多都是移位操作
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
//數組擴容的最大值
static final int MAXIMUM_CAPACITY = 1 << 30;
//加載因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//樹形閾值,鏈表改爲紅黑樹的最大值,超過8就需要將鏈表改爲紅黑樹
static final int TREEIFY_THRESHOLD = 8;
//非樹形閾值,當紅黑樹的元素個數小於6的時候,將樹形改爲鏈表
static final int UNTREEIFY_THRESHOLD = 6;
//linkedhashMap中存儲節點的類,可以看出在其內部有兩個變量,before,after,
//表示它前後的元素。這是維護雙向鏈表的基礎
static class LinkedHashMapEntry<K,V> extends HashMap.Node<K,V> {
LinkedHashMapEntry<K,V> before, after;
LinkedHashMapEntry(int hash, K key, V value, Node<K,V> next) {
super(hash, key, value, next);
}
}
//雙向鏈表的頭部元素,表示最久未被使用的元素
transient LinkedHashMapEntry<K,V> head;
//雙向鏈表末尾的元素,表示最近的元素
transient LinkedHashMapEntry<K,V> tail;
//這個變量決定鏈表的存儲方式,false按照存儲順序存儲,true表示按照訪問順序存儲
final boolean accessOrder;
我們先看構造函數:
//聲明數組的長度和加載因子
public LinkedHashMap(int initialCapacity, float loadFactor) {
super(initialCapacity, loadFactor);
accessOrder = false;
}
//只聲明數組長度,
public LinkedHashMap(int initialCapacity) {
super(initialCapacity);
accessOrder = false;
}
//均使用默認值
public LinkedHashMap() {
super();
accessOrder = false;
}
//直接使用另外的map初始化
public LinkedHashMap(Map<? extends K, ? extends V> m) {
super();
accessOrder = false;
putMapEntries(m, false);
}
//指定數組長度,加載因子,並且會根據accessOrder的值來確定輸出順序
public LinkedHashMap(int initialCapacity,
float loadFactor,
boolean accessOrder) {
super(initialCapacity, loadFactor);
this.accessOrder = accessOrder;
}
在這裏進行數據的初始化,各個變量在我們沒有賦值的情況下均使用默認值。默認值在上面已經寫明。
接下來我們看put函數:
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
在put的時候,我們首先要獲取一個key的hash值,根據這個值來確認哈希桶的位置,我們可以看出,key通過hashCode獲取整行值,然後會右移16位(也就是捨棄了低16位)然後與hashCode值進行異或。 這樣做的目的就是減少碰撞,使哈希桶的位置能均勻的分配到數組中區,否則如果發生碰撞就會使數組某些位置的哈希桶元素過多。導致鏈表過長。(異或可以使0,1出現概率均爲0.5)。
接下來看看linkedHashMap的增操作:需要明確的是linkedHashMap沒有重寫put方法,所以以下均是hashmap的方法
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數組肯定是空,將map的數組結果臨時複製給tab,
//首先需要調用resize()函數進行初始化,resize函數既有初始化功能也有擴容的
//的功能,下面會詳細講,只是這一句話利用的是它的初始化功能
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
//如果table已經創建,而需要添加的元素的哈希值對應的數組下標
//的哈希桶爲空,那麼直接創建一個新的元素存放到數組。此處有內容需要在
//下面詳細講
tab[i] = newNode(hash, key, value, null);
else {
//對應位置的哈希桶不爲空,發生了碰撞
Node<K,V> e; K k;
//如果對應位置有已經有元素了 且 key 是相同的則覆蓋元素
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節點的下一個是空,那麼直接把數據存儲在p的下一個即可
p.next = newNode(hash, key, value, null);
//存儲完成之後需要判斷是否已經達到了樹形閾值
//如果達到了這個值就需要把鏈表轉換爲紅黑樹
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
//如果遍歷過程中找到鏈表中有個節點的 key 與 當前要插入元素的 key 相同,
//此時 e 所指的節點爲需要替換 Value 的節點,並結束循環
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
//遍歷之後如果e不爲空,那麼表示新添加的數據需要替換e的值,這個時候需要返回原值。
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
//這個函數在linkedHashmap中實現,將添加的元素移動到雙向鏈表的尾端
//單純的hashmap不需要,是實現lru的方法
afterNodeAccess(e);
return oldValue;
}
}
//操作數加1
++modCount;
//如果元素數量加1,大於需要擴容閾值,則需要擴容
if (++size > threshold)
resize();
//hashmap中此函數爲空,這個是專門留給linkedhashmap的
//這個方法在linkedhashmap中實現,是實現lrucache的關鍵
afterNodeInsertion(evict);
return null;
}
在這過程中:p = tab[i = (n - 1) & hash] 當計算新入元素處於哈希桶的位置的時候,我們一般的想法應該是hash值對(capacity )取餘,如果以默認值舉例,那就是hash值對16取餘數,餘數爲多少。這個元素所在的哈希桶在數組的位置就是多少。在這裏hashmap沒有用%運算。直接用的(n-1)&hash, 位與運算。15的二進制表示是:01111,其餘的高位都是0. 所以如果按位與的話,任何數值的高28位無論是0,或者1,與的結果都是0. 所以無論低四位是什麼值,這個結果最大就是1111, 所以他與對15取餘效果一樣。這也是爲什麼數組的長度必須是2的N次方的原因。只有2的N次方-1,纔可以保證低位全部是1, 這樣任何數按位與運算都可以得出與取模一樣的結果。例如123&15
0000000000000000000000000 1111011
0000000000000000000000000 0001111
----------------------------------------------------------
0000000000000000000000000 0001011
結果是 11,這與123對16取餘是一樣的。但是&運算更加快捷簡單。我們接下來看看擴容的過程就是resize。
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) {
//如果原來的數組長度已經大於2^30,那麼將不再擴容,直接返回原數組
if (oldCap >= MAXIMUM_CAPACITY) {
//將擴容閾值改爲Integer.MAX_VALUE就是不再擴容
threshold = Integer.MAX_VALUE;
return oldTab;
}
//新的數組長度擴容爲原來的2倍,擴容閾值也變爲原來的2倍。
//<<1與*2的效果一樣。這裏也保證了數組長度爲2的N次冪
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
//如果我們通過加載因子指定了擴容閾值,那麼直接賦給數組長度
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else {
//直接通過默認值將數組長度指定爲16,擴容因子指定爲12
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
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;
//以上就是擴容中解決數組的部分。如果原數組不爲空,那麼需要重新分配哈希桶的位置
if (oldTab != null) {
//遍歷數組中的所有哈希桶,重新分配位置
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
//將原數組下標爲j的位置置空
oldTab[j] = null;
//如果哈希桶(也就是鏈表)只有一個元素,那麼從新計算它的位置直接插入
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
//如果是紅黑樹,那需要重新計算在紅黑樹中的節點。
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
//因爲擴容爲原來的2倍,所以需要重新計算鏈表中的每個元素從屬的
//哈希桶在數組中的位置,理論上原來的一條鏈表有可能會拆分成2條
//比如原來容量是16,現在是32,當時下標是1的元素,有可能是17,
//也有可能是1,或者33, 當擴容之後,下標就有可能是1,和17兩個了
//差值爲一個原來的數組長度,所以lo開頭的2個節點表示重新計算之後人處於
//低位的鏈表的頭尾兩個指針(比如下標爲1),hi開頭的2個節點表示處於
//高位的鏈表的頭尾節點.(比如下標17)。 至於e.hash &oldCap的作用
//下面單獨講
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;
}
這裏我們講講e.hash &oldcap ==0 的作用,首先我們明確oldcap無論是多少。他的二進制只有一位是1, 其餘全是0, 咱們用16舉例吧。那麼它是 0000 10000. 如果一個值a和它按位與爲0.那麼他的低5位肯定也是0.因爲當時擴容之前a是與00000 1111按位與,這個時候如果他的低5位是0, 那麼在與擴容之後的n-1按位與的話(本例是0000 11111),得到的結果仍然與原來的值相同,所以凡是與oldcap按位與等於0,那麼這個元素在新的數組中仍然處於與原來的數組同樣的一個哈希桶之中。如果&oldcap不等於0, 那麼他的第五位必是1,他與擴容之後的按位與的結果與原來的值相比就是多了一個10000,也就是16, 所以這個元素所屬的哈希桶的位置在原來的位置上要加16,也就是加一個oldcap.
所以整個添加過程如下:
1 當table爲空的時候表示第一次添加。那麼進行第一次擴容。
2 通過計算存儲元素的hash值,並通過計算獲取此元素所在哈希桶的數組下標。
3 判斷此下標中的哈希桶是否有值,如果爲空則直接插入,不爲空判斷key是否已經存才,如果存在則覆蓋原來的值。並返回原值。
4 如果哈希桶中沒有存儲過這個元素,那麼如果這個哈希桶已經變爲紅黑樹,就按照紅黑樹的原則添加,否則要遍歷鏈表將數據插入鏈 表的尾部,插入之後要判斷鏈表是否需要改爲紅黑樹。如果是就進行變換。
5 插入成功之後,判斷是否需要再次擴容。之後根據結果進行操作。
他的整個過程入下圖所示: 這張圖來源自java紅黑樹
以上的內容是hashmap的插入過程,但是linkedHashMap是雙向鏈表的結構,雖然linkedhashmap沒有重寫插入方法,但是重寫了幾個方法來實現它的目的。我們一一看看這些函數:
首先newNode()生成節點的方式,這是在插入過程中生成新的節點:
Node<K,V> newNode(int hash, K key, V value, Node<K,V> e) {
//生成一個新的節點,然後將它移動到最後
LinkedHashMapEntry<K,V> p =
new LinkedHashMapEntry<K,V>(hash, key, value, e);
linkNodeLast(p);
return p;
}
private void linkNodeLast(LinkedHashMapEntry<K,V> p) {
//臨時變量指向雙向鏈表的尾部節點
LinkedHashMapEntry<K,V> last = tail;
tail = p;
//如果尾部節點爲空,那麼就是第一個節點。這個時候新加入的數據賦給頭部節點
if (last == null)
head = p;
else {
//否則將p添加到末尾
p.before = last;
last.after = p;
}
}
這個是linked插入的過程,在hashmap中,還有幾個函數是插入之後的回調。如下:
// Callbacks to allow LinkedHashMap post-actions
void afterNodeAccess(Node<K,V> p) { }
void afterNodeInsertion(boolean evict) { }
void afterNodeRemoval(Node<K,V> p) { }
我們依次看看這幾個函數
//根據evict,也就是前文linkedHashMap構造函數中的accessOrder,來判斷是否刪除雙向
//鏈表中最老的元素,這個是實現lru需要用到的。
void afterNodeInsertion(boolean evict) {
LinkedHashMapEntry<K,V> first;
//判斷是否需要刪除
if (evict && (first = head) != null && removeEldestEntry(first)) {
K key = first.key;
removeNode(hash(key), key, null, false, true);
}
}
//根據源碼中的註釋,這個函數一般返回flase,但是當cache已滿的情況下返回true
protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
return false;
}
//將添加的元素移動到鏈表的尾端,一個簡單的操作,不做細講了。
void afterNodeAccess(Node<K,V> e) { // move node to last
LinkedHashMapEntry<K,V> last;
if (accessOrder && (last = tail) != e) {
LinkedHashMapEntry<K,V> p =
(LinkedHashMapEntry<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;
}
}
綜上我們可以大概描述linkedHashmap的添加流程:
到這裏我們基本把插入流程描述結束。由於篇幅限制,其他操作在下一篇博客中描述。linkedHashMap源碼解析(二)