Threads y Runnable

Avanzado
Actualizado: 08/05/2025

¡Desbloquea el curso completo!

IA
Ejercicios
Certificado
Entrar

Creación y ciclo de vida de threads

La concurrencia en Java nos permite ejecutar múltiples tareas simultáneamente, aprovechando mejor los recursos del sistema. Los threads (hilos) son la unidad básica de ejecución concurrente en Java, permitiendo que un programa realice varias operaciones al mismo tiempo.

Fundamentos de los threads

Un thread en Java representa un flujo de ejecución independiente dentro de un programa. Cuando ejecutamos una aplicación Java, el JVM crea automáticamente un thread principal (main thread) que ejecuta el método main(). A partir de este thread principal, podemos crear threads adicionales para realizar tareas en paralelo.

Creación de threads en Java

Java ofrece dos formas principales para crear threads:

  • Extendiendo la clase Thread: Creamos una subclase de Thread y sobrescribimos el método run().
  • Implementando la interfaz Runnable: Implementamos la interfaz Runnable y pasamos la instancia a un objeto Thread.

Veamos la primera aproximación:

public class MiThread extends Thread {
    @Override
    public void run() {
        // Código que se ejecutará en el nuevo thread
        System.out.println("Ejecutando en thread: " + Thread.currentThread().getName());
    }
    
    public static void main(String[] args) {
        // Creamos una instancia de nuestro thread
        MiThread thread = new MiThread();
        
        // Iniciamos el thread
        thread.start();
        
        // Este código se ejecuta en el thread principal
        System.out.println("Ejecutando en thread principal: " + Thread.currentThread().getName());
    }
}

Es importante destacar que debemos llamar al método start() para iniciar un nuevo thread, no al método run() directamente. El método start() realiza la inicialización necesaria y luego llama a run() en el nuevo thread.

Estados del ciclo de vida de un thread

Un thread en Java pasa por varios estados durante su ciclo de vida:

  1. NEW: El thread ha sido creado pero aún no ha iniciado su ejecución.
  2. RUNNABLE: El thread está ejecutándose o listo para ejecutarse cuando el planificador le asigne tiempo de CPU.
  3. BLOCKED: El thread está bloqueado esperando un monitor lock.
  4. WAITING: El thread está esperando indefinidamente a que otro thread realice una acción específica.
  5. TIMED_WAITING: El thread está esperando durante un tiempo específico a que otro thread realice una acción.
  6. TERMINATED: El thread ha completado su ejecución.

Podemos consultar el estado actual de un thread mediante el método getState():

Thread thread = new MiThread();
System.out.println("Estado inicial: " + thread.getState()); // NEW

thread.start();
System.out.println("Estado después de start(): " + thread.getState()); // RUNNABLE

try {
    thread.join(); // Espera a que el thread termine
} catch (InterruptedException e) {
    e.printStackTrace();
}

System.out.println("Estado final: " + thread.getState()); // TERMINATED

Transiciones entre estados

El siguiente diagrama conceptual muestra las transiciones entre los diferentes estados de un thread:

  1. Un thread comienza en estado NEW cuando se crea.
  2. Cuando se llama a start(), pasa a estado RUNNABLE.
  3. Desde RUNNABLE puede pasar a:
  • BLOCKED: Al intentar adquirir un monitor lock que está en uso.
  • WAITING: Al llamar a wait(), join() sin timeout.
  • TIMED_WAITING: Al llamar a sleep(), wait() con timeout, join() con timeout.
  1. Desde BLOCKED, WAITING o TIMED_WAITING vuelve a RUNNABLE cuando se cumplen las condiciones de espera.
  2. Finalmente, cuando el método run() termina, pasa a TERMINATED.

Métodos importantes para el control de threads

Java proporciona varios métodos clave para controlar el ciclo de vida de los threads:

  • start(): Inicia la ejecución del thread, cambiando su estado de NEW a RUNNABLE.
  • sleep(long millis): Pausa la ejecución del thread actual durante el tiempo especificado.
  • join(): Espera a que el thread termine su ejecución.
  • yield(): Sugiere al planificador que puede ceder el tiempo de CPU a otros threads.
  • interrupt(): Interrumpe un thread que está en estado WAITING, TIMED_WAITING o BLOCKED.

Veamos un ejemplo que ilustra algunos de estos métodos:

public class CicloVidaThread {
    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(() -> {
            try {
                System.out.println("Thread iniciado");
                Thread.sleep(2000); // El thread pasa a TIMED_WAITING
                System.out.println("Thread despertado");
            } catch (InterruptedException e) {
                System.out.println("Thread interrumpido");
                return; // Termina el thread si es interrumpido
            }
            System.out.println("Thread completando su ejecución");
        });
        
        System.out.println("Estado antes de start(): " + thread.getState());
        thread.start();
        System.out.println("Estado después de start(): " + thread.getState());
        
        Thread.sleep(1000); // Damos tiempo para que el thread entre en sleep
        System.out.println("Estado durante sleep(): " + thread.getState());
        
        thread.join(); // Esperamos a que el thread termine
        System.out.println("Estado después de terminar: " + thread.getState());
    }
}

Prioridades de threads

Java permite asignar prioridades a los threads mediante el método setPriority(int). Las prioridades van de 1 (mínima) a 10 (máxima), con 5 como valor predeterminado:

Thread thread = new Thread(() -> {
    // Código del thread
});
thread.setPriority(Thread.MAX_PRIORITY); // Prioridad 10
thread.start();

Es importante tener en cuenta que las prioridades son solo sugerencias para el planificador del sistema operativo, y su implementación puede variar entre diferentes plataformas.

Threads daemon

Los threads daemon son threads de segundo plano que no impiden que la JVM termine cuando todos los threads no-daemon han finalizado. Son útiles para tareas de mantenimiento o servicios que no necesitan completarse antes de que el programa termine:

Thread daemonThread = new Thread(() -> {
    while (true) {
        try {
            Thread.sleep(1000);
            System.out.println("Daemon thread trabajando...");
        } catch (InterruptedException e) {
            break;
        }
    }
});

daemonThread.setDaemon(true); // Establecer como daemon antes de iniciar
daemonThread.start();

// El programa principal continúa
Thread.sleep(3000);
System.out.println("Programa principal terminando");
// Al terminar el thread principal, el daemon thread también terminará

Manejo de excepciones en threads

Las excepciones no capturadas en un thread provocan la terminación del thread, pero no afectan a otros threads ni al thread principal. Para manejar estas excepciones, podemos establecer un UncaughtExceptionHandler:

Thread thread = new Thread(() -> {
    throw new RuntimeException("Error en el thread");
});

thread.setUncaughtExceptionHandler((t, e) -> {
    System.out.println("Thread " + t.getName() + " falló con excepción: " + e.getMessage());
});

thread.start();

Ejemplo práctico: Monitoreo del ciclo de vida

El siguiente ejemplo muestra cómo podemos monitorear los cambios de estado de un thread durante su ciclo de vida:

public class MonitoreoThread {
    public static void main(String[] args) throws InterruptedException {
        Object lock = new Object();
        
        Thread thread = new Thread(() -> {
            try {
                // Entramos en estado RUNNABLE
                for (int i = 0; i < 3; i++) {
                    System.out.println("Iteración " + i);
                    Thread.sleep(500); // TIMED_WAITING
                }
                
                synchronized (lock) {
                    // Esperamos indefinidamente - WAITING
                    lock.wait();
                }
            } catch (InterruptedException e) {
                System.out.println("Thread interrumpido");
            }
        });
        
        // Creamos un thread observador
        Thread monitor = new Thread(() -> {
            Thread.State estadoAnterior = thread.getState();
            System.out.println("Estado inicial: " + estadoAnterior);
            
            while (thread.isAlive()) {
                Thread.State estadoActual = thread.getState();
                if (estadoActual != estadoAnterior) {
                    System.out.println("Transición de estado: " + estadoAnterior + " -> " + estadoActual);
                    estadoAnterior = estadoActual;
                }
                
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    break;
                }
            }
        });
        
        monitor.start();
        thread.start();
        
        // Damos tiempo para que el thread entre en WAITING
        Thread.sleep(2000);
        
        synchronized (lock) {
            // Notificamos al thread para que salga de WAITING
            lock.notify();
        }
        
        // Esperamos a que ambos threads terminen
        thread.join();
        monitor.join();
    }
}

Este ejemplo muestra cómo un thread pasa por diferentes estados durante su ejecución y cómo podemos observar estas transiciones. Entender el ciclo de vida de los threads es fundamental para desarrollar aplicaciones concurrentes robustas y eficientes en Java.

¿Te está gustando esta lección?

Inicia sesión para no perder tu progreso y accede a miles de tutoriales, ejercicios prácticos y nuestro asistente de IA.

Progreso guardado
Asistente IA
Ejercicios
Iniciar sesión gratis

Más de 25.000 desarrolladores ya confían en CertiDevs

Implementación de Runnable vs. extender Thread

En Java existen dos enfoques principales para crear threads: extender la clase Thread o implementar la interfaz Runnable. Aunque ambos métodos permiten ejecutar código concurrentemente, presentan diferencias significativas en términos de diseño, flexibilidad y buenas prácticas.

Enfoque 1: Extender la clase Thread

Cuando extendemos la clase Thread, creamos una subclase especializada que hereda todos los métodos y propiedades de la clase base:

public class MiThread extends Thread {
    @Override
    public void run() {
        System.out.println("Ejecutando tarea en: " + Thread.currentThread().getName());
        // Lógica de la tarea
    }
}

// Uso:
MiThread miThread = new MiThread();
miThread.start();

Este enfoque es directo y puede parecer más sencillo inicialmente, pero presenta algunas limitaciones importantes.

Enfoque 2: Implementar la interfaz Runnable

Al implementar Runnable, separamos la tarea a ejecutar de su mecanismo de ejecución:

public class MiTarea implements Runnable {
    @Override
    public void run() {
        System.out.println("Ejecutando tarea en: " + Thread.currentThread().getName());
        // Lógica de la tarea
    }
}

// Uso:
Thread thread = new Thread(new MiTarea());
thread.start();

También podemos usar expresiones lambda para crear implementaciones de Runnable de forma más concisa:

Runnable tarea = () -> {
    System.out.println("Tarea ejecutándose en: " + Thread.currentThread().getName());
    // Lógica de la tarea
};

Thread thread = new Thread(tarea);
thread.start();

Comparación detallada

Analicemos las ventajas y desventajas de cada enfoque:

1. Herencia vs. Composición

  • Extender Thread: Utiliza herencia, lo que significa que cada instancia es un thread.
  • Implementar Runnable: Utiliza composición, separando la tarea (qué hacer) del thread (cómo ejecutarla).
// Con herencia - cada instancia es un thread
class DescargarArchivo extends Thread {
    private String url;
    
    public DescargarArchivo(String url) {
        this.url = url;
    }
    
    @Override
    public void run() {
        System.out.println("Descargando desde " + url);
        // Código de descarga
    }
}

// Con composición - la tarea es independiente del thread
class TareaDescarga implements Runnable {
    private String url;
    
    public TareaDescarga(String url) {
        this.url = url;
    }
    
    @Override
    public void run() {
        System.out.println("Descargando desde " + url);
        // Código de descarga
    }
}

2. Flexibilidad de diseño

  • Extender Thread: Limita la herencia, ya que Java no permite herencia múltiple. Si nuestra clase ya extiende otra, no podemos extender Thread.
  • Implementar Runnable: Permite implementar múltiples interfaces además de Runnable, manteniendo la flexibilidad de diseño.
// Esto no es posible en Java debido a la falta de herencia múltiple
class MiServicio extends ServicioBase extends Thread { ... }

// Esto sí es posible
class MiServicio extends ServicioBase implements Runnable, AutoCloseable {
    @Override
    public void run() { ... }
    
    @Override
    public void close() { ... }
}

3. Reutilización de tareas

  • Extender Thread: Cada instancia es un thread único que solo puede iniciarse una vez.
  • Implementar Runnable: La misma instancia Runnable puede ejecutarse en múltiples threads o reutilizarse.
// Con Runnable, podemos reutilizar la misma tarea en múltiples threads
Runnable tarea = new MiTarea();

// Ejecutar la misma tarea en tres threads diferentes
Thread thread1 = new Thread(tarea);
Thread thread2 = new Thread(tarea);
Thread thread3 = new Thread(tarea);

thread1.start();
thread2.start();
thread3.start();

4. Uso de pools de threads

  • Extender Thread: No se integra bien con los pools de threads del paquete java.util.concurrent.
  • Implementar Runnable: Se integra perfectamente con ExecutorService y otros componentes de concurrencia avanzados.
// Usando ExecutorService con Runnable
ExecutorService executor = Executors.newFixedThreadPool(3);

// Podemos enviar tareas Runnable directamente
executor.submit(new MiTarea());
executor.submit(() -> System.out.println("Tarea con lambda"));

// Cerramos el executor cuando terminamos
executor.shutdown();

5. Compartir estado entre threads

  • Extender Thread: Dificulta compartir estado entre múltiples threads.
  • Implementar Runnable: Facilita compartir datos entre threads que ejecutan la misma tarea.
// Compartiendo estado con Runnable
class ContadorCompartido implements Runnable {
    private int contador = 0;
    
    @Override
    public void run() {
        synchronized(this) {
            contador++;
            System.out.println(Thread.currentThread().getName() + 
                               " incrementó contador a: " + contador);
        }
    }
}

// La misma instancia compartida entre threads
ContadorCompartido contador = new ContadorCompartido();
new Thread(contador, "Thread-1").start();
new Thread(contador, "Thread-2").start();
new Thread(contador, "Thread-3").start();

Ejemplo práctico: Simulación de descarga

Veamos un ejemplo más completo que ilustra las diferencias entre ambos enfoques. Implementaremos una simulación de descarga de archivos:

Usando Thread:

public class DescargadorThread extends Thread {
    private final String archivo;
    private final int tiempoEstimado;
    
    public DescargadorThread(String archivo, int tiempoEstimado) {
        this.archivo = archivo;
        this.tiempoEstimado = tiempoEstimado;
    }
    
    @Override
    public void run() {
        System.out.println("Iniciando descarga de " + archivo);
        try {
            // Simulamos el tiempo de descarga
            Thread.sleep(tiempoEstimado);
            System.out.println("Descarga completa: " + archivo);
        } catch (InterruptedException e) {
            System.out.println("Descarga interrumpida: " + archivo);
            Thread.currentThread().interrupt();
        }
    }
    
    public static void main(String[] args) {
        DescargadorThread d1 = new DescargadorThread("video.mp4", 3000);
        DescargadorThread d2 = new DescargadorThread("imagen.jpg", 1000);
        DescargadorThread d3 = new DescargadorThread("documento.pdf", 2000);
        
        d1.start();
        d2.start();
        d3.start();
    }
}

Usando Runnable:

public class TareaDescarga implements Runnable {
    private final String archivo;
    private final int tiempoEstimado;
    
    public TareaDescarga(String archivo, int tiempoEstimado) {
        this.archivo = archivo;
        this.tiempoEstimado = tiempoEstimado;
    }
    
    @Override
    public void run() {
        System.out.println("Iniciando descarga de " + archivo);
        try {
            // Simulamos el tiempo de descarga
            Thread.sleep(tiempoEstimado);
            System.out.println("Descarga completa: " + archivo);
        } catch (InterruptedException e) {
            System.out.println("Descarga interrumpida: " + archivo);
            Thread.currentThread().interrupt();
        }
    }
    
    public static void main(String[] args) {
        // Usando threads individuales
        Thread t1 = new Thread(new TareaDescarga("video.mp4", 3000));
        Thread t2 = new Thread(new TareaDescarga("imagen.jpg", 1000));
        Thread t3 = new Thread(new TareaDescarga("documento.pdf", 2000));
        
        t1.start();
        t2.start();
        t3.start();
        
        // Alternativa: usando un pool de threads
        ExecutorService executor = Executors.newFixedThreadPool(3);
        executor.submit(new TareaDescarga("archivo1.zip", 2500));
        executor.submit(new TareaDescarga("archivo2.zip", 1500));
        executor.submit(new TareaDescarga("archivo3.zip", 3500));
        
        // Cerramos el executor cuando terminamos
        executor.shutdown();
    }
}

Implementación con expresiones lambda y métodos referenciados

Con Java 8 y posteriores, podemos aprovechar las expresiones lambda y referencias a métodos para crear implementaciones de Runnable más concisas:

public class EjemplosModernos {
    
    public static void descargarArchivo(String nombre) {
        System.out.println("Descargando " + nombre + " en " + Thread.currentThread().getName());
        try {
            Thread.sleep(1000);
            System.out.println("Descarga completa: " + nombre);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
    
    public static void main(String[] args) {
        // Usando lambda
        Thread t1 = new Thread(() -> {
            System.out.println("Ejecutando en " + Thread.currentThread().getName());
            // Lógica de la tarea
        });
        
        // Usando referencia a método
        Thread t2 = new Thread(() -> descargarArchivo("config.xml"));
        
        // Con ExecutorService y lambdas
        ExecutorService executor = Executors.newCachedThreadPool();
        
        // Enviando múltiples tareas
        for (int i = 1; i <= 5; i++) {
            final String nombreArchivo = "archivo" + i + ".dat";
            executor.submit(() -> descargarArchivo(nombreArchivo));
        }
        
        t1.start();
        t2.start();
        
        executor.shutdown();
    }
}

Recomendaciones y mejores prácticas

Basándonos en las comparaciones anteriores, podemos establecer algunas recomendaciones:

  • Preferir Runnable sobre Thread: La implementación de Runnable ofrece mayor flexibilidad, mejor diseño orientado a objetos y se alinea con el principio de separación de responsabilidades.

  • Usar expresiones lambda para tareas simples: Cuando la lógica de la tarea es breve, las expresiones lambda proporcionan una sintaxis concisa.

  • Considerar ExecutorService para gestionar threads: En lugar de crear threads manualmente, utilizar los servicios de ejecución del paquete java.util.concurrent.

  • Evitar extender Thread a menos que realmente necesites personalizar el comportamiento del thread en sí (no solo la tarea que ejecuta).

  • Implementar Callable en lugar de Runnable cuando necesites devolver un resultado o lanzar excepciones comprobadas.

// Ejemplo con Callable (devuelve un resultado)
Callable<Integer> tarea = () -> {
    Thread.sleep(1000);
    return 42; // Devuelve un resultado
};

ExecutorService executor = Executors.newSingleThreadExecutor();
Future<Integer> resultado = executor.submit(tarea);

try {
    System.out.println("Resultado: " + resultado.get());
} catch (Exception e) {
    e.printStackTrace();
}

executor.shutdown();

La elección entre extender Thread e implementar Runnable puede parecer trivial, pero tiene implicaciones importantes en el diseño y mantenimiento de aplicaciones concurrentes. En la mayoría de los casos, implementar Runnable proporciona un diseño más flexible y modular, alineado con los principios modernos de programación en Java.

Sincronización básica y palabra clave synchronized

Cuando trabajamos con múltiples threads en Java, surge un problema fundamental: el acceso concurrente a recursos compartidos puede provocar resultados impredecibles o incorrectos. La sincronización es el mecanismo que nos permite coordinar la ejecución de threads para garantizar la integridad de los datos compartidos.

El problema de la concurrencia

Imaginemos una situación donde dos threads intentan incrementar un contador compartido:

public class ContadorInseguro {
    private int contador = 0;
    
    public void incrementar() {
        contador++; // Esta operación no es atómica
    }
    
    public int getValor() {
        return contador;
    }
    
    public static void main(String[] args) throws InterruptedException {
        ContadorInseguro contador = new ContadorInseguro();
        
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                contador.incrementar();
            }
        });
        
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                contador.incrementar();
            }
        });
        
        t1.start();
        t2.start();
        
        t1.join();
        t2.join();
        
        System.out.println("Valor final: " + contador.getValor());
    }
}

Al ejecutar este código, esperaríamos que el valor final fuera 20000, pero frecuentemente obtendremos un valor menor. Esto ocurre porque la operación contador++ no es atómica - en realidad consta de tres pasos:

  1. Leer el valor actual
  2. Incrementarlo
  3. Escribir el nuevo valor

Si dos threads ejecutan estos pasos simultáneamente, pueden producirse condiciones de carrera donde un thread sobrescribe los cambios del otro.

La palabra clave synchronized

Java proporciona la palabra clave synchronized para crear secciones críticas - bloques de código que solo pueden ser ejecutados por un thread a la vez:

public class ContadorSeguro {
    private int contador = 0;
    
    public synchronized void incrementar() {
        contador++;
    }
    
    public synchronized int getValor() {
        return contador;
    }
}

Cuando un método se declara como synchronized, Java utiliza el objeto actual (this) como monitor o lock. Antes de que un thread pueda ejecutar el método, debe adquirir el lock del objeto. Si otro thread ya tiene el lock, el thread solicitante se bloquea hasta que el lock esté disponible.

Bloques synchronized

También podemos sincronizar solo partes específicas de un método usando bloques synchronized:

public class ContadorOptimizado {
    private int contador = 0;
    
    public void incrementar() {
        // Código no sincronizado
        
        synchronized(this) {
            contador++;
        }
        
        // Más código no sincronizado
    }
    
    public int getValor() {
        synchronized(this) {
            return contador;
        }
    }
}

Esta aproximación es más eficiente cuando solo una pequeña parte del método necesita sincronización, ya que minimiza el tiempo que los threads pasan bloqueados.

Sincronización en objetos específicos

Podemos sincronizar sobre cualquier objeto, no solo this:

public class BancoSincronizado {
    private final Object lockCuentas = new Object(); // objeto para sincronización
    private int[] cuentas = new int[100];
    
    public void transferir(int origen, int destino, int cantidad) {
        synchronized(lockCuentas) {
            if (cuentas[origen] >= cantidad) {
                cuentas[origen] -= cantidad;
                cuentas[destino] += cantidad;
            }
        }
    }
    
    public int saldoTotal() {
        synchronized(lockCuentas) {
            int suma = 0;
            for (int saldo : cuentas) {
                suma += saldo;
            }
            return suma;
        }
    }
}

Usar un objeto dedicado para la sincronización (como lockCuentas) es una buena práctica porque:

  1. Hace explícito qué recurso está siendo protegido
  2. Evita interferencias con otros mecanismos de sincronización
  3. Proporciona un control más granular sobre la sincronización

Métodos estáticos synchronized

Para sincronizar métodos estáticos, Java utiliza el lock de la clase en lugar del lock de la instancia:

public class Contador {
    private static int contador = 0;
    
    public static synchronized void incrementar() {
        contador++;
    }
    
    public static synchronized int getValor() {
        return contador;
    }
}

Esto es equivalente a:

public class Contador {
    private static int contador = 0;
    
    public static void incrementar() {
        synchronized(Contador.class) {
            contador++;
        }
    }
    
    public static int getValor() {
        synchronized(Contador.class) {
            return contador;
        }
    }
}

Sincronización de colecciones

Las colecciones estándar de Java como ArrayList o HashMap no son thread-safe. Para operaciones concurrentes, podemos:

  1. Usar colecciones sincronizadas:
List<String> listaSincronizada = Collections.synchronizedList(new ArrayList<>());
Map<String, Integer> mapaSincronizado = Collections.synchronizedMap(new HashMap<>());

// Uso seguro en múltiples threads
listaSincronizada.add("elemento");
mapaSincronizado.put("clave", 42);
  1. Usar colecciones concurrentes del paquete java.util.concurrent:
ConcurrentHashMap<String, Integer> mapaSeguro = new ConcurrentHashMap<>();
CopyOnWriteArrayList<String> listaSegura = new CopyOnWriteArrayList<>();

// Estas colecciones están optimizadas para concurrencia
mapaSeguro.put("clave", 100);
listaSegura.add("elemento");

Sincronización y visibilidad

La sincronización no solo previene el acceso simultáneo, también garantiza la visibilidad de los cambios entre threads. Cuando un thread sale de un bloque sincronizado, todos los cambios realizados dentro del bloque se vuelven visibles para otros threads que adquieran el mismo lock.

public class EjemploVisibilidad {
    private boolean listo = false;
    private int dato = 0;
    
    public synchronized void producir() {
        dato = 42;
        listo = true;
    }
    
    public synchronized void consumir() {
        if (listo) {
            System.out.println("Dato: " + dato); // Siempre verá 42
        }
    }
}

Sin sincronización, el thread consumidor podría ver listo = true pero no ver el valor actualizado de dato debido a la reordenación de instrucciones y el caching en los procesadores modernos.

Sincronización y rendimiento

Aunque la sincronización es necesaria para la corrección, tiene un impacto en el rendimiento:

  1. Overhead de adquisición de locks: Adquirir y liberar locks tiene un costo.
  2. Contención: Los threads que esperan un lock no pueden avanzar.
  3. Reducción del paralelismo: La sincronización limita la ejecución concurrente.

Por ello, es importante seguir algunas buenas prácticas:

  • Minimizar el alcance de los bloques sincronizados
  • Sincronizar solo lo necesario
  • Usar locks de grano fino para recursos independientes
  • Considerar alternativas como variables atómicas o estructuras sin bloqueo

Variables atómicas como alternativa

Para operaciones simples, las clases del paquete java.util.concurrent.atomic ofrecen una alternativa más eficiente:

import java.util.concurrent.atomic.AtomicInteger;

public class ContadorAtomico {
    private AtomicInteger contador = new AtomicInteger(0);
    
    public void incrementar() {
        contador.incrementAndGet(); // Operación atómica
    }
    
    public int getValor() {
        return contador.get();
    }
}

Las variables atómicas utilizan instrucciones especiales del procesador (como Compare-And-Swap) para realizar operaciones sin bloqueos, lo que las hace más eficientes en situaciones de baja contención.

Ejemplo práctico: Banco con transferencias seguras

Veamos un ejemplo más completo de sincronización en una aplicación bancaria:

public class Banco {
    private final double[] cuentas;
    private final Object lockCuentas = new Object();
    
    public Banco(int numCuentas, double saldoInicial) {
        cuentas = new double[numCuentas];
        Arrays.fill(cuentas, saldoInicial);
    }
    
    public void transferir(int origen, int destino, double cantidad) {
        synchronized(lockCuentas) {
            if (origen < 0 || origen >= cuentas.length || 
                destino < 0 || destino >= cuentas.length) {
                throw new IllegalArgumentException("Cuenta inválida");
            }
            
            if (cuentas[origen] < cantidad) {
                System.out.println("Fondos insuficientes");
                return;
            }
            
            System.out.printf("Transfiriendo %.2f de cuenta %d a cuenta %d%n", 
                             cantidad, origen, destino);
            
            cuentas[origen] -= cantidad;
            cuentas[destino] += cantidad;
            
            System.out.printf("Saldo total: %.2f%n", getSaldoTotal());
        }
    }
    
    public double getSaldoTotal() {
        synchronized(lockCuentas) {
            return Arrays.stream(cuentas).sum();
        }
    }
    
    public static void main(String[] args) {
        final int NUM_CUENTAS = 10;
        final double SALDO_INICIAL = 1000;
        final int NUM_THREADS = 5;
        final int NUM_TRANSFERENCIAS = 50;
        
        Banco banco = new Banco(NUM_CUENTAS, SALDO_INICIAL);
        
        for (int i = 0; i < NUM_THREADS; i++) {
            Thread t = new Thread(() -> {
                Random random = new Random();
                for (int j = 0; j < NUM_TRANSFERENCIAS; j++) {
                    int origen = random.nextInt(NUM_CUENTAS);
                    int destino;
                    do {
                        destino = random.nextInt(NUM_CUENTAS);
                    } while (destino == origen);
                    
                    double cantidad = random.nextDouble() * 100;
                    banco.transferir(origen, destino, cantidad);
                    
                    try {
                        Thread.sleep(random.nextInt(10));
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                    }
                }
            });
            t.start();
        }
    }
}

En este ejemplo, utilizamos sincronización para garantizar que:

  1. Las transferencias son atómicas - no pueden ser interrumpidas a la mitad
  2. El saldo total se mantiene constante (conservación del dinero)
  3. Las verificaciones de saldo y las transferencias son consistentes

Sincronización con múltiples locks

En aplicaciones complejas, a veces necesitamos múltiples locks. Es importante tener cuidado con el orden de adquisición para evitar deadlocks:

public class TransferenciaSegura {
    private final Object lock1 = new Object();
    private final Object lock2 = new Object();
    
    private double saldo1 = 1000;
    private double saldo2 = 1000;
    
    public void transferir1a2(double cantidad) {
        synchronized(lock1) {
            synchronized(lock2) {
                if (saldo1 >= cantidad) {
                    saldo1 -= cantidad;
                    saldo2 += cantidad;
                }
            }
        }
    }
    
    public void transferir2a1(double cantidad) {
        // Importante: mismo orden de adquisición que en transferir1a2
        synchronized(lock1) {
            synchronized(lock2) {
                if (saldo2 >= cantidad) {
                    saldo2 -= cantidad;
                    saldo1 += cantidad;
                }
            }
        }
    }
}

Si usáramos un orden diferente de locks en transferir2a1 (primero lock2 y luego lock1), podríamos crear condiciones para un deadlock.

La sincronización es un concepto fundamental en la programación concurrente en Java. Aunque la palabra clave synchronized proporciona una forma sencilla de proteger recursos compartidos, es importante entender sus implicaciones y utilizarla correctamente para evitar problemas de rendimiento y concurrencia.

Problemas comunes: deadlock, race conditions

La programación concurrente en Java ofrece grandes ventajas para aprovechar los recursos del sistema, pero también introduce desafíos específicos que pueden ser difíciles de detectar y resolver. Dos de los problemas más comunes y peligrosos son los deadlocks y las race conditions.

Race conditions (condiciones de carrera)

Una race condition ocurre cuando el comportamiento de un programa depende del orden relativo o la sincronización de eventos que no están bajo el control del programador. Típicamente, esto sucede cuando múltiples threads acceden y modifican datos compartidos sin la sincronización adecuada.

Veamos un ejemplo clásico de una condición de carrera:

public class ContadorInseguro implements Runnable {
    private static int contador = 0;
    
    @Override
    public void run() {
        for (int i = 0; i < 1000; i++) {
            // Operación no atómica vulnerable a race conditions
            contador = contador + 1;
        }
    }
    
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(new ContadorInseguro());
        Thread t2 = new Thread(new ContadorInseguro());
        
        t1.start();
        t2.start();
        
        t1.join();
        t2.join();
        
        System.out.println("Valor final del contador: " + contador);
        // El resultado esperado sería 2000, pero casi siempre será menor
    }
}

¿Por qué ocurre?

La operación contador = contador + 1 parece simple, pero en realidad involucra tres pasos:

  1. Leer el valor actual de contador
  2. Incrementar el valor en 1
  3. Escribir el nuevo valor en contador

