Fondamentaux des Threads en Java
Lorsqu'une application Java démarre, la machine virtuelle (JVM) crée automatiquement un thread principal responsable de l'exécution de la méthode main. Tout autre thread généré par le programme est considéré comme un thread enfant (ou worker thread), dont le cycle de vie actif débute dans la méthode run().
Modèles d'ordonnancement
- Time-sharing (Temps partagé) : Le processeur alloue des tranches de temps strictement égales à chaque thread.
- Preemptive (Préemptif) : Les threads dotés d'une priorité supérieure accèdent plus fréquemment au CPU. À priorité égale, l'ordonnanceur agit de manière pseudo-aléatoire. Java utilise nativement ce modèle préemptif.
Architecture Mémoire
La gestion de la mémoire dans un environnement multithread repose sur trois règles fondamentales :
- Chaque thread possède sa propre pile d'exécution (stack) isolée.
- Le tas (heap) du processus est entièrement partagé entre tous les threads actifs.
- Au sein d'un même thread, les instructions sont toujours évaluées de manière séquentielle.
Instanciation de Threads
Java propose deux approches principales pour créer et exécuter des tâches concurrentes.
1. Héritage de la classe Thread
Cette méthode consiste à étendre la classe Thread et à redéfinir la méthode run(). L'exécution asynchrone est déclenchée via l'appel à start().
public class PrimeCalculator extends Thread {
private final int limit;
public PrimeCalculator(String threadName, int limit) {
super(threadName);
this.limit = limit;
}
@Override
public void run() {
int count = 0;
for (int i = 2; i <= limit; i++) {
if (isPrime(i)) {
count++;
}
}
System.out.println(getName() + " a trouvé " + count + " nombres premiers.");
}
private boolean isPrime(int number) {
for (int i = 2; i <= Math.sqrt(number); i++) {
if (number % i == 0) return false;
}
return true;
}
public static void main(String[] args) {
PrimeCalculator taskA = new PrimeCalculator("Worker-Alpha", 10000);
PrimeCalculator taskB = new PrimeCalculator("Worker-Beta", 15000);
taskA.start();
taskB.start();
}
}
2. Implémentation de l'interface Runnable
Privilégier Runnable est une meilleure pratique de conception. Cela évite les limites de l'héritage unique en Java, découple la tâche de l'infrastructure de thread, et facilite l'intégration avec les pools de threads (comme ExecutorService).
public class DataDownloader implements Runnable {
private final String resourceId;
public DataDownloader(String resourceId) {
this.resourceId = resourceId;
}
@Override
public void run() {
System.out.println("[" + Thread.currentThread().getName() + "] Téléchargement de la ressource : " + resourceId);
try {
Thread.sleep(500); // Simulation d'un traitement réseau
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println("[" + Thread.currentThread().getName() + "] Opération terminée.");
}
public static void main(String[] args) {
System.out.println("Thread principal démarré : " + Thread.currentThread().getName());
Runnable task = new DataDownloader("API-Endpoint-99");
Thread worker = new Thread(task, "Network-Thread");
worker.start();
}
}
Sécurité et Synchronisation des Threads
Un code est dit "thread-safe" lorsque plusieurs threads peuvent manipuler simultanément une ressource partagée sans altérer l'intégrité des données.
Blocs Synchronisés
L'instruction synchronized garantit un accès exclusif (verrouillage mutuel) à un bloc de code critique. Le verrou (monitor lock) doit être un objet partagé par toutes les instances accédant à la ressource.
public class LimitedEditionSale implements Runnable {
private int inventory = 50;
private final Object mutex = new Object();
@Override
public void run() {
while (true) {
synchronized (mutex) {
if (inventory > 0) {
try { Thread.sleep(10); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
int current = inventory--;
System.out.println(Thread.currentThread().getName() + " a acheté l'article #" + current + ". Stock restant: " + inventory);
} else {
System.out.println(Thread.currentThread().getName() + " : Rupture de stock.");
break;
}
}
}
}
public static void main(String[] args) {
LimitedEditionSale saleEvent = new LimitedEditionSale();
new Thread(saleEvent, "Boutique-Paris").start();
new Thread(saleEvent, "Site-Web").start();
}
}
Méthodes Synchronisées
Au lieu de verrouiller un bloc spécifique, il est possible d'appliquer le mot-clé synchronized sur la signature d'une méthode. Pour les méthodes d'instance, le verrou est l'objet courant (this). Pour les méthodes statiques, le verrou est l'objet Class associé.
public class TheaterBooking implements Runnable {
private int availableSeats = 30;
@Override
public void run() {
while (reserveSeat()) {
// Continuer les réservations tant qu'il y a de la place
}
}
public synchronized boolean reserveSeat() {
if (availableSeats > 0) {
try { Thread.sleep(20); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
availableSeats--;
System.out.println(Thread.currentThread().getName() + " a réservé un siège. Places libres: " + availableSeats);
return true;
}
System.out.println(Thread.currentThread().getName() + " : Complet.");
return false;
}
public static void main(String[] args) {
TheaterBooking bookingSystem = new TheaterBooking();
new Thread(bookingSystem, "Guichet-A").start();
new Thread(bookingSystem, "Guichet-B").start();
new Thread(bookingSystem, "Application-Mobile").start();
}
}
L'API Lock (ReentrantLock)
L'interface Lock (implémentée par ReentrantLock) offre une alternative plus orientée objet et plus flexible que le mot-clé synchronized. Elle permet des opérations avancées comme les tentatives de verrouillage non bloquantes. Il est impératif de toujours relâcher le verrou dans un bloc finally pour éviter les deadlocks.
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class BankAccount implements Runnable {
private int balance = 1000;
private final Lock transactionLock = new ReentrantLock();
@Override
public void run() {
for (int i = 0; i < 5; i++) {
withdraw(50);
}
}
private void withdraw(int amount) {
transactionLock.lock();
try {
if (balance >= amount) {
Thread.sleep(15); // Simuler le délai de traitement
balance -= amount;
System.out.println(Thread.currentThread().getName() + " a retiré " + amount + "€. Solde: " + balance + "€");
} else {
System.out.println(Thread.currentThread().getName() + " : Fonds insuffisants.");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
transactionLock.unlock();
}
}
public static void main(String[] args) {
BankAccount account = new BankAccount();
new Thread(account, "Distributeur-1").start();
new Thread(account, "Distributeur-2").start();
}
}
Cycle de vie d'un Thread
Un thread Java évolue à travers plusieurs états définis par l'énumération Thread.State :
- NEW : L'objet thread a été instancié mais la méthode
start()n'a pas encore été invoquée. - RUNNABLE : Le thread est prêt à être exécuté par l'ordonnanceur du système d'exploitation, ou est activement en cours d'exécution.
- BLOCKED : Le thread est en attente d'un verrou moniteur pour entrer dans un bloc ou une méthode synchronisée.
- WAITING : Attente indéfinie d'une action spécifique d'un autre thread (ex: appel à
Object.wait()sans timeout). - TIMED_WAITING : Attente limitée dans le temps suite à l'appel de méthodes comme
Thread.sleep()ouObject.wait(timeout). - TERMINATED : L'exécution de la méthode
run()est achevée, ou le thread a été arrêté de manière prématurée.