上一篇我们聊了不可变Set------它O(1)的contains、它的集合运算,在大数据开发里无处不在。但有个问题:如果你要在一个循环里不断往Set里加元素,用不可变Set就会很别扭。这时候你需要的就是可变Set。
很多人学Scala的时候,记住了"不可变优先"这个原则,然后就走到另一个极端------什么都用不可变Set。直到有一天,他写了这么一段代码:
scala
// 用不可变Set动态收集数据
var seen = Set.empty[String]
for (line <- lines) {
if (needProcess(line)) {
seen = seen + line
}
}
看起来没问题对吧?每次seen + line返回一个新Set,然后重新赋值给seen。但当seen里有十万个元素的时候,每次+操作都要重新构建整个哈希表------这个性能开销,你品,你细品。
这时候你就需要可变Set了。
这篇文章不讲"可变Set是什么"这种入门废话,直接说干货:为什么不可变Set在动态收集场景下不够用?可变Set的底层是怎么实现的?什么时候你该用可变Set而不是不可变Set?

先从一个性能问题说起
假设你要写一个函数:给定一个文件的所有行,返回不重复的行集合。
用不可变Set怎么写?
scala
// 写法一:用不可变Set
var seen = Set.empty[String]
for (line <- lines) {
seen = seen + line
}
seen
逻辑很直白:遍历每一行,如果不在集合里就加进去。
但问题在哪?seen + line是O(n)的------因为不可变Set每次add都要创建一个新的哈希表结构,把旧元素复制过去。十万行数据,就是一百亿次操作。
怎么优化?把var换成val,把不可变Set换成可变Set:
scala
// 写法二:用可变Set
import scala.collection.mutable
val seen = mutable.Set.empty[String]
for (line <- lines) {
seen += line
}
seen.toSet // 最后转成不可变Set输出
现在seen += line是摊销O(1)的------因为可变Set是原地修改哈希表,不需要复制整个结构。整个函数的复杂度就变成了O(n)。
从几分钟降到几秒,就是这一行改动的差别。
这就是可变Set存在的意义:在需要动态收集数据的场景下,可变Set的性能远好于不可变Set。
可变Set的底层:它就是个可变的哈希表
很多人以为可变Set和不可变Set是两种完全不同的东西。其实它们的底层思路是一样的------都是基于哈希表。
区别在于:
- 不可变Set:每次add都返回一个新Set,内部会尽量复用旧结构(结构共享),但在动态添加场景下,复用效果有限
- 可变Set:直接在原哈希表上修改,add/remove都是原地操作
scala
import scala.collection.mutable
// 创建可变HashSet
val set = mutable.HashSet(1, 2, 3, 4, 5)
// add:原地添加
set += 6
// HashSet(1, 2, 3, 4, 5, 6)
// remove:原地删除
set -= 3
// HashSet(1, 2, 4, 5, 6)
// contains:和不可变Set一样,O(1)
set.contains(4) // true
看出来了吗?可变Set的API和不可变Set几乎一样------都是+=、-=、contains。区别就是:可变Set是原地修改,不可变Set是返回新对象。
这也是为什么Scala的集合体系这么优雅------不管可变还是不可变,它们共享同一套API。你学会了一个,就学会了所有。
可变Set的几种实现
和不可变Set一样,可变Set也有几种不同的实现,对应不同的场景。
HashSet:最常用的可变Set
scala
import scala.collection.mutable.HashSet
val set = HashSet(1, 2, 3)
set += 4
最常用的可变Set,底层就是个可变哈希表。add/remove/contains都是摊销O(1)。99%的场景用它就够了。
LinkedHashSet:保持插入顺序
scala
import scala.collection.mutable.LinkedHashSet
val set = LinkedHashSet[String]()
set += "c"
set += "a"
set += "b"
// LinkedHashSet("c", "a", "b") ------ 保持插入顺序
有时候你既要去重,又要保持元素的插入顺序------这时候用LinkedHashSet。它底层是哈希表加链表,既能O(1)查找,又能保持插入顺序。
这个在做日志解析、做事件追踪的时候特别好用------你既要去重,又要保持事件的发生顺序。
可变Set vs 不可变Set:怎么选?
这是最常见的困惑。我给一个简单的判断标准:
你是在"构建"一个Set,还是在"使用"一个Set?
- 在"构建"------数据是动态收集的,你不知道最终有多少元素------用可变Set
- 在"使用"------数据已经有了,你要做查找、做集合运算------用不可变Set
具体到实际场景:
| 场景 | 用什么 |
|---|---|
| 循环里动态收集元素 | 可变Set |
| 构建一个Set后要在多个地方传递 | 不可变Set |
| 需要频繁add/remove | 可变Set |
| 做并集/交集/差集 | 不可变Set |
| 方法参数传入的Set | 不可变Set(别假设它是可变的) |
| 方法返回的Set | 不可变Set(对外暴露不可变的) |
最佳实践:可变构建,不可变输出
这是Scala集合体系的老传统了:内部用可变Set构建,对外用不可变Set输出。
scala
def processLines(lines: List[String]): Set[String] = {
val seen = mutable.HashSet[String]()
for (line <- lines) {
if (needProcess(line)) {
seen += line
}
}
seen.toSet // 最后转成不可变Set输出
}
这个模式的好处是:
- 内部高效:用可变Set构建,性能好
- 外部安全:对外暴露不可变Set,调用方拿到的是线程安全的
- 边界清晰:可变的东西只在方法内部,不会泄露出去
这和ListBuffer的模式是一模一样的------可变收集,不可变输出。
和Java HashSet对比
Java也有HashSet,也有LinkedHashSet。那Scala的可变Set和Java的HashSet到底差在哪?
1. 运算符风格
java
// Java
Set<String> set = new HashSet<>();
set.add("a");
set.remove("a");
set.contains("a");
scala
// Scala
import scala.collection.mutable
val set = mutable.Set[String]()
set += "a"
set -= "a"
set.contains("a")
写法上的差别只是表面。真正的差别在这几点:
2. 和Scala集合体系无缝互转
Java的HashSet要转成List、转成数组,都得自己折腾。Scala的可变Set和不可变Set、List、Array之间互转都是一行代码的事。
3. 默认是不可变的
Java里,你new的HashSet默认就是可变的。你要想要不可变的,得自己用Collections.unmodifiableSet()包一层------而且这只是个"视图",你拿着原始引用还是能改。
Scala里,默认的Set就是不可变的。你要可变的,得显式importmutable.Set。
这个设计哲学的差别和List是一样的:把可变性上升到类型层面,编译器帮你检查。
几个真正影响性能的细节
1. toSet是O(n),不是O(1)
和ListBuffer的toList不一样,可变Set的toSet不是零拷贝的------它需要把可变哈希表复制成一个不可变Set。所以如果你最后不需要转成不可变Set,那就别转。
但大多数情况下,这个复制的开销是值得的------因为你对外暴露不可变Set,调用方用起来更安全。
2. 可变Set不是线程安全的
这个不用多说了,可变集合都不是线程安全的。如果你在多线程环境下需要共享Set,要么用不可变Set,要么自己加锁,要么用并发集合。
3. 大数据量下,Set的内存开销比List大
Set要维护哈希表,内存开销比List大。如果你的数据量特别大------比如几百万个元素------那Set的内存占用可能是List的好几倍。这时候你要权衡:你要O(1)的contains,还是要省内存?
写在最后
我写这篇文章的时候,特意没罗列可变Set有多少个方法------因为那些东西官方文档都有,查一下就行。
真正值得花时间理解的是:为什么需要可变Set?
不是因为"它就是Java HashSet的Scala版",而是因为在动态收集数据的场景下,不可变Set的性能不够好。你要在循环里不断往Set里加元素,用不可变Set就是O(n²)的灾难,用可变Set就是O(n)的高效。
但可变Set也不是万能的。它只适合"构建"场景------你在一个方法内部,动态收集一批数据,最后输出一个不可变Set。一旦数据收集完了,你就该把它转成不可变Set,在系统里安全地传递。
这就是Scala集合体系的核心哲学:可变用于构建,不可变用于传递。
理解了这个,你就理解了Scala集合设计的精髓。你不会再纠结"为什么不直接用Java的HashSet"------因为你知道,这不是哪个更好的问题,而是设计哲学的区别。
Java的集合是"命令式"的:你有一个可变容器,你往里面加东西、删东西,容器一直在变。
Scala的集合是"函数式"的:数据是不可变的,你操作数据的时候会生成新的数据。而在这个过程中,如果你真的需要一个可变的临时容器,Scala也给你准备好了------就是可变Set。
它不是"更好的HashSet",它是函数式编程世界里,那个让你可以安全地"作弊"的小窗口。平时你守规矩,用不可变Set;但当你真的需要动态构建数据的时候,可变Set给你提供了一个高效的临时工作台。