1. 继承关系到底在解决什么问题
不少初学者学Java时,见过最多的就是“extends”关键字,也知道类能继承父类的方法和字段。但真到了自己设计代码、阅读开源框架源码、或者面试被问到“String类继承谁”“ArrayList和LinkedList的共同父类是什么”的时候,很多人就卡住了。原因很简单:语法层面的“继承”你懂了,但常见类之间怎么构成一个继承网络、为什么JDK要这样设计、这些关系对你的日常开发有什么实际影响,这些问题很少有人系统梳理过。
这篇博文就围绕“常见类的继承关系”这个主题,把Java里几组最常用类的继承结构、背后的设计逻辑、以及你在写代码时跟这些继承关系打交道的具体场景,一次性讲透。不管你是刚接触Java不久、正在啃基础知识的初学者,还是写过一阵子业务代码但一直没空回头补基础的人,这篇文章都能帮你把脑子里零散的“继承”概念串联成一张清晰的图。
我先把话放这儿:继承关系不是靠背能背下来的,得靠“理解为什么这么设计”来记忆。一旦你搞清楚JDK这么分的逻辑,很多继承结构你根本不用记,自己都能推出来。
2. 整个继承网络的根:Object和它下面的第一层
2.1 为什么每个类都默认继承Object
先讲一个很多人忽略的事实:Java里所有的类,无论是JDK自带的还是你自己写的,最终都直接或间接继承自java.lang.Object。哪怕你的类声明里没有写extends,编译器也会自动补上extends Object这一层。
Object类往细了说,就是给所有对象定义了“最基本的通用能力”。它提供了几个你今天一定用过的、但未必意识到来自哪里的方法:
equals(Object obj):默认行为是比较引用地址,也就是两个引用是否指向同一个对象。hashCode():给对象算出一个哈希值,配合HashMap、HashSet这类基于哈希的集合使用。toString():默认返回“类名@十六进制哈希码”这种格式,比如com.demo.User@3b07d329。getClass():返回运行时的Class对象,是反射机制的入口。clone():做对象的浅拷贝,但默认是protected的,且需要实现Cloneable接口才能正常调用。finalize()、wait()、notify()、notifyAll():跟垃圾回收、多线程等待唤醒相关。
这个设计想表达的逻辑很简单:既然所有类都是Object的子类,那么任何对象都能赋给Object类型的变量,任何对象都能调用这些通用方法。这就是“多态”的底层支撑之一。你写一个方法,参数类型定为Object,那就能传入任何对象,然后通过getClass()或者instanceof在运行时判断真实类型——很多工具类就是这么干的。
2.2 JDK里继承Object的“第一层”典型代表
Object的直接子类非常多,但日常打交道的也就那么几类,我按用途分组说一下:
- 包装类(如
Integer、Long、Boolean、Character):这些类都直接继承Object(JDK8时代往下还有个Number抽象类在中间,但Boolean和Character这种非数值类型就是直接挂Object下面)。 - String类:
public final class String extends Object,直接继承Object,而且被final修饰,不能有子类。 - 数组类型:不管是
int[]还是String[],数组在JVM层面都被当作对象看待,从Object继承。 - 所有自定义的业务类:比如你写的User、Order、Student,没写extends时,默认第一层父类就是Object。
这里有一个值得注意的点:final类不能继承。String是final,这导致你不能搞出一个和String行为不同的子类来覆盖原有逻辑。JDK之所以把String设成final,主要考虑两点:一是安全,字符串在类加载、网络传输、反射场景中大量使用,不希望被篡改;二是性能,String内部做了缓存和常量池之类的优化,如果允许继承,这些优化机制容易被破坏。所以你在设计自己的类时也是一样的思路——没有特别强的扩展需求,用final封死更稳妥。
3. 数字与字符串:两条最常用的继承链
3.1 Number家族:所有数值包装类的共同父类
如果你用过Integer、Long、Double、Float、Short、Byte,这些类表面上各自挺独立,但代码里往下扒一层,它们上面有一个共同的抽象父类:java.lang.Number。
Number类的定义极其简洁,就是几个抽象方法:
public abstract class Number implements java.io.Serializable { public abstract int intValue(); public abstract long longValue(); public abstract float floatValue(); public abstract double doubleValue(); public abstract byte byteValue(); public abstract short shortValue(); }它的作用一句话就能概括:把任何数值类型的对象,统一转成各种基本数值类型的值。你可能写过类似代码:拿到一个Number类型的参数,或者从某个框架的回调里收到一个Number对象,然后调用xxx.intValue()或者xxx.doubleValue()取实际数值——这时候父类型的抽象方法就发挥作用了。
完整的继承链是:
Object └── Number (abstract) ├── Integer ├── Long ├── Double ├── Float ├── Short ├── Byte └── AtomicInteger / AtomicLong / BigDecimal / BigInteger(这几个也在Number下面)注意到没有,BigDecimal和BigInteger也继承自Number。所以你会看到很多金额计算工具类,入参直接写Number,然后根据业务需要调用某个具体的方法取值。这个设计的好处是:你写代码时不需要对着Integer、Long、BigDecimal每种类型各写一个重载方法,一个Number参数全收下了。
再说个小细节,Integer和Long这些包装类之间没有继承关系,它们是兄弟关系,都直接挂在Number下面。所以你不能把Integer对象直接赋给Long类型的变量,哪怕值都是1,类型层面它俩互不相干。很多初学者在这个坑上懵过:Object obj = Integer.valueOf(1)没问题,但Long l = Integer.valueOf(1)编译直接报错。
3.2 String、StringBuilder、StringBuffer之间的“继承”关系
String相关的类,是Java初学者最容易把继承关系搞混的一组。我先纠正一个高频误区:String、StringBuilder、StringBuffer三者之间,没有任何继承关系。它们并不是谁继承谁的关系,而是都实现了同一个接口,并且都挂在CharSequence这个接口下面。
各自的继承结构如下:
public final class String implements java.io.Serializable, Comparable<String>, CharSequencepublic final class StringBuilder extends AbstractStringBuilder implements Serializable, Comparable<StringBuilder>, CharSequencepublic final class StringBuffer extends AbstractStringBuilder implements Serializable, Comparable<StringBuffer>, CharSequence
这里就引出了一个新概念:接口继承。String这个层面的继承树不是单线程的“父子”关系,而是多了一个维度——实现接口。Java的继承分两种:类继承(extends)和接口实现(implements)。类只能继承一个父类,但可以实现多个接口。
这种设计的好处是:你写方法的时候,可以只要CharSequence类型的参数,然后String、StringBuilder、StringBuffer都能传进来。比如:
public void printLength(CharSequence cs) { System.out.println(cs.length()); }这个方法既接受String,也接受StringBuilder。这就是面向接口编程的实际价值——不关心具体实现类是谁,只关心这个对象能不能提供length()的能力。
再往深处说一层,StringBuilder和StringBuffer有个共同的父类AbstractStringBuilder,这个类才是实现字符串拼接逻辑的核心。StringBuilder和StringBuffer本质上只是这个抽象类的两个子类,区别仅在于StringBuffer的方法加了synchronized关键字,保证线程安全;StringBuilder没加,性能更好。你如果去看这俩类的方法名,会发现全部是append、insert、delete这些,因为它们逻辑都写在AbstractStringBuilder里,子类基本只是对外暴露一下并决定要不要加锁。
3.3 为什么说接口比类继承更“灵活”
类继承是一个“单亲”模型:一个类只能有一个直接父类。这在设计上带了天然的限制。比如你想让一个类既拥有“可打印”的能力,又拥有“可排序”的能力,用类继承做不到——你不能同时继承两个类。但接口给你打开了口子:
public class Report implements Printable, Comparable<Report> { ... }所以你会看到一个现象:但凡JDK里设计得比较灵活的常用类,基本都通过“实现多个接口”来扩展能力,而不是通过“继承很多层父类”来堆积功能。比如常见的集合类ArrayList,它的继承关系是:
Object └── AbstractCollection<E> └── AbstractList<E> └── ArrayList<E> 同时实现了List<E>, RandomAccess, Cloneable, java.io.Serializable这个设计思路值得你复用:能用组合、接口的地方,优先别往深了去继承。继承层数一旦超过三四层,代码的可维护性就会断崖式下跌——改父类的一个方法,所有子孙类全受影响,牵一发动全身。
4. 集合框架:一张庞大但规律的继承网
4.1 List家族的共同祖先和两条典型路线
集合框架的继承关系是面试和日常开发的重灾区,但它非常规律。先说List这条路。
最顶层是Collection<E>接口,下面衍生出List<E>接口,然后有两个最常用的实现类:ArrayList和LinkedList。完整链路分别是:
Collection<E> (接口) └── List<E> (接口) └── AbstractList<E> (抽象类) └── ArrayList<E> └── AbstractSequentialList<E> (抽象类,继承自AbstractList) └── LinkedList<E>可以看到,JDK在“接口”和“实现类”之间,故意插了一层“抽象类”。比如AbstractList。为什么要多这一层?因为List接口的方法比较多,有十来个。如果让每个实现类从头开始写,代码会大量重复。JDK的做法是:搞一个抽象类,把那些“大多数实现类都差不多的逻辑”先给你写好了。比如iterator()方法、contains()方法、indexOf()的通用逻辑,都在AbstractList里有默认实现。具体实现类只需要关注底层数据结构差异即可——ArrayList搞数组,LinkedList搞链表。
这种“接口定义行为 + 抽象类提供默认实现 + 具体类实现细节”的三层结构,在整个集合框架里反复出现。你理解了这套模板,看任何集合类都能快速找到它在继承树中的位置。
提示:看源码时先找“接口”,再看“Abstract开头的抽象类”,最后才看具体实现类。你就能理清这个类怎么一步步变成现在这样的。
4.2 Map其实不走Collection那条路
很多人把Map当成Collection的子接口,其实不对。Map<K,V>是一个独立于Collection的顶层接口,跟Collection是并列的,它俩的共同祖先都到Object为止。只是Map的三个视图方法——keySet()、values()、entrySet()——返回的是Collection或Set类型的视图,这才让人觉得它俩是一族的。
Map家族常见的继承关系是:
Object └── AbstractMap<K,V> (抽象类) ├── HashMap<K,V> ├── TreeMap<K,V> └── EnumMap<K,V> 同时HashMap有个特殊子类 LinkedHashMap<K,V>另外再说一个面试高频点:HashTable和HashMap的关系。HashTable的继承链是Dictionary抽象类 -> Hashtable,它不走AbstractMap那条路。而且HashTable已经算比较老旧的设计,新代码应该用HashMap或ConcurrentHashMap。
HashMap是AbstractMap的子类,同时实现了Map接口。跟List那条链完全一样的模式:Map接口 -> AbstractMap抽象类 -> 具体实现类。你要理解的是,JDK为什么要让HashMap延续AbstractMap而不是直接实现Map接口?因为在AbstractMap里,JDK把entrySet()抽象方法之外的通用方法都写好了,比如containsKey()的默认扫描逻辑,toString()的统一输出格式,equals和hashCode的语义。HashMap只需要处理自己最核心的哈希存储逻辑,其余通用行为白嫖父类的默认实现就行。
4.3 Set家族:本质上是Map的“马甲”
Set这一族,最有意思。你如果扒一扒HashSet的源码会发现,它内部居然维护了一个HashMap:
public class HashSet<E> extends AbstractSet<E> implements Set<E>, Cloneable, java.io.Serializable { private transient HashMap<E,Object> map; }HashSet往根上捋,链路是:
Collection<E> (接口) └── Set<E> (接口) └── AbstractSet<E> (抽象类) └── HashSet<E> └── LinkedHashSet<E>而TreeSet的底层其实是个TreeMap。也就是说,Set家族和Map家族用组合的方式“共享”了存储逻辑——Set只关心key,不关心value。JDK没有让Set去继承Map,因为语义上这俩属于不同集合类型;但在底层实现上,Set复用了Map的能力。这是组合优于继承的一个经典例子。
实操心得:判断一个类是不是继承自某个父类,别靠背。直接用IDE看源码导航条,或者写代码跑一下
ClassName.class.getSuperclass(),一次就能定位到直接父类,循环几次就能把完整链路拉出来。
5. 异常体系:一棵你每天都在用但没细看的继承树
5.1 Throwable下面的两大分支
异常体系的根不是某个叫Exception的类,而是java.lang.Throwable。它的两个直接子类是Error和Exception。
Object └── Throwable ├── Error │ ├── OutOfMemoryError │ ├── StackOverflowError │ └── ... └── Exception ├── RuntimeException │ ├── NullPointerException │ ├── ClassCastException │ ├── IndexOutOfBoundsException │ ├── IllegalArgumentException │ └── ... ├── IOException ├── SQLException └── ...这里面的设计逻辑要分两层看。
第一层,为什么没有一个总的AbstractThrowable之类的抽象类?因为Throwable本身就可以充当“抽象类”的角色,子类只需要往里面填message、cause、堆栈信息,这些字段和方法全在Throwable里定义好了。你写的自定义异常类,大部分时候直接继承RuntimeException或Exception就够了,根本不需要碰Throwable。
第二层,为什么要区分Error和Exception?这个区分的本质是“程序要不要对此负责”。Error(比如OutOfMemoryError、StackOverflowError)属于JVM层面的严重问题,含义是这程序已经处于不健康状态,你catch了也没太大意义,因为内存都满了你还能干嘛?Exception则分为两类:RuntimeException(运行时异常)和Checked异常(受检异常)。RuntimeException代表的是一类“可以通过代码逻辑规避”的问题,比如空指针、数组越界、类型转换错误;Checked异常代表的是“受外部环境影响的、必须显式处理的”问题,比如文件不存在、网络连接失败。所以编译器强制你对IOException做捕获或抛出处理,但不会强制你catch一个NullPointerException。
5.2 自定义异常时该选哪个父类
写业务代码的时候,你大概率需要自定义异常。选父类的时候,我建议按以下原则走:
- 如果是“业务上可以预见并需要调用方处理”的情况,继承
Exception,这样编译期就会强制调用方处理,避免漏接。 - 如果是“代码内部防御性检查失败、正常情况下不应该发生”的情况,继承
RuntimeException,让异常快速上抛,由统一异常处理器兜底。
举个例子,你在做一个下单接口,库存不足这种“业务预期内的分支”可以抛一个继承自Exception的InsufficientStockException;而某个非空参数传成了null,这种属于“代码逻辑错误”,适合抛IllegalArgumentException,或者你自定义一个继承RuntimeException的参数校验异常。
注意:不要动不动就自己从Throwable继承。Throwable的子类要么是Error要么是Exception,如果你直接继承Throwable,你的异常既不是受检异常也不是运行时异常,处在非常难看的中间地带,工具类和框架大概率也不会对它做处理,纯给自己添堵。
6. 输入输出体系:抽象类加装饰器模式的样板
6.1 InputStream/OutputStream这棵树的读法
IO框架的继承关系,比集合框架更难读,因为它混合了“继承”和“装饰器模式”两种东西。很多新手一打开InputStream的子类列表就懵了:怎么这么多类?哪些是干活的?哪些是套壳的?
先看最顶上的骨架。
Object └── InputStream (abstract) ├── FileInputStream ├── ByteArrayInputStream ├── FilterInputStream │ ├── BufferedInputStream │ ├── DataInputStream │ └── PushbackInputStream ├── ObjectInputStream └── ...同时对称地看输出侧:
Object └── OutputStream (abstract) ├── FileOutputStream ├── ByteArrayOutputStream ├── FilterOutputStream │ ├── BufferedOutputStream │ ├── DataOutputStream │ └── PrintStream └── ObjectOutputStream这里的核心概念就是FilterInputStream/FilterOutputStream。它们本身也是子类,但它俩存在的目的不是自己干多少活,而是作为“包装载体”,让其他人给原始流附加能力。你可能更熟悉它们的子孙类名字:BufferedInputStream(加缓冲)、DataInputStream(读取基本数据类型)、ObjectInputStream(反序列化对象)。
6.2 Reader/Writer体系为什么要单独再开一棵树
在字节流之外,JDK还搞了一整套字符流的继承体系:
Object └── Reader (abstract) ├── InputStreamReader │ └── FileReader ├── BufferedReader ├── StringReader └── ...注意这里的继承结构跟字节流那棵树的形态不太一样:BufferedReader并没有通过FilterReader去包装,而是直接继承了Reader抽象类。FileReader则是InputStreamReader的子类。
那问题来了:既然有了InputStream/OutputStream,为什么还需要Reader/Writer?答案在于“字符编码转换”。InputStream读的是字节,如果直接把字节转成String,需要你手动指定字符集并正确处理多字节字符的边界;而Reader/Writer这套体系帮你把字节到字符的转换逻辑封装好了。InputStreamReader就是“字节流到字符流”的转换桥梁,你这行代码应该见过:
BufferedReader reader = new BufferedReader(new InputStreamReader(new FileInputStream("a.txt"), StandardCharsets.UTF_8));这行的本质,是把三个不同责任的类套在一起:FileInputStream负责真正读文件字节,InputStreamReader负责把字节按UTF-8解码成字符,BufferedReader负责加缓冲减少IO次数。这就是装饰器模式的典型形态——每个层解决一个问题,用继承关系+组合关系拼装出完整能力。
6.3 从IO继承关系里能学到什么
IO这个继承体系对普通开发者的启发,不是让你去记所有类的名字,而是理解“封装”的思路。你如果以后要设计一套可以被装饰的组件,可以参考JDK的模式:
- 定义一个抽象的顶层类或接口,定义业务骨架方法。
- 定义一个“被装饰者”的实现类,提供基础能力。
- 再定义一个继承自顶层类的“装饰者”抽象类,内部持有被装饰者的引用。
- 然后装饰者的子类才能在保留原能力的基础上添加新逻辑。
这个思路不仅用于IO,Java的集合工具里也到处用到了。理解了这一层,你会发现自己看源码的“视力”一下子提升不少,因为你看到的不再是一堆类名,而是“谁负责基础能力、谁负责增强能力、谁负责对外统一接口”。
提示:以后读源码遇到一大片继承关系时,别逐字逐行细看。先画一条“主干继承线”,然后分清每个类在干“基础实现”还是“装饰增强”,基本就能把握住整个框架的骨架了。
7. 线程相关类的继承关系
7.1 Thread类的继承现状:建议组合而非继承
java.lang.Thread本身是可以被继承的。典型写法是:
class MyThread extends Thread { @Override public void run() { // ... } }但我建议你不要这么写。原因不在于Thread不能继承,而在于Java的类继承是单一继承,一旦你的任务类继承了Thread,它就再也不能继承其他业务父类了。更好的做法是,把任务逻辑提取成Runnable或Callable接口的实现,然后交给Thread或线程池去跑:
class MyTask implements Runnable { @Override public void run() { // ... } } Thread t = new Thread(new MyTask()); t.start();Thread的继承链其实非常简单:Object -> Thread,中间没有复杂的层级。它本身实现了Runnable接口,所以Thread的run()方法其实是重写的接口方法。
需要说明的是,现在写并发代码,连Thread都很少手动new了,直接用线程池。Executor框架底层的核心类是ThreadPoolExecutor,它的继承链是:
Object └── AbstractExecutorService (abstract) └── ThreadPoolExecutor 同时实现了Executor, ExecutorService接口这又是一个“接口 + 抽象类 + 具体实现类”的三层模板,跟集合框架的设计套路如出一辙。
7.2 Callable与Runnable的关系:别误认为继承
java.lang.Runnable和java.util.concurrent.Callable是两个独立的接口,这样说可能会让初学者意外,但实际上它俩没有继承关系。Runnable的run()方法没有返回值、不抛受检异常;Callable的call()方法有返回值、可以抛异常。它俩是平行关系,不是父子关系。
如果你要让Callable适配成Runnable,需要中间的适配器——FutureTask就是干这个的。它的继承结构是:
Object └── FutureTask<V> 同时实现了RunnableFuture<V> 其中RunnableFuture接口 继承自 Runnable和Future<V>所以你可以写:
FutureTask<String> task = new FutureTask<>(() -> "result"); Thread t = new Thread(task); t.start();这里的关键是FutureTask既实现了Runnable,又持有Callable结果,它利用接口组合的能力把两个不相关的接口“组合”到了同一个类上。由此可见,接口本身虽然不叫“继承”,但它提供的“多实现”能力在扩展性上不输继承,某些场景下甚至更好用。
8. 几个极其高频的兄弟类关系辨析
8.1 Integer和int的关系不属于“继承”
这是初学者最容易模糊的边界。Integer是int的包装类,但Integer不是int的“子类”,因为这俩根本不是“类与类”的关系,而是一个是类、一个是基本数据类型。JVM层面它俩的关系是靠自动装箱/拆箱来维持的:
Integer a = 127; // 自动装箱,等价于 Integer.valueOf(127) int b = a; // 自动拆箱,等价于 a.intValue()在继承关系上,Integer挂在Number下,和Long才是“兄弟”关系。理解这层之后,很多坑就能看明白了,比如:
- 不能写
Integer和Long直接互相赋值,编译期不通过。 - 集合里只能放包装类,不能放基本类型,因为泛型参数要求是引用类型。
Integer == Integer比较的是引用地址,两个Integer做==比较是个经典大坑。
8.2 Parent类引用指向Child对象:多态的底层机制
继承关系最有价值的应用场景就是“多态”:父类引用可以指向子类对象;调用方法时,实际执行的是子类重写后的版本。这个机制在设计模式里头非常关键,也几乎是所有框架扩展点的基础。
Number n = new Integer(10); Object o = new ArrayList<>();这里n的编译类型是Number,运行类型是Integer。编译期编译器只允许你调用Number类里“声明过”的方法;但运行期JVM动态分派会找到Integer里重写过的实现。这就是为什么Number里定义的intValue()能安全调用——Integer肯定实现了它。
但你需要注意反向的问题。如果父类引用指向子类对象,然后你想调用子类特有的方法,必须做显式类型转换,否则编译报错:
Number n = new Integer(10); // n.intValue() 可以调用,因为Number里有这个抽象方法 // n.compareTo(10) 就不行,因为Number里没有compareTo Integer i = (Integer) n; // 显式向下转型才能访问Integer私有方法如果父类引用指向的对象根本不是那个子类型,强行转换会抛ClassCastException。所以在做向下转型之前,先instanceof判断一下是稳妥习惯。
8.3 重写与重载:继承之后方法签名的纪律
子类继承父类之后,能对方法做两类操作:重写(Override)和重载(Overload)。
重写的规则是:方法名、参数列表必须与父类完全一致,返回类型可以是父类返回类型的子类型(协变返回),访问修饰符不能比父类更严格,不能抛出比父类更宽泛的受检异常。
重载是:方法名相同,参数列表不同。它其实跟继承关系不大,同一个类里也能重载。
新手最容易犯的错误是:想重写父类方法,结果参数类型写错了,变成了重载而不是重写;想调用父类被重写的方法,写super.method()结果燃出不同的行为。在方法上面加@Override注解是强制校验的好习惯——如果你写的是重载,编译器会直接红屏提醒你“这不是重写”,能提前拦下一批隐蔽Bug。
实操心得:写子类的时候,尽量让父类方法使用
protected或public的访问级别,别用private。private方法在子类里无法被重写(Java里private方法根本不参与动态分派),而且子类里跟父类private方法同名同参的方法,看起来像重写,其实根本不是——它是子类自己的新方法。这种“假重写”很容易让代码阅读者产生误解。
9. 常见问题与排查技巧
9.1 子类构造器为什么必须调用父类构造器
每次创建子类对象时,JVM都会先执行父类的构造器,再执行子类的构造器。如果父类有个带参构造器且没有默认构造器,子类构造器第一行必须显式super(参数),否则编译无法通过。
这个规则的深层原因是:子类对象本质上是一个“扩展版的父类对象”,父类部分的字段必须先初始化到位,子类才能在它上面添加内容。如果你不在子类构造器里调用父类带参构造器,父类的某些字段就没有合适的初始值,对象会处于不完整状态。
我见过不止一次,有人给父类加了个带参构造器之后,子类全部编译失败。这时候你要做的就是去每个子类构造器里补上super(...)调用,并确保参数语义正确。
9.2 继承层级过深带来的维护噩梦
有些项目里能见到五六层以上的继承链,改需求的时候想死的心都有。举个例子:你改父类的一个方法实现,怎么知道哪些子类依赖旧行为?靠全局搜索?搜索出来的调用方几十处,每一处都得重新验证。
我给你的建议是:如果你发现自己的类继承层级超过三四层,先停下来想想是不是设计出问题了。更符合实践的做法是“组合优先于继承”——子类持有父类的实例,通过调用父类实例的方法来复用逻辑,而不是一路extends到底。
继承的适用场景是“子类和父类有一种清晰的is-a关系,且子类确实需要扩展父类的行为”;如果你的目的只是复用几个工具方法,组合明显更合适。
9.3 使用IDE定位继承关系的小技巧
一个很实用的定位继承关系的办法:打开某个类的源码文件,在类名那一行右键,选择Diagrams -> Show Diagram,绝大多数主流IDE能看到继承关系图。如果嫌图形太花,也可以用快捷键跳到父类,然后顺着implements、extends关键字一路向上跳。这个方法比翻文档快得多,尤其是在你阅读第三方框架源码的时候。
另外你可以写一行打印语句,运行时直接看直接父类的类名:
System.out.println(ArrayList.class.getSuperclass());输出是class java.util.AbstractList。想继续往上,就再打印AbstractList的父类,用不了几次就能把整条线拉出来。虽然不能直接用代码看到“根到子”的全部链条,但每次跳一级,足够你把继承关系摸清了。
10. 怎么把这套知识用在自己的代码里
10.1 判断该用继承还是该用接口或组合
我给自己定的一个判断准则分享给你:
- 如果“子类确实能够被当作父类的替身使用”,也就是is-a关系成立,比如
Student是Person的一种,适合继承。 - 如果“只是需要某种能力,不在乎这个能力由谁提供”,适合接口。
- 如果“想复用某个类的内部逻辑,但不想绑定生命周期和血缘关系”,适合组合。
举一个具体例子:假设你在做一个订单处理系统,不同渠道的订单支付逻辑差别很大。你可能会想建一个OrderHandler父类,然后WechatOrderHandler、AlipayOrderHandler都继承它。这样设计本身不差,但更好的方式是定义OrderHandler接口,然后每个实现类各自持有一份公共的订单校验逻辑实例。继承带来的强耦合在业务场景里越来越不划算,因为父类一旦改动,所有子类都被动牵连。
10.2 重写equals和hashCode时的继承注意点
如果父类已经重写了equals,子类也重写了equals,你需要特别注意equals和hashCode的一致性要求:两个对象equals为true,它们的hashCode必须一样。否则HashMap、HashSet这类集合会出现“存进去取不出来”的问题。
继承场景下最容易踩的坑是:父类equals里用了instanceof,而子类重写equals时也基于instanceof,然后两个类互相比较时,equals出现“不对称”行为——parent.equals(child)为true,但child.equals(parent)为false。这个问题的根因是equals没有保持对称性。建议的做法是,重写equals时先判断getClass()是否完全一致,而不是用instanceof。除非你能保证这个类不会被继承,否则这个坑早晚会踩到。
10.3 从继承关系反推一个类的设计意图
我读一个陌生类的时候,第一步永远是看它的继承关系。只要看到这几个结构,就能推个八九不离十:
- 如果父类是
Abstract开头,说明这个类有通用的模板逻辑,具体实现留给子类。 - 如果类实现了
Cloneable和Serializable,说明它需要考虑克隆和序列化场景。 - 如果类被
final修饰,说明设计者不想让它被扩展,一般是考虑到安全性或不可变性(像是String、Integer等包装类)。 - 如果类继承了一个
Filter或Wrapper类,说明它大概率是装饰器模式的一环,核心能力在“包装其他对象并增强”。
这种方法远比死记类名高效,因为JDK的命名和设计风格统一性很高,一旦掌握规律,看到新类基本就能猜到它的结构。
写在后面的一些经验
继承关系这东西,本质不是让你去背哪些类挂了哪些子类,而是帮你建立“类之间如何协奏”的直觉。我当年学的时候也走了弯路,以为继承关系图只要能背下来,面试就稳了,结果真到业务里设计类结构,还是被多级继承的耦合问题坑了好几回。后来我总结出一个规律:看一个类的继承链,重点看它在每一层“承担了什么职责”——顶层定义抽象能力,中层提供默认实现,底层负责具体细节。看懂了职责分配,继承关系的记忆压力一下子小了很多。
另外一个小技巧强烈推荐:无论你用什么语言,在写子类之前,先问自己一句“我究竟是想继承父类的行为,还是想复用它内部的能力”。这个习惯能帮你避免至少一半的不合理继承。继承是面向对象里最强大的武器,但也是耦合度最高的关系,越是能少用就少用,越是能用接口和组合替代,代码的后续维护就越轻松。