并发队列-无界非阻塞队列 ConcurrentLinkedQueue 原理探究
/25668.html一、 前言
常用的并发队列有阻塞队列和非阻塞队列,前者使用锁实现,后者则使用CAS非阻塞算法实现,使用非阻塞队列一般性能比较好,下面就看看常用的非阻塞ConcurrentLinkedQueue是如何使用CAS实现的。
二、 ConcurrentLinkedQueue类图结构
如图ConcurrentLinkedQueue中有两个volatile类型的Node节点分别用来存在列表的首尾节点,其中head节点存放链表第一个item为null的节点,tail则并不是总指向最后一个节点。Node节点内部则维护一个变量item用来存放节点的值,next用来存放下一个节点,从而链接为一个单向无界列表。
如上代码初始化时候会构建一个item为NULL的空节点作为链表的首尾节点。
三、offer操作
offer操作是在链表末尾添加一个元素,下面看看实现原理。
从构造函数知道一开始有个item为null的哨兵节点,并且head和tail都是指向这个节点,然后当一个线程调用offer时候首先
如图首先查找尾节点,q==null,p就是尾节点,所以执行p.casNext通过cas设置p的next为新增节点,这时候p==t所以不重新设置尾节点为当前新节点。由于多线程可以调用offer方法,所以可能两个线程同时执行到了(1)进行cas,那么只有一个会成功(假如线程1成功了),成功后的链表为:
失败的线程会循环一次这时候指针为:
这时候会执行(3)所以p=q,然后在循环后指针位置为:
所以没有其他线程干扰的情况下会执行(1)执行cas把新增节点插入到尾部,没有干扰的情况下线程2 cas会成功,然后去更新尾节点tail,由于p!=t所以更新。这时候链表和指针为:
假如线程2cas时候线程3也在执行,那么线程3会失败,循环一次后,线程3的节点状态为:
这时候p!=t ;并且t的原始值为told,t的新值为tnew ,所以told!=tnew,所以 p=tnew=tail;
然后在循环一下后节点状态:
q==null所以执行(1)。
现在就差p==q这个分支还没有走,这个要在执行poll操作后才会出现这个情况。poll后会存在下面的状态
这个时候添加元素时候指针分布为:
所以会执行(2)分支 结果 p=head
然后循环,循环后指针分布:
所以执行(1),然后p!=t所以设置tail节点。现在分布图:
自引用的节点会被垃圾回收掉。
四、 add操作
add操作是在链表末尾添加一个元素,下面看看实现原理。
其实内部调用的还是offer
五、poll操作
poll操作是在链表头部获取并且移除一个元素,下面看看实现原理。
当队列为空时候:
可知执行(3)这时候有两种情况,第一没有其他线程添加元素时候(3)结果为true然后因为h!=p为false所以直接返回null。第二在执行q=p.next前,其他线程已经添加了一个元素到队列,这时候(3)返回false,然后执行(5)p=q,然后循环后节点分布:
这时候执行(1)分支,进行cas把当前节点值值为null,同时只有一个线程会成功,cas成功 标示该节点从队列中移除了,然后p!=h,调用updateHead方法,参数为h,p;h!=p所以把p变为当前链表head节点,然后h节点的next指向自己。现在状态为:
cas失败 后 会再次循环,这时候分布图为:
这时候执行(3)返回null.
现在还有个分支(4)没有执行过,那么什么时候会执行那?
这时候执行(1)分支,进行cas把当前节点值值为null,同时只有一个线程A会成功,cas成功 标示该节点从队列中移除了,然后p!=h,调用updateHead方法,假如执行updateHead前另外一个线程B开始poll这时候它p指向为原来的head节点,然后当前线程A执行updateHead这时候B线程链表状态为:
所以会执行(4)重新跳到外层循环,获取当前head,现在状态为:
六、peek操作
peek操作是获取链表头部一个元素(只读取不移除),下面看看实现原理。
代码与poll类似,只是少了castItem.并且peek操作会改变head指向,offer后head指向哨兵节点,第一次peek后head会指向第一个真的节点元素。
七、size操作
获取当前队列元素个数,在并发环境下不是很有用,因为使用CAS没有加锁所以从调用size函数到返回结果期间有可能增删元素,导致统计的元素个数不精确。
八、remove操作
如果队列里面存在该元素则删除给元素,如果存在多个则删除第一个,并返回true,否者返回false
九、contains操作
判断队列里面是否含有指定对象,由于是遍历整个队列,所以类似size 不是那么精确,有可能调用该方法时候元素还在队列里面,但是遍历过程中才把该元素删除了,那么就会返回false.
十、开源框架中使用
Tomcat中NioEndPoint中的每个poller里面就维护一个ConcurrentLinkedQueue<Runnable>用来作为缓冲存放任务。
10.1 Acceptor线程
accept线程作用是接受客户端发来的连接请求并放入到事件队列。
看下代码:
10.2 Poll线程
poll线程作用是从事件队列里面获取事件把链接套接字加入selector,并且监听socket事件进行处理。
十一、有意思的问题
10.1 一个判断的执行结果分析
offer中有个 判断 t != (t = tail)假如 t=node1;tail=node2;并且node1!=node2那么这个判断是true还是false那,答案是true,这个判断是看当前t是不是和tail相等,相等则返回true否者为false,但是无论结果是啥执行后t的值都是tail。
下面从字节码来分析下为啥?
一个例子
结果为:true;
字节码文件:
字节码命令介绍可参考: /web_code/article/details/12164733
一开始栈为空
第0行指令作用是把值2入栈栈顶元素为2第1行指令作用是将栈顶int类型值保存到局部变量t中。第2行指令作用是把值3入栈栈顶元素为3第3行指令作用是将栈顶int类型值保存到局部变量tail中。第4调用打印命令第7行指令作用是把变量t中的值入栈第8行指令作用是把变量tail中的值入栈现在栈里面元素为3,2并且3位栈顶第9行指令作用是当前栈顶元素入栈,所以现在栈内容3,3,2第10行指令作用是把栈顶元素存放到t,现在栈内容3,2第11行指令作用是判断栈顶两个元素值,相等则跳转 18。由于现在栈顶严肃为3,2不相等所以返回true.第14行指令作用是把1入栈。
然后回头分析下!=是双目运算符,应该是首先把左边的操作数入栈,然后在去计算了右侧操作数。
10.2 Node的构造函数
另外对于每个节点Node在构造时候使用UnSafe.putObject设置item替代了直接对volatile的赋值,这个是为了性能考虑?为啥不直接赋值那,看看类注解怎么说:
When constructing a Node (before enqueuing it) we avoid paying for a volatile write to item by using Unsafe.putObject instead of a normal write. This allows the cost of enqueue to be”one-and-a-half”
CASes.
也就是说当构造Node节点时候(这时候节点还没有放入队列链表)为了避免正常的写volatile变量的开销 使用了Unsafe.putObject代替。这使元素进队列仅仅花费1.5个cas操作的耗时。这个是说使用Unsafe.putObject比直接给volatile变量赋值更高效?目前还没有查到相关资料。
十二、总结
ConcurrentLinkedQueue使用CAS非阻塞算法实现使用CAS解决了当前节点与next节点之间的安全链接和对当前节点值的赋值。由于使用CAS没有使用锁,所以获取size的时候有可能进行offer,poll或者remove操作,导致获取的元素个数不精确,所以在并发情况下size函数不是很有用。另外第一次peek或者first时候会把head指向第一个真正的队列元素。
下面总结下如何实现线程安全的,可知入队出队函数都是操作volatile变量:head,tail。所以要保证队列线程安全只需要保证对这两个Node操作的可见性和原子性,由于volatile本身保证可见性,所以只需要看下多线程下如果保证对着两个变量操作的原子性(CAS)。
对于offer操作是在tail后面添加元素,也就是调用tail.casNext方法,而这个方法是使用的CAS操作,只有一个线程会成功,然后失败的线程会循环一下,重新获取tail,然后执行casNext方法。对于poll也是这样的。