BigDecimal概述

Java在java.math包中提供的API类BigDecimal,用来对超过16位有效位的数进行精确的运算。双精度浮点型变量double可以处理16位有效数,但在实际应用中,可能需要对更大或者更小的数进行运算和处理。

一般情况下,对于不需要精确计算精度的数字,可以直接使用Float和Double处理,但是Double.valueOf(String)和Float.valueOf(String)会丢失精度。所以如果需要精确计算的结果,则必须使用BigDecimal类来操作。

BigDecimal对象提供了传统的+、-、*、/等算术运算符对应的方法,通过这些方法进行相应的操作。BigDecimal都是不可变的(immutable)的,在进行每一次四则运算时,都会产生一个新的对象,所以在做加减乘除运算时要记得要保存操作后的值。

BigDecimal不可以随便用

在使用BigDecimal时,有四种使用场景下的坑,你一定要了解一下,如果使用不当,必定很惨。

一、浮点类型的坑

在学习了解BigDecimal的坑之前,先来说一个老生常谈的问题:如果使用Float、Double等浮点类型进行计算时,有可能得到的是一个近似值,而不是精确的值。

@Test
void test0() {
    float a = 1;
    float b = 0.9F;
    System.out.println(a - b); // 0.100000024
}

结果是多少?0.1吗?不是,执行上面代码的结果是0.100000024。之所以产生这样的结果,是因为0.1的二进制表示是无限循环的。由于计算机的资源是有限的,所以是没办法用二进制精确的表示0.1,只能用【近似值】来表示,就是在有限的精度情况下,最大化接近0.1的二进制数,于是就会造成精度缺失的情况。

关于上述的现象大家都知道,不再详细展开。同时,还会得出结论在科学计数法时可考虑使用浮点类型,但如果是涉及到金额计算要使用BigDecimal来计算。

那么,BigDecimal就一定能避免上述的浮点问题吗?来看下面的示例:

@Test
void test1() {
    BigDecimal a = new BigDecimal(0.01);
    BigDecimal b = BigDecimal.valueOf(0.01);
    System.out.println("a = " + a);// a = 0.01000000000000000020816681711721685132943093776702880859375
    System.out.println("b = " + b);// b = 0.01
}

即便是使用BigDecimal,结果依旧会出现精度问题。这就涉及到创建BigDecimal对象时,如果有初始值,是采用new BigDecimal()的形式,还是通过BigDecimal.valueOf()方法了

之所以会出现上述现象,是因为new BigDecimal时,传入的0.1已经是浮点类型了,鉴于上面说的这个值只是近似值,鉴于上面说的这个值只是近似值,在使用new BigDecimal时就把这个近似值完整的保留下来

BigDecimal.valueOf()

public static BigDecimal valueOf(double val) {
    // Reminder: a zero double returns '0.0', so we cannot fastpath
    // to use the constant ZERO.  This might be important enough to
    // justify a factory approach, a cache, or a few private
    // constants, later.
    return new BigDecimal(Double.toString(val));
}

在valueOf内部,使用Double.toString()方法,将浮点类型的值转换成了字符串,因此就不存在精度丢失问题了

在此得出一个基本的结论:

  • 在使用BigDecimal构造函数时,尽量传递字符串而非浮点类型
  • 如果无法满足第一条,则可采用BigDecimal.valueOf()方法来构造初始化值

这里延伸一下,BigDecimal常见的构造有如下几种:

  • BigDecimal(int): 创建一个具有参数所指定整数值的对象;
  • BigDecimal(double): 创建一个具有参数所指定双精度值得对象;
  • BigDecimal(long): 创建一个具有参数所指定长整数值得对象;
  • BigDecimal(String): 创建一个具有参数所指定以字符串表示得数值得对象;
二、浮点精度的坑

如果比较两个BigDecimal的值是否相等,你会如何比较?使用equals()方法还是compareTo()方法呢?

@Test
void test2() {
    BigDecimal a = new BigDecimal("0.01");
    BigDecimal b = new BigDecimal("0.010");
    System.out.println(a.equals(b));// false
    System.out.println(a.compareTo(b));// 0
}

equals()方法源码:

@Override
public boolean equals(Object x) {
    if (!(x instanceof BigDecimal))
        return false;
    BigDecimal xDec = (BigDecimal) x;
    if (x == this)
        return true;
    if (scale != xDec.scale)
        return false;
    long s = this.intCompact;
    long xs = xDec.intCompact;
    if (s != INFLATED) {
        if (xs == INFLATED)
            xs = compactValFor(xDec.intVal);
        return xs == s;
    } else if (xs != INFLATED)
        return xs == compactValFor(this.intVal);

    return this.inflated().equals(xDec.inflated());
}

仔细阅读代码可以看出,equals()方法不仅比较了值是否相等,还比较了精度是否相同。上述示例中,由于两者的精度不同,所以equals()方法的结果当然是false了。而compareTo()方法实现了Comparable接口,真正比较的是值得大小,返回的值为-1(小于),0(等于),1(大于)

基本结论:通常情况,如果比较两个BigDecimal值的大小,采用其实现的compareTo()方法;如果严格限制精度的比较,那么则可考虑使用equals()方法。

另外,这种场景在比较0值的时候比较常见,比如比较BigDecimal(“0”)、BigDecimal(“0.0”)、BigDecimal(“0.00”),此时一定要使用compareTo()方法进行比较

三、设置精度的坑

在项目中看到好多同学通过BigDecimal进行计算时不设置计算结果的精度和舍入模式,真是着急人,虽然大多数情况下不会出现什么问题。但下面的场景就不一定了:

@Test
void test3() {
    BigDecimal a = new BigDecimal("1.0");
    BigDecimal b = new BigDecimal("3.0");
    a.divide(b);
}

执行结果:

java.lang.ArithmeticException: Non-terminating decimal expansion; no exact representable decimal result.

在除法(divide)运算过程中,如果商是一个无限小数(0.333…),而操作的结果预期是一个精确的数字,那么将会抛出 ArithmeticException异常

此时,只需在使用divide方法时指定结果的精度即可:

@Test
void test3() {
    BigDecimal a = new BigDecimal("1.0");
    BigDecimal b = new BigDecimal("3.0");
    BigDecimal c = a.divide(b, 2, RoundingMode.HALF_UP);
    System.out.println(c);// 0.33
}

基本结论:在使用BigDecimal进行(所有)运算时,一定要明确指定精度和舍入模式

拓展一下,舍入模式定义在RoundingMode枚举类中,共有8种:

  1. RoundingMode.UP: 舍入远离零的舍入模式。在丢弃非零部分之前始终增加数字(始终对非零舍弃部分前面的数字加1)。注意:此舍入模式始终不会减少计算值的大小。
  2. RoundingMode.DOWN: 接近零的舍入模式。在丢弃某部分之前始终不增加数字(从不对舍弃部分前面的数字+1,即截短)。注意,此舍入模式始终不会增加计算值的大小。
  3. RoundingMode.CEILING: 接近正无穷大的舍入模式。如果BigDecimal为正,则舍入行为与ROUNDUP相同;如果为负,则舍入行为与ROUNDDOWN相同。注意,此舍入模式始终不会减少计算值。
  4. RoundingMode.FLOOR: 接近负无穷大的舍入模式。如果BigDecimal为正,则舍入行为与ROUNDDOWN相同;如果为负,则舍入行为与ROUNDUP相同。注意,此舍入模式始终不会增加计算值。
  5. RoundingMode.HALF_UP: 向“最接近的”数字舍入,如果与两个相邻数字的距离相等,则为向上舍入的舍入模式。如果舍弃部分 >= 0.5,则舍入行为与ROUND_UP相同;否则舍入行为与ROUND_DOWN相同。注意,这是我们在小学时学过的舍入模式(四舍五入)。
  6. RoundingMode.HALF_DOWN: 向“最接近的”数字舍入,如果与两个相邻数字的距离相等,则为上舍入的舍入模式。如果舍弃部分 > 0.5,则舍入行为与ROUND_UP相同;否则舍入行为与ROUND_DOWN相同(五舍六入)。
  7. RoundingMode.HALF_EVEN: 向“最接近的”数字舍入,如果与两个相邻数字的距离相等,则向相邻的偶数舍入。如果舍弃部分左边的数字为奇数,则舍入行为与ROUNDHALFUP相同;如果为偶数,则舍入行为与ROUNDHALF_DOWN相同。注意,在重复进行一系列计算时,此舍入模式可以将累加错误减到最小。此舍入模式也称为“银行家舍入法”,主要在美国使用。四舍五入,五分两种情况。如果前一位为奇数,则入位,否则舍去。一下例子为保留小数点1位,那么这种舍入方式下的结果。1.15 ==> 1.2,1.25 ==> 1.2
  8. RoundingMode.UNNECESSARY: 断言请求的操作具有精确的结果,因此不需要舍入。如果对获得精确结果的操作指定此舍入模式,则抛出ArithmeticException。

通常我们使用的四舍五入即RoundingMode.HALF_UP。

四、三种字符串输出的坑

当使用BigDecimal之后,需要转换为String类型,你是如何操作的?直接toString()?

@Test
void test4() {
     BigDecimal a = BigDecimal.valueOf(35634535255456719.22345634534124578902);
     System.out.println(a.toString()); // 3.563453525545672E+16
 }

也就是说,本来想打印字符串的,结果打印出来的是科学计数法的值。

在这里我们需要了解BigDecimal转换字符串的三个方法

  • toPlainString(): 不使用任何科学计数法
  • toString(): 在必要的时候使用科学计数法
  • toEngineeringString(): 在必要的时候使用工程计数法。类似于科学计数法,只不过指数的幂都是3的倍数,这样方便工程上的应用,因为在很多单位转换的时候都是10^3

基本结论:根据数据结果展示格式不同,采用不同的字符串输出方法,通常使用比较多的方法为toPlainString()。

另外,NumberFormat类的format()方法可以使用BigDecimal对象作为其参数,可以利用BigDecimal对超出16位有效数字的货币值,百分值,以及一般数值进行格式化控制。

使用示例如下:

@Test
void test5() {
    NumberFormat currency = NumberFormat.getCurrencyInstance(); // 建立货币格式化引用
    NumberFormat percent = NumberFormat.getPercentInstance();   // 建立百分比格式化引用
    percent.setMaximumFractionDigits(3);    // 百分比小数点最多3位

    BigDecimal loanAmount = new BigDecimal("15000.48");  // 金额
    BigDecimal interestRate = new BigDecimal("0.008");   // 利率
    BigDecimal interest = loanAmount.multiply(interestRate);   // 相乘

    System.out.println("金额:\t" + currency.format(loanAmount));
    System.out.println("利率:\t" + percent.format(interestRate));
    System.out.println("利息:\t" + currency.format(interest));
}

输出结果如下:

金额:	¥15,000.48
利率:	0.8%
利息:	¥120.00

总结

本篇文章介绍了BigDecimal使用中场景的坑,以及基于这些坑我们得出的“最佳实践”。虽然某些场景下推荐使用BigDecimal,它能够达到更好的精度,但性能相较于double和float,还是有一定的损失的,特别在处理庞大,复杂的运算时尤为明显。故一般精度的计算没必要使用BigDecimal。而必须使用时,一定要规避上述的坑。