ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

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

如果你写过一段时间的 Kotlin,一定遇到过这种需求:把一份用户列表里的重复名字去掉。我见过不少新人第一反应是打开 MutableList,然后contains判断一次加一次,写起来像这样:

val result = mutableListOf<String>() 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返回 true,Set 就认为它们是重复的。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: Set<String> = 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 = mutableSetOf<String>() 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. 自定义对象放进Set:equals和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 里正常工作,你需要遵循三条规则:

  1. equals返回 true 的两个对象,hashCode必须相同。
  2. hashCode相同不代表equals一定为 true,两个不同对象可以碰撞同一哈希码。
  3. 参与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 都相同才算同一个人,如果一条记录是 id=1,name="Tom",另一条是 id=1,name="Tommy",它们会作为两个元素共存。此时你应该手动实现 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内部哈希桶已经按 key=1 放好了 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.HashSet<String>(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 = mutableListOf<String>() for (tag in serverResult) { if (tag != null && !tags.contains(tag)) { tags.add(tag) } }

重构后:

val tags = serverResult.filterNotNull().toSet()

toSet()自动去重,filterNotNull把 null 过滤掉。类型从List<String?>变成Set<String>,语义更安全。这个改动还暴露了一个隐藏问题:原来tags是MutableList,因为包含了 null,后续逻辑里tags[0]可能为 null,导致 NPE。改成 Set 后在编译期就把可空性约束了,很多地方反而不能乱写了。

6.2 高频contains查询:从逐条in扫描换成Set命中

一个权限判断接口,需要检查“用户拥有的资源 ID 列表”里是否包含某个资源 ID。原代码用的是 List:

val userResourceIds: List<Long> = loadFromDb() fun hasPermission(resourceId: Long): Boolean = userResourceIds.contains(resourceId)

这个接口每秒被调用几十次,List 中有几百个元素,每次 contains 是 O(n),叠加 QPS 之后 CPU 明显飙升。改造很简单:

val userResourceIdSet: Set<Long> = 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(): Set<String> = internalTags.toSet() fun getMutableTags(): MutableSet<String> = internalTags

让调用方按需取用,性能和安全都保住了。Kotlin 的只读接口确实好用,但要注意它只是“视图”,不是“快照”,设计 API 时别把两者混为一谈。

6.4 小集合场景下的反向优化:Set反而拖慢了性能

还有一次我做了一个黑白名单过滤,黑名单只有不到十个元素,我习惯性用了 Set:

val 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 里的基础用法就过关了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进