Atelier 17 - Système de billetterie de concert
Créez un programme Java qui simule un système de vente de billets de concert, en utilisant les classes atomiques, les collections concurrentes, les verrous explicites et les ExecutorService.
Spécifications
1. Enum Section
Créez un enum Section avec les valeurs : PARTERRE, BALCON, VIP, PELOUSE, LOGE.
2. Classe Billet
Créez une classe Billet avec les attributs suivants :
id(int)section(Section)prix(double)acheteur(String)
Redéfinissez toString() pour afficher :
Billet #id [SECTION] — acheteur ($prix)
3. Données initiales
Créez une ConcurrentHashMap<Section, Integer> représentant les places disponibles par section :
| Section | Places |
|---|---|
| PARTERRE | 50 |
| BALCON | 30 |
| VIP | 10 |
| PELOUSE | 100 |
| LOGE | 5 |
Et une ConcurrentHashMap<Section, Double> pour les prix :
| Section | Prix |
|---|---|
| PARTERRE | 85.00 |
| BALCON | 55.00 |
| VIP | 250.00 |
| PELOUSE | 35.00 |
| LOGE | 500.00 |
Section 4 — Classes Atomiques
4a. Générateur d'identifiant unique
Créez un AtomicInteger idGenerateur initialisé à 1. Chaque fois qu'un billet est créé, son id est obtenu par idGenerateur.getAndIncrement().
Démontrez que même avec 10 threads créant chacun 5 billets simultanément, aucun id n'est dupliqué.
Affichez les 50 ids obtenus triés — ils doivent tous être distincts et aller de 1 à 50.
4b. Compteur de ventes
Créez un AtomicInteger totalVendus qui est incrémenté à chaque vente réussie.
Version sans protection :
int totalVendus = 0; // champ ordinaire
// Dans chaque thread :
totalVendus++; // pas atomique — race condition
Lancez 20 threads qui incrémentent chacun 100 fois. Le total attendu est 2000, mais la valeur finale sera souvent incorrecte.
Version avec AtomicInteger :
Remplacez par AtomicInteger totalVendus = new AtomicInteger(0) et utilisez incrementAndGet(). Relancez — le total doit toujours afficher 2000.
4c. Statut du concert
Créez un AtomicBoolean concertOuvert = new AtomicBoolean(true).
Simulez la fermeture de la billetterie : un thread "gestionnaire" appelle concertOuvert.set(false) après 200 ms. Les threads vendeurs vérifient concertOuvert.get() avant chaque vente et abandonnent si le concert est fermé.
4d. Dernière vente
Créez un AtomicReference<Billet> derniereVente = new AtomicReference<>(null).
Après chaque vente réussie, mettez à jour la référence avec derniereVente.set(billet). À la fin, affichez la dernière vente enregistrée.
AtomicReference.set()garantit que la référence ne peut jamais être "à moitié écrite" peu importe combien de threads appellentset()simultanément.
Section 5 — Collections concurrentes
5a. Problème avec ArrayList
Deux threads enregistrent simultanément leurs ventes dans la même ArrayList<Billet>. Chaque thread ajoute 500 billets — on s'attend à 1000 entrées.
List<Billet> historique = new ArrayList<>();
Thread vendeur1 = new Thread(() -> {
for (int i = 0; i < 500; i++) {
historique.add(new Billet(i, Section.PELOUSE, 35.0, "Client-V1-" + i));
}
});
Thread vendeur2 = new Thread(() -> {
for (int i = 0; i < 500; i++) {
historique.add(new Billet(i, Section.PELOUSE, 35.0, "Client-V2-" + i));
}
});
vendeur1.start();
vendeur2.start();
vendeur1.join();
vendeur2.join();
System.out.println("Billets vendus : " + historique.size()); // peut afficher < 1000 ou lancer une exception
Exécutez plusieurs fois et observez les résultats incohérents (taille incorrecte, ArrayIndexOutOfBoundsException, ou NullPointerException).
5b. Solution avec CopyOnWriteArrayList
Remplacez ArrayList par CopyOnWriteArrayList. Relancez — la taille doit toujours afficher 1000.
CopyOnWriteArrayListest particulièrement adaptée ici : les billets sont ajoutés (écriture) de façon concurrente, mais l'historique est surtout consulté (lecture) — par exemple pour afficher les reçus.
5c. ConcurrentHashMap — Gestion des places
Utilisez ConcurrentHashMap.compute() pour décrémenter atomiquement le nombre de places disponibles lors d'une vente :
boolean venteReussie = placesDisponibles.compute(section, (s, places) -> {
if (places == null || places <= 0) return 0;
return places - 1;
}) > 0; // false si la place était déjà à 0 avant le compute
Lancez 20 threads qui tentent chacun de vendre 3 billets en section PARTERRE (50 places). Affichez le nombre de ventes réussies et le nombre de places restantes — le total doit être cohérent (ventes + restant = 50).
.compute()applique la fonction de manière atomique — deux threads ne peuvent jamais décrémenter le même index simultanément.
5d. Collections.synchronizedList vs CopyOnWriteArrayList
Le code suivant utilise un wrapper synchronisé. Identifiez le problème et corrigez-le :
List<String> acheteurs = Collections.synchronizedList(new ArrayList<>());
// Thread A : ajoute
acheteurs.add("Alice");
// Thread B : parcourt sans bloc synchronized
for (String a : acheteurs) { // problème ici
System.out.println(a);
}
Indice : le parcours avec
for-eachn'est pas couvert par le verrou interne du wrapper.
Section 6 — Verrous explicites
6a. ReentrantLock — Section VIP
La section VIP (10 places) est très convoitée. Utilisez un ReentrantLock pour garantir qu'un seul thread à la fois effectue la vérification + déduction des places :
private final ReentrantLock lockVIP = new ReentrantLock();
public boolean acheterVIP(String acheteur) {
lockVIP.lock();
try {
int places = placesDisponibles.get(Section.VIP);
if (places <= 0) return false;
placesDisponibles.put(Section.VIP, places - 1);
// créer et enregistrer le billet...
return true;
} finally {
lockVIP.unlock();
}
}
Lancez 25 threads qui tentent chacun d'acheter un billet VIP. Vérifiez qu'exactement 10 ventes réussissent et que les places restantes sont 0.
6b. tryLock() — Tentative non bloquante
Modifiez la méthode acheterVIP pour utiliser tryLock(50, TimeUnit.MILLISECONDS) : si le verrou n'est pas disponible dans les 50 ms, le thread abandonne et affiche un message.
if (lockVIP.tryLock(50, TimeUnit.MILLISECONDS)) {
try {
// logique d'achat...
} finally {
lockVIP.unlock();
}
} else {
System.out.println(acheteur + " : délai d'attente dépassé, abandon.");
}
6c. ReadWriteLock — Catalogue des prix
Le catalogue des prix est lu très fréquemment (par tous les vendeurs avant chaque vente) mais modifié rarement (ex : promotion de dernière minute).
Implémentez un ReentrantReadWriteLock pour protéger la ConcurrentHashMap des prix :
- Les lectures (
getPrix(section)) utilisentreadLock()— plusieurs threads peuvent lire simultanément. - L'écriture (
appliquerPromotion(section, nouveauPrix)) utilisewriteLock()— exclusif.
Lancez 10 threads qui lisent les prix en boucle, et un thread qui applique une promotion sur BALCON après 100 ms. Vérifiez que la promotion est visible par tous les lecteurs une fois appliquée.
Section 7 — ExecutorService, Callable et Future
7a. Pool de vendeurs
Créez un pool de 4 threads avec Executors.newFixedThreadPool(4) pour traiter des demandes d'achat :
ExecutorService pool = Executors.newFixedThreadPool(4);
for (int i = 0; i < 20; i++) {
final String client = "Client-" + i;
final Section section = Section.values()[i % Section.values().length];
pool.submit(() -> {
// logique d'achat pour client dans section
System.out.println(Thread.currentThread().getName() + " : vente pour " + client);
});
}
pool.shutdown();
pool.awaitTermination(1, TimeUnit.MINUTES);
Observez que seulement 4 threads différents apparaissent dans la sortie, peu importe le nombre de demandes.
7b. Callable — Calcul du montant total
Créez un Callable<Double> qui calcule le montant total des ventes pour une section donnée :
Callable<Double> calculSectionParterre = () -> {
double total = historique.stream()
.filter(b -> b.getSection() == Section.PARTERRE)
.mapToDouble(Billet::getPrix)
.sum();
Thread.sleep(50); // simule un calcul long
return total;
};
Future<Double> futur = pool.submit(calculSectionParterre);
System.out.println("Calcul en cours...");
double montant = futur.get(); // bloque jusqu'au résultat
System.out.println("Total PARTERRE : $" + montant);
7c. invokeAll — Rapport complet
Créez un Callable<String> par section qui retourne un résumé "SECTION : X billets, total $Y".
Soumettez les 5 tâches avec invokeAll() et affichez les résultats dans l'ordre une fois tous les calculs terminés.
List<Callable<String>> rapports = List.of(
() -> calculerRapport(Section.PARTERRE),
() -> calculerRapport(Section.BALCON),
() -> calculerRapport(Section.VIP),
() -> calculerRapport(Section.PELOUSE),
() -> calculerRapport(Section.LOGE)
);
List<Future<String>> resultats = pool.invokeAll(rapports);
for (Future<String> f : resultats) {
System.out.println(f.get());
}
7d. Benchmark : thread par demande vs pool
Simulez 100 demandes d'achat, chacune faisant un calcul léger (somme de 10 000 nombres). Mesurez et comparez :
- Un thread par demande : 100 threads créés et démarrés manuellement
- Pool calibré :
Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors())
Affichez les deux durées en ms. Le pool doit être significativement plus rapide.
Avec 100 threads manuels, le CPU passe plus de temps à gérer les changements de contexte qu'à exécuter les calculs. Le pool réutilise les threads existants et les distribue efficacement sur les cœurs disponibles.
Section 8 — Pipeline combiné
Après toutes les ventes simulées, écrivez un seul pipeline (sans variables intermédiaires) avec parallelStream() qui :
- Filtre les billets dont le prix est supérieur à 50.00$
- Regroupe par section avec
Collectors.groupingBy - Pour chaque section, calcule le total des ventes avec
Collectors.summingDouble - Trie les sections par total décroissant
- Affiche chaque ligne sous le format
"SECTION : $total"
Exemple d'exécution attendu
=== 4a. Identifiants uniques ===
IDs générés (triés) : [1, 2, 3, 4, 5, ..., 50]
Tous distincts : true
=== 4b. Compteur de ventes ===
Sans AtomicInteger : 1847 ← valeur incorrecte possible
Avec AtomicInteger : 2000 ← toujours correct
=== 4c. Fermeture du concert ===
Concert fermé après 200 ms.
Ventes après fermeture refusées : 6
=== 4d. Dernière vente ===
Dernière vente : Billet #48 [PELOUSE] — Client-V2-47 ($35.0)
=== 5a. ArrayList corrompue ===
Billets vendus : 973 ← résultat incorrect (ou exception)
=== 5b. CopyOnWriteArrayList ===
Billets vendus : 1000 ← toujours correct
=== 5c. ConcurrentHashMap ===
Ventes réussies : 20
Places PARTERRE restantes : 30
Cohérence : 20 + 30 = 50 ✓
=== 6a. ReentrantLock VIP ===
Ventes VIP réussies : 10
Places VIP restantes : 0
=== 6b. tryLock ===
Client-14 : délai d'attente dépassé, abandon.
Client-19 : délai d'attente dépassé, abandon.
=== 6c. ReadWriteLock — promotion BALCON ===
Prix BALCON avant promotion : $55.0
[après 100ms] Promotion appliquée : $39.99
Prix BALCON (tous les lecteurs) : $39.99
=== 7a. Pool de vendeurs ===
pool-1-thread-2 : vente pour Client-0
pool-1-thread-1 : vente pour Client-1
pool-1-thread-4 : vente pour Client-2
pool-1-thread-3 : vente pour Client-3
pool-1-thread-2 : vente pour Client-4
...
=== 7b. Callable / Future ===
Calcul en cours...
Total PARTERRE : $1700.0
=== 7c. Rapport complet (invokeAll) ===
PARTERRE : 20 billets, total $1700.00
BALCON : 12 billets, total $479.88
VIP : 10 billets, total $2500.00
PELOUSE : 35 billets, total $1225.00
LOGE : 5 billets, total $2500.00
=== 7d. Benchmark ===
Cœurs disponibles : 8
100 threads manuels : 312 ms
Pool (8 threads) : 89 ms
=== 8. Revenus par section ===
VIP : $2500.00
LOGE : $2500.00
PARTERRE : $1700.00
PELOUSE : $1225.00
BALCON : $479.88
Contraintes
- La section 4b doit montrer les deux versions : avec et sans
AtomicInteger compareAndSetn'est pas requis, maisgetAndIncrement,incrementAndGet,set,get,updateAndGetdoivent être utilisés au moins une fois chacun dans l'ensemble de l'atelier- La section 5a doit démontrer le problème — ne pas le corriger directement, le montrer puis le corriger en 5b
ConcurrentHashMap.compute()est obligatoire en section 5c — pas deget+putséparés- La section 5d doit fournir la version corrigée (bloc
synchronizedautour dufor-each) ReentrantLockdoit toujours être déverrouillé dans un blocfinallytryLock(long, TimeUnit)est obligatoire en section 6b — pastryLock()sans délaiReadWriteLockest obligatoire en section 6c — pas desynchronizedà la place- La section 7b doit utiliser
Callable(avec valeur de retour) — pasRunnable invokeAllest obligatoire en section 7c — pas de boucle desubmitindividuels- Le pool de la section 7d doit être calibré sur
Runtime.getRuntime().availableProcessors() - Le pipeline de la section 8 doit utiliser
parallelStream()et être écrit sans variables intermédiaires
Questions de réflexion
- Quelle est la différence entre
AtomicInteger.incrementAndGet()et++iavecsynchronized? Dans quel cas préféreriez-vous l'un ou l'autre ? - Pourquoi
CopyOnWriteArrayListest-elle efficace pour beaucoup de lectures mais coûteuse pour beaucoup d'écritures ? Décrivez ce qui se passe en mémoire à chaqueadd(). ReentrantLocks'appelle "réentrant" — qu'est-ce que cela signifie concrètement ? Donnez un scénario où la réentrance est nécessaire.- Pourquoi
tryLock()est-il préférable àlock()dans certains contextes ? Quels risques évite-t-il ? - Quelle est la différence entre
ReadWriteLocketReentrantLocken termes de performance quand il y a beaucoup plus de lectures que d'écritures ? - Pourquoi
Callableexiste-t-il siRunnablesuffit pour lancer un thread ? Que se passe-t-il si une exception est levée dans unCallable— comment la récupérer ? Future.get()bloque le thread appelant. Dans quel scénario cela pose-t-il problème ? CommentisDone()permet-il de l'éviter ?- Quel est le bon nombre de threads pour un pool traitant des tâches CPU-intensives ? Et pour des tâches I/O-intensives (ex : appels réseau) ? Pourquoi la réponse diffère-t-elle ?