欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

Java并发47:并发集合系列-基于CAS算法的非阻塞单向*队列ConcurrentLinkedQueue

程序员文章站 2022-05-04 10:09:46
...

[超级链接:Java并发学习系列-绪论]
[系列序章:Java并发43:并发集合系列-序章]


原文地址:https://www.jianshu.com/p/9e73b9216322

一、 前言

常用的并发队列有阻塞队列非阻塞队列

前者使用锁实现后者则使用CAS非阻塞算法实现,使用非阻塞队列一般性能比较好

下面就看看常用的非阻塞ConcurrentLinkedQueue是如何使用CAS实现的。

二、 ConcurrentLinkedQueue类图结构

Java并发47:并发集合系列-基于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;
}

十、开源框架中使用

TomcatNioEndPoint中的每个poller里面就维护一个ConcurrentLinkedQueue用来作为缓冲存放任务

10.1 Acceptor线程

accept线程作用是接受客户端发来的连接请求并放入到事件队列
Java并发47:并发集合系列-基于CAS算法的非阻塞单向*队列ConcurrentLinkedQueue

看下代码:

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事件进行处理
Java并发47:并发集合系列-基于CAS算法的非阻塞单向*队列ConcurrentLinkedQueue
看下代码:

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也是这样的。