上周在代码评审里看到一段代码,一个子类的构造函数长这样:
public class RetryPolicy extends BasePolicy {
private final int maxAttempts;
private final Duration backoff;
public RetryPolicy(String name, int maxAttempts, long backoffMillis) {
super(
validateName(name),
normalizeBackoff(backoffMillis),
buildExtraConfig(maxAttempts, backoffMillis)
);
this.maxAttempts = maxAttempts;
this.backoff = Duration.ofMillis(backoffMillis);
}
private static String validateName(String name) {
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("name required");
}
return name.trim();
}
private static long normalizeBackoff(long ms) {
return Math.max(ms, 100L);
}
private static Map<String, Object> buildExtraConfig(int attempts, long ms) {
return Map.of("attempts", attempts, "backoff", ms);
}
}
三个私有静态方法,就为了让 super() 那一行看起来”干净”。问题是这种写法其实一点都不干净:校验逻辑被拆散了,参数之间的关系看不出来,将来加一个校验点又得再开一个静态方法。buildExtraConfig 更别扭——它只是把两个参数打个包,好让父类构造函数能一次性收下。
这就是过去十年 Java 构造函数最典型的妥协。而 JDK 25 里正式发布的 JEP 513(灵活构造函数体),终于能把这段代码改回正常样子了。
先看看旧的限制到底在哪里
Java 从第一天起就有一条规则:构造函数的第一条语句必须是 super(...) 或者 this(...)(不写就是隐式 super())。
这条规则的出发点是好的——保证父类先于子类完成初始化,避免子类构造过程里读到父类未初始化的字段。但副作用是:任何需要”先算一算再调父类”的场景都得绕路。
绕的方式主要有三种,各有各的难受:
第一种是静态辅助方法,也就是评审里看到的那种。缺点是参数校验和它的使用位置距离太远,读代码要在构造函数和辅助方法之间来回跳。而且静态方法里访问不到其它几个参数,某些校验做不了。
第二种是静态工厂方法,把构造函数设成 private,对外只暴露 of() / create()。这个方案本身没问题,但会带来新的问题:不能被子类继承了(因为构造函数私有),而且很多框架依赖公开构造函数做反射实例化,改成静态工厂之后要么加配置要么放弃。
第三种是把校验推迟到 setter 里或者干脆不校验,靠调用方自觉。这个最省事,也最容易埋雷。
我在项目里见过一个极端的例子:某个类的构造函数调了 super(loadConfig()),而 loadConfig() 里会读一个环境变量,读不到就抛异常。结果因为静态初始化顺序的问题,这个异常在某些类加载路径下会变成 ExceptionInInitializerError,排查了半天才找到根因。
JEP 513 到底放开了什么
规则从”第一句必须是 super()”变成了”super() 或 this() 在构造函数里必须被调用且只被调用一次,但不一定是第一句“。
前面可以写什么?官方描述里允许的事情包括:
- 声明并初始化局部变量
- 对参数做运算
- 调用静态方法
- 使用 if、switch、for 等控制流语句
- 抛出异常
- 使用 try/catch/finally
用一句话概括:只要这段代码不碰”这个还没构造完的对象”,就随便写。
把开头那个例子改一下:
public class RetryPolicy extends BasePolicy {
private final int maxAttempts;
private final Duration backoff;
public RetryPolicy(String name, int maxAttempts, long backoffMillis) {
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("name required");
}
if (maxAttempts < 1) {
throw new IllegalArgumentException("maxAttempts must be >= 1");
}
if (backoffMillis < 100) {
backoffMillis = 100; // 直接改参数,不用绕一个静态方法
}
Map<String, Object> extra = Map.of(
"attempts", maxAttempts,
"backoff", backoffMillis
);
super(name.trim(), backoffMillis, extra);
this.maxAttempts = maxAttempts;
this.backoff = Duration.ofMillis(backoffMillis);
}
}
三个静态辅助方法都没了。校验、归一化、组装参数,全部按顺序写在一个地方,读起来就是一段流水账。参数之间的关系(比如 extra 里放的 attempts 就是构造函数的第二个参数)也一眼能对上。
一个更贴近真实业务的重构
抽象一个具体的场景:我们要写一个分页查询的请求类,它继承一个通用的 BaseQuery。父类需要知道”从哪个游标开始”、”取多少条”、”排序字段是哪些”,而子类还要根据业务类型做一套额外的规则,比如导出场景最多只能取 5000 条,列表场景最多 100 条。
改造前:
public class ReportQuery extends BaseQuery {
private final String reportType;
private final LocalDate bizDate;
public ReportQuery(String reportType, LocalDate bizDate,
Integer pageSize, String cursor) {
super(
resolveMaxSize(reportType, pageSize),
cursor == null ? "" : cursor.trim(),
resolveSort(reportType)
);
this.reportType = reportType;
this.bizDate = bizDate;
}
private static int resolveMaxSize(String type, Integer size) {
int limit = "EXPORT".equals(type) ? 5000 : 100;
if (size == null) return Math.min(20, limit);
if (size < 1) throw new IllegalArgumentException("pageSize must be positive");
return Math.min(size, limit);
}
private static List<String> resolveSort(String type) {
if ("EXPORT".equals(type)) return List.of("created_at", "id");
return List.of("updated_at");
}
}
改造后:
public class ReportQuery extends BaseQuery {
private final String reportType;
private final LocalDate bizDate;
public ReportQuery(String reportType, LocalDate bizDate,
Integer pageSize, String cursor) {
if (!"EXPORT".equals(reportType) && !"LIST".equals(reportType)) {
throw new IllegalArgumentException("unknown reportType: " + reportType);
}
if (bizDate == null) {
throw new IllegalArgumentException("bizDate required");
}
int limit = "EXPORT".equals(reportType) ? 5000 : 100;
int size = pageSize == null ? Math.min(20, limit) : pageSize;
if (size < 1) {
throw new IllegalArgumentException("pageSize must be positive");
}
if (size > limit) {
size = limit; // 静默截断,而不是报错
}
String cleanCursor = cursor == null ? "" : cursor.trim();
List<String> sort = "EXPORT".equals(reportType)
? List.of("created_at", "id")
: List.of("updated_at");
super(size, cleanCursor, sort);
this.reportType = reportType;
this.bizDate = bizDate;
}
}
改动之后最大的感知不是”少了两个静态方法”,而是业务规则和它的作用范围在一起了。以前看 resolveMaxSize 的时候,得先确认它在哪些地方被调用才能判断影响面;现在这个逻辑只属于这个构造函数,改的时候心理负担小得多。
五条不能踩的线
这个特性看起来很宽松,但实际上编译器管得比我预想的严。第一次改的时候连续撞了几堵墙,列一下。
线一:不能在 super() 之前访问 this
这包括三件事:不能用 this.xxx 读字段、不能调实例方法、不能把 this 传给别人。
// 编译报错:cannot reference this before supertype constructor
public ReportQuery(...) {
this.reportType = reportType; // 不行
super(...);
}
原因不难理解:这时候父类的构造函数还没跑,子类对象处于半初始化状态,任何一个实例方法都可能读到不完整的状态。
线二:不能读字段,哪怕是通过无前缀的简单名
下面这个写法也是错的:
public class Foo extends Bar {
private int cached;
public Foo() {
if (cached > 0) { // 报错
super(cached);
} else {
super(0);
}
}
}
编译器会直接告诉你 cached 在这里不可访问。这个报错信息挺直白,但第一次看到的时候容易反应不过来——明明写的是字段名,为什么说不能访问?因为无前缀的字段引用在编译后就是 this.cached。
线三:super() 或 this() 仍然只能在代码里出现一次
灵活的是位置,不是数量。下面这种不行:
public Foo(int x) {
if (x > 0) {
super(x);
} else {
super(0);
}
// 报错:构造函数里必须恰好有一次 super/this 调用
}
正确的写法是把判断收敛到参数计算上,让 super() 只出现一次:
public Foo(int x) {
int safe = Math.max(x, 0);
super(safe);
}
这个限制在 try/catch 里同样适用,需要留意控制流的所有分支最后都汇聚到同一个 super() 上。
线四:不能给 final 字段赋值,等 super() 之后再说
这一点容易忽略。JEP 513 允许在 super() 之前声明局部变量,但“局部变量”不包括 final 字段。所以下面这种写法是编译不过的:
public class Foo extends Bar {
private final String normalized;
public Foo(String input) {
String tmp = input.trim().toLowerCase(); // 局部变量,可以
super(tmp);
this.normalized = tmp; // 字段赋值必须放在后面
}
}
庆幸的是这种限制很自然,改写成本几乎为零。
线五:构造过程里抛出的异常,栈帧对调试不太友好
这条不是编译限制,是个使用体会。把大量校验逻辑放在 super() 之前,如果抛异常,堆栈信息会指向构造函数内部的具体行号——大部分情况下没问题,但如果你用了 lambda 或者方法引用,行号可能会指向 super() 那一行,让人误以为是父类抛的。
我的应对办法是把校验抛异常的语句集中放在前面几行,不夹在别的逻辑中间,出问题的时候一眼能看到。这算半个规范,不强制。
什么时候建议用,什么时候不建议用
新语法上市之后总有一种”处处能用”的诱惑。但这几个月改下来,我自己的判断标准是这样:
建议用的场景:
- 构造函数需要对参数做一次性的校验或者归一化,然后再传给父类;
- 参数之间需要做一致性检查,比如”如果 A 是 X 类型,那么 B 不能为空”;
- 父类构造函数的签名和子类的对外签名差别较大,需要在中间做一次转换;
- 参数组合需要用 switch 或者 if-else 分派。
建议不用、或者改用别方案的场景:
- 纯数据对象——优先用 record。record 的紧凑构造函数(compact constructor)做参数校验其实比这个特性更直奔主题,而且天然就是不可变对象;
- 构造逻辑超过 20 行——这时候不是语法的问题,是这个类承担了太多职责,应该拆出一个专门负责参数解析的工厂或者 Builder;
- 需要在构造过程中做 I/O 或者远程调用——这本身就是反模式,新语法反而会让这种写法变得更”自然”,要小心;
- 参数合法组合非常多,需要大量条件分支——建议改用 builder,让非法状态根本无法构造出来。
前面第二条是我在一次重构里加的规则。有个类的构造函数在引入新语法之后一口气长到了 40 多行,参数校验、默认值填充、字段转换全塞在一起。虽然编译完全正确,但 reviewer 一致认为应该拆成 XxxParams 加 XxxParams.from(...)。语法给了便利,但设计边界不能因此模糊。
和 record、紧凑构造函数的关系
有人可能会问:既然 record 已经能写紧凑构造函数了,为什么还需要这个特性?
两者面向的问题不一样。
record 的紧凑构造函数处理的是”字段直接从参数赋值”这种情况,语法更短,语义更清晰。它的限制是不能有 extends(record 只能实现接口),所以有继承关系的场景用不了。
灵活构造函数体处理的是”父类构造函数需要参数,而参数得先经过计算”这种场景。它和继承强绑定。
我个人的偏好是:如果一个类没有必须继承的父类,优先写成 record;如果确实需要继承(比如框架要求、或者需要复用父类的行为),那用这个特性去清理 super() 之前的静态辅助方法。
团队落地的几个建议
特性好不好是一回事,能不能在团队里顺畅用起来是另一回事。我们内部推的时候做了三件事,效果还不错。
第一,加一条 checkstyle 规则。 禁止在构造函数里调用私有静态方法做参数预处理,也就是原来那个写法的典型症状。当然有例外情况——静态方法里确实有独立复用价值的情况还是允许的,规则上写成可忽略的软性提示。
第二,重构旧的类时不强制。 已有的代码没必要专门发一个版本去改。什么时候因为别的原因动到了那个类,顺手改一下就行。大规模重构带来的 review 成本反而比收益高。
第三,在团队的编码规范文档里加一节。 把”什么场景用、什么场景不用”写清楚,特别强调”不要为了用新语法而把逻辑堆进构造函数”这一点。这个风险很真实,因为新语法让构造函数”万能”起来,容易让人忽略它其实是对象构造的入口,不是业务逻辑的家。
最后说一句
这个特性单个拎出来看,只是省了几个静态方法,好像没什么大不了。但它解决的是一类积攒了二十年的妥协写法,这一类写法在很多项目里已经固化成了”常识”——大家甚至不觉得它是问题,只会抱怨”Java 构造函数真别扭”。
“别扭”变成了”顺手”,日积月累省下来的阅读成本和重构阻力,比那几行代码本身重要得多。
如果你正打算把项目升到 JDK 25,建议先挑一个身上挂着两三个 validateXxx 静态方法的类,拿它试一次。改写完之后再看代码,感受一下思路有没有变轻。这比看十篇 JEP 解读文章都直观。

