原文出處:http://blog.jobbole.com/30940/前端
引言java
咱們都聽過 cache,當你問他們是什麼是緩存的時候,他們會給你一個完美的答案,但是他們不知道緩存是怎麼構建的,或者沒有告訴你應該採用什麼標準去選擇緩存框架。在這邊文章,咱們會去討論緩存,緩存算法,緩存框架以及哪一個緩存框架會更好。面試
面試算法
「緩存就是存貯數據(使用頻繁的數據)的臨時地方,由於取原始數據的代價太大了,因此我能夠取得快一些。」數據庫
這就是 programmer one (programmer one 是一個面試者)在面試中的回答(一個月前,他向公司提交了簡歷,想要應聘要求在緩存,緩存框架,大規模數據操做有着豐富經驗的 java 開發職位)。數組
programmer one 經過 hash table 實現了他本身的緩存,可是他知道的只是他的緩存和他那存儲着150條記錄的 hash table,這就是他認爲的大規模數據(緩存 = hashtable,只須要在 hash table 查找就行了),因此,讓咱們來看看面試的過程吧。瀏覽器
面試官:你選擇的緩存方案,是基於什麼標準的?緩存
programmer one:呃,(想了5分鐘)嗯,基於,基於,基於數據(咳嗽……)數據結構
面試官:excese me ! 能不能重複一下?框架
programmer one:數據?!
面試官:好的。說說幾種緩存算法以及它們的做用
programmer one:(凝視着面試官,臉上露出了很奇怪的表情,沒有人知道原來人類能夠作出這種表情 )
面試官:好吧,那我換個說法,當緩存達到容量時,會怎麼作?
programmer one:容量?嗯(思考……hash table 的容量時沒有限制的,我能任意增長條目,它會自動擴充容量的)(這是 programmer one 的想法,可是他沒有說出來)
面試官對 programmer one 表示感謝(面試過程持續了10分鐘),以後一個女士走過來講:謝謝你的時間,咱們會給你打電話的,祝你好心情。這是 programmer one 最糟糕的面試(他沒有看到招聘對求職者有豐富的緩存經驗背景要求,實際上,他只看到了豐厚的報酬 )。
說到作到
programmer one 離開以後,他想要知道這個面試者說的問題和答案,因此他上網去查,programmer one 對緩存一無所知,除了:當我須要緩存的時候,我就會用 hash table。
在他使用了他最愛的搜索引擎搜索以後,他找到了一篇很不錯的關於緩存文章,而且開始去閱讀……
爲何咱們須要緩存?
好久好久之前,在尚未緩存的時候……用戶常常是去請求一個對象,而這個對象是從數據庫去取,而後,這個對象變得愈來愈大,這個用戶每次的請求時間也愈來愈長了,這也把數據庫弄得很痛苦,他無時不刻不在工做。因此,這個事情就把用戶和數據庫弄得很生氣,接着就有可能發生下面兩件事情:
1.用戶很煩,在抱怨,甚至不去用這個應用了(這是大多數狀況下都會發生的)
2.數據庫爲打包回家,離開這個應用,而後,就出現了大麻煩(沒地方去存儲數據了)(發生在極少數狀況下)
上帝派來了緩存
在幾年以後,IBM(60年代)的研究人員引進了一個新概念,它叫「緩存」。
什麼是緩存?
正如開篇所講,緩存是「存貯數據(使用頻繁的數據)的臨時地方,由於取原始數據的代價太大了,因此我能夠取得快一些。」
緩存能夠認爲是數據的池,這些數據是從數據庫裏的真實數據複製出來的,而且爲了能別取回,被標上了標籤(鍵 ID)。太棒了
programmer one 已經知道這點了,可是他還不知道下面的緩存術語。
命中:
當客戶發起一個請求(咱們說他想要查看一個產品信息),咱們的應用接受這個請求,而且若是是在第一次檢查緩存的時候,須要去數據庫讀取產品信息。
若是在緩存中,一個條目經過一個標記被找到了,這個條目就會被使用、咱們就叫它緩存命中。因此,命中率也就不難理解了。
Cache Miss:
可是這裏須要注意兩點:
1. 若是還有緩存的空間,那麼,沒有命中的對象會被存儲到緩存中來。
2. 若是緩存慢了,而又沒有命中緩存,那麼就會按照某一種策略,把緩存中的舊對象踢出,而把新的對象加入緩存池。而這些策略統稱爲替代策略(緩存算法),這些策略會決定到底應該提出哪些對象。
存儲成本:
當沒有命中時,咱們會從數據庫取出數據,而後放入緩存。而把這個數據放入緩存所須要的時間和空間,就是存儲成本。
索引成本:
和存儲成本相仿。
失效:
當存在緩存中的數據須要更新時,就意味着緩存中的這個數據失效了。
替代策略:
當緩存沒有命中時,而且緩存容量已經滿了,就須要在緩存中踢出一個老的條目,加入一條新的條目,而到底應該踢出什麼條目,就由替代策略決定。
最優替代策略:
最優的替代策略就是想把緩存中最沒用的條目給踢出去,可是將來是不可以被預知的,因此這種策略是不可能實現的。可是有不少策略,都是朝着這個目前去努力。
Java 街惡夢:
當 programmer one 在讀這篇文章的時候,他睡着了,而且作了個惡夢(每一個人都有作惡夢的時候)。
programmer one:nihahha,我要把你弄失效!(瘋狂的狀態)
緩存對象:別別,讓我活着,他們還須要我,我還有孩子。
programmer one:每一個緩存對象在失效以前都會那樣說。你從何時開始有孩子的?不用擔憂,如今就永遠消失吧!
哈哈哈哈哈……programmer one 恐怖的笑着,可是警笛打破了沉靜,警察把 programmer one 抓了起來,而且控告他殺死了(失效)一個仍需被使用的緩存對象,他被押到了監獄。
programmer one 忽然醒了,他被嚇到了,渾身是汗,他開始環顧四周,發現這確實是個夢,而後趕忙繼續閱讀這篇文章,努力的消除本身的恐慌。
在programmer one 醒來以後,他又開始閱讀文章了。
緩存算法
沒有人能說清哪一種緩存算法優於其餘的緩存算法
Least Frequently Used(LFU):
你們好,我是 LFU,我會計算爲每一個緩存對象計算他們被使用的頻率。我會把最不經常使用的緩存對象踢走。
Least Recently User(LRU):
我是 LRU 緩存算法,我把最近最少使用的緩存對象給踢走。
我老是須要去了解在何時,用了哪一個緩存對象。若是有人想要了解我爲何總能把最近最少使用的對象踢掉,是很是困難的。
瀏覽器就是使用了我(LRU)做爲緩存算法。新的對象會被放在緩存的頂部,當緩存達到了容量極限,我會把底部的對象踢走,而技巧就是:我會把最新被訪問的緩存對象,放到緩存池的頂部。
因此,常常被讀取的緩存對象就會一直呆在緩存池中。有兩種方法能夠實現我,array 或者是 linked list。
個人速度很快,我也能夠被數據訪問模式適配。我有一個你們庭,他們均可以完善我,甚至作的比我更好(我確實有時會嫉妒,可是不要緊)。我家庭的一些成員包括 LRU2 和 2Q,他們就是爲了完善 LRU 而存在的。
Least Recently Used 2(LRU2):
我是 Least Recently Used 2,有人叫我最近最少使用 twice,我更喜歡這個叫法。我會把被兩次訪問過的對象放入緩存池,當緩存池滿了以後,我會把有兩次最少使用的緩存對象踢走。由於須要跟蹤對象2次,訪問負載就會隨着緩存池的增長而增長。若是把我用在大容量的緩存池中,就會有問題。另外,我還須要跟蹤那麼不在緩存的對象,由於他們尚未被第二次讀取。我比LRU好,並且是 adoptive to access 模式 。
Two Queues(2Q):
我是 Two Queues;我把被訪問的數據放到 LRU 的緩存中,若是這個對象再一次被訪問,我就把他轉移到第二個、更大的 LRU 緩存。
我踢走緩存對象是爲了保持第一個緩存池是第二個緩存池的1/3。當緩存的訪問負載是固定的時候,把 LRU 換成 LRU2,就比增長緩存的容量更好。這種機制使得我比 LRU2 更好,我也是 LRU 家族中的一員,並且是 adoptive to access 模式 。
Adaptive Replacement Cache(ARC):
我是 ARC,有人說我是介於 LRU 和 LFU 之間,爲了提升效果,我是由2個 LRU 組成,第一個,也就是 L1,包含的條目是最近只被使用過一次的,而第二個 LRU,也就是 L2,包含的是最近被使用過兩次的條目。所以, L1 放的是新的對象,而 L2 放的是經常使用的對象。因此,別人纔會認爲我是介於 LRU 和 LFU 之間的,不過不要緊,我不介意。
我被認爲是性能最好的緩存算法之一,可以自調,而且是低負載的。我也保存着歷史對象,這樣,我就能夠記住那些被移除的對象,同時,也讓我能夠看到被移除的對象是否能夠留下,取而代之的是踢走別的對象。個人記憶力不好,可是我很快,適用性也強。
Most Recently Used(MRU):
我是 MRU,和 LRU 是對應的。我會移除最近最多被使用的對象,你必定會問我爲何。好吧,讓我告訴你,當一次訪問過來的時候,有些事情是沒法預測的,而且在緩存系統中找出最少最近使用的對象是一項時間複雜度很是高的運算,這就是爲何我是最好的選擇。
我是數據庫內存緩存中是多麼的常見!每當一次緩存記錄的使用,我會把它放到棧的頂端。當棧滿了的時候,你猜怎麼着?我會把棧頂的對象給換成新進來的對象!
First in First out(FIFO):
我是先進先出,我是一個低負載的算法,而且對緩存對象的管理要求不高。我經過一個隊列去跟蹤全部的緩存對象,最近最經常使用的緩存對象放在後面,而更早的緩存對象放在前面,當緩存容量滿時,排在前面的緩存對象會被踢走,而後把新的緩存對象加進去。我很快,可是我並不適用。
Second Chance:
你們好,我是 second chance,我是經過 FIFO 修改而來的,被你們叫作 second chance 緩存算法,我比 FIFO 好的地方是我改善了 FIFO 的成本。我是 FIFO 同樣也是在觀察隊列的前端,可是很FIFO的馬上踢出不一樣,我會檢查即將要被踢出的對象有沒有以前被使用過的標誌(1一個 bit 表示),沒有沒有被使用過,我就把他踢出;不然,我會把這個標誌位清除,而後把這個緩存對象當作新增緩存對象加入隊列。你能夠想象就這就像一個環隊列。當我再一次在隊頭碰到這個對象時,因爲他已經沒有這個標誌位了,因此我馬上就把他踢開了。我在速度上比 FIFO 快。
CLock:
我是 Clock,一個更好的 FIFO,也比 second chance 更好。由於我不會像 second chance 那樣把有標誌的緩存對象放到隊列的尾部,可是也能夠達到 second chance 的效果。
我持有一個裝有緩存對象的環形列表,頭指針指向列表中最老的緩存對象。當緩存 miss 發生而且沒有新的緩存空間時,我會問問指針指向的緩存對象的標誌位去決定我應該怎麼作。若是標誌是0,我會直接用新的緩存對象替代這個緩存對象;若是標誌位是1,我會把頭指針遞增,而後重複這個過程,知道新的緩存對象可以被放入。我比 second chance 更快。
Simple time-based:
我是 simple time-based 緩存算法,我經過絕對的時間週期去失效那些緩存對象。對於新增的對象,我會保存特定的時間。我很快,可是我並不適用。
Extended time-based expiration:
我是 extended time-based expiration 緩存算法,我是經過相對時間去失效緩存對象的;對於新增的緩存對象,我會保存特定的時間,好比是每5分鐘,天天的12點。
Sliding time-based expiration:
我是 sliding time-based expiration,與前面不一樣的是,被我管理的緩存對象的生命起點是在這個緩存的最後被訪問時間算起的。我很快,可是我也不太適用。
其餘的緩存算法還考慮到了下面幾點:
成本:若是緩存對象有不一樣的成本,應該把那些難以得到的對象保存下來。
容量:若是緩存對象有不一樣的大小,應該把那些大的緩存對象清除,這樣就可讓更多的小緩存對象進來了。
時間:一些緩存還保存着緩存的過時時間。電腦會失效他們,由於他們已通過期了。
根據緩存對象的大小而無論其餘的緩存算法多是有必要的。
電子郵件!
在讀完這篇文章以後,programmer one 想了一下子,而後決定給做者發封郵件,他感受做者的名字在哪聽過,可是已經想不起來了。無論怎樣,他仍是把郵件發送出來了,他詢問了做者在分佈式環境中,緩存是怎麼樣工做的。
文章的做者收到了郵件,具備諷刺意味的是,這個做者就是面試 programmer one 的人 ,做者回復了……
在這一部分中,咱們來看看如何實現這些著名的緩存算法。如下的代碼只是示例用的,若是你想本身實現緩存算法,可能本身還得加上一些額外的工做。
LeftOver 機制
在 programmer one 閱讀了文章以後,他接着看了文章的評論,其中有一篇評論提到了 leftover 機制——random cache。
Random Cache
我是隨機緩存,我隨意的替換緩存實體,沒人敢抱怨。你能夠說那個被替換的實體很倒黴。經過這些行爲,我隨意的去處緩存實體。我比 FIFO 機制好,在某些狀況下,我甚至比 LRU 好,可是,一般LRU都會比我好。
如今是評論時間
當 programmer one 繼續閱讀評論的時候,他發現有個評論很是有趣,這個評論實現了一些緩存算法,應該說這個評論作了一個鏈向評論者網站的連接,programmer one順着連接到了那個網站,接着閱讀。
看看緩存元素(緩存實體)
1
2
3
4
5
6
7
|
public
class
CacheElement
{
private
Object objectValue;
private
Object objectKey;
private
int
index;
private
int
hitCount;
// getters and setters
}
|
這個緩存實體擁有緩存的key和value,這個實體的數據結構會被如下全部緩存算法用到。
緩存算法的公用代碼
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
public
final
synchronized
void
addElement(Object key, Object value)
{
int
index;
Object obj;
// get the entry from the table
obj = table.get(key);
// If we have the entry already in our table
// then get it and replace only its value.
obj = table.get(key);
if
(obj !=
null
)
{
CacheElement element;
element = (CacheElement) obj;
element.setObjectValue(value);
element.setObjectKey(key);
return
;
}
}
|
上面的代碼會被全部的緩存算法實現用到。這段代碼是用來檢查緩存元素是否在緩存中了,若是是,咱們就替換它,可是若是咱們找不到這個 key 對應的緩存,咱們會怎麼作呢?那咱們就來深刻的看看會發生什麼吧!
現場訪問
今天的專題很特殊,由於咱們有特殊的客人,事實上他們是咱們想要聽的與會者,可是首先,先介紹一下咱們的客人:Random Cache,FIFO Cache。讓咱們從 Random Cache開始。
看看隨機緩存的實現
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
|
public
final
synchronized
void
addElement(Object key, Object value)
{
int
index;
Object obj;
obj = table.get(key);
if
(obj !=
null
)
{
CacheElement element;
// Just replace the value.
element = (CacheElement) obj;
element.setObjectValue(value);
element.setObjectKey(key);
return
;
}
// If we haven't filled the cache yet, put it at the end.
if
(!isFull())
{
index = numEntries;
++numEntries;
}
else
{
// Otherwise, replace a random entry.
index = (
int
) (cache.length * random.nextFloat());
table.remove(cache[index].getObjectKey());
}
cache[index].setObjectValue(value);
cache[index].setObjectKey(key);
table.put(key, cache[index]);
}
|
看看FIFO緩算法的實現
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
|
public
final
synchronized
void
addElement(Objectkey, Object value)
{
int
index;
Object obj;
obj = table.get(key);
if
(obj !=
null
)
{
CacheElement element;
// Just replace the value.
element = (CacheElement) obj;
element.setObjectValue(value);
element.setObjectKey(key);
return
;
}
// If we haven't filled the cache yet, put it at the end.
if
(!isFull())
{
index = numEntries;
++numEntries;
}
else
{
// Otherwise, replace the current pointer,
// entry with the new one.
index = current;
// in order to make Circular FIFO
if
(++current >= cache.length)
current =
0
;
table.remove(cache[index].getObjectKey());
}
cache[index].setObjectValue(value);
cache[index].setObjectKey(key);
table.put(key, cache[index]);
}
|
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
|
public
synchronized
Object getElement(Object key)
{
Object obj;
obj = table.get(key);
if
(obj !=
null
)
{
CacheElement element = (CacheElement) obj;
element.setHitCount(element.getHitCount() +
1
);
return
element.getObjectValue();
}
return
null
;
}
public
final
synchronized
void
addElement(Object key, Object value)
{
Object obj;
obj = table.get(key);
if
(obj !=
null
)
{
CacheElement element;
// Just replace the value.
element = (CacheElement) obj;
element.setObjectValue(value);
element.setObjectKey(key);
return
;
}
if
(!isFull())
{
index = numEntries;
++numEntries;
}
else
{
CacheElement element = removeLfuElement();
index = element.getIndex();
table.remove(element.getObjectKey());
}
cache[index].setObjectValue(value);
cache[index].setObjectKey(key);
cache[index].setIndex(index);
table.put(key, cache[index]);
}
public
CacheElement removeLfuElement()
{
CacheElement[] elements = getElementsFromTable();
CacheElement leastElement = leastHit(elements);
return
leastElement;
}
public
static
CacheElement leastHit(CacheElement[] elements)
{
CacheElement lowestElement =
null
;
for
(
int
i =
0
; i < elements.length; i++)
{
CacheElement element = elements[i];
if
(lowestElement ==
null
)
{
lowestElement = element;
}
else
{
if
(element.getHitCount() < lowestElement.getHitCount())
{
lowestElement = element;
}
}
}
return
lowestElement;
}
|
今天的專題很特殊,由於咱們有特殊的客人,事實上他們是咱們想要聽的與會者,可是首先,先介紹一下咱們的客人:Random Cache, FIFO Cache。讓咱們從 Random Cache開始。
最重點的代碼,就應該是 leastHit 這個方法,這段代碼就是把
hitCount 最低的元素找出來,而後刪除,給新進的緩存元素留位置。
看看LRU緩存算法實現
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
|
private
void
moveToFront(
int
index)
{
int
nextIndex, prevIndex;
if
(head != index)
{
nextIndex = next[index];
prevIndex = prev[index];
// Only the head has a prev entry that is an invalid index
// so we don't check.
next[prevIndex] = nextIndex;
// Make sure index is valid. If it isn't, we're at the tail
// and don't set prev[next].
if
(nextIndex >=
0
)
prev[nextIndex] = prevIndex;
else
tail = prevIndex;
prev[index] = -
1
;
next[index] = head;
prev[head] = index;
head = index;
}
}
public
final
synchronized
void
addElement(Object key, Object value)
{
int
index;Object obj;
obj = table.get(key);
if
(obj !=
null
)
{
CacheElement entry;
// Just replace the value, but move it to the front.
entry = (CacheElement)obj;
entry.setObjectValue(value);
entry.setObjectKey(key);
moveToFront(entry.getIndex());
return
;
}
// If we haven't filled the cache yet, place in next available
// spot and move to front.
if
(!isFull())
{
if
(_numEntries >
0
)
{
prev[_numEntries] = tail;
next[_numEntries] = -
1
;
moveToFront(numEntries);
}
++numEntries;
}
else
{
// We replace the tail of the list.
table.remove(cache[tail].getObjectKey());
moveToFront(tail);
}
cache[head].setObjectValue(value);
cache[head].setObjectKey(key);
table.put(key, cache[head]);
}
|
這段代碼的邏輯如 LRU算法 的描述同樣,把再次用到的緩存提取到最前面,而每次刪除的都是最後面的元素。
結論
咱們已經看到 LFU緩存算法 和 LRU緩存算法的實現方式,至於如何實現,採用數組仍是 LinkedHashMap,都由你決定,不夠我通常是小的緩存容量用數組,大的用 LinkedHashMap。