Aproveche el poder de la simultaneidad de Java para aplicaciones eficientes y escalables. Maximice el rendimiento y la capacidad de respuesta con programación simultánea.
Aproveche el poder de la computación paralela y los subprocesos múltiples para potenciar sus aplicaciones con simultaneidad de Java. Principiante o experto, esta guía catapultará sus habilidades al vertiginoso ámbito de la programación concurrente. ¡Abróchense los cinturones y descubramos las sólidas API de Java para un viaje fascinante al reino de la codificación de alto rendimiento!
¿Qué es la concurrencia?
La concurrencia es utilizada por todas las principales empresas de desarrollo de Java y se refiere a la capacidad de un programa para realizar múltiples tareas simultáneamente. Permite la utilización eficiente de los recursos del sistema y puede mejorar el rendimiento general y la capacidad de respuesta de la aplicación.
Los conceptos, clases e interfaces de concurrencia de Java utilizados para subprocesos múltiples, como `Thread`, `Runnable`, `Callable`, `Future`, `ExecutorService` y las clases en `java.util.concurrent`, son parte de las bibliotecas. Java estándar, por lo que no debería haber mucha diferencia entre los distintos marcos de Java. Pero antes de entrar en el meollo de la cuestión, primero una pregunta muy básica.
¿Varios hilos en Java?
Multithreading se refiere a una técnica de programación en la que existen múltiples hilos de ejecución dentro de una sola aplicación.
El multiproceso es sólo una forma de lograr la concurrencia en Java. La simultaneidad también se puede lograr por otros medios, como el multiprocesamiento, la programación asincrónica o la programación basada en eventos.
Pero sólo para los no iniciados, un 'hilo' es un flujo único de procesos que el procesador de una computadora puede ejecutar de forma independiente.
¿Por qué debería utilizar la concurrencia de Java?
La concurrencia es una gran solución para crear aplicaciones modernas de alto rendimiento por varias razones.
Desempeño mejorado
La concurrencia permite dividir tareas complejas y que requieren mucho tiempo en partes más pequeñas que se pueden ejecutar simultáneamente, lo que resulta en un mejor rendimiento. Esto aprovecha al máximo las CPU multinúcleo actuales y puede hacer que las aplicaciones se ejecuten mucho más rápido.
Mejor utilización de recursos
La concurrencia permite una utilización óptima de los recursos del sistema, lo que resulta en una mayor eficiencia de los recursos. Al implementar operaciones de E/S asincrónicas, el sistema puede evitar bloquear un solo subproceso y permitir que otras tareas se ejecuten simultáneamente, maximizando así la utilización de recursos y la eficiencia del sistema.
Capacidad de respuesta mejorada
Capacidad de respuesta mejorada: la concurrencia puede mejorar la experiencia del usuario en aplicaciones interactivas al garantizar que la aplicación siga respondiendo. Mientras un subproceso ejecuta una tarea computacionalmente intensiva, otro subproceso puede manejar simultáneamente entradas de usuario o actualizaciones de la interfaz de usuario.
Modelado simplificado
En ciertos escenarios, como simulaciones o motores de juegos, las entidades concurrentes son inherentes al dominio del problema y, por lo tanto, un enfoque de programación concurrente es más intuitivo y eficaz. Esto comúnmente se denomina modelado simplificado.
API de simultaneidad robusta
Java ofrece una API de concurrencia integral y adaptable que incluye grupos de subprocesos, colecciones concurrentes y variables atómicas para garantizar la solidez. Estas herramientas de concurrencia simplifican el desarrollo de código concurrente y mitigan los problemas de concurrencia frecuentes.
Desventajas de la concurrencia
Es importante comprender que la programación concurrente no es para principiantes. Aporta un mayor nivel de complejidad a sus aplicaciones e implica un conjunto distinto de dificultades, como gestionar la sincronización, evitar conflictos y garantizar la seguridad de los subprocesos, y eso no es todo. Aquí hay algunas cosas a considerar antes de dar el paso.
Complejidad: escribir programas simultáneos puede ser más difícil y llevar más tiempo que escribir programas de un solo subproceso. Es esencial que los desarrolladores comprendan la sincronización, la visibilidad de la memoria, las operaciones atómicas y la comunicación de subprocesos.
Dificultades de depuración: la naturaleza no determinista de los programas concurrentes puede plantear un desafío durante la depuración. La aparición de condiciones de carrera o puntos muertos puede ser inconsistente, lo que plantea un desafío para reproducirlos y resolverlos.
Potencial de error: el manejo inadecuado de la concurrencia puede provocar errores como condiciones de carrera, interbloqueos e interferencia de subprocesos. Este problema puede plantear dificultades en su identificación y resolución.
Contención de recursos: las aplicaciones simultáneas mal diseñadas pueden causar contención de recursos, en la que muchos subprocesos luchan por el mismo recurso, lo que resulta en una pérdida de rendimiento.
La sobrecarga: la creación y el mantenimiento de subprocesos aumentan el uso de CPU y memoria en su máquina. Una gestión insuficiente puede dar lugar a un rendimiento subóptimo o al agotamiento de los recursos.
Complicado de probar: debido a que la ejecución de subprocesos es impredecible y no determinista, probar programas simultáneos puede ser un desafío.
Entonces, si bien la concurrencia es una excelente opción, no todo es fácil.
Tutorial de concurrencia de Java: creación de subprocesos
Los hilos se pueden crear de tres maneras. Aquí crearemos el mismo hilo usando diferentes métodos.
Heredando de la clase Thread
Una forma de crear un hilo es heredarlo de la clase hilo. Entonces todo lo que necesitas hacer es anular el método de ejecución del objeto de hilo. El método de ejecución se invocará cuando se inicie el hilo.
clase pública EjemploThread extiende Thread { @Anular ejecución pública vacía { // contiene todo el código que deseas ejecutar // cuando comienza el hilo //imprime el nombre del hilo // que está ejecutando el proceso System.out.println(Thread.currentThread .getName); } }
Para iniciar un nuevo hilo, creamos una instancia de la clase anterior y llamamos al método de inicio en ella.
ejemplos de subprocesos de clase pública { público estático vacío principal (argumentos de cadena) { Hilo de ejemplo = nuevo hilo de ejemplo; hilo.inicio; } }
Un error común es llamar al método de ejecución para iniciar el hilo. Puede parecer correcto ya que todo funciona bien, pero llamar al método de ejecución no inicia un nuevo hilo. En cambio, ejecuta el código del hilo dentro del hilo principal. Usamos el método de inicio para ejecutar un nuevo hilo.
Puedes probar esto llamando a "thread.run" en lugar de "thread.start" en el código anterior. Verás que "principal" está impreso en la consola, lo que significa que no estamos creando ningún hilo. En cambio, la tarea se ejecuta en el hilo principal. Para obtener más información sobre la clase de hilo, asegúrese de consultar los documentos .
Implementación de interfaz ejecutable
Otra forma de crear un hilo es implementando la interfaz Runnable. De manera similar al método anterior, debe anular el método de ejecución, que contendrá todas las tareas que desea que realice el hilo ejecutable.
clase pública EjemploRunnable implementa Runnable { @Anular ejecución pública vacía { System.out.println(Thread.currentThread .getName); } } ejemplos de subprocesos de clase pública { público estático vacío principal (argumentos de cadena) { EjemploRunnable ejecutable = nuevo EjemploRunnable; Hilo hilo = nuevo hilo (ejecutable); hilo.inicio; } }
Ambos métodos funcionan exactamente igual sin diferencia en el rendimiento. Sin embargo, la interfaz Runnable deja la opción de extender la clase con alguna otra clase, ya que solo puedes heredar una clase en Java. También es más fácil crear un grupo de subprocesos utilizando ejecutables.
Usando declaraciones anónimas
Este método es muy similar al método anterior. Pero en lugar de crear una nueva clase que implemente el método ejecutable, crea una función anónima que contiene la tarea que desea realizar.
clase pública principal { público estático vacío principal (argumentos de cadena) { Hilo hilo = nuevo hilo ( -> { //tarea que deseas ejecutar System.out.println(Thread.currentThread .getName); }); hilo.inicio; } }
Métodos de hilo
Si llamamos al método threadOne.join dentro de threadTwo, pondrá a threadTwo en estado de espera hasta que threadOne termine de ejecutarse.
Llamar al método estático Thread.sleep(long timeInMilliSeconds) pondrá el hilo actual en un estado de espera cronometrado.
Ciclo de vida del hilo
Un hilo puede estar en uno de los siguientes estados. Utilice Thread.getState para obtener el estado actual del hilo.
- NUEVO: creado pero no iniciado
- RUNNABLE: ejecución iniciada
- BLOQUEADO: esperando adquirir un candado
- ESPERANDO: esperando que algún otro hilo realice una tarea
- TIMED_WAITING: esperando un período de tiempo específico
- TERMINADO: ejecución completada o abortada
Ejecutores y grupos de subprocesos
Los subprocesos requieren algunos recursos para iniciarse y se detienen una vez completada la tarea. Para aplicaciones con muchas tareas, querrás poner tareas en cola en lugar de crear más subprocesos. ¿No sería fantástico si de alguna manera pudiéramos reutilizar los hilos existentes y al mismo tiempo limitar el número de hilos que puedes crear?
La clase ExecutorService nos permite crear una cierta cantidad de subprocesos y distribuir tareas entre los subprocesos. Dado que está creando una cantidad fija de subprocesos, tiene mucho control sobre el rendimiento de su aplicación.
importar java.util.concurrent.ExecutorService; importar java.util.concurrent.Executors; clase pública principal { público estático vacío principal (argumentos de cadena) { ExecutorService ejecutor = Executors.newFixedThreadPool(2); para (int i = 0; i < 20; i++) { int finalI = i; executor.submit( -> System.out.println(Thread.currentThread .getName + " está ejecutando la tarea " + finalI)); } ejecutor.apagado; } }
Condiciones de carrera
Una condición de carrera es una condición de un programa en la que su comportamiento depende de la sincronización relativa o del entrelazado de múltiples subprocesos o procesos. Para comprender mejor esto, veamos el siguiente ejemplo.
Incremento de clase pública { recuento de int privado = 0; incremento de vacío público { contar += 1; } público int getCount { devolver este.count; } } clase pública Ejemplo de condiciones de carrera { público estático vacío principal (argumentos de cadena) { Incremento, por ejemplo = nuevo incremento; para (int i = 0; i < 1000; i++) { Hilo hilo = nuevo hilo (por ejemplo::incremento); hilo.inicio; } System.out.println(por ejemplo,getCount); } }
Aquí tenemos una clase Increment que almacena un recuento variable y una función que incrementa el recuento. En RaceConditionsExample, estamos iniciando mil subprocesos, cada uno de los cuales invocará el método de incremento. Finalmente, estamos esperando a que todos los subprocesos terminen de ejecutarse y luego imprimamos el valor de la variable de conteo.
Si ejecuta el código varias veces, notará que a veces el valor final del recuento es inferior a 1000. Para entender por qué sucede esto, tomemos dos subprocesos, Thread-x y Thread-y, como ejemplos. Los subprocesos pueden realizar operaciones de lectura y escritura en cualquier orden. Por tanto, habrá un caso en el que el orden de ejecución será el siguiente.
Thread-x: Lee this.count (que es 0) Thread-y: Lee this.count (que es 0) Thread-x: Incrementa this.count en 1 Thread-y: Incrementa this.count en 1 Thread-x: Actualiza this.count (que se convierte en 1) Thread-y: Actualiza this.count (que se convierte en 1)
En este caso, el valor final de la variable de conteo es 1 y no 2. Esto se debe a que ambos subprocesos leen la variable de conteo antes de que cualquiera de ellos pueda actualizar el valor. Esto se conoce como condición de carrera. Más específicamente, una condición de carrera de “lectura-modificación-escritura”.
Estrategias de sincronización
En la sección anterior, vimos cuáles son las condiciones de carrera. Para evitar condiciones de carrera, necesitamos sincronizar las tareas. En esta sección, veremos diferentes formas de sincronizar diferentes procesos en múltiples subprocesos.
Para bloquear
Habrá casos en los que desee que una tarea sea ejecutada por un solo subproceso a la vez. Pero, ¿cómo se puede garantizar que una tarea sea ejecutada por un solo subproceso?
Una forma de hacerlo es mediante el uso de candados. La idea es crear un objeto de bloqueo que pueda ser "adquirido" por un solo hilo a la vez. Antes de ejecutar una tarea, el hilo intenta adquirir el bloqueo. Si logra hacerlo, continúa con la tarea. Después de completar la tarea, libera el bloqueo. Si el hilo no puede adquirir el bloqueo, significa que la tarea la está realizando otro hilo.
A continuación se muestra un ejemplo que utiliza la clase ReentrantLock, que es una implementación de la interfaz de bloqueo.
importar java.util.concurrent.locks.ReentrantLock; clase pública LockExample { bloqueo ReentrantLock final privado = nuevo ReentrantLock; recuento de int privado = 0; incremento int público { bloquear.bloquear; intentar { devolver this.count++; } finalmente { bloqueo y desbloqueo; } } }
Cuando llamamos al método de bloqueo en un hilo, intenta adquirir el bloqueo. Si tiene éxito, ejecuta la tarea. Sin embargo, si no tiene éxito, el hilo se bloquea hasta que se libera el bloqueo.
isLocked devuelve un valor booleano dependiendo de si el bloqueo se puede adquirir o no.
El método tryLock intenta adquirir el bloqueo sin bloqueo. Devuelve verdadero si tiene éxito y falso en caso contrario.
El método de desbloqueo libera el bloqueo.
Bloqueo de lectura y escritura
Cuando trabaja con datos y recursos compartidos, normalmente desea dos cosas:
- Varios subprocesos deben poder leer el recurso a la vez si no se está escribiendo.
- Sólo un hilo puede escribir en el recurso compartido a la vez si ningún otro hilo está leyendo o escribiendo.
La interfaz ReadWriteLock logra esto mediante el uso de dos bloqueos en lugar de uno. El bloqueo de lectura puede ser adquirido por varios subprocesos al mismo tiempo si ningún subproceso ha adquirido el bloqueo de escritura. El bloqueo de escritura solo se puede adquirir si no se han adquirido los bloqueos de lectura y escritura.
Aquí hay un ejemplo para demostrarlo. Supongamos que tenemos una clase SharedCache que simplemente almacena pares clave-valor como se muestra a continuación.
clase pública SharedCache { mapa privado<String, String> caché = nuevo HashMap<>; cadena pública readData (clave de cadena) { devolver cache.get(clave); } writeData público vacío (clave de cadena, valor de cadena) { cache.put(clave, valor); } }
Queremos que varios subprocesos lean nuestro caché al mismo tiempo (mientras no se escribe en él). Pero sólo un hilo puede escribir nuestro caché a la vez. Para lograr esto, usaremos ReentrantReadWriteLock, que es una implementación de la interfaz ReadWriteLock.
clase pública SharedCache { mapa privado<String, String> caché = nuevo HashMap<>; bloqueo privado ReentrantReadWriteLock = nuevo ReentrantReadWriteLock; cadena pública readData (clave de cadena) { bloquear.readLock.lock; intentar { devolver cache.get(clave); } finalmente { bloquear.readLock.desbloquear; } } writeData público vacío (clave de cadena, valor de cadena) { bloqueo.writeLock.lock; intentar { cache.put(clave, valor); } finalmente { bloquear.writeLock.desbloquear; } } }
Bloques y métodos sincronizados.
Los bloques sincronizados son piezas de código Java que solo pueden ser ejecutadas por un subproceso a la vez. Son una forma sencilla de implementar la sincronización entre subprocesos.
//SINTAXIS sincronizado (Objeto referencia_objeto) { // código que deseas sincronizar }
Al crear un bloque sincronizado, debe pasar un objeto de referencia. En el ejemplo anterior, "este" o el objeto actual es el objeto de referencia, lo que significa que si se crean varias instancias de, no se sincronizarán.
También puede sincronizar un método utilizando la palabra clave sincronizada.
incremento int sincronizado público;
Calles sin salida
El punto muerto ocurre cuando dos o más subprocesos no pueden continuar porque cada uno de ellos está esperando que el otro libere un recurso o realice una acción específica. Como resultado, permanecen estancados indefinidamente, incapaces de progresar.
Considere esto: tiene dos subprocesos y dos bloqueos (llamémoslos subprocesoA, subprocesoB, bloqueoA y bloqueoB). ThreadA intentará adquirir lockA primero y, si tiene éxito, intentará adquirir lockB. ThreadB, por otro lado, intenta adquirir lockB primero y luego lockA.
importar java.util.concurrent.locks.ReentrantLock; clase pública principal { público estático vacío principal (argumentos de cadena) { ReentrantLock lockA = nuevo ReentrantLock; ReentrantLock lockB = nuevo ReentrantLock; Hilo hiloA = nuevo hilo( -> { lockA.lock; intentar { System.out.println("Thread-A ha adquirido Lock-A"); lockB.bloqueo; intentar { System.out.println("El hilo A ha adquirido el Bloqueo B"); } finalmente { bloquearB.desbloquear; } } finalmente { bloquearA.desbloquear; } }); Hilo hiloB = nuevo hilo( -> { lockB.bloqueo; intentar { System.out.println("Thread-B ha adquirido Lock-B"); lockA.lock; intentar { System.out.println("Thread-B ha adquirido Lock-A"); } finalmente { bloquearA.desbloquear; } } finalmente { bloquearB.desbloquear; } }); hiloA.start; hiloB.start; } }
Aquí, ThreadA adquiere lockA y espera lockB. ThreadB ha adquirido lockB y está esperando adquirir lockA. Aquí el hiloA nunca adquirirá el bloqueoB ya que lo mantiene el hiloB. Del mismo modo, el hiloB nunca puede adquirir el bloqueoA ya que lo mantiene el hiloA. Este tipo de situación se llama impasse.
Aquí hay algunos puntos que debe tener en cuenta para evitar puntos muertos.
- Establece un orden estricto en el que se deben adquirir los recursos. Todos los hilos deben seguir el mismo orden al solicitar recursos.
- Evite anidar cerraduras o bloques sincronizados. La causa del punto muerto en el ejemplo anterior fue que los subprocesos no pudieron liberar un bloqueo sin adquirir el otro.
- Asegúrese de que los subprocesos no adquieran varios recursos simultáneamente. Si un hilo contiene un recurso y necesita otro, debe liberar el primer recurso antes de intentar adquirir el segundo. Esto evita dependencias circulares y reduce la probabilidad de conflictos.
- Establezca tiempos de espera al adquirir bloqueos o recursos. Si un subproceso no logra adquirir un bloqueo dentro de un tiempo específico, libera todos los bloqueos adquiridos y vuelve a intentarlo más tarde. Esto evita una situación en la que un subproceso mantiene un bloqueo indefinidamente, lo que podría provocar un punto muerto.
Colecciones concurrentes de Java
La plataforma Java proporciona varias colecciones simultáneas en el paquete "java.util.concurrent" que están diseñadas para ser seguras para subprocesos y admitir acceso simultáneo.
Mapa de hash concurrente
ConcurrentHashMap es una alternativa segura para subprocesos a HashMap. Proporciona métodos optimizados para actualizaciones atómicas y operaciones de recuperación. Por ejemplo, los métodos putIfAbsent, remove y replace realizan operaciones de forma atómica y evitan condiciones de carrera.
Copiar en escritura Lista de matrices
Considere un escenario en el que un subproceso intenta leer o iterar sobre una lista de matrices mientras otro subproceso intenta modificarla. Esto puede crear inconsistencias en las operaciones de lectura o incluso generar una excepción ConcurrentModificationException.
CopyOnWriteArrayList resuelve este problema copiando el contenido de toda la matriz cada vez que se modifica. De esta manera, podemos iterar sobre la copia anterior mientras se modifica una nueva copia.
El mecanismo de seguridad de subprocesos de CopyOnWriteArrayList tiene un costo. Las operaciones de modificación, como agregar o eliminar elementos, son costosas porque requieren la creación de una nueva copia de la matriz subyacente. Esto hace que CopyOnWriteArrayList sea adecuado para escenarios donde las lecturas son más frecuentes que las escrituras.
Alternativas a la concurrencia en Java
Si desea crear una aplicación de alto rendimiento independiente del sistema operativo, optar por la concurrencia de Java es una excelente opción. Pero no es el único espectáculo de la ciudad. Aquí hay algunas alternativas que puede considerar.
El lenguaje de programación Go, también conocido como Golang, es un lenguaje de programación de tipo estático desarrollado por Google y ampliamente utilizado en los servicios de desarrollo de Go. Esta solución de software es elogiada por su rendimiento eficiente y optimizado, especialmente en el manejo de operaciones concurrentes. Un elemento central de su destreza en concurrencia es el uso de gorutinas, subprocesos livianos administrados por el tiempo de ejecución de Go, que hacen que la programación concurrente sea simple y altamente eficiente.
Scala, un lenguaje compatible con JVM que combina a la perfección paradigmas funcionales y orientados a objetos, es la opción preferida de muchas empresas de desarrollo de Scala. Una de sus características más fuertes es la robusta biblioteca conocida como Akka. Diseñado específicamente para manejar operaciones concurrentes, Akka emplea el modelo Actor para la concurrencia, proporcionando una alternativa intuitiva y menos propensa a errores a la concurrencia tradicional basada en subprocesos.
Python, un lenguaje ampliamente utilizado en los servicios de desarrollo de Python, viene equipado con bibliotecas de programación concurrentes como asyncio, multiprocesamiento y subprocesos. Estas bibliotecas permiten a los desarrolladores gestionar la ejecución paralela y simultánea de forma eficaz. Sin embargo, es importante tener en cuenta el bloqueo global de intérprete (GIL) de Python, que puede limitar la eficiencia de los subprocesos, especialmente para tareas vinculadas a la CPU.
Erlang/OTP es un lenguaje de programación funcional diseñado específicamente para construir sistemas altamente concurrentes. OTP es un middleware que ofrece una colección de principios de diseño y bibliotecas para desarrollar dichos sistemas.
Conclusión
La concurrencia de Java abre la puerta a un mejor rendimiento de las aplicaciones mediante computación paralela y subprocesos múltiples, una característica valiosa para los desarrolladores en la era de los procesadores multinúcleo. Aprovecha API sólidas, lo que hace que la programación concurrente sea eficiente y confiable. Sin embargo, trae consigo su propio conjunto de desafíos. Los desarrolladores deben administrar los recursos compartidos con cuidado para evitar problemas como interbloqueos, condiciones de carrera o interferencias de subprocesos. La complejidad de la programación concurrente en Java puede requerir más tiempo para dominarla, pero los beneficios potenciales para el rendimiento de la aplicación hacen que el esfuerzo valga la pena.
Si disfrutó de este tutorial de concurrencia de Java, asegúrese de consultar nuestros otros recursos de Java.
- Los mejores marcos de GUI de Java
- Los mejores IDE de Java y editores de texto
- Las 10 mejores bibliotecas y herramientas de PNL de Java
- Ajuste del rendimiento de Java: 10 técnicas probadas para maximizar la velocidad de Java
- Las 7 mejores herramientas de creación de perfiles de Java para 2021