Java 中的浮点数
# Java 中的浮点数
在 Java 中,double 和 float 的计算是不精确的。
观察下面的代码:
public class DataTypeDouble {
public static void main(String[] args) {
double d = 0.1;
double d2 = 0.2;
double d3 = d + d2;
System.out.println(d3);
float f = 0.3f;
float f2 = 0.1f;
float f3 = f - f2;
System.out.println(f3);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
编译和运行的结果:
javac DataTypeDouble.java
java DataTypeDouble
0.30000000000000004
0.20000002
2
3
4
不单单是 Java,几乎所有现代的编程语言都会遇到上述问题,包括 JavaScript、Ruby、Python、Swift 和 Go 等等。你可以在 0.30000000000000004.com/ (opens new window) 中找到常见的编程语言在计算 0.1 + 0.2 的结果
为什么会出现这样的现象呢?这得涉及到二进制和浮点数在计算机中如何存储的知识点了,我们这里简单讲一下。
# 二进制与小数
计算机中使用的是二进制,但我们如果想要存储十进制的数,怎么办呢?这就得用到二进制和十进制数的转换。
那么小数,如何转为二进制呢?采用"乘 2 取整,顺序排列"法:
- 用 2 乘十进制小数,可以得到积,将积的整数部分取出
- 再用 2 乘剩下的小数部分,又得到一个积,再将积的整数部分取出
- 重复操作,直到积中的小数部分为零,此时 0 或 1 为二进制的最后一位,或者达到所要求的精度为止
例如将 0.125 转换为二进制:
- 0.125 * 2 = 0.25 ------0
- 0.25 * 2 = 0.5 ------0
- 0.5 * 2 = 1.0 ------1
当小数部分为 0 就可以停止乘 2 了,然后正序排序就构成了二进制的小数部分:0.001
但是,有些小数转为二进制数是无限循环的(就好比 1/3 也是无限循环的 0.33333.......),我们以 0.1 为例:
- 0.1×2=0.2 ------ 0
- 0.2×2=0.4 ------0
- 0.4×2=0.8 ------0
- 0.8×2=1.6 ------1
- 0.6×2=1.2 ------1
- 0.2×2=0.4 ------0
再比如,十进制小数 0.7,转化为二进制小数是:0.1011001100110......,循环节是 0110。
结论:不是所有的十进制数都能转化为有限位二进制数的。
那些能分解为以(1/2)n 为单位的十进制小数,才可以转化为有限位数的二进制小数。
如十进制数:13/16=0.8125,它可以是拆成:13/16=1/2+1/4+1/16,或者直接可以看作是 13 个 1/16 所组成。而 1/2,1/4,1/16 这些数都是符合(1/2)n 形式的数。
就是因为有些小数,转为二进制是无限的小数,既然是无限的,计算机中肯定是存储不下来的,所以计算机中就无法很精确的存储浮点数;在运算的过程中,既然用的就是不准确的值了,那么得到的结果也就是不准确的了(只能说近似)。
想了解更多可以参考我写的博客:数字与进制 (opens new window)。
此外,计算机内是采用 IEEE 754 标准存储浮点数的,也可以去查看下该文件。
# 不准确带来的问题
这种不准确,很可能直接造成金钱上的损失。即使是零点零几的误差,累积下来也是巨大的损失。曾在网上看到一个新闻:
国外黑客利用银行漏洞 一次盗一美分 (opens new window)
据国外媒体报道,一位“黑客”利用银行漏洞从 PayPal、Google Checkout 和其它在线支付公司窃取了 5 万多美元,每次只偷几美分。他所利用的漏洞是:银行在开户后一般会向帐号发送小额钱去验证帐户是否有效,数额一般在几美分到几美元左右。
Google Checkout 和 Paypal 也使用相同的方法去检验与在线帐号捆绑的信用卡和借记卡帐号。 根据法庭公文,加利福尼亚人 Michael Largent 用一个自动脚本开了 58,000 个帐号,收集了数以千计的超小额费用,汇入到几个个人银行账户中去。
他从 Google Checkout 服务骗到了 $8,000 以上的现金。银行注意到了这种奇怪的现金流动,和他取得联系,Largent 解释他仔细阅读过相关服务条款,相信自己没做错事,声称需要钱去偿还债务。
但 Largent 使用了假名,包括卡通人物的名字,假的地址和社会保障号码,因此了违反了邮件、银行和电信欺骗法律。Largent 目前已被保释。
因此,在开发过程中,绝对不能使用 double 和 float 来存储和计算金额:老板,用 float 存储金额为什么要扣我工资 - 掘金 (opens new window)
# 解决方法
借用《Effactive Java》书中的一句话,float 和 double 类型设计的主要目标是为了科学计算和工程计算。它们主要用于执行二进制浮点运算,这是为了在广域数值范围上提供较为精确的快速近似计算而精心设计的。
但是,它们没有提供完全精确的计算结果,所以不应该被用于要求精确结果的场合。
在商业计算中往往要求结果精确,解决方案:
- 使用 JDK 提供的 BigDecimal:BigDecimal (opens new window)
- 将用户输入的浮点数,存储到字符串里,然后自己实现字符串里的数字的加减法,然后输出字符串。例如将有个字符串数组“1.1”,要和另一个字符串数组“1.2”相加,就逐个取出字符串里的内容,转为成整数相加,然后得到结果放到另一个字符串数组里。
# Java 和 IEEE 754 标准
在 Double 中,有这样一个方法:doubleToRawLongBits(double value)
,可以将 double 所表示的 64 位数用 long 表示出来。
这个方法是原始方法(注意其 native 修饰符,底层不是 java 实现,是 c++ 实现的),官方文档说明如下:
public static native long doubleToRawLongBits(double value);
1根据 IEEE 754 浮点“双精度格式”位布局,返回指定浮点值的表示形式,并保留 NaN 值。
位 63(由掩码
0x8000000000000000L
选择的位)表示浮点数的符号。 比特 62-52(由掩码0x7ff0000000000000L
选择的比特)表示指数。 位 51-0(由掩码0x000fffffffffffffL
选择的位)表示0x000fffffffffffffL
的有效数(有时称为尾数)。如果参数为正无穷大,则结果为
0x7ff0000000000000L
。如果参数为负无穷大,则结果为
0xfff0000000000000L
。如果参数为 NaN,则结果为
long
整数,表示实际的 NaN 值。 与doubleToLongBits
方法不同,doubleToRawLongBits
不会将编码 NaN 的所有位模式折叠为单个“规范”NaN 值。在所有情况下,结果都是
long
整数,当给定longBitsToDouble(long)
方法时,将产生与doubleToRawLongBits
的参数相同的浮点值。.......
参数:value - 双精度 (double) 浮点数。 返回:表示浮点数的位。
官方英文文档:Double (Java Platform SE 8 ) (opens new window)
举个例子:
public class TestDoubleToRawLongBits {
public static void main(String[] args) {
double d = 1;
System.out.println(Double.doubleToRawLongBits(d));
}
}
2
3
4
5
6
编译和有运行:
javac TestDoubleToRawLongBits.java
java TestDoubleToRawLongBits
4607182418800017408
2
3
这个结果是怎么来的呢?首先,将 4607182418800017408 转为二进制(可以用 Windows10 的计算器转化):
4607182418800017408
0011111111110000000000000000000000000000000000000000000000000000
2
将这串二进制,按照 IEEE 754 的标准,划分成 1 位符号数,11 位阶码和 23 位尾数:
0 01111111111 0000000000000000000000000000000000000000000000000000
其中,阶码是用移码表示的,因此实际的值要减去 127,得到的结果是 0.
也就是说,2 的 0 次方,也就是 1,就是我们前面定义的变量 d: double d = 1;
但是,这是因为刚好整数 1 可以在计算机中存储,如果是 0.1 这种,在计算机里存储就是不准确的值了,得到的结果也很奇怪:
double d2 = 0.1;
System.out.println(Double.doubleToRawLongBits(d2));
2
输出是:4591870180066957722,转为二进制是:
0011111110111001100110011001100110011001100110011001100110011010
感兴趣的读者可以自行根据 IEEE 754 标准计算其所代表的值是什么。
# 推荐阅读
为什么 0.1 + 0.2 = 0.300000004 · Why's THE Design? - 掘金 (opens new window)