这是我参与11月更文挑战的第12天,活动详情查看:2021最后一次更文挑战
基本队列
队列是一种先进先出的数据结构,其基本操作是:
- 出队(add)
- 入队(poll)
数组实现
数组实现列表大概思路是维护头指针下标、尾指针下标,add操作尾指针下标后移,poll操作头指针后移。细节是魔鬼,难点在于如何处理边界情况:
-
初始时头尾指针的位置放在哪里合适?我们设计的两个操作都是下标后移,所以放在length-1可以用的空间最大。
-
当头尾指针一直后移时,到达0时没有空间在add如何处理?很容易想到的方法就是扩容,新建更大容量的数组,复制数据,切换数组。
以上实现方式的一个缺点是,add、poll操作会使两个指针均往左移,没有使用到数组的所有容量便会进行扩容,在不看源码的情况下,我能想到的一种方式是将数组的首尾相接,如此依赖便可以无限左移,知道数组满了才会进行扩容。循环数组的关键点也是边界,对于左移,可以使用下标-1,可以减到负数,获取下标元素可以通过取模size,因为其环形的特点。判断是否达到数组容量可以通过判断头尾指针距离是否大于容量。扩容处理步骤也是相同。
链表实现
链表设计大概同数组,但是链表不会存在数组的问题,因为链表不需要扩容,只需要移动投喂指针即可。 ,,
jdk队列实现
ArrayDeque数组实现
先看了数组实现java.util.ArrayDeque。只需看他的addLast(),pollFirst()即可。
很明显,属于前面提到的正常思路,收尾指针下标向右侧移动。
这里列举一下ArrayDeque入队出队操作步骤,方便后面分析并发队列为何非线程安全。
入队:
- 尾节点设置添加元素
- 尾节点前移
- 判断是否容量不够
- 扩容
出队:
- 头节点元素赋值给局部变量
- 头节点元素=null
- 头节点前移
- 返回局部变量
从出队入队步骤中,我们可以看出入队时,步骤1、2非并发安全,,扩容更不用说了,必须卡在这里,数组的所有操作基本需要做同步操作。对于出队,步骤1、3也是非线程安全。
LinkedList列表实现
入队
public void addLast(E e) {
linkLast(e);
}
void linkLast(E e) {
final Node<E> l = last;// 1
final Node<E> newNode = new Node<>(l, e, null);// 2 设置新节点前驱节点
last = newNode; // 3 设置尾节点=新节点
if (l == null)
first = newNode;
else
l.next = newNode;// 4 设置前-》后的指向关系
size++;// 5 ++ 操作
modCount++;
}
- 全局变量尾节点给到局部变量尾节点
- 新节点设置前驱节点(双向队列做,单队列次步骤不需要考虑)
- 局部尾节点
出队:
private E unlinkFirst(Node<E> f) {
// assert f == first && f != null;
final E element = f.item;// 1 获取当前首节点元素赋值给局部变量
final Node<E> next = f.next;
f.item = null;
f.next = null; // help GC
first = next;// 2 设置首节点=next
if (next == null)
last = null;
else
next.prev = null;// 3 解除后-》前的指向关系
size--;// 4 ++操作
modCount++;
return element;
}
对于链表而言非并发安全的核心在于链表前后单向指向关系的替换。与数组相比而言,链表少了扩容的开销,所以队列通常用链表进行实现。
并发安全队列
从上面对比数组和链表实现队列对比,并发安全队列都是基于链表进行实现。如何实现并发安全队列呢?一个方法是直接加synchorized。为了更高性能我们来进行下锁优化,优化方向就两个点,一个是能否使用CAS方式,第二个是能否减小锁粒度,只要是在Java编程中,并发优化就这个套路。
先来看看链表队列步骤:
入队:
- 创建新节点对象
- 获取尾节点
- 尾节点.next=新节点
- 尾节点=新节点
上面步骤0可以不需要锁同步,按照CAS的套路,步骤1可以不进行CAS,但是步骤2、3需要一起做CAS才行,这个如何实现呢?脑子不够用了,看了下ConcurrentLinkedQueue实现:
从代码可以看到步骤2、3分别是CAS原子操作,但两个CAS合起来千万不要认为也是原子操作,比如线程1、2同时进入到达步骤2,线程1CAS成功,线程2失败,线程1CAS成功后上下文切换挂起,线程2重新经过循环CAS步骤2成功。然后线程2执行CAS步骤3,此时线程1恢复,在执行CAS步骤3。这个执行结果会导致线程2进行的第二个CAS操作对线程1不可见。对于此处的入队操作,对应的表现为tail节点设置到线程1的新节点,而不是线程2的新节点,显然这个效果并不会对入队功能产生线程安全问题。能想到这个技巧的人,称之为神也不为过。
出队: 0. 获取头节点元素
- 头节点指针=步骤0获取的节点
经过入队的操作,我尝试了下出对操作:
void poll() }
Node h = head;
while(!cas_head(h,h.next)) {
h = head;
}
return h;
}
写完后在看看ConcurrentLinkedQueue源码:
看了源码发现自己少了一个头节点.item=null的步骤,如果不去掉,元素不会被垃圾回收掉。后面的CAS操作是设置头节点指向。出对相对入队来说比较简单。
遇到的问题
debug 入队方法时候发现一个神奇的问题,第一次执行入队方法的p.casNext(null,newNode)会使尾节点的next指向它本身,并且head节点会指向该入队的新节点,但是代码中没有这个逻辑,不太清楚其原因是什么,之后的入队操作是正常的。
可以见下面的debug图。