Java 基础体系 · 第 3/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。

Java 25 类型与控制流:基本类型、引用、Record、sealed 和模式匹配

Java 的类型系统决定“一个值是什么、能否赋给某个变量、能调用哪些操作”;控制流决定“程序按什么顺序执行,以及在什么条件下改变路径”。recordsealed 和模式匹配并不是孤立语法,它们分别把数据建模、继承边界和类型判断连接起来。

本文以 Java 25 LTS 为范围,区分 Java 语言规范保证的行为、JDK 的常见实现方式,以及 Java 25 中仍处于预览阶段的能力。


1. 先建立类型系统的基本模型

Java 中的类型分为两大类:

Type
├── Primitive type    基本类型
└── Reference type    引用类型

1.1 基本类型表示值本身

Java 有 8 种基本类型:

类型 位宽 典型用途
boolean 语言层面只有 truefalse 逻辑值
byte 8 位有符号整数 小整数、二进制数据
short 16 位有符号整数 较小整数
int 32 位有符号整数 默认整数类型
long 64 位有符号整数 大整数、时间戳
char 16 位无符号 UTF-16 代码单元 UTF-16 单元
float 32 位 IEEE 754 浮点数 较低精度浮点
double 64 位 IEEE 754 浮点数 默认浮点类型

void 常被称为一种类型,但它不能声明变量,也没有值。它表示某个方法不返回结果。

基本类型变量直接存储一个值:

int a = 42;
int b = a;
b = 100;

System.out.println(a); // 42
System.out.println(b); // 100

赋值时复制的是整数值,因此修改 b 不会影响 a

1.2 char 不是完整 Unicode 字符

char 的范围是 \u0000\uFFFF,表示一个 UTF-16 代码单元,而不一定表示一个完整 Unicode 码点。

char a = 'A';

String text = "😀";
System.out.println(text.length()); // 2

😀 的 Unicode 码点超出 BMP,需要两个 UTF-16 代码单元。若要按 Unicode 码点处理字符串,应使用:

int codePointCount =
        text.codePointCount(0, text.length());

System.out.println(codePointCount); // 1

因此,char 适合表达 UTF-16 单元,不应未经判断就当作“一个用户可见字符”。

1.3 整数溢出是规范定义的结果

对于整数基本类型,算术结果超出类型范围时不会自动抛出异常,而是按该类型的位宽进行补码截断。

int x = Integer.MAX_VALUE;
int y = x + 1;

System.out.println(y); // -2147483648

Integer.MAX_VALUE2^31 - 1。加一后需要第 32 位之外的结果,转换回 int 后得到最小值 -2^31

这和 Math.addExact 不同:

int result = Math.addExact(Integer.MAX_VALUE, 1);
// 抛出 ArithmeticException

两者的差别是:

  • +:按基本类型算术规则溢出;
  • Math.addExact:检测溢出并抛出异常。

在金额、计数器、容量和安全边界计算中,不能假设整数溢出会自动失败。

1.4 浮点数遵循 IEEE 754 的特殊值规则

浮点类型可以表示:

  • 正无穷和负无穷;
  • NaN,即 Not-a-Number;
  • 正零和负零。
double a = 0.0 / 0.0;
double b = 1.0 / 0.0;

System.out.println(a); // NaN
System.out.println(b); // Infinity
System.out.println(a == a); // false

NaN 不等于任何值,包括它自己。判断应使用:

Double.isNaN(a)
Double.isInfinite(b)

浮点数也不适合直接用 == 判断十进制金额是否相等,因为很多十进制小数无法用二进制浮点数精确表示。


2. 引用类型:变量保存的是对象引用

引用类型包括:

  • 类类型;
  • 接口类型;
  • 数组类型;
  • 枚举类型;
  • record 类型;
  • 注解类型;
  • 类型变量和参数化类型等。

引用变量通常保存一个指向对象的引用,而不是对象本身:

String first = new String("java");
String second = first;

System.out.println(first == second); // true

firstsecond 引用同一个对象。

2.1 ==equals

对于基本类型,== 比较值:

int a = 10;
int b = 10;

System.out.println(a == b); // true

对于引用类型,== 比较引用是否指向同一个对象:

String a = new String("java");
String b = new String("java");

System.out.println(a == b);      // false
System.out.println(a.equals(b)); // true

因此:

  • ==:引用身份比较;
  • equals:由类型定义的逻辑相等比较。

Object.equals 默认仍然按身份比较。String 覆盖了它,改为比较字符内容。

2.2 null 是没有对象的引用值

任何引用类型的变量都可以为 null

String value = null;

null 不是一个对象,也不能调用方法:

value.length(); // NullPointerException

null 也不能赋给基本类型:

int x = null; // 编译错误

更隐蔽的情况来自自动拆箱:

Integer value = null;
int x = value; // 运行时 NullPointerException

这里发生了隐式的:

int x = value.intValue();

所以包装类型不仅改变存储方式,也会把 null 风险带入基本类型运算。

2.3 数组是对象,数组类型是引用类型

即使元素是基本类型,数组本身仍然是对象:

int[] numbers = {1, 2, 3};

System.out.println(numbers.getClass().getName());
// [I

数组有固定长度,并且运行时知道自己的元素类型:

Object value = new String[] {"a", "b"};

Object[] objects = (Object[]) value; // 成功
objects[0] = "c";                    // 成功
objects[1] = new StringBuilder();    // ArrayStoreException

String[]Object[] 的子类型,所以赋值转换合法;但数组运行时仍检查实际元素类型,写入 StringBuilder 时失败。

这体现了数组协变的代价。泛型集合通常使用不变类型,避免类似的运行时数组存储错误。


3. 类型转换:赋值是否成立取决于转换上下文

3.1 扩大基本类型转换通常是安全的

例如:

int count = 10;
long total = count;
double value = total;

这些转换不需要显式强制转换,因为目标类型可以表示源类型的整数范围,或者语言允许进行扩大转换。

但“扩大”不等于“数学上永远精确”。例如从 longfloat 可能丢失低位精度,因为 float 的有效精度有限。

3.2 缩小基本类型转换必须显式进行

int value = 130;
byte result = (byte) value;

System.out.println(result); // -126

byte 的范围是 -128127。转换过程保留低 8 位,130 的结果解释为 -126

显式强制转换只表示“开发者确认接受这个转换”,并不表示结果一定符合业务预期。

3.3 常量表达式有特殊的窄化规则

下面的代码可以编译:

byte x = 10;

因为整数常量 10 在编译期可确定,并且能表示在 byte 范围内。

但变量不适用同样规则:

int n = 10;
byte x = n; // 编译错误

即使运行时 n 的值恰好是 10,编译器也必须按照 int 类型处理它,不能假设所有执行路径都在 byte 范围内。

3.4 装箱与拆箱

包装类型把基本值包装成对象:

Integer boxed = 42; // 装箱
int primitive = boxed; // 拆箱

等价的显式形式大致是:

Integer boxed = Integer.valueOf(42);
int primitive = boxed.intValue();

规范定义了装箱、拆箱转换;具体对象缓存范围属于实现和库行为,不应依赖对象身份:

Integer a = 100;
Integer b = 100;

System.out.println(a == b); // 常见实现中为 true,但不应作为业务判断
System.out.println(a.equals(b)); // true

判断包装数值时应使用 equals,或者先安全拆箱。

3.5 二元数值提升会改变表达式类型

对数值运算,Java 会根据操作数进行二元数值提升。简化来看:

  1. 只要有 double,结果通常提升为 double
  2. 否则有 float,提升为 float
  3. 否则有 long,提升为 long
  4. 否则提升为 int

因此:

byte a = 1;
byte b = 2;

var sum = a + b;
System.out.println(((Object) sum).getClass()); // Integer

a + b 的结果是 int,不是 byte。下面代码不能编译:

byte sum = a + b; // 编译错误

如果确实接受截断,可以显式转换:

byte sum = (byte) (a + b);

但当运算可能溢出时,显式转换只是隐藏风险,不会消除风险。


4. 控制流的基础:表达式、语句和完成方式

Java 程序由表达式和语句组成。

  • 表达式产生值,或者产生副作用;
  • 语句控制执行路径;
  • 某些语句以“正常完成”结束;
  • 某些语句以“突然完成”结束,例如 returnbreakcontinue 和抛出异常。

4.1 表达式通常按从左到右求值

int result = first() + second();

Java 先求值 first(),再求值 second(),最后执行加法。方法调用参数也遵循确定的求值顺序:

call(makeA(), makeB(), makeC());

执行顺序是:

makeA()
→ makeB()
→ makeC()
→ call(...)

如果前面的表达式抛出异常,后面的表达式不会执行。

4.2 短路逻辑运算符改变控制流

&&|| 不一定求值右侧表达式:

if (value != null && !value.isEmpty()) {
    System.out.println(value);
}

value == null 时,右侧 !value.isEmpty() 不会执行,因此不会发生空指针异常。

对比:

if (value != null & !value.isEmpty()) {
    // 仍可能抛出 NullPointerException
}

& 对布尔操作数是非短路逻辑与,左右两侧都会求值。

4.3 if 的条件必须是 boolean

Java 不把整数自动转换为布尔值:

int count = 1;

if (count) { // 编译错误
}

必须明确写出条件:

if (count != 0) {
}

这与某些允许“零为假、非零为真”的语言不同。

4.4 循环中的 breakcontinue

for (int i = 0; i < 5; i++) {
    if (i == 2) {
        continue;
    }
    if (i == 4) {
        break;
    }
    System.out.println(i);
}

输出:

0
1
3

执行路径是:

  1. i = 0,打印;
  2. i = 1,打印;
  3. i = 2,执行 continue,跳过打印;
  4. i = 3,打印;
  5. i = 4,执行 break,结束循环。

嵌套循环可以使用带标签的 break,但标签改变的是控制流目标,不改变变量作用域:

outer:
for (int i = 0; i < 3; i++) {
    for (int j = 0; j < 3; j++) {
        if (i == 1 && j == 1) {
            break outer;
        }
    }
}

4.5 switch 表达式必须产生结果

Java 支持语句形式和表达式形式的 switch

int score = 85;

String level = switch (score / 10) {
    case 10, 9 -> "A";
    case 8 -> "B";
    case 7 -> "C";
    default -> "D";
};

箭头规则不会发生传统 case 的贯穿执行。若使用代码块,需要通过 yield 返回结果:

String level = switch (score / 10) {
    case 10, 9 -> "A";
    case 8 -> {
        System.out.println("good");
        yield "B";
    }
    default -> "D";
};

switch 表达式必须覆盖所有可能结果,否则编译器拒绝代码。通常使用 default,或者使用枚举、sealed 类型的穷尽性分析。


5. 类、接口和引用转换:运行时类型与编译时类型

考虑以下层次:

interface Named {
    String name();
}

class User implements Named {
    public String name() {
        return "user";
    }

    void login() {
    }
}
Named value = new User();

这里:

  • 编译时类型是 Named
  • 运行时对象类型是 User
  • 通过 value 只能直接调用 Named 声明的成员;
  • 覆盖方法的实际实现由运行时类型决定。
System.out.println(value.name()); // 调用 User.name()
value.login();                    // 编译错误

如果明确确认对象类型,可以强制转换:

User user = (User) value;
user.login();

但错误转换会在运行时抛出 ClassCastException

Named value = new OtherNamed();
User user = (User) value; // 运行时失败

类型模式把“判断类型”和“转换变量”合并起来:

if (value instanceof User user) {
    user.login();
}

只有当 valuenull 且运行时类型可赋值给 User 时,模式匹配成功。

User user = null;

if (user instanceof User u) {
    // 不会进入,因为 null 不匹配类型模式
}

6. Record:以数据状态为中心的类

record 是一种特殊的类声明,适合表示主要由固定组件构成的数据值。

public record Point(int x, int y) {
}

这相当于声明了两个组件:

组件 x: int
组件 y: int

编译器会生成:

  • 私有且不可重新赋值的字段;
  • 同名访问器 x()y()
  • 规范构造器;
  • equals
  • hashCode
  • toString

访问器不是 JavaBean 风格的 getX(),而是组件名本身:

Point point = new Point(3, 4);

System.out.println(point.x()); // 3
System.out.println(point.y()); // 4
System.out.println(point);     // Point[x=3, y=4]

6.1 Record 的规范构造器

可以使用紧凑构造器验证输入:

public record UserId(String value) {
    public UserId {
        if (value == null || value.isBlank()) {
            throw new IllegalArgumentException("value must not be blank");
        }
    }
}

紧凑构造器中可以直接使用组件名。编译器负责完成最终字段赋值,因此不能再次写:

this.value = value; // 紧凑构造器中不应这样写

创建对象时,验证流程是:

new UserId(input)
→ 进入规范构造器
→ 检查 null 和空白
→ 检查失败则抛出异常
→ 检查成功后由编译器完成字段初始化

也可以显式声明规范构造器:

public record UserId(String value) {
    public UserId(String value) {
        if (value == null || value.isBlank()) {
            throw new IllegalArgumentException();
        }
        this.value = value;
    }
}

6.2 Record 不是深度不可变对象

Record 的组件字段不能被重新赋值,但组件引用指向的对象仍可能可变:

import java.util.List;

record Group(List<String> members) {
}

var members = new java.util.ArrayList<>(List.of("A"));
var group = new Group(members);

members.add("B");

System.out.println(group.members()); // [A, B]

这里 Group 的字段引用没有改变,但它引用的 ArrayList 改变了。

如果需要更强的不可变边界,必须在构造时复制:

import java.util.List;

record ImmutableGroup(List<String> members) {
    public ImmutableGroup {
        members = List.copyOf(members);
    }
}

List.copyOf 还会拒绝 null 元素,并返回不可修改的列表视图或副本。这个设计取舍属于组件类型的语义,不是 record 自动提供的能力。

6.3 Record 可以自定义行为,但不能改变其核心身份

Record 可以声明方法、静态成员和实现接口:

record Celsius(double value) {
    double toFahrenheit() {
        return value * 9 / 5 + 32;
    }
}

但它不能:

  • 显式继承另一个类;
  • 声明实例字段来增加独立状态;
  • 把 record 组件字段改成可变字段;
  • 通过普通继承扩展为子类。

Record 隐式继承 java.lang.Record,本身是 final 的。它适合表达值对象、消息、解析结果和模式匹配中的数据分支,不适合需要复杂可变生命周期的实体对象。

6.4 Record 的相等性是组件相等性

record Point(int x, int y) {
}

System.out.println(new Point(1, 2).equals(new Point(1, 2))); // true

对于引用组件,生成的 equals 使用组件对象的 equals 语义;对于数组组件,数组的 equals 默认仍是身份比较:

record Bytes(byte[] value) {
}

var a = new Bytes(new byte[] {1, 2});
var b = new Bytes(new byte[] {1, 2});

System.out.println(a.equals(b)); // false,数组引用不同

如果业务需要按数组内容相等,就需要重新设计组件类型,例如使用不可变字节值对象,或明确覆盖 equalshashCode


7. Sealed:显式限制继承边界

sealed 用来限制某个类或接口允许哪些直接子类型:

sealed interface Shape
        permits Circle, Rectangle {
}

record Circle(double radius) implements Shape {
}

record Rectangle(double width, double height) implements Shape {
}

这里 Shape 的直接实现者只有 CircleRectangle

每个直接子类型必须明确选择一种继承策略:

  • final:不允许继续扩展;
  • sealed:继续限制下一层;
  • non-sealed:重新开放继承。
sealed interface Message
        permits TextMessage, BinaryMessage, ExtensionMessage {
}

