ARTICLE

Java 21 集合框架详解:常用集合、底层原理与选型

在 Java 后端开发中,集合几乎无处不在:查询结果通常放在 List 中,缓存和对象索引经常使用 Map,去重会使用 Set,任务调度和生产者—消费者模型则离不开队列。

集合类的难点并不只是记住 API。真正重要的是理解不同集合的底层结构、时间复杂度、线程安全特性和适用场景。本文以 Java 21 为版本基准,重点整理日常开发和面试中最常用、最重要的集合类型。

一、集合框架整体结构

1. Collection 体系

Collection 表示一组独立的元素,主要分为三类:

  • List:有序、可重复,例如 ArrayList。
  • Set:通常不允许重复,例如 HashSet。
  • Queue 和 Deque:面向队列或双端队列的操作,例如 ArrayDeque。

常见继承关系可以简化为:

1
2
3
4
5
6
Iterable
└── Collection
├── List
├── Set
└── Queue
└── Deque

2. Map 体系

Map 不属于 Collection,因为它存储的是键值对,而不是单独的元素。一个键最多对应一个值,常用实现包括:

  • HashMap:按哈希值快速查找。
  • LinkedHashMap:在哈希表基础上维护顺序。
  • TreeMap:按照键排序。
  • ConcurrentHashMap:支持并发访问。

3. 集合选择的基本思路

选择集合时,可以依次考虑以下问题:

  1. 是否需要键值映射?需要就考虑 Map。
  2. 是否允许重复?不允许就考虑 Set。
  3. 是否需要按索引访问?需要就优先考虑 ArrayList。
  4. 是否需要首尾进出?考虑 ArrayDeque。
  5. 是否需要自动排序?考虑 TreeMap 或 TreeSet。
  6. 是否存在并发访问?选择并发集合,而不是简单地给普通集合加一个“线程安全”的想象。

二、List:优先掌握 ArrayList

1. ArrayList 的特点

ArrayList 是开发中最常用的 List 实现,底层使用一个可扩容的数组保存元素。

1
2
3
4
5
6
7
List<String> names = new ArrayList<>();
names.add("Alice");
names.add("Bob");

String first = names.get(0);
names.set(1, "Charlie");
names.remove(0);

它的主要特点是:

  • 支持按索引快速访问。
  • 允许重复元素和 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
2
3
4
List<Integer> numbers = new ArrayList<>(List.of(10, 20, 30));

numbers.remove(1); // 删除索引为 1 的元素,即 20
numbers.remove(Integer.valueOf(30)); // 删除值为 30 的元素

5. LinkedList 是否值得使用

LinkedList 底层是双向链表,节点中保存前驱、元素和后继引用。

它在已经定位到节点时,插入和删除可以是 O(1),但按索引访问需要先从头部或尾部查找节点,复杂度为 O(n)。此外,每个节点都是独立对象,会带来额外的引用和对象开销,CPU 缓存局部性也不如数组。

因此,在普通的列表场景中通常优先使用 ArrayList。如果需要高效地从两端插入、删除或进行栈操作,优先考虑 ArrayDeque。

三、Map:HashMap 是核心

1. HashMap 的基本使用

1
2
3
4
5
6
Map<Long, String> users = new HashMap<>();
users.put(1L, "Alice");
users.put(2L, "Bob");

String name = users.get(1L);
String defaultName = users.getOrDefault(3L, "Unknown");

HashMap 的主要特点是:

  • 通过键的哈希值定位元素。
  • 平均情况下查询、插入和删除接近 O(1)。
  • 允许一个 null 键和多个 null 值。
  • 不保证遍历顺序。
  • 不是线程安全的。

2. put 的大致过程

执行 put(key, value) 时,可以抽象为以下步骤:

  1. 计算键的哈希值。
  2. 根据哈希值定位数组下标。
  3. 如果当前位置为空,直接放入节点。
  4. 如果发生冲突,比较键是否相等。
  5. 键相同则覆盖旧值,键不同则挂到链表或红黑树中。
  6. 元素数量超过阈值时扩容。

3. 哈希冲突与红黑树

HashMap 的桶中可能保存链表。当同一个桶中的节点较多时,链表查询会退化为 O(n)。为了降低极端冲突下的查询成本,HashMap 在满足条件时会将链表转换成红黑树。

源码中常见的相关阈值包括:

  • 链表长度达到 8 左右时,可能触发树化。
  • 数组容量不足 64 时,通常优先扩容而不是树化。
  • 红黑树节点数量减少到较小规模时,可能退化回链表。

这些阈值是实现细节,实际使用时不应依赖它们编写业务逻辑,但理解它们有助于理解 HashMap 的性能设计。

4. 负载因子与扩容

默认负载因子通常为 0.75。当元素数量超过“容量 × 负载因子”计算出的阈值后,哈希表会扩容并重新分布节点。

负载因子越小,桶中的冲突通常越少,但会占用更多空间;负载因子越大,空间利用率较高,但冲突可能增加。0.75 是时间和空间之间的折中。

5. equals 与 hashCode

作为 HashMap 键的类必须正确实现 equals() 和 hashCode():

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public final class UserId {
private final long value;

public UserId(long value) {
this.value = value;
}

@Override
public boolean equals(Object obj) {
return obj instanceof UserId other && value == other.value;
}

@Override
public int hashCode() {
return Long.hashCode(value);
}
}

必须满足:如果两个对象的 equals() 返回 true,它们的 hashCode() 必须相同。否则,集合可能把逻辑上相同的对象放入不同的桶中,导致查找失败或出现重复数据。

键对象还应该尽量不可变。若对象放入 Map 后参与哈希计算的字段发生变化,之后可能无法通过原对象找到对应的值。

6. 常用 Map 方法

1
2
3
4
5
Map<String, Integer> counter = new HashMap<>();

counter.merge("Java", 1, Integer::sum);
counter.putIfAbsent("Python", 1);
counter.computeIfAbsent("Go", key -> key.length());
  • putIfAbsent():键不存在时才写入。
  • computeIfAbsent():键不存在时计算值。
  • merge():适合计数和合并值。
  • getOrDefault():查询不到时返回默认值。

四、LinkedHashMap 与 TreeMap

1. LinkedHashMap

LinkedHashMap 在 HashMap 的基础上增加了双向链表,用来维护遍历顺序。

1
2
3
4
Map<String, Integer> scores = new LinkedHashMap<>();
scores.put("Alice", 90);
scores.put("Bob", 85);
scores.put("Charlie", 95);

它可以维护插入顺序,也可以配置为访问顺序:

1
Map<String, String> cache = new LinkedHashMap<>(16, 0.75f, true);

配合 removeEldestEntry(),可以实现一个简单的 LRU 缓存:

1
2
3
4
5
6
7
8
9
10
11
12
13
class LruCache<K, V> extends LinkedHashMap<K, V> {
private final int capacity;

LruCache(int capacity) {
super(capacity, 0.75f, true);
this.capacity = capacity;
}

@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return size() > capacity;
}
}

这个实现适合学习和简单场景。生产环境中的缓存通常还需要考虑并发、过期、淘汰策略和持久化等问题。

2. TreeMap

TreeMap 基于红黑树,按照键的自然顺序或指定比较器维护元素顺序。

1
2
3
4
5
6
7
NavigableMap<Integer, String> levels = new TreeMap<>();
levels.put(10, "normal");
levels.put(20, "important");
levels.put(30, "urgent");

Integer key = levels.floorKey(25); // 小于等于 25 的最大键
Integer next = levels.ceilingKey(25); // 大于等于 25 的最小键

TreeMap 的查询、插入和删除复杂度通常为 O(log n),适合需要排序或范围查询的场景。如果只需要根据键快速查找,并不需要顺序,应优先选择 HashMap。

五、Set:HashSet 与 TreeSet

1. HashSet

HashSet 底层基于 HashMap 实现,集合元素会作为 Map 的键保存。

1
2
3
4
5
Set<String> tags = new HashSet<>();
tags.add("Java");
tags.add("Java");

System.out.println(tags.size()); // 1

元素是否重复,取决于 equals() 和 hashCode()。因此,自定义对象放入 HashSet 时,同样必须正确实现这两个方法。

HashSet 适合快速判断元素是否存在,但不保证遍历顺序。如果既要去重,又要保持插入顺序,可以使用 LinkedHashSet。

2. TreeSet

TreeSet 基于红黑树,能够在去重的同时维护排序结果:

1
2
Set<Integer> numbers = new TreeSet<>(List.of(30, 10, 20, 10));
System.out.println(numbers); // [10, 20, 30]

需要特别注意,TreeSet 判断重复主要依赖比较结果。如果比较器对两个对象返回 0,它们就可能被认为是同一个元素,即使 equals() 返回 false。

六、ArrayDeque 与 PriorityQueue

1. ArrayDeque

ArrayDeque 是基于循环数组的双端队列,支持从头部和尾部高效添加、删除元素。

1
2
3
4
5
6
7
8
Deque<String> deque = new ArrayDeque<>();

deque.addLast("A");
deque.addLast("B");
deque.addFirst("Start");

String first = deque.removeFirst();
String last = deque.removeLast();

它既可以作为队列:

1
2
3
4
Queue<String> queue = new ArrayDeque<>();
queue.offer("task-1");
queue.offer("task-2");
String task = queue.poll();

也可以作为栈:

1
2
3
4
Deque<String> stack = new ArrayDeque<>();
stack.push("first");
stack.push("second");
String value = stack.pop(); // second

相比历史上的 Stack,ArrayDeque 的 API 更现代,通常也更适合作为栈使用。ArrayDeque 不允许存储 null,并且不是线程安全的。

2. PriorityQueue

PriorityQueue 底层是堆,默认情况下堆顶是最小元素:

1
2
3
4
5
6
Queue<Integer> minHeap = new PriorityQueue<>();
minHeap.offer(30);
minHeap.offer(10);
minHeap.offer(20);

System.out.println(minHeap.poll()); // 10

创建大顶堆可以提供反向比较器:

1
Queue<Integer> maxHeap = new PriorityQueue<>(Comparator.reverseOrder());

需要注意,PriorityQueue 只保证堆顶元素具有最高优先级,不保证迭代顺序完全有序。若要依次得到排序结果,应不断调用 poll(),或者直接使用排序集合。

七、并发集合

1. 为什么不能直接在线程间共享 HashMap

HashMap 没有提供并发控制。多个线程同时执行写入、扩容或复合操作时,可能出现数据覆盖、结果不一致等问题。

如果需要并发访问,应根据读写特征选择并发集合,而不是简单地把所有操作都包上一层锁。

2. ConcurrentHashMap

ConcurrentHashMap 是高并发场景下最常用的 Map。它通过 CAS 和局部同步减少锁竞争,不会像对整个 Map 加一把大锁那样限制所有线程。

1
2
ConcurrentMap<String, Long> counters = new ConcurrentHashMap<>();
counters.merge("request", 1L, Long::sum);

主要特点:

  • 支持多个线程并发读写。
  • 不允许 null 键和 null 值。
  • 迭代器具有弱一致性,不会简单地因为并发修改就抛出异常。
  • 适合缓存、索引、计数器和共享状态表。

它不允许 null 的主要原因是:当 get(key) 返回 null 时,无法区分“键不存在”和“键存在但值为 null”。

3. CopyOnWriteArrayList

CopyOnWriteArrayList 修改数据时会复制底层数组,读操作则可以直接读取当前快照。

它适合:

  • 读操作远多于写操作。
  • 集合规模不会特别大。
  • 遍历期间希望看到稳定快照。

它不适合高频写入,因为每次修改都可能复制整个数组,并产生额外内存压力。

4. BlockingQueue

BlockingQueue 适合生产者—消费者模型。队列满时,生产者可以等待;队列为空时,消费者可以等待。

1
2
3
4
5
6
7
BlockingQueue<String> tasks = new ArrayBlockingQueue<>(100);

// 生产者
tasks.put("task-1");

// 消费者
String task = tasks.take();

常用实现:

  • ArrayBlockingQueue:基于数组的有界阻塞队列。
  • LinkedBlockingQueue:基于链表,可配置容量。

offer()、poll() 通常是立即返回;put()、take() 则可能阻塞等待。在使用阻塞方法时,需要结合线程中断和超时机制设计退出逻辑。

八、Java 21:Sequenced Collections

Java 21 引入了有序集合的新抽象,用于统一表示具有明确遍历顺序的集合和 Map。相关接口包括:

  • SequencedCollection
  • SequencedSet
  • SequencedMap

它们统一了首尾元素和反向访问操作。例如:

1
2
3
4
5
6
7
SequencedCollection<String> items = new ArrayList<>();
items.addLast("A");
items.addLast("B");

System.out.println(items.getFirst()); // A
System.out.println(items.getLast()); // B
System.out.println(items.reversed()); // [B, A]

对 Map,可以使用首尾 Entry:

1
2
3
4
5
6
SequencedMap<String, Integer> scores = new LinkedHashMap<>();
scores.putLast("Alice", 90);
scores.putLast("Bob", 85);

Map.Entry<String, Integer> first = scores.firstEntry();
Map.Entry<String, Integer> last = scores.lastEntry();

reversed() 通常返回反向视图,而不是立即创建一个新的集合副本。因此,使用时要注意原集合和反向视图之间的关系。这个特性对于 LinkedHashMap、有序 Set 和双端队列的统一操作尤其有用。

九、常用集合选型

需求 推荐类型 原因
按索引访问列表 ArrayList 随机访问快,缓存局部性好
普通键值查询 HashMap 平均查找复杂度接近 O(1)
去重 HashSet 基于哈希快速判断存在性
去重并保持插入顺序 LinkedHashSet 哈希表加链表维护顺序
键值按顺序排列 TreeMap 红黑树维护键的顺序
元素自动排序并去重 TreeSet 红黑树维护唯一有序元素
栈或双端队列 ArrayDeque 两端操作高效
优先级处理 PriorityQueue 堆顶始终是最高优先级元素
并发键值存储 ConcurrentHashMap 支持高并发读写
生产者—消费者 BlockingQueue 支持阻塞式生产和消费
读多写少的共享列表 CopyOnWriteArrayList 读操作无需加锁

十、常见陷阱

1. 遍历时直接删除

下面的代码可能抛出 ConcurrentModificationException:

1
2
3
4
5
for (String item : items) {
if (item.isBlank()) {
items.remove(item);
}
}

可以使用 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
2
List<String> names = List.of("Alice", "Bob");
names.add("Charlie"); // UnsupportedOperationException

如果确实需要修改,应显式创建可变集合:

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 后端开发中的集合场景。

本文由 Guailper 撰写CC BY-NC-SA 4.0