Java并发包中并发队列

JDK中提供了一系列场景的并发安全队列。按照实现方式的不同可分为阻塞队列和非阻塞队列,前者使用锁实现,后者使用CAS非阻塞算法实现。相比阻塞算法,CAS算法使用CPU资源换取阻塞所带来的开销。

一、ConcurrentLinkedQueue

ConcurrentLinkedQueue是线程安全的无界非阻塞队列,其底层数据结构使用单项链表实现,对于入队和出队操作使用CAS来实现线程安全。

ConcurrentLinkedQueue的类图结构如图7-1所示

ConcurrentLinkedQueue内部的队列使用单向链表方式实现,其中有两个volatile类型的Node节点分别用来存放队列的首、尾节点。从下面的无参构造函数可知,默认头、尾节点都是指向item为null的哨兵节点。新元素会被插入队列末尾,出队时从队列头部获取一个元素。

    public ConcurrentLinkedQueue() {
        head = tail = new Node<E>(null);
    }

在Node节点内部则维护一个使用volatile修饰的变量item,用来存放节点的值;next用来存放链表的下一个节点,从而链接为一个单向无界链表。其内部则使用UNSafe工具类提供的CAS算法来保证出入队是操作链表的原子性。

 

1.offer操作

offer操作是在队列末尾添加一个元素,如果传递的参数是null则抛出NPE异常,否则由于ConcurrentLinkedQueue是无界队列,该方法一直会返回ture。另外,由于使用CAS无阻塞算法,因此该方法不会阻塞挂起调用线程。

2.add操作

add操作是在链表末尾添加一个元素,其实在内部调用的还是offer操作。

3.poll操作

poll操作是在队列头部获取并移除一个元素,如果队列为空则返回null。

总结:poll方法在移除一个元素时,只是简单地使用CAS操作把当前节点的item值设置为null,然后通过重新设置头节点将元素从队列里面移除,被移除的节点就成了孤立节点,这个节点会在垃圾回收时被回收掉。另外,如果在执行分支中发现头节点被修改了,要跳到外层循环重新获取新的头节点。

4.peek操作

peek操作是获取队列头部一个元素(只获取不移除),如果队列为空则返回null。

总结:peek操作的代码与poll操作类似,只是前者只获取队列头元素但是并不从队列里将它删除,而后者获取后需要从队列里面将它删除。另外,在第一次调用peek操作时,会删除哨兵节点,并让队列的head节点指向队列里面第一个元素或者null。

5.size操作

计算当前队列元素个数,在并发环境下不是很有用,因为CAS没有加锁,所以从调用size函数到返回结果期间有可能增删元素,导致统计的元素个数不精确。

6.remove操作

如果队列里面存在该元素则删除该元素,如果存在多个则删除第一个,并返回true,否则返回false。

7.contains操作

判断队列里面是否含有指定对象,由于是遍历整个队列,所以像size操作一样结果也不是那么精确,有可能调用该方法时元素还在队列里面,但是遍历过程中其他线程才把该元素删除了,那么就会返回false。

小结:

ConcurrentLinkedQueue的底层使用单向链表数据结构来保存队列元素,每个元素被包装成一个Node节点。队列是靠头、尾节点来维护的,创建队列时头、尾节点指向一个item为null的哨兵节点。第一次执行peek或者first操作时会把head指向第一个真正的队列元素。由于使用非阻塞CAS算法,没有加锁,所以在计算size时有可能进行了offer、poll或者remove操作,导致计算的元素个数不精确,所以在并发情况下size函数不是很有用。

如图7-27所示,入队、出队都是操作使用volatile修饰的tail、head节点,要保证在多线程下出入队线程安全,只需要保证这两个Node操作的可见性和原子性即可。由于volatile本身可以保证可见性,所以只需要保证对两个变量操作的原子性即可。

offer操作是在tail后面添加元素,也就是调用tail.casNext方法,而这个方法使用的是CAS操作,只有一个线程会成功,然后失败的线程会循环,重新获取tail,再执行casNext方法。poll操作也通过类似CAS的算法保证出队时移除节点操作的原子性。

 

二、LinkedBlockingQueue

LinkedBlockingQueue是使用独占锁实现的阻塞队列。LinkedBlockingQueue的类图如图7-28所示。

 

