JavaRush /Blog Java /Random-ES /No se puede estropear Java con un hilo: Parte III - Inter...
Viacheslav
Nivel 3

No se puede estropear Java con un hilo: Parte III - Interacción

Publicado en el grupo Random-ES
Una breve descripción general de las características de la interacción de hilos. Anteriormente, vimos cómo los subprocesos se sincronizan entre sí. Esta vez profundizaremos en los problemas que pueden surgir cuando los hilos interactúan y hablaremos sobre cómo se pueden evitar. También proporcionaremos algunos enlaces útiles para un estudio más profundo. No puedes arruinar Java con un hilo: Parte III - interacción - 1

Introducción

Entonces, sabemos que hay subprocesos en Java, sobre los cuales puede leer en la revisión " Los subprocesos no pueden estropear Java: Parte I - Subprocesos " y que los subprocesos se pueden sincronizar entre sí, lo cual tratamos en la revisión " El hilo no puede estropear Java "Estropear: Parte II - Sincronización ". Es hora de hablar sobre cómo interactúan los hilos entre sí. ¿Cómo comparten recursos comunes? ¿Qué problemas podría haber con esto?

Punto muerto

El peor problema es el punto muerto. Cuando dos o más subprocesos esperan eternamente el uno para el otro, esto se denomina punto muerto. Tomemos un ejemplo del sitio web de Oracle a partir de la descripción del concepto de " Deadlock ":
public class Deadlock {
    static class Friend {
        private final String name;
        public Friend(String name) {
            this.name = name;
        }
        public String getName() {
            return this.name;
        }
        public synchronized void bow(Friend bower) {
            System.out.format("%s: %s has bowed to me!%n",
                    this.name, bower.getName());
            bower.bowBack(this);
        }
        public synchronized void bowBack(Friend bower) {
            System.out.format("%s: %s has bowed back to me!%n",
                    this.name, bower.getName());
        }
    }

    public static void main(String[] args) {
        final Friend alphonse = new Friend("Alphonse");
        final Friend gaston = new Friend("Gaston");
        new Thread(() -> alphonse.bow(gaston)).start();
        new Thread(() -> gaston.bow(alphonse)).start();
    }
}
Es posible que el punto muerto aquí no aparezca la primera vez, pero si la ejecución de su programa se bloquea, es hora de ejecutarlo jvisualvm: No puedes arruinar Java con un hilo: Parte III - interacción - 2si hay un complemento instalado en JVisualVM (a través de Herramientas -> Complementos), podemos ver dónde ocurrió el punto muerto:
"Thread-1" - Thread t@12
   java.lang.Thread.State: BLOCKED
    at Deadlock$Friend.bowBack(Deadlock.java:16)
    - waiting to lock &lt33a78231> (a Deadlock$Friend) owned by "Thread-0" t@11
El hilo 1 está esperando un bloqueo del hilo 0. ¿Por qué sucede esto? Thread-1inicia la ejecución y ejecuta el método Friend#bow. Está marcado con la palabra clave synchronized, es decir, cogemos el monitor por this. Al ingresar al método, recibimos un enlace a otro Friend. Ahora, el hilo Thread-1quiere ejecutar un método en otro Friend, obteniendo así también un bloqueo de él. Pero si otro hilo (en este caso Thread-0) logró ingresar al método bow, entonces el bloqueo ya está ocupado y Thread-1esperando Thread-0, y viceversa. El bloqueo no tiene solución, por lo que está muerto, es decir, muerto. Tanto un agarre mortal (que no se puede soltar) como un bloqueo muerto del que no se puede escapar. Sobre el tema del interbloqueo, puede ver el vídeo: " Interbloqueo - Concurrencia n.º 1 - Java avanzado ".

bloqueo vivo

Si hay un Deadlock, ¿hay un Livelock? Sí, lo hay) Livelock es que los hilos parecen estar vivos exteriormente, pero al mismo tiempo no pueden hacer nada, porque... no se puede cumplir la condición bajo la cual intentan continuar su trabajo. En esencia, Livelock es similar a un punto muerto, pero los subprocesos no se "cuelgan" en el sistema esperando al monitor, sino que siempre están haciendo algo. Por ejemplo:
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class App {
    public static final String ANSI_BLUE = "\u001B[34m";
    public static final String ANSI_PURPLE = "\u001B[35m";

    public static void log(String text) {
        String name = Thread.currentThread().getName(); //like Thread-1 or Thread-0
        String color = ANSI_BLUE;
        int val = Integer.valueOf(name.substring(name.lastIndexOf("-") + 1)) + 1;
        if (val != 0) {
            color = ANSI_PURPLE;
        }
        System.out.println(color + name + ": " + text + color);
        try {
            System.out.println(color + name + ": wait for " + val + " sec" + color);
            Thread.currentThread().sleep(val * 1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    public static void main(String[] args) {
        Lock first = new ReentrantLock();
        Lock second = new ReentrantLock();

        Runnable locker = () -> {
            boolean firstLocked = false;
            boolean secondLocked = false;
            try {
                while (!firstLocked || !secondLocked) {
                    firstLocked = first.tryLock(100, TimeUnit.MILLISECONDS);
                    log("First Locked: " + firstLocked);
                    secondLocked = second.tryLock(100, TimeUnit.MILLISECONDS);
                    log("Second Locked: " + secondLocked);
                }
                first.unlock();
                second.unlock();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        };

        new Thread(locker).start();
        new Thread(locker).start();
    }
}
El éxito de este código depende del orden en que el programador de subprocesos de Java inicia los subprocesos. Si comienza primero Thead-1, obtendremos Livelock:
Thread-1: First Locked: true
Thread-1: wait for 2 sec
Thread-0: First Locked: false
Thread-0: wait for 1 sec
Thread-0: Second Locked: true
Thread-0: wait for 1 sec
Thread-1: Second Locked: false
Thread-1: wait for 2 sec
Thread-0: First Locked: false
Thread-0: wait for 1 sec
...
Como se puede ver en el ejemplo, ambos subprocesos intentan capturar ambos bloqueos alternativamente, pero fallan. Además, no están en un punto muerto, es decir, visualmente todo está bien para ellos y están haciendo su trabajo. No puedes arruinar Java con un hilo: Parte III - interacción - 3Según JVisualVM, vemos los períodos de suspensión y el período de estacionamiento (esto es cuando un subproceso intenta ocupar un bloqueo, entra en el estado de estacionamiento, como comentamos anteriormente cuando hablamos de sincronización de subprocesos ). Sobre el tema de livelock, puedes ver un ejemplo: " Java - Thread Livelock ".

Inanición

Además del bloqueo (bloqueo muerto y bloqueo activo), existe otro problema cuando se trabaja con subprocesos múltiples: el hambre o la "inanición". Este fenómeno se diferencia del bloqueo en que los hilos no se bloquean, sino que simplemente no tienen suficientes recursos para todos. Por lo tanto, mientras algunos hilos ocupan todo el tiempo de ejecución, otros no pueden ejecutarse: No puedes arruinar Java con un hilo: Parte III - interacción - 4

https://www.logicbig.com/

Puede encontrar un excelente ejemplo aquí: " Java: falta de subprocesos y equidad ". Este ejemplo muestra cómo funcionan los subprocesos en Starvation y cómo un pequeño cambio de Thread.sleep a Thread.wait puede distribuir la carga de manera uniforme. No puedes arruinar Java con un hilo: Parte III - interacción - 5

Condición de carrera

Cuando se trabaja con subprocesos múltiples, existe una "condición de carrera". Este fenómeno radica en el hecho de que los subprocesos comparten un determinado recurso entre ellos y el código está escrito de tal manera que en este caso no permite un funcionamiento correcto. Veamos un ejemplo:
public class App {
    public static int value = 0;

    public static void main(String[] args) {
        Runnable task = () -> {
            for (int i = 0; i < 10000; i++) {
                int oldValue = value;
                int newValue = ++value;
                if (oldValue + 1 != newValue) {
                    throw new IllegalStateException(oldValue + " + 1 = " + newValue);
                }
            }
        };
        new Thread(task).start();
        new Thread(task).start();
        new Thread(task).start();
    }
}
Es posible que este código no genere un error la primera vez. Y podría verse así:
Exception in thread "Thread-1" java.lang.IllegalStateException: 7899 + 1 = 7901
    at App.lambda$main$0(App.java:13)
    at java.lang.Thread.run(Thread.java:745)
Como puedes ver, mientras lo asignaban newValuealgo salió mal y newValuefueron más. Algunos de los hilos en la condición de carrera lograron cambiar valueentre estos dos equipos. Como podemos ver, ha aparecido una carrera entre hilos. Ahora imagine lo importante que es no cometer errores similares con las transacciones de dinero... También se pueden ver ejemplos y diagramas aquí: “ Código para simular la condición de carrera en el hilo de Java ”.

Volátil

Hablando de la interacción de hilos, cabe destacar especialmente la palabra clave volatile. Veamos un ejemplo sencillo:
public class App {
    public static boolean flag = false;

    public static void main(String[] args) throws InterruptedException {
        Runnable whileFlagFalse = () -> {
            while(!flag) {
            }
            System.out.println("Flag is now TRUE");
        };

        new Thread(whileFlagFalse).start();
        Thread.sleep(1000);
        flag = true;
    }
}
Lo más interesante es que con un alto grado de probabilidad no funcionará. El nuevo hilo no verá el cambio flag. Para solucionar este problema, flagdebe especificar una palabra clave para el campo volatile. ¿Como y por qué? Todas las acciones son realizadas por el procesador. Pero los resultados del cálculo deben almacenarse en algún lugar. Para ello, en el procesador hay una memoria principal y una caché de hardware. Estos cachés de procesador son como una pequeña porción de memoria para acceder a los datos más rápido que acceder a la memoria principal. Pero todo también tiene un inconveniente: es posible que los datos en el caché no estén actualizados (como en el ejemplo anterior, cuando el valor de la bandera no se actualizó). Entonces, la palabra clave volatilele dice a la JVM que no queremos almacenar en caché nuestra variable. Esto le permite ver el resultado real en todos los hilos. Esta es una formulación muy simplificada. Sobre este tema, volatilese recomienda encarecidamente leer la traducción de " Preguntas frecuentes sobre JSR 133 (modelo de memoria Java) ". También le aconsejo que lea más sobre los materiales " Java Memory Model " y " Java Volatile Keyword ". Además, es importante recordar que volatilese trata de visibilidad y no de atomicidad de los cambios. Si tomamos el código de "Condición de carrera", veremos una pista en IntelliJ Idea: No puedes arruinar Java con un hilo: Parte III - interacción - 6esta inspección (Inspección) se agregó a IntelliJ Idea como parte del problema IDEA-61117 , que figuraba en las Notas de la versión en 2010.

Atomicidad

Las operaciones atómicas son operaciones que no se pueden dividir. Por ejemplo, la operación de asignar un valor a una variable es atómica. Desafortunadamente, el incremento no es una operación atómica, porque un incremento requiere hasta tres operaciones: obtener el valor anterior, agregarle uno y guardar el valor. ¿Por qué es importante la atomicidad? En el ejemplo del incremento, si se produce una condición de carrera, en cualquier momento el recurso compartido (es decir, el valor compartido) puede cambiar repentinamente. Además, es importante que las estructuras de 64 bits tampoco sean atómicas, por ejemplo longy double. Puede leer más aquí: " Asegurar la atomicidad al leer y escribir valores de 64 bits ". Un ejemplo de problemas con la atomicidad se puede ver en el siguiente ejemplo:
public class App {
    public static int value = 0;
    public static AtomicInteger atomic = new AtomicInteger(0);

    public static void main(String[] args) throws InterruptedException {
        Runnable task = () -> {
            for (int i = 0; i < 10000; i++) {
                value++;
                atomic.incrementAndGet();
            }
        };
        for (int i = 0; i < 3; i++) {
            new Thread(task).start();
        }
        Thread.sleep(300);
        System.out.println(value);
        System.out.println(atomic.get());
    }
}
Una clase especial para trabajar con atomic Integersiempre nos mostrará 30000, pero valuecambiará de vez en cuando. Hay una breve descripción general sobre este tema " Introducción a las variables atómicas en Java ". Atomic se basa en el algoritmo de comparación e intercambio. Puede leer más al respecto en el artículo sobre Habré " Comparación de algoritmos sin bloqueo: CAS y FAA usando el ejemplo de JDK 7 y 8 " o en Wikipedia en el artículo sobre " Comparación con intercambio ". No puedes arruinar Java con un hilo: Parte III - interacción - 8

http://jeremymanson.blogspot.com/2008/11/what-volatile-means-in-java.html

sucede antes

Hay algo interesante y misterioso: sucede antes. Hablando de flujos, también vale la pena leerlo. La relación Sucede antes indica el orden en el que se verán las acciones entre subprocesos. Hay muchas interpretaciones e interpretaciones. Uno de los informes más recientes sobre este tema es este informe:
Probablemente sea mejor que este vídeo no diga nada al respecto. Así que simplemente dejaré un enlace al vídeo. Puede leer " Java: comprensión de las relaciones que suceden antes ".

Resultados

En esta revisión, analizamos las características de la interacción de hilos. Discutimos los problemas que pueden surgir y las formas de detectarlos y eliminarlos. Lista de materiales adicionales sobre el tema: #viacheslav
Comentarios
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION