第069篇 object 与 companion object:Kotlin 里的单例

简介: 本文深入剖析 `object` 与 `companion object` 的本质差异:二者虽均为单例,但字节码结构、初始化时机及 Java 可见性截然不同。`object` 编译为私有静态实例(饿汉式),而 `companion object` 是宿主类内的静态 `Companion` 实例,其初始化绑定类加载。`@JvmStatic` 和 `@JvmField` 不提升性能,而是改变 Java 调用形态与反射可见性。核心警示:慎用 `object` 持有可变状态,伴生对象避免耗时初始化——正确选型应基于作用域需求,而非便利性。

object 与 companion object 这题,答"object 是单例、companion object 是类的伴生对象"只够入门。真正拉开差距的是它们在字节码层面变成了什么、初始化时机是什么、Java 侧看到的样子是什么——这三个问题连起来,才能解释为什么 @JvmStatic、@JvmField 会影响性能与可见性。

先把结论放在前面:object 声明编译成一个私有 final 的静态实例字段 + 构造器被私有化,类初始化时创建唯一实例;companion object 是类内部的伴生对象,编译器为它生成一个静态 Companion 字段(private static final Companion Companion = null)与一个 INSTANCE 静态字段,伴生对象本身也是单例。 @JvmStatic 把伴生对象的成员额外生成一份静态方法转发;@JvmField 把属性暴露为真正的静态字段。这两者的差别是"Java 侧调用形态"与"反射可见性"的差别。

机制拆解

object 的字节码结构很直白:一个类里有一个 private static final 的实例字段(名字通常是 INSTANCE),静态初始化块里 new 一次,构造器私有。因为实例是 final 字段且在 <clinit> 里赋值,所以线程安全由类初始化锁保证——任何线程首次触发类初始化时同步执行,后续读 INSTANCE 拿到已完全构造的对象。 这就是"object 是饿汉式单例"的本质,没有额外的懒加载逻辑。

companion object 稍有不同:伴生对象是类的成员,它自己的实例由编译器在宿主类的静态初始化里创建。伴生对象里可以有 val(初始化一次)、fun(成员方法)、init 块(宿主类初始化时执行)。关键点:宿主类的静态初始化会触发伴生对象的初始化,所以伴生对象里的 val 初始化时机是"类首次被使用"。 这带来一个高频问题——如果伴生对象里初始化了耗时资源(一个大 map、一个 OkHttpClient),任何一次对该类的静态访问都会先把它建起来。

@JvmStatic 的机制是:给伴生对象的函数/属性额外生成一份位于宿主类上的静态成员,内部转发到 Companion.INSTANCE 的对应成员。于是 Java 侧可以从 Foo.bar() 调用,而不用写 Foo.Companion.bar()。代价是多了一份转发方法(字节码略大)、Java 侧能看到原本想隐藏的实现。 @JvmField 同理,把伴生对象的 val 属性变成宿主类的静态字段,省掉 getter。

这些坑的正确绕法

最常见的坑是 object 里的可变状态全局共享,成为跨页面数据串号的源头。 表现是退出登录后另一个账号短暂看到上一个账号的缓存,或者某个流程的中间状态被另一个流程读到。根因是 object 的生命周期等于进程,全局唯一意味着"所有页面共享同一份可变数据",而这类状态本该有明确作用域。 修法是分工:只把无状态的工具方法与真正的全局配置放进 object;缓存、登录态、流程中间态放到有作用域的容器里(ViewModel、单例的 Repository 内部字段、或者用 StateFlow 显式暴露状态)。object 的正确用法是"无状态",一旦有状态就要问一句"该放哪个作用域"。

其次是伴生对象里做耗时初始化,拖慢类的首次访问。 表现是某个工具类的第一次调用卡住几十毫秒,页面上有可感知的延迟。根因是类初始化是同步的、只做一次,但"只做一次"不等于"便宜"。修法是把初始化改成懒加载(by lazy、或干脆把伴生对象换成 object + by lazy),或者把耗时部分移出初始化、改为显式调用的方法。

还有一个更隐蔽的坑:@JvmStatic 暴露了本想隐藏的实现,Java 侧绕过 Kotlin 的设计约束。 表现是 Kotlin 里把伴生对象当"私有工具箱",加了 @JvmStatic 让 Java 调用方便,结果 Java 侧改了调用顺序、绕过了 Kotlin 侧的前置检查。 对策:@JvmStatic 只给确实需要给 Java 用的成员加,并把它视为对外 API;其余保持 Kotlin 形态(Foo.Companion.bar() 或改为 object + @JvmStatic),让"不该被外部调用"这件事有编译期保障。

代码里见真章

看一段能直接跑的代码,把上面的机制落到具体写法上:

// 1) object:静态单例,适合无状态工具
object LogUtil {
   
    private val tag = "App"
    fun d(msg: String) {
    Log.d(tag, msg) }
}

// 2) object 里的可变状态 —— 反例
object UserHolder {
                    // 全局共享,退出登录忘记清理就串号
    var currentId: Long = 0
    var name: String = ""
}
// 正解:状态放有作用域的容器
class UserViewModel : ViewModel() {
   
    var currentId: Long = 0
    var name: String = ""
}

// 3) companion object:类内共享,初始化随宿主类首次使用
class Config {
   
    companion object {
   
        const val KEY = "config"      // const 编译为静态常量,不进字段
        @JvmField val VERSION = 3     // 真正的静态字段,Java 侧 Config.VERSION
        val defaults = HashMap<String, Any>()   // 实例字段,需走 Companion
        fun of(app: Application): Config {
    ... }
        @JvmStatic fun reset() {
    defaults.clear() }   // Java 侧 Config.reset()
    }
}

// 4) 伴生对象里做耗时初始化 —— 拖慢首次访问
// companion object {
    val http = OkHttpClient() }   // 首次访问该类即建连接池

// 5) 想同时要"object 的单例"与"懒加载",用 by lazy
object Holder {
   
    val repo: Repo by lazy {
    RepoImpl() }    // 首次访问 repo 时才建
}

// 6) Java 侧调用形态对比
// Kotlin: Foo.bar() / Foo.Companion.bar() / Foo.INSTANCE.bar()
// Java:   Config.reset()(@JvmStatic)/ Config.Companion.reset()(无注解)

这段代码值得盯三处:第一处,LogUtil(无状态,合适)与 UserHolder(有状态,反例)的对比点明 object 的使用边界;第二处,companion object 里 const val(编译期常量)、@JvmField val(真静态字段)、普通 val(需走 Companion)三者的差异;第三处,by lazy 的组合写法——想要"全局唯一 + 懒加载"时,object Holder { val repo by lazy {} } 是比伴生对象更合适的选择。

这题在面试里怎么问、怎么答

"object 和 Java 的静态类有什么区别?"答:Java 的静态类只能有静态成员,不能有实例成员;object 是一个真正的单例实例,可以有实例方法、实例状态、可以继承接口。也就是说 object 既是静态容器,也是一个对象。

"companion object 和 Java 的 static 有什么区别? "答四点:①static 成员直接属于类,companion 是一个真实的对象;②companion 可以实现接口、可以被继承(父类的伴生对象成员可被子类访问);③companion 可以有构造函数与 init 块(Java 的静态初始化块受限);④访问形态不同(Foo.Companion.x() vs Foo.x(),除非加 @JvmStatic)。

"@JvmStatic 为什么能提升性能?"先纠正一个常见误解:它不提升运行时性能。它生成的是额外的静态转发方法,多了一层调用;它的收益在调用形态与Java 侧可读性。真正的性能考虑是:静态字段(@JvmField)比属性 getter 少一层方法调用,但差别可忽略;把常量声明为 const val 会编译成编译期常量,可以内联到使用处,这才是实质性的。

"使用中遇到过什么问题?"案例一:object 里缓存了登录用户,退出登录时只清了 Activity 侧的状态,全局缓存没清;修复为把登录态移入 Repository 作用域并在登出时统一清理。案例二:某个配置类的伴生对象初始化了一个大的默认 map,首次打开某个页面明显卡顿;修复为改成 by lazy 并把构建延后到真正需要时。

再补一个工程上值得讲清的一点:选择 object、companion object、还是普通单例,判据是"谁需要它"。 需要全局唯一且与某个类强相关的工具 → 伴生对象;需要全局唯一但与任何类无关的独立组件 → object;需要作用域、测试可替换、依赖可注入 → 别用单例,用 DI 容器管理的对象。Android 上最常被滥用的是 object 承担状态持有者。 单例的隐藏依赖会让单元测试无法替换依赖,测试时只能依赖真实的全局状态,进而把测试变成"顺序敏感的集成测试"。这与前面 lateinit/by lazy 一节讲的"优先用框架与 DI 方案"是同一条工程原则。

给正在准备面试的你

把这题画成一张"字节码形态对照图":三列分别对应 object、companion object、@JvmStatic 后的 companion。 每列画出宿主类里的静态字段(private static final X INSTANCE / private static final Companion Companion + Companion.INSTANCE / 额外生成的 public static 方法),并标出"Java 侧调用形态"(X.INSTANCE.method() / Foo.Companion.method() / Foo.method())。 图下方画一条红线,写"红线以上是编译器生成的,红线以下是手写的"。这张图能直接支撑"关键在构建过程"这个判断。

再补工程案例与踩坑——应用落点是清点项目里所有 object 与伴生对象,逐个判断是否持有可变状态、是否需要作用域,持有的改为 ViewModel 或 DI 管理的对象;把伴生对象里的耗时初始化改为 by lazy;给确实需要给 Java 调用的成员加 @JvmStatic/@JvmField 并视为对外 API。

复习时别孤立刷题:sealed class——data object(Kotlin 1.9+)本质就是一个没有属性的 object,常用来作为 sealed 层次里的无数据分支。

划两句重点:object 是真单例实例,状态可变就是串号源头;companion object 随宿主类首次使用初始化,耗时初始化要改 by lazy;@JvmStatic 改的是 Java 调用形态而非性能;const val 才是编译期常量。

下一篇聊扩展函数原理:它到底是不是给类加方法——沿着今天这条主线继续往前走。


如果这篇文章对你有帮助,欢迎点赞、在看、转发三连。你的支持就是这个系列持续更新的动力。

「Android软件开发面试·从入门到精通」连载系列

上一篇:sealed-class:状态建模的利器

下一篇预告:扩展函数原理:它到底是不是给类加方法

有任何问题欢迎在评论区留言交流。

相关文章
|
17天前
|
人工智能 JSON API
全网刷屏的 Jev 模型正式开放!一手实战测评 + 保姆级教程
全网爆火的 Jev 模型是什么?有什么用?怎么使用?怎么接入 AI 编程工具?效果真的好么?傻子可懂的 Jev 保姆级实战教程 + 项目实战测评来啦
8478 24
|
16天前
|
人工智能 并行计算 PyTorch
秋叶 ComfyUI 2026 整合包 v3.2 完整部署教程:Python 3.13 + Torch 2.13 全栈升级
秋叶aaaki ComfyUI 2026年8月整合包v3.2正式发布!全面升级Python 3.13.11、PyTorch 2.13.0+cu130及ComfyUI v0.30.2,原生支持MiniMax H3、Wan 2.2、Qwen-Image-2.1等2026主流音视频/图像模型,解压即用,无需环境配置。
2817 14
|
15天前
|
人工智能 测试技术 API
最近全网爆火的 Jev 到底是什么?适合干什么、怎么用,一篇讲透!
Jev是TypeSafe AI推出的“系统一模型”,不生成文本,专做毫秒级结构化决策:Choice(多选)、Score(打分)、Noul(是非概率)。响应快193倍、成本低444倍,适合工单路由、内容审核、测试定级等高频判断场景。
2021 4
最近全网爆火的 Jev 到底是什么?适合干什么、怎么用,一篇讲透!
|
14天前
|
人工智能 编解码 并行计算
MiniMax-H3 一键整合包技术文档:8G 显存运行 AI 漫剧制作 —— 角色替换 / 动作迁移 / 文图生视频部署与调参指南
MiniMax H3 是 MiniMax 开源的全模态视频生成模型,支持文/图/音/视多条件输入,输出最高2K、15秒带双声道音频视频。本文档详述其Int8量化版在8GB显存下的本地一键部署、三段式工作流(EDIT/REPLACE/CONTINUE)、参数调优及常见问题排查。(239字)
|
10天前
|
人工智能 Linux 开发者
【2026国内使用】Codex安装过程一篇讲透(Win/Mac/Linux全支持)
Codex是OpenAI推出的AI编程智能体,可读取本地项目、理解需求并自动修改代码。支持桌面GUI、命令行(CLI)及VS Code/Cursor插件三种形态,覆盖可视化操作、终端高效开发与编辑器无缝集成场景,助开发者用自然语言驱动编码全流程。(239字)
【2026国内使用】Codex安装过程一篇讲透(Win/Mac/Linux全支持)
|
4天前
|
人工智能 JSON Linux
【全网最详细】ComfyUI使用教程:下载+本地部署+配置+工作流搭建一篇搞定(2026最新版)
ComfyUI是一款免费开源的本地AI绘图工具,采用节点式工作流设计,支持文生图、图生图、局部重绘、放大、换脸等多种功能。可离线运行,依赖显卡加速,无需联网。支持自定义流程保存与分享,插件生态丰富,适合进阶用户。(239字)
|
10天前
|
人工智能 JSON 编解码
【2026最新版】ComfyUI本地部署教程,新手也能看懂!
ComfyUI是本地运行的AI绘画工具,采用节点式工作流设计:通过拖拽连接“加载模型”“提示词编码”“采样”“解码”等模块,实现高度可控的文生图。新手推荐使用秋叶整合包,一键启动、内置模型管理与插件安装器,轻松上手。(239字)

热门文章

最新文章