强转溢出与浮点数运算

java 中的数据类型转换.

在 java 中,存在两种转换的机制,默认类型转换(隐式转换)和强制类型转换。默认类型转换的规则如下:

  • byte,short,char -> int ->long ->float ->double

  • 当 byte,short,char 相互之间不能转换,它们参与运算首先将转换成 int 类型进行运算。

强制类型转换:

  • 目标类型 变量名 = (目标类型)(被转换的类型)

在进行类型转换时:

  1. 容量大的数据类型转换为容量小的数据类型时,要加上强制转换符,但可能造成精度降低或溢出;使用时要格外注意。
  2. 有多种类型的数据混合运算时,系统首先自动的将所有数据转换成容量最大的那一种数据类型,然后再进行计算。

byte 的存储范围是-128-127 的整数范围,那么如果有如下语句:

1
byte a = (byte)130;

结果会是多少呢?java 是如何处理强制类型转换的溢出处理呢?

在计算机中,所有的数据都是存储的补码形式,那么 130 首先被当成 int 型存储,四个字节 32 位,它的补码如下:0000 0000 0000 0000 0000 0000 1000 0010,转换为 byte 类型,进行截取,高字节部分去除,保留低字节部分,得到转换为 byte 类型的补码为:1000 0010,我们将其转换为源码:补码(1000 0010)->反码(1000 0001)->原码(1111 1110)为-126,所以最后的答案是-126.如果遇到其他的类型转换,也采用类似的处理方法。

我们都知道 Java 中基本数据类型中,整型的有byteshortintlong,依次占用内存空间是1、2、4、8个字节,它们的取值范围如下:

类型 字节数 取值范围
byte 1 [-128,127]
short 2 [-32768,32767]
int 4 [-2147483648,2147483647]
long 8 [-9223372036854775808,9223372036854775807]

既然数据有范围,那么就会存在数据溢出的问题,那么我们看下数据溢出了会是怎样的?

原理分析

我们知道,整型数据在计算机中都是用二进制表示的。这里我们继续拿 byte 进行举例,比如说1的二进制表示为0000 0001-1的二进制表示为1000 0001,最高位是符号位,1 表示负数,0 表示正数。

我们知道byte类型占一个字节,也就是 8bit,那么它应该能表示 128 个数字;除去最高位的符号位后,还有 7 个 bit 来表示数字,也就是[0,127]这个范围,共 128 个数字;如果加上符号位,那么byte可以表示的数的范围是[-127,-0][0,127],-0 和 0 表示的数据相同,我们进行合并,所以范围应该是[-127,127],而 java 规定的范围是[-128,127]-128怎么表示的。

其实-128就是用-0来表示的,二进制的补码表示就是1000 0000

接下来我们说下几个基本概念:原码、反码和补码。

原码、反码和补码

原码:就是数据的二进制表示形式,最高位是符号位,1 表示负数,0 表示正数。

反码:正数的反码跟原码相同;负数的反码是在原码的基础上,符号位不变,其余各位取反,1 变 0,0 变 1。

补码:正数的补码跟原码相同;负数的补码是在其反码的基础上加 1。

比如说,10的原码是0000 1010,由于它是正数,所以它的反码和补码均与原码相同。 -10的原码是1000 1010;它的反码是在原码基础上,符号位不变,其余位数取反,转换后的反码是1111 0101;补码是在反码的基础上+1,转换后的补码是1111 0110

加法运算过程拆解

在计算机的二进制计算中,减法运算也会转化为加法运算来计算。

对于10-10=0的这个运算,在实际计算过程中,会将10 - 10的操作转化为10 + (-10)。接下来我们看下具体的运算过程:

数据类型 10 -10
原码 0000 1010 1000 1010
反码 0000 1010 1111 0101
补码 0000 1010 1111 0110

得到对应的补码之后,我们对10-10的补码进行加法操作:

1
2
3
4
+ 0000 1010
——————————— = 0000 0000
1111 0110
复制代码

我们知道补码0000 0000对应的原码也为0000 0000,所以可以得出10 - 10 = 0

验证(byte)(127 +1)结果

我们接着看下byte类型的127 + 1的运算过程。

数据类型 127 1
原码 0111 1111 0000 0001
反码 0111 1111 0000 0001
补码 0111 1111 0000 0001

得到对应的补码之后,我们对相应的补码进行加法操作:

1
2
3
4
+ 0111 1111
——————————— = 1000 0000
0000 0001
复制代码

这里我们得到了1000 0000这个补码,而这个补码对应的数据就是-128,这是一个特例。

这里需要注意的是,因为使用以前的-0的补码来表示-128, 所以-128并没有原码和反码表示。(对-128 的补码表示[1000 0000]补算出来的原码是[0000 0000], 这是不正确的)。

浮点数运算和整数运算相比,只能进行加减乘除这些数值计算,不能做位运算和移位运算。

在计算机中,浮点数虽然表示的范围大,但是,浮点数有个非常重要的特点,就是浮点数常常无法精确表示。

举个栗子:

浮点数0.1在计算机中就无法精确表示,因为十进制的0.1换算成二进制是一个无限循环小数,很显然,无论使用float还是double,都只能存储一个0.1的近似值。但是,0.5这个浮点数又可以精确地表示。

因为浮点数常常无法精确表示,因此,浮点数运算会产生误差:

电脑是怎样储存一个整数的(Integer)

