你好,我是朱晔。今天,我要和你说说数值计算的精度、舍入和溢出问题。
之所以要单独分享数值计算,是因为很多时候我们习惯的或者说认为理所当然的计算,在计算器或计算机看来并不是那么回事儿。就比如前段时间爆出的一条新闻,说是手机计算器把10%+10%算成了0.11而不是0.2。
出现这种问题的原因在于,国外的计算程序使用的是单步计算法。在单步计算法中,a+b%代表的是a*(1+b%)。所以,手机计算器计算10%+10%时,其实计算的是10%*(1+10%),所以得到的是0.11而不是0.2。
在我看来,计算器或计算机会得到反直觉的计算结果的原因,可以归结为:
- 在人看来,浮点数只是具有小数点的数字,0.1和1都是一样精确的数字。但,计算机其实无法精确保存浮点数,因此浮点数的计算结果也不可能精确。
- 在人看来,一个超大的数字只是位数多一点而已,多写几个1并不会让大脑死机。但,计算机是把数值保存在了变量中,不同类型的数值变量能保存的数值范围不同,当数值超过类型能表达的数值上限则会发生溢出问题。
接下来,我们就具体看看这些问题吧。
“危险”的Double
我们先从简单的反直觉的四则运算看起。对几个简单的浮点数进行加减乘除运算:
1 | System.out.println(0.1+0.2); |
输出结果如下:
1 | 0.30000000000000004 |
可以看到,输出结果和我们预期的很不一样。比如,0.1+0.2输出的不是0.3而是0.30000000000000004;再比如,对2.15-1.10和1.05判等,结果判等不成立。
出现这种问题的主要原因是,计算机是以二进制存储数值的,浮点数也不例外。Java采用了IEEE 754标准实现浮点数的表达和运算,你可以通过这里查看数值转化为二进制的结果。
比如,0.1的二进制表示为0.0 0011 0011 0011… (0011 无限循环),再转换为十进制就是0.1000000000000000055511151231257827021181583404541015625。对于计算机而言,0.1无法精确表达,这是浮点数计算造成精度损失的根源。
你可能会说,以0.1为例,其十进制和二进制间转换后相差非常小,不会对计算产生什么影响。但,所谓积土成山,如果大量使用double来作大量的金钱计算,最终损失的精度就是大量的资金出入。比如,每天有一百万次交易,每次交易都差一分钱,一个月下来就差30万。这就不是小事儿了。那,如何解决这个问题呢?
我们大都听说过BigDecimal类型,浮点数精确表达和运算的场景,一定要使用这个类型。不过,在使用BigDecimal时有几个坑需要避开。我们用BigDecimal把之前的四则运算改一下:
1 | System.out.println(new BigDecimal(0.1).add(new BigDecimal(0.2))); |
输出如下:
1 | 0.3000000000000000166533453693773481063544750213623046875 |
可以看到,运算结果还是不精确,只不过是精度高了而已。这里给出浮点数运算避坑第一原则:使用BigDecimal表示和计算浮点数,且务必使用字符串的构造方法来初始化BigDecimal:
1 | System.out.println(new BigDecimal("0.1").add(new BigDecimal("0.2"))); |
改进后,就能得到我们想要的输出了:
1 | 0.3 |
到这里,你可能会继续问,不能调用BigDecimal传入Double的构造方法,但手头只有一个Double,如何转换为精确表达的BigDecimal呢?
我们试试用Double.toString把double转换为字符串,看看行不行?
1 | 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:
1 | private static void testScale() { |
BigDecimal的toString方法得到的字符串和scale相关,又会引出了另一个问题:对于浮点数的字符串形式输出和格式化,我们应该考虑显式进行,通过格式化表达式或格式化工具来明确小数位数和舍入方式。接下来,我们就聊聊浮点数舍入和格式化。
考虑浮点数舍入和格式化的方式
除了使用Double保存浮点数可能带来精度问题外,更匪夷所思的是这种精度问题,加上String.format的格式化舍入方式,可能得到让人摸不着头脑的结果。
我们看一个例子吧。首先用double和float初始化两个3.35的浮点数,然后通过String.format使用%.1f来格式化这2个数字:
1 | double num1 = 3.35; |
得到的结果居然是3.4和3.3。
这就是由精度问题和舍入方式共同导致的,double和float的3.35其实相当于3.350xxx和3.349xxx:
1 | 3.350000000000000088817841970012523233890533447265625 |
String.format采用四舍五入的方式进行舍入,取1位小数,double的3.350四舍五入为3.4,而float的3.349四舍五入为3.3。
我们看一下Formatter类的相关源码,可以发现使用的舍入模式是HALF_UP(代码第11行):
1 | else if (c == Conversion.DECIMAL_FLOAT) { |
如果我们希望使用其他舍入方式来格式化字符串的话,可以设置DecimalFormat,如下代码所示:
1 | double num1 = 3.35; |
当我们把这2个浮点数向下舍入取2位小数时,输出分别是3.35和3.34,还是我们之前说的浮点数无法精确存储的问题。
因此,即使通过DecimalFormat来精确控制舍入方式,double和float的问题也可能产生意想不到的结果,所以浮点数避坑第二原则:浮点数的字符串格式化也要通过BigDecimal进行。
比如下面这段代码,使用BigDecimal来格式化数字3.35,分别使用向下舍入和四舍五入方式取1位小数进行格式化:
1 | BigDecimal num1 = new BigDecimal("3.35"); |
这次得到的结果是3.3和3.4,符合预期。
用equals做判等,就一定是对的吗?
现在我们知道了,应该使用BigDecimal来进行浮点数的表示、计算、格式化。在上一讲介绍[判等问题]时,我提到一个原则:包装类的比较要通过equals进行,而不能使用==。那么,使用equals方法对两个BigDecimal判等,一定能得到我们想要的结果吗?
我们来看下面的例子。使用equals方法比较1.0和1这两个BigDecimal:
1 | 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:
1 | /** |
如果我们希望只比较BigDecimal的value,可以使用compareTo方法,修改后代码如下:
1 | System.out.println(new BigDecimal("1.0").compareTo(new BigDecimal("1"))==0); |
学过上一讲,你可能会意识到BigDecimal的equals和hashCode方法会同时考虑value和scale,如果结合HashSet或HashMap使用的话就可能会出现麻烦。比如,我们把值为1.0的BigDecimal加入HashSet,然后判断其是否存在值为1的BigDecimal,得到的结果是false:
1 | Set<BigDecimal> hashSet1 = new HashSet<>(); |
解决这个问题的办法有两个:
- 第一个方法是,使用TreeSet替换HashSet。TreeSet不使用hashCode方法,也不使用equals比较元素,而是使用compareTo方法,所以不会有问题。
1 | Set<BigDecimal> treeSet = new TreeSet<>(); |
- 第二个方法是,把BigDecimal存入HashSet或HashMap前,先使用stripTrailingZeros方法去掉尾部的零,比较的时候也去掉尾部的0,确保value相同的BigDecimal,scale也是一致的:
1 | Set<BigDecimal> hashSet2 = new HashSet<>(); |
小心数值溢出问题
数值计算还有一个要小心的点是溢出,不管是int还是long,所有的基本数值类型都有超出表达范围的可能性。
比如,对Long的最大值进行+1操作:
1 | long l = Long.MAX_VALUE; |
输出结果是一个负数,因为Long的最大值+1变为了Long的最小值:
1 | -9223372036854775808 |
显然这是发生了溢出,而且是默默地溢出,并没有任何异常。这类问题非常容易被忽略,改进方式有下面2种。
方法一是,考虑使用Math类的addExact、subtractExact等xxExact方法进行数值运算,这些方法可以在数值溢出时主动抛出异常。我们来测试一下,使用Math.addExact对Long最大值做+1操作:
1 | try { |
执行后,可以得到ArithmeticException,这是一个RuntimeException:
1 | java.lang.ArithmeticException: long overflow |
方法二是,使用大数类BigInteger。BigDecimal是处理浮点数的专家,而BigInteger则是对大数进行科学计算的专家。
如下代码,使用BigInteger对Long最大值进行+1操作;如果希望把计算结果转换一个Long变量的话,可以使用BigInteger的longValueExact方法,在转换出现溢出时,同样会抛出ArithmeticException:
1 | BigInteger i = new BigInteger(String.valueOf(Long.MAX_VALUE)); |
输出结果如下:
1 | 9223372036854775808 |
可以看到,通过BigInteger对Long的最大值加1一点问题都没有,当尝试把结果转换为Long类型时,则会提示BigInteger out of long range。
重点回顾
今天,我与你分享了浮点数的表示、计算、舍入和格式化、溢出等涉及的一些坑。
第一,切记,要精确表示浮点数应该使用BigDecimal。并且,使用BigDecimal的Double入参的构造方法同样存在精度丢失问题,应该使用String入参的构造方法或者BigDecimal.valueOf方法来初始化。
第二,对浮点数做精确计算,参与计算的各种数值应该始终使用BigDecimal,所有的计算都要通过BigDecimal的方法进行,切勿只是让BigDecimal来走过场。任何一个环节出现精度损失,最后的计算结果可能都会出现误差。
第三,对于浮点数的格式化,如果使用String.format的话,需要认识到它使用的是四舍五入,可以考虑使用DecimalFormat来明确指定舍入方式。但考虑到精度问题,我更建议使用BigDecimal来表示浮点数,并使用其setScale方法指定舍入的位数和方式。
第四,进行数值运算时要小心溢出问题,虽然溢出后不会出现异常,但得到的计算结果是完全错误的。我们考虑使用Math.xxxExact方法来进行运算,在溢出时能抛出异常,更建议对于可能会出现溢出的大数运算使用BigInteger类。
总之,对于金融、科学计算等场景,请尽可能使用BigDecimal和BigInteger,避免由精度和溢出问题引发难以发现,但影响重大的Bug。
今天用到的代码,我都放在了GitHub上,你可以点击这个链接查看。
思考与讨论
- BigDecimal提供了8种舍入模式,你能通过一些例子说说它们的区别吗?
- 数据库(比如MySQL)中的浮点数和整型数字,你知道应该怎样定义吗?又如何实现浮点数的准确计算呢?
针对数值运算,你还遇到过什么坑吗?我是朱晔,欢迎在评论区与我留言分享你的想法,也欢迎你把这篇文章分享给你的朋友或同事,一起交流。