研究完LinkedBlockingQueue之后呢,我的关注点又到了ConcurrentLinkedQueue上,不过说实现的,我的jdk是1.7的,1.7的ConcurrentLinkedQueue的代码简洁是够简洁的,但是读起来费力的要死,逻辑分支理解很僵硬,应该有我的原因,但是我去读过1.6的,1.6的明显在各个分支处的逻辑都很容易理解,在此也要注意一下自己的分支可读性,或许自己的代码过个一个月读起来也费尽的要死,注释还是必不可少的,。。。
首先理解过程中拜读了
http://www.zsfblues.com/2016/06/15/ConcurrentLinkedQueue%E6%BA%90%E7%A0%81%E5%88%86%E6%9E%90/
https://www.ibm.com/developerworks/cn/java/j-lo-concurrent/index.html
感谢大神们的分享。不过读归读,还是需要自己去画图理解的,直接读人家的文章,或者转过来是不负责任的表现,至少自己应该去实际理解下队列的运作。
首先源码中有大段的注释,在最开头,可以去读一下,意思大概就是下面的,我直接拿别人的解释贴过来。最主要的还是要理解head和tail的滞后更新策略,因为在入队和出对中head和tail的更新是需要格外注意的。
1.入队时最后一个结点中的next域为null
2.队列中的所有未删除结点的item域不能为null且从head都可以在O(N)时间内遍历到
3.对于要删除的结点,不是将其引用直接置为空,而是将其的item域先置为null(迭代器在遍历是会跳过item为null的结点)
4.允许head和tail滞后更新,也就是上文提到的head/tail并非总是指向队列的头 / 尾节点
(这主要是为了减少CAS指令执行的次数,但同时会增加volatile读的次数,但是这种消耗较小)。
具体而言就是,当在队列中插入一个元素是,会检测tail和最后一个结点之间的距离是否在两个结点及以上(内部称之为hop);
而在出队时,对head的检测就是与队列的第一个结点的距离是否达到两个,
有则将head指向第一个结点并将head原来指向的结点的next域指向自己,这样就能断开与队列的联系从而帮助GC.
以下为一些概念东西,也是需要注意的。我直接copy过来,其实就是一些规定,这些对于理解入队和出队操作有利。
head的不变性和可变性条件
不变性:
1.所有未删除节点,都能从head通过调用succ()方法遍历可达。
2.head不能为null。
3.head节点的next域不能引用到自身。
可变性:
1.head节点的item域可能为null,也可能不为null。
2.允许tail滞后(lag behind)于head,也就是说:从head开始遍历队列,不一定能到达tail。
tail的不变性和可变性条件
不变性:
1.通过tail调用succ()方法,最后节点总是可达的。
2.tail不能为null。
可变性:
1.tail节点的item域可能为null,也可能不为 null。
2.允许tail滞后于head,也就是说:从head开始遍历队列,不一定能到达tail。
3.tail节点的next域可以引用到自身。
空的构造方法,head 和tail 都指向一个空的节点,item域null,next域null。此时head和tail都指向一个空的Node节点,这个情况在之前都出现过,ConcurrentLinkedQueue支持的是head指向 空的Node和有值的Node。暂时只分析head指向空Node的情况。
public ConcurrentLinkedQueue() {
head = tail = new Node<E>(null);
}
offer进队列,逻辑比较复杂,情况很多,一点点分析。
public boolean offer(E e) {
checkNotNull(e);
final Node<E> newNode = new Node<E>(e);
for (Node<E> t = tail, p = t;;) {
Node<E> q = p.next;
if (q == null) {
// p is last node
if (p.casNext(null, newNode)) {
// Successful CAS is the linearization point
// for e to become an element of this queue,
// and for newNode to become "live".
if (p != t) // hop two nodes at a time
casTail(t, newNode); // Failure is OK.
return true;
}
// Lost CAS race to another thread; re-read next
}
else if (p == q)
// We have fallen off list. If tail is unchanged, it
// will also be off-list, in which case we need to
// jump to head, from which all live nodes are always
// reachable. Else the new tail is a better bet.
p = (t != (t = tail)) ? t : head;
else
// Check for tail updates after two hops.
p = (p != t && t != (t = tail)) ? t : q;
}
}
1.先来分析单线程的情况,单线程容易分析。
1.1 首先初始化操作,head和tail指向一个空Node。
1.2进入for循环,临时变量 t 和 p ,主要为了下面操作使用.
1.3此时 p.next 为null,也就是q为null, 进入插入流程。p.casNext。将当前空节点的next指向新增节点newNode。
1.4再判断是否设置tail节点,此时p和t指向同一个地址,空Node,所以不会进入设置流程。
1.5假设以上为第一个线程执行结束,此时再有第二个线程进入设置节点(当前讨论的都是单线程的情况,假想为每次都是一个线程进入进行新增过程)
1.6第二个线程进入了,此时tail仍然指向空的Node节点处,因此此时Node q就不是null了,因为第一个进入的线程已经新增了一个节点。
很明显,p=q的流程判断也进不去,它们指向的是不同的地地方。那么就会进入最后一个else流程。但是此时 p = t ,因此p = q(q就是p.next,
也就是将p指向 newNode节点),
这边p 和 t 的判断涉及到多线程的情况,暂时不讨论,
在完美情况下,这边的p会指向newNode,然后再次循环进入和第一个线程一样的新增流程。下图新增第二个节点newNode2
1.7 新增完第二个节点之后,此时再次判断 p 和 t是否相等,此时p和t不等,就进入设置tail节点的流程,可见,tail的设置是有延迟的,并不是每次新增一个节点之后就会立马设置tail节点。设置完tail节点之后如下图。这是第二个线程进入时的流程,当然还是基于单线程的完美情况。
2.以上都是基于单线程考虑,那么下面基于多线程来看看具体的情况。
2.1首先假设线程A,线程B 同时进入,A、B同时获取到 临时变量 t 和 p ,此时 t 和 p都是指向同一个空节点Node,跟第一个进入的单线程一致。
2.2 多线程的第一个冲突出现在p.casNext处,这边cas操作去设置next的值,有了之前的经验,cas操作是基于操作系统层面的加锁,那么此处A、B线程只能有一个成功。
2.3假设现在是A设置成功,那么此时队列的值如下图:为了理解方便,临时变量 tA 和 pA标注其中,这是线程A的临时变量。那么此时线程B干嘛了呢?线程B虽然进入了 q == null的逻辑段,但是并没有成功新增节点,因此它进入轮询。
2.4线程B进入轮训之前的状态为下图,newNode节点为线程A新增的。但是此时线程B读到的t 和 p 节点仍然是它第一次读取的,那么它轮训时 q,此时不为null了,会进入下面的逻辑段,
一般情况下 p == q的 逻辑分支进不去,这个逻辑分支的意义待会儿再说。
2.5 不进入 p == q 逻辑分支,就进入了 最后一段先说p = (p != t && t != (t = tail)) ? t : q; 为什么这边会要先判断 p和 t是否相等呢?
2.5.1 首先,p == t 的情况就是上图线程轮询第二次进入时的状态,以便下次轮询直到找到 next 为null的节点,进行插入操作。那么什么时候出现p != t 呢?
理想情况下,轮询第三次时就会出现,可以假设第三次轮询开始时,恰巧线程C抢先插入了一个节点,线程C进入时的初始状态是上图的线程B,此时队列中有一个新节点。
2.5.2,线程C 读取此时的t 和 p 仍然指向空节点Node 。那么此时线程C其实轮询第一次时是将 p 指向了 q,也就是newNode节点。那么线程C轮询第二次时进行了插入操作。
线程C第一次轮询时如下图:p t指向 空节点Node。
线程C第二次轮询时如下图:此时如果没有其他线程干扰的话,线程C就会设置tail成功,因为此时可以看见p != t,
2.5.3,再回到此时的线程B,线程B轮询第二次结束了,此时的p 指向的是 它的下一个节点 q,而原本的t指向的还是第一个空节点Node,这边好好思考下,分清楚此时不同线程
的状态。那么此时线程B的p虽然指向了它认为的是尾节点的q节点(这边的情况只假设了有一个节点,正确的说法应该是线程不断的轮询直至找到next域为null执行插入)。这边对于
线程B来说的话newNode就是尾节点,所以理论上它在下一次时就应该执行插入了,但是2.5.2中有个线程C抢先插入了一个节点,导致线程B在 执行 p.next的时候发现节点的存在
了,因此又会执行else 流程。p = (p != t && t != (t = tail)) ? t : q; 那么此时我们来看,线程B的状态和上图一致,p指向的是newNode节点,t指向的还是空节点Node。那么此时p!=t
在p!= t的情况下,还需要比较tail节点,上图线程C执行完之后呢,刚好触发了tail节点的更新操作,因此,p现在指向了 新的尾节点newNode2了。再次轮询执行插入。
2.5.4 那么什么时候 t != (t = tail) 是false呢?很简单,就拿上图来说,tail的设置是在新节点的插入之后,那么在这个期间,线程B进入了这个判断的话,p != t 的,但是此时呢?
此时tail节点还未变换,所以是fasle了,那么p 就指向了 新的节点newNode2了,因为此时p.next已经有值了,继续执行插入操作。
至此分析完多线程下的几种插入情况。
1.留了一个else if (p == q)逻辑分支没去讲,这是因为我在拜读其他人文章时出现了好几种解释,当时自己也懵了,我觉得这个状态应该需要从出队下考虑,暂且不说。
2.还有一个casTail(t, newNode);设置 tail的方法,源码中对于此方法注释为,失败也ok,想想这是为什么。设置失败只有一种情况,那就是当前线程在设置时,有其他线程抢先设置了
tail节点,导致其设置失败。
下图是线程C成功插入了一个新节点newNode2,正准备设置tail节点前的情况:上面说了导致其失败的原因是有其他线程抢先设置了tail节点,基于此,假设为线程D成功插入并正在
设置tail。
线程C设置tail节点之前的状态:
线程D设置新节点newNode3和成功设置了tail的状态:那么此时线程C即使设置不成功的话,也就不要紧了,线程D成功设置了tail节点。
那么其实对于线程D来说的话,也不一定能够正常完成设置tail节点,这个线程C一样。它又再次进入轮询,找寻尾节点,执行插入操作。可见设置tail节点并不一定是当前线程
完成的,有可能是其他线程帮忙完成的。
以下来分析出队流程:出队流程也是很简洁,但是很难读啊,,,,,,
public E poll() {
restartFromHead:
for (;;) {
for (Node<E> h = head, p = h, q;;) {
E item = p.item;
if (item != null && p.casItem(item, null)) { // 1
// Successful CAS is the linearization point
// for item to be removed from this queue.
if (p != h) // hop two nodes at a time
updateHead(h, ((q = p.next) != null) ? q : p);
return item;
}
else if ((q = p.next) == null) { //2
updateHead(h, p);
return null;
}
else if (p == q) //3
continue restartFromHead;
else //4
p = q;
}
}
}
final void updateHead(Node<E> h, Node<E> p) {
if (h != p && casHead(h, p))
h.lazySetNext(h);
}
1.首先还是基于单线程来看正常情况,下图为正常情况下的队列数值情况:
2.临时变量 h 、p、q 对于head节点处的item为null,这是初始化时就是null。分支1 不去,分支2 ,注意分支2进行了赋值操作,q指向了p.next节点,但是虽然指向了改节点,但是
next是有节点的,因此不去,进入分支4,p指向q,也就是p指向了下一节点,轮询一次之后,如下图:
3.再次轮询,此时p指向的节点newNode1,item项不为null,就执行出队操作。注意出队操作并不是删除,而是将newNode1节点的item值设置为null,p.casItem(item, null)。
此时设置成功之后,p != h 进入设置head节点的逻辑。updateHead(h, ((q = p.next) != null) ? q : p); 这边将h指向哪个节点呢?如果当前p.next有节点,就指向next节点,如果
没有节点了,就将当前节点设置为head,根据代码来看,就是这么处理的。此时head指向了后继节点newNode2处,准备下一次出队列。需要注意的是h.lazySetNext(h);
h指向自己,便于GC。
下面来看多线程的情况,比较复杂,慢慢分析,基于出队的情况,我们先来分析一下入队操作没有分析的那一段逻辑是怎么出现的。还记得么? p == q那一段。
假设当前队列情况如下图,
上图是什么阶段呢?是线程A执行了插入操作,但是还没来的及设置tail节点的阶段,此时线程B进入了,对于线程B来说的话它读取的还是tail指向空节点Node的情况。如下图:
那么假设此时有线程D出队操作了,,,出队操作也是在设置tail节点之前,基于前面出队分析,出队之后的队列数据如下图。出队具体逻辑不分析了,上面不远处就有,p和q是中间
状态,此时head节点指向了newNode2。
但是对于线程B来说呢?线程B的tail仍然指向的是空的Node。经过出队操作后,线程B执行时队列情况如下:
此时来看看q = p.next 指向的谁,指向它自己了,也就是刚刚执行完出队操作之后head节点指向了自己,方便GC的那个空Node。但是出队操作并不在队列中删除该节点,只是
将队列的item设置为null,还有就是将原有head指针指向自己方便GC。此时如果线程B执行入队操作的话,就会执行到 p ==q 逻辑段, p = (t != (t = tail)) ? t : head;看看这是干嘛的。
比较的是tail节点的位置,如果是false的话,就意味着tail节点在期间没有变更过,也就是我上面假设的在设置tail节点之前的情况下,这种情况下,将p指向head节点,也就跳过
了空的Node节点,跳到下一个head节点处,便于下次新增时能在有效队列中执行,那如果是true呢?这种情况出现在执行到p == q了,但是进入这个分支之后呢?线程A设置了新
的tail节点了,如下图,那么此时线程B的p应该指向的是最新的tail节点处,下次轮询新增。
此处例子比较凑巧,head处的下一个节点就能直接新增,但是事实并不是如此,一般来说,tail改变的话直接指向tail,而tail一般就是尾节点,下次就能直接新增。
至此 offer 入队操作就算完成了。。。。。。。。。。。。。。每个情况都分析到了。
-------------------------------------------------------------------------------------------分割线-----------------------------------------------------------------------------------------------------------------
继续来看poll方法。
那么什么时候进入分支2呢?节点为null的情况只可能是p.next是尾节点,其他情况不可能出现,我们知道tail可能指向自身,但是并不是null,特别注意。
假设线程A和线程B同时读出数据,如下图:
出队的首次冲突发生在p.casItem处,假设线程A修改当前节点的item值,那么线程B注定修改失败。情况如下图:
那么对于线程B来说的话,会将p.next 指向 q ,那么对于线程B来说此时的pB应该指向了newNode2了,如下图:
线程B进入轮询时的状态如上图所示,那么假设此时有线程C进入干扰了,导致线程B执行 p.casItem时又阻塞了,此时进入 分支 2的判断,p.next为null了,说明线程B出队列时
没有找到合适的数据,但是已经来到的尾节点处,这就尴尬了。其实newNode2的数据最终由线程C返回了,但是线程B此时干嘛呢?设置head节点啊,其实线程B和线程C都是要
设置head节点的,当next为null时,都是设置当前p指向的节点为head节点。
对于分支2的理解的话,其实每个线程应该都不想遇到的,因为它一直在轮询,往后移动节点,试图读出数据,但是每次都被打断,直至最终尾节点时,还是没有执行item的修改
那么最终只能返回null了,因为到最终它都没有获得读取数据的分支1逻辑。可见分支3的话是一个最终逻辑,当p指针指向了尾节点触发。
那么分支3是什么情况呢?感觉跟入队操作的时候一样啊,会不会也是受到了指向自身节点的干扰了?可以拿上图作为初始化状态,假设此时线程B执行完了设置item操作,将item
设置成null了。但是还没有来的及设置head操作。线程C来了,并且读取到了更新之前的数据,如下图:
那么此时对于线程C来说,它的内部是下图的样子:
然而不幸的是,它刚读取完之后,线程B就修改了head节点。指向了自己。
那么此时q其实也还是指向了空节点Node,p一开始就指向此,没移动过,对于这种情况来说的话,就是当前节点其实已经废了。那么从新开始restartFromHead。读取最新的
head节点即可。
至此分析完入队操作和出队操作所有的逻辑段,其他逻辑分支不是很难,但是对于 p == q 这个分支的进入如果对于两个操作没有分析透彻的话,是不容易理解的。