【DS】详解Java集合中ArrayList的扩容机制

一. ArrayList简介

ArrayList类又称动态数组, 同时实现了 Collection 和 List 接口,其内部数据结构由数组实现,因此可对容器内元素实现快速随机访问; 具体的框架图如下:

【说明】

1. ArrayList实现了RandomAccess接口,表明ArrayList 支持随机访问
2. ArrayList实现了Cloneable接口,表明ArrayList是 可以clone的
3. ArrayList实现了Serializable接口,表明ArrayList 是支持序列化的
4. 和Vector不同,ArrayList不是线程安全的,在单线程下可以使用,在多线程中可以选择Vector或者CopyOnWriteArrayList
5. ArrayList底层是一段连续的空间,并且可以动态扩容,是一个动态类型的顺序表
6. ArrayList中插入或删除一个元素需要移动其他元素,所以不适合在插入和删除操作频繁的场景下使用

二. ArrayList的构造方法

ArrayList有三种构造方法:

要学习这些构造方法需要先了解源码中的一些成员变量和常量:

// 默认的容量大小(常量)
private static final int DEFAULT_CAPACITY = 10;

// 定义的空数组(final修饰,大小固定为0)
private static final Object[] EMPTY_ELEMENTDATA = {};

// 定义的默认空容量的数组(final修饰,大小固定为0)
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};

// 定义的不可被序列化的数组,实际存储元素的数组
transient Object[] elementData; 

// 数组中元素的个数
private int size;

1. 无参的构造方法

// 无参的构造方法
public ArrayList() {
    this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}

观察这里给出的无参构造方法, 可以发现当我们直接无参实例化一个ArrayList时, elementData被赋予了默认空容量的数组;

要注意的是, 默认空容量数组是被final修饰的,这个数组是无法进行修改的, 所以此时ArrayList数组是空的、固定长度的,也就是说ArrayList数组此时的容量为0, 元素的个数size为默认值0.

看到这里小伙伴们可能会有一些疑问, 可能大家知道的是实例化ArrayList掉用无参构造方法时不是默认时分配10个空间的容量吗,这里为啥是0呢, 其实这跟ArrayList的扩容机制有关, 在后文中会有解释, 对于无参构造方法默认开辟10个空间这个说法是不准确的.

示例:

实例化一个0容量的顺序表:

List<Integer> list = new ArrayList<>();

2. 根据传入的数值大小, 创建指定长度的数组

// 传容量的构造方法
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);
    }
}
  • 当initialCapacity > 0时,会在堆上new一个大小为initialCapacity的数组,然后将其引用赋给elementData,此时ArrayList的容量为initialCapacity,元素个数size为默认值0。

  • 当initialCapacity = 0时,elementData被赋予了默认空数组,因为其被final修饰了,所以此时ArrayList的容量为0,元素个数size为默认值0。

  • 当initialCapacity < 0时,会抛出异常。

示例:

实例化一个容量为5的顺序表:

List<Integer> list2 = new ArrayList<>(15);

3. 通过传入Collection元素列表进行生成

// 传入Collection元素列表的构造方法
public ArrayList(Collection<? extends E> c) {
    // 将列表转化为对应的数组
    elementData = c.toArray();
    //更新size
    if ((size = elementData.length) != 0) {
        if (elementData.getClass() != Object[].class)
            // 把数组类型变成Object类型
            elementData = Arrays.copyOf(elementData, size, Object[].class);
    } else {
        // 赋予空数组
        this.elementData = EMPTY_ELEMENTDATA;
    }
}
  • 传入Collection元素列表后,构造方法首先会将其转化为数组,将其索引赋给elementData;

  • 如果此数组的长度为0,会重新赋予elementData为默认的空数组,此时ArrayList的容量是0,元素个数size为0。

  • 如果此数组的长度大于0,会更新size的大小为其长度,同时将数组的类型转化为Object, 此时ArrayList的容量为传入序列的长度,也就是size的大小,同时元素个数也为size,也就是说,此时ArrayList是中放的就是传进来的元素列表中的元素。

示例:

实例化一个和list2元素一致的顺序表:

List<Integer> list2 = new ArrayList<>(15);
List<Integer> list3 = new ArrayList<>(list2);

