我把日常开发里关于 List 和 Set 的思考、代码、踩过的坑一次性整理出来了。从接口设计逻辑到源码细节,再到能直接落地的去重、排序和性能优化场景,都写在下面了。如果你正在准备 Java 面试,或者写业务代码时对集合选择拿不准,这篇文章应该能帮你省不少时间。
1. 一上来先搞清楚:List 和 Set 的接口契约到底意味着什么
很多人用集合框架纯粹是“背 API”,什么ArrayList能存重复、HashSet会去重,背得滚瓜烂熟。但你要是问他:为什么 List 允许重复而 Set 不允许?List 的“有序”和 LinkedHashSet 的“有序”是不是一个意思?接口契约对底层实现的选择有什么约束?多半就卡壳了。
集合框架最值钱的地方不在某个具体类的 API,而在于接口层面的设计约束。List 和 Set 都继承自Collection,但两者的语义截然不同。
- 核心关键词:JAVA 集合框架中的
List和Set,前者是线性表语义,后者是数学意义上的集合语义。 - 解决的核心问题:当你在业务中需要“按顺序处理一批元素”或者“快速判断某个元素存不存在”时,选对接口比选对实现类更重要。
1.1 List:有序、可重复、支持随机访问
List 的契约有三条:元素有插入顺序(顺序由索引决定)、允许重复元素、可以通过索引访问。这三条是硬约束,任何 List 的实现类都必须满足。
这里要注意,List 的“有序”指的是“有明确的索引,遍历时按索引顺序输出”,它和排序完全是两码事。你往 List 里 add 的顺序就是它遍历时的顺序,整个过程不涉及元素大小的比较。
因为有了索引,List 天生适合“随机访问”场景。但随机访问的快慢取决于底层数据结构:数组实现的ArrayList是 O(1),链表实现的LinkedList是 O(n)。这正好引出一个实践问题——你写代码时如果只是要“按加入顺序遍历”,用 ArrayList 普遍更好,不要因为网上有人说“链表插入快”就无脑选 LinkedList。
1.2 Set:去重是灵魂,但“顺序”要分情况
Set 的契约核心是“不包含重复元素”,它模拟的是数学中集合的概念。换句话说,往 Set 里 add 一个已有元素,会直接返回 false,元素不会真的放进去。
很多人被 Set 的“无序”搞晕过。严格来说:
HashSet:无序,遍历顺序取决于 hash 散列的结果,可能和插入顺序完全不同。LinkedHashSet:保持插入顺序,底层用链表串联元素。TreeSet:按元素的自然顺序或自定义比较器排序,不是插入顺序。
所以你在面试或者写文档时,不要一刀切地说“Set 就是无序的”,要区分实现类。这也是 JAVA 集合框架进阶的典型考点:接口契约相同,不同实现的附加特性差异很大。
2. 核心实现类硬核对比:ArrayList、LinkedList、HashSet、LinkedHashSet、TreeSet
这一节直接上干货对比。我用过这么多集合类,最后真正留在生产代码里的其实就那几个:ArrayList 处理绝大多数列表场景,HashSet 做去重,LinkedHashSet 在需要保序去重时出场,TreeSet 在需要有序集合时偶尔用。LinkedList?除了一些特定队列场景,我在业务代码里很久没主动用过了。
2.1 ArrayList 与 LinkedList:数组和链表的相爱相杀
先看底层。ArrayList 底层是 Object 数组,LinkedList底层是双向链表(JDK 8 之后没有头尾节点区分,就是 first 和 last 两个指针加 Node)。
ArrayList 的优点:
- 随机访问 O(1),
get(i)直接按索引寻址。 - 尾部插入 O(1),只要不触发扩容。
- CPU 缓存友好,数组是连续内存空间。
ArrayList 的缺点:
- 指定位置插入/删除 O(n),因为要移动后续元素。
- 扩容有开销,但均摊下来是 O(1)。
LinkedList 的优点:
- 头部/尾部插入 O(1),不需要搬移元素。
- 理论上没有容量限制,不需要扩容。
LinkedList 的缺点:
- 随机访问 O(n),
get(i)要遍历链表。 - 每个元素都要存前后指针,内存占用明显更大,一个 Node 对象要额外扛两个引用。
这里有一个我自己实测的结论:在 JDK 8 及以后,LinkedList 在头部插入大量元素的场景可能会比 ArrayList 快,但差距没有想象中大,因为 ArrayList 虽然要搬移元素,但System.arraycopy是 JVM 级别的原生方法,搬移速度极快。而在随机访问场景,LinkedList 会被 ArrayList 碾压。所以我的选择标准很简单:95% 的场景用 ArrayList,需要频繁头插且对内存不敏感时再考虑 LinkedList。
2.2 HashSet 家族与 TreeSet:从散列到红黑树
HashSet 底层就是 HashMap,只是把所有 value 统一成一个固定的PRESENT对象。所以 HashSet 的特性完全由 HashMap 决定:无序、允许 null、基于 hash 散列。LinkedHashSet 在 HashSet 基础上额外维护了一个双向链表来记录插入顺序,代价是每次插入多了一点指针操作,内存也稍微高一些。
TreeSet 底层是 TreeMap,也就是红黑树。它的特点:
- 元素按比较器排序,
Comparable或Comparator二者必须提供一个。 - 核心操作(add、remove、contains)时间复杂度 O(log n)。
- 不允许 null,因为 null 没法参与比较。
这里要特别提醒:TreeSet 的“排序”是元素间的大小关系,不是插入顺序。如果你往 TreeSet 里依次添加 5、3、8、1,遍历出来是 1、3、5、8。如果你只是想去重并保持插入顺序,用 LinkedHashSet。这两个用混了,代码跑起来结果会完全不符预期,而且不太好排查。
2.3 复杂度与适用场景速查表
我每次写技术方案都会画一张复杂度表,选型时直接对着看,省脑子。这里给你一张精简版:
| 实现类 | 底层结构 | add | remove | get/contains | 顺序特性 | 适用场景 |
|---|---|---|---|---|---|---|
| ArrayList | 动态数组 | 均摊 O(1),指定位置 O(n) | 指定位置 O(n) | get O(1) | 插入顺序 | 绝大多数列表场景 |
| LinkedList | 双向链表 | 头尾 O(1),指定位置 O(n) | 头尾 O(1) | get O(n) | 插入顺序 | 头尾操作频繁且量大的场景 |
| HashSet | HashMap | O(1) | O(1) | O(1) | 无序 | 去重、存在性判断 |
| LinkedHashSet | HashMap + 双向链表 | O(1) | O(1) | O(1) | 插入顺序 | 保序去重 |
| TreeSet | TreeMap(红黑树) | O(log n) | O(log n) | O(log n) | 按比较器排序 | 需要有序集合、范围查找 |
注意:这里的复杂度是理论平均情况,实际性能还要考虑扩容、hash 冲突、缓存命中率等因素。极端情况下 HashMap/HashSet 会退化到 O(n),不过正常业务场景几乎碰不到。
3. 底层源码细节:理解这些才算真正进阶
说实话,光会 CRUD 用集合的人太多了,但很多人写了好几年代码,背不出 ArrayList 扩容到底扩多少倍,也说不清 HashSet 为什么判断重复要先比 hash 再比 equals。这些细节面试时是分水岭,平时排查线上问题也真的用得上。
3.1 ArrayList 扩容机制
ArrayList 默认容量是 10,当然构造时指定初始容量更好。当元素个数超过当前容量时触发扩容,新容量是oldCapacity + (oldCapacity >> 1),也就是 1.5 倍。
看一眼核心代码逻辑:
// 来自 JDK8 ArrayList.grow() private void grow(int minCapacity) { int oldCapacity = elementData.length; int newCapacity = oldCapacity + (oldCapacity >> 1); if (newCapacity - minCapacity < 0) newCapacity = minCapacity; if (newCapacity - MAX_ARRAY_SIZE > 0) newCapacity = hugeCapacity(minCapacity); elementData = Arrays.copyOf(elementData, newCapacity); }这里有两个点值得展开。
第一,为什么是 1.5 倍而不是 2 倍?1.5 倍兼顾了空间浪费和扩容次数。扩容是Arrays.copyOf,本质是新建数组并复制全部元素,成本 O(n)。如果扩得太小(比如 1.1 倍),复制次数太多;如果扩得太大(比如 3 倍),浪费内存。1.5 倍是工程上的折中。另外,这个倍数可以确保你在连续 add 的场景下,均摊复杂度依然是 O(1)。
第二,grow方法的名字叫 grow,这个细节曾经有一次是线上 OOM 的事故原因。批量 add 大量数据时,如果容量不够,会先 set 一个大数组再复制。如果预估不准,你不知不觉就会吃掉双倍内存。建议处理大批量数据时直接指定初始容量,比如new ArrayList<>(expectedSize),这一步能省掉好几次扩容复制,性能立竿见影。
3.2 HashSet 为何“无序”?从 HashMap 的 hash 散列说起
HashSet 存元素的时候,先算 hashCode,再通过(n - 1) & hash定位到数组槽位。JDK 8 之后,如果 hash 冲突了,先用链表存,冲突超过 8 个且数组长度超过 64 时转红黑树。
为什么要高位扰动?因为哈希值的高位不参与槽位计算,如果两个 hashCode 的高位不同、低位相同,直接&就会大量冲突。所以HashMap里有个hash()方法:
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }这行代码把高 16 位异或到低 16 位,让高位信息也能影响槽位分布。你平时很少直接注意到它,但它对散列均匀性影响非常大。
在面试中,你如果能接着讲:当 hash 冲突严重时,红黑树的引入把查询从 O(n) 降到了 O(log n),但树节点占用内存比普通节点大一倍,所以阈值设了 8,这个细节能立刻拉开差距。这些都属于 JAVA 集合框架进阶里高频考察的源码细节。
3.3 hashCode 与 equals 的约定
这是老生常谈,但错误率一直很高。放在 HashSet 里时,equals 相等则 hashCode 必须相等,否则 Set 会把它俩当成两个不同的元素,去重直接失效。
比如你有一个User对象,只重写了 equals 按 id 比较,没重写 hashCode,那放进 HashSet 时,两个 id 相同但 hashCode 不同的对象会被散列到不同槽位,重复数据就混进去了。这不只是面试题,业务里真的很容易出现:从两张表查出来的用户对象塞进一个 HashSet 去重,结果没去掉,因为实体类没实现 hashCode。
正确写法示例:
@Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; User user = (User) o; return id == user.id; } @Override public int hashCode() { return Objects.hash(id); }注意:JDK 7 引入的
Objects.hash()很方便,但它内部会创建数组,性能敏感场景建议自己手写乘法散列。另一个容易被踩的坑:放进 HashSet 之后不要再修改对象的 hashCode 字段,否则对象还在 Set 里却找不到了,清也清不掉。
3.4 Comparable vs Comparator
Set 里要用 TreeSet 排序,List 里要调用Collections.sort()或List.sort(),这些排序操作都要求元素具备“可比较”的能力。这时你有两个选择:让类实现Comparable,或者在调用排序时传入一个Comparator。
我个人的经验是:Comparable适合定义“这个类的天然顺序”,比如Integer的自然升序;Comparator适合定义“某个具体场景下的临时顺序”,比如按用户年龄降序、按创建时间排序。业务里应当多用 Comparator, 我一般用Comparator.comparing(User::getAge)这种方式,因为不用修改实体类,代码也简洁得多。
// 按年龄升序 List<User> users = ...; users.sort(Comparator.comparing(User::getAge)); // 按年龄降序 users.sort(Comparator.comparing(User::getAge).reversed());一个容易踩的坑:Comparator.comparing如果提取的 key 是 null,会直接 NPE。常见解法是传入Comparator.nullsLast(...)或者nullsFirst(...)。
users.sort(Comparator.comparing(User::getName, Comparator.nullsLast(String::compareTo)));这类细节,业务上线时经常会因为用户某个字段为空而引发线上事故,提前处理能省很多麻烦。
4. 实战:这些才是日常开发真正用得上的场景
好,理论聊完了,下面完全是实操。我摘了三个最常遇到的场景,每个都给了可以直接抄的代码,也标注了背后的考量。
4.1 去重实战
场景:从数据库查出来一批订单,订单里可能重复,要求按订单号去重。
最简单的方式:
List<Order> orders = ...; Set<String> seen = new HashSet<>(); List<Order> distinctOrders = new ArrayList<>(); for (Order order : orders) { if (seen.add(order.getOrderNo())) { distinctOrders.add(order); } }这里用seen.add()的返回值判断是否已经存在,一行代码完成了“是否见过”的判断。如果你希望在遍历时保持原始顺序,add 到 ArrayList 里按原顺序保留即可。如果把 orders 直接放进一个 HashSet 再转回 List,顺序就全乱了。
还有一个更简洁的 Java 8 Stream 写法:
List<Order> distinctOrders = orders.stream() .filter(distinctByKey(Order::getOrderNo)) .collect(Collectors.toList()); // 需要使用一个支持状态的方法 public static <T> Predicate<T> distinctByKey(Function<? super T, ?> keyExtractor) { Set<Object> seen = new HashSet<>(); return t -> seen.add(keyExtractor.apply(t)); }这种写法用了HashSet来维护已经见过的 key。注意filter是无状态操作,但这里我们是故意在外部持有一个 Set,通过add的返回值来做区分。如果我的业务量小,我更推荐写显式的 for 循环版本,可读性更高。如果业务里反复用到,我会把distinctByKey收到一个公共工具类里。
4.2 排序实战
场景:对一批订单按金额从高到低排序,金额相同按下单时间从新到旧排序。
orders.sort(Comparator.comparing(Order::getAmount) .reversed() .thenComparing(Order::getCreateTime, Comparator.reverseOrder()));几个细节:
- 先按金额降序,
reversed()会把整个比较器反转,如果你只想反转一个字段,注意放在正确位置。 thenComparing处理第二排序字段。- 如果 createTime 可能为 null,用
Comparator.nullsFirst/Last包装一下。
这里有一个我实际踩过的坑:用Comparator.comparing(Order::getAmount).reversed()的时候,reversed()是作用在“整个已经构建的比较器”上,而不是只反转金额。也就是说,如果你把.reversed()放在整个链的最外面,那么后续的thenComparing也会被反转,排序结果会和你预期的完全相反。正确写法要么像上面这样在字段级反转,要么分开写:
Comparator<Order> byAmountDesc = Comparator.comparing(Order::getAmount).reversed(); Comparator<Order> byCreateTimeDesc = Comparator.comparing(Order::getCreateTime).reversed(); orders.sort(byAmountDesc.thenComparing(byCreateTimeDesc));4.3 大数据量下的性能优化
场景:一次性往集合里塞 10 万条数据。
常规写法:
List<Order> orders = new ArrayList<>(); // 默认容量 10 for (...) { orders.add(order); // 反复扩容,复制数组 }优化写法:
List<Order> orders = new ArrayList<>(expectedSize); // 直接指定足够容量expectedSize怎么算?简单粗暴:如果能预估到精确数量,直接填。如果只能力估到范围,用(int)(expectedSize / 0.75f) + 1,也就是提前预留出负载因子造成的空隙。这个方法同样适用于 HashMap:
Map<String, Order> map = new HashMap<>((int)(expectedSize / 0.75f) + 1);HashMap 默认负载因子 0.75,如果你预计放 10000 个元素,直接new HashMap<>(10000),它会认为容量 10000 已经够用,但实际上当元素数达到 7500 时就会扩容。用/0.75f + 1的公式是为了让初始容量真正覆盖到目标元素量。
Set 也一样,如果预计 1 万个元素去重,最好new HashSet<>((int)(10000 / 0.75f) + 1),可以省去多次扩容和 rehash 的开销。这些在你处理导出、批量同步、缓存预热这些场景时,性能差距非常明显。
5. 高频问题和踩坑实录
这一节我整理几个几乎每个 Java 开发者都遇到过的问题,带有具体的报错信息和解决方案。
5.1 Arrays.asList 与 subList 陷阱
Arrays.asList("a", "b")返回的不是 java.util.ArrayList,而是 Arrays 内部类。它继承 AbstractList,但add和remove没有重写,会直接抛UnsupportedOperationException。不少被这个坑过的人,写完list.add("c")就直接上生产,结果接口 500 才发现。
ArrayList.subList更阴,它返回的是原列表的视图,不是副本。你往 subList 里加元素,原列表也会变。很多人以为 subList 是截取复制,结果改出 bug 来。如果确实要独立副本,用new ArrayList<>(list.subList(from, to))。
5.2 遍历删除导致的 ConcurrentModificationException
在 foreach 循环里直接调用list.remove(),会触发快速失败机制,抛ConcurrentModificationException。原因是迭代器内部维护了一个expectedModCount,和集合的modCount不一致时直接报错。
推荐方案有三种:
// 方案一:Iterator 的 remove Iterator<String> it = list.iterator(); while (it.hasNext()) { String item = it.next(); if (需要删除) { it.remove(); } } // 方案二:removeIf(JDK 8+ 推荐) list.removeIf(item -> 需要删除); // 方案三:收集要删除的元素,循环结束后统一 removeAll List<String> toRemove = new ArrayList<>(); for (String item : list) { if (需要删除) toRemove.add(item); } list.removeAll(toRemove);方案二最简洁,底层用的也是 Iterator。方案三适合删除逻辑特别复杂或需要提前汇总的场合。这里补充一句:removeAll的时间复杂度取决于入参集合的类型,如果入参是 HashSet,整体能达到 O(n),比传入 List 要快得多。
5.3 线程安全方案选择
ArrayList、HashSet 这些都不是线程安全的。并发场景加锁粗鲁而且容易性能差,一般来说有几种做法:
Collections.synchronizedList(new ArrayList<>()):简单粗暴,整个方法加锁,读写都串行,适合并发量不高的场景。CopyOnWriteArrayList:读多写少的场景非常合适,读不加锁,写时复制整份数组。写操作代价高,但读性能极好。ConcurrentHashMap.newKeySet():得到的是一个线程安全的 Set,底层基于 ConcurrentHashMap,JDK 8 之后并发能力很好。ConcurrentSkipListSet:线程安全的有序 Set,底层是跳表。
我自己最常见的套路是:缓存场景用ConcurrentHashMap.newKeySet()做并发去重记录,热点数据列表用CopyOnWriteArrayList做读多写少的配置存储。如果是全局共享的 List 且读写都频繁,那说明设计大概率有问题,要考虑队列或者数据库,而不是死磕集合类。
5.4 关于 null 的那些事
ArrayList 和 LinkedList 允许 null。HashSet 也允许 null,因为 HashMap 允许 null key。LinkedList 从 JDK 8 开始也允许 null。TreeSet 不允许 null,因为 add 时直接调用比较器,null 没有可比性,会抛 NullPointerException。
这个细节在对接外部接口时特别有用。如果你把接口返回的列表直接转成 TreeSet 做去重排序,而列表里混着 null,就会炸。先filter(Objects::nonNull)再收集。
最后多说一句
我在实际项目里发现,很多 Java 开发者容易陷入“背实现类特性”的误区,把 ArrayList 和 LinkedList 的区别背得滚瓜烂熟,但到了设计阶段却选不出正确的集合。其实集合框架的精华全在接口设计里:List 提供了线性访问,Set 提供了去重和快速存在性判断,Map 提供了键值映射,三者各司其职。理解了接口契约,再看实现类的底层结构,一切就都串起来了。
这一年下来我自己的体会是:写业务代码时,花半小时看清楚你用的集合的复杂度、容量行为和线程安全模型,比花半小时百度“某某集合怎么用”划算得多。集合是 Java 里被用得最多的数据结构,也是最值得你在源码层面深挖的领域。希望这篇东西能帮你少走一些弯路。