Le! = Check est-il sûr?


140

Je sais que les opérations composées telles que i++ne sont pas thread-safe car elles impliquent plusieurs opérations.

Mais la vérification de la référence avec elle-même est-elle une opération thread-safe?

a != a //is this thread-safe

J'ai essayé de programmer cela et d'utiliser plusieurs threads mais cela n'a pas échoué. Je suppose que je n'ai pas pu simuler la course sur ma machine.

ÉDITER:

public class TestThreadSafety {
    private Object a = new Object();

    public static void main(String[] args) {

        final TestThreadSafety instance = new TestThreadSafety();

        Thread testingReferenceThread = new Thread(new Runnable() {

            @Override
            public void run() {
                long countOfIterations = 0L;
                while(true){
                    boolean flag = instance.a != instance.a;
                    if(flag)
                        System.out.println(countOfIterations + ":" + flag);

                    countOfIterations++;
                }
            }
        });

        Thread updatingReferenceThread = new Thread(new Runnable() {

            @Override
            public void run() {
                while(true){
                    instance.a = new Object();
                }
            }
        });

        testingReferenceThread.start();
        updatingReferenceThread.start();
    }

}

C'est le programme que j'utilise pour tester la sécurité des threads.

Comportement étrange

Lorsque mon programme démarre entre certaines itérations, j'obtiens la valeur de l'indicateur de sortie, ce qui signifie que la !=vérification de référence échoue sur la même référence. MAIS après quelques itérations, la sortie devient une valeur constante falseet l'exécution du programme pendant une longue période ne génère pas une seule truesortie.

Comme la sortie le suggère après quelques n itérations (non fixes), la sortie semble être une valeur constante et ne change pas.

Production:

Pour certaines itérations:

1494:true
1495:true
1496:true
19970:true
19972:true
19974:true
//after this there is not a single instance when the condition becomes true

2
Qu'entendez-vous par «thread-safe» dans ce contexte? Demandez-vous s'il est garanti de toujours retourner faux?
JB Nizet

@JBNizet oui. C'est ce à quoi je pensais.
Narendra Pathai

5
Il ne renvoie même pas toujours false dans un contexte à thread unique. Ça pourrait être un NaN ..
harold

4
Explication probable: le code a été compilé juste à temps et le code compilé ne charge la variable qu'une seule fois. Ceci est attendu.
Marko Topolnik

3
L'impression des résultats individuels est une mauvaise façon de tester les courses. L'impression (à la fois le formatage et l'écriture des résultats) est relativement coûteuse par rapport à votre test (et parfois votre programme finira par bloquer en écriture lorsque la bande passante de la connexion au terminal ou au terminal lui-même est lente). En outre, IO contient souvent mutex de sa propre qui permute l'ordre d'exécution de vos fils (notez vos lignes individuelles de 1234:truene fracassent les uns des autres ). Un test de course nécessite une boucle intérieure plus serrée. Imprimez un résumé à la fin (comme quelqu'un l'a fait ci-dessous avec un cadre de test unitaire).
Ben Jackson

Réponses:


124

En l'absence de synchronisation ce code

Object a;

public boolean test() {
    return a != a;
}

peut produire true. Ceci est le bytecode pourtest()

    ALOAD 0
    GETFIELD test/Test1.a : Ljava/lang/Object;
    ALOAD 0
    GETFIELD test/Test1.a : Ljava/lang/Object;
    IF_ACMPEQ L1
...

comme nous pouvons le voir, il charge adeux fois le champ dans les variables locales, c'est une opération non atomique, si elle a aété modifiée entre les deux par une autre comparaison de threads peut produire false.

En outre, le problème de visibilité de la mémoire est pertinent ici, il n'y a aucune garantie que les modifications aapportées par un autre thread seront visibles pour le thread actuel.


22
Bien qu'il soit une preuve solide, le bytecode n'est pas réellement une preuve. Il doit également être quelque part dans le JLS ...
Marko Topolnik

10
@Marko Je suis d'accord avec votre réflexion, mais pas nécessairement avec votre conclusion. Pour moi, le bytecode ci-dessus est la manière évidente / canonique de mise en œuvre !=, qui consiste à charger séparément le LHS et le RHS. Et donc, si le JLS ne mentionne rien de spécifique sur les optimisations lorsque LHS et RHS sont syntaxiquement identiques, alors la règle générale s'appliquerait, ce qui signifie charger adeux fois.
Andrzej Doyle

20
En fait, en supposant que le bytecode généré soit conforme au JLS, c'est une preuve!
proskor

6
@Adrian: Premièrement: même si cette hypothèse est invalide, l'existence d'un seul compilateur où il peut s'évaluer à "vrai" suffit à démontrer qu'il peut parfois s'évaluer à "vrai" (même si la spécification l'interdit - ce qu'il pas). Deuxièmement: Java est bien spécifié et la plupart des compilateurs s'y conforment étroitement. Il est logique de les utiliser comme référence à cet égard. Troisièmement: vous utilisez le terme «JRE», mais je ne pense pas que cela signifie ce que vous pensez que cela signifie. . .
ruakh

2
@AlexanderTorstling - "Je ne suis pas sûr que cela soit suffisant pour empêcher une optimisation en lecture unique." Cela ne suffit pas. En fait, en l'absence de synchronisation (et le supplément "arrive avant" les relations qui s'imposent), l'optimisation est valide,
Stephen C

47

Le contrôle est a != a-il sûr?

Si apeut potentiellement être mis à jour par un autre thread (sans synchronisation appropriée!), Alors non.

J'ai essayé de programmer cela et d'utiliser plusieurs threads mais je n'ai pas échoué. Je suppose que je ne pouvais pas simuler la course sur ma machine.

Cela ne veut rien dire! Le problème est que si une exécution dans laquelle aest mise à jour par un autre thread est autorisée par le JLS, alors le code n'est pas thread-safe. Le fait que vous ne pouvez pas provoquer la condition de concurrence avec un cas de test particulier sur une machine particulière et une implémentation Java particulière ne l'empêche pas de se produire dans d'autres circonstances.

Cela signifie-t-il que a! = A pourrait revenir true.

Oui, en théorie, dans certaines circonstances.

Alternativement, a != apourrait revenir falsemême s'il achangeait simultanément.


Concernant le "comportement bizarre":

Comme mon programme démarre entre certaines itérations, j'obtiens la valeur de l'indicateur de sortie, ce qui signifie que la référence! = Check échoue sur la même référence. MAIS après quelques itérations, la sortie devient la valeur constante false, puis l'exécution du programme pendant longtemps ne génère pas une seule sortie vraie.

Ce comportement "étrange" est cohérent avec le scénario d'exécution suivant:

  1. Le programme est chargé et la JVM commence à interpréter les bytecodes. Puisque (comme nous l'avons vu à partir de la sortie javap) le bytecode effectue deux charges, vous voyez (apparemment) les résultats de la condition de concurrence, de temps en temps.

  2. Après un certain temps, le code est compilé par le compilateur JIT. L'optimiseur JIT remarque qu'il y a deux charges du même emplacement de mémoire ( a) proches l'une de l'autre et optimise la seconde. (En fait, il est possible que cela optimise entièrement le test ...)

  3. Maintenant, la condition de concurrence ne se manifeste plus, car il n'y a plus deux charges.

Notez que cela est tout conforme à ce que l'JLS permet une implémentation de Java faire.


@kriss a commenté ainsi:

Cela ressemble à ce que les programmeurs C ou C ++ appellent «comportement indéfini» (dépendant de l'implémentation). On dirait qu'il pourrait y avoir quelques UB en java dans des cas comme celui-ci.

Le modèle de mémoire Java (spécifié dans JLS 17.4 ) spécifie un ensemble de conditions préalables dans lesquelles un thread est assuré de voir les valeurs de mémoire écrites par un autre thread. Si un thread tente de lire une variable écrite par un autre, et que ces conditions préalables ne sont pas satisfaites, alors il peut y avoir un certain nombre d'exécutions possibles ... dont certaines sont susceptibles d'être incorrectes (du point de vue des exigences de l'application). En d'autres termes, l' ensemble des comportements possibles (c'est-à-dire l'ensemble des «exécutions bien formées») est défini, mais nous ne pouvons pas dire lesquels de ces comportements se produiront.

Le compilateur est autorisé à combiner et réorganiser les charges et à enregistrer (et à faire d'autres choses) à condition que l'effet final du code soit le même:

  • lorsqu'il est exécuté par un seul thread, et
  • lorsqu'il est exécuté par différents threads qui se synchronisent correctement (selon le modèle de mémoire).

Mais si le code ne se synchronise pas correctement (et donc les relations «se produit avant» ne contraignent pas suffisamment l'ensemble des exécutions bien formées), le compilateur est autorisé à réorganiser les charges et les magasins de manière à donner des résultats «incorrects». (Mais cela veut simplement dire que le programme est incorrect.)


Cela signifie-t-il que cela a != apourrait revenir vrai?
proskor

Je voulais dire que peut-être sur ma machine je ne pourrais pas simuler que le code ci-dessus n'est pas thread-safe. Alors peut-être qu'il y a un raisonnement théorique derrière cela.
Narendra Pathai

@NarendraPathai - Il n'y a aucune raison théorique pour laquelle vous ne pouvez pas le démontrer. Il y a peut-être une raison pratique ... ou peut-être que vous n'avez tout simplement pas eu de chance.
Stephen C

Veuillez vérifier ma réponse mise à jour avec le programme que j'utilise. La vérification renvoie parfois vrai mais il semble y avoir un comportement étrange dans la sortie.
Narendra Pathai

1
@NarendraPathai - Voir mon explication.
Stephen C

27

Prouvé avec test-ng:

public class MyTest {

  private static Integer count=1;

  @Test(threadPoolSize = 1000, invocationCount=10000)
  public void test(){
    count = new Integer(new Random().nextInt());
    Assert.assertFalse(count != count);
  }

}

J'ai 2 échecs sur 10 000 invocations. Donc NON , ce n'est PAS thread-safe


6
Vous ne vérifiez même pas l'égalité ... la Random.nextInt()partie est superflue. Vous auriez pu tester avec new Object()tout aussi bien.
Marko Topolnik

@MarkoTopolnik Veuillez vérifier ma réponse mise à jour avec le programme que j'utilise. La vérification renvoie parfois vrai mais il semble y avoir un comportement étrange dans la sortie.
Narendra Pathai

1
Remarque: les objets aléatoires sont généralement destinés à être réutilisés, pas créés chaque fois que vous avez besoin d'un nouvel int.
Simon Forsberg

15

Non, ça ne l'est pas. Pour une comparaison, la machine virtuelle Java doit mettre les deux valeurs à comparer sur la pile et exécuter l'instruction de comparaison (laquelle dépend du type de "a").

La machine virtuelle Java peut:

  1. Lisez "a" deux fois, mettez chacun sur la pile puis comparez les résultats
  2. Lisez "a" une seule fois, mettez-le sur la pile, dupliquez-le (instruction "dup") et lancez la comparaison
  3. Éliminez complètement l'expression et remplacez-la par false

Dans le 1er cas, un autre thread pourrait modifier la valeur de "a" entre les deux lectures.

La stratégie choisie dépend du compilateur Java et du Java Runtime (en particulier le compilateur JIT). Il peut même changer pendant l'exécution de votre programme.

Si vous voulez vous assurer de la manière dont on accède à la variable, vous devez la créer volatile(une soi-disant «barrière de la moitié de la mémoire») ou ajouter une barrière de la mémoire pleine ( synchronized). Vous pouvez également utiliser une API de niveau supérieur (par exemple, AtomicIntegercomme mentionné par Juned Ahasan).

Pour plus d'informations sur la sécurité des threads, lisez JSR 133 ( modèle de mémoire Java ).


Déclarer acomme volatileimpliquerait toujours deux lectures distinctes, avec la possibilité d'un changement entre les deux.
Holger

6

Tout a été bien expliqué par Stephen C. Pour vous amuser, vous pouvez essayer d'exécuter le même code avec les paramètres JVM suivants:

-XX:InlineSmallCode=0

Cela devrait empêcher l'optimisation faite par le JIT (c'est le cas sur le serveur hotspot 7) et vous verrez truepour toujours (je me suis arrêté à 2.000.000 mais je suppose que ça continue après ça).

Pour information, vous trouverez ci-dessous le code JIT. Pour être honnête, je ne lis pas assez couramment l'assemblage pour savoir si le test est réellement fait ou d'où viennent les deux charges. (la ligne 26 est le test flag = a != aet la ligne 31 est l'accolade fermante du while(true)).

  # {method} 'run' '()V' in 'javaapplication27/TestThreadSafety$1'
  0x00000000027dcc80: int3   
  0x00000000027dcc81: data32 data32 nop WORD PTR [rax+rax*1+0x0]
  0x00000000027dcc8c: data32 data32 xchg ax,ax
  0x00000000027dcc90: mov    DWORD PTR [rsp-0x6000],eax
  0x00000000027dcc97: push   rbp
  0x00000000027dcc98: sub    rsp,0x40
  0x00000000027dcc9c: mov    rbx,QWORD PTR [rdx+0x8]
  0x00000000027dcca0: mov    rbp,QWORD PTR [rdx+0x18]
  0x00000000027dcca4: mov    rcx,rdx
  0x00000000027dcca7: movabs r10,0x6e1a7680
  0x00000000027dccb1: call   r10
  0x00000000027dccb4: test   rbp,rbp
  0x00000000027dccb7: je     0x00000000027dccdd
  0x00000000027dccb9: mov    r10d,DWORD PTR [rbp+0x8]
  0x00000000027dccbd: cmp    r10d,0xefc158f4    ;   {oop('javaapplication27/TestThreadSafety$1')}
  0x00000000027dccc4: jne    0x00000000027dccf1
  0x00000000027dccc6: test   rbp,rbp
  0x00000000027dccc9: je     0x00000000027dcce1
  0x00000000027dcccb: cmp    r12d,DWORD PTR [rbp+0xc]
  0x00000000027dcccf: je     0x00000000027dcce1  ;*goto
                                                ; - javaapplication27.TestThreadSafety$1::run@62 (line 31)
  0x00000000027dccd1: add    rbx,0x1            ; OopMap{rbp=Oop off=85}
                                                ;*goto
                                                ; - javaapplication27.TestThreadSafety$1::run@62 (line 31)
  0x00000000027dccd5: test   DWORD PTR [rip+0xfffffffffdb53325],eax        # 0x0000000000330000
                                                ;*goto
                                                ; - javaapplication27.TestThreadSafety$1::run@62 (line 31)
                                                ;   {poll}
  0x00000000027dccdb: jmp    0x00000000027dccd1
  0x00000000027dccdd: xor    ebp,ebp
  0x00000000027dccdf: jmp    0x00000000027dccc6
  0x00000000027dcce1: mov    edx,0xffffff86
  0x00000000027dcce6: mov    QWORD PTR [rsp+0x20],rbx
  0x00000000027dcceb: call   0x00000000027a90a0  ; OopMap{rbp=Oop off=112}
                                                ;*aload_0
                                                ; - javaapplication27.TestThreadSafety$1::run@2 (line 26)
                                                ;   {runtime_call}
  0x00000000027dccf0: int3   
  0x00000000027dccf1: mov    edx,0xffffffad
  0x00000000027dccf6: mov    QWORD PTR [rsp+0x20],rbx
  0x00000000027dccfb: call   0x00000000027a90a0  ; OopMap{rbp=Oop off=128}
                                                ;*aload_0
                                                ; - javaapplication27.TestThreadSafety$1::run@2 (line 26)
                                                ;   {runtime_call}
  0x00000000027dcd00: int3                      ;*aload_0
                                                ; - javaapplication27.TestThreadSafety$1::run@2 (line 26)
  0x00000000027dcd01: int3   

1
C'est un bon exemple du type de code que JVM produira réellement lorsque vous avez une boucle infinie et que tout peut plus ou moins être extrait. La "boucle" réelle ici sont les trois instructions de 0x27dccd1à 0x27dccdf. Le jmpdans la boucle est inconditionnel (puisque la boucle est infinie). Les deux seules autres instructions de la boucle sont add rbc, 0x1- qui s'incrémentent countOfIterations(malgré le fait que la boucle ne sera jamais sortie, donc cette valeur ne sera pas lue: peut-être est-elle nécessaire au cas où vous y pénétriez dans le débogueur), .. .
BeeOnRope

... et l' testinstruction bizarre , qui n'est en fait là que pour l'accès à la mémoire (notez que ce eaxn'est même jamais défini dans la méthode!): c'est une page spéciale qui est définie sur non lisible lorsque la JVM veut déclencher tous les threads pour atteindre un point de restauration, afin qu'il puisse effectuer gc ou une autre opération qui nécessite que tous les threads soient dans un état connu.
BeeOnRope

Plus précisément, la JVM a complètement instance. a != instance.asorti la comparaison de la boucle, et ne l'exécute qu'une seule fois, avant que la boucle ne soit entrée! Il sait qu'il n'est pas nécessaire de recharger instanceou aqu'ils ne sont pas déclarés volatils et qu'il n'y a pas d'autre code qui peut les changer sur le même thread, donc il suppose simplement qu'ils sont les mêmes pendant toute la boucle, ce qui est autorisé par la mémoire modèle.
BeeOnRope

5

Non, a != an'est pas thread-safe. Cette expression se compose de trois parties: charger a, charger à anouveau et exécuter !=. Il est possible pour un autre thread d'obtenir le verrou intrinsèque sur ale parent de et de changer la valeur de aentre les 2 opérations de chargement.

Un autre facteur est cependant de savoir si aest local. Si aest local, aucun autre thread ne doit y avoir accès et doit donc être thread-safe.

void method () {
    int a = 0;
    System.out.println(a != a);
}

devrait également toujours imprimer false.

Déclarer acomme volatilene résoudrait pas le problème pour if ais staticou instance. Le problème n'est pas que les threads ont des valeurs différentes de a, mais qu'un thread se charge adeux fois avec des valeurs différentes. Cela peut en fait rendre le cas moins sûr pour les threads. Si ce an'est pas le cas, volatileil apeut être mis en cache et une modification dans un autre thread n'affectera pas la valeur mise en cache.


Votre exemple avec synchronizedest faux: pour que ce code soit garanti à imprimer false, toutes les méthodes définies a devraient l'être synchronizedégalement.
ruakh

Pourquoi ça? Si la méthode est synchronisée, comment un autre thread gagnerait-il le verrou intrinsèque sur ale parent de la méthode pendant l'exécution de la méthode, nécessaire pour définir la valeur a.
DoubleMx2

1
Vos locaux sont faux. Vous pouvez définir le champ d'un objet sans acquérir son verrou intrinsèque. Java n'a pas besoin d'un thread pour acquérir le verrou intrinsèque d'un objet avant de définir ses champs.
ruakh

3

Concernant le comportement étrange:

Étant donné que la variable an'est pas marquée comme volatile, à un moment donné, la valeur de apeut être mise en cache par le thread. Les deux as de a != asont alors la version mise en cache et donc toujours le même (sens flagest maintenant toujours false).


0

Même une simple lecture n'est pas atomique. Si aest longet n'est pas marqué comme volatilealors sur les JVM 32 bits long b = an'est pas thread-safe.


volatil et atomicité ne sont pas liés. même si je marque un volatile ce sera non atomique
Narendra Pathai

L'affectation d'un champ long volatil est toujours atomique. Les autres opérations comme ++ ne le sont pas.
ZhekaKozlov
En utilisant notre site, vous reconnaissez avoir lu et compris notre politique liée aux cookies et notre politique de confidentialité.
Licensed under cc by-sa 3.0 with attribution required.