前言
Reactor3在BUFFER背压策略下,当Publisher发布元素后会先到一个缓存队列中,然后由生产者推送或者消费者拉取。SpscLinkedArrayQueue就是这其中的队列。在JDK9中引入了响应式的接口Flow,在JDK中有一个Flow.Publisher的实现SubmissionPublisher。SubmissionPublisher的背压就很简单,使用的队列是一个Object数组。
那为什么Reactor3不使用简单的Object数组呢,要去另辟蹊径实现另外的一个队列?
数组在数据到达一定规模后需要扩容和缩容,就这一个问题就已经很致命了。在高并发情况下消息很容易堆积,数组就不得不频繁的进行扩容,数组扩容也就意味着会有元素进行复制,如果元素较多很容易成为性能瓶颈。
既然受到扩容和缩容的困扰为何不使用链表,这样不就是无限大吗?并且不用处理扩容和缩容的问题,链表的头尾插入和删除的时间复杂度都是O(1)性能也不拉胯。
确实可以,但是链表的性能在实际机器上跑起来确实不太行,最近某些公众号还有推LinkedList的作者都不使用它😂。问题出在哪?数组的数据是连续的地址空间存放很容易就会被存储在高速缓存中,但是链表是不连续存储。所以就不能连续的命中Cache Line,会频繁的使高速缓冲区的数据换入换出,所以实际跑起来的性能和纸面上性能的会差很多。
那Reactor3的SpscLinkedArrayQueue是怎么处理的呢😋?
提前剧透,SpscLinkedArrayQueue既使用了数组也使用了链表😈。
SpscLinkedArrayQueue简介
SpscLinkedArrayQueue是一个无界队列,它和jdk中的队列不太一样,它是一个单生产者单消费者的队列。不能同时有两个及以上的线程对它同时进行生产或者消费。SpscLinkedArrayQueue借鉴了JCTools的SpscUnboundedArrayQueue启发。
SpscLinkedArrayQueue形态
SpscLinkedArrayQueue是由数组和链表共同构成,拥有了两者共同的特点。链表的无限大小与无须动态扩容,数组的连续地址空间便于连续命中Cache Line。它是一个链表,每个链表的节点是一个数组,数组中会有一个下边表示该使用下一个数组了,最后一个下标存放下个数组的地址,如下图所示。
成员变量介绍
//掩码,用来计算槽位
final int mask;
//生产者下标
volatile long producerIndex;
//用于控制并发的producerIndex伴生成员变量
@SuppressWarnings("rawtypes")
static final AtomicLongFieldUpdater<SpscLinkedArrayQueue> PRODUCER_INDEX =
AtomicLongFieldUpdater.newUpdater(SpscLinkedArrayQueue.class,
"producerIndex");
//当前生产数组
AtomicReferenceArray<Object> producerArray;
//消费者下标
volatile long consumerIndex;
//用于控制并发的consumerIndex伴生成员变量
static final AtomicLongFieldUpdater<SpscLinkedArrayQueue> CONSUMER_INDEX =
AtomicLongFieldUpdater.newUpdater(SpscLinkedArrayQueue.class,
"consumerIndex");
//当前消费数组
AtomicReferenceArray<Object> consumerArray;
//标识需要进入下一个数组
static final Object NEXT = new Object();
构造方法
SpscLinkedArrayQueue(int linkSize) {
int c = Queues.ceilingNextPowerOfTwo(Math.max(8, linkSize));
this.producerArray = this.consumerArray = new AtomicReferenceArray<>(c + 1);
this.mask = c - 1;
}
首先将设置的容量转换为最接近的一个满足2的n次方的数,最小为8,这一步很重要,和HashMap的下标计算一样的思路。如果没有了解过的可以看看这一篇。
这里linkSize不是整个队列的长度,SpscLinkedArrayQueue是一个无界队列,linkSize是每个链表节点数组的大小的一个决定因素,数组的大小会比它大一。
生产
@Override
public boolean offer(T e) {
Objects.requireNonNull(e);
//将生产者下标存放到本地变量,避免在执行过程中生产下标发生变化
long pi = producerIndex;
//将生产者数组存放到本地变量,避免在执行过程中发生变化
AtomicReferenceArray<Object> a = producerArray;
int m = mask;
//计算出要存放下标的下一个节点
int offset = (int) (pi + 1) & m;
//当发现要存放的下标的下一个节点已经存放了数据则表示需要新产生一个数组了
if (a.get(offset) != null) {
//计算真实的下标
offset = (int) pi & m;
//创建新的数组
AtomicReferenceArray<Object> b = new AtomicReferenceArray<>(m + 2);
//将数组赋值给生产数组
producerArray = b;
//将元素存放到新产生的数组
b.lazySet(offset, e);
//将原生产数组的最后的下标存放新的生产数组地址
a.lazySet(m + 1, b);
//将原生产数组的当前下标存放NEXT,表示当前数组元素已经访问完毕,需要访问下一个数组了。
a.lazySet(offset, NEXT);
//更新生产下标
PRODUCER_INDEX.lazySet(this, pi + 1);
}
else {
//计算真实的下标
offset = (int) pi & m;
//将元素存放到生产数组
a.lazySet(offset, e);
//更新生产下标
PRODUCER_INDEX.lazySet(this, pi + 1);
}
return true;
}
我们可以看到在插入新的数据时,先判断的不是当前数据存放的下标是否有值,而是判断的当前下标的下一个下标。也就是需要至少保证当前生产数组存放这个元素后还有一个空闲的下标。如果没有则表示需要产生新的数组了。
消费
public T poll() {
long ci = consumerIndex;
//将当前的消费数组赋值给a
AtomicReferenceArray<Object> a = consumerArray;
int m = mask;
//计算当前消费的下标
int offset = (int) ci & m;
//得到当前的下标
Object o = a.get(offset);
//如果元素为null,则表示没有元素可消费了,直接返回
if (o == null) {
return null;
}
//如果为NEXT则表示需要访问下一个数组了,更新a的值
if (o == NEXT) {
AtomicReferenceArray<Object> b = (AtomicReferenceArray<Object>) a.get(m + 1);
//将即将要废弃的消费数组的最后一个下标的设置为null,用于gc,避免下一个数组消费完后还有指针指向它,而无法正常被回收。
a.lazySet(m + 1, null);
//从新的消费数组获取元素
o = b.get(offset);
a = b;
consumerArray = b;
}
//将当前的消费下标设置为null
a.lazySet(offset, null);
CONSUMER_INDEX.lazySet(this, ci + 1);
return (T) o;
}
如果生产的方法熟悉了,消费的就很好理解了。这里需要注意的就是需要及时的将无用的数据设置为null,避免无法回收。
总结
SpscLinkedArrayQueue它兼具数组与链表的优点。但是它并不能单独的拿出来直接使用,前面就有提到它是一个单生产者单消费者的队列,但是在SpscLinkedArrayQueue并没有控制只能有单个线程来生产或者消费。下一篇会给介绍Flux的create与push是如何使用SpscLinkedArrayQueue的,以及为什么会使用lazySet来设置下标与填充元素。
感谢阅读,希望对你有帮助。