Java 集合:选型、扩容与并发容器

对比 List、Map、Set 与阻塞队列的实现差异,讲清 fail-fast、CopyOnWrite 和不可变集合的适用边界

集合题的失分点通常不是「不知道 API」,而是选型理由说不清、边界条件记混。本文把常用实现放在同一张表里对照,再逐题拆开。

需求首选避免
随机访问、尾部追加ArrayList头部频繁插入用 LinkedList
栈、队列、双端队列ArrayDeque用 LinkedList 充当队列
键值查找,无需排序HashMap多线程共享时用 HashMap
保留插入顺序LinkedHashMap为排序而用 LinkedHashMap
按 key 排序 / 范围查询TreeMap大数据量下用 TreeMap 做计数
高并发读写ConcurrentHashMapHashtable、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 在数据同步上的差异。

参考资料

评论

为保障内容质量,评论需站长审核通过后才会展示;提交后没立刻看到属正常,请勿重复提交。

还没有评论,来抢沙发~