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:状态建模的利器
下一篇预告:扩展函数原理:它到底是不是给类加方法
有任何问题欢迎在评论区留言交流。