ICode9

精准搜索请尝试: 精确搜索
首页 > 编程语言> 文章详细

Java集合源码分析(十):ArrayList

2021-05-16 15:32:22  阅读:164  来源: 互联网

标签:index Java int ArrayList elementData 源码 数组 size


前面做了这么多准备,终于开始了哈,ArrayList开淦!ArrayList应该是我们使用最频繁的集合类了吧,我们先来看看文档是怎么介绍它的。
在这里插入图片描述
我们可以知道,ArrayList其实就是Vector的翻版,只是去除了线程安全。ArrayList是一个可以动态调整大小的List实现,其数据的顺序与插入顺序始终一致,其余特性与List一致。

一、ArrayList继承结构

在这里插入图片描述

从结构图中可以看到,ArrayList是AbstractList的子类,同时实现了List接口。除此之外,它还实现了三个标识型接口,这几个接口都没有任何方法,仅作为标识表示实现类具备某项功能。RandomAccess表示实现类支持快速随机访问,Cloneable表示实现类支持克隆,具体表现为重写了clone方法,java.io.Serializable则表示支持序列化,如果需要对此过程自定义,可以重写writeObject与readObject方法。

但是ArrayList这一部分面试高频问的点主要是在 ArrayList的初始大小是多少? 在初始化ArrayList时,可能都是直接调用无参构造函数,从未了解或者关注过此类问题,就像下面这样:

ArrayList<String> strings = new ArrayList<>();

我们在前几次的内容中也提到过,ArrayList是基于数组的,而且呢,数组的长度也是不可变的,那就有这么一个问题,既然数组是定长的,那为什么ArrayList为什么不需要指定长度而就可以实现既可以插入一条数据,也可以插入几千甚至上万条数据呢?

ArrayList可以动态调整其大小,所以我们才可以无感知的插入多条数据,这也就说明了ArrayList一定有一个默认的大小。而想要扩充其大小,只能通过复制,这样一来,默认大小以及如何动态调整其大小就会对其性能产生非常大的影响,接下俩我们来举一个例子来说明此情况:

比如一个默认大小为10,我们向其中插入10条数据,此时并没有什么影响,如果我们还想在插入20条数据,就需要将ArrayList的大小调整到30,此时就涉及到一次数组的复制,如果还想继续在插入50条数据呢,那就又要通过复制数组,把大小调整到80.也就是说,当容量已用完或者不够用时,我们每向其中插入一条数据,都要涉及到一次数据的拷贝,而且数据越大,需要拷贝的数据就越多,其性能也会迅速下降。

ArrayList仅仅是对数组的一个封装,里面肯定是采取了一些措施来解决以上我们所提到的问题,我们如果不利用这些来提升性能的措施,那和使用数组又有什么区别呢。就让我们一起来看看ArrayList采取了哪些措施,并且怎么去使用它们吧?

我们先从初始化说起。

二、ArrayList构造方法与初始化

ArrayList一共有三个构造方法,用到了以下两个成员变量:

//这是一个用来标记存储容量的数组,也是存放实际数据的数组。
//当ArrayList扩容时,其capacity就是这个数组应有的长度。
//默认时为空,添加进第一个元素后,就会直接扩展到DEFAULT_CAPACITY,也就是10
//这里和size区别在于,ArrayList扩容并不是需要多少就扩展多少的
transient Object[] elementData;

//这里就是实际存储的数据个数了
private int size;

这里说明一下哈:经过transient关键字修饰的字段是不能够被序列化的。

除了以上两个变量,还需要掌握一个变量,它就是:

protected transient int modCount = 0;

这个变量的主要作用
就是防止在进行一些操作时,改变了ArrayList的大小,那将使得结果变得不可预测。

除了这些,还有一些:

 private static final int DEFAULT_CAPACITY = 10;
  private static final Object[] EMPTY_ELEMENTDATA = {};
  private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};

下面我们来看看构造函数:

1、构造函数

//默认构造方法。文档说明其默认大小为10,但正如elementData定义所言,
//只有插入一条数据后才会扩展为10,而实际上默认是空的
 public ArrayList() {
    this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}

//带初始大小的构造方法,一旦指定了大小,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);
    }
}

//从一个其他的Collection中构造一个具有初始化数据的ArrayList。
//这里可以看到size是表示存储数据的数量
//这也展示了Collection这种抽象的魅力,可以在不同的结构间转换
public ArrayList(Collection<? extends E> c) {
    //转换最主要的是toArray(),这在Collection中就定义了
    elementData = c.toArray();
    if ((size = elementData.length) != 0) {
        if (elementData.getClass() != Object[].class)
            elementData = Arrays.copyOf(elementData, size, Object[].class);
    } else {
        // replace with empty array.
        this.elementData = EMPTY_ELEMENTDATA;
    }
}

