浮点数使用陷阱
浮点数使用陷阱
问题
在 P45 0044_韩顺平Java_浮点数细节2
中,提到了浮点数使用陷阱:2.7
和 8.1 / 3
比较
1 | double n8 = 2.7; |
为什么 n8
的输出是 2.7
,而 n9
的输出是 2.6999999999999997
?
要把这个问题彻底解释清楚不是一件简单的事情,其中涉及到:
- 十进制到二进制的转换
- 浮点数 IEEE 754 标准,以及该标准下的除法运算
- Java 的浮点数输出的设计机制
以上 1.2. 涉及到计算机组成原理方面的知识,3. 则是涉及到浮点数转字符串的算法
解释
当然,这里也没必要太深入其中的细节,一个简单的解释是:
因为二进制表示能力有限,绝大多数的十进制小数,转换为二进制后就变成了无限循环小数。
而计算机的存储空间是有限的,给每个浮点数分配的存储空间自然也是有限的,所以计算机只能保存有限的二进制位数,这其实本质上就是一种舍入,最终实际在计算机中存储的十进制小数只是一个近似值。
所以上面的 double n8 = 2.7
中的 2.7
,实际保存到 n8
中的是一个接近 2.7
的小数
同理,double n9 = 8.1 / 3
中的 8.1
同样在参与运算时,是一个接近 8.1
的小数,自然 8.1 / 3
最后的结果不会是一个精确的 2.7
。
总之,在输出前,n8
和 n9
中都保存了一个接近 2.7
的近似值,但又各不相同。
最后,Java System.out.println(double)
其中的浮点数输出的设计机制,决定了:
- 对于
n8
实际存储的近似于2.7
的浮点数,输出2.7
- 对于
n9
实际存储的另一个近似于2.7
的浮点数,输出2.6999999999999997
下面借助一些转换工具,举一个更具象的例子,同样是浮点数使用陷阱:0.3
和 0.1 + 0.2
比较
使用工具:十进制小数转 IEEE 754 标准浮点数工具,我们可以直接得到 0.1
、0.2
和 0.3
的 IEEE 754 存储和对应的近似值如下:
1 | 0.1 -> 0 01111111011 1001100110011001100110011001100110011001100110011010 |
然后我们使用 在线IEEE浮点二进制计算器工具 来计算一下 0.1 + 0.2
的结果对应的 IEEE 754 存储,然后再次使用 十进制小数转 IEEE 754 标准浮点数工具 得到对应的真实值,结果如下:
1 | 0.1 + 0.2 -> 0 01111111101 0011001100110011001100110011001100110011001100110100 |
可以看到,直接赋值 0.3
与赋值 0.1 + 0.2
的计算结果,最终在计算机中存储的是两个不同的但又都近似于 0.3
的近似值。尝试输出一下:
1 | double d1 = 0.3; |
可以看到,Java 的浮点数输出机制对这两个都近似于 0.3
但又各自不同的近似值的输出设计是不一样的。
下面简单谈一谈,Java 浮点数输出的设计机制。
你可能已经注意到了,无论是输出 2.6999999999999997
还是输出 0.30000000000000004
,好像它们从左往右数,恰好都是有 17 位有效数字。
其实这并不是巧合,而是精心设计的结果:Java 浮点数输出最多会保留 17 位有效数字!
为什么最多是保留 17 位呢,因为 IEEE 754 浮点数标准下,64 位的浮点数在二进制上的最微小变化,反映到十进制上,必然能在前 17 位有效数字上看到变化。
比如,下面是 IEEE 754 标准下,两个相邻的 64 位浮点数的实例:
1 | 0 01111111101 0011001100110011001100110011001100110011001100110100 |
可以看到,这两个浮点数的内部存储,只有最后一位不同(这一位的变化对值的影响最小)!
而这两个浮点数内部存储对应的真实值是:
1 | 0.3000000000000000444089209850062616169452667236328125 |
前 16 位看不出来变化,但到第 17 位就不一样了!也即,对于任何浮点数的内部存储对应的完整真实值,只看前 17 位,必然是存在差别的。
这个特性对于简化浮点数的输出非常有用,我们可以舍去大量低位的信息不去输出!毕竟对于小数,我们一般不太关注后面太低位的信息。
至于为什么要强调差别呢?我觉得可能是,希望对于不同的浮点数的内部二进制存储,最后输出的内容也是不同的。也即希望 内部 64 位二进制存储
与 输出内容
存在一个一一映射的关系。
按 IEEE 754 标准的换算逻辑,内部 64 位二进制存储
和 完整真实值
是一一映射的关系,现在我们可以再简化一下,内部 64 位二进制存储
和 完整真实值前 17 位
是一一映射的关系。只要保留 17 位,就能保证找到对应的内部二进制存储。
以上只是 Java 浮点数输出设计的冰山一角。
其实不局限于 Java,在任何语言中,都要考虑输出浮点数这件事情。
输出浮点数,本质就是将浮点数转换为恰当的字符串,这个问题看似简单,实际上是很复杂的事情。针对这个问题,设计了非常多的算法,如:Dragon4
,Grisu
,Grisu2
,Grisu3
,Ryu
等。
不论哪种算法,为了保证浮点数转换得到的字符串尽可能合理,有三个基本要求:
保值:一个正确的解析器可以将输出解析为原数。
实际上这个可以理解为是,要保证内部二进制存储与输出内容的一一映射关系。最短输出:输出的字符串必须尽可能短。
不可能对任意内部二进制存储,都输出前 17 位!17 位是最坏的情况,我们要做的是让它尽可能短。正确舍入:输出的字符串必须尽可能接近原数。
这一点必不可少,因为在输出内容与内部二进制存储毫无关系的情况下,是完全可以做到前两个要求的:保值与最短输出