June 6, 2025
By: Kevin

使用 clj-memory-meter.trace 追踪内存使用情况

  1. clj-memory-meter.trace
  2. 惰性求值方案
  3. Transducer 方案
  4. 结论
  5. 脚注

自动内存管理是 JVM 一大的卖点. 仿佛从此不再需要操心内存, 完全是交给垃圾回收器(garbage collector)就好.

当然, 手动保留了对已分配对象的引用(例如, 将对象存储在静态字段中), 它们无法被 GC 回收, 就可能"泄漏"内存.

除此以外, 内存就是取之不尽, 用之不竭的了吗?

显然不是. 内存从来都是有限的, 如果大量数据都需要同时存在, 内存可能不足. 关键不仅在于了解数据结构占用了 多少内存, 还在于了解这些数据结构的访问模式.

算法是每次处理一个条目, 还是将整个列表一次性载入内存? 结果是逐渐写入磁盘, 还是先在内存中累积? 当数据量很小, 远未达到内存极限时, 这些问题 无关紧要. 但当数据量大到一定程度时......💥

应用在 JVM 上耗尽内存体验非常糟糕, 因为它通常不会立即显现出来.

GC 的作用是清理未使用的对象, 是一个相对昂贵的过程, 所以JVM 通常只在剩余内存空间不足时才运行它.

随着用越来越多存活的(不可回收的)对象占据堆内存, JVM 开始更频繁地运行 GC.

但如果大部分已分配的东西都是"需要"的, 那么GC 无法释放.

一旦 JVM 即使高频行 GC, 而每次执行又只释放出微不足道的几个字节的时候, jvm别无选择, 只能更频繁运行 GC, 应用程序本身得不到cpu资源执行.

从外部看, 程序看起来非常繁忙(CPU 使用率 100%), 但实际上没有做任何有用的工作, 程序已经进入了死亡螺旋.

这种情况甚至可能持续数分钟, 直到 JVM 最终因为 OutOfMemoryError挂掉.

Java(Clojure) 程序员有多种内存调试工具. 简要介绍一下.

  • jstatVisualVM: 用于获取常规内存统计信息, 如总堆使用量, 堆限制, 不同 GC 区域的细分等. 有助于了解整体情况.
  • clj-memory-meter: 用于测量单个对象占用的堆内存大小. 帮助减少那些需要一直或大部分时间存在于内存中的东西(如缓存, 静态资源等)的内存占用.
  • :alloc 模式下的 clj-async-profiler: 用于理解和减少程序分配的垃圾量.
  • Eclipse MAT: 一个堆分析器, 用于探查堆转储(heap dumps)和检测内存泄漏.

尽管工具种类繁多, 但没有一个能回答这个问题: " 程序运行最多需要多少内存?" [1] 这就是为什么我创造了另一个工具, 并想在今天呈现给大家.

clj-memory-meter.trace

clj-memory-meter0.4.0 版本引入了一个新的命名空间 clj-memory-meter.trace, 旨在帮助弄清算法的内存需求.

它对程序中的函数进行插桩(instrument), 以报告函数调用前后的内存使用情况. 对于应该追踪哪些函数, 没有硬性规定或建议, 但从顶层函数到底层函数是个不错的思路.

这种追踪会显著减慢程序, 默认情况下, 每次调用被追踪的函数都会强制执行两次 GC. 它还会使用 clj-memory-meter.core/measure 来测量参数返回值.

我们将通过解决一个玩具问题来学习它的工作原理. 任务如下: 给定一个整数集合, 计算其所有子集中, 满足 ∏(子集) mod 7 = 1 的子集总数.

(ns example
  (:require [clj-memory-meter.trace :as cmm.trace]))

(defn matches? [s]
  (= (mod (reduce * s) 7) 1))

;; 给子集附加 10MB 的无用元数据, 使其在内存中时更加显眼.
(defn add-deadweight [s]
  (with-meta s {:deadweight (byte-array (* 10 1024 1024))}))

在生成的子集上调用 add-deadweight, 以夸大它们的大小, 达到教学演示的目的. 现在, 我们的第一个尝试将是一个简单的及早求值(eager)解决方案:

