并发包中的ConcurrentLinkedQueue和LinkedBlockingQueue的区别

初步了解
有的时候将并发包下面的所有容器习惯叫作并发容器,但是严格来讲类似ConcurrentLinkedQueue这种“Concurrent”容器才是真正代表并发。

两种的区别:

  • Concurrent类型基于lock-free,在日常的多线程访问场景,一般可以提供较高吞吐量。
  • 而LinkedBlockingQueue内部则是基于锁,并提供了BlockingQueue的等待性方法。

不知道大家在日常学习开发中有没有注意到, java.util.concurrent包提供的容器( Queue、 List、 Set)、 Map,从命名上可以大概分为Concurrent、CopyOnWrite和Blocking*等三类,同样是线程安全容器,可以简单认为:

  • Concurrent类型没有类似CopyOnWrite之类容器相对较重的修改开销。
  • 但是凡事都是有代价的,Concurrent往往提供了较低的遍历一致性。大家可以理解为弱一致性,例如当利用迭代器遍历时,如果容器发生修改迭代器仍然可以继续进行遍历。
  • 与弱一致性对应的,就是介绍过的同步容器常见的行为“fast-fail”,也就是检测到容器在遍历过程中发生了修改,则抛出ConcurrentModifcationException,不在继续遍历。
  • 弱一致性的另外一个体现是size等操作准确性是有限的,未必是100%准确。
  • 与此同时,读取的性能具有一定的不确定性

知识扩展
之前文章介绍过常见的集合中如LinkedList是个Deque,只不过不是线程安全的。下图是Java并发类库提供的各种各样的线程安全队列实现,注意,图中没有将线程安全部分包含进来。
在这里插入图片描述
可以从不同角度进行分类,从基本数据结构角度分析,有两个特别的Deque实现,ConcurrentLinkedDeque和LinkedBlockingDeque。Deque的侧重点是支持对队列头尾都进行插入和删除,因此提供了特定的方法,如:

  • 尾部插入需要的addLast(e)、 offerLast(e)。
  • 尾部删除所需要的removeLast()、 pollLast()。

通过上面的知识能够理解ConcurrentLinkedDeque和LinkedBlockingQueue的主要功能区别,就足够日常开发的需要了。

从行为特征来看,绝大部分Queue都是实现了BlockingQueue接口。在常规队列操作基础上,Blocking意味着其提供了特定的等待性操作,获取时等待元素进队,或者插入时等待队列出现空位。

	/**
	* 获取并移除队列头结点,如果必要,其会等待直到队列出现元素
	…
	*/
	E take() throws InterruptedException;
	/**
	* 插入元素,如果队列已满,则等待直到队列出现空闲空间
	…
	*/
	void put(E e) throws InterruptedException;

另一个BlockingQueue比较重要的一点是是否有界( Bounded、 Unbounded),这往往也是影响我们在应用开发中的选择。

  • ArrayBlockingQueue是最典型的有界队列,其内部以final数组保存数据,数组的大小决定了队列的边界,所以在创建ArrayBlockingQueue时都要指定容量,如public ArrayBlockingQueue(int capacity, boolean fair)
  • LinkedBlockingQueue容易被误解为无边界,但是其实其行为和内部代码都是基于有界的逻辑实现的,只不过如果我们没有在创建队列时就指定容量,那么其容量限制就自动被设置为Integer.MAX_VALUE,成为了无界队列。
  • SynchronousQueue,这是一个非常神奇的队列实现,每个删除操作都要等待插入操作,反之每个插入操作也都要等待删除动作。并且这个队列内部容量是0。
  • PriorityBlockingQueue是无边界的优先队列,其大小是受系统资源影响。
  • DelayedQueue和LinkedTransferQueue同样是无边界的队列。对于无边界的队列有一个自然的结果就是put操作永远不会发生在其他BlockingQueue那种等待情况。

如果分析不同队列的底层实现,BlockingQueue基本都是基于锁实现,下面来看看典型的LinkedBlockingQueue。

	/** 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, ofer, etc */
	private final ReentrantLock putLock = new ReentrantLock();
	/** Wait queue for waiting puts */
	private final Condition notFull = putLock.newCondition();

