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 loadConfig method runs only on first demand.
  • Automatic cache eviction options – if you need a bounded cache, you can wrap the map with something like Caffeine or use ConcurrentHashMap’s size‑based policies.
  • Read‑after‑write visibility – the map’s internal volatile guarantees 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 ConcurrentHashMap inside 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.