Programación

Java 8 frente a Java 21: las diferencias que importan, con código

9 min de lectura

Java 8 salió en marzo de 2014 y Java 21 en septiembre de 2023. Entre una y otra hay nueve años y trece versiones, y en ese tiempo Java cambió más que en toda la década anterior: llegaron var, los records, los bloques de texto, el switch con patrones y los hilos virtuales, y se fueron partes enteras de la plataforma. Muchas empresas siguen en Java 8, así que la pregunta es práctica: qué ganas al pasar a 21 y qué se rompe por el camino. Cada ejemplo de este artículo se compiló y se ejecutó en los dos JDK.

Las dos son versiones LTS (de soporte largo). Desde 2017, Java publica una versión cada seis meses y, desde la 17, marca como LTS una de cada cuatro, es decir, una cada dos años. Por eso la mayoría de proyectos saltan de una LTS a otra: de 8 a 11, a 17, a 21 y, hoy, a 25, la LTS más reciente, de septiembre de 2025. Todo lo que cuenta este artículo sigue valiendo en Java 25.

Línea de tiempo vertical de Java 8, de 2014, a Java 21, de 2023, con una tarjeta por versión. Java 8 (LTS): lambdas, streams, java.time. Java 9: módulos, List.of, JShell. Java 10: var. Java 11 (LTS): HttpClient, java Hola.java, sin Java EE. Java 14: switch con ->, NPE detallada. Java 15: bloques de texto """, sin Nashorn. Java 16: record, instanceof con patrón, toList(). Java 17 (LTS): sealed, encapsulación fuerte. Java 18: UTF-8 por omisión. Java 21 (LTS): hilos virtuales, patrones en switch, getFirst().De Java 8 a Java 21, versión a versiónlenguajebibliotecasplataformase eliminó8Java 8marzo de 2014LTSlambdasstreamsjava.time9Java 9septiembre de 2017módulosList.ofJShell10Java 10marzo de 2018var11Java 11septiembre de 2018LTSHttpClientjava Hola.javasin Java EE14Java 14marzo de 2020switch con ->NPE detallada15Java 15septiembre de 2020bloques de texto """sin Nashorn16Java 16marzo de 2021recordinstanceof con patróntoList()17Java 17septiembre de 2021LTSsealedencapsulación fuerte18Java 18marzo de 2022UTF-8 por omisión21Java 21septiembre de 2023LTShilos virtualespatrones en switchgetFirst()
Las novedades de este artículo, en la versión en que llegaron como definitivas. Esquema: decodigo.com.

Menos código para lo mismo

Colecciones: var, List.of, toList y las secuenciadas

En Java 8, crear una lista que no se pueda modificar y quedarse con su primer y último elemento cuesta bastante ceremonia:

import java.util.*;
import java.util.stream.*;

public class Main {
    public static void main(String[] args) {
        List<String> lenguajes = Collections.unmodifiableList(
            Arrays.asList("Java", "Kotlin", "Scala", "Groovy"));
        Map<String, Integer> anios = new HashMap<>();
        anios.put("Java", 1995);
        anios.put("Kotlin", 2011);

        List<String> cortos = lenguajes.stream()
            .filter(l -> l.length() <= 5)
            .collect(Collectors.toList());

        System.out.println(cortos);
        System.out.println(lenguajes.get(0) + " ... " + lenguajes.get(lenguajes.size() - 1));
        System.out.println(anios.get("Kotlin"));
    }
}
java 8[Java, Scala]
Java ... Groovy
2011

En Java 21, lo mismo:

import java.util.*;

public class Main {
    public static void main(String[] args) {
        var lenguajes = List.of("Java", "Kotlin", "Scala", "Groovy");
        var anios = Map.of("Java", 1995, "Kotlin", 2011);

        var cortos = lenguajes.stream()
            .filter(l -> l.length() <= 5)
            .toList();

        System.out.println(cortos);
        System.out.println(lenguajes.getFirst() + " ... " + lenguajes.getLast());
        System.out.println(lenguajes.reversed());
        System.out.println(anios.get("Kotlin"));
    }
}
java 21[Java, Scala]
Java ... Groovy
[Groovy, Scala, Kotlin, Java]
2011
  • var (Java 10) deja que el compilador deduzca el tipo de una variable local. Sigue siendo un tipo fijo: no es el var de JavaScript.
  • List.of, Set.of y Map.of (Java 9) crean colecciones inmutables en una línea. No admiten null, y Map.of llega hasta diez pares; para más, Map.ofEntries.
  • .toList() (Java 16) sustituye a .collect(Collectors.toList()) y devuelve una lista que no se puede modificar.
  • getFirst(), getLast() y reversed() (Java 21) llegan con las colecciones secuenciadas, una interfaz común para todo lo que tiene orden: listas, LinkedHashSet, LinkedHashMap, Deque…

