Skip to content

Prevent double size decrement in ConcurrentLruCache - #37268

Open
junhyeong9812 wants to merge 1 commit into
spring-projects:mainfrom
junhyeong9812:fix/lru-cache-double-decrement
Open

Prevent double size decrement in ConcurrentLruCache#37268
junhyeong9812 wants to merge 1 commit into
spring-projects:mainfrom
junhyeong9812:fix/lru-cache-double-decrement

Conversation

@junhyeong9812

Copy link
Copy Markdown
Contributor

Overview

ConcurrentLruCache.markAsRemoved decrements the current size without checking whether the node has already been removed. The eviction path and an explicit remove(K) can process the same node in sequence, decrementing the size twice. Every extra decrement makes currentSize permanently smaller than the number of cached entries, so eviction stops triggering and the cache silently exceeds its capacity for good. This PR adds the missing terminal-state guard, mirroring the sibling transition markForRemoval.

Problem

Removal work is buffered and drained under the eviction lock. When a drain runs a queued AddTask whose evictEntries() polls a node that a concurrent remove(K) has already taken out of the cache, the eviction marks the node removed and decrements the size; the queued RemovalTask for the same node then marks it removed again and decrements the size a second time. markForRemoval guards its transition with an isActive() check, but markAsRemoved performed no state check at all.

The resulting drift is cumulative and self-concealing: currentSize under-counts, evictEntries() compares against it and stops evicting, and the map keeps more entries than the configured capacity with no exception or log. In a bounded two-thread stress run (one thread inserting new keys, one repeatedly re-inserting and removing a single key), the cache stabilized far above its capacity in 20 out of 20 runs before the change, and converged back to its capacity in all runs after it.

Fix

markAsRemoved now returns without decrementing when the entry is already in the removed state. The removed state is terminal - no transition leads out of it - so each node is counted down exactly once regardless of how many cleanup paths visit it; the guard reads the same entry snapshot the CAS uses, so a lost race simply re-reads and observes the terminal state. The method comment is updated accordingly.

The new test races explicit removals against eviction-driving inserts and verifies that the cache converges back to its capacity once the pending work is drained; the accumulated drift makes the assertion reliable even though a single interleaving cannot be forced through the public API. The test also asserts that the racing thread actually performed removals and terminated cleanly, so a silently dead worker cannot turn it into a vacuous pass. The full spring-core test suite passes.

markAsRemoved transitions a node to the removed state and decrements
the current size, but it did not check whether the node had already
been removed. The eviction path and an explicit removal can process
the same node in sequence: when a write drain runs a queued AddTask
whose eviction polls a node that a concurrent remove(K) has already
taken out of the cache, the eviction decrements the size, and the
queued RemovalTask for the same node decrements it again. The sibling
transition markForRemoval guards against invalid transitions; this one
did not.

Each extra decrement makes currentSize permanently smaller than the
number of cached entries, so eviction stops triggering and the cache
exceeds its capacity for good, silently. A bounded two-thread stress
run accumulates the drift reliably: before the change the cache
stabilized far above its capacity in 20 out of 20 runs.

markAsRemoved now returns without decrementing when the entry is
already in the removed state, mirroring the guard in markForRemoval.
The removed state is terminal, so each node is counted down exactly
once. The new test races explicit removals against eviction and then
verifies that the cache converges back to its capacity; it also
asserts that the racing thread ran and terminated cleanly.

Signed-off-by: junhyeong9812 <pickjog@gmail.com>
@spring-projects-issues spring-projects-issues added the status: waiting-for-triage An issue we've not yet triaged or decided on label Sep 10, 2026
@sbrannen sbrannen added the in: core Issues in core modules (aop, beans, core, context, expression) label Sep 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

in: core Issues in core modules (aop, beans, core, context, expression) status: waiting-for-triage An issue we've not yet triaged or decided on

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants