Java并发-队列原理剖析

JDK中提供的并发安全队列总的来说可以分为阻塞队列和非阻塞队列,前者使用锁实现,而后者则使用CAS非阻塞算法实现。根据队列是否有长度限制还可以分为有界队列和无界队列。

LinkedBlockingQueue

LinkedBlockingQueue是由链表实现的一个阻塞队列,其内部通过锁来保证多线程的并发安全性,根据使用的构造函数不同,LinkedBlockingQueue可以是有界队列,也可以是无界队列。

// 使用无参构造函数的时候,默认会传入Integer.MAX_VALUE,此时队列是无界的
public LinkedBlockingQueue() {
    this(Integer.MAX_VALUE);
}

// 当我们给构造函数传入一个长度值时,此时就会有长度限制,为有界队列
public LinkedBlockingQueue(int capacity) {
    if (capacity <= 0) throw new IllegalArgumentException();
    this.capacity = capacity;
    last = head = new Node<E>(null);
}

属性

LinkedBlockingQueue的类结构图如下所示:
在这里插入图片描述
比较重要的属性有:

// 队列最大的长度限制
private final int capacity;
// 原子变量,记录队列元素的个数
// 这里用原子变量是因为LinkedBlockingQueue添加和移除是可以并行进行的,必须通过原子变量才可以保证数目的正确性
private final AtomicInteger count = new AtomicInteger();
// 队首元素
transient Node<E> head;
// 队尾元素
transient Node<E> last;
// 在移除元素的时候,所有线程都要来竞争这个锁
private final ReentrantLock takeLock = new ReentrantLock();
// 这是一个takeLock的条件队列,在队列为空的时候,如果使用的是take方法是会阻塞的,此时就会通过这个Condition进行阻塞,在队列不为空的时候再通过这个Condition唤醒线程。
private final Condition notEmpty = takeLock.newCondition();
// 在添加元素的时候,所有线程都要来竞争这个锁
private final ReentrantLock putLock = new ReentrantLock();
// 这是一个putLock的条件队列,在队列满的时候,如果使用的是put方法插入元素是会阻塞的,此时就会通过这个Condition进行阻塞,在队列不满的时候再通过这个Condition唤醒线程。
private final Condition notFull = putLock.newCondition();

当调用线程在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锁,否则会抛出llegalMonitorStateException异常。notFull 内部则维护着一个条件队列,当线程获取到putLock锁后调用notFull的await方法时,调用线程会被阻塞,然后该线程会被放到notFull 内部的条件队列进行等待,直到有线程调用了notFull的signal方法。

LinkedBlockingQueue原理

offer

一个线程调用offer去插入元素,首先要去获得putLock,并且是不会响应中断的。

public boolean offer(E e) {
    // 插入元素为null,直接报异常
    if (e == null) throw new NullPointerException();
    // 获得当前队列中元素的个数
    final AtomicInteger count = this.count;
    // 队列元素已经达到上限,直接返回false,添加失败,这里不会去阻塞
    if (count.get() == capacity)
        return false;
    int c = -1;
    // 将元素包装成node
    Node<E> node = new Node<E>(e);
    final ReentrantLock putLock = this.putLock;
    // 竞争锁,这里用了lock,不会响应中断
    putLock.lock();
    try {
        // 再次判断没有超过长度限制
        if (count.get() < capacity) {
            // 执行入队操作
            enqueue(node);
            // 元素的个数加一
            c = count.getAndIncrement();
            // getAndIncrement返回的是加一之前的长度,所以当前长度还是要加一,如果当前长度没有达到最大长度限制,就要唤醒那些调用了put阻塞的线程
            if (c + 1 < capacity)
                notFull.signal();
        }
    } finally {
        putLock.unlock();
    }
    if (c == 0)
        // 通知所有因为take阻塞的线程
        signalNotEmpty();
    return c >= 0;
}

put

put和offer有区别的地方在于加锁的方法和队列满时是否会阻塞,offer加锁不可以中断,而且队列满的时候直接返回false,而put加锁可以中断,队列满时会阻塞。

