开篇这道题为什么会出现在你的面试清单里先说结论Java 面向对象设计题不是考你背得出“三大特性”的定义而是考你在一个具体问题面前能不能用类、接口、抽象类、组合这些工具把现实世界的业务规则干净地映射成代码结构。我带过的不少候选人基础概念倒背如流什么封装继承多态、重载重写区别张口就来。但一到“请你设计一个书店的订单系统”这种题就开始抓瞎——要么类设计得像个上帝类一个类里塞了十几个方法要么完全不懂接口和抽象类该怎么取舍写出来的代码一改就崩。这背后缺的不是语法知识而是面向对象设计的“手感”。这篇文章就围绕一道典型的 Java 面向对象设计题展开我尽量把出题人想考察的点、答题时的思路轨迹、以及实际落地时的代码细节都拆开揉碎来讲。不管你是准备校招面试还是工作中要接手一个需要重新梳理结构的旧系统这篇应该都能给你一些能直接用上的东西。先说清楚这篇会覆盖的范围类的职责划分、封装与访问控制的实际用法、继承与组合的决策、接口和抽象类的选型、以及如何通过设计原则让代码在需求变化时不至于崩盘。这些都是面试设计题的高频考察区也是日常写业务代码时最容易走弯路的地方。1. 面试官拿到一道设计题他在等你说什么1.1 一个典型的题目长什么样这道“Java 面向对象设计题3”我很熟悉这类编号的来历——它大概率来自某份面试题库或者课程设计清单里的第三题。典型形态是这样的请设计一个咖啡点单系统支持多种饮品美式、拿铁、摩卡每种饮品可以加不同配料糖、奶、糖浆需要计算最终价格并且能打印订单详情。你觉得简单我见过大多数人的第一版代码是这样的一个Coffee类里面一堆布尔字段hasMilk、hasSugar、hasSyrup再加一个calculatePrice()里写三层 if-else。功能能跑但那不是设计——那是用面向对象语言写面向过程代码。出题人想看的东西其实很清楚你有没有把“变化的部分”抽象出来的意识你能不能给类分配合适的职责你的代码在“新增一种饮品”“新增一种配料”这种需求面前是开闭的还是要改原代码。这背后对应的就是面向对象设计的几个核心能力点抽象建模、接口/抽象类设计、以及设计原则的运用。1.2 大部分人的误区把“能跑”当成“会设计”在拆解这道题之前我先说一个观察。很多人做设计题时脑子里只有一个总目标程序跑起来结果正确。这个标准太低低到无法区分一个实习生和一个高级工程师的差别。真正的设计题考察的是“你的代码在需求变化时改起来有多痛”。比如上面这个咖啡系统出题人会在你写完第一版后追加需求“新增小杯/中杯/大杯价格不同”或者“加两份糖浆”。如果你第一版用一堆布尔字段加 if-else 堆出来的应付逻辑这时候就得动原来的代码——这本身就是设计失败的征兆。我自己评判一份设计题答案就看三个问题新增需求时需不需要改旧代码每个类是否只有一个明确的“为什么变化”的理由类与类之间的依赖方向是否自然。这三个问题你能当场说清楚这道题基本就稳了。1.3 拿到题后的第一件事别急着写类来我们把这道咖啡机的题当例题走一遍。我的习惯是拿到题目后先花三到五分钟在草稿纸上把业务规则列一遍把“名词”和“动词”分别圈出来。名词大概率是类或属性动词大概率是方法。名词饮品美式、拿铁、摩卡、配料糖、奶、糖浆、价格、订单明细动词点单、加配料、计算价格、打印详情有了这个清单第二步是找出“会变化的部分”。在这个系统里什么最容易变饮品种类会变配料种类会变计价规则也可能变。有了这两步类的雏形就出来了。我在带人过这种题时反复强调设计题的第一产出物不是代码而是你的建模思路。你嘴里能说出来“我先把饮品定义为一个抽象的概念因为具体饮品种类太多了但我希望新增一种饮品的成本足够低”面试官在听到前半句的时候就知道你会做了。2. 拆解核心设计从一坨代码到一个可扩展的模型2.1 第一版设计从抽象类开始回到咖啡系统。我的第一版设计通常是这样public abstract class Beverage { protected String description Unknown Beverage; public String getDescription() { return description; } public abstract double cost(); }Beverage是所有饮品的抽象父类定义了两个核心方法getDescription()用于获取描述cost()用于计算价格前者是公共逻辑后者留给子类实现。然后让具体饮品继承它public class Espresso extends Beverage { public Espresso() { description Espresso; } Override public double cost() { return 1.99; } } public class HouseBlend extends Beverage { public HouseBlend() { description House Blend Coffee; } Override public double cost() { return 0.89; } }到这里新增一种饮品就是新增一个子类不改动任何旧代码。已经体现了开闭原则的一部分。但问题来了——配料怎么处理2.2 配料设计抛出的经典陷阱你第一时间想到的是不是继续用继承比如写个MilkEspresso、SugarEspresso、MilkHouseBlend……我见过真实面试现场有人这么干而且越写越兴奋因为看起来每个类都很简单。直到面试官追问“如果我要加十种配料呢”十种配料意味着多少种组合2 的 10 次方1024 个类。你不可能为每种组合都写一个子类。这就是继承爆炸Class Explosion问题。这里其实就是《Head First 设计模式》里那个著名的星巴克例子用继承处理“叠加”行为必然导致类数量爆炸。解法是装饰者模式——但很多背了设计模式名字却不知道什么时候用的人在这里就卡住了。装饰者模式的核心是让配料自己也包裹一层“Beverage”外层包装叠在内层之上cost()逐层累加。我用代码示意核心逻辑public abstract class CondimentDecorator extends Beverage { public abstract String getDescription(); } public class Milk extends CondimentDecorator { private Beverage beverage; public Milk(Beverage beverage) { this.beverage beverage; } Override public String getDescription() { return beverage.getDescription() , Milk; } Override public double cost() { return beverage.cost() 0.10; } } public class Mocha extends CondimentDecorator { private Beverage beverage; public Mocha(Beverage beverage) { this.beverage beverage; } Override public String getDescription() { return beverage.getDescription() , Mocha; } Override public double cost() { return beverage.cost() 0.20; } }点单的时候这样组装Beverage beverage new Espresso(); beverage new Mocha(beverage); beverage new Milk(beverage); beverage new Milk(beverage); // 双份奶你看双层 Mocha 加奶就是一个对象套一个对象cost()从最内层往外逐层叠加。新增一种配料就是新增一个装饰器类现有代码一行都不用改。这个扩展性是那 1024 个子类的方案完全做不到的。2.3 太依赖继承的第一课请在真正写代码前算一算组合数量上面这个例子其实不是一个复杂的设计但它在面试和实际项目中反复出现。我经常跟人讲当你发现需要用一个继承体系表达“A 有 B 又有 C 还有 D 的变化组合”时先停下来算一下可能的类数量。如果是个天文数字那一定是用错了方向。组合优于继承这句话背三遍不如做一次这种运算来得刻骨铭心。面试现场如果你能在讲解时顺手把“组合数量是 2 的 n 次方”算出来面试官立刻会觉得你不是背答案而是真正理解了这个设计的动机。这是一个很加分的小细节。3. 深入封装访问控制和不可变性的实际讲究3.1 字段真的能直接设成 public 吗很多初学者写类的时候字段一律默认权限或者 public。在功能上没错但在设计题里这是硬伤。封装性的本质在于对象对自己的状态负责外部不能绕过对象的内部约束直接改数据。我来说一个很真实的例子。假设你的订单类里有个总价字段totalPrice如果直接 public那么任何代码都能order.totalPrice 0把总价清零业务规则完全被绕过。正确做法是把字段设为private所有对外修改都走方法在方法里做校验。比如public class Order { private ListBeverage items new ArrayList(); private double totalPrice; public void addItem(Beverage beverage) { // 可以在这里增加非空校验、库存校验等逻辑 if (beverage null) { throw new IllegalArgumentException(饮品不能为空); } items.add(beverage); totalPrice beverage.cost(); } public double getTotalPrice() { return totalPrice; } }这样外部拿到Order对象只能通过addItem()往里面加饮品想改价格没门。封装不是把字段藏起来那么简单而是“我把状态的管理权留在自己手里”。3.2 可变对象引用一个最容易脏的细节再往深了说。假设你设计了一个Customer类里面有个ListString的地址列表别急着把这个字段直接暴露 getter。如果 getter 直接返回内部列表引用外部代码就能拿到引用后clear()清空所有地址——这等于封装形同虚设。正确做法是返回不可变视图或深拷贝public class Customer { private final ListString addresses new ArrayList(); public void addAddress(String address) { addresses.add(address); } // 方法一返回不可变视图 public ListString getAddresses() { return Collections.unmodifiableList(addresses); } // 方法二返回深拷贝如果视图不够用 public ListString getAddressesCopy() { return new ArrayList(addresses); } }设计题里如果你能做到这一步自然会让面试官注意到你不只是懂语法层而是真的懂“保护对象边界”这套设计理念。3.3 深拷贝还是浅拷贝这次是真的有讲究说到拷贝搜索热词里有个“java对象深度拷贝”这确实是设计题的延伸考点。我来用一句话说清楚这两个概念浅拷贝复制对象本身但内部引用类型的字段还指向同一个对象深拷贝复制对象本身同时递归复制内部所有引用类型的字段什么时候用深拷贝如果你的对象内部持有可变对象作为状态而你又想把这个对象安全地传出去比如传给另一个线程处理不想让外部改动影响到内部状态那就要深拷贝。设计题里常见的坑是面试官问“你返回的这份列表外部改了会影响内部吗”你回答“不会”结果代码返回了直接引用——这就是灵活运用深浅拷贝的区别所在。实现上可以用序列化方式做深拷贝或者逐层手动复制也可以借助 JSON 序列化再反序列化。我这里给一个基于序列化的示例public static T T deepCopy(T object) throws Exception { ByteArrayOutputStream bos new ByteArrayOutputStream(); ObjectOutputStream oos new ObjectOutputStream(bos); oos.writeObject(object); oos.flush(); ByteArrayInputStream bis new ByteArrayInputStream(bos.toByteArray()); ObjectInputStream ois new ObjectInputStream(bis); return (T) ois.readObject(); }但注意这个方案要求所有涉及的类都实现Serializable。如果不能改类还是老老实实手写复制逻辑。4. 接口、抽象类、组合选型的决策逻辑4.1 接口和抽象类的选择标准很多 Java 基础面试题都会问“接口和抽象类的区别”。常规答法是语法上的几个点但设计题里的考察点在于你怎么决定什么时候用接口、什么时候用抽象类。我自己给了一条判断主线先问“多个子类之间是否存在‘模板性’的公共逻辑”如果有抽象类就是天然归宿可以把公共逻辑写在抽象类的方法体里子类只补充差异部分如果只是定义了一组行为规范没有也不应该有公共逻辑那就用接口。还用咖啡系统举例Beverage可以是抽象类因为所有饮品都有getDescription()这种共性描述逻辑cost()虽然不同但语义上也是统一的“应该存在”。而如果要抽象“可被加热”这个行为——微波炉加热咖啡、加热三明治——那就完全是接口的活因为咖啡和三明治没有任何公共实现逻辑但都需要有heat()这个行为。public interface Heatable { void heat(); }所以我把决策思路总结成一张表考虑维度用抽象类用接口子类之间的公共代码已经有需要复用没有或不需要多实现能力单继承限制一个类只能继承一个抽象类一个类可以实现多个接口语义关系“is-a”的强关联“can-do”的能力契约后续扩展方向需要稳定且共享基类逻辑重点在于统一外部行为规范这张表不是死规则但在面试时能展示你的判断逻辑比单纯背区别强得多。4.2 用代码演示接口的典型场景再举一个接口的设计题例子。面试中经常出现这类题“一个系统里有短信通知、邮件通知、App 推送通知请设计通知模块的接口。”public interface Notifier { void send(String recipient, String message); } public class EmailNotifier implements Notifier { Override public void send(String recipient, String message) { // 发送电子邮件 } } public class SmsNotifier implements Notifier { Override public void send(String recipient, String message) { // 发送短信 } } public class AppPushNotifier implements Notifier { Override public void send(String recipient, String message) { // 发送App推送 } }这段代码的意义在于调用方完全不需要关心你发的是邮件还是短信只依赖Notifier接口就能统一处理。将来要加一个钉钉通知写一个DingTalkNotifier实现Notifier注入进去就行。这就是面向接口编程。4.3 组合优先于继承这句话的实操含义面试题里“组合优于继承”几乎必考。它的核心逻辑是继承是一种静态的、编译期就绑定死的复用方式子类和父类强耦合在一起而组合是动态的、运行期可以替换的复用方式。举个例子。假设你有一个Vehicle类下面有Car、Truck如果你用继承来表达“汽车可以自动驾驶”这种能力写一个AutonomousDrivingCar extends Car那以后要“卡车也自动驾驶”又得写一个AutonomousDrivingTruck extends Truck——继承树越来越深。如果换成组合把“驾驶模式”作为一个可替换的组件public class Vehicle { private DriveBehavior driveBehavior; public void setDriveBehavior(DriveBehavior driveBehavior) { this.driveBehavior driveBehavior; } public void performDrive() { driveBehavior.drive(); } } public interface DriveBehavior { void drive(); } public class ManualDrive implements DriveBehavior { Override public void drive() { // 手动驾驶逻辑 } } public class AutonomousDrive implements DriveBehavior { Override public void drive() { // 自动驾驶逻辑 } }Car 和 Truck 只要各自持有不同的DriveBehavior就能实现自动驾驶或手动驾驶的切换。这就是把“变化点”封装成可以插拔的组件。我始终认为“组合优于继承”不是一句口号而是你在写代码时只要发现继承层级超过两层就要停下来问自己是不是该用组合了。5. 经典面试场景实录从需求变化到方案演进5.1 真实题目推演书店的订单价格计算把上面讲的能力点串起来我完整推演一道设计题这道题在面试中出现频率极高。题一个书店系统支持纸质书和电子书。纸质书按本计价电子书按本计价但有 VIP 会员折扣。请设计这个计价模块要求后续能支持促销满减。第一反应切分职责Book抽象类或接口描述书的基本信息PaperBook与EBook两个具体类型PricingStrategy接口封装计价策略VIP 折扣、满减促销都实现PricingStrategy代码草图public abstract class Book { protected String title; protected double basePrice; public abstract double getPrice(); } public class PaperBook extends Book { Override public double getPrice() { return basePrice; } } public class EBook extends Book { Override public double getPrice() { return basePrice; } }计价策略public interface PricingStrategy { double calculate(Book book); } public class VipDiscountStrategy implements PricingStrategy { private static final double VIP_DISCOUNT_RATE 0.85; Override public double calculate(Book book) { return book.getPrice() * VIP_DISCOUNT_RATE; } } public class PromoFullReductionStrategy implements PricingStrategy { private double threshold; private double reduction; public PromoFullReductionStrategy(double threshold, double reduction) { this.threshold threshold; this.reduction reduction; } Override public double calculate(Book book) { // 满减的具体逻辑需要结合订单总额这里只是示意 return book.getPrice(); } }然后给订单加上策略选择public class Order { private ListBook books new ArrayList(); private PricingStrategy pricingStrategy; public void addBook(Book book) { books.add(book); } public void setPricingStrategy(PricingStrategy strategy) { this.pricingStrategy strategy; } public double getTotal() { double total 0.0; for (Book book : books) { total pricingStrategy ! null ? pricingStrategy.calculate(book) : book.getPrice(); } return total; } }这个设计的亮点在哪里电商的玩法反正翻来覆去变今天是 VIP 折扣明天是满 100 减 20后天是限时秒杀。策略模式把每一种玩法封装成独立的类新增玩法等于新增类不改旧代码开闭原则续上。5.2 这个方案在面试中会怎么被追问如果我坐在面试官的位置拿到这个答案后会持续加码“如果你的 VIP 折扣和满减要叠加怎么处理”——连着用多个策略做一个CompositePricingStrategy。“如果促销策略在订单生成后要修改你的对象是可变的怎么保证并发安全”——这里就牵出不可变对象和线程安全字段尽可能final或者策略对象本身做成无状态。“如果不同用户角色普通、会员、超级会员折扣不同你会怎么扩展”——策略接口不变各自实现类即可。但如果折扣规则特别复杂还可以考虑策略工厂来创建不同策略。说实话能把第一问做到位已经是不错的水平。第二问和第三问如果也能接住那说明你面对“对象如何协作、如何分布职责”这类设计题是真的能思考的而不是靠背模板。5.3 动态代理和装饰者从这里冒出来的延伸考点搜索热词里出现“java动态代理”和“invocationhandler()”这也是面向对象设计题的延伸话题。动态代理常用的场景是给一个接口实现加一层通用的“横切逻辑”比如日志、权限校验、事务控制。从设计角度说动态代理和装饰者解决的是同一个问题的两种实现方式在不修改原有类代码的前提下为对象附加行为。装饰者是你在编译期手动包裹对象动态代理是运行期生成代理类。public class LoggingInvocationHandler implements InvocationHandler { private final Object target; public LoggingInvocationHandler(Object target) { this.target target; } Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { System.out.println(调用方法: method.getName()); Object result method.invoke(target, args); System.out.println(调用结束); return result; } }然后在需要的地方通过Proxy.newProxyInstance()生成代理对象。面试中如果能把“动态代理可以给代理对象加行为”和“这正是 AOP 的底层实现原理”串起来讲会显得你的知识面是一整条线的而不是碎片。6. 常见问题与排查技巧实录6.1 设计题里免不了踩的坑我在帮别人 review 设计题答案时几乎每次都能碰到下面几个高频问题。整理成一张速查表常见问题具体表现建议解决方式万能类一个类里塞了所有业务逻辑方法超过8个职责混乱先按名词拆类一个类的名字必须能一句话说清楚职责滥用继承为了省几行代码让子类继承不相关的父类继承前先判断是不是真的 is-a 关系否则用组合暴露内部状态getter 直接返回内部 List 或 Map 引用返回不可变视图或用深拷贝忽视接口所有类都是实体类行为散落在外部逻辑中抽出接口面向接口编程把变化点封装成策略忽略边界条件方法里没有非空/业务规则校验在对外方法入口处增加校验逻辑6.2 一个差点翻车的教训策略对象的有状态设计真实开发里我踩过一次很痛的坑。当时做一个营销系统为了省事把策略对象做成了有状态——策略类内部保存了上次计算的值作为缓存字段。结果在高并发场景下两个线程共用同一个策略对象A 线程刚写入缓存B 线程读到了计算全部错乱。那段代码排了很久的错最后定位到问题根源时就一个教训策略实现类尽量做成无状态的所有计算需要的输入都通过方法参数传进来。状态放到调用方或者单独的服务里管理。设计题里不太容易暴露这个点但在生产环境它是致命问题。6.3 排查设计问题的思路清单接到一份设计代码不管是你自己的还是别人的我习惯按下面的顺序过一遍先看类数量是不是合理——太少了可能是职责集中太多了可能拆粒度过细。然后逐个类的字段找public和非final的这两个都是潜在风险点。再看类与类之间的依赖方向是否都朝着接口或抽象层。最后做一次需求推演假设新增一个需求看要改哪些文件——如果改动文件多且都是旧文件那设计就有改进空间。这套清单你也可以用在日常工作里不必等到面试才用。结尾最后分享一点我的个人习惯做这类设计题这么多年我自己的一个习惯是永远给代码留一个“新增需求”的口子。写任何类时都下意识问一句——如果明天要我加一个新类型我需不需要改这个类这个口子不是说要预先把所有可能的变化全建模出来那会过度设计而是在结构上保证变化点是独立的而不是和稳定逻辑缠在一起的。我见过太多人一开始兴致勃勃地用各种设计模式堆小系统后来发现过度设计比没有设计更痛苦。一个好的设计题答案不应该是模式越多越好而应该是“每一个模式用出来都能解释它解决了哪个具体的问题”。咖啡系统用装饰者因为配料叠加组合数量爆炸订单计价用策略因为计价规则变化频繁。没有问题的模式包装是没有实际价值的装饰。这道“Java 面向对象设计题3”如果你按这个思路走一遍——先抽象建模、再分析变化点、再选实现方案、最后落代码——你拿到的就不只是一个能跑的答案而是一个能不断适应需求变化的体系。下次再有人问你这种题试着把你思考权衡的过程说出来那才是这道题真正想考察的东西。