6、浮点数
上一节课,我们讲了整型数的表示方法:补码,今天我们讲讲浮点数的表示方法
浮点数在平时的开发中也经常用到,比如用来表示金额等,浮点数并不能精确地表示整数或小数,所以,在使用时,要多加小心,稍有不慎就会引入 bug
因此,了解浮点数的表示方法等相关理论知识,就相当有必要了
public static String floatToBinaryStr(float num) { int binary = Float.floatToIntBits(num); return Integer.toBinaryString(binary); }
1、实数的二进制科学记数法
1.1、十进制的科学记数法
浮点数是计算机中用来表示实数的数据类型,实数是一个数学概念,这里我们可以简单理解为小数,不过,整数也可以看做实数,比如 5 可以看做 5.0,也算是实数
我们知道,整数在计算机中有固定的表示方法(或者叫存储格式):补码
同样,浮点数也有固定的表示方法,并且形成了一份规范文件,叫做 IEEE754 标准,绝大多数计算机都参照这个标准来存储浮点数
浮点数在计算机中的存储格式类似科学记数法,所以,我们先来了解一下什么是科学记数法
在数学计算中,特别是在表示一些物理量时,比如星球之间的距离,为了方便表示很大很大的数,我们一般采用科学记数法
我们将数据表示成 x * 10 ^ y 的形式,x 叫做尾数或有效数字,y 叫做指数、幂或者阶数
例如,我们可以将 12350000 表示为 1.235 * 10 ^ 7,为了方便书写,我们一般会将科学记数法的表示形式,简化为 xEy 的形式
例如,我们把 1.235 * 10 ^ 7 简写为 1.235E7,在编程中,我们也可以使用这种简化的书写方式
float f1 = 100000000.0F; System.out.println("" + f1); // 输出 1.0E8 float f2 = 1.3E23F; System.out.println("" + f2); // 输出 1.3E23
1.2、二进制的科学记数法
上面讲的是十进制的科学记数法,接下来,我们再看一下二进制的科学记数法
对于一个实数,我们可以将其分为两部分:整数部分和小数部分
我们将整数部分和小数部分分别转换为二进制数,然后中间用点号(.)连接,就得到了实数对应的二进制表示
例如,十进制数 12.375 转换成二进制表示为:1100.011,十进制数 -12.375 转换成二进制表示为:-1100.011
对于整数如何转化成二进制表示,我们在上一节课中已经讲过了,采用是除 2 求余的方法,接下来,我们重点讲一下,如何将小数部分转换成二进制表示
对于小数的十进制表示,点号以后的每一位都对应一个权值,依次为 1 / 10,1 / 10 ^ 2,1 / 10 ^ 3 ... 以此类推
同理,对于小数的二进制表示,点号以后的每一位对应权值依次为 1 / 2,1 / 2 ^ 2,1 / 2 ^ 3 ... 以此类推
类比十进制的乘 10 运算,假设某个小数 w 表示成二进制数之后为:0.xyz(x、y、z 为 0 或 1)
将其乘 2 就相当于点号后移一位,变为:x.yz,这时对其取整,就得到了 x,这样我们就分离出了第一位小数
以此类推,每次乘以 2,然后取整,就能依次得到小数点后的每一位
这种将小数转换为二进制表示的方法叫做乘 2 取整法,下图是对于上述处理过程的举例解释
但是,上述实数的二进制表示中包含负号和点号,只适合人类阅读,还无法直接存储到计算机中
那么,怎么将类似 -1100.011 这样的二进制格式,转换为适合计算机存储的二进制格式呢?
换句话说,如果我们用 4 字节去存储实数,那么如何将类似 -1100.011 这样的二进制串的所有信息,都保存在这 4 个字节中呢?
2、实数的存储格式:定点数
借鉴上一节课处理整数符号的方法,我们将一串二进制位的最高位作为符号位,来表示正负,符号位为 0 表示正数,符号位为 1 表示负数
符号如何存储的问题解决了,接下来,我们来看如何存储点号,也就是,当把二进制位存储到 4 个字节中时,如何区分哪几位是整数部分,哪几位是小数部分
比较简单的方法是固定整数和小数所占二进制位的个数,比如最高位为符号位,接下来的 20 位表示整数,最后 11 位表示小数,如下图所示
以上实数在计算机中的表示方法叫做定点数表示法,也就是说点号在整数和小数之间的位置是固定的,定点数最大的问题是,有时候会浪费一些存储空间
比如,当我们表示一个只包含整数部分,不包含小数部分的实数时,也就是说,小数部分的二进制位都为 0,即便整数部分都要溢出了,但也不能挪用小数部分的二进制位
同理,当我们要表示一个高精度的实数,并且它只包含小数不包含整数时,也就是整数部分的二进制位都是 0,即便小数部分因为存储空间不够都要被截断了,但也不能挪用整数部分的二进制位
3、实数的存储格式:浮点数
为了有效利用存储空间,于是就发明了浮点数,顾名思义,点号在 32 个二进制位中的位置是不固定的,这样就充分利用存储空间,能够表示更大的数据范围和更高的小数精度
计算机中的浮点数一般分为 4 字节单精度浮点数和 8 字节双精度浮点数,对应到 Java 语言中就是 float 类型和 double 类型
根据 IEEE754 标准的规定,浮点数的二进制表示格式为:(-1) ^ S * M * 2 ^ E,实际上就是二进制的科学记数法
- (-1) ^ S 表示符号,S 为 0 时表示正数,S 为 1 时表示负数
- M 是有效数字或尾数,E 是指数、幂或阶数
例如,-12.375 表示成二进制为 -1100.011,进而表示成二进制科学记数法为 (-1) ^ 1 * 1.100011 * 2 ^ 3,当然也可以表示为 (-1) ^ 1 * 11.00011 * 2 ^ 2 等
为了统一表示方式,IEEE754 标准规定,M 的整数位必须是 1,即 1 <= M < 2
这样 -12.375 就只能表示为 (-1) ^ 1 * 1.100011 * 2 ^ 3,对应的 S 为 1,M 为 1.00011,E 为 3,那么,如何将二进制科学记数法存储在计算机中呢?
IEEE754 标准规定,对于 4 字节单精度浮点数,最高位存储 S,中间 8 位存储指数 E,叫做指数域,最后 23 位存储有效数字,叫做有效数字域
对于 8 字节的双精度浮点数,最高位存储 S,中间 11 为存储指数 E,最后 52 为存储有效数字
因为这两种浮点数的存储结构类似,我们拿单精度浮点数来举例讲解
对于 M 和 E 的存储格式,IEEE754 标准还有其他规定
因为 IEEE754 标准规定,M 的整数位必须是 1,所以,在存储 M 时,我们可以不用存储整数位 1,只存储小数位即可
也就是说,我们可以用 23 个二进制位存储 24 位有效数字
我们再来看指数 E 如何存储,E 是一个整数,它既可以是负数(比如 0.000011 用科学记数法表示为 1.1 * 2 ^ (-5),E 为 -5),也可以是正数
整数的存储方法有多种,上一节讲到原码和补码
- 用原码存储,8 个二进制位可以表示的范围是 [-127, 127]
- 用补码存储,8 位二进制位可以表示的范围是 [-128, 127]
- 不过,IEEE754 并没有沿用原码或补码来存储 E(当然,使用原码或补码也是可以的)
IEEE754 限定指数 E 的范围是 [-126, 127],具体为什么是这个范围,我们待会再解释
IEEE754 将指数域解释为无符号类型,也就是,指数域中没有符号位,8 个二进制位全是数值位,那么,指数域可表示数据范围是 [0, 255](0000 0000 ~ 1111 1111)
不过,这样,负数指数就无法存储在指数域了,为了解决这个问题,IEEE 标准将指数 E 统一加 127 之后,再存储到指数域,同理,当从指数域中取出指数时,也要对应的减去 127
这样,指数 E 加 127 之后,范围就变成了 [1, 254],正好落在指数域可表示的范围([0, 255])内,我们举个例子,如下图所示
你可能会说,指数域中的 0 和 255 岂不是没用到
如果 IEEE754 将指数 E 范围扩大一点,限定为 [-127, 128],那么加 127 之后,范围变成 [0, 255],这不就正好跟指数域可表示范围相吻合,就不浪费 0 和 255 两个值了
实际上,IEEEE754 之所以把指数E的数据范围限定为 [-126, 127],这是因为指数域中的 0 和 255 这两个值还有其他特殊用处,我们依次来看下
- 指数域为 0(0000 0000)用来辅助表示浮点数 0
前面讲过,IEEE 规定有效数字 M 的整数位为 1,并且在存储到有效数字域中时,将整数位 1 省略
反过来,从有效数字域中的读取的二进制位,前面加 1 才是真正的有效数字
有效数字域能表示的最小数为 000....00000(23 个 0),将其翻译为有效数字时,在前面加 1 变为:1000...00000(1 个 1,23 个 0),所以,有效数字域无法表示为 0 的有效数字,也就无法表示浮点数 0 了
为了表示浮点数 0,IEEE754 标准规定,当指数域为 0 时,从有效数字域中读取的二进制位不需要在前面加 1
这样,当指数域为 0,有效数字域为 0 时,就可以表示浮点数 0 了
看到这里,你可能会说,IEEE754 真复杂,是的,这可是 Intel 公司请当时最优秀的数值分析家之一 William Kahan 教授设计的
- 指数域为 255(1111 1111)用来辅助表示无穷大或 NaN
当指数域为 255,有效数字域为 0 时,表示正无穷(S 为 0)和负无穷(S 为 1)
当指数域为 255,有效数字域的二进制位不全为 0 时,表示这是一个无意义数 NaN(Not a Number)
在 Java 中的 Float 类中定义 3 个静态常量来表示正负无穷大和 NaN,如下代码所示
当我们在开发中,需要初始化某个浮点类型的变量为正无穷大或负无穷大时,可以直接使用以下静态常量
public static final float POSITIVE_INFINITY = 1.0F / 0.0F; // positive_infinity 正无穷 +Infinity public static final float NEGATIVE_INFINITY = -1.0F / 0.0F; // negative_infinity 负无穷 -Infinity public static final float NaN = 0.0F / 0.0F; // not a number 不是一个数字 NaN
4、浮点数的表示范围和精度
浮点数的存储格式讲清楚了,我们再来看一下浮点数的表示范围和精度问题
4.1、浮点数的表示范围
确定浮点数的表示范围,也就是找到其可以表示的最大值和最小值,我们还是拿 4 字节的单精度浮点数来举例讲解
在 IEEE754 标准规定的浮点数的存储结构中,有效数字域占 23 个二进制位,所以,有效数字 M 的最大值是 1.111...11(总共有 24 个 1)
指数的范围是 [-126, 127],所以,指数的最大值是 127,因此浮点数可以表示的最大值是 1.111...11 * 2 ^ 127,最小值是 -1.111...11 * 2 ^ 127
转化成十进制数约等于 3.4E38 和 -3.4E38
浮点数可以表示的范围是非常大的
而同样占用 4 个字节存储空间的 int 类型的表示范围只有 -2 ^ 31 ~ 2 ^ 31 - 1,也就是 -1073741824 ~ 1073741823
那你有没有想过,同样是 4 字节长度,为什么浮点数就可以表示这么大的范围呢?
之所以浮点数能表示这么大的范围,是因为它有选择地表示这个范围内的一部分的数,而非全部的数
一来,实数是无限多的,全部表示本身就是不可能的事,二来,根据排列组合,32 个二进制位可以最多表示 2 ^ 32 个不同的数
根据鸽巢原理,用 2 ^ 32 个数来表示无限多的实数,必然会有取舍
由此我们也可以得到:不同的实数,在用浮点数表示的时候,在计算机中存储的可能是相同的浮点数
4.2、浮点数的精度问题
当某个实数表示成二进制科学计数法,其有效数字位数超过 24 位时,就会做精度舍弃,类似四舍五入的方法舍弃多出来的有效数字(注意不是直接截断舍弃,具体舍入的算法比较复杂,我们就不展开讲解了)
由此就会产生精度问题,某个实数存入计算机中,再次被取出时,有可能就不是之前存入的实数值了
这里需要注意一下,不仅仅只有小数会有精度问题,整数也有精度问题,只要有效数字个数超过 24 个,就会存在精度问题
float f = 33554433.0F; System.out.println(f); // 输出结果: 3.3554432E7
了解了精度问题产生的原因之后,我们来看下面这段代码,请你思考下,这段代码的打印结果是什么?
float f = 0.1F; System.out.printf("%.11f\n", f); // 格式化输出, 保留 11 位小数, 结果为 0.10000000149
你可能会说,0.1 的小数位只有 1 位,转化成二进制之后,有效数字肯定小于 24 个,所以,0.1 肯定能准确表示,打印结果是 0.1
实际上,这样的分析是不对的,浮点数 0.1 在计算机中无法精确表示,因为十进制的 0.1 换算成二进制是一个无限循环小数,有效数字位数无穷多,如下图所示
有效数字舍入处理之后,最终打印的结果 0.10000000149(注意,代码中使用 printf() 保留 11 位小数,如果使用 println() 打印,会舍入显示为 0.1)
这个值大于 0.1,也应证了我们前面讲到的,有效数字个数大于 24 时,执行舍入操作,而非直接截断
因为如果是直接截断的话,最终的值会小于 0.1,而舍入的话,就有可能大于 0.1
5、浮点数的替代品:BigDecimal
浮点数的表示会存在误差,因此浮点数的计算也会存在误差,如下所示
不过,这个误差非常小,大部分对精度不是特别敏感的系统,用浮点数来表示实数就足够了
float f1 = 10.00f; float f2 = 9.60f; float f3 = f1 - f2; System.out.println(f3); // 输出 0.39999962
对精度比较敏感的金融系统,代码中充斥着各种浮点数的表示和计算,一丢丢误差累积下来就会产生比较大的误差,所以,金融系统一般采用 BigDecimal 来表示实数
BigDecimal 将整数部分和小数部分分开存储,小数部分也当做整数来存储,这样就能精确表示像 0.1 这样数据了
BigDecimal bg = new BigDecimal("0.1"); System.out.println(bg.toString()); // 输出 0.1
注意上述代码,传递进 BigDecimal 中的是字符串 "0.1",而非 float 类型数据 0.1F,否则 BigDecimal 将无法表示精确的 0.1,原因是 0.1 在传入 BigDecimal 之前已经是不准确的了
BigDecimal bg = new BigDecimal(0.1F); System.out.println(bg.toString()); // 输出 0.100000001490116119384765625
BigDecimal 还提供了相应的方法,进行精确的加减乘除操作,示例代码如下所示
注意,对于无法整除的除法操作,我们需要指明舍入(Rounding)方法,否则,将抛出 ArithmeticException 异常
BigDecimal bg1 = new BigDecimal("10.00"); BigDecimal bg2 = new BigDecimal("9.60"); BigDecimal bg3 = bg1.subtract(bg2); System.out.println(bg3.toString()); // 0.40 BigDecimal a = new BigDecimal("1.0"); BigDecimal b = new BigDecimal("3.0"); BigDecimal c = a.divide(b, 2, BigDecimal.ROUND_HALF_EVEN); System.out.println(c.toString()); // 0.33
除此之外,浮点数的关系操作(判等、大于、小于等)是比较复杂的,需要引入误差,示例如下所示
而 BigDecimal 完全不存在这个问题,有现成的方法可以使用
// 浮点数判等, 误差小于 0.0001 就表示 f1 == f2 if (Math.abs(f1 - f2) < 0.0001) { ... } // BigDecimal 比较: ret = -1, 0, 1 分别表示 bg1 <、==、> bg2 int ret = bg1.compareTo(bg2);
BigDecimal 类提供的方法还有很多,我们就不一一介绍了,你可以自行研究一下
6、浮点数的精度取舍方法
上面讲了 BigDecimal 能精确表示和计算实数,但这并不代表就不存在精度舍入问题,存储、显示以及一些具体的业务需要,都有可能需要我们做一些舍入操作
拿金融系统来举例
在金融系统里面,代码中浮点计算的结果,最好尽量多保留几位小数,在存入数据库或者展示给用户时,再按照业务需要做舍入
比如计算过程浮点数保留 8 位小数,存入数据库中时保留 5 位小数,展示给用户时保留 2 位小数,也就是精确到 "分" 即可
常用的舍入算法是四舍五入法,但是,它的累积误差比较大
如果我们通过四舍五入保留 1 位小数,那么,0.01 舍,会产生 -0.01 的误差,而 0.09 入,会抵消 0.01 产生的 -0.01 的误差
同样,0.02 舍和 0.08 入,0.03 舍和 0.07 入,0.04 舍和 0.06 入,累积下来,都可以实现正负误差相抵
而 0.05 入,产生 +0.05 的误差,无人抵消
所以,在数据分布比较均匀的情况下,对于求和操作,10 次舍入就会产生一个 +0.05 的误差
对于金融系统来说,浮点计算非常频繁,100 万次舍入操作,就会产生 5 万的误差,累积误差就比较大了
金融系统经常用到的舍入方法是四舍六入五成双,也叫做银行家舍入算法,此舍入算法是对四舍五入方法的改进
- 舍去位的数值小于 5 时,直接舍去
- 舍去位的数值大于 5 时,进位
- 当舍去位的数值等于 5 时,根据 5 前一位数的奇偶性来判断是否需要进位,偶数进位,奇数舍去
当然银行家算法也不是适应用所有的情况,有时候还会根据业务需求选择其他舍入方法,比如分息向上取整,罚息向下取整,以保证客户不亏
实际上,BigDecimal 提供了各种舍入算法,以支持各种业务需求,你可以自己去了解下
除此之外,在开发中,我们要避免依赖数据库的舍入算法
Mysql 中 Decimal 和 Oracle 中的 Number 经常用来存储高精度数据
比如 Decimal(7, 3) 和 Number(7, 3),其中,7 表示全部的数据位数,3 表示小数点之后的数据位数
如果存储的数据超出了字段可表示的精度,Mysql 会四舍五入,Oracle 会直接截断
为了避免产生不可预测的结果,在存入数据库之前,最好按业务对精度的要求,提前做精度舍入,以免触发数据库的精度舍入
从数据库取出数据时,实数也要用 BigDecimal 来映射,避免映射为浮点数而导致的精确性问题
7、课后思考题
1、4 字节单精度浮点数能否准确表示 int 能表示的所有整数呢?如果不能,那么哪个范围内的整数可以准确表示?
不能准确表示 int 所能表示的所有整数 有效数字域长度为 23 bits,可以表示的整数范围是 -(2 ^ 24 - 1) ~ (2 ^ 24 - 1)
2、浮点数可以表示的最小的正数是多少?
指数范围是 -126 ~ 127 有效数字的最小值是 1.000...000(点之后有 23 个 0) 因此,最小正数为 2 ^ (-126)
本文来自博客园,作者:lidongdongdong~,转载请注明原文链接:https://www.cnblogs.com/lidong422339/p/17393458.html
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步