Quando Java deve parlare con una libreria C
Un'applicazione Java può aver bisogno di una libreria di calcolo già scritta in C, oppure di dati mantenuti fuori dall'heap della JVM. Nei capitoli precedenti gli oggetti sono stati creati, letti e raccolti secondo le regole della piattaforma Java. Qui il confine cambia: una funzione nativa segue convenzioni di chiamata della macchina, e un blocco di memoria esterna ha una dimensione e una durata che dobbiamo rispettare esplicitamente. Il problema da risolvere è usare quel confine senza fingere che i controlli ordinari sugli oggetti rendano automaticamente sicuro il codice nativo.
La Foreign Function and Memory API, o FFM, vive nel package stabile java.lang.foreign dalla versione 22 ed è disponibile in Java 25. Ha due famiglie collegate: la gestione di segmenti di memoria e il collegamento a funzioni esterne. Storicamente Java ha usato anche JNI, la Java Native Interface. JNI rimane una tecnologia valida per integrazioni esistenti e per casi particolari; FFM offre un modello diverso, con API Java per descrivere memoria, firme e durata. Non basta sostituire una chiamata JNI con una chiamata FFM per rendere portabile una libreria nativa.
Concetto chiave – Tre livelli da non confondere. Un programma Java può essere portabile a livello di bytecode. La FFM API è parte della piattaforma. La funzione C chiamata resta invece legata a una libreria, a un sistema operativo, a un'architettura e a un'ABI, cioè l'insieme delle convenzioni con cui i valori passano fra funzioni sulla macchina. Il contratto C deve essere noto e verificato; il linker non può indovinarlo dal nome della funzione.
La figura 28.1 mostra tre oggetti distinti: l'arena governa la durata, il segmento delimita una regione e il layout descrive come interpretare i byte. Se il segmento rappresenta tre interi, la sua dimensione in byte dipende dal layout scelto. Un MemorySegment può anche riferirsi a memoria dell'heap; «segmento» non significa sempre «fuori heap». Nell'esempio usiamo una regione nativa per rendere osservabile il ciclo di vita.
Allocare e leggere con un limite esplicito
Arena.ofConfined() crea un'arena accessibile dal thread proprietario e chiudibile in modo deterministico. Il try-with-resources chiude l'arena anche se un'operazione interna lancia un'eccezione. I segmenti allocati da essa condividono la durata dell'arena: dopo la chiusura non si possono più usare. Arena.ofShared() consente accesso da più thread e richiede più disciplina sul coordinamento; Arena.ofAuto() delega il recupero alla gestione automatica; Arena.global() dura per tutta la vita del processo. Scegliamo la forma meno estesa che copre il bisogno reale. Per un buffer usato da un solo metodo, l'arena confinata rende leggibile il confine temporale.
«Condivisa» descrive chi può accedere all'arena, non una garanzia che più modifiche allo stesso segmento formino una decisione atomica. Nel capitolo 20A abbiamo separato accessibilità dello stato e protocollo di aggiornamento: la stessa domanda torna qui, con in più durata del segmento, layout e possibili chiamate native. Se due thread o la libreria C scrivono nello stesso spazio, bisogna definire un coordinamento compatibile con entrambi i lati del confine; un AtomicInteger che vive altrove nella JVM non protegge quei byte per il solo fatto di esistere.
ValueLayout.JAVA_INT descrive il layout di un int Java per l'accesso al segmento. arena.allocate(ValueLayout.JAVA_INT, 3) alloca lo spazio per tre elementi con quel layout. setAtIndex e getAtIndex controllano l'indice rispetto al segmento; un accesso oltre i limiti fallisce invece di leggere memoria adiacente per caso. Il ciclo dell'esempio scrive 1, 2, 3 e poi somma i valori ottenendo 6. Si potrebbe usare un normale int[] per questa somma: l'esempio usa memoria nativa soltanto per mostrare i confini dell'API. Quando un programma non deve condividere dati con codice nativo né controllare un layout esterno, un array Java resta una scelta più semplice.
Un segmento può essere tagliato con asSlice per lavorare su una parte. Il taglio non rende indipendente la durata della memoria sottostante: se l'arena che la governa viene chiusa, anche la vista è invalida. Un layout di struttura può comporre valori, sequenze e padding; il padding è lo spazio aggiunto per rispettare vincoli di allineamento. Un offset è la distanza in byte dall'inizio della regione. Un errore di offset o di layout può far leggere un campo come se fosse un altro, anche quando l'accesso resta entro i limiti spaziali. La figura 28.2 mostra un layout didattico con byte di riempimento. Prima di usarlo per una struttura C reale occorre verificarne ABI, dimensioni e allineamento sul compilatore e sulla piattaforma del programma.
La differenza fra dimensione logica e disposizione fisica è decisiva. Una struttura con un campo a quattro byte seguito da uno a otto può avere spazio di riempimento fra i campi per allineare il secondo. La figura usa quattro byte di padding come ipotesi didattica, non come legge universale per qualunque compilatore. MemoryLayout.structLayout permette di descrivere una sequenza di membri; paddingLayout rende esplicito lo spazio richiesto dal layout scelto. Per un array di strutture occorre conoscere anche la dimensione complessiva di ciascun elemento, non soltanto gli offset interni. Una descrizione che produce i valori giusti su un singolo computer può essere falsa su un altro ABI.
Un VarHandle ottenuto da un percorso nel layout può rendere riusabile l'accesso a un campo annidato. Il nome somiglia ai metodi handle del capitolo 24, ma lo scopo qui è leggere o scrivere una posizione descritta da un layout di memoria. Reflection osserva metadati di tipi e membri Java; FFM descrive dati e chiamate oltre il confine della JVM. La disponibilità di un handle non sostituisce la scelta corretta di durata, allineamento e tipo.
Dalla firma C alla chiamata
Per chiamare una funzione nativa dobbiamo sapere in quale libreria si trova, quale simbolo la identifica e quale firma binaria usa. SymbolLookup cerca il simbolo e produce un indirizzo rappresentato come MemorySegment. Linker.nativeLinker() conosce l'ABI della piattaforma corrente. FunctionDescriptor descrive tipi di ritorno e parametri al livello nativo. Infine downcallHandle produce un MethodHandle che il codice Java può invocare. La figura 28.3 collega questi passaggi; saltarne uno significa affidarsi a una supposizione non verificata.
Nel programma completo usiamo strlen, una funzione della libreria C che conta i byte prima del terminatore zero di una stringa C. arena.allocateFrom("Java") crea una stringa codificata e terminata secondo il contratto dell'API; poiché le lettere ASCII dell'esempio occupano un byte ciascuna in UTF-8, il risultato è 4. Se scrivessimo caratteri accentati, strlen conterebbe byte, non caratteri Unicode né code point: è proprio il limite da capire. La funzione viene cercata nel lookup predefinito del linker nativo. Questo esempio è stato eseguito sul macOS e sul JDK 25 di lavoro; altre piattaforme possono esporre simboli e tipi C in modo diverso.
Per costruire un binding riproducibile non basta conoscere strlen a memoria. La firma C ufficiale restituisce size_t e riceve un puntatore a char terminato da zero. Il descrittore FFM traduce il puntatore in ValueLayout.ADDRESS, mentre la stringa allocata nell'arena fornisce memoria valida fino alla fine del try. Il risultato viene invocato con invokeExact, che richiede tipi Java esatti nella chiamata; il cast a long rende esplicita la firma del method handle costruito in questo ambiente. Se la funzione conservasse il puntatore dopo il ritorno, l'arena confinata chiusa a fine blocco sarebbe troppo breve: avremmo risolto la chiamata immediata ma non il contratto di durata.
import java.lang.foreign.Arena;
import java.lang.foreign.FunctionDescriptor;
import java.lang.foreign.Linker;
import java.lang.foreign.MemorySegment;
import java.lang.foreign.ValueLayout;
import java.lang.invoke.MethodHandle;
public class MemoriaNativa {
public static void main(String[] args) throws Throwable {
try (Arena arena = Arena.ofConfined()) {
MemorySegment numeri = arena.allocate(ValueLayout.JAVA_INT, 3);
for (int indice = 0; indice < 3; indice++) {
numeri.setAtIndex(ValueLayout.JAVA_INT, indice, indice + 1);
}
int somma = 0;
for (int indice = 0; indice < 3; indice++) {
somma += numeri.getAtIndex(ValueLayout.JAVA_INT, indice);
}
System.out.println("Somma nativa: " + somma);
Linker linker = Linker.nativeLinker();
MemorySegment simbolo = linker.defaultLookup().find("strlen")
.orElseThrow(() -> new IllegalStateException("strlen non disponibile"));
MethodHandle strlen = linker.downcallHandle(simbolo,
FunctionDescriptor.of(ValueLayout.JAVA_LONG, ValueLayout.ADDRESS));
MemorySegment testo = arena.allocateFrom("Java");
long lunghezza = (long) strlen.invokeExact(testo);
System.out.println("Byte prima dello zero: " + lunghezza);
}
}
}
Il comando di compilazione è javac --release 25 MemoriaNativa.java. Per eseguire una restricted method in un programma sul class path usiamo java --enable-native-access=ALL-UNNAMED MemoriaNativa. L'opzione abilita l'accesso nativo per tutti i moduli senza nome di quel processo; in un'applicazione modulare si dovrebbe indicare il modulo interessato. La compilazione con -Xlint:all segnala il metodo ristretto: l'avviso è informazione pertinente, non un difetto da nascondere. Abilitare l'accesso non verifica automaticamente che il descrittore di strlen sia corretto.
Attenzione – La firma dipende dall'ABI. In questa prova
size_t, il tipo C restituito dastrlen, viene descritto comeJAVA_LONG, coerentemente con l'ABI a 64 bit usata nell'ambiente di verifica. Non è una dichiarazione universale: su ABI a 32 bit o con rappresentazioni differenti il binding va adattato. Una firma sbagliata al confine nativo può corrompere memoria o terminare il processo. Il capitolo mostra il metodo di ragionamento e un caso controllato, non una libreria di binding pronta per ogni piattaforma.
Una upcall percorre la direzione opposta: una funzione nativa richiama codice Java attraverso uno stub creato dal linker. Lo stub deve restare valido finché la libreria nativa può usarlo; chiudere l'arena troppo presto rende il callback invalido. Anche l'eccezione che nasce nel codice Java richiamato deve essere gestita secondo il contratto del confine, non lasciata attraversare come se la funzione C conoscesse le eccezioni Java. Questa è una ragione per introdurre le upcall solo dopo aver compreso segmenti e downcall.
Immaginiamo una libreria che registra una funzione di notifica e la richiama dopo il ritorno della chiamata di registrazione. Se creiamo lo stub dentro un try (Arena arena = Arena.ofConfined()) che termina subito dopo la registrazione, consegniamo alla libreria un indirizzo destinato a diventare invalido prima del callback. La correzione non è semplicemente usare Arena.global(): bisogna stabilire chi annulla la registrazione, attendere che non ci siano callback in corso e poi chiudere l'arena appropriata. Se la notifica arriva da un thread nativo diverso, serve inoltre verificare i vincoli di accesso e di concorrenza. Il problema determina la durata, non il contrario.
Che cosa controlla l'API e che cosa resta nostro
L'API può controllare limiti del segmento, durata della sua arena e, per un'arena confinata, il thread autorizzato. Non può garantire che una libreria C rispetti la dimensione di un buffer ricevuto, conservi un puntatore soltanto per il tempo consentito o interpreti il layout che noi intendevamo. La sicurezza spaziale riguarda gli indirizzi e i limiti; quella temporale riguarda il momento in cui la memoria resta valida. Un controllo sul lato Java riduce errori importanti, ma il codice nativo può ancora violare il contratto.
Questo spiega anche perché la verifica non può limitarsi a un output positivo. Occorrono prove dei confini: segmenti troppo piccoli, arena chiusa, simbolo mancante, firma non disponibile sulla piattaforma e funzione nativa che restituisce un codice di errore. Le prime tre possono essere riprodotte in un test Java controllato; una firma ABI errata non va «provata per vedere se funziona» nel processo principale, perché il fallimento può essere un crash o una corruzione non immediatamente visibile. In quel caso si parte da header, documentazione della libreria e binding verificato, poi si usano prove isolate sulla piattaforma dichiarata.
Uno strumento come jextract può generare binding da header C, evitando molte trascrizioni manuali. È uno strumento del percorso di integrazione, non una promessa che ogni funzione diventi automaticamente portabile o sicura. Il build deve fissare versione dello strumento, header, compilatore, architettura e librerie usate. Se cambia l'ABI, i binding e le prove vanno ricontrollati. L'API Vector del capitolo 23 risolve un altro problema: elaborare dati con operazioni SIMD. Si possono usare vettori su dati che arrivano da segmenti, ma questo non fonde stabilità, rischi e finalità delle due API.
Verificare e trasferire
La prima verifica modifica il numero di elementi allocati da tre a due lasciando tre scritture: il programma deve fallire al controllo dei limiti, non stampare una somma inventata. La seconda conserva un segmento in una variabile, chiude l'arena e prova a leggerlo: deve fallire perché la durata è terminata. Non inseriamo la seconda prova nel flusso normale, dove interromperebbe il resto dell'esempio. Questi due esperimenti distinguono errore spaziale ed errore temporale.
Il trasferimento cambia la funzione C. Una libreria richiede un puntatore a una struttura con due interi, conserva il puntatore fino a un callback e poi restituisce un codice di stato. Prima di scrivere downcallHandle, il lettore deve rispondere: quale layout corrisponde alla struttura su questa ABI? Quale arena copre l'intera durata del puntatore? Quale thread può accedervi? Come si interpreta il codice di stato? Quale test controlla la chiusura? Se una risposta manca, un programma che «compila» non ha ancora risolto il problema.
Riferimenti essenziali. Il package
java.lang.foreigndi Java 25 descrive segmenti, layout e funzioni esterne; la specifica diArenadefinisce durata e accesso; la specifica diLinkerchiarisce il ruolo dell'ABI e dei metodi ristretti. Il casostrlenresta vincolato all'ambiente nativo dichiarato.