对于Java开发人员来说,只要日常工作中涉及到算术运算,那必然会跟BigDecimal这个类打交道。也许我们可以记住一些使用的注意事项,如使用String的构造函数而不是double的构造函数来避免精度问题。但是对于一个5000行的庞然大物,仅仅了解两个构造函数还不足以支撑我们大规模应用的信念,好在源代码对我们是完全开放的,那不妨来一次源代码的亲密接触。
站在用户的角度思考问题,与客户深入沟通,找到裕华网站设计与裕华网站推广的解决方案,凭借多年的经验,让设计与互联网技术结合,创造个性化、用户体验好的作品,建站类型包括:做网站、成都网站设计、企业官网、英文网站、手机端网站、网站推广、域名注册、网页空间、企业邮箱。业务覆盖裕华地区。
按照Java的惯例在每个重要类前面都有一篇论文式的注释,一般情况下把这段理解了应付个面试是没啥问题的。BigDecimal也不例外的在类注释上花了近200行,我们做个简单的摘要:
² 首先给出BigDecimal的定义为任意精度的有符号十进制数。BigDecimal可以表示为一个任意精度的无刻度值和一个32位整型的刻度。
² BigDecimal提供了一系列的方法,如算术操作、标度控制、舍入、比较等等方法,总之很强大。
² BigDecimal通过precision、scale、rounding mode和MathContext类来控制标度和进行舍入操作。
² BigDecimal的equals方法并不是数学意义上的相等,所以在用于Sorted Map和Sorted Set这些和比较有关系的数据结构时需要特别小心。
在论文注释的指引下,我们可以整理出BigDecimal类的脉络:
接下来我们就顺着脉络一点点的解剖这个庞然大物了。
从图中可以看出BigDecimal类主要需要关注5个主要属性
Ø intVal和scale
分别表示BigDecimal的无标度值和标度,结合我们在注释里看到的说法“BigDecimal可以表示为一个任意精度的无刻度值和一个32位整型的刻度”,这两个属性可以认为是BigDecimal类的骨架。
Ø precision
BigDecimal中数字的个数,在确定了precision后就会要求结合Rounding Mode做一些舍入方面的操作。
Ø stringCache
BigDecimal的字符表示,在toString方法的时候用到。
Ø intCompact
无标度值的Long表示,方便后续计算。如果intVal在compact的过程发现超过Long.MAX_VALUE则将intCompact记为Long.MIN_VALUE。
我们以三个例子来说明BigDecimal对于以上属性的定义
BigDecimal b1 = new BigDecimal(“3.1415926”);
从Debug的结果看,intVal为空,因为无标度值可以被压缩存储到intCompact中,precision表示有8个数字位,scale表示标度为7
BigDecimal b2 = new BigDecimal(“31415926314159263141592631415926”);
intVal记录的是无标度值,这时候由于无标度值超过了Long.MAX_VALUE,intCompact存储了Long.MIN_VALUE,precision表示当前数字位为32个,scale为0表示没有小数位。
MathContext mc3 = new MathContext(30,RoundingMode.HALF_UP); BigDecimal b2 = new BigDecimal(“31415926314159263141592631415926”);
在这里我们手动设置了precision为30,所以最后两位被丢弃并执行了舍入操作,同时scale记录为-2表示无标度值表示到小数点左边两位。
通过上面三个例子我们对BigDecimal的5个基本属性总结如下。
BigDecimal是通过unscaled value和scale来构造,同时使用Long.MAX_VALUE作为我们是否压缩的阈值。当unscaled value超过阈值时采用intVal字段存储unscaled value,intCompact字段存储Long.MIN_VALUE,否则对unscaled value进行压缩存储到long型的intCompact字段用于后续计算,intVal为空。
scale字段存储标度,可以理解为unscaled value最后一位到实际值小数点的距离。如例1中对于3.1415926来说unscaled value为31415926,最后一位6到实际值的小数点距离为7,scale记为7;对于例3中手动设置precision的情况,unscaled value为31415926xxx159的最后一位9到实际值31415926xxx15900的小数点距离为2,由于在小数点左边scale则记为-2。
precision字段记录的是unscaled value的数字个数,当手动指定MathContext并且指定的precision小于实际precision的时候,会要求进行rounding操作。
提到如何创建一个BigDecimal,首先想到的肯定是使用String参数的构造函数进行构建。
BigDecimal b = new BigDecimal(“3.14”);
实际上对于对象创建来说,BigDecimal提供了至少三种方式:
1, 构造函数
BigDecimal提供了16个public的构造函数,支持通过char数组,String,double,BigInteger,long和int类型的参数构造。
2, 工厂方法
BigDecimal主要通过valueOf方法提供对象的静态工厂,支持通过double,BigInteger和long类型的参数构造。具体用法:
BigDecimal f = BigDecimal.valueOf(1000L);
3, 对象缓存
对于常用的BigDecimal对象,内部通过数组进行缓存,并开放了ZERO,ONE和TEN三个对象供使用端复用。具体用法:
BigDecimal c = BigDecimal.ZERO;
接下来具体看看三种创建方式的实现方式。
首先看看BigDecimal类提供的私有构造函数。
/** * Trusted package private constructor. * Trusted simply means if val is INFLATED, intVal could not be null and * if intVal is null, val could not be INFLATED. */ BigDecimal(BigInteger intVal, long val, int scale, int prec) { this.scale = scale; this.precision = prec; this.intCompact = val; this.intVal = intVal; }
从这个私有构造函数可以看出BigDecimal对象主要关注的属性字段,如果可以准确的给这些属性字段赋值则可以成功构造一个BigDecimal对象。
这里我们可以大胆猜测其他公共的构造函数和工厂方法内部的逻辑都是计算这些属性字段。
从我们的脉络图上看,构造函数分为字符构造和数值构造。
对于字符构造我们只需要关注两个构造函数即可:
1, public BigDecimal(char[] in, int offset, int len, MathContext mc)
从规模上看这个构造函数是所有字符构造函数中方法体最大的,同时结合其他字符构造函数的逻辑可以发现这个构造函数正是字符构造函数的核心逻辑实现。
2, public BigDecimal(String val)
之所以关注这个构造函数,一方面是实际应用的比较多,再者这个构造函数的100行注释也表明了官方对于这个构造函数的推荐程度。
接下来我们集中攻克字符构造函数的核心实现,我们结合源代码以程序流的方式进行说明。
第一步:处理符号位,如果是符号位则设置isneg字段并将offset往后移动一位
// handle the sign boolean isneg = false; // assume positive if (in[offset] == '-') { isneg = true; // leading minus means negative offset++; len--; } else if (in[offset] == '+') { // leading + allowed offset++; len--; }
第二步,针对可压缩的情况,遍历字符进行分别处理。
² 如果是字符0判断了两种情况来处理prec和compact value的赋值,主要解决”00”这种多个0的无意义输入。
1) 第一位数字为0,则直接将prec设置为1
2) 非第一位数字为0,则判断之前的数值是否为0,如果为0则表明前面的数字是0,当前数字不予处理;如果不为0则将数值乘以10,prec加1
if ((c == '0')) { // have zero if (prec == 0) prec = 1; else if (rs != 0) { rs *= 10; ++prec; } // else digit is a redundant leading zero if (dot) ++scl; }
² 如果是字符1-9的情况,同样处理了prec和compact value的赋值,主要考虑解决”01”这种以0开头的数字的prec问题。
else if ((c >= '1' && c <= '9')) { // have digit int digit = c - '0'; if (prec != 1 || rs != 0) ++prec; // prec unchanged if preceded by 0s rs = rs * 10 + digit; if (dot) ++scl; }
² 如果是字符”.”的情况,主要解决出现了多个小数点的情况。
² 如果是Unicode或者其他格式的字符表示,通过Character.isDigit方法进行判断,判断完并完成转换后将上面0和1-9的逻辑再走一遍,有点重复代码的嫌疑。
² 如果是字符”e”和”E”,解析出e后面的数字用于后面计算scale
第三步,结合之前字符解析得到的prec和MathContext设置的prec进行rounding操作。主要逻辑是通过相差的prec算出一个drop,然后使用compact value和drop去做除法,比如需要drop 3位,那么就拿compact value和1000去做除法,并结合Rounding Mode判断结果是否需要加1。
由于rounding之后可能存在进位问题,这里使用while循环来进行检查。
int mcp = mc.precision; int drop = prec - mcp; if (mcp > 0 && drop > 0) { // do rounding while (drop > 0) { scl = checkScaleNonZero((long) scl - drop); rs = divideAndRound(rs, LONG_TEN_POWERS_TABLE[drop], mc.roundingMode.oldMode); prec = longDigitLength(rs); drop = prec - mcp; } }
第四步,针对不可压缩的情况,引入一个char数组容器用于构建BigInteger类型的intValue。其他对于字符的处理以及如何设置prec,scale以及如何处理rounding和数值可压缩的情况基本一致。
至此我们对于字符构造函数的分析已经结束,我们可以发现对于String类型的构造函数,我们其实是首先将String转换成数组类型char[],然后调用字符数组构造函数。所以出于性能考虑,如果我们的应用场景里面获取的是char[],可以直接调用字符数组构造函数,没有必要先转成String再去调用String构造函数,以至于白白损耗了两次转换的性能。
在数值构造函数中,我们重点关注double类型的构造函数,因为这是在日常使用中最容易出问题的地方。
其他构造函数的主要逻辑重点在于rounding和对于四个核心属性的赋值,这点可以在字符构造函数和后续的重点方法介绍中找到相应的实现解析。
下面就让我们集中火力攻克double构造函数吧,同样也是源代码结合程序流的方式。
第一步,将double转换成IEEE 754定义的浮点数bit表示方式,并通过位运算获取到三个部分的值。
其中转换成bit表示方式的方法是调用的虚拟机的native方法。
获取sign的值比较好理解,右移63位后判断值是否为0来确定数值的正负。
int sign = ((valBits >> 63) == 0 ? 1 : -1);
对于exponent和significand的逻辑就比较复杂了,首先明确目标是将这个double表示为以下格式val == sign * significand * 2^exponent,再来看代码:
int exponent = (int) ((valBits >> 52) & 0x7ffL); long significand = (exponent == 0 ? (valBits & ((1L << 52) - 1)) << 1 : (valBits & ((1L << 52) - 1)) | (1L << 52)); exponent -= 1075;
要看懂这段代码我们首先需要了解IEE754在浮点数转换的几点约定:
² 小数点左边隐含一位,通常是1
² 单精度偏移量127,双精度偏移量是1023
这时候回头来看这段代码,在计算significand的时候分成了两种情况,当exponent为0的时候直接进行左移右边补0否则在左边补1,都是为了补齐52个有效位和一个隐含位。
exponent需要偏移1075 = 1023 + 52,来源于自身的1023偏移量加上52位的有效位偏移。
第二步,将significand进行格式化,去除低位的0
while ((significand & 1) == 0) { // i.e., significand is even significand >>= 1; exponent++; }
第三步,计算intVal和scale
BigInteger intVal; long compactVal = sign * significand; if (exponent == 0) { intVal = (compactVal == INFLATED) ? INFLATED_BIGINT : null; } else { if (exponent < 0) { intVal = BigInteger.valueOf(5).pow(-exponent).multiply(compactVal); scale = -exponent; } else { // (exponent > 0) intVal = BigInteger.valueOf(2).pow(exponent).multiply(compactVal); } compactVal = compactValFor(intVal); }
计算的时候按照exponent分成三种情况,
exponent==0,直接计算intVal
exponent<0,表明存在小数位,由于二进制数0.1对应的十进制为0.5,所以小数位的转换是5作为底
exponent>0,表明需要要在右边补充0,二进制数1.0对应的十进制为2,所以整数位的转换是2作为底。
第四步,根据MathContext进行rounding操作,获取precision,intValue和compact value。这一步是通用操作,就不做过多表述。
至此对于数值构造函数的分析已经结束。我们主要分析了double类型的构造函数,从代码和程序流程可以看出double类型的构造函数首先将double转换成IEEE标准的二进制表示形式并分离出符号位、指数位和有效位,然后计算出precision、scale、intVal和compactVal来表示一个BigDecimal。由于小数转二进制存在误差导致了这个构造函数构造出的BigDecimal对象和实际值之间存在误差,这也是为什么double类型的构造函数不推荐使用的原因。
BigDecimal的工厂函数是通过静态的valueOf方法提供的,主要针对long,BigInteger和double类型的参数。
由于long和BigInteger的数据类型和BigDecimal中的intValue和intCompact匹配,所以对于这两种类型的工厂方法实现相对简单,主要就是四个属性的赋值。
而在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方法首先将double转换成字符串然后再调用字符构造函数,从而避免了精度丢失的问题,所以在注释中也提示了使用者:如果一定要用double来构造BigDecimal对象优先使用工厂方法。