在netty的NioEventLoop中用到的jcTools下的MPSC队列很有必要学习下。由于MPSC队列是多生产单消费者的,MPMC是多生产者多消费者的,更有利于学习,故此篇以MpmcArrayQueue为基础。在学习之前,先简单回顾一遍队列及相关知识。
队列是一种FIFO的数据结构,使用队列存取数据元素时,数据元素只能从表的一端进入队列,另一端出队列。常见于线程池的等待排队,锁的等待排队。
队列有两种实现方式(堆栈实现不讨论)
Queue定义如下:
public interface Queue<E> {
void enqueue(E e);
E dequeue();
int size();
}
定义了三个方法enqueue入队,dequeue出队,size队列大小。
基于数组实现的queue:
public class ArrayQueue1<E> implements Queue<E> {
private Object[] elements;
private int size;
private int index;
private int capacity;
public ArrayQueue1(int capacity) {
this.capacity = capacity;
elements = new Object[capacity];
}
@Override
public void enqueue(E e) {
elements[index++] = e;
size++;
}
@Override
public E dequeue() {
E e = (E) elements[0];
moveLeftOneStep();
index--;
size--;
return e;
}
//左移一步
private void moveLeftOneStep() {
for (int i = 1; i < elements.length - 1; i++) {
elements[i] = elements[i + 1];
}
}
@Override
public int size() {
return this.size;
}
}
ArrayQueue1中队列通过给定一个容量进行初始化,Object数组用于存元素,index表示元素放到何处了,size表示元素的个数。入队的时候index处放元素,然后index自增1,size加1。出队的时候从头(0号位置)取,取完之后剩余的元素集体左移一步。
注:上述代码省略了边界条件校验等其它必要的校验。
ArrayQueue1的代码存在一个不好的地方:每取一次元素都要移动一次,有N个元素就要移动N-1次。如果用时间复杂度表示就是O(n),这显然是不可接受的。于是,有了第二种的数组实现队列:循环数组队列。
循环数组是一个如同环的数组,它用两个指针putIndex和takeIndex,移动takeIndex就可以指示出可以取的元素在哪儿,就可以不用再移动元素,如下图。
这样做有两个问题需要考虑:
Q1的答案有3种。
Q2的答案有2种。
Q1的解决方法采用A1-预留长度法,入队+1,出队-1,size == capacity为满,size==0为空。Q2的解决方法采用A2来处理。
代码如下:
public class ArrayQueue2<E> implements Queue<E> {
private Object[] elements;
private int size;
private int putIndex;
private int takeIndex;
public ArrayQueue2(int capacity) {
this.elements = new Object[capacity];
}
@Override
public void enqueue(E e) {
Object[] elements = this.elements;
if (size == elements.length)
return;
elements[putIndex++] = e;
if (putIndex == elements.length)
putIndex = 0;
size++;
}
@Override
public E dequeue() {
Object[] elements = this.elements;
if (size == 0) return null;
E e = (E) elements[takeIndex];
elements[takeIndex--] = null;
if (takeIndex == elements.length)
takeIndex = 0;
size--;
return e;
}
@Override
public int size() {
return this.size;
}
}
通过这样的结构和方法实现了一个队列,但是它有问题,它是线程不安全的,也就是存在并发问题。
针对并发问题最简单的方法就是加锁,只需要在每个方法上加上synchronized关键字就可以了。
这里选择了lock接口来进行处理,synchronized关键字也是可以的看个人喜好。
public class ArrayQueue4<E> implements Queue<E> {
Lock lock = new ReentrantLock();
Condition notFull = lock.newCondition();
Condition notEmpty = lock.newCondition();
private Object[] elements;
private int size;
private int putIndex;
private int takeIndex;
public ArrayQueue4(int capacity) {
this.elements = new Object[capacity];
}
@Override
public void enqueue(E e) {
try {
lock.lock();
Object[] elements = this.elements;
while (size == elements.length) {
//队列满了,生产者等待
try {
notFull.await();
} catch (InterruptedException ex) {
ex.printStackTrace();
}
}
elements[putIndex] = e;
if (++putIndex == elements.length)
putIndex = 0;
size++;
notEmpty.signal();
} finally {
lock.unlock();
}
}
@Override
public E dequeue() {
try {
lock.lock();
Object[] elements = this.elements;
while (size == 0) {
//空了,消费者等待
try {
notEmpty.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
E e = (E) elements[takeIndex];
elements[takeIndex] = null;
if (++takeIndex == elements.length)
takeIndex = 0;
size--;
notFull.signal();
return e;
} finally {
lock.unlock();
}
}
@Override
public int size() {
return this.size;
}
每次操作的时候进行加锁,入队一个元素后进行一次消费者唤醒,出队一个元素后进行一次生产者唤醒,满了生产者等待,空了消费者等待。
到这里就解决了并发的问题,但是带来了另一个问题:性能问题。众所周知,无锁>CAS(CPU级锁)>有锁。无锁是最快的,但它不安全;有锁安全但不快。
前置知识
插一句,基于循环数组的无锁队列在Java中是不提供的,比较有名的是Disruptor,而MpmcArrayQueue和Disruptor不一样。在MpmcArrayQueue中用了双指针和双数组。
回到正文要去除锁就面临几个问题:
解决办法很简单,进行3个调整:
如下图
具体的解决方案:
Q1的问题主要在于后面的指针放元素时可能覆盖前面指针的元素,其实就是两个线程拿到的putIndex同时是0,现在改为putIndex原子递增,且putIndex一直增长,也就是每个线程拿到的putIndex必然不一样。 但有另外一个问题hash后映射的下标会一样, 比如:假设在容量为16的数组中,线程A拿到putIndex=0,线程X拿到putIndex=16,线程A放入元素后,线程X放入元素就会覆盖线程A的。消费者同理。这个问题很好解决,只要线程X放入元素时校验队列是不是满了,满就等待或放入失败就可以了。这样问题Q1转化为Q3。
Q2的问题用volatile+cas解决。CAS附带的ABA问题,这里不存在,指针不会变回A。
Q3容量的判断,putIndex==takeIndex为空,putIndex-takeIndex=capacity为满。
Q4的问题用putIndex-takeIndex,putIndex和takeIndex是自增的,且takeIndex一定小于等于putIndex。
这里还存在一个重要的问题:生产者和消费者如何协同工作?
比如:同一下标,线程A放入元素,线程X放入元素,线程B消费,线程C消费。他们之间的执行时间是不确定的。如果A放了,之后B消费了,而此时X拿到的是数组中的元素,不是null,C拿到的是什么?可能是null,可能是元素。总之,这个线程之间的顺序还是混乱的。怎么解决?
有人说是数组加volatile。这个方法实际不可行,比如说有内存浪费,其实是变相的提高了程序的耗时,拉低了性能。
答案是引入一个long数组,sequenceBuffer。
sequenceBuffer数组大小同元素数组,它在putIndex存元素时放putIndex+1,takeIndex消费时放takeIndex+capacity。
举个例子:
假设capacity=16,线程A的putIndex=0,线程X的putIndex=16。
A放入buffer的是1,若是有消费,buffer中是16,若A还没放则是0。
X就好判断了,X拿到的sequence比期待的sequence小,X就自旋等待即可。
public class ArrayQueue<E> implements Queue<E> {
private Object[] buffer;
private static final Unsafe UNSAFE = UnsafeAccess.UNSAFE;
private final long mask;
protected volatile long putIndex;
protected volatile long takeIndex;
protected final static long P_INDEX_OFFSET = UnsafeAccess.fieldOffset(ArrayQueue.class, "putIndex");
protected final static long C_INDEX_OFFSET = UnsafeAccess.fieldOffset(ArrayQueue.class, "takeIndex");
protected final long[] sequenceBuffer;
public ArrayQueue(int capacity) {
if(capacity<2)
throw new IllegalArgumentException("容量太小!");
//把容量变成2的幂次
int actualCapacity = Pow2.roundToPowerOfTwo(capacity);
mask = actualCapacity - 1;
buffer = new Object[actualCapacity];
sequenceBuffer = new long[actualCapacity];
for (int i = 0; i < actualCapacity; i++) {
long offset = UnsafeLongArrayAccess.calcCircularLongElementOffset(i, mask);
UNSAFE.putOrderedLong(sequenceBuffer, offset, i);
}
}
@Override
public void enqueue(E e) {
if (e == null) return ;
final long mask = this.mask;
final long capacity = mask + 1;
final long[] sBuffer = sequenceBuffer;
long pIndex;
long seqOffset;
long seq;//sequence下标
long cIndex = Long.MIN_VALUE;
do{
pIndex=putIndex;
seqOffset = UnsafeLongArrayAccess.calcCircularLongElementOffset(pIndex, mask);
seq = UNSAFE.getLongVolatile(sequenceBuffer, seqOffset);
if (seq < pIndex) {
if (pIndex - capacity >= cIndex &&
pIndex - capacity >= (cIndex = this.takeIndex)) {
return ;//可抛异常可自旋
} else {
seq = pIndex + 1;
}
}
}while (seq > pIndex ||
!UNSAFE.compareAndSwapLong(this, P_INDEX_OFFSET, pIndex, pIndex + 1));
UNSAFE.putObject(buffer, UnsafeRefArrayAccess.calcCircularRefElementOffset(pIndex, mask), e);
//seq++
UNSAFE.putOrderedLong(sBuffer, seqOffset, pIndex + 1);
return ;
}
@Override
public E dequeue() {
final long[] sBuffer = sequenceBuffer;
final long mask = this.mask;
long cIndex;
long seq;
long seqOffset;
long expectedSeq;
long pIndex = -1;
do {
cIndex = this.takeIndex;
seqOffset = UnsafeLongArrayAccess.calcCircularLongElementOffset(cIndex, mask);
seq = UNSAFE.getLongVolatile(sBuffer, seqOffset);
expectedSeq = cIndex + 1;
if (seq < expectedSeq) {
//槽位没有被生产者移动
if (cIndex >= pIndex &&
cIndex == (pIndex = this.takeIndex))//说明队列空了,该生产了。
return null;
else
seq = expectedSeq + 1;
}
} while (seq > expectedSeq ||
!UNSAFE.compareAndSwapLong(this, C_INDEX_OFFSET, cIndex, cIndex + 1));
final long offset = UnsafeRefArrayAccess.calcCircularRefElementOffset(cIndex, mask);
final E e = (E) UNSAFE.getObject(buffer, cIndex);
UNSAFE.putObject(buffer, offset, null);
//i.e. seq+=capacity
UNSAFE.putOrderedLong(sBuffer, seqOffset, cIndex + mask + 1);
return e;
}
@Override
public int size() {
long after = this.takeIndex;
long size;
while (true) {
final long before = after;
final long currentProducerIndex = this.putIndex;
after = this.takeIndex;
if (before == after) {
size = currentProducerIndex - after;
break;
}
}
if (size > Integer.MAX_VALUE)
return Integer.MAX_VALUE;
else if (size < 0)
return 0;
else if (capacity() != -1 && size > capacity())//-1表示无界容量
return capacity();
else
return (int) size;
}
/**
* 获取容量
* @return
*/
public int capacity(){
return (int) (this.mask+1);
}
原理是利用自旋不断的去取sequence,只要sequence不等于期待的sequence就去自旋,因为不等的情况就是其它线程或put或take,等着就好了。相等自增指针,指针正确,cas。
附上所有的代码:完整代码看release包
有些小问题主要注意:
整个无锁队列比较难的是这个long数组,其它的都比较简单。
最后
如有疑问和建议,请在评论区留言。