【源码系列】Java中的数据结构——数组与ArrayList1

简介: 【源码系列】Java中的数据结构——数组与ArrayList

前言


自从上次字节面试凉了之后,我就一直有这个想法,想写个源码系列的博客。无奈最近事情太多,无法真正静下心来写。原本是想暑假来好好写这个系列,但因为下周要由我来负责协会授课,所以只能在这周写完。也好,毕竟只有ddl才有效率嘛(笑哭)。


关于本系列


作为本系列的第一篇文章,我想讲讲我的对于此系列的想法。

首先作为源码系列的文章,我肯定会讲讲源码,当然我也只能是作为一个菜鸟的身份,带着大家一起阅读源码,顺便说说我自己的理解。


当然了,我也不想仅限于源码,我更希望达到一种效果——让概念与实践融合。


所以在讲源码之前,我会把这个数据结构的概念通讲一遍,在这个过程中我也会尽量抛出一些问题,来帮助大家思考理解。


当然,本人技术水平有限,大佬勿喷。


注:本系列的jdk版本为1.8

一、指令与二进制数据


在正式介绍今天的数据结构之前,我想让大家切换一下视角,来到硬件底层来看看我们的程序。


相信大家也都听过,数据结构和算法课上老师一定会说的一句话——“程序就是算法和数据结构”,当然我也相信听了这话的你一定是云里雾里的,感觉很高深,很对,但又说不来怎么个对法。


其实从硬件层面上来看就不难理解了。

在硬件层面,算法对应的就是cpu的一条条指令,数据结构便是对应内存中二进制数字(的存储方式),而计算机最核心的操作不就是拿着二进制数据去执行一条条指令吗?


那么问题来了,我们该如何去拿到内存中的数据呢?


这么多的数据我们又该如何确定数据存储的位置呢?


我们自然而然想到需要有个类似地址的东西来描述内存中数据的位置,这样数据才能被准确访问。


而这个位置便叫做物理地址。


由此引出指针这个概念,指针存储的就是数据的地址,当然这个地址和上面说的物理地址还是有些不同的,指针存储的一般是虚拟地址。


不过呢,现在你只需要知道两件事——

1.数据是通过地址来进行访问的

2.指针存储的就是数据的地址,计算机可以通过指针的地址访问该地址的数据


为什么要讲上面这些内容呢,这是为了让你更好的理解下面的内容。


二、最常用的数据结构——数组

1.理解数组原理

首先,我们来看看数组的声明与创建:


int[] nums=new int[5];

这是一条最简单的用Java写的数组声明与创建。


它主要干了两件事:

1. 声明了一个int类型的一维数组

2. 给这个数组分配了五个int长度的存储空间


那如果我想访问数组中第3个元素怎么办呢?


你只需要写上


nums[2]

即可访问。


那我们来仔细思考一个问题——为什么它就能访问到该数组的第三个元素呢?

换句话说,我们为什么只写nums[2]就能确定第二个元素的确切地址呢?


其实啊,nums数组本身便是一个指针(PS:Java中没有指针这个概念,但实际上除了基本类型外,处处都是指针,具体可以看这篇Java中有没有指针。当然官方叫做句柄,但是我觉得指针更为贴切一点),它指向数组0号元素的地址,而我们之前又声明了数组的类型,再加上数组下标,我们不难算出第三个元素地址为


nums地址+int字节数*2。


专业描述就是:


a[i]_address = base_address + i * data_type_size

q1.png

(图片来源于极客时间数据结构与算法之美)


而我们常说的——数组支持随机访问,根据下标随机访问的时间复杂度为 O(1),相信大家也就不难理解了吧。


有了数组,我们可以方便的存储同种类型元素,而不用为每个元素进行烦人的命名。


但数组有没有什么不方便的地方呢?


2.数组的优缺点

优点

1.支持随机访问

由于数组顺序存储,所以知道下标即可算出地址,这样根据下标访问数据的时间复杂度就为O(1)


2.同类型存储,避免烦人的命名

当然这个也是所有集合通用的优点


缺点(局限)

1.数组大小无法改变

由于数组元素在内存中的存储方式是连续存储,而大小在一开始便已经确定(这个也很好理解,不确定你怎么知道它之后的空间能不能分配给其他人),所以数组无法扩容。


而很多时候我们无法一开始就确定准确的数组大小,所以这对于某些情况就不太友好。


2.增加删除数组元素操作比较繁琐

除了在数组最后增加删除元素,其他时候增删数组元素都会牵扯到数组元素的移动,因为数组是顺序存储的,当中间的元素增加或删除时,后面的元素需要后移或前移。


总的来说,顺序存储的方式让数组支持随机访问,但也因此造成一些其他的局限。


三、Java中的封装类ArrayList

在理解完数组的原理和特点后,我们再来看看今天的主角——ArrayList。


它是Java对数组这个数据结构的封装,它封装了一些数组常用的操作,让操作更加简便,同时让数组看起来可以自动扩容。


好了,接下来开始我们的源码之旅。


源码阅读

1.源码阅读的方式

当然,在开始源码之前,我想啰嗦几句我关于源码研读的看法。


源码,在我看来就像一个巨大的迷宫,这对于体型庞大的jdk库更是如此。任何一个类都会牵扯到很多父类和接口,类中、类间方法的调用随处可见。如果你从头到尾阅读,那么你很容易迷失在这巨大的迷宫中。


读懂源码,走出迷宫的最好方式便是有一张地图(这个后面会讲)。然后根据地图,我们选择从我们熟悉的方法入手,慢慢研读,遇到不会的可以看看注释。如果还是不懂可以先跳过。很多时候我们首先要做的不是抓住每一个细节,而是看懂这个方法,看懂这个类大概是做什么的,做到心中有数,而很多时候方法的意思也能从方法名称中看出一二。


等你大致阅读了一遍明白了大概的框架后,再着眼细处也不迟。


所以接下来的源码之旅我也会据此展开。


2.ArrayList源码之旅

2.1 “迷宫地图”

ArrayList位于jdk的util包中,所以我们先打开ArrayList的源码。


打开idea,随便输个ArrayList,然后ctrl+点击,这样就进入了ArrayList的源码处,鼠标放置util处,右键选择图,选择第一个

q1.png



这样我们就生成了第一张地图——继承关系图

q2.png


是不是有点头晕,别急,这里你只需要大概记住继承关系即可。


然后返回ArrayList源码处,点击左侧的结构


q3.png


这样我们就有了第二张地图,它也是我们本次的向导,将带领我们此次的源码之旅。


2.2 属性字段

首先我们要看的是属性字段


//版本号,这个可以忽略
private static final long serialVersionUID = 8683452581122892189L;
//默认容量,这里为10,后面会提到
private static final int DEFAULT_CAPACITY = 10;
//一个空的对象数组
private static final Object[] EMPTY_ELEMENTDATA = {};
//另一个空的对象数组,为什么有两个呢?主要是为了区分后面的用法
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
//真正存储数据的数据,整个ArrayList就是围绕它展开的,transient表示该属性无法被序列化
transient Object[] elementData; 
//当前的元素数量
private int size;


有人可能会觉得这会占很多空间,其实不是的,前面几个属性都是static final,即静态常量,它们并不会占用对象的内存空间,当你创建ArrayList时,真正占空间的是elementData数组和size属性。


2.3 常用方法

这里我们主要探究List接口的方法


2.3.1 ArrayList构造函数

/**
 * 构造一个初始容量为10的空列表。
 */
public ArrayList() {
    this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}
/**
 * 构造一个具有指定初始容量的空列表。
 */
public ArrayList(int initialCapacity) {
    if (initialCapacity > 0) {
        this.elementData = new Object[initialCapacity];
    } else if (initialCapacity == 0) {
        this.elementData = EMPTY_ELEMENTDATA;
    } else {
        throw new IllegalArgumentException("Illegal Capacity: "+
                initialCapacity);
    }
}
/**
 * 构造一个列表,该列表包含指定集合的元素,其顺序由集合的迭代器返回。
 */
 public ArrayList(Collection<? extends E> c) {
    Object[] a = c.toArray();
    if ((size = a.length) != 0) {
        if (c.getClass() == java.util.ArrayList.class) {
            elementData = a;
        } else {
            elementData = Arrays.copyOf(a, size, Object[].class);
        }
    } else {
        // replace with empty array.
        elementData = EMPTY_ELEMENTDATA;
    }
}