(defn subsets-eager [[x & rst :as coll]]
  (if (empty? coll)
    [#{}]
    (let [rest-subsets (subsets-eager rst)]
      (into rest-subsets
            (map #(add-deadweight (conj % x)) rest-subsets)))))

(count (filter matches? (subsets-eager (range 2 7))))
=> 6

可以一直重复运行它, 它都会完美工作. 那么, 这个版本足够好了吗? 试试用一个稍大的输入集来运行它:

(count (filter matches? (subsets-eager (range 2 11))))
=> java.lang.OutOfMemoryError

嗯, 不太行. 让我们追踪几个函数, 看看算法在不同步骤中使用了多少堆内存:

(cmm.trace/trace-var #'matches?)
(cmm.trace/trace-var #'subsets-eager)

(cmm.trace/with-relative-usage
  (count (filter matches? (subsets-eager (range 2 7)))))
Initial used heap: 444.3 MiB (10.8%)
│ (example/subsets-eager <56 B>) | Heap: -1.8 KiB (-0.0%)
│ │ (example/subsets-eager <56 B>) | Heap: -13.0 KiB (-0.0%)
│ │ │ (example/subsets-eager <56 B>) | Heap: -11.9 KiB (-0.0%)
│ │ │ │ (example/subsets-eager <56 B>) | Heap: -11.5 KiB (-0.0%)
│ │ │ │ │ (example/subsets-eager <56 B>) | Heap: -11.0 KiB (-0.0%)
│ │ │ │ │ │ (example/subsets-eager <0 B>) | Heap: -9.7 KiB (-0.0%)
│ │ │ │ │ │ └─→ <320 B> | Heap: -9.7 KiB (-0.0%)
│ │ │ │ │ └─→ <10.0 MiB> | Heap: +12.0 MiB (+0.3%)
│ │ │ │ └─→ <30.0 MiB> | Heap: +36.0 MiB (+0.9%)
│ │ │ └─→ <70.0 MiB> | Heap: +84.0 MiB (+2.1%)
│ │ └─→ <150.0 MiB> | Heap: +180.0 MiB (+4.4%)
│ └─→ <310.0 MiB> | Heap: +372.0 MiB (+9.1%)
│
│ (example/matches? <10.0 MiB>) | Heap: +372.0 MiB (+9.1%)
│ └─→ <16 B> | Heap: +372.0 MiB (+9.1%)
│
│ (example/matches? <10.0 MiB>) | Heap: +372.0 MiB (+9.1%)
│ └─→ <16 B> | Heap: +372.0 MiB (+9.1%)
│
│ ...
│
│ (example/matches? <10.0 MiB>) | Heap: +372.1 MiB (+9.1%)
│ └─→ <16 B> | Heap: +372.1 MiB (+9.1%)
Final used heap: +75.6 KiB (+0.0%)

可以看到 subsets-eager 的每次递归调用返回时, 堆使用量都在增加(12MB, 然后是 36MB, 84MB, 依此类推). 一旦所有子集都被构建(占用 372MB), 所有这些内存都会被保留直到算法结束.

所有那些显示 +372.0 MiB 的行都表示与算法开始时(由 with-relative-usage 宏设定)相比的堆内存差异. 这并不意味着每次调用 matches? 之间都增加了 372MB.

最后一行显示, 在算法运行之后, 已用堆内存没有增加--这是预料之中的, 因为我们没有在任何地方存储任何数据.

惰性求值方案

将这个简单的实现改为惰性(lazy)是很容易的--只需将 into 换成 concat. 让我们这样做, 并用追踪器运行它:

(defn subsets-lazy [[x & rst :as coll]]
  (if (empty? coll)
    [#{}]
    (let [rest-subsets (subsets-lazy rst)]
      (concat rest-subsets
              (map #(add-deadweight (conj % x)) rest-subsets)))))

(cmm.trace/trace-var #'subsets-lazy)

(cmm.trace/with-relative-usage
  (count (filter matches? (subsets-lazy (range 2 7)))))
Initial used heap: 444.5 MiB (10.9%)
│ (example/subsets-lazy <56 B>) | Heap: +544 B (+0.0%)
│ │ (example/subsets-lazy <56 B>) | Heap: +1.8 KiB (+0.0%)
│ │ │ (example/subsets-lazy <56 B>) | Heap: +3.2 KiB (+0.0%)
│ │ │ │ (example/subsets-lazy <56 B>) | Heap: +4.5 KiB (+0.0%)
│ │ │ │ │ (example/subsets-lazy <56 B>) | Heap: +5.4 KiB (+0.0%)
│ │ │ │ │ │ (example/subsets-lazy <0 B>) | Heap: +6.7 KiB (+0.0%)
│ │ │ │ │ │ └─→ <320 B> | Heap: +6.7 KiB (+0.0%)
│ │ │ │ │ └─→ <576 B> | Heap: +8.1 KiB (+0.0%)
│ │ │ │ └─→ <832 B> | Heap: +15.2 KiB (+0.0%)
│ │ │ └─→ <1.1 KiB> | Heap: +2.9 KiB (+0.0%)
│ │ └─→ <1.3 KiB> | Heap: +9.9 KiB (+0.0%)
│ └─→ <1.6 KiB> | Heap: -16.7 KiB (-0.0%)
│
│ (example/matches? <10.0 MiB>) | Heap: +12.0 MiB (+0.3%)
│ └─→ <16 B> | Heap: +12.0 MiB (+0.3%)
│
│ (example/matches? <10.0 MiB>) | Heap: +24.0 MiB (+0.6%)
│ └─→ <16 B> | Heap: +24.0 MiB (+0.6%)
│
│ (example/matches? <10.0 MiB>) | Heap: +36.0 MiB (+1.2%)
│ └─→ <16 B> | Heap: +48.0 MiB (+1.2%)
│
│ ...
│
│ (example/matches? <10.0 MiB>) | Heap: +180.0 MiB (+4.4%)
│ └─→ <16 B> | Heap: +180.0 MiB (+4.4%)
│
│ (example/matches? <10.0 MiB>) | Heap: +192.0 MiB (+4.7%)
│ └─→ <16 B> | Heap: +192.0 MiB (+4.7%)
│
│ (example/matches? <10.0 MiB>) | Heap: +180.0 MiB (+4.4%)
│ └─→ <16 B> | Heap: +180.0 MiB (+4.4%)
│
│ (example/matches? <10.0 MiB>) | Heap: +168.0 MiB (+4.1%)
│ └─→ <16 B> | Heap: +168.0 MiB (+4.1%)
│
│ ...
│
│ (example/matches? <10.0 MiB>) | Heap: +36.0 MiB (+0.9%)
│ └─→ <16 B> | Heap: +36.0 MiB (+0.9%)
│
│ (example/matches? <10.0 MiB>) | Heap: +24.0 MiB (+0.6%)
│ └─→ <16 B> | Heap: +24.1 MiB (+0.6%)
Final used heap: +26.4 KiB (+0.0%)

这个实现表现出一种奇特的行为. 首先, subsets-lazy 本身并没有产生任何占用内存的东西--因为它是惰性的. 随着 matches? 被调用, 内存使用开始增长, 最高达到 192MB, 然后线性下降一直到零.

尽管如此, 它在峰值时也只使用了及早求值方案约 50% 的内存, 这算是一个改进.

那么, 一个不使用递归的线性惰性实现怎么样? 下一个算法根据位模式生成子集. 我们还将使用一个更大的输入集来展示一些惰性效应.

(defn subsets-lazy2 [coll]
  (let [v (vec coll)
        n (count v)]
    (map (fn [i]
           (->> v
                (keep-indexed (fn [idx item]
                                (when (bit-test i idx)
                                  item)))
                set
                add-deadweight))
         (range (bit-shift-left 1 n)))))

(cmm.trace/trace-var #'subsets-lazy2)

(cmm.trace/with-relative-usage
  (count (filter matches? (subsets-lazy2 (range 2 9)))))
Initial used heap: 432.5 MiB (10.6%)
│ (example/subsets-lazy2 <56 B>) | Heap: +544 B (+0.0%)
│ └─→ <624 B> | Heap: +42.2 KiB (+0.0%)
│
│ (example/matches? <10.0 MiB>) | Heap: +384.0 MiB (+9.4%)
│ └─→ <16 B> | Heap: +384.0 MiB (+9.4%)
│
│ (example/matches? <10.0 MiB>) | Heap: +384.0 MiB (+9.4%)
│ └─→ <16 B> | Heap: +384.0 MiB (+9.4%)
│
│ ...
│
│ (example/matches? <10.0 MiB>) | Heap: +456.0 MiB (+11.1%)
│ └─→ <16 B> | Heap: +456.0 MiB (+11.1%)
│
│ (example/matches? <10.0 MiB>) | Heap: +456.0 MiB (+11.1%)
│ └─→ <16 B> | Heap: +456.0 MiB (+11.1%)
Final used heap: +8.5 KiB (+0.0%)

正如预期的那样, subsets-lazy2 本身不占用任何内存, 但是第一次调用 matches? 后出现的惊人的 384MB 是怎么回事?

观察到的是**分块(chunking)**效应--Clojure 实现分块惰性序列时, 不是逐个元素处理, 而是一次处理 32 个元素. 32 * 10MB = 320MB;

不确定为什么是 384MB, 甚至后来增长到 456MB, 但这肯定与分块有关.

Transducer 方案

Clojure 提供了一种更可预测的延迟求值机制--transducer 和可归约(reducible)上下文. 尝试最后一个实现, 它返回一个 eduction 而不是惰性序列:

(defn subsets-eduction [coll]
  (let [v (vec coll)
        n (count v)]
    (eduction
     (map (fn [i]
            (->> v
                 (keep-indexed (fn [idx item]
                                 (when (bit-test i idx)
                                   item)))
                 set
                 add-deadweight)))
     (range (bit-shift-left 1 n)))))

(cmm.trace/trace-var #'subsets-eduction)

(cmm.trace/with-relative-usage
  (count (filter matches? (subsets-eduction (range 2 9)))))
Initial used heap: 432.5 MiB (10.6%)
│ (example/subsets-eduction <56 B>) | Heap: -4.5 KiB (-0.0%)
│ └─→ <568 B> | Heap: -3.3 KiB (-0.0%)
│
│ (example/matches? <10.0 MiB>) | Heap: +396.0 MiB (+9.7%)
│ └─→ <16 B> | Heap: +396.0 MiB (+9.7%)
│
│ ...
│
│ (example/matches? <10.0 MiB>) | Heap: +456.0 MiB (+11.1%)
│ └─→ <16 B> | Heap: +456.0 MiB (+11.1%)
Final used heap: +20.2 KiB (+0.0%)

什么情况? 为什么它用了更多的内存? 结果, 我们掉进了一个陷阱. filter 不知道如何高效地消费一个 eduction, 所以它对 eduction 调用了 seq, 将其变成 了一个普通的惰性序列, 从而失去了所有优势. 要正确地消费一个 eduction, 我们需要使用 reducetransduce:

(cmm.trace/with-relative-usage
  (reduce #(if (matches? %2) (inc %1) %1) 0 (subsets-eduction (range 2 9))))
Initial used heap: 432.5 MiB (10.6%)
│ (example/subsets-eduction <56 B>) | Heap: +568 B (+0.0%)
│ └─→ <568 B> | Heap: +1.7 KiB (+0.0%)
│
│ (example/matches? <10.0 MiB>) | Heap: +12.0 MiB (+0.3%)
│ └─→ <16 B> | Heap: +5.9 KiB (+0.0%)
│
│ (example/matches? <10.0 MiB>) | Heap: +12.0 MiB (+0.3%)
│ └─→ <16 B> | Heap: +2.6 KiB (+0.0%)
│
│ ...
│
│ (example/matches? <10.0 MiB>) | Heap: +12.0 MiB (+0.3%)
│ └─→ <16 B> | Heap: +5.4 KiB (+0.0%)
│
│ (example/matches? <10.0 MiB>) | Heap: +12.0 MiB (+0.3%)
│ └─→ <16 B> | Heap: +4.5 KiB (+0.0%)
Final used heap: +4.1 KiB (+0.0%)

终于, 我们得到了想要的结果. 在 eduction 上调用 matches? 会一次实现一个元素, 所以我们的内存使用峰值一直保持在 12MB.

我无法解释为什么是 12MB 而不是 10MB, 但除此之外, 算法的行为符合预期.

结论

跟内存打交道可不容易, 即使在一个承诺自己管好内存的运行时中也是如此. 特别是系统中遇到了递归与线性惰性的对比, 分块, 惰性函数与 eduction 之间交互的意外情况.

工具箱里多一个能研究和理解这些行为, 窥探黑匣子内部并从中变得更明智的工具总归是件好事. 希望这个新工具能让你生命中的某一天变得更快乐. 下次再见!

感谢 Clojurists Together 赞助我 2025 年的开源工作, 使得这个版本的发布成为可能.

脚注

[1] MAT 可以回答" 为什么我的程序会 OOM? " 这个问题. 通过 -XX:+HeapDumpOnOutOfMemoryError 运行程序会在 OOM 时创建一个堆转储, 可以将其提供给堆分析器. 研究支配对象树(dominator object trees)可能会揭示填满内存的最大数据结构. 但通常很难将这些数据结构与产生它的确切函数和代码行关联起来. ↑

Tags: clojure performance