Tema 1: Programación Concurrente

¿Qué es un programa concurrente?

Un programa que hace más de una cosa a la vez. Un hilo (thread) es una secuencia de llamadas que se ejecuta independientemente de otras, posiblemente al mismo tiempo, compartiendo recursos del sistema (ficheros, objetos). Los datos (objetos) son compartidos entre hilos pero cada hilo mantiene su propia pila.

Todo proceso tiene mínimo un hilo: el hilo principal (main).

Diagrama de transición de estados de los hilos en Java

Diagrama de transición de estados de los hilos en Java
Diagrama de transición de estados de los hilos en Java

Prioridades

Las prioridades de cada uno de los hilos se pueden fijar especificando en un rango MIN_PRIORITY a MAX_PRIORITY [0..10]. El valor por defecto es 5. Esta prioridad no garantiza la ejecución, es decir, si pones una prioridad alta frente a una mínima, no garantiza que se ejecute antes el de la prioridad máxima. Utilizamos el método setPriority() para modificar la prioridad del hilo en cuestión.

Cada hilo hereda la prioridad del hilo creador.

Interrupciones

Una interrupción indica al hilo que este debe dejar de actuar. Se envía con Thread.interrupt(). Esto puede lanzar una excepción InterruptedException.

Creación de hilos

Para poder crear un hilo, podemos realizarlo de dos maneras distintas. Un hilo debe contar con un método run(), que será lo que lance al utilizar el método start().

Extender de la clase Thread

class MiHilo extends Thread {
    public void run() {
        System.out.println("Soy un hilo");
    }
}
new MiHilo().start();

Extender de la clase Thread, nos permitirá heredar el método run() y redefinirlo. Sin embargo, esto nos limita si queremos heredar de alguna clase más, es por eso que se prefiere la siguiente opción:

Implementar la interfaz Runnable

Dado que en Java se permite la herencia múltiple de interfaces, no provocará ningún problema si lo que queremos es heredar métodos de otras clases.

class Hilo implements Runnable {
    int id;
    int contador = 1000;
    public Hilo(int id) { this.id = id; }
    public void run() {
        for (; contador > 0; contador--) {
            System.out.println("Soy el hilo: " + id);
            Thread.yield(); // ceder paso
        }
    }
}
// En main:
Thread t = new Thread(new Hilo(i));
t.start();

Thread.join()

Para poder suspender el hilo actual hasta que termine un hilo en concreto, utilizaremos el método join.

t.join() suspende el hilo actual hasta que t termine:

Thread t = new Thread(new Hilo(1));
t.start();
t.join(); // espera a que t termine
System.out.println("Programa terminado...");

Dormir hilos

Podemos dormir un hilo durante un tiempo especificado con el método Thread.sleep(milisegundos)

Thread.sleep(1000);

Siempre dentro de try-catch(InterruptedException ex)

Ejecutores

Desde Java 5, podemos delegar la gestión de los hilos a ejecutores. Esto nos permite reutilizar hilos y optimizar el rendimiento de los procesos.

Los ejecutores cuentan con una jerarquía de 3 niveles:

  • Executorexecute(Runnable): lanza un hilo con Runnable.
  • ExecutorService → añade gestión del ciclo de vida (shutdown, submit).
  • ScheduleExecutorService → permite planificar con retardo.

Tipos de depósitos (Executors factory):

  • newFixedThreadPool(n) → depósito fijo de n hilos.
  • newCachedThreadPool() → depósito dinámico. Aconsejable para muchas tareas de corta vida.
  • newSingleThreadExecutor() → ejecuta como máximo 1 tarea a la vez.
  • newScheduledThreadPool(n) → para ejecutar tareas periodicas con scheduleAtFixedRate.
ExecutorService exec = Executors.newCachedThreadPool();
for (int i = 0; i < 10; i++) {
    exec.execute(new HiloB(i));
}
exec.shutdown(); // terminar servicio

// ScheduledExecutorService - ejemplo:
ScheduledExecutorService exec2 = Executors.newScheduledThreadPool(1);
exec2.scheduleAtFixedRate(t, 0, 5, TimeUnit.SECONDS); // cada 5 seg

Concurrencia

Un programa concurrente hace más de una cosa a la vez. La JVM y el SO subyacente hacen posible la simultaneidad: paralelismo físico o tiempo compartido.

Aplicaciones de la concurrencia:

  • Servicios Web: soportar múltiples conexiones concurrentes, mejorar latencia.
  • Cálculo numérico: maximizar rendimiento con paralelismo real de CPU.
  • Procesamiento E/S: dispositivos independientes de CPU.
  • Simulación: objetos con comportamientos autónomos.
  • Aplicaciones GUI: no bloquear la interfaz con tareas largas.
  • Software basado en componentes: mejorar autonomía.
  • Código móvil (applets): aislar efectos de código desconocido.
  • Sistemas empotrados: tareas en tiempo real.

Programación Concurrente vs Programación Distribuida

La programación concurrente:

  • Se restringe a estructuras que afectan a una sola JVM.
  • Hilos compartiendo memoria dentro del mismo proceso.

La programación distribuida:

  • Múltiples JVM en distintos sistemas informáticos.
  • Comunicación por red (sockets).

Estructuras para la ejecución concurrente

Las estructuras que usamos para la ejecución concurrente cuentan con las siguientes características:

  • Autonomía e independencia
  • Administración: planificación, creación, gestión, comunicación.
  • Capacidad para compartir recursos subyacentes (CPU, memoria, canales E/S)
  • Comunicación: canales (sockets) / áreas de memoria compartidas / cerrojos

La jerarquía sigue la siguiente estructura:

  • Llamada → Tarea → Hilo → Proceso → Sistema Distribuido

Concurrencia y Programación Orientada a Objeto

Las principales diferencias que tiene la programación concurrente respecto a la programación secuencial orientada a objeto son:

  • Ejecuciones no deterministas
  • Comprensión de código no es secuencial.
  • Las interferencias entre actividades necesitan diseño conservador.

Transformaciones y modelo de objetos

Tenemos dos estrategias de transformación:

  • Que los objetos pasivos vivan en un contexto multihilo.
  • "Silenciar" objetos activos para expresarlos en estructuras de hilo.

Modelos mixtos: la programación concurrente deja la mayoría de las transformaciones a la JVM y al SO.

La programación concurrente vs paralela: el grado de control del desarrollador sobre las transformaciones los distingue.

Imposiciones de diseño

Los sistemas cuentan con dos perspectivas complementarias:

  • Centrado en objetos: conjunto de objetos
  • Centrado en la actividad: conjunto de actividades (mensajes, cadenas de llamadas, secuencia de eventos, tareas, sesiones)

Un objeto puede implicarse en varias actividades. Una actividad puede atravesar múltiples objetos.

Seguridad - Nunca sucede nada malo

Nuestro objetivo principal es asegurar que todos los objetos del sistema mantengan estados coherentes. Un estado coherente es aquel en el que todos los campos tienen valores legales y significativos.

Pueden existir distintos conflictos de almacenamiento a bajo nivel:

  • Lectura/Escritura: un hilo lee el valor de un campo mientras otro escribe en el mismo → dificil prever el valor leido.
  • Escritura/Escritura: dos hilos intentan escribir en el mismo campo → difícil conocer el valor de la siguiente lectura.

Utilizamos la exclusión para garantizar la atomicidad de las acciones públicas. Esto significa que cada acción se ejecuta hasta la terminación sin interferencias.

Mediante el uso de invariantes podemos proteger estos estados coherentes de los objetos del sistema. Algunos ejemplos son:

  • CuentaBancaria: saldo = depositos + intereses - reintegros.
  • BufferLimitado: contador de elementos siempre entre 0 y capacidad.
  • Contador: valor entero no negativo.

Vivacidad - Algo sucede eventualmente

En sistemas vivaces cada una de las actividades progresa hacia la terminación. Podemos encontrarnos con los siguientes tipos de bloqueos transitorios:

  • synchronized
  • wait()
  • E/S
  • Competencia CPU
  • Fallo

La vivacidad puede verse afectada cuando existen bloqueos permanentes:

  • Interbloqueo: dependencias circulares de cerrojos.
  • Señales perdidas: un hilo empieza a esperar después de la notificación.
  • Cerrojos anidados en un monitor: hilo espera para adquirir cerrojo que necesita otro hilo que debe despertarlo.
  • Falta de vivacidad: una acción repetida falla continuamente.
  • Inanición: JVM no puede asignar CPU
  • Falta de recursos: hilos tienen limites de recursos
  • Fallo distribuido: no accesible máquina remota por socket.

Rendimiento - Que se ejecute pronto y rápido

Para poder garantizar el rendimiento podemos aplicar las siguientes medidas:

  • Productividad
  • Latencia
  • Capacidad
  • Eficiencia
  • Escalabilidad
  • Degradación

En los diseños multihilo, se empeora la eficiencia para mejorar la latencia.

La concurrencia introduce una sobrecarga:

  • Cerrojos: mayor sobrecarga en métodos synchronized
  • Monitores: mayor corte que los cerrojos
  • Cambios de contexto entre hilos
  • Planificación de hilos
  • Sincronización de memoria entre CPUs

Tema 2: Programación Concurrente

Exclusión

Para garantizar la exclusión tenemos tres estrategias diferentes:

  • Eliminar la necesidad de exclusión, mediante la inmutabilidad.
  • Esto nos permite asegurar que los métodos nunca modifiquen la representación del objeto.
  • Garantizamos que nunca haya estados incoherentes.
  • Asegurar dinámicamente que solo uno de los hilos pueda acceder a la vez, esto podemos acometerlo mediante cerrojos (synchronized)
  • Asegurar estructuralmente que solo un hilo puede usar un objeto dado, mediante la ocultación o restricción de acceso (confinamiento)

La primera estrategia es la inmutabilidad

La segunda estrategia es el uso de cerrojos dinámicos.

La tercera estrategia es el confinamiento estructural.

La seguridad no se consigue con el compilador ni con pruebas funcionales. La responsabilidad es del programador.

Inmutabilidad

Si un objeto nunca puede cambiar de estado, nunca habrá conflictos

  • Las instancias no pueden tener conlictos lectura/escritura ni escritura/escritura
  • No pueden experimentar fallos de invariantes.

La inmutabilidad es aconsejable cuando crear nuevos objetos mediante copia es relativamente usual (esto significa que no es costoso).

Las instancias de clases inmutables deben seguir ciertas reglas:

  • No pueden tener conflictos lectura/escritura o escritura/escritura.
  • No pueden experimentar fallos de invariantes.
  • No siempre se puede aplicar, pero su uso es una herramienta básica.

Como construir una clase inmutable

Reglas para construir clases inmutables

  • Campos independientes del estado (sin campos internos).
class SumadorSinEstado {

	public int sumar (int a, int b){
		return a+b;
	}

}
  • Clases que solo poseen campos final.
class SumadorInmutable{
	private final int incremento;
}

Aplicaciones de las clases inmutables

  • Contenedores de valores: java.aut.Color, java.lang.Integer, java.lang.String.
  • Tipos donde la identidad no importa: Fracción, Intervalo, ComplexFloat
  • Redefinir equals() y hashCode() heredados de Object.
  • Los métodos que "modifican" devuelven nuevo objeto: fraccion.suma(f) devuelve new Fraccion(….)

Recomendaciones para garantizar inmutabilidad

  • Todos los campos private final
  • Sin setters (eliminar los métodos que modifiquen el estado)
  • Getters que devuelven copias si los campos son objetos mutables.
  • Redefinir equals() y hashCode()

Ejemplo de clase inmutable

class Fraccion{
	protected final long numerador; //FINAL
	protected final long denominador; //FINAL

	public Fraccion(long num, long den){
		// Normalización en el constructor.
	}

	public Fraccion suma(Fraccion f){
		// NO modifica this, devuelve NUEVO objeto
		return new Fraccion(numerador * f.denominador + f.numerador * denominador, denominador * f.denominador);
	}

	public boolean equals(Object other) { // redefinicion
	}
}

Sincronización

En la concurrencia, los bloqueos protegen contra conflictos de almacenamiento de bajo nivel y previenen los fallos de invariantes a alto nivel (evita que se violen postcondiciones).

Ejemplo de problema de sincronización.

class Par {
	private int n = 0;
	public int siguiente() {
		++n; ++n; // Post: n siempre es par
		assert n % 2 == 0;
		return n;
	}
}

Como puede verse en el código, dado que en cada iteración la variable n aumenta en dos ocasiones, el valor de la variable deberá ser par.

Al no tener synchronized, los hilos se ejecutarán de manera simultánea, y esto provoca que uno de los hilos pueda leer n = 1, incumpliendo la postcondición.

Bloques y métodos synchronized

Podemos utilizar la palabra clave synchronized para crear métodos y clases sincronizados.

  • Método sincronizado:

synchronized void f(){/* cuerpo */}

  • Bloque sincronizado (equivalente):

void f() { synchronized(this) {/* cuerpo */} }

Como funcionan los cerrojos

  • Se adquiere el cerrojo a la entrada del método/bloque synchronized
  • Se libera al salir, incluso si termina con excepción.
  • Los cerrojos funcionan en el contexto de los hilos, no de invocaciones.
  • Un método synchronized puede llamar a otro synchronized del mismo objeto sin bloquearse.

Objetos y cerrojos - Reglas importantes

  • Cada instancia de Object tiene un cerrojo
  • Los tipos primitivos (int, float…) no son objetos y no tienen cerrojo.
  • Los campos individuales no se pueden marcar con synchronized.
  • El bloqueo solo se aplica al uso de campos dentro de métodos.
  • El bloque de un array de Objects no bloquea sus elementos.
  • synchronized no forma parte de la signatura del método.
  • No se hereda la sincronización en subclases.
  • Interfaces no pueden tener métodos synchronized.
  • Los constructores no pueden ser synchronized (si pueden contener bloques synchronized)
  • Los métodos de instancia de la subclase usan el mismo cerrojo que su superclase.

Miembros estáticos

El bloqueo de un objeto no protege automaticamente los campos static. Los campos static se protegen con métodos/bloques synchronized static:

// Cerrojo estático de la clase C accesible desde métodos de instancia:

synchronized (C.class) { /* cuerpo*/ })

Malas prácticas en los miembros estáticos

  • synchronized (getClass()) {/* cuerpo */}

getClass() devuelve la clase real del objeto en tiempo de ejecución, que puede ser una subclase.

Esto significa que dos métodos que intentan proteger los mismos campos static podrían acabar usando cerrojos distintos si uno se ejecuta desde una instancia de la superclase y otro desde una instancia de la subclase.

class A {
    static int contador = 0;

    void incrementar() {
        synchronized (getClass()) { // ← si this es A, cerrojo = A.class
            contador++;             //   si this es B, cerrojo = B.class ← ¡distinto!
        }
    }
}

class B extends A { }

// Dos hilos:
new A().incrementar(); // adquiere cerrojo sobre A.class
new B().incrementar(); // adquiere cerrojo sobre B.class ← no se bloquean entre sí
  • Método synchronized static en subclase para proteger static de superclase.

Cada clase tiene su propio cerrojo de clase. El cerrojo de B.class es completamente independiente del cerrojo de A.class. Por lo tanto, si los campos static están declarados en A, añadir un método synchronized static en B para protegerlos no sirve de nada, porque usa el cerrojo equivocado.

class A {
    static int dato = 0; // campo static declarado en A
}

class B extends A {
    public synchronized static void incrementar() {
        // Este método adquiere el cerrojo de B.class
        // pero 'dato' pertenece a A → protegido por A.class
        dato++; // ← DESPROTEGIDO frente a accesos que usen A.class
    }
}

Objetos completamente sincronizados

Un objeto completamente sincronizado debe cumplir los siguientes principios:

  • Todos los métodos son synchronized
  • Todos los métodos son finitos (no bucles infinitos, el cerrojo se libera)
  • Todos los campos se inicializan en estado consistente en los constructores.
  • Estado consistente al principio y final de cada método (incluso con excepciones).

Los objetos completamente sincronizados no garantizan que no se produzca interbloqueo y tampoco garantizan la atomicidadde las operaciones compuestas.

Estrategias de recorrido en colecciones

Bloqueo en el lado del cliente

Collection c =
Collections.synchronizedCollection(myCollection);
synchronized(c){
	Iterator i = c.iterator(); // Must be in the synchronized block.
	while(i.hasNext()) foo(i.next());
	}
	-> El usuario sincroniza los recorridos
)}

Operaciones sincronizadas agregadas en la colección

Se abstrae la función a aplicar y se incorpora como método sincronizado interno.

Monitores

Las entidades que poseen tanto cerrojos como conjuntos de espera se denominan monitores.

java.lang.Object puede servir de monitor debido a que tiene un cerrojo (gestionado por synchronized) y un conjunto de espera (manipulado mediante wait(), notify(), notifyAll(), Thread.interrupt()).

Los métodos wait(), notify(), notifyAll() solo se pueden invocar cuando el objeto haya adquirido el manejo de la sincronización (es decir, dentro de un bloque o método synchronized sobre ese objeto).

Si se invoca fuera → se lanza IllegalMonitorStateException

IllegalMonitorStateException

Se lanza cuando se invoca wait(), notify() o notifyAll() sin tener el cerrojo del objeto.

Ejemplo:

obj.wait(); // fuera de synchronized
IllegalMonitorStateException

obj.notify(); // fuera del synchronized
IllegalMonitorStateException

obj.notifyAll(); // fuera de synchronized
IllegalMonitorStateException

synchronized(obj) {
	obj.wait(); // tenemos el cerrojo sobre obj
	obj.notify(); // tenemos el cerrojo sobre obj

}

// O bien en método synchronized
public synchronized void metodo() {
	wait(); // equivale a this.wait(), tenemos el cerrojo de this.
	notify(); // equivale a this.notify()
	notifyAll(); // equivale a this.notifyAll()

Diferencias entre wait(), notify() y notifyAll()

wait()

  • El hilo libera el cerrojo y pasa al conjunto de espera.
  • Se bloquea hasta que otro hilo llame notify()/notifyAll() o llegue interrupción.

notify()

  • Despierta un solo hilo del conjunto de espera (cuál es no determinista).
  • El hilo despertado vuelve a ejecutable y debe re-adquirir el cerrojo.

notifyAll()

  • Despierta todos los hilos del conjunto de espera.
  • Todos compiten por el cerrojo (solo uno lo adquirirá)

Es importante usar while (no if) para comprobar la condición tras despertar:

  synchronized(obj) {
  	while(!condicion) { // while, NO if
  		ob.wait();
  	}
  	// hacer el trabajo
  	obj.notifyAll();
  }

Con 'if', si el hilo despierta erróneamente (spurious wakeup) o si otro hilo tomó el recurso antes, el código procede aunque la condición no se cumpla. Con 'while' se vuelve a comprobar.

Singleton concurrente (EagerSingleton)

El patrón Singleton en entornos concurrentes, requiere de una solución especial.

class EagerSingletonCounter {
	private final long initial;
	private long count;

	private static final EagerSingletonCounter s = new EagerSingletonCounter(); // privado, estático y final)

	private EagerSingletonCounter() { // constructor privado
		initial = Math.abs(new java.util.Random().nextLong() / 2);
		count = initial;
	}

	public static EagerSingletonCounter instance () { // estático
		return s;
	}

	public synchronized long next() { return count++; }
	public synchronized void reset() { count = initial; }

}

ReentrantLock y Condition

java.util.concurrent.locks.ReentrantLock es una alternativa a synchronized.

java.util.concurrent.locks.Condition es la alternativa a wait/notify.

Si usas Lock, el método no debe ser 'synchronized'. Son mencanismos mutuamente excluyentes.

lock.lock();
try {
	while(!condicion) {
		condicion.await(); //equivale a wait()
	}
	// hacer trabajo
	otraCondicion.signal(); // equivale a notify() - o signalAll()
} finally {
	lock.unlock(); // siempre en finally
}

BufferLimitado con Lock/Condition

public class BufferLimitadoLock<T> {
    private T[] buffer;
    private int size, count, putIndex, getIndex;
    private Lock lock;
    private Condition bufferNotFull;
    private Condition bufferNotEmpty;

    public BufferLimitadoLock(int size) {
        this.size = size;
        this.buffer = (T[]) new Object[size];
        this.count = 0;
        this.putIndex = 0;
        this.getIndex = 0;
        this.lock = new ReentrantLock();
        this.bufferNotFull = lock.newCondition();
        this.bufferNotEmpty = lock.newCondition();
    }

    // SIN synchronized en la firma
    public void put(T item) throws InterruptedException {
        lock.lock();
        try {
            while (count == size) {          // buffer lleno: esperar
                bufferNotFull.await();
            }
            buffer[putIndex] = item;
            putIndex = (putIndex + 1) % size;
            count++;
            bufferNotEmpty.signal();         // avisar que ya hay algo
        } finally {
            lock.unlock();
        }
    }

    // SIN synchronized en la firma
    public T take() throws InterruptedException {
        lock.lock();
        try {
            while (count == 0) {             // buffer vacío: esperar
                bufferNotEmpty.await();
            }
            T item = buffer[getIndex];
            buffer[getIndex] = null;
            getIndex = (getIndex + 1) % size;
            count--;
            bufferNotFull.signal();          // avisar que ya hay hueco
            return item;
        } finally {
            lock.unlock();
        }
    }
}

Atomicidad - volatile vs synchronized

El modelo de memoria de Java permite lecturas/escrituras atómicas de celdas de memoria.

  • Tipos primitivos excepto long y double, tienen la atomicidad garantizada.
  • volatile exitenda la atomicidad a referencias a objetos, long y double.

La atomicidad no elimina la necesidad de sincronizar acciones atómicas.

Cuando usar volatile

Para poder usar volatile, deben cumplirse todas las condiciones siguientes:

  • El campo no necesita cumplir ningún invariante con respecto a otros campos.
  • La escritura del campo no depende de su valor actual.
  • Ningún hilo escribe nunca ningún valor ilegal.
  • Las acciones de los lectores no dependen de valores de otros campos no volátiles.

Ejemplo:

  • Termómetro → temperatura volatile (solo se lee/escribe, no hay invariante relacional)

Contra-ejemplo:

  • contador c++ → No es atómico (lee c, incrementa, escribe), necesita synchronized.

Confinamiento

El confinamiento asegura estructuralmente que solo un hilo pueda usar un objeto dado.

Es la tercera estrategia de exclusión (la más segura en términos de rendimiento).

Un objeto escapa si:

  • m pasa r como argumento en la invocación de un método.
  • m pasa r como valor de retorno.
  • m almacena r en un capo accesible desde otra actividad.
  • m libera otra referencia que da acceso alternativo a r.

Existen cuatro tipos de confinamientos:

Confinamiento de métodos

Si una invocación de un método crea un objeto y no lo deja escapar, ningún otro hilo interferirá. Es ocultación dentro de ámbitos locales.

Protocolo de llamada a final de método

  • En un momento dado como máximo un método activo tiene acceso al objeto.
  • Uso de métodos de fabricación (PD Factory Method)

Sesiones: un método de entrada construye los objetos y es responsable de la limpieza.

Protocolos alternativos

Aplicamos estos protocolos cuando no se puede usar "llamada final al método"

  • Copia en el llamador

muestra(new Point(p.x, p.y))

  • Copias en el receptor

Point puntoLocal = new Point(p.x, p.y)

  • Uso de argumentos escalares

muestra(p.x, p.y)

  • Confianza: el receptor se compromete a no modificar ni transmitir el objeto.

Confinamiento dentro de hilos + ThreadLocal

Un hilo por sesión (idéntico al confinamiento basado en sesiones).

new Thread(r).start() → cada sesión tiene su propio hilo y sus propios objetos.

java.lang.ThreadLocal

  • Permite datos específicos del hilo accesibles desde cualquier código
  • Mantiene una tabla que asocia datos a instancias del Thread actual
  • Proporciona set() y get()
  • Alternativa que elimina la dependencia de extensión sobre la clase Thread
  • Es extensión del patrón Singleton → crea una instancia de recurso por hilo accedida globalmente.

Confinamiento de objetos

Cuando no se puede restringir dentro de método o hilo → usar bloque dinámico.

El control de exclusión del objeto anfitrión se propaga automáticamente a todas sus partes internas.

Creación de subclases

  • Si se tiene la intención de confinar instancias de una clase dentro de otras → no hay razón para sincronizar sus métodos.
  • Si unas se confinan y otras no → lo más seguros es sincronizar todos sus métodos.

Confinamiento de grupos

Para grupos de objetos accesibles por múltiples hilos: asegurar que solo uno pueda tener acceso a un objeto recurso determinado.

Los recursos exclusivos son análogos a objetos físicos:

  • Si se posee un recurso → se puede hacer algo.
  • Si se posee → ningún otro lo tiene
  • Si se da → ya no se posee
  • Si se destruye → nadie lo poseerá

Protocolos

  • Adquirir: synchronized(this) { ref = r }
  • Olvidar: synchronized(this) { ref = null }
  • Dar (set): PropietarioY da r a PropietarioX
  • Coger (get): PropietarioY da r a PropietarioX (X pone ref = null y devuelve)
  • Intercambiar: PropietarioX intercambia su recurso r por s de PropietarioY.

Interbloqueo

Dependencias circulares de cerrojos.

class Celda {
	private long valor;
	synchronized long getValor() { return valor; }
	synchronized void setValor(long v) { valor = v; }
	synchronized void intercambiarValor (Celda otro) {
		long t = getValor();
		long v = otro.getValor(); // <- intenta adquirir cerrojo de 'otro'
		setValor(v);
		otro.setValor(t);
	}
}

// Dos hilos T1 y T2 con objetos a y b:
T1: a.intercambiarValor(b) -> adquiere cerrojo de 'a', intenta adquirir cerrojo de 'b'
T2: b.intercambiarValor(a) -> adquiere cerrojo de 'b', intenta adquirir cerrojo de 'a'
-> Interbloqueo: T1 espera a T2, T2 espera a T1

Tema 3: Programación Concurrente

Dependencias de estado

Las técnicas de exclusión preservan los invariantes del objeto. Las dependencias de estado imponen problemas adicionales: precondiciones y postcondiciones.

Existen dos estrategias de diseño para implementar las dependencias de estado:

Optimista

Impone vivacidad

  • Métodos "probar y ver": siempre se invocan pero no siempre tienen éxito.
  • Se basan en excepciones cuando no se satisfacen postcondiciones.

Conservador

Impone seguridad

  • Métodos "comprobar y actuar": se niegan a seguir si no se cumplen precondiciones.
  • Cuando las precondiciones son válidas, las acciones siempre tienen éxito.

Es posible combinar ambas estrategias de diseño de manera frecuente.

Como enfrentarse a fallos

Cuando una acción falla (postcondición no alcanzable), existen 6 respuestas posibles:

  • Finalización súbitaNullPointerException
  • Continuación → notificación fallida en listener de animación
  • Vuelta atrás → falta de interferencia con otros hilos
  • Avance o recuperación → alcanzar un punto seguro
  • Reintentos → limitar número de intentos + añadir retardos crecientes
  • Manejadores → delegar en manejadores centralizados (patrón Proxy)

Reintentos

Limitar intentos y añadir retardo temporal:

long delayTime = 5000 + (long) (Math.random() * 5000);
for(;;) {
	try { return new Socket(server, port); }
	catch(IOException ex) {
		Thread.sleep(delayTime);
		delayTime = delayTime * 3/2 + 1; // aumenta 50%
	}
}

Manejadores

Delegan el tratamiento en un manejador centralizado. Es más extensible y flexible cuando los clientes no saben como responder al fallo.

Cancelación - Thread.interrupt()

La cancelación es asíncrona, cancela actividades de otros hilos independientemente de lo que hagan.

Métodos

  • Thread.interrupt() → configura el estado de interrupción (No termina el hilo inmediatamente)
  • Thread.isInterrupted() → inspecciona el estado (no lo borra)
  • Thread.interrupted() → inspecciona y borra el estado.

Comportamiento

  • wait(), join(), sleep() comprueban automáticamente las interrupciones.
  • Si se interrumpen → lanzan InterruptedException y actualizan estado a false.
  • En bloqueos synchronized y E/S → los hilos no pueden comprobar interrupciones.

Métodos protegidos

Los métodos conservadores "comprobar y actuar" tienen 3 opciones si la precondición falla:

  • Rechazo → lanza una excepción
  • Suspensión protegida → suspende hasta que se cumpla la precondición.
  • Plazos temporales → espera un tiempo máximo (wait(long timeout))

Estados lógicos limitados

El siguiente ejemplo cuenta con un ContadorLimitado y un MIN = 0 y MAX = 10:

Estado Condición inc() dec()
superior getValor() == MAX no si
medio MIN < getValor() < MAX si si
inferior getValor() == MIN si no

Representación

Mediante una variable de rol (un campo que toma valores VACIO, PARCIAL, LLENO…)

Funcionamiento del Monitor de Hoare

Las entidades con cerrojo y conjunto de espera se llaman Monitores.

java.lang.Object puede ser monitor: tiene synchronized + wait/notify/notifyAll

Mecánica

wait()

  • JVM pone el hilo en el conjunto de espera del objeto.
  • Libera el cerrojo de sincronización (mantiene los demás cerrojos adquiridos).

wait(long milisegundos)

iIgual pero con tiempo máximo de espera.

notify()

  • Saca un hilo arbitrario del conjunto de espera.
  • Ese hilo (T) pasa a ejecutable, debe esperar para readquirir el cerrojo.
  • T no entra en ejecución hasta que el que llamó a notify() libere el cerrojo.
  • T reanuda desde el punto siguiente a su wait()

notifyAll()

Saca todos los hilos del conjunto de espera.

Thread.interrupt()

Como notify() pero al readquirir el cerrojo lanza InterruptedException.

Patrón correcto

Siempre hay que utilizar while, debido a que si usamos if, el hilo podría despertar sin cumplir la condición. Usando un bucle while nos aseguramos de que vuelva a comprobar.

public synchronized voic inc() throws InterruptedException {
	while (valor == MAX) // <- while no if
		wait();
	}
	setValor(valor + 1);
}

protected void setValor(long nuevo) {
	valor = nuevo;
	notifyAll(); // avisar a todos los que esperan
}

Uso de bibliotecas: ReentrantLock y Condition

ReentrantLock(boolean equidad)

  • true → el hilo que lleva más tiempo esperando entra primero → evita inanición
  • lock() → adquiere; si ocupado, espera
  • unlock() → libera (siempre en finally)
  • tryLock() → devuelve true si libre, false si ocupado (No bloquea)
  • lockInterruptibly() → adquiere salvo que el hilo sea interrumpido.

Condition (de lock.newCondition())

  • await() → libera el Lock y espera (equivale a wait())
  • signal() → despierta el hilo con más tiempo en espera (equivale a notify())
  • signalAll() → despierta a todos (equivale a notifyAll())

Patrón correcto

lock.lock();
try {
	while (!cond) {
		condicion1.await();
	}
	cond = false;
	condicion2.signalAll();
} finally {
	lock.unlock();
}

Patrón Productor-Consumidor

El patrón productor-consumidor, busca coordinar la producción y el consumo asíncrono de información.

Debe aplicarse cuando los objetos se producen o reciben de forma asíncrona para su consumo. Puede no haber objetos disponibles para ser usados o consumidos.

El patrón tiene la siguiente estructura:

Productor (Runnable) → produce datos → objeto pasivo Dato → Consumidor (Runnable)

Ejemplo de Implementación con objeto dato con synchronized

public synchronized void setDato(int val) {
	while (!sepuedeescribir) wait();
}
	dato = val;
	sepuedeescribir = false;
	notify();
}
public synchronized int getDato(){
	while (sepuedeescribir) wait(); // esperar si no hay dato
	sepuedeescribir = true;
	notify();
	return datp;

Patrón Bloqueo Lectura/Escritura

El patrón bloqueo lectura/escritura permite lecturas concurrentes y escrituras exclusivas.

Se asegura de que las lecturas concurrentes sean seguras si no hay escrituras ejecutándose.

De igual manera, garantiza las escrituras exclusivas, pero requiere que no haya lectores ni escritores activos.

Precondiciones

  • allowReader(): waitingWriters == 0 && activeWriters == 0
  • allowWriter(): activeReaders == 0 && activeWriters == 0

Variables de estado

  • activeReaders, activeWriters (ejecutando ahora)
  • waitingReaders, waitingWriters (esperando)

Ejemplo simplificado con ReentrantReadWriteLock

final ReadWriteLock lock = new ReentrantReadWriteLock();

// Lectura (múltiples hilos pueden tenerlo simultáneamente)
try { /* leer */ } finally { lock.readLock().unlock(); }

// Escritura (exclusivo)
lock.writeLock().lock();
try { /* escribir */ } finally { lock.writeLock().unlock(); }