目录
1 前言
本人jdk版本1.8。在并发编程中,有时候需要使用线程安全的队列。如果要实现一个线程安全的队列有两种方式:
- 使用阻塞算法:使用阻塞算法的队列可以用一个锁(入队和出队用同一把锁如ArrayBlockingQueue)或两个锁(入队和出队用不同的锁如LinkedBlockingQueue)等方式来实现。
- 使用非阻塞算法:非阻塞的实现方式则可以使用循环CAS的方式来实现。
ConcurrentLinkedQueue
是一个基于链接节点的无界线程安全队列,它采用FIFO
的规则对节点进行排序,当我们添加一个元素的时候,它会添加到队列的尾部;当我们获取一个元素时,它会返回队列头部的元素。它采用了“wait-free
”算法(即CAS算法)来实现,该算法在Michael&Scott
算法上进行了一些修改。
2 队列结构
ConcurrentLinkedQueue
由head
节点和tail
节点组成,每个节点Node
由节点元素item
和指向下一个节点next
的引用组成,节点与节点之间就是通过这个next
关联起来,从而组成一张链表结构的队列。默认情况下head
节点存储的元素为空,tail
节点等于head
节点。
private static class Node<E> {
volatile E item; // 数据
volatile Node<E> next; // 后继节点
Node(E item){...}
// cas的修改节点item属性,若节点的item为cmp,则设置为val
boolean casItem(E cmp, E val) {...}
// cas的修改节点的next属性
boolean casNext(Node<E> cmp, Node<E> val) {...}
}
private transient volatile Node<E> head; // 队列“头”指针
private transient volatile Node<E> tail; // 队列“尾”指针
3 入队——offer()方法
3.1 入队过程
下图是元素入队的过程,初始时head=tail=null:
添加元素1
:队列更新head
节点的next
节点为元素1
节点。又因为tail
节点默认情况下等于head
节点,所以它们的next
节点都指向元素1
节点。添加元素2
:队列首先设置元素1
节点的next
节点为元素2
节点,然后更新tail
节点指向元素2
节点。添加元素3
:设置tail
节点的next
节点为元素3
节点。添加元素4
:设置元素3
的next
节点为元素4
节点,然后将tail
节点指向元素4
节点。
通过上图我们发现,入队主要做两件事情:
- 将入队节点设置成当前队列尾节点的下一个节点。
- 更新
tail
节点,如果tail
节点的next
节点不为空,则将入队节点设置成tail
节点;如果tail节点的next
节点为空,则将入队节点设置成tail
的next
节点,所以tail
节点不总是尾节点。
3.2 offer()源码
结合上面入队过程,看看jdk源码:
public boolean offer(E e) {
checkNotNull(e); // 若e为空,抛出NullPointerException
final Node<E> newNode = new Node<E>(e);
for (Node<E> t = tail, p = t;;) { // 指针p用来寻找尾节点
Node<E> q = p.next;
if (q == null) {
// p.next == null 说明p是尾结点,使用casNext令p.next = newNode
if (p.casNext(null, newNode)) {
// 若初始时t不指向尾节点,p=t。因为q!=null,经过几次循环,p向后移动指向了尾结点,故p!=t
if (p != t) // 若tail没有指向尾节点
casTail(t, newNode); // 令tail指向尾结点。失败了没事
return true;
}
// 执行到这里说明CAS操作中输给了其它线程,再读p.next
}
else if (p == q)
// 多线程操作时候,由于poll时候会把旧的head变为自引用,然后将head的next设为新的head。
// 所以这里需要重新找新的head,因为新的head后面的节点才是激活的节点
p = (t != (t = tail)) ? t : head;
else
// 一般情况下令p = p.next,以此来寻找尾结点
p = (p != t && t != (t = tail)) ? t : q;
}
}
可以看到castTail方法的调用是有条件的,即p!=t,tail没有指向尾结点。上面源代码主要做了两件事:
- 第一是定位出尾结点。
- 第二十使用CAS算法将入队节点设置成尾结点的next节点,如不成功则重试。
3.3 tail并非一直指向尾结点的意图
让tail节点永远作为队列的尾节点,这样实现代码量非常少,而且逻辑非常清楚和易懂。但是这么做有个缺点就是每次都需要使用循环CAS更新tail节点。如果能减少CAS更新tail节点的次数,就能提高入队的效率。
在JDK 1.7的实现中,doug lea使用hops变量来控制并减少tail节点的更新频率,并不是每次节点入队后都将 tail节点更新成尾节点,而是当tail节点和尾节点的距离大于等于常量HOPS的值(默认等于1)时才更新tail节点,tail和尾节点的距离越长使用CAS更新tail节点的次数就会越少,但是距离越长带来的负面效果就是每次入队时定位尾节点的时间就越长,因为循环体需要多循环一次来定位出尾节点,但是这样仍然能提高入队的效率,因为从本质上来看它通过增加对volatile变量的读操作(循环查找尾结点时需要读取tail)来减少了对volatile变量的写操作(tail一直指向尾结点时每次添加元素都要令tail = 添加元素),而对volatile变量的写操作开销要远远大于读操作,所以入队效率会有所提升。
在JDK 1.8的实现中,tail的更新时机是通过p和t是否相等来判断的,其实现结果和JDK 1.7相同,即当tail节点和尾节点的距离大于等于1时,更新tail。
4 出队——poll()方法
4.1 出队过程
并不是每次出队时都更新head节点,当head节点里有元素时,直接弹出head节点里的元素,而不会更新head节点。只有当head节点里没有元素时,出队操作才会更新head节点。采用这种方式也是为了减少使用CAS更新head节点的消耗,从而提高出队效率。
4.2 poll()源码
public E poll() {
restartFromHead:
for (;;) {
for (Node<E> h = head, p = h, q;;) {
E item = p.item;
if (item != null && p.casItem(item, null)) {
// 只有初始head不指向头结点时才更新head
if (p != h) // hop two nodes at a time
updateHead(h, ((q = p.next) != null) ? q : p);
return item;
} // p.next == null说明队列为空,返回null
else if ((q = p.next) == null) {
updateHead(h, p);
return null;
}
else if (p == q)
continue restartFromHead;
else // p = p.next
p = q;
}
}
}
5 其它方法
5.1 size()
没有加锁的情况下将整个队列遍历一遍来计算队列中元素个数,显然在并发场景下计算结果可能不准确。
public int size() {
int count = 0;
for (Node<E> p = first(); p != null; p = succ(p))
if (p.item != null)
// Collection.size() spec says to max out
if (++count == Integer.MAX_VALUE)
break;
return count;
}