老生常谈之慎用 BigDecimal

原创
01/03 00:06
阅读数 81

image

前言

  在项目中发现开发小组成员在写程序时,对于 Oracle 数据类型为 Number 的字段、mysql中的decimal字段,在实体映射类型中,有的人用 Double,有的人用 BigDecimal,没有一个统一规范,为此我在这里总结记录一下。
  BigDecimal,相信对于很多人来说都不陌生,很多人都知道他的用法,这是一种 java.math 包中提供的一种可以用来进行精确运算的类型。很多人都知道,在进行金额表示、金额计算等场景,不能使用 double、float等类型,而是要使用对精度支持的更好的 BigDecimal。所以,很多支付、电商、金融等业务中,BigDecimal 的使用非常频繁。但是,如果误以为只要使用 BigDecimal 表示数字,结果就一定精确,那就大错特错了!关于这个问题,在《阿里巴巴Java开发手册》中有一条建议,或者说是要求:

  这是一条【强制】建议,那么,这背后的原理是什么呢?想要搞清楚这个问题,主要需要弄清楚以下几个问题。在知道这两个问题的答案之后,我们也就大概知道为什么不能使用BigDecimal(double)来创建一个BigDecimal了。

  • 为什么说double不精确?
  • BigDecimal是如何保证精确的?

一、快速入门

1.1 简介

  Java 在 java.math 包中提供的 API 类 BigDecimal,用来对超过16位有效位的数进行精确的运算,而双精度浮点型变量double可以处理16位有效数。在实际应用中,需要对更大或者更小的数进行运算和处理。float和double只能用来做科学计算或者是工程计算,在商业计算中要用 java.math.BigDecimal。BigDecimal 所创建的是对象,我们不能使用传统的+、-、*、/等算术运算符直接对其对象进行数学运算,而必须调用其相对应的方法。
  在 Java 开发中,我们经常会使用 BigDecimal 来进行精确的数值计算,特别是在涉及货币、金融等领域。BigDecimal 提供了高精度的计算能力,可以避免由于浮点数计算引起的精度丢失问题。然而,在线上环境中,慎用 BigDecimal 是一个需要考虑的问题。

1.2 构造函数

构造器是类的特殊方法,专门用来创建对象,特别是带有参数的对象。

构造函数 说明
BigDecimal(int) 创建一个具有参数所指定整数值的对象
BigDecimal(double) 创建一个具有参数所指定双精度值的对象
BigDecimal(long) 创建一个具有参数所指定长整数值的对象
BigDecimal(String) 创建一个具有参数所指定以字符串表示的数值的对象

二、BigDecimal常用方法

2.1 常用方法

方法 说明
add(BigDecimal) BigDecimal对象中的值相加,返回BigDecimal对象
subtract(BigDecimal) BigDecimal对象中的值相减,返回BigDecimal对象
multiply(BigDecimal) BigDecimal对象中的值相乘,返回BigDecimal对象
divide(BigDecimal) BigDecimal对象中的值相除,返回BigDecimal对象
toString() 将BigDecimal对象的数值转换成字符串
doubleValue() 将BigDecimal对象中的值以双精度数返回
floatValue() 将BigDecimal对象中的值以单精度数返回
longValue() 将BigDecimal对象中的值以长整数返回
intValue() 将BigDecimal对象中的值以整数返回

2.2 BigDecimal格式化

  由于 NumberFormat 类的 format() 方法可以使用 BigDecimal 对象作为其参数,可以利用 BigDecimal 对超出16位有效数字的货币值、百分值以及一般数值进行格式化控制。这里利用 BigDecimal 对货币和百分比格式化为例。首先,创建 BigDecimal 对象,进行 BigDecimal 的算术运算后,分别建立对货币和百分比格式化的引用,最后利用 BigDecimal 对象作为 format() 方法的参数,输出其格式化的货币值和百分比。

public class BigDecimalDemo {
    public static void main(String[] args) {
        // 建立货币格式化引用
        NumberFormat currency = NumberFormat.getCurrencyInstance();
        // 建立百分比格式化引用
        NumberFormat percent = NumberFormat.getPercentInstance();
        // 百分比小数点最多3位
        percent.setMaximumFractionDigits(3);
        //贷款金额
        BigDecimal loanAmount = new BigDecimal("15000.48");
        //利率
        BigDecimal interestRate = new BigDecimal("0.008");
        //相乘
        BigDecimal interest = loanAmount.multiply(interestRate);

        System.out.println("贷款金额:\t" + currency.format(loanAmount));
        System.out.println("利率:\t" + percent.format(interestRate));
        System.out.println("利息:\t" + currency.format(interest));
    }
}

