BigDecimal踩坑总结&最佳实践

BigDecimal踩坑总结&最佳实践

一、概述

Java在java.math包中提供的API类BigDecimal,用来对超过16位有效位的数进行精确的运算。双精度浮点型变量double可以处理16位有效数,但在实际应用中,可能需要对更大或者更小的数进行运算和处理。

一般情况下,对于那些不需要准确计算精度的数字,我们可以直接使用Float和Double处理,但是Double.valueOf(String) 和Float.valueOf(String)会丢失精度。所以开发中,如果我们需要精确计算的结果,则必须使用BigDecimal类来操作。

BigDecimal所创建的是对象,故我们不能使用传统的+、-、*、/等算术运算符直接对其对象进行数学运算,而必须调用其相对应的方法。方法中的参数也必须是BigDecimal的对象。构造器是类的特殊方法,专门用来创建对象,特别是带有参数的对象。

二、使用BigDecimal的常见的十大坑

2.1)创建BigDecimal对象

BigDecimal常见的构造函数如下:

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

使用示例:

BigDecimal bd1 = new BigDecimal(0.02);
BigDecimal bd2 = BigDecimal.valueOf(0.02);
BigDecimal bd3 = new BigDecimal("0.02");
BigDecimal bd4 = new BigDecimal(Double.toString(0.02));
System.out.println("bd1 = " + bd1);
System.out.println("bd2 = " + bd2);
System.out.println("bd3 = " + bd3);
System.out.println("bd4 = " + bd4);

输出如下:

bd1 = 0.0200000000000000004163336342344337026588618755340576171875
bd2 = 0.02
bd3 = 0.02
bd4 = 0.02

分析原因:

参数类型为double的构造方法的结果有一定的不可预知性。有人可能认为在Java中写入newBigDecimal(0.2)所创建的BigDecimal正好等于 0.2(非标度值 1,其标度为 1),但是它实际上等于0.0200000000000000004163336342344337026588618755340576171875。这是因为0.2无法准确地表示为 double(或者说对于该情况,不能表示为任何有限长度的二进制小数)。这样,传入到构造方法的值不会正好等于 0.2(虽然表面上等于该值)。

String 构造方法是完全可预知的:写入 newBigDecimal(“0.2”) 将创建一个 BigDecimal,它正好等于预期的 0.2。因此,比较而言, 通常建议优先使用String构造方法,参数为字符串就不会出现精度问题。

当double必须用作BigDecimal的源时,请注意,此构造方法提供了一个准确转换;它不提供与以下操作相同的结果:先使用Double.toString(double)方法,然后使用BigDecimal(String)构造方法,将double转换为String。要获取该结果,请使用static valueOf(double)方法,也可以通过BigDecimal.valueOf(double value)静态方法来创建对象。

2.2)BigDecimal 不可变

BigDecimal 和String 一样具有对象不可变行,一旦赋值就不会再变,即便做加减乘除运算;使用示例:

BigDecimal count = new BigDecimal("3.1415");
count.add(new BigDecimal("0.1645"));
System.out.println("count:" + count); // count:3.1415
// 并非在count基础上做运算
System.out.println("result:" + count.add(new BigDecimal("0.1645"))); // result:3.3060

输出如下:

count:3.1415
result:3.3060

2.3)保留小数位数

BigDecimal保留小数位数,主要用setScale方法:

public BigDecimal setScale(int newScale)
public BigDecimal setScale(int newScale, int roundingMode)
public BigDecimal setScale(int newScale, RoundingMode roundingMode)

第2个参数说明:

ROUND_CEILING      //向正无穷方向舍入
ROUND_DOWN         //向零方向舍入
ROUND_FLOOR        //向负无穷方向舍入
ROUND_HALF_DOWN    //向(距离)最近的一边舍入,除非两边(的距离)是相等,如果是这样,向下舍入,例如1.55 保留一位小数结果为1.5
ROUND_HALF_EVEN    //向(距离)最近的一边舍入,除非两边(的距离)是相等,如果是这样,如果保留位数是奇数,使用ROUND_HALF_UP,如果是偶数,使用ROUND_HALF_DOWN
ROUND_HALF_UP      //向(距离)最近的一边舍入,除非两边(的距离)是相等,如果是这样,向上舍入, 1.55保留一位小数结果为1.6
ROUND_UNNECESSARY  //计算结果是精确的,不需要舍入模式
ROUND_UP           //向远离0的方向舍入

