Java并发47:并发集合系列-基于CAS算法的非阻塞单向无界队列ConcurrentLinkedQueue
一、 前言
常用的并发队列有阻塞队列和非阻塞队列。
前者使用锁实现,后者则使用CAS非阻塞算法实现,使用非阻塞队列一般性能比较好。
下面就看看常用的非阻塞ConcurrentLinkedQueue是如何使用CAS实现的。
二、 ConcurrentLinkedQueue类图结构
如图ConcurrentLinkedQueue中有两个volatile类型的Node节点分别用来存在列表的首尾节点。
其中head节点存放链表第一个item为null的节点,tail则并不是总指向最后一个节点。
Node节点内部则维护一个变量item用来存放节点的值,next用来存放下一个节点,从而链接为一个单向无界列表。
public ConcurrentLinkedQueue() { head = tail = new Node<E>(null); }
如上代码初始化时候会构建一个item为NULL的空节点作为链表的首尾节点。
三、offer操作
offer操作是在链表末尾添加一个元素,下面看看实现原理。
public boolean offer(E e) { //e为null则抛出空指针异常 checkNotNull(e); //构造Node节点构造函数内部调用unsafe.putObject,后面统一讲 final Node<E> newNode = new Node<E>(e); //从尾节点插入 for (Node<E> t = tail, p = t;;) { Node<E> q = p.next; //如果q=null说明p是尾节点则插入 if (q == null) { //cas插入(1) if (p.casNext(null, newNode)) { //cas成功说明新增节点已经被放入链表,然后设置当前尾节点(包含head,1,3,5.。。个节点为尾节点) 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)//(2) //多线程操作时候,由于poll时候会把老的head变为自引用,然后head的next变为新head,所以这里需要 //重新找新的head,因为新的head后面的节点才是激活的节点 p = (t != (t = tail)) ? t : head; else // 寻找尾节点(3) p = (p != t && t != (t = tail)) ? t : q; } }
四、 add操作
add操作是在链表末尾添加一个元素,其实内部调用的还是offer,下面看看实现原理:
public boolean add(E e) { return offer(e); }
poll操作
poll操作是在链表头部获取并且移除一个元素,下面看看实现原理。
public E poll() { restartFromHead: //死循环 for (;;) { //死循环 for (Node<E> h = head, p = h, q;;) { //保存当前节点值 E item = p.item; //当前节点有值则cas变为null(1) if (item != null && p.casItem(item, null)) { //cas成功标志当前节点以及从链表中移除 if (p != h) // 类似tail间隔2设置一次头节点(2) updateHead(h, ((q = p.next) != null) ? q : p); return item; } //当前队列为空则返回null(3) else if ((q = p.next) == null) { updateHead(h, p); return null; } //自引用了,则重新找新的队列头节点(4) else if (p == q) continue restartFromHead; else//(5) p = q; } } } final void updateHead(Node<E> h, Node<E> p) { if (h != p && casHead(h, p)) h.lazySetNext(h); }
六、peek操作
peek操作是获取链表头部一个元素(只读取不移除),下面看看实现原理。
代码与poll类似,只是少了castItem。
并且peek操作会改变head指向,offer后head指向哨兵节点,第一次peek后head会指向第一个真的节点元素。
public E peek() { restartFromHead: for (;;) { for (Node<E> h = head, p = h, q;;) { E item = p.item; if (item != null || (q = p.next) == null) { updateHead(h, p); return item; } else if (p == q) continue restartFromHead; else p = q; } } }
七、size操作
size获取当前队列元素个数,在并发环境下不是很有用。
因为使用CAS没有加锁所以从调用size函数到返回结果期间有可能增删元素,导致统计的元素个数不精确。
public int size() { int count = 0; for (Node<E> p = first(); p != null; p = succ(p)) if (p.item != null) // 最大返回Integer.MAX_VALUE if (++count == Integer.MAX_VALUE) break; return count; } //获取第一个队列元素(哨兵元素不算),没有则为null Node<E> first() { restartFromHead: for (;;) { for (Node<E> h = head, p = h, q;;) { boolean hasItem = (p.item != null); if (hasItem || (q = p.next) == null) { updateHead(h, p); return hasItem ? p : null; } else if (p == q) continue restartFromHead; else p = q; } } } //获取当前节点的next元素,如果是自引入节点则返回真正头节点 final Node<E> succ(Node<E> p) { Node<E> next = p.next; return (p == next) ? head : next; }
八、remove操作
如果队列里面存在该元素则删除给元素,如果存在多个则删除第一个,并返回true,否者返回false。
public boolean remove(Object o) { //查找元素为空,直接返回false if (o == null) return false; Node<E> pred = null; for (Node<E> p = first(); p != null; p = succ(p)) { E item = p.item; //相等则使用cas值null,同时一个线程成功,失败的线程循环查找队列中其他元素是否有匹配的。 if (item != null && o.equals(item) && p.casItem(item, null)) { //获取next元素 Node<E> next = succ(p); //如果有前驱节点,并且next不为空则链接前驱节点到next, if (pred != null && next != null) pred.casNext(p, next); return true; } pred = p; } return false; }
九、contains操作
判断队列里面是否含有指定对象,由于是遍历整个队列,所以类似size 不是那么精确。
有可能调用该方法时候元素还在队列里面,但是遍历过程中才把该元素删除了,那么就会返回false.
public boolean contains(Object o) { if (o == null) return false; for (Node<E> p = first(); p != null; p = succ(p)) { E item = p.item; if (item != null && o.equals(item)) return true; } return false; }
十、开源框架中使用
Tomcat中NioEndPoint中的每个poller里面就维护一个ConcurrentLinkedQueue用来作为缓冲存放任务。
10.1 Acceptor线程
accept线程作用是接受客户端发来的连接请求并放入到事件队列。
看下代码:
protected class Acceptor extends AbstractEndpoint.Acceptor { @Override public void run() { int errorDelay = 0; // 一直循环直到接收到shutdown命令 while (running) { ... if (!running) { break; } state = AcceptorState.RUNNING; try { //如果达到max connections个请求则等待 countUpOrAwaitConnection(); SocketChannel socket = null; try { // 从TCP缓存获取一个完成三次握手的套接字,没有则阻塞 // socket socket = serverSock.accept(); } catch (IOException ioe) { ... } // Successful accept, reset the error delay errorDelay = 0; if (running && !paused) { if (!setSocketOptions(socket)) { countDownConnection(); closeSocket(socket); } } else { countDownConnection(); closeSocket(socket); } .... } catch (SocketTimeoutException sx) { // Ignore: Normal condition .... } state = AcceptorState.ENDED; } } protected boolean setSocketOptions(SocketChannel socket) { // Process the connection try { //disable blocking, APR style, we are gonna be polling it ... getPoller0().register(channel); } catch (Throwable t) { ... return false; } return true; } public void register(final NioChannel socket) { ... addEvent(r); } public void addEvent(Runnable event) { events.offer(event); ... }
10.2 Poll线程
poll线程作用是从事件队列里面获取事件把链接套接字加入selector,并且监听socket事件进行处理。
看下代码:
public void run() { while (true) { try { ... if (close) { ... } else { hasEvents = events(); } try { ... } catch ( NullPointerException x ) {... } Iterator<SelectionKey> iterator = keyCount > 0 ? selector.selectedKeys().iterator() : null; // 遍历所有注册的channel对感兴趣的事件处理 while (iterator != null && iterator.hasNext()) { SelectionKey sk = iterator.next(); KeyAttachment attachment = (KeyAttachment)sk.attachment(); if (attachment == null) { iterator.remove(); } else { attachment.access(); iterator.remove(); processKey(sk, attachment); } }//while //process timeouts timeout(keyCount,hasEvents); if ( oomParachute > 0 && oomParachuteData == null ) checkParachute(); } catch (OutOfMemoryError oom) { ... } }//while synchronized (this) { this.notifyAll(); } stopLatch.countDown(); }
public boolean events() { boolean result = false; //从队列获取任务并执行 Runnable r = null; while ( (r = events.poll()) != null ) { result = true; try { r.run(); if ( r instanceof PollerEvent ) { ((PollerEvent)r).reset(); eventCache.offer((PollerEvent)r); } } catch ( Throwable x ) { log.error("",x); } } return result; } //如配置线程池则请求交给线程池处理。 public boolean processSocket(NioChannel socket, SocketStatus status, boolean dispatch) { try { KeyAttachment attachment = (KeyAttachment)socket.getAttachment(); if (attachment == null) { return false; } attachment.setCometNotify(false); //will get reset upon next reg SocketProcessor sc = processorCache.poll(); if ( sc == null ) sc = new SocketProcessor(socket,status); else sc.reset(socket,status); if ( dispatch && getExecutor()!=null ) getExecutor().execute(sc); else sc.run(); } catch (RejectedExecutionException rx) { ... } return true; }
十一、有意思的问题
10.1 一个判断的执行结果分析
offer中有个 判断 t != (t = tail)
。
假如 t=node1;tail=node2;并且node1!=node2那么这个判断是true还是false?答案是true。
这个判断是看当前t是不是和tail相等,相等则返回false否者为true,但是无论结果是啥执行后t的值都是tail。
下面从字节码来分析下为啥?
一个例子
public static void main(String[] args) { int t = 2; int tail = 3; System.out.println(t != (t = tail)); }
结果为:true
10.2 Node的构造函数
另外对于每个节点Node在构造时候使用UnSafe.putObject设置item替代了直接对volatile的赋值,这个是为了性能考虑?
为啥不直接赋值那,看看类注解怎么说:
Node(E item) { UNSAFE.putObject(this, itemOffset, item); }
When constructing a Node (before enqueuing it) we avoid paying for a volatile write to item by using Unsafe.putObject instead of a normal write. This allows the cost of enqueue to be”one-and-a-half” CASes.
也就是说当构造Node节点时候(这时候节点还没有放入队列链表)为了避免正常的写volatile变量的开销 使用了Unsafe.putObject代替。
这使元素进队列仅仅花费1.5个cas操作的耗时。
这个是说使用Unsafe.putObject比直接给volatile变量赋值更高效?目前还没有查到相关资料。
十二、总结
ConcurrentLinkedQueue使用CAS非阻塞算法实现使用CAS解决了当前节点与next节点之间的安全链接和对当前节点值的赋值。
由于使用CAS没有使用锁,所以size和contains的时候有可能进行offer,poll或者remove操作,导致获取的元素个数不精确,所以在并发情况下size和contains函数不是很有用。
另外第一次peek或者first时候会把head指向第一个真正的队列元素。
下面总结下如何实现线程安全的,可知入队出队函数都是操作volatile变量:head,tail。
所以要保证队列线程安全只需要保证对这两个Node操作的可见性和原子性,由于volatile本身保证可见性,所以只需要看下多线程下如果保证对着两个变量操作的原子性。
对于offer操作是在tail后面添加元素,也就是调用tail.casNext方法,而这个方法是使用的CAS操作,只有一个线程会成功,然后失败的线程会循环一下,重新获取tail,然后执行casNext方法。对于poll也是这样的。