由类图可以看到,LinkedBlockingQueue也是使用单项链表实现的,其也有两个Node,分别用来存放首、尾节点,并且还有一个初始值为0的原子变量count,用来记录队列元素个数。另外还有两个ReentrantLock的实例,分别用来控制元素入队和出队的原子性,其中takeLock用来控制同时只有一个线程可以从队列头获取元素,其他线程必须等待,putLock控制同时只能有一个线程可以获取锁,在队列尾部添加元素,其他线程必须等待。另外,notEmpty和notFull是条件变量,它们内部都有一个条件队列用来存放进队和出队时被阻塞的线程,其实这是生产者——消费者模型。下面是独占锁的创建代码。

    /** Lock held by take, poll, etc */
    private final ReentrantLock takeLock = new ReentrantLock();

    /** Wait queue for waiting takes */
    private final Condition notEmpty = takeLock.newCondition();

    /** Lock held by put, offer, etc */
    private final ReentrantLock putLock = new ReentrantLock();

    /** Wait queue for waiting puts */
    private final Condition notFull = putLock.newCondition();

    /** Current number of elements */
    private final AtomicInteger count = new AtomicInteger();

当调用线程在LinkedBlockingQueue实例上执行take、poll等操作时需要获取到takeLock锁,从而保证同时只有一个线程可以操作链表头节点。另外由于条件变量notEmpty内部的条件队列的维护使用的是takeLock的锁状态管理机制,所以在调用notEmpty的await和signal方法前调用线程必须先获取到takeLock锁,否则会抛出IllegalMonitorStateException异常。notEmpty内部则维护这一个条件队列,当线程获取到takeLock锁后调用notEmpty的await方法时,调用线程会被阻塞,然后改线程会被放到notEmpty内部的条件队列进行等待,直到有线程调用了notEmpty的signal方法。

在LinkedBlockingQueue实例上执行put、offer等操作时需要获取到putLock锁,从而保证同时只有一个线程可以操作链表尾节点。同样由于条件变量notFull内部的条件队列的维护使用的是putLock的锁状态管理机制,所以在调用notFull的await和signal方法前调用线程必须先获取到putLock锁,否则会抛出IllegalMonitorStateException异常。notFull内部则维护着一个条件队列,当线程获取到putLock锁后调用notFull的await方法时,调用线程会被阻塞,然后该线程会被放到notFull内部的条件队列进行等待,直到有线程调用了notFull的signal方法。

下面是LinkedBlockingQueue的无参构造函数的代码。

 @Native public static final int   MAX_VALUE = 0x7fffffff;

 public LinkedBlockingQueue() {
        this(Integer.MAX_VALUE);
    }

    /**
     * Creates a {@code LinkedBlockingQueue} with the given (fixed) capacity.
     *
     * @param capacity the capacity of this queue
     * @throws IllegalArgumentException if {@code capacity} is not greater
     *         than zero
     */
    public LinkedBlockingQueue(int capacity) {
        if (capacity <= 0) throw new IllegalArgumentException();
        this.capacity = capacity;
        last = head = new Node<E>(null);
    }

由该代码可知,默认队列容量为0x7fffffff,用户也可以自己指定容量,所以从一定程度上可以说LinkedBlockingQueue是有界阻塞队列。

1.offer操作

向队列尾部插入一个元素,如果队列中有空闲则插入成功后返回true,如果队列已满则丢弃当前元素然后返回false。如果e元素为null则抛弃NPE异常。另外,该方法是非阻塞的。

2.put操作

向队列尾部插入一个元素,如果队列中有空闲则插入后直接返回,如果队列已满则阻塞当前线程,直到队列有空闲插入成功后返回。如果在阻塞时被其他线程设置了中断标志,则被阻塞线程会抛出InterruptedException异常而返回。另外,如果e元素为null则抛出NullPointerException异常。

3.poll操作

从队列头部获取并移除一个元素,如果队列为空则返回null,该方法是不阻塞的。

4.peek操作

获取队列头部元素但是不从队列里面移除它,如果队列为空则返回null。该方法是不阻塞的。

5.take操作

获取当前队列头部元素并从队列里面移除它。如果队列为空则阻塞当前线程直到队列不为空然后返回元素,如果在阻塞时被其他线程设置了中断标志,则被阻塞线程会抛出InterruptedException异常而返回。

6.remove操作

删除队列里面指定的元素,有则删除并返回true,没有则返回false。

总结:由于remove方法在删除指定元素前加了两把锁,所以在遍历队列查找指定元素的过程中是线程安全的,并且此时其他调用入队、出队操作的线程全部会被阻塞。另外,获取多个资源锁的顺序与释放的顺序是相反的。

7.size操作

获取当前队列元素个数。

由于进行出队、入队操作时的count是加了锁的,所以结果相比ConcurrentLinkedQueue的size方法比较准确。

小结:

LinkedBlockingQueue的内部是通过单向链表实现的,使用头、尾节点来进行入队和出队操作,也就是入队操作都是对尾节点进行操作,出队操作都是对头节点进行操作。

如图7-29所示,对头、尾节点的操作分别使用了单独的独占锁从而保证了原子性,所以出队和入队操作是可以同时进行的。另外对头、尾节点的独占锁都配备了一个条件队列,用来存放被阻塞的线程,并结合入队、出队操作实现了一个生产消费模型。

三、ArrayBlockingQueue

ArrayBlockingQueue是使用有界数组方式实现的阻塞队列。

ArrayBlockingQueue的类图如图7-30所示。

ArrayBlockingQueue的内部有一个数组items,用来存放队列元素,putIndex变量表示入队元素下标,takeIndex是出队下标,count统计队列元素个数。从定义可知,这些变量并没有使用volatile修饰,这时因为访问这些变量都是在锁块内,而加锁已经保证了锁块内变量的内存可见性了。另外有个独占锁lock用来保证出、入队操作的原子性,这保证了同时只有一个线程可以进行入队、出队操作。另外,notEmpty、notFull条件变量用来进行出、入队的同步。

另外,由于ArrayBlockingQueue是有界队列,所以构造函数必须传入队列大小参数。

1.offer操作

向队列尾部插入一个元素,如果队列有空闲空间则插入成功后返回true,如果队列已满则丢弃当前元素然后返回false。如果e元素为null则抛出NullPointerException异常。另外,该方法是不阻塞的。

2.put操作

向队列尾部插入一个元素,如果队列有空闲则插入后直接返回true,如果队列已满则阻塞当前线程直到队列有空闲并插入成功后返回true,如果在阻塞时被其他线程设置了中断标志,则被阻塞线程会抛出InterruptedException异常返回。另外,如果e元素为null则抛出NullPointerException异常。

3.poll操作

从队列头部获取并移除一个元素,如果队列为空则返回null,该方法是不阻塞的。

4.take操作

获取当前队列头部元素并从队列里面移除它。如果队列为空则阻塞当前线程直到队列不为空然后返回元素,如果在阻塞时被其他线程设置了中断标志,则被阻塞线程会抛出InterruptedException异常返回。

5.peek操作

获取队列头部元素但是不从队列里面移除它,如果队列为空则返回null,该方法是不阻塞的。

6.size操作

计算当前队列元素个数。

小结:

如图7-31所示,ArrayBlockingQueue通过使用全局独占锁实现了同时只能有一个线程进行入队或者出队操作,这个锁的粒度比较大,有点类似于在方法上添加synchronized的意思。其中offer和poll操作通过简单的加锁进行入队、出队操作,而put、take操作则使用条件变量实现了,如果队列满则等待,如果队列空则等待,然后分别在出队和入队操作中发送信号激活等待线程实现同步。另外,相比LinkedBlockingQueue,ArrayBlockingQueue的size操作的结果是精确的,因为计算前加了全局锁。

四、PriorityBlockingQueue

PriorityBlockingQueue是带优先级的无界阻塞队列,每次出队都返回优先级最高或者最低的元素。其内部是使用平衡二叉树堆实现的,所以直接遍历队列元素不保证有序。默认使用对象的compareTo方法提供比较规则,如果你需要自定义比较规则则可以根据自定义comparators。

PriorityBlockingQueue的类图如图7-32所示。

由图7-32可知,PriorityBlockingQueue内部有一个数组queue,用来存放队列元素,size用来存放队列元素个数。allocationSpinLock是个自旋锁,其使用CAS操作来保证同时只有一个线程可以扩容队列,状态为0或者1,其中0表示当前没有进行扩容,1表示正在扩容。

由于这是一个优先级队列,所以有一个比较器comparator用来比较元素大小。lock独占锁对象用来控制同时只能有一个线程可以进行入队、出队操作。notEmpty条件变量用来实现take方法阻塞模式。这里没有notFull条件变量是因为这里的put操作是非阻塞的,为啥要设计为非阻塞的,是因为这是无界队列。

