本篇假设你已经掌握类与对象、继承、重写与多态,并在机器上装好了 JDK 8 或更高版本。文中所有示例均为可直接 javac 编译运行的最小片段,涉及字节码的地方会给出 javap 的验证方式,建议边读边跑。如果你还在纠结「什么时候该用抽象类、什么时候该用接口」,读完第二、三章自然会有一套可复用的判断标准。
面向对象入门时我们习惯把「类」理解成数据与行为的打包,但真正决定代码可维护性的,是类型之间如何约定契约 。抽象类负责纵向的「是什么」,接口负责横向的「能干什么」,内部类解决「只在某个上下文里才有意义的类型」,枚举解决「取值有限且不可变」的状态建模。本篇沿着这条主线,把语法背后的字节码与设计取舍一次讲透。
一、抽象类:把「不完整的实现」合法化 1.1 abstract 的基本语法与约束 被 abstract 修饰的方法没有方法体,被 abstract 修饰的类不能 new。两者之间是一条单向约束:有抽象方法的类必须声明为抽象类,但抽象类完全可以没有任何抽象方法 。后者的用意是「这个类表达的是一个概念,不允许直接落地成对象」,例如 HttpServlet 即便没有抽象方法也不允许直接实例化。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 public abstract class Shape { protected String name; public Shape (String name) { this .name = name; } public abstract double area () ; public void describe () { System.out.println(name + " 的面积是 " + area()); } } class Circle extends Shape { private final double radius; public Circle (double radius) { super ("圆形" ); this .radius = radius; } @Override public double area () { return Math.PI * radius * radius; } }
抽象方法有三个「不能同时出现」的修饰符组合,原因都指向同一件事——抽象方法存在的唯一意义是被子类重写 :
冲突组合
编译错误原因
abstract + final
final 方法禁止重写,抽象方法必须被重写
abstract + private
private 方法对子类不可见,无法重写
abstract + static
static 方法属于类且不支持多态分派,无法重写
1.2 为什么抽象类不能实例化,但构造器依然存在 这是一个面试高频点。抽象类的构造器不但存在,而且一定会被调用 :子类构造器的第一条指令必然是 super(...),否则编译器自动插入 super()。抽象类的 <init> 在字节码层面与普通类毫无区别,差别只有一个标志位。
1 2 3 4 5 6 7 8 9 10 11 12 13 public abstract class AbstractDemo { private final int id; public AbstractDemo (int id) { this .id = id; System.out.println("父类构造器执行, id=" + id); } public int getId () { return id; } }
用 javap -v AbstractDemo.class 可以看到类访问标志里带有 ACC_ABSTRACT:
1 flags ACC_PUBLIC, ACC_ABSTRACT, ACC_SUPER
真正拦住 new 的是 JVM 的类加载后的实例化检查 :当执行 new 指令时,如果目标类带有 ACC_ABSTRACT(或 ACC_INTERFACE)标志,虚拟机直接抛出 InstantiationError。也就是说,抽象性是运行期语义 而非仅仅是编译期语法糖——反射 Class.newInstance() 同样会失败。这一点非常关键:抽象类的构造器是为「被继承」服务的,它为所有子类统一了状态初始化的入口,final 字段、protected 资源、模板方法的前置校验都可以放在这里完成。
1.3 模板方法模式:抽象类最典型的应用场景 模板方法模式的精髓是父类定义算法骨架,把可变步骤延迟到子类 ,同时用 final 锁死骨架本身防止子类篡改流程。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 public abstract class AbstractOrderProcessor { public final void process (String orderId) { validate(orderId); deductStock(orderId); pay(orderId); if (needNotify()) { notifyUser(orderId); } } private void validate (String orderId) { if (orderId == null || orderId.isEmpty()) { throw new IllegalArgumentException ("订单号非法" ); } } private void deductStock (String orderId) { System.out.println("扣减库存: " + orderId); } private void notifyUser (String orderId) { System.out.println("通知用户: " + orderId); } protected abstract void pay (String orderId) ; protected boolean needNotify () { return true ; } } class AliPayProcessor extends AbstractOrderProcessor { @Override protected void pay (String orderId) { System.out.println("调用支付宝 SDK 支付: " + orderId); } } class InternalOrderProcessor extends AbstractOrderProcessor { @Override protected void pay (String orderId) { System.out.println("内部记账, 不走真实支付: " + orderId); } @Override protected boolean needNotify () { return false ; } }
注意这里的访问控制设计:process 是 public final,暴露给调用方;pay 是 protected abstract,只暴露给子类;validate、deductStock、notifyUser 是 private,子类看不见也不需要看见。这种「对外窄、对内宽、对子类精准开放 」的可见性划分,是模板方法优于「接口 + 工具类」的地方。
1.4 抽象类的本质:is-a 关系与单继承约束 抽象类表达的是 is-a (是一个):Circle 是一个 Shape,因此 Circle 天然继承了 Shape 的字段、构造器与已实现方法,子类与父类共享同一份状态。Java 只允许单继承 ,这条限制带来了两个后果:一是类层次清晰、没有 C++ 那样的多继承歧义;二是「能力」这类横向复用无法用抽象类表达,必须交给接口。这也直接决定了下一章接口的定位。
二、接口:从纯契约到可演进的能力 2.1 接口的定义与默认修饰 接口里的一切都是「公开契约」:字段默认 public static final,方法默认 public abstract(JDK 8 之前)。显式写上 private 或 protected 修饰接口成员会直接编译失败。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 public interface Payable { int MAX_RETRY = 3 ; boolean pay (double amount) ; default boolean refund (double amount) { System.out.println("默认退款实现, 金额=" + amount); return true ; } } class WeChatPay implements Payable { @Override public boolean pay (double amount) { System.out.println("微信支付 " + amount + " 元, 最大重试 " + MAX_RETRY); return true ; } }
2.2 JDK 8:default 方法与 static 方法 default 方法的出现只有一个现实原因:让接口在不破坏二进制兼容的前提下演进 。JDK 8 要给 Collection 加 stream()、forEach()、removeIf(),如果只能加抽象方法,所有第三方实现类都会在类加载时因为「未实现抽象方法」而失败。default 方法把实现直接下沉到接口,老实现类无需改动即可编译运行。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 public interface Loggable { void log (String msg) ; default void logError (String msg) { log("[ERROR] " + msg); } default void logWithTime (String msg) { log(System.currentTimeMillis() + " " + msg); } static Loggable console () { return msg -> System.out.println(msg); } } class LoggableDemo { public static void main (String[] args) { Loggable logger = Loggable.console(); logger.logError("磁盘空间不足" ); Loggable fileLogger = msg -> System.out.println("写入文件: " + msg); fileLogger.logWithTime("订单创建" ); } }
default 方法的字节码表现值得一提:它和普通实例方法一样被放进接口的 ClassFile,调用点使用 invokeinterface 指令。与抽象方法的区别仅在于方法是否带有 Code 属性 ——有 Code 就是有实现,虚拟机在执行 invokeinterface 时直接在接口的方法表里找到可执行代码,无需再回退到实现类。
2.3 JDK 9:接口里的 private 方法 多个 default 方法之间也会出现重复代码。JDK 8 里只能把公共逻辑抽成另一个 default 方法(污染 API)或抽到外部工具类(破坏内聚),JDK 9 引入了 private 方法解决这个尴尬。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 public interface ReportGenerator { default String toJson (String data) { return wrap("json" , data); } default String toXml (String data) { return wrap("xml" , data); } private String wrap (String type, String data) { return "<" + type + ">" + data + "</" + type + ">" ; } private static String now () { return java.time.LocalDateTime.now().toString(); } static String header () { return "generated at " + now(); } }
private 接口方法在字节码中是 ACC_PRIVATE 且必须带 Code 属性 (因为它无法被重写,必须有实现)。它的调用使用 invokeinterface(非静态)或 invokestatic(静态),与类内私有方法语义一致。
2.4 多继承与钻石冲突:三条规则 接口之间可以多继承,类可以实现多个接口,于是 C++ 里臭名昭著的「钻石继承问题」在 Java 中必须有明确答案。Java 的策略是:不在语言层面自动做复杂解析,而是用三条简单规则,解析不了就强制开发者显式决策 。
规则一:类优先(class wins)。 父类(含祖先类)中的具体方法,优先级高于任何接口的 default 方法。这条规则保证了「类继承下来的行为不会被接口的默认实现悄悄替换」,也让 JDK 8 升级时老代码的行为绝对不变。
规则二:子接口优先(most specific wins)。 当两个接口存在继承关系时,更具体的那个接口的实现胜出。
规则三:否则必须显式重写。 如果两个无关接口提供了同名同签名的 default 方法,编译器直接报错,强制实现类用 接口名.super.方法名() 明确指定。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 interface Flyable { default String move () { return "用翅膀飞" ; } } interface Swimmable { default String move () { return "用鳍游" ; } } class Duck implements Flyable , Swimmable { @Override public String move () { return Flyable.super .move() + " / " + Swimmable.super .move(); } } interface Vehicle { default String move () { return "在路上跑" ; } } interface Amphibious extends Vehicle { @Override default String move () { return "水陆两栖移动" ; } } class AmphibiousCar implements Vehicle , Amphibious {} class Animal { public String move () { return "用腿跑" ; } } class Penguin extends Animal implements Flyable {}
Xxx.super.method() 这个语法只在「重写 default 方法」的语境下合法,本质上是一次 invokespecial 调用,绕过了虚分派直接调用接口中的实现。它只能出现在实现类中,不能在接口的 default 方法里调用父接口的实现(父接口的 default 在子接口里用 Parent.super.method() 同样是允许的,但仅限接口继承场景)。
对比一下 C++ 的做法:C++ 允许多继承并用「虚基类」消解重复,代价是对象布局复杂、构造顺序晦涩、指针偏移计算容易出错。Java 干脆禁止类的多继承,只在「无状态」的接口层面允许多继承,然后用三条规则 + 编译期报错把歧义消灭在编码阶段。这是典型的用一点表达能力换取确定性 的设计取舍。
2.5 函数式接口与 @FunctionalInterface 只有一个抽象方法的接口叫函数式接口 (default、static、private 方法不计入,与 Object 中 equals、hashCode、toString 签名相同的方法也不计入)。@FunctionalInterface 不是语法要求,而是一份编译期契约 :加上它之后,如果哪天有人往接口里加了第二个抽象方法,编译立即失败,防止破坏 Lambda 兼容性。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 @FunctionalInterface public interface OrderValidator { boolean validate (String orderId) ; default OrderValidator and (OrderValidator other) { return id -> validate(id) && other.validate(id); } } class ValidatorDemo { public static void main (String[] args) { OrderValidator notEmpty = id -> id != null && !id.isEmpty(); OrderValidator lengthOk = id -> id.length() >= 6 ; OrderValidator combined = notEmpty.and(lengthOk); System.out.println(combined.validate("ORD-20261012" )); System.out.println(combined.validate("ABC" )); } }
2.6 标记接口:Serializable 与 Cloneable Serializable、Cloneable、RandomAccess 这类接口没有任何成员 ,它们的作用是给类型打一个「标签」,由 JVM 或 JDK 代码在运行时通过 instanceof 检查。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 import java.io.*;class User implements Serializable { private static final long serialVersionUID = 1L ; private String name; private transient String password; public User (String name, String password) { this .name = name; this .password = password; } @Override public String toString () { return "User{name='" + name + "', password='" + password + "'}" ; } } class SerializeDemo { public static void main (String[] args) throws Exception { ByteArrayOutputStream bos = new ByteArrayOutputStream (); try (ObjectOutputStream oos = new ObjectOutputStream (bos)) { oos.writeObject(new User ("张三" , "123456" )); } try (ObjectInputStream ois = new ObjectInputStream (new ByteArrayInputStream (bos.toByteArray()))) { User u = (User) ois.readObject(); System.out.println(u); } } }
标记接口与注解之争是个有意思的取舍:Serializable 这类需要编译期类型约束 的(泛型 <T extends Serializable>)适合接口;@Deprecated、@Override 这类纯元数据适合注解。另外 Cloneable 是个著名的设计缺陷——Object.clone() 是 protected,且是否抛 CloneNotSupportedException 取决于运行时 instanceof Cloneable 检查,导致「接口契约」与「方法可见性」错位,这也是业界普遍推荐用拷贝构造器替代 Cloneable 的原因。
2.7 抽象类与接口的选型对照表
对比维度
抽象类 abstract class
接口 interface
关系语义
is-a,强调「是一个」与血缘
can-do,强调「具备某种能力」
继承数量
单继承,一个类只能 extends 一个
多实现,一个类可 implements 多个
实例字段
可以有普通实例字段,能表达状态
只能有 public static final 常量
构造器
有构造器,子类通过 super() 链式调用
没有构造器
方法实现
可含任意实现方法
JDK 8 起支持 default/static,JDK 9 起支持 private
访问修饰符
成员可用 private/protected/public
方法默认且只能是 public(private 仅限 JDK 9+ 的非抽象方法)
调用指令
invokevirtual(类分派,vtable 索引固定)
invokeinterface(需在运行期搜索 itable,历史实现更慢)
演进能力
加抽象方法会破坏子类
加 default 方法向后兼容,这是 Stream API 能落地的前提
适用场景
模板方法、共享状态、骨架代码
能力契约、策略注入、Lambda 目标类型
关于性能那一行需要补充:HotSpot 对 invokeinterface 做了大量优化(内联缓存、类层次分析 CHA),在单态调用点上二者差距已经可以忽略。真正该影响选型的从来不是那几纳秒,而是语义。
三、static 与 final:被低估的两个关键字 3.1 static 的五种用法 static 的本质是「归属类而不归属实例 」,它在类加载的准备/初始化阶段就已就位,生命周期与 Class 对象绑定。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 import static java.lang.Math.PI; public class StaticDemo { static int count = 0 ; final int instanceId; static { System.out.println("静态代码块执行" ); count = 100 ; } { instanceId = ++count; } static int maxOf (int a, int b) { return Math.max(a, b); } static class Config { String host = "localhost" ; } public static void main (String[] args) { System.out.println(PI); System.out.println(maxOf(3 , 5 )); System.out.println(new StaticDemo ().instanceId); System.out.println(new StaticDemo ().instanceId); } }
3.2 类加载时机与静态初始化顺序 类「被加载」和「被初始化」是两件事。JVM 规范严格规定了主动使用 (触发初始化)的几种情形,其余都是被动使用:
场景
是否触发初始化
说明
new 对象、访问静态字段、调用静态方法
是
最典型的主动使用
Class.forName("X")
是
默认 initialize=true
ClassLoader.loadClass("X")
否
只加载,不初始化
子类初始化
是(先父后子)
父类一定先于子类完成初始化
通过子类名访问父类 的静态字段
只初始化父类
子类不被初始化,被动使用
X[] arr = new X[10]
否
数组类型由虚拟机生成,不初始化元素类型
访问编译期常量(static final 字面量)
否
编译期已被常量折叠进调用方字节码
最后一行是很多人踩坑的地方:因为常量折叠,A.CONST 在编译后根本不引用 A 这个类,甚至连 A.class 文件删掉都不影响运行。
3.3 static 的滥用与代价 static 是一把「全局状态」的钥匙,滥用会带来四类问题:测试污染 (测试用例之间互相影响,需要手动 reset)、并发不安全 (静态可变字段天然共享,需要额外同步)、内存泄漏 (静态集合持有对象引用,对象永远无法被 GC)、破坏依赖注入 (无法替换实现,单元测试难以 mock)。判断标准很简单:如果这个东西需要随环境变化、需要被替换、有生命周期,就不要用 static。 常量、无状态工具方法、纯函数才是 static 的舒适区。
3.4 final 的四种语义与常量折叠 final 修饰不同目标时语义完全不同,这是面试里区分「背过」和「理解」的分水岭。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 public class FinalDemo { final int a = 1 ; final StringBuilder sb = new StringBuilder ("ok" ); static final int COMPILE_TIME = 10 ; static final int RUNTIME = new java .util.Random().nextInt(100 ); final void cannotOverride () {} public void demo (final int param) { sb.append("-changed" ); System.out.println(sb + " " + param); } } final class ImmutablePoint { private final int x, y; public ImmutablePoint (int x, int y) { this .x = x; this .y = y; } }
用 javap -c 反编译调用方,可以直观看到常量折叠:访问 COMPILE_TIME 的字节码是直接 bipush 10(常量被硬编码进调用方),而访问 RUNTIME 则是 getstatic 指令。这意味着改了编译期常量必须重新编译所有引用方 ,否则新值不生效——这是发布 jar 包时极其隐蔽的一类 Bug。
3.5 String 常量池相关的坑 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 public class StringPoolDemo { public static void main (String[] args) { String s1 = "java" ; String s2 = "java" ; String s3 = new String ("java" ); String s4 = s3.intern(); System.out.println(s1 == s2); System.out.println(s1 == s3); System.out.println(s1 == s4); String a = "ja" , b = "va" ; String s5 = a + b; String s6 = "ja" + "va" ; System.out.println(s5 == s1); System.out.println(s6 == s1); } }
这里的知识点串起了 static final 与 final:只有编译期可确定的常量表达式 才会被折叠,a + b 因为 a、b 是普通变量(final 修饰且字面量赋值时才会被折叠),只能在运行期用 StringBuilder 拼接。日常编码中比较字符串一律用 equals,== 只用于判断「是否是同一个对象」。
四、内部类:只在某个上下文里才有意义的类型 4.1 四类内部类总览
类型
定义位置
是否持有外部类引用
是否有静态成员
典型用途
成员内部类
类内、方法外,无 static
是(this$0)
不能(除编译期常量)
与外部实例强绑定的辅助对象,如迭代器
静态内部类
类内,带 static
否
可以
Builder、单例 Holder、不依赖外部实例的工具类型
局部内部类
方法或代码块内
是(若在实例方法中)
不能
只在单个方法内复用的实现
匿名内部类
表达式中,new 接口(){}
是(若在实例方法中)
不能
一次性实现,Lambda 之前的主要手段
4.2 成员内部类与 this$0 合成字段 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 public class Outer { private String name = "outer" ; private int value = 42 ; public class Inner { private int value = 100 ; public void print () { System.out.println("内部类字段 value = " + value); System.out.println("外部类字段 value = " + Outer.this .value); System.out.println("外部类私有字段 name = " + name); } } public Inner newInner () { return new Inner (); } public static void main (String[] args) { Outer outer = new Outer (); Outer.Inner inner = outer.newInner(); inner.print(); } }
编译后会产生两个文件:Outer.class 和 Outer$Inner.class。用 javap -p Outer$Inner.class 可以看到编译器偷偷加的字段:
1 final Outer this$0; // 合成字段,编译器自动生成
以及构造签名 Outer$Inner(Outer)——内部类实例必须 持有一个外部类实例的引用,这就是 this$0。私有成员 name 之所以能被直接访问,是因为编译器在 Outer 中生成了 access$000(Outer) 这样的合成静态访问方法,绕过了 private 的可见性限制(JDK 11 之后改用 nest-based access control,即 NestMembers/NestHost 属性,不再生成 access$xxx,但 this$0 依然存在)。
4.3 静态内部类:推荐做法 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 public class UserEntity { private final String name; private final int age; private UserEntity (Builder builder) { this .name = builder.name; this .age = builder.age; } public static class Builder { private String name; private int age; public Builder name (String name) { this .name = name; return this ; } public Builder age (int age) { this .age = age; return this ; } public UserEntity build () { return new UserEntity (this ); } } private static class Holder { static final UserEntity DEFAULT = new Builder ().name("anonymous" ).age(0 ).build(); } public static UserEntity defaultUser () { return Holder.DEFAULT; } }
静态内部类不生成 this$0 字段,不捕获外部实例,也没有 access$xxx 开销。它还能拥有静态成员、独立 new,是所有内部类形态中副作用最小的 。
4.4 局部内部类 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 public class LocalInnerDemo { public static void main (String[] args) { final String prefix = "[LOG] " ; class Formatter { String format (String msg) { return prefix + msg; } } Formatter f = new Formatter (); System.out.println(f.format("系统启动" )); } }
「effectively final」的限制来自数据一致性 :局部变量的生命周期是栈帧,而内部类对象可能存活到栈帧销毁之后。Java 的做法是让编译器把被捕获的变量复制一份到内部类的合成字段 里(值拷贝),如果允许修改,就会出现「外面改了里面没变」的分裂状态。所以干脆禁止修改。
4.5 匿名内部类与 Lambda 之前的世界 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 import java.util.*;public class AnonymousDemo { public static void main (String[] args) { List<String> names = new ArrayList <>(Arrays.asList("bob" , "alice" , "carl" )); Collections.sort(names, new Comparator <String>() { @Override public int compare (String a, String b) { return a.compareTo(b); } }); names.sort((a, b) -> a.compareTo(b)); TimerTask task = new TimerTask () { @Override public void run () { System.out.println("定时任务执行于 " + new Date ()); } }; task.run(); System.out.println("生成的内部类: " + task.getClass().getName()); } }
匿名内部类的字节码名字是 Outer$1.class、Outer$2.class(按出现顺序编号)。它有三个代价:每个匿名类都是一个真实的类文件(增大包体积和方法区占用)、如果定义在实例方法中会隐式持有外部实例、以及多一层调用栈。
4.6 内部类导致的内存泄漏与修复 非静态内部类持有外部类引用,一旦它被一个生命周期更长 的对象(静态缓存、线程池任务、异步回调、GUI 监听器)持有,外部类实例就会被间接钉在堆里无法回收。Android 的 Handler 内存泄漏、Web 应用中 static Map 缓存回调导致的泄漏,根因都在这里。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 import java.lang.ref.WeakReference;import java.util.concurrent.*;public class LeakDemo { private byte [] bigData = new byte [1024 * 1024 ]; public Runnable leakyTask () { return new Runnable () { @Override public void run () { System.out.println("处理数据大小: " + bigData.length); } }; } public static class SafeTask implements Runnable { private final int size; SafeTask(int size) { this .size = size; } @Override public void run () { System.out.println("处理数据大小: " + size); } } public static class WeakTask implements Runnable { private final WeakReference<LeakDemo> ref; WeakTask(LeakDemo demo) { this .ref = new WeakReference <>(demo); } @Override public void run () { LeakDemo demo = ref.get(); if (demo != null ) { System.out.println("处理数据大小: " + demo.bigData.length); } } } public Runnable safeTask () { return new WeakTask (this ); } }
判断口诀:只要内部类不需要访问外部类的实例成员,就把它声明为 static。 IDE(IntelliJ IDEA 的 “Inner class may be static” 提示)会主动提醒,不要忽略。
五、代码块与类初始化顺序 5.1 完整执行顺序演示 单类内的顺序是:静态字段/静态块(按代码出现顺序)→ 实例字段默认值 → 实例块/实例字段初始化(按出现顺序)→ 构造器 。加上继承后,规则变成「先静后动、先父后子 」。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 class Parent { static String ps = print("1. 父类静态字段" ); String pi = print("4. 父类实例字段" ); static { print("2. 父类静态代码块" ); } { print("5. 父类实例代码块" ); } public Parent () { print("6. 父类构造器" ); } static String print (String s) { System.out.println(s); return s; } } public class InitOrderDemo extends Parent { static String cs = print("3. 子类静态字段" ); String ci = print("7. 子类实例字段" ); static { print("8. 子类静态代码块" ); } { print("9. 子类实例代码块" ); } public InitOrderDemo () { print("10. 子类构造器" ); } public static void main (String[] args) { System.out.println("--- 第一次 new ---" ); new InitOrderDemo (); System.out.println("--- 第二次 new ---" ); new InitOrderDemo (); } }
实际输出顺序是:main 运行前先输出静态部分 1、2、3、8(因为执行 main 必须先完成本类初始化,而子类初始化会先触发父类初始化),随后打印分隔行,第一次 new 输出 4、5、6、7、9、10,第二次 new 再次输出 4、5、6、7、9、10——静态部分彻底消失。这段代码值得亲手跑一遍,它把所有规则压缩在了一个文件里。
5.2 静态代码块只执行一次的验证 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 public class StaticOnceDemo { private static int counter = 0 ; static { counter++; System.out.println("静态代码块执行, counter=" + counter); } public StaticOnceDemo () { System.out.println("构造器执行" ); } public static void main (String[] args) throws Exception { new StaticOnceDemo (); new StaticOnceDemo (); new StaticOnceDemo (); Class.forName("StaticOnceDemo" ); System.out.println("最终 counter = " + counter); } }
原因是「类初始化」在同一类加载器下只会执行一次,JVM 用 Class 对象的初始化状态机(being initialized / initialized)保证线程安全:多个线程同时触发初始化时,只有一个线程执行 <clinit>,其余线程阻塞等待。这也是「静态内部类单例」线程安全的底层依据。
5.3 顺序规则速查
序号
执行内容
触发时机
执行次数
1
父类静态字段与静态代码块
类首次主动使用
一次
2
子类静态字段与静态代码块
子类首次主动使用
一次
3
父类实例字段默认值与实例代码块
每次 new
每次
4
父类构造器
每次 new
每次
5
子类实例字段默认值与实例代码块
每次 new
每次
6
子类构造器
每次 new
每次
实例代码块看似多余(能写进构造器),但它有两个不可替代的场景:多个构造器需要共享一段初始化逻辑 (避免重复或抽取方法时还要处理 this() 调用链),以及匿名内部类无法声明构造器 ,只能靠实例块做初始化。
另外要记住一条硬规则:<clinit> 中只能给声明在它之前 的静态字段赋值,可以读取但不能在赋值前引用后面的字段(”非法前向引用”编译错误)。
六、枚举:不可变、可序列化、防反射的类型安全状态 6.1 枚举的本质 enum 是编译器语法糖。编译后它是一个 final class,继承自 java.lang.Enum,每个枚举常量都是该类的 public static final 实例,在 <clinit> 中通过 new 创建;values() 与 valueOf(String) 由编译器自动生成(父类 Enum 里并没有 values())。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 public enum OrderStatus { CREATED, PAID, SHIPPED, FINISHED, CANCELLED; public static void main (String[] args) { for (OrderStatus s : OrderStatus.values()) { System.out.println(s.name() + " -> " + s.ordinal()); } OrderStatus paid = OrderStatus.valueOf("PAID" ); System.out.println(paid == OrderStatus.PAID); System.out.println(OrderStatus.CREATED.compareTo(OrderStatus.PAID)); } }
6.2 带构造器、字段与方法的枚举 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 public enum ResultCode { SUCCESS(200 , "成功" ), PARAM_ERROR(400 , "参数错误" ), NOT_FOUND(404 , "资源不存在" ), SERVER_ERROR(500 , "服务器异常" ); private final int code; private final String desc; ResultCode(int code, String desc) { this .code = code; this .desc = desc; } public int getCode () { return code; } public String getDesc () { return desc; } public static ResultCode of (int code) { for (ResultCode rc : values()) { if (rc.code == code) { return rc; } } throw new IllegalArgumentException ("未知状态码: " + code); } }
6.3 枚举单例:防反射、防序列化的最优解 《Effective Java》把枚举单例称为单例的最佳实现,理由在于它同时堵住了两条破坏路径:
反射 :Constructor.newInstance() 在源码中显式拒绝枚举类型,抛出 IllegalArgumentException: Cannot reflectively create enum objects;
序列化 :枚举序列化时只写入 name 字段,反序列化时通过 Enum.valueOf 返回已有常量,因此即便绕过 readResolve 也不会产生新实例。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 public enum ConfigSingleton { INSTANCE; private final java.util.Properties props = new java .util.Properties(); ConfigSingleton() { props.setProperty("env" , "prod" ); } public String get (String key) { return props.getProperty(key); } public static void main (String[] args) throws Exception { System.out.println(ConfigSingleton.INSTANCE.get("env" )); java.lang.reflect.Constructor<ConfigSingleton> c = ConfigSingleton.class.getDeclaredConstructor(); c.setAccessible(true ); try { c.newInstance(); } catch (Exception e) { System.out.println("反射创建失败: " + e.getMessage()); } } }
此外枚举还天然线程安全(常量在 <clinit> 中创建,由类加载保证一次性),代码量也比「双重检查锁 + volatile」少得多。
6.4 EnumMap 与 EnumSet 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 import java.util.*;public class EnumCollectionDemo { enum Day { MON, TUE, WED, THU, FRI, SAT, SUN } public static void main (String[] args) { EnumMap<Day, String> schedule = new EnumMap <>(Day.class); schedule.put(Day.MON, "需求评审" ); schedule.put(Day.FRI, "周会" ); System.out.println(schedule.get(Day.MON)); EnumSet<Day> weekend = EnumSet.of(Day.SAT, Day.SUN); System.out.println(weekend.contains(Day.SAT)); System.out.println(EnumSet.complementOf(weekend)); } }
EnumSet 在枚举数量 ≤ 64 时使用单个 long 做位向量(RegularEnumSet),超过则退化为 JumboEnumSet(long[])。contains、add 都是一次位运算,这是普通 HashSet 无法企及的。
6.5 枚举与 switch switch 对枚举的支持也是编译器糖:编译器会生成一个 int[] $SwitchMap$xxx 数组,把 ordinal() 映射到 case 编号,因此 switch 对枚举依然是整数跳转,性能与 switch int 一致。不要手工依赖 ordinal() 做持久化或网络传输 ——一旦有人调整了枚举常量的声明顺序,语义就变了,应该用 name() 或自定义 code 字段。
6.6 策略枚举:加班工资计算 这是《Effective Java》的经典例子,用来解决「同一个枚举常量的行为依赖外部条件」的问题:直接在一个枚举里写 switch 会导致新增常量时必须记得改 switch(遗漏就是线上 Bug),策略枚举把行为绑定到常量自身,编译器会强制每个常量都提供实现。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 public enum PayrollDay { MONDAY(PayType.WEEKDAY), TUESDAY(PayType.WEEKDAY), WEDNESDAY(PayType.WEEKDAY), THURSDAY(PayType.WEEKDAY), FRIDAY(PayType.WEEKDAY), SATURDAY(PayType.WEEKEND), SUNDAY(PayType.WEEKEND); private final PayType payType; PayrollDay(PayType payType) { this .payType = payType; } public double pay (double hoursWorked, double hourlyRate) { return payType.pay(hoursWorked, hourlyRate); } private enum PayType { WEEKDAY { @Override double overtimePay (double hours, double rate) { return hours <= HOURS_PER_SHIFT ? 0 : (hours - HOURS_PER_SHIFT) * rate / 2 ; } }, WEEKEND { @Override double overtimePay (double hours, double rate) { return hours * rate / 2 ; } }; private static final int HOURS_PER_SHIFT = 8 ; abstract double overtimePay (double hours, double rate) ; double pay (double hoursWorked, double hourlyRate) { double basePay = hoursWorked * hourlyRate; return basePay + overtimePay(hoursWorked, hourlyRate); } } public static void main (String[] args) { System.out.println("周五工作 10 小时: " + PayrollDay.FRIDAY.pay(10 , 100 )); System.out.println("周日工作 10 小时: " + PayrollDay.SUNDAY.pay(10 , 100 )); } }
七、Lambda 与函数式接口铺垫 7.1 四大核心函数式接口 java.util.function 包定义了几十个函数式接口,最核心的四个覆盖了绝大多数场景:
接口
抽象方法
语义
典型场景
Function<T,R>
R apply(T t)
有输入有输出,做转换
map、字段映射
Consumer<T>
void accept(T t)
有输入无输出,做消费
forEach、日志
Supplier<T>
T get()
无输入有输出,做供给
懒加载、工厂
Predicate<T>
boolean test(T t)
有输入,返回布尔,做判断
filter、校验
7.2 Lambda 语法与类型推断 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 import java.util.function.*;import java.util.*;public class LambdaDemo { public static void main (String[] args) { Function<String, Integer> f1 = (String s) -> { return s.length(); }; Function<String, Integer> f2 = s -> s.length(); Predicate<String> notBlank = s -> s != null && !s.trim().isEmpty(); Consumer<String> printer = s -> System.out.println("消费: " + s); Supplier<List<String>> factory = ArrayList::new ; List<String> data = Arrays.asList("java" , "" , "spring" , null ); data.stream() .filter(notBlank) .map(f2) .forEach(len -> printer.accept("长度=" + len)); System.out.println(factory.get().getClass().getSimpleName()); } }
Lambda 的「目标类型」由赋值上下文决定(变量声明、方法参数、返回值、强制类型转换),这也是为什么 Lambda 不能单独存在——没有目标类型就无法推断。
7.3 方法引用的四种形式 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 import java.util.function.*;import java.util.*;public class MethodRefDemo { static void staticPrint (String s) { System.out.println("[静态] " + s); } void instancePrint (String s) { System.out.println("[实例] " + s); } public static void main (String[] args) { List<String> list = Arrays.asList("b" , "a" , "c" ); Consumer<String> c1 = MethodRefDemo::staticPrint; MethodRefDemo demo = new MethodRefDemo (); Consumer<String> c2 = demo::instancePrint; Function<String, String> upper = String::toUpperCase; Comparator<String> cmp = String::compareTo; Supplier<ArrayList<String>> ctor = ArrayList::new ; Function<String, StringBuilder> sbCtor = StringBuilder::new ; list.forEach(c1); list.forEach(c2); System.out.println(upper.apply("lambda" )); System.out.println(ctor.get().getClass().getSimpleName()); System.out.println(sbCtor.apply("ok" ).append("!" )); } }
方法引用不是 Lambda 的语法糖那么简单——它在编译后写入 MethodHandle 常量,运行期由 LambdaMetafactory 直接绑定到目标方法,少一层转发调用 ,可读性也更好。
7.4 Lambda 与匿名内部类的三个本质区别 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 public class LambdaVsAnonymous { private String name = "外部类字段" ; public void compare () { Runnable r1 = new Runnable () { @Override public void run () { System.out.println("匿名内部类 this = " + this .getClass().getName()); System.out.println("访问外部字段: " + LambdaVsAnonymous.this .name); } }; Runnable r2 = () -> { System.out.println("Lambda 中的 this = " + this .getClass().getName()); System.out.println("直接访问外部字段: " + name); }; r1.run(); r2.run(); } public static void main (String[] args) { new LambdaVsAnonymous ().compare(); } }
差别总结:
字节码 :匿名内部类编译成独立的 Outer$1.class,调用是普通的 invokeinterface;Lambda 编译成 invokedynamic 指令,运行期由 LambdaMetafactory 动态生成实现类(该类在内存中,不落盘为 class 文件),这也是 Lambda 支持「非捕获则复用同一实例」优化的基础。
this 语义 :匿名内部类引入新的作用域,this 指向自身;Lambda 是词法作用域,this 与外部一致,因此不存在「同名遮蔽」问题。
表达能力 :Lambda 只能实现函数式接口(单一抽象方法),匿名内部类可以继承类、可以实现多方法接口、可以定义字段。
调试时注意:Lambda 的栈帧里会出现 lambda$xxx$0 这样的方法名,这是编译器把 Lambda 体编译成的私有合成方法 。看到它不必惊慌,它正是 invokedynamic 最终要绑定的目标。另外,非捕获的 Lambda(不引用外部变量)在多次执行时复用同一实例,捕获变量的 Lambda 每次都会新建实例——不要依赖 Lambda 的「身份」做缓存 key。
八、高频面试题
抽象类可以有构造方法吗?可以被实例化吗? 可以有构造方法,用于初始化父类状态并由子类 super() 调用;不能实例化,因为类带 ACC_ABSTRACT 标志,new 指令会抛 InstantiationError,反射 newInstance 同样失败。
接口可以有成员变量吗? 只能有 public static final 常量(默认就是),没有实例字段,因为接口不参与对象状态。
抽象类与接口怎么选? 需要共享状态/模板骨架/单继承语义选抽象类;需要能力契约/多实现/向后兼容演进(加 default)选接口。
JDK 8 为什么引入 default 方法? 为了让已有接口(如 Collection)在不破坏二进制兼容的前提下新增方法,从而支撑 Stream API。
两个接口有同名 default 方法怎么办? 三条规则:类优先、子接口优先、无关接口必须显式重写并用 Xxx.super.method() 指定。
final 修饰引用变量时,对象内容能改吗? 能。final 只保证引用地址不变,对象内部状态可变;final 类禁止继承,final 方法禁止重写(private 方法隐式 final)。
静态内部类与非静态内部类的区别? 静态内部类不持有 this$0、可独立 new、可含静态成员;非静态内部类必须依附外部实例,且是内存泄漏的常见来源。
匿名内部类捕获的局部变量为什么必须是 final 或 effectively final? 因为变量被值拷贝到内部类的合成字段中,为了防止「内外状态不一致」干脆禁止重新赋值。
类加载/初始化顺序是怎样的? 父类静态 → 子类静态 → 父类实例字段与实例块 → 父类构造器 → 子类实例字段与实例块 → 子类构造器;静态部分只执行一次。
枚举为什么是实现单例的最佳方式? 反射被 Constructor.newInstance 显式拒绝,序列化只写 name 且反序列化走 valueOf,天然线程安全,代码极简。
枚举可以被继承吗? 不能,编译后是 final class 且继承 java.lang.Enum,Java 不支持多继承,因此枚举也无法再 extends 其它类,但可以实现接口。
Lambda 和匿名内部类在字节码上的区别? Lambda 用 invokedynamic + LambdaMetafactory 延迟绑定,不生成 class 文件;匿名内部类生成 Outer$N.class 并使用常规 invokeinterface。
static 方法能被重写吗? 不能。static 方法不具备多态性,子类同名方法只是「隐藏」(hide),调用哪个版本由变量的声明类型 在编译期决定。
switch 支持 String 和枚举的底层原理? String 用 hashCode + equals 双重校验(先哈希后比较,防止哈希碰撞);枚举由编译器生成 $SwitchMap 数组,把 ordinal() 映射为 case 下标。
到这里,面向对象的「类型设计」这条线就完整了:抽象类管纵向继承,接口管横向能力,static/final 管生命周期与不变性,内部类管作用域收敛,枚举管有限状态。下一篇我们将进入「集合框架」,届时会发现 ArrayList 的 Itr(成员内部类)、HashMap 的 Node(静态内部类)、Comparator(函数式接口)全都是本篇知识点的真实落地。