Demonstrates ConcurrentMapComputeRecursionDetector: a ConcurrentHashMap
computeIfAbsent() lambda that recursively calls computeIfAbsent() on the
same map, causing a deadlock-like hang.
GraphService.getNeighbors() lazily builds an adjacency list using
ConcurrentHashMap.computeIfAbsent(). The lambda itself calls getNeighbors()
for a related node, which triggers another computeIfAbsent() on the same
map, for a different key. ConcurrentHashMap's javadoc forbids that outright:
the mapping function must not modify the map during computation.
What it actually does was measured on JDK 26, over 200 fresh maps: the nested
computeIfAbsent ran and returned normally 198 times, and threw
IllegalStateException("Recursive update") twice, on the runs where the two keys
happened to land in the same bin. So the usual outcome is not a thrown exception
but a silent one: the adjacency list is built out of order and the graph is left
inconsistent, with nothing in the log to say so.
ConcurrentMapComputeRecursionDetector reports this. It used to key its evidence
on map, key and thread together, so a re-entry on a different key was invisible
and this example demonstrated a defect its own detector could not see; that was
#343. The rule now asks
whether the thread was already inside a compute on this map, whichever key it
was computing, because that is what the contract says. Nesting into a different
map is still not reported: a mapping function that fills some other cache is
ordinary code.
-
Remove
@DisabledfromtestGetNeighbors_concurrent_detectsRecursion. -
Run:
mvn testor./gradlew test -
The test fails with a ConcurrentMapComputeRecursionDetector report naming both keys:
CONCURRENT MAP COMPUTE RECURSION DETECTED: - Thread 'async-test-worker-1 (id=31)': compute*()/merge() on adjacency-map for key 'B' entered while key(s) [A] on the same map were still being computed ...
Two details that are easy to get wrong when instrumenting your own code, and that this example exists to show:
- The recording has to happen inside the mapping function. That is the only
place the nesting is visible. Recording around
getNeighbors("A")instead sees one balanced start/end per call and reports nothing, however long the run is.GraphServiceexposes two no-opConsumer<String>hooks for that; the test wires them to the detector, and the production path never touches the library. failOndefaults toNONE, which reports a finding without failing the run. Every disabled demonstration inexamples/therefore setsfailOn = FailOn.LOW; without it, removing@Disabledprints the report and leaves the test green.ExampleDisabledDemoTestnow requires that.
Fix: precompute the full graph outside any computeIfAbsent() lambda, or
use a plain HashMap with external synchronization so recursion is safe.