这里我们看到ArrayList有三种构造方法:


ArrayList():创建一个默认ArrayList对象。

这里有个很有意思的地方——它一开始并没有创建一个对象数组,而是把默认的空对象数组的指针赋给了它,至于注释里为什么说它是一个初始容量为10的地址呢,这个奥妙在于之后要讲的add方法。现在你只需要知道这里运用懒加载的技巧,所谓初始化并非给这个elementData 数组创建一个默认大小的对象数组,而是把一个空的对象数组指针赋给了它。尽管它这里是个空数组,但实际上你可以把它认为一个默认容量为10的对象数组。


ArrayList(int initialCapacity):这里创建一个大小为initialCapacity的ArrayList对象,这里有个细节的地方,当initialCapacity为0时,它是把之前那个空对象数组的指针赋给了它。


public ArrayList(Collection c):传入其他集合对象,调用toArray方法返回对象数组初始化一个ArrayList,其顺序由集合的迭代器决定。


2.3.2 add方法

这里有两个add方法,我们逐一来介绍。


①boolean add(E e)

第一个add是我最常用的往队尾添加一个元素,返回值表示添加是否成功、


public boolean add(E e) {
    ensureCapacityInternal(size + 1);  // Increments modCount!!
    elementData[size++] = e;
    return true;
}

添加一个元素到数组队尾,那么ensureCapacityInternal(size + 1)这句话干了什么呢?


点开方法可以看到


private void ensureCapacityInternal(int minCapacity) {
       ensureExplicitCapacity(calculateCapacity(elementData, minCapacity));
}

calculateCapacity方法作用是计算当前需要的容量大小


private static int calculateCapacity(Object[] elementData, int minCapacity) {
        if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
            return Math.max(DEFAULT_CAPACITY, minCapacity);
        }
        return minCapacity;
    }

从上面可以看到,它会判断当前的elementData 数组是不是DEFAULTCAPACITY_EMPTY_ELEMENTDATA,还记得之前说过ArrayList在执行空构造函数时的操作吗?就是让elementData 对象数组指向了DEFAULTCAPACITY_EMPTY_ELEMENTDATA,这时候执行add方法,他就会进入这个if语句中,返回DEFAULT_CAPACITY(默认容量10)和minCapacity(当前数组所需的最小容量)的最大值。


这就是当时为什么说执行无参构造函数时,虽然只是让elementData 指向一个空对象数组,但实际上可以认为它就是默认容量为10的数组的原因。


回到ensureCapacityInternal(int minCapacity)这个方法上来,在计算完所需容量后它会把这个返回值当做参数传入ensureExplicitCapacity(int minCapacity)方法中。


private void ensureExplicitCapacity(int minCapacity) {
  //这个暂时不用管它,它是为了防止并发修改时出现错误
        modCount++;
        // 判断当前所需的容量是否大于elementData数组的长度,即判断是否需要扩容
        if (minCapacity - elementData.length > 0)
            grow(minCapacity);
      }


当minCapacity 大于数组的长度时,说明此时的数组已经装不下现在的元素了,这时候就需要扩容,调用grow(int minCapacity)方法。


private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
private void grow(int minCapacity) {
        int oldCapacity = elementData.length;
        //正常情况是原来的3/2倍。注:这里用>>会比用/快
        int newCapacity = oldCapacity + (oldCapacity >> 1);
        //当计算好的新容量小于当前所需容量时,直接变成最小所需容量
        if (newCapacity - minCapacity < 0)
            newCapacity = minCapacity;
        //当新容量大于数组最大长度(Integer.MAX_VALUE - 8)时,执行hugeCapacity方法
        if (newCapacity - MAX_ARRAY_SIZE > 0)
          //这里会对边界情况作出处理,返回数组的边界大小
            newCapacity = hugeCapacity(minCapacity);
        // 调用Arrays.copyOf(最后会调用一个System.arraycopy方法)来进行数组复制,新数组大小为newCapacity,这样便实现了数组扩容
        elementData = Arrays.copyOf(elementData, newCapacity);
    }


这个方法就是扩容当前elementData ,它会把之前的elementData 复制并传入一个更大的数组中,并将elementData 的指针更新。而一般情况下扩容后的大小都是原来大小的3/2,除非到了边界或者当前所需的最小大小大于这个3/2,它会做出一些改变。


一句话来讲就是——grow方法会将当前的elementData 对象数组扩容到适合的大小,一般是原来的3/2倍。


回到最开始的问题——ensureCapacityInternal(size + 1)干了什么?


现在我们明白了,它是确保当前的数组容量能装下接下来的元素,如果不够它会选择扩容。


最后再执行


elementData[size++] = e;

即size自增1,然后将最后一个元素赋值为e。


这样add方法便完成了!


②add(int index, E element)

这个方法和上面类似,所以重复的部分我就不赘述了。


public void add(int index, E element) {
  //检查该index下标是否合法
    rangeCheckForAdd(index);
  //确保数组容量足够,如果不够则扩容
    ensureCapacityInternal(size + 1);  
    //调用arraycopy方法,让index后面的元素全部后移一位,好让新的元素加进来
    System.arraycopy(elementData, index, elementData, index + 1,
                     size - index);
    //赋值
    elementData[index] = element;
    //size自增1
    size++;
}


和之前类似,做了以下几步:


检查index下标是否合法

确保数组容量足够,如果不够则扩容

调用arraycopy方法,让index后面的元素全部后移一位,好让新的元素加进来

给index下标出的元素赋值

size++

这里我们看到每次插入需要做的便是将index下标后面的元素全部后移,所以效率不是特别高,比较麻烦。


2.3.3 remove方法

remove方法也有两种,一种是根据下标删除,一种是根据对象删除。


①E remove(int index)

根据数组下标删除元素

public E remove(int index) {
  //检查下标是否合法
        rangeCheck(index);
        //用于检查并发错误的标记变量++
        modCount++;
        //获取index下标的元素
        E oldValue = elementData(index);
  //index离最后一个元素的距离
        int numMoved = size - index - 1;
        //如果index后面存在元素则将index后面的元素前移一位
        if (numMoved > 0)
            System.arraycopy(elementData, index+1, elementData, index,
                             numMoved);
        //将之前最后一位元素指向null
        elementData[--size] = null; 
  //返回删除的对象
        return oldValue;
    }



这个方法说明看注释吧,注释说的很清楚了。


②boolean remove(Object o)

根据对象删除元素


public boolean remove(Object o) {
  //因为对象的判断需要调用equals方法,所以需要判断一下o是否为null
        if (o == null) {
          //遍历所有元素
            for (int index = 0; index < size; index++)
                if (elementData[index] == null) {
                  //快速删除index下标的元素
                    fastRemove(index);
                    return true;
                }
        } else {
            for (int index = 0; index < size; index++)
                if (o.equals(elementData[index])) {
                    fastRemove(index);
                    return true;
                }
        }
        return false;
    }



这里就是一次遍历所有元素,如果找到该对象就删除。


不过可以看看这个fastRemove方法,


private void fastRemove(int index) {
        modCount++;
        int numMoved = size - index - 1;
        if (numMoved > 0)
            System.arraycopy(elementData, index+1, elementData, index,
                             numMoved);
        elementData[--size] = null;
}

可以看到这里和前面非常类似,只不过它这里没有进行边界检查。


相关文章
|
17天前
|
XML Java 编译器
Java注解的底层源码剖析与技术认识
Java注解(Annotation)是Java 5引入的一种新特性,它提供了一种在代码中添加元数据(Metadata)的方式。注解本身并不是代码的一部分,它们不会直接影响代码的执行,但可以在编译、类加载和运行时被读取和处理。注解为开发者提供了一种以非侵入性的方式为代码提供额外信息的手段,这些信息可以用于生成文档、编译时检查、运行时处理等。
53 7
|
28天前
|
数据采集 人工智能 Java
Java产科专科电子病历系统源码
产科专科电子病历系统,全结构化设计,实现产科专科电子病历与院内HIS、LIS、PACS信息系统、区域妇幼信息平台的三级互联互通,系统由门诊系统、住院系统、数据统计模块三部分组成,它管理了孕妇从怀孕开始到生产结束42天一系列医院保健服务信息。
30 4
|
1月前
|
监控 Java 应用服务中间件
高级java面试---spring.factories文件的解析源码API机制
【11月更文挑战第20天】Spring Boot是一个用于快速构建基于Spring框架的应用程序的开源框架。它通过自动配置、起步依赖和内嵌服务器等特性,极大地简化了Spring应用的开发和部署过程。本文将深入探讨Spring Boot的背景历史、业务场景、功能点以及底层原理,并通过Java代码手写模拟Spring Boot的启动过程,特别是spring.factories文件的解析源码API机制。
73 2
|
9天前
|
存储 JavaScript 前端开发
基于 SpringBoot 和 Vue 开发校园点餐订餐外卖跑腿Java源码
一个非常实用的校园外卖系统,基于 SpringBoot 和 Vue 的开发。这一系统源于黑马的外卖案例项目 经过站长的进一步改进和优化,提供了更丰富的功能和更高的可用性。 这个项目的架构设计非常有趣。虽然它采用了SpringBoot和Vue的组合,但并不是一个完全分离的项目。 前端视图通过JS的方式引入了Vue和Element UI,既能利用Vue的快速开发优势,
60 13
|
10天前
|
存储 缓存 安全
Java 集合江湖:底层数据结构的大揭秘!
小米是一位热爱技术分享的程序员,本文详细解析了Java面试中常见的List、Set、Map的区别。不仅介绍了它们的基本特性和实现类,还深入探讨了各自的使用场景和面试技巧,帮助读者更好地理解和应对相关问题。
32 5
|
23天前
|
缓存 监控 Java
Java线程池提交任务流程底层源码与源码解析
【11月更文挑战第30天】嘿,各位技术爱好者们,今天咱们来聊聊Java线程池提交任务的底层源码与源码解析。作为一个资深的Java开发者,我相信你一定对线程池并不陌生。线程池作为并发编程中的一大利器,其重要性不言而喻。今天,我将以对话的方式,带你一步步深入线程池的奥秘,从概述到功能点,再到背景和业务点,最后到底层原理和示例,让你对线程池有一个全新的认识。
51 12
|
17天前
|
JavaScript 安全 Java
java版药品不良反应智能监测系统源码,采用SpringBoot、Vue、MySQL技术开发
基于B/S架构,采用Java、SpringBoot、Vue、MySQL等技术自主研发的ADR智能监测系统,适用于三甲医院,支持二次开发。该系统能自动监测全院患者药物不良反应,通过移动端和PC端实时反馈,提升用药安全。系统涵盖规则管理、监测报告、系统管理三大模块,确保精准、高效地处理ADR事件。
|
24天前
|
存储 缓存 算法
在C语言中,数据结构是构建高效程序的基石。本文探讨了数组、链表、栈、队列、树和图等常见数据结构的特点、应用及实现方式
在C语言中,数据结构是构建高效程序的基石。本文探讨了数组、链表、栈、队列、树和图等常见数据结构的特点、应用及实现方式,强调了合理选择数据结构的重要性,并通过案例分析展示了其在实际项目中的应用,旨在帮助读者提升编程能力。
45 5
|
1月前
|
存储 人工智能 算法
数据结构实验之C 语言的函数数组指针结构体知识
本实验旨在复习C语言中的函数、数组、指针、结构体与共用体等核心概念,并通过具体编程任务加深理解。任务包括输出100以内所有素数、逆序排列一维数组、查找二维数组中的鞍点、利用指针输出二维数组元素,以及使用结构体和共用体处理教师与学生信息。每个任务不仅强化了基本语法的应用,还涉及到了算法逻辑的设计与优化。实验结果显示,学生能够有效掌握并运用这些知识完成指定任务。
53 4
|
1月前
|
人工智能 监控 数据可视化
Java智慧工地信息管理平台源码 智慧工地信息化解决方案SaaS源码 支持二次开发
智慧工地系统是依托物联网、互联网、AI、可视化建立的大数据管理平台,是一种全新的管理模式,能够实现劳务管理、安全施工、绿色施工的智能化和互联网化。围绕施工现场管理的人、机、料、法、环五大维度,以及施工过程管理的进度、质量、安全三大体系为基础应用,实现全面高效的工程管理需求,满足工地多角色、多视角的有效监管,实现工程建设管理的降本增效,为监管平台提供数据支撑。
40 3