持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第14天,点击查看活动详情
一 前言
在我们实际的开发过程中,总会用到float,double类型,涉及到float,double类型的使用,就不可避免的会遇到精度丢失问题。纵观所有的编程语言,都存在精度丢失问题,那么这个问题产生的根本原因是什么?怎么去解决它们呢?在实际的使用过程中,又应该注意哪些东西呢。
二 问题描述
2.1 例子介绍
介绍前,我们先看一个常见的例子。
public static void main(String[] args) {
float num = 0;
for(int i =0;i< 100;i++){
num+= 0.1;
}
System.out.println(num);
}
这是一份用JAVA语言写成的小程序,逻辑十分简单易懂,所要实现的结果无非是将0.1相加100次之后再输出。我想不需要计算机来计算,我们自己心算就能立刻得出答案—10。那么计算机会交给我们一份什么样的答案呢?下面我们将这份JAVA代码编译并且运行一下。
答案一输出,就让人大吃一惊。怎么计算机还不如人的心算吗?但事实上代码是正确的,机器也是运行如常的。那么究竟是为什么计算机的运算输给了人的心算呢?这就引出了下一个问题,计算机是如何处理小数的呢?如果我们了解计算机处理小数的机制,那么这一切的迷就能够解开。
三 基本原理
首先我们了解下数字的格式。
3.1 数字的格式
一个程序可以看做是现实世界的一个数字化的模型。现实世界中的一切都可以转化为数字在计算机的世界中呈现。因此,一个不得不解决的问题就是数字是如何在计算机中表达的。机器语言全部都是数字。我们只关心现实中有意义的数字是如何在计算机中被表示的。有意义的数字大体可以分为以下三种格式。
整数格式
我们在开发的过程中遇到的大部分的数字其实都是整数。而整数在计算机中也是最容易表示的一种。我们遇到的整数都可以使用32位有符号整数来表示(Int32)。当然,如果需要,还有有符号 64 位整数数据类型(Int64)可供选择。至于和整数相对应的便是小数,而小数主要有两种表示方式。
定点格式
所谓定点格式,即约定机器中所有数据的小数点位置是固定不变的。而定点小数的最常见的例子是SQLServer中的money类型。它能够适合很多需要处理小数的情况。但是它有一个缺点,那就是由于小数点的位置固定,它能表示范围是受限的。因此出现了浮点格式。
浮点格式
浮点格式的组成则包括符号、尾数、基数和指数,通过这四部分来表示一个小数。由于计算机内部是二进制的,因此基数自然而然是2(就如十进制的基数是10一样)。因此计算机在数据中往往无需记录基数(因为总是2),而是只用符号、尾数、指数这三部分来表示。JAVA语言提供了两种使用浮点格式表示小数的数据类型,双精度浮点数double和单精度浮点数float,还有一种Decimal类型。
JAVA中浮点数遵循的是IEEE 754标准(IEEE-745浮点数表示法是一种可以精确地表示分数的二进制示法,比如1/2,1/8,1/1024):
- float单精度浮点数为32位。32位的构造为:符号部分1bit、指数部分8bit以及尾数部分23bit。
- double双精度浮点数为64位。64位的构造为:符号部分1bit、指数部分11bit以及尾数部分52bit。
3.2.浮点数介绍
浮点数是计算机用来表示小数的一种数据类型,采用科学计数法。在java中,double是双精度,64位,浮点数,默认是0.0d。float是单精度,32位.浮点数,默认是0.0f;float在内存中存储数据组成格式如图:
什么是指数?
比如1234.5678,这是我们常说的小数,在计算机里面就叫浮点数,用指数表示就是1.2345678E3,这里的E表示10,后面的3表示3次方。
什么是有效数字?
如果近似数的绝对误差不超过它某位数字的半个单位,那么从左到右,第一个不为零的数字起,到这位数字止,每一位数字都称为有效数字。用四舍五入法截得的近似数,其各位数字都是有效数字。表示同一个量的近似数,其有效数字越多,精确程度就越高。
float 符号位(1bit) 指数(8 bit) 尾数(23 bit)
double 符号位(1bit) 指数(11 bit) 尾数(52 bit)
float在内存中占8位,由于阶码实际存储的是指数的移码,假设指数的真值是e,阶码为E,则有E=e+(2^n-1 -1)。其中 2^n-1 -1是IEEE754标准规定的指数偏移量,根据这个公式我们可以得到 2^8 -1=127。于是,float的指数范围为-128 +127,而double的指数范围为-1024 +1023。其中负指数决定了浮点数所能表达的绝对值最小的非零数;而正指数决定了浮点数所能表达的绝对值最大的数,也即决定了浮点数的取值范围。
float的范围为-2^128 ~ +2^127,也即-3.40E+38 ~ +3.40E+38;
double的范围为-2^1024 ~ +2^1023,也即-1.79E+308 ~ +1.79E+308
3.3 精度失真
计算机在处理数据都涉及到数据的转换和各种复杂运算。
比如,不同单位换算,不同进制(如二进制十进制)换算等,很多除法运算不能除尽,比10÷3=3.3333.....无穷无尽,而精度是有限的,3.3333333x3并不等于10,经过复杂的处理后得到的十进制数据并不精确,精度越高越精确。float和double的精度是由尾数的位数来决定的,其整数部分始终是一个隐含着的“1”,由于它是不变的,故不能对精度造成影响。float:2^23 = 8388608,一共七位,由于最左为1的一位省略了,这意味着最多能表示8位数:28388608 = 16777216 。有8位有效数字,但绝对能保证的为7位,也即float的精度为7~ 8位有效数字;double:2^52 = 4503599627370496,一共16位,同理,double的精度为16~17位。
当到达一定值自动开始使用科学计数法,并保留相关精度的有效数字,所以结果是个近似数,并且指数为整数。
3.4 二进制转换
3.4.1 十进制整数转二进制
十进制整数换成二进制一般都会:1=>1 2=>10 3=>101 4=>100 5=>101 6=>110
6/2=3…0
3/2=1…1
1/2=0…1
倒过来就是110
3.4.2 十进制小数转二进制
在十进制中小数有些是无法完整用二进制表示的。所以只能用有限位来表示,从而在存储时可能就会有误差。对于十进制的小数转换成二进制采用乘2取整法进行计算,取掉整数部分后,剩下的小数继续乘以2,直到小数部分全为0。 0.25的二进制
0.252=0.5 取整是0 0.52=1.0 取整是1
即0.25的二进制为 0.01 ( 第一次所得到为最高位,最后一次得到为最低位)
0.8125的二进制
0.81252=1.625 取整是1 0.6252=1.25 取整是1
0.252=0.5 取整是0 0.52=1.0 取整是1
即0.8125的二进制是0.1101(第一次所得到为最高位,最后一次得到为最低位)
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.4*2=0.8======取出整数部分0
0.8*2=1.6======取出整数部分1
0.6*2=1.2======取出整数部分1 接下来会无限循环
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.1转化成二进制是:0.0001 1001 1001 1001…(无限循环)
0.1 => 0.0001 1001 1001 1001…(无限循环)
同理0.2的二进制是0.0011 0011 0011 0011…(无限循环)
而存储结构中的尾数部分最多只能表示 53 位。为了能表示 0.1,只能模仿十进制进行四舍五入,但二进制只有 0 和 1 , 于是变为 0 舍 1 入 。 因此,0.1 在计算机里的二进制表示形式如下:
0.1 => 0.0001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 101
0.2 => 0.0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 001 用标准计数法表示如下:
0.1 => (−1)0 × 2^4 × (1.1001100110011001100110011001100110011001100110011010)2
0.2 => (−1)0 × 2^3 × (1.1001100110011001100110011001100110011001100110011010)2
在计算浮点数相加时,需要先进行 “对位”,将较小的指数化为较大的指数,并将小数部分相应右移:
最终,“0.1 + 0.2” 在计算机里的计算过程如下:
经过上面的计算过程,0.1 + 0.2 得到的结果也可以表示为:
(−1)0 × 2−2 × (1.0011001100110011001100110011001100110011001100110100)2=>.0.30000000000000004
通过 JS 将这个二进制结果转化为十进制表示:
(-1)0 * 2-2 * (0b10011001100110011001100110011001100110011001100110100 * 2**-52); //0.30000000000000004
到这里我们可以看出:0.1 和 0.2 在转换为二进制时就发生了一次精度丢失,而对于计算后的二进制又有一次精度丢失 。因此,得到的结果是不准确的。
到这里,也解释了开始的时候,为什么100个 0.1 相加的结果并不等于10了,而是等于 10.000002
那么实际应用过程中我们如何去解决这个呢?
四 解决方案
4.1 使用bigDecimal
JAVA中可以使用BigDecimal类来处理浮点类型的计算问题。如下:
public static void main(String[] args) {
float num = 0;
for(int i =0;i< 100;i++){
num+= 0.1;
}
System.out.println("100个0.1相加结果: " + num);
Double d = 0.3d;
System.out.println("0.3浮点数打印结果: " + d);
BigDecimal decimal = new BigDecimal(d);
System.out.println("0.3BigDecimal打印结果 " + decimal);
BigDecimal decimal1 = new BigDecimal("0.3");
System.out.println("0.3 String BigDecimal打印结果 " + decimal1);
BigDecimal decimal2 = new BigDecimal(d.toString());
System.out.println("0.3 String BigDecimal打印结果 " + decimal2);
Double d1 = 0.1;
Double d2 = 0.2;
System.out.println("0.1 + 0.2 = " + (d1 + d2));
BigDecimal bigDecimal = new BigDecimal(d1);
BigDecimal bigDecima2 = new BigDecimal(d2);
System.out.println("0.1big + 0.2big = " + (bigDecimal.add(bigDecima2)));
BigDecimal bigDecima3 = new BigDecimal(d1.toString());
BigDecimal bigDecima4 = new BigDecimal(d2.toString());
System.out.println("0.1bigStr + 0.2bigStr = " + (bigDecima3.add(bigDecima4)));
}
运行结果如下:
100个0.1相加结果: 10.000002
0.3浮点数打印结果: 0.3
0.3BigDecimal打印结果 0.299999999999999988897769753748434595763683319091796875
0.3 String BigDecimal打印结果 0.3
0.3 String BigDecimal打印结果 0.3
0.1 + 0.2 = 0.30000000000000004
0.1big + 0.2big = 0.3000000000000000166533453693773481063544750213623046875
0.1bigStr + 0.2bigStr = 0.3
通过结果可以看出:由于double不能精确表示为0.3(任何有限长度的二进制),因此用double构造函数传递的值不完全等于0.3。使用bigdecimal时,必须使用String字符串参数构造方法来创建它。在使用BigDecimal时,尽量使用String的构造方法。使用double或float的构造方法,存在精度丢失问题。
4.2 先扩大再缩小
先扩大10^n倍,计算完成后再缩小10^n倍;
float no = 0.1f;
no = no * 10;
float result = 0;
for(int i =0;i< 100;i++){
result+= no;
}
System.out.println("100个no相加结果: " + result);
System.out.println("100个no相加结果: " + result/10);
运行结果:
100个no相加结果: 100.0
100个no相加结果: 10.0
不难看出,通过这种方案也解决了开始的那个精度丢失问题。因此,在实际的数据库应用场景中,大部分涉及到金额的都是存储最小单位的整数,全部通过内存进行计算,尽可能的避免精度丢失问题。
五 为什么bigDecimal可以做到精度不丢失
BigDecimal是不可变的,可以用来表示任意精度的带符号十进制数。double的问题是从小数点转换到二进制丢失精度,二进制丢失精度。BigDecimal在处理的时候把十进制小数扩大N倍让它在整数上进行计算,并保留相应的精度信息。