Texto: bloques de texto y los métodos que faltaban

public class Main {
    public static void main(String[] args) {
        String json = "{\n" +
                      "  \"nombre\": \"Ana\",\n" +
                      "  \"lenguaje\": \"Java\"\n" +
                      "}";
        String entrada = "   \t  ";
        System.out.println(json);
        System.out.println(entrada.trim().isEmpty());
        StringBuilder linea = new StringBuilder();
        for (int i = 0; i < 20; i++) linea.append('-');
        System.out.println(linea);
        System.out.println(String.format("%s tiene %d años", "Java", 2023 - 1995));
    }
}
java 8{
  "nombre": "Ana",
  "lenguaje": "Java"
}
true
--------------------
Java tiene 28 años
public class Main {
    public static void main(String[] args) {
        String json = """
            {
              "nombre": "Ana",
              "lenguaje": "Java"
            }""";
        String entrada = "   \t  ";
        System.out.println(json);
        System.out.println(entrada.isBlank());
        System.out.println("-".repeat(20));
        System.out.println("%s tiene %d años".formatted("Java", 2023 - 1995));
        "uno\ndos\ntres".lines().map(String::strip).forEach(System.out::println);
    }
}
java 21{
  "nombre": "Ana",
  "lenguaje": "Java"
}
true
--------------------
Java tiene 28 años
uno
dos
tres

Los bloques de texto (Java 15), entre triples comillas, permiten escribir JSON, SQL o HTML tal cual, sin escapar comillas ni concatenar líneas; la sangría común se quita sola. Java 11 añadió a String isBlank(), strip() (que, a diferencia de trim(), entiende los espacios de Unicode), lines() y repeat(), y Java 15, formatted().

Records: clases de datos en una línea

Una clase que solo guarda datos, en Java 8, necesita constructor, getters, equals, hashCode y toString, unas cuarenta líneas que casi siempre se generaban con el IDE o con Lombok:

import java.util.Objects;

public class Main {
    static final class Punto {
        private final int x;
        private final int y;

        Punto(int x, int y) {
            this.x = x;
            this.y = y;
        }

        int getX() { return x; }
        int getY() { return y; }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (!(o instanceof Punto)) return false;
            Punto p = (Punto) o;
            return x == p.x && y == p.y;
        }

        @Override
        public int hashCode() { return Objects.hash(x, y); }

        @Override
        public String toString() { return "Punto[x=" + x + ", y=" + y + "]"; }
    }

    public static void main(String[] args) {
        Punto a = new Punto(1, 2);
        System.out.println(a + " " + a.equals(new Punto(1, 2)) + " " + a.getX());
    }
}
java 8Punto[x=1, y=2] true 1
public class Main {
    record Punto(int x, int y) {
        Punto {
            if (x < 0 || y < 0) throw new IllegalArgumentException("Coordenadas negativas");
        }
    }

    public static void main(String[] args) {
        var a = new Punto(1, 2);
        System.out.println(a + " " + a.equals(new Punto(1, 2)) + " " + a.x());
        new Punto(-1, 0);
    }
}
java 21Punto[x=1, y=2] true 1
Exception in thread "main" java.lang.IllegalArgumentException: Coordenadas negativas
	at Main$Punto.<init>(Main.java:4)
	at Main.main(Main.java:11)
código de salida: 1

Un record (Java 16) declara los campos una vez y el compilador escribe el constructor, los métodos de acceso (x(), no getX()), equals, hashCode y toString. Es inmutable, y el constructor compacto permite validar los datos, como aquí, donde la segunda línea lanza la excepción. Es el sustituto natural de buena parte de los usos de Lombok.

Decidir según el tipo: patrones y clases selladas

