大家都知道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
 

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)数据进行‘与’运算,结果都将小于等于掩码n-1 作为掩码其二进制格式是 1111 1111。
 
0110|0111|1110 任意hash值 
|0000|1111 掩码15的二进制
-------------‘与’运算-----------
|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 <<  ) ^ 0xffffcd7d;
    h ^= (h >>> );
    h += (h <<   );
    h ^= (h >>>  );
    h += (h <<   ) + (h << );
    );
}
    
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 {
        ) { // 先看看数量是否大于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深入分析:

并发读写缓存实现机制(一):为什么ConcurrentHashMap可以这么快?的更多相关文章

  1. 探索 ConcurrentHashMap 高并发性的实现机制--转

    ConcurrentHashMap 是 Java concurrent 包的重要成员.本文将结合 Java 内存模型,来分析 ConcurrentHashMap 的 JDK 源代码.通过本文,读者将了 ...

  2. 【转】探索 ConcurrentHashMap 高并发性的实现机制

    原文链接:https://www.ibm.com/developerworks/cn/java/java-lo-concurrenthashmap/  <探索 ConcurrentHashMap ...

  3. Qunar机票技术部就有一个全年很关键的一个指标:搜索缓存命中率,当时已经做到了>99.7%。再往后,每提高0.1%,优化难度成指数级增长了。哪怕是千分之一,也直接影响用户体验,影响每天上万张机票的销售额。 在高并发场景下,提供了保证线程安全的对象、方法。比如经典的ConcurrentHashMap,它比起HashMap,有更小粒度的锁,并发读写性能更好。线程安全的StringBuilder取代S

    Qunar机票技术部就有一个全年很关键的一个指标:搜索缓存命中率,当时已经做到了>99.7%.再往后,每提高0.1%,优化难度成指数级增长了.哪怕是千分之一,也直接影响用户体验,影响每天上万张机 ...

  4. Redis 的缓存淘汰机制(Eviction)

    本文从源码层面分析了 redis 的缓存淘汰机制,并在文章末尾描述使用 Java 实现的思路,以供参考. 相关配置 为了适配用作缓存的场景,redis 支持缓存淘汰(eviction)并提供相应的了配 ...

  5. php中并发读写文件冲突的解决方案

    在这里提供4种高并发读写文件的方案,各有优点,可以根据自己的情况解决php并发读写文件冲突的问题. 对于日IP不高或者说并发数不是很大的应用,一般不用考虑这些!用一般的文件操作方法完全没有问题.但如果 ...

  6. php中并发读写文件冲突的解决方案(文件锁应用示例)

    PHP(外文名: Hypertext Preprocessor,中文名:“超文本预处理器”)是一种通用开源脚本语言.语法吸收了C语言.Java和Perl的特点,入门门槛较低,易于学习,使用广泛,主要适 ...

  7. 使用Spring提供的缓存抽象机制整合EHCache为项目提供二级缓存

      Spring自身并没有实现缓存解决方案,但是对缓存管理功能提供了声明式的支持,能够与多种流行的缓存实现进行集成. Spring Cache是作用在方法上的(不能理解为只注解在方法上),其核心思想是 ...

  8. 艺多不压身 -- 常用缓存Cache机制的实现

    常用缓存Cache机制的实现 缓存,就是将程序或系统经常要调用的对象存在内存中,以便其使用时可以快速调用,不必再去创建新的重复的实例. 这样做可以减少系统开销,提高系统效率. 缓存主要可分为二大类: ...

  9. 【Java 并发】Executor框架机制与线程池配置使用

    [Java 并发]Executor框架机制与线程池配置使用 一,Executor框架Executor框架便是Java 5中引入的,其内部使用了线程池机制,在java.util.cocurrent 包下 ...

随机推荐

  1. 关于flume配置加载(二)

    为什么翻flume的代码,一方面是确实遇到了问题,另一方面是想翻一下flume的源码,看看有什么收获,现在收获还谈不上,因为要继续总结.不够已经够解决问题了,而且确实有好的代码,后续会继续慢慢分享,这 ...

  2. Zend Studio 中安装emmet插件的方法

    本人的Zend Studio版本是Zend Studio 10.0.0. 1. 打开Zend Studio,点击 Help --> Install New Software,如下图: 2.  在 ...

  3. B 最熟悉的陌生人 (纪念当年就读的梅州市江南高级中学)

    最熟悉的陌生人 作者:张慧桥 枪与玫瑰 我看了一下聊天室的名单,哈哈哈,我不禁喜出望外:蝶恋花那丫头片子挂在线上呢,真是天助我也.初时的担心一扫而光,我精神抖擞地喝下一大口咖啡,猛抽了三口烟,现在的我 ...

  4. ajax-向服务器发送请求

    ajax-向服务器发送请求 1.将请求发送到服务器,使用XMLHttpRequest对象的 open() 和 send() 方法.     xmlhttp. open(method,url,async ...

  5. Adaptive Decontamination of the Training Set: A Unified Formulation for Discriminative Visual Tracking

    Martin Danelljan 判决类追踪模型是由训练样本学习得到,但是为了适应目标和背景的变化sample set在每一帧中都会更新. 令(xjk, yjk)表示第k帧k={1,2,...,t}中 ...

  6. ubuntu 更换系统语言,Change System Language

    1.打开设置,打开“Language Support”. 2.如果列表中没有你的语言,点击“Install/Remove Language”,下拉选择你的语言,点击“Apply Changes”. 3 ...

  7. Shiro标签

    在使用Shiro标签库前,首先需要在JSP引入shiro标签: <%@ taglib prefix="shiro" uri="http://shiro.apache ...

  8. Windows Server 2008配置服务器证书[转载]

    备忘 http://wangchunhai.blog.51cto.com/225186/139451

  9. AVD Manager

    启动AVD Manager会弹出下面的窗口: 然后点击右侧的Create来新建一个模拟器,创建一个模拟器,取名Phone,然后选择设备屏幕尺寸,SDK的大小. 创建完成后,选中刚刚创建的模拟器,然后点 ...

  10. (.text+0x12): undefined reference to `rpl_fprintf'

    问题1:(.text+0x12): undefined reference to `rpl_fprintf'解决办法:在yacc前面添加%{#undef yyerrorvoid yyerror (ch ...