210 votes

Pourquoi ce programme Java se termine-t-il alors qu'apparemment il ne devrait pas le faire (et ne l'a pas fait) ?

Une opération délicate dans mon laboratoire aujourd'hui a complètement mal tourné. Un actionneur sur un microscope électronique a dépassé sa limite, et après une chaîne d'événements, j'ai perdu 12 millions de dollars d'équipement. J'ai trouvé plus de 40 000 lignes dans le module défectueux :

import java.util.*;

class A {
    static Point currentPos = new Point(1,2);
    static class Point {
        int x;
        int y;
        Point(int x, int y) {
            this.x = x;
            this.y = y;
        }
    }
    public static void main(String[] args) {
        new Thread() {
            void f(Point p) {
                synchronized(this) {}
                if (p.x+1 != p.y) {
                    System.out.println(p.x+" "+p.y);
                    System.exit(1);
                }
            }
            @Override
            public void run() {
                while (currentPos == null);
                while (true)
                    f(currentPos);
            }
        }.start();
        while (true)
            currentPos = new Point(currentPos.x+1, currentPos.y+1);
    }
}

Quelques échantillons de la sortie que j'obtiens :

$ java A
145281 145282
$ java A
141373 141374
$ java A
49251 49252
$ java A
47007 47008
$ java A
47427 47428
$ java A
154800 154801
$ java A
34822 34823
$ java A
127271 127272
$ java A
63650 63651

Puisqu'il n'y a pas d'arithmétique à virgule flottante ici, et que nous savons tous que les entiers signés se comportent bien en cas de débordement en Java, je pense qu'il n'y a pas de problème avec ce code. Cependant, bien que la sortie indique que le programme n'a pas atteint la condition de sortie, il a atteint la condition de sortie (elle a été atteinte à la fois par le programme et par le programme). y pas atteint ?). Pourquoi ?


J'ai remarqué que cela ne se produit pas dans certains environnements. Je suis sur OpenJDK 6 sur Linux 64 bits.

41 votes

12 millions d'équipements ? je suis vraiment curieux de savoir comment cela peut arriver... pourquoi vous utilisez un bloc de synchronisation vide : synchronized(this) {} ?

86 votes

Ce n'est même pas sûr du tout.

1 votes

@MattBall, et ceci est un code réduit. Évidemment, l'écriture vers currentPos n'a pas happen-before la lecture de celui-ci, mais je ne vois pas comment cela peut être le problème.

141voto

assylias Points 102015

Il est évident que l'écriture dans currentPos n'a pas lieu avant la lecture de cette position, mais je ne vois pas comment cela peut être le problème.

currentPos = new Point(currentPos.x+1, currentPos.y+1); fait quelques choses, y compris écrire des valeurs par défaut dans le fichier x y y (0), puis en écrivant leurs valeurs initiales dans le constructeur. Puisque votre objet n'est pas publié en toute sécurité, ces 4 opérations d'écriture peuvent être librement réorganisées par le compilateur / JVM.

Donc, du point de vue du fil de lecture, c'est une exécution légale de lire x avec sa nouvelle valeur mais y avec sa valeur par défaut de 0 par exemple. Au moment où vous atteignez le println (qui est d'ailleurs synchronisée et influence donc les opérations de lecture), les variables ont leurs valeurs initiales et le programme imprime les valeurs attendues.

Marquage currentPos como volatile assurera une publication sûre puisque votre objet est effectivement immuable - si dans votre cas d'utilisation réel l'objet est muté après la construction, volatile Les garanties ne seront pas suffisantes et vous pourriez revoir un objet incohérent.

Alternativement, vous pouvez faire le Point immuable, ce qui garantira également une publication sécurisée, même sans utiliser volatile . Pour atteindre l'immuabilité, il suffit de marquer x y y final.

En passant, et comme déjà mentionné, synchronized(this) {} peut être traité comme un no-op par la JVM (je comprends que vous l'avez inclus pour reproduire le comportement).

4 votes

Je ne suis pas sûr, mais le fait de rendre x et y finaux n'aurait-il pas le même effet, en évitant la barrière de la mémoire ?

3 votes

Une conception plus simple est un objet ponctuel immuable qui teste les invariants lors de la construction. Ainsi, vous ne risquez jamais de publier une configuration dangereuse.

0 votes

@BuddyCasino Oui, en effet, je l'ai ajouté. Pour être honnête, je ne me souviens pas de toute la discussion d'il y a 3 mois (l'utilisation de final a été proposée dans les commentaires, donc je ne sais pas pourquoi je ne l'ai pas inclus comme option).

29voto

Ed Plese Points 1218

Depuis currentPos est modifié en dehors du fil de discussion, il doit être marqué comme volatile :

static volatile Point currentPos = new Point(1,2);

Sans volatile, le thread n'est pas assuré de lire les mises à jour de currentPos qui sont effectuées dans le thread principal. Ainsi, de nouvelles valeurs continuent à être écrites pour currentPos mais le thread continue à utiliser les versions précédentes mises en cache pour des raisons de performance. Comme un seul thread modifie currentPos, vous pouvez vous passer des verrous, ce qui améliore les performances.

Les résultats sont très différents si vous ne lisez les valeurs qu'une seule fois dans le fil de discussion pour les utiliser dans la comparaison et leur affichage ultérieur. Lorsque je fais ce qui suit x s'affiche toujours comme 1 y y varie entre 0 et un grand nombre entier. Je pense que le comportement de celui-ci à ce stade est quelque peu indéfini sans les volatile et il est possible que la compilation JIT du code contribue à ce comportement. De plus, si je commente le mot vide synchronized(this) {} alors le code fonctionne aussi bien et je soupçonne que c'est parce que le verrouillage cause un délai suffisant pour que currentPos et ses champs sont relus plutôt qu'utilisés à partir du cache.

int x = p.x + 1;
int y = p.y;

if (x != y) {
    System.out.println(x+" "+y);
    System.exit(1);
}

2 votes

Oui, et je pourrais aussi simplement mettre un verrou autour de tout. Qu'est-ce que tu veux dire ?

0 votes

J'ai ajouté quelques explications supplémentaires concernant l'utilisation de volatile .

19voto

paulj Points 171

Vous avez une mémoire ordinaire, la référence 'currentpos' et l'objet Point et ses champs derrière elle, partagée entre 2 threads, sans synchronisation. Ainsi, il n'y a pas d'ordre défini entre les écritures qui se produisent dans cette mémoire dans le thread principal et les lectures dans le thread créé (appelé T).

Le fil principal effectue les écritures suivantes (en ignorant la configuration initiale du point, il en résulte que p.x et p.y ont des valeurs par défaut) :

  • à p.x
  • à p.y
  • à la position actuelle

Comme ces écritures n'ont rien de spécial en termes de synchronisation/barrières, le runtime est libre de permettre au thread T de les voir se produire dans n'importe quel ordre (le thread principal voit bien sûr toujours les écritures et les lectures ordonnées selon l'ordre du programme), et de les faire se produire à n'importe quel moment entre les lectures dans T.

Donc T fait :

  1. lit la position actuelle dans p
  2. lire p.x et p.y (dans un ordre quelconque)
  3. comparer, et prendre la branche
  4. lire p.x et p.y (dans n'importe quel ordre) et appeler System.out.println

Etant donné qu'il n'y a pas de relations d'ordre entre les écritures dans main, et les lectures dans T, il y a clairement plusieurs façons de produire votre résultat, car T peut voir l'écriture de main à currentpos avant l'écriture à currentpos.y ou currentpos.x :

  1. Il lit d'abord currentpos.x, avant que l'écriture x ne se produise - obtient 0, puis lit currentpos.y avant que l'écriture y ne se produise - obtient 0. Comparez les résultats à true. Les écritures deviennent visibles pour T. System.out.println est appelé.
  2. Il lit d'abord currentpos.x, après que l'écriture x se soit produite, puis lit currentpos.y avant que l'écriture y se soit produite - il obtient 0. La comparaison est vraie. Les écritures deviennent visibles pour T... etc.
  3. Il lit d'abord currentpos.y, avant que l'écriture y ne se produise (0), puis lit currentpos.x après l'écriture x, évalue à true. etc.

et ainsi de suite... Il y a un certain nombre de courses aux données ici.

Je pense que l'hypothèse erronée ici est de penser que les écritures qui résultent de cette ligne sont rendues visibles à travers tous les threads dans l'ordre du programme du thread qui l'exécute :

currentPos = new Point(currentPos.x+1, currentPos.y+1);

Java n'offre pas une telle garantie (ce serait terrible pour les performances). Quelque chose de plus doit être ajouté si votre programme a besoin d'un ordre garanti des écritures par rapport aux lectures dans d'autres threads. D'autres ont suggéré de rendre les champs x,y finaux, ou alternativement de rendre currentpos volatile.

  • Si vous rendez les champs x,y finaux, Java garantit que l'écriture de leurs valeurs sera vue comme ayant lieu avant le retour du constructeur, dans tous les threads. Ainsi, comme l'affectation à currentpos se fait après le constructeur, le thread T est assuré de voir les écritures dans le bon ordre.
  • Si vous rendez currentpos volatile, Java garantit que c'est un point de synchronisation qui sera totalement ordonné par rapport aux autres points de synchronisation. Comme dans main, les écritures sur x et y doivent se produire avant l'écriture sur currentpos, alors toute lecture de currentpos dans un autre thread doit également voir les écritures de x, y qui se sont produites avant.

L'utilisation de final a l'avantage de rendre les champs immuables, et donc de permettre la mise en cache des valeurs. L'utilisation de volatile entraîne une synchronisation à chaque écriture et lecture de currentpos, ce qui peut nuire aux performances.

Voir le chapitre 17 de la spécification du langage Java pour les détails sanglants : http://docs.oracle.com/javase/specs/jls/se7/html/jls-17.html

(La réponse initiale supposait un modèle de mémoire plus faible, car je n'étais pas sûr que le volatile garanti par JLS était suffisant. Réponse modifiée pour refléter le commentaire d'assylias, indiquant que le modèle Java est plus fort - happens-before est transitif - et donc volatile sur currentpos suffit aussi).

2 votes

C'est la meilleure explication à mon avis. Merci beaucoup !

1 votes

@skyde mais faux sur la sémantique de volatile. volatile garantit que les lectures d'une variable volatile verront la dernière écriture disponible d'une variable volatile. ainsi que toute écriture précédente . Dans ce cas, si currentPos est rendue volatile, l'assignation assure une publication sûre de la currentPos ainsi que ses membres, même s'ils ne sont pas eux-mêmes volatils.

0 votes

Eh bien, je disais que je ne pouvais pas, pour moi-même, voir exactement comment le JLS garantissait que le volatile formait une barrière avec d'autres lectures et écritures normales. Techniquement, je ne peux pas avoir tort sur ce point ;). Lorsqu'il s'agit de modèles de mémoire, il est plus prudent de supposer qu'un ordre n'est pas garanti et de se tromper (vous êtes toujours en sécurité) que de faire l'inverse et de se tromper et de ne pas être en sécurité. C'est génial si volatile fournit cette garantie. Pouvez-vous expliquer comment le chapitre 17 du JLS l'offre ?

-2voto

Germano Fronza Points 1

Vous pourriez utiliser un objet pour synchroniser les écritures et les lectures. Sinon, comme d'autres l'ont déjà dit, une écriture dans currentPos se produira au milieu des deux lectures de p.x+1 et p.y.

new Thread() {
    void f(Point p) {
        if (p.x+1 != p.y) {
            System.out.println(p.x+" "+p.y);
            System.exit(1);
        }
    }
    @Override
    public void run() {
        while (currentPos == null);
        while (true)
            f(currentPos);
    }
}.start();
Object sem = new Object();
while (true) {
    synchronized(sem) {
        currentPos = new Point(currentPos.x+1, currentPos.y+1);
    }
}

0 votes

En fait, cela fait l'affaire. Dans ma première tentative, j'ai placé la lecture à l'intérieur du bloc synchronisé, mais j'ai réalisé plus tard que ce n'était pas vraiment nécessaire.

1 votes

-1 La JVM peut prouver que sem n'est pas partagé et traite l'instruction synchronisée comme un no-op... Le fait que cela résout le problème est un pur hasard.

4 votes

Je déteste la programmation multithread, beaucoup trop de choses fonctionnent grâce à la chance.

-3voto

user2686913 Points 3

Vous accédez deux fois à currentPos, et vous n'avez aucune garantie qu'il n'est pas mis à jour entre ces deux accès.

Par exemple :

  1. x = 10, y = 11
  2. le fil de travail évalue p.x comme 10
  3. le thread principal exécute la mise à jour, maintenant x = 11 et y = 12
  4. le fil de travail évalue p.y comme 12
  5. Le fil de travail remarque que 10+1 != 12, il imprime et sort.

Vous comparez essentiellement deux différents Points.

Notez que même le fait de rendre currentPos volatile ne vous protégera pas de ce problème, puisqu'il s'agit de deux lectures séparées par le thread du travailleur.

Ajouter un

boolean IsValid() { return x+1 == y; }

à votre classe de points. Cela garantira qu'une seule valeur de currentPos sera utilisée lors de la vérification de x+1 == y.

0 votes

CurrentPos n'est lu qu'une seule fois, sa valeur est copiée dans p. p est lu deux fois, mais il pointera toujours au même endroit.

Prograide.com

Prograide est une communauté de développeurs qui cherche à élargir la connaissance de la programmation au-delà de l'anglais.
Pour cela nous avons les plus grands doutes résolus en français et vous pouvez aussi poser vos propres questions ou résoudre celles des autres.

Powered by:

X