
直接进入正题。这是Java基础篇的第三期前两篇把环境搭建、语法结构和面向对象的基本轮廓过了一遍这次要啃的是真正决定你水平和面试能否过关的硬骨头封装、包结构、继承、多态、抽象类与接口外加两个几乎每天都会碰到的主题——常用类和异常体系。这些东西每一个拎出来都能写篇长文我尽量用实际项目里的视角把这些概念串起来而不是对着教科书念定义。这篇文章的定位很明确适合刚学完Java语法、开始接触面向对象设计的同学也适合准备Java面试、想把基础概念讲清楚而不是背八股的朋友。如果你已经写过一段时间业务代码再看这篇文章应该也会有些“原来当时那个坑是这么回事”的收获。1. 封装和包结构先把这层“墙”砌对后面的继承多态才不塌1.1 封装的本质不是“私有化”而是维护类的内部状态很多教程讲封装就是一句话用private把字段藏起来然后提供getter/setter。这句话没错但它远远没有说到点子上。你想想如果封装的意义只是“把字段设为私有”那它跟防君子不防小人的锁有什么区别工作的老代码里用IDE一键生成一堆getter/setter的类比比皆是字段该被改坏还是改坏。封装真正的价值是维持类的不变量invariant。举个例子一个银行账户类public class BankAccount { private double balance; public BankAccount(double initialBalance) { if (initialBalance 0) { throw new IllegalArgumentException(初始余额不能为负); } this.balance initialBalance; } public void deposit(double amount) { if (amount 0) { throw new IllegalArgumentException(存款金额必须为正); } this.balance amount; } public void withdraw(double amount) { if (amount 0 || amount balance) { throw new IllegalArgumentException(取款金额不合法或余额不足); } this.balance - amount; } public double getBalance() { return balance; } }这里的核心不是balance用了private而是deposit和withdraw这两个方法保证了“余额永远不为负”这条业务规则。如果balance是public字段任何代码都能直接把它改成负数整个账户模型就崩了。理解到这一层封装的本质就很清楚了它把“数据”和“操作数据的规则”绑在一起对外只暴露一个受控的入口。所以你在设计类的时候多问自己一句这个字段如果直接暴露出去外部代码能把它改成一种让类本身无法工作的状态吗能的话必须私有化。1.2 包结构命名空间、访问控制、模块边界的三合一Java的包package看起来就是个文件夹但它的作用远不止整理目录。我见过不少项目包结构随便建类就往里扔等到依赖关系乱成一锅粥才回头补课。包至少承担三件事第一命名空间。全世界Java程序员都可能写一个User类如果都放在默认包里类名必然撞车。域名反写比如com.company.project就是为了保证全局唯一性说穿了就是给类名加了个足够长的前缀。第二包级访问控制。default修饰符不加任何修饰符表示“包私有”同一个包内的类可以互相访问包外的类碰都不能碰。这个机制跟protected配合起来才是完整的访问控制体系。第三模块边界。这才是包结构最重要的意义。你画包结构的时候本质上是在画依赖图。好的包设计依赖方向是单向的、清晰的。比如controller依赖serviceservice依赖dao反过来不行。如果你发现两个包互相import说明边界划错了。一个我踩过的坑是所有工具方法都往一个util包里扔然后全项目所有包都依赖util等到想把这个util拆开的时候牵一发动全身。正确的思路是工具类按业务域拆分比如跟订单相关的、跟用户相关的各自归位而不是搞一个大杂烩。另外提一句package-info.java这个文件很多人不知道它的存在。它可以放包的文档注释和包级注解对于维护大型项目的包结构说明很管用。1.3 访问修饰符的选择一张表格说清可见性这里把Java的访问修饰符可见性整理一下新建类的时候对着这个表想一遍能避开很多隐蔽bug。修饰符同类同包子类不同包任意类private是否否否default无修饰符是是否否protected是是是否public是是是是一个常见的坑是重写方法时缩小访问权限。比如父类方法是protected子类重写时把它写成default编译器可能不报错如果父类子类同包但一旦子类被其他包使用原本通过父类引用调用的地方就访问不到了运行时直接抛IllegalAccessError。所以重写方法的访问权限只能扩大不能缩小实在拿不准就一律public或protected。2. 继承能改的和不能改的父类子类的边界要划清2.1 继承到底解决了什么问题继承最表面的作用是代码复用——子类拿父类的字段和方法直接用。但真正让继承在面向对象里站稳脚跟的是它建立了一套“类型体系”。有了继承一个方法可以接受父类类型然后自动兼容所有子类对象这就是后面多态的基础。Java的继承是单继承一个类只能有一个直接父类。为什么因为多继承会带来菱形问题如果两个父类都有一个foo()方法子类继承时到底调用谁的C用虚继承解决但那套规则极其复杂Java直接砍掉多继承用接口来弥补“多能力”的需求。这个设计取舍值得记住面试经常问。继承的代码很简单public class Animal { protected String name; public Animal(String name) { this.name name; } public void eat() { System.out.println(name is eating); } } public class Dog extends Animal { public Dog(String name) { super(name); } public void bark() { System.out.println(name is barking); } }super关键字有三个用途调用父类构造器、调用父类被重写的方法、访问父类被遮蔽的字段。这里最需要注意的是构造器链子类的构造器第一行必须是this(...)或super(...)如果你什么都不写编译器会隐式插入super()。这就意味着如果父类没有无参构造器子类就必须显式调用带参的super否则编译直接失败。这是个非常实用的坑很多人在父类里只写了带参构造器忘写无参构造器然后子类编译报错还一脸懵。经验是父类如果定义了带参构造器顺手就补一个无参构造器这不只是为了自己方便也是给所有未来的子类留条活路。2.2 重写Override和重载Overload别把两者混为一谈重写是子类重新实现父类的方法方法签名必须一致。Java对重写有几条硬性规定方法签名方法名参数列表必须相同返回类型可以相同也可以是父类返回类型的子类型协变返回类型访问权限不能比父类更严格不能抛出比父类更宽泛的受检异常为什么返回类型可以协变这是基于里氏替换原则的。父类引用调用方法时编译器认为返回值是父类型实际运行时返回子类型完全没问题因为子类型一定“是”父类型。这保证了任何使用父类返回值的代码拿到子类型对象都不会出问题。重载则是同一个类里方法名相同、参数列表不同。二者的区别不要用“编译时多态/运行时多态”这种模糊的说法来记重载在编译期就决定了调用哪个方法重写在运行期才决定。我后面讲多态会展开这个点。一个必须养成的习惯重写方法一定要加Override注解。这个注解不是装饰品它会触发编译器检查防止你因为参数列表写错比如Object不小心写成了objcet而“新建了一个方法却自以为重写了父类”。这种bug极其隐蔽方法不报错、逻辑不对排查半天最后发现是方法签名错了。2.3 字段遮蔽与静态方法隐藏继承里的两个“假多态”新手最容易踩的坑就在这里。实例方法有动态绑定多态但字段和静态方法没有。看这段代码class Parent { public String value parent; public static void staticMethod() { System.out.println(parent static); } public void instanceMethod() { System.out.println(parent instance); } } class Child extends Parent { public String value child; public static void staticMethod() { System.out.println(child static); } Override public void instanceMethod() { System.out.println(child instance); } } Parent p new Child(); System.out.println(p.value); // parent Parent.staticMethod(); // parent static通过p调用也是parent static p.instanceMethod(); // child instance同一个p引用了Child对象但p.value拿到的是Parent的字段p调用静态方法执行的是Parent的版本只有p.instanceMethod()真正走到了Child的实现。原因很简单字段访问和静态方法调用在编译期就按引用类型切定了根本不走动态绑定。实例方法则走虚方法表vtable运行期根据实际对象类型查找。这也是为什么实例方法才叫“多态”字段和静态方法只能叫“遮蔽”和“隐藏”。3. 多态同一个方法签名的背后为什么能跑出不同结果3.1 动态绑定的底层机制虚方法表多态的三个前提条件必须同时满足继承或实现接口、方法重写、父类引用指向子类对象。缺一个都不行。底层原理是虚方法表。每个类在类加载阶段JVM都会为它创建一张方法表里面按索引记录了所有可被调用的实例方法指针继承的方法会复制到子类的方法表对应槽位被重写的方法则替换成子类的实现。调用一个实例方法时实际上是在方法表里按索引查指针再调用。这个过程对开发者透明但理解它能解释很多现象为什么多态只作用于实例方法、为什么调用速度快索引查找、为什么接口调用比类调用多一步查表。用人话说就是你是顾客点菜单上写着“来一份鱼香肉丝”方法签名后厨的川菜师傅和鲁菜师傅子类对象都用这个名字做但做法不同。菜单是抽象的做出来的菜才是具体的。3.2 向上转型与向下转型什么时候该强转向上转型是安全的因为猫一定是动物这不需要任何特殊处理。向下转型则是把父类引用强转为子类引用风险在于这个对象原本可能根本不是那个子类强行转型会抛ClassCastException。所以向下转型之前必须用instanceof确认Animal animal getAnimal(); if (animal instanceof Dog) { Dog dog (Dog) animal; dog.bark(); }这里有几个细节值得记一下子类对象instanceof父类类型结果是true因为“是”的关系成立。null instanceof 任何类都是false所以不用担心空指针。编译器能判断出来的不可能类型会直接编译报错比如abc instanceof Integer因为String和Integer没有任何继承关系。Java 16之后可以直接用模式匹配简化if (animal instanceof Dog dog) { dog.bark(); }我个人的习惯能靠多态解决的就别向下转型。如果你在一个方法里频繁强转成各种子类大概率说明父类的接口设计得不够抽象该把行为往上提了。强转本身不违法但频繁强转是设计坏味道。3.3 多态的实战场景策略、工厂与集合框架多态在日常开发里有几个高频率使用场景第一个是策略模式。Collections.sort(List, Comparator) 这个方法只认Comparator接口你传按升序的、按降序的、按自定义规则比较的它都不关心具体实现。这就是策略接口的多态应用——调用方不依赖具体策略依赖的是抽象契约。第二个是工厂模式。工厂方法返回类型写成父类型或接口类型具体返回哪个子类由内部逻辑决定。调用方只依赖抽象天然解耦。第三个是集合框架。Collection接口统一了List、Set、Queue你写个方法接收Collection参数它能接收任何集合。如果你把参数定成ArrayList那就失去了多态带来的灵活性。面试的时候如果有人问“多态是什么”我建议你分三层回答语法上它需要继承/实现、重写、父类引用指向子类对象三要素机制上它是虚方法表的动态绑定设计上它让代码面向抽象编程而不是面向具体实现。这三层说清楚比背十遍定义都管用。3.4 重载到底算不算多态这是个经典的争论点。Java官方文档把重载称为编译时多态但我更愿意把话说清楚重载是静态分派编译期就根据实参类型确定了走哪个方法不涉及运行时动态绑定重写才是动态绑定是真正的多态。你可以在面试里说“重载是静态多态重写是动态多态”但心里要清楚两者机制完全不同。用“参数类型选择方法”来理解重载就够了不要跟多态的虚方法表机制混在一起。4. 抽象类与接口两套抽象方案到底该选哪一套4.1 抽象类把“骨架”固化把“细节”留给子类抽象类的定位是“半成品类”。它不能直接实例化但可以有构造器、字段、已有实现的方法和抽象方法。构造器存在的意义不是用来new而是让子类在初始化时先初始化父类的状态。抽象类最经典的应用是模板方法模式。举个实际点的例子一个简单的数据导入框架public abstract class AbstractDataImporter { // 模板方法定义算法骨架子类不能修改 public final void importData(String filePath) { validateFile(filePath); // 通用逻辑 Object parsed parse(filePath); // 抽象方法子类实现 save(parsed); // 抽象方法子类实现 logResult(filePath); // 通用逻辑 } protected abstract Object parse(String filePath); protected abstract void save(Object data); private void validateFile(String filePath) { if (filePath null || filePath.isEmpty()) { throw new IllegalArgumentException(文件路径为空); } } private void logResult(String filePath) { System.out.println(导入完成: filePath); } }这里importData用final修饰保证算法骨架不被改变。parse和save子类各自实现导入Excel和导入CSV的差异就被隔离了。这种“父类写流程、子类填细节”的模式就是抽象类的用武之地——它不仅要定义契约还要通过继承共享代码状态。4.2 接口把“能力”抽象成契约接口的定位是“能力契约”。它描述一个对象能做什么而不是它是什么。所以接口适合表达“can-do”关系一个类可以同时实现多个接口拥有多种能力这正好弥补了Java单继承的局限。JDK 8是一个分水岭。之前接口只能有抽象方法和常量JDK 8引入了default方法和static方法JDK 9又引入了private方法。default方法带来的好处是接口演进不破坏已有实现——你在接口里加一个default方法所有实现类都自动获得了这个能力不用逐个改。但也正因为如此default方法要谨慎使用它给所有实现类带了行为如果实现类本来有同名方法规则是“类优先于接口”这个细节容易引发混乱。static方法是接口级的工具方法比如Comparator.comparing就是接口的静态方法专门用来构造比较器。private方法则是给default方法和static方法复用代码用的跟外部调用无关。接口里声明的字段只能是public static final的常量这意味着接口不能有状态。这不是限制是设计意图——接口要的就是纯行为契约不掺和状态。4.3 抽象类与接口的选型决策很多教程说“优先用接口而不是抽象类”这话有道理但不能教条。我列个对比表然后给出一套实际选型逻辑维度抽象类接口关系is-a是一个can-do能做状态可以有字段只能有常量构造器有无已有实现可以有具体方法default方法JDK 8多继承单继承一个类只能继承一个抽象类可以实现多个接口演进性不稳定改抽象类影响继承体系相对稳定加default方法不破坏实现选型逻辑其实很简单如果多个类共享相同的状态和相同的实现逻辑并且它们本质上是同一类事物用抽象类如果只是能力相同、来源不同、状态不共享用接口。拿飞禽走兽举例子乌鸦、麻雀、老鹰都是鸟它们都有翅膀和喙共享大量身体结构和行为这种“同类事物”适合抽象类。而会飞的技能鸟会飞、飞机也会飞、蝙蝠也会飞三个毫不相干的类型只是因为都会飞才被拉到一起这就应该用Flyable接口。模板方法模式是抽象类的专属领地。default方法没有状态它做不了模板方法里需要共享字段的事情。所以别听“接口万能论”抽象类在特定场景依然是不可替代的。4.4 函数式接口与Lambda接口的现代形态函数式接口是只有一个抽象方法的接口用FunctionalInterface标注。Lambda表达式本质上是函数式接口的匿名实现。比如Runnable、Comparator、Callable都是函数式接口。现在写代码基本离不开Lambda理解这层关系之后你会发现接口不只是给对象用的也是给函数用的。5. 常用类String、包装类、日期API高频工具里的那些坑5.1 String的不可变性为什么说它是Java里最特殊的类String被final修饰不可继承内部用byte数组存储且数组不可变。不可变性是深思熟虑的设计安全性。类加载、网络参数、文件路径这些场景如果String可变恶意代码就能在传递过程中篡改内容后果不堪设想。缓存友好。hashCode可以安全缓存因为内容永不变。常量池复用。字符串字面量可以安全地共用同一实例节省内存。基于不可变性有个经典面试题new String(abc)和abc的区别。String s1 abc; // 可能复用常量池中的对象最多0个新对象 String s2 new String(abc); // 堆上新建对象至少1个新对象 s1 s2; // false引用不同 s1.equals(s2); // true内容相同字符串拼接也是重灾区。用拼接字符串编译器会用StringBuilder优化单次拼接但在循环里拼接会不断创建新的StringBuilder和String对象性能很差。正确的做法是循环外建一个StringBuilder循环内append。这属于基础常识但我在代码评审里见过太多次循环里直接s item的写法了。StringBuilder和StringBuffer的区别就一句话后者加了synchronized线程安全但慢前者线程不安全但快。单线程场景无脑用StringBuilder。5.2 包装类缓存池、自动拆箱与空指针Integer i 10; 这行代码背后是Integer.valueOf(10)的自动装箱而valueOf内部有缓存public static Integer valueOf(int i) { if (i IntegerCache.low i IntegerCache.high) { return IntegerCache.cache[i (-IntegerCache.low)]; } return new Integer(i); }缓存范围是-128到127。默认到127是JVM规范建议的上下界可以通过启动参数调整。这个缓存带来的著名坑是Integer a 127, b 127; a b; // true命中缓存同一个对象 Integer c 128, d 128; c d; // false超出缓存范围两个对象判断包装类是否相等永远用equals。另外两个常用方法要分清Integer.valueOf()返回包装对象Integer.parseInt()返回基本类型int。前者有缓存后者没有对象创建开销。自动拆箱还有一个隐形杀手如果Integer引用为null自动拆箱时直接抛NullPointerException。比如entity的age字段是Integer赋值给int时没判空线上NPE就这么来的。这里强调一下包装类判空之后再操作是基本素养。5.3 日期时间APISimpleDateFormat线程不安全换用java.timeSimpleDateFormat不是线程安全的它的内部Calendar状态会在format和parse时被并发修改导致结果错乱甚至死循环。多线程环境里要加同步或使用ThreadLocal但从JDK 8开始完全没必要再用它了。LocalDateTime、LocalDate、LocalTime都是不可变的所有操作返回新对象天然线程安全。日常用法LocalDateTime now LocalDateTime.now(); LocalDateTime tomorrow now.plusDays(1); DateTimeFormatter fmt DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss); String text now.format(fmt); LocalDateTime parsed LocalDateTime.parse(text, fmt);Instant代表时间戳ZoneId处理时区偏移Duration和Period处理时间差。这套API设计比Date和Calendar优雅得多强烈建议老项目里的SimpleDateFormat和java.util.Date在条件允许时逐步替换掉。5.4 集合框架速览先建个轮廓后面单独深入集合框架本来可以单独写一篇长文这里先给个轮廓Collection接口下分List、Set、QueueMap是独立的键值对体系。List是有序可重复的Set是无序不可重复的Map是键值映射。底层实现上ArrayList是数组扩容LinkedList是链表节点HashMap是数组加链表加红黑树。负载因子0.75是时间换空间的平衡点太大会增加哈希冲突概率太小浪费内存0.75是经过统计权衡的经验值。这套东西细节很多比如HashMap在Java 8之后引入红黑树、并发条件下要用ConcurrentHashMap、HashMap的key为什么要实现equals和hashCode等等后面单独用一篇来写这里先做铺垫。6. 异常出错不是小事关键是把错误扔给对的人6.1 异常的类型体系与“受检”的哲学Java异常体系是分层的Throwable是所有异常的根下面分Error和Exception。Error是JVM层面的严重问题比如OutOfMemoryError、StackOverflowError程序通常无法恢复不要捕获也不要抛出。Exception下面分两大类受检异常checked exception继承Exception但不继承RuntimeException编译器强制你处理。它表达的是“可以预见的、外部环境导致的、调用方应该关心的错误”比如文件不存在、网络连接失败。非受检异常RuntimeException表达的是“编程错误或前置条件不满足”比如空指针、下标越界、参数非法。设计自定义异常时的准则是如果你的异常表示调用方可以通过合理的处理让程序继续运行用受检异常如果表示程序本身有bug调用方无法恢复用非受检异常。值得提醒的是受检异常不能滥用。我在系统里见过层层抛出IOException只是为了方法签名好看的代码那已经是反模式了。受检异常的意义在于把风险显性化但过度使用会让调用链变成灾难。6.2 try-catch-finally的执行细节finally别乱写return这段代码看输出是什么public static int test() { try { return 1; } finally { return 2; } }答案是2。finally里的return会吞掉try里的return而且这种吞法是静默的业务逻辑完全被忽视。字节码层面finally里的return覆盖了之前栈上的返回值。所以finally块里原则上不要写return也不要抛异常——如果在finally里抛了新异常它会覆盖try里原始异常的堆栈信息排查问题时会一头雾水。正确的关闭资源方式是从Java 7开始的try-with-resourcestry (BufferedReader reader new BufferedReader(new FileReader(a.txt))) { String line reader.readLine(); } catch (IOException e) { log.error(读取失败, e); }这段代码等价于在finally里逐层关闭资源且关闭顺序与资源声明的逆序。资源必须实现AutoCloseable接口这是所有可关闭资源的统一契约。try-with-resources比手动finally更安全因为它连finally里自己写关闭可能忘记处理关闭异常的情况都考虑到了——如果close方法也抛异常它会成为suppressed exception附加在原始异常上可以用Throwable.getSuppressed()拿到。6.3 捕获异常之后怎么办这是异常处理里最“虚”也最重要的一块。最差的写法就是catch之后printStackTrace就结束了。StackTrace打到控制台就消失线上环境根本看不到而且异常被吞掉程序继续往错误状态走问题被无限期延后。正确的处理思路是三个选择当前方法能恢复错误或者有兜底逻辑就捕获并处理记录日志。当前方法不关心这个错误让上层去处理就抛出但要考虑是否需要包装成更有业务含义的异常。既不处理也不抛出那是给未来埋雷。包装异常的经典方式是try { // 调用外部接口 } catch (IOException e) { throw new BusinessException(获取用户信息失败, e); }构造异常时把cause传进去异常链就保住了最底层的原因不会丢。日志记录的时候一定要带上堆栈不能只打message不然没有调用上下文排查等于盲人摸象。catch的顺序也值得注意子类异常必须放在父类异常之前。如果先catch(Exception)后面的catch(IOException)根本不可能执行到编译器会直接报错。多跑几次代码这个顺序规则自然就记住了。6.4 自定义异常与统一异常处理实际项目里很少直接把底层异常抛给用户。一般会定义业务异常基类然后按业务域细分。比如public class BusinessException extends RuntimeException { private final String code; public BusinessException(String code, String message) { super(message); this.code code; } public BusinessException(String code, String message, Throwable cause) { super(message, cause); this.code code; } }为什么业务异常多继承RuntimeException而不是Exception因为大多数业务异常是“调用方传递了不合理的请求”或“业务条件不满足”属于编程前提错误或外部输入问题调用方不该被迫层层捕获。框架层面再做统一异常处理比如Spring Boot里的RestControllerAdvice把代码、消息映射成统一响应体。这样业务代码里只需抛异常不用关心HTTP状态码和响应格式异常体系和框架职责各管一段。说一个我自己的体会学Java基础最容易走偏的地方就是把这些概念当成考试题背答案。封装背“私有属性、getter/setter”多态背“三个条件”继承背“extends关键字”但面试官只要多问一句“为什么”就卡壳了。这篇文章里我花了很多篇幅解释“为什么”原因就在这里。你如果能用自己的话把“封装是维持不变量”“多态是面向抽象编程的基石”“接口是能力契约”这些本质讲清楚面试和实际写代码都会顺很多。最后分享一个我常用的学习路径每个基础概念学完之后去读一个JDK源码里的真实例子。看String为什么用final byte[]、看ArrayList怎么用transient数组加序列化保护、看HashMap怎么处理哈希冲突。从基础到源码再从源码回到基础这个循环转起来你的Java功底才是真的扎进去了。下一篇我会展开集合框架的底层实现到时候这些基础概念都会再次用上。