介绍ReentrantLock的条件变量用法的时候分析过ArrayBlockingQueue,不知道你有没有注意到,其条件变量与LinkedBlockingQueue版本的实现是有区别的。 notEmpty、 notFull都是同一个再入锁的条件变量,LinkedBlockingQueue则改进了锁操作的粒度,头、尾操作使用不同的锁,所以在通用场景下,它的吞吐量相对要更好一些。

下面的take方法与ArrayBlockingQueue中的实现,也是有不同的,由于其内部结构是链表,需要自己维护元素数量值,请参考下面的代码。

public E take() throws InterruptedException {
	final E x;
	final int c;
	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;
}

类似ConcurrentLinkedQueue等,则是基于CAS的无锁技术,不需要在每个操作时使用锁,所以扩展性表现要更加优异。

相对比较另类的SynchronousQueue,在Java 6中,其实现发生了非常大的变化,利用CAS替换掉了原本基于锁的逻辑,同步开销比较小。它是Executors.newCachedThreadPool()的默认队列。

在实际开发中,我提到过Queue被广泛使用在生产者-消费者场景,比如利用BlockingQueue来实现,由于其提供的等待机制,我们可以少操心很多协调工作,你可以参考下面样例代码:

import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;

public class ConsumerProducer {
	public static final String EXIT_MSG = "Good bye!";
	public static void main(String[] args) {
	// 使用较小的队列,以更好地在输出中展示其影响
	BlockingQueue<String> queue = new ArrayBlockingQueue<>(3);
	Producer producer = new Producer(queue);
	Consumer consumer = new Consumer(queue);
	new Thread(producer).sart();
	new Thread(consumer).sart();
}

static class Producer implements Runnable {
	private BlockingQueue<String> queue;
	public Producer(BlockingQueue<String> q) {
		this.queue = q;
	}
	@Override
	public void run() {
		for (int i = 0; i < 20; i++) {
			try{
				Thread.sleep(5L);
				String msg = "Message" + i;
				Sysem.out.println("Produced new item: " + msg);
				queue.put(msg);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
	}

	try {
		System.out.println("Time to say good bye!");
		queue.put(EXIT_MSG);
	} catch (InterruptedException e) {
		e.printStackTrace();
	}
  }
}

static class Consumer implements Runnable{
	private BlockingQueue<String> queue;
	public Consumer(BlockingQueue<String> q){
		this.queue=q;
}
	@Override
	public void run() {
		try{
		String msg;
		while(!EXIT_MSG.equalsIgnoreCase( (msg = queue.take()))){
			System.out.println("Consumed item: " + msg);
			Thread.sleep(10L);
		}
		System.out.println("Got exit message, bye!");
	}catch(InterruptedException e) {
		e.printStackTrace();
	}
  }
 }
}

上面是一个典型的生产者-消费者样例,如果使用非Blocking的队列,那么我们就要自己去实现轮询、条件判断(如检查poll返回值是否null)等逻辑,如果没有特别的场景要求, Blocking实现起来代码更加简单、直观。

在日常开发中如何去选择适合相应的队列实现呢?
以LinkedBlockingQueue、 ArrayBlockingQueue和SynchronousQueue为例,我们一起来分析一下,根据需求可以从很多方面考量:

  • 考虑应用场景中对队列边界的要求。 ArrayBlockingQueue是有明确的容量限制的,而LinkedBlockingQueue则取决于我们是否在创建时指定, SynchronousQueue则干脆不能缓存任何元素。
  • 从空间利用角度,数组结构的ArrayBlockingQueue要比LinkedBlockingQueue紧凑,因为其不需要创建所谓节点,但是其初始分配阶段就需要一段连续的空间,所以初始内存需求更大。
  • 通用场景中, LinkedBlockingQueue的吞吐量一般优于ArrayBlockingQueue,因为它实现了更加细粒度的锁操作。
  • ArrayBlockingQueue实现比较简单,性能更好预测,属于表现稳定的“选手”。
  • 如果我们需要实现的是两个线程之间接力性( handof)的场景,大家可能会选择CountDownLatch,但是SynchronousQueue也是完美符合这种场景的,而且线程间协调和数据传输统一起来,代码更加规范。
  • 可能令人意外的是,很多时候SynchronousQueue的性能表现,往往大大超过其他实现,尤其是在队列元素较小的场景。
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值