ThreadLocal: Estado por Thread, Vazamentos e InheritableThreadLocal
Objective
ThreadLocal<T> (java.lang.ThreadLocal) fornece uma variável isolada por thread: toda thread que chama get() ou set() na mesma instância de ThreadLocal enxerga e modifica sua própria cópia independente, invisível para todas as outras threads. É declarada uma vez, compartilhada, e idiomaticamente static final — a instância em si nunca guarda um valor; ela é uma chave que cada thread resolve contra seu próprio armazenamento privado. Essa isolação por thread é exatamente o que a torna perigosa de esquecer: em uma thread de pool que sobrevive a qualquer tarefa individual, um valor definido e nunca removido continua vivo no armazenamento daquela thread indefinidamente. ScopedValue (veja o conceito scoped-values) foi finalizado no Java 25 especificamente para substituir o caso de uso comum de contexto de requisição por uma alternativa imutável e com limpeza automática — mas ThreadLocal e sua subclasse InheritableThreadLocal continuam amplamente usados no ecossistema (MDCs de logging, vinculação de conexões JDBC, contextos de segurança) e vale a pena entendê-los em seus próprios termos.
Use Cases
- Carregar um pedaço de contexto por thread — um ID de correlação de requisição, a transação atual, uma instância de
SimpleDateFormat— através de código que não o recebe como parâmetro. - Dar suporte ao contexto de diagnóstico de frameworks de logging (o MDC do SLF4J/Logback é baseado em thread-local internamente; veja o conceito
logging-in-javapara a API do MDC em si) para que uma linha de log emitida no fundo de uma call stack ainda carregue o ID de requisição definido no ponto de entrada. - Dar a cada thread seu próprio objeto auxiliar não thread-safe (formatadores, geradores de números aleatórios, buffers) em vez de sincronizar o acesso a uma única instância compartilhada.
- Propagar um valor automaticamente para threads worker geradas por uma thread pai, via
InheritableThreadLocal, sem passá-lo por todo construtor e chamada de método pelo caminho.
Deep Dive
Declarando e usando um ThreadLocal: uma instância, várias cópias independentes
javapublic class RequestContext {
// static final — uma única chave compartilhada para o programa inteiro
static final ThreadLocal<String> CURRENT_USER = ThreadLocal.withInitial(() -> "anonymous");
}
// Thread A
RequestContext.CURRENT_USER.set("alice");
System.out.println(RequestContext.CURRENT_USER.get()); // "alice"
// Thread B, rodando concorrentemente
System.out.println(RequestContext.CURRENT_USER.get()); // "anonymous" — sua própria cópia, intocada pela Thread AAmbas as threads chamam get()/set() no exato mesmo objeto CURRENT_USER, mas nunca observam o valor uma da outra. withInitial(Supplier<T>) fornece o valor que uma thread enxerga na primeira vez que chama get() sem ter chamado set() antes; um simples new ThreadLocal<>() faz essa primeira leitura ser null por padrão.
O mecanismo: cada Thread possui um ThreadLocalMap, indexado pelo próprio ThreadLocal
A isolação não é mágica — é um hash map. Todo objeto Thread carrega um campo interno ThreadLocalMap. Chamar threadLocal.set(value) não armazena nada dentro da instância de ThreadLocal; ela busca o mapa da própria Thread.currentThread() e armazena o valor lá, indexado pela própria instância de ThreadLocal (via System.identityHashCode, não equals/hashCode):
java// conceitualmente, o que set() faz:
Thread t = Thread.currentThread();
t.threadLocalMap.put(this /* a instância do ThreadLocal */, value);
// e o que get() faz:
Thread t = Thread.currentThread();
Object value = t.threadLocalMap.get(this);É por isso que um único static final ThreadLocal compartilhado nunca colide entre threads: existe exatamente um mapa por thread, e a instância de ThreadLocal é apenas uma chave de busca no mapa da thread que estiver perguntando. Threads diferentes, mapas diferentes, mesmo objeto-chave — nenhuma célula mutável compartilhada em lugar nenhum.
O vazamento de memória: threads de pool nunca soltam o que carregavam
Um ExecutorService reutiliza o mesmo conjunto fixo de threads em várias tarefas — uma thread nunca morre entre tarefas, então seu ThreadLocalMap também nunca é coletado pelo garbage collector entre tarefas. Um valor definido com set() e nunca removido com remove() fica preso àquela thread de pool, invisível e sem uso, até que algo o sobrescreva ou a própria thread eventualmente termine:
javastatic final ThreadLocal<byte[]> BUFFER = new ThreadLocal<>();
ExecutorService pool = Executors.newFixedThreadPool(4);
for (int i = 0; i < 1000; i++) {
pool.submit(() -> {
BUFFER.set(new byte[1_000_000]); // 1 MB, "só para esta tarefa"
process(BUFFER.get());
// sem remove() — o array continua alcançável através do
// ThreadLocalMap da thread de pool muito depois desta tarefa retornar
});
}Com apenas 4 threads no pool, esse loop parece que deveria segurar no máximo 4 MB de cada vez — mas cada uma das 4 threads fica sobrescrevendo sua própria entrada no mapa a cada tarefa que pega, então o vazamento aqui é realmente "um array obsoleto de 1 MB por thread, substituído a cada execução" em vez de 1000 arrays se acumulando; o perigo é mais grave quando tarefas diferentes usam chaves ThreadLocal diferentes ou pulam condicionalmente o set(), deixando entradas antigas sem uma escrita posterior para substituí-las. De qualquer forma, o objeto permanece alcançável enquanto a thread viver e nada remover a entrada — invisível para o código que submeteu a tarefa, e não é algo que um profiler rotulado "tarefa X" vai apontar, porque a tarefa X já terminou.
A correção é a limpeza incondicional em um bloco finally ao redor do código que chama set():
javapool.submit(() -> {
BUFFER.set(new byte[1_000_000]);
try {
process(BUFFER.get());
} finally {
BUFFER.remove(); // desvincula do ThreadLocalMap desta thread de pool
}
});remove() apaga a entrada do mapa da thread atual completamente — não apenas a redefine para o valor inicial — então o objeto referenciado se torna elegível para coleta de lixo assim que nada mais o segurar, e a próxima tarefa naquela thread começa do zero.
InheritableThreadLocal: copiado no momento da criação do filho, não uma visão ao vivo
InheritableThreadLocal<T> estende ThreadLocal<T> e adiciona um comportamento: quando uma thread cria uma nova Thread, o ThreadLocalMap da filha é inicializado com uma cópia de todo valor InheritableThreadLocal que a pai tinha vinculado naquele momento:
javastatic final InheritableThreadLocal<String> TRACE_ID = new InheritableThreadLocal<>();
TRACE_ID.set("trace-abc");
Thread child = new Thread(() -> {
System.out.println(TRACE_ID.get()); // "trace-abc" — copiado quando esta Thread foi criada
});
child.start();A cópia acontece uma única vez, dentro da chamada da thread pai a new Thread(...) — não é um vínculo ao vivo de volta para a pai. Um valor que a pai define depois que a filha já existe nunca chega até ela:
javastatic final InheritableThreadLocal<String> TRACE_ID = new InheritableThreadLocal<>();
TRACE_ID.set("trace-abc");
Thread child = new Thread(() -> {
sleep(100);
System.out.println(TRACE_ID.get()); // ainda "trace-abc" — não o valor definido abaixo
});
child.start();
TRACE_ID.set("trace-xyz"); // o valor da própria pai muda; a filha já tem sua cópiaDuas consequências decorrem de "cópia na criação": as threads worker de um pool são criadas uma vez e reutilizadas para muitas tarefas, então um InheritableThreadLocal vinculado na thread submissora é copiado para o mapa de uma worker apenas na primeira vez que essa worker é gerada — tarefas posteriores submetidas por outros chamadores não recebem uma cópia nova, porque nenhuma Thread nova está sendo criada por tarefa. E por padrão a cópia é uma simples cópia de referência (childValue() retorna o valor da pai sem alteração), então um valor mutável compartilhado dessa forma é o mesmo objeto tanto na pai quanto na filha, sem nenhuma sincronização entre elas — sobrescrever childValue(T parentValue) é como uma subclasse pode fazer uma cópia profunda ou transformá-lo em vez disso.
Trade-offs
Vaza na reutilização, não em threads de execução única. Um
ThreadLocaldeixado semremove()em umaThreadque roda uma tarefa e morre é inofensivo — aThreadinteira, mapa incluído, vira lixo. O risco é específico de threads worker de vida longa, que é exatamente o que todo servidor baseado em executor usa.remove()é responsabilidade de quem chama, sempre, em todo caminho. Nada obriga isso — uma exceção lançada antes deremove()ser alcançado em código sem proteção pula a limpeza por completo.javaBUFFER.set(data); process(data); // lança exceção BUFFER.remove(); // nunca alcançado — precisa estar em um finallyInheritableThreadLocalsó dispara emnew Thread(...), não emExecutorService.submit(...)para um pool já em execução. Código que espera que o contexto "simplesmente flua" para tarefas submetidas, da mesma forma que flui para uma thread filha criada manualmente, vai descobrir que ele simplesmente não chega, porque a thread worker já existia antes do valor ser definido.Leituras obsoletas parecem leituras corretas.
get()retornandonullou um valor inicial é indistinguível de "nada foi definido nesta thread" — um valor vazado ou obsoleto de uma tarefa anterior na mesma thread de pool retorna de forma tão limpa quanto um valor novo, sem nenhuma exceção para sinalizar o erro.ScopedValueelimina toda essa classe de vazamento por construção onde se aplica — veja o conceitoscoped-valuespara a alternativa imutável e com desmontagem automática; ele cobre propagação de contexto de requisição e fan-out de concorrência estruturada, os dois casos para os quaisThreadLocal/InheritableThreadLocalsão mais buscados hoje.