Deep dive em concorrência e paralelismo na JVM — do Java Memory Model e happens-before até Virtual Threads e Structured Concurrency. Uma das áreas mais cobradas em entrevistas senior de Java, e uma das mais mal compreendidas. Para fundamentos gerais de Java, ver Java Fundamentals.
O que é
Concorrência em Java é a arte de escrever código que múltiplas threads podem executar simultaneamente sem produzir resultados incorretos ou inesperados. Envolve três conceitos centrais:
Visibilidade — uma thread ver escritas feitas por outras
Ordenação — garantir que operações aconteçam na ordem esperada
Atomicidade — operações compostas tratadas como unidade indivisível
Em entrevistas, o que diferencia um senior em concorrência:
Entender o Java Memory Model (JMM) — volatile, happens-before, e por que synchronized funciona
Saber quando usar synchronized vs Lock vs atômicos vs Concurrent* collections
Conhecer CompletableFuture — composição de operações assíncronas
Thread Java mapeia 1:1 para thread do OS. Cada thread custa ~1 MB de stack + estruturas no kernel. Limite prático: ~5.000-10.000 threads por JVM.
Thread t = new Thread(() -> { System.out.println("Running in " + Thread.currentThread().getName());});t.setName("worker-1");t.setDaemon(true); // thread daemon não impede JVM de sairt.start();// Esperar thread completart.join();// Interrompert.interrupt(); // seta flag; thread deve checar
Raramente criamos Thread diretamente em código moderno. Use ExecutorService ou Virtual Threads.
Muitas threads em BLOCKED no mesmo monitor → contention
Threads em WAITING em I/O → pool mal dimensionado
Threads em RUNNABLE com stacks CPU-intensivas → CPU saturada
Java Memory Model (JMM)
O JMM define o que uma thread pode ver quando lê uma variável modificada por outra thread. É o fundamento invisível de toda concorrência correta em Java.
O problema: reordering e visibility
Compiladores, JIT e CPUs reordenam instruções para performance. Em single-threaded isso é invisível, mas em multi-thread pode quebrar código aparentemente correto:
Cache de CPU pode manter (a) em cache local sem flush
Thread 2 pode ver flag=true e x=0
Happens-Before: a relação fundamental
Happens-before é uma relação entre operações. Se A happens-before B, então o efeito de A é visível para B, e A ocorreu antes de B na ordem de sincronização.
Relações happens-before definidas pelo JMM:
Program order — dentro de uma thread, cada operação happens-before a próxima
Monitor lock — unlock de um monitor happens-before qualquer lock subsequente do mesmo monitor
Volatile — write em volatile happens-before qualquer read subsequente da mesma variável
Thread start — Thread.start() happens-before qualquer ação na thread iniciada
Thread termination — qualquer ação em uma thread happens-before detecção de término via join()
Interruption — interrupt happens-before detecção via isInterrupted() ou InterruptedException
Final fields — término do construtor happens-before publicação do objeto (se publicado corretamente)
Transitivity — se A hb B e B hb C, então A hb C
Volatile
volatile garante visibilidade e ordenação para uma variável, mas não atomicidade.
public class StopFlag { private volatile boolean stop = false; public void stop() { stop = true; // visible para outras threads imediatamente } public void run() { while (!stop) { // sempre lê o valor mais recente // work } }}
Sem volatile, o JIT pode otimizar o loop assumindo que stop nunca muda → loop infinito.
Volatile NÃO substitui synchronized:
// RUIM — volatile não garante atomicidadeprivate volatile int counter = 0;public void increment() { counter++; } // read-modify-write, não atômico!// BOMprivate final AtomicInteger counter = new AtomicInteger();public void increment() { counter.incrementAndGet(); }// OUprivate int counter = 0;public synchronized void increment() { counter++; }
Double-Checked Locking (clássico)
Padrão para singleton lazy init, que só funciona com volatile desde Java 5:
public class Singleton { private static volatile Singleton instance; // volatile é crítico! public static Singleton getInstance() { Singleton local = instance; // leitura em variável local (otimização) if (local == null) { synchronized (Singleton.class) { local = instance; if (local == null) { local = new Singleton(); instance = local; } } } return local; }}
Sem volatile, outra thread pode ver instance != null mas com campos ainda não inicializados (reordering do construtor).
Solução moderna (mais simples):
public class Singleton { private static class Holder { static final Singleton INSTANCE = new Singleton(); } public static Singleton getInstance() { return Holder.INSTANCE; // Classloader garante initialization-on-demand thread-safe }}
Final fields
Campos final têm garantia especial: se o objeto é publicado de forma segura (não vazando this durante construção), outras threads sempre veem final fields corretamente inicializados. É o que permite String e Integer serem thread-safe sem sincronização.
Synchronized
Mecanismo built-in de locking baseado em monitores intrínsecos.
Synchronized methods
public class Counter { private int count = 0; // Sinônimo de synchronized(this) public synchronized void increment() { count++; } public synchronized int get() { return count; }}
Synchronized blocks
public class AccountService { private final Object lock = new Object(); private BigDecimal balance = BigDecimal.ZERO; public void deposit(BigDecimal amount) { synchronized (lock) { balance = balance.add(amount); } }}
Por que usar objeto explícito em vez de synchronized(this):
Não expõe o lock publicamente (evita interferência externa)
Pode ter múltiplos locks para operações diferentes
Protege contra synchronized(instance) feito por código cliente
Synchronized em static methods
public class Config { private static int value; // Equivalente a synchronized(Config.class) public static synchronized void set(int v) { value = v; }}
Reentrância
Monitores Java são reentrantes — a mesma thread pode adquirir o mesmo lock múltiplas vezes sem deadlock.
public synchronized void a() { b(); // OK, mesma thread já detém o lock}public synchronized void b() { ... }
Anti-patterns
// RUIM — synchronized em String literalsynchronized ("lock") { ... } // Strings são interned, outra parte do código pode compartilhar!// RUIM — synchronized em Integer/Longprivate Long counter = 0L;synchronized (counter) { ... } // Autoboxing cria novos objetos, lock muda!// BOM — objeto final dedicadoprivate final Object lock = new Object();synchronized (lock) { ... }
wait / notify / notifyAll
Comunicação entre threads via monitor. API antiga, raramente usada em código moderno (prefira BlockingQueue, CountDownLatch, Condition).
synchronized (lock) { while (!condition) { lock.wait(); // libera lock, espera notify } // usa recurso}// Em outra threadsynchronized (lock) { condition = true; lock.notifyAll(); // acorda threads esperando}
Regras:
wait(), notify(), notifyAll() só podem ser chamados segurando o monitor
Sempre wait() dentro de loop verificando a condição (spurious wakeups)
Prefira notifyAll() — notify() acorda uma thread arbitrária
java.util.concurrent.locks
API moderna de locks, mais flexível que synchronized.
ReentrantLock
private final ReentrantLock lock = new ReentrantLock();public void process() { lock.lock(); try { // critical section } finally { lock.unlock(); // SEMPRE em finally }}
Vantagens sobre synchronized:
// tryLock — não bloqueiaif (lock.tryLock(5, TimeUnit.SECONDS)) { try { ... } finally { lock.unlock(); }} else { // não conseguiu obter lock — fallback}// Interruptiblelock.lockInterruptibly();// Fair mode — FIFO (mais lento, mas sem starvation)Lock fairLock = new ReentrantLock(true);// Condition variables (wait/notify modernos)Condition notFull = lock.newCondition();Condition notEmpty = lock.newCondition();
ReadWriteLock
Múltiplas threads podem ler simultaneamente; escrita é exclusiva.
Quando usar: read-heavy workloads onde leituras são frequentes e escritas raras.
Alternativa moderna:ConcurrentHashMap ou StampedLock (abaixo) são frequentemente melhores.
StampedLock (Java 8+)
Mais performático que ReadWriteLock, com modo optimistic read que não bloqueia.
private final StampedLock lock = new StampedLock();private int x, y;public double distanceFromOrigin() { long stamp = lock.tryOptimisticRead(); // sem bloquear int currentX = x; int currentY = y; if (!lock.validate(stamp)) { // alguém escreveu durante a leitura? stamp = lock.readLock(); // fallback para read lock tradicional try { currentX = x; currentY = y; } finally { lock.unlockRead(stamp); } } return Math.sqrt(currentX * currentX + currentY * currentY);}public void move(int deltaX, int deltaY) { long stamp = lock.writeLock(); try { x += deltaX; y += deltaY; } finally { lock.unlockWrite(stamp); }}
Cuidado: StampedLock não é reentrante. Adquirir 2x na mesma thread = deadlock.
Atomic classes
java.util.concurrent.atomic oferece operações atômicas lock-free baseadas em CAS (Compare-And-Swap) — primitiva de CPU.
AtomicInteger, AtomicLong, AtomicReference
AtomicInteger counter = new AtomicInteger(0);counter.incrementAndGet(); // ++counter atômicocounter.getAndIncrement(); // counter++ atômicocounter.addAndGet(5); // += 5 atômicocounter.compareAndSet(10, 20); // if (counter == 10) counter = 20; retorna true/falsecounter.updateAndGet(x -> x * 2); // operação customizada atômicacounter.accumulateAndGet(10, Integer::sum);AtomicReference<User> ref = new AtomicReference<>(user);ref.compareAndSet(oldUser, newUser);
LongAdder, DoubleAdder (Java 8+)
Otimizado para alta contenção — mantém múltiplos contadores internos, soma sob demanda.
LongAdder counter = new LongAdder();counter.increment();long total = counter.sum(); // combina todos os contadores internos
Use LongAdder em vez de AtomicLong quando muitas threads incrementam e leituras são ocasionais. Escala muito melhor sob contenção.
AtomicStampedReference
Resolve o ABA problem — CAS vê A, mas o valor foi A → B → A no meio.
AtomicStampedReference<Node> ref = new AtomicStampedReference<>(node, 0);int[] stamp = new int[1];Node current = ref.get(stamp);ref.compareAndSet(current, newNode, stamp[0], stamp[0] + 1);
Concurrent Collections
Coleções thread-safe otimizadas para concorrência. Muito melhores que Collections.synchronizedList(...).
ConcurrentHashMap
Map thread-safe com alta concorrência. Não bloqueia reads, bloqueia escrita só em buckets individuais.
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();map.put("a", 1);map.putIfAbsent("a", 2); // só adiciona se ausente// Operações atômicas compostasmap.computeIfAbsent("key", k -> expensiveComputation(k));map.compute("count", (k, v) -> v == null ? 1 : v + 1);map.merge("count", 1, Integer::sum);// Iteração é weakly consistent — pode ver ou não modificações concorrentes, mas não lança ConcurrentModificationExceptionmap.forEach((k, v) -> System.out.println(k + "=" + v));// Paralelismo built-inmap.forEach(10_000, (k, v) -> process(k, v)); // usa ForkJoinPool se > thresholdmap.reduce(10_000, (k, v) -> v, Integer::sum);map.search(10_000, (k, v) -> v > 100 ? k : null);
CopyOnWriteArrayList / CopyOnWriteArraySet
Escrita cria uma cópia inteira do array. Reads são lock-free e consistentes.
Uso ideal: estruturas lidas constantemente e raramente modificadas (ex.: listeners, handlers registrados).
CopyOnWriteArrayList<Listener> listeners = new CopyOnWriteArrayList<>();listeners.add(listener1); // cria cópia do arraylisteners.forEach(Listener::onEvent); // lock-free, iterator snapshot
Não use para listas que mudam frequentemente — cada write é O(n).
BlockingQueue
Queue thread-safe com operações que bloqueiam. Base de thread pools e produtor-consumidor.
BlockingQueue<Task> queue = new LinkedBlockingQueue<>(1000); // capacidade limitada// Producerqueue.put(task); // bloqueia se cheiaqueue.offer(task, 5, TimeUnit.SECONDS); // timeoutqueue.offer(task); // retorna false se cheia (não bloqueia)// ConsumerTask t = queue.take(); // bloqueia se vaziaTask t = queue.poll(5, TimeUnit.SECONDS); // timeoutTask t = queue.poll(); // retorna null se vazia
LinkedTransferQueue — handoff com fallback para queue
ConcurrentLinkedQueue / ConcurrentLinkedDeque
Non-blocking, lock-free (CAS). Unbounded.
ConcurrentLinkedQueue<Event> events = new ConcurrentLinkedQueue<>();events.offer(event); // nunca bloqueiaEvent e = events.poll(); // retorna null se vazio
Use quando: você não quer bloquear mas precisa de fila thread-safe.
ConcurrentSkipListMap / ConcurrentSkipListSet
Alternativa concorrente a TreeMap / TreeSet. Mantém ordenação.
ExecutorService e Thread Pools
Abstração para gerenciar threads. Sempre prefira em vez de criar threads diretamente.
Factory methods
// Fixed pool — N threads persistentesExecutorService fixed = Executors.newFixedThreadPool(10);// Single thread — garante ordem sequencialExecutorService single = Executors.newSingleThreadExecutor();// Cached — cria threads sob demanda, reusa ociosas, sem limite (!)ExecutorService cached = Executors.newCachedThreadPool();// Scheduled — para tarefas agendadas/recorrentesScheduledExecutorService scheduled = Executors.newScheduledThreadPool(4);scheduled.scheduleAtFixedRate(task, 0, 1, TimeUnit.MINUTES);// Virtual threads (Java 21+) — 1 virtual thread por taskExecutorService virtual = Executors.newVirtualThreadPerTaskExecutor();
⚠️ Evite newCachedThreadPool() em produção — pode criar threads sem limite sob carga, causando OOM.
ThreadPoolExecutor (controle fino)
Para controle completo, use ThreadPoolExecutor diretamente:
ThreadPoolExecutor executor = new ThreadPoolExecutor( 10, // corePoolSize 50, // maximumPoolSize 60, TimeUnit.SECONDS, // keepAliveTime para threads acima do core new LinkedBlockingQueue<>(1000), // workQueue com capacidade new ThreadFactoryBuilder() // nome das threads para debugging .setNameFormat("worker-%d") .setDaemon(false) .build(), new ThreadPoolExecutor.CallerRunsPolicy() // rejection policy);
future .thenApply(User::enrich) .exceptionally(ex -> { log.error("Failed", ex); return User.empty(); });// handle — trata resultado E exceçãofuture.handle((result, ex) -> { if (ex != null) return "Error: " + ex.getMessage(); return "OK: " + result;});// whenComplete — side effect em ambos os casos (sem transformar)future.whenComplete((result, ex) -> { if (ex != null) log.error("Failed", ex); else log.info("Got {}", result);});// exceptionallyCompose — flatMap no caso de errofuture.exceptionallyCompose(ex -> fallbackFuture());
Timeout (Java 9+)
future.orTimeout(5, TimeUnit.SECONDS); // completa excepcionalmente em timeoutfuture.completeOnTimeout(defaultValue, 5, TimeUnit.SECONDS); // valor default em timeout
Async vs sync variants
Cada método tem 3 versões:
thenApply(fn) — executa na mesma thread (ou na thread que completou o future)
thenApplyAsync(fn) — executa no ForkJoinPool.commonPool()
thenApplyAsync(fn, executor) — executa em executor customizado
Regra: use Async + executor customizado em produção para controlar onde o código roda.
Sincronizadores
java.util.concurrent oferece várias primitivas de coordenação além de locks.
CountDownLatch
Uma thread (ou várias) espera até que um contador chegue a zero.
CountDownLatch latch = new CountDownLatch(3);// Workersfor (int i = 0; i < 3; i++) { executor.submit(() -> { try { doWork(); } finally { latch.countDown(); } });}// Main thread esperalatch.await(); // bloqueia até contador == 0// ouif (latch.await(30, TimeUnit.SECONDS)) { // todos completaram}
Uso: esperar inicialização de N componentes, coordenar fim de batch, etc.
Limitação: é one-shot. Depois de zerar, não reinicia.
CyclicBarrier
N threads esperam umas às outras em um barrier. Quando todas chegam, elas continuam juntas.
CyclicBarrier barrier = new CyclicBarrier(3, () -> { System.out.println("All arrived, proceeding");});for (int i = 0; i < 3; i++) { executor.submit(() -> { phase1(); barrier.await(); // espera até 3 threads chegarem phase2(); });}
Diferença do CountDownLatch:
CyclicBarrier pode ser reutilizado após reset()
CountDownLatch: uma thread espera N eventos; CyclicBarrier: N threads esperam umas às outras
Semaphore
Controla quantas threads podem acessar um recurso simultaneamente.
Semaphore semaphore = new Semaphore(5); // 5 permitspublic void callExternalAPI() { semaphore.acquire(); // bloqueia se sem permits try { externalApi.call(); } finally { semaphore.release(); }}
Uso: rate limiting, pool de recursos limitados, backpressure.
Phaser
Mais flexível que CountDownLatch e CyclicBarrier. Suporta múltiplas fases.
Phaser phaser = new Phaser(3);for (int i = 0; i < 3; i++) { executor.submit(() -> { phase1(); phaser.arriveAndAwaitAdvance(); // espera outras phase2(); phaser.arriveAndAwaitAdvance(); phase3(); phaser.arriveAndDeregister(); // sai do grupo });}
Exchanger
Handoff síncrono entre duas threads.
Exchanger<Buffer> exchanger = new Exchanger<>();// Thread ABuffer current = new Buffer();while (true) { fillBuffer(current); current = exchanger.exchange(current); // troca com Thread B}// Thread BBuffer current = new Buffer();while (true) { consumeBuffer(current); current = exchanger.exchange(current);}
ForkJoinPool
Thread pool otimizado para divide-and-conquer. Base das parallel streams e do common pool.
// Common pool — usado por parallelStream()ForkJoinPool common = ForkJoinPool.commonPool();// Pool customizadoForkJoinPool pool = new ForkJoinPool(Runtime.getRuntime().availableProcessors());// RecursiveTask — retorna valorclass SumTask extends RecursiveTask<Long> { private final long[] array; private final int start, end; @Override protected Long compute() { if (end - start <= 1000) { long sum = 0; for (int i = start; i < end; i++) sum += array[i]; return sum; } int mid = (start + end) / 2; SumTask left = new SumTask(array, start, mid); SumTask right = new SumTask(array, mid, end); left.fork(); // executa async long rightResult = right.compute(); // executa sync long leftResult = left.join(); return leftResult + rightResult; }}Long total = pool.invoke(new SumTask(array, 0, array.length));
Work stealing: cada worker tem uma deque própria. Quando fica sem trabalho, “rouba” tarefas de outras deques. Balanceia carga automaticamente.
Parallel Streams
Stream que executa operações em paralelo usando ForkJoinPool.commonPool().
long sum = list.parallelStream() .mapToLong(Item::getValue) .sum();
Quando usar:
Dados grandes (milhares+ elementos)
Operação CPU-intensive por elemento
Operação associativa e sem side effects
Fonte particionável (ArrayList ✅, LinkedList ❌)
Quando NÃO usar:
Poucos elementos (overhead > ganho)
I/O-bound (common pool fica bloqueado — use CompletableFuture)
Operações com ordem sequencial importante
Dentro de servidor web compartilhando o common pool
Cuidados críticos:
// RUIM — side effect não thread-safeList<String> result = new ArrayList<>();stream.parallel().forEach(result::add); // ConcurrentModificationException ou perda de dados// BOM — collect é thread-safeList<String> result = stream.parallel().collect(Collectors.toList());// RUIM — common pool saturado com I/Olist.parallelStream().map(id -> httpClient.fetch(id)).toList();// → bloqueia threads do common pool, afeta tudo
Virtual Threads (Java 21+)
O maior avanço em concorrência Java em décadas. Project Loom.
O problema que resolve
Platform threads são caras (~1 MB stack, kernel overhead). Em sistemas I/O-bound, você fica limitado por número de threads bloqueadas em I/O, não por CPU.
Abordagens tradicionais para contornar:
Thread pools — reutiliza threads, mas pool cheio = requests esperando
Async/Reactive — callback hell ou código “colorido” (CompletableFuture/Reactor)
Virtual threads oferecem a simplicidade do modelo síncrono com escalabilidade do modelo async.
Como funciona
Virtual threads são gerenciadas pela JVM, não pelo OS. Quando uma virtual thread bloqueia em I/O, ela é desmontada da platform thread carrier e outra virtual thread usa o carrier. Milhões de virtual threads rodam em ~CPU-count platform threads.
Platform threads (carriers): [ T1 ] [ T2 ] [ T3 ] [ T4 ]
│ │ │ │
│ │ │ │
Virtual threads: [ VT-1 ] [ VT-2 ] ... [ VT-1000000 ]
↑ ↑
monta quando quando I/O,
CPU-bound desmonta e libera carrier
Tratar grupos de tarefas concorrentes como unidade estruturada — começam juntas, terminam juntas, erros propagam para o escopo pai.
Problema que resolve
Concorrência tradicional é não estruturada — tasks começam sem relação pai/filho clara:
// Tradicional — task órfãs, difícil de gerenciarFuture<User> user = executor.submit(() -> fetchUser(id));Future<List<Order>> orders = executor.submit(() -> fetchOrders(id));// E se user falhar? orders continua rodando inutilmente
Com structured concurrency
try (var scope = StructuredTaskScope.<Object>open()) { var userTask = scope.fork(() -> fetchUser(id)); var ordersTask = scope.fork(() -> fetchOrders(id)); scope.join(); // espera ambos scope.throwIfFailed(); // propaga qualquer exceção User user = userTask.get(); List<Order> orders = ordersTask.get(); return new UserDetails(user, orders);}// Qualquer exceção cancela outras tasks e propaga para cima
Vantagens:
Cancelamento automático — se uma task falha, as outras são canceladas
Visibilidade de relações — scope deixa explícito o grupo de tasks
Error handling unificado — exceções propagam para o escopo
Evita leaks — todas as tasks têm lifetime atrelado ao scope
Policies
ShutdownOnFailure — cancela as outras se qualquer falha
ShutdownOnSuccess — cancela as outras quando qualquer uma completa (race)
try (var scope = StructuredTaskScope.open(Joiner.anySuccessfulResultOrThrow())) { scope.fork(() -> tryMirror1()); scope.fork(() -> tryMirror2()); scope.fork(() -> tryMirror3()); var result = scope.join(); // primeiro sucesso}
Scoped Values (Java 25 final)
Alternativa thread-safe e eficiente a ThreadLocal, especialmente com Virtual Threads.
Problema com ThreadLocal + Virtual Threads: cada virtual thread tem seus próprios ThreadLocals. Milhões de virtual threads = milhões de ThreadLocal copies.
// ThreadLocal tradicionalprivate static final ThreadLocal<User> CURRENT_USER = new ThreadLocal<>();CURRENT_USER.set(user);try { doSomething(); // lê CURRENT_USER} finally { CURRENT_USER.remove(); // fácil esquecer → leak}// Scoped Value (moderno)private static final ScopedValue<User> CURRENT_USER = ScopedValue.newInstance();ScopedValue.where(CURRENT_USER, user).run(() -> { doSomething(); // lê CURRENT_USER.get()});// Automaticamente limpo ao sair do scope
Vantagens:
Imutável dentro do escopo (mais seguro)
Sem leaks (lifecycle claro)
Eficiente com virtual threads (sem copies)
Deadlock, Race Condition e companhia
Os clássicos bugs de concorrência.
Race Condition
Resultado depende da ordem de execução de threads. O bug mais comum.
// BUG — check-then-actif (!map.containsKey(key)) { // ✓ thread B pode entrar aqui também map.put(key, value); // ✓ ambos escrevem}// FIXmap.putIfAbsent(key, value); // atômico// oumap.computeIfAbsent(key, k -> computeValue(k));
volatile para atomicidade — não protege operações compostas (counter++)
synchronized em wrapper — Long, Integer mudam de identidade com autoboxing
Double-checked locking sem volatile — quebra com reordering
catch (InterruptedException e) {} — engole o interrupt. Sempre Thread.currentThread().interrupt(); ou propague
ThreadLocal sem remove() — leak em pools de thread
Executors.newCachedThreadPool() em produção — sem limite, OOM sob carga
Compartilhar SimpleDateFormat sem sync — não é thread-safe
HashMap em concorrência — race condition, pode virar loop infinito
parallelStream() em I/O — bloqueia common pool, afeta todo o sistema
parallelStream() com side effects — ConcurrentModificationException ou perda de dados
Deadlock por ordem inconsistente de locks — sempre adquirir na mesma ordem
synchronized(this) com lock exposto — código externo pode sincronizar no mesmo objeto
wait() sem loop — spurious wakeups causam bugs
Virtual threads com muito synchronized — pinning, perde o benefício
CompletableFuture.get() sem timeout — pode travar para sempre
shutdown() sem awaitTermination() — tasks perdidas
Não nomear threads de pool — thread dumps ilegíveis (pool-1-thread-1)
Confiar em Thread.sleep() para coordenação — frágil, sempre é race condition
Incremento não atômico em contadores — use AtomicLong ou LongAdder
Assumir ordem entre threads sem happens-before — reordering pode morder
ThreadPoolExecutor com unbounded queue — efetivo até maximumPoolSize nunca ser atingido
Chamar API bloqueante de dentro de CompletableFuture sync — bloqueia o common pool
Na prática (da minha experiência)
MedEspecialista — migração para Virtual Threads:
Java 21 trouxe Virtual Threads, e foi uma das migrações mais impactantes que fiz. O backend Spring Boot estava usando newFixedThreadPool(200) para processar requests que fazem múltiplas chamadas a serviços externos (integrações com clínicas, pagamento, notificação). Sob carga, threads ficavam bloqueadas esperando I/O, e o pool enchia.
Migração:spring.threads.virtual.enabled=true em Spring Boot 3.2+. A partir daí, cada request do Tomcat roda em virtual thread. Resultado: eliminei tuning de pool, a aplicação passou a aguentar 10x mais concorrência no mesmo hardware, e o código continuou sendo imperativo síncrono — muito mais simples que migrar para WebFlux.
Armadilha: uma biblioteca interna usava synchronized pesado em um método chamado milhões de vezes. Isso causou pinning das virtual threads, anulando o ganho. Migrei o synchronized para ReentrantLock e o problema desapareceu. -Djdk.tracePinnedThreads=full foi essencial para diagnosticar.
Cuidado com ThreadLocal: o sistema tinha ThreadLocal<UserContext> para carregar o user autenticado através da chain de chamadas. Com virtual threads, cada request tem uma thread diferente, e ThreadLocal funcionava, mas vi consumo de memória aumentando. Migrei para Scoped Values (quando for final em Java 25) e reduziu o footprint significativamente.
CompletableFuture para chamadas paralelas:
Em endpoint que precisa agregar dados de 5 serviços diferentes, usei CompletableFuture para paralelizar. Antes era sequencial (~2s), depois ~400ms.
Executor customizado foi importante — não usar o ForkJoinPool.commonPool() porque o compartilho com parallel streams no resto da aplicação.
Incidente clássico de concorrência:
Um dia debuguei um bug onde um cache em HashMap estava entrando em loop infinito em produção, consumindo 100% de uma CPU. Causa: HashMap não é thread-safe, e sob resize concorrente, a estrutura interna pode virar um cyclic linked list. Solução: ConcurrentHashMap. Lesson learned: nunca use HashMap mutável em código compartilhado.
Lock contention em produção:
Descobri via JFR profiling que um método synchronized estava gargalando a aplicação. Contenção alta em java.util.Collections.synchronizedMap. Migrei para ConcurrentHashMap e p99 caiu de 200ms para 15ms.
A lição principal: concorrência correta é difícil, mas as ferramentas modernas (Virtual Threads, CompletableFuture, java.util.concurrent) tornaram muito mais fácil do que era em 2010. Aprenda o Memory Model uma vez, domine 2-3 padrões (immutability, producer-consumer, CompletableFuture chain), e você resolve 90% dos casos. Para os outros 10%, use thread dumps, JFR, e não tente ser esperto sem medir.
How to explain in English
“Java concurrency is an area where understanding the Java Memory Model is the foundation. The JMM defines happens-before relationships that determine when writes by one thread are visible to another. Without understanding this, even simple code can have subtle bugs — like a loop that doesn’t terminate because the compiler optimized assuming no other thread modifies the flag.
For synchronization, my default is choosing the weakest tool that works. Immutability first — if the object can’t change, it’s automatically thread-safe. Then atomic classes for counters and references. Then ConcurrentHashMap for maps. Only when I need compound operations do I reach for explicit locks. I use synchronized for simple cases and ReentrantLock when I need tryLock, timeouts, or fair mode.
For asynchronous composition, I use CompletableFuture. It’s much more expressive than the old Future interface — thenCompose, thenCombine, allOf, exceptionally, and orTimeout cover most use cases. I always pass an explicit executor to the async variants so I know which thread pool is running what, especially in production where the common ForkJoinPool shouldn’t be overloaded.
Virtual Threads, introduced in Java 21, have been transformative. For I/O-bound workloads, which is most of what a typical backend does, they eliminate the need to tune thread pools. At MedEspecialista, I enabled them in Spring Boot 3.2+ with a single property, and the application could handle 10x more concurrent requests with the same hardware. The code stays synchronous and imperative, which is simpler than moving to WebFlux. The main pitfall is pinning — if code uses synchronized heavily, virtual threads get pinned to their carrier thread and you lose the benefit. I use -Djdk.tracePinnedThreads=full to diagnose and migrate hot synchronized paths to ReentrantLock.
For common bugs, I watch for the usual suspects: race conditions in check-then-act, deadlocks from inconsistent lock ordering, visibility bugs from missing volatile, and the classic HashMap-in-concurrent-code trap. When things go wrong in production, jstack and JFR are my first tools.”
Frases úteis em entrevista
“The Java Memory Model defines happens-before relationships that determine cross-thread visibility.”
“I prefer immutability first, then atomic classes, then concurrent collections, then explicit locks.”
“Volatile gives you visibility and ordering, but not atomicity.”
“My default is synchronized for simplicity; I use ReentrantLock when I need tryLock or fair mode.”
“CompletableFuture is my go-to for async composition — chain with thenCompose, combine with allOf.”
“Virtual Threads in Java 21 let me keep imperative code while scaling I/O-bound workloads to millions of concurrent operations.”
“The main virtual thread pitfall is pinning — synchronized causes it until Java 24.”
“For CPU-bound work, I use platform threads with a pool sized to CPU count. Virtual threads don’t help there.”
“Never use plain HashMap in shared state — use ConcurrentHashMap or the copy-on-write pattern.”
“Rate limiting with Semaphore, coordinating phases with CountDownLatch or CyclicBarrier, handoff with SynchronousQueue.”
“Always pass an explicit executor to CompletableFuture async methods — don’t rely on the common pool.”