在如下构造函数中,默认队列容量为11,默认比较器为null,也就是使用元素的compareTo方法进行比较来确定元素的优先级,这意味着队列元素必须实现了Compareble接口。

    private static final int DEFAULT_INITIAL_CAPACITY = 11;

    public PriorityBlockingQueue() {
        this(DEFAULT_INITIAL_CAPACITY, null);
    }

    public PriorityBlockingQueue(int initialCapacity) {
        this(initialCapacity, null);
    }

    public PriorityBlockingQueue(int initialCapacity,
                                 Comparator<? super E> comparator) {
        if (initialCapacity < 1)
            throw new IllegalArgumentException();
        this.lock = new ReentrantLock();
        this.notEmpty = lock.newCondition();
        this.comparator = comparator;
        this.queue = new Object[initialCapacity];
    }

1.offer操作

offer操作的作用是在队列中插入一个元素,由于是无界队列,所以一直返回true。

2.poll操作

poll操作的作用是获取队列内部堆树的根节点元素,如果队列为空,则返回null。

3.put操作

put操作内部调用的是offer操作,由于是无界队列,所以不需要阻塞。

4.take操作

take操作的作用是获取队列内部堆树的根节点元素,如果队列为空则阻塞。

5.size操作

计算队列元素个数。如下代码在返回size前加了锁,以保证在调用size()方法时不会有其他线程进行入队和出队操作。另外,由于size变量没有被修饰为volatie的,所以这里加锁也保证了在多线程下size变量的内存可见性。

小结:PriorityBlockingQueue队列在内部使用二叉树堆维护元素优先级,使用数组作为元素存储的数据结构,这个数组是可扩容的。当当前元素个数>=最大容量时会通过CAS算法扩容,出队时始终保证出队的元素是堆树的根节点,而不是在队列里面停留时间最长的元素。使用元素的compareTo方法提供默认的元素优先级比较规则,用户可以自定义优先级的比较规则。

如图7-33所示,PriorityBlockingQueue类似于ArrayBlockingQueue,在内部使用一个独占锁来控制同时只有一个线程可以进行入队和出队操作。另外,前者只使用了一个notEmpty条件变量而没有使用notFull,这时因为前者是无界队列,执行put操作时永远不会处于await状态,所以也不需要被唤醒。而take方法是阻塞方法,而且是可被中断的。当需要存放有优先级的元素时该队列比较有用。

五、DelayQueue

DelayQueue并发队列是一个无界阻塞延迟队列,队列中的每个元素都有个过期时间,当从队列获取元素时,只有过期元素才会出队列。队列头元素是最快要过期的元素。

DelayQueue类图结构如图7-34所示

由该图可知,DelayQueue内部使用PriorityQueue存放数据,使用ReentrantLock实现线程同步。另外,队列里面的元素要实现Delayed接口,由于每个元素都有一个过期时间,所以要实现获知当前元素还剩下多少时间就过期了的接口,由于内部使用优先级队列来实现,所以要实现元素之间相互比较的接口。

public interface Delayed extends Comparable<Delayed> {

    long getDelay(TimeUnit unit);
}

在如下代码中,条件变量available与lock锁是对应的,其目的是为了实现线程间同步。

    private final Condition available = lock.newCondition();

其中leader变量的使用基于Leader-Follower模式的变体,用于尽量减少不必要的线程等待。当一个线程调用队列的take方法变为leader线程后,他会调用条件变量available.awaitNanos(delay)等待delay时间,但是其他线程(follwer线程)则会调用available.await()进行无限等待。leader线程延迟时间过期后,会退出take方法,并通过调用available.signal()方法唤醒一个follwer线程,被唤醒的follwer线程被选举为新的leader线程。

1.offer操作

插入元素到队列,如果插入元素为null则抛出NullPointerException异常,否则由于是无界队列,所以一直返回true。插入元素要实现Delayed接口。

2.take操作

获取并移除队列里面延迟时间过期的元素,如果队列里面没有过期元素则等待。

3.poll操作

获取并移除队头过期元素,如果没有过期元素则返回null。

4.size操作

计算队列元素个数,包含过期的和没有过期的。

小结:本节讲解了DelayQueue队列,其内部使用PriorityQueue存放数据,使用ReentrantLock实现线程同步。另外队列里面的元素要实现Delayed接口,其中一个是获取当前元素到过期剩余时间的接口,在出队时判断元素是否过期了,一个是元素之间比较的接口,因为这时一个有优先级的队列。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值