• 假如出一道算法题:请你实现绝对值函数。

相信绝大部分的人甚至都不屑一顾,(并投来嘲讽就这?就这?就这?这也能出题?)不到一分钟就给出如下答案,如果你第一印象给出的答案和下面不一样,请到评论区给我留言。

// V1.0
    public static double myAbs(double value) {
        if (value < 0) {
            return -value;
        }
        return value;
    }

咋一看没什么问题,符合绝对值的数学定义:正数或零的绝对值是它本身;负数的绝对值是它的相反数。但是我们忽略了一件很重要的事情,计算机里面的浮点数本身就是用离散的电位去模拟数学上的连续性,换言之我们身为程序员必须考虑数字在计算机中如何实现的,否则会产生难以解释的现象。参考如下代码:

// 阅读代码请思考程序会输出 oops1 还是 oops2 ?
        double x = -0.0;
        if (1 / Math.abs(x) < 0) {
            System.out.println("oops1");
        }

        if (1 / myAbs(x) < 0) {
            System.out.println("oops2");
        }

答案我就不说了,如果我们的实现绝对值和JDK实现的行为一致,那么应该不会有任何输出,具体请各位读者自行验证。

那么现在该打脸了,分分钟给出的答案在这个测试用例面前失效了orz。Java在实现浮点数的时候参考IEEE 745标准,Java区分正零+0.0和负零-0.0,在进行计算的时候两者有区别,如下代码所示。

// Infinity
        System.out.println(1 / +0.0);
        
        // -Infinity
        System.out.println(1 / -0.0);

但是在进行比较的时候,两者没有任何区别。这就是为什么我们实现的平凡的绝对值函数在上面那个测试用例中失败了,在绝对值函数中不应该出现-0.0,而myAbs却返回了-0.0。

System.out.println(+0.0 > 0);     // false
        System.out.println(+0.0 < 0);     // false
        System.out.println(+0.0 == 0);    // true

        System.out.println(-0.0 < 0);     // false
        System.out.println(-0.0 < 0);     // false
        System.out.println(-0.0 == 0);    // true

        System.out.println(-0.0 == +0.0); // true

现在我们终于弄清楚了这个bug,下面就是对它进行修复,一个很简单的思路既然是-0.0导致的问题,那么对它进行判断就好了,于是不到一分钟代码变成下面这样:

// V1.1
    public static double myAbs(double value) {
        if (value < 0 || -0.0 == value) {
            return -value;
        }
        return value;
    }

如果你也是这么想的,那么恭喜你第二次打脸了,-0.0 == +0.0 为true,那么用例中 x=+0.0 时,myAbs的行为仍然不正确。幸运的是可以使用JDK提供的Double.compare对浮点数进行比较,得到如下的代码,而且这个代码是可以通过测试用例正常工作的。 

// V1.2
    public static double myAbs(double value) {
        if (value < 0 || Double.compare(value, -0.0) == 0) {
            return -value;
        }
        return value;
    }

一般人就止步于此,但是假如我们是JDK的编写者,给用户这样的代码是不能接受的,因为一个如此常用的方法,却因为单独比较-0.0这个不常出现的用例而损失了大量性能,正数需要两次比较,-0.0和+0.0需要三次,可以参考JDK代码源码:

public static int compare(double d1, double d2) {
        if (d1 < d2)
            return -1;           // Neither val is NaN, thisVal is smaller
        if (d1 > d2)
            return 1;            // Neither val is NaN, thisVal is larger

        // Cannot use doubleToRawLongBits because of possibility of NaNs.
        long thisBits    = Double.doubleToLongBits(d1);
        long anotherBits = Double.doubleToLongBits(d2);

        return (thisBits == anotherBits ?  0 : // Values are equal
                (thisBits < anotherBits ? -1 : // (-0.0, 0.0) or (!NaN, NaN)
                 1));                          // (0.0, -0.0) or (NaN, !NaN)
    }

其中主要用到Double.doubleToLongBits将浮点数转成8位的Long型正数进行比较,那么我们去掉其中的比较部分把后面的逻辑加到myAbs中于是得到下面这个版本,对于正数和所有的零都只需要比较两次,在Java的JIT中Double.doubleToLongBits方法的性能忽略不计,只是相当于将寄存器中的bit重新解释,可能引起的操作是将专门用于浮点计算的寄存器转换成通用寄存器,这一点CPU是不关心的,所以这个版本的绝对值计算还是很快的。

// V1.3
    private static final long MINUS_ZERO_LONG_BITS =
            Double.doubleToLongBits(-0.0);
    public static double myAbs(double value) {
        if (value < 0 || Double.doubleToLongBits(value) == MINUS_ZERO_LONG_BITS) {
            return -value;
        }
        return value;
    }

目前我们又向前推进了一步,但是代码中存在两个两个分支,分支意味着bad,如果CPU的分支预测失效了,那么上述代码仍然是有性能损耗的,应该想办法减少分支。我们这时候可以参考JDK的代码发现只有一个分支!

// V1.4 for JDK1.8
    public static double abs(double a) {
        return (a <= 0.0D) ? 0.0D - a : a;
    }

能这么做基于这么个事实:不管是+0.0还是-0.0,用0.0去减都得到0.0,使得绝对值函数中不会出现-0.0且只包含一个分支。

System.out.println(0.0-(-0.0)); // 0.0
System.out.println(0.0-(+0.0)); // 0.0

这就是极致了吗?

NO,通过观察IEEE 745发现,浮点数的二进制表示中的第一位是符号位,1代表负数,0代表正数。这可能是最重要的一位,因为如果没有这一位你将无法分辨正数和负数。但是从计算绝对值这个操作来看,将符号位去掉,剩下的位解释成正数,就得到这个负数的绝对值,于是有下面代码,先获取负数的位表示,然后通过掩码 0x7fffffffffffffffL 将符号位置0,最后再将得到的值重新解释为浮点数,得到一个没有分支的绝对值函数:

// V1.5
    public static double abs(double value) {
        return Double.longBitsToDouble(
                Double.doubleToRawLongBits(value) & 0x7fffffffffffffffL);
    }

大多数情况下由于Java编译器的优秀性能上述代码几乎没有明显的性能提升,但是在某些平台上性能大约提升了10%,上述代码已经提交到openjdk commit, 文章介绍新的绝对值计算方法将在Java18更新https://github.com/openjdk/jdk/pull/4711

小结:

作为一个经常写业务代码的程序员,一般是不会考虑这些问题,就是计算机科班出身的程序员在学校里学过《计算机组成原理》这门课,但是真让你自己写一个标准的绝对值函数还是很难的,需要对Java编译器,对计算机底层及相关标准都要有理解。不管这个代码会不会合入openjdk,V1.5的代码不过两行,足显功力,还是让人心生敬佩,希望我也能成为一名追求极致和优雅的程序员。

参考:

[2] https://github.com/openjdk/jdk/pull/4711

[3] One does not simply calculate the absolute value / Habr