JavaRush /Blog Java /Random-FR /Deadlock en Java et méthodes pour le combattre
articles
Niveau 15

Deadlock en Java et méthodes pour le combattre

Publié dans le groupe Random-FR
Lors du développement d’applications multithread, un dilemme se pose souvent : ce qui importe le plus, c’est la fiabilité ou les performances de l’application. Par exemple, nous utilisons la synchronisation pour la sécurité des threads, et dans les cas où l'ordre de synchronisation est incorrect, nous pouvons provoquer des blocages. Nous utilisons également des pools de threads et des sémaphores pour limiter la consommation de ressources, et une erreur dans cette conception peut conduire à une impasse en raison du manque de ressources. Dans cet article, nous expliquerons comment éviter les blocages, ainsi que d'autres problèmes liés aux performances de l'application. Nous verrons également comment une application peut être écrite de manière à pouvoir récupérer en cas de blocage. Deadlock en Java et méthodes pour le combattre - 1L'impasse est une situation dans laquelle deux ou plusieurs processus occupant certaines ressources tentent d'acquérir d'autres ressources occupées par d'autres processus et aucun des processus ne peut occuper la ressource dont il a besoin et, par conséquent, libérer celle occupée. Cette définition est trop générale et donc difficile à comprendre ; pour une meilleure compréhension, nous examinerons les types de blocages à l'aide d'exemples.

Verrouillage mutuel de l'ordre de synchronisation

Considérez la tâche suivante : vous devez écrire une méthode qui effectue une transaction pour transférer une certaine somme d'argent d'un compte à un autre. La solution pourrait ressembler à ceci :
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);
			}
		}
	}
}
À première vue, ce code est synchronisé tout à fait normalement, nous avons une opération atomique de vérification et de changement de l'état du compte source et de changement du compte de destination. Cependant, avec cette stratégie de synchronisation, une situation de blocage peut survenir. Regardons un exemple de la façon dont cela se produit. Il est nécessaire d'effectuer deux transactions : transférer x argent du compte A vers le compte B, et transférer y argent du compte B vers le compte A. Souvent, cette situation ne provoquera pas de blocage, cependant, dans des circonstances malheureuses, la transaction 1 occupera le moniteur de compte A, la transaction 2 occupera le moniteur de compte B. Le résultat est une impasse : la transaction 1 attend que la transaction 2 libère le moniteur de compte. B, mais la transaction 2 doit accéder au moniteur A, qui est occupé par la transaction 1. L'un des gros problèmes des blocages est qu'ils ne sont pas faciles à trouver lors des tests. Même dans la situation décrite dans l'exemple, les threads peuvent ne pas se bloquer, c'est-à-dire que cette situation ne se reproduira pas constamment, ce qui complique considérablement le diagnostic. En général, le problème du non-déterminisme décrit est typique du multithreading (même si cela ne facilite pas les choses). La revue de code joue donc un rôle important dans l’amélioration de la qualité des applications multithread, car elle permet d’identifier les erreurs difficiles à reproduire lors des tests. Bien entendu, cela ne signifie pas que l’application n’a pas besoin d’être testée ; nous ne devons tout simplement pas oublier la révision du code. Que dois-je faire pour éviter que ce code ne provoque un blocage ? Ce blocage est dû au fait que la synchronisation des comptes peut s'effectuer dans un ordre différent. En conséquence, si vous introduisez un peu d'ordre dans les comptes (c'est une règle qui vous permet de dire que le compte A est inférieur au compte B), alors le problème sera éliminé. Comment faire? Premièrement, si les comptes ont une sorte d'identifiant unique (par exemple, un numéro de compte) numérique, minuscule ou autre avec un concept naturel d'ordre (les chaînes peuvent être comparées dans un ordre lexicographique, alors nous pouvons nous considérer chanceux, et nous le ferons toujours Nous pouvons d'abord occuper le moniteur du plus petit compte, puis du plus grand (ou vice versa).
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)}
			}
		}
	}
}
Deuxième option, si nous ne disposons pas d’un tel identifiant, nous devrons le trouver nous-mêmes. On peut, en première approximation, comparer des objets par code de hachage. Très probablement, ils seront différents. Mais que se passe-t-il s’ils s’avèrent identiques ? Ensuite, vous devrez ajouter un autre objet pour la synchronisation. Cela peut paraître un peu sophistiqué, mais que pouvez-vous faire ? Et d’ailleurs, le troisième objet sera assez rarement utilisé. Le résultat ressemblera à ceci :
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)
				}
			}
		}
	}
}

Impasse entre les objets

