本文为作者在学习阿里的《码处高效》时,对Java(JDK1.8)中浮点数底层的存储结构探究。由于float和double同理,但double位数过长,所以本文主要以float类型阐述。原理主要参考《码出高效》的浮点数章节,如果觉得枯燥乏味可以直接跳到代码解析部分。
代码解释主要以 16.35f 和 0.35f 中小数部分的存储值不同为例。
懵逼的浮点数判断结果
System.out.println(0.1f + 0.2f == 0.3f); //输出true
System.out.println(0.1 + 0.2 == 0.3); //输出false
System.out.println(0.3f + 0.6f == 0.9f); //输出false
System.out.println(0.3 + 0.6 == 0.9); //输出false
提示:在此类问题中,编码时需要注意float基本数据类型需要添加后缀f,以及它的包装类Float的后缀F,不写后缀会默认为double类型。
一开始看到这个结果是不是直接懵了,先且不说不写后缀默认double的问题,第1和第3一个true一个false就直接懵逼了。
本文以《码出高效》中浮点数部分结合示例代码进行详细的解析,最终以16.35f和0.35f的存储值来说明为什么会出现以上结果。因为用double的话位数太多,容易看乱,相信大家通过float的解析可以触类旁通,所以本文就不再反复赘述了。
浮点数表示就是如何用二进制数表示符号、指数和有效数字。当前业界流行的浮点数标准是IEEE754, 该标准规定了 浮点数类型单精度、双精度、延伸单精度、延伸双精度。前两种类型是最常用的,它们的取值范围如下表所示:
精度 | 字节数 | 正数取值范围 | 负数取值范围 |
---|---|---|---|
单精度类型 | 4 | 1.4e-45至3.4e+38 | -3.4e+38至-1.4e-45 |
双精度类型 | 8 | 4.9e-324至1.798e+308 | -1.798e+308至-4.9e-324 |
因为浮点数无法表示零值,所以取值范围分为两个区间:正数区间和负数区间。
以单精度类型float为例,它被分配了4个字节,总共 32 位,具体格式如下图所示:
指数称为“阶码”,有效数字称为“尾数”,所以用于存储符号、阶码、尾数的二进制位分别称为符号位、阶码位、尾数位,下面详细阐述三个部分的编码格式。
为了节约存储空间,将符合规格化尾数的首个1省略,所以尾数表面上是23位,却表示了24位二进制数,如下图所示:
常用浮点数的规格化表示如下表所示:
数值 | 浮点数二进制表示 | 说明 |
---|---|---|
-16 | 1100-0001-1000-0000-0000-0000-0000-0000 | 第1位为负数,131-127=4,即2的4次方等于16,尾数部分为1.0 |
16.35 | 0100-0001-1000-0010-1100-1100-1100-1101 | 符号位正,绿色部分上同,尾数部分见说明① |
0.35 | 0011-1110-1011-0011-0011-0011-0011-0011 | 此例的目的是说明16.35和0.35的尾数部分是不一样的 |
1.0 | 0011-1111-1000-0000-0000-0000-0000-0000 | 127-127=0即2的0次=1,尾数部分为1.0 |
0.9 | 0011-1111-0110-0110-0110-0110-0110-0110 | 126-127=-1即0.5② |
注意:
① 尾数部分的有效数字为1.00000101100110011001101,将其转换成十进制值为1.021875,然后乘以 24 得到16.35000038。由此可见,计算机实际存储的值可能与真值是不一样的。
② 0.9不能用有限二进制位精确表示,所以1-0.9并不精确地等于0.1,实际结果是0.100000024
对于float类型数据,其在计算机机器内部是二进制数,并用4个字节(32位)来表示。其中1位用于符号位,8位用于指数位,剩下的23位则用于存储“尾数”。此处的“尾数”是指数的底数,即223=8388608。因此,在Java中,float类型可以表示大约6-7位有效数字。最多能有7位有效数字,但能绝对保证的位数为6位。而double类型的浮点数采用64位存储结构。具体来说,这64位中包含:1位符号位,用于表示数值的正负;11位指数位,这部分采用偏正值表示,即实际的指数大小加上1023;剩下的52位是有效数位。这种设计使得double类型的数值范围可以达到-21024~ +21024(-1.79e+308 ~ +1.79e+308),并且有效位数可以达到16位。
需要注意的是,这里的有效数字位数指的是总位数,而不是小数点右边的数字。例如,float输出时,前7位有效数字是真实值,第8位是估算值,可能和原始一致,也可能是四舍五入上来的。
所以(反着写是因为这样小数位的比较更清晰)
0.10000000149011612 ≈ 0.1f
0.20000000298023224 ≈ 0.2f
0.30000001192092896 ≈ 0.3f
0.30000000447034836 ≈ 0.1f + 0.2f
float使用==比较的时候,截取6-7位,导致 System.out.println(0.1f + 0.2f == 0.3f); //输出true
若不写后缀,默认double,截取16位,导致System.out.println(0.1 + 0.2 == 0.3); //输出false
import java.util.Arrays;
import java.util.concurrent.TimeUnit;
/**
* 浮点数底层研究
*
* @author LiaoYuXing-Ray
* @version 1.0
* @createDate 2024/1/4 10:01
**/
public class FloatAnalysis{
public static void main(String[] args) throws Exception {
// 需要计算真实存储值的浮点数
float floatValue1 = 16.35f;
float floatValue2 = .35f;
System.out.println(floatValue1 + "的真实存储值:" + rayFloatStudy(floatValue1));
System.out.println("\n========我是一条华丽的分割线========\n");
System.out.println(floatValue2 + "的真实存储值:" + rayFloatStudy(floatValue2));
}
/**
* 解析浮点数组成
*
* @param floatValue 待解析的浮点数
* @author LiaoYuXing-Ray 2024/1/4 10:01
**/
public static double rayFloatStudy(float floatValue) throws Exception {
// 符号位(1位)
byte[] symbol = new byte[1];
// 阶码位(8位),指数部分
byte[] exponent = new byte[8];
// 尾数位(23位),有效数字
byte[] number = new byte[23];
String binaryRepresentation = floatToBinary(floatValue);
System.out.println("浮点数\t" + floatValue);
System.out.println("========开始解析每一位的含义========");
System.out.println(binaryRepresentation);
byte[] byteString = binaryRepresentation.getBytes();
// float 4字节 1字节=8bit 所以32
byte[] byteArray = new byte[32];
// ASCII码中48是0 49是1
for (int i = 0; i < byteString.length; i++) {
if (byteString[i] == 48) {
byteArray[i] = 0;
} else if (byteString[i] == 49) {
byteArray[i] = 1;
} else {
throw new Exception("不应该出现的分支,理论上转为二进制只有0和1");
}
}
/*
以下是获取符号位、指数部分、有效数字的值,并输出
*/
for (int i = 0; i < byteArray.length; i++) {
if (i == 0) {
symbol[i] = byteArray[i];
}
if (i >= 1 && i <= 8) {
exponent[i - 1] = byteArray[i];
}
if (i > 8) {
number[i - 9] = byteArray[i];
}
// 以下为输出,可注释
if (i != 0 && i % 4 == 0) {
System.out.print("-");
}
System.out.print(byteArray[i]);
}
System.out.print("\n符号位值(1位):" + Arrays.toString(symbol));
if (symbol[0] == 0) {
System.out.print("为正数");
} else if (symbol[0] == 1) {
System.out.print("为负数");
} else {
throw new Exception("不应该出现的分支,符号位理论上只有0和1两种情况");
}
System.out.print("\n指数部分(8位):[");
for (byte b : exponent) {
System.out.print(b);
}
System.out.print("]\t-> 转化十进制数:[" + binaryToDecimal(exponent) + "]");
System.out.print("\n有效数字(23位):[");
for (byte b : number) {
System.out.print(b);
}
System.out.print("]");
System.out.println("\n=======还原float的真实存储值=======");
// 指数部分的十进制数值
int exponentOfDecimalNumber = binaryToDecimal(exponent);
/*
以 IEEE754 标准规定,单精度的阶码偏移量为 2^(n-1)-1 (即127),这样能表示的指数范围为 [-126,127]
*/
// 阶码偏移量
int offsetExponent = exponentOfDecimalNumber - 127;
System.out.println("阶码偏移量=指数部分的十进制数值[" + exponentOfDecimalNumber + "]- [2^(n-1)-1 (即127)]=" + offsetExponent);
// 指数值。此处double是因为Math.pow方法的参数为double类型
double integerBitsBaseValue;
if (offsetExponent >= 0) {
integerBitsBaseValue = 1 << offsetExponent;
} else {
integerBitsBaseValue = Math.pow(2D, offsetExponent);
}
System.out.println("指数值=2^阶码偏移量[" + offsetExponent + "]=" + integerBitsBaseValue);
// 整数部分(小数点左边的部分)即整数位(基础值)
double integerBits = integerBitsBaseValue;
// 有效位数转化为10进制数
double tempCount;
if (offsetExponent >= 0) {
tempCount = 1D;
// 如果偏移量大于等于0,整数部分为0
integerBits = 0;
System.out.println("整数部分(小数点左边的部分)即整数位(基础值)=" + integerBits);
System.out.println("尾数23位实际为1.xxx,尾数为有效数字加上1.0");
System.out.print("所以有效位数的二进制表示为[1.");
} else {
tempCount = 0D;
System.out.println("整数部分(小数点左边的部分)即整数位(基础值):" + integerBits);
System.out.println("尾数23位部分为0.xxx,尾数为有效数字加上0.0");
System.out.print("所以有效位数的二进制表示为[0.");
}
/*
此处计算小数二进制转化为十进制,比如0.01(2)=0*2^(-1)+1*2^(-2)=0.25(10)
*/
for (int i = 0; i < number.length; i++) {
if (number[i] == 1) {
// 将2的负(i+1)次方累加
tempCount += Math.pow(2, -(i + 1));
}
System.out.print(number[i]);
}
System.out.println("] -> 有效位数10进制数的值" + tempCount);
// 小数有效值
double decimalEffectiveValue = tempCount * integerBitsBaseValue;
System.out.println("(有效位数10进制数的值" + tempCount + ")*(指数值" + integerBitsBaseValue + ")=小数有效值:" + decimalEffectiveValue);
double result = decimalEffectiveValue + integerBits;
if (byteArray[0] == 0) {
System.err.println(floatValue + "为正数,小数有效值[" + decimalEffectiveValue + "]加上整数部分[" + integerBits + "],最终结果:" + result);
} else {
System.err.println(floatValue + "为负数结果需要*(-1),小数有效值[" + decimalEffectiveValue + "]加上整数部分[" + integerBits + "],最终结果:" + (result * -1));
}
// 休眠是因为防止err语句输出顺序混乱
try {
TimeUnit.MILLISECONDS.sleep(50);
} catch (InterruptedException e) {
e.printStackTrace();
}
return result;
}
/**
* 长度为8的byte[]转换为对应的十进制数
*
* @param exponentBytes 长度为8的byte[]
* @return int
* @author LiaoYuXing-Ray 2024/1/4 10:01
**/
private static int binaryToDecimal(byte[] exponentBytes) {
int decimal = 0;
for (int i = 0; i < exponentBytes.length; i++) {
decimal += (exponentBytes[i] & 0xFF) * Math.pow(2, exponentBytes.length - 1 - i);
}
return decimal;
}
/**
* 浮点型转化为bit,字符串输出,会自动补零
*
* @param value 浮点数
* @return java.lang.String
* @author LiaoYuXing-Ray 2024/1/4 10:01
**/
private static String floatToBinary(float value) {
int intBits = Float.floatToIntBits(value);
return String.format("%32s", Integer.toBinaryString(intBits)).replace(' ', '0');
}
}
下面我们通过运行结果分析浮点数精度误差产生的原因
首先通过Java内置的Float.floatToIntBits(value)
方法将 16.35f 的底层二进制存储值进行还原:01000001100000101100110011001101
符号位值(1位):[0]为正数
指数部分(8位):[10000011] -> 转化十进制数:[131]
有效数字(23位):[00000101100110011001101]
由原理篇可知:
关于小数二进制转十进制举例:1.01(二进制)= 1×20+0×2(-1)+1×2-2 = 1+0+0.25=1.25(十进制)
由于上面的值是代码逻辑计算出的,可以经过以下验算,验证尾数部分计算逻辑是正确的
double result0 = Math.pow(2, 0); // 1.0
double result1 = Math.pow(2, -6); // 0.015625
double result2 = Math.pow(2, -8); // 0.00390625
double result3 = Math.pow(2, -9); // 0.001953125
double result4 = Math.pow(2, -12); // 2.44140625E-4
double result5 = Math.pow(2, -13); // 1.220703125E-4
double result6 = Math.pow(2, -16); // 1.52587890625E-5
double result7 = Math.pow(2, -17); // 7.62939453125E-6
double result8 = Math.pow(2, -20); // 9.5367431640625E-7
double result9 = Math.pow(2, -21); // 4.76837158203125E-7
double result10 = Math.pow(2, -23); // 1.1920928955078125E-7
System.out.println(result0 + result1 + result2 + result3 + result4 + result5 + result6 + result7 + result8 + result9 + result10);
// 1.021875023841858
System.out.printf("%.15f%n", 16.35f); // 输出: 16.350000381469727
验证推导正确需要区别的是:本节有效位数指的是float底层二进制存储位数
所以针对文章开头的比较,现在可以验证,真实存储值保留6-7位有效数字的float,导致了巧合情况 0.1f +0.2f = 0.3f,而保留16位有效数字的double 0.1 + 0.2 ≠ 0.3
double f1 = FloatAnalysis.rayFloatStudy(0.1f);
double f2 = FloatAnalysis.rayFloatStudy(0.2f);
double f3 = FloatAnalysis.rayFloatStudy(0.3f);
double sum = f1 + f2;
System.out.println(f1 + "\t= 0.1f"); // 0.10000000149011612 = 0.1f
System.out.println(f2 + "\t= 0.2f"); // 0.20000000298023224 = 0.2f
System.out.println(f3 + "\t= 0.3f"); // 0.30000001192092896 = 0.3f
System.out.println(sum + "\t= 0.1f + 0.2f"); // 0.30000000447034836 = 0.1f + 0.2f
同理验证,无论取6-7位有效数字还是16位有效数字,0.3 + 0.6 均不会等于 0.9
double f3 = FloatAnalysis.rayFloatStudy(0.3f);
double f6 = FloatAnalysis.rayFloatStudy(0.6f);
double f9 = FloatAnalysis.rayFloatStudy(0.9f);
double sum = f3 + f6;
System.out.println(f3 + "\t= 0.3f"); // 0.30000001192092896 = 0.3f
System.out.println(f6 + "\t= 0.6f"); // 0.6000000238418579 = 0.6f
System.out.println(f9 + "\t= 0.9f"); // 0.8999999761581421 = 0.9f
System.out.println(sum + "\t= 0.3f + 0.6f"); // 0.9000000357627869 = 0.3f + 0.6f
由于浮点数的底层存储数据结构的原因,所以在比较浮点数的时候,通常建议不要使用 == 运算符来比较两个浮点数是否相等,而是使用一个小的容差值来进行比较,例如:
float
float num1 = 0.1f + 0.2f;
float num2 = 0.3f;
float epsilon = 1e-6f;
if (Math.abs(num1 - num2) < epsilon) {
System.out.println("相等");
} else {
System.out.println("不相等");
}
double
double a = 0.1d + 0.2d;
double b = 0.3d;
double epsilon = 1e-10d;
if (Math.abs(a - b) < epsilon) {
System.out.println("相等");
} else {
System.out.println("不相等");
}
在理解了原理之后,使用代码辅助验证会更清晰有助于理解,希望本文能够对您有所帮助。部分原理和图片使用《码出高效》,本文是作者对其的粗浅理解,可能存在一些错误或不完善之处,如有遗漏或错误欢迎各位补充。