Java Math 类中的新功能第 2 部分浮点数.docx
《Java Math 类中的新功能第 2 部分浮点数.docx》由会员分享,可在线阅读,更多相关《Java Math 类中的新功能第 2 部分浮点数.docx(13页珍藏版)》请在冰豆网上搜索。
![Java Math 类中的新功能第 2 部分浮点数.docx](https://file1.bdocx.com/fileroot1/2023-2/21/ab2cd047-dbc2-439f-b76d-7ba9907933a2/ab2cd047-dbc2-439f-b76d-7ba9907933a21.gif)
JavaMath类中的新功能第2部分浮点数
JavaMath类中的新功能,第2部分:
浮点数
Java™语言规范第5版向java.lang.Math和java.lang.StrictMath添加了10种新方法,Java6又添加了10种。
这个共两部分的系列文章的第1部分介绍了很有意义的新的数学方法。
它提供了在还未出现计算机的时代中数学家比较熟悉的函数。
在第2部分中,我主要关注这样一些函数,它们的目的是操作浮点数,而不是抽象实数。
就像我在第1部分中提到的一样,实数(比如e或0.2)和它的计算机表示(比如Javadouble)之间的区别是非常重要的。
最理想的数字应该是无限精确的,然而Java表示的位数是固定的(float为32位,double为64位)。
float的最大值约为3.4*1038。
这个值还不足以表示某些东西,比如宇宙中的电子数目。
double的最大值为1.8*10308,几乎能够表示任何物理量。
不过涉及到抽象数学量的计算时,可能超出这些值的范围。
例如,光是171!
(171*170*169*168*...*1)就超出了double最大值。
float只能表示35!
以内的数字。
非常小的数(值接近于0的数字)也会带来麻烦,同时涉及到非常大的数和非常小的数的计算是非常危险的。
为了处理这个问题,浮点数学IEEE754标准(参见参考资料)添加了特殊值Inf和NaN,它们分别表示无穷大(Infinity)和非数字(NotaNumber)。
IEEE754还定义了正0和负0(在一般的数学中,0是不分正负的,但在计算机数学中,它们可以是正的,也可以是负的)。
这些值给传统的原理带来了混乱。
例如,当使用NaN时,排中律就不成立了。
x==y或x!
=y都有可能是不正确的。
当x或y为NaN时,这两个式子都不成立。
除了数字大小问题外,精度是一个更加实际的问题。
看看这个常见的循环,将1.0相加10次之后等到的结果不是10,而是9.99999999999998:
for(doublex=0.0;x<=10.0;x+=0.1){
System.err.println(x);
}
对于简单的应用程序,您通常让java.text.DecimalFormat将最终的输出格式化为与其值最接近的整数,这样就可以了。
不过,在科学和工程应用方面(您不能确定计算的结果是否为整数),则需要加倍小心。
如果需要在特别大的数字之间执行减法以得到较小的数字,则需要万分小心。
如果以特别小的数字作为除数,也需要加以注意。
这些操作能够将很小的错误变成大错误,并给现实应用带来巨大的影响。
由有限精度浮点数字引起的很小的舍入错误就会严重歪曲数学精度计算。
浮点数和双精度数字的二进制表示
由Java实现的IEEE754浮点数有32位。
第一位是符号位,0表示正,1表示负。
接下来的8位表示指数,其值的范围是-125到+127。
最后的23位表示尾数(有时称为有效数字),其值的范围是0到33,554,431。
综合起来,浮点数是这样表示的:
sign*mantissa*2exponent。
敏锐的读者可能已经注意到这些数字有些不对劲。
首先,表示指数的8位应该是从-128到127,就像带符号的字节一样。
但是这些指数的偏差是126,即用不带符号的值(0到255)减去126获得真正的指数(现在是从-126到128)。
但是128和-126是特殊值。
当指数都是1位(128)时,则表明这个数字是Inf、-Inf或NaN。
要确定具体情况,必须查看它的尾数。
当指数都是0位(-126)时,则表明这个数字是不正常的(稍后将详细介绍),但是指数仍然是-125。
尾数一般是一个23位的不带符号的整数—它非常简单。
23位可以容纳0到224-1,即16,777,215。
等一下,我刚才是不是说尾数的范围是从0到33,554,431?
即225-1。
多出的一位是从哪里来的?
因此,可以通过指数表示第1位是什么。
如果指数都是0位,则第1位为0。
否则第1位为1。
因为我们通常知道第1位是什么,所以没有必要包含在数字中。
您“免费”得到一个额外的位。
是不是有些离奇?
尾数的第1位为1的浮点数是正常的。
即尾数的值通常在1到2之间。
尾数的第1位为0的浮点数是不正常的,尽管指数通常为-125,但它通常能够表示更小的数字。
双精度数是以类似的方式编码的,但是它使用52位的尾数和11位的指数来获得更高的精度。
双精度数的指数的偏差是1023。
尾数和指数
在Java6中添加的两个getExponent()方法在表示浮点数或双精度数时返回无偏差指数。
对于浮点数,这个数字的范围是-125到+127,对于双精度数,这个数字的范围是-1022到+1023(Inf和NaN为+128/+1024)。
例如,清单1根据更常见的以2为底数的对数比较了getExponent()方法的结果:
清单1.Math.log(x)/Math.log
(2)和Math.getExponent()
publicclassExponentTest{
publicstaticvoidmain(String[]args){
System.out.println("x\tlg(x)\tMath.getExponent(x)");
for(inti=-255;i<256;i++){
doublex=Math.pow(2,i);
System.out.println(
x+"\t"+
lg(x)+"\t"+
Math.getExponent(x));
}
}
publicstaticdoublelg(doublex){
returnMath.log(x)/Math.log
(2);
}
}
对于使用舍入的一些值,Math.getExponent()比一般的计算要准确一些:
xlg(x)Math.getExponent(x)
...
2.68435456E828.028
5.36870912E829.00000000000000429
1.073741824E930.030
2.147483648E931.00000000000000431
4.294967296E932.032
如果要执行大量此类计算,Math.getExponent()会更快。
不过需要注意,它仅适用于计算2的幂次方。
例如,当改为3的幂次方时,结果如下:
xlg(x)Math.getExponent(x)
...
1.00.00
3.01.5849625007211561
9.03.16992500144231263
27.04.7548875021634694
81.06.3398500028846256
getExponent()不处理尾数,尾数由Math.log()处理。
通过一些步骤,就可以找到尾数、取尾数的对数并将该值添加到指数,但这有些费劲。
如果想要快速估计数量级(而不是精确值),Math.getExponent()是非常有用的。
与Math.log()不同,Math.getExponent()从不返回NaN或Inf。
如果参数为NaN或Inf,则对应的浮点数和双精度数的结果分别是128和1024。
如果参数为0,则对应的浮点数和双精度数的结果分别是-127和-1023。
如果参数为负数,则数字的指数与该数字的绝对值的指数相同。
例如,-8的指数为3,这与8的指数相同。
没有对应的getMantissa()方法,但是使用简单的数学知识就能构造一个:
publicstaticdoublegetMantissa(doublex){
intexponent=Math.getExponent(x);
returnx/Math.pow(2,exponent);
}
尽管算法不是很明显,但还是可以通过位屏蔽来查找尾数。
要提取位,仅需计算Double.doubleToLongBits(x)&0x000FFFFFFFFFFFFFL。
不过,随后则需要考虑正常数字中多出的1位,然后再转换回范围在1到2之间的浮点数。
最小的精度单位
实数是非常密集的。
任意两个不同的实数中间都可以出现其他实数。
但浮点数则不是这样。
对于浮点数和双精度数,也存在下一个浮点数;连续的浮点数和双精度数之间存在最小的有限距离。
nextUp()方法返回比第一个参数大的最近浮点数。
例如,清单2打印出所有在1.0和2.0之间的浮点数:
清单2.计算浮点数数量
publicclassFloatCounter{
publicstaticvoidmain(String[]args){
floatx=1.0F;
intnumFloats=0;
while(x<=2.0){
numFloats++;
System.out.println(x);
x=Math.nextUp(x);
}
System.out.println(numFloats);
}
}
结果是1.0和2.0之间包含8,388,609个浮点数;虽然很多,但还不至于是无穷多的实数。
相邻数字的距离为0.0000001。
这个距离称为ULP,它是最小精度单位(unitofleastprecision)或最后位置单位(unitinthelastplace)的缩略。
如果需要向后查找小于指定数字的最近浮点数,则可以改用nextAfter()方法。
第二个参数指定是否查找在第一个参数之上或之下的最近数字:
publicstaticdoublenextAfter(floatstart,floatdirection)
publicstaticdoublenextAfter(doublestart,doubledirection)
如果direction大于start,则nextAfter()返回在start之上的下一个数字。
如果direction小于start,则nextAfter()返回在start之下的下一个数字。
如果direction等于start,则nextAfter()返回start本身。
这些方法在某些建模或图形工具中是非常有用的。
从数字上来说,您可能需要在a和b之间的10,000个位置上提取样例值,但如果您具备的精度仅能识别a和b之间的1,000个独立的点,那么有十分之九的工作是重复的。
您可以只做十分之一的工作,但又获得相同的结果。
当然,如果一定需要额外的精度,则可以选择具有高精度的数据类型,比如double或BigDecimal。
例如,我曾经在Mandelbrot集合管理器看见过这种情况。
在其中可以放大曲线图,让其落在最近的两个双精度数之间。
Mandelbrot集合在各个级别上都是非常细微和复杂的,但是float或double可以在失去区分相邻点的能力之前达到这个细微的级别。
Math.ulp()返回一个数字和距其最近的数字之间的距离。
清单3列出了2的各种幂次方的ULP:
清单3.浮点数2的幂次方的ULP
publicclassUlpPrinter{
publicstaticvoidmain(String[]args){
for(floatx=1.0f;x<=Float.MAX_VALUE;x*=2.0f){
System.out.println(Math.getExponent(x)+"\t"+x+"\t"+Math.ulp(x));
}
}
}
下面给出了一些输出:
01.01.1920929E-7
12.02.3841858E-7
24.04.7683716E-7
38.09.536743E-7
416.01.9073486E-6
...
201048576.00.125
212097152.00.25
224194304.00.5
238388608.01.0
241.6777216E72.0
253.3554432E74.0
...
1254.2535296E375.0706024E30
1268.507059E371.0141205E31
1271.7014118E382.028241E31
可以看到,对于比较小的2的幂次方,浮点数是非常精确的。
但是在许多应用程序中,在数值约为220时,这一精度将出现问题。
在接近浮点数的最大极限时,相邻的值将被千的七乘方(sextillions)隔开(事实上可能更大一点,但我找不到词汇来表达)。
如清单3所示,ULP的大小并不是固定的。
随着数字变大,它们之间的浮点数就会越来越少。
例如,10,000和10,001之间只有1,025个浮点数;它们的距离是0.001。
在1,000,000和1,000,001之间仅有17个浮点数,它们的距离是0.05。
精度与数量级成反比关系。
对于浮点数10,000,000,ULP的精确度变为1.0,超过这个数之后,将有多个整数值映射到同一个浮点数。
对于双精度数,只有达到4.5E15时才会出现这种情况,但这也是个问题。
浮点数的有限精度会导致一个难以预料的结果:
超过某个点时,x+1==x便是真的。
例如,下面这个简单的循环实际上是无限的:
for(floatx=16777213f;x<16777218f;x+=1.0f){
System.out.println(x);
}
实际上,这个循环将在一个固定的点上停下来,准确的数字是16,777,216。
这个数字等于224,在这个点上,ULP比增量大。
Math.ulp()为测试提供一个实用的用途。
很明显,我们一般不会比较两个浮点数是否完全相等。
相反,我们检查它们是否在一定的容错范围内相等。
例如,在JUnit中,像以下这样比较预期的实际浮点值:
assertEquals(expectedValue,actualValue,0.02);
这表明实际值与预期值的偏差在0.02之内。
但是,0.02是合理的容错范围吗?
如果预期值是10.5或-107.82,则0.02是完全可以接受的。
但当预期值为几十亿时,0.02则与0没有什么区别。
通常,就ULP进行测试时考虑的是相对错误。
一般选择的容错范围在1至10ULP之间,具体情况取决于计算所需的精度。
例如,下面指定实际结果必须在真实值的5个ULP之内:
assertEquals(expectedValue,actualValue,5*Math.ulp(expectedValue));
根据期望值不同,这个值可以是万亿分之一,也可以是数百万。
scalb
Math.scalb(x,y)用2y乘以x,scalb是“scalebinary(二进法)”的缩写。
publicstaticdoublescalb(floatf,intscaleFactor)
publicstaticdoublescalb(doubled,intscaleFactor)
例如,Math.scalb(3,4)返回3*24,即3*16,结果是48.0。
也可以使用Math.scalb()来实现getMantissa():
publicstaticdoublegetMantissa(doublex){
intexponent=Math.getExponent(x);
returnx/Math.scalb(1.0,exponent);
}
Math.scalb()和x*Math.pow(2,scaleFactor)的区别是什么?
实际上,最终的结果是一样的。
任何输入返回的值都是完全一样的。
不过,性能方面则存在差别。
Math.pow()的性能是非常糟糕的。
它必须能够真正处理一些非常少见的情况,比如对3.14采用幂-0.078。
对于小的整数幂,比如2和3(或以2为基数,这比较特殊),通常会选择完全错误的算法。
我担心这会对总体性能产生影响。
一些编译器和VM的智能程度比较高。
一些优化器会将x*Math.pow(2,y)识别为特殊情况并将其转换为Math.scalb(x,y)或类似的东西。
因此性能上的影响体现不出来。
不过,我敢保证有些VM是没有这么智能的。
例如,使用Apple的Java6VM进行测试时,Math.scalb()几乎总是比x*Math.pow(2,y)快两个数量级。
当然,这通常不会造成影响。
但是在特殊情况下,比如执行数百万次求幂运算时,则需要考虑能否转换它们以使用Math.scalb()。
Copysign
Math.copySign()方法将第一个参数的标记设置为第二个参数的标记。
最简单的实现如清单4所示:
清单4.可能实现的copysign算法
publicstaticdoublecopySign(doublemagnitude,doublesign){
if(magnitude==0.0)return0.0;
elseif(sign<0){
if(magnitude<0)returnmagnitude;
elsereturn-magnitude;
}
elseif(sign>0){
if(magnitude<0)return-magnitude;
elsereturnmagnitude;
}
returnmagnitude;
}
不过,真正的实现如清单5所示:
清单5.来自sun.misc.FpUtils的真正算法
publicstaticdoublerawCopySign(doublemagnitude,doublesign){
returnDouble.longBitsToDouble((Double.doubleToRawLongBits(sign)&
(DoubleConsts.SIGN_BIT_MASK))|
(Double.doubleToRawLongBits(magnitude)&
(DoubleConsts.EXP_BIT_MASK|
DoubleConsts.SIGNIF_BIT_MASK)));
}
仔细观察这些位就会看到,NaN标记被视为正的。
严格来说,Math.copySign()并不对此提供保证,而是由StrictMath.copySign()负责,但在现实中,它们都调用相同的位处理代码。
清单5可能会比清单4快一些,但它的主要目的是正确处理负0。
Math.copySign(10,-0.0)返回-10,而Math.copySign(10,0.0)返回10.0。
清单4中最简单形式的算法在两种情况中都返回10.0。
当执行敏感的操作时,比如用极小的负双精度数除以极大的正双精度数,就可能出现负0。
例如,-1.0E-147/2.1E189返回负0,而1.0E-147/2.1E189返回正0。
不过,使用==比较这两个值时,它们是相等的。
因此,如果要区分它们,必须使用Math.copySign(10,-0.0)或Math.signum()(调用Math.copySign(10,-0.0))来执行比较。
对数和指数
指数函数是一个很好的例子,它表明处理有限精度浮点数(而不是无限精度实数)时是需要非常小心的。
在很多等式中都出现ex(Math.exp())。
例如,它可用于定义cosh函数,这已经在第1部分中讨论:
cosh(x)=(ex+e-x)/2
不过,对于负值的x,一般是-4以下的数字,用于计算Math.exp()的算法表现很差,并且容易出现舍入错误。
使用另一个算法计算ex-1会更加精确,然后在最终结果上加1。
Math.expm1()能够实现这个不同的算法(m1表示“减1”)。
例如,清单6给出的cosh函数根据x的大小在两个算法之间进行切换:
清单6.cosh函数
publicstaticdoublecosh(doublex){
if(x<0)x=-x;
doubleterm1=Math.exp(x);
doubleterm2=Math.expm1(-x)+1;
return(term1+term2)/2;
}
这个例子有些呆板,因为在Math.exp()与Math.expm1()+1之间的差别很明显的情况下,常常使用ex,而不是e-x。
不过,Math.expm1()在带有多种利率的金融计算中是非常实用的,比如短期国库券的日利率。
Math.log1p()与Math.expm1()刚好相反,就像Math.log()与Math.exp()的关系一样。
它计算1的对数和参数(1p表示“加1”)。
在值接近1的数字中使用这个函数。
例如,应该使用它计算Math.log1p(0.0002),而不是Math.log(1.0002)。
现在举一个例子,假设您需要知道在日利率为0.03的情况下,需要多少天投资才能使$1,000增长到$1,100。
清单7完成了这个计算任务:
清单7.计算从当前投资额增长到未来特定值所需的时间
publicstaticdoublecalculateNumberOfPeriods(
doublepresentValue,doublefutureValue,doublerate){
return(Math.log(futureValue)-Math.log(presentValue))/Math.log1p(rate);
}
在这个例子中,1p的含义是很容易理解的,因为在计算类似数据的一般公式中通常出现1+r。
换句话说,尽管投资方很希望获得初始投资成本的(1+r)n,贷方通常将利率作为附加的百分比(+r部分)。
实际