问题提出:12.0f - 11.9f = 0.10000038,“减不尽”为什么?

本文将详细剖析浮点型运算造成精度丢失的原因。

1. 小数的二进制表示问题

首先我们需要明确以下两个基础问题:

(1) 十进制整数如何转化为二进制数

算法很简单,采用“除 2 取余,逆序排列”法。举个例子,将 11 表示成二进制数:

11 / 2 = 5 ... 余 1
 5 / 2 = 2 ... 余 1
 2 / 2 = 1 ... 余 0
 1 / 2 = 0 ... 余 1

当商为 0 时结束。将余数从下往上排列,11 的二进制表示为:1011

注意:只要遇到除以后的结果为 0 就结束了。所有的整数除以 2 一定能够最终得到 0。换句话说,所有的整数转变为二进制数的算法会不会无限循环下去呢?绝对不会。整数永远可以用二进制精确表示,但小数就不一定了。

(2) 十进制小数如何转化为二进制数

算法是“乘 2 取整,顺序排列”,直到没有了小数为止。举个例子,0.9 表示成二进制数:

0.9 * 2 = 1.8   取整数部分 1
0.8 * 2 = 1.6   取整数部分 1  (取 1.8 的小数部分)
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   取整数部分 0
...

注意:上面的计算过程循环了,也就是说 *2 永远不可能消灭小数部分,这样算法将无限下去。很显然,小数的二进制表示有时是不可能精确的

其实道理很简单,十进制系统中能不能准确表示出 1/3 呢?同样二进制系统也无法准确表示 1/10。这也就解释了为什么浮点型减法出现了“减不尽”的精度丢失问题。

2. float 型在内存中的存储

众所周知,Java 的 float 型在内存中占 4 个字节。float 的 32 个二进制位结构如下:

float 内存存储结构 (32 bits)
-----------------------------------------------------------------------
| 31 (1bit) | 30 (1bit) | 29 ---- 23 (7bits) | 22 ---- 0 (23bits)   |
| 实数符号位 | 指数符号位 |      指数位        |       有效数位       |
-----------------------------------------------------------------------
  • 符号位:1 表示负,0 表示正。
  • 有效数位:共 24 位(其中一位是隐含的实数符号位/整数位)。

将一个 float 型转化为内存存储格式的步骤为:

  1. 先将这个实数的绝对值化为二进制格式(实数的整数部分和小数部分的二进制方法在上面已经探讨过了)。
  2. 将这个二进制格式实数的小数点左移或右移 n 位,直到小数点移动到第一个有效数字的右边。
  3. 从小数点右边第一位开始数出二十三位数字放入第 22 到第 0 位。
  4. 如果实数是正的,则在第 31 位放入"0",否则放入"1"。
  5. 如果 n 是左移得到的,说明指数是正的,第 30 位放入"1"。如果 n 是右移得到的或 n=0,则第 30 位放入"0"。
  6. 如果 n 是左移得到的,则将 n 减去 1 后化为二进制,并在左边加"0"补足七位,放入第 29 到第 23 位。如果 n 是右移得到的或 n=0,则将 n 化为二进制后在左边加"0"补足七位,再各位求反,再放入第 29 到第 23 位。

举例说明:11.9 的内存存储格式

  1. 将 11.9 化为二进制后大约是 1011.111001100110011001100...
  2. 将小数点左移三位到第一个有效位右侧:1.011 11100110011001100110。保证有效位数 24 位,右侧多余的截取(误差在这里产生了)。
  3. 这已经有了二十四位有效数字,将最左边一位"1"去掉,得到 011 11100110011001100110 共 23bit。将它放入 float 存储结构的第 22 到第 0 位。
  4. 因为 11.9 是正数,因此在第 31 位实数符号位放入"0"。
  5. 由于我们把小数点左移,因此在第 30 位指数符号位放入"1"。
  6. 因为我们是把小数点左移 3 位,因此将 3 减去 1 得 2,化为二进制,并补足 7 位得到 0000010,放入第 29 到第 23 位。

最后表示 11.9 为:

0 1 0000010 011 11100110011001100110

