BigDecimal——精度计算

763 阅读9分钟

金钱的精度计算

1. 溢出与精度

保证金额的准确性主要有两个方面:溢出精度

  • 溢出是指存储数据的空间得充足,不能金额较大就存储不下了。
  • 精度是指计算金额时不能有偏差,多一点少一点都不行。

我们来直观感受一下精度丢失:

double money = 1.0 - 0.9;

结果

0.09999999999999998

这个运算结果谁都知道该为 0.1,然而实际结果却是 0.09999999999999998。

出现这个现象是因为计算机底层是二进制运算,而二进制并不能精准表示十进制小数。

所以在商业计算等精确计算中要使用其他数据类型来保证精度不丢失,一定不要使用浮点数。

2. Double浮点型的问题

四则运算

image-20220714163346431

image-20220714163300091

由于计算机内部是以二进制存储数值的,浮点数亦是。 Java采用IEEE 754标准实现浮点数的表达和运算。比如,0.1的二进制表示为0.0 0011 0011 0011… (0011 无限循环),再转换为十进制就是0.1000000000000000055511151231257827021181583404541015625。计算机无法精确表示0.1,所以浮点数计算造成精度损失。

你可能觉得像0.1,其十进制和二进制间转换后相差很小,不会对计算产生什么严重影响。但积土成山,大量使用double作大量金钱计算,最终损失精度就是大量资金出入了。

3. 精度问题

方案1️⃣——定长整形

定长整数,顾名思义就是固定(小数)长度的整数。 它只是一个概念,并不是新的数据类型,我们使用的还是普通的整数。

金额好像理所应当有小数,但稍加思考便会发觉小数并非是必须的。之前我们演示的金额单位是元,1.55 就是一元五角五分。那如果我们单位是角,一元五角五分的值就会变成 15.5。如果再将单位缩小到分,值就为 155。没错,只要达到最小单位,小数完全可以省略!这个最小单位根据业务需求来定,比如系统要求精确到厘,那么值就是1550。当然,一般精确到分就可以了,咱们接下来演示单位都是分。

现在新建一个字段,类型为 bigint,单位为分,代码中对应的数据类型自然是 Long。基本类型的数值运算我们是再熟悉不过的了,直接使用运算操作符即可:

加减操作

long d1 = 10000L; // 100元
d1 += 500L; // 加五元
d1 -= 500L; // 减五元

乘除操作

long d1 = 2366L; // 23.66元
double result = d1 * 0.8; // 打八折,运算后结果为1892.8
d1 = (long)result; // 转换为整数,舍去所有小数,值为1892。即18.92元

进行小数运算,类型自然而然就会变为浮点数,所以我们还要将浮点数转换为整数。

强转会将所有小数舍去,这个舍去并不代表精度丢失。业务要求最小单位是什么,就只保留什么,低于分的单位我们压根没必要保存。 这一点和 BigDecimal 是一致的,如果系统中只需要到分,那小数精度就为 2, 剩余的小数都舍去。

不过有些业务计算可能要求四舍五入等其他操作,这一点我们可以通过 Math类来完成:

long d1 = 2366L; // 23.66元
double result = d1 * 0.8; // 运算后结果为1892.8
d1 = (long)result; // 强转舍去所有小数,值为1892
d1 = (long)Math.ceil(result); // 向上取整,值为1893
d1 = (long)Math.round(result); // 四舍五入,值为1893
...
复制代码

再来看除法运算。当整数除以整数时,会自动舍去所有小数:

long d1 = 2366L;
long result = d1 / 3; // 正确的值本应该为788.6666666666666,舍去所有小数,最终值为788

如果要进行四舍五入等其他小数操作,则运算时先进行浮点数运算,然后再转换成整数:

long d1 = 2366L;
double result = d1 / 3.0; // 注意,这里除以不是 3,而是 3.0 浮点数
d1 = (long)Math.round(result); // 四舍五入,最终值为789,即7.89元

虽说数据库存储和代码运算都是整数,但前端显示时若还是以分为单位就对用户不太友好了。

所以后端将值传递给前端后,前端需要自行将值除以 100,以元为单位展示给用户。然后前端传值给后端时,还是以约定好的整数传递。

方案2️⃣——BigDecimal

关于数据类型的选择,一要考虑数据库,二要考虑编程语言。即数据库中用什么类型来存储数据,代码中用什么类型来处理数据。

数据库层面自然是用 decimal 类型,因为该类型不存在精度损失的情况,用它来进行商业计算再合适不过。

将字段定义为 decimal 的语法为 decimal(M,N),M 代表存储多少位,N 代表小数存储多少位。 假设 decimal(20,2),则代表一共存储 20 位数值,其中小数占 2 位。这里小数位置保留 2 点,代表金额只存储到分,实际项目中存储到什么单位得根据业务需求来定,都是可以的。

数据库层面搞定了咱们来看代码层面,在 Java 中对应数据库 decimal 的是 java.math.BigDecimal类型,它自然也能保证精度完全准确。

创建BigDecimal

要创建BigDecimal主要有三种方法:

BigDecimal d1 = new BigDecimal(0.1); // BigDecimal(double val)(禁止使用)
BigDecimal d2 = new BigDecimal("0.1"); // BigDecimal(String val)
BigDecimal d3 = BigDecimal.valueOf(0.1); // static BigDecimal valueOf(double val)

前面两个是构造函数,后面一个是静态方法。这三种方法都非常方便,但第一种方法禁止使用!看一下这三个对象各自的打印结果就知道为什么了:

d1: 0.1000000000000000055511151231257827021181583404541015625
d2: 0.1
d3: 0.1

第一种方法通过构造函数传入 double 类型的参数并不能精确地获取到值,若想正确的创建 BigDecimal,要么将 double 转换为字符串然后调用构造方法,要么直接调用静态方法。事实上,静态方法内部也是将 double 转换为字符串然后调用的构造方法:

image-20220714143501859

如果是从数据库中查询出小数值,或者前端传递过来小数值,数据会准确映射成 BigDecimal 对象,这一点我们不用操心。

说完创建,接下来就要说最重要的数值运算。

BigDecimal运算方法

运算无非就是加减乘除,这些 BigDecimal 都提供了对应的方法:

BigDecimal add(BigDecimal); // 加
BigDecimal subtract(BigDecimal); // 减
BigDecimal multiply(BigDecimal); // 乘
BigDecimal divide(BigDecimal); // 除

BigDecimal 是不可变对象,意思就是这些操作都不会改变原有对象的值,方法执行完毕只会返回一个新的对象。 若要运算后更新原有值,只能重新赋值:

d1 = d1.subtract(d2);

口说无凭,我们来验证一下精度是否会丢失 :

BigDecimal d1 = new BigDecimal("1.0");
BigDecimal d2 = new BigDecimal("0.9");
System.out.println(d1.subtract(d2));

结果

0.1

输出结果毫无疑问为 0.1。

代码方面已经能保证精度不会丢失,但数学方面除法可能会出现除不尽的情况。

比如我们运算 10 除以 3,会抛出ArithmetricException异常。

精度控制

为了解决除不尽后导致的无穷小数问题,我们需要人为去控制小数的精度。 除法运算还有一个方法就是用来控制精度的:

BigDecimal divide(BigDecimal divisor, int scale, int roundingMode)

scale 参数表示运算后保留几位小数,roundingMode 参数表示计算小数的方式。

BigDecimal d1 = new BigDecimal("1.0");
BigDecimal d2 = new BigDecimal("3");
System.out.println(d1.divide(d2, 2, RoundingMode.DOWN)); // 小数精度为2,多余小数直接舍去。输出结果为0.33

image-20220714163200311

结果 3.3和3.4,符合预期。

RoundingMode 枚举 能够方便地指定小数运算方式,除了直接舍去,还有四舍五入、向上取整等多种方式,根据具体业务需求指定即可。

注意,小数精度尽量在代码中控制,不要通过数据库来控制。数据库中默认采用四舍五入的方式保留小数精度。

比如数据库中设置的小数精度为2,我存入 0.335,那么最终存储的值就会变为 0.34。

比较大小

我们已经知道如何创建和运算 BigDecimal 对象,只剩下最后一个操作:比较。

因为其不是基本数据类型,用双等号 == 肯定是不行的,那我们来试试用 equals比较:

BigDecimal d1 = new BigDecimal("0.33");
BigDecimal d2 = new BigDecimal("0.3300");
System.out.println(d1.equals(d2)); // false

输出结果为 false,因为 BigDecimal 的 equals 方法不光会比较值,还会比较精度,就算值一样但精度不一样结果也是 false

我们先看看BigDecimal里的equals方法源码:

public boolean equals(Object x) {
    //类型不同,直接返回false
    if (!(x instanceof BigDecimal))
        return false;
    BigDecimal xDec = (BigDecimal) x;
    //同一个对象,直接返回true
    if (x == this)
        return true;
    //精度不同,直接返回false!!
    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方法是会比较精度的。

推荐使用compareTo

若想避开精度判断值是否一样,需要使用 int compareTo(BigDecimal val)方法:

BigDecimal d1 = new BigDecimal("0.33");
BigDecimal d2 = new BigDecimal("0.3300");
System.out.println(d1.compareTo(d2) == 0); // true

d1 大于 d2,返回 1; d1 小于 d2,返回 -1; 两值相等,返回 0。

4. 溢出问题

方案1️⃣——使用Math类的xxExact进行数值运算

这些方法会在数值溢出时主动抛异常。

image-20220714163214482

执行后,会得到ArithmeticException,这是一个RuntimeException:

java.lang.ArithmeticException: long overflow

方案2️⃣——使用大数类BigInteger

BigDecimal专于处理浮点数的专家,而BigInteger则专于大数的科学计算

使用BigInteger对Long最大值进行+1操作。若想把计算结果转为Long变量,可使用BigInteger#longValueExact,在转换出现溢出时,同样会抛出ArithmeticException

    @Test
    public void jingduTest(){
        // Long型最大值
        System.out.println("Long_length = " + Long.MAX_VALUE);
        // 1. 使用biginteger
        BigInteger bigInteger = new BigInteger(String.valueOf(Long.MAX_VALUE));
        // 2. 对该biginteger加10,不会报计算异常
        BigInteger bigint = bigInteger.add(BigInteger.TEN);
        System.out.println("bigint = " + bigint);
       try {
           // 对该biginteger类+1后进行向下转型到long,会有溢出
           System.out.println("溢出 = " + bigint.add(BigInteger.ONE).longValue());
           // 对该biginteger类+1后进行向下转型到long,会有异常出现
           long l = bigint.add(BigInteger.ONE).longValueExact();
       } catch (Exception e){
           e.printStackTrace();
       }
    }

结果

image-20220714162844932

资料来源:

怎么处理电商业务中的数值计算的精度/舍入/溢出问题?

商业计算怎样才能保证精度并防止溢出