16

Can somebody explain to me why the following code:

public class Test {
    public static void main(String... args) {
        round(6.2088, 3);
        round(6.2089, 3);
    }

    private static void round(Double num, int numDecimal) {
        System.out.println("BigDecimal: " + new BigDecimal(num).toString());

        // Use Locale.ENGLISH for '.' as decimal separator
        NumberFormat nf = NumberFormat.getInstance(Locale.ENGLISH);
        nf.setGroupingUsed(false);
        nf.setMaximumFractionDigits(numDecimal);
        nf.setRoundingMode(RoundingMode.HALF_UP);

        if(Math.abs(num) - Math.abs(num.intValue()) != 0){
            nf.setMinimumFractionDigits(numDecimal);
        }

        System.out.println("Formatted: " + nf.format(num));
    }
}

gives the following output?

[me@localhost trunk]$ java Test
BigDecimal: 6.208800000000000096633812063373625278472900390625
Formatted: 6.209
BigDecimal: 6.208899999999999863575794734060764312744140625
Formatted: 6.208

In case you don't see it: "6.2089" rounded to 3 digits gives the output "6.208" while "6.2088" gives "6.209" as output. Less is more?

The results were good when using Java 5, 6 or 7 but this Java 8 gives me this strange output. Java version:

[me@localhost trunk]$ java -version
java version "1.8.0_05"
Java(TM) SE Runtime Environment (build 1.8.0_05-b13)
Java HotSpot(TM) Server VM (build 25.5-b02, mixed mode)

EDIT: this is Java 7's output:

[me@localhost trunk]$ java Test
BigDecimal: 6.208800000000000096633812063373625278472900390625
Formatted: 6.209
BigDecimal: 6.208899999999999863575794734060764312744140625
Formatted: 6.209

Java 7 version:

[me@localhost trunk]$ java -version
java version "1.7.0_51"
Java(TM) SE Runtime Environment (build 1.7.0_51-b13)
Java HotSpot(TM) Server VM (build 24.51-b03, mixed mode)
AndrewBourgeois
  • 2,634
  • 7
  • 41
  • 58

3 Answers3

19

I could track down this issue to class java.text.DigitList line 522.

The situation is that it thinks the decimal digits 6.0289 are already rounded (which is correct when comparing to the equivalent BigDecimal representation 6.208899…) and decides to not round up again. The problem is that this decision makes sense only in the case that the digit resulting from rounding up is 5, not when it is bigger than 5. Note how the code for HALF_DOWN correctly differentiates between the digit=='5' and digit>'5' case.

This is a bug, obviously, and a strange one given the fact that the code for doing similar right (just for the other direction) is right below the broken one.

        case HALF_UP:
            if (digits[maximumDigits] >= '5') {
                // We should not round up if the rounding digits position is
                // exactly the last index and if digits were already rounded.
                if ((maximumDigits == (count - 1)) &&
                    (alreadyRounded))
                    return false;

                // Value was exactly at or was above tie. We must round up.
                return true;
            }
            break;
        case HALF_DOWN:
            if (digits[maximumDigits] > '5') {
                return true;
            } else if (digits[maximumDigits] == '5' ) {
                if (maximumDigits == (count - 1)) {
                    // The rounding position is exactly the last index.
                    if (allDecimalDigits || alreadyRounded)
                        /* FloatingDecimal rounded up (value was below tie),
                         * or provided the exact list of digits (value was
                         * an exact tie). We should not round up, following
                         * the HALF_DOWN rounding rule.
                         */
                        return false;
                    else
                        // Value was above the tie, we must round up.
                        return true;
                }

                // We must round up if it gives a non null digit after '5'.
                for (int i=maximumDigits+1; i<count; ++i) {
                    if (digits[i] != '0') {
                        return true;
                    }
                }
            }
            break;

The reason why this doesn’t happen to the other number is that 6.2088 is not the result of rounding up (again, compare to the BigDecimal output 6.208800…). So in this case it will round up.

Holger
  • 285,553
  • 42
  • 434
  • 765
  • 2
    +1 for observing that it has already been fixed for HALF_DOWN. – Peter Lawrey Jun 26 '14 at 09:56
  • 4
    In case somebody is wondering why they actually did change the algorithm: Java 7 does it wrong for values like, e.g. `1234567890123.45949`. So the fix has introduced a new bug… The work-around is to use `nf.format(new BigDecimal(num))` which avoids both bugs. – Holger Jun 26 '14 at 13:46
  • Would you not favour `nf.format(BigDecimal.valueOf(num))` – Peter Lawrey Jun 26 '14 at 15:12
  • 3
    @Peter Lawrey: `new BigDecimal(double)` and `BigDecimal.valueOf(double)` do not the same. The latter would perform an implicit round-to-double operation which would re-introduce the double-rounding problem for `1234567890123.45949`. However `valueOf` would be the better choice if you actually meant `1234567890123.4595`. If you want to be on the safe side, use `BigDecimal.valueOf(String)` – Holger Jun 26 '14 at 15:25
  • The `new BigDecimal(String)` will make a difference when there is more precision than double can handle, otherwise it should be same (or at least that is what the Javadoc says ;) – Peter Lawrey Jun 26 '14 at 15:28
  • 1
    `new BigDecimal(String)` makes no difference for values for which using `BigDecimal.valueOf(double)` rather than `new BigDecimal(double)` makes no difference. But for the values here, it does. – Holger Jun 27 '14 at 08:11
3

Oracle fixed this bug in Java 8 update 40

An unofficial runtime patch is available for earlier versions

Thanks to the research in Holger's answer, I was able to develop a runtime patch and my employer has released it free under the terms of the GPLv2 license with Classpath Exception1 (the same as the OpenJDK source code).

The patch project and source code is hosted on GitHub with more details about this bug as well as links to downloadable binaries. The patch makes no modifications to the installed Java files on disk, and it should be safe for use on all versions of Oracle Java >= 6 and at least through version 8 (including fixed versions).

When the patch detects bytecode signatures that suggest the bug is present, it replaces the HALF_UP switch case with a revised implementation:

if (digits[maximumDigits] > '5') {
    return true;
} else if (digits[maximumDigits] == '5') {
    return maximumDigits != (count - 1)
        || allDecimalDigits
        || !alreadyRounded;
}
// else
return false; // in original switch(), was: break;

1 I am not a lawyer, but my understanding is that GPLv2 w/ CPE allows commercial use in binary form without GPL applying to the combined work.

Community
  • 1
  • 1
William Price
  • 4,033
  • 1
  • 35
  • 54
2

Tracing through the code you get to DigitList.set

final void set(boolean isNegative, double source, int maximumDigits, boolean fixedPoint) {

    FloatingDecimal.BinaryToASCIIConverter fdConverter  = FloatingDecimal.getBinaryToASCIIConverter(source);
    boolean hasBeenRoundedUp = fdConverter.digitsRoundedUp();

I have a simpler test for this bug

import java.math.RoundingMode;
import java.text.NumberFormat;
import java.util.Locale;

public class Test {
    public static void main(String... args) {
        for (int i = 0; i < 100; i++)
            test(i / 100.0);
    }

    private static void test(double num) {
        NumberFormat nf = NumberFormat.getInstance(Locale.ENGLISH);
        nf.setMaximumFractionDigits(1);
        String round1 = nf.format(num);

        NumberFormat nf2 = NumberFormat.getInstance(Locale.ENGLISH);
        nf2.setMaximumFractionDigits(1);
        nf2.setRoundingMode(RoundingMode.HALF_UP);
        String round2 = nf2.format(num);
        if (!round1.equals(round2))
            System.out.printf("%s, formatted with HALF_UP was %s but should be %s%n", num, round2, round1);
    }
}

prints

0.06, formatted with HALF_UP was 0 but should be 0.1
0.09, formatted with HALF_UP was 0 but should be 0.1
0.18, formatted with HALF_UP was 0.1 but should be 0.2
0.25, formatted with HALF_UP was 0.3 but should be 0.2
0.29, formatted with HALF_UP was 0.2 but should be 0.3
0.36, formatted with HALF_UP was 0.3 but should be 0.4
0.37, formatted with HALF_UP was 0.3 but should be 0.4
0.47, formatted with HALF_UP was 0.4 but should be 0.5
0.48, formatted with HALF_UP was 0.4 but should be 0.5
0.49, formatted with HALF_UP was 0.4 but should be 0.5
0.57, formatted with HALF_UP was 0.5 but should be 0.6
0.58, formatted with HALF_UP was 0.5 but should be 0.6
0.59, formatted with HALF_UP was 0.5 but should be 0.6
0.69, formatted with HALF_UP was 0.6 but should be 0.7
0.86, formatted with HALF_UP was 0.8 but should be 0.9
0.87, formatted with HALF_UP was 0.8 but should be 0.9
0.96, formatted with HALF_UP was 0.9 but should be 1
0.97, formatted with HALF_UP was 0.9 but should be 1
0.98, formatted with HALF_UP was 0.9 but should be 1
0.99, formatted with HALF_UP was 0.9 but should be 1

In the incorrect case hasBeenRoundedUp is true and this prevents any further rounding up. Note, if you drop setting the rounding, it has a default path which rounds up correctly.

I wouldn't use NumberFormat. It is pretty slow and complicated to use.

import java.math.BigDecimal;

public class Test {
    public static void main(String... args) {
        round(6.2088, 3);
        round(6.2089, 3);
    }

    private static void round(double num, int numDecimal) {
        BigDecimal bd = new BigDecimal(num);
        BigDecimal bd2 = BigDecimal.valueOf(num);
        System.out.println("new BigDecimal: " + bd);
        System.out.println("BigDecimal.valueOf: " + bd2);
        System.out.printf("%." + numDecimal + "f%n", num);
        System.out.printf("%." + numDecimal + "f%n", bd);
        System.out.printf("%." + numDecimal + "f%n", bd2);
        System.out.printf("%f%n", round3(num));
        System.out.printf("%s%n", round3(num));
        System.out.printf("%f%n", bd.setScale(numDecimal, BigDecimal.ROUND_HALF_UP));
        System.out.printf("%s%n", bd.setScale(numDecimal, BigDecimal.ROUND_HALF_UP));
        System.out.printf("%f%n", bd2.setScale(numDecimal, BigDecimal.ROUND_HALF_UP));
        System.out.printf("%s%n", bd2.setScale(numDecimal, BigDecimal.ROUND_HALF_UP));
    }

    private static double round3(double num) {
        final double factor = 1e3;
        return Math.round(num * factor) / factor;
    }
}

prints with Java 8.

new BigDecimal: 6.208800000000000096633812063373625278472900390625
BigDecimal.valueOf: 6.2088
6.209
6.209
6.209
6.209000
6.209
6.209000
6.209
6.209000
6.209
new BigDecimal: 6.208899999999999863575794734060764312744140625
BigDecimal.valueOf: 6.2089
6.209
6.209
6.209
6.209000
6.209
6.209000
6.209
6.209000
6.209
Peter Lawrey
  • 525,659
  • 79
  • 751
  • 1,130
  • 2
    You were 38s faster than me :-). However, it’s not the assumption of `hasBeenRoundedUp` that is incorrect here, but the consequence in this special rounding mode case. Details in my answer… – Holger Jun 26 '14 at 09:52