利用Java的大数展示浮点数之谜
引用知乎:https://zhuanlan.zhihu.com/p/89320102
1.浮点数的存储格式
浮点数的精度问题并不只是存在于某个语言之上,而是在整个计算机体系上来说都存在这样的问题
现在几乎所有语言都支持 IEEE 754 的二进制浮点格式。在说明这个格式之前,先看看科学计数法,有一个这样的数:123.456
,表示成科学计数法是:+ 1.23456 * 10^2
,这个表示法有三个部分:
+
是符号3.27849
是有效数- 10的2次方上的
2
是指数
二进制也有一样的科学计数法,如:
1001.0101 可以表示成 + 1.0010101 * 2^3
0.001011 可以表示成 + 1.011 * 2^-3
方法是将小数点移动到最左边的1之后,向左移多少位,就是2的几次方;向右移多少位,就是2的负几次方。
IEEE 754称这种表示方法叫正规化,这种叫正规数,其也规定了一种叫非正规数
- +为符号
- 1.0010101为有效数
- 3 为指数
IEEE 754的浮点数格式遵循科学计数法的表现方式,以32位浮点数为例(下面默认都是32位浮点数):
- 第31位表示符号,简写为S,0为正,1为负
- 接下来的8位与指数有关,简写为E,因为是8位,所以E的范围是[0, 255],其中0和255代表特殊值,剩下的值减去127得到指数:E - 127,即指数的范围是[-126, 127]
- 剩下的23位表示有效数的小数部分,也叫做尾数,我们简写为M,真正的有效数是 1.M,注意前面的1是被省略了的,正规化表示的数的M总是位于[0.5,1)这个区间之内
下面地公式是IEEE 754单精度浮点数地表示方法
(-1)^S * 1.M * 2^(E-127)
以上面图中二进制为例,一步步计算最终的值:
// E等于十进制的124
E = 01111100 = 124
// 代入公式
(-1)^0 * 1.01000000000000000000000 * 2^(124 - 127)
// 继续
1 * 1.01 * 2^-3
// 继续:下面就是二进制的最终值
0.00101
// 继续
0 + 0/2 + 0/4 + 1/8 + 0/16 + 1/32
// 继续
0 + 0 + 0 + 0.125 + 0 + 0.03125
// 得到结果
0.15625
2.浮点数越大,精度越差
现在我们假设一个问题,我们假设现在E的值固定是126,S的值固定是0,那么这个浮点数最终能表示的数的范围是多少呢?由于E和S值是固定的,剩下的能决定浮点数的值的就是M了,因为M有23位,算上省略的一位也就是24位,那么最终这个浮点数的范围就是 [0.5,1),也就是说,我们可以用2^24个数来表示[0.5,1)这个区间的数(首位固定是1),我们把这段区间想象成一个线段,每个数这2^24个数就是这个线段上的点,是不是能想到点什么?精度由谁决定?当然是由点的密集程度决定了,点越多是不是能表示的数也就越精确?
我们现在又来假设,假设E是127,S是0,那么最终这个浮点数的表示范围就成了[1,2),这时候你再看,我们的M同样是2^24次方个数,但是要表示的数的区间却变大了,是不是点之间的空隙也就变大了?精度也就随之变差了?所以说,其实一个浮点数越大,随之带来的就是精度的变差。
3.动手测试一下
我们假设现在想表示0.7这个单精度浮点数,把他转换为二进制形式可以发现是不能精确的表示出0.7的,其会一直出现循环。所以说,我们在把0.7存储到计算机的时候,其实就已经出现了精度损失。可能你问题由来了,那为什么我存一个0.7,明明精度出现了问题,但是还是能正确的表示出0.7?且听我慢慢道来
public static void main(String[] args) {
float a = 0.7f;
float b = 0.69999996f;
float c = 0.70000001f;
System.out.println(a); //0.7
System.out.println(b); //0.7
System.out.println(c); //0.7
System.out.println(Integer.toBinaryString(Float.floatToIntBits(-a))); //10111111001100110011001100110011
System.out.println(Integer.toBinaryString(Float.floatToIntBits(-b))); //10111111001100110011001100110011
System.out.println(Integer.toBinaryString(Float.floatToIntBits(-c))); //10111111001100110011001100110011
System.out.println(a == b); //true
System.out.println(a == c); //true
float d = 0.70000002f;
System.out.println(d); //0.70000005
System.out.println(Integer.toUnsignedString(Float.floatToIntBits(d), 16));//3f333334
}
为什么会出现上面的情况?因为在将0.69999996f或者0.7f转换为二进制的时候,因为精度问题,其实最终转换成的二进制都是一样的,看上面输出的二进制也能看出来,那为什么就输出了0.7呢?“10111111001100110011001100110011”我们取出这个数中的M的部分,也就是“1.01100110011001100110011”,E的值为“-1”,最终我们套入上面的公式计算一下看计算出来的结果到底是多少,利用Java的大数来计算一下
BigDecimal bigDecimal = new BigDecimal("0.25");
BigDecimal sum = new BigDecimal("0.5");
char[] chars = Integer.toBinaryString(Float.floatToIntBits(-c)).substring(8).toCharArray();
for (int i = 1; i < 24; i++) {
if (chars[i] == '1') {
sum = sum.add(bigDecimal);
}
bigDecimal = bigDecimal.divide(new BigDecimal(2));
}
System.out.println(sum.toPlainString()); //0.699999988079071044921875
其实最终的结果,也不是0.7,那为什么就能正确的输出0.7呢?其实啊这只是我们的计算过程,在计算机里面,还原0.7这个数的二进制的时候,也不会像我们这么精确,因为他表示不了啊。他最多只能看到这么多“0.6999999”,那是不是就是输出0.699999了呢?其实不是的,这时候后面的8就起到关键作用了,因为0.6999998这个数离0.7更接近,所以,最终就给我们输出了0.7,如果你的数是0.69999994,那最终的可能就是0.69999999了,具体过程是怎么实现的?说实话我也不知道,我猜想应该是CPU中的硬件完成的,因为CUP在计算的时候如果出现数字越界,有时候并不是直接抛弃,其有自己的处理规则。
4.特殊的浮点数值
有一些存储格式被预定义为特殊值,它们是:
0值
0:当E=0,M=0时,浮点数表示0,因为S的存在,浮点数会出现两个0的存储格式(以单精度为例):
+0 = 0 00000000 00000000000000000000000
-0 = 1 00000000 00000000000000000000000
无穷(Infinity)
当E=255,M=0时,表示无穷,同样有正无穷和负无穷:
+INFINITY = 0 11111111 00000000000000000000000
-INFINITY = 1 11111111 00000000000000000000000
无穷数有这样一些性质:
- 任何有穷数加上无穷,还是等于无穷,如:
var f = 1000.0 + math.Inf(1)
fmt.Printf("%f\n", f) // => +Inf
- 任何正有穷数乘以无穷,还是等于无穷;任何负有穷数乘以无穷,等于符号相反的无穷。
- 任何有穷数除以无穷,等于0。
总之,无穷数就是大到无法正常计算结果的数。
非数(NaN)
当E=255,M != 0时,称为非数:
0 11111111 00000000000000000000000 ~ 0 11111111 11111111111111111111111
1 11111111 00000000000000000000000 ~ 1 11111111 11111111111111111111111
非数也有很多性质,不过最值得提出来的是 NaN != NaN :)
Subnormals
前面说到浮点数的有效数是1.M,这种形式叫正规数。
当E=0,M!=0时,浮点数处于Subnormal的范围,它的表示方法是0.M,即前面的数是0,这种数也叫非正规数(denormal)。
定义非正规数的目的是:使最小正规数和0之间更平滑,换句话说能存储更多很小的数,比如下面的数:
0.00110001101001 * 2^(−126)
如果用正规的表示方式是:1.10001101001 * 2^(-129)
,指数部分已经超过E能表示的范围,所以按正规方式是没有办法存储的。但按非正规方式就可以:
- E = 0
- M = 00110001101001
- 公式写成
(-1)^S * 0.M * 2^(E-126)
这样就能表达很小的数了,不过这些数的精度要比正规数低,相当于牺牲精度为代价,消除最小正规数和0之间的差距。
作为程序员,特别是服务端程序员,我们只要明白浮点数不是均匀分布的,越大的浮点数精度越低,大多数实数在计算机中只能以近似值表示就差不多了。剩下的让浮点运算单元帮我们处理吧:)