java concurrentlinkedqueue遍历_java并发面试常识之ConcurrentLinkedQueue

ConcurrentLinkedQueue的设计非常考验设计功底,全程使用cas操作。为了使用无锁的机制,这个队列简化了很多操作,把主力全部放到了作为一个队列的使用方法,offer(),poll(),peek(),isEmpty()等队列常用方法。由于全程无锁,设计复杂程度远远高于直接使用锁,所以读起来比较晦涩难懂。所以面试的时候说出这个是实现过程,就比较容易加分。

基本条件介绍:

public ConcurrentLinkedQueue() {

head = tail = new Node(null);

}

最重要的两个节点,head和tail,初始化的时候都是指向一个空节点的。

offer操作

public boolean offer(E e) {

checkNotNull(e);

final Node newNode = new Node(e);

for (Node t = tail, p = t;;) {

Node q = p.next;

if (q == null) {

if (p.casNext(null, newNode)) {

if (p != t)

//更新tail

casTail(t, newNode);

return true;

}

}

else if (p == q)

//哨兵重新找

//哨兵节点是直接看别人写的,感觉比较适合,这里也这么称呼

//这个节点的下一个节点是指向自己的

p = (t != (t = tail)) ? t : head;

else

p = (p != t && t != (t = tail)) ? t : q;

}

}

offer操作的代码可以说非常精简。offer先把数据建立成一个节点,然后使用到t,p两个变量同时指向tail的位置,q是p的next,只要q为null就进入cas环节。这里都好理解,但是更新tail的时候需要做p!=t的判定,这个就是比较有讲究的地方了,具体下面说,哨兵节点是在poll产生的,具体在Poll中结合的说。

p = (p != t && t != (t = tail)) ? t : q;

这段代码利用了java的特性,就是解析成指令后,按照指令从左往右执行。

int t=6;

System.out.println(t!=(t=5));

例如上面这个程序,遇到t的时候就把6取出来,然后t=5赋值,此时表达式就变成6!=5。 以上程序,用c++重写后,结果就不是这个样子了。

这一句简单的话,就是在tail更新后使用新的tail的值,tail没有被其他线程更新的话,就直接让p移动到p的下一个节点的位置。以此找到正确的tail。哨兵节点的处理方式同理。

offer方法如果返回只会返回true,所以不能用返回值来做逻辑判断。

tail是有条件更新的

很多人都有这个疑问,p和t原来都是指向tail的,p经过查找,表示的是最后一个节点的位置,t经过查找,找到的是tail的位置,也就是说最后一个节点和tail节点不相同的时候,就更新tail。

p!=t的情况发生在两种情况下。

条件1,由于tail是有条件更新的,当tail指向最后最后一个节点的时候,此时添加节点后,t和p都没有变动,不会触发tail的更新,此时tail就是倒数第二个节点,等再次添加的时候,进入p!=t的条件,开始更新tail的位置。

条件2,tail被其他线程更新了,p指向的tail,已经不是tail指向的位置了。

tail的极限位置

由于这个条件,tail指向的可能是最后一个节点,可能是倒数第二个,可能是倒数第三个(就是在新加了元素后,还没执行更新tail的时候,tail此时就是节点的倒数第三个位置),在并发大的情况,下tail还可能极限到倒数第四个。最后和倒数第二个的情况,大家可以推导出来,这里再说一下倒数第四个的情况。触发条件是,tail是倒数第二的情况,然后同时两个线程一起并发添加,第一个线程使用cas添加成功,此时开始走p!=t的情况,与此同时,另外一个线程虽然cas失败,但是p的节点找到当时的最后一个,此时第一个线程添加节点成功,然后通过重新查找找到下一个节点,开始添加成功,而此时tail的cas开始执行,这个瞬间,tail还保持到原来的位置,但是已经新添加了2个节点。这个状态的时间非常短,但是有可能出现。

为什么说极限是倒数第四个,没有倒数第五个。因为cas操作是原子的,能成功的只有一个其他都是失败。所以以下代码最多容纳两个线程,一个casnext成功,进入castail的流程,另外的一个线程是casnext刚失败,然后往后找了一个节点,执行casNext成功,就算并发量再大,其他casnext都是失败的,一个时间点上只允许一个成功,此时casTail处于刚要成功的位置,加上原来正常的倒数第二个节点,正好是倒数第四个。

