前言
不知道你是否和我一样,遇到这个情况。在刚开始学习编程。计算0.3-0.2=0.1这个小学都知道,但是如果你在计算机执行,发现结果并不是0.1 。这个时候会疑问到底是为什么呢。
System.out.println("0.3-0.2="+(0.3-0.2));
0.3-0.2=0.09999999999999998
对于很多人来说都理解,是因为在计算机中,浮点数使用二进制表示,有限的二进制位数无法精确表示某些十进制小数。那么实际32位的浮点数,在计算机中是如何表示的呢。
二进制小数
在说小数之前,我们先聊下整数。
而说到整数,正整数很简单就是使用原码即可。
在二进制中,我们通常用最高位表示符号位,0 表示正数,1 表示负数。这导致了一个问题,即在进行加法和减法运算时,可能会出现溢出。例如,两个正数相加,结果超出了二进制能表示的范围,就会导致溢出。
了解决这个问题,人们引入了反码表示法。反码是通过将二进制数中的每个位取反(0 变成 1,1 变成 0)得到的。这样,两个正数相加后,如果发生溢出,就会产生一个额外的进位,将其添加到最低位,防止溢出。
然而,反码表示法引入了一个新的问题,即存在两个表示零的形式,即全 0 和全 1。为了解决这个问题,人们进一步引入了补码表示法,通过在反码的基础上再加上 1 来得到。补码表示法只有一个零的表示形式,同时在加法和减法运算中更加方便和一致。因此,补码取代了反码成为计算机中表示有符号整数的主要方式。
反码(Ones' Complement)反码表示负数时,将其正数部分的二进制表示按位取反。即将0变为1,将1变为0。例如,正数5的二进制表示为"00000101",对应的反码为"11111010"。反码的符号位为1表示负数。
反码中存在两个零,即正零和负零,因此在进行加减运算时需要特殊处理 (存在两个表示零的形式,即全 0 和全 1 )
补码(Two's Complement)补码表示负数时,将其正数部分的二进制表示按位取反(得到反码),然后再加1。补码的符号位为1表示负数。例如,正数5的二进制表示为"00000101",对应的补码为"11111011"。
补码解决了反码的正零和负零问题,并且能够通过简单的加法来实现负数的加减运算。
补码理解是把最高位1表示为负数,那么1000 0000=-128,11111011=-128+64+32+16+8+2+1=-5
移码(又叫增码)是对真值补码的符号位取反,一般用作浮点数的阶码,引入的目的是便于浮点数运算时的对阶操作。
-3=1000 0011(原码)=1111 1100(反码)=1111 1101(补码)=0111 1101(移码)
3=0000 0011(原码)=0000 0011(反码)=0000 0011(补码)=1000 0011(移码)
在十进制 0.1=1/10,在二进制中0.1=1/2.
那么3/4=0.75(十进制)=0.11(二进制)
因为 这是因为0.75等于1/2 + 1/4,在二进制中分别对应于0.1和0.01,因此0.75的二进制表示为0.11。在这里我们可以看到和十进制类似也就是每多一位 (*1/2)。这就是浮点数表示的基本方法。
但是上面表示有一个问题,例如1000000.1和1.0000000001 我们看到这两个数一个整数很大为,一个小数很多位。那么假设当前浮点数是32位,那么需要如何控制小数点放在哪里呢。遇到1000000.1放在后面一点比较划算,遇到1.0000000001 小数点放在前面比较划算。为了解决这个问题,人们使用IEEE 754表示浮点数。
IEEE二进位浮点数算术标准(IEEE 754)
IEEE 754 是一种用于二进制浮点数算术的标准,它定义了浮点数的表示方式、舍入规则以及基本的算术运算规则。该标准由 IEEE(Institute of Electrical and Electronics Engineers,电气和电子工程师学会)制定,于 1985 年首次发布,后来经过多次修订。
一些常见协会说明
IETF (Internet Engineering Task Force)互联网工程任务组, 成立于1985年,主要定义互联网标准(IP,TCP,HTTP)
IEEE (Institute of Electrical and Electronics Engineers)电气和电子工程师协会, 成立于1961年, 主要致力于电气、电子、计算机工程和与科学有关的领域的开发和研究.(浮点数,局域网)
ANSI (AMERICAN NATIONAL STANDARDS INSTITUTE)美国国家标准学会, 成立于1918年, 实际上成了国家标准化中心(ASCII编码)
ISO (International Organization for Standardization)国际标准化组织, 成立于1926年,主要制订国际标准(ISO9001编码)
IEEE 754 主要定义了两种浮点数表示格式:单精度(32 位)和双精度(64 位)。这两种格式都包括三个部分:符号位、指数部分和尾数部分。
我们先看下我们人类如何表示浮点数:
浮点数(科学计数法):123.45 用十进制科学计数法可以表达为 1.2345 × 10^2 ,尾数1.2345,基数10,指数2。我们先看下,在计算机中是如何表示的。
为了方便计算,接下来的例子都是使用单精度即float类型
123.45=01000010111101101110011001100110
我们可以看到这个和之前整数,表示方式不同。
单精度:符号(sign)+ 指数(Exponent) + 尾数(Mantissa)
1bit 8bit 23bit =32(位)=4字节
IEEE浮点标准 V=(-1)^S*M*2^E (e表示在IEEE的表示,E表示实际的指数)
我们再看下 123.45,如果使用科学计数法那么就是1.234510^2 ,而如果是0.045=0.4510^-1
那么用二进制怎么表示,我们看个简单的方式
将0.75表示为二进制形式:0.75的二进制表示形式是0.11。
根据IEEE 754标准,浮点数表示形式可以写为:(-1)^s * M * 2^E,其中s是符号位(0表示正数,1表示负数),M是尾数(即小数部分的二进制表示形式),E是指数。
根据0.75的二进制表示形式,M为11。由于0.75是正数,所以符号位s为0。
确定指数E:将0.11转换为科学计数法形式,得到0.11 = 1.1 * 2^(-1),所以E为-1。
因此,0.75的IEEE 754表示形式为:
符号位 s = 0(表示正数)
指数 E = -1(偏移量为127,所以实际指数为127 + (-1) = 126,用127的二进制表示为0111 1110)
尾数 M = 1.1,M=100 0000 0000 0000 0000 0000,小数点前面都是1隐藏去那么就剩下1,而M的位数是23位
将这些部分组合起来,得到IEEE 754表示为:0 01111101 10000000000000000000000
再看个负数的例子
-12.5D=-1100.1B=-1.1001*2^(3)=1100 0001 0100 1000 0000 0000 0000 0000 (先转成二进制然后再进行转换操作,不是直接在十进制中进行)
S=1;e=3(阶码)=1000 0010;M=100 1000 0000 0000 0000 0000; e=3+127=130=1000 0010(无符号数)
浮点数不能准确表示0,只能近似的非常小的数表示0。那么+0就是1.0*2^-127
e 由 8 位表示,取值范围为 0-255,去除 0 和 255 这两种特殊情况,那么指数 E 的取值范围就是 1-127=-126 到 254-127=127
32位中所有位都为0时,表示的就是正零;第一位位1,剩下都是0时表示负0(正0=0000 0000 0000 0000 0000 0000 0000 0000 ;负0=1000 0000 0000 0000 0000 0000 0000 0000)
对于32位中符号位为0,指数e部分全为1,尾数M部分全为0时,表示的就是正无穷大。负无穷那么就是符号位变为1 (正无穷=0111 1111 1000 0000 0000 0000 0000 0000;负无穷=1111 1111 1000 0000 0000 0000 0000 0000)
最大正数:S=0,阶码e=254,指数E=254-127=127,尾数M=11111111111111111111111。v=(-1)^0*(1.11111111111111111111111)2^127≈3.402823e+38
最小正数:S=0,阶码e=1,指数E=1-127=-126,尾数M=00000000000000000000000. v=(-1)^0*(1.0)*2^-126=1.175494e−38
IEEE小数代码实现
为了方便,查看转换的值。这边将二进制对应十进制的值算出来,看下。
package demo.code.utils;
/**
* IEEE浮点数表示实现
*
* @author jisl on 2023/11/21 15:42
**/
public class IEEEFloatingPointConverter {
// 将单精度浮点数转换为二进制字符串
public String floatToBinary(float num) {
// 将浮点数打包成字节数组
int binary = Float.floatToRawIntBits(num);
// 将整数转换为二进制字符串
String binaryStr = Integer.toBinaryString(binary);
// 将二进制字符串填充到32位长度
return String.format("%32s", binaryStr).replace(' ', '0');
}
// 将双精度浮点数转换为二进制字符串
public String doubleToBinary(double num) {
// 将双精度浮点数打包成字节数组
long binary = Double.doubleToRawLongBits(num);
// 将长整数转换为二进制字符串
String binaryStr = Long.toBinaryString(binary);
// 将二进制字符串填充到64位长度
return String.format("%64s", binaryStr).replace(' ', '0');
}
// 将二进制字符串转换为单精度浮点数
public double binaryToFloat(String binary) {
// 获取符号位
int s = Integer.parseInt(binary.substring(0, 1), 2);
// 获取指数部分
int e = Integer.parseInt(binary.substring(1, 9), 2);
// 计算指数
int E = e - 127;
// 获取尾数部分
String M = "1." + binary.substring(9);
// 计算浮点数
return Math.pow(-1, s) * binaryParseFloat(M, E);
}
// 辅助方法:将二进制小数字符串转换为浮点数
private double binaryParseFloat(String M, int E) {
// 如果有小数点,分割整数和小数部分
String[] parts = M.split("\\.");
int intPart = Integer.parseInt(parts[0], 2);
// 左移,以包括小数部分
intPart <<= parts[1].length();
intPart += Integer.parseInt(parts[1], 2);
// 计算浮点数
double res = intPart / Math.pow(2, parts[1].length());
// 根据指数调整浮点数
if (E > 0) {
return res * Math.pow(2, E);
} else {
return res / Math.pow(2, Math.abs(E));
}
}
public static void main(String[] args) {
IEEEFloatingPointConverter binaryConverter = new IEEEFloatingPointConverter();
String result = binaryConverter.floatToBinary(0.1F);
double x = binaryConverter.binaryToFloat(result);
System.out.println("单精度浮点数二进制表示:" + result);
System.out.println("转换回浮点数:" + x);
System.out.println("0.3-0.2="+(0.3-0.2));
}
}
读者可以执行上面代码,查看对应二进制值转成十进制。这也就是出现0.3-0.2=0.1原因。
BigDecimal介绍
从上面我们知道,如同在十进制中无法精确表示1/3,在二进制中我们也无法精确表示1/5。但是在实际生活中,我们不可避免要使用1/5这个精确小数。为了满足这个需要,大家用了个方法。就是二进制整数都是精确,那么我就先把你改成整数,进行运算。那么不就是精确了,这个方式和现在后端很多金额都是用分计算,思路是一样的。
BigDecimal是Java中的一个类,用于精确表示任意精度的十进制数。它的原理基于一个不可变的任意精度整数,它可以存储任意大小的整数,而不会丢失精度。
我们看下BigDecimal构成
public class BigDecimal extends Number implements Comparable<BigDecimal> {
/**
* The unscaled value of this BigDecimal, as returned by {@link
* #unscaledValue}.
*
* @serial
* @see #unscaledValue
*/
private final BigInteger intVal;
/**
* The scale of this BigDecimal, as returned by {@link #scale}.
*
* @serial
* @see #scale
*/
private final int scale; // Note: this may have any value, so
// calculations must be done in longs
/**
* The number of decimal digits in this BigDecimal, or 0 if the
* number of digits are not known (lookaside information). If
* nonzero, the value is guaranteed correct. Use the precision()
* method to obtain and set the value if it might be 0. This
* field is mutable until set nonzero.
*
* @since 1.5
*/
private transient int precision;
/**
* Used to store the canonical string representation, if computed.
*/
private transient String stringCache;
/**
* Sentinel value for {@link #intCompact} indicating the
* significand information is only available from {@code intVal}.
*/
static final long INFLATED = Long.MIN_VALUE;
private static final BigInteger INFLATED_BIGINT = BigInteger.valueOf(INFLATED);
/**
* If the absolute value of the significand of this BigDecimal is
* less than or equal to {@code Long.MAX_VALUE}, the value can be
* compactly stored in this field and used in computations.
*/
private final transient long intCompact;
}
final BigDecimal bigDecimal = new BigDecimal("0.3");
我们通过 0.3列子 解释以上几个值。
intVal:
intVal是一个BigInteger对象,用于存储BigDecimal对象表示的整数部分的值。
当整数部分可以用long类型表示时,intVal可能为空(null),而直接使用long类型的intCompact字段表示整数部分的值。
scale:
scale是一个int类型的值,表示BigDecimal对象的小数部分的精度,即小数点右边的位数。
正数表示小数点右边的位数,负数表示小数点左边的位数。例如,一个BigDecimal对象的scale为2表示其小数部分有两位小数。
stringCache:
stringCache是一个String类型的缓存,用于缓存BigDecimal对象的字符串表示形式。
当调用toString()方法时,会将结果缓存到stringCache中,以提高性能。
intCompact:
intCompact是一个long类型的字段,用于存储BigDecimal对象的整数部分的值,当整数部分可以用long类型表示时,使用该字段来存储整数部分的值,以提高性能和减少内存占用。
如果整数部分的值不能用long类型表示,则intCompact字段为0,而整数部分的值则存储在intVal字段中的BigInteger对象中。
例如 final BigDecimal bigDecimal = new BigDecimal("0.3");
变成整数,那么 stringCache="0.3",intCompact=3,scale=1(移动一位就变成整数了),intVal=null(因为intCompact可以表示)
BigDecimal运算
我们看下BigDecimal是如何计算的
/**
* 执行两个BigDecimal数的加法运算。
* @param xs 第一个数的整数部分
* @param scale1 第一个数的小数点位数
* @param ys 第二个数的整数部分
* @param scale2 第二个数的小数点位数
* @return 加法运算的结果
*/
private static BigDecimal add(final long xs, int scale1, final long ys, int scale2) {
// 计算两个数的小数点位数之差
long sdiff = (long) scale1 - scale2;
if (sdiff == 0) {
// 如果两个数的小数点位数相同,则直接调用另一个重载的add方法执行加法运算
return add(xs, ys, scale1);
} else if (sdiff < 0) {
// 如果第一个数的小数点位数较小,则将其扩大到与第二个数相同的位数,然后执行加法运算
int raise = checkScale(xs, -sdiff);
// 将第一个数的整数部分扩大到与第二个数的小数点位数相同
long scaledX = longMultiplyPowerTen(xs, raise);
if (scaledX != INFLATED) {
// 如果扩大后的结果可以用long类型表示,则直接执行加法运算
return add(scaledX, ys, scale2);
} else {
// 如果扩大后的结果不能用long类型表示,则使用BigInteger进行加法运算
BigInteger bigsum = bigMultiplyPowerTen(xs, raise).add(ys);
// 根据加法结果的符号创建新的BigDecimal对象
return ((xs ^ ys) >= 0) ? // 判断加法结果的符号是否相同
new BigDecimal(bigsum, INFLATED, scale2, 0)
: valueOf(bigsum, scale2, 0);
}
} else {
// 如果第二个数的小数点位数较小,则将其扩大到与第一个数相同的位数,然后执行加法运算
int raise = checkScale(ys, sdiff);
// 将第二个数的整数部分扩大到与第一个数的小数点位数相同
long scaledY = longMultiplyPowerTen(ys, raise);
if (scaledY != INFLATED) {
// 如果扩大后的结果可以用long类型表示,则直接执行加法运算
return add(xs, scaledY, scale1);
} else {
// 如果扩大后的结果不能用long类型表示,则使用BigInteger进行加法运算
BigInteger bigsum = bigMultiplyPowerTen(ys, raise).add(xs);
// 根据加法结果的符号创建新的BigDecimal对象
return ((xs ^ ys) >= 0) ?
new BigDecimal(bigsum, INFLATED, scale1, 0)
: valueOf(bigsum, scale1, 0);
}
}
}
从上面我们可以看到,加法运算变成整数和整型是一样。就是转换后如果scale不同,那么需要转换。我们看下
final BigDecimal bigDecimal = new BigDecimal("0.3");
final BigDecimal bigDecimal2 = new BigDecimal("0.12");
System.out.println(bigDecimal.add(bigDecimal2));
0.3是intCompact=3,scale=1
0.2变成intCompact=12,scale=2
两个scale不一样,改变0.3的字段值 intCompact=30,sacle=2
开始进行加法运算:30+12=42,而scale=2,那么就是0.42
总结
以上介绍了二进制小数表示,然后介绍了IEEE浮点数表示法,最后介绍了BigDecimal如何解决二进制不能精确表示十进制小数问题。