再举一个例子:0.2356 的内存存储格式

  1. 将 0.2356 化为二进制后大约是 0.00111100010100000100100000
  2. 将小数点右移三位得到 1.11100010100000100100000
  3. 从小数点右边数出二十三位有效数字,即 11100010100000100100000 放入第 22 到第 0 位。
  4. 由于 0.2356 是正的,所以在第 31 位放入"0"。
  5. 由于我们把小数点右移了,所以在第 30 位放入"0"。
  6. 因为小数点被右移了 3 位,所以将 3 化为二进制,在左边补"0"补足七位,得到 0000011,各位取反,得到 1111100,放入第 29 到第 23 位。

最后表示 0.2356 为:

0 0 1111100 11100010100000100100000

将一个内存存储的 float 二进制格式转化为十进制的步骤:

  1. 将第 22 位到第 0 位的二进制数写出来,在最左边补一位"1",得到二十四位有效数字。将小数点点在最左边那个"1"的右边。
  2. 取出第 29 到第 23 位所表示的值 n。当 30 位是"0"时将 n 各位求反。当 30 位是"1"时将 n 增 1。
  3. 将小数点左移 n 位(当 30 位是"0"时)或右移 n 位(当 30 位是"1"时),得到一个二进制表示的实数。
  4. 将这个二进制实数化为十进制,并根据第 31 位是"0"还是"1"加上正号或负号即可。

3. 浮点型的减法运算

浮点加减运算过程比定点运算过程复杂。完成浮点加减运算的操作过程大体分为四步:

  1. 0 操作数的检查
    如果判断两个需要加减的浮点数有一个为 0,即可得知运算结果而没有必要再进行有序的一系列操作。
  2. 比较阶码(指数位)大小并完成对阶
    两浮点数进行加减,首先要看两数的 指数位 是否相同,即小数点位置是否对齐。若两数指数位相同,表示小数点是对齐的,就可以进行尾数的加减运算。反之,若两数阶码不同,表示小数点位置没有对齐,此时必须使两数的阶码相同,这个过程叫做 对阶

    如何对阶 (假设两浮点数的指数位为 $E_x$ 和 $E_y$):
    通过尾数的移位以改变 $E_x$ 或 $E_y$,使之相等。由于浮点表示的数多是规格化的,尾数左移会引起最高有位的丢失,造成很大误差;而尾数右移虽引起最低有效位的丢失,但造成的误差较小。因此,对阶操作规定使尾数右移,尾数右移后使阶码作相应增加,其数值保持不变。很显然,一个增加后的阶码与另一个相等,所增加的阶码一定是小阶。因此在对阶时,总是使 小阶向大阶看齐,即小阶的尾数向右移位 ( 相当于小数点左移 ),每右移一位,其阶码加 1,直到两数的阶码相等为止,右移的位数等于阶差 $\Delta E$。

  3. 尾数(有效数位)进行加或减运算
    对阶完毕后就可有效数位求和。不论是加法运算还是减法运算,都按加法进行操作,其方法与定点加减运算完全一样。
  4. 结果规格化并进行舍入处理
    (略)

浮点数的加减法具体见:http://www.zzslxx.com/wmy/jy/Chap02/2.7.1.htm

4. 计算 12.0f - 11.9f

12.0f 的内存存储格式为:

0 1 0000010 10000000000000000000000

11.9f 的内存存储格式为:

0 1 0000010 011 11100110011001100110

可见两数的指数位完全相同,只要对有效数位进行减法即可。

12.0f - 11.9f 结果:

0 1 0000010 00000011001100110011010

将结果还原为十进制为:

0.000 11001100110011010 = 0.10000038

说明:本文关于 float 内存存储结构的描述(1 位符号 +1 位指数符号 +7 位指数值 +23 位尾数)为便于理解原理的简化模型。实际 Java float 遵循 IEEE 754 标准,采用 1 位符号位 +8 位阶码(偏置值表示,无独立符号位)+23 位尾数的结构。尽管存储细节有所不同,但二进制无法精确表示某些十进制小数从而导致精度丢失的核心结论是一致的。