Java: Use the BigDecimal class to cleverly handle the precision loss of the Double type

Key points of this article

  • Briefly describe the reasons why precision is lost when converting floating point from decimal to binary.

  • Introduce the differences between several ways to create BigDecimal.

  • A collection of tools for high-precision calculations.

  • I learned the provisions of Alibaba Java Development Manual on BigDecimal equality.

Classic problem: loss of precision of floating point numbers

The problem of precision loss also occurs in other computer languages. Float and double type data do not provide completely accurate results when performing binary floating point operations. The error is not due to the size of the number, but to the precision of the number.

Regarding the problem of loss of precision in floating-point number storage, the topic is too huge. Interested students can search for it by themselves: [Question Answer] Analyze the problem of float type memory storage and precision loss

Here is a brief discussion on why there is a loss of precision when converting decimal numbers to binary. Decimal numbers are divided into integer parts and decimal parts. Let’s take a look at them separately to understand why:

How to convert decimal integers into binary integers?

Divide the dividend by 2 each time and stop the process as long as the quotient is 0.

5 / 2 = 2 more than 1
2 / 2 = 1 remainder 0
1 / 2 = 0 remainder 1
    
//The result is 101

This algorithm will never loop infinitely. Integers can always be represented accurately using binary numbers, but what about decimals?

How to convert decimal numbers into binary numbers?

Multiply the decimal part by 2 each time and take out the integer part. If the decimal part is 0, you can stop the process.

0.1 * 2 = 0.2 takes the integer part 0
0.2 * 2 = 0.4 Take the integer part 0
0.4 * 2 = 0.8 Take the integer part 0
0.8 * 2 = 1.6 Take the integer part 1
0.6 * 2 = 1.2 Take the integer part 1
0.2 * 2 = 0.4 Take the integer part 0

//... I don’t need to write anymore when I write this. You should have discovered that the above process has begun to loop, and the decimal part can never be 0.

There is a certain probability that this algorithm will have an infinite loop, that is, it cannot use binary numbers of limited length to represent decimal decimals. This is the reason for the loss of precision problem.

How to use BigDecimal to solve the double precision problem?

We already understand why there is loss of accuracy, so we should know that when a certain business scenario requires very high accuracy for double data, some means must be taken to deal with this problem. This is why BigDecimal is widely used. The reason is in the amount payment scenario.

The BigDecimal class is located under the java.math package and is used to perform precise operations on numbers with more than 16 significant digits. Generally speaking, double type variables can handle 16-bit significant numbers, but in actual applications, if it exceeds 16 digits, the BigDecimal class is required to operate.

In this case, can BigDecimal be used to solve this problem?

 public static void main(String[] args) {
  // method 1
        BigDecimal a = new BigDecimal(0.1);
        System.out.println("a --> " + a);
  // Method 2
        BigDecimal b = new BigDecimal("0.1");
        System.out.println("b --> " + b);
  // Method 3
        BigDecimal c = BigDecimal.valueOf(0.1);
        System.out.println("c --> " + c);
    }

You can think about what the console output will be.

a --> 0.1000000000000000055511151231257827021181583404541015625
b --> 0.1
c --> 0.1

It can be seen that the problem of precision loss still occurs when using the constructor of method one, while methods two and three are in line with our expectations. Why is this?

These three methods actually correspond to three different constructors:

 // Pass in double
 public BigDecimal(double val) {
        this(val,MathContext.UNLIMITED);
    }
 // Pass in string
    public BigDecimal(String val) {
        this(val.toCharArray(), 0, val.length());
    }

    public static BigDecimal valueOf(double val) {
        // Reminder: a zero double returns '0.0', so we cannot fastpath
        // to use the constant ZERO. This might be important enough to
        // justify a factory approach, a cache, or a few private
        // constants, later.
        // You can see that it is actually the second type
        return new BigDecimal(Double.toString(val));
    }

Regarding these three constructors, JDK has given explanations and annotated them with Notes:

In order to prevent possible display problems with images in the future, let’s record them again:

new BigDecimal(double val)

This method is unpredictable. Taking 0.1 as an example, do you think that if you pass a double type 0.1, it will eventually return a BigDecimal with a value of 0.1? No, the reason is that 0.1 cannot be represented by a finite-length binary number and cannot be accurately represented as a double-precision number. The final result will be 0.100000xxx.

new BigDecimal(String val)

This method is completely predictable, that is to say, if you pass in a string “0.1”, it will return you a BigDecimal whose value is completely 0,1. The official also stated that if you can use this constructor, use this constructor. Function.

BigDecimal.valueOf(double val)

The second construction method is good enough, but you still want to pass in a double value. What should you do? The official actually provides you with an idea and implements it. You can use Double.toString(double val) to convert the double value to String first, and then call the second construction method. You can use the static method directly: valueOf(double val).

Double’s addition, subtraction, multiplication and division operation tool class

What BigDecimal creates is an object, so we cannot use traditional arithmetic operators such as +, -, *, / to directly perform mathematical operations on its objects, but must call its corresponding methods. The parameters in the method must also be BigDecimal objects. There are many such tools on the Internet. I will post them here directly. The logic is not difficult. It is mainly to simplify the problem of frequent mutual conversion in the project.

/**
 * Used for high-precision processing of commonly used mathematical operations
 */
public class ArithmeticUtils {
    //Default division operation precision
    private static final int DEF_DIV_SCALE = 10;

    /**
     * Provide precise addition operations
     *
     * @param v1 summand
     * @param v2 addend
     * @return the sum of the two parameters
     */

    public static double add(double v1, double v2) {
        BigDecimal b1 = new BigDecimal(Double.toString(v1));
        BigDecimal b2 = new BigDecimal(Double.toString(v2));
        return b1.add(b2).doubleValue();
    }

    /**
     * Provide precise addition operations
     *
     * @param v1 summand
     * @param v2 addend
     * @return the sum of the two parameters
     */
    public static BigDecimal add(String v1, String v2) {
        BigDecimal b1 = new BigDecimal(v1);
        BigDecimal b2 = new BigDecimal(v2);
        return b1.add(b2);
    }

    /**
     * Provide precise addition operations
     *
     * @param v1 summand
     * @param v2 addend
     * @param scale retain scale decimal places
     * @return the sum of the two parameters
     */
    public static String add(String v1, String v2, int scale) {
        if (scale < 0) {
            throw new IllegalArgumentException(
                    "The scale must be a positive integer or zero");
        }
        BigDecimal b1 = new BigDecimal(v1);
        BigDecimal b2 = new BigDecimal(v2);
        return b1.add(b2).setScale(scale, BigDecimal.ROUND_HALF_UP).toString();
    }

    /**
     * Provide accurate subtraction operation
     *
     * @param v1 minuend
     * @param v2 subtraction
     * @return the difference between the two parameters
     */
    public static double sub(double v1, double v2) {
        BigDecimal b1 = new BigDecimal(Double.toString(v1));
        BigDecimal b2 = new BigDecimal(Double.toString(v2));
        return b1.subtract(b2).doubleValue();
    }

    /**
     * Provides precise subtraction operations.
     *
     * @param v1 minuend
     * @param v2 subtraction
     * @return the difference between the two parameters
     */
    public static BigDecimal sub(String v1, String v2) {
        BigDecimal b1 = new BigDecimal(v1);
        BigDecimal b2 = new BigDecimal(v2);
        return b1.subtract(b2);
    }

    /**
     * Provide accurate subtraction operation
     *
     * @param v1 minuend
     * @param v2 subtraction
     * @param scale retain scale decimal places
     * @return the difference between the two parameters
     */
    public static String sub(String v1, String v2, int scale) {
        if (scale < 0) {
            throw new IllegalArgumentException(
                    "The scale must be a positive integer or zero");
        }
        BigDecimal b1 = new BigDecimal(v1);
        BigDecimal b2 = new BigDecimal(v2);
        return b1.subtract(b2).setScale(scale, BigDecimal.ROUND_HALF_UP).toString();
    }

    /**
     * Provides precise multiplication operations
     *
     * @param v1 multiplicand
     * @param v2 multiplier
     * @return the product of two parameters
     */
    public static double mul(double v1, double v2) {
        BigDecimal b1 = new BigDecimal(Double.toString(v1));
        BigDecimal b2 = new BigDecimal(Double.toString(v2));
        return b1.multiply(b2).doubleValue();
    }

    /**
     * Provides precise multiplication operations
     *
     * @param v1 multiplicand
     * @param v2 multiplier
     * @return the product of two parameters
     */
    public static BigDecimal mul(String v1, String v2) {
        BigDecimal b1 = new BigDecimal(v1);
        BigDecimal b2 = new BigDecimal(v2);
        return b1.multiply(b2);
    }

    /**
     * Provides precise multiplication operations
     *
     * @param v1 multiplicand
     * @param v2 multiplier
     * @param scale retain scale decimal places
     * @return the product of two parameters
     */
    public static double mul(double v1, double v2, int scale) {
        BigDecimal b1 = new BigDecimal(Double.toString(v1));
        BigDecimal b2 = new BigDecimal(Double.toString(v2));
        return round(b1.multiply(b2).doubleValue(), scale);
    }

    /**
     * Provides precise multiplication operations
     *
     * @param v1 multiplicand
     * @param v2 multiplier
     * @param scale retain scale decimal places
     * @return the product of two parameters
     */
    public static String mul(String v1, String v2, int scale) {
        if (scale < 0) {
            throw new IllegalArgumentException(
                    "The scale must be a positive integer or zero");
        }
        BigDecimal b1 = new BigDecimal(v1);
        BigDecimal b2 = new BigDecimal(v2);
        return b1.multiply(b2).setScale(scale, BigDecimal.ROUND_HALF_UP).toString();
    }

    /**
     * Provides (relatively) accurate division operations. When the division cannot be completed, the division operation is accurate to
     * 10 digits after the decimal point, subsequent digits are rounded off
     *
     * @param v1 dividend
     * @param v2 divisor
     * @return the quotient of the two parameters
     */

    public static double div(double v1, double v2) {
        return div(v1, v2, DEF_DIV_SCALE);
    }

    /**
     * Provides (relatively) accurate division operations. When inexhaustible division occurs, the scale parameter indicates
     * Fixed precision, subsequent numbers will be rounded
     *
     * @param v1 dividend
     * @param v2 divisor
     * @param scale means that it needs to be accurate to several decimal places.
     * @return the quotient of the two parameters
     */
    public static double div(double v1, double v2, int scale) {
        if (scale < 0) {
            throw new IllegalArgumentException("The scale must be a positive integer or zero");
        }
        BigDecimal b1 = new BigDecimal(Double.toString(v1));
        BigDecimal b2 = new BigDecimal(Double.toString(v2));
        return b1.divide(b2, scale, BigDecimal.ROUND_HALF_UP).doubleValue();
    }

    /**
     * Provides (relatively) accurate division operations. When inexhaustible division occurs, the scale parameter indicates
     * Fixed precision, subsequent numbers will be rounded
     *
     * @param v1 dividend
     * @param v2 divisor
     * @param scale indicates that it needs to be accurate to several decimal places
     * @return the quotient of the two parameters
     */
    public static String div(String v1, String v2, int scale) {
        if (scale < 0) {
            throw new IllegalArgumentException("The scale must be a positive integer or zero");
        }
        BigDecimal b1 = new BigDecimal(v1);
        BigDecimal b2 = new BigDecimal(v1);
        return b1.divide(b2, scale, BigDecimal.ROUND_HALF_UP).toString();
    }

    /**
     * Provide precise decimal place rounding processing
     *
     * @param v Number that needs to be rounded
     * @param scale How many decimal places to keep after the decimal point?
     * @return the rounded result
     */
    public static double round(double v, int scale) {
        if (scale < 0) {
            throw new IllegalArgumentException("The scale must be a positive integer or zero");
        }
        BigDecimal b = new BigDecimal(Double.toString(v));
        return b.setScale(scale, BigDecimal.ROUND_HALF_UP).doubleValue();
    }

    /**
     * Provide precise decimal place rounding processing
     *
     * @param v Number that needs to be rounded
     * @param scale How many decimal places to keep after the decimal point?
     * @return the rounded result
     */
    public static String round(String v, int scale) {
        if (scale < 0) {
            throw new IllegalArgumentException(
                    "The scale must be a positive integer or zero");
        }
        BigDecimal b = new BigDecimal(v);
        return b.setScale(scale, BigDecimal.ROUND_HALF_UP).toString();
    }

    /**
     * Take the remainder
     *
     * @param v1 dividend
     * @param v2 divisor
     * @param scale How many decimal places to keep after the decimal point?
     * @return remainder
     */
    public static String remainder(String v1, String v2, int scale) {
        if (scale < 0) {
            throw new IllegalArgumentException(
                    "The scale must be a positive integer or zero");
        }
        BigDecimal b1 = new BigDecimal(v1);
        BigDecimal b2 = new BigDecimal(v2);
        return b1.remainder(b2).setScale(scale, BigDecimal.ROUND_HALF_UP).toString();
    }

    /**
     * Get the remainder BigDecimal
     *
     * @param v1 dividend
     * @param v2 divisor
     * @param scale How many decimal places to keep after the decimal point?
     * @return remainder
     */
    public static BigDecimal remainder(BigDecimal v1, BigDecimal v2, int scale) {
        if (scale < 0) {
            throw new IllegalArgumentException(
                    "The scale must be a positive integer or zero");
        }
        return v1.remainder(v2).setScale(scale, BigDecimal.ROUND_HALF_UP);
    }

    /**
     * Comparison of size
     * Alibaba development specifications are clear: compareTo is required to compare equivalent values of BigDecimal, and equals is not available.
     * equals will compare the value and precision, compareTo will ignore the precision
     * @param v1 number to be compared
     * @param v2 comparison number
     * @return If v1 is greater than v2, return true otherwise false
     */
    public static boolean compare(String v1, String v2) {
        BigDecimal b1 = new BigDecimal(v1);
        BigDecimal b2 = new BigDecimal(v2);
        int bj = b1.compareTo(b2);
        boolean res;
        if (bj > 0)
            res = true;
        else
            res = false;
        return res;
    }
}

Alibaba Java Development Manual’s provisions on BigDecimal

[Mandatory] As shown above, the equality comparison of BigDecimal should use the compareTo() method instead of the equals() method.

Note: The equals() method will compare the value and precision (1.0 and 1.00 return false), while compareTo() will ignore the precision.

Let’s look at an example to understand this:

 public static void main(String[] args) {
        BigDecimal a = new BigDecimal("1");
        BigDecimal b = new BigDecimal("1.0");
        System.out.println(a.equals(b)); // false
        System.out.println(a.compareTo(b)); //0 means equal
    }

The explanation of these two methods in the JDK is as follows:

  • Using the compareTo method, two BigDecimal objects with equal values but different precisions will be considered equal, such as 2.0 and 2.00. It is recommended to use x.compareTo(y) 0 to represent one of the relationships in (<, ==, >, >=, !=, <=) , represents the operator.

  • The equals method is different from the compareTo method. This method is considered equal only when the value and precision of the two BigDecimal objects are equal. For example, 2.0 and 2.00 are not equal.

Reference reading

  • LanceToBigData: Detailed explanation of BigDecimal in Java

  • Why does Alibaba ban the use of BigDecimal's equals method for equality comparison?

  • Problem of precision loss of double and float in java

  • An in-depth discussion of the problem of loss of precision when calculating double in Java