并发安全的队列实现
ConcurrentLinkedQueue是一个线程安全的队列,队列的并发不安全性其实也就是会出现覆盖的问题
而实现一个线程安全的队列有两种方式去实现,其实总的来说是拥有两种算法
- 一种是阻塞算法,也就是用锁去控制队列的入队和出队,可以是两个锁也可以是一个锁
- 另外一种就是非阻塞算法,采用循环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
入队操作
入队其实就是将入队节点添加到队列的尾部,而入队的操作主要做两件事情
- 将入队节点设置成当前队列尾节点的下一个节点
- 更新tail节点
- 如果tail节点的next节点不为空,则将入队节点设置为tail节点
- 如果tail节点的next节点为空,则将入队节点设置成tail的next节点
- 所以,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;
}
}
看完源码,可以总结其入队流程是怎样的了
对于插入操作的并发,其要对两种情况进行预防,即另外其他线程执行插入与弹出动作
- 首先判断插入的值是不是空
- 如果为空,抛出空指针异常
- 不为空,执行下面的操作
- 通过死循环和tail节点去找到真正的尾节点
- 如果tail节点下一个节点为null,代表已经找到尾节点
- 使用CAS进行改变当前tail.next为新节点
- 如果CAS修改tail.next为新节点成功,再尝试CAS修改tail节点为新节点(允许CAS修改tail节点为新节点失败)
- 如果失败,代表有其他线程对tail.next进行了修改(入队和出队都有可能影响tail.next),则放弃进行CAS修改tail为新节点,并且进入下一轮循环
- 使用CAS进行改变当前tail.next为新节点
- 如果此时,tail = tail.next则证明出现了弹出情况(出现这种情况是因为一开始的head和tail是同一个引用,而且出队操作断开与队列的连接采用自引用的方式,一旦出现node == node.next情况,肯定是这个结点被弹出了,所以要重新获取新的tail去进行)
- 所以针对这种情况,就要从head进行遍历了,不可以从tail去遍历找到尾节点了,这是因为所有的节点都能从head进行遍历得到
- 但在这里并不是都会从head进行遍历,其还会去判断此时有没有别的线程进行插入
- 如果tail节点被改过了,证明此时有人插入并且更新了tail节点,下次循环仍然可以从tail节点出发
- 如果tail节点没被动过,证明此时没有人插入。那么就从head开始吧
- 区区一行代码竟如此复杂。。。
- 如果此时,tail.next不为空,那就需要下次循环继续遍历去找到尾节点,这也是为什么在进行新节点替换时是允许失败的!因为会回旋下次进行添加,也就是乐观锁的实现
- 注意,在这里也会去检查,在这个过程中tail有无被改动过
- 如果tail被改动过,就从新的tail出发(有线程进行入队或出队操作)
- 如果tail没被改动过,就从tail.next出发
- 如果tail节点下一个节点为null,代表已经找到尾节点
所以,入队的线程安全保证主要是靠乐观锁与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,下一轮循环接上去寻找真正头结点
-