When a Spring Boot application defines its own ScheduledExecutorService bean (e.g. via Executors.newSingleThreadScheduledExecutor()), the application hangs indefinitely after receiving a SIGTERM signal if there are active @Scheduled(cron = …) tasks.

This only occurs starting with Java 19, because ExecutorService now implements AutoCloseable. Spring detects the bean as AutoCloseable and calls close() during context shutdown. However, if the executor is used for @Scheduled cron jobs, it waits for the recurring tasks to finish — which never happens — resulting in a stuck shutdown.

Steps to Reproduce: * Use Java 20 or higher * Create a Spring Boot app with:

@SpringBootApplication
@EnableScheduling
public class DemoApp implements SchedulingConfigurer {

    public static void main(String[] args) {
        SpringApplication.run(DemoApp.class, args);
    }

    @Override
    public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
        taskRegistrar.setScheduler(taskExecutor());
    }

    @Bean
    public Executor taskExecutor() {
        return Executors.newSingleThreadScheduledExecutor();
    }

    @Scheduled(cron = "* 0 * * * *")
    public void runJob() {
        System.out.println("Running job...");
    }

}
  • Run the application
  • Send a SIGTERM (e.g. kill -15 <pid>)
  • Observe that the application does not exit

Expected behavior: Spring should shut down cleanly, cancelling or stopping recurring tasks using the executor.

Actual behavior: Shutdown hangs indefinitely, because the executor’s close() waits for the cron task to complete, but the task is recurring and never terminates.

Potential workarounds: * Let Spring define its default TaskScheduler, it's handled properly by calling shutdownNow(). * Annotate the executor with @Bean(destroyMethod = "shutdownNow")

Suggestion: Maybe a better approach would be always handling the custom ScheduledExecutorService the same way as localExecutor in ScheduledTaskRegistrar? But in this case, we don't cover custom TaskScheduler case... Any idea or suggestions are welcome 🙏

Comment From: jhoeller

ScheduledAnnotationBeanPostProcessor should cancel the recurring tasks on shutdown, in addition to the ExecutorService itself closing down. Any indication why this is not happening in your scenario?

In any case, we need to make sure that the out-of-the-box experience with such a custom ExecutorService does not lead to an indefinite hanging on shutdown.

Comment From: chengchen

Hi @jhoeller Thanks for the quick feedback 👍 I guess the fact that it becomes a managed bean and ExecutorService now implements AutoCloseable encourages Spring to use close() first - which hangs due to JDK changes.

Another workaround would be using @Bean(destroyMethod = "shutdown"), this works for sure - but less clean IMO.

Comment From: chengchen

Here is another example without Spring involved, showing why it's hanging for cron jobs but not for jobs annotated with @Scheduled(fixedDelay = 3_600_000):

public class ScheduledExecutorShutdownTest {

    @Test
    void thisHangs() throws InterruptedException {
        ScheduledExecutorService ex = Executors.newScheduledThreadPool(1);

        // called by cron jobs
        ex.schedule(() -> System.out.println("should run much later"), 1, TimeUnit.DAYS);

        // ensure task is enqueued
        Thread.sleep(100);

        // blocks until the delayed task is allowed to run (effectively 'hangs' for a day due to JDK changes)
        // but ex.shutdownNow() will be fine
        ex.close();
    }

    @Test
    void thisDoesNotHang() throws InterruptedException {
        ScheduledExecutorService ex = Executors.newScheduledThreadPool(1);

        // called by fixed delay jobs
        ex.scheduleWithFixedDelay(() -> System.out.println("should run much later"), 10, 10, TimeUnit.MINUTES);

        // ensure task is enqueued
        Thread.sleep(100);

        // this doesn't block
        ex.close();
    }

}

Comment From: jhoeller

As of 6.2.11, we consistently call shutdown() unless a specific close() method has been provided in an ExecutorService subclass. This hard-avoids the default AutoCloseable implementation on JDK 19+, in particular for ScheduledExecutorService but also any regular ExecutorService, effectively restoring pre-JDK-19 behavior which never hangs on shutdown.

By those rules, we still call close() on a ForkJoinPool (which has a dedicated close() implementation) as well as on custom ExecutorService implementations which happen to have a custom close() method (just like we would have treated those classes before). We literally just exclude the default close() method on ExecutorService itself.

There is a minor twist: We consistently invoke shutdown() even on JDK 17/18 for any kind of ExecutorService bean now, not just for @Bean-specified beans (which have destroy method inference by default), unless a specific close() method has been provided. This provides consistent behavior on JDK 17/18 next to JDK 19.