Skip to content

同一组数,换个顺序求和,结果就不一样了 ​

1000 万笔金额用 double 求和,单线程算一遍,再分成 4 段各自求和后合并,两个结果在小数点后第三位不同。两边的代码都没有写错,而且两个结果都不等于精确的和。

double 的加法每做一次,都要把结果舍入到最近的一个可表示的值。舍入发生在哪一步,取决于数是按什么顺序、怎么分组加起来的。所以浮点求和的结果不只由数据决定,还由求和的顺序决定:换一个并行度、换一种分块方式、把数据排个序,和就可能变。

本文在 JDK 25.0.4 上把这件事拆成几个可以逐一确认的现象,最后给出按用途选择的做法。全部是单线程的确定性运算,实验见文末。

一、0.1 存进去的时候就已经不是 0.1 ​

double 用 52 位二进制小数加一个指数表示数值。十进制的 0.1 写成二进制是无限循环小数,只能截到最近的一个可表示值:

java
new BigDecimal(0.1)
// 0.1000000000000000055511151231257827021181583404541015625
0.1 + 0.2          // 0.30000000000000004
0.1 + 0.2 == 0.3   // false

把 0.1 累加 10 次,得到的是 0.9999999999999999,与 1.0 不相等。每一个 0.1 都带着一点表示误差,每一次加法又要再舍入一次。

这一层误差很小,相对大小在 10 的负 16 次方量级。单看它不会造成业务上可见的问题,问题出在后面:误差怎么随着求和累积,以及累积的方式为什么和顺序有关。

二、大数会吞掉小数 ​

可表示的 double 在数轴上不是均匀分布的。数值越大,相邻两个可表示值之间的间距越大:1 附近约为 2.2e-16,1e8 附近约为 1.5e-8,到 1e16 附近已经是 2。

先加大数:1e16,然后 +1.0 十次1e16+2+4+6+8+10+121e16 + 1 不可表示,舍入回 1e16结果 1e16先加小数:1.0 十次得到 10,然后 +1e161e16+2+4+6+8+10+1210 + 1e16:落在可表示的值上1e16 + 10间距随数值增大而翻倍:1 附近是 2.2e-16,1e8 附近是 1.5e-8,1e16 附近是 2float 只有 24 位有效位:16777216(2^24)之后间距就是 2,+1.0f 不再起作用
图 1 · 1e16 附近相邻两个 double 相差 2。1e16 + 1 正好落在两个可表示的值中间,按「舍入到偶数」回到 1e16,加多少次都一样。先把 10 个 1.0 加成 10(精确),再加 1e16,结果 1e16 + 10 可以表示。两种顺序都没有出错,差别来自每一步都要把中间结果舍入到最近的可表示值。

于是:

java
double a = 1e16;
for (int i = 0; i < 10; i++) a += 1.0;   // 10000000000000000

double b = 0;
for (int i = 0; i < 10; i++) b += 1.0;
b += 1e16;                               // 10000000000000010

同样的 11 个数,先加大的,10 个 1.0 全部丢失;先加小的,一个都没丢。这也说明浮点加法不满足结合律:(0.1 + 0.2) + 0.3 是 0.6000000000000001,0.1 + (0.2 + 0.3) 是 0.6。

累加器本身就是那个「大数」。逐个累加时,和越加越大,后面每一个加数相对于它越来越小,被舍掉的低位也越来越多。float 上这个现象来得更早:

java
float f = 0;
for (int i = 0; i < 20_000_000; i++) f += 1.0f;   // 16777216

float 只有 24 位有效位,加到 16777216(2 的 24 次方)之后,+1.0f 落在两个可表示值的正中间,被舍入回原值,后面 300 多万次加法没有任何效果。用 float 累加 1000 万个 0.1f,实测得到 1087937.0,而不是 100 万。

三、分块方式不同,和就不同 ​

准备 1000 万笔金额,每笔是 0.01 到 9999.99 之间的两位小数(固定种子生成),先用 long 按分累加得到真正想要的合计:49996274454.21。再把每笔金额存成 double,用几种方式求和,与这些 double 的精确和(用 BigDecimal 逐个相加)比较。

逐个累加的误差是 +0.0024。然后模拟并行归约:把数组切成若干块,每块各自从 0 开始累加,最后把各块的和加起来。

-0.002-0.001精确和+0.001+0.0021 块(逐个累加)2 块3 块4 块6 块8 块16 块Kahan、DoubleStream.sum():-0.0000009两两归并:-0.0000085每个结果都是「对」的浮点运算,只是舍入发生的位置不同保留两位小数后,这 7 个结果在本组数据上相同;数据量或金额再大,差异会进到分位用 float 逐个累加同一组数:49472577536,比精确值少约 5.2 亿
图 2 · 实测:同样的 1000 万个 double(两位小数的金额,合计约 500 亿),分成 1、2、3、4、6、8、16 块各自累加再合并,得到 7 个互不相同的和,与精确值相差 -0.0019 到 +0.0024。补偿求和(Kahan)的误差是 -0.0000009。分块数由线程数或并行度决定时,同一份数据在不同机器上会算出不同的和。

7 种分块数,7 个不同的结果。原因和上一节相同:每一块的累加器从 0 开始,大小不同,舍掉的低位就不同。

这对应着几种实际会遇到的情况:

  • 并行流与 Fork/Join。DoubleStream.parallel().sum() 怎么切分取决于数据源和公共池的并行度。DoubleStream.sum 的文档明确写着,浮点和既取决于输入值也取决于加法顺序,而这个方法的加法顺序有意不作规定。同一份数据在 4 核和 16 核的机器上可以得到不同的和。
  • 分片汇总。按分库、分区或时间窗分别 SUM 再相加,与全量一次 SUM,在浮点列上结果可以不同。
  • 排序。把同一组数升序排序后再逐个累加,实测误差从 +0.0024 变成 +0.0015。常见的说法是「从小到大加更准」,在这组数据上确实好一些,但仍然远不如下一节的做法。

对结果做精确相等的比较(对账、回归测试里的 assertEquals(expected, actual)、缓存键)在这些情况下都会失败,而两边的代码都没有错。

四、补偿求和 ​

每一次加法丢掉的低位是可以算出来的。Kahan 求和多用一个变量把它记下来,在下一次加法时补回去:

java
double sum = 0, c = 0;          // c:上一步丢掉的部分
for (double x : values) {
    double y = x - c;           // 先把上次丢的补上
    double t = sum + y;         // 这一步会丢掉 y 的低位
    c = (t - sum) - y;          // 算出刚才丢了多少
    sum = t;
}

同样的 1000 万个数,Kahan 的误差是 -0.0000009,比逐个累加小了三个数量级。两两归并(递归地把数组对半分,各自求和后相加)不需要额外变量,误差是 -0.0000085,介于两者之间。

顺序流的 DoubleStream.sum() 实测误差与 Kahan 完全相同。JDK 的文档只说这个方法「可以」用补偿求和之类的技术实现,没有把它作为承诺,所以结果可以拿来用,但不要把「与手写循环的结果不同」当成缺陷。

补偿求和也有失效的时候。当某个加数比当前的和大得多时,Kahan 记下的补偿项本身会被吞掉:

java
double[] v = {1.0, 1e100, 1.0, -1e100};   // 精确和是 2
// 逐个累加 0.0,Kahan 0.0,DoubleStream.sum() 0.0,Neumaier 2.0

Neumaier 的改进是每一步比较和与加数的大小,把较小的那个的低位记进补偿项,最后再一次性加回去。在量级相差悬殊、正负相消的数据上(例如先加后减的流水、物理量的差分),它比 Kahan 可靠;在上面那组金额数据上,两者的误差相同。

需要说明的是,补偿求和减小的是累加过程中的舍入误差。输入本身的表示误差它管不了:这组数据里,1000 万个 double 的精确和与「按分累加」的合计相差约 -1.1e-9,这是把两位小数存成 double 时带进来的。

五、整数经过 double 也会丢 ​

double 的 52 位小数加上隐含的最高位,一共能精确表示 53 位的整数。超过 2 的 53 次方(9007199254740992)之后,不是每个整数都能表示:

java
long id = 9007199254740993L;
(long) (double) id        // 9007199254740992

这在两类地方会碰到:

  • 64 位 ID 经过只有双精度数值的环境。雪花算法之类的 ID 通常超过 2 的 53 次方,JSON 的数值到了 JavaScript 里按双精度解析,末几位会变。对外的接口把这类 ID 序列化成字符串。
  • 把 long 的计数或金额转成 double 做聚合。3 个 9007199254740993 用 double 累加得到 27021597764222976,比用 long 累加少 3。

六、按用途选择 ​

金额、库存、配额这类要求分毫不差的量,不用浮点。 两种常规做法:

  • 用 long 存最小单位(分、厘)。加减是精确的,溢出边界是 9.2e18,够用;需要按比例拆分时,自己决定余数归谁。
  • 用 BigDecimal。有三个容易出错的地方:new BigDecimal(0.1) 得到的是那个 55 位小数,应当用 BigDecimal.valueOf(0.1) 或字符串构造;equals 会比较小数位数,new BigDecimal("1.10").equals(new BigDecimal("1.1")) 是 false,比较数值用 compareTo(它在 HashSet 与 TreeSet 里因此表现不同,见 同一个金额,HashSet 里是两个,TreeSet 里是一个);除法要指定精度和舍入方式,否则除不尽时抛异常。

舍入方式的选择、一笔钱拆给多方之后合计对不上的问题,见对账差了一分钱。

数据库里同理:金额列用 DECIMAL,FLOAT 与 DOUBLE 是近似类型,对它们做 SUM 和等值比较会遇到本文的全部现象。

统计量、度量、模型特征这类本身就是近似值的量,用 double,但要管住三件事:

  1. 不用 float 做累加器。数据是 float 时也用 double 累加。
  2. 数据量大或量级差异大时用补偿求和或两两归并。Java 里顺序的 DoubleStream.sum()、DoubleSummaryStatistics 可以直接用。
  3. 比较时用容差,不用 ==。容差按量级取相对值(例如 Math.abs(a - b) <= 1e-9 * Math.max(Math.abs(a), Math.abs(b))),固定的绝对容差在数值很大或很小时都会失效。

需要结果可复现(对账、审计、回归基线)时,把求和顺序固定下来。 并行度、分片方式、排序规则都要成为计算定义的一部分;或者把计算改到整数、BigDecimal 上,让顺序不再影响结果。

另外两个值会让求和悄悄失去意义:数组里只要有一个 NaN,和就是 NaN,而 NaN == NaN 是 false,用 == 查不出来,要用 Double.isNaN;0.0 == -0.0 是 true,但 Double.compare(0.0, -0.0) 是 1,两者作为 Double 放进 HashMap 是两个不同的键。


配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权