Java™语言规范的版本5向java.lang.Math
和java.lang.StrictMath
添加了10个新方法,而Java 6添加了另外10 个方法 。本两篇系列文章的第1部分介绍了在数学上有意义的新方法。 也就是说,它们提供了计算机时代以前的数学家会熟悉的功能。 在第2部分中,我着重介绍仅当您意识到它们被设计用于浮点数而不是抽象实数时才有意义的功能。
正如我在第1部分中提到的那样,实数(例如e或0.2)与其计算机表示形式(例如Java double
)之间的区别是重要的。 该数字的柏拉图式理想是无限精确的,而Java表示仅具有固定数量的位数( float
为32, double
float
数为64)。 float
的最大值约为3.4 * 10 38 ,对于某些您可能希望表示的事物(例如宇宙中的电子数)而言,还不够大。
double
精度数最多可以表示1.8 * 10 308的数字 ,几乎涵盖了我能想到的任何物理量。 但是,在对抽象数学量进行计算时,可能会超过这些值。 例如,仅171个! (171 * 170 * 169 * 168 * ... * 1)足以超出double
的范围。 float
仅35点就超出范围! 小数字(即接近零的数字)也可能会产生问题,并且涉及大数字和小数字的计算都可能具有正危险性。
要解决此问题,用于浮点数学运算的IEEE 754标准(请参阅参考资料 )添加了特殊值Inf表示Infinity和NaN表示“ Not a Number”。 IEEE 754还定义了正零和负零。 (在常规数学中,零既不是正数也不是负数。在计算机数学中,它既可以是负数也可以是负数。)这些值在经典证明中造成严重破坏。 例如,当使用NaN时,排除的中间定律不再成立。 x == y或x!= y不一定是正确的。 如果x(或y)为NaN,则两者都可能为假。
除了数量级问题之外,精度甚至是更实际的问题。 我们都已经看到了这样的循环,您将其加0.1一百次,最后得到9.99999999999998而不是10:
for (double x = 0.0; x <= 10.0; x += 0.1) {
System.err.println(x);
}
对于简单的应用程序,通常只要求java.text.DecimalFormat
将最终输出格式化为最接近的整数,然后将其命名为day。 但是,在不确定工程是否可以使用整数的科学和工程应用中,您需要格外小心。 如果要互相减去大数得到一个小数,则需要非常小心。 如果要除以那个小数目,则仍然需要更加小心。 当将答案应用到物理世界中时,此类操作甚至可以将微小的错误急剧放大为大错误,从而产生明显的后果。 有限精度浮点数引起的小的舍入误差会严重歪曲数学上精确的计算结果。
浮点数和双精度数的二进制表示
用Java语言实现的IEEE 754浮点数有32位。 第一位是符号位,0表示正,1表示负。 接下来的八位是指数,可以保存-125至+127的值。 最后的23位保留尾数(有时称为有效尾数),范围从0到33554554。 将它们放在一起,浮点数将解释为sign * mantissa * 2 exponent
。
细心的读者可能会注意到,这些数字并没有完全相加。 首先,指数的八位应代表-128至127,就像带符号的字节一样。 但是,指数有126的偏差。即,您从无符号值(0到255)开始,然后减去126得到真实的指数,现在是-126到128。好吧,除了128和-126是特殊的价值观。 当指数全为1位(128)时,表示该数字为Inf,-Inf或NaN。 要弄清楚哪个,您必须查看尾数。 当指数全为零位(-126)时,即表示该信号已被非规格化 (更多有关这的含义),但指数仍为-125。
尾数基本上是一个23位无符号整数-很简单。 23位可以容纳0到2 24 -1之间的数字,即16,777,215。 等一下,我不是说尾数从0到33,554,431不等吗? 那是2 25 -1。 多余的钱从哪里来?
事实证明,您可以使用指数来判断第一位是什么。 如果指数全为零,则第一位为零。 否则,第一位为1。 因为您始终知道第一位是什么,所以不必将其包括在数字中。 您可以免费获得一点额外费用。 偷偷摸摸的吧?
尾数的第一位为1的浮点数已标准化 。 即,尾数始终具有介于1和2之间的值。即使指数始终为-125,尾数的第一位为零的浮点数也被归一化,并且可以表示更小的数字。
除了使用52位尾数和11位指数以提高精度外,双精度码的编码方式几乎相同。 双精度指数的偏差为1023。
尾数和指数
Java 6中添加的两个getExponent()
方法返回用于float或double表示形式的无偏指数。 浮点数介于-125到+127之间,双打数介于-1022和+1023之间(Inf和NaN则为+ 128 / + 1024)。 例如,清单1将getExponent()
方法的结果与一个更经典的以2为底的对数进行比较:
清单1. Math.log(x)/Math.log(2)
与Math.getExponent()
public class ExponentTest {
public static void main(String[] args) {
System.out.println("x\tlg(x)\tMath.getExponent(x)");
for (int i = -255; i < 256; i++) {
double x = Math.pow(2, i);
System.out.println(
x + "\t" +
lg(x) + "\t" +
Math.getExponent(x));
}
}
public static double lg(double x) {
return Math.log(x)/Math.log(2);
}
}
对于一些需要四舍五入的值, Math.getExponent()
可能比通常的计算精度高一到两个:
x lg(x) Math.getExponent(x)
...
2.68435456E8 28.0 28
5.36870912E8 29.000000000000004 29
1.073741824E9 30.0 30
2.147483648E9 31.000000000000004 31
4.294967296E9 32.0 32
如果您要进行大量此类计算, Math.getExponent()
可能也会更快。 但是,请注意,这仅适用于2的幂。 例如,如果更改为三的幂,则为以下输出:
x lg(x) Math.getExponent(x)
...
1.0 0.0 0
3.0 1.584962500721156 1
9.0 3.1699250014423126 3
27.0 4.754887502163469 4
81.0 6.339850002884625 6
getExponent()
不考虑尾数,而Math.log()
则考虑尾数。 付出一些努力,您可以分别找到尾数,取其尾数,并将该值添加到指数中,但这一点都不值得。 当您想要快速估计一个数量级而不是确切值时, Math.getExponent()
主要有用。
与Math.log()
不同, Math.getExponent()
从不返回NaN或Inf。 如果参数为NaN或Inf,则浮点数的结果为128,双精度数的结果为1024。 如果参数为零,则浮点数的结果为-127,双精度数的结果为-1023。 如果参数为负数,则指数与该数字的绝对值的指数相同。 例如,-8的指数是3,就像8的指数一样。
没有对应的getMantissa()
方法,但是用一个小的代数就可以很容易地推导出一个方法:
public static double getMantissa(double x) {
int exponent = Math.getExponent(x);
return x / Math.pow(2, exponent);
}
尾数也可以通过位屏蔽找到,尽管该算法远不那么明显。 要提取这些位,只需要计算Double.doubleToLongBits(x) & 0x000FFFFFFFFFFFFFL
。 但是,您随后需要在归一化的数字中考虑额外的1位,然后转换回1到2之间的浮点数。
最后精度单位
实数是无限密集的。 没有下一个实数的东西。 对于您命名的任何两个不同的实数,我可以在它们之间命名另一个。 对于浮点数则不是这样。 给定一个浮点数或两倍的浮点数,则存在下一个浮点数; 并且连续的浮点数和双精度数之间有一个最小的有限距离。 nextUp()
方法返回比第一个参数大的最接近的浮点数。 例如,清单2显示了1.0到2.0之间的所有浮点数:
清单2.计算浮点数
public class FloatCounter {
public static void main(String[] args) {
float x = 1.0F;
int numFloats = 0;
while (x <= 2.0) {
numFloats++;
System.out.println(x);
x = Math.nextUp(x);
}
System.out.println(numFloats);
}
}
事实证明,在1.0和2.0之间(含1.0和2.0),总共有8,388,609个浮点数; 很大,但几乎没有存在于此范围内的实数无穷大的无穷大。 连续的数字大约相隔0.0000001。 该距离称为最小精度 单位或最后一个单位的ULP 。
如果需要倒退(即找到小于指定数字的最近浮点数),则可以改用nextAfter()
方法。 第二个参数指定在第一个参数上方还是下方找到最接近的数字:
public static double nextAfter(float start, float direction)
public static double nextAfter(double start, double direction)
如果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列出了ULP的两种幂次方:
清单3.浮点数的2的幂的ULP
public class UlpPrinter {
public static void main(String[] args) {
for (float x = 1.0f; x <= Float.MAX_VALUE; x *= 2.0f) {
System.out.println(Math.getExponent(x) + "\t" + x + "\t" + Math.ulp(x));
}
}
}
这是一些输出:
0 1.0 1.1920929E-7
1 2.0 2.3841858E-7
2 4.0 4.7683716E-7
3 8.0 9.536743E-7
4 16.0 1.9073486E-6
...
20 1048576.0 0.125
21 2097152.0 0.25
22 4194304.0 0.5
23 8388608.0 1.0
24 1.6777216E7 2.0
25 3.3554432E7 4.0
...
125 4.2535296E37 5.0706024E30
126 8.507059E37 1.0141205E31
127 1.7014118E38 2.028241E31
浮点数的有限精度会产生一个意外的结果:超过某个点x + 1 == x是正确的。 例如,这个看似简单的循环实际上是无限的:
for (float x = 16777213f; x <
16777218f; x += 1.0f) {
System.out.println(x);
}
实际上,该循环被固定在精确的16,777,216处的固定点上。 那是2 24 ,并且ULP现在大于增量的点。
如您所见,浮点数对于2的小数次幂来说非常准确。 但是,对于许多应用而言,精度大约要达到2 20 。 在浮动的幅度极限附近,相继的值之间用六十亿分号分隔 (实际上, 相差不大 ,但我找不到一个词,意思是那么高)。
如清单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,并且过去有多个整数值映射到同一浮点。 对于两倍,直到大约45万亿次(4.5E15)才发生,但这仍然是一个问题。
Math.ulp()
方法在测试中具有实际用途。 毫无疑问,通常不应该比较浮点数来获得完全相等的结果。 而是检查它们是否在一定的公差范围内相等。 例如,在JUnit中,将期望值与实际浮点值进行比较,如下所示:
assertEquals(expectedValue, actualValue, 0.02);
这断言实际值在期望值的0.02以内。 但是0.02是合理的公差吗? 如果期望值为10.5或-107.82,则0.02可能很好。 但是,如果期望值为数十亿,则0.02可能与零完全无法区分。 通常,您应该测试的是相对于ULP的相对误差。 根据计算所需的精度,通常可以选择1到10个ULP之间的公差。 例如,在这里我指定实际结果必须在真实值的5个ULP之内:
assertEquals(expectedValue, actualValue, 5*Math.ulp(expectedValue));
根据期望值是多少,可能是万亿分之一,也可能是数百万。
scalb
Math.scalb(x, y)
将x乘以2 y ( scalb
是“ scale binary”的缩写)。
public static double scalb(float f, int scaleFactor)
public static double scalb(double d, int scaleFactor)
例如, Math.scalb(3, 4)
返回3 * 2 4 ,即3 * 16,即48.0。 您可以在Math.scalb()
的替代实现中使用getMantissa()
:
public static double getMantissa(double x) {
int exponent = Math.getExponent(x);
return x / Math.scalb(1.0, exponent);
}
Math.scalb()
与x*Math.pow(2, scaleFactor)
有何不同? 实际上,最终结果没有不同。 我无法设计出返回值甚至只有一点点不同的任何输入。 但是,性能值得一看。 Math.pow()
是一个臭名昭著的性能杀手。 它需要能够处理真正奇怪的情况,例如将3.14提高到-0.078幂。 对于像2和3这样的小整数幂,或者对于像2这样的底数的特殊情况,它通常会选择完全错误的算法。
与任何其他一般性能要求一样,我对此必须非常尝试。 一些编译器和VM比其他编译器和VM更智能。 一些优化器可能将x*Math.pow(2, y)
视为特殊情况,并将其转换为Math.scalb(x, y)
或与之非常接近的东西。 因此,可能根本没有性能差异。 但是,我已经证实至少有一些虚拟机不是那么聪明。 例如,在使用Apple的Java 6 VM进行测试时, Math.scalb()
再现性比x*Math.pow(2, y)
快两个数量级。 当然,通常这一点都没有关系。 但是,在少数情况下,如果要进行数百万次幂运算,则可能要考虑是否可以将其转换为使用Math.scalb()
。
Copysign
Math.copySign()
方法将第一个参数的符号设置为第二个参数的符号。 天真的实现可能类似于清单4:
清单4.可能的copysign
算法
public static double copySign(double magnitude, double sign) {
if (magnitude == 0.0) return 0.0;
else if (sign < 0) {
if (magnitude < 0) return magnitude;
else return -magnitude;
}
else if (sign > 0) {
if (magnitude < 0) return -magnitude;
else return magnitude;
}
return magnitude;
}
但是,实际的实现类似于清单5:
清单5. sun.misc.FpUtils
的真实算法
public static double rawCopySign(double magnitude, double sign) {
return Double.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稍快一些,但是它的主要理由是正确处理负零。 Math.copySign(10, -0.0)
返回-10,而Math.copySign(10, 0.0)
返回10.0。 清单4中的朴素算法在两种情况下均返回10.0。 当您执行敏感操作(例如,将极小的负双精度数除以极大的正双精度数)时,可能会产生负零。 例如, -1.0E-147/2.1E189
返回负零,而1.0E-147/2.1E189
返回正零。 但是,这两个值与==
比较相等,因此,如果要区分它们,则需要使用Math.copySign(10, -0.0)
或Math.signum()
(调用Math.copySign(10, -0.0)
)进行比较。
对数和指数
指数函数很好地说明了在处理有限精度的浮点数而不是无限精确的实数时必须非常小心的情况。 e x ( Math.exp()
)显示在很多方程中。 例如,它用于定义第1部分中讨论的cosh函数:
cosh(x)=( e x + e -x )/ 2。
但是,对于x的负值(大约为-4和更低),用于计算Math.exp()
的算法的行为相对较差,并且会产生舍入误差。 用不同的算法计算e x -1,然后将1加到最终结果会更准确。 Math.expm1()
方法实现了这种不同的算法。 ( m1
代表“负1”。)例如,清单6演示了一个cosh函数,该函数根据x
的大小在两种算法之间切换:
清单6.一个cosh函数
public static double cosh(double x) {
if (x < 0) x = -x;
double term1 = Math.exp(x);
double term2 = Math.expm1(-x) + 1;
return (term1 + term2)/2;
}
此示例有些学术性,因为在Math.exp()
和Math.expm1() + 1
之间的差异非常大的情况下, e x项将完全支配e -x项。 然而。 Math.expm1()
在利率较低的财务计算中非常实用,例如国库券的日利率。
Math.log1p()
是Math.expm1()
的逆,就像Math.log()
是Math.exp()
的逆。 它计算1的对数加上其参数。 ( 1p
代表“加1”。)将其用于接近1的值。例如,应该计算Math.log1p(0.0002)
而不是计算Math.log(1.0002)
Math.log1p(0.0002)
。
例如,假设您想知道每天以0.03的利率投资1,000美元才能增长到1,100美元所需的天数。 清单7将执行此操作:
清单7.查找从当前投资中获得指定的未来价值所需的时期数
public static double calculateNumberOfPeriods(
double presentValue, double futureValue, double rate) {
return (Math.log(futureValue) - Math.log(presentValue))/Math.log1p(rate);
}
在这种情况下, 1p
具有非常自然的解释,因为1+ r出现在用于计算这些东西的常规公式中。 换句话说,即使投资者肯定希望收回其初始投资的(1+ r ) n ,贷方通常也将利率作为附加百分比(+ r部分)。 的确,任何以3%的钱借钱而最终只得到3%的投资的投资者,实际上的表现都很糟糕。
双打不是实数
浮点数不是实数。 它们数量有限。 它们可以代表最大值和最小值。 最重要的是,尽管精度很高,但精度有限,并且会产生舍入误差。 的确,当使用整数时,浮点数和双精度的精度肯定会比整数和长整型差。 您应该仔细考虑这些限制,以生成健壮,可靠的代码,尤其是在科学和工程应用程序中。 在处理浮点数和倍数时,财务应用程序(尤其是要求精确到百分之一的会计应用程序)也需要格外小心。
java.lang.Math
和java.lang.StrictMath
类经过精心设计,可以解决这些问题。 正确使用这些类及其方法将改善您的程序。 如果没有别的,这篇文章应该向您展示了良好的浮点算术到底是多么棘手。 最好将专家委派给您可以的专家,而不要发布自己的算法。 如果可以使用java.lang.Math
和java.lang.StrictMath
的方法,请使用。 他们几乎总是更好的选择。
翻译自: https://www.ibm.com/developerworks/java/library/j-math2/index.html