1. 集合框架的起点:为什么必须先搞懂Collection接口
很多同学刚开始学Java集合时,容易一头扎进ArrayList、HashMap的源码里,背了一堆方法名和底层结构,真到写代码的时候却不知道该选哪个。这个问题我见过太多次了,归根结底是没把集合框架的“骨架”搭起来。骨架是什么?就是Collection接口以及它的两大核心分支List和Set。
简单说,Collection接口是Java集合框架的根接口之一(另一个是Map,注意Map不继承Collection,这是个高频误区),它定义了所有单列集合的共性行为:比如添加元素、删除元素、判断包含、获取大小、遍历集合等。你可以把它理解成一份“合同”,List和Set都签了这份合同,但各自对合同的实现方式完全不同。
这篇文章适合谁看?如果你正在准备面试、刚学完Java基础想进阶、或者写业务代码时经常纠结“用List还是Set”,这篇文章能帮你把整个体系一次理清楚。我尽量用大白话把逻辑结构和底层原理拆开讲,顺便带上一些实战里的经验和坑。
2. Collection接口的核心:逻辑结构与表达层次的拆解
2.1 从类继承关系看Collection的“地位”
先看整体关系。Java集合框架的最顶层是Iterable接口,它只做一件事:让集合可以被迭代。Collection接口继承自Iterable,然后List和Set都继承Collection。这句话看起来简单,但它直接决定了你在写代码时的很多行为,比如能不能用增强for循环、能不能配合Lambda做遍历和处理。
再往下走,List接口有三个常用实现类:ArrayList(底层Object数组)、LinkedList(底层双向链表)、Vector(老牌线程安全类,现在用得少)。Set接口则主要包括HashSet(底层就是HashMap的key部分)、LinkedHashSet(在HashSet基础上额外维护了双向链表记录插入顺序)和TreeSet(底层是红黑树)。另外还有一个特殊的Deque接口(比如ArrayDeque)也继承自Collection,它属于Queue体系,不过我们今天重点把List和Set讲透。
结构类比的思路很重要:Collection就像一个大仓库的门口管理规范,规定了你“可以做什么”;List是“有序货架”——每个货位有编号,你可以按编号取货;Set是“会员登记表”——每个人必须唯一,重复的人不能入册。抓住这个类比,后续所有细节都能串起来。
2.2 Collection接口里那些“约定俗成”的方法
接口本身不长,核心方法大致就这些:
- add(E e):添加元素,返回值是boolean,表示是否添加成功。
- remove(Object o):删除指定元素。
- contains(Object o):判断是否包含某元素。
- size():返回元素个数。
- isEmpty():判断是否为空。
- iterator():返回迭代器。
- clear():清空所有元素。
这里有个非常经典的坑:add方法的返回值。对于普通的List,add几乎永远返回true,因为List允许重复元素,往尾部添加总是成功的(不考虑容量限制时);但对于Set,add返回true表示“添加成功”,返回false则表示“该元素已经存在,没有重复加入”。我见过不少新手在写Set去重逻辑时,没有判断add的返回值,结果以为是并发问题,折腾半天才发现是自己漏了这层约定。实际上在向HashSet加对象时,底层先走hashCode定位桶,再走equals判断是否存在,两步缺一不可。
再强调一个“为什么”:接口中定义这些方法,而不是各自实现类各自造一套,最大好处是依赖抽象而非具体。你在业务代码里写一个接收Collection参数的方法,那ArrayList、LinkedList、HashSet、TreeSet全都能传进去,代码的通用性一下就上来了。这个思想在平时的工程里非常实用,比如做批量处理、统计工具时,面向Collection接口编程能省去大量类型分支判断。
实操心得:如果你的方法只需要读取和遍历集合,形参就写Collection;如果需要按下标访问元素或保证顺序,就写List;如果需要去重,就写Set。形参类型越抽象,方法的复用面越广,但也别抽象到失去语义,比如明明要按插入顺序处理,却接收一个HashSet,那就是给自己埋坑。
3. List接口体系:有序可重复的底层设计与选用场景
3.1 List的三大实现类,到底差在哪儿
List的特点就是有序、可重复、有索引。所谓有序,指的是元素的插入顺序会被保留,迭代时按插入顺序输出。可重复很好理解,同一个对象可以加入多次。有索引意味着你可以通过get(int index)直接定位元素,这是List区别于Set最直观的“能力”。
实现类之间的差别集中在底层数据结构:
- ArrayList:动态数组,查询快、尾部增删快,中间插入删除慢。扩容机制默认是原容量1.5倍,首次默认容量为10(在懒加载机制下是第一次add时才创建数组)。它是最常用的List实现,没有之一。
- LinkedList:双向链表,头尾操作极快,中间插入删除复杂度虽然也是O(n),但因为不需要移动后续元素,只需要改前后节点的引用,实际在结构上更轻。代价是随机访问get(index)需要从头或尾遍历,性能差。
- Vector:和ArrayList实现思路几乎相同,但主要方法用synchronized修饰,线程安全。问题是它不管读还是写都锁整个对象,并发环境下性能并不理想。现在做并发集合优先考虑CopyOnWriteArrayList等更细粒度的方案,Vector基本退出历史舞台了。
选型判断我给你一个速记:
- 读多写少、按下标访问多,选ArrayList。
- 频繁在头部或尾部插入删除,选LinkedList。
- 多线程环境下读多写少,优先CopyOnWriteArrayList,而不是Vector。
3.2 List的常用操作与遍历方式建议
List的常用操作,除了Collection接口那套,还增加了:
- get(int index):按下标取元素。
- set(int index, E element):替换指定位置元素。
- add(int index, E element):在指定位置插入。
- remove(int index):按下标删除。
- indexOf(Object o):查询元素首次出现的位置。
- subList(int fromIndex, int toIndex):取子List。
这里强烈提醒一点:subList返回的是原List的视图,不是新副本。对subList做修改会直接反映到原List上,如果不小心在原List上增删元素后再去操作subList,会抛出并发修改异常。如果你想要真正独立的子集合,得重新new一个ArrayList传进去,比如new ArrayList<>(list.subList(0, 3))。
遍历方式上,我个人的建议是:
- 普通遍历:
for (int i = 0; i < list.size(); i++),适合ArrayList,按下标访问效率高。 - 增强for:最通用,但是不能在循环体里直接调用
list.remove(),否则会抛ConcurrentModificationException。 - Iterator:如果你要在遍历时删除元素,这是正确姿势,通过
iterator.remove()删除。 - Stream:适合做过滤、收集、判空等操作,代码更简洁,但注意在流式操作里对外部变量的修改要谨慎。
实测经验:我有一个小习惯,凡是需要在循环里删除元素的场景,一律用Iterator或者Java 8的removeIf。比如list.removeIf(item -> item.getStatus() == 0),既清晰又安全,比你手动循环一个个删要省心太多。虽然removeIf底层也是用Iterator实现的,但它把细节封装好了,代码健康度直接上一个台阶。
3.3 LinkedList的“双重身份”:List + Deque
LinkedList经常被忽略的一点是,它不止实现了List,还实现了Deque接口。这意味着它既能当列表,也能当队列、双端队列甚至栈用。比如addFirst、addLast、removeFirst、peekFirst这些操作它都支持。当然,日常我们用ArrayDeque做队列和栈的性能更好,因为数组结构更连续、缓存命中率高。但需要同时使用列表和队列语义的场景,LinkedList这一个对象就能搞定,方便是真的方便。
提示:判断一个集合是否有序,不要看它是否“有序输出”,而要看插入顺序是否被保留。LinkedHashSet和HashSet输出顺序可能一样(数据量小时),但只有LinkedHashSet保证与插入顺序一致。
4. Set接口体系:无序不可重复的实现原理与避坑指引
4.1 HashSet去重的底层真相:HashMap的那个Key
HashSet表面上是独立的集合类,实际上它内部维护了一个HashMap,你向HashSet添加的元素,其实是被当作HashMap的Key存放的。而HashMap的Key之所以不能重复,靠的又是两把锁:hashCode和equals。
流程是这样的:添加新元素时,先计算该元素的hashCode,通过哈希值找到桶位置;如果该位置没有元素,直接放入;如果有元素,再用equals比较是否相同。如果hashCode相同且equals相同,那就认为“重复”,新元素不会被加入。所以我一直强调一个点:如果自定义对象要放进HashSet实现去重,必须同时重写hashCode和equals。只重写equals不重写hashCode,相同内容的对象大概率被分配到不同桶里,去重直接失效,这个坑在业务代码里特别常见。
有个实际例子我印象很深:某业务系统里有个“用户去重”的需求,用户对象只重写了equals,没重写hashCode。生产环境里同一批用户被重复拉进列表,排查了好久才发现是这两个方法没对齐。记住:hashCode相同,equals不一定相同(哈希碰撞);equals相同,hashCode必须相同(这是约定)。
4.2 LinkedHashSet与TreeSet:各自的位置
LinkedHashSet在HashSet基础上额外维护了一条双向链表,记录了元素的插入顺序。它特别适合“要去重,还要保留顺序”的场景。比如你从配置文件里读了一批关键词,既要自动去重,又不想打乱原来的排列顺序,LinkedHashSet就是最优解。代价是多维护链表导致内存更高,但功能正确性优先。
TreeSet则完全不同,它的底层是红黑树,元素按自然顺序或自定义比较器排序,而不是按插入顺序。往TreeSet里放元素时,每次插入都会触发树结构的比较和调整,时间复杂度是O(log n)。使用TreeSet有两个硬性要求:
- 元素必须实现了Comparable接口,或者你给TreeSet传一个Comparator比较器。
- 比较的结果为0会被视为“重复”,即使两个对象的内容并不完全相等。
第二点尤其容易踩雷。比如你按年龄比较两个用户对象,年龄一样就会被去重,不管你姓名、手机号是否相同。这种时候要么细化比较器的比较维度,要么换个Set实现。TreeSet并不适合所有“去重”需求,它更擅长“排序去重”。
4.3 Set的遍历与无序性的正确理解
很多教程说“HashSet无序”,这其实指的是:元素在集合中的存储位置由哈希值决定,因此迭代顺序不确定,且可能随着元素增多、扩容发生时改变。它不是“随机”,而是“不保证顺序”。在实际体验中,小数据量的HashSet迭代顺序可能看起来像有序,那是哈希值恰巧分布比较紧凑,不要因此怀疑自己的认知。
遍历Set的方式主要有三种:增强for、Iterator、Stream。如果只是遍历读取,三者的区别不大;如果要删除,还是用Iterator或removeIf。还有个常见问题:Set怎么转List?很简单,new ArrayList<>(set)即可。反过来List转Set可以用new HashSet<>(list)实现去重,但注意转换后顺序不保留,如果既要顺序又要去重就用new LinkedHashSet<>(list)。
实操心得:我写代码时,如果遇到“先收集、再去重、再顺序处理”的需求,基本是LinkedHashSet一条链走到底。否则用HashSet先去掉重复,再把结果排序,中间还要处理比较器和null值,麻烦且容易遗漏边界情况。
5. 集合体系选型对照:用场景驱动你的设计决策
5.1 一张表看懂List与Set的核心差异
| 维度 | List | Set |
|---|---|---|
| 是否有序 | 按插入顺序(Index) | HashSet无序,LinkedHashSet按插入顺序,TreeSet按比较排序 |
| 是否允许重复 | 允许 | 不允许 |
| 是否支持下标索引 | 支持(get(index)) | 不支持(TreeSet可用first/last等边界操作) |
| 常见实现类 | ArrayList, LinkedList | HashSet, LinkedHashSet, TreeSet |
| 去重原理 | 无特殊处理 | hashCode + equals,TreeSet走比较器 |
| 适用场景 | 需要存取顺序、按下标取值 | 需要去重、集合运算、自动排序 |
这个表基本覆盖了90%的选型场景。剩下10%是在并发环境或超大容量场景里的取舍,比如CopyOnWriteArrayList、ConcurrentSkipListSet这些,它们属于并发专用实现,在这里不展开,但你至少知道有这么个方向。
5.2 业务场景选型模拟
- 场景一:日志平台按时间顺序记录一批错误码,需要按顺序展示,错误码可能重复。答案是ArrayList,简单直接,重复也没关系,展示端自己处理。
- 场景二:用户批量导入,需要去重手机号再入库。先用HashSet接收手机号去重,但如果你想在程序里直接看到“哪些号码重复了”,可以先用List接收,再用
list.stream().collect(Collectors.groupingBy(...))统计出现次数,效果更直观。 - 场景三:有一个“标签集合”,需要去重,并且前端展示时保持后端配置的标签顺序。LinkedHashSet,去掉重复还保住顺序。
- 场景四:排行榜,Top N并发写入,需要实时有序。可以考虑采用同步机制。如果是纯Java内存场景,TreeSet配合Comparator也能实现,但并发写时它本身不是线程安全的,需要额外加锁。
- 场景五:两个集合求交集、并集、差集。Set和List都能做,但Set的性能优势更明显,因为contains操作是基于哈希的O(1)。
setA.addAll(setB)是并集,setA.retainAll(setB)是交集,setA.removeAll(setB)是差集。用List做,contains要遍历,数据一多性能就会崩。
从中能看出一个核心结论:选型不是看哪个类更高级,而是看你的核心操作是什么。核心操作是“按下标”,List;核心操作是“去重判存”,Set;核心操作是“频繁中间插入”,LinkedList;核心操作是“头部尾部插入删除”,ArrayDeque或LinkedList;核心操作是“保证唯一且有序”,TreeSet或LinkedHashSet。
6. 源码级细节:从方法的实现看设计者的决策逻辑
6.1 ArrayList扩容机制为什么是1.5倍
ArrayList的扩容方法叫grow,核心逻辑就是:新容量 = 旧容量 + (旧容量 >> 1)。为什么用1.5倍而不是直接2倍?设计者显然在“空间浪费”和“迁移成本”之间做了取舍。扩容必定伴随着数组复制(Arrays.copyOf),如果扩容倍数太大,内存浪费多;如果太小,每次添加都容易触发扩容,复制成本高。1.5倍是一个经验上比较平衡的值。
我建议你自己动手断点看一次ensureCapacityInternal和grow的执行过程,理解旧数组、新数组、System.arraycopy之间的数据搬运。看过一次源码,你对“ArrayList尾插很快”这句话的感受就完全不同了——快是因为多半情况下根本不需要扩容,直接把新元素放进下标位置即可。
6.2 HashSet的hashCode与equals调用计数
往HashSet里添加一个元素,最坏情况会调用几次equals?答案是可能很多次,取决于桶内链表的长度。我举个例子:某个桶里已经有3个元素,新元素的hashCode定位到这个桶,那么它先和桶内第一个元素equals比较,不一致就继续第二个,直到匹配或链表结束。如果桶里元素很多(即哈希碰撞严重),equals会被调用多次。所以为什么说重写hashCode要尽量让分布均匀,因为分布均匀可以减少哈希冲突,进而减少equals调用次数。
这种细节在设计业务对象的时候直接有关:如果对象的hashCode设计得很烂(比如全部返回同一个固定值),HashSet等价于退化成链表,增删查全部变成O(n),数据量大时性能不可接受。
6.3 迭代器与fail-fast机制
所有基于Collection的迭代器都有一个“快速失败”机制:在迭代过程中,如果集合的结构发生修改(增删元素),会立即抛出ConcurrentModificationException。实现原理是迭代器维护了一个modCount(修改次数)字段,每次迭代时检查集合的modCount是否一致。
理解这个机制后,你就明白为什么“在增强for中调用list.remove()会报错”,本质是增强for语法糖底层用了Iterator,但remove操作没有同步更新迭代器的预期modCount。而使用iterator.remove()不会报错,因为Iterator的remove方法内部会同步更新modCount的预期值。
注意:fail-fast机制只保证“尽力检测并发修改”,不保证绝对的线程安全。多线程环境下做集合修改,要么加锁,要么使用
ConcurrentHashMap、CopyOnWriteArrayList这些并发容器,不要指望异常来兜底。
7. 高频踩坑实录与问题排查技巧
7.1 用==判断Integer是否相等导致去重失效
这个坑是在判断Set中是否已包含某个元素时出现的。如果Set的元素类型是Integer,两个取值相同的Integer对象之间用equals比较是安全的,但如果业务代码里误用了==,因为Integer在-128到127之间有缓存,这个范围内的值比较不会出问题,超出范围后两个对象就不是同一个引用了,结果相等判断直接失败。所以在自定义对象的equals里做字段比较时,字符串用equals,包装类型也一律用equals。
7.2 对集合做增删时并发修改异常
场景:一个线程遍历Set,另一个线程向Set添加数据。遍历线程抛ConcurrentModificationException。解决方法有好几个层级:
- 如果业务允许,使用并发容器,比如
ConcurrentHashMap.newKeySet()。 - 如果业务必须用HashSet,可以配合
synchronized(set)做手动同步。 - 如果只是“批量处理过程中顺手去重”,建议先把数据收集到另一个集合,处理完后再统一合并。
根据我的经验,大部分并发修改异常其实是“逻辑层面事先可以避免的”,重点检查你是不是在循环里写了remove、add之外还换了底层集合的引用。
7.3 Set中修改对象属性导致“消失的元素”
把对象放入HashSet后,又修改了该对象某个参与hashCode计算的字段,这就出问题了。因为对象的hashCode变了,但它在HashSet里的桶位置还是原来的,后续contains、remove时用新hashCode去定位桶,大概率找不到它。可以从外部遍历对比看元素还在不在(其实在,但用新哈希定位不到)。这个问题的正确姿势是:如果对象会参与Set去重,尽量设计成不可变对象,或者保证放入Set后不再修改用于hash计算和equals比较的字段。
7.4 “新创建的HashSet大小为0,但预期是初始容量16”
这个其实不算bug,而是懒加载。new HashSet()后,底层的HashMap并不会立刻初始化table数组(table为null),只有第一次put元素时才初始化容量为16的数组。如果你写代码时判断set.size() == 0来决定是否初始化资源,那没有问题;但如果你依赖set.isEmpty(),这个同样没问题,因为size就是0。可如果你用反射去看table长度,会发现它是null,淡定就好。
7.5 关于null值
- ArrayList和LinkedList允许添加任意多个null,因为底层数组和链表节点只是存引用。
- HashSet也允许一个null,因为HashMap允许null作为Key。
- TreeSet不允许放null,因为TreeSet在插入时会立刻调用比较器,null没法比较大小,直接抛
NullPointerException。 - LinkedHashSet允许null,和HashSet行为一致。
这个细节也是面试常考点。业务上往Set里塞null之前先想清楚你的实现类是谁,尤其是TreeSet,不做空值校验很容易在运行时才发现问题。
8. 从个人经验出发的几点总结性建议
写集合这部分内容时,我回顾了这几年踩过的坑,其中最想说的一条建议是:不要背“ArrayList vs LinkedList”的性能对比,而应该背“底层结构是什么”。因为所有的性能差异,全部取决于底层数据结构的特性,结构记住了,性能结论就是顺水推舟的事。
第二点建议是:尽量面向接口编程。业务方法的入参能写Collection就写Collection,能写List就写List,不要一上来就写ArrayList。这样不仅方便后续替换实现,也能让方法自己不涉及具体类独有的方法,使得逻辑更纯粹。写测试和Mock的时候也好处理。
第三点建议:把遍历、去重、交集并集这类操作多写成工具方法沉淀下来。Java集合类本身已经足够强大,但工程上总有一些组合操作,比如“按某个字段去重”“把List按固定大小切分”“提炼Set中某个属性的值”,这些完全可以自己封装成通用工具。方法越通用,你后续写业务代码就越快。
还有个小技巧:排查集合相关问题时,先把集合的“逻辑结构”画出来再动手。比如你怀疑某个元素没被加入Set,顺着“hashCode → 桶位置 → equals→ 链表/红黑树”这条链路想,大部分问题都能自己找到答案,比直接查文档更快。