大家都知道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. ubuntu下安装rpm 文件

      正想着如何把rpm package 安装到ubuntu上, 发现了这篇文章,转载一下 Ubuntu的软件包格式是deb,如果要安装rpm的包,则要先用alien把rpm转换成deb. sudo a ...

  2. 10天学会phpWeChat——第二天:hello world!我的第一个功能模块

    今天我们开始进入<10天学会phpWeChat>系列教程的第二天:创建我的第一个hello world! 功能模块. 1.登录后台,进入 系统设置--自定义模块,如图: 自定义模块参数说明 ...

  3. Java命名规范基础

    一.java命名规范 1.类和接口:由多个单词组成时,所有单词的首字母大写,如TestJava 2.变量名和方法(函数):由多个单词组成时,所有第一个单词的首字母小写,之后每一个单词的首字母大写,如: ...

  4. Web前端MVC框架

    MVC: 模型层(model).视图层(view).控制层(controller) Model:即数据模型,用来包装和应用程序的业务逻辑相关的数据或者对数据进行处理,模型可以直接访问数据. View: ...

  5. sokite

    <?php interface Proto { //连接 function conn($url); //发送get请求 function get(); //发送post请求 function p ...

  6. how to use ldid

    1.进入管理员权限 sudo -s 2.赋予app运行权限 chmod -R 777 cellmap.app 3.查看app权限 ldid -e cellmap.app/cellmap 4.打开窗口 ...

  7. 首页使用page类完成生成页面内容的大部分工作

    fs2在处理异常及资源使用安全方面也有比较大的改善.fs2 Stream可以有几种方式自行引发异常:直接以函数式方式用fail来引发异常.在纯代码里隐式引发异常或者在运算中引发异常,最开始只是我自己浏 ...

  8. Three.js开发指南---使用构建three.js的基本组件(第二章)

    .gui本章的主要内容 1 场景中使用哪些组件 2 几何图形和材质如何关联 3 正投影相机和透视相机的区别 一,Three所需要的基本元素 场景scene:一个容器,用来保存并跟踪所有我们想渲染的物体 ...

  9. Web 前端开发学习之路(入门篇)

    字数1374 阅读4622 评论0 喜欢49 以前学习过一段时间的web前端开发,整理了一些我看过的/我认为比较好的学习资料(网站.书籍).不要问我为啥没有进阶版,我只是一条产品汪而已,求轻喷.== ...

  10. asp.net解决高并发的方案.[转]

    最近几天一直在读代震军的博客,他是Discuz!NT的设计者,读了他的一系列关于Discuz!NT的架构设计文章,大呼过瘾,特别是Discuz!NT在解决高访问高并发时所设计的一系列方案,本人尤其感兴 ...