El lenguaje de programación Java y la máquina virtual Java (JVM) están diseñados para soportar programación concurrente . Toda ejecución tiene lugar en el contexto de subprocesos . Los objetos y recursos pueden ser accedidos por muchos subprocesos separados. Cada subproceso tiene su propia ruta de ejecución, pero potencialmente puede acceder a cualquier objeto en el programa. El programador debe asegurarse de que el acceso de lectura y escritura a los objetos esté correctamente coordinado (o " sincronizado ") entre subprocesos. [1] [2] La sincronización de subprocesos asegura que los objetos sean modificados por un solo subproceso a la vez y evita que los subprocesos accedan a objetos parcialmente actualizados durante la modificación por otro subproceso. [2] El lenguaje Java tiene construcciones integradas para soportar esta coordinación.
La mayoría de las implementaciones de la máquina virtual Java se ejecutan como un único proceso . En el lenguaje de programación Java, la programación concurrente se ocupa principalmente de los subprocesos (también llamados procesos ligeros ). Solo se pueden implementar múltiples procesos con múltiples JVM.
Los subprocesos comparten los recursos del proceso, incluida la memoria y los archivos abiertos. Esto permite una comunicación eficiente, pero potencialmente problemática. [2] Cada aplicación tiene al menos un subproceso llamado subproceso principal. El subproceso principal tiene la capacidad de crear subprocesos adicionales como objetos Runnable
o Callable
. La Callable
interfaz es similar a Runnable
en el sentido de que ambos están diseñados para clases cuyas instancias son potencialmente ejecutadas por otro subproceso. [3] Sin embargo, un Runnable
, no devuelve un resultado y no puede lanzar una excepción comprobada. [4]
Cada subproceso se puede programar [5] en un núcleo de CPU diferente [6] o utilizar la segmentación de tiempo en un solo procesador de hardware, o la segmentación de tiempo en muchos procesadores de hardware. No existe una solución general para la forma en que los subprocesos de Java se asignan a los subprocesos del sistema operativo nativo. Cada implementación de JVM puede hacer esto de manera diferente.
Cada hilo está asociado a una instancia de la clase Thread
. Los hilos se pueden gestionar directamente mediante el uso de los Thread
objetos o indirectamente mediante el uso de mecanismos abstractos como Executor
s o Task
s. [7]
Dos formas de iniciar un hilo:
clase pública HelloRunnable implementa Runnable { @Override public void run () { System . out . println ( "¡Hola desde el hilo!" ); } public static void main ( String [] args ) { ( new Thread ( new HelloRunnable ())). start (); } }
clase pública HelloThread extiende Thread { @Override void público run () { System . out . println ( "¡Hola desde el hilo!" ); } void público estático main ( String [] args ) { ( new HelloThread ()). start (); } }
Una interrupción le dice a un hilo que debe detener lo que está haciendo y hacer otra cosa. Un hilo envía una interrupción invocando interrupt()
en el Thread
objeto para que el hilo sea interrumpido. El mecanismo de interrupción se implementa utilizando un boolean
indicador interno conocido como "estado interrumpido". [8] La invocación interrupt()
establece este indicador. [9] Por convención, cualquier método que sale lanzando un InterruptedException
borra el estado interrumpido cuando lo hace. Sin embargo, siempre es posible que el estado interrumpido se establezca de nuevo inmediatamente, por otro hilo que invoque interrupt()
.
El java.lang.Thread#join()
método permite Thread
esperar la finalización de otro.
Las excepciones no detectadas lanzadas por el código terminarán el hilo. El hilo principal imprime excepciones en la consola, pero los hilos creados por el usuario necesitan un controlador registrado para hacerlo. [10] [11]
El modelo de memoria de Java describe cómo interactúan los hilos en el lenguaje de programación Java a través de la memoria. En las plataformas modernas, el código con frecuencia no se ejecuta en el orden en que fue escrito. Es reordenado por el compilador , el procesador y el subsistema de memoria para lograr el máximo rendimiento. El lenguaje de programación Java no garantiza la linealización , o incluso la consistencia secuencial , [12] al leer o escribir campos de objetos compartidos, y esto es para permitir optimizaciones del compilador (como la asignación de registros , la eliminación de subexpresiones comunes y la eliminación de lecturas redundantes ), todas las cuales funcionan reordenando las lecturas y escrituras de la memoria. [13]
Los subprocesos se comunican principalmente compartiendo el acceso a los campos y a los objetos a los que hacen referencia los campos. Esta forma de comunicación es extremadamente eficiente, pero permite dos tipos de errores: interferencias de subprocesos y errores de consistencia de memoria. La herramienta necesaria para evitar estos errores es la sincronización.
Las reordenaciones pueden entrar en juego en programas multiproceso sincronizados incorrectamente , donde un subproceso puede observar los efectos de otros subprocesos y puede detectar que los accesos a variables se vuelven visibles para otros subprocesos en un orden diferente al ejecutado o especificado en el programa. La mayoría de las veces, a un subproceso no le importa lo que hace el otro. Pero cuando sí le importa, para eso está la sincronización.
Para sincronizar subprocesos, Java utiliza monitores , que son un mecanismo de alto nivel que permite que solo un subproceso a la vez ejecute una región de código protegida por el monitor. El comportamiento de los monitores se explica en términos de bloqueos ; hay un bloqueo asociado con cada objeto.
La sincronización tiene varios aspectos. El más conocido es la exclusión mutua : solo un subproceso puede contener un monitor a la vez, por lo que sincronizar en un monitor significa que una vez que un subproceso ingresa a un bloque sincronizado protegido por un monitor, ningún otro subproceso puede ingresar a un bloque protegido por ese monitor hasta que el primer subproceso salga del bloque sincronizado. [2]
Pero la sincronización implica mucho más que la exclusión mutua. La sincronización garantiza que las escrituras en la memoria realizadas por un subproceso antes o durante un bloque sincronizado se hagan visibles de manera predecible para otros subprocesos que se sincronizan en el mismo monitor. Después de salir de un bloque sincronizado, liberamos el monitor, lo que tiene el efecto de vaciar la memoria caché a la memoria principal, de modo que las escrituras realizadas por este subproceso puedan ser visibles para otros subprocesos. Antes de poder ingresar a un bloque sincronizado, adquirimos el monitor, lo que tiene el efecto de invalidar la memoria caché del procesador local de modo que las variables se vuelvan a cargar desde la memoria principal. Entonces podremos ver todas las escrituras que se hicieron visibles mediante la liberación anterior.
Las lecturas y escrituras en los campos son linealizables si el campo es volátil o si el campo está protegido por un bloqueo único que adquieren todos los lectores y escritores.
Un hilo puede lograr la exclusión mutua ya sea ingresando a un bloque o método sincronizado, que adquiere un bloqueo implícito, [14] [2] o adquiriendo un bloqueo explícito (como el ReentrantLock
del java.util.concurrent.locks
paquete [15] ). Ambos enfoques tienen las mismas implicaciones para el comportamiento de la memoria. Si todos los accesos a un campo en particular están protegidos por el mismo bloqueo, entonces las lecturas y escrituras en ese campo son linealizables (atómicas).
Cuando se aplica a un campo, la volatile
palabra clave Java garantiza que:
volatile
variable. Esto implica que cada subproceso que acceda a un volatile
campo leerá su valor actual antes de continuar, en lugar de (potencialmente) usar un valor almacenado en caché. (Sin embargo, no hay garantía sobre el orden relativo de las lecturas y escrituras volátiles con las lecturas y escrituras regulares, lo que significa que generalmente no es una construcción de subprocesos útil).Los volatile
campos A son linealizables. Leer un volatile
campo es como adquirir un bloqueo: la memoria de trabajo se invalida y el volatile
valor actual del campo se vuelve a leer desde la memoria. Escribir un volatile
campo es como liberar un bloqueo: el volatile
campo se vuelve a escribir inmediatamente en la memoria.
Un campo declarado como final
no puede modificarse una vez que se ha inicializado. [17] Los campos de un objeto final
se inicializan en su constructor. Mientras la this
referencia no se libere del constructor antes de que este regrese, el valor correcto de cualquier final
campo será visible para otros subprocesos sin sincronización. [18]
Desde JDK 1.2 , Java ha incluido un conjunto estándar de clases de colección, el marco de colecciones de Java
Doug Lea , quien también participó en la implementación del marco de colecciones de Java, desarrolló un paquete de concurrencia , que comprende varias primitivas de concurrencia y una gran batería de clases relacionadas con colecciones. [19] Este trabajo continuó y se actualizó como parte de JSR 166, que fue presidido por Doug Lea.
JDK 5.0 incorporó muchas novedades y aclaraciones al modelo de concurrencia de Java. Las API de concurrencia desarrolladas por JSR 166 también se incluyeron como parte de JDK por primera vez. JSR 133 brindó soporte para operaciones atómicas bien definidas en un entorno multiprocesador/multiproceso.
Tanto la versión Java SE 6 como la Java SE 7 introdujeron versiones actualizadas de las API JSR 166, así como varias API nuevas adicionales.
Nota: tras el lanzamiento de J2SE 5.0, este paquete entra en modo de mantenimiento: solo se publicarán las correcciones esenciales. El paquete java.util.concurrent de J2SE5 incluye versiones mejoradas, más eficientes y estandarizadas de los componentes principales de este paquete.