record TextMessage(String text) implements Message {
}

final class BinaryMessage implements Message {
}

non-sealed class ExtensionMessage implements Message {
}

继承关系是:

Message
├── TextMessage       final record
├── BinaryMessage     final class
└── ExtensionMessage  non-sealed,重新开放

7.1 permits 不是任意白名单

直接子类型必须满足语言规范要求,例如:

  • 必须直接扩展或实现 sealed 类型;
  • 必须出现在 permits 中,或满足允许省略 permits 的声明规则;
  • 相关类型必须满足包、模块和可访问性约束;
  • 直接子类型必须声明 finalsealednon-sealed

因此,sealed 是编译器和运行时都能识别的类型层次约束,不只是文档约定。

7.2 Sealed 类型支持穷尽性分析

如果 Shape 的直接子类型只有两个,并且它们都是不可继续扩展的,编译器可以推导出所有可能分支:

static double area(Shape shape) {
    return switch (shape) {
        case Circle c -> Math.PI * c.radius() * c.radius();
        case Rectangle r -> r.width() * r.height();
    };
}

这里不需要 default,因为:

Shape
→ Circle 或 Rectangle
→ 两个分支均已覆盖

如果未来给 Shape 增加新的 permitted subtype,这段代码可能无法再编译,迫使调用方重新审查处理逻辑。这是 sealed 类型的重要价值:让类型层次的变化暴露在编译阶段。

但是,若使用:

case Shape s -> ...

这个总类型模式已经覆盖所有非 nullShape,后续更具体的分支会因为被支配而无法编译。


8. 模式匹配:让类型测试、绑定和数据分解协同工作

模式匹配可以拆成三个动作:

  1. 判断输入是否满足某种形状;
  2. 在匹配成功时绑定变量;
  3. 直接取得对象中的数据。

8.1 instanceof 类型模式

传统写法:

if (value instanceof String) {
    String text = (String) value;
    System.out.println(text.length());
}

类型模式写法:

if (value instanceof String text) {
    System.out.println(text.length());
}

模式变量 text 只在匹配成功且变量确定非空的路径上可用。

if (value instanceof String text && !text.isBlank()) {
    System.out.println(text);
}

这里 && 的右侧成立的前提是左侧匹配成功,所以 text 在右侧可用。

下面的写法不成立:

if (value instanceof String text || text.isBlank()) {
    // 编译错误
}

因为当左侧为假时,右侧可能仍然执行,而 text 没有确定赋值。

这就是模式变量的流作用域:变量的可用范围由控制流证明决定,而不是简单地从声明位置延伸到代码块末尾。

8.2 否定条件可以缩短失败路径

if (!(value instanceof String text)) {
    return;
}

System.out.println(text.length());

执行到 return 之后的代码时,唯一剩余路径就是匹配成功,因此 text 在后续代码中可用。

8.3 Record pattern:同时检查类型和分解组件

对于:

record Point(int x, int y) {
}

可以使用 record pattern:

static int manhattanDistance(Object value) {
    if (value instanceof Point(int x, int y)) {
        return Math.abs(x) + Math.abs(y);
    }
    return -1;
}

匹配步骤是:

value == null                  → 失败
value 不是 Point                → 失败
value 是 Point                  → 读取 x 和 y
x、y 绑定为局部变量              → 执行计算

Record pattern 依赖 record 已声明的组件结构,因此它不是对任意 JavaBean 自动解构。

8.4 嵌套 Record pattern

record Address(String city) {
}

record User(String name, Address address) {
}

static String cityOf(Object value) {
    if (value instanceof User(String name, Address(String city))) {
        return name + "@" + city;
    }
    return "unknown";
}

这个模式要求:

  1. 外层对象是 User
  2. User.name()User.address() 可用于分解;
  3. addressAddress
  4. Address.city() 提供字符串值。

如果任意嵌套层为 null,嵌套 record pattern 不匹配,而不是自动跳过空值。


9. Pattern switch:按类型和结构选择分支

switch 的选择器可以是对象,并使用类型模式:

static String describe(Object value) {
    return switch (value) {
        case Integer i -> "integer: " + i;
        case String s -> "string: " + s;
        default -> "other";
    };
}

instanceof 相比,switch 更适合表达互斥的多分支分类。

9.1 null 必须显式考虑

传统对象 switch 遇到 null 通常会抛出 NullPointerException。Pattern switch 可以显式处理:

static String describe(Object value) {
    return switch (value) {
        case null -> "null";
        case String s -> "string: " + s;
        default -> "other";
    };
}

case null 不是类型模式,它专门匹配空引用。

9.2 when 添加守卫条件

可以在模式之后加入 when 条件:

static String classify(Object value) {
    return switch (value) {
        case null -> "null";
        case Integer i when i < 0 -> "negative integer";
        case Integer i -> "non-negative integer";
        default -> "other";
    };
}

对于 -3,执行过程是:

匹配 null?否
匹配 Integer 且 i < 0?是
返回 "negative integer"

对于 5

匹配 null?否
匹配 Integer 且 i < 0?否
匹配 Integer?是
返回 "non-negative integer"

守卫条件不会改变模式的类型覆盖范围,因此分支顺序仍然重要。

9.3 分支支配规则

更宽的模式不能放在更窄模式之前:

return switch (value) {
    case Object o -> "object";
    case String s -> "string"; // 编译错误:永远到达不了
};

Object 已经匹配所有非 null 对象,后面的 String 不可能被选择。

正确顺序是:

return switch (value) {
    case String s -> "string";
    case Object o -> "object";
};

带守卫的模式通常不能替代无守卫的覆盖关系,因此仍应按照从具体到一般的顺序组织分支。


10. Record、sealed 和模式匹配的完整组合

下面是一个可以保存为 ExpressionDemo.java 并运行的完整示例:

public class ExpressionDemo {
    sealed interface Expr permits NumberLiteral, Add {
    }

    record NumberLiteral(int value) implements Expr {
    }

    record Add(Expr left, Expr right) implements Expr {
    }

    static int evaluate(Expr expr) {
        return switch (expr) {
            case NumberLiteral(int value) -> value;
            case Add(Expr left, Expr right) ->
                    evaluate(left) + evaluate(right);
        };
    }

    static String describe(Expr expr) {
        return switch (expr) {
            case NumberLiteral(int value) ->
                    "number(" + value + ")";
            case Add(NumberLiteral(int left),
                     NumberLiteral(int right)) ->
                    "simple-add(" + left + ", " + right + ")";
            case Add(Expr left, Expr right) ->
                    "compound-add(" + describe(left)
                            + ", " + describe(right) + ")";
        };
    }

    public static void main(String[] args) {
        Expr expression = new Add(
                new NumberLiteral(1),
                new Add(
                        new NumberLiteral(2),
                        new NumberLiteral(3)
                )
        );

        System.out.println(evaluate(expression));
        System.out.println(describe(expression));
    }
}

使用 Java 25 编译运行:

javac --release 25 ExpressionDemo.java
java ExpressionDemo

预期输出:

6
compound-add(number(1), simple-add(2, 3))

10.1 evaluate 的穷尽性推导

Expr 的 permitted direct subtypes 是:

Expr
├── NumberLiteral
└── Add

两个 record 都是隐式 final,因此不存在未声明的 Expr 子类型。evaluate 的两个分支覆盖:

NumberLiteral → 返回 value
Add           → 递归计算 left 和 right

Add 分支,递归调用满足:

evaluate(Add(left, right))
= evaluate(left) + evaluate(right)

当表达式树为:

Add(
    NumberLiteral(1),
    Add(NumberLiteral(2), NumberLiteral(3))
)

计算过程为:

evaluate(NumberLiteral(1)) = 1
evaluate(NumberLiteral(2)) = 2
evaluate(NumberLiteral(3)) = 3
evaluate(Add(2, 3))         = 2 + 3 = 5
evaluate(Add(1, 5))         = 1 + 5 = 6

