SimpleDateFormatのマルチスレッド問題
JavaベースのWebサイトを本番リリースした後、発生するトラブル・・・。あってはいけないけど、トラブルが発生した原因を調査し、対処しなきゃいけない。
で、原因を調査するとき、まず再現条件を調べるんだけど、再現させるのが難しいのがこのマルチスレッド問題。ページをリロードする度に、うまくいったり、エラーになったりを繰り返すから、再現条件は分からない。ほとんどの場合、調査にも時間がかかってしまう。
Javaの関連記事:
- OutOfMemoryError回避のためのJavaコーディング – 前編
- 自力でNoClassDefFoundErrorを解決!(前半)
- マルチスレッドの注意点まとめ
- OracleでDate型の時刻が00:00:00になる原因
- MissingResourceExceptionの解決法
- NoClassDefFoundErrorの原因と対応(2)
- Eclipse スクラップブックの便利な使い方
マルチスレッド問題を再現してみる
じゃあ、まず、マルチスレッド問題が発生するサンプルで、実際に再現してみよう。
このサンプルページにアクセスすると、アクセスした日付が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メソッドの動作は以下の通り。
おおまかには、次のような流れとなる。
- ①で引数に指定したDate変数の値をメンバ変数calenderにセットする
- ②でStringBufferをインスタンス化する
- ③で出力パターンと日付の合成処理を行って、Stringにする
スレッドが割り込むと・・・
ここで、③-2と③-3の処理の間に、別スレッドが割り込んだらどうなるか?次のようなケースを考えてみる。
- スレッドA:GregorianCalenderの引数に2008/2/22を指定し、formatメソッドを呼び出す
- スレッドB:スレッドAが③-2の処理を終えた直後、GregorianCalenderの引数に1986/3/29を指定し、formatメソッドを呼び出す
スレッド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インスタンスを生成
もともと、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メソッド呼び出しの同期化
不正な日付が生成されたのは、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以外にも、まだまだ危険なクラスが・・・。それは次回で。
Javaの関連記事: