计算机如何表示浮点数?

从一个最简单也最经典问题说起:

能说一说 System.out.println( 1f == 0.999999999999f );的打印结果是什么吗?这么写有什么问题吗?

对于这样一个问题,回答结果一般也就两种情况。

其实这个题目考察的目的简单而明确:浮点数在计算机中是如何运算的?写代码时有什么要注意的?会有哪些坑?能说出这3个方面基本就可以了,但有些小伙伴可能忘记了。


那有同学会说了,考这样一个破题目有实际意义吗?工作中能遇到这种情况???

你别说,以前代码走查时还真看到过这种用==来进行浮点数等值判断的代码,而且这种浮点数的精度问题在工作中还是有相当的概率会遇到的,一旦没有发现,上线后往往就会出大问题,要背锅的。。。

Image

连《阿里巴巴Java开发手册》中都有一条强制性规约和浮点数运算有关,所以其重视程度可见一斑:

浮点数之间的等值判断,基本数据类型不能用==来比较,包装数据类型不能用 equals 来判断。

Image


一些奇怪的现象

在涉及诸如float或者double这类浮点型数据处理时,偶尔总会有一些奇葩问题,从而会导致各种超出预期的现象发生,以前也举过例子,比如:

  • 条件判断超预期
System.out.println( 1f == 0.9999999f );   // 打印:false
System.out.println( 1f == 0.99999999f );  // 打印:true  ?
  • 数据转换超预期
float f = 1.1f;
double d = (double) f;
System.out.println(f);  // 打印:1.1
System.out.println(d);  // 打印:1.100000023841858 ?
  • 基本运算超预期
System.out.println( 0.2 + 0.7 );  

// 打印:0.8999999999999999   ?
  • 数据自增超预期
float f2 = 84552631f;
for (int i = 0; i < 10; i++) {
    System.out.println(f2);
    f2++;
}
//    打印:8.4552632E7   ?
//    打印:8.4552632E7   ?
//    打印:8.4552632E7   ?
//    打印:8.4552632E7   ?
//    打印:8.4552632E7   ?
//    打印:8.4552632E7   ?
//    打印:8.4552632E7   ?
//    打印:8.4552632E7   ?
//    打印:8.4552632E7   ?
//    打印:8.4552632E7   ?

所以如果在业务代码中一旦涉及到诸如:订单金额商品金额交易值货币运算等这种对精度要求很高的场景时,使用浮点数就一定要慎重了,一不注意就可能锅从天而降了,而且排查起来有时候还挺费劲。


计算机是怎么表示小数的?

学过 《计算机组成原理》 或者类似 《计算机系统》 这些课程的小伙伴们应该都知道,浮点数在计算机中的存储方式遵循IEEE 754 浮点数计数标准,可以表示为:

Image

采用尾数 + 阶码的编码方式,更通俗一点说,就是类似于数学课本上所学的科学计数法表示方式:有效数字 + 指数位

因此,只要给出:符号(S)阶码部分(E)尾数部分(M) 这三个维度的信息,一个浮点数的表示就完全确定下来了,所以floatdouble这两种类型的浮点数在计算机中的存储结构就表示成下图所示这个样子:

Image

Image

1、符号部分(S)

0-正 1-负

2、阶码部分(E)(指数部分)

  • 对于float型浮点数,指数部分8位,考虑可正可负,因此可以表示的指数范围为-127 ~ 128
  • 对于double型浮点数,指数部分11位,考虑可正可负,因此可以表示的指数范围为-1023 ~ 1024

3、尾数部分(M)

浮点数的精度是由尾数的位数来决定的:

  • 对于float型浮点数,尾数部分23位,换算成十进制就是 2^23=8388608,所以十进制精度只有6 ~ 7位;
  • 对于double型浮点数,尾数部分52位,换算成十进制就是 2^52 = 4503599627370496,所以十进制精度只有15 ~ 16

所以,浮点数交给计算机存储的时候,可能会有精度丢失问题!!!因此使用时需要格外小心,如果真因为这一块出了bug,定位问题还是非常艰难的,所以预防工作要做好。


小数怎么换算成二进制?

上面说的是IEEE标准规定的内容,属于理论规约。那一个小数到底要怎么换算成二进制呢?我们得拿实际例子来解释。

先来个简单的例子

比如:把十进制小数0.875转换成二进制,具体怎么操作?

可以分几大步走:

1、以小数点为界,拆分

2、整数部分转换

整数转二进制我想大家应该都熟悉,使用:除2取余法 即可。而这里的0.875整数部分为0,无需操作。

3、小数部分转换

小数部分的转换不同于整数部分,采用的是 “乘2取整法” ,图示一下就明白了:

Image

4、合并结果

整数部分 + 小数部分,最终得到二进制结果为0.111

所以该结果按照上一节所述的尾数 + 阶码的计算机计数方式,则可以表示为:

Image

所以对应可得:

  • 符号位0
  • 阶码(E)部分:若以float为例,应为 127 +(-1)= 126,因此二进制表示为:01111110
  • 尾数部分(M):若以float为例,应为23位,因此尾部补齐后为11000000000000000000000

因此最终的总结果为(以32位精度float表示):

00111111011000000000000000000000

再来个复杂点例子

再比如:把十进制小数6.36转换成二进制,具体怎么操作?

但凡能用图示,我就不想写文字,所以用一张图就可以解释得明明白白:

Image

整数部分 + 小数部分,因此最终得到的结果二进制结果为110.01011100...

还是按照上一节所述的尾数 + 阶码的计算机计数方式,则可以表示为:

Image

所以对应可得:

  • 符号位:0
  • 阶码(E)部分:若以float为例,应为 127 +(2)= 129,因此二进制表示为:10000001
  • 尾数部分(M)1001011100...,但若以float型精度来截取23位,则可以表示为10010111000010100011111

因此最终的总结果为(以32位精度float表示):

01000000110010111000010100011111

因此像这种无限位数的尾数情况,用计算机存储产生截取是必然的,必定会有一定的精度损失!这也从根本上解释了为什么float或者double这种类型数据使用时的风险性,因此必须要结合实际业务理性考量。

所以回到文章开头的那个问题:System.out.println( 1f == 0.999999999999f );,换算一下你就会发现,其实在float类型下,不管是1f还是0.999999999999f,它们的二进制换算结果都是:

00111111 10000000 00000000 00000000

所以结果也就不奇怪了。


binaryconvert

大家如果对上面的计算结果不放心,或者想检查手动换算的结果是否正确,也有直接的这种二进制转换工具站,典型的比如binaryconvert

Image

不想手动换算的,直接去上面输入,转换一下即可得到结果,而且可以进制互换,使用非常方便。


小 结

所以情况大致就是这样,总之业务代码中一旦涉及到浮点数,就得提高警惕,格外小心一些

posted @ 2021-06-09 11:59  satire  阅读(943)  评论(0编辑  收藏  举报