在讲为什么会存在浮点误差之前,先来谈谈电脑是怎么用 0 跟 1 来表示一个 整数 的,大家应该都知道二进制:例如 101 代表 $2^2 + 2^0$ 也就是 5、1010 代表 $2^3 + 2^1$ 也就是 10。

如果是一个无符号的 32 bit 整数,代表它有 32 个位置可以放 0 或 1,所以最小值就是 0000...0000 也就是 0,而最大值 1111...1111 代表 $2^{31} + 2^{30} + … + 2^1 + 2^0$ 也就是 4294967295

从排列组合的角度来看,因为每一个 bit 位都可以是 0 或 1,整个变量的值有 $2^{32}$ 种可能,所以可以 精确的 表达出 0 到 $2^{23} - 1$ 之间的任一个值,不会有任何误差。

浮点数(Floating Point)

虽然从 0 到 $2^{23} - 1$ 之间存在很多个整数,但其数量终究是 有限 的,就是 $2^{32}$ 那么多个而已;但浮点数就不同了,我们可以这样想:在 1 到 10 这个区间中只有十个整数,却有 无穷多个 浮点数,例如 5.1、5.11、5.111 等等,怎么也列举不完。

但因为在 32 bit 的空间中就只有 2³² 种可能性,为了把所有浮点数都塞在这个 32 bit 的空间里面,许多 CPU 厂商发明了各种浮点数的表示方式,但如果每家 CPU 的格式都不一样也很麻烦,所以最后是以 IEEE 发布的 IEEE 754 作为通用的浮点数运算标准,现在的 CPU 也都遵循这个标准进行设计。

IEEE 754

IEEE 754 里面定义了很多东西,其中包括单精度(32 bit)、双精度(64 bit)和特殊值(无穷大、NaN)的表示方式等等

规格化

以 8.5 这个浮点数来说,如果要变成 IEEE 754 格式的话必须先做一些规格化处理:把 8.5 拆成 8 + 0.5 也就是 $2^3 + (\cfrac{1}{2})^1$ ,接着写成二进位变成 1000.1,最后再写成 $1.0001 \times 2^3$,与十进制的科学记数法很相似。

单精度浮点数

在 IEEE 754 中 32 bit 浮点数被拆分成三个部分,分别是 数符(sign)、阶码(exponent) 和尾数(fraction),加起来总共是 32 个 bit

  • 数符(sign):最左侧的 1 bit 代表正负号,正数的话 sign 就为 0,反之则是 1
  • 阶码(exponent):中间的 8 bit 代表规格化之后的次方数,采用的是 阶码真值 +127 的格式,也就是 3 还要再加上 127 等于 130
  • 尾数(fraction):最右侧的 23 bit 放的是小数部分,以 1.0001 来说就是去掉 1. 之后的 0001

所以如果把 8.5 表示成 32 bit 格式的话应该是这样:

什么情况下会产生误差?

前面举的 8.5 的例子可以表示为 $2^3 + (\cfrac{1}{2})^1$ ,是因为 8 和 0.5 刚好都是 2 的次方数,所以完全不会产生任何精准度问题。

但如果是 8.9 的话因为没办法换成 2 的次方数相加,所以最后会被迫表示成 $1.0001110011… \times 2^3$,而且还会产生大概 $0.0000003$ 的误差,如果对结果好奇的话可以到 IEEE-754 Floating Point Converter 网站上玩玩看。

双精度浮点数

前面所讲的单精度浮点数只用了 32 bit 来表示,为了让误差更小,IEEE 754 也定义了如何用 64 bit 来表示浮点数,跟 32 bit 比起来 fraction 部分扩大了两倍多,从 23 bit 变成 52 bit,所以精准度自然会提高许多。

以刚才的 8.9 为例,用 64 bit 表示的话虽然可以变得更准,但因为 8.9 无法完全写成 2 的次方数相加,到了小数下 16 位仍然会出现误差,不过与单精度的误差 0.0000003 比起来已经小了很多

类似的情况还有像 Python 中的 1.00.999...999 是相等的、123122.999...999 也是相等的,因为他们之间的差距已经小到无法放在 fraction 里面,所以从二进制格式看来它们每一个二进制位都是一样的。

解决方法

既然浮点数的误差是无法避免的,那就只好跟它共处了,下面是两个比较常见的处理方法:

设定最大允许误差 ε (epsilon)

在某些语言会提供所谓的 epsilon,用来让你判断是不是在浮点误差的允许范围内,以 Python 来说 epsilon 的值大约是 $2.2e^{-16}$

所以你可以把 0.1 + 0.2 == 0.3 改写成 0.1 + 0.2 — 0.3 <= epsilon,这样就能避免浮点误差在运算过程中捣乱,正确的比较出 0.1 加 0.2 是不是等于 0.3 了。

当然如果系统没提供的话你也可以自己定义一个 epsilon,设定在 2 的 -15 次方左右

完全使用十进制进行计算

之所以会有浮点误差,是因为把十进制转为二进制的过程中没办法把所有的小数部分都塞进了尾数中,既然转换可能会有误差,那干脆就不转了,直接用十进制来做运算。

在 Python 里面有一个 module 叫做 decimal,在 JavaScript 中也有类似的包。它可以帮你用十进制来进行计算,就像你自己用纸笔计算 0.1 + 0.2 绝对不会出错、也不会有任何误差。

虽然用十进制进行计算可以完全避免浮点数的误差,但因为 Decimal 的十进制计算是模拟出来的,在最底层的 CPU 电路中还是在用二进制进行运算,执行起来会比原生的浮点运算慢很多,所以不建议所有的浮点运算都用 Decimal 来进行。