JUC-ConcurrentLinkedQueue的设计分析与实现

持续更新中,

ConcurrentLinkedDeque的分析基本完成可以参考:JUC-ConcurrentLinkedDeque的设计分析与实现

目录

基础知识:

设计与实现

基础版本

改进方案1:

改进方案2

改进方案3:

官方文档翻译:


持续更新中,

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不过是一个优化而已。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值