背景
事情是这样的,由于业务中需要将几百个浮点数求和,因为以前都会将浮点数的值持久化在数据库中,求Sum这样的操作都是依赖数据库聚合函数Sum,从来都没有考虑过求和之后的值是否会精度损失的问题,也都是正确的。
但在这次项目中,需要将这些浮点数持久化在本地文件中,所以数值求和就不能依赖数据库了,只能自己写一个数值累加的函数,本来想这个事儿根本不算个事儿,写个for循环累加分分钟就搞定了,没有想到最后在单元测试的时候总是和标准值对不上,总是少个几分或者多几分,初步判断应该是在数值累加精度损失造成的。
为了彻底搞明白这个问题,对浮点数相关的存储格式学习了一遍。只有对底层的存储格式有了解才能对实际场景中产生的诡异现象作一个圆满的解释。
先看一个问题
执行以下代码:
System.out.println(Float.parseFloat("132541.35")); BigDecimal f = new BigDecimal(("132541.35")); System.out.println(f.floatValue());
结果:
132541.34
132541.34
无论是primitive还是BigDecimal精度都损失了,如果使用一个精度已经损失的值进行累加操作,最后的总和肯定是不对的,正所谓失之毫厘谬之千里。
为什么会精度损失?
要解释为什么会精度损失就要,就要找到其背后的本质,那就需要先看看计算机中是怎么保存浮点数的。
先到百度上搜先关的技术文章,找到一篇看似比较权威的博客(http://blog.csdn.net/rsp19801226/article/details/3085343)到的最核心的就是float和里面说double在内存中存贮格式,如下:
Float存储格式:
Double存储格式:
`
先看看“132541.35”这个浮点数在计算机中是怎么保存的。计算机中只能保存二进制,所以我们需要将它转成二进制。
“132541.35”分为整数部分,和小数部分。
十进制整数部分转二进制
整数部分转二进制是将整数部分进行迭代除2取余,直到商为0为止,将每次迭代之后的余数连接起来就行了。
132541的二进制表示:
十进制小数部分转二进制
十进制小数转成二进制小数,是需要对小数部分进行迭代乘2,每次迭代整数部分如果是1,则取小数部分进行下一次迭代,直到小数部分为0为止。
如上图所示,当得到0.4的小数部分之后,之后的每次迭代乘以2之后永远也没有办法得到整数1,就是以“0110”无限循环,所以0.35 不能用有限位的二进制表示,这就像1/3是无法用有限位的十进制表示是一样的道理,随着0.3333的尾数精度增加,只能无限趋近于1/3的真实值而不可能相等。
完整的“132541.35”二进制表示:
按照之前的说明,float是用32位存储的,前一位是符号位,中间8位为指数位,最后23位为尾数位。
以下是“132541.35”在计算机中存储结构:
第一位是符号位1代表正数,中间的指数段8位用移码来表示17,至于17怎么转成移码这里不深究了,23位尾数段,用来表示小数的部分也只用到了6位(底色是黄色的部分),其余的精度硬生生的被截断了。
试着将“010110”再转成十进制小数看看,1/4+1/16+1/32=0.34375 与0.35比较,精度损失了不少,这也就解释了最开头的那个问题产生的原因。如果要得到正确满意的结果,只能用双精度double了,因为双精度在尾数部分可以保存52位,可以保存比float更长的尾数部分。这样可以适当提高浮点数的精度。
但是问题是无论是单精度还是双精度,如果整数部分本身就是一个非常大的值的话那都会挤压小数部分的精度。
在实际的业务场景中,如果只是涉及到两个浮点数之间的相加求和,那还能得到满意的结果。如果是多个浮点数求和话就,随着累加值整数部分不断变大,小数部分的精度损失会越来越厉害。
解决之道
提高精度
可以总结出一个经验,就是在日常中,如果我们需要对浮点数进行累加操作,在明确整数部分的累加值不会特别大时候,需要全部使用double类型来操作。
使用BigDecimal
如果,最后的累加值会比较大,那就需要使用BigDecimal类进行累加,因为它不会因为整数部分变大而影响小数部分的精度。但是,由于BigDecimal是不可变对象,当两个BigDecimal对象累加之后会生成一个新的BigDecimal对象,本来如果用primitive值累加的话全部是在栈上完成的,如果用bigDecimal会在堆内存中产生大量临时对象,这样对VM(java)的young区的垃圾回收可能产生一定压力。
将浮点数先转成Integer
原理很简单,就是业务系统中保存的浮点数一般是表示金额,而金额一般都精确到分,所以在反序列化数据时候先将数据乘上100,转成以分为单位的int数值,然后再进行累加操作。这样带来的好处是累加过程中没有精度损失的烦恼,而且int值的累加操作理论上来说会比浮点的累加操作快不少,终端显示的时候只要再除以100就可以了。
但是特别注意的是,如果业务上使用这种方式处理浮点数操作的话,一定要注意,真实值被乘以两次100的问题,特别是在右多个函数嵌套的时候,乘两次100的问题极易发生,如果这个值的意义是“商家应付款”那将成为一个灾难。
如果要规避这样的问题,可以构建一个包装类,实际上我在工程中就构建了一个为浮点数的包装类,有了包装类可以有效避免乘两次,和除两次的问题。
包装类代码如下:
mport java.util.regex.Matcher; import java.util.regex.Pattern; import org.apache.commons.lang.StringUtils; public class TisMoney { // 分 private int fen = 0; private static final String[] ZERO_ARRAY = new String[] { "00", "0", StringUtils.EMPTY }; public static TisMoney create() { return new TisMoney(); } public static TisMoney create(String doubleValue) { return new TisMoney(doubleValue); } private TisMoney() { this.fen = 0; } private static final Pattern DECI_PATTERN = Pattern .compile("(\\-?)(\\d+)(\\.(\\d{1,2}))?"); private TisMoney(String doubleValue) { Matcher m = DECI_PATTERN.matcher(doubleValue); if (m.find()) { String decimal = m.group(4); this.fen = Integer .parseInt(m.group(2) + StringUtils.trimToEmpty(m.group(4)) + ZERO_ARRAY[StringUtils.length(decimal)]); if ("-".equals(m.group(1))) { this.fen *= -1; } return; } } public void addCoefficient(BigDecimal value) { this.fen *= value.doubleValue(); } public int getFen() { return this.fen; } public int intValue() { return (fen / 100); } public void add(TisMoney fen) { this.fen += fen.fen; } public String format() { int decim = Math.abs(fen % 100); return ((fen < 0) ? "-" : "") + (Math.abs(fen) / 100) + "." + ((decim < 10 && decim > 0) ? "0" : "") + decim; } @Override public String toString() { return format(); } }
有了强类型保护,使用起来就方便多了。
祝君玩得愉快!