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étodorun()
. - Implementando la interfaz Runnable: Implementamos la interfaz
Runnable
y pasamos la instancia a un objetoThread
.
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:
- NEW: El thread ha sido creado pero aún no ha iniciado su ejecución.
- RUNNABLE: El thread está ejecutándose o listo para ejecutarse cuando el planificador le asigne tiempo de CPU.
- BLOCKED: El thread está bloqueado esperando un monitor lock.
- WAITING: El thread está esperando indefinidamente a que otro thread realice una acción específica.
- TIMED_WAITING: El thread está esperando durante un tiempo específico a que otro thread realice una acción.
- 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:
- Un thread comienza en estado NEW cuando se crea.
- Cuando se llama a
start()
, pasa a estado RUNNABLE. - 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.
- Desde BLOCKED, WAITING o TIMED_WAITING vuelve a RUNNABLE cuando se cumplen las condiciones de espera.
- 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.
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:
- Leer el valor actual
- Incrementarlo
- 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:
- Hace explícito qué recurso está siendo protegido
- Evita interferencias con otros mecanismos de sincronización
- 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:
- 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);
- 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:
- Overhead de adquisición de locks: Adquirir y liberar locks tiene un costo.
- Contención: Los threads que esperan un lock no pueden avanzar.
- 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:
- Las transferencias son atómicas - no pueden ser interrumpidas a la mitad
- El saldo total se mantiene constante (conservación del dinero)
- 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:
- Leer el valor actual de
contador
- Incrementar el valor en 1
- 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
- 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;
}
}
}
}
- 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
}
}
}
- 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:
- El Thread 1 adquiere
recurso1
y luego intenta adquirirrecurso2
- El Thread 2 adquiere
recurso2
y luego intenta adquirirrecurso1
- 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:
- Exclusión mutua: Al menos un recurso debe ser retenido en modo no compartible
- Retención y espera: Un thread retiene al menos un recurso mientras espera recursos adicionales
- No apropiación: Los recursos no pueden ser forzosamente quitados de los threads
- 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:
- jstack: Herramienta de línea de comandos que muestra volcados de threads de JVM:
jstack <pid_del_proceso>
-
JVisualVM o JConsole: Herramientas gráficas que pueden detectar deadlocks
-
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
- 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);
}
}
}
}
- Uso de timeouts: Evitar esperas indefinidas con
tryLock()
deReentrantLock
:
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
}
}
-
Evitar anidamiento de locks: Minimizar la cantidad de locks adquiridos simultáneamente
-
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
- 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);
-
Reducir el tiempo de retención de recursos: Minimizar el tiempo que un thread mantiene un lock
-
Evitar prioridades extremas: No abusar de las prioridades de threads
Mejores prácticas para evitar problemas de concurrencia
- 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);
}
}
- 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);
}
}
-
Minimizar el estado compartido: Reducir la cantidad de datos que necesitan sincronización
-
Preferir estructuras concurrentes: Usar las clases del paquete
java.util.concurrent
-
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;
}
}
- 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:
- Ordenamiento de locks para prevenir deadlocks
- Timeouts para evitar esperas indefinidas
- Adquisición atómica de todos los recursos o ninguno
- 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