
1. 从一个小实验说起为什么需要lazy我最早对Scala的lazy产生兴趣不是因为读了什么文档而是被一个很基础的程序员直觉勾起来的。先看这段代码object ExpensiveResource { println(初始化昂贵资源读取大文件、建立连接、加载模型……) val data loadData() }我在很多项目里都见过这种写法模块启动时就把所有东西准备好。可问题是如果这个模块在程序运行过程中根本没用上那些昂贵的初始化动作就白做了。更麻烦的是如果一个对象构造顺序有问题某些字段在构造阶段就访问了还没准备好的资源程序会在最不该崩的时候崩掉。Scala里有一个很实用的关键字叫lazy专门解决这类问题。它的作用可以用一句话概括被lazy修饰的字段只有第一次被访问时才会执行初始化而且整个初始化只执行一次之后访问直接复用结果。从执行效果上看lazy让一个定义时就计算的表达式变成了使用时才计算的延迟表达式。它天然契合两个场景一是开销大、但未必用得到的资源初始化二是对象初始化顺序容易出问题希望通过延迟求值规避构造阶段的不确定性。不过光知道延迟执行是不够的。lazy能被当作工程利器还因为它牵扯到一个语言底层机制惰性求值Lazy Evaluation。这是函数式编程里很重要的一个概念很多语言都有类似能力只是叫法不同。比如Java里的volatile双重检查锁、Kotlin里的by lazy、Swift里的lazy var内核思路其实都很接近。这篇文章从实战出发把lazy的底层原理、典型用法、常见坑和性能影响拆开讲清楚。我会用到一些JVM层面的知识也会给可直接运行的代码示例适合已经从入门语法向真实项目过渡的Scala开发者。如果你正在处理Spark作业的资源初始化、服务启动时间优化、或者对象依赖顺序混乱的问题这篇文章应该能提供一些直接可用的思路。2. lazy的底层实现原理2.1 延迟表达式的三个关键机制想要理解lazy不能只看表面效果还得看编译器和运行时做了什么事。Scala编译器处理lazy val时本质上是做了三重改造第一层字段降级。原本的val是直接存储计算结果的lazy val则先变成一个是否已初始化的标记字段和一个真正存储值的字段。第二层访问器改造。直接用val时访问字段就是一次普通的字段读取用lazy val后每次访问都会经过一个访问器方法方法里先判断标记再决定是直接返回缓存值还是开始计算。第三层同步控制。因为lazy的初始化可能发生在任意线程编译器在访问器里注入了同步逻辑保证并发场景下也只会初始化一次。打个不严谨但很好理解的比方val像你提前买好菜放着做饭时直接拿lazy val像把菜放冰箱做菜前才拿出来洗切。如果一直不做饭菜就一直放着不会提前占用时间。这里有个重要区别需要强调lazy val不是每次访问都重新计算它是第一次访问计算、之后缓存。这与函数式语言里的纯惰性流比如LazyList完全不一样LazyList每次取元素都可能触发新的计算而lazy val一旦算完就固定了。2.2 字节码视角下的访问器与标记位如果只看高层API很多人看不出lazy和普通val的区别。把编译产物变成字节码来读区别就非常明显。class Config { lazy val host loadHost() }Scala 2.13编译这段代码后大致对应这样的Java逻辑public class Config { private String host; private volatile BitSet bitmap; public String host() { if (bitmap null) { bitmap new BitSet(); } if (!bitmap.get(0)) { synchronized(this) { if (!bitmap.get(0)) { host loadHost(); bitmap.set(0); } } } return host; } }注意看几个关键点volatile BitSet用来标记是否初始化完成多个字段共用一个BitSet每个字段占一个bit位。第一次判断不锁直接看标记能极大提升大部分场景下的访问速度。走到synchronized之后会二次检查标记这就是典型的**双重检查锁double-checked locking**模式和Java里手写单例的套路一模一样。之所以用BitSet而不是boolean是因为一个类里可能有多个lazy val字段每个字段用独立布尔值浪费空间合并成BitSet更紧凑。这是Scala编译器层面的优化也是看字节码时最容易忽略的细节。注意如果你的项目用的是Scala 3lazy val的字节码形态会略有调整但核心思想不变——标记位加锁保护。2.3 与Java和Kotlin的对比很多读者可能同时接触Java和Kotlin这里做个横向对比能帮助理解lazy并不是Scala独有的魔法。语言写法实现机制Scalalazy val编译器生成访问器和BitSet标记内部是双重检查锁线程安全Kotlinby lazy委托属性默认SYNCHRONIZED模式内部锁初始化Java手写双重检查锁需要自己负责volatile和synchronized的正确组合Swiftlazy var只能用于var首次访问时闭包求值非线程安全需自行处理从工程角度看Scala的lazy val和Kotlin的by lazy定位最接近都是语言层面帮你处理线程安全不需要手写锁。但要注意Kotlin的by lazy可以有不同LazyThreadSafetyMode而Scala的lazy val默认就是线程安全的没有可配置的低配模式。这意味着用lazy val不需要考虑锁粒度问题但也不能为了性能去牺牲线程安全。3. 典型应用场景与踩坑记录3.1 场景一昂贵资源的延迟初始化最典型的场景是加载配置文件、数据库连接、模型权重这类只初始化一次但开销很大的资源。在Spark任务里如果每个Executor进程都提前做一堆初始化而任务根本没用上资源浪费就很可观。改成lazy val后只有任务真的访问到这个资源初始化才会触发。object ModelLoader { lazy val model loadNeuralNetwork() lazy val config ConfigFactory.load(application.conf) }这样设计的好处有两个一是启动时间变短因为初始化动作被推后了二是资源利用率提高因为没被用到就不加载。但这里有个前提延迟初始化只适合那些不初始化也不影响正确性的资源。如果一个资源是业务逻辑的前置依赖所有路径都会用到那用不用lazy差别不大反而增加了一层访问开销。3.2 场景二打破对象初始化顺序依赖对象在构造阶段最怕的一件事就是一个字段在初始化时访问了另一个还没准备好的字段。比如class ReportGenerator { val header buildHeader() val content buildContent(header) // 依赖header val footer buildFooter() }这种写法的初始化顺序是固定的先header再content再footer。但如果content的初始化逻辑非常重而某个报表根本不生成content那每次构造ReportGenerator都会白白构建一大块内容。改一下class ReportGenerator { lazy val header buildHeader() lazy val content buildContent(header) lazy val footer buildFooter() }这样初始化顺序就不是由字段声明顺序决定了而是由第一次访问哪个字段决定。如果某个报表只需要header和footercontent永远不会被计算。如果content内部访问了header那么访问content时会先触发header的初始化再继续content的初始化依赖关系自动得到满足。这种按需触发依赖的能力在构建复杂对象图时非常有用。不过也要提醒一点lazy val解决的是构造顺序问题不是循环依赖问题。两个lazy val互相访问时仍然会出问题轻则无限递归重则抛出StackOverflowError。3.3 踩坑记录lazy val与线程死锁有一类比较隐蔽的问题和lazy在多线程环境下的初始化顺序有关。请看这个例子object A { lazy val x B.y 1 } object B { lazy val y A.x 1 }表面上看访问A.x时发现x未初始化于是初始化x初始化过程中访问B.yB.y又未初始化于是初始化B.y初始化B.y过程中访问A.x…… 在并发环境下两个线程分别触发A.x和B.y的初始化就可能出现互相等待的情况或者无限递归直到栈溢出。这种代码在真实项目里一般不会出现得这么直白更多是藏在复杂依赖里A依赖BB依赖CC依赖A然后三层初始化被并行触发。踩过这个坑之后我的习惯是凡是lazy val之间有依赖关系一定要在注释或文档里明确标出依赖链并且尽量保持初始化依赖是无环图结构。3.4 踩坑记录惰性求值与可变状态的相互作用lazy虽然好用但它和可变状态结合时需要特别小心。看这个反例class Counter { lazy val value { counter 1 counter } var counter 0 }value的计算过程依赖外部可变变量counter。第一次访问value时counter可能是0计算结果是1并缓存。之后哪怕counter被改成100value依然返回1。这种缓存时机决定结果的行为一旦代码里有人靠修改counter来间接影响valuebug就非常难排查。所以我把lazy val内部的表达式当做一个一次性的、纯计算来对待不要在lazy val的初始化语句里写有副作用的操作除了那些一次性副作用比如写日志、发送初始化完成的指标。如果有副作用请确保副作用本身就期望只发生一次。4. 惰性求值的性能边界与适用性分析4.1 启动性能 vs 运行时性能的权衡惰性求值不是免费的午餐。它把对资源的初始化从启动阶段推迟到运行阶段换来的是每次访问时的额外判断开销。普通val的访问一次字段读取极快。lazy val的访问两次判断是否已初始化加上可能的同步锁初始化。即使已初始化也要走访问器方法比普通字段访问稍慢。如果被lazy包裹的是一个极其高热度的字段比如在一个百万次遍历循环里每次读取那么积累下来的额外开销就会显现出来。我Profile过一些高并发代码某些请求处理路径上lazy val的访问频率能达到每秒百万次以上这些场景就更适合在构造阶段一次性初始化或改用普通val。反过来讲如果一个字段的初始化开销巨大、访问频率低、或者访问路径不确定性高那么lazy带来的收益远大于那一点访问开销。所以判断标准很简单延迟带来的收益是否大于额外检查的开销。4.2 与惰性集合LazyList的求值边界Scala里另一类惰性来自Stream和LazyList等集合类型。它们的惰性是元素级别的惰性构造一个集合时并不会立即计算所有元素而是按需计算。lazy val的惰性是值级别的惰性只对单个值做延迟。这两者在使用时经常被搞混。LazyList适合无穷序列或按需生成的序列lazy val适合一次性大结果。举个例子// 惰性集合每次遍历可能重新计算 val lazyList LazyList.continually(Random.nextInt()).take(5) // lazy val只计算一次 lazy val cached heavyComputation()在Scala 2.13之前有一个Stream类型它在人们印象中就是懒的。但Stream的缓存特性和LazyList不太一样LazyList是真正的惰性列表在使用时按需生成。而lazy val是纯粹的缓存值。看清楚这两个概念才能避免写出以为只算一次结果每次遍历都重新生成的bug。4.3 序列化和反射环境下的注意事项lazy val在字节码层面被改造成了一个方法加一个标记位这给序列化和反射带来了额外的麻烦。最典型的问题是如果使用Java序列化lazy val字段的状态不会像普通val那样被简单序列化因为真正的值存储在编译器生成的一个合成字段里还需要标记位来恢复。在实际项目中如果你把一个包含lazy val的对象序列化后传给别人序列化时lazy字段可能尚未初始化反序列化后也无法自动触发初始化可能导致访问到空值或报出NullPointerException。我的建议是涉及跨进程传输或持久化存储的对象尽量少用lazy val或至少提供显式的初始化保护。如果必须要用可以考虑在序列化之前强制访问一次lazy字段触发真实初始化。5. Scala安装与lazy关键字的快速上手环境5.1 在Linux环境下的安装路径很多读者看到这里可能手头还没有Scala环境想直接动手验证lazy的行为。这里给一份快速安装步骤基于JDK 8或JDK 11Scala 2.13和Scala 3都兼容# 1. 确认Java环境 java -version # 2. 使用Coursier安装Scala推荐比手动下载快 curl -fL https://github.com/coursier/coursier/releases/latest/download/cs-x86_64-pc-linux.gz | gzip -d cs chmod x cs ./cs setup # 3. 验证安装 scala -version装完以后直接在命令行输入scala就能进入REPL环境用来验证lazy val的初始化时机非常方便scala lazy val hello { println(初始化); world } hello: lazy String scala hello 初始化 res0: String world scala hello res1: String world // 注意第二次访问没有打印初始化如果你不想装完整的Scala环境也可以在scastie.scala-lang.org这类在线环境里跑代码效果完全一样。不过本地环境调试断点、看字节码更方便我还是建议在虚拟机或WSL里装一套。5.2 用scalap与javap观察lazy的实现痕迹装好环境后推荐做一个有趣的实验写一个包含lazy val的类编译后看字节码直观感受编译器做了什么。class Demo { lazy val x 42 }scalac Demo.scala javap -c -p Demojavap输出里你能看到bitmap字段、访问器方法内部的get判断、synchronized块。这一步做完你对lazy的理解就不仅仅是语法糖了而是能看到它确确实实是编译器和运行时机制协同的结果。6. 实战项目中的lazy使用策略6.1 团队规范与代码评审中的检查点结合上面这些原理和坑我建议在团队协作中建立以下lazy使用规范只有初始化成本大、访问频率不确定、且无副作用依赖的字段才考虑lazy。lazy val的初始化块内不要写对外部可变状态的写操作。如果多个lazy val存在依赖链必须确保依赖是无环图结构并在注释中标注依赖顺序。高并发、高访问频率的热点路径避免使用lazy val优先用普通val或显式初始化。涉及序列化的对象要么避免lazy要么在序列化前强制初始化。这些检查点看起来简单但每个都能挡住实际项目里出现过的bug类型。代码评审时多问一句这个字段真的需要延迟初始化吗经常能避免掉肉眼看不见的复杂度。6.2 一个完整的Demo从普通val到lazy val的演进最后用一个稍微完整的场景演示lazy的使用演进。假设我们做一个数据报表服务class DailyReportService { // v1: 启动时全部初始化 val dbConnection createConnection() val reportTemplate loadTemplate() val userProfiles loadAllUsers() }在v1版本里服务启动时就把数据库连接、模板、用户数据全部加载出来。结果监控发现有些reportTemplate和userProfiles根本很少被用到启动反而变慢。于是改成v2class DailyReportService { lazy val dbConnection createConnection() lazy val reportTemplate loadTemplate() lazy val userProfiles loadAllUsers() }v2版本里只有真正要用到reportTemplate时才会加载模板用到userProfiles时才加载用户数据。但如果这个服务是并发处理报表的多个线程同时第一次访问dbConnectionlazy val的线程安全机制会保证只初始化一次。更进一步如果userProfiles和reportTemplate之间有依赖关系class DailyReportService { lazy val reportTemplate loadTemplate() lazy val userProfiles { val base loadAllUsers() enrichUsers(base, reportTemplate) } }这样访问userProfiles时会自动先触发reportTemplate的初始化再完成userProfiles的初始化。整个依赖链由lazy的按需触发机制自动维护不需要手写构造顺序。6.3 我在真实项目里的几条体会第一个体会lazy val最怕的不是性能而是隐式依赖。代码里到处用lazy表面上看不出初始化顺序但实际上对象图变得很隐晦。一旦出问题排查成本比显式初始化高得多。第二个体会调试lazy val时断点打在初始化块内部往往比打在外面更有用。因为编译后的访问器方法里包含锁和判断断点打在外面容易看到的是未触发或已缓存看不到真正的初始化逻辑。第三个体会lazy不能替代Option的判空逻辑。有些同学喜欢用lazy来规避空指针指望用的时候才初始化就不会为空。但lazy val只负责延迟计算不负责优雅处理空值。如果一个初始化逻辑本身会返回nulllazy同样会缓存一个null后续访问照样崩。第四个体会在Spark、Flink这种分布式计算框架里lazy val通常只用在Driver端或Executor内的单例对象上用它来延迟加载配置文件。不要试图让算子内部的RDD转换逻辑依赖lazy val的求值时机因为RDD的转换本身就是惰性的再叠加lazy只会让执行计划更难读懂。7. 相关热词延展Kotlin by lazy与Scala的互相借鉴7.1 Kotlin的by lazy与Scala lazy val的同与异很多从Kotlin转Scala的同事会问by lazy是不是就是lazy val功能上很像但实现细节有差异。Kotlin的by lazy是属性委托通过Lazy接口实现默认情况下有三种线程模式SYNCHRONIZED默认、PUBLICATION、NONE。其中SYNCHRONIZED和Scala的lazy val一样保证只初始化一次PUBLICATION允许多个线程同时进入初始化函数但最终只有一个值被使用NONE完全不保证线程安全适合单线程环境。Scala的lazy val没有这几种模式它就是线程安全的不需要也不允许你自定义线程安全策略。这种设计简化了使用成本但也意味着如果你在纯单线程逻辑里追求极致性能Scala会显得稍微重一点。在实际工程中我更喜欢Scala的方式一个语言特性默认安全使用者可以省心。但Kotlin的方式给了更多控制权适合对性能有极端要求并且明确知道线程模型的场景。7.2 从lazy思想看函数式编程的按需计算lazy不只是Scala的一个语法糖。它背后反映的是函数式编程里按需计算的思想。纯函数式语言比如Haskell默认所有求值都是惰性的。Scala作为多范式语言把惰性变成了一种可选的工具让开发者自己决定什么时候用。这种设计非常符合工程的实际需要大多数场景我们其实希望预先计算以保证可控性和可预测性少数场景我们希望按需计算以节省开销。lazy给了我们一个选择权而不是强制改变整门语言的计算模型。理解到这一层你就能明白lazy val在Scala生态里的地位它不是一个为了懒而存在的语法糖而是把计算时机这个维度从立即扩展到了延迟让程序可以更精确地控制资源消耗和执行顺序。在实际项目中如果设计一个需要延迟加载的大型框架通常会在接口层提供一个惰性抽象比如自己写一个Lazy[T]的包装类内部用缓存和锁来控制重复计算。而lazy val作为一个天然的语言级惰性原语往往比自己在框架里实现要可靠得多因为它已经处理了线程安全问题和缓存问题。提示理解lazy val的深层价值不在于背概念而在于能判断什么时候应该延迟什么时候必须立即计算。这个判断力才是写出高性能、易维护Scala代码的分水岭。