ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Java三大特性:封装、继承、多态的底层原理与实战梳理

Java三大特性:封装、继承、多态的底层原理与实战梳理 做Java几年之后回头看面试题里那些“封装、继承、多态”其实不是用来背的而是写代码时最底层的三根柱子。很多人把这三件事当成入门概念但真正写项目的时候反而不知道怎么用甚至用错。封装、继承、多态这三个特性几乎决定了你写出来的类是能维护的还是只能跑一次的临时脚本。这篇文章不打算按教科书套路来我会把它们拆开揉碎讲清楚每个特性到底解决了什么问题、底层怎么运转、代码里怎么用不出来以及面试官真正想从你嘴里听到什么。无论你是刚学Java基础还是准备面试八股文或者已经写了两年代码想回头补补设计功底这篇都值得看完。1. 为什么Java把这三件事当成立身之本1.1 三大特性到底解决了什么问题先说一个很多人没想透的点封装、继承、多态不是Java发明出来的语法装饰而是软件工程里三个经典困境的答案。困境一是“改了这里崩了那里”。如果有人能随便拿到你的HashMap内部结构直接map.table[3] null你的业务逻辑瞬间就崩了。封装就是为了让调用者只能通过受控的入口操作数据内部怎么改都不影响外部。困境二是“重复代码太多”。十个类都需要同样的日志、同样的公共字段、同样的校验逻辑没有继承就得复制十遍改一个地方就要改十处。继承通过父类把公共部分收拢子类只写差异化内容。困境三是“代码越写越死”。如果业务判断全写成if (type 1) {...} else if (type 2) {...}每加一种类型就要改动原来的方法。多态允许你把变化的部分屏蔽在一个统一接口后面调用方写一次逻辑未来新增类型时不用再碰老代码。这三个困境在任何一个中大型项目里都会遇到。所以Java把这三点作为语言级约束等于强制你在设计阶段就必须考虑代码的长远结构而不是等代码烂了再重构。1.2 从面向过程到面向对象的思维切换很多新手之所以理解不了三大特性是因为思维还停留在面向过程先声明变量再写方法然后一步步调用。面向对象的做法是先抽象对象把数据和操作数据的方法绑在一起再通过对象之间的协作完成功能。举个最生活化的例子。面向过程的点外卖是你拿出手机、打开App、搜索店铺、下单、支付每一步都是独立函数数据全是全局变量。面向对象的点外卖是你创建了一个User对象它内部有余额、有地址又创建了一个Order对象它知道自己的金额和状态再创建PaymentService对象它负责完成扣款。用户不需要知道支付底层怎么对接银行只要调用paymentService.pay(user, order)就行。三大特性在这套思维里各司其职封装负责让每个对象管好自己的内部数据继承负责让类似对象之间共用能力多态负责让调用方用统一方式处理不同对象。有了这三板斧你才能写出真正可扩展、可维护的对象模型。2. 封装把不该暴露的东西藏起来2.1 封装到底在做什么封装的本质是两个动作一个是隐藏内部状态一个是暴露受控行为。用术语说是info hiding和controlled interface。你不是不给别人访问而是不给别人乱访问。最典型的案例是银行账户。假设你有一个Account类public class Account { private double balance; public void deposit(double amount) { if (amount 0) { throw new IllegalArgumentException(存款金额必须大于0); } this.balance amount; } public void withdraw(double amount) { if (amount 0) { throw new IllegalArgumentException(取款金额必须大于0); } if (amount this.balance) { throw new IllegalStateException(余额不足); } this.balance - amount; } public double getBalance() { return this.balance; } }这里的balance字段被private修饰外部无法直接赋值。如果有人把balance改成public那么任何地方都能执行account.balance -1000账户瞬间变成负数。而在真实业务里余额的变动必须经过校验、日志、流水记录等多个步骤直接暴露字段等于把这些流程全部绕开。这就是封装的核心价值约束数据变更路径。你不关心balance底层是普通double还是存在数据库里的BigDecimal只要方法签名不变内部实现随便换。这也是为什么所有Java开发规范都要求字段私有化、通过getter/setter访问——不是为了形式主义是为了给未来的改动留出空间。2.2 访问修饰符与getter/setter实战Java的访问修饰符有四个很多人记不全它们的区别面试也爱考。我习惯用“从内到外”的顺序记修饰符同类内同包内不同包子类全局private可访问不可访问不可访问不可访问默认不写可访问可访问不可访问不可访问protected可访问可访问可访问不可访问public可访问可访问可访问可访问实际项目里字段几乎全是private少量常量用public static final。protected适合那些“子类可能需要覆盖”的钩子方法但实际使用频率并不高因为很多人用继承不是真需要而是因为“方便”。再说getter/setter。有一个经常被吐槽的现象很多人给每个private字段都生成getter和setter结果封装了个寂寞外部照样可以随便改。正确的做法是getter可以无脑给setter必须想清楚。比如一个订单类public class Order { private String status; private ListOrderItem items; public void addItem(OrderItem item) { if (this.status ! null) { throw new IllegalStateException(订单已提交不能添加商品); } this.items.add(item); } public void submit() { if (this.items.isEmpty()) { throw new IllegalStateException(订单不能为空); } this.status SUBMITTED; } }这里我根本不给status提供setter外部不可能直接把订单状态改成“已支付”。状态的改变只能通过submit、cancel等方法每个方法内部都维护自己的业务规则。这才是封装的高级用法状态机交给对象自己管理而不是让每个调用方都来写一遍状态流转逻辑。2.3 不可变对象与防御性拷贝封装做到极致就是不可变对象。Java里的String、Integer、LocalDate都是不可变对象创建之后状态无法修改。不可变对象天然线程安全没有并发问题所以被广泛用在缓存、配置、多线程环境下。创建一个不可变类有几个要点public final class User { private final String name; private final int age; private final ListString tags; public User(String name, int age, ListString tags) { this.name name; this.age age; this.tags Collections.unmodifiableList(new ArrayList(tags)); } public String getName() { return name; } public int getAge() { return age; } public ListString getTags() { return tags; } }注意几个细节。第一类要final防止子类重写方法绕过不可变性。第二字段要final构造时全部赋值。第三集合字段不能用调用方传进来的原引用要重新new ArrayList()拷贝一份否则调用方后续向原列表添加元素这个“不可变”类就被污染了。第四getter返回集合时要返回不可修改视图或者再拷贝一份。这里涉及到一个非常常见的面试坑浅拷贝和深拷贝。比如上一份代码里只做了浅拷贝tags里的字符串本身是不可变的所以没问题。但如果列表里放的是可变对象就要考虑深拷贝否则外部仍然能通过user.getTags().get(0).setName(xxx)修改内部状态。防御性拷贝也是面试里问得比较深的一个点能说清楚的人并不多。3. 继承代码复用与类型体系的搭建3.1 extends背后的机制构造链、super与重写继承的语法很简单一个extends关键字但底层机制值得深究。当你new一个子类对象时Java会先调用父类构造器再调用子类构造器这个链路会一直追溯到底层Object类。所以如果父类没有无参构造器子类构造器第一行必须显式调用super(参数)否则编译直接报错。我整理一个经典的初始化顺序案例class Parent { static { System.out.println(Parent static block); } { System.out.println(Parent instance block); } public Parent() { System.out.println(Parent constructor); } } class Child extends Parent { static { System.out.println(Child static block); } { System.out.println(Child instance block); } public Child() { System.out.println(Child constructor); } } public class Main { public static void main(String[] args) { new Child(); } }输出顺序是Parent static block Child static block Parent instance block Parent constructor Child instance block Child constructor这里有一个很多面试者容易答错的关键点静态代码块只在类首次加载时执行一次所以如果main里再new Child()一次静态块不会再次打印。面试官往往通过追问这个点考察你是否真正理解类加载机制而不只是背口诀。super关键字除了调用父类构造器还可以访问父类被覆盖的方法和字段。常见用法是子类重写方法后先调用父类逻辑做通用处理再写自己的差异化逻辑。比如public class BaseService { public void doSave() { System.out.println(记录审计日志); System.out.println(执行通用校验); } } public class OrderService extends BaseService { Override public void doSave() { super.doSave(); System.out.println(执行订单特有逻辑); } }这里super.doSave()保证公共逻辑不被跳过是模板方法模式的一种基础写法人。3.2 重写和重载的区别面试问烂了的问题但真正能说透的人不多。重写override是父子类之间的事方法签名相同运行时动态绑定重载overload是同一个类内部的事方法名相同但参数列表不同编译期静态决定。重写有几条硬性约束最好记牢class Animal { protected Object getInfo() { return animal; } } class Dog extends Animal { Override public String getInfo() { return dog; } }这里子类的返回值类型String是父类Object的子类型这叫协变返回类型是Java 5之后允许的。除此之外还有几条访问权限不能比父类更严格。父类是protected子类可以提升为public但不能降为private。抛出的受检异常不能比父类更宽泛。父类不抛异常子类就不能抛受检异常。被final修饰的方法不能被重写。被static修饰的方法不能被重写只能被隐藏。重载相对宽松主要依赖参数的类型、个数、顺序与返回值无关。调用哪个重载版本是在编译期根据静态类型决定的这也是后面要说的多态坑之一。3.3 继承的坑多用组合少用继承虽然继承是三大特性之一但实际编码经验告诉我能不用继承就不用继承组合比继承更加灵活。有个很出名的原则叫“组合优于继承”英文是favor composition over inheritance。继承最大的问题在于耦合过深。父类的实现细节会泄露给子类父类一旦改动可能影响所有子类行为。比如父类在某个方法里加了一个校验逻辑本意是约束子类结果某个子类覆盖了方法并且没有调用super父类逻辑直接失效。这在大型项目里很容易出现“基类腐化”问题——父类越来越胖子类越来越脆。常见的替代方案是组合把公共能力封装成独立组件通过字段注入public class EmailSender { public void send(String message) { // 发送邮件逻辑 } } public class UserService { private final EmailSender emailSender; public UserService(EmailSender emailSender) { this.emailSender emailSender; } public void notifyUser(String email, String message) { emailSender.send(To: email , Content: message); } }这样UserService不需要继承EmailSender也能拥有发邮件能力而且以后如果想换成短信发送只要再实现一个MessageSender接口注入即可完全不影响其他逻辑。继承真正适合的场景是“is-a”关系非常稳定的领域模型。比如Dog extends Animal、Square extends Shape这种关系不会频繁变动。如果你发现自己在继承链上不断加方法、加子类八成是要重构了。4. 多态一个接口多种实现4.1 多态的底层原理动态绑定与向上转型多态在Java里体现为同一种行为在不同对象上表现不同。写代码的时候你用的是父类引用运行的时候执行的是子类方法——这个机制叫动态绑定也叫运行时绑定时。看这段代码class Animal { public void speak() { System.out.println(动物叫); } } class Dog extends Animal { Override public void speak() { System.out.println(汪汪汪); } } class Cat extends Animal { Override public void speak() { System.out.println(喵喵喵); } } public class Demo { public static void main(String[] args) { Animal animal new Dog(); animal.speak(); animal new Cat(); animal.speak(); } }两个animal.speak()调用打印出来的分别是“汪汪汪”和“喵喵喵”。关键在于变量声明类型是Animal但引用指向的实际对象是Dog或Cat。Java的JVM在运行时检查对象真实类型再确定调用哪个方法实现。这种能力背后的功臣是方法表。JVM加载类的时候会为每个类生成一个方法表记录了方法签名到实际实现地址的映射。当执行animal.speak()时JVM根据对象的实际类型查方法表找到speak的具体实现地址并调用。这就是动态绑定的底层原理也是面试官问“多态底层怎么实现”时最希望听到的回答方向之一。多态的价值不只是“能调用子类方法”而是让代码依赖抽象不依赖具体实现。比如策略模式的经典写法public interface PaymentStrategy { void pay(double amount); } public class AlipayStrategy implements PaymentStrategy { Override public void pay(double amount) { System.out.println(支付宝支付 amount 元); } } public class WechatPayStrategy implements PaymentStrategy { Override public void pay(double amount) { System.out.println(微信支付 amount 元); } } public class CheckoutService { private final PaymentStrategy strategy; public CheckoutService(PaymentStrategy strategy) { this.strategy strategy; } public void checkout(double amount) { strategy.pay(amount); } }调用方CheckoutService完全不知道具体是支付宝还是微信支付只要传入一个PaymentStrategy实现就行。以后接入银行卡支付、海外钱包支付都不需要改动CheckoutService。这就是开闭原则的体现对扩展开放对修改关闭。4.2 接口多态与泛型中的多态Java里有两种多态一种是继承多态通过重写父类方法实现另一种是接口多态通过实现接口实现。实际项目中接口多态更常用因为一个类只能继承一个父类但可以实现多个接口。接口设计的好例子是JDK里的List接口ListString list1 new ArrayList(); ListString list2 new LinkedList();第一行代码面向接口编程以后把ArrayList换成CopyOnWriteArrayList也只需要改这一行。如果你把声明写成ArrayList list new ArrayList()那后面换成别的实现就麻烦了。泛型里的多态藏着一个容易踩的坑泛型不支持协变但数组支持。举个例子String[] strings new String[3]; Object[] objects strings; // 编译通过数组是协变的 ListString strList new ArrayList(); ListObject objList strList; // 编译报错泛型是不可协变的这里ListString不能被当作ListObject使用因为Java泛型的设计目标是保证类型安全。如果你真想让一个方法接收ListString也能接收ListInteger应该用限定通配符public void printAll(List? extends Object list) { for (Object o : list) { System.out.println(o); } }这种? extends和? super的用法在很多源码和框架里出现频率很高也是面试常考的通配符上下界问题。理解“继承关系与泛型类型参数之间的不同”才算真正超出只会背答案的层次。5. 面试八股高频点与易错代码5.1 类加载与初始化顺序三大特性的面试题里类加载和初始化顺序是最容易翻车的一类。前面已经给了一个实例这里我再补充一点细节main方法所在类的静态块一定是在其他类之前先执行因为JVM要加载包含main方法的类才能启动。再举一个更复杂的场景如果父类构造器调用了被重写的方法class Parent { public Parent() { show(); } public void show() { System.out.println(Parent show); } } class Child extends Parent { private String name child; public Child() { System.out.println(Child constructor); } Override public void show() { System.out.println(Child show, name name); } } public class Main { public static void main(String[] args) { new Child(); } }输出结果是Child show, namenull Child constructor为什么show()里打印的name是null因为在调用父类构造器时子类的字段还没有初始化动态绑定的目标又是子类的show方法所以访问到的是默认值null。这是“构造器里调用可重写方法”的经典反模式实际编码时一定要避免否则你会在线上看到一堆莫名其妙的数据初始化为null的Bug。5.2 equals/hashCode与多态陷阱多态不仅体现在方法调用上还体现在一些基础类的方法上。比如equals方法如果不重写就相当于比较引用两个内容一样的对象会被判定为不相等。所以业务对象往往要重写equals和hashCode。hashCode和equals有个约定如果两个对象通过equals比较相等那么它们的hashCode必须相等反过来不要求。这个约定是为了保证hash表结构HashMap、HashSet正常工作。如果违反了你会遇到非常诡异的Bug对象明明“相等”但HashMap.containsKey()却返回false。重写equals的标准模板Override public boolean equals(Object o) { if (this o) return true; if (o null || getClass() ! o.getClass()) return false; User user (User) o; return age user.age Objects.equals(name, user.name); } Override public int hashCode() { return Objects.hash(name, age); }很多人在equals里用instanceof而不是getClass() ! o.getClass()这里有个隐藏风险。如果父类和子类都用instanceof判断可能出现父子类互相判断相等、但hashCode却不同的情况破坏对称性。比如Animal和Doganimal.equals(dog)为truedog.equals(animal)也为true但两者的hashCode可能不同。这道题在很多面试里都能炸出一批只背模板、不懂原理的人。5.3 静态方法、私有方法的“假多态”多态只适用于实例方法不适用于静态方法、私有方法和final方法。很多代码在这一点上栽过跟头class Parent { public static void info() { System.out.println(Parent info); } } class Child extends Parent { public static void info() { System.out.println(Child info); } } public class Main { public static void main(String[] args) { Parent p new Child(); p.info(); } }这里打印的是“Parent info”而不是“Child info”。因为静态方法属于类Java在编译期就根据变量的静态类型Parent决定调用Parent.info()这和实例方法的动态绑定完全不同。这个现象叫隐藏不叫重写加上Override注解会直接编译报错。同样的private方法在子类里写一个同名同参方法也只是一个同名的新方法与父类没有任何覆盖关系。理解这些边界情况能帮你避免很多“我明明重写了为什么没生效”的困扰。另外一个常见面试变体是多态与try-with-resources混在一起。比如用父类引用指向子类流对象关闭逻辑走的是哪个实现。一般只要记住“finally里close实际调用的是实例方法走动态绑定”就不会答错。6. 写代码时如何用好三大特性实操与心得6.1 日常开发中的封装落地很多人在看懂了语法之后还是不会用。我给你一个最容易上手的切入方式从“不让外部随便改我的数据”开始把类的字段全部设成private然后逐个思考哪些值需要通过方法修改。举个例子有个Order类之前状态字段是public外部代码到处都是order.status PAID一改需求所有地方都要跟着改。后来我把它改成私有提供pay()、cancel()、refund()方法所有状态流转都收拢到这几个方法里后续加状态条件、加审计日志都只需要改一个地方。封装还有一个实践叫“浅层封装”很多人会忽略方法内部的循环、判断也是不稳定的细节。比如一段代码里到处都在判断if (order.getStatus().equals(PAID))一旦状态字符串变化就要全局搜索替换。更好的做法是把这个判断封装到order.isPaid()方法里判断逻辑只写一次外部不需要知道状态用什么字符串表示。6.2 设计原则与三大特性的搭配封装、继承、多态和SOLID原则的对应关系非常明显。单一职责原则要求一个类只做一件事这本身就是封装思想里氏替换原则约束了继承关系子类必须能替换父类而不破坏行为开闭原则则依赖多态实现通过接口扩展新的行为。给你一个简单的判断标准你想保护某个字段不想让它被乱改用封装。你发现多个类有大量公共逻辑而且逻辑将来不会变化用继承。你发现同一动作有多个实现想在运行时自由切换用多态尤其是接口多态。实际开发中这三个特性往往组合使用。比如一个订单处理系统BaseOrderHandler抽象类封装了公共校验逻辑RefundHandler、CreateHandler继承它并实现各自差异调用方通过OrderHandler接口统一调用传入不同实现即可。这里封装在字段和方法里继承在代码复用上多态在最外层调用上三者形成一个完整的骨架。6.3 我的踩坑记录与最终建议写了不少年代的Java踩过的坑可以列一个小型备忘录。第一不要为了“复用”强行继承。项目的继承深度建议保持在三层以内超过三层的类很难维护。第二不要给所有字段都生成setter这一条能替你挡掉一半的Bug。第三在构造器里绝不能调用可重写的方法否则子类字段尚未初始化你会得到一堆null。第四集合字段返回时要做防御性拷贝或返回不可变视图否则内部状态被人透过getter改掉排查起来非常痛苦。第五重构时多想想能不能用接口多态代替if-else尤其是类型判断反复出现时。如果你现在刚开始学Java基础我建议你不要只停留在背诵“封装是将属性私有化提供公有的方法访问”这种层面而是去读JDK源码比如ArrayList的subList方法为什么要抛ConcurrentModificationExceptionInteger的valueOf缓存里为什么要用私有构造器。你越早把三大特性从语法知识转化成设计意识写代码的质量提升会越明显。以后遇到新需求先想清楚这个对象自己应该管好什么再想它和谁有共同点最后如果动作可能有多种实现就果断把行为甩到接口后面去。
返回列表