10.2 describe 展示了模式的优先级

Add(NumberLiteral(...), NumberLiteral(...)) 比一般的 Add(Expr, Expr) 更具体,因此必须排在后者之前。

如果交换两个分支:

case Add(Expr left, Expr right) -> ...;
case Add(NumberLiteral(int left), NumberLiteral(int right)) -> ...;

第二个分支会被编译器判定为不可达,因为第一个模式已经匹配所有 Add


11. 基本类型模式:Java 25 中需要区分预览能力

截至 Java 25,基本类型参与模式匹配和 switch 的能力仍属于预览特性,应明确使用预览参数编译运行:

javac --enable-preview --release 25 Example.java
java --enable-preview Example

预览特性不是 Java SE 25 永久稳定语言能力,后续版本可能修改或移除语法和语义。生产代码使用时必须经过版本、编译参数和部署运行时的统一验证。

基本类型模式的目标是让模式匹配不再只围绕引用类型,例如对 intlong 等进行模式判断和 switch 分派。需要特别注意:

  • 基本类型没有 null
  • 包装类型存在 null
  • 包装类型参与匹配时可能涉及拆箱;
  • 拆箱遇到 null 可能抛出 NullPointerException
  • 不同基本类型之间的可匹配关系受数值转换和模式规则限制;
  • 不能把预览语法当作 Java 25 正式永久语法使用。

例如,下面的非预览代码已经可以稳定表达“先判断包装类型,再读取值”:

static String classify(Integer value) {
    return switch (value) {
        case null -> "null";
        case Integer i when i < 0 -> "negative";
        case Integer i -> "non-negative";
    };
}

而涉及基本类型 selector、基本类型模式或相关原始类型扩展时,应使用 --enable-preview --release 25,并在构建流水线中固定编译器和运行时版本。


12. 泛型与模式匹配的边界

泛型类型在运行时受到类型擦除影响:

List<String> names = new ArrayList<>();

运行时通常只知道它是某种 List,不能直接这样判断:

if (value instanceof List<String> names) {
    // 编译错误:不能对不可具体化参数化类型做 instanceof
}

可以判断原始或通配符类型:

if (value instanceof List<?> names) {
    System.out.println(names.size());
}

但这只能证明它是一个列表,不能证明其中每个元素都是 String。元素类型检查仍需逐个进行:

if (value instanceof List<?> list
        && list.stream().allMatch(String.class::isInstance)) {
    // 此时仍需谨慎处理类型转换和空元素策略
}

类型擦除还解释了为什么不能重载:

void process(List<String> value) {}
void process(List<Integer> value) {} // 编译错误

两者擦除后都近似为 process(List),签名冲突。

模式匹配能够利用编译期可判断的类型关系,但不能凭空恢复运行时已经擦除的泛型实参。


13. 常见失败表现与诊断方法

13.1 把 record 当成深度不可变对象

失败表现通常不是编译错误,而是状态从外部被改变:

record Config(Map<String, String> values) {
}

如果直接保存外部传入的可变 Map,调用者仍可修改 record 观察到的内容。

诊断方法:

  1. 查组件是否为集合、数组或其他可变对象;
  2. 检查构造器是否进行防御性复制;
  3. 检查访问器是否直接暴露可变内部对象;
  4. 检查 equalshashCode 使用期间组件是否会改变。

13.2 误用 == 比较包装对象或字符串

失败表现可能随缓存、输入来源或运行环境变化:

Integer a = Integer.valueOf(1000);
Integer b = Integer.valueOf(1000);

System.out.println(a == b); // 不应依赖结果

应改为:

a.equals(b)

并先处理可能的 null

java.util.Objects.equals(a, b)

13.3 忽略整数溢出

失败表现包括:

  • 分页计算得到负数;
  • 时间间隔变成负值;
  • 容量检查绕过边界;
  • 数组长度或缓冲区大小计算异常。

诊断时检查:

