什麼是雜湊
雜湊表(hash table)我們平時也叫它雜湊表或者Hash表,它用的是陣列支援按照下標隨機訪問資料的特性,所以雜湊表其實就是陣列的一種擴充套件,由陣列演化而來。可以說,沒有陣列就沒有雜湊表。
比如我們有100件商品,編號沒有規律的4位數字,現在我們想要通過編號快速獲取商品資訊,如何做呢?我們可以將這100件商品資訊放到陣列裡,通過 商品編號%100這樣的方式得到一個值,值為1的商品放到陣列中下標為1的位置,值為2的商品,我們放到陣列中下標為2的位置。以此類推,編號為K的選手放到陣列中下標為K的位置。因為商品編號通過雜湊函式(編號%100)跟資料下標一一對應,所以但我們需要查詢編號為x的商品資訊的時候,我們使用同樣的方式,將編號轉換為陣列下標,就可以從對應的陣列下標的位置取出資料。 這就是雜湊的典型思想。
我們通過上面的例子可以得出這樣規律:雜湊表用的就是陣列支援按照下標隨機訪問的時候,時間複雜度是 O(1) 的特性。通過雜湊函式(商品編號%100)把元素的鍵值映 射為下標,然後將資料儲存在陣列中對應下標的位置。當我們按照鍵值查詢元素時,我們用同樣的雜湊函式,將鍵值轉化陣列下標,從對應的陣列下標的位置取 資料。
開放定址
一說到雜湊(或者叫做hash表),大家更熟悉的是HashMap或者LinkedHashMap,而今天的主角是ThreadLocalMap,它是ThreadLocal中的一個內部類。分析ThreadLocal原始碼的時候不可能繞過它。
由於雜湊表使用了陣列,無論hash函式如何設計都無可避免存在hash衝突。上面的例子如果兩件商品的id分別是1001和1101,那麼他們的資料就會就會被放到陣列的同一個位置,出現了衝突
鴿巢原理,又名狄利克雷抽屜原理、鴿籠原理。其中一種簡單的表述法為:若有n個籠子和n+1只鴿子,所有的鴿子都被關在鴿籠裡,那麼至少有一個籠子有至少2只鴿子
ThreadLocalMap作為hash表的一種實現方式,它是使用什麼方式來解決衝突的呢?它使用了開放定址法來解決這個問題。
元素插入
開放定址法的核心是如果出現了雜湊衝突,就重新探測一個空閒位置,將其插入。當我們往雜湊表中插入資料時,如果某個資料經過雜湊函式雜湊之後,儲存位置已經被佔用了,我們就從當前位置開始,依次往後查詢,看是否有空閒位置,直到找到為止。
從圖中可以看出,雜湊表的大小為 10 ,在元素 x 插入雜湊表之前,已經 6 個元素插入到雜湊表中。 x 經過 Hash 演算法之後,被雜湊到位置下標為 7 的位置,但是這個位置已經有資料了,所以就產生了衝突。於是我們就順序地往後一個一個找,看有沒有空閒的位置,遍歷到尾部都沒有找到空閒的位置,於是我們再從表頭開始找,直到找到空閒位置 2 ,於是將其插入到這個位置。
元素查詢
在雜湊表中查詢元素的過程有點兒類似插入過程。我們通過雜湊函式求出要查詢元素的鍵值對應的雜湊值,然後比較陣列中下標為雜湊值的元素和要查詢的元素。如果相等,則說明就是我們要找的元素;否則就順序往後依次查詢。如果遍歷到陣列中的空閒位置,還沒有找到,就說明要查詢的元素並沒有在雜湊表中。
元素刪除
ThreadLocalMap跟陣列一樣,不僅支援插入、查詢操作,還支援刪除操作。對於使用線性探測法解決衝突的雜湊表,刪除操作稍微有些特別。我們不能單純地把要刪除的元素設定為空。
還記得我們剛講的查詢操作嗎?在查詢的時候,一旦我們通過線性探測方法,找到一個空閒位置,我們就可以認定雜湊表中不存在這個資料。但是,如果這個空閒位置是我們後來刪除的,就會導致原來的查詢演算法失效。本來存在的資料,會被認定為不存在。這個問題如何解決呢?
我們可以在刪除元素之後,將之後不為null的資料rehash,這樣就不會影響查詢的邏輯
另一種方法是:可以將刪除的元素,特殊標記為 deleted 。當線性探測查詢的時候,遇到標記為 deleted 的空間,並不是停下來,而是繼續往下探測
這裡解釋下rehash的過程:當刪除元素8的時候,先把下標為8的值設定為null,然後將其後面不為空的陣列元素rehash。比如8後面的元素是9,其原本應該的位置(9%10=9)就在9所以不移動。下一個元素是19,應該在下標為9的位置,但是已經被佔用了,所以就找下一個空閒的位置,下標為3的位置是空閒的,放入tab[3]。接著下一個元素1就在tab[1]不移動, 元素7的位置在tab[7],因為已經被佔用,放入下一個空閒位置tab[8]。下一個元素仍然是19,這裡由於tab[9]已經被佔用,所以放入下一個空閒位置tab[0]。接著最後一個元素4位置就在tab[4],所以不移動。元素4的下一個位置為空,整個rehash過程結束。
裝載因子
你可能已經發現了,線性探測法其實存在很大問題。當雜湊表中插入的資料越來越多時,雜湊衝突發生的可能性就會越來越大,空閒位置會越來越少,線性探測的時間就會越來越久。極端情況下,我們可能需要探測整個雜湊表,所以最壞情況下的時間複雜度為 O(n) 。同理,在刪除和查詢時,也有可能會線性探測整張雜湊表,才能找到要查詢或者刪除的資料。
不管採用哪種探測方法,hash函式設計得在合理,當雜湊表中空閒位置不多的時候,雜湊衝突的概率就會大大提高。為了儘可能保證雜湊表的操作效率,一般情況下,我們會盡可能保證雜湊表中有一定比例的空閒槽位。我們用裝載因子(load factor)來表示空位的多少。
裝載因子的計算公式是:雜湊表的裝載因子=填入表中的元素個數/雜湊表的長度 裝載因子越大,說明空閒位置越少,衝突越多,雜湊表的效能會下降。
原始碼分析
ThreadLocalMap定義
ThreadLocal的核心資料結構是ThreadLocalMap,它的資料結構如下:
static class ThreadLocalMap {
// 這裡的entry繼承WeakReference了
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
// 初始化容量,必須是2的n次方
private static final int INITIAL_CAPACITY = 16;
// entry陣列,用於儲存資料
private Entry[] table;
// map的容量
private int size = 0;
// 資料量達到多少進行擴容,預設是 table.length * 2 / 3
private int threshold;
複製程式碼
從ThreadLocalMap的定義可以看出Entry的key就是ThreadLocal,而value就是值。同時,Entry也繼承WeakReference,所以說Entry所對應key(ThreadLocal例項)的引用為一個弱引用。而且定義了裝載因子為陣列長度的三分之二。
set()方法
private void set(ThreadLocal<?> key, Object value) {
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);
// 採用線性探測,尋找合適的插入位置
for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) {
ThreadLocal<?> k = e.get();
// key存在則直接覆蓋
if (k == key) {
e.value = value;
return;
}
// key不存在,說明之前的ThreadLocal物件被回收了
if (k == null) {
replaceStaleEntry(key, value, i);
return;
}
}
// 不存在也沒有舊元素,就建立一個
tab[i] = new Entry(key, value);
int sz = ++size;
// 清除舊的槽(entry不為空,但是ThreadLocal為空),並且當陣列中元素大於閾值就rehash
if (!cleanSomeSlots(i, sz) && sz >= threshold)
expungeStaleEntries();
// 擴容
if (size >= threshold - threshold / 4)
resize();
}
複製程式碼
上面原始碼的主要步驟如下:
- 採用線性探測法,尋找合適的插入位置。首先判斷key是否存在,存在則直接覆蓋。如果key不存在證明被垃圾回收了此時需要用新的元素替換舊的元素
- 不存在對應的元素,需要建立一個新的元素
- 清除entry不為空,但是ThreadLocal(entry的key被回收了)的元素,防止記憶體洩露
- 如果滿足條件:size >= threshold - threshold / 4就將陣列擴大為之前的兩倍,同時會重新計算陣列元素所處的位置並進行移動(rehash)。比如最開始陣列初始大小為16,當size >= (16*2/3=10) - (10/4) = 8的時候就會擴容,將陣列大小擴容至32.
無論是replaceStaleEntry()方法還是cleanSomeSlots()方法,最重要的方法呼叫是expungeStaleEntry(),你可以在ThreadLocalMap中的get,set,remove都能發現呼叫它的身影。
private int expungeStaleEntry(int staleSlot) {
Entry[] tab = table;
int len = tab.length;
// 刪除對應位置的entry
tab[staleSlot].value = null;
tab[staleSlot] = null;
size--;
Entry e;
int i;
// rehash過程,直到entry為null
for (i = nextIndex(staleSlot, len);(e = tab[i]) != null; i = nextIndex(i, len)) {
ThreadLocal<?> k = e.get();
// k為空,證明已經被垃圾回收了
if (k == null) {
e.value = null;
tab[i] = null;
size--;
} else {
int h = k.threadLocalHashCode & (len - 1);
// 判斷當前元素是否處於"真正"應該待的位置
if (h != i) {
tab[i] = null;
// 線性探測
while (tab[h] != null)
h = nextIndex(h, len);
tab[h] = e;
}
}
}
return i;
}
複製程式碼
上面rehash的程式碼結合文章開頭的說明理解起來更是容易,當從ThreadLocalMap新增,獲取,刪除的時候都會根據條件進行rehash,條件如下
- ThreadLocal物件被回收,此時Entry中key為null,value不為null。這時會觸發rehash
- 當閾值達到ThreadLocalMap容量的三分之二的時候
get()方法
private Entry getEntry(ThreadLocal<?> key) {
int i = key.threadLocalHashCode & (table.length - 1);
Entry e = table[i];
// 現在資料中進行查詢
if (e != null && e.get() == key)
return e;
else
return getEntryAfterMiss(key, i, e);
}
// 採用線性探測找到對應元素
private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
Entry[] tab = table;
int len = tab.length;
while (e != null) {
ThreadLocal<?> k = e.get();
// 找到元素
if (k == key)
return e;
// ThreadLocal為空,需要刪除過期元素,同時進行rehash
if (k == null)
expungeStaleEntry(i);
else
i = nextIndex(i, len);
e = tab[i];
}
return null;
}
複製程式碼
線性探測法貫穿了get,set的所有流程,理解了原理在看程式碼就很簡單了。
remove()方法
private void remove(ThreadLocal<?> key) {
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);
for (Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
if (e.get() == key) {
e.clear();
expungeStaleEntry(i);
return;
}
}
}
複製程式碼
remove的時候回刪除舊的entry,然後進行rehash.
ThreadLocal的使用
public class Counter {
private static ThreadLocal<Integer> seqCount = new ThreadLocal<Integer>(){
public Integer initialValue() {
return 0;
}
};
public int nextInt(){
seqCount.set(seqCount.get() + 1);
return seqCount.get();
}
public static void main(String[] args){
Counter seqCount = new Counter();
CountThread thread1 = new CountThread(seqCount);
CountThread thread2 = new CountThread(seqCount);
CountThread thread3 = new CountThread(seqCount);
CountThread thread4 = new CountThread(seqCount);
thread1.start();
thread2.start();
thread3.start();
thread4.start();
}
private static class CountThread extends Thread{
private Counter counter;
CountThread(Counter counter){
this.counter = counter;
}
@Override
public void run() {
for(int i = 0 ; i < 3 ; i++){
System.out.println(Thread.currentThread().getName() + " seqCount :" + counter.nextInt());
}
}
}
}
複製程式碼
執行效果如下:
Thread-3 seqCount :1
Thread-0 seqCount :1
Thread-3 seqCount :2
Thread-0 seqCount :2
Thread-0 seqCount :3
Thread-2 seqCount :1
Thread-2 seqCount :2
Thread-1 seqCount :1
Thread-3 seqCount :3
Thread-1 seqCount :2
Thread-1 seqCount :3
Thread-2 seqCount :3
複製程式碼
ThreadLocal 其實是為每個執行緒都提供一份變數的副本, 從而實現同時訪問而不受影響。從這裡也看出來了它和synchronized之間的應用場景不同, synchronized是為了讓每個執行緒對變數的修改都對其他執行緒可見, 而 ThreadLocal 是為了執行緒物件的資料不受其他執行緒影響, 它最適合的場景應該是在同一執行緒的不同開發層次中共享資料。