走进浮点数:为什么1-0.9不等于0.1?

1,263 阅读13分钟

1. 在计算机里,单精度的1-0.9等于多少?

按照正常计算的情况,1-0.9的结果肯定是0.1了,答案毋庸置疑。但是在计算机眼里,单精度的1-0.9等于多少?学过相关知识的同学们肯定也都知道,它的结果是0.100000024

我们执行一下命令。

public static void main(String[] args) {
 
    System.out.println(1.0f-0.9f);

}

//结果为0.100000024

再“猜测”一下,单精度的1-0.9等于0.9-0.8吗?

public static void main(String[] args) {
    
    System.out.println((1.0f-0.9f)==(0.9f-0.8f));
    
}

//结果为false

我们输出一下

public static void main(String[] args) {
    
    System.out.println(0.9f-0.8f);
    
}

//结果为0.099999964

可以看到,计算结果与预期存在明显的误差,那么这个误差是如何产生的呢?下面一步步走进浮点数的存储与计算

2. 0与1

在正式介绍浮点数之前,先复习一下二进制,因为浮点数在计算机的存储和计算也是通过二进制的方式进行的。

简单来说,计算机就是一种电子设备,在此基础上的不管什么技术,本质上就是0与1的信号处理。信息存储与逻辑计算的元数据,只能是0和1 。它的进位规则是“逢二进一”,借位规则是“借一当二”。比如十进制的1在二进制中也是1,而十进制的2在二进制中就是10了,同理4==>100, 24==>11000,……

以十进制24为例,将其转化成二进制的方式,令24不断除以2,(第一次商12,余数为0),(第二次商6,余数为0),(第三次商3,余数为0),(第四次商1,余数为1),(第五次商0,余数为1),然后将余数反向排列就是24对应的二进制了,也就是11000

image-20201121180753916

二进制转化为十进制的方式,以11000为例,就是24+232^4+2^3,结果为24

计算机中使用的存储计量单位,最基本的就是,即bit,简写为b。8个bit组成1个字节,即1个Byte,简写为B。1024个Byte,成为1KB;1024个KB记为1MB;1024个MB记作1GB,……

我们以一个字节为基本单位介绍二进制的计算,十进制对应的二进制表示为0000 0001。最左侧的值表示正负,0表示正,1表示负,那么-1可以表示为1000 0001,这就是二进制的原码

image-20201121183834707

二进制的计算涉及到三种编码方式:原码、反码和补码。

  • 原码:正数是数值本身,符号为0;负数是数值本身,符号位是1 。8位二进制数的表示范围是[-127,127]
  • 反码:正数是数值本身,符号为0;负数的数值是在正数表示的基础上按位取反,符号位是1 。8位二进制数的表示范围是[-127,127]
  • 补码:正数是数值本身,符号为0;负数的数值是在反码的基础上加1,符号位是1 。8位二进制数的表示范围是[-128,127](注意,补码所表示的范围比原码和反码大一点,稍后做解释)

示例:

十进制数值原码反码补码
10000 00010000 00010000 0001
-11000 00011111 11101111 1111
20000 00100000 00100000 0010
-21000 00101111 11011111 1110

那么问题来了,既然原码更符合我们的认知,要反码和补码干什么用?因为计算机的运算方式和人类的思维模式是不相同的,我们人可以轻易的分辨出一个数值是正数还是负数,而计算机如果将符号位与数值位分开计算,就需要作额外的判断,在一个比较复杂的程序中,这样的计算堆叠起来就是巨大的计算开销,显然是不合理的。为了加速计算机的运算速度,需要将符号位一起参与计算,而如果使用原码计算,在一些情况下容易出现问题,以减法为例,减去一个值就是加上这个值的相反数,1-2=1+(-2)=-1,按照原码计算,[00000001]+[10000010]=[10000011]=3[0000 0001]_原+[1000 0010]_原=[1000 0011]_原=-3,这是不正确的,而如果使用反码进行计算,[00000001]+[11111101]=[11111110]=1[0000 0001]_反+[1111 1101]_反=[1111 1110]_反=-1,计算正确。关于补码,再举一个例子,2+(-2)=[00000010]+[11111101]=[11111111]=0[0000 0010]_反+[1111 1101]_反=[1111 1111]_反=-0,按照正确的认知,0就是0,没有正负之分,这样计算显然存在问题。随着编码的发展,补码应运而生了,同样的计算,2+(-2)=[00000010]+[11111110]=[00000000]=0[0000 0010]_补+[1111 1110]_补=[0000 0000]_补=0,补码,解决了+0和-0的问题。另外,补码的诞生,增大了二进制编码所能表示的范围,在8位二进制编码中,补码可以表示到-128,其对应的补码为[10000000][1000 0000]_补(补充:补码的补码就等于原码)

3. 浮点数

浮点数不同于整数,不可以像上面介绍的那样进行存储与计算,而是分成了符号、指数和有效数字分别表示。当前业界流行的浮点数标准是IEEE754,该标准规定了4种浮点数类型:单精度、双精度、延伸单精度、延伸双精度,前两种是最常用的,而单精度与双精度的区别只是位数不同而已,下面一起复习单精度浮点数

单精度被分配了4个字节,占32位,具体格式如下图所示:

image-20201122001036786

通常将内存地址低端的位写在最右边,称作“最低有效位”,代表最小的比特,改变时对整体影响最小。单精度浮点数的表示格式如上图所示,符号代表了数值的正负;浮点数的表示依赖于科学记数法,指数位就表示了规格化之后数值的指数,这一块占了8位,在二进制中称作“阶码位”;最后的有效数字在二进制中称作为“尾数”。

  1. 符号位

在二进制最高位分配了1位表示浮点数的符号,0表示正数,1表示负数

  1. 阶码位

在符号位右侧分配了8位用来存储指数,IEEE754标准规定了阶码位存储的是指数对应的移码,而不是原码或者补码。移码的几何意义是把真值映射到了一个正数域,在比较真值大小时,只要将高位对齐后逐个比较即可。

定义真值为ee,阶码为EE,IEEE754标准规定的偏移量为2n112^{n-1}-1nn是阶码的位数,这里n=8n=8。那么有E=e+(2n11)E=e+(2^{n-1}-1)。关于偏移量,前面介绍到,8位二进制能表示到的范围是[-128,127],将其平移到正数域,每个值需要加上128,得到的范围是[0,255],而计算机规定阶码全为0或者全为1的两个值会被当做特殊值进行处理,全为0时认为是机器零(小到精度达不到的值认为是0,与值0不同,值0表示一个点,机器零表示一个区域),全1则认为是无穷大。去掉两个特殊值,范围变成了[1, 254],而偏移量取2n112^{n-1}-1的话,指数的范围就是[-126, 127]。

  1. 尾数位

最右侧的23位用来存储有效数字,根据科学记数法,由有效数字和指数组成数值代表了最终浮点数的大小。在科学记数法中要求小数点前的数值范围是[1,10),在二进制中,这个范围就是[1,2),而为了节省存储空间,将规格化之后形成的类似1.xyz的首位1省略,因此23位的区域表示了24位的二进制数值,也因此这一区域被称为尾数。

三个区域有着各自的职责,我们可以将其简化,如下图所示。

image-20201122005439751

数值的计算公式如下:

X=(1)S×(1.M)×2E127X=(-1)^S\times (1.M)\times 2^{E-127}

以数值16为例,8位二进制原码表示为0001 0000,而浮点数表示为0100-0001-1000-0000-0000-0000-0000-0000,我们计算一下,最高位0,表示正数;100-0001-1转化为十进制为131,131-127=4,24=162^4=16;尾数为全为0,即1×24=161\times2^4=16

数值1,对应浮点数表示为0011-1111-1000-0000-0000-0000-0000-0000,最高为0,表示正数;011-1111-1转化为十进制为127,2127127=12^{127-127}=1;尾数全为0,即1×1=11\times1=1

上面两个数值使用浮点数正好可以精确表示,但是对于大多数的值来说,有限位的值无法精确表示。

