Thread‑Safe Lazy Caching in Java Using ConcurrentHashMap.computeIfAbsent
I've often found myself needing a simple, thread‑safe cache
In many of the services I work on, there is a recurring need to create an object that is expensive to build but cheap to reuse. Think of a parsed configuration object, a compiled regular expression, or a heavyweight client for an external API. The first time a thread asks for the object we want to create it; subsequent calls should return the same instance without the cost of reconstruction. Doing this correctly in a concurrent environment is trickier than it looks.
The classic double‑checked locking pitfall
Before Java 5, developers often wrote something like this:
private volatile Config config;
public Config getConfig() {
if (config == null) { // first check (no lock)
synchronized (this) {
if (config == null) { // second check (with lock)
config = loadConfig();
}
}
}
return config;
}
Even with the volatile keyword, this pattern is fragile on older JVMs and can lead to subtle visibility bugs. Modern Java gives us a far cleaner alternative that is both readable and provably safe.
Enter ConcurrentHashMap.computeIfAbsent
The java.util.concurrent.ConcurrentHashMap class provides an atomic method that does exactly what we need: if a key is absent, it computes the value using a supplied function and puts it into the map; if the key is already present, it simply returns the existing value. The whole operation is performed under the map’s internal locking, guaranteeing that only one thread can compute the value for a given key.
Here is how we can turn the previous example into a thread‑safe lazy cache:
import java.util.concurrent.ConcurrentHashMap;
public class ConfigProvider {
private final ConcurrentHashMap cache = new ConcurrentHashMap<>();
/**
* Returns the configuration for the given environment.
* The configuration is loaded lazily and cached for future calls.
*/
public Config getConfig(String env) {
return cache.computeIfAbsent(env, this::loadConfig);
}
/**
* Simulates an expensive operation – e.g., reading files, parsing, validation.
* In real code this could be a network call or a DB lookup.
*/
private Config loadConfig(String env) {
// Pretend this is costly
try {
Thread.sleep(200);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException(e);
}
return new Config(env); // Config is a simple POJO holding settings
}
}
The lambda this::loadConfig is only invoked when the map does not already contain a value for env. Because computeIfAbsent is atomic, we never end up with two different Config instances for the same key, even under heavy concurrent access.
Why this approach is preferable
- No explicit synchronization – we delegate the threading concerns to a battle‑tested concurrent map.
- Lazy initialization – the expensive
loadConfigmethod runs only on first demand. - Automatic cache eviction options – if you need a bounded cache, you can wrap the map with something like
Caffeineor useConcurrentHashMap’ssize‑based policies. - Read‑after‑write visibility – the map’s internal
volatileguarantees that once a value is stored, all threads see it without additional fences.
Potential pitfalls to keep in mind
Remember that the mapping function must be side‑effect free with respect to the map itself. If you try to modify the same
ConcurrentHashMapinside the function, you could cause a deadlock or undefined behavior.
Also, if the computation can throw a checked exception, you need to wrap it in an unchecked one (as shown with InterruptedException) because the function signature of computeIfAbsent expects an unchecked exception.
Real‑world scenario: parsing JSON schemas
Imagine a service that validates incoming requests against a set of JSON Schema documents. Loading and compiling a schema with a library like everit‑json‑schema can take tens of milliseconds. Each request carries a schemaId header indicating which schema to use. By caching the compiled schema with computeIfAbsent, we guarantee that each schema is compiled exactly once, no matter how many threads hit the endpoint simultaneously.
public class SchemaValidator {
private final ConcurrentHashMap schemaCache = new ConcurrentHashMap<>();
private final JsonSchemaFactory factory = JsonSchemaFactory.byDefault();
public JsonSchema getSchema(String schemaId) {
return schemaCache.computeIfAbsent(schemaId, id -> {
String json = loadSchemaFromDisk(id); // expensive I/O
return factory.getJsonSchema(json);
});
}
private String loadSchemaFromDisk(String id) {
// implementation omitted for brevity
return "{}";
}
}
Under load, the latency distribution becomes far more predictable because the expensive parsing step happens only once per unique schema.
Wrapping up
When you need a lazy, thread‑safe cache in Java, ConcurrentHashMap.computeIfAbsent is often the simplest and most reliable tool. It removes the need for home‑grown double‑checked locking, reduces boilerplate, and gives you a clear, readable intent: "compute this value only if it isn’t already there." Next time you reach for a synchronized block or a custom CacheLoader, consider whether this one‑liner from the concurrent package can do the job just as well — and with far fewer opportunities for subtle bugs.