Article

Correctly using utility classes in multithreaded Java applications

A comparison of thread-safe and non-thread-safe utility classes and how to correctly use them in multithreaded Java applications.

Beginner
Florian Beaufumé
Florian Beaufumé LinkedIn X GitHub
Published 2 Sep 2025 - Last updated 3 Sep 2025 - 5 min read
Correctly using utility classes in multithreaded Java applications

Table of contents

Introduction

ObjectMapper is a thread-safe class from the Jackson library used to marshal beans to and from JSON. SimpleDateFormat is a non-thread-safe class from the Java standard library used to parse and format dates. Both are frequently used in Java applications, but they behave differently in multithreaded Java applications such as Spring Boot.

Incorrect usages of these classes, or any other utility classes, can lead to performance issues or, worse, to incorrect results or exceptions.

In this article, I will explain and illustrate their concurrency differences and show how to correctly use the various utility classes you may encounter.

Thread-safe utility classes

ObjectMapper is a thread-safe utility class, meaning that you can create a single instance and share it across multiple threads without any issues.

You can, and you generally should, do so to improve the response time and decrease the memory usage.

Let's write a basic JMH (Java Microbenchmark Harness) benchmark to compare the performances between using a single shared ObjectMapper instance and creating a new instance for each operation:

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 1, time = 5)
@Measurement(iterations = 1, time = 10)
@Fork(value = 0, warmups = 0)
public class ObjectMapperBenchmark {

private static final String JSON_DATA = "{\"firstName\":\"John\",\"lastName\":\"Doe\",\"email\":\"john.doe@acme.com\",\"age\":42}";

private static final ObjectMapper reusedObjectMapper = new ObjectMapper();

private record Person(String firstName, String lastName, String email, int age) {
}

@Benchmark
public void unmarshalWithReusedMapper(Blackhole blackhole) throws JsonProcessingException {
blackhole.consume(reusedObjectMapper.readValue(JSON_DATA, Person.class));
}

@Benchmark
public void unmarshalWithNewMapper(Blackhole blackhole) throws JsonProcessingException {
ObjectMapper objectMapper = new ObjectMapper();
blackhole.consume(objectMapper.readValue(JSON_DATA, Person.class));
}
}

The benchmark result is:

Benchmark                                        Mode  Cnt      Score   Error  Units
ObjectMapperBenchmark.unmarshalWithNewMapper avgt 12705,609 ns/op
ObjectMapperBenchmark.unmarshalWithReusedMapper avgt 398,337 ns/op

It means that using a single shared ObjectMapper instance is, in this specific case, about 32 times faster than creating a new instance for each operation (12.7 μs versus 0.4 μs). It also consumes less memory. The 12 μs difference may not be noticeable if you rarely use an ObjectMapper but why pay more when it is as easy to do it properly?

Non-thread-safe utility classes

SimpleDateFormat is not a thread-safe utility class, meaning that if you create an instance and share it across multiple threads, you may get incorrect results or even exceptions.

The probability of such problem may seem low, but it is still a risk you should not take.

Here is a simple program that uses a shared SimpleDateFormat instance across multiple threads to format dates and compare the results with a thread-safe alternative, DateTimeFormatter:

public class SimpleDateFormatUsage {

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

private static final DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS").withZone(java.time.ZoneOffset.UTC);

private static final AtomicLong successCount = new AtomicLong(0);
private static final AtomicLong failureCount = new AtomicLong(0);
private static final AtomicLong exceptionCount = new AtomicLong(0);

public static void main(String[] args) {
dateFormat.setTimeZone(java.util.TimeZone.getTimeZone("UTC"));

run(1, 100_000);
run(2, 100_000);
run(10, 100_000);
run(100, 100_000);
run(1000, 100_000);
}

public static void run(int threadCound, int taskCount) {
successCount.set(0);
failureCount.set(0);
exceptionCount.set(0);

try (ExecutorService executor = Executors.newFixedThreadPool(threadCound)) {
for (int i = 0; i < taskCount; i++) {
executor.execute(() -> {
// A random timestamp between EPOCH and nov 20, 2286
long timestamp = ThreadLocalRandom.current().nextLong(10_000_000_000_000L);

// Use the SimpleDateFormat (not thread-safe)
String result1;
try {
result1 = dateFormat.format(new Date(timestamp));
} catch (Exception e) {
//e.printStackTrace();
exceptionCount.incrementAndGet();
return;
}

// Use the DateTimeFormatter (thread-safe)
String result2 = dateTimeFormatter.format(Instant.ofEpochMilli(timestamp));

// Compare the results
if (result1.equals(result2)) {
successCount.incrementAndGet();
} else {
failureCount.incrementAndGet();
}
});
}
}

System.out.printf("For %d threads and %d tasks: %.0f%% success (%d), %.0f%% failure (%d), %.1f%% exception (%d)%n",
threadCound, taskCount, successCount.get() * 100.0 / taskCount, successCount.get(),
failureCount.get() * 100.0 / taskCount, failureCount.get(), exceptionCount.get() * 100.0 / taskCount, exceptionCount.get());
}
}

The output is:

For 1 threads and 100000 tasks: 100% success (100000), 0% failure (0), 0,0% exception (0)
For 2 threads and 100000 tasks: 55% success (55416), 44% failure (44263), 0,3% exception (321)
For 10 threads and 100000 tasks: 22% success (21552), 78% failure (78028), 0,4% exception (420)
For 100 threads and 100000 tasks: 21% success (21000), 79% failure (78602), 0,4% exception (398)
For 1000 threads and 100000 tasks: 26% success (26122), 73% failure (73396), 0,5% exception (482)

For a single thread, the success rate is 100%, as expected.

But as soon as we start using multiple threads, the success rate drops significantly. We even start to get a small percentage of exceptions such as:

java.lang.ArrayIndexOutOfBoundsException: Index 703 out of bounds for length 13
at java.base/sun.util.calendar.BaseCalendar.getCalendarDateFromFixedDate(BaseCalendar.java:453)
at java.base/java.util.GregorianCalendar.computeFields(GregorianCalendar.java:2369)
at java.base/java.util.GregorianCalendar.computeFields(GregorianCalendar.java:2284)
at java.base/java.util.Calendar.setTimeInMillis(Calendar.java:1836)
at java.base/java.util.Calendar.setTime(Calendar.java:1802)
at java.base/java.text.SimpleDateFormat.format(SimpleDateFormat.java:978)
at java.base/java.text.SimpleDateFormat.format(SimpleDateFormat.java:971)
at java.base/java.text.DateFormat.format(DateFormat.java:378)
(...)

How to deal with non-thread-safe utility classes?

So, what should we do when dealing with a non-thread-safe utility class like SimpleDateFormat?

There are several main options:

  • Use a thread-safe alternative
  • Use a new instance for each usage
  • Use a ThreadLocal to store a dedicated instance for each thread
  • Use a pool of instances

The first option is the simplest and most efficient in our case, but is not always possible. If available, use a thread-safe alternative. For SimpleDateFormat, the alternative is DateTimeFormatter.

The second option is very easy to implement. Instead of sharing a single instance, create and use a new instance for each usage:

SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS");
String formattedDate = dateFormat.format(myDate);

It may consume more CPU and/or memory depending on the nature and complexity of the class.

The third option, based on ThreadLocal, is more complex to implement but works fine if you are stuck with a non-thread-safe utility class that is costly to create (in terms of CPU time and/or memory). If you don't know what a ThreadLocal is, it is a Java class that allows you to store an object that is specific to the current thread.

A basic implementation of this option would look like this:

public class DateFormatUtil {

private static final ThreadLocal<SimpleDateFormat> threadLocalDateFormat =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS"));

public static String format(Date date) {
return threadLocalDateFormat.get().format(date);
}
}

Note that a proper implementation will also call remove() on the ThreadLocal when the thread is done using the utility object, to free the resources.

The fourth option relies on a pool. When you need to use a utility object, borrow it from the pool for the duration of the processing. It is also more complex to implement, but works fine with costly non-thread-safe utility classes. The added benefits is that you can reduce the number of instances at the potential cost of extra contention. There are many Java pool libraries out there, so no need to implement your own.

A basic implementation of this option could look like this (the actual implementation depends on the chosen pool library):

public class DateFormatUtil {

private static final SomePool<SimpleDateFormat> pool =
new SomePool(20, () -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS"));

public static String format(Date date) {
return pool.executeWithBorrowedObject(dateFormat -> dateFormat.format(date));
}
}

How do I know if a utility class is thread-safe?

To know if a utility class is thread-safe, check the documentation of the library or the Javadoc of the class. For example, look for "thread-safe" or "immutable" in the class or method description. Both are fine, since immutable means that the object cannot be modified after it is created, which makes it safe to share across multiple threads.

If you are not sure, it is better to assume that the utility class is not thread-safe and use one of the solutions mentioned above to avoid potential issues.

What about virtual threads?

Recent versions of Java introduced virtual threads, which are lightweight threads that can scale to much higher counts (millions instead of thousands). They target highly concurrent applications.

When a virtual thread executes some code, it uses one of the platform threads. This means that the utility objects can still be used concurrently. As a consequence virtual threads do not free you from checking the thread-safety of utility classes and applying the previous recommendations.

Conclusion

I hope this article shed some light on utility classes, and that the next time you use a new one, you will check if it is thread-safe or not, in order to use it properly and efficiently.

© 2007-2025 Florian Beaufumé