Simultaneidade Java: domine a arte do multithreading

Simultaneidade Java: domine a arte do multithreading

Aproveite o poder da simultaneidade Java para obter aplicativos eficientes e escaláveis. Maximize o desempenho e a capacidade de resposta com programação simultânea.

Imagem em destaque

Aproveite o poder da computação paralela e multithreading para turbinar seus aplicativos com simultaneidade Java. Iniciante ou especialista, este guia irá catapultar suas habilidades para a arena acelerada da programação simultânea. Aperte os cintos e vamos desvendar as APIs robustas do Java para uma viagem fascinante no reino da codificação de alto desempenho!

O que é simultaneidade?

A simultaneidade é usada por todas as principais empresas de desenvolvimento Java e refere-se à capacidade de um programa executar várias tarefas simultaneamente. Ele permite a utilização eficiente dos recursos do sistema e pode melhorar o desempenho geral e a capacidade de resposta do aplicativo.

Os conceitos, classes e interfaces de simultaneidade Java usadas para multithreading, como `Thread`, `Runnable`, `Callable`, `Future`, `ExecutorService` e as classes em `java.util.concurrent`, fazem parte de as bibliotecas Java padrão, portanto não deve haver muita diferença entre as várias estruturas Java. Mas antes de entrarmos no âmago da questão, primeiro uma questão muito básica.

Vários threads em Java?

Multithreading refere-se a uma técnica de programação em que existem vários threads de execução em um único aplicativo.

Multithreading é apenas uma maneira de obter simultaneidade em Java. A simultaneidade também pode ser alcançada por outros meios, como multiprocessamento, programação assíncrona ou programação orientada a eventos.

Mas apenas para os não iniciados, um 'thread' é um fluxo único de processos que pode ser executado independentemente pelo processador de um computador.

Por que você deve usar simultaneidade Java?

A simultaneidade é uma ótima solução para criar aplicativos modernos de alto desempenho por vários motivos.

Performance melhorada

A simultaneidade permite a divisão de tarefas complexas e demoradas em partes menores que podem ser executadas simultaneamente, resultando em melhor desempenho. Isso aproveita ao máximo as CPUs multi-core atuais e pode fazer com que os aplicativos sejam executados muito mais rapidamente.

Melhor utilização de recursos

A simultaneidade permite a utilização ideal dos recursos do sistema, resultando em maior eficiência de recursos. Ao implementar operações de E/S assíncronas, o sistema pode evitar o bloqueio de um único thread e permitir que outras tarefas sejam executadas simultaneamente, maximizando assim a utilização de recursos e a eficiência do sistema.

Capacidade de resposta aprimorada

Capacidade de resposta aprimorada: a simultaneidade pode aprimorar a experiência do usuário em aplicativos interativos, garantindo que o aplicativo permaneça responsivo. Durante a execução de uma tarefa computacionalmente intensa por um thread, outro thread pode manipular simultaneamente entradas do usuário ou atualizações da interface do usuário.

Modelagem Simplificada

Em certos cenários, como simulações ou motores de jogos, entidades simultâneas são inerentes ao domínio do problema e, portanto, uma abordagem de programação simultânea é mais intuitiva e com melhor desempenho. Isso é comumente chamado de modelagem simplificada.

API de simultaneidade robusta

Java oferece uma API de simultaneidade abrangente e adaptável que inclui pools de threads, coleções simultâneas e variáveis ​​atômicas para garantir robustez. Essas ferramentas de simultaneidade simplificam o desenvolvimento de código simultâneo e atenuam problemas de simultaneidade predominantes.

Desvantagens da simultaneidade

É importante entender que a programação simultânea não é para iniciantes. Ele traz um maior nível de complexidade aos seus aplicativos e acarreta um conjunto distinto de dificuldades, como gerenciar a sincronização, evitar conflitos e garantir a segurança do thread, e isso não é tudo. Aqui estão algumas coisas a considerar antes de mergulhar.

Complexidade: Escrever programas simultâneos pode ser mais difícil e demorado do que escrever programas de thread único. É fundamental que os desenvolvedores tenham noção de sincronização, visibilidade de memória, operações atômicas e comunicação de thread.

Dificuldades de depuração: A natureza não determinística dos programas simultâneos pode representar um desafio durante a depuração. A ocorrência de condições de corrida ou impasses pode ser inconsistente, o que representa um desafio na sua reprodução e resolução.

Potencial de erro: O tratamento inadequado da simultaneidade pode levar a erros como condições de corrida, conflitos e interferência de thread. Este problema pode representar dificuldades na identificação e resolução.

Contenção de recursos: Aplicações simultâneas mal projetadas podem causar contenção de recursos, na qual muitos threads lutam pelo mesmo recurso, resultando em perda de desempenho.

A sobrecarga: Criar e manter threads aumenta o uso de CPU e memória em sua máquina. O gerenciamento insuficiente pode resultar em desempenho abaixo do ideal ou esgotamento de recursos.

Complicado de testar: Como a execução de threads é imprevisível e não determinística, testar programas simultâneos pode ser um desafio.

Portanto, embora a simultaneidade seja uma ótima opção, nem tudo é fácil.

Tutorial de simultaneidade Java: criação de thread

Threads podem ser criados de três maneiras. Aqui, criaremos o mesmo thread usando métodos diferentes.

Herdando da classe Thread

Uma maneira de criar um thread é herdá-lo da classe thread. Então, tudo que você precisa fazer é substituir o método run do objeto thread. O método run será invocado quando o thread for iniciado.

public class ExampleThread extends Thread {
    @Override
    public void run  {
        // contains all the code you want to execute
        // when the thread starts

        // prints out the name of the thread
        // which is running the process
        System.out.println(Thread.currentThread .getName );
    }
}

Para iniciar um novo thread, criamos uma instância da classe acima e chamamos o método start nela.

public class ThreadExamples {
    public static void main(String  args) {
        ExampleThread thread = new ExampleThread ;
        thread.start ;
    }
}

Um erro comum é chamar o método run para iniciar o thread. Pode parecer correto, pois tudo funciona bem, mas chamar o método run não inicia um novo thread. Em vez disso, ele executa o código do thread dentro do thread pai. Usamos o método start para executar um novo thread.

Você pode testar isso chamando “thread.run ” em vez de “thread.start ” no código acima. Você verá que “main” está impresso no console, o que significa que não estamos criando nenhum thread. Em vez disso, a tarefa é executada no thread principal. Para mais informações sobre classe de thread, certifique-se de verificar o documentos.

Implementando interface executável

Outra forma de criar um thread é implementando a interface Runnable. Semelhante ao método anterior, você precisa substituir o método run , que conterá todas as tarefas que você deseja que o thread executável execute.

public class ExampleRunnable implements Runnable {
    @Override
    public void run  {
        System.out.println(Thread.currentThread .getName );
    }
}

public class ThreadExamples {
    public static void main(String  args) {
        ExampleRunnable runnable = new ExampleRunnable ;
        Thread thread = new Thread(runnable);
        thread.start ;
    }
}

Ambos os métodos funcionam exatamente da mesma forma, sem diferença no desempenho. Entretanto, a interface Runnable deixa a opção de estender a classe com alguma outra classe, já que você pode herdar apenas uma classe em Java. Também é mais fácil criar um pool de threads usando executáveis.

Usando declarações anônimas

Este método é muito semelhante ao método acima. Mas em vez de criar uma nova classe que implemente o método executável, você cria uma função anônima que contém a tarefa que deseja executar.

public class Main {
    public static void main(String  args) {
        Thread thread = new Thread(  -> {
            // task you want to execute
            System.out.println(Thread.currentThread .getName );
        });
        thread.start ;
    }
}

Métodos de thread

Se chamarmos o método threadOne.join dentro de threadTwo, ele colocará threadTwo em um estado de espera até que threadOne termine a execução.

Chamar o método estático Thread.sleep(long timeInMilliSeconds) colocará o thread atual em um estado de espera cronometrada.

Ciclo de vida do thread

Um thread pode estar em um dos seguintes estados. Use Thread.getState para obter o estado atual do thread.

  1. NOVO: criado mas não iniciou a execução
  2. RUNNABLE: execução iniciada
  3. BLOQUEADO: aguardando para adquirir um bloqueio
  4. WAITING: aguardando que algum outro thread execute uma tarefa
  5. TIMED_WAITING: aguardando um período de tempo especificado
  6. TERMINADO: execução concluída ou abortada

Executores e pools de threads

Threads requerem alguns recursos para serem iniciados e são interrompidos após a conclusão da tarefa. Para aplicativos com muitas tarefas, você desejaria colocar tarefas na fila em vez de criar mais threads. Não seria ótimo se pudéssemos de alguma forma reutilizar threads existentes e ao mesmo tempo limitar o número de threads que você pode criar?

A classe ExecutorService nos permite criar um certo número de threads e distribuir tarefas entre os threads. Como você está criando um número fixo de threads, você tem muito controle sobre o desempenho do seu aplicativo.

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class Main {
    public static void main(String  args) {
        ExecutorService executor = Executors.newFixedThreadPool(2);

        for (int i = 0; i < 20; i++) {
            int finalI = i;
            executor.submit(  -> System.out.println(Thread.currentThread .getName  + " is executing task " + finalI));
        }
        executor.shutdown ;
    }
}

Condições da corrida

Uma condição de corrida é uma condição de um programa em que seu comportamento depende do tempo relativo ou da intercalação de vários threads ou processos. Para entender melhor isso, vejamos o exemplo abaixo.

public class Increment {
    private int count = 0;

    public void increment  {
        count += 1;
    }

    public int getCount  {
        return this.count;
    }
}

public class RaceConditionsExample {
    public static void main(String  args) {
        Increment eg = new Increment ;
        for (int i = 0; i < 1000; i++) {
            Thread thread = new Thread(eg::increment);
            thread.start ;
        }
        System.out.println(eg.getCount );
    }
}

Aqui, temos uma classe Increment que armazena uma contagem variável e uma função que incrementa a contagem. No RaceConditionsExample, estamos iniciando mil threads, cada um dos quais invocará o método increment . Por fim, estamos aguardando que todos os threads terminem a execução e então imprimamos o valor da variável count.

Se você executar o código várias vezes, notará que às vezes o valor final de count é menor que 1.000. Para entender por que isso acontece, vamos pegar dois threads, Thread-x e Thread-y, como exemplos. Os threads podem executar a operação de leitura e gravação em qualquer ordem. Portanto, haverá um caso em que a ordem de execução será a seguinte.

Thread-x: Reads this.count (which is 0)
Thread-y: Reads this.count (which is 0)
Thread-x: Increments this.count by 1
Thread-y: Increments this.count by 1
Thread-x: Updates this.count (which becomes 1)
Thread-y: Updates this.count (which becomes 1)

Nesse caso, o valor final da variável de contagem é 1 e não 2. Isso ocorre porque ambos os threads estão lendo a variável de contagem antes que qualquer um deles possa atualizar o valor. Isso é conhecido como condição de corrida. Mais especificamente, uma condição de corrida “leitura-modificação-gravação”.

Estratégias de sincronização

Na seção anterior, examinamos o que são condições de corrida. Para evitar condições de corrida, precisamos sincronizar as tarefas. Nesta seção, veremos diferentes maneiras de sincronizar diferentes processos em vários threads.

Trancar

Haverá casos em que você deseja que uma tarefa seja executada por um único thread por vez. Mas como você garantiria que uma tarefa estivesse sendo executada por apenas um thread?

Uma maneira de fazer isso é usando bloqueios. A ideia é que você crie um objeto de bloqueio que possa ser “adquirido” por um único thread por vez. Antes de executar uma tarefa, o thread tenta adquirir o bloqueio. Se conseguir fazer isso, ele prossegue com a tarefa. Depois de concluir a tarefa, ele libera o bloqueio. Se o thread não conseguir adquirir o bloqueio, significa que a tarefa está sendo executada por outro thread.

Aqui está um exemplo usando a classe ReentrantLock, que é uma implementação da interface de bloqueio.

import java.util.concurrent.locks.ReentrantLock;


public class LockExample {
    private final ReentrantLock lock = new ReentrantLock ;
    private int count = 0;

    public int increment  {
        lock.lock ;
        try {
            return this.count++;
        } finally {
            lock.unlock ;
        }
    }
}

Quando chamamos o método lock em um thread, ele tenta adquirir o bloqueio. Se for bem-sucedido, ele executa a tarefa. No entanto, se não tiver êxito, o thread será bloqueado até que o bloqueio seja liberado.

O isLocked retorna um valor booleano dependendo se o bloqueio pode ser adquirido ou não.

O método tryLock tenta adquirir o bloqueio de forma não bloqueadora. Ele retorna verdadeiro se for bem-sucedido e falso caso contrário.

O método unlock libera o bloqueio.

ReadWriteLock

Ao trabalhar com dados e recursos compartilhados, normalmente você deseja duas coisas:

  1. Vários threads deverão ser capazes de ler o recurso por vez, se ele não estiver sendo gravado.
  2. Apenas um thread pode gravar o recurso compartilhado por vez, se nenhum outro thread estiver lendo ou gravando.

A interface ReadWriteLock consegue isso usando dois bloqueios em vez de um. O bloqueio de leitura pode ser adquirido por vários threads ao mesmo tempo se nenhum thread tiver adquirido o bloqueio de gravação. O bloqueio de gravação só pode ser adquirido se o bloqueio de leitura e gravação não tiver sido adquirido.

Aqui está um exemplo para demonstrar. Suponha que temos uma classe SharedCache que simplesmente armazena pares de valores-chave conforme mostrado abaixo.

public class SharedCache {
    private Map<String, String> cache = new HashMap<> ;

    public String readData(String key) {
        return cache.get(key);
    }

    public void writeData(String key, String value) {
        cache.put(key, value);
    }
}

Queremos que vários threads leiam nosso cache ao mesmo tempo (enquanto ele não está sendo gravado). Mas apenas um thread pode gravar nosso cache por vez. Para conseguir isso, usaremos o ReentrantReadWriteLock que é uma implementação da interface ReadWriteLock.

public class SharedCache {
    private Map<String, String> cache = new HashMap<> ;
    private ReentrantReadWriteLock lock = new ReentrantReadWriteLock ;
    
    public String readData(String key) {
        lock.readLock .lock ;
        try {
            return cache.get(key);
        } finally {
            lock.readLock .unlock ;
        }
    }
    

    public void writeData(String key, String value) {
        lock.writeLock .lock ;
        try {
            cache.put(key, value);
        } finally {
            lock.writeLock .unlock ;
        }
    }
}

Blocos e métodos sincronizados

Blocos sincronizados são pedaços de código Java que podem ser executados por apenas um thread por vez. Eles são uma maneira simples de implementar a sincronização entre threads.

// SYNTAX
synchronized (Object reference_object) {
 // code you want to be synchronized
}

Ao criar um bloco sincronizado, você precisa passar um objeto de referência. No exemplo acima, “este” ou o objeto atual é o objeto de referência, o que significa que se múltiplas instâncias de forem criadas, elas não serão sincronizadas.

Você também pode sincronizar um método usando a palavra-chave sincronizada.

public synchronized int increment ;

Impasses

O deadlock ocorre quando dois ou mais threads não conseguem prosseguir porque cada um deles está aguardando que o outro libere um recurso ou execute uma ação específica. Como resultado, permanecem presos indefinidamente, incapazes de progredir.

Considere isso: você tem dois threads e dois locks (vamos chamá-los de threadA, threadB, lockA e lockB). ThreadA tentará adquirir o lockA primeiro e, se tiver sucesso, tentará adquirir o lockB. ThreadB, por outro lado, tenta adquirir primeiro o lockB e depois o lockA.

import java.util.concurrent.locks.ReentrantLock;

public class Main {
    public static void main(String  args) {
        ReentrantLock lockA = new ReentrantLock ;
        ReentrantLock lockB = new ReentrantLock ;

        Thread threadA = new Thread(  -> {
            lockA.lock ;
            try {
                System.out.println("Thread-A has acquired Lock-A");
                lockB.lock ;
                try {
                    System.out.println("Thread-A has acquired Lock-B");
                } finally {
                    lockB.unlock ;
                }
            } finally {
                lockA.unlock ;
            }
        });

        Thread threadB = new Thread(  -> {
            lockB.lock ;
            try {
                System.out.println("Thread-B has acquired Lock-B");
                lockA.lock ;
                try {
                    System.out.println("Thread-B has acquired Lock-A");
                } finally {
                    lockA.unlock ;
                }
            } finally {
                lockB.unlock ;
            }
        });
        
        threadA.start ;
        threadB.start ;
    }
}

Aqui, ThreadA adquire lockA e aguarda lockB. ThreadB adquiriu lockB e está aguardando para adquirir lockA. Aqui, threadA nunca adquirirá lockB, pois é mantido por threadB. Da mesma forma, threadB nunca pode adquirir lockA, pois é mantido por threadA. Esse tipo de situação é chamado de impasse.

Aqui estão alguns pontos que você deve ter em mente para evitar impasses.

  1. Defina uma ordem estrita na qual os recursos devem ser adquiridos. Todos os threads devem seguir a mesma ordem ao solicitar recursos.
  2. Evite aninhamento de bloqueios ou blocos sincronizados. A causa do impasse no exemplo anterior foi que os threads não conseguiram liberar um bloqueio sem adquirir o outro bloqueio.
  3. Certifique-se de que os threads não adquiram vários recursos simultaneamente. Se um thread contém um recurso e precisa de outro, ele deverá liberar o primeiro recurso antes de tentar adquirir o segundo. Isso evita dependências circulares e reduz a probabilidade de conflitos.
  4. Defina tempos limite ao adquirir bloqueios ou recursos. Se um thread não conseguir adquirir um bloqueio dentro de um tempo especificado, ele libera todos os bloqueios adquiridos e tenta novamente mais tarde. Isso evita uma situação em que um thread mantém um bloqueio indefinidamente, causando potencialmente um conflito.

Coleções simultâneas Java

A plataforma Java fornece várias coleções simultâneas no pacote “java.util.concurrent” que são projetadas para serem thread-safe e suportar acesso simultâneo.

ConcurrentHashMap

O ConcurrentHashMap é uma alternativa thread-safe ao HashMap. Ele fornece métodos otimizados para atualizações atômicas e operações de recuperação. Por exemplo, os métodos putIfAbsent , remove e replace executam as operações atomicamente e evitam condições de corrida.

CopyOnWriteArrayList

Considere um cenário em que um thread está tentando ler ou iterar em uma lista de array enquanto outro thread está tentando modificá-la. Isso pode criar inconsistências nas operações de leitura ou até mesmo lançar ConcurrentModificationException.

CopyOnWriteArrayList resolve esse problema copiando o conteúdo de todo o array sempre que ele é modificado. Dessa forma, podemos iterar sobre a cópia anterior enquanto uma nova cópia está sendo modificada.

O mecanismo de segurança de thread de CopyOnWriteArrayList tem um custo. As operações de modificação, como adicionar ou remover elementos, são caras porque exigem a criação de uma nova cópia do array subjacente. Isso torna CopyOnWriteArrayList adequado para cenários em que as leituras são mais frequentes do que as gravações.

Alternativas à simultaneidade em Java

Se você deseja construir um aplicativo de alto desempenho independente do sistema operacional, optar pela simultaneidade Java é uma ótima opção. Mas não é o único show na cidade. Aqui estão algumas alternativas que você pode considerar.

A linguagem de programação Go, também conhecida como Golang, é uma linguagem de programação de tipo estático desenvolvida pelo Google e amplamente utilizada em serviços de desenvolvimento Go. Esta solução de software é elogiada por seu desempenho eficiente e simplificado, especialmente no tratamento de operações simultâneas. Central para sua habilidade em simultaneidade é o uso de goroutines, threads leves gerenciados pelo tempo de execução Go, que tornam a programação simultânea simples e altamente eficiente.

Scala, uma linguagem compatível com JVM que combina perfeitamente paradigmas funcionais e orientados a objetos, é a escolha preferida de muitas empresas de desenvolvimento de Scala. Uma de suas características mais fortes é a biblioteca robusta conhecida como Akka. Projetado especificamente para lidar com operações simultâneas, o Akka emprega o modelo Actor para simultaneidade, fornecendo uma alternativa intuitiva e menos propensa a erros à simultaneidade tradicional baseada em thread.

Python, uma linguagem amplamente usada em serviços de desenvolvimento python, vem equipado com bibliotecas de programação simultânea, como assíncio, multiprocessamento e threading. Essas bibliotecas capacitam os desenvolvedores a gerenciar a execução paralela e simultânea de maneira eficaz. No entanto, é importante estar ciente do Global Interpreter Lock (GIL) do Python, que pode limitar a eficiência do threading, especialmente para tarefas vinculadas à CPU.

Erlang/OTP é uma linguagem de programação funcional que foi projetada especificamente para construir sistemas altamente simultâneos. OTP é um middleware que oferece uma coleção de princípios de design e bibliotecas para o desenvolvimento de tais sistemas.

Conclusão

A simultaneidade Java abre as portas para um melhor desempenho de aplicativos por meio de computação paralela e multithreading, um recurso valioso para desenvolvedores na era dos processadores multi-core. Ele aproveita APIs robustas, tornando a programação simultânea eficiente e confiável. No entanto, ele traz seu próprio conjunto de desafios. Os desenvolvedores precisam gerenciar os recursos compartilhados com cuidado para evitar problemas como impasses, condições de corrida ou interferência de thread. A complexidade da programação simultânea em Java pode exigir mais tempo para ser dominada, mas os benefícios potenciais para o desempenho do aplicativo fazem com que esse esforço valha a pena.

Se você gostou deste tutorial de simultaneidade Java, não deixe de conferir nossos outros recursos sobre Java

  • Melhores estruturas Java GUI
  • Melhores IDEs Java e editores de texto
  • 10 melhores bibliotecas e ferramentas Java PNL
  • Ajuste de desempenho Java: 10 técnicas comprovadas para maximizar a velocidade Java
  • As 7 melhores ferramentas Java Profiler para 2023

Conteúdo Relacionado

O Rails 8 sempre foi um divisor de águas...
Os genéricos são uma característica poderosa em Java que...
A GenAI está transformando a força de trabalho com...
Entenda o papel fundamental dos testes unitários na validação...
Aprenda como os testes de carga garantem que seu...
Aprofunde-se nas funções complementares dos testes positivos e negativos...
Vídeos deep fake ao vivo cada vez mais sofisticados...
Entenda a metodologia por trás dos testes de estresse...
Descubra a imprevisibilidade dos testes ad hoc e seu...
A nomeação de Nacho De Marco para o Fast...
Aprenda como os processos baseados em IA aprimoram o...
Java e JavaScript são duas das linguagens de programação...
Introdução Quando se trata de desenvolvimento de software, a...
Os desenvolvedores Java enfrentam uma variedade de erros relacionados...
Neste artigo, discutiremos os benefícios de gerar imagens em...
O Java 23 finalmente foi lançado, e podemos começar...
Milhares de sites são criados todos os dias. Não...
Os recursos de linguagem que serão incluídos na próxima...
Zurück zum Blog

Hinterlasse einen Kommentar

Bitte beachte, dass Kommentare vor der Veröffentlichung freigegeben werden müssen.