java-topology/defects/spring/patch/spring-MOADX-0001-fallback-cache-operation-source-stampede.md

6.9 KiB
Raw Blame History

UNDF: UNDF-2026-000000295

UNDF: (pending)

spring-MOADX-0001: AbstractFallbackCacheOperationSource — cache stampede O(N) concurrent annotation scans on cold start

MOAD-0006 Candidate — The Thundering Herd

Field Value
ID spring-MOADX-0001
Severity MEDIUM
Ecosystem spring-framework
File 1 spring-context/src/main/java/org/springframework/cache/interceptor/AbstractFallbackCacheOperationSource.java
File 2 spring-context-support/src/main/java/org/springframework/cache/jcache/interceptor/AbstractFallbackJCacheOperationSource.java
Lines 95121 (standard), 6791 (JCache)
Pattern ConcurrentHashMap.get → null check → reflection scan → ConcurrentHashMap.put
Trigger N concurrent requests to same @Cacheable-annotated method before cache warms → N concurrent annotation reflection scans
Hot path Every @Cacheable / @CacheResult method invocation before the operation metadata is cached
Compute cost Java reflection: AopUtils.getMostSpecificMethod, method/class annotation traversal, proxy unwrapping

Defect

AbstractFallbackCacheOperationSource is the core metadata resolver for Spring's @Cacheable, @CachePut, and @CacheEvict annotations. It caches the resolved CacheOperation collection in a ConcurrentHashMap, but uses a non-atomic get-null-compute-put pattern:

// AbstractFallbackCacheOperationSource.java:95121
private @Nullable Collection<CacheOperation> getCacheOperations(
        Method method, @Nullable Class<?> targetClass, boolean cacheNull) {

    if (ReflectionUtils.isObjectMethod(method)) {
        return null;
    }

    Object cacheKey = getCacheKey(method, targetClass);
    Collection<CacheOperation> cached = this.operationCache.get(cacheKey);   // 1. read

    if (cached != null) {
        return (cached != NULL_CACHING_MARKER ? cached : null);              // 2. hit
    }
    else {
        Collection<CacheOperation> cacheOps = computeCacheOperations(method, targetClass); // 3. compute
        if (cacheOps != null) {
            this.operationCache.put(cacheKey, cacheOps);                     // 4. write
        }
        else if (cacheNull) {
            this.operationCache.put(cacheKey, NULL_CACHING_MARKER);
        }
        return cacheOps;
    }
}

computeCacheOperations resolves the most-specific method (proxy unwrapping), then walks the method and declaring class for cache annotations — up to 4 reflection calls:

private @Nullable Collection<CacheOperation> computeCacheOperations(Method method, @Nullable Class<?> targetClass) {
    Method specificMethod = AopUtils.getMostSpecificMethod(method, targetClass);  // proxy unwrap + class hierarchy
    Collection<CacheOperation> opDef = findCacheOperations(specificMethod);       // method annotation scan
    if (opDef != null) return opDef;
    opDef = findCacheOperations(specificMethod.getDeclaringClass());              // class annotation scan
    if (opDef != null && ClassUtils.isUserLevelMethod(method)) return opDef;
    if (specificMethod != method) {
        opDef = findCacheOperations(method);                                      // original method scan
        if (opDef != null) return opDef;
        opDef = findCacheOperations(method.getDeclaringClass());                  // original class scan
        if (opDef != null && ClassUtils.isUserLevelMethod(method)) return opDef;
    }
    return null;
}

The identical pattern exists in AbstractFallbackJCacheOperationSource.getCacheOperation for @CacheResult/@CachePut/@CacheRemove annotations (lines 6791).

Stampede Scenario

  1. Application receives N concurrent requests to the same @Cacheable-annotated method (e.g., a REST endpoint called on startup health check or during load test ramp-up).
  2. The operationCache is empty (cold start, or first invocation after context refresh).
  3. All N threads call getCacheOperations simultaneously.
  4. All N threads get a cache miss (step 1).
  5. All N threads independently call computeCacheOperations: proxy unwrapping, annotation scanning.
  6. All N threads race to put the same result — identical values, redundant work.

In a large Spring application with many @Cacheable beans, concurrent startup traffic forces redundant annotation scans across hundreds of method-class pairs simultaneously. This delays initial response times and causes contention in the reflection infrastructure.

Fix

Replace get-null-compute-put with computeIfAbsent. The ConcurrentHashMap already supports this atomically:

Standard cache annotation source

// AbstractFallbackCacheOperationSource.java
private @Nullable Collection<CacheOperation> getCacheOperations(
        Method method, @Nullable Class<?> targetClass, boolean cacheNull) {

    if (ReflectionUtils.isObjectMethod(method)) {
        return null;
    }

    Object cacheKey = getCacheKey(method, targetClass);
    // computeIfAbsent is atomic: only one thread computes, others wait for the result
    Object cached = this.operationCache.computeIfAbsent(cacheKey, k -> {
        Collection<CacheOperation> ops = computeCacheOperations(method, targetClass);
        return (ops != null) ? ops : (cacheNull ? NULL_CACHING_MARKER : null);
    });

    if (cached == null) {
        return null;  // cacheNull was false and no operation found
    }
    return (cached != NULL_CACHING_MARKER ? (Collection<CacheOperation>) cached : null);
}

Note: ConcurrentHashMap.computeIfAbsent does not accept null return values; the NULL_CACHING_MARKER sentinel handles the "no annotation" case correctly.

JCache annotation source

Same fix applies to AbstractFallbackJCacheOperationSource.getCacheOperation:

private @Nullable JCacheOperation<?> getCacheOperation(Method method, @Nullable Class<?> targetClass, boolean cacheNull) {
    if (ReflectionUtils.isObjectMethod(method)) {
        return null;
    }
    MethodClassKey cacheKey = new MethodClassKey(method, targetClass);
    Object cached = this.operationCache.computeIfAbsent(cacheKey, k -> {
        JCacheOperation<?> op = computeCacheOperation(method, targetClass);
        return (op != null) ? op : (cacheNull ? NULL_CACHING_MARKER : null);
    });
    if (cached == null) return null;
    return (cached != NULL_CACHING_MARKER ? (JCacheOperation<?>) cached : null);
}

Severity Rationale

  • MEDIUM: The compute cost (annotation reflection) is lower than I/O or SQL plan building, but reflection is synchronized on the JVM class object and can serialize under high parallelism.
  • Triggered on every cold start or after Spring context refresh — affects all @Cacheable-heavy applications.
  • At N=200 concurrent startup requests across 50 annotated methods, 10,000 redundant annotation scans execute before the cache warms. Each scan acquires internal JVM locks.
  • The fix is a one-line change per method — computeIfAbsent is already present on the ConcurrentHashMap backing the operationCache.