BigDecimal 类的四个常见陷阱,请别再这么使用了!

陷阱 1:使用 double 构造函数

在日常使用 Java 中进行业务计算,尤其是涉及货币,金额时,建议使用 BigDecimal 类,以避免使用 floatdouble 等原始类型所遇到的浮点数运算精度问题。

请看下面这个例子:

BigDecimal x = new BigDecimal(0.1);
System.out.println("x=" + x); //输出结果 x=0.1000000000000000055511151231257827021181583404541015625

结果发现 此构造函数的结果可能不准确。这是因为浮点数在计算机硬件中表示为以2为底的(二进制)分数。然而,大多数十进制分数不能精确地表示为二进制分数。实际存储在计算机中的二进制浮点数仅近似于输入的十进制浮点数。因此,传递给double构造函数的值并不完全等于0.1。

相比之下,String构造函数是完全可预测的,它产生的BigDecimal正好等于0.1,正如预期的那样。

BigDecimal y = new BigDecimal("0.1");
System.out.println("y=" + y);  //输出结果 y=0.1

所以, 应该优先使用String构造函数而不是double构造函数。

如果必须使用double来创建BigDecimal,请使用静态BigDecimal.valueOf(double)方法。

底层会使用Double.toString(double)方法然后使用BigDecimal(String)构造函数将double转换为String相同的结果。

陷阱 2:使用静态方法 valueOf(double)

如果使用静态BigDecimal. valueof (double)方法创建BigDecimal,请注意这个方法返回值精度有限

BigDecimal x = BigDecimal.valueOf(1.01234567890123456789);
BigDecimal y = new BigDecimal("1.01234567890123456789");
System.out.println("x=" + x);
System.out.println("y=" + y);
//输出结果 
// x=1.0123456789012346
// y=1.01234567890123456789

x值丢失了四位小数,因为double的精度只有15-17位(float的精度只有6-9位),而BigDecimal的精度是任意的(仅受内存限制)。

因此,使用String构造函数实际最佳实践,因为有效地避免了双构造函数引起的两个主要问题。

陷阱 3:使用 equals(BigDecimal) 方法

即使两个 BigDecimal 值在数值上相等,但由于它们的小数位数(scale)不同,使用 equals() 比较时仍可能返回 false。

BigDecimal x = new BigDecimal("1");
BigDecimal y = new BigDecimal("1.0");

System.out.println(x.equals(y));         // false
System.out.println(x.compareTo(y) == 0); // true

x.equals(y)为什么等于false呢?

这是由于BigDecimal由一个任意精度的未缩放整数值和一个32位整数组成,这两个值都必须等于正在比较的另一个BigDecimal的相应值。在这种情况下

  • x的值为1,精度值为0。

  • y的值10, 精度的1。

因此,x不等于y。

因此,不应使用equals()方法比较BigDecimal的两个实例,而应使用compareTo()方法,因为它比较BigDecimal两个实例表示的数值(x=1;y=1.0)。这里有一个例子:

System.out.println(x.compareTo(y) == 0);  // 输出 true

所以大家在开发过程中应使用 compareTo() 进行数值相等比较;如果一定要用equals() 方法需要同时考虑值和小数位数

陷阱 4:使用 round(MathContext) 方法

大家在开发过程中 可能会试图使用round(new MathContext(precision,routingMode))方法将BigDecimal四舍五入到(假设)两位小数,这种方法会出问题的。

round() 方法是基于有效数字进行舍入,而不是基于小数位数

BigDecimal x = new BigDecimal("12345.6789");
x = x.round(new MathContext(2, RoundingMode.HALF_UP));

System.out.println(x.toPlainString()); // 输出: 12000
System.out.println(x.scale());         // 输出: -3

该方法不会对小数部分进行舍入,但会将未缩放的值四舍五入到给定的有效位数(从左到右计数),保持小数点不变,在上面的示例中,这会导致-3的负小数位数。

这是为什么?

未缩放的值(123456789)四舍五入为两位有效数字(12),表示精度为2。但是,由于小数点保持不变,因此此BigDecimal表示的实际值为120000000。这也可以写成12000,因为小数点右侧的四个零没有意义。

但是精度呢?为什么它是-3,而不是0?

这是因为这个BigDecimal的未缩放值是12,因此,它必须乘以1000,即10的3次方,(12 x 103)等于12000。

因此,正刻度表示小数位数(即小数点右侧的位数),而负刻度表示小数点左侧不重要的位数(在这种情况下,尾随的零,因为它们只是表示数字刻度的占位符)。

因此,最后,BigDecimal表示的数字是未缩放的Value x 10小数位数。

还要注意,上面的代码使用了toPlainString()方法,该方法不以科学符号(1.2E+4)显示结果。

要获得12345.68的预期结果,请尝试setScale(scale,roundingMode)方法,例如

BigDecimal x = new BigDecimal("12345.6789");
x = x.setScale(2, RoundingMode.HALF_UP);
System.out.println("x=" + x)); //输出 x=12345.68

setScale(scale,roundingMode)方法根据指定的舍入模式将分数部分舍入到小数点后两位。

还可以使用round(new MathContext(precision, roundingMode))方法进行常规舍入。

但这需要你知道计算结果小数点左侧的数字总数。以下示例:

BigDecimal a = new BigDecimal("12345.12345");
BigDecimal b = new BigDecimal("23456.23456");
BigDecimal c = a.multiply(b);
System.out.println("c=" + c); //输出 c=289570111.3153564320

例如,要将c四舍五入到小数点后两位,必须使用精度为11的MathContext对象

BigDecimal d = c.round(new MathContext(11, RoundingMode.HALF_UP));
System.out.println("d=" + d); //d=289570111.32

小数点左侧的总位数可以这样计算

bigDecimal.precision() - bigDecimal.scale() + newScale

其中bigDecimal.precision()是未舍入结果的精度。scale()是未舍入结果的比例。

newScale是要舍入到的比例。那么最后完整代码

BigDecimal e = c.round(new MathContext(c.precision() - c.scale() + 2, RoundingMode.HALF_UP));
//结果输出 e=289570111.32

然而,如果你比较下面这个表达式代码

c.setScale(2, RoundingMode.HALF_UP);

为了确保代码的可读性和简洁性,选择这种代码就更好。

所以如需保留两位小数,应使用 setScale(2, RoundingMode.HALF_UP),而非 round()

扩展 BigDecimal:自定义类 Decimal

你可以将 BigDecimal 扩展为一个自定义类(如 Decimal),以封装最佳实践。

  • 添加构造函数来规避 double 精度问题
  • 重写返回类型为 BigDecimal 的方法,使其返回 Decimal
  • 添加新方法如 equalTo(), greaterThan() 等,提升可读性
public Decimal(double val) {
    super(Double.toString(val));
    if (precision() > 14) {
        throw new IllegalArgumentException("Potential precision loss");
    }
}

格式化与舍入辅助方法

使用接口和枚举可以标准化舍入与格式化的逻辑。

  • RoundingInfo 接口定义舍入规则
  • FormatInfo 接口定义格式模板和地区
  • 可创建 Rounding.AMOUNT, Format.AMOUNT 等常用预设
Decimal c = a.multiply(b).round(Rounding.AMOUNT);
System.out.println("c=" + c.format(Format.AMOUNT)); // 输出: 289,570,111.32

结论

BigDecimal 是处理精确小数的关键类,但使用不当容易出错。通过:

  • 避免使用 double 构造函数
  • 使用 compareTo() 而非 equals()
  • 使用 setScale() 进行舍入
  • 自定义类封装常用逻辑
下一篇