Oi! Continuamos nosso estudo de multithreading. Hoje vamos conhecer a
Quando chamamos o
volatile
palavra-chave e o yield()
método. Vamos mergulhar :)
A palavra-chave volátil
Ao criar aplicativos multithread, podemos nos deparar com dois problemas sérios. Primeiro, quando um aplicativo multithread está em execução, diferentes threads podem armazenar em cache os valores das variáveis (já falamos sobre isso na lição intitulada 'Usando voláteis' ). Você pode ter a situação em que um thread altera o valor de uma variável, mas um segundo thread não vê a alteração, porque está trabalhando com sua cópia em cache da variável. Naturalmente, as consequências podem ser graves. Suponha que não seja apenas uma variável qualquer, mas sim o saldo da sua conta bancária, que de repente começa a pular aleatoriamente para cima e para baixo :) Isso não parece divertido, certo? Em segundo lugar, em Java, as operações para ler e escrever todos os tipos primitivos,long
double
, são atômicos. Bem, por exemplo, se você mudar o valor de uma int
variável em um thread, e em outro thread você ler o valor da variável, você obterá seu valor antigo ou o novo, ou seja, o valor que resultou da alteração no thread 1. Não há 'valores intermediários'. No entanto, isso não funciona com long
s e double
s. Por que? Por causa do suporte multiplataforma. Lembra que nos níveis iniciais dissemos que o princípio orientador do Java é 'escreva uma vez, execute em qualquer lugar'? Isso significa suporte multiplataforma. Em outras palavras, um aplicativo Java é executado em todos os tipos de plataformas diferentes. Por exemplo, em sistemas operacionais Windows, diferentes versões do Linux ou MacOS. Ele será executado sem problemas em todos eles. Pesando em 64 bits,long
double
são os primitivos 'mais pesados' em Java. E certas plataformas de 32 bits simplesmente não implementam leitura e gravação atômica de variáveis de 64 bits. Tais variáveis são lidas e escritas em duas operações. Primeiro, os primeiros 32 bits são gravados na variável e, em seguida, outros 32 bits são gravados. Como resultado, pode surgir um problema. Um thread grava algum valor de 64 bits em uma X
variável e o faz em duas operações. Ao mesmo tempo, um segundo thread tenta ler o valor da variável e o faz entre essas duas operações — quando os primeiros 32 bits foram escritos, mas os segundos 32 bits não. Como resultado, ele lê um valor intermediário incorreto e temos um bug. Por exemplo, se em tal plataforma tentamos escrever o número para um 9223372036854775809 a uma variável, ocupará 64 bits. Na forma binária, fica assim: 1000000000000000000000000000000000000000000000000000000000000001 O primeiro thread começa a escrever o número na variável. Primeiro escreve os primeiros 32 bits (1000000000000000000000000000000) e depois os segundos 32 bits (00000000000000000000000000000001) E a segunda thread pode ficar presa entre essas operações, lendo o valor intermediário da variável (100000000000000000000000000000000), que são os primeiros 32 bits que já foram escritos. No sistema decimal, esse número é 2.147.483.648. Ou seja, queríamos apenas escrever o número 9223372036854775809 para uma variável, mas devido ao fato dessa operação não ser atômica em algumas plataformas, temos o número maligno 2.147.483.648, que surgiu do nada e terá um efeito desconhecido no programa. A segunda thread simplesmente lê o valor da variável antes que ela termine de ser escrita, ou seja, a thread viu os primeiros 32 bits, mas não os segundos 32 bits. Claro, esses problemas não surgiram ontem. Java os resolve com uma única palavra-chave: volatile
. Se usarmos ovolatile
palavra-chave ao declarar alguma variável em nosso programa…
public class Main {
public volatile long x = 2222222222222222222L;
public static void main(String[] args) {
}
}
…significa que:
- Ele sempre será lido e escrito atomicamente. Mesmo que seja de 64 bits
double
oulong
. - A máquina Java não o armazenará em cache. Portanto, você não terá uma situação em que 10 threads estejam trabalhando com suas próprias cópias locais.
O método yield()
Já revisamos muitos dosThread
métodos da classe, mas há um importante que será novidade para você. É o yield()
método . E faz exatamente o que o nome indica! 
yield
método em um thread, ele realmente fala com os outros threads: 'Ei, pessoal. Não estou particularmente com pressa de ir a lugar nenhum, então, se for importante para algum de vocês obter tempo de processador, aproveite - posso esperar'. Aqui está um exemplo simples de como isso funciona:
public class ThreadExample extends Thread {
public ThreadExample() {
this.start();
}
public void run() {
System.out.println(Thread.currentThread().getName() + " yields its place to others");
Thread.yield();
System.out.println(Thread.currentThread().getName() + " has finished executing.");
}
public static void main(String[] args) {
new ThreadExample();
new ThreadExample();
new ThreadExample();
}
}
Criamos e iniciamos sequencialmente três threads: Thread-0
, Thread-1
, e Thread-2
. Thread-0
começa primeiro e imediatamente cede aos outros. Então Thread-1
é iniciado e também rende. Então Thread-2
é iniciado, que também rende. Não temos mais threads e, depois Thread-2
de ceder seu lugar por último, o agendador de threads diz: 'Hmm, não há mais threads novos. Quem temos na fila? Quem cedeu seu lugar antes Thread-2
? Parece que foi Thread-1
. Ok, isso significa que vamos deixá-lo correr'. Thread-1
conclui seu trabalho e, em seguida, o escalonador de encadeamento continua sua coordenação: 'Ok, Thread-1
concluído. Temos mais alguém na fila?'. Thread-0 está na fila: cedeu seu lugar logo antesThread-1
. Ele agora tem sua vez e é executado até a conclusão. Em seguida, o escalonador termina de coordenar os threads: 'Ok, Thread-2
você cedeu a outros threads e agora todos estão prontos. Você foi o último a ceder, então agora é a sua vez'. Em seguida, Thread-2
é executado até a conclusão. A saída do console será assim: Thread-0 cede seu lugar a outros Thread-1 cede seu lugar a outros Thread-2 cede seu lugar a outros Thread-1 concluiu a execução. Thread-0 terminou a execução. Thread-2 terminou de executar. Obviamente, o agendador de threads pode iniciar os threads em uma ordem diferente (por exemplo, 2-1-0 em vez de 0-1-2), mas o princípio permanece o mesmo.
Regras do que acontece antes
A última coisa que abordaremos hoje é o conceito de ' acontece antes '. Como você já sabe, em Java o escalonador de threads executa a maior parte do trabalho envolvido na alocação de tempo e recursos para que os threads executem suas tarefas. Você também viu repetidamente como os threads são executados em uma ordem aleatória que geralmente é impossível de prever. E, em geral, depois da programação 'seqüencial' que fizemos anteriormente, a programação multithread parece algo aleatório. Você já acredita que pode usar uma série de métodos para controlar o fluxo de um programa multithread. Mas o multithreading em Java tem mais um pilar — as 4 regras ' acontece antes '. Entender essas regras é bastante simples. Imagine que temos dois segmentos -A
eB
. Cada um desses threads pode executar operações 1
e 2
. Em cada regra, quando dizemos ' A acontece antes de B ', queremos dizer que todas as alterações feitas pela thread A
antes da operação 1
e as alterações resultantes dessa operação são visíveis para a thread B
quando a operação 2
é executada e posteriormente. Cada regra garante que, quando você escreve um programa multithread, certos eventos ocorrerão antes de outros 100% do tempo e que, no momento da operação, o 2
thread B
sempre estará ciente das alterações que o thread A
fez durante a operação 1
. Vamos analisá-los.
Regra 1.
A liberação de um mutex ocorre antes que o mesmo monitor seja adquirido por outro thread. Acho que você entende tudo aqui. Se o mutex de um objeto ou classe for adquirido por um thread, por exemplo, por threadA
, outro thread (thread B
) não poderá adquiri-lo ao mesmo tempo. Ele deve esperar até que o mutex seja liberado.
Regra 2.
OThread.start()
método acontece antes Thread.run()
. Novamente, nada difícil aqui. Você já sabe que para começar a executar o código dentro do run()
método, você deve chamar o start()
método na thread. Especificamente, o método start, não o run()
método em si! Esta regra garante que os valores de todas as variáveis definidas antes de Thread.start()
serem chamadas estarão visíveis dentro do run()
método assim que ele for iniciado.
Regra 3.
O fim dorun()
método acontece antes do retorno do join()
método. Voltemos aos nossos dois tópicos: A
e B
. Chamamos o join()
método para que o encadeamento B
tenha a garantia de aguardar a conclusão do encadeamento A
antes de fazer seu trabalho. Isso significa que run()
é garantido que o método do objeto A será executado até o fim. E todas as alterações nos dados que ocorrem no run()
método de encadeamento A
têm cem por cento de garantia de serem visíveis no encadeamento, B
uma vez que ele é concluído, esperando que o encadeamento A
termine seu trabalho para que possa começar seu próprio trabalho.
Regra 4.
A gravação em umavolatile
variável ocorre antes da leitura dessa mesma variável. Quando usamos a volatile
palavra-chave, sempre obtemos o valor atual. Mesmo com um long
ou double
(falamos anteriormente sobre problemas que podem acontecer aqui). Como você já entendeu, as alterações feitas em alguns threads nem sempre são visíveis para outros threads. Mas, é claro, há situações muito frequentes em que tal comportamento não nos convém. Suponha que atribuímos um valor a uma variável no thread A
:
int z;
….
z = 555;
Se nosso B
thread deve exibir o valor da z
variável no console, ele pode facilmente exibir 0, porque não sabe sobre o valor atribuído. Mas a Regra 4 garante que, se declararmos a z
variável como volatile
, as alterações em seu valor em um thread sempre serão visíveis em outro thread. Se adicionarmos a palavra volatile
ao código anterior...
volatile int z;
….
z = 555;
...então evitamos a situação em que o thread B
pode exibir 0. A gravação em volatile
variáveis ocorre antes da leitura delas.
GO TO FULL VERSION