Comparaison et échange de sémantique et de performance Java

Quelle est la sémantique de la comparaison et de l’échange dans Java? À savoir, la méthode de comparaison et d’échange d’un AtomicInteger garantit-elle simplement un access ordonné entre différents threads à l’emplacement de mémoire particulier de l’instance de nombre entier atomique, ou garantit-elle un access ordonné à tous les emplacements en mémoire, c’est-à-dire qu’elle agit comme s’il s’agissait d’une volatile (une barrière de mémoire).

De la docs :

  • weakCompareAndSet lit de manière atomique et écrit de manière conditionnelle une variable mais ne crée pas d’ordonnances arrive-avant, ne fournit donc aucune garantie en ce qui concerne les lectures et les écritures précédentes ou ultérieures de variables autres que la cible du weakCompareAndSet .
  • compareAndSet et toutes les autres opérations de lecture et de mise à jour telles que getAndIncrement ont les effets en mémoire de la lecture et de l’écriture de variables volatiles.

Il ressort de la documentation de l’API que compareAndSet agit comme s’il s’agissait d’une variable volatile. Cependant, weakCompareAndSet est censé simplement modifier son emplacement de mémoire spécifique. Ainsi, si cet emplacement de mémoire est exclusif au cache d’un seul processeur, weakCompareAndSet est censé être beaucoup plus rapide que le compareAndSet normal.

Je pose la question parce que j’ai comparé les méthodes suivantes en exécutant threadnum différents threads, en modifiant threadnum de 1 à 8 et en ayant totalwork=1e9 (le code est écrit en Scala, langage JVM compilé statiquement, mais sa signification et la traduction de bytecode est isomorphe à celle de Java dans ce cas – ces courts extraits doivent être clairs):

 val atomic_cnt = new AtomicInteger(0) val atomic_tlocal_cnt = new java.lang.ThreadLocal[AtomicInteger] { override def initialValue = new AtomicInteger(0) } def loop_atomic_tlocal_cas = { var i = 0 val until = totalwork / threadnum val acnt = atomic_tlocal_cnt.get while (i < until) { i += 1 acnt.compareAndSet(i - 1, i) } acnt.get + i } def loop_atomic_weakcas = { var i = 0 val until = totalwork / threadnum val acnt = atomic_cnt while (i < until) { i += 1 acnt.weakCompareAndSet(i - 1, i) } acnt.get + i } def loop_atomic_tlocal_weakcas = { var i = 0 val until = totalwork / threadnum val acnt = atomic_tlocal_cnt.get while (i < until) { i += 1 acnt.weakCompareAndSet(i - 1, i) } acnt.get + i } 

sur un AMD avec 4 cœurs doubles de 2,8 GHz et un processeur i7 à 4 cœurs de 2,67 GHz. La machine virtuelle Java est Sun Server Hotspot JVM 1.6. Les résultats ne montrent aucune différence de performance.

Spécifications: AMD 8220 4x double cœur à 2,8 GHz

Nom du test: loop_atomic_tlocal_cas

  • Nombre de fils: 1

Temps d’exécution: (montrant les 3 derniers) 7504.562 7502.817 7504.626 (moyenne = 7415.637 min = 7147.628 maximum = 7504.886)

  • Nombre de fils: 2

Temps d’exécution: (montrant les 3 derniers) 3751.553 3752.589 3751.519 (avg = 3713.5513 min = 3574.708 max = 3752.949)

  • N ° de fil: 4

Temps d’exécution: (montrant les 3 derniers) 1890,055 1889,813 1890,047 (moyenne = 2065,7207 min = 1804,652 max = 3755,852)

  • N ° de fil: 8

Temps d’exécution: (montrant les 3 derniers) 960,12 989,453 970,842 (moyenne = 1058,8776 min = 940,492 max = 1893,127)


Nom du test: loop_atomic_weakcas

  • N ° de fil: 1

Temps d’exécution: (montrant les 3 derniers) 7325,425 7057,03 7325,407 (moyenne = 7231,8682 min = 7057,03 max = 7325,45)

  • N ° de fil: 2

Temps d’exécution: (montrant les 3 derniers) 3663.21 3665.838 3533.406 (moyenne = 3607.2149 min = 3529.177 max = 3665.838)

  • N ° de fil: 4

Temps d’exécution: (montrant les 3 derniers) 3664,163 1831,979 1835,07 (moyenne = 2014,2086 min = 1797,997 max = 3664,163)

  • N ° de fil: 8

Temps d’exécution: (montrant les 3 derniers) 940.504 928.467 921.376 (moyenne = 943,665 min = 919,985 max = 997,681)


Nom du test: loop_atomic_tlocal_weakcas

  • N ° de fil: 1

Temps d’exécution: (montrant les 3 derniers) 7502,876 7502,857 7502,933 (moyenne = 7414,8132 min = 7145,869 max = 7502,933)

  • N ° de fil: 2

Temps d’exécution: (montrant les 3 derniers) 3752.623 3751.53 3752.434 (avg = 3710.1782 min = 3574.398 max = 3752.623)

  • N ° de fil: 4

Temps d’exécution: (montrant les 3 derniers) 1876,723 1881,069 1876,538 (moyenne = 4110,4221 min = 1804,62 max = 12467,351)

  • N ° de fil: 8

Temps d’exécution: (montrant les 3 derniers) 959,329 1010,53 969,767 (moyenne = 1072,8444 min = 959,329 max = 1880,049)

Spécifications: Intel i7 quad-core à 2,67 GHz

Nom du test: loop_atomic_tlocal_cas

  • N ° de fil: 1

Temps d’exécution: (montrant les 3 derniers) 8138.3175 8130.0044 8130.1535 (avg = 8119.2888 min = 8049.6497 max = 8150.1950)

  • Nombre de fils: 2

Temps d’exécution: (montrant les 3 derniers) 4067.7399 4067.5403 4068.3747 (avg = 4059.6344 min = 4026.2739 max = 4068.5455)

  • Nombre de fils: 4

Temps d’exécution: (montrant les 3 derniers) 2033.4389 2033.2695 2033.2918 (avg = 2030.5825 min = 2017.6880 max = 2035.0352)


Nom du test: loop_atomic_weakcas

  • N ° de fil: 1

Temps d’exécution: (montrant les 3 derniers) 8130.5620 8129.9963 8132.3382 (avg = 8114.0052 min = 8042.0742 max = 8132.8542)

  • N ° de fil: 2

Temps d’exécution: (montrant les 3 derniers) 4066.9559 4067.0414 4067.2080 (avg = 4086.0608 min = 4023.6822 max = 4335.1791)

  • Nombre de fils: 4

Temps d’exécution: (montrant les 3 derniers) 2034.6084 2169.8127 2034.5625 (avg = 2047.7025 min = 2032.8131 max = 2169.8127)


Nom du test: loop_atomic_tlocal_weakcas

  • N ° de fil: 1

Temps d’exécution: (montrant les 3 derniers) 8132.5267 8132.0299 8132.2415 (avg = 8114.9328 min = 8043.3674 max = 8134.0418)

  • N ° de fil: 2

Temps d’exécution: (montrant les 3 derniers) 4066.5924 4066.5797 4066.6519 (avg = 4059.1911 min = 4025.0703 max = 4066.8547)

  • Nombre de fils: 4

Temps d’exécution: (montrant les 3 derniers) 2033.2614 2035.5754 2036.9110 (avg = 2033.2958 min = 2023.5082 max = 2038.8750)


Bien qu’il soit possible que les locus de threads dans l’exemple ci-dessus se retrouvent dans les mêmes lignes de cache, il me semble qu’il n’y a pas de différence de performance observable entre le CAS classique et sa version faible.

Cela pourrait signifier qu’en fait, une comparaison faible et un swap agissent comme une barrière de mémoire à part entière, c’est-à-dire qu’il s’agit d’une variable volatile.

Question: Cette observation est-elle correcte? En outre, existe-t-il une architecture ou une dissortingbution Java connue pour laquelle une comparaison et un ensemble faibles est en réalité plus rapide? Si non, quel est l’avantage d’utiliser une CAS faible en premier lieu?

    Une comparaison et un swap faibles pourraient constituer une variable volatile complète, en fonction de la mise en œuvre de la JVM, bien sûr. En fait, je ne serais pas surpris si, sur certaines architectures, il n’est pas possible d’implémenter un CAS faible d’une manière nettement plus performante que le CAS normal. Sur ces architectures, il se peut que des CAS faibles soient implémentés de la même manière qu’un CAS complet. Ou peut-être simplement que votre machine virtuelle Java n’a pas eu beaucoup d’optimisation pour rendre particulièrement rapides les CAS faibles, ainsi l’implémentation actuelle appelle simplement une CAS complète car elle est rapide à mettre en œuvre, et une version future l’affinera.

    Le JLS dit simplement qu’un CAS faible n’établit pas de relation « passe avant », c’est simplement qu’il n’ya aucune garantie que la modification provoquée soit visible dans les autres threads. Dans ce cas, tout ce que vous obtenez est la garantie que l’opération de comparaison et définition est atomique, mais sans garantie quant à la visibilité de la nouvelle valeur (potentielle). Ce n’est pas la même chose que de garantir que cela ne sera pas vu. Vos tests sont donc cohérents avec cela.

    En général, évitez de tirer des conclusions sur le comportement lié à la concurrence par le biais de l’expérimentation. Il y a tellement de variables à prendre en compte que si vous ne suivez pas ce que le JLS garantit d’être correct, votre programme pourrait alors échouer à tout moment (peut-être sur une architecture différente, peut-être dans le cadre d’une optimisation plus agressive, provoquée par une légère modifier la disposition de votre code, peut-être dans les futures versions de la JVM qui n’existent pas encore, etc.). Il n’y a jamais de raison de supposer que vous pouvez vous en sortir avec quelque chose qui n’est pas garanti, car les expériences montrent que “ça marche”.

    L’instruction x86 pour “comparer et échanger atomiquement” est LOCK CMPXCHG . Cette instruction crée une clôture de mémoire complète.

    Aucune instruction n’effectue ce travail sans créer de barrière de mémoire. Il est donc très probable que compareAndSet et weakCompareAndSet correspondent à LOCK CMPXCHG et effectuent une barrière de mémoire complète.

    Mais c’est pour x86, d’autres architectures (y compris les futures variantes de x86) peuvent faire les choses différemment.

    weakCompareAndSwap n’est pas garanti pour être plus rapide; il est juste permis d’être plus rapide. Vous pouvez regarder le code open-source de OpenJDK pour voir ce que certaines personnes intelligentes ont décidé de faire avec cette autorisation:

    • code source de compareAndSet

    • code source de faiblesseCompareAndSet

    À savoir: ils sont tous deux mis en œuvre comme le one-liner

     return unsafe.compareAndSwapObject(this, valueOffset, expect, update); 

    Ils ont exactement les mêmes performances, car ils ont exactement la même implémentation! (au moins dans OpenJDK). D’autres personnes ont fait remarquer que de toute façon, on ne peut pas vraiment faire mieux sur x86, car le matériel vous offre déjà de nombreuses garanties “gratuitement”. Ce n’est que sur des architectures plus simples comme ARM que vous devez vous en préoccuper.