Why computeIfAbsent Keeps Reloading a Missing Java Value
A Java backend can look as though its cache is “missing” the same key over and over when the real problem is more precise: no negative mapping was ever installed. With Map.computeIfAbsent, a mapping function that returns null produces a null result but does not establish a new non-null mapping. On a HashMap, an existing key whose value is already null also qualifies for computation. Those are API semantics, not evidence of a broken cache implementation. Oracle documents both behaviors in the Java SE 21 Map contract.
This playbook isolates that contract in a local, single-process fixture. The loader returns a String for presence, null only for authoritative absence, and throws for a transient source failure. Per-key call counts, containsKey, payload state, and Optional presence form the evidence ledger. The printed driver was independently compiled and executed on October 17, 2026, using OpenJDK 21.0.12.1, vendor Debian. All nine diagnostic cases passed. This identifies the measured reference runtime, not the newest JDK.
The operational objective is not to maximize a “hit rate.” It is to preserve four meanings: not loaded, present, authoritatively absent, and failed to load. The map and the loader’s completion outcome must be read together; the map alone does not retain a history of failed attempts. Once those meanings are explicit, repeated loader calls become diagnosable, negative caching becomes testable, and invalidation becomes an owned correctness action rather than an accidental side effect.
Diagnose the repeated load rather than a low hit rate
Start with the smallest failure signature: call computeIfAbsent("M", loader) twice on an empty HashMap, and let the loader return null both times. The important observation is not merely that both calls return null. The important evidence is that the loader runs twice and containsKey("M") remains false after both calls. Under the Java SE 21 Map contract, when the mapping function returns null, no mapping is recorded; a subsequent invocation therefore has no stored non-null value to reuse.
That distinction matters operationally because “returned null” and “cached an absence” are different claims. A method named computeIfAbsent does not promise that every completed computation becomes retained map state. The return-value contract decides whether a mapping can be installed. The overview of general backend caching and performance practices discusses avoiding repeated work, but that broad advice does not answer whether this particular Java call stored anything. The local map state and loader count do.
Do not begin by inventing a hit-rate percentage, measuring latency, or blaming synchronization. None of those observations is required to explain the sequential reproduction. Instead, build a semantic ledger for one key: how many times did the source loader run, does the map contain the key, what value did get return, and, when using an explicit wrapper, does that wrapper encode presence or authoritative absence? This is enough to separate a cache that has a negative entry from a map that simply returned null and stayed unmapped.
The acceptance question is therefore narrow: after an authoritative not-found result, did the application install a non-null representation of that fact, and is the application entitled to keep serving that representation? If not, repeated calls are expected behavior under the method contract, not evidence that Java “forgot” a cached miss.
For incident triage, that changes the first questions to ask. Capture one affected key, record whether its loader completed normally or threw, inspect membership immediately after the call, and repeat the lookup under the same controlled state. If the second lookup crosses the loader boundary again and the first computation returned null, you already have a contract-level explanation worth testing before escalating to JVM internals, race conditions, or performance tooling. The method is doing what its return-value rule permits; the design question is whether the application wanted a stored negative state.
Pin Java and declare the loader contract
The supplied preparation notes report compilation with --release 21 and execution on OpenJDK runtime 21.0.12.1+1-1-deb13u1-Debian, vendor Debian. The independent review run recorded that same environment: java -version reported OpenJDK 21.0.12.1, javac -version reported 21.0.12.1, and Runtime.version() printed 21.0.12.1+1-1-deb13u1-Debian. The printed driver reproduced the nine diagnostic outcomes reported in preparation. These environment observations are not Java release-status claims.
The recorded version outputs, followed by the driver’s two environment lines, were:
openjdk version "21.0.12.1" 2026-08-18
OpenJDK Runtime Environment (build 21.0.12.1+1-1-deb13u1-Debian)
OpenJDK 64-Bit Server VM (build 21.0.12.1+1-1-deb13u1-Debian, mixed mode, sharing)
javac 21.0.12.1
runtime=21.0.12.1+1-1-deb13u1-Debian
vendor=Debian
The fixture deliberately stays inside one JVM and one process. The source of truth is another local map, not a database or remote service. HashMap permits null values and is unsynchronized, which is why the core cases run on one thread. Oracle’s Java SE 21 HashMap documentation explicitly states that it permits null values and is unsynchronized. The ConcurrentHashMap comparison later is also sequential; it is a contract control, not a concurrency benchmark.
The loader contract is equally deliberate. For this owned fixture only, load(key) returns a non-null String when the source contains a label, returns null only when the source authoritatively says the key is absent, and throws IllegalStateException for an injected transient failure. That contract is what makes Optional.ofNullable(load(key)) meaningful. In a production integration where a timeout, permission error, transport failure, and not-found can all surface as null, wrapping that value in Optional would preserve ambiguity rather than repair it.
Establish service-boundary meanings before introducing a cache. The roadmap on backend service and persistence foundations provides broader context for connecting backend logic to a data layer. Here, however, the source is only an owned local map. A cache should preserve that source contract, not rewrite unavailable into nonexistent. The acceptance evidence comes from the Java contracts and the executed fixture, not from a general backend roadmap.
Separate not loaded, absent, present and failed
For this problem, four meanings must remain distinguishable. Not loaded describes the initial state before a source attempt; after invalidation, uncached is the more precise description. Present means the source returned a String, including the empty string "". Authoritatively absent means the source completed normally and returned null under the declared fixture contract. Failed means the source did not produce a business answer because it threw. In the Optional cache, both an untouched key and a key whose load failed have no entry; the exception and per-key counter distinguish their histories.
A raw Map<String,String> can represent present strings and, in a HashMap, an explicit null, but computeIfAbsent treats a null mapping as eligible for recomputation. A Map<String,Optional<String>> can instead use non-null Optional instances so that Optional.empty() is a stored negative entry and Optional.of(value) is a stored presence. Oracle documents Optional.ofNullable as returning a present Optional for non-null input and an empty Optional for null input. That representation solves only the state-encoding problem. It does not create expiry, refresh, capacity bounds, or invalidation ownership.
The state model gives reviewers a vocabulary without pretending the map stores four different variants. A successful load creates a present or absent result. A failed attempt remains visible through exceptional completion and the call ledger, not through Optional.empty(). A later explicit lookup can retry, and owned invalidation removes either kind of cached result. The following table describes the Optional-based policy; J1 and J3 deliberately test raw nullable alternatives.
Meaning | Observable evidence | Optional-cache state |
Not loaded / uncached | Initially zero calls; after invalidation, prior calls may exist. No current source result is retained. | No entry. This state alone does not explain why the key is uncached. |
Present | Normal completion returns a non-null String, including "". | A non-null, present Optional contains the payload. |
Authoritatively absent | Normal completion returns null under the owned loader contract. | A non-null Optional.empty() is retained. |
Failed to load | An injected source failure is thrown after its call count increases. A later explicit lookup may retry. | No computed entry. The failure is propagated, not stored as absence. |
Build a counted loader and an independent ledger
The fixture below is intentionally plain Java 21: one class, no external dependencies, no network operations, no assertion flag requirement, and a fresh loader/map for each case. For each non-null fixture key, the loader increments a per-key count before returning or raising the injected failure. That count independently records whether computeIfAbsent reached the source. containsKey answers whether the map has an entry, get shows the stored value where meaningful, and Optional.isPresent() or isEmpty() determines explicit semantic state. The checks throw AssertionError directly, so the run does not depend on -ea.
The source map, call counter, and failOnce set belong to the loader and are distinct from cache c. The mapping callback does not modify c itself. That matters because the Map mapping-function rules say the callback should not modify that same map during computation. The test’s counter increments are observations on a separate object, not recursive cache updates.
Save the complete source as NegativeCacheLab.java in a fresh, owned working directory. Record the runtime and compiler, then compile successfully before executing. These commands retain the environment evidence rather than leaving it only in terminal scrollback:
java -version > java-version.txt 2>&1
javac -version > javac-version.txt 2>&1
javac --release 21 NegativeCacheLab.java &&
java NegativeCacheLab > negative-cache-evidence.txt
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
public class NegativeCacheLab {
static final class Loader {
final Map<String, String> source = new HashMap<>();
final Map<String, Integer> calls = new HashMap<>();
final Set<String> failOnce = new HashSet<>();
Loader() {
source.put("P", "present");
source.put("E", "recovered");
source.put("Z", "");
}
String load(String key) {
Objects.requireNonNull(key);
calls.merge(key, 1, Integer::sum);
if (failOnce.remove(key)) {
throw new IllegalStateException("inert failure");
}
return source.get(key);
}
int count(String key) {
return calls.getOrDefault(key, 0);
}
}
static void check(boolean ok, String why) {
if (!ok) throw new AssertionError(why);
}
static void row(String id, int calls, boolean contains, Object value) {
System.out.printf("%s|calls=%d|contains=%s|value=%s%n",
id, calls, contains, value);
}
public static void main(String[] args) {
System.out.println("runtime=" + Runtime.version());
System.out.println("vendor=" + System.getProperty("java.vendor"));
{
Loader l = new Loader();
Map<String, String> c = new HashMap<>();
for (int i = 0; i < 2; i++) {
check(c.computeIfAbsent("M", l::load) == null, "raw miss");
}
check(l.count("M") == 2 && !c.containsKey("M"),
"null not cached");
row("J1 raw missing", l.count("M"),
c.containsKey("M"), c.get("M"));
}
{
Loader l = new Loader();
Map<String, String> c = new HashMap<>();
for (int i = 0; i < 2; i++) {
check("present".equals(c.computeIfAbsent("P", l::load)),
"present");
}
check(l.count("P") == 1 && c.containsKey("P"),
"positive cached");
row("J2 raw present", l.count("P"),
c.containsKey("P"), c.get("P"));
}
{
Loader l = new Loader();
Map<String, String> c = new HashMap<>();
c.put("M", null);
for (int i = 0; i < 2; i++) {
c.computeIfAbsent("M", l::load);
}
check(
l.count("M") == 2
&& c.containsKey("M")
&& c.get("M") == null,
"existing null"
);
row("J3 explicit null", l.count("M"),
c.containsKey("M"), c.get("M"));
}
{
Loader l = new Loader();
Map<String, Optional<String>> c = new HashMap<>();
for (int i = 0; i < 2; i++) {
check(
c.computeIfAbsent("M", k -> Optional.ofNullable(l.load(k)))
.isEmpty(),
"negative"
);
}
check(l.count("M") == 1 && c.containsKey("M"),
"negative cached");
row("J4 explicit negative", l.count("M"),
c.containsKey("M"), c.get("M"));
}
{
Loader l = new Loader();
l.failOnce.add("E");
Map<String, Optional<String>> c = new HashMap<>();
try {
c.computeIfAbsent("E", k -> Optional.ofNullable(l.load(k)));
throw new AssertionError("failure was swallowed");
} catch (IllegalStateException expected) {
check(!c.containsKey("E"), "failure not absence");
}
check(
c.computeIfAbsent("E", k -> Optional.ofNullable(l.load(k)))
.orElseThrow().equals("recovered"),
"recovered"
);
check(l.count("E") == 2 && c.containsKey("E"),
"retry called loader");
row("J5 retry after error", l.count("E"),
c.containsKey("E"), c.get("E"));
}
{
Loader l = new Loader();
l.failOnce.add("E");
Map<String, Optional<String>> c = new HashMap<>();
for (int i = 0; i < 2; i++) {
c.computeIfAbsent("E", k -> {
try {
return Optional.ofNullable(l.load(k));
} catch (IllegalStateException wrongPolicy) {
// J6 is a rejected policy, not a repair.
return Optional.empty();
}
});
}
check(
l.count("E") == 1 && c.containsKey("E")
&& c.get("E").isEmpty(),
"poisoned negative control"
);
row("J6 wrong error-to-absence policy", l.count("E"),
c.containsKey("E"), c.get("E"));
}
{
Loader l = new Loader();
Map<String, Optional<String>> c = new HashMap<>();
c.computeIfAbsent("M", k -> Optional.ofNullable(l.load(k)));
l.source.put("M", "now-present");
check(c.computeIfAbsent("M", k -> Optional.ofNullable(l.load(k))).isEmpty(),
"stale negative visible");
check(l.count("M") == 1,
"no refresh implied");
c.remove("M");
check(!c.containsKey("M"), "owned key removed");
check(
c.computeIfAbsent("M", k -> Optional.ofNullable(l.load(k)))
.orElseThrow().equals("now-present"),
"invalidation"
);
check(l.count("M") == 2 && c.containsKey("M"),
"explicit reload");
row("J7 invalidate then reload", l.count("M"),
c.containsKey("M"), c.get("M"));
}
{
Loader l = new Loader();
Map<String, String> c = new ConcurrentHashMap<>();
for (int i = 0; i < 2; i++) {
c.computeIfAbsent("M", l::load);
}
check(l.count("M") == 2 && !c.containsKey("M")
"atomicity is not negative caching");
row("J8 concurrent map sequential misses", l.count("M"),
c.containsKey("M"), c.get("M"));
}
{
Loader l = new Loader();
Map<String, Optional<String>> c = new HashMap<>();
for (int i = 0; i < 2; i++) {
check(
c.computeIfAbsent("Z", k -> Optional.ofNullable(l.load(k)))
.orElseThrow().isEmpty(),
"empty text is a present value"
);
}
check(
l.count("Z") == 1 && c.containsKey("Z")
&& c.get("Z").isPresent()
&& c.get("Z").orElseThrow().length() == 0,
"empty is not absent"
);
row("J9 empty string is present", l.count("Z"),
c.containsKey("Z"), c.get("Z"));
}
System.out.println("ALL 9 CASES PASSED");
}
}The preparation ledger reported that all nine cases passed. The independent execution of the driver printed here produced the following output, matching those reported call counts and mapping states:
runtime=21.0.12.1+1-1-deb13u1-Debian
vendor=Debian
J1 raw missing|calls=2|contains=false|value=null
J2 raw present|calls=1|contains=true|value=present
J3 explicit null|calls=2|contains=true|value=null
J4 explicit negative|calls=1|contains=true|value=Optional.empty
J5 retry after error|calls=2|contains=true|value=Optional[recovered]
J6 wrong error-to-absence policy|calls=1|contains=true|value=Optional.empty
J7 invalidate then reload|calls=2|contains=true|value=Optional[now-present]
J8 concurrent map sequential misses|calls=2|contains=false|value=null
J9 empty string is present|calls=1|contains=true|value=Optional[]
ALL 9 CASES PASSED
Keep four evidence classes separate. The Java API references establish documented behavior. The preparation ledger is supplied prior evidence. The output above is an independently reproduced observation from the printed driver. Predictions for other configurations remain expectations until executed. None of these classes should be silently promoted into evidence for untested JDKs, custom Map implementations, parallel schedules, or remote loaders.
The output file is small enough to inspect directly. Reconcile each assertion with its loader count and final mapping state: did the source run, and does a reusable entry exist? This is a semantic experiment, not a benchmark. Latency, allocation, lock contention, and throughput are separate questions; the diagnostic ledger measures none of them.
Prove that a null computation is not a cached miss
J1 is the central case. The source does not contain M, so the loader returns null under the fixture’s authoritative-absence contract. The code invokes computeIfAbsent("M", l::load) twice. The first invocation calls the loader, receives null, and returns null. Because the computed value is null, no new mapping is recorded. The second invocation therefore sees the key as still unmapped and calls the loader again. The new run recorded calls=2, contains=false, and value=null.
The Java SE 21 Map.computeIfAbsent specification supports that explanation: computation is attempted when the key has no value or is mapped to null, but a null computation does not install a new value. An unchecked mapping exception also propagates without establishing the computed mapping. This is not a promise to erase a pre-existing null entry, as J3 demonstrates. The word “absent” describes the condition for attempting computation, not an automatic promise to cache a negative result.
J2 supplies the positive control. Key P exists in the local source with value "present". The first call invokes the loader, receives a non-null String, and installs it in the HashMap. The second call finds the existing non-null mapping and does not invoke the loader. The observed ledger is calls=1, contains=true, value=present. That contrast removes several misleading explanations at once: the counter works, the same computeIfAbsent call can reuse a positive mapping, and the repeated J1 load is explained by the null return-value rule.
The practical diagnostic is to inspect post-call state, not just return values. A nullable loader makes two different histories look the same to a caller that records only the return: “the source authoritatively said absent and nothing was stored” and “the cache contains a null-like state.” The map membership and subsequent loader count distinguish them.
A useful review trick is to read the call as two possible phases: “should computation run?” and “did computation produce something storable?” The first phase can be true because the current value is missing or null. The second phase can still fail to install state because the mapping result is null or the function throws. Keeping those phases separate prevents a common reasoning error in which engineers assume that entering the mapping function necessarily means an entry will exist when the method returns.
Read the return value beside map membership
For a raw HashMap<String,String>, record at least three facts during diagnosis: the method result, containsKey(key), and the loader count. A null method result says the call did not produce a non-null value. It does not establish that an absence marker exists. containsKey=false after J1 confirms that no entry was installed. The second source call then confirms that there was no reusable non-null cache state.
This is also why a synthetic “cache hit rate” would be an unsafe acceptance oracle here. You could define a metric that labels the second Java method invocation a “hit” because the caller received the same semantic absence, or a “miss” because the loader ran again. Neither label proves the mapping contract. The acceptance evidence should instead state what was stored and how many times the source boundary was crossed.
Test an existing key mapped to null
J3 addresses a subtler misconception: pre-populating the HashMap with c.put("M", null) does not convert null into a negative-cache entry for computeIfAbsent. Before either computation, containsKey("M") is true. Yet HashMap.get("M") returns null, and computeIfAbsent treats a key mapped to null as eligible for computation. Oracle’s HashMap documentation explicitly notes both that null values are permitted and that get returning null cannot by itself distinguish “no mapping” from “mapped to null”; containsKey is the differentiator for that question.
The loader still returns null for M, so each of the two J3 calls computes again. Because a null result is not entered as a new non-null mapping, the pre-existing M -> null entry remains. The reproduced ledger therefore looks initially paradoxical: calls=2, contains=true, value=null. It is not contradictory. containsKey proves the explicit null mapping exists; the two loader calls prove that this mapping does not satisfy computeIfAbsent’s non-null-value condition.
This is why “just insert null for not found” is not the repair. It may make membership queries distinguishable from never-loaded state, but it does not stop computeIfAbsent from invoking the loader again. Worse, downstream code that uses only get still cannot tell explicit null from no mapping without an additional membership test.
Compare this with cached coroutine lifecycle as a separate failure mode. In that Python workflow, the problem is reusing a cached coroutine after it has been awaited; Python’s coroutine object contract forbids another await of the same coroutine. J3 instead retains a null-valued entry that computeIfAbsent treats as eligible for computation. The common diagnostic habit is to inspect what was stored. The stored objects and the reasons they fail the reuse contract are different.
Store authoritative absence as a non-null value
J4 changes the representation rather than changing the source contract. The cache becomes Map<String, Optional<String>>, and the mapping function returns Optional.ofNullable(l.load(k)). Under this fixture’s explicit rule (non-null string means present, null means authoritative absence, exception means failure), the wrapper gives computeIfAbsent a non-null object even when the source is absent. Oracle documents Optional.ofNullable as producing an empty Optional for null input and a present Optional otherwise.
That single representation change alters the ledger. On the first lookup of M, the loader runs once and returns null; Optional.ofNullable converts that business absence to Optional.empty(). The Optional object is non-null, so computeIfAbsent installs it. On the second lookup, the map already has a non-null value for M, so the mapping function does not run. The reproduced state is calls=1, contains=true, with an empty Optional stored.
For this small contract, Map<String,Optional<String>> is therefore one explicit negative-cache representation. It is not a universal Java cache design. Optional does not specify expiration time, refresh scheduling, maximum entry count, source-change notification, distributed invalidation, or retry policy. Those concerns remain separate ownership decisions. The only claim established here is that a non-null wrapper can encode authoritative absence in a way that computeIfAbsent can retain as a normal mapping.
This is also where production review must challenge the loader contract. If a real integration returns null for “record not found” and for “database connection unavailable,” Optional.ofNullable would make both look like the same retained negative. The wrapper preserves whatever meaning the loader supplied; it cannot recover distinctions the loader erased.
That is why the repair should be reviewed at the loader boundary and not only at the collection type. A migration from Map<K,V> to Map<K,Optional<V>> can reduce repeated reads while simultaneously making wrong absence decisions last longer. The call count may improve, yet correctness may regress. Acceptance therefore needs both dimensions: fewer calls only when the stored semantic state is justified by the source outcome.
Keep Optional presence independent of payload content
Use isEmpty() and isPresent() to test the wrapper’s semantic state. Oracle specifically recommends presence tests rather than identity comparisons with Optional.empty(), because empty optionals are value objects and singleton identity is not guaranteed. For a present value, inspect the payload separately.
J9 is the guardrail: source key Z maps to the empty string "". Optional.ofNullable("") is present because the payload is non-null. The loop checks that the returned string is empty, and the final assertion separately checks Optional presence and payload length() == 0. The printed representation in this run is Optional[], which can be misread. Optional.toString() is diagnostic text, not the acceptance oracle. Presence plus zero payload length proves that empty text is a present value, not authoritative absence.
Propagate loader failure and allow a genuine recovery attempt
J5 injects one transient failure for key E. The source already contains E -> "recovered", but failOnce causes the first loader call to throw IllegalStateException before returning the source value. The mapping lambda does not catch that exception. Under the Map.computeIfAbsent contract, an unchecked exception from the mapping function is rethrown and the computed mapping is left unestablished. The test immediately verifies !c.containsKey("E") after catching the expected exception.
The next call is therefore a real retry, not a cache read. failOnce has been consumed, the loader reaches the local source, returns "recovered", Optional.ofNullable creates a present wrapper, and the map stores it. The reproduced evidence is two loader calls total and a final present mapping. This is the desired distinction: a failed load does not become an authoritative absence merely because both paths might otherwise have used a nullable return.
Notice what is not rolled back: the failed attempt increments the call counter and consumes the failOnce marker. Those loader-side changes are why the second attempt can succeed. “No computed entry” describes cache membership, not a transaction that reverses arbitrary work inside the loader. Keep that distinction when adapting the example to a source with side effects.
This pattern defines a recoverable service boundary. Business absence is a normal result; transient failure is an exceptional completion. The cache records the business result only when the loader actually produced one. A later lookup is then allowed to observe recovery. IllegalStateException models a one-time failure in this owned test; its class alone does not identify a retryable production condition. Which failures deserve another attempt, and when, depends on the source contract and surrounding service policy.
Avoid broad catch (RuntimeException) blocks whose only purpose is to make cache population “succeed.” Such a block would erase distinctions between a deliberately modeled not-found result, programming defects, source outages, and other failures. The cache layer should not manufacture business knowledge that the loader never obtained.
In production code, that usually means keeping the adapter that talks to the source responsible for translating source-specific outcomes into the service contract. Once the adapter has said “authoritatively absent,” the local cache may store that decision. If the adapter instead reports a failure, the cache should not reinterpret it merely to avoid another call. This division of responsibility keeps recovery possible and makes fault policy reviewable independently from collection mechanics.
Expose the poisoned-negative counterexample
J6 is intentionally wrong and should remain in the lab because it demonstrates how a cache can look efficient while serving the wrong meaning. The setup is the same one-time failure for E, but the mapping lambda catches IllegalStateException and returns Optional.empty(). That converts “the source failed before answering” into “the source authoritatively says absent.” Because Optional.empty() is non-null, computeIfAbsent stores it.
The second lookup never reaches the loader. The map already contains a non-null Optional, so computeIfAbsent returns that cached empty wrapper. The call ledger is only one. If someone measured only “source calls avoided,” J6 could look better than J5. Semantically it is worse: the test source was ready to return "recovered" on the next attempt, but the poisoned negative entry suppressed that recovery path.
The diagnostic assertion passes because the fixture is proving that the bad policy behaves exactly as expected: one failed call becomes an empty cached value. Passing the test does not mean the policy is accepted. This distinction between reproducing a defect and approving behavior is important in operational playbooks. A negative control should be stable enough to demonstrate the failure signature.
When reviewing a real loader, ask what evidence supports an absence decision. In this fixture, the local source lacks M and contains only non-null labels, so a normal null result is authoritative under its declared rules. An adapter for a different source needs equally explicit outcome classification. A timeout, broken connection, authentication failure, or unclassified exception does not become absence merely because the cache would prefer a value.
Do not cache an outage as a missing business object
The minimum precondition for negative caching is an explicit source contract that distinguishes “not found” from “could not determine.” If the loader currently uses null for both, the correct action is HOLD, not “wrap it in Optional.” Repair the loader interface first, perhaps by throwing on failure or returning an explicit result type whose cases cannot be confused, then rerun the cache acceptance cases.
That boundary is more important than the wrapper type. Optional.empty() meaningfully represents a negative result only when empty means authoritative absence. Serving it later also requires a freshness decision. It is not an outage classifier, a retry mechanism, or a policy engine. Treating every exception as empty can suppress a recovery attempt for as long as that entry remains installed and the lookup continues to reuse it.
Invalidate a negative entry after the source changes
J7 demonstrates the other cost of successfully caching an absence: it can become stale. First, the cache loads M from a source where M is absent, storing Optional.empty(). The fixture then mutates the underlying source with l.source.put("M", "now-present"). A second computeIfAbsent still returns the cached empty wrapper and does not call the loader, so the source call count remains one. That is correct behavior for the map state; the cache has no reason to know that its source changed.
The fixture then performs an owned, key-specific invalidation with c.remove("M"). The next lookup is genuinely absent from the cache, so it runs the loader, sees "now-present", and stores a present Optional. The final call count is two. This establishes the narrow acceptance rule for the example: when the application knows the source for an owned key changed, explicit invalidation permits a reload that observes the new source value.
Do not mislabel this as automatic expiry. Optional supplies no clock, age, refresh callback, or staleness check. A bare HashMap likewise supplies no cache TTL. If the source can change without an event that the cache owner can observe, this fixture does not provide a freshness solution. That is a separate design problem and is intentionally outside scope.
For HTTP cache revalidation as a different freshness contract, the boundaries are different. RFC 9111’s validation rules describe conditional requests and the reuse of stored HTTP responses. The local Java map in this playbook has no transport, validators, or protocol-defined freshness policy. Do not borrow a conclusion about HTTP response reuse to justify leaving a local negative entry installed after its source changes.
Operationally, invalidation should require two facts: the source change is known, and the caller owns the cache key being removed. Blindly deleting unrelated keys after ambiguous failures is not a freshness policy. When either fact is missing, preserve the current evidence and escalate ownership rather than pretending a reload is necessarily safer.
Separate atomic computation from negative caching
J8 replaces the HashMap with ConcurrentHashMap<String,String> but keeps the test sequential. The key M is absent from the source, so the loader returns null twice. The reproduced result is again calls=2, contains=false, value=null. This control shows that changing to a concurrent map does not itself create a negative-cache representation.
The Java SE 21 ConcurrentHashMap contract specifies atomic execution of the complete computeIfAbsent invocation. It evaluates the supplied function once for an absent key within that invocation, otherwise not at all, and it installs no computed value when the result is null. Oracle also advises short, simple computations because some other updates may be blocked while one runs. These are documented implementation guarantees, not concurrency measurements produced by J8.
Those guarantees address a different question from negative caching. Atomicity concerns a method invocation’s indivisible map operation, not the business meaning of its result. Negative caching here requires a retained non-null mapping that represents authoritative absence after the invocation returns. A null-returning loader leaves neither tested map with such a negative representation, so a later invocation can compute again. This is in-memory retention, not persistence across process restarts.
This playbook does not run two threads against the same key, measure contention, prove request collapsing, or assess throughput. It also does not claim that the behavior of the Map default method is identical to the stronger atomic behavior of ConcurrentHashMap. The default Map.computeIfAbsent specification does not promise synchronization or atomicity; implementations with concurrency guarantees must document them.
Avoid a once-forever loader guarantee
Do not turn a per-invocation rule into a lifetime guarantee. For ConcurrentHashMap, the documented function-invocation condition concerns an absent key within one computeIfAbsent call, not one source access for the lifetime of a key. A later call after a null result can compute again because the previous call installed no value. A mapping function that throws also leaves the computed mapping unestablished. None of this promises exactly-once business effects.
The sequential J8 case therefore supports only a limited conclusion: stronger per-invocation atomicity does not turn null into a negative cache entry. It says nothing about single-flight behavior across a real service workload, exactly-once source access, fair scheduling, latency, or contention. Those require different fixtures and are excluded from this playbook.
Reconcile all nine cases by key and state
The complete ledger declares both source activity and resulting cache semantics. Expected counts come from the fixture’s initial state and assertions; observed counts come from the independent Java 21 execution printed above. The state descriptions likewise matched the observed run. No case changes the source mapping after construction except J7. J5 and J6 inject a failure without removing E from the source. J6 remains a rejected policy despite passing its diagnostic assertion.
Case / key | Setup and source event | Calls | Key stored? | State and operational interpretation |
J1 / M | M absent; raw String cache. | 2 / 2 | false / false | Both calls return null; no negative entry is stored. |
J2 / P | P contains "present". | 1 / 1 | true / true | Present string is retained and reused on the second lookup. |
J3 / M | Preload cache with M mapped to null; source still lacks M. | 2 / 2 | true / true | Explicit null entry remains, but computation runs twice. |
J4 / M | Wrap authoritative absence with Optional.ofNullable. | 1 / 1 | true / true | Stored Optional.empty() is reused as an explicit negative result. |
J5 / E | First load throws; the second reaches the existing "recovered" value. | 2 / 2 | true / true | After failure: no entry. After retry: present "recovered". |
J6 / E | Catch the injected failure and return Optional.empty(). | 1 / 1 | true / true | Poisoned negative entry suppresses recovery. Policy rejected. |
J7 / M | Cache absence; insert "now-present" into source; remove cache key; reload. | 2 / 2 | true / true | Before removal: stale absence, one call. After removal: no entry. After reload: present "now-present". |
J8 / M | Sequential ConcurrentHashMap lookups; source lacks M. | 2 / 2 | false / false | Both computed results are null; atomicity does not install a negative entry. |
J9 / Z | Z contains the empty string "". | 1 / 1 | true / true | Present Optional, payload length zero. Empty text is not absence. |
J9 deserves emphasis because logs can mislead. The printed Optional[] is not the evidence. c.get("Z").isPresent() is true, and the contained String has length zero, explicitly checked by length() == 0. Presence and payload emptiness therefore coexist. The loader runs only once because the map has a non-null Optional value after the first call.
Keep this topic separate from Java Stream validation callbacks and count. The Java SE 21 Stream.count contract permits skipping pipeline evaluation when the count can be determined directly from the source. That is a skipped-callback problem. This playbook instead measures repeated loader execution because a reusable non-null mapping was never installed. It does not run a stream-optimization experiment.
The nine-case matrix also blocks an overly simple rule such as “one loader call means correct.” J6 and J4 both have one loader call, but only J4 reflects an authoritative absence. Correctness comes from the source contract plus the stored state, not from minimizing calls alone.
The matrix should travel with any proposed repair. A code diff that changes only J1 into a one-call result is incomplete evidence unless J5 still retries after failure, J6 remains rejected, J7 still exposes stale negative state before invalidation, and J9 still treats the empty string as present. These controls prevent a “fix” for repeated misses from silently broadening into a policy that caches every non-successful load as absence.
Choose ACCEPT, REPAIR, INVALIDATE, or HOLD
Operational decisions combine an explicit null/exception contract, a supported map implementation, observed call counts, stored state, and freshness ownership. The labels below are stricter than “works” or “doesn’t work.” They identify the next action and the evidence needed to justify it. A stopped loader is not, by itself, proof of a correct cache.
Decision | Required conditions | Evidence and next action |
ACCEPT | Explicit presence/absence/failure contract; supported map; justified absence representation; known freshness owner; intended call count and mapping state. | Reconcile loader count, membership, and semantic state. Preserve the tested policy and regression tests. |
REPAIR_AND_RERUN | Known repeated raw-null behavior is unwanted, or a known failure is incorrectly converted to absence. | Introduce explicit absence representation or correct the failure policy. Rerun the affected cases and controls. |
INVALIDATE_RELOAD | A source change is known, and the caller owns the affected cache key. | Retain change provenance and the before/after ledger. Remove that key, reload, and verify the new state. |
HOLD | null conflates failure with absence; source semantics or supported behavior are unclear; or freshness ownership is unknown. | Resolve the ambiguity with the responsible owner. Do not approve negative caching or key removal by guesswork. |
Use ACCEPT for J2, J4, J5’s recovered final state, and J9 under the fixture contract. J1 may be acceptable only if repeated source calls for authoritative absence are intentionally desired; if the goal is to retain negative state, it needs REPAIR_AND_RERUN. J6 is unambiguously REPAIR_AND_RERUN because its failure policy is wrong. J7 demonstrates INVALIDATE_RELOAD only because the source mutation is known and the key is owned. A production cache with unknown external writers and no invalidation contract should be HOLD, not guessed into freshness.
Most importantly, do not accept a repair simply because repeated calls stop. J6 proves that stopping calls can be the symptom of a semantic regression.
For operational use, the decision labels also determine who must act. REPAIR_AND_RERUN belongs to the code owner because semantics or representation are wrong. INVALIDATE_RELOAD belongs to the freshness owner because the cached fact was once valid but the source changed. HOLD belongs to the team that can resolve an ambiguous source contract or ownership boundary. ACCEPT is not “do nothing”; it means preserve the tested contract and rerun the evidence suite when one of its declared assumptions changes.
Assign freshness and recovery ownership
Once absence is represented explicitly, the engineering problem shifts from “why did the loader run again?” to “who is responsible for making this cached decision stop being authoritative?” That owner must be named. If the same service controls source writes and the local cache, it may invalidate the affected key as part of its write path. If another component changes the source, the cache owner needs a separately designed notification, refresh, or bounded-staleness policy. None of that is supplied by Optional or computeIfAbsent.
Recovery ownership is equally important. The source adapter should classify authoritative not-found separately from transient or unexpected failure. The cache layer should preserve that classification rather than catching broad exceptions. The calling service should define whether a failed read is retried, surfaced to the request, or handled by a higher-level resilience policy. J5 makes one explicit subsequent lookup; this playbook does not implement an automated retry strategy, circuit breaker, asynchronous loader, or remote fallback. Those mechanisms introduce additional transitions that require their own tests.
Evidence ownership should also be explicit. Preserve the exact JDK/runtime identifiers, source revision, fixture code, command line, and per-case output for every acceptance run. Rerun after a meaningful JDK change, map implementation change, or loader-contract change rather than assuming the prior evidence transfers automatically. Oracle’s Java SE 21 documentation is the contract authority used here, while the executed output establishes what this particular reference runtime did.
Capacity and expiry are follow-on design boundaries, not hidden features. This map has no configured entry limit, so accumulating negative entries consumes memory until entries are removed or memory becomes a constraint. An absent entity can later appear, as J7 shows. This article excludes eviction libraries, TTL implementations, distributed caches, and throughput benchmarking so those concerns are not smuggled into an Optional example and mistaken for solved behavior.
Document the untested concurrency and production boundaries
The fixture has no parallel schedule, external service, network timeout, database transaction, distributed invalidation, TTL, eviction, negative-entry capacity limit, or asynchronous refresh. J8 uses ConcurrentHashMap sequentially only. No case establishes single-flight behavior or exactly-once loader execution. HashMap remains unsuitable for unsynchronized concurrent mutation; this playbook does not convert it into a production concurrent cache merely by explaining its null semantics.
Retain evidence under the same ownership discipline as the cache. This fixture contains only inert keys and labels. An adaptation may expose user identifiers or sensitive source values, so keep those records out of public examples and limit access to retained evidence. A regression report should preserve the environment, source revision, failure classification, and exact before/after state without turning production data into illustrative content.
Develop practical software correctness judgement
The useful habit here is not memorizing Optional. It is learning to separate API return semantics, business meaning, failure classification, stored state, and freshness ownership, and then to demand evidence for each boundary before declaring a cache correct. That same judgement applies broadly to backend and scalable-system work.
For structured practice in the broader discipline, review Refonte Learning’s Software Engineering Program. Its official page describes three months at 12–14 hours per week, with full-stack development, cloud and microservices, real-time data processing, performance optimization, application security, scalable software solutions, the software development lifecycle, and a capstone. These broad curriculum topics are relevant to evaluating service boundaries and software behavior; they do not establish that a particular Java API or this exact lab is taught. Bring the same questions to any practical project: what does the result mean, what state was retained, what evidence supports reuse, and who owns recovery when the source changes?
