【问题标题】:Clojure head retentionClojure 头部保留
【发布时间】:2013-04-06 08:03:39
【问题描述】:

我正在阅读 O'Reilly 的 Clojure Programming 一书。

我遇到了一个头部保留的例子。 第一个示例保留了对d 的引用(我推测),因此它不会被垃圾收集:

(let [[t d] (split-with #(< % 12) (range 1e8))]
    [(count d) (count t)])
;= #<OutOfMemoryError java.lang.OutOfMemoryError: Java heap space>

虽然第二个示例没有保留它,但它没有问题:

(let [[t d] (split-with #(< % 12) (range 1e8))]
    [(count t) (count d)])
;= [12 99999988]

我在这里不明白的是,在这种情况下究竟保留了什么以及为什么保留。 如果我尝试只返回[(count d)],像这样:

(let [[t d] (split-with #(< % 12) (range 1e8))]
    [(count d)])

这似乎造成了同样的内存问题。

此外,我记得读过count 在每种情况下都实现/评估一个序列。 所以,我需要澄清一下。

如果我首先尝试返回(count t),与根本不返回相比,这如何更快/更节省内存? 在这种情况下保留什么以及为什么保留?

【问题讨论】:

    标签: clojure functional-programming jvm lisp


    【解决方案1】:

    在第一个和最后一个示例中,传递给split-with 的原始序列被保留,同时在内存中完全实现;因此OOME。这种情况发生的方式是间接的;直接保留的是t,而原始序列被t 保留,这是一个惰性序列,处于未实现状态

    t 导致原始序列保持的方式如下。在实现之前,t 是一个 LazySeq 对象,存储了一个 thunk,可以在某些时候调用它来实现 t;这个 thunk 需要将指向原始序列参数的指针存储到split-with,然后才能实现将其传递给take-while——参见split-with 的实现。一旦实现t,thunk 就可以在t 处获得GC(在LazySeq 对象中保存它的字段设置为null)不再持有巨大输入seq 的头部。

    输入seq本身是由(count d)完整实现的,需要实现d,从而得到原来的输入seq。

    继续讨论为什么保留t

    在第一种情况下,这是因为 (count d)(count t) 之前被评估。由于 Clojure 从左到右对这些表达式求值,因此本地 t 需要等待第二次调用计数,并且由于它恰好保持一个巨大的 seq(如上所述),这会导致 OOME。

    仅返回(count d) 的最后一个示例最好不要保留t;不是这种情况的原因有些微妙,最好参考第二个例子来解释。

    第二个示例恰好可以正常工作,因为在评估 (count t) 之后,不再需要 t。 Clojure 编译器注意到了这一点,并使用一个巧妙的技巧将本地重置为nil,同时进行count 调用。 Java 代码的关键部分执行类似f(t, t=null) 的操作,以便将t 的当前值传递给适当的函数,但在将控制权移交给f 之前清除本地值,因为这是作为侧面发生的表达式t=null 的效果,它是f 的参数;很明显,Java 从左到右的语义是这项工作的关键。

    回到最后一个例子,这是行不通的,因为t 实际上并没有在任何地方使用,并且未使用的局部变量不会由局部变量清除过程处理。 (清除发生在最后使用点;如果程序中没有这样的点,则没有清除。)

    至于count实现惰性序列:它必须这样做,因为没有一般的方法可以在不意识到的情况下预测惰性序列的长度。

    【讨论】:

    • 您说“本地 t 需要等待第二次调用才能计数,并且由于它恰好指向一个巨大的 seq,这会导致 OOME。”但是t 只是 12 项序列。如果是 12 项序列,如何处理 t 发挥如此巨大的作用.. 将 d 保存在内存中并不是问题,尽管它是 (range 1e8) 的所有其余部分
    • 好吧,t 在以某种方式使用之前是一个未实现的惰性序列,它在内部包含一些代码,在某些时候可能会调用这些代码来实现t。这段代码需要在传递给split-with的原始序列处保存一个指针,因此它可以将它传递给take-while(参见split-with的实现)。感谢您的评论 - 这应该是答案的一部分,我会编辑它。
    • 第二个问题是。首先,不是在它实现之后。请参阅编辑后的答案。
    • 是的,但是(count d) 需要实现这一切。
    • 是的,现在我意识到这就是我应该开始回答的问题......虽然t 是直接保留的,但它当然是导致问题的原始序列。将进行另一轮编辑(在此期间,请改掉一些错别字)。
    【解决方案2】:

    @Michał Marczyk 的回答虽然正确,但有点难以理解。我发现this post on Google Groups 更容易掌握。

    我是这样理解的:

    第 1 步 创建惰性序列:(range 1e8)。值尚未实现,我将它们标记为星号(*):

    * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * ... * * *
    

    第 2 步再创建两个惰性序列,它们是“窗口”,您可以通过它查看原始的巨大惰性序列。第一个窗口仅包含 12 个元素 (t),其余元素 (d):

    * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * ... * * *
    t t t t t t t t t t t t t d d d d d d d d d d d d d d d d d ... d d d 
    

    第 3 步 - 内存不足场景 - 您评估 [(count d) (count t)]。因此,首先计算d 中的元素,然后计算t 中的元素。将会发生的是,您将从d 的第一个元素开始遍历所有值并实现它们(标记为!):

    * * * * * * * * * * * * * ! * * * * * * * * * * * * * * * * ... * * *
    t t t t t t t t t t t t t d d d d d d d d d d d d d d d d d ... d d d 
                              ^
                             start here and move right ->
    
    * * * * * * * * * * * * * ! ! * * * * * * * * * * * * * * * ... * * *
    t t t t t t t t t t t t t d d d d d d d d d d d d d d d d d ... d d d 
                                ^
    
    * * * * * * * * * * * * * ! ! ! * * * * * * * * * * * * * * ... * * *
    t t t t t t t t t t t t t d d d d d d d d d d d d d d d d d ... d d d 
                                  ^
    
                         ...
    
    ; this is theoretical end of counting process which will never happen
    ; because of OutOfMemoryError
    * * * * * * * * * * * * * ! ! ! ! ! ! ! ! ! ! ! ! ! ! ! ! ! ... ! ! !
    t t t t t t t t t t t t t d d d d d d d d d d d d d d d d d ... d d d 
                                                                        ^
    

    问题是所有已实现的值 (!) 都被保留,因为仍然需要集合的头部(前 12 个元素) - 我们仍然需要评估 (count t)。这会消耗大量内存导致 JVM 崩溃。

    第 3 步 - 有效场景 - 这次您评估 [(count t) (count d)]。所以我们首先要计算较小的 head 序列中的元素:

    ! * * * * * * * * * * * * * * * * * * * * * * * * * * * * * ... * * *
    t t t t t t t t t t t t t d d d d d d d d d d d d d d d d d ... d d d 
    ^
    start here and move right ->
    
                            ! * * * * * * * * * * * * * * * * * ... * * *
    t t t t t t t t t t t t t d d d d d d d d d d d d d d d d d ... d d d 
                            ^
    

    然后,我们计算d 序列中的元素。编译器知道来自t 的元素不再需要,因此它可以垃圾收集它们以释放内存:

                              ! * * * * * * * * * * * * * * * * ... * * *
    t t t t t t t t t t t t t d d d d d d d d d d d d d d d d d ... d d d 
                              ^
    
                                ! * * * * * * * * * * * * * * * ... * * *
    t t t t t t t t t t t t t d d d d d d d d d d d d d d d d d ... d d d 
                                ^
    
                         ...
    
                                                                ...     !
    t t t t t t t t t t t t t d d d d d d d d d d d d d d d d d ... d d d 
                                                                        ^
    

    现在我们可以看到,由于不再需要来自 t 的元素,编译器能够在它通过大序列时清除内存。

    【讨论】:

    • 非常感谢这个kamituel。我理解 Michał Marczyk 的出色回答,但这更清楚了。
    【解决方案3】:

    最后一个例子的重要补充:

    (let [[t d] (split-with #(< % 12) (range 1e8))]
        [(count d)])
    

    回到最后一个例子,这是行不通的,因为 t 实际上并没有在任何地方使用,并且未使用的局部变量不会由局部变量清除过程处理。

    现在不是这样了。由于 Clojure 1.9 未使用的解构局部变量被清除。详情请见CLJ-1744

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 2018-08-02
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2012-12-01
      • 2012-06-23
      • 1970-01-01
      相关资源
      最近更新 更多