Unveiling Floating-Point Modulus Surprises in Java

When working with double in Java, floating-point representation errors can accumulate, leading to unexpected behaviour—especially when using the modulus operator. In this article, we'll explore how these errors manifest and why they can cause loops to terminate earlier than anticipated.

The Unexpected Loop Termination

Consider the following loop:

Set<Double> set = new HashSet<>();
for (int i = 0; set.size() < 1000; i++) {
    double d = i / 10.0;
    double mod = d % 0.1;
    if (set.add(mod)) {
        System.out.printf("i: %,d / 10.0 = %s, with %% 0.1 = %s%n",
                i, new BigDecimal(d), new BigDecimal(mod));
    }
}

At first glance, this loop should run indefinitely. After all, the modulus of d % 0.1 for multiples of 0.1 should always be zero, right? Surprisingly, this loop completes after 2,243 iterations, having collected 1,000 unique modulus values. How is this possible?

The full code is available on GitHub.

Understanding Floating-Point Representation Errors

To grasp what's happening, we need to delve into how floating-point numbers are represented in Java.

The Illusion of Precision

While integers and some fractions can be represented exactly in binary, numbers like 0.1 cannot. The double representation of 0.1 isn't precisely 0.1; instead, it's a close approximation. Using BigDecimal, we can reveal the exact value:

double value = 0.1d;
BigDecimal bd = new BigDecimal(value);
System.out.println("0.1d = " + bd);

Output:

0.1d = 0.1000000000000000055511151231257827021181583404541015625

As you can see, 0.1d is actually slightly more than 0.1.

Division Introduces Errors

When we divide an integer by 10.0, we might assume the result is precise. However, due to floating-point representation, even these results can be slightly off.

Let's examine the case when i = 3 and i = 11:

int i = 3;
double d = i / 10.0;
BigDecimal bd = new BigDecimal(d);
System.out.println(i + " / 10.0 = " + bd);

i = 11;
d = i / 10.0;
bd = new BigDecimal(d);
System.out.println(i + " / 10.0 = " + bd);

Output:

3 / 10.0 = 0.299999999999999988897769753748434595763683319091796875
11 / 10.0 = 1.100000000000000088817841970012523233890533447265625

Instead of 0.3, we get a number slightly less, and instead of 1.1, we get a value slightly more.

The Modulus Operation's Sensitivity

The modulus operator % is particularly sensitive to these tiny discrepancies. When we compute d % 0.1, we calculate the remainder after dividing d by 0.1. If both d and 0.1 have representation errors, the result can be unpredictable.

Continuing with i = 3:

double mod = d % 0.1;
BigDecimal modBd = new BigDecimal(mod);
System.out.println("d % 0.1 = " + modBd);

Output for i = 3:

d % 0.1 = 0.09999999999999997779553950749686919152736663818359375

Here, instead of 0.0, we get a value slightly less than 0.1.

Output for i = 11:

d % 0.1 = 2.77555756156289135105907917022705078125E-17

Here, we get a value slightly more than 0.0.

Accumulation of Unique Modulus Values

Due to these minute differences, each iteration may produce a unique mod value that the Set<Double> considers distinct. This is why our loop terminates after 2,243 iterations—the set reaches 1,000 unique modulus values much sooner than expected.

Practical Implications

In real-world applications, such floating-point anomalies can lead to bugs that are hard to detect and fix. For instance:

  • Incorrect Loop Termination: Loops might exit earlier or later than intended.
  • Equality Checks Fail: Comparisons using == may fail unexpectedly.
  • Data Structures Misbehave: Hash-based collections like Set and Map might treat nearly identical values as distinct keys.

Conclusion

Floating-point representation errors are a subtle yet significant aspect of programming in Java. They can lead to surprising results, particularly when using operations sensitive to these errors, like modulus. By understanding these issues and adopting best practices, we can write more robust and reliable code.

Key Takeaways

  • Floating-point numbers may not represent decimal values precisely due to binary limitations.
  • Modulus operations on doubles can produce unexpected results because of accumulated representation errors.
  • Use BigDecimal for precise decimal arithmetic or appropriate rounding to avoid floating-point inaccuracies.
  • Implement tolerance levels when comparing floating-point numbers.

Engage and Share

Have you encountered unexpected behaviour due to floating-point errors in your projects? How did you address them? Share your experiences in the comments below.

Comments

Popular posts from this blog

Java is Very Fast, If You Don’t Create Many Objects

System wide unique nanosecond timestamps

Comparing Approaches to Durability in Low Latency Messaging Queues