JavaRush /Blog Java /Random-ES /No puedes arruinar Java con un hilo: Parte II - sincroniz...
Viacheslav
Nivel 3

No puedes arruinar Java con un hilo: Parte II - sincronización

Publicado en el grupo Random-ES

Introducción

Entonces, sabemos que hay subprocesos en Java, sobre los cuales puede leer en la revisión " No se puede estropear Java con un subproceso: Parte I - Subprocesos ". Se necesitan subprocesos para trabajar simultáneamente. Por lo tanto, es muy probable que los hilos interactúen de alguna manera entre sí. Entendamos cómo sucede esto y qué controles básicos tenemos. No puedes arruinar Java con un hilo: Parte II - sincronización - 1

Producir

El método Thread.yield() es misterioso y rara vez se utiliza. Hay muchas variaciones de su descripción en Internet. Hasta el punto de que algunos escriben sobre una especie de cola de hilos, en la que el hilo se moverá hacia abajo teniendo en cuenta sus prioridades. Alguien escribe que el hilo cambiará su estado de en ejecución a ejecutable (aunque no hay división en estos estados y Java no distingue entre ellos). Pero en realidad todo es mucho más desconocido y, en cierto sentido, más sencillo. No puedes arruinar Java con un hilo: Parte II - sincronización - 2En el tema de la documentación del método, yieldhay un error " JDK-6416721: (subproceso de especificaciones) Fix Thread.yield() javadoc ". Si lo lee, está claro que, de hecho, el método yieldsolo transmite alguna recomendación al programador de subprocesos de Java de que a este subproceso se le puede dar menos tiempo de ejecución. Pero lo que realmente sucederá, si el programador escuchará la recomendación y qué hará en general depende de la implementación de la JVM y del sistema operativo. O tal vez por otros factores. Lo más probable es que toda la confusión se deba al replanteamiento del subproceso múltiple durante el desarrollo del lenguaje Java. Puede leer más en la revisión " Breve introducción a Java Thread.yield() ".

Dormir - Hilo para quedarse dormido

