O cenário é este: uma aplicação tem @EnableScheduling e dois métodos @Scheduled. Um deles faz um trabalho pesado e demora mais que o próprio intervalo. O outro só incrementa um contador a cada 50 ms. Você escreve um teste, espera o método lento começar, olha o contador do rápido e ele está em zero.
Diante disso, quase todo mundo chega a uma de duas conclusões: o agendamento quebrou, ou falta um @Async. Nenhuma das duas está certa. O scheduler está funcionando. Ele só tem uma thread, e essa thread está ocupada dormindo dentro do método lento.
No fim do texto você vai ter uma fixture Maven sem web com três testes. O primeiro mostra os dois métodos serializados em scheduling-1. O segundo mostra o método rápido rodando em scheduling-2 depois de uma mudança de propriedade. O terceiro mede a diferença entre fixedRate e fixedDelay quando o corpo do método dura mais que o período. Também fica claro onde aumentar o pool deixa de ser a resposta.
O que rodou e com quais versões
A fixture rodou em 28/10/2026 com Spring Boot 4.1.2, Temurin 25.0.4 LTS (25.0.4+7-LTS) e Maven 3.9.12, com java.version=25 no POM. A página de requisitos do Spring Boot 4.1 diz que o 4.1.2 exige pelo menos Java 17, é compatível até o Java 27 e precisa do Spring Framework 7.0.10 ou superior. O JDK 25 está dentro da matriz.
O mvn -q test passou com Tests run: 3, Failures: 0, Errors: 0. As classes são PoolSizeOneTest, PoolSizeTwoTest e FixedRateVsFixedDelayTest. Não há servidor web nem Actuator, e o contexto sobe com webEnvironment = NONE.
Se você está vindo do Boot 3.x, a propriedade que aparece aqui já existia lá. O que pega de surpresa é o default, e não alguma API nova.
O default é uma thread
A documentação de task execution and scheduling do Boot 4.1 diz que, na ausência de um scheduler seu, o Boot auto-configura um ThreadPoolTaskScheduler com uma thread. O controle é spring.task.scheduling.pool.size, e as threads recebem o prefixo scheduling-, que pode ser trocado com spring.task.scheduling.thread-name-prefix.
Uma thread só para todos os @Scheduled da aplicação. Não é uma por método e não é uma por bean. Colocar os dois métodos em classes separadas não muda nada, porque os dois continuam sendo disparados pelo mesmo pool.
A fixture
O POM, sem o cabeçalho:
<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>
A aplicação:
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);
}
}
Os dois métodos ficam no mesmo bean, ativado pelo profile slowfast. O slow() tem período de 100 ms e dorme 400 ms. O fast() tem período de 50 ms e não dorme:
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();
}
}
}
O primeiro teste fixa spring.task.scheduling.pool.size=1, que é o próprio default. Fica explícito para que o teste não dependa de alguém lembrar o default. Ele espera o primeiro slow() começar, observa por 1200 ms e imprime o log de eventos:
@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);
}
}
O que o log mostrou com uma 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
Esse log desfaz duas leituras erradas.
A primeira é achar que o fast() foi cancelado. Ele não foi, só ficou na fila. Entre 14 ms e 414 ms não existe nenhum evento fast, então um teste que olhasse o contador nessa janela veria fastRuns == 0. É daí que sai o diagnóstico de "o segundo método não dispara". Depois de cada slow-end, o fast() roda duas vezes no mesmo milissegundo e logo devolve a thread para o próximo slow-start, que já está atrasado.
A segunda é achar que os métodos estão "competindo". Na verdade eles se revezam numa thread só, e todos os eventos do log saíram de scheduling-1. Com fixedRate = 50, uma janela de 1,2 s daria umas duas dezenas de execuções do fast(). O log tem seis. O método rápido virou passageiro do lento.
Repare também que o slow() recomeça logo em seguida, em 415, 815 e 1215 ms. Com período de 100 ms e corpo de 400 ms, ele já está sempre atrasado, e a thread passa quase todo o tempo dentro dele. O fast() só entra nas brechas.
Isso não é o problema do @Async chamado pelo this, que está no post sobre @Async e self-invocation. Lá o interceptor não roda. Aqui não há proxy nenhum envolvido: o scheduler chama os dois métodos corretamente e só tem uma thread para isso. Colocar @Async no método lento só tiraria o trabalho do scheduler se você também tivesse @EnableAsync e a chamada passasse pelo proxy. Mesmo assim, o pool de scheduling continuaria com uma thread.
Com pool.size=2, o segundo método roda
O segundo teste usa o mesmo bean e o mesmo profile. Só a propriedade muda, e a observação é de 700 ms depois do primeiro 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);
}
}
Saída:
[pool=2] corePoolSize=2 slowRuns=2 fastRuns=15 slowThread=scheduling-1 fastThread=scheduling-2
Em 700 ms foram 15 execuções do fast(), contra 6 em 1200 ms no cenário anterior. Enquanto o slow() dorme em scheduling-1, o fast() usa scheduling-2. Os campos slowThread e fastThread guardam a última thread que cada método usou. A asserção de que são diferentes prova que, no momento da leitura, os dois não estavam presos à mesma thread.
A propriedade para colar amanhã:
spring.task.scheduling.pool.size=2
Duas threads resolvem este exemplo porque ele tem exatamente um método lento. Se você tiver três métodos lentos, o problema volta, só que com um número maior.
fixedRate e fixedDelay quando o corpo passa do período
Com o pool liberado, vale entender o que cada modo faz quando o corpo do método dura mais que o período. O javadoc de @Scheduled define fixedRate como o período entre os inícios das invocações e fixedDelay como o período entre o fim de uma invocação e o início da próxima.
A fixture coloca os dois lado a lado no profile ratedelay, com corpo de 250 ms, período de 100 ms e pool.size=2 para que um não atrase o outro:
@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();
}
}
}
O teste dorme 2800 ms, calcula a mediana dos intervalos entre inícios e afirma a diferença:
@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.
}
Saída:
[rate-vs-delay] rateStarts=14 delayStarts=10 rateMedianGapMs=250 delayMedianGapMs=350
| modo | período | corpo | mediana entre inícios | inícios em ~2,8 s |
|---|---|---|---|---|
fixedRate = 100 | 100 ms | 250 ms | 250 ms | 14 |
fixedDelay = 100 | 100 ms | 250 ms | 350 ms | 10 |
No fixedRate, a próxima execução já está vencida quando o corpo termina, e por isso começa na hora. O intervalo real vira a duração do corpo, 250 ms, e não os 100 ms anotados. fixedRate = 100 declara uma intenção, e o relógio não tem obrigação de cumpri-la. No fixedDelay, a espera de 100 ms começa no fim do corpo, o que dá 250 + 100 = 350 ms.
Na prática:
fixedRateserve quando você quer manter a cadência e o corpo cabe com folga no período. Quando ele não cabe, o método passa a ocupar a thread sem pausa, que é exatamente o que oslow()fez com ofast()na seção anterior.fixedDelayserve quando cada execução deve terminar antes de a próxima começar a contar. A cadência fica mais lenta e previsível, e o método devolve a thread ao pool entre uma execução e outra. Num pool compartilhado, é essa pausa que dá espaço para os outros métodos.
Quando o trabalho pesado não deveria estar no scheduler
Aumentar spring.task.scheduling.pool.size é a correção mínima para que um segundo método leve volte a rodar. Não é autorização para estacionar processamento em lote no scheduler. A função da thread de scheduling é disparar no horário. Se ela passa 400 ms dentro de um método, todos os outros @Scheduled da aplicação herdam esse atraso. Com fixedRate, as execuções vencidas ainda se acumulam. Com N threads, você só passou a ter N trabalhos pesados atrelados à latência do agendamento.
O padrão que a DevDojo adotaria: o @Scheduled fica curto e só entrega o trabalho, e o processamento roda num TaskExecutor próprio. Um esboço, que não fez parte da 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);
}
}
O método agendado devolve a thread de scheduling- em microssegundos, e o relatório roda em relatorio-1. Com fila de capacidade 1, um disparo que encontra o executor ocupado e a fila cheia é rejeitado com TaskRejectedException, e a exceção aparece dentro do próprio @Scheduled. Isso é melhor do que a fila crescer sem que ninguém perceba. Antes de declarar um Executor seu no contexto, confira na mesma página de task execution and scheduling como isso afeta o executor que o Boot auto-configura para o resto da aplicação.
Observar em produção
A forma mais barata é o nome da thread. O padrão de log do Boot já imprime a thread entre colchetes. Se o seu log mostra dois @Scheduled diferentes sempre em scheduling-1, o diagnóstico está pronto. Quase ninguém olha essa coluna, e ela costuma ser a única evidência de que você precisa.
Para confirmar o tamanho do pool, faça o que os testes fazem: converta o TaskScheduler injetado para ThreadPoolTaskScheduler e leia getScheduledThreadPoolExecutor().getCorePoolSize(). Registrar esse valor no startup custa uma linha. Se o projeto tiver Actuator, o endpoint scheduledtasks lista as tarefas registradas e seus triggers. A fixture não usou esse endpoint.
Virtual threads mudam as regras
Esta fixture rodou com platform threads. Segundo a documentação do Boot 4.1, com spring.threads.virtual.enabled=true o scheduler auto-configurado passa a ser um SimpleAsyncTaskScheduler, e as propriedades de pool são ignoradas. Nesse caso, spring.task.scheduling.pool.size=2 deixa de valer, e o javadoc de @Scheduled avisa que fixedDelay roda numa única thread de scheduler. Os números deste texto não se aplicam a esse modo, e a fixture não o testou.
Próximo passo
- Liste os
@Scheduleddo projeto (grep -rn "@Scheduled" src/main/java) e anote quanto cada um dura de verdade, não quanto deveria durar. - Se mais de um método divide o pool default, coloque
spring.task.scheduling.pool.sizenoapplication.propertiescom um valor consciente e escreva um teste como oPoolSizeTwoTest, que afirmagetCorePoolSize()e threads diferentes. - Para todo método cujo corpo passa do período, decida entre trocar
fixedRateporfixedDelaye mover o trabalho para um executor próprio. Se ele faz I/O ou lote, mova.