Java 101

Concorrência e Paralelismo

Concorrência e Paralelismo


Esse post faz parte de uma série introdutória sobre Java, se você não conhece a linguagem e não leu os posts anteriores, recomendo os ler para ter uma visão melhor da plataforma. Nessa série, já falamos sobre o que é o ecossistema Java, o que é a biblioteca Collections, como Java faz Orientação a Objetos e o que é a biblioteca I/O, esses tópicos são necessários para o que vamos falar agora: Concorrência e Paralelismo.

O que é Concorrência e Paralelismo?!?

Nos frameworks modernos é muito raro lidarmos com paralelismo, apesar que podemos lidar com concorrência o tempo inteiro. Para entender isso precisamos primeiro compreender a diferença entre esses dois conceitos. Para isso vamos imaginar que estamos em uma biblioteca, nessa biblioteca tem dois tipos de livros: os comuns e os raros. Os livros comuns estão acessíveis na estantes para que todos possam ler e pegar emprestado, mas os livros raros estão disponíveis em uma sala especifica em que você precisa pedir para um bibliotecário pegar ele e deve ler somente na sala.

Vamos imaginar que surgiu um estranho interesse por se ler livros raros na cidade e isso gerou uma procura inesperada que surpreendeu até mesmo a direção da biblioteca.

— Todos estão disponíveis na internet! Só acessar o Projeto Gutenberg!!!

Isso gerou uma fila enorme na sessão de livros raros pois só tinha um bibliotecário para encontrar o livro, registrar a saída e ele ainda precisava observar se o livro estava sendo manipulado corretamente. Logo surgiram várias opções de como melhorar o atendimento da biblioteca, mas só poderiam ser consideradas as opções que mantivessem o cuidado para com as obras.

biblioteca antiga
Figura 1. Biblioteca da Escola Sá de Miranda

A primeira opção foi contratar mais um bibliotecário. Feita a contratação ele começou a dividir as tarefas com o mais antigo. Enquanto o primeiro cuidava de encontrar as obras e registrar as saídas, o segundo fiscalizava se todos os usuários da biblioteca estavam manuseando corretamente o livro.

A direção da biblioteca achou a opção boa, mas eles perceberam que o aumento da eficiência foi de apenas 30% enquanto se esperava 100% de eficiência com a contratação de um novo funcionário. Isso aconteceu porque as atividades foram distribuídas, mas nenhuma atividade era feita em paralelo. A atividade que mais demandava tempo era encontrar a obra e registrar a sua saída com cerca de 90% do tempo, logo essa atividade deveria ser feita em paralelo. Paralelismo acontece quando a mesma tarefa é realizada simultaneamente por mais de um bibliotecário. Assim os dois bibliotecários decidiram que iriam trabalhar em todo o conjunto de atividades aumentando a eficiência de 30% para 50%.

Mas eles encontraram um pequeno problema, só havia um computador na bancada e por isso eles precisavam se revesar para usar o computador. No começo eles replicavam a atividade que faziam quando havia apenas 1 bibliotecário: atendiam o cliente, encontravam o livro e registravam a saída. Mas perceberam que o tempo de registrar o livro também era demorado, ele demorava cerca de 3 vezes o tempo de pegar o livro, pois o software era bem lento e implementado em Javascript. Logo eles foram procurar solução para o problema deles e descobriram que estavam enfrentando um problema de concorrência. Concorrência acontece quando dois ou mais bibliotecários desejam acessar recursos limitados.

Eles perceberam que o mais demorado era entrar no sistema, logo resolveram atender 3 clientes por vez. Assim cada bibliotecário pegava o pedido de 3 clientes e depois registravam no sistema. Essa abordagem fez com que o atendimento se tornasse 70% mais eficiente do que era quando se tinha apenas um funcionário.

Por fim a biblioteca decidiu contratar uma bibliotecária para fiscalizar o manuseio dos livros porque percebeu que só tinha homens nessa história. E o aumento de eficiência passou para 150% pois ela conseguia fiscalizar e atender na bancada quando possível.

