集合题的失分点通常不是「不知道 API」,而是选型理由说不清、边界条件记混。本文把常用实现放在同一张表里对照,再逐题拆开。
| 需求 | 首选 | 避免 |
|---|---|---|
| 随机访问、尾部追加 | ArrayList | 头部频繁插入用 LinkedList |
| 栈、队列、双端队列 | ArrayDeque | 用 LinkedList 充当队列 |
| 键值查找,无需排序 | HashMap | 多线程共享时用 HashMap |
| 保留插入顺序 | LinkedHashMap | 为排序而用 LinkedHashMap |
| 按 key 排序 / 范围查询 | TreeMap | 大数据量下用 TreeMap 做计数 |
| 高并发读写 | ConcurrentHashMap | Hashtable、Collections.synchronizedMap |
1. ArrayList、LinkedList、ArrayDeque 怎么选?
ArrayList 基于连续数组,随机访问 O(1),尾部追加摊还 O(1),中间插入要移动元素;LinkedList 是双向链表,头尾操作 O(1),但按下标访问是 O(n),而且每个节点都有额外的对象头和前后指针,缓存局部性差。
实践结论:绝大多数场景选 ArrayList。即使频繁在头部插入,ArrayDeque 的环形数组通常也比 LinkedList 快;只有在持有节点引用做大量中间删除时,链表才有优势。
ArrayDeque 不允许 null,容量按 2 倍增长,作为栈比 Stack 更合适,因为 Stack 继承自 Vector,每个方法都加锁。
2. ArrayList 的扩容过程是怎样的?
默认构造出来的是空数组,第一次 add 才扩到 10。之后容量不够时按 oldCapacity + (oldCapacity >> 1) 扩到约 1.5 倍,再用 Arrays.copyOf 复制到新数组。
扩容是 O(n) 的复制,所以能预估大小时应该用 new ArrayList<>(size) 一次性分配,避免多次搬移。trimToSize() 可以回收多余容量,但会再复制一次,不要频繁调用。
注意 subList() 返回的是视图而非拷贝,原列表结构修改后视图会抛 ConcurrentModificationException;Arrays.asList() 返回定长列表,add 会抛异常,但 set 可以改。
3. HashMap、LinkedHashMap、TreeMap、ConcurrentHashMap 怎么选?
HashMap 用哈希表,平均 O(1),不保证顺序;LinkedHashMap 在哈希表基础上加了双向链表,可保留插入顺序或访问顺序,适合做 LRU;TreeMap 基于红黑树,O(log n),支持排序和范围查询,但 key 必须可比较。
ConcurrentHashMap 用 CAS 加桶级锁保证并发安全,读操作基本无锁,适合多线程共享的缓存和计数场景。它不允许 null 键值,这一点和 HashMap 不同。
选择顺序通常是:先问要不要并发,再问要不要顺序,最后才看单次操作复杂度。很多「用 TreeMap 排序」的需求,其实在查询时对结果排一次序更划算。
4. 什么是 fail-fast?遍历时删除为什么会抛异常?
ArrayList、HashMap 等非并发集合在结构修改时会改变 modCount;迭代器创建时记下期望值,每次 next() 都检查两者是否一致,不一致就抛 ConcurrentModificationException。
所以 for (String s : list) list.remove(s) 会失败,正确写法是 iterator.remove(),或者用 removeIf,后者内部同样走了迭代器。单线程下这是主动检测;多线程下它只是「尽力而为」,不能当并发保护使用。
ConcurrentHashMap 的迭代器是弱一致的:不抛异常,但不保证看到遍历开始后的所有修改。CopyOnWriteArrayList 的迭代器基于快照,同样不抛异常,也看不到快照之后的更新。
5. CopyOnWriteArrayList 适合什么场景?代价是什么?
写操作时复制整个数组,在副本上修改,再用 ReentrantLock 保护发布;读操作完全无锁,直接读当前数组引用。
它适合「读多写极少、且读方不能接受加锁」的场景,例如监听器列表、黑白名单、路由配置。写操作是 O(n) 复制,元素多或写频繁时性能会很差,内存峰值也会翻倍。
它的迭代器是快照式的:遍历期间看不到新写入,也不会抛 ConcurrentModificationException;iterator.remove() 不支持,要删元素得直接调列表的 remove。如果要求实时一致,应该换 ConcurrentHashMap 对应的并发结构或加锁。
6. Comparable 和 Comparator 有什么区别?排序要注意什么?
Comparable 由元素自身实现 compareTo,定义「自然顺序」,一个类通常只有一种;Comparator 是外部比较器,可以按需定义多种排序规则,配合 Comparator.comparing(...).thenComparing(...) 组合。
注意三点:比较器必须满足自反、反对称、传递,否则 TimSort 可能抛 IllegalArgumentException: Comparison method violates its general contract;用减法实现比较可能整数溢出,应该用 Integer.compare;null 值要显式处理。
Collections.sort 和 Arrays.sort 对对象数组使用稳定的归并类算法,相等元素的相对顺序会保留;对基本类型数组则使用不稳定但更快的双轴快排。
7. List.of() 和 Collections.unmodifiableList() 有什么区别?
List.of() 创建的是真正不可变且独立的列表:内部拷贝了元素,之后原数组或原集合的修改不会影响它,任何 add、set、remove 都抛异常,且不允许 null 元素。
Collections.unmodifiableList() 是包装视图:它自己拒绝修改,但底层列表变化时会「透传」出来,因此只读保证是相对的。适合「对外暴露只读视图、内部仍需修改」的场景。
Arrays.asList() 又是另一种:返回定长视图,set 可以,add/remove 不行,且与原数组共享数据。三者不要混用。
8. 并发场景下常用哪些阻塞队列?怎么选?
ArrayBlockingQueue 有界、数组实现、单把锁配两个条件队列,适合容量可控的生产消费;LinkedBlockingQueue 默认近似无界(Integer.MAX_VALUE),用两把锁分离入队出队,吞吐更高但必须显式设容量,否则堆积时容易 OOM。
SynchronousQueue 不存储元素,生产者必须等到消费者接手,适合直接交接;PriorityBlockingQueue 无界、按优先级出队;DelayQueue 用于定时任务和缓存过期;LinkedTransferQueue 在「生产者等待消费者」的场景下效率更高。
线程池选队列要结合拒绝策略一起看:用无界队列时 maximumPoolSize 基本不会生效,任务会一直排队,这是很多线上事故的根源。
易错点与自测
subList、Arrays.asList、unmodifiableList都是视图,只有List.of和new ArrayList<>(...)是独立拷贝。- 「线程安全」不等于「复合操作原子」:
ConcurrentHashMap的get后再put仍需computeIfAbsent或显式锁。 HashMap允许一个null键和多个null值,ConcurrentHashMap全都不允许。
自测:① 说明为什么 ArrayDeque 比 LinkedList 更适合做队列;② 写出安全删除 ArrayList 元素的三种方式;③ 解释 List.of 与 unmodifiableList 在数据同步上的差异。
参考资料
- java.util 包 API(Java 21) — 各集合实现的复杂度与线程安全声明
- ArrayList 源码(OpenJDK) — 扩容与
modCount检查 - JEP 269:Convenience Factory Methods for Collections —
List.of的不可变语义