Java浮点数计算误差

1年前 (2017-07-10) wang JAVA 0评论 已收录 287℃ 浏览数:97

最近在做一个类似滴滴的项目。我写的计算价格的模块。结果今天在查看数据库的时候,发现了一个问题。这个价格怎么会是2617.2999999999997元?


恰巧今天在看书的时候发现了这个问题。


我去Java平台下试了一下。

public static void main(String[] args) {
	System.out.println(3-2.4);
}

输出是 0.6000000000000001 。js也是这样。然后我搜索了一下问题。找到了原因。

大多数语言在处理浮点数的时候都会遇到精度问题,准确的说,所有支持二进制浮点数运算(绝大部分都是 IEEE 754 的实现)的系统都存在这个现象。

我们首先从计算机的本身去讨论这个问题。计算机不能识别除了二进制以外的任何数据。无论我们在什么情况下使用任何的编程语言。都需要先把源程序翻译成计算机可以认识的机器码。我们上面的问题,就是十进制的2.4转换为二进制中出现了问题。就像1/3转换为十进制是一个无限循环小数一样。2.4转换为二进制也无法精确的转换,反而会转换为更接近的2.3999999999999999。这里涉及到了浮点数在计算机中的表示。

计算机中的浮点数
浮点指的是带有小数的数值,浮点运算即是小数的四则运算,常用来测量电脑运算速度。大部份计算机采用二进制(b=2)的表示方法。(bit)是衡量浮点数所需存储空间的单位,通常为32位或64位,分别被叫作单精度双精度

问:要把小数装入计算机,总共分几步?

  • 第一步:转换成二进制
  • 第二步:用二进制科学计算法表示
  • 第三步:表示成 IEEE 754 形式

在上面的第一步和第三步都有可能 丢失精度。这个知识点等下会用到。

考虑我们将 1/7(七分之一) 写成小数的时候是如何做的?

用 1 除以 7,得到的商就是小数部分,剩下的余数我们继续除以 7,一直除到什么时候结束呢? 有两种情况:

  1. 如果余数为 0。结束了。
  2. 当除到某一步时,余数为 1,可是,余数如果为 1 的话,再继续除下去,不就又是 1/7 了吗?对的。它循环了。

注意我上面说的 情况2,我们判断他循环,并 不是从直观看感觉它重复了,而是因为在计算过程中,它又回到了开头。为什么这么说呢?当你计算一个分数时,它总是连续出现 5,出现了好多次,例如 0.5555555… 你也无法断定它是无限循环的,比如 一亿分之五。

循环小数不能精确表示,放到计算机中会丢失精度; 那么有限小数可以精确表示吧,比如 0.1。

那么 0.1 在计算机中可以精确表示吗?

答案是出人意料的, 不能

在此之前,先思考个问题: 在 0.1 到 0.9 的 9 个小数中,有多少可以用二进制精确表示呢?

我们按照乘以 2 取整数位的方法,把 0.1 表示为二进制:

(1) 0.1 x 2 = 0.2   取整数位 0  得 0.0
(2) 0.2 x 2 = 0.4   取整数位 0  得 0.00
(3) 0.4 x 2 = 0.8   取整数位 0  得 0.000
(4) 0.8 x 2 = 1.6   取整数位 1  得 0.0001
(5) 0.6 x 2 = 0.2   取整数位 1  得 0.00011
(6) 0.2 x 2 = 0.4   取整数位 0  得 0.000110
(7) 0.4 x 2 = 0.8   取整数位 0  得 0.0001100
(8) 0.8 x 2 = 1.6   取整数位 1  得 0.00011001
(9) 0.6 x 2 = 1.2   取整数位 1  得 0.000110011
(n) ...

我们得到一个无限循环的二进制小数 0.000110011…

剩下的0.3

(1) 0.3 x 2 = 0.6 取整数位 0 得 0.0
(2) 0.6 x 2 = 1.2 取整数位 1 得 0.01
(3) 0.2 x 2 = 0.4 取整数位 0 得 0.010
(4) 0.4 x 2 = 0.8 取整数位 0 得 0.0100
(5) 0.8 x 2 = 1.6 取整数位 1 得 0.01001
(6) 0.6 x 2 = 1.2 取整数位 1 得 0.010011
(n) ...

然后跟上面一样循环了

0.7就是0.7 x 2 = 1.4 取整数位 1 后  从0.4开始循环

0.9就是0.9 x 2 = 1.8 取整数位 1 后 从0.8开始循环

所以0.1 到 0.9 的 9 个小数中,只有 0.5 可以用二进制精确的表示。

如果把 0.0 再算上,那么就有两个数可以精确表示,一个奇数 0.5,一个偶数 0.0。

其实答案很显然,0.5 就是一半的意思。 在十进制中,进制的基数是 10,而 5 正好是 10 的一半。 2 的一半是多少?当然是 1 了。 所以,十进制的 0.5 就是二进制的 0.1。如果我用八进制呢? 不用计算你就应该立刻回答:0.4;转换成十六进制呢,当然就是 0.8 了。

(0.5)10 = (0.1)2 = (0.4)8 = (0.8)16

那我们按照只算小数点一位的情况下,十进制可以表示小数点后的0-9,二进制可以表示小数点后的0-1,那么十六进制应该可以精确表示所有的 0.1 到 0.9 的数甚至还可以精确表示其它的 6 个数?

答案是 错误。16 进制可以精确表示的数 和 2 进制可以精确表示的数是一样的,只能精确表示 0.5。

为什么会这样?

那么到底怎么确定一个数能否精确表示呢?还是回到我们熟悉的十进制分数。

1/2、5/9、34/25 哪些可以写成有限小数?把一个分数化到最简(分子分母无公约数),如果分母的因式分解只有 2 和 5,那么就可以写成有限小数,否则就是无限循环小数。为什么是 2 和 5 呢?因为他们是 10 的因子 10 = 2 x 5。

二进制和十六进制呢?他们的因子只有 2,所以十六进制只是二进制的一种简写形式,它的精度和二进制一样。

如果一个十进制数可以用二进制精确表示,那么它的最后一位肯定是 5。

备注:这是个必要条件,而不是充分条件。例如0.05就不能用二进制表示,为什么呢?0.05 x 2= 0.1,0.1无法用二进制表示出来,所以0.05也一样 。

那么精度是在哪里丢失的呢?

我们运行这样一段代码

public static void main(String[] args) {
	Double a = 0.6;
	Double b = 0.2 + 0.4;
	System.out.println(a);
	System.out.println(b);
}

输出的是什么?

0.6
0.6000000000000001

好像很矛盾吧?通过a我们知道,Java至少是可以精确显示0.6的,哪怕0.6无法在计算机中精确的存储,但是可以是精确显示出来的。这不是和b矛盾了吗?

则是个很有意思的问题。问题出在哪里。出在 要把小数装入计算机,总共分几步? 这个问题中。在直观上认为 0.2 + 0.4 = 0.6 是必然成立的(在数学上确实如此),既然a的结果是 0.6,而且 java 可以精确显示 0.6,那么b的结果应该输出 0.6。其实在计算机上 0.2 + 0.4 根本就不等于 0.6,因为 0.2 和 0.4 在计算机中都不能精确的存储。 浮点数的精度丢失在每一个表达式,而不仅仅是表达式的求值结果。

我们用数学中的概念类比一下,比如四舍五入,我们计算 1.4 + 2.3 保留整数。

1.4 + 2.3 = 3.7

四舍五入后会输出4

那如果换一种方法

1.4先四舍五入为1

2.3四舍五入变为2

1 + 2 = 3

通过两种运算,我们得到了两个结果 43。同理,在我们的浮点数运算中,参与运算的两个数 0.2 和 0.4 精度已经丢失了,所以他们求和的结果已经不是 0.6 了。

以上参考代码之谜(五)- 浮点数(谁偷了你的精度?)

细心的同学可能去Java中尝试了一下,会发现更奇怪的问题。我们来看以下的代码。

public static void main(String[] args) {
	Double a = 0.2 + 0.4;
	Double b = 0.3 + 0.3;
	Double c = 0.2 + 0.3;
	System.out.println(a);
	System.out.println(b);
	System.out.println(c);
}

我们已经尝试过0.2 + 0.4 输出 0.6000000000000001,那么b 和 c会输出什么?
答案很有意思 0.6 0.5
为什么?为什么会这样子?不是精度已经丢失了吗?
答案是0.1-0.9的小数除了0.5是计算机可以正确的存储,其他的小数存储都是不准确的,但是两个都没有准确存储的小数在进行计算后,可能发生巧合,结果变的正确。不知道大家能不能理解这个意思。

我们再来看这样一段代码

public static void main(String[] args) {
	BigDecimal a = new BigDecimal(0.2);
	BigDecimal b = new BigDecimal(0.3);
	System.out.println(a);
	System.out.println(b);
	System.out.println(a.add(b));
}

输出是

0.200000000000000011102230246251565404236316680908203125
0.299999999999999988897769753748434595763683319091796875
0.500000000000000000000000000000000000000000000000000000

这样大家应该就能看到。0.2 和0.3的存储都是不准确的,但是在计算后的结果巧合的变成了正确的值。

那么在Java里面如何正确的进行小数的计算呢?BigDecimal 类。

(1)构造方法

BigDecimal 由任意精度的整数非标度值 和32 位的整数标度 (scale) 组成。如果为零或正数,则标度是小数点后的位数。如果为负数,则将该数的非标度值乘以 10 的负scale 次幂。因此,BigDecimal表示的数值是(unscaledValue × 10-scale)。

使用BigDecimal 进行计算的时候一定要注意这样一个问题。

public static void main(String[] args) {
	BigDecimal aDouble = new BigDecimal(0.1);
	System.out.println("construct with a double value: " + aDouble);
	BigDecimal aString = new BigDecimal("0.1");
	System.out.println("construct with a String value: " + aString);
}

       你认为输出结果会是什么呢?如果你没有认为第一个会输出0.1,那么恭喜你答对了,输出结果如下:

       construct with a double value: 0.1000000000000000055511151231257827021181583404541015625

       construct with a String value: 0.1

        JDK的描述:

        1、参数类型为double的构造方法的结果有一定的不可预知性。有人可能认为在Java中写入newBigDecimal(0.1)所创建的BigDecimal正好等于 0.1(非标度值 1,其标度为 1),但是它实际上等于0.1000000000000000055511151231257827021181583404541015625。这是因为0.1无法准确地表示为 double(或者说对于该情况,不能表示为任何有限长度的二进制小数)。这样,传入到构造方法的值不会正好等于 0.1(虽然表面上等于该值)。

        2、另一方面,String 构造方法是完全可预知的:写入 newBigDecimal("0.1") 将创建一个 BigDecimal,它正好等于预期的 0.1。因此,比较而言,通常建议优先使用String构造方法

        3、当double必须用作BigDecimal的源时,请注意,此构造方法提供了一个准确转换;它不提供与以下操作相同的结果:先使用Double.toString(double)方法,然后使用BigDecimal(String)构造方法,将double转换为String

(2)加法操作

public static void main(String[] args) {
	BigDecimal a =new BigDecimal("1");
	System.out.println("construct with a String value: " + a);
	BigDecimal b =new BigDecimal("2");
	a.add(b);
	System.out.println("aplus b is : " + a);
}

会输出什么?

       construct with a Stringvalue: 1

       a plus b is :3

      实际上a plus b is :1

BigDecimal 的减乘除其实最终都返回的是一个新的BigDecimal对象,因为BigInteger与BigDecimal都是不可变的(immutable)的,在进行每一步运算时,都会产生一个新的对象,所以a.add(b);虽然做了加法操作,但是a并没有保存加操作后的值,正确的用法应该是a=a.add(b);

 

其实这些东西如果你不去发现,或者不去思考原理,你是根本不会弄懂的。这个小问题,我花了一天半的时间去搜索,去明白原理。才会慢慢发现世界真的很奇妙。一定要多注意细节,多思考。

博主

Just do it. Now or never.

相关推荐

嗨、骚年、快来消灭0回复。