Visão geral do modelo de memória Java (JMM)

Modelo de memória Java (modelo de memória Java)

Transferência de Jenkov

O modelo de memória Java especifica como a máquina virtual Java é usada com a memória do computador (RAM). A máquina virtual Java é um modelo de todo o computador, então o modelo naturalmente inclui um modelo de memória - também conhecido como modelo de memória Java.

Se você deseja projetar adequadamente um programa com comportamento simultâneo, é muito importante entender o modelo de memória Java. O modelo de memória Java especifica como e quando diferentes threads veem os valores gravados em variáveis ​​compartilhadas por outros threads e como sincronizar o acesso a variáveis ​​compartilhadas quando necessário.

O modelo de memória Java original era insuficiente, portanto, o modelo de memória Java foi revisado no Java 1.5. Esta versão do modelo de memória Java ainda é usada em Java (Java 14+) hoje.

Modelo de memória Java interna

O modelo de memória Java usado internamente pela JVM aloca memória entre a pilha de encadeamentos e o heap. Esta figura ilustra o modelo de memória Java de uma perspectiva lógica:

Insira a descrição da imagem aqui
Cada encadeamento em execução na máquina virtual Java possui sua própria pilha de encadeamentos. A pilha de encadeamentos contém informações sobre quais métodos o encadeamento chamou para atingir o ponto de execução atual. Eu chamo isso de "pilha de chamadas". Quando um thread executa seu código, a pilha de chamadas muda.

A pilha de threads também contém todas as variáveis ​​locais de cada método que está sendo executado (todos os métodos na pilha de chamadas). Um thread só pode acessar sua própria pilha de threads. Variáveis ​​locais criadas por um thread são invisíveis para todos os outros threads, exceto o thread de criação. Mesmo se o código executado pelas duas threads for exatamente o mesmo, as duas threads ainda criarão variáveis ​​locais do código em suas respectivas pilhas de threads. Portanto, cada thread tem sua própria versão de cada variável local.

Todas as variáveis ​​locais de tipos primitivos (boolean, byte, short, char, int, long, float, double) são completamente armazenadas na pilha de encadeamentos, portanto, não são visíveis para outros encadeamentos. Um thread pode passar uma cópia de uma variável principal para outro thread, mas não pode compartilhar a própria variável local original.

O heap contém todos os objetos criados em um aplicativo Java, independentemente do encadeamento que criou o objeto. Isso inclui tipos primitivos (como versões de objeto Byte, Integer, Long, etc.). Não importa se você cria um objeto e o atribui a uma variável local, ou o cria como uma variável membro de outro objeto, o objeto ainda está armazenado no heap.

Este é um diagrama que ilustra a pilha de chamadas, as variáveis ​​locais armazenadas na pilha de encadeamentos e os objetos armazenados no heap:
Insira a descrição da imagem aqui
as variáveis ​​locais podem ser tipos primitivos, caso em que permanecem completamente na pilha de encadeamentos.

Variáveis ​​locais também podem ser referências a objetos. Nesse caso, a referência (variável local) é armazenada na pilha de threads, mas o próprio objeto (se armazenado no heap).

Um objeto pode conter métodos e esses métodos podem conter variáveis ​​locais. Essas variáveis ​​locais também são armazenadas na pilha de threads, mesmo se o objeto ao qual o método pertence estiver armazenado no heap.

As variáveis ​​de membro do objeto são armazenadas no heap junto com o próprio objeto. Isso é verdadeiro quando a variável de membro é um tipo primitivo e quando é uma referência a um objeto.

Variáveis ​​de classe estáticas também são armazenadas no heap junto com a definição de classe.

Todos os threads que fazem referência ao objeto podem acessar o objeto no heap. Quando um thread pode acessar um objeto, ele também pode acessar as variáveis ​​de membro do objeto. Se duas threads chamarem um método no mesmo objeto ao mesmo tempo, ambas terão acesso às variáveis ​​de membro do objeto, mas cada thread terá sua própria cópia das variáveis ​​locais.

Este é um diagrama que ilustra os pontos acima:
Insira a descrição da imagem aqui
dois threads têm um conjunto de variáveis ​​locais. Uma das variáveis ​​locais (Variável local 2) aponta para um objeto compartilhado (Objeto 3) no heap. Cada um desses dois threads tem referências diferentes ao mesmo objeto. Suas referências são variáveis ​​locais, portanto, são armazenadas na pilha de threads de cada thread (em cada thread). No entanto, duas referências diferentes apontam para o mesmo objeto no heap.

Observe como o objeto compartilhado (objeto 3) faz referência ao objeto 2 e ao objeto 4 como variáveis ​​de membro (conforme mostrado pelas setas do objeto 3 ao objeto 2 e ao objeto 4). Por meio dessas referências de variáveis ​​de membro no objeto 3, dois threads podem acessar o objeto 2 e o objeto 4.

A figura também mostra uma variável local que aponta para dois objetos diferentes no heap. Nesse caso, a referência aponta para dois objetos diferentes (objeto 1 e objeto 5), não para o mesmo objeto. Em teoria, se dois threads fazem referência a dois objetos, ambos os threads podem acessar o objeto 1 e o objeto 5. Mas na figura acima, cada thread faz referência a apenas um dos dois objetos.

Então, que tipo de código Java pode causar o gráfico de memória acima? Bem, o código é tão simples quanto o seguinte código:

public class MyRunnable implements Runnable() {
    
    

    public void run() {
    
    
        methodOne();
    }

    public void methodOne() {
    
    
        int localVariable1 = 45;

        MySharedObject localVariable2 =
            MySharedObject.sharedInstance;

        //... do more with local variables.

        methodTwo();
    }

    public void methodTwo() {
    
    
        Integer localVariable1 = new Integer(99);

        //... do more with local variable.
    }
}

public class MySharedObject {
    
    

    //static variable pointing to instance of MySharedObject

    public static final MySharedObject sharedInstance =
        new MySharedObject();


    //member variables pointing to two objects on the heap

    public Integer object2 = new Integer(22);
    public Integer object4 = new Integer(44);

    public long member1 = 12345;
    public long member2 = 67890;
}

Se duas threads estiverem executando o método run (), o resultado será como antes. O método run () chama methodOne () e methodOne () chama methodTwo ().

methodOne () declara uma variável local básica (localVariable1 tipo int) e uma variável local, que é uma referência de objeto (localVariable2).

Cada thread executando methodOne () criará sua própria cópia, localVariable1 e localVariable2 em sua própria pilha de threads. Essas variáveis ​​localVariable1 serão completamente separadas umas das outras e só existirão na pilha de threads de cada thread. Um encadeamento não pode ver as alterações feitas por outro encadeamento em sua cópia localVariable1.

Cada thread de execução methodOne () também criará sua própria cópia de localVariable2. No entanto, duas cópias diferentes das duas localVariable2 acabam apontando para o mesmo objeto no heap. Este código define localVariable2 para apontar para o objeto referenciado pela variável estática. Existe apenas uma cópia de uma variável estática e essa cópia é armazenada no heap. Portanto, ambas as cópias finais localVariable2 apontam para a mesma instância apontada pela variável estática MySharedObject. A instância MySharedObject também é armazenada no heap. Corresponde ao objeto 3 da figura acima.

Observe que a classe MySharedObject também contém duas variáveis ​​de membro. A própria variável de membro é armazenada no heap junto com o objeto. Essas duas variáveis ​​de membro apontam para dois outros objetos Integer. Esses objetos inteiros correspondem ao objeto 2 e ao objeto 4 na figura acima.

Observe também como o methodTwo () cria uma variável local chamada localVariable1. Esta variável local é um inteiro de referência de objeto para o objeto. Este método define a referência localVariable1 para apontar para a nova instância Integer. A referência localVariable1 será armazenada em uma cópia do methodTwo () em cada thread de execução. Os dois objetos instanciados por Integer serão armazenados no heap, mas como o método Integer criará um novo objeto cada vez que for executado, os dois threads que executam este método criarão instâncias de Integer separadas. O objeto methodTwo () criado dentro de Integer corresponde ao objeto 1 e ao objeto 5 na figura acima.

Observe também que as duas variáveis ​​de membro na classe de tipo MySharedObject, desde que sejam tipos primitivos. Como essas variáveis ​​são variáveis ​​de membro, elas ainda são armazenadas no heap junto com o objeto. Apenas variáveis ​​locais são armazenadas na pilha de threads.

Arquitetura de memória de hardware

A arquitetura de memória de hardware moderna é diferente do modelo de memória Java interna. Também é importante entender a arquitetura de memória de hardware e entender como o modelo de memória Java funciona com ela. Esta seção descreve arquiteturas de memória de hardware comuns e a próxima seção descreve como o modelo de memória Java funciona com ela.

Este é um diagrama simplificado da arquitetura moderna de hardware de computador:
Insira a descrição da imagem aqui

Os computadores modernos geralmente contêm 2 ou mais CPUs. Algumas dessas CPUs também podem ter vários núcleos. A questão é que em computadores modernos com 2 ou mais CPUs, vários threads podem ser executados simultaneamente. Cada CPU pode executar um thread a qualquer momento. Isso significa que se o aplicativo Java for multiencadeado, cada CPU pode executar um encadeamento no aplicativo Java ao mesmo tempo (simultaneamente).

Cada CPU contém um conjunto de registros, que são essencialmente memória da CPU. A CPU realiza operações nesses registros muito mais rápido do que as variáveis ​​na memória principal. Isso ocorre porque a CPU pode acessar esses registros mais rapidamente do que a memória principal.

Cada CPU também pode ter uma camada de armazenamento de cache da CPU. Na verdade, a maioria das CPUs modernas tem um certo tamanho de camada de cache. A CPU pode acessar seu cache mais rápido do que a memória principal, mas geralmente não é tão rápida quanto pode acessar registros internos. Portanto, a memória cache da CPU está localizada entre os registros internos e a velocidade da memória principal. Algumas CPUs podem ter várias camadas de cache (nível 1 e nível 2), mas não é importante entender como o modelo de memória Java interage com a memória. É importante saber que a CPU pode ter algum tipo de camada de cache.

O computador também contém uma área de armazenamento principal (RAM). Todas as CPUs podem acessar a memória principal. A área de armazenamento principal geralmente é muito maior do que o cache da CPU.

Normalmente, quando a CPU precisa acessar a memória principal, ela lê parte da memória principal no cache da CPU. Ele pode até mesmo ler parte do cache em seus registros internos e, em seguida, executar operações nele. Quando a CPU precisa gravar o resultado de volta na memória principal, ela descarrega o valor de seu registro interno para o cache e, em seguida, descarrega o valor de volta para a memória principal em algum ponto.

Quando a CPU precisa armazenar outro conteúdo no cache, ela geralmente libera o valor armazenado no cache de volta para a memória principal. O cache da CPU pode gravar dados em parte de sua memória por vez e atualizar parte de sua memória por vez. Não é necessário ler / gravar no cache completo sempre que for atualizado. Geralmente, o cache é atualizado em blocos menores de memória chamados "linhas de cache". Uma ou mais linhas de cache podem ser lidas na memória cache e uma ou mais linhas de cache podem ser descarregadas de volta para a memória principal novamente.

Preenchendo a lacuna entre o modelo de memória Java e a arquitetura de memória de hardware

Conforme mencionado anteriormente, o modelo de memória Java e a arquitetura de memória de hardware são diferentes. A arquitetura de memória de hardware não faz distinção entre a pilha de threads e o heap. No hardware, a pilha de encadeamentos e o heap estão localizados na memória principal. Parte da pilha de threads e heap podem às vezes aparecer no cache da CPU e nos registros internos da CPU. A figura a seguir ilustra isso:
Insira a descrição da imagem aqui
Quando objetos e variáveis ​​podem ser armazenados em várias áreas de armazenamento do computador, certos problemas podem ocorrer. Os dois principais problemas são:

  • Atualizações de thread (gravações) para a visibilidade de variáveis ​​compartilhadas.
  • Condições de corrida ao ler, verificar e escrever variáveis ​​compartilhadas.

Esses dois problemas serão explicados nas seções a seguir.

Visibilidade de objetos compartilhados

Se duas ou mais threads compartilham um objeto sem usar a declaração volátil ou a sincronização corretamente, as atualizações feitas por uma thread no objeto compartilhado podem não ser visíveis a outras threads.

Imagine que o objeto compartilhado é inicialmente armazenado na memória principal. Então, o thread em execução na CPU lê o objeto compartilhado em seu cache de CPU. Lá, ele mudou a biblioteca compartilhada. Desde que o cache da CPU não seja descarregado de volta para a memória principal, os threads em execução em outras CPUs não podem ver a versão alterada do objeto compartilhado. Desta forma, cada thread pode eventualmente ter sua própria cópia da biblioteca compartilhada, com cada cópia em um cache de CPU diferente.

A figura abaixo ilustra essa situação. Um thread em execução na CPU esquerda copia a biblioteca compartilhada em seu cache de CPU e altera sua variável de contagem para 2. Os outros threads em execução na CPU certa não podem ver essa mudança porque a contagem não liberou a atualização de volta para a memória principal.
Insira a descrição da imagem aqui

Para resolver esse problema, você pode usar a palavra-chave volátil do Java . A palavra-chave volatile pode garantir que uma determinada variável seja sempre gravada de volta na memória principal quando é lida e atualizada diretamente da memória principal.

Condições de competição

Se dois ou mais threads compartilham um objeto, e mais de um thread atualiza variáveis ​​no objeto compartilhado, pode ocorrer uma condição de corrida .

Imagine se o thread A lê a variável da biblioteca compartilhada de contagem em seu cache de CPU. Imagine também que o thread B tem a mesma função, mas está em um cache de CPU diferente. Agora, o encadeamento A adiciona uma contagem e o encadeamento B realiza a mesma operação. Agora var1 foi aumentado duas vezes, uma por cache de CPU.

Se esses incrementos forem executados sequencialmente, a contagem da variável será incrementada duas vezes e o valor original + 2 será gravado de volta na memória principal.

No entanto, esses dois incrementos são executados simultaneamente sem a sincronização adequada. Não importa qual thread A ou B grava sua versão atualizada de volta na memória principal, embora haja dois incrementos, o valor atualizado é apenas 1 maior que o valor original.

A figura ilustra a ocorrência do problema de condição de corrida descrito acima:
Insira a descrição da imagem aqui
Para resolver esse problema, você pode usar o bloco de sincronização Java . O bloco de sincronização garante que apenas um thread pode entrar em uma determinada parte crítica do código a qualquer momento. O bloco de sincronização também garante que todas as variáveis ​​acessadas no bloco de sincronização serão lidas da memória principal.Quando o thread sai do bloco de sincronização, todas as variáveis ​​atualizadas serão descarregadas de volta para a memória principal, independentemente de a variável ser declarada volátil.

Acho que você gosta

Origin blog.csdn.net/e891377/article/details/108686228
Recomendado
Clasificación