Les conditions de blocage décrites représentent le cas de blocage le plus simple à diagnostiquer. Souvent, dans les applications multithread, différents objets tentent d'accéder aux mêmes blocs synchronisés. Cela peut provoquer une impasse. Prenons l'exemple suivant : une application de régulateur de vol. Les avions informent le contrôleur lorsqu'ils sont arrivés à destination et demandent l'autorisation d'atterrir. Le contrôleur stocke toutes les informations sur les avions volant dans sa direction et peut tracer leur position sur la carte.
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;
	}
}
Comprendre qu'il y a un bug dans ce code qui peut conduire à une impasse est plus difficile que dans le précédent. À première vue, il n’y a pas de resynchronisation, mais ce n’est pas le cas. Vous avez probablement déjà remarqué que setLocationles classes Planeet les méthodes getMapde classe Dispatchersont synchronisées et appellent les méthodes synchronisées d'autres classes en elles-mêmes. C'est généralement une mauvaise pratique. La façon dont cela peut être corrigé sera discutée dans la section suivante. En conséquence, si l’avion arrive sur place, au moment où quelqu’un décide d’obtenir la carte, une impasse peut se produire. Autrement dit, les méthodes getMapet seront appelées, setLocationqui occuperont respectivement les moniteurs d'instance Dispatcheret . PlaneLa méthode getMapappellera alors plane.getLocation(notamment sur l'instance Planeactuellement occupée) qui attendra que le moniteur se libère pour chacune des instances Plane. En même temps, la méthode setLocationsera appelée dispatcher.requestLanding, tandis que le moniteur d'instance Dispatcherreste occupé à dessiner la carte. Le résultat est une impasse.

Appels ouverts

Afin d'éviter des situations comme celle décrite dans la section précédente, il est recommandé d'utiliser des appels publics aux méthodes d'autres objets. Autrement dit, appelez les méthodes d’autres objets en dehors du bloc synchronisé. Si les méthodes sont réécrites en utilisant le principe des appels ouverts setLocation, getMapla possibilité d'un blocage sera éliminée. Cela ressemblera par exemple à ceci :
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;
}

Impasse des ressources

Des blocages peuvent également se produire lors de la tentative d'accès à certaines ressources qu'un seul thread peut utiliser à la fois. Un exemple serait un pool de connexions à une base de données. Si certains threads doivent accéder à deux connexions en même temps et y accèdent dans des ordres différents, cela peut conduire à une impasse. Fondamentalement, ce type de verrouillage n'est pas différent du verrouillage par ordre de synchronisation, sauf qu'il ne se produit pas lors de la tentative d'exécution d'un code, mais lors de la tentative d'accès aux ressources.

Comment éviter les blocages ?

Bien sûr, si le code est écrit sans aucune erreur (dont nous avons vu des exemples dans les sections précédentes), il n'y aura pas de blocage. Mais qui peut garantir que son code est écrit sans erreurs ? Bien sûr, les tests permettent d'identifier une partie importante des erreurs, mais comme nous l'avons vu précédemment, les erreurs dans le code multithread ne sont pas faciles à diagnostiquer et même après les tests, vous ne pouvez pas être sûr qu'il n'y a pas de situations de blocage. Pouvons-nous d’une manière ou d’une autre nous protéger du blocage ? La réponse est oui. Des techniques similaires sont utilisées dans les moteurs de bases de données, qui doivent souvent se remettre des blocages (associés au mécanisme de transaction dans la base de données). L'interface Locket ses implémentations disponibles dans le package java.util.concurrent.lockspermettent de tenter d'occuper le moniteur associé à une instance de cette classe à l'aide de la méthode tryLock(renvoie true s'il était possible d'occuper le moniteur). Supposons que nous ayons une paire d'objets qui implémentent une interface Locket que nous devions occuper leurs moniteurs de manière à éviter un blocage mutuel. Vous pouvez l'implémenter comme ceci :
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();
			}
		}
	}
}
Comme vous pouvez le voir dans ce programme, nous occupons deux moniteurs, tout en éliminant la possibilité de blocage mutuel. Veuillez noter que le blocage try- finallyest nécessaire car les classes du package java.util.concurrent.locksne libèrent pas automatiquement le moniteur, et si une exception se produit lors de l'exécution de votre tâche, le moniteur sera bloqué dans un état verrouillé. Comment diagnostiquer les blocages ? La JVM vous permet de diagnostiquer les blocages en les affichant dans des thread dumps. Ces sauvegardes incluent des informations sur l'état dans lequel se trouve le thread. S'il est bloqué, le dump contient des informations sur le moniteur dont le thread attend la libération. Avant de vider les threads, la JVM examine le graphique des moniteurs en attente (occupés) et si elle trouve des cycles, elle ajoute des informations de blocage, indiquant les moniteurs et les threads participants. Un vidage de threads bloqués ressemble à ceci :
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)
Le dump ci-dessus montre clairement que deux threads travaillant avec la base de données se sont bloqués. Afin de diagnostiquer les blocages à l'aide de cette fonctionnalité JVM, il est nécessaire d'appeler l'opération de vidage de thread à divers endroits du programme et de tester l'application. Ensuite, vous devez analyser les journaux résultants. S'ils indiquent qu'un blocage s'est produit, les informations du dump aideront à détecter les conditions dans lesquelles il s'est produit. En général, vous devez éviter les situations comme celles des exemples de blocage. Dans de tels cas, l'application fonctionnera probablement de manière stable. Mais n'oubliez pas les tests et la révision du code. Cela aidera à identifier les problèmes s’ils surviennent. Dans les cas où vous développez un système pour lequel la récupération du champ de blocage est critique, vous pouvez utiliser la méthode décrite dans la section « Comment éviter les blocages ? ». Dans ce cas, la méthode lockInterruptiblyd'interface Lockdu java.util.concurrent.locks. Il permet d'interrompre le thread qui a occupé le moniteur grâce à cette méthode (et ainsi de libérer le moniteur).
Commentaires
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION