大家都知道ConcurrentHashMap的並發讀寫速度很快,但為什么它會這么快?這主要歸功於其內部數據結構和獨特的hash運算以及分離鎖的機制。做游戲性能很重要,為了提高數據的讀寫速度,方法之一就是采用緩存機制。因此緩存的性能直接影響游戲的承載量和運行流暢度,作為核心基礎設施,緩存必須具備以下方面的功能:
1.快速定位數據
2.並發變更數據
3.數據的過期控制與異步寫入
4.高並發的情況下緩存數據的一致性
接下來,我就就幾篇文章從上述幾個方面來講述下單服務器的緩存實現原理,本文的緩存是在guava的Cache基礎上進一步擴展,原google緩存文檔可參考:http://code.google.com/p/guava-libraries/wiki/CachesExplained
注意:本文是guava的Cache增強版,因此源碼有稍許改動,詳細源碼請參考:https://github.com/cm4j/cm4j-all。
系列文章目錄:
並發讀寫緩存實現機制(三):
API封裝和簡化
1.ConcurrentHashMap的數據結構
我們知道,一本書有着豐富的內容,那如何從一本書中找到我所需要的主要內容呢?自然而然我們就想到目錄和子目錄,首先,目錄把書的內容分成很多個小塊;其次,目錄也是一個索引,通過目錄我們就知道對應內容位於這本書的第幾頁,然后我們再按順序瀏覽就能找到我們所需要的文章內容。
google的Cache借鑒了JDK的ConcurrentHashMap的設計思路,其本質就是基於上述流程設計的,翻看兩者源碼,有很大一部分是相同的,為了更好的理解緩存的高並發的實現,我們先來探索下ConcurrentHashMap的數據結構圖:

