Aller au contenu principal

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 :

SectionPlaces
PARTERRE50
BALCON30
VIP10
PELOUSE100
LOGE5

Et une ConcurrentHashMap<Section, Double> pour les prix :

SectionPrix
PARTERRE85.00
BALCON55.00
VIP250.00
PELOUSE35.00
LOGE500.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 appellent set() 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.

CopyOnWriteArrayList est 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-each n'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)) utilisent readLock() — plusieurs threads peuvent lire simultanément.
  • L'écriture (appliquerPromotion(section, nouveauPrix)) utilise writeLock() — 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 :

  1. Un thread par demande : 100 threads créés et démarrés manuellement
  2. 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 :

  1. Filtre les billets dont le prix est supérieur à 50.00$
  2. Regroupe par section avec Collectors.groupingBy
  3. Pour chaque section, calcule le total des ventes avec Collectors.summingDouble
  4. Trie les sections par total décroissant
  5. 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
  • compareAndSet n'est pas requis, mais getAndIncrement, incrementAndGet, set, get, updateAndGet doivent ê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 de get + put séparés
  • La section 5d doit fournir la version corrigée (bloc synchronized autour du for-each)
  • ReentrantLock doit toujours être déverrouillé dans un bloc finally
  • tryLock(long, TimeUnit) est obligatoire en section 6b — pas tryLock() sans délai
  • ReadWriteLock est obligatoire en section 6c — pas de synchronized à la place
  • La section 7b doit utiliser Callable (avec valeur de retour) — pas Runnable
  • invokeAll est obligatoire en section 7c — pas de boucle de submit individuels
  • 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 ++i avec synchronized ? Dans quel cas préféreriez-vous l'un ou l'autre ?
  • Pourquoi CopyOnWriteArrayList est-elle efficace pour beaucoup de lectures mais coûteuse pour beaucoup d'écritures ? Décrivez ce qui se passe en mémoire à chaque add().
  • ReentrantLock s'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 ReadWriteLock et ReentrantLock en termes de performance quand il y a beaucoup plus de lectures que d'écritures ?
  • Pourquoi Callable existe-t-il si Runnable suffit pour lancer un thread ? Que se passe-t-il si une exception est levée dans un Callable — comment la récupérer ?
  • Future.get() bloque le thread appelant. Dans quel scénario cela pose-t-il problème ? Comment isDone() 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 ?