JavaRush /Blog Java /Random-ES /Punto muerto en Java y métodos para combatirlo
articles
Nivel 15

Punto muerto en Java y métodos para combatirlo

Publicado en el grupo Random-ES
Al desarrollar aplicaciones multiproceso, a menudo surge un dilema: lo que es más importante es la confiabilidad o el rendimiento de la aplicación. Por ejemplo, utilizamos la sincronización para la seguridad de los subprocesos y, en los casos en que el orden de sincronización sea incorrecto, podemos provocar interbloqueos. También utilizamos grupos de subprocesos y semáforos para limitar el consumo de recursos, y un error en este diseño puede provocar un punto muerto por falta de recursos. En este artículo hablaremos sobre cómo evitar el punto muerto, así como otros problemas en el rendimiento de la aplicación. También veremos cómo se puede escribir una aplicación de tal manera que pueda recuperarse en casos de bloqueo. Punto muerto en Java y métodos para combatirlo - 1Un punto muerto es una situación en la que dos o más procesos que ocupan algunos recursos intentan adquirir otros recursos ocupados por otros procesos y ninguno de los procesos puede ocupar el recurso que necesita y, en consecuencia, liberar el ocupado. Esta definición es demasiado general y, por tanto, difícil de entender; para una mejor comprensión, veremos los tipos de interbloqueos mediante ejemplos.

Orden de sincronización Bloqueo mutuo

Considere la siguiente tarea: necesita escribir un método que realice una transacción para transferir una determinada cantidad de dinero de una cuenta a otra. La solución podría verse así:
public void transferMoney(Account fromAccount, Account toAccount, Amount amount) throws InsufficientFundsException {
	synchronized (fromAccount) {
		synchronized (toAccount) {
			if (fromAccount.getBalance().compareTo(amount) < 0)
				throw new InsufficientFundsException();
			else {
				fromAccount.debit(amount);
				toAccount.credit(amount);
			}
		}
	}
}
A primera vista, este código se sincroniza con bastante normalidad; tenemos una operación atómica de verificar y cambiar el estado de la cuenta de origen y cambiar la cuenta de destino. Sin embargo, con esta estrategia de sincronización, puede ocurrir una situación de punto muerto. Veamos un ejemplo de cómo sucede esto. Es necesario realizar dos transacciones: transferir x dinero de la cuenta A a la cuenta B y transferir y dinero de la cuenta B a la cuenta A. A menudo, esta situación no causará un punto muerto; sin embargo, en un conjunto desafortunado de circunstancias, la transacción 1 ocupará el monitor de cuenta A, la transacción 2 ocupará el monitor de cuenta B. El resultado es un punto muerto: la transacción 1 espera a que la transacción 2 libere el monitor de cuenta. B, pero la transacción 2 debe acceder al monitor A, que está ocupado por la transacción 1. Uno de los grandes problemas con los interbloqueos es que no son fáciles de encontrar en las pruebas. Incluso en la situación descrita en el ejemplo, es posible que los subprocesos no se bloqueen, es decir, esta situación no se reproducirá constantemente, lo que complica significativamente el diagnóstico. En general, el problema descrito del no determinismo es típico del subproceso múltiple (aunque esto no lo hace más fácil). Por lo tanto, la revisión del código juega un papel importante en la mejora de la calidad de las aplicaciones multiproceso, ya que permite identificar errores que son difíciles de reproducir durante las pruebas. Esto, por supuesto, no significa que no sea necesario probar la aplicación; simplemente no debemos olvidarnos de la revisión del código. ¿Qué debo hacer para evitar que este código provoque un punto muerto? Este bloqueo se debe al hecho de que la sincronización de cuentas puede ocurrir en un orden diferente. En consecuencia, si introduce algún orden en las cuentas (esta es una regla que le permite decir que la cuenta A es menor que la cuenta B), el problema se eliminará. ¿Cómo hacerlo? En primer lugar, si las cuentas tienen algún tipo de identificador único (por ejemplo, un número de cuenta) numérico, minúscula o algún otro con un concepto natural de orden (las cadenas se pueden comparar en orden lexicográfico), entonces podemos considerarnos afortunados y lo haremos. Siempre podemos ocupar primero el monitor de la cuenta más pequeña, y luego la más grande (o viceversa).
private void doTransfer(final Account fromAcct, final Account toAcct, final DollarAmount amount) throws InsufficientFundsException {
	if (fromAcct.getBalance().compareTo(amount) < 0)
		throw new InsufficientFundsException();
	else {
		fromAcct.debit(amount);
		toAcct.credit(amount);
	}
}
public void transferMoney(final Account fromAcct, final Account toAcct, final DollarAmount amount) throws InsufficientFundsException {
	int fromId= fromAcct.getId();
	int toId = fromAcct.getId();
	if (fromId < toId) {
		synchronized (fromAcct) {
			synchronized (toAcct) {
				doTransfer(fromAcct, toAcct, amount)}
			}
		}
	} else  {
		synchronized (toAcct) {
			synchronized (fromAcct) {
				doTransfer(fromAcct, toAcct, amount)}
			}
		}
	}
}
La segunda opción, si no tenemos dicho identificador, tendremos que crearlo nosotros mismos. Podemos, en una primera aproximación, comparar objetos mediante código hash. Lo más probable es que sean diferentes. ¿Pero qué pasa si resultan ser iguales? Luego tendrás que agregar otro objeto para la sincronización. Puede parecer un poco sofisticado, pero ¿qué puedes hacer? Y además, el tercer objeto rara vez se utilizará. El resultado se verá así:
private static final Object tieLock = new Object();
private void doTransfer(final Account fromAcct, final Account toAcct, final DollarAmount amount) throws InsufficientFundsException {
	if (fromAcct.getBalance().compareTo(amount) < 0)
		throw new InsufficientFundsException();
	else {
		fromAcct.debit(amount);
		toAcct.credit(amount);
	}
}
public void transferMoney(final Account fromAcct, final Account toAcct, final DollarAmount amount) throws InsufficientFundsException {
	int fromHash = System.identityHashCode(fromAcct);
	int toHash = System.identityHashCode(toAcct);
	if (fromHash < toHash) {
		synchronized (fromAcct) {
			synchronized (toAcct) {
				doTransfer(fromAcct, toAcct, amount);
			}
		}
	} else if (fromHash > toHash) {
		synchronized (toAcct) {
			synchronized (fromAcct) {
				doTransfer(fromAcct, toAcct, amount);
			}
		}
	} else {
		synchronized (tieLock) {
			synchronized (fromAcct) {
				synchronized (toAcct) {
					doTransfer(fromAcct, toAcct, amount)
				}
			}
		}
	}
}

Punto muerto entre objetos

Las condiciones de bloqueo descritas representan el caso de interbloqueo más fácil de diagnosticar. A menudo, en aplicaciones multiproceso, diferentes objetos intentan acceder a los mismos bloques sincronizados. Esto puede causar un punto muerto. Considere el siguiente ejemplo: una aplicación de despachador de vuelo. Los aviones avisan al controlador cuando han llegado a su destino y solicitan permiso para aterrizar. El controlador almacena toda la información sobre los aviones que vuelan en su dirección y puede trazar su posición en el mapa.
class Plane {
	private Point location, destination;
	private final Dispatcher dispatcher;

	public Plane(Dispatcher dispatcher) {
		this.dispatcher = dispatcher;
	}
	public synchronized Point getLocation() {
		return location;
	}
	public synchronized void setLocation(Point location) {
		this.location = location;
		if (location.equals(destination))
		dispatcher.requestLanding(this);
	}
}

class Dispatcher {
	private final Set<Plane> planes;
	private final Set<Plane> planesPendingLanding;

	public Dispatcher() {
		planes = new HashSet<Plane>();
		planesPendingLanding = new HashSet<Plane>();
	}
	public synchronized void requestLanding(Plane plane) {
		planesPendingLanding.add(plane);
	}
	public synchronized Image getMap() {
		Image image = new Image();
		for (Plane plane : planes)
			image.drawMarker(plane.getLocation());
		return image;
	}
}
Comprender que hay un error en este código que puede provocar un punto muerto es más difícil que en el anterior. A primera vista no tiene resincronizaciones, pero no es así. Probablemente ya hayas notado que setLocationla clase Planey los métodos getMapde clase Dispatcherestán sincronizados y llaman a métodos sincronizados de otras clases dentro de ellos mismos. En general, esta es una mala práctica. En la siguiente sección se analizará cómo se puede corregir esto. Como resultado, si el avión llega al lugar, en el momento en que alguien decide obtener la tarjeta, puede producirse un punto muerto. Es decir, se llamarán los métodos getMapy , setLocationque ocuparán los monitores de instancia Dispatchery Planerespectivamente. Luego, el método getMapllamará plane.getLocation(específicamente a la instancia Planeque está ocupada actualmente) y esperará a que el monitor quede libre para cada una de las instancias Plane. Al mismo tiempo, setLocationse llamará al método dispatcher.requestLanding, mientras el monitor de instancia Dispatcherpermanece ocupado dibujando el mapa. El resultado es un punto muerto.

Convocatorias abiertas

Para evitar situaciones como la descrita en el apartado anterior, se recomienda utilizar llamadas públicas a métodos de otros objetos. Es decir, llamar a métodos de otros objetos fuera del bloque sincronizado. Si los métodos se reescriben utilizando el principio de llamadas abiertas setLocation, getMapse eliminará la posibilidad de un punto muerto. Se verá, por ejemplo, así:
public void setLocation(Point location) {
	boolean reachedDestination;
	synchronized(this){
		this.location = location;
		reachedDestination = location.equals(destination);
	}
	if (reachedDestination)
		dispatcher.requestLanding(this);
}
………………………………………………………………………………
public Image getMap() {
	Set<Plane> copy;
	synchronized(this){
		copy = new HashSet<Plane>( planes);
	}
	Image image = new Image();
	for (Plane plane : copy)
		image.drawMarker(plane.getLocation());
	return image;
}

Estancamiento de recursos

También pueden ocurrir interbloqueos al intentar acceder a algunos recursos que solo un subproceso puede usar a la vez. Un ejemplo sería un grupo de conexiones de base de datos. Si algunos subprocesos necesitan acceder a dos conexiones al mismo tiempo y acceden a ellas en diferentes órdenes, esto puede provocar un punto muerto. Fundamentalmente, este tipo de bloqueo no es diferente del bloqueo de orden de sincronización, excepto que no ocurre cuando se intenta ejecutar algún código, sino cuando se intenta acceder a recursos.

¿Cómo evitar puntos muertos?

Por supuesto, si el código se escribe sin errores (ejemplos de los cuales vimos en las secciones anteriores), entonces no habrá puntos muertos en él. ¿Pero quién puede garantizar que su código esté escrito sin errores? Por supuesto, las pruebas ayudan a identificar una parte importante de los errores, pero como hemos visto anteriormente, los errores en el código multiproceso no son fáciles de diagnosticar e incluso después de las pruebas no se puede estar seguro de que no haya situaciones de bloqueo. ¿Podemos de alguna manera protegernos del bloqueo? La respuesta es sí. Se utilizan técnicas similares en los motores de bases de datos, que a menudo necesitan recuperarse de bloqueos (asociados con el mecanismo de transacción en la base de datos). La interfaz Locky sus implementaciones disponibles en el paquete java.util.concurrent.locksle permiten intentar ocupar el monitor asociado a una instancia de esta clase usando el método tryLock(devuelve verdadero si fue posible ocupar el monitor). Supongamos que tenemos un par de objetos que implementan una interfaz Locky necesitamos ocupar sus monitores de tal manera que evitemos el bloqueo mutuo. Puedes implementarlo así:
public void twoLocks(Lock A,  Lock B){
	while(true){
		if(A.tryLock()){
			if(B.tryLock())
			{
				try{
					//do something
				} finally{
					B.unlock();
					A.unlock();
				}
			} else{
				A.unlock();
			}
		}
	}
}
Como puedes ver en este programa, ocupamos dos monitores, eliminando la posibilidad de bloqueo mutuo. Tenga en cuenta que el bloqueo try- finallyes necesario porque las clases del paquete java.util.concurrent.locksno liberan automáticamente el monitor y, si se produce alguna excepción durante la ejecución de su tarea, el monitor quedará bloqueado en un estado bloqueado. ¿Cómo diagnosticar puntos muertos? La JVM le permite diagnosticar interbloqueos mostrándolos en volcados de subprocesos. Dichos volcados incluyen información sobre el estado en el que se encuentra el hilo. Si está bloqueado, el volcado contiene información sobre el monitor que el subproceso está esperando para ser liberado. Antes de volcar subprocesos, la JVM mira el gráfico de monitores en espera (ocupados) y, si encuentra ciclos, agrega información de interbloqueo, indicando los monitores y subprocesos participantes. Un volcado de hilos estancados se ve así:
Found one Java-level deadlock:
=============================
"ApplicationServerThread":
waiting to lock monitor 0x0f0d80cc (a MyDBConnection),
which is held by "ApplicationServerThread"
"ApplicationServerThread":
waiting to lock monitor 0x0f0d8fed (a MyDBCallableStatement),
which is held by "ApplicationServerThread"
Java stack information for the threads listed above:
"ApplicationServerThread":
at MyDBConnection.remove_statement
- waiting to lock <0x6f50f730> (a MyDBConnection)
at MyDBStatement.close
- locked <0x604ffbb0> (a MyDBCallableStatement)
...
"ApplicationServerThread":
at MyDBCallableStatement.sendBatch
- waiting to lock <0x604ffbb0> (a MyDBCallableStatement)
at MyDBConnection.commit
- locked <0x6f50f730> (a MyDBConnection)
El volcado anterior muestra claramente que dos subprocesos que trabajan con la base de datos se han bloqueado entre sí. Para diagnosticar interbloqueos utilizando esta función JVM, es necesario realizar llamadas a la operación de volcado de subprocesos en varios lugares del programa y probar la aplicación. A continuación, debes analizar los registros resultantes. Si indican que se ha producido un punto muerto, la información del volcado ayudará a detectar las condiciones bajo las cuales ocurrió. En general, debes evitar situaciones como las de los ejemplos de interbloqueo. En tales casos, lo más probable es que la aplicación funcione de manera estable. Pero no se olvide de las pruebas y la revisión del código. Esto ayudará a identificar problemas si ocurren. En los casos en los que esté desarrollando un sistema para el cual la recuperación del campo de interbloqueo es crítica, puede utilizar el método descrito en la sección "¿Cómo evitar interbloqueos?". En este caso, el método lockInterruptiblyde interfaz Lockdel archivo java.util.concurrent.locks. Te permite interrumpir el hilo que ha ocupado el monitor usando este método (y así liberar el monitor).
Comentarios
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION