HHeLiBeXの日記 正道編

日々の記憶の記録とメモ‥

java.text.SimpleDateFormatはスレッドセーフではない

先日購入した「コーディングの掟」を読んでいて、java.text.SimpleDateFormatがスレッドセーフではないことに初めて気がついた。結構長くJavaプログラマやってるんだけどなぁ‥orz
JavaAPIドキュメントを見てみると、ちゃんと書いてある。

Synchronization

Date formats are not synchronized. It is recommended to create separate format instances for each thread. If multiple threads access a format concurrently, it must be synchronized externally.

Java 2 Platform SE v1.4.2

(かなり前からちゃんとドキュメント化されているということを示すためにわざと古いものを引用。)
検証のための以下のようなコードを書いてみた。

import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Date;
import java.util.List;

public class Main {

    private static final DateFormat df = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS");

    private static String fmt(int n, int len) {
        StringBuilder sb = new StringBuilder();
        sb.append(n);
        while (sb.length() < len) {
            sb.insert(0, '0');
        }
        return sb.toString();
    }
    private static class Tester implements Runnable {
        private final DateFormat df2 = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS");
        private int n;
        public Tester(int no) {
            this.n = no;
        }
        public void run() {
            Calendar cal = Calendar.getInstance();
            cal.set(Calendar.YEAR, n);
            cal.set(Calendar.MONTH, (n - 1) % 12);
            cal.set(Calendar.DAY_OF_MONTH, n % 28);
            cal.set(Calendar.HOUR_OF_DAY, n % 24);
            cal.set(Calendar.MINUTE, n % 60);
            cal.set(Calendar.SECOND, n % 60);
            cal.set(Calendar.MILLISECOND, n % 1000);
            Date date = cal.getTime();
            String expected = fmt(cal.get(Calendar.YEAR), 4)
                    + "-" + fmt(cal.get(Calendar.MONTH) + 1, 2)
                    + "-" + fmt(cal.get(Calendar.DAY_OF_MONTH), 2)
                    + " " + fmt(cal.get(Calendar.HOUR_OF_DAY), 2)
                    + ":" + fmt(cal.get(Calendar.MINUTE), 2)
                    + ":" + fmt(cal.get(Calendar.SECOND), 2)
                    + "." + fmt(cal.get(Calendar.MILLISECOND), 3)
                    ;
            for (int i = 0; i < 1; ++i) {
                String s1 = df.format(date);
                String s2 = df2.format(date);
                boolean b1 = s1.equals(expected);
                boolean b2 = s2.equals(expected);
                String result = (b1 ? "O" : "X") + (b2 ? "O" : "X");
                if (!b1 || !b2) {
                    result = "*" + result + "*";
                } else {
                    result = "-" + result + "-";
                }
                System.out.println("[" + Thread.currentThread().getName() + "] " + result + " " + expected + " | " + s1 + " | " + s2 + " |");
            }
        }
        
    }
    public static void main(String[] args) throws InterruptedException {
        System.out.println("[  Thread Name  ]              Expected        |    shared DateFormat    |   separate DateFormat   |");
        List<Thread> threads = new ArrayList<Thread>();
        for (int i = 0; i < 10; ++i) {
            final int n = i + 1;
            Thread t = new Thread(new Tester(n));
            t.setName("TestThread-" + fmt(n, 4));

            t.start();
            threads.add(t);
        }
        for (Thread t : threads) {
            t.join();
        }
    }

}

スレッドセーフなのであれば、実行結果の3つのカラムの値はすべて同じになるはずである。また、スレッド名の番号と同じ数字になるように合わせてあるので、1つの行に0(ゼロ)以外の異なる数字が並ぶことはないはずである。
これを実行すると、タイミングなどにも左右されるが、以下のような結果が出力される。

[  Thread Name  ]              Expected        |    shared DateFormat    |   separate DateFormat   |
[TestThread-0004] -OO- 0004-04-04 04:04:04.004 | 0004-04-04 04:04:04.004 | 0004-04-04 04:04:04.004 |
[TestThread-0001] *XO* 0001-01-01 01:01:01.001 | 0004-04-04 04:04:04.004 | 0001-01-01 01:01:01.001 |
[TestThread-0003] -OO- 0003-03-03 03:03:03.003 | 0003-03-03 03:03:03.003 | 0003-03-03 03:03:03.003 |
[TestThread-0002] -OO- 0002-02-02 02:02:02.002 | 0002-02-02 02:02:02.002 | 0002-02-02 02:02:02.002 |
[TestThread-0005] *XO* 0005-05-05 05:05:05.005 | 0005-07-07 07:07:07.007 | 0005-05-05 05:05:05.005 |
[TestThread-0007] *XO* 0007-07-07 07:07:07.007 | 007-07-07 07:07:07.0007 | 0007-07-07 07:07:07.007 |
[TestThread-0006] -OO- 0006-06-06 06:06:06.006 | 0006-06-06 06:06:06.006 | 0006-06-06 06:06:06.006 |
[TestThread-0008] -OO- 0008-08-08 08:08:08.008 | 0008-08-08 08:08:08.008 | 0008-08-08 08:08:08.008 |
[TestThread-0009] -OO- 0009-09-09 09:09:09.009 | 0009-09-09 09:09:09.009 | 0009-09-09 09:09:09.009 |
[TestThread-0010] -OO- 0010-10-10 10:10:10.010 | 0010-10-10 10:10:10.010 | 0010-10-10 10:10:10.010 |

複数スレッドでSimpleDateFormatオブジェクトを使いまわしている部分の値が、2行目、5行目、6行目で隣と異なっている。また6行目は、年フィールドとミリ秒フィールドの桁数がおかしい。

昔は、スレッドごとに異なるSimpleDateFormatインスタンスを使用しても問題があったようだ(SimpleDateFormat is not thread-safe)が、少なくともJ2SDK 1.4.2_18のソースを確認した限りでは、毎回インスタンスを生成したり、ThreadLocalにスレッドごとのインスタンスを保持して使用するようにすれば問題ないようだ。

やれやれ、仕事の合間を見て持っている知識の総点検をしないと危ないなぁ‥


(2009年3月2日 追記)
じゃあ、問題ないように使いまわすにはどうしたらいいのか、またそれぞれのパターンの時間計測などを行っている方がいたのでリンクさせていただく。
Java日付処理メモ(Hishidama's Java Date Memo)