☰
List与Set全面剖析:接口契约、源码细节与性能优化实战
2026/10/7 6:36:45 网站建设 项目流程

我把日常开发里关于 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 复杂度与适用场景速查表

我每次写技术方案都会画一张复杂度表,选型时直接对着看,省脑子。这里给你一张精简版:

实现类底层结构addremoveget/contains顺序特性适用场景
ArrayList动态数组均摊 O(1),指定位置 O(n)指定位置 O(n)get O(1)插入顺序绝大多数列表场景
LinkedList双向链表头尾 O(1),指定位置 O(n)头尾 O(1)get O(n)插入顺序头尾操作频繁且量大的场景
HashSetHashMapO(1)O(1)O(1)无序去重、存在性判断
LinkedHashSetHashMap + 双向链表O(1)O(1)O(1)插入顺序保序去重
TreeSetTreeMap(红黑树)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 里被用得最多的数据结构,也是最值得你在源码层面深挖的领域。希望这篇东西能帮你少走一些弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询