大家都知道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深入分析: