跳到主要内容
约 9 分钟3 张卡

BigDecimal

BigDecimal 用整数值和小数位数表示十进制数,适合需要明确舍入规则的金额计算。本文先说明 double 为什么会有十进制误差,再介绍构造、运算和比较。最后讨论金额模型,以及舍入方向和精确转换。

1. 面试核心​

先记住这几条,再读正文
  • 十进制精度。 double 用二进制浮点数保存 0.1 的近似值,金额可从十进制文本构造 BigDecimal。卡 · double 的十进制误差
  • 数值表示。 BigDecimal 的值等于 unscaledValue × 10^-scale。
  • 除法与舍入。 未指定舍入策略的 divide 遇到无限循环小数会抛出 ArithmeticException。金额运算需要明确保留精度与舍入规则。
  • 不可变运算。 BigDecimal 运算保持原对象不变,结果需由调用方接收。
  • 相等与比较。 equals 比较数值和 scale,compareTo 只比较数值。卡 · equals 与 compareTo

2. double 的十进制误差​

要理解金额计算中的尾差,先看 double 怎样保存小数。它使用二进制浮点表示,而 0.1 的二进制形式是无限循环小数。存储空间有限,所以程序只能保存最接近的可表示值:

double result = 0.1 + 0.2;
System.out.println(result); // 0.30000000000000004

结果中的尾差来自二进制近似和运算舍入。允许误差的科学计算、图形处理和统计场景可以使用 double。金额与税率结算需要控制十进制规则,因此可使用 BigDecimal,或按最小货币单位保存整数。

3. BigDecimal 的表示​

一个 BigDecimal 由任意精度整数值和 scale 组成,其数值可以理解为:

unscaledValue × 10^-scale

例如,123.45 可以写成 12345 × 10^-2:

BigDecimal amount = new BigDecimal("123.45");

System.out.println(amount.unscaledValue()); // 12345
System.out.println(amount.scale()); // 2
System.out.println(amount.precision()); // 5

上例可以用来区分小数位数与有效数字位数:

  • scale 为非负数时,表示小数点右侧的位数,这里是 2。
  • precision 表示有效数字的位数,这里是 5。
  • unscaledValue 保存数值公式中的整数系数,这里是 12345。

BigDecimal 是不可变对象。add、setScale 返回运算结果,原对象保持不变:

BigDecimal original = new BigDecimal("10.00");
BigDecimal updated = original.add(new BigDecimal("2.50"));

System.out.println(original); // 10.00
System.out.println(updated); // 12.50

4. BigDecimal 构造​

构造方式决定了 BigDecimal 接收到的是原始十进制数,还是已经舍入的浮点数。已知十进制文本时,直接使用字符串构造方法:

BigDecimal price = new BigDecimal("19.90");
BigDecimal taxRate = new BigDecimal("0.06");

整数来源可以直接使用 valueOf(long) 或常量:

BigDecimal count = BigDecimal.valueOf(3);
BigDecimal zero = BigDecimal.ZERO;

4.1 new BigDecimal(double) 的问题​

来源只能提供 double 时,需要区分直接构造与 valueOf 的转换方式。下面两种写法接收相同的 0.1,结果却不同:

BigDecimal unexpected = new BigDecimal(0.1);
System.out.println(unexpected);
// 0.1000000000000000055511151231257827021181583404541015625

BigDecimal value = BigDecimal.valueOf(0.1);
System.out.println(value); // 0.1

这两种结果来自不同的转换依据:

  • new BigDecimal(double) 精确转换传入的二进制浮点值。0.1 在传入前已经舍入,所以转换后会出现一长串小数。
  • valueOf(double) 先取得浮点数的规范字符串表示,再转为十进制数。这里使用的是 "0.1",所以结果为 0.1。

valueOf 也无法恢复来源在转为 double 前丢失的精度。因此,金额应直接从文本、数据库 DECIMAL 或最小单位整数进入系统。

5. 运算与舍入​

金额运算既要保存数值,也要确定什么时候舍入。不传入 MathContext 时,加、减、乘按精确值计算。MathContext 是限定有效数字位数和舍入方式的配置,后面会进一步说明。

BigDecimal subtotal = new BigDecimal("19.90")
.multiply(BigDecimal.valueOf(3));

