聊一聊Java中double精度去哪了

缘由

前段时间, 因为要测试一个刚出炉的高频策略, 放实盘去跑吧, 怕出岔, 所以写了个简单的回测系统, 跑一遍历史数据. 其中有一部分是关于撮合系统, 简陋了点, 还算能跑得起来, 几个用例下来, 也没什么问题, 接着增加历史数据量, 居然出现了负数, 简直不可能发生的事情居然出现了, 虽然都是小金额的偏差, 但是毕竟跟钱打交道, 必须谨慎, 况且现在比特币那么贵, 丝毫偏差都是不允许的!

当然, 后面就是苦逼的找bug, 逻辑没问题, 发狠的, 把所有的数据都打印出来, 日志一页一页没有尽头, 心里发麻, 硬着头皮一条条排查, 人品不错, 开头就发现一条异常数据, 0.05+0.01=0.060000000000000005, 瞬间明白, google it, 才发现Java的double原来精度那么蛋疼. 网上推荐BigDecimal代替double, 果然不错, 那就用BigDecimal替换. 等所有的double都换之后, 狗血的事情发生了, BigDecimal是如此的慢, 以至于跑一个用例花了之前N倍的时间, 怎么办, 只能用一个折中的办法, 数值表示仍然用double, 数值计算用BigDecimal, 于是乎, 有了如下的一个四则运算工具类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
package com.iovi.math;

import java.math.BigDecimal;
import java.math.RoundingMode;

/**
* @author sunnycomes
*
*/
public class MathUtil {

/**
* Return a double whose value is a add b and the scale is specified to 4.
*
* @param a the first double value
* @param b value to be added to a
* @return a + b, the scale is 4
*/
public static double add(double a, double b) {
BigDecimal aa = new BigDecimal(a + "");
BigDecimal bb = new BigDecimal(b + "");

aa = aa.add(bb);
aa.setScale(4, RoundingMode.FLOOR);

return aa.doubleValue();
}

/**
* Return a double whose value is a subtract b and the scale is specified to 4.
*
* @param a the first double value
* @param b value to be subtracted from a
* @return a - b, the scale is 4
*/
public static double subtract(double a, double b) {
BigDecimal aa = new BigDecimal(a + "");
BigDecimal bb = new BigDecimal(b + "");

aa = aa.subtract(bb);
aa.setScale(4, RoundingMode.FLOOR);

return aa.doubleValue();
}

/**
* Return a double whose value is a multiply b and the scale is specified to 4.
*
* @param a the first double value
* @param b value to be multiply by a
* @return a * b, the scale is 4
*/
public static double multiply(double a, double b) {
BigDecimal aa = new BigDecimal(a + "");
BigDecimal bb = new BigDecimal(b + "");

aa = aa.multiply(bb);
aa.setScale(4, RoundingMode.FLOOR);

return aa.doubleValue();
}

/**
* Return a double whose value is a divide b and the scale is specified to 4.
*
* @param a the first double value
* @param b value by which a is to be divided
* @return a + b, the scale is 4
*/
public static double divide(double a, double b) {
BigDecimal aa = new BigDecimal(a + "");
BigDecimal bb = new BigDecimal(b + "");

aa = aa.divide(bb);
aa.setScale(4, RoundingMode.FLOOR);

return aa.doubleValue();
}
}

当然, 这里我想做的, 不仅仅只是找到暂时的解决方案, 更多的事想明白为什么double会出现这样的问题, 如果是其他语言, 例如C, 会不会也出现这样的问题, 我试着用同一组数据, 发现C对于这组数据是没有出现异常的, 那么, Java为什么会这么与众不同呢? 网上说其他语言也有类似的情况, 那么我们该如何避免这些地雷呢? 既然Java的double问题那么多, 我当前系统用double表示数值, 会不会出现偏差? 如果Java中采用BigDecimal效率这么低, 那些大型交易所, 性能要求极高, 如何控制延迟呢? 或者还有其他更好的技术?

疑惑

继续先前的话题, double奇葩的精度. 试完了C, 为何不看看其他的语言, 如python, 毕竟基本上现在的编程语言基本采用IEEE754标准, 存储方式相同, 计算由CPU完成, 结果为什么会不同? 果然使用python得出的结论也是0.060000000000000005, 不免怀疑之前C的结果. C的语句是这样的:

printf("%lf", 0.01 + 0.05);  #输出结果为0.060000.

是精度, 保留的精度不对, 于是设定保留小数点后18位, 因为java的输出小数点后有18位:

printf("%.18lf", 0.01 + 0.05);  #输出结果为0.060000000000000005.

这才是真相, 线索越来越多, 0.01+0.05这道题, 跟语言无关, 而是跟IEEE754标准有关? IEEE754标准见下图.

IEEE754 Double

这个实验当中, 结果比预期的多, 那么有没有比预期的少?

System.out.println(0.09 + 0.01);  #输出结果为0.09999999999999999.

但是, 如果取小数点后10位以内, 结果还是好的, 而且一般现实也不需要那么高的精度, 但是, 倘若每次计算都做一次round, 势必性能大打折扣!然而不做round, 的确是不严谨的作法, 如果是支付场景, 0.09+0.01<0.1, 那么这次交易就完成不了, 这是绝对不能容忍的错误!

发现

谈了现象, 再谈谈原因, 其实很简单, 10进制的世界, 对于0, 1的世界的计算机来说, 有点悬. 这里我们回顾一下小数转二进制的计算规则:乘2取整法, 以0.05为例:

1. 0.05 * 2 % 1 = 0.1(0)
2. 0.1 * 2 % 1 = 0.2(0)
3. 0.2 * 2 % 1 = 0.4(0)
4. 0.4 * 2 % 1 = 0.8(0)
5. 0.8 * 2 % 1 = 0.6(1)
6. 0.6 * 2 % 1 = 0.2(1) // 到达这里的时候, 又回到先前第2步状态

如果一直算下去, 结果会是:0.00001100110011…, 0011会一直重复, 用科学技术法表示:$1.1001100110011… * (2^{-5})$, 用IEEE754表示,exp=1023+(-5)=1018, fraction部分, 整数1去掉, 二进制小数第53位做0舍1入操作, 则:

s=0(正数)
exp==01111111010
frac=1001100110011001100110011001100110011001100110011010
binary=0 01111111010 1001100110011001100110011001100110011001100110011010
hex=3fa999999999999a

用相同的方式对0.01做转换, 可以发现也只能用近似的值表示。

尾数整数能表示最大值是(2^{54}-1), 即当你的整数部分大于或接近(2^{54}-1)时, 如果有小数, 则小数精度丢失非常严重. 这里可以得出一个初步结论:

在整数部分不太大的情况下, double可以保证精度丢失微乎其微;而当整数部分过大时, 小数部分会做非常粗暴省略.

接下来聊一聊精度取舍的原则, 然后判断是否适合自身项目的需求.

double之所以会产生精度的丢失, 最根本的因素是用于表示小数的二进制位数不够, 然后做round, 造成丢失.

这里我们假设能表示小数的二进制位长度为x, 那么, 在存储的时候, 如果x位后面还有内容, 将做round处理, 那么到底损失或者增加了多少? 这里验证下:

1. 如果x+1二进制位为1, 进位, 则比原数要放大一些, 假设增量t, 那么(t < 1/2^x), 以下是证明:
    - tmax = 1/2^{x+1} + 1/2^{x+2} + 1/2^{x+3} + \ldots + 1/2^{x+n}
    - 1/2^x - tmax = 1/2^x(1-1/2-1/4-1/8...) > 0

1. 如果x+1二进制位为0, 不进位, 则比原数字小一些, 假设减量d, 那么(d < 1/2^{x+1}), 证明同上.

其实, 在计算过程中, 只要考虑的(1/2^x)影响有多大即可!因为(1/2^x)都不会影响结果, (1/2^{x+1})更不会了.

假设存储的浮点数小数的最长10进制位长度为L, 我们可以通过改变量, 来判断是否产生精度影响.
这里举个例子, 令L = 4, A = ?.0004, 假设最坏的情况, A在转化为二进制后, 存储时发生了截断, 做了round, 那么:

  • 如果是增加, 最多增加(t = 1/2^x). x = 14时, (t = 1/2^x) = 0.000061035, A + t = ?.000461035, 如果从内存里读取, 然后保留4位10进制小数, 变成了?.0005, 误差产生了;x = 15时, (t = 1/2^x) = 0.000030518, 变成了?.0004, 与实际相符.

  • 如果是减少, 最多减少(d = 1/2^{x+1}). x = 14时, (d = 1/2^{x+1}) = 0.000030518, A - d = ?.000360482, 如果从内存里读取, 然后保留4位10进制小数, 变成了?.0004, .

那么, 可认为, L=4的情况下, 需要二进制小数位至少x=15, 才能保证符合要求.

从上面的例子可以看到, 当十进制小数位L+1上的加减值大于5时, 会是结果产生偏差. 这里, 可以推出一般的规律:

如果(1/2^x < 5 * 10^{-L-1}), 则能保证精度满足要求.

这里举一个比特币交易所的例子, 关于balance, price, amount的问题, price整数最大长度a, 小数精确到x位, amount整数最大长度b, 小数精确到y位, 那么理论上price * amount, 整数最多a+b位, 小数最多位x+y位, 令x=2,y=4,看是否满足实际生产,

amount<200, 000个, 则200, 000(10)=30D40(16), 二进制长度为20位, 小数部分34位, (1/2^{34}=5.82077*10^{-11}), 则小数点后9位是最大极限, 满足要求.

一般price<100,000, 则10,0000(10)=186A0(16), 二进制长度20位, 小数部分34位, 同上, 小数点后9位是最大极限, 满足要求.

那么, 对于balance, a+b=40, 则小数部分14位二进制, (1/2^{14}=6.1035*10^{-5}), 则保守取值, 3位, 小数已经不能满足x+y=6的需求, 要产生误差了.

从上面的计算也可以看出, 使用double, 保留小数最多不超过3位, 勉勉强强应付几百个亿, 那对于金融, 科学计算领域, 可以想见, double已经不能满足需求了!因此, 采用Java BigDecimail是非常有必要的.

申明:这里提到的double都是采用IEEE754规范的64位浮点数.