Olá! Continuamos estudando multithreading e hoje conheceremos uma nova palavra-chave - volátil e o método yield(). Vamos descobrir o que é :)
Palavra-chave volátil
Ao criar aplicativos multithread, podemos enfrentar dois problemas sérios. Primeiramente, durante a operação de uma aplicação multithread, diferentes threads podem armazenar em cache os valores das variáveis (falaremos mais sobre isso na palestra “Usando voláteis” ). É possível que um thread tenha alterado o valor de uma variável, mas o segundo não tenha visto essa alteração porque estava trabalhando com sua própria cópia em cache da variável. Naturalmente, as consequências podem ser graves. Imagine que não se trata apenas de uma espécie de “variável”, mas, por exemplo, do saldo do seu cartão bancário, que de repente começou a saltar aleatoriamente para frente e para trás :) Não é muito agradável, certo? Em segundo lugar, em Java, operações de leitura e gravação em campos de todos os tipos, excetolong
e double
são atômicos. O que é atomicidade? Bem, por exemplo, se você alterar o valor de uma variável em um thread int
e em outro thread você ler o valor dessa variável, você obterá seu valor antigo ou um novo - aquele que resultou após a mudança em tópico 1. Nenhuma “opção intermediária” aparecerá lá. Talvez. No entanto, isso não funciona com long
e . double
Por que? Porque é multiplataforma. Você se lembra de como dissemos nos primeiros níveis que o princípio Java é “escrito uma vez, funciona em qualquer lugar”? Isso é multiplataforma. Ou seja, uma aplicação Java roda em plataformas completamente diferentes. Por exemplo, em sistemas operacionais Windows, diferentes versões de Linux ou MacOS e em todos os lugares este aplicativo funcionará de forma estável. long
e double
- as primitivas mais “pesadas” em Java: pesam 64 bits. E algumas plataformas de 32 bits simplesmente não implementam a atomicidade de leitura e gravação 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, depois outros 32. Conseqüentemente, nesses casos pode surgir um problema. Um thread grava algum valor de 64 bits em uma variávelХ
, e ele faz isso “em duas etapas”. Ao mesmo tempo, o segundo thread tenta ler o valor desta variável, e faz isso bem no meio, quando os primeiros 32 bits já foram escritos, mas os segundos ainda não foram escritos. Como resultado, ele lê um valor intermediário incorreto e ocorre um erro. Por exemplo, se em tal plataforma tentarmos escrever um número em uma variável - 9223372036854775809 - ela ocupará 64 bits. Na forma binária, ficará assim: 10000000000000000000000000000000000000000000000000000000001 O primeiro thread começará a escrever esse número em uma variável e primeiro escreverá os primeiros 32 bits: 10000000000000000000000 0000 00000 e depois o segundo 32: 0000000000000000000000000001 E um segundo fio pode se encaixar nessa lacuna e leia o valor intermediário da variável - 1000000000000000000000000000000, os primeiros 32 bits que já foram escritos. No sistema decimal, esse número é igual a 2147483648. Ou seja, queríamos apenas escrever o número 9223372036854775809 em uma variável, mas devido ao fato dessa operação em algumas plataformas não ser atômica, obtivemos o número “esquerdo” 2147483648 , do qual não precisamos, do nada e não se sabe como isso afetará o funcionamento do programa. O segundo thread simplesmente leu o valor da variável antes de finalmente ser escrita, ou seja, viu os primeiros 32 bits, mas não os segundos 32 bits. Esses problemas, é claro, não surgiram ontem e em Java eles são resolvidos usando apenas uma palavra-chave - volátil . Se declararmos alguma variável em nosso programa com a palavra volátil...
public class Main {
public volatile long x = 2222222222222222222L;
public static void main(String[] args) {
}
}
…significa que:
- Sempre será lido e escrito atomicamente. Mesmo que seja de 64 bits
double
oulong
. - A máquina Java não irá armazená-lo em cache. Portanto, a situação em que 10 threads trabalham com suas cópias locais é excluída.
método rendimento()
Já vimos muitos métodos da classeThread
, mas há um importante que será novo para você. Este é o método yield() . Traduzido do inglês como “ceder”. E é exatamente isso que o método faz! Quando chamamos o método yield em um thread, ele na verdade diz para outros threads: “Ok, pessoal, não estou com muita pressa, então se for importante para algum de vocês obter tempo de CPU, aproveitem, estou não urgente." Aqui está um exemplo simples de como funciona:
public class ThreadExample extends Thread {
public ThreadExample() {
this.start();
}
public void run() {
System.out.println(Thread.currentThread().getName() + "give way 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 lançamos sequencialmente três threads - Thread-0
, Thread-1
e Thread-2
. Thread-0
começa primeiro e imediatamente dá lugar a outros. Depois disso Thread-1
, ele começa e também cede. Depois disso, começa Thread-2
, que também é inferior. Não temos mais threads, e depois que Thread-2
o último cedeu seu lugar, o agendador de threads olha: “Então, não há mais threads novos, quem temos na fila? Quem foi o último a desistir do seu lugar antes Thread-2
? Eu acho que foi Thread-1
? Ok, então deixe ser feito. Thread-1
faz seu trabalho até o final, após o qual o agendador de threads continua a coordenar: “Ok, Thread-1 foi concluído. Temos mais alguém na fila?" Há Thread-0 na fila: ele cedeu seu lugar imediatamente antes do Thread-1. Agora o assunto chegou até ele e ele está sendo levado até o fim. Após isso o escalonador finaliza a coordenação das threads: “Ok, Thread-2, você deu lugar para outras threads, todas já funcionaram. Você foi o último a ceder, então agora é a sua vez.” Depois disso, o Thread-2 é executado até a conclusão. A saída do console ficará assim: Thread-0 dá lugar a outros Thread-1 dá lugar a outros Thread-2 dá lugar a outros Thread-1 terminou de ser executado. Thread-0 terminou a execução. Thread-2 terminou a execução. O agendador de threads pode, é claro, executar threads em uma ordem diferente (por exemplo, 2-1-0 em vez de 0-1-2), mas o princípio é o mesmo.
Regras do que acontece antes
A última coisa que abordaremos hoje são os princípios do “ acontece antes ”. Como você já sabe, em Java, a maior parte do trabalho de alocação de tempo e recursos aos threads para completar suas tarefas é feita pelo agendador de threads. Além disso, você já viu mais de uma vez como os threads são executados em ordem arbitrária e, na maioria das vezes, é impossível prever isso. E em geral, depois da programação “sequencial” que fizemos antes, o multithreading parece uma coisa aleatória. Como você já viu, o progresso de um programa multithread pode ser controlado usando todo um conjunto de métodos. Mas, além disso, no multithreading Java existe outra “ilha de estabilidade” - 4 regras chamadas “ acontece antes ”. Literalmente do inglês, isso é traduzido como “acontece antes” ou “acontece antes”. O significado destas regras é bastante simples de entender. Imagine que temos dois threads -A
e B
. Cada um desses threads pode executar operações 1
e 2
. E quando em cada uma das regras dizemos “ A acontece antes de B ”, isso significa que todas as alterações feitas pelo thread A
antes da operação 1
e as alterações que esta operação implicou são visíveis para o thread B
no momento em que a operação é executada 2
e após a operação ser realizada. Cada uma dessas regras garante que ao escrever um programa multithread, alguns eventos acontecerão antes de outros 100% das vezes, e que o thread B
no momento da operação 2
estará sempre ciente das alterações que o thread А
fez durante a operação 1
. Vamos dar uma olhada neles.
Regra 1.
A liberação de um mutex acontece antes de outro thread adquirir o mesmo monitor. Bem, tudo parece claro aqui. Se o mutex de um objeto ou classe for adquirido por um thread, por exemplo, um threadА
, outro thread (thread B
) não poderá adquiri-lo ao mesmo tempo. Você precisa esperar até que o mutex seja liberado.
Regra 2.
Thread.start()
Isso acontece antes do método Thread.run()
. Nada complicado também. Você já sabe: para que o código dentro do método comece a ser executado run()
, você precisa chamar o método na thread start()
. É dele, e não o método em si run()
! Esta regra garante que Thread.start()
os valores de todas as variáveis definidas antes da execução ficarão visíveis dentro do método que iniciou a execução run()
.
Regra 3.
A conclusão do métodorun()
acontece antes do método exit join()
. Voltemos aos nossos dois fluxos - А
e B
. Chamamos o método join()
de tal forma que o thread B
deve esperar até a conclusão A
antes de realizar seu trabalho. Isso significa que o método run()
do objeto A certamente será executado até o fim. E todas as alterações nos dados que ocorrem no método run()
thread A
ficarão completamente visíveis no thread B
quando ele aguardar a conclusão A
e começar a funcionar sozinho.
Regra 4.
A gravação em uma variável volátil acontece antes da leitura da mesma variável. Ao usar a palavra-chave volátil, obteremos, de fato, sempre o valor atual. Mesmo no caso delong
e double
, cujos problemas foram discutidos anteriormente. Como você já entendeu, as alterações feitas em alguns threads nem sempre são visíveis para outros threads. Mas, é claro, muitas vezes há situações em que esse comportamento do programa não nos agrada. Digamos que atribuímos um valor a uma variável em um thread A
:
int z;
….
z= 555;
Se nosso thread B
imprimisse o valor de uma variável z
no console, ele poderia facilmente imprimir 0 porque não sabe o valor atribuído a ela. Portanto, a Regra 4 nos garante: se você declarar uma variável z
como volátil, as alterações em seus valores em um thread sempre serão visíveis em outro thread. Se adicionarmos a palavra volátil ao código anterior...
volatile int z;
….
z= 555;
...a situação em que o fluxo B
produzirá 0 no console é excluída. A gravação em variáveis voláteis ocorre antes da leitura delas.
GO TO FULL VERSION