聊聊Java中浮点丢失精度的事

2023年 9月 4日 88.5k 0

在说这个之前,我们先看看十进制到二进制的转换过程

整数的十进制到二进制的转换过程

用白话说这个过程就是不断的除2,得到商继续除,直到商小于1为止,然后他每次结果的余数倒着排列出来就是它的二进制结果了,直接上图

整数十进制转二进制.jpg
说一下为什么倒着排列就是二进制结果哈

通俗点说就是整数是一步一步除下来的,那回去不得一步一步乘上去?也就是说从上到下就是二进制从低位到高位的过程。

小数十进制到二进制的转换过程

小数的十进制到二进制的转换其实和整数类似,只不过算的方式变成了乘法,也就是用小数不断的乘2,然后得到的结果的整数部分拿出来,接着剩下的小数部分继续乘2,直到小数部分为0为止,直接上图~

小数十进制转二进制过程(不循环).jpg
二进制结果中的二分之一是转换后的,其实就是2的-1次方,-2次方。。。

当然了,小数转二进制的过程中,很多情况下都是无尽的,接着上图

小数十进制转二进制过程(循环).jpg
所以可以看到这样的循环下去是得不到二进制的结果的,所以计算机就要进行取舍。也就是IEEE 754规范

IEEE 754规范

IEEE 754规定了四种标识浮点数值的方式,单精确度(32位),双精确度(64位),延伸单精确度(43比特以上,很少用)和延伸双精确度(79比特以上,通常80位)

最常用的还是单精确度和双精确度,也就是对标的float和double。但是IEEE 754规范并没有解决精确标识小数的问题,只是提供了一种用近似值标识小数的方式。而且精确度不同近似值也会不同。# 为什么会精度丢失?教你看懂 IEEE-754!

下面有个例子来看一下丢失精度的问题,如0.1+0.2
0.1的64位二进制:0.00011001100110011001100110011001100110011001100110011001
0.2的64位二进制:0.00110011001100110011001100110011001100110011001100110011
二者相加的结果为:0.30000000000000004

那么如何解决精度问题呢?

BigDecimal

BigDecimal使用java.math包提供的,在涉及到金钱相关的计算的时候都需要使用它,而且其中提供了大量的方法,比如加减乘除都是可以直接调用的。

先看这个问题,BigDecimal中的比较问题

先看下面这个例子

public class ReferenceDemo {

    public static void main(String[] args) {

        BigDecimal bigDecimal1 = new BigDecimal(1);
        BigDecimal bigDecimal2 = new BigDecimal(1);
        System.out.println(bigDecimal1.equals(bigDecimal2));

        BigDecimal bigDecimal3 = new BigDecimal(1);
        BigDecimal bigDecimal4 = new BigDecimal(1.0);
        System.out.println(bigDecimal3.equals(bigDecimal4));

        BigDecimal bigDecimal5 = new BigDecimal("1");
        BigDecimal bigDecimal6 = new BigDecimal("1.0");

        System.out.println(bigDecimal5.equals(bigDecimal6));
    }


}

结果为:

image.png
其中第二个例子和第三个例子的不同是需要聊一聊的。为什么会出现这种呢?下面是BigDecimal中的equals的源码。

public boolean equals(Object x) {
    if (!(x instanceof BigDecimal))
        return false;
    BigDecimal xDec = (BigDecimal) x;
    if (x == this)
        return true;
    //关键在这一行,比较了scale    
    if (scale != xDec.scale)
        return false;
    long s = this.intCompact;
    long xs = xDec.intCompact;
    if (s != INFLATED) {
        if (xs == INFLATED)
            xs = compactValFor(xDec.intVal);
        return xs == s;
    } else if (xs != INFLATED)
        return xs == compactValFor(this.intVal);

    return this.inflated().equals(xDec.inflated());
}

由上面的注释可以看到BigDecimal中有一个很关键的属性,就是scale,标度。标度是什么?
首先看一下BigDecimal的结构

public class BigDecimal extends Number implements Comparable {
    /**
     * 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
                              
     /**
     * 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;
}

我截取了几个关键字段,依次看一下:

intVal: 无标度值

scale: 标度

intCompact: 当intVal超过阈值(默认为Long.MAX_VALUE)时,进行压缩运算,结果存到这个字段上,用于后续计算。

注释中解释到,scale为0或者正数的时候代表数字小数点之后的位数,如果scale为负数,代表数字的无标度值需要乘10的该负数的绝对值的幂,即末尾有几个0

比如123.123这个数,他的intVal就是123123,scale就是3了

而二进制无法标识0.1,通过BigDecimal标识的话,它的intVal就是1,scale也是1。

接着看回上面的例子,传入的参数是字符串的bigDecimal5和bigDecimal6,为什么就返回了false。上图

image.png

他们的标度是不同的,所以直接返回了false,那么在看bigDecimal3和bigDecimal4的比较,为什么就返回了true呢,同样上图

image.png
可以看到他们的intVal和scale都是相等的,但是明明传入了不同的,有兴趣的可以取看看源码,找一些资料,对于1.0这个数,它本质上也是一个整数,经过一系列的运算他的intVal还是1,scale还是0,所以比较之后返回的是true。

这时候就能看出来equals方法的一些问题了,用equals涉及到scale的比较,实际的结果可能和预期不一样,所在BigDecimal的比较推荐用compareTo方法,如果返回0,代表相等

BigDecimal bigDecimal5 = new BigDecimal("1");
BigDecimal bigDecimal6 = new BigDecimal("1.0");

System.out.println(bigDecimal5.compareTo(bigDecimal6));

说到这里同时提一下,不要用传参为double的构造方法,同样会丢失精度,如果需要小数,需要传入字符串的小数来获取BigDecimal的实例对象。

说到这其实应该明白了他是怎么保证精度的了,其实关键点就是scale,这个标度贯穿了整个过程,加减乘除的运算都需要它来把控。上面说了其实2个参数最为关键,一个是无标度值,一个是标度,无标度值就是整数了,以加法为例子,不就可以变成整数的加法了吗,然后用scale控制小数点,说是这么说,实现过程还是很复杂的,有兴趣的可以自己查资料去学习。

除了用字符串代替double来表示BigDecimal的小数,其实也可以通过BigDecimal.valueOf()方法,它传入double之后可以和字符串一样的效果,为啥呢?上代码

public static BigDecimal valueOf(double val) {
    // Reminder: a zero double returns '0.0', so we cannot fastpath
    // to use the constant ZERO.  This might be important enough to
    // justify a factory approach, a cache, or a few private
    // constants, later.
    return new BigDecimal(Double.toString(val));
}

它把传入的double给toString了。。。。

相关文章

JavaScript2024新功能:Object.groupBy、正则表达式v标志
PHP trim 函数对多字节字符的使用和限制
新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
为React 19做准备:WordPress 6.6用户指南
如何删除WordPress中的所有评论

发布评论