Java 容器类(四)非阻塞队列和优先队列

104 阅读4分钟

Queue本身是一个接口,定义于java.util下,继承了了Collections接口。

public interface Queue<E> extends Collection<E> 

其本身对队列的一系列通用操作做了规范,包括add()、offer()、remove()等等;

Queue本身的方法

方法名称作用错误时回结果
add插入值抛出异常
offer插入值抛出异常
element都是获取但不删除队首元素抛出异常
peek都是获取但不删除队首元素null
remove获取并删除队首元素抛出异常
poll获取并删除队首元素null
remove(o)删除某个和o相等的元素(有多个仅仅删除一个)null

注:

  1. 错误时指的是一个空队列进行调用产生的结果,如【注3】中;
  2. add方法的实现中,调用了offer方法,二者并没有区别;
  3. peek和element错误的示例,remove和poll同理:
PriorityQueue<Integer> queue = new PriorityQueue<>();
System.out.println(queue.peek()); //null
System.out.println(queue.element()); // throw an exception
        

PriorityQueue

简介

PriorityQueue是基于优先堆的一个无界队列,这个优先队列中的元素可以默认自然排序或者通过提供的Comparator比较器在队列实例化时再进行排序,这和TreeSet的排序机制相似,只不过TreeSet内部的实现数据结构是红黑树,而PriorityQueue是利用堆来实现的。

优先队列并不支持空值,而且在通常情况下,不支持不可比较的对象,例如用户自定义的没有实现Comparable接口的类。

优先队列的构造方法如下:

我们可以发现,和TreeMap一样,优先队列也支持手动传入Comparator。

优先队列的大小是不受限制的,但在创建时可以指定初始大小。当我们向优先队列增加元素的时候,队列大小会自动增加。

实现

小(大)顶堆简介

堆实际上是一棵特殊的完全二叉树,小顶堆的定义:根的值小于左右子节点的值的完全二叉树。

对于完全二叉树,我们理所应当地想到一种最为有效的表示方法:数组,因为从ROOT到最后一个叶子结点之间没有空结点的干扰,因此,我们可以将一棵完全二叉树,即堆存入一个数组中,其父结点、左子结点、右子结点的值是具有一定关系的:

left = parent * 2 + 1
right = parent * 2 + 2
root = (current - 1) / 2

通过上述三个公式,可以轻易计算出某个节点的父节点以及子节点的下标。这也就是为什么可以直接用数组来存储堆的原因。

又因为内部的存储方式是数组,所以对于PriorityQueue的peek()和element()操作是常数时间,add(), offer(), 无参数的remove()以及poll()方法的时间复杂度都是log(N)。

堆的相关操作:

堆的创建
  1. 先将所有的元素全部加入堆中;
  2. 从 2分之下标处开始检查Root、Right、Left之间元素大小的关系,不对的则进行换位,直到最小(大)的元素处于Root位置上;
  3. 例如有10个元素,就从下标9 / 2 = 4.5 = 4处开始检查,检查、调整完4后就检查3、2,直到0,检查完成后,最小(大)的元素就位于根上了。
堆的查询、修改

我们每次可以从堆顶上取出最小或者是最大的元素。因此,堆的Peek方法具有O(1)的时间复杂度,但是一旦我们对堆进行删除、添加,那么就要重新执行调整算法,将一个堆重新进行调整成大(小)顶堆。

ConcurrentLinkedQueue

在多线程开发时,可能在某个狭小的时隙会有多个线程对同一个数据结构进行访问,如果各自都是同时读取那么不会有什么问题;但是只要有一个人在写,那么可能会导致不同时间点的读取并不一致;两个人同时写,那么大概率会导致数据的错误。

面对这种同步问题,我们通常会想到一种解决办法:在有人写时阻塞掉其他读/写进程。这种方式就是阻塞算法。

而非阻塞方法,就是CAS方法。ConcurrentLinkedQueue就采用了CAS方法。

Synchronized 机制和CAS 机制(乐观锁和悲观锁)

Synchronized是Java中的一个关键字,被用来修饰类成员方法、静态方法或者是某段的代码块,一旦一个被Synchronized修饰的资源被一个线程所持有,其他期望得到该资源的线程就会被立即挂起,这种加锁方式成为悲观锁

而CAS本意是“Compare And Swap”:比较并替换,是另一种思路:每次不进行加锁,而是假设没有冲突去完成某项操作。所以在数据进行提交更新的时候,会有三个变量:内存地址A、原来的值B、要修改的值C。在提交时,要检查现在在内存地址A的值B1,是否和原来的值B相等,如果不相同,那么说明有其他的线程修改过这个值了,这样一来就产生了冲突;如果相同,那么就说明没有产生冲突,可以直接写入。在该线程检查到发生冲突后,只能再次读取内存地址A的值,计算,产生新的要修改的值C1,然后再走上面的比较、判断冲突步骤,这个重新尝试的过程称为自旋

对于一大段数据说,悲观锁一旦一人访问,那么整个数据加锁,这样不利于线程的并发执行;而乐观锁可能产生冲突要不断地自旋,消耗CPU资源,在高并发的场景下并不合适,另外乐观锁通常只能对一个资源(变量)进行加锁,如果要对3个变量施加原子性的更新,那么就不得不用Synchronized了。

ConcurrentLinkedQueue 构成

ConcurrentLinkedQueue由一个Head节点和Tail节点构成,每个节点Node类型由item变量和next指针域构成。那么ConcurrentLinkedQueue 是如保证同步的呢?

入队

我们对add()方法进行操作,其中直接Call了Offer方法,在Offer中:

   public boolean offer(E e) {
    final Node<E> newNode = new Node<E>(Objects.requireNonNull(e));

    for (Node<E> t = tail, p = t;;) {
        Node<E> q = p.next;
        // 如果q是最后一个节点,说明p是tail(fast和slow指针)
        if (q == null) {
            // 对p进行CAS检查,p的后面是否为null,还有要修改的值newNode
            if (NEXT.compareAndSet(p, null, newNode)) {
	            //首次添加时,p 等于t,不进行尾节点更新,所以所尾节点存在滞后性  
                //并发环境,可能存添加/删除,tail就更难保证正确指向最后节点。
	            if (p != t)
                    TAIL.weakCompareAndSet(this, t, newNode);
                return true;
            }
        }
		//当tail不执行最后节点时,如果执行出列操作,很有可能将tail也给移除了    
        //此时需要对tail节点进行复位,复位到head节点
        else if (p == q)
            p = (t != (t = tail)) ? t : head;
        else
            //推动tail尾节点往队尾移动
            p = (p != t && t != (t = tail)) ? t : q;
    }
}

我们发现:

 for (Node<E> t = tail, p = t;;) 

这是一个死循环,只能在for循环体内条件跳出,而且,只在一个地方提供了return true,循环体实际上在对q和p的值进行轮询,直到符合原子性操作就执行并return掉;不符合地话就不断地自旋。

对ConcurrentLinkedQueue 的入列线程安全具体可以区分为两种:

一. 一读一写

ConcurrentLinkedQueue 遍历是线程不安全的, 线程1遍历,线程2很有可能进行入列出列操作, 所以ConcurrentLinkedQueue 的size是变化。换句话说,要想安全遍历ConcurrentLinkedQueue 队列,必须额外加锁。

二. 双写

线程1,线程2不管在offer哪个位置开始并发,他们最终的目的都是入列,也即都需要执行casNext方法, 我们只需要确保所有线程都有机会执行casNext方法,并且保证casNext方法是原子操作即可。casNext失败的线程,可以进入下一轮循环,人品好的话就可以入列,衰的话继续循环

入队总结起来可以概括为几步:

  1. 本线程准备加入的元素当成最后一个节点;
  2. 更新tail,但是在多线程环境中,如果另一个线程抢先更新了队列,进行了一个入队操作,那么此时方法体内的tail指针将不会再在指向最后一个元素了,所以就产生了tail的不同情况:(1)tail的下一个节点为空,这种情况下是无冲突的情况,直接插入即可;
    (2)tail的下一个节点非空,在这种情况下,说明有其他线程修改了tail节点,此时的tail已经不再是队尾了,这种情况下我们应该重新找队尾,进行CAS判断,是否是队尾进行插入(即自旋),直到插入成功;

出队

首先要提一下:

1.“删除节点是将item设置为null, 队列迭代时跳过item为null节点。”
2.head和tail具有滞后性,head不一定是头结点,而tail也不一定是尾节点。

出队操作和入队操作类似,通过CAS + 自旋来保证非阻塞队列的正常工作。

首先获取head节点的元素,并判断head节点元素是否为空,如果为空,表示另外一个线程已经进行了一次出队操作将该节点的元素取走;如果不为空,则使用CAS的方式将head节点的引用设置成null,如果CAS成功,则直接返 回head节点的元素,如果CAS不成功,表示另外一个线程已经进行了一次出队操作更新了head节点,导致元素发生了变化,需要重新获取head节点。

如果p节点的下一个节点为null,则说明这个队列为空(此时队列没有元素,只有一个伪结点p),则更新head节点。

ConcurrentLinkedQueue 小结

  1. 入列出列线程安全,遍历不安全
  2. 不允许添加null元素
  3. 底层使用列表与cas算法包装入列出列安全

参考来自

  1. Java:CAS(乐观锁)
  2. 并发容器-ConcurrentLinkedQueue详解
  3. Java并发编程(七)ConcurrentLinkedQueue的实现原理和源码分析