Un hilo puede quedarse dormido durante su ejecución. Este es el tipo más simple de interacción con otros hilos. El sistema operativo en el que está instalada la máquina virtual Java, donde se ejecuta el código Java, tiene su propio programador de subprocesos, llamado Thread Scheduler. Es él quien decide qué hilo ejecutar y cuándo. El programador no puede interactuar con este programador directamente desde el código Java, pero puede, a través de la JVM, pedirle al programador que detenga el hilo por un tiempo, para "ponerlo en suspensión". Puede leer más en los artículos " Thread.sleep() " y " Cómo funciona Multithreading ". Además, puede descubrir cómo funcionan los subprocesos en el sistema operativo Windows: " Partes internas del subproceso de Windows ". Ahora lo veremos con nuestros propios ojos. Guardemos el siguiente código en un archivo HelloWorldApp.java:
class HelloWorldApp {
    public static void main(String []args) {
        Runnable task = () -> {
            try {
                int secToWait = 1000 * 60;
                Thread.currentThread().sleep(secToWait);
                System.out.println("Waked up");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        };
        Thread thread = new Thread(task);
        thread.start();
    }
}
Como puede ver, tenemos una tarea que espera 60 segundos, luego de los cuales finaliza el programa. Compilamos javac HelloWorldApp.javay ejecutamos java HelloWorldApp. Es mejor iniciarlo en una ventana separada. Por ejemplo, en Windows sería así: start java HelloWorldApp. Usando el comando jps, encontramos el PID del proceso y abrimos la lista de subprocesos usando jvisualvm --openpid pidПроцесса: No puedes arruinar Java con un hilo: Parte II - sincronización - 3Como puede ver, nuestro subproceso ha entrado en estado Inactivo. De hecho, dormir el hilo actual se puede hacer de manera más hermosa:
try {
	TimeUnit.SECONDS.sleep(60);
	System.out.println("Waked up");
} catch (InterruptedException e) {
	e.printStackTrace();
}
Probablemente hayas notado que procesamos en todas partes InterruptedException. Entendamos por qué.

Interrumpir un hilo o Thread.interrupt

La cuestión es que mientras el hilo espera mientras duerme, es posible que alguien quiera interrumpir esta espera. En este caso, manejamos tal excepción. Esto se hizo después de que el método Thread.stopfuera declarado obsoleto, es decir. obsoletos e indeseables para su uso. La razón de esto fue que cuando se invocaba el método, stopel hilo simplemente "se mataba", lo cual era muy impredecible. No podíamos saber cuándo se detendría el flujo, no podíamos garantizar la coherencia de los datos. Imagine que está escribiendo datos en un archivo y luego la secuencia se destruye. Por lo tanto, decidieron que sería más lógico no cortar el flujo, sino informarle que debía interrumpirse. Cómo reaccionar ante esto depende del flujo mismo. Se pueden encontrar más detalles en "¿ Por qué Thread.stop está obsoleto? " de Oracle. Veamos un ejemplo:
public static void main(String []args) {
	Runnable task = () -> {
		try {
			TimeUnit.SECONDS.sleep(60);
		} catch (InterruptedException e) {
			System.out.println("Interrupted");
		}
	};
	Thread thread = new Thread(task);
	thread.start();
	thread.interrupt();
}
En este ejemplo, no esperaremos 60 segundos, sino que imprimiremos inmediatamente "Interrumpido". Esto se debe a que llamamos al método del hilo interrupt. Este método establece un "indicador interno llamado estado de interrupción". Es decir, cada hilo tiene un indicador interno al que no se puede acceder directamente. Pero tenemos métodos nativos para interactuar con esta bandera. Pero ésta no es la única manera. Un hilo puede estar en proceso de ejecución, sin esperar algo, sino simplemente realizando acciones. Pero puede prever que querrán completarlo en un momento determinado de su trabajo. Por ejemplo:
public static void main(String []args) {
	Runnable task = () -> {
		while(!Thread.currentThread().isInterrupted()) {
			//Do some work
		}
		System.out.println("Finished");
	};
	Thread thread = new Thread(task);
	thread.start();
	thread.interrupt();
}
En el ejemplo anterior, puede ver que el bucle whilese ejecutará hasta que el hilo se interrumpa externamente. Lo importante que debemos saber sobre el indicador isInterrupted es que si lo detectamos InterruptedException, el indicador isInterruptedse restablece y luego isInterrupteddevolverá falso. También hay un método estático en la clase Thread que solo se aplica al hilo actual: Thread.interrupted() , ¡pero este método restablece el indicador a falso! Puedes leer más en el capítulo " Interrupción del hilo ".

Unirse: esperando a que se complete otro hilo

El tipo de espera más simple es esperar a que se complete otro hilo.
public static void main(String []args) throws InterruptedException {
	Runnable task = () -> {
		try {
			TimeUnit.SECONDS.sleep(5);
		} catch (InterruptedException e) {
			System.out.println("Interrupted");
		}
	};
	Thread thread = new Thread(task);
	thread.start();
	thread.join();
	System.out.println("Finished");
}
En este ejemplo, el nuevo hilo dormirá durante 5 segundos. Al mismo tiempo, el hilo principal esperará hasta que el hilo dormido se despierte y termine su trabajo. Si miras a través de JVisualVM, el estado del hilo se verá así: No puedes arruinar Java con un hilo: Parte II - sincronización - 4Gracias a las herramientas de monitoreo, puedes ver lo que está sucediendo con el hilo. El método joines bastante simple, porque es simplemente un método con código java que se ejecuta waitmientras el hilo en el que se llama está vivo. Una vez que el hilo muere (al terminar), la espera finaliza. Esa es toda la magia del método join. Por tanto, pasemos a la parte más interesante.

Monitor de concepto

En subprocesos múltiples existe algo llamado Monitor. En general, la palabra monitor se traduce del latín como “supervisor” o “supervisor”. En el marco de este artículo, intentaremos recordar la esencia y, para aquellos que quieran, les pido que se sumerjan en el material desde los enlaces para obtener más detalles. Comencemos nuestro viaje con la especificación del lenguaje Java, es decir, con JLS: " 17.1. Sincronización ". Dice lo siguiente: No puedes arruinar Java con un hilo: Parte II - sincronización - 5Resulta que para la sincronización entre subprocesos, Java utiliza un determinado mecanismo llamado "Monitor". Cada objeto tiene un monitor asociado y los subprocesos pueden bloquearlo o desbloquearlo. A continuación, encontraremos un tutorial de formación en la web de Oracle: “ Bloqueos intrínsecos y sincronización ”. Este tutorial explica que la sincronización en Java se basa en una entidad interna conocida como bloqueo intrínseco o bloqueo de monitor. A menudo, este tipo de cerradura se denomina simplemente "monitor". También vemos nuevamente que cada objeto en Java tiene un bloqueo intrínseco asociado. Puede leer " Java: bloqueos intrínsecos y sincronización ". A continuación, es importante comprender cómo se puede asociar un objeto en Java con un monitor. Cada objeto en Java tiene un encabezado, una especie de metadatos internos que no están disponibles para el programador en el código, pero que la máquina virtual necesita para trabajar correctamente con los objetos. El encabezado del objeto incluye una MarkWord que se ve así: No puedes arruinar Java con un hilo: Parte II - sincronización - 6

https://edu.netbeans.org/contrib/slides/java-overview-and-java-se6.pdf

Un artículo de Habr es muy útil aquí: "¿ Pero cómo funciona el subproceso múltiple? Parte I: sincronización ". A este artículo vale la pena agregar una descripción del bloque Resumen de tareas del JDK bugtaker: " JDK-8183909 ". Puedes leer lo mismo en " JEP-8183909 ". Entonces, en Java, un monitor está asociado con un objeto y el hilo puede bloquear este hilo, o también dicen "obtener un bloqueo". El ejemplo más simple:
public class HelloWorld{
    public static void main(String []args){
        Object object = new Object();
        synchronized(object) {
            System.out.println("Hello World");
        }
    }
}
Entonces, usando la palabra clave, synchronizedel hilo actual (en el que se ejecutan estas líneas de código) intenta usar el monitor asociado con el objeto objecty "obtener un bloqueo" o "capturar el monitor" (la segunda opción es incluso preferible). Si no hay competencia por el monitor (es decir, nadie más quiere sincronizar en el mismo objeto), Java puede intentar realizar una optimización llamada "bloqueo sesgado". El título del objeto en Mark Word contendrá la etiqueta correspondiente y un registro del hilo al que está conectado el monitor. Esto reduce la sobrecarga al capturar el monitor. Si el monitor ya ha sido atado a otro hilo antes, entonces este bloqueo no es suficiente. La JVM cambia al siguiente tipo de bloqueo: bloqueo básico. Utiliza operaciones de comparación e intercambio (CAS). Al mismo tiempo, el encabezado en Mark Word ya no almacena el propio Mark Word, sino que se cambia un enlace a su almacenamiento + la etiqueta para que la JVM entienda que estamos usando un bloqueo básico. Si hay competencia por el monitor de varios subprocesos (uno ha capturado el monitor y el segundo está esperando a que se libere), entonces la etiqueta en Mark Word cambia y Mark Word comienza a almacenar una referencia al monitor como un objeto: alguna entidad interna de la JVM. Como se indica en el JEP, en este caso, se requiere espacio en el área de memoria del montón nativo para almacenar esta entidad. El enlace a la ubicación de almacenamiento de esta entidad interna se ubicará en el objeto Mark Word. Por tanto, como vemos, el monitor es en realidad un mecanismo para garantizar la sincronización del acceso de múltiples subprocesos a recursos compartidos. Hay varias implementaciones de este mecanismo entre las que cambia la JVM. Por tanto, para simplificar, cuando hablamos de monitor, en realidad estamos hablando de cerraduras. No puedes arruinar Java con un hilo: Parte II - sincronización - 7

Sincronizado y esperando por bloqueo

El concepto de monitor, como vimos anteriormente, está estrechamente relacionado con el concepto de “bloque de sincronización” (o, como también se le llama, sección crítica). Veamos un ejemplo:
public static void main(String[] args) throws InterruptedException {
	Object lock = new Object();

	Runnable task = () -> {
		synchronized (lock) {
			System.out.println("thread");
		}
	};

	Thread th1 = new Thread(task);
	th1.start();
	synchronized (lock) {
		for (int i = 0; i < 8; i++) {
			Thread.currentThread().sleep(1000);
			System.out.print("  " + i);
		}
		System.out.println(" ...");
	}
}
Aquí, el hilo principal primero envía la tarea a un nuevo hilo y luego inmediatamente "captura" el candado y realiza una operación larga con él (8 segundos). Durante todo este tiempo, la tarea no puede ingresar al bloque para su ejecución synchronized, porque la cerradura ya está ocupada. Si un hilo no puede obtener un bloqueo, lo esperará en el monitor. Tan pronto como lo reciba, continuará la ejecución. Cuando un hilo sale del monitor, libera el bloqueo. En JVisualVM se vería así: No puedes arruinar Java con un hilo: Parte II - sincronización - 8Como puedes ver, el estado en JVisualVM se llama "Monitor" porque el hilo está bloqueado y no puede ocupar el monitor. También puedes conocer el estado del hilo en el código, pero el nombre de este estado no coincide con los términos de JVisualVM, aunque son similares. En este caso, th1.getState()el bucle fordevolverá BLOCKED , porque Mientras se ejecuta el bucle, el monitor lockestá ocupado mainpor el subproceso, el subproceso th1está bloqueado y no puede continuar funcionando hasta que se devuelva el bloqueo. Además de los bloques de sincronización, se puede sincronizar un método completo. Por ejemplo, un método de la clase HashTable:
public synchronized int size() {
	return count;
}
En una unidad de tiempo, este método será ejecutado por un solo hilo. Pero necesitamos un candado, ¿verdad? Si, lo necesito. En el caso de métodos de objeto, el bloqueo será this. Hay una discusión interesante sobre este tema: "¿ Existe alguna ventaja en utilizar un método sincronizado en lugar de un bloque sincronizado? ". Si el método es estático, entonces el bloqueo no lo será this(ya que para un método estático no puede ser this), sino el objeto de clase (por ejemplo, Integer.class).

Espere y espere en el monitor. Los métodos notificar y notificar a todos.

Thread tiene otro método de espera, que está conectado al monitor. A diferencia de sleepy join, no se puede llamar simplemente. Y su nombre es wait. El método se ejecuta waiten el objeto en cuyo monitor queremos esperar. Veamos un ejemplo:
public static void main(String []args) throws InterruptedException {
	    Object lock = new Object();
	    // task будет ждать, пока его не оповестят через lock
	    Runnable task = () -> {
	        synchronized(lock) {
	            try {
	                lock.wait();
	            } catch(InterruptedException e) {
	                System.out.println("interrupted");
	            }
	        }
	        // После оповещения нас мы будем ждать, пока сможем взять лок
	        System.out.println("thread");
	    };
	    Thread taskThread = new Thread(task);
	    taskThread.start();
        // Ждём и после этого забираем себе лок, оповещаем и отдаём лок
	    Thread.currentThread().sleep(3000);
	    System.out.println("main");
	    synchronized(lock) {
	        lock.notify();
	    }
}
En JVisualVM se verá así: No puedes arruinar Java con un hilo: Parte II - sincronización - 10Para entender cómo funciona esto, debes recordar que los métodos waithacen referencia notifya java.lang.Object. Parece extraño que los métodos relacionados con subprocesos estén en el archivo Object. Pero aquí está la respuesta. Como recordamos, cada objeto en Java tiene un encabezado. El encabezado contiene diversa información de servicio, incluida información sobre el monitor: datos sobre el estado de bloqueo. Y como recordamos, cada objeto (es decir, cada instancia) tiene una asociación con una entidad interna de JVM llamada bloqueo intrínseco, que también se llama monitor. En el ejemplo anterior, la tarea describe que ingresamos al bloque de sincronización en el monitor asociado a lock. Si es posible obtener un bloqueo en este monitor, entonces wait. El hilo que ejecuta esta tarea liberará el monitor lock, pero se unirá a la cola de hilos que esperan notificación en el monitor lock. Esta cola de subprocesos se llama WAIT-SET, que refleja más correctamente la esencia. Es más un conjunto que una cola. El hilo maincrea un nuevo hilo con la tarea, lo inicia y espera 3 segundos. Esto permite, con un alto grado de probabilidad, que un nuevo hilo agarre el candado antes que el hilo mainy se ponga en cola en el monitor. Después de lo cual el hilo mainingresa al bloque de sincronización locky realiza la notificación del hilo en el monitor. Después de enviar la notificación, el hilo mainlibera el monitor locky el nuevo hilo (que estaba esperando anteriormente) lockcontinúa ejecutándose después de esperar a que se libere el monitor. Es posible enviar una notificación solo a uno de los subprocesos ( notify) o a todos los subprocesos en la cola a la vez ( notifyAll). Puede leer más en " Diferencia entre notificar() y notificarTodos() en Java ". Es importante tener en cuenta que el orden de notificación depende de la implementación de JVM. Puedes leer más en "¿ Cómo solucionar el hambre con notify y notifyall? ". La sincronización se puede realizar sin especificar un objeto. Esto se puede hacer cuando no se sincroniza una sección separada de código, sino un método completo. Por ejemplo, para métodos estáticos el bloqueo será el objeto de clase (obtenido a través de .class):
public static synchronized void printA() {
	System.out.println("A");
}
public static void printB() {
	synchronized(HelloWorld.class) {
		System.out.println("B");
	}
}
En cuanto al uso de cerraduras, ambos métodos son iguales. Si el método no es estático, entonces la sincronización se realizará según el actual instance, es decir, según this. Por cierto, antes dijimos que usando el método getStatepuedes obtener el estado de un hilo. Entonces, aquí hay un hilo que el monitor pone en cola, el estado será ESPERA o TIMED_WAITING si el método waitespecifica un límite de tiempo de espera. No puedes arruinar Java con un hilo: Parte II - sincronización - 11

Ciclo de vida de un hilo

Como hemos visto, el flujo cambia de estatus a lo largo de la vida. En esencia, estos cambios son el ciclo de vida del hilo. Cuando se acaba de crear un hilo, tiene el estado NUEVO. En esta posición, aún no se ha iniciado y el Java Thread Scheduler aún no sabe nada sobre el nuevo hilo. Para que el programador de subprocesos conozca un subproceso, debe llamar al archivo thread.start(). Entonces el hilo pasará al estado EJECUTABLE. Hay muchos esquemas incorrectos en Internet donde los estados Runnable y Running están separados. Pero esto es un error, porque... Java no diferencia entre estados "listo para ejecutar" y "en ejecución". Cuando un hilo está activo pero no activo (no ejecutable), se encuentra en uno de dos estados:
  • BLOQUEADO: espera la entrada a una sección protegida, es decir, al synchonizedbloque.
  • ESPERANDO: espera otro hilo según una condición. Si la condición es verdadera, el programador de subprocesos inicia el subproceso.
Si un hilo está esperando por tiempo, está en el estado TIMED_WAITING. Si el hilo ya no se está ejecutando (se completó correctamente o con una excepción), pasa al estado TERMINADO. Para conocer el estado de un hilo (su estado), se utiliza el método getState. Los subprocesos también tienen un método isAliveque devuelve verdadero si el subproceso no finaliza.

LockSupport y estacionamiento de subprocesos

Desde Java 1.6 existía un mecanismo interesante llamado LockSupport . No puedes arruinar Java con un hilo: Parte II - sincronización - 12Esta clase asocia un "permiso" o permiso con cada hilo que lo utiliza. La llamada al método parkregresa inmediatamente si hay un permiso disponible, ocupando ese mismo permiso durante la llamada. De lo contrario queda bloqueado. Llamar al método unparkhace que el permiso esté disponible si aún no está disponible. Solo hay 1 permiso. En la API de Java, LockSupportun determinado Semaphore. Veamos un ejemplo sencillo:
import java.util.concurrent.Semaphore;
public class HelloWorldApp{

    public static void main(String[] args) {
        Semaphore semaphore = new Semaphore(0);
        try {
            semaphore.acquire();
        } catch (InterruptedException e) {
            // Просим разрешение и ждём, пока не получим его
            e.printStackTrace();
        }
        System.out.println("Hello, World!");
    }
}
Este código esperará para siempre porque el semáforo ahora tiene 0 permiso. Y cuando se llama en código acquire(es decir, solicita permiso), el hilo espera hasta recibir permiso. Como estamos esperando, estamos obligados a procesarlo InterruptedException. Curiosamente, un semáforo implementa un estado de hilo separado. Si miramos en JVisualVM, veremos que nuestro estado no es Espera, sino Parque. No puedes arruinar Java con un hilo: Parte II - sincronización - 13Veamos otro ejemplo:
public static void main(String[] args) throws InterruptedException {
        Runnable task = () -> {
            //Запаркуем текущий поток
            System.err.println("Will be Parked");
            LockSupport.park();
            // Как только нас распаркуют - начнём действовать
            System.err.println("Unparked");
        };
        Thread th = new Thread(task);
        th.start();
        Thread.currentThread().sleep(2000);
        System.err.println("Thread state: " + th.getState());

        LockSupport.unpark(th);
        Thread.currentThread().sleep(2000);
}
El estado del hilo será ESPERANDO, pero JVisualVM distingue waitentre from synchronizedy parkfrom LockSupport. ¿ Por qué es éste tan importante LockSupport? Volvamos nuevamente a la API de Java y observemos el estado del hilo EN ESPERA . Como puede ver, sólo hay tres formas de acceder a él. 2 formas: esta waity join. Y el tercero es LockSupport. Los bloqueos en Java se basan en los mismos principios LockSupporty representan herramientas de nivel superior. Intentemos usar uno. Veamos, por ejemplo ReentrantLock,:
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class HelloWorld{

    public static void main(String []args) throws InterruptedException {
        Lock lock = new ReentrantLock();
        Runnable task = () -> {
            lock.lock();
            System.out.println("Thread");
            lock.unlock();
        };
        lock.lock();

        Thread th = new Thread(task);
        th.start();
        System.out.println("main");
        Thread.currentThread().sleep(2000);
        lock.unlock();
    }
}
Como en ejemplos anteriores, aquí todo es sencillo. lockespera a que alguien libere un recurso. Si miramos en JVisualVM, veremos que el nuevo hilo quedará aparcado hasta que mainel hilo le dé el bloqueo. Puede leer más sobre bloqueos aquí: " Programación multiproceso en Java 8. Segunda parte. Sincronización del acceso a objetos mutables " y " Java Lock API. Teoría y ejemplo de uso ". Para comprender mejor la implementación de bloqueos, es útil leer sobre Phazer en la descripción general " Clase Phaser ". Y hablando de varios sincronizadores, debes leer el artículo de Habré “ Java.util.concurrent.* Synchronizers Reference ”.

Total

En esta revisión, analizamos las principales formas en que interactúan los subprocesos en Java. Material adicional: #viacheslav
Comentarios
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION