求求你,不要再让浮点数背锅了

缘起

今天刷技术公众号,看到了骇人听闻的标题: 踩坑了,BigDecimal使用不当,造成P0事故。点进去一看,影响到了核心链路上的收银台,P0不冤。
原文链接: https://juejin.cn/post/7087404273503305736

引出问题

作者写了示例代码,直接复现了问题。如下图所示。


问题复现

一句话,错误的使用了BigDecimal(double)的构造函数导致的。直接打开Double源码,源码作者也是用心良苦,直接警告了调用者,这玩意不准,大家要小心,还贴心地给了方法如何规避这个问题。如下图所示。


JDK中Double源码

写代码试了下,在阿里巴巴检查插件(Ali-Check)中直接提示该问题:
IDEA插件检查报错

心生疑问,为什么传入double就不准了呢?为什么会出现那么多位的不可描述的数字呢?复习下二进制小数的表达和存储就明白了。

浮点数详解

1、数学上二进制小数的表达

大家都知道,十进制数字的表达式:
其中,m为整数部分的位数,n为小数部分的位数;di是0~9的整数。
比如:

以此类推,二进制数字的表达式:
其中,m为整数部分的位数,n为小数部分的位数;di是0~1的整数。
比如:

知道了这个,那问大家一个问题。0.1用二进制中如何表达呢?套用上面的求和表达式,似乎要先变成(d=0,1)的求和形式才行。
教大家一个方法,就是不停的乘以二进制的2(类似于不停的用10乘以10进制下的小数),以得到小数点后一位上的值。

推倒过程分解:
第1位: --> 乘积小于1,故为0 --> 得到小数0.0
第2位: --> 乘积小于1,故为0 --> 得到小数0.00
第3位: --> 乘积小于1,故为0 --> 得到小数0.000
第4位: --> 乘积大于1,故为1 --> 得到小数0.0001 (注意,这里要减去1后重新来过)
第5位: --> 乘积大于1,故为1 --> 得到小数0.00011
第6位: --> 乘积小于1,故为0 --> 得到小数0.000110(大家发现没有,到这里其实跟第2位的计算一样,开始无限循环了)
故最终的二进制表达式为:0.00011[0011](后面的中括号中无限循环)

惊不惊喜,意不意外,0.1这么简单的数字,在二进制中居然无限循环。如果再复杂点的数字,也就更加不可预测了。
当然,也有满足条件的小数是能被二进制准确(有限位内)表示的。条件就是它能够被表示成的形式。比如:63/64,26/128。发现没?都是2的幂为底的分数。在实数的世界里,沧海一粟。

数学上可以有无限循环的概念去表达一个小数,但是计算机的存储长度是有限制的(float是32位,double是64位),也就无法准确表达这个0.1了,即使是能够准确表达的小数,如果位数太长,由于计算机位数的限制,也是会被截断的。

可以想象,在浮点数的计算过程中,势必会有截断(向上舍入、向下舍入、向偶舍入、向零舍入)的操作,那计算结果自然会存在精度的问题了。现在再回想下,计算机计算出上面的结果:也就不奇怪了。再把这个值直接传给BigDecimal(用double入参的方式),那BigDecimal也表示很无奈。

试试看 动动小手,看看63/64和26/128表示成二进制该如何写?

2、计算机中浮点数的存储

计算机中使用的是IEEE浮点标准,这个标准统一了不同机器上的浮点数存储标准。下面说说这个详细的存储方法。
IEEE浮点标准用的形式来表示一个数。

  • 符号s(sign)决定是正数(s=0)还是负数(s=1);
  • 有效数M(mantissa)是一个二进制小数,它的范围在[1,2)或者[0,1)之间;
  • 指数E(exponent)是2的幂(可能是正数,也可能是负数);

下图表示了float和double的存储格式:


浮点数的存储示意图

按照E的不同取值,分为三种情况。
1、格式化值
E既不是全是0,也不是全是1时,属于格式化的值。此时exponent的位数中不再有符号位,也就是无法天然地表达正负数。于是就引入了偏置(biased)的概念。也就是说E=e-biased(这里e表示exponent区域的二进制表达的实际数字),而真正的指数值E需要减去偏置(biased)。对于float,biased=127;对于double,biased=1023。

对于有效数字M,其取值范围是[1,2)。由于二进制的小数表达中,第一位一定是1,比如正常的二进制科学计数表达式:。为了节省这一位,这个1也就不会保存在M中。换言之,其存储的是小数点以后的二进制数,隐藏了1。

2、非格式化值
E全是0时,表示非格式化的值。此时,E=1-biased;同时M的范围变成了[0,1),也不会再隐藏1。

非格式化值的2个作用如下所述:

  • 表示0(有意思的是IEEE标准中,既有+0,也有-0,且二者不等)
    float的+0的格式:
    float的-0的格式:
    double一样,这里不赘述。
  • 表示非常接近0的数
    这个非常近怎么衡量呢?对于float来说,就是小于;对于double来说,就是小于;因为如果数字比这个还小,那么E由于存储位数的限制是无法表达的。

3、特殊值
E全是1时表示特殊值。

  • 正无穷: s=0,E全是1,M全是0;
  • 负无穷: s=1,E全是1,M全是0;
  • NaN(Not a Number):E全是1,M不为0;

举个栗子
float f = 0.1f; int ifv = Float.floatToIntBits(f); System.out.println(Integer.toBinaryString(ifv));
此时输出:
111101110011001100110011001101

补足32位,并按照1、8、23来划分开,分别计算s、E和M。
0 01111011 10011001100110011001101
s=0
E=123-127=-4
M= =

我们上面计算过0.1的二进制表达:
看吧,对上了,明显计算机做了截断。

回顾

今天我们学习了2个内容:
1、数学上小数的二进制表达方法。
2、计算机中二进制浮点数的保存标准。

结论

  • 计算机世界中二进制并不能精确表达所有浮点数;浮点数计算存在精度丢失。
  • 科学计算是可以用浮点数运算;金融计算用BigDecimal。

你可能感兴趣的:(求求你,不要再让浮点数背锅了)