public void put(E e) throws InterruptedException {
    if (e == null) throw new NullPointerException();
    int c = -1;
    Node<E> node = new Node<E>(e);
    final ReentrantLock putLock = this.putLock;
    final AtomicInteger count = this.count;
    // 这里要获得putLock,用的是lockInterruptibly可以响应中断
    putLock.lockInterruptibly();
    try {
        // 如果此时队列是满的,就要使用notFull阻塞当前线程,这里需要循环判断条件,是防止虚假唤醒。
        while (count.get() == capacity) {
            notFull.await();
        }
        enqueue(node);
        c = count.getAndIncrement();
        if (c + 1 < capacity)
            notFull.signal();
    } finally {
        putLock.unlock();
    }
    if (c == 0)
        signalNotEmpty();
}

poll

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

public E poll() {
    final AtomicInteger count = this.count;
    // 如果当前队列为空,直接返回null
    if (count.get() == 0)
        return null;
    E x = null;
    int c = -1;
    final ReentrantLock takeLock = this.takeLock;
    // 竞争takeLock,保证同一个时刻只有一个线程可以从队列中获取元素,不响应中断
    takeLock.lock();
    try {
        // 再一次判断是否有元素,因为在上次判断和获得锁之间可能还有其他线程进行可出队操作
        if (count.get() > 0) {
            // 出队
            x = dequeue();
            // 数量减一
            c = count.getAndDecrement();
            if (c > 1)
                // 如果此时还有元素,唤醒其他线程take阻塞的线程
                notEmpty.signal();
        }
    } finally {
        takeLock.unlock();
    }
    if (c == capacity)
        // 唤醒put阻塞的线程
        signalNotFull();
    return x;
}

take

take和poll的区别只是在加锁方式上,take可以响应中断,队列是空的时候也会阻塞。

public E take() throws InterruptedException {
    E x;
    int c = -1;
    final AtomicInteger count = this.count;
    final ReentrantLock takeLock = this.takeLock;
    // 竞争锁,响应中断
    takeLock.lockInterruptibly();
    try {
        // 如果队列是空的,就阻塞,等到队列有元素的时候在唤醒
        while (count.get() == 0) {
            notEmpty.await();
        }
        x = dequeue();
        c = count.getAndDecrement();
        if (c > 1)
            notEmpty.signal();
    } finally {
        takeLock.unlock();
    }
    if (c == capacity)
        signalNotFull();
    return x;
}

remove

remove设计队列结构的变化,所以要保证putLock和takeLock都要获取到,保证这期间没有插入和获取。

public boolean remove(Object o) {
    if (o == null) return false;
    // 加锁
    fullyLock();
    try {
        // 找到对应的元素,并移除
        for (Node<E> trail = head, p = trail.next;
             p != null;
             trail = p, p = p.next) {
            if (o.equals(p.item)) {
                unlink(p, trail);
                return true;
            }
        }
        return false;
    } finally {
        fullyUnlock();
    }
}

void fullyLock() {
    putLock.lock();
    takeLock.lock();
}

void fullyUnlock() {
    takeLock.unlock();
    putLock.unlock();
}

小结

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

ArrayBlockingQueue

ArrayBlockingQueue是一个有界阻塞队列,其内部通过数组来实现,在构建ArrayBlockingQueue的时候必须要传入队列的长度。

public ArrayBlockingQueue(int capacity) {
    this(capacity, false);
}

public ArrayBlockingQueue(int capacity, boolean fair) {
    if (capacity <= 0)
        throw new IllegalArgumentException();
    this.items = new Object[capacity];
    // 如果调用无参构造函数,那么默认是非公平的锁
    lock = new ReentrantLock(fair);
    // notEmpty和notFull都是属于lock的Condition队列
    notEmpty = lock.newCondition();
    notFull =  lock.newCondition();
}

属性

ArrayBlockingQueue的类结构图如下所示:
在这里插入图片描述
比较重要的属性有:

// 存放具体的元素
final Object[] items;
// 获取元素的下标
int takeIndex;
// 插入元素的下标
int putIndex;
// 队列中元素的数量
int count;
// 独占锁,队列发生操作时都要获得该锁
final ReentrantLock lock;
// 在队列为空的时候,如果使用的是take方法是会阻塞的,此时就会通过这个Condition进行阻塞,在队列不为空的时候再通过这个Condition唤醒线程。
private final Condition notEmpty;
// 在队列满的时候,如果使用的是put方法插入元素是会阻塞的,此时就会通过这个Condition进行阻塞,在队列不满的时候再通过这个Condition唤醒线程。
private final Condition notFull;

putindex变量表示入队元素下标,takeIndex是出队下标,count统计队列元素个数,这三个属性只是普通的int类型,并且没有使用volatile修饰,因为对这些变量的修改都是在获得了独占锁的情况下进行的,而加锁已经保证了锁块内变量的内存可见性了。另外有个独占锁lock用来保证出、入队操作的原子性,这保证了同时只有一个线程可以进行入队、出队操作。

ArrayBlockingQueue原理

offer

往队列中插入一个元素,如果队列满了,不会阻塞,会直接返回,不会响应中断。

public boolean offer(E e) {
    checkNotNull(e);
    final ReentrantLock lock = this.lock;
    // 获得锁
    lock.lock();
    try {
        // 如果队列满了,直接返回false
        if (count == items.length)
            return false;
        else {
            // 入队
            enqueue(e);
            return true;
        }
    } finally {
        lock.unlock();
    }
}

put

往队列中插入一个元素,如果队列满了,就阻塞,直到队列不满被唤醒,会响应中断。

public void put(E e) throws InterruptedException {
    checkNotNull(e);
    final ReentrantLock lock = this.lock;
    // 获得锁,可以响应中断
    lock.lockInterruptibly();
    try {
        // 如果队列满了,就阻塞,这里循环判断条件是防止虚假唤醒
        while (count == items.length)
            notFull.await();
        enqueue(e);
    } finally {
        lock.unlock();
    }
}

poll

poll在队列是空的时候,会直接返回null,不会阻塞,加锁不会响应中断。

public E poll() {
    final ReentrantLock lock = this.lock;
    // 加锁
    lock.lock();
    try {
        return (count == 0) ? null : dequeue();
    } finally {
        lock.unlock();
    }
}

take

take在队列为空的时候会阻塞,并且加锁会响应中断。

public E take() throws InterruptedException {
    final ReentrantLock lock = this.lock;
    // 获得锁,可以响应中断
    lock.lockInterruptibly();
    try {
        // 如果队列是空的,就阻塞,这里循环判断条件是防止虚假唤醒
        while (count == 0)
            notEmpty.await();
        return dequeue();
    } finally {
        lock.unlock();
    }
}

remove

public boolean remove(Object o) {
    if (o == null) return false;
    final Object[] items = this.items;
    final ReentrantLock lock = this.lock;
    // 加锁
    lock.lock();
    try {
        // 找到并删除元素
        if (count > 0) {
            final int putIndex = this.putIndex;
            int i = takeIndex;
            do {
                if (o.equals(items[i])) {
                    removeAt(i);
                    return true;
                }
                if (++i == items.length)
                    i = 0;
            } while (i != putIndex);
        }
        return false;
    } finally {
        lock.unlock();
    }
}

小结

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

