CodeGym /Cursos /JAVA 25 SELF /Recursos compartilhados e sincronização: problemas de ace...

Recursos compartilhados e sincronização: problemas de acesso

JAVA 25 SELF
Nível 52 , Lição 0
Disponível

1. O que é um recurso compartilhado

Você já lidou com recursos compartilhados. A casa em que uma família mora — é um recurso compartilhado dessa família. A geladeira do escritório — um recurso compartilhado para todos os colegas. Acho que deu para entender a ideia.

Em programação, um recurso compartilhado é uma variável, objeto ou estrutura de dados a que vários threads podem acessar simultaneamente. Isso pode ser:

  • Uma variável-contador do número de pedidos processados.
  • Uma lista de solicitações, que é preenchida por algumas threads e processada por outras.
  • Um arquivo aberto no qual várias threads escrevem.
  • Uma conexão com o banco de dados usada por diferentes partes do programa.

Em Java, qualquer objeto ou variável que possam ser acessados por várias threads tornam-se potencialmente um “recurso compartilhado”.

Exemplo de recurso compartilhado: contador global

public class Counter {
    public int value = 0;
}

Se várias threads incrementarem esse contador, elas acessarão a mesma variável value — aí está o recurso compartilhado.

2. Problemas de acesso simultâneo

Em um programa single-thread tudo é simples: uma thread — um executor — ela segue o código como um trem nos trilhos. Mas assim que entram várias threads em cena, começa uma verdadeira “dança das espadas”: as threads podem interferir no trabalho umas das outras nos lugares mais inesperados.

Race condition (condição de corrida)

Race condition é a situação em que o resultado do programa depende de como exatamente as ações das threads “se misturaram”. Ou seja, se você executar o mesmo programa várias vezes, o resultado pode ser diferente — e isso não é um bug, é uma “feature” da multithread.

Exemplo clássico: duas threads incrementam um contador

Vamos modelar uma situação simples: temos um contador compartilhado e duas threads incrementam seu valor mil vezes.

public class Counter {
    public int value = 0;
}

public class CounterDemo {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();

        Runnable incrementTask = () -> {
            for (int i = 0; i < 1000; i++) {
                counter.value++; // PONTO PERIGOSO!
            }
        };

        Thread t1 = new Thread(incrementTask);
        Thread t2 = new Thread(incrementTask);

        t1.start();
        t2.start();

        t1.join();
        t2.join();

        System.out.println("Valor esperado: 2000");
        System.out.println("Valor real: " + counter.value);
    }
}

O que veremos na tela?

Às vezes — 2000, às vezes — 1985, às vezes — 1937... Por quê? Porque a operação counter.value++ não é atômica! Ela consiste em três etapas:

  1. Ler o valor atual de counter.value.
  2. Incrementá-lo em 1.
  3. Gravar de volta.

Se duas threads lerem ao mesmo tempo o mesmo valor, ambas o incrementarem e depois ambas gravarem o resultado — no fim um incremento será “perdido”. Isso é o lost update — atualização perdida.

Estado inconsistente do objeto

Se você tem um objeto complexo composto por vários campos e as threads alteram campos diferentes ao mesmo tempo, o objeto pode acabar em um estado “estranho” ou inconsistente. Por exemplo, o saldo diminuiu, mas o histórico de operações não foi atualizado — o cliente entra em pânico, o contador fica em choque.

3. Por que precisamos de sincronização

Sincronização é uma forma de dizer ao programa: “Pare, este trecho de código deve ser executado por apenas uma thread por vez! As demais — aguardam!” É como uma placa de “Limpeza! Não entre!” na porta do banheiro: enquanto uma pessoa está lá dentro, as outras esperam do lado de fora (e mentalmente amaldiçoam quem não sai).

Garantia de integridade dos dados

Se quisermos que nosso contador sempre seja incrementado corretamente, precisamos impedir a alteração simultânea de seu valor por várias threads.

Exemplo: sincronizando o incremento do contador

public class Counter {
    public int value = 0;

    public synchronized void increment() {
        value++;
    }
}

Agora, se duas threads chamarem increment(), apenas uma poderá executar esse método em um dado momento. A segunda vai esperar até a primeira terminar.

Esquema: o que acontece durante a sincronização

+-------------------+
|  Potok 1          |    --\
+-------------------+      \
      |                      \
      V                       \
+-------------------+          > [ synchronized increment() ]
|  Potok 2          |    --/  /
+-------------------+      / /
      |                    / /
      V                   / /
+-------------------+    / /
|  Potok 3          | --/ /
+-------------------+    /
      |                 /
      V                /
+-------------------+ /
|  Potok N          |/
+-------------------+

Todas as threads entram em fila para executar a seção protegida do código. Apenas uma thread pode estar dentro da “seção crítica” ( synchronized-bloco) ao mesmo tempo.

4. Introdução rápida às formas de sincronização

Sincronização em Java não é um único método, mas sim um verdadeiro “arsenal” de ferramentas que permitem proteger o recurso compartilhado do acesso simultâneo.

Palavra-chave synchronized

É a principal ferramenta de sincronização em Java. Ela pode ser usada de duas formas:

Método sincronizado

public synchronized void increment() {
    value++;
}

Bloco sincronizado

public void increment() {
    synchronized (this) {
        value++;
    }
}

Aqui, this é o objeto no qual a trava é realizada. Enquanto uma thread executa esse bloco, outras threads que queiram entrar em um bloco igual com o mesmo objeto terão de esperar.

Classes especializadas de java.util.concurrent

  • Lock, ReentrantLock — alternativa mais flexível ao synchronized.
  • ReadWriteLock — para separar bloqueios de leitura e escrita.
  • Semaphore — limita a quantidade de threads executando o código ao mesmo tempo.
  • CountDownLatch, CyclicBarrier e outros — para coordenar o trabalho das threads.

Importante: Hoje é apenas uma introdução aos fundamentos — falaremos sobre essas classes um pouco mais adiante.

5. Exemplo prático: aplicação com contador multithread

Suponha que estamos implementando estatísticas de acessos de usuários a um serviço. Cada thread é um usuário separado que incrementa um contador compartilhado.

Sem sincronização

public class Counter {
    public int value = 0;
}

public class MultiThreadCounterDemo {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();

        Runnable user = () -> {
            for (int i = 0; i < 10000; i++) {
                counter.value++;
            }
        };

        Thread t1 = new Thread(user);
        Thread t2 = new Thread(user);
        Thread t3 = new Thread(user);

        t1.start();
        t2.start();
        t3.start();

        t1.join();
        t2.join();
        t3.join();

        System.out.println("Valor esperado: 30000");
        System.out.println("Valor real: " + counter.value);
    }
}

Resultado: Quase sempre menor que 30000. Às vezes — muito menor! Por quê? Porque as threads “atropelam” umas às outras.

Sincronização: corrigindo o erro

public class Counter {
    public int value = 0;

    public synchronized void increment() {
        value++;
    }
}

public class MultiThreadCounterDemo {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();

        Runnable user = () -> {
            for (int i = 0; i < 10000; i++) {
                counter.increment();
            }
        };

        Thread t1 = new Thread(user);
        Thread t2 = new Thread(user);
        Thread t3 = new Thread(user);

        t1.start();
        t2.start();
        t3.start();

        t1.join();
        t2.join();
        t3.join();

        System.out.println("Valor esperado: 30000");
        System.out.println("Valor real: " + counter.value);
    }
}

Resultado: Sempre 30000. Viva, a sincronização funciona!

6. Nuances úteis

Visualização: como é uma race condition

Vamos desenhar uma pequena tabela para mostrar como duas threads podem “perder” um incremento:

Etapa Thread 1 Thread 2 Valor de value
1 Lê value=0 0
2 Lê value=0 0
3 Incrementa para 1 0
4 Incrementa para 1 0
5 Grava 1 1
6 Grava 1 1

Quando a sincronização é necessária

A sincronização nem sempre é necessária. Quando a variável vive em seu pequeno mundo e apenas uma thread trabalha com ela — dá para relaxar. Mas basta compartilhá‑la com outras threads e a sincronização passa a ser indispensável. Mesmo que pareça que “vai dar tudo certo” — não acredite. A condição de corrida é traiçoeira: pode ficar escondida por muito tempo e, de repente, explodir no pior momento.

Para o futuro: que outras ferramentas de sincronização existem

Hoje vimos apenas a ferramenta básica — synchronized. Nas próximas aulas vamos considerar:

  • Como funciona o monitor do objeto e quais tipos de bloqueios existem.
  • O que são métodos estáticos sincronizados (static + synchronized).
  • Como funciona a palavra‑chave volatile e para que ela serve.
  • Quais são as classes modernas para sincronização (Lock, Semaphore etc.).

7. Erros típicos ao trabalhar com recursos compartilhados

Erro nº 1: Ignorar multithread.
Um dos erros mais comuns é não pensar que a variável pode ser acessada por várias threads. Mesmo que agora o programa seja single-thread, mais tarde alguém pode adicionar threads — e os bugs surgirão “do nada”.

Erro nº 2: Sincronização insuficiente ou excessiva.
Se não sincronizar o acesso ao recurso compartilhado — você terá race condition e dados inconsistentes. Se sincronizar tudo, o programa vai “sufocar” com bloqueios e ficará lento. Procure sempre sincronizar apenas o que realmente precisa.

Erro nº 3: Sincronizar no objeto errado.
Se você sincronizar em objetos diferentes (por exemplo, em variáveis locais ou literais de string), isso não protegerá o recurso compartilhado. Todas as threads devem sincronizar no mesmo objeto.

Erro nº 4: Esperar atomicidade de operações não atômicas.
A operação i++ não é atômica! Mesmo que a variável seja declarada como volatile, isso não torna o incremento atômico. Para tais operações, é necessária sincronização.

Erro nº 5: “Tive sorte, aqui funciona”.
A race condition pode não se manifestar no seu computador, mas certamente vai aparecer no servidor ou para o usuário. Nunca confie no “vamos ver” em programas multithread!

Comentários
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION