【问题标题】:Scala: Mutable vs. Immutable Object Performance - OutOfMemoryErrorScala:可变对象与不可变对象性能 - OutOfMemoryError
【发布时间】:2010-11-21 11:24:46
【问题描述】:

我想比较 Scala 中 immutable.Map 和 mutable.Map 的性能特征,以进行类似的操作(即将多个映射合并为一个。参见this question)。对于可变映射和不可变映射,我似乎都有类似的实现(见下文)。

作为测试,我生成了一个包含 1,000,000 个单项 Map[Int, Int] 的列表,并将此列表传递给我正在测试的函数。有了足够的内存,结果就不足为奇了:mutable.Map 约为 1200 毫秒,immutable.Map 约为 1800 毫秒,使用 mutable.Map 的命令式实现约为 750 毫秒——不确定是什么导致了巨大的差异,但请随意对此也发表评论。

让我有点吃惊的可能是因为我有点厚,在 IntelliJ 8.1 中的默认运行配置下,两个可变实现都遇到了 OutOfMemoryError,但不可变集合没有。不可变测试确实运行到完成,但运行速度非常慢——大约需要 28 秒。当我增加最大 JVM 内存(大约 200MB,不确定阈值在哪里)时,我得到了上面的结果。

无论如何,这是我真正想知道的:

为什么可变实现会耗尽内存,而不可变实现不会?我怀疑不可变版本允许垃圾收集器在可变实现之前运行并释放内存——所有这些垃圾回收都解释了不可变低内存运行的缓慢性——但我想要一个更详细的解释。

下面的实现。 (注意:我并不是说这些是最好的实现。请随意提出改进建议。)

  def mergeMaps[A,B](func: (B,B) => B)(listOfMaps: List[Map[A,B]]): Map[A,B] =
    (Map[A,B]() /: (for (m <- listOfMaps; kv <-m) yield kv)) { (acc, kv) =>
      acc + (if (acc.contains(kv._1)) kv._1 -> func(acc(kv._1), kv._2) else kv)
    }

  def mergeMutableMaps[A,B](func: (B,B) => B)(listOfMaps: List[mutable.Map[A,B]]): mutable.Map[A,B] =
    (mutable.Map[A,B]() /: (for (m <- listOfMaps; kv <- m) yield kv)) { (acc, kv) =>
      acc + (if (acc.contains(kv._1)) kv._1 -> func(acc(kv._1), kv._2) else kv)
    }

  def mergeMutableImperative[A,B](func: (B,B) => B)(listOfMaps: List[mutable.Map[A,B]]): mutable.Map[A,B] = {
    val toReturn = mutable.Map[A,B]()
    for (m <- listOfMaps; kv <- m) {
      if (toReturn contains kv._1) {
        toReturn(kv._1) = func(toReturn(kv._1), kv._2)
      } else {
        toReturn(kv._1) = kv._2
      }
    }
    toReturn
  }

【问题讨论】:

    标签: performance memory scala garbage-collection mutability


    【解决方案1】:

    嗯,这实际上取决于您使用的实际地图类型。可能是HashMap。现在,像这样的可变结构通过预先分配它期望使用的内存来获得性能。你要加入一百万张地图,所以最终的地图一定会有点大。让我们看看如何添加这些键/值:

    protected def addEntry(e: Entry) { 
      val h = index(elemHashCode(e.key)) 
      e.next = table(h).asInstanceOf[Entry] 
      table(h) = e 
      tableSize = tableSize + 1 
      if (tableSize > threshold) 
        resize(2 * table.length) 
    } 
    

    看到resize 行中的2 * 了吗?可变的HashMap 每次空间不足时都会增加一倍,而不可变的则在内存使用方面非常保守(尽管现有的键在更新时通常会占用两倍的空间)。

    现在,至于其他性能问题,您正在前两个版本中创建键和值列表。这意味着,在您加入任何地图之前,您已经在内存中拥有每个 Tuple2(键/值对)两次!加上List 的开销,虽然很小,但我们说的是超过一百万个元素乘以开销。

    您可能想要使用投影,这样可以避免这种情况。不幸的是,投影基于Stream,这对于我们在 Scala 2.7.x 上的目的来说不是很可靠。不过,还是试试这个吧:

    for (m <- listOfMaps.projection; kv <- m) yield kv
    

    Stream 在需要之前不会计算值。垃圾收集器也应该收集未使用的元素,只要您不保留对 Stream 头部的引用,您的算法似乎就是这种情况。

    编辑

    作为补充,for/yield 推导式接受一个或多个集合并返回一个新集合。通常,返回的集合与原始集合的类型相同。因此,例如,在下面的代码中,for-comprehension 创建了一个新列表,然后将其存储在l2 中。创建新列表的不是val l2 =,而是用于理解。

    val l = List(1,2,3)
    val l2 = for (e <- l) yield e*2
    

    现在,让我们看看前两种算法中使用的代码(减去mutable关键字):

    (Map[A,B]() /: (for (m <- listOfMaps; kv <-m) yield kv)) 
    

    foldLeft 运算符,这里用它的/: 同义词编写,将在由 for-comprehension 返回的对象上调用。请记住,运算符末尾的: 会颠倒对象和参数的顺序。

    现在,让我们考虑一下这是什么对象,在哪个对象上调用了foldLeft。这个用于理解的第一个生成器是m &lt;- listOfMaps。我们知道listOfMaps 是 List[X] 类型的集合,其中 X 在这里并不真正相关。对List 的理解结果始终是另一个List。其他生成器不相关。

    所以,你使用这个List,获取每个Map 中的所有键/值,这是这个List 的一个组件,然后用所有这些创建一个新的List。这就是为什么你要复制你所拥有的一切。

    (其实更糟糕的是,因为每个生成器都会创建一个新的集合;第二个生成器创建的集合只是listOfMaps的每个元素的大小,使用后立即丢弃)

    下一个问题——实际上是第一个问题,但更容易颠倒答案——是projection 的使用有什么帮助。

    当您在 List 上调用 projection 时,它会返回 Stream 类型的新对象(在 Scala 2.7.x 上)。起初您可能认为这只会让事情变得更糟,因为您现在将拥有三个 List 副本,而不是一个。但是 Stream 不是预先计算的。它是惰性计算的。

    这意味着生成的对象Stream 不是List 的副本,而是一个可用于在需要时计算Stream 的函数。计算完成后,结果将被保存,无需再次计算。

    此外,Stream 中的 mapflatMapfilter 都返回一个新的 Stream,这意味着您可以将它们全部链接在一起,而无需复制创建它们的 List .由于与yield 的理解使用了这些功能,因此在内部使用Stream 可以防止不必要的数据复制。

    现在,假设你写了这样的东西:

    val kvs = for (m <- listOfMaps.projection; kv <-m) yield kv
    (Map[A,B]() /: kvs) { ... }
    

    在这种情况下,您什么也得不到。将Stream 分配给kvs 后,数据还没有被复制。但是,一旦执行了第二行,kvs 就会计算出它的每个元素,因此会保存数据的完整副本。

    现在考虑原来的形式::

    (Map[A,B]() /: (for (m <- listOfMaps.projection; kv <-m) yield kv)) 
    

    在这种情况下,Stream 在计算的同时被使用。让我们简单看一下foldLeft对于Stream是如何定义的:

    override final def foldLeft[B](z: B)(f: (B, A) => B): B = { 
      if (isEmpty) z 
      else tail.foldLeft(f(z, head))(f) 
    } 
    

    如果Stream 为空,则返回累加器。否则,计算一个新的累加器(f(z, head)),然后将它和函数传递给Streamtail

    不过,一旦f(z, head) 已执行,将不会再有对head 的引用。或者,换句话说,程序中的任何地方都不会指向headStream,这意味着垃圾收集器可以收集它,从而释放内存。

    最终结果是,由 for-comprehension 生成的每个元素都将短暂存在,而您使用它来计算累加器。这就是您保存完整数据副本的方法。

    最后,还有一个问题,为什么第三种算法不能从中受益。好吧,第三种算法不使用yield,因此不会复制任何数据。在这种情况下,使用projection 只会增加一个间接层。

    【讨论】:

    • 有趣。我听从了您的建议,将 Lists 切换为 Streams,但性能没有任何提升。事实上,命令式实现所需的时间翻了一番。但是,可变实现停止耗尽内存。
    • 另外,这可能是一个愚蠢的问题,但前两个版本如何创建键和值列表?这是因为 for() 产生理解创建了一个新列表吗?我不清楚如何在 for 表达式中调用 .projection() 来避免这种情况。这是因为推导返回的序列类型与传递的序列相同吗?
    • 我会补充我的答案,因为这太多了,无法作为评论保留。
    • 好的,我补充了我的答案。简短的是,“它是关于内存,而不是性能”,“是的,这是因为理解”,“是的,这是因为理解返回与它传递的相同的类型 o 序列”,尽管最后一个答案不是真的。每个集合,或者作为第一个生成器传递给for 的任何东西,都可能返回它想要的任何东西。碰巧List 将返回ListStream 将返回Stream
    • 太好了。非常感谢。
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2021-11-30
    • 2011-01-14
    • 2012-03-11
    • 1970-01-01
    相关资源
    最近更新 更多