对JAVA的ArrayDeque的深入理解

前几天我们分析了JAVA中实现数据结构-列表的几种方式,今天我们将把目光转向另一种数据结构-队列,从源码层面来分析一下JAVA中是如何具体实现队列这种数据结构的。详细的解释一下ArrayLDeque对象的底层实现原理。

首先我们还是从ArrayDeque的构造方法入手,我们发现ArrayDeque有三个构造方法,接下来我们会通过这三个构造方法来分析一下ArrayDeque的底层实现原理。

我们先来看ArrayDeque的无参构造方法,源码如下:

这是一个非常简单的方法,定义了队列elements数据成员为容量16的Object对象数组。

接着我们来看下一个构造方法ArrayDeque(int),源码如下:

我们发现这个方法调了一个allocateElemnents方法,我们继续看这个方法的源码:

看到这个方法我们发现它的目的是生成一个容量为calculateSize(numElements),我们来看一下calculateSize函数的源码,看一下如何生成这个对象数组的容量。

该方法首先定义了一个initialCapacity变量并将它赋值为MIN_INITIAL_CAPACITY,我们来看一下MIN_INITIAL_CAPACITY的值是多少,发现它等于8.我们继续来看下面的语句,首先判断了一下我们传入的numElements和initialCapacity之间的大小关系,假如numElements<initialCapacity直接返回initialCapacity,即8.否则先将initialCapacity赋值为我们传入的值numElements,接下来的几句话非常具有技巧性我们来详细解释。首先解释一下>>>运算符的作用,作用是把一个整数向右移动n个二进制位,,左侧用0补齐,|= 即将自身与另一个数进行按位或运算,规则是两个数的二进制形式从个位开始,每一位进行|运算,1|0 = 1,0|0 = 0 ,1|1=1,例如100 | 10 = 110这里的数都是二进制数。我们回到代码

initialCapacity |= (initialCapacity >>>  1)

首先将initialCapacity右移了一位然后在于initialCapacity进行|运算,且initialCapacity是一个>=8的数,即initialCapacity的值会不变或者增加,原因是因为|=只会使initialCapacity二进制形式中的0变为1,更具体的来说是使得initialCapacity二进制形式中类似于'10'的形式中的0变为1,那依此类推

initialCapacity |= (initialCapacity >>> x)是将initialCapacity二进制形式中类似于'1n0'的形式中的0变为1,n代表两个1和0之间的二进制位个数,n的值是>>>右边的值决定的,即n = x-1,这样的运算可以使得initialCapacity增长到最接近但不超过下一个比它大的2的幂的值。

然后在进行initialCapacity++的目的是initialCapacity变为2的幂,但也会带来一个问题假如initialCapacity正好等于int的最大值,会使得initialCapacity变为负数,因为initialCapacity最左边的符号位0变为了1,所以这就是下面为什么要判断initialCapacity<0的原因,如果initialCapacity<0,我们再把initialCapacity右移一位,使得它变成它的绝对值的一半。那为什么要这样初始化initialCapacity呢?有以下几点原因:

  1. 内存效率
    动态数据结构(如动态数组、哈希表等)在内部通常使用连续的内存块来存储元素。当这些数据结构需要扩展时(即添加更多元素时),它们可能会重新分配一个更大的内存块并将现有元素复制过去。如果 initialCapacity 是2的幂次方,那么扩展时更容易计算新的容量(通常是当前容量的两倍),并且可以减少内部碎片(即未使用的内存空间)。

  2. 优化访问速度
    在某些数据结构中,特别是哈希表,使用2的幂次方的容量可以优化哈希函数的性能。这是因为哈希函数通常需要将键(key)映射到一个索引(index)上,而使用2的幂次方作为容量可以简化这个映射过程,有时可以使用位操作(如与操作 &)来代替取模操作(%),从而提高效率。

  3. 空间和时间权衡
    选择一个合适的初始容量是在空间使用效率和时间效率之间做出权衡。如果 initialCapacity 设置得太小,数据结构可能会频繁地重新分配内存,这会导致性能下降。相反,如果设置得太大,可能会浪费内存空间。通过选择一个足够大且是2的幂次方的值,可以在两者之间找到一个相对较好的平衡点。

  4. 算法简化
    在某些算法实现中,使用2的幂次方的容量可以简化代码逻辑。例如,在动态数组的实现中,如果知道容量是2的幂次方,那么在扩容时就可以简单地将容量翻倍,而不需要进行复杂的计算来确定新的容量大小。

  5. 标准化和兼容性
    许多流行的数据结构和库都遵循这种惯例,即将容量设置为2的幂次方。这样做有助于保持一致性,使得不同库之间的互操作性更强,同时也使得学习和使用这些库变得更加容易。

了解了这些我们自己在写某个容器时初始化容器大小也可以模仿这种方法。所以ArrayDeque(int)构造方法是将自己的容量初始化为一个最接近传入参数的2次幂容量的ArrayDeque。

