Fork / Join version Java 8

Java 8, c’est pour 2013 d’après les annonces d’Oracle. Autant dire pour demain. De nombreuses choses sont déjà sur les rails, dont les évolutions du framework Fork / Join, dont je t’ai déjà beaucoup parlé, cher et précieux lecteur. Alors pourquoi revenir encore sur le sujet ? Simplement parce que le célèbre Brian Goetz, auteur du non moins célèbre Java Concurrency in Practice, nous a décrit par le menu ce qui nous attendait sur le front des calculs en parallèle dans Java 8. C’était le 16/11/2011, lors de Devoxx, à Anvers. D’ailleurs, si tu n’es pas encore au courant, je te signale, précieux lecteur, que Devoxx va s’importer à Paris en avril 2012, puisque Devoxx France est maintenant officiellement lancée !

Au début était le λ

Des esprits égarés pensaient qu’au début était l’α, et qu’à la fin des fins se trouvait l’ω.

Non. En informatique, comme tout est toujours en retard, on commence à λ. Mine de rien, ça fait quand même onze lettres de retard, belle performance ! Sans λ rien ne peut se faire, tout passe par lui, tout le monde l’attend, à tel point que lorsqu’il sera finalement là, on se demande bien ce que l’on fera, et ce que l’on pourra attendre.

Blague à part, le λ est aujourd’hui considéré (à tort ou à raison), comme la solution au grand problème de notre temps de développeur : le calcul en parallèle, ou concurrent. Durant ces quinze dernières années, on considérait qu’il était du ressort du programme de se palucher la parallélisation de ses codes de calcul, et maintenant que l’on se rend compte que ça ne marche pas bien, on se dit qu’il vaut mieux confier cette tâche à la JVM directement. De là à en déduire qu’on nous prend pour des andouilles, il n’y a qu’un pas, mais ça n’est pas l’objet de cet article.

Donc le λ arrive, et va révolutionner le 1% de projets qui va l’utiliser. Plutôt que d’écrire l’ignoble code suivant :

int maxAge = 0 ;
for (Person p : Persons) {
   if (p.getAge() > maxAge) {
      maxAge = p.getAge() ;
   }
}

On écrira ce genre de chose :

int maxAge =
   persons
      .map(p -> p.getAge())
      .reduce(0, Integer::max) ;

Cette deuxième version est sans aucun doute plus courte que la première. Dire que, puisqu’elle est plus courte elle est aussi plus lisible me semble un peu plus hasardeux. Je reste persuadé qu’il sera aussi facile d’écrire du code illisible avec des λ que sans eux. Les λ amènent de nouvelles règles syntaxiques, donc des types de bugs nouveaux…

Mine de rien, précieux et sagace lecteur, tu n’auras pas laissé passer le point suivant : l’introduction de deux nouvelles méthodes map() et reduce(). Toutes deux prennent une expression λ en paramètre, et sont invoquées sur un objet de type Collection. C’est du moins ce que laisse supposer le code, tel qu’il est écrit. Par quel tour de passe-passe ce code va-t-il bien pouvoir compiler ?

Des méthodes virtuelles dans les interfaces

Ce tour de passe-passe n’en est pas un, et il porte un nom : les méthodes virtuelles d’extension. Le problème tel que posé par Brian Goetz est très simple.

  • Ces méthodes ne peuvent être ajoutées ailleurs que sur les interfaces de l’API Collection. Donc on les ajoute à cet endroit.
  • Les interfaces de cette API sont vieilles, soit, mais sont intouchables. Les modifier imposerait des changements trop profonds de l’API Java, briserait la sacro-sainte compatibilité ascendante (dont on tordra quand même le cou, mais plus tard, en Java 9), et restreindrait probablement l’adoption de Java 8 du fait de cette incompatibilité.

Qu’est-ce qu’une méthode virtuelle d’extension ? Simplement une méthode, déclarée dans une interface, dont on fournit une implémentation par défaut. Eh oui, précieux lecteur, toi à qui l’on a inculqué qu’une interface ne pouvait pas comporter d’implémentation, eh bien on va lui en coller en Java 8 !

Au niveau de la syntaxe, les choses ressembleront à cela.

public interface Collection<T> {
   // méthode virtuelle d'extension
   public void sort(Comparator<? super T> comp)
      default Collections.sort(comp) ;
}

L’interface Collection définit simplement une nouvelle méthode : sort(), qui prend un comparateur en paramètre. Cette méthode peut être redéfinie dans une classe d’implémentation, mais si ce n’est pas le cas, elle appelle la méthode statique de la classe Collections. Tout va bien, notre bon vieux code legacy de Java 5 compilera, et nous pourrons ajouter nos nouvelles méthodes à nos interfaces.

Un problème peut se poser toutefois : une classe donnée peut implémenter plusieurs interfaces. Et une interface donnée peut étendre plusieurs interfaces. Java n’autorise pas l’héritage multiple, sauf dans le cas des types. Brian Goetz nous indique que Java va autoriser l’héritage multiple d’implémentation, ce qui est nouveau. Cela dit, il n’autorisera toujours pas l’héritage multiple d’état (sic).

On imagine sans peine les collisions, nous avons donc besoin d’une règle de résolution. Cette règle est simple.

  • On prend toujours l’implémentation par défaut définie dans la classe ou l’interface la plus spécifique. C’est une règle déjà appliquée dans d’autres domaines du fonctionnement du langage.
  • En cas d’égalité, on impose que la classe concrète redéfinisse sa propre implémentation, qui peut appeler une des implémentations par défaut.
// créons une collision
// une interface A
public interface A {
   public void a() default ... ;
}

// une interface B
public interface B {
   public void a() default ... ;
}

// une classe qui implémente les deux
public class AImpl implements A, B {
   // collision : on doit définir a() explicitement
   public void a() {
      // on choisit l'implémentation
      // par défaut définie dans A
      A.super.a() ;
   }
}

Nous sommes donc sauvés ! On a des λ, on peut les utiliser dans de nouvelles interfaces qui ne cassent pas toutes nos compilations, la vie est belle !

Il est difficile de se lancer dans la description du framework Fork / Join dans Java 8, sans expliquer ce que vont être ces fameux lambdas dont tout le monde parle depuis si longtemps, et qui vont finir par arriver.

Fork / Join : les évolutions

Disponible seulement depuis Java 7 (c’est-à-dire depuis moins d’un an), le framework est déjà l’objet d’une critique principale : il expose les détails de l’implémentation de l’algorithmique en parallèle de façon trop directe. Le développeur (nous !) doit créer le pool de threads à la main, définir le niveau de parallélisme, la stratégie de division du problème, toutes choses qu’on ne devrait surtout pas confier, précisément, au développeur. Cette idée est déjà présente dans les parallel arrays, puisque dans ce framework, on ne définit précisément pas la stratégie de division, qui est prise en charge pas l’API.

L’idée de Brian Goetz va en fait plus loin : la prochaine version du framework fera tout toute seule. À côté de l’interface Iterable, bien connue, va être créée l’interface Spliterable.

L’interface Collection, et, on peut raisonnable le penser, l’interface Map, admettront toutes deux une méthode parallel(), qui retournera un objet de type Spliterable.

Actuellement, l’interface Iterable est la suivante.

public interface Iterable<T> {
   Iterator<T> iterator() ;
}

L’interface Spliterable ressemblera à cela.

public interface Spliterable<T> {
   Iterator<T> iterator() ;
   Spliterable<T> left() ;
   Spliterable<T> right() ;
   Iterable<T> sequential() ;
}

Les deux interfaces Iterable et Spliterable ajouteront également un lot de méthodes virtuelles d’extension, permettant de réaliser toutes les opérations de map / reduce, de filtrage, de tri, etc…

Si l’on veut extraire toutes les personnes dont l’âge est supérieur à 40 (je dis bien personnes, et non pas développeurs…), on pourra donc écrire le code suivant.

Collection<Person> persons = ... ;
Collection<Person> oldies =
   persons.filter(p -> p.age > 40) // appel classique avec des λ
          .into(new ArrayList<>()) ;

Ce code s’exécutera dans un unique thread.

Une simple modification permettra de l’exécuter dans un environnement multithread.

Collection<Person> persons = ... ;
Collection<Person> oldies =
   persons.parallel() // cet appel retourne un Spliterable<Person>
          .filter(p -> p.age > 40)
          .into(new ArrayList<>()) ;

Le simple appel à la méthode parallel(), définie sur Collection, permet d’indiquer que l’on souhaite que le traitement de notre collection s’effectue en multithread.

Cette façon de faire est sans aucun doute beaucoup plus simple. Espérons tout de même que l’on aura encore un contrôle sur le taux de parallélisme, et l’attribution de nos calculs à des pools de threads précis, de façon à pouvoir définir finement les priorités de nos traitements.

Conclusion

Cette nouvelle façon d’écrire les traitements sur les collections est évidemment une évolution majeure du langage Java, et de ses API.

Je pense que cette évolution est comparable à celle qui a consisté, il y a 15 ans, à retirer les malloc() et les free() des mains des développeurs Java. Nous sommes trop bêtes pour gérer la mémoire nous-mêmes ? Qu’à cela ne tienne, la JVM va le faire pour nous. Nous ne sommes pas assez bons pour gérer la programmation concurrente nous-mêmes ? La JVM le fera également pour nous !

Aujourd’hui les processeurs courants comportent quelques cœurs, on peut supposer que dans 10 ans ils en compteront des centaines, peut-être des milliers. On n’en sera plus à parler de programmation concurrente, ni même de calculs parallèles. De nouvelles notions apparaîtront pour décrire les méthodes de traitement de données de demain. Décrire ces traitements de façon atomique (sur une donnée unique), et laisser la JVM mener le traitement global le plus efficacement possible, en fonction des ressources disponibles, c’est bien là le véritable enjeu. Les ressources de calcul sont en train d’acquérir le même statut que les ressources disque ou mémoire, il est finalement logique de les gérer avec les mêmes approches.

Références

OpenJDK : projet lambda : http://openjdk.java.net/projects/lambda/

Brian Goetz : Language / Library co-evolution in Java SE 8

http://www.devoxx.com/display/DV11/Brian+Goetz

Les slides de la présentation de Brian Goetz sont en ligne ici.

Une réflexion au sujet de « Fork / Join version Java 8 »

  1. Très bon article. Les méthodes virtuelles d’extension me font penser aux Traits en Scala. Coté API, je suis vraiment pour une solution dans le langage, qui facilite l’écriture de code parrallélisable. Intéressant en tous les cas.

Les commentaires sont fermés.