BigDecimal格式化保留2为小数,不足则补0:

public class BigDecimalDemo {
    public static void main(String[] args) {
        System.out.println(formatToNumber(new BigDecimal("3.435")));
        System.out.println(formatToNumber(new BigDecimal(0)));
        System.out.println(formatToNumber(new BigDecimal("0.00")));
        System.out.println(formatToNumber(new BigDecimal("0.001")));
        System.out.println(formatToNumber(new BigDecimal("0.006")));
        System.out.println(formatToNumber(new BigDecimal("0.206")));
    }

    /**
     * 1、0~1之间的BigDecimal小数,格式化后失去前面的0,则前面直接加上0。
     * 2、传入的参数等于0,则直接返回字符串"0.00"
     * 3、大于1的小数,直接格式化返回字符串
     */
    public static String formatToNumber(BigDecimal obj) {
        DecimalFormat df = new DecimalFormat("#.00");
        if (obj.compareTo(BigDecimal.ZERO) == 0) {
            return "0.00";
        } else if (obj.compareTo(BigDecimal.ZERO) > 0 && obj.compareTo(new BigDecimal(1)) < 0) {
            return "0" + df.format(obj).toString();
        } else {
            return df.format(obj).toString();
        }
    }
}

三、BigDecimal常见异常

3.1 使用除法时除不尽会报 ArithmeticException 异常

public static void main(String[] args) {
    System.out.println(BigDecimal.valueOf(121).divide(BigDecimal.valueOf(3)));
}

  通过BigDecimal的divide方法进行除法时当不整除,出现无限循环小数时,就会抛异常:java.lang.ArithmeticException: Non-terminating decimal expansion; no exact representable decimal result.
  处理上述异常也很简单,只需要 divide 方法设置精确的小数点,如:divide(xxxxx,2)

public static void main(String[] args) {
    System.out.println(BigDecimal.valueOf(121).divide(BigDecimal.valueOf(3), 2, RoundingMode.HALF_UP)); 
}

3.2 比较大小不方便

BigDecimal大小的比较都需要使用compareTo,如果需要返回更大的数或更小的数可以使用maxmin。还要注意在BigDecimal中慎用equals

public static void main(String[] args) {
    BigDecimal a = BigDecimal.valueOf(12.3);
    BigDecimal b = BigDecimal.valueOf(12.32);
    System.out.println(a.compareTo(b)); // -1
    System.out.println(b.compareTo(a)); //1
    System.out.println(a.max(b)); // 12.32
    System.out.println(a.min(b)); // 12.3
    System.out.println(b.max(a)); // 12.32
    System.out.println(b.min(a)); // 12.3
    System.out.println(BigDecimal.valueOf(1).equals(BigDecimal.valueOf(1.0))); //false
}

  BigDecimal 重写了 equals 方法,在 equals 方法里比较了小数位数,所以 BigDecimal.valueOf(1).equals(BigDecimal.valueOf(1.0)) 为什么结果为 false 就可以理解了。在附录中会贴出 BigDecimal 中的 equals方法的源码。通过源码可以知道 if (scale != xDec.scale) 这句代码就是比较了小数位数,不等则直接返回false

四、附录

4.1 工具类推荐

package org.dllwh.oshi;

import java.math.*;

/**
 * 把今天最好的表现当作明天最新的起点..~
 * <p>
 * Today the best performance as tomorrow the newest starter!
 *
 * @类描述: 用于高精确处理常用的数学运算
 * @author: <a href="mailto:duleilewuhen@sina.com">独泪了无痕</a>
 * @创建时间: 2024-01-02 23:29
 * @版本: V 1.0.1
 * @since: JDK 1.8
 */
public class ArithmeticHelper {
    // 默认除法运算精度
    private static final int DEF_DIV_SCALE = 10;

    /**
     * 提供精确的加法运算
     *
     * @param v1 被加数
     * @param v2 加数
     * @return 两个参数的和
     */

    public static double add(double v1, double v2) {
        BigDecimal b1 = new BigDecimal(Double.toString(v1));
        BigDecimal b2 = new BigDecimal(Double.toString(v2));
        return b1.add(b2).doubleValue();
    }

    /**
     * 提供精确的加法运算
     *
     * @param v1 被加数
     * @param v2 加数
     * @return 两个参数的和
     */
    public static BigDecimal add(String v1, String v2) {
        BigDecimal b1 = new BigDecimal(v1);
        BigDecimal b2 = new BigDecimal(v2);
        return b1.add(b2);
    }

