requireNonNullElseGet 的回源结果也不能为空
把缓存回源写成 Objects.requireNonNullElseGet(cached, this::load),看起来只是省掉一个 if。但它还附带一个更强的承诺:返回结果一定非空。缓存没有值、回源也没有值时,这个承诺会以 NullPointerException 失败,而不是自然返回 null。
如果业务允许“两层都没找到”,方法名里的 require 就值得认真看待。它适合选择一个必定存在的默认对象,不适合直接表达所有可空回源流程。
下面程序在 Java 17 中可直接运行,保存为 LazyFallback.java,执行 java LazyFallback.java:
import java.util.Objects;
import java.util.concurrent.atomic.AtomicInteger;
public class LazyFallback {
static String load(AtomicInteger calls) {
calls.incrementAndGet();
return "fallback";
}
public static void main(String[] args) {
AtomicInteger calls = new AtomicInteger();
System.out.println("eager=" + Objects.requireNonNullElse("hit", load(calls)));
System.out.println("after-eager=" + calls.get());
System.out.println("lazy=" + Objects.requireNonNullElseGet("hit", () -> load(calls)));
System.out.println("after-lazy-hit=" + calls.get());
System.out.println("miss=" + Objects.requireNonNullElseGet(null, () -> load(calls)));
System.out.println("after-miss=" + calls.get());
try { Objects.requireNonNullElseGet(null, () -> null); }
catch (NullPointerException e) { System.out.println("null-result=rejected"); }
String first = null;
String second = null;
String resolved = first != null ? first : second;
System.out.println("nullable-policy=" + resolved);
try {
Objects.requireNonNullElseGet(null, () -> { throw new IllegalStateException("load failed"); });
} catch (IllegalStateException e) { System.out.println(e.getMessage()); }
}
}
eager=hit
after-eager=1
lazy=hit
after-lazy-hit=1
miss=fallback
after-miss=2
null-result=rejected
nullable-policy=null
load failed
计数器先说明 else 与 elseGet 的求值差异。Java 在进入方法前就会求值实参,所以 requireNonNullElse("hit", load(calls)) 仍然调用 load。换成 Supplier 后,命中分支才避免执行加载函数;没有命中时调用一次,结果仍须非空。
这不是一个“用 lambda 就自动缓存”的机制。每次没有值地调用 elseGet,都可以再次运行 Supplier,方法不会记住上次结果。多个线程同时 miss,也可能各自执行一次。需要合并并发回源或缓存失败结果,应由缓存组件或业务同步机制处理,不能从这个空值工具推导出额外保证。
回源为空,先决定它究竟算什么
用户不存在、配置没有覆盖、文件没有可选字段,都可能合理地产生空结果。示例中的条件表达式明确允许最终为 null;服务也可以返回 Optional 或自己的结果对象,把“没找到”保留下来。不要先用 requireNonNullElseGet 压成异常,再在外层捕获所有 NullPointerException 猜测是不是用户不存在。
相反,某项配置确实必须存在,可以让 Supplier 返回一个明确的默认值,或者在加载函数里抛出有业务含义的异常。这样错误指向“必需配置缺失”,而不是在深处出现一个缺少上下文的空指针。要求非空的选择器与允许不存在的查询,需要不同的返回合同。
load failed 那行还验证了另一件事:Supplier 抛出的异常不会被转换成默认值。惰性执行只是延后工作,不提供异常恢复。把网络调用藏进 Supplier,也仍然需要合理的超时、错误分类和回源规则;elseGet 不会替调用方处理这些协议。
配置覆盖:不存在可以回退,写错不能悄悄回退
例如请求超时有三层配置:租户覆盖、共享配置、固定默认。租户没有设置才读取共享配置;共享配置也没有值才选择默认1000毫秒。租户明确写了oops却属于非法配置,应该暴露错误,不应假装它不存在后默默选一个有效默认值。
下面的 TimeoutConfiguration.java 用Selected记录选中值及来源。Supplier每条分支都返回非空Selected,才满足requireNonNullElseGet的结果合同;选择完成以后,再对这份值执行语法与范围验证。
import java.util.Objects;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Supplier;
public class TimeoutConfiguration {
record Selected(String text, String source) {}
record Config(int milliseconds, String source) {}
static Config resolve(String override, Supplier<String> shared) {
Objects.requireNonNull(shared,"shared loader");
Selected selected = Objects.requireNonNullElseGet(
override == null ? null : new Selected(override,"override"),
() -> {
String text = shared.get();
return text == null ? new Selected("1000","default") : new Selected(text,"shared");
});
if (!selected.text().matches("[1-9][0-9]{0,4}")) throw new IllegalArgumentException("invalid timeout syntax");
int value = Integer.parseInt(selected.text());
if (value < 100 || value > 10000) throw new IllegalArgumentException("timeout outside policy");
return new Config(value,selected.source());
}
public static void main(String[] args) {
AtomicInteger loads = new AtomicInteger();
Supplier<String> absent = () -> { loads.incrementAndGet(); return null; };
System.out.println("hit=" + resolve("2500",absent) + ", loads=" + loads.get());
System.out.println("shared_null=" + resolve(null,absent) + ", loads=" + loads.get());
try { resolve("oops",absent); }
catch (IllegalArgumentException expected) { System.out.println("invalid override=rejected, loads=" + loads.get()); }
try { resolve(null,() -> { throw new IllegalStateException("unavailable"); }); }
catch (IllegalStateException e) { System.out.println("failed load=" + e.getMessage()); }
if (loads.get()!=1) throw new AssertionError("unnecessary fallback");
}
}
hit=Config[milliseconds=2500, source=override], loads=0
shared_null=Config[milliseconds=1000, source=default], loads=1
invalid override=rejected, loads=1
failed load=unavailable
命中覆盖值时共享加载次数为零,共享返回null时才进入明确默认分支。非法覆盖没有触发额外加载,因此故障不会被另一层配置掩盖;共享读取本身失败则正常抛出unavailable,与“成功查过但没配置”不同。
这里的null只在入口表达缺失,Selected进入校验后就必须持有实际文本。返回结果还保存来源,排查时能解释当前2500毫秒究竟从哪里来。真实配置系统若需要保留版本或更新时间,也可以放进同一选择结果,而不是在最终int值外面另猜一次读取路径。
不把空字符串自动当缺失,同样是这份协议的选择。若产品规定空白等于未配置,应先在输入边界完成明确转换;若空白属于错误,就像示例一样拒绝。一个泛用空值工具不应该顺便决定所有空字符串、零值、异常和缺失是否等价。
可见,lambda里确实可以放一段多步选择,但应该保持失败含义清楚:缺失走后备,加载失败留证据,非法已选值返回拒绝。若要求共享系统暂时不可用时也允许旧值,需要再定义缓存版本和降级策略,不能捕获所有异常后一律回默认,并让调用方以为拿到了刚更新的配置。
这个场景仍然没有合并并发加载。许多租户同时缺少覆盖时,会分别触发共享读取;是否批量缓存、多久失效、更新是否一致,要由配置组件处理。把这份选择器测试保存下来,能保护调用次数与缺失语义,却不能代替缓存压力和真实更新链路验证。
惰性表达式不意味着周围的一切都惰性
() -> load(calls) 的函数体直到需要时才执行,但创建这个表达式之前已经算好的参数不会倒退回去。例如先调用一次远程函数得到变量,再让 lambda 返回该变量,并没有减少那次远程调用。代码审查应追踪工作的真正发生点,不能只看到后缀 Get 就判断避免了计算。
还有一种容易误解的写法是把可空接收者的方法引用传进来。若构造引用本身就需要解引用一个 null 对象,可能在调用选择器之前失败;正常值也救不了那次参数求值。把必要的状态读取写清楚,通常比追求一行表达式更容易说明异常发生在哪个阶段。
共享默认值同样需要留意对象是否可变。默认字符串可以安全复用,但一个公共 ArrayList 被所有空结果返回后,任一调用方都可能改变其他人的“默认集合”。若需要每次独立对象,应在 miss 路径创建;若要求公共只读值,则应把只读合同落实到对象及其暴露方式,而不是只保证引用非空。