Este es el cambio más profundo del lenguaje. En Java 8, actuar según el tipo de un objeto es una cadena de instanceof con conversiones:

public class Main {
    interface Forma {}
    static final class Circulo implements Forma { final double r; Circulo(double r) { this.r = r; } }
    static final class Rectangulo implements Forma { final double an, al; Rectangulo(double an, double al) { this.an = an; this.al = al; } }

    static double area(Forma f) {
        if (f instanceof Circulo) {
            Circulo c = (Circulo) f;
            return Math.PI * c.r * c.r;
        } else if (f instanceof Rectangulo) {
            Rectangulo r = (Rectangulo) f;
            return r.an * r.al;
        }
        throw new IllegalArgumentException("Forma desconocida");
    }

    public static void main(String[] args) {
        System.out.printf("%.2f %.2f%n", area(new Circulo(1)), area(new Rectangulo(2, 3)));
    }
}
java 83.14 6.00
public class Main {
    sealed interface Forma permits Circulo, Rectangulo, Triangulo {}
    record Circulo(double r) implements Forma {}
    record Rectangulo(double an, double al) implements Forma {}
    record Triangulo(double base, double alto) implements Forma {}

    static double area(Forma f) {
        return switch (f) {
            case Circulo(double r) -> Math.PI * r * r;
            case Rectangulo(double an, double al) when an == al -> an * an;
            case Rectangulo(double an, double al) -> an * al;
            case Triangulo(double b, double h) -> b * h / 2;
        };
    }

    public static void main(String[] args) {
        System.out.printf("%.2f %.2f %.2f%n", area(new Circulo(1)), area(new Rectangulo(2, 3)), area(new Triangulo(4, 5)));
        Object o = "Java 21";
        if (o instanceof String s && s.length() > 4) {
            System.out.println(s.toUpperCase());
        }
    }
}
java 213.14 6.00 10.00
JAVA 21

Cuatro novedades trabajan juntas:

  • switch como expresión (Java 14): devuelve un valor, usa flechas y no se cae al caso siguiente, así que se acabaron los break olvidados.
  • Patrones en el switch y patrones de records (Java 21): case Circulo(double r) comprueba el tipo y saca sus componentes a la vez, y when añade una condición.
  • Clases selladas (Java 17): sealed ... permits fija la lista cerrada de implementaciones. Gracias a eso el compilador sabe que el switch cubre todos los casos y no hace falta un default.
  • instanceof con patrón (Java 16): o instanceof String s comprueba y declara la variable de una vez.

El efecto más útil es el que no se ve: si mañana alguien añade una forma nueva a la interfaz sellada, el switch de area deja de compilar hasta que se trate. Lo comprobamos quitando la línea del triángulo:

public class Main {
    sealed interface Forma permits Circulo, Rectangulo, Triangulo {}
    record Circulo(double r) implements Forma {}
    record Rectangulo(double an, double al) implements Forma {}
    record Triangulo(double base, double alto) implements Forma {}

    static double area(Forma f) {
        return switch (f) {
            case Circulo(double r) -> Math.PI * r * r;
            case Rectangulo(double an, double al) -> an * al;
        };
    }

