DEV Community

Gino Luraschi
Gino Luraschi

Posted on

Multi-hilos en Java

Introducción

La programación multihilo en Java permite a los desarrolladores crear aplicaciones que pueden realizar múltiples tareas simultáneamente. Cada tarea se ejecuta en su propio hilo, que es una unidad de procesamiento independiente. Los hilos permiten que una aplicación sea más receptiva, eficiente y capaz de aprovechar múltiples núcleos de CPU en sistemas modernos.

Java proporciona un sólido soporte para la programación multihilo a través de su API de concurrencia, que incluye clases y herramientas que facilitan la creación y gestión de hilos. Los hilos en Java se pueden utilizar para realizar tareas en segundo plano, procesamiento paralelo, tareas de larga duración y otros escenarios donde la concurrencia es necesaria.

Sin embargo, la programación multihilo también introduce desafíos, como la sincronización de hilos para evitar condiciones de carrera y problemas de concurrencia. La comprensión de los conceptos de sincronización, exclusión mutua y manejo seguro de recursos compartidos es fundamental en la programación multihilo en Java.

Hilos en Java

En Java, un "thread" (o hilo) es una unidad básica de ejecución que forma parte de un proceso más grande. Cada programa de Java se ejecuta en al menos un hilo, que generalmente se denomina "hilo principal" o "main thread". Los hilos permiten que una aplicación realice múltiples tareas de manera simultánea o en paralelo, lo que mejora la eficiencia y la capacidad de respuesta de la aplicación. Aquí hay algunos puntos clave sobre los hilos en Java:

Hilo Principal (Main Thread): Cuando ejecutas una aplicación de Java, se crea automáticamente un hilo principal que inicia la ejecución del programa. El método main de la clase principal se ejecuta en este hilo.

Creación de Hilos: Puedes crear hilos adicionales en Java de varias maneras, pero una de las más comunes es extender la clase Thread o implementar la interfaz Runnable. Los hilos creados de esta manera pueden realizar tareas en segundo plano y ejecutar código en paralelo.

Paralelismo y Concurrencia: Los hilos pueden usarse para lograr el paralelismo (donde las tareas se ejecutan verdaderamente simultáneamente en múltiples núcleos de CPU) o para la concurrencia (donde las tareas se alternan en la CPU para simular la simultaneidad). Java proporciona una API de concurrencia rica que facilita la administración de hilos y la sincronización de acceso a recursos compartidos.

Sincronización: La programación multihilo puede llevar a problemas de sincronización, como condiciones de carrera o bloqueos. Java ofrece mecanismos de sincronización, como synchronized y objetos bloqueo (Lock), para garantizar el acceso seguro a recursos compartidos entre hilos.

Beneficios de los Hilos: Los hilos se utilizan para mejorar la capacidad de respuesta de las aplicaciones y aprovechar la capacidad de procesamiento multicore de las CPU modernas. Se utilizan en aplicaciones que requieren ejecución simultánea de tareas, como aplicaciones gráficas, servidores web, aplicaciones de red y mucho más.

Los hilos en Java son unidades de ejecución que permiten que las aplicaciones realicen tareas concurrentes y paralelas. El uso adecuado de hilos puede mejorar el rendimiento y la eficiencia de las aplicaciones, pero también plantea desafíos de concurrencia que deben abordarse con cuidado. Java proporciona herramientas y bibliotecas para trabajar de manera segura y eficaz con hilos.

Herramientas en Java para la programación multihilo

Threads

Para la implementación de threads Java nos provee una suite de clases como por ejemplo la interfaz Runnable para crear elementos que van a correr en hilos, y justamente la clase thread que ejecuta los hilos.

Para este ejemplo pensemos el caso de un navegador que en el main principal carga una página, y a través de 2 hilos renderiza y descarga las dependencias. Para crear y ejecutar 2 hilos, nuestras clases tienen que extender de la interfaz runnable, y las mismas se tienen que instanciar (o poner una instancia) dentro de la creación de nuestro thread.

package multithreads;

public class App{

    public static void main(String[] args) {
        //...
        System.out.println("Loading web page..."); 
        Thread renderApp = new Thread(new RenderPage()); // Declaramos nuestro hilo para renderizar la página
        Thread downloadDependencies = new Thread(new DownloadDependencies()); // Declaramos el hilo para descargar las dependencias

        // Ejecutamos en paralelo
        renderApp.start();
        downloadDependencies.start();

        System.out.println("Finishing web page loading...");
    }
}
// RenderPage.java
package multithreads;

public class RenderPage implements Runnable{ // Cuando implementamos un runnable tenemos que implementar el método run() 

    @Override
    public void run() {
        System.out.println("Rendering app");
    }
}
// DownloadDependencies.java
package multithreads;

public class DownloadDependencies implements Runnable{ // Cuando implementamos un runnable tenemos que implementar el método run()
    @Override
    public void run() {
        System.out.println("Download dependencies....");
    }
}
Enter fullscreen mode Exit fullscreen mode

Synchronized

La palabra clave synchronized se utiliza para crear bloques de código o métodos que están sincronizados o bloqueados, lo que significa que solo un hilo (thread) puede acceder a ese código o método a la vez. Esta característica se utiliza en programación concurrente para evitar problemas de concurrencia, como condiciones de carrera y accesos simultáneos a datos compartidos, que pueden llevar a comportamientos inesperados y errores en aplicaciones multi-hilo.

Cuando marcas un bloque de código o un método como synchronized, estás indicando que solo un hilo a la vez puede ejecutar este bloque o método, lo que garantiza la exclusión mutua. Esto es útil en situaciones en las que varios hilos pueden intentar acceder o modificar datos compartidos al mismo tiempo. El uso de synchronized ayuda a prevenir problemas de concurrencia, ya que otros hilos deben esperar su turno para acceder a la sección crítica del código.

En el siguiente ejemplo vemos como tenemos un carrito de compras al que podemos agregarles productos en paralelo, suponiendo que desde 2 clientes queremos agregar productos, para evitar una condición de carrera con la lista de productos hacemos que el método para agregar productos sea síncrono:

package multithreads.syncronized;

public class App {

    public static void main(String[] args) {
        final Cart cart = new Cart();

        // Creamos dos hilos que intentarán agregar elementos a la lista simultáneamente
        Thread thread1 = new Thread(() -> {
            cart.add("Prod 1");
        });

        Thread thread2 = new Thread(() -> {
            cart.add("Prod 2");
        });

        // Iniciamos los hilos
        thread1.start();
        thread2.start();

        // Esperamos a que ambos hilos terminen
        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}
// Cart.java
package multithreads.syncronized;

import java.util.ArrayList;
import java.util.List;

public class Cart {
    List<String> products = new ArrayList<>();

    public synchronized void add(String prod) {
        this.products.add(prod);
    }
}
Enter fullscreen mode Exit fullscreen mode

Atomic

Los tipos atómicos en Java, como AtomicInteger, AtomicLong, y AtomicReference, se utilizan para realizar operaciones de manera atómica, lo que significa que garantizan que ninguna otra operación se puede ejecutar al mismo tiempo en el mismo dato, evitando problemas de concurrencia.

En este ejemplo veremos cómo utilizar el contador atómico para un caso similar al carrito, en este caso vemos que tenemos un item al que se puede estar llamando de forma concurrente de distintos clientes, para evitar la condición de carrera y tener de forma correcta la cantidad de ítems, utilizamos un contador atómico:

package multithreads.atomic;

public class App {

    public static void main(String[] args) {
        // Create cart
        // Add some items
        final Item item = new Item("Prod 1");

        // Creamos dos hilos que intentarán agregar elementos a la lista simultáneamente
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 100; i++) {
                item.add();
            }
        });

        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 100; i++) {
                item.add();
            }
        });

        // Iniciamos los hilos
        thread1.start();
        thread2.start();

        // Esperamos a que ambos hilos terminen
        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println(item);
    }
}
// Item.java
package multithreads.atomic;

import java.util.concurrent.atomic.AtomicInteger;

public class Item {
    private final String name;
    private final AtomicInteger quantity;

    public Item(String name) {
        this.name = name;
        quantity = new AtomicInteger();
    }

    public String getName() {
        return name;
    }

    public AtomicInteger getQuantity() {
        return quantity;
    }

    public void add() {
        this.quantity.incrementAndGet();
    }

    @Override
    public String toString() {
        return "name='" + name + '\'' +
                ", quantity=" + quantity;
    }
}

Enter fullscreen mode Exit fullscreen mode

ReentrantLock

ReentrantLock en Java es una implementación de la interfaz Lock que proporciona un mecanismo más flexible y poderoso para sincronización de hilos en comparación con synchronized blocks o métodos. A diferencia de synchronized, ReentrantLock permite la reentrada, lo que significa que un hilo que ya tiene el candado (lock) puede volver a adquirirlo sin causar un bloqueo, lo que es útil en situaciones específicas.

En este ejemplo, los dos hilos comparten una instancia de ReentrantLock para asegurarse de que solo un hilo pueda imprimir un número a la vez. La reentrada permite que el mismo hilo vuelva a adquirir el candado sin bloquear si ya lo tiene. Esto evita bloqueos y garantiza que los hilos no se bloqueen entre sí en la exclusión mutua.

Para este ejemplo, vamos a agregar 10 productos en 2 hilos distintos, al imprimir veremos que los productos se van agregando en un distinto orden al preestablecido:

package multithreads.reetrablocks;

public class App {
    public static void main(String[] args) {
        Cart cart = new Cart();

        // Hilos concurrentes intentan imprimir números del 1 al 10 de manera segura
        Thread thread1 = new Thread(() -> {
            for (int i = 1; i <= 10; i++) {
                cart.add("Thread 1 - prod " + i);
            }
        });

        Thread thread2 = new Thread(() -> {
            for (int i = 1; i <= 10; i++) {
                cart.add("Thread 2 - prod " + i);
            }
        });

        thread1.start();
        thread2.start();
    }
}
// Cart.java
package multithreads.reetrablocks;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.locks.ReentrantLock;

public class Cart {
    ReentrantLock lock = new ReentrantLock();
    List<String> products = new ArrayList<>();

    public synchronized void add(String prod) {
        lock.lock();
        try {
            System.out.println("Adding product " + prod);
            this.products.add(prod);
        } finally {
            lock.unlock();
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

Conclusión

En resumen, la programación multihilo en Java es una técnica poderosa para mejorar la eficiencia y la capacidad de respuesta de las aplicaciones al permitir la ejecución simultánea de múltiples tareas. Java proporciona las herramientas y las API necesarias para aprovechar esta funcionalidad de manera efectiva, pero es importante comprender y gestionar adecuadamente la concurrencia para evitar problemas de sincronización y errores.

Top comments (0)