Je ne parlerai pas des preview (ni des second preview) ni d'incubator car j'attendrai que les fonctionnalités arrivent dans le langage pour les présenter.

Dans cette article, le JDK21 est la version long term support qui suit le JDK 17. Pour rappel, il n'est pas attendu que les logiciels en production suivent le rythme de sortie du JDK, suivre les LTS est très bien (cfr roadmap oracle).

JDK 18

charset

Le charset par défaut est UTF-8 [JEP 400].

Simple web server

À l'instar d'autres langages, java propose un Simple Web Server qui sert statiquement les pages du répertoires courant [JEP 408]. Ce web serveur est disponible via la commande jwebserver comme ceci : 

jwebserver -p 8082

Binding to loopback by default. For all interfaces use "-b 0.0.0.0" or "-b ::".
Serving /elsewhere and subdirectories on 127.0.0.1 port 8082
URL http://127.0.0.1:8082/
127.0.0.1 - - [19/févr./2025:11:35:35 +0100] "GET / HTTP/1.1" 200 -

Javadoc

Ajout d'un tag : @snippet. Ce tag facilite l'introduction d'exemple de code dans la javadoc.

Là où il fallait écrire

/**
 * The following code shows how to use {@code Optional.isPresent}:
 * <pre>{@code
 * if (v.isPresent()) {
 *     System.out.println("v: " + v.get());
 * }
 * }</pre>
 */

il est maintenant possible d'écrire :

/**
 * The following code shows how to use {@code Optional.isPresent}:
 * {@snippet :
 * if (v.isPresent()) {
 *     System.out.println("v: " + v.get());
 * }
 * }
 */

D'après la documentation de [JEP 413], il est également possible de faire référence à un code dans la javadoc et de baliser l'extrait de code grâce aux tags @start region="example" et @end.

Finalisation

La finalisation — aka l'utilisation d'un bloc finally — est proposée pour être retirée. Cette vielle structure est définitivement enterrée :

ObjectOutputStream oos = null;
try {
   oos = new ObjectOutputStream(new FileOutputStream(file));
   oos.writeObject(shapes);
   oos.flush();
} catch (FileNotFoundException ex) {
    // complain to user
} catch (IOException ex) {
    // notify user
} finally {
    if (oos != null) {
        try {
            oos.close();
        } catch (IOException ex) {
            // ignore ... any significant errors should already have been
            // reported via an IOException from the final flush.
        }
    }
}

(Source stackoverflow)

btw, la lecture de la JEP le concernant est intéressante puisqu'elle nous rappelle comment les fichiers — en tout cas leur fermeture — étaient gérés avant le try-ressources.

Voir JEP 421.


Éléments non abordés : - [JEP 416], - [JEP 417] (Possibilité d'optimisation de calcul en utilisant de la vectorisation), - [JEP 418] (Définit un SPI (service-provider interface) pour la résolution de nom. La résolution de nom par défaut reste celle faites par l'hôte),

JDK 19, JDK 20

Ces versions n'apportent que des previews et des incubators, voir JDK 19 et voir JDK 20.

JDK 21 (LTS long term support)

Introduction de SequencedCollection, SequencedSet et SequencedMap

Introduction de nouvelles interface dans la hiérarchie de Collection pour uniformiser les opérations sur les collections ordonnées (ordonnée pas triée).

L'interface SequencedCollection ajoute des méthodes pour manipuler les extrémités de la collection :

interface SequencedCollection<E> extends Collection<E> {
    // new method
    SequencedCollection<E> reversed();
    // methods promoted from Deque
    void addFirst(E);
    void addLast(E);
    E getFirst();
    E getLast();
    E removeFirst();
    E removeLast();
}

Pour parcourir une collection ordonnée (sequenced collection), on peut écrire :

SequencedCollection<String> nombrils = List.of("Vicky", "Jenny", "Karine");

/*
for modifiable sequenced collection
nombrils = new ArrayList<>(List.of("Vicky", "Jenny", "Karine"));
*/

System.out.println(nombrils.getFirst());
System.out.println(nombrils.getLast());
for (var s : nombrils) {
    System.out.print(s + " ");
}
System.out.println();

for (var s : nombrils.reversed()) {
    System.out.print(s + " ");
}
System.out.println();
// or with stream
nombrils.reversed().stream().forEach(System.out::print);

De même pour SequencedSet<E> (qui étend Set<E>) et SequencedMap<K, V> (qui étend Map<K, V).

Ces nouvelles interfaces répondent à des incohérences dans le framework Collection :

  • List et Deque ont un ordre implicite, mais leur supertype commun, Collection, n'en définit pas;
  • Set n'impose pas d'ordre, bien que LinkedHashSet et SortedSet le fassent et;
  • les collections immuables, comme celles créées avec Collections.unmodifiableSet(), perdent les informations d'ordre.

De plus, accéder au premier ou dernier élément varie selon les collections :

Collection Premier élément Dernier élément
List list.get(0) list.get(list.size() - 1)
Deque deque.getFirst() deque.getLast()
SortedSet sortedSet.first() sortedSet.last()
LinkedHashSet linkedHashSet.iterator().next() Aucune méthode dédiée

Itérer en sens inverse n'est pas non plus cohérent :

  • Deque (et NavigableSet) propose(nt) descendingIterator() :
Deque<String> deque = new LinkedList<>(
        List.of("Vicky", "Jenny", "Karine"));
for (var it = deque.descendingIterator(); it.hasNext(); ) {
    System.out.println(it.next());
}
  • NavigableSet utilise (aussi) descendingSet() :
NavigableSet<String> navigableSet = new TreeSet<>();
navigableSet.addAll(List.of("Vicky", "Jenny", "Karine"));

 for (var s : navigableSet.descendingSet()) {
    System.out.println(s);            
}
  • tandis que List emploie un ListIterator :
List list = new ArrayList(List.of("Vicky", "Jenny", "Karine"));
for (var it = list.listIterator(list.size()); it.hasPrevious(); ) {
    System.out.println(it.previous());
}

La méthode reversed() amène une belle cohérence à tout ça.

Voir JEP 431

Record pattern

Les record patterns permettent de « déconstruire » des records. Ces record patterns ont fait l'objet de plusieurs preview depuis JDK19.

Avant les type patterns introduit avec JDK16, l’idiome instanceof-and-cast s'écrivait :

// Prior to JDK16
if (obj instanceof String) {
    String s = (String)obj;
    // ... use s ...
}

JDK16 et le type pattern à simplifié l'écriture avec,

// As of Java 16
if (obj instanceof String s) {
    // ... use s ...
}

Que se passe-t-il si l'objet n'est pas de type String mais est un record ?

// As of Java 16
record Point(int x, int y) {}

static void printSum(Object obj) {
    if (obj instanceof Point p) {
        int x = p.x();
        int y = p.y();
        System.out.println(x+y);
    }
}

La pattern variable p n'est utilisée que pour accéder aux attributs du record. C'est dommage de ne pas pouvoir accéder directement aux attributs. Depuis JDK21, il est possible d'écrire :

// As of Java 21
static void printSum(Object obj) {
    if (obj instanceof Point(int x, int y)) {
        System.out.println(x+y);
    }
}

… et Point(int x, int y) est un record pattern.

JDK21 permet d'aller plus loin et d'imbriquer (nested) les record patterns. Si l'on prend l'exemple — assez habituel — d'un point qui devient un point coloré et qui intervient dans un rectangle, on peut définir :

// As of Java 16
record Point(int x, int y) {}
enum Color { RED, GREEN, BLUE }
record ColoredPoint(Point p, Color c) {}
record Rectangle(ColoredPoint upperLeft, ColoredPoint lowerRight) {}

Un record pattern simple permet d'écrire : 

// As of Java 21
static void printUpperLeftColoredPoint(Rectangle r) {
    if (r instanceof Rectangle(ColoredPoint ul, ColoredPoint lr)) {
         System.out.println(ul.c());
    }
}

mais si l'on imbrique les record patterns, on peut écrire :

// As of Java 21
static void printColorOfUpperLeftPoint(Rectangle r) {
    if (r instanceof Rectangle(ColoredPoint(Point p, Color c),
                               ColoredPoint lr)) {
        System.out.println(c);
    }
}

Les exemples sont issus de JEP 440 et il est possible d'aller plus loin et de voir leur usage dans le cas des génériques (generics)…

Voir JEP 440

Pattern matching pour le switch

Dans la même idée, JDK21 va également un pas plus loin dans le cas du switch.

Il arrive parfois (souvent) que l'on reçoive une variable de type Object et que l'action a exécuter va dépendre du type effectif de cet objet. Dans cas, les tests se font à grands coups de instanceof, un peu comme suit (en utilisant type pattern) :

// Prior to Java 21
static String formatter(Object obj) {
    String formatted = "unknown";
    if (obj instanceof Integer i) {
        formatted = String.format("int %d", i);
    } else if (obj instanceof Long l) {
        formatted = String.format("long %d", l);
    } else if (obj instanceof Double d) {
        formatted = String.format("double %f", d);
    } else if (obj instanceof String s) {
        formatted = String.format("String %s", s);
    }
    return formatted;
}

Là où le switch n'autorisait que les types byte, char, short et int, il autorise maintenant un type référence et l'on peut écrire un switch expression comme suit :

// As of Java 21
static String formatterPatternSwitch(Object obj) {
    return switch (obj) {
        case Integer i -> String.format("int %d", i);
        case Long l    -> String.format("long %d", l);
        case Double d  -> String.format("double %f", d);
        case String s  -> String.format("String %s", s);
        default        -> obj.toString();
    };
}

Le switch version JDK21 autorise également que la variable soit null et l'on peut donc ajouter case null -> // do something à l'exemple précédent. Le label default ne correspond jamais à null. null doit être traité à part.

Comme les différents cas ne correspondent plus à une constante mais à un type, le pattern case label peut amener à plusieurs valeurs différentes, comme dans l'exemple (toujours issu de JEP 441) : 

// As of Java 21
static void testStringOld(String response) {
    switch (response) {
        case null -> { }
        case String s -> {
            if (s.equalsIgnoreCase("YES"))
                System.out.println("You got it");
            else if (s.equalsIgnoreCase("NO"))
                System.out.println("Shame");
            else
                System.out.println("Sorry?");
        }
    }
}

Pour écrire plus proprement ce genre de switch, JDK21 propose une clause when dans le case, un peu comme suit :

// As of Java 21
static void testStringNew(String response) {
    switch (response) {
        case null -> { }
        case String s
        when s.equalsIgnoreCase("YES") -> {
            System.out.println("You got it");
        }
        case String s
        when s.equalsIgnoreCase("NO") -> {
            System.out.println("Shame");
        }
        case String s -> {
            System.out.println("Sorry?");
        }
    }
}

Il est également possible de mélanger null (null label), case constants et case patterns dans un même switch, comme suit :

// As of Java 21
static void testStringEnhanced(String response) {
    switch (response) {
        case null -> { }
        case "y", "Y" -> {
            System.out.println("You got it");
        }
        case "n", "N" -> {
            System.out.println("Shame");
        }
        case String s
        when s.equalsIgnoreCase("YES") -> {
            System.out.println("You got it");
        }
        case String s
        when s.equalsIgnoreCase("NO") -> {
            System.out.println("Shame");
        }
        case String s -> {
            System.out.println("Sorry?");
        }
    }
}

Pour aller plus loin (et retrouver ces exemples), consulter JEP 441.

Thread virtuel

Les threads virtuels (virtual thread) ont été en preview dans la JEP 4225 (JDK19) et JEP 436 (JDK20). Les virtual threads sont des threads légers utilisant les threads de la plateforme.

Là où les threads de plateforme sont plus lourds, coûteux, limités en nombre, les threads virtuels sont plus légers et peuvent être plus nombreux. Ils sont également non bloquant ; lorsque un thread virtuel bloque sur une I/O par exemple il se détache du thread OS auquel il est lié, le libérant pour un autre tâche. Il n'est pas nécessaire — c'est même une mauvaise idée — d'utiliser un pool de threads pour les threads virtuels. C'est la jvm (machine virtuelle java, java vritual machine) qui gère les threads. La jvm utilise n threads OS pour y lancer m threads virtuels. Ces threads OS sont également appelés carrier threads puisqu'ils servent à « transporter » les threads virtuels.

Testons…

var n = 10_000;

// virtuals threads
var before = System.currentTimeMillis();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    IntStream.range(0, n).forEach(i -> {
        executor.submit(() -> {
            Thread.sleep(Duration.ofSeconds(1));
            return i;
        });
    });
}
var after = System.currentTimeMillis();
System.out.println("Elapsed time : " + (after - before));

// threads OS (one thread per task)
before = System.currentTimeMillis();
try (var executor = Executors.newThreadPerTaskExecutor(
        Executors.defaultThreadFactory())) {
    IntStream.range(0, n).forEach(i -> {
        executor.submit(() -> {
            Thread.sleep(Duration.ofSeconds(1));
            return i;
        });
    });
}
after = System.currentTimeMillis();
System.out.println("Elapsed time : " + (after - before));

// threads OS (with pool of 100 threads)
before = System.currentTimeMillis();
try (var executor = Executors.newFixedThreadPool(100)) {
    IntStream.range(0, n).forEach(i -> {
        executor.submit(() -> {
            Thread.sleep(Duration.ofSeconds(1));
            return i;
        });
    });
}
after = System.currentTimeMillis();
System.out.println("Elapsed time : " + (after - before));

Voici les différences en fonction des valeurs de n (c'est bien un ordre de grandeur) :

n Virtual thread Thread OS (1/task) Thread OS (with pool)
10 1 066 ms 1 007 ms 1 004 ms
100 1 005 ms 1 026 ms 1 019 ms
1_000 1 048 ms 2 246 ms 10 024 ms
10_000 1 353 ms 7 263 ms 101 015 ms
100_000 1 568 ms 35 276 ms 1 000 426 ms (± 17 min)

Y a pas photo.

Pour conclure, the new way pour créer un thread est :

var t1 = Thread.ofPlatform()
        .start(() -> System.out.println("Platform thread"));
var t2 = Thread.ofVirtual()
        .start(() -> System.out.println("Virtual thread"));
var t3 = Thread.startVirtualThread(
        () -> System.out.println("Virtual thread (other way)"));
var t4 = Thread.ofPlatform()
        .daemon()
        .start(() -> System.out.println("Platform thread (dæmon)"));

(Déclarer les variables permet de faire un join() ensuite)

Pour aller plus loin (et retrouver ces exemples), consulter JEP 444.


Éléments non abordés

  • JEP 439 Optimisation du garbage collector,
  • JEP 452 Key Encapsulation Mechanism API relatif à la cryptographie


JDK 22

Et l'on revient dans un JDK qui n'est pas long term support.

Variables et pattern anonyme _

Unnamed variables & patterns (variables et patterns anonymes) ; lorsqu'une déclaration de variable ou d'un pattern imbriqué (nested pattern) est obligatoire et que la variable ou le pattern ne sera pas utilisé. On utilise alors _.

Par exemple :

static int count(Iterable<Order> orders) {
    int total = 0;
    for (Order order : orders)    // order is unused
        total++;
    return total;
}

(Exemple de JEP)

devient :

static int count(Iterable<Order> orders) {
    int total = 0;
    for (var _ : orders)  
        total++;
    return total;
}

Une variable anonyme ou un pattern anonyme est déclaré en utilisant le caractère underscore (barre de soulignement, tiret bas, _, U+005F)

Si l'on reprend l'exemple d'un nested record pattern,

// As of Java 21
static void printColorOfUpperLeftPoint(Rectangle r) {
    if (r instanceof Rectangle(ColoredPoint(Point p, Color c),
                               ColoredPoint lr)) {
        System.out.println(c);
    }
}

cette exemple peut devenir : 

// As of Java 22
static void printColorOfUpperLeftPoint(Rectangle r) {
    if (r instanceof Rectangle(ColoredPoint(_, Color c), _)) {
        System.out.println(c);
    }
}

Pour plus d'infos et d'exemples voir JEP 456

Lancer plusieurs fichiers sources

Depuis JDK11, il est possible d'écrire

public class Hello{
    public static void main(String[] args) {
        System.out.println("Hello");
    }
}

et de directement exécuter le code d'un

~:$ java Hello.java

Le fichier est alors compilé, le bytecode stocké en mémoire et exécuté. À chaque exécution, le code source est recompilé. C'est la « méthode à la shebang ».

Il est possible d'écrire deux classes dans un même fichier. Un peu comme ci-dessous et ça fonctionne bien.

class Prog {
    public static void main(String[] args) { Helper.run(); }
}

class Helper {
    static void run() { System.out.println("Hello!"); }
}

Bien sûr pour un programme conséquent, les fichiers sont compilés et les fichiers .class sont conservés sur disque. Bien sûr aussi qu'un outil de construction est conseillé (Maven ou autre).

Entre un simple Hello world et un programme complexe, il existe des programmes qui utiliseront plusieurs fichiers mais pas encore le besoin de comprendre son IDE ou d'appréhender un outil de construction (build tool). JDK22 permet de lancer directement un programme se trouvant dans plusieurs classes et réparties dans plusieurs fichiers sous certaines conditions.

Si le code précédent se trouve dans deux fichiers différents, java compilera la première classe dans le premier fichier. Lorsque le loader rencontre la seconde classe, java cherche le fichier et le compile. Toujours en mémoire.

Voir JEP 458 pour les détails dans les cas de librairies (bibliothèques) externes, de packages


Éléments non abordés

  • JEP 423 Region Pinning for G1, optimisation du garbage collector G1
  • JEP 454 Changement au niveau des appels de fonctions étrangères (comme JNI java native interface)