坐在美团的面试间里,面试官把简历往桌上一推,开口第一句就不是"介绍一下项目",而是:"面向对象三大特性,说说看。"
你心里一松。这题小学就背过。封装、继承、多态,嘴里顺溜地往外蹦。
然后面试官笑了笑:"封装在 JVM 层面到底封了什么?多态的时候,字段会不会跟着一起变?"
空气突然安静。
说白了,背概念谁都会,大厂面试官要的从来不是定义。他要的是——你知不知道这些特性在虚拟机里是怎么落地的。今天这篇,我就把这场对话原样复盘一遍,帮你把"会背"变成"真懂"。
一、封装:不是加个 private 就完事
面试官:先说封装吧,你觉得它解决什么问题?
候选人(标准答法):隐藏内部实现细节,只暴露必要的接口,降低耦合。
这话没错,但太薄。
深度解析:封装的本质是"边界控制"。Java 里靠访问修饰符划边界:private只在类内可见,protected跨包子类可见,public全局。
举个 Android 里的真例子。我们写ViewModel持有 UI 状态,如果把MutableLiveData直接public暴露出去,外部谁都能setValue,状态就失控了。正确做法是只暴露不可变的LiveData:
class HomeViewModel : ViewModel() { // 内部可变,外部拿不到这个引用 private val _count = MutableLiveData(0) // 对外只给只读视图,调用方无法篡改 val count: LiveData<Int> get() = _count fun inc() { _count.value = (_count.value ?: 0) + 1 } }普通答法 vs 高分答法:
- 普通答法:封装就是 private 字段 + getter/setter,保护数据安全。
- 高分答法:封装保护的是"业务不变式"(invariant)。getter/setter 不是必选项,关键是不让外部破坏对象的一致性。我会补一句:Kotlin 的
val/var配合private set比 Java 的 getter 更克制,比如var token: String = "" private set,外部能读不能改,这才是现代封装。
二、继承:单继承的代价与方法重写
面试官:继承呢?Java 为什么只支持单继承?
候选人:避免多继承的歧义,比如两个父类有同名方法,子类不知道调哪个。
深度解析:这就引到了核心问答——重载(overload)和重写(override)分别在编译期还是运行期确定?
先说结论:重载是编译期静态绑定,重写是运行期动态分派。
重载看的是"编译时的方法签名"(方法名 + 参数类型/个数/顺序),跟对象实际类型无关。重写看的是"运行时的对象实际类型",靠 JVM 的方法表(vtable)查。
open class Animal { open fun sound() = "..." // 可被重写 fun eat(food: String) = "吃$food" // 重载基准 fun eat(food: Int) = "吃$food份" // 重载:参数类型不同,编译期定 } class Dog : Animal() { override fun sound() = "汪汪" // 重写:运行期动态分派 }JVM 层面,Dog会有一张方法表,里面sound的槽位指向Dog.sound的实现。父类引用val a: Animal = Dog()调a.sound(),字节码是invokevirtual,运行时查a的实际类型Dog的方法表——所以输出"汪汪"。
普通答法 vs 高分答法:
- 普通:重写是子类改父类方法,运行时生效。
- 高分:我会点出
invokevirtual的动态分派机制,以及"静态类型决定能调哪些方法,实际类型决定调到哪"。顺带提一句:私有方法、final 方法、构造器是静态绑定(invokespecial/invokestatic),不走 vtable,所以构造器里调可重写方法是个经典坑——此时子类字段还没初始化。
三、多态:JVM 层到底怎么实现的
面试官:那你说的多态,虚拟机层面到底干了啥?
这是本篇第一道核心问答。
深度解析:多态 = 父类引用指向子类对象 + 运行期动态分派。JVM 的实现靠两样东西:方法表(vtable)和invokevirtual 指令。
每个类在加载时,方法区会存一张方法表,记录该类所有虚方法的入口地址。子类的方法表继承父类布局,被重写的槽位地址覆盖成子类实现,新增方法追加在后面。
调用invokevirtual时:
1. 从操作数栈弹出对象引用obj;
2. 查obj实际类型的方法表;
3. 按编译期解析出的"方法符号引用"定位槽位;
4. 跳到该槽位的实际地址执行。
普通答法 vs 高分答法:
- 普通:多态就是父类引用调子类方法。
- 高分:我会强调"只有方法有多态,字段没有"。这正是面试官最爱追问的下一句——
四、实战追问:多态场景下字段会动态绑定吗?
面试官:那如果父类有个字段name,子类也定义了name,a.name取的是哪个?
答案很反直觉:取父类的。字段不参与多态,它是静态绑定,由编译期的静态类型决定。
open class Base { val tag = "base" } class Sub : Base() { val tag = "sub" } // 隐藏而非重写父类字段 fun main() { val s: Base = Sub() println(s.tag) // 输出 "base"!字段看静态类型 Base }字节码里字段访问是getfield,直接按声明的字段偏移取,根本不查 vtable。所以子类那个tag只是字段隐藏(shadowing),不是重写,多态对它无效。
工程实战:这坑在项目里真实出现过。我们有一个基类BaseRequest有val timeout = 30,子类想改成 60,结果网络层用父类引用调用,永远走 30,请求频繁超时。改成open val+override才解决。
高分答法补充:Kotlin 的open val重写在字节码层面其实是生成了getter方法(getTag()),走的是方法分派,所以能多态;而直接访问val字段在 Kotlin 里其实也是调 getter,但上面那个子类"重新声明 val"没加override时,父类的 getter 没被覆盖,所以仍取父类值。这点讲清楚,面试官基本就点头了。
五、开放追问:接口默认方法与菱形问题
面试官:Java 8 加了 default method,如果俩接口都有同名默认方法,类同时实现俩,调哪个?
候选人:编译报错?
深度解析:不会编译报错,但必须显式重写来消除歧义,否则编译不通过——这正是 Java 用"单继承 + 接口"规避菱形继承后,default method 重新引入的小尾巴。
interface A { fun foo() = println("A") } interface B { fun foo() = println("B") } class C : A, B { // 不重写 foo 直接报错 override fun foo() { // 必须显式指定 A.super.foo() // 用 super 限定选 A 的实现 } }规则优先级:类自己的实现 > 接口中显式重写的 > 父接口的 default。如果没类实现也没重写,编译器无法决定选 A 还是 B,强制你重写。
高分答法:我会把话题拉回设计层面——default method 的初衷是给已有接口(如List)无痛加forEach、stream而不破坏旧实现,但它破坏了"接口无实现"的纯粹性。我的看法是:业务代码里尽量别用 default method 塞逻辑,它让接口的契约变模糊。需要默认行为就用抽象类,需要多继承能力才上接口。
收尾:我的几点实战建议
写到这里,回头看那道"人人会背"的题。封装、继承、多态从来不是三个孤立名词,它们在 JVM 里是一整套连贯机制:访问修饰符划边界、vtable 支撑动态分派、字段静态绑定是例外。
几个面试 Tips(具体到怎么答):
- 被问"三大特性"时,别急着背定义,先抛一句"它们在 JVM 层是这么落地的",直接把对话拉到源码层,区分度立刻出来。
- 重写 vs 重载,务必点明"编译期 vs 运行期",这是硬分界。
- 主动提"字段不多态、只有方法多态",这反常识点最容易让面试官眼前一亮。
- 说到继承,顺带带一句"构造器里别调可重写方法",展示你踩过坑。
评论区聊聊:你在面试里被"面向对象"追问到最尴尬的一句是啥?我评论区翻牌。
---
点赞、在看、转发,三连是对我最大的支持!
「Android 大厂面经·从入门到精通」连载系列
上一篇:暂无(系列开篇)
下一篇预告:第002篇 字节跳动·应届 Android 面试——String 为什么设计成不可变
评论区聊聊:你面试遇到过最难的 Android 问题是哪道?
关于本系列:「Android 大厂面经·从入门到精通」是360篇连载系列,覆盖美团、字节跳动、阿里巴巴、快手、百度、京东、华为、小米等30+家公司从 Java 基础到架构师终面的真实面试内容。本篇属于阶段1·入门篇——Java 语言基础。