ArrayDeque(JDK双端队列)源码深度剖析

前言

在本篇文章当中主要跟大家介绍JDK给我们提供的一种用数组实现的双端队列,在之前的文章LinkedList源码剖析当中我们已经介绍了一种双端队列,不过与ArrayDeque不同的是,LinkedList的双端队列使用双向链表实现的。

双端队列整体分析

我们通常所谈论到的队列都是一端进一端出,而双端队列的两端则都是可进可出。下面是双端队列的几个操作:

  • 数据从双端队列左侧进入。

  • 数据从双端队列右侧进入。

  • 数据从双端队列左侧弹出。

  • 数据从双端队列右侧弹出。

而在ArrayDeque当中也给我们提供了对应的方法去实现,比如下面这个例子就是上图对应的代码操作:

  1. public void test() {
  2. ArrayDeque<Integer> deque = new ArrayDeque<>();
  3. deque.addLast(100);
  4. System.out.println(deque);
  5. deque.addFirst(55);
  6. System.out.println(deque);
  7. deque.addLast(-55);
  8. System.out.println(deque);
  9. deque.removeFirst();
  10. System.out.println(deque);
  11. deque.removeLast();
  12. System.out.println(deque);
  13. }
  14. // 输出结果
  15. [100]
  16. [55, 100]
  17. [55, 100, -55]
  18. [100, -55]
  19. [100]

数组实现ArrayDeque(双端队列)的原理

ArrayDeque底层是使用数组实现的,而且数组的长度必须是2的整数次幂,这么操作的原因是为了后面位运算好操作。在ArrayDeque当中有两个整形变量headtail,分别指向右侧的第一个进入队列的数据和左侧第一个进行队列的数据,整个内存布局如下图所示:

其中tail指的位置没有数据,head指的位置存在数据。

  • 当我们需要从左往右增加数据时(入队),内存当中数据变化情况如下:

  • 当我们需要从右往做左增加数据时(入队),内存当中数据变化情况如下:

  • 当我们需要从右往左删除数据时(出队),内存当中数据变化情况如下:

  • 当我们需要从左往右删除数据时(出队),内存当中数据变化情况如下:

底层数据遍历顺序和逻辑顺序

上面主要谈论到的数组在内存当中的布局,但是他是具体的物理存储数据的顺序,这个顺序和我们的逻辑上的顺序是不一样的,根据上面的插入顺序,我们可以画出下面的图,大家可以仔细分析一下这个图的顺序问题。

上图当中队列左侧的如队顺序是0, 1, 2, 3,右侧入队的顺序为15, 14, 13, 12, 11, 10, 9, 8,因此在逻辑上我们的队列当中的数据布局如下图所示:

根据前面一小节谈到的输入在入队的时候数组当中数据的变化我们可以知道,数据在数组当中的布局为:

ArrayDeque类关键字段分析

  1. // 底层用于存储具体数据的数组
  2. transient Object[] elements;
  3. // 这就是前面谈到的 head
  4. transient int head;
  5. // 与上文谈到的 tail 含义一样
  6. transient int tail;
  7. // MIN_INITIAL_CAPACITY 表示数组 elements 的最短长度
  8. private static final int MIN_INITIAL_CAPACITY = 8;

以上就是ArrayDeque当中的最主要的字段,其含义还是比较容易理解的!

ArrayDeque构造函数分析

  • 默认构造函数,数组默认申请的长度为16
  1. public ArrayDeque() {
  2. elements = new Object[16];
  3. }
  • 指定数组长度的初始化长度,下面列出了改构造函数涉及的所有函数。
  1. public ArrayDeque(int numElements) {
  2. allocateElements(numElements);
  3. }
  4. private void allocateElements(int numElements) {
  5. elements = new Object[calculateSize(numElements)];
  6. }
  7. private static int calculateSize(int numElements) {
  8. int initialCapacity = MIN_INITIAL_CAPACITY;
  9. // Find the best power of two to hold elements.
  10. // Tests "<=" because arrays aren't kept full.
  11. if (numElements >= initialCapacity) {
  12. initialCapacity = numElements;
  13. initialCapacity |= (initialCapacity >>> 1);
  14. initialCapacity |= (initialCapacity >>> 2);
  15. initialCapacity |= (initialCapacity >>> 4);
  16. initialCapacity |= (initialCapacity >>> 8);
  17. initialCapacity |= (initialCapacity >>> 16);
  18. initialCapacity++;
  19. if (initialCapacity < 0) // Too many elements, must back off
  20. initialCapacity >>>= 1;// Good luck allocating 2 ^ 30 elements
  21. }
  22. return initialCapacity;
  23. }

上面的最难理解的就是函数calculateSize了,他的主要作用是如果用户输入的长度小于MIN_INITIAL_CAPACITY时,返回MIN_INITIAL_CAPACITY。否则返回比initialCapacity大的第一个是2的整数幂的整数,比如说如果输入的是9返回的16,输入4返回8

calculateSize的代码还是很难理解的,让我们一点一点的来分析。首先我们使用一个2的整数次幂的数进行上面移位操作的操作!



从上图当中我们会发现,我们在一个数的二进制数的32位放一个1,经过移位之后最终32位的比特数字全部变成了1。根据上面数字变化的规律我们可以发现,任何一个比特经过上面移位的变化,这个比特后面的31个比特位都会变成1,像下图那样:

因此上述的移位操作的结果只取决于最高一位的比特值为1,移位操作后它后面的所有比特位的值全为1,而在上面函数的最后,我们返回的结果就是上面移位之后的结果 +1。又因为移位之后最高位的1到最低位的1之间的比特值全为1,当我们+1之后他会不断的进位,最终只有一个比特位置是1,因此它是2的整数倍。

经过上述过程分析,我们就可以立即函数calculateSize了。

ArrayDeque关键函数分析

addLast函数分析

  1. // tail 的初始值为 0
  2. public void addLast(E e) {
  3. if (e == null)
  4. throw new NullPointerException();
  5. elements[tail] = e;
  6. // 这里进行的 & 位运算 相当于取余数操作
  7. // (tail + 1) & (elements.length - 1) == (tail + 1) % elements.length
  8. // 这个操作主要是用于判断数组是否满了,如果满了则需要扩容
  9. // 同时这个操作将 tail + 1,即 tail = tail + 1
  10. if ( (tail = (tail + 1) & (elements.length - 1)) == head)
  11. doubleCapacity();
  12. }

代码(tail + 1) & (elements.length - 1) == (tail + 1) % elements.length成立的原因是任意一个数\(a\)对\(2^n\)进行取余数操作和\(a\)跟\(2^n - 1\)进行&运算的结果相等,即:

\[a\% 2^n = a \& (2^n - 1)
\]

从上面的代码来看下标为tail的位置是没有数据的,是一个空位置。

addFirst函数分析

  1. // head 的初始值为 0
  2. public void addFirst(E e) {
  3. if (e == null)
  4. throw new NullPointerException();
  5. // 若此时数组长度elements.length = 16
  6. // 那么下面代码执行过后 head = 15
  7. // 下面代码的操作结果和下面两行代码含义一致
  8. // elements[(head - 1 + elements.length) % elements.length] = e
  9. // head = (head - 1 + elements.length) % elements.length
  10. elements[head = (head - 1) & (elements.length - 1)] = e;
  11. if (head == tail)
  12. doubleCapacity();
  13. }

上面代码操作结果和上文当中我们提到的,在队列当中从右向左加入数据一样。从上面的代码看,我们可以发现下标为head的位置是存在数据的。

doubleCapacity函数分析

  1. private void doubleCapacity() {
  2. assert head == tail;
  3. int p = head;
  4. int n = elements.length;
  5. int r = n - p; // number of elements to the right of p
  6. int newCapacity = n << 1;
  7. if (newCapacity < 0)
  8. throw new IllegalStateException("Sorry, deque too big");
  9. Object[] a = new Object[newCapacity];
  10. // arraycopy(Object src, int srcPos,
  11. Object dest, int destPos,
  12. int length)
  13. // 上面是函数 System.arraycopy 的函数参数列表
  14. // 大家可以参考上面理解下面的拷贝代码
  15. System.arraycopy(elements, p, a, 0, r);
  16. System.arraycopy(elements, 0, a, r, p);
  17. elements = a;
  18. head = 0;
  19. tail = n;
  20. }

上面的代码还是比较简单的,这里给大家一个图示,大家就更加容易理解了:

扩容之后将原来数组的数据拷贝到了新数组当中,虽然数据在旧数组和新数组当中的顺序发生变化了,但是他们的相对顺序却没有发生变化,他们的逻辑顺序也是一样的,这里的逻辑可能有点绕,大家在这里可以好好思考一下。

pollLast和pollFirst函数分析

这两个函数的代码就比较简单了,大家可以根据前文所谈到的内容和图示去理解下面的代码。

  1. public E pollLast() {
  2. // 计算出待删除的数据的下标
  3. int t = (tail - 1) & (elements.length - 1);
  4. @SuppressWarnings("unchecked")
  5. E result = (E) elements[t];
  6. if (result == null)
  7. return null;
  8. // 将需要删除的数据的下标值设置为 null 这样这块内存就
  9. // 可以被回收了
  10. elements[t] = null;
  11. tail = t;
  12. return result;
  13. }
  14. public E pollFirst() {
  15. int h = head;
  16. @SuppressWarnings("unchecked")
  17. E result = (E) elements[h];
  18. // Element is null if deque empty
  19. if (result == null)
  20. return null;
  21. elements[h] = null; // Must null out slot
  22. head = (h + 1) & (elements.length - 1);
  23. return result;
  24. }

总结

在本篇文章当中,主要跟大家分享了ArrayDeque的设计原理,和他的底层实现过程。ArrayDeque底层数组当中的数据顺序和队列的逻辑顺序这部分可能比较抽象,大家可以根据图示好好体会一下!!!

以上就是本篇文章的所有内容了,希望大家有所收获,我是LeHung,我们下期再见!!!都看到这里了,给孩子一个赞(start)吧,免费的哦!!!


更多精彩内容合集可访问项目:https://github.com/Chang-LeHung/CSCore

关注公众号:一无是处的研究僧,了解更多计算机(Java、Python、计算机系统基础、算法与数据结构)知识。

ArrayDeque(JDK双端队列)源码深度剖析的更多相关文章

  1. JDK数组阻塞队列源码深入剖析

    JDK数组阻塞队列源码深入剖析 前言 在前面一篇文章从零开始自己动手写阻塞队列当中我们仔细介绍了阻塞队列提供给我们的功能,以及他的实现原理,并且基于谈到的内容我们自己实现了一个低配版的数组阻塞队列.在 ...

  2. FutureTask源码深度剖析

    FutureTask源码深度剖析 前言 在前面的文章自己动手写FutureTask当中我们已经仔细分析了FutureTask给我们提供的功能,并且深入分析了我们该如何实现它的功能,并且给出了使用Ree ...

  3. libevent源码深度剖析十二

    libevent源码深度剖析十二 ——让libevent支持多线程 张亮 Libevent本身不是多线程安全的,在多核的时代,如何能充分利用CPU的能力呢,这一节来说说如何在多线程环境中使用libev ...

  4. libevent源码深度剖析四

    libevent源码深度剖析四 ——libevent源代码文件组织 1 前言 详细分析源代码之前,如果能对其代码文件的基本结构有个大概的认识和分类,对于代码的分析将是大有裨益的.本节内容不多,我想并不 ...

  5. Axios源码深度剖析

    Axios源码深度剖析 - XHR篇 axios 是一个基于 Promise 的http请求库,可以用在浏览器和node.js中,目前在github上有 42K 的star数 分析axios - 目录 ...

  6. HashMap源码深度剖析,手把手带你分析每一行代码,包会!!!

    HashMap源码深度剖析,手把手带你分析每一行代码! 在前面的两篇文章哈希表的原理和200行代码带你写自己的HashMap(如果你阅读这篇文章感觉有点困难,可以先阅读这两篇文章)当中我们仔细谈到了哈 ...

  7. libevent 源码深度剖析十三

    libevent 源码深度剖析十三 —— libevent 信号处理注意点 前面讲到了 libevent 实现多线程的方法,然而在多线程的环境中注册信号事件,还是有一些情况需要小心处理,那就是不能在多 ...

  8. libevent源码深度剖析十一

    libevent源码深度剖析十一 ——时间管理 张亮 为了支持定时器,Libevent必须和系统时间打交道,这一部分的内容也比较简单,主要涉及到时间的加减辅助函数.时间缓存.时间校正和定时器堆的时间值 ...

  9. libevent源码深度剖析十

    libevent源码深度剖析十 ——支持I/O多路复用技术 张亮 Libevent的核心是事件驱动.同步非阻塞,为了达到这一目标,必须采用系统提供的I/O多路复用技术,而这些在Windows.Linu ...

随机推荐

  1. Linux 运维请务必收藏~ Nginx 五大常见应用场景

    关注「开源Linux」,选择"设为星标" 回复「学习」,有我为您特别筛选的学习资料~ Nginx 是一个很强大的高性能 Web 和反向代理服务,它具有很多非常优越的特性,在连接高并 ...

  2. 不要使用Java Executors 提供的默认线程池

    线程池构造方法 public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUni ...

  3. JDK自带线程池学习

    JDK自带线程池 线程池的状态 线程有如下状态 RUNNING状态:Accept new tasks and process queued tasks SHUTDOWN状态:Don't accept ...

  4. SQL查询与SQL优化[姊妹篇.第四弹]

    在上一篇文章中,我们一起了解了关系模型与关系运算相关的知识,接下来我们一起谈谈,面对复杂的关系数据,我们如何来优化,SQL如何玩转更优呢? 在上一篇中抛出了4个关于优化方面的问题: 1.返回表中0.0 ...

  5. 637. Average of Levels in Binary Tree - LeetCode

    Question 637. Average of Levels in Binary Tree Solution 思路:定义一个map,层数作为key,value保存每层的元素个数和所有元素的和,遍历这 ...

  6. CoaXPress 线缆和接插件的设计要求

    本文的原理部分内容不仅适用于CoaXPress 协议,也同样适用于其它高速信号传输情形.在高速.低干扰信号传输时,线缆和接插件的选取是非常讲究的,我们在实际应用中经常会遇到线缆原因.阻抗匹配原因导致的 ...

  7. 场景实践:基于 IntelliJ IDEA 插件部署微服务应用

    体验简介 阿里云云起实验室提供相关实验资源,点击前往 本场景指导您把微服务应用部署到 SAE 平台: 登陆 SAE 控制台,基于 jar 包创建应用 基于 IntelliJ IDEA 插件更新 SAE ...

  8. SpringSecurity的 loginProcessingUrl为什么不能用

    前情提要: 我在做一个springsecurity动态鉴权的项目时, 据网上说配置了 loginProcessingUrl("/login1"); 以后 就可以自定义login的请 ...

  9. Excel中把汉字转换成拼音码

    1.启动Excel 2003(其它版本请仿照操作),打开相应的工作表: 2.执行"工具→宏→Visual Basic编辑器"命令(或者直接按"Alt+F11"组 ...

  10. 类型转换——JavaSE基础

    类型转换 类型判断 可以通过Instanceof关键字判断左操作数是否是右操作数的父类或本身 强制类型转换 不能对布尔值进行转换 不能将对象类型转换为不相关的类型 把高容量转向低容量时,需要进行强制类 ...