现在假设你负责一个广告公司的结算系统,你需要统计下月度点击收入,生成一个月度报告。假设有2000w个点击,每个点击平均1元,我们用小学数学计算就知道总收入是2000w。但是我们用计算机累加就会出问题了。如果我们用float存储数据,float可以表示的数据范围浮点数美丽的表象(为什么要慎用浮点数)_float浮点数美丽的表象(为什么要慎用浮点数)_浮点数_02,看起来绝对够啊!那让我们写个程序测试下。

public static void main(String[] args) {
int N = 20000000;
float sum = 0.0;
float price = 1.0f;
for (int i = 0; i < N; i++) {
sum += price;
}
System.out.println("sum is " + sum);
}
1.6777216E7

浮点数美丽的表象(为什么要慎用浮点数)_float_03


What?输出应该是2000w的,但为什么少了3222784.0,如果在生成环境中,这意味着我们的钱凭空消失了3222784.0!why?

这其实是float累加过程中精度丢失导致的,要理解这点我们首先要理解什么是浮点数。首先我们了解数在计算机中是如何表示的,因为计算机只能理解0和1两个数,所以一切信息都是用二进制表示的。如何保存更多的信息就是计算机设计者面临的挑战。

在各个编程语言中的int都有4个字节32位,32位二进制最多有浮点数美丽的表象(为什么要慎用浮点数)_计算机原理_04种状态,也就是意味着最多能表示浮点数美丽的表象(为什么要慎用浮点数)_计算机原理_04个数,但这里还需要流出1位做为符号位来标识正负。所以能标识的最大数是2147483647(01111111111111111111111111111111),最小数是-2147483647(11111111111111111111111111111111),这个时候其实0有两个,一个+0,一个-0,两个表示的意义其实是一样的,我们干脆用-0代表最小数-2147483648。所以最终int的表示范围就是-2147483648~2147483647了,这就是定点数,它只能用来表示整数。

  

如何表示小数?小数的特点是小数点前后的位数是不固定的,这个小数点是浮动的,这就是浮点数这个名词的由来。为了表示浮点数,我们可以把一个数拆分成两个部分,数值部分和指数部分,比如11.16可以表示为1116乘以浮点数美丽的表象(为什么要慎用浮点数)_数位_06 ,0.1表示为1乘以浮点数美丽的表象(为什么要慎用浮点数)_数据_07。没错,在计算机中也是这么做的,只不过用的是2进制而已。

  

float使用了4个字节来存储数据,总共32位,最高位作为符号位,最高2-9位作为指数位,最后的23位才作为有效数位。注意,23位之前有个1被省略掉了,所以他的有效位其实是24位,float所能表示的有效数值只有浮点数美丽的表象(为什么要慎用浮点数)_float_08,大概8位数,因此它不能标识超过8位的有效数字,否则会丢失精度,这就是浮点数美丽的表象。​​​https://www.h-schmidt.net/FloatConverter/IEEE754.html​​ 这里可以可视化float数值的二进制表示,方便你理解float。

  

让我们继续来看为什么上面的代码会少数据。这就得先理解浮点数的加法是怎么做的。当两个float数相加时,计算机首先会对齐两个数的指数位,向指数位比较大的一个靠拢,这时候比较小的float数的有效数位就要右移。如果其中一个的数的指数位太大,就有可能让指数位比较小的数的有效数一直右移,甚至变成0。

  

上面的代码一直+1,当sum大于16777216之后,1.0为了和sum的指数位对其,它的有效数会右移动24次,上面说到float的有效数只有24位,所以它会完全变成0。所以上面sum大于16777216之后它再加1也相当于+0。

浮点数美丽的表象(为什么要慎用浮点数)_数位_09


如果我们换做double类型呢?这次没有出现精度损失。但并不意外这就不会出现精度损失,double比float有更多的精确位,但也不是无限的,在数据非常大时也会丢失精度。在金融行业,即便是非常非常小的精度损失,也会让客户对你失去信任,所以要保证100%的精度。

如何保证? 有个叫​​Kahan Summation算法​​,可以保证不会出现精度损失,代码如下,测试确实不会损失精度。

public class KahanSummation {
public static void main(String[] args) {
int N = 20000000;
float sum = 0.0f;
float c = 0.0f;
for (int i = 0; i < N; i++) {
float x = 1.0f;
float y = x - c;
float t = sum + y;
c = (t-sum)-y;
sum = t;
}
System.out.println("sum is " + sum);
}
}

这个算法虽然好用,但看起来很复杂,所以在实际使用中很少使用。因为有更简单的方法。当然,从古至今解决问题最好最彻底的方式就是避免问题的发生。我们直接不使用浮点型,而是转而用long。用最小的货币单位分来计量,long的取值范围是-9223372036854775808~9223372036854775807,绝对够用了。

本文参考了大量极客时间《深入理解计算机组成原理》的内容