由上圖我們可以看出,首先ConcurrentHashMap先把數據分到0-16個默認創建好的數組中,數組里面的元素就叫segment,相當於書的大目錄;每個segment里面包含一個名叫table的數組,這個數組里面的元素就是HashEntry,相當於書的一個子目錄;HashEntry里面有下一個HashEntry的引用,這樣一個一個迭代就能找到我們所需要的內容。
ConcurrentHashMap 類中包含兩個靜態內部類HashEntry和Segment。HashEntry用來封裝映射表的 鍵/值對;Segment 用來充當數據划分和鎖的角色,每個Segment對象守護整個散列映射表的若干個table。每個table是由若干個 HashEntry對象鏈接起來的鏈表。一個ConcurrentHashMap實例中包含由若干個Segment對象組成的數組。
a.HashEntry
清單1:HashEntry的定義
1
2 3 4 5 6 |
static
final
class HashEntry<K, V> {
final K key; final int hash; volatile AbsReference value; final HashEntry<K, V> next; } |
書本上同一目錄和子目錄下面可能包含許多個章節內容,同樣的,在ConcurrentHashMap中同一個Segment中同一個HashEntry代表的位置上可能也有許多不同的內容,我們稱之為數據碰撞,而ConcurrentHashMap采用“分離鏈接法”來處理“碰撞”,即把“碰撞”的 HashEntry 對象鏈接成一個鏈表,一個接一個的。
HashEntry的一個特點,除了value以外,其他的幾個變量都是final的,這樣做是為了防止鏈表結構被破壞,出現ConcurrentModification的情況,這種不變性來降低讀操作對加鎖的需求,ConcurrentHashMap才能保證數據在高並發的一致性。后面的數據寫入章節我們再進行討論數據是如何插入和移除的。
b.Segment
清單2:Segment的定義
1
2 3 4 5 6 7 |
static
final
class Segment
extends ReentrantLock
implements Serializable {
transient volatile int count; transient int modCount; transient int threshold; transient volatile AtomicReferenceArray<HashEntry> table; final float loadFactor; } |
詳細解釋一下Segment里面的成員變量的意義:
count:Segment中元素的數量
modCount:對table的大小造成影響的操作的數量(比如put或者remove操作)
threshold:閾值,Segment里面元素的數量超過這個值依舊就會對Segment進行擴容
table:鏈表數組,數組中的每一個元素代表了一個鏈表的頭部
loadFactor:負載因子,用於確定threshold
2.Hash運算的妙用
位運算定位數據在某數組中下標
ConcurrentHashMap為什么叫HashMap,這和它的運算的方法有着密切的關聯,ConcurrentHashMap中查找數據對象采用的是對數據鍵的hash值兩次位運算來定位數據,在這里我們先簡單了解下如何通過位運算來定位到數據在某個數組的下標位置。
假設我們有一個長度為 16 的數組,我們如何通過位運算才能快速的放入和讀取數據呢?
本質上就是我們需要把數據的hash值放入到數組的固定位置,那這個位置也就是介於0-15之間的數值,根據位運算法則,任何數與一個指定的掩碼(Mask)數據進行‘與’運算,結果都將小於等於掩碼,則我們可指定掩碼為15=24-1;為什么數組長度必須要2n?因為的 2n-1 作為掩碼其二進制格式是 1111 1111。
0110|0111|1110 任意hash值
0000|0000|1111 掩碼15的二進制
-------------‘與’運算-----------
0000|0000|1110 結果<=掩碼
位運算小口訣:清零取數用與,某位置一用或,取反交換用異或
通過上面的小例子,我們可以了解:hash值與 數組長度-1 的掩碼進行‘與’操作,會得到一個介於0到長度-1的數值,我們就可以設定這個數值就是數據所在的數組下標,即數據所在的數組下標=hash & [數組長度-1],這就是HashMap定位數據的基本位操作。
3.ConcurrentHashMap中數據的定位
a.二次hash
首先緩存先對hash值進行了二次hash,之所以要進行再哈希,其目的是為了減少哈希沖突,使元素能夠均勻的分布在不同的Segment上,從而提高容器的存取效率。
清單3:Wang/Jenkins再hash
1
2 3 4 5 6 7 8 9 10 |
private
static
int hash(
int h) {
// Spread bits to regularize both segment and index locations, // using variant of single-word Wang/Jenkins hash. h += (h << 15) ^ 0xffffcd7d; h ^= (h >>> 10); h += (h << 3); h ^= (h >>> 6); h += (h << 2) + (h << 14); return h ^ (h >>> 16); } |
b.Segment定位
上面的數據結構中我們講到ConcurrentHashMap首先把數據分為2個大塊,segment和table,這2個都是數組,首先我們看下segment的定位,它的代碼也比較簡潔:
清單4:segment的定位
1
2 3 4 |
final Segment<K, V> segmentFor(
int hash) {
// 這里的segmentMask就是數組長度-1 return segments[(hash >>> segmentShift) & segmentMask]; } |
上面的代碼有2個步驟:
1.將hash值右移,目的是讓高位參與hash運算,以避免低位運算hash值一樣的情況。右移的位數如何確定?假設Segment的數量是2的n次方,根據元素的hash值的高n位就可以確定元素到底在哪一個Segment中,因此右移的位數為:n位
2.和segmentMask進行‘與’操作,得到segments的數組下標
如果大家想了解二次hash和右移的原因,請參考:http://blog.csdn.net/guangcigeyun/article/details/8278346
c.Segment中get()方法
在定位到數據所在的segment,接下來我們看下segment中get()方法,這個方法是查找數據的主要方法。
清單5:Segment中get()方法
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 |
AbsReference get(
String key,
int hash, CacheLoader<
String, AbsReference> loader,
boolean isLoad) {
final StopWatch watch = new Slf4JStopWatch(); try { if (count != 0) { // 先看看數量是否大於0 HashEntry e = getEntry(key, hash); if (e != null) { // 這里只是一次無鎖情況的快速嘗試查詢,如果未查詢到,會在有鎖情況下再查一次 AbsReference value = getLiveValue(key, hash, now()); watch.lap( "cache.getLiveValue()"); if (value != null) { recordAccess(e); return value; } } } if (isLoad) { // 對象為null或者對象已過期,則從在鎖的情況下再查一次,還沒有則從DB中加載數據 AbsReference ref = lockedGetOrLoad(key, hash, loader); watch.lap( "cache.lockedGetOrLoad()"); return ref; } } finally { postReadCleanup(); watch.stop( "cache.get()"); } return null; } |
從上面代碼不長,但我們可以看看4-15行,刪除這幾行代碼對運行結果毫無影響,其存在的原因是為了提高數據查詢效率,它的原理是在沒有鎖的情況下做一次數據查詢嘗試,如果查詢到則直接返回,沒查到則繼續下面的流程;而第18行代碼則是在有鎖的情況下再查詢數據,查不到則從DB加載數據返回。在大多數情況下,因為查詢不需要對數據塊加鎖,所以效率有很大提升。
d.HashEntry定位
清單6:根據key和hash定位到具體的HashEntry
1
2 3 4 5 6 7 8 |
HashEntry
getEntry(
String key,
int hash) {
// 首先拿到鏈頭HashEntry,然后依次查找整個entry鏈 for (HashEntry e = getFirst(hash); e != null; e = e.next) { if (e.hash == hash && key.equals(e.key)) { return e; } } return null; } |
清單5:鏈頭HashEntry的定位
1
2 3 4 |
HashEntry<K, V>
getFirst(
int hash) {
AtomicReferenceArray<HashEntry> tab = table; return tab.get(hash & (tab.length() - 1)); } |
相較於Segment的復雜度,HashEntry則是正統的位運算定位方法,標准的 hash & [長度-1]。
總結
至此我們可以了解緩存的整個數據查找的過程:
1.將key的hash進行二次hash
2.根據hash值定位到數據在哪一個segment中:segmentFor()
3.根據hash值定位到數據在table中的第一個HashEntry
4.根據HashEntry中的next屬性,依次比對,直到返回結果
從上述過程中,我們可以理解緩存為什么這么快,因為它在查找過程中僅進行一次hash運算,2次位運算就定位到數據所在的數據塊,而鏈式查找的效率也是比較高的,更關鍵的是絕大多數情況下,如果數據存在,緩存會首先進行查詢嘗試,以避免數據塊加鎖,所以緩存才能快速的查詢到數據。
接下來我們會講講緩存的並發寫入流程,敬請期待。
原創文章,請注明引用來源:CM4J
參考文章:
Java多線程(三)之ConcurrentHashMap深入分析: