Dynamic Cron Scheduling in Spring Boot: Implementing Mutable and Immutable @Scheduled Tasks
This article explains how to use Spring Boot's @EnableScheduling and @Scheduled annotations to create cron, fixed‑delay, and fixed‑rate tasks, and demonstrates a lightweight approach for dynamically updating or disabling cron expressions at runtime without external schedulers.
Hello everyone, I am Chen.
In a Spring Boot project you can enable scheduling support with the @EnableScheduling annotation and quickly create scheduled jobs using the @Scheduled annotation.
@Scheduled supports three ways to define the execution time:
cron(expression) : execute according to a Cron expression.
fixedDelay(period) : execute with a fixed delay between the end of the previous execution and the start of the next, regardless of how long the task runs.
fixedRate(period) : execute at a fixed rate from the start of the previous execution; if a run is delayed, the next execution is triggered immediately.
The most commonly used method is the first one, based on Cron expressions, because of its flexibility.
Mutable and Immutable
By default, a method annotated with @Scheduled becomes immutable after the bean is initialized; Spring registers the task and does not change it later. Before the task actually starts, you can still modify its execution period via application.properties with @Value , or load a CronTrigger from a database or other storage. Once the task is registered, its parameters are fixed and cannot be changed.
Therefore, after a task has begun execution you cannot dynamically change its Cron expression or disable it; the registration is immutable.
Create and Destroy
To work around this, we keep the essential information of each task during registration and use another scheduled job to monitor configuration changes. If a change is detected, we cancel the old task and register a new one; otherwise we leave it untouched.
First, define a simple abstraction to identify and manage tasks uniformly:
public interface IPollableService {
/**
* Execute method
*/
void poll();
/**
* Get Cron expression
* @return CronExpression
*/
default String getCronExpression() {
return null;
}
/**
* Get task name
* @return task name
*/
default String getTaskName() {
return this.getClass().getSimpleName();
}
}The key method is getCronExpression() ; each implementation decides its own expression and whether it is mutable.
Next, implement dynamic registration:
@Configuration
@EnableAsync
@EnableScheduling
public class SchedulingConfiguration implements SchedulingConfigurer, ApplicationContextAware {
private static final Logger log = LoggerFactory.getLogger(SchedulingConfiguration.class);
private static ApplicationContext appCtx;
private final ConcurrentMap
scheduledTaskHolder = new ConcurrentHashMap<>(16);
private final ConcurrentMap
cronExpressionHolder = new ConcurrentHashMap<>(16);
private ScheduledTaskRegistrar taskRegistrar;
public static synchronized void setAppCtx(ApplicationContext ctx) {
appCtx = ctx;
}
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
setAppCtx(applicationContext);
}
@Override
public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
this.taskRegistrar = taskRegistrar;
}
/** Refresh scheduled task expressions */
public void refresh() {
Map
beanMap = appCtx.getBeansOfType(IPollableService.class);
if (beanMap.isEmpty() || taskRegistrar == null) {
return;
}
beanMap.forEach((beanName, task) -> {
String expression = task.getCronExpression();
String taskName = task.getTaskName();
if (expression == null) {
log.warn("Scheduled task [{}] expression not configured or invalid", taskName);
return;
}
boolean unmodified = scheduledTaskHolder.containsKey(beanName) &&
cronExpressionHolder.get(beanName).equals(expression);
if (unmodified) {
log.info("Scheduled task [{}] expression unchanged, no refresh needed", taskName);
return;
}
Optional.ofNullable(scheduledTaskHolder.remove(beanName)).ifPresent(existing -> {
existing.cancel();
cronExpressionHolder.remove(beanName);
});
if (ScheduledTaskRegistrar.CRON_DISABLED.equals(expression)) {
log.warn("Scheduled task [{}] expression set to disabled, will not be scheduled", taskName);
return;
}
CronTask cronTask = new CronTask(task::poll, expression);
ScheduledTask scheduledTask = taskRegistrar.scheduleCronTask(cronTask);
if (scheduledTask != null) {
log.info("Scheduled task [{}] loaded with expression [{}]", taskName, expression);
scheduledTaskHolder.put(beanName, scheduledTask);
cronExpressionHolder.put(beanName, expression);
}
});
}
}The crucial part is keeping a reference to the ScheduledTask object; the special expression "-" is used as a marker to disable a task.
Disabling a task can later be "revived" by assigning a new Cron expression. To continuously monitor and refresh configurations we add another scheduled component:
@Component
public class CronTaskLoader implements ApplicationRunner {
private static final Logger log = LoggerFactory.getLogger(CronTaskLoader.class);
private final SchedulingConfiguration schedulingConfiguration;
private final AtomicBoolean appStarted = new AtomicBoolean(false);
private final AtomicBoolean initializing = new AtomicBoolean(false);
public CronTaskLoader(SchedulingConfiguration schedulingConfiguration) {
this.schedulingConfiguration = schedulingConfiguration;
}
/** Periodically refresh task configurations */
@Scheduled(fixedDelay = 5000)
public void cronTaskConfigRefresh() {
if (appStarted.get() && initializing.compareAndSet(false, true)) {
log.info("Dynamic scheduling task loading start >>>>>");
try {
schedulingConfiguration.refresh();
} finally {
initializing.set(false);
}
log.info("Dynamic scheduling task loading end <<<<<");
}
}
@Override
public void run(ApplicationArguments args) {
if (appStarted.compareAndSet(false, true)) {
cronTaskConfigRefresh();
}
}
}You could merge this logic into SchedulingConfiguration , but separating execution from triggering makes future extensions easier. You can also trigger a refresh manually via a UI button or a message queue.
Verification
We create a sample project with three simple scheduled tasks to verify the approach.
Task 1: a fixed‑rate task whose Cron expression never changes:
@Service
public class CronTaskBar implements IPollableService {
@Override
public void poll() {
System.out.println("Say Bar");
}
@Override
public String getCronExpression() {
return "0/1 * * * * ?";
}
}Task 2: a task that frequently changes its execution period using a random number generator:
@Service
public class CronTaskFoo implements IPollableService {
private static final Random random = new SecureRandom();
@Override
public void poll() {
System.out.println("Say Foo");
}
@Override
public String getCronExpression() {
return "0/" + (random.nextInt(9) + 1) + " * * * * ?";
}
}Task 3: a task that toggles between enabled and disabled states:
@Service
public class CronTaskUnavailable implements IPollableService {
private String cronExpression = "-";
private static final Map
map = new HashMap<>();
static {
map.put("-", "0/1 * * * * ?");
map.put("0/1 * * * * ?", "-");
}
@Override
public void poll() {
System.out.println("Say Unavailable");
}
@Override
public String getCronExpression() {
return (cronExpression = map.get(cronExpression));
}
}If everything is set up correctly, the logs will show messages similar to the following, indicating which tasks were refreshed, loaded, or disabled:
Dynamic scheduling task loading start >>>>>>
Scheduled task [CronTaskBar] expression unchanged, no refresh needed
Scheduled task [CronTaskFoo] loaded with expression [0/6 * * * * ?]
Scheduled task [CronTaskUnavailable] expression set to disabled, will not be scheduled
Dynamic scheduling task loading end <<<<<
Say Bar
Say Bar
Say Foo
... (subsequent cycles show updated expressions and toggling)Summary
By periodically refreshing and rebuilding scheduled tasks we achieve dynamic modification of Cron expressions without introducing heavyweight schedulers like Quartz, resulting in a lightweight and elegant solution suitable for most projects. Feel free to share alternative approaches if you have better ideas.
Finally, if this article helped you, please like, share, and follow the "Code Monkey" technical column for more Spring Boot tips.
Code Ape Tech Column
Former Ant Group P8 engineer, pure technologist, sharing full‑stack Java, job interview and career advice through a column. Site: java-family.cn
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.