避免 final 字段被修改

本文翻译自 Avoiding Final Field Mutation,版权归原作者所有,作者 Nicolai Parlog


Java 语言要求 final 字段必须在对象构造期间完成赋值,并且禁止后续重新赋值,但 JDK 仍然提供了一些机制允许这样做。JDK 26 迈出了让 final 字段真正不可变的第一步——当通过反射 API 修改 final 字段时发出警告。JEP 500 详细解释了这一举措的原理和影响,以及如何使用新的命令行参数 --enable-final-field-mutation--illegal-final-field-mutation。虽然这些参数允许修改 final 字段,但这应被视为最后的手段,项目应该逐步摆脱这种做法。

本文讨论了通过反射修改 final 字段的常见场景,以及每种场景对应的替代方案。这些场景可能出现在框架、库和应用程序中,因此本文面向所有这些类型的开发者。

我们的指导原则来自默认完整性 JEP 中的以下陈述,该原则不仅适用于序列化:

一般来说,库在没有对象所属类的配合下对对象进行序列化和反序列化是一种错误。

注意:本文使用序列化作为通用术语,泛指能够将 Java 实例转换为外部格式(如 JSON、YAML 或 protobuf)以及反向转换的机制。对于 Java 内置的围绕 Serializable 接口以及 ObjectInputStreamObjectOutputStream 类运行的机制,本文使用平台序列化这一术语。

实例初始化

final 字段最常被非法修改的情况是在构造完成后立即初始化实例(而非在实例生命周期后期重新赋值)。这可能发生在依赖注入、反序列化、克隆或其他需要在将实例交给用户之前创建可用实例的初始化过程中。其中一些用例与字段值的来源(例如来自 JSON 字符串或其他序列化形式)紧密相关,我们将在后面的章节中讨论这些联系。尽管如此,这些解决方案之间存在一些共性,因此值得单独讨论实例初始化问题。

绕过构造函数

一些初始化机制通常的做法是先构造所有字段为 null 的"空"对象,然后在构造完成后再赋值。Java 自身的平台反序列化就是这样工作的(底层如此,如果实现了 readObject 方法则更是显式如此),Java Bean 普遍遵循这种模式,依赖注入也曾如此。

然而,这种"先构造后赋值"的方式与 final 字段直接冲突,因为 Java 语言承诺 final 字段在构造期间恰好赋值一次(无论是在字段初始化器、构造函数还是初始化块中),之后绝不会被修改。因此,这些过程不得不诉诸强制赋值,无论是通过反射调用 setAccessible、使用 Unsafe 还是 JNI,从而破坏了 final 关键字的完整性,带来了 JEP 500 中列出的所有系统性弊端。

而且,构造后赋值(无论字段是否为 final)对类本身也有直接的负面影响,因为它使得保证填充后的实例满足类的不变条件变得困难得多。不变条件通常在构造函数中建立,但一个要求"空"构造函数甚至完全绕过构造函数的初始化过程可能会创建不正确的实例,导致程序行为异常甚至产生安全漏洞。

使用构造函数

幸运的是,许多初始化过程已经摒弃了"先构造后赋值"的方式。例如,依赖注入框架现在支持并且通常默认使用"构造函数注入",即将值传递给构造函数,由构造函数负责验证它们然后再赋值给字段。这不仅建立了类的不变条件,还使用了语言预期的机制来为 final 字段赋值,从而解决了前面所述的问题。

拥抱构造函数

鉴于通过构造函数调用完全初始化对象的诸多好处,这不仅应该是默认方法,而且除非有充分的理由使其不可行,否则应该是唯一的方法。不过它是灵活的,可以采取多种形式:

  • 如果使用 record,则有一个规范的构造函数。
  • 如果使用 class,初始化过程可以要求一个特定的构造函数承担该角色。
  • 该过程可以要求用户通过注解等方式标识一个专用的构造函数或静态工厂方法。
  • 这样的构造函数或方法不必是 public 的,因此如果更合适,可以将其排除在类型的公共 API 之外。

使用构造函数或工厂方法的一个挑战是正确处理参数顺序。默认情况下,字节码不包含参数名称,这使得在没有额外信息的情况下很难正确排列参数顺序。这些信息可以来自:

  • 使用 record,其组件名称是其公共 API 的一部分
  • 使用 -parameters 选项编译,使参数名对反射 API 可用
  • 用户用于标识正确构造函数或工厂方法的注解

请注意,反射 API 甚至字节码都不保证按源代码中声明的顺序报告或包含字段,因此不要试图将字段顺序和参数顺序对齐。

平台序列化

如果你的代码包含 Serializable 类,它可能会在反序列化期间使用反射来修改 final 字段。

在下面的例子中,可序列化类 Token 包含一个字段 derivedKey,该字段不能跨应用程序运行重复使用,而必须使用当前运行的加密配置进行派生。因此它是 transient 的,并且由于 Token 应该是不可变的,它也是 final 的:

// This is an example for reflective final field mutation, _not_ for a good
// implementation of the described use case.
// Do _not_ draw any security implications from this code!
public final class Token implements Serializable {

	private static final long serialVersionUID = 7665514043582332374L;

	private final String userId;
	private final byte[] token;
	private transient final byte[] derivedKey;

	public Token(String userId, byte[] token) {
		// verifies and assigns fields and then derives `derivedKey`
		this.derivedKey = Kdf.derive(this.token);
	}

	// implements `readObject` to compute and assign a derived key
	private void readObject(ObjectInputStream ois) throws IOException, ClassNotFoundException {
		// side note: before the following method call, this instance has all-null fields
		ois.defaultReadObject();

		byte[] derivedKey = Kdf.derive(this.token);
		// use of reflection to assign `derivedKey` to the field of the same name
		try {
			var derivedKeyField = Token.class.getDeclaredField("derivedKey");
			derivedKeyField.setAccessible(true);
			derivedKeyField.set(this, derivedKey);
		} catch (NoSuchFieldException | IllegalAccessException ex) {
			throw new InvalidObjectException("Failed to set `derivedKey`", ex);
		}
	}

}

在类似情况下,有几种避免修改 final 字段的选择。其中一些遵循拥抱常规构造函数调用的指导原则:

  • 使用 record 代替 class(详见下文)
  • 使用 readResolve 代替 readObject
  • 采用序列化代理模式(也使用 readResolve

另外一些选择围绕字段本身:

  • 移除该字段,在按需使用时计算值,而不是在反序列化时计算
  • 移除该字段,将值存储在外部数据结构中
  • 将该字段改为非 final

任何一种都可以消除修改 final 字段的需求。

通用序列化

如果你维护一个自定义序列化过程,例如作为将 Java 对象转换为 JSON、YAML 等格式的库的维护者,有几种途径可以避免修改 final 字段。对你而言改动最少的方法是指示用户在可序列化类中避免使用 final 字段,但考虑到 final 字段的好处,你可能更愿意给用户更多选择。

限制为 Record

一个简单的步骤是将序列化限制为 record——作为透明的数据载体,它们非常适合这种用例。它们定义的(反)构造协议允许简单无损地重建实例,通常不需要用户进一步干预(如注解)。

使用 Record 作为代理

如果这限制太大,可以通过一个协议实现更高的自由度,该协议要求实例将其状态压缩到一个专用的 record 实例中,并从该代理中恢复。这类似于前面提到的序列化代理模式,同样,这些方法不必是 public 的,尽管依赖"魔法名称"(如平台序列化那样)并不理想——使用注解来标识 to-proxy 和 from-proxy 方法会是更清晰的做法。以下是一个示例:

// IN THE SERIALIZATION LIBRARY

// marker interface to quickly identify serializable instances
// (not strictly needed)
interface DataCarrier {
	// defines no methods, so that the serialization protocol
	// doesn't have to be public API
}

// annotations to identify the serialization methods
// (retention policy, possible attributes, etc. are missing)
@interface ToData { }
@interface FromData { }


// ON THE USER'S SIDE

// for some reason not a record
public class Person implements DataCarrier {

	private final String name;
	private final int age;

	// [constructor, methods, etc.]

	@ToData
	private PersonData toData() {
		return new PersonData(name, age);
	}

	@FromData
	private static Person fromData(PersonData data) {
		return new Person(data.name(), data.age());
	}

	private record PersonData(String name, int age) { }

}

拥有这样的显式外部表示还使得跨版本边界操作变得直接。如果 Person 类演化并需要新的序列化格式,toData 可以返回新类 PersonDataV2 的实例,但除了新方法 fromData(PersonDataV2) 之外,旧方法 fromData(PersonData) 可以保留。如果反序列化仍然可能,它仍可以正常工作,否则可以创建特定的错误消息。

限制为 Serializable

平台序列化能够写入 Serializable 实例的 final 字段,并且在可预见的未来仍将如此(我不知道有任何改变的计划)。而且它提供了钩子供外部库利用这种能力!这样,可以将对象转换为字段值,将合适的值转换为对象,两者都通过其类的序列化协议进行。这意味着你的(通用)序列化库可以指示用户将其类设为(平台)可序列化,然后你可以钩入该过程以向 final 字段读取和写入值。

Java 通过 sun.reflect.ReflectionFactory 提供与平台序列化相关的方法,该 API 被认为是关键内部 API。这些 API 在 JDK 专用模块 jdk.unsupported 中可用,并且由于没有受支持的替代品而未封装。ReflectionFactory 的功能只能应用于实现 Serializable 的类。

警告:此 API 是一把极其锋利的工具,不适合胆小者。全面解释它超出了本文的范围。

以下是如何使用 ReflectionFactory 将一对 nameage(可以从任何外部表示中读取)转换为 Person 实例的示例。首先是 Person 类,与上面类似,但实现了 Serializable

class Person implements Serializable {

	private static final long serialVersionUID = 8127572164613693569L;

	private final String name;
	private final int age;

	public Person(String name, int age) {
		this.name = Objects.requireNonNull(name);
		if (age < 0)
			throw new IllegalArgumentException("Age must be 0 or larger");
		this.age = age;
	}

	@Override
	public String toString() {
		return "Person{name='" + name + "', age=" + age + "}";
	}

}

由于平台序列化处理输入/输出流,需要一个填充了应赋值给字段的值的 ObjectInputStream。提供这种流的一种方法是创建一个从 Map 中读取值的自定义实现:

static class MapObjectInputStream extends ObjectInputStream {

	private final Iterator<Map<String, Object>> objects;

	MapObjectInputStream(List<Map<String, Object>> objects) throws IOException {
		// create immutable copies of the list and maps
		this.objects = objects.stream()
			.map(Map::copyOf)
			.toList()
			.iterator();
		super();
	}

	@Override
	public ObjectInputStream.GetField readFields() throws IOException {
		if (objects.hasNext()) {
			return new MapGetField(objects.next());
		} else {
			throw new IOException("No more objects");
		}
	}

};

static class MapGetField extends ObjectInputStream.GetField {

	private final Map<String, Object> values;

	MapGetField(Map<String, Object> values) {
		this.values = values;
	}

	@Override
	public ObjectStreamClass getObjectStreamClass() {
		throw new UnsupportedOperationException();
	}

	@Override
	public boolean defaulted(String name) {
		return !values.containsKey(name);
	}

	@Override
	public boolean get(String name, boolean bln) {
		return (boolean) values.getOrDefault(name, bln);
	}

	@Override
	public byte get(String name, byte b) {
		return (byte) values.getOrDefault(name, b);
	}

	@Override
	public char get(String name, char c) {
		return (char) values.getOrDefault(name, c);
	}

	@Override
	public short get(String name, short s) {
		return (short) values.getOrDefault(name, s);
	}

	@Override
	public int get(String name, int i) {
		return (int) values.getOrDefault(name, i);
	}

	@Override
	public long get(String name, long l) {
		return (long) values.getOrDefault(name, l);
	}

	@Override
	public float get(String name, float f) {
		return (float) values.getOrDefault(name, f);
	}

	@Override
	public double get(String name, double d) {
		return (double) values.getOrDefault(name, d);
	}

	@Override
	public Object get(String name, Object o) {
		return values.getOrDefault(name, o);
	}

}

以下紧凑源文件使用前面的类和 ReflectionFactory 实现了:

  • 创建一个 Person 的"空"实例(尽管该类没有无参构造函数)
  • 用合法的名称 "John Doe" 和非法的年龄 -5 填充它(因为 defaultReadObjectForSerialization 返回的方法句柄不经过构造函数,因此不应用任何检查)
  • 用合法的值对 "Jane Doe"/23 覆盖这些值(通过再次调用相同的方法句柄)
import module jdk.unsupported;

void main() throws Throwable {
	ReflectionFactory factory = ReflectionFactory.getReflectionFactory();
	@SuppressWarnings("unchecked")
	Constructor<Person> personConstructor = (Constructor<Person>) factory.newConstructorForSerialization(Person.class);
	MethodHandle personReader = factory.defaultReadObjectForSerialization(Person.class);

	var person = personConstructor.newInstance();
	IO.println(person);

	var inputStream = new MapObjectInputStream(List.of(
		Map.of(
			"name", "John Doe",
			"age", -5),
		Map.of(
			"name", "Jane Doe",
			"age", 23)
	));

	personReader.invoke(person, inputStream);
	IO.println(person);

	personReader.invoke(person, inputStream);
	IO.println(person);
}

平台序列化是 JDK 的组成部分,应该可以在没有警告和限制的情况下继续使用,因此它获得了 JEP 500 规则的例外,但 JDK 不想让外部序列化库处于不利地位,因此这一例外扩展到它们。因此,执行上述代码时,无论 --illegal-final-field-mutation 设置为什么值(包括 deny),都不会发出关于 final 字段修改的警告或错误。虽然这可能看起来是一种继续无限制地向 final 字段赋值的直接方式(只要类是 Serializable 的),但这带来了一个巨大的隐患——它继承了平台序列化的所有挑战:

  • 需要完整重新实现协议(例如关于 readObjectreadResolve),这很复杂且难以正确实现,尤其是在安全性方面
  • 依赖相同的超语言机制,这使得用户很难保证实例是合法的,但很容易让(有意或无意的)值错误导致行为异常的实例
  • 扩散了 Serializable 类的使用,由于 final 字段并非真正 final 的例外,这些类将继续遭受 JEP 500 旨在修复的完整性缺失(包括对可维护性、安全性和性能的所有影响)

与平台序列化一样,“只需让类实现 Serializable“的表面简单性在其他地方需要大量复杂性。更糟糕的是,一旦序列化形式与字段不是一一对应(例如由于代码演化但受限于固定的外部形式)或者反序列化无法简单地创建实例(例如需要强制执行不变条件),这种复杂性很快就会渗透到类本身。从平台序列化中吸取的教训是,更显式的协议(即使需要用户多写一些代码)在长期来看效果更好。

拥抱构造函数

如果这些方法都不能满足所有需求,最灵活的方法就是完全拥抱构造函数:读取实例的字段进行序列化,并让用户标识要在反序列化期间调用的构造函数或静态工厂方法。不过,这种方法需要用户额外配置,且实现更复杂:

  • 需要标识应该(和不应该)成为序列化形式一部分的字段。
  • 如果序列化形式按名称标识值(如大多数文本格式),这些名称可以从字段名称派生,但这会将此实现细节变成序列化协议的一部分。建议将这些名称设为可配置的。
  • 如果序列化形式不保证可靠的顺序(也如同大多数文本格式),将值与反序列化构造函数或方法的参数匹配需要用户额外配置。
  • 如果序列化形式依赖于顺序,则顺序必须由用户定义,因为它无法从字段中推断出来。

注意 record 如何极大地简化了这一点,因为其组件的顺序和名称是其 API 的一部分,并且它们保证有一个为每个组件接受一个参数的构造函数。

依赖注入

如前所述,依赖注入框架通常要求无参构造函数,这样它们可以创建"空"实例,然后直接将依赖写入字段——这称为"字段注入”,需要非 final 字段或反射修改 final 字段。生态系统已基本转向构造函数注入,即调用常规构造函数,从而可以在其中为 final 字段赋值。如果你维护以某种方式注入依赖的代码,你可能应该默认使用构造函数注入,甚至将其作为唯一选项。

克隆

在某些实现 Cloneable 的类中,clone() 方法需要重新赋值字段。常见原因包括对集合等可变数据结构的防御性拷贝,或需要进行深拷贝(Object.clone 只创建浅拷贝)。然而,如果这些字段是 final 的,在 Object.clone 返回后就不能重新赋值,因此经常使用反射来修改。

在以下示例中,地址列表是可变的,不能在 Person 实例之间共享,因此 clone() 创建一个需要赋值给 final 字段 addresses 的副本,为此它使用了反射:

class Person implements Cloneable {

	private final List<Address> addresses;

	public Person(List<Address> addresses) {
		this.addresses = new ArrayList<>(addresses);
	}

	@Override
	public Person clone() throws CloneNotSupportedException {
		try {
			Person clone = (Person) super.clone();

			Field field = Person.class.getDeclaredField("addresses");
			field.setAccessible(true);
			field.set(clone, new ArrayList<>(this.addresses));

			return clone;
		} catch (ReflectiveOperationException ex) {
			throw new IllegalStateException(ex);
		}
	}

}

克隆不在 final 字段修改限制的例外范围内,因此与其他情况一样,应避免使用反射 API。根据触发其使用的需求,可能有专门的解决方案。例如,在集合防御性拷贝的情况下,构造函数可以改为使用 List.copyOfSet.copyOfMap.copyOf 创建不可变副本,然后克隆对象可以引用同一个集合实例而不需要首先创建防御性拷贝。

不过,这类解决方案不能一概而论。更好的方法是完全避免克隆(它还有其他缺点),而是使用拷贝构造函数或静态工厂/克隆方法,这两者都可以在将参数赋值给 final 字段之前准备好它们,从而消除为此使用反射的需要。

非构造场景

在实例创建之外修改 final 字段,特别是修改包含反射代码的类以外的类的字段,应当是非常极端的例外。可能的原因包括绕过依赖项中的 bug,或配置原本不可配置的行为。然而,这种修复/编辑应被视为临时的,因为依赖项的任何更改都可能使该代码失败,甚至触发异常行为。强烈建议努力将修复/更改合并到依赖项中,使修改代码变得多余并将其移除。

尽管如此,对于此类紧急情况,反射 API 修改 final 字段的能力仍然保留,但需要应用程序所有者通过命令行参数允许。JDK 26 为此引入了两个新选项:

  • --enable-final-field-mutation 是一个永久选项,允许特定模块修改 final 字段
  • --illegal-final-field-mutation 是一个临时选项,有 allowwarn(JDK 26 上的默认值)、debugdeny(未来默认值)几个值,管理如何处理没有特定权限但试图修改 final 字段的代码

JEP 500 以及 Inside Java Newscast #101 详细讨论了这些选项的使用以及它们与强封装的交互,这对应用程序维护者尤为重要。

comments powered by Disqus