System.out.println(subtotal); // 59.70

精确除法要求结果能表示为有限小数。1/3 无法满足该条件,因此无舍入参数的 divide 会报错:

BigDecimal one = BigDecimal.ONE;
BigDecimal three = BigDecimal.valueOf(3);

// one.divide(three); // ArithmeticException

BigDecimal result = one.divide(three, 2, RoundingMode.HALF_UP);
System.out.println(result); // 0.33

上例选择保留两位小数,按 HALF_UP 舍入后得到 0.33。这属于业务规则,需要提前明确以下四项精度要求:

  • 中间计算保留多少精度。
  • 哪一步进行舍入。
  • 最终展示、结算和入账分别使用什么小数位。
  • 正数和负数是否使用相同舍入方式。

5.1 小数位调整​

setScale 用于调整小数位数。它保持原对象不变,因此调用方需要接收返回结果:

BigDecimal amount = new BigDecimal("12.345");
BigDecimal rounded = amount.setScale(2, RoundingMode.HALF_UP);

System.out.println(amount); // 12.345
System.out.println(rounded); // 12.35

只调用 amount.setScale(...) 而不接收返回值,不会改变 amount。

5.2 MathContext​

MathContext 限定有效数字个数及舍入方式。setScale 则控制小数位数:

MathContext context = new MathContext(4, RoundingMode.HALF_UP);
BigDecimal result = new BigDecimal("123.45")
.multiply(new BigDecimal("6.789"), context);

System.out.println(result); // 838.1

上例的 4 表示整个结果保留四位有效数字,因此结果是 838.1。它并非保留四位小数。

金融结算按币种和业务规则控制小数位,高精度科学计算则会关注有效数字位数。因此,setScale 与 MathContext 要按各自用途配置,而不是统一套用一个全局精度。

6. equals 与 compareTo​

两个金额数值相同,小数位数仍可能不同。equals 和 compareTo 对这种情况采用不同的判断方式:

BigDecimal first = new BigDecimal("2.0");
BigDecimal second = new BigDecimal("2.00");

System.out.println(first.equals(second)); // false
System.out.println(first.compareTo(second)); // 0

对照结果,需要区分数值相等与表示相同:

  • equals 同时比较数值和 scale,所以 2.0 与 2.00 不相等。
  • compareTo 只比较数值大小,因此两者的比较结果为 0。

判断金额数值是否相等,可以使用 compareTo(...) == 0。HashMap 和 HashSet 则按 equals 与 hashCode 识别键或元素。因此,这两个值会成为不同的哈希键。

一种做法是在进入领域对象时统一 scale,让对象表示也具有明确约束:

public record Money(BigDecimal amount) {
public Money {
amount = amount.setScale(2, RoundingMode.UNNECESSARY);
}
}

这个示例只适用于明确要求两位小数且输入不得舍入的业务。不同货币和业务不一定都使用两位小数,规则应由领域决定。

7. 金额模型的选择​

金额可以按元等主单位保存,也可以换成分等最小单位保存。选择前要明确币种和换算规则,再比较以下两种模型。

7.1 BigDecimal 主单位模型​

BigDecimal 可以直接保存以元为单位的金额。它适合对接数据库 DECIMAL,也适合小数位可变或计算规则复杂的业务。

采用这种模型时,需要在数据进入系统时统一金额表示规则:

  • 精度和舍入模式要符合计算与结算要求。
  • 货币单位要明确,例如数值表示元还是分。
  • 序列化格式要一致,例如接口如何输出小数位和科学计数形式。

7.2 long 最小单位模型​

货币的最小单位固定时,可以用 long 保存整数。例如,以分保存 19.90 元:

long amountInCents = 1990;

整数运算没有小数表示误差,类型也更轻量。但需要防止溢出,并且只有在货币的最小单位和换算规则固定时才简单。涉及比例、税率和分摊时仍然必须定义余数和舍入规则。

两种模型都应把数值与币种放在一起。例如,人民币 100 元与美元 100 元需要先换算,再按业务规则计算。这样可以避免把相同数字误当成同一种金额。

8. 舍入方向与精确转换​

负数更容易暴露舍入方向的差别。下面用同一个 -1.25 比较四种舍入模式,都保留一位小数:

BigDecimal value = new BigDecimal("-1.25");
System.out.println(value.setScale(1, RoundingMode.HALF_UP)); // -1.3
System.out.println(value.setScale(1, RoundingMode.HALF_EVEN)); // -1.2
System.out.println(value.setScale(1, RoundingMode.DOWN)); // -1.2
System.out.println(value.setScale(1, RoundingMode.FLOOR)); // -1.3

这些结果分别对应以下规则:

  • HALF_UP 选择最近的值,距离相等时远离零,因此结果是 -1.3。
  • HALF_EVEN 也选择最近的值,距离相等时保留偶数末位,因此结果是 -1.2。
  • DOWN 向零舍入,因此结果是 -1.2。
  • FLOOR 向负无穷舍入,因此结果是 -1.3。

如果业务要求数值保持精确,可以使用 UNNECESSARY。它要求当前操作无需舍入,否则就抛出异常。

转换为整数时也需要明确是否允许丢失精度。intValue() 会截去小数,溢出时保留低 32 位。intValueExact() 则在有非零小数或溢出时抛出异常,让调用方发现这次转换会丢失数值。

自然排序的 TreeSet 用 compareTo 判重。2.0 与 2.00 在其中合为一个元素。HashSet 使用 equals,会保留两个元素。

9. 常见问题​

9.1 使用 BigDecimal 后,还需要定义哪些金额规则?​

BigDecimal 负责十进制表示和精度控制,完整的金额业务还需要定义以下结算规则:

  • 金额使用什么币种,按哪个时点的汇率换算。
  • 分摊后有余数时,把余数分配给谁。
  • 在哪一步舍入,以及遵循哪些会计规则。

选错 scale 或在每一步重复舍入,都会影响最终金额。因此,即使使用 BigDecimal,也要验证完整的计算过程。

9.2 stripTrailingZeros() 适合统一 scale 吗?​

9.3 为什么不使用 double,再格式化为两位小数?​

10 · 面试题卡片​

10.1 金额为什么不适合直接使用 double​

出现公司:帆软、阿里巴巴

考察重点

  • 解释十进制小数为什么会在二进制浮点表示中产生误差。
  • 说明 BigDecimal 怎样保存数值和小数位。
  • 区分精确表示数值与制定业务舍入规则各自解决的问题。

相关内容:double 的十进制误差、BigDecimal 的表示、运算与舍入。

参考回答

double 只能近似保存许多十进制小数,连续计算会出现尾差。

BigDecimal 则用整数系数与 scale 表示十进制数,因此可以精确保存十进制文本中的金额。

使用 BigDecimal 时仍要明确创建方式和舍入规则。我会从字符串、数据库 DECIMAL 或最小货币单位创建,避免 new BigDecimal(double);除法和结算时由业务指定精度与 RoundingMode。

10.2 BigDecimal 的 equals 与 compareTo 有什么区别​

出现公司:帆软

考察重点

  • 区分数值相同与表示相同。
  • 解释 scale 怎样影响 equals、hashCode 和哈希集合判重。
  • 根据业务需要判断是否应统一小数位。

相关内容:equals 与 compareTo。

参考回答

equals 同时比较数值和 scale,所以 2.0 与 2.00 不相等;compareTo 比较数值大小,两者比较结果为 0。

判断金额数值是否一致使用 compareTo(...) == 0。

如果 BigDecimal 作为哈希键,集合会使用 equals 和 hashCode,不同 scale 会形成不同键。领域模型可以在构造边界统一合法的小数位,让表示规则也保持一致。

10.3 负数的 HALF_UP、DOWN 和 FLOOR 有什么区别?​

出现公司:暂无已核验的公开来源(本站自测)

考察重点:根据舍入方向推导负数的结果,并判断怎样发现精度丢失。

相关内容:舍入方向与精确转换。

参考回答

HALF_UP 在两个候选值的中点选择远离零的一侧,DOWN 向零舍入,FLOOR 向负无穷舍入。

所以,-1.25 保留一位小数时,三者依次得到 -1.3、-1.2、-1.3。要求操作保持精确时,可以使用 UNNECESSARY。转换为整数时,则用 intValueExact() 这类精确转换方法,让精度丢失直接报错。

11. 参考资料​