丸辣!BigDecimal又踩坑了

2024-08-20 08:59:44 浏览数 (2)

丸辣!BigDecimal又踩坑了

前言

小菜之前在国内的一家电商公司自研电商项目,在那个项目中是以人民币的分为最小单位使用Long来进行计算

现在小菜在跨境电商公司也接到了类似的计算需求,在小菜火速完成提交代码后,却被技术leader给叫过去臭骂了一顿

技术leader让小菜将类型改为BigDecimal,小菜苦思不得其解,于是下班后发奋图强,准备搞懂BigDecimal后再对代码进行修改

...

在 Java 中,浮点类型在进行运算时可能会产生精度丢失的问题

尤其是当它们表示非常大或非常小的数,或者需要进行高精度的金融计算时

为了解决这个问题,Java 提供了 BigDecimal

BigDecimal 使用各种字段来满足高精度计算,为了后续的描述,这里只需要记住两个字段

precision字段:存储数据十进制的位数,包括小数部分

scale字段:存储小数的位数

BigDecimal的使用方式再后续踩坑中进行描述,最终总结出BigDecimal的最佳实践

BigDecimal的坑

创建实例的坑

错误示例:

在BigDecimal有参构造使用浮点型,会导致精度丢失

代码语言:java复制
BigDecimal d1 = new BigDecimal(6.66);

正确的使用方法应该是在有参构造中使用字符串,如果一定要有浮点数则可以使用BigDecimal.valueOf

代码语言:java复制
private static void createInstance() {
    //错误用法
    BigDecimal d1 = new BigDecimal(6.66);
    
    //正确用法
    BigDecimal d2 = new BigDecimal("6.66");
    BigDecimal d3 = BigDecimal.valueOf(6.66);

    //6.660000000000000142108547152020037174224853515625
    System.out.println(d1);
    //6.66
    System.out.println(d2);
    //6.66
    System.out.println(d3);
}
toString方法的坑

当数据量太大时,使用BigDecimal.valueOf的实例,使用toString方法时会采用科学计数法,导致结果异常

代码语言:java复制
BigDecimal d2 = BigDecimal.valueOf(123456789012345678901234567890.12345678901234567890);
//1.2345678901234568E 29
System.out.println(d2);

如果要打印正常结果就要使用toPlainString,或者使用字符串进行构造

代码语言:java复制
private static void toPlainString() {
    BigDecimal d1 = new BigDecimal("123456789012345678901234567890.12345678901234567890");
    BigDecimal d2 = BigDecimal.valueOf(123456789012345678901234567890.12345678901234567890);

    //123456789012345678901234567890.12345678901234567890
    System.out.println(d1);
    //123456789012345678901234567890.12345678901234567890
    System.out.println(d1.toPlainString());

    //1.2345678901234568E 29
    System.out.println(d2);
    //123456789012345678901234567890.12345678901234567890
    System.out.println(d2.toPlainString());
}
比较大小的坑

比较大小常用的方法有equalscompareTo

equals用于判断两个对象是否相等

compareTo比较两个对象大小,结果为0相等、1大于、-1小于

BigDecimal使用equals时,如果两数小数位数scale不相同,那么就会认为它们不相同,而compareTo则不会比较小数精度

代码语言:java复制
private static void compare() {
    BigDecimal d1 = BigDecimal.valueOf(1);
    BigDecimal d2 = BigDecimal.valueOf(1.00);

    // false
    System.out.println(d1.equals(d2));
    // 0
    System.out.println(d1.compareTo(d2));
}

在BigDecimal的equals方法中能看到,小数位数scale不相等则返回false

代码语言:java复制
public boolean equals(Object x) {
    if (!(x instanceof BigDecimal))
        return false;
    BigDecimal xDec = (BigDecimal) x;
    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());
}

因此,BigDecimal比较时常用compareTo,如果要比较小数精度才使用equals

运算的坑

常见的运算包括加、减、乘、除,如果不了解原理的情况就使用会存在大量的坑

在运算得到结果后,小数位数可能与原始数据发生改变,加、减运算在这种情况下类似

当原始数据为1.00(2位小数位数)和5.555(3位小数位数)相加/减时,结果的小数位数变成3位

代码语言:java复制
	private static void calc() {
        BigDecimal d1 = BigDecimal.valueOf(1.00);
        BigDecimal d2 = BigDecimal.valueOf(5.555);

        //1.0
        System.out.println(d1);
        //5.555
        System.out.println(d2);
        //6.555
        System.out.println(d1.add(d2));
        //-4.555
        System.out.println(d1.subtract(d2));
    }

在加、减运算的源码中,会选择两数中小数位数(scale)最大的当作结果的小数位数(scale)

代码语言:java复制
private static BigDecimal add(final long xs, int scale1, final long ys, int scale2) {
 	//用差值来判断使用哪个scale
    long sdiff = (long) scale1 - scale2;
    if (sdiff == 0) {
        //scale相等时
        return add(xs, ys, scale1);
    } else if (sdiff < 0) {
        int raise = checkScale(xs,-sdiff);
        long scaledX = longMultiplyPowerTen(xs, raise);
        if (scaledX != INFLATED) {
            //scale2大时用scale2
            return add(scaledX, ys, scale2);
        } else {
            BigInteger bigsum = bigMultiplyPowerTen(xs,raise).add(ys);
            //scale2大时用scale2
            return ((xs^ys)>=0) ? // same sign test
                new BigDecimal(bigsum, INFLATED, scale2, 0)
                : valueOf(bigsum, scale2, 0);
        }
    } else {
        
        int raise = checkScale(ys,sdiff);
        long scaledY = longMultiplyPowerTen(ys, raise);
        if (scaledY != INFLATED) {
            //scale1大用scale1
            return add(xs, scaledY, scale1);
        } else {
            BigInteger bigsum = bigMultiplyPowerTen(ys,raise).add(xs);
            //scale1大用scale1
            return ((xs^ys)>=0) ?
                new BigDecimal(bigsum, INFLATED, scale1, 0)
                : valueOf(bigsum, scale1, 0);
        }
    }
}

再来看看乘法

原始数据还是1.00(2位小数位数)和5.555(3位小数位数),当进行乘法时得到结果的小数位数为5.5550(4位小数)

代码语言:java复制
private static void calc() {
    BigDecimal d1 = BigDecimal.valueOf(1.00);
    BigDecimal d2 = BigDecimal.valueOf(5.555);

    //1.0
    System.out.println(d1);
    //5.555
    System.out.println(d2);
    //5.5550
    System.out.println(d1.multiply(d2));
}

实际上1.00会被优化成1.0(上面代码示例的结果也显示了),在进行乘法时会将scale进行相加,因此结果为1 3=4位

代码语言:java复制
public BigDecimal multiply(BigDecimal multiplicand) {
    //小数位数相加
    int productScale = checkScale((long) scale   multiplicand.scale);
    
    if (this.intCompact != INFLATED) {
        if ((multiplicand.intCompact != INFLATED)) {
            return multiply(this.intCompact, multiplicand.intCompact, productScale);
        } else {
            return multiply(this.intCompact, multiplicand.intVal, productScale);
        }
    } else {
        if ((multiplicand.intCompact != INFLATED)) {
            return multiply(multiplicand.intCompact, this.intVal, productScale);
        } else {
            return multiply(this.intVal, multiplicand.intVal, productScale);
        }
    }
}

而除法没有像前面所说的运算方法有规律性,因此使用除法时必须要指定保留小数位数以及舍入方式

进行除法时可以立马指定保留的小数位数和舍入方式(如代码d5)也可以除完再设置保留小数位数和舍入方式(如代码d3、d4)

代码语言:java复制
private static void calc() {
    BigDecimal d1 = BigDecimal.valueOf(1.00);
    BigDecimal d2 = BigDecimal.valueOf(5.555);

    BigDecimal d3 = d2.divide(d1);
    BigDecimal d4 = d3.setScale(2, RoundingMode.HALF_UP);
    BigDecimal d5 = d2.divide(d1, 2, RoundingMode.HALF_UP);
    //5.555
    System.out.println(d3);
    //5.56
    System.out.println(d4);
    //5.56
    System.out.println(d5);
}

RoundingMode枚举类提供各种各样的舍入方式,RoundingMode.HALF_UP是常用的四舍五入

除了除法必须指定小数位数和舍入方式外,建议其他运算也主动设置进行兜底,以防意外的情况出现

计算价格的坑

在电商系统中,在订单中会有购买商品的价格明细

比如用完优惠卷后总价为10.00,而买了三件商品,要计算每件商品花费的价格

这种情况下10除3是除不尽的,那我们该如何解决呢?

可以将除不尽的余数加到最后一件商品作为兜底

代码语言:java复制
private static void priceCalc() {
    //总价
    BigDecimal total = BigDecimal.valueOf(10.00);
    //商品数量
    int num = 3;
    BigDecimal count = BigDecimal.valueOf(num);
    //每件商品价格
    BigDecimal price = total.divide(count, 2, RoundingMode.HALF_UP);
    //3.33
    System.out.println(price);

    //剩余的价格 加到最后一件商品 兜底
    BigDecimal residue = total.subtract(price.multiply(count));
    //最后一件价格
    BigDecimal lastPrice = price.add(residue);
    //3.34
    System.out.println(lastPrice);
}

总结

普通的计算可以以最小金额作为计算单位并且用Long进行计算,而面对汇率、计算量大的场景可以采用BigDecimal作为计算单位

创建BigDecimal有两种常用的方式,字符串作为构造的参数以及浮点型作为静态方法valueOf的参数,后者在数据大/小的情况下toString方法会采用科学计数法,因此最好使用字符串作为构造器参数的方式

BigDecimal比较大小时,如果需要小数位数精度都相同就采用equals方法,忽略小数位数比较可以使用compareTo方法

BigDecimal进行运算时,加减运算会采用原始两个数据中精度最长的作为结果的精度,乘法运算则是将两个数据的精度相加得到结果的精度,而除法没有规律,必须指定小数位数和舍入模式,其他运算方式也建议主动设置小数位数和舍入模式进行兜底

当遇到商品平摊价格除不尽的情况时,可以将余数加到最后一件商品的价格进行兜底

最后(不要白嫖,一键三连求求拉~)

本篇文章被收入专栏 Java,感兴趣的同学可以持续关注喔

本篇文章笔记以及案例被收入 Gitee-CaiCaiJava、 Github-CaiCaiJava,除此之外还有更多Java进阶相关知识,感兴趣的同学可以starred持续关注喔~

有什么问题可以在评论区交流,如果觉得菜菜写的不错,可以点赞、关注、收藏支持一下~

关注菜菜,分享更多技术干货,公众号:菜菜的后端私房菜

0 人点赞