java.math.BigDecimalが一部の計算結果を誤る

IBMから不具合情報が出ていました。
http://www-06.ibm.com/jp/domino01/mkt/cnpages1.nsf/page/default-ywa00002
JDKで計算結果のミスとか結構深刻です。しかしIBMJDKのみの問題なのでSun Java SDKを使用するOS(SolarisHP-UX)では発生しません。なのでそこまでは騒がれてませんね。

そして不具合の詳細を見てみると

【発生条件】
上記対象となっているJDK上で、java.math.BigDecimalを使用し、以下の処理を実行した場合、誤った結果が戻る可能性があるという問題が、APAR PK87098にて報告されています。実行結果として例外がスローされず、誤った計算結果が戻る可能性があります。 com.ibm.math.BigDecimalクラスを使用した場合にはこの問題は発生しません。

(1) BigDecimalオブジェクトに値をセット
(2) 値を add(), subtract(), multiply(), divide() 四則演算を実行
(3) 計算結果を setScale() で roundingMode を ROUND_HALF_UP とする処理を実行

こんな感じ。しかしこれだけ読むとかなり当たり前な動作にしか見えません。
こんなのがなんで今まで見つからなかったの?と疑問になったので調べてみました。

実証コードで検証

IBMより出ている実証コードで検証してみる。

    public static void main(String[] args) {
        BigDecimal temp = new BigDecimal("0");
        BigDecimal result = new BigDecimal("0");
        BigDecimal bd1 = new BigDecimal("0.02105839");
        BigDecimal bd2 = new BigDecimal("47.4870000000");
        temp = bd1.multiply(bd2);
        System.out.println("temp = " + temp);
        result = temp.setScale(0, BigDecimal.ROUND_HALF_UP);
        System.out.println("result = " + result);
        result = temp.setScale(1, BigDecimal.ROUND_HALF_UP);
        System.out.println("result = " + result);
    }

これを各JVMで動かしてみると

SunJava1.6.0
temp = 0.999999765930000000
result = 1

まったく普通。

IBMJDK1.4.2
temp = 0.999999765930000000
result = -1

なんと!!「0.999・・・」をSetScaleすると「-1」になるのか!!!
これは華麗な不具合。

原因を追う

最初「-1」になっているのでVMの内部で桁あふれでもしてるのかと思っていたのですがソースを追ったところBigDecimalの内部の計算方法が原因でした。
まず、SunJavaのBigDecimalとIBMJDKのBigDecimalはソースがかなり違います。IBMJDKは独自に「intLong support」を行っており、この計算ロジックでこの不具合が起こっていることがわかりました。

まずsetScalseが呼ばれスケールが異なっている場合内部で「divide」が呼び出されます。

   public BigDecimal setScale(int scale, int roundingMode)
   {

      if (scale < 0)
         throw new ArithmeticException("Negative scale");
      if (roundingMode < ROUND_UP || roundingMode > ROUND_UNNECESSARY)
         throw new IllegalArgumentException("Invalid rounding mode");

      /* Handle the easy cases */
      if (scale == this.scale)
         return this;


      if (scale > this.scale)
         return scaleUp(this, scale-this.scale);
      else if (scale < MAX_DIGITS)/* scale < this.scale */
         return divide(scaleUp(valueOf(1), scale), scale, roundingMode);
      else
         return divide(valueOf(1),scale,roundingMode);

   }

そしてdivideに来た後、指定されたスケールの数字(0.1や0.01)で割り算をして余りを求め、その余りによって切り上げ、切捨てなどを計算しているのですが、この割り算の中に不具合の原因があります。

実証例の場合setScaleする前の計算結果(temp)は「0.999999765930000000」です。このスケールは18桁。
これとsetScale(0)なので「1」で四捨五入を計算します。
その際に以下の様な流れになります。

 A 0.999999765930000000 を整数にして 999999765930000000 を取得
 B 1*(10^18) を行いスケールを合せます。
 C A/B を計算し余りを求めます。
 D Cで求めた余り*10/B を計算し「5」と比較し切り上げるか切り下げるかを判定します。

このようにして四捨五入を実現しています。
この中で不具合となるのはCの部分。

 999999765930000000 % 1000000000000000000
と成る為余りは
 999999765930000000
これに10をかけます。すると
 9999997659300000000
となる。
これはLongの最大値
 9223372036854775807
を超えています。
その為マイナス値に成ってしまい
 -8446746414409551616
と評価されます。

これは5より小さい為切捨てと判断され、
Cの割り算の商の「0」から「1」を引きます。
 0 - 1 = -1
結果出力は-1になる。

こんな原因だったのですね。しかしなかなかレアなケースだ。
検証していませんが、ソースを読んだ限りこの不具合の起こる要件は以下の通りだと思います。

  1. setScaleされる元のBigDecimalのスケールが18であること
  2. setScaleで指定したスケールとその数字の保持するスケールの差が18あること。⇒つまりsetScale(0)の場合
  3. 元の少数の頭部分が0.922・・を超えていること

教訓

掛け算するときは常に限界値を意識しましょう。
って事ですね。いや、ほんと