
Java核心中的集合框架:常用接口与实现类详解
Java 集合框架提供了一组用于存放、查找和处理对象的接口与实现类。选型时,不能只记住类名,还要考虑是否需要保持顺序、是否允许重复、是否需要排序、是否存在并发访问,以及集合是否可以被修改。
本文以 Java 8 及以上版本为背景介绍常用类型;涉及 List.of、Map.of 等工厂方法时,要求 Java 9 或更高版本。集合框架中没有一个实现能同时满足所有需求,合适的选择取决于数据规模、访问方式和并发模型。
一、先理解集合框架的主要接口
集合类大体分为两支:Collection 表示一组元素,Map 表示键值映射。Map 不继承 Collection,因为它存储的是键和值的对应关系,而不是单纯的元素序列。
List<E>:有序序列,允许重复元素,可通过下标访问。Set<E>:不允许重复元素;不同实现对遍历顺序的保证不同。Queue<E>:通常用于排队处理元素,提供插入、查看和移除队首等操作。Deque<E>:双端队列,可以在两端插入和移除,也可以用作栈。Map<K,V>:通过键查询值,键不能重复;值是否允许重复由具体实现决定。
这些接口定义操作能力,不承诺某个固定的性能。例如,List 既可以由支持快速随机访问的数组实现,也可以由链表实现。写代码时,变量类型通常声明为接口,具体创建哪种实现再根据需求决定。
二、List:有序、可重复的元素序列
1. ArrayList:大多数场景的默认选择
ArrayList 基于动态数组实现,适合按下标读取、遍历以及在尾部追加元素。按下标读取通常为 O(1);在中间插入或删除时,后续元素需要移动,成本与元素数量相关。尾部追加通常很快,但扩容时可能需要分配新数组并复制数据。
容量和元素个数不是一回事。构造函数中的初始容量用于减少扩容,并不代表集合已经包含对应数量的元素。
import java.util.ArrayList;
import java.util.List;
public class ArrayListExample {
public static void main(String[] args) {
List<String> names = new ArrayList<>(16);
names.add("安");
names.add("博");
names.add("安");
System.out.println(names.get(0)); // 安
System.out.println(names.size()); // 3,初始容量不是元素个数
names.remove("安"); // 删除第一个匹配元素
System.out.println(names); // [博, 安]
}
}
如果程序已知数据量的大致范围,可以传入合适的初始容量,减少扩容;不要把一个未经验证的外部输入直接当作巨大容量。集合的容量会受 JVM 可用内存、数组大小限制等因素影响,不能把容量参数理解成可靠的资源保障。
2. LinkedList:双端队列用途通常比随机访问更合适
LinkedList 是双向链表,同时实现了 List 和 Deque。从首尾插入、删除可以很方便,但按下标访问需要沿链表查找,通常为 O(n)。与数组列表相比,链表还需要为节点保存额外引用,内存开销往往更高。
因此,不能简单认为“频繁增删就选 LinkedList”。如果操作位置需要先通过下标或查找确定,定位成本仍然存在;许多实际场景下,数组列表的缓存局部性也更有利。需要双端操作时,优先从 Deque 接口的使用方式出发,再根据测量结果选择实现。
3. 固定大小视图与不可修改列表不是一回事
Arrays.asList(array) 返回由数组支撑的定长列表。可以替换已有位置的值,但不能添加或删除元素;修改列表元素也会反映到原数组中。List.of(Java 9 起)返回不可修改列表,不能增删改,并且不接受 null 元素。
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
public class ListFactoriesExample {
public static void main(String[] args) {
String[] array = {"a", "b"};
List<String> fixed = Arrays.asList(array);
fixed.set(0, "x");
System.out.println(array[0]); // x
// fixed.add("c"); // UnsupportedOperationException
List<String> immutable = List.of("a", "b");
// immutable.set(0, "x"); // UnsupportedOperationException
List<String> mutableCopy = new ArrayList<>(fixed);
mutableCopy.add("c");
System.out.println(mutableCopy);
}
}
三、Set:唯一性与顺序
1. HashSet:只关心去重时的常见选择
HashSet 基于哈希表实现,通常适合快速判断元素是否存在、去重等场景。它不保证遍历顺序。对象是否被视为重复,取决于 equals 和 hashCode:如果重写了其中一个,通常必须同时按一致规则重写另一个。
元素加入集合后,如果修改了影响 equals 或 hashCode 的字段,集合可能无法按预期找到或删除该元素。因此,应避免把可变对象作为哈希集合中的元素,或者至少保证其判等相关状态在集合中保持不变。
import java.util.HashSet;
import java.util.Set;
public class SetExample {
public static void main(String[] args) {
Set<String> tags = new HashSet<>();
tags.add("java");
tags.add("java");
tags.add("collections");
System.out.println(tags.size()); // 2
System.out.println(tags.contains("java")); // true
// 不要依赖遍历输出顺序
}
}
2. LinkedHashSet 与 TreeSet
LinkedHashSet 在保留元素唯一性的同时,按插入顺序遍历,适合需要稳定展示顺序的去重场景。它会为顺序维护额外结构,因此不要因为“顺序更好看”就无条件替换 HashSet。
TreeSet 按自然顺序或指定比较器排序,常见操作如查找、插入和删除通常为 O(log n)。元素必须能够比较,或在创建集合时提供兼容的 Comparator。比较器若把两个不同对象比较为 0,TreeSet 会将它们视为同一个排序键,后加入的元素不会作为独立元素保留。比较规则最好与 equals 一致,否则集合的行为可能令人意外。
四、Map:键值映射的常用实现
1. HashMap:通用键值存储
HashMap 适合不要求遍历顺序的键值查询。通常情况下,get、put 等操作的平均成本接近 O(1),但性能取决于哈希分布、容量和负载等因素,不能把平均复杂度当作对所有输入的保证。
HashMap 允许一个 null 键和多个 null 值。键的判等依赖 equals 与 hashCode。与哈希集合一样,不应在键加入后修改影响哈希值或相等性判断的状态。
2. LinkedHashMap 与 TreeMap
LinkedHashMap 默认按插入顺序遍历,也可以配置为按访问顺序维护条目,常用于构建简单的有界缓存策略。它本身不会自动限制大小,也不是线程安全缓存;移除最旧条目等行为需要通过合适的构造方式和 removeEldestEntry 等机制实现,并处理好并发与缓存失效问题。
TreeMap 按键的自然顺序或比较器排序,适合需要有序遍历、范围查询、最近键查询等场景。键需要可比较,或提供适用的比较器。排序比较为 0 的两个键会被视为同一个映射位置,即使它们的 equals 返回 false 也如此。TreeMap 不允许 null 键;在自然排序下,插入空键会失败。是否允许空值与此不同:TreeMap 通常允许空值。
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.TreeMap;
public class MapExample {
public static void main(String[] args) {
Map<String, Integer> counts = new HashMap<>();
counts.put("java", 2);
counts.merge("java", 1, Integer::sum);
System.out.println(counts.get("java")); // 3
Map<String, Integer> ordered = new LinkedHashMap<>();
ordered.put("second", 2);
ordered.put("first", 1);
System.out.println(ordered.keySet()); // [second, first]
Map<String, Integer> sorted = new TreeMap<>();
sorted.put("b", 2);
sorted.put("a", 1);
System.out.println(sorted.keySet()); // [a, b]
}
}
Map.get(key) 返回 null 时,可能表示键不存在,也可能表示键存在但对应的值为 null。在允许空值的映射中,需要用 containsKey 区分;如果业务不需要空值,约定不存储 null 可以减少歧义。
Java 8 起,getOrDefault、putIfAbsent 和 merge 等方法可以简化常见逻辑,但它们不等于任意实现上的通用并发事务。并发需求应使用明确的并发集合与适当的原子操作。
五、Queue 与 Deque:排队、栈和优先级处理
1. ArrayDeque:单线程队列或栈的常见选择
ArrayDeque 是基于数组的双端队列,通常适合在两端进行插入和删除。它不允许存入 null。与旧式 Stack 类相比,若只是需要栈操作,使用 Deque 接口通常更合适;使用 push、pop 可表达栈语义,使用 addLast、removeFirst 可表达队列语义。ArrayDeque 不是线程安全实现。
2. PriorityQueue:按优先级取出,而不是保持整体排序
PriorityQueue 会根据自然顺序或比较器优先取出最小(或按自定义规则优先级最高)的元素,但迭代器遍历不保证按优先级排序。若需要排序后的完整列表,应复制到列表并排序,而不是依赖队列的迭代顺序。优先级相同的元素,取出次序没有额外稳定性保证。它也不允许 null。
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.PriorityQueue;
import java.util.Queue;
public class QueueExample {
public static void main(String[] args) {
Deque<String> stack = new ArrayDeque<>();
stack.push("first");
stack.push("second");
System.out.println(stack.pop()); // second
Queue<Integer> priorities = new PriorityQueue<>();
priorities.add(8);
priorities.add(3);
priorities.add(5);
System.out.println(priorities.poll()); // 3
}
}
队列接口中有两组相近操作:add/offer 用于插入,remove/poll 用于移除队首,element/peek 用于查看队首。差别在于队列无法接受操作时的处理方式:前一组通常抛出异常,后一组返回特殊值(插入失败时为 false,获取或移除空队列时为 null)。具体实现是否会遇到容量限制,要看该实现和构造方式。
六、不可修改集合、视图与防御性复制
Collections.unmodifiableList(list) 这类方法返回的是不可通过该视图修改的包装对象,不是底层数据的独立快照。如果其他代码仍持有原列表并修改它,包装视图观察到的内容也会变化。它防止的是经由该包装引用进行修改,不是访问控制或安全边界。
List.of、Set.of、Map.of(Java 9 起)创建的集合不可修改,且不接受 null。Set.of 中有重复元素会抛出异常;Map.of 中有重复键也会抛出异常。Map.of 最多接受 10 对键值;需要创建更多条目时,可使用 Map.ofEntries 或构造可变映射。不可修改集合只限制集合结构,不会自动让其中的可变对象变成不可变对象。
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public class DefensiveCopyExample {
public static void main(String[] args) {
List<String> source = new ArrayList<>();
source.add("original");
List<String> view = Collections.unmodifiableList(source);
List<String> copy = Collections.unmodifiableList(
new ArrayList<>(source));
source.add("later");
System.out.println(view); // [original, later]
System.out.println(copy); // [original]
// view.add("x"); // UnsupportedOperationException
}
}
如果类要保存调用方传入的集合,且不希望调用方事后影响内部状态,可以复制输入集合;若还要防止调用方通过 getter 修改内部集合,应返回不可修改视图或再次返回副本。复制只是浅复制:集合中的对象引用仍然指向同一对象。若元素本身可变,需要结合对象设计决定是否也要复制元素。
七、迭代与并发:不要把“能运行”误认为“安全”
许多普通集合在迭代期间发生结构性修改时,可能抛出 ConcurrentModificationException。这类快速失败行为主要用于尽早发现错误,不是线程同步机制,也不保证所有并发修改都一定能被检测到。需要边遍历边删除时,可使用迭代器自己的 remove(若该迭代器支持),或先收集要删除的元素,再统一处理。
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
public class IteratorRemoveExample {
public static void main(String[] args) {
List<Integer> values =
new ArrayList<>(List.of(1, 2, 3, 4));
Iterator<Integer> iterator = values.iterator();
while (iterator.hasNext()) {
if (iterator.next() % 2 == 0) {
iterator.remove();
}
}
System.out.println(values); // [1, 3]
}
}
常见并发选项包括:
ConcurrentHashMap:支持并发访问,不能存储null键或值。复合业务操作仍要选择合适的原子方法,不能把多次独立调用当成一个事务。CopyOnWriteArrayList:写操作会复制底层数组,适合读多写少且迭代期间希望看到稳定快照的场景;写入成本和内存开销较高。Collections.synchronizedList等包装:对单个方法调用提供同步访问,但迭代时仍应按 API 要求在包装对象上同步整个遍历过程。
并发集合解决的是特定访问模式下的线程安全问题,不代表业务层面的复合操作自动原子化,也不提供身份验证、授权、输入校验或持久化一致性。
八、常见错误与边界检查
- 把集合大小和索引上限混淆。下标从 0 开始,有效索引范围是
0到size() - 1;空列表没有有效下标。 - 用容量代替初始化元素。
new ArrayList<>(100)创建的是空列表,不是 100 个元素的列表;此时size()仍为 0。 - 以为数组转换一定得到可增删列表。
Arrays.asList是定长视图,需要可增删列表时,复制到ArrayList。 - 只重写
equals,不重写hashCode。哈希集合与映射可能因此出现违反预期的查找和去重结果。 - 依赖未承诺的遍历顺序。
HashMap和HashSet不保证遍历顺序;PriorityQueue的迭代也不是排序输出。 - 在集合中修改关键字段。哈希键、哈希集合元素的判等状态,以及有序集合元素的比较状态,都不宜在存入后改变。
- 直接接受外部数据规模。对来自请求、文件或消息的集合长度、分配容量和元素数量设置业务上限,并验证值是否合法;否则可能造成过量内存消耗或计算开销。限值应按应用资源与业务要求确定,没有适用于所有程序的固定安全数字。
- 把集合当安全边界。不可修改包装、私有引用或并发集合都不能替代权限校验和输入验证。对外暴露可变内部集合,可能让调用方绕过对象的不变量。
九、选型速查
| 需求 | 优先考虑 | 注意事项 |
|---|---|---|
| 通用序列、按下标读取、尾部追加 | ArrayList | 中间插入删除会移动元素 |
| 元素去重,不要求顺序 | HashSet | 保证正确实现 equals 与 hashCode |
| 去重且按插入顺序遍历 | LinkedHashSet | 维护顺序会增加开销 |
| 键值查询,不要求顺序 | HashMap | 键的哈希与相等性规则必须稳定 |
| 按键排序或范围查询 | TreeMap | 键必须可比较,比较规则要合理 |
| 单线程双端队列或栈 | ArrayDeque | 不接受 null,不是线程安全实现 |
| 并发键值访问 | ConcurrentHashMap | 不接受 null,复合操作仍需正确设计 |
实际项目中,先把需求说清楚:是否需要顺序、是否允许重复、是否需要范围查询、是否存在并发写入,再选择接口与实现。对性能敏感的场景,应使用真实数据和代表性操作进行测量;不要仅凭“链表增删快”或某个复杂度标签做决定。与此同时,明确集合的可变性、输入规模和对象判等规则,通常比换一个实现类更能避免隐蔽错误。









