在Java开发中,数据存储和操作是最基础也是最重要的环节之一。虽然数组是最基本的数据容器,但在实际开发中我们更常使用集合框架,尤其是ArrayList这个"万金油"容器。为什么会出现这种现象?让我们从最基础的对比开始讲起。
数组确实简单直接,但它有个致命的缺点:长度固定。想象你开了一家小店,用固定大小的货架(数组)存放商品。当生意突然变好需要增加商品种类时,固定货架就显得捉襟见肘了。而ArrayList就像一套可伸缩的智能货架系统,能根据商品数量自动调整大小,还自带各种便捷的管理功能。
ArrayList底层其实还是基于数组实现的,但它通过巧妙的扩容机制解决了数组长度固定的问题。当元素数量超过当前容量时,ArrayList会自动创建一个更大的新数组(通常是原容量的1.5倍),然后把旧数组的元素复制过去。这个过程对开发者完全透明,我们只管往里面添加元素就行。
提示:虽然ArrayList会自动扩容,但如果我们能预估数据量大小,最好在创建时指定初始容量(如
new ArrayList<>(100)),这样可以减少扩容操作带来的性能开销。
创建ArrayList对象有几种常见方式,每种都有其适用场景:
java复制// 最常用的无参构造,初始容量为10
ArrayList<String> defaultList = new ArrayList<>();
// 指定初始容量,适合已知大概数据量的情况
ArrayList<Integer> sizedList = new ArrayList<>(100);
// 通过已有集合初始化
List<String> existingData = Arrays.asList("A", "B", "C");
ArrayList<String> fromExisting = new ArrayList<>(existingData);
在实际项目中,我强烈推荐使用接口类型List来声明变量(如List<String> list = new ArrayList<>()),这样后续如果需要更换实现类(比如换成LinkedList),只需修改一行代码即可,符合面向接口编程的原则。
ArrayList提供了一套完整的CRUD(增删改查)方法,我们先看最基础的增删操作:
java复制List<String> languages = new ArrayList<>();
// 添加元素
languages.add("Java"); // 尾部添加
languages.add(0, "Python"); // 指定位置插入
// 删除元素
languages.remove("Java"); // 按元素删除
languages.remove(0); // 按索引删除
// 修改元素
languages.set(0, "JavaScript"); // 替换指定位置元素
查询操作除了基本的按索引获取外,还有一些实用方法:
java复制// 基本查询
String first = languages.get(0);
// 判断存在性
boolean hasJava = languages.contains("Java");
// 查找位置
int index = languages.indexOf("Python"); // 不存在返回-1
// 获取大小
int size = languages.size();
// 判断空集合
boolean isEmpty = languages.isEmpty();
注意:直接通过索引获取元素时(如get(5)),如果索引越界会抛出IndexOutOfBoundsException。安全的做法是先检查size()或使用Optional包装。
ArrayList有多种遍历方式,性能和使用场景各不相同:
java复制for(int i=0; i<languages.size(); i++) {
System.out.println(i + ": " + languages.get(i));
}
java复制for(String lang : languages) {
System.out.println(lang);
}
java复制Iterator<String> it = languages.iterator();
while(it.hasNext()) {
String lang = it.next();
if(lang.equals("Java")) {
it.remove(); // 安全删除
}
}
java复制languages.forEach(System.out::println);
// 或带索引的版本
IntStream.range(0, languages.size())
.forEach(i -> System.out.println(i + ": " + languages.get(i)));
在性能敏感的场景中,传统for循环通常是最快的,因为它没有迭代器对象的创建开销。但在大多数情况下,选择哪种方式更多取决于代码可读性和具体需求。
ArrayList的自动扩容看似简单,但里面有不少值得关注的细节。默认情况下:
我们可以通过以下代码观察扩容过程:
java复制List<Integer> numbers = new ArrayList<>();
for(int i=0; i<100; i++) {
numbers.add(i);
// 反射获取实际容量
Field field = ArrayList.class.getDeclaredField("elementData");
field.setAccessible(true);
Object[] elementData = (Object[]) field.get(numbers);
System.out.println("Size: " + numbers.size() + ", Capacity: " + elementData.length);
}
在实际开发中,频繁扩容会影响性能。假设我们要添加1000个元素:
这也是为什么在已知数据量大概范围时,指定初始容量是个好习惯。
ArrayList不是线程安全的,这意味着在多线程环境下同时修改ArrayList可能导致数据不一致甚至程序崩溃。常见问题包括:
解决方案有多种:
java复制List<String> syncList = Collections.synchronizedList(new ArrayList<>());
java复制List<String> cowList = new CopyOnWriteArrayList<>();
java复制List<String> list = new ArrayList<>();
// 写操作时
synchronized(list) {
list.add("item");
}
在Java8+中,我们还可以考虑使用ConcurrentHashMap等并发集合替代,具体选择取决于使用场景。
ArrayList不同操作的时间复杂度:
基于这些特点,我们可以得出一些优化建议:
除了基本操作,ArrayList还有一些高级用法值得掌握:
批量操作:
java复制List<String> list1 = new ArrayList<>(Arrays.asList("A", "B", "C"));
List<String> list2 = new ArrayList<>(Arrays.asList("B", "C", "D"));
// 并集
list1.addAll(list2); // [A, B, C, B, C, D]
// 交集
list1.retainAll(list2); // [B, C]
// 差集
list1.removeAll(list2); // [A]
列表转换:
java复制// ArrayList转数组
String[] array = list1.toArray(new String[0]);
// 数组转ArrayList
List<String> newList = new ArrayList<>(Arrays.asList(array));
不可变列表(Java9+):
java复制List<String> immutable = List.of("A", "B", "C");
java复制// 错误示范
for(String item : list) {
if(item.equals("Java")) {
list.remove(item); // 抛出ConcurrentModificationException
}
}
// 正确做法1:使用迭代器
Iterator<String> it = list.iterator();
while(it.hasNext()) {
if(it.next().equals("Java")) {
it.remove();
}
}
// 正确做法2:Java8 removeIf
list.removeIf(item -> item.equals("Java"));
java复制List<Person> original = new ArrayList<>();
original.add(new Person("Alice"));
// 看似拷贝,实则共享引用
List<Person> copy = new ArrayList<>(original);
copy.get(0).setName("Bob"); // original中的Alice也被修改了
// 深拷贝解决方案
List<Person> deepCopy = original.stream()
.map(p -> new Person(p.getName()))
.collect(Collectors.toList());
java复制List<String> list = null;
// 错误示范
list.add("Java"); // NullPointerException
// 防御性编程
list = Optional.ofNullable(list).orElseGet(ArrayList::new);
list.add("Java");
让我们看一个实际案例:处理百万级数据时如何优化ArrayList使用。
原始版本(性能较差):
java复制List<Integer> numbers = new ArrayList<>(); // 默认容量10
for(int i=0; i<1_000_000; i++) {
numbers.add(i); // 频繁扩容
}
优化版本1:预分配容量
java复制List<Integer> numbers = new ArrayList<>(1_000_000);
for(int i=0; i<1_000_000; i++) {
numbers.add(i); // 无扩容
}
优化版本2:并行流处理(适合计算密集型)
java复制List<Integer> numbers = IntStream.range(0, 1_000_000)
.parallel()
.boxed()
.collect(Collectors.toList());
在我的性能测试中(百万级数据):
选择哪种优化方式取决于具体场景。对于简单操作,预分配通常是最平衡的选择。
虽然都实现了List接口,但ArrayList和LinkedList内部结构完全不同:
| 特性 | ArrayList | LinkedList |
|---|---|---|
| 底层结构 | 动态数组 | 双向链表 |
| 随机访问性能 | O(1) | O(n) |
| 头部插入/删除 | O(n) | O(1) |
| 尾部插入/删除 | O(1)(摊销) | O(1) |
| 内存占用 | 更小(仅数组) | 更大(节点开销) |
| 迭代器性能 | 更快 | 较慢 |
选择原则:
Vector是Java早期的线程安全动态数组实现,现在基本被ArrayList+外部同步取代:
| 特性 | ArrayList | Vector |
|---|---|---|
| 线程安全 | 不安全 | 安全(方法同步) |
| 扩容增量 | 1.5倍 | 可指定(默认2倍) |
| 性能 | 更高 | 较低(同步开销) |
| 迭代器 | fail-fast | fail-fast |
现代Java开发中,除非必须维护遗留系统,否则应该优先使用ArrayList。
当我们需要保证元素唯一性时,可以考虑Set接口的实现类:
| 特性 | ArrayList | HashSet |
|---|---|---|
| 元素唯一性 | 不保证 | 保证 |
| 顺序性 | 插入顺序 | 无保证 |
| 包含检查性能 | O(n) | O(1)平均 |
| 允许null | 是 | 是(仅一个) |
实际开发中经常需要两者转换:
java复制// 去重
List<String> withDuplicates = ...;
List<String> unique = new ArrayList<>(new HashSet<>(withDuplicates));
// 保留顺序去重(Java8+)
List<String> uniqueOrdered = withDuplicates.stream()
.distinct()
.collect(Collectors.toList());
经过多年的Java开发,我总结了以下ArrayList使用的最佳实践:
List<String>而非ArrayList<String>最后分享一个真实案例:在一次性能调优中,我发现某核心服务频繁GC,追踪发现是因为大量使用默认大小的ArrayList处理万级数据。通过简单添加初始容量参数,不仅GC次数减少了80%,整体吞吐量也提升了30%。这个小改动带来的收益让我印象深刻,也验证了"魔鬼在细节中"的道理。