La programación concurrente es una de las habilidades más importantes para los programadores de Java y una de las habilidades más difíciles de dominar. Requiere que los programadores tengan una comprensión profunda de los principios operativos más bajos de la computadora, y al mismo tiempo, requiere que los programadores tengan una lógica clara y un pensamiento meticuloso, para que puedan escribir programas concurrentes multiproceso eficientes, seguros y confiables. Esta serie comenzará desde la naturaleza de la coordinación entre thread (esperar, notificar, notificar), sincronizar y volátil, y explicar en detalle cada herramienta de concurrencia y mecanismo de implementación subyacente proporcionado por JDK. Sobre esta base, analizaremos más a fondo las clases de herramientas del paquete java.util.concurrent, incluido su uso, implementación del código fuente y los principios detrás de él. Este artículo es el primer artículo de esta serie y es la parte teórica más central de esta serie. Los artículos posteriores se analizarán y explicarán en base a esto.
1. Compartir
El intercambio de datos es una de las principales razones para la seguridad de los subprocesos. Si todos los datos solo son válidos en el hilo, no hay un problema de seguridad del hilo, que es una de las principales razones por las que a menudo no necesitamos considerar la seguridad del subproceso al programar. Sin embargo, en la programación multiproceso, el intercambio de datos es inevitable. El escenario más típico son los datos en la base de datos. Para garantizar la consistencia de los datos, generalmente necesitamos compartir los datos en la misma base de datos. Incluso en el caso de maestro y esclavo, se accede a los mismos datos. El maestro y el esclavo solo están copiando los mismos datos para la eficiencia del acceso y la seguridad de los datos. Ahora demostramos los problemas causados por compartir datos en múltiples hilos a través de un ejemplo simple:
Fragmento de código 1:
paquete com.paddx.test.concurrent; clase pública compartida sharedata {public static int count = 0; public static void main (string [] args) {final sharedata data = new Sharedata (); for (int i = 0; i <10; i ++) {new Thread (new runnable () {@Override public void run () {try {// pausa por 1 miliseCond cuando se ingresa para aumentar la posibilidad de problemas de concurrencia Thread.seLep (1);} Catch (interruptedException e) {e.PrintStackTrace ();} para (int j = 0; 0; J+J ++) data.addCount (); } try {// El programa principal se detiene durante 3 segundos para garantizar que la ejecución del programa anterior se complete Thread.sleep (3000); } catch (InterruptedException e) {E.PrintStackTrace (); } System.out.println ("count =" + count); } public void addCount () {count ++; }}El propósito del código anterior es agregar una operación para contar y ejecutar 1,000 veces, pero aquí se implementa a través de 10 subprocesos, cada subproceso se ejecuta 100 veces y, en circunstancias normales, se deben emitir 1,000. Sin embargo, si ejecuta el programa anterior, encontrará que el resultado no es el caso. Aquí está el resultado de la ejecución de un cierto tiempo (los resultados de cada ejecución pueden no ser los mismos, y a veces se puede obtener el resultado correcto):
Se puede ver que para las operaciones variables compartidas, varios resultados inesperados se ven fácilmente en un entorno múltiple.
2. Exclusión mutua
La exclusión mutua de recursos significa que solo un visitante puede acceder a él al mismo tiempo, lo cual es único y exclusivo. Por lo general, permitimos que varios subprocesos lean datos al mismo tiempo, pero solo un hilo puede escribir datos al mismo tiempo. Por lo tanto, generalmente dividimos las cerraduras en cerraduras compartidas y cerraduras exclusivas, también llamados cerraduras de lectura y cerraduras de escritura. Si los recursos no son mutuamente excluyentes, no necesitamos preocuparnos por la seguridad de los hilos, incluso si son recursos compartidos. Por ejemplo, para el intercambio de datos inmutable, todos los subprocesos solo pueden leerlo, por lo que los problemas de seguridad de los subprocesos no son necesarios. Sin embargo, las operaciones de escritura para datos compartidos generalmente requieren exclusión mutua. En el ejemplo anterior, los problemas de modificación de datos ocurren debido a la falta de exclusión mutua. Java proporciona múltiples mecanismos para garantizar la exclusión mutua, la forma más fácil es usar sincronizado. Ahora agregamos sincronizado al programa anterior y ejecutamos:
Fragmento de código dos:
paquete com.paddx.test.concurrent; clase pública compartida sharedata {public static int count = 0; public static void main (string [] args) {final sharedata data = new Sharedata (); for (int i = 0; i <10; i ++) {new Thread (new runnable () {@Override public void run () {try {// pausa por 1 miliseCond cuando se ingresa para aumentar la posibilidad de problemas de concurrencia Thread.seLep (1);} Catch (interruptedException e) {e.PrintStackTrace ();} para (int j = 0; 0; J+J ++) data.addCount (); } try {// El programa principal se detiene durante 3 segundos para garantizar que la ejecución del programa anterior se complete Thread.sleep (3000); } catch (InterruptedException e) {E.PrintStackTrace (); } System.out.println ("count =" + count); } / *** Agregar palabra clave sincronizada* / public sincronizado void addCount () {count ++; }}Ahora que se ejecuta el código anterior, encontrará que no importa cuántas veces ejecute, el resultado final será de 1000.
Iii. Atomicidad
La atomicidad se refiere a la operación de datos como un todo independiente e indivisible. En otras palabras, es una operación continua e ininterrumpida. La mitad de la ejecución de datos no es modificada por otros hilos. La forma más fácil de garantizar la atomicidad son las instrucciones del sistema operativo, es decir, si una operación corresponde a una instrucción del sistema operativo a la vez, definitivamente garantizará la atomicidad. Sin embargo, muchas operaciones no se pueden completar con una instrucción. Por ejemplo, para las operaciones de tipo largo, muchos sistemas deben dividirse en múltiples instrucciones para operar en las posiciones altas y bajas, respectivamente. Por ejemplo, la operación del entero i ++ que a menudo usamos realmente debe dividirse en tres pasos: (1) leer el valor del entero i; (2) Agregue una operación a I; (3) Escriba el resultado de nuevo a la memoria. Este proceso puede ocurrir en la lectura múltiple:
Esta es también la razón por la cual el resultado de la ejecución del segmento de código es incorrecto. Para esta operación de combinación, la forma más común de garantizar que se bloquee la atomicidad, como sincronizada o bloquear en Java, y el segmento de código 2 se implementa a través de sincronizado. Además de las cerraduras, hay otra forma de CAS (comparar y intercambiar), es decir, antes de modificar los datos, comparar si los valores leídos antes de los anteriores son consistentes. Si son consistentes, modifíquelos, y si son inconsistentes, serán ejecutados nuevamente. Este es también el principio de optimizar la implementación de bloqueo. Sin embargo, CAS puede no ser efectivo en algunos escenarios. Por ejemplo, otro hilo primero modifica un cierto valor y luego lo cambia al valor original. En este caso, CAS no puede juzgar.
4. Visibilidad
Para comprender la visibilidad, debe tener una cierta comprensión del modelo de memoria del JVM. El modelo de memoria del JVM es similar al sistema operativo, como se muestra en la figura:
A partir de esta figura, podemos ver que cada hilo tiene su propia memoria de trabajo (equivalente al búfer avanzado CPU. El propósito de esto es reducir aún más la diferencia de velocidad entre el sistema de almacenamiento y la CPU y mejorar el rendimiento). Para las variables compartidas, cada vez que el hilo lee una copia de la variable compartida en la memoria de trabajo. Al escribir, modifica directamente el valor de la copia en la memoria de trabajo y luego sincroniza la memoria de trabajo con el valor en la memoria principal en un momento determinado en el tiempo. El problema que causa esto es que si el hilo 1 modifica una cierta variable, el hilo 2 puede no ver las modificaciones realizadas por el hilo 1 a la variable compartida. A través del siguiente programa, podemos demostrar el problema invisible:
paquete com.paddx.test.concurrent; public class VisibilityTest {private static boolean listo; Número de inticates de intivides privados; Private static class ReaderThread extiende el hilo {public void run () {try {Thread.sleep (10); } catch (InterruptedException e) {E.PrintStackTrace (); } if (! Ready) {System.out.println (Ready); } System.out.println (número); }} Clase estática privada Escritor de escritura extiende el hilo {public void run () {try {thread.sleep (10); } catch (InterruptedException e) {E.PrintStackTrace (); } número = 100; Ready = True; }} public static void main (string [] args) {new WriterThread (). Start (); nuevo ReaderThread (). Start (); }}Intuitivamente, este programa solo debe emitir 100, y el valor listo no se imprimirá. De hecho, si ejecuta el código anterior varias veces, puede haber muchos resultados diferentes. Aquí están los resultados de unas dos corridas:
Por supuesto, este resultado solo se puede decir que es posible debido a la visibilidad. Cuando el hilo de escritura (WriterThread) se establece listo = True, el ReaderThread no puede ver el resultado modificado, por lo que se imprimirá False. Para el segundo resultado, es decir, el resultado del hilo de escritura no se ha leído al ejecutar if (! Ready), pero el resultado de la ejecución del hilo de escritura se lee al ejecutar System.out.println (listo). Sin embargo, este resultado también puede ser causado por la ejecución alternativa de hilos. La visibilidad se puede garantizar a través de sincronizado o volátil en Java, y los detalles específicos se analizarán en artículos posteriores.
5. Secuencia
Para mejorar el rendimiento, el compilador y el procesador pueden reordenar las instrucciones. Hay tres tipos de reordenamiento:
(1) Reordening optimizado del compilador. El compilador puede reprogramar la orden de ejecución de las declaraciones sin cambiar la semántica de un programa único.
(2) Reordenamiento del paralelismo a nivel de instrucción. Los procesadores modernos utilizan tecnología paralela a nivel de instrucción (ICP) para superponer la ejecución de múltiples instrucciones. Si no hay dependencia de datos, el procesador puede cambiar la orden de ejecución de la declaración correspondiente a las instrucciones de la máquina.
(3) Reordenamiento del sistema de memoria. Dado que el procesador usa búferes de caché y lectura/escritura, esto hace que las operaciones de carga y almacenamiento parezcan ejecutarse fuera de servicio.
Podemos referirnos directamente a la descripción de los problemas de reordenamiento en JSR 133:
(1) (2)
Primero veamos la parte del código fuente (1) en la imagen de arriba. Desde el código fuente, la instrucción 1 se ejecuta primero o la instrucción 3 se ejecuta primero. Si la instrucción 1 se ejecuta primero, R2 no debe ver el valor escrito en la instrucción 4. Si la instrucción 3 se ejecuta primero, R1 no debe ver el valor escrito por la instrucción 2. Sin embargo, el resultado de ejecución puede tener R2 == 2 y R1 == 1, que es el resultado de "reordenar". La figura anterior (2) es un posible resultado de compilación legal. Después de la compilación, el orden de la instrucción 1 y la instrucción 2 pueden intercambiarse. Por lo tanto, aparecerá el resultado de R2 == 2 y R1 == 1. Sincronizado o volátil también se puede usar en Java para garantizar el orden.
Seis resumen
Este artículo explica la base teórica de la programación concurrente de Java, y algunas cosas se discutirán con más detalle en el análisis posterior, como visibilidad, orden, etc. Se discutirán artículos posteriores en función del contenido de este capítulo. Si puede comprender bien el contenido anterior, creo que será de gran ayuda para usted si es para comprender otros artículos de programación concurrentes o en su trabajo de programación concurrente diario.