lazy 与 lateinit 到底有什么区别?

1,440 阅读11分钟

0.jpg

上周的时候,我发表了一篇文章,主要讲解的是《委托属性》。

我同事看完后,跑过来说:这个 lazy 我熟啊,但是他和 lateinit 的作用我感觉差不多。

我回答他:还是有点区别,你等我给你写一篇文章,中午好下饭!

lazy 与 lateinit

lazy 和 lateinit 都用于延迟初始化,但它们解决的问题并不相同,行为也有明显差异。

理解两者的区别,对于在实际项目中做出正确选择很关键。

lazy 是一种属性委托,它会在属性第一次被访问时完成初始化,而且只会执行一次。这很适合那些初始化成本较高、但未必总会被用到的属性。

lazy 默认具备线程安全特性,能够保证初始化逻辑只运行一次,因此特别适合这类场景:属性值需要动态计算,但一旦得到结果后就不再变化。

val lazyValue: String by lazy {
    println("Computed once")
    "Hello, Lazy"
}

fun main() {
    println(lazyValue) // 初始化发生在这里
    println(lazyValue) // 复用已经计算出的值
}

相比之下,lateinit 用于 var 属性,表示这个非空变量会在稍后的某个时机再完成初始化。

和 lazy 不同,它更适合那些属性必须可变、并且初始化发生在声明之外的情况,比如依赖注入,或者与 Android 这类框架配合时。

注意,lateinit 并不会替你保证初始化安全;如果在赋值之前访问它,就会直接抛出异常。因此,开发者必须自己确保它在使用前已经正确初始化。

lateinit var message: String

fun setupMessage() {
    message = "Hello, Lateinit"
}

fun main() {
    setupMessage()
    println(message) // 初始化后可安全访问
}

关键差异

对比 Kotlin 中的 lazy 和 lateinit,最核心的差别在于属性会以什么方式、在什么时机被初始化。lazy 属性会在第一次访问时自动初始化,而 lateinit 属性则需要你在使用前手动完成初始化。

  • 从可变性来看:lazy 只能用于 val,也就是值一旦确定后就不能再修改;而 lateinit 用于 var,后续仍然可以重新赋值。
  • 从数据类型来看:lateinit 不能用于基本数据类型(如 Int, Double, Boolean 等)。这是因为 lateinit 在底层是利用 JVM 字段的 null 值来标记“未初始化”状态的,而 Java 的基本类型没有 null 状态。lazy 则没有这个限制。
  • 从线程安全来看:lazy 默认就是线程安全的,当然你也可以配置不同的线程安全模式;lateinit 则没有任何内建保证,在多线程环境下需要格外小心。
  • 从错误处理来看:lazy 会在第一次真正需要这个值时安全地完成初始化;而如果你提前访问了尚未初始化的 lateinit 属性,就会触发 UninitializedPropertyAccessException。

这里我考大家一个问题:lateinit 不能用于基本数据类型,那么如果我使用 value class 包裹一层呢?

你要是想看答案,直接拉最后!

局部和顶层 lateinit 变量

早在 Kotlin 1.2 中,基于相关的 KEEP 提案,lateinit 就已被正式扩展到了局部变量和顶层变量中。

对于局部变量来说,这意味着可以在更细粒度的作用域里进行延迟初始化,比如函数内部,从而在不引入可空类型的情况下获得更高的灵活性。

对于顶层变量,支持 lateinit 能让语言行为更一致,因为过去的限制往往迫使开发者退回到可空属性或自定义委托等变通方案。

与此同时,当时的提案也提到了生命周期管理、线程安全等实现难点,强调必须谨慎设计,才能继续保持 Kotlin 的安全性承诺。

对局部变量而言,这种方案还能让变量在局部函数或 lambda 中完成初始化,某种意义上可以作为实现 effect system(即在复杂的闭包或函数式编程中,安全地推迟副作用逻辑的执行)的一种临时办法。

fun foo() {
    lateinit var x: Bar
    synchronized {
        x = bar()
    }
    // ...
}

总结

如果一个属性需要延迟初始化、只读,而且希望默认具备线程安全能力,那么 lazy 通常是更合适的选择,尤其适用于那些计算成本高或访问频率低的属性。

不过在 Android 开发等场景中需要注意,如果 lazy 的初始化 lambda 捕获了 Activity 或 Context 等重量级对象,且该 lazy 属性所在的宿主生命周期长于组件本身,就可能导致内存泄漏。

相对地,lateinit 更适合可变属性,并且这些属性往往需要在稍后的某个时机完成初始化,例如依赖注入,或者依赖组件生命周期的框架场景。选择合适的机制,能够帮助你更好地管理 Kotlin 应用中的资源使用和属性生命周期。

进阶:如何判断初始化

在 Kotlin 中,可以通过 ::propertyName.isInitialized 语法来判断某个 lateinit 属性是否已经初始化。这样你就能在真正读取它之前先做一次检查,从而避免 UninitializedPropertyAccessException。

lateinit var message: String

fun setupMessage() {
    message = "Hello, skydoves"
}

fun main() {
    if (::message.isInitialized) {
        println("Message is initialized: $message")
    } else {
        println("Message is not initialized yet")
    }

    // 初始化这个属性
    setupMessage()

    if (::message.isInitialized) {
        println("Message is initialized: $message")
    }
}

在这个例子里,::message.isInitialized 会先确认 message 是否已经完成初始化,再决定是否访问它的值。这样做既安全,也能避免运行时异常。需要注意的是,isInitialized 只适用于 lateinit 属性,普通属性不能这样检查。

底层机制

早在 Kotlin 1.2 中,基于相关的 KEEP 提案,编译器就为 lateinit 属性引入了 isInitialized 的内建/内联支持(intrinsic)。这个特性不仅避免了 UninitializedPropertyAccessException,而且因为它是一种 intrinsic 机制,编译器会将其直接编译为底层的判空指令(而非低效的反射),因此性能非常高。

它尤其适合那些初始化依赖外部条件的场景,例如依赖注入、生命周期驱动的初始化等。借助它,你可以像下面这样,在真正使用之前先高效地确认属性是否已经就绪:

class Test {
    lateinit var file: File

    fun test() {
        if (this::file.isInitialized) {  // ALOAD 0, GETFIELD Test.file, IFNULL ...
            // 执行文件操作
            println("File is initialized")
        }
    }
}

进阶:字节码

在 Kotlin 的非空类型体系中,所有属性原则上都必须在对象构造期间初始化,要么在构造函数里完成,要么放在 init 代码块里。这保证了对象从创建那一刻起就始终处于合法且一致的状态。

不过,某些常见设计模式,比如依赖注入,或者自定义生命周期里的 setup 过程,往往要求属性在对象创建完成之后再赋值。

lateinit 正是 Kotlin 为这类场景提供的务实方案。它允许开发者声明一个没有初始值的非空 var,相当于向编译器承诺:这个属性会在第一次读取之前完成初始化。

来看一个通用示例。这里有一个 Service 类依赖 Configuration 对象,而这个依赖会在构造之后通过 setup 方法注入进来。

class Configuration(val settings: String)

class Service {
    // 一个非空属性,我们承诺稍后初始化它。
    private lateinit var config: Configuration

    // 对象会先被构造出来。
    // 此时 'config' 仍然还没有初始化。

    // 构造之后会调用一个 setup 方法。
    fun initialize(configuration: Configuration) {
        this.config = configuration
        println("Service initialized.")
    }

    fun doWork() {
        // 现在访问这个属性已经安全了。
        println("Working with settings: ${config.settings}")
    }
}

这个约定非常明确:在 initialize() 调用之前,Service 还不能算完全可用。lateinit 让 config 在源码层面保持非空,于是你可以在 doWork() 里省去空判断,同时又保留这种“两阶段初始化”的灵活性。

当这段 Kotlin 代码被编译后再反编译成 Java,你会发现 lateinit 属性最终会变成一个普通的可变 Java 字段。真正负责兑现这份“承诺”的,并不是字段本身,而是编译器自动生成的访问器方法。

import kotlin.jvm.internal.Intrinsics;
import org.jetbrains.annotations.NotNull;

public final class Service {
    // 1. lateinit var 会变成一个普通的、非 final 字段。
    // 它不会被初始化,默认值会是 `null`。
    private Configuration config;

    // 2. 编译生成的 getter 方法中包含运行时检查。
    @NotNull
    public final Configuration getConfig() {
        Configuration localConfig = this.config;
        // 这是关键的运行时检查!
        if (localConfig == null) {
            Intrinsics.throwUninitializedPropertyAccessException("config");
        }
        return localConfig;
    }

    // 3. 编译生成的 setter 是一个标准的 Java setter。
    public final void setConfig(@NotNull Configuration configuration) {
        Intrinsics.checkNotNullParameter(configuration, "<set-?>");
        this.config = configuration;
    }

    // initialize 方法会调用 setter。
    public final void initialize(@NotNull Configuration configuration) {
        Intrinsics.checkNotNullParameter(configuration, "configuration");
        this.setConfig(configuration);
        System.out.println("Service initialized.");
    }

    // doWork 方法会调用 getter。
    public final void doWork() {
        String message = "Working with settings: " + this.getConfig().getSettings();
        System.out.println(message);
    }
}

这段反编译结果清楚展示了 lateinit 的实现思路,一共可以拆成三个部分:

  1. 它会变成一个标准的可空字段:private lateinit var config: Configuration 最终会被编译成简单的 private Configuration config;。在 Java 里,未初始化的对象字段默认就是 null。也就是说,在字节码层面,lateinit 并不会阻止字段为 null;它只是告诉 Kotlin 编译器:请允许这种写法,同时在源码层面依然把它当成非空属性来使用。

  2. getter 负责执行约束检查:这是 lateinit 机制的核心。对外暴露的 getter 方法,也就是 getConfig(),会在运行时检查这份“承诺”是否成立。编译器会在返回值前插入判断:if (this.config == null)。如果字段在 getter 被调用时依然是 null,就会抛出一个专门的 UninitializedPropertyAccessException,并明确告诉你究竟是哪个属性还没有初始化。这样一来,错误会在第一时间、以更清晰的方式暴露出来,而不是等到更隐蔽的地方才以 NullPointerException 的形式出现。如果检查通过,就返回这个非空值。

  3. 会生成一个标准的 setter:setConfig(...) 本质上就是一个普通的 Java setter,它的职责很单纯,就是把传入的值赋给字段本身。所以当你在 Kotlin 中写下 this.config = configuration 时,背后实际上调用的就是这个 setter。

Kotlin 还提供了一种安全检查 lateinit 是否已经初始化的方式,也就是属性引用语法 ::config.isInitialized。编译器会把它翻译成对后备字段的直接 null 判断,而且这个检查本身不会抛异常。

// 在 Kotlin 中:
if (::config.isInitialized) { /* ... */ }

// 从概念上会被编译为:
if (this.config != null) { /* ... */ }

如何解决那个问题

来回答一下文章中间抛出的那个问题:可以使用 value class 包裹基本数据类型来跳过 lateinit 的限制吗?

答案是:完全不行!

如果你尝试写出下面这样的代码:

@JvmInline
value class MyInt(val value: Int)

lateinit var count: MyInt // ❌ 编译报错

编译器会无情地给你甩出一个错误:'lateinit' modifier is not allowed on properties of inline classes。

为什么呢?

因为 value class 在编译后会被内联为它所包裹的底层类型(在这里就是 JVM 的基本类型 int)。既然底层还是基本数据类型,它就依然没有 null 状态,自然无法满足 lateinit 依赖 null 做检查的底层机制。

那么解决方案是什么?

用 data class,这个方案我不细说,你肯定懂。但是这种做法会产生对象分配开销,如果你对性能极其敏感呢?

Delegates.notNull()。

这依然是针对“延迟初始化基本类型”最标准、最 Kotlin 的做法,且不需要额外的包装类。

var max: Int by Delegates.notNull()

// println(max) // 没有初始化的时候使用,会抛出 IllegalStateException

max = 10
println(max) // 10 

不过,Delegates.notNull() 有个小问题,你是无法直接检查 max 是否被初始化的。如果你想检查是否被初始化,可能最方便的方案就是使用另一个 boolean 值去记忆了。

一点想法

简单总结一下,lazy 是一种属性委托,利用其线程安全和计算缓存的特性,非常适合用于初始化开销较大且一旦赋值就不再改变的只读属性(val)。

而 lateinit 则是编译器为“稍后初始化的可变属性 var ”提供的一种务实承诺,它在底层退化为普通的 Java 字段加上读写时的安全检查。在实际开发中,理解它们在字节码层面的不同行为,有助于我们写出更健壮、安全的 Kotlin 代码。

不知道今天中午这顿 Kotlin 的“饭”,你吃得还香吗?