4. 错误的实例化

观察下面的实例化方式有什么问题, 初学时很容易写出这样的代码:

List list = new ArrayList();

这种实例化方式,没有指定泛型的类型,此时顺序表中可以存放任意类型的元素,这样的代码使用和维护都不太方便。

三. ArrayList的扩容机制

1. 源码分析

上面在介绍无参构造方法时, 强调了当使用无参构造方法时, 顺序表中其实是一个容量为0的空数组, 那么既然容量为0, 又如何往其中添加元素呢, 这里就引出了它的扩容机制了.

首先看源码中的add方法, 假设我们是第一次往顺序表中添加元素, 那么size此时就是0;

    Object[] elementData; // 存放元素的空间
    private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {}; // 默认空间
    private static final int DEFAULT_CAPACITY = 10; // 默认容量大小
    public boolean add(E e) {
        ensureCapacityInternal(size + 1); // Increments modCount!!
        elementData[size++] = e;
        return true;
    }
    private void ensureCapacityInternal(int minCapacity) {
        ensureExplicitCapacity(calculateCapacity(elementData, minCapacity));
    }
    private static int calculateCapacity(Object[] elementData, int minCapacity) {
        if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
            return Math.max(DEFAULT_CAPACITY, minCapacity);
        }
        return minCapacity;
    }
    private void ensureExplicitCapacity(int minCapacity) {
        modCount++;
        // overflow-conscious code
        if (minCapacity - elementData.length > 0)
            grow(minCapacity);
    }
    private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
    private void grow(int minCapacity) {
        // 获取旧空间大小
        int oldCapacity = elementData.length;
        // 预计按照1.5倍方式扩容
        int newCapacity = oldCapacity + (oldCapacity >> 1);
        // 如果用户需要扩容大小 超过 原空间1.5倍,按照用户所需大小扩容
        if (newCapacity - minCapacity < 0)
            newCapacity = minCapacity;
        // 如果需要扩容大小超过MAX_ARRAY_SIZE,重新计算容量大小
        if (newCapacity - MAX_ARRAY_SIZE > 0)
            newCapacity = hugeCapacity(minCapacity);
        // 调用copyOf扩容
        elementData = Arrays.copyOf(elementData, newCapacity);
    }
    private static int hugeCapacity(int minCapacity) {
        // 如果minCapacity小于0,抛出OutOfMemoryError异常
        if (minCapacity < 0)
            throw new OutOfMemoryError();
        return (minCapacity > MAX_ARRAY_SIZE) ? Integer.MAX_VALUE : MAX_ARRAY_SIZE;
    }

通过对于源码的分析此时我们就知道了当第一次add时, 先执行的操作是将数组的容量是由0扩容为10;

【总结】

1. 检测是否真正需要扩容,如果是调用grow准备扩容
2. 预估需要库容的大小
初步预估按照1.5倍大小扩容
如果用户所需大小超过预估1.5倍大小,则按照用户所需大小扩容
真正扩容之前检测是否能扩容成功,防止太大导致扩容失败
3. 使用copyOf进行扩容

2. 关于构造和扩容的总结

[扩容可分为两种情况]:

第一种情况,当ArrayList的容量为0时,此时添加元素的话,需要扩容,三种构造方法创建的ArrayList在扩容时略有不同:

  • 无参构造,创建ArrayList后容量为0,添加第一个元素后,容量变为10,此后若需要扩容,则正常扩容。

  • 传容量构造,当参数为0时,创建ArrayList后容量为0,添加第一个元素后,容量为1,此时ArrayList是满的,下次添加元素时需正常扩容。

  • 传列表构造,当列表为空时,创建ArrayList后容量为0,添加第一个元素后,容量为1,此时ArrayList是满的,下次添加元素时需正常扩容。

第二种情况,当ArrayList的容量大于0,并且ArrayList是满的时,此时添加元素的话,进行正常扩容,每次扩容到原来的1.5倍。

三. ArrayList常见方法

[注意]:

  • contains方法判断元素是否存在是根据元素的equals方法进行比较,若元素中没有重写equals方法,那么contains将比较的是对象的引用 , 这里代码中的String类中是重写了equals方法的。

  • 这里的截取并不是获取到列表范围内的元素再拷贝到一个新的列表中 , 也就是说 , 截取所得到的列表和被截取的列表是同一个列表 , 在同一个空间范围内 , 只是指向不同 , 所以上面的代码中修改截取到列表中的元素 , 原来的列表中的内容也会被修改.

四. 遍历ArraayList

ArrayList 可以使用四种方式遍历:直接输出, for循环+下标, foreach, 使用迭代器

1. 直接输出

直接输出有一个缺陷 , 它不能修改其中的内容 , 只能一次性全部输出.

public static void main(String[] args) {
        List<String> list=new ArrayList<>();
        list.add("张三");
        list.add("李四");
        list.add("王五");
        list.add("赵钱");
        list.add("孙李");
        System.out.println(list);
    }

2. for循环+下标/foreach

public static void main(String[] args) {
        List<String> list=new ArrayList<>();
        list.add("张三");
        list.add("李四");
        list.add("王五");
        list.add("赵钱");
        list.add("孙李");    
        //for循环+下标
        for (int i = 0; i < list.size(); i++) {
            System.out.print(list.get(i)+" ");
        }        
        //foreach
        for (String s:list) {
            System.out.print(s+" ");
        }
    }

3. 通过迭代器遍历和删除

迭代器是一种设计模式,迭代器可以用于遍历集合,开发人员不必去了解这个集合的底层结构。迭代器封装了数据获取和预处理逻辑,屏蔽了容器的实现细节,无需暴露数据结构内部。在数据量非常庞大时使用迭代器进行数据迭代获取,避免全部取出占用过多的服务器资源,且可以对部分数据进行预加载,提升性能。

下面给出的是Iterator迭代器接口的源码 , Iterator中有三个方法:分别为hasNext() , next() , remove()

package java.util;

import java.util.function.Consumer;

public interface Iterator<E> {
    //如果迭代有更多元素,则返回true
    boolean hasNext();

    //返回迭代中的下一个元素
    //如果没有元素则抛出NoSuchElementException异常
    E next();

    //从底层集合中移除此迭代器已经返回的最后一个元素,只能在next被调用后使用
    default void remove() {
        throw new UnsupportedOperationException("remove");
    }

    //对每个剩余元素执行给定的操作,直到处理完所有元素或操作引发异常。 
    default void forEachRemaining(Consumer<? super E> action) {
        Objects.requireNonNull(action);
        while (hasNext())
            action.accept(next());
    }
}

集合想要获取一个迭代器可以使用 iterator() 方法返回一个用Iterator接收的对象 , 这个对象所对应的类是ArrayList中的内部类 , 其中重写了Iterator接口中的那些方法。

注意:iterator()方法是java.lang.Iterable接口中的抽象方法 , 被Collection继承; 最终在ArrayList中实现了iterator(); 要使用迭代器, 必须是在实现迭代器接口的前提下.

也可以使用 listIterator() 方法 , 对应的 listIterator 接口其实是继承了Iterator接口.

   //遍历元素
public static void main(String[] args) {
        List<String> list=new ArrayList<>();
        list.add("张三");
        list.add("李四");
        list.add("王五");
        list.add("赵钱");
        list.add("孙李");

        System.out.println("遍历元素");
        Iterator<String> it = list.listIterator();
        while (it.hasNext()) {
            System.out.print(it.next()+" ");
        }
    }
   //删除元素
public static void main(String[] args) {
        List<String> list=new ArrayList<>();
        list.add("张三");
        list.add("李四");
        list.add("王五");
        list.add("赵钱");
        list.add("孙李");

        Iterator<String> it = list.listIterator();
        System.out.println("删除第一个元素");
        it.next();
        it.remove();
        System.out.println(list);
    }

五.顺序表的问题及思考

1. 顺序表中间/头部的插入删除,时间复杂度为O(N)

2. 增容需要申请新空间,拷贝数据,释放旧空间。会有不小的消耗。

3. 增容一般是呈2倍的增长,势必会有一定的空间浪费。例如当前容量为100,满了以后增容到200,我们再继续插入了5个数据,后面没有数据插入了,那么就浪费了95个数据空间。

思考: 如何解决以上问题呢?

下一节链表将继续解决。

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

苏黎世卡

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值