资讯动态

Kotlin Set集合详解:从去重到hashCode与性能陷阱

发布时间:2026/10/17 10:29:51 来源:尧图企业网站定制
如果你写过一段时间的 Kotlin一定遇到过这种需求把一份用户列表里的重复名字去掉。我见过不少新人第一反应是打开 MutableList然后contains判断一次加一次写起来像这样val result mutableListOfString() for (name in originList) { if (!result.contains(name)) { result.add(name) } }能跑但丑而且contains在 List 上是 O(n)两层循环一叠加数据量上千之后明显卡顿。换成 Kotlin 的Set一行toSet()搞定语义还清晰。这也是我写这篇“Set 集合详解”的初衷很多基础东西你天天在用但未必真正理解它背后的契约、实现和坑。这篇文章不是把文档抄一遍而是从实际使用场景出发把 Kotlin 里 Set 的底层实现、常用操作、性能表现和踩过的坑讲清楚适合刚学 Kotlin 的人也适合已经写了一阵子但总在 Set 上出小问题的开发者。1. 为什么非要用Set从List去重的尴尬说起1.1 一个最常见的错误去重写法先回到开头那个场景。用可变 List 去重除了性能差还有一个隐含的语义问题你在用 List 的contains判断“是否存在”而 List 本身允许重复元素它天然就不适合表达“唯一”这个约束。去重这种逻辑本质上是集合论里的“集合”不是“序列”。更规范的写法是用distinct()val result originList.distinct()distinct()内部也是借助 Set 来实现的但它会保留原 List 的第一次出现顺序返回一个新的 List。如果你不在乎顺序直接用toSet()更干脆。这套操作能跑通是因为 Kotlin 的Set接口从设计上就保证了不包含重复元素每个元素最多出现一次。1.2 Set的契约唯一性建立在equals之上很多人误以为 Set 去重是靠“哈希值”或“内存地址”其实真正的唯一性判断核心是equals方法。当你往 HashSet 里添加元素时首先会计算元素的hashCode()定位到某个“桶”再在该桶内用equals确认是否真的相同。所以一个很重要的推论两个对象只要equals返回 trueSet 就认为它们是重复的。String 的equals比较的是字符内容所以两个不同引用的 hello 字符串在 Set 里只有一个。而普通自定义类的默认equals继承自Any比较的是引用地址两个字段完全相同的对象在 Set 里会被当成两个不同元素。这个问题后面有一章专门讲。1.3 Kotlin集合框架里Set的位置Kotlin 的集合体系分为不可变接口和可变接口两部分。Set是只读接口只能看不能改MutableSet继承Set增加了add、remove、clear等修改方法。这套设计和 Java 的 Collection 框架不同Java 里Set本身就有 add/remove而 Kotlin 把“只读”表达在了类型系统里。理解了这个位置你就明白了为什么setOf(a, b)返回的集合不能直接调add——编译期就报错这是 Kotlin 优于 Java 的地方用接口约束行为而不是靠运行时异常。2. 先看清Kotlin的Set家族setOf与mutableSetOf背后是谁2.1 setOf返回的到底是哪个实现类这是 Kotlin 新手最容易忽略的细节。你以为setOf(a, b)是个通用的 Set实际上在 JVM 平台上Kotlin 编译器会根据元素数量做优化空 Set 返回的是EmptySet单例对象。单个元素返回的是SingletonSet。多个元素返回的是LinkedHashSet。也就是说你平时写setOf(apple, banana)拿到的就是LinkedHashSet。虽然没有明确规定但这些是 Kotlin 标准库在 JVM 上的实际行为。LinkedHashSet的特点是底层用哈希表存储同时用链表维护插入顺序。所以绝大多数场景下你遍历setOf的结果会发现顺序和插入顺序一致——这不是所有 Set 都保证的只是这个具体实现给你的“额外福利”。2.2 想要控制顺序和底层应该怎么显式创建Set如果你明确需要某种特性不要依赖setOf的隐藏行为直接用标准库提供的构造函数函数返回的底层实现特点hashSetOf(...)HashSet哈希存储读写平均 O(1)顺序不保证linkedSetOf(...)LinkedHashSet哈希存储 链表维护插入顺序sortedSetOf(...)TreeSet红黑树元素按自然顺序或自定义比较器排序mutableSetOf(...)LinkedHashSet与 setOf 相同实现但支持修改操作注意可能返回 Java 的 LinkedHashSet举个实际例子你的业务需要按注册时间展示用户标签同时要求去重就应该用linkedSetOf而不是hashSetOf。反之如果你无所谓顺序只想最快地判断“某个 ID 是否存在”hashSetOf或者mutableSetOf就够用没必要为了保序多付出链路维护的开销。val sortedSet sortedSetOf(3, 1, 2) println(sortedSet) // [1, 2, 3]sortedSetOf要求元素必须实现Comparable或者你提供Comparator。如果你塞入一个没有实现Comparable的自定义类会直接抛异常。这是个容易踩的坑。2.3 不可变和可变只读接口与真正冻结的差别Kotlin 的setOf返回的接口是只读的但底层对象不一定是不可变的。你完全可以把一个MutableSet向上转型成Set暴露出去外部无法通过类型调用修改方法但持有原可变引用的地方依然能改这就会导致“你以为没有变化其实变了”的尴尬。val mutable mutableSetOf(a) val readOnly: SetString mutable println(readOnly) // [a] mutable.add(b) println(readOnly) // [a, b] 这就是只读视图的“脆弱性”所以如果你的接口需要真正不允许修改的集合请返回setOf()构建的元素或者调用toSet()复制一份快照。只读不是不可变这是 Kotlin 集合设计里最容易被误解的一点。3. Set常用操作拆解增删改查、集合运算与遍历陷阱3.1 可变Set的增删改查返回值别忽略MutableSet的add和remove都返回Boolean表示本次操作是否改变了集合。这不是多余的设计它很有用做去重统计时你可以用返回值判断元素是不是第一次出现。val set mutableSetOfString() val addedFirst set.add(kotlin) // true val addedSecond set.add(kotlin) // false因为已经存在remove同理删除不存在的元素返回 false这比先contains再remove高效一次操作完成判断和修改。查询方面contains是 Set 最核心的优势之一。一个十万级元素的有序 List用contains查找某个元素最坏要扫描十万次HashSet 的平均时间复杂度是 O(1)。这也是为什么我在后面重构场景里会强调频繁查“在不在”要用 Set 而不是 List。另外要注意MutableSet没有“修改单个元素”的 API因为 Set 元素一旦加入如果它的状态影响了hashCode或equals再修改它就可能导致集合内部数据错乱。实践上你应该先删除旧元素再加入修改后的新元素或者直接构建一个新的 Set。val set mutableSetOf(User(1, Tom)) set.remove(User(1, Tom)) set.add(User(1, Tommy))这么做虽然啰嗦但能避免“元素在集合里却 contains 不到”的灵异事件。3.2 plus/minus与集合运算用immutable风格写代码Kotlin 的Set提供了一套基于原集合返回新集合的操作符方便你用不可变风格处理数据。val base setOf(a, b) val newSet base c // [a, b, c] val another base - a // [b]和-分别对应plus和minus都返回新的 Set不修改原集合。此外还有三个集合运算扩展intersect(other)交集两个集合共有的元素。union(other)并集合并去重。subtract(other)差集去掉 other 里出现的元素等价于a - b。val a setOf(1, 2, 3) val b setOf(2, 3, 4) println(a intersect b) // [2, 3] println(a union b) // [1, 2, 3, 4] println(a subtract b) // [1]这些操作在权限校验、标签过滤、黑白名单对照等场景里非常好用。我做过一个权限系统用户能访问的菜单就是“角色权限集合”与“公共权限集合”的并集再用“禁用菜单集合”做差集。写出来就是一行链式调用可读性非常高。3.3 遍历过程中删除元素为什么崩溃如果你在遍历Set过程中直接调用remove大概率会抛出ConcurrentModificationException。这跟 List 的遍历删除问题一样根源是迭代器记录了modCount修改次数结构性修改会导致迭代器检测到并发修改。比较安全的做法有两种。第一种是使用迭代器的remove()方法val iterator mutableSetOf(a, b, c).iterator() while (iterator.hasNext()) { if (iterator.next() b) { iterator.remove() } }第二种是收集后统一删除val set mutableSetOf(a, b, c) val toRemove set.filter { it b } set.removeAll(toRemove)同样filter返回的是 List你需要removeAll而不是remove。别小看这个细节生产环境里因为遍历时删元素崩掉的例子我见过不止一次。4. 自定义对象放进Setequals和hashCode的规矩4.1 默认的引用比较为什么会“去重失败”假设你定义了一个最简单的用户类class User(val id: Int, val name: String) fun main() { val u1 User(1, Tom) val u2 User(1, Tom) println(u1 u2) // false默认按引用比较 val set setOf(u1, u2) println(set.size) // 2 }此时 Set 认为 u1 和 u2 是两个不同元素因为父类Any的equals是引用比较hashCode默认也和内存地址有关。放到业务里去重你会发现同一个用户出现两次怎么都去不掉。这就是“去重失败”最常见的原因你没有告诉 Set 到底什么算“同一个”。4.2 hashCode与equals必须同步遵守的三条规则要让自定义类在 Set 里正常工作你需要遵循三条规则equals返回 true 的两个对象hashCode必须相同。hashCode相同不代表equals一定为 true两个不同对象可以碰撞同一哈希码。参与hashCode计算的字段在对象加入 Set 后不能改变。第一条最关键。HashSet 查找时先用hashCode定位桶再在桶内用equals判断。如果两个对象 equals true 但 hashCode 不同它们会被放到不同桶Set 永远发现不了它们是同一个。反过来hashCode 相同但 equals 不同只是让它们落在同一桶里多了几次 equals 比较而已不影响正确性只影响性能。正确的重写方式class User(val id: Int, val name: String) { override fun equals(other: Any?): Boolean { if (this other) return true if (other !is User) return false return id other.id name other.name } override fun hashCode(): Int id * 31 name.hashCode() }注意hashCode里计算的字段必须和equals里比较的字段保持一致。如果你equals只比较 id但hashCode用 id 和 name那么两个 id 相同 name 不同的对象 equals 为 true但 hashCode 不同会被分配到不同桶Set 的去重逻辑就失效了。这个错误非常隐蔽因为单元测试里数据少可能碰巧不触发数据量大时随机出现。4.3 data class能解决大部分问题但别无脑依赖Kotlin 的data class会自动生成基于主构造器所有属性的equals、hashCode、toString等方法。所以data class User(val id: Int, val name: String)上面去重失败的问题自动消失。但要注意两点。第一如果你的业务规则只需要按 id 判断同一用户而姓名允许变化那 data class 自动生成的 equals 会误判。它认为 id 和 name 都相同才算同一个人如果一条记录是 id1,nameTom另一条是 id1,nameTommy它们会作为两个元素共存。此时你应该手动实现 equals/hashCode只比较 id。第二data class 的copy方法可以创建新对象这在更新 Set 元素时很方便但不要修改正在工作中的状态。你从 Set 里取到一个var的 data class 实例如果它的属性是var直接修改属性会破坏哈希表的不变式。Kotlin 并不强制你使用不可变属性但请务必用val。data class Item(var key: Int, var value: String) val set mutableSetOf(Item(1, a)) set.first().key 2 // 危险操作set内部哈希桶已经按 key1 放好了 println(set.contains(Item(2, a))) // 可能是 false这种情况我称之为“集合里的幽灵元素”它存在但你查不到。解决方法是不要在加入 Set 后修改参与 equals/hashCode 的字段。5. Set变慢的那些场景底层结构与性能真相5.1 哈希表查找的时间和空间成本很多人把哈希表当成“免费午餐”实际上它牺牲了空间换时间。HashSet 内部维护一个数组桶数组每个桶是一个链表或红黑树Java 8 起链表长度超过阈值会树化。理想情况下每个桶只有一两个元素contains只需计算哈希、定位桶、equals 一次所以是 O(1)。但这是平均值不是绝对保证。影响性能的因素主要有三个初始容量太小频繁扩容扩容需要重新哈希所有元素开销大。负载因子默认 0.75超过容量 × 负载因子后扩容。哈希碰撞严重许多元素落到同一桶查找变成链表遍历退化为 O(n)树化后 O(log n)。Kotlin 里mutableSetOf并没有让你指定初始容量它是标准库封装。如果你预先知道要放入十万个元素建议直接使用 Java 的HashSet构造器val bigSet java.util.HashSetString(200_000) // 初始容量设200k虽然 Kotlin 官方不推荐跨 Java 集合但这种性能敏感场景下合理使用没什么问题。5.2 LinkedHashSet为什么比HashSet慢一点LinkedHashSet在HashSet基础上维护了一个双向链表用于记录插入顺序。每次插入、删除元素时除了操作哈希表还要更新链表节点。这个额外开销很小通常可以忽略但在极高频的增删场景下它确实比HashSet慢而且占用更多内存。反过来如果你的业务特别依赖稳定的迭代顺序用LinkedHashSet是明智的。比如你有一个“最近访问的权限 ID”集合顺序代表访问顺序同时又需要去重那么LinkedHashSet能同时满足两个需求。为了顺序牺牲一点性能是划算的。5.3 当hashCode分布出了问题Set怎么退化最典型的反面例子是自定义类重写了hashCode但每次都返回同一个值override fun hashCode(): Int 42所有元素都落进同一个桶HashSet 退化成链表插入、查找从 O(1) 变成 O(n)。更气人的是代码逻辑完全正确但性能就是上不去因为桶里元素多了还得逐个 equals。另一个常见问题是对象的hashCode依赖了可变字段。当一个对象的字段变了它的哈希码变了但它在桶数组里的位置没变。之后你用新哈希去查找不到它即使找到了桶里 equals 可能也匹配不上。这种情况我在第 4.3 节提到过属于正确性 bug不只是性能问题。5.4 元素不多时Set未必比List快最后说一个反直觉的结论小数据量下Set 不一定比 List 快。因为 Set 计算 hashCode 也有开销而 List 的 contains 在小规模线性扫描时非常快。比如你只有三五个元素用list.contains可能比set.contains更快因为哈希计算 桶定位的开销可能超过两次简单的相等比较。所以做选型时不要一听到“Set 查询 O(1)”就盲目替换。经验值为几十个元素以内List 和 Set 差异可以忽略上百个元素且查询频率高Set 优势明显上万元素Set 是必需品。我在下面的重构案例里会展示一个因为元素量小但误用 Set 导致性能反而下降的例子。6. 从List改成Set的重构实录三个真实场景6.1 用户标签去重顺手解决了NPE隐患之前有个模块要给用户打标签标签存储在 List 里服务端可能返回重复项还可能因为上游数据问题返回 null。原来的代码是val tags mutableListOfString() for (tag in serverResult) { if (tag ! null !tags.contains(tag)) { tags.add(tag) } }重构后val tags serverResult.filterNotNull().toSet()toSet()自动去重filterNotNull把 null 过滤掉。类型从ListString?变成SetString语义更安全。这个改动还暴露了一个隐藏问题原来tags是MutableList因为包含了 null后续逻辑里tags[0]可能为 null导致 NPE。改成 Set 后在编译期就把可空性约束了很多地方反而不能乱写了。6.2 高频contains查询从逐条in扫描换成Set命中一个权限判断接口需要检查“用户拥有的资源 ID 列表”里是否包含某个资源 ID。原代码用的是 Listval userResourceIds: ListLong loadFromDb() fun hasPermission(resourceId: Long): Boolean userResourceIds.contains(resourceId)这个接口每秒被调用几十次List 中有几百个元素每次 contains 是 O(n)叠加 QPS 之后 CPU 明显飙升。改造很简单val userResourceIdSet: SetLong loadFromDb().toSet() fun hasPermission(resourceId: Long): Boolean userResourceIdSet.contains(resourceId)性能提升是质的飞越contains 从线性扫描变成哈希查找。这个案例里要注意的是改完之后如果用户资源列表中途会变必须同时更新 Set不能只改一个。我当时就踩过坑只把加载函数改了但用户新增资源后手动往 List 里 add结果 Set 里永远没有新资源权限判断一直失败。后面统一改成一个可变 Set 维护或者每次更新后重新toSet()。6.3 接口返回类型没有改对导致调用方收到只读集合重构时我把一个内部函数的返回类型从MutableSet改成了Set本意是防止外部修改。但这个函数有很多调用方有一部分调用方其实需要继续往里面加元素。因为 Kotlin 的Set没有add方法那些调用方在编译期就报错了。有一处调用方图省事直接把toMutableSet()强行转换虽然编译过了但每次转换都复制一份白白增加开销。这个教训是返回类型要提前和调用方对齐除非你确定外部不需要修改否则用MutableSet返回再在内部保证不滥用。更稳妥的做法是把返回的 Set 用toSet()复制成不可变集合但前提是调用方确实不会修改。后来我把接口设计成两个函数fun getReadOnlyTags(): SetString internalTags.toSet() fun getMutableTags(): MutableSetString internalTags让调用方按需取用性能和安全都保住了。Kotlin 的只读接口确实好用但要注意它只是“视图”不是“快照”设计 API 时别把两者混为一谈。6.4 小集合场景下的反向优化Set反而拖慢了性能还有一次我做了一个黑白名单过滤黑名单只有不到十个元素我习惯性用了 Setval blacklist setOf(a, b, c, ...) fun isBlocked(name: String) name in blacklist压测的时候发现这个函数的开销比想象中高分析之后发现name in blacklist的contains调用需要先计算 String 的 hashCode而 String 的 hashCode 要遍历每个字符。黑名单只有八个元素List 线性比较用equals往往第一个字符不同就直接返回 false成本更低。后面我把这个小黑名单换回 List反而快了一点。这个案例说明Set 不是万能性能解药。判断依据很简单元素数量、查询频率、计算 hashCode 的成本。数据量小且字符串长的时候List 的短路比较可能胜出。合适才是最好的不要为用而用。写到这里关于 Kotlin Set 的内容也基本覆盖全了。从最基础的去重到底层实现到 equals/hashCode 的规矩再到性能选型最后用几个重构案例把知识点串起来。说到底Set 是集合框架里“唯一性”的代言人你只需要记住三件事第一去重语义用 Set第二自定义对象放进 Set 前先想清楚 equals 和 hashCode第三不要只盯着 O(1) 而忽略实现细节。能做到这三点Set 在 Kotlin 里的基础用法就过关了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