Java行业常见业务开发数值计算丢失精度问题总结

原创
2021/04/11 11:08
阅读数 187

一直以来我都会负责公司有关订单模块的项目开发,时常会面对各种金额的计算,在开发的过程中需要注意防止计算精度丢失的问题,今天我说说数值计算的精度、舍入和溢出问题,出于总结,也希望可以为一些读者“闭坑”。

“危险”的 Double

我们先从简单的反直觉的四则运算看起。对几个简单的浮点数进行加减乘除运算:

System.out.println(0.1+0.2);
System.out.println(1.0-0.8);
System.out.println(4.015*100);
System.out.println(123.3/100);
double amount1 = 2.15;
double amount2 = 1.10;
if (amount1 - amount2 == 1.05)
System.out.println("OK");

结果输出如下:

0.30000000000000004
0.19999999999999996
401.49999999999994
1.2329999999999999

可以看到,输出结果和我们预期的很不一样。比如,0.1+0.2 输出的不是 0.3 而是0.30000000000000004;再比如,对 2.15-1.10 和 1.05 判等,结果判等不成立,出现这种问题的主要原因是,计算机是以二进制存储数值的,浮点数也不例外,对于计算机而言,0.1 无法精确表达,这是浮点数计算造成精度损失的根源。

很多人可能会说,以 0.1 为例,其十进制和二进制间转换后相差非常小,不会对计算产生什么影响。但,所谓积土成山,如果大量使用 double 来作大量的金钱计算,最终损失的精度就是大量的资金出入。比如,每天有一百万次交易,每次交易都差一分钱,一个月下来就差30 万。这就不是小事儿了。那,如何解决这个问题呢?

BigDecimal 类型

我们大都听说过 BigDecimal 类型,浮点数精确表达和运算的场景,一定要使用这个类型。不过,在使用 BigDecimal 时有几个坑需要避开。我们用 BigDecimal 把之前的四则运算改一下:

System.out.println(new BigDecimal(0.1).add(new BigDecimal(0.2)));
System.out.println(new BigDecimal(1.0).subtract(new BigDecimal(0.8)));
System.out.println(new BigDecimal(4.015).multiply(new BigDecimal(100)));
System.out.println(new BigDecimal(123.3).divide(new BigDecimal(100)));

输出如下:

0.3000000000000000166533453693773481063544750213623046875
0.1999999999999999555910790149937383830547332763671875
401.49999999999996802557689079549163579940795898437500
1.232999999999999971578290569595992565155029296875

可以看到,运算结果还是不精确,只不过是精度高了而已。这里给出浮点数运算避坑第一原则:使用 BigDecimal 表示和计算浮点数,且务必使用字符串的构造方法来初始化BigDecimal:

System.out.println(new BigDecimal("0.1").add(new BigDecimal("0.2")));
System.out.println(new BigDecimal("1.0").subtract(new BigDecimal("0.8")));
System.out.println(new BigDecimal("4.015").multiply(new BigDecimal("100")));
System.out.println(new BigDecimal("123.3").divide(new BigDecimal("100")));

改进后,就得到我们想要的输出结果了:

0.3
0.2
401.500
1.233

到这里,你可能会继续问,不能调用 BigDecimal 传入 Double 的构造方法,但手头只有一个 Double,如何转换为精确表达的 BigDecimal 呢?

我尝试使用 Double.toString 把 double 转换为字符串:

System.out.println(new BigDecimal("4.015").multiply(new BigDecimal(Double.toString(100))));

输出结果为:401.5000,与上面字符串初始化 100 和 4.015 相乘得到的结果 401.500 相比,这里为什么多了 1 个 0 呢?原因就是,BigDecimal 有 scale 和 precision 的概念,scale 表示小数点右边的位数,而 precision 表示精度,也就是有效数字的长度。试一下可以发现,new BigDecimal(Double.toString(100)) 得到的 BigDecimal 的scale=1、precision=4;而 new BigDecimal(“100”) 得到的 BigDecimal 的 scale=0、precision=3。对于 BigDecimal 乘法操作,返回值的 scale 是两个数的 scale 相加。所以,初始化 100 的两种不同方式,导致最后结果的 scale 分别是 4 和 3。

如果一定要用 Double 来初始化 BigDecimal 的话,可以使用 BigDecimal.valueOf 方法,以确保其表现和字符串形式的构造方法一致!

System.out.println(new BigDecimal("4.015").multiply(BigDecimal.valueOf(100)));

结果输出:401.500

数值判断

现在我们知道了,应该使用 BigDecimal 来进行浮点数的表示、计算、格式化。Java中的原则:包装类的比较要通过 equals 进行,而不能使用 ==。那么,使用 equals 方法对两个 BigDecimal 判等,一定能得到我们想要的结果吗?比如:

System.out.println(new BigDecimal("1.0").equals(new BigDecimal("1")));

答案是:false,为什么呢?BigDecimal 的 equals 方法的注释中说明了原因,equals 比较的是 BigDecimal 的 value 和 scale,1.0 的 scale 是 1,1 的scale 是 0,所以结果一定是 false。

如果我们希望只比较 BigDecimal 的 value,可以使用 compareTo 方法,修改代码如下:

System.out.println(new BigDecimal("1.0").compareTo(new BigDecimal("1"))==0);

输出结果是:true

总结:

第一,切记,要精确表示浮点数应该使用 BigDecimal。并且,使用 BigDecimal 的Double 入参的构造方法同样存在精度丢失问题,应该使用 String 入参的构造方法或者BigDecimal.valueOf 方法来初始化。

第二,对浮点数做精确计算,参与计算的各种数值应该始终使用 BigDecimal,所有的计算都要通过 BigDecimal 的方法进行,切勿只是让 BigDecimal 来走过场。任何一个环节出现精度损失,最后的计算结果可能都会出现误差。

第三,对于浮点数的格式化,如果使用 String.format 的话,需要认识到它使用的是四舍五入,可以考虑使用 DecimalFormat 来明确指定舍入方式。但考虑到精度问题,我更建议使用 BigDecimal 来表示浮点数,并使用其 setScale 方法指定舍入的位数和方式。

总之,对于金融、科学计算等场景,请尽可能使用 BigDecimal 和 BigInteger,避免由精度和溢出问题引发难以发现,但影响重大的 Bug!

展开阅读全文
打赏
0
1 收藏
分享
加载中
更多评论
打赏
0 评论
1 收藏
0
分享
返回顶部
顶部