ArrayBlockingQueue和LinkedBlockingQueue区别

  1. 队列大小有所不同,ArrayBlockingQueue是有界的初始化必须指定大小,而LinkedBlockingQueue可以是有界的也可以是无界的(Integer.MAX_VALUE),对于后者而言,当添加速度大于移除速度时,在无界的情况下,可能会造成内存溢出等问题。
  2. 数据存储容器不同,ArrayBlockingQueue采用的是数组作为数据存储容器,而LinkedBlockingQueue采用的则是以Node节点作为连接对象的链表。
  3. 由于ArrayBlockingQueue采用的是数组的存储容器,因此在插入或删除元素时不会产生或销毁任何额外的对象实例,而LinkedBlockingQueue则会生成一个额外的Node对象。这可能在长时间内需要高效并发地处理大批量数据的时,对于GC可能存在较大影响。
  4. 两者的实现队列添加或移除的锁不一样,ArrayBlockingQueue实现的队列中的锁是没有分离的,即添加操作和移除操作采用的同一个ReenterLock锁,而LinkedBlockingQueue实现的队列中的锁是分离的,其添加采用的是putLock,移除采用的则是takeLock,这样能大大提高队列的吞吐量,也意味着在高并发的情况下生产者和消费者可以并行地操作队列中的数据,以此来提高整个队列的并发性能。
    5.两者的size都是强一致的。但是实现有区别,Array使用全局锁,而Linked使用原子变量实现。

其他的阻塞队列

ConcurrentLinkedQueue

ConcurrentLinkedQueue是线程安全的无界非阻塞队列,其底层数据结构使用单向链表实现,对于入队和出队操作使用CAS来实现线程安全。ConcurrentLinkedQueue无论是插入还是获取都是通过CAS保证的,所以是非阻塞的,这样可以减少上下文切换,但是大量CAS自旋操作会占用CPU的资源。
在获取ConcurrentLinkedQueue中元素的个数时,也就是size方法,会去遍历链表,得到一个统计值,因为CAS没有加锁,所以从调用size函数到返回结果期间有可能增删元素,导致统计的元素个数不精确。

PriorityBlockingQueue

PriorityBlockingQueue是带优先级的无界阻塞队列,每次出队都返回优先级最高或者最低的元素。其内部是使用平衡二叉树堆实现的,所以直接遍历队列元素不保证有序。默认使用对象的compareTo方法提供比较规则,如果需要自定义比较规则则可以自定义comparators。
PriorityBlockingQueue内部有一个数组queue,用来存放队列元素,size用来存放队列元素个数。allocationSpinLock是个自旋锁,其使用CAS操作来保证同时只有一个线程可以扩容队列,状态为0或者1,其中0表示当前没有进行扩容,1表示当前正在扩容。
由于PriorityBlockingQueue是一个优先级队列,所以有一个比较器comparator 用来比较元素大小。lock 独占锁对象用来控制同时只能有一个线程可以进行入队、出队操作。notEmpty 条件变量用来实现 take方法阻塞模式。这里没有notFull条件变量是因为这里的put操作是非阻塞的,为啥要设计为非阻塞的,是因为这是无界队列。
PriorityBlockingQueue内部数组默认大小为11,默认比较器为null,也就是使用元素的compareTo方法进行比较来确定元素的优先级,这意味着队列元素必须实现了Comparable接口。

DelayQueue

DelayQucue并发队列是一个无界阻塞延迟队列,队列中的每个元素都有个过期时间,当从队列获取元素时,只有过期元素才会出队列。队列头元素是最快要过期的元素。
DelayQucue内部使用PriorityQueue存放数据,使用ReentrantLock实现线程同步。另外,队列里面的元素要实现 Delayed接口,由于每个元素都有一个过期时间,所以要实现获知当前元素还剩下多少时间就过期了的接口,由于内部使用优先级队列来实现,所以要实现元素之间相互比较的接口。
DelayQucue内部的优先队列通过过期时间进行排序,因此可以保证队列头元素是最快要过期的元素。

DelayQueue判断是否过期

public E poll() {
    final ReentrantLock lock = this.lock;
    // 加锁
    lock.lock();
    try {
        // 获得队头元素,但是不弹出
        E first = q.peek();
        // 通过元素的getDelay获得是否过期,如果是过期的getDelay会返回负数
        if (first == null || first.getDelay(NANOSECONDS) > 0)
            return null;
        else
            // 已经过期,从优先级队列弹出
            return q.poll();
    } finally {
        lock.unlock();
    }
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值