基于本文回答

播面 播面

文图音视,全方位拆解八股文
0
评论

ArrayList全面详解

知识点图片

ArrayList是基于动态数组的集合。其核心特性是:随机访问快(O(1)),中间位置增删慢(O(n)),并且非线程安全。

我们来对 Java 中的 ArrayList 进行一个全面而详细的解析。

这篇详解将涵盖以下内容:

  1. ArrayList 是什么? (概念与定位)
  2. 内部实现原理 (核心:动态数组与扩容)
  3. 主要特性 (优点与缺点)
  4. 常用方法详解 (增删改查遍历)
  5. ArrayList vs LinkedList (经典对比)
  6. 多线程环境下的使用 (线程安全问题)
  7. 常见陷阱与最佳实践 (使用注意事项)
  8. 总结

1. ArrayList 是什么?

ArrayList 是 Java 集合框架(Java Collections Framework)中的一个核心类,它位于 java.util 包下。简单来说,ArrayList 是一个可以动态调整大小的数组

它实现了 List 接口,这意味着:

  • 有序集合:元素存储的顺序和添加的顺序是一致的。
  • 允许重复元素:可以存储多个相同的元素。
  • 允许 null:可以存储 null

ArrayList 在功能上与普通的 Java 数组 (String[], int[]) 相似,但它解决了数组长度不可变的痛点。你可以在创建 ArrayList 后,随意地向其中添加或删除元素,而不用担心容量问题。

2. 内部实现原理

要真正理解 ArrayList,就必须了解它的内部是如何工作的。

核心数据结构

ArrayList 的底层实际上就是一个 Object 类型的数组。在 ArrayList 类的源码中,你会看到一个关键的成员变量:

java
transient Object[] elementData; // ArrayList存储元素的数组

transient 关键字表示这个字段不会被序列化。

动态扩容(The Magic)

ArrayList 之所以能“动态”调整大小,关键就在于它的扩容(grow)机制。

  1. 初始容量:当你创建一个空的 ArrayList (new ArrayList<>()) 时,它内部的 elementData 数组在 JDK 1.7 之后版本中是空的 ({}),初始容量为 0。只有在第一次添加元素时,才会分配一个默认的初始容量,通常是 10

    • 你也可以在创建时指定初始容量:new ArrayList<>(20); 这可以避免初期的几次扩容,提升性能。
  2. 扩容时机:当你调用 add() 方法添加元素时,ArrayList 会检查当前数组容量是否足够。如果数组已满(size == elementData.length),就会触发扩容。

  3. 扩容逻辑:扩容的过程大致如下:

    • 计算新容量:新的容量通常是旧容量的 1.5 倍newCapacity = oldCapacity + (oldCapacity >> 1),位右移一位相当于除以2)。
    • 创建新数组:根据新容量创建一个更大的新数组。
    • 复制元素:使用 Arrays.copyOf() 方法将旧数组中的所有元素复制到新数组中。
    • 引用切换:将 elementData 的引用指向这个新创建的数组。

扩容是一个成本较高的操作,因为它涉及到内存分配和数组的整体复制。因此,如果你能预估到要存储的元素数量,最好在创建 ArrayList 时就指定一个合适的初始容量,以减少不必要的扩容次数。

3. 主要特性

特性 描述
查询/访问效率高 由于底层是数组,它支持随机访问(Random Access)。通过索引 get(index) 直接访问元素的时间复杂度是 O(1),非常快。
增删效率(末尾)高 在列表末尾添加或删除元素(add(element), remove(size-1))时,通常不涉及元素移动,时间复杂度接近 O(1)(不考虑扩容)。
增删效率(中间)低 在列表的中间或开头插入或删除元素时,需要移动该位置之后的所有元素。例如,在索引 i 处插入一个元素,需要将 i 以及之后的所有元素向后移动一位。这个操作的时间复杂度是 O(n),其中 n 是需要移动的元素数量。
非线程安全 ArrayList 不是线程安全的。如果在多线程环境下对同一个 ArrayList 实例进行修改操作,可能会导致数据不一致或抛出 ConcurrentModificationException 异常。
内存连续 底层的数组需要一块连续的内存空间。

4. 常用方法详解

以下是 ArrayList 最核心和常用的方法,配有代码示例。

java
// 1. 创建 ArrayList
List<String> fruits = new ArrayList<>(); // 推荐使用接口类型 List 声明

// 2. 添加元素 (Add)
fruits.add("Apple");        // 在末尾添加
fruits.add("Banana");
fruits.add("Cherry");
fruits.add(1, "Orange");  // 在索引 1 的位置插入 "Orange"
// 当前列表: ["Apple", "Orange", "Banana", "Cherry"]

// 3. 获取元素 (Get/Read)
String firstFruit = fruits.get(0); // "Apple"
System.out.println("第一个水果是: " + firstFruit);

// 4. 修改元素 (Set/Update)
fruits.set(2, "Grape"); // 将索引 2 的元素从 "Banana" 修改为 "Grape"
// 当前列表: ["Apple", "Orange", "Grape", "Cherry"]

// 5. 删除元素 (Remove)
fruits.remove("Apple");    // 按内容删除第一个匹配的元素
fruits.remove(2);        // 按索引删除元素 ("Cherry")
// 当前列表: ["Orange", "Grape"]

// 6. 获取列表信息
int size = fruits.size();                   // 获取元素数量,结果是 2
boolean isEmpty = fruits.isEmpty();         // 判断是否为空,结果是 false
boolean hasGrape = fruits.contains("Grape"); // 判断是否包含某个元素,结果是 true

// 7. 遍历 (Iterate) - 推荐方式
System.out.println("--- 使用 for-each 遍历 ---");
for (String fruit : fruits) {
    System.out.println(fruit);
}

System.out.println("--- 使用 Iterator 遍历 ---");
Iterator<String> iterator = fruits.iterator();
while (iterator.hasNext()) {
    String fruit = iterator.next();
    System.out.println(fruit);
}

System.out.println("--- 使用 forEach 方法 (Java 8+) ---");
fruits.forEach(fruit -> System.out.println(fruit));
// 或者使用方法引用
// fruits.forEach(System.out::println);

// 8. 清空列表
fruits.clear();
System.out.println("清空后的大小: " + fruits.size()); // 0

5. ArrayList vs LinkedList

这是 Java 面试中的高频问题,也是选择正确数据结构的关键。

特性 ArrayList LinkedList
底层结构 动态数组 双向链表 (每个节点包含数据、前驱指针、后继指针)
随机访问 快 (O(1))。通过索引直接定位。 慢 (O(n))。需要从头或尾开始遍历查找。
插入/删除 (中间) 慢 (O(n))。需要移动后续元素。 快 (O(1))。只需修改前后节点的指针。(前提是已经定位到该节点)
内存开销 较小。只需存储元素本身。 较大。每个元素都需要额外的空间存储前后节点的引用。
内存要求 需要连续的内存空间。 不需要连续的内存空间。

选择指南:

  • 优先选择 ArrayList:在绝大多数情况下,ArrayList 的性能都更好,特别是当你的主要操作是遍历和随机访问时。它是默认和最常用的 List 实现。
  • 选择 LinkedList:当你的应用场景需要频繁地在列表的开头、中间进行插入和删除操作时,LinkedList 才可能更具优势。例如,实现一个队列 (Queue)。

6. 多线程环境下的使用

如前所述,ArrayList 是非线程安全的。如果多个线程同时修改它,程序可能会崩溃。

解决方案:

  1. Collections.synchronizedList()

    java
    List<String> syncList = Collections.synchronizedList(new ArrayList<>());

    这个工厂方法会返回一个线程安全的 List 包装器。它通过在每个公共方法(如 add, get, remove)上添加 synchronized 关键字来实现同步。

    • 缺点:性能较差,因为它对所有操作都加了锁,在高并发下会导致激烈的锁竞争。同时,对于复合操作(如先检查后添加)和迭代,仍需要手动加锁。
  2. CopyOnWriteArrayList

    java
    List<String> cowList = new CopyOnWriteArrayList<>();

    这是 java.util.concurrent 包下的一个线程安全类。

    • 工作原理:写时复制。当进行修改操作(add, set, remove)时,它不会直接修改现有数组,而是复制一份新数组,在新数组上进行修改,最后再将引用指向新数组。
    • 优点:读操作完全无锁,性能极高。适合读多写少的并发场景。
    • 缺点:写操作成本很高(因为要复制整个数组),并且无法保证数据的实时一致性(迭代器看到的是修改前的快照)。

7. 常见陷阱与最佳实践

  1. for-each 循环中删除元素
    错误的示例:

    java
    for (String fruit : fruits) {
        if ("Apple".equals(fruit)) {
            fruits.remove(fruit); // 会抛出 ConcurrentModificationException
        }
    }

    原因for-each 循环内部是使用 Iterator 实现的。当你在循环外部通过 list.remove() 修改集合结构时,Iterator 内部维护的一个状态(modCount)会与 ArrayList 的状态不一致,导致异常。

    正确的删除方式是使用 Iterator 自己的 remove() 方法:

    java
    Iterator<String> iterator = fruits.iterator();
    while (iterator.hasNext()) {
        String fruit = iterator.next();
        if ("Apple".equals(fruit)) {
            iterator.remove(); // 正确!
        }
    }

    或者使用 Java 8 的 removeIf 方法,更简洁:

    java
    fruits.removeIf(fruit -> "Apple".equals(fruit));
  2. subList() 返回的是视图而非副本
    list.subList(from, to) 返回的是原始列表的一个视图。对这个子列表的任何修改都会反映到原始列表中,反之亦然。对原始列表的结构性修改(增删元素)会导致子列表的后续操作失败。

    java
    List<String> sub = fruits.subList(0, 2);
    sub.set(0, "Mango"); // 原 fruits 列表的第一个元素也会变成 "Mango"
    
    fruits.add("Kiwi"); // 对原列表结构性修改
    // sub.get(0); // 此时再操作 sub 会抛出 ConcurrentModificationException

    如果需要一个独立的副本,应该这样做:
    List<String> independentSubList = new ArrayList<>(fruits.subList(0, 2));

  3. 预设容量
    如前所述,如果能预估元素数量,请使用 new ArrayList<>(estimatedSize) 来创建,以提高性能。

8. 总结

ArrayList 是 Java 中最基础、最重要的数据结构之一。

  • 核心是动态数组,通过扩容机制实现大小可变。
  • 查询快 (O(1))中间增删慢 (O(n))
  • 非线程安全的。
  • 绝大多数场景下都是 List 接口的最佳选择

掌握了 ArrayList 的原理、特性和使用方法,你就能在日常开发和面试中更加从容自信。

00:00
00:00