Eu espero que com essa história você tenha compreendido que esse processo acontece com qualquer servidor web. É EXATAMENTE ASSIM! Pense que a biblioteca é o servidor, os bibliotecários são threads, os livros são os recursos que o servidor usa e os clientes são os clientes que estão acessando a API do servidor. Eu não sei se os conceitos de concorrência e paralelismo são usado na bibliotecas, eles são conceitos da computação que foram usado nesse texto para descrever e diferenciar eles. Logo podemos redefinir Paralelismo quando a mesma tarefa é realizada simultaneamente por mais de uma thread ou processo e Concorrência quando acontece duas ou mais threads, ou processos desejam acessar recursos limitados.

O que é Thread e Processo?!?

Falar de Paralelismo e Concorrência não é uma tarefa fácil porque envolve vários conceitos de vários níveis. Até agora nós falamos de conceitos abstratos, mas agora vamos falar de algo bem mais concreto. Eu citei Thread e Processo e esses são conceitos sobre o sistema operacional.

Um processo é um programa rodando na memória. Ele é instanciado pelo sistema operacional e terá seu ciclo de vida até ser encerrado por si mesmo ou pelo próprio sistema operacional. Cada processo tem um identificador único e compartilha os recursos da máquina com outros processo. No trecho abaixo vemos a listagem dos 9 primeiros processos iniciados pelo Linux que ainda estão em execução, observe que o PID é o identificado único de cada processo, se eu quiser finalizar um processo preciso enviar um comando kill -15 <PID> onde -15 é o sinal que o programa deve ser encerrado, se eu usar -9 ele será encerrado imediatamente.

$ ps -aux | head
USER         PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root           1  0.0  0.0 202552  5172 ?        Ss   Jul01  70:40 /usr/lib/systemd/systemd --switched-root --system --deserialize 22
root           2  0.0  0.0      0     0 ?        S    Jul01   0:05 [kthreadd]
root           4  0.0  0.0      0     0 ?        S<   Jul01   0:00 [kworker/0:0H]
root           6  0.0  0.0      0     0 ?        S    Jul01   0:35 [ksoftirqd/0]
root           7  0.0  0.0      0     0 ?        S    Jul01   0:07 [migration/0]
root           8  0.0  0.0      0     0 ?        S    Jul01   0:00 [rcu_bh]
root           9  0.0  0.0      0     0 ?        S    Jul01  37:47 [rcu_sched]
root          10  0.0  0.0      0     0 ?        S<   Jul01   0:00 [lru-add-drain]
root          11  0.0  0.0      0     0 ?        S    Jul01   0:22 [watchdog/0]

Se fossemos falar em processos no nosso exemplo da biblioteca, teríamos que criar uma biblioteca nova. Como eu disse, dois processo compartilham recursos mas isso não significa que eles podem acessar o mesmo recurso ao mesmo tempo. Por exemplo, se eu tenho um servidor rodando na porta 80, não posso iniciar outro processo na porta 80. Um processo não tem acesso a memória de outro processo, isso significa que para um mesmo objeto não pode existir em dois processos diferentes. (Até pode, mas não vamos falar de RMI porque é complicado e já foi removido da biblioteca padrão do Java.)

— E se eu quiser que as requisições que cheguem na porta 80 sejam processadas em paralelo, como faço?!?!

Lembra da nossa biblioteca? Pois é, cada biblioteca é um processo, mas cada bibliotecário é uma Thread. Thread são dois fluxos que compartilham o mesmo espaço de memória, ou seja, é quando um processo tem dois fluxos de execução em paralelo compartilhando recursos. Threads podem acessar a mesma porta, assim como podem acessar os mesmo objetos. Mas ele não podem ser feitas ao mesmo momento. Lembra do computador do balcão da biblioteca? A metáfora da biblioteca foi construída para similar exatamente o que acontece em um computador.

Thread e Processo em Java

Vamos agora mostrar algumas classes que podemos usar para manipular processos e threads usando Java. Uma das preocupações da plataforma Java foi criar uma abstração para que o mesmo código possa ser usado em qualquer sistema operacional, logo todo o código demonstrado pode ser executando tando em Linux quando Windows e sistemas derivados do Unix como o MacOS.

Processos

Para que possamos acessar as informações de todos os processos em execução podemos usar a classe ProcessHandle (adicionada no Java 9). Navegue pela documentação dela para perceber que processos podem ter uma relação de parentescos como podemos perceber através dos métodos children(), descendants​() e parent​(). Na execução abaixo vemos as informações do processo atual e a listagem de todos os processos em execução.

$ jshell
|  Welcome to JShell -- Version 18
|  For an introduction type: /help intro

jshell> System.out.println(ProcessHandle.current().pid());
System.out.println(ProcessHandle.current  .pid()  );
20092

jshell> System.out.println(ProcessHandle.current().info());
System.out.println(ProcessHandle.current  .info  );
[user: Optional[VEPO], cmd: C:\Users\vepo\.sdkman\candidates\java\18-open\bin\java.exe, startTime: Optional[2022-09-02T18:49:28.093Z], totalTime: Optional[PT0.328125S]]

jshell> ProcessHandle.allProcesses().forEach(System.out::println);
ProcessHandle.allProcesses  .forEach(System.out::println);
0
4
72
[...]

Caso você deseje criar um novo processo, é preciso fazer uma chamada de sistema usando a classe Runtime. No trecho de código abaixo usamos o método exec para criar um novo processo.

jshell> Runtime.getRuntime().exec("pwd")
Runtime.getRuntime  .exec("pwd")
$4 ==> Process[pid=19628, exitValue="not exited"]

Na resposta da execução podemos ver que o método exec retorna o novo processo, mas não espera por ele terminar, retornando apenas um objeto Process para poder ser manipulado. Em posse desse objeto, podemos esperar por ele terminar e ver se a execução foi um sucesso.

jshell> Runtime.getRuntime().exec("pwd").waitFor()
Runtime.getRuntime  .exec("pwd").waitFor
$5 ==> 0

Percebeu que o método waitFor retornou 0? Todo processo precisa finalizar com um número e zero significa sucesso. Qualquer número diferente de zero significa que o programa foi finalizado com erro. O programa que eu executei acima é o pwd que retorna o diretório corrente em Linux, apesar de usar Windows uso o Git Bash que é um porte do MinGW que simula um bash Linux.

Threads

Threads também são criadas pelo sistemas operacional, mas o Java dá suporte a duas bibliotecas bem interessantes que precisamos demonstrar. A primeira é a classe Threads que deve ser usada com muita parcimônia essa classe, o livro Java Efetivo nos diz no Item 80: Dê preferência aos executores, às tarefas e às streams em vez de threads. Os Executors são a proxima classe que vamos ver que podem entregar as mesmas funcionalidades.

— Então porque entender Threads?!?!

Threads são importantes porque são um conceito do sistema operacional. Um executor não elimina uma thread, ele apenas facilita a implementação delas e otimiza o seu uso. Threads são gerenciadas pelo Sistema Operacional. O tempo de CPU será dividido entre os processos e as threads. Isso significa que se seu computador tem 4 CPUs e seu programa tem ao menos 2 threads, é provável que em algum momento seu programa esteja rodando em 2 CPUs ao mesmo tempo, mas quem define isso é o sistema operacional.

Threads são um recurso do sistema operacional limitado e caro. No Windows isso não é transparente, mas no Linux é possível acessar essas informações facilmente através do arquivo /proc/sys/kernel/threads-max. Na execução abaixo vemos que essa instância do Linux só pode rodar 32.768 processos concorrentes e 100.435 threads concorrentes, o que dá em média 3 threads por processo.

$ cat /proc/sys/kernel/threads-max
100435

$ cat /proc/sys/kernel/pid_max
32768

— Mas 3 threads por processo não é muito pouco?!?!

Não! Porque é praticamente impossível rodar 32.768 processos concorrentes e a grande maioria dos processos tem apenas uma thread rodando.

— Mas o que acontece quando o Java pede uma thread nova?

Para entender isso, precisamos compreender outro conceito importante de Sistemas Operacionais o espaço do usuário e o espaço do kernel (user space e kernel space). Espaço do usuário é todo o código dos nossos programas, já o espaço do kernel é o código do sistema operacional que nossos programas usam para realizar algumas operações. Toda operação que sai do espaço do usuário e vai para o espaço do kernel é custosa porque pode envolver recursos compartilhados como sockets, arquivos ou threads. Logo, criar uma nova thread é custoso porque tem que criar uma nova thread no sistema operacional que não é apenas alocar um espaço na memória.

No código abaixo uma thread é criada que sua única função é pegar o instante em que é iniciada, dormir por 500ms e armazenar o instante em que ela é finalizada. Os tempos deve ser armazenados no array tempos porque nenhuma variável pode ser alterada diretamente entre duas threads que não seja uma variável final, pois estamos falando de duas pilhas de execução diferentes.

long[] tempos = new long[4];
tempos[0] = System.nanoTime();
Thread t = new Thread() {
    @Override
    public void run() {
        tempos[1] = System.nanoTime();
        try {
            Thread.sleep(500);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        tempos[2] = System.nanoTime();
    }
};
t.start();
try {
    t.join();
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();
}
tempos[3] = System.nanoTime();
System.out.println(String.format("Tempo de inicialização: %dµs", (tempos[1] - tempos[0]) / 1000));
System.out.println(String.format("Tempo de execução     : %dµs", (tempos[2] - tempos[1]) / 1000));
System.out.println(String.format("Tempo total           : %dµs", (tempos[3] - tempos[0]) / 1000));

O resultado da execução é o visto abaixo, observe que demora quase meio milissegundo para que a thread seja iniciada. Esse tempo pode parecer pouco, mas se houver um uso abusivo dessa classe pode impactar a performance, pois esse tempo é latência adicionada ao processamento.

Tempo de inicialização: 436µs
Tempo de execução     : 510061µs
Tempo total           : 510643µs

Observe também que usamos os métodos start e join, eles servem para controlar a thread. Uma thread não inicia sua execução imediatamente, é preciso que o código que a instanciou dispare a execução. Mas quando a execução se inicia os dois códigos começam a ser executados em paralelo, para que se aguarde a finalização da thread é preciso usar o método join que fará com que a thread corrente seja bloqueada até que a outra seja finalizada.

Outro ponto importante é o uso da exceção InterruptedException, ela é lançada pela JVM toda vez que a thread é interrompida pelo sistema operacional.

— Mas o que significa a thread ser interrompida pelo sistema operacional?

Ora, já teve vezes em que uma janelinha do Windows ficou não responsiva e você foi lá forçou ela a ser fechada? Ou você executou um comando no bash e não quis esperar a resposta e pressionou CRTL + C. Nessa hora o sistema operacional envia um sinal ao programa que ele deve finalizar, o SIGTERM. Quando esse sinal é recebido pela thread, ela deve liberar todos os recursos e se finalizar, por isso quanto tempos uma InterruptedException é hora de limpar a casa e fechar tudo.

Se você ignorar essa exception, o seu processo pode virar um processo zumbi, pois outras threads podem ter obedecido o sinal e já ter finalizada criando instabilidade para a execução. Então, recebeu um InterruptedException, fecha tudo e chama Thread.currentThread().interrupt().

Há um outro sinal que não fornece essa informação, o SIGKILL, o sistema operacional simplesmente mata a execução sem nenhuma educação e protocolo.

Por fim, você deve ter reparado que implementamos o método run na thread. Esse método é definido na classe Runnable, essa classe é muito importante porque nem sempre precisamos definir uma thread nova, podemos estender essa classe e criar quantas threads forem necessária com o mesmo código.

Existe a possibilidade de se criar grupos de threads com a classe ThreadGroup, mas não vamos abordar ela porque todas as funcionalidades delas podem ser endereçadas com Executors.

Executors

Executors são a nova, em relação a Thread, biblioteca adicionada no Java 5 que permite um controle melhor sobre Threads e grupos de threads. A vantagem do uso da classe Executors é que temos uma interface bem mais interessante, como veremos a diante. Primeiro vamos focar em performance.

Como falamos, criar thread pode ser uma operação cara, com executors podemos criar pool de threads ou reutilizar threads já existentes sem a necessidade de se criar novas threads. Se compararmos a execução vemos que o uso de pools de thread diminuem o tempo gasto com a inicialização dessas threads. Nos teste que executamos, vemos que o tempo de inicialização e o tempo médio total são menores, somente o tempo médio de execução é maior, mas isso é devido a fatores externos ao código já que executamos o mesmo código em ambos o caso.

Usando Threads
Tempo de inicialização: 402µs
Tempo de execução     : 511415µs
Tempo total           : 511939µs

Tempo médio de inicialização: 77370µs
Tempo médio de execução     : 50792817µs
Tempo médio total           : 50880048µs

Usando Executors
Tempo de inicialização: 2829µs    (+2.427µs)
Tempo de execução     : 509877µs  (-1.538µs)
Tempo total           : 513237µs  (+1.298µs)

Tempo médio de inicialização: 19708µs    (-57.662µs)
Tempo médio de execução     : 50806122µs (+13.305µs)
Tempo médio total           : 50839674µs (-40.374µs)

Para se criar um ExecutorService deve se usar a classe Executors. Nessa classe tempos vários tipos de ExecutorServices, mas os mais importantes são os FixedThreadPool, CachedThreadPool e ScheduledThreadPool. Cada um desses tem suas peculiaridades que não vamos abordar aqui, apenas vamos ressaltar que ScheduledThreadPool deve ser usado quando precisamos criar threads que executam em intervalos pré definidos.

long[] tempos = new long[4];
tempos[0] = System.nanoTime();
ExecutorService executor = Executors.newSingleThreadExecutor();
Future<?> ft = executor.submit(() -> {
        tempos[1] = System.nanoTime();
        try {
            Thread.sleep(500);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        tempos[2] = System.nanoTime();
    });
try {
    ft.get();
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();
}
tempos[3] = System.nanoTime();
System.out.println(String.format("Tempo de inicialização: %dµs", (tempos[1] - tempos[0]) / 1000));
System.out.println(String.format("Tempo de execução     : %dµs", (tempos[2] - tempos[1]) / 1000));
System.out.println(String.format("Tempo total           : %dµs", (tempos[3] - tempos[0]) / 1000));
executor.shutdown();

A grande diferença é que quando criamos uma nova execução o ExecutorService retorna um Future que irá prover informações sobre a execução e o retorno da execução. Um executor não aceita apenas um Runnable, mas também Callable que retorna valores. A opção por usar Callable irá tornar seu código mais legível.

Outro ponto importante do uso de ExecutorService é que assim que uma nova atividade é submetida, ela entrará na fila de execução. É preciso ressaltar que ela só será executada quando houver thread disponível. Isso significa que um ExecutorService deve ser usado para atividades rápidas e não com longa duração. Se você precisar executar algo que dure toda execução crie um ExecutorService de tamanho pré-definido, usando newFixedThreadPool ou cria a thread manualmente.

Por fim um ExecutorService não finaliza automaticamente, ele deve ser finalizado através do método shutdown. Caso você não chame esse método o seu programa vai virar um processo zumbi.

Controle de Concorrência

Como vimos concorrência é um problema diferente de paralelismo, ela é a solução para garantir que apenas uma thread está executando um trecho de código. As soluções de concorrência da JVM são propostas para que seja usadas dentro de uma mesma instância da JVM, ou seja, não é possível pela biblioteca padrão garantir concorrência entre dois processos distintos.

Vamos começar a ver pelos modos mais antigos, mesmo que eles já não sejam os mais utilizados. O primeiro dele é o mais simples de todos, usar o modificado synchronized. No trecho de código abaixo, o synchronized permite que o de counter seja impresso na linha de comando sequencialmente, caso seja removido valores repetidos e fora de ordem aparecerão. O synchronized vai garantir que quando uma thread está executando o método printAndIncrement as outras serão bloqueadas até que a execução seja finalizada. Quando usamos o synchronized em um método de instância, o efeito do bloqueio só acontece quando método de um mesmo objeto são executados concorrentemente, caso o controle de concorrência deva ser feito globalmente o synchronized pode ser usado em métodos estáticos.

public class Sync {
    private int counter;

    public Sync() {
        counter = 0;
    }

    public synchronized void printAndIncrement() {
        counter++;
        System.err.println(String.format("Thread [%s] valor:%d", Thread.currentThread().getName(), counter));
    }
}

Usar o modificador synchronized ainda é uma prática bem comum apesar que existem soluções melhores. Ele deve ser usado quando é realmente necessário bloquear todo o bloco de execução. Se você precisa usar em uma das classes da biblioteca Collection (vista na sessão 3) a melhor solução é usar uma das classes da biblioteca padrão do Java. A classe Collections tem alguns métodos que criam um envolucro para objetos, por exemplo, se eu tenho uma lista e desejo usar ela em várias threads, eu posso usar Collections.synchronizedList(minhaLista).

Observe no trecho de código abaixo que temos duas listas mas apenas a segunda pode ser usada em várias threads. Qualquer operação na segunda lista reflete na primeira. Usar uma lista não sincronizada pode ser que não faça o programa apresentar uma exceção, mas com certeza vai criar estados inconsistentes.

$ jshell
|  Welcome to JShell -- Version 18
|  For an introduction type: /help intro

jshell> List<String> minhaLista = new ArrayList<>();
List<String> minhaLista = new ArrayList<>  ;
minhaLista ==> []

jshell> List<String> minhaListaSync = Collections.synchronizedList(minhaLista);
List<String> minhaListaSync = Collections.synchronizedList(minhaLista);
minhaListaSync ==> []

jshell> minhaLista.add("String 1")
minhaLista.add("String 1")
$3 ==> true

jshell> minhaListaSync.add("String 2")
minhaListaSync.add("String 2")
$4 ==> true

jshell> minhaLista
minhaLista
minhaLista ==> [String 1, String 2]

jshell> minhaListaSync
minhaListaSync
minhaListaSync ==> [String 1, String 2]

O synchronized também pode ser usado como bloco de código, mas essa é uma forma um pouco arcaica como veremos. Vamos imagina que temos duas threads, uma produzindo valores e a outra consumindo. A thread que consome valores deve sempre retornar um valor, não importa se não existe valores no momento. Normalmente isso é o que acontece quando temos um buffer em quem uma thread está produzindo e outra consumindo.

public class Buffer {
    private Object lock = new Object();
    private List<int[]> _buffer = new LinkedList<>();
    public void add(int[] valores) {
        synchronized(lock) {
            _buffer.add(valores);
            lock.notifyAll();
        }
    }

    public int[] consume() {
        int[] nextValue = null;
        synchronized(lock) {
            while(_buffer.isEmpty()) {
                lock.wait();
            }
            nextValue = _buffer.remove(0);
        }
        return nextValue;
    }
}

A classe acima está implementada usando técnicas que não devem mais ser usadas. O primeiro problema é que toda chamada ao bloco sincronizado será feita por apenas uma thread por vez, existe técnicas mais recentes que permitem que mais de uma thread acessem um bloco sincronizado que veremos a seguir. O bloco sincronizado deve ser feito usando um objeto em comum, no caso esse objeto pode ser compartilhado em mais de um objeto, caso a thread deseje esperar por alguma condição, deve se usar o método wait que será despertado por uma chamada ao método notify ou notifyAll. No exemplo acima, se não há valores a serem consumidos, eles devem esperar por um valor.

Uma alternativa ao bloco sincronizado é o uso da classe ReadWriteLock. A necessidade dessa classe surgem quando se percebe que apenas as threads que escrevem devem ter acesso exclusivo, as threads de leitura podem acessar os métodos livremente. No exemplo acima não é possível usar ela porque ambos os métodos escrevem ao adicionar e remover valores na lista por isso serão necessárias algumas alterações.

public class Buffer {
    private final ReadWriteLock readWriteLock = new ReentrantReadWriteLock();
    private final Lock readLock = readWriteLock.readLock();
    private final Lock writeLock = readWriteLock.writeLock();
    private final Condition newItem = writeLock.newCondition();
    private final List<int[]> _buffer = new LinkedList<>();

    public void add(int[] valores) {
        writeLock.lock();
        try {
            _buffer.add(valores);
            newItem.signalAll();
        } finally {
            writeLock.unlock();
        }
    }

    public int available() {
        readLock.lock();
        try {
            return _buffer.size();
        } finally {
            readLock.unlock();
        }
    }

    public int[] consume(int position) {
        readLock.lock();
        try {
            while (_buffer.size() <= position) {
                newItem.await();
            }
            return _buffer.get(position);
        } finally {
            readLock.unlock();
        }
    }
}

Na nossa nova classe Buffer, quem é responsável por saber a posição no buffer é a thread que consome que pode ser mais de uma. Cada chamada ao método consome e available poderão ser feitas sem nenhum bloqueio. Mas se uma chamada ao método add for feita, ela deverá esperar pela finalização de todas as chamadas aos locks de leitura e todos os locks de leitura deverão esperar pela finalização do lock de escrita. Os locks de leitura podem ser executados concorrentemente, mas o lock de escrita só pode acontecer quando nenhum outro lock estiver ativo.

No código acima podemos ver também o uso da classe Condition. Essa classe deve ser usada quando esperamos alguma condição especifica, no nosso caso é a lista ter o item desejado ou não. O uso dessa classe é bem similar ao dos métodos wait, notify e notifyAll, mas é adicionada uma melhor semântica pode podemos criar mais que uma condição e usar elas para dar uma boa legibilidade ao código.

Por fim a biblioteca padrão do Java tem uma série de classes atômicas que são extremamente úteis. Elas estão no pacote java.util.concurrent.atomic e todas elas tem comportamento similar, vão permitir você realizar operações atômicas sem se preocupar com a concorrência. Para demonstrar o uso delas vou mostrar o caso mais comum que é criar um contador sincronizado.

ExecutorService executor = Executors.newFixedThreadPool(15);
AtomicInteger counter = new AtomicInteger(0);
List<Future<?>> allFuture = new ArrayList<>();
for (int i = 0; i < 1_000; ++i) {
    allFuture.add(executor.submit(() -> System.out.println("Contador: " + counter.incrementAndGet())));
}
executor.shutdown();
try {
    executor.awaitTermination(1, TimeUnit.SECONDS);
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();
}

No código acima não podemos garantir que os valores impressos estarão em ordem, mas podemos garantir que todos os valores de 1 a 1000 serão impressos. A classe AtomicInteger garante que a operação incrementAndGet seja feita atomicamente, isso significa que ela não será interrompida por outra chamada a outro método desse mesmo objeto. Todas as classes desse pacote merecem nossa atenção pois elas são bem importantes, principalmente se você está desenvolvendo um aplicativo Desktop que irá lidar com várias threads.

Originally published September 03, 2022