Java Connection集合分析之Queue

Queue队列

1.Queue用于模拟队列这种数据结构,队列通常是指“先进先出”(FIFO)的容器。新元素插入(offer)到队列的尾部,访问元素(poll)操作会返回队列头部的元素。通常,队列不允许随机访问队列中的元素。这种结构就如同我们生活中的排队一样。

 

2.Queue家族有两大分支,即阻塞队列和非阻塞队列,阻塞队列都是用于并发编程当中。

阻塞队列会对当前线程产生阻塞,比如一个线程从一个空的阻塞队列中取元素,此时线程会被阻塞直到阻塞队列中有了元素。当队列中有元素后,被阻塞的线程会自动被唤醒(不需要我们编写代码去唤醒),或者针对有界队列不光是获取元素时会阻塞,当队列中没有空余空间的时候相对列插入元素也会产生阻塞。

非阻塞队列在插入元素和获取元素时不会对当前线程产生阻塞,在不涉及并发环境以及并发环境中不涉及多线程共享变量的代码中经常使用。在并发环境中使用非阻塞队列的时候有一个很大问题就是:它不会对当前线程产生阻塞,那么在面对类似消费者-生产者的模型时,就必须额外地实现同步策略以及线程间唤醒策略,这个实现起来就非常麻烦。

362526e12a6d80312936068eda084658bc1.jpg

非阻塞队列

1.PriorityQueue保存队列元素的顺序不是按加入队列的顺序,而是按队列元素的大小进行重新排序。因此当调用peek()或pool()方法取出队列中头部的元素时,并不是取出最先进入队列的元素,而是取出队列中的最小的元素。PriorityQueue中的元素可以默认自然排序(也就是数字默认是小的在队列头,字符串则按字典序排列)或者通过提供的Comparator(比较器)在队列实例化时指定的排序方式。当PriorityQueue中没有指定Comparator时,加入PriorityQueue的元素必须实现了Comparable接口(即元素是可比较的),否则会导致 ClassCastException。

注:任何集合中Comparator的优先级要高于实现Comparable接口。

 

2.PriorityQueue 本质也是一个动态数组,在这一方面与ArrayList是一致的。

PriorityQueue调用默认的构造方法时,使用默认的初始容量(DEFAULT_INITIAL_CAPACITY=11)创建一个 PriorityQueue,并根据其自然顺序来排序其元素(使用加入其中的集合元素实现的Comparable)。

41ebf67df680cace29d92ccff4a823f1423.jpg

3712148f627e5b4a7c480ac7580a1cd1ee3.jpg

从下面的构造方法可以看出,内部维护了一个动态数组。

当添加元素到集合时,会先检查数组是否还有余量,有余量则把新元素加入集合,没余量则调用grow()方法增加容量,然后调用siftUp将新加入的元素排序插入对应位置。

0588aee1b5862bda80ffe52f968a7c2cd1e.jpg

grow()方法在队列容量小于64时,每次增加一倍+2,当容量大于64时,每次扩容50%

ddd5dce27de32a002d1012974547e049511.jpg

除此之外,还要注意:

①PriorityQueue不是线程安全的。如果多个线程中的任意线程从结构上修改了列表, 则这些线程不应同时访问 PriorityQueue 实例,这时请使用线程安全的PriorityBlockingQueue 类。

②不允许插入 null 元素。

③PriorityQueue实现插入方法(offer、poll、remove() 和 add 方法) 的时间复杂度是O(log(n)) ;实现 remove(Object) 和 contains(Object) 方法的时间复杂度是O(n) ;实现检索方法(peek、element 和 size)的时间复杂度是O(1)。所以在遍历时,若不需要删除元素,则以peek的方式遍历每个元素。

④方法iterator()中提供的迭代器并不保证以有序的方式遍历优PriorityQueue中的元素。而是经过比较排序后的顺序遍历元素。

 

3.Deque接口代表一个"双端队列",双端队列可以同时从两端来添加、删除元素,因此Deque的实现类既可以当成队列使用、也可以当成栈使用。LinkedList也实现了Deque接口,所以也可以被当作双端队列使用。

当 Deque 当做 Queue队列使用时(FIFO),添加元素是添加到队尾,删除时删除的是头部元素。

c09c02c4cf256501c931c03943247f59fb5.jpg

Deque 也能当Stack栈用(LIFO)。这时入栈、出栈元素都是在 双端队列的头部 进行。

75cb0eb5bd393201d6dc2e6f568dda08814.jpg

注意:Stack过于古老,并且实现地非常不好,因此现在基本已经不用了,可以直接用Deque来代替Stack进行栈操作。

 

4.ArrayDeque顾名思义,就是用数组实现的Deque;既然是底层是数组那肯定也可以指定其容量,也可以不指定,默认长度是16,然后根据添加的元素的个数,动态扩展。ArrayDeque由于是两端队列,所以其顺序是按照元素插入数组中对应位置产生的(下面会具体说明)。

由于本身数据结构的限制,ArrayDeque没有像ArrayList中的trimToSize方法可以为自己瘦身。ArrayDeque的使用方法就是上面的Deque的使用方法,基本没有对Deque拓展什么方法。

db35d67693cb27f4cc566260cbe4ff102ca.jpg

2353e4adf1b8c4c78aebc5ce0912d246574.jpg

5.ArrayDeque为了满足可以同时在数组两端插入或删除元素的需求,其内部的动态数组还必须是循环的,即循环数组(circular array),也就是说数组的任何一点都可能被看作起点或者终点。

ArrayDeque维护了两个变量,表示ArrayDeque的头和尾。

ada5fca06a3f4e28198add290909c61e712.jpg

当向头部插入元素时,head下标减一然后插入元素。而 tail表示的索引为当前末尾元素表示的索引值加一。若当向尾部插入元素时,直接向tail表示的位置插入,然后tail再减一。

下面具体看看ArrayDeque怎么把循环数组实际应用的?

我们从源码当中获取答案

从队列头部添加元素

a5cadb73dc7c5400605c7fd65fde2aac756.jpg

从队列尾部添加元素

9436de85016345ea6d14d2c2adf40408b93.jpg

当加入元素时,先看是否为空(ArrayDeque不可以存取null元素,因为系统根据某个位置是否为null来判断元素的存在)。然后head-1插入元素。head = (head - 1) & (elements.length - 1)很好的解决了下标越界的问题。这段代码相当于取模,同时解决了head为负值的情况。因为elements.length必需是2的指数倍(代码中有具体操作),elements - 1就是二进制低位全1,跟head - 1相与之后就起到了取模的作用。如果head - 1为负数,其实只可能是-1,当为-1时,和elements.length - 1进行与操作,这时结果为elements.length - 1。其他情况则不变,等于它本身。

当插入元素后,在进行判断是否还有余量。因为tail总是指向下一个可插入的空位,也就意味着elements数组至少有一个空位,所以插入元素的时候不用考虑空间问题。

下面再说说扩容函数doubleCapacity(),其逻辑是申请一个更大的数组(原数组的两倍),然后将原数组复制过去。

9230ebf56e00df114d187e4801d53d0f903.jpg

再说说扩容时复制的机制,过程如下图所示:

e1598eb5e2cbcefd1c037df12de7f0d126e.jpg

图中我们看到,复制分两次进行,第一次复制head右边的元素,第二次复制head左边的元素。代码如下:

73bfe7236d6d8ed35c2abe6b8380fa9441a.jpg

6.再来详细说说ArrayDeque中的绝妙之笔,就是那一行代码将环形的数组的各种实现问题全部解决:

.下标越界的问题

.head标记为负数的问题

上面的两个问题我们完全可以通过代码来去解决,但是你先想想你需要多少行代码。而Java开发者们只需要这一行:

head = (head - 1) & (elements.length - 1)/tail = (tail + 1) & (elements.length - 1)

上面两行代码我们在ArrayDeque的从队列头部添加元素的方法以及从队列尾部添加元素的方法中可以看到,他们分别是计算元素从头插入和从尾插入的下标位置的,并且非常巧妙的解决了上面两个问题。

我们再来回顾下ArrayDeque的插入逻辑,ArrayDeque中维系了一个数组来充当队列,并且通过head和tail分别记录了头结点下标和尾节点下标。当从头部插入时,head减一。当从尾部插入时tail加一。

