实验状态:LAB_VERIFIED。Scala 3.3.7、JDK 21 下,Scala CLI 与 sbt 均运行本章断言,另核验局部 lazy 的字节码;原始记录在 examples/scala-lab/evidence/20261002-batch01/。成员字段源码分析标为 SOURCE_VERIFIED,没有对应运行实验。

两次读取为何产生不同的金额

报价服务每次调用可能产生新结果。即使暂时没有网络,用一个递增计数器也能观察同一问题:一个名字出现两次,右侧计算到底执行几次?把 val 换成 def,把普通参数换成传名参数,程序仍可能通过类型检查,得到的值和副作用次数却已经改变。

求值讨论需要先选定观察对象。对 2 + 3 重算多少次通常不会改变结果;对读取时间、消耗迭代器或增加计数器的表达式,次数直接影响业务行为。本文使用确定性计数,不请求实际报价端,也不以墙上时钟判断先后。完整程序在 snippets/02/Chapter02.scala,每个阶段同时断言结果与计数。

val 固定绑定,def 保留计算

先定义一个每次调用都递增的来源:

1
2
3
4
5
6
7
8
9
var evaluations = 0
def source(): Int =
evaluations += 1
evaluations

val strict = source()
assert(strict + strict == 2 && evaluations == 1)
def repeated = source()
assert(repeated + repeated == 5 && evaluations == 3)

执行到普通局部 val 定义时,右侧先求值,结果绑定给 strict。随后两次读取都拿到 1,不会再次调用 source。def repeated 则定义一个无参数方法,读取它会执行方法体;两次读取分别得到 2 和 3。表达式 repeated + repeated 得到 5,累计求值三次。

二者都允许用同一个名字组织代码,但保存的东西不同:strict 保存这次结果,repeated 提供获得结果的计算。名称、返回类型和看起来相似的调用语法,不能抹掉这个差别。如果 source 是报价请求,两次调用是否合理必须由业务规则决定,不能仅为了代码短就随意替换。

def 也不意味着所有定义内部的状态都会自动刷新。方法可以读取对象字段,也可以返回同一个缓存对象;这里会重算,是因为方法体明确调用 source。判断求值次数时,需要顺着方法体继续看它做了什么,不能只检查声明使用了哪个关键词。

lazy val 推迟第一次成功结果

在上述计数为 3 的位置增加:

1
2
3
lazy val cached = source()
assert(evaluations == 3)
assert(cached + cached == 8 && evaluations == 4)

lazy 定义没有立即调用 source。第一次读取开始初始化,得到 4;成功后第二次读取复用同一个结果,所以相加得到 8。它把“何时获得结果”和“获得结果后是否重复计算”分开:开始时延迟,成功后缓存。

这种缓存有作用域。局部 lazy val 属于这次方法调用建立的局部状态;重新调用 main 会建立另一份。类中的 lazy 字段通常跟随实例,两个实例也不会因为字段名字相同就共享结果。将一个局部缓存移到长期存活的对象中,可能改变更新频率与资源存活时间,需要单独分析。

延迟还会改变异常发生的位置。普通 val 可能在构造或进入代码块时失败,lazy val 可能直到某次读取才失败。把初始化改成 lazy 只是改变触发条件,不会消除网络错误、非法配置或资源耗尽。它也可能保留初始化所需的引用更久;具体对象存活情况要观察产物与运行时,不能从关键词直接推断内存收益。

传名参数每次使用都重新求值

普通参数在调用前求值;value: => Int 接收一个需要时才计算的表达式。官方传名参数说明强调使用时求值和不使用时不求值。它没有承诺自动记住第一次结果:

1
2
3
4
5
6
7
8
9
10
def twice(value: => Int): Int = value + value
assert(twice(source()) == 11 && evaluations == 6)

def twiceCached(value: => Int): Int =
lazy val local = value
local + local
assert(twiceCached(source()) == 14 && evaluations == 7)

def ignore(value: => Int): Int = 0
assert(ignore(source()) == 0 && evaluations == 7)

进入 twice 时计数为 4。方法体使用 value 两次,分别调用 source 得到 5、6,结果是 11。twiceCached 内部第一次读取 local 才使用传名参数,source 得到 7;下一次读取缓存,结果是 14。ignore 根本没有读取参数,所以计数仍为 7。

如果参数改为普通 Int,进入 twice 前就会调用一次 source,方法体两次读取同一参数值。若传入的是已经绑定的 strict,即使参数声明传名,两次读取也只是在读取那个绑定,不会追溯并重新执行 strict 最初的 source。次数由被传表达式与接收方使用方式共同决定。

阶段 本阶段调用 source 次数 累计次数 本阶段结果
strict 两次读取 1 1 2
repeated 两次读取 2 3 5
cached 两次读取 1 4 8
twice 传名参数 2 6 11
twiceCached 局部缓存 1 7 14
ignore 不使用参数 0 7 0

“传名”与“按需并缓存”因此需要区分。前者提供延迟计算,后者还需要缓存机制。这个差别会影响重试与日志:传名参数如果包着一个带副作用的操作,接收方增加一次参数使用就增加一次操作。它不能作为任意函数的无成本替换。

val 没有冻结引用指向的对象

另一个容易混淆的问题是可变性。下面没有给 items 重新赋值,却修改了内部集合:

1
2
3
val items = scala.collection.mutable.ArrayBuffer("created")
items += "paid"
assert(items.toList == List("created", "paid"))

val 限制重新绑定,ArrayBuffer 的 API 仍允许修改内容。把同一引用传给其他代码后,其他代码的修改也可能被观察到。声称“整个数据不可变”需要继续检查字段、嵌套对象和暴露的方法,仅看最外层 val 不够。

配套负例 negative/02/val-reassignment/BadVal.scala 尝试给 val 重新赋值,被编译器以 Reassignment to val 拒绝。它与正例形成对照:语言拒绝重新绑定,同时允许通过合法方法改变可变对象。编译拒绝不是深度不可变的证明。

lazy val 缓存的也可能是可变对象。成功初始化后不再执行初始化块,不代表返回对象后续不能改变,更不代表对其所有操作都线程安全。并发共享 ArrayBuffer 仍需自己的访问协议,惰性初始化不会自动为业务方法添加同步。

初始化失败后会不会再尝试

把初始化块改为第一次失败、第二次成功:

1
2
3
4
5
var attempts = 0
lazy val unstable: Int =
attempts += 1
if attempts == 1 then throw new IllegalStateException("first attempt")
42

程序第一次读取捕获指定异常,断言 attempts 为 1;随后两次读取都得到 42,最终 attempts 为 2。本次 JVM 实验证明:失败没有被当成成功结果缓存,后一次读取会再尝试,成功后才复用结果。

因此“lazy 只执行一次”缺少成功前提。初始化失败次数可能多于一次,初始化块里面先发生的副作用也可能重复。若先扣减资源再抛异常,下一次初始化不会替应用撤销第一次扣减。需要幂等、补偿或显式状态管理的业务,不能靠 lazy val 完成这些保证。

这也不是自动重试策略。程序不会在异常之后自行定时重试,是下一次读取触发另一轮尝试;它没有退避、次数上限或业务错误分类。本文只检验这个初始化语义,后续错误处理与资源章节才建立完整执行协议。

并发读者共享成功初始化

第二项实验创建两个工作线程,用 CountDownLatch 等待两者到达起点,再同时放行读取同一个局部 lazy val。初始化块通过 AtomicInteger 记录开始次数并返回 99。两个 Future 都得到 99,计数为 1;等待设置五秒超时,线程池在 finally 中关闭。

这个设计避免用“先睡若干毫秒”猜测线程已经启动,也防止异常时遗留工作线程。它仍只是一个有限调度样本:同时放行不等于已经观测到两条线程都进入初始化竞争路径,更不能证明所有调度都正确。测试记录支持共享读取的结果与初始化次数,线程安全的通用规则另由实现与语言资料核对。

并发检查没有向初始化块引入递归读取。冻结版惰性初始化说明指出递归 lazy 初始化行为未定义,可能死锁。本章不把无限等待作为可运行练习,相关递归死锁实验为 NOT_RUN。需要相互依赖的状态时,应先检查依赖图是否有环,再设计初始化协议。

冻结源码中有不同的初始化实现

同样写 lazy val,不代表所有位置生成同一段代码。Scala 3.3.7 的 LazyVals.scala先区分类成员与局部定义,还区分特殊模块、@threadUnsafe、Scala.js 等路径。本文只运行 JVM 默认模式,未启用线程不安全注解。

本章实际样例使用局部 lazy 定义。该版 transformLocalDef生成 holder,先检查 initialized;未初始化时进入同步块,再检查一次,然后用右侧求值结果调用 initialize。右侧抛异常时无法完成 initialize,这解释了后续读取仍能重试。

对 sbt 已生成的 scalaexamples.Chapter02$ 执行 JDK 21 的 javap -private -c,cached$lzyINIT1$1 确实包含 monitorenter、LazyInt.initialized 检查及 LazyInt.initialize 调用;外层访问器先检查是否完成,再决定返回值或进入初始化方法。unstable$lzyINIT1$1 中抛出首次异常的 athrow 位于 initialize 之前,与失败后仍可重试的观察对应。这项产物核验没有借用成员字段的 CAS 路径来解释局部定义。

普通 JVM 成员字段的默认路径则使用对象状态与 CAS,出现 Evaluating、Waiting 及已计算结果;初始化结果发布在 finally 中。该版还保留 legacy 路径。因此参考文档中 bitmap 的示意不能直接贴成“本版所有 lazy val 的实际实现”。源码分析应先辨别入口条件,再读状态转换,而不是搜索一个方法名就宣布已追踪当前例子。

上述源码路径核验为 SOURCE_VERIFIED。本章的局部字节码、失败重试与并发读取断言为 LAB_VERIFIED;成员字段产物检查、Scala.js、线程不安全模式及跨版本比较均未运行。三种证据边界分别保留,避免用一次局部实验替所有实现背书。

重跑实验与阅读输出

在第 00 篇所选 JDK 下,从仓库根目录运行:

1
2
3
4
python3 examples/scala-lab/run.py chapter 02 --run-id local
python3 examples/scala-lab/run.py negative --run-id local
python3 examples/scala-lab/run.py sbt --run-id local
python3 examples/scala-lab/run.py bytecode --run-id local

首批统一入口在 Scala CLI 与 sbt 中均通过,实际输出包括:

1
02 evaluations=7; lazy attempts=2; concurrent initializations=1; items=created,paid

输出只是摘要,判断是否通过还要读取进程退出码与各项 assert。共享脚本归档 all.log、sbt.log、相应 JSON 和 negative-02-val-reassignment.log;该反例同时检查非零退出码和重新绑定诊断,依赖下载失败无法冒充规则验证。

bytecode 模式先要求 sbt 产物已经存在,保存 bytecode-02.log 与同名 JSON,并匹配 holder、状态检查、初始化调用及同步指令。它检查这次实际生成的局部实现,没有测量锁成本、分配量或吞吐;这些性能问题留到独立基准章节。

练习与答案

手算:将 twiceCached 中的 lazy val local = value 改为 def local = value,其他代码不动,能继续得到 14 吗?不能。进入该阶段时计数为 6,两次调用分别取得 7、8,结果为 15,累计次数为 8;下一条保持计数为 7 的断言也会失败。

改动练习:让 unstable 前两次都失败,保持现有检查。第二次读取仍会抛异常,当前程序不能正常完成。若确实要研究两次失败,就应分别捕获两次指定异常,断言尝试次数,再验证第三次成功及第四次复用;不能扩大 catch 吞掉所有错误。

设计判断:报价结果要求每次更新,采用重新计算的入口;同一次业务处理中两处使用需要同一个报价,显式保存一次结果;只在确实需要时获取并在本次调用复用,才选择延迟加局部缓存。先表达次数和存活范围,再选择语法,代码的求值行为就能对应业务契约。

参考资料与系列导航

顺序导航:系列入口:00 · 上一篇:01 · 下一篇:03。