Java并发容器(二):Java并发容器和框架--ConcurrentLinkedQueue(JDK1.7)

并发安全的队列实现

ConcurrentLinkedQueue是一个线程安全的队列,队列的并发不安全性其实也就是会出现覆盖的问题

而实现一个线程安全的队列有两种方式去实现,其实总的来说是拥有两种算法

  1. 一种是阻塞算法,也就是用锁去控制队列的入队和出队,可以是两个锁也可以是一个锁
  2. 另外一种就是非阻塞算法,采用循环CAS的方式去实现,其实也就是乐观锁

ConcurrentLinkedQueue的实现方式就是非阻塞算法

ConcurrentLinkedQueue结构

ConcurrentLinkedQueue是一个基于链接节点的无界线程安全队列,采用先进先出(FIFO)的规则对节点进行排序,传统的队列都是这样,新添加的元素在队列的尾部,获取一个元素时,返回队列头部的元素,采用CAS算法去实现,也称为wait-free算法

下面就来看看这个类的结构
在这里插入图片描述
可以看到其类架构

ConcurrentLinkedQueue继承了AbstractQueue,AbstractQueue继承了AbstractCollection
在这里插入图片描述
从图中可以看到这个AbstractCollection实现了Collection接口,前面学习List接口,也有接触这个AbstractCollection,所以可以看到这个队列的实现跟List和Set的实现类可能有点共同之处。

下面就来研究一下这个ConcurrentLinkedQueue是怎样架构的

拥有的变量属性

在这里插入图片描述
下面就来看看这几个变量是干什么的

  • serialVersionUID:序列化ID

  • head:头节点

  • tail:尾节点

  • headOffset:头节点偏移量

  • tailOffset:尾节点偏移量

  • UNSAFE:还不清除这个具体干嘛的,在ConcurrentHashMap看过用它来替换元素

Node类

head和tail属性都是其Node内部类,所以先来看这个Node类
在这里插入图片描述
其有两个属性

  • item:存储的值
  • next:下一个Node地址

再看里面的一段静态代码,因为实例前会执行静态代码块,所以先看这个
在这里插入图片描述
可以看到,这里使用了静态变量去获取item和next字段相对Java对象的“起始地址”的偏移量

所以,这个类一被实例化,就会去获取item和next字段相对实例对象的偏移量了

接下来我们来看里面的方法
在这里插入图片描述
可以看到,对于item和next的赋值,是用CAS来实现的,所以这方面保证了并发的安全

构造方法

下面只讲无参构造方法
在这里插入图片描述
从无参构造方法可以看到,一开始,head和tail结点都是同一个引用,都是一个空item的Node

入队操作

入队其实就是将入队节点添加到队列的尾部,而入队的操作主要做两件事情

  1. 将入队节点设置成当前队列尾节点的下一个节点
  2. 更新tail节点
    1. 如果tail节点的next节点不为空,则将入队节点设置为tail节点
    2. 如果tail节点的next节点为空,则将入队节点设置成tail的next节点
    3. 所以,tail节点不一定是尾节点!头节点只起到一个哨兵作用!

大概流程

一开始,head、tail节点情况
在这里插入图片描述
添加第一个节点,由于tail节点下一个节点为null,所以让tail节点指向新入队的节点
在这里插入图片描述
添加第二个节点,由于tail节点的next有值了,所以第二个新添加进来的节点成为tail节点
在这里插入图片描述
下面的情况就以此类推不再示范了,那为什么要采用这种方式去添加节点呢?而不是直接默认新添加的节点为tail节点呢?

我自己感觉,这样可以减少tail节点变换的次数(只有尾节点和tail节点距离为1时去更新tail),这里的变换是指tail节点地址变换,因为每次去添加一个节点都要去对尾节点更新的,假如让新添加的节点为tail节点的话,就代表每一次添加,tail节点都会变成另外一个,容易产生并发问题,两个线程去同时获取tail节点,而且都为同一个,那就会产生覆盖问题了,后添加的节点会覆盖先添加的节点(虽然说可以利用CAS来避免,但是CAS太多次会影响效率,CAS次数也是要减少的),假如我们通过让tail节点的next节点去控制尾节点,当两个线程同时获取到tail节点也不一定会产生并发问题,假如现在两个节点同时获取了tail节点,并且该tail节点的next为null,那么并不会去修改tail节点,对于另外获取了同一个tail节点的线程是没有影响的,当然,假如同时判断了tail节点的next为null,同样也会产生并发问题

下面来看看ConcurrencyLinkedQueue是怎样使用CAS来入队的,入队的具体实现逻辑在offer方法里面

在这里插入图片描述
首先看这个方法给的注释

由于队列是无界的,这个方法永远不会返回false

从这句话大概可以猜测出,返回值为布尔类型代表插入是否成功,并且强调了不会返回false,因为队列是无界的!同时也可以看出这个队列的底层实现是使用链表

    public boolean offer(E e) {
        //这个方法只是检验传来的值是不是Null
        checkNotNull(e);
        //去创建一个新的入队节点
        final Node<E> newNode = new Node<E>(e);
		//死循环遍历·链表得到尾节点,注意不是tail节点
        //是从tail节点出发去遍历找到尾节点
        for (Node<E> t = tail, p = t;;) {
            //获取当前tail节点的下一个节点
            Node<E> q = p.next;
            //如果tail节点下一个为Null就代表此时tail是尾节点了
            //注意,此时是tail节点第二中情况,tail.next为空
            //所以tail节点不变,让新节点直接为tail.next
            if (q == null) {
                // p is last node
                //调用节点本身的CAS方法去进行替换节点
                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".
                    // CAS换tail.next节点成功后
                    // tail节点进行cas更新,让新插入的节点为tail节点
                    if (p != t) // hop two nodes at a time
                        // 并且注意,此时是允许CAS失败的!!!
                        // 所以会引出第一种情况,tail.next不为空!
                        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.
                // 由于可能之前CAS更新tail失败,即有人变过tail
                // 所以tail.next != null了
                // 这种情况就继续遍历下去找到null为即可
                p = (p != t && t != (t = tail)) ? t : q;
        }
    }

看完源码,可以总结其入队流程是怎样的了

对于插入操作的并发,其要对两种情况进行预防,即另外其他线程执行插入与弹出动作

  1. 首先判断插入的值是不是空
    1. 如果为空,抛出空指针异常
    2. 不为空,执行下面的操作
  2. 通过死循环和tail节点去找到真正的尾节点
    1. 如果tail节点下一个节点为null,代表已经找到尾节点
      1. 使用CAS进行改变当前tail.next为新节点
        1. 如果CAS修改tail.next为新节点成功,再尝试CAS修改tail节点为新节点(允许CAS修改tail节点为新节点失败)
        2. 如果失败,代表有其他线程对tail.next进行了修改(入队和出队都有可能影响tail.next),则放弃进行CAS修改tail为新节点,并且进入下一轮循环
    2. 如果此时,tail = tail.next则证明出现了弹出情况(出现这种情况是因为一开始的head和tail是同一个引用,而且出队操作断开与队列的连接采用自引用的方式,一旦出现node == node.next情况,肯定是这个结点被弹出了,所以要重新获取新的tail去进行)
      1. 所以针对这种情况,就要从head进行遍历了,不可以从tail去遍历找到尾节点了,这是因为所有的节点都能从head进行遍历得到
      2. 但在这里并不是都会从head进行遍历,其还会去判断此时有没有别的线程进行插入
        1. 如果tail节点被改过了,证明此时有人插入并且更新了tail节点,下次循环仍然可以从tail节点出发
        2. 如果tail节点没被动过,证明此时没有人插入。那么就从head开始吧
      3. 区区一行代码竟如此复杂。。。
    3. 如果此时,tail.next不为空,那就需要下次循环继续遍历去找到尾节点,这也是为什么在进行新节点替换时是允许失败的!因为会回旋下次进行添加,也就是乐观锁的实现
      1. 注意,在这里也会去检查,在这个过程中tail有无被改动过
      2. 如果tail被改动过,就从新的tail出发(有线程进行入队或出队操作)
      3. 如果tail没被改动过,就从tail.next出发

所以,入队的线程安全保证主要是靠乐观锁与tail节点是否变动来实现

注意:

  • CAS允许失败,采用乐观锁来保证线程安全
  • 从tail节点去找尾节点时,如果此时tail节点不是尾节点,都会去判断tail节点是否变动过,只要变动过就会重新获取tail节点去开始下一轮寻找尾节点(因为变动过就代表有线程进行改变),如果没变过,就从tail.next开始
  • 特殊情况:进入入队方法,但tail被弹出,导致tail == tail.next,此时也会去判断有无线程把tail给改了,如果改了,就可以重新获取tail开始,如果没改,就要从head开始,因为只有从head开始,才能经过所有正常节点来保证线程安全,并且此时tail被弹出是不可以用的且没被其余线程更新。
  • 每次进行插入时,如果插入成功都会去尝试更换tail节点为尾节点,但同时也会允许失败,此时tail节点和尾节点距离为1,因为新插入的节点为尾节点,也就是说,只有tail节点和尾节点距离为1时才会去更换,这样可以减少CAS的操作,假如tail节点距离尾节点距离越远(中间被超级多的线程插队!),那么该线程是会去更新tail节点,而不是进行CAS操作(相比于直接使用乐观锁,进行CAS替换next节点和CAS替换tail节点同时成功才会return true来说,CAS操作减少了非常多,既下面的代码)
public boolean offer(E e){
    if(e != null){
        Node newNode = new Node(e);
        while(true){
            Node t = tail;
            //只有两个CAS操作才会返回true
            if(t.casNext(newNode) && t.casTail(t,newNode)){
                return true;
            }
        }
    }
}

出队操作

看完入队,下面来看下出队是如何的。

出队的对应是poll方法,同样出队列也是如此,并不是每一次出队列都会去改变head节点

  • 如果head节点里有元素,直接弹出里面的元素,而不会更新head节点
  • 如果head节点里没有元素,才会去更新head节点

源码如下

public E poll() {
    //做了一个label标签
    restartFromHead:
    //死循环
    for (;;) {
        //又是一个死循环遍历
        for (Node<E> h = head, p = h, q;;) {
            //获取头节点的值
            E item = p.item;
			//如果head节点的item不为空
            //代表此时队列里里面只有一个head节点
            //尝试CAS更新head节点里面的值为Null
            //并且if判断也是判断当前节点是不是头节点,因为第一个有item的节点就是头节点
            if (item != null && p.casItem(item, null)) {
                // Successful CAS is the linearization point
                // for item to be removed from this queue.
                //如果CAS更新head成功
                if (p != h) // hop two nodes at a time
                    //调用updateHead去更新head节点
                    updateHead(h, ((q = p.next) != null) ? q : p);
                return item;
            }
           //如果头节点里面的值为空,且下一个节点为空
            //证明队列根本没有什么东西可以弹出
            //对头节点进行更新 并且返回空
            else if ((q = p.next) == null) {
                updateHead(h, p);
                return null;
            }
            //如果head节点的item为空,但下一个节点不为Null
            //且下一个节点就是头节点本身,说明head节点很有可能被弹出了
            //所以从头进行开始循环,要从新去获取head节点,所以要跳到最外层的循环
            else if (p == q)
                continue restartFromHead;
            //head节点的item为空,即单纯是一个哨兵节点
            //那就继续next下去找到头节点(第一个item不为空的节点)
            else
                p = q;
        }
    }
}
updateHead方法源码

出队操作涉及到updateHead方法,所以下面就看看这个方法干了什么

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
从注释上,我们也可以看出其实是CAS去更新head节点,并且更新成功后,注意此时p成为了新的head节点(从casHead方法可以看出),然后被弹出的节点是旧的head节点(其实也并不一定是旧的head节点,是新的head节点的上一个节点),并且将旧的head节点的next指向了自己(自引用),这个操作就是断开了旧的head节点与队列的连接

所以,ConcurrentLinkedQueue弹出节点是使用自引用的方式弹出

下面总结一下出队的步骤

  • 定义一个死循环(该死循环用label标签标记,并且该循环的作用是当head节点被弹出后,进行重新获取head节点的)

  • 定义一个死循环,并获取head节点存放在变量中

  • 开始循环找头节点(head并不一定是头节点,当是头节点一定在head节点之后,所以从head节点开始找)

    • 如果当前节点里面有item,那就证明当前节点为头节点

      • 将其item取出,并CAS改为NULL,如果成功CAS将item改为null,那就可以进行弹出了
      • 将head节点进行更新,同样是CAS进行更新,允许更新失败,不过只有更新成功,才会正确将节点弹出,使用自引用关系切除跟队列的联系
      • 返回item值
    • 如果head里面没有item

      • 如果一开始的head就没有next,那就证明队列根本没元素,返回空并更新head
      • 如果有next,但是next还是自己,那就证明有线程在进行弹出,你获取的节点是一个被弹出的节点(原因还是自引用),那么这个节点肯定是不可以操作的, 而且自己还要去重新寻找新的头结点(重新获取head,所以跳到最外的死循环开始重新寻找头结点)
      • 如果有next,且next不是自己,那就证明真正头结点在后面,继续改变next,下一轮循环接上去寻找真正头结点
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值