Math.addExact(...)
Math.subtractExact(...)
Math.multiplyExact(...)

是否适用于该计算;如果值本身可能超过 long 范围,应进一步考虑 BigInteger

13.4 在 switch 中遗漏 null

对象类型 selector 为 null 时,未处理的 switch 可能抛出 NullPointerException。若 null 是合法输入状态,应显式写:

case null -> ...

null 代表违反前置条件,也应在 API 边界明确拒绝,而不是依靠深层 switch 偶然失败。

13.5 通过 non-sealed 破坏穷尽性假设

如果 sealed 层次中存在:

non-sealed class ExtensionMessage implements Message {
}

那么 Message 的子类集合不再完全封闭。对 Message 做穷尽性 switch 时,通常需要 default 或覆盖更一般的模式。

这不是编译器缺陷,而是继承边界已经被 non-sealed 有意重新开放。


14. 设计边界:何时使用哪种机制

14.1 使用基本类型表达没有对象身份的值

计数、标志、索引和简单数值通常适合基本类型。但要注意:

  • 包装类型允许 null,基本类型不允许;
  • 泛型参数不能直接使用基本类型,会发生装箱;
  • 需要高精度金额时不要用二进制浮点数;
  • 需要溢出检测时使用显式检查 API。

14.2 使用引用类型表达身份、生命周期或可组合行为

实体对象通常需要:

  • 唯一身份;
  • 可变生命周期;
  • 与外部资源交互;
  • 多态行为;
  • 复杂不变量。

这些场景不应为了语法简洁强行使用 record。

14.3 使用 Record 表达稳定的数据边界

Record 适合:

  • 请求和响应数据;
  • 值对象;
  • 解析后的不可变结果;
  • 事件载荷;
  • 模式匹配中的数据分支。

但组件的可变性、复制策略、空值策略和相等性仍然由开发者负责。

14.4 使用 sealed 表达受控的变体集合

当业务概念有明确且有限的变体时,sealed 类型能够把设计意图传递给编译器:

支付结果
├── 成功
├── 余额不足
└── 风控拒绝

如果业务允许第三方扩展或插件注册未知类型,则不应把整个层次错误地声明为封闭集合;可以只封闭核心内部模型,并在边界处使用开放接口或扩展分支。

14.5 使用模式匹配消除不安全的类型转换

模式匹配的价值不是减少几行代码,而是让类型检查、变量绑定和控制流保持一致:

if (value instanceof User user) {
    user.login();
}

编译器能够验证 user 的作用域和分支关系,减少“判断的是一个对象、转换的却是另一个对象”这类错误。


15. 版本边界与规范依据

Java 25 中应区分以下能力:

  • 基本类型、引用类型、类、接口、数组、泛型和常规控制流:Java 语言基础能力;
  • record:正式语言特性;
  • sealed 类和接口:正式语言特性;
  • instanceof 类型模式:正式语言特性;
  • switch 模式匹配:正式语言特性;
  • record patterns:正式语言特性;
  • 基本类型参与模式和 switch 的相关扩展:Java 25 预览能力,编译运行需启用 preview。

具体语法、类型转换、模式变量作用域、switch 支配规则、sealed 类型穷尽性和 record 构造器语义,应以 Java SE 25 的 Java Language Specification 为准;类库行为和命令说明可参考 Java SE 25 Documentation

掌握这些机制后,可以把一段 Java 代码按以下顺序分析:

1. 值是基本类型还是引用类型?
2. 发生了哪些隐式转换、装箱或拆箱?
3. 引用的编译时类型和运行时类型分别是什么?
4. 控制流是否保证变量已赋值、对象非 null、模式已匹配?
5. record 的组件是否真的满足所需的不变性?
6. sealed 层次是否封闭,switch 是否穷尽?
7. 使用的语法是否属于 Java 25 预览特性?

这套分析路径把类型、数据建模、继承边界和控制流连接起来,也是理解 Java 对象模型、泛型边界以及不可变设计的基础。


系列导航与关联阅读

官方资料

本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。