Programação Concorrente em Java: Técnicas de Sincronização e Comunicação entre Threads

Aálise de um Problema de Concorrência

Vamos analisar o seguinte problema de programação concorrente:

Implementar um contêiner que forneça dois métodos: adicionar e tamanho. Criar duas threads: a primeira adiciona 10 elementos ao contêiner, e a segunda monitora o número de elementos, terminando quando o contador atingir 5.

O código inicial:


package exemplo.concorrencia;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;

public class ContenedorBasico {

    private List<object> elementos = new ArrayList<>();

    public void adicionar(Object elemento) {
        elementos.add(elemento);
    }

    public int tamanho() {
        return elementos.size();
    }
    
    public static void main(String[] args) {
        ContenedorBasico cont = new ContenedorBasico();

        new Thread(() -> {
            for(int i=0; i<10; i++) {
                cont.adicionar(new Object());
                System.out.println("Adicionado " + i);
                
                try {
                    TimeUnit.SECONDS.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }, "Thread1").start();
        
        new Thread(() -> {
            while(true) {
                if(cont.tamanho() == 5) {
                    break;
                }
            }
            System.out.println("Thread2 finalizada");
        }, "Thread2").start();
    }
}
</object>

Análise do Problema

Esta implementação não funciona corretamente devido a problemas de visibilidade entre threads e falta de sincronização.

Adicionando Volatile

Podemos tentar adicionar o modificador volatile para garantir a visibilidade:


public class ContenedorComVolatile {

    private volatile List<object> elementos = new ArrayList<>();

    public void adicionar(Object elemento) {
        elementos.add(elemento);
    }

    public int tamanho() {
        return elementos.size();
    }
    
    // Restante do código...
}
</object>

No entanto, essa abordagem apresenta dois problemas principais:

  1. Não é precisa, pois a verificação do tamanho pode ocorrer após outra thread ter adicionado mais elementos
  2. Consome excessivamente CPU devido ao loop contínuo

Além disso, o volatile não funciona como esperado quando modificamos o conteúdo do objeto referenciado, apenas a referência em si.

Usando Coleções Sincronizadas

Para resolver o problema de thread-safety, podemos usar uma coleção sincronizada:


private volatile List<object> elementos = Collections.synchronizedList(new ArrayList<>());
</object>

Limitações do Volatile

Como mencionado, o volatile não resolve completamente o problema, pois não detecta mudanças no estado do objeto referenciado:


package exemplo.concorrencia;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.TimeUnit;

public class ExemploVolatile {

    volatile List<object> elementos = Collections.synchronizedList(new ArrayList<>());

    public void adicionar(Object o) {
        elementos.add(o);
    }

    public int tamanho(){
        return elementos.size();
    }

    public static void main(String[] args) {
        ExemploVolatile c = new ExemploVolatile();

        new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                c.adicionar(new Object());
                System.out.println("Adicionado " + i);
            }
        }, "Thread1").start();

        new Thread(() -> {
            while (true){
                if(c.tamanho() == 5){
                    break;
                }
            }
            System.out.println("Thread2 finalizada");
        }, "Thread2").start();
    }
}
</object>

Usando wait() e notify()

Para evitar o loop contínuo que consome CPU, podemos usar os métodos wait() e notify():


package exemplo.concorrencia;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;

public class ContenedorComWaitNotify {

    private List<object> elementos = new ArrayList();
    
    private final Object lock = new Object();

    public void adicionar(Object elemento) {
        elementos.add(elemento);
    }

    public int tamanho() {
        return elementos.size();
    }
    
