现在假设你负责一个广告公司的结算系统,你需要统计下月度点击收入,生成一个月度报告。假设有2000w个点击,每个点击平均1元,我们用小学数学计算就知道总收入是2000w。但是我们用计算机累加就会出问题了。如果我们用float存储数据,float可以表示的数据范围− 2 128 -2^{128}−2
128
到 2 128 2^{128}2
128
,看起来绝对够啊!那让我们写个程序测试下。
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
What?输出应该是2000w的,但为什么少了3222784.0,如果在生成环境中,这意味着我们的钱凭空消失了3222784.0!why?
这其实是float累加过程中精度丢失导致的,要理解这点我们首先要理解什么是浮点数。首先我们了解数在计算机中是如何表示的,因为计算机只能理解0和1两个数,所以一切信息都是用二进制表示的。如何保存更多的信息就是计算机设计者面临的挑战。
在各个编程语言中的int都有4个字节32位,32位二进制最多有2 32 2^{32}2
32
种状态,也就是意味着最多能表示2 32 2^{32}2
32
个数,但这里还需要流出1位做为符号位来标识正负。所以能标识的最大数是2147483647(01111111111111111111111111111111),最小数是-2147483647(11111111111111111111111111111111),这个时候其实0有两个,一个+0,一个-0,两个表示的意义其实是一样的,我们干脆用-0代表最小数-2147483648。所以最终int的表示范围就是-2147483648~2147483647了,这就是定点数,它只能用来表示整数。
如何表示小数?小数的特点是小数点前后的位数是不固定的,这个小数点是浮动的,这就是浮点数这个名词的由来。为了表示浮点数,我们可以把一个数拆分成两个部分,数值部分和指数部分,比如11.16可以表示为1116乘以1 0 − 2 10^{-2}10
−2
,0.1表示为1乘以1 0 − 1 10^{-1}10
−1
。没错,在计算机中也是这么做的,只不过用的是2进制而已。
float使用了4个字节来存储数据,总共32位,最高位作为符号位,最高2-9位作为指数位,最后的23位才作为有效数位。注意,23位之前有个1被省略掉了,所以他的有效位其实是24位,float所能表示的有效数值只有2 24 2^{24}2
24
,大概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。
如果我们换做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,绝对够用了。