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 吗?
固定小数位的金额接口应使用 setScale。stripTrailingZeros() 用于移除末尾零,结果的 scale 可以为负。
例如,new BigDecimal("1000").stripTrailingZeros() 的整数系数为 1,scale 为 -3。按数值公式计算,它仍然是 1000,但文本形式会是 1E+3。所以显示或持久化前,要确认接口是否接受这种表示。
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() 这类精确转换方法,让精度丢失直接报错。