Java 并发编程面试题——BlockingQueue

目录

  • 1.什么是阻塞队列 (BlockingQueue)?
  • 2.BlockingQueue 有哪些核心方法?
  • 3.BlockingQueue 有哪些常用的实现类?
    • 3.1.ArrayBlockingQueue
    • 3.2.DelayQueue
    • 3.3.LinkedBlockingQueue
    • 3.4.PriorityBlockingQueue
    • 3.5.SynchronousQueue
  • 4.✨BlockingQueue 的实现原理是什么?
    • 4.1.构造器
    • 4.2.put 操作
    • 4.3.take 操作
  • 5.BlockingQueue 的使用场景有哪些?
    • 5.1.生产者—消费者模式
    • 5.2.线程池中使用阻塞队列
  • 6.✨手动实现一个简单的阻塞队列?

1.什么是阻塞队列 (BlockingQueue)?

BlockingQueuejava.util.concurrent 包下重要的数据结构,与普通的队列不同,BlockingQueue 提供了线程安全的队列访问方式,该包下很多高级同步类的实现都是基于 BlockingQueue 实现的。BlockingQueue ⼀般用于生产者——消费者模式,生产者是往队列里添加元素的线程, 消费者是从队列里取出元素的线程。BlockingQueue 就是存放元素的容器

2.BlockingQueue 有哪些核心方法?

(1)BlockingQueue 是一个接口,继承自 Queue,所以其实现类也可以作为 Queue 的实现来使用,而 Queue 又继承自 Collection 接口。

Java 并发编程面试题——BlockingQueue_第1张图片

(2)BlockingQueue 中的核心方法如下:

方法 \ 处理方式 抛出异常 返回特殊值 一直阻塞 超时退出
插入 add(e) offer(e) put(e) offer(e, time, unit)
移除 remove() poll() take() poll(time, unit)
检查 element() peek() - -
  • 抛出异常:如果试图的操作无法立即执行,抛出异常。当阻塞队列满时候,再往队列里插入元素,会抛出 IllegalStateException(“Queue full”) 异常。当队列为空时,从队列里获取元素时会抛出 NoSuchElementException 异常。
  • 返回特殊值:如果试图的操作无法立即执行,返回⼀个特殊值,通常是 true / false。
  • ⼀直阻塞:如果试图的操作无法立即执行,则⼀直阻塞或者响应中断。
  • 超时退出:如果试图的操作无法立即执行,该⽅法调⽤将会发生阻塞,直到能够执行,但等待时间不会超过给定值。返回⼀个特定值以告知该操作是否成功,通常是 true / false。

注意:
① 不能往阻塞队列中插入 null,否则会抛出空指针异常。
② 可以访问阻塞队列中的任意元素,调用 remove(o) 可以将队列之中的特定对象移除,但并不高效,尽量避免使用。

(3)BlockingQueue 的源码如下:

package java.util.concurrent;

import java.util.Collection;
import java.util.Queue;

public interface BlockingQueue<E> extends Queue<E> {
    
    boolean add(E e);

    boolean offer(E e);

    void put(E e) throws InterruptedException;

    boolean offer(E e, long timeout, TimeUnit unit)
        throws InterruptedException;

    E take() throws InterruptedException;
  
    E poll(long timeout, TimeUnit unit)
        throws InterruptedException;

    int remainingCapacity();

    boolean remove(Object o);

    public boolean contains(Object o);

    int drainTo(Collection<? super E> c);

    int drainTo(Collection<? super E> c, int maxElements);
}

3.BlockingQueue 有哪些常用的实现类?

下面主要介绍 5 个常用的 BlockingQueue 实现类。

在这里插入图片描述

3.1.ArrayBlockingQueue

(1)ArrayBlockingQueue 是由数组结构组成的有界阻塞队列。内部结构是数组,故具有数组的特性。其源码中的构造函数如下:

public class ArrayBlockingQueue<E> extends AbstractQueue<E>
        implements BlockingQueue<E>, java.io.Serializable {
	
	//...

	public ArrayBlockingQueue(int capacity) {
        this(capacity, false);
    }
	
	public ArrayBlockingQueue(int capacity, boolean fair) {
        if (capacity <= 0)
            throw new IllegalArgumentException();
        //...
    }
	
	public ArrayBlockingQueue(int capacity, boolean fair,
                              Collection<? extends E> c) {
        this(capacity, fair);
        //...
    }
}

(2)ArrayBlockingQueue 一旦创建,容量不能改变。其并发控制采用可重入锁 ReentrantLock,不管是插入操作还是读取操作,都需要获取到锁才能进行操作。当队列容量满时,尝试将元素放入队列将导致操作阻塞;尝试从一个空队列中取一个元素也会同样阻塞。

(3)ArrayBlockingQueue 默认情况下不能保证线程访问队列的公平性,所谓公平性是指严格按照线程等待的绝对时间顺序,即最先等待的线程能够最先访问到 ArrayBlockingQueue。而非公平性则是指访问 ArrayBlockingQueue 的顺序不是遵守严格的时间顺序,有可能存在当 ArrayBlockingQueue 可以被访问时,长时间阻塞的线程依然无法访问到 ArrayBlockingQueue 的情况。如果保证公平性,那么通常会降低吞吐量。如果需要获得公平性的 ArrayBlockingQueue,可采用如下代码:

ArrayBlockingQueue<Integer> blockingQueue = new ArrayBlockingQueue<Integer>(10, true);

3.2.DelayQueue

(1)DelayQueue 中的元素只有当其指定的延迟时间到了,才能够从队列中获取到该元素。DelayQueue 的应用场景主要是处理具有延迟需求的任务调度,比如定时任务、缓存过期等等。它提供了一种方便的方式来实现元素的延迟处理。。源码中的构造方法如下所示:

public class DelayQueue<E extends Delayed> extends AbstractQueue<E>
    implements BlockingQueue<E> {
	
	//...	

	public DelayQueue() {}
	
	public DelayQueue(Collection<? extends E> c) {
        this.addAll(c);
    }
}

(2)DelayQueue 的特点如下:

  • 元素按照延迟时间的顺序进行排序,延迟时间越短的元素排在队列的前面。
  • 元素只有在指定的延迟时间到达后才可以从队列中取出。
  • 如果队列中没有到达延迟时间的元素,那么从队列中取元素的操作将会被阻塞,直到有元素到达延迟时间。
  • DelayQueue 是线程安全的,多个线程可以安全地操作同一个 DelayQueue 实例。

(3)DelayQueue 的底层实现原理是基于 PriorityQueue(优先级队列)和 ReentrantLock(可重入锁)实现的。

  • DelayQueue 使用 PriorityQueue 作为其内部数据结构,这是一个基于堆的优先级队列,用于存储元素并按照其延迟时间进行排序。在 PriorityQueue 中,延迟时间越短的元素排在队列的前面。
  • 当元素被插入到 DelayQueue 中时,会根据元素的延迟时间进行排序,并被放置在对应的位置。取出元素时,会检查队列头部的元素是否已经到达了延迟时间,如果还未到达延迟时间,则会阻塞等待。当元素的延迟时间到达后,该元素可以被取出。
  • 为了保证线程安全性和可并发性,DelayQueue 使用了 ReentrantLock 进行同步,确保多个线程可以安全地操作 DelayQueue。
  • 另外,DelayQueue 中的元素需要实现 Delayed 接口,这个接口定义了两个方法:getDelay(TimeUnit unit)compareTo(Delayed o)。前者用于获取当前元素的剩余延迟时间,后者用于比较两个元素的延迟时间大小。这样,DelayQueue就能根据元素的延迟时间进行有序排列。

总结起来,DelayQueue 的底层实现原理是基于 PriorityQueue 和 ReentrantLock。PriorityQueue 用于按照延迟时间对元素进行排序,ReentrantLock 用于保证线程安全性。通过这两者的组合,DelayQueue 可以有效地实现具有延迟需求的任务调度功能。

(4)使用 DelayQueue 的示例如下:

class DelayedElement implements Delayed {
    private String element;
    private long delayTime;

    public DelayedElement(String element, long delayTime) {
        this.element = element;
        this.delayTime = System.currentTimeMillis() + delayTime;
    }

    @Override
    public long getDelay(TimeUnit unit) {
        long remainingTime = delayTime - System.currentTimeMillis();
        return unit.convert(remainingTime, TimeUnit.MILLISECONDS);
    }

    @Override
    public int compareTo(Delayed other) {
        long diff = getDelay(TimeUnit.MILLISECONDS) - other.getDelay(TimeUnit.MILLISECONDS);
        return Long.compare(diff, 0);
    }

    public String getElement() {
        return element;
    }
}

class DelayQueueExample {
    public static void main(String[] args) throws InterruptedException {
        DelayQueue<DelayedElement> delayQueue = new DelayQueue<>();

        //添加延时元素到队列中
        delayQueue.add(new DelayedElement("Element 1", 2000));
        delayQueue.add(new DelayedElement("Element 2", 500));
        delayQueue.add(new DelayedElement("Element 3", 3000));

        //从队列中取出延时元素
        while (!delayQueue.isEmpty()) {
            DelayedElement element = delayQueue.take();
            System.out.println("取出元素:" + element.getElement());
        }
    }
}

3.3.LinkedBlockingQueue

(1)LinkedBlockingQueue 底层基于单向链表实现的阻塞队列,可以当做无界队列也可以当做有界队列来使用,同样满足 FIFO 的特性,与 ArrayBlockingQueue 相比起来具有更高的吞吐量,为了防止 LinkedBlockingQueue 容量迅速增,损耗大量内存。通常在创建 LinkedBlockingQueue 对象时,会指定其大小,如果未指定,容量等于 Integer.MAX_VALUE

(2)源码中的构造方法如下所示:

public class LinkedBlockingDeque<E>
    extends AbstractQueue<E>
    implements BlockingDeque<E>, java.io.Serializable {
	
	//...

	public LinkedBlockingDeque() {
        this(Integer.MAX_VALUE);
    }
	
	public LinkedBlockingDeque(int capacity) {
        if (capacity <= 0) throw new IllegalArgumentException();
        this.capacity = capacity;
    }

	public LinkedBlockingDeque(Collection<? extends E> c) {
        this(Integer.MAX_VALUE);
        //...
    }
}

3.4.PriorityBlockingQueue

(1)PriorityBlockingQueue 是一个支持优先级的无界阻塞队列。默认情况下元素采用自然顺序进行排序,也可以通过自定义类实现 compareTo() 方法来指定元素排序规则,或者初始化时通过构造器参数 Comparator 来指定排序规则。

(2)PriorityBlockingQueue 并发控制采用的是可重入锁 ReentrantLock,队列为无界队列(ArrayBlockingQueue 是有界队列,LinkedBlockingQueue 也可以通过在构造函数中传入 capacity 指定队列最大的容量,但是 PriorityBlockingQueue 只能指定初始的队列大小,后面插入元素的时候,如果空间不够的话会自动扩容)。简单地说,它就是 PriorityQueue 的线程安全版本。不可以插入 null 值,同时,插入队列的对象必须是可比较大小的 (comparable),否则报 ClassCastException 异常。它的插入操作 put 方法不会 block,因为它是无界队列(take 方法在队列为空的时候会阻塞)。

(3)源码中的构造方法如下所示:

public class PriorityBlockingQueue<E> extends AbstractQueue<E>
    implements BlockingQueue<E>, java.io.Serializable {
	
	//...

	public PriorityBlockingQueue() {
        this(DEFAULT_INITIAL_CAPACITY, null);
    }
	
	public PriorityBlockingQueue(int initialCapacity) {
        this(initialCapacity, null);
    }	
	
	public PriorityBlockingQueue(int initialCapacity,
                                 Comparator<? super E> comparator) {
        //...
    }
	
	public PriorityBlockingQueue(Collection<? extends E> c) {
        //...
    }
}

3.5.SynchronousQueue

SynchronousQueue 这个队列比较特殊,没有任何内部容量,是一种无缓冲的等待队列,类似于无中介的直接交易。并且每个 put 必须等待⼀个 take,反之亦然。 需要区别容量为 1 的 ArrayBlockingQueue、LinkedBlockingQueue。以下方法的返回值,可以帮助理解这个队列:

方法 返回值
iterator() 永远返回 null
peek() 永远返回 null
put() 往队列里放进去⼀个元素以后就⼀直等待,直到有其他线程将该元素取走
offer() 往队列里放⼀个元素后立即返回,如果碰巧该元素被另⼀个线程取走了,那么该方法返回 true,认为 offer 成功;否则返回 false
take() 取出队列中的元素,若取不到则一直等待
poll() 只有到碰巧另外⼀个线程正在往队列中 offer 元素或者 put 元素时,该方法才会取到元素;否则立即返回 null
isEmpty() 永远返回 true
remove() & removeAll() 永远返回 false

4.✨BlockingQueue 的实现原理是什么?

阻塞队列的原理很简单,利⽤了 Lock 锁的多条件 (Condition) 阻塞控制。下面对 ArrayBlockingQueue JDK 1.8 的源码进行分析。

4.1.构造器

首先是构造器,除了初始化队列的大小和是否是公平锁之外,还对同⼀个锁 (lock) 初始化了两个监视器,分别是 notEmptynotFull。这两个监视器的作用目前可以简单理解为标记分组:

  • 当该线程是 put 操作时,给他加上监视器 notFull,标记这个线程是⼀个生产者
  • 当线程是 take 操作时,给他加上监视器 notEmpty,标记这个线程是⼀个消费者
public class ArrayBlockingQueue<E> extends AbstractQueue<E> implements BlockingQueue<E>, java.io.Serializable {
	//数据元素数组
	final Object[] items;
	//下⼀个待取出元素索引
	int takeIndex;
	//下⼀个待添加元素索引
	int putIndex;
	//元素个数
	int count;
	//内部锁
	final ReentrantLock lock;
	//消费者监视器
	private final Condition notEmpty;
	//⽣产者监视器
	private final Condition notFull; 
	public ArrayBlockingQueue(int capacity, boolean fair) {
		 //..省略其他代码
		 lock = new ReentrantLock(fair);
		 notEmpty = lock.newCondition();
		 notFull = lock.newCondition();
	}

	//...
}

4.2.put 操作

put 的流程如下:

  • 所有执行 put 操作的线程竞争 lock 锁,拿到了 lock 锁的线程进入下⼀步,没有拿到 lock 锁的线程自旋竞争锁
  • 判断阻塞队列是否满了:
    • 如果满了,则调用 notFull.await() 方法阻塞这个线程,并标记为 notFull(生产者)线程,同时释放 lock 锁,等待被消费者线程唤醒。
    • 如果没有满,则调用 enqueue 方法将元素 put 进阻塞队列。注意这⼀步的线程还有⼀种情况是第⼆步中阻塞的线程被唤醒且又拿到了lock 锁的线程。
  • 释放 lock 锁,并唤醒⼀个标记为 notEmpty(消费者)的线程。
public void put(E e) throws InterruptedException {
    checkNotNull(e);
    final ReentrantLock lock = this.lock;
    //⾃旋拿锁
    lock.lockInterruptibly();
    try {
        //判断队列是否满了
        while (count == items.length)
            //如果满了,阻塞该线程,并标记为 notFull 线程,等待 notEmpty 的唤醒,唤醒之后继续执⾏ while 循环
            notFull.await();
        //如果没有满,则进⼊队列
        enqueue(e);
    } finally {
        lock.unlock();
    }
}

private void enqueue(E x) {
    // assert lock.getHoldCount() == 1;
    // assert items[putIndex] == null;
    final Object[] items = this.items;
    items[putIndex] = x;
    if (++putIndex == items.length)
        putIndex = 0;
    count++;
    //唤醒⼀个等待的线程
    notEmpty.signal();
}

4.3.take 操作

take 操作和 put 操作的流程是类似的,总结⼀下 take 操作的流程:

  • 所有执行 take 操作的线程竞争 lock 锁,拿到了 lock 锁的线程进入下⼀步,没有拿到 lock 锁的线程自旋竞争锁。
  • 判断阻塞队列是否为空:
    • 如果是空,则调用 notEmpty.await 方法阻塞这个线程,并标记为 notEmpty(消费者)线程,同时释放 lock 锁,等待被生产者线程唤醒。
    • 如果没有空,则调用 dequeue 方法。注意这⼀步的线程还有⼀种情况是第⼆步中阻塞的线程被唤醒且又拿到了 lock 锁的线程。
  • 释放 lock 锁,并唤醒⼀个标记为 notFull(生产者)的线程。
public E take() throws InterruptedException {
    final ReentrantLock lock = this.lock;
    lock.lockInterruptibly();
    try {
        while (count == 0)
            notEmpty.await();
        return dequeue();
    } finally {
        lock.unlock();
    }
}

private E dequeue() {
    // assert lock.getHoldCount() == 1;
    // assert items[takeIndex] != null;
    final Object[] items = this.items;
    @SuppressWarnings("unchecked")
    E x = (E) items[takeIndex];
    items[takeIndex] = null;
    if (++takeIndex == items.length)
        takeIndex = 0;
    count--;
    if (itrs != null)
        itrs.elementDequeued();
    notFull.signal();
    return x;
}

相关知识点:
Java 并发编程面试题——Condition 接口

5.BlockingQueue 的使用场景有哪些?

5.1.生产者—消费者模式

public class Test {
    int queueSize = 10;
    BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(queueSize);
    
    public static void main(String[] args) {
        Test test = new Test();
        Producer producer = test.new Producer();
        Consumer consumer = test.new Consumer();
        producer.start();
        consumer.start();
    }
    
    class Consumer extends Thread {
        @Override
        public void run() {
            consume();
        }
        
        private void consume() {
            while (true) {
                try {
                    queue.take();
                    System.out.println("从队列取走⼀个元素,队列剩余 " + queue.size() + " 个元素");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }
    
    class Producer extends Thread {
        @Override
        public void run() {
            produce();
        }
        
        private void produce() {
            while (true) {
                try {
                    queue.put(1);
                    System.out.println("向队列取中插入⼀个元素,队列剩余空间:" + (queue.size()));
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}

上述代码的一个结果片段如下:

从队列取⾛⼀个元素,队列剩余 0 个元素 
从队列取⾛⼀个元素,队列剩余 0 个元素 
向队列取中插⼊⼀个元素,队列剩余空间:9 
向队列取中插⼊⼀个元素,队列剩余空间:9 
向队列取中插⼊⼀个元素,队列剩余空间:9 
向队列取中插⼊⼀个元素,队列剩余空间:8 
向队列取中插⼊⼀个元素,队列剩余空间:7 
向队列取中插⼊⼀个元素,队列剩余空间:6 
向队列取中插⼊⼀个元素,队列剩余空间:5 
向队列取中插⼊⼀个元素,队列剩余空间:4 
向队列取中插⼊⼀个元素,队列剩余空间:3 
向队列取中插⼊⼀个元素,队列剩余空间:2 
向队列取中插⼊⼀个元素,队列剩余空间:1 
向队列取中插⼊⼀个元素,队列剩余空间:0 
从队列取⾛⼀个元素,队列剩余 1 个元素 
从队列取⾛⼀个元素,队列剩余 9 个元素

注意,这个例子中的输出结果看起来可能有问题,比如有几行在插入⼀个元素之后,队列的剩余空间不变。这是由于 System.out.println 语句没有锁。考虑到这样的情况:线程 1 在执行完 put/take 操作后立即失去 CPU 时间片,然后切换到线程 2 执行 put/take 操作,执行完毕后回到线程 1 的 System.out.println 语句并输出,发现这个时候阻塞队列的 size 已经被线程 2 改变了,所以这个时候输出的 size 并不是当时线程 1 执行完 put/take 操作之后阻塞队列的 size,但可以确保的是 size 不会超过 10 个。实际上使用阻塞队列是没有问题的。

5.2.线程池中使用阻塞队列

(1)ThreadPoolExecutor.java 中的一个构造函数源码如下:

public ThreadPoolExecutor(int corePoolSize,
                          int maximumPoolSize,
                          long keepAliveTime,
                          TimeUnit unit,
                          BlockingQueue<Runnable> workQueue) {
    this(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue,
         Executors.defaultThreadFactory(), defaultHandler);
}

(2)Java 中的线程池就是使用阻塞队列实现的,我们在了解阻塞队列之后,无论是使用 Exectors 类中已经提供的线程池,还是自己通过 ThreadPoolExecutor 实现线程池,都会比较方便。

6.✨手动实现一个简单的阻塞队列?

(1)以下是一个使用 Java 手写的基本阻塞队列的示例:

import java.util.LinkedList;
import java.util.Queue;

public class BlockingQueue<T> {

    private Queue<T> queue;
    private int capacity;

    public BlockingQueue(int capacity) {
        this.queue = new LinkedList<>();
        this.capacity = capacity;
    }

    public synchronized void enqueue(T item) throws InterruptedException {
        while (queue.size() == capacity) {
            wait(); // 如果队列已满,等待直到有空间
        }
        queue.add(item);
        notifyAll(); // 通知其他线程队列中有新元素
    }
    
    public synchronized T dequeue() throws InterruptedException {
        while (queue.isEmpty()) {
            wait(); // 如果队列为空,等待直到有元素可出队
        }
        T item = queue.poll();
        notifyAll(); // 通知其他线程队列中有空间
        return item;
    }
}

上述代码定义了一个泛型的阻塞队列类 BlockingQueue,基于一个内部的 LinkedList 实现队列。

  • 构造函数接受一个容量参数用于限制队列的大小。
  • enqueue 方法用于将元素添加到队尾。如果队列已满,则当前线程进入等待状态,直到有空间可用。添加元素后,使用 notifyAll() 方法通知其他线程有新元素可用。
  • dequeue 方法用于从队头获取并移除一个元素。如果队列为空,则当前线程进入等待状态,直到有元素可出队。获取元素后,使用notifyAll()方法通知其他线程有空间可用。

(2)请注意,这只是一个简单的示例,可能还需要进行更多的安全性和异常处理的优化。此外,阻塞队列的实现可以有多种方式,这只是其中的一种。在实际使用时,你还需要根据具体需求进行适当的调整和扩展。

(3)InterruptedException 的说明:

  • InterruptedException 是 Java 中的一个异常类,用于表示在线程处于阻塞状态时被中断的情况。当一个线程调用了处于阻塞状态的方法(如 Thread.sleep()Object.wait()BlockingQueue.take() 等)时,如果该线程被其他线程调用了 interrupt() 方法中断,那么该阻塞方法将抛出 InterruptedException 异常。
  • InterruptedException 是一个受检查的异常,意味着在使用阻塞方法时必须显式地处理或传递这个异常。当抛出 InterruptedException 时,线程的中断状态将被清除,即 Thread.interrupted() 方法会返回 false。
  • 主要情况下,我们对 InterruptedException 的处理方式通常有两种:
    • 向上抛出异常:在方法的签名中声明 throws InterruptedException,将异常传递给调用者处理。这要求调用者必须在调用时处理或继续传递该异常。
    • 恢复中断状态:在 catch 块中捕获 InterruptedException,然后根据需要重新中断线程,通常是通过调用 Thread.currentThread().interrupt() 方法。这样做是为了保持线程的中断状态,以便其他线程能够检测到中断并做出响应。

下面是一个示例,展示了如何处理 InterruptedException:

public void myMethod() {
    try {
        // 执行可能会抛出 InterruptedException 的操作
        Thread.sleep(1000);
    } catch (InterruptedException e) {
        // 恢复线程的中断状态
        Thread.currentThread().interrupt();

        // 可以进行其他的处理
        System.out.println("Caught InterruptedException: " + e.getMessage());
    }
}

在上述示例中,myMethod 方法使用 Thread.sleep() 方法模拟一个可能被中断的操作。当线程在执行 sleep 方法时被中断时,将抛出 InterruptedException。在 catch 块中,我们恢复线程的中断状态,并进行其他适当的处理,如打印异常信息。通过正确处理 InterruptedException,我们可以更好地响应线程的中断请求,并采取适当的措施来维护线程的中断状态。

你可能感兴趣的:(Java,后端面试,BlockingQueue,面试题,阻塞队列)