1. ArrayList基础操作全解析
ArrayList作为Java集合框架中最常用的动态数组实现,几乎出现在所有Java开发者的日常编码中。今天我想结合自己多年的项目经验,系统梳理ArrayList对字符串和自定义对象的存储、修改、删除及遍历操作,分享一些实际开发中的技巧和避坑指南。
记得刚入行时,我曾在一次线上事故中因为不熟悉ArrayList的删除操作导致数据错乱,从那以后就养成了深入研究每个API底层机制的习惯。ArrayList看似简单,但想要真正用好它,需要理解其动态扩容机制、fail-fast机制以及各种操作的时间复杂度。
2. 存储操作详解
2.1 字符串元素的存储
存储字符串是ArrayList最基础的用法,但其中也有不少细节需要注意:
java复制ArrayList<String> strList = new ArrayList<>();
strList.add("Java");
strList.add("Python");
strList.add(1, "Golang"); // 在索引1处插入元素
这里有几个关键点:
- 初始容量默认为10,当元素数量超过容量时会自动扩容1.5倍
- 插入元素时如果指定位置已有元素,该位置及后续元素会整体后移
- 频繁插入操作(特别是列表前部)会导致大量数组拷贝,时间复杂度O(n)
实际项目中,如果预知数据量较大,建议在构造时指定初始容量:
new ArrayList<>(1000),避免多次扩容带来的性能损耗。
2.2 自定义对象的存储
存储自定义对象时,有几个必须注意的要点:
java复制class Student {
private String name;
private int age;
// 构造方法、getter/setter省略
}
ArrayList<Student> stuList = new ArrayList<>();
stuList.add(new Student("张三", 20));
stuList.add(new Student("李四", 22));
关键注意事项:
- 自定义类必须正确实现equals()和hashCode()方法,否则会影响contains、remove等操作
- 对于可能被放入集合的类,建议实现Serializable接口
- 对象引用存储在ArrayList中,修改对象属性会影响列表中对应的元素
3. 修改元素操作
3.1 set方法使用详解
修改元素主要通过set(int index, E element)方法实现:
java复制strList.set(1, "C++"); // 将索引1处的元素替换为"C++"
需要注意:
- 索引必须有效(0 <= index < size()),否则抛出IndexOutOfBoundsException
- 返回被替换的旧元素
- 时间复杂度O(1),直接通过数组下标访问
3.2 修改操作的线程安全问题
ArrayList不是线程安全的,在多线程环境下修改元素可能导致数据不一致:
java复制// 错误示例 - 多线程不安全
List<String> unsafeList = new ArrayList<>();
Runnable task = () -> {
for (int i = 0; i < 1000; i++) {
unsafeList.add("item");
}
};
new Thread(task).start();
new Thread(task).start();
解决方案:
- 使用Collections.synchronizedList包装
- 改用CopyOnWriteArrayList
- 在关键操作处加锁
4. 删除元素操作
4.1 按索引删除
java复制String removed = strList.remove(0); // 删除并返回第一个元素
特点:
- 删除后后续元素会前移,时间复杂度O(n)
- 会触发modCount修改,影响迭代器行为
- 索引越界会抛出IndexOutOfBoundsException
4.2 按对象删除
java复制boolean isRemoved = strList.remove("Java"); // 删除首次出现的"Java"
关键点:
- 依赖equals()方法比较元素
- 成功删除返回true,否则false
- 同样会导致元素前移
4.3 批量删除
java复制strList.removeAll(Arrays.asList("Java", "Python")); // 删除所有匹配元素
strList.removeIf(s -> s.length() > 5); // Java8+ 按条件删除
在遍历过程中删除元素是个常见陷阱,稍后会专门讲解。
5. 遍历操作全解析
5.1 基本遍历方式
- for循环遍历(适合随机访问):
java复制for (int i = 0; i < strList.size(); i++) {
System.out.println(strList.get(i));
}
- 增强for循环(语法糖,实际使用迭代器):
java复制for (String s : strList) {
System.out.println(s);
}
- 迭代器遍历:
java复制Iterator<String> it = strList.iterator();
while (it.hasNext()) {
System.out.println(it.next());
}
- Java8 forEach:
java复制strList.forEach(System.out::println);
5.2 遍历时删除元素的正确方式
这是ArrayList操作中最容易出错的地方,先看错误示范:
java复制// 错误!会抛出ConcurrentModificationException
for (String s : strList) {
if (s.equals("Java")) {
strList.remove(s);
}
}
正确做法:
- 使用迭代器的remove方法:
java复制Iterator<String> it = strList.iterator();
while (it.hasNext()) {
if (it.next().equals("Java")) {
it.remove(); // 关键点:使用迭代器的remove
}
}
- Java8+ removeIf:
java复制strList.removeIf(s -> s.equals("Java"));
- 从后往前遍历:
java复制for (int i = strList.size() - 1; i >= 0; i--) {
if (strList.get(i).equals("Java")) {
strList.remove(i);
}
}
6. 性能优化与最佳实践
6.1 初始化容量优化
ArrayList的扩容是个昂贵的操作,涉及数组拷贝:
java复制// 不好的做法 - 默认初始容量10
ArrayList<String> list1 = new ArrayList<>();
// 好的做法 - 预估容量
ArrayList<String> list2 = new ArrayList<>(1000);
扩容过程:
- 创建新数组(原大小1.5倍)
- 使用System.arraycopy拷贝元素
- 丢弃旧数组
6.2 批量操作优化
批量添加使用addAll比循环add更高效:
java复制// 低效做法
for (String s : anotherList) {
list.add(s);
}
// 高效做法
list.addAll(anotherList);
原因:
- addAll会先检查容量需求,可能只需扩容一次
- 使用native方法批量拷贝,效率更高
6.3 选择正确的遍历方式
不同场景下的遍历方式选择:
| 场景 | 推荐方式 | 原因 |
|---|---|---|
| 随机访问 | 普通for循环 | 直接通过索引访问 |
| 只读遍历 | 增强for循环 | 代码简洁 |
| 需要删除 | 迭代器遍历 | 安全删除 |
| 并行处理 | Java8 Stream | 利用多核 |
7. 常见问题排查
7.1 ConcurrentModificationException
这是使用ArrayList时最常见的异常,典型场景:
java复制ArrayList<String> list = new ArrayList<>(Arrays.asList("A", "B", "C"));
for (String s : list) {
if (s.equals("B")) {
list.remove(s); // 抛出异常
}
}
根本原因:
- ArrayList的迭代器是fail-fast的
- 检测到结构修改(modCount变化)就立即失败
解决方案:
- 使用迭代器自己的remove方法
- 改用CopyOnWriteArrayList
- 使用Java8的removeIf
7.2 性能问题排查
当发现ArrayList操作变慢时,可能的瓶颈:
- 频繁插入删除中间元素 - 考虑改用LinkedList
- 大量扩容操作 - 初始化时指定足够容量
- 大量contains操作 - 考虑改用HashSet
7.3 序列化问题
ArrayList实现了Serializable,但要注意:
- 自定义对象也必须实现Serializable
- transient元素不会被序列化
- 序列化不保存容量信息,反序列化后容量=size
8. 实际项目经验分享
在电商项目中,我们曾用ArrayList存储商品评价列表。初期直接使用默认设置,当评价量达到数万时出现性能问题。经过分析发现:
- 频繁的随机插入导致大量数组拷贝
- 分页查询时subList创建了不必要的视图
- 没有合理利用批量操作
优化方案:
- 初始化时评估容量:
new ArrayList<>(20000) - 批量加载评价:
addAll()替代循环add - 对只读操作返回不可变列表:
Collections.unmodifiableList()
另一个经验是,在多线程环境下,我们曾因为直接使用ArrayList导致评价显示错乱。最终解决方案是:
- 读多写少场景:使用CopyOnWriteArrayList
- 写多场景:使用Collections.synchronizedList包装
- 配合ReentrantReadWriteLock实现细粒度控制
对于自定义对象的存储,我们要求所有DTO必须:
- 实现Serializable
- 正确重写equals()和hashCode()
- 实现Comparable接口(如需排序)
这些实践使我们的评价系统能够稳定支持日均百万级的访问量。