使用示例:

BigDecimal b = new BigDecimal("1.6666");
System.out.println("result b:" + b.setScale(2, BigDecimal.ROUND_HALF_UP)); // 1.67
System.out.println("result b:" + b.setScale(2)); // 精度错误

输出如下:

result b:1.67
Exception in thread "main" java.lang.ArithmeticException: Rounding necessary

分析原因:

setScale方法默认使用的roundingMode是ROUND_UNNECESSARY,不需要使用舍入模式,设置精度2位,小数点后4位肯定会抛异常。

2.4)BigDecimal转回String

BigDecimal转回String提供了三个方法:

// 有必要时使用科学计数法
String toString();   
// 不使用科学计数法
String toPlainString(); 
// 工程计算中经常使用的记录数字的方法,与科学计数法类似,但要求10的幂必须是3的倍数
String toEngineeringString();  

使用示例:

BigDecimal d = BigDecimal.valueOf(12334535345456700.12345634534534578901);
String out = d.toString(); // Or perform any formatting that needs to be done
System.out.println(out); 

输出如下:

1.23345353454567E+16

分析原因:

默认的toString方法结果被转换成了科学计数法,推荐使用toPlainString

2.5)BigDecimal等值比较

BigDecimal提供了equals、compareTo两个方法可以进行比较;使用示例:

BigDecimal bd5 = new BigDecimal("2.0");
BigDecimal bd6 = new BigDecimal("2.00");
System.out.println(bd5.equals(bd6));
System.out.println(bd5.compareTo(bd6));

输出结果:

false
true

分析原因:

BigDecimal中equals方法的实现会比较两个数字的精度,而compareTo方法则只会比较数值的大小。

2.6)使用BigDecimal进行计算时参数不能为NULL

在使用BigDecimal类型进行计算时,进行加、减、乘、除、比较大小时,一定要保证参与计算的两个值不能为空,否则会抛出java.lang.NullPointerException异常。使用示例:

BigDecimal number1 = new BigDecimal("88.88");
BigDecimal number2 = null;

BigDecimal number3 = number1.add(number2);
System.out.println("number1 add number2 = " + number3);

输出:

Exception in thread "main" java.lang.NullPointerException

2.7)使用BigDecimal进行除法计算时被除数不能为0

使用示例:

BigDecimal number4 = new BigDecimal("88.88");
BigDecimal number5 = BigDecimal.ZERO;

BigDecimal number6 = number4.divide(number5);
System.out.println("number5 divide number6 = " + number6);

输出:

Exception in thread "main" java.lang.ArithmeticException: Division by zero

2.8)使用divide方法方法结果为无限循环小数

使用divide方法特别要注意设置精度;使用示例:

// 含税金额
BigDecimal inclusiveTaxAmount = new BigDecimal("1000");
// 税率
BigDecimal taxRate = new BigDecimal("0.13");
// 不含税金额 = 含税金额 / (1+税率)
BigDecimal exclusiveTaxAmount = inclusiveTaxAmount.divide(BigDecimal.ONE.add(taxRate));
System.out.println(exclusiveTaxAmount);

输出结果:

Exception in thread "main" java.lang.ArithmeticException: Non-terminating decimal expansion; no exact representable decimal result.

分析原因:

报错原因是因为无法整除,导致结果是无限循环小数:关于这个异常,Oracle的官方文档有具体说明

If the quotient has a nonterminating decimal expansion and the operation is specified to return an exact result, an ArithmeticException is thrown. Otherwise, the exact result of the division is returned, as done for other operations.

大意是,如果除法的商的结果是一个无限小数但是我们期望返回精确的结果,那程序就会抛出异常。回到我们的这个例子,我们需要告诉JVM我们不需要返回精确的结果就好了

解决方案:

解决方案是指定下舍入模式,需求是保留2位小数,四舍五入:

// 不含税金额 = 含税金额 / (1+税率)
BigDecimal exclusiveTaxAmount = inclusiveTaxAmount.divide(BigDecimal.ONE.add(taxRate), 2, RoundingMode.HALF_UP);

此时的输出结果为:884.96

2.9)执行顺序不能调换(乘法交换律失效)

乘法满足交换律是一个常识,但是在计算机的世界里,会出现不满足乘法交换律的情况;使用示例:

BigDecimal h = BigDecimal.valueOf(1.0);
BigDecimal i = BigDecimal.valueOf(3.0);
BigDecimal j = BigDecimal.valueOf(3.0);
System.out.println(h.divide(i, 2, RoundingMode.HALF_UP).multiply(j)); // 0.990
System.out.println(h.multiply(j).divide(i, 2, RoundingMode.HALF_UP)); // 1.00

输出结果:

0.990
1.00

别小看这这0.01的差别,在汇金领域,会产生非常大的金额差异,非常容易造成资损,使用顺序建议先乘后除。

2.10)BigDecimal格式化

由于NumberFormat类的format()方法可以使用BigDecimal对象作为其参数,可以利用BigDecimal对超出16位有效数字的货币值,百分值,以及一般数值进行格式化控制。

以利用BigDecimal对货币和百分比格式化为例。首先,创建BigDecimal对象,进行BigDecimal的算术运算后,分别建立对货币和百分比格式化的引用,最后利用BigDecimal对象作为format()方法的参数,输出其格式化的货币值和百分比。

使用示例:

//建立货币格式化引用
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));

输出结果:

贷款金额:  ¥15,000.48
利率:  0.8%
利息:  ¥120.00

三、最佳实践

关于金额计算,很多业务团队会基于BigDecimal再封装一个Money类,其实我们直接可以用一个半官方的Money类:JSR 354 ,虽然没能在Java 9中成为Java标准,很有可能集成到后续的Java版本中成为官方库。当然基于金额也有几个Money三方库组件使用,比如joda-money,hutool也提供了Money,三者之间封装Money功能都相差不大,hutool更适合国人使用。

具体使用,需要引入如下maven依赖:


<dependency>
  <artifactId>hutool-all</artifactId>
  <groupId>cn.hutool</groupId>
  <version>5.7.7</version>
</dependency>
<dependency>
  <groupId>org.javamoney</groupId>
  <artifactId>moneta</artifactId>
  <version>1.1</version>
</dependency>
<dependency>
  <groupId>org.joda</groupId>
  <artifactId>joda-money</artifactId>
  <version>1.0.1</version>
</dependency>

3.1)为什么推荐使用Money

货币类中封装了货币金额和币种。目前金额在内部是long类型表示,单位是所属币种的最小货币单位(对人民币是分)。目前,Money货币实现了以下主要功能:

  • 支持货币对象与double(float)/long(int)/String/BigDecimal之间相互转换;

  • 货币类在运算中提供与JDK中的BigDecimal类似的运算接口,BigDecimal的运算接口支持任意指定精度的运算功能,能够支持各种可能的财务规则;

  • 货币类在运算中也提供一组简单运算接口,使用这组运算接口,则在精度处理上使用缺省的处理规则;

  • 提供基本的格式化功能;

  • Money类中不包含与业务相关的统计功能和格式化功能。业务相关的功能建议使用utility类来实现;

  • Money类实现了Serializable接口,支持作为远程调用的参数和返回值;

  • Money类实现了equals和hashCode方法。

3.2)为什么不推荐使用BigDecimal

不建议直接使用BigDecimal的原因在于:

  • 使用BigDecimal,同样金额和币种的货币使用BigDecimal存在多种可能的表示,例如:new BigDecimal(“10.5”)与new BigDecimal(“10.50”)不相等,因为scale不等。使得Money类,同样金额和币种的货币只有一种表示方式,new Money(“10.5”)和new Money(“10.50”)应该是相等的;
  • BigDecimal是Immutable,一旦创建就不可更改,对BigDecimal进行任意运算都会生成一个新 BigDecimal对象,因此对于大批量统计的性能不够满意。Money类是mutable的,对大批量统计提供较好的支持。

3.3)使用示例

以javamoney为例具体介绍一下使用方式:新建Money类

CurrencyUnit cny = Monetary.getCurrency("CNY");
Money money = Money.of(1.0, cny); 
// 或者 Money money = Money.of(1.0, "CNY");
//System.out.println(money);

金额运算

CurrencyUnit cny = Monetary.getCurrency("CNY");
Money oneYuan = Money.of(1.0, cny);
Money threeYuan = oneYuan.add(Money.of(2.0, "CNY")); //CNY 3
Money tenYuan = oneYuan.multiply(10); // CNY 10
Money fiveFen = oneYuan.divide(2); //CNY 0.5

比较相等

Money fiveFen = Money.of(0.5, "CNY"); //CNY 0.5
Money anotherFiveFen = Money.of(0.50, "CNY"); // CNY 0.50
System.out.println(fiveFen.equals(anotherFiveFen)); // true

可以看到,这个类对金额做了显性的抽象,增加了金额的单位,也避免了直接使用BigDecimal的一些坑。

3.4)数据库如何保存Money

一般在数据库表结构中使用金额定义为decimal类型,Java对象属性为BigDecimal,Mybatis会自动进行转换映射,如果使用了Money包装类如何进行转换映射呢,可以实现一个Mybatis的TypeHandler。具体TypeHandler定义如下:


public class MoneyTypeHandler extends BaseTypeHandler<Money> {
    
    
    @Override
    public void setNonNullParameter(PreparedStatement ps, int i, Money parameter, JdbcType jdbcType) throws SQLException {
    
    
        ps.setLong(i, parameter.getAmountMinorLong());
    }
 
    @Override
    public Money getNullableResult(ResultSet rs, String columnName) throws SQLException {
    
    
        return parseMoney(rs.getLong(columnName));
    }
 
    @Override
    public Money getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
    
    
        return parseMoney(rs.getLong(columnIndex));
    }
 
    @Override
    public Money getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
    
    
        return parseMoney(cs.getLong(columnIndex));
    }
 
    private Money parseMoney(long value) {
    
    
        return Money.of(CurrencyUnit.of("CNY"), value / 100.00);
    }
}

在springboot应用里的application.properties添加如下配置:

# 指定handler包位置
mybatis.type-handlers-package=com.zhichubao.demo.handler
# 开启实体里和数据库对于下划线的处理
mybatis.configuration.map-underscore-to-camel-case=true

上面MoneyTypeHandler作用是全局的,如果需要转换的字段是指定的某个字段,在mapper中需要转换的字段,指定typeHandler属性。具体示例如下:

@Insert("insert into goods (name, create_time, update_time, price, price1)"
        + "values (#{name}, now(), now(), #{price, typeHandler=com.example.demo.handler.MoneyTypeHandler}, " +
        " #{price1, typeHandler=com.example.demo.handler.MoneyTypeHandler})")
@Options(useGeneratedKeys = true)
int save(Goods goods);


@Select("select * from goods where id = #{id}")
@Results({
    
    
        @Result(id = true, column = "id", property = "id"),
        @Result(column = "create_time", property = "createTime"),
        @Result(column = "price", property = "price", typeHandler = MoneyTypeHandler.class),
        @Result(column = "price1", property = "price1", typeHandler = MoneyTypeHandler.class)
})
Goods findById(@Param("id") Long id);

四、总结

本篇文章介绍了BigDecimal使用中场景的坑,以及基于这些坑我们得出的“最佳实践”。虽然某些场景下推荐使用BigDecimal,它能够达到更好的精度,但性能相较于double和float,还是有一定的损失的,特别在处理庞大,复杂的运算时尤为明显。故一般精度的计算没必要使用BigDecimal。而必须使用时,一定要规避上述的坑。

最后我强烈推荐使用Money,Money在使用时能够自动避免上述很多坑,在一定程度能够避免异常以及金融方面的资损。

猜你喜欢

转载自blog.csdn.net/jianghao233/article/details/125970370