Java 源码 - java.lang.StrictMath

1,427 阅读6分钟

概述

之前在Math类中有说到,其实很多的方法实现都是依靠StrictMath来实现的。事实上,StrictMath的方法都是native方法,所以本文只是简单列出相对重要的一些方法,此外,还会加上一些使用Math类经常碰到的坑。

构造方法和属性

private StrictMath() {}

public static final double E = 2.7182818284590452354;

public static final double PI = 3.14159265358979323846;

三角函数

public static native double sin(double a);

public static native double cos(double a);

public static native double tan(double a);

public static native double asin(double a);

public static native double acos(double a);

public static native double atan(double a);

public static strictfp double toRadians(double angdeg) {
    // Do not delegate to Math.toRadians(angdeg) because
    // this method has the strictfp modifier.
    return angdeg / 180.0 * PI;
}

public static strictfp double toDegrees(double angrad) {
    // Do not delegate to Math.toDegrees(angrad) because
    // this method has the strictfp modifier.
    return angrad * 180.0 / PI;
}
自Java2以来,Java语言增加了一个关键字strictfp。strictfp的意思是FP-strict,也就是说精确浮点的意思。在Java虚拟机进行浮点运算时,如果没有指定strictfp关键字时,Java的编译器以及运行环境在对浮点运算的表达式是采取一种近似于我行我素的行为来完成这些操作,以致于得到的结果往往无法令你满意。而一旦使用了strictfp来声明一个类、接口或者方法时,那么所声明的范围内Java的编译器以及运行环境会完全依照浮点规范IEEE-754来执行。因此如果你想让你的浮点运算更加精确,而且不会因为不同的硬件平台所执行的结果不一致的话,那就请用关键字strictfp。
你可以将一个类、接口以及方法声明为strictfp,但是不允许对接口中的方法以及构造函数声明strictfp关键字。

一旦使用了关键字strictfp来声明某个类、接口或者方法时,那么在这个关键字所声明的范围内所有浮点运算都是精确的,符合IEEE-754规范的。例如一个类被声明为strictfp,那么该类中所有的方法都是strictfp的。

指数对数运算

public static native double exp(double a);

public static native double log(double a);

public static native double log10(double a);

开方运算

public static native double sqrt(double a);

public static native double cbrt(double a);

/**
* Computes the remainder operation on two arguments as prescribed
* by the IEEE 754 standard.
**/
public static native double IEEEremainder(double f1, double f2);

ceil()/floor()

public static double ceil(double a) {
        return floorOrCeil(a, -0.0, 1.0, 1.0);
    }

public static double floor(double a) {
        return floorOrCeil(a, -1.0, 0.0, -1.0);
    }

    private static double floorOrCeil(double a,
                                      double negativeBoundary,
                                      double positiveBoundary,
                                      double sign) {
        int exponent = Math.getExponent(a);

        if (exponent < 0) {
            /*
             * Absolute value of argument is less than 1.
             * floorOrceil(-0.0) => -0.0
             * floorOrceil(+0.0) => +0.0
             */
            return ((a == 0.0) ? a :
                    ( (a < 0.0) ?  negativeBoundary : positiveBoundary) );
        } else if (exponent >= 52) {
            /*
             * Infinity, NaN, or a value so large it must be integral.
             */
            return a;
        }
        // Else the argument is either an integral value already XOR it
        // has to be rounded to one.
        assert exponent >= 0 && exponent <= 51;

        long doppel = Double.doubleToRawLongBits(a);
        long mask   = DoubleConsts.SIGNIF_BIT_MASK >> exponent;

        if ( (mask & doppel) == 0L )
            return a; // integral value
        else {
            double result = Double.longBitsToDouble(doppel & (~mask));
            if (sign*a > 0.0)
                result = result + sign;
            return result;
        }
    }

round() 方法

public static int round(float a) {
        return Math.round(a);
    }

public static long round(double a) {
        return Math.round(a);
    }

在使用过程中的一些坑

1. 起初

最近在进行ARM切换的过程中发现了很多因为Java Math库在不同的平台上的精度不同导致用例失败,我们以Math.log为例,做一下简单的分析。下面是一个简单的计算log(3)的示例:

public class Hello {
    public static void main(String[] args) {
        System.out.println("Math.log(3): " + Math.log(3));
        System.out.println("StrictMath.log(3): " + StrictMath.log(3));
    }
}

我们发现,在x86下,Math的结果为1.0986122886681098

# on x86
$ java Hello
Math.log(3): 1.0986122886681098
StrictMath.log(3): 1.0986122886681096

而aarch64的结果为1.0986122886681096

# on aarch64
$ java Hello
Math.log(3): 1.0986122886681096
StrictMath.log(3): 1.0986122886681096

而在Java 8的官方文档中,对此有明确说明:

Unlike some of the numeric methods of class StrictMath, all implementations of the equivalent functions of class Math are not defined to return the bit-for-bit same results. This relaxation permits better-performing implementations where strict reproducibility is not required.

因此,结论是:Math的结果有可能是不精确的,如果结果对精度有苛求,那么请使用StrictMath

在此,我们留下2个疑问:

  1. 为什么说Math的实现不是the bit-for-bit same results
  2. Math是怎么实现在各个架构下better-performing implementations的?

2. 深度探索一下Math的实现

为了能够更清晰的看到StrictMath的实现,我们深入的看了下JDK的实现。

2.1 Math和StrictMath的基本实现

我们从Math.log和StrictMath.log的实现为例,进行深入学习:

  1. Math.log的代码表面上很简单,就是直接调用StrictMath.log。

    public static double log(double a) { return StrictMath.log(a); // default impl. delegates to StrictMath }

  2. StrictMath的代码,会调用StrictMath.c中的方法,最终会调用fdlibm的e_log.c的实现。

总体的实现和下图类似:

对于StrictMath来说,没有什么黑科技,最终的实现就是e_log.c的ieee754标准实现,是通过C语言实现的,所以在各个平台的表现是一样的,整个流程如图中蓝色部分。感兴趣的同学可以看e_log.c的源码实现即可。

2.2 Math的黑科技

回到我们最初的起点,再加上一个问题:

  1. 为什么说Math的实现不是the bit-for-bit same results
  2. Math是怎么实现在各个架构下better-performing implementations的?
  3. 既然Math的实现,也是直接调用StrictMath,为什么结果确不一样呢?

原来,JVM为了让各个arch的CPU能够充分的发挥自己CPU的优势,会根据架构不同,会通过Hotspot intrinsics替换掉Math函数的实现,我们可以从代码vmSymbols.hpp看到,Math的很多实现都被替换掉了。log的替换类似于:

do_intrinsic(_dlog, java_lang_Math, log_name, double_double_signature, F_S)

最终,Math的调用为下图红色部分:

log的实现:

  • 在x86下,最终其实调用的是assembler_x86.cpp中的flog实现:

    void Assembler::flog() { fldln2(); fxch(); fyl2x(); }

  • 而在aarch64下,我们可以从src/hotspot/cpu/目录下看到,aarch64并未实现优化版本。因此,实际aarch64调用的就是标准的StrictMath。

正因如此,x86汇编的计算结果的差异导致了x86和aarch64结果在Math.log差异。

当然,aarch64也在JDK 11中,对部分的Math接口做了加速实现,有兴趣可以看看JEP 315: Improve Aarch64 Intrinsics的实现。

3. toRadians的小插曲

在ARM优化过程中,有的是因为Math库和StrictMath不同的实现造成结果不同,所以我们如果对精度要求非常高,直接切到StrictMath即可。

但有的函数,由于在Java大版本升级的过程中,出现了一些实现的差异,先看一个简单的Java程序

public class Hello {
    public static void main(String[] args) {
	System.out.println("Math.toRadians(0.33): " + Math.toRadians(0.33));
	System.out.println("StrictMath.toRadians(0.33): " + StrictMath.toRadians(0.33));
    }
}

我们分别看看在Java11和Java8的结果:

$ /usr/lib/jvm/java-11-openjdk-amd64/bin/java Hello
Math.toRadians(0.33): 0.005759586531581287
StrictMath.toRadians(0.33): 0.005759586531581287

$ /usr/lib/jvm/java-1.8.0-openjdk-amd64/bin/java Hello
Math.toRadians(0.33): 0.005759586531581288
StrictMath.toRadians(0.33): 0.005759586531581288

最后一位很奇怪的差了1,我们继续深入进去看到toRadians的实现:

  • Java8的实现为:

    // Java 8 public static double toDegrees(double angrad) { return angrad * 180.0 / PI; }

  • Java11的实现为:

    private static final double DEGREES_TO_RADIANS = 0.017453292519943295; public static double toRadians(double angdeg) { return angdeg * DEGREES_TO_RADIANS; }

原来在Java11的实现中,为了优化性能,将* 180.0 / PI提前算好了,这样每次只用乘以乘数即可,从而化简了计算。这也最终导致了,Java8和Java11在精度上有一些差别。

4. 总结

  • Math在各个arch下的实现不同,精度也不同,如果对精度要求很高,可以使用StrictMath。
  • Java不同版本的优化,也有可能导致Math库的精度不同
  • Math库在实现时,利用intrinsics机制,把各个arch下Math的实现换掉了,从而充分的发挥各个CPU自身的优势。