Starvation (famine de thread) est une situation dans laquelle un thread ne peut pas accéder à une ressource nécessaire pour continuer son travail, bien qu'il soit prêt à s'exécuter. Selon Baeldung (Java Thread Starvation, 2024), la famine survient en raison d'une ordonnancement injuste, où les threads de faible priorité sont constamment reportés au profit de ceux de priorité plus élevée. Contrairement à Deadlock, Starvation ne bloque pas le thread — il reste dans l'état RUNNABLE mais n'obtient jamais de temps CPU.
Points Clés
Starvation (famine de thread) est un problème de programmation multithread où un thread ne peut pas accéder à une ressource nécessaire pour accomplir sa tâche, bien que la ressource ne soit pas verrouillée en permanence par un autre thread. Le thread est dans l'état RUNNABLE, mais l'ordonnanceur ou le mécanisme de synchronisation reporte systématiquement son exécution au profit d'autres threads.
Dans le développement mobile, Starvation se manifeste comme une exécution inégale des tâches : certaines opérations s'exécutent instantanément, tandis que d'autres subissent des retards catastrophiques. Par exemple, un thread de synchronisation de données en arrière-plan peut ne jamais obtenir l'accès à la base de données si le thread UI et les gestionnaires d'animation le devancent constamment. Selon Android Developer Blog (Performance Matters, 2023), environ 12 % des images perdues (jank) sur Android sont causées par Starvation de tâches en arrière-plan dont dépend le rendu.
La différence clé entre Starvation et Deadlock est la réversibilité. Si la charge du système diminue ou si les priorités sont redistribuées, le thread affamé peut acquérir la ressource et terminer son travail. Cependant, sous une charge élevée soutenue, Starvation peut durer indéfiniment, créant l'impression d'une application gelée.
synchronized en Java et Kotlin est un exemple classique de mécanisme injuste. Sous forte contention, la JVM peut accorder continuellement le verrou aux mêmes threads actifs, tandis que d'autres threads perdent constamment la course. Ce n'est pas un bug de la JVM mais un compromis de conception : les verrous injustes offrent un débit plus élevé au détriment de l'équité d'accès. Pour les applications mobiles avec 4 à 8 threads, ce problème est particulièrement pertinent.
Définir des priorités de threads différentes peut entraîner Starvation des threads de faible priorité. Dans Android Runtime, l'ordonnanceur CFS (Completely Fair Scheduler) de Linux distribue le temps CPU proportionnellement aux priorités, et si les threads de haute priorité sont constamment actifs, les threads de faible priorité peuvent ne jamais obtenir de temps CPU. Google déconseille fortement de modifier les priorités des threads dans Android — le système les gère lui-même.
Si un thread maintient un verrou trop longtemps (effectuant des calculs lourds, des requêtes réseau ou des opérations fichier dans un bloc synchronized), les autres threads attendant ce verrou souffrent de famine. C'est particulièrement dangereux dans Android, où les opérations longues sur le thread UI provoquent des ANR, et les déplacer vers des threads d'arrière-plan sans optimiser les sections critiques ne fait que transférer le problème de Starvation aux threads workers.
Considérons un exemple où un thread acquiert un verrou trop fréquemment en raison d'un ordonnancement injuste. Starvation est démontrée par une boucle infinie d'un thread de haute priorité qui empêche un thread de faible priorité d'accéder à une ressource partagée.
class SharedResource {
private val lock = Any()
fun criticalSection(id: String) {
synchronized(lock) {
println("$id a obtenu l'accès")
Thread.sleep(10) // simulation de travail
}
}
}
fun main() {
val resource = SharedResource()
// Thread de haute priorité — constamment actif
val highPriority = Thread {
while (true) {
resource.criticalSection("High")
}
}
// Thread de faible priorité — peut ne jamais obtenir l'accès
val lowPriority = Thread {
while (true) {
resource.criticalSection("Low")
}
}
highPriority.start()
lowPriority.start()
// "Low" peut ne jamais imprimer de message — Starvation !
}
Dans cet exemple, le thread highPriority acquiert constamment le verrou et ne le libère que pendant 10 ms. En raison de la nature injuste de synchronized, l'ordonnanceur JVM accordera très probablement le verrou à nouveau au même thread qui vient de le libérer — le thread de faible priorité souffre de famine. La solution est d'utiliser ReentrantLock(true) avec le flag fair, qui garantit un ordre d'attente FIFO.
La version corrigée avec un verrou équitable assure une distribution équitable de l'accès à la ressource.
class FairSharedResource {
private val fairLock = ReentrantLock(true) // fair = true
fun criticalSection(id: String) {
fairLock.lock()
try {
println("$id a obtenu l'accès (fair)")
Thread.sleep(10)
} finally {
fairLock.unlock()
}
}
}
Trois problèmes classiques de multithreading — Starvation, Deadlock et Livelock — sont souvent regroupés, mais leurs mécanismes et solutions diffèrent. Starvation — le thread est prêt mais ne peut pas acquérir la ressource. Deadlock — les threads sont bloqués par attente cyclique. Livelock — les threads sont actifs mais ne progressent pas.
| Paramètre | Starvation | Deadlock | Livelock |
|---|---|---|---|
| État du thread | RUNNABLE | BLOCKED | RUNNABLE |
| Progrès | Aucun | Aucun | Aucun (bien qu'actif) |
| Utilisation CPU | Faible | Minimale | Élevée (jusqu'à 100 %) |
| Cause | Ordonnancement injuste | Attente cyclique | Même réponse au conflit |
| Correction principale | Fair Lock, raccourcir les sections critiques | Hiérarchie de verrous | Limite de tentatives, exponential backoff |
Starvation est considéré moins critique que Deadlock car il n'est pas fatal — sous charge réduite, le thread affamé finira par s'exécuter. Cependant, dans l'utilisation réelle d'Android, où la mémoire et le CPU sont limités, Starvation peut durer des minutes, créant une expérience utilisateur inacceptable.
Thread Dump pris à plusieurs reprises à intervalles courts — la méthode de base pour détecter la famine. Si un thread est constamment dans l'état RUNNABLE mais que sa pile d'appels ne change pas sur plusieurs dumps — c'est un signe classique de Starvation. Dans Android Studio, utilisez Android Profiler avec enregistrement de l'état des threads dans le temps.
La détection automatisée est possible via la surveillance du temps d'exécution des tâches. Si une tâche avec un temps d'exécution prévisible (par exemple, 50 ms) prend 5 secondes ou plus — il y a une forte probabilité de Starvation. Dans les applications mobiles, Firebase Performance Monitoring permet de configurer des traces personnalisées pour les sections critiques et de recevoir des notifications lorsque les seuils sont dépassés.
Pour diagnostiquer Starvation causée par des blocs synchronized, utilisez Java Flight Recorder (JFR) (disponible sur Android via l'API OpenJDK) ou Async Profiler. Ces outils montrent quels moniteurs ont les temps d'attente les plus élevés et quels threads sont en compétition pour chaque moniteur. Les données JFR s'intègrent avec IntelliJ IDEA Ultimate via son profileur intégré.
ReentrantLock(true) garantit que les threads acquièrent le verrou dans l'ordre FIFO. Contrairement à synchronized, un verrou équitable ne permet pas à un thread qui vient de libérer le verrou de le réacquérir immédiatement. Cela élimine complètement Starvation, bien que cela réduise le débit global de 10 à 20 % en raison de la surcharge de maintenance de la file d'attente.
Les structures de données sans verrou (ConcurrentHashMap, AtomicReference, LongAdder) éliminent Starvation par définition, car elles n'ont pas de verrous qui peuvent être retenus par un seul thread. Toutes les opérations utilisent des instructions CAS du CPU qui garantissent qu'au moins un thread progresse en un nombre fini d'étapes. Pour le développement mobile, préférez ConcurrentLinkedQueue pour les files de tâches.
Minimiser le temps de rétention du verrou est un moyen universel de réduire le risque de Starvation. Déplacez les opérations lourdes (réseau, E/S disque, calculs complexes) hors des blocs synchronized. Utilisez ReadWriteLock pour les scénarios où les lecteurs ne doivent pas souffrir de famine en raison d'écrivains rares. La bibliothèque Kotlin Coroutines fournit Mutex avec un mécanisme de suspension qui ne bloque pas un thread OS.
Condition.await() et signal() doivent être utilisés avec précaution : un thread attendant sur une Condition se réveille avec d'autres threads (spurious wakeup), et tous sont en compétition pour le verrou. Si un thread retourne immédiatement en attente après await tandis que d'autres parviennent à acquérir le verrou, le thread affamé peut se réveiller et se rendormir indéfiniment. Vérifiez toujours la condition dans une boucle while plutôt que dans un if pour garantir une revérification.
Questions Fréquentes
Priority Inversion est une situation où un thread de faible priorité maintient un verrou nécessaire à un thread de haute priorité. En conséquence, le thread de haute priorité attend le thread de faible priorité — les priorités sont inversées. Starvation est un problème plus large : un thread ne peut pas acquérir une ressource indépendamment de la priorité, en raison d'un ordonnancement injuste ou de longues sections critiques.
Non, Starvation est un problème de multithreading. Le code monothread n'a pas de contention de ressources ni d'ordonnancement de threads. Cependant, Starvation peut se produire dans du code asynchrone monothread (par exemple, la boucle d'événements JavaScript) si une micro-tâche retarde indéfiniment l'exécution d'autres via setTimeout avec un délai nul.
JMM (Java Memory Model) définit les règles de visibilité des changements entre les threads mais ne garantit pas un ordonnancement équitable. synchronized, conformément à JMM, assure la cohérence séquentielle — l'exactitude de base — mais ne prévient pas Starvation. L'équité nécessite des mécanismes supplémentaires non spécifiés dans JMM.
Le thread UI (Main Thread) ne peut pas souffrir de famine au sens classique car il a la priorité la plus élevée. Cependant, Starvation se produit lorsque le thread UI attend un résultat d'un thread d'arrière-plan affamé. Un scénario typique : une AsyncTask ou coroutine charge des données mais ne peut pas accéder à la base de données en raison de la contention avec d'autres threads, et l'UI se fige en attendant.
Dans les coroutines, pour prévenir Starvation, utilisez limitedParallelism sur Dispatchers.IO pour éviter l'épuisement des threads. Pour la synchronisation, utilisez Mutex de kotlinx.coroutines.sync — il suspend la coroutine plutôt que de bloquer le thread, réduisant le risque de famine. Évitez runBlocking dans les coroutines, car il peut capturer un thread du pool et provoquer Starvation d'autres coroutines.
Résumé
Nous développerons une application mobile clé en main
IT Sectr crée des applications iOS et Android pour les startups et les entreprises depuis 2017. Nous vous conseillerons et vous proposerons la meilleure solution.
Lisez aussi