1. Qué es un recurso compartido
Ya te has encontrado con recursos compartidos. La casa en la que vive una familia — recurso compartido de esa familia. El frigorífico de la oficina — recurso compartido para todos los compañeros. Creo que la idea queda clara.
En programación, un recurso compartido es una variable, objeto o estructura de datos a los que pueden acceder varios hilos a la vez. Puede ser:
- Una variable‑contador del número de pedidos procesados.
- Una lista de solicitudes que unos hilos rellenan y otros procesan.
- Un archivo abierto al que escriben varios hilos.
- Una conexión a la base de datos con la que trabajan distintas partes del programa.
En Java, cualquier objeto o variable a los que puedan acceder varios hilos se convierte potencialmente en un «recurso compartido».
Ejemplo de recurso compartido: contador global
public class Counter {
public int value = 0;
}
Si varios hilos incrementan este contador, accederán a la misma variable value — ahí tienes el recurso compartido.
2. Problemas del acceso simultáneo
En un programa monohilo todo es sencillo: un hilo — un ejecutor — recorre el código como un tren por las vías. Pero en cuanto entran varios hilos en juego, empieza una auténtica «danza de los sables»: los hilos pueden interferir en el trabajo de los demás en los lugares más inesperados.
Race condition (condición de carrera)
Race condition — es una situación en la que el resultado del programa depende de cómo se «mezclaron» las acciones de los hilos. Es decir, si ejecutas varias veces el mismo programa, el resultado puede ser distinto — y no es un bug, es una “feature” de la concurrencia.
Ejemplo clásico: dos hilos incrementan un contador
Vamos a modelar una situación simple: tenemos un contador compartido y dos hilos incrementan su valor mil veces.
public class Counter {
public int value = 0;
}
public class CounterDemo {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Runnable incrementTask = () -> {
for (int i = 0; i < 1000; i++) {
counter.value++; // ¡LUGAR PELIGROSO!
}
};
Thread t1 = new Thread(incrementTask);
Thread t2 = new Thread(incrementTask);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Valor esperado: 2000");
System.out.println("Valor real: " + counter.value);
}
}
¿Qué veremos en pantalla?
A veces — 2000, a veces — 1985, a veces — 1937... ¿Por qué? Porque la operación counter.value++ no es atómica. Consta de tres pasos:
- Leer el valor actual de counter.value.
- Incrementarlo en 1.
- Escribirlo de nuevo.
Si dos hilos leen a la vez el mismo valor, ambos lo incrementan y luego ambos escriben el resultado — al final uno de los incrementos se «pierde». Esto es un lost update — una actualización perdida.
Estado inconsistente del objeto
Si tienes un objeto complejo compuesto por varios campos y los hilos cambian campos distintos a la vez, el objeto puede quedar en un estado «raro» o inconsistente. Por ejemplo, el saldo de la cuenta se ha reducido, pero el historial de operaciones no se ha actualizado — el cliente entra en pánico, el contable se queda de piedra.
3. Por qué necesitamos sincronización
La sincronización es una manera de decirle al programa: «Alto, este trozo de código debe ejecutarse solo por un hilo a la vez. ¡Los demás esperarán!» Es como un cartel de «¡Limpieza! No pasar» en la puerta de un aseo: mientras una persona está dentro, las demás esperan fuera (y maldicen mentalmente a quien tarda demasiado en salir).
Garantía de integridad de los datos
Si queremos que nuestro contador se incremente siempre correctamente, debemos prohibir que varios hilos cambien su valor simultáneamente.
Ejemplo: sincronizar el incremento del contador
public class Counter {
public int value = 0;
public synchronized void increment() {
value++;
}
}
Ahora, si dos hilos llaman a increment(), solo uno de ellos podrá ejecutar este método en ese momento. El segundo esperará hasta que el primero termine.
Esquema: qué ocurre con la sincronización
+-------------------+
| Hilo 1 | --\
+-------------------+ \
| \
V \
+-------------------+ > [ synchronized increment() ]
| Hilo 2 | --/ /
+-------------------+ / /
| / /
V / /
+-------------------+ / /
| Hilo 3 | --/ /
+-------------------+ /
| /
V /
+-------------------+ /
| Hilo N |/
+-------------------+
Todos los hilos se ponen en cola para ejecutar la sección protegida del código. Solo un hilo puede estar dentro de la «sección crítica» (bloque synchronized) a la vez.
4. Introducción breve a las formas de sincronización
La sincronización en Java no es un único método, sino todo un «arsenal» de herramientas que permiten proteger un recurso compartido del acceso simultáneo.
Palabra clave synchronized
Es la herramienta principal de sincronización en Java. Se puede usar de dos maneras:
Método sincronizado
public synchronized void increment() {
value++;
}
Bloque sincronizado
public void increment() {
synchronized (this) {
value++;
}
}
Aquí this es el objeto sobre el que se realiza el bloqueo. Mientras un hilo ejecuta este bloque, otros hilos que quieran entrar en un bloque igual con el mismo objeto tendrán que esperar.
Clases especializadas de java.util.concurrent
- Lock, ReentrantLock — alternativa más flexible a synchronized.
- ReadWriteLock — para separar bloqueos de lectura y escritura.
- Semaphore — limitar el número de hilos que ejecutan código simultáneamente.
- CountDownLatch, CyclicBarrier y otros — para coordinar el trabajo de los hilos.
Importante: hoy solo nos familiarizamos con lo básico — hablaremos de estas clases un poco más adelante.
5. Ejemplo práctico: aplicación con contador multihilo
Supongamos que implementamos estadísticas de accesos de usuarios a algún servicio. Cada hilo es un usuario independiente que incrementa un contador común.
Sin sincronización
public class Counter {
public int value = 0;
}
public class MultiThreadCounterDemo {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Runnable user = () -> {
for (int i = 0; i < 10000; i++) {
counter.value++;
}
};
Thread t1 = new Thread(user);
Thread t2 = new Thread(user);
Thread t3 = new Thread(user);
t1.start();
t2.start();
t3.start();
t1.join();
t2.join();
t3.join();
System.out.println("Valor esperado: 30000");
System.out.println("Valor real: " + counter.value);
}
}
Resultado: Casi siempre menor que 30000. ¡A veces mucho menor! ¿Por qué? Porque los hilos se «pisan» unos a otros.
Sincronización: corregimos el error
public class Counter {
public int value = 0;
public synchronized void increment() {
value++;
}
}
public class MultiThreadCounterDemo {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Runnable user = () -> {
for (int i = 0; i < 10000; i++) {
counter.increment();
}
};
Thread t1 = new Thread(user);
Thread t2 = new Thread(user);
Thread t3 = new Thread(user);
t1.start();
t2.start();
t3.start();
t1.join();
t2.join();
t3.join();
System.out.println("Valor esperado: 30000");
System.out.println("Valor real: " + counter.value);
}
}
Resultado: Siempre 30000. ¡Hurra, la sincronización funciona!
6. Matices útiles
Visualización: cómo se ve una race condition
Hagamos una pequeña tabla para mostrar cómo dos hilos pueden «perder» un incremento:
| Paso | Hilo 1 | Hilo 2 | Valor de value |
|---|---|---|---|
| 1 | Lee value=0 | 0 | |
| 2 | Lee value=0 | 0 | |
| 3 | Incrementa a 1 | 0 | |
| 4 | Incrementa a 1 | 0 | |
| 5 | Escribe 1 | 1 | |
| 6 | Escribe 1 | 1 |
Cuándo se necesita sincronización
La sincronización no siempre es necesaria. Cuando una variable vive en su pequeño mundo y solo un hilo trabaja con ella — puedes relajarte. Pero en cuanto la compartes con otros hilos, ya no hay forma de prescindir de la sincronización. Aunque parezca que no pasará nada — no te fíes. El error de carrera es traicionero: puede esconderse durante mucho tiempo y, de repente, dispararse en el peor momento.
De cara al futuro: qué otros medios de sincronización existen
Hoy solo hemos visto la herramienta básica — synchronized. En las próximas lecciones veremos:
- Cómo funciona el monitor de un objeto y qué tipos de bloqueos existen.
- Qué son los métodos estáticos sincronizados (static + synchronized).
- Cómo funciona la palabra clave volatile y para qué sirve.
- Qué clases modernas de sincronización existen (Lock, Semaphore y otros).
7. Errores típicos al trabajar con recursos compartidos
Error n.º 1: Ignorar la concurrencia.
Uno de los errores más comunes es no pensar en que una variable puede ser accesible desde varios hilos. Aunque ahora el programa sea monohilo, más adelante alguien añadirá hilos — y los bugs aparecerán «de la nada».
Error n.º 2: Sincronización insuficiente o excesiva.
Si no sincronizas el acceso a un recurso compartido — tendrás race conditions y datos inconsistentes. Si, por el contrario, sincronizas todo, el programa «se ahogará» en bloqueos y se volverá lento. Intenta sincronizar solo lo que realmente sea necesario.
Error n.º 3: Sincronizar sobre el objeto equivocado.
Si sincronizas el acceso en distintos objetos (por ejemplo, en variables locales o literales de cadena), no protegerás el recurso compartido. Todos los hilos deben sincronizarse sobre el mismo objeto.
Error n.º 4: Esperar atomicidad de operaciones no atómicas.
La operación i++ no es atómica. Incluso si la variable se declara como volatile, eso no hace atómico el incremento. Para tales operaciones se necesita sincronización.
Error n.º 5: «He tenido suerte, a mí me funciona».
Una race condition puede no manifestarse en tu equipo, pero sí lo hará en el servidor o en el equipo del usuario. Nunca te fíes del «a ver si hay suerte» en programas multihilo.
GO TO FULL VERSION