【问题标题】:Clojure - Memory usage parsing smallish CSV fileClojure - 解析小型 CSV 文件的内存使用情况
【发布时间】:2026-02-11 23:25:01
【问题描述】:

我正在尝试解析一个 50MB 的 CSV 文件。约 2500 行,约 5500 列,一列是字符串(日期为 yyyy-mm-dd),其余的是带有很多空点的浮点数。我需要能够访问所有数据,所以想实现完整的文件,在那个大小下应该是可能的。

我尝试了以下几个选项:

(with-open [rdr (io/reader path)] (doall (csv/read-csv rdr))))

使用line-seq 并手动将字符串解析为数字的手动方式。

我在单个 slurp 上的 JVM 使用量增加了 100MB,是文件大小的 2 倍。在解析数据时,我会增加 1-2GB,具体取决于它是如何完成的。如果我多次打开文件并将其解析为同一个变量,内存使用量会不断增加,最终会出现内存错误,程序会失败。 (我知道查看任务管理器并不是查看内存泄漏的最佳方法,但事实是程序失败了,所以某处存在泄漏)

打开文件的正确方法是什么?我的最终用例是我每天都会获取一个新文件,并且我希望服务器应用程序每天都打开文件并处理数据,而不会耗尽内存并需要重新启动服务器。

编辑:为了比较,使用 Python pandas 读取该文件将消耗大约 100MB 的内存,并且随后重新读取该文件不会继续增加内存使用量。

Edit2:这是一个使用局部原子来尝试查看发生了什么的最小示例:

(defn parse-number [s] (if (= s "") nil (read-string s)))

(defn parse-line [line]
  (let [result (atom [])]
    (doseq [x (clojure.string/split line #",")]
      (swap! result conj (parse-number x)))
    @result))

(defn line-by-line-parser [file]
  (let [result (atom [])]
    (with-open [rdr (clojure.java.io/reader file)]
      (doseq [line (line-seq rdr)]
        (swap! result conj (parse-line line)))
      @result)))

;in the repl:
(def x (line-by-line-parser "C:\\temp\\history.csv")) ; memory goes up 1GB
(def x (line-by-line-parser "C:\\temp\\history.csv")) ; memory goes up an extra 1GB
; etc

非常感谢!

【问题讨论】:

  • 200MB 的意义是因为 JAVA 使用 UCS-2 处理文本,而在 csv 中,大多数字符占用两个字节。字符通常为 1 个字节,有时会超过一个。
  • "如果我多次打开文件并将其解析为同一个变量,内存使用量不断上升,最终出现内存错误,程序失败。" - 你能分享一些代码来演示吗这个?可能是 MCVE。
  • CSV 文件中的每个字段在解析后成为内存中单独的 Clojure/Java 字符串。 JDK 8 中的每个 Java 字符串都需要 24 个字节的字符串对象,加上 16 字节的数组对象,加上每个字符 2 个字节(它们以 UTF-16 存储在内存中,每个字符 2 个字节,即使是 ASCII)。每个字段 40 个字节可能远大于每个字符 2 个字节,具体取决于 CSV 文件有多少个字段。如果您使用 JDK 9 或更高版本,如果字段仅包含 ASCII 字符,紧凑字符串会启用内存中每个字符 1 个字节的内存优化,但它不会减少每个字符串/字段的 40 个字节。
  • @andy_fingerhut 谢谢你的解释 - 有没有办法让 Java/Clojure 不对数据进行装箱(它们都是浮点数或零)?还有为什么每次读取的文件都会增加?
  • 内存增加的另一种可能:可能是两个(def x ...)在一行中,第二个保留之前的数据,直到新数据完全解析并创建数据结构后在内存中,因此至少在瞬间,根据 JVM,旧的和新的都是内存中的非垃圾。您可以通过在读取下一个文件之前执行(def x nil) 来强制旧的垃圾成为可回收垃圾。

标签: csv memory clojure garbage-collection


【解决方案1】:

只要您不将解析后的数据保存在任何 GC 根目录下(例如 defmemoize 函数),您上面显示的代码就不会泄漏。您可以通过循环代码 100 次来轻松证明这一点,看看您是否得到了 OOM(我不希望任何)。话虽如此,您可以按照其他人的建议采取一些措施来缓解记忆压力。

如果您想确切地知道内存的去向,请使用像 this 这样的分析器来深入了解它。

我对您的情况的预感只是 GC 压力(不是泄漏)。具体上read-string的使用,远远超过conj/atom。尝试将read-string 替换为更低级别的内容(例如Integer/parse),您应该会看到很大的不同。另一方面,conj 从持久数据结构的角度来看(Python 不使用)非常高效,但它当然永远不会击败原始数组(Python 使用)。 atom 通常用于并发。在您的情况下,它可以替换为transient(和persistent!),但我不认为它会产生很大的不同。

更新 - 添加分配火焰图

  • 如您所见,read-string 在运行时占用了 70% 的内存分配

【讨论】:

  • 这是一个很好的答案——我没有意识到 read-string 这么重。用 Float/parseFloat 替换有很大帮助!谢谢。
【解决方案2】:

正如我在评论中提到的,看到您如何使用原子,有两件事困扰我:

  1. 您使用它们的方式看起来非常命令式。 Clojure 是一种函数式编程语言,您使用它们的方式不是很地道。
  2. 我的猜测是每个swap 调用都会生成一堆垃圾对象,这可以解释内存使用情况。

第一和第二可以使用intoreduce 解决。作为一个好处,生成的代码会更短。

我比较熟悉reduce,所以这里是一个使用它的例子:

(defn parse-number [s] (if (= s "") nil (read-string s)))

(defn parse-line [line]
  (reduce #(conj %1 (parse-number %2))
          []
          (clojure.string/split line #",")))

(defn line-by-line-parser [file]
  (with-open [rdr (clojure.java.io/reader file)]
    (reduce #(conj %1 (parse-line %2))
            []
            (line-seq rdr))))

由于我没有您的测试数据,我只能猜测它可以解决您的问题。因此,如果您对其进行测试并报告是否有任何改进,我会很高兴。

【讨论】:

  • 谢谢约书亚。我的第一个解决方案都是“clojuresque”,我求助于原子来确保事情会按顺序处理并收集垃圾。您的解决方案虽然非常干净,但无助于重新记忆。到目前为止,似乎对我有帮助的是将数据拆箱以尽可能多地浮动。
  • @alex314159 真可惜。您是否尝试过像 github.com/clojure/data.csv 这样的专用 csv 解析库?
  • 是的,这是我的第一个解决方案。我现在已经设法让我的 50MB 文件“只”使用 500MB 内存并且在重新加载时不会增长,这不是很好但可以接受。让您欣赏 Python pandas 背后的出色工作!