    /**
     * 提供精确的加法运算
     *
     * @param v1    被加数
     * @param v2    加数
     * @param scale 保留scale 位小数
     * @return 两个参数的和
     */
    public static String add(String v1, String v2, int scale) {
        if (scale &lt; 0) {
            throw new IllegalArgumentException("The scale must be a positive integer or zero");
        }
        BigDecimal b1 = new BigDecimal(v1);
        BigDecimal b2 = new BigDecimal(v2);
        return b1.add(b2).setScale(scale, RoundingMode.HALF_UP).toString();
    }

    /**
     * 提供精确的减法运算
     *
     * @param v1 被减数
     * @param v2 减数
     * @return 两个参数的差
     */
    public static double sub(double v1, double v2) {
        BigDecimal b1 = new BigDecimal(Double.toString(v1));
        BigDecimal b2 = new BigDecimal(Double.toString(v2));
        return b1.subtract(b2).doubleValue();
    }

    /**
     * 提供精确的减法运算。
     *
     * @param v1 被减数
     * @param v2 减数
     * @return 两个参数的差
     */
    public static BigDecimal sub(String v1, String v2) {
        BigDecimal b1 = new BigDecimal(v1);
        BigDecimal b2 = new BigDecimal(v2);
        return b1.subtract(b2);
    }

    /**
     * 提供精确的减法运算
     *
     * @param v1    被减数
     * @param v2    减数
     * @param scale 保留scale 位小数
     * @return 两个参数的差
     */
    public static String sub(String v1, String v2, int scale) {
        if (scale &lt; 0) {
            throw new IllegalArgumentException("The scale must be a positive integer or zero");
        }
        BigDecimal b1 = new BigDecimal(v1);
        BigDecimal b2 = new BigDecimal(v2);
        return b1.subtract(b2).setScale(scale, RoundingMode.HALF_UP).toString();
    }

    /**
     * 提供精确的乘法运算
     *
     * @param v1 被乘数
     * @param v2 乘数
     * @return 两个参数的积
     */
    public static double mul(double v1, double v2) {
        BigDecimal b1 = new BigDecimal(Double.toString(v1));
        BigDecimal b2 = new BigDecimal(Double.toString(v2));
        return b1.multiply(b2).doubleValue();
    }

    /**
     * 提供精确的乘法运算
     *
     * @param v1 被乘数
     * @param v2 乘数
     * @return 两个参数的积
     */
    public static BigDecimal mul(String v1, String v2) {
        BigDecimal b1 = new BigDecimal(v1);
        BigDecimal b2 = new BigDecimal(v2);
        return b1.multiply(b2);
    }

    /**
     * 提供精确的乘法运算
     *
     * @param v1    被乘数
     * @param v2    乘数
     * @param scale 保留scale 位小数
     * @return 两个参数的积
     */
    public static double mul(double v1, double v2, int scale) {
        BigDecimal b1 = new BigDecimal(Double.toString(v1));
        BigDecimal b2 = new BigDecimal(Double.toString(v2));
        return round(b1.multiply(b2).doubleValue(), scale);
    }

    /**
     * 提供精确的乘法运算
     *
     * @param v1    被乘数
     * @param v2    乘数
     * @param scale 保留scale 位小数
     * @return 两个参数的积
     */
    public static String mul(String v1, String v2, int scale) {
        if (scale &lt; 0) {
            throw new IllegalArgumentException(
                    "The scale must be a positive integer or zero");
        }
        BigDecimal b1 = new BigDecimal(v1);
        BigDecimal b2 = new BigDecimal(v2);
        return b1.multiply(b2).setScale(scale, RoundingMode.HALF_UP).toString();
    }

    /**
     * 提供(相对)精确的除法运算,当发生除不尽的情况时,精确到
     * 小数点以后10位,以后的数字四舍五入
     *
     * @param v1 被除数
     * @param v2 除数
     * @return 两个参数的商
     */

    public static double div(double v1, double v2) {
        return div(v1, v2, DEF_DIV_SCALE);
    }

    /**
     * 提供(相对)精确的除法运算。当发生除不尽的情况时,由scale参数指
     * 定精度,以后的数字四舍五入
     *
     * @param v1    被除数
     * @param v2    除数
     * @param scale 表示表示需要精确到小数点以后几位。
     * @return 两个参数的商
     */
    public static double div(double v1, double v2, int scale) {
        if (scale &lt; 0) {
            throw new IllegalArgumentException("The scale must be a positive integer or zero");
        }
        BigDecimal b1 = new BigDecimal(Double.toString(v1));
        BigDecimal b2 = new BigDecimal(Double.toString(v2));
        return b1.divide(b2, scale, RoundingMode.HALF_UP).doubleValue();
    }

    /**
     * 提供(相对)精确的除法运算。当发生除不尽的情况时,由scale参数指
     * 定精度,以后的数字四舍五入
     *
     * @param v1    被除数
     * @param v2    除数
     * @param scale 表示需要精确到小数点以后几位
     * @return 两个参数的商
     */
    public static String div(String v1, String v2, int scale) {
        if (scale &lt; 0) {
            throw new IllegalArgumentException("The scale must be a positive integer or zero");
        }
        BigDecimal b1 = new BigDecimal(v1);
        BigDecimal b2 = new BigDecimal(v1);
        return b1.divide(b2, scale, RoundingMode.HALF_UP).toString();
    }

    /**
     * 提供精确的小数位四舍五入处理
     *
     * @param v     需要四舍五入的数字
     * @param scale 小数点后保留几位
     * @return 四舍五入后的结果
     */
    public static double round(double v, int scale) {
        if (scale &lt; 0) {
            throw new IllegalArgumentException("The scale must be a positive integer or zero");
        }
        BigDecimal b = new BigDecimal(Double.toString(v));
        return b.setScale(scale, RoundingMode.HALF_UP).doubleValue();
    }

    /**
     * 提供精确的小数位四舍五入处理
     *
     * @param v     需要四舍五入的数字
     * @param scale 小数点后保留几位
     * @return 四舍五入后的结果
     */
    public static String round(String v, int scale) {
        if (scale &lt; 0) {
            throw new IllegalArgumentException(
                    "The scale must be a positive integer or zero");
        }
        BigDecimal b = new BigDecimal(v);
        return b.setScale(scale, RoundingMode.HALF_UP).toString();
    }

    /**
     * 取余数
     *
     * @param v1    被除数
     * @param v2    除数
     * @param scale 小数点后保留几位
     * @return 余数
     */
    public static String remainder(String v1, String v2, int scale) {
        if (scale &lt; 0) {
            throw new IllegalArgumentException(
                    "The scale must be a positive integer or zero");
        }
        BigDecimal b1 = new BigDecimal(v1);
        BigDecimal b2 = new BigDecimal(v2);
        return b1.remainder(b2).setScale(scale, RoundingMode.HALF_UP).toString();
    }

    /**
     * 取余数  BigDecimal
     *
     * @param v1    被除数
     * @param v2    除数
     * @param scale 小数点后保留几位
     * @return 余数
     */
    public static BigDecimal remainder(BigDecimal v1, BigDecimal v2, int scale) {
        if (scale &lt; 0) {
            throw new IllegalArgumentException(
                    "The scale must be a positive integer or zero");
        }
        return v1.remainder(v2).setScale(scale, RoundingMode.HALF_UP);
    }

    /**
     * 比较大小
     *
     * @param v1 被比较数
     * @param v2 比较数
     * @return 如果v1 大于v2 则 返回true 否则false
     */
    public static boolean compare(String v1, String v2) {
        BigDecimal b1 = new BigDecimal(v1);
        BigDecimal b2 = new BigDecimal(v2);
        int bj = b1.compareTo(b2);
        return bj &gt; 0;
    }
}

4.2 BigDecimal(double)有什么问题

  首先,计算机是只认识二进制的,即0和1,这个大家一定都知道。那么,所有数字,包括整数和小数,想要在计算机中存储和展示,都需要转成二进制。十进制整数转成二进制很简单,通常采用"除2取余,逆序排列"即可,如10的二进制为1010。但是,小数的二进制如何表示呢?
  十进制小数转成二进制,一般采用"乘2取整,顺序排列"方法,如0.625转成二进制的表示为0.101。但是,并不是所有小数都能转成二进制,如0.1就不能直接用二进制表示,他的二进制是0.000110011001100… 这是一个无限循环小数。所以,计算机是没办法用二进制精确的表示0.1的。也就是说,在计算机中,很多小数没办法精确的使用二进制表示出来。那么,这个问题总要解决吧。那么,人们想出了一种采用一定的精度,使用近似值表示一个小数的办法。这就是IEEE 754(IEEE二进制浮点数算术标准)规范的主要思想。
  IEEE 754规定了多种表示浮点数值的方式,其中最常用的就是32位单精度浮点数和64位双精度浮点数。在Java中,使用float和double分别用来表示单精度浮点数和双精度浮点数。所谓精度不同,可以简单的理解为保留有效位数不同,采用保留有效位数的方式近似的表示小数。所以,大家也就知道为什么double表示的小数不精确了。
  那么BigDecimal又是如何精确计数?如果大家看过BigDecimal的源码,其实可以发现,实际上一个BigDecimal是通过一个"无标度值"和一个"标度"来表示一个数的。在BigDecimal中,标度是通过scale字段来表示的。而无标度值的表示比较复杂。当unscaled value超过阈值(默认为Long.MAX_VALUE)时采用intVal字段存储unscaled value,intCompact字段存储Long.MIN_VALUE,否则对unscaled value进行压缩存储到long型的intCompact字段用于后续计算,intVal为空。涉及到的字段就是这几个:

public class BigDecimal extends Number implements Comparable<bigdecimal> {

    private final BigInteger intVal;

    private final int scale; 

    private final transient long intCompact;

}

> 关于无标度值的压缩机制大家了解即可,不是本文的重点,大家只需要知道BigDecimal主要是通过一个无标度值和标度来表示的就行了。

  BigDecimal 中提供了一个通过 double 创建 BigDecimal 的方法——BigDecimal(double) ,但是,同时也给我们留了一个坑!因为我们知道,double表示的小数是不精确的,如0.1这个数字,double只能表示他的近似值,这是因为doule自身表示的只是一个近似值。所以,如果我们在代码中,使用BigDecimal(double) 来创建一个BigDecimal的话,那么是损失了精度的,这是极其严重的。

4.3 使用BigDecimal(String)创建

  那么,该如何创建一个精确的BigDecimal来表示小数呢,答案是使用String创建。而对于BigDecimal(String) ,当我们使用new BigDecimal("0.1")创建一个BigDecimal 的时候,其实创建出来的值正好就是等于0.1的。那么他的标度也就是1。但是需要注意的是,new BigDecimal("0.10000")和new BigDecimal("0.1")这两个数的标度分别是5和1,如果使用BigDecimal的equals方法比较,得到的结果是false。那么,想要创建一个能精确的表示0.1的BigDecimal,请使用以下两种方式:

BigDecimal recommend1 = new BigDecimal("0.1");

BigDecimal recommend2 = BigDecimal.valueOf(0.1);

4.4 BigDecimal 的 equals方法源码

/**
 * 该方法认为两个BigDecimal对象只有在值和比例相等时才相等,所以当使用该方法比较2.0与2.00时,二者不相等。
 */
public boolean equals(Object x) {
    // 比较对象是否为 BigDecimal 数据类型,不是直接返回false
    if (!(x instanceof BigDecimal))
        return false;
    BigDecimal xDec = (BigDecimal) x;
    if (x == this)
        return true;
    // 比较 scale 值是否相等。在这里比较了小数位数,不等返回false。
    // scale 是BigDecimal 的标度。如果为零或正数,则标度是小数点后的位数。
    // 如果为负数,则将该数的非标度值乘以 10 的负 scale 次幂。例如,-3 标度是指非标度值乘以 1000。
    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());
}

4.5 关于MySql中如何选用这两种类型

  在查资料的时候还看到了关于MySql中如何选用这两种类型的问题,也在此记录一下。在数据库中除了指定数据类型之外还需要指定精度,所以在MySqlDouble的计算精度丢失比在Java里要高很多,Java的默认精度到了15-16位。在阿里的编码规范中也强调 统一带小数类型的一律要使用Decimal类型而不是Double,使用Decimal可以大大减少计算采坑的概率。所以在选用类型时,与Java同样,在精度要求不高的情况下可以使用Double,比如经纬度,但是有需要计算、金融金额等优先使用Decimal

五、小结

  在需要精确的小数计算时再使用 BigDecimal,BigDecimal 的性能比 double 和 float差,在处理庞大,复杂的运算时尤为明显。故一般精度的计算没必要使用BigDecimal,尽量使用参数类型为String的构造函数。
  BigDecimal都是不可变的(immutable)的, 在进行每一次四则运算时,都会产生一个新的对象 ,所以在做加减乘除运算时要记得要保存操作后的值。所以,当我们使用new BigDecimal(0.1)创建一个BigDecimal 的时候,其实创建出来的值并不是正好等于0.1的。

参考链接:

image

展开阅读全文
加载中
点击引领话题📣 发布并加入讨论🔥
打赏
0 评论
0 收藏
0
分享
返回顶部
顶部