Java 開発において、日付や時刻の処理は頻繁に行われる作業です。SimpleDateFormat クラスはかつて広く利用されてきましたが、マルチスレッド環境下での使用には重大なリスクが存在します。本稿では、なぜこのクラスがスレッドセーフではないのか、その根本原因を解明し、高并发環境における適切な対策方法を解説します。
スレッド安全性の問題を再現する
単一スレッドでの利用においては問題が発生しにくいものの、複数のスレッドが同時に同一インスタンスにアクセスすると、予期せぬ例外が発生します。これを検証するために、スレッドプールと同期補助クラスを用いたテストコードを作成します。
以下の例では、共有されたSimpleDateFormatインスタンスに対して多数のスレッドから同時にparseメソッドを呼び出します。
package com.tech.example.concurrent;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;
public class DateFormatThreadSafetyTest {
private static final int TOTAL_ITERATIONS = 1000;
private static final int CONCURRENT_THREADS = 20;
private static final SimpleDateFormat sharedFormatter = new SimpleDateFormat("yyyy-MM-dd");
public static void main(String[] args) throws InterruptedException {
Semaphore semaphore = new Semaphore(CONCURRENT_THREADS);
CountDownLatch latch = new CountDownLatch(TOTAL_ITERATIONS);
ExecutorService pool = Executors.newCachedThreadPool();
for (int i = 0; i < TOTAL_ITERATIONS; i++) {
pool.execute(() -> {
try {
semaphore.acquire();
try {
sharedFormatter.parse("1990-05-15");
} catch (ParseException | NumberFormatException e) {
System.err.println("エラー発生: " + Thread.currentThread().getName());
e.printStackTrace();
System.exit(1);
}
semaphore.release();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
latch.countDown();
}
});
}
latch.await();
pool.shutdown();
System.out.println("処理完了");
}
}
このコードを実行すると、環境によっては以下のような例外が投げられ、プログラムが異常終了します。
Exception in thread "pool-1-thread-4" java.lang.ArrayIndexOutOfBoundsException
...
at java.text.DigitList.getLong(DigitList.java:191)
at java.text.DecimalFormat.parse(DecimalFormat.java:2084)
...
java.lang.NumberFormatException: For input string: ""
...
この結果は、SimpleDateFormat がマルチスレッド環境で安全に動作しないことを明確に示しています。
スレッド不安全となる根本原因
問題の核心は、SimpleDateFormat の親クラスであるDateFormat が内部でCalendarオブジェクトを保持していることにあります。このCalendarフィールドはフォーマットおよびパース処理共有して使用されます。
ソースコードを確認すると、parseメソッド内ではCalendarオブジェクトに対してclear()やset()といった状態変更操作が行われています。これらの操作は原子性を持たず、複数のスレッドが同時に介入すると内部状態が破損し、不正な値の参照や配列境界外アクセスが発生します。formatメソッドにおいても同様の競合状態が発生し得ます。
つまり、共有されたCalendarインスタンスに対する排他制御がない状態が、スレッド安全性を損なう主要原因です。
対策方法の検討
高并发環境でこの問題に対処するには、いくつかのアプローチが考えられます。それぞれの特徴と実装例を示します。
1. ローカル変数としての利用
最も単純な方法は、メソッド内ごとに新しいインスタンスを生成することです。これにより、スレッド間で状態を共有しないため、安全性が保証されます。
package com.tech.example.concurrent;
import java.text.SimpleDateFormat;
import java.util.concurrent.*;
public class LocalInstanceApproach {
private static final int TOTAL_ITERATIONS = 1000;
private static final int CONCURRENT_THREADS = 20;
public static void main(String[] args) throws InterruptedException {
Semaphore semaphore = new Semaphore(CONCURRENT_THREADS);
CountDownLatch latch = new CountDownLatch(TOTAL_ITERATIONS);
ExecutorService pool = Executors.newCachedThreadPool();
for (int i = 0; i < TOTAL_ITERATIONS; i++) {
pool.execute(() -> {
try {
semaphore.acquire();
// メソッド内でインスタンスを生成
SimpleDateFormat formatter = new SimpleDateFormat("yyyy-MM-dd");
formatter.parse("1990-05-15");
semaphore.release();
} catch (Exception e) {
e.printStackTrace();
} finally {
latch.countDown();
}
});
}
latch.await();
pool.shutdown();
System.out.println("処理完了");
}
}
この方式は安全ですが、高頻度で呼び出される場合、オブジェクト生成のオーバーヘッドによりパフォーマンスが低下する可能性があります。
2. synchronized による排他制御
共有インスタンスを使用しつつ、アクセス部分を同步ブロックで囲む方法です。
package com.tech.example.concurrent;
import java.text.SimpleDateFormat;
import java.util.concurrent.*;
public class SynchronizedBlockApproach {
private static final int TOTAL_ITERATIONS = 1000;
private static final int CONCURRENT_THREADS = 20;
private static final SimpleDateFormat sharedFormatter = new SimpleDateFormat("yyyy-MM-dd");
public static void main(String[] args) throws InterruptedException {
Semaphore semaphore = new Semaphore(CONCURRENT_THREADS);
CountDownLatch latch = new CountDownLatch(TOTAL_ITERATIONS);
ExecutorService pool = Executors.newCachedThreadPool();
for (int i = 0; i < TOTAL_ITERATIONS; i++) {
pool.execute(() -> {
try {
semaphore.acquire();
synchronized (sharedFormatter) {
sharedFormatter.parse("1990-05-15");
}
semaphore.release();
} catch (Exception e) {
e.printStackTrace();
} finally {
latch.countDown();
}
});
}
latch.await();
pool.shutdown();
System.out.println("処理完了");
}
}
これにより競合は避けられますが、同時実行性が失われ、処理性能が大幅に低下する懸念があります。
3. Lock インターフェースの利用
ReentrantLock などの明示的なロックを使用する方法です。動作原理はsynchronized と同様ですが、より柔軟な制御が可能です。
package com.tech.example.concurrent;
import java.text.SimpleDateFormat;
import java.util.concurrent.*;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class ExplicitLockApproach {
private static final int TOTAL_ITERATIONS = 1000;
private static final int CONCURRENT_THREADS = 20;
private static final SimpleDateFormat sharedFormatter = new SimpleDateFormat("yyyy-MM-dd");
private static final Lock lock = new ReentrantLock();
public static void main(String[] args) throws InterruptedException {
Semaphore semaphore = new Semaphore(CONCURRENT_THREADS);
CountDownLatch latch = new CountDownLatch(TOTAL_ITERATIONS);
ExecutorService pool = Executors.newCachedThreadPool();
for (int i = 0; i < TOTAL_ITERATIONS; i++) {
pool.execute(() -> {
lock.lock();
try {
semaphore.acquire();
sharedFormatter.parse("1990-05-15");
semaphore.release();
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlock();
latch.countDown();
}
});
}
latch.await();
pool.shutdown();
System.out.println("処理完了");
}
}
例外発生時でもロックが解放されるよう、finally ブロックでの解放処理が必須となります。性能面では同期化と同様の制約があります。
4. ThreadLocal によるスレッド毎の管理
各スレッドに固有のインスタンスを割り当てるThreadLocalを使用する方法です。これが最も推奨される対策の一つです。
package com.tech.example.concurrent;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.concurrent.*;
public class ThreadLocalStorageApproach {
private static final int TOTAL_ITERATIONS = 1000;
private static final int CONCURRENT_THREADS = 20;
private static final ThreadLocal<DateFormat> dateFormatter = new ThreadLocal<>() {
@Override
protected DateFormat initialValue() {
return new SimpleDateFormat("yyyy-MM-dd");
}
};
public static void main(String[] args) throws InterruptedException {
Semaphore semaphore = new Semaphore(CONCURRENT_THREADS);
CountDownLatch latch = new CountDownLatch(TOTAL_ITERATIONS);
ExecutorService pool = Executors.newCachedThreadPool();
for (int i = 0; i < TOTAL_ITERATIONS; i++) {
pool.execute(() -> {
try {
semaphore.acquire();
dateFormatter.get().parse("1990-05-15");
semaphore.release();
} catch (Exception e) {
e.printStackTrace();
} finally {
latch.countDown();
}
});
}
latch.await();
pool.shutdown();
System.out.println("処理完了");
}
}
この実装では、スレッドごとに独立したSimpleDateFormatインスタンスが保持されるため、ロックオーバーヘッド 없이 スレッド安全性を確保できます。