比如0.9,阶码位可以给出212^{-1}(即0.5),202^0222^{-2}无法通过乘以1.x得到0.9;对于尾数位,只要精确表示0.8,那么整体就可以精确表示0.9,但是有限的二进制位没有办法精确到0.8。0.9对应的浮点数二进制为0011-1111-0110-0110-0110-0110-0110-0110,没有精确表示0.9,那么回到开头的问题,在计算机中,1-0.9也就并不精确等于0.1,具体结果在后面进行计算。(补充,二进制小数转化为十进制,小数点后一位表示212^{-1},依次累加,如1.00000101=1+26+281.00000101=1+2^{-6}+2^{-8}

思考一下,能够由这样的方式精确表示的两个相邻的值相差多少?指数的范围是[-126, 127],指数最小为21262^{-126};两个相邻的值尾数位相差2232^{-23}(尾数位的最后一个值加1,在十进制中就是加2232^{-23}),那么两个相邻的值相差2126×223=21492^{-126}\times2^{-23}=2^{-149}。那么单精度可以表示的最大值是多少?指数取最大,也就是21272^{127},约等于1.7×10381.7\times10^{38};尾数位取最大,即各个位都为1,所表示的1.11··11近似认为是一个无限接近于2的值,因此可表示的最大值为2×1.7×1038=3.4×10382\times1.7\times10^{38}=3.4\times10^{38} 。最小正数值呢?同理,可以取到的最小正数值为1.0×21261.0\times2^{-126},这里有个概念,称为渐进式下溢出,也就是两个值相差应该是均匀的;而现在最小值与0的差值1.0×21261.0\times2^{-126}相比最小值与次小值的差值21492^{-149}相差了2232^{23}倍这样的数量级,可以说是非常突然的下溢出到0,这种情况被称为突然式下溢出 。IEEE754标准规定采用渐进式下溢出,也就是与0的差值也为21492^{-149},约等于1.4×10451.4\times10^{-45}

4. 加减运算

对两个采用科学记数法表示的值进行加减运算,首先需要进行操作确保指数一样,再将有效值按照正常的数进行加减运算。

  1. 零值检测

规定阶码和尾数全为0表示的值就是0,如果两个值其中一个为0可以直接得出结果。

  1. 对阶

两个值的阶码相同时,表示小数点对齐了。如果不相同,则需要移动尾数来改变阶码,尾数向右移动一位,则阶码值加1,反之减1。思考一下,对比左移和右移,都有可能将部分二进制位挤出,导致误差,但左移一位误差在212^{-1}级,右移一位误差在2232^{-23}级,明显右移带来的误差更小,因此标准规定对阶操作只能右移。

  1. 尾数求和

当对阶完成后,尾数按位相加即可完成求和(负数需要转化为补码进行运算)

  1. 规格化

将结果转化为前面介绍的规范的形式的过程就称作规格化。尾数位向右移动称为右归,反之称为左归

  1. 结果舍入

对接操作和规格化操作都有可能造成精度损失,为了减少这样的损失,先将移出的这部分数据保存起来,称为保护位,在规范化后再根据保护位进行舍入处理。

了解了二进制浮点数的加减运算,再回到开头的问题,单精度的1-0.9=?

  • 1.0的二进制为0011-1111-1000-0000-0000-0000-0000-0000
  • -0.9的二进制为1011-1111-0110-0110-0110-0110-0110-0110

为方便计算,将三个区域的数值分割开来

浮点数符号阶码尾数(加入隐藏值1)尾数补码(加入隐藏值)
1.001271000-0000-0000-0000-0000-00001000-0000-0000-0000-0000-0000
-0.911261110-0110-0110-0110-0110-01100001-1001-1001-1001-1001-1001
  1. 对阶

1.0阶码为127,-0.9阶码为126,标准规定只能右移,因此-0.9的阶码变为127,尾数补码右移后在最高位补1,变为1000-1100-1100-1100-1100-1100,舍掉了最后一个1,实际上作结果舍人时可以把这个1补回来,为方便计算,直接补回这个1,也就变成了1000-1100-1100-1100-1100-1101

  1. 尾数求和

image-20201122113629603

  1. 规格化

上一步计算出的值并不符合要求,尾数的最高为必须为1,因此需要将尾数位左移4位,对应的阶码减去4 。规格化后的符号为0,阶码为123(对应的二进制为1111011),尾数去掉隐藏值为100-1100-1100-1100-1101 。三部分组合起来就是1-0.9的结果,转化为十进制就为0.100000024

如果要求绝对精度呢?比如在金融领域,一点点精度的缺失可能带来巨额的财产损失,这个时候推荐使用Decimal类型进行表示。

public static void main(String[] args) {

    BigDecimal a = new BigDecimal("1.0");
    BigDecimal b = new BigDecimal("0.9");

    BigDecimal c = a.subtract(b);
    System.out.println(c);

}

//结果为0.1

觉得有帮助到你的话,就请给本文点个赞吧!万分感谢!

5. 参考

  • Java开发手册
  • 百度百科