Compilación cruzada en Java

¿Pueden usarse las últimas herramientas añadidas al lenguaje sin limitarse a una versión antigua?

Una situación que se da con frecuencia es tener que escribir código para un proyecto que está en una versión antigua de java.

En Damavis, siempre nos gusta hacer uso de las últimas herramientas añadidas al lenguaje, por lo que en estos casos puede ser frustrante tener que ceñirnos en una versión antigua, sobretodo cuando sabemos que con versiones más modernas, podríamos hacer lo mismo de forma más compacta, más eficiente e incluso en menos tiempo.

Es normal que en estos casos surja la duda “¿puedo escribir código en la versión más reciente del lenguaje y que al compilarlo el bytecode resultante sea el de una versión más antigua de la JVM?”

Cómo realizar la compilación cruzada

Supongamos por ejemplo que la aplicación que estamos manteniendo se escribió para java 8, y nosotros queremos usar, al menos, la LTS más reciente, java 11

De entrada, puede parecer que para conseguir esto es tan fácil como cambiar unos parámetros en el fichero pom.xml. En maven tenemos las propiedades maven.compiler.source y maven.compiler.target, y en la primera se indica la versión del código fuente, y en la segunda la versión del bytecode.

Por lo que debería ser tan simple como establecer la propiedad source a 11 y target a 8.

<properties>
   <maven.compiler.source>11</maven.compiler.source>
   <maven.compiler.target>8</maven.compiler.target>
</properties>

Pero si intentamos compilar así, maven nos va a reportar el siguiente error:

source release 11 requires target release 11

¿Qué está pasando?

Las propiedades source y target no son dos variables independientes. Hay relaciones entre ellas que se deben cumplir. Concretamente, la propiedad target no puede ser menor que source: si estamos escribiendo código en java 11, el bytecode generado debe ser (al menos) el de java 11.

Hay varias razones para esto:

La primera, que puede no haber forma de traducir el código escrito a una versión anterior de la JVM. Ampliaciones y modificaciones en la semántica del lenguaje generalmente requieren de un conocimiento explícito por parte del intérprete. Por tanto, una versión anterior del mismo no será capaz de reconocer el bytecode generado por un compilador más reciente. 

Pero java es reconocido por tener muy buena compatibilidad hacia atrás. ¿No rompe esto ese principio? No, porque son conceptos diferentes:

Compatibilidad hacia atrás es cuando la JVM es capaz de ejecutar código compilado en una versión anterior del lenguaje. Esta es una funcionalidad de la JVM, y java es reconocido por ser un lenguaje que en toda su historia apenas ha introducido cambios que impidan esto. Pero lo que estamos intentando aquí es que la JVM ejecute código de una versión más reciente, y esta no es una funcionalidad soportada.

La otra razón que impide llevar a cabo esta “compilación hacia atrás” son las librerías. Por ejemplo, es muy probable que código actual haga un uso extensivo de la función List.of(). Pero esta función no se añadió al lenguaje hasta java 9. Por tanto, la JVM de java 8 no va a ser capaz de ejecutar este código.  

Por consiguiente, cuando se definen los atributos de maven source y target, obligatoriamente target debe ser mayor o igual que source.

Si, es posible que target sea mayor que source. Es decir, podemos escribir código usando sintaxis estrictamente de java 8, y (usando un compilador java 11 o posterior) compilarlo al bytecode de java 11. Pero en la práctica esto no tiene mucha utilidad.

Compilación cruzada con maven

Volviendo a la pregunta inicial, tenemos nuestro proyecto en el viejo y buen java 8. Y no, no podemos usar nuevas capacidades del lenguaje y que el proyecto siga funcionando bajo el mismo entorno.

Para ello hay que hacer una migración. Esto no significa que tengamos que usar forzosamente el compilador de java 8. Podemos hacer “compilación cruzada”: compilar un proyecto para java 8 con nuestro compilador de java 11. Pero para hacer esto no hay que usar los atributos source y target. En su lugar, hay que usar la propiedad release. Por tanto, en maven, en lugar de poner esto:

<properties>
   <maven.compiler.source>8</maven.compiler.source>
   <maven.compiler.target>8</maven.compiler.target>
</properties>

Pondremos esto:

   <properties>
       <maven.compiler.release>8</maven.compiler.release>
   </properties>

¿Cuál es la diferencia?

La propiedad release es equivalente a definir el mismo valor para source y para target, pero añade algo más: indica qué versión de las librerías se debe usar. 

Durante una build, las librerías que se usan son las que corresponden al JDK que estamos usando para compilar. Supongamos el siguiente mini-proyecto de ejemplo:

Ejemplo

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
   <modelVersion>4.0.0</modelVersion>

   <groupId>com.damavis</groupId>
   <artifactId>release-usage</artifactId>
   <version>1.0-SNAPSHOT</version>

   <properties>
       <maven.compiler.source>8</maven.compiler.source>
       <maven.compiler.target>8</maven.compiler.target>
   </properties>

</project>

App.java:

package com;

import java.util.List;

public class App {

   public static void main(String[] args)  {
       List<String> myList = List.of("some", "new", "java", "code");
       /* ... */	 
   }
}

Compilamos esto con el JDK 11, y no da ningún error. Pero al intentar ejecutar el jar generado con el intérprete de java 8, salta la siguiente excepción:

Exception in thread "main" java.lang.NoSuchMethodError: java.util.List.of(Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)Ljava/util/List;

Esto es debido a que al compilar con java 11, éste por defecto utiliza las librerías de dicha versión. El atributo release indica al compilador que debe usar las librerías de la versión que se haya especificado.

Probamos a sustituir el pom anterior por el siguiente (es importante tener en cuenta que la propiedad release solo es reconocida a partir de la versión 3.7.0 del maven-compiler-plugin)

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
   <modelVersion>4.0.0</modelVersion>

   <groupId>com.damavis</groupId>
   <artifactId>release-usage</artifactId>
   <version>1.0-SNAPSHOT</version>

   <properties>
       <maven.compiler.release>8</maven.compiler.release>
   </properties>

   <build>
       <plugins>
           <plugin>
               <groupId>org.apache.maven.plugins</groupId>
               <artifactId>maven-compiler-plugin</artifactId>
               <version>3.7.0</version>
           </plugin>
       </plugins>
   </build>

</project>

Ahora el proyecto ya no compila. Y eso es correcto: no hemos escrito código java 8 válido, por tanto no tiene que compilar. 

Conclusiones

  • No se puede “compilar hacia atrás”. No es posible compilar código escrito en una versión reciente del lenguaje para que funciones en una JVM pasada.
  • Si se puede usar el JDK que tenemos instalado para compilar proyectos hechos con un JDK anterior. Para eso, hay que usar la propiedad release en lugar de source y target.
Si te ha parecido útil este post, compártelo con tus contactos para que ellos también puedan leerlo y opinar. ¡Nos vemos en redes!

Imagen por defecto
Pedro Riera
Artículos: 2