A programação simultânea é uma das habilidades mais importantes para os programadores Java e uma das habilidades mais difíceis de dominar. Exige que os programadores tenham um profundo entendimento dos princípios operacionais mais baixos do computador e, ao mesmo tempo, exige que os programadores tenham lógica clara e pensamento meticuloso, para que possam escrever programas simultâneos eficientes, seguros e confiáveis. Esta série começará pela natureza da coordenação entre thread (aguarde, notificar, notificar todos), sincronizados e voláteis e explicar em detalhes cada ferramenta de simultaneidade e mecanismo de implementação subjacente fornecido pelo JDK. Nesta base, analisaremos ainda mais as classes de ferramentas do pacote java.util.concurrent, incluindo seu uso, implementação do código -fonte e os princípios por trás dele. Este artigo é o primeiro artigo desta série e é a parte teórica mais central desta série. Os artigos subsequentes serão analisados e explicados com base nisso.
1. Compartilhamento
O compartilhamento de dados é uma das principais razões para a segurança do thread. Se todos os dados forem válidos apenas no thread, não há problema de segurança de threads, que é uma das principais razões pelas quais geralmente não precisamos considerar a segurança do thread ao programar. No entanto, na programação multithread, o compartilhamento de dados é inevitável. O cenário mais típico são os dados no banco de dados. Para garantir a consistência dos dados, geralmente precisamos compartilhar os dados no mesmo banco de dados. Mesmo no caso de mestre e escravo, os mesmos dados são acessados. O mestre e o escravo estão apenas copiando os mesmos dados para a eficiência de acesso e segurança de dados. Agora demonstramos os problemas causados pelo compartilhamento de dados em vários tópicos por meio de um exemplo simples:
Snippet de código 1:
pacote com.paddx.test.concurrent; classe pública sharedata {public static int count = 0; public static void main (string [] args) {final sharedata dados = new sharedata (); para (int i = 0; i <10; i ++) {new Thread (new Runnable () {@Override public void run () {try {// pausa por 1 milissegundos ao entrar para aumentar a chance de jeofiSTSTACTRACHTRACE.Sleep (1);} Catch (interruptException e) {e.printChtrace (); data.addcount (); } tente {// O programa principal é pausado por 3 segundos para garantir que a execução do programa acima seja concluída Thread.Sleep (3000); } catch (interruptedException e) {e.printStackTrace (); } System.out.println ("count =" + count); } public void addCount () {count ++; }}O objetivo do código acima é adicionar uma operação para contar e executar 1.000 vezes, mas aqui é implementado através de 10 threads, cada thread executa 100 vezes e, em circunstâncias normais, 1.000 devem ser produzidos. No entanto, se você executar o programa acima, descobrirá que o resultado não é o caso. Aqui está o resultado da execução de um certo tempo (os resultados de cada execução podem não ser os mesmos e, às vezes, o resultado correto pode ser obtido):
Pode-se observar que, para operações variáveis compartilhadas, vários resultados inesperados são facilmente vistos em um ambiente multithread.
2. Exclusão mútua
Exclusão mútua de recursos significa que apenas um visitante pode acessá -lo ao mesmo tempo, o que é único e exclusivo. Geralmente, permitimos que vários threads leiam dados ao mesmo tempo, mas apenas um thread pode gravar dados ao mesmo tempo. Por isso, geralmente dividimos bloqueios em bloqueios compartilhados e bloqueios exclusivos, também chamados de travas de leitura e bloqueios de gravação. Se os recursos não forem mutuamente exclusivos, não precisamos nos preocupar com a segurança dos threads, mesmo que sejam recursos compartilhados. Por exemplo, para compartilhamento de dados imutáveis, todos os threads só podem lê -lo; portanto, os problemas de segurança dos threads não são necessários. No entanto, a escrita de operações para dados compartilhados geralmente requer exclusão mútua. No exemplo acima, os problemas de modificação de dados ocorrem devido à falta de exclusão mútua. O Java fornece vários mecanismos para garantir a exclusão mútua, a maneira mais fácil é usar sincronizado. Agora adicionamos sincronizados ao programa acima e executamos:
Snippet de código dois:
pacote com.paddx.test.concurrent; classe pública sharedata {public static int count = 0; public static void main (string [] args) {final sharedata dados = new sharedata (); para (int i = 0; i <10; i ++) {new Thread (new Runnable () {@Override public void run () {try {// pausa por 1 milissegundos ao entrar para aumentar a chance de jeofiSTSTACTRACHTRACE.Sleep (1);} Catch (interruptException e) {e.printChtrace (); data.addcount (); } tente {// O programa principal é pausado por 3 segundos para garantir que a execução do programa acima seja concluída Thread.Sleep (3000); } catch (interruptedException e) {e.printStackTrace (); } System.out.println ("count =" + count); } / *** Adicione palavra -chave sincronizado* / public sincronizado void addCount () {count ++; }}Agora que o código acima é executado, você descobrirá que, não importa quantas vezes você execute, o resultado final será 1000.
Iii. Atomicidade
Atomicidade refere -se à operação de dados como um todo independente e indivisível. Em outras palavras, é uma operação contínua e ininterrupta. Metade da execução de dados não é modificada por outros threads. A maneira mais fácil de garantir a atomicidade é as instruções do sistema operacional, ou seja, se uma operação corresponde a uma instrução do sistema operacional por vez, ela definitivamente garantirá a atomicidade. No entanto, muitas operações não podem ser concluídas com uma instrução. Por exemplo, para operações do tipo longo, muitos sistemas precisam ser divididos em várias instruções para operar nas posições altas e baixas, respectivamente. Por exemplo, a operação do número inteiro I ++ que geralmente usamos realmente precisa ser dividido em três etapas: (1) Leia o valor do número inteiro i; (2) Adicione uma operação a I; (3) Escreva o resultado de volta à memória. Este processo pode ocorrer no multithreading:
Essa também é a razão pela qual o resultado da execução do segmento de código está incorreto. Para esta operação de combinação, a maneira mais comum de garantir que a atomicidade seja travada, como sincronizada ou bloqueio em Java, pode ser implementada, e o segmento de código 2 é implementado por meio de sincronizado. Além dos bloqueios, há outra maneira de CAS (comparar e trocar), ou seja, antes de modificar os dados, comparar se os valores lidos antes dos anteriores são consistentes. Se forem consistentes, modifique -os e se forem inconsistentes, serão executados novamente. Este também é o princípio de otimizar a implementação do bloqueio. No entanto, o CAS pode não ser eficaz em alguns cenários. Por exemplo, outro thread primeiro modifica um determinado valor e depois o altera para o valor original. Nesse caso, o CAS não pode julgar.
4. Visibilidade
Para entender a visibilidade, você precisa ter um certo entendimento do modelo de memória da JVM. O modelo de memória da JVM é semelhante ao sistema operacional, como mostrado na figura:
A partir dessa figura, podemos ver que cada encadeamento possui sua própria memória de trabalho (equivalente ao buffer avançado da CPU. O objetivo disso é restringir ainda mais a diferença de velocidade entre o sistema de armazenamento e a CPU e melhorar o desempenho). Para variáveis compartilhadas, cada vez que o thread lê uma cópia da variável compartilhada na memória de trabalho. Ao escrever, ele modifica diretamente o valor da cópia na memória de trabalho e sincroniza a memória de trabalho com o valor na memória principal em um determinado momento. O problema que isso causa é que, se o Thread 1 modificar uma determinada variável, o encadeamento 2 poderá não ver as modificações feitas pelo encadeamento 1 na variável compartilhada. Através do programa a seguir, podemos demonstrar o problema invisível:
pacote com.paddx.test.concurrent; classe pública visibilidadetest {private estático booleano pronto; número estático privado int; classe estática privada ReaderThread estende o thread {public void run () {try {thread.sleep (10); } catch (interruptedException e) {e.printStackTrace (); } if (! Ready) {System.out.println (pronto); } System.out.println (número); }} classe estática privada Writerthread Extende Thread {public void run () {try {thread.sleep (10); } catch (interruptedException e) {e.printStackTrace (); } número = 100; pronto = true; }} public static void main (string [] args) {new writterthread (). start (); new ReaderThread (). start (); }}Intuitivamente, este programa deve produzir apenas 100 e o valor pronto não será impresso. De fato, se você executar o código acima várias vezes, pode haver muitos resultados diferentes. Aqui estão os resultados de algumas duas corridas:
Obviamente, esse resultado só pode ser possível ser possível devido à visibilidade. Quando o thread Write (Writterthread) está pronto = true, o ReaderThread não pode ver o resultado modificado, portanto, False será impresso. Para o segundo resultado, ou seja, o resultado do thread de gravação não foi lido ao executar se (! Pronto), mas o resultado da execução do thread de gravação é lido ao executar o System.out.println (pronto). No entanto, esse resultado também pode ser causado pela execução alternativa de threads. A visibilidade pode ser garantida por meio de sincronizado ou volátil em Java, e os detalhes específicos serão analisados em artigos subsequentes.
5. Sequência
Para melhorar o desempenho, o compilador e o processador podem reordenar as instruções. Existem três tipos de reordenação:
(1) Reordenação otimizada do compilador. O compilador pode reagendar a ordem de execução das declarações sem alterar a semântica de um programa de thread único.
(2) Reordenando o paralelismo no nível da instrução. Os processadores modernos usam a tecnologia paralela de nível de instrução (ICP) para sobrepor a execução de múltiplas instruções. Se não houver dependência de dados, o processador poderá alterar a ordem de execução da instrução correspondente às instruções da máquina.
(3) reordenação do sistema de memória. Como o processador usa buffers de cache e leitura/gravação, isso faz com que as operações de carregamento e armazenamento pareçam ser executadas fora de ordem.
Podemos nos referir diretamente à descrição dos problemas de reordenação no JSR 133:
(1) (2)
Vamos primeiro olhar para a parte do código -fonte (1) na figura acima. A partir do código -fonte, a instrução 1 é executada primeiro ou a instrução 3 é executada primeiro. Se a instrução 1 for executada primeiro, o R2 não deve ver o valor escrito na Instrução 4. Se a Instrução 3 for executada primeiro, o R1 não deve ver o valor escrito pela Instrução 2. No entanto, o resultado em execução poderá ter R2 == 2 e R1 == 1, que é o resultado da "reordenação". A figura acima (2) é um possível resultado de compilação legal. Após a compilação, a Ordem da Instrução 1 e a Instrução 2 podem ser trocadas. Portanto, o resultado de R2 == 2 e R1 == 1 aparecerá. Sincronizado ou volátil também pode ser usado em Java para garantir a ordem.
Seis resumo
Este artigo explica a base teórica da programação simultânea de Java, e algumas coisas serão discutidas em mais detalhes em análises subsequentes, como visibilidade, ordem, etc. Os artigos subsequentes serão discutidos com base no conteúdo deste capítulo. Se você pode entender bem o conteúdo acima, acredito que será de grande ajuda para você, seja para entender outros artigos de programação simultânea ou em seu trabalho diário de programação simultânea.