CodeGym /Blogue Java /Random-PT /Melhor juntos: Java e a classe Thread. Parte III — Intera...
John Squirrels
Nível 41
San Francisco

Melhor juntos: Java e a classe Thread. Parte III — Interação

Publicado no grupo Random-PT
Uma breve visão geral dos detalhes de como as threads interagem. Anteriormente, vimos como os threads são sincronizados entre si. Desta vez, vamos nos aprofundar nos problemas que podem surgir à medida que os threads interagem e falaremos sobre como evitá-los. Também forneceremos alguns links úteis para um estudo mais aprofundado. Melhor juntos: Java e a classe Thread.  Parte III — Interação - 1

Introdução

Então, sabemos que Java tem threads. Você pode ler sobre isso na revisão intitulada Better together: Java and the Thread class. Parte I — Threads de execução . E exploramos o fato de que os encadeamentos podem sincronizar uns com os outros na revisão intitulada Melhor juntos: Java e a classe Thread. Parte II — Sincronização . É hora de falar sobre como os threads interagem uns com os outros. Como eles compartilham recursos compartilhados? Que problemas podem surgir aqui? Melhor juntos: Java e a classe Thread.  Parte III — Interação - 2

Impasse

O problema mais assustador de todos é o impasse. Deadlock é quando duas ou mais threads estão eternamente esperando uma pela outra. Vamos pegar um exemplo da página da Oracle que descreve o impasse :

public class Deadlock {
    static class Friend {
        private final String name;
        public Friend(String name) {
            this.name = name;
        }
        public String getName() {
            return this.name;
        }
        public synchronized void bow(Friend bower) {
            System.out.format("%s: %s bowed to me!%n",
                    this.name, bower.getName());
            bower.bowBack(this);
        }
        public synchronized void bowBack(Friend bower) {
            System.out.format("%s: %s bowed back to me!%n",
                    this.name, bower.getName());
        }
    }

    public static void main(String[] args) {
        final Friend alphonse = new Friend("Alphonse");
        final Friend gaston = new Friend("Gaston");
        new Thread(() -> alphonse.bow(gaston)).start();
        new Thread(() -> gaston.bow(alphonse)).start();
    }
}
O impasse pode não ocorrer aqui na primeira vez, mas se o seu programa travar, é hora de executar jvisualvm: Melhor juntos: Java e a classe Thread.  Parte III — Interação - 3Com um plug-in JVisualVM instalado (via Ferramentas -> Plugins), podemos ver onde ocorreu o impasse:

"Thread-1" - Thread t@12
   java.lang.Thread.State: BLOCKED
	at Deadlock$Friend.bowBack(Deadlock.java:16)
	- waiting to lock <33a78231> (a Deadlock$Friend) owned by "Thread-0" t@11
O thread 1 está esperando o bloqueio do thread 0. Por que isso acontece? Thread-1começa a correr e executa o Friend#bowmétodo. Está marcado com a synchronizedpalavra-chave, o que significa que estamos adquirindo o monitor para this(o objeto atual). A entrada do método era uma referência ao outro Friendobjeto. Agora, Thread-1deseja executar o método no outro Friend, e deve adquirir seu bloqueio para fazê-lo. Mas se a outra thread (neste caso Thread-0) conseguiu entrar no bow()método, então o bloqueio já foi adquirido e Thread-1aguardaThread-0, e vice versa. Esse impasse é insolúvel e o chamamos de impasse. Como um aperto mortal que não pode ser liberado, o impasse é um bloqueio mútuo que não pode ser quebrado. Para outra explicação sobre deadlock, você pode assistir a este vídeo: Deadlock e Livelock explicados .

Livelock

Se houver deadlock, também haverá livelock? Sim, existe :) O Livelock acontece quando os threads externamente parecem estar vivos, mas são incapazes de fazer qualquer coisa, porque a(s) condição(ões) necessária(s) para que continuem seu trabalho não pode(m) ser cumprida(s). Basicamente, o livelock é semelhante ao deadlock, mas os threads não "travam" esperando por um monitor. Em vez disso, eles estão sempre fazendo algo. Por exemplo:

import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class App {
    public static final String ANSI_BLUE = "\u001B[34m";
    public static final String ANSI_PURPLE = "\u001B[35m";
    
    public static void log(String text) {
        String name = Thread.currentThread().getName(); // Like "Thread-1" or "Thread-0"
        String color = ANSI_BLUE;
        int val = Integer.valueOf(name.substring(name.lastIndexOf("-") + 1)) + 1;
        if (val != 0) {
            color = ANSI_PURPLE;
        }
        System.out.println(color + name + ": " + text + color);
        try {
            System.out.println(color + name + ": wait for " + val + " sec" + color);
            Thread.currentThread().sleep(val * 1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    public static void main(String[] args) {
        Lock first = new ReentrantLock();
        Lock second = new ReentrantLock();

        Runnable locker = () -> {
            boolean firstLocked = false;
            boolean secondLocked = false;
            try {
                while (!firstLocked || !secondLocked) {
                    firstLocked = first.tryLock(100, TimeUnit.MILLISECONDS);
                    log("First Locked: " + firstLocked);
                    secondLocked = second.tryLock(100, TimeUnit.MILLISECONDS);
                    log("Second Locked: " + secondLocked);
                }
                first.unlock();
                second.unlock();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        };

        new Thread(locker).start();
        new Thread(locker).start();
    }
}
O sucesso desse código depende da ordem na qual o planejador de encadeamento Java inicia os encadeamentos. Se Thead-1começar primeiro, teremos livelock:

Thread-1: First Locked: true
Thread-1: wait for 2 sec
Thread-0: First Locked: false
Thread-0: wait for 1 sec
Thread-0: Second Locked: true
Thread-0: wait for 1 sec
Thread-1: Second Locked: false
Thread-1: wait for 2 sec
Thread-0: First Locked: false
Thread-0: wait for 1 sec
...
Como você pode ver no exemplo, ambos os encadeamentos tentam adquirir os dois bloqueios por vez, mas eles falham. Mas, eles não estão em um impasse. Externamente, está tudo bem e eles estão fazendo seu trabalho. Melhor juntos: Java e a classe Thread.  Parte III — Interação - 4De acordo com o JVisualVM, vemos períodos de hibernação e um período de estacionamento (é quando um thread tenta adquirir um bloqueio — ele entra no estado de park, como discutimos anteriormente quando falamos sobre sincronização de thread ) . Você pode ver um exemplo de livelock aqui: Java - Thread Livelock .

Inanição

Além do deadlock e do livelock, há outro problema que pode ocorrer durante o multithreading: starvation. Esse fenômeno difere das formas anteriores de bloqueio porque os encadeamentos não são bloqueados — eles simplesmente não têm recursos suficientes. Como resultado, enquanto algumas threads levam todo o tempo de execução, outras não conseguem executar: Melhor juntos: Java e a classe Thread.  Parte III — Interação - 5

https://www.logicbig.com/

Você pode ver um super exemplo aqui: Java - Thread Starvation and Fairness . Este exemplo mostra o que acontece com os encadeamentos durante a fome e como uma pequena alteração de Thread.sleep()para Thread.wait()permite distribuir a carga uniformemente. Melhor juntos: Java e a classe Thread.  Parte III — Interação - 6

Condições da corrida

Em multithreading, existe uma "condição de corrida". Esse fenômeno ocorre quando as threads compartilham um recurso, mas o código é escrito de forma a não garantir o compartilhamento correto. Dê uma olhada em um exemplo:

public class App {
    public static int value = 0;

    public static void main(String[] args) {
        Runnable task = () -> {
            for (int i = 0; i < 10000; i++) {
                int oldValue = value;
                int newValue = ++value;
                if (oldValue + 1 != newValue) {
                    throw new IllegalStateException(oldValue + " + 1 = " + newValue);
                }
            }
        };
        new Thread(task).start();
        new Thread(task).start();
        new Thread(task).start();
    }
}
Este código pode não gerar um erro na primeira vez. Quando isso acontecer, pode ser assim:

Exception in thread "Thread-1" java.lang.IllegalStateException: 7899 + 1 = 7901
	at App.lambda$main$0(App.java:13)
	at java.lang.Thread.run(Thread.java:745)
Como você pode ver, algo deu errado enquanto newValueestava sendo atribuído um valor. newValueé muito grande. Por causa da condição de corrida, um dos threads conseguiu alterar as variáveis value​​entre as duas instruções. Acontece que há uma corrida entre os fios. Agora pense em como é importante não cometer erros semelhantes com transações monetárias... Exemplos e diagramas também podem ser vistos aqui: Código para simular condição de corrida em thread Java .

Volátil

Falando sobre a interação de threads, volatilevale a pena mencionar a palavra-chave. Vejamos um exemplo simples:

public class App {
    public static boolean flag = false;

    public static void main(String[] args) throws InterruptedException {
        Runnable whileFlagFalse = () -> {
            while(!flag) {
            }
            System.out.println("Flag is now TRUE");
        };

        new Thread(whileFlagFalse).start();
        Thread.sleep(1000);
        flag = true;
    }
}
O mais interessante é que é altamente provável que isso não funcione. O novo thread não verá a alteração no flagcampo. Para corrigir isso para o flagcampo, precisamos usar a volatilepalavra-chave. Como e por quê? O processador executa todas as ações. Mas os resultados dos cálculos devem ser armazenados em algum lugar. Para isso existe a memória principal e existe o cache do processador. Os caches de um processador são como um pequeno pedaço de memória usado para acessar dados mais rapidamente do que ao acessar a memória principal. Mas tudo tem um lado negativo: os dados no cache podem não estar atualizados (como no exemplo acima, quando o valor do campo flag não foi atualizado). Então ovolatileA palavra-chave informa à JVM que não queremos armazenar em cache nossa variável. Isso permite que o resultado atualizado seja visto em todos os encadeamentos. Esta é uma explicação altamente simplificada. Quanto à volatilepalavra-chave, recomendo fortemente que você leia este artigo . Para mais informações, também aconselho você a ler Java Memory Model e Java Volatile Keyword . Além disso, é importante lembrar que volatilese trata da visibilidade, e não da atomicidade das mudanças. Observando o código na seção "Condições de corrida", veremos uma dica de ferramenta no IntelliJ IDEA: Melhor juntos: Java e a classe Thread.  Parte III — Interação - 7Esta inspeção foi adicionada ao IntelliJ IDEA como parte do problema IDEA-61117 , listado nas Notas de versão em 2010.

Atomicidade

Operações atômicas são operações que não podem ser divididas. Por exemplo, a operação de atribuir um valor a uma variável deve ser atômica. Infelizmente, a operação de incremento não é atômica, porque o incremento requer até três operações de CPU: obtenha o valor antigo, adicione um a ele e salve o valor. Por que a atomicidade é importante? Com a operação de incremento, se houver uma condição de corrida, o recurso compartilhado (ou seja, o valor compartilhado) pode mudar repentinamente a qualquer momento. Além disso, operações envolvendo estruturas de 64 bits, por exemplo longe double, não são atômicas. Mais detalhes podem ser lidos aqui: Garantir atomicidade ao ler e escrever valores de 64 bits . Problemas relacionados à atomicidade podem ser vistos neste exemplo:

public class App {
    public static int value = 0;
    public static AtomicInteger atomic = new AtomicInteger(0);

    public static void main(String[] args) throws InterruptedException {
        Runnable task = () -> {
            for (int i = 0; i < 10000; i++) {
                value++;
                atomic.incrementAndGet();
            }
        };
        for (int i = 0; i < 3; i++) {
            new Thread(task).start();
        }
        Thread.sleep(300);
        System.out.println(value);
        System.out.println(atomic.get());
    }
}
A classe especial AtomicIntegersempre nos dará 30.000, mas mudará valuede tempos em tempos. Há uma breve visão geral deste tópico: Introdução às variáveis ​​atômicas em Java . O algoritmo "comparar e trocar" está no centro das classes atômicas. Você pode ler mais sobre isso aqui em Comparação de algoritmos sem bloqueio - CAS e FAA no exemplo de JDK 7 e 8 ou no artigo Compare-and-swap na Wikipedia. Melhor juntos: Java e a classe Thread.  Parte III — Interação - 9

http://jeremymanson.blogspot.com/2008/11/what-volatile-means-in-java.html

acontece antes

Existe um conceito interessante e misterioso chamado "acontece antes". Como parte de seu estudo de threads, você deve ler sobre isso. O relacionamento que acontece antes mostra a ordem na qual as ações entre as threads serão vistas. Existem muitas interpretações e comentários. Aqui está uma das apresentações mais recentes sobre este assunto: Java "Happens-Before" Relationships .

Resumo

Nesta revisão, exploramos algumas das especificidades de como os encadeamentos interagem. Discutimos problemas que podem surgir, bem como formas de identificá-los e eliminá-los. Lista de materiais adicionais sobre o tema: Melhor juntos: Java e a classe Thread. Parte I — Threads de execução Melhor juntos: Java e a classe Thread. Parte II — Sincronização melhor juntos: Java e a classe Thread. Parte IV — Callable, Future e amigos Melhor juntos: Java e a classe Thread. Parte V — Executor, ThreadPool, Fork/Join Better juntos: Java e a classe Thread. Parte VI — Atire!
Comentários
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION