La concurrence intervient quand on exécute plusieurs morceaux de code en même temps.
La concurrence est utile, voire nécessaire, dans deux situations :
L'orsque l'on exécute un programme comme Firefox ou Eclipse, l'OS créé un processus qui a sa propre mémoire ainsi qu'un fil d'exécution appelé thread.
Un processus peut contenir plusieurs threads.
Chaque thread :
Tous ces threads s'exécutent en même temps et vont lire et écrire (les champs) dans la même zone mémoire.
C'est toute la problématique de ce cours !
Le scheduler demande l'exécution des différents threads sur les cœurs du CPU pour un certain laps de temps.
java.lang.Thread
L'objet Java Thread
existe avant et après le moment où le thread (le fil d'exécution) existe.
Pour créer un thread, il faut uniquement le code qu'il doit exécuter.
Un code est un objet implémentant l'interface Runnable
.
@FunctionalInterface public interface Runnable { /* public abstract */ void run(); }
Thread
, puis on le démarre :
Runnable runnable = ... Thread thread = new Thread(runnable); thread.start();
Thread.ofPlatform()
est un builder qui va créer l'objet Thread
, et c'est la méthode start()
qui démarre le fil d'exécution sous-jacent.
Runnable runnable = ... Thread thread = Thread.ofPlatform().start(runnable);
Il y a plusieurs façons de créer un Runnable
:
Runnable
;
On pourrait, techniquement, hériter de la classe Thread
, mais on ne le fait jamais.
public class Application { private static void myRun() { System.out.println("Hello Thread"); } public static void main(String[] args) { Runnable runnable = () -> { System.out.println("Hello Thread"); }; var thread1 = Thread.ofPlatform().start(Application::myRun); var thread2 = Thread.ofPlatform().start(runnable); } }
Avant le start() :
Après le start() :
La création d'un objet Thread
ne créé pas le fil d'exécution
La création du fil d'exécution se fait avec la méthode start()
qui :
run()
du Runnable ;run()
finie, le thread de l'OS meurt.L'objet Java Thread
ne meurt que lorsqu'il est garbage collecté.
Comme il y a moins de cœurs de CPU que de threads potentiels, le scheduler de l'OS est chargé de trouver un cœur disponible pour exécuter chaque thread.
Un thread occupe un cœur :
Le scheduler essaye de donner le même temps de CPU à chaque thread, on dit que le scheduler est fair (équitable).
On ne contrôle rien :
Les threads plateform sont schedulés par le scheduler de l'OS, ce qui requiert une commutation de contexte (context switch : userland / kernel).
Les threads virtuels (depuis Java 19) sont schedulés par le scheduler de la machine virtuelle Java ce qui réduit les problèmes de latence.
Remarque : c'est la même classe Java qui contrôle et manipule les threads plateform ou virtuels.
Pour un thread virtuel, on utilise le builder Thread.ofVirtual().
Runnable runnable = ... Thread thread = Thread.ofVirtual().start(runnable);
Dans ce cours, on utilisera principalement des threads plateform, on reviendra sur les threads virtuels en fin de semestre.
Il créé par défaut autant de threads plateform que de cœurs et schedule les threads virtuels sur ces threads plateform.
Un thread virtuel occupe un thread plateform :
Comme avec le scheduler de l'OS, on ne contrôle rien.
Pour des raisons de debuggage, on peut attribuer un nom avant la création du thread
avec la méthode builder.name(String name)
.
var thread = Thread.ofPlatform().name("Listener thread").start(runnable);
On peut aussi utiliser setName(String name)
sur le thread.
On peut récupérer l'objet Thread
qui est en train d'exécuter notre code grâce à la méthode statique Thread.currentThread()
.
Thread.ofPlatform().name("Listener thread").start(() -> { var groot = Thread.currentThread(); System.out.println("I am " + groot); System.out.println("My name is " + groot.getName()); });
I am Thread[#20,Listener thread,5,main] My name is Listener thread
public static void main(String[] args) { var runnable = () -> { System.out.println(Thread.currentThread().getName()); }; Thread.ofPlatform().name("Thread 1").start(runnable); Thread.ofPlatform().name("Thread 2").start(runnable); System.out.println(Thread.currentThread().getName()); }
Que peut afficher ce programme (dans quel ordre) ?
Ce code peut afficher les noms des threads dans n'importe quel ordre.
Il n'y a aucun moyen de tuer un thread. On verra plus tard qu'il est possible de lui demander (gentiment) de s'arrêter.
Un thread meurt quand sa méthode run se termine :
return
,On ne peut pas redémarrer un thread.
La JVM (et donc votre programme) ne s'arrête que quand tous les threads (non-daemon) sont terminés.
La fin du thread main
n'implique pas nécessairement l'arrêt de la JVM.
Thread.ofPlatform().start(() -> { for (;;) { System.out.println("Top"); } }); System.out.println("Fini");
Le thread main
est terminé (on affiche "Fini"), mais la JVM ne s'arrête pas, on continue d'afficher "Top".
Un thread daemon est un thread dont l’exécution n’empêche pas la JVM de s'arrêter (comme celui du garbage collector).
Thread.ofPlatform().daemon().start(() -> { for (;;) { System.out.println("Top"); } }); System.out.println("Fini");
Le thread main
est terminé (on affiche "Fini"), et la JVM s'arrête.
Le thread courant peut attendre la mort d'un autre thread en utilisant la méthode join
de cet autre thread (otherThread
) :
otherThread.join();
Cela permet d'introduire un ordre d'exécution sur le code.
Après l'appel à join()
, on est sûr que le code de la méthode run()
de otherThread
est fini.
join()
public static void main(String[] args) throws InterruptedException { var thread1 = Thread.ofPlatform().start(() -> { System.out.println("Thread 1"); }); var thread2 = Thread.ofPlatform().start(() -> { System.out.println("Thread 2"); }); thread1.join(); thread2.join(); System.out.println("Thread main"); }
Ce code ne peut plus afficher les noms des threads dans n'importe quel ordre.
public static void main(String[] args) throws InterruptedException { ...
L'appel thread.join()
peut lever une InterruptedException
.
Contrairement à ce que son nom indique, cette exception sera déclenchée par votre code, si vous demandez au
thread de s’arrêter (on verra plus tard comment) et qu'il est en attente dans sa méthode join
. On dit que cette méthode est bloquante.
Pour l'instant, cette exception ne sera jamais levée et ne nécessite donc pas de traitement (d'où l'utilisation de throws
).
La méthode statique Thread.sleep(int milliseconds)
permet au thread qui l'exécute d’attendre un certain nombre de millisecondes, sans rien faire.
Tout comme join()
, c'est un exemple de méthode bloquante.
Thread.ofPlatform().start(() -> { try { Thread.sleep(5000); // wait for 5 seconds } catch (InterruptedException e) { throw new AssertionError(e); } System.out.println("Done"); });
Comme pour thread.join
, l'exception InterruptedException
ne sera pour l'instant jamais levée mais il faut la gérer avec un bloc try-catch
car la méthode run
ne peut pas lever d'exception checked (ça ne correspond pas au contrat de l'interface fonctionnelle).
Concevoir et mettre en œuvre des applications logicielles, en Java, qui utilisent de la concurrence.
Les principes et les compétences de ce cours vous permettront de réaliser des applications concurrentes en se basant sur les verrous et les opérations atomiques.