El origen de los errores de programación concurrentes de Java

Este artículo es el tercero de "Java High Concurrency", que se publicó por primera vez en el sitio web personal .

Creo que todo el mundo ha oído hablar de la programación concurrente, y a menudo te preguntan sobre estos conocimientos en las entrevistas. A veces, déjame decirte si tienes alguna experiencia en programación concurrente y hablar sobre ello en detalle. Los resultados se pueden imaginar, el conocimiento teórico se puede decir, pero no hay mucha experiencia práctica, lo que es aún más problemático es la enorme brecha entre la teoría y la práctica. En el trabajo, la concurrencia del sistema es relativamente baja, con la ayuda de bases de datos y middleware como Tomcat, básicamente no necesitamos escribir programas concurrentes.

En una palabra, cuando la concurrencia del sistema no es alta, los problemas de concurrencia se resuelven básicamente mediante el middleware y la base de datos, o el volumen de datos del sistema es relativamente grande y requiere rendimiento, entonces se requiere programación concurrente.

La programación concurrente es algo bueno, pero no hay comida gratis en el mundo, todo tiene un precio, además de obtener un alto rendimiento, también tiene que soportar muchos problemas causados ​​por la programación concurrente.

Problemas de visibilidad causados ​​por el almacenamiento en caché

Visibilidad (visibilidad de objetos compartidos) : la visibilidad del subproceso para las modificaciones de variables compartidas. Cuando un subproceso modifica el valor de una variable compartida, otros subprocesos se dan cuenta inmediatamente de la modificación.

En el artículo Java Memory Model , se introduce la relación entre hilos, memoria de trabajo y memoria principal.Si no tiene ninguna impresión, puede revisarla.

Si dos o más subprocesos comparten un objeto, las actualizaciones del objeto compartido por un subproceso pueden no ser visibles para otros subprocesos: el objeto compartido se inicializa en la memoria principal. Un subproceso que se ejecuta en la CPU lee el objeto compartido en la memoria caché de la CPU y luego modifica el objeto. Siempre que la memoria caché de la CPU no se vacíe a la memoria principal, la versión modificada del objeto es invisible para los subprocesos que se ejecutan en otras CPU. Este enfoque podría dar como resultado que cada subproceso tenga una copia privada del objeto compartido, cada uno en una caché de CPU diferente.

La siguiente figura ilustra esta situación. El subproceso que se ejecuta en la CPU izquierda copia el objeto compartido en su caché de CPU y luego cambia el valor de la variable de conteo a 2. Esta modificación es invisible para otros subprocesos que se ejecutan en la CPU derecha, porque el valor de conteo modificado no se ha devuelto a la memoria principal.

Cambio de variables entre la memoria caché de la CPU y la memoria principal

Lo demostramos a través de un caso subordinado. Cuando el subproceso B cambia el valor de la variable stopRequested, pero no ha tenido tiempo de escribirlo en la memoria principal, el subproceso B pasa a hacer otras cosas, entonces el subproceso A no sabe el cambio del subproceso B. a la variable stopRequested. , por lo que continuará ciclando.

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();
  }
}

Este código es una pieza típica de código, y muchas personas pueden usar este método de marcado al interrumpir un hilo. Pero, de hecho, ¿se ejecutará correctamente este código? ¿Se interrumpirá el hilo? No necesariamente, tal vez la mayoría de las veces, este código puede interrumpir el hilo, pero también puede causar que el hilo se interrumpa (aunque esta posibilidad es muy pequeña, mientras esto suceda, causará un bucle infinito).

Problema de atomicidad causado por el cambio de hilo

Incluso los procesadores de un solo núcleo admiten la ejecución de código de subprocesos múltiples, y la CPU implementa este mecanismo asignando intervalos de tiempo de CPU a cada subproceso. El intervalo de tiempo es el tiempo asignado por la CPU a cada subproceso. Debido a que el intervalo de tiempo es muy corto, la CPU cambia la ejecución de los subprocesos continuamente, de modo que sentimos que varios subprocesos se ejecutan al mismo tiempo. El intervalo de tiempo generalmente es decenas de milisegundos (ms).

La CPU ejecuta tareas cíclicamente a través del algoritmo de asignación de intervalos de tiempo. Después de que la tarea actual ejecuta un intervalo de tiempo, cambiará a la siguiente tarea. Sin embargo, el estado de la tarea anterior se guarda antes de cambiar, de modo que el estado de esta tarea se puede volver a cargar la próxima vez que vuelva a cambiar a esta tarea. Entonces, el proceso de la tarea desde guardar hasta recargar es un cambio de contexto .

El diagrama esquemático del cambio de hilo es el siguiente:

Diagrama esquemático del cambio de hilo

Todavía demostramos a través de un caso clásico:

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++;
    }
  }
}

Lo que hace el código anterior es muy simple, abre 2 hilos para realizar 100,000 más 1 operaciones en la misma variable entera compartida respectivamente, esperamos que el valor de conteo que se imprima al final sea 200000, pero no funciona, ejecuta el código anterior, es muy probable que el valor de la cuenta no sea igual a 200,000, y el resultado de cada ejecución es diferente, siempre menos de 200,000. ¿Por qué pasó esto?

La operación de autoincremento no es atómica, incluye leer el valor original de la variable, sumar 1 y escribir en la memoria de trabajo. Entonces significa que las tres suboperaciones de la operación de incremento automático pueden ejecutarse por separado, lo que puede dar lugar a las siguientes situaciones:

Si el valor de la variable cuenta en un momento determinado es 10,

El subproceso A realiza una operación de incremento automático en la variable. El subproceso A primero lee el valor original de la variable conteo, y luego el subproceso A se bloquea (si existe);

然后线程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;
  }
}

Suponiendo que hay dos subprocesos A y B que llaman al método getInstance() al mismo tiempo, encontrarán instancia == nulo al mismo tiempo, por lo que bloquearán Singleton.class al mismo tiempo. garantiza que solo un subproceso puede bloquearse con éxito (suponiendo que sea el subproceso A), otro subproceso estará en estado de espera (suponiendo que el subproceso B); el subproceso A creará una instancia de Singleton, luego liberará el bloqueo, después de que se libere el bloqueo, el subproceso B se despierta y el subproceso B intenta bloquearse nuevamente, en este momento se puede bloquear. Si el bloqueo tiene éxito, cuando el subproceso B verifica la instancia == nulo, encontrará que ya se ha creado una instancia Singleton, por lo que el subproceso B lo hará. no cree otra instancia de Singleton.

Hay tres pasos para instanciar un objeto:

(1) Asignar espacio de memoria.

(2) Inicialice el objeto.

(3) Asigne la dirección del espacio de memoria a la referencia correspondiente.

Pero dado que el sistema operativo puede reordenar las instrucciones, el proceso anterior también puede convertirse en el siguiente proceso:

(1) Asignar espacio de memoria.

(2) Asigne la dirección del espacio de memoria a la referencia correspondiente.

(3) Inicializar el objeto s

Si es este proceso, una referencia de objeto no inicializado puede quedar expuesta en un entorno de subprocesos múltiples, lo que genera resultados impredecibles.

Objeto de inicialización de subprocesos múltiples

Resumir

Para escribir un buen programa concurrente, primero debe saber dónde está el problema del programa concurrente. Solo determinando el "objetivo" se puede resolver el problema. Después de todo, todas las soluciones son para el problema.

El propósito del almacenamiento en caché, el subprocesamiento y la optimización de la compilación es el mismo que nuestro propósito de escribir programas concurrentes, que es mejorar el rendimiento del programa. Sin embargo, si bien la tecnología resuelve un problema, inevitablemente traerá otro problema, por lo tanto, al adoptar una tecnología, debemos tener claro qué problemas trae y cómo evitarlos.

Supongo que te gusta

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