    public static void main(String[] args) {
        ContenedorComWaitNotify c = new ContenedorComWaitNotify();
        
        new Thread(() -> {
            synchronized(c.lock) {
                System.out.println("Thread2 iniciada");
                if(c.tamanho() != 5) {
                    try {
                        c.lock.wait();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
                System.out.println("Thread2 finalizada");
            }
            
        }, "Thread2").start();
        
        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e1) {
            e1.printStackTrace();
        }

        new Thread(() -> {
            System.out.println("Thread1 iniciada");
            synchronized(c.lock) {
                for(int i=0; i<10; i++) {
                    c.adicionar(new Object());
                    System.out.println("Adicionado " + i);
                    
                    if(c.tamanho() == 5) {
                        c.lock.notify();
                    }
                    
                    try {
                        TimeUnit.SECONDS.sleep(1);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        }, "Thread1").start();
    }
}
</object>

Explicação de wait() e notify()

  • wait() e notify() devem ser chamados em um objeto bloqueado
  • wait() suspende a thread e libera o lock
  • notify() acorda uma thread esperando no mesmo objeto
  • notifyAll() acorda todas as threads esperando

Problema com a Impleemntação

Na implementação acima, mesmo quando o tamanho atinge 5, a Thread2 só é finalizada após a Thread1 completar sua execução. Isso ocorre porque notify() não libera o lock imediatamente.

Melhorando a Implementação com wait() e notify()

Para corrigir o problema, precisamos que ambas as threads notifiquem uma após a outra:


package exemplo.concorrencia;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;

public class ContenedorMelhorado {

    private List<object> elementos = new ArrayList();
    
    private final Object lock = new Object();

    public void adicionar(Object elemento) {
        elementos.add(elemento);
    }

    public int tamanho() {
        return elementos.size();
    }
    
    public static void main(String[] args) {
        ContenedorMelhorado c = new ContenedorMelhorado();
        
        new Thread(() -> {
            synchronized(c.lock) {
                System.out.println("Thread2 iniciada");
                if(c.tamanho() != 5) {
                    try {
                        c.lock.wait();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
                System.out.println("Thread2 finalizada");
                c.lock.notify();
            }
            
        }, "Thread2").start();
        
        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e1) {
            e1.printStackTrace();
        }

        new Thread(() -> {
            System.out.println("Thread1 iniciada");
            synchronized(c.lock) {
                for(int i=0; i<10; i++) {
                    c.adicionar(new Object());
                    System.out.println("Adicionado " + i);
                    
                    if(c.tamanho() == 5) {
                        c.lock.notify();
                        try {
                            c.lock.wait();
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    }
                    
                    try {
                        TimeUnit.SECONDS.sleep(1);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        }, "Thread1").start();
    }
}
</object>

Simplificando com CountDownLatch

A comunicação entre threads pode ser simplificada usando CountDownLatch:


package exemplo.concorrencia;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;

public class ContenedorComLatch {

    private List<object> elementos = new ArrayList();

    public void adicionar(Object elemento) {
        elementos.add(elemento);
    }

    public int tamanho() {
        return elementos.size();
    }

    public static void main(String[] args) {
        ContenedorComLatch c = new ContenedorComLatch();

        CountDownLatch latch = new CountDownLatch(1);

        new Thread(() -> {
            System.out.println("Thread2 iniciada");
            if (c.tamanho() != 5) {
                try {
                    latch.await();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println("Thread2 finalizada");

        }, "Thread2").start();

        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e1) {
            e1.printStackTrace();
        }

        new Thread(() -> {
            System.out.println("Thread1 iniciada");
            for (int i = 0; i < 10; i++) {
                c.adicionar(new Object());
                System.out.println("Adicionado " + i);

                if (c.tamanho() == 5) {
                    latch.countDown();
                }

                try {
                    TimeUnit.SECONDS.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }

        }, "Thread1").start();
    }
}
</object>

Melhorando a Implementação com CountDownLatch

Para garantir que a verificação seja precisa quando o tamanho atinge exatamente 5, precisamos de dois latches:


package exemplo.concorrencia;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;

public class ContenedorComLatchDuplo {

    List<object> elementos = Collections.synchronizedList(new ArrayList<>());

    public void adicionar(Object o) {
        elementos.add(o);
    }

    public int tamanho() {
        return elementos.size();
    }

    public static void main(String[] args) {
        ContenedorComLatchDuplo c = new ContenedorComLatchDuplo();

        CountDownLatch latch1 = new CountDownLatch(1);
        CountDownLatch latch2 = new CountDownLatch(1);

        new Thread(() -> {
            System.out.println("Thread2 iniciada");
            if (c.tamanho() != 5) {
                try {
                    latch1.await();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println("Thread2 finalizada");
            latch2.countDown();

        }, "Thread2").start();

        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e1) {
            e1.printStackTrace();
        }

        new Thread(() -> {
            System.out.println("Thread1 iniciada");
            for (int i = 0; i < 10; i++) {
                c.adicionar(new Object());
                System.out.println("Adicionado " + i);

                if (c.tamanho() == 5) {
                    latch1.countDown();
                    try {
                        latch2.await();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }

        }, "Thread1").start();

    }
}
</object>

Usando LockSupport

Outra alternativa simplificada é usar LockSupport:


package exemplo.concorrencia;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.LockSupport;

public class ContenedorComLockSupport {

    List<object> elementos = Collections.synchronizedList(new ArrayList<>());

    public void adicionar(Object o) {
        elementos.add(o);
    }

    public int tamanho() {
        return elementos.size();
    }

    static Thread thread1 = null, thread2 = null;

    public static void main(String[] args) {
        ContenedorComLockSupport c = new ContenedorComLockSupport();

        thread1 = new Thread(() -> {
            System.out.println("Thread1 iniciada");
            for (int i = 0; i < 10; i++) {
                c.adicionar(new Object());
                System.out.println("Adicionado " + i);

                if (c.tamanho() == 5) {
                    LockSupport.unpark(thread2);
                    LockSupport.park();
                }
            }
        }, "Thread1");

        thread2 = new Thread(() -> {
            System.out.println("Thread2 iniciada");
            LockSupport.park();
            System.out.println("Thread2 finalizada");
            LockSupport.unpark(thread1);
        }, "Thread2");

        thread2.start();
        thread1.start();
    }
}
</object>

Usando Semaphore

Para controle de threads, podemos usar Semaphore:


package exemplo.concorrencia;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;

public class ContenedorComSemaphore {

    List<object> elementos = Collections.synchronizedList(new ArrayList<>());

    public void adicionar(Object o) {
        elementos.add(o);
    }

    public int tamanho() {
        return elementos.size();
    }

    public static void main(String[] args) {
        ContenedorComSemaphore c = new ContenedorComSemaphore();

        Semaphore semaforo = new Semaphore(1);

        Thread thread2 = new Thread(() -> {
            System.out.println("Thread2 iniciada");
            try {
                semaforo.acquire();
                System.out.println("Thread2 finalizada");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }finally {
                semaforo.release();
            }

        }, "Thread2");


        new Thread(() -> {
            System.out.println("Thread1 iniciada");
            try{
                semaforo.acquire();
                thread2.start();
                for (int i = 0; i < 10; i++) {
                    c.adicionar(new Object());
                    System.out.println("Adicionado " + i);

                    if (c.tamanho() == 5) {
                        semaforo.release();
                        thread2.join();
                        semaforo.acquire();
                    }
                }
            }catch (InterruptedException e){
                e.printStackTrace();
            }finally {
                semaforo.release();
            }

        }, "Thread1").start();

    }
}
</object>

Exemplo de Uso de Semaphore

Um exemplo mais simples de Semaphore:


package exemplo.concorrencia;

import java.util.concurrent.Semaphore;

/**
 * Exemplo de uso de Semaphore para controle de acesso a recursos
 */
public class ExemploSemaphore {
    public static void main(String[] args) {
        // Criar semáforo com 1 permissão (fair = true)
        Semaphore semaforo = new Semaphore(1, true);
        
        // Primeira thread
        new Thread(() -> {
            try {
                semaforo.acquire();
                System.out.println("Thread1 iniciada...");
                Thread.sleep(2000);
                System.out.println("Thread1 finalizada...");
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                semaforo.release();
            }
        }).start();

        // Segunda thread
        new Thread(() -> {
            try {
                semaforo.acquire();
                System.out.println("Thread2 iniciada...");
                Thread.sleep(2000);
                System.out.println("Thread2 finalizada...");
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                semaforo.release();
            }
        }).start();
    }
}

Problema Clássico: Impressão Sequencial A1B2C3...

Um problema clássico de programação concorrente é imprimir caracteres e números de forma alternada usando duas threads:


package exemplo.concorrencia;

import java.util.concurrent.CountDownLatch;

/**
 * Exemplo de impressão sequencial A1B2C3... usando duas threads
 */
public class ImpressaoSequencial {
    public static void main(String[] args) {
        String[] caracteres = {"A", "B", "C", "D", "E", "F", "G", "H", "I", "J"};
        String[] numeros = {"1", "2", "3", "4", "5", "6", "7", "8", "9", "10"};

        final Object lock = new Object();
        StringBuilder resultado = new StringBuilder();
        CountDownLatch latch = new CountDownLatch(2);

        Thread threadCaracteres = new Thread(() -> {
            for (int i = 0; i < caracteres.length; i++) {
                synchronized (lock) {
                    resultado.append(caracteres[i]);
                    try {
                        lock.wait();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    lock.notify();
                }
            }
            latch.countDown();
        });

        Thread threadNumeros = new Thread(() -> {
            for (int j = 0; j < numeros.length; j++) {
                synchronized (lock) {
                    resultado.append(numeros[j]);
                    lock.notify();
                    try {
                        lock.wait();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
            latch.countDown();
        });

        threadCaracteres.start();
        threadNumeros.start();

        try {
            latch.await();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("Resultado: " + resultado.toString());
    }
}

Tags: java programação concorrente threads Sincronização volatile

Publicado em 6-17 00:58