在 Java 后端开发中,集合几乎无处不在:查询结果通常放在 List 中,缓存和对象索引经常使用 Map,去重会使用 Set,任务调度和生产者—消费者模型则离不开队列。
集合类的难点并不只是记住 API。真正重要的是理解不同集合的底层结构、时间复杂度、线程安全特性和适用场景。本文以 Java 21 为版本基准,重点整理日常开发和面试中最常用、最重要的集合类型。
一、集合框架整体结构
1. Collection 体系
Collection 表示一组独立的元素,主要分为三类:
List:有序、可重复,例如ArrayList。Set:通常不允许重复,例如HashSet。Queue和Deque:面向队列或双端队列的操作,例如ArrayDeque。
常见继承关系可以简化为:
1 | Iterable |
2. Map 体系
Map 不属于 Collection,因为它存储的是键值对,而不是单独的元素。一个键最多对应一个值,常用实现包括:
HashMap:按哈希值快速查找。LinkedHashMap:在哈希表基础上维护顺序。TreeMap:按照键排序。ConcurrentHashMap:支持并发访问。
3. 集合选择的基本思路
选择集合时,可以依次考虑以下问题:
- 是否需要键值映射?需要就考虑
Map。 - 是否允许重复?不允许就考虑
Set。 - 是否需要按索引访问?需要就优先考虑
ArrayList。 - 是否需要首尾进出?考虑
ArrayDeque。 - 是否需要自动排序?考虑
TreeMap或TreeSet。 - 是否存在并发访问?选择并发集合,而不是简单地给普通集合加一个“线程安全”的想象。
二、List:优先掌握 ArrayList
1. ArrayList 的特点
ArrayList 是开发中最常用的 List 实现,底层使用一个可扩容的数组保存元素。
1 | List<String> names = new ArrayList<>(); |
它的主要特点是:
- 支持按索引快速访问。
- 允许重复元素和
null。 - 保持元素的插入顺序。
- 尾部添加通常很快,但扩容时需要复制数组。
- 中间插入和删除需要移动后续元素。
2. 扩容机制
ArrayList 内部维护一个数组。当数组容量不足时,会创建更大的数组并复制原有元素。Java 21 中,空的 ArrayList 通常不会在构造时立即分配完整数组,而是在第一次添加元素时分配容量。
因此,如果能够预估数据规模,可以指定初始容量:
1 | List<Integer> values = new ArrayList<>(10_000); |
这样可以减少多次扩容和数组复制,但也不应该为了“保险”而盲目设置一个特别大的容量,否则会浪费内存。
3. ArrayList 的复杂度
| 操作 | 平均复杂度 | 说明 |
|---|---|---|
get(index) |
O(1) | 数组随机访问 |
set(index, value) |
O(1) | 直接修改数组位置 |
尾部 add() |
均摊 O(1) | 扩容时会暂时变慢 |
中间 add() |
O(n) | 需要移动元素 |
按索引 remove() |
O(n) | 需要移动元素 |
contains() |
O(n) | 需要线性查找 |
4. remove() 的重载陷阱
对 List<Integer> 调用 remove() 时,传入整数默认会匹配按索引删除的方法:
1 | List<Integer> numbers = new ArrayList<>(List.of(10, 20, 30)); |
5. LinkedList 是否值得使用
LinkedList 底层是双向链表,节点中保存前驱、元素和后继引用。
它在已经定位到节点时,插入和删除可以是 O(1),但按索引访问需要先从头部或尾部查找节点,复杂度为 O(n)。此外,每个节点都是独立对象,会带来额外的引用和对象开销,CPU 缓存局部性也不如数组。
因此,在普通的列表场景中通常优先使用 ArrayList。如果需要高效地从两端插入、删除或进行栈操作,优先考虑 ArrayDeque。
三、Map:HashMap 是核心
1. HashMap 的基本使用
1 | Map<Long, String> users = new HashMap<>(); |
HashMap 的主要特点是:
- 通过键的哈希值定位元素。
- 平均情况下查询、插入和删除接近 O(1)。
- 允许一个
null键和多个null值。 - 不保证遍历顺序。
- 不是线程安全的。
2. put 的大致过程
执行 put(key, value) 时,可以抽象为以下步骤:
- 计算键的哈希值。
- 根据哈希值定位数组下标。
- 如果当前位置为空,直接放入节点。
- 如果发生冲突,比较键是否相等。
- 键相同则覆盖旧值,键不同则挂到链表或红黑树中。
- 元素数量超过阈值时扩容。
3. 哈希冲突与红黑树
HashMap 的桶中可能保存链表。当同一个桶中的节点较多时,链表查询会退化为 O(n)。为了降低极端冲突下的查询成本,HashMap 在满足条件时会将链表转换成红黑树。
源码中常见的相关阈值包括:
- 链表长度达到 8 左右时,可能触发树化。
- 数组容量不足 64 时,通常优先扩容而不是树化。
- 红黑树节点数量减少到较小规模时,可能退化回链表。
这些阈值是实现细节,实际使用时不应依赖它们编写业务逻辑,但理解它们有助于理解 HashMap 的性能设计。
4. 负载因子与扩容
默认负载因子通常为 0.75。当元素数量超过“容量 × 负载因子”计算出的阈值后,哈希表会扩容并重新分布节点。
负载因子越小,桶中的冲突通常越少,但会占用更多空间;负载因子越大,空间利用率较高,但冲突可能增加。0.75 是时间和空间之间的折中。
5. equals 与 hashCode
作为 HashMap 键的类必须正确实现 equals() 和 hashCode():
1 | public final class UserId { |
必须满足:如果两个对象的 equals() 返回 true,它们的 hashCode() 必须相同。否则,集合可能把逻辑上相同的对象放入不同的桶中,导致查找失败或出现重复数据。
键对象还应该尽量不可变。若对象放入 Map 后参与哈希计算的字段发生变化,之后可能无法通过原对象找到对应的值。
6. 常用 Map 方法
1 | Map<String, Integer> counter = new HashMap<>(); |
putIfAbsent():键不存在时才写入。computeIfAbsent():键不存在时计算值。merge():适合计数和合并值。getOrDefault():查询不到时返回默认值。
四、LinkedHashMap 与 TreeMap
1. LinkedHashMap
LinkedHashMap 在 HashMap 的基础上增加了双向链表,用来维护遍历顺序。
1 | Map<String, Integer> scores = new LinkedHashMap<>(); |
它可以维护插入顺序,也可以配置为访问顺序:
1 | Map<String, String> cache = new LinkedHashMap<>(16, 0.75f, true); |
配合 removeEldestEntry(),可以实现一个简单的 LRU 缓存:
1 | class LruCache<K, V> extends LinkedHashMap<K, V> { |
这个实现适合学习和简单场景。生产环境中的缓存通常还需要考虑并发、过期、淘汰策略和持久化等问题。
2. TreeMap
TreeMap 基于红黑树,按照键的自然顺序或指定比较器维护元素顺序。
1 | NavigableMap<Integer, String> levels = new TreeMap<>(); |
TreeMap 的查询、插入和删除复杂度通常为 O(log n),适合需要排序或范围查询的场景。如果只需要根据键快速查找,并不需要顺序,应优先选择 HashMap。
五、Set:HashSet 与 TreeSet
1. HashSet
HashSet 底层基于 HashMap 实现,集合元素会作为 Map 的键保存。
1 | Set<String> tags = new HashSet<>(); |
元素是否重复,取决于 equals() 和 hashCode()。因此,自定义对象放入 HashSet 时,同样必须正确实现这两个方法。
HashSet 适合快速判断元素是否存在,但不保证遍历顺序。如果既要去重,又要保持插入顺序,可以使用 LinkedHashSet。
2. TreeSet
TreeSet 基于红黑树,能够在去重的同时维护排序结果:
1 | Set<Integer> numbers = new TreeSet<>(List.of(30, 10, 20, 10)); |
需要特别注意,TreeSet 判断重复主要依赖比较结果。如果比较器对两个对象返回 0,它们就可能被认为是同一个元素,即使 equals() 返回 false。
六、ArrayDeque 与 PriorityQueue
1. ArrayDeque
ArrayDeque 是基于循环数组的双端队列,支持从头部和尾部高效添加、删除元素。
1 | Deque<String> deque = new ArrayDeque<>(); |
它既可以作为队列:
1 | Queue<String> queue = new ArrayDeque<>(); |
也可以作为栈:
1 | Deque<String> stack = new ArrayDeque<>(); |
相比历史上的 Stack,ArrayDeque 的 API 更现代,通常也更适合作为栈使用。ArrayDeque 不允许存储 null,并且不是线程安全的。
2. PriorityQueue
PriorityQueue 底层是堆,默认情况下堆顶是最小元素:
1 | Queue<Integer> minHeap = new PriorityQueue<>(); |
创建大顶堆可以提供反向比较器:
1 | Queue<Integer> maxHeap = new PriorityQueue<>(Comparator.reverseOrder()); |
需要注意,PriorityQueue 只保证堆顶元素具有最高优先级,不保证迭代顺序完全有序。若要依次得到排序结果,应不断调用 poll(),或者直接使用排序集合。
七、并发集合
1. 为什么不能直接在线程间共享 HashMap
HashMap 没有提供并发控制。多个线程同时执行写入、扩容或复合操作时,可能出现数据覆盖、结果不一致等问题。
如果需要并发访问,应根据读写特征选择并发集合,而不是简单地把所有操作都包上一层锁。
2. ConcurrentHashMap
ConcurrentHashMap 是高并发场景下最常用的 Map。它通过 CAS 和局部同步减少锁竞争,不会像对整个 Map 加一把大锁那样限制所有线程。
1 | ConcurrentMap<String, Long> counters = new ConcurrentHashMap<>(); |
主要特点:
- 支持多个线程并发读写。
- 不允许
null键和null值。 - 迭代器具有弱一致性,不会简单地因为并发修改就抛出异常。
- 适合缓存、索引、计数器和共享状态表。
它不允许 null 的主要原因是:当 get(key) 返回 null 时,无法区分“键不存在”和“键存在但值为 null”。
3. CopyOnWriteArrayList
CopyOnWriteArrayList 修改数据时会复制底层数组,读操作则可以直接读取当前快照。
它适合:
- 读操作远多于写操作。
- 集合规模不会特别大。
- 遍历期间希望看到稳定快照。
它不适合高频写入,因为每次修改都可能复制整个数组,并产生额外内存压力。
4. BlockingQueue
BlockingQueue 适合生产者—消费者模型。队列满时,生产者可以等待;队列为空时,消费者可以等待。
1 | BlockingQueue<String> tasks = new ArrayBlockingQueue<>(100); |
常用实现:
ArrayBlockingQueue:基于数组的有界阻塞队列。LinkedBlockingQueue:基于链表,可配置容量。
offer()、poll() 通常是立即返回;put()、take() 则可能阻塞等待。在使用阻塞方法时,需要结合线程中断和超时机制设计退出逻辑。
八、Java 21:Sequenced Collections
Java 21 引入了有序集合的新抽象,用于统一表示具有明确遍历顺序的集合和 Map。相关接口包括:
SequencedCollectionSequencedSetSequencedMap
它们统一了首尾元素和反向访问操作。例如:
1 | SequencedCollection<String> items = new ArrayList<>(); |
对 Map,可以使用首尾 Entry:
1 | SequencedMap<String, Integer> scores = new LinkedHashMap<>(); |
reversed() 通常返回反向视图,而不是立即创建一个新的集合副本。因此,使用时要注意原集合和反向视图之间的关系。这个特性对于 LinkedHashMap、有序 Set 和双端队列的统一操作尤其有用。
九、常用集合选型
| 需求 | 推荐类型 | 原因 |
|---|---|---|
| 按索引访问列表 | ArrayList |
随机访问快,缓存局部性好 |
| 普通键值查询 | HashMap |
平均查找复杂度接近 O(1) |
| 去重 | HashSet |
基于哈希快速判断存在性 |
| 去重并保持插入顺序 | LinkedHashSet |
哈希表加链表维护顺序 |
| 键值按顺序排列 | TreeMap |
红黑树维护键的顺序 |
| 元素自动排序并去重 | TreeSet |
红黑树维护唯一有序元素 |
| 栈或双端队列 | ArrayDeque |
两端操作高效 |
| 优先级处理 | PriorityQueue |
堆顶始终是最高优先级元素 |
| 并发键值存储 | ConcurrentHashMap |
支持高并发读写 |
| 生产者—消费者 | BlockingQueue |
支持阻塞式生产和消费 |
| 读多写少的共享列表 | CopyOnWriteArrayList |
读操作无需加锁 |
十、常见陷阱
1. 遍历时直接删除
下面的代码可能抛出 ConcurrentModificationException:
1 | for (String item : items) { |
可以使用 Iterator.remove() 或 removeIf():
1 | items.removeIf(String::isBlank); |
2. 把 HashMap 当作有序 Map
HashMap 不保证插入顺序,也不应该根据某次运行得到的遍历顺序编写业务逻辑。需要顺序时,应明确使用 LinkedHashMap 或 TreeMap。
3. 把同步包装集合当成并发集合
Collections.synchronizedList() 能为单次操作提供同步,但复合操作和遍历仍然需要额外同步。高并发场景应优先评估 ConcurrentHashMap、CopyOnWriteArrayList 或阻塞队列。
4. 使用可变对象作为 HashMap 的键
如果键参与 equals() 或 hashCode() 的字段发生变化,Map 可能无法再定位到原来的桶。作为键的对象最好设计为不可变对象。
5. 误用不可变集合工厂方法
1 | List<String> names = List.of("Alice", "Bob"); |
如果确实需要修改,应显式创建可变集合:
1 | List<String> mutableNames = new ArrayList<>(List.of("Alice", "Bob")); |
十一、总结
Java 集合的学习重点不是记住所有实现类,而是建立“需求—接口—实现—底层结构”的对应关系:
- 需要列表和随机访问,优先使用
ArrayList。 - 需要键值快速查询,优先使用
HashMap。 - 需要去重,使用
HashSet。 - 需要顺序,使用
LinkedHashMap或LinkedHashSet。 - 需要排序和范围查询,使用
TreeMap或TreeSet。 - 需要栈、队列或双端操作,使用
ArrayDeque。 - 需要优先级调度,使用
PriorityQueue。 - 需要并发键值访问,使用
ConcurrentHashMap。 - 需要线程间传递任务,使用
BlockingQueue。
Java 21 的 Sequenced Collections 进一步统一了有序集合的首尾操作和反向访问。掌握这些核心类型及其使用边界,已经能够覆盖绝大多数 Java 后端开发中的集合场景。
