深入理解Scala可变Map:动态构建场景下的性能与设计权衡

上一篇我们聊了不可变Map------它O(1)的查找、它的函数式操作,在大数据开发里无处不在。但有个问题:如果你要在一个循环里不断往Map里加key-value对,用不可变Map就会很别扭。这时候你需要的就是可变Map。

很多人学Scala的时候,记住了"不可变优先"这个原则,然后就走到另一个极端------什么都用不可变Map。直到有一天,他写了这么一段代码:

scala 复制代码
// 用不可变Map动态统计词频
var wordCount = Map.empty[String, Int]
for (word <- words) {
  val count = wordCount.getOrElse(word, 0)
  wordCount = wordCount + (word -> (count + 1))
}

看起来没问题对吧?每次+操作返回一个新Map,然后重新赋值给wordCount。但当words有十万个词的时候,每次+操作都要重新构建整个哈希表------这个性能开销,你品,你细品。

这时候你就需要可变Map了。

这篇文章不讲"可变Map是什么"这种入门废话,直接说干货:为什么不可变Map在动态构建场景下不够用?可变Map的底层是怎么实现的?什么时候你该用可变Map而不是不可变Map?


先从一个性能问题说起

假设你要写一个词频统计函数:给定一个单词列表,返回每个单词出现的次数。

用不可变Map怎么写?

scala 复制代码
// 写法一:用不可变Map
var wordCount = Map.empty[String, Int]
for (word <- words) {
  val count = wordCount.getOrElse(word, 0)
  wordCount = wordCount + (word -> (count + 1))
}
wordCount

逻辑很直白:遍历每个单词,如果在Map里就加一,不在就设为1。

但问题在哪?wordCount + (word -> (count + 1))是O(n)的------因为不可变Map每次add都要创建一个新的哈希表结构,把旧元素复制过去。十万个单词,就是一百亿次操作。

怎么优化?把var换成val,把不可变Map换成可变Map:

scala 复制代码
// 写法二:用可变Map
import scala.collection.mutable

val wordCount = mutable.Map.empty[String, Int]
for (word <- words) {
  wordCount(word) = wordCount.getOrElse(word, 0) + 1
}
wordCount.toMap  // 最后转成不可变Map输出

现在wordCount(word) = ...是摊销O(1)的------因为可变Map是原地修改哈希表,不需要复制整个结构。整个函数的复杂度就变成了O(n)。

从几分钟降到几秒,就是这一行改动的差别。

这就是可变Map存在的意义:在需要动态构建Map的场景下,可变Map的性能远好于不可变Map。


可变Map的底层:它就是个可变的哈希表

很多人以为可变Map和不可变Map是两种完全不同的东西。其实它们的底层思路是一样的------都是基于哈希表。

区别在于:

  • 不可变Map:每次add都返回一个新Map,内部会尽量复用旧结构(结构共享),但在动态添加场景下,复用效果有限
  • 可变Map:直接在原哈希表上修改,add/update/remove都是原地操作
scala 复制代码
import scala.collection.mutable

// 创建可变HashMap
val map = mutable.HashMap("a" -> 1, "b" -> 2, "c" -> 3)

// add/update:原地修改
map("d") = 4
// HashMap("a" -> 1, "b" -> 2, "c" -> 3, "d" -> 4)

// remove:原地删除
map -= "b"
// HashMap("a" -> 1, "c" -> 3, "d" -> 4)

// get:和不可变Map一样,O(1)
map("a")    // 1

看出来了吗?可变Map的API和不可变Map几乎一样------都是apply、-=、getOrElse。区别就是:可变Map是原地修改,不可变Map是返回新对象。

这也是为什么Scala的集合体系这么优雅------不管可变还是不可变,它们共享同一套API。你学会了一个,就学会了所有。


可变Map的几种实现

和不可变Map一样,可变Map也有几种不同的实现,对应不同的场景。

HashMap:最常用的可变Map

scala 复制代码
import scala.collection.mutable.HashMap

val map = HashMap[String, Int]()
map("a") = 1

最常用的可变Map,底层就是个可变哈希表。add/update/remove/get都是摊销O(1)。99%的场景用它就够了。

LinkedHashMap:保持插入顺序

scala 复制代码
import scala.collection.mutable.LinkedHashMap

val map = LinkedHashMap[String, Int]()
map("c") = 3
map("a") = 1
map("b") = 2
// LinkedHashMap("c" -> 3, "a" -> 1, "b" -> 2) ------ 保持插入顺序

有时候你既要通过key快速查找,又要保持key的插入顺序------这时候用LinkedHashMap。它底层是哈希表加链表,既能O(1)查找,又能保持插入顺序。

这个在做配置文件解析、做日志格式处理的时候特别好用------你既要通过配置项名称快速取值,又要保持配置项的原始顺序。


可变Map vs 不可变Map:怎么选?

这是最常见的困惑。我给一个简单的判断标准:

你是在"构建"一个Map,还是在"使用"一个Map?

  • 在"构建"------数据是动态收集的,你不知道最终有多少key------用可变Map
  • 在"使用"------数据已经有了,你要做查找、做转换、做过滤------用不可变Map

具体到实际场景:

场景 用什么Map
循环里动态构建Map(词频统计、分组聚合) 可变Map
构建后要在多个地方传递 不可变Map
需要频繁update/remove 可变Map
做map/filter转换 不可变Map
方法参数传入的Map 不可变Map(别假设它是可变的)
方法返回的Map 不可变Map(对外暴露不可变的)

最佳实践:可变构建,不可变输出

这是Scala集合体系的老传统了:内部用可变Map构建,对外用不可变Map输出。

scala 复制代码
def countWords(words: List[String]): Map[String, Int] = {
  val wordCount = mutable.HashMap[String, Int]()
  for (word <- words) {
    wordCount(word) = wordCount.getOrElse(word, 0) + 1
  }
  wordCount.toMap  // 最后转成不可变Map输出
}

这个模式的好处是:

  1. 内部高效:用可变Map构建,性能好
  2. 外部安全:对外暴露不可变Map,调用方拿到的是线程安全的
  3. 边界清晰:可变的东西只在方法内部,不会泄露出去

这和ListBuffer的模式、可变Set的模式是一模一样的------可变收集,不可变输出。


和Java HashMap对比

Java也有HashMap,也有LinkedHashMap。那Scala的可变Map和Java的HashMap到底差在哪?

1. 运算符风格

java 复制代码
// Java
Map<String, Integer> map = new HashMap<>();
map.put("a", 1);
map.remove("a");
map.get("a");
scala 复制代码
// Scala
import scala.collection.mutable
val map = mutable.Map[String, Int]()
map("a") = 1
map -= "a"
map.getOrElse("a", 0)

写法上的差别只是表面。真正的差别在这几点:

2. getOrElse vs get

Java的map.get(key)在key不存在的时候返回null------这就是臭名昭著的NPE问题的根源。

Scala的可变Map提供了getOrElse(key, default)方法------key不存在的时候直接返回默认值,不需要你判空。

这个设计看起来是小事,但它从根源上消灭了一类bug:NullPointerException。

3. 默认是不可变的

Java里,你new的HashMap默认就是可变的。你要想要不可变的,得自己用Collections.unmodifiableMap()包一层------而且这只是个"视图",你拿着原始引用还是能改。

Scala里,默认的Map就是不可变的。你要可变的,得显式importmutable.Map。

这个设计哲学的差别和List、Set是一样的:把可变性上升到类型层面,编译器帮你检查。


几个真正影响性能的细节

1. toMap是O(n),不是O(1)

和可变Set一样,可变Map的toMap不是零拷贝的------它需要把可变哈希表复制成一个不可变Map。所以如果你最后不需要转成不可变Map,那就别转。

但大多数情况下,这个复制的开销是值得的------因为你对外暴露不可变Map,调用方用起来更安全。

2. 可变Map不是线程安全的

这个不用多说了,可变集合都不是线程安全的。如果你在多线程环境下需要共享Map,要么用不可变Map,要么自己加锁,要么用并发集合(ConcurrentHashMap)。

3. 大数据量下,Map的内存开销比List大

Map要维护哈希表,内存开销比List大。如果你的数据量特别大------比如几百万个key------那Map的内存占用可能是List的好几倍。这时候你要权衡:你要O(1)的查找,还是要省内存?


写在最后

我写这篇文章的时候,特意没罗列可变Map有多少个方法------因为那些东西官方文档都有,查一下就行。

真正值得花时间理解的是:为什么需要可变Map?

不是因为"它就是Java HashMap的Scala版",而是因为在动态构建数据的场景下,不可变Map的性能不够好。你要在循环里不断往Map里加key-value对,用不可变Map就是O(n²)的灾难,用可变Map就是O(n)的高效。

但可变Map也不是万能的。它只适合"构建"场景------你在一个方法内部,动态构建一个Map,最后输出一个不可变Map。一旦数据构建完了,你就该把它转成不可变Map,在系统里安全地传递。

这就是Scala集合体系的核心哲学:可变用于构建,不可变用于传递。

理解了这个,你就理解了Scala集合设计的精髓。你不会再纠结"为什么不直接用Java的HashMap"------因为你知道,这不是哪个更好的问题,而是设计哲学的区别。

Java的集合是"命令式"的:你有一个可变容器,你往里面加东西、删东西,容器一直在变。

Scala的集合是"函数式"的:数据是不可变的,你操作数据的时候会生成新的数据。而在这个过程中,如果你真的需要一个可变的临时容器,Scala也给你准备好了------就是可变Map。

它不是"更好的HashMap",它是函数式编程世界里,那个让你可以安全地"作弊"的小窗口。平时你守规矩,用不可变Map;但当你真的需要动态构建数据的时候,可变Map给你提供了一个高效的临时工作台。

相关推荐
zhanghaha13141 小时前
C# 系列教程_7 判断语句(分支)保姆级精讲
开发语言·c#
apcipot_rain1 小时前
C++转Java日记(02)
java·开发语言·c++
杜 硕2 小时前
从零开始的C++生活——第七讲——02
开发语言·c++·生活
zhanghaha13142 小时前
C# 系列教程_8 循环语句 保姆级精讲(面向新手)
开发语言·c#
麦麦麦当劳大王10 小时前
【油猴脚本】Boss直聘 岗位职责复制
开发语言·前端·javascript
starzy199010 小时前
深入理解Scala Map:从实现原理到工程实践
开发语言·后端·scala
只睡四小时11 小时前
Three.js Sub-tick:漏判 60.7%→0
开发语言·javascript·性能优化·ecmascript·游戏开发·three.js·碰撞检测
码农小河6612 小时前
基于SSM+JSP的高校专业信息管理系统【附源码+数据库】
java·开发语言·数据库
东鹏特饮14 小时前
系统运维(传统/通用运维)面试题
开发语言·php