SimpleDateFormat Thread Safety Trap: The Formatter That Silently Lies Under Load2026-07-03
This logger has been in production for years. Load tests pass, unit tests pass, and single-threaded benchmarks look great. Then one Black Friday it starts stamping log lines with dates from next year — and sometimes throws NumberFormatException from deep inside the JDK.
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.concurrent.*;
public class RequestLogger {
private static final SimpleDateFormat FMT =
new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS");
public static String stamp(Date d) {
return FMT.format(d);
}
public static void log(String msg) {
System.out.println(stamp(new Date()) + " " + msg);
}
public static void main(String[] args) throws Exception {
ExecutorService pool = Executors.newFixedThreadPool(16);
for (int i = 0; i < 100_000; i++) {
final int n = i;
pool.submit(() -> log("request " + n));
}
pool.shutdown();
pool.awaitTermination(1, TimeUnit.MINUTES);
}
}
SimpleDateFormat is not thread-safe, and nothing in its signature warns you. Making it static final feels like the right optimization — parsing the pattern is expensive, so you reuse the instance — but "final" only makes the reference immutable; the object's internals are mutable and unsynchronized.
Internally, SimpleDateFormat extends DateFormat, which holds a Calendar instance. Every call to format() mutates that shared Calendar: it sets the year, month, day, hour, etc., then reads them back to build the string. When two threads interleave inside format(), one thread's writes overwrite another's mid-formatting. The output is a Frankenstein of both moments in time — often subtly wrong (last month's day-of-month glued to this month) rather than obviously garbage.
Even worse: the internal StringBuffer and numeric conversion buffers get scribbled on concurrently. This is how you get bizarre exceptions like NumberFormatException: multiple points thrown from inside Double.parseDouble, called from date formatting code that has no business parsing floats. The stack trace looks impossible.
The trap is that the class works perfectly under any test that doesn't hit it hard from multiple threads at once. You ship it, it runs fine for months, and then one traffic spike produces a stream of impossible timestamps that nobody can reproduce locally.
Since Java 8, use DateTimeFormatter from java.time. It's immutable and thread-safe by design:
import java.time.*;
import java.time.format.DateTimeFormatter;
private static final DateTimeFormatter FMT =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS")
.withZone(ZoneId.systemDefault());
public static String stamp(Instant i) {
return FMT.format(i);
}
If you're stuck on legacy Java 7 code with SimpleDateFormat, either wrap access in synchronized, put the formatter in a ThreadLocal, or construct a fresh one per call and eat the cost. Do not share a single instance across threads and hope for the best.
The same warning applies to NumberFormat, DecimalFormat, and MessageFormat — all inherited from the same era and all quietly non-thread-safe.
final protects the reference, not the object — and SimpleDateFormat's mutable internal Calendar turns any shared instance into a race waiting for production traffic; reach for java.time.DateTimeFormatter instead.
