[Devoxx 2013] Implémentation des lambdas au niveau bytecode

devoxx 2013 Java 8 sera révolutionnaire en introduisant les expressions lambdas, tout en ne s’appuyant finalement que sur un nombre limité de nouvelles fonctionnalités. Lors de l’une de ses présentations – Lambda: A peek under the hood – à Devoxx World, Brian Goetz, architecte du langage Java chez Oracle et actuellement responsable de la spécification JSR-335 relative aux lambdas, a expliqué les choix d’implémentation des lambdas au niveau du compilateur et de la JVM.

Au cours de sa conférence il a traité les points suivants :

  • le type d’une lambda,
  • l’implémentation des lambdas au niveau bytecode et,
  • les performances des lambdas faces à celles des classes anonymes.

Qu’est-ce qu’une lambda ?

Une lambda est une méthode anonyme, ayant des paramètres, un type de retour et un corps comme n’importe quelle méthode, mais avec une syntaxe plus compacte.

people.map((Person person) -> person.getAge());

Les types pouvant être inférés, le code précédent peut être réécrit de la manière suivante :

people.map(person -> person.getAge());

De plus, les lambdas peuvent capturer les valeurs de variables d’instances statiques ou en réalité final (variables assignées une seule fois et jamais modifiées par la suite dans la méthode contenant l’expression Lambda).

people.filter(person -> person.getAge() >= minAge);

Les lambdas sont un moyen de supporter les architectures multicœurs. Mais elles sont aussi un moyen d’écrire du code plus simplement et surtout de rendre le code plus simple à lire. En reprenant les termes de Brian Goetz lors de la keynote Java 8 and Beyond, “Reading code is more important than writing code”.

Quel est le type d’une lambda ?

Une réponse évidente serait d’avoir un type Function comme dans de nombreux langages. Mais cette idée soulève de nombreuses questions au niveau bytecode :

  • Comment représenter une fonction dans la signature d’une méthode ?
  • Comment représenter l’invocation d’une fonction ? Pouvons-nous utiliser une instruction existante ou faut-il en introduire de nouvelles ?
  • Comment représenter la création d’une instance ? Actuellement nous avons new et newarray. Pouvons-nous les utiliser ou faut-il créer de nouvelles instructions ?

Des questions compliquées qui n’interviennent pas au niveau du code Java mais qui sont essentielles pour un compilateur et lorsque du bytecode doit être généré. Cela aurait nécessité la création d’une dizaine de nouvelles instructions, mais aussi posé de nombreux problèmes d’interopérabilité.

Interfaces fonctionnelles

Au lieu de complexifier le système de typage, les interfaces ne possédant qu’une seule méthode – telles que les classes Runnable ou Comparator – ont été normalisées. Elles ont été renommées interfaces fonctionnelles et sont devenues le type des expressions lambdas.

interface Predicate<T> {
    boolean test(T t);
}

// La méthode removeAll() prend un paramètre de type Predicate
people.removeAll(person -> person.getAge() < 18);

Le compilateur identifie l’interface Predicate en tant qu’interface fonctionnelle (en raison de sa structure) et infère le type de la lambda pour qu’elle ait le type Predicate<Person>.

Ceci fonctionne parfaitement puisque le mécanisme se base sur le système de types existants. De plus, ceci est un avantage pour de nombreuses bibliothèques n’ayant pas été créées en pensant aux lambdas (telles que les APIs du JDK il y a quinze ans). Elles peuvent être utilisées aujourd’hui sans aucun problème avec des lambdas.

Comment l’instance d’une lambda est-elle créée ?

En partant de l’exemple suivant :

Predicate<Person> predicate = person -> person.getAge() >= minAge;

Classes anonymes

Une fois encore une solution simple vient à l’esprit, l’utilisation de classes anonymes. Nous pourrions très bien réécrire l’exemple précédent :

Predicate<Person> predicate = new Predicate<Person>() {
  @Override
  boolean test(Person p) {
    return p.getAge() >= minAge;
  }
}

Voyons comment le compilateur traduit une classe anonyme :

class Foo$1 implements Predicate<Person> {
  private final int $v0;

  Foo$1(int v0) {
    this.$v0 = v0;
  }

  public boolean test(Person p) {
    return p.getAge() >= minAge;
  }
}

Il suffit ensuite d’utiliser Foo$1 à la place de la lambda :

people.removeAll(new Foo$1(minAge));

Ce mécanisme semble simple et direct. De fait, pourquoi ne pas convertir les lambdas en classes anonymes ?

  • pour des raisons de performances
    • nous aurions une classe par lambda, et donc
    • une pollution de types
    • la JVM alloue une nouvelle instance à chaque fois
  • les règles pour une classe anonyme sont nombreuses et compliquées
  • etc.

Encore une solution évidente qui s’avère être un mauvais choix.

MethodHandles

Avec Java 7 est apparu le concept de method handles, un véritable couteau suisse pour tout développeur de compilateurs. Les method handles sont un nouveau moyen de représenter l’appel d’une méthode au niveau de la machine virtuelle. Ils peuvent être stockés dans le pool de constantes d’un fichier .class et permettent une sorte d’introspection au niveau de la machine virtuelle, en beaucoup plus rapide que l’API Reflection (java.lang.reflect).

Une lambda est un objet méthode au niveau Java et un MethodHandle est un objet méthode au niveau de la JVM. Il semble donc possible de transposer la première forme dans la seconde.

La lambda peut être transformée en une méthode statique :

private static boolean lambda$1(int minAge, Person p) {
  return p.getAge() >= minAge;
}

L’appel de la méthode removeAll() étant extrêmement simple :

MethodHandle mh = LDC[lambda$1];
mh = MethodHandles.insertArguments(mh, 0, minAge);
people.removeAll(mh);

En faisant ceci, la méthode removeAll() aurait pour paramètre un objet de type MethodHandle, ce qui est identique à Object ou ne pas avoir de type du tout. Il serait donc impossible de surcharger deux méthodes ayant des lambdas différentes.

Une fois encore cette solution n’est pas adaptée. Il fallait donc trouver un autre niveau d’indirection. Nous pourrions demander au compilateur de générer une recette déclarative et non du code impératif pour notre lambda, en laissant la JVM exécuter cette recette de la manière qu’elle estime la meilleure. Cette idée est en réalité le fonctionnement de l’instruction invokedynamic.

invokedynamic

L’instruction invokedynamic, introduite en Java 7, est la seule instruction ayant été rajoutée depuis la version 1 de Java. Avant Java 7, il y avait quatre façons d’appeler une méthode – en fonction du contexte. Ces quatre instructions prenant chacune trois paramètres, le nom de la classe, le nom de la méthode et la signature de la méthode. invokedynamic a été créée de manière à être plus flexible que les quatre précédentes, pour pouvoir être utilisée par d’autres langages, notamment les langages dynamiques. Elle permet au compilateur de générer une logique de délégation de méthodes. Le langage et la machine virtuelle deviennent donc partenaires dans la délégation de méthodes d’une manière flexible et intelligente, contrairement au mécanisme d’introspection.

Par exemple, pour un langage dynamique :

def add(a, b) {
  a + b
}

Les types de a et b ne sont pas connus lors de la compilation, et peuvent changer d’un appel à l’autre, mais ce n’est généralement pas le cas.

Lorsque la JVM exécute pour la première fois une instruction invokedynamic précise (le processus est identique pour toutes les autres instructions invokedynamic présentes dans le code) plusieurs actions sont effectuées au niveau de la JVM :

  • La JVM consulte une méthode de bootstrap en lui fournissant toutes les informations – au sujet de l’appel – qu’elle possède
  • La méthode de bootstrap retourne un call site contenant les informations de la méthode à appeler
  • Le call site peut contenir des conditions qui peuvent nécessiter que celui-ci se lie à une autre méthode, par exemple s’il y a un changement du type des paramètres. Sinon, la JVM n’a plus besoin de consulter à nouveau la méthode de bootstrap.

Un call site d’invokedynamic a trois groupes d’opérandes :

  • Une méthode de bootstrap
  • Une liste d’arguments statiques présents dans le pool de constantes d’un fichier .class
  • Une liste d’arguments dynamiques, comme toutes les autres instructions de type invoke*

Ce qui est dynamique ici ce n’est pas le typage mais la stratégie de génération du code, ce que nous avons appelé “un autre niveau d’indirection”.

Voyons comment procéder pour transformer notre lambda en une recette déclarative. La première étape est de créer une méthode statique comparable à celle utilisée par les method handles :

private static boolean lambda$1(int minAge, Person p) {
  return p.getAge() >= minAge;
}

Ensuite, nous générons un call site pour invokedynamic qui retourne une lambda :

Predicate $predicate = indy[bootstrap=LambdaMetaFactory,
                            staticargs=[Predicate, lambda$1],
                            dynargs=[minAge]];

people.removeAll($predicate);

Aujourd’hui, nous générons à l’exécution la même chose que ce que le compilateur aurait généré si nous avions utilisé la solution des classes anonymes. Pourquoi ? Il s’agit en réalité de la stratégie de génération la plus simple, mais il y en a d’autres – plus complexes – qui en théorie sont plus performantes. Et c’est un des avantages d’utiliser le mécanisme d’invokedynamic : changer la stratégie peut être fait de manière complètement transparente pour l’utilisateur, ce qui a l’avantage de ne pas nécessiter une nouvelle compilation pour pouvoir bénéficier d’une nouvelle stratégie. En d’autres termes, la première version de Java 8 sera livrée avec une stratégie, mais cela n’empêche en rien d’avoir une mise à jour au niveau de la JVM en implémentant une nouvelle.

Performances

Voyons à présent les performances des lambdas face aux classes anonymes dans différents contextes.

Coût d’initialisation

Anonyme Lambda Différence
Sans capture et sans tiered 7473 6891 8,4%
Sans capture et avec tiered 7038 5743 14,4%
Avec capture et sans tiered 7885 6550 20,3%
Avec capture et avec tiered 7638 5727 24%
  • Temps d’initialisation en ms pour 32 000 classes anonymes/lambdas.
  • Capture = capture de la valeur d’une variable
  • Tiered = compilation à plusieurs niveaux

Coût de la capture d’une variable

Mono-thread Saturé Extensibilité
Classe anonyme 160 1407 x8,8
Lambda sans capture 636 23201 x36,4
Lambda avec capture 160 1400 x8,8
  • Tous les nombres sont en opérations par micro secondes
  • Les tests ont été effectués sur un serveur ayant un processeur Nehalem EX de 10 cœurs hyperthreadés
  • Dans le pire des cas les chiffres pour les lambdas sont identiques à ceux pour les classes anonymes
  • Une classe anonyme capture toujours au moins une valeur, this représentant la classe dans laquelle elle est incluse.

En attendant, la vidéo de Devoxx, il est possible de visionner celle de JavaOne qui est sensiblement identique.

Nombre de vue : 63

AJOUTER UN COMMENTAIRE