本番リリース後にトラブル発生!魔のJavaマルチスレッド問題とは!?

JavaベースのWebサイトを本番リリースした後、発生するトラブル・・・。あってはいけないけど、トラブルが発生した原因を調査し、対処しなきゃいけない。

で、原因を調査するとき、まず再現条件を調べるんだけど、再現させるのが難しいのがこのマルチスレッド問題。ページをリロードする度に、うまくいったり、エラーになったりを繰り返すから、再現条件は分からない。ほとんどの場合、調査にも時間がかかってしまう。

Javaの関連記事:

マルチスレッド問題を再現してみる

じゃあ、まず、マルチスレッド問題が発生するサンプルで、実際に再現してみよう。

このサンプルページにアクセスすると、アクセスした日付が50回並べて表示される。dパラメータを指定すると、アクセスした日から指定日数だけ前、もしくは後の日付が表示される。

import java.io.IOException;
import java.text.*;
import java.util.*;
import javax.servlet.*;
import javax.servlet.http.*;

public class BugServlet extends HttpServlet {
  private static final DateFormat format = new SimpleDateFormat("yyyy/M/d");

@Override
  protected void doGet(HttpServletRequest request, HttpServletResponse response)
    throws ServletException, IOException {

    response.setContentType("text/plain");

    // 発生確率を高めるため、50回繰り返す
    for (int i = 0; i < 50; i++) {
      // 対象日を計算
      int d = 0;
      try {
        d = Integer.parseInt(request.getParameter("d"));
      } catch (Exception e) {}

      Calendar date = new GregorianCalendar();
      date.add(GregorianCalendar.DATE, d);

      // 文字列に変換
      String text = format.format(date.getTime());

      // レスポンスに出力
      response.getWriter().printf("date: %srn", text);
    }
  }
}

たとえば、2008/2/22 に http://localhost:8080/test/BugServlet?d=0 というURLにアクセスすると、次のように表示される。

date: 2008/2/22
date: 2008/2/22
date: 2008/2/22
date: 2008/2/22
date: 2008/2/22
date: 2008/2/22
date: 2008/2/22
  (以下略)

http://localhost:8080/test/BugServlet?d=-8000 というURLだと、次のように表示される。

date: 1986/3/29
date: 1986/3/29
date: 1986/3/29
date: 1986/3/29
date: 1986/3/29
date: 1986/3/29
date: 1986/3/29
  (以下略)

この段階では、何も異常は感じないが・・・。

これがマルチスレッド問題によるデータ破壊だ!

今度は、2台のPCを使ってサンプルページに集中的にアクセスしてみる。すると、思わぬ結果になる。

次のような条件でテストを行ってみた。

  • PC-1:http://localhost:8080/test/BugServlet?d=0 にアクセスし、[F5]キー押しっぱなし
  • PC-2:http://localhost:8080/test/BugServlet?d=-8000 にアクセスし、[F5]キーを時々押す

すると、PC-1の画面が次のようになった。

date: 2008/2/22
date: 2008/2/22
date: 2008/3/29   ← 注目
date: 2008/2/29   ← 注目
date: 2008/2/29   ← 注目
date: 2008/2/29   ← 注目
date: 2008/2/29   ← 注目
date: 2008/2/22
date: 2008/2/22
date: 2008/2/22
date: 2008/2/22
  (以下略)

何と、2008/2/22に混じって、2008/3/29とか、2008/2/29とか、ありえない日付に!

この画面、すぐに表示されたわけじゃない。何度も何度も試して、ようやく1回だけ再現。

それくらい発見も難しく、さらに再現性が難しいクセ者。それがマルチスレッド問題なのだ。

何が起きていたのか?

なぜこんなことが起きたのか?キーポイントとなるのは、DateFormatクラスの仕組みとその使い方だ。

DateFormat#formatの仕組み

まずは、DateFormatクラスのformatメソッドがどのような動作をしているのかを見てみる。formatメソッドは、引数に指定された日付(Date)を文字列(String)に整形して結果を返す。

例として、次のようなコードを考える。

static DateFormat format = new SimpleDateFormat("yyyy/M/d");

                 :

  Calender date = new GregorianCalender(2008, 2, 22);
  String s = format.format(date.getTime());

実行した際のformatメソッドの動作は以下の通り。

thread1-s

おおまかには、次のような流れとなる。

  1. ①で引数に指定したDate変数の値をメンバ変数calenderにセットする
  2. ②でStringBufferをインスタンス化する
  3. ③で出力パターンと日付の合成処理を行って、Stringにする

スレッドが割り込むと・・・

ここで、③-2と③-3の処理の間に、別スレッドが割り込んだらどうなるか?次のようなケースを考えてみる。

  • スレッドA:GregorianCalenderの引数に2008/2/22を指定し、formatメソッドを呼び出す
  • スレッドB:スレッドAが③-2の処理を終えた直後、GregorianCalenderの引数に1986/3/29を指定し、formatメソッドを呼び出す

thread3-s

スレッドAが③-2の処理を終えた段階では、StringBuffer内の文字列は”2008/”となっている。しかし、スレッドBが①の処理を行い、calender変数の値が変化してしまうと・・・。スレッドAが③-3や③-5で月や日を出力すると、今度は1986/3/29の月や日を出力してしまうのだ!

Javaアプリケーションサーバーは、複数クライアントからのリクエストを同時に受け付ける。なので、複数台のPCから同時にアクセスすると、別スレッドの割り込みが発生した。それで、”2008/3/29”とか、”2008/2/29″といった不整合データが発生したのだった!

どうすりゃ防げるか!?

じゃあ、この現象を防ぐにはどうすればよかったのか?方法は2つある。

  • [方法A]スレッドごとにDateFormatインスタンスを生成
  • [方法B]DateFormat#formatメソッド呼び出しの同期化

[方法A]スレッドごとにDateFormatインスタンスを生成

thread4-s

もともと、DateFormatはインスタンスを1つだけ作成したものをすべてのスレッドで共通して使用していた。共有しているがゆえに、他のスレッドの動作に影響を受けてしまうのだ。

この方法は、インスタンスを共有せず、スレッドごとにDateFormatインスタンスを作成するというもの。そうすれば、①でcalender変数を書き換えても、別のスレッドで使用されることがない。つまり、formatメソッドの動作が他のスレッドに影響されることはないのだ。

ソースコードは次のように修正すればOK。

  // ★インスタンスは毎回作成するため、doGet内に移動
  //private static final DateFormat format = new SimpleDateFormat("yyyy/M/d");

  @Override
  protected void doGet(HttpServletRequest request, HttpServletResponse response)
    throws ServletException, IOException {

       :
       :
       :

      Calendar date = new GregorianCalendar();
      date.add(GregorianCalendar.DATE, d);

      // ★インスタンスを毎回作成する
      DateFormat format = new SimpleDateFormat("yyyy/M/d");
      String text = format.format(date.getTime());

       :
       :
       :

[方法B]DateFormat#formatメソッド呼び出しの同期化

thread5-s

不正な日付が生成されたのは、formatメソッドの処理途中で、別のスレッドがformatメソッドの処理を行ったことにある。formatメソッドの処理途中に他のスレッドがformatメソッドを呼び出しても、formatメソッドの処理が終了するまで待つ。その方法なら、formatメソッドが同時に処理されることがないから、問題は発生しない。

同期化するには、synchronizedブロックでformatメソッドの呼び出しを囲む。具体的には、次のようなコード。

  private static final DateFormat format = new SimpleDateFormat("yyyy/M/d");

  @Override
  protected void doGet(HttpServletRequest request, HttpServletResponse response)
    throws ServletException, IOException {

       :
       :
       :

      // 文字列に変換
      String text;
      // ★format変数で同期化する。
      synchronized (format) {
        text = format.format(date.getTime());
      }
       :
       :
       :

DateFormatクラスは注意が必要

注意が必要なDateFormatクラス。そのことは、JavaDocにもちゃんと書いてある(DateFormatクラスのJavaDoc)。

日付フォーマットは同期化されません。スレッドごとに別のフォーマットインスタンスを作成することをお勧めします。複数のスレッドがフォーマットに同時にアクセスする場合は、外部的に同期化する必要があります。

ここに書いてある「スレッドごとに別のフォーマットインスタンスを作成」とは、具体的には上の[方法A]を指す。「外部的に同期化」は、[方法B]が該当する。

まとめ

マルチスレッド関連のトラブルは、発見がホントに困難だし、修正しても動作確認をするのも大変。ソースを書くときから注意したい。

以下ルールを守れば、DateFormatによる問題は防げる。同期化よりは、毎回インスタンス化する方が分かりやすくていいかな?

  • DateFormatは毎回インスタンス化する
  • インスタンス化したDateFormatは、クラス変数やインスタンス変数にセットしない

ちなみにDateFormat以外にも、まだまだ危険なクラスが・・・。それは次回で。