Introdução ao modelo de memória Java
O Java Memory Model (JMM) descreve o comportamento dos encadeamentos no Java Runtime Environment. O modelo de memória faz parte da semântica da linguagem Java e descreve o que um programador pode e não deve esperar ao desenvolver software não para uma máquina Java específica, mas para Java como um todo.
O modelo original de memória Java (que, em particular, se refere à "memória percolocal"), desenvolvido em 1995, é considerado um fracasso: muitas otimizações não podem ser feitas sem perder a garantia de segurança do código. Em particular, existem várias opções para escrever "single" multi-threaded:
- ou todo ato de acessar um singleton (mesmo quando o objeto foi criado há muito tempo e nada pode mudar) causará um bloqueio entre threads;
- ou sob um determinado conjunto de circunstâncias, o sistema emitirá um solitário inacabado;
- ou sob um certo conjunto de circunstâncias, o sistema criará dois solitários;
- ou o projeto dependerá do comportamento de uma determinada máquina.
Portanto, o mecanismo de memória foi redesenhado. Em 2005, com o lançamento do Java 5, foi apresentada uma nova abordagem, que foi aprimorada ainda mais com o lançamento do Java 14.
O novo modelo é baseado em três regras:
Regra nº 1 : Programas de thread único são executados pseudo-sequencialmente. Isso significa: na realidade, o processador pode realizar várias operações por clock, ao mesmo tempo alterando sua ordem, porém, todas as dependências de dados permanecem, portanto o comportamento não difere do sequencial.
Regra número 2 : não existem valores do nada. A leitura de qualquer variável (exceto long e double não voláteis, para os quais esta regra pode não valer) retornará o valor padrão (zero) ou algo escrito lá por outro comando.
E a regra número 3 : o resto dos eventos são executados em ordem, se estiverem conectados por uma relação de ordem parcial estrita “executa antes” ( acontece antes ).
acontece antes
Leslie Lamport surgiu com o conceito de Happens before . Esta é uma relação de ordem parcial estrita introduzida entre os comandos atômicos (++ e -- não são atômicos) e não significa "fisicamente antes".
Ele diz que a segunda equipe estará "a par" das mudanças feitas pela primeira.
Por exemplo, um é executado antes do outro para tais operações:
Sincronização e monitores:
- Capturando o monitor ( método de bloqueio , início sincronizado) e o que quer que aconteça no mesmo thread depois dele.
- O retorno do monitor (método unlock , fim do sincronizado) e o que acontecer na mesma thread antes dele.
- Retornando o monitor e depois capturando-o por outro thread.
Escrita e leitura:
- Gravar em qualquer variável e, em seguida, lê-la no mesmo fluxo.
- Tudo no mesmo thread antes de escrever para a variável volátil e a própria escrita. leitura volátil e tudo no mesmo segmento depois dele.
- Escrever em uma variável volátil e depois lê-la novamente. Uma gravação volátil interage com a memória da mesma forma que um retorno de monitor, enquanto uma leitura é como uma captura. Acontece que se um thread escreveu em uma variável volátil e o segundo o encontrou, tudo o que precede a gravação é executado antes de tudo o que vem depois da leitura; Ver foto.
Manutenção de objetos:
- Inicialização estática e quaisquer ações com quaisquer instâncias de objetos.
- Gravando nos campos finais no construtor e tudo depois do construtor. Como exceção, a relação que acontece antes não se conecta transitivamente a outras regras e pode, portanto, causar uma corrida entre threads.
- Qualquer trabalho com o objeto e finalize() .
Serviço de transmissão:
- Iniciando um thread e qualquer código no thread.
- Zerar variáveis relacionadas ao thread e qualquer código no thread.
- Código em thread e join() ; código no encadeamento e isAlive() == false .
- interrupt() a thread e detecta que ela parou.
Acontece antes das nuances do trabalho
A liberação de um monitor que ocorre antes ocorre antes da aquisição do mesmo monitor. Vale ressaltar que é a liberação, e não a saída, ou seja, você não precisa se preocupar com a segurança ao utilizar o wait.
Vejamos como esse conhecimento nos ajudará a corrigir nosso exemplo. Nesse caso, tudo é muito simples: basta remover a verificação externa e deixar a sincronização como está. Agora a segunda thread tem a garantia de ver todas as alterações, pois ela só receberá o monitor depois que a outra thread liberar. E como ele não o liberará até que tudo seja inicializado, veremos todas as alterações de uma vez, e não separadamente:
public class Keeper {
private Data data = null;
public Data getData() {
synchronized(this) {
if(data == null) {
data = new Data();
}
}
return data;
}
}
A gravação em uma variável volátil ocorre antes da leitura da mesma variável. A alteração que fizemos, é claro, corrige o bug, mas coloca quem escreveu o código original de volta de onde veio - bloqueando todas as vezes. A palavra-chave volátil pode salvar. Na verdade, a declaração em questão significa que, ao ler tudo o que é declarado como volátil, obteremos sempre o valor real.
Além disso, como eu disse anteriormente, para campos voláteis, a escrita é sempre (incluindo longa e dupla) uma operação atômica. Outro ponto importante: se você tiver uma entidade volátil que tenha referências a outras entidades (por exemplo, um array, List ou alguma outra classe), então apenas uma referência à própria entidade sempre será “fresca”, mas não a tudo em está chegando.
Então, de volta aos nossos aríetes de bloqueio duplo. Usando volátil, você pode corrigir a situação assim:
public class Keeper {
private volatile Data data = null;
public Data getData() {
if(data == null) {
synchronized(this) {
if(data == null) {
data = new Data();
}
}
}
return data;
}
}
Aqui ainda temos um bloqueio, mas somente se data == null. Filtramos os casos restantes usando leitura volátil. A correção é garantida pelo fato de que o armazenamento volátil ocorre antes da leitura volátil, e todas as operações que ocorrem no construtor são visíveis para quem lê o valor do campo.
GO TO FULL VERSION