29

我正在阅读 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]

我在这里没有得到的是在这种情况下究竟保留了什么以及为什么保留。如果我尝试返回 just [(count d)],如下所示:

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

它似乎产生了同样的内存问题。

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

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

4

3 回答 3

26

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

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

输入 seq 本身完全由(count d)需要实现的 实现d,因此是原始输入 seq。

继续讨论为什么t要保留:

在第一种情况下,这是因为(count d)在 之前进行了评估(count t)。由于 Clojure 从左到右评估这些表达式,本地t需要等待第二次调用 count,并且由于它碰巧持有一个巨大的 seq(如上所述),这导致了 OOME。

理想情况下,返回only 的最后一个示例(count d)不应保留t; 不是这种情况的原因有些微妙,最好通过参考第二个示例来解释。

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

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

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

于 2013-04-14T02:47:48.870 回答
25

@Michał Marczyk 的回答虽然正确,但有点难以理解。我发现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)]。所以我们首先要计算较小的头部序列中的元素:

! * * * * * * * * * * * * * * * * * * * * * * * * * * * * * ... * * *
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不再需要 from 的元素,编译器能够在经过大序列时清除内存。

于 2014-02-15T21:18:34.333 回答
0

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

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

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

现在不是这样了。由于 Clojure 1.9 未使用的解构局部变量被清除。有关详细信息,请参阅CLJ-1744 。

于 2020-10-08T09:58:00.830 回答