Java Concurrent Programming Series [inacabado]:
• Programação de concorrência Java: teoria do núcleo
• Programação concorrente Java: sincronizada e seus princípios de implementação
• Programação simultânea de Java: otimização subjacente sincronizada (trava leve, trava tendenciosa)
• Programação simultânea de Java: colaboração entre threads (aguarde/notificar/dormir/rendimento/junção)
• Programação concorrente Java: o uso de volátil e seus princípios
1. O papel do volátil
No artigo "Programação de concorrência de Java: teoria do núcleo", mencionamos os problemas de visibilidade, ordem e atomicidade. Geralmente, podemos resolver esses problemas através da palavra -chave sincronizada. No entanto, se você entender o princípio de sincronizado, deve saber que o sincronizado é uma operação relativamente pesada e tem um impacto relativamente grande no desempenho do sistema. Portanto, se houver outras soluções, geralmente evitamos o uso sincronizado para resolver o problema. A palavra -chave volátil é outra solução fornecida em Java para resolver os problemas de visibilidade e ordem. Em relação à atomicidade, também é um ponto que todos são propensos a mal -entendidos: uma única operação de leitura/gravação de variáveis voláteis pode garantir a atomicidade, como variáveis longas e duplas, mas não pode garantir a atomicidade das operações de I ++, porque em essência, I ++ é lida e escreva operações duas vezes.
2. Uso de volátil
Em relação ao uso de voláteis, podemos usar vários exemplos para ilustrar seu uso e cenários.
1. Evite a reordenação
Vamos analisar o problema de reordenação de um dos exemplos mais clássicos. Todos devem estar familiarizados com a implementação do modelo Singleton e, em um ambiente simultâneo, geralmente podemos usar o método de bloqueio de verificação dupla (DCL) para implementá -lo. O código -fonte é o seguinte:
pacote com.paddx.test.concurrent; public class singleton {public static volátil singleton singleton; / *** O construtor é privado, proibindo a instanciação externa*/ private singleton () {}; public static singleton getInstance () {if (singleton == null) {synchronized (singleton) {if (singleton == null) {singleton = new singleton (); }} retornar singleton; }}Agora, vamos analisar por que precisamos adicionar a palavra -chave volátil entre a variável singleton. Para entender esse problema, você deve primeiro entender o processo de construção de objetos. Instantar um objeto pode realmente ser dividido em três etapas:
(1) alocar espaço de memória.
(2) Inicialize o objeto.
(3) Atribua o endereço do espaço de memória à referência correspondente.
No entanto, como o sistema operacional pode reordenar as instruções, o processo acima também pode se tornar o seguinte processo:
(1) alocar espaço de memória.
(2) Atribua o endereço do espaço de memória à referência correspondente.
(3) inicializar o objeto
Se esse processo for o processo, uma referência de objeto não inicializada poderá ser exposta em um ambiente multithread, resultando em resultados imprevisíveis. Portanto, para evitar a reordenação desse processo, precisamos definir a variável como uma variável de tipo volátil.
2. Alcançar visibilidade
O problema de visibilidade refere -se principalmente a um thread modificando o valor variável compartilhado, enquanto o outro thread não pode vê -lo. O principal motivo do problema de visibilidade é que cada encadeamento possui sua própria área de cache - a memória de trabalho do encadeamento. A palavra -chave volátil pode resolver efetivamente esse problema. Vejamos os seguintes exemplos para saber sua função:
pacote com.paddx.test.concurrent; classe pública volatilEtest {int a = 1; int b = 2; public void alteração () {a = 3; b = a; } public void print () {System.out.println ("b ="+b+"; a ="+a); } public static void main (string [] args) {while (true) {final volatilEtest test = new VolatilEtest (); novo thread (novo runnable () {@Override public void run () {try {thread.sleep (10);} catch (interruptedException e) {e.printStackTrace ();} test.change ();}}). start (); novo thread (novo runnable () {@Override public void run () {try {thread.sleep (10);} catch (interruptedException e) {e.printStacktrace ();} test.print ();}}). start (); }}}Intuitivamente falando, existem apenas dois resultados possíveis para este código: B = 3; a = 3 ou b = 2; a = 1. No entanto, executando o código acima (talvez demore um pouco mais), você descobrirá que, além dos dois resultados anteriores, também há um terceiro resultado:
...... b = 2; a = 1b = 2; a = 1b = 3; a = 3b = 3; a = 3b = 3; a = 1b = 3; a = 3b = 2; a = 1b = 3; a = 3b = 3; a = 3b = 3; a = 3 ...
Por que um resultado como B = 3; a = 1 aparece? Em circunstâncias normais, se você executar o método de mudança primeiro e executar o método de impressão, o resultado da saída deve ser b = 3; a = 3. Pelo contrário, se você executar o método de impressão primeiro e executar o método de mudança, o resultado deve ser B = 2; a = 1. Então, como o resultado de B = 3; a = 1 sai? O motivo é que o primeiro thread modifica o valor A = 3, mas é invisível para o segundo encadeamento, então esse resultado ocorre. Se A e B forem alterados para variáveis do tipo volátil e executados, o resultado de b = 3; a = 1 nunca mais aparecerá.
3. Garanta atomicidade
A questão da atomicidade foi explicada acima. O volátil só pode garantir atomicidade para leitura/gravação única. Este problema pode ser descrito no JLS:
17.7 Tratamento não atômico de duplo e longo para os propósitos do modelo de memória da linguagem de programação Java, uma única gravação em um valor não volátil de comprimento ou duplo é tratado como duas gravações separadas: uma para cada metade de 32 bits. Isso pode resultar em uma situação em que um thread vê os primeiros 32 bits de um valor de 64 bits de uma gravação, e os segundos 32 bits de outra gravação. Escreva e leituras de valores voláteis e duplos são sempre atômicos. As gravações e as leituras das referências são sempre atômicas, independentemente de serem implementadas como valores de 32 ou 64 bits. Algumas implementações podem achar conveniente dividir uma única ação de gravação em um valor de 64 bits de comprimento ou duplo em duas ações de gravação em valores adjacentes de 32 bits. Por uma questão de eficiência, esse comportamento é específico da implementação; Uma implementação da máquina virtual Java é gratuita para executar gravações em valores longos e duplos atomicamente ou em duas partes. As implementações da máquina virtual Java são incentivadas a evitar a divisão de valores de 64 bits sempre que possível. Os programadores são incentivados a declarar valores compartilhados de 64 bits como voláteis ou sincronizar seus programas corretamente para evitar possíveis complicações.
O conteúdo desta passagem é aproximadamente semelhante ao que eu descrevi anteriormente. Como as operações dos dois tipos de dados de longa e dupla podem ser divididos em duas partes: 32 bits altos e 32 bits, tipos longos ou duplos comuns podem não ser atômicos. Portanto, todos são incentivados a definir as variáveis longas e duplas compartilhadas como tipos voláteis, o que pode garantir que as operações de leitura/gravação únicas de longas e duplas sejam atômicas em qualquer caso.
Há um problema de que variáveis voláteis garantem atomicidade, o que é facilmente incompreendido. Agora, demonstraremos esse problema através do seguinte programa:
pacote com.paddx.test.concurrent; classe pública volatiletest01 {volatile int i; public void addi () {i ++; } public static void main (string [] args) lança interruptedException {Final VolatilEtest01 test01 = new VolatileTest01 (); para (int n = 0; n <1000; n ++) {new Thread (new Runnable () {@Override public void run () {try {thread.sleep (10);} catch (interruptedException e) {e.printstacktrace ();} test01.addi ();}}). } Thread.sleep (10000); // Aguarde 10 segundos para garantir que a execução do programa acima seja concluída System.out.println (test01.i); }}Você pode acreditar erroneamente que, depois de adicionar a palavra-chave volátil à variável I, este programa é seguro para threads. Você pode tentar executar o programa acima. Aqui estão os resultados da minha corrida local:
Talvez todo mundo execute os resultados de maneira diferente. No entanto, deve -se observar que o volátil não pode garantir atomicidade (caso contrário, o resultado deve ser 1000). O motivo também é muito simples. I ++ é na verdade uma operação composta, incluindo três etapas:
(1) Leia o valor de i.
(2) Adicione 1 a i.
(3) Escreva o valor de I de volta à memória.
Não há garantia de que essas três operações sejam atômicas. Podemos garantir a atomicidade das operações de +1 através do AtomicInteger ou sincronizado.
Nota: O método Thread.sleep () foi executado em muitos lugares nas seções acima do código, com o objetivo de aumentar a chance de problemas de simultaneidade e não tem outro efeito.
3. O princípio do volátil
Através dos exemplos acima, devemos basicamente saber o que é volátil e como usá -lo. Agora, vamos dar uma olhada em como a camada subjacente de volátil é implementada.
1. Implementação de visibilidade:
Como mencionado no artigo anterior, o tópico em si não interage diretamente com os principais dados da memória, mas conclui as operações correspondentes através da memória de trabalho do encadeamento. Essa também é a razão essencial pela qual os dados entre os threads são invisíveis. Portanto, para obter visibilidade de variáveis voláteis, você pode começar diretamente a partir desse aspecto. Existem duas diferenças principais entre as operações de escrita em variáveis voláteis e variáveis comuns:
(1) Ao modificar a variável volátil, o valor modificado será forçado a atualizar a memória principal.
(2) A modificação da variável volátil causará os valores variáveis correspondentes na memória de trabalho de outros threads falharem. Portanto, ao ler o valor dessa variável novamente, você precisa ler o valor na memória principal novamente.
Através dessas duas operações, o problema de visibilidade das variáveis voláteis pode ser resolvido.
2. Implementação ordenada:
Antes de explicar esse problema, vamos primeiro entender as regras que acontecem em Java. A definição de acontecimento antes no JSR 133 é a seguinte:
Duas ações podem ser ordenadas por um relacionamento acontecer antes. Se uma ação acontecer antes de outra, a primeira é visível e encomendada antes do segundo.
Nos termos do leigo, se um acontecimento antes de B, qualquer operações a for visível para b. (Todos devem se lembrar disso, porque a palavra que acontece antes é facilmente incompreendida como antes e depois). Vamos dar uma olhada no que as regras que acontecem antes são definidas no JSR 133:
• Cada ação em um encadeamento ocorre antes de cada ação subsequente nesse tópico. • Um desbloqueio em um monitor ocorre antes de cada bloqueio subsequente nesse monitor. • Uma gravação para um campo volátil ocorre antes de cada leitura subsequente dessa volátil. • Uma chamada para iniciar () em um thread ocorre antes de quaisquer ações no thread iniciado. • Todas as ações em um encadeamento acontecem antes que qualquer outro thread retorne com sucesso de uma junção () nesse tópico. • Se uma ação A acontecer antes de uma ação B e B acontecer antes de uma ação C, ACHA ANTES ANTES C.
Traduzido como:
• A operação anterior aconteceu antes do mesmo thread. (ou seja, em um único thread, é legal executar na ordem do código. No entanto, o compilador e o processador podem reordenar sem afetar a execução resulta em um único ambiente de encadeamento. Em outras palavras, é que as regras não podem garantir a reordenação de compilação e reordenação de instruções).
• Desbloquear operação no monitor Acontece antes de sua operação de travamento subsequente. (Regras sincronizadas)
• Escreva a operação para variáveis voláteis acontecerem antes das operações de leitura subsequente. (Regras voláteis)
• O método start () do encadeamento acontece antes de todas as operações subsequentes do encadeamento. (Regra de inicialização do thread)
• Todas as operações do thread acontecem antes de outros threads chamam a participação neste thread e retorne a operação bem-sucedida.
• Se um acontecimento antes de b, B acontecer antes de C, então um acontecimento antes de C (transitivo).
Aqui, analisamos principalmente a terceira regra: as regras para garantir a ordem das variáveis voláteis. O artigo "Java Concurrency Programming: Core Theory" mencionou que a reordenação é dividida em reordenação de compiladores e reordenação de processadores. Para implementar a semântica da memória volátil, o JMM restringe a reordenação desses dois tipos de variáveis voláteis. A seguir, é apresentada a tabela de regras de reordenação especificadas pelo JMM para variáveis voláteis:
| Pode reordenar | 2ª operação | |||
| 1ª operação | Carga normal Loja normal | Carga volátil | Loja volátil | |
| Carga normal Loja normal | Não | |||
| Carga volátil | Não | Não | Não | |
| Loja volátil | Não | Não | ||
3. Barreira de memória
Para implementar a visibilidade volátil e a semântica da realização. A JVM subjacente é feita através de algo chamado "barreira de memória". A barreira da memória, também conhecida como cerca de memória, é um conjunto de instruções do processador usadas para implementar restrições seqüenciais nas operações de memória. Aqui está a barreira de memória necessária para concluir as regras acima:
| Barreiras necessárias | 2ª operação | |||
| 1ª operação | Carga normal | Loja normal | Carga volátil | Loja volátil |
| Carga normal | Loadstore | |||
| Loja normal | Storestore | |||
| Carga volátil | Carga de carga | Loadstore | Carga de carga | Loadstore |
| Loja volátil | Storeload | Storestore | ||
(1) barreira de carga de carga
Ordem de execução: Load1 -> LoadLoad -> Load2
Verifique se o carregamento2 e as instruções de carga subsequente podem acessar os dados carregados pelo carregamento1 antes do carregamento dos dados.
(2) Barreira de Storestore
Ordem de execução: store1 -> storestore -> store2
Verifique se os dados da operação do Store1 estão visíveis para outros processadores antes que o Store2 e as instruções subsequentes da loja sejam executadas.
(3) Barreira de Loadstore
Ordem de execução: load1 -> loadstore -> store2
Certifique -se de que, antes que o Store2 e as instruções subsequentes da loja sejam executadas, os dados carregados pelo Load1 possam ser acessados.
(4) Barreira de Storeload
Ordem de execução: Store1 -> storeload -> load2
Certifique -se de que, antes que o load2 e as instruções de carga subsequentes sejam lidas, os dados da Store1 sejam visíveis para outros processadores.
Finalmente, posso usar um exemplo para ilustrar como a barreira da memória é inserida na JVM:
pacote com.paddx.test.concurrent; public class MemoryBarrier {int a, b; volátil int v, u; void f () {int i, j; i = a; j = b; i = v; // carregamento de carga j = u; // loadstore a = i; b = j; // storestore v = i; // storestore u = j; // storeload i = u; // loadload // loadstore j = b; a = i; }}4. Resumo
No geral, o entendimento volátil ainda é relativamente difícil. Se você não entende em particular, não precisa se apressar. É preciso um processo para entendê -lo completamente. Você também verá os cenários de uso de voláteis muitas vezes nos artigos subsequentes. Aqui tenho um entendimento básico do conhecimento básico de volátil e do original. De um modo geral, o volátil é uma otimização na programação simultânea, que pode substituir sincronizada em alguns cenários. No entanto, o volátil não pode substituir completamente a posição de sincronizado. Somente em alguns cenários especiais pode ser aplicado volátil. Em geral, as duas condições a seguir devem ser atendidas ao mesmo tempo para garantir a segurança dos threads em um ambiente simultâneo:
(1) A operação de gravação para variáveis não depende do valor atual.
(2) Essa variável não está incluída no invariante com outras variáveis.
O artigo acima sobre programação simultânea Java: o uso do Volátil e sua análise de princípios são todo o conteúdo que compartilho com você. Espero que você possa lhe dar uma referência e espero que você possa apoiar mais o wulin.com.