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.

acontece antes

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.