Saltar al contenido
NextPatch Blog

JDK 28 abarata las arenas confinadas de la FFM API sin tocar tu código

A partir de JDK 28, Arena.ofConfined() sirve las reservas pequeñas desde pools de memoria nativa reutilizables. No hay que cambiar nada y, en los microbenchmarks del equipo de Java, las reservas de pocos bytes van entre 6,8 y 18,9 veces más rápido.

Java 5 min de lectura

Si usas la Foreign Function & Memory API para llamar a código nativo, lo más probable es que envuelvas cada llamada en un try (Arena arena = Arena.ofConfined()). En JDK 28 ese patrón sale más barato: las reservas pequeñas se sirven desde pools de memoria nativa que se reutilizan, y no tienes que cambiar ni una línea.

Lo explica Per-Ake Minborg en Inside Java, y el porqué se entiende rápido. Hasta ahora, reservar cuatro bytes para un int de salida pasaba por el asignador nativo normal y además registraba una acción de limpieza. En las llamadas nativas pequeñas, ese papeleo podía costar más que usar la propia memoria.

Y las llamadas pequeñas son lo habitual. Minborg instrumentó una ejecución de un conjunto de tests: algunas arenas confinadas no reservaban nada, y de las que sí lo hacían, más del 99,99 % reservaba menos de 64 bytes. Él mismo avisa de que un conjunto de tests no representa a todas las aplicaciones, pero la tendencia es difícil de ignorar.

Qué cambia por dentro

  • Cada hilo de plataforma mantiene, de forma perezosa, una caché de pools de memoria nativa. Por defecto puede guardar cuatro pools de 64 bytes.
  • Una arena solo coge un pool con su primera reserva que pueda aprovecharlo. Si nunca reserva nada, no hay pool.
  • El pool pertenece a una única arena y las reservas se van cortando de él en orden. Las arenas confinadas anidadas siguen aisladas entre sí.
  • Lo que no cabe, o lo que pide un alineamiento que el pool no puede garantizar, va por el camino de siempre.
  • Al cerrar la arena, la parte usada se pone a cero y el pool vuelve a la caché, o se libera si la caché ya está llena.

Este es el tipo de código que sale ganando: una arena por llamada y un string corto que se pasa a C.

java
import java.lang.foreign.*;
import java.lang.invoke.MethodHandle;

public class Strlen {
    private static final Linker LINKER = Linker.nativeLinker();
    private static final MethodHandle STRLEN = LINKER.downcallHandle(
            LINKER.defaultLookup().find("strlen").orElseThrow(),
            FunctionDescriptor.of(ValueLayout.JAVA_LONG, ValueLayout.ADDRESS));

    static long strlen(String s) throws Throwable {
        // Una arena confinada por llamada: el patrón que JDK 28 abarata
        try (Arena arena = Arena.ofConfined()) {
            MemorySegment cString = arena.allocateFrom(s);
            return (long) STRLEN.invokeExact(cString);
        }
    }

    public static void main(String[] args) throws Throwable {
        System.out.println(strlen("NextPatch")); // 9
    }
}

Como cadena C, "NextPatch" ocupa 10 bytes con el terminador, así que en JDK 28 sale del pool. Si lo ejecutas, añade --enable-native-access=ALL-UNNAMED: sin esa opción, el JDK avisa de que has llamado a un método restringido.

Los números, con su letra pequeña

En un Apple M4 con hilos de plataforma, reservar 5 bytes baja de 15,862 ns/op sin pool a 1,052 ns/op con pool, y 20 bytes, de 18,139 a 1,138. A partir de 100 bytes los resultados quedan prácticamente empatados: esas reservas superan los 64 bytes del pool y van por el camino normal.

Por plataformas, la mejora nominal va de 6,8x (Linux x64, 5 bytes) a 18,9x (Windows x64, 20 bytes). Son microbenchmarks de desarrollo y, como recuerda el propio artículo, eso no se traduce tal cual en una aplicación completa más rápida.

¿Y con hilos virtuales?

Un hilo virtual no tiene caché propia: toma prestado un pool de su hilo portador y solo queda anclado a él (el famoso pinning) durante el traspaso. A partir de ahí el pool es de la arena, así que el hilo virtual puede cambiar de portador mientras la arena sigue abierta. Al cerrarla, el pool vuelve a la caché del portador en el que esté montado en ese momento.

La ganancia es algo menor por esa gestión extra: en el M4, 5 bytes cuestan 3,222 ns/op con pool frente a 15,672 sin él.

Lo que sí puedes notar

Las reglas de vida de los segmentos no cambian: al cerrar la arena, su scope deja de estar vivo y los segmentos ya no se pueden usar. La memoria se pone a cero antes de reutilizarse, así que una arena nunca ve datos de otra, y las acciones de limpieza se ejecutan antes de vaciar el pool.

Lo que cambia es que la reserva nativa que hay debajo ya no vive lo mismo que el segmento. Si tienes herramientas que cuentan malloc y free y esperan una correspondencia uno a uno con las reservas de la arena, esa suposición deja de valer.

A quién le toca

Solo afecta a Arena.ofConfined(); el resto de arenas sigue igual. Lo notarán sobre todo los bindings que crean una arena confinada por cada llamada nativa, los wrappers generados al estilo de jextract y el código sensible a la latencia que hace muchas llamadas nativas baratas. Si es tu caso, apúntalo para cuando pruebes JDK 28: el cambio se sigue en JDK-8385697.

El artículo de Inside Java enlaza el PR #31365 de OpenJDK, con la configuración de los benchmarks y los resultados en bruto.

Pasa de leerlo a practicarlo.

Un resumen de 45 segundos y un reto de dos minutos al día. La cuenta es gratis y guarda tu XP, tu racha y tus insignias.

Crear cuenta gratis