你真的了解 ArrayList 吗?(上)

文摘   2024-12-13 09:01   山东  

ArrayList 概述

ArrayList 是 Java 开发中广泛使用的集合之一,它是List 接口的一种实现方式。它采用可变数组作为底层数据结构,允许存储包括null 在内的任何元素。

关键特点

  • 存储有序ArrayList 内部维护了一个可变数组,用来存储列表中的元素,所以元素可以按照被添加到列表中的顺序进行保存。当遍历列表时,元素会按照它们被插入的顺序输出(也就是插入顺序 = 输出顺序)。
  • 动态扩容:当向ArrayList 添加元素时,如果当前容量不足以容纳新的元素,ArrayList 会自动扩充其容量。默认情况下,它会增加到当前容量的 1.5 倍。开发时,我们可以通过调用ensureCapacity(int minCapacity) 或者trimToSize() 方法来手动调整控制容量,以优化其存储性能。
  • 查询高效ArrayList 在查询时底层是通过访问数组元素方式进行查询的,而数组在声明时,会在内存中申请一块连续相邻的内存空间。当要通过索引访问数组元素时,可通过数组地址值和偏移量,直接计算出要查找元素的内存地址,所以数组支持通过索引直接访问元素,效率非常高。
  • 线程不安全ArrayList 是线程不安全的。如果多个线程同时访问一个ArrayList 实例,并且至少有一个线程对列表结构进行了修改(如添加或删除元素),则调用者必须从外部进行同步处理。可以使用Collections.synchronizedList() 方法中来创建一个线程安全的列表。

接口实现

  • List 接口:提供List 线性列表集合框架的基础行为,如添加、删除、获取和更新元素等。
  • RandomAccess 接口RandomAccess 是一个标记接口,没有任何接口方法,只用作标识某个类是否支持高效的随机访问。ArrayList 内部使用数组来存储元素,因此可以通过索引直接访问元素,时间复杂度为 O(1)。相比之下,LinkedList 没有实现RandomAccess 接口,因为它需要遍历链表来找到特定位置的元素,时间复杂度为 O(n)。
  • Cloneable 接口ArrayList 实现了Cloneable 接口,允许通过clone() 方法创建一个新的对象实例。不过这个操作是一个浅拷贝,也就是说新旧对象中的元素仍然是相同的引用。

ArrayList clone() 代码示例:

public class ArrayListCloneExample {
    public static void main(String[] args) {
        ArrayList<Person> list = new ArrayList<>();
        list.add(new Person("张三"));

        System.out.println("Original: " + list);

        System.out.println("---------------clone---------------");
        ArrayList<Person> cloneList = (ArrayList<Person>) list.clone();
        cloneList.get(0).setName("李四");

        System.out.println("Original: " + list);
        System.out.println("Clone: " + cloneList);
    }
}

@Data
@NoArgsConstructor
@AllArgsConstructor
class Person {
    private String name;
}

运行结果:

Original: [Person(name=张三)]
---------------clone---------------
Original: [Person(name=李四)]
Clone: [Person(name=李四)]
  • Serializable 接口:实现这个接口表示支持序列化,允许对象被转换为字节流以保存到文件或在网络上传输。需要注意的是:只有当被添加到ArrayList 的元素也实现了Serializable 接口时,整个ArrayList 才能成功序列化。

深拷贝(Deep Copy)和浅拷贝(Shallow Copy)

  • 深拷贝:创建一个新对象,并且复制原对象中的所有字段,包括引用类型的字段。对于每个引用类型的字段,它都会创建一个新的实例,确保源对象和目标对象之间没有任何共享的引用。
  • 浅拷贝:也创建一个新对象,该对象与原对象具有相同的值,但对引用类型字段只会复制引用地址,而不复制实际的对象。意思是原对象和新对象的引用类型属性指向同一块内存。

ArrayList 源码分析

核心成员变量

// 默认的初始化容量
private static final int DEFAULT_CAPACITY = 10;

// 调用带参构造初始化集合时,指定初始化容量为0时(new ArrayList(0)),使用该数组赋值
private static final Object[] EMPTY_ELEMENTDATA = {};

// 调用空参数构造初始化时,使用该数组赋值
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};

/**
 * 实际存放 ArrayList 元素的数组,数组的容量即是 ArrayList 的容量,
 * 当一个空 ArrayList 的 elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA 这个空数组的时候,
 * 添加第一个元素时,ArrayList 将被扩容至 DEFAULT_CAPACITY 默认容量
 */

transient Object[] elementData; // non-private to simplify nested class access

/**
 * 该字段表示当前添加到 ArrayList 的元素的实际个数,需要注意的是它必定小于等于数组对象 elementData 的长
 * 度。一旦当 size 与 elementData 的长度相同,并且还在往列表中添加元素的时,ArrayList 会执行扩容操作,用一
 * 个更长的数组对象存储先前的元素。
 */

private int size;

EMPTY_ELEMENTDATA 与 DEFAULTCAPACITY_EMPTY_ELEMENTDATA

两者均为静态成员变量,代表空的数组实例,是为了减少空 ArrayList 对象创建时的内存占用。通过共享相同的空数组实例,多个空的 ArrayList 实例不再各自持有独立的空数组,从而节省了内存资源。此外,这两个静态成员还帮助区分不同构造函数创建的 ArrayList 在首次扩容时的行为逻辑,以优化性能(下文扩容逻辑中会进行说明)。

构造方法

// 无参构造函数,初始化为空数组 EMPTY_ELEMENTDATA
public ArrayList() {
    this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}

// 指定初始容量的构造函数,允许提前指定数组容量,减少扩容次数
public ArrayList(int initialCapacity) {
    if (initialCapacity > 0) {
        // 指定容量大于0时,直接创建一个指定容量的数组
        this.elementData = new Object[initialCapacity];
    } else if (initialCapacity == 0) {
        // 指定容量为0时,使用默认空数组初始化
        this.elementData = EMPTY_ELEMENTDATA;
    } else {
        throw new IllegalArgumentException("Illegal Capacity: " + initialCapacity);
    }
}

// 根据集合初始化的构造函数,接收一个集合并将其转换为 ArrayList
public ArrayList(Collection<? extends E> c) {
    // 将集合转为数组,如果集合本身是一个 ArrayList,那么它的 toArray() 方法返回的就是它的内部数组引用。
    Object[] a = c.toArray();
    if ((size = a.length) != 0) {
        // 如果传入的集合是 ArrayList
        if (c.getClass() == ArrayList.class{
            // 直接将 elementData 指向传入 ArrayList 的内部数组 a,这样做避免了额外的数组复制操作,提高了性能
            elementData = a;
        } else {
            // 传入其他类型的集合,直接调用 Arrays.copyOf() 方法来创建一个新的数组,并将传入集合的元素复制到这个新数组中。
            // 这是因为不同类型的集合可能有不同的内部表示形式,直接使用它们的 toArray() 方法返回的结果可能会导致类型不匹配或其他问题
            elementData = Arrays.copyOf(a, size, Object[].class);
        }
    } else {
        // 传入空集合时,使用默认空数组初始化
        elementData = EMPTY_ELEMENTDATA;
    }
}

添加元素与扩容

public boolean add(E e) {
    // 新增一个元素时,要确保有足够的容量容纳新元素
    ensureCapacityInternal(size + 1);  // Increments modCount!!
    /**
     * 将新元素放置在数组的当前位置(size 位置),然后递增 size。
     * 这行代码实际上进行了两个操作:
     * 1. 赋值操作:elementData[size] = e;
     * 2. 自增操作:size++;
     * 在执行 elementData[size++] = e; 这行代码之前,size 变量记录了当前 ArrayList 中已经存储了多少个元素。
     * 因为数组的索引是从 0 开始的,所以 size 的值实际上指向了下一个应该放置新元素的位置,即第一个空闲的位置。
     * 这样做确保了新元素总是被添加到列表的末尾,并且保持了元素之间的相对顺序不变。
     * 总结:从这里可以看出,size 不仅是 ArrayList 中实际元素数量的计数器,它也是一个逻辑上的“指针”,指向下一个可用的插入位置。
     */

    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) {
        /**
         * 当 elementData 是 DEFAULTCAPACITY_EMPTY_ELEMENTDATA 时,第一次添加元素进行扩容时,
         * 会直接跳过小规模的逐步增长,直接达到或超过 DEFAULT_CAPACITY,从而减少了后续可能发生的频繁扩容操作。
         *
         * 如果 elementData 是 EMPTY_ELEMENTDATA,calculateCapacity 方法就不会进入上述的 if 分支,而是直接返回 minCapacity,
         * 这时候会仅按需分配空间,而不是立即扩展到较大的默认容量,这可以节省内存资源,特别是当不确定列表最终会有多大时。
         */

        return Math.max(DEFAULT_CAPACITY, minCapacity);
    }
    return minCapacity;
}

// 确保数组具有足够的容量
private void ensureExplicitCapacity(int minCapacity) {
    modCount++; // 记录结构修改次数

    // overflow-conscious code
    if (minCapacity - elementData.length > 0)
        // 如果添加当前元素后需要的容量比 elementData 数组的长度要大了(当前数组已满,无法存储新加入的元素),则进行扩容。
        grow(minCapacity);
}

private void grow(int minCapacity) {
    // overflow-conscious code
    int oldCapacity = elementData.length;
    // 原来的容量带符号右移 1 位再与原来的容量相加,也就是 1.5 倍
    int newCapacity = oldCapacity + (oldCapacity >> 1);
    if (newCapacity - minCapacity < 0)
        newCapacity = minCapacity;
    // 如果新容量超过最大数组大小(MAX_ARRAY_SIZE),则调用 hugeCapacity(minCapacity) 处理大容量情况,最大分配值为 Integer.MAX_VALUE
    if (newCapacity - MAX_ARRAY_SIZE > 0)
        newCapacity = hugeCapacity(minCapacity);
    // copy 数组,将内部所有元素放到长度为 newCapacity 的新数组中,并将新数组对应的引用赋值给 elementData,
    // 此刻 ArrayList 内部引用的对象就是更新了长度后的新数组,至此,扩容完成。
    elementData = Arrays.copyOf(elementData, newCapacity);
}

// 添加元素的重载方法,添加元素到指定位置
public void add(int index, E element) {
    // 检查索引是否合法,如果越界则抛出异常
    rangeCheckForAdd(index);

    ensureCapacityInternal(size + 1);  // Increments modCount!!
    /**
     * 1. 使用 System.arraycopy() 将从索引 index 开始的所有元素向后移动一位,腾出空位给新元素
     * 2. 将新元素插入到索引 index 处
     * 3. 增加 size
     */

    System.arraycopy(elementData, index, elementData, index + 1,
                     size - index);
    elementData[index] = element;
    size++;
}

add(E e) 与 add(int index, E element) 对比:

  • add(E e):通常情况下,该方法非常高效,因为它直接在数组末尾添加元素,不需要任何额外的数据位移操作。只有当触发扩容机制时,才会出现性能下降的情况。
  • add(int index, E element):该方法的效率依赖于插入点的位置。越靠近列表前端插入,性能开销越大;而在接近或恰好是列表末端插入时,性能损失较小。此外,频繁地使用该方法可能导致更多的内存分配和数据拷贝操作,进而影响整体性能。

扩容总结:在调用像add() 方法,向ArrayList 中添加新元素而当前容量不足以容纳这些元素时,就会触发扩容操作。

好啦,到这里,面试官爱问的扩容逻辑我们基本已经梳理清晰了,那么你可知道,ArrayList 其实还可以进行手动缩容。面试时,回答这一点,可能会让面试官刮目相看哦~

请看代码:

public void trimToSize() {
    // 记录修改次数
    modCount++;
    // 如果当前列表实际存储的元素个数(size)小于内部数组的长度(elementData.length),则进行调整;
    // 这说明 ArrayList 存在多余的容量,可以考虑减小内部数组的大小以节省内存。
    if (size < elementData.length) {
        // 如果列表为空,则将 elementData 设为空数组引用 EMPTY_ELEMENTDATA
        // 否则,使用 Arrays.copyOf 创建一个新的、更小的数组,并复制现有元素到新数组中
        elementData = (size == 0)
            ? EMPTY_ELEMENTDATA
            : Arrays.copyOf(elementData, size);
    }
}

trimToSize() 特别适用于那些在初始化阶段会经历一系列快速添加元素的过程,然后在之后的业务中几乎不再增长的列表。例如,加载一大批数据后,可以通过调用 trimToSize() 来释放占用的多余的内存空间。

trimToSize() 虽然可以帮助我们去手动释放内存,提高资源利用率,但是它并不是一个轻量级的操作。因为每次调用都涉及到数组的复制,所以,我们尽量在已知数据量的情况下去手动指定 ArrayList 的容量,而不是调用此方法,以免影响性能。

删除元素

public E remove(int index) {
    // 检查提供的索引是否越界,如果越界则抛出异常
    rangeCheck(index);
    // 记录结构修改次数,用于迭代器检测并发修改
    modCount++;
    // 获取即将被删除的元素
    E oldValue = elementData(index);
    // 删除元素后,后边的元素需要向前移动,这里计算要向前移动的元素数量
    // 比如 size 为5,索引分别是 0,1,2,3,4,删除下标为 2 的元素,
    // 那么 numMoved = 5 - 2 - 1 = 2,也就是移动下标为 3 和 4 的元素
    int numMoved = size - index - 1;
    if (numMoved > 0)
        // 进行元素移动
        System.arraycopy(elementData, index+1, elementData, index,
                         numMoved);
    // 将数组中最后的位置设为 null,帮助 GC 回收不再使用的对象,减少内存占用
    elementData[--size] = null// clear to let GC do its work
    // 返回被删除的元素
    return oldValue;
}

public boolean remove(Object o) {
    // 处理传入的对象是 null 的情况
    if (o == null) {
        // 遍历整个数组,找到第一个 null 删除并返回
        for (int index = 0; index < size; index++)
            if (elementData[index] == null) {
                // 删除逻辑与 remove(int index) 方法基本一致,这里不再赘述
                fastRemove(index);
                return true;
            }
    } else {
        // 对于非 null 的对象,需要遍历列表并调用对象的 equals() 方法逐个进行比较
        for (int index = 0; index < size; index++)
            // 找到匹配项,删除并返回
            if (o.equals(elementData[index])) {
                fastRemove(index);
                return true;
            }
    }
    // 如果遍历结束仍未找到匹配项,则返回 false 表明没有执行任何删除操作
    return false;
}

remove(int index) 与 remove(Object o) 对比:

  • remove(int index) 直接定位到特定索引处进行删除,效率较高,时间复杂度为 O(1),因为它不需要遍历列表。
  • remove(Object o) 必须遍历列表直到找到匹配项为止,最坏情况下时间复杂度为 O(n),其中 n 是列表的长度,在处理大型列表时可能不如 remove(int index) 方法更高效。

总结:无论是哪种形式的删除操作,底层最终都会使用 System.arraycopy() 方法来进行数组元素的移动,所以效率上都会受到影响。

获取元素

public E get(int index) {
    // 检查索引是否合法
    rangeCheck(index);
    // 调用 elementData 方法返回指定索引处的元素
    return elementData(index);
}

elementData(int index) {
    // 返回数组中指定索引位置的元素,并进行类型转换
    return (E) elementData[index];
}

get(int index) 方法的时间复杂度为 O(1),因为它只需要一次直接的数组访问就可以完成元素的检索。理论上,无论 ArrayList 有多大,获取任意位置上的元素所需的时间几乎是恒定的,这是 ArrayList 的一大优势。

修改元素

public E set(int index, E element) {
    // 检查索引是否合法
    rangeCheck(index);
    // 获取当前索引位置的旧值
    E oldValue = elementData(index);
    // 将新元素设置到指定索引位置
    elementData[index] = element;
    // 返回旧值,这里允许调用者知道被替换的元素是什么
    return oldValue;
}

set(int index, E element) 方法不会改变列表的大小,它只替换现有位置上的元素。它的时间复杂度为 O(1),因为它也是只需要一次直接的数组访问就可以完成元素的替换操作。

结语

本篇文章中,我们通过分析ArrayList 中的成员变量与几个关键方法的源码,了解了ArrayList 的内部工作原理。在接下来的文章中,我们将讨论ArrayList 的序列化机制以及并发修改相关的问题。

您的支持就是对我最大的鼓励!如果本文对您有帮助,请记得点赞分享在看哦~~~谢谢!

Java驿站
这里是【Java驿站】,一个Java编程学习与交流平台。
 最新文章