2 votes

Code non conventionnel dans le JDK - constructions spécifiques utilisées pour une raison inconnue

Je parcourais le code du JDK (JDK 12, mais cela s'applique aussi aux plus anciens) et j'ai trouvé des constructions bizarres, et je ne comprends pas pourquoi elles ont été utilisées. Prenons par exemple Map.computeIfPresent car c'est simple :

default V computeIfPresent(K key, BiFunction<? super K, ? super V, ? extends V> remappingFunction) {
    Object oldValue;
    if ((oldValue = this.get(key)) != null) {
        V newValue = remappingFunction.apply(key, oldValue);
        if (newValue != null) {
            this.put(key, newValue);
            return newValue;
        } else {
            this.remove(key);
            return null;
        }
    } else {
        return null;
    }
}

Cette construction if ((oldValue = this.get(key)) != null) m'a surpris. Je savais que c'était possible, parce qu'il n'y a rien de vraiment spécial, mais dans un code de production normal, je considérerais cela comme une odeur de code. Pourquoi ne pas l'écrire de façon normale ( Object oldValue = this.get(key) ) ? Il doit y avoir une optimisation de l'embrayage, c'est ce que j'ai pensé.

Écriture d'une version plus petite pour vérifier le bytecode :

int computeIfPresent(int key) {
  Integer oldValue;
  if ((oldValue = get(key)) != null) {
    return oldValue;
  } else {
    return 2;
  }
}

Sortie du bytecode :

int computeIfPresent(int);
  Code:
     0: aload_0
     1: iload_1
     2: invokevirtual #2                  // Method get:(I)Ljava/lang/Integer;
     5: dup
     6: astore_2
     7: ifnull        15
    10: aload_2
    11: invokevirtual #3                  // Method java/lang/Integer.intValue:()I
    14: ireturn
    15: iconst_2
    16: ireturn

Bytecode pour la version "normale" avec initialisation classique des variables :

int computeIfPresent(int);
  Code:
     0: aload_0
     1: iload_1
     2: invokevirtual #2                  // Method get:(I)Ljava/lang/Integer;
     5: astore_2
     6: aload_2
     7: ifnull        15
    10: aload_2
    11: invokevirtual #3                  // Method java/lang/Integer.intValue:()I
    14: ireturn
    15: iconst_2
    16: ireturn

La seule différence est dup + astore_2 vs astore_2 + aload_2 . Je pourrais même soupçonner que la première version "optimisée pour l'embrayage" est pire, parce que dup est utilisé et la pile est plus grande sans raison. Peut-être que mon exemple était trop simple et que l'optimisation s'étend beaucoup dans un contexte plus compliqué.

Il s'agit d'un exemple simple, qui n'est certainement pas présent dans le code du JDK. HashMap.java Il y a des tonnes de fragments de ce type, parfois plusieurs sur la même ligne :

if ((first = tab[i = (n - 1) & hash]) != null)

Même si c'est très simple, je dois m'arrêter un instant et réfléchir à ce que ce code fait réellement, à cause de ces constructions.

Quelle est la véritable raison de l'utilisation de ces constructions ? Je suis sûr qu'il ne s'agit pas simplement d'un mauvais code. À mon avis, la qualité du code en souffre beaucoup, donc le bénéfice doit être substantiel. Ou simplement la règle leave small optimizations to JIT ne s'applique pas au JDK, parce qu'il doit être le plus performant possible ?

Ou s'agit-il simplement d'une règle extrême ? initialize variables as late as possible ? :)

2voto

Brian Points 7975

La réponse à votre question se trouve dans la spécification de la JVM, et plus précisément dans la différence que vous avez signalée : la fonction dup instruction ( JVMS §6.5.dup ). D'après ces documents :

Dupliquer la valeur supérieure de la pile d'opérandes et pousser la valeur dupliquée sur la pile d'opérandes.

En consultant la documentation sur la pile d'opérandes ( JVMS §2.6.2 , l'accent ajouté) :

Un petit nombre d'instructions de la machine virtuelle Java (les dup instructions (§dup) et swap (§swap)) opèrent sur les zones de données d'exécution en tant que valeurs brutes sans tenir compte de leurs types spécifiques ; ces instructions sont définies de telle sorte qu'elles ne peuvent pas être utilisées pour modifier ou décomposer des valeurs individuelles. Ces restrictions sur la manipulation de la pile d'opérandes sont mise en œuvre par le biais d'une vérification du dossier de classe (§4.10).

Si l'on descend d'un niveau supplémentaire et que l'on examine la section relative à la vérification de la classe ( JVMS §4.10 , l'accent ajouté) :

La vérification de la liaison améliore les performances de l'interpréteur d'exécution. Contrôles coûteux qu'il faudrait autrement effectuer pour vérifier les contraintes au moment de l'exécution pour les chaque instruction interprétée peut être éliminé . La machine virtuelle Java peut supposer que ces contrôles ont déjà été effectués .

Cela montre que ces restrictions sont validées à lien qui correspond au moment où la JVM charge votre fichier de classe. Pour répondre à votre question :

Quelle est la véritable raison de l'utilisation de ces constructions ?

Décortiquons ce que font les instructions dans chaque cas :

Dans le premier cas (avec le dup instruction) :

  1. invokevirtual stocke le résultat au sommet de la pile d'opérandes
  2. dup le duplique, de sorte qu'il y a maintenant deux copies du résultat au sommet de la pile
  3. astore_2 le stocke dans la variable locale n° 2, ce qui supprime une référence de la pile des opérandes
  4. ifnull vérifie si le sommet de la pile d'opérandes est nul, et si c'est le cas, passe à l'instruction 15, sinon continue (nous supposerons qu'il n'est pas nul)
  5. aload_2 place la variable locale n° 2 au sommet de la pile d'opérandes
  6. invokevirtual appelle une méthode sur la valeur supérieure de la pile d'opérandes, la retire, puis pousse le résultat.
  7. ireturn extrait la valeur supérieure de la pile d'opérandes et la renvoie

Dans le second cas :

  1. invokevirtual stocke le résultat au sommet de la pile des opérandes
  2. astore_2 retire le résultat de la pile des opérandes et le stocke dans la variable locale #2
  3. aload_2 place la variable locale n° 2 au sommet de la pile d'opérandes
  4. ifnull vérifie si le sommet de la pile d'opérandes est nul, et si c'est le cas, passe à l'instruction 15, sinon continue (nous supposerons qu'il n'est pas nul)
  5. aload_2 place la variable locale n° 2 au sommet de la pile d'opérandes
  6. invokevirtual appelle une méthode sur la valeur supérieure de la pile d'opérandes, la retire, puis pousse le résultat.
  7. ireturn extrait la valeur supérieure de la pile d'opérandes et la renvoie

Quelle est la différence ? Le premier appelle aload_2 une fois et dup une fois, la seconde appelle simplement aload deux fois. La différence sera pratiquement nulle. Si vous regardez la taille de la pile tout au long des opérations, vous verrez que la première implémentation augmente la pile d'opérandes d'une valeur supplémentaire (moins de 10 octets, généralement 8 ou 4 octets selon la JVM 64 bits ou 32 bits), mais qu'elle a un chargement de variable locale en moins dans la mémoire de la pile. La seconde conserve la pile d'opérandes légèrement plus petite, mais a un chargement supplémentaire de variable locale (lire : extraction de la mémoire).

En fin de compte, ces optimisations auront un effet positif sur la qualité de la vie. très l'impact est minime, sauf dans les applications où la mémoire est extrêmement faible, par exemple les systèmes embarqués. Alors, pour vous ? Faites ce qui est lisible.

En cas de doute : "L'optimisation prématurée (peut être) la racine de tous les maux". Tant que vous ne savez pas que votre code est lent ou que vous ne pouvez pas prouver qu'il est lent avant de l'exécuter, il est préférable d'écrire un code lisible. Cela n'entre pas dans les 3% critiques de ce qu'il faut optimiser à l'avance.

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