写 Java 的日常里,集合(Collection)用得太频繁了,频繁到很多人觉得这是理所当然的东西。但一旦面试官问起 ArrayList 怎么扩容、HashMap 的哈希碰撞怎么处理、为什么在 foreach 里删除元素会抛 ConcurrentModificationException,不少人就卡壳了。我当初学集合框架也经历过同样的过程:接口和实现类背了一堆,真到了自己分析问题的时候才发现,底层机制和设计意图才是理解整个集合体系的钥匙。
这篇文章是我的 Java 学习日记第四篇,主攻集合框架。目标是两件事:一是把这套体系的主干讲清楚——List、Set、Map 各自的职责和常用实现;二是把那些高频考点背后的原理拆开——扩容、哈希、去重、树化、fail-fast,让新手能看懂,也让准备面试的人能带着理解去复习,而不是死记八股。
1. 先搞明白:数组做得好好的,集合到底解决什么问题
1.1 数组的三个硬伤,集合是怎么治的
Java 里最基础的数据结构是数组,它在教科书上出现频率极高,但实际开发中用起来却经常束手束脚。问题主要有三个。
第一,长度固定。一个 new Object[10] 创建出来,容量就是 10,第 11 个元素放不进去。如果想把数据存满之后再继续加,只能创建一个更大的新数组,再手动把旧数组的元素一个个拷贝过去。我曾经用数组写过一个小型日志收集器,业务数据一涨,就要写一大段扩容拷贝的代码,非常痛苦。
第二,增删元素麻烦。在数组中间插入一个元素,需要把插入位置后面的所有元素整体后移;删除一个元素,则需要把后面的元素整体前移。这个操作的复杂度是 O(n),数据一多就明显卡顿。
第三,按内容查找低效。想知道某个元素在不在数组里,只能从头到尾遍历比较,没有更优雅的捷径。
集合框架出来就是来解决这些问题的。ArrayList 能自动扩容,Set 天然去重,Map 用键直接映射到值,查找效率大幅提升。它的设计目标很直接:让开发者用最少的代码完成最常见的数据存取操作,不用每次自己造轮子。
1.2 集合框架的两大体系:Collection 和 Map
Java 集合框架其实分两大体系,这是最基础也最容易混的地方。
Collection 接口是单列数据的根接口,下面主要派生出 List、Set 和 Queue 三个子接口:
List:有序、可重复。元素的遍历顺序跟插入顺序一致,可以存在值相同的多个元素。Set:大多数实现下无序、不可重复。靠equals和hashCode判断元素是否相等,重复元素不会被加入。Queue:队列,主要用于先进先出场景,Deque是双端队列的扩展接口。
Map 接口是双列数据的根接口,存的是键值对(key-value pair)。key 不允许重复,value 可以重复。需要特别注意,Map 并不继承 Collection,它是独立于 Collection 的另一棵体系树,但两者统称 Java 集合框架,都在 java.util 包下。
理解这个分类的意义在于:拿到一个需求时,先判断到底是需要"单列数据集合"还是"键值对映射",再往下选具体实现类。比如统计一篇文章中每个单词出现的次数,应该用 Map<String, Integer> 而不是 List;去重一批用户 ID,应该用 Set 而不是 List 再手动 contains 判断。
下面这张表是把常用实现类放在一起看,方便对照:
| 体系 | 接口 | 常用实现类 | 特点 |
|---|---|---|---|
| 单列 | List | ArrayList, LinkedList, Vector | 有序,可重复 |
| 单列 | Set | HashSet, LinkedHashSet, TreeSet | 不可重复 |
| 单列 | Queue | ArrayDeque, LinkedList | 队列操作 |
| 双列 | Map | HashMap, LinkedHashMap, TreeMap, Hashtable, ConcurrentHashMap | 键值对存储 |
学到这里,整体图谱基本上就有轮廓了。接下来分别把 List、Set、Map 一个一个拆开来看。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. List 实用心法:ArrayList 和 LinkedList,别光背八股
2.1 ArrayList 扩容:从默认 10 到 1.5 倍的真正细节
ArrayList 的内部就是一个 Object 数组,引用名是 elementData,还有一个 size 字段记录实际存储的元素个数。注意,这个 size 和数组长度是两个概念,数组长度是底层容器的容量,size 是真正放进去的元素数量。
我刚学的时候以为 new ArrayList<>(100) 是创建了一个包含 100 个元素的列表,其实不是,它只是预设底层数组容量为 100,size 仍然为 0。很多面试题会在这里挖坑。
关于扩容机制,有几个细节值得记下来:
第一,JDK 1.8 中默认的 ArrayList 初始容量虽然是 10,但这个 10 并不是 new 出来时就有的。看源码会发现,new ArrayList<>() 时 elementData 指向的是一个空数组 DEFAULTCAPACITY_EMPTY_ELEMENTDATA,第一次执行 add 时才通过 grow 方法扩展到默认容量 10。
第二,当 size + 1 > elementData.length 时触发扩容。新容量的计算方式很巧妙:oldCapacity + (oldCapacity >> 1),也就是原来容量的 1.5 倍。举个例子,默认容量 10,扩容后是 15;下一次是 22(15 >> 1 等于 7,15 + 7 = 22)。
第三,扩容完成后执行 Arrays.copyOf 把旧数组的内容拷贝到新数组,这一步是 O(n) 操作。如果频繁触发扩容,拷贝成本会积少成多。
所以实际项目里,只要能预估数据量,最好在构造时指定初始容量,比如 new ArrayList<>(expectedSize),这样能减少扩容次数。这个优化对大数据量场景尤其明显,也是一个容易讲出彩的面试点。
还有一个值得思考的问题:为什么扩容是 1.5 倍而不是 2 倍或者更大?如果扩容幅度太小,比如 1.1 倍,会频繁扩容、频繁拷贝,性能变差;如果幅度太大,比如直接翻 4 倍,虽然扩容次数少了,但内存浪费严重。1.5 倍是空间和时间之间一个相对平衡的折中,配合均摊分析,n 次添加元素的总复杂度仍然是 O(n),均摊下来每次 add 都是 O(1)。
2.2 LinkedList 的"快"其实有代价
LinkedList 底层是双向链表,每个节点有 prev、next、item 三个字段。它同时实现了 List 接口和 Deque 接口,所以既能当 List 用,也能当队列或者双端队列用。
提到 LinkedList,很多人的第一反应是"插入删除快,随机访问慢"。但这里必须澄清一个前提:插入删除 O(1) 是指你已经拿到了要操作位置的节点引用。比如通过迭代器指向了某个节点,此时在该节点前后插入或删除确实是 O(1)。但如果只是告诉你"在第 i 个位置插入",你怎么找到第 i 个位置?只能从头部或者尾部开始遍历,这个查找过程本身是 O(n),整体操作复杂度仍然是 O(n)。
我实际测试过一个场景:对 1 万个元素的 LinkedList 用 for 循环加 get(i) 遍历,性能比用迭代器遍历慢了一个量级以上。原因是每次 get(i) 都要从头节点开始数位置,而迭代器是沿着链表的 next 指针一步步走的,每个节点只访问一次。所以遍历 LinkedList 一定要用增强 for 或迭代器,不要用按索引访问的方式。
那么实际选型怎么选?我给出一个经验倾向:
- 大量随机访问:首选
ArrayList,按下标访问 O(1)。 - 频繁在头部插入删除:
LinkedList或ArrayDeque有优势。 - 频繁在中间插入删除且能配合迭代器定位:
LinkedList可行,但代码复杂度高。 - 大多数情况下,数据只是追加在尾部,
ArrayList的尾部 add 均摊 O(1),完全够用。
这里额外提一句,Vector 是 ArrayList 的旧版线程安全实现,方法级加 synchronized,并发性能并不理想,新项目基本不用了。线程安全场景优先考虑 CopyOnWriteArrayList,或者后续读并发包时再深入了解。
2.3 一个顺手可用的选型对照表
把上面讨论的选择逻辑整理成一张表,以后写代码可以直接参照:
| 使用场景 | 推荐容器 |
|---|---|
| 批量顺序存储并支持随机访问 | ArrayList |
| 已知大致数据量 | ArrayList 并指定初始容量 |
| 频繁头部插入或删除 | ArrayDeque 或 LinkedList |
| 需要同时作为队列和栈使用 | ArrayDeque |
| 多线程并发读写 | CopyOnWriteArrayList |
3. Set 去重的底层逻辑:equals、hashCode 和红黑树的配合
3.1 HashSet 为什么能去重
HashSet 底层其实是 HashMap。执行 add 时,它把元素作为 key,value 用一个固定的占位对象 PRESENT 存进 HashMap。因为 HashMap 的 key 不能重复,所以 Set 天然具备去重能力。
但"不可重复"到底是怎么判断的?这是整个 Set 逻辑的核心:先算 hashCode 定位到哈希桶,再通过 equals 比较确认是否真的是同一个元素。如果两个对象的 hashCode 不同,它们在哈希表上的位置大概率不同,HashMap 认为它们是不同的 key,直接都存进去;如果 hashCode 相同但 equals 不等,它们会落在同一个桶里,形成链表或共用一个桶的多个节点,此时 HashMap 会继续用 equals 比较,相等才算重复,不相等就共存。
所以 Java 官方约定是:重写 equals 时必须重写 hashCode,保证"equals 相等的两个对象 hashCode 一定相等"。如果违反了这个约定,会出现诡异现象:两个逻辑上完全相同的对象,因为 hashCode 不同被存进了 Set 的不同位置,结果 Set 里出现了"重复"元素,去重失效。
举个例子。定义一个 Person 类,只重写 equals,不重写 hashCode:
java复制Set<Person> set = new HashSet<>();
set.add(new Person("张三", 20));
set.add(new Person("张三", 20));
System.out.println(set.size()); // 结果是 2,而不是 1
这个例子特别经典,就是面试题里常考的"重写 equals 却忘记重写 hashCode 的坑"。解决方案也不难,直接用 IDE 自动生成 equals 和 hashCode 方法,基于业务上的主键字段来生成,不要手写。
3.2 TreeSet 排序与 Comparable/Comparator
TreeSet 底层是 TreeMap,也就是红黑树。它不依赖 hashCode 和 equals 的哈希逻辑,而是根据比较结果来确定元素位置。元素要么实现 Comparable 接口,要么在构造 TreeSet 时传入一个 Comparator。
这里有个关键点:在 TreeSet 中判断两个元素相等,不是靠 equals,而是靠 compareTo 或 compare 的返回值是否为 0。如果比较结果为 0,就认为是同一个元素,只保留一个。
实际项目中踩过这种坑:某个类的 compareTo 只针对 id 字段进行比较,但业务上需要 id 加姓名共同决定一份数据的唯一性。两个 id 相同但姓名不同的对象,会被 TreeSet 当成同一个元素,导致数据莫名"少"了。排查了很久才发现是比较逻辑和等值判断逻辑不一致导致的。
TreeSet 适合需要有序集合的场景,比如排行榜、需要范围查找的数据结构。但要记住,它内部是红黑树,插入、删除、查找都是 O(log n),比 HashSet 的均摊 O(1) 慢一些。所以不要为了排序把所有 Set 都换成 TreeSet,排序功能换个思路用 ArrayList 加 Collections.sort 反而更灵活。
3.3 LinkedHashSet:去重的同时保持顺序
LinkedHashSet 继承自 HashSet,内部是一个 LinkedHashMap,在哈希表的基础上额外维护了一条双向链表记录插入顺序。因此它同时具备 HashSet 的去重能力和"插入顺序可预期"的特性。
实际使用场景很常见:比如接口返回了一批标签,里面可能有重复的标签,但展示的时候希望保持原始顺序。用 LinkedHashSet 直接去重,顺序仍是第一次出现的顺序,非常省事。
顺带提一个延伸知识点:LinkedHashMap 在构造参数 accessOrder 为 true 时,双向链表会按访问顺序排列,这正好可以实现 LRU 缓存(最近最少使用淘汰)。以后做本地缓存时可以回头再看这个机制。
4. HashMap 的核心机制:哈希、碰撞和树化
4.1 从 put 开始看 HashMap 的数据结构
JDK 1.8 之后的 HashMap 是"数组 + 链表 + 红黑树"的结构。看一个 put(key, value) 的全过程,会有比较直观的理解:
第一步,对 key 调用 hashCode(),得到一个 int 类型的哈希值。
第二步,做一次扰动处理:hash = h ^ (h >>> 16)。这个操作的目的,是把哈希值的高 16 位借给低 16 位参与运算,因为后续计算桶下标时用的是低位。这样即使 key 的 hashCode 分布不够均匀,低位也能带上高位的随机性,减少碰撞。
第三步,计算桶下标:index = (n - 1) & hash,其中 n 是当前数组长度。这一步结合了位运算的效率。
第四步,如果该位置为空,直接放一个 Node 节点。
第五步,如果该位置不为空,遍历链表或树:找到 key 相同(先比较 hash 是否相等,再用 equals 判断)的节点,覆盖 value;没找到就尾插法新加一个节点。
第六步,如果链表长度达到 8 并且数组长度达到 64,链表会转换成红黑树,把查找时间从 O(n) 降为 O(log n)。如果链表长度达到 8 但数组长度还不足 64,优先扩容而不是树化。
第七步,如果当前 size 超过了阈值 threshold = capacity * loadFactor,触发扩容,容量翻倍。
整个过程本质上就是一张哈希表,链表解决哈希冲突,红黑树解决极端情况下链表过长的问题。
4.2 容量为什么总是 2 的 n 次幂
HashMap 的初始容量是 16,每次扩容翻倍,16、32、64。为什么必须是 2 的幂?答案在 index = (n - 1) & hash 这个式子里。
如果 n 是 2 的幂,那么 n - 1 的二进制低位全是 1。比如 16 - 1 = 15,二进制是 1111。此时 (n - 1) & hash 等价于 hash % n,但位运算比取模运算快得多。更重要的是,当 n 是 2 的幂时,哈希值的高位可以被保留参与分布,桶下标分布相对均匀。
反过来,如果容量不是 2 的幂,比如 n = 15,那么 n - 1 = 14,二进制是 1110,最低位是 0。任何 hash 值与 14 取与之后,最低位永远是 0,这意味着下标为奇数的桶永远不会有元素,数组空间利用率直接损失一半。
这也是为什么学 HashMap 时,不能忽略位运算和二进制视角。理解了这一点,很多面试题都能顺藤摸瓜答出来。
4.3 负载因子 0.75 背后的权衡
负载因子(load factor)默认是 0.75,这个数字很多人背过,但未必知道它为什么是 0.75。
负载因子太小,比如 0.5,哈希冲突会少,查找快,但数组需要频繁扩容,内存浪费明显。负载因子太大,比如 1,数组空间利用率高了,但链表变长,哈希冲突严重,查找性能下降。
0.75 是时间和空间的权衡结果。在随机哈希码的前提下,一个桶中节点数量的分布服从泊松分布。官方注释里给出了一组概率数据,当负载因子为 0.75 时,桶中节点数达到 8 的概率已经小到约千万分之六,所以链表长度 8 被选为树化阈值。
这也是面试问答的经典切入点:为什么链表长度到 8 才转红黑树?因为 0.75 的负载因子下,随机哈希使链表长度超过 8 的概率极低,树化是兜底策略,而不是常态。平时大多数情况下,桶里就是链表或者只有一个节点。
4.4 HashMap、Hashtable、ConcurrentHashMap 怎么选
HashMap:非线程安全,效率最高,允许 null key 和 null value,单线程场景首选。Hashtable:老一代线程安全 Map,所有方法都用synchronized锁整个表,并发高时性能很差,新项目不建议用。ConcurrentHashMap:并发场景首选。JDK 1.8 后采用 CAS +synchronized,锁粒度细化到了桶的头节点,多线程读写效率远高于Hashtable。
一句话总结:单线程用 HashMap,多线程用 ConcurrentHashMap,Hashtable 已经基本退居历史舞台。另外 Collections.synchronizedMap() 也是锁整个 Map,并发性能不如 ConcurrentHashMap。
5. 遍历集合最容易踩的坑:modCount 和 fail-fast
5.1 为什么 foreach 里删除元素会崩
很多新手都写过类似代码:
java复制List<String> list = new ArrayList<>();
list.add("a");
list.add("b");
list.add("c");
for (String s : list) {
if ("b".equals(s)) {
list.remove(s);
}
}
如果删除的不是倒数第二个元素,这段代码大概率会抛 ConcurrentModificationException。
根本原因在 ArrayList 内部维护了一个 modCount 字段,记录结构性修改的次数。add、remove、clear 等操作都会让 modCount 加一。迭代器创建时会把当前的 modCount 记录到 expectedModCount,每次调用 next() 都会检查 modCount 是否仍然等于 expectedModCount,不相等就直接抛异常。foreach 本质上就是迭代器的语法糖,所以在 foreach 中直接调用 list.remove(),modCount 变了而迭代器不知道,第二次取元素时一检查就炸了。
有一个反直觉的情况:如果删除的是倒数第二个元素,这段代码反而可能不抛异常。因为迭代到倒数第二个元素之后,hasNext() 发现没有下一个了,循环直接结束,没有触发 next() 的检查。这只是侥幸,不是应该依赖的行为。
5.2 安全删除的几种姿势
正确的删除方式主要有四种。
第一种,使用迭代器自身的 remove 方法:
java复制Iterator<String> it = list.iterator();
while (it.hasNext()) {
String s = it.next();
if ("b".equals(s)) {
it.remove();
}
}
迭代器自己的 remove 会同步更新 expectedModCount,所以不抛异常。
第二种,使用 Java 8 的 removeIf:
java复制list.removeIf(s -> "b".equals(s));
第三种,先收集再删除:
java复制List<String> toRemove = list.stream()
.filter(s -> "b".equals(s))
.collect(Collectors.toList());
list.removeAll(toRemove);
第四种,从后往前遍历删除,利用索引变动方向的特性来规避问题,但这种方式要求你对索引逻辑非常清楚,不推荐给新手。
HashMap 在遍历时也有同样的 fail-fast 机制,所以遍历 Map 时不要直接调用 map.remove(key) 修改结构,应该用 entrySet().removeIf,或者先收集要删除的 key 到另一个列表里,再统一删除。
这里顺带把 fail-fast 和 fail-safe 的区别讲一下:ArrayList、HashMap 这类容器的迭代器是 fail-fast,检测到结构改动就快速失败抛异常,避免在脏数据上继续迭代;CopyOnWriteArrayList 等并发容器的迭代器是 fail-safe,遍历的是创建迭代器时的快照,不会抛异常,但也不保证能看到最新的修改。
6. 学到什么程度才算真正掌握集合
6.1 建立体系比背方法重要
很多新人学集合,盯着方法列表背:add、remove、get、size……背了一遍又一遍,但真到设计问题时还是无从下手。我建议反过来,先理解接口的定位,再理解实现类的数据结构差异。
比如问"ArrayList 和 LinkedList 有什么区别",不要急着背列表,先想数据结构:一个是动态数组,一个是双向链表。再看复杂度:随机访问 O(1) 对 O(n),尾部插入均摊 O(1) 对 O(1),头部插入 O(n) 对 O(1)。最后想使用场景。数据结构一出来,答案自然就串起来了。
6.2 读源码的优先级
学集合不需要从第一行读到最后一行的源码,按下面的顺序效率会更高:
ArrayList:重点看grow扩容和add逻辑,代码量不大,容易读懂。HashMap:重点看putVal、resize、treeifyBin核心方法,代码虽然长,但这是面试高频。LinkedList:看node(index)方法和双链表的插入、删除逻辑。ConcurrentHashMap:留到并发编程阶段再看,先了解整体设计,再深入细节。
读源码时带着问题读,比如为什么容量必须是 2 的幂、为什么负载因子是 0.75、什么时候树化、什么时候扩容。这些问题在源码里都有答案。
6.3 一个学完就能上手的练习
纸上谈兵不如动手写一写。我建议学完集合后做一个小工具:控制台版的学生成绩管理系统。需求可以这样设计:用 ArrayList 存学生对象,用 HashMap 存学号和成绩,用 TreeSet 或 Comparator 完成成绩排名。做完之后,对集合的直观感受会完全不一样。
再来几个经典练习题:
- 给定一个字符串列表,统计每个字符串出现的次数,使用
Map。 - 找出出现次数最多的前 3 个,使用
TreeSet或Stream排序。 - 输出去重后保持原顺序的列表,使用
LinkedHashSet。
这些小练习都是实际开发中经常遇到的需求。一步步做完,集合框架就算真正入门了。
说实话,集合框架这块内容,我一开始学的时候也觉得繁琐,但后来发现,所有复杂的 Java 代码,说到底都是数组、链表、哈希表、树这些数据结构的不同组合方式。把集合框架的底层弄清楚,不仅仅是为了应付面试,更是为了以后写代码的时候,能在心里大概估一下"这段操作的时间复杂度是多少、会不会在数据量大时出问题"。至少我现在写代码选哪个集合类,基本都是下意识在想了。
