Java ArrayList 详解:从动态数组到扩容机制与常见陷阱

文章目录

    • 前言
    • [一、认识 ArrayList](#一、认识 ArrayList)
      • [1.1 ArrayList 是什么](#1.1 ArrayList 是什么)
      • [1.2 ArrayList 的主要特点](#1.2 ArrayList 的主要特点)
      • [1.3 ArrayList 与普通数组的区别](#1.3 ArrayList 与普通数组的区别)
      • [1.4 ArrayList 的适用场景](#1.4 ArrayList 的适用场景)
    • 二、创建方式与底层结构
      • [2.1 ArrayList 的创建方式](#2.1 ArrayList 的创建方式)
        • [1. 无参构造](#1. 无参构造)
        • [2. 指定初始容量](#2. 指定初始容量)
        • [3. 根据已有集合创建](#3. 根据已有集合创建)
      • [2.2 ArrayList 的核心成员变量](#2.2 ArrayList 的核心成员变量)
      • [2.3 size 与 capacity 的区别](#2.3 size 与 capacity 的区别)
      • [2.4 为什么底层是 Object 数组](#2.4 为什么底层是 Object 数组)
      • [2.5 ArrayList 保存的是对象引用](#2.5 ArrayList 保存的是对象引用)
    • 三、添加元素与扩容机制
      • [3.1 add(E e) 添加元素](#3.1 add(E e) 添加元素)
      • [3.2 无参构造与懒初始化](#3.2 无参构造与懒初始化)
      • [3.3 第 11 个元素为什么会触发扩容](#3.3 第 11 个元素为什么会触发扩容)
      • [3.4 为什么不能每次只扩大一个位置](#3.4 为什么不能每次只扩大一个位置)
      • [3.5 Arrays.copyOf() 的作用](#3.5 Arrays.copyOf() 的作用)
      • [3.6 ensureCapacity() 与 trimToSize()](#3.6 ensureCapacity() 与 trimToSize())
    • 四、增删改查与元素移动
      • [4.1 get() 与 set():下标访问为什么快](#4.1 get() 与 set():下标访问为什么快)
      • [4.2 indexOf()、lastIndexOf() 与 contains()](#4.2 indexOf()、lastIndexOf() 与 contains())
      • [4.3 add(index, element):指定位置插入元素](#4.3 add(index, element):指定位置插入元素)
      • [4.4 remove():删除元素为什么可能较慢](#4.4 remove():删除元素为什么可能较慢)
      • [4.5 Integer 列表中的 remove() 陷阱](#4.5 Integer 列表中的 remove() 陷阱)
    • 五、遍历、排序与安全删除
      • [5.1 ArrayList 的常见遍历方式](#5.1 ArrayList 的常见遍历方式)
        • [1. 普通 for 循环](#1. 普通 for 循环)
        • [2. 增强 for 循环](#2. 增强 for 循环)
        • [3. Iterator 迭代器](#3. Iterator 迭代器)
      • [5.2 遍历过程中直接删除的问题](#5.2 遍历过程中直接删除的问题)
      • [5.3 正确删除方式](#5.3 正确删除方式)
        • [1. 使用 Iterator.remove()](#1. 使用 Iterator.remove())
        • [2. 使用 removeIf()](#2. 使用 removeIf())
      • [5.4 排序与二分查找](#5.4 排序与二分查找)
    • 六、常见陷阱与使用选择
      • [6.1 Arrays.asList() 不能直接增删元素](#6.1 Arrays.asList() 不能直接增删元素)
      • [6.2 subList() 返回的是视图](#6.2 subList() 返回的是视图)
      • [6.3 clone() 只是浅拷贝](#6.3 clone() 只是浅拷贝)
      • [6.4 ArrayList 不是线程安全集合](#6.4 ArrayList 不是线程安全集合)
      • [6.5 ArrayList 与 LinkedList 的选择](#6.5 ArrayList 与 LinkedList 的选择)
      • [6.6 常见操作时间复杂度](#6.6 常见操作时间复杂度)
    • 七、完整测试代码
      • [7.1 测试内容说明](#7.1 测试内容说明)
      • [7.2 完整可运行代码](#7.2 完整可运行代码)
    • 总结

前言

在 Java 开发中,ArrayList 是最常用的集合类之一。查询结果、商品列表、评论列表、菜单选项等业务数据,经常都会使用它来保存。

普通数组就像一排固定数量的座位 :创建时有多少位置,后续就只能使用多少位置。ArrayList 则像一节可以不断扩建的车厢:底层仍然使用数组,但空间不足时,会自动创建更大的数组,并把原有元素搬过去。

本文从基础使用开始,逐步讲解 ArrayList 的底层结构、扩容机制、增删改查原理、遍历删除陷阱、线程安全问题以及常见使用误区。

本文源码分析主要以 JDK 8 为基础。较新的 JDK 对部分内部方法进行了重构,但 ArrayList 的核心思想并没有改变:

text 复制代码
底层是数组
容量不足时需要扩容
根据下标访问速度快
中间插入和删除需要移动元素

一、认识 ArrayList

1.1 ArrayList 是什么

ArrayList<E>List<E> 接口的一个实现类,底层通过数组保存元素。

java 复制代码
List<String> names = new ArrayList<>();

这段代码中:

java 复制代码
List<String> names = new ArrayList<>();

可以拆解为:

内容 说明
List 列表接口,表示一组有顺序的数据
ArrayList 基于数组实现的列表类
String 集合中存放的元素类型
<> 泛型,用于限制元素类型

例如:

java 复制代码
List<String> names = new ArrayList<>();

names.add("张三");
names.add("李四");
names.add("王五");

System.out.println(names);

输出结果:

text 复制代码
[张三, 李四, 王五]

1.2 ArrayList 的主要特点

ArrayList 具有以下特点:

特点 说明
有序 元素按照添加顺序保存
可重复 可以保存相同元素
允许 null 可以添加空值
支持下标访问 可以通过 get(index) 获取指定位置元素
自动扩容 底层数组空间不足时会自动扩大
非线程安全 多线程同时修改时需要额外处理

示例:

java 复制代码
List<String> list = new ArrayList<>();

list.add("Java");
list.add("Java");
list.add(null);

System.out.println(list);

输出结果:

text 复制代码
[Java, Java, null]

1.3 ArrayList 与普通数组的区别

普通数组创建后,长度不能改变:

java 复制代码
String[] names = new String[3];

names[0] = "张三";
names[1] = "李四";
names[2] = "王五";

// names[3] = "赵六"; // 数组越界

ArrayList 可以继续添加元素:

java 复制代码
List<String> names = new ArrayList<>();

names.add("张三");
names.add("李四");
names.add("王五");
names.add("赵六");

System.out.println(names);

输出结果:

text 复制代码
[张三, 李四, 王五, 赵六]

可以这样理解:

  1. 普通数组:固定座位的大巴车,座位满了就不能继续上人。
  2. ArrayList:可以增加车厢的列车,座位满了就扩建空间。

1.4 ArrayList 的适用场景

ArrayList 适合以下业务场景:

场景 原因
保存查询结果 通常按照顺序遍历和展示
保存商品列表 读取和遍历操作较多
保存文章评论 需要按顺序显示
保存下拉框选项 数据量较小,读取方便
保存接口返回数据 便于遍历、过滤和转换

例如:

java 复制代码
List<String> courses = new ArrayList<>();

courses.add("Java");
courses.add("MySQL");
courses.add("Spring Boot");

for (String course : courses) {
    System.out.println(course);
}

如果业务中经常需要在列表头部插入或删除大量数据,ArrayList 就不一定合适,因为它需要频繁移动后续元素。


二、创建方式与底层结构

2.1 ArrayList 的创建方式

创建 ArrayList 时,通常推荐使用接口接收实现类:

java 复制代码
List<String> names = new ArrayList<>();

而不是:

java 复制代码
ArrayList<String> names = new ArrayList<>();

这样写的好处是:后续如果需要更换集合实现,业务代码修改更少。

java 复制代码
List<String> names = new ArrayList<>();

// 后续如果确实需要,也可以替换为:
// List<String> names = new LinkedList<>();

ArrayList 常见的创建方式有三种。

1. 无参构造
java 复制代码
List<String> list = new ArrayList<>();

适合暂时无法确定元素数量的场景。

2. 指定初始容量
java 复制代码
List<String> list = new ArrayList<>(100);

适合提前知道大概数据量的场景。

例如,预计需要保存 1000 条商品数据:

java 复制代码
List<String> products = new ArrayList<>(1000);

提前指定容量,可以减少扩容和数组复制的次数。

3. 根据已有集合创建
java 复制代码
List<String> oldList = Arrays.asList("Java", "MySQL", "Redis");

List<String> newList = new ArrayList<>(oldList);

newList.add("Spring Boot");

System.out.println(newList);

输出结果:

text 复制代码
[Java, MySQL, Redis, Spring Boot]

2.2 ArrayList 的核心成员变量

以 JDK 8 的源码思想为例,ArrayList 中最重要的成员变量可以简化理解为:

java 复制代码
public class ArrayList<E> {

    // 默认容量
    private static final int DEFAULT_CAPACITY = 10;

    // 保存元素的底层数组
    transient Object[] elementData;

    // 当前实际元素数量
    private int size;
}

其中最重要的是两个变量:

java 复制代码
Object[] elementData;
int size;

例如:

java 复制代码
List<String> list = new ArrayList<>();

list.add("Java");
list.add("MySQL");
list.add("Redis");

底层可以理解为:

text 复制代码
elementData = ["Java", "MySQL", "Redis", null, null, null, null, null, null, null]

size = 3

2.3 size 与 capacity 的区别

sizecapacity 是两个非常容易混淆的概念。

概念 含义
size 当前实际保存了多少个元素
capacity 底层数组目前最多能够容纳多少个元素

例如:

text 复制代码
底层数组容量 capacity = 10

下标:   0       1       2       3       4       5 ...
元素: Java    MySQL   Redis    null    null    null ...

实际元素数量 size = 3

可以把它理解为一间教室:

  1. 教室一共有 10 个座位:capacity = 10
  2. 当前坐了 3 名学生:size = 3

2.4 为什么底层是 Object 数组

ArrayList 可以保存不同类型的数据:

java 复制代码
List<String> names = new ArrayList<>();
List<Integer> scores = new ArrayList<>();
List<User> users = new ArrayList<>();

但是在底层,ArrayList 使用的是:

java 复制代码
Object[] elementData;

泛型主要在编译阶段负责限制元素类型。

java 复制代码
List<String> list = new ArrayList<>();

list.add("Java");

// list.add(100); // 编译错误

2.5 ArrayList 保存的是对象引用

假设有一个用户对象:

java 复制代码
User user = new User("张三");

List<User> users = new ArrayList<>();
users.add(user);

user.setName("李四");

System.out.println(users.get(0).getName());

输出结果:

text 复制代码
李四

原因是,ArrayList 保存的是对象引用,而不是对象副本。

text 复制代码
user  ───────┐
             ▼
        User对象{name="李四"}
             ▲
users[0] ────┘

变量 userusers.get(0) 指向的是同一个对象,因此修改对象属性后,从列表中读取到的内容也会发生变化。


三、添加元素与扩容机制

3.1 add(E e) 添加元素

最常见的添加方式如下:

java 复制代码
List<String> list = new ArrayList<>();

list.add("Java");
list.add("MySQL");
list.add("Redis");

添加元素的核心流程可以概括为:

text 复制代码
调用 add(element)
        ↓
判断底层数组是否还有空间
        ↓
有空间:直接将元素保存到数组末尾
        ↓
没有空间:创建更大的数组,并复制原有元素
        ↓
保存新元素,size 加 1

可以简化理解为:

java 复制代码
elementData[size] = element;
size++;

当然,在真正写入元素之前,ArrayList 会先检查容量是否足够。

3.2 无参构造与懒初始化

执行下面代码时:

java 复制代码
List<String> list = new ArrayList<>();

列表虽然已经创建,但底层数组此时通常还是空数组。

text 复制代码
刚创建时:

size = 0
底层数组长度 = 0

当第一次添加元素时:

java 复制代码
list.add("Java");

底层数组才真正扩展为默认容量 10。

text 复制代码
第一次添加后:
size = 1
capacity = 10

数组状态可以理解为:

text 复制代码
["Java", null, null, null, null, null, null, null, null, null]

这是一种懒初始化思想:

text 复制代码
仓库刚注册时,不急着立即摆满货架;
等第一批货物真正到达时,再准备默认数量的货架。

3.3 第 11 个元素为什么会触发扩容

假设使用无参构造创建列表,并不断添加元素:

java 复制代码
List<Integer> list = new ArrayList<>();

for (int i = 1; i <= 11; i++) {
    list.add(i);
}

容量变化过程可以理解为:

text 复制代码
添加第 1 个元素:容量扩展为 10
添加第 2 ~ 10 个元素:容量仍然是 10
添加第 11 个元素:原数组放不下,触发扩容

以 JDK 8 的源码为例,扩容核心计算方式如下:

java 复制代码
int newCapacity = oldCapacity + (oldCapacity >> 1);

其中:

java 复制代码
oldCapacity >> 1

对于正整数容量,可以简单理解为:

java 复制代码
oldCapacity / 2

因此:

java 复制代码
newCapacity = oldCapacity + oldCapacity / 2;

当旧容量为 10 时:

java 复制代码
newCapacity = 10 + 10 / 2 = 15;

容量变化大致如下:

text 复制代码
0 → 10 → 15 → 22 → 33 → 49 ...

3.4 为什么不能每次只扩大一个位置

假设每添加一个元素,只增加一个数组位置:

text 复制代码
添加第 1 个元素:创建长度为 1 的数组
添加第 2 个元素:创建长度为 2 的数组,复制 1 个元素
添加第 3 个元素:创建长度为 3 的数组,复制 2 个元素
添加第 4 个元素:创建长度为 4 的数组,复制 3 个元素
...

这样会导致频繁创建新数组和复制旧数据,性能很差。

按比例扩容则能够减少复制次数:

text 复制代码
原容量不足
    ↓
一次多申请一部分空间
    ↓
后续多次添加可以直接使用空余位置

这是一种时间与空间之间的平衡:

扩容方式 优点 缺点
每次只增加一个位置 空间浪费少 扩容和复制过于频繁
一次扩大很多空间 添加效率高 可能浪费较多空间
按比例扩容 时间与空间较均衡 会预留部分空闲位置

3.5 Arrays.copyOf() 的作用

数组本身不能直接改变长度。

当旧数组空间不足时,ArrayList 会创建一个更大的新数组,然后将旧元素复制过去。

可以通过下面的代码理解:

java 复制代码
Integer[] oldArray = {1, 2, 3};

Integer[] newArray = Arrays.copyOf(oldArray, 5);

System.out.println(Arrays.toString(newArray));

输出结果:

text 复制代码
[1, 2, 3, null, null]

ArrayList 扩容的本质可以简化为:

java 复制代码
elementData = Arrays.copyOf(elementData, newCapacity);

可以把这个过程理解为搬仓库:

text 复制代码
旧仓库已经装满
        ↓
新建一个更大的仓库
        ↓
把旧货物整体搬到新仓库
        ↓
继续保存新货物

3.6 ensureCapacity() 与 trimToSize()

如果已经知道大概需要保存多少数据,可以提前预留容量:

java 复制代码
ArrayList<String> list = new ArrayList<>();

list.ensureCapacity(1000);

for (int i = 0; i < 1000; i++) {
    list.add("商品-" + i);
}

这种方式适合:

  • 批量读取数据库数据;
  • 解析大量文件内容;
  • 批量组装接口返回结果;
  • 构建大量临时对象列表。

如果列表曾经保存过大量数据,后来删除了很多元素,底层数组容量不会自动缩小。

java 复制代码
ArrayList<String> list = new ArrayList<>(1000);

list.add("Java");
list.add("MySQL");

list.trimToSize();

调用 trimToSize() 后,底层数组容量会缩减到当前 size

不过,不建议频繁调用该方法。因为如果后续又继续添加元素,列表可能马上再次扩容。


四、增删改查与元素移动

4.1 get() 与 set():下标访问为什么快

根据下标读取元素:

java 复制代码
List<String> list = new ArrayList<>();

list.add("Java");
list.add("MySQL");
list.add("Redis");

String value = list.get(1);

System.out.println(value);

输出结果:

text 复制代码
MySQL

底层可以直接根据下标访问数组:

java 复制代码
elementData[index]

因此,get(index) 的时间复杂度通常为:

text 复制代码
O(1)

可以把数组理解为带编号的储物柜:

text 复制代码
0 号柜:Java
1 号柜:MySQL
2 号柜:Redis

想读取 1 号柜的数据,直接找到对应柜子即可,不需要从头开始寻找。

修改元素同样简单:

java 复制代码
List<String> list = new ArrayList<>();

list.add("Java");
list.add("MySQL");
list.add("Redis");

String oldValue = list.set(1, "Spring Boot");

System.out.println("旧值:" + oldValue);
System.out.println("新列表:" + list);

输出结果:

text 复制代码
旧值:MySQL
新列表:[Java, Spring Boot, Redis]

set() 的核心逻辑可以理解为:

java 复制代码
E oldValue = elementData[index];
elementData[index] = element;
return oldValue;

需要注意:

text 复制代码
set() 只是替换元素,不会改变列表长度。

4.2 indexOf()、lastIndexOf() 与 contains()

根据元素内容查找位置:

java 复制代码
List<String> list = new ArrayList<>();

list.add("Java");
list.add("MySQL");
list.add("Java");
list.add("Redis");

System.out.println(list.indexOf("Java"));
System.out.println(list.lastIndexOf("Java"));
System.out.println(list.contains("Redis"));

输出结果:

text 复制代码
0
2
true

几个方法的作用如下:

方法 作用
indexOf(value) 从前往后查找第一次出现的位置
lastIndexOf(value) 从后往前查找最后一次出现的位置
contains(value) 判断列表中是否存在指定元素

虽然 get(index) 很快,但 contains()indexOf() 通常需要从头比较元素。

例如:

text 复制代码
查找 Redis:

Java → MySQL → Java → Redis

因此,根据值查找元素的时间复杂度通常为:

text 复制代码
O(n)

4.3 add(index, element):指定位置插入元素

除了在列表末尾添加元素,还可以在指定位置插入元素:

java 复制代码
List<String> list = new ArrayList<>();

list.add("Java");
list.add("Redis");

list.add(1, "MySQL");

System.out.println(list);

输出结果:

text 复制代码
[Java, MySQL, Redis]

执行过程如下:

text 复制代码
插入前:

下标:   0       1
元素: Java    Redis

在下标 1 插入 MySQL:

第一步:Redis 向后移动
下标:   0       1       2
元素: Java    Redis    Redis

第二步:在下标 1 放入 MySQL
下标:   0       1       2
元素: Java    MySQL   Redis

底层移动元素时,会使用类似下面的数组复制操作:

java 复制代码
System.arraycopy(
        elementData,
        index,
        elementData,
        index + 1,
        size - index
);

其含义是:

text 复制代码
从插入位置开始,
把后面的元素整体向右移动一格,
为新元素腾出位置。

因此:

操作位置 移动成本
尾部添加 通常不需要移动元素
中间插入 需要移动一部分元素
头部插入 可能需要移动全部已有元素

如果列表中有 10000 个元素:

java 复制代码
list.add(0, "Java");

可能需要将原有 10000 个元素全部向后移动。

4.4 remove():删除元素为什么可能较慢

根据下标删除元素:

java 复制代码
List<String> list = new ArrayList<>();

list.add("Java");
list.add("MySQL");
list.add("Redis");

String removed = list.remove(1);

System.out.println("删除元素:" + removed);
System.out.println("删除后:" + list);

输出结果:

text 复制代码
删除元素:MySQL
删除后:[Java, Redis]

删除过程可以理解为:

text 复制代码
删除前:
[Java, MySQL, Redis]

删除下标 1 的 MySQL:

Redis 向前移动:
[Java, Redis, Redis]

清空最后一个无效位置:
[Java, Redis, null]

删除完成后,将末尾无效位置设置为 null,可以解除集合对原对象的引用,便于垃圾回收。

根据值删除元素:

java 复制代码
List<String> list = new ArrayList<>();

list.add("Java");
list.add("MySQL");
list.add("Java");

boolean result = list.remove("Java");

System.out.println(result);
System.out.println(list);

输出结果:

text 复制代码
true
[MySQL, Java]

需要注意:

text 复制代码
remove(Object o) 只会删除第一次出现的目标元素。

根据值删除通常包含两个过程:

text 复制代码
先查找目标元素
        ↓
再移动后续元素

因此,时间复杂度通常为:

text 复制代码
O(n)

4.5 Integer 列表中的 remove() 陷阱

这是使用 ArrayList<Integer> 时非常常见的错误。

java 复制代码
List<Integer> numbers = new ArrayList<>();

numbers.add(10);
numbers.add(20);
numbers.add(30);

numbers.remove(1);

System.out.println(numbers);

输出结果:

text 复制代码
[10, 30]

这里删除的不是数字 1,而是:

text 复制代码
下标为 1 的元素,也就是 20。

原因是 ArrayList 中存在两个重载方法:

java 复制代码
remove(int index)
remove(Object o)

当传入普通整数时:

java 复制代码
numbers.remove(1);

Java 会优先将其理解为:

java 复制代码
删除下标为 1 的元素

如果希望删除数值 20,应该写成:

java 复制代码
numbers.remove(Integer.valueOf(20));

完整示例:

java 复制代码
List<Integer> numbers = new ArrayList<>();

numbers.add(10);
numbers.add(20);
numbers.add(30);

numbers.remove(Integer.valueOf(20));

System.out.println(numbers);

输出结果:

text 复制代码
[10, 30]

五、遍历、排序与安全删除

5.1 ArrayList 的常见遍历方式

1. 普通 for 循环
java 复制代码
List<String> list = Arrays.asList("Java", "MySQL", "Redis");

for (int i = 0; i < list.size(); i++) {
    System.out.println(list.get(i));
}

适合需要使用下标的场景。

2. 增强 for 循环
java 复制代码
List<String> list = Arrays.asList("Java", "MySQL", "Redis");

for (String item : list) {
    System.out.println(item);
}

适合只读取元素、不需要操作下标的场景。

3. Iterator 迭代器
java 复制代码
List<String> list = new ArrayList<>();

list.add("Java");
list.add("MySQL");
list.add("Redis");

Iterator<String> iterator = list.iterator();

while (iterator.hasNext()) {
    String item = iterator.next();
    System.out.println(item);
}

适合遍历过程中需要安全删除元素的场景。

5.2 遍历过程中直接删除的问题

下面代码可能会抛出 ConcurrentModificationException

java 复制代码
List<String> list = new ArrayList<>();

list.add("Java");
list.add("MySQL");
list.add("Redis");

for (String item : list) {
    if ("MySQL".equals(item)) {
        list.remove(item);
    }
}

增强 for 循环底层实际依赖迭代器。

可以把迭代器理解为一个拿着名单点名的人:

text 复制代码
迭代器已经拿到原始名单
        ↓
遍历过程中,列表突然被外部修改
        ↓
迭代器发现名单与实际数据不一致
        ↓
抛出 ConcurrentModificationException

这种机制称为:

text 复制代码
fail-fast:快速失败机制

它的作用不是保证线程安全,而是尽快发现遍历过程中不合理的结构性修改。

5.3 正确删除方式

1. 使用 Iterator.remove()
java 复制代码
List<String> list = new ArrayList<>();

list.add("Java");
list.add("MySQL");
list.add("Redis");

Iterator<String> iterator = list.iterator();

while (iterator.hasNext()) {
    String item = iterator.next();

    if ("MySQL".equals(item)) {
        iterator.remove();
    }
}

System.out.println(list);

输出结果:

text 复制代码
[Java, Redis]
2. 使用 removeIf()
java 复制代码
List<String> list = new ArrayList<>();

list.add("Java");
list.add("MySQL");
list.add("Redis");
list.add("MyBatis");

list.removeIf(item -> item.startsWith("My"));

System.out.println(list);

输出结果:

text 复制代码
[Java, Redis]

当删除规则较简单时,removeIf() 更加简洁。

5.4 排序与二分查找

对数字列表进行排序:

java 复制代码
List<Integer> numbers = new ArrayList<>();

numbers.add(30);
numbers.add(10);
numbers.add(20);

Collections.sort(numbers);

System.out.println(numbers);

输出结果:

text 复制代码
[10, 20, 30]

对于自定义对象,可以根据指定字段排序:

java 复制代码
class User {

    private String name;
    private int age;

    public User(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public int getAge() {
        return age;
    }

    @Override
    public String toString() {
        return name + "-" + age;
    }
}
List<User> users = new ArrayList<>();

users.add(new User("张三", 22));
users.add(new User("李四", 18));
users.add(new User("王五", 25));

users.sort(Comparator.comparingInt(User::getAge));

System.out.println(users);

输出结果:

text 复制代码
[李四-18, 张三-22, 王五-25]

当列表已经有序时,可以使用二分查找:

java 复制代码
List<Integer> numbers = new ArrayList<>();

numbers.add(10);
numbers.add(20);
numbers.add(30);
numbers.add(40);
numbers.add(50);

int index = Collections.binarySearch(numbers, 30);

System.out.println(index);

输出结果:

text 复制代码
2

需要注意:

text 复制代码
二分查找的前提是:列表必须已经按照相同规则排序。

错误示例:

java 复制代码
List<Integer> numbers = Arrays.asList(30, 10, 20);

int index = Collections.binarySearch(numbers, 20);

由于列表没有排序,查找结果没有可靠意义。


六、常见陷阱与使用选择

6.1 Arrays.asList() 不能直接增删元素

下面代码会抛出异常:

java 复制代码
List<String> list = Arrays.asList("Java", "MySQL", "Redis");

list.add("Spring Boot");

原因是:

text 复制代码
Arrays.asList() 返回的是基于原数组的固定长度列表,
支持修改已有位置,但不支持改变元素数量。

下面的代码是可以执行的:

java 复制代码
List<String> list = Arrays.asList("Java", "MySQL", "Redis");

list.set(0, "Spring Boot");

System.out.println(list);

输出结果:

text 复制代码
[Spring Boot, MySQL, Redis]

如果希望继续进行添加和删除操作,可以重新包装成 ArrayList

java 复制代码
List<String> list = new ArrayList<>(
        Arrays.asList("Java", "MySQL", "Redis")
);

list.add("Spring Boot");

System.out.println(list);

输出结果:

text 复制代码
[Java, MySQL, Redis, Spring Boot]

6.2 subList() 返回的是视图

subList() 返回的不是完全独立的新列表,而是原列表的一段视图。

java 复制代码
List<String> list = new ArrayList<>();

list.add("Java");
list.add("MySQL");
list.add("Redis");
list.add("Spring Boot");

List<String> subList = list.subList(1, 3);

subList.set(0, "MongoDB");

System.out.println(list);

输出结果:

text 复制代码
[Java, MongoDB, Redis, Spring Boot]

原因是:

text 复制代码
subList 与原列表共享对应范围的数据。

如果希望得到独立副本,应该写成:

java 复制代码
List<String> copy = new ArrayList<>(list.subList(1, 3));

6.3 clone() 只是浅拷贝

ArrayListclone() 会复制列表容器,但不会复制其中的对象。

java 复制代码
ArrayList<User> users = new ArrayList<>();

users.add(new User("张三", 20));

ArrayList<User> copy = (ArrayList<User>) users.clone();

copy.get(0).setName("李四");

System.out.println(users.get(0).getName());

输出结果:

text 复制代码
李四

可以理解为:

text 复制代码
原列表和复制后的列表是两个盒子,
但两个盒子中保存的是同一个对象地址。

6.4 ArrayList 不是线程安全集合

java 复制代码
List<Integer> list = new ArrayList<>();

如果多个线程同时修改同一个 ArrayList,可能导致数据错误。

简单场景下,可以使用同步包装:

java 复制代码
List<String> list = Collections.synchronizedList(new ArrayList<>());

list.add("Java");
list.add("MySQL");

遍历时仍然需要手动同步:

java 复制代码
synchronized (list) {
    for (String item : list) {
        System.out.println(item);
    }
}

如果业务属于读多写少的场景,例如监听器列表、配置快照列表,可以考虑:

java 复制代码
List<String> listeners = new CopyOnWriteArrayList<>();

listeners.add("Listener-A");
listeners.add("Listener-B");

不过,CopyOnWriteArrayList 每次修改都可能复制数组,因此不适合高频写入场景。

6.5 ArrayList 与 LinkedList 的选择

ArrayListLinkedList 的简单对比如下:

对比项 ArrayList LinkedList
底层结构 动态数组 双向链表
根据下标查询 较慢
尾部添加
中间插入删除 需要移动元素 找到节点后修改链接
内存占用 相对较小 需要保存前后节点引用
普通业务使用频率 更高 特殊场景使用

大多数普通业务场景,可以优先使用:

java 复制代码
List<String> list = new ArrayList<>();

例如:

  • 查询结果列表;
  • 商品集合;
  • 页面展示数据;
  • 用户权限集合;
  • 配置项列表;
  • 临时计算结果。

需要注意的是,中间插入和删除虽然从结构上看链表更方便,但 LinkedList 在定位节点时也需要遍历。实际开发中,不应仅凭"插入删除多"就直接选择 LinkedList,而应该结合数据量、访问方式和性能测试进行判断。

如果业务需求是频繁从头部添加或删除元素,例如队列操作:

java 复制代码
addLast()
removeFirst()

通常更适合考虑:

java 复制代码
Deque<String> queue = new ArrayDeque<>();

6.6 常见操作时间复杂度

操作 示例 时间复杂度 原因
根据下标查询 get(index) O(1) 数组可以直接定位
根据下标修改 set(index, value) O(1) 直接覆盖对应位置
尾部添加 add(value) 摊还 O(1) 大多数时候直接写入,偶尔扩容
指定位置插入 add(index, value) O(n) 需要移动后续元素
删除末尾元素 remove(size - 1) O(1) 通常不需要移动
删除中间元素 remove(index) O(n) 后续元素需要前移
根据值删除 remove(value) O(n) 先查找,再移动
根据值查找 contains() / indexOf() O(n) 需要逐个比较
排序 sort() 通常 O(n log n) 依赖排序算法
二分查找 binarySearch() O(log n) 前提是列表已经有序

尾部添加的复杂度为什么叫做"摊还 O(1)"?

因为并不是每一次添加都一定只执行一步:

  1. 前 10 次添加:大多数情况下直接写入数组
  2. 第 11 次添加:容量不足,需要扩容和复制
  3. 扩容之后:后续若干次添加又可以直接写入

因此,将大量添加操作整体平均后,尾部添加的平均成本仍然很低。


七、完整测试代码

7.1 测试内容说明

下面的测试代码用于验证以下知识点:

测试方法 验证内容
testBasicOperations() 基本增删改查
testNullAndDuplicate() 重复元素和 null
testCapacityGrowth() 默认容量与扩容过程
testEnsureCapacityAndTrim() 容量预留与压缩
testInsertAndRemove() 中间插入与删除
testIntegerRemovePitfall() Integer 删除陷阱
testSearchSortAndBinarySearch() 查找、排序、二分查找
testIteratorRemove() 迭代器安全删除
testFailFast() 快速失败机制
testSubListView() subList() 视图特点
testObjectReference() 对象引用特点

7.2 完整可运行代码

java 复制代码
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.ConcurrentModificationException;
import java.util.Iterator;
import java.util.List;

public class ArrayListLearningDemo {

    public static void main(String[] args) throws Exception {
        System.out.println("========== ArrayList 学习测试开始 ==========");

        testBasicOperations();
        testNullAndDuplicate();
        testCapacityGrowth();
        testEnsureCapacityAndTrim();
        testInsertAndRemove();
        testIntegerRemovePitfall();
        testSearchSortAndBinarySearch();
        testIteratorRemove();
        testFailFast();
        testSubListView();
        testObjectReference();

        System.out.println("\n========== 所有测试通过 ==========");
    }

    /**
     * 测试基本的添加、查询、修改和删除操作。
     */
    private static void testBasicOperations() {
        System.out.println("\n--- 1. 基本增删改查测试 ---");

        List<String> skills = new ArrayList<>();

        skills.add("Java");
        skills.add("MySQL");
        skills.add("Redis");

        assertEquals(3, skills.size(), "添加三个元素后,size 应为 3");
        assertEquals("MySQL", skills.get(1), "下标 1 的元素应为 MySQL");

        String oldValue = skills.set(1, "Spring Boot");

        assertEquals("MySQL", oldValue, "set() 应返回旧值");
        assertEquals("Spring Boot", skills.get(1), "修改后的值不正确");

        boolean removed = skills.remove("Redis");

        assertTrue(removed, "Redis 应该删除成功");
        assertEquals(Arrays.asList("Java", "Spring Boot"), skills, "删除后的内容不正确");

        System.out.println("当前列表:" + skills);
    }

    /**
     * 测试重复元素和 null。
     */
    private static void testNullAndDuplicate() {
        System.out.println("\n--- 2. null 与重复元素测试 ---");

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

        list.add("Java");
        list.add(null);
        list.add("Java");
        list.add(null);

        assertEquals(4, list.size(), "列表长度不正确");
        assertEquals(0, list.indexOf("Java"), "第一个 Java 的位置不正确");
        assertEquals(2, list.lastIndexOf("Java"), "最后一个 Java 的位置不正确");
        assertEquals(1, list.indexOf(null), "null 的位置不正确");

        System.out.println("当前列表:" + list);
    }

    /**
     * 测试无参构造后的懒初始化和扩容过程。
     *
     * 需要通过反射观察底层数组长度。
     * JDK 9 及以上版本运行时需要添加:
     * --add-opens java.base/java.util=ALL-UNNAMED
     */
    private static void testCapacityGrowth() throws Exception {
        System.out.println("\n--- 3. 扩容机制测试 ---");

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

        System.out.println("刚创建时:size = " + list.size()
                + ", capacity = " + getCapacity(list));

        assertEquals(0, list.size(), "刚创建时 size 应为 0");
        assertEquals(0, getCapacity(list), "刚创建时底层容量应为 0");

        for (int i = 1; i <= 11; i++) {
            list.add(i);

            System.out.println("添加第 " + i + " 个元素后:size = "
                    + list.size() + ", capacity = " + getCapacity(list));
        }

        assertEquals(11, list.size(), "元素数量不正确");
        assertTrue(getCapacity(list) >= 11, "容量必须能够容纳全部元素");
    }

    /**
     * 测试提前预留容量和缩减容量。
     */
    private static void testEnsureCapacityAndTrim() throws Exception {
        System.out.println("\n--- 4. ensureCapacity 与 trimToSize 测试 ---");

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

        list.ensureCapacity(100);

        int capacityAfterEnsure = getCapacity(list);

        assertTrue(capacityAfterEnsure >= 100,
                "ensureCapacity(100) 后容量应至少为 100");

        list.add("Java");
        list.add("MySQL");
        list.add("Redis");

        list.trimToSize();

        int capacityAfterTrim = getCapacity(list);

        assertEquals(3, capacityAfterTrim,
                "trimToSize() 后容量应等于当前元素数量");

        System.out.println("预留容量后 capacity = " + capacityAfterEnsure);
        System.out.println("压缩容量后 capacity = " + capacityAfterTrim);
    }

    /**
     * 测试指定位置插入和删除元素。
     */
    private static void testInsertAndRemove() {
        System.out.println("\n--- 5. 指定位置插入与删除测试 ---");

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

        list.add("Java");
        list.add("Redis");

        list.add(1, "MySQL");

        assertEquals(
                Arrays.asList("Java", "MySQL", "Redis"),
                list,
                "指定位置插入结果不正确"
        );

        String removed = list.remove(1);

        assertEquals("MySQL", removed, "删除的元素不正确");
        assertEquals(
                Arrays.asList("Java", "Redis"),
                list,
                "删除中间元素后的结果不正确"
        );

        System.out.println("最终列表:" + list);
    }

    /**
     * 测试 Integer 列表中 remove() 的重载陷阱。
     */
    private static void testIntegerRemovePitfall() {
        System.out.println("\n--- 6. Integer remove 重载陷阱测试 ---");

        List<Integer> numbers = new ArrayList<>(
                Arrays.asList(10, 20, 30, 20)
        );

        Integer removedByIndex = numbers.remove(1);

        assertEquals(Integer.valueOf(20), removedByIndex,
                "remove(1) 应删除下标为 1 的元素");

        assertEquals(
                Arrays.asList(10, 30, 20),
                numbers,
                "按下标删除后的列表不正确"
        );

        boolean removedByValue = numbers.remove(Integer.valueOf(20));

        assertTrue(removedByValue, "按值删除应该成功");
        assertEquals(
                Arrays.asList(10, 30),
                numbers,
                "按值删除后的列表不正确"
        );

        System.out.println("最终列表:" + numbers);
    }

    /**
     * 测试查找、排序、自定义对象排序和二分查找。
     */
    private static void testSearchSortAndBinarySearch() {
        System.out.println("\n--- 7. 查找、排序与二分查找测试 ---");

        List<Integer> numbers = new ArrayList<>(
                Arrays.asList(30, 10, 20, 30, 40)
        );

        assertEquals(0, numbers.indexOf(30), "第一个 30 的位置不正确");
        assertEquals(3, numbers.lastIndexOf(30), "最后一个 30 的位置不正确");
        assertTrue(numbers.contains(20), "列表中应该包含 20");

        Collections.sort(numbers);

        assertEquals(
                Arrays.asList(10, 20, 30, 30, 40),
                numbers,
                "数字排序结果不正确"
        );

        int index = Collections.binarySearch(numbers, 20);

        assertEquals(1, index, "二分查找结果不正确");

        List<User> users = new ArrayList<>();

        users.add(new User("张三", 22));
        users.add(new User("李四", 18));
        users.add(new User("王五", 25));

        users.sort(Comparator.comparingInt(User::getAge));

        assertEquals("李四", users.get(0).getName(), "年龄排序结果不正确");

        System.out.println("排序后的数字列表:" + numbers);
        System.out.println("20 的位置:" + index);
        System.out.println("按照年龄排序后的用户列表:" + users);
    }

    /**
     * 测试使用 Iterator.remove() 安全删除元素。
     */
    private static void testIteratorRemove() {
        System.out.println("\n--- 8. Iterator 安全删除测试 ---");

        List<String> list = new ArrayList<>(
                Arrays.asList("Java", "MySQL", "Redis", "MyBatis")
        );

        Iterator<String> iterator = list.iterator();

        while (iterator.hasNext()) {
            String item = iterator.next();

            if (item.startsWith("My")) {
                iterator.remove();
            }
        }

        assertEquals(
                Arrays.asList("Java", "Redis"),
                list,
                "Iterator 删除后的结果不正确"
        );

        System.out.println("删除后列表:" + list);
    }

    /**
     * 测试遍历过程中直接修改列表导致的快速失败。
     */
    private static void testFailFast() {
        System.out.println("\n--- 9. fail-fast 测试 ---");

        List<String> list = new ArrayList<>(
                Arrays.asList("Java", "MySQL", "Redis")
        );

        boolean exceptionThrown = false;

        try {
            for (String item : list) {
                if ("Java".equals(item)) {
                    list.remove(item);
                }
            }
        } catch (ConcurrentModificationException e) {
            exceptionThrown = true;
            System.out.println("捕获到预期异常:" + e.getClass().getSimpleName());
        }

        assertTrue(exceptionThrown,
                "遍历过程中直接删除元素,应该触发快速失败异常");
    }

    /**
     * 测试 subList() 返回的是原列表视图。
     */
    private static void testSubListView() {
        System.out.println("\n--- 10. subList 视图测试 ---");

        List<String> list = new ArrayList<>(
                Arrays.asList("Java", "MySQL", "Redis", "Spring Boot")
        );

        List<String> subList = list.subList(1, 3);

        subList.set(0, "MongoDB");

        assertEquals("MongoDB", list.get(1),
                "修改 subList 后,原列表对应位置也应该发生变化");

        List<String> independentCopy = new ArrayList<>(subList);

        independentCopy.set(0, "Oracle");

        assertEquals("MongoDB", list.get(1),
                "修改独立副本后,不应该影响原列表");

        System.out.println("原列表:" + list);
        System.out.println("独立副本:" + independentCopy);
    }

    /**
     * 测试 ArrayList 中保存的是对象引用。
     */
    private static void testObjectReference() {
        System.out.println("\n--- 11. 对象引用测试 ---");

        User user = new User("张三", 20);

        List<User> users = new ArrayList<>();

        users.add(user);

        user.setName("李四");

        assertEquals("李四", users.get(0).getName(),
                "修改对象后,列表中读取到的对象属性也应该改变");

        System.out.println("列表中的用户:" + users.get(0));
    }

    /**
     * 通过反射读取 ArrayList 底层数组容量。
     * 该方法仅用于学习和测试,不应写入实际业务代码。
     */
    private static int getCapacity(ArrayList<?> list) throws Exception {
        Field field = ArrayList.class.getDeclaredField("elementData");

        field.setAccessible(true);

        Object[] elementData = (Object[]) field.get(list);

        return elementData.length;
    }

    private static void assertTrue(boolean condition, String message) {
        if (!condition) {
            throw new AssertionError("断言失败:" + message);
        }
    }

    private static void assertEquals(Object expected, Object actual, String message) {
        boolean equals = expected == null
                ? actual == null
                : expected.equals(actual);

        if (!equals) {
            throw new AssertionError(
                    "断言失败:" + message
                            + ",期望值:" + expected
                            + ",实际值:" + actual
            );
        }
    }

    /**
     * 测试使用的用户类。
     */
    private static class User {

        private String name;
        private int age;

        public User(String name, int age) {
            this.name = name;
            this.age = age;
        }

        public String getName() {
            return name;
        }

        public int getAge() {
            return age;
        }

        public void setName(String name) {
            this.name = name;
        }

        @Override
        public String toString() {
            return "User{name='" + name + "', age=" + age + "}";
        }
    }
}

总结

ArrayList 的本质,是一个基于数组实现的动态列表。

它保留了数组按下标访问速度快的优势,同时通过自动扩容解决了普通数组长度固定的问题。

学习 ArrayList 时,需要重点掌握以下内容:

核心知识点 结论
底层结构 基于 Object[] 动态数组
元素特点 有序、可重复、允许 null
容量概念 size 表示元素数量,capacity 表示底层容量
扩容机制 容量不足时创建更大的数组并复制旧元素
查询修改 get()set() 通常为 O(1)
尾部添加 摊还时间复杂度为 O(1)
中间插入删除 需要移动元素,通常为 O(n)
遍历删除 使用 Iterator.remove()removeIf()
线程安全 ArrayList 本身不是线程安全集合
常见陷阱 remove(Integer)Arrays.asList()subList()

在实际开发中,如果业务主要是顺序保存、频繁读取、尾部添加 ,通常可以优先考虑 ArrayList。而当业务需要频繁进行队列式的头尾操作时,可以进一步考虑 ArrayDeque 等更合适的数据结构。

相关推荐
ytttr87314 小时前
基于MATLAB的三维六面体有限元网格模型
开发语言·matlab
Chloeis Syntax14 小时前
JavaEE初阶学习日记(3)---网络初认识
java·网络·笔记·学习
枫叶丹414 小时前
【HarmonyOS 6.0】Live View Kit 实况支持显示夕阳和赏月背景的技术解读与实践
开发语言·华为·harmonyos
AI人工智能+电脑小能手14 小时前
【大白话说Java面试题 第80题】【Mysql篇】第10题:MySQL 在什么条件下索引失效?
java·开发语言·mysql·adb·面试
还在忙碌的吴小二14 小时前
Spring Boot Examples 学习示例集新手入门指南
java·spring boot·后端·学习·spring
风吹夏回14 小时前
Python JWT 认证实战:从原理到 PyCharm 落地指南
开发语言·python·pycharm·jwt
jieyucx14 小时前
Go 语言 JSON 序列化/反序列化:Tag 用法完全指南
开发语言·golang·json·序列化·tag
霸道流氓气质14 小时前
Spring AI 工作流引擎扩展 Human-in-the-Loop 人工审批功能完整实战
java·人工智能·spring
小肝一下14 小时前
STL——list
开发语言·c++·stl·list·伊雷娜