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

Java 25 对象初始化:字段、初始化块、构造器、继承与发布安全

Java 中“创建一个对象”不是简单地执行某个构造器。一个对象从分配内存到能够被其他线程可靠使用,中间经历了多个阶段:

  1. 为对象分配内存,并把实例字段设为默认值;
  2. 初始化父类部分;
  3. 按文本顺序执行当前类的实例字段初始化表达式和实例初始化块;
  4. 执行构造器主体;
  5. 将对象引用发布给其他代码或线程。

这些步骤的顺序决定了字段在构造期间看到什么值、父类构造器能否访问子类状态、初始化失败后对象是否可用,以及多线程环境中其他线程能否看到完整状态。

本文讨论 Java 25 LTS 语境下的普通类对象初始化,也会补充静态初始化、final 字段、继承、构造器委托和 Java 内存模型中的发布安全。


一、先区分“声明”“初始化”和“赋值”

考虑下面的类:

class User {
    private String name = "unknown";

    {
        System.out.println("instance initializer");
    }

    User(String name) {
        this.name = name;
    }
}

其中有三种不同概念:

  • private String name;字段声明
  • private String name = "unknown"; 包含声明和一个实例字段初始化表达式
  • this.name = name; 是构造器中的一次赋值

“初始化”在不同语境下可能指不同范围:

  • 对象刚分配时,所有实例字段先获得默认值;
  • 执行字段初始化表达式和初始化块,是类初始化过程的一部分;
  • 构造器主体中的赋值,也可能改变字段,但不等同于字段初始化表达式;
  • 线程看到一个对象,是发布;对象本身是否已经构造完成,是另一个问题。

字段初始化表达式和初始化块最终都会成为构造过程中的一部分,但它们不是单独可调用的方法,也没有独立的返回值。初始化块会被编译器按照源码位置“展开”到构造器中。


二、实例字段的默认值:对象分配后的第一状态

当通过 new 创建对象时,JVM 首先为对象分配存储空间。实例字段在任何显式初始化代码执行前,都会具有对应的默认值:

类型 默认值
byteshortintlong 0
floatdouble 0.0
char '\u0000'
boolean false
引用类型 null

例如:

class Defaults {
    int number;
    boolean enabled;
    String text;
}

执行:

Defaults value = new Defaults();

System.out.println(value.number);
System.out.println(value.enabled);
System.out.println(value.text);

输出为:

0
false
null

这里的 0falsenull 不是由编译器自动插入到构造器中的三条赋值语句产生的,而是对象分配和初始化语义的一部分。即使类没有声明任何构造器,默认构造器也不能跳过这一步。

1. 显式初始化会覆盖默认值

class Config {
    int timeout = 30;
    String endpoint = "https://example.test";
}

new Config() 的逻辑结果是:

  1. timeout 先变成 0
  2. endpoint 先变成 null
  3. 执行字段初始化表达式;
  4. timeout 变成 30
  5. endpoint 变成 "https://example.test"

因此,字段初始化表达式读取其他字段时,读取到的可能是默认值,也可能是之前已经按顺序初始化完成的值。


三、字段初始化表达式与实例初始化块:按文本顺序执行

实例字段初始化表达式和实例初始化块按照它们在类体中出现的顺序执行。

class InitializationOrder {
    private int first = print("field first", 1);

    {
        print("block one", first);
    }

    private int second = print("field second", 2);

    {
        print("block two", second);
    }

    private static int print(String label, int value) {
        System.out.println(label + " = " + value);
        return value;
    }

    InitializationOrder() {
        System.out.println("constructor");
    }
}

运行:

new InitializationOrder();

输出顺序是:

field first = 1
block one = 1
field second = 2
block two = 2
constructor

这里的因果关系是:

  • first 的初始化表达式先执行,因此第一个初始化块可以读取到 first == 1
  • second 尚未初始化前,第二个初始化块不会执行;
  • 所有字段初始化表达式和初始化块完成后,才进入构造器主体。

初始化块可以访问此前已经声明或初始化的字段,但不能绕过 Java 的“先声明后使用”规则。

1. 直接向前引用会被拒绝

class InvalidOrder {
    int first = second + 1; // 编译错误
    int second = 10;
}

实例字段初始化表达式不能通过简单名称直接引用文本上位于后面的字段。编译器会报告非法的前向引用。

但这不意味着运行时绝对不能间接访问后面的字段。通过方法调用或其他间接路径可能绕过编译器的简单检查,却产生更危险的结果:

class IndirectOrder {
    int first = readSecond();

    int second = 10;

    int readSecond() {
        return second;
    }
}

readSecond() 执行时,second 还没有执行初始化表达式,因此读取到的是默认值 0。最终 first 会变成 0,而不是 11

这说明“字段有初始化表达式”不等于“该字段在整个构造过程中都已经有目标值”。初始化顺序仍然由源码位置决定。


四、构造器:完成实例初始化的入口

构造器是与类同名、没有返回类型的方法样式声明,但它不是普通方法:

class Account {
    private final String id;

    Account(String id) {
        this.id = id;
    }
}

构造器的职责通常包括:

  • 接收创建对象所需的参数;
  • 校验参数;
  • 建立字段之间的不变量;
  • 完成资源或辅助对象的初始化;
  • 使对象在构造完成后满足类的公开契约。

如果一个类完全没有声明构造器,编译器会生成一个无参构造器。这个默认构造器会隐式调用直接父类的无参构造器;如果父类没有可访问的无参构造器,子类也无法依靠默认构造器通过编译。

class Parent {
    Parent(int value) {}
}

class Child extends Parent {
    // 编译错误:隐式 super() 不存在
}

必须显式调用父类构造器:

class Child extends Parent {
    Child() {
        super(10);
    }
}

五、构造器调用链:this(...)super(...)

构造器可以调用同一个类的另一个构造器:

class Point {
    private final int x;
    private final int y;

    Point() {
        this(0, 0);
    }

    Point(int x, int y) {
        this.x = x;
        this.y = y;
    }
}

this(...) 称为构造器委托,它让多个构造器共享初始化逻辑。

构造器也可以调用直接父类构造器:

class ColoredPoint extends Point {
    private final String color;

    ColoredPoint(int x, int y, String color) {
        super(x, y);
        this.color = color;
    }
}

super(...) 不会创建第二个对象,它只是在当前对象中初始化父类声明的那一部分状态。

构造器调用关系必须形成一条有限链:

Child() 
  -> Child(int)
      -> Parent(int)

不能形成循环:

class Broken {
    Broken() {
        this(1);
    }

    Broken(int value) {
        this(); // 编译错误:构造器递归调用
    }
}

Java 25 的构造器前置代码

在传统 Java 语法中,显式构造器调用 this(...)super(...) 必须是构造器中的第一条语句。Java 25 支持了灵活构造器体后,构造器可以在显式构造器调用前执行一段前置代码,但这段代码不能访问当前对象的实例成员,也不能使用 thissuper 相关实例状态。

示例:

class Port {
    private final int value;

    Port(int value) {
        if (value < 0 || value > 65535) {
            throw new IllegalArgumentException("invalid port: " + value);
        }

        super();

        this.value = value;
    }
}

这里的前置校验只依赖参数,因此适合放在 super() 之前。它不能写成:

class InvalidPort {
    private int normalized;

    InvalidPort(int value) {
        normalized = value; // 不允许在父类构造器调用前访问实例状态
        super();
    }
}

原因是:在直接父类构造器完成前,当前类的实例初始化还没有开始。允许前置代码随意读取或修改当前对象,会破坏父类先于子类建立状态的规则。

如果项目使用的编译器或构建配置未启用对应语言级别,应以实际 Java 25 编译器的语言规则为准;不要把其他版本的“构造器首句必须是 super”机械套用于 Java 25 的灵活构造器体语法。


六、继承下的完整初始化顺序

设有如下继承层次:

class Base {
    private int baseField = print("Base field");

    {
        print("Base block");
    }

    Base() {
        print("Base constructor");
    }

    private static int print(String text) {
        System.out.println(text);
        return 1;
    }
}

class Derived extends Base {
    private int derivedField = print("Derived field");

    {
        print("Derived block");
    }

    Derived() {
        print("Derived constructor");
    }

    private static int print(String text) {
        System.out.println(text);
        return 1;
    }
}

执行:

new Derived();

输出:

Base field
Base block
Base constructor
Derived field
Derived block
Derived constructor

其形式化顺序可以写成:

对象分配并设置所有实例字段默认值
    ↓
直接或间接父类的初始化
    ↓
父类实例字段初始化表达式和初始化块
    ↓
父类构造器主体
    ↓
当前类实例字段初始化表达式和初始化块
    ↓
当前类构造器主体

对每一个类,从父类到子类重复同一规则。初始化表达式和初始化块不是在整个对象层面统一排序,而是在每个类自己的构造阶段中按源码顺序执行。

1. 父类构造器不能看到“已初始化的子类字段”

class Parent {
    Parent() {
        printState();
    }

    void printState() {
        System.out.println("parent");
    }
}

class Child extends Parent {
    private String name = "ready";

    Child() {}

    @Override
    void printState() {
        System.out.println(name);
    }
}

执行 new Child() 时,父类构造器会调用到子类重写的 printState(),但此时 name 的初始化表达式尚未执行,所以输出的是:

null

这不是多态失效,而是多态正常工作、初始化顺序却尚未完成。父类构造器调用了一个动态方法,方法体读取了仍处于默认值状态的子类字段。

因此,构造器调用可重写实例方法具有明确风险:

class Base {
    Base() {
        hook();
    }

    protected void hook() {}
}

子类重写 hook() 后,父类构造器可能在子类字段初始化前调用它。规范允许这种动态分派;“能编译”不等于“子类状态已就绪”。


七、初始化块在语义上如何进入构造器

实例初始化块:

class Sample {
    private int a = 1;

    {
        a += 10;
    }

    Sample() {
        a *= 2;
    }
}

可以近似理解为编译器把初始化代码放入每个构造器中,并放在父类构造器调用之后:

class SampleEquivalent {
    private int a;

    SampleEquivalent() {
        super();
        a = 1;
        a += 10;
        a *= 2;
    }
}

这是理解顺序的有效模型,但不是要求编译器生成完全相同字节码的规范描述。真实编译结果可能使用不同的局部变量表、常量折叠或字节码布局。

如果构造器通过 this(...) 委托,当前类的实例字段初始化表达式和初始化块只应在构造器链中执行一次。它们不会因为从一个构造器跳到另一个构造器而重复执行。

class Counter {
    private int value = increment();

    Counter() {
        this(10);
    }

    Counter(int value) {
        this.value += value;
    }

    private int increment() {
        System.out.println("initializer");
        return 1;
    }
}

执行 new Counter() 时,initializer 只输出一次,随后执行目标构造器的主体。


八、final 字段:构造期间必须建立的约束

实例 final 字段只能被赋值一次,但这个“一次”不是指只能在字段声明处赋值。可以在字段初始化表达式、初始化块或构造器中赋值。

class UserId {
    private final String value;

    UserId(String value) {
        if (value == null || value.isBlank()) {
            throw new IllegalArgumentException("empty id");
        }
        this.value = value;
    }
}

对于空白输入:

new UserId("   ");

构造器抛出异常,对象不会作为构造表达式的结果返回。调用者不能获得一个正常构造完成的 UserId 引用。

同一个构造路径不能给 final 字段赋值两次:

class InvalidFinal {
    private final int value;

    InvalidFinal() {
        value = 1;
        value = 2; // 编译错误
    }
}

对于多个构造器,编译器会分别检查每条构造路径是否恰好完成必要的赋值:

class Required {
    private final int value;

    Required(boolean useDefault) {
        if (useDefault) {
            value = 0;
        } else {
            value = 1;
        }
    }
}

这段代码合法,因为所有正常路径都为 value 赋值。若存在某条可能正常结束的路径未赋值,编译器会拒绝该类。

final 不代表深度不可变

class Group {
    private final List<String> members = new ArrayList<>();

    List<String> members() {
        return members;
    }
}

members 引用不能重新指向另一个列表,但列表内容仍然可以变化。final 约束的是字段引用本身,不会递归冻结引用指向的对象。

要实现不可变状态,还需要:

  • 不向外暴露可变内部对象;
  • 对输入集合做防御性复制;
  • 返回不可变视图或副本;
  • 确保元素本身也满足所需的不可变约束。

九、静态字段与实例字段不是同一条初始化链

实例初始化针对每一个对象执行一次。静态初始化针对一个类或接口的初始化过程执行一次。

class Settings {
    static String source = loadSource();

    static {
        System.out.println("static block");
    }

    private static String loadSource() {
        System.out.println("load source");
        return "file";
    }

    String value = "instance";
}

第一次主动使用 Settings 的静态成员时,通常会触发:

load source
static block

之后创建多个对象时,source 的静态初始化不会重复执行,但 value 的实例初始化会对每个对象执行一次。

同一个类的静态字段初始化表达式和静态初始化块也按文本顺序执行:

class StaticOrder {
    static int a = b + 1; // b 此时为默认值 0
    static int b = 10;
}

这里 a 会得到 1,而不是 11。静态字段同样先有默认值,再按文本顺序执行显式初始化。

类初始化由 JVM 协调:同一个类的初始化不会被多个线程同时执行,类初始化完成与后续使用之间也具有内存可见性保证。因此,下面这种静态初始化通常可以安全地构造共享对象:

class Global {
    static final Service SERVICE = new Service();
}

但这只解决了类初始化和发布问题,不代表 Service 的方法在后续并发调用中自动线程安全。


十、对象构造完成不等于对象已经安全发布

发布是指让一个线程创建的对象引用被另一个线程获得。例如:

class Holder {
    static Item item;
}

线程 A 执行:

Holder.item = new Item();

线程 B 读取:

Item value = Holder.item;

如果没有同步关系,这就是一次可能存在数据竞争的发布。

安全发布要求:其他线程不仅获得引用,还能按照 Java 内存模型观察到构造期间应当对普通字段产生的写入。常见建立 happens-before 关系的方式包括:

  • volatile 字段的写入和后续读取;
  • 同一锁上的解锁与加锁;
  • 线程启动前完成对象构造并调用 Thread.start()
  • 一个线程结束后,另一个线程调用 join()
  • 使用线程安全并发容器;
  • 通过类初始化发布静态对象;
  • 某些满足规范条件的 final 字段保证。

1. 有竞争的发布示例

class UnsafePublication {
    static UnsafePublication instance;

    int number;
    String text;

    UnsafePublication() {
        number = 42;
        text = "ready";
    }

    static void writer() {
        instance = new UnsafePublication();
    }

    static void reader() {
        UnsafePublication value = instance;
        if (value != null) {
            System.out.println(value.number + ", " + value.text);
        }
    }
}

如果 writer()reader() 在不同线程执行,且 instance 没有 volatile、锁或其他同步手段,程序存在数据竞争。读线程可能:

  • 仍然读取到 instance == null
  • 读取到引用后看到字段的旧值;
  • 在允许的内存模型行为下观察到不符合直觉的状态。

“构造器已经返回”只说明构造线程完成了构造器执行,不自动说明另一个没有同步关系的线程何时、以什么顺序看到这些写入。

2. 使用 volatile 发布

class SafePublication {
    private static volatile SafePublication instance;

    private final int number;
    private String text;

    private SafePublication() {
        number = 42;
        text = "ready";
    }

    static void publish() {
        instance = new SafePublication();
    }

    static SafePublication read() {
        return instance;
    }
}

构造完成后对 instance 的 volatile 写,与另一个线程对 instance 的 volatile 读建立 happens-before 关系。读线程通过这次读取获得引用后,可以看到构造器中对 numbertext 的写入。