    public static void main(String[] args) {
        System.out.println(area(new Circulo(1)));
    }
}
javac 21Main.java:8: error: the switch expression does not cover all possible input values
        return switch (f) {
               ^
1 error
código de salida: 1

Errores que se entienden

El mismo programa, con un null escondido, ejecutado en los dos JDK:

public class Main {
    static class Direccion { String ciudad; }
    static class Cliente { Direccion direccion; }

    public static void main(String[] args) {
        Cliente c = new Cliente();
        c.direccion = new Direccion();
        c.direccion.ciudad = "Lima";
        Cliente[] clientes = { c, new Cliente() };
        for (Cliente cl : clientes) {
            System.out.println(cl.direccion.ciudad.length());
        }
    }
}
java 84
Exception in thread "main" java.lang.NullPointerException
	at Main.main(Main.java:11)
código de salida: 1
java 214
Exception in thread "main" java.lang.NullPointerException: Cannot read field "ciudad" because "<local6>.direccion" is null
	at Main.main(Main.java:11)
código de salida: 1

El primer cliente funciona; el segundo no tiene dirección. En Java 8, el error dice la línea, pero no qué era null: ¿cl, direccion o ciudad? Desde Java 14, las NullPointerException detalladas lo dicen. El <local6> es la variable cl: su nombre solo aparece si se compila con javac -g, que es lo que hacen Maven y Gradle por omisión. Pequeño cambio, horas de depuración ahorradas.

Hilos virtuales: miles de tareas sin miles de hilos

La novedad más importante de Java 21 para los servidores. Un hilo normal de Java es un hilo del sistema operativo, caro de crear y limitado a unos pocos miles. Por eso, en Java 8, las tareas que pasan casi todo el tiempo esperando (a una base de datos, a una API) se reparten en un grupo fijo de hilos, y las que no caben esperan su turno:

import java.util.concurrent.*;

public class Main {
    public static void main(String[] args) throws Exception {
        long inicio = System.nanoTime();
        ExecutorService ejecutor = Executors.newFixedThreadPool(100);
        for (int i = 0; i < 2_000; i++) {
            ejecutor.submit(() -> { Thread.sleep(100); return null; });
        }
        ejecutor.shutdown();
        ejecutor.awaitTermination(1, TimeUnit.MINUTES);
        long ms = (System.nanoTime() - inicio) / 1_000_000;
        System.out.println("2000 esperas de 100 ms con 100 hilos: unos " + Math.round(ms / 100.0) / 10.0 + " s");
    }
}
java 82000 esperas de 100 ms con 100 hilos: unos 2.0 s
import java.util.concurrent.*;

public class Main {
    public static void main(String[] args) throws Exception {
        long inicio = System.nanoTime();
        try (var ejecutor = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < 2_000; i++) {
                ejecutor.submit(() -> { Thread.sleep(100); return null; });
            }
        }
        long ms = (System.nanoTime() - inicio) / 1_000_000;
        System.out.println("2000 esperas de 100 ms con hilos virtuales: unos " + Math.round(ms / 100.0) / 10.0 + " s");
    }
}
java 212000 esperas de 100 ms con hilos virtuales: unos 0.1 s

Con 100 hilos, 2,000 esperas de 100 ms tardan unos dos segundos: van de cien en cien. Con hilos virtuales (Java 21), cada tarea tiene su propio hilo, que la JVM gestiona y aparca mientras espera, sin ocupar un hilo del sistema: las 2,000 esperan a la vez. El código apenas cambia, y sigue siendo el estilo sencillo de «una petición, un hilo», sin programación reactiva. Hay más en el ejemplo de tareas en paralelo con hilos virtuales. Los tiempos exactos varían en cada ejecución; la diferencia de orden de magnitud, no.

Lo que cambia sin tocar el código

UTF-8 por omisión

import java.nio.charset.Charset;

public class Main {
    public static void main(String[] args) {
        System.out.println(Charset.defaultCharset());
    }
}
LANG=C java Main   (Java 8)US-ASCII
LANG=C java Main   (Java 21)UTF-8

En Java 8, el juego de caracteres por omisión depende del sistema: en un Linux sin idioma configurado (LANG=C) es ASCII (US-ASCII), y en un Windows en español suele ser windows-1252. Desde Java 18 es UTF-8 en todas partes. Un programa que leía archivos sin indicar la codificación puede empezar a mostrar las tildes de otra manera al migrar; lo correcto, en las dos versiones, es indicarla siempre (Files.readString(ruta, StandardCharsets.UTF_8)).

El recolector de basura

java -XX:+PrintCommandLineFlags -version   (Java 8)-XX:InitialHeapSize=373306944 -XX:MaxHeapSize=5972911104 -XX:+PrintCommandLineFlags -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:+UseParallelGC 
java -XX:+PrintCommandLineFlags -version   (Java 21)-XX:ConcGCThreads=3 -XX:G1ConcRefinementThreads=13 -XX:GCDrainStackTargetSize=64 -XX:InitialHeapSize=373306944 -XX:MarkStackSize=4194304 -XX:MaxHeapSize=5972911104 -XX:MinHeapSize=6815736 -XX:+PrintCommandLineFlags -XX:ReservedCodeCacheSize=251658240 -XX:+SegmentedCodeCache -XX:-THPStackMitigation -XX:+UseCompressedOops -XX:+UseG1GC 

Java 8 usa por omisión el recolector Parallel, pensado para el rendimiento total con pausas largas; desde Java 9 es G1, que reparte el trabajo para que las pausas sean cortas. Java 21 añade además ZGC generacional para pausas de menos de un milisegundo con montones enormes. Si tu aplicación fija el recolector o sus opciones con parámetros de la JVM heredados de Java 8, revísalos: algunos ya no existen y la JVM se niega a arrancar.

Ejecutar un archivo suelto

java Hola.java   (Java 8)Error: Could not find or load main class Hola.java
código de salida: 1
java Hola.java   (Java 21)Hola desde un solo archivo
código de salida: 0

Desde Java 11, java Hola.java compila y ejecuta un archivo sin pasar por javac, útil para guiones y para aprender. Y desde Java 9 existe JShell, una consola para probar código línea a línea.

Lo que se rompe al migrar

Lo anterior son ventajas. Esto es lo que suele dar trabajo al pasar un proyecto de Java 8 a 21.

Java EE ya no viene con el JDK

import javax.xml.bind.DatatypeConverter;

public class Main {
    public static void main(String[] args) {
        System.out.println(DatatypeConverter.printBase64Binary("Hola".getBytes()));
    }
}
java 8SG9sYQ==
javac 21Main.java:1: error: package javax.xml.bind does not exist
import javax.xml.bind.DatatypeConverter;
                     ^
Main.java:5: error: cannot find symbol
        System.out.println(DatatypeConverter.printBase64Binary("Hola".getBytes()));
                           ^
  symbol:   variable DatatypeConverter
  location: class Main
2 errors
código de salida: 1

Java 11 retiró del JDK los módulos de Java EE y CORBA: JAXB (javax.xml.bind), JAX-WS, javax.activation y otros. El código que los usa deja de compilar. La solución es añadirlos como dependencias de Maven o Gradle (hoy, en su versión de Jakarta, con el paquete jakarta.xml.bind) o usar la alternativa estándar, como aquí java.util.Base64.

Las tripas del JDK están cerradas

import java.lang.reflect.Field;
import java.util.ArrayList;

public class Main {
    public static void main(String[] args) throws Exception {
        ArrayList<String> lista = new ArrayList<>();
        lista.add("a");
        Field interno = ArrayList.class.getDeclaredField("elementData");
        interno.setAccessible(true);
        System.out.println("Capacidad interna: " + ((Object[]) interno.get(lista)).length);
    }
}
java 8Capacidad interna: 10
java 21Exception in thread "main" java.lang.reflect.InaccessibleObjectException: Unable to make field transient java.lang.Object[] java.util.ArrayList.elementData accessible: module java.base does not "opens java.util" to unnamed module @2a139a55
	at java.base/java.lang.reflect.AccessibleObject.throwInaccessibleObjectException(AccessibleObject.java:391)
	at java.base/java.lang.reflect.AccessibleObject.checkCanSetAccessible(AccessibleObject.java:367)
	at java.base/java.lang.reflect.AccessibleObject.checkCanSetAccessible(AccessibleObject.java:315)
	at java.base/java.lang.reflect.Field.checkCanSetAccessible(Field.java:183)
	at java.base/java.lang.reflect.Field.setAccessible(Field.java:177)
	at Main.main(Main.java:9)
código de salida: 1

Con los módulos de Java 9, las clases internas del JDK pasaron a estar encapsuladas, y desde Java 17 ya no hay forma de abrirlas de manera global (la opción --illegal-access desapareció). Las bibliotecas antiguas que hurgaban con reflexión en clases del JDK fallan así; casi siempre basta con actualizarlas a una versión moderna. Como último recurso, --add-opens abre un paquete concreto.

Nashorn, sun.misc y compañía

import javax.script.*;

public class Main {
    public static void main(String[] args) throws Exception {
        ScriptEngine motor = new ScriptEngineManager().getEngineByName("nashorn");
        System.out.println(motor == null ? "No hay motor de JavaScript" : "1 + 2 = " + motor.eval("1 + 2"));
    }
}
java 81 + 2 = 3
java 21No hay motor de JavaScript

El motor de JavaScript Nashorn se retiró en Java 15 (existe como biblioteca aparte), y con él otras piezas: los applets, javafx (que se distribuye por separado desde Java 11), el SecurityManager (obsoleto desde Java 17) y APIs internas como sun.misc.BASE64Encoder. Para encontrarlas en un proyecto existe jdeps, que viene con el JDK:

jdeps --jdk-internals Viejo.class   (con el JDK 21, sobre una clase compilada con Java 8)Viejo.class -> JDK removed internal API
   Viejo                                              -> sun.misc.BASE64Encoder                             JDK removed internal API

Warning: JDK internal APIs are unsupported and private to JDK implementation that are
subject to be removed or changed incompatibly and could break your application.
Please modify your code to eliminate dependence on any JDK internal APIs.
For the most recent update on JDK internal API replacements, please check:
https://wiki.openjdk.org/display/JDK8/Java+Dependency+Analysis+Tool

JDK Internal API                         Suggested Replacement
----------------                         ---------------------
sun.misc.BASE64Encoder                   Use java.util.Base64 @since 1.8

jdeps lista cada uso de una API interna y propone su sustituta. Su compañero jdeprscan busca lo que está marcado como obsoleto.

Cómo migrar de Java 8 a 21

  1. Actualiza primero las herramientas: Maven, Gradle y sus plugins, y las bibliotecas (Spring, Hibernate, Lombok, Mockito…), a versiones que admitan Java 21. Es la parte que más problemas resuelve sola.
  2. Pasa jdeps --jdk-internals y jdeprscan sobre tus JAR y los de tus dependencias.
  3. Añade las dependencias de Java EE que antes venían con el JDK, si las usas.
  4. Compila y prueba con Java 21, pero manteniendo de momento el nivel de lenguaje antiguo si quieres ir por partes: --release 8. Después sube a --release 21 y empieza a usar lo nuevo.
  5. Revisa la configuración de la JVM: opciones de recolector, de memoria y de registro que ya no existen hacen que la JVM no arranque.
  6. Prueba en un entorno igual al de producción, con atención a la codificación de archivos, las fechas y la memoria.
¿Por qué no ir directamente a Java 25? Es la LTS más reciente y la migración desde 8 es prácticamente la misma. Si tus dependencias ya la admiten, es la mejor opción; Java 21 es el salto seguro si alguna todavía no. En el curso de Java usamos Java 25.

¿Y si me quedo en Java 8?

Java 8 sigue recibiendo parches de seguridad en las distribuciones gratuitas de OpenJDK, como Eclipse Temurin, que promete mantenerla al menos hasta diciembre de 2030, más que a Java 11 y 17. Pero recibir parches no es lo mismo que estar al día: las bibliotecas nuevas ya no la admiten (Spring Boot 3, por ejemplo, exige Java 17), los desarrolladores la conocen cada vez menos y se pierde todo lo anterior. Y ojo con la licencia: las actualizaciones del Oracle JDK 8 para uso comercial son de pago desde 2019; con una distribución de OpenJDK como Temurin o Amazon Corretto no hay ese problema.

Java 8Java 21
Saliómarzo de 2014septiembre de 2023
Soporte en Temurinal menos hasta dic. de 2030al menos hasta dic. de 2029
Lenguajelambdas y streams+ var, records, bloques de texto, switch con patrones, clases selladas
Concurrenciahilos del sistema+ hilos virtuales
Recolector por omisiónParallelG1
Codificación por omisiónla del sistemaUTF-8
Incluye Java EE y Nashornsíno
Bibliotecas modernascada vez menossí (Spring Boot 3 exige 17)

La conclusión es clara: Java 21 se escribe con menos código, avisa antes de los errores y aguanta mucha más carga con los hilos virtuales. La migración tiene un coste, sobre todo en dependencias antiguas, pero cada año que pasa en Java 8 ese coste crece. Si empiezas un proyecto hoy, la pregunta no es entre 8 y 21, sino entre 21 y 25.

Todo el código se compiló y ejecutó con Eclipse Temurin 1.8.0_504 y 21.0.12.1, en contenedores sin acceso a la red y sin idioma configurado; las salidas están copiadas de esa ejecución (los tiempos de los hilos varían ligeramente en cada una). Fechas de soporte: Eclipse Adoptium. Versión de cada novedad: sus propuestas de mejora (JEP) en openjdk.org.