Tutoriel sur la programmation concurrente en Java

Partie 3 : Structures de données

Logan Mauzaize

La programmation concurrente est un enjeu important et parfois difficile pour les développeurs. Cette série d'articles vise à vous présenter les différentes API disponibles en standard avec Java.

Les articles de la série :

- Gestion des threads

- Éléments de synchronisation (natifs et API)

- Structures de données

- Verrous

- Variables sans verrou

- Conseils et astuces

Ce troisième article vise à présenter les structures données fournies par l'API.

Commentez

- Titre : Tutoriel sur la programmation concurrente en Java

- Auteur : Logan Mauzaize

- Parution : 19 septembre 2016

- Public visé :

- Licence : Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright ® 2016 Logan Mauzaize. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- I. Bases

- II. Synchronisation d'une collection

- III. Files bloquantes

- III-A. Interfaces

- III-B. SynchronousQueue

- III-C. Linked/Array BlockingQueue

- III-D. PriorityBlockingQueue

- III-E. DelayQueue

- III-F. TransferQueue (1.7+)

- IV. CopyOnWrite List/Set

- V. Maps concurrentes

- V-A. ConcurrentSkipList

- V-B. ConcurrentHashMap

- VI. Conclusion

- VII. Remerciements

- VIII. Annexes

- VIII-A. Sources des exemples

- VIII-B. Java Concurrent Animated

I - Bases

La plupart des collections basiques de Java ne supportent pas la concurrence. En effet, la gestion de la concurrence a un coût sur les performances et il n'y a pas de nécessité à ce que la concurrence soit gérée en permanence. Ainsi des classes telles que Vector et Hashtable sont quasiment considérées comme dépréciées.

L'API des collections introduit cependant le concept de fail-fast qui consiste à offrir des mécanismes simples et non sûrs (principe de « pour le mieux ») de détection de modification concurrente. Il s'agit principalement de faire échouer la navigation par Iterator lorsqu'intervient une modification structurelle sur la collection.

II - Synchronisation d'une collection

La classe utilitaire Collections possède un certain nombre de méthodes préfixées par synchronized afin d'encapsuler une instance non thread-safe dans une instance qui l'est :

Collections.synchronized - démo (code)

1. //com.developpez.lmauzaize.java.concurrence.ch03_structure_donnees.CollectionsSynchronizedDemo

2. class Tache implements Runnable {

3. int nbThreads;

4. int nbPerThread;

5. List<String> values;

7. Tache(int nbThreads, int nbPerThread) {

8. this.nbThreads   = nbThreads;

9. this.nbPerThread = nbPerThread;

10. }

12. public void run() {

13. for (int i = 0; i < nbPerThread; i++) {

14. values.add(Thread.currentThread().getName());

15. }

16. }

18. public void execute(boolean sync) throws InterruptedException {

19. values = new ArrayList<>(1);

20. if (sync) {

21. values = Collections.synchronizedList(values);

22. }

23. ExecutorService executor = Executors.newCachedThreadPool();

24. for (int i = 0; i < nbThreads; i++) {

25. executor.submit(this);

26. }

27. executor.shutdown();

28. if (!executor.awaitTermination(2, TimeUnit.SECONDS)) {

29. throw new CancellationException();

30. }

31. Logger.println("Synchronisé=%-5b  Attendu=%,06d, Réel=%,06d", sync, nbThreads*nbPerThread, values.size());

32. }

33. };

34. Tache tache = new Tache(20_000, 10);

35. tache.execute(false);

36. tache.execute(true);

Collections.synchronized - démo (console)

1. 00:00:00.018 [main           ] Synchronisé=false  Attendu=200 000, Réel=195 059

2. 00:00:00.075 [main           ] Synchronisé=true   Attendu=200 000, Réel=200 000

Le fait de synchroniser une collection permet seulement de protéger chaque appel de méthode, mais ne protège pas le bloc qui englobe ses appels. Si vous devez effectuer différents appels sur une collection et que ces appels dépendent les uns des autres, vous devez utiliser un système de synchronisation externe !

Collections.synchronized - Instance vs Bloc (code)

1. //com.developpez.lmauzaize.java.concurrence.ch03_structure_donnees.CollectionsSynchronizedInstanceVsBloc

2. class Tache implements Runnable {

3. int nbThreads;

4. int nbPerThread;

5. List<Integer> values;

6. boolean sync;

8. Tache(int nbThreads, int nbPerThread) {

9. this.nbThreads   = nbThreads;

10. this.nbPerThread = nbPerThread;

11. }

13. public void run() {

14. for (int i = 0; i < nbPerThread; i++) {

15. if (sync) {

16. syncAdd();

17. } else {

18. add();

19. }

20. }

21. }

23. void add() {

24. values.add(values.size());

25. }

26. void syncAdd() {

27. synchronized (values) {

28. add();

29. }

30. }

31. void newList() {

32. values = new ArrayList<>(1);

33. if (!sync) {

34. values = Collections.synchronizedList(values);

35. }

36. }

37. int check() {

38. int check = values.size();

39. for (int i = 0; i < values.size(); i++) {

40. if (i != values.get(i)) {

41. check--;

42. }

43. }

44. return check;

45. }

47. Tache execute(boolean sync) throws InterruptedException {

48. this.sync = sync;

49. newList();

50. ExecutorService executor = Executors.newCachedThreadPool();

51. for (int i = 0; i < nbThreads; i++) {

52. executor.submit(this);

53. }

54. executor.shutdown();

55. if (!executor.awaitTermination(5, TimeUnit.SECONDS)) {

56. throw new CancellationException();

57. }

58. Logger.println("Synchronisé=%-5b  Attendu=%,06d, Réel=%,06d/%,06d", sync, nbThreads*nbPerThread, check(), values.size());

59. return this;

60. }

61. };

62. new Tache(20_000, 10).execute(false).execute(true);

Collections.synchronized - Instance vs Bloc (console)

1. 00:00:00.019 [main           ] Synchronisé=false  Attendu=200 000, Réel=195 419/200 000

2. 00:00:00.122 [main           ] Synchronisé=true   Attendu=200 000, Réel=200 000/200 000

Pour toutes les instances renvoyées par ces méthodes, le verrou utilisé est l'instance elle-même (ni un verrou interne, ni la collection encapsulée), les Iterators ne sont pas protégés. Le verrou est également partagé par les vues. Voici la liste des interfaces supportées (ainsi que la liste des vues associées) :

- Collection (synchronizedCollection)

- List (synchronizedList)

- subList

- Set (synchronizedSet)

- SortedSet (synchronizedSortedSet)

- headSet

- subSet

- tailSet

- NavigableSet (synchronizedNavigableSet, 1.8+)

- descendingSet

- Map (synchronizedMap)

- entrySet

- keySet

- values

- SortedMap (synchronizedSortedMap)

- headMap

- subMap

- tailMap

- NavigableMap (synchronizedNavigableMap, 1.8+)

- descendingKeySet

- descendingMap

- navigableKeySet

III - Files bloquantes

III-A - Interfaces

Il est commun dans un environnement de traitement parallèle d'accumuler des données d'un côté et de les dépiler de l'autre. C'est le principe du modèle producteur-consommateur, du pattern « Pipes and Filters » ou des pools d'exécution. La structure de données qui se prête le mieux à ces manipulations est la file (queue, First In First Out). En Java, il existe également l'interface Deque (prononcer « dèc ») qui permet également de gérer une pile (Last In First Out). Les variantes « bloquantes » sont représentées par les interfaces BlockingQueue et BlockingDeque.

Une file bloquante offre différents types d'opérations :

- non bloquante : si la file n'est pas en mesure de répondre à la demande (vide ou pleine), celle-ci est rejetée. Il existe alors deux formes de retour.

1. exception : la file lance une exception si elle est vide (remove, element) ou si elle est pleine (add).

2. valeur particulière : poll/peek renvoient null (si vide) et offer renvoie false (si pleine).

- bloquante : la demande est mise en attente jusqu'à ce que l'état de la file le permette, il n'est en revanche pas possible d'examiner comme avec les méthodes element() et peek(). Il existe deux formes d'attente :

1. avec timeout : ce sont les mêmes méthodes que la version non bloquante avec valeur spéciale (offer, poll).

2. Indéfiniment : ce sont les méthodes put et take qui sont utilisées.

III-B - SynchronousQueue

SynchronousQueue est la plus simple des files bloquantes. En réalité, il ne s'agit pas d'une file puisqu'elle a une capacité de 0 :

SynchronousQueue - démo (code)

1. //com.developpez.lmauzaize.java.concurrence.ch03_structure_donnees.SynchronousQueueDemo

2. SynchronousQueue<String> queue = new SynchronousQueue<>();

3. //Non bloquant avec exception

4. try {

5. queue.add("A");

6. } catch (Exception e) {

7. Logger.println("Ajout refusé: %s", e);

8. }

9. //Non bloquant avec valeur

10. Logger.println("Ajout? %b", queue.offer("A"));

13. new Thread("Consommateur") {

14. public void run() {

15. Logger.println("Lecture non bloquante : %s", queue.poll());

16. Thread.sleep(1_500);

17. Logger.println("Lecture non bloquante : %s", queue.poll());

18. Logger.println("Attente");

19. Logger.println("Lecture     bloquante : %s", queue.take());

20. }

21. }.start();

22. new Thread("Producteur") {

23. public void run() {

24. Thread.sleep(1_000);

25. Logger.println("Ajout       bloquant  : A");

26. queue.put("A");

27. Thread.sleep(1_000);

28. Logger.println("Ajout   non bloquant  : B");

29. queue.add("B");

30. }

31. }.start();

SynchronousQueue - démo (console)

1. 00:00:00.021 [main           ] Ajout refusé: java.lang.IllegalStateException: Queue full

2. 00:00:00.029 [main           ] Ajout? false

3. 00:00:00.030 [Consommateur   ] Lecture non bloquante : null

4. 00:00:01.031 [Producteur     ] Ajout       bloquant  : A

5. 00:00:01.530 [Consommateur   ] Lecture non bloquante : A

6. 00:00:01.530 [Consommateur   ] Attente

7. 00:00:02.530 [Producteur     ] Ajout   non bloquant  : B

8. 00:00:02.530 [Consommateur   ] Lecture     bloquante : B

Comme il s'agit d'une file sans stockage, l'itération sur une telle file ne renvoie jamais d'élément, même si un ajout est en attente :

SynchronousQueue - itération (code)

1. //com.developpez.lmauzaize.java.concurrence.ch03_structure_donnees.SynchronousQueueIteration

2. SynchronousQueue<String> queue = new SynchronousQueue<>();

3. new Thread("Producteur") {

4. public void run() {

5. Logger.println("Ajout");

6. queue.put(getName());

7. Logger.println("Ajouté");

8. }

9. }.start();

10. Thread.sleep(1_000);

11. int i = 0;

12. Logger.println("début");

13. for (String element : queue) {

14. Logger.println("[%02d] %s", i++, element);

15. }

16. Logger.println("fin");

17. Logger.println("%s", queue.poll());

SynchronousQueue - itération (console)

1. 00:00:00.020 [Producteur     ] Ajout

2. 00:00:00.999 [main           ] début

3. 00:00:00.999 [main           ] fin

4. 00:00:01.000 [main           ] Producteur

5. 00:00:01.000 [Producteur     ] Ajouté

III-C - Linked/Array BlockingQueue

LinkedBlockingQueue est une implémentation basée sur les listes chaînées. L'implémentation jumelle est ArrayBlockingQueue qui est elle basée sur une table fixe. Globalement, les ArrayBlockingQueue offrent de meilleures performances (voir exemple ci-dessous), mais ont l'inconvénient de fixer leur utilisation mémoire sur la taille maximale tandis que les LinkedBlockingQueue auront une utilisation mémoire proportionnelle à la taille réelle de la file.

BlockingQueue - Linked vs Array (API)

1. //com.developpez.lmauzaize.java.concurrence.ch03_structure_donnees.BlockingQueueLinkedVsArray

2. class BlockingQueueLinkedVsArray {

3. // Actions

4. //// Remplir/vider la file

5. void remplir();

6. void vider();

7. //// Lance/attend des threads qui lisent la file jusqu'à épuisement.

8. void lancerConsommateurs();

9. void attendreConsommateurs();

10. //// Lance/attend des threads qui remplissent la file

11. void lancerProducteurs();

12. void attendreProducteurs();

14. // Bench

15. //// Exécute des actions avant/après chaque mesure

16. void avant();

17. void après();

18. //// Action à mesurer

19. void run();

20. //// Joue le scénario plusieurs fois pour chaque implémentation, puis affiche le temps moyen d'exécution

21. void bench();

22. }

BlockingQueue - Linked vs Array (main)

1. //com.developpez.lmauzaize.java.concurrence.ch03_structure_donnees.BlockingQueueLinkedVsArray

2. // Remplit la file en utilisant un seul thread

3. new BlockingQueueLinkedVsArray() {

4. { action = "AjoutSéquentiel"; }

5. void run() {

6. remplir();

7. }

8. }.bench();

10. // Remplit la file en utilisant plusieurs threads

11. new BlockingQueueLinkedVsArray() {

12. { action = "AjoutParallèle"; }

13. void run() throws Exception {

14. lancerProducteurs();

15. attendreProducteurs();

16. }

17. }.bench();

19. // Vide la file en utilisant un seul thread

20. new BlockingQueueLinkedVsArray() {

21. { action = "RetraitSéquentiel"; }

22. void avant() {

23. remplir();

24. }

25. void run() throws Exception {

26. vider();

27. }

28. }.bench();

30. // Vide la file en utilisant plusieurs threads

31. new BlockingQueueLinkedVsArray() {

32. { action = "RetraitParallèle"; }

33. void avant() {

34. remplir();

35. }

36. void run() throws Exception {

37. lancerConsommateurs();

38. attendreConsommateurs();

39. }

40. }.bench();

42. // Vide et remplit la file en utilisant plusieurs threads

43. new BlockingQueueLinkedVsArray() {

44. { action = "Parallèle"; }

45. void run() throws Exception {

46. lancerConsommateurs();

47. lancerProducteurs();

48. attendreProducteurs();

49. attendreConsommateurs();

50. }

51. }.bench();

BlockingQueue - Linked vs Array (console)

1. Paramètres :

2. - Itérations par implémentation :        10

3. - Nombre de producteurs         :     1 000

4. - Nombre de consommateurs       :     1 000

5. - Nombre total d'éléments       : 2 000 000

7. Test: AjoutSéquentiel  , File:  LinkedBlockingQueue, Temps: 00:00:00.317

8. Test: AjoutSéquentiel  , File:   ArrayBlockingQueue, Temps: 00:00:00.055

10. Test: AjoutParallèle   , File:  LinkedBlockingQueue, Temps: 00:00:00.201

11. Test: AjoutParallèle   , File:   ArrayBlockingQueue, Temps: 00:00:00.075

13. Test: RetraitSéquentiel, File:  LinkedBlockingQueue, Temps: 00:00:00.071

14. Test: RetraitSéquentiel, File:   ArrayBlockingQueue, Temps: 00:00:00.055

16. Test: RetraitParallèle , File:  LinkedBlockingQueue, Temps: 00:00:00.138

17. Test: RetraitParallèle , File:   ArrayBlockingQueue, Temps: 00:00:00.090

19. Test: Parallèle        , File:  LinkedBlockingQueue, Temps: 00:00:00.342

20. Test: Parallèle        , File:   ArrayBlockingQueue, Temps: 00:00:00.194

Contrairement aux collections « standards » (et SynchronousQueue), il est tout à fait possible d'itérer sur la collection même si des modifications interviennent entre temps. Cependant, les modifications peuvent ou pas être visibles lors de l'itération.

ArrayBlockingQueue - itération (code)

1. //com.developpez.lmauzaize.java.concurrence.ch03_structure_donnees.ArrayBlockingQueueIteration

2. BlockingQueue<String> file = new ArrayBlockingQueue<>(100);

3. for (int i = 0; i < 3; i++) {

4. new Thread("Producteur-" + i) {

5. public void run() {

6. for (int i = 0; i < 4; i++) {

7. String e = getName() + "-" + i;

8. Logger.println("Ajout %s", e);

9. file.put(e);

10. Thread.sleep(40);

11. }

12. }

13. }.start();

14. }

15. int i = 0;

16. Thread.sleep(50);

17. Logger.println("début");

18. for (String élément : file) {

19. Logger.println("[%02d] %s", i++, élément);

20. Thread.sleep(50);

21. }

22. Logger.println("fin");

ArrayBlockingQueue - itération (console)

1. 00:00:00.022 [Producteur-0   ] Ajout Producteur-0-0

2. 00:00:00.022 [Producteur-1   ] Ajout Producteur-1-0

3. 00:00:00.022 [Producteur-2   ] Ajout Producteur-2-0

4. 00:00:00.049 [main           ] début

5. 00:00:00.049 [main           ] [00] Producteur-0-0

6. 00:00:00.071 [Producteur-2   ] Ajout Producteur-2-1

7. 00:00:00.071 [Producteur-0   ] Ajout Producteur-0-1

8. 00:00:00.071 [Producteur-1   ] Ajout Producteur-1-1

9. 00:00:00.100 [main           ] [01] Producteur-1-0

10. 00:00:00.111 [Producteur-2   ] Ajout Producteur-2-2

11. 00:00:00.111 [Producteur-0   ] Ajout Producteur-0-2

12. 00:00:00.111 [Producteur-1   ] Ajout Producteur-1-2

13. 00:00:00.150 [main           ] [02] Producteur-2-0

14. 00:00:00.151 [Producteur-0   ] Ajout Producteur-0-3

15. 00:00:00.151 [Producteur-2   ] Ajout Producteur-2-3

16. 00:00:00.151 [Producteur-1   ] Ajout Producteur-1-3

17. 00:00:00.200 [main           ] [03] Producteur-2-1

18. 00:00:00.250 [main           ] [04] Producteur-0-1

19. 00:00:00.300 [main           ] [05] Producteur-1-1

20. 00:00:00.350 [main           ] [06] Producteur-2-2

21. 00:00:00.400 [main           ] [07] Producteur-0-2

22. 00:00:00.450 [main           ] [08] Producteur-1-2

23. 00:00:00.500 [main           ] [09] Producteur-0-3

24. 00:00:00.550 [main           ] [10] Producteur-2-3

25. 00:00:00.600 [main           ] [11] Producteur-1-3

26. 00:00:00.650 [main           ] fin

III-D - PriorityBlockingQueue

La file bloquante à priorité (PriorityBlockingQueue) est la variante concurrente de PriorityQueue. La priorité est gérée soit par l'ordre naturel, soit un ordre spécifique.

PriorityBlockingQueue - démo (codle)

1. //com.developpez.lmauzaize.java.concurrence.ch03_structure_donnees.PriorityBlockingQueueDemo

2. class Élément implements Comparable<Élément> {

3. String nom;

4. int priorité;

5. Élément(String nom, int priorité) {

6. this.nom  = nom;

7. this.priorité = priorité;

8. }

9. public int compareTo(Élément that) {

10. return this.priorité - that.priorité;

11. }

12. @Override

13. public String toString() {

14. return String.format("{%s:%02d}", nom, priorité);

15. }

16. }

17. PriorityBlockingQueue<Élément> file = new PriorityBlockingQueue<>();

18. file.offer(new Élément("A", 10));

19. file.offer(new Élément("B",  0));

20. file.offer(new Élément("C", 20));

21. file.offer(new Élément("D", 10));

22. file.offer(new Élément("E", 10));

23. file.offer(new Élément("F", 10));

24. Logger.println("file    =%s", file);

25. List<Élément> éléments = new ArrayList<>(file.size());

26. file.drainTo(éléments);

27. Logger.println("éléments=%s", éléments);

PriorityBlockingQueue - démo (console)

1. 00:00:00.020 [main           ] file    =[{B:00}, {A:10}, {F:10}, {D:10}, {E:10}, {C:20}]

2. 00:00:00.028 [main           ] éléments=[{B:00}, {A:10}, {E:10}, {D:10}, {F:10}, {C:20}]

L'itération sur ce type de file ne tient pas compte de la priorité des éléments (idem pour sa représentation sous forme de chaîne de caractères). Si vous souhaitez manipuler cette liste conformément aux priorités, vous devez utiliser les méthodes spécifiques aux files (ex : peek, poll, drainTo).

Ce sont les éléments avec la plus petite priorité qui sont retirés en premier. À priorité égale l'ordre n'est pas spécifié.

III-E - DelayQueue

Les files différées (DelayQueue) permettent de retarder le traitement des éléments qui la composent. En effet, elles se composent uniquement d'éléments différés (Delayed) qui expirent si le délai est inférieur ou égal à zéro.

Tant qu'aucun élément n'a expiré, la file ne possède pas de tête à tirer ; c'est-à-dire que :

- peek() renvoie un élément qui n'a pas encore expiré ;

- element() renvoie un élément qui n'a pas encore expiré ;

- poll() renvoie null ;

- remove() lance une NoSuchElementException ;

- drainTo() ne retire aucun élément ;

- take() attend le premier élément à expirer.

DelayQueue - démo (code)

1. //com.developpez.lmauzaize.java.concurrence.ch03_structure_donnees.DelayQueueDemo

2. class Différé implements Delayed {

3. long expiration;

4. String toString;

5. Différé(long temps, TimeUnit unité) {

6. long now   = System.nanoTime();

7. long nanos = unité.toNanos(temps);

8. expiration = now + nanos;

9. toString   = temps + " " + unité;

10. }

12. public long getDelay(TimeUnit unité) {

13. long now   = System.nanoTime();

14. long temps = expiration - now;

15. long délai = unité.convert(temps, TimeUnit.NANOSECONDS);

16. return délai;

17. }

19. public int compareTo(Delayed o) {

20. Différé d = (Différé) o;

21. long diff = this.expiration - d.expiration;

22. if (diff > Integer.MAX_VALUE) return Integer.MAX_VALUE;

23. if (diff < Integer.MIN_VALUE) return Integer.MIN_VALUE;

24. return (int) diff;

25. }

27. public String toString() {

28. return toString;

29. }

30. }

31. DelayQueue<Différé> file = new DelayQueue<>();

32. for (long délai : new long[] { 5, 2, 1, 3, 4, 6 }) {

33. Différé d = new Différé(délai, TimeUnit.SECONDS);

34. file.add(d);

35. }

37. Runnable afficherFile = () -> {

38. Logger.println("File : %s", file);

39. Logger.println("   %-10s= %s", "size" , file.size());

40. Logger.println("   %-10s= %s", "iterator", new ArrayList<>(file));

41. Logger.println("   %-10s= %s", "array", Arrays.toString(file.toArray()));

42. Logger.println("   %-10s= %s", "peek" , file.peek());

43. Logger.println("   %-10s= %s", "element" , file.element());

44. Logger.println("   ***");

45. Logger.println("   %-10s= %s", "poll" , file.poll());

46. Object remove;

47. try {

48. remove = file.remove();

49. } catch (NoSuchElementException e) {

50. remove = e;

51. }

52. Logger.println("   %-10s= %s", "remove" , remove);

53. List<Différé> liste = new ArrayList<>();

54. file.drainTo(liste);

55. Logger.println("   %-10s= %s", "drainTo", liste);

57. };

60. afficherFile.run();

61. Logger.println("");

62. Logger.println("Sleep");

63. Logger.println("");

64. TimeUnit.SECONDS.sleep(5);

65. afficherFile.run();

DelayQueue - démo (console)

1. 00:00:00.021 [main           ] File : [1 SECONDS, 3 SECONDS, 2 SECONDS, 5 SECONDS, 4 SECONDS, 6 SECONDS]

2. 00:00:00.030 [main           ]    size      = 6

3. 00:00:00.030 [main           ]    iterator  = [1 SECONDS, 3 SECONDS, 2 SECONDS, 5 SECONDS, 4 SECONDS, 6 SECONDS]

4. 00:00:00.030 [main           ]    array     = [1 SECONDS, 3 SECONDS, 2 SECONDS, 5 SECONDS, 4 SECONDS, 6 SECONDS]

5. 00:00:00.031 [main           ]    peek      = 1 SECONDS

6. 00:00:00.031 [main           ]    element   = 1 SECONDS

7. 00:00:00.031 [main           ]    ***

8. 00:00:00.031 [main           ]    poll      = null

9. 00:00:00.032 [main           ]    remove    = java.util.NoSuchElementException

10. 00:00:00.032 [main           ]    drainTo   = []

11. 00:00:00.032 [main           ]

12. 00:00:00.032 [main           ] Sleep

13. 00:00:00.033 [main           ]

14. 00:00:05.033 [main           ] File : [1 SECONDS, 3 SECONDS, 2 SECONDS, 5 SECONDS, 4 SECONDS, 6 SECONDS]

15. 00:00:05.033 [main           ]    size      = 6

