资讯动态

深入理解 Scala lazy val:惰性求值、线程安全与性能陷阱

发布时间:2026/10/3 15:08:35 来源:尧图企业网站定制
开篇先扯个场景你接手一个 Scala 项目刚打开某个类就看到一行lazy val注释写着“这个别动动完会出事”。你试试改成普通val测试挂了改回lazy又好了。这时候大概率你已经意识到lazy不只是“晚点再算”的意思这么简单。真正让我把lazy val吃透是在几次并发事故和启动性能优化之后。这篇文章会把 Scala 惰性求值的底层原理、应用场景、常见坑一次讲清楚适合刚入门的 Scala 新手也适合写了一阵子却没深究过lazy语义的人。1. 惰性求值改变了什么一次求值时机的小小调整影响比你想象得大1.1 val、def、lazy val 的三角关系很多语言都有懒加载的概念但 Scala 的lazy val把“延迟求值”和“只求值一次”绑到了一起这个组合非常关键。想要理解它最好先和另外两个形态对比着看形态求值时机求值次数线程安全典型场景val a expr定义处求值构造对象时已算完1 次构造期间安全发布之后只读普通常量、构造时需要的依赖def a expr每次调用都重新求值按调用次数取决于函数内部逻辑轻量 getter、无状态计算lazy val a expr第一次被访问时求值1 次之后缓存同一实例保证只初始化一次重资源延迟加载、跨字段依赖、自引用结构光看表格还不够。我举个最容易理解的例子假设你写了一个报表类构造的时候要解析一个几百 MB 的配置文件。用val的话对象一旦创建解析立刻发生哪怕用户只看了一下名称字段也要白白等上几十秒。用def的话每次调config都重新解析一次慢不说还会反复触发相同的副作用。用lazy val第一次真正用到配置时才解析之后所有调用都复用第一次的结果。用做菜来类比val是菜一进厨房就开火不管你最后有没有点这道菜def是每次你说“再来一盘”就重新炒一遍lazy val是你坐下之后第一次催菜才下锅之后加菜直接端同一盘上来不再重做。这个类比虽然简单但能解释lazy val80% 的价值。1.2 引用透明与副作用为什么纯函数喜欢它命令式代码怕它惰性求值之所以在函数式编程里地位很高是因为它背后站着一个“引用透明”的概念如果一个表达式是纯的、没有副作用的那么把它放在哪里执行、何时执行理论上都不会改变外部可观察的行为。既然早算晚算结果都一样延迟到最后一刻自然就成了一种安全的优化手段。但现实项目里初始化表达式往往不是纯的。最常见的三类副作用是打印日志、操作全局计数器、打开外部资源。一旦有了副作用lazy val就会改变程序执行顺序。举个例子var log Vector.empty[String] val a { log : val a evaluated 1 } lazy val b { log : lazy b evaluated 2 }如果不访问b上面的log里永远不会出现lazy b evaluated。有些代码在切换到lazy之后原本每次启动都会出现的日志突然消失就是因为这个原因。所以一个非常值得记住的判断标准是如果初始化表达式里有不可控的副作用lazy val会让执行时序变得不可预测这时要格外谨慎。1.3 惰性会改变外部行为吗那是不是有副作用的场景就完全不能用lazy了也不绝对。关键看你能否接受“第一次访问时才发生副作用”这个语义。我自己的实践原则是lazy val的初始化表达式尽量不要依赖程序启动顺序也不要做“计数”这类需要精确次数的操作。如果副作用确实重要主动写注释说明这个值什么时候被触发、初始化里做了什么避免后来者稀里糊涂地提前访问它。否则别人为了调试随手调用一次副作用就开始执行了那体验非常酸爽。顺带一提lazy val也不是优化性能的绝对银弹。它带来的“延迟”确实能避免白算但访问时多一次标志位判断如果恰好是高并发首次访问还有锁竞争成本。这一点在后面原理和实战章节都会再展开。2. 从字节码看 lazy val 原理一次初始化背后到底发生了什么2.1 编译器生成了什么一个标志位加一个同步计算访问器只停留在“第一次访问才求值”的认知上还是太表面。真正理解lazy val得看编译器把它变成了什么。Scala 编译lazy val时并不会直接生成一个简单的普通字段而是生成了一套合成逻辑。语义上等价于下面这段伪代码class Demo { private var _value: Double 0.0 private var bitmap$0: Boolean false def value: Double { if (bitmap$0) _value else value$lzycompute() } private def value$lzycompute(): Double synchronized { if (!bitmap$0) { _value { // 这里放你写的初始化表达式 42.0 } bitmap$0 true } _value } }当然真实的字节码里不是用一个Boolean而是一个int位图一个类里多个lazy val会共用同一个位图每个字段占一位。使用int位图纯粹是为了省空间一个int可以管 32 个lazy val再多才加一个int字段。关键点在于访问器分两条路径fast path已经初始化直接读和slow path未初始化加锁计算。任何第一次访问lazy val的线程都会进入同步块但在块内还要再判断一次标志位这就是典型的双重检查。2.2 线程安全从哪里来双重检查加内存屏障lazy val的线程安全是语言规范保证的同一个实例上多个线程同时第一次访问lazy val只会有一个线程真正执行初始化表达式其他线程等它执行完后读取结果。这一点在 Scala 2.12/2.13 里靠的是 synchronized 块锁住持有对象。这里有个值得展开的策略细节fast path 读取标志位时理论上可能因为没有内存屏障读到旧值。但即使读到旧值最坏情况也只是多走一次 slow path进入同步块后再查一次标志位发现已经初始化就直接返回不会重复执行初始化表达式。这是一个非常聪明的容错设计——不要求 fast path 绝对精确依靠同步块的可重入检查和锁的 happens-before 关系兜底。那初始化表达式抛异常怎么办注意bitmap$0只有在表达式成功执行完才会被置为 true。假如初始化过程中抛了RuntimeException标志位保持 false下一次访问会重试整个初始化表达式。这一点和许多人的直觉相反在后面章节里我会单独作为重点提醒。2.3 与 Java、Kotlin 的懒加载对比如果你写过 Java可能自己实现过双重检查锁private volatile HeavyResource instance; public HeavyResource getInstance() { if (instance null) { synchronized (this) { if (instance null) { instance new HeavyResource(); } } } return instance; }Scala 的lazy val本质上是把这段模板封装成了语言机制但细节更严格。Kotlin 的by lazy默认也提供线程安全模式不过可以通过参数切换成非线程安全的LazyThreadSafetyMode.NONE。Scala 没有这种开关lazy val在语义上就是线程安全的。这意味着如果你只是想本地单线程懒加载一个开销很小但调用频繁的值lazy val的同步成本可能比预期要高需要权衡。3. 实际应用场景什么该 lazy 化什么不该3.1 重量级资源延迟加载使用lazy val最自然的场景是加载真正的重资源。比如数据库连接、SparkSession、加密算法需要的密钥对象、机器学习模型文件。这些资源初始化动辄几秒而很多时候它们只在特定分支中才会被用到。用lazy val包起来可以让主流程不被拖累。一个典型的写法是这样的class UserRepository(config: Config) { private lazy val driver { val d new DatabaseDriver(config.dbUrl) d.connect() d } def findById(id: String): User driver.query(...) def ping(): Unit println(alive) }假设系统每隔几秒会对UserRepository做一次健康检查而健康检查只调用ping根本不查数据库。如果driver是普通val每次构建UserRepository都得先连数据库健康检查服务会莫名其妙地背上一堆连接开销。改成lazy val后连接被推迟到第一次真正查库时才建立效果立竿见影。有一个小经验想分享lazy val的初始化表达式里尽量只做一件事。比如“创建连接 设置超时 测试连通性 注册监控”这种串一串的长表达式代码难读而且一旦某一步抛异常整个初始化都会重来排错时容易让人摸不着头脑。3.2 循环依赖与对象图构建两个对象互相引用在普通构造里很容易陷入无限递归。lazy val是解决这类问题的利器。class Engine(val id: String) { lazy val controller new Controller(this) } class Controller(val engine: Engine) { def describe: String scontroller of ${engine.id} }Engine构造时不需要立即创建Controller所以可以安全地把this传给Controller。只要Controller在构造阶段不立刻调用engine的成员就不会触发半初始化状态下的访问。等到真正需要时engine.id已经就绪。这种模式在构建复杂对象图时特别有用。但要注意lazy val只是推迟了访问时机并没有改变“半初始化对象被访问”的风险。如果Controller构造时不小心调用了engine.someHeavyMethod而这个 method 内部又依赖尚未初始化的Engine字段那还是会炸。所以我会在对象图构建场景里坚持一个原则构造阶段绝不触发对方的lazy val。3.3 只算一次且代价高的普通表达式有时候表达式本身并不“重”只是你不想让它反复执行而普通val在对象构造时就执行又太早。lazy val可以充当一个带缓存的计算函数。case class Metrics(samples: Vector[Double]) { lazy val sum: Double samples.sum lazy val mean: Double sum / samples.size lazy val variance: Double samples.map(x (x - mean) * (x - mean)).sum / Math.max(1, samples.size - 1) }这段代码很有意思variance依赖meanmean依赖sum。如果全部用def每次访问variance都会把sum和mean重新算一遍如果用lazy val每个指标只算一次且它们在第一次被访问时才触发。这种“链式缓存”在我实际写的统计、解析类代码里出现频率极高。不过这里有个坑和case class在一起时特别容易踩case class的copy方法会生成一个全新实例新实例的lazy val缓存完全丢弃。换句话说如果你反复copy一个包含大计算lazy val的对象缓存可能根本起不到作用计算逻辑会一遍遍重新执行。遇到这种情况我会把昂贵的缓存结果提到伴生对象里用输入参数做 key 来缓存或者直接重构避免频繁copy。3.4 trait 初始化顺序问题的规避Scala 的 trait 没有构造参数字段初始化顺序在多层继承时很容易让人头疼。一个子类字段在父 trait 的初始化代码中被提前访问很可能会拿到 null 或默认值。lazy val可以很好地绕过这个问题。trait Greeting { lazy val message: String shello $name def name: String } class Person(val name: String) extends Greeting { override lazy val message: String super.message }即使name是构造后才完成赋值的只要不在Person构造期间访问message等到外部调用message时name早就绪了。这个技巧在搭建小型框架或分层 trait 时非常常见。但别把lazy val当成万能钥匙。如果初始化顺序本身就隐含了强依赖比如trait A的初始化逻辑必须发生在trait B之前那么靠lazy把问题掩盖住只会让维护者更困惑。该用线性化、显式初始化顺序解决的还是要从结构上解决。3.5 无限数据结构与 LazyList 的组合惰性求值的另一个经典舞台是无限集合。在 Scala 2.13 之后Stream被LazyList取代它本身就是按需计算的。配合lazy val可以写出经典的斐波那契数列定义object Fibonacci { lazy val fibs: LazyList[BigInt] BigInt(0) #:: BigInt(1) #:: fibs.zip(fibs.tail).map(_ _) }这里fibs必须用lazy val而不是普通val因为等号右侧的表达式直接引用了fibs自身。普通val在初始化时访问fibs会得到未定义值lazy val把求值推迟到fibs已经被绑定到对象的字段之后递归定义才成立。这个用法从理解上已经摸到了惰性求值的深层它允许你在定义处引用“尚未完成构造”的自身运行时才把整个图展开。类似的手法也能用在懒加载的图结构上不过要小心不要写出无限递归的初始化表达式。3.6 不该用 lazy 的三个地方有送分也有送命。lazy val不适合以下场景。第一每次访问都需要最新外部状态的场景。比如系统的时间戳、实时配置、环境变量用def才是正确的lazy val会把第一次读取的值缓存一年。第二极轻量且高频的访问路径。比如在一个亿级循环里访问lazy val每次多一个标志位判断和潜在的缓存未命中性能至少会比普通val差一个身位。这种热点代码里用普通val或final val才是正解。第三对象需要频繁序列化和反序列化的场景。lazy val生成的字段参与序列化但涉及外部连接的资源数据库连接、网络 socket序列化后往往无法恢复反序列化出来的对象拿到的是一个失效的缓存值。这种情况更适合用独立生命周期管理资源而不是依赖lazy val自愈。4. 实操过程与核心环节实现三个实验彻底看清 lazy val4.1 最小项目跑起来验证求值时机先搭一个最简单 Scala 工程。用 sbt 或 scala-cli 都行这里用虽朴素但稳的sbtsbt new scala/hello-world.g8然后在主代码里写一个能打印执行顺序的示例object LazyTiming extends App { var evaluateCount 0 def expensive(): Int { evaluateCount 1 println(scomputing on ${Thread.currentThread().getName}) Thread.sleep(1000) 42 } lazy val cached expensive() val eager expensive() println(before first access) println(sfirst: ${cached}) println(ssecond: ${cached}) println(sevaluateCount$evaluateCount) }这里的eager用的是普通val它在对象构造阶段就会执行一次expensive。因此输出中computing会出现两次一次来自eager的构造一次来自cached的第一次访问。如果你把eager那行注释掉就只会看到一条computing日志且打印在before first access之后。这个实验是理解lazy求值时机的最小样本适合作为口头面试题。4.2 多线程并发初始化实验光验证“少算一次”还不够线程安全才是lazy val的重头戏。写一个经典的并发触发实验object ConcurrentLazy extends App { import java.util.concurrent.ConcurrentLinkedQueue val initLogs new ConcurrentLinkedQueue[String]() val ready new java.util.concurrent.CountDownLatch(8) val start new java.util.concurrent.CountDownLatch(1) val done new java.util.concurrent.CountDownLatch(8) lazy val shared { initLogs.add(sreal init by ${Thread.currentThread().getName}) Thread.sleep(500) System.currentTimeMillis() } (1 to 8).foreach { i new Thread(() { ready.countDown() start.await() val value shared initLogs.add(sread $value on ${Thread.currentThread().getName}) done.countDown() }, sworker-$i).start() } ready.await() start.countDown() done.await() initLogs.forEach(println) }用CountDownLatch确保 8 个线程在同一瞬间同时触发shared。最终日志里只会出现一行real init by ...其余 8 行都是read ...。如果你把lazy改成def日志里会出现 8 行real init by ...因为它们各自重新执行了表达式。如果改成普通val初始化日志会提前执行8 个线程只是读取同一个已初始化字段。这个实验特别适合验证自己对lazy val线程安全的理解。我第一次跑的时候还专门盯着控制台数了几遍日志确认只有一行真正的初始化才彻底放心。4.3 自己写一个“手搓 lazy”来理解底层成本为了更贴近底层可以手动实现一个极简懒加载包装器它的结构和 Scala 编译器生成的东西神似final class ManualLazy[A](init: () A) { private var value: AnyRef ManualLazy.uninitialized def get(): A { if (value eq ManualLazy.uninitialized) { this.synchronized { if (value eq ManualLazy.uninitialized) { value init().asInstanceOf[AnyRef] } } } value.asInstanceOf[A] } } object ManualLazy { private val uninitialized new Object }注意两点这个包装器的value是一个普通的var初始引用指向一个哨兵对象判断是否初始化用的是eq引用比较而不是 equals。外层先做一次无锁检查命中未初始化才加锁加锁后再次检查避免重复初始化。Scala 编译器的真实实现比这更省字段但核心控制流是一模一样的。这种“手搓”的价值在于让你意识到lazy val不是魔法它是用常规的锁和标志位换来的语义保证。所以你完全可以用同样的思路给其他语言手写懒加载只是要注意 volatile 和内存屏障这些细节少走弯路。4.4 性能影响什么时候值得改 lazy关于性能我尽量不给你拍脑袋的数字因为不同 JVM 版本、不同 CPU 下差距浮动很大。但我可以给出一个经验性的量级普通val访问基本就是读一个字段成本接近零。lazy val已初始化后的访问多一次标志位判断通常比val慢几十纳秒以内。lazy val首次并发访问有锁竞争可能慢几个数量级具体取决于初始化表达式本身的耗时。因此性能层面lazy val最吃亏的不是“读过之后”而是“第一次读”。如果确定某个值几乎必然会被用到且初始化开销不大那完全没必要lazy直接val更干脆。如果它可能不被用到且初始化成本高lazy的“延迟”收益通常会盖过那些微不足道的标志位判断。我曾经优化过一个报表服务启动时要构建一堆分析器但多数请求只用其中两三个。把分析器从val改成lazy val后启动时间从 12 秒降到 4 秒后续请求响应时间基本没受影响。这个案例最能说明lazy是“按需分配计算”的工具不是“加速每次访问”的工具。5. 常见问题与排查技巧实录5.1 自己递归引用自己lazy val n n 1写代码时手一滑很容易写出这种object Trap { lazy val n: Int n 1 }第一次访问n时编译器会去求值右侧的n 1而此刻n字段还没有被填充读取的可能是默认值 0 或者抛空指针异常。实际表现因 JVM 而异同一 JVM 内大概率看到StackOverflowError或NullPointerException而不是你预期的1。这类问题在递归数据结构里尤其隐蔽。比如自定义树结构时想让某个lazy val引用兄弟节点的值结果间接形成了自引用环。排查时我一般会把-Xlint打开靠编译期警告辅助但更多时候还是靠经验凡是看到lazy的初始化表达式里出现了它自己的名字立刻停下来确认是不是自引用。5.2 两个 lazy val 互相依赖导致的死锁lazy val使用持有对象的 synchronized 作为锁所以它天然存在锁嵌套问题。两个对象互相依赖时多线程同时触发就可能导致死锁。下面这个例子可以稳定复现object DeadlockDemo extends App { object A { lazy val value: Int { Thread.sleep(100) B.value 1 } } object B { lazy val value: Int { Thread.sleep(100) A.value 1 } } val t1 new Thread(() println(A.value)) val t2 new Thread(() println(B.value)) t1.start() t2.start() t1.join() t2.join() }如果t1先触发A.value它持有 A 对象的锁初始化过程中要访问B.value与此同时t2触发B.value持有 B 对象的锁初始化过程中要访问A.value。两个线程互相等对方释放锁程序就卡死在这里。运行时用jstack查进程会看到两个线程都处于BLOCKED状态等的是对方的 monitor。真实项目里这种互相依赖往往藏得很深比如通过三层调用间接形成了环。如果遇到启动阶段偶发挂起优先怀疑lazy val初始化链是否成环。解决思路有两个一是打破环至少让一个方向的依赖变成普通方法而不是懒值二是集中管理初始化顺序不要依赖多个对象的懒加载互相协作。5.3 初始化抛出异常后下次访问还会重试这是我个人踩过最狠的坑。lazy val在初始化表达式抛异常时标志位不会被置位。这意味着下一次访问会重新执行整个初始化表达式。如果初始化里有副作用比如写日志、发请求那副作用也会再次发生。实际案例一个配置加载器用lazy val包装远程配置首次访问时网络抖动抛异常程序 catch 住后继续跑过了几秒再次访问它又重新拉取远程配置。这个行为在某些场景下是“自愈”的好特性但在另外一些场景下却是灾难——比如初始化里有一条埋点统计你以为只推一次结果异常重试时推了好几次。我的建议是如果决定依赖“失败后重试”这个语义就把重试也设计得显式一点否则至少给lazy val的初始化表达式包一层安全带lazy val config: Config { try loadRemoteConfig() catch { case e: IOException logger.error(config load failed, will retry on next access, e) DefaultConfig } }这样哪怕失败后续访问也只会拿到一个降级配置不会反复打爆远程服务。5.4 序列化后缓存值丢了或失效了lazy val字段本质上是普通字段类实现Serializable后序列化会原样保存这个字段和标志位。反序列化出来的对象如果标志位显示已初始化会直接使用缓存值不会重新执行初始化表达式。这对不可变计算结果没问题但对数据库连接、网络客户端、临时文件句柄这类资源反序列化后得到的是失效的句柄访问时大概率直接报错。处理手段是自定义readResolve或实现显式的生命周期恢复throws(classOf[IOException]) private def readResolve(): AnyRef { val revived new Heavy // 重新建立连接等 revived }不要指望lazy val自愈。它只是缓存不是连接管理器。区分“可序列化的纯计算结果”和“不可序列化的外部资源”是决定能否直接用lazy val的关键。5.5 case class 的 copy 会重置缓存前面提到过case class的copy会创建新实例因此新实例上所有的lazy val缓存全部丢失。这个坑在不可变数据流重构里特别常见。比如case class RichText(content: String) { lazy val tokens: Vector[String] tokenize(content) }每次richText.copy(content newContent)生成的新对象都会在第一次访问tokens时重新执行分词。如果tokens计算成本高整个重构流程都会悄悄变慢。有些人为了性能把tokens改成普通val虽然避免了重复计算但copy时还是会算。真正的解法是把昂贵缓存提升到伴生对象object RichText { private val tokenCache java.util.concurrent.ConcurrentHashMap[String, Vector[String]]() def cachedTokens(content: String): Vector[String] tokenCache.computeIfAbsent(content, tokenize) }对象本身保持轻量缓存只依赖输入copy不再产生额外计算。这个思路也可以推广到任何需要频繁派生新对象的场景。5.6 常见问题速查表现象可能原因对策初始化代码执行了多次初始化抛异常后再次访问会重试或误用了def确认副作用显式捕获异常并降级必要时换普通val多个线程重复初始化误解了lazy val语义或初始化表达式有外部可见副作用用并发实验验证只保留纯计算逻辑自引用导致栈溢出lazy初始化表达式内访问了自己检查递归定义改用LazyList或打破自引用程序启动偶发挂死多个lazy val初始化互相等待对方用jstack看 BLOCKED 线程打破依赖环反序列化后访问崩溃缓存了不可序列化/失效的外部资源实现readResolve重建资源copy后性能异常新实例丢失lazy缓存缓存提升到伴生对象或外部缓存表热循环访问偏慢lazy val有标志位判断且可能触发同步路径热点路径改普通val或final val最后分享一个这些年攒下来的心得lazy val最适合服务“计算何时发生”这个设计问题而不是“计算快不快”这个性能问题。它真正擅长的事情是延迟加载重资源、构造互相引用的对象图、表达无限数据结构。用到它的时候我一定会顺手检查初始化表达式里有没有副作用有没有递归环有没有可能在多线程下被同时触发。做完这三步检查绝大部分lazy val的坑就已经提前排掉了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