这里要注意两点:

  1. volatile 修饰的是发布引用,不是对象内部所有字段;
  2. 发布之后如果多个线程继续修改 text,这些修改仍然需要独立的同步设计。

3. 使用类初始化发布

class ServiceHolder {
    private static class Holder {
        static final Service INSTANCE = new Service();
    }

    static Service instance() {
        return Holder.INSTANCE;
    }
}

Holder.INSTANCE 的初始化属于 Holder 的类初始化过程。JVM 保证同一类的初始化由一个线程完成,并让后续使用该类的线程看到已完成的类初始化结果。

这种方式常用于延迟初始化,但 Service 本身仍必须设计为适合并发使用;安全发布不等于业务操作自动互斥。


十一、final 字段的特殊可见性,以及它的边界

Java 内存模型对正确构造的对象中的 final 字段提供特殊保证。即使对象引用通过普通数据竞争被读取,其他线程在满足规范条件时,也能看到构造器中为 final 字段写入的值。

class Message {
    private final int code;
    private final String text;

    Message(int code, String text) {
        this.code = code;
        this.text = text;
    }
}

这项保证的直觉是:final 字段代表对象建立后不应再变化的结构性状态,JMM 对构造过程和后续读取提供了比普通字段更强的约束。

但它不是“任意情况下都安全”的许可证。以下情况会破坏设计前提或削弱可推理性:

1. 在构造器中让 this 逃逸

class Escaping {
    private final int value;

    Escaping(Registry registry) {
        registry.register(this);
        value = 42;
    }
}

register(this) 可能立刻把对象交给另一个线程,或者保存到全局结构中。此时对象尚未完成构造,其他代码可以观察到未完成状态。

2. 构造器中启动线程

class StartsThread {
    private final int value;

    StartsThread() {
        new Thread(() -> System.out.println(value)).start();
        value = 42;
    }
}

线程启动时,构造器尚未完成字段赋值。即便某些执行结果看起来正常,也不能把这种顺序当成安全发布协议。

正确方向是先完成构造,再启动使用该对象的线程:

class StartsThreadSafely {
    private final int value;

    StartsThreadSafely() {
        value = 42;
    }

    void start() {
        Thread thread = new Thread(() -> System.out.println(value));
        thread.start();
    }
}

3. final 引用指向可变对象

class Snapshot {
    private final int[] values;

    Snapshot(int[] values) {
        this.values = values;
    }
}

即使 values 字段是 final,调用者仍可能在构造之后修改传入数组:

int[] source = {1, 2};
Snapshot snapshot = new Snapshot(source);
source[0] = 99;

如果类需要快照语义,应复制输入:

class ImmutableSnapshot {
    private final int[] values;

    ImmutableSnapshot(int[] values) {
        this.values = values.clone();
    }

    int[] values() {
        return values.clone();
    }
}

这里的复制解决的是别名和可变性问题,不是单独解决线程发布问题;对象仍然应通过合理同步关系交给其他线程。


十二、构造期间的动态分派:最容易被忽视的初始化陷阱

Java 的实例方法调用通常采用动态分派,即使调用发生在父类构造器中,也可能执行子类重写的方法。

class BaseConfig {
    BaseConfig() {
        validate();
    }

    protected void validate() {
        System.out.println("base validation");
    }
}

class ChildConfig extends BaseConfig {
    private String mode = "strict";

    @Override
    protected void validate() {
        System.out.println("mode = " + mode);
    }
}

执行:

new ChildConfig();

输出:

mode = null

执行顺序为:

  1. 分配 ChildConfig 对象;
  2. 所有字段获得默认值,mode == null
  3. 进入 BaseConfig 构造器;
  4. validate() 动态分派到 ChildConfig.validate()
  5. mode 的字段初始化表达式尚未执行;
  6. BaseConfig 构造器完成;
  7. 执行 ChildConfig 的字段初始化和构造器。

这类错误可能有三种表现:

  • 读取到 null0
  • 根据未初始化状态作出错误决策;
  • 调用子类方法时抛出异常,导致整个构造失败。

privatestaticfinal 方法不以普通可重写实例方法的方式参与这种子类重写,因此把构造期间需要使用的内部逻辑设计为不可重写方法,能够减少动态分派风险。但这只是局部手段;更根本的做法是让父类构造器只依赖父类已经建立的状态。


十三、初始化失败时发生什么

初始化表达式、初始化块或构造器都可能抛出异常:

class Failing {
    private final int value = load();

    private int load() {
        throw new IllegalStateException("cannot load");
    }
}

执行:

Failing value = new Failing();

不会得到一个“部分构造对象”作为表达式结果。构造过程异常终止,引用赋值也不会完成。

对于普通实例构造,已经分配的对象最终会成为不可达对象,之后由垃圾收集器回收。构造器中的外部副作用则不会自动撤销:

class ResourceOwner {
    ResourceOwner(ResourceRegistry registry) {
        registry.register(this);
        throw new IllegalStateException("failed");
    }
}

即使对象构造失败,registry.register(this) 已经发生的注册动作不会因为 Java 自动回滚。初始化逻辑涉及文件、连接、注册表、线程或监听器时,必须明确失败清理路径。

静态初始化的失败更特殊:

class BrokenClass {
    static {
        throw new RuntimeException("failed");
    }
}

类初始化失败后,JVM 会把该类标记为初始化失败状态。后续主动使用通常不会再次正常执行静态初始化,而会得到 NoClassDefFoundError,其原因链中通常可以追溯到早先的初始化异常。

因此,静态初始化中的外部 I/O、复杂依赖和可能长时间阻塞的操作,必须考虑:

  • 失败时应用启动或首次使用的行为;
  • 异常是否能被记录;
  • 初始化失败后是否有可恢复路径;
  • 是否应改为显式启动步骤。

十四、一个完整的执行顺序示例

下面的程序同时展示字段初始化、初始化块、继承和构造器主体:

public class InitializationDemo {
    static class Parent {
        private int parentField = log("Parent field initializer");

        {
            log("Parent instance initializer");
        }

        Parent() {
            log("Parent constructor");
        }

        protected void show() {
            log("Parent show: " + parentField);
        }

        private static void log(String message) {
            System.out.println(message);
        }
    }

    static class Child extends Parent {
        private int childField = logAndReturn("Child field initializer", 20);

        {
            log("Child instance initializer");
        }

        Child() {
            log("Child constructor");
        }

        @Override
        protected void show() {
            log("Child show: " + childField);
        }

        private static int logAndReturn(String message, int value) {
            log(message);
            return value;
        }

        private static void log(String message) {
            System.out.println(message);
        }
    }

    public static void main(String[] args) {
        Child child = new Child();
        child.show();
    }
}

输出:

Parent field initializer
Parent instance initializer
Parent constructor
Child field initializer
Child instance initializer
Child constructor
Child show: 20

如果把 Parent() 改成:

Parent() {
    show();
    log("Parent constructor");
}

则输出路径变为:

Parent field initializer
Parent instance initializer
Child show: 0
Parent constructor
Child field initializer
Child instance initializer
Child constructor
Child show: 20

Child.show() 第一次执行时,childField 仍是默认值 0。这段程序可以直接用 Java 25 编译运行:

javac --release 25 InitializationDemo.java
java InitializationDemo

--release 25 用于让编译器按 Java 25 的 API 和语言目标检查代码;运行时也应使用兼容的 Java 25 运行环境。


十五、用状态转换理解一次 new

可以把对象初始化抽象为下面的状态机:

stateDiagram-v2
    [*] --> Allocated
    Allocated: 已分配<br/>实例字段为默认值
    Allocated --> SuperInit: 进入父类初始化
    SuperInit --> CurrentInit: 父类构造完成
    CurrentInit: 执行当前类字段初始化和初始化块
    CurrentInit --> ConstructorBody: 当前类初始化代码完成
    ConstructorBody --> Constructed: 构造器正常返回
    ConstructorBody --> Failed: 抛出异常
    SuperInit --> Failed: 父类初始化失败
    CurrentInit --> Failed: 初始化表达式或初始化块失败
    Constructed --> Published: 引用通过某种方式发布
    Published --> Observable: 其他线程读取对象
    Failed --> [*]

关键路径是:

Allocated
  → SuperInit
  → CurrentInit
  → ConstructorBody
  → Constructed
  → Published
  → Observable

其中 Constructed → Published 不是 JVM 自动为每个普通字段提供的并发协议。对象可以已经构造完成,却仍然通过数据竞争被不安全地发布。发布安全属于构造完成之后的内存可见性问题,但它的正确性前提是在构造期间不能让 this 逃逸。


十六、常见误解与诊断方法

误解一:字段初始化表达式先于父类构造器执行

错误。子类字段初始化表达式要等父类构造器完成后才执行。

class Child extends Parent {
    private int value = 1;
}

创建 Child 时,先完成 Parent 的字段初始化、初始化块和构造器,再执行 Child 的初始化代码。

误解二:初始化块按构造器书写位置执行

初始化块不在构造器声明文本的位置执行。它按照自身在类体中的位置,插入到该类构造阶段的固定位置。this(...) 委托不会让它重复执行。

误解三:构造器返回就一定能被其他线程完整看到

错误。构造完成和安全发布是两个条件。没有 volatile、锁、线程启动关系、并发容器或类初始化等 happens-before 关系,普通字段的跨线程可见性不能仅凭构造器返回来保证。

误解四:final 字段等于整个对象不可变

错误。final List<T> 仍可能修改列表内容,final 数组仍可能修改元素。需要区分引用不可重新赋值、对象状态不可变和线程安全三个层次。

误解五:父类构造器调用重写方法时,子类已经准备好

错误。动态分派可能调用子类方法,但子类实例字段初始化尚未执行。诊断这类问题时,应沿着以下顺序检查:

  1. 当前方法是普通可重写实例方法,还是 privatestatic 等不可按该方式重写的方法;
  2. 调用发生在父类构造器、字段初始化表达式或初始化块的哪个阶段;
  3. 被调用方法读取了哪些子类字段;
  4. 这些字段在源码中的初始化位置;
  5. 是否发生了 this 逃逸或跨线程读取;
  6. 初始化异常是否被包装成了后续的类初始化错误。

调试时在字段初始化表达式、初始化块、父子类构造器和重写方法中分别打印日志,通常可以快速还原顺序。但日志本身不能建立线程同步,也不能证明生产环境中不存在数据竞争。


十七、工程代码中的取舍

初始化代码最重要的性质不是“写在哪里看起来整齐”,而是它是否明确建立了对象不变量。

适合放在字段初始化表达式中的内容通常是:

private final List<String> values = List.of();
private final int bufferSize = 1024;

这些表达式依赖少、不会因构造参数变化,并且失败路径简单。

适合放在构造器中的内容通常是:

User(String name) {
    if (name == null || name.isBlank()) {
        throw new IllegalArgumentException("name must not be blank");
    }
    this.name = name;
}

构造参数校验和跨字段不变量应集中在构造器中,因为构造器能统一控制对象何时算作有效。

初始化块适用于同一个类的多个构造器都必须执行、且不便抽成独立方法的少量逻辑,但过多初始化块会让构造顺序分散在类体多个位置。需要资源获取、异常处理、回滚或依赖注入时,显式构造器代码通常更容易审计。

对象初始化的安全边界可以归纳为:

先建立父类状态
再建立当前类状态
构造期间不发布 this
构造完成后通过同步关系发布
发布后仍为可变状态提供并发保护

这几条并不是同一个规则的重复表述,而是分别对应继承初始化顺序、构造期间对象可见性、Java 内存模型和后续并发访问。只有把它们区分开,才能正确判断一个对象是“已经创建”“已经构造完成”,还是“已经安全地供其他线程使用”。


系列导航与关联阅读

官方资料

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