All articles

// Knowledge.log — 技術記事

Two @Scheduled methods on Spring Boot 4.1: the second waits for the first

Boot 4.1.2's default TaskScheduler has one thread. Two @Scheduled methods, one sleeping past its interval, and the second only runs after it.

Here's the setup. An application has @EnableScheduling and two @Scheduled methods. One does heavy work and takes longer than its own interval. The other just bumps a counter every 50 ms. You write a test, wait for the slow method to start, check the fast method's counter, and it reads zero.

Most people land on one of two conclusions here: scheduling is broken, or an @Async is missing. Neither is right. The scheduler is working. It just has one thread, and that thread is busy sleeping inside the slow method.

By the end you'll have a non-web Maven fixture with three tests. The first shows both methods serialized on scheduling-1. The second shows the fast method running on scheduling-2 after one property change. The third measures how fixedRate and fixedDelay differ when the method body runs longer than the period. You'll also see where a bigger pool stops being the answer.

What ran, and on which versions

The fixture ran on October 28, 2026 with Spring Boot 4.1.2, Temurin 25.0.4 LTS (25.0.4+7-LTS) and Maven 3.9.12, with java.version=25 in the POM. The Spring Boot 4.1 system requirements page says 4.1.2 needs at least Java 17, is compatible up to Java 27, and requires Spring Framework 7.0.10 or later. JDK 25 sits inside that matrix.

mvn -q test passed with Tests run: 3, Failures: 0, Errors: 0. The classes are PoolSizeOneTest, PoolSizeTwoTest and FixedRateVsFixedDelayTest. There's no web server and no Actuator, and the context starts with webEnvironment = NONE.

If you're coming from Boot 3.x, the property used here already existed there. What catches people off guard is the default, not some new API.

The default is one thread

The Boot 4.1 task execution and scheduling docs say that if you don't define a scheduler yourself, Boot auto-configures a ThreadPoolTaskScheduler with one thread. The knob is spring.task.scheduling.pool.size. Threads get the scheduling- prefix, which you can change with spring.task.scheduling.thread-name-prefix.

That's one thread for every @Scheduled in the application. Not one per method, and not one per bean. Moving the two methods into separate classes changes nothing, because the same pool still fires both of them.

The fixture

The POM, minus the header:

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>4.1.2</version>
    <relativePath/>
</parent>

<properties>
    <java.version>25</java.version>
</properties>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

The application:

package devdojo.sched;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;

@SpringBootApplication
@EnableScheduling
public class SchedApp {

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

Both methods live in the same bean, enabled by the slowfast profile. slow() has a 100 ms period and sleeps 400 ms. fast() has a 50 ms period and doesn't sleep at all:

package devdojo.sched;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicInteger;

import org.springframework.context.annotation.Profile;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

@Component
@Profile("slowfast")
public class SlowAndFastTasks {

    public final AtomicInteger slowRuns = new AtomicInteger();
    public final AtomicInteger fastRuns = new AtomicInteger();
    public final CountDownLatch slowStarted = new CountDownLatch(1);
    public volatile String slowThread = "<none>";
    public volatile String fastThread = "<none>";
    public final List<String> log = Collections.synchronizedList(new ArrayList<>());
    public final long t0 = System.nanoTime();

    @Scheduled(fixedRate = 100)
    public void slow() {
        long start = System.nanoTime();
        slowThread = Thread.currentThread().getName();
        slowRuns.incrementAndGet();
        slowStarted.countDown();
        log.add("slow-start@" + (start - t0) / 1_000_000 + "ms/" + slowThread);
        sleep(400);
        log.add("slow-end  @" + (System.nanoTime() - t0) / 1_000_000 + "ms/" + Thread.currentThread().getName());
    }

    @Scheduled(fixedRate = 50)
    public void fast() {
        fastThread = Thread.currentThread().getName();
        fastRuns.incrementAndGet();
        log.add("fast     @" + (System.nanoTime() - t0) / 1_000_000 + "ms/" + fastThread);
    }

    private static void sleep(long millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException ex) {
            Thread.currentThread().interrupt();
        }
    }
}

The first test pins spring.task.scheduling.pool.size=1, which is the default anyway. It's set explicitly so the test doesn't rely on somebody remembering the default. The test waits for the first slow() to start, watches for 1200 ms, and prints the event log:

@SpringBootTest(classes = SchedApp.class,
        webEnvironment = SpringBootTest.WebEnvironment.NONE,
        properties = {
                "spring.task.scheduling.pool.size=1",
                "spring.profiles.active=slowfast"
        })
class PoolSizeOneTest {

    @Autowired
    SlowAndFastTasks tasks;

    @Autowired
    TaskScheduler taskScheduler;

    @Test
    void diagnose() throws Exception {
        ThreadPoolTaskScheduler scheduler = (ThreadPoolTaskScheduler) taskScheduler;
        System.out.println("[pool=1] core=" + scheduler.getScheduledThreadPoolExecutor().getCorePoolSize()
                + " poolSize=" + scheduler.getScheduledThreadPoolExecutor().getPoolSize());
        assertThat(tasks.slowStarted.await(5, TimeUnit.SECONDS)).isTrue();
        Thread.sleep(1200);
        ArrayList<String> snapshot;
        synchronized (tasks.log) {
            snapshot = new ArrayList<>(tasks.log);
        }
        System.out.println("[pool=1] events=" + snapshot.size() + " slowRuns=" + tasks.slowRuns.get()
                + " fastRuns=" + tasks.fastRuns.get() + " slowThread=" + tasks.slowThread
                + " fastThread=" + tasks.fastThread);
        snapshot.forEach(e -> System.out.println("[pool=1]   " + e));
        assertThat(scheduler.getScheduledThreadPoolExecutor().getCorePoolSize()).isEqualTo(1);
    }
}

What the log showed with one thread

[pool=1] core=1 poolSize=1
[pool=1] events=13 slowRuns=4 fastRuns=6 slowThread=scheduling-1 fastThread=scheduling-1
[pool=1]   slow-start@14ms/scheduling-1
[pool=1]   slow-end  @414ms/scheduling-1
[pool=1]   fast     @415ms/scheduling-1
[pool=1]   fast     @415ms/scheduling-1
[pool=1]   slow-start@415ms/scheduling-1
[pool=1]   slow-end  @815ms/scheduling-1
[pool=1]   fast     @815ms/scheduling-1
[pool=1]   fast     @815ms/scheduling-1
[pool=1]   slow-start@815ms/scheduling-1
[pool=1]   slow-end  @1215ms/scheduling-1
[pool=1]   fast     @1215ms/scheduling-1
[pool=1]   fast     @1215ms/scheduling-1
[pool=1]   slow-start@1215ms/scheduling-1

This log rules out two wrong readings.

The first is that fast() got cancelled. It didn't. It was just waiting in line. There isn't a single fast event between 14 ms and 414 ms, so a test that checked the counter in that window would see fastRuns == 0. That's where the "the second method never fires" diagnosis comes from. After each slow-end, fast() runs twice in the same millisecond and then hands the thread straight to the next slow-start, which is already late.

The second is that the methods are "competing." They're actually taking turns on a single thread, and every event in the log came from scheduling-1. With fixedRate = 50, a 1.2 s window should give you a couple dozen fast() runs. The log has six. The fast method has become a passenger of the slow one.

Notice also that slow() starts again right away, at 415, 815 and 1215 ms. With a 100 ms period and a 400 ms body it's always behind, so the thread spends nearly all its time inside it. fast() only gets in through the cracks.

This isn't the @Async-called-through-this problem from the post on @Async and self-invocation. There, the interceptor never runs. Here no proxy is involved at all: the scheduler calls both methods correctly and has exactly one thread to do it with. Putting @Async on the slow method would only take the work off the scheduler if you also had @EnableAsync and the call went through the proxy. Even then, the scheduling pool would still have one thread.

With pool.size=2, the second method runs

The second test uses the same bean and the same profile. Only the property changes, and it observes for 700 ms after the first slow():

@SpringBootTest(classes = SchedApp.class,
        webEnvironment = SpringBootTest.WebEnvironment.NONE,
        properties = {
                "spring.task.scheduling.pool.size=2",
                "spring.profiles.active=slowfast"
        })
class PoolSizeTwoTest {

    @Autowired
    SlowAndFastTasks tasks;

    @Autowired
    TaskScheduler taskScheduler;

    @Test
    void secondScheduledMethodRunsWithTwoThreads() throws Exception {
        ThreadPoolTaskScheduler scheduler = (ThreadPoolTaskScheduler) taskScheduler;

        assertThat(scheduler.getScheduledThreadPoolExecutor().getCorePoolSize()).isEqualTo(2);
        assertThat(tasks.slowStarted.await(5, TimeUnit.SECONDS)).isTrue();

        Thread.sleep(700);

        System.out.println("[pool=2] corePoolSize=" + scheduler.getScheduledThreadPoolExecutor().getCorePoolSize()
                + " slowRuns=" + tasks.slowRuns.get()
                + " fastRuns=" + tasks.fastRuns.get()
                + " slowThread=" + tasks.slowThread
                + " fastThread=" + tasks.fastThread);

        assertThat(tasks.fastRuns.get()).isGreaterThanOrEqualTo(5);
        assertThat(tasks.fastThread).isNotEqualTo(tasks.slowThread);
    }
}

Output:

[pool=2] corePoolSize=2 slowRuns=2 fastRuns=15 slowThread=scheduling-1 fastThread=scheduling-2

That's 15 fast() runs in 700 ms, against 6 in 1200 ms before. While slow() sleeps on scheduling-1, fast() uses scheduling-2. The slowThread and fastThread fields hold the last thread each method ran on. Asserting that they differ proves the two weren't stuck on the same thread when the test read them.

The property to paste in tomorrow:

spring.task.scheduling.pool.size=2

Two threads fix this example because it has exactly one slow method. With three slow methods the problem comes back. It just has a bigger number on it.

fixedRate vs fixedDelay when the body outlasts the period

With the pool freed up, it's worth knowing what each mode does when the method body takes longer than the period. The @Scheduled javadoc defines fixedRate as the period between invocation starts, and fixedDelay as the period between the end of one invocation and the start of the next.

The fixture runs them side by side in the ratedelay profile, with a 250 ms body, a 100 ms period, and pool.size=2 so neither one delays the other:

@Component
@Profile("ratedelay")
public class RateVsDelayTasks {

    public final List<Long> rateStarts = Collections.synchronizedList(new ArrayList<>());
    public final List<Long> delayStarts = Collections.synchronizedList(new ArrayList<>());

    @Scheduled(fixedRate = 100)
    public void rate() {
        rateStarts.add(System.nanoTime());
        sleep(250);
    }

    @Scheduled(fixedDelay = 100)
    public void delay() {
        delayStarts.add(System.nanoTime());
        sleep(250);
    }

    private static void sleep(long millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException ex) {
            Thread.currentThread().interrupt();
        }
    }
}

The test sleeps 2800 ms, takes the median gap between starts, and asserts the difference:

@SpringBootTest(classes = SchedApp.class,
        webEnvironment = SpringBootTest.WebEnvironment.NONE,
        properties = {
                "spring.task.scheduling.pool.size=2",
                "spring.profiles.active=ratedelay"
        })
class FixedRateVsFixedDelayTest {

    @Autowired
    RateVsDelayTasks tasks;

    @Test
    void fixedRateCatchesUpAndFixedDelayWaitsForTheEnd() throws Exception {
        Thread.sleep(2800);

        List<Long> rateGaps = gapsMillis(tasks.rateStarts);
        List<Long> delayGaps = gapsMillis(tasks.delayStarts);
        long rateMedian = median(rateGaps);
        long delayMedian = median(delayGaps);

        System.out.println("[rate-vs-delay] rateStarts=" + tasks.rateStarts.size()
                + " delayStarts=" + tasks.delayStarts.size()
                + " rateMedianGapMs=" + rateMedian
                + " delayMedianGapMs=" + delayMedian);

        assertThat(rateMedian).isLessThan(300);
        assertThat(delayMedian).isGreaterThan(320);
        assertThat(tasks.rateStarts.size()).isGreaterThanOrEqualTo(tasks.delayStarts.size() + 2);
    }

    // gapsMillis(...) e median(...) são helpers simples: diferenças em ms entre inícios
    // consecutivos e o elemento do meio da lista ordenada.
}

The two helpers are trivial: one computes the gaps in ms between consecutive starts, the other returns the middle element of the sorted list.

Output:

[rate-vs-delay] rateStarts=14 delayStarts=10 rateMedianGapMs=250 delayMedianGapMs=350
modeperiodbodymedian gap between startsstarts in ~2.8 s
fixedRate = 100100 ms250 ms250 ms14
fixedDelay = 100100 ms250 ms350 ms10

With fixedRate, the next run is already overdue by the time the body finishes, so it starts immediately. The real interval becomes the body's duration, 250 ms, not the 100 ms on the annotation. fixedRate = 100 states an intention, and the clock is under no obligation to honor it. With fixedDelay, the 100 ms wait starts when the body ends, which gives 250 + 100 = 350 ms.

In practice:

  • fixedRate fits when you want to hold a cadence and the body fits comfortably inside the period. When it doesn't fit, the method holds the thread nonstop, which is exactly what slow() did to fast() in the previous section.
  • fixedDelay fits when each run must finish before the clock for the next one starts. The cadence is slower and more predictable, and the method gives the thread back to the pool between runs. In a shared pool, that pause is what makes room for the other methods.

When the heavy work shouldn't be on the scheduler at all

Raising spring.task.scheduling.pool.size is the minimum fix to get a second, lightweight method running again. It isn't permission to park batch processing on the scheduler. The scheduling thread's job is to fire on time. If it spends 400 ms inside one method, every other @Scheduled in the application inherits that delay. With fixedRate, overdue runs pile up on top of that. With N threads, all you've done is tie N heavy jobs to scheduling latency.

The pattern DevDojo would use: keep the @Scheduled method short so it only hands off the work, and run the processing on a dedicated TaskExecutor. A sketch, which was not part of the fixture:

@Configuration
class RelatorioConfig {

    @Bean
    ThreadPoolTaskExecutor relatorioExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(1);
        executor.setMaxPoolSize(1);
        executor.setQueueCapacity(1);
        executor.setThreadNamePrefix("relatorio-");
        return executor;
    }
}

@Component
class RelatorioTrigger {

    private final ThreadPoolTaskExecutor relatorioExecutor;
    private final RelatorioService relatorioService;

    RelatorioTrigger(ThreadPoolTaskExecutor relatorioExecutor, RelatorioService relatorioService) {
        this.relatorioExecutor = relatorioExecutor;
        this.relatorioService = relatorioService;
    }

    @Scheduled(fixedDelay = 60_000)
    public void disparar() {
        relatorioExecutor.execute(relatorioService::gerar);
    }
}

The scheduled method gives the scheduling- thread back in microseconds, and the report runs on relatorio-1. With a queue capacity of 1, a trigger that finds the executor busy and the queue full is rejected with TaskRejectedException, and the exception surfaces inside the @Scheduled method itself. That beats a queue that keeps growing while nobody notices. Before you declare your own Executor in the context, check the same task execution and scheduling page for how that affects the executor Boot auto-configures for the rest of the application.

Watching it in production

The cheapest signal is the thread name. Boot's default log pattern already prints the thread in brackets. If your log shows two different @Scheduled methods always on scheduling-1, you have your diagnosis. Hardly anyone reads that column, and it's often the only evidence you need.

To confirm the pool size, do what the tests do: cast the injected TaskScheduler to ThreadPoolTaskScheduler and read getScheduledThreadPoolExecutor().getCorePoolSize(). Logging that value at startup costs you one line. If the project has Actuator, the scheduledtasks endpoint lists the registered tasks and their triggers. The fixture didn't use that endpoint.

Virtual threads change the rules

This fixture ran on platform threads. According to the Boot 4.1 docs, with spring.threads.virtual.enabled=true the auto-configured scheduler becomes a SimpleAsyncTaskScheduler, and the pooling properties are ignored. In that mode spring.task.scheduling.pool.size=2 does nothing, and the @Scheduled javadoc notes that fixedDelay runs on a single scheduler thread. The numbers in this post don't apply to that mode, and the fixture didn't test it.

Next steps

  1. List the @Scheduled methods in your project (grep -rn "@Scheduled" src/main/java) and write down how long each one actually takes, not how long it's supposed to take.
  2. If more than one method shares the default pool, set spring.task.scheduling.pool.size in application.properties to a deliberate value, and write a test like PoolSizeTwoTest that asserts getCorePoolSize() and distinct threads.
  3. For every method whose body outlasts its period, decide whether to swap fixedRate for fixedDelay or move the work to a dedicated executor. If it does I/O or batch work, move it.
javaspring-boot

// Continue.training — 次のステップ

Knowledge only counts when it becomes practice.

Go back to the article, run the examples, and share what you learned.

Explore more articles