支付金额使用 BigDecimal 会丢失精度问题

前言

当我们在计算金额或者显示金额时,基本已经形成了常识,都会使用 BigDecimal 而不是其他的,这个也是涉及到金额时非常推荐的一个类型。而我们也都知道浮点型变量在进行计算的时候会出现丢失精度的问题。

在我们使用 BigDecimal 的同时,是否知道也会丢失精度呢?接下来我们就一探究竟,从而在计算金额时更好使用 BigDecimal。

原因分析

下面我们看一段代码,如下所示:

public static void main(String[] args) throws ParseException, Exception {
		// TODO Auto-generated method stub
		System.out.println(0.05 + 0.01);  
		System.out.println(1.0 - 0.42);    
	}
0.060000000000000005
0.5800000000000001

通过测试发现,当使用 double 或者 float 这些浮点数据类型时,会丢失精度。如果我们在进行商品价格计算的时候,就会出现问题,如果线上这么用是一个很严重的问题。所以接下来我们就使用 Java 中的 BigDecimal 类来解决这类问题,如下所示:

public static void main(String[] args) throws ParseException, Exception {
		// TODO Auto-generated method stub
		BigDecimal bigDecimal=new BigDecimal(6);  
	    System.out.println(bigDecimal);  
	    bigDecimal=new BigDecimal("6.6");  
	    System.out.println(bigDecimal);  
	    bigDecimal=new BigDecimal(6.6);  
	    System.out.println(bigDecimal);  
	}

执行结果如下:

6
6.6
6.5999999999999996447286321199499070644378662109375

从上面的例子中,我们可以知道 BigDecimal 丢失精度更加的明显,但是使用 Bigdecimal 的 BigDecimal(String) 构造器的变量在进行运算的时候却没有出现这种问题,为什么会这样呢。

那么 double 类型为什么会出现精度丢失问题?

其实这个问题很简单,我们知道计算机在发展的时间里,它始终只能识别 0 和 1(即二进制)。无论我们使用哪种编程语言,在哪种编译环境下工作,计算机会先将其转换为二进制数据,才能被计算机所识别,然后再进行相关的运算。

这里我们举个例子,比如 0.1 是十进制的,但计算机不能直接识别,要先编译成二进制。那么这里会有个问题,0.1 的二进制并非是精确的 0.1,反而是最为接近的二进制表示: 0.10000000000000000。

在十进制转二进制的过程中,有些十进制数是无法使用一个有限的二进制数来表达的,换言之就是转换的时候出现了精度的丢失问题,所以导致最后在运算的过程中,自然就出现了我们看到的一幕。

而且如果在转换过程中,浮点数参与了计算,那么在转换的过程中就会变得不可预知,并且变得不可逆,在计算的过程中,发生了精度的丢失。

为什么这么说呢,源码中注释也给我们说明情况,我们进一步去看看源码:
在这里插入图片描述
从源码注释中我们可以知道,BigDecimal 构造方法中传入 double 类型的值 0.1 不能保证一定返回 0.1。

解释也说明了一点:即无法用有限长度的二进制数表示十进制的小数,这就是精度丢失问题产生的原因。

那么,为什么使用二进制无法精确表达一个 double 类型的数据呢? 下面我们简单画图来剖析换算的方法,十进制数转二进制为什么会出现精度丢失的现象。

十进制小数转化为二进制数

这里我们举个经典的案例:

0.1(十进制) = 0.0001100110011001(二进制)

0.1*2=0.2……0——整数部分为“0”。整数部分“0”清零后为“0”,用“0.2”接着计算。

0.2*2=0.4……0——整数部分为“0”。整数部分“0”清零后为“0”,用“0.4”接着计算。

0.4*2=0.8……0——整数部分为“0”。整数部分“0”清零后为“0”,用“0.8”接着计算。

0.8*2=1.6……1——整数部分为“1”。整数部分“1”清零后为“0”,用“0.6”接着计算。

0.6*2=1.2……1——整数部分为“1”。整数部分“1”清零后为“0”,用“0.2”接着计算。

0.2*2=0.4……0——整数部分为“0”。整数部分“0”清零后为“0”,用“0.4”接着计算。

0.4*2=0.8……0——整数部分为“0”。整数部分“0”清零后为“0”,用“0.8”接着计算。

0.8*2=1.6……1——整数部分为“1”。整数部分“1”清零后为“0”,用“0.6”接着计算。

0.6*2=1.2……1——整数部分为“1”。整数部分“1”清零后为“0”,用“0.2”接着计算。

0.2*2=0.4……0——整数部分为“0”。整数部分“0”清零后为“0”,用“0.4”接着计算。

0.4*2=0.8……0——整数部分为“0”。整数部分“0”清零后为“0”,用“0.2”接着计算。

0.8*2=1.6……1——整数部分为“1”。整数部分“1”清零后为“0”,用“0.2”接着计算。

。最终处于一个无限循环状态,
。得到的整数依次是:0.000110011001…
所以可以得出用有限长度的二进制数表示十进制的小数,精度会丢失。

而 double 类型主要是为科学计算和工程计算而设计的,它们执行二进制浮点运算,这些运算经过精心设计,能够在广泛的数值范围内提供更精确的快速近似和计算而精心设计的。但是,它们不能提供完全准确的结果,因此不能用于需要计算精确结果的场景中。当浮点数达到一定的大数时自动使用科学计数法。这样的表示只是近似真实数而不等于真实数。当十进制小数转换为二进制时,也会出现无限循环或超出浮点数尾部的长度

如何解决 double 类型精度丢失问题?

这里我们直接找到 BigDecimal 构造方法传 double 类型的源码就可以看到,注释给我们讲得很清楚了,如下图:
在这里插入图片描述
这里记录一下构造方法传参的三种实现方法:

1、new BigDecimal(double val)
这个方法是不可预测的,以 0.1 为例,如 double 类型的值为 0.1,最后不会返回一个值为 0.1 的 BigDecimal,为什么?原因在于 0.1 无法用有限长度的二进制数表示,无法精确地表示为双精度数,最后的结果会是 0.000110011001…。

2、new BigDecimal(String val)
这个 方法是完全可预测的,也就是说你传入一个字符串 “0.1”,他就会给你返回一个值完全为 0.1 的BigDecimal,官方也表示,能用这个构造函数就用这个构造函数。

3、BigDecimal.valueOf(double val)
这个方法是为了必须传 double 类型的值,但是又想转换成 String 类型的,可以使用Double.toString(double val) 先将 double 值转为 String,再调用第二种构造方式,你可以直接使用静态方法:valueOf(double val)。

小结:使用 BigDecimal(double val) 构造函数时仍会存在精度丢失问题,建议使用 BigDecimal(String val) 或者 BigDecimal.valueOf(double val),将 double 转为 BigDecimal 的时候,需要先把 double 转换为字符串,然后再作为 BigDecimal(String val) 构造函数的参数,这样才能避免出现精度问题。

代码案例

处理 double 类型数据的加、减、乘、除运算,如下所示:

public class DoubleUtil{
    private static final long serialVersionUID = -3345205828566485102L;
    // 默认除法运算精度
    private static final Integer DEF_DIV_SCALE = 2;

    /**
     * 提供精确的加法运算。
     *
     * @param value1 被加数
     * @param value2 加数
     * @return 两个参数的和
     */
    public static Double add(Double value1, Double value2) {
        BigDecimal b1 = new BigDecimal(Double.toString(value1));
        BigDecimal b2 = new BigDecimal(Double.toString(value2));
        return b1.add(b2).doubleValue();
    }

    /**
     * 提供精确的减法运算。
     *
     * @param value1 被减数
     * @param value2 减数
     * @return 两个参数的差
     */
    public static double sub(Double value1, Double value2) {
        BigDecimal b1 = new BigDecimal(Double.toString(value1));
        BigDecimal b2 = new BigDecimal(Double.toString(value2));
        return b1.subtract(b2).doubleValue();
    }

    /**
     * 提供精确的乘法运算。
     *
     * @param value1 被乘数
     * @param value2 乘数
     * @return 两个参数的积
     */
    public static Double mul(Double value1, Double value2) {
        BigDecimal b1 = new BigDecimal(Double.toString(value1));
        BigDecimal b2 = new BigDecimal(Double.toString(value2));
        return b1.multiply(b2).doubleValue();
    }

    /**
     * 提供(相对)精确的除法运算,当发生除不尽的情况时, 精确到小数点以后10位,以后的数字四舍五入。
     *
     * @param dividend 被除数
     * @param divisor  除数
     * @return 两个参数的商
     */
    public static Double divide(Double dividend, Double divisor) {
        return divide(dividend, divisor, DEF_DIV_SCALE);
    }

    /**
     * 提供(相对)精确的除法运算。 当发生除不尽的情况时,由scale参数指定精度,以后的数字四舍五入。
     *
     * @param dividend 被除数
     * @param divisor  除数
     * @param scale    表示表示需要精确到小数点以后几位。
     * @return 两个参数的商
     */
    public static Double divide(Double dividend, Double divisor, Integer scale) {
        if (scale < 0) {
            throw new IllegalArgumentException("The scale must be a positive integer or zero");
        }
        BigDecimal b1 = new BigDecimal(Double.toString(dividend));
        BigDecimal b2 = new BigDecimal(Double.toString(divisor));
        return b1.divide(b2, scale,RoundingMode.HALF_UP).doubleValue();
    }

    /**
     * 提供指定数值的(精确)小数位四舍五入处理。
     *
     * @param value 需要四舍五入的数字
     * @param scale 小数点后保留几位
     * @return 四舍五入后的结果
     */
    public static double round(double value,int scale){
        if(scale<0){
            throw new IllegalArgumentException("The scale must be a positive integer or zero");
        }
        BigDecimal b = new BigDecimal(Double.toString(value));
        BigDecimal one = new BigDecimal("1");
        return b.divide(one,scale, RoundingMode.HALF_UP).doubleValue();
    }
}

总结

因为计算机采用二进制处理数据,但是很多小数,如上面我们说到的 0.1 传换成二进制是一个无限循环小数,而这种数字在计算机中是无法精确表示的。所以,人们采用了一种通过近似值的方式在计算机中表示,于是就有了单精度浮点数和双精度浮点数等。作为单精度浮点数的 float 和双精度浮点数的 double,在表示小数的时候只是近似值,并不是真实值。

所以,当使用 new BigDecimal(double val) 创建的时候,得到的 BigDecimal 是损失了精度的。而使用一个损失了精度的数字进行计算,得到的结果也是不精确的。

如果想要避免这个问题,可以通过 BigDecimal(String val) 或者 BigDecimal.valueOf(double val) 的方式创建 BigDecimal,这样 0.1 就会被精确的计算出来。

  • 2
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值