Dans la classe ArrayList
:
private Object[] elementData; private int size; ... private void add(E e, Object[] elementData, int s) { if (s == elementData.length) elementData = grow(); // renvoie un tab. plus grand elementData[s] = e; size = s + 1; } public boolean add(E e) { modCount++; add(e, elementData, size); return true; }
Dans le cas où la capacité initiale est fixée et assez grande, grow()
n'est jamais appelée.
À partir de là, on peut donner un scénario pour que la taille ne soit pas 20 000.
public class Counter { private int value; public void addALot() { for (var i = 0; i < 10_000; i++) { value = value + 1; // ce n'est pas une opération atomique! } } public static void main(String[] args) throws InterruptedException { var counter = new Counter(); var thread1 = Thread.ofPlatform().start(counter::addALot); var thread2 = Thread.ofPlatform().start(counter::addALot); thread1.join(); thread2.join(); System.out.println(counter.value); // :( } }
On voudrait s'assurer que 2 threads différents ne puissent pas être en train d’exécuter le code qui correspond à l'instruction value = value + 1
en même temps.
Et on voudrait aussi garantir qu'on ne va pas essayer de lire la valeur de value
avant que l'écriture précédente soit finie (c'est à dire répercutée en RAM et pas juste gardée dans une variable locale ou en cache).
C'est ce à quoi sert une section critique.
public class Counter { private int value; public void addALot() { for (var i = 0; i < 10_000; i++) { // début section critique value = value + 1; // fin section critique } } }
Un seul thread à la fois peut être en train d’exécuter le code de la section critique !
Attention, ça ne veut PAS dire que le thread ne peut pas être de-schédulé s'il est dans la section critique.
Est-ce que c'est une garantie suffisante?
Non... il faut aussi que ça assure que la RAM est mise à jour correctement!
On veut pouvoir "protéger" plusieurs portions de code différentes :
public class Counter { private int value; public void addALot() { for (var i = 0; i < 10_000; i++) { // début section critique value = value + 1; // fin section critique } } public void add42() { // début section critique if (value != 13){ value = value + 42; } // fin section critique } }
synchronized
et lock
public class Counter { private int value; private final Object lock = new Object(); public void addALot() { for (var i = 0; i < 10_000; i++) { synchronized(lock){ value = value + 1; } } } }
C'est important que l'objet qui sert de lock (on dit aussi moniteur) soit privé et final, on verra bientôt pourquoi.
synchronized
C'est un verrou : quand un thread entre dans un bloc synchronized
, il prend un jeton associé à l'objet lock et lorsqu'il sort du bloc synchronized
, il rend le jeton.
synchronized
C'est une barrière en mémoire entre le blocs synchronisés sur le même lock :
synchronized
(sur le même verrou).
synchronized
(précisions)synchronized
impose que l'optimiseur de code/JIT
ne déplace pas des instructions hors du bloc.
synchronized
les instructions peuvent être ré-organisées sans problème.
synchronized
invalide les registres ; sa sortie oblige la ré-écriture des registres vers la RAM ;
synchronized
n’empêche pas la RAM d'être dans un état incohérent au milieu du bloc...
null
;
L'interning est une technique utilisée pour éviter d'allouer plusieurs fois un objet représentant la même chose. Il y en a notamment dans :
java.lang.Integer
avec Integer.valueOf
ou auto-boxing.
Par exemple :
Object o = 3; // appelle implicitement Integer.valueOf(3)
java.lang.Class
avec ".class"
ou Class.forName()
On n'utilise pas ces objets en tant que lock pour éviter que le même objet serve de verrou pour des besoins différents.
On empêche que les locks soient visibles hors du code nécessaire et on on utilise le principe d'encapsulation de la POO :
private
;Ces objets ne doivent pas pouvoir servir de verrou en dehors de votre code !
Enfin, on évite de prendre un objet qui est peut déjà être utlisé comme lock (comme System.out
, par exemple)
final
.L'objet qui sert de lock doit être final car on ne veut pas que la référence de l'objet puisse changer.
Ça ne suffit pas ! Il faut qu'il soit déclaré final
. Sinon, il est possible pour un thread de le voir alors qu'il n'est pas encore initialisé (c'est lié au problème de publication que nous verrons plus tard) et dans ce cas l'objet est null
.
public class Foo { private final Object lock = new Object(); ... public void bar() { synchronized(lock){ ... } } }
Un même thread peut prendre plusieurs fois le même lock :
public class Foo { private int value; private final Object lock = new Object(); public void setValue(int value) { synchronized (lock) { this.value = value; } } public void reset() { synchronized (lock) { setValue(0); } } }
public class HelloListFixed { public static void main(String[] args) { var nbThreads = 4; var threads = new Thread[nbThreads]; var list = new ArrayList<Integer>(); var lock = new Object(); IntStream.range(0, nbThreads).forEach(j -> { threads[j] = Thread.ofPlatform().start(() -> { for (var i = 0; i < 5000; i++) { synchronized(lock){ list.add(i); } } }); }); for (var thread : threads) { try { thread.join(); } catch (InterruptedException e) { throw new AssertionError(); } } System.out.println("taille de la liste : " + list.size()); } }NON ! Qu'est-ce qui ne va pas dans ce code ?
On appelle thread-safe une classe qui peut être utilisée par plusieurs threads sans avoir d'états incohérents.
Cette définition est nécessairement un peu floue car cela dépend du contrat spécifié par la classe.
Remarque : Un record ou une classe non-mutable est par définition thread-safe (par exemple: java.lang.String
).
En ce qui concerne l'API Java :
java.util.HashMap
), sauf si c'est explicitement spécifié dans la javadoc (par exemple : java.util.Random
).java.util.concurrent
sont toutes thread-safe.public class ThreadSafeList { private final ArrayList<Integer> list = new ArrayList<>(); private final Object lock = new Object(); public void add(int value) { synchronized (lock) { list.add(value); } } public int size() { synchronized (lock) { return list.size(); } } }
Attention à ne pas oublier le bloc synchronized
dans size()
. Par exemple, il est nécessaire pour que les modifications du add
soient bien relues en RAM.
public class HelloListBetter { public static void main(String[] args){ var nbThreads = 4; var threads = new Thread[nbThreads]; var list = new ThreadSafeList(); IntStream.range(0, nbThreads).forEach(j -> { threads[j] = Thread.ofPlatform().start(() -> { for (var i = 0; i < 5000; i++) { list.add(i); } }); }); for (var thread : threads) { try { thread.join(); } catch (InterruptedException e) { throw new AssertionError(); } } System.out.println("taille de la liste : " + list.size()); } }
Attention, une classe thread-safe, c'est un contrat qui vous garantit le comportement des méthodes de cette classe ....
... par contre, l'utilisation d'une classe thread-safe ne garantit pas que le code qui l'utilise est correct en termes de concurrence !
public class ThreadSafeList { private final ArrayList<Integer> list = new ArrayList<>(); private final Object lock = new Object(); ... public void removeFirst() { synchronized (lock) { list.removeFirst(); } } }
public static void main(String[] args) { var list = new ThreadSafeList(); for (int j = 0; j < 2; j++) { Thread.ofPlatform().start(() -> { for (var i = 0; i < 100; i++) { if (list.size() != 0) { list.removeFirst(); } } }); } for (var i = 0; i < 200; i++) { list.add(i); } }