持续更新中,
ConcurrentLinkedDeque的分析基本完成可以参考:JUC-ConcurrentLinkedDeque的设计分析与实现
目录
持续更新中,
ConcurrentLinkedDeque的分析基本完成可以参考:JUC-ConcurrentLinkedDeque的设计分析与实现
基础知识:
CAS操作与无锁编程:?待更新?
设计与实现
官方的设计稍微有些复杂,可以先从最简单的设计慢慢的优化为官方的设计,从而明白官方为什么那样设计。
基础版本
基于CAS操作设计实现的基础版MyConcurrentLinkedQueue,该队列设计如下:
1,队列由一个个的节点Node相连,标记队头为head,队尾为tail。
2,队列初始化时将head和tail指向一个哑节点,该哑节点的next为null,当head的next为null则表明队列为null。
3,该队列不支持从队列中间取出元素,只能从队头取元素。添加元素只能从队尾添加。
Node的定义如下:
class Node<T> { volatile T item; volatile Node<T> next;}
入队操作就是不断尝试将新节点放置到tail后面,并将新节点设置为tail。
出队操作就是不断尝试将head后移一个单位,然后取出head中的item即可。
入队的代码如下:
public boolean myOffer(T item) {
Node<T> newNode = new Node<>(item);
// 不断尝试将新元素放到tail的后面直到成功
// 失败的情况是因为其他线程抢先一步将新元素放置到tail后面了
while(!tail.casNext(null, newNode))
;
tail = newNode;
return true;
}
出队的代码如下:
public T myPoll() {
Node<T> h = head, q;
while ((q = h.next) != null) { // 如果head的next为null表示队列为空
// 尝试将head后移一个单位
if (casHead(h, q)) {
// 成功则取出队头元素
return q.item;
}
else {
// 失败表明其他线程已经将head后移了,该线程重新读取head再次尝试
h = head;
}
}
return null;
}
改进方案1:
基础版本的实现不支持从队列中间取出元素remove(Object),因为myTake()是通过将head后移一个位置后,取出该位置中的元素,假设remove(Object)刚好已经把这个元素取出,那么此时myTake()将返回null,但队列尾部可能还有元素,此时返回null显然是不可取的。因此将出队的逻辑改为:
从head向后遍历直到遇到item不为null的节点,尝试将item取出,如果失败那么继续往后找item不为null的节点,直到成功为止。失败的原因是其他线程抢先一步将元素取出了。
在这个逻辑下,remove操作就可以安全的从队列中间取元素而不用害怕出队操作发生异常。
public T myPoll() {
// p从head出发,不断向后遍历,直到遇到item不为null的节点,尝试取出
for (Node<T> h = head, p = h, q;;) {
T item = p.item;
// 如果item不为空则尝试取出
if(item != null && p.casItem(item, null)) {
// 成功取出,则尝试更新head到p的后继也就是q,这一步是安全的,因为很显然q前
// 面的节点的item都为null了,将head放到p,其他线程就可以直接从p向后取元素
// 更新失败也没关系,这时候可能是其他线程t抢先一步将head设置到了别的位置x,
// t线程也会按照着这个逻辑更新head,从而更新head是安全的。
casHead(h, ((q = p.next) != null) ? q : p);
return item;
}
else if ((q = p.next) == null) { // 到队尾了,表示队列中没有元素了
// 尝试将head放到队尾,道理同上面的更新操作一样
casHead(h, p);
return null;
}
else {
// 继续向后遍历找item不为null的节点
p = q;
}
}
}
改进方案2
到现在为止已经和官方代码中出队代码poll()的很相似了,再加上一个小的修改。在上面的代码中每次都会在取出元素后尝试更新head,考虑只有一个线程在不断取元素的情况,该线程每取出一个元素就把head更新一次,而且显然都会成功(没人和他竞争),因为更新head是CAS操作,CAS操作的代价比较高,所以可以想办法减少head的更新的频率,那就是每取出两个元素后才更新一次head,这就是标准库中所说的跳跃式更新head以减少CAS(或volatile)操作。因此将代码改成如下:
public T myPoll() {
for (Node<T> h = head, p = h, q;;) {
T item = p.item;
if(item != null && p.casItem(item, null)) {
// 就加了一个if语句,p和h的距离大于1才更新head到p的后继q
if (p != h)
casHead(h, ((q = p.next) != null) ? q : p);
return item;
}
else if ((q = p.next) == null) {
casHead(h, p);
return null;
}
else {
p = q;
}
}
}
按照这个思路顺便将入队的代码也修改一下:
从tail出发,不断向后遍历找到队尾,尝试将新节点放到队尾后面,失败的情况是因为其他线程抢先一步把某个新节点放到了队尾,此时继续向后找到新的队尾,再次尝试将新节点放到队尾后面。
public boolean myOffer(T item) {
Node<T> newNode = new Node<>(item);
// 不断尝试将新节点放置到tail后面
for(Node<T> t = tail, p = t, q;;) {
if ((q = p.next) == null) {
if (p.casNext(null, newNode)) {
if (p != t) {
casTail(t, newNode);
}
return true;
}
}
else {
p = q;
}
}
}
改进方案3:
到目前为止已经完成标准库中的基本功能了,我们来看看还有什么可以优化的。
假设一开始有一个空的队列,在一段时间的入队和出队后,队列状态如下,Node1(null)表示队列Node1中item为null:
Node1(null)->Node2(null)->Node3->..->NodeN
假设此时head指向Node3,在这个队列中存在一个问题,那就是Node1和Node2已经不包含实际的item了,但是他们还链接在队列上,这会降低垃圾回收的效率,因此我们希望这两个节点不在链接在队列上,也就是将Node2和Node3之间的链接打断,将Node2的next指针赋值为null或指向它自己可以实现这个目标。
为什么不选择将next赋null,因为这样的话假设有个入队的线程正好在Node2上停留,那么按照上面入队的代码,它会将新节点放置到Node2后面。这显然是错误的入队,因为从队列的head出发将无法遍历到该节点,因此我们选择将next指向Node2自身。这一步称之为gc-unlink。
每次我们更新head,就将原head进行gc-unlink,这样就相当于将前面的已经出队的节点指向队列的指针打断了,因此抽象出一个updateHead()的代码如下:
final void updateHead(Node<E> h, Node<E> p) {
if (h != p && casHead(h, p))
// 将原head的next指针指向其自身
h.lazySetNext(h);
}
但是将已经出队的节点p指向的next指向自身会有一个问题,那就是假设有个线程正好遍历过程停在p上,那么由于p的next指向p,它将无法继续向后遍历。这个问题怎么解决?其实也很简单,那就是直接跳到head上就可以了,因为被打断的节点只可能在head前面,head和head后面的节点还没出队,不会将next指针指向自身。因此将入队和出队的代码修改如下:
出队:
public T poll() {
retry:
for(;;) {
for (Node<T> h = head, p = h, q;; p = q) {
T item = p.item;
if (item != null && p.casItem(item, null)) {
// 尝试跳跃式更新head
if (p != h) {
updateHead(h, (q = p.next) == null ? p : q);
}
return item;
}
else if (p.next == null) {
updateHead(h, p);
return null;
}
// 如果遇到next指向自身的节点,则跳到head处
else if ((q = p.next) == p) {
continue retry;
}
}
}
}
入队:
public boolean offer(T item) {
Node<T> node = new Node<>(item);
for(Node<T> t = tail, p = t;;) {
Node<T> q = p.next;
if (q == null) {
if(p.casNext(null, node)) {
if (p != t) {
casTail(t, node);
}
return true;
}
}
// 遇到next指向自身的节点,则跳到head
else if (p == q) {
// 优化一下,如果发现tail被更新了,那么相比于
// 跳到head,跳到tail是更好的选择
p = (t != (t = tail)) ? t : head;
}
else {
// 继续向后,优化一下,如果发现tail被更新
// 了,那么跳到tail是更好的选择
p = (p != t && t != (t = tail)) ? t : q;
}
}
}
到目前为止入队和出队的代码就是标准库中的实现了,如果对实现和垃圾回收不懂的地方欢迎评论提出。
官方文档翻译:
。该实现是Michael&Scott算法的一个变种,以适应具有垃圾回收机制的语言环境,同时支持内部节点的删除操作(remove(Object))。更多信息可以参考Michael&Scott的论文。
。值得注意的是,同大多数concurrent包下的并发算法一样,这个实现基于一个这样的事实:在垃圾回收机制下,不会出现由于重新使用被回收的节点导致的ABA问题,所以实现上没必要使用“counted pointer”等相关技术来防止出现ABA问题。
(解释一下:counted pointer可以理解为唯一指针,也就是每次新建的对象都会被赋予一个唯一的指针地址)
。基本的不变式如下:
-,队列中始终只有一个next指针为null的节点,该节点为队尾节点(last节点),当新节点入队时队尾节点的next指针会被CAS为新节点(此时新节点成为队尾节点)。队尾节点可以在O(1)的复杂度下从tail到达,不过tail仅仅是一个优化,因为总可以在O(N)的复杂度下从head到达队尾
(解释一下:tail和head是两个指向队列中节点的指针,作用就是用来快速定位队头和队尾)
-,队列中的元素就是所有节点中保存的非空item,这些非空item一定可以从head出发向后遍历获得。将节点中的item通过CAS为null就相当于将item从队列中删除。即使在并发修改head的情况下,从head出发一定能达到队列中的所有item。一个出队的节点可能被迭代器(Iterator)或者一个失去CPU时间片的poll()操作无限期持有。
上面的设计中,被出队的前继节点可以GC-reachable到队列中的所有节点,这会导致两个问题:
-迭代器Iterator可能导致即使将节点从队列出队也无法垃圾回收(GC)该节点
-老年代的节点对新生代的节点的引用导致对于分代GC收集器而言,新生代节点即使出队也很难被回收,从而频繁的触发major GC。
因为只需要保证从被出队的节点能到达没有被删除的节点(这种节点是item不为空的节点),并且这种可达性不需要同垃圾回收中的可达性一样。我们将出队的节点的尾指针指向其自身,当遍历过程中遇到这样的指向自身的的节点时意味着遍历要重新从head出发。
(解释一下:上面所说的可达性,假设a可达b,对于垃圾回收中的可达性而言,a必须传递性的包含b的引用。而对于该设计中的可达性而言,a可以不传递性的包含b引用,因为可以通过head到达b,通过上面提到的head这个中转和恰当的设定,就可以表达a可达b,此时因为a不包含b的引用这样当b出队时GC就可以回收b)
。head和tail设计为可滞后的。事实上,对head和tail的更新失败正是优化的关键,因为这意味着更少的CAS操作(因为CAS操作代价很大,因此越少越好)。如果LinkedTranferQueue一样,我们将滞后系数设置为2,意思是当head/tail距离队头和队尾的距离大于2时才会尝试将head和tail更新到队头或队尾,否则让head/tail滞后于队头和队尾。
。注意,因为head和tail是相互独立的更新的,因此可能出现head跑到tail后面的情况。
。将Node的item通过CAS为null就自动的将该item从queue中移除了。迭代器会跳过这些item为null的节点。该类之前的实现中,poll()和remove(Object)会发生竞争,在这种情况下这两个操作都会成功的删除同一个节点。remove(Object)同样lazily unlink被删除的节点,不过lazy unlink仅仅是一种优化而已。
。当新建节点时(在入队之前),我们通过Unsafe.putObject避免进行volatile写操作。这样入队操作的代价就变成“一个半”CAS操作的代价。
。head和tail可能指向item为null的节点。如果队列为空,所有item当然必须为null。在初始化队列时,head和tail指向一个item为null的dummy节点。head和tail只能通过CAS更新,所以他们不会发生倒退,尽管如上面提到过的head和tail不过是一个优化而已。