Math.addExact 该放在哪:让溢出成为明确失败,而不是半份更新
一个正计数再加一,变成负数。Java的普通整数运算允许回绕,它不会因为这个值在业务里叫累计数量就自动报警。Math.addExact能把表示范围溢出变成ArithmeticException,但放在哪里,会决定失败以后系统留下什么状态。
假设一次操作要同时更新已处理数量和总字节数。如果先把第一项写回对象,再在第二项addExact时失败,第一项已经变了。精确运算并不等于整次业务更新原子。
下面把三个层次放在同一个小程序:普通加法回绕、单次精确加法拒绝、两字段候选状态验证后一次交付。
public class ExactUpdate {
record Totals(long items, long bytes) { }
static Totals apply(Totals old, long addItems, long addBytes, long maxBytes) {
if (addItems < 0 || addBytes < 0) throw new IllegalArgumentException("negative increment");
long nextItems = Math.addExact(old.items(), addItems);
long nextBytes = Math.addExact(old.bytes(), addBytes);
if (nextBytes > maxBytes) throw new IllegalStateException("business byte limit");
return new Totals(nextItems, nextBytes);
}
public static void main(String[] args) {
System.out.println("wrapped=" + (Integer.MAX_VALUE + 1));
try { Math.addExact(Integer.MAX_VALUE, 1); throw new AssertionError(); }
catch (ArithmeticException expected) { System.out.println("exact=OVERFLOW"); }
Totals current = new Totals(5, Long.MAX_VALUE);
try { current = apply(current, 1, 1, Long.MAX_VALUE); throw new AssertionError(); }
catch (ArithmeticException expected) { System.out.println("original_items=" + current.items()); }
System.out.println("valid=" + apply(new Totals(5, 10), 1, 2, 100));
try { apply(new Totals(5, 99), 1, 2, 100); throw new AssertionError(); }
catch (IllegalStateException expected) { System.out.println("business_limit=REJECTED"); }
}
}
JDK 17中保存为 ExactUpdate.java,执行 java ExactUpdate.java:
wrapped=-2147483648
exact=OVERFLOW
original_items=5
valid=Totals[items=6, bytes=12]
business_limit=REJECTED
算出所有候选值,再改变当前状态
Totals是不可变记录,apply先在局部算出两个新值;任意一步失败,都不会返回一份半更新对象。调用方只在方法成功返回后才把current替换为next。样本让第二项溢出,原对象的items仍为5,说明第一项候选值没有提前泄露成当前状态。
这个模式适合内存模型的局部更新,也能指导数据库业务:先完成可前置验证,相关写入再由事务边界保护。它没有让一个Java对象替换自动变成跨服务事务,多个线程共享current时还要有同步、CAS或版本条件。
若直接在可变对象里依次写两个字段,即便每次加法都使用Exact,也仍然需要恢复或整体同步协议。把运算符改安全,只解决了值能否表示,没解决其他线程何时看见哪一半状态。
业务超限不必等到类型真的装不下
允许额度可能只有一百万,而long还能表示更大的数。addExact不会替业务执行这条上限。样本在精确加法以后检查maxBytes,让“本次结果仍可表示,但超过业务额度”以单独错误失败。
反过来,先用普通加法得到一个回绕负数,再判断它小于上限,可能让非法输入穿过去。先精确计算,或采用经过证明的差值比较,才能确保额度判断针对真实数学结果。
增量是否允许负数也要决定。本例拒绝负增量,计数和字节总量只增加。如果系统还有冲正、扣减或撤销,应按相应状态机验证下界与业务原因,不能把任意负数当通用修复入口。
类型拓宽必须覆盖中间表达式
把结果声明为long,若两个int操作数先完成相乘,溢出仍可能已经发生。long total = intA * intB不会自动把乘法升级;应先拓宽一个操作数,或使用匹配类型的multiplyExact。
addExact、subtractExact与multiplyExact分别对应不同运算,转换成更窄类型时还可用toIntExact。不要只在最后一个加法上加保护,就忽略前面单位换算、批量乘积或数组大小计算。错误可能在到达这个方法以前就已经确定。
除法也有自己的极端情况,不能把Exact家族名称理解成“整个表达式所有数学操作都受保护”。代码审查应按表达式的每个阶段检查表示范围与业务单位,而不是搜索到一次addExact就结束。
失败类型要让调用者能采取正确动作
输入不符合负增量规则、超过业务额度、数学表示溢出,可能需要不同的错误协议。对用户可给出稳定错误码,内部保留原始分类;不要把ArithmeticException统一转换成0再继续,那会让失败看起来像一次成功的无增量更新。
测试里第二字段失败、第一字段保持不变,正是为了验证这份原子交付约定。还应补上恰好最大值成功、再加一失败、零增量和业务上限等值。随机多算几个小数字,很难碰到这些真正决定正确性的边界。
如果设计上就是需要模运算,例如固定宽度序号或位模式计算,普通回绕可以是明确选择。此时应把模数与比较协议写清楚。对于数量、容量和金额单位,静默回绕通常没有合理业务解释;让它失败并保留完整状态,比生成一份可继续传播的错误数字更容易处理。