Introduction
SimpleDateFormat is not thread-safe in Java. Sharing a single instance across threads without synchronization produces corrupted dates, wrong values, or NumberFormatException. The best fix in modern Java (8+) is to replace SimpleDateFormat with DateTimeFormatter, which is immutable and thread-safe. If you must use SimpleDateFormat, the options are: ThreadLocal<SimpleDateFormat>, explicit synchronized blocks, or creating a new instance per use. ThreadLocal gives the best performance; creating new instances is the simplest.
The Problem
1import java.text.SimpleDateFormat;
2import java.util.Date;
3
4// THIS IS BROKEN — shared mutable state across threads
5public class DateUtil {
6 // SimpleDateFormat is NOT thread-safe
7 private static final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
8
9 public static String format(Date date) {
10 return sdf.format(date); // Race condition: corrupted output
11 }
12
13 public static Date parse(String text) throws Exception {
14 return sdf.parse(text); // Race condition: wrong dates or exceptions
15 }
16}
SimpleDateFormat uses internal mutable fields (calendar, numberFormat) during format() and parse(). When two threads call format() simultaneously, they overwrite each other's intermediate state, producing garbage output like "2025-03-2025" or throwing ArrayIndexOutOfBoundsException.
1import java.time.LocalDate;
2import java.time.LocalDateTime;
3import java.time.format.DateTimeFormatter;
4
5public class DateUtil {
6 // DateTimeFormatter is immutable and thread-safe — share freely
7 private static final DateTimeFormatter FORMATTER =
8 DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
9
10 public static String format(LocalDateTime dateTime) {
11 return dateTime.format(FORMATTER);
12 }
13
14 public static LocalDateTime parse(String text) {
15 return LocalDateTime.parse(text, FORMATTER);
16 }
17
18 public static LocalDate parseDate(String text) {
19 return LocalDate.parse(text, DateTimeFormatter.ISO_LOCAL_DATE);
20 }
21}
22
23// Usage from any thread — no synchronization needed
24String formatted = DateUtil.format(LocalDateTime.now());
25LocalDateTime parsed = DateUtil.parse("2025-03-02 14:30:00");
Solution 2: ThreadLocal
1import java.text.SimpleDateFormat;
2import java.util.Date;
3
4public class DateUtil {
5 private static final ThreadLocal<SimpleDateFormat> SDF =
6 ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
7
8 public static String format(Date date) {
9 return SDF.get().format(date); // Each thread gets its own instance
10 }
11
12 public static Date parse(String text) throws Exception {
13 return SDF.get().parse(text);
14 }
15}
Each thread gets its own SimpleDateFormat instance, eliminating contention. The instance is created lazily on first access and reused for subsequent calls on the same thread.
Solution 3: synchronized Block
1public class DateUtil {
2 private static final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
3
4 public static synchronized String format(Date date) {
5 return sdf.format(date);
6 }
7
8 public static synchronized Date parse(String text) throws Exception {
9 return sdf.parse(text);
10 }
11}
12
13// Or with explicit lock for finer control
14public class DateUtil {
15 private static final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
16 private static final Object lock = new Object();
17
18 public static String format(Date date) {
19 synchronized (lock) {
20 return sdf.format(date);
21 }
22 }
23}
Simple but creates a bottleneck — only one thread can format/parse at a time. Acceptable for low-contention scenarios.
Solution 4: New Instance Per Call
1public class DateUtil {
2 private static final String PATTERN = "yyyy-MM-dd";
3
4 public static String format(Date date) {
5 return new SimpleDateFormat(PATTERN).format(date);
6 }
7
8 public static Date parse(String text) throws Exception {
9 return new SimpleDateFormat(PATTERN).parse(text);
10 }
11}
The simplest approach but creates garbage for the GC on every call. Acceptable if formatting is infrequent.
1// Rough performance ranking (best to worst):
2// 1. DateTimeFormatter: immutable, no synchronization overhead, fastest
3// 2. ThreadLocal: one instance per thread, no contention, reusable
4// 3. New instance per call: GC pressure but no contention
5// 4. synchronized: single point of contention, blocks under load
6
7// For high-throughput applications:
8// - DateTimeFormatter: ~50ns per format
9// - ThreadLocal SDF: ~80ns per format
10// - New SDF per call: ~500ns per format (includes object creation)
11// - Synchronized SDF: depends on contention (unbounded under load)
1import java.time.*;
2import java.time.format.DateTimeFormatter;
3import java.util.Date;
4
5public class DateMigration {
6 // Old: SimpleDateFormat
7 // new SimpleDateFormat("yyyy-MM-dd HH:mm:ss")
8
9 // New: DateTimeFormatter
10 private static final DateTimeFormatter FORMATTER =
11 DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
12
13 // Convert Date to LocalDateTime
14 public static LocalDateTime fromDate(Date date) {
15 return date.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime();
16 }
17
18 // Convert LocalDateTime to Date
19 public static Date toDate(LocalDateTime ldt) {
20 return Date.from(ldt.atZone(ZoneId.systemDefault()).toInstant());
21 }
22
23 // Format with the new API
24 public static String format(Date date) {
25 return fromDate(date).format(FORMATTER);
26 }
27}
Common Pitfalls
Sharing a SimpleDateFormat instance as a static field: This is the root cause. Any static final SimpleDateFormat accessed from multiple threads is a data race. Either replace with DateTimeFormatter, wrap in ThreadLocal, or add synchronization. A single production incident from this bug can produce silently wrong dates in database records.
Using ThreadLocal in thread pools without cleanup: In application servers and thread pools, threads are reused. If ThreadLocal holds a SimpleDateFormat with a modified time zone or locale, subsequent requests on the same thread inherit stale settings. Call ThreadLocal.remove() in a finally block when the scope ends.
Forgetting that DateTimeFormatter.ofPattern is locale-sensitive: DateTimeFormatter.ofPattern("MMM dd") uses the JVM's default locale for month names. On a server with Locale.JAPAN, "Mar" becomes "3月". Use DateTimeFormatter.ofPattern("MMM dd", Locale.US) for consistent English output.
Mixing java.util.Date and java.time APIs carelessly: Converting between Date and LocalDateTime requires a timezone (ZoneId). Date.toInstant() gives UTC, but LocalDateTime has no timezone. Use ZonedDateTime or specify ZoneId.of("UTC") explicitly to avoid off-by-hours bugs.
Synchronized block scope too broad: Synchronizing an entire method that does I/O or computation beyond date formatting creates unnecessary contention. Synchronize only the format()/parse() call, not the surrounding logic.
Summary
SimpleDateFormat is not thread-safe — never share a single instance across threads without protection
Use DateTimeFormatter (Java 8+) as the default replacement — it is immutable and thread-safe
Use ThreadLocal<SimpleDateFormat> if you must stay on the old API for performance with thread safety
synchronized blocks work but create contention under high load
Migrate from java.util.Date + SimpleDateFormat to java.time + DateTimeFormatter for new code