可以看到,在默认的无参构造器中,这个所创建的ArrayList实际上还是
空的,只有当插入一条数据后,容量才会扩展到默认的10.这里要注意到。

二、ArrayList中重写的方法

我们都知道,ArrayList已经是一个具体的实现类了,所以在List接口中定义的所有方法都在其中被实现。ArrayList中有一些已经在AbstractList实现过的方法,但事在这里再次被重写,我们来看一看有什么不同。

先来看一些较简单的方法:

//还记得在AbstractList中的实现吗?那是基于Iterator完成的。
//在这里完全没必要先转成Iterator再进行操作
public int indexOf(Object o) {
    if (o == null) {
        for (int i = 0; i < size; i++)
            if (elementData[i]==null)
                return i;
    } else {
        for (int i = 0; i < size; i++)
            if (o.equals(elementData[i]))
                return i;
    }
    return -1;
}

//和indexOf是相同的道理
 public int lastIndexOf(Object o) {
    //...
}

//一样的道理,已经有了所有元素,不需要再利用Iterator来获取元素了
//注意这里返回时把elementData截断为size大小
public Object[] toArray() {
    return Arrays.copyOf(elementData, size);
}

//带类型的转换,看到这里a[size] = null;这个用处真不大,除非你确定所有元素都不为空,
//才可以通过null来判断获取了多少有用数据。
public <T> T[] toArray(T[] a) {
    if (a.length < size)
        // 给定的数据长度不够,复制出一个新的并返回
        return (T[]) Arrays.copyOf(elementData, size, a.getClass());
    System.arraycopy(elementData, 0, a, 0, size);
    if (a.length > size)
        a[size] = null;
    return a;
}

看完这几个简单的,我们再来看看增删改查,而在增删改查中,改和查都不涉及到数组长度的变化,而增删就涉及到动态调整大小的问题,也就与性能挂钩,那我们就来先看看改和查是怎么实现的:

private void rangeCheck(int index) {
    if (index >= size)
        throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
}

//获取对应下标处的元素
    E elementData(int index) {
        return (E) elementData[index];
    }

//只要获取的数据位置在0-size之间即可
public E get(int index) {
    rangeCheck(index);

    return elementData(index);
}

//改变下对应位置的值
public E set(int index, E element) {
    rangeCheck(index);

    E oldValue = elementData(index);
    elementData[index] = element;
    return oldValue;
}

增和删是ArrayList最重要的部分,这部分代码需要我们仔细去推敲,搞明白,我们来看看源码是如何实现的

//在最后添加一个元素
public boolean add(E e) {
    //先确保elementData数组的长度足够
    ensureCapacityInternal(size + 1);  // Increments modCount!!
    elementData[size++] = e;
    return true;
}

public void add(int index, E element) {
    rangeCheckForAdd(index);

    //先确保elementData数组的长度足够
    ensureCapacityInternal(size + 1);  // Increments modCount!!
    //将数据向后移动一位,空出位置之后再插入
    System.arraycopy(elementData, index, elementData, index + 1,
                         size - index);
    elementData[index] = element;
    size++;
}

小伙伴们,应该不难发现,以上两种方法都用到了 ensureExplicitCapacity方法,我们来看看这方法是怎么实现的:

//在定义elementData时就提过,插入第一个数据就直接将其扩充至10
private void ensureCapacityInternal(int minCapacity) {
    if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
        minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
    }
    
    //这里把工作又交了出去
    ensureExplicitCapacity(minCapacity);
}

//如果elementData的长度不能满足需求,就需要扩充了
private void ensureExplicitCapacity(int minCapacity) {
    modCount++;

    // overflow-conscious code
    if (minCapacity - elementData.length > 0)
        grow(minCapacity);
}

//扩充
private void grow(int minCapacity) {
    // overflow-conscious code
    int oldCapacity = elementData.length;
    //可以看到这里是1.5倍扩充的
    int newCapacity = oldCapacity + (oldCapacity >> 1);
    
    //扩充完之后,还是没满足,这时候就直接扩充到minCapacity
    if (newCapacity - minCapacity < 0)
        newCapacity = minCapacity;
    //防止溢出
    if (newCapacity - MAX_ARRAY_SIZE > 0)
        newCapacity = hugeCapacity(minCapacity);
    // minCapacity is usually close to size, so this is a win:
    elementData = Arrays.copyOf(elementData, newCapacity);
}

我们来分析分析整个add方法过程:

add(E e),在list末尾添加元素:
这里我们假设当前已有一个stuList,其size为7,。

第一步:在这里插入图片描述
因为是在list末尾添加元素,所以扩容时所需要的最小容量为当前的 size+1;也就是8

第二步:
在这里插入图片描述
这里的ensureCapacityInternal方法体里是调用的是ensureExplicitCapacity方法,传入的参数是calculateCapacity方法的返回值minCapacity ,我们先来看看参数部分

第三步:
在这里插入图片描述
可以看到,这个方法传进来的参数一个是当前list的数组,也就是当前stuList的容量为8的数组,还有一个呢就是就是第一步里的minCapacity(8)。如果当前数组是调用空参构造器创建ArrayList时的默认空数组,那就返回最小容量和默认容量(10)二者中较大的一个,如果不是的话,就返回 size+1 的最小容量。这里不是默认空数组,所以就返回8.

第四步:在这里插入图片描述
modCount是记录当前集合被修改的次数。这里如果最小容量大于当前数组的长度,8>7,所以到下一步grow,来扩展器容量。

第五步:
在这里插入图片描述
在这里插入图片描述

将当前的数组未改变的长度赋值给oldCapacity(7),newCapacity的值为旧容量+旧容量÷2(此时新容量10,最小容量为8),然后在比较,如果新容量小于最小容量的话,新容量就在赋值为最小容量(新容量还是为10),在下一步,就是防止溢出了,如果新容量大于最大的数组长度(Integer.MAX_VALUE - 8),就执行hugeCapacity方法:
在这里插入图片描述
返回值为:如果最小容量> MAX_ARRAY_SIZE,就返回int的最大上限,否则就返回数组的最大长度。接着上面继续,然后复制数组,将之前的元素复制过去,并且size变为newCapacity。

最后一步:
在这里插入图片描述

再在数组的最后将元素添加进去。

其他几个add方法这里就不一 一说明,其实现原理与上面 大同小异。

代码我们看到这里,我猜大家差不多也都明白了。ArrayList的扩容机制,其实就是:首先 创建一个空数组elementData,第一次插入数据时,直接扩展到10,如果elementData 的长度不够,就扩充1.5倍,如果还是不够的话,就使用需要的长度作为elementData的长度。

这样的方式显然比我们例子中好一些,但是在遇到大量数据时还是会频繁的拷贝数据。那么如何缓解这种问题呢,ArrayList为我们提供了两种可行的方案:

1、 使用ArrayList(int initialCapacity)这个有参构造,在创建时就声明一个较大的大小,这样解决了频繁拷贝问题,但是需要我们提前预知数据的数量级,也会一直占有较大的内存。

2、 除了添加数据时可以自动扩容外,我们还可以在插入前先进行一次扩容。只要提前预知数据的数量级,就可以在需要时直接一次扩充到位,与ArrayList(int initialCapacity)相比的好处在于不必一直占有较大内存,同时数据拷贝的次数也大大减少了。这个方法就是ensureCapacity(int minCapacity),其内部就是调用了ensureCapacityInternal(int minCapacity)。

还有一些add方法类似的方法,例如addAll等等方法,其实现原理也大同小异,这里我们就不一 一分析了,这里我把他列举出来,想深入了解可以自己去看看源码

//将elementData的大小设置为和size一样大,释放所有无用内存
public void trimToSize() {
    //...
}

//删除指定位置的元素
public E remove(int index) {
    //...
}

//根据元素本身删除
public boolean remove(Object o) {
    //...
}

//在末尾添加一些元素
public boolean addAll(Collection<? extends E> c) {
    //...
}

//从指定位置起,添加一些元素
public boolean addAll(int index, Collection<? extends E> c){
    //...
}

接下来我们在看看删 remove方法

在这里插入图片描述
先查看下标是否越界,然后计数当前几个被修改的次数,根据下标把要删除的元素先找到,然后再用arrayCopy方法来处理在,这里我们来举一个例子来看一下数组复制这个过程:
一个数组nums{a,b,c,d,e,f,g},我们要删除下标为3的元素,也就是d,源数组elementData,从下标index+1开始复制,目标数组elementData,从目标数组的下标index开始,复制numMoved个元素,System.arrayCopy(nums,4,nums,3,3)的结果就为{a,b,c,e,f,g}.
复制完之后,在把移动之后最后一个地方设为null。

其他几个与remove有关的方法其删除原理与上面大同小异。

ArrayList还对其父级实现的ListIterator以及SubList进行了优化,主要是使用位置访问元素,这里就不一 一说明了,感兴趣的小伙伴可以自己去看下源码。

三、 ArrayList其他的一些实现方法

ArrayList不仅实现了List中定义的所有功能,还实现了equals、hashCode、clone、writeObject与readObject等方法。这些方法都需要与存储的数据配合,否则结果将是错误的或者克隆得到的数据只是浅拷贝,或者数据本身不支持序列化等,这些我们定义数据时注意到即可。我们主要看下其在序列化时自定义了哪些东西。

//这里就能解开我们的迷惑了,elementData被transient修饰,也就是不会参与序列化
//这里我们看到数据是一个个写入的,并且将size也写入了进去
private void writeObject(java.io.ObjectOutputStream s)
    throws java.io.IOException{
    // Write out element count, and any hidden stuff
    int expectedModCount = modCount;
    s.defaultWriteObject();

    // Write out size as capacity for behavioural compatibility with clone()
    s.writeInt(size);

        // Write out all elements in the proper order.
    for (int i=0; i<size; i++) {
        s.writeObject(elementData[i]);
    }

    //modCount的作用在此体现,如果序列化时进行了修改操作,就会抛出异常
    if (modCount != expectedModCount) {
        throw new ConcurrentModificationException();
    }
}

readObject是一个相反的过程,就是把数据正确的恢复回来,并将elementData设置好即可,感兴趣可以自行阅读源码。

四、ArrayList线程不安全

import java.util.ArrayList;
import java.util.List;
import java.util.UUID;

public class NoSafeArrayList {
    public static void main(String[] args) {

        List<String> list=new ArrayList();
        for (int i=0;i<30;i++) {
            new Thread(()->{
                list.add(UUID.randomUUID().toString().substring(8));  //UUID工具类,取一个八位的随机字符串  ,还有一个常用的取不重复字符串的方法:system.currentTime()  当前时间戳
                System.out.println(list);
            }).start();
        }
    }
}

在这里插入图片描述
ArrayList类在多线程环境下是线程不安全的,在多线程读写情况下会抛出并发读写异常(ConcurrentModificationException)

ArrayList线程不安全主要体现在两个方面:

一、不是原子操作

elementData[size++] = e;

先赋值,size在+1

但线程执行这一段代码没什么毛病,但是在多线程环境下,问题可就大了。可能其中一个线程会覆盖另一个线程的值。

举个例子:

1、 列表为空 size = 0。
2、 线程 A 执行完 elementData[size] = e;之后挂起。A 把 “a” 放在了下标为 0 的位置。此时 size = 0。
3、 线程 B 执行 elementData[size] = e; 因为此时 size = 0,所以 B 把 “b” 放在了下标为 0 的位置,于是刚好把 A 的数据给覆盖掉了。
4、 线程 B 将 size 的值增加为 1。
5、 线程 A 将 size 的值增加为 2。

这样子,当线程 A 和线程 B 都执行完之后理想情况下应该是 “a” 在下标为 0 的位置,“b” 在标为 1 的位置。而实际情况确是下标为 0 的位置为 “b”,下标为 1 的位置啥也没有。

二、扩容时非原子操作

ArrayList 默认数组大小为 10。假设现在已经添加进去 9 个元素了,size = 9。

1、 线程 A 执行完 add 函数中的ensureCapacityInternal(size + 1)挂起了。
2、 线程 B 开始执行,校验数组容量发现不需要扩容。于是把 “b” 放在了下标为 9 的位置,且 size 自增 1。此时 size = 10。
3、 线程 A 接着执行,尝试把 “a” 放在下标为 10 的位置,因为 size = 10。但因为数组还没有扩容,最大的下标才为 9,所以会抛出数组越界异常ArrayIndexOutOfBoundsException。

五、总结

1、 ArrayList其底层其实用一个elementData数组实现的。

2、 ArrayList与数组不同的点就在于ArrayList的grow方法,可以实现自动扩容。

3、 ArrayList是可以存放null值的哦

4、 ArrayList还是和数组一样,更适合于数据随机访问(查和改),而不太适合于大量的插入与删除,插入和删除还是用LinkedList好一些。

如果补充的不到位,欢迎小伙伴们留言补充!

标签:index,Java,int,ArrayList,elementData,源码,数组,size
来源: https://blog.csdn.net/weixin_45827693/article/details/116741016

本站声明: 1. iCode9 技术分享网(下文简称本站)提供的所有内容,仅供技术学习、探讨和分享;
2. 关于本站的所有留言、评论、转载及引用,纯属内容发起人的个人观点,与本站观点和立场无关;
3. 关于本站的所有言论和文字,纯属内容发起人的个人观点,与本站观点和立场无关;
4. 本站文章均是网友提供,不完全保证技术分享内容的完整性、准确性、时效性、风险性和版权归属;如您发现该文章侵犯了您的权益,可联系我们第一时间进行删除;
5. 本站为非盈利性的个人网站,所有内容不会用来进行牟利,也不会利用任何形式的广告来间接获益,纯粹是为了广大技术爱好者提供技术内容和技术思想的分享性交流网站。

专注分享技术,共同学习,共同进步。侵权联系[81616952@qq.com]

Copyright (C)ICode9.com, All Rights Reserved.

ICode9版权所有