Pular para o conteúdo

Trilha de Kotlin · parte 22 de 59

Threads e concorrência na JVM

Threads, condição de corrida, synchronized, volatile, pools com Future e deadlock na JVM, com código rodado em Kotlin e as saídas que apareceram de verdade.

intermediárioPublicada em · 12 min de leitura

Tamanho da fonte:
Largura da coluna:
Tom:
Espaço entre linhas:
Fonte:

Threads e concorrência na JVM

Imagine uma cozinha de restaurante com três cozinheiros e uma única geladeira. Se dois pegam o último ovo ao mesmo tempo, um deles volta de mãos vazias sem saber por quê. Threads são esses cozinheiros, e a geladeira é a memória que eles compartilham. A analogia tem limite: cozinheiros combinam a vez conversando, e threads não combinam nada. Quem decide a vez é o sistema operacional. A aula usa funções, lambdas e propriedades das fases 1 a 3, e não supõe contato prévio com threads.

o mapa da aula

Vemos e , criamos threads e mostramos que a ordem entre elas não é garantida. Um contador de visitas mostra a e as formas de protegê-la. Fecham a aula o custo das threads, os erros comuns e dois exercícios.

1. processo e thread

Um processo é um programa em execução, com memória própria. Dentro dele, as threads avançam no seu ritmo e enxergam os mesmos objetos, então precisam se coordenar ao tocar neles. O guarda objetos e campos estáticos, como a propriedade de topo de arquivo que servirá de contador. A pilha é privada de cada thread e guarda as variáveis locais, que nunca são o problema desta aula.

Três threads de um processo, cada uma com pilha própria, leem e escrevem o mesmo heap, onde fica o contador visitas.
figura 1 · Processo com três threads e um heap compartilhado

As threads estão à esquerda e o heap à direita. Sem pontas de seta, o rótulo diz a direção: duas threads leem e escrevem visitas, e a main só lê no fim.

2. por que usar várias threads

Enquanto uma thread espera I/O (entrada e saída, como a resposta de uma conexão de rede), outra segue trabalhando. Além disso, uma thread avança num núcleo por vez, então dividir um cálculo permite usar vários núcleos. A primeira razão é a que as coroutines atacam, como mostra suspend e a máquina de estados.

3. criar uma thread e esperar por ela

Thread { } cria a thread sem iniciá-la, e start() pede ao sistema que a execute. thread { } faz as duas coisas, e join() espera a thread terminar. A pausa de 1 ms só mistura as linhas.

import kotlin.concurrent.thread

fun main() {
    val a = Thread {
        repeat(5) {
            println("A passo $it")
            Thread.sleep(1)
        }
    }
    a.start()
    val b = thread {
        repeat(5) {
            println("B passo $it")
            Thread.sleep(1)
        }
    }
    a.join()
    b.join()
    println("fim")
}

Duas execuções no JDK 21:

-- JDK 21, execução 1 --
A passo 0
A passo 1
A passo 2
B passo 0
A passo 3
B passo 1
A passo 4
B passo 2
B passo 3
B passo 4
fim
-- JDK 21, execução 2 --
A passo 0
A passo 1
A passo 2
A passo 3
A passo 4
B passo 0
B passo 1
B passo 2
B passo 3
B passo 4
fim

Em cinco execuções no JDK 21 saíram três padrões. No JDK 25 saíram todas iguais. A ordem é decisão do escalonador, e nenhum programa deve depender dela.

4. condição de corrida

O domínio da aula é um contador de visitas: duas threads somam um milhão de visitas cada, e o total certo seria 2 000 000.

import kotlin.concurrent.thread

var visitas = 0

fun main() {
    val a = thread { repeat(1_000_000) { visitas++ } }
    val b = thread { repeat(1_000_000) { visitas++ } }
    a.join()
    b.join()
    println("visitas: $visitas (esperado: 2000000)")
}

Duas das dez execuções, uma por JDK:

-- JDK 21, execução 1 --
visitas: 1732221 (esperado: 2000000)
-- JDK 25, execução 1 --
visitas: 1935517 (esperado: 2000000)

Nas dez, o total variou de 1 417 101 a 2 000 000. Isso é uma condição de corrida (race condition): o resultado depende da intercalação, e o programa não reclama. A causa está em visitas++, que lê, soma e grava. Duas threads podem ler o mesmo 5 antes de qualquer gravação.

Linha do tempo de duas threads que leem 5 do contador antes de gravar, e gravam 6 nas duas, perdendo um incremento.
figura 2 · Duas threads gravam sobre a mesma leitura e perdem um incremento

Na linha do tempo, as duas leem 5 e gravam 6, e um incremento se perde. Um dos dez acertou, no JDK 25, e o código seguia errado.

5. com synchronized

Exclusão mútua garante que só uma thread executa um trecho crítico por vez. A JVM usa um , o lock intrínseco de cada objeto. Pense na chave do banheiro de um posto de gasolina: quem pega a chave entra, e a fila espera. O limite é que a chave é física, e o monitor protege só quem pede o mesmo objeto. @Synchronized numa função de membro usa o monitor da instância, e synchronized(objeto) { } usa o objeto informado. A seção 6 usa as duas formas no mesmo programa. Um programa só com @Synchronized chegou a 2 000 000 em seis execuções, três por JDK.

A trava custa, porque quem chega fica em fila. Medi com duas threads de cinco milhões de chamadas cada, em três rodadas, em duas execuções por JDK: sem trava, de 13 a 73 ms; com @Synchronized, de 320 a 518 ms. A versão sem trava perdeu entre 28% e 39% dos incrementos, por isso não é um benchmark. Proteja o menor trecho possível.

6. dois locks diferentes

O erro mais comum é usar travas diferentes para o mesmo dado. @Synchronized trava this, e synchronized(trava) trava outro objeto, então as duas threads não se excluem.

import kotlin.concurrent.thread

class Contador {
    private var visitas = 0
    private val trava = Any()

    @Synchronized
    fun porMetodo() {
        visitas++
    }

    fun porBloco() {
        synchronized(trava) {
            visitas++
        }
    }

    fun total() = visitas
}

fun main() {
    val contador = Contador()
    val a = thread { repeat(1_000_000) { contador.porMetodo() } }
    val b = thread { repeat(1_000_000) { contador.porBloco() } }
    a.join()
    b.join()
    println("visitas: ${contador.total()} (esperado: 2000000)")
}

Uma execução por JDK:

-- JDK 21, execução 1 --
visitas: 1715654 (esperado: 2000000)
-- JDK 25, execução 1 --
visitas: 1586353 (esperado: 2000000)

Nas execuções feitas, os totais ficaram entre 1 495 895 e 1 728 740. Todo acesso ao mesmo estado precisa usar o mesmo monitor.

7. visibilidade com volatile

Sem sincronização, uma thread pode deixar de ver a gravação de outra. @Volatile torna as escritas no campo visíveis a todas, e a documentação do Kotlin diz que leituras e escritas do campo são atômicas. Isso não substitui a exclusão mútua, porque visitas++ continua sendo ler e depois gravar. Num teste com um laço que espera uma flag, sem @Volatile a thread continuou viva depois de 3 s em quatro execuções, e com a anotação o laço terminou em todas. O resultado depende do compilador JIT, e a documentação garante a visibilidade, não que o laço trave.

8. pools com ExecutorService e Future

Criar uma thread por tarefa tem custo, como a seção 10 mostra. Um pool mantém um número fixo de threads, que pegam tarefas de uma fila. submit recebe um Callable, uma tarefa que devolve um resultado, e devolve um Future, o resultado que ainda pode não existir. get() espera e devolve o valor. A analogia é um restaurante com dois garçons e uma fila de pedidos. O limite: o pool limita quantas tarefas rodam juntas, mas cada thread continua custando.

import java.util.concurrent.Callable
import java.util.concurrent.Executors

fun main() {
    val pool = Executors.newFixedThreadPool(2)
    val futuros = (1..4).map { n ->
        pool.submit(Callable {
            Thread.sleep(200)
            println("tarefa $n na ${Thread.currentThread().name}")
            n * n
        })
    }
    val soma = futuros.sumOf { it.get() }
    pool.shutdown()
    println("soma dos quadrados: $soma")
}

Uma execução por JDK. A ordem nas threads muda, e a soma é sempre 30:

-- JDK 21, execução 1 --
tarefa 2 na pool-1-thread-2
tarefa 1 na pool-1-thread-1
tarefa 3 na pool-1-thread-2
tarefa 4 na pool-1-thread-1
soma dos quadrados: 30
-- JDK 25, execução 1 --
tarefa 2 na pool-1-thread-2
tarefa 1 na pool-1-thread-1
tarefa 4 na pool-1-thread-1
tarefa 3 na pool-1-thread-2
soma dos quadrados: 30

Sem shutdown(), a soma sai, mas o processo não termina. Rodei com limite de 6 s e encerrei na mão, nos dois JDKs. Pela documentação, shutdown() barra novas tarefas, as threads do pool padrão são não daemon, e a JVM só encerra quando restam apenas threads daemon.

9.

Deadlock é o ciclo em que duas threads seguram um lock cada uma e esperam o da outra. A analogia é o cruzamento estreito em que dois carros se encaram. O limite: carro pode dar ré, e uma thread presa num monitor não solta a trava. O código usa threads daemon e join com limite de 2 s, para não travar a máquina.

import kotlin.concurrent.thread

val trancaA = Any()
val trancaB = Any()

fun main() {
    val t1 = thread(isDaemon = true) {
        synchronized(trancaA) {
            Thread.sleep(100)
            synchronized(trancaB) { println("t1 terminou") }
        }
    }
    val t2 = thread(isDaemon = true) {
        synchronized(trancaB) {
            Thread.sleep(100)
            synchronized(trancaA) { println("t2 terminou") }
        }
    }
    t1.join(2000)
    t2.join(2000)
    println("t1 viva: ${t1.isAlive}, t2 viva: ${t2.isAlive}")
    val ciclo = java.lang.management.ManagementFactory.getThreadMXBean().findDeadlockedThreads()
    println("threads em deadlock: ${ciclo?.size ?: 0}")
}

Duas execuções por JDK:

-- JDK 21, execução 1 --
t1 viva: true, t2 viva: true
threads em deadlock: 2
-- JDK 21, execução 2 --
t1 viva: true, t2 viva: true
threads em deadlock: 2
-- JDK 25, execução 1 --
t1 viva: true, t2 viva: true
threads em deadlock: 2
-- JDK 25, execução 2 --
t1 viva: true, t2 viva: true
threads em deadlock: 2

Nas quatro, as threads ficaram vivas e o ThreadMXBean achou o ciclo. Ele devolve null sem ciclo, por isso ?.size ?: 0.

Duas threads, cada uma segurando um lock e esperando o lock da outra, formando um ciclo que o programa não desfaz sozinho.
figura 3 · Deadlock: duas threads seguram um lock e esperam o outro

Hexágonos azuis são threads, e retângulos laranja são locks. A linha azul diz o que cada thread segura, e a rosa tracejada, com o X, é o pedido barrado.

10. o custo das threads

Uma thread de plataforma ocupa memória para a sua pilha e entra no escalonamento do sistema operacional. Não medi a memória por thread. Medi só a criação: 10 000 threads dormindo 1 s terminaram em cerca de 4 s, nos dois JDKs, nesta máquina. Dez mil coroutines sobre uma thread aparecem em suspend e a máquina de estados, e a alternativa do Java 21 está em Virtual threads e testes com runTest.

Pergunta de entrevistaComo você evitaria um deadlock?

Deadlock acontece quando duas threads seguram locks e esperam o lock uma da outra. Evito pedindo os locks sempre na mesma ordem global, usando tryLock com timeout, e mantendo a seção crítica pequena. Para diagnosticar, uso um dump de threads, como jstack, ou o ThreadMXBean, que encontra o ciclo. Em código de aplicação, prefiro estruturas de alto nível, como filas e canais, para não gerenciar locks à mão.

11. erros comuns e boas práticas

Chamar run() em vez de start() não cria thread: o código roda na thread atual, e nenhuma mensagem avisa. A documentação diz que o método “is not intended to be invoked directly”. Os outros erros já têm seção: travas diferentes na 6, pool sem shutdown() na 8. Dê nome às threads com thread(name = ...) e peça as travas sempre na mesma ordem.

12. exercícios

exercício 1: soma em partes (guiado)

Some os números de 1 a 1 000 000 com quatro threads de um pool, em quatro partes de 250 000. Envie cada parte com submit e um Callable, some os get() e chame shutdown().

Resposta:

import java.util.concurrent.Callable
import java.util.concurrent.Executors

fun somaDoIntervalo(inicio: Long, fim: Long): Long {
    var soma = 0L
    for (n in inicio..fim) soma += n
    return soma
}

fun main() {
    val pool = Executors.newFixedThreadPool(4)
    val partes = 4
    val tamanho = 1_000_000L / partes
    val futuros = (0 until partes).map { i ->
        val inicio = i * tamanho + 1
        val fim = (i + 1) * tamanho
        pool.submit(Callable { somaDoIntervalo(inicio, fim) })
    }
    val total = futuros.sumOf { it.get() }
    pool.shutdown()
    println("soma de 1 a 1000000: $total")
}
-- JDK 21, execução 1 --
soma de 1 a 1000000: 500000500000
-- JDK 25, execução 1 --
soma de 1 a 1000000: 500000500000

O valor bate com n(n + 1) / 2 para n = 1 000 000.

exercício 2: a mesma ordem nos locks

Use o código da seção 9 e troque só o bloco da t2, para que ela pegue o lock A antes do B. Explique por que o ciclo some.

Resposta: só o bloco da t2 muda.

    val t2 = thread(isDaemon = true) {
        synchronized(trancaA) {
            Thread.sleep(100)
            synchronized(trancaB) { println("t2 terminou") }
        }
    }
-- JDK 21, execução 1 --
t1 terminou
t2 terminou
t1 viva: false, t2 viva: false
threads em deadlock: 0
-- JDK 25, execução 1 --
t1 terminou
t2 terminou
t1 viva: false, t2 viva: false
threads em deadlock: 0

Quem pega o lock A primeiro termina e solta os dois. A outra thread espera fora, sem segurar nada, e o ciclo não se forma.

13. glossário

  • Processo: programa em execução, com memória própria.
  • Thread: linha de execução dentro de um processo.
  • Heap: memória compartilhada pelas threads, com objetos e campos estáticos.
  • Pilha de thread: memória privada de cada thread, com as variáveis locais.
  • Condição de corrida (race condition): erro em que o resultado depende da intercalação das threads.
  • Exclusão mútua: garantia de que só uma thread executa um trecho crítico por vez.
  • Monitor (lock intrínseco): trava que a JVM associa a cada objeto.
  • Deadlock: ciclo de espera em que nenhuma thread avança.
  • Pool de threads: conjunto de threads que pegam tarefas de uma fila.
  • Callable e Future: tarefa que devolve valor, e o objeto que representa esse valor pendente.
  • Thread daemon: thread que não impede a JVM de encerrar.

o que não foi confirmado ou medido

  • Plataforma: Mac com Apple silicon, OpenJDK 21.0.8 (Temurin) e OpenJDK 25. Windows, Linux e IntelliJ não foram testados. Bytecode para JVM 21, com Gradle 9.7.0 e Kotlin 2.4.21.
  • Valores: as saídas são de execuções específicas. A ordem saiu igual no JDK 25 nas cinco execuções, o que não garante nada.
  • volatile: o resultado depende do JIT. O programa de teste não entrou na aula.
  • Custo do synchronized: sem aquecimento separado nem repetições estatísticas. Use como ordem de grandeza.
  • Memória por thread e limite do sistema: não medidos.
  • Programas de medição: os códigos das medições das seções 5, 7, 8 e 10 (custo do synchronized, teste do volatile, pool sem shutdown() e 10 000 threads) não estão nesta nota. Os números citados não são reproduzíveis só pelo texto. Modelo de memória e atomics ficam para outras aulas.

Fontes

Consultadas em 04/11/2026.

Relacionados

↑ Voltar ao topo

Atalhos de teclado

Abrir busca
/
Alternar tema claro/escuro
t
Aula anterior na série
←
Próxima aula na série
→
Lista de atalhos
?