Todos os artigos

// Knowledge.log — 技術記事

Fila sem limite na frente da chamada lenta: quem decide o tamanho é a GC

LinkedBlockingQueue sem capacidade aceita tudo até a GC decidir o limite. Veja ArrayBlockingQueue, AbortPolicy e CallerRunsPolicy com teste JVM.

Uma dependência lenta degrada, as chamadas continuam chegando e alguém, em algum commit antigo, criou um ExecutorService com fila em memória para absorver o excesso. Ninguém decidiu explicitamente quantos itens essa fila aguenta. Ela simplesmente aceita. Continua aceitando enquanto a JVM tiver memória para criar mais nós de lista ligada. Quem acaba decidindo o tamanho real da fila não é nenhuma configuração do time — é o coletor de lixo, no dia em que ele não consegue mais achar espaço.

Este post mostra o mecanismo por trás disso com JDK puro: java.util.concurrent, sem biblioteca de resiliência. Todo número abaixo saiu de um teste executado neste host com Java 25.0.4 (Temurin), Maven 3.9.12, JUnit Jupiter 6.1.3 via junit-bom, maven-surefire-plugin 3.5.6, maven-compiler-plugin 3.16.0 e maven.compiler.release=25. A JUnit User Guide 6.1.3 exige Java 17 ou superior em runtime — 25 está dentro dessa faixa, então não há rota de upgrade a documentar aqui.

A fila sem limite aceita tudo — esse é o problema

O controle "ruim" do experimento é um ThreadPoolExecutor com new LinkedBlockingQueue<>() sem argumento:

public static ThreadPoolExecutor unboundedLinked(int poolSize) {
    return new ThreadPoolExecutor(
            poolSize,
            poolSize,
            0L,
            TimeUnit.MILLISECONDS,
            new LinkedBlockingQueue<>());
}

A documentação do LinkedBlockingQueue é direta: é uma fila "opcionalmente limitada", e a capacidade, se não especificada, é igual a Integer.MAX_VALUE. O construtor sem argumento cria a fila com essa capacidade. Nós são criados dinamicamente a cada inserção, a menos que isso ultrapasse a capacidade — e Integer.MAX_VALUE é uma capacidade que, na prática, nunca é ultrapassada antes de a memória acabar.

A documentação do ThreadPoolExecutor descreve exatamente esse comportamento na seção de enfileiramento: usar uma fila sem limite faz com que novas tarefas esperem na fila quando todas as corePoolSize threads estão ocupadas — nenhuma thread além de corePoolSize chega a ser criada, e maximumPoolSize deixa de ter qualquer efeito. O texto admite explicitamente a possibilidade de crescimento sem limite da fila de trabalho quando comandos continuam chegando, em média, mais rápido do que conseguem ser processados.

O teste comprova isso com um contador, não com um dump de heap. Com pool de tamanho 1 e um worker bloqueado num CountDownLatch, 16 submissões via execute foram todas aceitas:

accepted=16 rejected=0 queueSize=15 remainingCapacity=2147483632

2147483647 - 15 = 2147483632: as 15 tarefas na fila estão sentadas sobre uma capacidade de Integer.MAX_VALUE, não sobre um limite pequeno que alguém escolheu. Uma das 16 tarefas é a que o worker já pegou para executar e não está na fila.

O mesmo acontece com Executors.newFixedThreadPool(poolSize). O javadoc público diz que o método cria um pool reutilizando "uma fila compartilhada sem limite" e que tarefas extras esperarão na fila até que uma thread esteja disponível. Ele não menciona LinkedBlockingQueue em nenhum lugar do texto. Quem quer saber qual classe concreta está por trás disso precisa olhar a implementação — neste Temurin 25.0.4, é a mesma LinkedBlockingQueue sem capacidade, confirmada tanto na fonte do JDK quanto no teste:

accepted=16 rejected=0 queueClass=java.util.concurrent.LinkedBlockingQueue queueSize=15 remainingCapacity=2147483632

Mesmo comportamento, mesma classe, mesmo remainingCapacity absurdo. newFixedThreadPool não é uma alternativa mais segura ao LinkedBlockingQueue manual — é o mesmo mecanismo com um nome mais amigável. A fila que ninguém dimensionou continua sem dimensão nenhuma, só que agora escondida atrás de um nome de fábrica que soa responsável.

ArrayBlockingQueue + RejectedExecutionHandler: alguém tem que dizer não

A troca central é simples: trocar a fila sem limite por uma com limite fixo, e dar ao executor uma resposta explícita para quando ela enche.

public static ThreadPoolExecutor bounded(
        int poolSize,
        int queueCapacity,
        RejectedExecutionHandler handler) {
    return new ThreadPoolExecutor(
            poolSize,
            poolSize,
            0L,
            TimeUnit.MILLISECONDS,
            new ArrayBlockingQueue<>(queueCapacity),
            handler);
}

O ArrayBlockingQueue é descrito como uma fila bloqueante limitada, apoiada em um array — um buffer clássico limitado. Uma vez criada, a capacidade não muda. Isso já é a diferença de postura em relação ao LinkedBlockingQueue: aqui alguém decidiu o número, e o número não vai se mover sozinho.

Vale um detalhe que costuma confundir: o javadoc do ArrayBlockingQueue descreve put como a operação que insere um elemento esperando por espaço disponível se a fila estiver cheia — ou seja, bloqueia o chamador. Mas o ThreadPoolExecutor não chama put. Ele chama offer, que insere o elemento na cauda da fila se for possível fazer isso imediatamente sem exceder a capacidade, retornando true em caso de sucesso e false se a fila estiver cheia. Numa fila cheia, offer retorna false na hora — sem bloquear — e é esse false que faz o executor acionar o RejectedExecutionHandler.

A regra de enfileiramento do ThreadPoolExecutor, para pool fixo (core igual a maximum): com menos threads que corePoolSize rodando, prefere criar thread nova; com core ou mais threads rodando, prefere a fila; se a tarefa não couber na fila e não houver espaço para nova thread até maximumPoolSize, ela é rejeitada. Com poolSize == maximumPoolSize, não existe a etapa intermediária de "criar mais uma thread" — pool cheio mais fila cheia é rejeição direta.

AbortPolicy vs. CallerRunsPolicy: duas respostas para o mesmo "não cabe"

Dizer "rejeita" não é uma decisão de design completa. O ThreadPoolExecutor documenta várias formas de rejeitar, e as duas mais usadas se comportam de jeitos opostos.

ThreadPoolExecutor.AbortPolicy é o handler padrão. O javadoc é econômico: lança uma RejectedExecutionException. No teste, com pool 2 e fila com capacidade 2, quatro tarefas bloqueantes ocupam tudo (2 threads ativas, 2 na fila) e a quinta submissão estoura:

accepted=4 rejected=1 exception=java.util.concurrent.RejectedExecutionException

O toString do executor, capturado dentro da própria mensagem da exceção, mostra Running, pool size = 2, active threads = 2, queued tasks = 2, completed tasks = 0. Depois do lançamento: queueSize=2 remainingCapacity=0 poolSize=2 handler=java.util.concurrent.ThreadPoolExecutor$AbortPolicy. A exceção sobe para quem chamou execute — a quinta tarefa nunca roda.

ThreadPoolExecutor.CallerRunsPolicy faz o oposto: a thread que invoca execute executa a tarefa ela mesma, a menos que o executor já tenha sido desligado — nesse caso, a tarefa é descartada. O javadoc chama isso de um mecanismo simples de controle de feedback, que reduz a taxa de novas submissões. No mesmo cenário — pool 2, fila 2, quatro bloqueadores aceitos — a quinta tarefa não é rejeitada com exceção: ela roda ali mesmo, na thread chamadora.

acceptedBeforeExtra=4 callerThread=main runnerThread=main sameThread=true

callerThread e runnerThread são o mesmo nome de thread. O execute só retorna depois que essa tarefa extra já rodou — na prática, quem chamou execute virou, por um instante, um worker do próprio pool.

Essa cortesia tem um preço, e é o tipo de detalhe que só aparece quando alguém já caiu nele: se a tarefa rejeitada esperasse no mesmo CountDownLatch que os workers bloqueados, CallerRunsPolicy travaria o chamador dentro do próprio execute, esperando um sinal que só os workers do pool — agora todos ocupados — poderiam dar. A política que existe para desacelerar o produtor acaba enforcando o produtor. No teste, a tarefa extra só grava o nome da thread e devolve — não espera o latch, então não reproduz esse deadlock; fica como risco de desenho, não como resultado observado aqui.

O caso de desligamento — CallerRunsPolicy descartando a tarefa porque o executor já foi encerrado — não foi exercitado neste experimento; fica registrado como comportamento documentado, não como número medido.

O teste que satura a fila de propósito

Provar saturação sem Thread.sleep exige controlar exatamente quando cada worker libera a vez. A saída aqui é um CountDownLatch compartilhado: cada tarefa "lenta" apenas espera nesse latch.

private static Runnable blocker(CountDownLatch release) {
    return () -> {
        try {
            release.await();
        } catch (InterruptedException interrupted) {
            Thread.currentThread().interrupt();
        }
    };
}

O padrão de preenchimento é sempre o mesmo: submeter poolSize + queueCapacity bloqueadores — o suficiente para ocupar todas as threads e toda a fila — e então fazer mais uma submissão, que é a que testa o comportamento de rejeição:

int accepted = submitBlockers(executor, release, poolSize + queueCapacity);
RejectedExecutionException rejected = assertThrows(
        RejectedExecutionException.class,
        () -> executor.execute(blocker(release)));

No finally de cada teste, o latch é liberado e o executor recebe shutdownNow:

private static void release(CountDownLatch release, ExecutorService executor) throws InterruptedException {
    release.countDown();
    executor.shutdownNow();
    executor.awaitTermination(5, TimeUnit.SECONDS);
}

Sem esse finally, as threads do pool — não-daemon por padrão — ficam presas esperando o latch para sempre, e a JVM não desliga sozinha. awaitTermination ali é limpeza de teste, não a prova de nada; a prova é o contador de aceitos/rejeitados e o estado da fila, lidos com os workers ainda bloqueados.

Rodando mvn -B test no projeto de evidência, os quatro testes passam:

Tests run: 4, Failures: 0, Errors: 0, Skipped: 0

Saída de processo 0. unboundedLinkedQueueAcceptsEverySubmittedTask, fixedThreadPoolFactoryAlsoAcceptsEverySubmittedTask, abortPolicySurfacesRejectedExecutionException e callerRunsPolicyRunsRejectedTaskOnCallerThread — a documentação do JUnit 6.1.3 não muda o comportamento aqui, só garante que o runtime mínimo (Java 17) está coberto pelo Java 25 usado no experimento.

Isso não é circuit breaker

Vale dizer sem rodeio: bound de fila não é circuit breaker. Não existe máquina de estados aberto/meio-aberto aqui, nem chamada de sondagem para decidir quando voltar a aceitar tráfego. O que existe é capacidade fixa: quando um worker termina, uma vaga na fila libera, e é só isso — não há decisão sobre se a dependência já melhorou o suficiente para tentar de novo.

O post bulkhead do Resilience4j trata de um problema vizinho: isolar duas dependências nomeadas entre si, cada uma com seu próprio semáforo ou pool de threads, para que uma dependência lenta não consuma a capacidade reservada para a outra. Este post aqui limita uma única fila do JDK na frente de uma única chamada lenta, sem framework — é o mecanismo por baixo, para quem não pode ou não quer adicionar Resilience4j, ou só quer entender o que a fábrica de executores faz de verdade antes de embrulhar isso numa lib.

Quando limitar a fila, e quando usar outra ferramenta

Toda vez que um ExecutorService for criado na frente de uma dependência que pode ficar lenta — banco, fila externa, chamada HTTP síncrona — a fila de trabalho merece uma decisão explícita, não o valor padrão de uma fábrica de conveniência. Isso vale tanto para Executors.newFixedThreadPool quanto para qualquer new LinkedBlockingQueue<>() escrito à mão sem pensar na capacidade.

A correção concreta para cada um dos problemas diagnosticados aqui:

  • Fila sem limite crescendo: trocar por ArrayBlockingQueue com capacidade explícita e um RejectedExecutionHandlerAbortPolicy quando o chamador deve saber na hora que foi rejeitado e decidir o que fazer (fallback, retry com backoff em outra camada, erro para o cliente); CallerRunsPolicy quando o chamador pode absorver a lentidão diretamente, e só se a tarefa rejeitada não depender de recurso que os workers do próprio pool também disputam — senão é o deadlock descrito acima.
  • Observabilidade em produção: os campos que o próprio ThreadPoolExecutor já expõe — getPoolSize(), getActiveCount(), getQueue().size(), getCompletedTaskCount() — são o material bruto para qualquer log ou métrica de saturação. Não é preciso inventar contador novo; é preciso ler o que o executor já calcula e expor isso, ainda que comece só como log estruturado antes de virar métrica de verdade.

Dito isso, bound de fila resolve um problema específico: capacidade de admissão fixa em memória. Quando o objetivo é outro — parar de bater numa dependência que está consistentemente falhando, dar tempo para ela se recuperar, ou isolar duas dependências independentes uma da outra —, a ferramenta certa é diferente. Um circuit breaker com estado aberto/meio-aberto serve para decidir quando parar de tentar. Um bulkhead como o do Resilience4j serve para impedir que uma dependência lenta consuma toda a capacidade que outra dependência também precisa. Bound de fila com ArrayBlockingQueue não decide nada disso — só diz, com um número fixo, quanto trabalho em espera é aceitável antes de dizer não. Às vezes é exatamente o que falta; às vezes é só a metade do problema.

javaarchitecture

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

Conhecimento só conta quando vira prática.

Volte ao artigo, execute os exemplos e compartilhe o que aprendeu.

Explorar mais artigos