Java集合框架学习笔记(二):ArrayBlockingQueue和LinkedBlockingQueue

163 阅读8分钟
原文链接: www.geekmuseo.com

1、ArrayBlockingQueue

1.1、takeIndex和putIndex

ArrayBlockingQueue内部使用一个循环Object数组做数据存储。该数组上有两个索引:takeIndex和putIndex。有三张图表示take操作和put操作对这两个索引的影响:

初始状态takeIndex和putIndex都指向下标为0的位置。

执行一次put操作后,下标为0的位置就存入了数据,这时候putIndex自增1,表示下一个数据填入的位置。

执行一次take操作后,下标为0的位置的数据被取出,然后takeIndex自增1,表示下一个可以取数据的位置。


1.2、循环数组

ArrayBlockingQueue初始化的时候需要传入一个正整数表示容量,初始化后这个容量不再改变,当putIndex和takeIndex自增到数组末尾时,如果继续自增,则会变为0,这就是循环数组的原理。下面用两张图来说明:

putIndex指向数组末尾

执行一次put后,putIndex指向数组的开始位置。

1.3、阻塞

ArrayBlockingQueue内部会保存一个变量count,一次put操作会让count加一,一次take操作会让count减一,当count和数组容量相等时,表示数组已满,这时候put会阻塞,当count为0时表示数组已空,这时候take会阻塞。下面用四张图来表示:

执行put两次,putIndex为2,表示指向第三个格子,count此时为2

执行take一次,takeIndex为1,表示指向第二个格子,count此时为1

执行put5次,此时putIndex为1,表示指向第二个格子,count为6,如果继续执行put,由于count和数组大小一样,因此会阻塞

执行take6次,此时takeIndex为1,表示指向第二个格子,count为0,如果继续执行take,由于count为0,因此会阻塞
由上面四张图可知,putIndex和takeInde在数组满和数组空的情况下是相等的,因此不能仅仅通过putIndex和takeInde判断数组是否满或者空,于是引入了count变量。

1.4、put和take的同步原理

put操作和take操作都会在一定条件下阻塞,下面一张图说明了这两个操作的同步原理:

假设当前队列是满的

1、线程A执行put操作,发现队列满了,因此利用同步条件A让线程A挂起

2、线程B执行take操作,发现队列不为空,于是从队列获取数据,然后利用同步条件A让线程A唤醒

3、线程A被唤醒后,继续执行put操作,这时候发现队列不满,于是插入数据,然后通知同步条件B,让同步条件B唤醒线程B,由于线程B本身未阻塞,因此该操作并没有任何效果

加上当前队列为空

1、线程B执行take操作,发现队列空,因此利用同步条件B让线程B挂起

2、线程A执行put操作,发现队列不满,于是插入数据,然后利用同步条件B唤醒线程B

3、线程B被唤醒后,继续执行take操作,发现队列不为空,于是获取数据,然后用同步条件A唤醒线程A,由于线程A本身未阻塞,因此该操作并没有任何效果。

1.5、offer和poll

offer用来插入数据,不过它和put不同,如果队列满了,offer会返回一个false表示插入失败,而不是阻塞。poll用来获取数据,它和take不同,如果队列为空,poll直接返回null,而不是阻塞


1.6、add和remove

add操作效果和offer一样,remove操作效果和poll一样


1.7、lock

ArrayBlockingQueue内部保持一把ReentrantLock,所有的操作都会在锁上进行。对于contains、toArray、toString、clear、drainTo这些操作,都需要遍历队列所有的元素,而这些操作也会加锁,因此性能会受到影响,尽量避免使用。

1.8、迭代器

ArrayBlockingQueue队列数据会动态变化,因此ArrayBlockingQueue返回的迭代器无法精确迭代队列元素,只能保持一种弱一致性。

2、LinkedBlockingQueue

2.1、head和last

LinkedBlockingQueue是基于链表的阻塞队列,内部使用Node数据结构,并用head和last两个Node索引来实现插入和移除操作。下面用三张图来说明:

刚开始head和last都指向一个头结点,这个结点的内容为空

执行两次put,每次put都会生成一个包含数据的节点,然后和前面的节点连接,从而形成一张单向链表,last会指向最后一个节点

执行一次take,会将head执行头结点的下一个节点,取出该节点的内容从而变成新的头结点,最后把老的头结点的指针置空,让它被GC回收。由于每次put都会new一个Node,每次take都会让一个Node对象被回收,因此在高并发环境下,使用LinkedBlockingQueue会导致短时间大量的Node对象的创建和回收,有可能会影响性能,而在ArrayBlockingQueue中使用了循环数组,不存在这个问题。

1.2、阻塞

LinkedBlockingQueue内部有个int类型的capacity,表示最大容量,它是由初始化的时候传入,如果不传,则使用最大的int值。除此之外还有一个一个AtomicInteger类型的count变量,表示当前容量。当count等于capacity时,put会阻塞,当count等于0时,take会阻塞。下面用四张图来表示:

初始化时count为0,capacity为4

先执行两次put,count 为2

再执行一次take,count变为1

在执行三次put,count变为4,这时候等于capacity,如果再执行put,则会阻塞

连续执行四次take,count变为0,又回到了初始状态,这时候如果再执行take,则会阻塞
从上面四张图可以看出,队列满和队列空,head与last的位置是不一样的,这点和ArrayBlockingQueue是有区别的。

1.3、putLock和takeLock

LinkedBlockingQueue内部有两把锁:putLock和takeLock每把锁上都有一个同步条件:putLock–notFull takeLock–notEmpty之所以要用两把锁,是为了减少线程之间的竞争,put线程只和put线程竞争,take线程只和take线程竞争,而在ArrayBlockingQueue中,put线程和take线程会一起竞争。

1.4、put和take的同步原理

首先看put操作

假设存在两个put线程:A1和A2

1、当前队列已满

2、A1获得putLock的控制权,此时A2阻塞

3、A1发现队列已满,于是利用同步条件notFull让自己阻塞

4、此时take线程连续取走了3个元素,然后调用同步条件notFull进行唤醒操作,notFull将A1线程唤醒

5、A1发现队列不满,于是插入数据,然后发现当前容量及时加1,队列也不会满,因此使用同步条件notFull进行唤醒操作,此时A2线程会被唤醒,最后执行take线程的唤醒操作

6、A2线程发现队列不满,于是插入数据,然后发现当前容量加1正好等于容量,于是执行take线程的唤醒操作。

然后对应到take:

假设存在两个take线程:B1和B2

1、当前队列已空

2、B1获得takeLock的控制权,此时B2阻塞

3、B1发现队列已空,于是利用同步条件notEmpty让自己阻塞

4、此时put线程连续插入3个元素,然后调用同步条件notEmpty进行唤醒操作,notEmpty将B1线程唤醒

5、B1发现队列有数据,于是获取一个数据,然后发现当前容量大于1,因此使用同步条件notEmpty进行唤醒操作,此时B2线程会被唤醒,最后执行put线程的唤醒操作

6、B2线程发现队列不满,于是获取,然后发现当前容量为1,于是执行put线程的唤醒操作。

在ArrayBlockingQueue的同步策略中,put线程是由take线程唤醒,反之亦然。而在LinkedBlockingQueue的同步策略中,put线程不仅会被take线程唤醒,而且put线程也可以唤醒put线程,反之亦然。这样做的目的是为了加速线程唤醒,从而加速对象的处理。

1.5、offer和poll

offer用来插入数据,不过它和put不同,如果队列满了,offer会返回一个false表示插入失败,而不是阻塞。poll用来获取数据,它和take不同,如果队列为空,poll直接返回null,而不是阻塞offer和poll的线程会在竞争锁的时候阻塞,这和队列是否空和满无关。

1.6、add和remove

add操作效果和offer一样,remove操作效果和poll一样


1.7、迭代器

和ArrayBlockingQueue一样,LinkedBlockingQueue的迭代器也是一种弱一致性,有时候无法完全迭代出队列中的数据。


1.8、性能

对于contains、toArray、toString、clear、drainTo这些操作,都需要遍历队列所有的元素,而这些操作会同时获取take锁和put锁,因此性能会受到影响,尽量避免使用。

3、建议

put和take线程不多的时候,使用ArrayBlockingQueue,因为线程竞争不会太激烈,而且不需要频繁开辟和释放内存。put和take线程很多的时候,使用LinkedBlockingQueue,虽然会频繁开辟和释放内存导致GC频繁,但是锁的竞争降低了。