16. 00:00:05.034 [main           ]    iterator  = [1 SECONDS, 3 SECONDS, 2 SECONDS, 5 SECONDS, 4 SECONDS, 6 SECONDS]

17. 00:00:05.034 [main           ]    array     = [1 SECONDS, 3 SECONDS, 2 SECONDS, 5 SECONDS, 4 SECONDS, 6 SECONDS]

18. 00:00:05.035 [main           ]    peek      = 1 SECONDS

19. 00:00:05.035 [main           ]    element   = 1 SECONDS

20. 00:00:05.036 [main           ]    ***

21. 00:00:05.036 [main           ]    poll      = 1 SECONDS

22. 00:00:05.037 [main           ]    remove    = 2 SECONDS

23. 00:00:05.038 [main           ]    drainTo   = [3 SECONDS, 4 SECONDS, 5 SECONDS]

III-F - TransferQueue (1.7+)

Les files de transfert (TransferQueue) sont des files bloquantes qui permettent à un « producteur » d'attendre que sa ressource soit « consommée ». L'interface demeure la même pour le consommateur, mais le producteur peut :

- tenter de faire consommer sa ressource immédiatement via tryTransfer(E). La méthode renvoie un booléen indiquant si l'élément a été transféré ;

- tenter de faire consommer sa ressource pendant un certain délai via tryTransfer(E, long, TimeUnit). La méthode renvoie un booléen indiquant si l'élément a été transféré ;

- attendre que sa ressource soit consommée via transfer(E). La méthode attend jusqu'au traitement de la ressource ou l'interruption du thread.

La seule implémentation de cette interface est la classe LinkedTransferQueue, comme son nom l'indique elle repose sur une liste chaînée.

TransferQueue - démo (code)

1. //com.developpez.lmauzaize.java.concurrence.ch03_structure_donnees.TransferQueueDémo

2. class TransferQueueDémo {

4. TransferQueue<String> file = new LinkedTransferQueue<>();

6. void tryTransfer(String valeur) {

7. Logger.println("   résultat=%b", file.tryTransfer(valeur));

8. };

9. void tryTransferAttente(String valeur) {

10. Logger.println("   résultat=%b", file.tryTransfer(valeur, 5, TimeUnit.SECONDS));

11. };

12. void transfer(String valeur) {

13. file.transfer(valeur);

14. Logger.println("   transfert terminé");

15. };

17. void avecConsommateur() {

18. new Thread("Consommateur") {

19. public void run() {

20. Logger.println("   pause");

21. TimeUnit.SECONDS.sleep(2);

22. Logger.println("   lecture: %s", file.take());

23. }

24. }.start();

25. };

26. }

29. TransferQueueDémo démo = new TransferQueueDémo();

31. // tryTransfer - immédiat

32. Logger.println("tryTransfer: A");

33. démo.tryTransfer("A");

34. Logger.println("");

36. // tryTransfer - expiration

37. Logger.println("tryTransfer: B, attente 5s");

38. démo.tryTransferAttente("B");

39. Logger.println("");

41. // tryTransfer - attente

42. Logger.println("tryTransfer: C, attente 5s");

43. démo.avecConsommateur();

44. démo.tryTransferAttente("C");

45. Logger.println("");

47. // transfer

48. Logger.println("transfer: D");

49. démo.avecConsommateur();

50. démo.transfer("D");

TransferQueue - démo (console)

1. 00:00:00.021 [main           ] tryTransfer: A

2. 00:00:00.030 [main           ]    résultat=false

3. 00:00:00.030 [main           ]

4. 00:00:00.031 [main           ] tryTransfer: B, attente 5s

5. 00:00:05.032 [main           ]    résultat=false

6. 00:00:05.032 [main           ]

7. 00:00:05.033 [main           ] tryTransfer: C, attente 5s

8. 00:00:05.035 [Consommateur   ]    pause

9. 00:00:07.036 [Consommateur   ]    lecture: C

10. 00:00:07.036 [main           ]    résultat=true

11. 00:00:07.037 [main           ]

12. 00:00:07.038 [main           ] transfer: D

13. 00:00:07.039 [Consommateur   ]    pause

14. 00:00:09.039 [Consommateur   ]    lecture: D

15. 00:00:09.039 [main           ]    transfert terminé

IV - CopyOnWrite List/Set

Les collections CopyOnWrite* sont des collections concurrentes qui stockent leurs éléments dans un tableau. Chaque modification entraîne la création d'un nouveau tableau en effectuant une copie de l'original.

Les itérateurs conservent une référence du tableau utilisé lors de leur création. Ils sont hermétiques aux modifications qui surviennent sur la collection source et ne supportent pas les opérations de modifications (remove, add et set).

Ces collections sont à utiliser si le nombre de modifications est peu important et/ou que le nombre d'éléments n'est pas important. Les modifications sont très coûteuses, mais ces collections se révèlent efficaces dès lors que les parcours surpassent largement les modifications. La classe de base est CopyOnWriteArrayList, il existe une variante CopyOnWriteArraySet qui implémente l'interface Set ; cette dernière encapsule une instance de CopyOnWriteArrayList pour stocker les données.

CopyOnWriteArrayList - démo (API)

1. //com.developpez.lmauzaize.java.concurrence.ch03_structure_donnees.CopyOnWriteArrayListDemo

2. class CopyOnWriteArrayListDemo {

3. // Pour chaque implémentation, initialise une instance, puis joue le scénario 10 fois et enfin affiche le temps moyen par itération.

4. void test(String action);

6. // Actions : créer une tâche qui sera exécutée N fois en parallèle avec une pause optionnelle avant.

7. //  - Effectue "n" insertions par thread

8. CopyOnWriteArrayListDemo add(thread, int n, int délai);

9. //  - Itère la collection à chaque thread

10. CopyOnWriteArrayListDemo iterator(int thread, final int délai);

11. //  - Effectue "n" retraits par thread

12. CopyOnWriteArrayListDemo remove(int thread, int quantité, int délai);

14. }

CopyOnWriteArrayList - démo (main)

1. //com.developpez.lmauzaize.java.concurrence.ch03_structure_donnees.CopyOnWriteArrayListDemo

2. final CopyOnWriteArrayListDemo démo = new CopyOnWriteArrayListDemo();

3. int thread, quantité, remplissage, délai = 0;

5. démo.add(thread=  1, quantité=10_000, délai).test(remplissage=0, "Ajout linéaire");

6. démo.add(thread=100, quantité=   100, délai).test(remplissage=0, "Ajout parallèle");

8. démo.iterator(thread=1_000, délai).test(remplissage=1_000, "Parcours [1k/1k]");

9. démo.iterator(thread=5_000, délai).test(remplissage=1_000, "Parcours [5k/1k]");

10. démo.iterator(thread=1_000, délai).test(remplissage=3_000, "Parcours [1k/3k]");

11. démo.iterator(thread=3_000, délai).test(remplissage=3_000, "Parcours [3k/3k]");

13. for (int i = 0; i < 3; i++) {

14. délai = i*100;

15. démo

16. .add     (thread=  100, quantité=10, délai)

17. .iterator(thread=1_000,              délai)

18. .remove  (thread=  100, quantité= 5, délai);

19. }

20. démo.test(remplissage=1_000, "Concurrence");

CopyOnWriteArrayList - démo (console)

1. Test: Ajout linéaire   , File: CopyOnWriteArrayQueue, Temps: 00:00:00.035

2. Test: Ajout linéaire   , File:    ArrayBlockingQueue, Temps: 00:00:00.000

4. Test: Ajout parallèle  , File: CopyOnWriteArrayQueue, Temps: 00:00:00.040

5. Test: Ajout parallèle  , File:    ArrayBlockingQueue, Temps: 00:00:00.001

7. Test: Parcours [1k/1k] , File: CopyOnWriteArrayQueue, Temps: 00:00:00.005

8. Test: Parcours [1k/1k] , File:    ArrayBlockingQueue, Temps: 00:00:00.038

10. Test: Parcours [5k/1k] , File: CopyOnWriteArrayQueue, Temps: 00:00:00.007

11. Test: Parcours [5k/1k] , File:    ArrayBlockingQueue, Temps: 00:00:00.172

13. Test: Parcours [1k/3k] , File: CopyOnWriteArrayQueue, Temps: 00:00:00.003

14. Test: Parcours [1k/3k] , File:    ArrayBlockingQueue, Temps: 00:00:00.085

16. Test: Parcours [3k/3k] , File: CopyOnWriteArrayQueue, Temps: 00:00:00.005

17. Test: Parcours [3k/3k] , File:    ArrayBlockingQueue, Temps: 00:00:00.263

19. Test: Concurrence      , File: CopyOnWriteArrayQueue, Temps: 00:00:00.331

20. Test: Concurrence      , File:    ArrayBlockingQueue, Temps: 00:00:00.409

V - Maps concurrentes

V-A - ConcurrentSkipList

Une liste à enjambements (Skip-list) est une structure de données triées qui utilise plusieurs niveaux de listes chaînées pour accéder plus rapidement à un élément. Voici un schéma montrant la composition interne d'une telle structure :

Structure d'une 'skip list'

1. 1

2. 1-----4---6

3. 1---3-4---6-----9

4. 1-2-3-4-5-6-7-8-9-10

Pour localiser un élément, on procède couche par couche. On recherche le plus petit élément correspondant dans la couche courante, puis on passe dans la couche suivante par mouvement vertical (en réalité, un pointeur sur le nœud correspondant de la couche suivante). Il s'agit donc d'une sorte de structure arborescente dont un nœud est chaîné à la fois à son voisin et à son équivalent dans la couche inférieure.

En Java, cette structure est représentée par la classe ConcurrentSkipListMap qui gère également les accès concurrents. Voici une liste de points clés à retenir :

- l'itération (en concurrence avec les modifications) est supportée même si les modifications postérieures peuvent ne pas être visibles ;

- Les paires clé-valeur renvoyées ne sont pas modifiables ;

- déterminer la taille du dictionnaire nécessite de le parcourir ;

- les opérations massives (ex : putAll, ou même equals) ne sont pas nécessairement atomiques ;

- la clé null n'est pas supportée ;

- les opérations « par ordre décroissant » (ex. : descendingMap) sont moins performantes que les opérations classiques.

ConcurrentSkipListSet est une version implémentant l'interface Set ; elle utilise une ConcurrentSkipListMap en interne.

ConcurrentSkipListMap - démo (API)

1. //com.developpez.lmauzaize.java.concurrence.ch03_structure_donnees.ConcurrentSkipListMapDemo

2. class ConcurrentSkipListMapDemo {

3. // Modifie le nom du thread courant et exécute séquentiellement ses actions

4. class Activité implements Runnable {

5. String nom;

6. List<Runnable> actions = new ArrayList<>();

8. // Actions

9. /** Insère la clé "<nom>-<i>" **/

10. Activité put(int i);

11. /** Supprime la clé **/

12. Activité remove(String clé);

13. /** Crée une map content "n" clé ("<nom>-<i>") et l'insère massivement **/

14. Activité putAll(int début, int n);

15. /** Itère sur les entrées et fait une pause entre chaque **/

16. Activité parcours(TimeUnit unité, long temps);

17. /** Effectue une pause **/

18. Activité pause(TimeUnit unité, int temps);

19. }

21. ConcurrentSkipListMap<String, String> map = new ConcurrentSkipListMap<>();

22. List<Activité> activités = new ArrayList<>();

23. List<Runnable> après     = Collections.emptyList();

24. List<Runnable> avant     = Collections.emptyList();

26. // Pré/post actions

27. /** Indique les actions avant "exécuter" **/

28. ConcurrentSkipListMapDemo avant();

29. /** Indique les actions après "exécuter" **/

30. ConcurrentSkipListMapDemo après();

31. /** Insère "n" clés **/

32. ConcurrentSkipListMapDemo remplir(int n);

33. /** Affiche les noms de threads consécutifs **/

34. ConcurrentSkipListMapDemo verifierChangementThread();

35. /** Crée une nouvelle activité. Les activités sont exécutées en parallèle **/

36. Activité activité(String nom);

37. /** Exécute les actions "avant", les activités en parallèle, puis actions "après". Enfin, réinitialise l'instance.  **/

38. void exécuter();

39. }

ConcurrentSkipListMap - démo (main)

1. //com.developpez.lmauzaize.java.concurrence.ch03_structure_donnees.ConcurrentSkipListMapDemo

2. ConcurrentSkipListMapDemo demo = new ConcurrentSkipListMapDemo();

4. Logger.println("---------------------------------");

5. Logger.println("Itération");

6. demo.avant().remplir(5);

7. demo.activité("iter0")                                   .parcours(TimeUnit.MILLISECONDS, 100);

8. demo.activité("iter1") .pause(TimeUnit.MILLISECONDS, 200).parcours(TimeUnit.MILLISECONDS, 100);

9. demo.activité("put_10").pause(TimeUnit.MILLISECONDS, 500).put(10);

10. demo.activité("rmv_02").pause(TimeUnit.MILLISECONDS, 400).remove("main-02");

11. demo.activité("iter2") .pause(TimeUnit.MILLISECONDS, 800).parcours(TimeUnit.MILLISECONDS,   0);

12. demo.exécuter();

14. Logger.println("---------------------------------");

15. Logger.println("Ajout massif");

16. for (int i = 0; i < 5; i++) {

17. demo.activité("putAll" + i).putAll(0, 200_000);

18. }

19. demo.après().verifierChangementThread();

20. demo.exécuter();

ConcurrentSkipListMap - démo (console)

1. 00:00:00.022 [main           ] ---------------------------------

2. 00:00:00.031 [main           ] Itération

3. 00:00:00.139 [iter0          ] main-00=main

4. 00:00:00.239 [iter0          ] main-01=main

5. 00:00:00.339 [iter0          ] main-02=main

6. 00:00:00.339 [iter1          ] main-00=main

7. 00:00:00.439 [iter0          ] main-03=main

8. 00:00:00.439 [rmv_02         ] Retrait main-02

9. 00:00:00.440 [iter1          ] main-01=main

10. 00:00:00.539 [iter0          ] main-04=main

11. 00:00:00.539 [put_10         ] Ajout put_10-0010

12. 00:00:00.540 [iter1          ] main-02=main

13. 00:00:00.640 [iter1          ] main-03=main

14. 00:00:00.740 [iter1          ] main-04=main

15. 00:00:00.839 [iter2          ] main-00=main

16. 00:00:00.839 [iter2          ] main-01=main

17. 00:00:00.840 [iter1          ] put_10-0010=put_10

18. 00:00:00.840 [iter2          ] main-03=main

19. 00:00:00.841 [iter2          ] main-04=main

20. 00:00:00.841 [iter2          ] put_10-0010=put_10

21. 00:00:00.842 [main           ] ---------------------------------

22. 00:00:00.843 [main           ] Ajout massif

23. 00:00:02.306 [main           ] [putAll0 x 200 000, putAll1 x 200 000, putAll2 x 200 000, putAll3 x 200 000, putAll4 x 200 000]

V-B - ConcurrentHashMap

Contrairement aux « Skip-List », ce type de dictionnaire repose sur l'identité et l'empreinte d'un objet (comme HashMap, avec la gestion de la concurrence en plus). Cette implémentation est à privilégier par rapport à Hashtable. En effet, Hashtable offre de moins bonnes performances et ne supporte pas l'itération en concurrence avec les modifications (elle implémente le principe fail-fast). Pour améliorer les performances, ConcurrentHashMap repose sur deux principes :

- des lectures non bloquantes (la plupart du temps) ;

- le partitionnement de sa structure interne.

Lors de modification, seule la partition concernée est verrouillée. Le nombre de partitions se contrôle à l'aide du paramètre concurrencyLevel. En réalité, le nombre de partitions correspondra à la première puissance de 2 supérieure (ou égale), sans pouvoir excéder 65 536. Ce nombre doit correspondre aux nombres de threads qui peuvent potentiellement modifier simultanément la Map. Ainsi si un seul thread à la fois est censé écrire dans la Map (et quel que soit le nombre de lectures concurrentes), une valeur de 1 est parfaitement adéquate. S'il n'est pas nécessaire d'avoir une estimation exacte de la concurrence, il est nécessaire d'avoir un ordre de grandeur proche sous peine de dégrader les performances de la Map (temps d'exécution des opérations et consommation mémoire) que ce nombre soit sous-estimé ou surestimé.

Comme pour la plupart des Maps, il est également recommandé de « tailler » correctement la Map, c'est-à-dire de positionner la taille (initial capacity) et le facteur de charge (load factor).

Les mesures qui suivent concernent l'impact du niveau de concurrence sur les performances de la Map. Ce test consiste à exécuter 16 threads qui vont ajouter 1 000 000 de clés aléatoires (parmi 1 024 clés possibles). On fait varier le niveau de concurrence à chaque scénario.

ConcurrentHashMap - niveau de concurrence (code)

1. //com.developpez.lmauzaize.java.concurrence.ch03_structure_donnees.ConcurrentHashMapConcurrence

2. // Paramètres

3. int thread =        16;

4. int taille = 2_000_000;

5. int limite =     1_024;

7. // Génération des valeurs possibles

8. String[] cache = IntStream.range(0, limite).mapToObj(String::valueOf).toArray(String[]::new);

10. // Génère une liste fixe de tâches pour tous les scénarios

11. List<Callable<String>> tasks = new ArrayList<>(thread);

12. Random random = new Random();

13. for (int i = 0; i < thread; i++) {

14. List<String> list = random.ints(0, limite).limit(taille).mapToObj(key -> cache[key]).collect(Collectors.toCollection(() -> new ArrayList<>(taille)));

15. Callable<String> task = () -> {

16. String name = Thread.currentThread().getName();

17. for (String item : list) {

18. map.put(item, name);

19. }

20. return name;

21. };

22. tasks.add(task);

23. }

25. ExecutorService threadpool = Executors.newFixedThreadPool(thread);

26. for (int concurrence = 1; concurrence <= 256; concurrence*=2) {

27. map = new ConcurrentHashMap<>(limite, 1.0f, concurrence);

28. int count = 0;

29. int total = 0;

30. for (int i = 0; i < 50; i++) {

31. map.clear();

32. long start = System.currentTimeMillis();

33. threadpool.invokeAll(tasks);

34. long spent = System.currentTimeMillis() - start;

35. if (i >= 20) {

36. count++;

37. total += spent;

38. }

39. }

40. Logger.println("test: %04d, classe: %17s temps: %tT.%<tL", concurrence, map.getClass().getSimpleName(), new Date(total / count));

41. }

42. threadpool.shutdownNow();

ConcurrentHashMap - niveau de concurrence (console)

1. 00:00:00.016 [main           ] test: 0001, classe: ConcurrentHashMap temps: 00:00:00.639

2. 00:00:32.648 [main           ] test: 0002, classe: ConcurrentHashMap temps: 00:00:00.637

3. 00:01:06.252 [main           ] test: 0004, classe: ConcurrentHashMap temps: 00:00:00.664

4. 00:01:40.124 [main           ] test: 0008, classe: ConcurrentHashMap temps: 00:00:00.682

5. 00:02:13.029 [main           ] test: 0016, classe: ConcurrentHashMap temps: 00:00:00.665

6. 00:02:49.347 [main           ] test: 0032, classe: ConcurrentHashMap temps: 00:00:00.698

7. 00:03:22.275 [main           ] test: 0064, classe: ConcurrentHashMap temps: 00:00:00.666

8. 00:03:56.879 [main           ] test: 0128, classe: ConcurrentHashMap temps: 00:00:00.667

9. 00:04:32.157 [main           ] test: 0256, classe: ConcurrentHashMap temps: 00:00:00.699

Dans cet exemple, on n'observe aucun impact significatif du niveau de concurrence. Ces mesures ont été effectuées sur un Intel Core i5-3470 @ 3.20 GHz (4 cœurs / 4 threads) et un impact plus significatif pourrait être observé sur une machine avec plus de cœurs.

Pour réaliser les mesures suivantes, nous allons utiliser cette classe utilitaire :

ConcurrentHashMap - utilitaire

1. //com.developpez.lmauzaize.java.concurrence.ch03_structure_donnees.ConcurrentHashMapDemo

2. class ConcurrentHashMapDemo {

3. // Type de map utilisable

4. static enum TypeMap {

5. CONCURRENT, HASHTABLE;

6. }

7. TypeMap[] typeMaps = TypeMap.values();

9. // Paramètres des maps

10. int taille = 16, concurrence = 16;

11. float charge = 0.75f;

13. // Tâches

14. // - Ajoute n clés au hasard

15. Runnable ajout(int n);

16. // - Lit n clés au hasard

17. Runnable lecture(int n);

19. // Exécute la liste de tâches programmées en plusieurs itérations et indique le temps d'exécution moyen

20. void bench();

21. }

La seconde mesure consiste à comparer les performances avec une Hashtable. Une première série de scénarios consiste à observer l'impact du nombre de threads, on conserve cependant un nombre constant d'insertions totales. Le dernier scénario consiste à observer l'impact des lectures concurrentes.

ConcurrentHashMap - ConcurrentHashMap vs Hashtable (code)

1. //com.developpez.lmauzaize.java.concurrence.ch03_structure_donnees.ConcurrentHashMapVsHashtable

2. ConcurrentHashMapDemo demo = new ConcurrentHashMapDemo();

4. demo.tache(    3, demo.ajout(2_000_000));

5. demo.bench();

6. Logger.println("");

8. demo.tache(   30, demo.ajout( 200_000));

9. demo.bench();

10. Logger.println("");

12. demo.tache(  300, demo.ajout(  20_000));

13. demo.bench();

14. Logger.println("");

16. demo.tache(    3, demo.ajout(2_000_000));

17. demo.tache(1_000, demo.lecture(100));

18. demo.bench();

19. Logger.println("");

ConcurrentHashMap - ConcurrentHashMap vs Hashtable (console)

1. 00:00:00.062 [main           ] test: [3*put(2M)], classe: ConcurrentHashMap temps: 00:00:00.907

2. 00:02:14.320 [main           ] test: [3*put(2M)], classe:         Hashtable temps: 00:00:02.620

3. 00:02:14.321 [main           ]

4. 00:02:58.471 [main           ] test: [30*put(200k)], classe: ConcurrentHashMap temps: 00:00:00.792

5. 00:05:12.714 [main           ] test: [30*put(200k)], classe:         Hashtable temps: 00:00:02.693

6. 00:05:12.715 [main           ]

7. 00:05:55.347 [main           ] test: [300*put(20k)], classe: ConcurrentHashMap temps: 00:00:00.822

8. 00:08:11.216 [main           ] test: [300*put(20k)], classe:         Hashtable temps: 00:00:02.722

9. 00:08:11.217 [main           ]

10. 00:09:04.250 [main           ] test: [3*put(2M), 1k*get(100)], classe: ConcurrentHashMap temps: 00:00:00.982

11. 00:11:16.949 [main           ] test: [3*put(2M), 1k*get(100)], classe:         Hashtable temps: 00:00:02.643

12. 00:11:16.950 [main           ]

En premier lieu, on observe de bien meilleures performances (d'un facteur 3) de la ConcurrentHashMap par rapport à la Hashtable.

Autre fait remarquable, le nombre de threads n'a pas d'incidence sur le temps de traitement global. Ceci peut être dû comme précédemment au nombre de cœurs de la machine de test.

Enfin, le dernier scénario met en lumière le faible impact des lectures concurrentes.

VI - Conclusion

Au cours de ce troisième article, nous avons vu comment gérer le stockage de vos données pour une utilisation concurrente. Le prochain article sera dédié aux différents types de verrous.

VII - Remerciements

Je remercie tous les contributeurs de l'API Java et les packages java.util.concurrent.* et plus particulièrement Doug Lea qui est l'un des principaux auteurs.

Je remercie également Mickael Baron, Thierry Leriche-Dessirier alias thierryler, Claude Leloup et f-leb pour leur relecture attentive, leurs remarques et leurs bons conseils.

Je tiens aussi à remercier la communauté Developpez.com qui a mis en place tous les outils, procédures et l'hébergement nécessaires à la publication de cet article.

Enfin mon épouse et mes enfants pour leur patience et leur tolérance durant les nombreuses heures qui ont été nécessaires à la rédaction de cet article.

VIII - Annexes

VIII-A - Sources des exemples

Tous les exemples donnés dans cet article sont disponibles sous la forme d'un projet Maven hébergé sous GitHub. Tous les exemples cités contiennent une première ligne commentaire indiquant l'emplacement du fichier dans les sources. Les sources propres à ce chapitre se trouvent sous le package com.developpez.lmauzaize.java.concurrence.ch03_structure_donnees.

Si vous ne savez pas comment importer le projet, je vous invite à consulter l'article « Importer un projet Maven dans Eclipse en 5 minutes ».

VIII-B - Java Concurrent Animated

Java Concurrent Animated est un projet Swing visant à montrer graphiquement le comportement de différents composants de l'API concurrente de Java.