之前谈到了在我的项目里用到了Disruptor,因为对它了解不足的原因,才会引发之前的问题,因此,今天特意来探讨其原理。

为什么采用Disruptor

先介绍一下我的这个服务。这个服务主要是作为游戏服务器的游戏逻辑部分,包括帧同步逻辑及其他在游戏过程中玩家产生的一些业务逻辑。

从用户量来说,现在最高峰大概有300人同时在线,游戏服务器设置1秒有30帧的数据量,因此,1秒内服务器接收到的请求量为30 * 300 = 9000

虽然QPS并不是很高,但对于多人对抗竞技类游戏而言,低延迟十分重要,每一次客户端向服务器端的响应时间需要低于1/30秒(因为1秒需要发送30次)。针对这种情况,我需要的存储消息的容器应该具备快速生产、快速消费的特性。

那为什么当初要选择使用Disruptor作为存储客户端发来消息的容器,为什么不直接使用Java本身自带的那些队列结构呢?

让我们看看Java里常用的线程安全的内置队列:

是否有界 是否加锁 底层数据结构
ArrayBlockingQueue 有界 加锁 数组
LinkedBlockingQueue 有界(2^31-1) 加锁 链表
ConcurrentLinkedQueue 无界 无锁 链表
LinkedTransferQueue 无界 无锁 链表
PriorityBlockingQueue 无界 加锁
DelayQueue 无界 加锁

一般来说我们并不会考虑堆,因为堆在实现带有优先级的队列更好。

从性能上来说,无锁时的QPS一般来说优于加锁,而ConcurrentLinkedQueue的无锁其实是通过原子变量进行compare and swap(以下简称为CAS,由CPU保证原子性)这种不加锁的方式来实现的。

但无锁的结构都是无界的,为了系统的稳定,我们需要防止生产者速度过快导致内存溢出,我们需要使队列有界;同时,为了减少Java的垃圾回收对系统性能的影响,会尽量选择array/heap(因为使用这两种结构,数据在内存中存储的地址连续)。

这样筛选下来,ArrayBlockingQueue可能相对而言更加合适,但它依旧存在性能问题——加锁、伪共享。

加锁上面也提到了,更好的方式是使用CAS,那伪共享又是什么呢?

伪共享

什么是共享

下图是计算的基本结构。L1、L2、L3分别表示一级缓存、二级缓存、三级缓存,越靠近CPU的缓存,速度越快,容量也越小。所以L1缓存很小但很快,并且紧靠着在使用它的CPU内核;L2大一些,也慢一些,并且仍然只能被一个单独的CPU核使用;L3更大、更慢,并且被单个插槽上的所有CPU核共享;最后是主存,由全部插槽上的所有CPU核共享。如图:

当CPU执行运算的时候,它先去L1查找所需的数据、再去L2、然后是L3,如果最后这些缓存中都没有,所需的数据就要去主内存拿。走得越远,运算耗费的时间就越长。所以如果你在做一些很频繁的事,你要尽量确保数据在L1缓存中。

另外,线程之间共享一份数据的时候,需要一个线程把数据写回主存,而另一个线程访问主存中相应的数据。

缓存行

Cache是由很多个cache line组成的。每个cache line通常是64字节,并且它有效地引用主内存中的一块儿地址。一个Java的long类型变量是8字节,因此在一个缓存行中可以存8个long类型的变量。