JEP 401 en Java: clases de valor, aplanamiento de objetos y cómo verificarlo con JFR

Cada objeto Java tiene un precio fijo: entre 12 y 16 bytes de cabecera de objeto, más una referencia de 4 u 8 bytes cada vez que lo almacenas en otro objeto o en una colección. Para una clase como Point con dos int (8 bytes de datos útiles), ese overhead puede ser mayor que el propio dato. Si tienes un array de un millón de puntos, el coste de memoria y la presión sobre el recolector de basura son considerables. El JEP 401 de Project Valhalla ataca exactamente ese problema.

El problema de identidad en los objetos Java

En Java, cada objeto tiene una identidad: una dirección de memoria que lo hace único. Esa identidad permite cosas como la sincronización con synchronized, la comparación con == o el uso como clave en un IdentityHashMap. Pero muchas clases de datos no necesitan identidad. Un punto (3, 7) no necesita ser el mismo objeto que otro punto (3, 7); lo que importa es el valor, no quién es.

El problema es que la JVM actual no puede saberlo. Trata todos los objetos como si tuvieran identidad, y por tanto los almacena siempre con cabecera y referencia, en el heap. Esto hace que las operaciones sobre colecciones de objetos pequeños sean mucho más lentas de lo que podrían ser si la JVM pudiera guardar los datos directamente, sin indirección.

Project Valhalla y el JEP 401

Project Valhalla lleva años explorando cómo añadir tipos de valor a Java sin romper la compatibilidad. El JEP 401 es el resultado más concreto: introduce las value classes, clases que declaran explícitamente que sus instancias no tienen identidad. A cambio, la JVM puede aplanarlas.

El aplanamiento (flattening) significa que la JVM puede almacenar el contenido de la clase directamente en el array o en el objeto que la contiene, sin puntero intermedio. Un Point[] de un millón de elementos puede ser un bloque contiguo de 8 MB (2 ints por punto, 4 bytes cada uno) en lugar de un millón de referencias apuntando a un millón de objetos dispersos por el heap.

Sintaxis de value class

La declaración es sencilla: añades la palabra clave value antes de class.

public value class Point {
    private final int x;
    private final int y;

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

    public int x() { return x; }
    public int y() { return y; }
}

Las restricciones son directas:

  • Todos los campos deben ser final. Las value classes son inmutables.
  • No pueden sincronizarse sobre sí mismas (synchronized(punto) lanzará una excepción).
  • No tienen identidad de objeto: punto1 == punto2 compara por valor, no por referencia.
  • No pueden ser null como tipo primitivo plano (aunque sí como referencia Point? con la sintaxis nullable que Valhalla añade).

Value class vs record

A primera vista parece que los record de Java 16 ya hacen algo similar. La diferencia es fundamental. Un record tiene identidad de objeto; es una clase normal con un constructor canónico y métodos generados automáticamente, pero la JVM lo trata como cualquier otro objeto en el heap. Dos records con los mismos valores son equals() pero no son ==.

Una value class no tiene identidad. La JVM puede decidir copiarla, aplanarla o representarla como primitivo según le convenga. El programador ve la misma API, pero el runtime puede hacer optimizaciones imposibles con objetos normales.

// Record: identidad de objeto, en el heap
public record PointRecord(int x, int y) {}

// Value class: sin identidad, aplanable
public value class PointValue {
    final int x;
    final int y;
    // ...
}

PointRecord r1 = new PointRecord(3, 7);
PointRecord r2 = new PointRecord(3, 7);
System.out.println(r1 == r2);    // false (distintos objetos)

PointValue v1 = new PointValue(3, 7);
PointValue v2 = new PointValue(3, 7);
System.out.println(v1 == v2);    // true (mismo valor)

Verificar el aplanamiento con Java Flight Recorder

Java Flight Recorder (JFR) permite observar el comportamiento de la JVM en tiempo real o volcar eventos para análisis posterior. Para verificar que el aplanamiento funciona, hay dos enfoques: medir la presión de GC y analizar el layout de memoria.

Arrancar la aplicación con JFR activado:

java --enable-preview 
     -XX:StartFlightRecording=duration=60s,filename=miapp.jfr 
     -jar miapp.jar

Con JDK Mission Control (JMC), abre el archivo .jfr y mira:

  • GC activity: con value classes en arrays, el GC tiene menos trabajo porque no hay millones de referencias que rastrear. Si la actividad de GC baja con respecto a la versión sin value classes para el mismo conjunto de datos, el aplanamiento está funcionando.
  • Heap allocation rate: menos objetos en el heap significa menos allocaciones. El evento jdk.ObjectAllocationInNewTLAB te muestra qué tipos se están allocando más.
  • Memory footprint: el evento jdk.GCHeapSummary muestra el tamaño del heap antes y después de cada GC. Con datos aplanados, verás un heap más pequeño para la misma cantidad de datos.

También puedes usar -Xlog:gc* para ver en consola los tiempos de pausa de GC y compararlos antes y después de convertir una clase a value class.

Impacto en rendimiento

Las mejoras vienen por tres vías. Primero, menos GC pressure: si un array de un millón de puntos ocupa 8 MB contiguos en lugar de un millón de objetos dispersos, el GC tiene menos trabajo y las pausas son más cortas. Segundo, mejor cache locality: los procesadores modernos trabajan con cachés de nivel L1, L2 y L3. Datos contiguos en memoria se cargan en caché de forma eficiente; datos dispersos provocan cache misses continuos. Tercero, menos allocaciones: si la JVM puede representar una value class como primitivo en el stack, ni siquiera hay allocación en el heap.

Casos de uso reales

Las value classes son especialmente útiles para tipos de datos pequeños e inmutables que aparecen en grandes cantidades:

  • Coordenadas geográficas o geométricas: Point, Vector3D, LatLon.
  • Dinero y moneda: Money(BigDecimal amount, Currency currency).
  • Rangos de fechas: DateRange(LocalDate start, LocalDate end).
  • Colores RGBA, píxeles en procesamiento de imagen.
  • Vectores numéricos en aplicaciones de machine learning o simulación.

En todos estos casos, la aplicación típicamente crea millones de instancias durante su vida útil. Con value classes, una parte significativa de ese trabajo desaparece del heap.

Estado actual: preview en Java 25

El JEP 401 está disponible como preview feature en Java 25 LTS, donde el JEP 401 está disponible como preview. Para probarlo, necesitas activar el flag de preview en compilación y ejecución:

# Compilar con preview habilitado
javac --enable-preview --release 25 MiClase.java

# Ejecutar con preview habilitado
java --enable-preview MiClase

# Con Maven
<compilerArgs>
    <arg>--enable-preview</arg>
</compilerArgs>
<release>25</release>

Las features en preview pueden cambiar entre versiones de Java. El objetivo es que el JEP 401 pase a feature definitiva en una versión posterior, pero la sintaxis y semántica pueden variar mientras está en preview. Dicho esto, Project Valhalla lleva en desarrollo desde 2014 y el JEP 401 representa la iteración más madura hasta la fecha.

Para proyectos en producción, la recomendación es explorar y medir ahora, pero esperar a que la feature salga de preview antes de adoptarla en código crítico. Los benchmarks con JFR son la forma más fiable de cuantificar si el aplanamiento justifica el cambio en tu caso concreto.

Este tipo de optimizaciones a nivel de runtime complementa bien el trabajo de diseño a nivel de código. Los patrones de diseño en Java como Value Object o Flyweight buscan exactamente este tipo de eficiencia desde la perspectiva del diseño; el JEP 401 lo lleva al nivel de la JVM.

Referencias: JEP 401, Project Valhalla.

Imagen: Pexels / Daniil Komov

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP