循环、管道、不可变数据和惰性求值各自改变了不同的成本。一次计时无法同时回答执行时间、分配流量、历史版本保留空间和缓存命中率。把这些指标混成“函数式代码快不快”,得到的结论很难指导具体修改。

本章给出一个可复现的 Java 21 微实验:相同整数输入上比较循环与 IntStream,分别记录多轮时间和当前线程分配量;另用可达对象图比较复制与共享历史;最后检查严格求值、按需消费和有界缓存。它不是 JMH 基准,不给出生产容量建议。每个结论都限定在实际测量或断言能够支持的范围内。

先确定计算的是同一件事

输入是从零到九千九百九十九的 int 数组。两种实现都选择偶数,计算平方,再用 long 汇总。乘法前先转换成 long,避免把 int 乘法溢出的结果再扩宽。

1
2
3
4
5
6
7
8
9
10
11
static long loop(int[] xs) {
long s = 0;
for (int x : xs) if ((x & 1) == 0) s += (long) x * x;
return s;
}
static long pipeline(int[] xs) {
return Arrays.stream(xs)
.filter(x -> (x & 1) == 0)
.mapToLong(x -> (long) x * x)
.sum();
}

循环没有构造中间集合,管道使用基本类型流,也没有把每个元素包装成 Integer。若把它改成 Stream,比较的内容就增加了装箱与对象访问,不能继续沿用当前结果。若一边缓存预计算结果、一边每次重新求值,则更不是同一个任务。

测量包装器接收 LongSupplier,执行后先校验结果等于预期,再把结果写入 volatile blackhole。这个名称只描述防止结果完全无人使用的意图,不意味着复制了专业基准工具的黑洞实现。重复相同输入仍可能受到内联、循环优化和公共子表达式等因素影响。

结果相等是性能比较的前提,但不是全部行为等价。这个输入域没有异常、外部回调、顺序敏感状态和无限流。把其中一个实现替换到含副作用的管道中,还需要独立回归业务行为。不能用整数求和的等值断言授权改变订单处理顺序。

预热与多轮测量

程序先执行五轮预热,每轮各运行两种实现一百次,然后进行八轮测量。每次测量内部重复一百次,用总结果检查没有漏算;偶数轮先循环后管道,奇数轮交换顺序。交换顺序能减轻固定先后造成的偏差,不能消除进程内的所有干扰。

时间使用 System.nanoTime 的差值,记录的是这一段调用的经过时间,不是 CPU 指令数,也不是严格的 CPU 占用时间。操作系统调度、同机负载和 JIT 编译都会影响它。纳秒是数值单位,不是测量精度的保证。

预热次数是实验配置,不是“JIT 已完全稳定”的证明。当前小函数可能很快进入稳定状态,换成复杂调用图后,同样五轮未必足够。观察多轮数据可以发现趋势或异常,但不能仅因为曲线看起来平坦,就断言所有优化都已完成。

公共 runner 的原始输出逐轮记录循环与管道的纳秒值,每个数都对应一百次计算。同机环境负载不受控,应同时查看全部八轮,不挑选最快一轮。准确值、运行环境和源码散列以运行证据为准;重新执行可能产生不同数值,正文不固定某次耗时范围。

这些数值支持“本次实验条件下循环记录值较低”,不支持“所有函数式代码都慢若干倍”。程序没有多进程 fork,没有置信区间,没有隔离其他进程负载,也没有模拟数据库、序列化或实际订单规模。八轮是同一 JVM 内的重复观察,不是八个相互独立的机器样本。

若业务请求的大部分时间在网络等待,缩短这个求和函数可能几乎不改变端到端延迟。反过来,如果相同内核在大批量计算中重复数百万次,小差异也可能有意义。是否优化应由真实热点、输入分布和预算决定,而不是由代码风格标签决定。

分配流量与存活空间分开读

实验使用 com.sun.management.ThreadMXBean 的当前线程分配计数,在执行前后取差。启动时先检查该能力是否受支持,并启用计数;不支持时实验直接失败,不用零代替不可用值。这个接口提供线程累计分配的近似字节数,是 JDK 扩展能力,不是所有 JVM 都必须提供的可移植指标。ThreadMXBean API

本次八轮循环记录的分配差值为零,管道为每轮三万四千四百字节。每轮执行一百次,因此不能把三万四千四百写成单次分配。这个结果也不意味着源代码层面绝对没有对象表达式:优化器可能消除分配,计数器本身也有适用范围。

计数只覆盖当前平台线程。若被测实现把工作交给线程池,而包装器仍只读取调用线程计数,就会漏掉工作线程上的分配。本章两种实现均在当前线程顺序执行,没有把 parallel 流与普通循环混在一个线程计数口径中比较。

分配过的对象可能马上变成不可达。累计分配大,不等于方法返回后长期占用的堆大;存活对象少,也不意味着分配成本低。高频短命对象会给垃圾收集带来压力,而缓存中的少量长寿对象可能长期保留很大的依赖图。这两个问题需要不同观察。

测量对象 Measurement 在计数取差之后构造,打印发生在测量结束后。这样减少了日志和结果包装对区间的直接污染,但仍不构成完全隔离。若把 println 放进被测 supplier,计时就会包含格式化、锁竞争和输出,得到的是另一个工作负载。

零也必须按原口径解释。它表示当前计数器在本次区间记录的增量为零,不能外推成“此算法永远零分配”,更不能用它证明该代码不会造成任何内存压力。输入数组本身、JVM 元数据和测量前建立的对象都不在这个增量里。

保留历史时比较可达图

第二个实验逐次建立三百个版本,且保留每个版本的根。复制版本每次把旧列表内容复制到新列表并在头部加入元素;共享版本建立一个 Node(value, tail),tail 指向上一个根。两者都允许访问全部历史。

复制列表的逻辑元素槽总数为一加二一直到三百,即四万五千一百五十。共享版本用 IdentityHashMap 按身份遍历所有根可达的 Node,去重后是三百个。另一个断言确认最新根的 tail 就是前一个根。

1
2
3
4
5
6
7
8
record Node(int value, Node tail) {}
var seen = Collections.newSetFromMap(
new IdentityHashMap<Node, Boolean>());
for (Node root : shared) {
for (Node p = root; p != null && seen.add(p); p = p.tail()) {}
}
assert seen.size() == 300;
assert shared.get(299).tail() == shared.get(298);

这里比较的是复制列表的逻辑元素槽数和共享链表的节点数,不是两个精确堆字节值。列表包装对象、底层数组容量、对象头、对齐、Integer 共享、根容器和 JVM 压缩指针配置都会影响实际堆占用。不能拿四万五千一百五十除以三百,就声称节省了一百五十点五倍内存。

程序明确输出 exact-heap-bytes=NOT_MEASURED。没有执行堆转储、对象布局分析或可靠的存活空间测量,因此不提供精确 retained bytes。System.gc 后读取 Runtime 空闲空间也不足以自动变成精确对象大小测量,本实验没有使用这种替代说法。

模型仍然有价值。它准确展示了“保留全部历史”这个条件下,重复复制怎样累计存储位置,尾部共享怎样复用节点。这个结论依赖更新方式:头部插入天然适合单链表;若频繁更新中间元素,需要复制到修改点的前缀,成本模型就不同。

如果只保留最新版本,复制列表最终只有三百个元素槽,共享链表也有三百个节点,历史累积差异不再按同一个公式成立。旧根是否被闭包、日志、缓存或 UI 历史列表持有,直接决定实际可回收范围。谈持久化结构的空间优势,必须同时说明根集合。

结构共享还会延长某些数据的可达时间。一个看似很小的切片或尾指针可能保留后续整段链。如果业务只需要十个值,却把原大结构的根一并缓存,就不能从“每次更新只分配一个节点”推断长期空间很小。分配局部性与对象寿命是两个独立维度。

惰性节省的是未被需求的工作

第三个实验给变换函数加入计数器。严格版本先把一千个整数全部平方并收集,再取前十个;惰性版本在流上 limit(10) 后收集。两个结果列表相同,严格版本回调执行一千次,惰性版本十次。

这个断言测量的是调用次数,没有把次数直接换算成纳秒。对非常便宜的函数,惰性管道自身的管理成本可能占更大比例;对昂贵解析或远程请求,减少九百九十次工作则可能很重要。需要用对应场景的指标判断,不应把“少调用”直接写成固定速度提升。

同时,limit 的位置也决定需求。在 filter 之后取十个,可能需要检查超过十个输入;在 map 之后取十个,通常只需要变换十个被消费元素;如果排序需要完整输入,后面的 limit 无法阻止排序阶段收集所有元素。把任何流都描述为逐元素常量空间是不准确的。

惰性还会推迟错误发生的位置。严格版本在函数返回前可能已经发现第九百个元素非法;只消费前十个的惰性版本不会看到那个错误。这是否可接受取决于契约:预览前十条允许忽略后续,完整文件校验则不允许把未消费区域当成有效。

计数器本身属于测试观察,不应原样放进被测纯变换来宣称函数仍然纯。实验故意通过它观察需求传播;实际业务可以保留纯变换,并在外层用独立测试记录调用。不同观察目的应分开,不要让测量工具改变被承诺的生产接口语义。

有界缓存也可能持续失效

最后一个实验创建容量十六的访问顺序 LinkedHashMap,依次访问二十个不同键并循环五轮,共一百次。每次缺席才计算并插入,超出容量时淘汰最久未访问项。断言最终容量十六,缺失次数一百。

这不是随机坏运气。循环工作集大于容量,每个键再次出现之前已经经历十九个其他键,旧值早已被淘汰。因此有界缓存控制了空间,却没有减少这个访问序列的计算次数,还增加了查找、插入和淘汰成本。

如果把容量改为二十,第一轮之后的重复访问会命中;如果把访问集中在四个热点键,即使容量十六也可能有较高命中率。缓存收益取决于工作集与局部性,不能只由“函数是纯的,所以可以缓存”推出“应该缓存”。

纯函数允许复用结果,但键策略和结果持有关系仍然要设计。可变参数作为键可能在插入后改变哈希语义,结果可能引用很大的输入图,缓存也可能需要区分正常缺席与未计算。本章只用整数键值,避免把这些额外语义混进当前容量实验。

旧文函数缓存 memoize中的假值命中问题关注的是正确性:用真值判断会把零或 false 当作未缓存。本章缓存只存非 null 整数,因此用 get 返回 null 判断缺席,零仍是有效命中。访问顺序 LinkedHashMap 的 get 会刷新命中项的位置,containsKey 不会;这一差别由 JDK 21 API规定。

容量断言之外,另一个样例先填入键零至十五,get(0) 确认零值命中,再插入十六。断言容量仍为十六、键一被淘汰、键零仍保留,完整键顺序为二至十五、零、十六。输出 lru-hit-refresh、evicted=1、retained=0 对应这组观察。全部缺失的循环样例无法检验命中刷新,必须用这个包含命中的访问序列区分 LRU 与仅按插入顺序淘汰。

如何读一份性能报告

一份可以复核的报告至少要让读者知道被比较的函数、输入规模、结果是否相同、预热和重复方式,以及指标的口径。只列一行“快两倍”,没有说明总耗时对应多少次调用,读者无法区分单位错误、冷启动效应和真实差异。

本章证据记录 Java 版本、操作系统、架构、runner 环境、执行命令和源码散列;程序输出明确列出 input=10000、warmup=5x100、rounds=8、repetitions=100。CPU 信息由公共 runner 环境记录。没有设置 CPU 亲和性,没有控制温度或频率,也没有把其他代理任务视为静止背景。

因此原始数据应与这些限制一起保留。若后来修改实现或运行环境,不要只覆盖文章中的最快数值而保持旧散列不变。应重新运行整个相关场景,核对等值断言和全部轮次,再讨论变化是否可归因。当前 evidence 文件是一次实际执行记录,不是跨版本性能历史数据库。

对单个热点做生产决策时,还需要更接近业务的输入分布、独立进程运行和误差分析。本章的最小实验负责训练指标区分,并暴露一些常见错误推理;它没有完成这些生产评估,也不把缺失项目标记为通过。

旧文不可变集合与结构共享通过共享尾部解释旧版本保留,本章增加根集合和可达图计数的口径。旧文Iterator、View 与 LazyList讨论需求与缓存,本章用一千次对十次的回调观察和容量十六的循环访问分别验证两种成本,避免把惰性与记忆化混为一谈。

修改实验时一次改变一个因素

要判断管道开销是否随输入规模变化,可以固定当前实现与重复次数,分别用小数组和大数组运行。小数组可能更受建立管道的固定成本影响,大数组则包含更多实际遍历工作。但在没有运行这些规模之前,这只是待检验解释,不能从当前一万个元素的结果画出一条虚构增长曲线。

要判断装箱成本,应增加一个明确使用对象流的第三实现,并保持相同筛选、乘法范围和汇总结果。不要同时改成并行流,因为那又引入任务调度、线程计数范围和合并成本。把多个因素同时改变后,即使差异很大,也很难回答差异来自哪里。

要判断结果使用是否影响优化,可以让每次重复使用不同但确定的输入,并仍然计算独立期望。当前程序固定输入并汇总一百次结果,适合最小演示,却不保证能阻止所有跨迭代优化。更强的实验需要把输入分布设计成协议的一部分,而不是在计时函数里临时调用随机数增加另一种成本。

预热同样不能只看次数。若新实现会在第一次调用时加载配置或初始化缓存,应区分冷启动和稳定调用两个问题。将初始化移出区间会改变测量对象,并非天然更公平;业务若每次短命进程只调用一次,冷启动本来就是用户成本。报告需要说明为何选择当前口径。

垃圾收集发生时,时间样本可能变大,但单凭一个较慢值不能确认是 GC。需要相应运行时事件或其他证据才能归因。不能将慢轮次全部删除后称为去噪,也不能把所有抖动都归咎于函数式结构。当前报告保留全部八轮,没有给单轮变化指定未经观察的原因。

图模型中的对象身份与业务相等

共享节点去重使用 IdentityHashMap,原因是要统计同一个对象是否从多个根可达。如果改为按 Node 的结构相等去重,两棵内容相同但独立分配的链可能被当成同一棵,得到虚假的共享结论。业务值相等适合检查计算结果,引用身份适合当前对象图问题,二者不能互换。

反过来,仅凭两条 tail 引用相同,也不能知道整个库的结构布局。当前实验拥有手写 Node 的定义,可以解释每次头插新增一个节点;对复杂持久化向量,还需要检查分支因子、路径复制和内部数组等实现。不能把链表结论无条件推广到所有不可变集合。

复制版本统计的是每个历史列表的 size 之和,不声称底层一定逐槽分配独立对象。某些值对象可以被共享,某些集合实现可能保存额外元数据。模型刻意选择一个稳定、可复核的逻辑量,再把无法从这个量推出的精确堆占用标为未测量。

这些区别还影响缓存评估。缓存一个函数结果可能只新增一条引用,也可能因为这条引用让原本可回收的整张输入图继续存活。观察一次插入的分配量无法回答后者;需要从缓存根出发看可达关系,或者使用适合的堆分析。当前缓存实验只验证容量与缺失次数,没有偷偷把它扩大成 retained heap 测试。

样本范围只能描述这八次观察,不能代替总体分布。报告没有计算统计显著性,也没有据此推荐修改线上服务的资源配额。当前线程分配值同样不能用来估计整个服务进程在请求峰值下需要多少堆容量。

复现与修改练习

完整实验源码包含所有断言;结果文件包含此次运行的实际输出。仓库根目录执行:

1
node examples/functional-programming/run.mjs 35

手算题:保留十个逐次增长版本时,复制列表共有多少逻辑元素槽,共享链表有多少不同节点?若只留下最新根,哪些数字需要改变?答案是五十五个槽与十个节点;删除旧根后应按最新结构重新计算,不能沿用全部历史的总和。

修改题:把缓存容量从十六改为二十,先写出预期缺失次数,再运行验证;随后把输入规模增大,并仍保留全部八轮时间和分配输出。不得只保留最快轮次。另加一个“仅保留最新根”的图模型,对照历史根删除前后的可达节点,明确它仍然不是精确堆字节测量。

阅读结果时分别作出三个判断:当前返回值是否正确,当前测量是否覆盖所声称的指标,这个输入是否代表需要优化的业务。只有第一个问题通过,不能推出后两个问题也已解决。