在并发编程中,有时候需要使用线程安全的队列。如果要实现一个线程安全的队列有两种方式:一种是使用阻塞算法,另一种是使用非阻塞算法。使用阻塞算法的队列可以用一个锁(入队和出队用同一把锁)或两个锁(入队和出队用不同的锁)等方式来实现。非阻塞的实现方式则可以使用循环CAS的方式来实现。而今天要分享的是使用非阻塞的方式实现的线程安全队列ConcurrentLinkedQueue,类名就可以看的出来实现队列的数据结构是链式。
(注:自己阅读源码过程中,涉及到unsafe的方法不懂,想要理解的,可以去看文章,很详细,直接Ctrl+f 去查找):
https://www.jianshu.com/p/1cc04a31f48e
ConcurrentLinkedQueue是继承了AbstartQueue,实现了Queue接口的。最上层是实现了Iterable,用于迭代元素,属于集合。要想学习该类是源码,需要知道ConcurrentLinkedQueue由head节点和tail节点组成,每个节点(Node)由节点元素(item)和指向下一个节点(next)的引用组成,节点与节点之间就是通过这个next关联起来,从而组成一张链表结构的队列。默认情况下head节点存储的元素为空,tail节点等于head节点。
可以先看用于存放数据的与Node节点元素···
private static class Node {
volatile E item; //用于存放node元素
volatile Node next; //用于存放下一个节点的node
Node(E item) {
//使用Unsafe的putObject方法直接操作内存偏移量,存放数据,效率高。
UNSAFE.putObject(this, itemOffset, item);
}
boolean casItem(E cmp, E val) {
//通过内存偏移地址修改变量值
/**
* compareAndSwapObject(Object o, long offset,Object expected,Object x);
* 更新变量值为x,如果当前值为expected
* o:对象 offset:偏移量 expected:期望值 x:新值
*/
return UNSAFE.compareAndSwapObject(this, itemOffset, cmp, val);
}
//cas操作修改变量值。
boolean casNext(Node cmp, Node val) {
return UNSAFE.compareAndSwapObject(this, nextOffset, cmp, val);
}
private static final sun.misc.Unsafe UNSAFE;
private static final long itemOffset;
private static final long nextOffset;
static {
try {
UNSAFE = sun.misc.Unsafe.getUnsafe();
Class> k = Node.class;
itemOffset = UNSAFE.objectFieldOffset
(k.getDeclaredField("item"));
nextOffset = UNSAFE.objectFieldOffset
(k.getDeclaredField("next"));
} catch (Exception e) {
throw new Error(e);
}
}
}
然后再来看两个全局变量:
private transient volatile Node head; //头节点
private transient volatile Node tail; //尾节点
在初始化构造函数时,默认构造函数如下:
public ConcurrentLinkedQueue() {
//初始化时首结点与尾节点是一致的,因为没有值。
head = tail = new Node(null);
}
//同时,静态化去初始化跟Node节点一样的一些参数,
另外需要知道设计的几个CAS操作
//更改Node中的数据域item
boolean casItem(E cmp, E val) {
return UNSAFE.compareAndSwapObject(this, itemOffset, cmp, val);
}
//更改Node中的指针域next
void lazySetNext(Node val) {
UNSAFE.putOrderedObject(this, nextOffset, val);
}
//更改Node中的指针域next
boolean casNext(Node cmp, Node val) {
return UNSAFE.compareAndSwapObject(this, nextOffset, cmp, val);
}
可以看出这些方法实际上是通过调用UNSAFE实例的方法,UNSAFE为sun.misc.Unsafe类.想要了解更多关于Unsafe的可以看我下面分享的这篇文章:
https://www.jianshu.com/p/1cc04a31f48e
可以看到ConcurrentLinkedQueue是带Concurrent,所以看源代码,一定不单单考虑单线程的情况,还需要考虑多线程情况下,如何运行,怎么下的。同时,有进队列,也会有出队列,还需要考虑同时进队列和出队列的情况下是如何运行的。
分如下情况: offer为入队列,poll为出队列
1.单线程
入队列
入队列的主要操作在于操作Tail尾节点的下一个节点,单线程情况下不会与出队列有影响。出队列
入队列的主要操作在于操作头节点,单线程情况下不会与入队列有影响。
2.多线程多线程入队列
因为入队列,首先需要获取tail节点,然后跟判断tail节点的下一个节点是否为null去做处理,如果下一个节点在还没入队列时,有新的节点插入了,这时候获取到的尾节点为脏数据,需要重新获取尾节点。多线程出队列
3.同时入队列 、出队列
入队列远大于出队列
入队列大于出队列的话,一般两者之间也不会有交集。这个可以按多线程区别讨论。-
出队列大于入队列
出队列大于入队列的话,也就是队头删的速度要快于队尾添加节点的速度,导致的结果就是队列长度会越来越短,而offer线程和poll线程就会出现“交集”,即那一时刻就可以称之为offer线程和poll线程同时操作的节点为 临界点 ,且在该节点offer线程和poll线程必定相互影响。那么可以根据临界点执行两个操作的发生顺寻分为以下两种:
执行顺序为offer-->poll-->offer,即表现为当offer线程在Node1后插入Node2时,此时poll线程已经将Node1删除,这种情况很显然需要在offer方法中考虑;
2.执行顺序可能为:poll-->offer-->poll,即表现为当poll线程准备删除的节点为null时(队列为空队列),此时offer线程插入一个节点使得队列变为非空队列.
入队列的源码(offer)
public boolean add(E e) {
return offer(e);
}
public boolean offer(E e) {
//判空 #单1
checkNotNull(e);
//包装成node类,更好的管理和操作队列里面的节点。
final Node newNode = new Node(e); #单2
for (Node t = tail, p = t;;) { #单3 //这里 p = t = tail
//p被认为队列真正的尾节点,tail不一定指向对象真正的尾节点,因为在
//ConcurrentLinkedQueue中tail是被延迟更新的
Node q = p.next; #单4
if (q == null) { #单5
if (p.casNext(null, newNode)) { #单6
if (p != t) // hop two nodes at a time
casTail(t, newNode); // Failure is OK. #单9
return true; #单7
}
// Lost CAS race to another thread; re-read next
}
else if (p == q) #多1
p = (t != (t = tail)) ? t : head;
else #单8
// Check for tail updates after two hops.
p = (p != t && t != (t = tail)) ? t : q;
}
}
单线程情况下(可以看我备注的 #单 的代码顺序)第一次入队列
1.先判断入队列数据是否为null
2.包装成node
3.自旋,先获取tail节点
4.这里要注意,初始化时head和tail存入node的值都为null并且相等,如果tail节点的下一个节点为null,则直接用cas操作加入队列,如果成功CAS操作成功。
如图,此时队列的尾节点应该为Node1,而tail指向的节点依然还是Node0,因此可以说明tail是延迟更新的。然后我们接着看,要入队列第二个节点的时候怎么运行,此时#单5 执行tail节点的下一个next节点不会为null,此时会执行#单8 这一条件下的代码:
p = (p != t && t != (t = tail)) ? t : q;
如果这段代码在单线程环境执行时,很显然由于p==t,此时p会被赋值为q,而q等于Node
这里的casTail失败不需要重试的原因是,offer代码中主要是通过p的next节点q(Node
多线程执行角度分析
1.多个线程offer
看p = (p != t && t != (t = tail)) ? t : q;这行代码在单线程中,这段代码永远不会将p赋值为t,那么这么写就不会有任何作用。
如图,假设线程A此时读取了变量t,线程B刚好在这个时候offer一个Node后,此时会修改tail指针,那么这个时候线程A再次执行t=tail时t会指向另外一个节点,很显然线程A前后两次读取的变量t指向的节点不相同,即t != (t = tail)为true,并且由于t指向节点的变化p != t也为true,此时该行代码的执行结果为p和t最新的t指针指向了同一个节点,并且此时t也是队列真正的对尾节点。那么,现在已经定位到队列真正的队尾节点,就可以执行offer操作了。
2.offer->poll->offer 先offer的过程中被poll了。
然后来分析下 #多1 这行代码。 p = (t != (t = tail)) ? t : head;
大致可以猜想到应该就是回答一部分线程offer,一部分poll的这种情况。当if (p == q)为true时,说明p指向的节点的next也指向它自己,这种节点称之为哨兵节点,这种节点在队列中存在的价值不大,一般表示为要删除的节点或者是空节点。
入队列的源码poll方法
public E poll() {
restartFromHead:
1. for (;;) {
2. for (Node h = head, p = h, q;;) {
3. E item = p.item;
4. if (item != null && p.casItem(item, null)) {
// Successful CAS is the linearization point
// for item to be removed from this queue.
5. if (p != h) // hop two nodes at a time
6. updateHead(h, ((q = p.next) != null) ? q : p);
7. return item;
}
8. else if ((q = p.next) == null) {
9. updateHead(h, p);
10. return null;
}
11. else if (p == q)
12. continue restartFromHead;
else
13. p = q;
}
}
}
final void updateHead(Node h, Node p) {
if (h != p && casHead(h, p))
h.lazySetNext(h);
}
void lazySetNext(Node val) {
UNSAFE.putOrderedObject(this, nextOffset, val);
}
单线程的角度
先将变量p作为队列要删除真正的队头节点,h(head)指向的节点并不一定是队列的队头节点。看poll出Node1时的情况,由于p=h=head,参照上图,很显然此时p指向的Node1的数据域不为null,在第4行代码中item!=null判断为true后接下来通过casItem将Node1的数据域设置为null。如果CAS设置失败则此次循环结束等待下一次循环进行重试。若第4行执行成功进入到第5行代码,此时p和h都指向Node1,第5行if判断为false,然后直接到第7行return回Node1的数据域1,方法运行结束.
继续从队列中poll,很显然当前h和p指向的Node1的数据域为null,那么第一件事就是要定位准备删除的队头节点(找到数据域不为null的节点)。
定位删除的队头节点
继续看,第三行代码item为null,第4行代码if判断为false,走到第8行代码(q = p.next)if也为false,由于q指向了Node2,在第11行的if判断也为false,因此代码走到了第13行,这个时候p和q共同指向了Node2,也就找到了要删除的真正的队头节点。可以总结出,定位待删除的队头节点的过程为:如果当前节点的数据域为null,很显然该节点不是待删除的节点,就用当前节点的下一个节点去试探。在经过第一次循环后,此时状态图为下图进行下一次循环,第4行的操作同上述,当前假设第4行中casItem设置成功,由于p已经指向了Node2,而h还依旧指向Node1,此时第5行的if判断为true,然后执行updateHead(h, ((q = p.next) != null) ? q : p),此时q指向的Node3,所有传入updateHead方法的分别是指向Node1的h引用和指向Node3的q引用。updateHead方法的源码看上面
该方法主要是通过casHead将队列的head指向Node3,并且通过 h.lazySetNext将Node1的next域指向它自己。最后在第7行代码中返回Node2的值。此时队列的状态如下图所示Node1的next域指向它自己,head指向了Node3。如果队列为空队列的话,就会执行到代码的第8行(q = p.next) == null。
单线程执行总结
如果当前head,h和p指向的节点的item为null的话,则说明该节点不是真正的待删除节点,那么应该做的就是寻找item不为null的节点。通过让q指向p的下一个节点(q = p.next)进行试探,若找到则通过updateHead方法更新head指向的节点以及构造哨兵节点(通过updateHead方法的h.lazySetNext(h))
多线程执行情况分析:
else if (p == q)
continue restartFromHead;
这一部分就是处理多个线程poll的情况,q = p.next也就是说q永远指向的是p的下一个节点,只有p指向的节点在poll的时候转变成了哨兵节点(通过updateHead方法中的h.lazySetNext)。当线程A在判断p==q时,线程B已经将执行完poll方法将p指向的节点转换为哨兵节点并且head指向的节点已经发生了改变,所以就需要从restartFromHead处执行,保证用到的是最新的head。
poll->offer->poll
试想,还有这样一种情况,如果当前队列为空队列,线程A进行poll操作,同时线程B执行offer,然后线程A在执行poll,那么此时线程A返回的是null还是线程B刚插入的最新的那个节点呢?
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
Integer value = queue.poll();
System.out.println(Thread.currentThread().getName() + " poll 的值为:" + value);
System.out.println("queue当前是否为空队列:" + queue.isEmpty());
});
thread1.start();
Thread thread2 = new Thread(() -> {
queue.offer(1);
});
thread2.start();
}
输出结果为:
Thread-0 poll 的值为:null
queue当前是否为空队列:false
通过debug控制线程thread1和线程thread2的执行顺序,thread1先执行到第8行代码if ((q = p.next) == null),由于此时队列为空队列if判断为true,进入if块,此时先让thread1暂停,然后thread2进行offer插入值为1的节点后,thread2执行结束。再让thread1执行,这时thread1并没有进行重试,而是代码继续往下走,返回null,尽管此时队列由于thread2已经插入了值为1的新的节点。所以输出结果为thread0 poll的为null,然队列不为空队列。因此,在判断队列是否为空队列的时候是不能通过线程在poll的时候返回为null进行判断的,可以通过isEmpty方法进行判断。
然后我们再回到offer方法中,有一个
11. else if (p == q)
当执行的顺序是: offer->poll->offer 时,不难想到在线程A执行offer时,线程B执行poll就会存在如下一种情况:
如图,线程A的tail节点存在next节点Node1,因此会通过引用q往前寻找队列真正的队尾节点,当执行到判断if (p == q)时,此时线程B执行poll操作,在对线程B来说,head和p指向Node0,由于Node0的item域为null,同样会往前递进找到队列真正的队头节点Node1,在线程B执行完poll之后,Node0就会转换为哨兵节点,也就意味着队列的head发生了改变,此时队列状态为下图
此时线程A在执行判断if (p == q)时就为true,会继续执行
p = (t != (t = tail)) ? t : head;,
由于tail指针没有发生改变所以p被赋值为head,重新从head开始完成插入操作。
- HOPS的设计
tail更新触发时机:当tail指向的节点的下一个节点不为null的时候,会执行定位队列真正的队尾节点的操作,找到队尾节点后完成插入之后才会通过casTail进行tail更新;当tail指向的节点的下一个节点为null的时候,只插入节点不更新tail。
head更新触发时机:当head指向的节点的item域为null的时候,会执行定位队列真正的队头节点的操作,找到队头节点后完成删除之后才会通过updateHead进行head更新;当head指向的节点的item域不为null的时候,只删除节点不更新head。
并且在更新操作时,源码中会有注释为:hop two nodes at a time。所以这种延迟更新的策略就被叫做HOPS
如果让tail永远作为队列的队尾节点,实现的代码量会更少,而且逻辑更易懂。但是,这样做有一个缺点,如果大量的入队操作,每次都要执行CAS进行tail的更新,汇总起来对性能也会是大大的损耗。如果能减少CAS更新的操作,无疑可以大大提升入队的操作效率,所以doug lea大师每间隔1次(tail和队尾节点的距离为1)进行才利用CAS更新tail。对head的更新也是同样的道理,虽然,这样设计会多出在循环中定位队尾节点,但总体来说读的操作效率要远远高于写的性能,因此,多出来的在循环中定位尾节点的操作的性能损耗相对而言是很小的。
一些注意点
虽然ConcurrentLinkedQueue的性能很好,但是在调用size()方法的时候,会遍历一遍集合,对性能损害较大,执行很慢,因此应该尽量的减少使用这个方法,如果判断是否为空,最好用isEmpty()方法。
ConcurrentLinkedQueue不允许插入null元素,会抛出空指针异常。
ConcurrentLinkedQueue是无界的,所以使用时,一定要注意内存溢出的问题。即对并发不是很大中等的情况下使用,不然占用内存过多或者溢出,对程序的性能影响很大,甚至是致命的。
应用场景
ConcurrentLinkedQueue 多用于消息队列
待续
部分摘自:
https://www.jianshu.com/p/001c45716232
《java并发编程的艺术》
《Java高并发程序设计》
https://www.cnblogs.com/sunshine-2015/p/6067709.html