尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

List与Set全面剖析:接口契约、源码细节与性能优化实战

List与Set全面剖析:接口契约、源码细节与性能优化实战 我把日常开发里关于 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 ComparatorSet 里要用 TreeSet 排序List 里要调用Collections.sort()或List.sort()这些排序操作都要求元素具备“可比较”的能力。这时你有两个选择让类实现Comparable或者在调用排序时传入一个Comparator。我个人的经验是Comparable适合定义“这个类的天然顺序”比如Integer的自然升序Comparator适合定义“某个具体场景下的临时顺序”比如按用户年龄降序、按创建时间排序。业务里应当多用 Comparator 我一般用Comparator.comparing(User::getAge)这种方式因为不用修改实体类代码也简洁得多。// 按年龄升序 ListUser 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 去重实战场景从数据库查出来一批订单订单里可能重复要求按订单号去重。最简单的方式ListOrder orders ...; SetString seen new HashSet(); ListOrder 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 写法ListOrder distinctOrders orders.stream() .filter(distinctByKey(Order::getOrderNo)) .collect(Collectors.toList()); // 需要使用一个支持状态的方法 public static T PredicateT distinctByKey(Function? super T, ? keyExtractor) { SetObject 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也会被反转排序结果会和你预期的完全相反。正确写法要么像上面这样在字段级反转要么分开写ComparatorOrder byAmountDesc Comparator.comparing(Order::getAmount).reversed(); ComparatorOrder byCreateTimeDesc Comparator.comparing(Order::getCreateTime).reversed(); orders.sort(byAmountDesc.thenComparing(byCreateTimeDesc));4.3 大数据量下的性能优化场景一次性往集合里塞 10 万条数据。常规写法ListOrder orders new ArrayList(); // 默认容量 10 for (...) { orders.add(order); // 反复扩容复制数组 }优化写法ListOrder orders new ArrayList(expectedSize); // 直接指定足够容量expectedSize怎么算简单粗暴如果能预估到精确数量直接填。如果只能力估到范围用(int)(expectedSize / 0.75f) 1也就是提前预留出负载因子造成的空隙。这个方法同样适用于 HashMapMapString, 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 IteratorString it list.iterator(); while (it.hasNext()) { String item it.next(); if (需要删除) { it.remove(); } } // 方案二removeIfJDK 8 推荐 list.removeIf(item - 需要删除); // 方案三收集要删除的元素循环结束后统一 removeAll ListString 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底层基于 ConcurrentHashMapJDK 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 里被用得最多的数据结构也是最值得你在源码层面深挖的领域。希望这篇东西能帮你少走一些弯路。
返回列表