Java 从入门到精通(四):面向对象进阶——抽象类、接口、内部类与枚举

本篇假设你已经掌握类与对象、继承、重写与多态,并在机器上装好了 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;
}

// 必须实现父类的全部抽象方法,否则 Circle 也要声明为 abstract
@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 {

// final 保证算法骨架不被子类重写,这是模板方法的关键
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);

/** 钩子方法:子类可选覆盖,默认返回 true */
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 {
// 字段默认 public static final,且必须在声明时赋值
int MAX_RETRY = 3;

// 方法默认 public abstract
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 方法:提供默认实现,实现类可自由选择是否覆盖
default void logError(String msg) {
log("[ERROR] " + msg);
}

default void logWithTime(String msg) {
log(System.currentTimeMillis() + " " + msg);
}

// 接口静态方法:属于接口本身,不能被实现类继承,只能通过 Loggable.xxx() 调用
static Loggable console() {
return msg -> System.out.println(msg);
}
}

class LoggableDemo {
public static void main(String[] args) {
Loggable logger = Loggable.console(); // 静态方法的正确调用方式
logger.logError("磁盘空间不足");

// 实现类只实现一个抽象方法即可,default 方法免费获得
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 方法:只在接口内部复用,不对外暴露,实现类也无法重写
private String wrap(String type, String data) {
return "<" + type + ">" + data + "</" + type + ">";
}

// private static 方法:供 static 方法复用
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() {
// 通过 接口名.super.方法名() 精确选择,也可以两个都不用而自己实现
return Flyable.super.move() + " / " + Swimmable.super.move();
}
}

// 场景二:子接口优先
interface Vehicle {
default String move() {
return "在路上跑";
}
}

// Amphibious 继承 Vehicle 并重写,它是「更具体」的接口
interface Amphibious extends Vehicle {
@Override
default String move() {
return "水陆两栖移动";
}
}

// 无需重写:Amphibious 比 Vehicle 更具体,直接胜出
class AmphibiousCar implements Vehicle, Amphibious {
}

// 场景三:类优先
class Animal {
public String move() {
return "用腿跑";
}
}

// 即使 Penguin 实现了 Flyable,Animal 的具体方法仍然胜出,default 被忽略
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 方法不计入抽象方法数量
default OrderValidator and(OrderValidator other) {
return id -> validate(id) && other.validate(id);
}
}

class ValidatorDemo {
public static void main(String[] args) {
// Lambda 直接作为函数式接口的实现
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")); // true
System.out.println(combined.validate("ABC")); // false
}
}

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.*;

// 实现 Serializable 表示「我允许被序列化」,真正的读写逻辑由 ObjectOutputStream 完成
class User implements Serializable {
// serialVersionUID 是版本指纹,类结构变化但指纹一致时仍可反序列化
private static final long serialVersionUID = 1L;
private String name;
private transient String password; // transient 字段被序列化机制跳过

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); // password 为 null,被 transient 跳过
}
}
}

标记接口与注解之争是个有意思的取舍: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;   // 静态导入:可以直接写 PI、max 而不带类名

public class StaticDemo {
static int count = 0; // 静态字段:全类共享一份
final int instanceId; // 实例字段:每个对象一份

static { // 静态代码块:类初始化时执行一次
System.out.println("静态代码块执行");
count = 100;
}

{ // 实例代码块:每次 new 都执行
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 {
// 1. final 变量:基本类型值不可变,引用类型「引用不可变」但对象内容可变
final int a = 1;
final StringBuilder sb = new StringBuilder("ok");

// 2. 编译期常量:static final + 基本类型/String + 字面量赋值
static final int COMPILE_TIME = 10;
// 3. 运行期常量:值需要运行期计算,不会被折叠
static final int RUNTIME = new java.util.Random().nextInt(100);

// 4. final 方法:禁止子类重写(private 方法隐式 final)
final void cannotOverride() {}

public void demo(final int param) {
// 5. final 参数:方法内不能重新赋值,常用于匿名内部类/Lambda 捕获语义的显式表达
// param = 2; // 编译错误
sb.append("-changed"); // 合法:对象内容可变
// sb = new StringBuilder(); // 编译错误:引用不可变
System.out.println(sb + " " + param);
}
}

// 6. final 类:禁止继承,String、Integer 都是 final 类
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); // true
System.out.println(s1 == s3); // false
System.out.println(s1 == s4); // true

String a = "ja", b = "va";
String s5 = a + b; // 运行期拼接,产生新对象
String s6 = "ja" + "va"; // 编译期常量折叠,等价于 "java"
System.out.println(s5 == s1); // false
System.out.println(s6 == s1); // true
}
}

这里的知识点串起了 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(); // 等价于 new Outer().new Inner() 中的 this.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;
}

// 静态内部类:不依赖外部实例,可以独立 new,不会造成外部类泄漏
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] "; // JDK 8 起 final 可省略,但必须是 effectively final

// 局部内部类:作用域仅限于本方法
class Formatter {
String format(String msg) {
// 捕获的局部变量必须是 final 或 effectively final
return prefix + msg;
}
}

Formatter f = new Formatter();
System.out.println(f.format("系统启动"));
// prefix = "[X]"; // 一旦重新赋值,上一行捕获处立即编译错误
}
}

「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"));

// JDK 8 之前:用匿名内部类实现比较器,编译后生成 AnonymousDemo$1.class
Collections.sort(names, new Comparator<String>() {
@Override
public int compare(String a, String b) {
return a.compareTo(b);
}
});

// JDK 8 之后:Lambda,语义等价但无额外 class 文件
names.sort((a, b) -> a.compareTo(b));

// 匿名内部类还能继承类(Lambda 只能实现接口),这是它不可完全替代的场景
TimerTask task = new TimerTask() {
@Override
public void run() {
System.out.println("定时任务执行于 " + new Date());
}
};
task.run(); // 直接执行,避免启动 Timer 线程导致进程无法退出
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]; // 模拟大对象

// 错误示范:非静态内部类,且被静态线程池持有 -> bigData 永远无法回收
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); // 始终是 1
}
}

原因是「类初始化」在同一类加载器下只会执行一次,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) {
// values() 返回编译器生成的 $VALUES 数组的克隆
for (OrderStatus s : OrderStatus.values()) {
// name()/ordinal() 来自 Enum 父类,ordinal 是声明顺序,从 0 开始
System.out.println(s.name() + " -> " + s.ordinal());
}

// valueOf 按 name 精确匹配,找不到抛 IllegalArgumentException
OrderStatus paid = OrderStatus.valueOf("PAID");
System.out.println(paid == OrderStatus.PAID); // true,枚举常量全局唯一

// 枚举自带 Comparable(按 ordinal)与 toString
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(省略也默认 private),不允许外部创建实例
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; }

// 自定义查找方法,比直接用 ordinal 做映射可靠得多
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(); // 抛出 IllegalArgumentException
} 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:内部是定长数组,以 ordinal 为下标,比 HashMap 快且无哈希冲突
EnumMap<Day, String> schedule = new EnumMap<>(Day.class);
schedule.put(Day.MON, "需求评审");
schedule.put(Day.FRI, "周会");
System.out.println(schedule.get(Day.MON));

// EnumSet:内部用位向量(long 或 long[])实现,极端节省内存
EnumSet<Day> weekend = EnumSet.of(Day.SAT, Day.SUN);
System.out.println(weekend.contains(Day.SAT)); // true
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;
}

// 委托给策略枚举,新增星期时编译器强制要求指定 PayType
public double pay(double hoursWorked, double hourlyRate) {
return payType.pay(hoursWorked, hourlyRate);
}

// 策略枚举:内嵌枚举,描述「工资计算策略」而不是「某一天」
private enum PayType {
WEEKDAY {
@Override
double overtimePay(double hours, double rate) {
// 工作日超出 8 小时的部分算加班
return hours <= HOURS_PER_SHIFT ? 0 : (hours - HOURS_PER_SHIFT) * rate / 2;
}
},
WEEKEND {
@Override
double overtimePay(double hours, double rate) {
// 周末全部工时按 1.5 倍计
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> 推出参数类型
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");

// 1. 静态方法引用:类名::静态方法
Consumer<String> c1 = MethodRefDemo::staticPrint;

// 2. 实例方法引用(特定对象):对象::实例方法
MethodRefDemo demo = new MethodRefDemo();
Consumer<String> c2 = demo::instancePrint;

// 3. 任意对象的实例方法引用:类名::实例方法(第一个参数成为调用者)
Function<String, String> upper = String::toUpperCase;
Comparator<String> cmp = String::compareTo;

// 4. 构造器引用:类名::new
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() {
// 匿名内部类:有自己的 this,指向内部类实例
Runnable r1 = new Runnable() {
@Override
public void run() {
System.out.println("匿名内部类 this = " + this.getClass().getName());
System.out.println("访问外部字段: " + LambdaVsAnonymous.this.name);
}
};

// Lambda:没有自己的 this,this 就是外部类的 this
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。

八、高频面试题

  1. 抽象类可以有构造方法吗?可以被实例化吗? 可以有构造方法,用于初始化父类状态并由子类 super() 调用;不能实例化,因为类带 ACC_ABSTRACT 标志,new 指令会抛 InstantiationError,反射 newInstance 同样失败。
  2. 接口可以有成员变量吗? 只能有 public static final 常量(默认就是),没有实例字段,因为接口不参与对象状态。
  3. 抽象类与接口怎么选? 需要共享状态/模板骨架/单继承语义选抽象类;需要能力契约/多实现/向后兼容演进(加 default)选接口。
  4. JDK 8 为什么引入 default 方法? 为了让已有接口(如 Collection)在不破坏二进制兼容的前提下新增方法,从而支撑 Stream API。
  5. 两个接口有同名 default 方法怎么办? 三条规则:类优先、子接口优先、无关接口必须显式重写并用 Xxx.super.method() 指定。
  6. final 修饰引用变量时,对象内容能改吗? 能。final 只保证引用地址不变,对象内部状态可变;final 类禁止继承,final 方法禁止重写(private 方法隐式 final)。
  7. 静态内部类与非静态内部类的区别? 静态内部类不持有 this$0、可独立 new、可含静态成员;非静态内部类必须依附外部实例,且是内存泄漏的常见来源。
  8. 匿名内部类捕获的局部变量为什么必须是 final 或 effectively final? 因为变量被值拷贝到内部类的合成字段中,为了防止「内外状态不一致」干脆禁止重新赋值。
  9. 类加载/初始化顺序是怎样的? 父类静态 → 子类静态 → 父类实例字段与实例块 → 父类构造器 → 子类实例字段与实例块 → 子类构造器;静态部分只执行一次。
  10. 枚举为什么是实现单例的最佳方式? 反射被 Constructor.newInstance 显式拒绝,序列化只写 name 且反序列化走 valueOf,天然线程安全,代码极简。
  11. 枚举可以被继承吗? 不能,编译后是 final class 且继承 java.lang.Enum,Java 不支持多继承,因此枚举也无法再 extends 其它类,但可以实现接口。
  12. Lambda 和匿名内部类在字节码上的区别? Lambda 用 invokedynamic + LambdaMetafactory 延迟绑定,不生成 class 文件;匿名内部类生成 Outer$N.class 并使用常规 invokeinterface。
  13. static 方法能被重写吗? 不能。static 方法不具备多态性,子类同名方法只是「隐藏」(hide),调用哪个版本由变量的声明类型在编译期决定。
  14. switch 支持 String 和枚举的底层原理? String 用 hashCode + equals 双重校验(先哈希后比较,防止哈希碰撞);枚举由编译器生成 $SwitchMap 数组,把 ordinal() 映射为 case 下标。

到这里,面向对象的「类型设计」这条线就完整了:抽象类管纵向继承,接口管横向能力,static/final 管生命周期与不变性,内部类管作用域收敛,枚举管有限状态。下一篇我们将进入「集合框架」,届时会发现 ArrayList 的 Itr(成员内部类)、HashMap 的 Node(静态内部类)、Comparator(函数式接口)全都是本篇知识点的真实落地。