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频繁,但是锁的竞争降低了。