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.
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: EXPIREDThe 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.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