我们在看ArrayDeque的最后一个构造方法ArrayDeque(Collection<? extends E> c),源码如下:

 

第一句话与第二种构造方法类似,即初始化ArrayDeque的初始化容量为最接近集合c的容量的2次幂容量,我们再来看第二句话,addAll方法源码如下:

看具体方法前我们应该注意到addAll方法是ArrayDeque父类AbstractCollection的方法,而不是ArrayDeque定义的方法,第一句先定义了一个bool类型的变量modefied为false,然后在遍历c假如为c为空集合,直接返回modefied的值,否则对c的每一个遍历对象都调用add方法,我们在来看add方法的源码,在查看add方法源码时我们要查看ArrayDeque的add方法而不是AbstractCollection的add方法,AbstractCollection的add方法仅仅是抛出了一个异常

我们来看ArrayDeque add方法的源码:

发现它又调用了addLast方法,我们继续查看addLast方法的源码:

addLast方法首先判断e为不为空,为空直接抛出NullPointerException异常,在下面两个代码中出现了两个关键变量tail 和 head,我们看一下这两个变量值。这两个变量是int值,默认值为0,我们继续看addLast的代码,element[tail] = e即设置elements数组tail位置的值为e,下面判断一个语句((tail = (tail+1)) & (elements.length -1)==head),这句话是什么意思呢?其实这句话是循环数组一种特殊处理手段。我们呢先来讲一下什么是循环数组,循环数组是实现队列的一种数组的实现方式,队列是先进先出的,但我们只是简单的使用数组实现队列会极大的空间浪费,画个图来说明。

假设数组容量是10,定义两个头尾指针,即代码中的head和tail,head指向第一个插入的元素,tail指向最后一个插入的元素的后一个位置。

根据图上的情况我们会发现此时队列移除了三个元素,同时后面的元素已经添加满了,假如我们再次添加元素,是否需要再次扩容呢?很显然是没有必要的,我们为什么继续吧元素放到前面的空余位置呢?实际操作也很简单我们计算tail是进行一个取模运算,取模的值为数组的长度,这样我们就避免了再次扩容,只有到tail == head的时候我们才会进行扩容,这就是循环数组。

我们再次回到语句((tail = (tail+1)) & (elements.length -1)==head)上,根据我们上面的介绍这个语句其实就是比较tail和head是否相等,假如相等就对ArrayDeque进行扩容。但为什么它没有进行取模运算呢?原因是因为这是一种特殊情况,根据上面的分析,ArrayDeque的容量一定是以2次幂的的大小进行扩充所以说elements.length是一个二次幂的数,当我们对一个二次幂-1的数进行取模运算的时候,我们会发现使用&运算符会和%运算是等价的。

具体来说:
如果 tail + 1 的值小于 elements.length,那么它的所有位都低于 elements.length - 1 的最高位,因此与 elements.length - 1 所有二进制位都为1,进行按位与时,所有位都保持不变,结果仍然是 tail + 1。
如果 tail + 1 的值等于或大于 elements.length,那么它至少有一个位是高于 elements.length - 1 的最高位的。这个高位(或多个高位)在与 elements.length - 1 进行按位与时会被清零,因为 elements.length - 1 在这些位上的值都是0。这样,结果就被“截断”到了 0 到 elements.length - 1 的范围内。

而&由于是二进制位运算,它的性能是远高于%运算的,所以选择&运算。

所以说这个判断语句的作用就是判断数组是否满了,是否需要进行扩容。我们在打开doubleCapacity方法的源码,看一下ArrayDeque是如何进行扩容的。

首先使用assert关键字在java中这个关键字的作用是它允许你设置断言条件,这些条件在程序运行时会被检查。如果断言条件为 false,则抛出一个 AssertionError 异常。所以在这里是判断ArrayDeque是否真的需要扩容的,假如ArrayDeque需要扩容,就使用一个p变量记录当前head的值,使用变量n记录当期ArrayDeque数组容量,定义变量r =  数组容量n-head指针位置,这个值就是有多少个后插入的值放在了head的前面,为了后面保持队列的顺序的正确性。使用左移运算符<<扩大新容量为原来的两倍,接着判读新容量是否小于0因为左移运算会导致符号位可能变为1,即数据变成负数,这时抛出IllegalStateException异常。后面的代码是申请一个新的容量的数组a,

System.arraycopy方法,该方法是一个native方法。

该方法用于将一个数组中的元素复制到另一个数组中。参数说明如下:

  • src:源数组
  • srcPos:源数组中复制的起始位置
  • dest:目标数组
  • destPos:目标数组中复制的起始位置
  • length:要复制的元素个数

所以第一个System.arraycopy方法是将elements数组head位置到末尾位置的元素搬到a中,第二个System.arraycopy方法是将elements数组中0到tail位置的元素搬到a中,最后将elements指向a,重新设置head =0,tail为元素个数n。所以说ArrayDeque底层原理就是使用循环数组实现的,它的设计十分巧妙,非常值得我们学习。


                
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值