浮点数使用陷阱

问题

P45 0044_韩顺平Java_浮点数细节2 中,提到了浮点数使用陷阱:2.78.1 / 3 比较

1
2
3
4
double n8 = 2.7;
double n9 = 8.1 / 3; // 数学上,除法结果就是 2.7
System.out.println(n8); // 输出 2.7
System.out.println(n9); // 输出一个接近 2.7 的小数:2.6999999999999997

为什么 n8 的输出是 2.7,而 n9 的输出是 2.6999999999999997

要把这个问题彻底解释清楚不是一件简单的事情,其中涉及到:

  1. 十进制到二进制的转换
  2. 浮点数 IEEE 754 标准,以及该标准下的除法运算
  3. 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

总之,在输出前,n8n9 中都保存了一个接近 2.7 的近似值,但又各不相同。

最后,Java System.out.println(double) 其中的浮点数输出的设计机制,决定了:

  • 对于 n8 实际存储的近似于 2.7 的浮点数,输出 2.7
  • 对于 n9 实际存储的另一个近似于 2.7 的浮点数,输出 2.6999999999999997

下面借助一些转换工具,举一个更具象的例子,同样是浮点数使用陷阱:0.30.1 + 0.2 比较

使用工具:十进制小数转 IEEE 754 标准浮点数工具,我们可以直接得到 0.10.20.3 的 IEEE 754 存储和对应的近似值如下:

1
2
3
4
5
6
7
8
0.1 -> 0 01111111011 1001100110011001100110011001100110011001100110011010
-> 0.1000000000000000055511151231257827021181583404541015625

0.2 -> 0 01111111100 1001100110011001100110011001100110011001100110011010
-> 0.200000000000000011102230246251565404236316680908203125

0.3 -> 0 01111111101 0011001100110011001100110011001100110011001100110011
-> 0.299999999999999988897769753748434595763683319091796875

然后我们使用 在线IEEE浮点二进制计算器工具 来计算一下 0.1 + 0.2 的结果对应的 IEEE 754 存储,然后再次使用 十进制小数转 IEEE 754 标准浮点数工具 得到对应的真实值,结果如下:

1
2
0.1 + 0.2 -> 0 01111111101 0011001100110011001100110011001100110011001100110100
-> 0.3000000000000000444089209850062616169452667236328125

可以看到,直接赋值 0.3 与赋值 0.1 + 0.2 的计算结果,最终在计算机中存储的是两个不同的但又都近似于 0.3 的近似值。尝试输出一下:

1
2
3
4
5
6
7
8
double d1 = 0.3;
double d2 = 0.1 + 0.2;
double d3 = 0.299999999999999988897769753748434595763683319091796875;
double d4 = 0.3000000000000000444089209850062616169452667236328125
System.out.println(d1); // 输出 0.3
System.out.println(d2); // 输出 0.30000000000000004
System.out.println(d3); // 输出 0.3
System.out.println(d4); // 输出 0.30000000000000004

可以看到,Java 的浮点数输出机制对这两个都近似于 0.3 但又各自不同的近似值的输出设计是不一样的。


下面简单谈一谈,Java 浮点数输出的设计机制。

你可能已经注意到了,无论是输出 2.6999999999999997 还是输出 0.30000000000000004,好像它们从左往右数,恰好都是有 17 位有效数字。

其实这并不是巧合,而是精心设计的结果:Java 浮点数输出最多会保留 17 位有效数字!

为什么最多是保留 17 位呢,因为 IEEE 754 浮点数标准下,64 位的浮点数在二进制上的最微小变化,反映到十进制上,必然能在前 17 位有效数字上看到变化。

比如,下面是 IEEE 754 标准下,两个相邻的 64 位浮点数的实例:

1
2
0 01111111101 0011001100110011001100110011001100110011001100110100
0 01111111101 0011001100110011001100110011001100110011001100110101

可以看到,这两个浮点数的内部存储,只有最后一位不同(这一位的变化对值的影响最小)!
而这两个浮点数内部存储对应的真实值是:

1
2
0.3000000000000000444089209850062616169452667236328125
0.300000000000000099920072216264088638126850128173828125

前 16 位看不出来变化,但到第 17 位就不一样了!也即,对于任何浮点数的内部存储对应的完整真实值,只看前 17 位,必然是存在差别的。

这个特性对于简化浮点数的输出非常有用,我们可以舍去大量低位的信息不去输出!毕竟对于小数,我们一般不太关注后面太低位的信息。

至于为什么要强调差别呢?我觉得可能是,希望对于不同的浮点数的内部二进制存储,最后输出的内容也是不同的。也即希望 内部 64 位二进制存储输出内容 存在一个一一映射的关系。

按 IEEE 754 标准的换算逻辑,内部 64 位二进制存储完整真实值 是一一映射的关系,现在我们可以再简化一下,内部 64 位二进制存储完整真实值前 17 位 是一一映射的关系。只要保留 17 位,就能保证找到对应的内部二进制存储。

以上只是 Java 浮点数输出设计的冰山一角。

其实不局限于 Java,在任何语言中,都要考虑输出浮点数这件事情。

输出浮点数,本质就是将浮点数转换为恰当的字符串,这个问题看似简单,实际上是很复杂的事情。针对这个问题,设计了非常多的算法,如:Dragon4GrisuGrisu2Grisu3Ryu 等。

不论哪种算法,为了保证浮点数转换得到的字符串尽可能合理,有三个基本要求:

  • 保值:一个正确的解析器可以将输出解析为原数。
    实际上这个可以理解为是,要保证内部二进制存储与输出内容的一一映射关系。

  • 最短输出:输出的字符串必须尽可能短。
    不可能对任意内部二进制存储,都输出前 17 位!17 位是最坏的情况,我们要做的是让它尽可能短。

  • 正确舍入:输出的字符串必须尽可能接近原数。
    这一点必不可少,因为在输出内容与内部二进制存储毫无关系的情况下,是完全可以做到前两个要求的:保值与最短输出

相关博文