针对ArrayDeque中的数组,如何做到环形访问?就是通过head和tail来实现的。初始head和tail为0。当head小于0时,head此时应该指向数组的最后一个下标。同样原理当tail大于数组最后的下标时,tail应当指向数组第一个下标。当head和tail再次相等时,则表示队列已经满了,需要扩容。

我们来分析下上面的代码以head = (head - 1) & (elements.length - 1) 为例,我们来分析下它都做了什么:

(1) 当我们第一次从队列的头部添加元素时,此时head为0,addFirst方法执行上面代码的前半部分(head - 1) = -1 后半部分 (elements.length - 1) 为 16 -1 = 15(16为ArrayDeque默认初始化容量)

(2) 当 -1 & 15 时,神奇的事情发生了,-1 & 15的结果为15。也就是说,当第一次向数组头部添加元素时,head会小于0,此时上面的代码将 -1 这个越界的下标帮我们优雅的转化为了数组的末尾下标15。这中间具体发生了什么?

. -1的二进制表示是需要先找到的1二进制表示,也就是原码,再计算其反码,反码就是1和0互换 补码就是在反码的基础上最右边一位加1 根据二进制的进位原则的补码 就是-1的二进制表示

0000 0001  //1的源码

1111 1110  //1的反码

1111 1111  //1 的补码 也就是-1

&

1111 1111  //15二进制表示

————————————

1111 1111  //结果为15

 

. 为什么说 & 运算相当于取模呢?

位运算(&)效率要比取模运算(%)高很多,主要原因是位运算直接对内存数据进行操作,不需要转成十进制,因此处理速度非常快。

a % b == a & (b - 1)

前提:b 为 2^N

具体的效率对比这里不赘述,简单说一下为什么 & 可以代替 % :

X % 2^n = X & (2^n - 1)

2^n 表示 2 的 n 次方,也就是说, 一个数对 2^n 取模相当于一个数和 (2^n - 1) 做按位与运算。

 

假设 n 为 3,则 2^3 = 8,表示成 2 进制就是 1000。2^3 - 1 = 7 ,即 0111。

此时 X & (2^3 - 1) 就相当于取 X 的 2 进制的最后三位数。

从 2 进制角度来看,X / 8 相当于 X >> 3,即把 X 右移 3 位,此时得到了 X / 8 的商,而被移掉的部分(后三位),则是 X % 8,也就是余数。

推广到一般:

对于所有 2^n 的数,二进制表示为:

1000…000,1 后面跟 n 个 0

而 2^n - 1 的二进制为:

0111…111,0 后面跟 n 个 1

X / 2^n 是 X >> n,那么 X & (2^n - 1) 就是取被移掉的后 n 位,也就是 X % 2^n。

 

而tail的计算原理同上面的相同,这里就不做阐述了。

 

7.再来说说ArrayDeque的初始化,肯定有人会想,为什么默认初始化长度会是16?其实,重点不是16,我们从上面的针对每次添加元素计算head的方法中看到了head = (head - 1) & (elements.length - 1),并且也分析了为什么&操作相当于取模,原因就是X % 2^n = X & (2^n - 1)这个公式,由于这个公式取模的高效,因此初始化预计扩容时ArrayDeque的厂区都必须是2的整数倍。我们来看下ArrayDeque的初始化方法:

5891e1261dc8f57bb663601cca37d527855.jpg

除了默认初始化,另外两个都有一个allocateElements方法,他的作用是寻找比输入的厂区大的最近2次幂,因为用户不一定会传入2的次幂的整数。

05e5be577bb963fa480ddb8bfd7448e8286.jpg

看到这段迷之代码了吗?在HashMap中也有一段类似的实现。但要读懂它,我们需要先掌握以下几个概念:

在java中,int的长度是32位,有符号int可以表示的值范围是 (-2)31 到 231-1,其中最高位是符号位,0表示正数,1表示负数。

>>>:无符号右移,忽略符号位,空位都以0补齐。

|:位或运算,按位进行或操作,逢1为1。

 

我们知道,计算机存储任何数据都是采用二进制形式,所以一个int值为80的数在内存中可能是这样的:

0000 0000 0000 0000 0000 0000 0101 0000

比80大的最近的2次幂是128,其值是这样的:

0000 0000 0000 0000 0000 0000 1000 0000

我们多找几组数据就可以发现规律:

每个2的次幂用二进制表示时,只有一位为 1,其余位均为 0(不包含符合位)

要找到比一个数大的2的次幂(在正数范围内),只需要将其最高位左移一位(从左往右第一个 1 出现的位置),其余位置 0 即可。

但从实践上讲,没有可行的方法能够进行以上操作,即使通过&操作符可以将某一位置 0 或置 1,也无法确认最高位出现的位置,也就是基于最高位进行操作不可行。

但还有一个很整齐的数字可以被我们利用,那就是 2n-1,我们看下128-1=127的表示形式:

0000 0000 0000 0000 0000 0000 0111 1111

把它和80对比一下:

0000 0000 0000 0000 0000 0000 0101 0000 //80

0000 0000 0000 0000 0000 0000 0111 1111 //127

可以发现,我们只要把80从最高位起每一位全置为1,就可以得到离它最近且比它大的 2n-1,最后再执行一次+1操作即可。具体操作步骤为(为了演示,这里使用了很大的数字):

原值:

0011 0000 0000 0000 0000 0000 0000 0010

无符号右移1位

0001 1000 0000 0000 0000 0000 0000 0001

与原值|操作:

0011 1000 0000 0000 0000 0000 0000 0011

可以看到最高2位都是1了,也仅能保证前两位为1,这时就可以直接移动两位

无符号右移2位

0000 1110 0000 0000 0000 0000 0000 0000

与原值|操作:

0011 1110 0000 0000 0000 0000 0000 0011

此时就可以保证前4位为1了,下一步移动4位

无符号右移4位

0000 0011 1110 0000 0000 0000 0000 0000

与原值|操作:

0011 1111 1110 0000 0000 0000 0000 0011

此时就可以保证前8位为1了,下一步移动8位

无符号右移8位

0000 0000 0011 1111 1110 0000 0000 0000

与原值|操作:

0011 1111 1111 1111 1110 0000 0000 0011

此时前16位都是1,只需要再移位操作一次,即可把32位都置为1了。

无符号右移16位

0000 0000 0000 0000 0011 1111 1111 1111

与原值|操作:

0011 1111 1111 1111 1111 1111 1111 1111

进行+1操作:

0100 0000 0000 0000 0000 0000 0000 0000

如此经过11步操作后,我们终于找到了合适的2次幂。写成代码就是:

cf873e0cb661439be6d005b4b4654a6f0c7.jpg

不过为了防止溢出,导致出现负值(如果把符号位置为1,就为负值了)还需要一次校验:

096cbc0f50fbb0e17e182570141a5eb8642.jpg

至此,初始化的过程就完毕了。

 

8.我们经常看到说可以基于LinkedList去实现队列或者栈,因为LinkedList本身也是Deque双端队列,其实通过ArrayDeque来实现队列或栈要比LinkedList效率高,因为LinkedList在向列表添加元素时,需要new Node对象,而ArrayDeque内部基于数组创建的,不需要new对象,因此ArrayDeque在实现队列或栈时要有一定优势。

c91aa6b748a5b4643d6e205b26dd58b28da.jpg

我们看到50w次push和pop差距就已经很明显了,这也体现了了解数据结构和源码实现,能够有助于我们写出更加优秀的代码。

阻塞队列

ArrayBlockingQueue :一个由数组结构组成的有界阻塞队列。

LinkedBlockingQueue :一个由链表结构组成的有界阻塞队列。

PriorityBlockingQueue :一个支持优先级排序的无界阻塞队列。

DelayQueue:一个使用优先级队列实现的无界阻塞队列。

SynchronousQueue:一个不存储元素的阻塞队列。

LinkedTransferQueue:一个由链表结构组成的无界阻塞队列。

LinkedBlockingDeque:一个由链表结构组成的双向阻塞队列。

 

(待更新)

转载于:https://my.oschina.net/u/3687664/blog/2876005

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值