Introduction au modèle de mémoire Java

Le modèle de mémoire Java (JMM) décrit le comportement des threads dans l'environnement d'exécution Java. Le modèle de mémoire fait partie de la sémantique du langage Java et décrit ce à quoi un programmeur peut et ne doit pas s'attendre lorsqu'il développe un logiciel non pas pour une machine Java spécifique, mais pour Java dans son ensemble.

Le modèle original de mémoire Java (qui fait notamment référence à la "mémoire percolocale"), développé en 1995, est considéré comme un échec : de nombreuses optimisations ne peuvent être faites sans perdre la garantie de sécurité du code. En particulier, il existe plusieurs options pour écrire un "single" multi-thread :

  • soit chaque acte d'accès à un singleton (même lorsque l'objet a été créé il y a longtemps et que rien ne peut changer) provoquera un verrou inter-thread ;
  • ou dans certaines circonstances, le système émettra un solitaire inachevé ;
  • ou dans certaines circonstances, le système créera deux solitaires ;
  • ou la conception dépendra du comportement d'une machine particulière.

Par conséquent, le mécanisme de la mémoire a été repensé. En 2005, avec la sortie de Java 5, une nouvelle approche a été présentée, qui a été encore améliorée avec la sortie de Java 14.

Le nouveau modèle repose sur trois règles :

Règle #1 : Les programmes à thread unique s'exécutent de manière pseudo-séquentielle. Cela signifie: en réalité, le processeur peut effectuer plusieurs opérations par horloge, en modifiant simultanément leur ordre, cependant, toutes les dépendances de données restent, de sorte que le comportement ne diffère pas de séquentiel.

Règle numéro 2 : il n'y a pas de valeurs sorties de nulle part. La lecture de n'importe quelle variable (à l'exception des longs et doubles non volatiles, pour lesquels cette règle peut ne pas s'appliquer) renverra soit la valeur par défaut (zéro), soit quelque chose qui y est écrit par une autre commande.

Et la règle numéro 3 : le reste des événements sont exécutés dans l'ordre, s'ils sont reliés par une relation stricte d'ordre partiel « s'exécute avant » ( arrive avant ).

Se produit avant

Leslie Lamport a inventé le concept de Happens before . Il s'agit d'une relation d'ordre partiel stricte introduite entre les commandes atomiques (++ et -- ne sont pas atomiques) et ne signifie pas "physiquement avant".

Il précise que la deuxième équipe sera "au courant" des modifications apportées par la première.

Se produit avant

Par exemple, l'un est exécuté avant l'autre pour de telles opérations :

Synchronisation et moniteurs :

  • Capturer le moniteur ( méthode de verrouillage , démarrage synchronisé) et tout ce qui se passe sur le même thread après.
  • Le retour du moniteur (méthode unlock , fin de synchronized) et tout ce qui se passe sur le même thread avant lui.
  • Renvoyer le moniteur puis le capturer par un autre thread.

Ecriture et lecture :

  • Écrire dans n'importe quelle variable, puis la lire dans le même flux.
  • Tout dans le même fil avant d'écrire dans la variable volatile, et l'écriture elle-même. lecture volatile et tout sur le même fil après.
  • Écrire dans une variable volatile puis la relire. Une écriture volatile interagit avec la mémoire de la même manière qu'un retour de moniteur, tandis qu'une lecture est comme une capture. Il s'avère que si un thread écrit dans une variable volatile et que le second la trouve, tout ce qui précède l'écriture est exécuté avant tout ce qui vient après la lecture ; voir l'image.

Maintenance d'objet :

  • Initialisation statique et toutes les actions avec toutes les instances d'objets.
  • Écrire dans les champs finaux du constructeur et tout après le constructeur. Exceptionnellement, la relation arrive-avant ne se connecte pas transitivement à d'autres règles et peut donc provoquer une course inter-thread.
  • Tout travail avec l'objet et finalize() .

Service de diffusion :

  • Démarrage d'un thread et de tout code dans le thread.
  • Mise à zéro des variables liées au thread et à tout code dans le thread.
  • Code dans le thread et join() ; code dans le thread et isAlive() == false .
  • interrupt() le thread et détecte qu'il s'est arrêté.

Se produit avant les nuances de travail

La libération d'un moniteur se produit avant l'acquisition du même moniteur. Il convient de noter qu'il s'agit de la libération et non de la sortie, c'est-à-dire que vous n'avez pas à vous soucier de la sécurité lors de l'utilisation de l'attente.

Voyons comment cette connaissance nous aidera à corriger notre exemple. Dans ce cas, tout est très simple : il suffit de supprimer la vérification externe et de laisser la synchronisation telle quelle. Maintenant, le deuxième thread est assuré de voir toutes les modifications, car il n'obtiendra le moniteur qu'après que l'autre thread l'aura publié. Et comme il ne le publiera pas tant que tout ne sera pas initialisé, nous verrons tous les changements en même temps, et non séparément :

public class Keeper {
    private Data data = null;

    public Data getData() {
        synchronized(this) {
            if(data == null) {
                data = new Data();
            }
        }

        return data;
    }
}

L'écriture dans une variable volatile se produit avant la lecture de la même variable. La modification que nous avons apportée, bien sûr, corrige le bogue, mais elle remet celui qui a écrit le code d'origine d'où il vient - bloquant à chaque fois. Le mot-clé volatile peut sauver. En fait, la déclaration en question signifie qu'en lisant tout ce qui est déclaré volatil, nous obtiendrons toujours la valeur réelle.

De plus, comme je l'ai dit plus tôt, pour les champs volatils, l'écriture est toujours (y compris long et double) une opération atomique. Autre point important : si vous avez une entité volatile qui a des références à d'autres entités (par exemple, un tableau, une liste ou une autre classe), seule une référence à l'entité elle-même sera toujours "fraîche", mais pas à tout ce qui se trouve dans il entrant.

Revenons donc à nos vérins à double verrouillage. En utilisant volatile, vous pouvez régler la situation comme ceci :

public class Keeper {
    private volatile Data data = null;

    public Data getData() {
        if(data == null) {
            synchronized(this) {
                if(data == null) {
                    data = new Data();
                }
            }
        }
        return data;
    }
}

Ici, nous avons toujours un verrou, mais seulement si data == null. Nous filtrons les cas restants à l'aide d'une lecture volatile. L'exactitude est assurée par le fait que le stockage volatile se produit avant la lecture volatile, et toutes les opérations qui se produisent dans le constructeur sont visibles pour quiconque lit la valeur du champ.