两行金额都是0.105元,分别保留两位再相加是0.22元,先相加再保留两位却是0.21元。这两种写法都用了BigDecimal,差的一分也可能来自舍入顺序。
下面用JDK 21.0.11对照四组自建数据,分别检查double构造、逐行与合计舍入、数值比较和除法。先找出差额出现在哪一步,再核对对应业务规则;这里没有使用真实账单。
先区分两种不同的差额
第一种是输入已经近似。new BigDecimal(2.675)接收到的是double实际保存的值;它不等于直接解析十进制文本"2.675"。本例保留两位、使用HALF_UP时,前者得到2.67,后者得到2.68。
BigDecimal的double构造方法文档专门说明了这一点。若上游输入是十进制文本,应保留它,不先转换为double再期望恢复原精度。JDK 21 BigDecimal文档
第二种是计算规则不同。两行0.105,逐行舍入再合计为0.22,合计后舍入为0.21。这一组从输入到运算都使用十进制字符串构造,没有经过double。

要选哪条路径,由业务规则决定。代码只能准确执行已经确定的规则,不能替你决定明细与总计应该如何对应。
用一个完整程序留下反例
保存为MoneyRoundingDemo.java:
shell
javac -encoding UTF-8 --release 21 MoneyRoundingDemo.java
java MoneyRoundingDemo
java
import java.math.BigDecimal;
import java.math.RoundingMode;
public class MoneyRoundingDemo {
static BigDecimal cents(BigDecimal value) { return value.setScale(2, RoundingMode.HALF_UP); }
static void equal(String expected, BigDecimal actual) {
if (!actual.toPlainString().equals(expected)) throw new AssertionError(actual);
}
public static void main(String[] args) {
BigDecimal fromDouble = cents(new BigDecimal(2.675));
BigDecimal fromText = cents(new BigDecimal("2.675"));
equal("2.67", fromDouble); equal("2.68", fromText);
System.out.println("double构造后舍入=" + fromDouble + ", 字符串构造后舍入=" + fromText);
BigDecimal price = new BigDecimal("0.105");
BigDecimal lineFirst = cents(price).add(cents(price));
BigDecimal totalFirst = cents(price.add(price));
equal("0.22", lineFirst); equal("0.21", totalFirst);
System.out.println("逐行舍入再合计=" + lineFirst + ", 合计后舍入=" + totalFirst);
BigDecimal a = new BigDecimal("1.0"), b = new BigDecimal("1.00");
if (a.equals(b) || a.compareTo(b) != 0) throw new AssertionError();
System.out.println("1.0.equals(1.00)=" + a.equals(b) + ", compareTo=" + a.compareTo(b));
boolean rejected = false;
try { BigDecimal.ONE.divide(new BigDecimal("3")); }
catch (ArithmeticException expected) { rejected = true; }
if (!rejected) throw new AssertionError();
BigDecimal rounded = BigDecimal.ONE.divide(new BigDecimal("3"), 2, RoundingMode.HALF_UP);
equal("0.33", rounded);
System.out.println("1/3不指定精度会报错=" + rejected + ", 指定2位结果=" + rounded);
}
}
实际输出:
text
double构造后舍入=2.67, 字符串构造后舍入=2.68
逐行舍入再合计=0.22, 合计后舍入=0.21
1.0.equals(1.00)=false, compareTo=0
1/3不指定精度会报错=true, 指定2位结果=0.33
这里的HALF_UP是本例明确选择的舍入方式,不意味着所有价格、税额或分摊业务都应使用它。生产代码应从对应业务规则中确定方式与保留位数,测试预期也要来自同一规则。
compareTo与equals不能随手互换
1.0和1.00的数值相同,compareTo返回0;equals还比较scale,所以返回false。Map键和HashSet去重通常遵循equals与hashCode,业务若只认数值相等,需要明确统一表示方式或实现相应的比较逻辑,不能因为一次compareTo相等,就假设容器也会自动合并它们。
除法则检查另一类约定:1/3没有有限位十进制结果,直接调用不带精度或舍入参数的divide会抛ArithmeticException。代码又运行了明确保留两位的版本,得到0.33,但这只是指定计算规则后的结果,并没有实现分摊余数处理。
定位差一分的问题时,可以按原始输入、每行中间值、舍入结果、总计四项逐步比较。哪一步开始不同,就在那一步核对规则。先不要靠"最后统一加一分"把差异抹掉,那会让下一种输入继续出问题。
如果明细逐行显示0.11元,两行的总计却是0.21元,你们会先核对哪一步?业务要求逐行舍入还是合计后舍入,页面、导出和结算是否用的是同一条规则?