A fonte de bugs de programação concorrente Java

Este artigo é o terceiro artigo de "Java High Concurrency", que foi publicado pela primeira vez no site pessoal .

Acredito que todo mundo já ouviu falar em programação concorrente, e muitas vezes você é questionado sobre esse conhecimento em entrevistas.Às vezes, deixe-me dizer se você tem alguma experiência em programação concorrente e falar sobre isso em detalhes. Os resultados podem ser imaginados, o conhecimento teórico pode ser dito, mas não há muita experiência prática, o que é ainda mais problemático é a enorme distância entre teoria e prática. No trabalho, a simultaneidade do sistema é relativamente baixa.Com a ajuda de bancos de dados e middleware como o Tomcat, basicamente não precisamos escrever programas concorrentes.

Em uma palavra, quando a simultaneidade do sistema não é alta, os problemas de simultaneidade são basicamente resolvidos pelo middleware e banco de dados, ou o volume de dados do sistema é relativamente grande e requer desempenho, então a programação simultânea é necessária.

Programação concorrente é uma coisa boa, mas não existe almoço grátis no mundo. Tudo tem um preço. Ao mesmo tempo em que obtém alta performance, também tem que suportar muitos problemas causados ​​pela programação concorrente.

Problemas de visibilidade causados ​​pelo cache

Visibilidade (Visibilidade de Objeto Compartilhado) : A visibilidade do encadeamento para modificações de variáveis ​​compartilhadas. Quando um encadeamento modifica o valor de uma variável compartilhada, outros encadeamentos ficam imediatamente cientes da modificação.

No artigo Java Memory Model , é apresentada a relação entre threads, memória de trabalho e memória principal. Se você não tiver nenhuma impressão, poderá revisá-la.

Se dois ou mais threads compartilham um objeto, as atualizações do objeto compartilhado por um thread podem não ser visíveis para outros threads: o objeto compartilhado é inicializado na memória principal. Um thread em execução na CPU lê o objeto compartilhado no cache da CPU e então modifica o objeto. Contanto que o cache da CPU não seja liberado para a memória principal, a versão modificada do objeto é invisível para threads em execução em outras CPUs. Essa abordagem pode resultar em cada encadeamento com uma cópia privada do objeto compartilhado, cada um em um cache de CPU diferente.

A figura abaixo ilustra essa situação. O thread em execução na CPU esquerda copia o objeto compartilhado em seu cache da CPU e altera o valor da variável de contagem para 2. Essa modificação é invisível para outros threads em execução na CPU correta, porque o valor de contagem modificado não foi liberado de volta para a memória principal.

As variáveis ​​mudam entre o cache da CPU e a memória principal

Demonstramos através de um caso subordinado. Quando a thread B altera o valor da variável stopRequested, mas não teve tempo de escrevê-la na memória principal, a thread B passa a fazer outras coisas, então a thread A não sabe a mudança da thread B para a variável stopRequested. , então ela continuará a circular.

public class VisibilityCacheTest {

  private static boolean stopRequested = false;

  public static void main(String[] args) throws InterruptedException {
    Thread thread1 = new Thread(() -> {
      int i = 0;
      while (!stopRequested) {
        i++;
      }
    },"A");

    Thread thread2 = new Thread(() -> {
      stopRequested = true;
    },"B");

    thread1.start();
    TimeUnit.SECONDS.sleep(1);	//为了演示死循环,特意sleep一秒
    thread2.start();
  }
}

Esse código é um pedaço de código típico e muitas pessoas podem usar esse método de marcação ao interromper um encadeamento. Mas, na verdade, esse código será executado exatamente certo? O fio será interrompido? Não necessariamente, talvez na maioria das vezes, esse código possa interromper a thread, mas também pode fazer com que a thread seja interrompida (embora essa possibilidade seja muito pequena, enquanto isso acontecer, causará um loop infinito).

Problema de atomicidade causado pela troca de thread

Mesmo os processadores single-core suportam a execução de código multithread, e a CPU implementa esse mecanismo atribuindo fatias de tempo da CPU a cada thread. A fatia de tempo é o tempo alocado pela CPU para cada thread. Como a fatia de tempo é muito curta, a CPU alterna a execução das threads continuamente, de modo que sentimos que várias threads estão executando ao mesmo tempo. A fatia de tempo geralmente é dezenas de milissegundos (ms).

A CPU executa tarefas ciclicamente através do algoritmo de alocação de fatias de tempo, após a tarefa atual executar uma fatia de tempo, ela passará para a próxima tarefa. No entanto, o estado da tarefa anterior é salvo antes de alternar, para que o estado dessa tarefa possa ser recarregado na próxima vez que você retornar a essa tarefa. Assim, o processo da tarefa de salvar para recarregar é uma troca de contexto .

O diagrama esquemático da comutação de threads é o seguinte:

Diagrama esquemático de troca de thread

Ainda demonstramos através de um caso clássico:

public class AtomicityTest {

  static int count = 0;

  public static void main(String[] args) throws InterruptedException {
    AtomicityTest obj = new AtomicityTest();
    Thread t1 = new Thread(() -> {
      obj.add();
    }, "A");

    Thread t2 = new Thread(() -> {
      obj.add();
    }, "B");

    t1.start();
    t2.start();

    t1.join();
    t2.join();

    System.out.println("main线程输入结果为==>" + count);
  }

  public void add() {
    for (int i = 0; i < 100000; i++) {
      count++;
    }
  }
}

O que o código acima faz é muito simples, abra 2 threads para realizar 100.000 mais 1 operações na mesma variável inteira compartilhada respectivamente, esperamos que o valor de count seja impresso no final seja 200000, mas não funciona, execute o código acima, O valor de contagem é muito provavelmente não igual a 200.000, e o resultado de cada execução é diferente, sempre menor que 200.000. Por que isso acontece?

A operação de incremento automático não é atômica, inclui a leitura do valor original da variável, a adição de 1 e a gravação na memória de trabalho. Então significa que as três suboperações da operação de autoincremento podem ser executadas separadamente, o que pode levar às seguintes situações:

Se o valor da variável count em um determinado momento for 10,

A thread A executa uma operação de incremento automático na variável. A thread A lê primeiro o valor original da variável count e, em seguida, a thread A é bloqueada (se existir);

然后线程B对变量进行自增操作,线程B也去读取变量 count 的原始值,由于线程A只是对变量 count 进行读取操作,而没有对变量进行修改操作,所以主存中 count 的值未发生改变,此时线程B会直接去主存读取 count 的值,发现 count 的值为10,然后进行加1操作,并把11写入工作内存,最后写入主存。

然后线程A接着进行加1操作,由于已经读取了 count 的值,注意此时在线程A的工作内存中 count 的值仍然为10,所以线程A对 count 进行加1操作后 count 的值为11,然后将11写入工作内存,最后写入主存。

那么两个线程分别进行了一次自增操作后,inc只增加了1。

编译优化带来的有序性问题

在 Java 高并发系列开始时,第一篇文章介绍了计算机的一些基础知识。处理器为了提高 CPU 的效率,通常会采用指令乱序执行的技术,即将两个没有数据依赖的指令乱序执行,但并不会影响最终的结果。与处理器的乱序执行优化类似,Java 虚拟机的即时编译器中也有类似的指令重排序(Instruction Reorder)优化。

在 Java 领域一个经典的案例就是利用双重检查创建单例对象,例如下面的代码:在获取实例 getInstance() 的方法中,我们首先判断 instance 是否为空,如果为空,则锁定 Singleton.class 并再次检查 instance 是否为空,如果还为空则创建 Singleton 的一个实例。

public class Singleton {
  static Singleton instance;
  static Singleton getInstance(){
    if (instance == null) {
      synchronized(Singleton.class) {
        if (instance == null)
          instance = new Singleton();
        }
    }
    return instance;
  }
}

Assumindo que existem dois encadeamentos A e B chamando o método getInstance() ao mesmo tempo, eles encontrarão instance == null ao mesmo tempo, então eles bloquearão o Singleton.class ao mesmo tempo. garante que apenas um thread pode bloquear com sucesso (supondo que seja o thread A), outro thread estará em estado de espera (assumindo o thread B); o thread A criará uma instância Singleton e liberará o bloqueio, depois que o bloqueio for liberado, thread B é despertado e o thread B tenta bloquear novamente, neste momento pode ser bloqueado Se o bloqueio for bem-sucedido, quando o thread B verificar a instância == null, ele descobrirá que uma instância Singleton já foi criada, então o thread B irá não crie outra instância Singleton.

Existem três etapas para instanciar um objeto:

(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.

Mas como o sistema operacional pode reordenar instruções, o processo acima também pode se tornar o seguinte:

(1) Alocar espaço de memória.

(2) Atribua o endereço do espaço de memória à referência correspondente.

(3) Inicialize o objeto s

Se for esse processo, uma referência de objeto não inicializada pode ser exposta em um ambiente multithread, resultando em resultados imprevisíveis.

Objeto de inicialização multithread

Resumir

Para escrever um bom programa concorrente, você deve primeiro saber onde está o problema do programa concorrente. Somente determinando o "alvo" o problema pode ser resolvido. Afinal, todas as soluções são para o problema.

O propósito de caching, threading e otimização de compilação é o mesmo que nosso propósito de escrever programas simultâneos, que é melhorar o desempenho do programa. No entanto, enquanto a tecnologia resolve um problema, inevitavelmente trará outro problema, portanto, ao adotar uma tecnologia, devemos ter clareza sobre quais problemas ela traz e como evitá-los.

Acho que você gosta

Origin juejin.im/post/7117517103791341605
Recomendado
Clasificación