Si dos threads ejecutan estos pasos simultáneamente, pueden interferir entre sí:

Thread 1: Lee contador (valor 42)
Thread 2: Lee contador (valor 42)
Thread 1: Incrementa a 43
Thread 2: Incrementa a 43
Thread 1: Escribe 43 en contador
Thread 2: Escribe 43 en contador

En este escenario, aunque ambos threads incrementaron el contador, el valor final es 43 en lugar de 44, perdiendo un incremento.

Detección de race conditions

Las race conditions son notoriamente difíciles de detectar porque:

  • Pueden no manifestarse en cada ejecución
  • A menudo dependen de la carga del sistema y la planificación de threads
  • Pueden aparecer solo en producción bajo carga real

Algunas herramientas que ayudan a detectarlas:

  • Analizadores estáticos: FindBugs, SpotBugs, SonarQube
  • Herramientas de análisis dinámico: Java PathFinder, ThreadSanitizer
  • Pruebas de estrés: Ejecutar el código con muchos threads simultáneos

Soluciones para race conditions

  1. Sincronización con synchronized:
public class ContadorSeguro implements Runnable {
    private static int contador = 0;
    private static final Object lock = new Object();
    
    @Override
    public void run() {
        for (int i = 0; i < 1000; i++) {
            synchronized(lock) {
                contador = contador + 1;
            }
        }
    }
}
  1. Uso de variables atómicas:
import java.util.concurrent.atomic.AtomicInteger;

public class ContadorAtomico implements Runnable {
    private static AtomicInteger contador = new AtomicInteger(0);
    
    @Override
    public void run() {
        for (int i = 0; i < 1000; i++) {
            contador.incrementAndGet(); // Operación atómica
        }
    }
}
  1. Uso de estructuras thread-safe:
import java.util.concurrent.ConcurrentHashMap;

// En lugar de HashMap normal
ConcurrentHashMap<String, Integer> mapaSeguro = new ConcurrentHashMap<>();

Deadlocks

Un deadlock ocurre cuando dos o más threads se bloquean permanentemente, esperando recursos que están siendo retenidos por otros threads en el mismo grupo. Es como si dos personas en un pasillo estrecho se detuvieran cada una esperando que la otra se mueva primero.

Ejemplo clásico de deadlock

public class EjemploDeadlock {
    private static final Object recurso1 = new Object();
    private static final Object recurso2 = new Object();
    
    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            synchronized(recurso1) {
                System.out.println("Thread 1: Tiene recurso 1");
                
                try {
                    Thread.sleep(100); // Aumenta la probabilidad de deadlock
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                
                System.out.println("Thread 1: Esperando recurso 2");
                synchronized(recurso2) {
                    System.out.println("Thread 1: Tiene ambos recursos");
                }
            }
        });
        
        Thread thread2 = new Thread(() -> {
            synchronized(recurso2) {
                System.out.println("Thread 2: Tiene recurso 2");
                
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                
                System.out.println("Thread 2: Esperando recurso 1");
                synchronized(recurso1) {
                    System.out.println("Thread 2: Tiene ambos recursos");
                }
            }
        });
        
        thread1.start();
        thread2.start();
    }
}

En este ejemplo:

  1. El Thread 1 adquiere recurso1 y luego intenta adquirir recurso2
  2. El Thread 2 adquiere recurso2 y luego intenta adquirir recurso1
  3. Ambos threads quedan bloqueados indefinidamente, esperando un recurso que nunca será liberado

Las cuatro condiciones para un deadlock

Un deadlock puede ocurrir solo cuando se cumplen estas cuatro condiciones simultáneamente:

  1. Exclusión mutua: Al menos un recurso debe ser retenido en modo no compartible
  2. Retención y espera: Un thread retiene al menos un recurso mientras espera recursos adicionales
  3. No apropiación: Los recursos no pueden ser forzosamente quitados de los threads
  4. Espera circular: Existe un ciclo de threads donde cada uno espera un recurso retenido por el siguiente

Detección de deadlocks

Java proporciona herramientas para detectar deadlocks:

  1. jstack: Herramienta de línea de comandos que muestra volcados de threads de JVM:
jstack <pid_del_proceso>
  1. JVisualVM o JConsole: Herramientas gráficas que pueden detectar deadlocks

  2. ThreadMXBean: API para detectar deadlocks programáticamente:

import java.lang.management.ManagementFactory;
import java.lang.management.ThreadMXBean;

public class DetectorDeadlock {
    public static void detectarDeadlock() {
        ThreadMXBean threadBean = ManagementFactory.getThreadMXBean();
        long[] threadIds = threadBean.findDeadlockedThreads();
        
        if (threadIds != null) {
            System.out.println("¡Deadlock detectado!");
            // Obtener información detallada de los threads
            for (long id : threadIds) {
                System.out.println(threadBean.getThreadInfo(id, Integer.MAX_VALUE));
            }
        } else {
            System.out.println("No se detectaron deadlocks");
        }
    }
}

Prevención de deadlocks

  1. Ordenamiento de locks: Siempre adquirir múltiples locks en el mismo orden:
public void transferir(Cuenta origen, Cuenta destino, double cantidad) {
    // Ordenamos las cuentas para adquirir locks siempre en el mismo orden
    Cuenta primera = origen.getId() < destino.getId() ? origen : destino;
    Cuenta segunda = origen.getId() < destino.getId() ? destino : origen;
    
    synchronized(primera) {
        synchronized(segunda) {
            // Realizar la transferencia
            if (origen == primera) {
                origen.retirar(cantidad);
                destino.depositar(cantidad);
            } else {
                destino.retirar(cantidad);
                origen.depositar(cantidad);
            }
        }
    }
}
  1. Uso de timeouts: Evitar esperas indefinidas con tryLock() de ReentrantLock:
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class TransferenciaConTimeout {
    private final Lock lock = new ReentrantLock();
    
    public boolean transferir(Cuenta origen, Cuenta destino, double cantidad) {
        try {
            if (origen.lock.tryLock(1, TimeUnit.SECONDS)) {
                try {
                    if (destino.lock.tryLock(1, TimeUnit.SECONDS)) {
                        try {
                            if (origen.getSaldo() >= cantidad) {
                                origen.retirar(cantidad);
                                destino.depositar(cantidad);
                                return true;
                            }
                        } finally {
                            destino.lock.unlock();
                        }
                    }
                } finally {
                    origen.lock.unlock();
                }
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        return false; // No se pudo adquirir ambos locks
    }
}
  1. Evitar anidamiento de locks: Minimizar la cantidad de locks adquiridos simultáneamente

  2. Usar estructuras de alto nivel: Utilizar clases del paquete java.util.concurrent que manejan la sincronización internamente

Livelock

Un livelock es similar a un deadlock, pero los threads no están bloqueados - están ocupados respondiendo a las acciones de los otros threads sin hacer progreso real. Es como cuando dos personas se encuentran en un pasillo y ambas se mueven al mismo lado intentando dejar pasar a la otra.

public class EjemploLivelock {
    static class Recurso {
        private String nombre;
        private boolean enUso = false;
        
        public Recurso(String nombre) {
            this.nombre = nombre;
        }
        
        public String getNombre() {
            return nombre;
        }
        
        public boolean isEnUso() {
            return enUso;
        }
        
        public synchronized boolean usar(Trabajador trabajador) {
            if (enUso) {
                System.out.println(trabajador.getNombre() + " esperando que " + 
                                  nombre + " sea liberado");
                return false;
            }
            
            enUso = true;
            System.out.println(trabajador.getNombre() + " usando " + nombre);
            return true;
        }
        
        public synchronized void liberar() {
            enUso = false;
            System.out.println(nombre + " liberado");
        }
    }
    
    static class Trabajador {
        private String nombre;
        private boolean activo = true;
        
        public Trabajador(String nombre) {
            this.nombre = nombre;
        }
        
        public String getNombre() {
            return nombre;
        }
        
        public void trabajar(Recurso recursoPropio, Recurso recursoAjeno) {
            while (activo) {
                // Si no puede usar su propio recurso, lo libera para que otro lo use
                if (recursoPropio.usar(this)) {
                    System.out.println(nombre + " intentando usar " + recursoAjeno.getNombre());
                    
                    if (!recursoAjeno.usar(this)) {
                        System.out.println(nombre + " no puede usar " + 
                                          recursoAjeno.getNombre() + ", liberando recursos");
                        recursoPropio.liberar();
                        // Espera para reintentar
                        try {
                            Thread.sleep(10);
                        } catch (InterruptedException e) {
                            Thread.currentThread().interrupt();
                        }
                        continue;
                    }
                    
                    // Si llega aquí, tiene ambos recursos
                    activo = false;
                    System.out.println(nombre + " completó su trabajo");
                    recursoAjeno.liberar();
                    recursoPropio.liberar();
                }
            }
        }
    }
    
    public static void main(String[] args) {
        final Recurso recursoA = new Recurso("Recurso A");
        final Recurso recursoB = new Recurso("Recurso B");
        
        final Trabajador trabajador1 = new Trabajador("Trabajador 1");
        final Trabajador trabajador2 = new Trabajador("Trabajador 2");
        
        new Thread(() -> {
            trabajador1.trabajar(recursoA, recursoB);
        }).start();
        
        new Thread(() -> {
            trabajador2.trabajar(recursoB, recursoA);
        }).start();
    }
}

En este ejemplo, ambos trabajadores intentan ser "corteses" liberando su recurso si no pueden obtener el segundo, pero esto puede llevar a un ciclo infinito donde ninguno hace progreso.

Starvation (inanición)

La starvation ocurre cuando un thread no puede acceder a los recursos que necesita durante un tiempo prolongado debido a que otros threads de mayor prioridad los acaparan constantemente.

public class EjemploStarvation {
    private static Object recurso = new Object();
    
    public static void main(String[] args) {
        // Creamos 5 threads con prioridad normal
        for (int i = 0; i < 5; i++) {
            Thread t = new Thread(new TrabajadorNormal());
            t.setName("Thread Normal " + i);
            t.start();
        }
        
        // Creamos un thread con alta prioridad que monopoliza el recurso
        Thread threadPrioritario = new Thread(new TrabajadorPrioritario());
        threadPrioritario.setPriority(Thread.MAX_PRIORITY);
        threadPrioritario.setName("Thread Prioritario");
        threadPrioritario.start();
    }
    
    static class TrabajadorNormal implements Runnable {
        @Override
        public void run() {
            while (true) {
                synchronized(recurso) {
                    System.out.println(Thread.currentThread().getName() + 
                                      " obtuvo el recurso");
                    try {
                        Thread.sleep(100);
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                    }
                }
                
                // Tiempo fuera del bloque sincronizado
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            }
        }
    }
    
    static class TrabajadorPrioritario implements Runnable {
        @Override
        public void run() {
            while (true) {
                synchronized(recurso) {
                    System.out.println(Thread.currentThread().getName() + 
                                      " obtuvo el recurso");
                    try {
                        // Retiene el recurso por más tiempo
                        Thread.sleep(200);
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                    }
                }
                
                // Apenas suelta el recurso, intenta obtenerlo de nuevo
                try {
                    Thread.sleep(10);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            }
        }
    }
}

Soluciones para starvation

  1. Usar políticas de equidad: Algunos locks en Java pueden configurarse para ser "justos":
import java.util.concurrent.locks.ReentrantLock;

// El parámetro true activa la política de equidad
ReentrantLock lockJusto = new ReentrantLock(true);
  1. Reducir el tiempo de retención de recursos: Minimizar el tiempo que un thread mantiene un lock

  2. Evitar prioridades extremas: No abusar de las prioridades de threads

Mejores prácticas para evitar problemas de concurrencia

  1. Inmutabilidad: Los objetos inmutables son inherentemente thread-safe:
public final class Punto {
    private final int x;
    private final int y;
    
    public Punto(int x, int y) {
        this.x = x;
        this.y = y;
    }
    
    public int getX() { return x; }
    public int getY() { return y; }
    
    // No hay setters - los valores no pueden cambiar
    
    public Punto sumar(Punto otro) {
        // Retorna un nuevo objeto en lugar de modificar el existente
        return new Punto(this.x + otro.x, this.y + otro.y);
    }
}
  1. Confinamiento de threads: Limitar el acceso a datos a un solo thread:
public class ConfinamientoThread {
    // ThreadLocal confina los datos a cada thread individual
    private static ThreadLocal<SimpleDateFormat> formatoFecha = 
        ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
    
    public String formatearFecha(Date fecha) {
        // Cada thread tiene su propia instancia de SimpleDateFormat
        return formatoFecha.get().format(fecha);
    }
}
  1. Minimizar el estado compartido: Reducir la cantidad de datos que necesitan sincronización

  2. Preferir estructuras concurrentes: Usar las clases del paquete java.util.concurrent

  3. Documentar la estrategia de sincronización: Hacer explícito cómo se protegen los datos compartidos

/**
 * Esta clase es thread-safe.
 * La sincronización se realiza a nivel de método para proteger el estado interno.
 */
public class ContadorSeguro {
    private int valor;
    
    public synchronized void incrementar() {
        valor++;
    }
    
    public synchronized int getValor() {
        return valor;
    }
}
  1. Usar herramientas de análisis: Incorporar análisis estático y dinámico en el proceso de desarrollo

Ejemplo integrado: Sistema de reservas

Veamos un ejemplo más completo que ilustra cómo evitar race conditions y deadlocks en un sistema de reservas:

import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class SistemaReservas {
    private final Map<Integer, Recurso> recursos = new HashMap<>();
    
    // Constructor que inicializa recursos
    public SistemaReservas(int numRecursos) {
        for (int i = 1; i <= numRecursos; i++) {
            recursos.put(i, new Recurso(i, "Recurso " + i));
        }
    }
    
    // Método seguro para reservar múltiples recursos
    public boolean reservar(int idUsuario, int[] idsRecursos) {
        // Ordenamos los IDs para evitar deadlocks
        java.util.Arrays.sort(idsRecursos);
        
        // Lista de recursos bloqueados para liberarlos en caso de fallo
        Map<Integer, Recurso> bloqueados = new HashMap<>();
        
        try {
            // Intentamos adquirir todos los locks en orden
            for (int id : idsRecursos) {
                Recurso recurso = recursos.get(id);
                if (recurso == null) {
                    throw new IllegalArgumentException("Recurso no encontrado: " + id);
                }
                
                if (!recurso.lock.tryLock(500, java.util.concurrent.TimeUnit.MILLISECONDS)) {
                    // No pudimos obtener el lock, abortamos
                    return false;
                }
                
                bloqueados.put(id, recurso);
            }
            
            // Verificamos disponibilidad de todos los recursos
            for (int id : idsRecursos) {
                Recurso recurso = recursos.get(id);
                if (recurso.estaReservado()) {
                    return false;
                }
            }
            
            // Realizamos las reservas
            for (int id : idsRecursos) {
                Recurso recurso = recursos.get(id);
                recurso.reservar(idUsuario);
                System.out.println("Recurso " + id + " reservado por usuario " + idUsuario);
            }
            
            return true;
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return false;
        } finally {
            // Liberamos todos los locks adquiridos
            for (Recurso recurso : bloqueados.values()) {
                recurso.lock.unlock();
            }
        }
    }
    
    // Clase interna que representa un recurso reservable
    static class Recurso {
        private final int id;
        private final String nombre;
        private int reservadoPor = -1; // -1 significa no reservado
        final Lock lock = new ReentrantLock();
        
        public Recurso(int id, String nombre) {
            this.id = id;
            this.nombre = nombre;
        }
        
        public boolean estaReservado() {
            return reservadoPor != -1;
        }
        
        public void reservar(int idUsuario) {
            this.reservadoPor = idUsuario;
        }
        
        public void liberar() {
            this.reservadoPor = -1;
        }
    }
    
    // Método principal para demostración
    public static void main(String[] args) {
        final SistemaReservas sistema = new SistemaReservas(5);
        
        // Simulamos múltiples usuarios intentando reservar recursos
        for (int usuario = 1; usuario <= 3; usuario++) {
            final int idUsuario = usuario;
            new Thread(() -> {
                // Cada usuario intenta reservar 2-3 recursos aleatorios
                int[] recursosDeseados = generarRecursosAleatorios(2 + idUsuario % 2);
                
                System.out.println("Usuario " + idUsuario + " intenta reservar: " + 
                                  java.util.Arrays.toString(recursosDeseados));
                
                boolean exito = sistema.reservar(idUsuario, recursosDeseados);
                
                System.out.println("Usuario " + idUsuario + 
                                  (exito ? " reservó con éxito" : " no pudo reservar"));
            }).start();
        }
    }
    
    // Método auxiliar para generar IDs de recursos aleatorios
    private static int[] generarRecursosAleatorios(int cantidad) {
        java.util.Random random = new java.util.Random();
        int[] resultado = new int[cantidad];
        for (int i = 0; i < cantidad; i++) {
            resultado[i] = 1 + random.nextInt(5); // IDs del 1 al 5
        }
        return resultado;
    }
}

Este ejemplo implementa varias técnicas para evitar problemas de concurrencia:

  1. Ordenamiento de locks para prevenir deadlocks
  2. Timeouts para evitar esperas indefinidas
  3. Adquisición atómica de todos los recursos o ninguno
  4. Liberación adecuada de locks en bloques finally

Los problemas de concurrencia como deadlocks y race conditions son desafíos inherentes a la programación multithreading. Aunque no existe una solución universal, comprender estos problemas y aplicar las técnicas adecuadas nos permite desarrollar aplicaciones concurrentes más robustas y confiables.

Aprendizajes de esta lección

  • Comprender qué es un thread y cómo se crea en Java mediante la extensión de Thread o la implementación de Runnable.
  • Conocer el ciclo de vida de un thread y los estados por los que puede pasar.
  • Aprender a sincronizar el acceso a recursos compartidos usando la palabra clave synchronized y otras técnicas.
  • Identificar y prevenir problemas comunes en concurrencia como race conditions, deadlocks, livelocks y starvation.
  • Aplicar buenas prácticas y patrones para diseñar aplicaciones concurrentes robustas y eficientes en Java.

Completa y certifícate

Únete a nuestra plataforma y accede a miles de tutoriales, ejercicios prácticos, proyectos reales y nuestro asistente de IA personalizado para acelerar tu aprendizaje.

Asistente IA

Resuelve dudas al instante

Ejercicios

Practica con proyectos reales

Certificados

Valida tus conocimientos

Más de 25.000 desarrolladores ya se han certificado con CertiDevs

⭐⭐⭐⭐⭐
4.9/5 valoración