面向对象三大特性是什么

候选人小李坐在美团 P6 面试间,面试官翻开简历上"熟练掌握 Java 面向对象编程",直接开炮:

"面向对象三大特性说一下。"

小李脱口而出:"封装、继承、多态。"

面试官点点头:"多态怎么实现的?"

小李开始背书:"通过方法重写和向上转型..."

面试官打断:"我问的是底层怎么实现的。你知道虚方法表吗?"

小李愣了三秒,开始擦汗。

【面试官心理】 三大特性本身不难,但我问"底层怎么实现",是在筛选两类候选人:一类背过八股,能说概念;另一类真正看过 JVM 规范或字节码,能说原理。能说出"虚方法表"、"动态分派"的,基本确认是 P6+。

一、封装 🔴

1.1 问题拆解

封装是把数据(属性)和操作数据的方法(行为)包装在一起,对外只暴露必要的接口,隐藏内部实现细节。

追问链:

  • 第一层:为什么要封装?
  • 第二层:private/protected/public/默认 有什么区别?
  • 第三层:封装破坏的场景是什么?(反射、序列化)

1.2 ❌ 错误示范

候选人原话:"封装就是用 private 修饰属性,然后写 getter 和 setter。"

问题诊断

  • 把"实现方式"当成"概念本质"
  • 完全没有说明封装的目的和价值
  • 对访问修饰符的理解停留在语法层

面试官内心 OS:"这个候选人只知道写 get/set,没有理解封装为什么存在。"

1.3 标准回答

P5 版本

封装是隐藏对象的内部状态和实现细节,只通过公开的接口与外界交互。核心是访问控制,private 属性配合 getter/setter 是最常见的实现。

P6 版本

封装的本质是「最小知识原则」。通过访问修饰符控制暴露范围:

  • private:仅本类可见
  • 默认(package-private):同包可见
  • protected:同包 + 子类可见
  • public:所有类可见

封装的价值在于降低耦合,修改内部实现不影响外部调用方。

P7 版本(加分点):

封装并不只是 getter/setter,还体现在:

  1. 接口设计:暴露行为而非状态,如 account.deposit(100) 而非 account.setBalance(account.getBalance() + 100)
  2. 不可变对象:所有字段 private final,无 setter,如 String
  3. 防御性拷贝:避免外部通过引用修改内部状态
💡

能说出"暴露行为而非状态"和"防御性拷贝"的候选人,面试官会认为有一定的设计意识,直接加分。

1.4 追问升级

面试官追问:"反射能破坏封装吗?"

// 反射访问 private 字段
Field field = obj.getClass().getDeclaredField("secret");
field.setAccessible(true); // 绕过访问控制
field.get(obj);

正确回答:能,setAccessible(true) 可以绕过 Java 的访问控制机制。这是反射的强大之处,也是安全风险之一。Java 9 模块系统引入后,对跨模块的反射访问进行了限制。

二、继承 🔴

2.1 问题拆解

继承允许子类复用父类的属性和方法,实现代码复用和类型层次化。

追问链:

  • 第一层:Java 为什么不支持多继承?
  • 第二层:接口与抽象类的区别?
  • 第三层:继承会带来什么问题?(菱形问题、脆弱基类问题)

2.2 ❌ 错误示范

候选人原话:"继承就是 extends 关键字,子类继承父类的属性和方法,实现代码复用。"

问题诊断

  • 只说了语法,没说设计原则
  • 没有提到继承的缺点
  • 无法区分继承和组合的适用场景

2.3 标准回答

Java 为什么只支持单继承?

多继承会导致「菱形问题(Diamond Problem)」:

    A (method foo)
   / \
  B   C  (都重写了 foo)
   \ /
    D    (继承 B 和 C,foo 应该调用哪个?)

Java 用接口的多实现来替代多继承,但接口方法冲突时由编译器强制要求实现类显式解决。

继承的缺点——脆弱基类问题

父类修改方法实现,可能导致子类行为异常。经典案例:

// 父类
class Counter {
    protected int count = 0;

    public void add(int n) {
        count += n;
    }

    public void addAll(int[] arr) {
        for (int v : arr) {
            add(v); // 调用 add
        }
    }
}

// 子类——统计 add 调用次数
class LoggingCounter extends Counter {
    private int callCount = 0;

    @Override
    public void add(int n) {
        callCount++;
        super.add(n);
    }
}

// 问题:
LoggingCounter lc = new LoggingCounter();
lc.addAll(new int[]{1, 2, 3}); // callCount = 3(正确)
// 但如果父类重构 addAll 改成直接操作 count,callCount = 0(bug!)
⚠️

继承是强耦合关系,优先使用组合(Composition over Inheritance)。能说出这一点的候选人,面试官会认为有工程意识。

【面试官心理】 我问继承,真正在考的是候选人有没有踩过坑。能背出"Java 单继承"的人很多,能说出"脆弱基类"和"组合优于继承"的才是有项目积累的人。

三、多态 🔴

3.1 多态的底层实现

多态是最容易背但最难真正理解的特性。

Animal animal = new Dog(); // 向上转型
animal.speak();            // 运行时调用 Dog 的 speak(),而非 Animal 的

为什么能做到运行时才决定调用哪个方法?

这是 JVM 的动态分派(Dynamic Dispatch)机制,底层基于虚方法表(vtable)

3.2 虚方法表详解

JVM 在类加载时,为每个类创建一张虚方法表(方法区中):

Animal 的 vtable:
┌──────────┬───────────────────────────────────┐
│  方法签名 │  指向的实际方法入口                │
├──────────┼───────────────────────────────────┤
│  speak() │  -> Animal.speak()                 │
│  eat()   │  -> Animal.eat()                   │
└──────────┴───────────────────────────────────┘

Dog 的 vtable:
┌──────────┬───────────────────────────────────┐
│  方法签名 │  指向的实际方法入口                │
├──────────┼───────────────────────────────────┤
│  speak() │  -> Dog.speak()    ← 覆盖了父类     │
│  eat()   │  -> Animal.eat()   ← 继承父类       │
└──────────┴───────────────────────────────────┘

当执行 animal.speak() 时:

  1. JVM 查找 animal 对象的实际类型(运行时是 Dog
  2. Dog 的 vtable 中找到 speak() 对应的入口
  3. 调用 Dog.speak()

这就是运行时多态的本质:方法的具体调用在运行时才确定。

3.3 静态分派 vs 动态分派

分类触发时机典型场景例子
静态分派编译期方法重载(Overload)print(String) vs print(int)
动态分派运行期方法重写(Override)父类引用调子类方法
// 静态分派示例(编译期就确定了调用哪个 print)
void print(String s) { System.out.println("String: " + s); }
void print(int i) { System.out.println("int: " + i); }

print("hello"); // 编译期确定调 print(String)
print(1);       // 编译期确定调 print(int)

// 动态分派示例(运行期才确定调哪个 speak)
Animal a = new Dog();
a.speak(); // 运行期确定调 Dog.speak()
💡

能说出"虚方法表"和"静态分派/动态分派"的候选人,面试官会认为真正看过 JVM 规范,P6+ 水准。

3.4 多态的局限性

Animal a = new Dog();
a.speak();   // ✅ 多态,调 Dog.speak()

// ❌ 下面两种情况没有多态
// 1. 字段不参与多态(静态绑定)
System.out.println(a.type); // 输出 Animal.type,而非 Dog.type

// 2. static 方法不参与多态(类方法,不走 vtable)
a.staticMethod(); // 调用 Animal.staticMethod(),而非 Dog.staticMethod()

【面试官心理】 追问"字段和 static 方法是否参与多态",能把候选人对多态的理解逼到极限。95% 的人只知道实例方法多态,能说出字段和 static 方法例外情况的,才是真正读懂过 JVM 规范的。

四、生产避坑

4.1 多态导致的空指针风险

// 父类构造器调用了被子类重写的方法
class Parent {
    Parent() {
        init(); // 危险!
    }
    void init() { System.out.println("Parent init"); }
}

class Child extends Parent {
    private String name = "child"; // 字段初始化在构造器之后!

    @Override
    void init() {
        System.out.println(name.length()); // name 是 null!NPE!
    }
}

// 执行 new Child() 时:
// 1. 调用 Child() 构造器,先执行 super()
// 2. super() 调用 init(),多态触发 Child.init()
// 3. 此时 name 还未初始化(是 null)→ NPE
⚠️

禁止在父类构造器中调用可被子类重写的方法。这是多态的经典陷阱,生产中真实发生过。

4.2 继承滥用的生产教训

有一段业务代码,把 ArrayList 作为父类,通过继承添加"只允许存 User 对象"的限制:

// 错误做法
class UserList extends ArrayList<User> {
    // 重写 add 做类型检查...
}

这种做法导致:

  • 任何 ArrayList 的修改都可能影响 UserList(脆弱基类)
  • Collections.sort 等工具方法可能绕过类型检查

正确做法:组合,UserList 内部持有一个 List<User>,暴露有限接口。

五、P5/P6/P7 差距对比

级别考察重点期望回答
P5能说出三个特性及基本概念封装隐藏细节、继承复用代码、多态统一接口
P6能说出底层原理及缺点虚方法表、脆弱基类问题、静态/动态分派区分
P7能做工程权衡何时用继承、何时用组合、不可变对象设计