if (p.casNext(null, newNode)) {

if (p != t)

casTail(t, newNode);

tail为什么不作为最后一个节点

很多人都问这个问题,如果是正常操作的话,应该让tail成为最后一个节点,这样每次都可以直接取出tail,然后往后cas添加。以上这个思维其实是加锁的一种做法,毕竟casNext和casTail是两个操作,在不加锁的情况下,多线程跑起来,没法保证这个操作。

我在网上找到了类似的算法,那个算法就是保证线程都结束后,tail就是最后一个节点。

p=new node();

while(true){

//获取最后节点

tail=getTail()

if(tail->next==null){

//添加节点

if(casAddNode()){

break;

}

}else{

//更新tail节点

cas(tail,tail->next)

}

}

//更新tail

castail(tail,q)

为了保证tail是最后一个,每个线程都在做tail的维护工作。但是在并发高的情况下,tail也只是保证在当前一个时间点上是最后一个元素,你在更新的时候别人还在加元素,你刚更新完tail,另外的线程就加了一个元素,所以在多线程添加元素的情况下,也只能是依靠现有的tail去查找到最后一个元素。相对于每次添加元素而言,tail并不一定是最后一个元素,而且还加入了频繁的casTail操作。

后面又提出了一种优化方式,就是对tail的定义为接近最后一个元素的位置。

p=new node();

while(true){

//获取最后节点

tail=getTail()

if(tail->next==null){

//添加节点

if(casAddNode()){

break;

}

}else{

//更新tail节点

while(tail->next != null)

tail = tail->next;

}

}

//更新tail

castail(tail,q)

这个是对第一种的改良,用while替换了cas操作,效率上高了很多,存在的问题就是tail的更新在完成之后,那么在并发高的情况下,还没更新tail的时候,可能已经加入了很多节点,每个都是拿着当前变量去查找最后一个节点,就算更新了tail也不会使用tail的值。每个线程找真正最后一个节点的遍历次数可能非常多。

于是新的算法被提出来Michael & Scott的改良。ConcurrentLinkedQueue就是用了这个算法。就是tail本身定义就是接近结尾的元素。相比第二个算法的改良,就是使用了tail的更新,并不是单纯一个局部变量去更新,p = (p != t && t != (t = tail)) ? t : q;再有人更新tail就使用了tail,第二种的while循环则要和加入的速度竞争,效果和一个水缸,一个口在放水,一个口在加水一样。

为什么还要加入p!=t的条件,每次都更新不好吗

Michael & Scott的改良本身对tail的定义就不是最后一个节点,所以更新只是为了防止距离太远,如果距离可以接近,那可以省去这个cas操作。只有一种情况会出现不更新,就是tail节点就是原来添加元素的最后一个节点,在并发高的情况下,p!=t的条件很容易满足,只要多线程开始处理,很多情况下都是p!=t的,都会尝试更新tail节点。慢慢的就退化成上述的第二种情况,每次都会去更新tail。

poll操作

public E poll() {

restartFromHead:

for (;;) {

for (Node h = head, p = h, q;;) {

E item = p.item;

if (item != null && p.casItem(item, null)) {

if (p != h)

updateHead(h, ((q = p.next) != null) ? q : p);

return item;

}

else if ((q = p.next) == null) {

updateHead(h, p);

return null;

}

//哨兵节点

else if (p == q)

continue restartFromHead;

else

p = q;

}

}

}

poll的操作相对offer,要稍微好看一些,直接从head开始遍历,初始化的时候head是一个空节点,head表示的也不一定是第一个元素,起码初始化的时候head是一个空节点,p表示的是一个有数据的节点,h表示的就是head的位置,p!=h只有一种情况下出现,就是head是一个空节点而不是第一个元素节点,在p!=h的情况下,如果p有下一个节点,那么p的下一个节点作为head,否则p本身作为head。p的next作为head的时候,head就是指向了第一个元素,再次删除的时候,就只会把一个元素的值设置为null,并不会更新head,此时head又成为了一个表头,他的下一个元素才是第一个数据节点。

哨兵的产生以及相遇

final void updateHead(Node h, Node p) {

if (h != p && casHead(h, p))

h.lazySetNext(h);

}

在更新头节点的时候原来的head就会自己指向自己,成为哨兵。

当添加入第一个元素后,head和tail都指向了头结点(不是第一个元素),此时添加的时候获取tail,poll正好把head做成哨兵节点,此时找head的下一个节点的时候就会无限循环下去。

总结

ConcurrentLinkedQueue通过无锁来做到了更高的并发量,是个高性能的队列,但是使用场景相对不如阻塞队列常见,毕竟取数据也要不停的去循环,不如阻塞的逻辑好设计,但是在并发量特别大的情况下,是个不错的选择,性能上好很多,而且这个队列的设计也是特别费力,尤其的使用的改良算法和对哨兵的处理。整体的思路都是比较严谨的,这个也是使用了无锁造成的,我们自己使用无锁的条件的话,这个队列是个不错的参考。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值