Le Singleton en environnement Multithread

Christophe Jollivet

Dans cet article, je vais aborder le problème du Singleton en environnement multithread. Cette discussion nous amènera à parler du Java Memory Model et du double-check Locking qui, bien qu'encore recommandé dans de nombreux endroits, ne marche pas. ♪

- Titre : Le Singleton en environnement Multithread

- Auteur : Christophe Jollivet

- Parution : 14 février 2005

- 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 ® 2004 Christophe Jollivet. 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

- Remerciements

- I. Introduction

- II. Le Singleton

- II-A. Description

- II-B. Le Singleton en multithread

- III. Les solutions envisageables

- III-A. La gestion de la mémoire

- III-B. Utilisation de la synchronisation

- III-B-1. La synchronisation de getInstance()

- III-B-2. La synchronisation de l'appel au constructeur

- III-B-3. Le double-check locking

- III-B-3-a. Principe

- III-B-3-b. Pourquoi le DCL ne marche pas ?

- III-C. Utilisation de ThreadLocal

- III-D. Et pourquoi pas volatile ?

- IV. Les solutions qui fonctionnent

- V. Conclusion

- VI. Liens

Remerciements

Merci à Vedaer et Bestiol pour les corrections et suggestions.

I - Introduction

Le design pattern singleton fait partie des plus utilisés en programmation. Il permet de s'assurer qu'il n'y a qu'une seule instance d'une classe dans un environnement d'exécution et pour une durée d'exécution : Pour plus de détails.

Toutefois, dans un environnement multithread, l'utilisation de ce pattern nécessite quelques précautions pour limiter les problèmes d'accès concurrents. Dans cet article, nous allons évoquer le problème du singleton en environnement multithread.

II - Le Singleton

II-A - Description

Le principe du singleton repose sur un constructeur déclaré privé, un attribut permettant de stocker l'instance de la classe et une méthode statique qui vérifie qu'une instance de la classe a été créée et la renvoie. Lors du premier appel, cette méthode voyant que l'instance n'existe pas, elle la crée et la stocke dans l'attribut privé. Le code suivant représente la structure d'un singleton

import java.util.* ;
class Singleton{
private static Singleton instance;
private List maList;
private boolean etat;

private Singleton(){
maList= new ArrayList();
etat = true;
}
public static Singleton getInstance(){
if (instance==null)                    //1
instance=new Singleton();            //2
return instance;                    //3
}
}

Cette implémentation fonctionne, mais uniquement dans un environnement monothread. Le problème est que dans le cadre d'une implémentation multithread, la méthode getInstance() est susceptible de retourner deux instances différentes du Singleton si elle n'est pas protégée par synchronisation.

II-B - Le Singleton en multithread

Considérons deux threads appelant cette méthode de façon concurrente et la séquence suivante d'événements :

1. Thread 1 appelle getInstance() et détermine que instance est null en ligne //1 ;

2. Thread 1 entre dans le bloc if puis est préempté par le thread 2 avant l'exécution de la ligne //2 ;

3. Thread 2 appelle getInstance() et détermine que instance est null en ligne //1 ;

4. Thread 2 entre dans le bloc if, crée un nouveau Singleton et assigne ce nouvel objet à la variable instance en ligne //2 ;

5. Thread 2 retourne la référence au Singleton en ligne 3 ;

6. Thread 2 est préempté par le Thread 1 ;

7. Thread 1 reprend où il s'était arrêté et exécute la ligne //2 créant alors une autre instance de Singleton ;

8. Thread 1 retourne cette nouvelle instance en ligne //3.

Nous voyons donc que nous avons obtenu deux instances de notre Singleton. Statistiquement, ce risque est faible, puisque cela peut se produire uniquement lors du premier appel du Singleton. Il s'agit ici du cas typique de bogue, entraînant des plantages aléatoires difficilement reproductibles en débogage.

III - Les solutions envisageables

Avant de décrire les solutions envisageables, je suis obligé de décrire la gestion de la mémoire en Java.

III-A - La gestion de la mémoire

Java traite chaque thread comme s'il fonctionnait sur son propre processeur, avec sa propre mémoire locale, chacun parlant avec et se synchronisant sur une mémoire principale partagée. Même sur des systèmes monoprocesseurs, ce modèle de mémoire est important à cause des caches de mémoire et de l'utilisation de registres des processeurs pour le stockage de variables. Quand un thread modifie une variable dans sa mémoire locale, ce changement n'est pas forcément répercuté instantanément sur la mémoire principale. Le compilateur est autorisé à réordonner les opérations d'écriture/lecture vers la mémoire à des fins de performance tant que le thread courant ne voit pas la différence.

Il existe plusieurs types de gestion de mémoire et cache au niveau des processeurs en fonction des architectures matérielles, mais aussi des choix d'implémentation de l'OS. Comme java est destiné à être multiplateforme et que le modèle de mémoire de java autorise un certain nombre de réarrangements à des fins de performance, dites-vous que ce qui marche aujourd'hui sur votre machine peut ne pas marcher sur la machine de votre voisin à moins de prendre en considération ces problèmes. Ce n'est pas parce que sur votre ordinateur, votre fonction s'exécute de façon procédurale qu'il en sera de même pour les autres ordinateurs. Vous ne savez pas sur quel type d'architecture s'exécutera votre application plus tard surtout avec l'apparition des processeurs multicores dans la machine de monsieur tout le monde.

Pour parer à ces éventuels problèmes, la gestion de la mémoire en java est clairement décrite sous l'appellation de Java Memory Model ou JMM. Le JMM autorise l'optimisation des écritures/lectures en mémoire en les réordonnant. Il définit les réorganisations qui sont autorisées et définit aussi une sémantique permettant de prévoir le comportement de la mémoire. Cette sémantique comprend des termes tels que synchronized et volatile. Il est originellement défini au chapitre 17 de Java Language Specification, mais comporte certaines failles faisant, entre autres, que les String ne sont pas réellement immutables. Ce JMM a été spécifié une nouvelle fois par la JSR 133 (Java Specification Request) qui est partiellement incluse dans le JDK1.4 puis le JDK 1.5.

III-B - Utilisation de la synchronisation

L'utilisation de la synchronisation inclut l'exclusion mutuelle de l'exécution de certaines portions de code sur la base de sémaphores, mais elle impose aussi des règles sur l'interaction des threads avec la mémoire principale. Ainsi un thread doit transférer toutes ses variables modifiées dans la mémoire principale avant la libération du sémaphore. De même à l'entrée d'un bloc synchronisé, c'est comme si toute la mémoire locale du thread était invalidée et ce dernier doit recharger depuis la mémoire principale toutes les variables qui sont référencées dans le bloc synchronisé. On peut dire que deux blocs de code se synchronisant sur le même objet s'exécutent de façon atomique l'un pour l'autre. Ces lectures/écritures de variables et l'acquisition des sémaphores font que la synchronisation de code entraîne un ralentissement de son exécution.

III-B-1 - La synchronisation de getInstance()

La première solution qui s'impose est de synchroniser la méthode getInstance comme suit :

public static synchronized Singleton getInstance(){
if (instance==null)                //1
instance=new Singleton();        //2
return instance;                //3
}

Ce code fonctionne bien en environnement multithread. Toutefois la synchronisation de la méthode n'est nécessaire que lors du premier appel (quand instance est null). Avec ce code tous les appels devront payer le coût de la synchronisation. Bien que les JVM soient de plus en plus performantes, ce coût peut poser des problèmes de performance lors de fortes montées en charge de l'application. Pour limiter ce problème, on peut envisager de ne synchroniser que l'appel au constructeur en ligne 2.

III-B-2 - La synchronisation de l'appel au constructeur

Cette synchronisation donne le code suivant pour la méthode getInstance() :

public static Singleton getInstance(){
if (instance==null)
{
synchronized (Singleton.class){
instance=new Singleton();
}
}
return instance;
}

Au premier abord, ce code semble correct, toutefois il présente le même problème que le premier en environnement multithread. Deux threads peuvent entrer dans le bloc if simultanément, quand instance vaut null. Alors un thread entre dans le bloc synchronisé pour initialiser instance pendant que l'autre thread est bloqué. Quand le premier thread sort du bloc synchronisé, le thread en attente peut alors entrer dans ce bloc et initialiser une seconde instance de notre Singleton. Le problème est que, quand le second thread pénètre dans le bloc synchronisé, il ne vérifie pas si instance vaut toujours null. Nous avons besoin d'une seconde vérification de instance. Un idiome effectuant cette seconde vérification existe et s'appelle le « double-check locking » ou DCL.

III-B-3 - Le double-check locking

III-B-3-a - Principe

Le code suivant est décrit dans de nombreux articles, il est fortement déconseillé de l'utiliser, car il ne fonctionne pas.

Avec le DCL, l'implémentation de la méthode getInstance() est la suivante :

public static Singleton getInstance(){
if (instance==null)
{
synchronized (Singleton.class){            //1
if(instance==null)            //2
instance=new Singleton();    //3
}
}
return instance;
}

La théorie derrière le DCL est que la seconde vérification en ligne 2 rend impossible la création de deux Singleton comme vu précédemment. Considérons la séquence d'événements suivante :

1. Thread 1 entre dans la méthode getInstance() ;

2. Thread 1 entre dans le bloc synchronisé en //1, car instance est null ;

3. Thread 1 est préempté par Thread 2 ;

4. Thread 2 entre dans la méthode getInstance() ;

5. Thread 2 essaie d'obtenir le verrou en ligne //1, car instance est null. Toutefois comme Thread 1 possède le verrou, Thread 2 est bloqué en //1 ;

6. Thread 2 est préempté par Thread 1 ;

7. Thread 1 reprend et comme instance est null en //2, crée une instance de Singleton et assigne sa référence à instance ;

8. Thread 1 sort du bloc synchronisé et renvoie l'instance ;

9. Thread 1 est préempté par Thread 2 ;

10. Thread 2 acquiert le verrou en //1 et vérifie si instance est null comme instance n'est plus null, un second Singleton n'est pas créé et celui créé par le Thread 1 est retourné.

La théorie derrière le DCL semble parfaite. Malheureusement la réalité est complètement différente. Le DCL n'apporte aucune garantie que cela fonctionnera.

III-B-3-b - Pourquoi le DCL ne marche pas ?

L'échec du DCL n'est pas dû à un bogue d'implémentation de la JVM, mais au modèle de mémoire de la JVM. Ce modèle de mémoire autorise ce qui est connu comme le « out-of-order writes » ou écriture dans le désordre.

Pour plus d'informations sur la gestion de la mémoire en java, vous pouvez lire les spécifications de la JVM et plus particulièrement le chapitre 8 sur la gestion des threads et verrous

La conséquence de ce mode de fonctionnement est que le DCL ne fonctionne pas. Dans la ligne 3 du code précédent, une instance du singleton est créée et la variable instance est initialisée pour pointer cet objet. Le problème avec cette ligne de code est que la variable instance peut devenir non null avant l'exécution du constructeur du Singleton.
Comment cela est-il possible ? Le code instance = new Singleton() peut être représenté de la façon suivante en pseudocode :

mem = allocate() ;
instance = mem ;
ctorSingleton(instance)

On a d'abord allocation de la mémoire pour l'instance du Singleton. Puis la variable instance est initialisée pour référencer cet espace de mémoire, instance est alors non null, mais le Singleton n'est pas initialisé. Enfin il y a invocation du constructeur pour l'initialisation du Singleton. Cet ordre d'exécution dépendra du compilateur JIT (Just In Time) que vous utiliserez.

La conséquence au niveau de l'appel de notre méthode getInstance() est alors :

1. Thread 1 entre dans getInstance() ;

2. Thread 1 entre dans le bloc synchronisé en //1, car instance est null ;

3. Thread 1 exécute la ligne 3 et rend instance non null ;

4. Thread 1 est préempté par Thread 2 avant de pouvoir appeler le constructeur ou pendant l'exécution de ce constructeur ;

5. Thread 2 vérifie si instance est null. Comme elle ne l'est pas Thread 2 retourne une référence sur instance qui est un objet Singleton construit (mémoire allouée), mais partiellement initialisé (le constructeur n'est pas fini) ;

6. Thread 2 est préempté par Thread 1 ;

7. Thread 1 complète l'initialisation du singleton en exécutant/finissant son constructeur et retourne sa référence.

La conséquence avec le code de notre Singleton, est que le thread 2 peut tenter de mettre des Objects dans l'ArrayList du Singleton alors qu'elle n'est pas initialisée.

III-C - Utilisation de ThreadLocal

Apparu avec le JDK 1.2, ThreadLocal peut sembler être une solution au problème du DCL. Une variable ThreadLocal est une variable qui a une copie différente pour chacun des threads qui l'utilisent. Chaque thread peut manipuler sa version de la variable indépendamment des autres threads et, en réalité, ignore complètement la valeur de cette variable dans les autres threads. La classe ThreadLocal présente trois méthodes :

public class ThreadLocal {
public Object get();
public void set(Object newValue);
public Object initialValue();
}

Les méthodes get() et set() sont des accesseurs à la valeur pour la version de la variable du thread courant, et la méthode initialValue() agit comme un constructeur qui initialise la variable sur la base d'une variable par thread. Le code du Singleton pourrait alors être le suivant :

Class ThreadLocalSingleton{
private static ThreadLocal initHolder = new ThreadLocal()
private static ThreadLocalSingleton instance=null;

public static ThreadLocalSingleton getInstance(){
if (initHolder.get() == null){
synchronized{
if (instance== null)
instance = new ThreadLocalSingleton();
initHolder.set(Boolean.TRUE);
}
}
return instance;
}
}

Le principe de cette implémentation est qu'au lieu de vérifier l'instance partagée entre les threads, elle utilise l'objet ThreadLocal pour voir si le thread est passé dans le bloc synchronisé. Comme ThreadLocal implique qu'il n'y a pas de partage de la variable entre les threads, nous n'avons pas de problème d'écriture dans le désordre. Toutefois l'implémentation de ThreadLocal était peu performante dans les premiers JDK et reposait sur la synchronisation, ce qui fait que cette méthode n'est pas plus performante que la synchronisation de toute la méthode getInstance. L'implémentation de ThreadLocal a été repensée et réécrite pour les JDK plus récents. Toutefois vous ne pouvez garantir sur quelle version du JDK votre application fonctionnera et donc si cette méthode vous apportera un gain de performance.

III-D - Et pourquoi pas volatile ?

On peut envisager de déclarer instance comme un champ volatile. Volatile est défini pour faire en sorte qu'il n'y ait pas d'utilisation du cache pour les attributs déclarés volatile.

Toutefois, si l'ancien modèle de mémoire de la JVM prévient de la réorganisation de l'écriture des variables volatile entre elles et assure qu'elles sont transférées immédiatement dans la mémoire principale, il autorise la réorganisation des lectures/écritures des variables volatile par rapport aux lectures/écritures des variables non volatile. Cela signifie que -à moins que toutes les ressources soient volatiles- thread 2 peut percevoir les effets du constructeur après que instance ait référencé le nouveau Singleton créé. La solution est alors de déclarer toutes les variables comme volatiles, ayant pour conséquence l'absence d'utilisation des registres et la dégradation des performances.

Le nouveau JMM fixe ce problème en interdisant la réorganisation par rapport aux autres variables. Mais alors, nous nous retrouvons dans le cas de la synchronisation avec un flush complet du registre du processeur. Le gain de performance recherché en n'utilisant pas la synchronisation est alors nul.

Comme vous ne savez pas sur quelle version de la JVM votre application s'exécutera, il est fortement recommandé de ne pas utiliser cet idiome.

IV - Les solutions qui fonctionnent

Nous venons de voir que l'utilisation de Singleton en environnement multithread doit faire l'objet d'un certain nombre de précautions. Trois solutions s'offrent à nous pour l'implémentation d'un singleton qui fonctionne :

1. Synchroniser toute la méthode getInstance() ;

2. Utiliser ThreadLocal ;

3. Abandonner la synchronisation et utiliser un initialiseur static.

Les deux premières solutions ont été décrites précédemment, nous allons voir la troisième.
Une implémentation utilisant cette initialiseur static donne le code suivant :

import java.util.* ;
class Singleton{
private static Singleton instance= new Singleton();
private List maList;
private Boolean etat;

private Singleton(){
maList= new ArrayList();
etat = true;
}
public static Singleton getInstance(){
return instance;
}
}

Cette solution présente l'avantage, non négligeable, d'éliminer la synchronisation lors de chaque accès. Cela fonctionne, car la machine virtuelle garantit qu'un objet d'une classe ne peut être accédé tant que la classe n'est pas complètement chargée. Toutefois cette solution n'est viable que si toutes les informations nécessaires à la création du Singleton sont disponibles au moment du chargement, ce qui ne peut être toujours garanti. En effet, la machine virtuelle charge les classes comme elle le veut et il n'y a aucune garantie que le chargement soit différé jusqu'au premier appel de la méthode getInstance().

V - Conclusion

Dans cet article, nous venons de voir que l'initialisation du Singleton peut être à l'origine de certains problèmes dans un code multithread. La première solution est la synchronisation de la méthode getInstance(). Toutefois cette synchronisation est coûteuse en terme de temps, surtout sur les anciennes versions de la JVM. Dans un souci d'éviter de payer ce coût, les programmeurs ont imaginé le double-check locking. Malheureusement, bien que cet idiome soit très répandu dans le monde de la programmation, et encore recommandé sur de nombreux sites web, il est évident que ce n'est pas une technique de programmation sûre en environnement multithread. Bien que le modèle de mémoire évolue avec les versions de la JVM, comme vous ne pouvez pas être sûr de celle qu'utilise votre client, il est fortement recommandé d'accepter le coût de la synchronisation de toute la méthode ou d'utiliser un initialiseur static quand votre code le permet.

VI - Liens

Pour aller plus loin, vous pouvez consulter les articles et sites suivants :

- Double-check locking and the Singleton pattern

- Fixing the java memory model part1

- La page de Bill Pugh sur le Java Memory Model