Why Caffeine’s Default Scheduler Skips Expired Eviction and How to Enable It

This article explains why Caffeine’s default scheduler (disabledScheduler) does not trigger RemovalListener on expired entries, details the three cache cleanup strategies, compares the four built‑in schedulers, and shows how to configure a functional scheduler with code examples.

FunTester
FunTester
FunTester
Why Caffeine’s Default Scheduler Skips Expired Eviction and How to Enable It

Caffeine can be used together with a custom object pool. Objects borrowed from the pool are stored as cache values and should be returned to the pool when the cache entry is removed. The cache removal is observed via RemovalListener, which receives the key, value and a RemovalCause (e.g., EXPIRED).

Why the RemovalListener was not invoked

When the cache is built with expireAfterWrite(1, TimeUnit.MINUTES) but without an explicit scheduler, the listener is never called after the entry expires. Caffeine defines three cleanup strategies:

Timed cleanup – runs at fixed intervals.

Delayed cleanup – runs shortly after an entry becomes expired.

Manual cleanup – invoked explicitly by the application.

All strategies require a Scheduler supplied via scheduler(...). If no scheduler is set, Caffeine falls back to Scheduler.disabledScheduler(), which performs no background cleanup. Consequently, expired entries are removed only when they are accessed, manually invalidated, or when Cache.cleanUp() is called.

Scheduler implementations in Caffeine

SystemScheduler – the default real scheduler that uses CompletableFuture.delayedExecutor backed by a thread pool.

GuardedScheduler – wraps another scheduler and guarantees that the same task is not scheduled concurrently.

DisabledScheduler – a no‑op scheduler; its schedule() method only validates arguments and returns a DisabledFuture.

ExecutorServiceScheduler – builds a scheduler on a user‑provided ScheduledExecutorService for custom thread‑pool control.

Default behaviour

The method com.github.benmanes.caffeine.cache.Caffeine#getScheduler() returns Scheduler.disabledScheduler() when the builder does not specify a scheduler:

Scheduler getScheduler() {
    if (this.scheduler != null && this.scheduler != Scheduler.disabledScheduler()) {
        return this.scheduler == Scheduler.systemScheduler()
            ? this.scheduler
            : Scheduler.guardedScheduler(this.scheduler);
    } else {
        return Scheduler.disabledScheduler();
    }
}

Implementation of the disabled scheduler:

enum DisabledScheduler implements Scheduler {
    INSTANCE;
    public Future<Void> schedule(Executor executor, Runnable command,
                                 long delay, TimeUnit unit) {
        Objects.requireNonNull(executor);
        Objects.requireNonNull(command);
        Objects.requireNonNull(unit);
        return DisabledFuture.INSTANCE; // no work scheduled
    }
}

Implementation of the system scheduler (the one that actually schedules delayed tasks):

enum SystemScheduler implements Scheduler {
    INSTANCE;
    public Future<?> schedule(Executor executor, Runnable command,
                              long delay, TimeUnit unit) {
        Executor delayedExecutor =
            CompletableFuture.delayedExecutor(delay, unit, executor);
        return CompletableFuture.runAsync(command, delayedExecutor);
    }
}

Enabling automatic eviction

Configure the cache with a real scheduler, e.g. Scheduler.systemScheduler(), so that expired entries are removed in the background and the RemovalListener is triggered.

static void main(String[] args) {
    // Example object pool (implementation omitted for brevity)
    FunPool<String> pool = new FunPool<>(new FunPooledFactory<String>() {
        @Override
        public String newInstance() {
            return "FunTester" + getRandomInt(Integer.MAX_VALUE);
        }
    });

    RemovalListener<String, String> listener = new RemovalListener<String, String>() {
        @Override
        public void onRemoval(String key, String value, RemovalCause cause) {
            System.out.println("Key: " + key + ", Value: " + value + " cause: " + cause);
            pool.back(value); // return object to the pool
        }
    };

    var cache = Caffeine.<String, String>newBuilder()
            .expireAfterWrite(1, TimeUnit.SECONDS)
            .maximumSize(100)
            .removalListener(listener)
            .scheduler(Scheduler.systemScheduler())
            .build();

    cache.put("tester", pool.borrow());
    // Wait longer than the expiration time
    Thread.sleep(2000);
}

Running the program prints a line similar to:

12:29:15:054 ForkJoinPool.commonPool-worker-2 Key: tester , Value: FunTester208146637 cause: EXPIRED

The output confirms that the removal listener was invoked and the object was returned to the pool.

Key take‑aways

The default scheduler in Caffeine is DisabledScheduler, which disables automatic cleanup.

To have expired entries evicted automatically, explicitly set a scheduler such as Scheduler.systemScheduler() or a custom ExecutorServiceScheduler.

When a real scheduler is active, the RemovalListener receives EXPIRED events, allowing you to recycle pooled objects without manual intervention.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

JavaCacheSchedulerCaffeineObjectPoolRemovalListener
FunTester
Written by

FunTester

10k followers, 1k articles | completely useless

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.