Java队列的一些设计与实现

344 阅读5分钟

这是我参与11月更文挑战的第12天,活动详情查看:2021最后一次更文挑战

基本队列

队列是一种先进先出的数据结构,其基本操作是:

  1. 出队(add)
  2. 入队(poll)

数组实现

数组实现列表大概思路是维护头指针下标、尾指针下标,add操作尾指针下标后移,poll操作头指针后移。细节是魔鬼,难点在于如何处理边界情况:

  1. 初始时头尾指针的位置放在哪里合适?我们设计的两个操作都是下标后移,所以放在length-1可以用的空间最大。

  2. 当头尾指针一直后移时,到达0时没有空间在add如何处理?很容易想到的方法就是扩容,新建更大容量的数组,复制数据,切换数组。

以上实现方式的一个缺点是,add、poll操作会使两个指针均往左移,没有使用到数组的所有容量便会进行扩容,在不看源码的情况下,我能想到的一种方式是将数组的首尾相接,如此依赖便可以无限左移,知道数组满了才会进行扩容。循环数组的关键点也是边界,对于左移,可以使用下标-1,可以减到负数,获取下标元素可以通过取模size,因为其环形的特点。判断是否达到数组容量可以通过判断头尾指针距离是否大于容量。扩容处理步骤也是相同。

链表实现

链表设计大概同数组,但是链表不会存在数组的问题,因为链表不需要扩容,只需要移动投喂指针即可。 ,,

jdk队列实现

ArrayDeque数组实现

先看了数组实现java.util.ArrayDeque。只需看他的addLast()pollFirst()即可。

image.png

image.png 很明显,属于前面提到的正常思路,收尾指针下标向右侧移动。 这里列举一下ArrayDeque入队出队操作步骤,方便后面分析并发队列为何非线程安全。 入队:

  1. 尾节点设置添加元素
  2. 尾节点前移
  3. 判断是否容量不够
  4. 扩容

出队:

  1. 头节点元素赋值给局部变量
  2. 头节点元素=null
  3. 头节点前移
  4. 返回局部变量

从出队入队步骤中,我们可以看出入队时,步骤1、2非并发安全,,扩容更不用说了,必须卡在这里,数组的所有操作基本需要做同步操作。对于出队,步骤1、3也是非线程安全。

LinkedList列表实现

入队

public void addLast(E e) {
    linkLast(e);
}
void linkLast(E e) {
    final Node<E> l = last;// 1
    final Node<E> newNode = new Node<>(l, e, null);// 2 设置新节点前驱节点
    last = newNode; // 3 设置尾节点=新节点
    if (l == null)
        first = newNode;
    else
        l.next = newNode;// 4 设置前-》后的指向关系
    size++;// 5 ++ 操作
    modCount++;
}
  1. 全局变量尾节点给到局部变量尾节点
  2. 新节点设置前驱节点(双向队列做,单队列次步骤不需要考虑)
  3. 局部尾节点

出队:

private E unlinkFirst(Node<E> f) {
    // assert f == first && f != null;
    final E element = f.item;// 1 获取当前首节点元素赋值给局部变量
    final Node<E> next = f.next;
    f.item = null;
    f.next = null; // help GC
    first = next;// 2 设置首节点=next
    if (next == null)
        last = null;
    else
        next.prev = null;// 3 解除后-》前的指向关系
    size--;// 4 ++操作
    modCount++;
    return element;
}

对于链表而言非并发安全的核心在于链表前后单向指向关系的替换。与数组相比而言,链表少了扩容的开销,所以队列通常用链表进行实现。

并发安全队列

从上面对比数组和链表实现队列对比,并发安全队列都是基于链表进行实现。如何实现并发安全队列呢?一个方法是直接加synchorized。为了更高性能我们来进行下锁优化,优化方向就两个点,一个是能否使用CAS方式,第二个是能否减小锁粒度,只要是在Java编程中,并发优化就这个套路。

先来看看链表队列步骤:

入队:

  1. 创建新节点对象
  2. 获取尾节点
  3. 尾节点.next=新节点
  4. 尾节点=新节点

上面步骤0可以不需要锁同步,按照CAS的套路,步骤1可以不进行CAS,但是步骤2、3需要一起做CAS才行,这个如何实现呢?脑子不够用了,看了下ConcurrentLinkedQueue实现:

image.png

从代码可以看到步骤2、3分别是CAS原子操作,但两个CAS合起来千万不要认为也是原子操作,比如线程1、2同时进入到达步骤2,线程1CAS成功,线程2失败,线程1CAS成功后上下文切换挂起,线程2重新经过循环CAS步骤2成功。然后线程2执行CAS步骤3,此时线程1恢复,在执行CAS步骤3。这个执行结果会导致线程2进行的第二个CAS操作对线程1不可见。对于此处的入队操作,对应的表现为tail节点设置到线程1的新节点,而不是线程2的新节点,显然这个效果并不会对入队功能产生线程安全问题。能想到这个技巧的人,称之为神也不为过。

出队: 0. 获取头节点元素

  1. 头节点指针=步骤0获取的节点

经过入队的操作,我尝试了下出对操作:

void poll() }
    Node h = head;
    while(!cas_head(h,h.next)) {
        h = head;
    }
    return h;
}

写完后在看看ConcurrentLinkedQueue源码: image.png 看了源码发现自己少了一个头节点.item=null的步骤,如果不去掉,元素不会被垃圾回收掉。后面的CAS操作是设置头节点指向。出对相对入队来说比较简单。

遇到的问题

debug 入队方法时候发现一个神奇的问题,第一次执行入队方法的p.casNext(null,newNode)会使尾节点的next指向它本身,并且head节点会指向该入队的新节点,但是代码中没有这个逻辑,不太清楚其原因是什么,之后的入队操作是正常的。 可以见下面的debug图。 image.png